Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -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
23 changes: 23 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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"
80 changes: 80 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -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/<filename>" }`. 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 |
3 changes: 3 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
14 changes: 14 additions & 0 deletions app/api/upload/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<SessionData>(await cookies(), sessionOptions);
if (!session.user?.isAdmin) {
return NextResponse.json({ message: "Forbidden" }, { status: 403 });
Expand Down
12 changes: 10 additions & 2 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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/**",
Expand Down
11 changes: 11 additions & 0 deletions next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Loading
Loading