#!/usr/bin/env bash # # PDF OCR Hotfolder — Installer / Instanz-Manager für Debian 12/13 # # Basis-Installation erfolgt einmalig (Code, venv, systemd-Template-Unit). # Danach werden Hotfolder-Instanzen verwaltet: # - Beim Erstlauf: mindestens eine Instanz wird angelegt # - Beim Folgelauf: bestehende Instanzen werden erkannt; neue können ergänzt werden # # Unterstützt lokale System-User und AD-User mit lokaler UID (SSSD/Winbind). # set -euo pipefail RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; BLUE='\033[0;34m'; NC='\033[0m' log_info() { echo -e "${GREEN}[INFO]${NC} $*"; } log_warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } log_error() { echo -e "${RED}[ERROR]${NC} $*"; } log_step() { echo -e "\n${BLUE}==>${NC} $*"; } if [ "${EUID}" -ne 0 ]; then log_error "Bitte als root ausführen: sudo ./install.sh" exit 1 fi INSTALL_DIR="/opt/pdf-ocr-hotfolder" CONFIG_DIR="/etc/pdf-ocr-hotfolder" DATA_ROOT="/var/lib/pdf-ocr-hotfolder" SERVICE_TEMPLATE="pdf-ocr-hotfolder@.service" DEFAULT_USER="pdfocr" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_DIR="$SCRIPT_DIR" if [ ! -f "$REPO_DIR/pdf_ocr_hotfolder/__init__.py" ]; then log_error "Repo-Layout nicht erkannt. install.sh aus dem Repo ausführen." exit 1 fi # ============================================================ # Basis-Installation (idempotent) # ============================================================ install_base() { log_step "System-Pakete installieren" apt-get update -qq apt-get install -y --no-install-recommends \ python3 python3-venv python3-pip \ tesseract-ocr tesseract-ocr-deu tesseract-ocr-eng \ ghostscript qpdf unpaper pngquant \ icc-profiles-free ca-certificates curl log_info "System-Pakete ok ✓" # Ghostscript-Versions-Check (Issue #3 + Issue #6) if command -v gs >/dev/null 2>&1; then GS_VER="$(gs --version 2>/dev/null || echo 0.0)" log_info "Ghostscript: $GS_VER" case "$GS_VER" in 10.0.0|10.00.0|10.01.*|10.02.0) echo log_warn "═══════════════════════════════════════════════════════════════" log_warn "Ghostscript $GS_VER ist vom PDF/A-Bug betroffen (10.0.0–10.02.0)." log_warn "Mit pdfa_level + skip_text=true kann ocrmypdf KEINE PDFs verarbeiten." log_warn "═══════════════════════════════════════════════════════════════" echo # Prüfe ob Debian bookworm (12) — Backports anbieten if grep -q 'bookworm' /etc/os-release 2>/dev/null; then read -r -p "Ghostscript via bookworm-backports upgraden? [J/n]: " UPGRADE_GS UPGRADE_GS="${UPGRADE_GS:-J}" if [[ "$UPGRADE_GS" =~ ^[JjYy]$ ]]; then log_info "Aktiviere bookworm-backports..." if ! grep -q 'bookworm-backports' /etc/apt/sources.list /etc/apt/sources.list.d/*.list 2>/dev/null; then echo 'deb http://deb.debian.org/debian bookworm-backports main' \ > /etc/apt/sources.list.d/bookworm-backports.list apt-get update -qq fi apt-get install -y -t bookworm-backports ghostscript GS_VER_NEW="$(gs --version 2>/dev/null || echo '?')" log_info "Ghostscript aktualisiert: $GS_VER → $GS_VER_NEW ✓" else log_warn "Workaround: In der Config [ocr].pdfa_level = \"\" setzen (Default ab v0.2.2)" fi else log_warn "Kein Debian bookworm erkannt — manuelles Upgrade nötig." log_warn "Workaround: In der Config [ocr].pdfa_level = \"\" setzen (Default ab v0.2.2)" fi echo ;; esac fi # LXC/Container-Erkennung (Issue #4) if systemd-detect-virt --container -q 2>/dev/null; then VIRT_TYPE="$(systemd-detect-virt --container 2>/dev/null || echo 'container')" log_warn "Container-Umgebung erkannt ($VIRT_TYPE)." log_warn "systemd-Hardening kann in Containern fehlschlagen (Error 226/NAMESPACE)." read -r -p "LXC-Kompatibilitäts-Drop-in installieren? [J/n]: " LXC_FIX LXC_FIX="${LXC_FIX:-J}" if [[ "$LXC_FIX" =~ ^[JjYy]$ ]]; then local LXC_DROPIN_DIR="/etc/systemd/system/pdf-ocr-hotfolder@.service.d" mkdir -p "$LXC_DROPIN_DIR" cp "$REPO_DIR/systemd/lxc-compat.conf" "$LXC_DROPIN_DIR/lxc-compat.conf" systemctl daemon-reload log_info "LXC-Kompatibilitäts-Drop-in installiert ✓" fi fi log_step "Default-User '$DEFAULT_USER' prüfen" if id "$DEFAULT_USER" &>/dev/null; then log_info "'$DEFAULT_USER' existiert bereits" else adduser --system --group --home "$DATA_ROOT" --shell /usr/sbin/nologin "$DEFAULT_USER" log_info "System-User '$DEFAULT_USER' angelegt ✓" fi log_step "Verzeichnisse anlegen" mkdir -p "$INSTALL_DIR" "$CONFIG_DIR" "$DATA_ROOT" chown root:"$DEFAULT_USER" "$CONFIG_DIR" chmod 750 "$CONFIG_DIR" log_step "Code kopieren" rm -rf "$INSTALL_DIR/pdf_ocr_hotfolder" cp -r "$REPO_DIR/pdf_ocr_hotfolder" "$INSTALL_DIR/" cp "$REPO_DIR/requirements.txt" "$INSTALL_DIR/" cp "$REPO_DIR/VERSION" "$INSTALL_DIR/" cp "$REPO_DIR/config.example.toml" "$INSTALL_DIR/" echo "$REPO_DIR" > "$INSTALL_DIR/.repo_path" log_step "Python venv" if [ ! -d "$INSTALL_DIR/venv" ]; then python3 -m venv "$INSTALL_DIR/venv" fi "$INSTALL_DIR/venv/bin/pip" install --upgrade pip -q "$INSTALL_DIR/venv/bin/pip" install -r "$INSTALL_DIR/requirements.txt" -q log_info "venv ok ✓" log_step "systemd Template-Unit installieren" cp "$REPO_DIR/systemd/$SERVICE_TEMPLATE" "/etc/systemd/system/$SERVICE_TEMPLATE" systemctl daemon-reload log_info "Template-Unit installiert ✓" chown -R "$DEFAULT_USER":"$DEFAULT_USER" "$INSTALL_DIR" } # ============================================================ # Instanz-Verwaltung # ============================================================ list_instances() { find "$CONFIG_DIR" -maxdepth 1 -name '*.toml' -type f 2>/dev/null \ | sed 's|.*/||; s|\.toml$||' \ | sort } show_existing_instances() { local instances mapfile -t instances < <(list_instances) if [ "${#instances[@]}" -eq 0 ]; then log_info "Keine bestehenden Instanzen gefunden." return fi echo log_info "Bestehende Instanzen:" for name in "${instances[@]}"; do local active active=$(systemctl is-active "pdf-ocr-hotfolder@${name}.service" 2>/dev/null || echo inactive) printf " • %-30s [%s]\n" "$name" "$active" done echo } # Liest den Wert eines Keys (erste Zuweisung am Zeilenanfang) aus einer Config config_value() { local file="$1" key="$2" sed -n "s|^${key}[[:space:]]*=[[:space:]]*\"\(.*\)\"[[:space:]]*$|\1|p" "$file" | head -n1 } # Maskiert Sonderzeichen, damit ein Pfad gefahrlos in eine sed-Ersetzung darf # (Trennzeichen '|', Rueckverweis '&', Backslash). sed_escape_repl() { printf '%s' "$1" | sed -e 's/[\\&|]/\\&/g' } # Prueft jeden Tesseract-Sprachcode gegen die installierten Sprachdateien und # bietet fehlende Pakete zur Installation an. # Rueckgabe: 0 = alle Sprachen verfuegbar (oder Pruefung nicht moeglich), # 1 = mindestens eine Sprache fehlt weiterhin. ensure_tesseract_langs() { local langs="$1" local raw installed code pkg answer rc=0 local -a codes if ! command -v tesseract >/dev/null 2>&1; then log_warn "tesseract ist nicht aufrufbar — Sprachpruefung wird uebersprungen." log_warn "Eingabe '$langs' wird unveraendert uebernommen." return 0 fi if ! raw="$(tesseract --list-langs 2>/dev/null)"; then log_warn "'tesseract --list-langs' schlug fehl — Sprachpruefung wird uebersprungen." log_warn "Eingabe '$langs' wird unveraendert uebernommen." return 0 fi installed="$(printf '%s\n' "$raw" | grep -vi '^List of available' || true)" IFS='+' read -r -a codes <<< "$langs" for code in "${codes[@]}"; do [ -n "$code" ] || continue if printf '%s\n' "$installed" | grep -qxF "$code"; then log_info "Sprache '$code' ist installiert ✓" continue fi pkg="tesseract-ocr-${code//_/-}" log_warn "Sprache '$code' ist nicht installiert (Paket: $pkg)." read -r -p "Paket '$pkg' jetzt installieren? [J/n]: " answer answer="${answer:-J}" if [[ "$answer" =~ ^[JjYy]$ ]]; then if ! apt-get install -y --no-install-recommends "$pkg"; then log_error "Paket '$pkg' liess sich nicht installieren." elif tesseract --list-langs 2>/dev/null | grep -qxF "$code"; then log_info "Paket '$pkg' installiert ✓" continue else log_error "Paket '$pkg' ist da, aber tesseract kennt '$code' weiterhin nicht." fi fi log_warn "Ohne die Sprachdatei '$code' scheitert das OCR bei JEDER Datei." rc=1 done return $rc } create_instance() { echo read -r -p "Instanz-Name (nur a-z, 0-9, -): " INST if [[ ! "$INST" =~ ^[a-z0-9][a-z0-9-]*$ ]]; then log_error "Ungültiger Name. Abbruch." return 1 fi if [ -f "$CONFIG_DIR/$INST.toml" ]; then log_error "Instanz '$INST' existiert bereits. Abbruch." return 1 fi local default_base="$DATA_ROOT/$INST" read -r -p "Basis-Pfad für Daten [$default_base]: " BASE BASE="${BASE:-$default_base}" read -r -p "Service-User [$DEFAULT_USER]: " SVC_USER SVC_USER="${SVC_USER:-$DEFAULT_USER}" local SVC_GROUP if id "$SVC_USER" &>/dev/null; then SVC_GROUP="$(id -gn "$SVC_USER")" log_info "User '$SVC_USER' existiert (Gruppe: $SVC_GROUP)" else log_warn "User '$SVC_USER' existiert nicht." read -r -p "Lokal als System-User anlegen? [J/n]: " CREATE_USER CREATE_USER="${CREATE_USER:-J}" if [[ "$CREATE_USER" =~ ^[JjYy]$ ]]; then adduser --system --group --home "$BASE" --shell /usr/sbin/nologin "$SVC_USER" SVC_GROUP="$SVC_USER" log_info "User '$SVC_USER' angelegt ✓" else log_error "User muss existieren (z.B. via AD/SSSD). Abbruch." return 1 fi fi # --- OCR-Sprachen --- echo log_info "Tesseract-Sprachen — gelten NUR fuer diese Instanz '$INST'." log_info "Jede zusaetzliche Sprache kostet Laufzeit und verschlechtert zugleich" log_info "die Erkennung — also so eng wie moeglich waehlen (z.B. nur 'deu')." local LANGS while true; do read -r -p "Tesseract-Sprachen [deu+eng]: " LANGS LANGS="${LANGS:-deu+eng}" if [[ ! "$LANGS" =~ ^[a-z]{3}(_[A-Za-z]+)?(\+[a-z]{3}(_[A-Za-z]+)?)*$ ]]; then log_error "Ungueltiges Format. Erwartet: Sprachcodes mit '+' verbunden," log_error "z.B. 'deu', 'deu+eng' oder 'chi_sim+eng'." continue fi if ensure_tesseract_langs "$LANGS"; then break fi log_warn "Bitte Sprachen erneut angeben (fehlende Sprache einfach weglassen)." echo done # --- Original archivieren? --- echo local ORIG_MODE="delete" local ARCHIVE_DIR="" local ARCHIVE_ANS read -r -p "Original nach erfolgreichem OCR archivieren? [j/N]: " ARCHIVE_ANS ARCHIVE_ANS="${ARCHIVE_ANS:-N}" if [[ "$ARCHIVE_ANS" =~ ^[JjYy]$ ]]; then ORIG_MODE="archive" local default_archive="$BASE/archive" while true; do read -r -p "Archiv-Verzeichnis [$default_archive]: " ARCHIVE_DIR ARCHIVE_DIR="${ARCHIVE_DIR:-$default_archive}" if [[ "$ARCHIVE_DIR" != /* ]]; then log_error "Bitte einen absoluten Pfad angeben (beginnt mit '/')." continue fi # Das Archiv darf keines der Arbeitsverzeichnisse sein: im Eingang # wuerde das Original endlos neu aufgegriffen, in den uebrigen # kollidiert es mit der Verarbeitung. case "${ARCHIVE_DIR%/}" in "$BASE/incoming"|"$BASE/outgoing"|"$BASE/working"|"$BASE/error") log_error "Das Archiv darf nicht incoming/outgoing/working/error sein." continue ;; esac break done else log_info "Original wird nach erfolgreichem OCR geloescht (original_on_success = \"delete\")." fi log_info "Lege Datenverzeichnisse unter $BASE an..." mkdir -p "$BASE"/{incoming,outgoing,working,error} if [ -n "$ARCHIVE_DIR" ]; then mkdir -p "$ARCHIVE_DIR" fi chown -R "$SVC_USER":"$SVC_GROUP" "$BASE" # Innerhalb von $BASE erledigt das chown -R oben schon alles; nur ein Archiv # ausserhalb braucht eigenes mkdir/chown. if [ -n "$ARCHIVE_DIR" ] && [[ "$ARCHIVE_DIR" != "$BASE"/* ]] && [ "$ARCHIVE_DIR" != "$BASE" ]; then chown -R "$SVC_USER":"$SVC_GROUP" "$ARCHIVE_DIR" log_info "Archiv-Verzeichnis $ARCHIVE_DIR angelegt (liegt ausserhalb von $BASE)" fi log_info "Erstelle Config $CONFIG_DIR/$INST.toml..." # Verankerte Ausdruecke (Zeilenanfang + Key + '='), damit die deutschen # Kommentarzeilen ueber den Keys unangetastet bleiben. local ESC_BASE ESC_ARCHIVE ESC_LANGS ESC_BASE="$(sed_escape_repl "$BASE")" ESC_ARCHIVE="$(sed_escape_repl "$ARCHIVE_DIR")" ESC_LANGS="$(sed_escape_repl "$LANGS")" sed \ -e "s|^incoming[[:space:]]*=.*|incoming = \"$ESC_BASE/incoming\"|" \ -e "s|^outgoing[[:space:]]*=.*|outgoing = \"$ESC_BASE/outgoing\"|" \ -e "s|^working[[:space:]]*=.*|working = \"$ESC_BASE/working\"|" \ -e "s|^error[[:space:]]*=.*|error = \"$ESC_BASE/error\"|" \ -e "s|^languages[[:space:]]*=.*|languages = \"$ESC_LANGS\"|" \ -e "s|^original_on_success[[:space:]]*=.*|original_on_success = \"$ORIG_MODE\"|" \ -e "s|^archive_dir[[:space:]]*=.*|archive_dir = \"$ESC_ARCHIVE\"|" \ "$INSTALL_DIR/config.example.toml" > "$CONFIG_DIR/$INST.toml" chown root:"$SVC_GROUP" "$CONFIG_DIR/$INST.toml" chmod 640 "$CONFIG_DIR/$INST.toml" # Erzeugte Config gegenpruefen: tragen die drei Keys wirklich die Auswahl? local CFG_OK=1 got key want for key in languages original_on_success archive_dir; do case "$key" in languages) want="$LANGS" ;; original_on_success) want="$ORIG_MODE" ;; archive_dir) want="$ARCHIVE_DIR" ;; esac got="$(config_value "$CONFIG_DIR/$INST.toml" "$key")" if [ "$got" != "$want" ]; then log_error "Config-Pruefung: $key ist \"$got\", erwartet \"$want\"" CFG_OK=0 fi done if [ "$CFG_OK" -eq 1 ]; then log_info "Config-Pruefung ok ✓ (languages / original_on_success / archive_dir)" else log_warn "Bitte $CONFIG_DIR/$INST.toml von Hand nachziehen." fi # Drop-in für abweichenden Service-User if [ "$SVC_USER" != "$DEFAULT_USER" ]; then local DROPIN_DIR="/etc/systemd/system/pdf-ocr-hotfolder@${INST}.service.d" mkdir -p "$DROPIN_DIR" cat > "$DROPIN_DIR/user.conf" < -f" echo " Neustart: systemctl restart pdf-ocr-hotfolder@" echo " Update: sudo ./update.sh" echo