diff --git a/.env.example b/.env.example index 9645c2b7..6a2e5df7 100644 --- a/.env.example +++ b/.env.example @@ -51,11 +51,13 @@ SEAFILE_ADMIN_PASSWORD=change-this-seafile-admin-password M2M_API_KEY=change-this-m2m-key API_BASE_URL=https://app.example.com/backend -# Collaboration: public browser URL and canonical HTTPS API address (including reverse-proxy prefix). -FEDEO_PUBLIC_URL=https://app.example.com -FEDEO_FEDERATION_URL=https://app.example.com/backend -# Requires mutual tenant partner approval under Zusammenarbeit. -FEDEO_FEDERATION_ENABLED=false +# Collaboration uses API_BASE_URL for federation and its origin for guest links. +# Optional overrides only, e.g. when using a separate federation gateway. +FEDEO_PUBLIC_URL= +FEDEO_FEDERATION_URL= +# auto: enabled with a valid HTTPS API address and ENCRYPTION_KEY; false: opt out. +# Partners still require mutual tenant approval under Zusammenarbeit. +FEDEO_FEDERATION_ENABLED=auto GOCARDLESS_BASE_URL=https://api.gocardless.com GOCARDLESS_SECRET_ID=replace-this GOCARDLESS_SECRET_KEY=replace-this diff --git a/README.md b/README.md index 854aed3a..6fdbc70e 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,8 @@ Minimal wichtige Werte fur den ersten Start: - `DATABASE_URL=postgres://fedeo:@db:5432/fedeo` - `API_BASE_URL=https://app.example.com/backend` +Die Chat-Föderation verwendet ebenfalls `API_BASE_URL`; Gastlinks übernehmen deren Ursprung. Mit gültiger öffentlicher HTTPS-Adresse auf Port 443 und vorhandenem gültigem `ENCRYPTION_KEY` ist sie automatisch verfügbar. Zusätzliche URLs sind nur bei abweichender Infrastruktur nötig (`FEDEO_FEDERATION_URL`, `FEDEO_PUBLIC_URL`). `FEDEO_FEDERATION_ENABLED=false` schaltet sie ausdrücklich ab; der Standard ist `auto`. Der Updater übernimmt die passenden Compose-Einträge aus dem Release. Partner müssen anschließend weiterhin je Mandant freigegeben werden; siehe [Föderation einrichten](docs/chat-foederation-konzept.md#15-implementierter-stand-und-pilotbetrieb). + Wenn du MinIO verwendest, setze zusatzlich: - `S3_ENDPOINT=http://minio:9000` diff --git a/backend/src/modules/collaboration/config.ts b/backend/src/modules/collaboration/config.ts new file mode 100644 index 00000000..3cc1b283 --- /dev/null +++ b/backend/src/modules/collaboration/config.ts @@ -0,0 +1,30 @@ +import { instanceUrl } from "./transport" + +type Settings = { + API_BASE_URL?: string; + ENCRYPTION_KEY?: string; + FEDEO_PUBLIC_URL?: string; + FEDEO_FEDERATION_URL?: string; + FEDEO_FEDERATION_ENABLED?: string; +} + +/** Existing deployment settings are sufficient; extra URLs are optional overrides. */ +export function collaborationConfig(settings: Settings) { + const configuredApi = settings.API_BASE_URL?.trim() || "" + let apiUrl = (settings.FEDEO_FEDERATION_URL?.trim() || configuredApi).replace(/\/+$/, "") + let publicUrl = settings.FEDEO_PUBLIC_URL?.trim().replace(/\/+$/, "") || "" + if (!publicUrl) { + try { publicUrl = new URL(configuredApi || apiUrl).origin } catch { /* Local development may have no public URL. */ } + } + const mode = settings.FEDEO_FEDERATION_ENABLED?.trim().toLowerCase() || "auto" + if (!["auto", "true", "false"].includes(mode)) throw new Error("FEDEO_FEDERATION_ENABLED muss auto, true oder false sein") + if (mode === "false") return { apiUrl, publicUrl, enabled: false, disabledReason: "Die Föderation wurde vom Betreiber deaktiviert." } + + let disabledReason: string | undefined + try { apiUrl = instanceUrl(apiUrl) } + catch { disabledReason = "Föderation benötigt eine gültige öffentliche HTTPS-API_BASE_URL auf Port 443." } + if (!disabledReason && !/^[a-f0-9]{64}$/i.test(settings.ENCRYPTION_KEY || "")) { + disabledReason = "Föderation benötigt einen gültigen ENCRYPTION_KEY mit 64 Hex-Zeichen." } + if (mode === "true" && disabledReason) throw new Error(disabledReason) + return { apiUrl, publicUrl, enabled: !disabledReason, disabledReason } +} diff --git a/backend/src/modules/collaboration/federation.ts b/backend/src/modules/collaboration/federation.ts index 86e18641..77263aae 100644 --- a/backend/src/modules/collaboration/federation.ts +++ b/backend/src/modules/collaboration/federation.ts @@ -7,6 +7,7 @@ import { FederationKey, Headers, instanceUrl, safeTransport, signedHeaders, Tran type Options = { apiUrl: string; enabled: boolean; + disabledReason?: string; seal: (value: string) => any; unseal: (value: any) => string; transport?: Transport; } @@ -22,7 +23,7 @@ export class FederationService { this.transport = options.transport || safeTransport } enabled() { - if (!this.options.enabled) fail("Der Betreiber muss die Föderation zuerst aktivieren", 503) + if (!this.options.enabled) fail(this.options.disabledReason || "Der Betreiber muss die Föderation zuerst aktivieren", 503) instanceUrl(this.origin) } async keys() { @@ -71,7 +72,7 @@ export class FederationService { VALUES (${ctx.tenantId},${origin},${instanceId},${keyId},${publicKey}) ON CONFLICT(tenant_id,origin) DO UPDATE SET active=true,updated_at=now()`) } } - return { enabled: this.options.enabled, origin: this.origin, partners: (await rows(this.service.db, + return { enabled: this.options.enabled, disabledReason: this.options.disabledReason, origin: this.origin, partners: (await rows(this.service.db, sql`SELECT origin,active,public_key FROM collaboration_partners WHERE tenant_id=${ctx.tenantId} ORDER BY origin`)).map(p => ({ origin: p.origin, active: p.active, fingerprint: hash(p.public_key) })) } } async call(tenantId: number, origin: string, action: string, payload: any) { diff --git a/backend/src/modules/collaboration/runtime.ts b/backend/src/modules/collaboration/runtime.ts index 0f8d12b6..b7005545 100644 --- a/backend/src/modules/collaboration/runtime.ts +++ b/backend/src/modules/collaboration/runtime.ts @@ -4,16 +4,18 @@ import { sendMail } from "../../utils/mailer" import { CollaborationService } from "./service" import { FederationService } from "./federation" import { encrypt, decrypt } from "../../utils/crypt" +import { collaborationConfig } from "./config" export function collaborationRuntime(server: FastifyInstance) { - const apiUrl = String(process.env.FEDEO_FEDERATION_URL || secrets.API_BASE_URL || "").replace(/\/$/, "") - const publicUrl = String(process.env.FEDEO_PUBLIC_URL || (apiUrl ? new URL(apiUrl).origin : "")).replace(/\/$/, "") + const { apiUrl, publicUrl, enabled, disabledReason } = collaborationConfig({ + ...process.env, API_BASE_URL: secrets.API_BASE_URL, ENCRYPTION_KEY: secrets.ENCRYPTION_KEY, + }) const service = new CollaborationService(server.db, { publicUrl, guestPepper: () => secrets.ENCRYPTION_KEY || "", sendMail, }) - const federation = new FederationService(service, { apiUrl, enabled: process.env.FEDEO_FEDERATION_ENABLED === "true", seal: encrypt, unseal: decrypt }) + const federation = new FederationService(service, { apiUrl, enabled, disabledReason, seal: encrypt, unseal: decrypt }) service.options.changed = (roomId, db) => federation.changed(roomId, db) service.options.invited = (ctx, roomId, target, invitationId, db) => federation.invite(ctx, roomId, target, invitationId, db) let timer: ReturnType diff --git a/backend/src/modules/scanner-inbox.service.ts b/backend/src/modules/scanner-inbox.service.ts index b119812c..4e62c423 100644 --- a/backend/src/modules/scanner-inbox.service.ts +++ b/backend/src/modules/scanner-inbox.service.ts @@ -184,6 +184,29 @@ export function createScannerInboxService(db: FastifyInstance["db"], storage: Sc if (!file) throw scannerError(404, "Dokument nicht gefunden.") return currentAssignments(db, tenantId, file) } + const archiveDocument = async (tenantId: number, userId: string, fileId: string) => db.transaction(async tx => { + const [tenant] = await tx.select({ locked: tenants.locked }).from(tenants) + .where(eq(tenants.id, tenantId)).for("share") + if (!tenant) throw scannerError(404, "Mandant nicht gefunden.") + if (tenant.locked === "maintenance_tenant") throw scannerError(423, "Der Mandant wird gerade gewartet.") + // Use the same lock order as filing so both actions cannot succeed concurrently. + const [entry] = await tx.select({ filedAt: scannerInboxDocuments.filedAt }).from(scannerInboxDocuments) + .where(and(eq(scannerInboxDocuments.tenantId, tenantId), eq(scannerInboxDocuments.fileId, fileId))).for("update") + if (!entry) throw scannerError(404, "Dokument nicht in der Ablage gefunden.") + if (entry.filedAt) throw scannerError(409, "Dieses Dokument wurde bereits abgelegt. Bitte den Eingang aktualisieren.") + const [file] = await tx.select({ archived: files.archived }).from(files) + .where(and(eq(files.id, fileId), eq(files.tenant, tenantId))).for("update") + if (!file) throw scannerError(404, "Dokument nicht gefunden.") + if (!file.archived) { + await tx.update(files).set({ archived: true, updatedAt: new Date(), updatedBy: userId }) + .where(and(eq(files.id, fileId), eq(files.tenant, tenantId))) + } + // Keep the receipt for scanner retries; invalidate any analysis already in flight. + await tx.update(scannerInboxDocuments).set({ suggestionClaim: null, + suggestionStatus: sql`CASE WHEN ${scannerInboxDocuments.suggestionStatus} = 'processing' THEN 'pending' ELSE ${scannerInboxDocuments.suggestionStatus} END` }) + .where(and(eq(scannerInboxDocuments.fileId, fileId), eq(scannerInboxDocuments.tenantId, tenantId))) + return { id: fileId, archived: true } + }) const fileDocument = async (tenantId: number, userId: string, fileId: string, folderId: string | null, typeId: string, assignments: FileAssignments = {}) => db.transaction(async tx => { const [tenant] = await tx.select({ locked: tenants.locked }).from(tenants) .where(eq(tenants.id, tenantId)).for("share") @@ -277,7 +300,7 @@ export function createScannerInboxService(db: FastifyInstance["db"], storage: Sc } } return { connection, credentials, disable, authenticate, find, list, openCount, filingOptions, getSuggestion, queueSuggestion, processNextSuggestion, - getAssignments, assignmentOptions: (tenantId: number, kind: string, search: string) => assignmentOptions(db, tenantId, kind, search), fileDocument, probe, accept } + getAssignments, assignmentOptions: (tenantId: number, kind: string, search: string) => assignmentOptions(db, tenantId, kind, search), archiveDocument, fileDocument, probe, accept } } export type ScannerInboxService = ReturnType diff --git a/backend/src/routes/documentInbox.ts b/backend/src/routes/documentInbox.ts index f2fcfd08..5a896310 100644 --- a/backend/src/routes/documentInbox.ts +++ b/backend/src/routes/documentInbox.ts @@ -41,6 +41,9 @@ export default async function documentInboxRoutes(server: FastifyInstance, optio server.post<{ Params: { id: string } }>("/document-inbox/:id/suggestion", { schema: { params: { type: "object", required: ["id"], properties: { id: { type: "string", format: "uuid" } } } }, }, async (req, reply) => reply.code(202).send(await service.queueSuggestion(req.user.tenant_id!, req.params.id))) + server.post<{ Params: { id: string } }>("/document-inbox/:id/archive", { + schema: { params: { type: "object", required: ["id"], properties: { id: { type: "string", format: "uuid" } } } }, + }, async req => service.archiveDocument(req.user.tenant_id!, req.user.user_id, req.params.id)) server.post<{ Params: { id: string }; Body: { folder: string | null; type: string; assignments?: FileAssignments } }>("/document-inbox/:id/file", { schema: { params: { type: "object", required: ["id"], properties: { id: { type: "string", format: "uuid" } } }, diff --git a/backend/tests/collaborationConfig.test.ts b/backend/tests/collaborationConfig.test.ts new file mode 100644 index 00000000..101c1ca5 --- /dev/null +++ b/backend/tests/collaborationConfig.test.ts @@ -0,0 +1,54 @@ +import assert from "node:assert/strict" +import test from "node:test" +import { collaborationConfig } from "../src/modules/collaboration/config" + +const deployment = { API_BASE_URL: "https://fedeo.example/backend", ENCRYPTION_KEY: "a".repeat(64) } + +test("existing HTTPS deployment enables federation without additional environment values", () => { + const config = collaborationConfig(deployment) + assert.equal(config.enabled, true) + assert.equal(config.apiUrl, deployment.API_BASE_URL) + assert.equal(config.publicUrl, "https://fedeo.example") + assert.equal(config.disabledReason, undefined) + assert.equal(collaborationConfig({ ...deployment, FEDEO_FEDERATION_ENABLED: "auto", FEDEO_FEDERATION_URL: "", FEDEO_PUBLIC_URL: "" }).enabled, true) +}) + +test("custom API paths are retained and instance addresses are canonicalized", () => { + const config = collaborationConfig({ ...deployment, API_BASE_URL: "https://FEDEO.example:443/application/api/" }) + assert.equal(config.apiUrl, "https://fedeo.example/application/api") + assert.equal(config.publicUrl, "https://fedeo.example") + assert.equal(config.enabled, true) +}) + +test("explicit opt-out stays disabled even on a correctly configured deployment", () => { + const config = collaborationConfig({ ...deployment, FEDEO_FEDERATION_ENABLED: "false" }) + assert.equal(config.enabled, false) + assert.match(config.disabledReason!, /Betreiber deaktiviert/) +}) + +test("optional gateway override does not move guest links away from the web application", () => { + const config = collaborationConfig({ ...deployment, FEDEO_FEDERATION_URL: "https://gateway.example/federation-api/" }) + assert.equal(config.apiUrl, "https://gateway.example/federation-api") + assert.equal(config.publicUrl, "https://fedeo.example") + assert.equal(collaborationConfig({ ...deployment, FEDEO_PUBLIC_URL: "https://portal.example/app/" }).publicUrl, "https://portal.example/app") +}) + +test("local and incomplete deployments keep local collaboration available without federation", () => { + for (const API_BASE_URL of [undefined, "", "invalid", "http://localhost:3100", "https://fedeo.example:8443/backend", "https://user:password@fedeo.example/backend"]) { + const config = collaborationConfig({ ...deployment, API_BASE_URL }) + assert.equal(config.enabled, false, API_BASE_URL) + assert.match(config.disabledReason!, /HTTPS-API_BASE_URL/) + } + for (const ENCRYPTION_KEY of [undefined, "", "invalid", "x".repeat(64)]) { + const config = collaborationConfig({ ...deployment, ENCRYPTION_KEY }) + assert.equal(config.enabled, false) + assert.match(config.disabledReason!, /ENCRYPTION_KEY/) + } +}) + +test("forced activation reports invalid configuration instead of silently ignoring it", () => { + assert.throws(() => collaborationConfig({ ...deployment, FEDEO_FEDERATION_ENABLED: "true", API_BASE_URL: "http://localhost" }), /HTTPS/) + assert.throws(() => collaborationConfig({ ...deployment, FEDEO_FEDERATION_ENABLED: "true", ENCRYPTION_KEY: "invalid" }), /ENCRYPTION_KEY/) + assert.throws(() => collaborationConfig({ ...deployment, FEDEO_FEDERATION_ENABLED: "typo" }), /auto, true oder false/) + assert.equal(collaborationConfig({ ...deployment, FEDEO_FEDERATION_ENABLED: "true" }).enabled, true) +}) diff --git a/backend/tests/scannerInbox.test.ts b/backend/tests/scannerInbox.test.ts index f89475bd..0e88c4b4 100644 --- a/backend/tests/scannerInbox.test.ts +++ b/backend/tests/scannerInbox.test.ts @@ -527,6 +527,61 @@ test("scanner inbox integration: migration, persistence, isolation, retries and assert.equal((await fileInto(id)).statusCode, 404) await pool.query("UPDATE files SET archived=false WHERE id=$1", [id]) }) + await t.test("archiving is tenant scoped, preserves the original and invalidates an ongoing analysis", async () => { + assert.equal((await send("archive.pdf")).statusCode, 201) + const id = (await service.list(1, 50, 0, "archive.pdf")).documents[0].id + const archive = (fileId = id, headers = {}) => app.inject({ method: "POST", url: `/api/document-inbox/${fileId}/archive`, headers }) + assert.equal((await archive(id, { "x-test-tenant": "2" })).statusCode, 404) + assert.equal((await archive(randomUUID())).statusCode, 404) + assert.equal((await archive("invalid")).statusCode, 400) + const filedId = (await pool.query("SELECT file_id FROM scanner_inbox_documents WHERE tenant_id=1 AND remote_filename='scan.pdf'")).rows[0].file_id + assert.equal((await archive(filedId)).statusCode, 409) + await pool.query("UPDATE tenants SET locked='maintenance_tenant' WHERE id=1") + assert.equal((await archive()).statusCode, 423) + await pool.query("UPDATE tenants SET locked=null WHERE id=1") + await pool.query("INSERT INTO auth_profiles (tenant_id, user_id, customer_for_portal) VALUES (1, $1, 123)", [userId]) + assert.equal((await archive()).statusCode, 403) + await pool.query("DELETE FROM auth_profiles") + const before = (await pool.query("SELECT * FROM files WHERE id=$1", [id])).rows[0] + const countBefore = (await service.openCount(1)).total + const storedBefore = stored.size + // Run this document first, then archive it while its provider response is still pending. + await pool.query("UPDATE scanner_inbox_documents SET received_at=now()-interval '1 day' WHERE file_id=$1", [id]) + let finish!: () => void + analysisGate = new Promise(resolve => { finish = resolve }) + const started = new Promise(resolve => { analysisStarted = resolve }) + const pending = service.processNextSuggestion() + await started + assert.equal(analyses.at(-1).document.name, "archive.pdf") + const result = await archive(id, { "x-test-admin": "false" }) + finish() + await pending + analysisGate = undefined + analysisStarted = undefined + assert.equal(result.statusCode, 200, result.body) + assert.deepEqual(result.json(), { id, archived: true }) + const row = (await pool.query("SELECT * FROM files WHERE id=$1", [id])).rows[0] + assert.equal(row.archived, true) + assert.equal(row.updated_by, userId) + assert.ok(row.updated_at) + assert.equal(row.path, before.path) + assert.equal(row.folder, before.folder) + assert.equal(row.type, before.type) + assert.deepEqual(stored.get(row.path), pdf) + const receipt = (await pool.query("SELECT filed_at, suggestion_claim, suggestion_status FROM scanner_inbox_documents WHERE file_id=$1", [id])).rows[0] + assert.deepEqual(receipt, { filed_at: null, suggestion_claim: null, suggestion_status: "pending" }) + assert.equal((await service.list(1, 50, 0, "archive.pdf")).total, 0) + assert.equal((await service.openCount(1)).total, countBefore - 1) + assert.equal((await suggest(id)).statusCode, 404) + assert.equal((await fileInto(id)).statusCode, 404) + assert.equal((await archive()).statusCode, 200, "repeated requests remain successful") + assert.equal((await pool.query("SELECT updated_at FROM files WHERE id=$1", [id])).rows[0].updated_at.getTime(), row.updated_at.getTime()) + assert.equal((await send("archive.pdf")).statusCode, 204) + assert.equal((await service.list(1, 50, 0, "archive.pdf")).total, 0) + assert.equal(stored.size, storedBefore) + while (await service.processNextSuggestion()) { /* drain the remaining fixtures */ } + assert.equal(analyses.filter(item => item.document.name === "archive.pdf").length, 1) + }) 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") assert.equal((await send("maintenance.pdf")).statusCode, 423) diff --git a/docker-compose.selfhost.yml b/docker-compose.selfhost.yml index 020086b7..38b21255 100644 --- a/docker-compose.selfhost.yml +++ b/docker-compose.selfhost.yml @@ -125,7 +125,7 @@ services: API_BASE_URL: ${API_BASE_URL} FEDEO_PUBLIC_URL: ${FEDEO_PUBLIC_URL:-} FEDEO_FEDERATION_URL: ${FEDEO_FEDERATION_URL:-} - FEDEO_FEDERATION_ENABLED: ${FEDEO_FEDERATION_ENABLED:-false} + FEDEO_FEDERATION_ENABLED: ${FEDEO_FEDERATION_ENABLED:-auto} GOCARDLESS_BASE_URL: ${GOCARDLESS_BASE_URL} GOCARDLESS_SECRET_ID: ${GOCARDLESS_SECRET_ID} GOCARDLESS_SECRET_KEY: ${GOCARDLESS_SECRET_KEY} diff --git a/docs/chat-foederation-konzept.md b/docs/chat-foederation-konzept.md index 9df7e723..61602b55 100644 --- a/docs/chat-foederation-konzept.md +++ b/docs/chat-foederation-konzept.md @@ -1,6 +1,6 @@ # Konzept: Föderierte Chats und externe Projektteilnehmer -Stand: 30. September 2026. Das ursprüngliche Zielbild basiert auf Commit `df48c976`. Die Schritte 0, 1a, 1b und ein föderierter Web-Pilot aus Schritt 2 sind inzwischen implementiert (`21fe4723`, `ea641bb4`, `7b5696ed`). Die folgenden Architekturkapitel enthalten weiterhin spätere Ausbaustufen; den tatsächlichen Lieferumfang, die Einrichtung und verbleibende Grenzen beschreibt Abschnitt 15. Es wurden keine produktiven Instanzen verändert oder echten Einladungen versendet. +Stand: 2. Oktober 2026. Das ursprüngliche Zielbild basiert auf Commit `df48c976`. Die Schritte 0, 1a, 1b und ein föderierter Web-Pilot aus Schritt 2 sind inzwischen implementiert (`21fe4723`, `ea641bb4`, `7b5696ed`). Die folgenden Architekturkapitel enthalten weiterhin spätere Ausbaustufen; den tatsächlichen Lieferumfang, die Einrichtung und verbleibende Grenzen beschreibt Abschnitt 15. Es wurden keine produktiven Instanzen verändert oder echten Einladungen versendet. ## 1. Empfehlung @@ -298,12 +298,14 @@ Bei einem vorübergehend nicht erreichbaren Eigentümer sind bereits geladene Na ### Einrichtung 1. Vor dem Backendstart die additiven Migrationen `0075_project_collaboration.sql` und `0076_collaboration_federation.sql` über den bestehenden Migrationslauf einspielen. Der Selfhost-Container führt standardmäßig `npm run migrate` aus. Bestehende interne Chats bleiben getrennt; kein vorhandener Chat wird automatisch freigegeben. -2. `FEDEO_PUBLIC_URL` auf die öffentliche Webadresse setzen. Für Gastcodes und optional versendete Einladungs-E-Mails muss der bestehende SMTP-Versand funktionieren. `ENCRYPTION_KEY` muss wie für die übrige Backend-Verschlüsselung ein stabiler 64-stelliger Hex-Schlüssel sein. -3. Für den instanzübergreifenden Pilotbetrieb `FEDEO_FEDERATION_URL` auf die kanonische öffentliche HTTPS-API-Adresse setzen, einschließlich des Reverse-Proxy-Präfixes, etwa `https://fedeo.example/backend`. Anschließend `FEDEO_FEDERATION_ENABLED=true` setzen. Die Variablen sind in `.env.example` und `docker-compose.selfhost.yml` aufgenommen. Föderation ist standardmäßig deaktiviert; lokale Unterräume und Gäste funktionieren unabhängig davon. +2. Die vorhandene `API_BASE_URL` wird als Föderationsadresse verwendet, einschließlich des Reverse-Proxy-Präfixes, etwa `https://fedeo.example/backend`. Gastlinks werden aus deren Ursprung `https://fedeo.example` abgeleitet. Zusätzliche URL-Variablen sind im Standardbetrieb nicht nötig. Für Gastcodes und optional versendete Einladungs-E-Mails muss der bestehende SMTP-Versand funktionieren. Den vorhandenen `ENCRYPTION_KEY` beibehalten; er muss ein stabiler 64-stelliger Hex-Schlüssel sein. +3. Der Standard ist jetzt `FEDEO_FEDERATION_ENABLED=auto`, auch wenn die Variable nicht gesetzt ist. Mit gültiger HTTPS-API-Adresse auf Port 443 und gültigem Verschlüsselungsschlüssel wird Föderation automatisch bereitgestellt. Lokale HTTP-Entwicklungsumgebungen oder unvollständige Konfigurationen bleiben ohne Föderation startfähig; die Partnerverwaltung nennt den fehlenden Konfigurationswert. `false` schaltet Föderation ausdrücklich ab. `true` erzwingt die Aktivierung und meldet eine ungültige Konfiguration bereits beim Start. Nur bei abweichender Infrastruktur `FEDEO_FEDERATION_URL` oder `FEDEO_PUBLIC_URL` setzen; beide überschreiben die automatisch abgeleiteten Werte. Ein separates Föderationsgateway verändert die Gast-Webadresse nicht automatisch. 4. Der Proxy muss `GET /.well-known/fedeo-federation` und `POST /federation/v1/rpc` durchreichen und darf JSON-Body oder Signaturheader nicht verändern. Externe Ziele benötigen öffentlich auflösbare HTTPS-Adressen auf Port 443. Private/reservierte IPv4- und IPv6-Ziele sowie Weiterleitungen werden abgewiesen; die geprüfte DNS-Adresse wird für die TLS-Verbindung festgehalten. 5. In beiden beteiligten Mandanten die jeweils andere API-Adresse unter **Partnerinstanzen** freigeben. Discovery ermittelt Instanz-ID und öffentlichen Schlüssel über HTTPS. Die erste Freigabe pinnt diese Identität. Ein später abweichender Schlüssel wird abgelehnt und braucht eine gesonderte Betreiberprüfung; eine automatische Schlüsselrotation ist noch nicht vorhanden. 6. Persönliche Kontaktadresse des Empfängers verwenden, gezielt in einen Unterraum einladen und beim Empfänger annehmen. Für einen ersten Pilotversuch anschließend Rechteänderung, Widerruf und eine vorübergehend unterbrochene Verbindung prüfen. +Der normale Selfhost-Updater übernimmt die Compose-Datei und Migrationen aus dem Zielrelease; ein manuelles Nachtragen der Föderationsvariablen ist bei verwalteten Installationen nicht erforderlich. Die vorhandene `.env` wird bis auf die Release-Version beibehalten. Eine darin oder in der Override-Datei gesetzte Abschaltung bleibt wirksam. Wurde `FEDEO_FEDERATION_ENABLED=false` aus der früheren Beispielkonfiguration übernommen, muss dieser Wert einmal entfernt oder auf `auto` gesetzt werden, wenn Föderation gewünscht ist. Die automatische Bereitstellung gibt keine Partner oder vorhandenen Chats frei; dafür bleiben die gegenseitige Mandantenfreigabe und explizite Einladung erforderlich. + Das feste Signaturprofil verwendet Ed25519 mit HTTP Message Signatures und Content-Digest. Signiert werden Methode, vollständige Zieladresse, Inhaltstyp, Inhaltsdigest und Herkunft; Zeitfenster und gespeicherte Nonces verhindern Replay. Das RPC-Protokoll ist Version 1 und ausschließlich für diese FEDEO-Implementierung bestimmt. Es ersetzt weder Matrix-Kompatibilität noch Ende-zu-Ende-Verschlüsselung. ### Speicherung, Betrieb und Wiederaufnahme @@ -329,3 +331,5 @@ COLLABORATION_TEST_DATABASE_URL=postgresql://USER:PASSWORD@127.0.0.1:PORT/collab Jeder Datenbanktest erzeugt ein eigenes Schema und räumt es anschließend auf. Ohne gesetzte Testadresse werden die Datenbanktests bewusst übersprungen; der Datenbankname wird vor dem Anlegen geprüft. Backend-Typprüfung und Nuxt-Produktionsbuild waren erfolgreich, die abschließenden Vue-Komponenten lassen sich kompilieren. Eine visuelle Browserabnahme war in der Arbeitsumgebung mangels verbundenem Browser nicht möglich. Die nächste Ausbaustufe aus Schritt 3 umfasst insbesondere native Mobile-Parität, Push-Benachrichtigungen, Moderation, feinere Kontingente sowie Schlüsselrotation und abgestimmte Wiederherstellung. Dateifreigaben, Aufgabenfreigaben und E2EE sind weiterhin Erweiterungen. Der hier implementierte Stand ist ein nutzbarer Web-Pilot für Chats und ausdrücklich begrenzte Projektzusammenarbeit; ein produktiver Rollout ist damit noch nicht erfolgt. + +Die automatische Einrichtung wurde zusätzlich mit sechs Konfigurationstests und 58 Updater-Tests geprüft. Die Update-Regressionen verwenden echte Compose-Auswertung mit simuliertem Containerbetrieb und prüfen sowohl Installationen ohne Föderationsvariablen als auch den Erhalt einer ausdrücklichen Abschaltung und eigener URL-Vorgaben. diff --git a/docs/scanner-dokumentenablage.md b/docs/scanner-dokumentenablage.md index fc26839f..cdf34a5e 100644 --- a/docs/scanner-dokumentenablage.md +++ b/docs/scanner-dokumentenablage.md @@ -12,7 +12,13 @@ Unter **Organisation → Dokumentenablage** erscheinen noch nicht abgelegte Scan Nur aktive Ordner, Typen und Zuordnungsziele desselben Mandanten sind zulässig. Mitarbeiter müssen aktiv und interne Benutzer sein. Bei einem Fehler bleibt das Dokument im Eingang; auch die übrigen Änderungen werden nicht teilweise gespeichert. Ein erneuter Scanner-Upload mit demselben Namen und Inhalt bestätigt weiterhin den ursprünglichen Empfang, ohne einen neuen Eingangseintrag oder Analyseauftrag anzulegen. -### KI-Vorschläge +## Archivieren + +**Archivieren** steht sowohl in jeder Listenzeile als auch im Vorschaufenster zur Verfügung. Nach Bestätigung wird die bestehende Datei als archiviert markiert, ohne sie zu löschen. Ordner und Dokumenttyp müssen dafür nicht ausgewählt sein; ungespeicherte Zuordnungen aus der Vorschau werden nicht übernommen. Das Dokument verschwindet aus der Ablage und der Menüzähler wird sofort aktualisiert. Bei einem Fehler bleibt es sichtbar und der Vorgang kann erneut versucht werden. + +Das Original und sein Empfangseintrag bleiben erhalten. Eine identische Wiederholung durch den Scanner aktiviert die Datei nicht erneut. Wartende KI-Aufträge werden für archivierte Dateien übersprungen und Ergebnisse bereits laufender Analysen verworfen. Die Änderung hält Zeitpunkt und Benutzer fest. Bereits unter Dateien abgelegte Dokumente können nicht mehr über die Ablage archiviert werden. + +## KI-Vorschläge Die Analyse nutzt die bestehende zentrale FEDEO-KI-Anbindung oder den konfigurierten `OPENAI_API_KEY`, entsprechend den E-Mail-Zuordnungsvorschlägen. Die Antwort verwendet [Structured Outputs](https://developers.openai.com/api/docs/guides/structured-outputs) und `store: false`. Dokumentinhalt, Dateiname und Kandidatennamen gelten als Daten, nicht als Anweisungen. Das Backend prüft vorgeschlagene IDs und verpflichtende Ordnertypen gegen die aktiven Stammdaten des Mandanten, auch noch einmal nach der Analyse. Der Scanner-Zugang kann diese API nicht nutzen. @@ -49,6 +55,7 @@ Nach der Dateinamenprüfung sendet der ES-580W zunächst einen leeren `PUT` mit - 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/count` liefert `{ "total": 3 }` für den aktiven Mandanten. `GET /api/document-inbox/filing-options` liefert verfügbare Ordner, Typen und `assignmentKinds`. `GET /api/document-inbox/assignment-options?kind=project&search=Beispiel` liefert bis zu 100 passende Ziele mit `id` und `name`; `GET /api/document-inbox/:id/assignments` die bereits bestehenden Verknüpfungen des Dokuments. - `POST /api/document-inbox/:id/file` mit JSON `{ "folder": "", "type": "", "assignments": { "project": 123, "customer": 456 } }` klassifiziert das Original, speichert die optionalen Verknüpfungen und markiert den Eingang atomar als abgelegt. Weggelassene Zuordnungen bleiben erhalten; JSON-`null` entfernt eine vorhandene Zuordnung. Die Schlüssel entsprechen den Dateifeldern; `check` und `authProfile` verwenden UUIDs, die übrigen Bereiche numerische IDs. 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. +- `POST /api/document-inbox/:id/archive` archiviert eine Eingangsdatei des aktiven Mandanten ohne weitere Pflichtangaben und antwortet mit `{ "id": "", "archived": true }`. Wiederholte Aufrufe sind erfolgreich, ohne den ursprünglichen Archivierungszeitpunkt zu ändern. Bereits abgelegte Dokumente ergeben `409`, fremde oder nicht im Eingang registrierte Dateien `404` und Mandantenwartung `423`. Archivieren und Ablegen sind gegeneinander abgesichert. - `GET /api/document-inbox/:id/suggestion` liest den gespeicherten Status `pending`, `processing`, `ready` oder `failed` und liefert bei Erfolg beispielsweise `{ "status": "ready", "suggestion": { "folderId": "", "typeId": "", "confidence": "high|medium", "reason": "…" } }`. Ohne passende Zuordnung ist `suggestion` gleich `null` und `message` erklärt den Grund. `POST /api/document-inbox/:id/suggestion` fordert eine erneute Vorbereitung an und antwortet mit `202` und demselben Statusformat, ohne auf die Analyse zu warten. Ein bereits wartender oder laufender Auftrag wird nicht dupliziert. Nur offene, nicht archivierte Dokumente des aktiven Mandanten sind zugänglich. Diese Anfragen legen nichts ab; dafür bleibt der separate `/file`-Aufruf erforderlich. ## Lokaler Funktionstest diff --git a/docs/selfhost-updates.md b/docs/selfhost-updates.md index f93a7ae0..b39320f6 100644 --- a/docs/selfhost-updates.md +++ b/docs/selfhost-updates.md @@ -13,6 +13,8 @@ Der Updater verweigert externe Datenbanken, externen S3-/Seafile-Speicher, zusätzliche Dienste, Compose-Profile, geänderte Datenpfade und PostgreSQL-Imagewechsel. Dafür braucht es zunächst passende Backup-/Migrationsadapter. Eigene Einstellungen innerhalb der unterstützten Dienste können in `docker-compose.override.yml` **oder** `docker-compose.override.yaml` bleiben; der Updater verwendet diese Datei bei jedem Compose-Aufruf. Änderungen an dieser Override-Datei werden nicht überschrieben. +Für die Chat-Föderation übernimmt der Updater die neuen Backend-Einträge aus der Release-Compose-Datei automatisch. Zusätzliche Einträge in der bestehenden `.env` sind nicht nötig: Die Föderationsadresse folgt `API_BASE_URL`, Gastlinks verwenden deren Ursprung. Der Standard `FEDEO_FEDERATION_ENABLED=auto` aktiviert die Funktion bei gültiger HTTPS-Adresse auf Port 443 und gültigem `ENCRYPTION_KEY`. Ein vorhandenes `false` und eigene URL-Vorgaben bleiben erhalten. Partnerfreigaben und Einladungen werden weiterhin ausdrücklich je Mandant erteilt. Details stehen in der [Föderationsanleitung](chat-foederation-konzept.md#15-implementierter-stand-und-pilotbetrieb). + Eine gezielte Ausnahme ist die Stilllegung des **entfernten alten Matrix-Stacks**: `matrix-db`, `matrix-synapse`, `matrix-redis`, `matrix-element`, `matrix-livekit`, `matrix-rtc-jwt`, `matrix-turn` und `matrix-well-known` dürfen in der bestehenden Konfiguration vorkommen. Die Zielkonfiguration muss ausschließlich die FEDEO-Dienste des jeweiligen Speicherproviders enthalten, auch nach Zusammenführung mit einer Override-Datei. Matrix wird nicht in den neuen Stack übernommen. Bereits stillgelegte Matrix-Container bleiben erhalten und ihre lokalen Bind-Verzeichnisse werden weiter gesichert. Andere Zusatzdienste bleiben gesperrt; die Fehlermeldung nennt die konkreten Abweichungen. Ein Abbruch mit `mutationStarted: false` und `recoveryRequired: false` benötigt keine Wiederherstellung und keinen `resolve`-Aufruf. Für diesen Übergang müssen die Matrix-Daten lokal unter `matrix/` liegen, insbesondere `matrix/postgres` und `matrix/synapse`; symbolische Links, andere konfigurierte Datenpfade, benannte Volumes und spezielle Compose-Konfigurationen benötigen eine manuelle Stilllegung. Die tatsächlichen Bind-Mounts der alten Container müssen zur Compose-Datei passen. Der Plan nennt `retiredServices` und `retainedDataPaths` und berücksichtigt den zusätzlichen Backup-Platz. Alle Matrix-Container werden vor dem Backup gestoppt. Das komplette lokale `matrix/`-Verzeichnis wird bei gestoppter Matrix-Datenbank in `files.tar` gesichert. Erst nach erfolgreichem Backup setzt der Updater die Neustartrichtlinie der alten Matrix-Container auf `no`, damit sie auch nach einem Host-Neustart stillgelegt bleiben. Er löscht weder diese Container noch ihre Daten oder Volumes. Image-seitig angelegte anonyme Volumes und schreibbare Container-Schichten bleiben an den alten Containern erhalten; sie sind nicht Bestandteil von `files.tar`. Die alten Container daher für die Wiederherstellung behalten und nicht mit `--remove-orphans` oder einem Docker-Prune entfernen. diff --git a/frontend/pages/communication/collaboration.vue b/frontend/pages/communication/collaboration.vue index 4cee6b6d..55076a89 100644 --- a/frontend/pages/communication/collaboration.vue +++ b/frontend/pages/communication/collaboration.vue @@ -92,7 +92,7 @@ onBeforeUnmount(() => clearInterval(timer))

Freigegebene Partnerinstanzen

Beide Firmen geben die jeweils andere Instanz frei. Einzelne Personen erhalten anschließend eine eigene Einladung.

-

Die Föderation muss zuerst vom Betreiber aktiviert werden.

+

{{ federation.disabledReason || 'Die Föderation muss zuerst vom Betreiber aktiviert werden.' }}

Partner freigeben
{{ partner.origin }} · {{ partner.active ? 'Freigegeben' : 'Gesperrt' }}{{ partner.active ? 'Sperren' : 'Freigeben' }}
diff --git a/frontend/pages/organisation/dokumentenablage.vue b/frontend/pages/organisation/dokumentenablage.vue index ce68b1a5..30ed0c3a 100644 --- a/frontend/pages/organisation/dokumentenablage.vue +++ b/frontend/pages/organisation/dokumentenablage.vue @@ -36,6 +36,12 @@ const previewDocument = ref(null) const filingDocument = ref(null) const filingLoading = ref(false) const filingSaving = ref(false) +const archiveOpen = ref(false) +const archiveTarget = ref(null) +const archiveSaving = ref(false) +const archiveError = ref("") +let archiveVersion = 0 +const documentBusy = computed(() => filingSaving.value || archiveSaving.value) const filingError = ref("") const filingFolders = ref([]) const filingTypes = ref([]) @@ -80,7 +86,7 @@ const folderOptions = computed(() => { return [{ id: "root", name: "Dateien (Hauptebene)" }, ...filingFolders.value .map(folder => ({ id: folder.id, name: path(folder) })).sort((a, b) => a.name.localeCompare(b.name, "de"))] }) -const canFile = computed(() => Boolean(filingDocument.value && !filingLoading.value && !filingSaving.value +const canFile = computed(() => Boolean(filingDocument.value && !filingLoading.value && !documentBusy.value && (filingFolder.value === "root" || selectedFolder.value) && filingTypes.value.some(type => type.id === filingType.value) && (!fixedType.value || filingType.value === selectedFolder.value?.standardFiletype))) @@ -225,6 +231,7 @@ const removeAssignment = (kind: string) => { const openPreview = (entry: InboxDocument) => openFiling(entry) const openFiling = async (entry: InboxDocument) => { + if (disposed || documentBusy.value) return suggestionVersion++ clearTimeout(suggestionTimer) suggestionController?.abort() @@ -278,6 +285,44 @@ const openFiling = async (entry: InboxDocument) => { } } +const removeInboxEntry = (id: string) => { + // Discard older list responses that still contain the completed document. + requestVersion++ + clearTimeout(pollTimer) + if (documents.value.some(document => document.id === id)) total.value = Math.max(0, total.value - 1) + documents.value = documents.value.filter(document => document.id !== id) + page.value = Math.min(page.value, Math.max(1, Math.ceil(total.value / pageSize))) + void inboxCount.refresh() + if (previewDocument.value?.id === id) previewOpen.value = false +} + +const openArchive = (entry: InboxDocument) => { + if (disposed || documentBusy.value || !auth.activeTenant) return + archiveTarget.value = entry + archiveError.value = "" + archiveOpen.value = true +} + +const archiveDocument = async () => { + const entry = archiveTarget.value + if (!entry || !archiveOpen.value || disposed || documentBusy.value || !auth.activeTenant) return + const version = ++archiveVersion + archiveSaving.value = true + archiveError.value = "" + try { + await $api(`/api/document-inbox/${entry.id}/archive`, { method: "POST" }) + if (disposed || version !== archiveVersion) return + removeInboxEntry(entry.id) + archiveOpen.value = false + toast.add({ title: "Dokument archiviert", description: `${entry.name} wurde aus der Ablage entfernt.`, color: "success" }) + await load(true) + } catch (cause: any) { + if (!disposed && version === archiveVersion) archiveError.value = cause?.data?.error || cause?.data?.message || "Das Dokument konnte nicht archiviert werden." + } finally { + if (version === archiveVersion) archiveSaving.value = false + } +} + const fileDocument = async () => { if (!canFile.value) return const entry = filingDocument.value! @@ -288,14 +333,7 @@ const fileDocument = async () => { try { await $api(`/api/document-inbox/${entry.id}/file`, { method: "POST", body: { folder, type: filingType.value, assignments: { ...assignmentChanges.value } } }) if (disposed || version !== filingVersion) return - // Cancel pending polling responses before removing the successfully filed entry. - requestVersion++ - clearTimeout(pollTimer) - documents.value = documents.value.filter(document => document.id !== entry.id) - total.value = Math.max(0, total.value - 1) - page.value = Math.min(page.value, Math.max(1, Math.ceil(total.value / pageSize))) - void inboxCount.refresh() - previewOpen.value = false + removeInboxEntry(entry.id) toast.add({ title: "Dokument abgelegt", description: `${entry.name} ist unter Dateien verfügbar.`, color: "success" }) await load(true) } catch (cause: any) { @@ -429,7 +467,15 @@ watch(previewOpen, open => { filingLoading.value = false } }) +watch(archiveOpen, open => { + if (!open) { archiveTarget.value = null; archiveError.value = "" } +}) watch(() => auth.activeTenant, () => { + archiveVersion++ + archiveOpen.value = false + archiveTarget.value = null + archiveError.value = "" + archiveSaving.value = false requestVersion++ setupVersion++ filingVersion++ @@ -455,6 +501,7 @@ watch(() => auth.activeTenant, () => { onMounted(() => { origin.value = window.location.origin; load() }) onBeforeUnmount(() => { disposed = true + archiveVersion++ requestVersion++ setupVersion++ filingVersion++ @@ -507,7 +554,12 @@ onBeforeUnmount(() => { {{ dayjs(entry.receivedAt).format('DD.MM.YYYY HH:mm') }} {{ formatSize(entry.size) }} -
AblegenVorschau
+
+ Ablegen + Vorschau + + Archivieren +
@@ -522,7 +574,7 @@ onBeforeUnmount(() => { - +