feat(documents): add Epson scanner inbox and document shelf

This commit is contained in:
2026-09-29 12:42:05 +02:00
parent 552a90e9e1
commit 2192fa5bb4
13 changed files with 942 additions and 0 deletions

View File

@@ -0,0 +1,74 @@
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 => ({ "<": "&lt;", ">": "&gt;", "&": "&amp;", '"': "&quot;", "'": "&apos;" }[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<FastifyRequest, ScannerIdentity>()
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)!
const filename = (req.params as { "*"?: string })["*"] || ""
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()
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)
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('<d:error xmlns:d="DAV:"><d:propfind-finite-depth/></d:error>')
}
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
? `<d:resourcetype/><d:getcontentlength>${entry.size}</d:getcontentlength><d:getcontenttype>${entry.mimeType}</d:getcontenttype><d:getetag>"${entry.checksum}"</d:getetag><d:getlastmodified>${entry.receivedAt.toUTCString()}</d:getlastmodified>`
: '<d:resourcetype><d:collection/></d:resourcetype>'
return reply.header("DAV", "1").type("application/xml; charset=utf-8").code(207).send(
`<?xml version="1.0" encoding="utf-8"?><d:multistatus xmlns:d="DAV:"><d:response><d:href>${escapeXml(href)}</d:href><d:propstat><d:prop><d:displayname>${escapeXml(filename || "FEDEO Dokumentenablage")}</d:displayname>${props}</d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response></d:multistatus>`)
}
return reply.header("Allow", methods).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 })
}
}