Astro 7, and stop a print-only instance calling a route it has switched off
CI / check (push) Has been cancelled

The critical advisory in the dependency tree (GHSA-26w7-cxv4-gfx2, remote code
execution through AVIF image optimisation) has no fix inside Astro 5, so this
moves to Astro 7.3.2 with @astrojs/node 11 and current sharp. `npm audit` now
reports nothing. The only code the jump needed: `csp` graduated out of
`experimental`, and sharp's `Sharp` type is a named export now.

Also fixed, both found by testing with every module switched on rather than
only the print-only default:

- `src/pages/index.astro` passed `dict` to the studio island without ever
  defining it, so the studio page returned 500 on any instance with the AI
  module on. The print-only default redirects away from that page, which is
  why the earlier run never hit it.
- The print studio fetched `/api/delivery-targets` even with the delivery
  module off. The route answered 404 exactly as designed, but it left a red
  line in the console of every print-only instance, which reads like a fault.
  It now asks only when there is a module to ask, and hides the card otherwise.

Plus: the first-run box sizes itself to its content instead of miscounting
padding, and the README's counts are recounted rather than remembered.

Verified against a live instance on a fresh database, in both module sets:
sign-in, module gating, the German interface, an eight-up passport sheet and
its PDF. 124 tests, type check, build and secret scan all clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nh4A79bbqT9dcd2inzYUdg
This commit is contained in:
tillandClaude Opus 5 committed 2026-09-15 15:23:42 +00:00
1 parent 4ff9f8b7a0
commit 0798eb7438
13 files changed
+1681 -3890

No files matched your search

+3 -1
View File
@@ -11,7 +11,8 @@ private repository as a personal tool.
default is the print studio alone — no image model, no object store, no bot default is the print studio alone — no image model, no object store, no bot
token, no account anywhere. See `src/lib/modules.ts`. token, no account anywhere. See `src/lib/modules.ts`.
- **English interface**, with German as a full second language. The English - **English interface**, with German as a full second language. The English
sentence is the translation key; 1,038 strings. sentence is the translation key; 1,067 entries, covering every translatable
string in the source.
- **First-run setup.** No built-in accounts. The first boot creates one - **First-run setup.** No built-in accounts. The first boot creates one
administrator from `ADMIN_USERNAME` / `ADMIN_PASSWORD`, or generates a password administrator from `ADMIN_USERNAME` / `ADMIN_PASSWORD`, or generates a password
and prints it once to the log. and prints it once to the log.
@@ -23,6 +24,7 @@ private repository as a personal tool.
- English routes: `/print`, `/library`, `/queue`, `/shares`, `/help`, `/account`, - English routes: `/print`, `/library`, `/queue`, `/shares`, `/help`, `/account`,
`/tour`, `/password`. `/tour`, `/password`.
- AGPL-3.0, CI (build, tests, type check, secret scan), `CONTRIBUTING`, `SECURITY`. - AGPL-3.0, CI (build, tests, type check, secret scan), `CONTRIBUTING`, `SECURITY`.
- Astro 7 and current `sharp`: `npm audit` reports no known vulnerability.
**Carried over from the private tool** **Carried over from the private tool**
+5 -4
View File
@@ -112,10 +112,11 @@ Astro 5 (SSR, Node standalone) with React islands · PostgreSQL · `sharp` ·
`pdf-lib` · `pg-boss` · OKLCH design tokens, no Tailwind. Files go to a volume by `pdf-lib` · `pg-boss` · OKLCH design tokens, no Tailwind. Files go to a volume by
default; S3/MinIO is available but not required. default; S3/MinIO is available but not required.
About 13,600 lines across 116 files, 124 tests, 16 forward-only idempotent About 13,700 lines across 117 files, 124 tests, 16 forward-only idempotent
migrations that run themselves on boot. English and German interface, 1,038 migrations that run themselves on boot. English and German interface — 1,067
translated strings; the English sentence is the translation key, so there is no key entries, covering every translatable string in the source. The English sentence is
file to keep in sync. the translation key, so there is no key file to keep in sync and a missing entry
degrades to readable English rather than to `print.sheet.title`.
## Install ## Install
+12 -13
View File
@@ -14,19 +14,18 @@ export default defineConfig({
// Content-Security-Policy with hashes instead of 'unsafe-inline': Astro computes // Content-Security-Policy with hashes instead of 'unsafe-inline': Astro computes
// the hash of every inline script and style it emits, at build time. The remaining // the hash of every inline script and style it emits, at build time. The remaining
// directives are set by the middleware (src/middleware.ts). // directives are set by the middleware (src/middleware.ts).
experimental: { // Graduated out of `experimental` in Astro 7.
csp: { csp: {
directives: [ directives: [
"default-src 'self'", "default-src 'self'",
"img-src 'self' data: blob:", "img-src 'self' data: blob:",
"font-src 'self'", "font-src 'self'",
"connect-src 'self'", "connect-src 'self'",
"object-src 'none'", "object-src 'none'",
"base-uri 'self'", "base-uri 'self'",
"form-action 'self'", "form-action 'self'",
"frame-ancestors 'none'", "frame-ancestors 'none'",
], ],
},
}, },
server: { host: true, port: Number(process.env.PORT) || 4321 }, server: { host: true, port: Number(process.env.PORT) || 4321 },
vite: { vite: {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 229 KiB

After

Width:  |  Height:  |  Size: 233 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 169 KiB

After

Width:  |  Height:  |  Size: 168 KiB

+1627 -3853
View File
File diff suppressed because it is too large. Load diff
+4 -4
View File
@@ -38,14 +38,14 @@
"scan": "node scripts/secret-scan.mjs" "scan": "node scripts/secret-scan.mjs"
}, },
"dependencies": { "dependencies": {
"@astrojs/node": "^9.1.3", "@astrojs/node": "^11.1.5",
"@astrojs/react": "^4.2.1", "@astrojs/react": "^6.0.5",
"@aws-sdk/client-s3": "^3.700.0", "@aws-sdk/client-s3": "^3.700.0",
"@fontsource/space-grotesk": "^5.1.0", "@fontsource/space-grotesk": "^5.1.0",
"@fontsource/space-mono": "^5.1.0", "@fontsource/space-mono": "^5.1.0",
"archiver": "^7.0.1", "archiver": "^7.0.1",
"argon2": "^0.41.1", "argon2": "^0.41.1",
"astro": "^5.5.0", "astro": "^7.3.2",
"basic-ftp": "^5.0.5", "basic-ftp": "^5.0.5",
"grammy": "^1.30.0", "grammy": "^1.30.0",
"heic-convert": "^2.1.0", "heic-convert": "^2.1.0",
@@ -55,7 +55,7 @@
"pg-boss": "^10.1.5", "pg-boss": "^10.1.5",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"sharp": "^0.33.5", "sharp": "^0.35.4",
"ssh2-sftp-client": "^11.0.0" "ssh2-sftp-client": "^11.0.0"
}, },
"devDependencies": { "devDependencies": {
+11 -4
View File
@@ -110,7 +110,8 @@ function shrink(dataUrl: string, maxEdge = 700): Promise<{ url: string; w: numbe
/* ========================================================================== */ /* ========================================================================== */
export default function PrintApp({ locale, dict }: { locale?: Locale; dict?: Dict | null }) { export default function PrintApp({ locale, dict, canDeliver = false }:
{ locale?: Locale; dict?: Dict | null; canDeliver?: boolean }) {
const t = useT(dict); const t = useT(dict);
const [cells, setCells] = useState<Cell[]>([]); const [cells, setCells] = useState<Cell[]>([]);
const [paperId, setPaperId] = useState('A4'); const [paperId, setPaperId] = useState('A4');
@@ -298,8 +299,13 @@ export default function PrintApp({ locale, dict }: { locale?: Locale; dict?: Dic
useEffect(() => { useEffect(() => {
loadPresets(); loadPresets();
fetch('/api/delivery-targets').then((r) => r.json()).then((j) => setTargets(j.targets || [])).catch(() => {}); // Only ask when the delivery module is on. Asking anyway would work — the
}, []); // route answers 404 by design — but it puts a red line in the console of
// every print-only instance, which reads like a fault and is not one.
if (canDeliver) {
fetch('/api/delivery-targets').then((r) => r.json()).then((j) => setTargets(j.targets || [])).catch(() => {});
}
}, [canDeliver]);
const loadPresets = () => fetch('/api/print/presets').then((r) => r.json()) const loadPresets = () => fetch('/api/print/presets').then((r) => r.json())
.then((j) => setPresets(j.presets || [])).catch(() => {}); .then((j) => setPresets(j.presets || [])).catch(() => {});
@@ -706,6 +712,7 @@ export default function PrintApp({ locale, dict }: { locale?: Locale; dict?: Dic
)} )}
</Group> </Group>
{canDeliver && (
<Group closed title={t('Where to?')} hint={deliverTo ? t('deliver as well') : t('download only')}> <Group closed title={t('Where to?')} hint={deliverTo ? t('deliver as well') : t('download only')}>
<select className="select" value={deliverTo} onChange={(e) => setDeliverTo(e.target.value)} <select className="select" value={deliverTo} onChange={(e) => setDeliverTo(e.target.value)}
aria-label={t('Additional target')}> aria-label={t('Additional target')}>
@@ -718,7 +725,7 @@ export default function PrintApp({ locale, dict }: { locale?: Locale; dict?: Dic
<input className="input" style={{ marginTop: 7 }} value={gallery} onChange={(e) => setGallery(e.target.value)} <input className="input" style={{ marginTop: 7 }} value={gallery} onChange={(e) => setGallery(e.target.value)}
placeholder={t('Folder on the target (empty = default)')} aria-label={t('Folder on the target')} /> placeholder={t('Folder on the target (empty = default)')} aria-label={t('Folder on the target')} />
)} )}
</Group> </Group>)}
</div> </div>
</div> </div>
</Card> </Card>
+2 -2
View File
@@ -1,7 +1,7 @@
// Rendering the print sheet: crop (sharp) + PDF assembly (pdf-lib). // Rendering the print sheet: crop (sharp) + PDF assembly (pdf-lib).
// Deliberately without AI — plain geometry and scaling, so that the result is // Deliberately without AI — plain geometry and scaling, so that the result is
// predictable and repeatable. // predictable and repeatable.
import sharp from 'sharp'; import sharp, { type Sharp } from 'sharp';
import { PDFDocument, StandardFonts, rgb, degrees } from 'pdf-lib'; import { PDFDocument, StandardFonts, rgb, degrees } from 'pdf-lib';
import { mmToPt, mmToPx } from './paper.ts'; import { mmToPt, mmToPx } from './paper.ts';
import type { Line, Page, SheetSpec } from './printlayout.ts'; import type { Line, Page, SheetSpec } from './printlayout.ts';
@@ -112,7 +112,7 @@ export async function renderCell(
const ext: 'jpg' | 'png' = opt.ext === 'png' || hasAlpha ? 'png' : 'jpg'; const ext: 'jpg' | 'png' = opt.ext === 'png' || hasAlpha ? 'png' : 'jpg';
if (hasAlpha && ext === 'jpg') img = img.flatten({ background: bg }); if (hasAlpha && ext === 'jpg') img = img.flatten({ background: bg });
const encode = (pipe: sharp.Sharp) => (ext === 'png' const encode = (pipe: Sharp) => (ext === 'png'
? pipe.withMetadata({ density: dpi }).png({ compressionLevel: 9 }) ? pipe.withMetadata({ density: dpi }).png({ compressionLevel: 9 })
: pipe.withMetadata({ density: dpi }).jpeg({ quality: 94, mozjpeg: true })); : pipe.withMetadata({ density: dpi }).jpeg({ quality: 94, mozjpeg: true }));
+10 -7
View File
@@ -44,14 +44,17 @@ async function seedAdmin(): Promise<void> {
} else { } else {
// Printed once, on the first boot only. There is no way to recover it later; // 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. // the reset flow (module "mail") or a fresh volume is the way back.
const lines = [
'Klarbild is set up. Sign in with:',
` user ${username}`,
` password ${password}`,
'Shown once. Change it under Account after you sign in.',
];
const w = Math.max(...lines.map((l) => l.length)) + 2;
console.log( console.log(
'\n' + '\n ┌' + '─'.repeat(w) + '┐\n' +
' ┌────────────────────────────────────────────────────────────┐\n' + lines.map((l) => ` │ ${l.padEnd(w - 1)}│`).join('\n') +
' │ Klarbild is set up. Sign in with: │\n' + '\n └' + '─'.repeat(w) + '┘\n',
` │ user ${username.padEnd(46)}│\n` +
` │ password ${password.padEnd(46)}│\n` +
' │ This is shown once. Change it under Account after login. │\n' +
' └────────────────────────────────────────────────────────────┘\n',
); );
} }
} }
+1
View File
@@ -789,6 +789,7 @@
"Storage & cleanup": "Speicher & Aufräumen", "Storage & cleanup": "Speicher & Aufräumen",
"Stored encrypted, takes precedence over the environment variable.": "Verschlüsselt gespeichert, hat Vorrang vor der Umgebungsvariable.", "Stored encrypted, takes precedence over the environment variable.": "Verschlüsselt gespeichert, hat Vorrang vor der Umgebungsvariable.",
"Studio": "Studio", "Studio": "Studio",
"Studio · Klarbild": "Studio · Klarbild",
"Subject only, background transparent.": "Nur das Motiv, Hintergrund transparent.", "Subject only, background transparent.": "Nur das Motiv, Hintergrund transparent.",
"Switch back on": "Wieder freigeben", "Switch back on": "Wieder freigeben",
"Switch off": "Abschalten", "Switch off": "Abschalten",
+4 -1
View File
@@ -3,13 +3,16 @@ import Base from '../layouts/Base.astro';
import StudioApp from '../components/StudioApp.tsx'; import StudioApp from '../components/StudioApp.tsx';
import { query } from '../lib/db'; import { query } from '../lib/db';
import { has } from '../lib/modules'; import { has } from '../lib/modules';
import { tFor, dictFor } from '../lib/i18n.server';
// Without the AI module there is no studio, and the print studio is the product. // Without the AI module there is no studio, and the print studio is the product.
if (!has('ai')) return Astro.redirect('/print', 302); if (!has('ai')) return Astro.redirect('/print', 302);
const locale = Astro.locals.locale ?? 'en'; const locale = Astro.locals.locale ?? 'en';
const t = tFor(locale);
const dict = dictFor(locale);
const recipes = await query('SELECT * FROM recipes ORDER BY is_default DESC, name').catch(() => []); const recipes = await query('SELECT * FROM recipes ORDER BY is_default DESC, name').catch(() => []);
--- ---
<Base title="Studio · Klarbild"> <Base title={t('Studio · Klarbild')}>
<StudioApp client:load recipes={recipes} locale={locale} dict={dict} /> <StudioApp client:load recipes={recipes} locale={locale} dict={dict} />
</Base> </Base>
+2 -1
View File
@@ -2,6 +2,7 @@
import Base from '../layouts/Base.astro'; import Base from '../layouts/Base.astro';
import PrintApp from '../components/PrintApp.tsx'; import PrintApp from '../components/PrintApp.tsx';
import { tFor, dictFor } from '../lib/i18n.server'; import { tFor, dictFor } from '../lib/i18n.server';
import { has } from '../lib/modules';
const locale = Astro.locals.locale ?? 'en'; const locale = Astro.locals.locale ?? 'en';
const t = tFor(locale); const t = tFor(locale);
const dict = dictFor(locale); const dict = dictFor(locale);
@@ -11,7 +12,7 @@ const dict = dictFor(locale);
<p class="page-lead"> <p class="page-lead">
{t('No AI: bring images to exact sizes and place several of them at 100% size with crop marks on one sheet — from the biometric passport photo through the school photo set to the 30 × 40 poster.')} {t('No AI: bring images to exact sizes and place several of them at 100% size with crop marks on one sheet — from the biometric passport photo through the school photo set to the 30 × 40 poster.')}
</p> </p>
<PrintApp client:load locale={locale} dict={dict} /> <PrintApp client:load locale={locale} dict={dict} canDeliver={has('delivery') || has('mirror')} />
<style> <style>
.page-title{font-family:var(--font-display);font-size:1.6rem;margin:6px 0 4px;letter-spacing:-.02em;} .page-title{font-family:var(--font-display);font-size:1.6rem;margin:6px 0 4px;letter-spacing:-.02em;}
.page-lead{color:var(--soft);font-size:13.5px;margin:0 0 16px;max-width:64ch;line-height:1.55;} .page-lead{color:var(--soft);font-size:13.5px;margin:0 0 16px;max-width:64ch;line-height:1.55;}