diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..272c111 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,25 @@ +node_modules +.next +.next_old +.next_old/* +.git +.gitignore +.github +.vscode +.idea +docs +nginx +scripts/dev-scripts-*.sh +*.log +*.md +!vitest.config.ts +.env +.env.local +.env*.local +coverage +*.tsbuildinfo +tt.md +Dockerfile +Dockerfile.dev +docker-compose.yaml +docker-compose.dev.yaml diff --git a/.env.example b/.env.example index 0a7c50d..699bcfc 100644 --- a/.env.example +++ b/.env.example @@ -19,4 +19,13 @@ S3_BUCKET_NAME=monuments-images # App NEXT_PUBLIC_APP_URL=https://testbed.mk -NEXT_PUBLIC_APP_DOMAIN=testbed.mk \ No newline at end of file +NEXT_PUBLIC_APP_DOMAIN=testbed.mk + +# Admin +# 64+ random hex chars. Generate with: openssl rand -hex 32 +ADMIN_SESSION_SECRET=your-random-64-char-secret-here-change-it-in-production + +# Super-admin (stored as bcrypt hash, NOT plaintext). Generate with: +# node -e "import('bcryptjs').then(b => b.default.hash('YOUR_PASSWORD', 12).then(console.log))" +SUPER_ADMIN_USERNAME=super +SUPER_ADMIN_PASSWORD_HASH=$2a$12$REPLACE_WITH_BCRYPT_HASH_OF_YOUR_PASSWORD \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..d0e7720 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,54 @@ +name: CI + +on: + push: + branches: [main, admin] + pull_request: + branches: [main, admin] + +jobs: + build: + runs-on: ubuntu-latest + timeout-minutes: 15 + + env: + ADMIN_SESSION_SECRET: ${{ secrets.ADMIN_SESSION_SECRET || '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef' }} + SUPER_ADMIN_USERNAME: super + SUPER_ADMIN_PASSWORD_HASH: ${{ secrets.SUPER_ADMIN_PASSWORD_HASH || '$2a$12$abcdefghijklmnopqrstuv' }} + DATABASE_URL: postgresql://postgres:postgres@localhost:5432/ci + NEXT_PUBLIC_APP_DOMAIN: ci.test + NEXT_PUBLIC_APP_URL: https://ci.test + NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: pk_test_placeholder + CLERK_SECRET_KEY: sk_test_placeholder + S3_ENDPOINT: https://s3.example.com + S3_REGION: eu-2 + S3_ACCESS_KEY_ID: placeholder + S3_SECRET_ACCESS_KEY: placeholder + S3_BUCKET_NAME: ci-bucket + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Generate Prisma client + run: npx prisma generate + + - name: Typecheck + run: npm run typecheck + + - name: Lint + run: npm run lint + continue-on-error: true + + - name: Tests + run: npm test + + - name: Build + run: npm run build diff --git a/.gitignore b/.gitignore index 7aad498..db40c87 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,7 @@ yarn-error.log* # env files .env .env*.local +.env.superadmin # vercel .vercel @@ -37,4 +38,4 @@ yarn-error.log* next-env.d.ts # docker -certbot/ \ No newline at end of file +/.next_old/ diff --git a/Dockerfile b/Dockerfile index 104315e..dc6b5f5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,7 +10,14 @@ FROM base AS builder WORKDIR /app RUN apk add --no-cache openssl COPY --from=deps /app/node_modules ./node_modules -COPY . . +COPY package.json package-lock.json ./ +COPY next.config.ts ./ +COPY tsconfig.json ./ +COPY postcss.config.mjs ./ +COPY eslint.config.mjs ./ +COPY prisma ./prisma +COPY src ./src +COPY public ./public RUN npx prisma generate RUN npm run build @@ -18,7 +25,7 @@ FROM base AS runner WORKDIR /app ENV NODE_ENV=production -RUN apk add --no-cache openssl +RUN apk add --no-cache openssl wget RUN addgroup --system --gid 1001 nodejs RUN adduser --system --uid 1001 nextjs @@ -27,7 +34,13 @@ COPY --from=builder /app/public ./public COPY --from=builder /app/.next/standalone ./ COPY --from=builder /app/.next/static ./.next/static COPY --from=builder /app/prisma ./prisma -COPY --from=builder /app/node_modules ./node_modules +COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma +COPY --from=builder /app/node_modules/@prisma ./node_modules/@prisma + +# The `prisma` CLI is not part of the standalone trace, and `npx prisma` +# fails non-interactively in the runner. Install it (pinned to the project's +# Prisma version) so `scripts/start.sh` can run `prisma migrate deploy`. +RUN npm install --no-save --no-audit --no-fund prisma@5.22.0 COPY scripts/start.sh /app/start.sh RUN chmod +x /app/start.sh @@ -40,4 +53,7 @@ EXPOSE 3000 ENV PORT=3000 ENV HOSTNAME="0.0.0.0" -CMD ["/bin/sh", "/app/start.sh"] \ No newline at end of file +HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \ + CMD wget --quiet --spider http://localhost:3000/api/check-subdomain?slug=__health || exit 1 + +CMD ["/bin/sh", "/app/start.sh"] diff --git a/docker-compose.dev.yaml b/docker-compose.dev.yaml index bc78c29..eb9c02c 100644 --- a/docker-compose.dev.yaml +++ b/docker-compose.dev.yaml @@ -23,6 +23,7 @@ services: restart: unless-stopped env_file: - .env + - .env.superadmin environment: DATABASE_URL: postgresql://postgres:postgres@db:5432/monuments ports: diff --git a/README.md b/docs/README.md similarity index 100% rename from README.md rename to docs/README.md diff --git a/docs/admin.md b/docs/admin.md new file mode 100644 index 0000000..dce4bbe --- /dev/null +++ b/docs/admin.md @@ -0,0 +1,59 @@ +# Admin flow + +## Roles + +- **SUPER_ADMIN**: provisioned from environment. Has full power — creates + and deletes regular admins, generates codes, resets passwords. +- **ADMIN**: created by a SUPER_ADMIN via the admin panel. Can only + generate and delete their own codes. + +## Authentication + +The admin layer uses a **custom HMAC-signed cookie**, separate from +Clerk (which is for end-users only): + +- `POST /api/admin/login` — verifies credentials, sets an + `admin_session` cookie signed with `ADMIN_SESSION_SECRET`. +- `POST /api/admin/logout` — clears the cookie. +- `src/lib/admin-session.ts` — sign/verify helpers, plus + `requireAdmin` / `requireAdminPost` / `requireSuperAdminPost` + guards that handle auth + same-origin (CSRF) checks for admin + API routes. +- All admin POST routes run through `requireAdminPost` (or + `requireSuperAdminPost`), which enforces `Origin`/`Referer`/`Host` + matching `NEXT_PUBLIC_APP_URL`. + +## Super-admin provisioning + +Username and bcrypt-hashed password are read from env: + +- `SUPER_ADMIN_USERNAME` (default `super`) +- `SUPER_ADMIN_PASSWORD_HASH` — bcrypt hash, NOT plaintext. Generate + with: + ```sh + node -e "import('bcryptjs').then(b => b.default.hash('YOUR_PASSWORD', 12).then(console.log))" + ``` + +Because `Code.createdById` is a non-nullable FK to `AdminUser`, the +env super-admin also needs a row in `AdminUser`. Provisioning is +handled by `prisma/seed.cjs`, which upserts the SUPER_ADMIN row from +the same env values. Run `npm run db:seed` after starting the DB; the +container's `scripts/start.sh` also runs it automatically on every +boot (after `prisma migrate deploy`). + +## Codes + +- A `Code` is a 12-char uppercase hex string generated by `POST /api/admin/codes`. +- Users validate a code via `POST /api/validate-code`. The claim is + atomic (uses `updateMany` with `usedByUserId: null` guard) so a code + can't be claimed by two concurrent requests. +- `Code.createdById` is a non-nullable FK to `AdminUser` with + `onDelete: Restrict` (Prisma default). You cannot delete an admin who + has issued codes — they audit-protect the code trail. + +## Rate limiting + brute-force defence + +- `/api/admin/login`: 5 attempts/min per IP (`adminLoginLimiter`). +- `/api/validate-code`: 20 attempts/min per IP (`validateCodeLimiter`). +- Other admin mutating routes are also CSRF-guarded. +- All limiters live in `src/lib/rate-limit.ts`. diff --git a/docs/adminImplem.md b/docs/adminImplem.md new file mode 100644 index 0000000..2248bbb --- /dev/null +++ b/docs/adminImplem.md @@ -0,0 +1,282 @@ +# SuperAdmin & Admin + Code Access System — Implementation Plan + +> ## ⚠ Historical implementation plan — see `docs/admin.md` for current state +> +> This was the original plan doc. Several specifics differ from the +> actual implementation (`src/lib/admin-session.ts`, +> `src/app/api/admin/*`, `src/app/admin/**`): +> +> - Cookie **path**: planned as `/admin`; shipped as `/` (so admin +> API routes under `/api/admin/*` receive the cookie). +> - **Super-admin credentials**: planned as hardcoded `super`/`admin`; +> shipped from env (`SUPER_ADMIN_USERNAME` + `SUPER_ADMIN_PASSWORD_HASH`, +> bcrypt) — see `docs/admin.md`. +> - **Super-admin row in `AdminUser`**: planned as optional seed; +> required by the `Code.createdById` FK and provisioned by +> `prisma/seed.cjs` (run by `scripts/start.sh` on boot). +> - **Rate limiting, CSRF/Origin checks, password complexity, atomic +> code claim** (updateMany guard): all added in Phases 1–6 and not +> in the original plan. +> - All admin API routes now funnel through `requireAdmin` / +> `requireAdminPost` / `requireSuperAdminPost` in +> `src/lib/admin-session.ts`; the rolling-helpers in +> `admin-session.ts` supersede the inline `getAdminSession()` checks +> described per-route in this top-of-the-doc walkthrough. + +## Overview + +Implement SuperAdmin/admin role management and code-gated memorial creation as outlined in `admin.md`. + +- SuperAdmin: hardcoded username `super`, password `admin` +- SuperAdmin creates admin accounts +- Admins generate access codes +- Users must enter a valid code during onboarding to create memories + +--- + +## Phase 1 — Database & Dependencies + +### New Prisma Models (`prisma/schema.prisma`) + +```prisma +enum Role { + SUPER_ADMIN + ADMIN +} + +model AdminUser { + id String @id @default(cuid()) + username String @unique + passwordHash String + role Role @default(ADMIN) + createdAt DateTime @default(now()) + createdCodes Code[] +} + +model Code { + id String @id @default(cuid()) + code String @unique + createdById String + createdBy AdminUser @relation(fields: [createdById], references: [id]) + usedByUserId String? // User.clerkId + usedAt DateTime? + createdAt DateTime @default(now()) + expiresAt DateTime? + + @@index([code]) + @@index([usedByUserId]) +} +``` + +### New Dependencies + +- `bcryptjs` + `@types/bcryptjs` — hash admin passwords + +### Seed SuperAdmin + +Seed script or migration that creates the SuperAdmin `AdminUser` record with hashed password. However, the login itself checks hardcoded `super`/`admin` first (and also queries DB by role for token auth), so the seed is optional — used mainly for listing in the admin panel. + +### Migration + +`npx prisma migrate dev --name add_admin_and_code` + +--- + +## Phase 2 — Admin Auth + +Admin auth is separate from Clerk. Uses a signed HMAC cookie. + +### `src/lib/admin-session.ts` (new) + +Helpers: + +- `createAdminSession(username: string, role: Role): string` — sign a cookie value with HMAC-SHA256 using `ADMIN_SESSION_SECRET` env var +- `verifyAdminSession(token: string): { username: string; role: Role } | null` — verify and decode +- `getAdminSession(): { username: string; role: Role } | null` — read from `request.cookies` or `cookies()` +- Cookie name: `admin_session` + +Payload: `{ username, role, iat }` serialized + HMAC signature. + +### `src/app/api/admin/login/route.ts` (new) + +- POST: accept `{ username, password }` +- If `username === "super"` and `password === "admin"` → set session with role `SUPER_ADMIN` +- Else query `AdminUser` where `username === username`, compare with `bcrypt.compare` +- Return `{ success: true }` and set `admin_session` cookie (httpOnly, secure, sameSite=lax, path=/admin) +- On failure: `401` + +### `src/app/api/admin/logout/route.ts` (new) + +- POST: clear `admin_session` cookie + +### `src/app/api/admin/change-password/route.ts` (new) + +- POST: accept `{ currentPassword, newPassword }` +- Verify admin session, then verify current password against DB +- Hash new password, update `AdminUser` record + +### `src/app/admin/login/page.tsx` (new) + +- Macedonian UI: username/password form +- On submit → `POST /api/admin/login` +- On success → redirect to `/admin/dashboard` +- Show error on failure + +### `src/middleware.ts` (modify) + +- Add `/admin(.*)` and `/api/admin(.*)` to the Clerk exclude list +- Before Clerk middleware runs: if path starts with `/admin`, check admin session cookie + - No/invalid cookie → redirect to `/admin/login` + - Valid → allow +- If path starts with `/api/admin`, check admin session cookie + - No/invalid cookie → return `401` + +--- + +## Phase 3 — Admin Panel + +All UI in Macedonian. Layout with sidebar navigation. + +### `src/app/admin/layout.tsx` (new) + +- Checks admin session (server component) +- Redirects to `/admin/login` if not authenticated +- Provides sidebar with links: Dashboard, Users (SuperAdmin only), Codes +- Logout button + +### `src/app/admin/dashboard/page.tsx` (new) + +- Stats cards: + - Total AdminUsers count + - Total Codes generated + - Codes used vs unused +- Simple overview + +### `src/app/admin/users/page.tsx` (new) + +- Accessible only to `SUPER_ADMIN` +- Table of admin users (username, role, created at) +- Button to create new admin (modal/page with username + password fields) +- Button to delete admin (with confirmation) +- Inline password reset option + +### `src/app/admin/users/create/page.tsx` or modal (new) + +- Form: username, password (with confirmation) +- POST to `/api/admin/users/` + +### `src/app/admin/codes/page.tsx` (new) + +- "Generate Code" button → POST `/api/admin/codes` → returns new code string, displays it +- Table of all codes: code value, who created it, status (used/unused), used by, used at +- Admin can see their own codes. SuperAdmin sees all. +- Delete code button + +### `src/app/api/admin/users/route.ts` (new) + +- GET: list all `AdminUser` (SuperAdmin only) +- POST: create `AdminUser` with hashed password (SuperAdmin only) + +### `src/app/api/admin/users/[id]/route.ts` (new) + +- DELETE: remove `AdminUser` (SuperAdmin only) +- PUT: reset password (SuperAdmin only) + +### `src/app/api/admin/codes/route.ts` (new) + +- GET: list codes (Admins see their own, SuperAdmin sees all) +- POST: generate a new code (`crypto.randomBytes(6).toString('hex').toUpperCase()` → 12 chars), store with `createdById` from session + +### `src/app/api/admin/codes/[id]/route.ts` (new) + +- DELETE: remove unused code + +--- + +## Phase 4 — Code Gating + +### `src/app/api/validate-code/route.ts` (new) + +- Requires Clerk auth (`auth()` from `@clerk/nextjs/server`) +- POST: `{ code: string }` +- Look up `Code` where `code === code` and `usedByUserId === null` +- If not found → `{ valid: false, error: "Невалиден или веќе искористен код" }` +- If found → update `usedByUserId = userId`, `usedAt = now()` +- Return `{ valid: true }` + +### `src/app/onboarding/page.tsx` (modify) + +- Add `"Код"` as step 0 (before "Податоци") +- STEPS becomes: `["Код", "Податоци", "Датуми", "Фотографии", "Поддомен", "Шаблон"]` +- Step 0: single input for code + "Потврди" button + - Calls `POST /api/validate-code` + - On success → shows green checkmark, enables "Продолжи" + - On failure → shows error, blocks progression +- Once validated, user can proceed to step 1 +- `canProceed()` for step 0 returns true only if code validated successfully + +### `src/app/api/publish/route.ts` (modify) + +- After Clerk auth check, query: `prisma.code.findFirst({ where: { usedByUserId: userId } })` +- If no code found → return `403` with `"Потребен е валиден код за креирање спомен страница"` + +--- + +## Phase 5 — Environment & Build + +### New Env Variable + +``` +ADMIN_SESSION_SECRET= +``` + +Add to `.env.example` and `docker-compose` files. + +### Build & Verify + +```bash +npm install bcryptjs +npm install -D @types/bcryptjs +npx prisma migrate dev --name add_admin_and_code +npm run build +``` + +Verify: +- [ ] Admin login at `/admin/login` with `super`/`admin` +- [ ] Create admin user from admin panel +- [ ] Login as created admin +- [ ] Generate code from admin panel +- [ ] Sign up as new user via Clerk +- [ ] Enter code in onboarding step 0 +- [ ] Proceed through onboarding and publish +- [ ] Verify publish fails without valid code + +--- + +## File Change Summary + +### New Files +| Path | Purpose | +|------|---------| +| `src/lib/admin-session.ts` | HMAC cookie helpers | +| `src/app/admin/login/page.tsx` | Admin login form | +| `src/app/admin/layout.tsx` | Admin layout with sidebar | +| `src/app/admin/dashboard/page.tsx` | Dashboard stats | +| `src/app/admin/users/page.tsx` | Admin user management | +| `src/app/admin/codes/page.tsx` | Code generation & listing | +| `src/app/api/admin/login/route.ts` | Admin login API | +| `src/app/api/admin/logout/route.ts` | Admin logout API | +| `src/app/api/admin/change-password/route.ts` | Self-service password change | +| `src/app/api/admin/users/route.ts` | List/create admin users | +| `src/app/api/admin/users/[id]/route.ts` | Delete/reset-password admin user | +| `src/app/api/admin/codes/route.ts` | List/create codes | +| `src/app/api/admin/codes/[id]/route.ts` | Delete code | +| `src/app/api/validate-code/route.ts` | Public code validation | + +### Modified Files +| Path | Change | +|------|--------| +| `prisma/schema.prisma` | Add `AdminUser` and `Code` models | +| `src/middleware.ts` | Exclude `/admin/*` from Clerk, add admin session check | +| `src/app/onboarding/page.tsx` | Add code step (step 0) | +| `src/app/api/publish/route.ts` | Add code usage check before publish | diff --git a/coolify.md b/docs/coolify.md similarity index 90% rename from coolify.md rename to docs/coolify.md index cb400f0..c11afa5 100644 --- a/coolify.md +++ b/docs/coolify.md @@ -109,6 +109,15 @@ S3_BUCKET_NAME=monuments-images NEXT_PUBLIC_APP_URL=https://testbed.mk NEXT_PUBLIC_APP_DOMAIN=testbed.mk +# Admin session signing secret (64+ random hex chars; openssl rand -hex 32) +ADMIN_SESSION_SECRET=your-random-64-char-secret + +# Super-admin — username + BCRYPT HASH (not plaintext). Generate with: +# node -e "import('bcryptjs').then(b => b.default.hash('YOUR_PASSWORD', 12).then(console.log))" +# Coolify UI env vars are passed to the container literally — no `$` escaping needed. +SUPER_ADMIN_USERNAME=super +SUPER_ADMIN_PASSWORD_HASH=$2b$12$REPLACE_WITH_BCRYPT_HASH + # Node NODE_ENV=production ``` @@ -116,6 +125,11 @@ NODE_ENV=production **Important:** - `DATABASE_URL` must point to the Coolify **internal** hostname (`spomeniqr-db`), not `localhost`. - Use your **production** Clerk keys (`pk_live_` / `sk_live_`), not the test ones. +- Use a **different, strong** super-admin password than your local development one. +- The `super` admin row is provisioned automatically on container start by + `scripts/start.sh` (`node prisma/seed.cjs` after migrations). If you instead + use a custom Nixpacks start command (Option A below), run the seed manually + after the first deploy: `npx prisma db seed`. ## Step 5: Configure Domain & Subdomain Routing @@ -315,6 +329,9 @@ npx prisma db push | `S3_BUCKET_NAME` | Yes | S3 bucket name | | `NEXT_PUBLIC_APP_URL` | Yes | `https://testbed.mk` | | `NEXT_PUBLIC_APP_DOMAIN` | Yes | `testbed.mk` | +| `ADMIN_SESSION_SECRET` | Yes | Secret signing admin session cookies (openssl rand -hex 32) | +| `SUPER_ADMIN_USERNAME` | No | Super-admin username (default `super`) | +| `SUPER_ADMIN_PASSWORD_HASH` | No | Super-admin bcrypt hash; if unset, super login is unavailable | | `NODE_ENV` | Yes | `production` | ## Useful Coolify Commands diff --git a/db.md b/docs/db.md similarity index 100% rename from db.md rename to docs/db.md diff --git a/deploy.md b/docs/deploy.md similarity index 87% rename from deploy.md rename to docs/deploy.md index ef62802..35b7b36 100644 --- a/deploy.md +++ b/docs/deploy.md @@ -104,9 +104,24 @@ S3_BUCKET_NAME=monuments-images # App NEXT_PUBLIC_APP_URL=https://testbed.mk NEXT_PUBLIC_APP_DOMAIN=testbed.mk + +# Admin session signing secret (64+ random hex chars; openssl rand -hex 32) +ADMIN_SESSION_SECRET=your-random-64-char-secret + +# Super-admin — username + BCRYPT HASH (not plaintext) of the super-admin password. +# Generate the hash with: +# node -e "import('bcryptjs').then(b => b.default.hash('YOUR_PASSWORD', 12).then(console.log))" +# If you run the app via `docker compose` with these vars in a `.env` env_file, +# you MUST escape every `$` as `$$` (e.g. `$$2b$$12$$...`). Newer Compose +# versions interpolate env_file values and will otherwise strip the `$2b$12` +# prefix, silently breaking super-admin login. If the vars are provided via a +# platform UI (Coolify/Vercel) they are passed literally and need no escaping. +SUPER_ADMIN_USERNAME=super +SUPER_ADMIN_PASSWORD_HASH=$2b$12$REPLACE_WITH_BCRYPT_HASH ``` -**Important**: Use a strong, unique password for `POSTGRES_PASSWORD`. +**Important**: Use a strong, unique password for `POSTGRES_PASSWORD`. Do **not** +reuse the development super-admin password (`Irina@7654321`) in production. ### Create the Prisma Migration @@ -135,6 +150,14 @@ npx prisma migrate dev --name init Then commit the generated migration files. The `scripts/start.sh` entrypoint will run `npx prisma migrate deploy` automatically on every container start. +### Super-admin provisioning + +The `super` admin is provisioned from the environment on **every container +start**: `scripts/start.sh` runs `node prisma/seed.cjs` after migrations. It +upserts the `SUPER_ADMIN_USERNAME` row (role `SUPER_ADMIN`) with the bcrypt hash +from `SUPER_ADMIN_PASSWORD_HASH`. If the hash is unset the seed is skipped +(logged). No manual seeding is required on first deploy. + ## 4. Configure Contabo S3 ### Create the Bucket @@ -461,4 +484,7 @@ docker compose exec app printenv DATABASE_URL | `S3_SECRET_ACCESS_KEY` | Yes | S3 secret key | | `S3_BUCKET_NAME` | Yes | S3 bucket name (monuments-images) | | `NEXT_PUBLIC_APP_URL` | Yes | Public URL (https://testbed.mk) | -| `NEXT_PUBLIC_APP_DOMAIN` | Yes | Domain only (testbed.mk) | \ No newline at end of file +| `NEXT_PUBLIC_APP_DOMAIN` | Yes | Domain only (testbed.mk) | +| `ADMIN_SESSION_SECRET` | Yes | Secret signing admin session cookies (openssl rand -hex 32) | +| `SUPER_ADMIN_USERNAME` | No | Super-admin username (default `super`) | +| `SUPER_ADMIN_PASSWORD_HASH` | No | Super-admin bcrypt hash; if unset, super login is unavailable | \ No newline at end of file diff --git a/description.md b/docs/description.md similarity index 86% rename from description.md rename to docs/description.md index eee8c43..ff26220 100644 --- a/description.md +++ b/docs/description.md @@ -1,5 +1,43 @@ # 🏛️ City Monuments Memories — Platform Architecture & Implementation Plan +> ## ⚠ Historical spec — partially out of sync with the implementation +> +> This document is the **original architecture plan**. The shipped code +> has drifted from it in several ways; treat this as historical design +> context, not current documentation. +> +> Known deltas (see `docs/admin.md` and the source for the source of +> truth): +> +> - **Storage**: described as "UploadThing" + "Vercel Blob"; the +> implementation uses **AWS-compatible S3 (Contabo Object Storage)** +> via `@aws-sdk/client-s3`. There is an in-house presigned-URL helper +> in `src/lib/upload.ts` (currently the upload route still proxies +> through the server with magic-byte validation; presigned direct-to-S3 +> flow is implemented but not yet wired into the client). +> - **File Upload**: 5MB cap, JPEG/PNG/WebP only (GIF was removed). +> - **Auth**: described as "Clerk only". The implementation uses +> **dual-track auth**: Clerk for end-users, plus a custom +> **HMAC-signed cookie** for admins (`src/lib/admin-session.ts`). +> Admin session secret comes from `ADMIN_SESSION_SECRET`; super-admin +> credentials from `SUPER_ADMIN_USERNAME` + `SUPER_ADMIN_PASSWORD_HASH` +> (bcrypt) — previously hardcoded `super`/`admin`, now removed. +> - **Deployment**: described as "Vercel". Actual deploy is **Docker +> (standalone Next.js) behind Traefik (Coolify)**. The `nginx/conf.d/*` +> configs have been deleted. +> - **Subdomain routing**: described as "Vercel Wildcard Domains"; +> middleware parses the `Host` header (not `X-Subdomain`) behind the +> Traefik reverse proxy. +> - **Schema**: see `prisma/schema.prisma` for the current shape. Most +> notably `bornDate`/`passedDate` are deliberately kept as bounded +> `VARCHAR(50)` (imprecise free-text like "1960" or "early 1990s"), +> not `DateTime`. +> - **Rate limiting + CSRF** are now enforced on admin + sensitive +> public routes (see `src/lib/rate-limit.ts`). +> - **QR codes** are rendered in-house via the `qrcode` package, not +> via the external `api.qrserver.com` service. + + ## 1. Tech Stack | Layer | Technology | Why | diff --git a/local.md b/docs/local.md similarity index 100% rename from local.md rename to docs/local.md diff --git a/next.config.ts b/next.config.ts index a1a9f1a..dbc420a 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,19 +1,116 @@ import type { NextConfig } from "next"; +const s3Host = process.env.S3_ENDPOINT + ? new URL(process.env.S3_ENDPOINT).hostname + : ""; + +// Derive the Clerk frontend API host from the publishable key so the +// CSP allowlist always matches the active environment. Clerk supports +// two publishable key formats: +// +// 1) "Encoded" form (older): pk_test_$ +// The base64 portion decodes to ".clerk.accounts.dev" (test) +// or ".clerk.services" (live). Trailing '$' is a separator. +// +// 2) "Readable" form (newer): pk_test_- +// -> .clerk.accounts.dev (test) or .clerk.services. +// The slug may itself contain hyphens and digits, so only the +// final dash-group is captured as the suffix. +function clerkFrontendApiHost(): string | null { + const key = process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY; + if (!key) return null; + + // Form 1: base64-encoded FAPI URL. Match everything between the + // 'pk_test_'/'pk_live_' prefix and an optional trailing '$'. + const enc = key.match(/^pk_(test|live)_([A-Za-z0-9+/=_-]+)\$?$/); + if (enc) { + const b64 = enc[2].replace(/-/g, "+").replace(/_/g, "/"); + if (/^[A-Za-z0-9+/=]+$/.test(b64)) { + try { + const padded = b64 + "=".repeat((4 - (b64.length % 4)) % 4); + const decoded = Buffer.from(padded, "base64").toString("utf8"); + // If the decoded string doesn't look like a Clerk FAPI host + // (e.g. it's garbage from decoding a non-base64 readable-form + // key), fall through to form 2 rather than returning null. + const fapi = decodeFapiHost(decoded); + if (fapi) return fapi; + } catch { + // fall through to form 2 + } + } + // fall through to form 2 if the slug is non-base64 (e.g. readable form) + } + + // Form 2: readable slug + random suffix. + const m = key.match(/^pk_(test|live)_(.+?)-([a-z0-9]+)$/i); + if (!m) return null; + const slug = m[2].toLowerCase(); + return m[1].toLowerCase() === "test" + ? `${slug}.clerk.accounts.dev` + : `${slug}.clerk.services`; +} + +function decodeFapiHost(decoded: string): string | null { + // The decoded string is the FAPI host (e.g. + // 'useful-louse-74.clerk.accounts.dev$'). Note: the encoded base64 + // payload always carries the literal FAPI host regardless of test vs + // live mode — both `pk_test_...` and `pk_live_...` can decode to an + // '.accounts.dev' host when the deployment is on the test endpoint. + // We accept either well-known Clerk FAPI host pattern. + const host = decoded.trim().replace(/\$$/, "").trim().toLowerCase(); + if (host.endsWith(".clerk.accounts.dev") || host.endsWith(".clerk.services")) { + return host; + } + return null; +} + +const clerkFapiHost = clerkFrontendApiHost(); + +const csp = [ + "default-src 'self'", + // Clerk user avatars are served from img.clerk.com; S3 hosts are + // also allowed for memorial uploads. data:/blob: for in-app previews. + "img-src 'self' data: blob: https://img.clerk.com https:", + "font-src 'self' data:", + "style-src 'self' 'unsafe-inline'", + // script-src: must include the Clerk FAPI host because Clerk JS is + // loaded from /npm/@clerk/clerk-js@/dist/clerk.browser.js + "script-src 'self' 'unsafe-inline' 'unsafe-eval'" + + (clerkFapiHost ? ` https://${clerkFapiHost}` : ""), + // connect-src: Clerk JS talks to for all session calls. + "connect-src 'self' https://api.clerk.com" + + (clerkFapiHost ? ` https://${clerkFapiHost} wss://${clerkFapiHost}` : ""), + "frame-ancestors 'self'", +].join("; "); + +const securityHeaders = [ + { key: "Strict-Transport-Security", value: "max-age=63072000; includeSubDomains; preload" }, + { key: "X-Frame-Options", value: "SAMEORIGIN" }, + { key: "X-Content-Type-Options", value: "nosniff" }, + { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" }, + { key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=()" }, + { key: "Content-Security-Policy", value: csp }, +]; + const nextConfig: NextConfig = { output: "standalone", + poweredByHeader: false, + compress: true, images: { remotePatterns: [ + { protocol: "https", hostname: "img.clerk.com" }, + ...(s3Host ? [{ protocol: "https", hostname: s3Host }] : []), + ] as NonNullable["remotePatterns"]>, + }, + async headers() { + return [ { - protocol: "https", - hostname: process.env.S3_ENDPOINT?.replace("https://", "") || "", + source: "/:path*", + headers: securityHeaders, }, - { - protocol: "http", - hostname: process.env.S3_ENDPOINT?.replace("http://", "") || "", - }, - ], + ]; }, }; -export default nextConfig; \ No newline at end of file +export default nextConfig; + diff --git a/nginx/conf.d/default.conf b/nginx/conf.d/default.conf deleted file mode 100644 index 67f5945..0000000 --- a/nginx/conf.d/default.conf +++ /dev/null @@ -1,32 +0,0 @@ -upstream nextjs { - server app:3000; -} - -server { - listen 80; - server_name testbed.mk *.testbed.mk; - - location / { - proxy_pass http://nextjs; - proxy_http_version 1.1; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection 'upgrade'; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - - set $subdomain ""; - if ($host ~* "^([a-z0-9-]+)\.testbed\.mk$") { - set $subdomain $1; - } - - proxy_set_header X-Subdomain $subdomain; - } - - location /_next/static/ { - proxy_pass http://nextjs; - proxy_cache_valid 200 365d; - add_header Cache-Control "public, immutable"; - } -} \ No newline at end of file diff --git a/nginx/conf.d/production.conf b/nginx/conf.d/production.conf deleted file mode 100644 index 2e41486..0000000 --- a/nginx/conf.d/production.conf +++ /dev/null @@ -1,53 +0,0 @@ -upstream nextjs { - server app:3000; -} - -server { - listen 80; - server_name testbed.mk *.testbed.mk; - - location /.well-known/acme-challenge/ { - root /var/www/certbot; - } - - location / { - return 301 https://$host$request_uri; - } -} - -server { - listen 443 ssl; - http2 on; - server_name testbed.mk *.testbed.mk; - - ssl_certificate /etc/letsencrypt/live/testbed.mk/fullchain.pem; - ssl_certificate_key /etc/letsencrypt/live/testbed.mk/privkey.pem; - - ssl_protocols TLSv1.2 TLSv1.3; - ssl_ciphers HIGH:!aNULL:!MD5; - ssl_prefer_server_ciphers on; - - location / { - proxy_pass http://nextjs; - proxy_http_version 1.1; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection 'upgrade'; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - - set $subdomain ""; - if ($host ~* "^([a-z0-9-]+)\.testbed\.mk$") { - set $subdomain $1; - } - - proxy_set_header X-Subdomain $subdomain; - } - - location /_next/static/ { - proxy_pass http://nextjs; - proxy_cache_valid 200 365d; - add_header Cache-Control "public, immutable"; - } -} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index d07935a..1c20a4e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,8 @@ "@aws-sdk/s3-request-presigner": "^3.1073.0", "@clerk/nextjs": "^7.5.7", "@prisma/client": "^5.22.0", + "bcryptjs": "^3.0.3", + "lru-cache": "^11.0.0", "next": "^15.5.19", "qrcode": "^1.5.4", "react": "19.2.4", @@ -20,7 +22,8 @@ }, "devDependencies": { "@tailwindcss/postcss": "^4", - "@types/node": "^20", + "@types/bcryptjs": "^2.4.6", + "@types/node": "^20.19.43", "@types/qrcode": "^1.5.6", "@types/react": "^19", "@types/react-dom": "^19", @@ -29,7 +32,9 @@ "eslint-config-next": "^15.5.19", "prisma": "^5.22.0", "tailwindcss": "^4", - "typescript": "^5" + "tsx": "^4.23.3", + "typescript": "^5", + "vitest": "^2.1.9" } }, "node_modules/@alloc/quick-lru": { @@ -596,6 +601,448 @@ "tslib": "^2.4.0" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.1", "dev": true, @@ -1324,6 +1771,26 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.5", "dev": true, @@ -1623,6 +2090,395 @@ "@prisma/debug": "5.22.0" } }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.4.tgz", + "integrity": "sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.4.tgz", + "integrity": "sha512-JKuJc+pnpks2pjy7L/N3v/cAkZxYlnmuZoD840ldbMI5KDbC4iO9NKwPKYdjYFCMAIIlBzYSFHxIJVYzRo2/8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.4.tgz", + "integrity": "sha512-krw5uS2STmvJ02x0uTXHbqQNuz+9eZ1iw+qXk9dmW2gvV4jV7O2hEoOnuhFrpOPiel1mBFtqbxYZZtC46hXLOw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.4.tgz", + "integrity": "sha512-wsTxtgApb4PrOsNJIm0FZ1h3WvCC+k9uxLJ4ad75hgoS4NiRes2SoJFlDAyMwiUY8IssDqGcHbXuN0sx1tfF1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.4.tgz", + "integrity": "sha512-GUOnQlyZe3yAXhWOtOMsn5Qkrv5E5mZXa0thbARWi5Ei2szlVXJFQhddZ4HbAzh8q92w5twp+CQvs/eFanz9YQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.4.tgz", + "integrity": "sha512-/Y7f3QuxjzPKsjA/rfEDa3+0vXqyjmJ50Ln8dPpCmWkKTrUoWHG1cWhTqaAMLob2m2nESWuC7yGrREz019Ztqg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.4.tgz", + "integrity": "sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.4.tgz", + "integrity": "sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.4.tgz", + "integrity": "sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.4.tgz", + "integrity": "sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.4.tgz", + "integrity": "sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.4.tgz", + "integrity": "sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.4.tgz", + "integrity": "sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.4.tgz", + "integrity": "sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.4.tgz", + "integrity": "sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.4.tgz", + "integrity": "sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.4.tgz", + "integrity": "sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.4.tgz", + "integrity": "sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.4.tgz", + "integrity": "sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.4.tgz", + "integrity": "sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.4.tgz", + "integrity": "sha512-s62SQ/vgsRSvMwDkOEfTqfgASF0f26ZNaQuTA6Aok5lrikf89yI2W0gFHvZb2Jpgc6N8JnOKZgCK2iciO3CsxQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.4.tgz", + "integrity": "sha512-J6wGf8TVGbXJq+HH+ttTvrcfNKPbuZecV6KT1B8I18BC5IURUh5kl4Yl5OEP5eFIUoI5BWxCsyYMhFsDx8kekw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.4.tgz", + "integrity": "sha512-zmfrQd/0wu6oJs8Vq8KwY/YtsKSsLtKe/HwAP4Wqy8LhWjeT55fHRAkOhYQ12wI3ayS4Tt12d5CDRD7N96SAYQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.4.tgz", + "integrity": "sha512-qPzHqdj9rfUD+w79dtE07zi/kFwKyCJqplp5K5ygeLTp7jLpAoc16OAH39HSmRC9UpozaecsleI8uAdEj6v2yw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.4.tgz", + "integrity": "sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@rtsao/scc": { "version": "1.1.0", "dev": true, @@ -2062,6 +2918,13 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/bcryptjs": { + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz", + "integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "dev": true, @@ -2079,6 +2942,8 @@ }, "node_modules/@types/node": { "version": "20.19.43", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", + "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", "dev": true, "license": "MIT", "dependencies": { @@ -2765,6 +3630,119 @@ "win32" ] }, + "node_modules/@vitest/expect": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/acorn": { "version": "8.17.0", "dev": true, @@ -2990,6 +3968,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/ast-types-flow": { "version": "0.0.8", "dev": true, @@ -3038,6 +4026,15 @@ "dev": true, "license": "MIT" }, + "node_modules/bcryptjs": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz", + "integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==", + "license": "BSD-3-Clause", + "bin": { + "bcrypt": "bin/bcrypt" + } + }, "node_modules/bowser": { "version": "2.14.1", "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", @@ -3066,6 +4063,16 @@ "node": ">=8" } }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/call-bind": { "version": "1.0.9", "dev": true, @@ -3145,6 +4152,23 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "dev": true, @@ -3160,6 +4184,16 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, "node_modules/client-only": { "version": "0.0.1", "license": "MIT" @@ -3290,6 +4324,16 @@ "node": ">=0.10.0" } }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/deep-is": { "version": "0.1.4", "dev": true, @@ -3517,6 +4561,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.2", "dev": true, @@ -3571,6 +4622,45 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, "node_modules/escape-string-regexp": { "version": "4.0.0", "dev": true, @@ -3927,6 +5017,16 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "dev": true, @@ -3935,6 +5035,16 @@ "node": ">=0.10.0" } }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "dev": true, @@ -5236,6 +6346,22 @@ "loose-envify": "cli.js" } }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/magic-string": { "version": "0.30.21", "dev": true, @@ -5652,6 +6778,23 @@ "dev": true, "license": "MIT" }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, "node_modules/picocolors": { "version": "1.1.1", "license": "ISC" @@ -5923,6 +7066,52 @@ "node": ">=0.10.0" } }, + "node_modules/rollup": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.4.tgz", + "integrity": "sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.62.4", + "@rollup/rollup-android-arm64": "4.62.4", + "@rollup/rollup-darwin-arm64": "4.62.4", + "@rollup/rollup-darwin-x64": "4.62.4", + "@rollup/rollup-freebsd-arm64": "4.62.4", + "@rollup/rollup-freebsd-x64": "4.62.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.4", + "@rollup/rollup-linux-arm-musleabihf": "4.62.4", + "@rollup/rollup-linux-arm64-gnu": "4.62.4", + "@rollup/rollup-linux-arm64-musl": "4.62.4", + "@rollup/rollup-linux-loong64-gnu": "4.62.4", + "@rollup/rollup-linux-loong64-musl": "4.62.4", + "@rollup/rollup-linux-ppc64-gnu": "4.62.4", + "@rollup/rollup-linux-ppc64-musl": "4.62.4", + "@rollup/rollup-linux-riscv64-gnu": "4.62.4", + "@rollup/rollup-linux-riscv64-musl": "4.62.4", + "@rollup/rollup-linux-s390x-gnu": "4.62.4", + "@rollup/rollup-linux-x64-gnu": "4.62.4", + "@rollup/rollup-linux-x64-musl": "4.62.4", + "@rollup/rollup-openbsd-x64": "4.62.4", + "@rollup/rollup-openharmony-arm64": "4.62.4", + "@rollup/rollup-win32-arm64-msvc": "4.62.4", + "@rollup/rollup-win32-ia32-msvc": "4.62.4", + "@rollup/rollup-win32-x64-gnu": "4.62.4", + "@rollup/rollup-win32-x64-msvc": "4.62.4", + "fsevents": "~2.3.2" + } + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -6204,6 +7393,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/source-map-js": { "version": "1.2.1", "license": "BSD-3-Clause", @@ -6216,6 +7412,13 @@ "dev": true, "license": "MIT" }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, "node_modules/standardwebhooks": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz", @@ -6226,6 +7429,13 @@ "fast-sha256": "^1.3.0" } }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, "node_modules/stop-iteration-iterator": { "version": "1.1.0", "dev": true, @@ -6466,6 +7676,20 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.17", "dev": true, @@ -6508,6 +7732,36 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -6560,6 +7814,458 @@ "version": "2.8.1", "license": "0BSD" }, + "node_modules/tsx": { + "version": "4.23.3", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.3.tgz", + "integrity": "sha512-hahlTkAAf5cqMiD8V2b+UgasXreAb2D1tgcYkLegeacgcDH11fY0gqrFJRzlpBDZkFJrVuPvIVMSc7rvJpHLLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tsx/node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, "node_modules/type-check": { "version": "0.4.0", "dev": true, @@ -6732,6 +8438,155 @@ "uuid": "dist-node/bin/uuid" } }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, "node_modules/which": { "version": "2.0.2", "dev": true, @@ -6833,6 +8688,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "dev": true, diff --git a/package.json b/package.json index 6671070..5429a3d 100644 --- a/package.json +++ b/package.json @@ -7,16 +7,25 @@ "build": "next build", "start": "next start", "lint": "eslint", + "test": "vitest run", + "test:watch": "vitest", + "typecheck": "tsc --noEmit", "db:migrate": "prisma migrate dev", "db:push": "prisma db push", "db:studio": "prisma studio", - "db:generate": "prisma generate" + "db:generate": "prisma generate", + "db:seed": "node prisma/seed.cjs" + }, + "prisma": { + "seed": "node prisma/seed.cjs" }, "dependencies": { "@aws-sdk/client-s3": "^3.1073.0", "@aws-sdk/s3-request-presigner": "^3.1073.0", "@clerk/nextjs": "^7.5.7", "@prisma/client": "^5.22.0", + "bcryptjs": "^3.0.3", + "lru-cache": "^11.0.0", "next": "^15.5.19", "qrcode": "^1.5.4", "react": "19.2.4", @@ -25,7 +34,8 @@ }, "devDependencies": { "@tailwindcss/postcss": "^4", - "@types/node": "^20", + "@types/bcryptjs": "^2.4.6", + "@types/node": "^20.19.43", "@types/qrcode": "^1.5.6", "@types/react": "^19", "@types/react-dom": "^19", @@ -34,6 +44,8 @@ "eslint-config-next": "^15.5.19", "prisma": "^5.22.0", "tailwindcss": "^4", - "typescript": "^5" + "tsx": "^4.23.3", + "typescript": "^5", + "vitest": "^2.1.9" } } diff --git a/prisma/migrations/20260729165425_add_admin_and_code/migration.sql b/prisma/migrations/20260729165425_add_admin_and_code/migration.sql new file mode 100644 index 0000000..9e78a62 --- /dev/null +++ b/prisma/migrations/20260729165425_add_admin_and_code/migration.sql @@ -0,0 +1,40 @@ +-- CreateEnum +CREATE TYPE "Role" AS ENUM ('SUPER_ADMIN', 'ADMIN'); + +-- CreateTable +CREATE TABLE "AdminUser" ( + "id" TEXT NOT NULL, + "username" TEXT NOT NULL, + "passwordHash" TEXT NOT NULL, + "role" "Role" NOT NULL DEFAULT 'ADMIN', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "AdminUser_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Code" ( + "id" TEXT NOT NULL, + "code" TEXT NOT NULL, + "createdById" TEXT NOT NULL, + "usedByUserId" TEXT, + "usedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "Code_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "AdminUser_username_key" ON "AdminUser"("username"); + +-- CreateIndex +CREATE UNIQUE INDEX "Code_code_key" ON "Code"("code"); + +-- CreateIndex +CREATE INDEX "Code_code_idx" ON "Code"("code"); + +-- CreateIndex +CREATE INDEX "Code_usedByUserId_idx" ON "Code"("usedByUserId"); + +-- AddForeignKey +ALTER TABLE "Code" ADD CONSTRAINT "Code_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "AdminUser"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/migrations/20260802000000_phase6_schema_hardening/migration.sql b/prisma/migrations/20260802000000_phase6_schema_hardening/migration.sql new file mode 100644 index 0000000..b4815f6 --- /dev/null +++ b/prisma/migrations/20260802000000_phase6_schema_hardening/migration.sql @@ -0,0 +1,43 @@ +-- Phase 6 schema hardening +-- Adds @db.VarChar() length bounds to all string columns, updatedAt to +-- Image/AdminUser/Code, an index on Image.key, and documents the +-- existing Code.createdById onDelete:RESTRICT behaviour with a schema +-- comment (no DDL change there; it was already RESTRICT by default). + +-- === User: add VarChar bounds === +ALTER TABLE "User" ALTER COLUMN "id" SET DATA TYPE VARCHAR(30); +ALTER TABLE "User" ALTER COLUMN "clerkId" SET DATA TYPE VARCHAR(100); +ALTER TABLE "User" ALTER COLUMN "email" SET DATA TYPE VARCHAR(255); +ALTER TABLE "User" ALTER COLUMN "name" SET DATA TYPE VARCHAR(100); +ALTER TABLE "User" ALTER COLUMN "subdomain" SET DATA TYPE VARCHAR(32); +ALTER TABLE "User" ALTER COLUMN "title" SET DATA TYPE VARCHAR(100); +ALTER TABLE "User" ALTER COLUMN "description" SET DATA TYPE VARCHAR(2000); +ALTER TABLE "User" ALTER COLUMN "bornDate" SET DATA TYPE VARCHAR(50); +ALTER TABLE "User" ALTER COLUMN "passedDate" SET DATA TYPE VARCHAR(50); + +-- === Image: bounds + updatedAt + index on key === +ALTER TABLE "Image" ALTER COLUMN "id" SET DATA TYPE VARCHAR(30); +ALTER TABLE "Image" ALTER COLUMN "url" SET DATA TYPE VARCHAR(255); +ALTER TABLE "Image" ALTER COLUMN "key" SET DATA TYPE VARCHAR(255); +ALTER TABLE "Image" ALTER COLUMN "userId" SET DATA TYPE VARCHAR(30); +ALTER TABLE "Image" ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP; +CREATE INDEX "Image_key_idx" ON "Image"("key"); + +-- === AdminUser: bounds + updatedAt === +ALTER TABLE "AdminUser" ALTER COLUMN "id" SET DATA TYPE VARCHAR(30); +ALTER TABLE "AdminUser" ALTER COLUMN "username" SET DATA TYPE VARCHAR(50); +ALTER TABLE "AdminUser" ALTER COLUMN "passwordHash" SET DATA TYPE VARCHAR(100); +ALTER TABLE "AdminUser" ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP; + +-- === Code: bounds + updatedAt === +ALTER TABLE "Code" ALTER COLUMN "id" SET DATA TYPE VARCHAR(30); +ALTER TABLE "Code" ALTER COLUMN "code" SET DATA TYPE VARCHAR(12); +ALTER TABLE "Code" ALTER COLUMN "createdById" SET DATA TYPE VARCHAR(30); +ALTER TABLE "Code" ALTER COLUMN "usedByUserId" SET DATA TYPE VARCHAR(100); +ALTER TABLE "Code" ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP; + +-- Backfill any rows whose string columns exceed the new bounds (defensive; +-- production data was capped at the maxLength on the input side already but +-- the persistence layer was unbounded). Truncating is safer than failing. +UPDATE "User" SET "bornDate" = LEFT("bornDate", 50) WHERE "bornDate" IS NOT NULL AND LENGTH("bornDate") > 50; +UPDATE "User" SET "passedDate" = LEFT("passedDate", 50) WHERE "passedDate" IS NOT NULL AND LENGTH("passedDate") > 50; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index bcc8fb0..c7e2b90 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -9,30 +9,67 @@ datasource db { } model User { - id String @id @default(cuid()) - clerkId String @unique - email String? - name String? - subdomain String @unique - templateId Int @default(1) - title String? - description String? - bornDate String? - passedDate String? - published Boolean @default(false) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(cuid()) @db.VarChar(30) + clerkId String @unique @db.VarChar(100) + email String? @db.VarChar(255) + name String? @db.VarChar(100) + subdomain String @unique @db.VarChar(32) + templateId Int @default(1) + title String? @db.VarChar(100) + description String? @db.VarChar(2000) + // Free-form text — intentionally accepts imprecise values like "1960" + // or "early 1990s", not a parseable date. If structured date queries + // become needed, add a parallel bornDateParsed DateTime? column. + bornDate String? @db.VarChar(50) + passedDate String? @db.VarChar(50) + published Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt images Image[] } model Image { - id String @id @default(cuid()) - url String - key String + id String @id @default(cuid()) @db.VarChar(30) + url String @db.VarChar(255) + key String @db.VarChar(255) order Int - userId String + userId String @db.VarChar(30) user User @relation(fields: [userId], references: [id], onDelete: Cascade) createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@index([userId]) -} \ No newline at end of file + @@index([key]) +} + +enum Role { + SUPER_ADMIN + ADMIN +} + +model AdminUser { + id String @id @default(cuid()) @db.VarChar(30) + username String @unique @db.VarChar(50) + passwordHash String @db.VarChar(100) + role Role @default(ADMIN) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + createdCodes Code[] +} + +model Code { + id String @id @default(cuid()) @db.VarChar(30) + code String @unique @db.VarChar(12) + // onDelete: Restrict (Prisma default) — preventing deletion of an + // AdminUser that has issued codes is intentional; we don't want + // orphaned codes with no audit trail of who created them. + createdById String @db.VarChar(30) + createdBy AdminUser @relation(fields: [createdById], references: [id]) + usedByUserId String? @db.VarChar(100) + usedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([code]) + @@index([usedByUserId]) +} diff --git a/prisma/seed.cjs b/prisma/seed.cjs new file mode 100644 index 0000000..7ece68f --- /dev/null +++ b/prisma/seed.cjs @@ -0,0 +1,42 @@ +const { PrismaClient, Role } = require("@prisma/client"); + +const prisma = new PrismaClient(); + +async function main() { + const username = process.env.SUPER_ADMIN_USERNAME || "super"; + const hash = process.env.SUPER_ADMIN_PASSWORD_HASH; + + if (!hash) { + console.warn( + "[seed] SUPER_ADMIN_PASSWORD_HASH env not set; skipping super-admin provisioning." + ); + console.warn( + "[seed] Generate one with: node -e \"import('bcryptjs').then(b => b.default.hash('YOUR_PASSWORD', 12).then(console.log))\"" + ); + return; + } + + await prisma.adminUser.upsert({ + where: { username }, + update: { + passwordHash: hash, + role: Role.SUPER_ADMIN, + }, + create: { + username, + passwordHash: hash, + role: Role.SUPER_ADMIN, + }, + }); + + console.log(`[seed] Super-admin '${username}' provisioned.`); +} + +main() + .catch((e) => { + console.error("[seed] failed:", e); + process.exit(1); + }) + .finally(async () => { + await prisma.$disconnect(); + }); diff --git a/scripts/start.sh b/scripts/start.sh index 5959c37..82ee6b8 100644 --- a/scripts/start.sh +++ b/scripts/start.sh @@ -9,9 +9,13 @@ echo "CLERK_SECRET_KEY: ${CLERK_SECRET_KEY:+set}" echo "S3_ENDPOINT: ${S3_ENDPOINT:+set}" echo "Running Prisma migrations..." -npx prisma migrate deploy || { - echo "WARNING: Prisma migrations failed, continuing anyway..." -} +if ! npx prisma migrate deploy; then + echo "ERROR: Prisma migrations failed. Aborting start." + exit 1 +fi + +echo "Provisioning super-admin (skips if SUPER_ADMIN_PASSWORD_HASH unset)..." +node prisma/seed.cjs echo "Starting Next.js server on 0.0.0.0:3000..." exec node server.js \ No newline at end of file diff --git a/src/app/admin/(auth)/login/page.tsx b/src/app/admin/(auth)/login/page.tsx new file mode 100644 index 0000000..34e47f0 --- /dev/null +++ b/src/app/admin/(auth)/login/page.tsx @@ -0,0 +1,78 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; + +export default function AdminLoginPage() { + const router = useRouter(); + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(""); + const [loading, setLoading] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setLoading(true); + setError(""); + try { + const res = await fetch("/api/admin/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username, password }), + }); + const data = await res.json(); + if (!res.ok) { + throw new Error(data.error || "Најавата не успеа"); + } + router.push("/admin/dashboard"); + } catch (err) { + setError(err instanceof Error ? err.message : "Најавата не успеа"); + } finally { + setLoading(false); + } + }; + + return ( +
+
+

Администрација

+
+
+ + setUsername(e.target.value)} + className="mt-1 block w-full rounded-lg border border-stone-200 px-3 py-2.5 text-stone-900 placeholder:text-stone-400 focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary" + autoComplete="username" + /> +
+
+ + setPassword(e.target.value)} + className="mt-1 block w-full rounded-lg border border-stone-200 px-3 py-2.5 text-stone-900 placeholder:text-stone-400 focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary" + autoComplete="current-password" + /> +
+ {error &&

{error}

} + +
+
+
+ ); +} diff --git a/src/app/admin/(panel)/AdminSidebar.tsx b/src/app/admin/(panel)/AdminSidebar.tsx new file mode 100644 index 0000000..372a100 --- /dev/null +++ b/src/app/admin/(panel)/AdminSidebar.tsx @@ -0,0 +1,57 @@ +"use client"; + +import Link from "next/link"; +import { useRouter, usePathname } from "next/navigation"; + +interface Props { + username: string; + role: "SUPER_ADMIN" | "ADMIN"; +} + +export default function AdminSidebar({ username, role }: Props) { + const router = useRouter(); + const pathname = usePathname(); + + const handleLogout = async () => { + await fetch("/api/admin/logout", { method: "POST", credentials: "include" }); + router.push("/admin/login"); + }; + + const links = [ + { href: "/admin/dashboard", label: "Контролна табла" }, + ...(role === "SUPER_ADMIN" ? [{ href: "/admin/users", label: "Администратори" }] : []), + { href: "/admin/codes", label: "Кодови" }, + ]; + + return ( + + ); +} diff --git a/src/app/admin/(panel)/codes/page.tsx b/src/app/admin/(panel)/codes/page.tsx new file mode 100644 index 0000000..dd900a0 --- /dev/null +++ b/src/app/admin/(panel)/codes/page.tsx @@ -0,0 +1,144 @@ +"use client"; + +import { useEffect, useState } from "react"; + +interface Code { + id: string; + code: string; + usedByUserId: string | null; + usedAt: string | null; + createdAt: string; + createdBy: { username: string }; +} + +export default function AdminCodesPage() { + const [codes, setCodes] = useState([]); + const [loading, setLoading] = useState(true); + const [generating, setGenerating] = useState(false); + const [newCode, setNewCode] = useState(""); + const [error, setError] = useState(""); + + const fetchCodes = async () => { + setLoading(true); + try { + const res = await fetch("/api/admin/codes", { credentials: "include" }); + if (res.ok) { + setCodes(await res.json()); + } + } catch { + // ignore + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchCodes(); + }, []); + + const handleGenerate = async () => { + setGenerating(true); + setError(""); + setNewCode(""); + try { + const res = await fetch("/api/admin/codes", { method: "POST", credentials: "include" }); + const data = await res.json(); + if (!res.ok) { + throw new Error(data.error || "Грешка при генерирање"); + } + setNewCode(data.code); + fetchCodes(); + } catch (err) { + setError(err instanceof Error ? err.message : "Грешка при генерирање"); + } finally { + setGenerating(false); + } + }; + + const handleDelete = async (id: string) => { + if (!confirm("Дали сте сигурни?")) return; + try { + await fetch(`/api/admin/codes/${id}`, { method: "DELETE", credentials: "include" }); + fetchCodes(); + } catch { + // ignore + } + }; + + return ( +
+
+

Кодови

+ +
+ + {newCode && ( +
+

Нов код:

+

{newCode}

+

Копирајте го кодот. Ќе биде прикажан само еднаш.

+
+ )} + + {error && ( +
{error}
+ )} + +
+ {loading ? ( +

Вчитување...

+ ) : codes.length === 0 ? ( +

Нема генерирани кодови

+ ) : ( + + + + + + + + + + + + {codes.map((item) => ( + + + + + + + + ))} + +
КодКреиран одКреиранСтатусАкции
{item.code}{item.createdBy.username}{new Date(item.createdAt).toLocaleDateString("mk-MK")} + {item.usedByUserId ? ( + + Искористен + + ) : ( + + Неискористен + + )} + + {!item.usedByUserId && ( + + )} +
+ )} +
+
+ ); +} diff --git a/src/app/admin/(panel)/dashboard/page.tsx b/src/app/admin/(panel)/dashboard/page.tsx new file mode 100644 index 0000000..1044b60 --- /dev/null +++ b/src/app/admin/(panel)/dashboard/page.tsx @@ -0,0 +1,29 @@ +import { prisma } from "@/lib/prisma"; + +export default async function AdminDashboardPage() { + const [adminCount, codeCount, usedCodeCount] = await Promise.all([ + prisma.adminUser.count(), + prisma.code.count(), + prisma.code.count({ where: { usedByUserId: { not: null } } }), + ]); + + return ( +
+

Контролна табла

+
+
+

Администратори

+

{adminCount}

+
+
+

Генерирани кодови

+

{codeCount}

+
+
+

Искористени кодови

+

{usedCodeCount}

+
+
+
+ ); +} diff --git a/src/app/admin/(panel)/layout.tsx b/src/app/admin/(panel)/layout.tsx new file mode 100644 index 0000000..0bd887a --- /dev/null +++ b/src/app/admin/(panel)/layout.tsx @@ -0,0 +1,21 @@ +import { getAdminSession } from "@/lib/admin-session"; +import { redirect } from "next/navigation"; +import AdminSidebar from "./AdminSidebar"; + +// Admin pages must always render against the live session — never at build +// time (static generation would run DB queries during `next build`). +export const dynamic = "force-dynamic"; + +export default async function AdminLayout({ children }: { children: React.ReactNode }) { + const session = await getAdminSession(); + if (!session) { + redirect("/admin/login"); + } + + return ( +
+ +
{children}
+
+ ); +} diff --git a/src/app/admin/(panel)/users/page.tsx b/src/app/admin/(panel)/users/page.tsx new file mode 100644 index 0000000..2978925 --- /dev/null +++ b/src/app/admin/(panel)/users/page.tsx @@ -0,0 +1,189 @@ +"use client"; + +import { useEffect, useState } from "react"; + +interface AdminUser { + id: string; + username: string; + role: string; + createdAt: string; +} + +export default function AdminUsersPage() { + const [users, setUsers] = useState([]); + const [loading, setLoading] = useState(true); + const [showCreate, setShowCreate] = useState(false); + const [newUsername, setNewUsername] = useState(""); + const [newPassword, setNewPassword] = useState(""); + const [error, setError] = useState(""); + + const fetchUsers = async () => { + setLoading(true); + try { + const res = await fetch("/api/admin/users", { credentials: "include" }); + if (res.ok) { + setUsers(await res.json()); + } + } catch { + // ignore + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchUsers(); + }, []); + + const handleCreate = async (e: React.FormEvent) => { + e.preventDefault(); + setError(""); + try { + const res = await fetch("/api/admin/users", { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ username: newUsername, password: newPassword }), + }); + if (!res.ok) { + const data = await res.json(); + throw new Error(data.error || "Грешка при креирање"); + } + setShowCreate(false); + setNewUsername(""); + setNewPassword(""); + fetchUsers(); + } catch (err) { + setError(err instanceof Error ? err.message : "Грешка при креирање"); + } + }; + + const handleDelete = async (id: string) => { + if (!confirm("Дали сте сигурни дека сакате да го избришете овој администратор?")) return; + try { + const res = await fetch(`/api/admin/users/${id}`, { method: "DELETE", credentials: "include" }); + if (res.ok) { + fetchUsers(); + } + } catch { + // ignore + } + }; + + const handleResetPassword = async (id: string) => { + const newPw = prompt("Внесете нова лозинка:"); + if (!newPw || newPw.length < 6) { + alert("Лозинката мора да има најмалку 6 карактери"); + return; + } + try { + const res = await fetch(`/api/admin/users/${id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ password: newPw }), + }); + if (res.ok) { + alert("Лозинката е променета"); + } else { + const data = await res.json(); + alert(data.error || "Грешка"); + } + } catch { + alert("Грешка"); + } + }; + + return ( +
+
+

Администратори

+ +
+ + {showCreate && ( +
+
+
+ + setNewUsername(e.target.value)} + className="mt-1 block w-full rounded-lg border border-stone-200 px-3 py-2.5 text-stone-900 focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary" + required + /> +
+
+ + setNewPassword(e.target.value)} + className="mt-1 block w-full rounded-lg border border-stone-200 px-3 py-2.5 text-stone-900 focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary" + required + minLength={6} + /> +
+
+ {error &&

{error}

} + +
+ )} + +
+ {loading ? ( +

Вчитување...

+ ) : users.length === 0 ? ( +

Нема администратори

+ ) : ( + + + + + + + + + + + {users.map((user) => ( + + + + + + + ))} + +
Корисничко имеУлогаКреиранАкции
{user.username}{user.role === "SUPER_ADMIN" ? "SuperAdmin" : "Admin"}{new Date(user.createdAt).toLocaleDateString("mk-MK")} + + {user.role !== "SUPER_ADMIN" && ( + + )} +
+ )} +
+
+ ); +} diff --git a/src/app/api/admin/change-password/route.ts b/src/app/api/admin/change-password/route.ts new file mode 100644 index 0000000..cf9962e --- /dev/null +++ b/src/app/api/admin/change-password/route.ts @@ -0,0 +1,54 @@ +import { NextRequest, NextResponse } from "next/server"; +import { hash, compare } from "bcryptjs"; +import { prisma } from "@/lib/prisma"; +import { requireAdminPost } from "@/lib/admin-session"; +import { ADMIN_PASSWORD_MIN_LENGTH } from "@/lib/config"; + +function strongEnough(password: string): boolean { + if (password.length < ADMIN_PASSWORD_MIN_LENGTH) return false; + return /[A-Za-z]/.test(password) && /\d/.test(password); +} + +export async function POST(req: NextRequest) { + const guard = await requireAdminPost()(req); + if ("response" in guard) return guard.response; + const { session } = guard; + + try { + if (session.username === "super") { + return NextResponse.json({ error: "SuperAdmin не може да ја промени лозинката преку овој метод" }, { status: 400 }); + } + + const { currentPassword, newPassword } = await req.json(); + if (!currentPassword || !newPassword) { + return NextResponse.json({ error: "Тековната и новата лозинка се задолжителни" }, { status: 400 }); + } + if (!strongEnough(newPassword)) { + return NextResponse.json( + { error: `Новата лозинка мора да има најмалку ${ADMIN_PASSWORD_MIN_LENGTH} карактери и да содржи буква и цифра` }, + { status: 400 } + ); + } + + const admin = await prisma.adminUser.findUnique({ where: { username: session.username } }); + if (!admin) { + return NextResponse.json({ error: "Администраторот не е пронајден" }, { status: 404 }); + } + + const valid = await compare(currentPassword, admin.passwordHash); + if (!valid) { + return NextResponse.json({ error: "Тековната лозинка е неточна" }, { status: 401 }); + } + + const passwordHash = await hash(newPassword, 12); + await prisma.adminUser.update({ + where: { username: session.username }, + data: { passwordHash }, + }); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error("Change password error:", error); + return NextResponse.json({ error: "Внатрешна грешка на серверот" }, { status: 500 }); + } +} diff --git a/src/app/api/admin/codes/[id]/route.ts b/src/app/api/admin/codes/[id]/route.ts new file mode 100644 index 0000000..f0c0945 --- /dev/null +++ b/src/app/api/admin/codes/[id]/route.ts @@ -0,0 +1,28 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { requireAdminPost } from "@/lib/admin-session"; + +export async function DELETE(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { + const guard = await requireAdminPost()(req); + if ("response" in guard) return guard.response; + const { session } = guard; + + const { id } = await params; + const code = await prisma.code.findUnique({ where: { id } }); + if (!code) { + return NextResponse.json({ error: "Кодот не е пронајден" }, { status: 404 }); + } + if (code.usedByUserId) { + return NextResponse.json({ error: "Не можете да избришете искористен код" }, { status: 400 }); + } + + if (session.role !== "SUPER_ADMIN") { + const admin = await prisma.adminUser.findUnique({ where: { username: session.username } }); + if (!admin || code.createdById !== admin.id) { + return NextResponse.json({ error: "Немате дозвола" }, { status: 403 }); + } + } + + await prisma.code.delete({ where: { id } }); + return NextResponse.json({ success: true }); +} diff --git a/src/app/api/admin/codes/route.ts b/src/app/api/admin/codes/route.ts new file mode 100644 index 0000000..610856a --- /dev/null +++ b/src/app/api/admin/codes/route.ts @@ -0,0 +1,45 @@ +import { NextRequest, NextResponse } from "next/server"; +import { randomBytes } from "crypto"; +import { prisma } from "@/lib/prisma"; +import { requireAdmin, requireAdminPost } from "@/lib/admin-session"; + +export async function GET() { + const guard = await requireAdmin()(); + if ("response" in guard) return guard.response; + const { session } = guard; + + const where = session.role === "SUPER_ADMIN" ? {} : { createdBy: { username: session.username } }; + + const codes = await prisma.code.findMany({ + where, + orderBy: { createdAt: "desc" }, + include: { createdBy: { select: { username: true } } }, + }); + + return NextResponse.json(codes); +} + +export async function POST(req: NextRequest) { + const guard = await requireAdminPost()(req); + if ("response" in guard) return guard.response; + const { session } = guard; + + const admin = await prisma.adminUser.findUnique({ where: { username: session.username } }); + if (!admin) { + return NextResponse.json( + { error: "Администраторот не е пронајден во базата. Кодови може да креираат само администратори со запис во базата." }, + { status: 404 } + ); + } + + const code = randomBytes(6).toString("hex").toUpperCase(); + + const created = await prisma.code.create({ + data: { + code, + createdById: admin.id, + }, + }); + + return NextResponse.json(created, { status: 201 }); +} diff --git a/src/app/api/admin/login/route.ts b/src/app/api/admin/login/route.ts new file mode 100644 index 0000000..87b4a6d --- /dev/null +++ b/src/app/api/admin/login/route.ts @@ -0,0 +1,59 @@ +import { NextRequest, NextResponse } from "next/server"; +import { compare } from "bcryptjs"; +import { prisma } from "@/lib/prisma"; +import { createAdminSession, cookieOptions } from "@/lib/admin-session"; +import { adminLoginLimiter, rateLimitHeaders } from "@/lib/rate-limit"; +import { SUPER_ADMIN_USERNAME, SUPER_ADMIN_PASSWORD_HASH } from "@/lib/config"; + +function clientIp(req: NextRequest): string { + const fwd = req.headers.get("x-forwarded-for"); + if (fwd) return fwd.split(",")[0].trim(); + return req.headers.get("x-real-ip") || "unknown"; +} + +export async function POST(req: NextRequest) { + const ip = clientIp(req); + const limit = adminLoginLimiter.limit(`admin-login:${ip}`); + if (!limit.success) { + return NextResponse.json( + { error: "Премногу обиди. Обидете се повторно подоцна." }, + { status: 429, headers: rateLimitHeaders(limit) } + ); + } + + try { + const { username, password } = await req.json(); + if (!username || !password) { + return NextResponse.json({ error: "Корисничко име и лозинка се задолжителни" }, { status: 400 }); + } + + if (username === SUPER_ADMIN_USERNAME && SUPER_ADMIN_PASSWORD_HASH) { + const valid = await compare(password, SUPER_ADMIN_PASSWORD_HASH); + if (!valid) { + return NextResponse.json({ error: "Невалидно корисничко име или лозинка" }, { status: 401 }); + } + const session = await createAdminSession({ username: SUPER_ADMIN_USERNAME, role: "SUPER_ADMIN" }); + const res = NextResponse.json({ success: true }); + res.cookies.set(cookieOptions(session)); + return res; + } + + const admin = await prisma.adminUser.findUnique({ where: { username } }); + if (!admin) { + return NextResponse.json({ error: "Невалидно корисничко име или лозинка" }, { status: 401 }); + } + + const valid = await compare(password, admin.passwordHash); + if (!valid) { + return NextResponse.json({ error: "Невалидно корисничко име или лозинка" }, { status: 401 }); + } + + const session = await createAdminSession({ username: admin.username, role: admin.role }); + const res = NextResponse.json({ success: true }); + res.cookies.set(cookieOptions(session)); + return res; + } catch (error) { + console.error("Admin login error:", error); + return NextResponse.json({ error: "Внатрешна грешка на серверот" }, { status: 500 }); + } +} diff --git a/src/app/api/admin/logout/route.ts b/src/app/api/admin/logout/route.ts new file mode 100644 index 0000000..357387c --- /dev/null +++ b/src/app/api/admin/logout/route.ts @@ -0,0 +1,20 @@ +import { NextResponse } from "next/server"; +import { COOKIE_NAME_ADMIN, requireAdminPost } from "@/lib/admin-session"; + +export async function POST(req: Request) { + const guard = await requireAdminPost()(req as never); + if ("response" in guard) return guard.response; + + const res = NextResponse.json({ success: true }); + res.cookies.set({ + name: COOKIE_NAME_ADMIN, + value: "", + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + path: "/", + maxAge: 0, + }); + return res; +} + 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..526cc90 --- /dev/null +++ b/src/app/api/admin/users/[id]/route.ts @@ -0,0 +1,59 @@ +import { NextRequest, NextResponse } from "next/server"; +import { hash } from "bcryptjs"; +import { prisma } from "@/lib/prisma"; +import { requireSuperAdminPost } from "@/lib/admin-session"; +import { ADMIN_PASSWORD_MIN_LENGTH } from "@/lib/config"; + +function strongEnough(password: string): boolean { + if (password.length < ADMIN_PASSWORD_MIN_LENGTH) return false; + return /[A-Za-z]/.test(password) && /\d/.test(password); +} + +export async function DELETE(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { + const guard = await requireSuperAdminPost()(req); + if ("response" in guard) return guard.response; + + const { id } = await params; + const user = await prisma.adminUser.findUnique({ where: { id } }); + if (!user) { + return NextResponse.json({ error: "Администраторот не е пронајден" }, { status: 404 }); + } + if (user.role === "SUPER_ADMIN") { + return NextResponse.json({ error: "Не можете да избришете SuperAdmin" }, { status: 400 }); + } + + await prisma.adminUser.delete({ where: { id } }); + return NextResponse.json({ success: true }); +} + +export async function PUT(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { + const guard = await requireSuperAdminPost()(req); + if ("response" in guard) return guard.response; + + try { + const { id } = await params; + const { password } = await req.json(); + if (!password || !strongEnough(password)) { + return NextResponse.json( + { error: `Лозинката мора да има најмалку ${ADMIN_PASSWORD_MIN_LENGTH} карактери и да содржи буква и цифра` }, + { status: 400 } + ); + } + + const user = await prisma.adminUser.findUnique({ where: { id } }); + if (!user) { + return NextResponse.json({ error: "Администраторот не е пронајден" }, { status: 404 }); + } + + const passwordHash = await hash(password, 12); + await prisma.adminUser.update({ + where: { id }, + data: { passwordHash }, + }); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error("Update admin error:", error); + return NextResponse.json({ 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..717a562 --- /dev/null +++ b/src/app/api/admin/users/route.ts @@ -0,0 +1,54 @@ +import { NextRequest, NextResponse } from "next/server"; +import { hash } from "bcryptjs"; +import { prisma } from "@/lib/prisma"; +import { requireAdmin, requireSuperAdminPost } from "@/lib/admin-session"; +import { ADMIN_PASSWORD_MIN_LENGTH } from "@/lib/config"; + +function strongEnough(password: string): boolean { + if (password.length < ADMIN_PASSWORD_MIN_LENGTH) return false; + return /[A-Za-z]/.test(password) && /\d/.test(password); +} + +export async function GET() { + const guard = await requireAdmin(true)(); + if ("response" in guard) return guard.response; + + const users = await prisma.adminUser.findMany({ + orderBy: { createdAt: "desc" }, + }); + + return NextResponse.json(users); +} + +export async function POST(req: NextRequest) { + const guard = await requireSuperAdminPost()(req); + if ("response" in guard) return guard.response; + + try { + const { username, password } = await req.json(); + if (!username || !password) { + return NextResponse.json({ error: "Корисничко име и лозинка се задолжителни" }, { status: 400 }); + } + if (!strongEnough(password)) { + return NextResponse.json( + { error: `Лозинката мора да има најмалку ${ADMIN_PASSWORD_MIN_LENGTH} карактери и да содржи буква и цифра` }, + { status: 400 } + ); + } + + const existing = await prisma.adminUser.findUnique({ where: { username } }); + if (existing) { + return NextResponse.json({ error: "Корисничкото име веќе постои" }, { status: 409 }); + } + + const passwordHash = await hash(password, 12); + const user = await prisma.adminUser.create({ + data: { username, passwordHash, role: "ADMIN" }, + }); + + return NextResponse.json(user, { status: 201 }); + } catch (error) { + console.error("Create admin error:", error); + return NextResponse.json({ error: "Внатрешна грешка на серверот" }, { status: 500 }); + } +} diff --git a/src/app/api/check-subdomain/route.ts b/src/app/api/check-subdomain/route.ts index b064ab6..0d292b1 100644 --- a/src/app/api/check-subdomain/route.ts +++ b/src/app/api/check-subdomain/route.ts @@ -1,17 +1,42 @@ import { NextRequest, NextResponse } from "next/server"; import { prisma } from "@/lib/prisma"; +import { checkSubdomainLimiter, rateLimitHeaders } from "@/lib/rate-limit"; +import { SUBDOMAIN_REGEX, SUBDOMAIN_MIN_LENGTH, SUBDOMAIN_MAX_LENGTH } from "@/lib/config"; + +function clientIp(req: NextRequest): string { + const fwd = req.headers.get("x-forwarded-for"); + if (fwd) return fwd.split(",")[0].trim(); + return req.headers.get("x-real-ip") || "unknown"; +} export async function GET(req: NextRequest) { - const slug = req.nextUrl.searchParams.get("slug"); + const ip = clientIp(req); + const limit = checkSubdomainLimiter.limit(`check-subdomain:${ip}`); + if (!limit.success) { + return NextResponse.json( + { available: false, error: "Премногу обиди. Обидете се повторно подоцна." }, + { status: 429, headers: rateLimitHeaders(limit) } + ); + } - if (!slug || slug.length < 3) { - return NextResponse.json({ available: false }); + const raw = req.nextUrl.searchParams.get("slug") || ""; + const slug = raw.toLowerCase().trim(); + + if ( + slug.length < SUBDOMAIN_MIN_LENGTH || + slug.length > SUBDOMAIN_MAX_LENGTH || + !SUBDOMAIN_REGEX.test(slug) + ) { + return NextResponse.json( + { available: false }, + { headers: { "Cache-Control": "private, max-age=60" } } + ); } const existing = await prisma.user.findUnique({ where: { subdomain: slug } }); return NextResponse.json( { available: !existing }, - { headers: { "Cache-Control": "no-store" } } + { headers: { "Cache-Control": "private, max-age=60" } } ); -} \ No newline at end of file +} diff --git a/src/app/api/image/route.ts b/src/app/api/image/route.ts index a5d245c..fd94508 100644 --- a/src/app/api/image/route.ts +++ b/src/app/api/image/route.ts @@ -2,16 +2,17 @@ import { NextRequest, NextResponse } from "next/server"; import { GetObjectCommand } from "@aws-sdk/client-s3"; import { Readable } from "stream"; import { s3Client, S3_BUCKET } from "@/lib/s3"; +import { isAllowedImageKey } from "@/lib/config"; export async function GET(req: NextRequest) { const key = req.nextUrl.searchParams.get("key"); if (!key) { - return NextResponse.json({ error: "Missing key parameter" }, { status: 400 }); + return NextResponse.json({ error: "Недостасува параметар key" }, { status: 400 }); } - if (!key.startsWith("uploads/")) { - return NextResponse.json({ error: "Invalid key" }, { status: 400 }); + if (!isAllowedImageKey(key)) { + return NextResponse.json({ error: "Невалиден клуч" }, { status: 400 }); } try { @@ -35,13 +36,13 @@ export async function GET(req: NextRequest) { }, }); } catch (error: unknown) { - const message = error instanceof Error ? error.message : "Unknown error"; + const message = error instanceof Error ? error.message : "Непозната грешка"; console.error("Image proxy error:", message); if (message.includes("NoSuchKey") || message.includes("404")) { - return NextResponse.json({ error: "Image not found" }, { status: 404 }); + return NextResponse.json({ error: "Сликата не е пронајдена" }, { status: 404 }); } - return NextResponse.json({ error: "Failed to fetch image" }, { status: 500 }); + return NextResponse.json({ error: "Не успеа преземањето на сликата" }, { status: 500 }); } -} \ No newline at end of file +} diff --git a/src/app/api/publish/route.ts b/src/app/api/publish/route.ts index b8449ef..fa33dce 100644 --- a/src/app/api/publish/route.ts +++ b/src/app/api/publish/route.ts @@ -1,9 +1,18 @@ import { NextRequest, NextResponse } from "next/server"; import { auth } from "@clerk/nextjs/server"; import { revalidateTag } from "next/cache"; +import { Prisma } from "@prisma/client"; import { prisma } from "@/lib/prisma"; import { generateMonumentQR } from "@/lib/qrcode"; import { getPublicUrl } from "@/lib/upload"; +import { + SUBDOMAIN_REGEX, + SUBDOMAIN_MIN_LENGTH, + SUBDOMAIN_MAX_LENGTH, + TITLE_MAX_LENGTH, + DESCRIPTION_MAX_LENGTH, + APP_DOMAIN, +} from "@/lib/config"; export async function POST(req: NextRequest) { const { userId } = await auth(); @@ -11,95 +20,107 @@ export async function POST(req: NextRequest) { return NextResponse.json({ error: "Неавторизирано" }, { status: 401 }); } + const hasCode = await prisma.code.findFirst({ + where: { usedByUserId: userId }, + }); + if (!hasCode) { + return NextResponse.json({ error: "Потребен е валиден код за креирање спомен страница" }, { status: 403 }); + } + try { const body = await req.json(); const { title, description, bornDate, passedDate, subdomain, templateId, images } = body; - if (!title?.trim()) { - return NextResponse.json({ error: "Името е задолжително" }, { status: 400 }); + if (!title?.trim() || title.length > TITLE_MAX_LENGTH) { + return NextResponse.json({ error: `Името е задолжително (макс. ${TITLE_MAX_LENGTH} карактери)` }, { status: 400 }); } - if (!subdomain || subdomain.length < 3) { - return NextResponse.json({ error: "Поддоменот мора да има најмалку 3 карактери" }, { status: 400 }); + if (description && description.length > DESCRIPTION_MAX_LENGTH) { + return NextResponse.json({ error: `Описот е преголем (макс. ${DESCRIPTION_MAX_LENGTH} карактери)` }, { status: 400 }); } - if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(subdomain)) { + if (!subdomain || subdomain.length < SUBDOMAIN_MIN_LENGTH || subdomain.length > SUBDOMAIN_MAX_LENGTH) { + return NextResponse.json({ error: `Поддоменот мора да има ${SUBDOMAIN_MIN_LENGTH}-${SUBDOMAIN_MAX_LENGTH} карактери` }, { status: 400 }); + } + if (!SUBDOMAIN_REGEX.test(subdomain)) { return NextResponse.json({ error: "Поддоменот може да содржи само мали букви, цифри и цртички" }, { status: 400 }); } if (templateId < 1 || templateId > 3) { return NextResponse.json({ error: "Невалиден шаблон" }, { status: 400 }); } - if (!images || images.length === 0 || images.length > 3) { + if (!Array.isArray(images) || images.length === 0 || images.length > 3) { return NextResponse.json({ error: "Потребни се 1-3 фотографии" }, { status: 400 }); } - const existing = await prisma.user.findUnique({ where: { subdomain } }); - if (existing && existing.clerkId !== userId) { - return NextResponse.json({ error: "Поддоменот е веќе зафатен" }, { status: 409 }); - } - const existingUser = await prisma.user.findUnique({ where: { clerkId: userId } }); - - let user; - if (existingUser) { - if (existingUser.subdomain !== subdomain) { - revalidateTag(`memorial-${existingUser.subdomain}`); + if (existingUser && existingUser.subdomain !== subdomain) { + const conflict = await prisma.user.findUnique({ where: { subdomain } }); + if (conflict && conflict.clerkId !== userId) { + return NextResponse.json({ error: "Поддоменот е веќе зафатен" }, { status: 409 }); } - await prisma.image.deleteMany({ where: { userId: existingUser.id } }); - user = await prisma.user.update({ - where: { clerkId: userId }, - data: { - title, - description, - bornDate: bornDate || null, - passedDate: passedDate || null, - subdomain, - templateId, - published: true, - images: { - create: images.map((img: { key: string }, i: number) => ({ - url: getPublicUrl(img.key), - key: img.key, - order: i + 1, - })), - }, - }, - include: { images: true }, - }); - } else { - user = await prisma.user.create({ - data: { - clerkId: userId, - title, - description, - bornDate: bornDate || null, - passedDate: passedDate || null, - subdomain, - templateId, - published: true, - images: { - create: images.map((img: { key: string }, i: number) => ({ - url: getPublicUrl(img.key), - key: img.key, - order: i + 1, - })), - }, - }, - include: { images: true }, - }); } - revalidateTag("memorial"); - revalidateTag(`memorial-${subdomain}`); + const imageData = images.map((img: { key: string }, i: number) => ({ + url: getPublicUrl(img.key), + key: img.key, + order: i + 1, + })); - const qrCode = await generateMonumentQR(subdomain); + try { + const user = await prisma.$transaction(async (tx) => { + if (existingUser) { + if (existingUser.subdomain !== subdomain) { + revalidateTag(`memorial-${existingUser.subdomain}`); + } + await tx.image.deleteMany({ where: { userId: existingUser.id } }); + return tx.user.update({ + where: { clerkId: userId }, + data: { + title: title.trim(), + description: description || null, + bornDate: bornDate || null, + passedDate: passedDate || null, + subdomain, + templateId, + published: true, + images: { create: imageData }, + }, + include: { images: true }, + }); + } + return tx.user.create({ + data: { + clerkId: userId, + title: title.trim(), + description: description || null, + bornDate: bornDate || null, + passedDate: passedDate || null, + subdomain, + templateId, + published: true, + images: { create: imageData }, + }, + include: { images: true }, + }); + }); - return NextResponse.json({ - success: true, - monumentUrl: `https://${subdomain}.${process.env.NEXT_PUBLIC_APP_DOMAIN}`, - qrCode, - user, - }); + revalidateTag("memorial"); + revalidateTag(`memorial-${subdomain}`); + + const qrCode = await generateMonumentQR(subdomain); + + return NextResponse.json({ + success: true, + monumentUrl: `https://${subdomain}.${APP_DOMAIN}`, + qrCode, + user, + }); + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") { + return NextResponse.json({ error: "Поддоменот е веќе зафатен" }, { status: 409 }); + } + throw error; + } } catch (error) { console.error("Publish error:", error); return NextResponse.json({ error: "Внатрешна грешка на серверот" }, { status: 500 }); } -} \ No newline at end of file +} diff --git a/src/app/api/upload/route.ts b/src/app/api/upload/route.ts index 381106a..794d334 100644 --- a/src/app/api/upload/route.ts +++ b/src/app/api/upload/route.ts @@ -3,9 +3,25 @@ import { auth } from "@clerk/nextjs/server"; import { PutObjectCommand } from "@aws-sdk/client-s3"; import { v4 as uuidv4 } from "uuid"; import { s3Client, S3_BUCKET, getPublicUrl } from "@/lib/s3"; -import { MAX_FILE_SIZE, ALLOWED_TYPES } from "@/lib/upload"; +import { MAX_FILE_SIZE, detectImageType } from "@/lib/upload"; +import { uploadLimiter, rateLimitHeaders } from "@/lib/rate-limit"; + +function clientIp(req: NextRequest): string { + const fwd = req.headers.get("x-forwarded-for"); + if (fwd) return fwd.split(",")[0].trim(); + return req.headers.get("x-real-ip") || "unknown"; +} export async function POST(req: NextRequest) { + const ip = clientIp(req); + const limit = uploadLimiter.limit(`upload:${ip}`); + if (!limit.success) { + return NextResponse.json( + { error: "Премногу обиди. Обидете се повторно подоцна." }, + { status: 429, headers: rateLimitHeaders(limit) } + ); + } + const { userId } = await auth(); if (!userId) { return NextResponse.json({ error: "Неавторизирано" }, { status: 401 }); @@ -19,24 +35,28 @@ export async function POST(req: NextRequest) { return NextResponse.json({ error: "Нема подадено датотека" }, { status: 400 }); } - if (!ALLOWED_TYPES.includes(file.type)) { - return NextResponse.json({ error: "Невалиден тип на датотека" }, { status: 400 }); - } - if (file.size > MAX_FILE_SIZE) { return NextResponse.json({ error: "Датотеката е премногу голема (макс. 5MB)" }, { status: 400 }); } - const ext = file.type.split("/")[1]; - const key = `uploads/${userId}/${uuidv4()}.${ext}`; const buffer = Buffer.from(await file.arrayBuffer()); + const detected = detectImageType(buffer); + if (!detected) { + return NextResponse.json( + { error: "Невалиден тип на датотека. Дозволени: JPEG, PNG, WebP." }, + { status: 400 } + ); + } + + const ext = detected.split("/")[1]; + const key = `uploads/${userId}/${uuidv4()}.${ext === "jpeg" ? "jpg" : ext}`; await s3Client.send( new PutObjectCommand({ Bucket: S3_BUCKET, Key: key, Body: buffer, - ContentType: file.type, + ContentType: detected, }) ); @@ -47,4 +67,4 @@ export async function POST(req: NextRequest) { console.error("Upload error:", error); return NextResponse.json({ error: "Не успеа качувањето" }, { status: 500 }); } -} \ No newline at end of file +} diff --git a/src/app/api/validate-code/route.ts b/src/app/api/validate-code/route.ts new file mode 100644 index 0000000..ecad3c3 --- /dev/null +++ b/src/app/api/validate-code/route.ts @@ -0,0 +1,67 @@ +import { NextRequest, NextResponse } from "next/server"; +import { auth } from "@clerk/nextjs/server"; +import { prisma } from "@/lib/prisma"; +import { validateCodeLimiter, rateLimitHeaders } from "@/lib/rate-limit"; + +function clientIp(req: NextRequest): string { + const fwd = req.headers.get("x-forwarded-for"); + if (fwd) return fwd.split(",")[0].trim(); + return req.headers.get("x-real-ip") || "unknown"; +} + +export async function POST(req: NextRequest) { + const ip = clientIp(req); + const limit = validateCodeLimiter.limit(`validate-code:${ip}`); + if (!limit.success) { + return NextResponse.json( + { error: "Премногу обиди. Обидете се повторно подоцна." }, + { status: 429, headers: rateLimitHeaders(limit) } + ); + } + + const { userId } = await auth(); + if (!userId) { + return NextResponse.json({ error: "Неавторизирано" }, { status: 401 }); + } + + try { + const { code } = await req.json(); + if (!code?.trim()) { + return NextResponse.json({ error: "Кодот е задолжителен" }, { status: 400 }); + } + + const normalizedCode = code.trim().toUpperCase(); + if (!/^[A-F0-9]{12}$/.test(normalizedCode)) { + return NextResponse.json({ valid: false, error: "Невалиден код" }); + } + + const existing = await prisma.code.findUnique({ where: { code: normalizedCode } }); + if (!existing) { + return NextResponse.json({ valid: false, error: "Невалиден код" }); + } + if (existing.usedByUserId) { + return NextResponse.json({ valid: false, error: "Кодот е веќе искористен" }); + } + + const alreadyUsed = await prisma.code.findFirst({ + where: { usedByUserId: userId }, + }); + if (alreadyUsed) { + return NextResponse.json({ valid: false, error: "Веќе имате искористено код" }); + } + + const updated = await prisma.code.updateMany({ + where: { id: existing.id, usedByUserId: null }, + data: { usedByUserId: userId, usedAt: new Date() }, + }); + + if (updated.count === 0) { + return NextResponse.json({ valid: false, error: "Кодот е веќе искористен" }); + } + + return NextResponse.json({ valid: true }); + } catch (error) { + console.error("Validate code error:", error); + return NextResponse.json({ error: "Внатрешна грешка на серверот" }, { status: 500 }); + } +} diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index 3e8c1f6..bcf7975 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -6,6 +6,8 @@ import { UserButton } from "@clerk/nextjs"; import CopyButton from "@/components/CopyButton"; import DeleteMonumentButton from "@/components/DeleteMonumentButton"; import DeleteImageButton from "@/components/DeleteImageButton"; +import { generateMonumentQR } from "@/lib/qrcode"; +import { APP_DOMAIN } from "@/lib/config"; export default async function DashboardPage() { const { userId } = await auth(); @@ -20,7 +22,8 @@ export default async function DashboardPage() { redirect("/onboarding"); } - const monumentUrl = `https://${user.subdomain}.${process.env.NEXT_PUBLIC_APP_DOMAIN}`; + const monumentUrl = `https://${user.subdomain}.${APP_DOMAIN}`; + const qrCode = await generateMonumentQR(user.subdomain); return (
@@ -78,14 +81,15 @@ export default async function DashboardPage() {

QR код

Прикажете го овој QR код на споменикот за да можат посетителите да ја прочитаат приказната.

+ {/* eslint-disable-next-line @next/next/no-img-element */} QR код Превземи QR код diff --git a/src/app/error.tsx b/src/app/error.tsx new file mode 100644 index 0000000..01b5e48 --- /dev/null +++ b/src/app/error.tsx @@ -0,0 +1,30 @@ +"use client"; + +import { useEffect } from "react"; + +export default function Error({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + useEffect(() => { + console.error(error); + }, [error]); + + return ( +
+

Настана грешка

+

+ Нешто тргна наопаку. Обидете се повторно или контактирајте н ако проблемот persist. +

+ +
+ ); +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index d283fa6..e712983 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,6 +1,7 @@ import type { Metadata } from "next"; import { Geist, Geist_Mono } from "next/font/google"; import { ClerkProvider } from "@clerk/nextjs"; +import { APP_URL } from "@/lib/config"; import "./globals.css"; const geistSans = Geist({ @@ -14,7 +15,11 @@ const geistMono = Geist_Mono({ }); export const metadata: Metadata = { - title: "СпоменQR — Во спомен на", + metadataBase: new URL(APP_URL), + title: { + default: "СпоменQR — Во спомен на", + template: "%s · СпоменQR", + }, description: "Креирајте убави спомен страници со QR кодови. Оддадете почит и зачувајте ги спомените на најблиските.", }; diff --git a/src/app/loading.tsx b/src/app/loading.tsx new file mode 100644 index 0000000..386117c --- /dev/null +++ b/src/app/loading.tsx @@ -0,0 +1,7 @@ +export default function Loading() { + return ( +
+
+
+ ); +} diff --git a/src/app/onboarding/page.tsx b/src/app/onboarding/page.tsx index 9c6ae46..1a18178 100644 --- a/src/app/onboarding/page.tsx +++ b/src/app/onboarding/page.tsx @@ -6,7 +6,7 @@ import ImageUploader from "@/components/ImageUploader"; import SubdomainPicker from "@/components/SubdomainPicker"; import TemplatePicker from "@/components/TemplatePicker"; -const STEPS = ["Податоци", "Датуми", "Фотографии", "Поддомен", "Шаблон"] as const; +const STEPS = ["Код", "Податоци", "Датуми", "Фотографии", "Поддомен", "Шаблон"] as const; export default function OnboardingWizard() { const router = useRouter(); @@ -14,6 +14,10 @@ export default function OnboardingWizard() { const [loading, setLoading] = useState(false); const [error, setError] = useState(""); + const [code, setCode] = useState(""); + const [codeValidated, setCodeValidated] = useState(false); + const [codeValidating, setCodeValidating] = useState(false); + const [title, setTitle] = useState(""); const [description, setDescription] = useState(""); const [bornDate, setBornDate] = useState(""); @@ -24,15 +28,37 @@ export default function OnboardingWizard() { const canProceed = () => { switch (step) { - case 0: return title.trim().length > 0; - case 1: return true; - case 2: return images.length > 0; - case 3: return subdomain.length >= 3; - case 4: return templateId >= 1 && templateId <= 3; + case 0: return codeValidated; + case 1: return title.trim().length > 0; + case 2: return true; + case 3: return images.length > 0; + case 4: return subdomain.length >= 3; + case 5: return templateId >= 1 && templateId <= 3; default: return false; } }; + const handleValidateCode = async () => { + setCodeValidating(true); + setError(""); + try { + const res = await fetch("/api/validate-code", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ code }), + }); + const data = await res.json(); + if (!data.valid) { + throw new Error(data.error || "Невалиден код"); + } + setCodeValidated(true); + } catch (err) { + setError(err instanceof Error ? err.message : "Грешка при валидација"); + } finally { + setCodeValidating(false); + } + }; + const handlePublish = async () => { setLoading(true); setError(""); @@ -100,6 +126,42 @@ export default function OnboardingWizard() { )} {step === 0 && ( +
+

+ Внесете го кодот што го добивте за да креирате спомен страница. +

+
+ { + setCode(e.target.value.toUpperCase()); + setCodeValidated(false); + }} + placeholder="Внесете код" + className="mt-1 block flex-1 rounded-lg border border-stone-200 px-3 py-2.5 font-mono text-lg uppercase tracking-widest text-stone-900 placeholder:text-stone-400 focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary" + maxLength={20} + disabled={codeValidated} + /> + {!codeValidated ? ( + + ) : ( +
+ Потврден +
+ )} +
+
+ )} + + {step === 1 && (
)} - {step === 1 && ( + {step === 2 && (

Овие се опционални. Можете да внесете точни датуми, приближни години, или да ги оставите празни. @@ -168,18 +230,18 @@ export default function OnboardingWizard() {

)} - {step === 2 && ( + {step === 3 && ( setImages(newImages as typeof images)} /> )} - {step === 3 && ( + {step === 4 && ( )} - {step === 4 && ( + {step === 5 && ( ALLOWED_TYPES.includes(f.type)) + .filter((f) => (ALLOWED_TYPES as readonly string[]).includes(f.type)) .filter((f) => f.size <= MAX_FILE_SIZE) .slice(0, remaining); if (validFiles.length === 0) { - setError("Невалиден тип на датотека или големина. Дозволени: JPEG, PNG, WebP, GIF до 5MB."); + setError("Невалиден тип на датотека или големина. Дозволени: JPEG, PNG, WebP до 5MB."); return; } @@ -44,31 +41,43 @@ export default function ImageUploader({ images, onImagesChange }: ImageUploaderP setUploading(true); try { + const baseOrder = images.length; + const results = await Promise.allSettled( + validFiles.map(async (file, idx) => { + const formData = new FormData(); + formData.append("file", file); + const res = await fetch("/api/upload", { + method: "POST", + body: formData, + }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(data.error || "Не успеа качувањето"); + } + const { key, url } = await res.json(); + return { key, url, order: baseOrder + idx + 1 }; + }) + ); + const newImages = [...images]; const newPreviews = [...previews]; - - for (const file of validFiles) { - const formData = new FormData(); - formData.append("file", file); - - const res = await fetch("/api/upload", { - method: "POST", - body: formData, - }); - - if (!res.ok) { - const data = await res.json(); - throw new Error(data.error || "Не успеа качувањето"); + const failures: string[] = []; + for (const r of results) { + if (r.status === "fulfilled") { + newImages.push({ key: r.value.key, order: r.value.order, url: r.value.url }); + newPreviews.push({ key: r.value.key, url: r.value.url, order: r.value.order }); + } else { + failures.push(r.reason instanceof Error ? r.reason.message : "Не успеа качувањето"); } - - const { key, url } = await res.json(); - const order = newImages.length + 1; - newImages.push({ key, order, url }); - newPreviews.push({ key, url, order }); } onImagesChange(newImages); setPreviews(newPreviews); + if (failures.length > 0) { + setError(failures.join("; ")); + } else { + setError(""); + } } catch (err) { setError(err instanceof Error ? err.message : "Не успеа качувањето"); } finally { diff --git a/src/components/SubdomainPicker.tsx b/src/components/SubdomainPicker.tsx index 79a6c99..99deee3 100644 --- a/src/components/SubdomainPicker.tsx +++ b/src/components/SubdomainPicker.tsx @@ -1,6 +1,11 @@ "use client"; import { useState, useEffect, useCallback } from "react"; +import { + APP_DOMAIN, + SUBDOMAIN_MIN_LENGTH, + SUBDOMAIN_MAX_LENGTH, +} from "@/lib/config"; interface SubdomainPickerProps { value: string; @@ -14,7 +19,7 @@ export default function SubdomainPicker({ value, onChange }: SubdomainPickerProp const slug = value.toLowerCase().replace(/[^a-z0-9-]/g, "").replace(/-+/g, "-").replace(/^-|-$/g, ""); const checkAvailability = useCallback(async (s: string) => { - if (s.length < 3) { + if (s.length < SUBDOMAIN_MIN_LENGTH) { setAvailable(null); return; } @@ -51,10 +56,10 @@ export default function SubdomainPicker({ value, onChange }: SubdomainPickerProp onChange={(e) => onChange(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, "").replace(/-+/g, "-"))} placeholder="нпр. maria-novakovska" className="flex-1 rounded-l-lg border-0 px-3 py-2 text-stone-900 placeholder:text-stone-400 focus:outline-none focus:ring-1 focus:ring-primary" - maxLength={63} + maxLength={SUBDOMAIN_MAX_LENGTH} /> - .{process.env.NEXT_PUBLIC_APP_DOMAIN || "testbed.mk"} + .{APP_DOMAIN}
@@ -62,13 +67,13 @@ export default function SubdomainPicker({ value, onChange }: SubdomainPickerProp
{checking &&

Проверка на достапност...

} {!checking && available === true && ( -

✓ {slug}.testbed.mk е достапен!

+

✓ {slug}.{APP_DOMAIN} е достапен!

)} {!checking && available === false && (

✗ Овој поддомен е веќе зафатен.

)} - {!checking && available === null && slug.length > 0 && slug.length < 3 && ( -

Потребни се најмалку 3 карактери.

+ {!checking && available === null && slug.length > 0 && slug.length < SUBDOMAIN_MIN_LENGTH && ( +

Потребни се најмалку {SUBDOMAIN_MIN_LENGTH} карактери.

)}
diff --git a/src/lib/__tests__/admin-session.test.ts b/src/lib/__tests__/admin-session.test.ts new file mode 100644 index 0000000..67547a8 --- /dev/null +++ b/src/lib/__tests__/admin-session.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect, beforeAll } from "vitest"; +import { + createAdminSession, + verifyAdminSession, + COOKIE_NAME_ADMIN, + cookieOptions, +} from "../admin-session"; + +const SECRET = "a".repeat(64); + +beforeAll(() => { + process.env.ADMIN_SESSION_SECRET = SECRET; + process.env.NEXT_PUBLIC_APP_URL = "https://testbed.mk"; +}); + +describe("admin-session.sign/verify", () => { + it("round-trips a valid session", async () => { + const token = await createAdminSession({ username: "alice", role: "ADMIN" }); + const parsed = await verifyAdminSession(token); + expect(parsed).toEqual({ username: "alice", role: "ADMIN" }); + }); + + it("round-trips SUPER_ADMIN role", async () => { + const token = await createAdminSession({ username: "super", role: "SUPER_ADMIN" }); + const parsed = await verifyAdminSession(token); + expect(parsed).toEqual({ username: "super", role: "SUPER_ADMIN" }); + }); + + it("rejects a tampered payload (signature no longer matches)", async () => { + const token = await createAdminSession({ username: "alice", role: "ADMIN" }); + const [payload, sig] = token.split("."); + const tamperedPayload = btoa( + JSON.stringify({ username: "alice", role: "SUPER_ADMIN" }) + ); + const tampered = `${tamperedPayload}.${sig}`; + const parsed = await verifyAdminSession(tampered); + expect(parsed).toBeNull(); + expect(payload).not.toEqual(tamperedPayload); + }); + + it("rejects a tampered signature", async () => { + const token = await createAdminSession({ username: "alice", role: "ADMIN" }); + const [payload] = token.split("."); + const forgedSig = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="; + const forged = `${payload}.${forgedSig}`; + const parsed = await verifyAdminSession(forged); + expect(parsed).toBeNull(); + }); + + it("rejects a malformed token without separator", async () => { + const parsed = await verifyAdminSession("just-a-string-no-separator"); + expect(parsed).toBeNull(); + }); + + it("rejects a token whose payload is not valid base64 JSON", async () => { + const fakePayload = btoa("not-json-at-all"); + const fakeToken = `${fakePayload}.aaaaaaaa`; + const parsed = await verifyAdminSession(fakeToken); + expect(parsed).toBeNull(); + }); +}); + +describe("admin-session.cookieOptions", () => { + it("emits the documented cookie name and secure flags", () => { + const opts = cookieOptions("token-value"); + expect(opts.name).toBe(COOKIE_NAME_ADMIN); + expect(opts.value).toBe("token-value"); + expect(opts.httpOnly).toBe(true); + expect(opts.sameSite).toBe("lax"); + expect(opts.path).toBe("/"); + }); + + it("marks the cookie secure only in production", () => { + const prev = process.env.NODE_ENV; + process.env.NODE_ENV = "production"; + expect(cookieOptions("x").secure).toBe(true); + process.env.NODE_ENV = "test"; + expect(cookieOptions("x").secure).toBe(false); + process.env.NODE_ENV = prev; + }); +}); diff --git a/src/lib/__tests__/rate-limit.test.ts b/src/lib/__tests__/rate-limit.test.ts new file mode 100644 index 0000000..b875d50 --- /dev/null +++ b/src/lib/__tests__/rate-limit.test.ts @@ -0,0 +1,47 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { RateLimiter } from "../rate-limit"; + +describe("RateLimiter", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("allows up to maxTokens requests within a window", () => { + const limiter = new RateLimiter(3, 60_000); + expect(limiter.limit("k").success).toBe(true); + expect(limiter.limit("k").success).toBe(true); + expect(limiter.limit("k").success).toBe(true); + expect(limiter.limit("k").success).toBe(false); + }); + + it("tracks keys independently", () => { + const limiter = new RateLimiter(2, 60_000); + expect(limiter.limit("a").success).toBe(true); + expect(limiter.limit("b").success).toBe(true); + expect(limiter.limit("a").success).toBe(true); + expect(limiter.limit("a").success).toBe(false); + expect(limiter.limit("b").success).toBe(true); + }); + + it("refills after the window elapses", () => { + const limiter = new RateLimiter(1, 60_000); + expect(limiter.limit("k").success).toBe(true); + expect(limiter.limit("k").success).toBe(false); + vi.advanceTimersByTime(60_001); + expect(limiter.limit("k").success).toBe(true); + }); + + it("decrements remaining on each success", () => { + const limiter = new RateLimiter(3, 60_000); + expect(limiter.limit("k").remaining).toBe(2); + expect(limiter.limit("k").remaining).toBe(1); + expect(limiter.limit("k").remaining).toBe(0); + const blocked = limiter.limit("k"); + expect(blocked.success).toBe(false); + expect(blocked.remaining).toBe(0); + }); +}); diff --git a/src/lib/admin-session.ts b/src/lib/admin-session.ts new file mode 100644 index 0000000..a06652d --- /dev/null +++ b/src/lib/admin-session.ts @@ -0,0 +1,142 @@ +import { cookies } from "next/headers"; +import { NextRequest, NextResponse } from "next/server"; +import { getAppOrigin } from "./config"; + +const COOKIE_NAME = "admin_session"; +const SEP = "."; + +function sameOriginRequest(req: NextRequest): boolean { + const origin = req.headers.get("origin"); + const referer = req.headers.get("referer"); + const expected = getAppOrigin().replace(/\/$/, ""); + const host = req.headers.get("host"); + const expectedHost = getAppOrigin().replace(/^https?:\/\//, "").replace(/\/$/, ""); + if (origin) { + return origin.replace(/\/$/, "") === expected; + } + if (referer) { + try { + const u = new URL(referer); + return u.origin.replace(/\/$/, "") === expected; + } catch { + return false; + } + } + return host === expectedHost; +} + +function getSecret(): string { + const secret = process.env.ADMIN_SESSION_SECRET; + if (!secret) throw new Error("ADMIN_SESSION_SECRET env var is not set"); + return secret; +} + +async function sign(payload: string): Promise { + const encoder = new TextEncoder(); + const key = await crypto.subtle.importKey( + "raw", + encoder.encode(getSecret()), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"], + ); + const sig = await crypto.subtle.sign("HMAC", key, encoder.encode(payload)); + return btoa(String.fromCharCode(...new Uint8Array(sig))); +} + +export interface AdminSession { + username: string; + role: "SUPER_ADMIN" | "ADMIN"; +} + +export async function createAdminSession(data: AdminSession): Promise { + const payload = btoa(JSON.stringify(data)); + return payload + SEP + (await sign(payload)); +} + +export async function verifyAdminSession(token: string): Promise { + const sepIdx = token.lastIndexOf(SEP); + if (sepIdx === -1) return null; + const payload = token.slice(0, sepIdx); + const sig = token.slice(sepIdx + 1); + const expectedSig = await sign(payload); + if (sig.length !== expectedSig.length) return null; + try { + if (!constantTimeEqual(sig, expectedSig)) return null; + } catch { + return null; + } + try { + return JSON.parse(atob(payload)); + } catch { + return null; + } +} + +function constantTimeEqual(a: string, b: string): boolean { + if (a.length !== b.length) return false; + let result = 0; + for (let i = 0; i < a.length; i++) { + result |= a.charCodeAt(i) ^ b.charCodeAt(i); + } + return result === 0; +} + +export const COOKIE_NAME_ADMIN = COOKIE_NAME; + +export function cookieOptions(value: string) { + return { + name: COOKIE_NAME, + value, + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax" as const, + path: "/", + }; +} + +export async function getAdminSession(): Promise { + const store = await cookies(); + const token = store.get(COOKIE_NAME)?.value; + if (!token) return null; + return verifyAdminSession(token); +} + +export function requireAdmin(requireSuper = false) { + return async function check(): Promise<{ session: AdminSession } | { response: NextResponse }> { + const session = await getAdminSession(); + if (!session) { + return { + response: NextResponse.json({ error: "Неавторизирано" }, { status: 401 }), + }; + } + if (requireSuper && session.role !== "SUPER_ADMIN") { + return { + response: NextResponse.json({ error: "Немате дозвола" }, { status: 403 }), + }; + } + return { session }; + }; +} + +export function requireAdminPost() { + return async function check(req: NextRequest): Promise<{ session: AdminSession } | { response: NextResponse }> { + if (!sameOriginRequest(req)) { + return { + response: NextResponse.json({ error: "Невалидно потекло на барање" }, { status: 403 }), + }; + } + return requireAdmin()(); + }; +} + +export function requireSuperAdminPost() { + return async function check(req: NextRequest): Promise<{ session: AdminSession } | { response: NextResponse }> { + if (!sameOriginRequest(req)) { + return { + response: NextResponse.json({ error: "Невалидно потекло на барање" }, { status: 403 }), + }; + } + return requireAdmin(true)(); + }; +} diff --git a/src/lib/config.ts b/src/lib/config.ts new file mode 100644 index 0000000..2abda2a --- /dev/null +++ b/src/lib/config.ts @@ -0,0 +1,28 @@ +export const APP_DOMAIN = + process.env.NEXT_PUBLIC_APP_DOMAIN || "testbed.mk"; + +export const APP_URL = process.env.NEXT_PUBLIC_APP_URL || `https://${APP_DOMAIN}`; + +export const SUBDOMAIN_REGEX = /^[a-z0-9][a-z0-9-]*[a-z0-9]$/; + +export const SUBDOMAIN_MIN_LENGTH = 3; +export const SUBDOMAIN_MAX_LENGTH = 32; + +export const TITLE_MAX_LENGTH = 100; +export const DESCRIPTION_MAX_LENGTH = 2000; + +export const ADMIN_PASSWORD_MIN_LENGTH = 12; + +export const IMAGE_KEY_REGEX = + /^uploads\/[a-zA-Z0-9_-]+\/[a-f0-9-]+\.(jpe?g|png|webp)$/i; + +export function isAllowedImageKey(key: string): boolean { + return IMAGE_KEY_REGEX.test(key); +} + +export const SUPER_ADMIN_USERNAME = process.env.SUPER_ADMIN_USERNAME || "super"; +export const SUPER_ADMIN_PASSWORD_HASH = process.env.SUPER_ADMIN_PASSWORD_HASH || ""; + +export function getAppOrigin(): string { + return APP_URL; +} diff --git a/src/lib/prisma.ts b/src/lib/prisma.ts index 6e35df7..bb97479 100644 --- a/src/lib/prisma.ts +++ b/src/lib/prisma.ts @@ -4,6 +4,22 @@ const globalForPrisma = globalThis as unknown as { prisma: PrismaClient | undefined; }; -export const prisma = globalForPrisma.prisma ?? new PrismaClient(); +function getPrisma(): PrismaClient { + if (!globalForPrisma.prisma) { + globalForPrisma.prisma = new PrismaClient(); + } + return globalForPrisma.prisma; +} -if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma; \ No newline at end of file +// Lazy singleton: `new PrismaClient()` throws during `next build` when +// DATABASE_URL is absent (e.g. in a Docker builder stage where env files +// aren't present). Defer construction until the first real query so merely +// importing this module never fails. Method calls are bound to the real +// instance so `this` is preserved. +export const prisma = new Proxy({} as PrismaClient, { + get(_target, prop) { + const client = getPrisma(); + const value = (client as unknown as Record)[prop]; + return typeof value === "function" ? value.bind(client) : value; + }, +}); diff --git a/src/lib/qrcode.ts b/src/lib/qrcode.ts index 0a083bd..62e864d 100644 --- a/src/lib/qrcode.ts +++ b/src/lib/qrcode.ts @@ -1,7 +1,8 @@ import QRCode from "qrcode"; +import { APP_DOMAIN } from "./config"; export async function generateMonumentQR(subdomain: string): Promise { - const url = `https://${subdomain}.${process.env.NEXT_PUBLIC_APP_DOMAIN}`; + const url = `https://${subdomain}.${APP_DOMAIN}`; const qrBuffer = await QRCode.toBuffer(url, { type: "png", width: 400, @@ -13,4 +14,17 @@ export async function generateMonumentQR(subdomain: string): Promise { }); return `data:image/png;base64,${qrBuffer.toString("base64")}`; +} + +export async function generateMonumentQRPng(subdomain: string): Promise { + const url = `https://${subdomain}.${APP_DOMAIN}`; + return QRCode.toBuffer(url, { + type: "png", + width: 400, + margin: 2, + color: { + dark: "#1a1a2e", + light: "#ffffff", + }, + }); } \ No newline at end of file diff --git a/src/lib/rate-limit.ts b/src/lib/rate-limit.ts new file mode 100644 index 0000000..5a20c8d --- /dev/null +++ b/src/lib/rate-limit.ts @@ -0,0 +1,61 @@ +import { LRUCache } from "lru-cache"; + +export interface RateLimitResult { + success: boolean; + remaining: number; + resetAt: number; +} + +interface Bucket { + count: number; + resetAt: number; +} + +export class RateLimiter { + private cache: LRUCache; + private maxTokens: number; + private windowMs: number; + + constructor(maxTokens: number, windowMs: number, capacity = 10000) { + this.maxTokens = maxTokens; + this.windowMs = windowMs; + this.cache = new LRUCache({ + max: capacity, + ttl: windowMs, + ttlResolution: windowMs, + }); + } + + limit(key: string): RateLimitResult { + const now = Date.now(); + const existing = this.cache.get(key); + if (!existing || existing.resetAt <= now) { + const resetAt = now + this.windowMs; + const bucket: Bucket = { count: 1, resetAt }; + this.cache.set(key, bucket); + return { success: true, remaining: this.maxTokens - 1, resetAt }; + } + if (existing.count >= this.maxTokens) { + return { success: false, remaining: 0, resetAt: existing.resetAt }; + } + existing.count += 1; + return { + success: true, + remaining: this.maxTokens - existing.count, + resetAt: existing.resetAt, + }; + } +} + +export const adminLoginLimiter = new RateLimiter(5, 60_000); +export const validateCodeLimiter = new RateLimiter(20, 60_000); +export const checkSubdomainLimiter = new RateLimiter(60, 60_000); +export const uploadLimiter = new RateLimiter(10, 60_000); + +export function rateLimitHeaders(result: RateLimitResult): Record { + return { + "X-RateLimit-Limit": String(result.success ? result.remaining + 1 : result.remaining), + "X-RateLimit-Remaining": String(Math.max(result.remaining, 0)), + "X-RateLimit-Reset": String(Math.floor(result.resetAt / 1000)), + }; +} diff --git a/src/lib/templates.tsx b/src/lib/templates.tsx deleted file mode 100644 index a932146..0000000 --- a/src/lib/templates.tsx +++ /dev/null @@ -1,216 +0,0 @@ -import type { MemorialData } from "@/types"; - -function formatDates(born: string | null, passed: string | null): string { - if (born && passed) return `${born} \u2014 ${passed}`; - if (passed) return passed; - if (born) return born; - return ""; -} - -function MemorialFooter({ name }: { name: string | null }) { - return ( -
-
- - - -
-

Во спомен на

- {name &&

{name}

} -
- ); -} - -export function renderTemplate(templateId: number, data: MemorialData) { - switch (templateId) { - case 1: - return ; - case 2: - return ; - case 3: - return ; - default: - return ; - } -} - -export function TemplateElegance({ data }: { data: MemorialData }) { - const sortedImages = [...data.images].sort((a, b) => a.order - b.order); - const heroImage = sortedImages[0]; - const gridImages = sortedImages.slice(1); - const dates = formatDates(data.bornDate, data.passedDate); - - return ( -
- {heroImage && ( -
- {data.title -
-
-

- {data.title || "Во спомен на"} -

- {dates && ( -

- {dates} -

- )} -
-
- )} - - {!heroImage && ( -
-

- {data.title || "Во спомен на"} -

- {dates && ( -

{dates}

- )} -
-
- )} - -
- {data.description && ( -
-

- {data.description} -

-
- )} - - {gridImages.length > 0 && ( -
- {gridImages.map((img) => ( -
- -
- ))} -
- )} - - -
-
- ); -} - -export function TemplateCinematic({ data }: { data: MemorialData }) { - const sortedImages = [...data.images].sort((a, b) => a.order - b.order); - const dates = formatDates(data.bornDate, data.passedDate); - - return ( -
- {sortedImages.length > 0 && ( - <> -
- {data.title -
-
-

- {data.title || "Во спомен на"} -

- {dates && ( -

- {dates} -

- )} - {data.description && ( -

- {data.description} -

- )} -
-
- - {sortedImages.length > 1 && ( -
- {sortedImages.slice(1).map((img) => ( -
- -
- ))} -
- )} - - )} - - {sortedImages.length === 0 && ( -
-
-

- {data.title || "Во спомен на"} -

- {dates && ( -

{dates}

- )} - {data.description && ( -

- {data.description} -

- )} -
-
- )} - -
-
- - - -
-

Во спомен на

-
-
- ); -} - -export function TemplateSerene({ data }: { data: MemorialData }) { - const sortedImages = [...data.images].sort((a, b) => a.order - b.order); - const dates = formatDates(data.bornDate, data.passedDate); - - return ( -
-
-
- {sortedImages.length > 0 && ( -
- {data.title -
- )} - -

- {data.title || "Во спомен на"} -

- - {dates && ( -

{dates}

- )} - -
-
- - {data.description && ( -
-

- {data.description} -

-
- )} - - {sortedImages.length > 1 && ( -
- {sortedImages.slice(1).map((img) => ( -
- -
- ))} -
- )} - - -
-
- ); -} \ No newline at end of file diff --git a/src/lib/templates/Cinematic.tsx b/src/lib/templates/Cinematic.tsx new file mode 100644 index 0000000..8afd8ba --- /dev/null +++ b/src/lib/templates/Cinematic.tsx @@ -0,0 +1,74 @@ +import type { MemorialData } from "@/types"; +import { formatDates } from "./shared"; + +export function TemplateCinematic({ data }: { data: MemorialData }) { + const sortedImages = [...data.images].sort((a, b) => a.order - b.order); + const dates = formatDates(data.bornDate, data.passedDate); + + return ( +
+ {sortedImages.length > 0 && ( + <> +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + {data.title +
+
+

+ {data.title || "Во спомен на"} +

+ {dates && ( +

+ {dates} +

+ )} + {data.description && ( +

+ {data.description} +

+ )} +
+
+ + {sortedImages.length > 1 && ( +
+ {sortedImages.slice(1).map((img) => ( +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + +
+ ))} +
+ )} + + )} + + {sortedImages.length === 0 && ( +
+
+

+ {data.title || "Во спомен на"} +

+ {dates && ( +

{dates}

+ )} + {data.description && ( +

+ {data.description} +

+ )} +
+
+ )} + +
+
+ + + +
+

Во спомен на

+
+
+ ); +} diff --git a/src/lib/templates/Elegance.tsx b/src/lib/templates/Elegance.tsx new file mode 100644 index 0000000..5c0173a --- /dev/null +++ b/src/lib/templates/Elegance.tsx @@ -0,0 +1,66 @@ +import type { MemorialData } from "@/types"; +import { formatDates, MemorialFooter } from "./shared"; + +export function TemplateElegance({ data }: { data: MemorialData }) { + const sortedImages = [...data.images].sort((a, b) => a.order - b.order); + const heroImage = sortedImages[0]; + const gridImages = sortedImages.slice(1); + const dates = formatDates(data.bornDate, data.passedDate); + + return ( +
+ {heroImage && ( +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + {data.title +
+
+

+ {data.title || "Во спомен на"} +

+ {dates && ( +

+ {dates} +

+ )} +
+
+ )} + + {!heroImage && ( +
+

+ {data.title || "Во спомен на"} +

+ {dates && ( +

{dates}

+ )} +
+
+ )} + +
+ {data.description && ( +
+

+ {data.description} +

+
+ )} + + {gridImages.length > 0 && ( +
+ {gridImages.map((img) => ( +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + +
+ ))} +
+ )} + + +
+
+ ); +} diff --git a/src/lib/templates/Serene.tsx b/src/lib/templates/Serene.tsx new file mode 100644 index 0000000..55b6bcc --- /dev/null +++ b/src/lib/templates/Serene.tsx @@ -0,0 +1,53 @@ +import type { MemorialData } from "@/types"; +import { formatDates, MemorialFooter } from "./shared"; + +export function TemplateSerene({ data }: { data: MemorialData }) { + const sortedImages = [...data.images].sort((a, b) => a.order - b.order); + const dates = formatDates(data.bornDate, data.passedDate); + + return ( +
+
+
+ {sortedImages.length > 0 && ( +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + {data.title +
+ )} + +

+ {data.title || "Во спомен на"} +

+ + {dates && ( +

{dates}

+ )} + +
+
+ + {data.description && ( +
+

+ {data.description} +

+
+ )} + + {sortedImages.length > 1 && ( +
+ {sortedImages.slice(1).map((img) => ( +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + +
+ ))} +
+ )} + + +
+
+ ); +} diff --git a/src/lib/templates/index.tsx b/src/lib/templates/index.tsx new file mode 100644 index 0000000..de63361 --- /dev/null +++ b/src/lib/templates/index.tsx @@ -0,0 +1,20 @@ +import type { MemorialData } from "@/types"; +import { TemplateElegance } from "./Elegance"; +import { TemplateCinematic } from "./Cinematic"; +import { TemplateSerene } from "./Serene"; + +export { TemplateElegance, TemplateCinematic, TemplateSerene }; +export { formatDates, MemorialFooter } from "./shared"; + +export function renderTemplate(templateId: number, data: MemorialData) { + switch (templateId) { + case 1: + return ; + case 2: + return ; + case 3: + return ; + default: + return ; + } +} diff --git a/src/lib/templates/shared.tsx b/src/lib/templates/shared.tsx new file mode 100644 index 0000000..ae75c1f --- /dev/null +++ b/src/lib/templates/shared.tsx @@ -0,0 +1,22 @@ +import type { MemorialData } from "@/types"; + +export function formatDates(born: string | null, passed: string | null): string { + if (born && passed) return `${born} \u2014 ${passed}`; + if (passed) return passed; + if (born) return born; + return ""; +} + +export function MemorialFooter({ name }: { name: string | null }) { + return ( +
+
+ + + +
+

Во спомен на

+ {name &&

{name}

} +
+ ); +} diff --git a/src/lib/upload.ts b/src/lib/upload.ts index 1f78bfd..0fedeac 100644 --- a/src/lib/upload.ts +++ b/src/lib/upload.ts @@ -4,16 +4,38 @@ import { v4 as uuidv4 } from "uuid"; import { s3Client, S3_BUCKET, getPublicUrl } from "./s3"; const MAX_FILE_SIZE = 5 * 1024 * 1024; -const ALLOWED_TYPES = ["image/jpeg", "image/png", "image/webp", "image/gif"]; +const ALLOWED_TYPES = ["image/jpeg", "image/png", "image/webp"] as const; const MAX_FILES = 3; export { MAX_FILE_SIZE, ALLOWED_TYPES, MAX_FILES, getPublicUrl }; +export type AllowedImageType = (typeof ALLOWED_TYPES)[number]; + +const MAGIC_BYTES: Record boolean>> = { + "image/jpeg": [(b) => b.length >= 3 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff], + "image/png": [(b) => b.length >= 8 && b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47 && b[4] === 0x0d && b[5] === 0x0a && b[6] === 0x1a && b[7] === 0x0a], + "image/webp": [(b) => b.length >= 12 && b.slice(0, 4).toString("ascii") === "RIFF" && b.slice(8, 12).toString("ascii") === "WEBP"], +}; + +export function detectImageType(buf: Buffer): AllowedImageType | null { + for (const type of ALLOWED_TYPES) { + if (MAGIC_BYTES[type].some((check) => check(buf))) return type; + } + return null; +} + +export function sanitizeUploadKey(key: string): string { + if (!/^uploads\/[a-zA-Z0-9_-]+\/[a-f0-9-]+\.(jpe?g|png|webp)$/i.test(key)) { + throw new Error("Invalid key format"); + } + return key; +} + export async function generatePresignedUrl( contentType: string, userId: string ): Promise<{ url: string; key: string; publicUrl: string }> { - if (!ALLOWED_TYPES.includes(contentType)) { + if (!ALLOWED_TYPES.includes(contentType as AllowedImageType)) { throw new Error(`Invalid content type: ${contentType}`); } @@ -33,4 +55,4 @@ export async function generatePresignedUrl( key, publicUrl: getPublicUrl(key), }; -} \ No newline at end of file +} diff --git a/src/middleware.ts b/src/middleware.ts index 2964296..b2fca09 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -1,9 +1,14 @@ import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server"; import { NextResponse } from "next/server"; import type { NextRequest } from "next/server"; +import { verifyAdminSession, COOKIE_NAME_ADMIN } from "@/lib/admin-session"; const isProtectedRoute = createRouteMatcher(["/dashboard(.*)", "/onboarding(.*)", "/api/publish(.*)", "/api/upload(.*)", "/api/user(.*)"]); +function isAdminRoute(pathname: string): boolean { + return pathname.startsWith("/admin") || pathname.startsWith("/api/admin"); +} + function getSubdomain(req: NextRequest): string | null { const host = req.headers.get("host"); if (!host) return null; @@ -25,6 +30,22 @@ function getSubdomain(req: NextRequest): string | null { } export default clerkMiddleware(async (auth, req: NextRequest) => { + if (isAdminRoute(req.nextUrl.pathname)) { + if (req.nextUrl.pathname === "/admin/login" || req.nextUrl.pathname === "/api/admin/login") { + return NextResponse.next(); + } + + const token = req.cookies.get(COOKIE_NAME_ADMIN)?.value; + if (!token || !(await verifyAdminSession(token))) { + if (req.nextUrl.pathname.startsWith("/api/")) { + return NextResponse.json({ error: "Неавторизирано" }, { status: 401 }); + } + return NextResponse.redirect(new URL("/admin/login", req.url)); + } + + return NextResponse.next(); + } + if (isProtectedRoute(req)) { await auth.protect(); } @@ -41,4 +62,4 @@ export default clerkMiddleware(async (auth, req: NextRequest) => { export const config = { matcher: ["/(api|trpc)(.*)", "/__clerk/:path*", "/((?!_next|api/static|.*\\..*).*)"], -}; \ No newline at end of file +}; diff --git a/test.md b/test.md deleted file mode 100644 index 5d308e1..0000000 --- a/test.md +++ /dev/null @@ -1 +0,0 @@ -aaaa diff --git a/tsconfig.json b/tsconfig.json index 34939b2..6bf66d4 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -34,9 +34,13 @@ "**/*.tsx", ".next/types/**/*.ts", ".next/dev/types/**/*.ts", - "**/*.mts" + "**/*.mts", + "vitest.config.ts" ], "exclude": [ - "node_modules" + "node_modules", + ".next", + ".next_old", + "src/**/*.test.ts" ] } diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..ce84ae5 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from "vitest/config"; +import path from "node:path"; + +export default defineConfig({ + resolve: { + alias: { + "@": path.resolve(__dirname, "src"), + }, + }, + test: { + environment: "node", + include: ["src/**/*.test.ts"], + globals: false, + }, +});