10 Commits

Author SHA1 Message Date
fcf9cc3fc9 Merge pull request 'dev' (#229) from dev into main
All checks were successful
Build and Release FEDEO / verify (push) Successful in 49s
Build and Release FEDEO / build-and-release (push) Successful in 6m6s
Reviewed-on: #229
2026-09-25 09:24:39 +00:00
eaa5627062 fix(documents): initialize new documents as drafts
All checks were successful
Build and Release FEDEO / verify (push) Successful in 52s
Build and Release FEDEO / build-and-release (push) Successful in 5m57s
2026-09-25 11:14:09 +02:00
9fe5a9b1da fix(selfhost): reuse locally cached digest images during updates 2026-09-25 10:10:30 +02:00
a65bd72d74 feat(scan): whiten receipt backgrounds and embed OCR in PDFs 2026-09-24 17:41:30 +02:00
b9cfdbe29e fix(scan): allow slower SANE network discovery 2026-09-24 17:09:35 +02:00
dde46f10e1 fix(scan): generate valid systemd working directory 2026-09-24 17:06:52 +02:00
20091143d2 Merge pull request 'dev' (#228) from dev into main
All checks were successful
Build and Release FEDEO / verify (push) Successful in 45s
Build and Release FEDEO / build-and-release (push) Successful in 6m5s
Reviewed-on: #228
2026-09-23 17:20:58 +00:00
8e1505023b feat(selfhost): start full updates from administration
All checks were successful
Build and Release FEDEO / verify (push) Successful in 45s
Build and Release FEDEO / build-and-release (push) Successful in 5m49s
2026-09-23 18:49:10 +02:00
60553bbba8 fix(auth): defer dashboard loading until tenant selection 2026-09-23 18:21:40 +02:00
96295ee4f0 fix(scan): restore missing scanner database migrations 2026-09-23 18:21:37 +02:00
51 changed files with 2324 additions and 710 deletions

View File

@@ -499,7 +499,7 @@ sudo python3 /opt/fedeo/scripts/selfhost-update.py status
Beim ersten Update die erzeugte Compose-Differenz prüfen, eigene Anpassungen in eine Override-Datei übertragen und den Austausch mit `--accept-compose-replacement` bestätigen. Bestehende Installationen müssen das Update-Skript zunächst aus einem passenden Release übernehmen. Beim ersten Update die erzeugte Compose-Differenz prüfen, eigene Anpassungen in eine Override-Datei übertragen und den Austausch mit `--accept-compose-replacement` bestätigen. Bestehende Installationen müssen das Update-Skript zunächst aus einem passenden Release übernehmen.
Optional führt `fedeo-updater.service` die Aufträge unabhängig von der anfragenden Verbindung aus. Nach Bereitstellung der zugehörigen Dienstdateien installiert `sudo bash /opt/fedeo/scripts/selfhost-update-service-install.sh` den systemd-Dienst. Die lokale Unix-Socket-API bietet Prüfung, Update-Start und Status; Backend-Anbindung und Frontend-Button folgen separat. Einrichtung und API-Beispiele stehen in der folgenden Anleitung. Unter **Administration → Systemstatus → FEDEO-Updates** können Systemadministratoren neue Releases prüfen und nach Bestätigung installieren. Dazu führt `fedeo-updater.service` die Aufträge unabhängig von Browser und Backend-Neustarts aus. Nach Bereitstellung der zugehörigen Dienstdateien installiert `sudo bash /opt/fedeo/scripts/selfhost-update-service-install.sh` den systemd-Dienst. Die aktuelle Selfhost-Compose-Datei bindet dessen lokales Socket-Verzeichnis im Backend ein. Bestehende Installationen benötigen einmalig die aktuellen Hostskripte und ein CLI-Update auf einen Release mit der Oberfläche. Einrichtung und Schritte stehen in der folgenden Anleitung.
Voraussetzungen, unterstützte Konfigurationen und Wiederherstellung stehen in [Vollständige Selfhost-Updates](docs/selfhost-updates.md). Für ältere Releases steht der manuelle Ablauf in [Versionierung und Releases](docs/releases.md). Voraussetzungen, unterstützte Konfigurationen und Wiederherstellung stehen in [Vollständige Selfhost-Updates](docs/selfhost-updates.md). Für ältere Releases steht der manuelle Ablauf in [Versionierung und Releases](docs/releases.md).

View File

@@ -10,6 +10,9 @@ FEDEO_SCAN_MODE=Color
FEDEO_SCAN_SOURCE= FEDEO_SCAN_SOURCE=
FEDEO_SCAN_TIMEOUT_SECONDS=900 FEDEO_SCAN_TIMEOUT_SECONDS=900
FEDEO_SCAN_POSTPROCESS=false FEDEO_SCAN_POSTPROCESS=false
FEDEO_SCAN_BACKGROUND_CLEANUP=true
FEDEO_SCAN_OCR=true
FEDEO_SCAN_OCR_LANGUAGE=deu+eng
FEDEO_SCAN_POSTPROCESS_PROFILE=document FEDEO_SCAN_POSTPROCESS_PROFILE=document
FEDEO_SCAN_POSTPROCESS_PYTHON= FEDEO_SCAN_POSTPROCESS_PYTHON=
FEDEO_SCAN_POSTPROCESS_STRICT=false FEDEO_SCAN_POSTPROCESS_STRICT=false

View File

@@ -13,6 +13,9 @@ FROM node:20-bookworm-slim AS runtime
ENV NODE_ENV=production ENV NODE_ENV=production
ENV FEDEO_WORK_DIR=/work ENV FEDEO_WORK_DIR=/work
ENV FEDEO_SCAN_POSTPROCESS=false ENV FEDEO_SCAN_POSTPROCESS=false
ENV FEDEO_SCAN_BACKGROUND_CLEANUP=true
ENV FEDEO_SCAN_OCR=true
ENV FEDEO_SCAN_OCR_LANGUAGE=deu+eng
ENV FEDEO_SCAN_POSTPROCESS_PROFILE=document ENV FEDEO_SCAN_POSTPROCESS_PROFILE=document
ENV FEDEO_SCAN_POSTPROCESS_PYTHON=/opt/fedeo-device-agent/.venv-opencv/bin/python ENV FEDEO_SCAN_POSTPROCESS_PYTHON=/opt/fedeo-device-agent/.venv-opencv/bin/python
ENV FEDEO_SCAN_POSTPROCESS_STRICT=false ENV FEDEO_SCAN_POSTPROCESS_STRICT=false
@@ -28,6 +31,9 @@ RUN apt-get update \
python3-pip \ python3-pip \
python3-venv \ python3-venv \
sane-utils \ sane-utils \
tesseract-ocr \
tesseract-ocr-deu \
tesseract-ocr-eng \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
COPY requirements-opencv.txt ./ COPY requirements-opencv.txt ./

View File

@@ -26,16 +26,29 @@ Die Installation ist je Agent getrennt:
Die entsprechenden XDG-Verzeichnisse werden berücksichtigt. Der Autostart beginnt bei der Benutzeranmeldung. Ohne Autostart muss das gestartete Terminal geöffnet bleiben. Protokolle: `journalctl --user -u fedeo-device-agent-<ID>.service -f`. Eine erneute Installation ersetzt nur die Dateien dieses Agenten. Die entsprechenden XDG-Verzeichnisse werden berücksichtigt. Der Autostart beginnt bei der Benutzeranmeldung. Ohne Autostart muss das gestartete Terminal geöffnet bleiben. Protokolle: `journalctl --user -u fedeo-device-agent-<ID>.service -f`. Eine erneute Installation ersetzt nur die Dateien dieses Agenten.
### Scannerverwaltung meldet HTTP 500 nach einem Serverupdate
Die älteren Scanner-SQL-Dateien `0051` bis `0053` waren nicht im Drizzle-Migrationsjournal eingetragen und wurden dadurch bei regulären Updates übersprungen. Die Vorwärtsmigration `0072_restore_instance_scan_agents` legt fehlende Tabellen und Spalten nachträglich an. Vorhandene Agenten, Tokens, Einstellungen und Scanaufträge bleiben erhalten.
Zuerst einen Backend-Stand mit dieser Migration ausrollen. Bei aktiviertem `FEDEO_RUN_MIGRATIONS` wird sie beim Start automatisch ausgeführt. Bei bewusst deaktivierten Startmigrationen muss anschließend im aktualisierten Backend `npm run migrate` ausgeführt werden. Die alten Journal-Zeitstempel nachträglich einzufügen genügt bei bereits aktualisierten Datenbanken nicht.
Der Datenbanktest läuft isoliert gegen ein temporäres PostgreSQL-Image und verwendet keine Anwendungskonfiguration:
```bash
cd backend
FEDEO_TEST_POSTGRES_IMAGE=postgres:16-alpine node --import tsx tests/instanceAgentMigration.test.ts
```
## Voraussetzungen ## Voraussetzungen
### macOS ### macOS
```bash ```bash
brew install node sane-backends brew install node sane-backends tesseract tesseract-lang
scanimage -L scanimage -L
``` ```
Für die Dateierstellung wird Python mit Pillow benötigt, beispielsweise über `npm run setup:opencv` (siehe unten). Für die Dateierstellung und Hintergrundbereinigung wird Python mit Pillow und OpenCV benötigt, beispielsweise über `npm run setup:opencv` (siehe unten). `tesseract --list-langs` muss für die Standardkonfiguration `deu` und `eng` enthalten.
Drucken nutzt später das macOS-Drucksystem/CUPS: Drucken nutzt später das macOS-Drucksystem/CUPS:
@@ -47,7 +60,7 @@ lpstat -p
```bash ```bash
sudo apt update sudo apt update
sudo apt install -y nodejs npm sane-utils python3-pil sudo apt install -y nodejs npm sane-utils python3-pil python3-opencv tesseract-ocr tesseract-ocr-deu tesseract-ocr-eng
scanimage -L scanimage -L
``` ```
@@ -77,6 +90,9 @@ Der iX1600 wird vom SANE-Backend `fujitsu` per USB unterstützt. Die USB-ID ist
FEDEO_SCAN_RESOLUTION=300 FEDEO_SCAN_RESOLUTION=300
FEDEO_SCAN_MODE=Color FEDEO_SCAN_MODE=Color
FEDEO_SCAN_POSTPROCESS=false FEDEO_SCAN_POSTPROCESS=false
FEDEO_SCAN_BACKGROUND_CLEANUP=true
FEDEO_SCAN_OCR=true
FEDEO_SCAN_OCR_LANGUAGE=deu+eng
FEDEO_SCAN_TIMEOUT_SECONDS=900 FEDEO_SCAN_TIMEOUT_SECONDS=900
``` ```
@@ -126,7 +142,24 @@ npm run build:installer
`npm test` prüft mit `check:installer`, dass das Paket dem aktuellen Quellcode entspricht. Das Paket enthält keine Zugangsdaten; diese ergänzt erst der Browser beim Download. Die Installer-Tests verwenden temporäre Verzeichnisse und simulierte Systembefehle. `npm test` prüft mit `check:installer`, dass das Paket dem aktuellen Quellcode entspricht. Das Paket enthält keine Zugangsdaten; diese ergänzt erst der Browser beim Download. Die Installer-Tests verwenden temporäre Verzeichnisse und simulierte Systembefehle.
## OpenCV-Nachbearbeitung ## Scannerhintergrund und OCR
Hintergrundbereinigung und OCR sind standardmäßig aktiv und lassen sich in **Administration → Scanner** als Vorgabe oder im einzelnen Scan-Dialog einstellen:
- **Scannerhintergrund weiß machen:** Bei einem kleinen Bon oder Dokument werden sicher erkannte Flächen außerhalb des Papiers weiß. Seitengröße, Position und Inhalt des Papiers bleiben erhalten. Unsichere Papiergrenzen werden nicht verändert. Diese Verarbeitung ist unabhängig vom zusätzlichen Zuschnitt.
- **Durchsuchbares PDF (OCR):** Der Agent erkennt nach der Bildverarbeitung Text mit Tesseract und bettet ihn direkt in das PDF ein, bevor er es hochlädt. Das gilt auch für normale Dateien ohne Eingangsbeleg. Deutsch und Englisch sind vorausgewählt; PNG und TIFF erhalten keine OCR-Textschicht. Die Darstellung bleibt das gescannte Bild, ergänzt um auswählbaren und durchsuchbaren Text ([Tesseract-Dokumentation](https://tesseract-ocr.github.io/tessdoc/Command-Line-Usage.html)).
```env
FEDEO_SCAN_BACKGROUND_CLEANUP=true
FEDEO_SCAN_OCR=true
FEDEO_SCAN_OCR_LANGUAGE=deu+eng
```
Nach einem Update von FEDEO bestehende Agenten über **Einrichtung öffnen** mit dem neuen Linux-Installer aktualisieren. Dieser installiert auch OpenCV, Tesseract und die Sprachpakete. Bei manueller Installation die oben genannten Pakete und die aktuelle Agent-Software installieren und den Dienst neu starten. Der Agent meldet die verfügbare Verarbeitung beim Start an FEDEO; fehlende Fähigkeiten werden vor dem Auftrag gemeldet. Zusätzlich prüft er die benötigten Pakete und die gewählte OCR-Sprache vor jedem Papiereinzug. Ein OCR-Fehler erzeugt keinen erfolgreichen Scan ohne Textschicht; die Rohseiten bleiben zur Wiederherstellung erhalten.
Die OCR läuft innerhalb des Scan-Auftrags nach dem Einzug. Bei langen Stapeln kann dieser Schritt länger dauern; das Zeitlimit für die Dateierstellung wird ebenfalls über `FEDEO_SCAN_TIMEOUT_SECONDS` gesetzt. Leere Rückseiten und Seitenreihenfolge bleiben erhalten.
## Zusätzliche OpenCV-Nachbearbeitung
Für automatischen Zuschnitt, leichte Entzerrung, Rotation und Kontrastkorrektur kann die OpenCV-Pipeline aktiviert werden. Für automatischen Zuschnitt, leichte Entzerrung, Rotation und Kontrastkorrektur kann die OpenCV-Pipeline aktiviert werden.
@@ -143,7 +176,7 @@ FEDEO_SCAN_POSTPROCESS_PYTHON=/pfad/zum/agent/.venv-opencv/bin/python
FEDEO_SCAN_POSTPROCESS_STRICT=false FEDEO_SCAN_POSTPROCESS_STRICT=false
``` ```
Wenn `FEDEO_SCAN_POSTPROCESS_PYTHON` leer bleibt, verwendet der Agent automatisch `.venv-opencv/bin/python`, sofern diese Umgebung existiert, sonst `python3`. Pillow ist auch ohne Bildkorrektur für die Dateierstellung erforderlich. Falls OpenCV nicht installiert ist und `FEDEO_SCAN_POSTPROCESS_STRICT=false` gesetzt ist, werden die Rohseiten im gewünschten Ausgabeformat zusammengeführt. Eine fehlgeschlagene Korrektur verliert keine Seiten. Die Warnung erscheint im Scan-Ergebnis. Beim iX1600-Profil ist die Bildkorrektur zunächst ausgeschaltet, damit der vollständige A4-Scan erhalten bleibt. Wenn `FEDEO_SCAN_POSTPROCESS_PYTHON` leer bleibt, verwendet der Agent automatisch `.venv-opencv/bin/python`, sofern diese Umgebung existiert, sonst `python3`. Pillow ist auch ohne Bildkorrektur für die Dateierstellung erforderlich. Falls die zusätzliche Korrektur fehlschlägt und `FEDEO_SCAN_POSTPROCESS_STRICT=false` gesetzt ist, werden die betroffenen Rohseiten weiterverarbeitet; die Warnung erscheint im Scan-Ergebnis. Fehlendes OpenCV kann nur bei ausgeschalteter Hintergrundbereinigung toleriert werden. Beim iX1600-Profil ist der zusätzliche Zuschnitt ausgeschaltet, damit der vollständige A4-Scan erhalten bleibt; Hintergrundbereinigung und OCR sind eingeschaltet.
Profile: Profile:
@@ -153,7 +186,7 @@ Profile:
## Container-Betrieb ## Container-Betrieb
Auf Linux und Raspberry Pi OS kann der Agent komplett im Container laufen. Dadurch bleiben Node.js, Python, OpenCV und SANE im Image. Auf dem Host werden dann nur Docker und Zugriff auf den USB-Scanner benötigt. Auf Linux und Raspberry Pi OS kann der Agent komplett im Container laufen. Dadurch bleiben Node.js, Python, OpenCV, Tesseract einschließlich Deutsch/Englisch und SANE im Image. Auf dem Host werden dann nur Docker und Zugriff auf den USB-Scanner benötigt.
```bash ```bash
cp .env.example .env cp .env.example .env
@@ -189,7 +222,7 @@ npm test
npm run test:python npm run test:python
``` ```
Die Tests benötigen Python 3 mit Pillow. Sie simulieren `scanimage`, erstellen echte PDFs und prüfen Duplex-/Simplex-Stapel, Seitenreihenfolge, Auflösung, leeren Einzug, Papierstau, beschädigte Seiten, Dateinamen und OpenCV-Ausfälle. Ein Test mit angeschlossenem iX1600 bleibt zusätzlich erforderlich. Die Tests benötigen Python 3 mit Pillow, NumPy und OpenCV. Für den OCR-Integrationstest sind Tesseract mit `deu`/`eng` und `pdftotext` aus Poppler erforderlich; ohne diese Programme wird er übersprungen. Bei Verwendung der virtuellen Umgebung zuerst `. .venv-opencv/bin/activate` ausführen. Die Tests simulieren `scanimage`, erstellen echte PDFs und prüfen Duplex-/Simplex-Stapel, Seitenreihenfolge, Auflösung, leeren Einzug, Papierstau, beschädigte Seiten, Dateinamen und OpenCV-Ausfälle. Bildtests prüfen die Hintergrundbereinigung bei geraden und schrägen Bons sowie den Erhalt von Text, farbigen Logos und vollständigen Seiten. Der OCR-Test liest die tatsächliche PDF-Textschicht einschließlich leerer Rückseiten aus. Ein Test mit angeschlossenem iX1600 bleibt zusätzlich erforderlich.
Hardwareprüfung am 17.09.2026: ScanSnap iX1600 per USB unter Linux Mint 22.3 mit SANE 1.2.1. Zwei A4-Blätter wurden mit 300 dpi in Farbe beidseitig gescannt; das erzeugte PDF enthält vier A4-Seiten in der Reihenfolge Vorderseite/Rückseite. Alle vier Seiten wurden visuell geprüft. Der Test umfasste den lokalen Agenten bis zur PDF-Erstellung; der FEDEO-Upload wird separat mit einem simulierten Dateispeicher getestet. Hardwareprüfung am 17.09.2026: ScanSnap iX1600 per USB unter Linux Mint 22.3 mit SANE 1.2.1. Zwei A4-Blätter wurden mit 300 dpi in Farbe beidseitig gescannt; das erzeugte PDF enthält vier A4-Seiten in der Reihenfolge Vorderseite/Rückseite. Alle vier Seiten wurden visuell geprüft. Der Test umfasste den lokalen Agenten bis zur PDF-Erstellung; der FEDEO-Upload wird separat mit einem simulierten Dateispeicher getestet.

View File

@@ -14,7 +14,7 @@ async function filesUnder(directory) {
const packageJson = JSON.parse(await readFile(path.join(root, 'package.json'), 'utf8')) const packageJson = JSON.parse(await readFile(path.join(root, 'package.json'), 'utf8'))
const paths = [ const paths = [
...(await filesUnder('dist')).filter(file => file.endsWith('.js')), ...(await filesUnder('dist')).filter(file => file.endsWith('.js')),
'scripts/scan_document.py', 'scripts/opencv_postprocess.py', 'scripts/setup-opencv.sh', 'scripts/scan_document.py', 'scripts/scan_background.py', 'scripts/opencv_postprocess.py', 'scripts/setup-opencv.sh',
'requirements-opencv.txt', 'requirements-opencv.txt',
] ]
const files = Object.fromEntries(await Promise.all(paths.map(async file => [file, (await readFile(path.join(root, file))).toString('base64')]))) const files = Object.fromEntries(await Promise.all(paths.map(async file => [file, (await readFile(path.join(root, file))).toString('base64')])))

View File

@@ -25,17 +25,22 @@ FEDEO_SETUP_UNIT="fedeo-device-agent-$FEDEO_SETUP_ID.service"
FEDEO_SETUP_UNITS="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user" FEDEO_SETUP_UNITS="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user"
echo 'FEDEO Scan-Agent wird für diesen Benutzer eingerichtet.' echo 'FEDEO Scan-Agent wird für diesen Benutzer eingerichtet.'
if ! command -v node >/dev/null || ! command -v scanimage >/dev/null || ! python3 -c 'import PIL' >/dev/null 2>&1; then fedeo_processing_ready() {
python3 -c 'import PIL, cv2, numpy' >/dev/null 2>&1 &&
tesseract --list-langs 2>/dev/null | grep -qx deu &&
tesseract --list-langs 2>/dev/null | grep -qx eng
}
if ! command -v node >/dev/null || ! command -v scanimage >/dev/null || ! fedeo_processing_ready; then
if ! command -v apt-get >/dev/null; then if ! command -v apt-get >/dev/null; then
echo 'Bitte Node.js >= 20, SANE (scanimage), Python 3 und Pillow installieren und das Skript erneut starten.' >&2 echo 'Bitte Node.js >= 20, SANE, Python/Pillow/OpenCV sowie Tesseract mit Deutsch/Englisch installieren und das Skript erneut starten.' >&2
exit 1 exit 1
fi fi
echo 'Fehlende Systempakete werden über apt installiert (sudo benötigt).' echo 'Fehlende Systempakete werden über apt installiert (sudo benötigt).'
sudo apt-get update sudo apt-get update
sudo apt-get install -y nodejs sane-utils python3-pil sudo apt-get install -y nodejs sane-utils python3-pil python3-opencv tesseract-ocr tesseract-ocr-deu tesseract-ocr-eng
fi fi
node -e 'if (Number(process.versions.node.split(".")[0]) < 20) { console.error("Node.js >= 20 wird benötigt. Bitte Node.js aktualisieren und die Installation erneut starten."); process.exit(1) }' node -e 'if (Number(process.versions.node.split(".")[0]) < 20) { console.error("Node.js >= 20 wird benötigt. Bitte Node.js aktualisieren und die Installation erneut starten."); process.exit(1) }'
python3 -c 'import PIL' fedeo_processing_ready || { echo 'Die benötigte Bildverarbeitung oder die OCR-Sprachpakete fehlen weiterhin.' >&2; exit 1; }
FEDEO_SETUP_NODE="$(command -v node)" FEDEO_SETUP_NODE="$(command -v node)"
FEDEO_SETUP_HAS_SYSTEMD=false FEDEO_SETUP_HAS_SYSTEMD=false
@@ -81,7 +86,9 @@ def unit_quote(value):
unit_path.write_text('\n'.join([ unit_path.write_text('\n'.join([
'[Unit]', 'Description=FEDEO Scan-Agent', 'After=network-online.target', '', '[Unit]', 'Description=FEDEO Scan-Agent', 'After=network-online.target', '',
'[Service]', 'Type=simple', '[Service]', 'Type=simple',
'WorkingDirectory=' + unit_quote(agent_dir), # WorkingDirectory takes a literal path, unlike Environment/ExecStart.
# Quotes would become part of the path; only systemd specifiers need escaping.
'WorkingDirectory=' + str(agent_dir).replace('%', '%%'),
'Environment=' + unit_quote('FEDEO_AGENT_ENV=' + str(config_path)), 'Environment=' + unit_quote('FEDEO_AGENT_ENV=' + str(config_path)),
'ExecStart=' + unit_quote(node) + ' ' + unit_quote(agent_dir / 'dist/main.js'), 'ExecStart=' + unit_quote(node) + ' ' + unit_quote(agent_dir / 'dist/main.js'),
'Restart=on-failure', 'RestartSec=5', 'UMask=0077', '', 'Restart=on-failure', 'RestartSec=5', 'UMask=0077', '',

View File

@@ -0,0 +1,74 @@
"""Conservative paper masking. Keep the canvas and every pixel inside the paper."""
from pathlib import Path
import cv2
import numpy as np
def whiten_background(image):
height, width = image.shape[:2]
scale = min(1.0, 1000.0 / max(height, width))
small = cv2.resize(image, None, fx=scale, fy=scale, interpolation=cv2.INTER_AREA)
gray = cv2.cvtColor(small, cv2.COLOR_BGR2GRAY)
border = np.concatenate((gray[0], gray[-1], gray[:, 0], gray[:, -1]))
background = float(np.median(border))
# White surroundings already need no correction. Never search inside a
# white document for an isolated text box or logo to keep instead.
if background >= 235 or np.percentile(border, 75) - np.percentile(border, 25) > 55:
return image
threshold, _ = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
mask = np.uint8(gray > max(threshold, background + 20)) * 255
mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, np.ones((5, 5), np.uint8))
contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
candidates = []
area = gray.size
for contour in contours:
contour_area = cv2.contourArea(contour)
if not 0.015 * area < contour_area < 0.985 * area:
continue
perimeter = cv2.arcLength(contour, True)
polygon = cv2.approxPolyDP(contour, 0.018 * perimeter, True)
if len(polygon) != 4 or not cv2.isContourConvex(polygon):
continue
# Use the quadrilateral only to recognize a sheet, then keep its full
# contour hull so rounded or slightly uneven edges cannot lose content.
polygon = cv2.convexHull(contour)
paper = np.zeros_like(gray)
cv2.fillPoly(paper, [polygon], 255)
inside = cv2.erode(paper, np.ones((7, 7), np.uint8)) > 0
ring = (cv2.dilate(paper, np.ones((9, 9), np.uint8)) > 0) & (paper == 0)
if inside.sum() < 100 or ring.sum() < 50:
continue
paper_light = float(np.median(gray[inside]))
# A paper edge must separate a mostly light sheet from darker scanner
# backing all around it. Ambiguous photos/pages are left untouched.
if paper_light < background + 30 or np.mean(gray[ring] < paper_light - 20) < 0.85:
continue
candidates.append(polygon)
if not candidates:
return image
keep_small = np.zeros_like(gray)
cv2.fillPoly(keep_small, candidates, 255)
# Do not erase another significant object that we could not classify.
outside = (keep_small == 0)
if np.mean(gray[outside] > background + 30) > 0.04:
return image
keep = np.zeros((height, width), dtype=np.uint8)
polygons = [np.rint(polygon / scale).astype(np.int32) for polygon in candidates]
cv2.fillPoly(keep, polygons, 255)
# Rounding/downsampling uncertainty grows with the original scan DPI.
padding = max(2, int(np.ceil(2 / scale)))
keep = cv2.dilate(keep, np.ones((2 * padding + 1, 2 * padding + 1), np.uint8))
result = image.copy()
result[keep == 0] = 255
return result
def clean_scan_background(input_path: Path, output_path: Path):
image = cv2.imread(str(input_path), cv2.IMREAD_COLOR)
if image is None:
raise RuntimeError("Scan-Bild konnte nicht gelesen werden")
if not cv2.imwrite(str(output_path), whiten_background(image)):
raise RuntimeError("Bereinigtes Scan-Bild konnte nicht gespeichert werden")

View File

@@ -1,21 +1,56 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Assemble ordered scanner pages; OpenCV is optional, PDF creation is not.""" """Prepare ordered scanner pages and publish an optionally searchable PDF atomically."""
import argparse import argparse
import json import json
import math
from pathlib import Path from pathlib import Path
import re
import signal
import subprocess
import tempfile
from PIL import Image from PIL import Image, PdfParser
def create_document(inputs, output, resolution, use_postprocess=False, profile="document", strict=False): def run_ocr_command(arguments):
process = subprocess.Popen(arguments, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
try:
stdout, stderr = process.communicate()
except BaseException:
process.kill()
process.wait()
raise
if process.returncode != 0:
raise RuntimeError("OCR fehlgeschlagen: " + stderr.strip())
return stdout
def check_ocr(language):
if not re.fullmatch(r"[a-zA-Z0-9_]+(?:\+[a-zA-Z0-9_]+)*", language):
raise ValueError("Ungültige OCR-Sprache")
try:
languages = run_ocr_command(["tesseract", "--list-langs"]).splitlines()
except FileNotFoundError as error:
raise RuntimeError("Tesseract fehlt. Bitte tesseract-ocr und die Sprachpakete tesseract-ocr-deu/tesseract-ocr-eng installieren.") from error
missing = set(language.split("+")) - {line.strip() for line in languages}
if missing:
raise RuntimeError("OCR-Sprachpakete fehlen: " + ", ".join(sorted(missing)))
def create_document(inputs, output, resolution, use_postprocess=False, profile="document", strict=False,
ocr=False, ocr_language="deu+eng", background_cleanup=False):
if not inputs: if not inputs:
raise ValueError("Keine Seiten vorhanden") raise ValueError("Keine Seiten vorhanden")
if not math.isfinite(resolution) or resolution <= 0:
raise ValueError("Ungültige Scan-Auflösung")
output = Path(output) output = Path(output)
file_format = {".pdf": "PDF", ".png": "PNG", ".tiff": "TIFF"}.get(output.suffix.lower()) file_format = {".pdf": "PDF", ".png": "PNG", ".tiff": "TIFF"}.get(output.suffix.lower())
if not file_format: if not file_format:
raise ValueError("Unbekanntes Ausgabeformat") raise ValueError("Unbekanntes Ausgabeformat")
if file_format != "PDF" and len(inputs) != 1: if (len(inputs) != 1 or ocr) and file_format != "PDF":
raise ValueError("Mehrseitige Scans benötigen PDF als Ausgabeformat") raise ValueError("Mehrseitige Scans und OCR benötigen PDF als Ausgabeformat")
if ocr:
check_ocr(ocr_language)
warnings = [] warnings = []
processor = None processor = None
@@ -27,47 +62,92 @@ def create_document(inputs, output, resolution, use_postprocess=False, profile="
if strict: if strict:
raise raise
warnings.append("OpenCV fehlt; alle Seiten wurden ohne Bildkorrektur übernommen.") warnings.append("OpenCV fehlt; alle Seiten wurden ohne Bildkorrektur übernommen.")
if background_cleanup:
from scan_background import clean_scan_background
partial = output.with_name(output.name + ".partial") partial = output.with_name(output.name + ".partial")
try: try:
for index, input_path in enumerate(inputs): with tempfile.TemporaryDirectory(prefix="prepared-", dir=output.parent) as directory:
input_path = Path(input_path) prepared = []
image_path = input_path for index, input_path in enumerate(inputs):
corrected = input_path.with_name(input_path.stem + ".corrected.png") image_path = Path(input_path)
if processor: corrected = Path(directory) / f"corrected-{index:06}.png"
try: if processor:
processor(input_path, corrected, profile) try:
image_path = corrected processor(image_path, corrected, profile)
except Exception: image_path = corrected
if strict: except Exception:
raise if strict:
warnings.append(f"Seite {index + 1}: Bildkorrektur fehlgeschlagen; Rohscan übernommen.") raise
warnings.append(f"Seite {index + 1}: Bildkorrektur fehlgeschlagen; Rohscan übernommen.")
if background_cleanup:
cleaned = Path(directory) / f"cleaned-{index:06}.png"
clean_scan_background(image_path, cleaned)
image_path = cleaned
# Only one decoded page is kept in memory, even for large duplex stacks. # Only one decoded page is kept in memory, even for duplex stacks.
with Image.open(image_path) as image: with Image.open(image_path) as image:
with image.convert("RGB") as rgb: with image.convert("RGB") as rgb:
if file_format == "PDF": if ocr:
rgb.save(partial, "PDF", resolution=resolution, quality=90, append=index > 0) page = Path(directory).resolve() / f"page-{index:06}.png"
else: rgb.save(page, "PNG", dpi=(resolution, resolution))
rgb.save(partial, file_format, dpi=(resolution, resolution)) prepared.append(page)
corrected.unlink(missing_ok=True) elif file_format == "PDF":
partial.replace(output) rgb.save(partial, "PDF", resolution=resolution, quality=90, append=index > 0)
else:
rgb.save(partial, file_format, dpi=(resolution, resolution))
corrected.unlink(missing_ok=True)
if background_cleanup:
cleaned.unlink(missing_ok=True)
if ocr:
page_list = Path(directory) / "pages.txt"
if any("\n" in str(page) or "\r" in str(page) for page in prepared):
raise ValueError("Das Scan-Arbeitsverzeichnis darf keinen Zeilenumbruch enthalten")
page_list.write_text("\n".join(map(str, prepared)) + "\n", encoding="utf8")
prefix = Path(directory) / "searchable"
run_ocr_command(["tesseract", str(page_list), str(prefix), "-l", ocr_language,
"--dpi", str(round(resolution)), "pdf"])
searchable = prefix.with_suffix(".pdf")
with PdfParser.PdfParser(filename=str(searchable)) as pdf:
if len(pdf.pages) != len(inputs):
raise RuntimeError("OCR-PDF ist unvollständig; Rohscans bleiben erhalten")
searchable.replace(partial)
partial.replace(output)
finally: finally:
partial.unlink(missing_ok=True) partial.unlink(missing_ok=True)
return {"pageCount": len(inputs), "warnings": warnings} return {"pageCount": len(inputs), "warnings": warnings}
def interrupted(*_):
raise SystemExit("Scan-Verarbeitung abgebrochen")
def main(): def main():
# Let finally blocks stop Tesseract and remove incomplete PDFs on agent timeout.
signal.signal(signal.SIGTERM, interrupted)
parser = argparse.ArgumentParser(description="Scan-Seiten zu einer Datei zusammenführen") parser = argparse.ArgumentParser(description="Scan-Seiten zu einer Datei zusammenführen")
parser.add_argument("--inputs", nargs="+", required=True) parser.add_argument("--inputs", nargs="+")
parser.add_argument("--output", required=True) parser.add_argument("--output")
parser.add_argument("--resolution", type=float, required=True) parser.add_argument("--resolution", type=float, default=300)
parser.add_argument("--profile", choices=["document", "receipt", "raw"], default="document") parser.add_argument("--profile", choices=["document", "receipt", "raw"], default="document")
parser.add_argument("--postprocess", action="store_true") parser.add_argument("--postprocess", action="store_true")
parser.add_argument("--strict", action="store_true") parser.add_argument("--strict", action="store_true")
parser.add_argument("--background-cleanup", action="store_true")
parser.add_argument("--ocr", action="store_true")
parser.add_argument("--ocr-language", default="deu+eng")
parser.add_argument("--check-runtime", action="store_true")
args = parser.parse_args() args = parser.parse_args()
print(json.dumps(create_document(args.inputs, args.output, args.resolution, args.postprocess, args.profile, args.strict))) if args.check_runtime:
if args.background_cleanup or (args.postprocess and args.strict):
import cv2, numpy
if args.ocr:
check_ocr(args.ocr_language)
print(json.dumps({"ready": True}))
return
if not args.inputs or not args.output:
parser.error("--inputs und --output sind erforderlich")
print(json.dumps(create_document(args.inputs, args.output, args.resolution, args.postprocess, args.profile,
args.strict, args.ocr, args.ocr_language, args.background_cleanup)))
if __name__ == "__main__": if __name__ == "__main__":

View File

@@ -62,6 +62,9 @@ export const loadConfig = (): AgentConfig => {
scanSource: optional(process.env.FEDEO_SCAN_SOURCE), scanSource: optional(process.env.FEDEO_SCAN_SOURCE),
scanTimeoutSeconds: numberFromEnv(process.env.FEDEO_SCAN_TIMEOUT_SECONDS, 900), scanTimeoutSeconds: numberFromEnv(process.env.FEDEO_SCAN_TIMEOUT_SECONDS, 900),
scanPostprocess: booleanFromEnv(process.env.FEDEO_SCAN_POSTPROCESS, false), scanPostprocess: booleanFromEnv(process.env.FEDEO_SCAN_POSTPROCESS, false),
scanBackgroundCleanup: booleanFromEnv(process.env.FEDEO_SCAN_BACKGROUND_CLEANUP, true),
scanOcr: booleanFromEnv(process.env.FEDEO_SCAN_OCR, true),
scanOcrLanguage: optional(process.env.FEDEO_SCAN_OCR_LANGUAGE) || "deu+eng",
postprocessProfile: postprocessProfileFromEnv(process.env.FEDEO_SCAN_POSTPROCESS_PROFILE), postprocessProfile: postprocessProfileFromEnv(process.env.FEDEO_SCAN_POSTPROCESS_PROFILE),
postprocessPython: optional(process.env.FEDEO_SCAN_POSTPROCESS_PYTHON) || defaultPostprocessPython(), postprocessPython: optional(process.env.FEDEO_SCAN_POSTPROCESS_PYTHON) || defaultPostprocessPython(),
postprocessStrict: booleanFromEnv(process.env.FEDEO_SCAN_POSTPROCESS_STRICT, false), postprocessStrict: booleanFromEnv(process.env.FEDEO_SCAN_POSTPROCESS_STRICT, false),

View File

@@ -7,6 +7,7 @@ import { loadConfig } from "./config.js"
import { log } from "./logger.js" import { log } from "./logger.js"
import { listPrinters } from "./print/cups.js" import { listPrinters } from "./print/cups.js"
import { hasSane, listScanners, runScan } from "./scan/sane.js" import { hasSane, listScanners, runScan } from "./scan/sane.js"
import { processingCapabilities } from "./scan/postprocess.js"
import { AgentHeartbeat } from "./types.js" import { AgentHeartbeat } from "./types.js"
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
@@ -19,6 +20,7 @@ const stringifyError = (error: unknown) => {
const main = async () => { const main = async () => {
const config = loadConfig() const config = loadConfig()
const api = new FedeoApi(config) const api = new FedeoApi(config)
const processing = await processingCapabilities(config)
log.info("FEDEO Geräte-Agent startet", { log.info("FEDEO Geräte-Agent startet", {
platform: process.platform, platform: process.platform,
@@ -38,6 +40,7 @@ const main = async () => {
const heartbeatPayload: AgentHeartbeat = { const heartbeatPayload: AgentHeartbeat = {
capabilities: { capabilities: {
scan: scanAvailable, scan: scanAvailable,
...processing,
print: printerNames.length > 0, print: printerNames.length > 0,
platform: process.platform, platform: process.platform,
}, },

View File

@@ -6,15 +6,34 @@ import { runCommand } from "../commands.js"
const agentRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..") const agentRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..")
const documentScript = path.join(agentRoot, "scripts/scan_document.py") const documentScript = path.join(agentRoot, "scripts/scan_document.py")
export const checkScanRuntime = async (config: AgentConfig, postprocess: boolean) => { type ProcessingOptions = { ocr: boolean; ocrLanguage: string; backgroundCleanup: boolean }
const imports = postprocess && config.postprocessStrict ? "import PIL, cv2, numpy" : "import PIL"
export const checkScanRuntime = async (config: AgentConfig, postprocess: boolean, options: ProcessingOptions) => {
const args = [documentScript, "--check-runtime", "--ocr-language", options.ocrLanguage]
if (postprocess) args.push("--postprocess")
if (config.postprocessStrict) args.push("--strict")
if (options.backgroundCleanup) args.push("--background-cleanup")
if (options.ocr) args.push("--ocr")
try { try {
const result = await runCommand(config.postprocessPython, ["-c", imports], { timeoutMs: 10_000 }) const result = await runCommand(config.postprocessPython, args, { timeoutMs: 10_000 })
if (result.code === 0) return if (result.code === 0) return
} catch { throw new Error(result.stderr.trim())
} catch (error) {
// Report missing runtimes before the scanner consumes any paper. // Report missing runtimes before the scanner consumes any paper.
const detail = error instanceof Error ? error.message : String(error)
throw new Error("Scan-Verarbeitung nicht verfügbar. Bitte den Agenten mit Python/Pillow, OpenCV und Tesseract samt OCR-Sprachpaketen installieren. " + detail)
} }
throw new Error("Python/Pillow (bei strikter Nachbearbeitung zusätzlich OpenCV) fehlt. Bitte npm run setup:opencv ausführen oder python3-pil installieren.") }
export const processingCapabilities = async (config: AgentConfig) => {
const available = async (options: ProcessingOptions) => {
try { await checkScanRuntime(config, false, options); return true } catch { return false }
}
const [ocr, backgroundCleanup] = await Promise.all([
available({ ocr: true, ocrLanguage: config.scanOcrLanguage, backgroundCleanup: false }),
available({ ocr: false, ocrLanguage: config.scanOcrLanguage, backgroundCleanup: true }),
])
return { ocr, backgroundCleanup }
} }
export const createScanDocument = async ( export const createScanDocument = async (
@@ -23,11 +42,14 @@ export const createScanDocument = async (
outputPath: string, outputPath: string,
resolution: number, resolution: number,
postprocess: boolean, postprocess: boolean,
profile: AgentConfig["postprocessProfile"] profile: AgentConfig["postprocessProfile"],
options: ProcessingOptions
): Promise<ScanResult> => { ): Promise<ScanResult> => {
const args = [documentScript, "--output", outputPath, "--resolution", String(resolution), "--profile", profile] const args = [documentScript, "--output", outputPath, "--resolution", String(resolution), "--profile", profile]
if (postprocess) args.push("--postprocess") if (postprocess) args.push("--postprocess")
if (config.postprocessStrict) args.push("--strict") if (config.postprocessStrict) args.push("--strict")
if (options.backgroundCleanup) args.push("--background-cleanup")
if (options.ocr) args.push("--ocr", "--ocr-language", options.ocrLanguage)
args.push("--inputs", ...pages) args.push("--inputs", ...pages)
const result = await runCommand(config.postprocessPython, args, { const result = await runCommand(config.postprocessPython, args, {

View File

@@ -20,7 +20,9 @@ export const hasSane = () => commandExists("scanimage")
export const listScanners = async () => { export const listScanners = async () => {
if (!await hasSane()) return [] if (!await hasSane()) return []
const result = await runCommand("scanimage", ["-L"], { timeoutMs: 10_000 }) // SANE also probes network backends; these can take more than ten seconds
// even when the USB scanner is already available.
const result = await runCommand("scanimage", ["-L"], { timeoutMs: 30_000 })
if (result.code !== 0) return [] if (result.code !== 0) return []
return result.stdout.split(/\r?\n/) return result.stdout.split(/\r?\n/)
.map((line) => line.trim().match(/^device `([^']+)'/)?.[1]) .map((line) => line.trim().match(/^device `([^']+)'/)?.[1])
@@ -51,7 +53,12 @@ export const runScan = async (
const postprocess = booleanSetting(settings, "postprocess", config.scanPostprocess) const postprocess = booleanSetting(settings, "postprocess", config.scanPostprocess)
const profile = stringSetting(settings, "postprocessProfile") || config.postprocessProfile const profile = stringSetting(settings, "postprocessProfile") || config.postprocessProfile
if (!["document", "receipt", "raw"].includes(profile)) throw new Error("Unbekanntes Nachbearbeitungsprofil") if (!["document", "receipt", "raw"].includes(profile)) throw new Error("Unbekanntes Nachbearbeitungsprofil")
await checkScanRuntime(config, postprocess) const processing = {
backgroundCleanup: booleanSetting(settings, "backgroundCleanup", config.scanBackgroundCleanup),
ocr: format === "pdf" && booleanSetting(settings, "ocr", config.scanOcr),
ocrLanguage: stringSetting(settings, "ocrLanguage") || config.scanOcrLanguage,
}
await checkScanRuntime(config, postprocess, processing)
// Each physical scan has its own directory. A filename cannot escape it or overwrite another job. // Each physical scan has its own directory. A filename cannot escape it or overwrite another job.
await mkdir(config.workDir, { recursive: true, mode: 0o700 }) await mkdir(config.workDir, { recursive: true, mode: 0o700 })
@@ -109,8 +116,8 @@ export const runScan = async (
throw new Error("Der Duplex-Scan ist unvollständig: Zu einer Vorderseite fehlt die Rückseite.") throw new Error("Der Duplex-Scan ist unvollständig: Zu einer Vorderseite fehlt die Rückseite.")
} }
await reportProgress(`${pages.length} Seiten gescannt. Datei wird erstellt...`) await reportProgress(`${pages.length} Seiten gescannt. ${processing.ocr ? "Durchsuchbares PDF mit OCR wird erstellt..." : "Datei wird erstellt..."}`)
return await createScanDocument(config, pages, outputPath, resolution, postprocess, profile as AgentConfig["postprocessProfile"]) return await createScanDocument(config, pages, outputPath, resolution, postprocess, profile as AgentConfig["postprocessProfile"], processing)
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : String(error) const message = error instanceof Error ? error.message : String(error)
throw new Error(`${message} Rohscans bleiben zur Wiederherstellung in ${jobDir}.`) throw new Error(`${message} Rohscans bleiben zur Wiederherstellung in ${jobDir}.`)

View File

@@ -11,6 +11,9 @@ export type AgentConfig = {
scanSource?: string scanSource?: string
scanTimeoutSeconds: number scanTimeoutSeconds: number
scanPostprocess: boolean scanPostprocess: boolean
scanBackgroundCleanup: boolean
scanOcr: boolean
scanOcrLanguage: string
postprocessProfile: "document" | "receipt" | "raw" postprocessProfile: "document" | "receipt" | "raw"
postprocessPython: string postprocessPython: string
postprocessStrict: boolean postprocessStrict: boolean
@@ -20,6 +23,8 @@ export type AgentHeartbeat = {
capabilities: { capabilities: {
scan: boolean scan: boolean
print: boolean print: boolean
ocr: boolean
backgroundCleanup: boolean
platform: NodeJS.Platform platform: NodeJS.Platform
} }
scannerNames: string[] scannerNames: string[]

View File

@@ -14,6 +14,8 @@ const agentId = 'b48c0e33-1abd-4b11-89fa-1bb7b39a3057'
const token = 'fedeo_agent_' + 'a'.repeat(64) const token = 'fedeo_agent_' + 'a'.repeat(64)
const options = { agentId, token, backendUrl: 'https://fedeo.example.com/backend', autostart: true } const options = { agentId, token, backendUrl: 'https://fedeo.example.com/backend', autostart: true }
const execute = promisify(execFile) const execute = promisify(execFile)
const hasSystemdAnalyze = process.platform === 'linux' && await execute('systemd-analyze', ['--version'])
.then(() => true, error => { if (error.code === 'ENOENT') return false; throw error })
test('backend URLs preserve reverse-proxy paths and reject credentials or non-HTTP protocols', () => { test('backend URLs preserve reverse-proxy paths and reject credentials or non-HTTP protocols', () => {
assert.equal(agentBackendUrl('/backend/', 'https://fedeo.example.com'), options.backendUrl) assert.equal(agentBackendUrl('/backend/', 'https://fedeo.example.com'), options.backendUrl)
@@ -50,11 +52,15 @@ for (const start of [false, true]) {
await writeFile(path.join(bin, 'scanimage'), '#!/bin/sh\nexit 0\n', { mode: 0o755 }) await writeFile(path.join(bin, 'scanimage'), '#!/bin/sh\nexit 0\n', { mode: 0o755 })
await writeFile(path.join(bin, 'id'), '#!/bin/sh\nprintf "1000\\n"\n', { mode: 0o755 }) await writeFile(path.join(bin, 'id'), '#!/bin/sh\nprintf "1000\\n"\n', { mode: 0o755 })
await writeFile(path.join(bin, 'systemctl'), '#!/bin/sh\nprintf "%s\\n" "$*" >> "$FEDEO_TEST_SYSTEMD_LOG"\n', { mode: 0o755 }) await writeFile(path.join(bin, 'systemctl'), '#!/bin/sh\nprintf "%s\\n" "$*" >> "$FEDEO_TEST_SYSTEMD_LOG"\n', { mode: 0o755 })
const realPython = (await execute('sh', ['-c', 'command -v python3'])).stdout.trim()
await writeFile(path.join(bin, 'python3'), '#!/bin/sh\nif [ "$1" = "-c" ] && [ "$2" = "import PIL, cv2, numpy" ]; then exit 0; fi\nexec "$FEDEO_TEST_PYTHON" "$@"\n', { mode: 0o755 })
await writeFile(path.join(bin, 'tesseract'), '#!/bin/sh\nprintf "deu\\neng\\n"\n', { mode: 0o755 })
const env = { const env = {
...process.env, PATH: bin + ':' + process.env.PATH, ...process.env, PATH: bin + ':' + process.env.PATH,
XDG_DATA_HOME: path.join(temp, 'data with spaces %'), XDG_DATA_HOME: path.join(temp, 'data with spaces %'),
XDG_CONFIG_HOME: path.join(temp, 'config'), XDG_STATE_HOME: path.join(temp, 'state'), XDG_CONFIG_HOME: path.join(temp, 'config'), XDG_STATE_HOME: path.join(temp, 'state'),
FEDEO_TEST_SYSTEMD_LOG: path.join(temp, 'systemd.log'), FEDEO_TEST_SYSTEMD_LOG: path.join(temp, 'systemd.log'),
FEDEO_TEST_PYTHON: realPython,
} }
const scriptPath = path.join(temp, 'install.sh') const scriptPath = path.join(temp, 'install.sh')
await writeFile(scriptPath, renderAgentInstaller(bundle, options)) await writeFile(scriptPath, renderAgentInstaller(bundle, options))
@@ -70,9 +76,13 @@ for (const start of [false, true]) {
for (const [file, encoded] of Object.entries(bundle.files)) { for (const [file, encoded] of Object.entries(bundle.files)) {
assert.equal((await readFile(path.join(agentDir, file))).toString('base64'), encoded) assert.equal((await readFile(path.join(agentDir, file))).toString('base64'), encoded)
} }
const unit = await readFile(path.join(env.XDG_CONFIG_HOME, 'systemd/user', 'fedeo-device-agent-' + agentId + '.service'), 'utf8') const unitPath = path.join(env.XDG_CONFIG_HOME, 'systemd/user', 'fedeo-device-agent-' + agentId + '.service')
const unit = await readFile(unitPath, 'utf8')
assert.ok(unit.includes('data with spaces %%')) assert.ok(unit.includes('data with spaces %%'))
assert.ok(!unit.includes(token)) assert.ok(!unit.includes(token))
await t.test('systemd accepts the generated service, including a working directory with spaces and percent signs', { skip: !hasSystemdAnalyze }, async () => {
await execute('systemd-analyze', ['--user', 'verify', unitPath])
})
const calls = await readFile(env.FEDEO_TEST_SYSTEMD_LOG, 'utf8') const calls = await readFile(env.FEDEO_TEST_SYSTEMD_LOG, 'utf8')
assert.equal(calls.includes('--user enable fedeo-device-agent-'), start) assert.equal(calls.includes('--user enable fedeo-device-agent-'), start)
assert.equal(calls.includes('--user restart fedeo-device-agent-'), start) assert.equal(calls.includes('--user restart fedeo-device-agent-'), start)

View File

@@ -3,16 +3,21 @@ import test, { TestContext } from "node:test"
import os from "node:os" import os from "node:os"
import path from "node:path" import path from "node:path"
import { chmod, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises" import { chmod, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises"
import { runScan } from "../src/scan/sane.js" import { listScanners, runScan } from "../src/scan/sane.js"
import { commandExists, runCommand } from "../src/commands.js" import { commandExists, runCommand } from "../src/commands.js"
import { AgentConfig, ScanJob } from "../src/types.js" import { AgentConfig, ScanJob } from "../src/types.js"
const fakeScanner = `#!/usr/bin/env python3 const fakeScanner = `#!/usr/bin/env python3
import json, os, sys import json, os, sys, time
from pathlib import Path from pathlib import Path
from PIL import Image from PIL import Image
args = sys.argv[1:] args = sys.argv[1:]
Path(os.environ['SCAN_TEST_CALL']).write_text(json.dumps(args)) Path(os.environ['SCAN_TEST_CALL']).write_text(json.dumps(args))
if args == ['-L']:
time.sleep(float(os.environ.get('SCAN_TEST_DISCOVERY_DELAY', '0')))
print("device " + chr(96) + "fujitsu:ScanSnap iX1600:test' is a FUJITSU scanner")
print("device " + chr(96) + "airscan:test' is a network scanner")
sys.exit(int(os.environ.get('SCAN_TEST_EXIT', '0')))
batch = next((arg.split('=', 1)[1] for arg in args if arg.startswith('--batch=')), None) batch = next((arg.split('=', 1)[1] for arg in args if arg.startswith('--batch=')), None)
single = args[args.index('--output-file') + 1] if '--output-file' in args else None single = args[args.index('--output-file') + 1] if '--output-file' in args else None
count = int(os.environ.get('SCAN_TEST_PAGES', '4')) if batch else 1 count = int(os.environ.get('SCAN_TEST_PAGES', '4')) if batch else 1
@@ -46,12 +51,25 @@ const setup = async (t: TestContext) => {
workDir: path.join(dir, "scans with 100% space"), scannerName: "fujitsu:ScanSnap iX1600:test", workDir: path.join(dir, "scans with 100% space"), scannerName: "fujitsu:ScanSnap iX1600:test",
scanFormat: "pdf", scanResolution: 300, scanMode: "Color", scanSource: "ADF Duplex", scanFormat: "pdf", scanResolution: 300, scanMode: "Color", scanSource: "ADF Duplex",
scanTimeoutSeconds: 10, scanPostprocess: false, postprocessProfile: "document", scanTimeoutSeconds: 10, scanPostprocess: false, postprocessProfile: "document",
scanBackgroundCleanup: false, scanOcr: false, scanOcrLanguage: "deu+eng",
postprocessPython: "python3", postprocessStrict: false, postprocessPython: "python3", postprocessStrict: false,
} }
const job: ScanJob = { id: "test-job", tenantId: 1, agentId: "test-agent", status: "running", settings: { paperSize: "A4" } } const job: ScanJob = { id: "test-job", tenantId: 1, agentId: "test-agent", status: "running", settings: { paperSize: "A4" } }
return { config, job, dir, args: async () => JSON.parse(await readFile(process.env.SCAN_TEST_CALL!, "utf8")) as string[] } return { config, job, dir, args: async () => JSON.parse(await readFile(process.env.SCAN_TEST_CALL!, "utf8")) as string[] }
} }
test("scanner discovery waits for network probes beyond the former ten-second limit", async (t) => {
await setup(t)
process.env.SCAN_TEST_DISCOVERY_DELAY = "10.2"
assert.deepEqual(await listScanners(), ["fujitsu:ScanSnap iX1600:test", "airscan:test"])
})
test("failed scanner discovery does not advertise devices from an unsuccessful command", async (t) => {
await setup(t)
process.env.SCAN_TEST_EXIT = "1"
assert.deepEqual(await listScanners(), [])
})
test("duplex batch saves all pages as PDF, accepts NO_DOCS and passes A4 geometry", async (t) => { test("duplex batch saves all pages as PDF, accepts NO_DOCS and passes A4 geometry", async (t) => {
const { config, job, args } = await setup(t) const { config, job, args } = await setup(t)
process.env.SCAN_TEST_EMPTY_FINAL = "1" process.env.SCAN_TEST_EMPTY_FINAL = "1"
@@ -126,6 +144,18 @@ test("missing image runtime is detected before feeding paper", async (t) => {
await assert.rejects(args(), /ENOENT/) await assert.rejects(args(), /ENOENT/)
}) })
test("missing requested OCR language fails before the scanner consumes paper", async (t) => {
const { config, job, dir, args } = await setup(t)
const tesseract = path.join(dir, "tesseract")
await writeFile(tesseract, "#!/bin/sh\nprintf 'List of available languages (1):\\neng\\n'\n")
await chmod(tesseract, 0o755)
job.settings!.ocr = true
job.settings!.ocrLanguage = "deu+eng"
await assert.rejects(runScan(config, job), /OCR-Sprachpakete fehlen: deu/)
await assert.rejects(args(), /ENOENT/)
await assert.rejects(readdir(config.workDir), /ENOENT/)
})
test("single-page scans work without batch and requested paths cannot escape the job directory", async (t) => { test("single-page scans work without batch and requested paths cannot escape the job directory", async (t) => {
const { config, job, args } = await setup(t) const { config, job, args } = await setup(t)
job.settings = { source: "Flatbed", format: "png" } job.settings = { source: "Flatbed", format: "png" }

View File

@@ -0,0 +1,64 @@
import sys
import unittest
from pathlib import Path
import cv2
import numpy as np
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
from scan_background import whiten_background
class ScanBackgroundTest(unittest.TestCase):
def receipt(self, background=45, paper=245):
image = np.full((1600, 1200, 3), background, dtype=np.uint8)
image[120:1440, 360:820] = paper
for y in range(220, 1350, 70):
cv2.putText(image, "BON 12,34 EUR", (375, y), cv2.FONT_HERSHEY_SIMPLEX, .7, (20, 20, 20), 2)
cv2.rectangle(image, (400, 140), (780, 180), (40, 80, 210), -1)
return image
def test_receipt_on_dark_or_gray_backing_keeps_canvas_text_and_colored_logo(self):
for background in (35, 140):
image = self.receipt(background)
result = whiten_background(image)
self.assertEqual(result.shape, image.shape)
self.assertTrue(np.all(result[:80] == 255))
self.assertTrue(np.all(result[:, :250] == 255))
self.assertTrue(np.array_equal(result[120:1440, 360:820], image[120:1440, 360:820]))
def test_slanted_receipt_is_whitened_without_rotating_or_cropping(self):
image = np.full((1600, 1200, 3), 55, dtype=np.uint8)
polygon = np.array([[450, 100], [890, 155], [720, 1450], [280, 1390]])
cv2.fillPoly(image, [polygon], (245, 245, 245))
cv2.putText(image, "FEDEO", (470, 700), cv2.FONT_HERSHEY_SIMPLEX, 1.5, (0, 0, 0), 3)
result = whiten_background(image)
self.assertEqual(result.shape, image.shape)
inside = np.zeros(image.shape[:2], dtype=np.uint8)
cv2.fillPoly(inside, [polygon], 255)
self.assertTrue(np.array_equal(result[inside > 0], image[inside > 0]))
self.assertTrue(np.all(result[:50] == 255))
def test_white_full_page_is_not_replaced_by_its_largest_text_box(self):
image = np.full((1600, 1200, 3), 255, dtype=np.uint8)
cv2.rectangle(image, (300, 400), (800, 1200), (30, 30, 30), 4)
cv2.putText(image, "Footer", (20, 1550), cv2.FONT_HERSHEY_SIMPLEX, 1, (0, 0, 0), 2)
self.assertTrue(np.array_equal(whiten_background(image), image))
def test_slanted_receipt_retains_every_paper_edge_pixel_at_300_dpi(self):
image = np.full((3508, 2480, 3), 45, dtype=np.uint8)
polygon = np.array([[820, 200], [1700, 340], [1250, 3300], [380, 3160]])
cv2.fillPoly(image, [polygon], (245, 245, 245))
inside = np.zeros(image.shape[:2], dtype=np.uint8)
cv2.fillPoly(inside, [polygon], 255)
result = whiten_background(image)
self.assertTrue(np.array_equal(result[inside > 0], image[inside > 0]))
self.assertTrue(np.all(result[:50] == 255))
def test_blank_and_ambiguous_low_contrast_pages_remain_unchanged(self):
for image in [np.full((300, 200, 3), 245, dtype=np.uint8), self.receipt(215, 230)]:
self.assertTrue(np.array_equal(whiten_background(image), image))
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,71 @@
import shutil
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from PIL import Image, ImageDraw, ImageFont, PdfParser
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
from scan_document import create_document, check_ocr
class ScanOcrTest(unittest.TestCase):
def test_missing_language_is_reported_before_processing(self):
with patch("scan_document.run_ocr_command", return_value="List of available languages (1):\neng\n"):
with self.assertRaisesRegex(RuntimeError, "deu"):
check_ocr("deu+eng")
with self.assertRaises(ValueError):
check_ocr("--bad-language")
@unittest.skipUnless(shutil.which("tesseract") and shutil.which("pdftotext"), "Tesseract and Poppler required")
def test_ocr_keeps_receipt_canvas_blank_back_and_page_order_with_real_extractable_text(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
pages = []
for index, text in enumerate(["FEDEO Bon 12345", "", "Zweite Seite 24680"]):
page = root / f"page-{index}.png"
image = Image.new("RGB", (1240, 1754), (45, 45, 45) if index == 0 else "white")
draw = ImageDraw.Draw(image)
if index == 0:
draw.rectangle((260, 150, 1000, 1550), fill="white")
if text:
draw.text((300, 300), text, fill="black", font=ImageFont.load_default(size=44))
draw.text((300, 390), "Summe EUR 12,34", fill="black", font=ImageFont.load_default(size=36))
image.save(page)
pages.append(page)
output = root / "searchable.pdf"
result = create_document(pages, output, 150, ocr=True, background_cleanup=True)
self.assertEqual(result["pageCount"], 3)
with PdfParser.PdfParser(filename=str(output)) as pdf:
self.assertEqual(len(pdf.pages), 3)
for reference in pdf.pages:
box = pdf.read_indirect(reference)[b"MediaBox"]
self.assertAlmostEqual(box[2], 1240 * 72 / 150, delta=.1)
self.assertAlmostEqual(box[3], 1754 * 72 / 150, delta=.1)
text = subprocess.check_output(["pdftotext", str(output), "-"], text=True)
extracted = text.split("\f")
self.assertIn("12345", extracted[0])
self.assertEqual(extracted[1].strip(), "")
self.assertIn("24680", extracted[2])
self.assertTrue(all(page.exists() for page in pages))
def test_ocr_failure_never_publishes_a_partial_document_or_removes_raw_pages(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
page = root / "raw.png"
Image.new("RGB", (400, 600), "white").save(page)
output = root / "failed.pdf"
with patch("scan_document.check_ocr"), patch("scan_document.run_ocr_command", side_effect=RuntimeError("OCR failed")):
with self.assertRaisesRegex(RuntimeError, "OCR failed"):
create_document([page], output, 300, ocr=True)
self.assertFalse(output.exists())
self.assertFalse(output.with_name("failed.pdf.partial").exists())
self.assertTrue(page.exists())
self.assertEqual(list(root.glob("prepared-*")), [])
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,51 @@
-- Forward repair: 0051-0053 were shipped without migration journal entries.
-- Keep existing agents/jobs when these SQL files were applied manually.
CREATE TABLE IF NOT EXISTS "instance_agents" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
"name" text NOT NULL,
"description" text,
"token_prefix" text NOT NULL,
"token_hash" text NOT NULL,
"active" boolean DEFAULT true NOT NULL,
"capabilities" jsonb DEFAULT '{"scan":true,"print":false}'::jsonb NOT NULL,
"scanner_names" jsonb DEFAULT '[]'::jsonb NOT NULL,
"printer_names" jsonb DEFAULT '[]'::jsonb NOT NULL,
"last_seen_at" timestamp with time zone,
"last_debug_info" jsonb,
CONSTRAINT "instance_agents_token_hash_unique" UNIQUE("token_hash")
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "instance_agent_scan_jobs" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
"tenant_id" bigint NOT NULL,
"agent_id" uuid NOT NULL,
"requested_by" uuid,
"status" text DEFAULT 'pending' NOT NULL,
"scanner_name" text,
"requested_filename" text,
"settings" jsonb DEFAULT '{}'::jsonb NOT NULL,
"target" jsonb DEFAULT '{}'::jsonb NOT NULL,
"agent_message" text,
"attempts" integer DEFAULT 0 NOT NULL,
"claimed_at" timestamp with time zone,
"finished_at" timestamp with time zone,
"file_id" uuid,
CONSTRAINT "instance_agent_scan_jobs_tenant_id_tenants_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id") ON DELETE cascade ON UPDATE cascade,
CONSTRAINT "instance_agent_scan_jobs_agent_id_instance_agents_id_fk" FOREIGN KEY ("agent_id") REFERENCES "public"."instance_agents"("id") ON DELETE cascade ON UPDATE cascade,
CONSTRAINT "instance_agent_scan_jobs_requested_by_auth_users_id_fk" FOREIGN KEY ("requested_by") REFERENCES "public"."auth_users"("id") ON DELETE set null ON UPDATE cascade,
CONSTRAINT "instance_agent_scan_jobs_file_id_files_id_fk" FOREIGN KEY ("file_id") REFERENCES "public"."files"("id") ON DELETE set null ON UPDATE cascade
);
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "instance_agent_scan_jobs_agent_status_idx" ON "instance_agent_scan_jobs" USING btree ("agent_id","status","created_at");
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "instance_agent_scan_jobs_tenant_idx" ON "instance_agent_scan_jobs" USING btree ("tenant_id","created_at");
--> statement-breakpoint
ALTER TABLE "instance_agents" ADD COLUMN IF NOT EXISTS "preferred_scanner_name" text;
--> statement-breakpoint
ALTER TABLE "instance_agents" ADD COLUMN IF NOT EXISTS "scan_defaults" jsonb DEFAULT '{"format":"pdf","resolution":300,"mode":"Color","source":null}'::jsonb NOT NULL;
--> statement-breakpoint
ALTER TABLE "instance_agents" ALTER COLUMN "scan_defaults" SET DEFAULT '{"format":"pdf","resolution":300,"mode":"Color","source":null,"postprocess":false,"postprocessProfile":"document"}'::jsonb;

View File

@@ -484,6 +484,13 @@
"when": 1790121600000, "when": 1790121600000,
"tag": "0071_statement_allocation_costcentre", "tag": "0071_statement_allocation_costcentre",
"breakpoints": true "breakpoints": true
},
{
"idx": 69,
"version": "7",
"when": 1790121600001,
"tag": "0072_restore_instance_scan_agents",
"breakpoints": true
} }
] ]
} }

View File

@@ -9,6 +9,7 @@ import authRoutes from "./routes/auth/auth";
import authRoutesAuthenticated from "./routes/auth/auth-authenticated"; import authRoutesAuthenticated from "./routes/auth/auth-authenticated";
import authPlugin from "./plugins/auth"; import authPlugin from "./plugins/auth";
import adminRoutes from "./routes/admin"; import adminRoutes from "./routes/admin";
import selfhostUpdateRoutes from "./routes/selfhostUpdates";
import corsPlugin from "./plugins/cors"; import corsPlugin from "./plugins/cors";
import queryConfigPlugin from "./plugins/queryconfig"; import queryConfigPlugin from "./plugins/queryconfig";
import dbPlugin from "./plugins/db"; import dbPlugin from "./plugins/db";
@@ -143,6 +144,7 @@ async function main() {
await subApp.register(meRoutes); await subApp.register(meRoutes);
await subApp.register(tenantRoutes); await subApp.register(tenantRoutes);
await subApp.register(adminRoutes); await subApp.register(adminRoutes);
await subApp.register(selfhostUpdateRoutes);
await subApp.register(resourceRoutesSpecial); await subApp.register(resourceRoutesSpecial);
await subApp.register(historyRoutes); await subApp.register(historyRoutes);
await subApp.register(fileRoutes); await subApp.register(fileRoutes);

View File

@@ -0,0 +1,91 @@
import { request } from "node:http";
import { z } from "zod";
const version = z.string().regex(/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/);
export const requestId = z.string().regex(/^[A-Za-z0-9][A-Za-z0-9_-]{7,79}$/);
export const checkBody = z.object({ requestId, version: version.optional() }).strict();
export const applyBody = z.object({ requestId, version, checkRequestId: requestId }).strict();
const errors = {
INVALID_REQUEST: "Ungültige Anfrage.", NOT_FOUND: "Auftrag nicht gefunden.",
BUSY: "Eine Prüfung oder ein Update läuft bereits.", SHUTTING_DOWN: "Der Update-Dienst wird beendet.",
REQUEST_CONFLICT: "Diese Auftrags-ID wurde bereits für eine andere Anfrage verwendet.",
CHECK_REQUIRED: "Bitte die Zielversion erneut prüfen.",
COMPOSE_REVIEW_REQUIRED: "Die Compose-Datei muss zuerst auf dem Host geprüft und übernommen werden.",
RECOVERY_REQUIRED: "Ein vorheriges Update benötigt Wiederherstellung auf dem Host.",
PLAN_CHANGED: "Release oder Konfiguration haben sich geändert. Bitte erneut prüfen.",
UPDATE_FAILED: "Auftrag fehlgeschlagen. Geschützte Protokolle auf dem Host prüfen.",
INTERRUPTED: "Der Auftrag wurde unterbrochen. Status auf dem Host prüfen.",
UPDATER_UNAVAILABLE: "Der Host-Update-Dienst ist nicht erreichbar. Dienst und Socket-Einbindung prüfen.",
UPDATER_PROTOCOL: "Der Host-Update-Dienst hat eine ungültige Antwort geliefert.",
};
type ErrorCode = keyof typeof errors;
const knownError = (code: unknown): code is ErrorCode => typeof code === "string" && Object.prototype.hasOwnProperty.call(errors, code);
export class UpdaterError extends Error {
constructor(public code: ErrorCode, public status = 503) { super(errors[code]); }
}
const state = z.object({
status: z.enum(["idle", "queued", "running", "checked", "up_to_date", "succeeded", "failed", "interrupted", "recovered"]),
phase: z.enum(["preparing", "validating", "checked", "pulling", "maintenance", "backed_up", "applying",
"retiring_matrix", "migrating", "starting", "verifying", "complete", "resolved"]).optional(),
command: z.enum(["check", "apply"]).optional(), requestId: requestId.optional(),
version: version.optional(), previousVersion: version.optional(), revision: z.string().regex(/^[a-f0-9]{40}$/).optional(),
recoveryRequired: z.boolean().optional(), maintenance: z.boolean().optional(), mutationStarted: z.boolean().optional(),
startedAt: z.string().regex(/^[0-9T:+.Z-]{10,40}$/).optional(), updatedAt: z.string().regex(/^[0-9T:+.Z-]{10,40}$/).optional(),
});
const plan = state.omit({ status: true }).extend({
updateAvailable: z.boolean(), requiresComposeAdoption: z.boolean(),
estimatedBackupBytes: z.number().nonnegative().finite().optional(),
retiredServices: z.array(z.enum(["matrix-db", "matrix-synapse", "matrix-redis", "matrix-element",
"matrix-livekit", "matrix-rtc-jwt", "matrix-turn", "matrix-well-known"])),
});
const job = state.extend({ plan: plan.optional(), progress: state.optional(),
error: z.object({ code: z.string() }).transform(({ code }) => ({
code: knownError(code) ? code : "UPDATE_FAILED",
message: knownError(code) ? errors[code] : errors.UPDATE_FAILED,
})).optional(),
});
const status = z.object({ apiVersion: z.literal(1), acceptingRequests: z.boolean(), engine: state,
installed: state.pick({ version: true, revision: true }), request: job.optional(),
});
// No caller-controlled host, URL, socket, headers or command can reach the privileged service.
export function createUpdaterClient(socketPath = "/run/fedeo-updater/control.sock", timeoutMs = 5000) {
return (method: "GET" | "POST", path: string, body?: unknown): Promise<{ status: number; body: unknown }> => {
if (!((method === "GET" && (path === "/v1/status" || /^\/v1\/requests\/[A-Za-z0-9][A-Za-z0-9_-]{7,79}$/.test(path)))
|| (method === "POST" && ["/v1/check", "/v1/apply"].includes(path)))) {
return Promise.reject(new UpdaterError("INVALID_REQUEST", 400));
}
return new Promise((resolve, reject) => {
const payload = body === undefined ? undefined : JSON.stringify(body);
const req = request({ socketPath, path, method, agent: false, headers: payload ? {
"Content-Type": "application/json", "Content-Length": Buffer.byteLength(payload),
} : {} }, res => {
const chunks: Buffer[] = [];
let bytes = 0;
res.on("data", chunk => {
bytes += chunk.length;
if (bytes > 65536) req.destroy(new UpdaterError("UPDATER_PROTOCOL", 502));
else chunks.push(chunk);
});
res.on("error", () => reject(new UpdaterError("UPDATER_UNAVAILABLE")));
res.on("end", () => {
try {
const data = JSON.parse(Buffer.concat(chunks).toString("utf8"));
const code = data?.error?.code;
if (![200, 202].includes(res.statusCode || 0)) {
throw knownError(code) && [400, 404, 409, 503].includes(res.statusCode!)
? new UpdaterError(code, res.statusCode) : new UpdaterError("UPDATER_PROTOCOL", 502);
}
resolve({ status: res.statusCode!, body: (path === "/v1/status" ? status : job).parse(data) });
} catch (error) {
reject(error instanceof UpdaterError ? error : new UpdaterError("UPDATER_PROTOCOL", 502));
}
});
});
const timer = setTimeout(() => req.destroy(new UpdaterError("UPDATER_UNAVAILABLE")), timeoutMs);
req.on("close", () => clearTimeout(timer));
req.on("error", error => reject(error instanceof UpdaterError ? error : new UpdaterError("UPDATER_UNAVAILABLE")));
req.end(payload);
});
};
}

View File

@@ -29,6 +29,7 @@ import {
importTenantFullExportArchive, importTenantFullExportArchive,
} from "../utils/tenantFullExport"; } from "../utils/tenantFullExport";
import type { TenantFullExport } from "../utils/tenantFullExport"; import type { TenantFullExport } from "../utils/tenantFullExport";
import { requireSystemAdmin } from "../utils/requireSystemAdmin";
import { buildSystemStatus } from "../modules/system-status.service"; import { buildSystemStatus } from "../modules/system-status.service";
import { s3 } from "../utils/s3"; import { s3 } from "../utils/s3";
import { secrets } from "../utils/secrets"; import { secrets } from "../utils/secrets";
@@ -563,29 +564,7 @@ export default async function adminRoutes(server: FastifyInstance) {
return job; return job;
}; };
const requireAdmin = async (req: FastifyRequest, reply: FastifyReply) => { const requireAdmin = (req: FastifyRequest, reply: FastifyReply) => requireSystemAdmin(server, req, reply);
if (!req.user?.user_id) {
reply.code(401).send({ error: "Unauthorized" });
return null;
}
const [currentUser] = await server.db
.select({
id: authUsers.id,
email: authUsers.email,
is_admin: authUsers.is_admin,
})
.from(authUsers)
.where(eq(authUsers.id, req.user.user_id))
.limit(1);
if (!currentUser?.is_admin) {
reply.code(403).send({ error: "Admin access required" });
return null;
}
return currentUser;
};
const ensurePortalRoleForTenant = async (tenantId: number, createdBy: string) => { const ensurePortalRoleForTenant = async (tenantId: number, createdBy: string) => {
const existingRoles = await server.db const existingRoles = await server.db

View File

@@ -152,6 +152,7 @@ export default async function instanceAgentRoutes(server: FastifyInstance) {
active: instanceAgents.active, active: instanceAgents.active,
preferredScannerName: instanceAgents.preferredScannerName, preferredScannerName: instanceAgents.preferredScannerName,
scanDefaults: instanceAgents.scanDefaults, scanDefaults: instanceAgents.scanDefaults,
capabilities: instanceAgents.capabilities,
}) })
.from(instanceAgents) .from(instanceAgents)
.where(eq(instanceAgents.id, body.agentId)) .where(eq(instanceAgents.id, body.agentId))
@@ -161,6 +162,14 @@ export default async function instanceAgentRoutes(server: FastifyInstance) {
return reply.code(404).send({ error: "Active agent not found" }) return reply.code(404).send({ error: "Active agent not found" })
} }
const settings = { ...((agent.scanDefaults || {}) as Record<string, any>), ...(body.settings || {}) }
const enabled = (value: unknown) => value === true || (typeof value === "string" && ["1", "true", "yes", "ja", "on"].includes(value.toLowerCase().trim()))
const capabilities = agent.capabilities as Record<string, any>
if (((settings.format || "pdf") === "pdf" && enabled(settings.ocr) && !capabilities?.ocr) ||
(enabled(settings.backgroundCleanup) && !capabilities?.backgroundCleanup)) {
return reply.code(409).send({ error: "Bitte den Scanner-Agenten mit Tesseract und OpenCV aktualisieren. Die angeforderte Scan-Verarbeitung ist dort noch nicht verfügbar." })
}
const [job] = await server.db const [job] = await server.db
.insert(instanceAgentScanJobs) .insert(instanceAgentScanJobs)
.values({ .values({
@@ -169,10 +178,7 @@ export default async function instanceAgentRoutes(server: FastifyInstance) {
requestedBy: req.user?.user_id, requestedBy: req.user?.user_id,
scannerName: body.scannerName || agent.preferredScannerName, scannerName: body.scannerName || agent.preferredScannerName,
requestedFilename: body.requestedFilename, requestedFilename: body.requestedFilename,
settings: { settings,
...((agent.scanDefaults || {}) as Record<string, any>),
...(body.settings || {}),
},
target: body.target || {}, target: body.target || {},
}) })
.returning() .returning()

View File

@@ -0,0 +1,38 @@
import type { FastifyInstance } from "fastify";
import { applyBody, checkBody, createUpdaterClient, requestId, UpdaterError } from "../modules/selfhost-updater";
import { requireSystemAdmin } from "../utils/requireSystemAdmin";
import { getBuildInfo } from "../utils/buildInfo";
export default async function selfhostUpdateRoutes(server: FastifyInstance, options: { client?: ReturnType<typeof createUpdaterClient> }) {
const client = options.client || createUpdaterClient();
server.addHook("preHandler", async (req, reply) => {
reply.header("Cache-Control", "no-store");
await requireSystemAdmin(server, req, reply);
});
const forward = async (reply: any, method: "GET" | "POST", path: string, body?: unknown) => {
try {
const result = await client(method, path, body);
return reply.code(result.status).send(path === "/v1/status"
? { ...result.body as object, backend: getBuildInfo() } : result.body);
} catch (error) {
const safe = error instanceof UpdaterError ? error : new UpdaterError("UPDATER_UNAVAILABLE");
return reply.code(safe.status).send({ error: { code: safe.code, message: safe.message } });
}
};
server.get("/admin/updates/status", async (_req, reply) => forward(reply, "GET", "/v1/status"));
server.get<{ Params: { id: string } }>("/admin/updates/requests/:id", async (req, reply) => {
if (!requestId.safeParse(req.params.id).success) return reply.code(400).send({ error: { code: "INVALID_REQUEST" } });
return forward(reply, "GET", `/v1/requests/${req.params.id}`);
});
for (const command of ["check", "apply"] as const) {
server.post(`/admin/updates/${command}`, { bodyLimit: 4096 }, async (req, reply) => {
// The auth plugin verifies this JWT. Cookies alone cannot trigger host operations (CSRF).
if (!/^Bearer \S{11,}$/.test(req.headers.authorization || "")) {
return reply.code(403).send({ error: { code: "BEARER_REQUIRED", message: "Bitte erneut anmelden." } });
}
const parsed = (command === "apply" ? applyBody : checkBody).safeParse(req.body);
if (!parsed.success) return reply.code(400).send({ error: { code: "INVALID_REQUEST" } });
return forward(reply, "POST", `/v1/${command}`, parsed.data);
});
}
}

View File

@@ -0,0 +1,18 @@
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { eq } from "drizzle-orm";
import { authUsers } from "../../db/schema/auth_users";
export async function requireSystemAdmin(server: FastifyInstance, req: FastifyRequest, reply: FastifyReply) {
if (!req.user?.user_id) {
reply.code(401).send({ error: "Unauthorized" });
return null;
}
// Read the current global role; tenant roles and stale JWT claims do not grant access.
const [user] = await server.db.select({ id: authUsers.id, email: authUsers.email, is_admin: authUsers.is_admin })
.from(authUsers).where(eq(authUsers.id, req.user.user_id)).limit(1);
if (!user?.is_admin) {
reply.code(403).send({ error: "Admin access required" });
return null;
}
return user;
}

View File

@@ -0,0 +1,133 @@
import assert from "node:assert/strict"
import test from "node:test"
import { execFile } from "node:child_process"
import { promisify } from "node:util"
import { readFile, mkdir, mkdtemp, writeFile, rm } from "node:fs/promises"
import { readFileSync } from "node:fs"
import path from "node:path"
import os from "node:os"
import { randomUUID } from "node:crypto"
import { Pool } from "pg"
import { drizzle } from "drizzle-orm/node-postgres"
import { migrate } from "drizzle-orm/node-postgres/migrator"
import { readMigrationFiles } from "drizzle-orm/migrator"
import Fastify from "fastify"
import routes from "../src/routes/instanceAgents"
const migrationsFolder = path.resolve(__dirname, "../db/migrations") + path.sep
const repairTag = "0072_restore_instance_scan_agents"
const journal = JSON.parse(readFileSync(migrationsFolder + "meta/_journal.json", "utf8")).entries
const repair = journal.find((entry: any) => entry.tag === repairTag)
const previousWatermark = Math.max(...journal.filter((entry: any) => entry.idx < repair?.idx).map((entry: any) => entry.when))
const execute = promisify(execFile)
test("the scanner repair is registered after every previously shipped migration", () => {
assert.ok(repair, "scanner repair must be in the Drizzle journal")
assert.ok(repair.when > previousWatermark)
const pending = readMigrationFiles({ migrationsFolder }).filter(entry => entry.folderMillis > previousWatermark)
assert.ok(pending.some(entry => entry.sql.some(sql => sql.includes('CREATE TABLE IF NOT EXISTS "instance_agents"'))))
})
// Opt in with FEDEO_TEST_POSTGRES_IMAGE=postgres:16-alpine. This creates its own
// disposable database container; it never reads DATABASE_URL or application secrets.
test("migration repairs upgraded databases and preserves manually installed scanners", {
skip: !process.env.FEDEO_TEST_POSTGRES_IMAGE,
}, async t => {
// Isolate this repair from unrelated future migrations. Registration in the
// actual shipped journal is checked above; its timestamp remains unchanged.
const testMigrations = await mkdtemp(path.join(os.tmpdir(), "fedeo-scan-migrations-"))
t.after(() => rm(testMigrations, { recursive: true, force: true }))
await mkdir(path.join(testMigrations, "meta"))
await writeFile(path.join(testMigrations, "meta/_journal.json"), JSON.stringify({ version: "7", dialect: "postgresql", entries: [repair] }))
await writeFile(path.join(testMigrations, repairTag + ".sql"), await readFile(migrationsFolder + repairTag + ".sql"))
const container = "fedeo-scan-migration-" + randomUUID()
const password = randomUUID()
await execute("docker", ["run", "--rm", "-d", "--name", container,
"-e", "POSTGRES_PASSWORD=" + password, "-p", "127.0.0.1::5432",
process.env.FEDEO_TEST_POSTGRES_IMAGE!])
let admin: Pool | undefined
t.after(async () => {
try { await admin?.end() }
finally { await execute("docker", ["rm", "-f", container]) }
})
const { stdout } = await execute("docker", ["port", container, "5432/tcp"])
const port = Number(stdout.trim().split(":").at(-1))
admin = new Pool({ host: "127.0.0.1", port, user: "postgres", password, database: "postgres", connectionTimeoutMillis: 1000 })
for (let attempt = 0; ; attempt++) {
try { await admin.query("SELECT 1"); break } catch (error) {
if (attempt >= 80) throw error
await new Promise(resolve => setTimeout(resolve, 100))
}
}
for (const state of ["absent", "0051", "0052", "0053"]) {
await t.test(state, async () => {
const database = "scan_" + state
await admin.query('CREATE DATABASE "' + database + '"')
const pool = new Pool({ host: "127.0.0.1", port, user: "postgres", password, database, max: 1 })
const app = Fastify()
try {
await pool.query(`
CREATE TABLE tenants (id bigint PRIMARY KEY);
CREATE TABLE auth_users (id uuid PRIMARY KEY);
CREATE TABLE files (id uuid PRIMARY KEY);
INSERT INTO tenants VALUES (1);
CREATE SCHEMA drizzle;
CREATE TABLE drizzle.__drizzle_migrations (id serial PRIMARY KEY, hash text NOT NULL, created_at bigint);
`)
// An existing installation already passed 0071. Re-inserting old
// journal entries 0051-0053 would therefore NOT fix this database.
await pool.query("INSERT INTO drizzle.__drizzle_migrations (hash, created_at) VALUES ('previous-release', $1)", [previousWatermark])
if (state !== "absent") {
for (const file of ["0051_instance_scan_agents", "0052_instance_agent_scan_defaults", "0053_instance_agent_postprocess_defaults"]) {
if (file.slice(0, 4) <= state) await pool.query(await readFile(migrationsFolder + file + ".sql", "utf8"))
}
await pool.query(`
INSERT INTO instance_agents (id, name, token_prefix, token_hash)
VALUES ('00000000-0000-4000-8000-000000000001', 'Existing scanner', 'prefix', 'existing-hash');
INSERT INTO instance_agent_scan_jobs (tenant_id, agent_id, status, settings)
VALUES (1, '00000000-0000-4000-8000-000000000001', 'completed', '{"resolution":600}');
`)
if (state !== "0051") {
await pool.query(`UPDATE instance_agents SET preferred_scanner_name='existing-device', scan_defaults='{"resolution":600}'`)
}
}
const db = drizzle(pool)
await migrate(db, { migrationsFolder: testMigrations })
await migrate(db, { migrationsFolder: testMigrations })
// Also prove the repair SQL itself is safe after manual application.
await pool.query(await readFile(migrationsFolder + repairTag + ".sql", "utf8"))
app.decorate("db", db)
app.addHook("onRequest", async req => { req.user = { is_admin: true } as any })
await app.register(routes)
const listing = await app.inject({ method: "GET", url: "/instance-agents" })
assert.equal(listing.statusCode, 200, listing.body)
const agents = listing.json().agents
assert.equal(agents.length, state === "absent" ? 0 : 1)
if (state !== "absent") {
assert.equal(agents[0].name, "Existing scanner")
assert.equal((await pool.query("SELECT token_hash FROM instance_agents")).rows[0].token_hash, "existing-hash")
assert.equal((await pool.query("SELECT status, settings FROM instance_agent_scan_jobs")).rows[0].settings.resolution, 600)
if (state !== "0051") {
assert.equal(agents[0].preferredScannerName, "existing-device")
assert.deepEqual(agents[0].scanDefaults, { resolution: 600 })
}
}
const created = await app.inject({ method: "POST", url: "/instance-agents", payload: { name: "New scanner" } })
assert.equal(created.statusCode, 200, created.body)
assert.equal(created.json().agent.scanDefaults.postprocess, false)
assert.equal(created.json().agent.scanDefaults.postprocessProfile, "document")
assert.equal(created.json().agent.scanDefaults.format, "pdf")
assert.equal((await pool.query("SELECT count(*)::int AS count FROM drizzle.__drizzle_migrations WHERE created_at = $1", [repair.when])).rows[0].count, 1)
const indexes = (await pool.query("SELECT indexname FROM pg_indexes WHERE tablename='instance_agent_scan_jobs'")).rows.map(row => row.indexname)
assert.ok(indexes.includes("instance_agent_scan_jobs_agent_status_idx"))
assert.ok(indexes.includes("instance_agent_scan_jobs_tenant_idx"))
await assert.rejects(pool.query("INSERT INTO instance_agent_scan_jobs (tenant_id, agent_id) VALUES (999, $1)", [created.json().agent.id]), { code: "23503" })
} finally {
await app.close()
await pool.end()
}
})
}
})

View File

@@ -55,3 +55,30 @@ test("unknown agents cannot receive setup credentials", async (t) => {
assert.equal(response.statusCode, 404) assert.equal(response.statusCode, 404)
assert.equal(f.updates.length, 0) assert.equal(f.updates.length, 0)
}) })
test("OCR and background cleanup require an agent that supports the requested processing", async t => {
const app = Fastify()
t.after(() => app.close())
const jobs: any[] = []
let capabilities: Record<string, boolean> = { scan: true }
app.addHook("onRequest", async request => { request.user = { is_admin: true, tenant_id: 1 } as any })
app.decorate("db", {
select: () => ({ from: () => ({ where: () => ({ limit: async () => [{ id, active: true, scanDefaults: {}, capabilities }] }) }) }),
insert: () => ({ values: (data: any) => ({ returning: async () => { jobs.push(data); return [{ id: "job", ...data }] } }) }),
})
await app.register(routes)
for (const settings of [{ ocr: true }, { ocr: "true" }, { backgroundCleanup: true }]) {
const response = await app.inject({ method: "POST", url: "/scan-jobs", payload: { agentId: id, settings } })
assert.equal(response.statusCode, 409)
assert.equal(jobs.length, 0)
}
capabilities = { scan: true, ocr: true, backgroundCleanup: true }
const response = await app.inject({
method: "POST", url: "/scan-jobs",
payload: { agentId: id, settings: { ocr: true, ocrLanguage: "deu+eng", backgroundCleanup: true }, target: { folder: null, type: null } },
})
assert.equal(response.statusCode, 200)
assert.equal(jobs.length, 1)
assert.equal(jobs[0].settings.ocr, true)
assert.equal(jobs[0].target.type, null)
})

View File

@@ -0,0 +1,119 @@
import assert from "node:assert/strict";
import test from "node:test";
import { createServer } from "node:http";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import Fastify from "fastify";
import routes from "../src/routes/selfhostUpdates";
import { createUpdaterClient } from "../src/modules/selfhost-updater";
import authPlugin from "../src/plugins/auth";
import { secrets } from "../src/utils/secrets";
import jwt from "jsonwebtoken";
const id = "12345678-1234-4123-a123-123456789abc";
const headers = { authorization: "Bearer verified-jwt-token" };
async function fixture(t: any, admin = true, authenticated = true) {
const app = Fastify();
const calls: unknown[] = [];
let currentAdmin = admin;
// Mirror the parent auth plugin's preHandler order, not an earlier onRequest hook.
app.addHook("preHandler", async req => { if (authenticated) req.user = { user_id: id, is_admin: true } as any; });
app.decorate("db", { select: () => ({ from: () => ({ where: () => ({ limit: async () => [{ id, is_admin: currentAdmin }] }) }) }) });
await app.register(routes, { client: async (...args) => {
calls.push(args); return { status: 202, body: { status: "queued", requestId: id } };
} });
t.after(() => app.close());
return { app, calls, revoke: () => { currentAdmin = false; } };
}
test("all update endpoints require a current global admin, including after revocation", async t => {
const f = await fixture(t);
assert.equal((await f.app.inject({ url: "/admin/updates/status" })).statusCode, 202);
f.revoke();
for (const [method, url] of [["GET", "/admin/updates/status"], ["GET", `/admin/updates/requests/${id}`],
["POST", "/admin/updates/check"], ["POST", "/admin/updates/apply"]] as const) {
assert.equal((await f.app.inject({ method, url, headers, payload: method === "POST" ? { requestId: id } : undefined })).statusCode, 403);
}
assert.equal(f.calls.length, 1);
});
test("unauthenticated and cookie-only requests cannot start jobs", async t => {
const anonymous = await fixture(t, true, false);
assert.equal((await anonymous.app.inject({ url: "/admin/updates/status" })).statusCode, 401);
const f = await fixture(t);
for (const authorization of [undefined, "Bearer short", "bearer long-but-not-used-by-auth"]) {
assert.equal((await f.app.inject({ method: "POST", url: "/admin/updates/check",
headers: authorization ? { authorization } : {}, payload: { requestId: id } })).statusCode, 403);
}
assert.equal(f.calls.length, 0);
});
test("fixed routes reject injected options, malformed IDs and unreviewed apply requests", async t => {
const f = await fixture(t);
for (const payload of [{ requestId: id, directory: "/root" }, { requestId: id, version: "latest" },
{ requestId: "../../etc/passwd" }, { requestId: id, acceptComposeReplacement: true }]) {
assert.equal((await f.app.inject({ method: "POST", url: "/admin/updates/check", headers, payload })).statusCode, 400);
}
assert.equal((await f.app.inject({ method: "POST", url: "/admin/updates/apply", headers,
payload: { requestId: id, version: "3.2.0" } })).statusCode, 400);
assert.equal(f.calls.length, 0);
const response = await f.app.inject({ method: "POST", url: "/admin/updates/apply", headers,
payload: { requestId: id, version: "3.2.0", checkRequestId: "checked-request" } });
assert.equal(response.statusCode, 202);
assert.equal(response.headers["cache-control"], "no-store");
assert.deepEqual(f.calls[0], ["POST", "/v1/apply", { requestId: id, version: "3.2.0", checkRequestId: "checked-request" }]);
});
test("real authentication requires a valid header JWT even when a valid cookie exists", async t => {
const app = Fastify();
t.after(() => app.close());
const previous = secrets.JWT_SECRET;
secrets.JWT_SECRET = "update-route-test-secret";
t.after(() => { secrets.JWT_SECRET = previous; });
const token = jwt.sign({ user_id: id, tenant_id: null, is_admin: true }, secrets.JWT_SECRET, { expiresIn: "5m" });
app.addHook("onRequest", async req => { req.cookies = { token }; });
app.decorate("db", { select: () => ({ from: () => ({ where: () => ({ limit: async () => [{ id, is_admin: true }] }) }) }) });
await app.register(authPlugin);
let forwarded = 0;
await app.register(routes, { client: async () => { forwarded++; return { status: 202, body: { status: "queued" } }; } });
for (const authorization of ["Bearer x", "bearer ignored-token-with-valid-cookie", "Bearer invalid-long-jwt-token"]) {
const result = await app.inject({ method: "POST", url: "/admin/updates/check", headers: { authorization }, payload: { requestId: id } });
assert.ok([401, 403].includes(result.statusCode));
}
assert.equal(forwarded, 0);
assert.equal((await app.inject({ method: "POST", url: "/admin/updates/check",
headers: { authorization: `Bearer ${token}` }, payload: { requestId: id } })).statusCode, 202);
assert.equal(forwarded, 1);
});
async function socketFixture(t: any, handler: Parameters<typeof createServer>[0]) {
const directory = await mkdtemp(`${tmpdir()}/fedeo-api-`);
const socket = `${directory}/control.sock`;
const server = createServer(handler);
await new Promise<void>(resolve => server.listen(socket, resolve));
t.after(async () => { server.closeAllConnections(); await new Promise<void>(resolve => server.close(() => resolve())); await rm(directory, { recursive: true, force: true }); });
return createUpdaterClient(socket, 150);
}
test("Unix client strips private fields and never forwards credentials or raw error messages", async t => {
const client = await socketFixture(t, (req, res) => {
assert.equal(req.headers.authorization, undefined);
assert.equal(req.headers.cookie, undefined);
if (req.url === "/v1/status") res.end(JSON.stringify({ apiVersion: 1, acceptingRequests: true,
engine: { status: "idle", job: "/secret/path", error: "password" }, installed: {}, secret: "token" }));
else { res.writeHead(409); res.end(JSON.stringify({ error: { code: "PLAN_CHANGED", message: "password" } })); }
});
const response = await client("GET", "/v1/status");
assert.deepEqual(response.body, { apiVersion: 1, acceptingRequests: true, engine: { status: "idle" }, installed: {} });
await assert.rejects(client("POST", "/v1/apply", {}), { code: "PLAN_CHANGED", status: 409,
message: "Release oder Konfiguration haben sich geändert. Bitte erneut prüfen." });
await assert.rejects(client("GET", "http://example.com"), { code: "INVALID_REQUEST" });
});
test("Unix client bounds response size, deadline, and protocol shape", async t => {
for (const scenario of ["oversize", "timeout", "invalid"] as const) {
await t.test(scenario, async t => {
const client = await socketFixture(t, (_req, res) => {
if (scenario === "oversize") res.end("x".repeat(70000));
else if (scenario === "invalid") res.end('{"apiVersion":2}');
});
await assert.rejects(client("GET", "/v1/status"), (error: any) =>
["UPDATER_UNAVAILABLE", "UPDATER_PROTOCOL"].includes(error.code) && !error.message.includes("ENOENT"));
});
}
});

View File

@@ -86,6 +86,9 @@ services:
image: git.federspiel.tech/flfeders/fedeo/backend:${FEDEO_VERSION:-dev} image: git.federspiel.tech/flfeders/fedeo/backend:${FEDEO_VERSION:-dev}
container_name: fedeo-backend container_name: fedeo-backend
restart: unless-stopped restart: unless-stopped
volumes:
# Mount the directory so a service restart can replace the Unix socket.
- /run/fedeo-updater:/run/fedeo-updater:ro
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy

View File

@@ -1,6 +1,6 @@
# Vollständige Selfhost-Updates # Vollständige Selfhost-Updates
`scripts/selfhost-update.py` ist der gemeinsame Update-Ablauf für die Kommandozeile und den optionalen Host-Dienst `fedeo-updater.service`. Beide laufen auf dem Docker-Host außerhalb der FEDEO-Container und verwenden dieselbe exklusive Sperre. Der Dienst bietet bereits eine lokale Schnittstelle; Backend-Anbindung und Update-Button im Frontend folgen separat. `scripts/selfhost-update.py` ist der gemeinsame Update-Ablauf für die Kommandozeile und den optionalen Host-Dienst `fedeo-updater.service`. Beide laufen auf dem Docker-Host außerhalb der FEDEO-Container und verwenden dieselbe exklusive Sperre. Das Backend verbindet diese lokale Schnittstelle mit dem Update-Bereich unter Administration → Systemstatus.
## Voraussetzungen und Umfang ## Voraussetzungen und Umfang
@@ -75,10 +75,34 @@ Der Installer legt die Python-Module root-eigen unter `/usr/local/lib/fedeo-upda
bash /opt/fedeo/scripts/selfhost-update-service-install.sh --directory /opt/fedeo --print-unit bash /opt/fedeo/scripts/selfhost-update-service-install.sh --directory /opt/fedeo --print-unit
``` ```
Der Dienst läuft wegen Docker und der Backups als root. Seine HTTP-Schnittstelle lauscht ausschließlich auf `/run/fedeo-updater/control.sock`, nicht auf einem TCP-Port. Verzeichnis und Socket gehören `root:fedeo-updater` mit `0750` bzw. `0660`. Zugriff auf diese Gruppe erlaubt instanzweite Updates; nur vertrauenswürdigen Host-Prozessen Zugriff geben. Die spätere Backend-Anbindung muss Systemadministratoren prüfen und darf nur diese kontrollierte Schnittstelle verwenden. Es wird kein Docker-Socket in Backend oder Frontend eingebunden. Dieser Stand richtet noch keine Backend-Route und keinen Frontend-Button ein. Der Dienst läuft wegen Docker und der Backups als root. Seine HTTP-Schnittstelle lauscht ausschließlich auf `/run/fedeo-updater/control.sock`, nicht auf einem TCP-Port. Verzeichnis und Socket gehören `root:fedeo-updater` mit `0750` bzw. `0660`. Zugriff auf diese Gruppe erlaubt instanzweite Updates; nur vertrauenswürdigen Host-Prozessen Zugriff geben. Die Selfhost-Compose-Datei bindet `/run/fedeo-updater` schreibgeschützt unter demselben Pfad im Backend ein. Das Verzeichnis statt der einzelnen Socket-Datei bleibt auch bei einem Socket-Austausch durch Dienstneustarts nutzbar. Die Standard-Backend-Container laufen als root; bei einer eigenen unprivilegierten Konfiguration benötigt der Backend-Prozess zusätzlich die Host-GID von `fedeo-updater`. Es wird kein Docker-Socket in Backend oder Frontend eingebunden. Ohne installierten Dienst bleibt die Anwendung nutzbar; der Update-Bereich meldet den Dienst als nicht erreichbar.
Registry-Anmeldung bei privaten Images als root durchführen. Für private Releases kann `/etc/fedeo/updater.env` root-lesbar (`0600`) `FEDEO_RELEASE_TOKEN=...` enthalten; danach den Dienst neu starten. Dieser Token wird nicht an API-Clients ausgegeben. Die Unit verwendet `/root` als Home-Verzeichnis für die Docker-Anmeldung. Der Dienst ersetzt sich bei einem FEDEO-Update nicht selbst; neue Dienstdateien zusammen bereitstellen und den Dienstinstaller erneut ausführen. Registry-Anmeldung bei privaten Images als root durchführen. Für private Releases kann `/etc/fedeo/updater.env` root-lesbar (`0600`) `FEDEO_RELEASE_TOKEN=...` enthalten; danach den Dienst neu starten. Dieser Token wird nicht an API-Clients ausgegeben. Die Unit verwendet `/root` als Home-Verzeichnis für die Docker-Anmeldung. Der Dienst ersetzt sich bei einem FEDEO-Update nicht selbst; neue Dienstdateien zusammen bereitstellen und den Dienstinstaller erneut ausführen.
## Updates im Frontend und einmaliger Übergang
Systemadministratoren öffnen **Administration → Systemstatus → FEDEO-Updates**. „Nach Updates suchen“ prüft den neuesten stabilen Release. Die Oberfläche zeigt den laufenden Backend-Stand, die geprüfte Zielversion, den Commit, den geschätzten Backup-Platz und gegebenenfalls stillzulegende Matrix-Dienste. „Update auf … installieren“ öffnet die Bestätigung für die Wartungsunterbrechung aller Mandanten. Erst „Jetzt installieren“ nach Bestätigung startet den Auftrag.
Der Fortschritt wird automatisch abgefragt. Während des Stack-Neustarts ist die Verbindung vorübergehend unterbrochen; der Host arbeitet weiter. Nach Erfolg lädt „Neue Version laden“ die Oberfläche neu. Auftrags-IDs bleiben im Browser gespeichert und der Dienst liefert auch nach seinem Neustart den letzten Auftrag. Geht die Startantwort verloren, fragt die Oberfläche zuerst dieselbe ID ab. „Auftrag erneut senden“ verwendet dieselbe ID und denselben Inhalt; es wird kein zweites Update gestartet. Nach einer erneuten Anmeldung die Systemstatus-Seite wieder öffnen. Auch ohne Browser-Speicher lässt sich der aktuelle Auftrag über den Dienststatus verfolgen.
Für eine bestehende Instanz auf `3.1.0` ist noch ein einmaliger Übergang nötig, **nachdem ein Release mit diesem Update-Bereich veröffentlicht wurde**:
1. Die vier zusammengehörigen Host-Dateien aus dessen geprüften Release-Assets nach `/opt/fedeo/scripts/` übernehmen: `selfhost-update.py`, `selfhost-update-service.py`, `selfhost-update-service-install.sh`, `fedeo-updater.service`.
2. Den Dienstinstaller erneut ausführen, damit auch `/usr/local/lib/fedeo-updater/` den aktuellen Stand erhält.
3. Einmal per CLI auf diesen Release aktualisieren. `X.Y.Z` durch die veröffentlichte Version ersetzen:
```bash
sudo bash /opt/fedeo/scripts/selfhost-update-service-install.sh --directory /opt/fedeo
sudo python3 /opt/fedeo/scripts/selfhost-update.py check --version X.Y.Z
sudo python3 /opt/fedeo/scripts/selfhost-update.py apply --version X.Y.Z
```
Der aktuelle Updater erlaubt dabei gezielt das zusätzliche, schreibgeschützte Standard-Socket-Verzeichnis am Backend. Änderungen an bestehenden Daten-Mounts sowie abweichende oder schreibbare neue Kontroll-Mounts bleiben gesperrt. Ältere Updater besitzen diese Übergangsregel noch nicht. Ein bereits erfolgreich verwalteter Compose-Stand benötigt keine erneute `--accept-compose-replacement`-Freigabe. Falls der Prüfplan sie verlangt, zunächst die Compose-Differenz auf dem Host prüfen wie oben beschrieben.
Danach laufen weitere Anwendungs-Releases einschließlich Compose und Migrationen über den Button. Änderungen am Host-Dienst selbst benötigen weiterhin die gemeinsame Aktualisierung der Host-Dateien und den Dienstinstaller.
Die Backend-Routen `/api/admin/updates/status`, `/api/admin/updates/requests/:id`, `/api/admin/updates/check` und `/api/admin/updates/apply` prüfen bei jeder Anfrage die aktuellen globalen Administratorrechte aus der Datenbank. Mandantenadministratoren haben keinen Zugriff. Startanfragen benötigen zusätzlich den bereits von der Authentifizierung geprüften Bearer-JWT; Cookie-Anmeldung allein kann keine Hostaktion auslösen. Das Backend verwendet ausschließlich den festen Unix-Socket, begrenzt Antwortgröße und Wartezeit und liefert nur erlaubte Status-/Planfelder aus. CLI-Wiederherstellung, Übernahme fremder Compose-Dateien, Shell-Befehle und frei wählbare Download-Adressen bleiben auf dem Host.
## Lokale Dienst-API ## Lokale Dienst-API
Alle Antworten sind JSON mit `Cache-Control: no-store`. Mutationen benötigen `Content-Type: application/json`, höchstens 4096 Byte und eine vom Client erzeugte `requestId` (8–80 Zeichen, Buchstaben/Ziffern/Unterstrich/Bindestrich). Für jede neue Aktion eine neue ID verwenden; bei einem Verbindungsfehler dieselbe ID und denselben Inhalt wiederholen. Das liefert denselben Auftrag statt eines zweiten Updates, auch nach einem Dienstneustart. Eine andere Anfrage mit derselben ID wird mit `409 REQUEST_CONFLICT` abgelehnt. Alle Antworten sind JSON mit `Cache-Control: no-store`. Mutationen benötigen `Content-Type: application/json`, höchstens 4096 Byte und eine vom Client erzeugte `requestId` (8–80 Zeichen, Buchstaben/Ziffern/Unterstrich/Bindestrich). Für jede neue Aktion eine neue ID verwenden; bei einem Verbindungsfehler dieselbe ID und denselben Inhalt wiederholen. Das liefert denselben Auftrag statt eines zweiten Updates, auch nach einem Dienstneustart. Eine andere Anfrage mit derselben ID wird mit `409 REQUEST_CONFLICT` abgelehnt.
@@ -127,7 +151,7 @@ Aufträge liegen dauerhaft und root-lesbar unter `.updates/service/`. Nach einem
1. Exklusive Host-Sperre erwerben; offene Wiederherstellungsfälle blockieren neue Updates. 1. Exklusive Host-Sperre erwerben; offene Wiederherstellungsfälle blockieren neue Updates.
2. Release samt Prüfsummen laden, Manifest/Commit prüfen und die FEDEO-Images auf die exakten Digests setzen. 2. Release samt Prüfsummen laden, Manifest/Commit prüfen und die FEDEO-Images auf die exakten Digests setzen.
3. Aktuelle und neue Compose-Konfiguration mit Docker Compose validieren. Projektname, Container-Identitäten, Mounts, Netzwerke und Zugangsdaten erhalten. Die Shell-Umgebung überschreibt dabei keine Werte aus der Instanz-`.env`. 3. Aktuelle und neue Compose-Konfiguration mit Docker Compose validieren. Projektname, Container-Identitäten, Mounts, Netzwerke und Zugangsdaten erhalten. Die Shell-Umgebung überschreibt dabei keine Werte aus der Instanz-`.env`.
4. Speicherplatz prüfen: mindestens 5 GiB frei am Docker-Datenverzeichnis vor dem Pull sowie geschätzte Backup-Größe plus Reserve im Betriebsverzeichnis. Images laden, Backup-Platz erneut prüfen. 4. Speicherplatz prüfen: mindestens 5 GiB frei am Docker-Datenverzeichnis vor dem Pull sowie geschätzte Backup-Größe plus Reserve im Betriebsverzeichnis. Images mit exakt gepinntem SHA-256-Digest nur laden, wenn sie lokal fehlen (`pull --policy missing`); vorhandene Tags ohne passenden Digest reichen dafür nicht aus. Bewegliche Infrastruktur-Tags weiterhin aktualisieren (`pull --policy always`). Ein fehlendes, nicht herunterladbares Image beendet den Auftrag vor der Wartung. Backup-Platz erneut prüfen. Während der anschließenden Infrastrukturstarts und Migrationen erfolgt kein weiterer Pull (`up`/`run --pull never`), auch nicht bei einer abweichenden Pull-Policy in einer Override-Datei.
5. Schreibende Dienste und Proxy anhalten. PostgreSQL bleibt für `pg_dump` aktiv. 5. Schreibende Dienste und Proxy anhalten. PostgreSQL bleibt für `pg_dump` aktiv.
6. Konfiguration, Container-Metadaten, PostgreSQL-Dump im Custom-Format, globale PostgreSQL-Rollen, gestoppten MinIO-Dateispeicher und Zertifikate sichern. Dump und TAR auf Lesbarkeit prüfen und SHA-256-Prüfsummen schreiben. `backup/complete.json` markiert ein vollständig geschriebenes Backup; ein Restore-Probelauf auf einer getrennten Instanz wird dadurch nicht ersetzt. 6. Konfiguration, Container-Metadaten, PostgreSQL-Dump im Custom-Format, globale PostgreSQL-Rollen, gestoppten MinIO-Dateispeicher und Zertifikate sichern. Dump und TAR auf Lesbarkeit prüfen und SHA-256-Prüfsummen schreiben. `backup/complete.json` markiert ein vollständig geschriebenes Backup; ein Restore-Probelauf auf einer getrennten Instanz wird dadurch nicht ersetzt.
Beim Übergang vom alten Matrix-Stack enthält das TAR zusätzlich `matrix/`. Container-Metadaten enthalten die ursprünglichen Image-IDs, Mounts und Neustartrichtlinien; `complete.json` nennt die stillgelegten Dienste und erhaltenen Container. Vor dem Dateiaustausch wird deren automatischer Neustart deaktiviert. Das Zielrelease enthält keine Matrix-Dienste und keine Backend-Abhängigkeit auf Synapse mehr. Beim Übergang vom alten Matrix-Stack enthält das TAR zusätzlich `matrix/`. Container-Metadaten enthalten die ursprünglichen Image-IDs, Mounts und Neustartrichtlinien; `complete.json` nennt die stillgelegten Dienste und erhaltenen Container. Vor dem Dateiaustausch wird deren automatischer Neustart deaktiviert. Das Zielrelease enthält keine Matrix-Dienste und keine Backend-Abhängigkeit auf Synapse mehr.
@@ -139,6 +163,8 @@ Status und Phasen werden atomar in `.updates/status.json` und pro Auftrag in `st
## Fehler und Wiederherstellung ## Fehler und Wiederherstellung
Bei einer nicht erreichbaren Registry können bereits lokal vorhandene, exakt passende Digest-Images weiterverwendet werden. Das hilft beispielsweise bei einem `401 Unauthorized` der MinIO-Registry, wenn sowohl MinIO als auch `mc` mit den Ziel-Digests bereits auf diesem Host liegen. Der Host-Updater muss diese Pull-Logik bereits enthalten; bestehende Dienstinstallationen nach Austausch des Hostskripts mit dem Dienstinstaller aktualisieren. Veröffentlichte Release-Assets werden nicht nachträglich verändert. Für Instanzen ohne diese Images bleibt eine geprüfte Bezugsquelle notwendig. Der lokale Cache ersetzt weder Sicherheitsupdates noch eine dauerhafte Versorgung mit gepflegten Images.
- **Download, Konfiguration oder Pull fehlgeschlagen:** Der laufende Stack bleibt bestehen. - **Download, Konfiguration oder Pull fehlgeschlagen:** Der laufende Stack bleibt bestehen.
- **Backup oder Stop fehlgeschlagen:** Der Updater versucht, die zuvor laufenden Container wieder zu starten. Gelingt das nicht, bleibt der Wiederherstellungsstatus gesetzt. - **Backup oder Stop fehlgeschlagen:** Der Updater versucht, die zuvor laufenden Container wieder zu starten. Gelingt das nicht, bleibt der Wiederherstellungsstatus gesetzt.
- **Ab Austausch der Dateien/Infrastruktur:** Keine automatische Rückkehr zu älteren Images. Bei einem Fehler werden Backend, Frontend und Proxy nach Möglichkeit angehalten. Das Datenbankschema oder das Speicherformat könnten bereits verändert sein. - **Ab Austausch der Dateien/Infrastruktur:** Keine automatische Rückkehr zu älteren Images. Bei einem Fehler werden Backend, Frontend und Proxy nach Möglichkeit angehalten. Das Datenbankschema oder das Speicherformat könnten bereits verändert sein.
@@ -167,6 +193,8 @@ Bei Rückkehr zum alten Matrix-Stack dessen ursprüngliche Compose-Datei aus dem
```bash ```bash
npm test npm test
npm run commits:check npm run commits:check
cd backend && node --import tsx --test tests/selfhostUpdates.test.ts
cd ../frontend && node --test tests/selfhost-updates.test.cjs
``` ```
Die Update-Tests verwenden den echten Compose-Parser und einen simulierten Docker-Daemon. Sie prüfen u. a. Erfolg, Prüfsummen, Release-Auswahl, unveränderte Konfiguration, Speicherplatz, parallele Aufträge, Backupfehler, Migrationsfehler, falsche laufende Versionen und unterbrochene Aufträge. Die Matrix-Tests prüfen Stilllegung, Daten-/Container-Erhalt, Backup vor Änderung der Neustartrichtlinien, Fehler und Unterbrechungen sowie die weitere Ablehnung unbekannter Dienste. Ein vollständiger Test mit realem PostgreSQL-/MinIO-/Matrix-Restore auf einer separaten Selfhost-Testinstanz ist vor dem Produktionseinsatz zusätzlich erforderlich. Die Update-Tests verwenden den echten Compose-Parser und einen simulierten Docker-Daemon. Sie prüfen u. a. Erfolg, Prüfsummen, Release-Auswahl, unveränderte Konfiguration, Speicherplatz, parallele Aufträge, Backupfehler, Migrationsfehler, falsche laufende Versionen und unterbrochene Aufträge. Die Matrix-Tests prüfen Stilllegung, Daten-/Container-Erhalt, Backup vor Änderung der Neustartrichtlinien, Fehler und Unterbrechungen sowie die weitere Ablehnung unbekannter Dienste. Ein vollständiger Test mit realem PostgreSQL-/MinIO-/Matrix-Restore auf einer separaten Selfhost-Testinstanz ist vor dem Produktionseinsatz zusätzlich erforderlich.

File diff suppressed because one or more lines are too long

View File

@@ -38,7 +38,14 @@ const installCommand = computed(() => 'bash ' + filename.value)
const tokenReady = computed(() => /^fedeo_agent_[a-f0-9]{64}$/.test(token.value.trim())) const tokenReady = computed(() => /^fedeo_agent_[a-f0-9]{64}$/.test(token.value.trim()))
const online = computed(() => agentSetupReady(agent.value, checkingSince.value, now.value)) const online = computed(() => agentSetupReady(agent.value, checkingSince.value, now.value))
const scannerOptions = computed(() => (agent.value?.scannerNames || []).map(value => ({ label: value, value }))) const scannerOptions = computed(() => (agent.value?.scannerNames || []).map(value => ({ label: value, value })))
const readyToSave = computed(() => online.value && agent.value?.capabilities?.scan && scannerOptions.value.some(item => item.value === selectedScanner.value)) const setupScanDefaults = computed(() => profile.value === 'ix1600' ? { ...ix1600ScanDefaults } : {
format: 'pdf', resolution: 300, mode: 'Color', paperSize: 'auto', postprocess: false,
backgroundCleanup: true, ocr: true, ocrLanguage: 'deu+eng', ...agent.value?.scanDefaults,
})
const processingMissing = computed(() =>
(setupScanDefaults.value.format === 'pdf' && setupScanDefaults.value.ocr && !agent.value?.capabilities?.ocr) ||
(setupScanDefaults.value.backgroundCleanup && !agent.value?.capabilities?.backgroundCleanup))
const readyToSave = computed(() => online.value && agent.value?.capabilities?.scan && !processingMissing.value && scannerOptions.value.some(item => item.value === selectedScanner.value))
const endpointPreview = computed(() => { const endpointPreview = computed(() => {
try { return agentBackendUrl(backendUrl.value) + '/instance-agent/heartbeat' } catch { return '' } try { return agentBackendUrl(backendUrl.value) + '/instance-agent/heartbeat' } catch { return '' }
}) })
@@ -129,10 +136,7 @@ const finishSetup = async () => {
if (!agent.value || !readyToSave.value || busy.value) return if (!agent.value || !readyToSave.value || busy.value) return
busy.value = true busy.value = true
try { try {
const scanDefaults = profile.value === 'ix1600' ? { ...ix1600ScanDefaults } : { await admin.updateInstanceAgent(agent.value.id, { preferredScannerName: selectedScanner.value, scanDefaults: setupScanDefaults.value })
format: 'pdf', resolution: 300, mode: 'Color', paperSize: 'auto', postprocess: false, ...agent.value.scanDefaults,
}
await admin.updateInstanceAgent(agent.value.id, { preferredScannerName: selectedScanner.value, scanDefaults })
saved.value = true saved.value = true
token.value = '' token.value = ''
stopPolling() stopPolling()
@@ -212,7 +216,7 @@ onBeforeUnmount(() => {
</div> </div>
<div class="space-y-4 rounded-lg bg-muted/40 p-5"> <div class="space-y-4 rounded-lg bg-muted/40 p-5">
<h3 class="font-semibold text-highlighted">Installation herunterladen</h3> <h3 class="font-semibold text-highlighted">Installation herunterladen</h3>
<p class="text-sm text-muted">Die Datei enthält die Agent-Software und deine Konfiguration. Sie prüft Node.js ab Version 20, SANE und Python/Pillow und installiert fehlende Systempakete mit sudo.</p> <p class="text-sm text-muted">Die Datei enthält die Agent-Software und deine Konfiguration. Sie installiert bei Bedarf Node.js ab Version 20, SANE, Bildverarbeitung und OCR für Deutsch/Englisch mit sudo.</p>
<p class="text-sm text-muted">Die Installation erfolgt in deinem Benutzerverzeichnis. Starte die Datei auf dem Rechner mit dem Scanner.</p> <p class="text-sm text-muted">Die Installation erfolgt in deinem Benutzerverzeichnis. Starte die Datei auf dem Rechner mit dem Scanner.</p>
<UAlert v-if="configurationError" color="error" variant="soft" :title="configurationError" /> <UAlert v-if="configurationError" color="error" variant="soft" :title="configurationError" />
<UButton icon="i-heroicons-arrow-down-tray" :disabled="!tokenReady || busy" @click="downloadInstaller">Linux-Installation herunterladen</UButton> <UButton icon="i-heroicons-arrow-down-tray" :disabled="!tokenReady || busy" @click="downloadInstaller">Linux-Installation herunterladen</UButton>
@@ -253,6 +257,8 @@ onBeforeUnmount(() => {
<UAlert v-if="!online" color="warning" variant="soft" title="Agent ist derzeit nicht verbunden" description="Agent starten und die Verbindung erneut prüfen." /> <UAlert v-if="!online" color="warning" variant="soft" title="Agent ist derzeit nicht verbunden" description="Agent starten und die Verbindung erneut prüfen." />
<UAlert v-else-if="!agent?.capabilities?.scan || !scannerOptions.length" color="warning" variant="soft" title="Noch kein Scanner verfügbar" <UAlert v-else-if="!agent?.capabilities?.scan || !scannerOptions.length" color="warning" variant="soft" title="Noch kein Scanner verfügbar"
description="USB-Verbindung und Stromversorgung prüfen. scanimage -L muss den Scanner für denselben Linux-Benutzer anzeigen." /> description="USB-Verbindung und Stromversorgung prüfen. scanimage -L muss den Scanner für denselben Linux-Benutzer anzeigen." />
<UAlert v-else-if="processingMissing" color="warning" variant="soft" title="Scan-Verarbeitung noch nicht bereit"
description="Für dieses Profil fehlen OCR oder Hintergrundbereinigung. Die aktuelle Linux-Installation aus Schritt 2 ausführen und anschließend den Agenten neu starten." />
<UAlert v-if="connectionError" color="error" variant="soft" title="Status konnte nicht geprüft werden" :description="connectionError" /> <UAlert v-if="connectionError" color="error" variant="soft" title="Status konnte nicht geprüft werden" :description="connectionError" />
<UFormField label="Scanner" required> <UFormField label="Scanner" required>
<USelectMenu v-model="selectedScanner" :items="scannerOptions" value-key="value" label-key="label" class="w-full" placeholder="Erkannten Scanner auswählen" /> <USelectMenu v-model="selectedScanner" :items="scannerOptions" value-key="value" label-key="label" class="w-full" placeholder="Erkannten Scanner auswählen" />
@@ -263,7 +269,7 @@ onBeforeUnmount(() => {
{ label: 'Bisherige Einstellungen / allgemeiner Scanner', value: 'existing' } { label: 'Bisherige Einstellungen / allgemeiner Scanner', value: 'existing' }
]" value-key="value" label-key="label" class="w-full" /> ]" value-key="value" label-key="label" class="w-full" />
</UFormField> </UFormField>
<p class="text-sm text-muted">Das iX1600-Profil speichert den gesamten Stapel mit Vorder- und Rückseiten in einem PDF. Weitere Einstellungen kannst du anschließend in der Scannerverwaltung ändern.</p> <p class="text-sm text-muted">Das iX1600-Profil speichert Vorder- und Rückseiten als durchsuchbares PDF. Erkannter Scannerhintergrund außerhalb des Papiers wird weiß; das A4-Format bleibt erhalten.</p>
<div class="flex flex-wrap gap-3"> <div class="flex flex-wrap gap-3">
<UButton color="neutral" variant="outline" :loading="checking" @click="checkConnection">Scanner erneut suchen</UButton> <UButton color="neutral" variant="outline" :loading="checking" @click="checkConnection">Scanner erneut suchen</UButton>
<UButton :disabled="!readyToSave" :loading="busy" @click="finishSetup">Einrichtung abschließen</UButton> <UButton :disabled="!readyToSave" :loading="busy" @click="finishSetup">Einrichtung abschließen</UButton>

View File

@@ -0,0 +1,595 @@
<script setup>
import "gridstack/dist/gridstack.min.css"
const DisplayIncomeAndExpenditure = defineAsyncComponent(() => import("~/components/displayIncomeAndExpenditure.vue"))
const DisplayOpenBalances = defineAsyncComponent(() => import("~/components/displayOpenBalances.vue"))
const DisplayBankaccounts = defineAsyncComponent(() => import("~/components/displayBankaccounts.vue"))
const DisplayProjectsInPhases = defineAsyncComponent(() => import("~/components/displayProjectsInPhases.vue"))
const DisplayOpenTasks = defineAsyncComponent(() => import("~/components/displayOpenTasks.vue"))
const DisplayTaxSummary = defineAsyncComponent(() => import("~/components/displayTaxSummary.vue"))
const DisplayBWASummary = defineAsyncComponent(() => import("~/components/displayBWASummary.vue"))
const toast = useToast()
const tempStore = useTempStore()
const gridElement = ref(null)
const grid = shallowRef(null)
const isEditMode = ref(false)
const manageCardsOpen = ref(false)
const widgets = ref([])
let gridStackClass = null
let persistTimeout = null
const isSyncingGrid = ref(false)
const DASHBOARD_WIDGETS = [
{
id: "income-expense",
title: "Einnahmen und Ausgaben",
description: "Umsatz- und Ausgabenentwicklung",
component: markRaw(DisplayIncomeAndExpenditure),
defaultLayout: { x: 0, y: 0, w: 12, h: 4 },
minW: 4,
minH: 4
},
{
id: "open-balances",
title: "Buchhaltung",
description: "Offene Rechnungen und Entwurfsstatus",
component: markRaw(DisplayOpenBalances),
defaultLayout: { x: 0, y: 4, w: 4, h: 3 },
minW: 3,
minH: 3
},
{
id: "bankaccounts",
title: "Bank",
description: "Kontostand und offene Zuordnungen",
component: markRaw(DisplayBankaccounts),
defaultLayout: { x: 4, y: 4, w: 4, h: 3 },
minW: 3,
minH: 3
},
{
id: "projects",
title: "Projekte",
description: "Aktuelle Projektphasen",
component: markRaw(DisplayProjectsInPhases),
defaultLayout: { x: 8, y: 4, w: 4, h: 3 },
minW: 3,
minH: 3
},
{
id: "tasks",
title: "Aufgaben",
description: "Offene Aufgaben des aktuellen Nutzers",
component: markRaw(DisplayOpenTasks),
defaultLayout: { x: 0, y: 7, w: 4, h: 3 },
minW: 3,
minH: 3
},
{
id: "tax-summary",
title: "USt aktuell",
description: "USt, Vorsteuer und Saldo des aktuellen Zeitraums",
component: markRaw(DisplayTaxSummary),
defaultLayout: { x: 4, y: 7, w: 4, h: 3 },
minW: 3,
minH: 3
},
{
id: "bwa-summary",
title: "BWA aktuell",
description: "Einnahmen, Ausgaben und Ergebnis des aktuellen Monats",
component: markRaw(DisplayBWASummary),
defaultLayout: { x: 8, y: 7, w: 4, h: 3 },
minW: 3,
minH: 3
}
]
const widgetDefinitions = Object.fromEntries(DASHBOARD_WIDGETS.map((widget) => [widget.id, widget]))
function getDefaultDashboardWidgets() {
return DASHBOARD_WIDGETS.map((definition) => ({
id: definition.id,
x: definition.defaultLayout.x,
y: definition.defaultLayout.y,
w: definition.defaultLayout.w,
h: definition.defaultLayout.h,
visible: true
}))
}
function normalizeNumber(value, fallback) {
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : fallback
}
function serializeWidgets(input) {
return input.map((widget) => ({
id: widget.id,
x: widget.x,
y: widget.y,
w: widget.w,
h: widget.h,
visible: widget.visible
}))
}
function normalizeDashboardWidgets(storedWidgets) {
const storedById = new Map(
(Array.isArray(storedWidgets) ? storedWidgets : [])
.filter((widget) => widget?.id && widgetDefinitions[widget.id])
.map((widget) => [widget.id, widget])
)
return DASHBOARD_WIDGETS.map((definition) => {
const stored = storedById.get(definition.id) || {}
return {
id: definition.id,
x: normalizeNumber(stored.x, definition.defaultLayout.x),
y: normalizeNumber(stored.y, definition.defaultLayout.y),
w: normalizeNumber(stored.w, definition.defaultLayout.w),
h: normalizeNumber(stored.h, definition.defaultLayout.h),
visible: typeof stored.visible === "boolean" ? stored.visible : true
}
})
}
function widgetsSignature(input) {
return JSON.stringify(serializeWidgets(input))
}
function getWidgetLayout(id) {
return widgets.value.find((widget) => widget.id === id)
}
function updateWidget(id, patch) {
widgets.value = widgets.value.map((widget) => {
if (widget.id !== id) return widget
return { ...widget, ...patch }
})
}
function persistWidgets() {
tempStore.modifySettings("dashboard", {
version: 1,
widgets: serializeWidgets(widgets.value)
})
}
function schedulePersistWidgets() {
clearTimeout(persistTimeout)
persistTimeout = setTimeout(() => {
persistWidgets()
}, 120)
}
async function ensureGridStack() {
if (gridStackClass) return gridStackClass
const gridStackModule = await import("gridstack")
gridStackClass = gridStackModule.GridStack
return gridStackClass
}
function gridOptionsFor(widget) {
const definition = widgetDefinitions[widget.id]
return {
x: widget.x,
y: widget.y,
w: widget.w,
h: widget.h,
minW: definition.minW,
minH: definition.minH,
id: widget.id
}
}
function handleGridChange(_event, items = []) {
if (isSyncingGrid.value || !isEditMode.value) return
let hasChanges = false
items.forEach((item) => {
const widgetId = item.el?.dataset?.widgetId || item.id
if (!widgetId) return
const current = getWidgetLayout(widgetId)
if (!current) return
const nextPatch = {
x: normalizeNumber(item.x, current.x),
y: normalizeNumber(item.y, current.y),
w: normalizeNumber(item.w, current.w),
h: normalizeNumber(item.h, current.h)
}
if (current.x === nextPatch.x && current.y === nextPatch.y && current.w === nextPatch.w && current.h === nextPatch.h) {
return
}
updateWidget(widgetId, nextPatch)
hasChanges = true
})
if (hasChanges) schedulePersistWidgets()
}
function syncGridInteractivity() {
if (!grid.value) return
grid.value.enableMove(isEditMode.value)
grid.value.enableResize(isEditMode.value)
}
async function syncGridWithDom() {
if (!import.meta.client) return
await nextTick()
if (!gridElement.value) return
const GridStack = await ensureGridStack()
if (!grid.value) {
isSyncingGrid.value = true
grid.value = GridStack.init(
{
column: 12,
float: true,
margin: 16,
cellHeight: 96,
disableOneColumnMode: false,
handle: ".dashboard-widget-drag-handle",
resizable: {
handles: "all"
}
},
gridElement.value
)
grid.value.on("change", handleGridChange)
syncGridInteractivity()
isSyncingGrid.value = false
}
const visibleIds = new Set(visibleWidgets.value.map((widget) => widget.id))
isSyncingGrid.value = true
grid.value.batchUpdate()
;[...grid.value.engine.nodes].forEach((node) => {
const widgetId = node.el?.dataset?.widgetId || node.id
if (!node.el || !node.el.isConnected || !visibleIds.has(widgetId)) {
if (node.el) grid.value.removeWidget(node.el, false, false)
}
})
const domWidgets = [...gridElement.value.querySelectorAll(".grid-stack-item")]
domWidgets.forEach((element) => {
const widget = visibleWidgets.value.find((item) => item.id === element.dataset.widgetId)
if (!widget) return
if (!element.gridstackNode) {
grid.value.makeWidget(element)
}
grid.value.update(element, gridOptionsFor(widget))
})
grid.value.batchUpdate(false)
isSyncingGrid.value = false
}
function addWidget(id) {
const widget = getWidgetLayout(id)
if (!widget || widget.visible) return
updateWidget(id, { visible: true })
persistWidgets()
}
function toggleEditMode() {
isEditMode.value = !isEditMode.value
if (!isEditMode.value) manageCardsOpen.value = false
}
function removeWidget(id) {
if (visibleWidgets.value.length <= 1) {
toast.add({
title: "Letzte Karte",
description: "Mindestens eine Dashboard-Karte sollte sichtbar bleiben.",
color: "orange"
})
return
}
updateWidget(id, { visible: false })
persistWidgets()
}
function resetDashboard() {
widgets.value = getDefaultDashboardWidgets()
persistWidgets()
toast.add({
title: "Dashboard zurückgesetzt",
description: "Das Standardlayout wurde wiederhergestellt.",
color: "primary"
})
}
const visibleWidgets = computed(() =>
widgets.value
.filter((widget) => widget.visible)
.map((widget) => ({
...widgetDefinitions[widget.id],
...widget
}))
)
const hiddenWidgets = computed(() =>
DASHBOARD_WIDGETS.filter((definition) => !getWidgetLayout(definition.id)?.visible)
)
watch(
() => tempStore.settings?.dashboard,
(storedDashboard) => {
const nextWidgets = normalizeDashboardWidgets(storedDashboard?.widgets)
if (widgetsSignature(nextWidgets) === widgetsSignature(widgets.value)) return
widgets.value = nextWidgets
},
{ immediate: true, deep: true }
)
watch(
visibleWidgets,
async () => {
await syncGridWithDom()
},
{ deep: true }
)
watch(isEditMode, () => {
syncGridInteractivity()
})
onMounted(async () => {
if (!widgets.value.length) widgets.value = normalizeDashboardWidgets()
await syncGridWithDom()
})
onBeforeUnmount(() => {
clearTimeout(persistTimeout)
if (grid.value) {
grid.value.off("change", handleGridChange)
grid.value.destroy(false)
grid.value = null
}
})
</script>
<template>
<UDashboardNavbar title="Home">
<template #right>
<div class="flex items-center gap-2">
<UButton
:icon="isEditMode ? 'i-heroicons-check' : 'i-heroicons-pencil-square'"
:color="isEditMode ? 'primary' : 'gray'"
:variant="isEditMode ? 'solid' : 'ghost'"
@click="toggleEditMode"
>
{{ isEditMode ? "Bearbeitung beenden" : "Dashboard bearbeiten" }}
</UButton>
<UButton
v-if="isEditMode && hiddenWidgets.length > 0"
icon="i-heroicons-plus"
color="white"
variant="soft"
@click="manageCardsOpen = true"
>
Karte hinzufügen
</UButton>
<UButton
v-if="isEditMode"
icon="i-heroicons-arrow-path"
color="gray"
variant="ghost"
@click="resetDashboard"
>
Standardlayout
</UButton>
<UButton
v-if="isEditMode"
icon="i-heroicons-squares-2x2"
color="gray"
variant="ghost"
@click="manageCardsOpen = true"
>
Karten verwalten
</UButton>
</div>
</template>
</UDashboardNavbar>
<div v-if="visibleWidgets.length > 0" ref="gridElement" class="grid-stack dashboard-grid overflow-y-auto">
<div
v-for="widget in visibleWidgets"
:key="widget.id"
:class="['grid-stack-item', isEditMode ? 'dashboard-widget-editing' : '']"
:data-widget-id="widget.id"
:gs-x="widget.x"
:gs-y="widget.y"
:gs-w="widget.w"
:gs-h="widget.h"
:gs-min-w="widget.minW"
:gs-min-h="widget.minH"
>
<div class="grid-stack-item-content dashboard-grid-item">
<div class="dashboard-widget-card border border-gray-200 dark:border-gray-800">
<div class="dashboard-widget-header border-b border-gray-200 dark:border-gray-800">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<div :class="['dashboard-widget-drag-handle font-semibold', isEditMode ? 'cursor-move' : 'cursor-default']">
{{ widget.title }}
</div>
<p class="mt-1 text-sm">
{{ widget.description }}
</p>
</div>
<div class="dashboard-widget-header-actions">
<div :id="`dashboard-widget-header-actions-${widget.id}`" class="dashboard-widget-header-target" />
<UButtonGroup v-if="isEditMode" size="xs">
<UButton
color="gray"
variant="soft"
icon="i-heroicons-arrows-pointing-out"
class="dashboard-widget-drag-handle"
/>
<UButton
color="gray"
variant="soft"
icon="i-heroicons-x-mark"
:disabled="visibleWidgets.length <= 1"
@click="removeWidget(widget.id)"
/>
</UButtonGroup>
</div>
</div>
</div>
<div class="dashboard-widget-body">
<component
:is="widget.component"
v-bind="widget.id === 'income-expense'
? { headerTarget: `#dashboard-widget-header-actions-${widget.id}` }
: {}"
/>
</div>
</div>
</div>
</div>
</div>
<div v-else class="rounded-xl border border-dashed border-gray-300 dark:border-gray-700 p-10 text-center">
<p class="text-sm">
Es sind aktuell keine Dashboard-Karten sichtbar.
</p>
<UButton v-if="isEditMode" class="mt-4" icon="i-heroicons-plus" @click="manageCardsOpen = true">
Karte hinzufügen
</UButton>
</div>
<UModal v-model:open="manageCardsOpen">
<template #content>
<UCard>
<template #header>
<div class="flex items-center justify-between gap-3">
<div>
<h2 class="font-semibold">Dashboard-Karten</h2>
<p class="text-sm">
Karten ein- oder ausblenden und bei Bedarf auf das Standardlayout zurücksetzen.
</p>
</div>
<UButton color="gray" variant="ghost" icon="i-heroicons-arrow-path" @click="resetDashboard">
Zurücksetzen
</UButton>
</div>
</template>
<div class="space-y-3">
<div
v-for="definition in DASHBOARD_WIDGETS"
:key="definition.id"
class="flex items-center justify-between gap-3 rounded-lg border border-gray-200 dark:border-gray-800 px-4 py-3"
>
<div>
<p class="font-medium">{{ definition.title }}</p>
<p class="text-sm">{{ definition.description }}</p>
</div>
<UButton
v-if="getWidgetLayout(definition.id)?.visible"
color="gray"
variant="soft"
icon="i-heroicons-minus"
:disabled="visibleWidgets.length <= 1"
@click="removeWidget(definition.id)"
>
Entfernen
</UButton>
<UButton
v-else
color="primary"
variant="soft"
icon="i-heroicons-plus"
@click="addWidget(definition.id)"
>
Hinzufügen
</UButton>
</div>
</div>
</UCard>
</template>
</UModal>
</template>
<style scoped>
.dashboard-grid {
min-height: 60vh;
}
.dashboard-grid-item {
height: 100%;
padding: 0.25rem;
}
.dashboard-widget-card {
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
border-radius: 0.75rem;
box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05);
}
.dashboard-widget-header {
flex: 0 0 auto;
padding: 1rem 1rem 0.875rem;
}
.dashboard-widget-header-actions {
display: flex;
flex: 0 0 auto;
flex-wrap: wrap;
align-items: flex-start;
justify-content: flex-end;
gap: 0.5rem;
}
.dashboard-widget-header-target {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: 0.5rem;
}
.dashboard-widget-body {
flex: 1 1 auto;
min-height: 0;
overflow: hidden;
padding: 1rem;
}
:deep(.grid-stack-item-content) {
inset: 0;
background: transparent;
overflow-y: hidden !important;
}
:deep(.grid-stack-item:not(.dashboard-widget-editing) .ui-resizable-handle) {
display: none;
}
</style>

View File

@@ -34,6 +34,9 @@ const scanForm = reactive({
source: "ADF Duplex", source: "ADF Duplex",
paperSize: "A4", paperSize: "A4",
postprocess: false, postprocess: false,
backgroundCleanup: true,
ocr: true,
ocrLanguage: "deu+eng",
postprocessProfile: "document" postprocessProfile: "document"
}) })
@@ -49,7 +52,8 @@ watch(() => scanForm.format, (format) => {
const applyIx1600Preset = () => { const applyIx1600Preset = () => {
Object.assign(scanForm, { Object.assign(scanForm, {
format: "pdf", resolution: 300, mode: "Color", source: "ADF Duplex", format: "pdf", resolution: 300, mode: "Color", source: "ADF Duplex",
paperSize: "A4", postprocess: false, postprocessProfile: "document" paperSize: "A4", postprocess: false, postprocessProfile: "document",
backgroundCleanup: true, ocr: true, ocrLanguage: "deu+eng"
}) })
} }
@@ -83,6 +87,9 @@ const applyAgentDefaults = (agent) => {
scanForm.source = agent.scanDefaults?.source || "ADF Duplex" scanForm.source = agent.scanDefaults?.source || "ADF Duplex"
scanForm.paperSize = agent.scanDefaults?.paperSize || "A4" scanForm.paperSize = agent.scanDefaults?.paperSize || "A4"
scanForm.postprocess = Boolean(agent.scanDefaults?.postprocess) scanForm.postprocess = Boolean(agent.scanDefaults?.postprocess)
scanForm.backgroundCleanup = agent.scanDefaults?.backgroundCleanup !== false
scanForm.ocr = agent.scanDefaults?.ocr !== false
scanForm.ocrLanguage = agent.scanDefaults?.ocrLanguage || "deu+eng"
scanForm.postprocessProfile = agent.scanDefaults?.postprocessProfile || "document" scanForm.postprocessProfile = agent.scanDefaults?.postprocessProfile || "document"
if (isFeederScan.value) scanForm.format = "pdf" if (isFeederScan.value) scanForm.format = "pdf"
@@ -147,6 +154,11 @@ const waitForJob = async (jobId) => {
const startScan = async () => { const startScan = async () => {
if (!selectedAgentId.value || scanInProgress.value) return if (!selectedAgentId.value || scanInProgress.value) return
if ((scanForm.format === "pdf" && scanForm.ocr && !selectedAgent.value?.capabilities?.ocr) ||
(scanForm.backgroundCleanup && !selectedAgent.value?.capabilities?.backgroundCleanup)) {
toast.add({ title: "Agent aktualisieren", description: "Für OCR und Hintergrundbereinigung benötigt der Scanner-Rechner die aktuelle Agent-Installation mit Tesseract und OpenCV.", color: "error" })
return
}
scanInProgress.value = true scanInProgress.value = true
statusMessage.value = "Scan-Auftrag wird erstellt..." statusMessage.value = "Scan-Auftrag wird erstellt..."
@@ -166,6 +178,9 @@ const startScan = async () => {
source: scanForm.source || null, source: scanForm.source || null,
paperSize: scanForm.paperSize, paperSize: scanForm.paperSize,
postprocess: scanForm.postprocess, postprocess: scanForm.postprocess,
backgroundCleanup: scanForm.backgroundCleanup,
ocr: scanForm.format === "pdf" && scanForm.ocr,
ocrLanguage: scanForm.ocrLanguage,
postprocessProfile: scanForm.postprocessProfile postprocessProfile: scanForm.postprocessProfile
}, },
target: { target: {
@@ -319,10 +334,23 @@ loadAgents()
</UFormField> </UFormField>
</div> </div>
<div class="space-y-3">
<UCheckbox v-model="scanForm.backgroundCleanup" label="Scannerhintergrund weiß machen" :disabled="scanInProgress" />
<p class="text-xs text-muted">Erkannte Flächen außerhalb von Bon oder Dokument werden weiß. Das Seitenformat bleibt erhalten; bei unsicheren Rändern bleibt der Scan unverändert.</p>
<UCheckbox v-model="scanForm.ocr" label="Durchsuchbares PDF (OCR)" :disabled="scanForm.format !== 'pdf' || scanInProgress" />
<UFormField v-if="scanForm.ocr && scanForm.format === 'pdf'" label="OCR-Sprache">
<USelectMenu v-model="scanForm.ocrLanguage" :items="[
{ label: 'Deutsch und Englisch', value: 'deu+eng' },
{ label: 'Deutsch', value: 'deu' }, { label: 'Englisch', value: 'eng' }
]" value-key="value" label-key="label" :disabled="scanInProgress" />
</UFormField>
<p class="text-xs text-muted">Texterkennung erfolgt vor dem Upload auf dem Scanner-Rechner, auch für normale Dateien ohne Eingangsbeleg.</p>
</div>
<div class="grid gap-3 sm:grid-cols-[auto_minmax(0,1fr)]"> <div class="grid gap-3 sm:grid-cols-[auto_minmax(0,1fr)]">
<UCheckbox <UCheckbox
v-model="scanForm.postprocess" v-model="scanForm.postprocess"
label="OpenCV-Korrektur" label="Zuschnitt und Ausrichtung zusätzlich korrigieren"
:disabled="scanInProgress" :disabled="scanInProgress"
/> />
<UFormField label="Profil"> <UFormField label="Profil">

View File

@@ -0,0 +1,87 @@
<script setup lang="ts">
const { overview, job, pending, connected, submitting, refreshing, message, accessDenied, recovery, busy, available, canApply,
check, apply, refresh, sendPending } = useSelfhostUpdates()
const build = useRuntimeConfig().public
const confirmOpen = ref(false)
const confirmed = ref(false)
const phaseLabels: Record<string, string> = {
preparing: 'Update vorbereiten', validating: 'Release und Konfiguration prüfen', checked: 'Prüfung abgeschlossen',
pulling: 'Images herunterladen', maintenance: 'Dienste anhalten und Backup erstellen', backed_up: 'Backup abgeschlossen',
applying: 'Compose und Konfiguration aktualisieren', retiring_matrix: 'Alte Matrix-Dienste stilllegen',
migrating: 'Datenbank migrieren', starting: 'Dienste starten', verifying: 'Version und Erreichbarkeit prüfen',
complete: 'Abgeschlossen', resolved: 'Wiederherstellung bestätigt',
}
const statusLabels: Record<string, string> = {
queued: 'Auftrag angenommen', running: 'Auftrag läuft', checked: 'Update verfügbar', up_to_date: 'FEDEO ist aktuell',
succeeded: 'Update erfolgreich abgeschlossen', failed: 'Auftrag fehlgeschlagen', interrupted: 'Auftrag unterbrochen',
idle: 'Bereit', recovered: 'Wiederherstellung bestätigt', unknown: 'Auftragsstatus wird geladen',
}
const progress = computed(() => job.value?.progress || (busy.value ? overview.value?.engine : null))
const statusLabel = computed(() => phaseLabels[progress.value?.phase || ''] || statusLabels[job.value?.status || 'idle'])
const backupSize = computed(() => {
const bytes = job.value?.plan?.estimatedBackupBytes
return bytes === undefined ? 'Wird vor dem Start geprüft' : `${(bytes / 1024 ** 3).toLocaleString('de-DE', { maximumFractionDigits: 1 })} GiB`
})
const openConfirmation = () => { confirmed.value = false; confirmOpen.value = true }
const start = async () => {
if (!confirmed.value || !canApply.value) return
confirmOpen.value = false
await apply()
}
const reload = () => window.location.reload()
</script>
<template>
<UCard :ui="{ root: 'rounded-lg' }">
<template #header>
<div class="flex flex-wrap items-center justify-between gap-3">
<div class="flex items-center gap-2">
<UIcon name="i-heroicons-arrow-down-tray" class="size-5 text-primary" />
<h2 class="text-base font-semibold text-highlighted">FEDEO-Updates</h2>
</div>
<UBadge color="neutral" variant="soft">Installiert: {{ overview?.backend.version || build.fedeoVersion }}</UBadge>
</div>
</template>
<div class="space-y-4">
<p class="text-sm text-muted">Aktualisiert die gesamte Selfhost-Instanz einschließlich Docker Compose und Datenbank. Vor Änderungen wird ein Backup erstellt.</p>
<div role="status" aria-live="polite" class="space-y-3">
<UAlert v-if="message" :color="busy ? 'warning' : 'neutral'" variant="soft" :title="message" />
<UAlert v-if="recovery" color="error" title="Wiederherstellung auf dem Host erforderlich" description="Weitere Updates sind gesperrt. Den letzten Auftrag und die Backups auf dem Host prüfen." />
<UAlert v-if="job?.error" color="error" :title="job.error.message" />
<UAlert v-if="job?.plan?.requiresComposeAdoption" color="warning" title="Compose-Datei zuerst auf dem Host übernehmen" description="Die bestehende Konfiguration benötigt eine manuelle Prüfung mit dem CLI-Updater. Anschließend hier erneut nach Updates suchen." />
<p v-if="job || busy" class="text-sm font-medium">{{ statusLabel }}</p>
<UProgress v-if="busy && !pending" aria-label="Update-Fortschritt" />
</div>
<dl v-if="job?.plan" class="grid gap-3 text-sm sm:grid-cols-3">
<div><dt class="text-muted">Geprüfte Version</dt><dd class="font-medium">{{ job.plan.version }}</dd></div>
<div><dt class="text-muted">Commit</dt><dd class="font-mono" :title="job.plan.revision">{{ job.plan.revision?.slice(0, 8) }}</dd></div>
<div><dt class="text-muted">Geschätztes Backup</dt><dd>{{ backupSize }}</dd></div>
</dl>
<p v-if="job?.plan?.retiredServices.length" class="text-sm text-muted">Alte Matrix-Dienste werden stillgelegt; Daten und Container bleiben erhalten: {{ job.plan.retiredServices.join(', ') }}.</p>
<p v-if="job?.requestId" class="break-all text-xs text-muted">Auftrag: {{ job.requestId }}</p>
<div class="flex flex-wrap gap-2">
<UButton icon="i-heroicons-magnifying-glass" :disabled="!available" :loading="submitting && pending?.command === 'check'" @click="check">Nach Updates suchen</UButton>
<UButton v-if="canApply" icon="i-heroicons-arrow-down-tray" @click="openConfirmation">Update auf {{ job?.plan?.version }} installieren</UButton>
<UButton v-if="pending && connected && !accessDenied" color="warning" :loading="submitting" @click="sendPending">Auftrag erneut senden</UButton>
<UButton v-if="!connected && !accessDenied" color="neutral" variant="outline" :loading="refreshing" @click="refresh">Verbindung prüfen</UButton>
<UButton v-if="job?.command === 'apply' && job.status === 'succeeded'" color="success" @click="reload">Neue Version laden</UButton>
</div>
</div>
</UCard>
<UModal v-model:open="confirmOpen" title="FEDEO-Update installieren" description="Dieses Update betrifft die gesamte Instanz und alle Mandanten.">
<template #body>
<div class="space-y-4 text-sm">
<p>Version <strong>{{ job?.plan?.version }}</strong> wird installiert. Dabei werden die Dienste vorübergehend angehalten, Daten gesichert und Datenbankmigrationen ausgeführt.</p>
<p>Die Verbindung kann währenddessen abbrechen. Der Host setzt den Auftrag fort; diese Seite fragt den Status automatisch wieder ab.</p>
<p>Falls die Migration fehlschlägt, kann eine manuelle Wiederherstellung auf dem Host erforderlich sein.</p>
<UCheckbox v-model="confirmed" label="Die Wartungsunterbrechung für alle Mandanten ist abgestimmt." />
</div>
</template>
<template #footer>
<div class="flex flex-wrap justify-end gap-2">
<UButton color="neutral" variant="outline" @click="confirmOpen = false">Abbrechen</UButton>
<UButton :disabled="!confirmed || !canApply" :loading="submitting" @click="start">Jetzt installieren</UButton>
</div>
</template>
</UModal>
</template>

View File

@@ -144,6 +144,9 @@ export type InstanceAgent = {
resolution?: number resolution?: number
mode?: string mode?: string
source?: string | null source?: string | null
backgroundCleanup?: boolean
ocr?: boolean
ocrLanguage?: string
[key: string]: any [key: string]: any
} }
lastSeenAt?: string | null lastSeenAt?: string | null

View File

@@ -0,0 +1,149 @@
export type UpdateState = {
status: string; requestId?: string; command?: 'check' | 'apply'; phase?: string;
version?: string; revision?: string; recoveryRequired?: boolean; updatedAt?: string;
}
export type UpdateJob = UpdateState & {
progress?: UpdateState; error?: { code: string; message: string };
plan?: { version: string; revision: string; previousVersion?: string; updateAvailable: boolean;
requiresComposeAdoption: boolean; estimatedBackupBytes?: number; retiredServices: string[] };
}
type Overview = {
acceptingRequests: boolean; engine: UpdateState; request?: UpdateJob;
installed: { version?: string }; backend: { version: string; revision: string };
}
type Submission = { command: 'check' | 'apply'; body: { requestId: string; version?: string; checkRequestId?: string } }
export const useSelfhostUpdates = () => {
const { $api } = useNuxtApp()
const auth = useAuthStore()
const overview = ref<Overview | null>(null)
const job = ref<UpdateJob | null>(null)
const pending = ref<Submission | null>(null)
const connected = ref(false)
const submitting = ref(false)
const refreshing = ref(false)
const message = ref('')
const accessDenied = ref(false)
let stopped = false
let generation = 0
let timer: ReturnType<typeof setTimeout> | undefined
const storageKey = `fedeo:update:${auth.user?.user_id || auth.user?.id || auth.user?.email}`
const active = (value?: UpdateState | null) => ['queued', 'running'].includes(value?.status || '')
const recovery = computed(() => !!overview.value?.engine.recoveryRequired && !active(overview.value?.engine))
const busy = computed(() => submitting.value || !!pending.value || active(job.value) || active(overview.value?.engine))
const available = computed(() => connected.value && !accessDenied.value && overview.value?.acceptingRequests && !busy.value && !recovery.value)
const canApply = computed(() => !!(available.value && job.value?.command === 'check' && job.value.status === 'checked'
&& job.value.plan?.updateAvailable && !job.value.plan.requiresComposeAdoption))
const persist = () => {
// Losing localStorage must not lose the durable host job; status also returns the latest request.
try { localStorage.setItem(storageKey, JSON.stringify({ requestId: job.value?.requestId, pending: pending.value })) } catch {}
}
const api = <T>(path: string, options = {}) => $api<T>(`/api/admin/updates/${path}`, {
retry: 0, timeout: 8000, onRequestError: () => {}, onResponseError: () => {}, ...options,
})
const errorStatus = (error: any) => error?.statusCode || error?.response?.status
const report = (error: any) => {
if ([401, 403].includes(errorStatus(error))) {
accessDenied.value = true
message.value = 'Bitte erneut als Systemadministrator anmelden.'
} else if (pending.value) {
message.value = 'Der Start ist noch nicht bestätigt. Der Auftragsstatus wird weiter geprüft.'
} else if (job.value?.command === 'apply' && active(job.value)) {
message.value = 'Verbindung unterbrochen. Der Host führt das Update weiter aus; die Verbindung wird automatisch erneut geprüft.'
} else {
message.value = error?.data?.error?.message || 'Der Update-Dienst ist nicht erreichbar. Dienst und Socket-Einbindung auf dem Host prüfen.'
}
}
const refresh = async () => {
if (refreshing.value || stopped || accessDenied.value) return
refreshing.value = true
const currentGeneration = generation
try {
const latest = await api<Overview>('status')
if (currentGeneration !== generation || stopped) return
overview.value = latest
// A durable submission may have succeeded even when its HTTP response was lost.
const ownId = pending.value?.body.requestId || job.value?.requestId
if (ownId) {
try {
const result = await api<UpdateJob>(`requests/${encodeURIComponent(ownId)}`)
if (currentGeneration !== generation || stopped) return
job.value = result
pending.value = null
} catch (error) {
if (currentGeneration !== generation || stopped) return
if (errorStatus(error) !== 404) throw error
if (!pending.value) job.value = null
}
}
if (!pending.value && latest.request && (active(latest.request) || !job.value)) job.value = latest.request
connected.value = true
message.value = pending.value ? 'Start noch nicht bestätigt. Du kannst denselben Auftrag erneut senden.' : ''
persist()
} catch (error) {
if (currentGeneration !== generation || stopped) return
connected.value = false
report(error)
} finally { refreshing.value = false }
}
const sendPending = async () => {
if (!pending.value || submitting.value || accessDenied.value) return
generation++
submitting.value = true
const submission = pending.value
try {
job.value = await api<UpdateJob>(submission.command, { method: 'POST', body: submission.body })
pending.value = null
connected.value = true
message.value = ''
} catch (error: any) {
// Only a definite rejection releases the ID. Timeouts/5xx keep it for idempotent recovery.
if ([400, 401, 403, 404, 409, 413, 415].includes(errorStatus(error))) {
pending.value = null
job.value = null
message.value = error?.data?.error?.message || 'Der Auftrag wurde abgelehnt. Bitte erneut prüfen.'
if ([401, 403].includes(errorStatus(error))) report(error)
} else {
connected.value = false
report(error)
}
} finally { submitting.value = false; persist() }
}
const check = async () => {
if (!available.value) return
pending.value = { command: 'check', body: { requestId: crypto.randomUUID() } }
job.value = null
persist()
await sendPending()
}
const apply = async () => {
if (!canApply.value) return
pending.value = { command: 'apply', body: {
requestId: crypto.randomUUID(), version: job.value!.plan!.version, checkRequestId: job.value!.requestId!,
} }
persist()
await sendPending()
}
const poll = async () => {
await refresh()
if (!stopped && !accessDenied.value) timer = setTimeout(poll, busy.value || !connected.value ? 3000 : 15000)
}
onMounted(() => {
try {
const saved = JSON.parse(localStorage.getItem(storageKey) || 'null')
const validId = (value: unknown) => typeof value === 'string' && /^[A-Za-z0-9][A-Za-z0-9_-]{7,79}$/.test(value)
if (validId(saved?.requestId)) job.value = { status: 'unknown', requestId: saved.requestId }
const p = saved?.pending
if (p && ['check', 'apply'].includes(p.command) && validId(p.body?.requestId)
&& (p.command === 'check' || (validId(p.body?.checkRequestId) && /^\d+\.\d+\.\d+$/.test(p.body?.version)))) {
pending.value = { command: p.command, body: { requestId: p.body.requestId,
...(p.command === 'apply' ? { version: p.body.version, checkRequestId: p.body.checkRequestId } : {}) } }
}
} catch {}
void poll()
})
onBeforeUnmount(() => { stopped = true; clearTimeout(timer) })
return { overview, job, pending, connected, submitting, refreshing, message, accessDenied, recovery, busy, available, canApply,
check, apply, refresh, sendPending }
}

View File

@@ -31,6 +31,9 @@ const editState = reactive({
source: "", source: "",
paperSize: "A4", paperSize: "A4",
postprocess: false, postprocess: false,
backgroundCleanup: true,
ocr: true,
ocrLanguage: "deu+eng",
postprocessProfile: "document", postprocessProfile: "document",
}) })
@@ -69,13 +72,17 @@ const applyAgentToForm = (agent: InstanceAgent | null) => {
editState.source = agent.scanDefaults?.source || "" editState.source = agent.scanDefaults?.source || ""
editState.paperSize = agent.scanDefaults?.paperSize || "A4" editState.paperSize = agent.scanDefaults?.paperSize || "A4"
editState.postprocess = Boolean(agent.scanDefaults?.postprocess) editState.postprocess = Boolean(agent.scanDefaults?.postprocess)
editState.backgroundCleanup = agent.scanDefaults?.backgroundCleanup !== false
editState.ocr = agent.scanDefaults?.ocr !== false
editState.ocrLanguage = agent.scanDefaults?.ocrLanguage || "deu+eng"
editState.postprocessProfile = agent.scanDefaults?.postprocessProfile || "document" editState.postprocessProfile = agent.scanDefaults?.postprocessProfile || "document"
} }
const applyIx1600Preset = () => { const applyIx1600Preset = () => {
Object.assign(editState, { Object.assign(editState, {
format: "pdf", resolution: 300, mode: "Color", source: "ADF Duplex", format: "pdf", resolution: 300, mode: "Color", source: "ADF Duplex",
paperSize: "A4", postprocess: false, postprocessProfile: "document" paperSize: "A4", postprocess: false, postprocessProfile: "document",
backgroundCleanup: true, ocr: true, ocrLanguage: "deu+eng"
}) })
} }
@@ -132,6 +139,9 @@ const saveAgent = async () => {
source: editState.source || null, source: editState.source || null,
paperSize: editState.paperSize, paperSize: editState.paperSize,
postprocess: editState.postprocess, postprocess: editState.postprocess,
backgroundCleanup: editState.backgroundCleanup,
ocr: editState.format === "pdf" && editState.ocr,
ocrLanguage: editState.ocrLanguage,
postprocessProfile: editState.postprocessProfile, postprocessProfile: editState.postprocessProfile,
}, },
}) })
@@ -312,8 +322,21 @@ onMounted(async () => {
]" value-key="value" label-key="label" /> ]" value-key="value" label-key="label" />
</UFormField> </UFormField>
<div class="space-y-3">
<UCheckbox v-model="editState.backgroundCleanup" label="Scannerhintergrund weiß machen" />
<p class="text-xs text-muted">Erkannte Flächen außerhalb von Bon oder Dokument werden weiß. Das Seitenformat bleibt erhalten; bei unsicheren Rändern bleibt der Scan unverändert.</p>
<UCheckbox v-model="editState.ocr" label="Durchsuchbares PDF (OCR)" :disabled="editState.format !== 'pdf'" />
<UFormField v-if="editState.ocr && editState.format === 'pdf'" label="OCR-Sprache">
<USelectMenu v-model="editState.ocrLanguage" :items="[
{ label: 'Deutsch und Englisch', value: 'deu+eng' },
{ label: 'Deutsch', value: 'deu' }, { label: 'Englisch', value: 'eng' }
]" value-key="value" label-key="label" />
</UFormField>
<p class="text-xs text-muted">Texterkennung erfolgt vor dem Upload auf dem Scanner-Rechner, auch für normale Dateien ohne Eingangsbeleg.</p>
</div>
<div class="grid gap-3 sm:grid-cols-[auto_minmax(0,1fr)]"> <div class="grid gap-3 sm:grid-cols-[auto_minmax(0,1fr)]">
<UCheckbox v-model="editState.postprocess" label="OpenCV-Nachbearbeitung" /> <UCheckbox v-model="editState.postprocess" label="Zuschnitt und Ausrichtung zusätzlich korrigieren" />
<UFormField label="Profil"> <UFormField label="Profil">
<USelectMenu <USelectMenu
v-model="editState.postprocessProfile" v-model="editState.postprocessProfile"

View File

@@ -96,6 +96,7 @@ onMounted(async () => {
<UDashboardPanelContent> <UDashboardPanelContent>
<div class="space-y-6"> <div class="space-y-6">
<SelfhostUpdateCard v-if="auth.user?.is_admin" />
<UAlert <UAlert
:icon="overallStatus === 'ok' ? 'i-heroicons-check-circle' : 'i-heroicons-exclamation-triangle'" :icon="overallStatus === 'ok' ? 'i-heroicons-check-circle' : 'i-heroicons-exclamation-triangle'"
:color="overallStatus === 'ok' ? 'success' : 'warning'" :color="overallStatus === 'ok' ? 'success' : 'warning'"

View File

@@ -35,6 +35,7 @@ const guard = ref(true)
const itemInfo = ref({ const itemInfo = ref({
type: "invoices", type: "invoices",
state: "Entwurf",
taxType: "Standard", taxType: "Standard",
customer: null, customer: null,
contact: null, contact: null,
@@ -2094,7 +2095,7 @@ const setRowData = async (row, service = {sellingPriceComposed: {}}, product = {
<ArchiveButton <ArchiveButton
color="error" color="error"
type="createddocuments" type="createddocuments"
v-if="!isTemplateMode && (itemInfo.state === 'Entwurf' || itemInfo.type === 'serialInvoices')" v-if="itemInfo.id && !isTemplateMode && (itemInfo.state === 'Entwurf' || itemInfo.type === 'serialInvoices')"
variant="outline" variant="outline"
@confirmed="useEntities('createddocuments').update(itemInfo.id,{archived: true}), @confirmed="useEntities('createddocuments').update(itemInfo.id,{archived: true}),
router.push('/')" router.push('/')"

View File

@@ -1,598 +1,27 @@
<script setup> <script setup>
import { setPageLayout } from "#app" import { setPageLayout } from "#app"
import "gridstack/dist/gridstack.min.css" import DashboardHome from "~/components/DashboardHome.vue"
const DisplayIncomeAndExpenditure = defineAsyncComponent(() => import("~/components/displayIncomeAndExpenditure.vue"))
const DisplayOpenBalances = defineAsyncComponent(() => import("~/components/displayOpenBalances.vue"))
const DisplayBankaccounts = defineAsyncComponent(() => import("~/components/displayBankaccounts.vue"))
const DisplayProjectsInPhases = defineAsyncComponent(() => import("~/components/displayProjectsInPhases.vue"))
const DisplayOpenTasks = defineAsyncComponent(() => import("~/components/displayOpenTasks.vue"))
const DisplayTaxSummary = defineAsyncComponent(() => import("~/components/displayTaxSummary.vue"))
const DisplayBWASummary = defineAsyncComponent(() => import("~/components/displayBWASummary.vue"))
setPageLayout("default") setPageLayout("default")
const auth = useAuthStore()
const toast = useToast()
const tempStore = useTempStore()
const gridElement = ref(null)
const grid = shallowRef(null)
const isEditMode = ref(false)
const manageCardsOpen = ref(false)
const widgets = ref([])
let gridStackClass = null
let persistTimeout = null
const isSyncingGrid = ref(false)
const DASHBOARD_WIDGETS = [
{
id: "income-expense",
title: "Einnahmen und Ausgaben",
description: "Umsatz- und Ausgabenentwicklung",
component: markRaw(DisplayIncomeAndExpenditure),
defaultLayout: { x: 0, y: 0, w: 12, h: 4 },
minW: 4,
minH: 4
},
{
id: "open-balances",
title: "Buchhaltung",
description: "Offene Rechnungen und Entwurfsstatus",
component: markRaw(DisplayOpenBalances),
defaultLayout: { x: 0, y: 4, w: 4, h: 3 },
minW: 3,
minH: 3
},
{
id: "bankaccounts",
title: "Bank",
description: "Kontostand und offene Zuordnungen",
component: markRaw(DisplayBankaccounts),
defaultLayout: { x: 4, y: 4, w: 4, h: 3 },
minW: 3,
minH: 3
},
{
id: "projects",
title: "Projekte",
description: "Aktuelle Projektphasen",
component: markRaw(DisplayProjectsInPhases),
defaultLayout: { x: 8, y: 4, w: 4, h: 3 },
minW: 3,
minH: 3
},
{
id: "tasks",
title: "Aufgaben",
description: "Offene Aufgaben des aktuellen Nutzers",
component: markRaw(DisplayOpenTasks),
defaultLayout: { x: 0, y: 7, w: 4, h: 3 },
minW: 3,
minH: 3
},
{
id: "tax-summary",
title: "USt aktuell",
description: "USt, Vorsteuer und Saldo des aktuellen Zeitraums",
component: markRaw(DisplayTaxSummary),
defaultLayout: { x: 4, y: 7, w: 4, h: 3 },
minW: 3,
minH: 3
},
{
id: "bwa-summary",
title: "BWA aktuell",
description: "Einnahmen, Ausgaben und Ergebnis des aktuellen Monats",
component: markRaw(DisplayBWASummary),
defaultLayout: { x: 8, y: 7, w: 4, h: 3 },
minW: 3,
minH: 3
}
]
const widgetDefinitions = Object.fromEntries(DASHBOARD_WIDGETS.map((widget) => [widget.id, widget]))
function getDefaultDashboardWidgets() {
return DASHBOARD_WIDGETS.map((definition) => ({
id: definition.id,
x: definition.defaultLayout.x,
y: definition.defaultLayout.y,
w: definition.defaultLayout.w,
h: definition.defaultLayout.h,
visible: true
}))
}
function normalizeNumber(value, fallback) {
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : fallback
}
function serializeWidgets(input) {
return input.map((widget) => ({
id: widget.id,
x: widget.x,
y: widget.y,
w: widget.w,
h: widget.h,
visible: widget.visible
}))
}
function normalizeDashboardWidgets(storedWidgets) {
const storedById = new Map(
(Array.isArray(storedWidgets) ? storedWidgets : [])
.filter((widget) => widget?.id && widgetDefinitions[widget.id])
.map((widget) => [widget.id, widget])
)
return DASHBOARD_WIDGETS.map((definition) => {
const stored = storedById.get(definition.id) || {}
return {
id: definition.id,
x: normalizeNumber(stored.x, definition.defaultLayout.x),
y: normalizeNumber(stored.y, definition.defaultLayout.y),
w: normalizeNumber(stored.w, definition.defaultLayout.w),
h: normalizeNumber(stored.h, definition.defaultLayout.h),
visible: typeof stored.visible === "boolean" ? stored.visible : true
}
})
}
function widgetsSignature(input) {
return JSON.stringify(serializeWidgets(input))
}
function getWidgetLayout(id) {
return widgets.value.find((widget) => widget.id === id)
}
function updateWidget(id, patch) {
widgets.value = widgets.value.map((widget) => {
if (widget.id !== id) return widget
return { ...widget, ...patch }
})
}
function persistWidgets() {
tempStore.modifySettings("dashboard", {
version: 1,
widgets: serializeWidgets(widgets.value)
})
}
function schedulePersistWidgets() {
clearTimeout(persistTimeout)
persistTimeout = setTimeout(() => {
persistWidgets()
}, 120)
}
async function ensureGridStack() {
if (gridStackClass) return gridStackClass
const gridStackModule = await import("gridstack")
gridStackClass = gridStackModule.GridStack
return gridStackClass
}
function gridOptionsFor(widget) {
const definition = widgetDefinitions[widget.id]
return {
x: widget.x,
y: widget.y,
w: widget.w,
h: widget.h,
minW: definition.minW,
minH: definition.minH,
id: widget.id
}
}
function handleGridChange(_event, items = []) {
if (isSyncingGrid.value || !isEditMode.value) return
let hasChanges = false
items.forEach((item) => {
const widgetId = item.el?.dataset?.widgetId || item.id
if (!widgetId) return
const current = getWidgetLayout(widgetId)
if (!current) return
const nextPatch = {
x: normalizeNumber(item.x, current.x),
y: normalizeNumber(item.y, current.y),
w: normalizeNumber(item.w, current.w),
h: normalizeNumber(item.h, current.h)
}
if (current.x === nextPatch.x && current.y === nextPatch.y && current.w === nextPatch.w && current.h === nextPatch.h) {
return
}
updateWidget(widgetId, nextPatch)
hasChanges = true
})
if (hasChanges) schedulePersistWidgets()
}
function syncGridInteractivity() {
if (!grid.value) return
grid.value.enableMove(isEditMode.value)
grid.value.enableResize(isEditMode.value)
}
async function syncGridWithDom() {
if (!import.meta.client) return
await nextTick()
if (!gridElement.value) return
const GridStack = await ensureGridStack()
if (!grid.value) {
isSyncingGrid.value = true
grid.value = GridStack.init(
{
column: 12,
float: true,
margin: 16,
cellHeight: 96,
disableOneColumnMode: false,
handle: ".dashboard-widget-drag-handle",
resizable: {
handles: "all"
}
},
gridElement.value
)
grid.value.on("change", handleGridChange)
syncGridInteractivity()
isSyncingGrid.value = false
}
const visibleIds = new Set(visibleWidgets.value.map((widget) => widget.id))
isSyncingGrid.value = true
grid.value.batchUpdate()
;[...grid.value.engine.nodes].forEach((node) => {
const widgetId = node.el?.dataset?.widgetId || node.id
if (!node.el || !node.el.isConnected || !visibleIds.has(widgetId)) {
if (node.el) grid.value.removeWidget(node.el, false, false)
}
})
const domWidgets = [...gridElement.value.querySelectorAll(".grid-stack-item")]
domWidgets.forEach((element) => {
const widget = visibleWidgets.value.find((item) => item.id === element.dataset.widgetId)
if (!widget) return
if (!element.gridstackNode) {
grid.value.makeWidget(element)
}
grid.value.update(element, gridOptionsFor(widget))
})
grid.value.batchUpdate(false)
isSyncingGrid.value = false
}
function addWidget(id) {
const widget = getWidgetLayout(id)
if (!widget || widget.visible) return
updateWidget(id, { visible: true })
persistWidgets()
}
function toggleEditMode() {
isEditMode.value = !isEditMode.value
if (!isEditMode.value) manageCardsOpen.value = false
}
function removeWidget(id) {
if (visibleWidgets.value.length <= 1) {
toast.add({
title: "Letzte Karte",
description: "Mindestens eine Dashboard-Karte sollte sichtbar bleiben.",
color: "orange"
})
return
}
updateWidget(id, { visible: false })
persistWidgets()
}
function resetDashboard() {
widgets.value = getDefaultDashboardWidgets()
persistWidgets()
toast.add({
title: "Dashboard zurückgesetzt",
description: "Das Standardlayout wurde wiederhergestellt.",
color: "primary"
})
}
const visibleWidgets = computed(() =>
widgets.value
.filter((widget) => widget.visible)
.map((widget) => ({
...widgetDefinitions[widget.id],
...widget
}))
)
const hiddenWidgets = computed(() =>
DASHBOARD_WIDGETS.filter((definition) => !getWidgetLayout(definition.id)?.visible)
)
watch(
() => tempStore.settings?.dashboard,
(storedDashboard) => {
const nextWidgets = normalizeDashboardWidgets(storedDashboard?.widgets)
if (widgetsSignature(nextWidgets) === widgetsSignature(widgets.value)) return
widgets.value = nextWidgets
},
{ immediate: true, deep: true }
)
watch(
visibleWidgets,
async () => {
await syncGridWithDom()
},
{ deep: true }
)
watch(isEditMode, () => {
syncGridInteractivity()
})
onMounted(async () => {
if (!widgets.value.length) widgets.value = normalizeDashboardWidgets()
await syncGridWithDom()
})
onBeforeUnmount(() => {
clearTimeout(persistTimeout)
if (grid.value) {
grid.value.off("change", handleGridChange)
grid.value.destroy(false)
grid.value = null
}
})
</script> </script>
<template> <template>
<UDashboardNavbar title="Home"> <DashboardHome v-if="auth.activeTenant > 0" :key="auth.activeTenant" />
<template #right> <template v-else>
<div class="flex items-center gap-2"> <UDashboardNavbar title="Home" />
<UButton <UDashboardPanelContent>
:icon="isEditMode ? 'i-heroicons-check' : 'i-heroicons-pencil-square'" <UCard class="mx-auto mt-8 max-w-lg">
:color="isEditMode ? 'primary' : 'gray'" <div class="space-y-4">
:variant="isEditMode ? 'solid' : 'ghost'" <h1 class="text-lg font-semibold">Mandant auswählen</h1>
@click="toggleEditMode" <template v-if="auth.tenants.length">
> <p class="text-sm text-muted">Wähle zuerst einen Mandanten, um dessen Dashboard zu laden.</p>
{{ isEditMode ? "Bearbeitung beenden" : "Dashboard bearbeiten" }} <TenantDropdown class="w-full" />
</UButton> </template>
<UButton <p v-else class="text-sm text-muted">Deinem Benutzer ist noch kein Mandant zugeordnet.</p>
v-if="isEditMode && hiddenWidgets.length > 0" <UButton v-if="auth.user?.is_admin" to="/administration/tenants" variant="outline">Mandanten verwalten</UButton>
icon="i-heroicons-plus"
color="white"
variant="soft"
@click="manageCardsOpen = true"
>
Karte hinzufügen
</UButton>
<UButton
v-if="isEditMode"
icon="i-heroicons-arrow-path"
color="gray"
variant="ghost"
@click="resetDashboard"
>
Standardlayout
</UButton>
<UButton
v-if="isEditMode"
icon="i-heroicons-squares-2x2"
color="gray"
variant="ghost"
@click="manageCardsOpen = true"
>
Karten verwalten
</UButton>
</div>
</template>
</UDashboardNavbar>
<div v-if="visibleWidgets.length > 0" ref="gridElement" class="grid-stack dashboard-grid overflow-y-auto">
<div
v-for="widget in visibleWidgets"
:key="widget.id"
:class="['grid-stack-item', isEditMode ? 'dashboard-widget-editing' : '']"
:data-widget-id="widget.id"
:gs-x="widget.x"
:gs-y="widget.y"
:gs-w="widget.w"
:gs-h="widget.h"
:gs-min-w="widget.minW"
:gs-min-h="widget.minH"
>
<div class="grid-stack-item-content dashboard-grid-item">
<div class="dashboard-widget-card border border-gray-200 dark:border-gray-800">
<div class="dashboard-widget-header border-b border-gray-200 dark:border-gray-800">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<div :class="['dashboard-widget-drag-handle font-semibold', isEditMode ? 'cursor-move' : 'cursor-default']">
{{ widget.title }}
</div>
<p class="mt-1 text-sm">
{{ widget.description }}
</p>
</div>
<div class="dashboard-widget-header-actions">
<div :id="`dashboard-widget-header-actions-${widget.id}`" class="dashboard-widget-header-target" />
<UButtonGroup v-if="isEditMode" size="xs">
<UButton
color="gray"
variant="soft"
icon="i-heroicons-arrows-pointing-out"
class="dashboard-widget-drag-handle"
/>
<UButton
color="gray"
variant="soft"
icon="i-heroicons-x-mark"
:disabled="visibleWidgets.length <= 1"
@click="removeWidget(widget.id)"
/>
</UButtonGroup>
</div>
</div>
</div>
<div class="dashboard-widget-body">
<component
:is="widget.component"
v-bind="widget.id === 'income-expense'
? { headerTarget: `#dashboard-widget-header-actions-${widget.id}` }
: {}"
/>
</div>
</div>
</div>
</div>
</div>
<div v-else class="rounded-xl border border-dashed border-gray-300 dark:border-gray-700 p-10 text-center">
<p class="text-sm">
Es sind aktuell keine Dashboard-Karten sichtbar.
</p>
<UButton v-if="isEditMode" class="mt-4" icon="i-heroicons-plus" @click="manageCardsOpen = true">
Karte hinzufügen
</UButton>
</div>
<UModal v-model:open="manageCardsOpen">
<template #content>
<UCard>
<template #header>
<div class="flex items-center justify-between gap-3">
<div>
<h2 class="font-semibold">Dashboard-Karten</h2>
<p class="text-sm">
Karten ein- oder ausblenden und bei Bedarf auf das Standardlayout zurücksetzen.
</p>
</div>
<UButton color="gray" variant="ghost" icon="i-heroicons-arrow-path" @click="resetDashboard">
Zurücksetzen
</UButton>
</div>
</template>
<div class="space-y-3">
<div
v-for="definition in DASHBOARD_WIDGETS"
:key="definition.id"
class="flex items-center justify-between gap-3 rounded-lg border border-gray-200 dark:border-gray-800 px-4 py-3"
>
<div>
<p class="font-medium">{{ definition.title }}</p>
<p class="text-sm">{{ definition.description }}</p>
</div>
<UButton
v-if="getWidgetLayout(definition.id)?.visible"
color="gray"
variant="soft"
icon="i-heroicons-minus"
:disabled="visibleWidgets.length <= 1"
@click="removeWidget(definition.id)"
>
Entfernen
</UButton>
<UButton
v-else
color="primary"
variant="soft"
icon="i-heroicons-plus"
@click="addWidget(definition.id)"
>
Hinzufügen
</UButton>
</div>
</div> </div>
</UCard> </UCard>
</template> </UDashboardPanelContent>
</UModal> </template>
</template> </template>
<style scoped>
.dashboard-grid {
min-height: 60vh;
}
.dashboard-grid-item {
height: 100%;
padding: 0.25rem;
}
.dashboard-widget-card {
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
border-radius: 0.75rem;
box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05);
}
.dashboard-widget-header {
flex: 0 0 auto;
padding: 1rem 1rem 0.875rem;
}
.dashboard-widget-header-actions {
display: flex;
flex: 0 0 auto;
flex-wrap: wrap;
align-items: flex-start;
justify-content: flex-end;
gap: 0.5rem;
}
.dashboard-widget-header-target {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: 0.5rem;
}
.dashboard-widget-body {
flex: 1 1 auto;
min-height: 0;
overflow: hidden;
padding: 1rem;
}
:deep(.grid-stack-item-content) {
inset: 0;
background: transparent;
overflow-y: hidden !important;
}
:deep(.grid-stack-item:not(.dashboard-widget-editing) .ui-resizable-handle) {
display: none;
}
</style>

View File

@@ -280,7 +280,6 @@ export const useAuthStore = defineStore("auth", {
body: { email, password, rememberMe } body: { email, password, rememberMe }
}) })
console.log("Token: " + token)
// Tokens abhängig von „Angemeldet bleiben“ dauerhaft oder nur für diese Sitzung speichern // Tokens abhängig von „Angemeldet bleiben“ dauerhaft oder nur für diese Sitzung speichern
this.setSession(token, refreshToken, rememberMe) this.setSession(token, refreshToken, rememberMe)
@@ -289,7 +288,6 @@ export const useAuthStore = defineStore("auth", {
// 2. User Daten laden // 2. User Daten laden
await this.fetchMe(token) await this.fetchMe(token)
console.log(this.user)
// 3. WICHTIG: Jetzt explizit weiterleiten! // 3. WICHTIG: Jetzt explizit weiterleiten!
// Prüfen, ob der User geladen wurde // Prüfen, ob der User geladen wurde
@@ -380,7 +378,6 @@ export const useAuthStore = defineStore("auth", {
}) })
// ... (Deine Logik für tenants, sorting etc. bleibt gleich) ... // ... (Deine Logik für tenants, sorting etc. bleibt gleich) ...
console.log(me)
this.user = me.user this.user = me.user
this.permissions = me.permissions this.permissions = me.permissions
this.tenants = me.tenants this.tenants = me.tenants
@@ -394,6 +391,9 @@ export const useAuthStore = defineStore("auth", {
if(this.profile?.temp_config) tempStore.setStoredTempConfig(this.profile.temp_config) if(this.profile?.temp_config) tempStore.setStoredTempConfig(this.profile.temp_config)
this.activeTenant = null
this.activeTenantData = null
if(me.activeTenant > 0) { if(me.activeTenant > 0) {
const normalizedActiveTenant = String(me.activeTenant) const normalizedActiveTenant = String(me.activeTenant)
this.activeTenant = normalizedActiveTenant this.activeTenant = normalizedActiveTenant
@@ -417,7 +417,6 @@ export const useAuthStore = defineStore("auth", {
this.scheduleSessionTimers(tokenToUse) this.scheduleSessionTimers(tokenToUse)
console.log(this)
} catch (err: any) { } catch (err: any) {
// WICHTIG: Hier NICHT this.logout() aufrufen, weil das navigiert! // WICHTIG: Hier NICHT this.logout() aufrufen, weil das navigiert!
@@ -477,7 +476,6 @@ export const useAuthStore = defineStore("auth", {
refreshToken: this.getStoredRefreshToken() refreshToken: this.getStoredRefreshToken()
} }
}) })
console.log(res)
const {token, refreshToken} = res const {token, refreshToken} = res
this.setSession(token, refreshToken, this.sessionPersistent) this.setSession(token, refreshToken, this.sessionPersistent)

View File

@@ -85,13 +85,15 @@ test('stale or inactive agents cannot finish setup; fresh reports save the chose
await ui.run('finishSetup()') await ui.run('finishSetup()')
assert.equal(ui.run('online.value'), false) assert.equal(ui.run('online.value'), false)
assert.equal(ui.requests.length, 0) assert.equal(ui.requests.length, 0)
ui.report({ ...baseAgent, lastSeenAt: new Date().toISOString(), scannerNames: [scanner], capabilities: { scan: true } }) ui.report({ ...baseAgent, lastSeenAt: new Date().toISOString(), scannerNames: [scanner], capabilities: { scan: true, ocr: true, backgroundCleanup: true } })
await ui.run('checkConnection()') await ui.run('checkConnection()')
ui.run('profile.value="ix1600"; step.value=3') ui.run('profile.value="ix1600"; step.value=3')
await ui.run('finishSetup()') await ui.run('finishSetup()')
assert.equal(ui.run('saved.value'), true) assert.equal(ui.run('saved.value'), true)
assert.equal(ui.requests[0][2].preferredScannerName, scanner) assert.equal(ui.requests[0][2].preferredScannerName, scanner)
assert.equal(ui.requests[0][2].scanDefaults.source, 'ADF Duplex') assert.equal(ui.requests[0][2].scanDefaults.source, 'ADF Duplex')
assert.equal(ui.requests[0][2].scanDefaults.ocr, true)
assert.equal(ui.requests[0][2].scanDefaults.backgroundCleanup, true)
assert.equal(ui.run('token.value'), '') assert.equal(ui.run('token.value'), '')
assert.equal(ui.timers.size, 0) assert.equal(ui.timers.size, 0)
ui.run('openTestScan()') ui.run('openTestScan()')
@@ -108,6 +110,16 @@ test('online agent without a detected scanner remains incomplete', async t => {
assert.equal(ui.requests.length, 0) assert.equal(ui.requests.length, 0)
}) })
test('setup cannot finish with an agent missing the processing required by its profile', async t => {
const ui = await setup(t, { ...baseAgent })
ui.report({ ...baseAgent, lastSeenAt: new Date().toISOString(), scannerNames: [scanner], capabilities: { scan: true } })
await ui.run('checkConnection()')
ui.run('profile.value="ix1600"; step.value=3')
await ui.run('finishSetup()')
assert.equal(ui.run('processingMissing.value'), true)
assert.equal(ui.requests.length, 0)
})
test('connection errors stop automatic requests and are visible for retry', async t => { test('connection errors stop automatic requests and are visible for retry', async t => {
const ui = await setup(t, { ...baseAgent }, { getInstanceAgents: async () => { throw Error('offline') } }) const ui = await setup(t, { ...baseAgent }, { getInstanceAgents: async () => { throw Error('offline') } })
ui.run('step.value=2') ui.run('step.value=2')

View File

@@ -0,0 +1,132 @@
const { test } = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const vm = require('node:vm')
const ts = require('typescript')
const vue = require('vue')
const { parse } = require('@vue/compiler-sfc')
const { compile } = require('@vue/compiler-dom')
function mountHome(auth) {
const requests = []
const { descriptor } = parse(fs.readFileSync(__dirname + '/../pages/index.client.vue', 'utf8'))
const context = vm.createContext({ useAuthStore: () => auth, setPageLayout() {} })
vm.runInContext(descriptor.scriptSetup.content.replace(/^import .*$/gm, ''), context)
const remove = node => {
if (node.parent) node.parent.children.splice(node.parent.children.indexOf(node), 1)
node.parent = null
}
const renderer = vue.createRenderer({
createElement: type => ({ type, children: [], parent: null }),
createText: text => ({ text, children: [], parent: null }),
createComment: text => ({ text, children: [], parent: null }),
setElementText: (node, text) => { node.text = text },
setText: (node, text) => { node.text = text },
parentNode: node => node.parent,
nextSibling: node => node.parent?.children[node.parent.children.indexOf(node) + 1] || null,
insert(node, parent, anchor) {
remove(node)
node.parent = parent
const index = anchor ? parent.children.indexOf(anchor) : -1
if (index < 0) parent.children.push(node)
else parent.children.splice(index, 0, node)
},
remove, patchProp() {},
})
const component = {
setup: () => vm.runInContext('({ auth })', context),
render: new Function('Vue', compile(descriptor.template.content, { mode: 'function', prefixIdentifiers: true }).code)(vue),
}
const app = renderer.createApp(component)
app.component('DashboardHome', {
setup() {
requests.push(auth.activeTenant)
return () => vue.h('div', 'Dashboard ' + auth.activeTenant)
},
})
for (const name of ['UDashboardNavbar', 'UDashboardPanelContent', 'UCard', 'UButton', 'TenantDropdown']) {
app.component(name, { setup: (_, { slots }) => () => vue.h('div', slots.default?.()) })
}
const root = { children: [] }
app.mount(root)
const content = node => [node.text || '', ...node.children.map(content)].join(' ')
return { requests, close: () => app.unmount(), text: () => content(root) }
}
test('dashboard data only mounts with a tenant and remounts when the tenant changes', async t => {
const auth = vue.reactive({ activeTenant: null, tenants: [{ id: '1' }, { id: '2' }], user: {} })
const ui = mountHome(auth)
t.after(ui.close)
assert.deepEqual(ui.requests, [])
assert.match(ui.text(), /Mandant auswählen/)
auth.activeTenant = '1'
await vue.nextTick()
assert.deepEqual(ui.requests, ['1'])
auth.activeTenant = '2'
await vue.nextTick()
assert.deepEqual(ui.requests, ['1', '2'])
auth.activeTenant = null
await vue.nextTick()
assert.deepEqual(ui.requests, ['1', '2'])
assert.match(ui.text(), /Mandant auswählen/)
})
test('an account without assigned tenants receives an explanation without loading widgets', t => {
const ui = mountHome(vue.reactive({ activeTenant: null, tenants: [], user: { is_admin: true } }))
t.after(ui.close)
assert.deepEqual(ui.requests, [])
assert.match(ui.text(), /noch kein Mandant zugeordnet/)
assert.match(ui.text(), /Mandanten verwalten/)
})
function authFixture() {
const logs = [], calls = [], sessions = []
const token = 'test-access-token'
const refreshToken = 'test-refresh-token'
let me = { user: { email: 'test@example.com' }, activeTenant: null, tenants: [], permissions: [], profile: null }
const context = vm.createContext({
exports: {}, process: { client: false },
defineStore: (_, definition) => definition,
console: { log: (...args) => logs.push(args), error: (...args) => logs.push(args) },
useTempStore: () => ({ setStoredTempConfig() {} }),
navigateTo() {},
useNuxtApp: () => ({ $api: async (url, options) => {
calls.push({ url, options })
if (url === '/api/me') return me
if (url === '/auth/login' || url === '/api/tenant/switch') return { token, refreshToken }
throw Error('Unexpected API request: ' + url)
} }),
})
const source = fs.readFileSync(__dirname + '/../stores/auth.ts', 'utf8').replace(/^import .*$/gm, '')
vm.runInContext(ts.transpile(source, { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS }), context)
const definition = context.exports.useAuthStore
const store = definition.state()
for (const [name, action] of Object.entries(definition.actions)) store[name] = action.bind(store)
store.getStoredToken = () => token
store.getStoredRefreshToken = () => refreshToken
store.scheduleSessionTimers = () => {}
store.setSession = (...args) => sessions.push(args)
return { store, logs, calls, sessions, token, refreshToken, setMe: value => { me = value } }
}
test('refreshing a session without an active tenant clears the previous tenant state', async () => {
const f = authFixture()
f.store.activeTenant = '12'
f.store.activeTenantData = { id: '12', name: 'Old tenant' }
await f.store.fetchMe()
assert.equal(f.store.activeTenant, null)
assert.equal(f.store.activeTenantData, null)
assert.deepEqual(f.calls.map(call => call.url), ['/api/me'])
})
test('login and tenant switching store credentials without writing them to the console', async () => {
const f = authFixture()
await f.store.login('test@example.com', 'test-password')
await f.store.switchTenant('1')
assert.equal(f.sessions.length, 2)
assert.equal(f.sessions[0][0], f.token)
assert.equal(f.sessions[1][1], f.refreshToken)
const output = JSON.stringify(f.logs)
assert.ok(!output.includes(f.token))
assert.ok(!output.includes(f.refreshToken))
})

View File

@@ -4,7 +4,7 @@ const fs = require('node:fs')
const vm = require('node:vm') const vm = require('node:vm')
const vue = require('vue') const vue = require('vue')
async function setup(getJob, scanData = {}, agents = [{ id: 'agent', active: true, capabilities: { scan: true }, scannerNames: ['fujitsu:ScanSnap iX1600:test'] }]) { async function setup(getJob, scanData = {}, agents = [{ id: 'agent', active: true, capabilities: { scan: true, ocr: true, backgroundCleanup: true }, scannerNames: ['fujitsu:ScanSnap iX1600:test'] }]) {
const notices = [] const notices = []
const requests = [] const requests = []
let unmount let unmount
@@ -38,6 +38,9 @@ test('duplex scanning continues past the former three-minute polling limit', asy
const body = ui.requests.find(request => request.url === '/api/scan-jobs').options.body const body = ui.requests.find(request => request.url === '/api/scan-jobs').options.body
assert.equal(body.settings.source, 'ADF Duplex') assert.equal(body.settings.source, 'ADF Duplex')
assert.equal(body.settings.paperSize, 'A4') assert.equal(body.settings.paperSize, 'A4')
assert.equal(body.settings.ocr, true)
assert.equal(body.settings.ocrLanguage, 'deu+eng')
assert.equal(body.settings.backgroundCleanup, true)
assert.equal(body.target.folder, 'folder-1') assert.equal(body.target.folder, 'folder-1')
}) })
@@ -72,7 +75,7 @@ test('iX1600 preset resets image settings and normalizes the output filename to
}) })
test('setup test scan uses the requested agent even when another agent is first', async () => { test('setup test scan uses the requested agent even when another agent is first', async () => {
const agents = ['first', 'configured'].map(id => ({ id, active: true, capabilities: { scan: true }, scannerNames: ['scanner-' + id] })) const agents = ['first', 'configured'].map(id => ({ id, active: true, capabilities: { scan: true, ocr: true, backgroundCleanup: true }, scannerNames: ['scanner-' + id] }))
const ui = await setup(() => ({ job: { status: 'completed' } }), { agentId: 'configured' }, agents) const ui = await setup(() => ({ job: { status: 'completed' } }), { agentId: 'configured' }, agents)
await ui.run('startScan()') await ui.run('startScan()')
assert.equal(ui.requests.find(request => request.url === '/api/scan-jobs').options.body.agentId, 'configured') assert.equal(ui.requests.find(request => request.url === '/api/scan-jobs').options.body.agentId, 'configured')
@@ -83,3 +86,10 @@ test('an unavailable setup agent never silently scans with another device', asyn
await ui.run('startScan()') await ui.run('startScan()')
assert.equal(ui.requests.filter(request => request.url === '/api/scan-jobs').length, 0) assert.equal(ui.requests.filter(request => request.url === '/api/scan-jobs').length, 0)
}) })
test('an old agent cannot silently produce an image-only PDF when OCR was requested', async () => {
const ui = await setup(() => ({}), {}, [{ id: 'old', active: true, capabilities: { scan: true }, scannerNames: ['scanner'] }])
await ui.run('startScan()')
assert.equal(ui.requests.filter(request => request.url === '/api/scan-jobs').length, 0)
assert.equal(ui.notices[0].title, 'Agent aktualisieren')
})

View File

@@ -0,0 +1,101 @@
const { test } = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const vm = require('node:vm')
const ts = require('typescript')
const vue = require('vue')
const check = { status: 'checked', command: 'check', requestId: 'checked-request', plan: {
version: '3.2.0', revision: 'a'.repeat(40), updateAvailable: true, requiresComposeAdoption: false, retiredServices: [],
} }
const overview = (job = check, engine = { status: 'checked' }) => ({ acceptingRequests: true, engine, request: job, installed: {}, backend: { version: '3.1.0' } })
async function setup(t, api, saved = null) {
const mounted = [], unmounted = [], calls = [], storage = new Map(saved ? [['fedeo:update:admin', saved]] : [])
let nextId = 0
const context = vm.createContext({
ref: vue.ref, computed: vue.computed, onMounted: fn => mounted.push(fn), onBeforeUnmount: fn => unmounted.push(fn),
useAuthStore: () => ({ user: { id: 'admin' } }),
useNuxtApp: () => ({ $api: async (path, options) => { calls.push({ path, options }); return api(path, options) } }),
localStorage: { getItem: key => storage.get(key), setItem: (key, value) => storage.set(key, value) },
crypto: { randomUUID: () => `request-id-${++nextId}` }, setTimeout: () => 1, clearTimeout() {},
})
const source = fs.readFileSync(__dirname + '/../composables/useSelfhostUpdates.ts', 'utf8').replace(/export /g, '')
vm.runInContext(ts.transpile(source, { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.None }) + '\nglobalThis.ui = useSelfhostUpdates()', context)
mounted.forEach(fn => fn()); await new Promise(setImmediate)
t.after(() => unmounted.forEach(fn => fn()))
return { ui: context.ui, calls, storage }
}
test('checked version enables one apply; duplicate click is blocked and polling survives restart', async t => {
let state = overview(), offline = false, finish
const f = await setup(t, async (path, options) => {
if (offline) throw new Error('network')
if (options.method === 'POST') return new Promise(resolve => { finish = resolve })
return path.endsWith('/status') ? state : state.request
})
assert.equal(f.ui.canApply.value, true)
const start = f.ui.apply(); await f.ui.apply()
assert.equal(f.calls.filter(c => c.options.method === 'POST').length, 1)
const body = f.calls.at(-1).options.body
assert.equal(body.version, '3.2.0'); assert.equal(body.checkRequestId, 'checked-request')
state = overview({ status: 'running', command: 'apply', requestId: body.requestId }, { status: 'running', recoveryRequired: true })
finish(state.request); await start
await f.ui.refresh(); assert.equal(f.ui.recovery.value, false)
offline = true; await f.ui.refresh()
assert.match(f.ui.message.value, /Host führt das Update weiter/)
assert.equal(f.ui.canApply.value, false)
offline = false; state = overview({ ...state.request, status: 'succeeded' }, { status: 'succeeded' })
await f.ui.refresh(); assert.equal(f.ui.job.value.status, 'succeeded')
})
test('lost start response is recovered after reload without replaying an update', async t => {
const first = await setup(t, async (path, options) => {
if (options.method === 'POST') throw new Error('response lost')
return path.endsWith('/status') ? overview() : check
})
await first.ui.apply()
const id = first.ui.pending.value.body.requestId
const saved = first.storage.get('fedeo:update:admin')
const running = { status: 'running', command: 'apply', requestId: id }
const second = await setup(t, async path => path.endsWith('/status') ? overview(running) : running, saved)
assert.equal(second.ui.pending.value, null)
assert.equal(second.ui.job.value.requestId, id)
assert.equal(second.calls.some(c => c.options.method === 'POST'), false)
})
test('unconfirmed submission retains its original ID for explicit retry', async t => {
const saved = JSON.stringify({ pending: { command: 'apply', body: { requestId: 'original-request', version: '3.2.0', checkRequestId: 'checked-request' } } })
const f = await setup(t, async (path, options) => {
if (options.method === 'POST') return { status: 'queued', command: 'apply', requestId: options.body.requestId }
if (path.endsWith('/status')) return overview()
throw { statusCode: 404 }
}, saved)
assert.equal(f.ui.canApply.value, false)
await f.ui.sendPending()
assert.equal(f.calls.at(-1).options.body.requestId, 'original-request')
})
test('recovery, up-to-date, unmanaged compose and other active jobs prevent applying', async t => {
for (const state of [overview(check, { status: 'failed', recoveryRequired: true }),
overview({ ...check, status: 'up_to_date' }),
overview({ ...check, plan: { ...check.plan, requiresComposeAdoption: true } }),
overview({ status: 'running', command: 'apply', requestId: 'someone-else' })]) {
const f = await setup(t, async () => state)
assert.equal(f.ui.canApply.value, false)
await f.ui.apply(); assert.equal(f.calls.some(c => c.options.method === 'POST'), false)
}
})
test('an older polling response cannot erase a new submission whose response was lost', async t => {
let hold = false, release
const f = await setup(t, async (path, options) => {
if (options.method === 'POST') throw new Error('response lost')
if (path.endsWith('/status')) return overview()
if (hold) return new Promise(resolve => { release = resolve })
return check
})
hold = true
const polling = f.ui.refresh()
await new Promise(setImmediate)
await f.ui.apply()
const id = f.ui.pending.value.body.requestId
release(check); await polling
assert.equal(f.ui.pending.value.body.requestId, id)
assert.equal(f.ui.canApply.value, false)
})

View File

@@ -26,6 +26,7 @@ export function renderAgentInstaller(bundle, options) {
'FEDEO_POLL_SECONDS=5', 'FEDEO_SCAN_FORMAT=pdf', 'FEDEO_SCAN_RESOLUTION=300', 'FEDEO_POLL_SECONDS=5', 'FEDEO_SCAN_FORMAT=pdf', 'FEDEO_SCAN_RESOLUTION=300',
'FEDEO_SCAN_MODE=Color', 'FEDEO_SCAN_TIMEOUT_SECONDS=900', 'FEDEO_SCAN_MODE=Color', 'FEDEO_SCAN_TIMEOUT_SECONDS=900',
'FEDEO_SCAN_POSTPROCESS=false', 'FEDEO_SCAN_POSTPROCESS_PROFILE=document', '', 'FEDEO_SCAN_POSTPROCESS=false', 'FEDEO_SCAN_POSTPROCESS_PROFILE=document', '',
'FEDEO_SCAN_BACKGROUND_CLEANUP=true', 'FEDEO_SCAN_OCR=true', 'FEDEO_SCAN_OCR_LANGUAGE=deu+eng', '',
].join('\n') ].join('\n')
const replacements = { const replacements = {
AGENT_ID: options.agentId, AUTOSTART: String(Boolean(options.autostart)), AGENT_ID: options.agentId, AUTOSTART: String(Boolean(options.autostart)),
@@ -40,6 +41,7 @@ export function renderAgentInstaller(bundle, options) {
export const ix1600ScanDefaults = { export const ix1600ScanDefaults = {
format: 'pdf', resolution: 300, mode: 'Color', source: 'ADF Duplex', format: 'pdf', resolution: 300, mode: 'Color', source: 'ADF Duplex',
paperSize: 'A4', postprocess: false, postprocessProfile: 'document', paperSize: 'A4', postprocess: false, postprocessProfile: 'document',
backgroundCleanup: true, ocr: true, ocrLanguage: 'deu+eng',
} }
export function agentSetupReady(agent, since = 0, now = Date.now()) { export function agentSetupReady(agent, since = 0, now = Date.now()) {

View File

@@ -109,6 +109,9 @@ class Controller:
if record.get("status") in ("queued", "running"): if record.get("status") in ("queued", "running"):
record.update(status="interrupted", errorCode="INTERRUPTED", updatedAt=engine.now()) record.update(status="interrupted", errorCode="INTERRUPTED", updatedAt=engine.now())
engine.write_json(path, record) engine.write_json(path, record)
if (REQUEST_ID.fullmatch(str(record.get("requestId", "")))
and (self.active is None or record.get("startedAt", "") > self.read(self.active).get("startedAt", ""))):
self.active = record["requestId"]
updater.status() # Detect an abandoned engine job without touching an active CLI job. updater.status() # Detect an abandoned engine job without touching an active CLI job.
except BaseException: except BaseException:
self.daemon_lock.close() self.daemon_lock.close()

View File

@@ -418,7 +418,14 @@ class Updater:
for name in required: for name in required:
if old[name].get("profiles") or new[name].get("profiles") or new[name].get("build"): if old[name].get("profiles") or new[name].get("profiles") or new[name].get("build"):
raise UpdateError("Build-Kontexte und Compose-Profile werden nicht unterstützt.") raise UpdateError("Build-Kontexte und Compose-Profile werden nicht unterstützt.")
if old[name].get("volumes", []) != new[name].get("volumes", []): old_mounts, new_mounts = old[name].get("volumes", []), new[name].get("volumes", [])
# One-time bootstrap of the constrained updater API; never relax data-mount checks.
control_mount = {"type": "bind", "source": "/run/fedeo-updater", "target": "/run/fedeo-updater",
"read_only": True, "bind": {}}
control_variants = (control_mount, {**control_mount, "bind": {"create_host_path": True}})
if name == "backend" and not any(v.get("target") == "/run/fedeo-updater" for v in old_mounts):
new_mounts = [v for v in new_mounts if v not in control_variants]
if old_mounts != new_mounts:
raise UpdateError(f"Datenpfade/Mounts von {name} würden verändert; zuerst manuell migrieren.") raise UpdateError(f"Datenpfade/Mounts von {name} würden verändert; zuerst manuell migrieren.")
if old[name].get("container_name") != new[name].get("container_name"): if old[name].get("container_name") != new[name].get("container_name"):
raise UpdateError(f"Container-Identität von {name} würde verändert.") raise UpdateError(f"Container-Identität von {name} würde verändert.")
@@ -559,7 +566,13 @@ class Updater:
if not self.managed and not accept_compose: if not self.managed and not accept_compose:
raise UpdateError("Compose-Datei ist noch nicht verwaltet oder wurde bearbeitet. compose.diff prüfen, Anpassungen in die Override-Datei übertragen und --accept-compose-replacement verwenden.") raise UpdateError("Compose-Datei ist noch nicht verwaltet oder wurde bearbeitet. compose.diff prüfen, Anpassungen in die Override-Datei übertragen und --accept-compose-replacement verwenden.")
self.save("pulling") self.save("pulling")
self.compose(True, "pull", timeout=3600) # Exact digests are immutable: reuse local copies even when their registry is offline.
# Mutable infrastructure tags must still be refreshed before entering maintenance.
for policy in ("missing", "always"):
services = [name for name, service in self.candidate["services"].items()
if bool(re.search(r"@sha256:[a-f0-9]{64}$", service["image"])) == (policy == "missing")]
if services:
self.compose(True, "pull", "--policy", policy, *services, timeout=3600)
self.check_space(self.backup_size, self.home) self.check_space(self.backup_size, self.home)
if any(sha(Path(path).read_bytes()) != value for path, value in self.before.items()): if any(sha(Path(path).read_bytes()) != value for path, value in self.before.items()):
raise UpdateError("Konfiguration wurde während der Vorbereitung verändert; erneut prüfen.") raise UpdateError("Konfiguration wurde während der Vorbereitung verändert; erneut prüfen.")
@@ -583,13 +596,13 @@ class Updater:
atomic(self.root / ".env.example", (self.job / ".env.example").read_bytes()) atomic(self.root / ".env.example", (self.job / ".env.example").read_bytes())
atomic(self.root / "scripts/selfhost-setup.sh", (self.job / "selfhost-setup.sh").read_bytes()) atomic(self.root / "scripts/selfhost-setup.sh", (self.job / "selfhost-setup.sh").read_bytes())
os.chmod(self.root / "scripts/selfhost-setup.sh", 0o700) os.chmod(self.root / "scripts/selfhost-setup.sh", 0o700)
self.compose(True, "up", "-d", "--no-deps", "--wait", "--wait-timeout", "180", "db", "minio", "node-exporter") self.compose(True, "up", "--pull", "never", "-d", "--no-deps", "--wait", "--wait-timeout", "180", "db", "minio", "node-exporter")
self.compose(True, "run", "--rm", "--no-deps", "createbuckets") self.compose(True, "run", "--pull", "never", "--rm", "--no-deps", "createbuckets")
self.save("migrating", migrationContainer="fedeo-update-" + self.job.name) self.save("migrating", migrationContainer="fedeo-update-" + self.job.name)
self.compose(True, "run", "--rm", "--no-deps", "--name", self.state["migrationContainer"], self.compose(True, "run", "--pull", "never", "--rm", "--no-deps", "--name", self.state["migrationContainer"],
"-e", "FEDEO_RUN_MIGRATIONS=false", "--entrypoint", "npm", "backend", "run", "migrate") "-e", "FEDEO_RUN_MIGRATIONS=false", "--entrypoint", "npm", "backend", "run", "migrate")
self.save("starting") self.save("starting")
self.compose(True, "up", "-d", "--wait", "--wait-timeout", "600", timeout=900) self.compose(True, "up", "--pull", "never", "-d", "--wait", "--wait-timeout", "600", timeout=900)
self.save("verifying") self.save("verifying")
self.verify_matrix_stopped(retired=True) self.verify_matrix_stopped(retired=True)
for name, image in self.images.items(): for name, image in self.images.items():

View File

@@ -130,6 +130,7 @@ class ServiceTests(unittest.TestCase):
self.controller.close() self.controller.close()
self.controller = s.Controller(self.root, updater_factory=self.factory) self.controller = s.Controller(self.root, updater_factory=self.factory)
self.server.controller = self.controller self.server.controller = self.controller
self.assertEqual(self.request('GET', '/v1/status')[1]['request']['requestId'], request_id)
status, _ = self.request('POST', '/v1/check', {'requestId': request_id, 'version': '3.2.0'}) status, _ = self.request('POST', '/v1/check', {'requestId': request_id, 'version': '3.2.0'})
self.assertEqual(status, 200) self.assertEqual(status, 200)
self.assertEqual(len(self.factory.calls), 1) self.assertEqual(len(self.factory.calls), 1)

View File

@@ -79,6 +79,32 @@ class Runner:
self.matrix_containers = {} self.matrix_containers = {}
self.on_stop = None self.on_stop = None
self.real_tar = False self.real_tar = False
self.compose_configs = {}
self.local_images = set()
self.unavailable_images = set()
self.downloads = []
@staticmethod
def image_reference(image):
# Docker resolves tag@digest using the repository and digest, not the tag.
if '@' not in image:
return image
name, digest = image.split('@', 1)
repository, slash, basename = name.rpartition('/')
return repository + slash + basename.split(':', 1)[0] + '@' + digest
def pull_image(self, image, policy):
reference = self.image_reference(image)
if policy == 'never':
if reference not in self.local_images:
raise u.UpdateError('Image missing locally')
return
if policy == 'missing' and reference in self.local_images:
return
self.downloads.append(reference)
if reference in self.unavailable_images:
raise u.UpdateError('Registry unavailable for ' + reference)
self.local_images.add(reference)
def run(self, args, output=None, input_file=None, **kwargs): def run(self, args, output=None, input_file=None, **kwargs):
self.calls.append(args) self.calls.append(args)
@@ -108,6 +134,7 @@ class Runner:
capture_output=True, text=True) capture_output=True, text=True)
if result.returncode: if result.returncode:
raise u.UpdateError('Compose invalid: ' + result.stderr) raise u.UpdateError('Compose invalid: ' + result.stderr)
self.compose_configs[args[args.index('-f') + 1]] = json.loads(result.stdout)
return result.stdout return result.stdout
if 'ps' in args: if 'ps' in args:
if args[:2] == ['docker', 'ps']: if args[:2] == ['docker', 'ps']:
@@ -121,8 +148,17 @@ class Runner:
container['State']['Running'] = 'start' in args container['State']['Running'] = 'start' in args
if 'stop' in args and self.on_stop: if 'stop' in args and self.on_stop:
self.on_stop() self.on_stop()
if 'pull' in args and self.on_pull: if args[:2] == ['docker', 'compose'] and any(command in args for command in ('pull', 'up', 'run')):
self.on_pull() services = self.compose_configs[args[args.index('-f') + 1]]['services']
selected = [service for name, service in services.items() if name in args]
if not selected:
selected = list(services.values())
for service in selected:
policy = (args[args.index('--policy') + 1] if '--policy' in args else 'always') if 'pull' in args else (
args[args.index('--pull') + 1] if '--pull' in args else service.get('pull_policy', 'missing'))
self.pull_image(service['image'], policy)
if 'pull' in args and self.on_pull:
self.on_pull()
if any('pg_database_size' in part for part in args): if any('pg_database_size' in part for part in args):
return '1000' return '1000'
if args[0] == 'du': if args[0] == 'du':
@@ -449,6 +485,26 @@ class UpdateTests(unittest.TestCase):
self.run_update() self.run_update()
self.assert_untouched() self.assert_untouched()
def test_first_update_can_add_only_the_readonly_control_directory(self):
control_line = b' - /run/fedeo-updater:/run/fedeo-updater:ro\n'
self.assertIn(control_line, self.original_compose)
(self.root / 'docker-compose.yml').write_bytes(self.original_compose.replace(control_line, b' []\n'))
result = self.run_update()
self.assertEqual(result['status'], 'succeeded')
self.assertIn(control_line, (self.root / 'docker-compose.yml').read_bytes())
def test_control_mount_bootstrap_rejects_write_access_or_another_host_directory(self):
control_line = b' - /run/fedeo-updater:/run/fedeo-updater:ro\n'
(self.root / 'docker-compose.yml').write_bytes(self.original_compose.replace(control_line, b' []\n'))
for replacement in (b' - /run/fedeo-updater:/run/fedeo-updater:rw\n',
b' - /var/run/docker.sock:/run/fedeo-updater:ro\n'):
with self.subTest(replacement=replacement):
self.http.files['docker-compose.selfhost.yml'] = self.original_compose.replace(control_line, replacement)
self.http.refresh()
with self.assertRaisesRegex(u.UpdateError, 'Datenpfade'):
self.run_update()
self.assertFalse(self.called('stop'))
def test_external_storage_fails_before_maintenance(self): def test_external_storage_fails_before_maintenance(self):
(self.root / '.env').write_bytes(self.original_env.replace(b'http://minio:9000', b'https://storage.example.test')) (self.root / '.env').write_bytes(self.original_env.replace(b'http://minio:9000', b'https://storage.example.test'))
with self.assertRaisesRegex(u.UpdateError, 'Backup-Adapter'): with self.assertRaisesRegex(u.UpdateError, 'Backup-Adapter'):
@@ -473,6 +529,49 @@ class UpdateTests(unittest.TestCase):
self.run_update() self.run_update()
self.assert_untouched() self.assert_untouched()
def test_cached_exact_images_work_with_unavailable_registries_and_forced_pull_overrides(self):
self.updater.execute('check', '3.1.0')
services = self.updater.candidate['services']
refs = {name: self.runner.image_reference(service['image']) for name, service in services.items()}
self.runner.local_images.update(refs.values())
self.runner.unavailable_images.update(ref for ref in refs.values() if '@sha256:' in ref)
(self.root / 'docker-compose.override.yml').write_text(
'services:\n' + ''.join(f' {name}:\n pull_policy: always\n' for name in services))
result = self.run_update()
self.assertEqual(result['status'], 'succeeded')
self.assertEqual(set(self.runner.downloads), {refs[name] for name in ('db', 'traefik', 'node-exporter')})
self.assertEqual(len(self.runner.downloads), 3)
def test_missing_digest_is_downloaded_even_if_tag_or_another_digest_is_cached(self):
self.updater.execute('check', '3.1.0')
services = self.updater.candidate['services']
for service in services.values():
image = service['image']
if '@' in image:
self.runner.local_images.add(image.split('@')[0])
self.runner.local_images.add(self.runner.image_reference(image).split('@')[0] + '@sha256:' + '0' * 64)
self.assertEqual(self.run_update()['status'], 'succeeded')
self.assertEqual(set(self.runner.downloads), {self.runner.image_reference(s['image']) for s in services.values()})
def test_missing_unavailable_digest_stops_before_maintenance_despite_cached_tag(self):
self.updater.execute('check', '3.1.0')
image = self.updater.candidate['services']['minio']['image']
self.runner.local_images.add(image.split('@')[0])
self.runner.unavailable_images.add(self.runner.image_reference(image))
with self.assertRaisesRegex(u.UpdateError, 'Registry unavailable'):
self.run_update()
self.assert_untouched()
self.assertFalse(self.updater.status()['recoveryRequired'])
def test_cached_mutable_tag_still_requires_a_successful_registry_refresh(self):
self.updater.execute('check', '3.1.0')
services = self.updater.candidate['services']
self.runner.local_images.update(self.runner.image_reference(s['image']) for s in services.values())
self.runner.unavailable_images.add(services['db']['image'])
with self.assertRaisesRegex(u.UpdateError, 'Registry unavailable'):
self.run_update()
self.assert_untouched()
def test_configuration_changed_during_pull_is_detected(self): def test_configuration_changed_during_pull_is_detected(self):
self.runner.on_pull = lambda: (self.root / '.env').write_bytes(self.original_env + b'\nNEW=value\n') self.runner.on_pull = lambda: (self.root / '.env').write_bytes(self.original_env + b'\nNEW=value\n')
with self.assertRaisesRegex(u.UpdateError, 'während'): with self.assertRaisesRegex(u.UpdateError, 'während'):