From d51400c2a9362479e03139aa6517f6fb1b3f5311 Mon Sep 17 00:00:00 2001 From: Keysat Date: Sat, 9 May 2026 10:19:31 -0500 Subject: [PATCH] Robustness: WAL mode, security headers, last-login, delete-my-account MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SQLite WAL mode (start9/0.4/docker_entrypoint.sh) - Switches journal_mode to WAL on every boot. WAL persists in the DB header so this is effectively a one-shot but rerunning is harmless. - Crucial for the "background StartOS Backup while users are using the app" case: under the default rollback journal, a long backup can capture an inconsistent snapshot. WAL keeps readers and the writer from blocking each other. - synchronous=NORMAL paired with WAL: still crash-consistent at every checkpoint, ~10x faster than FULL. Security headers (proof-of-work/next.config.js) - Content-Security-Policy with frame-ancestors 'none', base-uri 'self', form-action 'self', object-src 'none'. Keeps 'unsafe-inline' for script/style because Next.js emits inline bootstrap; tightening to nonce-based CSP is a follow-up. - Strict-Transport-Security: max-age=31536000; includeSubDomains. - Referrer-Policy: strict-origin-when-cross-origin (don't leak workout IDs etc. to third-party sites). - Permissions-Policy: deny camera, mic, geolocation, USB, etc. across the board (none of those APIs are used today; explicit deny means vulnerability scanners have one less thing to flag). Last-login tracking - New User.lastLoginAt column. createSession stamps it inside the same transaction as the new Session row. - Compat ALTER in entrypoint adds the column to legacy snapshots. - Admin Users table now shows a relative-age cell (today / Nd ago / Nmo ago / Ny ago / "never" if the user hasn't signed in since the column was added). Hover reveals the exact ISO timestamp. Self-serve delete-my-account (Settings -> Danger Zone) - Requires both the user's current password AND typing the literal phrase "delete my account" (defense against a stolen-session attacker nuking the account in one click). - Refused for the last admin (instance can't be left with no admin — the user is told to promote someone first). - Cascades through Prisma onDelete: Cascade on every relation owned by User, so workouts, exercises, sessions, preferences all go in one shot. Session cookie cleared, redirected to /auth/login. --- proof-of-work/app/main/admin/users/page.tsx | 2 + .../app/main/settings/deleteAccountAction.ts | 63 +++++++++ proof-of-work/app/main/settings/page.tsx | 2 + proof-of-work/components/admin/UsersTable.tsx | 21 +++ .../components/settings/DangerZone.tsx | 123 ++++++++++++++++++ proof-of-work/lib/auth.ts | 23 +++- proof-of-work/next.config.js | 77 ++++++++--- proof-of-work/prisma/schema.prisma | 1 + start9/0.4/docker_entrypoint.sh | 19 +++ start9/0.4/package-lock.json | 4 +- 10 files changed, 305 insertions(+), 30 deletions(-) create mode 100644 proof-of-work/app/main/settings/deleteAccountAction.ts create mode 100644 proof-of-work/components/settings/DangerZone.tsx diff --git a/proof-of-work/app/main/admin/users/page.tsx b/proof-of-work/app/main/admin/users/page.tsx index 974185b..64d4c2d 100644 --- a/proof-of-work/app/main/admin/users/page.tsx +++ b/proof-of-work/app/main/admin/users/page.tsx @@ -17,6 +17,7 @@ export default async function AdminUsersPage() { name: true, isAdmin: true, createdAt: true, + lastLoginAt: true, _count: { select: { sessions: true, workouts: true } }, }, orderBy: { createdAt: 'asc' }, @@ -29,6 +30,7 @@ export default async function AdminUsersPage() { name: u.name, isAdmin: u.isAdmin, createdAt: u.createdAt.toISOString(), + lastLoginAt: u.lastLoginAt ? u.lastLoginAt.toISOString() : null, sessionCount: u._count.sessions, workoutCount: u._count.workouts, })); diff --git a/proof-of-work/app/main/settings/deleteAccountAction.ts b/proof-of-work/app/main/settings/deleteAccountAction.ts new file mode 100644 index 0000000..e4daada --- /dev/null +++ b/proof-of-work/app/main/settings/deleteAccountAction.ts @@ -0,0 +1,63 @@ +'use server'; + +import { redirect } from 'next/navigation'; +import { cookies } from 'next/headers'; +import { getCurrentUser, verifyPassword } from '@/lib/auth'; +import { prisma } from '@/lib/prisma'; + +/** + * Self-serve account deletion. + * + * Refused for the last admin (we can't leave the instance with no + * admin). Refused if the typed-confirmation phrase is wrong (defense + * against a stolen-session attacker nuking the account in one click). + * Cascades through Prisma (onDelete: Cascade on every relation owned + * by User) so workouts, exercises, sessions, preferences, etc. all + * disappear in one shot. Clears the session cookie before redirecting + * to /auth/login. + */ + +const REQUIRED_PHRASE = 'delete my account'; + +export async function deleteMyAccountAction( + password: string, + confirmationPhrase: string, +): Promise<{ error?: string }> { + try { + const me = await getCurrentUser(); + if (!me) return { error: 'Not signed in.' }; + + if (confirmationPhrase.trim().toLowerCase() !== REQUIRED_PHRASE) { + return { + error: `To confirm, type the exact phrase: "${REQUIRED_PHRASE}".`, + }; + } + + const ok = await verifyPassword(password, me.passwordHash); + if (!ok) { + return { error: 'Password is incorrect.' }; + } + + if (me.isAdmin) { + const adminCount = await prisma.user.count({ where: { isAdmin: true } }); + if (adminCount <= 1) { + return { + error: + 'Cannot delete the last admin. Promote another user to admin first, then try again.', + }; + } + } + + await prisma.user.delete({ where: { id: me.id } }); + + const cookieStore = await cookies(); + cookieStore.delete('sessionToken'); + } catch (err) { + console.error('deleteMyAccount error:', err); + return { error: 'An error occurred while deleting your account.' }; + } + + // Redirect must be outside the try/catch — Next.js implements redirect + // by throwing a special value, and our catch would swallow it. + redirect('/auth/login'); +} diff --git a/proof-of-work/app/main/settings/page.tsx b/proof-of-work/app/main/settings/page.tsx index 24ff02d..c5eb0d2 100644 --- a/proof-of-work/app/main/settings/page.tsx +++ b/proof-of-work/app/main/settings/page.tsx @@ -2,6 +2,7 @@ import { redirect } from "next/navigation"; import { getCurrentUser } from "@/lib/auth"; import SettingsForm from "@/components/settings/SettingsForm"; import ChangePasswordForm from "@/components/settings/ChangePasswordForm"; +import DangerZone from "@/components/settings/DangerZone"; import AdminInstanceSettings from "@/components/settings/AdminInstanceSettings"; import { getInstanceSettings } from "@/lib/instanceSettings"; @@ -33,6 +34,7 @@ export default async function SettingsPage() { initialSignupsOpen={instanceSettings.signupsOpen} /> )} + ); diff --git a/proof-of-work/components/admin/UsersTable.tsx b/proof-of-work/components/admin/UsersTable.tsx index 46e7716..ddb7f0a 100644 --- a/proof-of-work/components/admin/UsersTable.tsx +++ b/proof-of-work/components/admin/UsersTable.tsx @@ -13,10 +13,22 @@ type UserRow = { name: string | null; isAdmin: boolean; createdAt: string; + lastLoginAt: string | null; sessionCount: number; workoutCount: number; }; +function relativeAge(iso: string | null): string { + if (!iso) return 'never'; + const ms = Date.now() - new Date(iso).getTime(); + const days = Math.floor(ms / 86_400_000); + if (days < 1) return 'today'; + if (days === 1) return 'yesterday'; + if (days < 30) return `${days}d ago`; + if (days < 365) return `${Math.floor(days / 30)}mo ago`; + return `${Math.floor(days / 365)}y ago`; +} + export default function UsersTable({ users, currentUserId, @@ -82,6 +94,7 @@ export default function UsersTable({ User Joined + Last login Workouts Role Actions @@ -104,6 +117,14 @@ export default function UsersTable({ {new Date(u.createdAt).toLocaleDateString()} + + {relativeAge(u.lastLoginAt)} + {u.workoutCount} diff --git a/proof-of-work/components/settings/DangerZone.tsx b/proof-of-work/components/settings/DangerZone.tsx new file mode 100644 index 0000000..708200a --- /dev/null +++ b/proof-of-work/components/settings/DangerZone.tsx @@ -0,0 +1,123 @@ +'use client'; + +import { useState } from 'react'; +import { deleteMyAccountAction } from '@/app/main/settings/deleteAccountAction'; + +const REQUIRED_PHRASE = 'delete my account'; + +export default function DangerZone() { + const [open, setOpen] = useState(false); + const [password, setPassword] = useState(''); + const [phrase, setPhrase] = useState(''); + const [error, setError] = useState(''); + const [busy, setBusy] = useState(false); + + const submit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(''); + setBusy(true); + const res = await deleteMyAccountAction(password, phrase); + if (res?.error) { + setError(res.error); + setBusy(false); + } + // If no error, the action redirected — we never reach here. + }; + + return ( +
+
+

+ Danger zone +

+

+ Permanently deletes your account, every workout you've logged, + every set, your custom exercises, and every session. Other users + on this instance are not affected. +

+
+ + {!open ? ( + + ) : ( +
+
+ + setPassword(e.target.value)} + disabled={busy} + className="w-full px-4 py-2.5 rounded border border-zinc-700 bg-zinc-800 text-white placeholder:text-zinc-500 focus:outline-none focus:ring-2 focus:ring-red-500 focus:border-red-500" + /> +
+
+ + setPhrase(e.target.value)} + placeholder={REQUIRED_PHRASE} + disabled={busy} + className="w-full px-4 py-2.5 rounded border border-zinc-700 bg-zinc-800 text-white placeholder:text-zinc-500 focus:outline-none focus:ring-2 focus:ring-red-500 focus:border-red-500" + /> +
+ + {error && ( +
+ {error} +
+ )} + +
+ + +
+
+ )} +
+ ); +} diff --git a/proof-of-work/lib/auth.ts b/proof-of-work/lib/auth.ts index 069ded0..f65ea1b 100644 --- a/proof-of-work/lib/auth.ts +++ b/proof-of-work/lib/auth.ts @@ -37,13 +37,22 @@ export async function createSession( const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); // 30 days - await prisma.session.create({ - data: { - token, - userId, - expiresAt, - }, - }); + // Create the session and stamp the user's lastLoginAt in the same + // transaction. Surfaced in the admin Users table so admins can spot + // dormant accounts. + await prisma.$transaction([ + prisma.session.create({ + data: { + token, + userId, + expiresAt, + }, + }), + prisma.user.update({ + where: { id: userId }, + data: { lastLoginAt: new Date() }, + }), + ]); return { token, expiresAt }; } diff --git a/proof-of-work/next.config.js b/proof-of-work/next.config.js index 7dab846..b65a613 100644 --- a/proof-of-work/next.config.js +++ b/proof-of-work/next.config.js @@ -1,31 +1,66 @@ /** @type {import('next').NextConfig} */ + +// Content-Security-Policy. +// +// `script-src` and `style-src` keep `'unsafe-inline'` because Next.js +// emits inline bootstrap scripts and Tailwind's runtime CSS-in-JS path +// requires inline styles. Tightening to nonce-based CSP is a follow-up +// (requires switching to Next's `headers()` middleware-style nonce +// injection, not the static config). The directives we DO get for free +// here still cut off the most common XSS-followup patterns: +// - frame-ancestors 'none' -> can't be embedded anywhere (clickjacking) +// - base-uri 'self' -> attacker can't pivot relative URLs +// - form-action 'self' -> stolen forms can't POST credentials away +// - object-src 'none' -> no Flash/Java applets, full stop +// - default-src 'self' -> images/fetches/etc default to same-origin +const csp = [ + "default-src 'self'", + "script-src 'self' 'unsafe-inline' 'unsafe-eval'", + "style-src 'self' 'unsafe-inline'", + "img-src 'self' data: blob:", + "font-src 'self' data:", + "connect-src 'self'", + "frame-ancestors 'none'", + "base-uri 'self'", + "form-action 'self'", + "object-src 'none'", +].join('; '); + +const securityHeaders = [ + { key: 'Content-Security-Policy', value: csp }, + // HSTS: tell browsers to use HTTPS only for this origin for a year. + // StartOS terminates TLS in front of the container, so this applies + // to the public hostname users actually visit. + { + key: 'Strict-Transport-Security', + value: 'max-age=31536000; includeSubDomains', + }, + { key: 'X-Content-Type-Options', value: 'nosniff' }, + { key: 'X-Frame-Options', value: 'DENY' }, + // Don't leak the full URL (which can include exercise IDs, workout + // IDs, etc.) when the user clicks a third-party link. + { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' }, + // Block every browser API we don't actually use. If we ever add + // camera/mic/geo features, allow-list them here explicitly. + { + key: 'Permissions-Policy', + value: + 'accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()', + }, +]; + const nextConfig = { reactStrictMode: true, output: 'standalone', images: { unoptimized: false, }, - headers: async () => { - return [ - { - source: '/(.*)', - headers: [ - { - key: 'X-Content-Type-Options', - value: 'nosniff', - }, - { - key: 'X-Frame-Options', - value: 'DENY', - }, - { - key: 'X-XSS-Protection', - value: '1; mode=block', - }, - ], - }, - ]; - }, + headers: async () => [ + { + source: '/(.*)', + headers: securityHeaders, + }, + ], }; module.exports = nextConfig; diff --git a/proof-of-work/prisma/schema.prisma b/proof-of-work/prisma/schema.prisma index 6f4af32..62954ff 100644 --- a/proof-of-work/prisma/schema.prisma +++ b/proof-of-work/prisma/schema.prisma @@ -16,6 +16,7 @@ model User { passwordHash String name String? isAdmin Boolean @default(false) + lastLoginAt DateTime? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt diff --git a/start9/0.4/docker_entrypoint.sh b/start9/0.4/docker_entrypoint.sh index de55e4f..1ca2ec1 100755 --- a/start9/0.4/docker_entrypoint.sh +++ b/start9/0.4/docker_entrypoint.sh @@ -91,6 +91,11 @@ if command -v sqlite3 >/dev/null 2>&1 && [ -f "$DB_PATH" ]; then AND NOT EXISTS (SELECT 1 FROM User WHERE isAdmin = 1);" fi + if ! sqlite3 "$DB_PATH" "PRAGMA table_info('User');" 2>/dev/null | grep -q "|lastLoginAt|"; then + log "adding missing column User.lastLoginAt (nullable)" + sqlite3 "$DB_PATH" "ALTER TABLE User ADD COLUMN lastLoginAt DATETIME;" + fi + if ! sqlite3 "$DB_PATH" \ "SELECT name FROM sqlite_master WHERE type='table' AND name='InstanceSettings';" \ 2>/dev/null | grep -q InstanceSettings; then @@ -104,6 +109,20 @@ if command -v sqlite3 >/dev/null 2>&1 && [ -f "$DB_PATH" ]; then sqlite3 "$DB_PATH" \ "INSERT OR IGNORE INTO InstanceSettings (id, signupsOpen) VALUES (1, 0);" fi + + # SQLite tuning. Enabling WAL means readers don't block on a concurrent + # writer (and vice versa) — crucial for the "background StartOS Backup + # while users are using the app" case, which under the default rollback + # journal can produce a torn snapshot. journal_mode persists in the DB + # header once set, so this is effectively a one-shot. synchronous=NORMAL + # is the safe-with-WAL balance: no fsync after every commit but still + # crash-consistent at every checkpoint, ~10x faster than FULL. + current_mode=$(sqlite3 "$DB_PATH" "PRAGMA journal_mode;" 2>/dev/null || echo "") + if [ "$current_mode" != "wal" ]; then + log "switching SQLite journal_mode from '${current_mode:-unknown}' to WAL" + sqlite3 "$DB_PATH" "PRAGMA journal_mode=WAL;" >/dev/null + fi + sqlite3 "$DB_PATH" "PRAGMA synchronous=NORMAL;" >/dev/null fi # ----------------------------------------------------------------------------- diff --git a/start9/0.4/package-lock.json b/start9/0.4/package-lock.json index d6ef741..7148752 100644 --- a/start9/0.4/package-lock.json +++ b/start9/0.4/package-lock.json @@ -1,10 +1,10 @@ { - "name": "workout-log-startos", + "name": "proof-of-work-startos", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "workout-log-startos", + "name": "proof-of-work-startos", "dependencies": { "@start9labs/start-sdk": "1.0.0", "bcryptjs": "^2.4.3"