Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0a74e07288 | |||
| f359bfb579 |
@@ -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>
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user