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,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>