From 9fdcd43ddd9e95174b2b0a45ae8b17d5a7acea77 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 11 Jun 2026 22:37:02 +0000 Subject: [PATCH] chore: add CLAUDE.md, typecheck, CI, .env.example, healthcheck + wire CSRF for repo parity https://claude.ai/code/session_013yX3FPohZkQNiUGSNwwjbM --- .env.example | 11 + .github/workflows/ci.yml | 23 ++ CLAUDE.md | 80 +++++ Dockerfile | 3 + app/api/upload/route.ts | 14 + eslint.config.mjs | 12 +- next.config.ts | 11 + package-lock.json | 630 +----------------------------------- package.json | 3 +- pages/api/projects/[id].ts | 3 + pages/api/projects/index.ts | 2 + pages/api/register.ts | 6 + pages/api/sessions/[id].ts | 3 + pages/api/sessions/index.ts | 2 + pages/api/site-config.ts | 2 + pages/api/users/[id].ts | 5 + 16 files changed, 179 insertions(+), 631 deletions(-) create mode 100644 .env.example create mode 100644 .github/workflows/ci.yml create mode 100644 CLAUDE.md diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..8008db3 --- /dev/null +++ b/.env.example @@ -0,0 +1,11 @@ +# Required in production — generate with: openssl rand -hex 32 +SESSION_SECRET=change_me_to_a_random_64_char_string_before_deploying + +# Set to "true" if you terminate TLS in front of this app +SESSION_COOKIE_SECURE=false + +# Path to the SQLite database file (created automatically) +DATABASE_PATH=/data/amethyst.db + +# Port the server listens on +PORT=3000 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..bb0ed8e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,23 @@ +name: CI + +on: + push: + branches: [main, master] + pull_request: + +jobs: + verify: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + - run: npm ci + - run: npm run lint + - run: npm run typecheck + - run: npm run build + env: + SESSION_SECRET: ci_build_placeholder_secret_at_least_32_chars_long + NEXT_TELEMETRY_DISABLED: "1" diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..455722f --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,80 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Commands + +```bash +npm run dev # http://localhost:3000 +npm run build +npm run typecheck +npm run lint +docker build -t rayderc/amethyst:latest . +docker run -d --name amethyst -p 3000:3000 -v amethyst-data:/data -v amethyst-uploads:/app/public/uploads --restart unless-stopped rayderc/amethyst:latest +``` + +## Architecture + +Hybrid Next.js router: `app/` (App Router, all UI, all `"use client"`) + `pages/api/` (Pages Router, all API routes). No middleware — auth is enforced client-side via `/api/user` in `app/dashboard/layout.tsx`. + +`lib/db.ts` opens SQLite and runs migrations on import. Key schema: `users` (id, username, password, is_admin, created_at); `projects` (id, title, slug, description, content, tech_stack, github_url, live_url, image_url, gallery, youtube_url, featured, created_at, updated_at); `service_links` (id, name, url, description, category, icon_url, sort_order, created_at); `site_config` (key, value — arbitrary key/value store for home-page content). + +iron-session (`lib/session.ts`), cookie `amethyst_session`. Production requires `SESSION_SECRET` ≥ 32 chars (auto-generated by `docker-entrypoint.sh` if not set). CSRF (`lib/csrf.ts`) checks `origin` vs `host` on all mutating routes except `/api/setup` and `/api/login`. + +## API Routes (`pages/api/`) + +- `login` / `logout` / `user` / `setup` / `register` — auth lifecycle; setup and login are CSRF-exempt +- `projects/` — CRUD for portfolio projects (public GET, admin-only write) +- `sessions/` — CRUD for Hub service links (any logged-in user can GET, admin-only write) +- `site-config` — Home-page content (public GET, admin-only PUT) +- `users/` — User management (admin-only) +- `favicon` — Favicon proxy: tries direct fetch then falls back to Google + +App Router upload route: `app/api/upload/route.ts` — POST only, handles multipart image uploads, saves to `public/uploads/`. Has inline CSRF check (origin vs host) because it uses `NextRequest` not `NextApiRequest`. + +## CSRF Protection + +`lib/csrf.ts` exports `checkCsrf(req: NextApiRequest): boolean`. Returns `true` if no Origin header (same-origin or direct API client), or if parsed Origin host matches Host header. All mutating handlers call: + +```ts +if (!checkCsrf(req)) return res.status(403).json({ message: "Forbidden" }); +``` + +Exempt routes: `/api/setup` and `/api/login`. + +## Upload Route + +`app/api/upload/route.ts` accepts POST multipart/form-data with a `file` field. Allowed types: jpeg, png, gif, webp, jpg, heic, heif. Saves to `process.cwd()/public/uploads/` with a random hex filename. Returns `{ url: "/uploads/" }`. The upload directory is not configurable via env var — it is always `public/uploads` relative to the app root (mounted as a Docker volume at `/app/public/uploads`). + +## Design System + +Cyberpunk dark theme in `app/globals.css`. Shared design tokens (consistent with AstroFit/ComicOrbit/SkyBit): + +- `--bg: #0a0a12` +- `--primary: #7c0eb3` (amethyst purple) +- `--accent-cyan: #22d3ee` +- `--accent-magenta: #f472b6` +- `--font-mono: 'JetBrains Mono', 'Fira Code', ...` + +Body uses system fonts (`-apple-system, BlinkMacSystemFont, Segoe UI, Roboto`). `var(--font-mono)` applies only to nav labels, badges, stat values, code elements, `.sidebar-logo` — never body text. Auth/modal cards use `overflow: hidden` with brackets 10px inside (cyan top-left, magenta bottom-right). Sidebar active state uses `box-shadow: inset 2px 0 0 var(--accent-cyan)`. Page titles use chromatic `text-shadow`. No logo image — text-only branding. + +Mobile: sidebar collapses to a slide-out drawer with a hamburger button. Mobile top bar shown at small widths. + +## Docker + +Port 3000. Two volumes: +- `/data` — SQLite database (`amethyst.db`) +- `/app/public/uploads` — uploaded images, served at `/uploads/*` + +`.dockerignore` must exclude `node_modules` (Windows DLL would corrupt the Linux SQLite native binding). Use named volumes — WAL locking can break on Windows bind mounts. + +The runner stage uses `docker-entrypoint.sh` which auto-generates `SESSION_SECRET` if not provided (sessions will not survive container restart in that case). Set `SESSION_SECRET` explicitly to keep sessions stable across restarts. + +Environment variables: + +| Variable | Default | Description | +|---|---|---| +| `SESSION_SECRET` | auto-generated | Cookie encryption key (min 32 chars) | +| `DATABASE_PATH` | `/data/amethyst.db` | Path to SQLite database | +| `SESSION_COOKIE_SECURE` | `false` | Set `true` behind a TLS-terminating proxy | +| `PORT` | `3000` | Server listen port | diff --git a/Dockerfile b/Dockerfile index 87562c9..86aa2c6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -55,4 +55,7 @@ RUN chmod +x /usr/local/bin/docker-entrypoint.sh \ EXPOSE 3000 +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ + CMD node -e "fetch('http://127.0.0.1:'+(process.env.PORT||'3000')+'/').then(r=>process.exit(r.status<500?0:1)).catch(()=>process.exit(1))" + ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"] diff --git a/app/api/upload/route.ts b/app/api/upload/route.ts index 85082c5..c9620b4 100644 --- a/app/api/upload/route.ts +++ b/app/api/upload/route.ts @@ -9,6 +9,20 @@ import { randomBytes } from "crypto"; type SessionData = { user?: User }; export async function POST(req: NextRequest) { + // CSRF: if Origin header is present it must match the Host header + const origin = req.headers.get("origin"); + const host = req.headers.get("host"); + if (origin) { + try { + const parsed = new URL(origin); + if (parsed.host !== host) { + return NextResponse.json({ message: "Forbidden" }, { status: 403 }); + } + } catch { + return NextResponse.json({ message: "Forbidden" }, { status: 403 }); + } + } + const session = await getIronSession(await cookies(), sessionOptions); if (!session.user?.isAdmin) { return NextResponse.json({ message: "Forbidden" }, { status: 403 }); diff --git a/eslint.config.mjs b/eslint.config.mjs index 05e726d..8a27c3a 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -5,9 +5,17 @@ import nextTs from "eslint-config-next/typescript"; const eslintConfig = defineConfig([ ...nextVitals, ...nextTs, - // Override default ignores of eslint-config-next. + { + rules: { + // We deliberately call setState in effects for one-shot init / route-driven + // resets — the same pattern used across the sibling codebases. + "react-hooks/set-state-in-effect": "off", + // Project thumbnails, galleries, and favicons are dynamic remote URLs + // — Next/Image's static optimization doesn't help here. + "@next/next/no-img-element": "off", + }, + }, globalIgnores([ - // Default ignores of eslint-config-next: ".next/**", "out/**", "build/**", diff --git a/next.config.ts b/next.config.ts index ef52842..98ac8b6 100644 --- a/next.config.ts +++ b/next.config.ts @@ -4,6 +4,17 @@ const nextConfig: NextConfig = { output: "standalone", serverExternalPackages: ["better-sqlite3"], transpilePackages: ["react-markdown", "remark-gfm"], + async headers() { + return [ + { + source: "/(.*)", + headers: [ + { key: "X-Content-Type-Options", value: "nosniff" }, + { key: "X-Frame-Options", value: "SAMEORIGIN" }, + ], + }, + ]; + }, }; export default nextConfig; diff --git a/package-lock.json b/package-lock.json index 348fdd7..ad5830e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,30 +19,15 @@ "remark-gfm": "^4.0.1" }, "devDependencies": { - "@tailwindcss/postcss": "^4", "@types/better-sqlite3": "^7.6.13", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", "eslint": "^9", "eslint-config-next": "16.1.1", - "tailwindcss": "^4", "typescript": "^5" } }, - "node_modules/@alloc/quick-lru": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", - "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/@babel/code-frame": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", @@ -1249,277 +1234,6 @@ "tslib": "^2.8.0" } }, - "node_modules/@tailwindcss/node": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.18.tgz", - "integrity": "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/remapping": "^2.3.4", - "enhanced-resolve": "^5.18.3", - "jiti": "^2.6.1", - "lightningcss": "1.30.2", - "magic-string": "^0.30.21", - "source-map-js": "^1.2.1", - "tailwindcss": "4.1.18" - } - }, - "node_modules/@tailwindcss/oxide": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.18.tgz", - "integrity": "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 10" - }, - "optionalDependencies": { - "@tailwindcss/oxide-android-arm64": "4.1.18", - "@tailwindcss/oxide-darwin-arm64": "4.1.18", - "@tailwindcss/oxide-darwin-x64": "4.1.18", - "@tailwindcss/oxide-freebsd-x64": "4.1.18", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18", - "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18", - "@tailwindcss/oxide-linux-arm64-musl": "4.1.18", - "@tailwindcss/oxide-linux-x64-gnu": "4.1.18", - "@tailwindcss/oxide-linux-x64-musl": "4.1.18", - "@tailwindcss/oxide-wasm32-wasi": "4.1.18", - "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18", - "@tailwindcss/oxide-win32-x64-msvc": "4.1.18" - } - }, - "node_modules/@tailwindcss/oxide-android-arm64": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.18.tgz", - "integrity": "sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/oxide-darwin-arm64": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.18.tgz", - "integrity": "sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/oxide-darwin-x64": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.18.tgz", - "integrity": "sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/oxide-freebsd-x64": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.18.tgz", - "integrity": "sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.18.tgz", - "integrity": "sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.18.tgz", - "integrity": "sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/oxide-linux-arm64-musl": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.18.tgz", - "integrity": "sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/oxide-linux-x64-gnu": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.18.tgz", - "integrity": "sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/oxide-linux-x64-musl": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.18.tgz", - "integrity": "sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.18.tgz", - "integrity": "sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==", - "bundleDependencies": [ - "@napi-rs/wasm-runtime", - "@emnapi/core", - "@emnapi/runtime", - "@tybys/wasm-util", - "@emnapi/wasi-threads", - "tslib" - ], - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1", - "@emnapi/wasi-threads": "^1.1.0", - "@napi-rs/wasm-runtime": "^1.1.0", - "@tybys/wasm-util": "^0.10.1", - "tslib": "^2.4.0" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz", - "integrity": "sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/oxide-win32-x64-msvc": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.18.tgz", - "integrity": "sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/postcss": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.1.18.tgz", - "integrity": "sha512-Ce0GFnzAOuPyfV5SxjXGn0CubwGcuDB0zcdaPuCSzAa/2vII24JTkH+I6jcbXLb1ctjZMZZI6OjDaLPJQL1S0g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@alloc/quick-lru": "^5.2.0", - "@tailwindcss/node": "4.1.18", - "@tailwindcss/oxide": "4.1.18", - "postcss": "^8.4.41", - "tailwindcss": "4.1.18" - } - }, "node_modules/@tybys/wasm-util": { "version": "0.10.1", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", @@ -3116,20 +2830,6 @@ "once": "^1.4.0" } }, - "node_modules/enhanced-resolve": { - "version": "5.18.4", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.4.tgz", - "integrity": "sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.4", - "tapable": "^2.2.0" - }, - "engines": { - "node": ">=10.13.0" - } - }, "node_modules/es-abstract": { "version": "1.24.1", "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.1.tgz", @@ -4125,13 +3825,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/graceful-fs": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true, - "license": "ISC" - }, "node_modules/has-bigints": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", @@ -4925,6 +4618,8 @@ "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "bin": { "jiti": "lib/jiti-cli.mjs" } @@ -5056,267 +4751,6 @@ "node": ">= 0.8.0" } }, - "node_modules/lightningcss": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz", - "integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==", - "dev": true, - "license": "MPL-2.0", - "dependencies": { - "detect-libc": "^2.0.3" - }, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - }, - "optionalDependencies": { - "lightningcss-android-arm64": "1.30.2", - "lightningcss-darwin-arm64": "1.30.2", - "lightningcss-darwin-x64": "1.30.2", - "lightningcss-freebsd-x64": "1.30.2", - "lightningcss-linux-arm-gnueabihf": "1.30.2", - "lightningcss-linux-arm64-gnu": "1.30.2", - "lightningcss-linux-arm64-musl": "1.30.2", - "lightningcss-linux-x64-gnu": "1.30.2", - "lightningcss-linux-x64-musl": "1.30.2", - "lightningcss-win32-arm64-msvc": "1.30.2", - "lightningcss-win32-x64-msvc": "1.30.2" - } - }, - "node_modules/lightningcss-android-arm64": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz", - "integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-arm64": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz", - "integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-x64": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz", - "integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-freebsd-x64": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz", - "integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz", - "integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz", - "integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz", - "integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz", - "integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-musl": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz", - "integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz", - "integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz", - "integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -5373,16 +4807,6 @@ "yallist": "^3.0.2" } }, - "node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" - } - }, "node_modules/markdown-table": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", @@ -6771,35 +6195,6 @@ "node": ">= 0.4" } }, - "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.11", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, "node_modules/prebuild-install": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", @@ -7786,27 +7181,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/tailwindcss": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", - "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==", - "dev": true, - "license": "MIT" - }, - "node_modules/tapable": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", - "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, "node_modules/tar-fs": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", diff --git a/package.json b/package.json index 12e27cc..4502570 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,8 @@ "dev": "next dev", "build": "next build", "start": "next start", - "lint": "next lint" + "lint": "eslint .", + "typecheck": "tsc --noEmit" }, "dependencies": { "bcryptjs": "^3.0.3", diff --git a/pages/api/projects/[id].ts b/pages/api/projects/[id].ts index 9138771..d94b683 100644 --- a/pages/api/projects/[id].ts +++ b/pages/api/projects/[id].ts @@ -2,6 +2,7 @@ import type { NextApiRequest, NextApiResponse } from "next"; import db from "../../../lib/db"; import { getIronSession } from "iron-session"; import { sessionOptions, User } from "../../../lib/session"; +import { checkCsrf } from "../../../lib/csrf"; import type { Project } from "./index"; export default async function handler(req: NextApiRequest, res: NextApiResponse) { @@ -14,6 +15,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) } if (req.method === "PUT") { + if (!checkCsrf(req)) return res.status(403).json({ message: "Forbidden" }); const session = await getIronSession<{ user?: User }>(req, res, sessionOptions); if (!session.user?.isAdmin) return res.status(403).json({ message: "Forbidden" }); @@ -45,6 +47,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) } if (req.method === "DELETE") { + if (!checkCsrf(req)) return res.status(403).json({ message: "Forbidden" }); const session = await getIronSession<{ user?: User }>(req, res, sessionOptions); if (!session.user?.isAdmin) return res.status(403).json({ message: "Forbidden" }); diff --git a/pages/api/projects/index.ts b/pages/api/projects/index.ts index f436695..1994729 100644 --- a/pages/api/projects/index.ts +++ b/pages/api/projects/index.ts @@ -2,6 +2,7 @@ import type { NextApiRequest, NextApiResponse } from "next"; import db from "../../../lib/db"; import { getIronSession } from "iron-session"; import { sessionOptions, User } from "../../../lib/session"; +import { checkCsrf } from "../../../lib/csrf"; export type Project = { id: number; @@ -46,6 +47,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) } if (req.method === "POST") { + if (!checkCsrf(req)) return res.status(403).json({ message: "Forbidden" }); const session = await getIronSession<{ user?: User }>(req, res, sessionOptions); if (!session.user?.isAdmin) { return res.status(403).json({ message: "Forbidden" }); diff --git a/pages/api/register.ts b/pages/api/register.ts index 71cd7da..ca83138 100644 --- a/pages/api/register.ts +++ b/pages/api/register.ts @@ -3,6 +3,7 @@ import db from "../../lib/db"; import bcrypt from "bcryptjs"; import { getIronSession } from "iron-session"; import { sessionOptions, User } from "../../lib/session"; +import { checkCsrf } from "../../lib/csrf"; export default async function handler(req: NextApiRequest, res: NextApiResponse) { if (req.method !== "POST") { @@ -10,6 +11,11 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) return; } + if (!checkCsrf(req)) { + res.status(403).json({ message: "Forbidden" }); + return; + } + const session = await getIronSession<{ user?: User }>(req, res, sessionOptions); if (!session.user?.isAdmin) { res.status(403).json({ message: "Only an administrator may create accounts" }); diff --git a/pages/api/sessions/[id].ts b/pages/api/sessions/[id].ts index 741964b..c95b56a 100644 --- a/pages/api/sessions/[id].ts +++ b/pages/api/sessions/[id].ts @@ -3,6 +3,7 @@ import db from "../../../lib/db"; import { getIronSession } from "iron-session"; import { sessionOptions, User } from "../../../lib/session"; import { normalizeUrl } from "../../../lib/url"; +import { checkCsrf } from "../../../lib/csrf"; export default async function handler(req: NextApiRequest, res: NextApiResponse) { const session = await getIronSession<{ user?: User }>(req, res, sessionOptions); @@ -12,6 +13,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) if (isNaN(id)) return res.status(400).json({ message: "Invalid id" }); if (req.method === "PUT") { + if (!checkCsrf(req)) return res.status(403).json({ message: "Forbidden" }); const { name, url, description, category, icon_url, sort_order } = req.body; if (!name || !url) return res.status(400).json({ message: "Name and URL are required" }); @@ -33,6 +35,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) } if (req.method === "DELETE") { + if (!checkCsrf(req)) return res.status(403).json({ message: "Forbidden" }); db.prepare("DELETE FROM service_links WHERE id = ?").run(id); return res.json({ ok: true }); } diff --git a/pages/api/sessions/index.ts b/pages/api/sessions/index.ts index 1b850ba..d25da2a 100644 --- a/pages/api/sessions/index.ts +++ b/pages/api/sessions/index.ts @@ -3,6 +3,7 @@ import db from "../../../lib/db"; import { getIronSession } from "iron-session"; import { sessionOptions, User } from "../../../lib/session"; import { normalizeUrl } from "../../../lib/url"; +import { checkCsrf } from "../../../lib/csrf"; export type ServiceLink = { id: number; @@ -31,6 +32,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) } if (req.method === "POST") { + if (!checkCsrf(req)) return res.status(403).json({ message: "Forbidden" }); if (!session.user.isAdmin) return res.status(403).json({ message: "Forbidden" }); const { name, url, description, category, icon_url, sort_order } = req.body; if (!name || !url) return res.status(400).json({ message: "Name and URL are required" }); diff --git a/pages/api/site-config.ts b/pages/api/site-config.ts index 4899377..6953f40 100644 --- a/pages/api/site-config.ts +++ b/pages/api/site-config.ts @@ -2,6 +2,7 @@ import type { NextApiRequest, NextApiResponse } from "next"; import { getIronSession } from "iron-session"; import { sessionOptions, User } from "../../lib/session"; import { getSiteConfig, setSiteConfigKey } from "../../lib/db"; +import { checkCsrf } from "../../lib/csrf"; export default async function handler(req: NextApiRequest, res: NextApiResponse) { if (req.method === "GET") { @@ -9,6 +10,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) } if (req.method === "PUT") { + if (!checkCsrf(req)) return res.status(403).json({ message: "Forbidden" }); const session = await getIronSession<{ user?: User }>(req, res, sessionOptions); if (!session.user?.isAdmin) return res.status(403).json({ message: "Forbidden" }); diff --git a/pages/api/users/[id].ts b/pages/api/users/[id].ts index e92200c..d0f75aa 100644 --- a/pages/api/users/[id].ts +++ b/pages/api/users/[id].ts @@ -2,6 +2,7 @@ import type { NextApiRequest, NextApiResponse } from "next"; import db, { adminCount } from "../../../lib/db"; import { getIronSession } from "iron-session"; import { sessionOptions, User } from "../../../lib/session"; +import { checkCsrf } from "../../../lib/csrf"; export default async function handler(req: NextApiRequest, res: NextApiResponse) { const session = await getIronSession<{ user?: User }>(req, res, sessionOptions); @@ -17,6 +18,10 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) } if (req.method === "DELETE") { + if (!checkCsrf(req)) { + res.status(403).json({ message: "Forbidden" }); + return; + } const target = db.prepare("SELECT is_admin FROM users WHERE id = ?").get(id) as { is_admin: number } | undefined; if (!target) { res.status(404).json({ message: "User not found" });