import type { FastifyInstance, FastifyRequest } from "fastify" import { createScannerInboxService, SCANNER_UPLOAD_LIMIT, scannerError, type ScannerIdentity, type ScannerInboxService } from "../modules/scanner-inbox.service" const escapeXml = (text: string) => text.replace(/[<>&"']/g, char => ({ "<": "<", ">": ">", "&": "&", '"': """, "'": "'" }[char]!)) const methods = "OPTIONS, PROPFIND, HEAD, PUT" export default async function scannerInboxGateway(server: FastifyInstance, options: { service?: ScannerInboxService }) { const service = options.service || createScannerInboxService(server.db) const identities = new WeakMap() server.addHttpMethod("PROPFIND", { hasBody: true }) server.addHttpMethod("MKCOL", { hasBody: true }) // Keep raw scanner bytes, even when firmware supplies application/octet-stream or no MIME type. server.removeAllContentTypeParsers() server.addContentTypeParser("*", { parseAs: "buffer", bodyLimit: SCANNER_UPLOAD_LIMIT }, (_req, body, done) => done(null, body)) server.addHook("onRequest", async (req, reply) => { reply.header("Cache-Control", "no-store") const encoded = /^Basic ([A-Za-z0-9+/]+={0,2})$/i.exec(req.headers.authorization || "")?.[1] const value = encoded && encoded.length < 1024 ? Buffer.from(encoded, "base64").toString("utf8") : "" const separator = value.indexOf(":") const identity = separator > 0 ? await service.authenticate(value.slice(0, separator), value.slice(separator + 1)) : null if (!identity) return reply.header("WWW-Authenticate", 'Basic realm="FEDEO Scanner", charset="UTF-8"') .code(401).send({ error: "Scanner-Zugangsdaten erforderlich." }) identities.set(req, identity) }) const handle = async (req: FastifyRequest, reply: any) => { const identity = identities.get(req)! // Epson appends its own separator even when the configured inbox URL ends in '/'. // Remove only extra separators at the collection boundary, not within filenames. const filename = ((req.params as { "*"?: string })["*"] || "").replace(/^\/+/, "") if (filename && (filename.length > 200 || filename.startsWith(".") || /[\\/\u0000-\u001f\u007f]/.test(filename))) { throw scannerError(400, "Unterordner und ungültige Dateinamen werden nicht unterstützt.") } 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.") if (req.headers["if-match"] || req.headers["if-unmodified-since"] || req.headers.if) { throw scannerError(412, "Änderungen an vorhandenen Dokumenten werden nicht unterstützt.") } 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"] === "*") return reply.header("ETag", `"${result.checksum}"`).code(result.created ? 201 : 204).send() } const entry = filename ? await service.find(identity.tenantId, filename) : null if (filename && !entry) return reply.code(404).send() if (req.method === "HEAD") { if (entry) reply.header("Content-Length", entry.size).header("Content-Type", entry.mimeType).header("ETag", `"${entry.checksum}"`) return reply.code(200).send() } if (req.method === "PROPFIND") { // This is an upload inbox, not a browsable archive. Depth 1 deliberately exposes no other files. if (req.headers.depth && !["0", "1"].includes(String(req.headers.depth))) { return reply.type("application/xml").code(403).send('') } const prefixHeader = req.headers["x-forwarded-prefix"] const prefix = typeof prefixHeader === "string" && /^(\/[A-Za-z0-9._~-]+)*\/?$/.test(prefixHeader) ? prefixHeader.replace(/\/$/, "") : "" const href = prefix + req.url.split("?")[0] + (!filename && !req.url.split("?")[0].endsWith("/") ? "/" : "") const props = entry ? `${entry.size}${entry.mimeType}"${entry.checksum}"${entry.receivedAt.toUTCString()}` : '' 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", allowedMethods).code(405).send() } // Both slash forms work without redirects (some scanners discard authentication on redirects). for (const url of ["/", "/*"]) { server.route<{}, { cors: false }>({ method: ["OPTIONS", "PROPFIND", "HEAD", "PUT", "MKCOL", "GET", "DELETE", "POST", "PATCH"], // Scanner OPTIONS requests are WebDAV discovery, not browser CORS preflights. url, config: { cors: false }, bodyLimit: SCANNER_UPLOAD_LIMIT, handler: handle }) } }