Astro 7, and stop a print-only instance calling a route it has switched off
CI / check (push) Has been cancelled
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:
13 files changed
+1681
-3890
No files matched your search
+3
-1
@@ -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**
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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 |
Generated
+1627
-3853
File diff suppressed because it is too large.
Load diff
+4
-4
@@ -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": {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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',
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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,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;}
|
||||||
|
|||||||
Reference in new issue
Block a user