10 Commits

Author SHA1 Message Date
96a7acff33 Merge pull request 'feat(selfhost): show sanitized update logs in admin UI' (#231) from dev into main
All checks were successful
Build and Release FEDEO / verify (push) Successful in 47s
Build and Release FEDEO / build-and-release (push) Successful in 6m7s
Reviewed-on: #231
2026-09-25 17:22:30 +00:00
29155f916a feat(selfhost): show sanitized update logs in admin UI
All checks were successful
Build and Release FEDEO / verify (push) Successful in 48s
Build and Release FEDEO / build-and-release (push) Successful in 5m45s
2026-09-25 18:42:26 +02:00
65588686c7 Merge pull request 'fix(documents): normalize units when selecting and loading rows' (#230) from dev into main
All checks were successful
Build and Release FEDEO / verify (push) Successful in 48s
Build and Release FEDEO / build-and-release (push) Successful in 6m3s
Reviewed-on: #230
2026-09-25 09:54:08 +00:00
8f06d06037 fix(documents): normalize units when selecting and loading rows
All checks were successful
Build and Release FEDEO / verify (push) Successful in 49s
Build and Release FEDEO / build-and-release (push) Successful in 5m50s
2026-09-25 11:42:17 +02:00
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
39 changed files with 1207 additions and 112 deletions

View File

@@ -10,6 +10,9 @@ FEDEO_SCAN_MODE=Color
FEDEO_SCAN_SOURCE=
FEDEO_SCAN_TIMEOUT_SECONDS=900
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_PYTHON=
FEDEO_SCAN_POSTPROCESS_STRICT=false

View File

@@ -13,6 +13,9 @@ FROM node:20-bookworm-slim AS runtime
ENV NODE_ENV=production
ENV FEDEO_WORK_DIR=/work
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_PYTHON=/opt/fedeo-device-agent/.venv-opencv/bin/python
ENV FEDEO_SCAN_POSTPROCESS_STRICT=false
@@ -28,6 +31,9 @@ RUN apt-get update \
python3-pip \
python3-venv \
sane-utils \
tesseract-ocr \
tesseract-ocr-deu \
tesseract-ocr-eng \
&& rm -rf /var/lib/apt/lists/*
COPY requirements-opencv.txt ./

View File

@@ -44,11 +44,11 @@ FEDEO_TEST_POSTGRES_IMAGE=postgres:16-alpine node --import tsx tests/instanceAge
### macOS
```bash
brew install node sane-backends
brew install node sane-backends tesseract tesseract-lang
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:
@@ -60,7 +60,7 @@ lpstat -p
```bash
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
```
@@ -90,6 +90,9 @@ Der iX1600 wird vom SANE-Backend `fujitsu` per USB unterstützt. Die USB-ID ist
FEDEO_SCAN_RESOLUTION=300
FEDEO_SCAN_MODE=Color
FEDEO_SCAN_POSTPROCESS=false
FEDEO_SCAN_BACKGROUND_CLEANUP=true
FEDEO_SCAN_OCR=true
FEDEO_SCAN_OCR_LANGUAGE=deu+eng
FEDEO_SCAN_TIMEOUT_SECONDS=900
```
@@ -139,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.
## 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.
@@ -156,7 +176,7 @@ FEDEO_SCAN_POSTPROCESS_PYTHON=/pfad/zum/agent/.venv-opencv/bin/python
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:
@@ -166,7 +186,7 @@ Profile:
## 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
cp .env.example .env
@@ -202,7 +222,7 @@ npm test
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.

View File

@@ -14,7 +14,7 @@ async function filesUnder(directory) {
const packageJson = JSON.parse(await readFile(path.join(root, 'package.json'), 'utf8'))
const paths = [
...(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',
]
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"
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
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
fi
echo 'Fehlende Systempakete werden über apt installiert (sudo benötigt).'
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
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_HAS_SYSTEMD=false
@@ -81,7 +86,9 @@ def unit_quote(value):
unit_path.write_text('\n'.join([
'[Unit]', 'Description=FEDEO Scan-Agent', 'After=network-online.target', '',
'[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)),
'ExecStart=' + unit_quote(node) + ' ' + unit_quote(agent_dir / 'dist/main.js'),
'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
"""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 json
import math
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:
raise ValueError("Keine Seiten vorhanden")
if not math.isfinite(resolution) or resolution <= 0:
raise ValueError("Ungültige Scan-Auflösung")
output = Path(output)
file_format = {".pdf": "PDF", ".png": "PNG", ".tiff": "TIFF"}.get(output.suffix.lower())
if not file_format:
raise ValueError("Unbekanntes Ausgabeformat")
if file_format != "PDF" and len(inputs) != 1:
raise ValueError("Mehrseitige Scans benötigen PDF als Ausgabeformat")
if (len(inputs) != 1 or ocr) and file_format != "PDF":
raise ValueError("Mehrseitige Scans und OCR benötigen PDF als Ausgabeformat")
if ocr:
check_ocr(ocr_language)
warnings = []
processor = None
@@ -27,47 +62,92 @@ def create_document(inputs, output, resolution, use_postprocess=False, profile="
if strict:
raise
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")
try:
with tempfile.TemporaryDirectory(prefix="prepared-", dir=output.parent) as directory:
prepared = []
for index, input_path in enumerate(inputs):
input_path = Path(input_path)
image_path = input_path
corrected = input_path.with_name(input_path.stem + ".corrected.png")
image_path = Path(input_path)
corrected = Path(directory) / f"corrected-{index:06}.png"
if processor:
try:
processor(input_path, corrected, profile)
processor(image_path, corrected, profile)
image_path = corrected
except Exception:
if strict:
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.convert("RGB") as rgb:
if file_format == "PDF":
if ocr:
page = Path(directory).resolve() / f"page-{index:06}.png"
rgb.save(page, "PNG", dpi=(resolution, resolution))
prepared.append(page)
elif file_format == "PDF":
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:
partial.unlink(missing_ok=True)
return {"pageCount": len(inputs), "warnings": warnings}
def interrupted(*_):
raise SystemExit("Scan-Verarbeitung abgebrochen")
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.add_argument("--inputs", nargs="+", required=True)
parser.add_argument("--output", required=True)
parser.add_argument("--resolution", type=float, required=True)
parser.add_argument("--inputs", nargs="+")
parser.add_argument("--output")
parser.add_argument("--resolution", type=float, default=300)
parser.add_argument("--profile", choices=["document", "receipt", "raw"], default="document")
parser.add_argument("--postprocess", 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()
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__":

View File

@@ -62,6 +62,9 @@ export const loadConfig = (): AgentConfig => {
scanSource: optional(process.env.FEDEO_SCAN_SOURCE),
scanTimeoutSeconds: numberFromEnv(process.env.FEDEO_SCAN_TIMEOUT_SECONDS, 900),
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),
postprocessPython: optional(process.env.FEDEO_SCAN_POSTPROCESS_PYTHON) || defaultPostprocessPython(),
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 { listPrinters } from "./print/cups.js"
import { hasSane, listScanners, runScan } from "./scan/sane.js"
import { processingCapabilities } from "./scan/postprocess.js"
import { AgentHeartbeat } from "./types.js"
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
@@ -19,6 +20,7 @@ const stringifyError = (error: unknown) => {
const main = async () => {
const config = loadConfig()
const api = new FedeoApi(config)
const processing = await processingCapabilities(config)
log.info("FEDEO Geräte-Agent startet", {
platform: process.platform,
@@ -38,6 +40,7 @@ const main = async () => {
const heartbeatPayload: AgentHeartbeat = {
capabilities: {
scan: scanAvailable,
...processing,
print: printerNames.length > 0,
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 documentScript = path.join(agentRoot, "scripts/scan_document.py")
export const checkScanRuntime = async (config: AgentConfig, postprocess: boolean) => {
const imports = postprocess && config.postprocessStrict ? "import PIL, cv2, numpy" : "import PIL"
type ProcessingOptions = { ocr: boolean; ocrLanguage: string; backgroundCleanup: boolean }
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 {
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
} catch {
throw new Error(result.stderr.trim())
} catch (error) {
// 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 (
@@ -23,11 +42,14 @@ export const createScanDocument = async (
outputPath: string,
resolution: number,
postprocess: boolean,
profile: AgentConfig["postprocessProfile"]
profile: AgentConfig["postprocessProfile"],
options: ProcessingOptions
): Promise<ScanResult> => {
const args = [documentScript, "--output", outputPath, "--resolution", String(resolution), "--profile", profile]
if (postprocess) args.push("--postprocess")
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)
const result = await runCommand(config.postprocessPython, args, {

View File

@@ -20,7 +20,9 @@ export const hasSane = () => commandExists("scanimage")
export const listScanners = async () => {
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 []
return result.stdout.split(/\r?\n/)
.map((line) => line.trim().match(/^device `([^']+)'/)?.[1])
@@ -51,7 +53,12 @@ export const runScan = async (
const postprocess = booleanSetting(settings, "postprocess", config.scanPostprocess)
const profile = stringSetting(settings, "postprocessProfile") || config.postprocessProfile
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.
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.")
}
await reportProgress(`${pages.length} Seiten gescannt. Datei wird erstellt...`)
return await createScanDocument(config, pages, outputPath, resolution, postprocess, profile as AgentConfig["postprocessProfile"])
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"], processing)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
throw new Error(`${message} Rohscans bleiben zur Wiederherstellung in ${jobDir}.`)

View File

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

View File

@@ -14,6 +14,8 @@ const agentId = 'b48c0e33-1abd-4b11-89fa-1bb7b39a3057'
const token = 'fedeo_agent_' + 'a'.repeat(64)
const options = { agentId, token, backendUrl: 'https://fedeo.example.com/backend', autostart: true }
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', () => {
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, '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 })
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 = {
...process.env, PATH: bin + ':' + process.env.PATH,
XDG_DATA_HOME: path.join(temp, 'data with spaces %'),
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_PYTHON: realPython,
}
const scriptPath = path.join(temp, 'install.sh')
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)) {
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(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')
assert.equal(calls.includes('--user enable 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 path from "node:path"
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 { AgentConfig, ScanJob } from "../src/types.js"
const fakeScanner = `#!/usr/bin/env python3
import json, os, sys
import json, os, sys, time
from pathlib import Path
from PIL import Image
args = sys.argv[1:]
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)
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
@@ -46,12 +51,25 @@ const setup = async (t: TestContext) => {
workDir: path.join(dir, "scans with 100% space"), scannerName: "fujitsu:ScanSnap iX1600:test",
scanFormat: "pdf", scanResolution: 300, scanMode: "Color", scanSource: "ADF Duplex",
scanTimeoutSeconds: 10, scanPostprocess: false, postprocessProfile: "document",
scanBackgroundCleanup: false, scanOcr: false, scanOcrLanguage: "deu+eng",
postprocessPython: "python3", postprocessStrict: false,
}
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[] }
}
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) => {
const { config, job, args } = await setup(t)
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/)
})
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) => {
const { config, job, args } = await setup(t)
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

@@ -13,7 +13,8 @@ const errors = {
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.",
UPDATE_FAILED: "Auftrag fehlgeschlagen.",
LOGS_UNAVAILABLE: "Das Update-Protokoll kann gerade nicht gelesen werden.",
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.",
@@ -46,12 +47,17 @@ const job = state.extend({ plan: plan.optional(), progress: state.optional(),
});
const status = z.object({ apiVersion: z.literal(1), acceptingRequests: z.boolean(), engine: state,
installed: state.pick({ version: true, revision: true }), request: job.optional(),
capabilities: z.object({ requestLogs: z.boolean() }).optional(),
});
const logs = z.object({ requestId, logs: z.array(z.object({
source: z.enum(["service", "events", "commands"]), content: z.string().max(6000), truncated: z.boolean(),
})).max(3),
});
// 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)))
if (!((method === "GET" && (path === "/v1/status" || /^\/v1\/requests\/[A-Za-z0-9][A-Za-z0-9_-]{7,79}(?:\/logs)?$/.test(path)))
|| (method === "POST" && ["/v1/check", "/v1/apply"].includes(path)))) {
return Promise.reject(new UpdaterError("INVALID_REQUEST", 400));
}
@@ -76,7 +82,12 @@ export function createUpdaterClient(socketPath = "/run/fedeo-updater/control.soc
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) });
const schema = path === "/v1/status" ? status : path.endsWith("/logs") ? logs : job;
const parsed = schema.parse(data);
if (path.endsWith("/logs") && data.requestId !== path.split("/")[3]) {
throw new UpdaterError("UPDATER_PROTOCOL", 502);
}
resolve({ status: res.statusCode!, body: parsed });
} catch (error) {
reject(error instanceof UpdaterError ? error : new UpdaterError("UPDATER_PROTOCOL", 502));
}

View File

@@ -152,6 +152,7 @@ export default async function instanceAgentRoutes(server: FastifyInstance) {
active: instanceAgents.active,
preferredScannerName: instanceAgents.preferredScannerName,
scanDefaults: instanceAgents.scanDefaults,
capabilities: instanceAgents.capabilities,
})
.from(instanceAgents)
.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" })
}
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
.insert(instanceAgentScanJobs)
.values({
@@ -169,10 +178,7 @@ export default async function instanceAgentRoutes(server: FastifyInstance) {
requestedBy: req.user?.user_id,
scannerName: body.scannerName || agent.preferredScannerName,
requestedFilename: body.requestedFilename,
settings: {
...((agent.scanDefaults || {}) as Record<string, any>),
...(body.settings || {}),
},
settings,
target: body.target || {},
})
.returning()

View File

@@ -24,6 +24,10 @@ export default async function selfhostUpdateRoutes(server: FastifyInstance, opti
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}`);
});
server.get<{ Params: { id: string } }>("/admin/updates/requests/:id/logs", 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}/logs`);
});
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).

View File

@@ -55,3 +55,30 @@ test("unknown agents cannot receive setup credentials", async (t) => {
assert.equal(response.statusCode, 404)
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

@@ -30,11 +30,22 @@ test("all update endpoints require a current global admin, including after revoc
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}`],
["GET", `/admin/updates/requests/${id}/logs`],
["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("admin log access uses the fixed request route and does not cache responses", async t => {
const f = await fixture(t);
const response = await f.app.inject({ url: `/admin/updates/requests/${id}/logs` });
assert.equal(response.statusCode, 202);
assert.equal(response.headers["cache-control"], "no-store");
assert.deepEqual(f.calls[0], ["GET", `/v1/requests/${id}/logs`, undefined]);
assert.equal((await f.app.inject({ url: "/admin/updates/requests/bad.id/logs" })).statusCode, 400);
const anonymous = await fixture(t, true, false);
assert.equal((await anonymous.app.inject({ url: `/admin/updates/requests/${id}/logs` })).statusCode, 401);
});
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);
@@ -117,3 +128,19 @@ test("Unix client bounds response size, deadline, and protocol shape", async t =
});
}
});
test("Unix client validates bounded log responses and their request association", async t => {
let payload: any = { requestId: id, logs: [{ source: "commands", content: "pull access denied", truncated: true,
path: "/private/commands.log" }], private: "omitted" };
const client = await socketFixture(t, (_req, res) => res.end(JSON.stringify(payload)));
const response = await client("GET", `/v1/requests/${id}/logs`);
assert.deepEqual(response.body, { requestId: id, logs: [{ source: "commands", content: "pull access denied", truncated: true }] });
for (const invalid of [
{ ...payload, requestId: "different-request" },
{ ...payload, logs: [{ source: "env", content: "secret", truncated: false }] },
{ ...payload, logs: [{ source: "commands", content: "x".repeat(6001), truncated: false }] },
]) {
payload = invalid;
await assert.rejects(client("GET", `/v1/requests/${id}/logs`), { code: "UPDATER_PROTOCOL" });
}
await assert.rejects(client("GET", `/v1/requests/${id}/logs?path=/etc/passwd`), { code: "INVALID_REQUEST" });
});

View File

@@ -101,7 +101,15 @@ Der aktuelle Updater erlaubt dabei gezielt das zusätzliche, schreibgeschützte
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.
Die Backend-Routen `/api/admin/updates/status`, `/api/admin/updates/requests/:id`, `/api/admin/updates/requests/:id/logs`, `/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 und bereinigte Protokollauszüge aus. CLI-Wiederherstellung, Übernahme fremder Compose-Dateien, Shell-Befehle und frei wählbare Download-Adressen bleiben auf dem Host.
### Update-Protokoll in der Oberfläche
Unter **FEDEO-Updates → Update-Protokoll anzeigen** sehen Systemadministratoren die Fehlerdetails des Host-Dienstes, den Update-Verlauf und die Werkzeugausgaben (beispielsweise Docker-Pull- oder Migrationsfehler). Bei fehlgeschlagenen oder unterbrochenen Aufträgen öffnet sich die Ansicht automatisch. Solange sie geöffnet ist, wird sie zusammen mit dem Auftragsstatus aktualisiert; „Protokoll aktualisieren“ fragt sie zusätzlich sofort ab. Während der Wartungsunterbrechung bleibt die letzte geladene Ausgabe sichtbar.
Pro Quelle werden höchstens die letzten 6000 UTF-8-Bytes angezeigt; gekürzte Ausgaben sind markiert. Die Bereinigung erfolgt auf dem Host vor der Übertragung: erkannte Kennwörter, Tokens, Authentifizierungsheader und URL-Zugangsdaten sowie bekannte geheime Werte aus der Umgebung und den aktuellen/gesicherten Konfigurationsdateien werden ersetzt. Die Originalprotokolle bleiben unverändert geschützt auf dem Host. Nicht als Zugangsdaten erkennbare Anwendungsdaten können weiterhin enthalten sein.
Bestehende Installationen benötigen für diese Ansicht **auch einen aktualisierten Host-Dienst**, zusätzlich zu Backend und Frontend. Die vier zusammengehörigen Host-Dateien aus einem geprüften Stand bereitstellen und `selfhost-update-service-install.sh` erneut ausführen, wie unter „Host-Dienst installieren“ beschrieben. Ein Anwendungsupdate allein ersetzt den Host-Dienst nicht. Die Oberfläche erkennt ältere Dienste und zeigt dafür einen konkreten Hinweis. Bereits vorhandene Aufträge bleiben lesbar; ältere Protokolle werden anhand der gespeicherten Auftrags-ID zugeordnet. Nach Aktualisierung des Host-Dienstes lässt sich auch vor einem Anwendungsupdate direkt `GET /v1/requests/<requestId>/logs` über den Unix-Socket verwenden.
## Lokale Dienst-API
@@ -112,6 +120,7 @@ Alle Antworten sind JSON mit `Cache-Control: no-store`. Mutationen benötigen `C
| `GET /v1/status` | Installierter Stand, Zustand der Update-Engine und aktueller Dienstauftrag |
| `POST /v1/check` | Asynchrone Prüfung; `requestId`, optional `version` ohne `v` |
| `GET /v1/requests/<requestId>` | Auftrag, bereinigter Prüfplan und aktueller Fortschritt |
| `GET /v1/requests/<requestId>/logs` | Bereinigte, begrenzte Auszüge aus Dienst-, Ablauf- und Werkzeugprotokoll; nur für diesen Auftrag |
| `POST /v1/apply` | Asynchrones Update; `requestId`, feste `version` und erfolgreiche `checkRequestId` |
Beispiel für eine Prüfung des neuesten Releases:
@@ -151,7 +160,7 @@ Aufträge liegen dauerhaft und root-lesbar unter `.updates/service/`. Nach einem
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.
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.
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.
@@ -163,6 +172,8 @@ Status und Phasen werden atomar in `.updates/status.json` und pro Auftrag in `st
## 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.
- **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.

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 online = computed(() => agentSetupReady(agent.value, checkingSince.value, now.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(() => {
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
busy.value = true
try {
const scanDefaults = profile.value === 'ix1600' ? { ...ix1600ScanDefaults } : {
format: 'pdf', resolution: 300, mode: 'Color', paperSize: 'auto', postprocess: false, ...agent.value.scanDefaults,
}
await admin.updateInstanceAgent(agent.value.id, { preferredScannerName: selectedScanner.value, scanDefaults })
await admin.updateInstanceAgent(agent.value.id, { preferredScannerName: selectedScanner.value, scanDefaults: setupScanDefaults.value })
saved.value = true
token.value = ''
stopPolling()
@@ -212,7 +216,7 @@ onBeforeUnmount(() => {
</div>
<div class="space-y-4 rounded-lg bg-muted/40 p-5">
<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>
<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>
@@ -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-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." />
<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" />
<UFormField label="Scanner" required>
<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' }
]" value-key="value" label-key="label" class="w-full" />
</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">
<UButton color="neutral" variant="outline" :loading="checking" @click="checkConnection">Scanner erneut suchen</UButton>
<UButton :disabled="!readyToSave" :loading="busy" @click="finishSetup">Einrichtung abschließen</UButton>

View File

@@ -34,6 +34,9 @@ const scanForm = reactive({
source: "ADF Duplex",
paperSize: "A4",
postprocess: false,
backgroundCleanup: true,
ocr: true,
ocrLanguage: "deu+eng",
postprocessProfile: "document"
})
@@ -49,7 +52,8 @@ watch(() => scanForm.format, (format) => {
const applyIx1600Preset = () => {
Object.assign(scanForm, {
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.paperSize = agent.scanDefaults?.paperSize || "A4"
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"
if (isFeederScan.value) scanForm.format = "pdf"
@@ -147,6 +154,11 @@ const waitForJob = async (jobId) => {
const startScan = async () => {
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
statusMessage.value = "Scan-Auftrag wird erstellt..."
@@ -166,6 +178,9 @@ const startScan = async () => {
source: scanForm.source || null,
paperSize: scanForm.paperSize,
postprocess: scanForm.postprocess,
backgroundCleanup: scanForm.backgroundCleanup,
ocr: scanForm.format === "pdf" && scanForm.ocr,
ocrLanguage: scanForm.ocrLanguage,
postprocessProfile: scanForm.postprocessProfile
},
target: {
@@ -319,10 +334,23 @@ loadAgents()
</UFormField>
</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)]">
<UCheckbox
v-model="scanForm.postprocess"
label="OpenCV-Korrektur"
label="Zuschnitt und Ausrichtung zusätzlich korrigieren"
:disabled="scanInProgress"
/>
<UFormField label="Profil">

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
const { overview, job, pending, connected, submitting, refreshing, message, accessDenied, recovery, busy, available, canApply,
check, apply, refresh, sendPending } = useSelfhostUpdates()
logsOpen, logsLoading, logsMessage, logEntries, refreshLogs, toggleLogs, check, apply, refresh, sendPending } = useSelfhostUpdates()
const build = useRuntimeConfig().public
const confirmOpen = ref(false)
const confirmed = ref(false)
@@ -16,6 +16,7 @@ const statusLabels: Record<string, string> = {
succeeded: 'Update erfolgreich abgeschlossen', failed: 'Auftrag fehlgeschlagen', interrupted: 'Auftrag unterbrochen',
idle: 'Bereit', recovered: 'Wiederherstellung bestätigt', unknown: 'Auftragsstatus wird geladen',
}
const logLabels = { service: 'Fehlerdetails', events: 'Update-Verlauf', commands: 'Werkzeugausgaben' }
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(() => {
@@ -59,6 +60,24 @@ const reload = () => window.location.reload()
</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>
<section v-if="job?.requestId && !accessDenied" aria-label="Update-Protokoll" class="space-y-3 rounded-lg border border-default p-3">
<div class="flex flex-wrap items-center justify-between gap-2">
<UButton color="neutral" variant="ghost" icon="i-heroicons-document-text" :aria-expanded="logsOpen" @click="toggleLogs">
{{ logsOpen ? 'Update-Protokoll ausblenden' : 'Update-Protokoll anzeigen' }}
</UButton>
<UButton v-if="logsOpen && overview?.capabilities?.requestLogs" color="neutral" variant="outline" size="xs" :loading="logsLoading" :disabled="!connected" @click="refreshLogs">Protokoll aktualisieren</UButton>
</div>
<div v-if="logsOpen" class="space-y-3">
<p class="text-xs text-muted">Die letzten Protokolleinträge werden automatisch aktualisiert. Erkannte Zugangsdaten sind ausgeblendet.</p>
<UAlert v-if="logsMessage" color="warning" variant="soft" :title="logsMessage" />
<p v-else-if="!logEntries.length" class="text-sm text-muted">{{ logsLoading ? 'Protokoll wird geladen …' : 'Für diesen Auftrag liegen noch keine Protokolleinträge vor.' }}</p>
<div v-for="entry in logEntries" :key="entry.source" class="space-y-1">
<h3 class="text-sm font-medium">{{ logLabels[entry.source] }}</h3>
<p v-if="entry.truncated" class="text-xs text-muted">Gekürzte Anzeige der letzten Einträge. Das vollständige Protokoll liegt auf dem Host.</p>
<pre tabindex="0" :aria-label="logLabels[entry.source]" class="max-h-80 overflow-auto whitespace-pre-wrap break-words rounded-md bg-elevated p-3 text-xs">{{ entry.content }}</pre>
</div>
</div>
</section>
<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>

View File

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

View File

@@ -7,9 +7,11 @@ export type UpdateJob = UpdateState & {
plan?: { version: string; revision: string; previousVersion?: string; updateAvailable: boolean;
requiresComposeAdoption: boolean; estimatedBackupBytes?: number; retiredServices: string[] };
}
export type UpdateLogs = { requestId: string; logs: { source: 'service' | 'events' | 'commands'; content: string; truncated: boolean }[] }
type Overview = {
acceptingRequests: boolean; engine: UpdateState; request?: UpdateJob;
installed: { version?: string }; backend: { version: string; revision: string };
capabilities?: { requestLogs: boolean };
}
type Submission = { command: 'check' | 'apply'; body: { requestId: string; version?: string; checkRequestId?: string } }
@@ -24,6 +26,12 @@ export const useSelfhostUpdates = () => {
const refreshing = ref(false)
const message = ref('')
const accessDenied = ref(false)
const logs = ref<UpdateLogs | null>(null)
const logsOpen = ref(false)
const logsLoading = ref(false)
const logsMessage = ref('')
const logEntries = computed(() => logs.value?.requestId === job.value?.requestId ? logs.value?.logs || [] : [])
let openedFailureId: string | undefined
let stopped = false
let generation = 0
let timer: ReturnType<typeof setTimeout> | undefined
@@ -55,6 +63,28 @@ export const useSelfhostUpdates = () => {
message.value = error?.data?.error?.message || 'Der Update-Dienst ist nicht erreichbar. Dienst und Socket-Einbindung auf dem Host prüfen.'
}
}
const refreshLogs = async () => {
const id = job.value?.requestId
if (!logsOpen.value || !id || stopped || accessDenied.value || logsLoading.value) return
if (logs.value?.requestId !== id) { logs.value = null; logsMessage.value = '' }
if (!overview.value?.capabilities?.requestLogs) {
logsMessage.value = 'Dieser Host-Update-Dienst unterstützt die Protokollansicht noch nicht. Die zusammengehörigen Host-Update-Dateien aktualisieren und den Dienstinstaller erneut ausführen. Bis dahin liegen die Protokolle auf dem Host unter .updates/.'
return
}
const currentGeneration = generation
logsLoading.value = true
try {
const result = await api<UpdateLogs>(`requests/${encodeURIComponent(id)}/logs`)
if (stopped || currentGeneration !== generation || job.value?.requestId !== id) return
logs.value = result
logsMessage.value = ''
} catch (error: any) {
if (stopped || currentGeneration !== generation || job.value?.requestId !== id) return
logsMessage.value = error?.data?.error?.message || 'Das Update-Protokoll konnte nicht geladen werden. Die Abfrage wird automatisch wiederholt.'
if ([401, 403].includes(errorStatus(error))) { logs.value = null; report(error) }
} finally { logsLoading.value = false }
}
const toggleLogs = async () => { logsOpen.value = !logsOpen.value; await refreshLogs() }
const refresh = async () => {
if (refreshing.value || stopped || accessDenied.value) return
refreshing.value = true
@@ -81,6 +111,11 @@ export const useSelfhostUpdates = () => {
connected.value = true
message.value = pending.value ? 'Start noch nicht bestätigt. Du kannst denselben Auftrag erneut senden.' : ''
persist()
if (['failed', 'interrupted'].includes(job.value?.status || '') && openedFailureId !== job.value?.requestId) {
openedFailureId = job.value?.requestId
logsOpen.value = true
}
await refreshLogs()
} catch (error) {
if (currentGeneration !== generation || stopped) return
connected.value = false
@@ -90,6 +125,8 @@ export const useSelfhostUpdates = () => {
const sendPending = async () => {
if (!pending.value || submitting.value || accessDenied.value) return
generation++
logs.value = null
logsMessage.value = ''
submitting.value = true
const submission = pending.value
try {
@@ -109,6 +146,7 @@ export const useSelfhostUpdates = () => {
report(error)
}
} finally { submitting.value = false; persist() }
await refreshLogs()
}
const check = async () => {
if (!available.value) return
@@ -145,5 +183,5 @@ export const useSelfhostUpdates = () => {
})
onBeforeUnmount(() => { stopped = true; clearTimeout(timer) })
return { overview, job, pending, connected, submitting, refreshing, message, accessDenied, recovery, busy, available, canApply,
check, apply, refresh, sendPending }
logsOpen, logsLoading, logsMessage, logEntries, refreshLogs, toggleLogs, check, apply, refresh, sendPending }
}

View File

@@ -31,6 +31,9 @@ const editState = reactive({
source: "",
paperSize: "A4",
postprocess: false,
backgroundCleanup: true,
ocr: true,
ocrLanguage: "deu+eng",
postprocessProfile: "document",
})
@@ -69,13 +72,17 @@ const applyAgentToForm = (agent: InstanceAgent | null) => {
editState.source = agent.scanDefaults?.source || ""
editState.paperSize = agent.scanDefaults?.paperSize || "A4"
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"
}
const applyIx1600Preset = () => {
Object.assign(editState, {
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,
paperSize: editState.paperSize,
postprocess: editState.postprocess,
backgroundCleanup: editState.backgroundCleanup,
ocr: editState.format === "pdf" && editState.ocr,
ocrLanguage: editState.ocrLanguage,
postprocessProfile: editState.postprocessProfile,
},
})
@@ -312,8 +322,21 @@ onMounted(async () => {
]" value-key="value" label-key="label" />
</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)]">
<UCheckbox v-model="editState.postprocess" label="OpenCV-Nachbearbeitung" />
<UCheckbox v-model="editState.postprocess" label="Zuschnitt und Ausrichtung zusätzlich korrigieren" />
<UFormField label="Profil">
<USelectMenu
v-model="editState.postprocessProfile"

View File

@@ -35,6 +35,7 @@ const guard = ref(true)
const itemInfo = ref({
type: "invoices",
state: "Entwurf",
taxType: "Standard",
customer: null,
contact: null,
@@ -332,6 +333,13 @@ const normalizeEntityId = (value) => {
}
return typeof value === "object" ? (value.id ?? null) : value
}
const normalizeUnitId = (unit) => {
const value = unit && typeof unit === "object" ? unit.id : unit
if (typeof value !== "number" && typeof value !== "string") return null
const id = Number(value)
return Number.isSafeInteger(id) && id > 0 ? id : null
}
const normalizeCreatedDocumentRow = (row) => {
let normalizedRow = row
@@ -358,6 +366,7 @@ const normalizeCreatedDocumentRow = (row) => {
return {
...normalizedRow,
id: normalizedRow.id || uuidv4(),
unit: normalizeUnitId(normalizedRow.unit),
costCentre: normalizedRow.costCentre || normalizedRow.costcentre || null,
purchasePrice: Number(normalizedRow.purchasePrice || 0),
linkedEntitys: Array.isArray(normalizedRow.linkedEntitys) ? normalizedRow.linkedEntitys : [],
@@ -688,6 +697,7 @@ const setupPage = async () => {
}
itemInfo.value.rows = normalizeCreatedDocumentRows(itemInfo.value.rows)
loaded.value = true
}
@@ -2034,19 +2044,8 @@ const setRowData = async (row, service = {sellingPriceComposed: {}}, product = {
if (row.service) {
console.log(service)
if(service.unit) {
row.unit = service.unit
} else {
let selectedService = services.value.find(i => i.id === row.service)
console.log(selectedService)
if(selectedService.unit?.id) {
row.unit = selectedService.unit.id
} else {
row.unit = selectedService.unit
}
}
row.unit = normalizeUnitId(service?.unit ?? services.value.find(i => i.id === row.service)?.unit)
//row.unit = service.unit ? service.unit : services.value.find(i => i.id === row.service).unit
row.inputPrice = ((service.sellingPriceComposed.total || service.sellingPrice) ? (service.sellingPriceComposed.total || service.sellingPrice) : (services.value.find(i => i.id === row.service).sellingPriceComposed.total || services.value.find(i => i.id === row.service).sellingPrice))
row.description = service.description ? service.description : (services.value.find(i => i.id === row.service) ? services.value.find(i => i.id === row.service).description : "")
row.costCentre = service.costcentre || services.value.find(i => i.id === row.service)?.costcentre || itemInfo.value.costcentre
@@ -2060,7 +2059,7 @@ const setRowData = async (row, service = {sellingPriceComposed: {}}, product = {
if (row.product) {
console.log("Product Detected")
row.unit = product.unit ? product.unit : products.value.find(i => i.id === row.product).unit
row.unit = normalizeUnitId(product?.unit ?? products.value.find(i => i.id === row.product)?.unit)
row.inputPrice = (product.selling_price ? product.selling_price : products.value.find(i => i.id === row.product).selling_price)
row.costCentre = row.costCentre || itemInfo.value.costcentre
//row.price = Number((row.originalPrice * (1 + itemInfo.value.customSurchargePercentage /100)).toFixed(2))
@@ -2094,7 +2093,7 @@ const setRowData = async (row, service = {sellingPriceComposed: {}}, product = {
<ArchiveButton
color="error"
type="createddocuments"
v-if="!isTemplateMode && (itemInfo.state === 'Entwurf' || itemInfo.type === 'serialInvoices')"
v-if="itemInfo.id && !isTemplateMode && (itemInfo.state === 'Entwurf' || itemInfo.type === 'serialInvoices')"
variant="outline"
@confirmed="useEntities('createddocuments').update(itemInfo.id,{archived: true}),
router.push('/')"

View File

@@ -85,13 +85,15 @@ test('stale or inactive agents cannot finish setup; fresh reports save the chose
await ui.run('finishSetup()')
assert.equal(ui.run('online.value'), false)
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()')
ui.run('profile.value="ix1600"; step.value=3')
await ui.run('finishSetup()')
assert.equal(ui.run('saved.value'), true)
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.ocr, true)
assert.equal(ui.requests[0][2].scanDefaults.backgroundCleanup, true)
assert.equal(ui.run('token.value'), '')
assert.equal(ui.timers.size, 0)
ui.run('openTestScan()')
@@ -108,6 +110,16 @@ test('online agent without a detected scanner remains incomplete', async t => {
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 => {
const ui = await setup(t, { ...baseAgent }, { getInstanceAgents: async () => { throw Error('offline') } })
ui.run('step.value=2')

View File

@@ -0,0 +1,97 @@
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 dayjs = require('dayjs')
const { parse } = require('@vue/compiler-sfc')
const { descriptor } = parse(fs.readFileSync(`${__dirname}/../pages/createDocument/edit/[[id]].vue`, 'utf8'))
const script = ts.createSourceFile('document-editor.js', descriptor.scriptSetup.content, ts.ScriptTarget.Latest, true, ts.ScriptKind.JS)
const handlers = new Set(['normalizeEntityId', 'normalizeUnitId', 'normalizeCreatedDocumentRow', 'normalizeCreatedDocumentRows', 'normalizeTaxTypeValue', 'setupPage', 'setRowData'])
const source = script.statements
.filter(statement => ts.isVariableStatement(statement) && statement.declarationList.declarations.some(declaration => handlers.has(declaration.name.getText(script))))
.map(statement => statement.getText(script)).join('\n')
function fixture(unit) {
const ref = value => ({ value })
const product = { id: 20, name: 'Artikel', unit, selling_price: 10, tax_percentage: 19 }
const service = { id: 30, name: 'Leistung', unit, sellingPrice: 20, sellingPriceComposed: {}, taxPercentage: 19 }
const context = vm.createContext({
dayjs, uuidv4: () => 'row-id', console: { log() {} },
products: ref([product]), services: ref([service]),
productcategories: ref([]), servicecategories: ref([]),
itemInfo: ref({ type: 'invoices', taxType: 'Standard', rows: [], agriculture: {} }),
route: { params: {}, query: {} }, loaded: ref(false), templateName: ref(''),
taxTypeItems: [{ key: 'Standard' }],
dataStore: { documentTypesForCreation: { invoices: { labelSingle: 'Rechnung' } } },
setupData: async () => {}, setDocumentTypeConfig() {}, setContactPersonData: async () => {},
checkCompatibilityWithInputPrice() {}, updateCustomSurcharge() {},
useEntities: relation => ({
select: async () => relation === 'products' ? [product] : [service],
selectSingle: async () => {
if (relation === 'documenttemplates') return { name: 'Vorlage', documentType: 'invoices', templateData: { rows: [{ id: 'saved-row', mode: 'normal', product: 20, unit }] } }
return { id: 40, type: 'invoices', taxType: 'Standard', rows: [{ id: 'saved-row', mode: 'normal', product: 20, unit }] }
},
}),
})
vm.runInContext(source, context)
return { context, run: code => vm.runInContext(code, context) }
}
const formats = [1, { id: 1, name: 'Stück' }, '1', { id: '1', name: 'Stück' }]
test('article and service selection resolve relation objects and legacy IDs to a selectable unit', async () => {
for (const unit of formats) {
const { run } = fixture(unit)
for (const entity of ['product', 'service']) {
run(`itemInfo.value.rows = [{ ${entity}: ${entity === 'product' ? 20 : 30} }]`)
await run('setRowData(itemInfo.value.rows[0])')
assert.equal(run('itemInfo.value.rows[0].unit'), 1, `${entity}: ${JSON.stringify(unit)}`)
}
}
})
test('article and service modal results retain their selected unit', async () => {
for (const unit of formats) {
const { run } = fixture(unit)
run('itemInfo.value.rows = [{}]')
await run('setRowData(itemInfo.value.rows[0], null, products.value[0])')
assert.equal(run('itemInfo.value.rows[0].unit'), 1)
run('itemInfo.value.rows = [{}]')
await run('setRowData(itemInfo.value.rows[0], services.value[0], null)')
assert.equal(run('itemInfo.value.rows[0].unit'), 1)
}
})
test('saved documents and both template entry points restore units without overwriting the stored choice', async () => {
for (const route of [{ params: { id: 40 }, query: {} }, { params: {}, query: { templateId: 50 } }, { params: {}, query: { templateFromDocument: 40 } }]) {
for (const unit of formats) {
const { context, run } = fixture(unit)
context.route = route
context.products.value[0].unit = { id: 2, name: 'Meter' }
await run('setupPage()')
assert.equal(run('loaded.value'), true)
assert.equal(run('itemInfo.value.rows[0].unit'), 1, JSON.stringify(route))
assert.equal(run('itemInfo.value.rows[0].id'), 'saved-row')
}
}
})
test('imported JSON rows preserve quantity, prices and the stored unit', () => {
const { run, context } = fixture(1)
context.rows = [JSON.stringify({ id: 'imported', mode: 'normal', unit: { id: '1' }, quantity: 2.5, price: 12.34 })]
const row = run('normalizeCreatedDocumentRows(rows)[0]')
assert.equal(row.unit, 1)
assert.equal(row.id, 'imported')
assert.equal(row.quantity, 2.5)
assert.equal(row.price, 12.34)
})
test('missing and malformed units remain unselected', () => {
const { run, context } = fixture(1)
for (const unit of [null, undefined, '', {}, { id: null }, 'invalid', 0, -1, true]) {
context.row = { unit }
assert.equal(run('normalizeCreatedDocumentRow(row).unit'), null)
}
})

View File

@@ -4,7 +4,7 @@ const fs = require('node:fs')
const vm = require('node:vm')
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 requests = []
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
assert.equal(body.settings.source, 'ADF Duplex')
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')
})
@@ -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 () => {
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)
await ui.run('startScan()')
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()')
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

@@ -99,3 +99,90 @@ test('an older polling response cannot erase a new submission whose response was
assert.equal(f.ui.pending.value.body.requestId, id)
assert.equal(f.ui.canApply.value, false)
})
test('failed jobs automatically show logs and refresh them only while open', async t => {
const failed = { status: 'failed', command: 'apply', requestId: 'failed-request', error: { code: 'UPDATE_FAILED', message: 'Auftrag fehlgeschlagen.' } }
let content = 'pull access denied'
const f = await setup(t, async path => {
if (path.endsWith('/logs')) return { requestId: failed.requestId, logs: [{ source: 'commands', content, truncated: false }] }
return path.endsWith('/status') ? { ...overview(failed), capabilities: { requestLogs: true } } : failed
})
assert.equal(f.ui.logsOpen.value, true)
assert.equal(f.ui.logEntries.value[0].content, content)
content = 'final failure detail'
await f.ui.refresh()
assert.equal(f.ui.logEntries.value[0].content, content)
await f.ui.toggleLogs()
const count = f.calls.filter(c => c.path.endsWith('/logs')).length
await f.ui.refresh()
assert.equal(f.ui.logsOpen.value, false)
assert.equal(f.calls.filter(c => c.path.endsWith('/logs')).length, count)
})
test('older host services explain the required upgrade without requesting unsupported endpoints', async t => {
const failed = { status: 'failed', command: 'apply', requestId: 'failed-request' }
const f = await setup(t, async path => path.endsWith('/status') ? overview(failed) : failed)
assert.equal(f.ui.logsOpen.value, true)
assert.match(f.ui.logsMessage.value, /Host-Update-Dateien aktualisieren/)
assert.equal(f.calls.some(c => c.path.endsWith('/logs')), false)
assert.equal(f.ui.connected.value, true)
})
test('log failures preserve job state and revoked admin access clears the displayed logs', async t => {
let failure
const f = await setup(t, async path => {
if (path.endsWith('/logs')) {
if (failure) throw failure
return { requestId: check.requestId, logs: [{ source: 'commands', content: 'diagnostic', truncated: false }] }
}
return path.endsWith('/status') ? { ...overview(), capabilities: { requestLogs: true } } : check
})
await f.ui.toggleLogs()
failure = { statusCode: 503 }
await f.ui.refreshLogs()
assert.match(f.ui.logsMessage.value, /konnte nicht geladen/)
assert.equal(f.ui.connected.value, true)
assert.equal(f.ui.job.value.status, 'checked')
failure = { statusCode: 403 }
await f.ui.refreshLogs()
assert.equal(f.ui.accessDenied.value, true)
assert.equal(f.ui.logEntries.value.length, 0)
})
test('a delayed log response cannot appear under a newly submitted job', async t => {
let release
const f = await setup(t, async (path, options) => {
if (options.method === 'POST') return { status: 'queued', requestId: options.body.requestId, command: 'check' }
if (path.endsWith('/logs')) return new Promise(resolve => { release = resolve })
return path.endsWith('/status') ? { ...overview(), capabilities: { requestLogs: true } } : check
})
const loading = f.ui.toggleLogs()
await new Promise(setImmediate)
await f.ui.check()
release({ requestId: check.requestId, logs: [{ source: 'commands', content: 'previous job', truncated: false }] })
await loading
assert.notEqual(f.ui.job.value.requestId, check.requestId)
assert.equal(f.ui.logEntries.value.length, 0)
})
test('the update card renders log output as escaped text and explains truncation', async t => {
const f = await setup(t, async path => path.endsWith('/status') ? overview() : check)
f.ui.logsOpen.value = true
const { parse, compileScript } = require('@vue/compiler-sfc')
const descriptor = parse(fs.readFileSync(__dirname + '/../components/SelfhostUpdateCard.vue', 'utf8')).descriptor
const compiled = compileScript(descriptor, { id: 'update-card-test', inlineTemplate: true }).content
const exports = {}
const context = vm.createContext({ exports, require, ref: vue.ref, computed: vue.computed,
useRuntimeConfig: () => ({ public: {} }), useSelfhostUpdates: () => ({ ...f.ui,
logEntries: vue.ref([{ source: 'commands', content: '<script>alert("log")</script>', truncated: true }]) }) })
vm.runInContext(ts.transpile(compiled, { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS }), context)
const app = vue.createSSRApp(exports.default)
for (const name of ['UCard', 'UIcon', 'UBadge', 'UAlert', 'UProgress', 'UButton', 'UModal', 'UCheckbox']) {
app.component(name, { setup: (_props, { slots }) => () => vue.h('div', slots.default?.()) })
}
const html = await require('@vue/server-renderer').renderToString(app)
assert.match(html, /Werkzeugausgaben/)
assert.match(html, /Gekürzte Anzeige/)
assert.match(html, /&lt;script&gt;/)
assert.doesNotMatch(html, /<script>/)
})

View File

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

View File

@@ -14,6 +14,7 @@ import socketserver
import stat
import threading
import traceback
import urllib.parse
spec = importlib.util.spec_from_file_location("fedeo_updater", Path(__file__).with_name("selfhost-update.py"))
engine = importlib.util.module_from_spec(spec)
@@ -33,7 +34,8 @@ ERRORS = {
"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. Erneut prüfen.",
"UPDATE_FAILED": "Auftrag fehlgeschlagen. Geschützte Protokolle auf dem Host prüfen.",
"UPDATE_FAILED": "Auftrag fehlgeschlagen.",
"LOGS_UNAVAILABLE": "Das Update-Protokoll kann gerade nicht gelesen werden.",
"INTERRUPTED": "Der Auftrag wurde unterbrochen. Status auf dem Host prüfen.",
}
@@ -82,6 +84,74 @@ def public_plan(plan):
return result
LOG_BYTES = 6000
SECRET_KEY = r"[\w.-]*(?:password|passwd|secret|token|credential|authorization|(?:api|private|access|encryption|signing)[_-]?key|database[_-]?url|dsn|auth)[\w.-]*"
QUOTED_VALUE = r"\"(?:\\.|[^\"\\])*\"|'[^']*'"
SECRET_VALUE = re.compile(r"(?im)(?<![\w.-])([\"']?" + SECRET_KEY + r"[\"']?\s*[:=]\s*)(" + QUOTED_VALUE + r"|[^\r\n,}\]]+)")
CONFIG_SECRET = re.compile(r"(?im)^\s*(?:export\s+|-\s*)?[\"']?" + SECRET_KEY + r"[\"']?\s*[:=]\s*(" + QUOTED_VALUE + r"|[^\r\n]+)")
def read_private(path, *, tail=False):
"""Read only regular files, never follow a log/config symlink or block on a FIFO."""
try:
fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
except FileNotFoundError:
return "", False
with os.fdopen(fd, "rb") as stream:
size = os.fstat(stream.fileno()).st_size
if not stat.S_ISREG(os.fstat(stream.fileno()).st_mode):
raise OSError("Not a regular file")
limit = 32768 if tail else 1024 * 1024
if size > limit and not tail:
raise OSError("Configuration too large to redact safely")
truncated = size > limit
if truncated:
stream.seek(-limit, os.SEEK_END)
stream.readline(limit) # Never expose a suffix of a secret on a cut line.
return stream.read(limit).decode("utf-8", errors="replace"), truncated
def log_redactor(directory, job):
# Include original values after an update changed the active configuration.
paths = [directory / name for name in (".env", "docker-compose.yml", "docker-compose.override.yml", "docker-compose.override.yaml")]
if job:
paths += [job / "target.env", job / "target.compose.yml"]
paths += [job / "backup/config" / name for name in (".env", "docker-compose.yml", "docker-compose.override.yml", "docker-compose.override.yaml")]
values = {value for key, value in os.environ.items() if re.fullmatch(SECRET_KEY, key, re.I) and value}
for path in paths:
content, _ = read_private(path)
for match in CONFIG_SECRET.finditer(content):
value = match[1].strip()
if value[:1] in ("'", '"'):
if value.startswith('"'):
try:
values.add(json.loads(value))
except ValueError:
pass
value = value[1:-1]
else:
value = value.split(" #", 1)[0].strip()
if value:
values.add(value)
for match in re.finditer(r"[a-z][a-z0-9+.-]*://[^\s\"']+", content, re.I):
password = urllib.parse.urlsplit(match[0]).password
if password:
values.update((password, urllib.parse.unquote(password)))
values |= {urllib.parse.quote(value, safe="") for value in values}
def redact(content):
content = re.sub(r"\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1b\\))", "", content)
for value in sorted(values, key=len, reverse=True):
content = content.replace(value, "[REDACTED]")
content = re.sub(r"(?is)-----BEGIN [^-]*PRIVATE KEY-----.*?(?:-----END [^-]*PRIVATE KEY-----|\Z)", "[REDACTED]", content)
content = re.sub(r"(?i)([a-z][a-z0-9+.-]*://)[^\s/]*@", r"\1[REDACTED]@", content)
content = re.sub(r"(?i)\b(Bearer|Basic|token)\s+[A-Za-z0-9._~+/=-]+", r"\1 [REDACTED]", content)
content = SECRET_VALUE.sub(lambda m: m[1] + "[REDACTED]", content)
content = re.sub(r"(?i)(--" + SECRET_KEY + r"\s+)(" + QUOTED_VALUE + r"|\S+)", r"\1[REDACTED]", content)
return re.sub(r"[\x00-\x08\x0b-\x1f\x7f]", "", content)
return redact
class Controller:
def __init__(self, directory, *, updater_factory=engine.Updater):
self.directory = Path(directory).resolve()
@@ -140,9 +210,47 @@ class Controller:
result["progress"] = public_state(state)
return result
def request_logs(self, request_id):
record = self.read(request_id)
try:
home = self.home.parent
state = engine.read_json(home / "status.json", {})
candidates = [Path(record["job"])] if record.get("job") else []
if state.get("requestId") == request_id and state.get("job"):
candidates.append(Path(state["job"]))
# Older service versions did not persist the engine-job association.
if not candidates:
candidates = [path.parent for path in home.glob("*/status.json")]
job = None
for candidate in candidates:
if candidate.parent != home or candidate.is_symlink() or not candidate.is_dir():
continue
content, _ = read_private(candidate / "status.json")
if content and json.loads(content).get("requestId") == request_id:
job = candidate
break
redact = log_redactor(self.directory, job)
files = [("service", self.home / (request_id + ".log"))]
if job:
files += [("events", job / "events.jsonl"), ("commands", job / "commands.log")]
logs = []
for source, path in files:
content, truncated = read_private(path, tail=True)
content = redact(content)
encoded = content.encode("utf-8")
if len(encoded) > LOG_BYTES:
content = encoded[-LOG_BYTES:].decode("utf-8", errors="ignore")
truncated = True
if content or truncated:
logs.append({"source": source, "content": content, "truncated": truncated})
return {"requestId": request_id, "logs": logs}
except (OSError, ValueError, TypeError):
raise ApiError("LOGS_UNAVAILABLE", 503) from None
def status(self):
updater = self.factory(self.directory)
result = {"apiVersion": 1, "engine": public_state(updater.status()), "acceptingRequests": not self.closing}
result = {"apiVersion": 1, "engine": public_state(updater.status()), "acceptingRequests": not self.closing,
"capabilities": {"requestLogs": True}}
installed = engine.read_json(updater.home / "installed.json", {}).get("manifest", {})
result["installed"] = {k: v for k, v in public_state({"version": installed.get("version"),
"revision": installed.get("commit")}).items() if k != "status"}
@@ -208,6 +316,7 @@ class Controller:
def run(self, record, fingerprint):
request_id = record["requestId"]
updater = None
try:
record.update(status="running", updatedAt=engine.now())
engine.write_json(self.path(request_id), record)
@@ -224,6 +333,8 @@ class Controller:
os.chmod(log.name, 0o600)
traceback.print_exc(file=log)
finally:
if updater and updater.state and updater.state.get("requestId") == request_id:
record["job"] = str(updater.job)
record["updatedAt"] = engine.now()
engine.write_json(self.path(request_id), record)
@@ -267,7 +378,9 @@ class Handler(BaseHTTPRequestHandler):
if self.command == "GET" and self.path == "/v1/status":
self.send_json(200, controller.status())
elif self.command == "GET" and self.path.startswith("/v1/requests/"):
self.send_json(200, controller.request_status(self.path.removeprefix("/v1/requests/")))
suffix = self.path.removeprefix("/v1/requests/")
self.send_json(200, controller.request_logs(suffix.removesuffix("/logs"))
if suffix.endswith("/logs") else controller.request_status(suffix))
elif self.command == "POST" and self.path in ("/v1/check", "/v1/apply"):
lengths = self.headers.get_all("Content-Length", [])
if (len(lengths) != 1 or not lengths[0].isdigit() or not 1 <= int(lengths[0]) <= 4096

View File

@@ -566,7 +566,13 @@ class Updater:
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.")
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)
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.")
@@ -590,13 +596,13 @@ class Updater:
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())
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, "run", "--rm", "--no-deps", "createbuckets")
self.compose(True, "up", "--pull", "never", "-d", "--no-deps", "--wait", "--wait-timeout", "180", "db", "minio", "node-exporter")
self.compose(True, "run", "--pull", "never", "--rm", "--no-deps", "createbuckets")
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")
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.verify_matrix_stopped(retired=True)
for name, image in self.images.items():

View File

@@ -8,6 +8,7 @@ import subprocess
import tempfile
import threading
import unittest
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[3]
spec = importlib.util.spec_from_file_location('update_service', ROOT / 'scripts/selfhost-update-service.py')
@@ -232,6 +233,86 @@ class ServiceTests(unittest.TestCase):
self.assertEqual(self.request('GET', '/v1/logs')[0], 404)
self.assertEqual((self.controller.home / 'check-request-01.log').stat().st_mode & 0o777, 0o600)
def test_logs_include_failure_commands_and_events_with_credentials_redacted(self):
(self.root / '.env').write_text('POSTGRES_PASSWORD="database-secret"\nAPI_TOKEN=api-secret\n'
'SMTP_PASSWORD=complex,secret/suffix\n'
'DATABASE_URL=postgres://user:db%2Fsecret@db/fedeo\n')
(self.root / 'docker-compose.override.yml').write_text(' MINIO_ROOT_PASSWORD: "minio-secret"\n')
self.factory.failure = s.engine.UpdateError('Registry unauthorized: database-secret')
request_id = self.check()
job = self.root / '.updates' / ('job-' + request_id)
(job / 'target.env').write_text("SMTP_PASSWORD='previous-secret'\n")
(job / 'commands.log').write_text(
'\x1b[31mError: pull access denied\x1b[0m\n'
'database-secret api-secret minio-secret previous-secret release-secret\n'
'complex,secret/suffix complex%2Csecret%2Fsuffix db/secret\n'
'https://user:url-secret@registry.test/image\n'
'Authorization: Bearer header-secret\n'
'{"password": "json-secret", "access_token": "query-secret"}\n'
'TOKEN=unquoted-secret\n--password cli-secret\n')
with patch.dict(s.os.environ, {'FEDEO_RELEASE_TOKEN': 'release-secret'}):
status, result = self.request('GET', f'/v1/requests/{request_id}/logs')
self.assertEqual(status, 200)
self.assertEqual(result['requestId'], request_id)
logs = {entry['source']: entry['content'] for entry in result['logs']}
self.assertIn('Registry unauthorized', logs['service'])
self.assertIn('pull access denied', logs['commands'])
self.assertIn('validating', logs['events'])
for secret in ('database-secret', 'api-secret', 'minio-secret', 'previous-secret', 'release-secret',
'url-secret', 'header-secret', 'json-secret', 'query-secret', 'unquoted-secret', 'cli-secret',
'complex,secret/suffix', 'complex%2Csecret%2Fsuffix', 'db/secret', '\x1b'):
self.assertNotIn(secret, json.dumps(result))
self.assertTrue(self.request('GET', '/v1/status')[1]['capabilities']['requestLogs'])
def test_logs_survive_restart_and_find_legacy_jobs_without_crossing_requests(self):
first = self.check()
first_job = self.root / '.updates' / ('job-' + first)
(first_job / 'commands.log').write_text('first request only\n')
# Simulate the private request record from an older host service.
record = self.controller.read(first)
record.pop('job')
s.engine.write_json(self.controller.path(first), record)
second = self.check('check-request-02')
(self.root / '.updates' / ('job-' + second) / 'commands.log').write_text('second request only\n')
self.controller.close()
self.controller = s.Controller(self.root, updater_factory=self.factory)
self.server.controller = self.controller
for request_id, own, other in [(first, 'first', 'second'), (second, 'second', 'first')]:
payload = json.dumps(self.request('GET', f'/v1/requests/{request_id}/logs')[1])
self.assertIn(own + ' request only', payload)
self.assertNotIn(other + ' request only', payload)
self.assertEqual(self.request('GET', '/v1/requests/missing-request/logs')[0], 404)
self.assertEqual(self.request('GET', '/v1/requests/../../secret/logs')[0], 400)
def test_running_logs_are_bounded_and_do_not_expose_partial_secret_lines(self):
self.factory.release.clear()
request_id = 'check-running-logs'
self.request('POST', '/v1/check', {'requestId': request_id})
self.assertTrue(self.factory.started.wait(3))
job = self.root / '.updates' / ('job-' + request_id)
(job / 'commands.log').write_text('PASSWORD=' + 'secret-fragment' * 4000 + '\n' + 'old entry\n' * 900 + 'Last diagnostic: ä\n')
result = self.request('GET', f'/v1/requests/{request_id}/logs')[1]
commands = next(entry for entry in result['logs'] if entry['source'] == 'commands')
self.assertTrue(commands['truncated'])
self.assertLessEqual(len(commands['content'].encode()), s.LOG_BYTES)
self.assertIn('Last diagnostic: ä', commands['content'])
self.assertNotIn('secret-fragment', commands['content'])
self.assertLess(len(json.dumps(result).encode()), 65536)
def test_logs_fail_closed_for_symlinks_and_unreadable_redaction_configuration(self):
request_id = self.check()
job = self.root / '.updates' / ('job-' + request_id)
private = self.root / 'private.txt'
private.write_text('never publish this')
(job / 'commands.log').symlink_to(private)
status, result = self.request('GET', f'/v1/requests/{request_id}/logs')
self.assertEqual(status, 503)
self.assertEqual(result['error']['code'], 'LOGS_UNAVAILABLE')
self.assertNotIn('never publish this', json.dumps(result))
(job / 'commands.log').unlink()
(self.root / '.env').symlink_to(private)
self.assertEqual(self.request('GET', f'/v1/requests/{request_id}/logs')[0], 503)
def test_api_rejects_unsupported_commands_options_bodies_and_browser_requests(self):
for body in [[], {'requestId': 'bad/path'}, {'requestId': 'check-request-01', 'version': ';reboot'},
{'requestId': 'check-request-01', 'directory': '/etc'},

View File

@@ -79,6 +79,32 @@ class Runner:
self.matrix_containers = {}
self.on_stop = None
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):
self.calls.append(args)
@@ -108,6 +134,7 @@ class Runner:
capture_output=True, text=True)
if result.returncode:
raise u.UpdateError('Compose invalid: ' + result.stderr)
self.compose_configs[args[args.index('-f') + 1]] = json.loads(result.stdout)
return result.stdout
if 'ps' in args:
if args[:2] == ['docker', 'ps']:
@@ -121,6 +148,15 @@ class Runner:
container['State']['Running'] = 'start' in args
if 'stop' in args and self.on_stop:
self.on_stop()
if args[:2] == ['docker', 'compose'] and any(command in args for command in ('pull', 'up', 'run')):
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):
@@ -493,6 +529,49 @@ class UpdateTests(unittest.TestCase):
self.run_update()
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):
self.runner.on_pull = lambda: (self.root / '.env').write_bytes(self.original_env + b'\nNEW=value\n')
with self.assertRaisesRegex(u.UpdateError, 'während'):