v4: settings are stored on the server, starting with the style, the color scheme and the digest switches — every setting lived in the browser's localStorage, so it didn't follow an account to another browser and the server couldn't read it, which the digests need (whether to send each, and in which style). user_settings (migration 0019) holds one row per user who changed anything, the defaults otherwise, and GET/PATCH /api/users/me/settings read and change it (settings-service, model/settings, validators/settings, shared/settings). AuthProvider reads the settings into the session alongside the user, on mount and on focus, so a change made elsewhere arrives; useSettings changes them at once and puts them back if the server refuses. ThemeProvider now sits inside AuthProvider: a signed-in user's choice comes from their settings and is mirrored to localStorage for index.html's first-paint script, a visitor's stays in the browser, and an account with no choice yet takes the one this browser had, once. Until anyone picks a style, the instance's GERBEUR_DEFAULT_STYLE applies, written into index.html's default-style tag along with email-enabled (src/config/instance.ts), and a browser no longer stores the default as if it had been chosen, so changing the instance's style reaches everyone who never picked one. The settings tab gets an Email section with the weekly digest and monthly recap switches, disabled with a note when the server has no SMTP. The style and scheme names moved to shared/theme.ts in the previous commit; the default feed tab and sounds stay in the browser for now. 223 backend tests (new: settings defaults, patches, isolation, refusals and cascade; index.html's new tags) and 658 frontend tests (new: useSettings, the account's theme winning and a browser's carried over, settings read into the session, the digest switches) pass, and tsc, eslint, doc:lint, deno check, deno lint and deno fmt are clean.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -43,9 +43,10 @@ export type EmailLocale = "en" | "fr";
|
||||
export const EMAIL_LOCALE: EmailLocale =
|
||||
Deno.env.get("GERBEUR_EMAIL_LOCALE")?.trim() === "fr" ? "fr" : "en";
|
||||
/**
|
||||
* The instance's style (`GERBEUR_DEFAULT_STYLE`). The emails that aren't a
|
||||
* user's own (the welcome and the password reset) are drawn in it. A value
|
||||
* that isn't a style means "smooth".
|
||||
* The instance's style (`GERBEUR_DEFAULT_STYLE`). The site shows it until a
|
||||
* visitor or their account picks one, through index.html, and the emails that
|
||||
* aren't a user's own (the welcome and the password reset) are drawn in it. A
|
||||
* value that isn't a style means "smooth".
|
||||
*/
|
||||
export const DEFAULT_STYLE: StyleName = (() => {
|
||||
const style = Deno.env.get("GERBEUR_DEFAULT_STYLE")?.trim();
|
||||
|
||||
@@ -25,6 +25,7 @@ import { up as up0015TokenRevocation } from "./migrations/0015_token_revocation.
|
||||
import { up as up0016PerformanceIndexes } from "./migrations/0016_performance_indexes.ts";
|
||||
import { up as up0017CaseInsensitiveUsers } from "./migrations/0017_case_insensitive_users.ts";
|
||||
import { up as up0018AttachmentResourceType } from "./migrations/0018_attachment_resource_type.ts";
|
||||
import { up as up0019UserSettings } from "./migrations/0019_user_settings.ts";
|
||||
|
||||
interface Migration {
|
||||
name: string;
|
||||
@@ -62,6 +63,7 @@ const MIGRATIONS: Migration[] = [
|
||||
name: "0018_attachment_resource_type",
|
||||
up: up0018AttachmentResourceType,
|
||||
},
|
||||
{ name: "0019_user_settings", up: up0019UserSettings },
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
28
api/db/migrations/0019_user_settings.ts
Normal file
28
api/db/migrations/0019_user_settings.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Gives each user settings stored on the server, so that they follow the
|
||||
* account across browsers and the server can read them: the digests need the
|
||||
* reader's choice to receive them and their style. `user_settings` holds one
|
||||
* row per user who changed anything; a user without one has the defaults.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
|
||||
/**
|
||||
* Run once by `runMigrations`. Existing users get no row, so they start with
|
||||
* the defaults, digests on, like a new account.
|
||||
*/
|
||||
export function up(db: DatabaseSync): void {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS user_settings (
|
||||
user_id TEXT PRIMARY KEY,
|
||||
style TEXT,
|
||||
color_scheme TEXT,
|
||||
digest_weekly INTEGER NOT NULL DEFAULT 1,
|
||||
digest_monthly INTEGER NOT NULL DEFAULT 1,
|
||||
updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
`);
|
||||
}
|
||||
@@ -213,6 +213,16 @@ CREATE UNIQUE INDEX idx_notifications_dedup
|
||||
ON notifications(user_id, source_key)
|
||||
WHERE source_key IS NOT NULL;
|
||||
|
||||
CREATE TABLE user_settings (
|
||||
user_id TEXT PRIMARY KEY,
|
||||
style TEXT,
|
||||
color_scheme TEXT,
|
||||
digest_weekly INTEGER NOT NULL DEFAULT 1,
|
||||
digest_monthly INTEGER NOT NULL DEFAULT 1,
|
||||
updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE chat_messages (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
|
||||
@@ -6,7 +6,13 @@
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { BANDCAMP_PLAYER, OG_SITE_NAME, SITE_EMOJI } from "../config.ts";
|
||||
import {
|
||||
BANDCAMP_PLAYER,
|
||||
DEFAULT_STYLE,
|
||||
OG_SITE_NAME,
|
||||
SITE_EMOJI,
|
||||
SMTPS_URL,
|
||||
} from "../config.ts";
|
||||
import { emojiToCodepoint } from "./site-icons.ts";
|
||||
|
||||
/**
|
||||
@@ -21,8 +27,8 @@ const ICON_VERSION = emojiToCodepoint(SITE_EMOJI);
|
||||
const indexHtml = new Map<string, { mtime: number; html: string }>();
|
||||
|
||||
/**
|
||||
* Returns `${root}/index.html` with the site name, emoji, icon version and
|
||||
* Bandcamp player mode filled in.
|
||||
* Returns `${root}/index.html` with the site name, emoji, icon version,
|
||||
* Bandcamp player mode, default style and whether email is on filled in.
|
||||
*
|
||||
* @throws {Error} When the file can't be read.
|
||||
*/
|
||||
@@ -35,7 +41,9 @@ export async function loadIndexHtml(root: string): Promise<string> {
|
||||
.replaceAll("__SITE_NAME__", OG_SITE_NAME)
|
||||
.replaceAll("__SITE_EMOJI__", SITE_EMOJI)
|
||||
.replaceAll("__ICON_VERSION__", ICON_VERSION)
|
||||
.replaceAll("__BANDCAMP_PLAYER__", BANDCAMP_PLAYER);
|
||||
.replaceAll("__BANDCAMP_PLAYER__", BANDCAMP_PLAYER)
|
||||
.replaceAll("__DEFAULT_STYLE__", DEFAULT_STYLE)
|
||||
.replaceAll("__EMAIL_ENABLED__", SMTPS_URL ? "on" : "off");
|
||||
indexHtml.set(root, { mtime, html });
|
||||
return html;
|
||||
}
|
||||
|
||||
55
api/model/settings.ts
Normal file
55
api/model/settings.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Checks the rows `settings-service.ts` reads from `user_settings`, and turns
|
||||
* them into the {@link UserSettings} the settings endpoints answer.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { SQLOutputValue } from "node:sqlite";
|
||||
import type { UserSettings } from "../../shared/settings.ts";
|
||||
import { isColorScheme, isStyleName } from "../../shared/theme.ts";
|
||||
|
||||
/**
|
||||
* Stored by `updateSettings` and read back by `getSettings`. A user without a
|
||||
* row has the defaults.
|
||||
*/
|
||||
export interface UserSettingsRow {
|
||||
user_id: string;
|
||||
/** A style name, or null for the instance's. */
|
||||
style: string | null;
|
||||
color_scheme: string | null;
|
||||
/** 0 or 1. */
|
||||
digest_weekly: number;
|
||||
/** 0 or 1. */
|
||||
digest_monthly: number;
|
||||
updated_at: string;
|
||||
[key: string]: SQLOutputValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether `obj`, a row `getSettings` selected, is a
|
||||
* {@link UserSettingsRow}.
|
||||
*/
|
||||
export function isUserSettingsRow(obj: unknown): obj is UserSettingsRow {
|
||||
return !!obj && typeof obj === "object" &&
|
||||
"user_id" in obj && typeof obj.user_id === "string" &&
|
||||
"style" in obj && (obj.style === null || typeof obj.style === "string") &&
|
||||
"color_scheme" in obj &&
|
||||
(obj.color_scheme === null || typeof obj.color_scheme === "string") &&
|
||||
"digest_weekly" in obj && typeof obj.digest_weekly === "number" &&
|
||||
"digest_monthly" in obj && typeof obj.digest_monthly === "number" &&
|
||||
"updated_at" in obj && typeof obj.updated_at === "string";
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a row to the {@link UserSettings} the client gets. A style or
|
||||
* scheme the code no longer knows, as after one is removed, reads as unset.
|
||||
*/
|
||||
export function settingsRowToApi(row: UserSettingsRow): UserSettings {
|
||||
return {
|
||||
style: isStyleName(row.style) ? row.style : null,
|
||||
colorScheme: isColorScheme(row.color_scheme) ? row.color_scheme : null,
|
||||
digestWeekly: row.digest_weekly === 1,
|
||||
digestMonthly: row.digest_monthly === 1,
|
||||
};
|
||||
}
|
||||
@@ -19,6 +19,9 @@
|
||||
* tokens.
|
||||
* - `PATCH /api/users/me` — signed in. Updates the caller's profile fields:
|
||||
* description and email.
|
||||
* - `GET /api/users/me/settings` — signed in. Answers the caller's settings.
|
||||
* - `PATCH /api/users/me/settings` — signed in. Changes some of the caller's
|
||||
* settings, and answers them all.
|
||||
* - `PATCH /api/users/:userId/role` — permission `user:manage`. Sets another
|
||||
* user's role.
|
||||
* - `GET /api/users/search?q=` — public. Answers up to 8 users whose name
|
||||
@@ -91,6 +94,9 @@ import {
|
||||
resetPassword,
|
||||
} from "../services/password-reset-service.ts";
|
||||
import { VALIDATION } from "../../shared/validation.ts";
|
||||
import type { UserSettings } from "../../shared/settings.ts";
|
||||
import { isUpdateSettingsRequest } from "../validators/settings.ts";
|
||||
import { getSettings, updateSettings } from "../services/settings-service.ts";
|
||||
import { broadcastUserUpdated } from "../services/ws-service.ts";
|
||||
import {
|
||||
getDumpsByUser,
|
||||
@@ -301,6 +307,26 @@ router.patch("/me", authMiddleware, async (ctx: AuthContext) => {
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
router.get("/me/settings", authMiddleware, (ctx: AuthContext) => {
|
||||
const responseBody: APIResponse<UserSettings> = {
|
||||
success: true,
|
||||
data: getSettings(ctx.state.user.userId),
|
||||
};
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
router.patch("/me/settings", authMiddleware, async (ctx: AuthContext) => {
|
||||
const body: unknown = await ctx.request.body.json();
|
||||
if (!isUpdateSettingsRequest(body)) {
|
||||
throw new APIException(400, "Invalid settings");
|
||||
}
|
||||
const responseBody: APIResponse<UserSettings> = {
|
||||
success: true,
|
||||
data: updateSettings(ctx.state.user.userId, body),
|
||||
};
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
router.patch(
|
||||
"/:userId/role",
|
||||
authMiddleware,
|
||||
|
||||
56
api/services/settings-service.ts
Normal file
56
api/services/settings-service.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Reads and writes each user's settings in `user_settings`. The users routes
|
||||
* answer `GET` and `PATCH /api/users/me/settings` with it, and the digests read
|
||||
* their readers' choices from the same table.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { db } from "../db/connection.ts";
|
||||
import {
|
||||
DEFAULT_SETTINGS,
|
||||
type UpdateSettingsRequest,
|
||||
type UserSettings,
|
||||
} from "../../shared/settings.ts";
|
||||
import { isUserSettingsRow, settingsRowToApi } from "../model/settings.ts";
|
||||
|
||||
/** Returns the settings of user `userId`, the defaults if they changed none. */
|
||||
export function getSettings(userId: string): UserSettings {
|
||||
const row = db.prepare(
|
||||
`SELECT user_id, style, color_scheme, digest_weekly, digest_monthly, updated_at
|
||||
FROM user_settings WHERE user_id = ?;`,
|
||||
).get(userId);
|
||||
return isUserSettingsRow(row)
|
||||
? settingsRowToApi(row)
|
||||
: { ...DEFAULT_SETTINGS };
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies `patch` to the settings of user `userId` and returns them all. The
|
||||
* settings it leaves out keep their value.
|
||||
*/
|
||||
export function updateSettings(
|
||||
userId: string,
|
||||
patch: UpdateSettingsRequest,
|
||||
): UserSettings {
|
||||
const next = { ...getSettings(userId), ...patch };
|
||||
db.prepare(
|
||||
`INSERT INTO user_settings
|
||||
(user_id, style, color_scheme, digest_weekly, digest_monthly, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (user_id) DO UPDATE SET
|
||||
style = excluded.style,
|
||||
color_scheme = excluded.color_scheme,
|
||||
digest_weekly = excluded.digest_weekly,
|
||||
digest_monthly = excluded.digest_monthly,
|
||||
updated_at = excluded.updated_at;`,
|
||||
).run(
|
||||
userId,
|
||||
next.style,
|
||||
next.colorScheme,
|
||||
next.digestWeekly ? 1 : 0,
|
||||
next.digestMonthly ? 1 : 0,
|
||||
new Date().toISOString(),
|
||||
);
|
||||
return next;
|
||||
}
|
||||
@@ -28,14 +28,14 @@ Deno.test("runMigrations: a fresh database is left as schema.sql made it", () =>
|
||||
.get() as {
|
||||
n: number;
|
||||
};
|
||||
assertEquals(applied.n, 18);
|
||||
assertEquals(applied.n, 19);
|
||||
// Running again applies nothing.
|
||||
runMigrations(db);
|
||||
assertEquals(
|
||||
(db.prepare(`SELECT COUNT(*) as n FROM schema_migrations;`).get() as {
|
||||
n: number;
|
||||
}).n,
|
||||
18,
|
||||
19,
|
||||
);
|
||||
db.close();
|
||||
});
|
||||
|
||||
@@ -34,6 +34,7 @@ const TABLES = [
|
||||
"invites",
|
||||
"attachments",
|
||||
"chat_messages",
|
||||
"user_settings",
|
||||
"categories",
|
||||
"playlists",
|
||||
"dumps",
|
||||
|
||||
@@ -8,11 +8,11 @@ Deno.test("loadIndexHtml: fills the placeholders, and rereads a rebuilt file", a
|
||||
const path = `${root}/index.html`;
|
||||
await Deno.writeTextFile(
|
||||
path,
|
||||
"<title>__SITE_NAME__</title><body>__BANDCAMP_PLAYER__</body>",
|
||||
"<title>__SITE_NAME__</title><body>__BANDCAMP_PLAYER__ __DEFAULT_STYLE__ __EMAIL_ENABLED__</body>",
|
||||
);
|
||||
assertEquals(
|
||||
await loadIndexHtml(root),
|
||||
"<title>gerbeur</title><body>embed</body>",
|
||||
"<title>gerbeur</title><body>embed smooth off</body>",
|
||||
);
|
||||
|
||||
await Deno.writeTextFile(path, "<title>rebuilt __SITE_NAME__</title>");
|
||||
|
||||
@@ -29,6 +29,10 @@ import {
|
||||
import { authenticateMedia } from "../../lib/auth.ts";
|
||||
import type { Dump } from "../../../shared/dump.ts";
|
||||
import type { PaginatedData } from "../../../shared/api.ts";
|
||||
import {
|
||||
DEFAULT_SETTINGS,
|
||||
type UserSettings,
|
||||
} from "../../../shared/settings.ts";
|
||||
|
||||
function invite(inviterId: string, createdAt = new Date()): string {
|
||||
const token = crypto.randomUUID();
|
||||
@@ -541,3 +545,62 @@ test("GET /api/users/me/media-token issues a media token for the caller", async
|
||||
assertEquals(res.status, 200);
|
||||
assertEquals(await authenticateMedia(res.data.mediaToken), user.id);
|
||||
});
|
||||
|
||||
// ── Settings ─────────────────────────────────────────────────────────────────
|
||||
|
||||
test("settings: defaults until changed, a patch keeps the rest, each user their own", async () => {
|
||||
const ann = await createUser();
|
||||
const bob = await createUser();
|
||||
const initial = await get<UserSettings>("/api/users/me/settings", {
|
||||
auth: ann.auth,
|
||||
});
|
||||
assertEquals(initial.data, DEFAULT_SETTINGS);
|
||||
|
||||
const changed = await patch<UserSettings>("/api/users/me/settings", {
|
||||
auth: ann.auth,
|
||||
json: { style: "kanagawa", digestWeekly: false },
|
||||
});
|
||||
assertEquals(changed.data, {
|
||||
...DEFAULT_SETTINGS,
|
||||
style: "kanagawa",
|
||||
digestWeekly: false,
|
||||
});
|
||||
const scheme = await patch<UserSettings>("/api/users/me/settings", {
|
||||
auth: ann.auth,
|
||||
json: { colorScheme: "dark" },
|
||||
});
|
||||
assertEquals(scheme.data.style, "kanagawa");
|
||||
assertEquals(scheme.data.colorScheme, "dark");
|
||||
assertEquals(scheme.data.digestWeekly, false);
|
||||
|
||||
const bobs = await get<UserSettings>("/api/users/me/settings", {
|
||||
auth: bob.auth,
|
||||
});
|
||||
assertEquals(bobs.data, DEFAULT_SETTINGS);
|
||||
// Deleting the account takes its settings along.
|
||||
db.prepare(`DELETE FROM users WHERE id = ?;`).run(ann.id);
|
||||
assertEquals(count(`SELECT COUNT(*) as n FROM user_settings;`), 0);
|
||||
});
|
||||
|
||||
test("settings: signed in only, and a bad value or unknown key is refused", async () => {
|
||||
const ann = await createUser();
|
||||
assertEquals((await get("/api/users/me/settings")).status, 401);
|
||||
for (
|
||||
const json of [
|
||||
{ style: "comic" },
|
||||
{ colorScheme: "sepia" },
|
||||
{ digestMonthly: "no" },
|
||||
{ theme: "smooth" },
|
||||
[],
|
||||
]
|
||||
) {
|
||||
const res = await patch("/api/users/me/settings", { auth: ann.auth, json });
|
||||
assertEquals(res.status, 400, JSON.stringify(json));
|
||||
}
|
||||
// Null hands the style back to the instance's.
|
||||
const reset = await patch<UserSettings>("/api/users/me/settings", {
|
||||
auth: ann.auth,
|
||||
json: { style: null },
|
||||
});
|
||||
assertEquals(reset.data.style, null);
|
||||
});
|
||||
|
||||
32
api/validators/settings.ts
Normal file
32
api/validators/settings.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Checks the body of `PATCH /api/users/me/settings` before the users route
|
||||
* trusts it.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { UpdateSettingsRequest } from "../../shared/settings.ts";
|
||||
import { isColorScheme, isStyleName } from "../../shared/theme.ts";
|
||||
|
||||
const VALIDATORS: Record<keyof UpdateSettingsRequest, (v: unknown) => boolean> =
|
||||
{
|
||||
style: (v) => v === null || isStyleName(v),
|
||||
colorScheme: (v) => v === null || isColorScheme(v),
|
||||
digestWeekly: (v) => typeof v === "boolean",
|
||||
digestMonthly: (v) => typeof v === "boolean",
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks whether `obj` is an {@link UpdateSettingsRequest}: an object whose
|
||||
* every key is a setting, each with a value that setting takes. An unknown
|
||||
* key fails, so a typo doesn't pass for a change.
|
||||
*/
|
||||
export function isUpdateSettingsRequest(
|
||||
obj: unknown,
|
||||
): obj is UpdateSettingsRequest {
|
||||
if (!obj || typeof obj !== "object" || Array.isArray(obj)) return false;
|
||||
return Object.entries(obj).every(([key, value]) =>
|
||||
key in VALIDATORS &&
|
||||
VALIDATORS[key as keyof UpdateSettingsRequest](value)
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user