- CI pinned Node 20, but `node --test` with type stripping needs 22.6, so the workflow could never have gone green. CI and `engines` now say 22.6. - `docker compose up -d` failed on the documented path: the compose files require DB_PASSWORD and .env.example never mentioned it. Documented, and the README no longer claims "three secrets". - The secret scanner treated any comment line as safe, so a pasted `.env` fragment — the usual way a key gets committed — slipped through. Verified against planted keys before and after. - Module pages were not guarded: with KLARBILD_MODULES=print a signed-in user could still open /shares, /library and /queue and read about features the instance does not have. They now redirect, matching the API's 404 behaviour. - German left in the English build: `bild.<ext>` as a download fallback, and 31 t() keys with no German at all — the whole main navigation among them, so a German instance rendered its nav in English. Dictionary now covers every key in the source (1,067 entries). - Third-party names removed from the data as well as the labels: the `theframe` format id is now `tv169`, and the dead Cricut admin field is gone. - Dockerfile ignored the committed lockfile; it now copies it and uses `npm ci`. - Dropped an unused dependency (@aws-sdk/s3-request-presigner — storage.ts explains why presigned URLs are not needed), a dead `/g/` entry in the unauthenticated path list, and an undocumented N8N_WEBHOOK_URL that nothing read. Documented TRUSTED_PROXIES, which the rate limiter does read. - Migration 004 still had "nas" in its filename; README said A6–A2 where the list runs A6–A1; two committed screenshots were referenced nowhere. 124 tests green, type check clean, build clean, secret scan clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nh4A79bbqT9dcd2inzYUdg
123 lines
6.4 KiB
TypeScript
123 lines
6.4 KiB
TypeScript
import { randomBytes } from 'node:crypto';
|
||
import { one, query } from './db';
|
||
import { hashPassword } from './auth';
|
||
import { has } from './modules';
|
||
|
||
/**
|
||
* First start: create the administrator, the settings row, and a few example
|
||
* presets. Idempotent — it runs on every boot and does nothing once the
|
||
* instance has been set up.
|
||
*
|
||
* There are no built-in accounts. The very first boot creates exactly one
|
||
* administrator, from `ADMIN_USERNAME` / `ADMIN_PASSWORD` if you set them, and
|
||
* otherwise with a random password printed once to the server log. Nothing here
|
||
* carries a name, a folder or a mailbox belonging to whoever built this.
|
||
*/
|
||
export async function seed(): Promise<void> {
|
||
await query(`INSERT INTO settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING`);
|
||
|
||
if (has('ai')) await seedModels();
|
||
await seedAdmin();
|
||
await seedPrintPresets();
|
||
if (has('ai')) await seedRecipes();
|
||
}
|
||
|
||
/* ------------------------------------------------------------------ admin */
|
||
|
||
async function seedAdmin(): Promise<void> {
|
||
const existing = await one<{ n: string }>(`SELECT count(*)::text AS n FROM users`);
|
||
if (existing && Number(existing.n) > 0) return;
|
||
|
||
const username = (process.env.ADMIN_USERNAME || 'admin').trim().toLowerCase();
|
||
const given = process.env.ADMIN_PASSWORD;
|
||
// 24 bytes of base64url is 32 characters — long enough that nobody is tempted
|
||
// to leave it in place, short enough to copy out of a log by hand.
|
||
const password = given || randomBytes(24).toString('base64url');
|
||
|
||
await query(
|
||
`INSERT INTO users (username, role, display_name, password_hash) VALUES ($1,'admin',$2,$3)`,
|
||
[username, username, await hashPassword(password)],
|
||
);
|
||
|
||
if (given) {
|
||
console.log(`[setup] Administrator "${username}" created with the password from ADMIN_PASSWORD.`);
|
||
} else {
|
||
// Printed once, on the first boot only. There is no way to recover it later;
|
||
// the reset flow (module "mail") or a fresh volume is the way back.
|
||
console.log(
|
||
'\n' +
|
||
' ┌────────────────────────────────────────────────────────────┐\n' +
|
||
' │ Klarbild is set up. Sign in with: │\n' +
|
||
` │ user ${username.padEnd(46)}│\n` +
|
||
` │ password ${password.padEnd(46)}│\n` +
|
||
' │ This is shown once. Change it under Account after login. │\n' +
|
||
' └────────────────────────────────────────────────────────────┘\n',
|
||
);
|
||
}
|
||
}
|
||
|
||
/* ----------------------------------------------------------------- models */
|
||
|
||
async function seedModels(): Promise<void> {
|
||
const count = await one<{ n: string }>(`SELECT count(*)::text AS n FROM models`);
|
||
if (count && Number(count.n) === 0) {
|
||
await query(`INSERT INTO models (model_id, label, description, active, is_default, supports_alpha, sort) VALUES
|
||
('google/gemini-3.1-flash-image','Fast','Ready in seconds. The everyday choice.',true,true,true,0),
|
||
('google/gemini-3-pro-image','Best quality','Slower, but truer to text and fine detail.',true,false,true,1),
|
||
('black-forest-labs/flux.2-flex','Open weights (FLUX.2)','Open model, good quality. Cannot cut out to transparency.',true,false,false,2)`);
|
||
}
|
||
}
|
||
|
||
/* ---------------------------------------------------------------- recipes */
|
||
|
||
async function seedRecipes(): Promise<void> {
|
||
const count = await one<{ n: string }>(`SELECT count(*)::text AS n FROM recipes`);
|
||
if (!count || Number(count.n) > 0) return;
|
||
|
||
const admin = await one<{ id: string }>(`SELECT id FROM users WHERE role='admin' ORDER BY created_at LIMIT 1`);
|
||
const by = admin?.id ?? null;
|
||
const R = (name: string, tasks: string[], fmt: string, orient: string, extra: Record<string, unknown> = {}) =>
|
||
query(
|
||
`INSERT INTO recipes (name, tasks, output_format, orientation, crop_mode, dpi, delivery, is_default, created_by, contour_mm)
|
||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`,
|
||
[name, JSON.stringify(tasks), fmt, orient, (extra.crop_mode as string) || 'crop', 300,
|
||
(extra.delivery as string) || 'library', !!extra.is_default, by, (extra.contour_mm as number) ?? null]);
|
||
|
||
await R('Clean up only', ['clean'], 'keep', 'landscape', { is_default: true });
|
||
await R('Poster 30×40', ['clean', 'format'], '30x40', 'portrait');
|
||
await R('TV frame (16:9)', ['clean', 'format'], 'tv169', 'landscape');
|
||
await R('Sticker 5 cm', ['clean', 'cutout', 'format', 'contour'], 'sticker5', 'landscape', { contour_mm: 3 });
|
||
}
|
||
|
||
/* --------------------------------------------------------- print presets */
|
||
|
||
async function seedPrintPresets(): Promise<void> {
|
||
const admin = await one<{ id: string }>(`SELECT id FROM users WHERE role='admin' ORDER BY created_at LIMIT 1`);
|
||
const by = admin?.id ?? null;
|
||
const P = async (name: string, config: Record<string, unknown>) => {
|
||
const exists = await one(`SELECT id FROM print_presets WHERE name=$1`, [name]);
|
||
if (exists) return;
|
||
await query(`INSERT INTO print_presets (name, created_by, config) VALUES ($1,$2,$3)`,
|
||
[name, by, JSON.stringify(config)]);
|
||
};
|
||
const sheet = {
|
||
paperId: 'A4', paperCustom: '', paperLandscape: false, marginMm: 5,
|
||
autoGap: true, gapMm: 14, bleedMm: 0,
|
||
marks: 'corner', markLen: 4, markOff: 3, dpi: 300, ext: 'jpg', center: true, footer: true,
|
||
};
|
||
const F = (sizeId: string, count: number, landscape = false) =>
|
||
({ sizeId, customSize: '', landscape, count, allowRotate: true });
|
||
|
||
// The classic school-photo set from a single portrait.
|
||
await P('School photo set (A4)', { ...sheet, formats: [F('S13x18', 1), F('S9x13', 2), F('P35x45', 8)] });
|
||
// Just the small prints, for when the large one is already done.
|
||
await P('Small prints (A4)', { ...sheet, formats: [F('S9x13', 4), F('K30x40', 8)] });
|
||
// A full sheet of passport photos — continuous lines, because every cell is identical.
|
||
await P('Passport sheet 35×45 (A4)', { ...sheet, marks: 'grid', autoGap: false, gapMm: 4, formats: [F('P35x45', 20)] });
|
||
// Two images at true size on one sheet of 10×15 photo paper.
|
||
await P('2 × 10×7.5 on 10×15 photo paper', {
|
||
...sheet, paperId: 'F10x15', marginMm: 0, marks: 'grid', autoGap: false, gapMm: 0,
|
||
formats: [{ sizeId: 'custom', customSize: '10x7,5', landscape: false, count: 2, allowRotate: true }],
|
||
});
|
||
}
|