From b585b972f2091b29ba765838b0c2e1c2071b3f22 Mon Sep 17 00:00:00 2001 From: flfeders Date: Wed, 30 Sep 2026 19:04:12 +0200 Subject: [PATCH] fix(scanner): accept Epson inbox GET preflight --- backend/src/routes/scannerInboxGateway.ts | 10 +++++--- backend/tests/scannerInbox.test.ts | 29 +++++++++++++++++++++-- docs/scanner-dokumentenablage.md | 9 ++++--- 3 files changed, 40 insertions(+), 8 deletions(-) diff --git a/backend/src/routes/scannerInboxGateway.ts b/backend/src/routes/scannerInboxGateway.ts index 00bd4da0..f9c2235f 100644 --- a/backend/src/routes/scannerInboxGateway.ts +++ b/backend/src/routes/scannerInboxGateway.ts @@ -30,8 +30,12 @@ export default async function scannerInboxGateway(server: FastifyInstance, optio if (filename && (filename.length > 200 || filename.startsWith(".") || /[\\/\u0000-\u001f\u007f]/.test(filename))) { throw scannerError(400, "Unterordner und ungültige Dateinamen werden nicht unterstützt.") } - if (req.method === "OPTIONS") return reply.header("DAV", "1").header("Allow", methods).code(200).send() - if (req.method === "MKCOL") return reply.header("Allow", methods).code(filename ? 403 : 405).send() + const allowedMethods = filename ? methods : `${methods}, GET` + if (req.method === "OPTIONS") return reply.header("DAV", "1").header("Allow", allowedMethods).code(200).send() + if (req.method === "MKCOL") return reply.header("Allow", allowedMethods).code(filename ? 403 : 405).send() + // ES-580W checks authentication with GET before WebDAV discovery or uploading. + // Confirm only the collection itself; never return a listing or document content. + if (req.method === "GET" && !filename) return reply.header("DAV", "1").code(200).send() if (req.method === "PUT") { if (!filename) throw scannerError(400, "Ein Dateiname ist erforderlich.") if (req.headers["content-range"]) throw scannerError(400, "Bitte die vollständige Datei übertragen.") @@ -62,7 +66,7 @@ export default async function scannerInboxGateway(server: FastifyInstance, optio return reply.header("DAV", "1").type("application/xml; charset=utf-8").code(207).send( `${escapeXml(href)}${escapeXml(filename || "FEDEO Dokumentenablage")}${props}HTTP/1.1 200 OK`) } - return reply.header("Allow", methods).code(405).send() + return reply.header("Allow", allowedMethods).code(405).send() } // Both slash forms work without redirects (some scanners discard authentication on redirects). diff --git a/backend/tests/scannerInbox.test.ts b/backend/tests/scannerInbox.test.ts index 4a6254b8..96fda3c0 100644 --- a/backend/tests/scannerInbox.test.ts +++ b/backend/tests/scannerInbox.test.ts @@ -53,7 +53,7 @@ const protocolFixture = async () => { test("WebDAV authentication happens before body parsing, including discovery requests", async t => { const { app } = await protocolFixture() t.after(() => app.close()) - for (const method of ["OPTIONS", "PROPFIND", "PUT", "HEAD"] as const) { + for (const method of ["OPTIONS", "PROPFIND", "PUT", "HEAD", "GET"] as const) { const res = await app.inject({ method, url: "/scanner-inbox/scan.pdf" }) assert.equal(res.statusCode, 401) assert.match(res.headers["www-authenticate"] as string, /^Basic /) @@ -62,6 +62,27 @@ test("WebDAV authentication happens before body parsing, including discovery req assert.equal(res.statusCode, 401) }) +test("Epson can authenticate with a GET probe of the inbox without listing documents", async t => { + const { app, calls } = await protocolFixture() + t.after(() => app.close()) + for (const url of ["/scanner-inbox", "/scanner-inbox/"]) { + const challenge = await app.inject({ method: "GET", url }) + assert.equal(challenge.statusCode, 401) + assert.match(challenge.headers["www-authenticate"] as string, /^Basic /) + assert.equal((await app.inject({ method: "GET", url, headers: basic("scanner", "wrong") })).statusCode, 401) + const probe = await app.inject({ method: "GET", url, headers: basic() }) + assert.equal(probe.statusCode, 200) + assert.equal(probe.body, "") + assert.equal(probe.headers.dav, "1") + assert.equal(probe.headers["cache-control"], "no-store") + assert.equal(probe.headers.location, undefined) + const options = await app.inject({ method: "OPTIONS", url, headers: basic() }) + assert.ok((options.headers.allow as string).split(/,\s*/).includes("GET")) + } + assert.equal(calls.length, 0) + assert.equal((await app.inject({ method: "GET", url: "/scanner-inbox/missing.pdf", headers: basic() })).statusCode, 404) +}) + test("Epson discovery works with both slash forms, XML bodies and a backend proxy prefix", async t => { const { app } = await protocolFixture() t.after(() => app.close()) @@ -90,7 +111,7 @@ test("raw PDFs reach storage unchanged and scanner credentials cannot download o assert.equal(calls.at(-1)[1], "Rechnung & Beleg.pdf") assert.deepEqual(calls.at(-1)[2], samplePdf) } - for (const method of ["GET", "DELETE", "POST", "PATCH"] as const) { + for (const method of ["DELETE", "POST", "PATCH"] as const) { assert.equal((await app.inject({ method, url: "/scanner-inbox/", headers: basic() })).statusCode, 405) } for (const name of ["sub/scan.pdf", "sub%2Fscan.pdf", "..%5Cscan.pdf"]) { @@ -190,6 +211,10 @@ test("scanner inbox integration: migration, persistence, isolation, retries and assert.equal(listing.json().total, 1) assert.equal(listing.json().documents[0].name, "scan.pdf") assert.equal((await app.inject({ method: "HEAD", url: "/scanner-inbox/scan.pdf", headers: basic(first.username, first.password) })).statusCode, 200) + const download = await app.inject({ method: "GET", url: "/scanner-inbox/scan.pdf", headers: basic(first.username, first.password) }) + assert.equal(download.statusCode, 405) + assert.ok(!(download.headers.allow as string).split(/,\s*/).includes("GET")) + assert.notDeepEqual(download.rawPayload, pdf) }) await t.test("serialises simultaneous retries and preserves existing content on filename conflicts", async () => { const retry = await Promise.all([send("scan.pdf"), send("scan.pdf")]) diff --git a/docs/scanner-dokumentenablage.md b/docs/scanner-dokumentenablage.md index 8916bb63..c1afab9b 100644 --- a/docs/scanner-dokumentenablage.md +++ b/docs/scanner-dokumentenablage.md @@ -18,15 +18,17 @@ Nur aktive Ordner und Typen desselben Mandanten sind zulässig. Bei einem Fehler 3. Am Epson ES-580W oder ES-C380W **Netzwerkordner/FTP → WebDAV (HTTPS)** wählen. In Web Config kann der Zieltyp **SharePoint (WebDAV)** heißen; dort bei separater Auswahl von HTTPS im Feld **Speichern unter** den Adressanteil ohne `https://` eintragen. Zieladresse und Zugangsdaten eintragen, Port 443 bzw. den Port der eigenen HTTPS-Adresse verwenden. Das Serverzertifikat muss vom Scanner akzeptiert werden. 4. PDF, Duplex und beispielsweise 300 dpi einstellen. Das Ziel als **FEDEO** speichern. Dateinamen mit Datum/Uhrzeit und laufender Nummer verwenden. Für getrennte Dokumente jeweils eine eigene Datei übertragen; Vorder- und Rückseite können in einer PDF bleiben. +Beim ES-580W nach einem Neustart eine gegebenenfalls angezeigte Freigabe der Netzwerkfunktionen bestätigen. Solange diese gesperrt sind, kann der Scanner vor dem Papiereinzug einen Kommunikationsfehler melden, ohne das Ziel anzusprechen; seine Weboberfläche kann trotzdem erreichbar sein. + Die vollständige Adresse wird aus der konfigurierten Backend-Adresse gebildet, z. B. `https://fedeo.example/backend/scanner-inbox/`. Ein vorhandener `/backend`-Präfix muss erhalten bleiben. Es ist kein lokaler Rechner oder Geräte-Agent erforderlich. -Grundlage sind die Epson-Handbücher für [ES-580W](https://download.epson.de/pub/download/6371/epson637123eu.pdf) und [ES-C380W](https://download4.epson.biz/sec_pubs/es-c380w/useg/en/GUID-55C5993A-63B2-45C9-B3C9-9C6D6801CDF0.htm). Der tatsächliche Verbindungsablauf muss bei der Inbetriebnahme am Gerät geprüft werden; es wurde kein physischer Epson-Scanner getestet. +Grundlage sind die Epson-Handbücher für [ES-580W](https://download.epson.de/pub/download/6371/epson637123eu.pdf) und [ES-C380W](https://download4.epson.biz/sec_pubs/es-c380w/useg/en/GUID-55C5993A-63B2-45C9-B3C9-9C6D6801CDF0.htm). Am physischen ES-580W wurde eine anfängliche `GET`-Prüfung des Empfangsordners beobachtet: Auf die `401`-Anmeldeaufforderung folgt ein authentifiziertes `GET`. Dieses muss erfolgreich beantwortet werden. Einen vollständigen Scan bis zum Eintrag in der Dokumentenablage bei der Inbetriebnahme prüfen. ## Schnittstelle und Betrieb - Datenbankmigrationen `0073_scanner_document_inbox` und `0074_scanner_inbox_filing` werden über die reguläre Drizzle-Migration eingespielt. Keine neuen Umgebungsvariablen oder zusätzlichen Dienste sind erforderlich; der vorhandene S3-kompatible Dateispeicher wird genutzt. - Der Scanner-Endpunkt liegt außerhalb der Browser-JWT-Anmeldung unter `/scanner-inbox/` und verwendet HTTP Basic mit eigenen zufälligen Zugangsdaten. Ausschließlich über HTTPS veröffentlichen. Passwörter werden nur als SHA-256-Hash gespeichert. -- Unterstützt werden `OPTIONS`, `PROPFIND` (Depth 0/1), `HEAD` und `PUT /scanner-inbox/`. Der Empfangsordner existiert bereits; Unterordner, Lesen, Löschen, Umbenennen und Überschreiben über den Scanner-Zugang sind nicht vorgesehen. Dies ist ein begrenzter WebDAV-Upload-Eingang, kein allgemeines Netzlaufwerk. Ein PROPFIND des Ordners gibt keine Dokumentenliste preis. +- Unterstützt werden `OPTIONS`, `PROPFIND` (Depth 0/1), `HEAD` und `PUT /scanner-inbox/`. Ein authentifiziertes `GET` auf den Empfangsordner (mit oder ohne abschließenden Schrägstrich) liefert für die Epson-Vorprüfung `200` mit leerem Inhalt. Dokumente lassen sich damit nicht herunterladen. Der Empfangsordner existiert bereits; Unterordner, Lesen von Dateien, Löschen, Umbenennen und Überschreiben über den Scanner-Zugang sind nicht vorgesehen. Dies ist ein begrenzter WebDAV-Upload-Eingang, kein allgemeines Netzlaufwerk. Ein PROPFIND des Ordners gibt keine Dokumentenliste preis. - `PUT` enthält genau eine vollständige Datei als Rohdaten, ohne Multipart-Formular. PDF, JPEG, PNG und TIFF werden anhand von Dateiendung und Signatur geprüft, maximal 100 MiB. Keine Prüfung der gesamten Dokumentstruktur auf Beschädigungen. - Erfolgreiches Speichern: `201`. Identische Wiederholung unter demselben Dateinamen: `204`, ohne zweiten Ablageeintrag. Anderer Inhalt unter demselben Namen: `409`; mit `If-None-Match: *` bei vorhandenem Namen: `412`. Gleicher Inhalt unter einem neuen Namen wird als weiteres Dokument akzeptiert. - Fehlende/ungültige/deaktivierte Zugangsdaten: `401`; leerer Upload/ungültiger Pfad: `400`; zu groß: `413`; falsches Format: `415`; Mandantenwartung: `423`. Bei Speicherfehlern gibt es keine Erfolgsbestätigung und keinen unvollständigen Ablageeintrag. Der Scanner kann erneut senden. @@ -39,11 +41,12 @@ Grundlage sind die Epson-Handbücher für [ES-580W](https://download.epson.de/pu Mit Testzugangsdaten und einer Test-PDF gegen eine eigene Instanz: ```bash +curl --user "$SCANNER_USER:$SCANNER_PASSWORD" --include "$SCANNER_URL" curl --user "$SCANNER_USER:$SCANNER_PASSWORD" -X PROPFIND -H 'Depth: 0' "$SCANNER_URL" curl --user "$SCANNER_USER:$SCANNER_PASSWORD" -H 'Content-Type: application/pdf' --upload-file test.pdf "${SCANNER_URL}test.pdf" ``` -Die Antwort ist zunächst `201`; eine identische Wiederholung ergibt `204`. Anschließend steht genau ein Dokument in der Ablage. Die automatisierten Integrationsprüfungen verwenden eine isolierte PostgreSQL-Datenbank und einen ersetzten Objektspeicher, niemals produktive Zugangsdaten: +Die Vorprüfungen liefern `200` (GET, leerer Inhalt) und `207` (PROPFIND). Der erste Upload liefert `201`; eine identische Wiederholung ergibt `204`. Anschließend steht genau ein Dokument in der Ablage. Die automatisierten Integrationsprüfungen verwenden eine isolierte PostgreSQL-Datenbank und einen ersetzten Objektspeicher, niemals produktive Zugangsdaten: ```bash cd backend