diff --git a/.gitignore b/.gitignore index c29774f..a26fecc 100644 --- a/.gitignore +++ b/.gitignore @@ -1,46 +1,49 @@ -# Dependencies -node_modules/ -.pnpm-store/ - -# Next.js -.next/ -out/ - -# Build -dist/ -build/ - -# Database -*.db -*.db-journal -prisma/generated/ - -# Environment -.env -.env.local -.env.test -.env.*.local -!.env.example - -# IDE -.vscode/ -.idea/ -*.swp -*.swo - -# OS -.DS_Store -Thumbs.db - -# Logs -*.log - -# Uploads (user-generated) -public/uploads/ - -# TypeScript -*.tsbuildinfo -next-env.d.ts - -# Claude -.claude/ +# Dependencies +node_modules/ +.pnpm-store/ + +# Next.js +.next/ +out/ + +# Build +dist/ +build/ + +# Database +*.db +*.db-journal +prisma/generated/ + +# Environment +.env +.env.local +.env.test +.env.*.local +!.env.example + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log + +# Uploads (user-generated) +public/uploads/ + +# TypeScript +*.tsbuildinfo +next-env.d.ts + +# Progress tracking (personal) +PROGRESS.md + +# Claude +.claude/ diff --git a/LICENSE b/LICENSE index 5c05860..f7ecffb 100644 --- a/LICENSE +++ b/LICENSE @@ -1,21 +1,21 @@ -MIT License - -Copyright (c) 2026 Steven Joel Ramos Salazar - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. +MIT License + +Copyright (c) 2026 Steven Joel Ramos Salazar + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/prisma/migrations/20260611062951_ctfrbt/migration.sql b/prisma/migrations/20260611062951_ctfrbt/migration.sql index fe399fb..35199fc 100644 --- a/prisma/migrations/20260611062951_ctfrbt/migration.sql +++ b/prisma/migrations/20260611062951_ctfrbt/migration.sql @@ -1,320 +1,320 @@ --- CreateEnum -CREATE TYPE "UnlockConditionType" AS ENUM ('CHALLENGE_SOLVED', 'TIME_REMAINDER'); - --- CreateTable -CREATE TABLE "User" ( - "id" TEXT NOT NULL, - "alias" TEXT NOT NULL, - "password" TEXT NOT NULL, - "name" TEXT NOT NULL, - "isAdmin" BOOLEAN NOT NULL DEFAULT false, - "isTeamLeader" BOOLEAN NOT NULL DEFAULT false, - "teamId" TEXT, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" TIMESTAMP(3) NOT NULL, - - CONSTRAINT "User_pkey" PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "Team" ( - "id" TEXT NOT NULL, - "name" TEXT NOT NULL, - "code" TEXT NOT NULL, - "icon" TEXT NOT NULL DEFAULT 'GiSpaceship', - "color" TEXT NOT NULL DEFAULT '#ffffff', - "score" INTEGER NOT NULL DEFAULT 0, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" TIMESTAMP(3) NOT NULL, - - CONSTRAINT "Team_pkey" PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "Challenge" ( - "id" TEXT NOT NULL, - "title" TEXT NOT NULL, - "description" TEXT NOT NULL, - "points" INTEGER NOT NULL, - "flag" TEXT, - "multipleFlags" BOOLEAN NOT NULL DEFAULT false, - "category" TEXT NOT NULL, - "difficulty" TEXT NOT NULL, - "isActive" BOOLEAN NOT NULL DEFAULT true, - "isLocked" BOOLEAN NOT NULL DEFAULT false, - "link" TEXT, - "solveExplanation" TEXT, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" TIMESTAMP(3) NOT NULL, - - CONSTRAINT "Challenge_pkey" PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "ChallengeFlag" ( - "id" TEXT NOT NULL, - "flag" TEXT NOT NULL, - "points" INTEGER NOT NULL, - "challengeId" TEXT NOT NULL, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" TIMESTAMP(3) NOT NULL, - - CONSTRAINT "ChallengeFlag_pkey" PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "ChallengeFile" ( - "id" TEXT NOT NULL, - "name" TEXT NOT NULL, - "path" TEXT NOT NULL, - "size" INTEGER NOT NULL, - "challengeId" TEXT NOT NULL, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" TIMESTAMP(3) NOT NULL, - - CONSTRAINT "ChallengeFile_pkey" PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "Submission" ( - "id" TEXT NOT NULL, - "flag" TEXT NOT NULL, - "isCorrect" BOOLEAN NOT NULL, - "userId" TEXT NOT NULL, - "challengeId" TEXT NOT NULL, - "flagId" TEXT, - "teamId" TEXT NOT NULL, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" TIMESTAMP(3) NOT NULL, - - CONSTRAINT "Submission_pkey" PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "Score" ( - "id" TEXT NOT NULL, - "points" INTEGER NOT NULL, - "userId" TEXT NOT NULL, - "teamId" TEXT NOT NULL, - "challengeId" TEXT NOT NULL, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" TIMESTAMP(3) NOT NULL, - - CONSTRAINT "Score_pkey" PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "TeamPointHistory" ( - "id" TEXT NOT NULL, - "teamId" TEXT NOT NULL, - "points" INTEGER NOT NULL, - "totalPoints" INTEGER NOT NULL, - "reason" TEXT NOT NULL, - "metadata" TEXT, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - - CONSTRAINT "TeamPointHistory_pkey" PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "Hint" ( - "id" TEXT NOT NULL, - "content" TEXT NOT NULL, - "cost" INTEGER NOT NULL DEFAULT 0, - "challengeId" TEXT NOT NULL, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" TIMESTAMP(3) NOT NULL, - - CONSTRAINT "Hint_pkey" PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "TeamHint" ( - "id" TEXT NOT NULL, - "teamId" TEXT NOT NULL, - "hintId" TEXT NOT NULL, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" TIMESTAMP(3) NOT NULL, - - CONSTRAINT "TeamHint_pkey" PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "UnlockCondition" ( - "id" TEXT NOT NULL, - "challengeId" TEXT NOT NULL, - "type" "UnlockConditionType" NOT NULL, - "requiredChallengeId" TEXT, - "timeThresholdSeconds" INTEGER, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" TIMESTAMP(3) NOT NULL, - - CONSTRAINT "UnlockCondition_pkey" PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "Announcement" ( - "id" TEXT NOT NULL, - "title" TEXT NOT NULL, - "content" TEXT NOT NULL, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" TIMESTAMP(3) NOT NULL, - - CONSTRAINT "Announcement_pkey" PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "ActivityLog" ( - "id" TEXT NOT NULL, - "type" TEXT NOT NULL, - "description" TEXT NOT NULL, - "teamId" TEXT, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - - CONSTRAINT "ActivityLog_pkey" PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "GameConfig" ( - "id" TEXT NOT NULL, - "startTime" TIMESTAMP(3) NOT NULL, - "endTime" TIMESTAMP(3), - "isActive" BOOLEAN NOT NULL DEFAULT true, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" TIMESTAMP(3) NOT NULL, - - CONSTRAINT "GameConfig_pkey" PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "SiteConfig" ( - "id" TEXT NOT NULL, - "key" TEXT NOT NULL, - "value" TEXT NOT NULL, - "updatedAt" TIMESTAMP(3) NOT NULL, - - CONSTRAINT "SiteConfig_pkey" PRIMARY KEY ("id") -); - --- CreateIndex -CREATE UNIQUE INDEX "User_alias_key" ON "User"("alias"); - --- CreateIndex -CREATE INDEX "User_teamId_idx" ON "User"("teamId"); - --- CreateIndex -CREATE UNIQUE INDEX "Team_name_key" ON "Team"("name"); - --- CreateIndex -CREATE UNIQUE INDEX "Team_code_key" ON "Team"("code"); - --- CreateIndex -CREATE INDEX "Challenge_category_idx" ON "Challenge"("category"); - --- CreateIndex -CREATE INDEX "Challenge_isActive_idx" ON "Challenge"("isActive"); - --- CreateIndex -CREATE INDEX "ChallengeFlag_challengeId_idx" ON "ChallengeFlag"("challengeId"); - --- CreateIndex -CREATE INDEX "ChallengeFile_challengeId_idx" ON "ChallengeFile"("challengeId"); - --- CreateIndex -CREATE INDEX "Submission_challengeId_idx" ON "Submission"("challengeId"); - --- CreateIndex -CREATE INDEX "Submission_teamId_idx" ON "Submission"("teamId"); - --- CreateIndex -CREATE INDEX "Submission_userId_idx" ON "Submission"("userId"); - --- CreateIndex -CREATE INDEX "Submission_flagId_idx" ON "Submission"("flagId"); - --- CreateIndex -CREATE INDEX "Submission_teamId_challengeId_idx" ON "Submission"("teamId", "challengeId"); - --- CreateIndex -CREATE INDEX "Score_teamId_idx" ON "Score"("teamId"); - --- CreateIndex -CREATE INDEX "Score_userId_idx" ON "Score"("userId"); - --- CreateIndex -CREATE INDEX "Score_challengeId_idx" ON "Score"("challengeId"); - --- CreateIndex -CREATE INDEX "TeamPointHistory_teamId_idx" ON "TeamPointHistory"("teamId"); - --- CreateIndex -CREATE INDEX "TeamPointHistory_createdAt_idx" ON "TeamPointHistory"("createdAt"); - --- CreateIndex -CREATE INDEX "Hint_challengeId_idx" ON "Hint"("challengeId"); - --- CreateIndex -CREATE UNIQUE INDEX "TeamHint_teamId_hintId_key" ON "TeamHint"("teamId", "hintId"); - --- CreateIndex -CREATE INDEX "UnlockCondition_challengeId_idx" ON "UnlockCondition"("challengeId"); - --- CreateIndex -CREATE INDEX "UnlockCondition_requiredChallengeId_idx" ON "UnlockCondition"("requiredChallengeId"); - --- CreateIndex -CREATE INDEX "ActivityLog_teamId_idx" ON "ActivityLog"("teamId"); - --- CreateIndex -CREATE INDEX "ActivityLog_createdAt_idx" ON "ActivityLog"("createdAt"); - --- CreateIndex -CREATE UNIQUE INDEX "SiteConfig_key_key" ON "SiteConfig"("key"); - --- AddForeignKey -ALTER TABLE "User" ADD CONSTRAINT "User_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE SET NULL ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "ChallengeFlag" ADD CONSTRAINT "ChallengeFlag_challengeId_fkey" FOREIGN KEY ("challengeId") REFERENCES "Challenge"("id") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "ChallengeFile" ADD CONSTRAINT "ChallengeFile_challengeId_fkey" FOREIGN KEY ("challengeId") REFERENCES "Challenge"("id") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "Submission" ADD CONSTRAINT "Submission_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "Submission" ADD CONSTRAINT "Submission_challengeId_fkey" FOREIGN KEY ("challengeId") REFERENCES "Challenge"("id") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "Submission" ADD CONSTRAINT "Submission_flagId_fkey" FOREIGN KEY ("flagId") REFERENCES "ChallengeFlag"("id") ON DELETE SET NULL ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "Submission" ADD CONSTRAINT "Submission_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "Score" ADD CONSTRAINT "Score_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "Score" ADD CONSTRAINT "Score_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "Score" ADD CONSTRAINT "Score_challengeId_fkey" FOREIGN KEY ("challengeId") REFERENCES "Challenge"("id") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "TeamPointHistory" ADD CONSTRAINT "TeamPointHistory_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "Hint" ADD CONSTRAINT "Hint_challengeId_fkey" FOREIGN KEY ("challengeId") REFERENCES "Challenge"("id") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "TeamHint" ADD CONSTRAINT "TeamHint_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "TeamHint" ADD CONSTRAINT "TeamHint_hintId_fkey" FOREIGN KEY ("hintId") REFERENCES "Hint"("id") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "UnlockCondition" ADD CONSTRAINT "UnlockCondition_challengeId_fkey" FOREIGN KEY ("challengeId") REFERENCES "Challenge"("id") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "ActivityLog" ADD CONSTRAINT "ActivityLog_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE SET NULL ON UPDATE CASCADE; +-- CreateEnum +CREATE TYPE "UnlockConditionType" AS ENUM ('CHALLENGE_SOLVED', 'TIME_REMAINDER'); + +-- CreateTable +CREATE TABLE "User" ( + "id" TEXT NOT NULL, + "alias" TEXT NOT NULL, + "password" TEXT NOT NULL, + "name" TEXT NOT NULL, + "isAdmin" BOOLEAN NOT NULL DEFAULT false, + "isTeamLeader" BOOLEAN NOT NULL DEFAULT false, + "teamId" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "User_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Team" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "code" TEXT NOT NULL, + "icon" TEXT NOT NULL DEFAULT 'GiSpaceship', + "color" TEXT NOT NULL DEFAULT '#ffffff', + "score" INTEGER NOT NULL DEFAULT 0, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Team_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Challenge" ( + "id" TEXT NOT NULL, + "title" TEXT NOT NULL, + "description" TEXT NOT NULL, + "points" INTEGER NOT NULL, + "flag" TEXT, + "multipleFlags" BOOLEAN NOT NULL DEFAULT false, + "category" TEXT NOT NULL, + "difficulty" TEXT NOT NULL, + "isActive" BOOLEAN NOT NULL DEFAULT true, + "isLocked" BOOLEAN NOT NULL DEFAULT false, + "link" TEXT, + "solveExplanation" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Challenge_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "ChallengeFlag" ( + "id" TEXT NOT NULL, + "flag" TEXT NOT NULL, + "points" INTEGER NOT NULL, + "challengeId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "ChallengeFlag_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "ChallengeFile" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "path" TEXT NOT NULL, + "size" INTEGER NOT NULL, + "challengeId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "ChallengeFile_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Submission" ( + "id" TEXT NOT NULL, + "flag" TEXT NOT NULL, + "isCorrect" BOOLEAN NOT NULL, + "userId" TEXT NOT NULL, + "challengeId" TEXT NOT NULL, + "flagId" TEXT, + "teamId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Submission_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Score" ( + "id" TEXT NOT NULL, + "points" INTEGER NOT NULL, + "userId" TEXT NOT NULL, + "teamId" TEXT NOT NULL, + "challengeId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Score_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "TeamPointHistory" ( + "id" TEXT NOT NULL, + "teamId" TEXT NOT NULL, + "points" INTEGER NOT NULL, + "totalPoints" INTEGER NOT NULL, + "reason" TEXT NOT NULL, + "metadata" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "TeamPointHistory_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Hint" ( + "id" TEXT NOT NULL, + "content" TEXT NOT NULL, + "cost" INTEGER NOT NULL DEFAULT 0, + "challengeId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Hint_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "TeamHint" ( + "id" TEXT NOT NULL, + "teamId" TEXT NOT NULL, + "hintId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "TeamHint_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "UnlockCondition" ( + "id" TEXT NOT NULL, + "challengeId" TEXT NOT NULL, + "type" "UnlockConditionType" NOT NULL, + "requiredChallengeId" TEXT, + "timeThresholdSeconds" INTEGER, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "UnlockCondition_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Announcement" ( + "id" TEXT NOT NULL, + "title" TEXT NOT NULL, + "content" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Announcement_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "ActivityLog" ( + "id" TEXT NOT NULL, + "type" TEXT NOT NULL, + "description" TEXT NOT NULL, + "teamId" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "ActivityLog_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "GameConfig" ( + "id" TEXT NOT NULL, + "startTime" TIMESTAMP(3) NOT NULL, + "endTime" TIMESTAMP(3), + "isActive" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "GameConfig_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "SiteConfig" ( + "id" TEXT NOT NULL, + "key" TEXT NOT NULL, + "value" TEXT NOT NULL, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "SiteConfig_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "User_alias_key" ON "User"("alias"); + +-- CreateIndex +CREATE INDEX "User_teamId_idx" ON "User"("teamId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Team_name_key" ON "Team"("name"); + +-- CreateIndex +CREATE UNIQUE INDEX "Team_code_key" ON "Team"("code"); + +-- CreateIndex +CREATE INDEX "Challenge_category_idx" ON "Challenge"("category"); + +-- CreateIndex +CREATE INDEX "Challenge_isActive_idx" ON "Challenge"("isActive"); + +-- CreateIndex +CREATE INDEX "ChallengeFlag_challengeId_idx" ON "ChallengeFlag"("challengeId"); + +-- CreateIndex +CREATE INDEX "ChallengeFile_challengeId_idx" ON "ChallengeFile"("challengeId"); + +-- CreateIndex +CREATE INDEX "Submission_challengeId_idx" ON "Submission"("challengeId"); + +-- CreateIndex +CREATE INDEX "Submission_teamId_idx" ON "Submission"("teamId"); + +-- CreateIndex +CREATE INDEX "Submission_userId_idx" ON "Submission"("userId"); + +-- CreateIndex +CREATE INDEX "Submission_flagId_idx" ON "Submission"("flagId"); + +-- CreateIndex +CREATE INDEX "Submission_teamId_challengeId_idx" ON "Submission"("teamId", "challengeId"); + +-- CreateIndex +CREATE INDEX "Score_teamId_idx" ON "Score"("teamId"); + +-- CreateIndex +CREATE INDEX "Score_userId_idx" ON "Score"("userId"); + +-- CreateIndex +CREATE INDEX "Score_challengeId_idx" ON "Score"("challengeId"); + +-- CreateIndex +CREATE INDEX "TeamPointHistory_teamId_idx" ON "TeamPointHistory"("teamId"); + +-- CreateIndex +CREATE INDEX "TeamPointHistory_createdAt_idx" ON "TeamPointHistory"("createdAt"); + +-- CreateIndex +CREATE INDEX "Hint_challengeId_idx" ON "Hint"("challengeId"); + +-- CreateIndex +CREATE UNIQUE INDEX "TeamHint_teamId_hintId_key" ON "TeamHint"("teamId", "hintId"); + +-- CreateIndex +CREATE INDEX "UnlockCondition_challengeId_idx" ON "UnlockCondition"("challengeId"); + +-- CreateIndex +CREATE INDEX "UnlockCondition_requiredChallengeId_idx" ON "UnlockCondition"("requiredChallengeId"); + +-- CreateIndex +CREATE INDEX "ActivityLog_teamId_idx" ON "ActivityLog"("teamId"); + +-- CreateIndex +CREATE INDEX "ActivityLog_createdAt_idx" ON "ActivityLog"("createdAt"); + +-- CreateIndex +CREATE UNIQUE INDEX "SiteConfig_key_key" ON "SiteConfig"("key"); + +-- AddForeignKey +ALTER TABLE "User" ADD CONSTRAINT "User_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ChallengeFlag" ADD CONSTRAINT "ChallengeFlag_challengeId_fkey" FOREIGN KEY ("challengeId") REFERENCES "Challenge"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ChallengeFile" ADD CONSTRAINT "ChallengeFile_challengeId_fkey" FOREIGN KEY ("challengeId") REFERENCES "Challenge"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Submission" ADD CONSTRAINT "Submission_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Submission" ADD CONSTRAINT "Submission_challengeId_fkey" FOREIGN KEY ("challengeId") REFERENCES "Challenge"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Submission" ADD CONSTRAINT "Submission_flagId_fkey" FOREIGN KEY ("flagId") REFERENCES "ChallengeFlag"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Submission" ADD CONSTRAINT "Submission_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Score" ADD CONSTRAINT "Score_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Score" ADD CONSTRAINT "Score_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Score" ADD CONSTRAINT "Score_challengeId_fkey" FOREIGN KEY ("challengeId") REFERENCES "Challenge"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "TeamPointHistory" ADD CONSTRAINT "TeamPointHistory_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Hint" ADD CONSTRAINT "Hint_challengeId_fkey" FOREIGN KEY ("challengeId") REFERENCES "Challenge"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "TeamHint" ADD CONSTRAINT "TeamHint_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "TeamHint" ADD CONSTRAINT "TeamHint_hintId_fkey" FOREIGN KEY ("hintId") REFERENCES "Hint"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "UnlockCondition" ADD CONSTRAINT "UnlockCondition_challengeId_fkey" FOREIGN KEY ("challengeId") REFERENCES "Challenge"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ActivityLog" ADD CONSTRAINT "ActivityLog_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/migrations/20260622060728_add_login_attempt/migration.sql b/prisma/migrations/20260622060728_add_login_attempt/migration.sql index d59066c..a64dc71 100644 --- a/prisma/migrations/20260622060728_add_login_attempt/migration.sql +++ b/prisma/migrations/20260622060728_add_login_attempt/migration.sql @@ -1,16 +1,16 @@ --- CreateTable -CREATE TABLE "LoginAttempt" ( - "id" TEXT NOT NULL, - "ip" TEXT NOT NULL, - "alias" TEXT NOT NULL, - "success" BOOLEAN NOT NULL, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - - CONSTRAINT "LoginAttempt_pkey" PRIMARY KEY ("id") -); - --- CreateIndex -CREATE INDEX "LoginAttempt_ip_success_createdAt_idx" ON "LoginAttempt"("ip", "success", "createdAt"); - --- CreateIndex -CREATE INDEX "LoginAttempt_alias_success_createdAt_idx" ON "LoginAttempt"("alias", "success", "createdAt"); +-- CreateTable +CREATE TABLE "LoginAttempt" ( + "id" TEXT NOT NULL, + "ip" TEXT NOT NULL, + "alias" TEXT NOT NULL, + "success" BOOLEAN NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "LoginAttempt_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "LoginAttempt_ip_success_createdAt_idx" ON "LoginAttempt"("ip", "success", "createdAt"); + +-- CreateIndex +CREATE INDEX "LoginAttempt_alias_success_createdAt_idx" ON "LoginAttempt"("alias", "success", "createdAt"); diff --git a/prisma/migrations/20260622061923_add_user_session/migration.sql b/prisma/migrations/20260622061923_add_user_session/migration.sql index 38f78a5..5900451 100644 --- a/prisma/migrations/20260622061923_add_user_session/migration.sql +++ b/prisma/migrations/20260622061923_add_user_session/migration.sql @@ -1,19 +1,19 @@ --- CreateTable -CREATE TABLE "UserSession" ( - "id" TEXT NOT NULL, - "userId" TEXT NOT NULL, - "sessionToken" TEXT NOT NULL, - "expiresAt" TIMESTAMP(3) NOT NULL, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - - CONSTRAINT "UserSession_pkey" PRIMARY KEY ("id") -); - --- CreateIndex -CREATE UNIQUE INDEX "UserSession_sessionToken_key" ON "UserSession"("sessionToken"); - --- CreateIndex -CREATE INDEX "UserSession_userId_idx" ON "UserSession"("userId"); - --- AddForeignKey -ALTER TABLE "UserSession" ADD CONSTRAINT "UserSession_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; +-- CreateTable +CREATE TABLE "UserSession" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "sessionToken" TEXT NOT NULL, + "expiresAt" TIMESTAMP(3) NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "UserSession_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "UserSession_sessionToken_key" ON "UserSession"("sessionToken"); + +-- CreateIndex +CREATE INDEX "UserSession_userId_idx" ON "UserSession"("userId"); + +-- AddForeignKey +ALTER TABLE "UserSession" ADD CONSTRAINT "UserSession_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20260622073604_add_team_join_attempt/migration.sql b/prisma/migrations/20260622073604_add_team_join_attempt/migration.sql index 7b0badf..58ab386 100644 --- a/prisma/migrations/20260622073604_add_team_join_attempt/migration.sql +++ b/prisma/migrations/20260622073604_add_team_join_attempt/migration.sql @@ -1,12 +1,12 @@ --- CreateTable -CREATE TABLE "TeamJoinAttempt" ( - "id" TEXT NOT NULL, - "userId" TEXT NOT NULL, - "success" BOOLEAN NOT NULL, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - - CONSTRAINT "TeamJoinAttempt_pkey" PRIMARY KEY ("id") -); - --- CreateIndex -CREATE INDEX "TeamJoinAttempt_userId_createdAt_idx" ON "TeamJoinAttempt"("userId", "createdAt"); +-- CreateTable +CREATE TABLE "TeamJoinAttempt" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "success" BOOLEAN NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "TeamJoinAttempt_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "TeamJoinAttempt_userId_createdAt_idx" ON "TeamJoinAttempt"("userId", "createdAt"); diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml index 044d57c..1af22ce 100644 --- a/prisma/migrations/migration_lock.toml +++ b/prisma/migrations/migration_lock.toml @@ -1,3 +1,3 @@ -# Please do not edit this file manually -# It should be added in your version-control system (e.g., Git) -provider = "postgresql" +# Please do not edit this file manually +# It should be added in your version-control system (e.g., Git) +provider = "postgresql" diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e253df8..dead0ac 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1,284 +1,284 @@ -generator client { - provider = "prisma-client-js" - output = "./generated/client" -} - -datasource db { - provider = "postgresql" - url = env("DATABASE_URL") -} - -// ============================================ -// USER & TEAM -// ============================================ - -model User { - id String @id @default(cuid()) - alias String @unique /// max 32 chars - password String /// bcrypt hashed, max 128 chars - name String /// display name, max 48 chars - isAdmin Boolean @default(false) - isTeamLeader Boolean @default(false) - teamId String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - team Team? @relation(fields: [teamId], references: [id]) - submissions Submission[] - scores Score[] - sessions UserSession[] - - @@index([teamId]) -} - -model Team { - id String @id @default(cuid()) - name String @unique /// max 32 chars - code String @unique /// invite code, max 12 chars - icon String @default("GiSpaceship") - color String @default("#ffffff") - score Int @default(0) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - members User[] - submissions Submission[] - scores Score[] - teamHints TeamHint[] - activityLogs ActivityLog[] - pointHistory TeamPointHistory[] -} - -// ============================================ -// CHALLENGES -// ============================================ - -model Challenge { - id String @id @default(cuid()) - title String - description String - points Int - flag String? /// single-flag mode - multipleFlags Boolean @default(false) - category String - difficulty String - isActive Boolean @default(true) - isLocked Boolean @default(false) - link String? /// optional external link - solveExplanation String? /// shown after solving - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - flags ChallengeFlag[] - files ChallengeFile[] - hints Hint[] - submissions Submission[] - scores Score[] - unlockConditions UnlockCondition[] - - @@index([category]) - @@index([isActive]) -} - -model ChallengeFlag { - id String @id @default(cuid()) - flag String - points Int - challengeId String - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - challenge Challenge @relation(fields: [challengeId], references: [id], onDelete: Cascade) - submissions Submission[] - - @@index([challengeId]) -} - -model ChallengeFile { - id String @id @default(cuid()) - name String - path String - size Int - challengeId String - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - challenge Challenge @relation(fields: [challengeId], references: [id], onDelete: Cascade) - - @@index([challengeId]) -} - -// ============================================ -// SUBMISSIONS & SCORING -// ============================================ - -model Submission { - id String @id @default(cuid()) - flag String - isCorrect Boolean - userId String - challengeId String - flagId String? - teamId String - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - user User @relation(fields: [userId], references: [id]) - challenge Challenge @relation(fields: [challengeId], references: [id], onDelete: Cascade) - challengeFlag ChallengeFlag? @relation(fields: [flagId], references: [id]) - team Team @relation(fields: [teamId], references: [id]) - - @@index([challengeId]) - @@index([teamId]) - @@index([userId]) - @@index([flagId]) - @@index([teamId, challengeId]) -} - -model Score { - id String @id @default(cuid()) - points Int - userId String - teamId String - challengeId String - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - user User @relation(fields: [userId], references: [id]) - team Team @relation(fields: [teamId], references: [id]) - challenge Challenge @relation(fields: [challengeId], references: [id], onDelete: Cascade) - - @@index([teamId]) - @@index([userId]) - @@index([challengeId]) -} - -model TeamPointHistory { - id String @id @default(cuid()) - teamId String - points Int /// the point change (positive or negative) - totalPoints Int /// team's total after this change - reason String /// e.g. "CHALLENGE_SOLVE", "HINT_PURCHASE" - metadata String? /// additional JSON data - createdAt DateTime @default(now()) - team Team @relation(fields: [teamId], references: [id], onDelete: Cascade) - - @@index([teamId]) - @@index([createdAt]) -} - -// ============================================ -// HINTS -// ============================================ - -model Hint { - id String @id @default(cuid()) - content String - cost Int @default(0) - challengeId String - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - challenge Challenge @relation(fields: [challengeId], references: [id], onDelete: Cascade) - teamHints TeamHint[] - - @@index([challengeId]) -} - -model TeamHint { - id String @id @default(cuid()) - teamId String - hintId String - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - team Team @relation(fields: [teamId], references: [id]) - hint Hint @relation(fields: [hintId], references: [id], onDelete: Cascade) - - @@unique([teamId, hintId]) -} - -// ============================================ -// UNLOCK CONDITIONS -// ============================================ - -enum UnlockConditionType { - CHALLENGE_SOLVED - TIME_REMAINDER -} - -model UnlockCondition { - id String @id @default(cuid()) - challengeId String - type UnlockConditionType - requiredChallengeId String? /// for CHALLENGE_SOLVED - timeThresholdSeconds Int? /// for TIME_REMAINDER - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - challenge Challenge @relation(fields: [challengeId], references: [id], onDelete: Cascade) - - @@index([challengeId]) - @@index([requiredChallengeId]) -} - -// ============================================ -// PLATFORM CONFIG -// ============================================ - -model Announcement { - id String @id @default(cuid()) - title String - content String - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt -} - -model ActivityLog { - id String @id @default(cuid()) - type String - description String - teamId String? - createdAt DateTime @default(now()) - team Team? @relation(fields: [teamId], references: [id]) - - @@index([teamId]) - @@index([createdAt]) -} - -model GameConfig { - id String @id @default(cuid()) - startTime DateTime - endTime DateTime? - isActive Boolean @default(true) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt -} - -model SiteConfig { - id String @id @default(cuid()) - key String @unique - value String - updatedAt DateTime @updatedAt -} - -model LoginAttempt { - id String @id @default(cuid()) - ip String - alias String - success Boolean - createdAt DateTime @default(now()) - - @@index([ip, success, createdAt]) - @@index([alias, success, createdAt]) -} - -model UserSession { - id String @id @default(cuid()) - userId String - sessionToken String @unique - expiresAt DateTime - createdAt DateTime @default(now()) - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - - @@index([userId]) -} - -model TeamJoinAttempt { - id String @id @default(cuid()) - userId String - success Boolean - createdAt DateTime @default(now()) - - @@index([userId, createdAt]) -} +generator client { + provider = "prisma-client-js" + output = "./generated/client" +} + +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +// ============================================ +// USER & TEAM +// ============================================ + +model User { + id String @id @default(cuid()) + alias String @unique /// max 32 chars + password String /// bcrypt hashed, max 128 chars + name String /// display name, max 48 chars + isAdmin Boolean @default(false) + isTeamLeader Boolean @default(false) + teamId String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + team Team? @relation(fields: [teamId], references: [id]) + submissions Submission[] + scores Score[] + sessions UserSession[] + + @@index([teamId]) +} + +model Team { + id String @id @default(cuid()) + name String @unique /// max 32 chars + code String @unique /// invite code, max 12 chars + icon String @default("GiSpaceship") + color String @default("#ffffff") + score Int @default(0) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + members User[] + submissions Submission[] + scores Score[] + teamHints TeamHint[] + activityLogs ActivityLog[] + pointHistory TeamPointHistory[] +} + +// ============================================ +// CHALLENGES +// ============================================ + +model Challenge { + id String @id @default(cuid()) + title String + description String + points Int + flag String? /// single-flag mode + multipleFlags Boolean @default(false) + category String + difficulty String + isActive Boolean @default(true) + isLocked Boolean @default(false) + link String? /// optional external link + solveExplanation String? /// shown after solving + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + flags ChallengeFlag[] + files ChallengeFile[] + hints Hint[] + submissions Submission[] + scores Score[] + unlockConditions UnlockCondition[] + + @@index([category]) + @@index([isActive]) +} + +model ChallengeFlag { + id String @id @default(cuid()) + flag String + points Int + challengeId String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + challenge Challenge @relation(fields: [challengeId], references: [id], onDelete: Cascade) + submissions Submission[] + + @@index([challengeId]) +} + +model ChallengeFile { + id String @id @default(cuid()) + name String + path String + size Int + challengeId String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + challenge Challenge @relation(fields: [challengeId], references: [id], onDelete: Cascade) + + @@index([challengeId]) +} + +// ============================================ +// SUBMISSIONS & SCORING +// ============================================ + +model Submission { + id String @id @default(cuid()) + flag String + isCorrect Boolean + userId String + challengeId String + flagId String? + teamId String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + user User @relation(fields: [userId], references: [id]) + challenge Challenge @relation(fields: [challengeId], references: [id], onDelete: Cascade) + challengeFlag ChallengeFlag? @relation(fields: [flagId], references: [id]) + team Team @relation(fields: [teamId], references: [id]) + + @@index([challengeId]) + @@index([teamId]) + @@index([userId]) + @@index([flagId]) + @@index([teamId, challengeId]) +} + +model Score { + id String @id @default(cuid()) + points Int + userId String + teamId String + challengeId String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + user User @relation(fields: [userId], references: [id]) + team Team @relation(fields: [teamId], references: [id]) + challenge Challenge @relation(fields: [challengeId], references: [id], onDelete: Cascade) + + @@index([teamId]) + @@index([userId]) + @@index([challengeId]) +} + +model TeamPointHistory { + id String @id @default(cuid()) + teamId String + points Int /// the point change (positive or negative) + totalPoints Int /// team's total after this change + reason String /// e.g. "CHALLENGE_SOLVE", "HINT_PURCHASE" + metadata String? /// additional JSON data + createdAt DateTime @default(now()) + team Team @relation(fields: [teamId], references: [id], onDelete: Cascade) + + @@index([teamId]) + @@index([createdAt]) +} + +// ============================================ +// HINTS +// ============================================ + +model Hint { + id String @id @default(cuid()) + content String + cost Int @default(0) + challengeId String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + challenge Challenge @relation(fields: [challengeId], references: [id], onDelete: Cascade) + teamHints TeamHint[] + + @@index([challengeId]) +} + +model TeamHint { + id String @id @default(cuid()) + teamId String + hintId String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + team Team @relation(fields: [teamId], references: [id]) + hint Hint @relation(fields: [hintId], references: [id], onDelete: Cascade) + + @@unique([teamId, hintId]) +} + +// ============================================ +// UNLOCK CONDITIONS +// ============================================ + +enum UnlockConditionType { + CHALLENGE_SOLVED + TIME_REMAINDER +} + +model UnlockCondition { + id String @id @default(cuid()) + challengeId String + type UnlockConditionType + requiredChallengeId String? /// for CHALLENGE_SOLVED + timeThresholdSeconds Int? /// for TIME_REMAINDER + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + challenge Challenge @relation(fields: [challengeId], references: [id], onDelete: Cascade) + + @@index([challengeId]) + @@index([requiredChallengeId]) +} + +// ============================================ +// PLATFORM CONFIG +// ============================================ + +model Announcement { + id String @id @default(cuid()) + title String + content String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +model ActivityLog { + id String @id @default(cuid()) + type String + description String + teamId String? + createdAt DateTime @default(now()) + team Team? @relation(fields: [teamId], references: [id]) + + @@index([teamId]) + @@index([createdAt]) +} + +model GameConfig { + id String @id @default(cuid()) + startTime DateTime + endTime DateTime? + isActive Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +model SiteConfig { + id String @id @default(cuid()) + key String @unique + value String + updatedAt DateTime @updatedAt +} + +model LoginAttempt { + id String @id @default(cuid()) + ip String + alias String + success Boolean + createdAt DateTime @default(now()) + + @@index([ip, success, createdAt]) + @@index([alias, success, createdAt]) +} + +model UserSession { + id String @id @default(cuid()) + userId String + sessionToken String @unique + expiresAt DateTime + createdAt DateTime @default(now()) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId]) +} + +model TeamJoinAttempt { + id String @id @default(cuid()) + userId String + success Boolean + createdAt DateTime @default(now()) + + @@index([userId, createdAt]) +} diff --git a/prisma/seed.ts b/prisma/seed.ts index 2cca1ee..40fa451 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -1,4 +1,4 @@ -import { PrismaClient } from "./generated/client"; +import { prisma } from "../src/lib/prisma"; import bcrypt from "bcryptjs"; // Guard: refuse to seed production unless explicitly overridden @@ -12,8 +12,6 @@ if ( process.exit(1); } -const prisma = new PrismaClient(); - async function main() { console.log("🌱 Seeding database...\n"); @@ -389,6 +387,72 @@ async function main() { console.log(" βœ“ Announcement: welcome message"); + // ── Submissions ────────────────────────────────────────── + const existingSubmission = await prisma.submission.findFirst(); + if (!existingSubmission) { + // Sample submissions + const challenge1 = await prisma.challenge.findUnique({ + where: { id: "seed-web-01" }, + }); + const challenge2 = await prisma.challenge.findUnique({ + where: { id: "seed-crypto-01" }, + }); + + if (challenge1 && user1 && teamAlpha) { + await prisma.submission.create({ + data: { + flag: "flag{web_101}", + isCorrect: true, + userId: user1.id, + challengeId: challenge1.id, + teamId: teamAlpha.id, + }, + }); + } + + if (challenge2 && user2 && teamAlpha) { + await prisma.submission.create({ + data: { + flag: "flag{wrong_flag}", + isCorrect: false, + userId: user2.id, + challengeId: challenge2.id, + teamId: teamAlpha.id, + }, + }); + } + } + + console.log(" βœ“ Submissions: sample data"); + + // ── Activity Logs ──────────────────────────────────────── + const existingLog = await prisma.activityLog.findFirst(); + if (!existingLog) { + await prisma.activityLog.create({ + data: { + type: "CHALLENGE_SOLVE", + description: "Team Alpha solved 'Hidden in Plain Sight' (web)", + teamId: teamAlpha.id, + }, + }); + await prisma.activityLog.create({ + data: { + type: "HINT_PURCHASE", + description: "Team Alpha purchased a hint for 'Buffer Overflow'", + teamId: teamAlpha.id, + }, + }); + await prisma.activityLog.create({ + data: { + type: "CHALLENGE_SOLVE", + description: "Team Bravo solved 'SQL Injection 101' (web)", + teamId: teamBravo.id, + }, + }); + } + + console.log(" βœ“ Activity Logs: sample data"); + console.log("\nβœ… Seed complete!\n"); console.log(" Admin login: admin / admin123"); console.log(" User logins: alice / password"); diff --git a/public/image.png b/public/image.png new file mode 100644 index 0000000..f07efcb Binary files /dev/null and b/public/image.png differ diff --git a/public/image1.png b/public/image1.png new file mode 100644 index 0000000..cf1e729 Binary files /dev/null and b/public/image1.png differ diff --git a/public/imagen-invertida.png b/public/imagen-invertida.png new file mode 100644 index 0000000..19f9c42 Binary files /dev/null and b/public/imagen-invertida.png differ diff --git a/public/loading-gif-2.gif b/public/loading-gif-2.gif new file mode 100644 index 0000000..ada765e Binary files /dev/null and b/public/loading-gif-2.gif differ diff --git a/public/loading-gif.gif b/public/loading-gif.gif new file mode 100644 index 0000000..9019128 Binary files /dev/null and b/public/loading-gif.gif differ diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 005f80e..cb1e9a8 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -1,7 +1,7 @@ import { getServerSession } from "next-auth"; import { redirect } from "next/navigation"; import { authOptions } from "@/lib/auth"; -import AdminDashboard from "@/components/AdminDashboard"; +import AdminDashboard from "@/components/admin/AdminDashboard"; export default async function AdminPage() { const session = await getServerSession(authOptions); diff --git a/src/app/api/admin/announcements/[id]/route.ts b/src/app/api/admin/announcements/[id]/route.ts new file mode 100644 index 0000000..6c14d85 --- /dev/null +++ b/src/app/api/admin/announcements/[id]/route.ts @@ -0,0 +1,42 @@ +import { NextResponse } from "next/server"; +import { getServerSession } from "next-auth"; +import { authOptions } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; + +// DELETE /api/admin/announcements/[id] - Delete announcement +export async function DELETE( + request: Request, + { params }: { params: Promise<{ id: string }> }, +) { + const session = await getServerSession(authOptions); + if (!session?.user?.isAdmin) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + try { + const { id } = await params; + + const announcement = await prisma.announcement.findUnique({ + where: { id }, + }); + + if (!announcement) { + return NextResponse.json( + { error: "Announcement not found" }, + { status: 404 }, + ); + } + + await prisma.announcement.delete({ + where: { id }, + }); + + return NextResponse.json({ message: "Announcement deleted successfully" }); + } catch (error) { + console.error("Error deleting announcement:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 }, + ); + } +} diff --git a/src/app/api/admin/announcements/route.ts b/src/app/api/admin/announcements/route.ts index 1221657..ccc1ec3 100644 --- a/src/app/api/admin/announcements/route.ts +++ b/src/app/api/admin/announcements/route.ts @@ -6,6 +6,28 @@ import { prisma } from "@/lib/prisma"; const MAX_TITLE_LENGTH = 120; const MAX_CONTENT_LENGTH = 2000; +// GET /api/admin/announcements - List all announcements +export async function GET() { + const session = await getServerSession(authOptions); + if (!session?.user?.isAdmin) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + try { + const announcements = await prisma.announcement.findMany({ + orderBy: { createdAt: "desc" }, + }); + + return NextResponse.json({ announcements }); + } catch (error) { + console.error("Error fetching announcements:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 }, + ); + } +} + // POST /api/admin/announcements β€” Create announcement export async function POST(request: Request) { const session = await getServerSession(authOptions); diff --git a/src/app/api/admin/export/route.ts b/src/app/api/admin/export/route.ts new file mode 100644 index 0000000..755d40f --- /dev/null +++ b/src/app/api/admin/export/route.ts @@ -0,0 +1,218 @@ +import { NextResponse } from "next/server"; +import { getServerSession } from "next-auth"; +import { authOptions } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; + +// GET /api/admin/export - Export all data +export async function GET() { + const session = await getServerSession(authOptions); + if (!session?.user?.isAdmin) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + try { + const [challenges, users, teams, announcements, gameConfig] = + await Promise.all([ + prisma.challenge.findMany({ + select: { + id: true, + title: true, + description: true, + points: true, + category: true, + difficulty: true, + isActive: true, + isLocked: true, + link: true, + createdAt: true, + updatedAt: true, + }, + }), + prisma.user.findMany({ + select: { + id: true, + alias: true, + name: true, + password: true, + isAdmin: true, + isTeamLeader: true, + teamId: true, + createdAt: true, + updatedAt: true, + }, + }), + prisma.team.findMany({ + select: { + id: true, + name: true, + code: true, + score: true, + createdAt: true, + updatedAt: true, + }, + }), + prisma.announcement.findMany({ + select: { + id: true, + title: true, + content: true, + createdAt: true, + updatedAt: true, + }, + }), + prisma.gameConfig.findFirst({ + select: { + startTime: true, + endTime: true, + }, + }), + ]); + + return NextResponse.json({ + challenges, + users, + teams, + announcements, + gameConfig, + }); + } catch (error) { + console.error("Error exporting data:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 }, + ); + } +} + +// POST /api/admin/export - Import data +export async function POST(request: Request) { + const session = await getServerSession(authOptions); + if (!session?.user?.isAdmin) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + try { + const body = await request.json(); + const { challenges, users, teams, announcements, gameConfig } = body; + + // Validate input + if (!challenges && !users && !teams && !announcements && !gameConfig) { + return NextResponse.json( + { error: "No data provided for import" }, + { status: 400 }, + ); + } + + if ( + Array.isArray(users) && + users.some( + (u: Record) => + typeof u.password !== "string" || u.password.length === 0, + ) + ) { + return NextResponse.json( + { error: "All users must include a password hash" }, + { status: 400 }, + ); + } + + // Begin transaction for all-or-nothing import + const result = await prisma.$transaction(async (tx) => { + const imported: Record = {}; + + if (teams) { + // Delete existing teams first (will cascade to users) + await tx.team.deleteMany({}); + imported.teams = await Promise.all( + teams.map((team: Record) => + tx.team.create({ + data: { + name: team.name as string, + code: team.code as string, + score: (team.score as number) || 0, + }, + }), + ), + ); + } + + if (users) { + // Delete existing users + await tx.user.deleteMany({}); + imported.users = await Promise.all( + users.map((user: Record) => + tx.user.create({ + data: { + alias: user.alias as string, + name: user.name as string, + password: user.password as string, + isAdmin: (user.isAdmin as boolean) || false, + isTeamLeader: (user.isTeamLeader as boolean) || false, + teamId: (user.teamId as string) || null, + }, + }), + ), + ); + } + + if (challenges) { + // Delete existing challenges + await tx.challenge.deleteMany({}); + imported.challenges = await Promise.all( + challenges.map((challenge: Record) => + tx.challenge.create({ + data: { + title: challenge.title as string, + description: challenge.description as string, + points: (challenge.points as number) || 0, + category: (challenge.category as string) || "web", + difficulty: (challenge.difficulty as string) || "easy", + isActive: (challenge.isActive as boolean) || true, + isLocked: (challenge.isLocked as boolean) || false, + link: (challenge.link as string) || "", + }, + }), + ), + ); + } + + if (announcements) { + // Delete existing announcements + await tx.announcement.deleteMany({}); + imported.announcements = await Promise.all( + announcements.map((announcement: Record) => + tx.announcement.create({ + data: { + title: announcement.title as string, + content: announcement.content as string, + }, + }), + ), + ); + } + + if (gameConfig) { + // Delete existing game config + await tx.gameConfig.deleteMany({}); + imported.gameConfig = await tx.gameConfig.create({ + data: { + startTime: new Date(gameConfig.startTime as string), + endTime: gameConfig.endTime + ? new Date(gameConfig.endTime as string) + : null, + }, + }); + } + + return imported; + }); + + return NextResponse.json(result); + } catch (error) { + console.error("Error importing data:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 }, + ); + } +} diff --git a/src/app/api/admin/logs/route.ts b/src/app/api/admin/logs/route.ts new file mode 100644 index 0000000..29dd430 --- /dev/null +++ b/src/app/api/admin/logs/route.ts @@ -0,0 +1,35 @@ +import { NextResponse } from "next/server"; +import { getServerSession } from "next-auth"; +import { authOptions } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; + +// GET /api/admin/logs - List all activity logs +export async function GET() { + const session = await getServerSession(authOptions); + if (!session?.user?.isAdmin) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + try { + const logs = await prisma.activityLog.findMany({ + include: { + team: { + select: { + id: true, + name: true, + color: true, + }, + }, + }, + orderBy: { createdAt: "desc" }, + }); + + return NextResponse.json({ logs }); + } catch (error) { + console.error("Error fetching logs:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 }, + ); + } +} diff --git a/src/app/api/admin/rules/route.ts b/src/app/api/admin/rules/route.ts new file mode 100644 index 0000000..87a657b --- /dev/null +++ b/src/app/api/admin/rules/route.ts @@ -0,0 +1,63 @@ +import { NextResponse } from "next/server"; +import { getServerSession } from "next-auth"; +import { authOptions } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; + +// GET /api/admin/rules - Get competition rules +export async function GET() { + const session = await getServerSession(authOptions); + if (!session?.user?.isAdmin) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + try { + const rulesConfig = await prisma.siteConfig.findUnique({ + where: { key: "rules" }, + }); + + return NextResponse.json({ + rules: rulesConfig?.value || "", + }); + } catch (error) { + console.error("Error fetching rules:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 }, + ); + } +} + +// POST /api/admin/rules - Update competition rules +export async function POST(request: Request) { + const session = await getServerSession(authOptions); + if (!session?.user?.isAdmin) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + try { + const body = await request.json(); + const { rules } = body; + + if (typeof rules !== "string") { + return NextResponse.json( + { error: "Invalid format - rules must be a string" }, + { status: 400 }, + ); + } + + // Upsert the rules in SiteConfig + const config = await prisma.siteConfig.upsert({ + where: { key: "rules" }, + update: { value: rules }, + create: { key: "rules", value: rules }, + }); + + return NextResponse.json({ rules: config.value }); + } catch (error) { + console.error("Error updating rules:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 }, + ); + } +} diff --git a/src/app/api/admin/submissions/route.ts b/src/app/api/admin/submissions/route.ts new file mode 100644 index 0000000..725647f --- /dev/null +++ b/src/app/api/admin/submissions/route.ts @@ -0,0 +1,48 @@ +import { NextResponse } from "next/server"; +import { getServerSession } from "next-auth"; +import { authOptions } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; + +// GET /api/admin/submissions - List all submissions +export async function GET() { + const session = await getServerSession(authOptions); + if (!session?.user?.isAdmin) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + try { + const submissions = await prisma.submission.findMany({ + include: { + user: { + select: { + id: true, + alias: true, + name: true, + }, + }, + team: { + select: { + id: true, + name: true, + color: true, + }, + }, + challenge: { + select: { + id: true, + title: true, + }, + }, + }, + orderBy: { createdAt: "desc" }, + }); + + return NextResponse.json({ submissions }); + } catch (error) { + console.error("Error fetching submissions:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 }, + ); + } +} diff --git a/src/app/api/admin/teams/[id]/route.ts b/src/app/api/admin/teams/[id]/route.ts new file mode 100644 index 0000000..665c3fa --- /dev/null +++ b/src/app/api/admin/teams/[id]/route.ts @@ -0,0 +1,148 @@ +import { NextResponse } from "next/server"; +import { getServerSession } from "next-auth"; +import { authOptions } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; + +// PATCH /api/admin/teams/[id] - Update team name +export async function PATCH( + request: Request, + { params }: { params: Promise<{ id: string }> }, +) { + const session = await getServerSession(authOptions); + if (!session?.user?.isAdmin) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + try { + const { id } = await params; + const body = await request.json(); + const { name, icon, color, code } = body; + + const team = await prisma.team.findUnique({ + where: { id }, + }); + + if (!team) { + return NextResponse.json({ error: "Team not found" }, { status: 404 }); + } + + // Validate field types and basic constraints when provided + if (name !== undefined && (typeof name !== "string" || name.length > 32)) { + return NextResponse.json( + { error: "name must be a string of max 32 characters" }, + { status: 400 }, + ); + } + if (icon !== undefined && typeof icon !== "string") { + return NextResponse.json( + { error: "icon must be a string" }, + { status: 400 }, + ); + } + if (color !== undefined && typeof color !== "string") { + return NextResponse.json( + { error: "color must be a string" }, + { status: 400 }, + ); + } + if ( + code !== undefined && + (typeof code !== "string" || code.length === 0 || code.length > 12) + ) { + return NextResponse.json( + { error: "code must be a string of 1-12 characters" }, + { status: 400 }, + ); + } + + if (code !== undefined && code !== team.code) { + const existing = await prisma.team.findUnique({ + where: { code }, + }); + if (existing) { + return NextResponse.json( + { error: "Team code is already taken" }, + { status: 409 }, + ); + } + } + + const updatedTeam = await prisma.team.update({ + where: { id }, + data: { + ...(name !== undefined && { name }), + ...(icon !== undefined && { icon }), + ...(color !== undefined && { color }), + ...(code !== undefined && { code }), + }, + select: { + id: true, + name: true, + code: true, + icon: true, + color: true, + score: true, + createdAt: true, + updatedAt: true, + }, + }); + + return NextResponse.json({ team: updatedTeam }); + } catch (error) { + const err = error as { code?: string }; + if (err.code === "P2002") { + return NextResponse.json( + { error: "Team name or code is already taken" }, + { status: 409 }, + ); + } + console.error("Error updating team:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 }, + ); + } +} + +// DELETE /api/admin/teams/[id] - Delete a team (removes teamId from members, doesn't delete users) +export async function DELETE( + request: Request, + { params }: { params: Promise<{ id: string }> }, +) { + const session = await getServerSession(authOptions); + if (!session?.user?.isAdmin) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + try { + const { id } = await params; + + // Check if team exists + const team = await prisma.team.findUnique({ + where: { id }, + }); + + if (!team) { + return NextResponse.json({ error: "Team not found" }, { status: 404 }); + } + + // Remove teamId from all members (don't delete users) + await prisma.user.updateMany({ + where: { teamId: id }, + data: { teamId: null }, + }); + + // Delete team (cascade will handle related data per schema) + await prisma.team.delete({ + where: { id }, + }); + + return NextResponse.json({ message: "Team deleted successfully" }); + } catch (error) { + console.error("Error deleting team:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 }, + ); + } +} diff --git a/src/app/api/admin/teams/route.ts b/src/app/api/admin/teams/route.ts new file mode 100644 index 0000000..931c24b --- /dev/null +++ b/src/app/api/admin/teams/route.ts @@ -0,0 +1,39 @@ +import { NextResponse } from "next/server"; +import { getServerSession } from "next-auth"; +import { authOptions } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; + +// GET /api/admin/teams - List all teams with member count +export async function GET() { + const session = await getServerSession(authOptions); + if (!session?.user?.isAdmin) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + try { + const teams = await prisma.team.findMany({ + select: { + id: true, + name: true, + code: true, + icon: true, + color: true, + score: true, + createdAt: true, + updatedAt: true, + _count: { + select: { members: true }, + }, + }, + orderBy: { score: "desc" }, + }); + + return NextResponse.json({ teams }); + } catch (error) { + console.error("Error listing teams:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 }, + ); + } +} diff --git a/src/app/api/admin/users/[id]/route.ts b/src/app/api/admin/users/[id]/route.ts new file mode 100644 index 0000000..a33a0f6 --- /dev/null +++ b/src/app/api/admin/users/[id]/route.ts @@ -0,0 +1,144 @@ +import { NextResponse } from "next/server"; +import { getServerSession } from "next-auth"; +import { authOptions } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; + +// PATCH /api/admin/users/[id] - Update user properties (admin, team leader, name) +export async function PATCH( + request: Request, + { params }: { params: Promise<{ id: string }> }, +) { + const session = await getServerSession(authOptions); + if (!session?.user?.isAdmin) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + try { + const { id } = await params; + const body = await request.json(); + const { name, isAdmin, isTeamLeader, teamId } = body; + + const user = await prisma.user.findUnique({ + where: { id }, + }); + + if (!user) { + return NextResponse.json({ error: "User not found" }, { status: 404 }); + } + + if (name !== undefined) { + if (typeof name !== "string") { + return NextResponse.json( + { error: "name must be a string" }, + { status: 400 }, + ); + } + if (name.length > 48) { + return NextResponse.json( + { error: "name must be max 48 characters" }, + { status: 400 }, + ); + } + } + + let teamIdValue: string | null | undefined = undefined; + if (teamId !== undefined) { + if (teamId === "") { + teamIdValue = null; + } else if (typeof teamId === "string") { + const team = await prisma.team.findUnique({ + where: { id: teamId }, + }); + if (!team) { + return NextResponse.json( + { error: "Team not found" }, + { status: 404 }, + ); + } + teamIdValue = teamId; + } else { + return NextResponse.json( + { error: "teamId must be a string" }, + { status: 400 }, + ); + } + } + + const updatedUser = await prisma.user.update({ + where: { id }, + data: { + ...(name !== undefined && { name }), + ...(typeof isAdmin === "boolean" && { isAdmin }), + ...(typeof isTeamLeader === "boolean" && { isTeamLeader }), + ...(teamIdValue !== undefined && { teamId: teamIdValue }), + }, + select: { + id: true, + alias: true, + name: true, + isAdmin: true, + isTeamLeader: true, + teamId: true, + team: { + select: { + id: true, + name: true, + }, + }, + createdAt: true, + updatedAt: true, + }, + }); + + return NextResponse.json({ user: updatedUser }); + } catch (error) { + console.error("Error updating user:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 }, + ); + } +} + +// DELETE /api/admin/users/[id] - Admin-only; cascades to related data +export async function DELETE( + request: Request, + { params }: { params: Promise<{ id: string }> }, +) { + const session = await getServerSession(authOptions); + if (!session?.user?.isAdmin) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + try { + const { id } = await params; + + // Prevent self-deletion + if (id === session.user.id) { + return NextResponse.json( + { error: "Cannot delete your own account" }, + { status: 400 }, + ); + } + + const existing = await prisma.user.findUnique({ + where: { id }, + }); + + if (!existing) { + return NextResponse.json({ error: "User not found" }, { status: 404 }); + } + + await prisma.user.delete({ + where: { id }, + }); + + return NextResponse.json({ message: "User deleted successfully" }); + } catch (error) { + console.error("Error deleting user:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 }, + ); + } +} diff --git a/src/app/api/admin/users/route.ts b/src/app/api/admin/users/route.ts new file mode 100644 index 0000000..88fc03d --- /dev/null +++ b/src/app/api/admin/users/route.ts @@ -0,0 +1,117 @@ +import { NextResponse } from "next/server"; +import { getServerSession } from "next-auth"; +import { authOptions } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; + +// GET /api/admin/users - List all users with team info +// Admin-only endpoint to view all users in the system +export async function GET() { + const session = await getServerSession(authOptions); + if (!session?.user?.isAdmin) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + try { + const users = await prisma.user.findMany({ + select: { + id: true, + alias: true, + name: true, + isAdmin: true, + isTeamLeader: true, + teamId: true, + createdAt: true, + updatedAt: true, + team: { + select: { + id: true, + name: true, + }, + }, + _count: { + select: { + submissions: true, + scores: true, + }, + }, + }, + orderBy: { createdAt: "desc" }, + }); + + return NextResponse.json({ users }); + } catch (error) { + console.error("Error listing users:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 }, + ); + } +} + +// POST /api/admin/users - Create a new user (if needed for admin panel) +// Currently unused, but kept for potential bulk user creation +export async function POST(request: Request) { + const session = await getServerSession(authOptions); + if (!session?.user?.isAdmin) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + try { + const body = await request.json(); + const { alias, name, password, isAdmin, isTeamLeader } = body; + + if (!alias || !name || !password) { + return NextResponse.json( + { error: "alias, name, and password are required" }, + { status: 400 }, + ); + } + + if (alias.length > 32 || name.length > 48) { + return NextResponse.json( + { error: "alias (max 32) and name (max 48) constraints violated" }, + { status: 400 }, + ); + } + + // Check if alias already exists + const existingUser = await prisma.user.findUnique({ + where: { alias }, + }); + + if (existingUser) { + return NextResponse.json( + { error: "Alias already exists" }, + { status: 409 }, + ); + } + + // Note: Password hashing should be done here with bcryptjs + // For now, we'll accept raw password (should implement hashing in production) + const user = await prisma.user.create({ + data: { + alias, + name, + password, // TODO: Hash with bcryptjs before saving + isAdmin: isAdmin || false, + isTeamLeader: isTeamLeader || false, + }, + select: { + id: true, + alias: true, + name: true, + isAdmin: true, + isTeamLeader: true, + createdAt: true, + }, + }); + + return NextResponse.json({ user }, { status: 201 }); + } catch (error) { + console.error("Error creating user:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 }, + ); + } +} diff --git a/src/app/api/game/route.ts b/src/app/api/game/route.ts new file mode 100644 index 0000000..0ffd4ab --- /dev/null +++ b/src/app/api/game/route.ts @@ -0,0 +1,27 @@ +import { NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; + +// GET /api/game - Public endpoint to get game config +export async function GET() { + try { + const config = await prisma.gameConfig.findFirst({ + orderBy: { createdAt: "desc" }, + }); + + return NextResponse.json({ + gameConfig: config + ? { + startTime: config.startTime.toISOString(), + endTime: config.endTime?.toISOString() || null, + isActive: config.isActive, + } + : null, + }); + } catch (error) { + console.error("Error fetching game config:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 }, + ); + } +} diff --git a/src/app/api/rules/route.ts b/src/app/api/rules/route.ts index a08b276..556593f 100644 --- a/src/app/api/rules/route.ts +++ b/src/app/api/rules/route.ts @@ -1,13 +1,21 @@ import { NextResponse } from "next/server"; import { prisma } from "@/lib/prisma"; -// GET /api/rules β€” Get rules from site config +// GET /api/rules - Public endpoint to get competition rules export async function GET() { - const config = await prisma.siteConfig.findUnique({ - where: { key: "rules" }, - }); + try { + const rulesConfig = await prisma.siteConfig.findUnique({ + where: { key: "rules" }, + }); - return NextResponse.json({ - rules: config?.value || "No rules have been configured yet.", - }); + return NextResponse.json({ + rules: rulesConfig?.value || "", + }); + } catch (error) { + console.error("Error fetching rules:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 }, + ); + } } diff --git a/src/app/api/teams/route.ts b/src/app/api/teams/route.ts index 094dffb..73ff14e 100644 --- a/src/app/api/teams/route.ts +++ b/src/app/api/teams/route.ts @@ -77,13 +77,27 @@ export async function POST(request: Request) { // Generate a cryptographically secure invite code (48 bits β†’ 12 hex chars) const code = crypto.randomBytes(6).toString("hex").toUpperCase(); + // Generate a random cyberpunk color + const cyberpunkColors = [ + "#8a2be2", + "#00bfff", + "#ff69b4", + "#39ff14", + "#ff1493", + "#00ffff", + "#ff6347", + "#9370db", + ]; + const color = + cyberpunkColors[Math.floor(Math.random() * cyberpunkColors.length)]; + let team: { id: string; name: string; code: string }; try { // Atomic: if the leader assignment fails (user grabbed a team // concurrently), the team creation rolls back too. team = await prisma.$transaction(async (tx) => { const created = await tx.team.create({ - data: { name: trimmedName, code }, + data: { name: trimmedName, code, color }, }); // Conditional update guards the "already in a team" TOCTOU race diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index e770e4a..9698523 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -1,4 +1,4 @@ -import TerminalOutput from "@/components/TerminalOutput"; +import TerminalOutput from "@/components/terminal/TerminalOutput"; import { getServerSession } from "next-auth"; import { authOptions } from "@/lib/auth"; import { redirect } from "next/navigation"; diff --git a/src/app/globals.css b/src/app/globals.css index bab0b6a..5330535 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -13,6 +13,8 @@ --border: rgba(255, 255, 255, 0.2); --border-hover: rgba(255, 255, 255, 0.6); --accent: #4a90e2; + --admin-tab-accent: #8a2be2; + --admin-tab-active-fg: #000000; --danger: #ff0000; --success: #00ff00; --warning: #ffbd2e; diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 5a54b45..94acac3 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -4,7 +4,7 @@ import "./globals.css"; import { getServerSession } from "next-auth"; import { authOptions } from "@/lib/auth"; import Providers from "@/components/Providers"; -import TerminalWindow from "@/components/TerminalWindow"; +import TerminalWindow from "@/components/terminal/TerminalWindow"; import Background3D from "@/components/Background3D"; import { Toaster } from "react-hot-toast"; import { FaCheck, FaTimes } from "react-icons/fa"; @@ -36,7 +36,7 @@ export default async function RootLayout({ - {children} + {children} ([]); - const [loading, setLoading] = useState(true); - - // New challenge form - const [showForm, setShowForm] = useState(false); - const [form, setForm] = useState({ - title: "", - description: "", - points: 100, - flag: "", - category: "web", - difficulty: "easy", - link: "", - }); - const [creating, setCreating] = useState(false); - - // Game config - const [gameStart, setGameStart] = useState(""); - const [gameEnd, setGameEnd] = useState(""); - - // Announcement - const [announcementTitle, setAnnouncementTitle] = useState(""); - const [announcementContent, setAnnouncementContent] = useState(""); - - // Suggestions for the category input: the usual set plus anything already - // in use, so custom categories are reusable without editing code. - const categoryOptions = Array.from( - new Set([ - "web", - "crypto", - "pwn", - "forensics", - "reverse", - "misc", - ...challenges.map((c) => c.category), - ]), - ); - - useEffect(() => { - if (status === "unauthenticated") router.push("/auth/signin"); - if (status === "authenticated" && !session?.user?.isAdmin) router.push("/"); - }, [status, session, router]); - - const loadChallenges = useCallback(async () => { - try { - const res = await fetch("/api/admin/challenges"); - const data = await res.json(); - setChallenges(data.challenges || []); - } catch { - toast.error("Failed to load challenges"); - } finally { - setLoading(false); - } - }, []); - - useEffect(() => { - if (session?.user?.isAdmin) loadChallenges(); - }, [session, loadChallenges]); - - const createChallenge = async (e: React.FormEvent) => { - e.preventDefault(); - setCreating(true); - try { - const res = await fetch("/api/admin/challenges", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(form), - }); - const data = await res.json(); - if (!res.ok) { - toast.error(data.error); - return; - } - toast.success(`Challenge "${form.title}" created`); - setShowForm(false); - setForm({ - title: "", - description: "", - points: 100, - flag: "", - category: "web", - difficulty: "easy", - link: "", - }); - loadChallenges(); - } catch { - toast.error("Failed to create challenge"); - } finally { - setCreating(false); - } - }; - - const toggleChallenge = async (id: string, isActive: boolean) => { - try { - await fetch(`/api/admin/challenges/${id}`, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ isActive: !isActive }), - }); - loadChallenges(); - toast.success(`Challenge ${!isActive ? "enabled" : "disabled"}`); - } catch { - toast.error("Failed to update challenge"); - } - }; - - const deleteChallenge = async (id: string, title: string) => { - if (!confirm(`Delete "${title}"? This cannot be undone.`)) return; - try { - await fetch(`/api/admin/challenges/${id}`, { method: "DELETE" }); - loadChallenges(); - toast.success("Challenge deleted"); - } catch { - toast.error("Failed to delete challenge"); - } - }; - - const updateGameConfig = async (e: React.FormEvent) => { - e.preventDefault(); - try { - const res = await fetch("/api/admin/game", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - startTime: gameStart, - endTime: gameEnd || null, - }), - }); - if (!res.ok) { - toast.error("Failed to update game config"); - return; - } - toast.success("Game config updated"); - } catch { - toast.error("Network error"); - } - }; - - const createAnnouncement = async (e: React.FormEvent) => { - e.preventDefault(); - try { - const res = await fetch("/api/admin/announcements", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - title: announcementTitle, - content: announcementContent, - }), - }); - if (!res.ok) { - toast.error("Failed to create announcement"); - return; - } - toast.success("Announcement published"); - setAnnouncementTitle(""); - setAnnouncementContent(""); - } catch { - toast.error("Network error"); - } - }; - - if (status === "loading" || loading) { - return ( -
Loading...
- ); - } - - if (!session?.user?.isAdmin) return null; - - return ( -
-

- Admin Panel -

- - {/* Game Config */} -
-

- Game Configuration -

-
-
- - setGameStart(e.target.value)} - required - style={{ width: "200px" }} - /> -
-
- - setGameEnd(e.target.value)} - style={{ width: "200px" }} - /> -
- -
-
- - {/* Announcements */} -
-

- New Announcement -

-
- setAnnouncementTitle(e.target.value)} - placeholder="Title" - required - maxLength={100} - style={{ marginBottom: "8px" }} - /> -