Merge pull request 'fix(scanner): accept Epson empty write probes' (#234) from dev into main
All checks were successful
Build and Release FEDEO / verify (push) Successful in 58s
Build and Release FEDEO / build-and-release (push) Successful in 6m8s

Reviewed-on: #234
This commit was merged in pull request #234.
This commit is contained in:
2026-10-01 15:36:56 +00:00
4 changed files with 80 additions and 13 deletions

View File

@@ -27,10 +27,14 @@ const objectStorage: ScannerStorage = {
}, },
} }
export function validateScannerFile(filename: string, body: Buffer) { function validateScannerFilename(filename: string) {
if (!filename || filename.length > 200 || /[\\/\u0000-\u001f\u007f]/.test(filename) || filename.startsWith(".")) { if (!filename || filename.length > 200 || /[\\/\u0000-\u001f\u007f]/.test(filename) || filename.startsWith(".")) {
throw scannerError(400, "Bitte einen einzelnen Dateinamen ohne Unterordner verwenden.") throw scannerError(400, "Bitte einen einzelnen Dateinamen ohne Unterordner verwenden.")
} }
}
export function validateScannerFile(filename: string, body: Buffer) {
validateScannerFilename(filename)
if (!body.length) throw scannerError(400, "Die Datei ist leer.") if (!body.length) throw scannerError(400, "Die Datei ist leer.")
if (body.length > SCANNER_UPLOAD_LIMIT) throw scannerError(413, "Die Datei überschreitet 100 MB.") if (body.length > SCANNER_UPLOAD_LIMIT) throw scannerError(413, "Die Datei überschreitet 100 MB.")
const extension = filename.split(".").pop()?.toLowerCase() const extension = filename.split(".").pop()?.toLowerCase()
@@ -135,21 +139,38 @@ export function createScannerInboxService(db: FastifyInstance["db"], storage: Sc
.where(and(eq(scannerInboxDocuments.fileId, fileId), eq(scannerInboxDocuments.tenantId, tenantId))) .where(and(eq(scannerInboxDocuments.fileId, fileId), eq(scannerInboxDocuments.tenantId, tenantId)))
return { id: fileId, folder: folderId, type: typeId } return { id: fileId, folder: folderId, type: typeId }
}) })
const assertWritable = async (tx: Pick<FastifyInstance["db"], "select">, identity: ScannerIdentity) => {
// Hold these locks until the write or write probe has finished.
const [tenant] = await tx.select({ locked: tenants.locked }).from(tenants)
.where(eq(tenants.id, identity.tenantId)).for("share")
if (!tenant) throw scannerError(401, "Scanner-Zugang ungültig.")
if (tenant.locked === "maintenance_tenant") throw scannerError(423, "Der Mandant wird gerade gewartet.")
const [active] = await tx.select({ tenantId: scannerInboxConnections.tenantId }).from(scannerInboxConnections)
.where(and(eq(scannerInboxConnections.tenantId, identity.tenantId), eq(scannerInboxConnections.username, identity.username),
eq(scannerInboxConnections.passwordHash, identity.passwordHash), eq(scannerInboxConnections.active, true))).for("share")
if (!active) throw scannerError(401, "Scanner-Zugang ungültig.")
}
const probe = async (identity: ScannerIdentity, filename: string, onlyIfNew = false) => {
validateScannerFilename(filename)
if (!/\.(pdf|jpe?g|png|tiff?)$/i.test(filename)) {
throw scannerError(415, "Bitte eine vollständige PDF-, JPEG-, PNG- oder TIFF-Datei übertragen.")
}
await db.transaction(async tx => {
await assertWritable(tx, identity)
await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtextextended(${`${identity.tenantId}:${filename}`}, 0))`)
const [existing] = await tx.select({ fileId: scannerInboxDocuments.fileId }).from(scannerInboxDocuments)
.where(and(eq(scannerInboxDocuments.tenantId, identity.tenantId), eq(scannerInboxDocuments.remoteFilename, filename))).limit(1)
if (existing) throw scannerError(onlyIfNew ? 412 : 409, "Der Dateiname ist bereits vorhanden.")
// Epson probes writability with an empty PUT. No file, receipt or reservation is stored.
})
}
const accept = async (identity: ScannerIdentity, filename: string, body: Buffer, onlyIfNew = false) => { const accept = async (identity: ScannerIdentity, filename: string, body: Buffer, onlyIfNew = false) => {
const contentType = validateScannerFile(filename, body) const contentType = validateScannerFile(filename, body)
const checksum = digest(body) const checksum = digest(body)
let uploadedKey: string | undefined let uploadedKey: string | undefined
try { try {
return await db.transaction(async tx => { return await db.transaction(async tx => {
// Coordinate with tenant maintenance and credential revocation during the upload. await assertWritable(tx, identity)
const [tenant] = await tx.select({ locked: tenants.locked }).from(tenants)
.where(eq(tenants.id, identity.tenantId)).for("share")
if (!tenant) throw scannerError(401, "Scanner-Zugang ungültig.")
if (tenant.locked === "maintenance_tenant") throw scannerError(423, "Der Mandant wird gerade gewartet.")
const [active] = await tx.select({ tenantId: scannerInboxConnections.tenantId }).from(scannerInboxConnections)
.where(and(eq(scannerInboxConnections.tenantId, identity.tenantId), eq(scannerInboxConnections.username, identity.username),
eq(scannerInboxConnections.passwordHash, identity.passwordHash), eq(scannerInboxConnections.active, true))).for("share")
if (!active) throw scannerError(401, "Scanner-Zugang ungültig.")
// Serialise retries across processes; a lost HTTP response must not duplicate a document. // Serialise retries across processes; a lost HTTP response must not duplicate a document.
await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtextextended(${`${identity.tenantId}:${filename}`}, 0))`) await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtextextended(${`${identity.tenantId}:${filename}`}, 0))`)
@@ -178,7 +199,7 @@ export function createScannerInboxService(db: FastifyInstance["db"], storage: Sc
throw error throw error
} }
} }
return { connection, credentials, disable, authenticate, find, list, filingOptions, fileDocument, accept } return { connection, credentials, disable, authenticate, find, list, filingOptions, fileDocument, probe, accept }
} }
export type ScannerInboxService = ReturnType<typeof createScannerInboxService> export type ScannerInboxService = ReturnType<typeof createScannerInboxService>

View File

@@ -45,6 +45,10 @@ export default async function scannerInboxGateway(server: FastifyInstance, optio
throw scannerError(412, "Änderungen an vorhandenen Dokumenten werden nicht unterstützt.") throw scannerError(412, "Änderungen an vorhandenen Dokumenten werden nicht unterstützt.")
} }
const body = Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0) const body = Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0)
if (!body.length && req.headers["content-length"] === "0") {
await service.probe(identity, filename, req.headers["if-none-match"] === "*")
return reply.code(201).send()
}
const result = await service.accept(identity, filename, body, req.headers["if-none-match"] === "*") const result = await service.accept(identity, filename, body, req.headers["if-none-match"] === "*")
return reply.header("ETag", `"${result.checksum}"`).code(result.created ? 201 : 204).send() return reply.header("ETag", `"${result.checksum}"`).code(result.created ? 201 : 204).send()
} }

View File

@@ -38,16 +38,18 @@ test("scanner formats are verified independently of the supplied content type",
const protocolFixture = async () => { const protocolFixture = async () => {
const calls: any[] = [] const calls: any[] = []
const probes: any[] = []
const service = { const service = {
authenticate: async (username: string, password: string) => username === "scanner" && password === "secret" ? { tenantId: 7 } : null, authenticate: async (username: string, password: string) => username === "scanner" && password === "secret" ? { tenantId: 7 } : null,
accept: async (...args: any[]) => { calls.push(args); validateScannerFile(args[1], args[2]); return { created: true, checksum: "abc" } }, accept: async (...args: any[]) => { calls.push(args); validateScannerFile(args[1], args[2]); return { created: true, checksum: "abc" } },
probe: async (...args: any[]) => { probes.push(args) },
find: async () => null, find: async () => null,
} as unknown as ScannerInboxService } as unknown as ScannerInboxService
const app = Fastify() const app = Fastify()
await app.register(gateway, { prefix: "/scanner-inbox", service }) await app.register(gateway, { prefix: "/scanner-inbox", service })
// Match production: the global CORS plugin is registered after the scanner gateway. // Match production: the global CORS plugin is registered after the scanner gateway.
await app.register(corsPlugin) await app.register(corsPlugin)
return { app, calls } return { app, calls, probes }
} }
test("WebDAV authentication happens before body parsing, including discovery requests", async t => { test("WebDAV authentication happens before body parsing, including discovery requests", async t => {
@@ -118,6 +120,23 @@ test("Epson's extra separator does not turn filename checks into false collision
assert.equal(calls.length, 2) assert.equal(calls.length, 2)
}) })
test("Epson's empty PUT is a write probe, followed by the actual document upload", async t => {
const { app, calls, probes } = await protocolFixture()
t.after(() => app.close())
const url = "/scanner-inbox//scan.pdf"
const probe = await app.inject({ method: "PUT", url, headers: { ...basic(), "content-length": "0" } })
assert.equal(probe.statusCode, 201, probe.body)
assert.equal(calls.length, 0)
assert.equal(probes.length, 1)
assert.equal(probes[0][0].tenantId, 7)
assert.equal(probes[0][1], "scan.pdf")
const upload = await app.inject({ method: "PUT", url, headers: basic(), payload: samplePdf })
assert.equal(upload.statusCode, 201)
assert.equal(calls.length, 1)
assert.deepEqual(calls[0][2], samplePdf)
assert.equal(probes.length, 1)
})
test("raw PDFs reach storage unchanged and scanner credentials cannot download or delete", async t => { test("raw PDFs reach storage unchanged and scanner credentials cannot download or delete", async t => {
const { app, calls } = await protocolFixture() const { app, calls } = await protocolFixture()
t.after(() => app.close()) t.after(() => app.close())
@@ -214,6 +233,20 @@ test("scanner inbox integration: migration, persistence, isolation, retries and
doc.addPage() doc.addPage()
const pdf = Buffer.from(await doc.save()) const pdf = Buffer.from(await doc.save())
const send = (name: string, payload = pdf, headers: Record<string, string> = basic(first.username, first.password)) => app.inject({ method: "PUT", url: `/scanner-inbox/${encodeURIComponent(name)}`, headers: { "content-type": "application/pdf", ...headers }, payload }) const send = (name: string, payload = pdf, headers: Record<string, string> = basic(first.username, first.password)) => app.inject({ method: "PUT", url: `/scanner-inbox/${encodeURIComponent(name)}`, headers: { "content-type": "application/pdf", ...headers }, payload })
const probe = (name: string, headers: Record<string, string> = basic(first.username, first.password)) =>
app.inject({ method: "PUT", url: `/scanner-inbox/${encodeURIComponent(name)}`, headers: { "content-length": "0", ...headers } })
await t.test("empty write probes do not create documents, receipts or storage objects", async () => {
for (let attempt = 0; attempt < 2; attempt++) assert.equal((await probe("scan.pdf")).statusCode, 201)
assert.equal((await probe("scan.pdf", basic(second.username, second.password))).statusCode, 201)
assert.equal((await probe("invalid.zip")).statusCode, 415)
assert.equal((await probe("sub/scan.pdf")).statusCode, 400)
assert.equal((await probe("unauthenticated.pdf", {})).statusCode, 401)
assert.equal(stored.size, 0)
assert.equal((await pool!.query("SELECT count(*) FROM files")).rows[0].count, "0")
assert.equal((await pool!.query("SELECT count(*) FROM scanner_inbox_documents")).rows[0].count, "0")
assert.equal((await app.inject({ method: "HEAD", url: "/scanner-inbox/scan.pdf", headers: basic(first.username, first.password) })).statusCode, 404)
})
await t.test("stores one unchanged two-page PDF as one document without extraction", async () => { await t.test("stores one unchanged two-page PDF as one document without extraction", async () => {
const res = await send("scan.pdf") const res = await send("scan.pdf")
@@ -236,6 +269,10 @@ test("scanner inbox integration: migration, persistence, isolation, retries and
assert.equal(download.statusCode, 405) assert.equal(download.statusCode, 405)
assert.ok(!(download.headers.allow as string).split(/,\s*/).includes("GET")) assert.ok(!(download.headers.allow as string).split(/,\s*/).includes("GET"))
assert.notDeepEqual(download.rawPayload, pdf) assert.notDeepEqual(download.rawPayload, pdf)
assert.equal((await probe("scan.pdf")).statusCode, 409)
assert.equal((await probe("scan.pdf", { ...basic(first.username, first.password), "if-none-match": "*" })).statusCode, 412)
assert.deepEqual(stored.get(row.path), pdf)
assert.equal(stored.size, 1)
}) })
await t.test("serialises simultaneous retries and preserves existing content on filename conflicts", async () => { 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")]) const retry = await Promise.all([send("scan.pdf"), send("scan.pdf")])
@@ -350,6 +387,7 @@ test("scanner inbox integration: migration, persistence, isolation, retries and
await t.test("maintenance, portal access and administrator-only settings are enforced", async () => { await t.test("maintenance, portal access and administrator-only settings are enforced", async () => {
await pool.query("UPDATE tenants SET locked='maintenance_tenant' WHERE id=1") await pool.query("UPDATE tenants SET locked='maintenance_tenant' WHERE id=1")
assert.equal((await send("maintenance.pdf")).statusCode, 423) assert.equal((await send("maintenance.pdf")).statusCode, 423)
assert.equal((await probe("maintenance.pdf")).statusCode, 423)
await pool.query("UPDATE tenants SET locked=null WHERE id=1") await pool.query("UPDATE tenants SET locked=null WHERE id=1")
assert.equal((await app.inject({ method: "GET", url: "/api/document-inbox", headers: { "x-test-tenant": "0" } })).statusCode, 400) assert.equal((await app.inject({ method: "GET", url: "/api/document-inbox", headers: { "x-test-tenant": "0" } })).statusCode, 400)
for (const [method, url] of [["GET", "/api/document-inbox/scanner"], ["POST", "/api/document-inbox/scanner/credentials"], ["DELETE", "/api/document-inbox/scanner"]] as const) { for (const [method, url] of [["GET", "/api/document-inbox/scanner"], ["POST", "/api/document-inbox/scanner/credentials"], ["DELETE", "/api/document-inbox/scanner"]] as const) {
@@ -364,12 +402,14 @@ test("scanner inbox integration: migration, persistence, isolation, retries and
const oldIdentity = await service.authenticate(first.username, first.password) const oldIdentity = await service.authenticate(first.username, first.password)
assert.equal((await app.inject({ method: "DELETE", url: "/api/document-inbox/scanner" })).statusCode, 204) assert.equal((await app.inject({ method: "DELETE", url: "/api/document-inbox/scanner" })).statusCode, 204)
assert.equal((await send("disabled.pdf")).statusCode, 401) assert.equal((await send("disabled.pdf")).statusCode, 401)
assert.equal((await probe("disabled.pdf")).statusCode, 401)
const rotated = await app.inject({ method: "POST", url: "/api/document-inbox/scanner/credentials" }) const rotated = await app.inject({ method: "POST", url: "/api/document-inbox/scanner/credentials" })
assert.equal(rotated.statusCode, 200) assert.equal(rotated.statusCode, 200)
assert.equal(rotated.headers["cache-control"], "no-store") assert.equal(rotated.headers["cache-control"], "no-store")
assert.equal(rotated.json().connection.username, first.username) assert.equal(rotated.json().connection.username, first.username)
assert.equal((await send("old.pdf")).statusCode, 401) assert.equal((await send("old.pdf")).statusCode, 401)
await assert.rejects(service.accept(oldIdentity!, "late.pdf", pdf), { statusCode: 401 }) await assert.rejects(service.accept(oldIdentity!, "late.pdf", pdf), { statusCode: 401 })
await assert.rejects(service.probe(oldIdentity!, "late.pdf"), { statusCode: 401 })
assert.equal((await send("new.pdf", pdf, basic(first.username, rotated.json().connection.password))).statusCode, 201) assert.equal((await send("new.pdf", pdf, basic(first.username, rotated.json().connection.password))).statusCode, 201)
assert.equal((await service.list(1, 50, 0, "")).total, 2) assert.equal((await service.list(1, 50, 0, "")).total, 2)
}) })

View File

@@ -24,6 +24,8 @@ Die vollständige Adresse wird aus der konfigurierten Backend-Adresse gebildet,
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. Danach prüft Epson den Dateinamen per `HEAD`. Bei einer Zieladresse mit abschließendem Schrägstrich hängt das Gerät einen weiteren Trenner an, beispielsweise `/scanner-inbox//scan.pdf`. Der Endpunkt behandelt diese Adresse wie `/scanner-inbox/scan.pdf`; echte Unterordner und ungültige Dateinamen bleiben gesperrt. Ein fälschlich mit `400` beantworteter HEAD-Test führte am Gerät zur Meldung „Dateiname bereits verwendet“. Einen vollständigen Scan bis zum Eintrag in der Dokumentenablage bei der Inbetriebnahme prüfen. 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. Danach prüft Epson den Dateinamen per `HEAD`. Bei einer Zieladresse mit abschließendem Schrägstrich hängt das Gerät einen weiteren Trenner an, beispielsweise `/scanner-inbox//scan.pdf`. Der Endpunkt behandelt diese Adresse wie `/scanner-inbox/scan.pdf`; echte Unterordner und ungültige Dateinamen bleiben gesperrt. Ein fälschlich mit `400` beantworteter HEAD-Test führte am Gerät zur Meldung „Dateiname bereits verwendet“. Einen vollständigen Scan bis zum Eintrag in der Dokumentenablage bei der Inbetriebnahme prüfen.
Nach der Dateinamenprüfung sendet der ES-580W zunächst einen leeren `PUT` mit `Content-Length: 0`. Dieser wird als Schreibprobe mit `201` bestätigt, sofern Zugang, Mandant und Dateiname zulässig sind. Dabei entstehen weder eine Datei noch ein Ablageeintrag oder eine dauerhafte Namensreservierung. Erst ein anschließender vollständiger Dokument-Upload wird gespeichert. Vorhandene Dateinamen werden auch bei einer leeren Probe mit `409` bzw. bei `If-None-Match: *` mit `412` geschützt.
## Schnittstelle und Betrieb ## 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. - 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.
@@ -31,7 +33,7 @@ Grundlage sind die Epson-Handbücher für [ES-580W](https://download.epson.de/pu
- Unterstützt werden `OPTIONS`, `PROPFIND` (Depth 0/1), `HEAD` und `PUT /scanner-inbox/<Dateiname>`. 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. - Unterstützt werden `OPTIONS`, `PROPFIND` (Depth 0/1), `HEAD` und `PUT /scanner-inbox/<Dateiname>`. 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. - `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. - 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. - Fehlende/ungültige/deaktivierte Zugangsdaten: `401`; ungültiger Pfad oder leerer Upload ohne explizite Schreibprobe (`Content-Length: 0`): `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.
- Der Reverse Proxy muss die genannten HTTP-Methoden, den Authorization-Header und Uploads bis 100 MiB zulassen. Bei einem abgeschnittenen Pfadpräfix sollte er `X-Forwarded-Prefix` setzen (Traefik StripPrefix erledigt dies). HTTPS endet üblicherweise am bestehenden Proxy. - Der Reverse Proxy muss die genannten HTTP-Methoden, den Authorization-Header und Uploads bis 100 MiB zulassen. Bei einem abgeschnittenen Pfadpräfix sollte er `X-Forwarded-Prefix` setzen (Traefik StripPrefix erledigt dies). HTTPS endet üblicherweise am bestehenden Proxy.
- Die Browser-API unter `/api/document-inbox` erfordert die normale FEDEO-Anmeldung und einen aktiven Mandanten. Portalbenutzer sind ausgeschlossen. Scanner-Zugänge verwalten nur Administratoren. Die Originale sind außerdem über die bestehende Dateiverwaltung zugänglich. - Die Browser-API unter `/api/document-inbox` erfordert die normale FEDEO-Anmeldung und einen aktiven Mandanten. Portalbenutzer sind ausgeschlossen. Scanner-Zugänge verwalten nur Administratoren. Die Originale sind außerdem über die bestehende Dateiverwaltung zugänglich.
- `GET /api/document-inbox/filing-options` liefert verfügbare Ordner und Typen. `POST /api/document-inbox/:id/file` mit JSON `{ "folder": "<Ordner-UUID oder null>", "type": "<Dateityp-UUID>" }` klassifiziert das Original und markiert den Eingang atomar als abgelegt. Für die Hauptebene ist `folder` als JSON-`null` zu senden. Ein bereits abgelegtes Dokument ergibt `409`; zwischenzeitlich archivierte oder fremde Ziele werden abgewiesen. Dateiänderungen enthalten Zeitpunkt und Benutzer. - `GET /api/document-inbox/filing-options` liefert verfügbare Ordner und Typen. `POST /api/document-inbox/:id/file` mit JSON `{ "folder": "<Ordner-UUID oder null>", "type": "<Dateityp-UUID>" }` klassifiziert das Original und markiert den Eingang atomar als abgelegt. Für die Hauptebene ist `folder` als JSON-`null` zu senden. Ein bereits abgelegtes Dokument ergibt `409`; zwischenzeitlich archivierte oder fremde Ziele werden abgewiesen. Dateiänderungen enthalten Zeitpunkt und Benutzer.