Robustness: WAL mode, security headers, last-login, delete-my-account
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.
This commit is contained in:
@@ -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,
|
||||
}));
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
<DangerZone />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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({
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3">User</th>
|
||||
<th className="text-left px-4 py-3 hidden sm:table-cell">Joined</th>
|
||||
<th className="text-left px-4 py-3 hidden md:table-cell">Last login</th>
|
||||
<th className="text-left px-4 py-3 hidden sm:table-cell">Workouts</th>
|
||||
<th className="text-left px-4 py-3">Role</th>
|
||||
<th className="text-right px-4 py-3">Actions</th>
|
||||
@@ -104,6 +117,14 @@ export default function UsersTable({
|
||||
<td className="px-4 py-3 text-xs text-zinc-500 hidden sm:table-cell">
|
||||
{new Date(u.createdAt).toLocaleDateString()}
|
||||
</td>
|
||||
<td
|
||||
className={`px-4 py-3 text-xs hidden md:table-cell ${
|
||||
u.lastLoginAt ? 'text-zinc-400' : 'text-zinc-600 italic'
|
||||
}`}
|
||||
title={u.lastLoginAt ?? 'never'}
|
||||
>
|
||||
{relativeAge(u.lastLoginAt)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-xs text-zinc-400 hidden sm:table-cell">
|
||||
{u.workoutCount}
|
||||
</td>
|
||||
|
||||
@@ -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 (
|
||||
<section className="bg-zinc-900 rounded border border-red-900/50 p-6 space-y-4">
|
||||
<header>
|
||||
<h2 className="text-sm font-semibold text-red-400 uppercase tracking-wider">
|
||||
Danger zone
|
||||
</h2>
|
||||
<p className="text-xs text-zinc-500 mt-1">
|
||||
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.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{!open ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
className="text-xs px-3 py-1.5 rounded border border-red-900 text-red-400 uppercase tracking-wider hover:bg-red-900/30"
|
||||
>
|
||||
Delete my account
|
||||
</button>
|
||||
) : (
|
||||
<form onSubmit={submit} className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="del-pwd"
|
||||
className="text-xs font-semibold text-white uppercase tracking-wider"
|
||||
>
|
||||
Your password
|
||||
</label>
|
||||
<input
|
||||
id="del-pwd"
|
||||
type="password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="del-phrase"
|
||||
className="text-xs font-semibold text-white uppercase tracking-wider"
|
||||
>
|
||||
Type{' '}
|
||||
<span className="text-red-400 font-mono normal-case">
|
||||
{REQUIRED_PHRASE}
|
||||
</span>{' '}
|
||||
to confirm
|
||||
</label>
|
||||
<input
|
||||
id="del-phrase"
|
||||
type="text"
|
||||
required
|
||||
autoComplete="off"
|
||||
value={phrase}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded bg-red-900/50 px-3 py-2 border border-red-800 text-xs text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy}
|
||||
className="text-xs px-3 py-1.5 rounded bg-red-700 text-white font-bold uppercase tracking-wider hover:bg-red-600 disabled:bg-zinc-700 disabled:text-zinc-500"
|
||||
>
|
||||
{busy ? 'Deleting...' : 'Delete forever'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
setPassword('');
|
||||
setPhrase('');
|
||||
setError('');
|
||||
}}
|
||||
disabled={busy}
|
||||
className="text-xs px-3 py-1.5 rounded border border-zinc-700 text-zinc-300 hover:bg-zinc-800 disabled:opacity-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -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({
|
||||
// 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 };
|
||||
}
|
||||
|
||||
@@ -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 [
|
||||
headers: async () => [
|
||||
{
|
||||
source: '/(.*)',
|
||||
headers: [
|
||||
{
|
||||
key: 'X-Content-Type-Options',
|
||||
value: 'nosniff',
|
||||
},
|
||||
{
|
||||
key: 'X-Frame-Options',
|
||||
value: 'DENY',
|
||||
},
|
||||
{
|
||||
key: 'X-XSS-Protection',
|
||||
value: '1; mode=block',
|
||||
headers: securityHeaders,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = nextConfig;
|
||||
|
||||
@@ -16,6 +16,7 @@ model User {
|
||||
passwordHash String
|
||||
name String?
|
||||
isAdmin Boolean @default(false)
|
||||
lastLoginAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
Generated
+2
-2
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user