From 5f58d9b96f761b2ce0b586ae32d91505c066800b Mon Sep 17 00:00:00 2001 From: Harlekin Date: Wed, 2 Sep 2026 20:27:22 +0200 Subject: [PATCH] =?UTF-8?q?Phase=205=20abgeschlossen:=20Dateien=20mit=20vo?= =?UTF-8?q?llst=C3=A4ndiger=20Pr=C3=BCfkette?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Upload → quarantine/ → Typprüfung → ClamAV → Neukodierung → store/. Es gibt keinen Pfad, auf dem eine Datei ungeprüft in store/ landet, und keinen Schalter, der die Kette abschaltet. Die Typprüfung liest Magic Bytes aus einer eigenen Signaturtabelle statt aus file-type: bei einer Positivliste aus fünf Formaten hilft es nicht, hundert erkennen zu können. Die Signatur muss an Position 0 stehen — ein Vorspann vor %PDF- ist der klassische Weg, eine Datei für zwei Programme zugleich lesbar zu machen. Wer trotzdem durchkommt, kommt als text/plain durch und wird unter der Endung des erkannten Typs ausgeliefert, nicht unter der hochgeladenen. Drei Ausgänge statt zwei: BLOCKED heisst "ist gefährlich", FAILED heisst "wissen wir nicht". Nur FAILED lässt sich freigeben, und die Freigabe wiederholt die Kette ohne den Scanner — Typprüfung und Neukodierung laufen dabei mit. Ein fehlender clamd macht damit keine Datei sauber; ein Test hält das fest. Auslieferung nur über /api/files/[id], mit Sichtbarkeitsprüfung der zugehörigen Akte, Protokoll ab Stufe 3 und den harten Kopfzeilen aus docs/07. Vorschaubilder dürfen inline, weil sie serverseitig als WebP neu kodiert wurden. Das Lichtbild im Aktenblatt ist damit gefüllt: der Feldwert ist die Kennung eines geprüften Anhangs, serverseitig zu einer Adresse aufgelöst. /api/events liefert attachment.scanned über pg_notify — die Grundlage, auf der Phase 6 den Aktenchat aufsetzt. Die Sichtbarkeit wird je Ereignis geprüft, sonst verriete schon die Kennung die Existenz. Nebenbei riss das Bündelbudget (121 kB gegen 120 erlaubte): lib/i18n/de.ts war ein Objekt, und eine Client-Komponente zieht die ganze Datei mit. Die Texte stehen jetzt in sechs Dateien nach Bereichen, de.ts setzt sie nur zusammen. Grösste Route wieder bei 118 kB, und sie wächst nicht mehr mit jedem neuen Bereich mit. 133 Tests im Web-Paket, 15 in der Datenbank, 28 e2e. Co-Authored-By: Claude Opus 5 --- TASKS.md | 16 +- docker/web.Dockerfile | 4 +- docs/07-dateien-und-sicherheit.md | 20 +- docs/13-entscheidungen.md | 81 +++ .../20260902220000_dateien/migration.sql | 27 + packages/db/prisma/schema.prisma | 23 +- pnpm-lock.yaml | 319 +++++------- start.sh | 16 + web/app/(app)/admin/richtlinie/PolicyForm.tsx | 17 +- web/app/(app)/admin/rollen/RoleRow.tsx | 22 +- .../(app)/admin/vorlagen/TemplateEditor.tsx | 35 +- web/app/(app)/dateien/actions.ts | 38 ++ .../[documentNumber]/DocumentActions.tsx | 30 +- .../(app)/dokumente/[documentNumber]/page.tsx | 52 ++ .../(app)/faelle/[caseNumber]/CaseActions.tsx | 23 +- .../(app)/faelle/[caseNumber]/CaseLinks.tsx | 23 +- .../faelle/[caseNumber]/EntryActions.tsx | 19 +- .../faelle/[caseNumber]/EntryComposer.tsx | 10 +- .../faelle/[caseNumber]/Participants.tsx | 31 +- web/app/(app)/faelle/[caseNumber]/page.tsx | 19 +- web/app/(app)/faelle/neu/NewCaseForm.tsx | 15 +- .../(app)/personalakten/[id]/Assignments.tsx | 24 +- web/app/(app)/personalakten/[id]/LinkCase.tsx | 20 +- .../personalakten/[id]/PersonnelHead.tsx | 46 +- .../personalakten/neu/NewPersonnelForm.tsx | 25 +- web/app/api/events/route.ts | 111 ++++ web/app/api/files/[id]/route.ts | 108 ++++ web/app/api/files/route.ts | 86 ++++ web/app/error.tsx | 8 +- web/components/documents/NewDocument.tsx | 19 +- web/components/documents/Sheet.tsx | 26 +- web/components/documents/SheetSwitch.tsx | 8 +- web/components/files/Attachments.tsx | 250 +++++++++ web/components/files/PhotoField.tsx | 169 +++++++ web/components/files/useScanEvents.ts | 43 ++ web/components/sections/SectionEditor.tsx | 20 +- web/components/sections/Sections.tsx | 34 +- web/lib/access/attachmentFilter.ts | 25 + web/lib/audit.ts | 3 + web/lib/files/clamav.ts | 93 ++++ web/lib/files/delivery.ts | 32 ++ web/lib/files/limits.ts | 26 + web/lib/files/magic.ts | 118 +++++ web/lib/files/paths.ts | 58 +++ web/lib/files/process.ts | 168 ++++++ web/lib/files/reencode.ts | 160 ++++++ web/lib/files/service.ts | 284 +++++++++++ web/lib/files/view.ts | 39 ++ web/lib/health/check.ts | 2 +- web/lib/health/clamav.ts | 24 - web/lib/i18n/admin.ts | 87 ++++ web/lib/i18n/cases.ts | 153 ++++++ web/lib/i18n/common.ts | 68 +++ web/lib/i18n/de.ts | 477 ++---------------- web/lib/i18n/documents.ts | 88 ++++ web/lib/i18n/people.ts | 59 +++ web/lib/i18n/shell.ts | 86 ++++ web/lib/realtime/listener.ts | 38 +- web/lib/realtime/publish.ts | 22 + web/package.json | 2 + web/tests/anhaenge.test.ts | 152 ++++++ web/tests/dateitypen.test.ts | 159 ++++++ web/tests/health.test.ts | 4 +- web/tests/neukodierung.test.ts | 94 ++++ web/tests/pruefkette.test.ts | 180 +++++++ 65 files changed, 3668 insertions(+), 870 deletions(-) create mode 100644 packages/db/prisma/migrations/20260902220000_dateien/migration.sql create mode 100644 web/app/(app)/dateien/actions.ts create mode 100644 web/app/api/events/route.ts create mode 100644 web/app/api/files/[id]/route.ts create mode 100644 web/app/api/files/route.ts create mode 100644 web/components/files/Attachments.tsx create mode 100644 web/components/files/PhotoField.tsx create mode 100644 web/components/files/useScanEvents.ts create mode 100644 web/lib/access/attachmentFilter.ts create mode 100644 web/lib/files/clamav.ts create mode 100644 web/lib/files/delivery.ts create mode 100644 web/lib/files/limits.ts create mode 100644 web/lib/files/magic.ts create mode 100644 web/lib/files/paths.ts create mode 100644 web/lib/files/process.ts create mode 100644 web/lib/files/reencode.ts create mode 100644 web/lib/files/service.ts create mode 100644 web/lib/files/view.ts delete mode 100644 web/lib/health/clamav.ts create mode 100644 web/lib/i18n/admin.ts create mode 100644 web/lib/i18n/cases.ts create mode 100644 web/lib/i18n/common.ts create mode 100644 web/lib/i18n/documents.ts create mode 100644 web/lib/i18n/people.ts create mode 100644 web/lib/i18n/shell.ts create mode 100644 web/lib/realtime/publish.ts create mode 100644 web/tests/anhaenge.test.ts create mode 100644 web/tests/dateitypen.test.ts create mode 100644 web/tests/neukodierung.test.ts create mode 100644 web/tests/pruefkette.test.ts diff --git a/TASKS.md b/TASKS.md index e9eba11..a9d75da 100644 --- a/TASKS.md +++ b/TASKS.md @@ -92,14 +92,14 @@ Das ist der schwierigste Teil. Wenn er steht, ist der Rest Formularbau. ## Phase 5 — Dateien -- [ ] Upload-Endpunkt mit Quarantäne, Limits, Kontingent -- [ ] Typprüfung über Magic Bytes, Whitelist -- [ ] ClamAV-Container angebunden -- [ ] Neu-Kodierung mit sharp und qpdf, Vorschaubild und Blurhash -- [ ] Auslieferung über `/api/files/[id]` mit Rechteprüfung und harten Headern -- [ ] Upload-UI: lokale Vorschau, Fortschrittsbalken, Zustand „Wird geprüft" -- [ ] SSE-Ereignis `attachment.scanned` aktualisiert die Anzeige -- [ ] Lichtbild im Aktenblatt: Feldtyp `photo` füllen, Wert ist die Kennung +- [x] Upload-Endpunkt mit Quarantäne, Limits, Kontingent +- [x] Typprüfung über Magic Bytes, Whitelist +- [x] ClamAV-Container angebunden +- [x] Neu-Kodierung mit sharp und qpdf, Vorschaubild und Blurhash +- [x] Auslieferung über `/api/files/[id]` mit Rechteprüfung und harten Headern +- [x] Upload-UI: lokale Vorschau, Fortschrittsbalken, Zustand „Wird geprüft" +- [x] SSE-Ereignis `attachment.scanned` aktualisiert die Anzeige +- [x] Lichtbild im Aktenblatt: Feldtyp `photo` füllen, Wert ist die Kennung eines geprüften Anhangs — das Feld und sein Platz stehen seit Phase 4 ## Phase 6 — Chat und Echtzeit diff --git a/docker/web.Dockerfile b/docker/web.Dockerfile index cf076d4..a7f2073 100644 --- a/docker/web.Dockerfile +++ b/docker/web.Dockerfile @@ -59,7 +59,9 @@ ENV NODE_ENV=production \ PORT=3000 \ HOSTNAME=0.0.0.0 -RUN apk add --no-cache wget openssl && \ +# qpdf säubert hochgeladene PDF-Dateien, siehe docs/07-dateien-und-sicherheit.md. +# Fehlt es, bleiben PDF-Uploads in der Quarantäne statt ungeprüft durchzugehen. +RUN apk add --no-cache wget openssl qpdf && \ addgroup -g 1001 -S afis && \ adduser -u 1001 -S afis -G afis diff --git a/docs/07-dateien-und-sicherheit.md b/docs/07-dateien-und-sicherheit.md index 54b5d2b..ad75083 100644 --- a/docs/07-dateien-und-sicherheit.md +++ b/docs/07-dateien-und-sicherheit.md @@ -26,17 +26,27 @@ Upload → quarantine/ → Typprüfung → ClamAV → Neu kodieren Erlaubt: `image/png`, `image/jpeg`, `image/webp`, `application/pdf`, `text/plain`. -Geprüft wird mit `file-type` anhand des Inhalts, **nie anhand der Endung**. Stimmt der -erkannte Typ nicht mit dem gemeldeten überein, ist die Datei blockiert. +Geprüft wird anhand des Inhalts, **nie anhand der Endung**. Stimmt der erkannte Typ nicht mit +dem gemeldeten überein, ist die Datei blockiert. + +Gebaut mit einer eigenen Signaturtabelle statt mit `file-type`, begründet in +`docs/13-entscheidungen.md`. Die Signatur muss an Position 0 stehen; ein Vorspann davor wird +nicht akzeptiert. Ausgeliefert wird unter der Endung des **erkannten** Typs, nicht unter der +des hochgeladenen Namens. Ausdrücklich verboten, auch wenn es bequem wäre: SVG (führt Skripte aus), HTML, ZIP und andere Archive, Office-Dokumente mit Makros, alles Ausführbare. ### 3. ClamAV -Eigener Container `clamav/clamav:stable`, angebunden über den clamd-Unix-Socket, geteiltes -Volume mit `quarantine/`. `freshclam` läuft im Container. Timeout 30 Sekunden, bei Timeout -`FAILED` und manuelle Freigabe durch einen Admin möglich. +Eigener Container `clamav/clamav:stable`, angebunden über den clamd-Unix-Socket. Übergeben wird +per `INSTREAM`, nicht als Pfad: dann muss der Scanner die Datei nicht sehen können. +`freshclam` läuft im Container. Timeout 30 Sekunden, bei Timeout `FAILED` und manuelle +Freigabe durch einen Admin möglich. + +`BLOCKED` und `FAILED` sind nicht dasselbe: das eine heisst „ist gefährlich", das andere +„wissen wir nicht". Nur `FAILED` lässt sich freigeben, und die Freigabe wiederholt die Kette +ohne den Scanner — Typprüfung und Neukodierung laufen dabei mit. ### 4. Neu kodieren diff --git a/docs/13-entscheidungen.md b/docs/13-entscheidungen.md index 5bffbfd..c34c81f 100644 --- a/docs/13-entscheidungen.md +++ b/docs/13-entscheidungen.md @@ -469,3 +469,84 @@ zusammen — dieselbe Prüfung, derselbe Protokolleintrag. Der Unterschied liegt in der Auswahl: auf der Fallakte eine Liste der sichtbaren Personen, auf der Personalakte ein Eingabefeld für das Aktenzeichen. Eine Liste aller sichtbaren Fälle auf einer Personalakte wäre am falschen Ort — sie beantwortet nicht die Frage, die man dort hat. + +## Dateien + +### Eigene Signaturtabelle statt `file-type` + +`docs/07-dateien-und-sicherheit.md` nennt die Bibliothek `file-type`. Gebaut wurde eine +eigene Tabelle in `web/lib/files/magic.ts`, aus drei Gründen: + +- Die Liste der erlaubten Typen ist **kurz und geschlossen**. Bei einer Positivliste hilft es + nicht, hundert Formate erkennen zu können — was nicht in der Tabelle steht, fliegt ohnehin + raus. +- Der eigentliche Schutz ist nicht die Erkennung, sondern die **Neukodierung** danach. Ein + Bild, das durch `sharp` gelaufen ist, besteht nur noch aus Pixeln. +- Eine Abhängigkeit weniger, die auf einem Heimserver aktuell gehalten werden muss. + +Die Tabelle ist streng: die Signatur muss an Position 0 stehen. Ein Vorspann vor `%PDF-` ist +der klassische Weg, eine Datei für zwei Programme zugleich lesbar zu machen; die +PDF-Spezifikation erlaubt ihn, wir nicht. + +### `text/plain` ist das Schlupfloch, und deshalb eng + +Reiner Text hat keine Signatur. Er wird zuletzt geprüft und nur angenommen, wenn der **ganze** +Inhalt gültiges UTF-8 ohne Steuerzeichen ist. Sonst wäre `text/plain` der Weg, durch den jedes +Binärformat passt. + +Eine Folge davon: eine Datei, die sowohl lesbarer Text als auch ein PDF sein will, wird als +Text erkannt. Sie wird deshalb **unter der Endung des erkannten Typs ausgeliefert** — aus +`bericht.pdf` wird `bericht.txt`. Damit landet sie beim Doppelklick in keinem PDF-Betrachter. +Die Endung folgt grundsätzlich dem erkannten Typ, nie dem hochgeladenen Namen. + +### Ein ausgefallener Scanner macht eine Datei nicht sauber + +Drei Ausgänge statt zwei: + +| Zustand | Bedeutung | +|---|---| +| `BLOCKED` | Als Schadsoftware oder unzulässiger Typ **erkannt**. Endgültig | +| `FAILED` | Die Prüfung konnte nicht abgeschlossen werden — Scanner weg, `qpdf` fehlt, Zeitüberschreitung | +| `CLEAN` | Die ganze Kette bestanden | + +Der Unterschied zwischen `BLOCKED` und `FAILED` ist der zwischen „ist gefährlich" und „wissen +wir nicht". Nur das Zweite lässt sich von einem Administrator freigeben, und diese Freigabe +wiederholt die Kette **ohne den Scanner**: die Typprüfung und die Neukodierung laufen dabei +mit. Übersprungen wird nur, was ausgefallen war. + +Damit gibt es keinen Schalter, der die Prüfung abschaltet. In der Entwicklung ohne +clamav-Container landet jeder Upload auf `FAILED` und wird von Hand freigegeben — unbequem, +aber ehrlich. Eine Umgebungsvariable, die Dateien ungeprüft durchwinkt, wäre genau die Art +Abkürzung, die irgendwann in der Produktion steht. + +### Das Lichtbild ist eine Kennung, kein Bild + +Der Wert eines `photo`-Feldes ist die Kennung eines geprüften Anhangs. Die Vorlage der +Abteilung legt das Bild als Data-URL ab; im Abschnittswert wäre das eine ungeprüfte Datei +mitten im Aktentext, die sich nachträglich kaum wieder herausbekommen liesse. + +Aufgelöst wird **serverseitig**: der Browser bekommt eine Adresse oder gar nichts, nie eine +Kennung zum Ausprobieren. + +### Die Verarbeitung läuft im Web-Dienst + +`compose.yaml` hängt das Dateivolumen und den clamd-Socket an `web`, nicht an `worker` — die +Prüfkette läuft deshalb dort, angestossen nach der Antwort auf den Upload. Der Nutzer sieht +den Anhang sofort mit „Wird geprüft"; der Fortschrittsbalken zeigt allein die Übertragung. +Kein Balken wartet auf einen Virenscanner, siehe docs/09-performance-und-ux.md. + +## Die Oberflächentexte sind nach Bereichen getrennt + +`lib/i18n/de.ts` war ein einziges Objekt. Eine Client-Komponente zieht aber die **ganze +Datei** ins Bündel, in der ihr Text steht — jede Ansicht trug damit die Texte aller anderen +mit sich herum. Mit Phase 5 riss das Budget aus `docs/09-performance-und-ux.md`: 121 kB gegen +120 kB erlaubte. + +Die Texte stehen jetzt in sechs Dateien nach Bereichen (`shell`, `common`, `cases`, `people`, +`documents`, `admin`), und `de.ts` setzt sie nur noch zusammen. Serverkomponenten nehmen +weiter `de` — was dorthin gezogen wird, verlässt den Server nicht. Client-Komponenten +importieren den Bereich, den sie brauchen. + +Das brachte die grösste Route von 121 auf 118 kB und wächst nicht mehr mit jedem neuen +Bereich mit. Der Zuschnitt der sechs Gruppen folgt der gemeinsamen Verwendung, nicht der +Grösse: was zusammen gebraucht wird, liegt zusammen. diff --git a/packages/db/prisma/migrations/20260902220000_dateien/migration.sql b/packages/db/prisma/migrations/20260902220000_dateien/migration.sql new file mode 100644 index 0000000..8cebc07 --- /dev/null +++ b/packages/db/prisma/migrations/20260902220000_dateien/migration.sql @@ -0,0 +1,27 @@ +-- Phase 5: Dateien. +-- +-- Anhänge können jetzt auch an einem Dokument hängen (Lichtbild im +-- Aktenblatt), tragen die Maße des Bildes und merken sich, wer eine Datei +-- freigegeben hat, deren Prüfung nicht abgeschlossen werden konnte. + +ALTER TABLE "Attachment" + ADD COLUMN "documentId" TEXT, + ADD COLUMN "width" INTEGER, + ADD COLUMN "height" INTEGER, + ADD COLUMN "releasedById" TEXT, + ADD COLUMN "releasedAt" TIMESTAMP(3); + +CREATE INDEX "Attachment_documentId_idx" ON "Attachment"("documentId"); + +ALTER TABLE "Attachment" ADD CONSTRAINT "Attachment_documentId_fkey" + FOREIGN KEY ("documentId") REFERENCES "Document"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- Ein Anhang gehört zu höchstens einem Ort. Dieselbe Begründung wie beim +-- Dokument: zwei Besitzer wären eine Aussage über die Sichtbarkeit, die +-- niemand prüfen könnte. +ALTER TABLE "Attachment" ADD CONSTRAINT "Attachment_one_owner" + CHECK ( + (CASE WHEN "entryId" IS NULL THEN 0 ELSE 1 END) + + (CASE WHEN "caseId" IS NULL THEN 0 ELSE 1 END) + + (CASE WHEN "documentId" IS NULL THEN 0 ELSE 1 END) <= 1 + ); diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 70a16b1..10960af 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -583,6 +583,7 @@ model Document { case Case? @relation(fields: [caseId], references: [id], onDelete: Restrict) personnelFile PersonnelFile? @relation(fields: [personnelFileId], references: [id], onDelete: Restrict) sections DocumentSection[] + attachments Attachment[] @@index([caseId, createdAt(sort: Desc)]) @@index([personnelFileId, createdAt(sort: Desc)]) @@ -639,29 +640,45 @@ model DocumentSectionVersion { // Dateien // --------------------------------------------------------------------------- +/// Eine hochgeladene Datei. Sie ist erst dann etwas wert, wenn sie die +/// Pruefkette aus docs/07-dateien-und-sicherheit.md ganz durchlaufen hat: +/// Typpruefung ueber Magic Bytes, ClamAV, Neukodierung. Bis dahin liegt sie in +/// quarantine/ und wird nicht ausgeliefert. model Attachment { id String @id @default(uuid(7)) entryId String? caseId String? + documentId String? originalName String + /// Der **erkannte** Typ, nicht der gemeldete. Siehe lib/files/magic.ts. mimeType String sizeBytes Int sha256 String scanStatus ScanStatus @default(PENDING) scanMessage String? - storageKey String // UUID-Pfad in store/ + /// Pfad unterhalb von FILES_DIR, inklusive quarantine/ oder store/. + storageKey String thumbKey String? blurhash String? + width Int? + height Int? uploadedById String createdAt DateTime @default(now()) - entry CaseEntry? @relation(fields: [entryId], references: [id], onDelete: Cascade) - case Case? @relation(fields: [caseId], references: [id], onDelete: Cascade) + /// Freigabe einer Datei, die die Pruefung nicht abschliessen konnte. + /// Nur mit Recht 'admin', immer protokolliert. + releasedById String? + releasedAt DateTime? + + entry CaseEntry? @relation(fields: [entryId], references: [id], onDelete: Cascade) + case Case? @relation(fields: [caseId], references: [id], onDelete: Cascade) + document Document? @relation(fields: [documentId], references: [id], onDelete: Cascade) @@index([sha256]) @@index([scanStatus]) @@index([caseId]) @@index([entryId]) + @@index([documentId]) @@index([uploadedById, createdAt]) } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 47ff250..5bab958 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -85,12 +85,15 @@ importers: '@afis/db': specifier: workspace:* version: link:../packages/db + blurhash: + specifier: 2.0.5 + version: 2.0.5 next: specifier: 15.5.25 - version: 15.5.25(@babel/core@7.29.7)(@playwright/test@1.62.1)(@types/node@22.20.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + version: 15.5.25(@babel/core@7.29.7)(@playwright/test@1.62.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) next-auth: specifier: 5.0.0-beta.32 - version: 5.0.0-beta.32(next@15.5.25(@babel/core@7.29.7)(@playwright/test@1.62.1)(@types/node@22.20.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8) + version: 5.0.0-beta.32(next@15.5.25(@babel/core@7.29.7)(@playwright/test@1.62.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8) pg: specifier: 8.13.1 version: 8.13.1 @@ -100,6 +103,9 @@ importers: react-dom: specifier: ^19.0.0 version: 19.2.8(react@19.2.8) + sharp: + specifier: 0.34.4 + version: 0.34.4 uuidv7: specifier: 1.0.2 version: 1.0.2 @@ -628,145 +634,125 @@ packages: resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} engines: {node: '>=18'} - '@img/sharp-darwin-arm64@0.35.4': - resolution: {integrity: sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==} - engines: {node: '>=20.9.0'} + '@img/sharp-darwin-arm64@0.34.4': + resolution: {integrity: sha512-sitdlPzDVyvmINUdJle3TNHl+AG9QcwiAMsXmccqsCOMZNIdW2/7S26w0LyU8euiLVzFBL3dXPwVCq/ODnf2vA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [darwin] - '@img/sharp-darwin-x64@0.35.4': - resolution: {integrity: sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==} - engines: {node: '>=20.9.0'} + '@img/sharp-darwin-x64@0.34.4': + resolution: {integrity: sha512-rZheupWIoa3+SOdF/IcUe1ah4ZDpKBGWcsPX6MT0lYniH9micvIU7HQkYTfrx5Xi8u+YqwLtxC/3vl8TQN6rMg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [darwin] - '@img/sharp-freebsd-wasm32@0.35.4': - resolution: {integrity: sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==} - engines: {node: '>=20.9.0'} - os: [freebsd] - - '@img/sharp-libvips-darwin-arm64@1.3.3': - resolution: {integrity: sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==} + '@img/sharp-libvips-darwin-arm64@1.2.3': + resolution: {integrity: sha512-QzWAKo7kpHxbuHqUC28DZ9pIKpSi2ts2OJnoIGI26+HMgq92ZZ4vk8iJd4XsxN+tYfNJxzH6W62X5eTcsBymHw==} cpu: [arm64] os: [darwin] - '@img/sharp-libvips-darwin-x64@1.3.3': - resolution: {integrity: sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==} + '@img/sharp-libvips-darwin-x64@1.2.3': + resolution: {integrity: sha512-Ju+g2xn1E2AKO6YBhxjj+ACcsPQRHT0bhpglxcEf+3uyPY+/gL8veniKoo96335ZaPo03bdDXMv0t+BBFAbmRA==} cpu: [x64] os: [darwin] - '@img/sharp-libvips-linux-arm64@1.3.3': - resolution: {integrity: sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==} + '@img/sharp-libvips-linux-arm64@1.2.3': + resolution: {integrity: sha512-I4RxkXU90cpufazhGPyVujYwfIm9Nk1QDEmiIsaPwdnm013F7RIceaCc87kAH+oUB1ezqEvC6ga4m7MSlqsJvQ==} cpu: [arm64] os: [linux] - '@img/sharp-libvips-linux-arm@1.3.3': - resolution: {integrity: sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==} + '@img/sharp-libvips-linux-arm@1.2.3': + resolution: {integrity: sha512-x1uE93lyP6wEwGvgAIV0gP6zmaL/a0tGzJs/BIDDG0zeBhMnuUPm7ptxGhUbcGs4okDJrk4nxgrmxpib9g6HpA==} cpu: [arm] os: [linux] - '@img/sharp-libvips-linux-ppc64@1.3.3': - resolution: {integrity: sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==} + '@img/sharp-libvips-linux-ppc64@1.2.3': + resolution: {integrity: sha512-Y2T7IsQvJLMCBM+pmPbM3bKT/yYJvVtLJGfCs4Sp95SjvnFIjynbjzsa7dY1fRJX45FTSfDksbTp6AGWudiyCg==} cpu: [ppc64] os: [linux] - '@img/sharp-libvips-linux-riscv64@1.3.3': - resolution: {integrity: sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==} - cpu: [riscv64] - os: [linux] - - '@img/sharp-libvips-linux-s390x@1.3.3': - resolution: {integrity: sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==} + '@img/sharp-libvips-linux-s390x@1.2.3': + resolution: {integrity: sha512-RgWrs/gVU7f+K7P+KeHFaBAJlNkD1nIZuVXdQv6S+fNA6syCcoboNjsV2Pou7zNlVdNQoQUpQTk8SWDHUA3y/w==} cpu: [s390x] os: [linux] - '@img/sharp-libvips-linux-x64@1.3.3': - resolution: {integrity: sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==} + '@img/sharp-libvips-linux-x64@1.2.3': + resolution: {integrity: sha512-3JU7LmR85K6bBiRzSUc/Ff9JBVIFVvq6bomKE0e63UXGeRw2HPVEjoJke1Yx+iU4rL7/7kUjES4dZ/81Qjhyxg==} cpu: [x64] os: [linux] - '@img/sharp-libvips-linuxmusl-arm64@1.3.3': - resolution: {integrity: sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==} + '@img/sharp-libvips-linuxmusl-arm64@1.2.3': + resolution: {integrity: sha512-F9q83RZ8yaCwENw1GieztSfj5msz7GGykG/BA+MOUefvER69K/ubgFHNeSyUu64amHIYKGDs4sRCMzXVj8sEyw==} cpu: [arm64] os: [linux] - '@img/sharp-libvips-linuxmusl-x64@1.3.3': - resolution: {integrity: sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==} + '@img/sharp-libvips-linuxmusl-x64@1.2.3': + resolution: {integrity: sha512-U5PUY5jbc45ANM6tSJpsgqmBF/VsL6LnxJmIf11kB7J5DctHgqm0SkuXzVWtIY90GnJxKnC/JT251TDnk1fu/g==} cpu: [x64] os: [linux] - '@img/sharp-linux-arm64@0.35.4': - resolution: {integrity: sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==} - engines: {node: '>=20.9.0'} + '@img/sharp-linux-arm64@0.34.4': + resolution: {integrity: sha512-YXU1F/mN/Wu786tl72CyJjP/Ngl8mGHN1hST4BGl+hiW5jhCnV2uRVTNOcaYPs73NeT/H8Upm3y9582JVuZHrQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] - '@img/sharp-linux-arm@0.35.4': - resolution: {integrity: sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==} - engines: {node: '>=20.9.0'} + '@img/sharp-linux-arm@0.34.4': + resolution: {integrity: sha512-Xyam4mlqM0KkTHYVSuc6wXRmM7LGN0P12li03jAnZ3EJWZqj83+hi8Y9UxZUbxsgsK1qOEwg7O0Bc0LjqQVtxA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm] os: [linux] - '@img/sharp-linux-ppc64@0.35.4': - resolution: {integrity: sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==} - engines: {node: '>=20.9.0'} + '@img/sharp-linux-ppc64@0.34.4': + resolution: {integrity: sha512-F4PDtF4Cy8L8hXA2p3TO6s4aDt93v+LKmpcYFLAVdkkD3hSxZzee0rh6/+94FpAynsuMpLX5h+LRsSG3rIciUQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ppc64] os: [linux] - '@img/sharp-linux-riscv64@0.35.4': - resolution: {integrity: sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==} - engines: {node: '>=20.9.0'} - cpu: [riscv64] - os: [linux] - - '@img/sharp-linux-s390x@0.35.4': - resolution: {integrity: sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==} - engines: {node: '>=20.9.0'} + '@img/sharp-linux-s390x@0.34.4': + resolution: {integrity: sha512-qVrZKE9Bsnzy+myf7lFKvng6bQzhNUAYcVORq2P7bDlvmF6u2sCmK2KyEQEBdYk+u3T01pVsPrkj943T1aJAsw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [s390x] os: [linux] - '@img/sharp-linux-x64@0.35.4': - resolution: {integrity: sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==} - engines: {node: '>=20.9.0'} + '@img/sharp-linux-x64@0.34.4': + resolution: {integrity: sha512-ZfGtcp2xS51iG79c6Vhw9CWqQC8l2Ot8dygxoDoIQPTat/Ov3qAa8qpxSrtAEAJW+UjTXc4yxCjNfxm4h6Xm2A==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] - '@img/sharp-linuxmusl-arm64@0.35.4': - resolution: {integrity: sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==} - engines: {node: '>=20.9.0'} + '@img/sharp-linuxmusl-arm64@0.34.4': + resolution: {integrity: sha512-8hDVvW9eu4yHWnjaOOR8kHVrew1iIX+MUgwxSuH2XyYeNRtLUe4VNioSqbNkB7ZYQJj9rUTT4PyRscyk2PXFKA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] - '@img/sharp-linuxmusl-x64@0.35.4': - resolution: {integrity: sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==} - engines: {node: '>=20.9.0'} + '@img/sharp-linuxmusl-x64@0.34.4': + resolution: {integrity: sha512-lU0aA5L8QTlfKjpDCEFOZsTYGn3AEiO6db8W5aQDxj0nQkVrZWmN3ZP9sYKWJdtq3PWPhUNlqehWyXpYDcI9Sg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] - '@img/sharp-wasm32@0.35.4': - resolution: {integrity: sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==} - engines: {node: '>=20.9.0'} - - '@img/sharp-webcontainers-wasm32@0.35.4': - resolution: {integrity: sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==} - engines: {node: '>=20.9.0'} + '@img/sharp-wasm32@0.34.4': + resolution: {integrity: sha512-33QL6ZO/qpRyG7woB/HUALz28WnTMI2W1jgX3Nu2bypqLIKx/QKMILLJzJjI+SIbvXdG9fUnmrxR7vbi1sTBeA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [wasm32] - '@img/sharp-win32-arm64@0.35.4': - resolution: {integrity: sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==} - engines: {node: '>=20.9.0'} + '@img/sharp-win32-arm64@0.34.4': + resolution: {integrity: sha512-2Q250do/5WXTwxW3zjsEuMSv5sUU4Tq9VThWKlU2EYLm4MB7ZeMwF+SFJutldYODXF6jzc6YEOC+VfX0SZQPqA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [win32] - '@img/sharp-win32-ia32@0.35.4': - resolution: {integrity: sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==} - engines: {node: ^20.9.0} + '@img/sharp-win32-ia32@0.34.4': + resolution: {integrity: sha512-3ZeLue5V82dT92CNL6rsal6I2weKw1cYu+rGKm8fOCCtJTR2gYeUfY3FqUnIJsMUPIH68oS5jmZ0NiJ508YpEw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ia32] os: [win32] - '@img/sharp-win32-x64@0.35.4': - resolution: {integrity: sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==} - engines: {node: '>=20.9.0'} + '@img/sharp-win32-x64@0.34.4': + resolution: {integrity: sha512-xIyj4wpYs8J18sVN3mSQjwrw7fKUqRw+Z5rnHNCy5fYTxigBz81u5mOMPmFumwjcn8+ld1ppptMBCLic1nz6ig==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [win32] @@ -1699,6 +1685,9 @@ packages: better-result@2.10.0: resolution: {integrity: sha512-oQhh0y1qo2/ZKdAAEvHZAqKKiHOFU5k/bW96fE2ScgQOVkJRiHwB+nOS1SgFsYqRlxMDWvefXi9Q3px7QvgNDw==} + blurhash@2.0.5: + resolution: {integrity: sha512-cRygWd7kGBQO3VEhPiTgq4Wc43ctsM+o46urrmPOiuAe+07fzlSB9OJVdpgDL0jPqXUVQ9ht7aq7kxOeJHRK+w==} + brace-expansion@1.1.18: resolution: {integrity: sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==} @@ -3243,14 +3232,9 @@ packages: resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==} engines: {node: '>= 0.4'} - sharp@0.35.4: - resolution: {integrity: sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==} - engines: {node: '>=20.9.0'} - peerDependencies: - '@types/node': '*' - peerDependenciesMeta: - '@types/node': - optional: true + sharp@0.34.4: + resolution: {integrity: sha512-FUH39xp3SBPnxWvd5iib1X8XY7J0K0X7d93sie9CJg2PO8/7gmg89Nve6OjItK53/MlAushNNxteBYfM6DEuoA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} @@ -4075,111 +4059,92 @@ snapshots: '@humanwhocodes/retry@0.4.3': {} - '@img/colour@1.1.0': - optional: true + '@img/colour@1.1.0': {} - '@img/sharp-darwin-arm64@0.35.4': + '@img/sharp-darwin-arm64@0.34.4': optionalDependencies: - '@img/sharp-libvips-darwin-arm64': 1.3.3 + '@img/sharp-libvips-darwin-arm64': 1.2.3 optional: true - '@img/sharp-darwin-x64@0.35.4': + '@img/sharp-darwin-x64@0.34.4': optionalDependencies: - '@img/sharp-libvips-darwin-x64': 1.3.3 + '@img/sharp-libvips-darwin-x64': 1.2.3 optional: true - '@img/sharp-freebsd-wasm32@0.35.4': - dependencies: - '@img/sharp-wasm32': 0.35.4 + '@img/sharp-libvips-darwin-arm64@1.2.3': optional: true - '@img/sharp-libvips-darwin-arm64@1.3.3': + '@img/sharp-libvips-darwin-x64@1.2.3': optional: true - '@img/sharp-libvips-darwin-x64@1.3.3': + '@img/sharp-libvips-linux-arm64@1.2.3': optional: true - '@img/sharp-libvips-linux-arm64@1.3.3': + '@img/sharp-libvips-linux-arm@1.2.3': optional: true - '@img/sharp-libvips-linux-arm@1.3.3': + '@img/sharp-libvips-linux-ppc64@1.2.3': optional: true - '@img/sharp-libvips-linux-ppc64@1.3.3': + '@img/sharp-libvips-linux-s390x@1.2.3': optional: true - '@img/sharp-libvips-linux-riscv64@1.3.3': + '@img/sharp-libvips-linux-x64@1.2.3': optional: true - '@img/sharp-libvips-linux-s390x@1.3.3': + '@img/sharp-libvips-linuxmusl-arm64@1.2.3': optional: true - '@img/sharp-libvips-linux-x64@1.3.3': + '@img/sharp-libvips-linuxmusl-x64@1.2.3': optional: true - '@img/sharp-libvips-linuxmusl-arm64@1.3.3': - optional: true - - '@img/sharp-libvips-linuxmusl-x64@1.3.3': - optional: true - - '@img/sharp-linux-arm64@0.35.4': + '@img/sharp-linux-arm64@0.34.4': optionalDependencies: - '@img/sharp-libvips-linux-arm64': 1.3.3 + '@img/sharp-libvips-linux-arm64': 1.2.3 optional: true - '@img/sharp-linux-arm@0.35.4': + '@img/sharp-linux-arm@0.34.4': optionalDependencies: - '@img/sharp-libvips-linux-arm': 1.3.3 + '@img/sharp-libvips-linux-arm': 1.2.3 optional: true - '@img/sharp-linux-ppc64@0.35.4': + '@img/sharp-linux-ppc64@0.34.4': optionalDependencies: - '@img/sharp-libvips-linux-ppc64': 1.3.3 + '@img/sharp-libvips-linux-ppc64': 1.2.3 optional: true - '@img/sharp-linux-riscv64@0.35.4': + '@img/sharp-linux-s390x@0.34.4': optionalDependencies: - '@img/sharp-libvips-linux-riscv64': 1.3.3 + '@img/sharp-libvips-linux-s390x': 1.2.3 optional: true - '@img/sharp-linux-s390x@0.35.4': + '@img/sharp-linux-x64@0.34.4': optionalDependencies: - '@img/sharp-libvips-linux-s390x': 1.3.3 + '@img/sharp-libvips-linux-x64': 1.2.3 optional: true - '@img/sharp-linux-x64@0.35.4': + '@img/sharp-linuxmusl-arm64@0.34.4': optionalDependencies: - '@img/sharp-libvips-linux-x64': 1.3.3 + '@img/sharp-libvips-linuxmusl-arm64': 1.2.3 optional: true - '@img/sharp-linuxmusl-arm64@0.35.4': + '@img/sharp-linuxmusl-x64@0.34.4': optionalDependencies: - '@img/sharp-libvips-linuxmusl-arm64': 1.3.3 + '@img/sharp-libvips-linuxmusl-x64': 1.2.3 optional: true - '@img/sharp-linuxmusl-x64@0.35.4': - optionalDependencies: - '@img/sharp-libvips-linuxmusl-x64': 1.3.3 - optional: true - - '@img/sharp-wasm32@0.35.4': + '@img/sharp-wasm32@0.34.4': dependencies: '@emnapi/runtime': 1.11.3 optional: true - '@img/sharp-webcontainers-wasm32@0.35.4': - dependencies: - '@img/sharp-wasm32': 0.35.4 + '@img/sharp-win32-arm64@0.34.4': optional: true - '@img/sharp-win32-arm64@0.35.4': + '@img/sharp-win32-ia32@0.34.4': optional: true - '@img/sharp-win32-ia32@0.35.4': - optional: true - - '@img/sharp-win32-x64@0.35.4': + '@img/sharp-win32-x64@0.34.4': optional: true '@jridgewell/gen-mapping@0.3.13': @@ -5114,6 +5079,8 @@ snapshots: better-result@2.10.0: {} + blurhash@2.0.5: {} + brace-expansion@1.1.18: dependencies: balanced-match: 1.0.2 @@ -5545,8 +5512,8 @@ snapshots: '@typescript-eslint/parser': 8.69.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) eslint: 9.39.5(jiti@2.7.0) eslint-import-resolver-node: 0.3.10 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.69.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0)) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.69.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.69.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.5(jiti@2.7.0)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.69.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0)) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.5(jiti@2.7.0)) eslint-plugin-react: 7.37.5(eslint@9.39.5(jiti@2.7.0)) eslint-plugin-react-hooks: 5.2.0(eslint@9.39.5(jiti@2.7.0)) @@ -5565,7 +5532,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.69.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0)): + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.5(jiti@2.7.0)): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.3 @@ -5576,22 +5543,22 @@ snapshots: tinyglobby: 0.2.17 unrs-resolver: 1.12.2 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.69.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.69.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.69.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0)) transitivePeerDependencies: - supports-color - eslint-module-utils@2.14.0(@typescript-eslint/parser@8.69.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.69.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0)): + eslint-module-utils@2.14.0(@typescript-eslint/parser@8.69.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0)): dependencies: debug: 3.2.7 optionalDependencies: '@typescript-eslint/parser': 8.69.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) eslint: 9.39.5(jiti@2.7.0) eslint-import-resolver-node: 0.3.10 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.69.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.5(jiti@2.7.0)) transitivePeerDependencies: - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.69.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.69.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0)): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.69.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -5602,7 +5569,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.39.5(jiti@2.7.0) eslint-import-resolver-node: 0.3.10 - eslint-module-utils: 2.14.0(@typescript-eslint/parser@8.69.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.69.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0)) + eslint-module-utils: 2.14.0(@typescript-eslint/parser@8.69.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0)) hasown: 2.0.4 is-core-module: 2.16.2 is-glob: 4.0.3 @@ -6370,13 +6337,13 @@ snapshots: natural-compare@1.4.0: {} - next-auth@5.0.0-beta.32(next@15.5.25(@babel/core@7.29.7)(@playwright/test@1.62.1)(@types/node@22.20.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8): + next-auth@5.0.0-beta.32(next@15.5.25(@babel/core@7.29.7)(@playwright/test@1.62.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8): dependencies: '@auth/core': 0.41.3 - next: 15.5.25(@babel/core@7.29.7)(@playwright/test@1.62.1)(@types/node@22.20.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + next: 15.5.25(@babel/core@7.29.7)(@playwright/test@1.62.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) react: 19.2.8 - next@15.5.25(@babel/core@7.29.7)(@playwright/test@1.62.1)(@types/node@22.20.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + next@15.5.25(@babel/core@7.29.7)(@playwright/test@1.62.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8): dependencies: '@next/env': 15.5.25 '@swc/helpers': 0.5.15 @@ -6395,10 +6362,9 @@ snapshots: '@next/swc-win32-arm64-msvc': 15.5.25 '@next/swc-win32-x64-msvc': 15.5.25 '@playwright/test': 1.62.1 - sharp: 0.35.4(@types/node@22.20.1) + sharp: 0.34.4 transitivePeerDependencies: - '@babel/core' - - '@types/node' - babel-plugin-macros node-exports-info@1.6.2: @@ -6805,39 +6771,34 @@ snapshots: es-errors: 1.3.0 es-object-atoms: 1.1.2 - sharp@0.35.4(@types/node@22.20.1): + sharp@0.34.4: dependencies: '@img/colour': 1.1.0 detect-libc: 2.1.2 semver: 7.8.5 optionalDependencies: - '@img/sharp-darwin-arm64': 0.35.4 - '@img/sharp-darwin-x64': 0.35.4 - '@img/sharp-freebsd-wasm32': 0.35.4 - '@img/sharp-libvips-darwin-arm64': 1.3.3 - '@img/sharp-libvips-darwin-x64': 1.3.3 - '@img/sharp-libvips-linux-arm': 1.3.3 - '@img/sharp-libvips-linux-arm64': 1.3.3 - '@img/sharp-libvips-linux-ppc64': 1.3.3 - '@img/sharp-libvips-linux-riscv64': 1.3.3 - '@img/sharp-libvips-linux-s390x': 1.3.3 - '@img/sharp-libvips-linux-x64': 1.3.3 - '@img/sharp-libvips-linuxmusl-arm64': 1.3.3 - '@img/sharp-libvips-linuxmusl-x64': 1.3.3 - '@img/sharp-linux-arm': 0.35.4 - '@img/sharp-linux-arm64': 0.35.4 - '@img/sharp-linux-ppc64': 0.35.4 - '@img/sharp-linux-riscv64': 0.35.4 - '@img/sharp-linux-s390x': 0.35.4 - '@img/sharp-linux-x64': 0.35.4 - '@img/sharp-linuxmusl-arm64': 0.35.4 - '@img/sharp-linuxmusl-x64': 0.35.4 - '@img/sharp-webcontainers-wasm32': 0.35.4 - '@img/sharp-win32-arm64': 0.35.4 - '@img/sharp-win32-ia32': 0.35.4 - '@img/sharp-win32-x64': 0.35.4 - '@types/node': 22.20.1 - optional: true + '@img/sharp-darwin-arm64': 0.34.4 + '@img/sharp-darwin-x64': 0.34.4 + '@img/sharp-libvips-darwin-arm64': 1.2.3 + '@img/sharp-libvips-darwin-x64': 1.2.3 + '@img/sharp-libvips-linux-arm': 1.2.3 + '@img/sharp-libvips-linux-arm64': 1.2.3 + '@img/sharp-libvips-linux-ppc64': 1.2.3 + '@img/sharp-libvips-linux-s390x': 1.2.3 + '@img/sharp-libvips-linux-x64': 1.2.3 + '@img/sharp-libvips-linuxmusl-arm64': 1.2.3 + '@img/sharp-libvips-linuxmusl-x64': 1.2.3 + '@img/sharp-linux-arm': 0.34.4 + '@img/sharp-linux-arm64': 0.34.4 + '@img/sharp-linux-ppc64': 0.34.4 + '@img/sharp-linux-s390x': 0.34.4 + '@img/sharp-linux-x64': 0.34.4 + '@img/sharp-linuxmusl-arm64': 0.34.4 + '@img/sharp-linuxmusl-x64': 0.34.4 + '@img/sharp-wasm32': 0.34.4 + '@img/sharp-win32-arm64': 0.34.4 + '@img/sharp-win32-ia32': 0.34.4 + '@img/sharp-win32-x64': 0.34.4 shebang-command@2.0.0: dependencies: diff --git a/start.sh b/start.sh index 3e060e7..1357e1b 100755 --- a/start.sh +++ b/start.sh @@ -89,6 +89,22 @@ pnpm db:deploy >/dev/null info "Grundzustand einspielen" pnpm db:seed >/dev/null +# --- Dateiablage ------------------------------------------------------------- +# Im Stack ist das ein Volume, hier ein Verzeichnis. Ohne es scheitert der +# erste Upload mit einem Fehler, der nichts erklärt. +FILES_DIR_LOKAL="$(grep -E '^FILES_DIR=' .env 2>/dev/null | cut -d= -f2- || true)" +FILES_DIR_LOKAL="${FILES_DIR_LOKAL:-/tmp/afis-files}" +mkdir -p "${FILES_DIR_LOKAL}/quarantine" "${FILES_DIR_LOKAL}/store" +info "Dateiablage unter ${FILES_DIR_LOKAL}" + +# Die Prüfkette braucht beides. Fehlt eines, bleiben die betroffenen Uploads in +# der Quarantäne — sie gehen nicht ungeprüft durch. Siehe docs/07. +CLAMAV_SOCKET_LOKAL="$(grep -E '^CLAMAV_SOCKET=' .env 2>/dev/null | cut -d= -f2- || true)" +[ -S "${CLAMAV_SOCKET_LOKAL:-/var/run/clamav/clamd.sock}" ] \ + || warn "Kein clamd erreichbar: Uploads landen auf 'Prüfung unvollständig' und brauchen die Freigabe von Hand" +command -v qpdf >/dev/null \ + || warn "qpdf fehlt: PDF-Uploads landen auf 'Prüfung unvollständig' (im Container ist es enthalten)" + if [ "$NUR_DB" = "1" ]; then info "Datenbank läuft auf localhost:${DB_PORT}. Beenden mit: docker stop ${DB_CONTAINER}" exit 0 diff --git a/web/app/(app)/admin/richtlinie/PolicyForm.tsx b/web/app/(app)/admin/richtlinie/PolicyForm.tsx index cda3a21..6162a49 100644 --- a/web/app/(app)/admin/richtlinie/PolicyForm.tsx +++ b/web/app/(app)/admin/richtlinie/PolicyForm.tsx @@ -1,7 +1,8 @@ 'use client' import { useState, useTransition } from 'react' -import { de } from '@/lib/i18n/de' +import { settingsText } from '@/lib/i18n/admin' +import { caseText } from '@/lib/i18n/cases' import { savePolicy } from './actions' const POLICIES = ['APPEND_ONLY', 'GRACE_WINDOW', 'OPEN'] as const @@ -24,7 +25,7 @@ export function PolicyForm({ const [pending, startTransition] = useTransition() function submit() { - if (!confirm(de.settings.confirm)) return + if (!confirm(settingsText.confirm)) return setError(null) startTransition(async () => { const result = await savePolicy({ @@ -40,7 +41,7 @@ export function PolicyForm({ return (
- {de.settings.policy} + {settingsText.policy}
{POLICIES.map((value) => ( ))}
@@ -96,10 +97,10 @@ export function PolicyForm({ disabled={pending} className="border border-line bg-raised px-4 py-2 font-condensed text-xs text-accent disabled:text-text-dim" > - {pending ? de.case.saving : de.settings.save} + {pending ? caseText.saving : settingsText.save} {saved ? ( - {de.settings.saved} + {settingsText.saved} ) : null} {error ? {error} : null}
diff --git a/web/app/(app)/admin/rollen/RoleRow.tsx b/web/app/(app)/admin/rollen/RoleRow.tsx index 9ea301f..065ceff 100644 --- a/web/app/(app)/admin/rollen/RoleRow.tsx +++ b/web/app/(app)/admin/rollen/RoleRow.tsx @@ -1,7 +1,7 @@ 'use client' import { useState, useTransition } from 'react' -import { de } from '@/lib/i18n/de' +import { roleAdminText } from '@/lib/i18n/admin' import { OPT_IN_PERMISSIONS, PERMISSIONS, type Permission } from '@/lib/auth/permissions' import { saveRoleMapping } from './actions' @@ -52,7 +52,7 @@ export function RoleRow({ } function submit() { - if (!confirm(de.roleAdmin.confirm)) return + if (!confirm(roleAdminText.confirm)) return setError(null) startTransition(async () => { const result = await saveRoleMapping({ @@ -77,14 +77,14 @@ export function RoleRow({ {role.discordRoleId} {!role.isPresent ? ( - {de.roleAdmin.missingFromGuild} + {roleAdminText.missingFromGuild} ) : null}
diff --git a/web/app/(app)/admin/vorlagen/TemplateEditor.tsx b/web/app/(app)/admin/vorlagen/TemplateEditor.tsx index 1eb7a6c..85c6c7e 100644 --- a/web/app/(app)/admin/vorlagen/TemplateEditor.tsx +++ b/web/app/(app)/admin/vorlagen/TemplateEditor.tsx @@ -1,7 +1,8 @@ 'use client' import { useMemo, useState, useTransition } from 'react' -import { de } from '@/lib/i18n/de' +import { templateAdminText } from '@/lib/i18n/admin' +import { caseText, sectionText } from '@/lib/i18n/cases' import { isInputField, looksLikeFields, type TemplateField } from '@/lib/templates/fields' import { saveTemplate } from './actions' @@ -41,11 +42,11 @@ export function TemplateEditor({ vorlage }: { vorlage: Vorlage }) { // Nur eine grobe Formprüfung für die Vorschau. Ob die Vorlage // gespeichert werden darf, entscheidet allein der Server. if (!looksLikeFields(roh)) { - return { ok: false as const, fehler: de.templateAdmin.fieldsInvalid } + return { ok: false as const, fehler: templateAdminText.fieldsInvalid } } return { ok: true as const, fields: roh } } catch { - return { ok: false as const, fehler: de.templateAdmin.invalid } + return { ok: false as const, fehler: templateAdminText.invalid } } }, [json]) @@ -62,7 +63,7 @@ export function TemplateEditor({ vorlage }: { vorlage: Vorlage }) { numberPadding, fields: geparst.fields, }) - if (antwort.ok) setResult(de.templateAdmin.saved(antwort.version)) + if (antwort.ok) setResult(templateAdminText.saved(antwort.version)) else setError(antwort.error) }) } @@ -73,14 +74,14 @@ export function TemplateEditor({ vorlage }: { vorlage: Vorlage }) {

{vorlage.name}

{vorlage.key} - {de.templateAdmin.version(vorlage.version)} + {templateAdminText.version(vorlage.version)}