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

@@ -37,6 +37,8 @@ import telephonyRoutes from "./routes/telephony";
import instanceAgentRoutes from "./routes/instanceAgents";
import instanceAgentGatewayRoutes from "./routes/instanceAgentGateway";
import documentImportRoutes from "./routes/documentImports";
import scannerInboxGateway from "./routes/scannerInboxGateway";
import documentInboxRoutes from "./routes/documentInbox";
//Public Links
import publiclinksNonAuthenticatedRoutes from "./routes/publiclinks/publiclinks-non-authenticated";
@@ -134,6 +136,8 @@ async function main() {
await agentApp.register(instanceAgentGatewayRoutes)
},{prefix: "/instance-agent"})
await app.register(scannerInboxGateway, { prefix: "/scanner-inbox" });
await app.register(corsPlugin);
//Geschützte Routes
@@ -167,6 +171,7 @@ async function main() {
await subApp.register(telephonyRoutes);
await subApp.register(instanceAgentRoutes);
await subApp.register(documentImportRoutes);
await subApp.register(documentInboxRoutes);
},{prefix: "/api"})

View File

@@ -0,0 +1,142 @@
import { createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto"
import { and, count, desc, eq, ilike, sql } from "drizzle-orm"
import type { FastifyInstance } from "fastify"
import { DeleteObjectCommand, PutObjectCommand } from "@aws-sdk/client-s3"
import { files, scannerInboxConnections, scannerInboxDocuments, tenants } from "../../db/schema"
import { sanitizeFilename } from "../utils/filename"
import { s3 } from "../utils/s3"
import { secrets } from "../utils/secrets"
export const SCANNER_UPLOAD_LIMIT = 100 * 1024 * 1024
export const SCANNER_DAV_PATH = "/scanner-inbox/"
export const scannerError = (statusCode: number, message: string) => Object.assign(new Error(message), { statusCode })
const digest = (value: string | Buffer) => createHash("sha256").update(value).digest("hex")
export type ScannerIdentity = { tenantId: number; username: string; passwordHash: string }
export interface ScannerStorage {
put(key: string, body: Buffer, contentType: string): Promise<void>
remove(key: string): Promise<void>
}
const objectStorage: ScannerStorage = {
async put(key, body, contentType) {
await s3.send(new PutObjectCommand({ Bucket: secrets.S3_BUCKET, Key: key, Body: body, ContentType: contentType, ContentLength: body.length }))
},
async remove(key) {
await s3.send(new DeleteObjectCommand({ Bucket: secrets.S3_BUCKET, Key: key }))
},
}
export function validateScannerFile(filename: string, body: Buffer) {
if (!filename || filename.length > 200 || /[\\/\u0000-\u001f\u007f]/.test(filename) || filename.startsWith(".")) {
throw scannerError(400, "Bitte einen einzelnen Dateinamen ohne Unterordner verwenden.")
}
if (!body.length) throw scannerError(400, "Die Datei ist leer.")
if (body.length > SCANNER_UPLOAD_LIMIT) throw scannerError(413, "Die Datei überschreitet 100 MB.")
const extension = filename.split(".").pop()?.toLowerCase()
if (extension === "pdf" && body.subarray(0, 5).toString() === "%PDF-" && body.subarray(-1024).includes(Buffer.from("%%EOF"))) return "application/pdf"
if (["jpg", "jpeg"].includes(extension) && body[0] === 0xff && body[1] === 0xd8 && body[2] === 0xff) return "image/jpeg"
if (extension === "png" && body.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))) return "image/png"
if (["tif", "tiff"].includes(extension) && ["49492a00", "4d4d002a"].includes(body.subarray(0, 4).toString("hex"))) return "image/tiff"
throw scannerError(415, "Bitte eine vollständige PDF-, JPEG-, PNG- oder TIFF-Datei übertragen.")
}
export function createScannerInboxService(db: FastifyInstance["db"], storage: ScannerStorage = objectStorage) {
const publicConnection = {
username: scannerInboxConnections.username,
active: scannerInboxConnections.active,
createdAt: scannerInboxConnections.createdAt,
}
const connection = async (tenantId: number) => {
const [row] = await db.select(publicConnection).from(scannerInboxConnections)
.where(eq(scannerInboxConnections.tenantId, tenantId)).limit(1)
return row || null
}
const credentials = async (tenantId: number) => {
// Epson ES-580W destination passwords are limited to 20 characters (120 random bits).
const password = randomBytes(15).toString("base64url")
const [row] = await db.insert(scannerInboxConnections).values({
tenantId, username: `scan_${randomBytes(8).toString("hex")}`, passwordHash: digest(password),
}).onConflictDoUpdate({
target: scannerInboxConnections.tenantId,
set: { passwordHash: digest(password), active: true, updatedAt: new Date() },
}).returning(publicConnection)
return { ...row, password }
}
const disable = async (tenantId: number) => {
await db.update(scannerInboxConnections).set({ active: false, updatedAt: new Date() })
.where(eq(scannerInboxConnections.tenantId, tenantId))
}
const authenticate = async (username: string, password: string): Promise<ScannerIdentity | null> => {
const [row] = await db.select().from(scannerInboxConnections).where(and(
eq(scannerInboxConnections.username, username), eq(scannerInboxConnections.active, true),
)).limit(1)
const expected = row?.passwordHash || "0".repeat(64)
const valid = timingSafeEqual(Buffer.from(digest(password)), Buffer.from(expected))
return row && valid ? { tenantId: row.tenantId, username: row.username, passwordHash: row.passwordHash } : null
}
const find = async (tenantId: number, filename: string) => {
const [row] = await db.select({ size: files.size, mimeType: files.mimeType, checksum: scannerInboxDocuments.checksum,
receivedAt: scannerInboxDocuments.receivedAt }).from(scannerInboxDocuments)
.innerJoin(files, and(eq(files.id, scannerInboxDocuments.fileId), eq(files.tenant, scannerInboxDocuments.tenantId)))
.where(and(eq(scannerInboxDocuments.tenantId, tenantId), eq(scannerInboxDocuments.remoteFilename, filename))).limit(1)
return row || null
}
const list = async (tenantId: number, limit: number, offset: number, search: string) => {
const condition = and(eq(scannerInboxDocuments.tenantId, tenantId), eq(files.tenant, tenantId),
search ? ilike(scannerInboxDocuments.remoteFilename, `%${search.replace(/[\\%_]/g, "\\$&")}%`) : undefined)
const documents = await db.select({ id: files.id, name: scannerInboxDocuments.remoteFilename,
mimeType: files.mimeType, size: files.size, receivedAt: scannerInboxDocuments.receivedAt })
.from(scannerInboxDocuments).innerJoin(files, eq(files.id, scannerInboxDocuments.fileId))
.where(condition).orderBy(desc(scannerInboxDocuments.receivedAt), desc(files.id)).limit(limit).offset(offset)
const [total] = await db.select({ value: count() }).from(scannerInboxDocuments)
.innerJoin(files, eq(files.id, scannerInboxDocuments.fileId)).where(condition)
return { documents, total: total.value }
}
const accept = async (identity: ScannerIdentity, filename: string, body: Buffer, onlyIfNew = false) => {
const contentType = validateScannerFile(filename, body)
const checksum = digest(body)
let uploadedKey: string | undefined
try {
return await db.transaction(async tx => {
// Coordinate with tenant maintenance and credential revocation during the upload.
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.
await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtextextended(${`${identity.tenantId}:${filename}`}, 0))`)
const [existing] = await tx.select().from(scannerInboxDocuments).where(and(
eq(scannerInboxDocuments.tenantId, identity.tenantId), eq(scannerInboxDocuments.remoteFilename, filename),
)).limit(1)
if (existing) {
if (onlyIfNew) throw scannerError(412, "Der Dateiname ist bereits vorhanden.")
if (existing.checksum !== checksum) throw scannerError(409, "Der Dateiname ist bereits vergeben. Bitte einen eindeutigen Dateinamen verwenden.")
return { created: false, checksum }
}
const id = randomUUID()
const key = `${identity.tenantId}/filesbyid/${id}/${sanitizeFilename(filename)}`
uploadedKey = key
await storage.put(key, body, contentType)
// Bypass saveFile's text extraction: the inbox only stores the original document.
await tx.insert(files).values({ id, tenant: identity.tenantId, name: filename, path: key, mimeType: contentType, size: body.length })
await tx.insert(scannerInboxDocuments).values({ fileId: id, tenantId: identity.tenantId, remoteFilename: filename, checksum })
return { created: true, checksum }
})
} catch (error) {
if (uploadedKey) {
try { await storage.remove(uploadedKey) }
catch (cleanupError) { console.error("Scanner-Upload konnte nicht bereinigt werden", { key: uploadedKey, cleanupError }) }
}
throw error
}
}
return { connection, credentials, disable, authenticate, find, list, accept }
}
export type ScannerInboxService = ReturnType<typeof createScannerInboxService>

View File

@@ -0,0 +1,37 @@
import { and, eq } from "drizzle-orm"
import type { FastifyInstance } from "fastify"
import { authProfiles } from "../../db/schema"
import { createScannerInboxService, SCANNER_DAV_PATH, SCANNER_UPLOAD_LIMIT, type ScannerInboxService } from "../modules/scanner-inbox.service"
export default async function documentInboxRoutes(server: FastifyInstance, options: { service?: ScannerInboxService }) {
const service = options.service || createScannerInboxService(server.db)
server.addHook("preHandler", async (req, reply) => {
if (!req.user?.tenant_id) return reply.code(400).send({ error: "Bitte einen Mandanten auswählen." })
const [profile] = await server.db.select({ portal: authProfiles.customer_for_portal }).from(authProfiles)
.where(and(eq(authProfiles.tenant_id, req.user.tenant_id), eq(authProfiles.user_id, req.user.user_id))).limit(1)
if (profile?.portal) return reply.code(403).send({ error: "Die Dokumentenablage ist nur intern verfügbar." })
if (req.routeOptions.url?.includes("/scanner") && !req.user.is_admin) {
return reply.code(403).send({ error: "Scanner-Zugänge können nur Administratoren verwalten." })
}
reply.header("Cache-Control", "no-store")
})
server.get("/document-inbox", async (req, reply) => {
const query = req.query as { limit?: string; offset?: string; search?: string }
const limit = Number(query.limit ?? 50)
const offset = Number(query.offset ?? 0)
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100 || !Number.isSafeInteger(offset) || offset < 0 || (query.search?.length || 0) > 200) {
return reply.code(400).send({ error: "Ungültige Such- oder Seiteneinstellungen." })
}
return service.list(req.user.tenant_id!, limit, offset, query.search?.trim() || "")
})
server.get("/document-inbox/scanner", async req => ({
connection: await service.connection(req.user.tenant_id!), path: SCANNER_DAV_PATH, maxFileSize: SCANNER_UPLOAD_LIMIT,
}))
server.post("/document-inbox/scanner/credentials", async req => ({
connection: await service.credentials(req.user.tenant_id!), path: SCANNER_DAV_PATH,
}))
server.delete("/document-inbox/scanner", async (_req, reply) => {
await service.disable(_req.user.tenant_id!)
return reply.code(204).send()
})
}

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 })
}
}