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
token, no account anywhere. See `src/lib/modules.ts`.
- **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
administrator from `ADMIN_USERNAME` / `ADMIN_PASSWORD`, or generates a password
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`,
`/tour`, `/password`.
- 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**
+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
default; S3/MinIO is available but not required.
About 13,600 lines across 116 files, 124 tests, 16 forward-only idempotent
migrations that run themselves on boot. English and German interface, 1,038
translated strings; the English sentence is the translation key, so there is no key
file to keep in sync.
About 13,700 lines across 117 files, 124 tests, 16 forward-only idempotent
migrations that run themselves on boot. English and German interface — 1,067
entries, covering every translatable string in the source. The English sentence is
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
+12 -13
View File
@@ -14,19 +14,18 @@ export default defineConfig({
// 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
// directives are set by the middleware (src/middleware.ts).
experimental: {
csp: {
directives: [
"default-src 'self'",
"img-src 'self' data: blob:",
"font-src 'self'",
"connect-src 'self'",
"object-src 'none'",
"base-uri 'self'",
"form-action 'self'",
"frame-ancestors 'none'",
],
},
// Graduated out of `experimental` in Astro 7.
csp: {
directives: [
"default-src 'self'",
"img-src 'self' data: blob:",
"font-src 'self'",
"connect-src 'self'",
"object-src 'none'",
"base-uri 'self'",
"form-action 'self'",
"frame-ancestors 'none'",
],
},
server: { host: true, port: Number(process.env.PORT) || 4321 },
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"
},
"dependencies": {
"@astrojs/node": "^9.1.3",
"@astrojs/react": "^4.2.1",
"@astrojs/node": "^11.1.5",
"@astrojs/react": "^6.0.5",
"@aws-sdk/client-s3": "^3.700.0",
"@fontsource/space-grotesk": "^5.1.0",
"@fontsource/space-mono": "^5.1.0",
"archiver": "^7.0.1",
"argon2": "^0.41.1",
"astro": "^5.5.0",
"astro": "^7.3.2",
"basic-ftp": "^5.0.5",
"grammy": "^1.30.0",
"heic-convert": "^2.1.0",
@@ -55,7 +55,7 @@
"pg-boss": "^10.1.5",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"sharp": "^0.33.5",
"sharp": "^0.35.4",
"ssh2-sftp-client": "^11.0.0"
},
"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 [cells, setCells] = useState<Cell[]>([]);
const [paperId, setPaperId] = useState('A4');
@@ -298,8 +299,13 @@ export default function PrintApp({ locale, dict }: { locale?: Locale; dict?: Dic
useEffect(() => {
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())
.then((j) => setPresets(j.presets || [])).catch(() => {});
@@ -706,6 +712,7 @@ export default function PrintApp({ locale, dict }: { locale?: Locale; dict?: Dic
)}
</Group>
{canDeliver && (
<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)}
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)}
placeholder={t('Folder on the target (empty = default)')} aria-label={t('Folder on the target')} />
)}
</Group>
</Group>)}
</div>
</div>
</Card>
+2 -2
View File
@@ -1,7 +1,7 @@
// Rendering the print sheet: crop (sharp) + PDF assembly (pdf-lib).
// Deliberately without AI — plain geometry and scaling, so that the result is
// predictable and repeatable.
import sharp from 'sharp';
import sharp, { type Sharp } from 'sharp';
import { PDFDocument, StandardFonts, rgb, degrees } from 'pdf-lib';
import { mmToPt, mmToPx } from './paper.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';
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 }).jpeg({ quality: 94, mozjpeg: true }));
+10 -7
View File
@@ -44,14 +44,17 @@ async function seedAdmin(): Promise<void> {
} 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.
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(
'\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',
'\n ┌' + '─'.repeat(w) + '┐\n' +
lines.map((l) => ` │ ${l.padEnd(w - 1)}│`).join('\n') +
'\n └' + '─'.repeat(w) + '┘\n',
);
}
}
+1
View File
@@ -789,6 +789,7 @@
"Storage & cleanup": "Speicher & Aufräumen",
"Stored encrypted, takes precedence over the environment variable.": "Verschlüsselt gespeichert, hat Vorrang vor der Umgebungsvariable.",
"Studio": "Studio",
"Studio · Klarbild": "Studio · Klarbild",
"Subject only, background transparent.": "Nur das Motiv, Hintergrund transparent.",
"Switch back on": "Wieder freigeben",
"Switch off": "Abschalten",
+4 -1
View File
@@ -3,13 +3,16 @@ import Base from '../layouts/Base.astro';
import StudioApp from '../components/StudioApp.tsx';
import { query } from '../lib/db';
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.
if (!has('ai')) return Astro.redirect('/print', 302);
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(() => []);
---
<Base title="Studio · Klarbild">
<Base title={t('Studio · Klarbild')}>
<StudioApp client:load recipes={recipes} locale={locale} dict={dict} />
</Base>
+2 -1
View File
@@ -2,6 +2,7 @@
import Base from '../layouts/Base.astro';
import PrintApp from '../components/PrintApp.tsx';
import { tFor, dictFor } from '../lib/i18n.server';
import { has } from '../lib/modules';
const locale = Astro.locals.locale ?? 'en';
const t = tFor(locale);
const dict = dictFor(locale);
@@ -11,7 +12,7 @@ const dict = dictFor(locale);
<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.')}
</p>
<PrintApp client:load locale={locale} dict={dict} />
<PrintApp client:load locale={locale} dict={dict} canDeliver={has('delivery') || has('mirror')} />
<style>
.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;}