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,
|
name: true,
|
||||||
isAdmin: true,
|
isAdmin: true,
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
|
lastLoginAt: true,
|
||||||
_count: { select: { sessions: true, workouts: true } },
|
_count: { select: { sessions: true, workouts: true } },
|
||||||
},
|
},
|
||||||
orderBy: { createdAt: 'asc' },
|
orderBy: { createdAt: 'asc' },
|
||||||
@@ -29,6 +30,7 @@ export default async function AdminUsersPage() {
|
|||||||
name: u.name,
|
name: u.name,
|
||||||
isAdmin: u.isAdmin,
|
isAdmin: u.isAdmin,
|
||||||
createdAt: u.createdAt.toISOString(),
|
createdAt: u.createdAt.toISOString(),
|
||||||
|
lastLoginAt: u.lastLoginAt ? u.lastLoginAt.toISOString() : null,
|
||||||
sessionCount: u._count.sessions,
|
sessionCount: u._count.sessions,
|
||||||
workoutCount: u._count.workouts,
|
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 { getCurrentUser } from "@/lib/auth";
|
||||||
import SettingsForm from "@/components/settings/SettingsForm";
|
import SettingsForm from "@/components/settings/SettingsForm";
|
||||||
import ChangePasswordForm from "@/components/settings/ChangePasswordForm";
|
import ChangePasswordForm from "@/components/settings/ChangePasswordForm";
|
||||||
|
import DangerZone from "@/components/settings/DangerZone";
|
||||||
import AdminInstanceSettings from "@/components/settings/AdminInstanceSettings";
|
import AdminInstanceSettings from "@/components/settings/AdminInstanceSettings";
|
||||||
import { getInstanceSettings } from "@/lib/instanceSettings";
|
import { getInstanceSettings } from "@/lib/instanceSettings";
|
||||||
|
|
||||||
@@ -33,6 +34,7 @@ export default async function SettingsPage() {
|
|||||||
initialSignupsOpen={instanceSettings.signupsOpen}
|
initialSignupsOpen={instanceSettings.signupsOpen}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
<DangerZone />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -13,10 +13,22 @@ type UserRow = {
|
|||||||
name: string | null;
|
name: string | null;
|
||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
lastLoginAt: string | null;
|
||||||
sessionCount: number;
|
sessionCount: number;
|
||||||
workoutCount: 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({
|
export default function UsersTable({
|
||||||
users,
|
users,
|
||||||
currentUserId,
|
currentUserId,
|
||||||
@@ -82,6 +94,7 @@ export default function UsersTable({
|
|||||||
<tr>
|
<tr>
|
||||||
<th className="text-left px-4 py-3">User</th>
|
<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 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 hidden sm:table-cell">Workouts</th>
|
||||||
<th className="text-left px-4 py-3">Role</th>
|
<th className="text-left px-4 py-3">Role</th>
|
||||||
<th className="text-right px-4 py-3">Actions</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">
|
<td className="px-4 py-3 text-xs text-zinc-500 hidden sm:table-cell">
|
||||||
{new Date(u.createdAt).toLocaleDateString()}
|
{new Date(u.createdAt).toLocaleDateString()}
|
||||||
</td>
|
</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">
|
<td className="px-4 py-3 text-xs text-zinc-400 hidden sm:table-cell">
|
||||||
{u.workoutCount}
|
{u.workoutCount}
|
||||||
</td>
|
</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
|
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
|
||||||
data: {
|
// transaction. Surfaced in the admin Users table so admins can spot
|
||||||
token,
|
// dormant accounts.
|
||||||
userId,
|
await prisma.$transaction([
|
||||||
expiresAt,
|
prisma.session.create({
|
||||||
},
|
data: {
|
||||||
});
|
token,
|
||||||
|
userId,
|
||||||
|
expiresAt,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.user.update({
|
||||||
|
where: { id: userId },
|
||||||
|
data: { lastLoginAt: new Date() },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
return { token, expiresAt };
|
return { token, expiresAt };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,31 +1,66 @@
|
|||||||
/** @type {import('next').NextConfig} */
|
/** @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 = {
|
const nextConfig = {
|
||||||
reactStrictMode: true,
|
reactStrictMode: true,
|
||||||
output: 'standalone',
|
output: 'standalone',
|
||||||
images: {
|
images: {
|
||||||
unoptimized: false,
|
unoptimized: false,
|
||||||
},
|
},
|
||||||
headers: async () => {
|
headers: async () => [
|
||||||
return [
|
{
|
||||||
{
|
source: '/(.*)',
|
||||||
source: '/(.*)',
|
headers: securityHeaders,
|
||||||
headers: [
|
},
|
||||||
{
|
],
|
||||||
key: 'X-Content-Type-Options',
|
|
||||||
value: 'nosniff',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'X-Frame-Options',
|
|
||||||
value: 'DENY',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'X-XSS-Protection',
|
|
||||||
value: '1; mode=block',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports = nextConfig;
|
module.exports = nextConfig;
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ model User {
|
|||||||
passwordHash String
|
passwordHash String
|
||||||
name String?
|
name String?
|
||||||
isAdmin Boolean @default(false)
|
isAdmin Boolean @default(false)
|
||||||
|
lastLoginAt DateTime?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
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);"
|
AND NOT EXISTS (SELECT 1 FROM User WHERE isAdmin = 1);"
|
||||||
fi
|
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" \
|
if ! sqlite3 "$DB_PATH" \
|
||||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='InstanceSettings';" \
|
"SELECT name FROM sqlite_master WHERE type='table' AND name='InstanceSettings';" \
|
||||||
2>/dev/null | grep -q InstanceSettings; then
|
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" \
|
sqlite3 "$DB_PATH" \
|
||||||
"INSERT OR IGNORE INTO InstanceSettings (id, signupsOpen) VALUES (1, 0);"
|
"INSERT OR IGNORE INTO InstanceSettings (id, signupsOpen) VALUES (1, 0);"
|
||||||
fi
|
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
|
fi
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,10 +1,10 @@
|
|||||||
{
|
{
|
||||||
"name": "workout-log-startos",
|
"name": "proof-of-work-startos",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "workout-log-startos",
|
"name": "proof-of-work-startos",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@start9labs/start-sdk": "1.0.0",
|
"@start9labs/start-sdk": "1.0.0",
|
||||||
"bcryptjs": "^2.4.3"
|
"bcryptjs": "^2.4.3"
|
||||||
|
|||||||
Reference in New Issue
Block a user