From 0a7b27e4f2e34842d70c1ad343d20999ba364f36 Mon Sep 17 00:00:00 2001 From: Samuel Zielke Date: Tue, 13 Oct 2026 13:42:34 +0200 Subject: [PATCH] Erste Version --- .gitignore | 14 + README.md | 377 ++++++++++++++ apps-script/Code.gs | 452 ++++++++++++++++ install.sh | 519 +++++++++++++++++++ server/.env.example | 26 + server/monitor-check.service | 32 ++ server/nginx-monitor-check.conf | 39 ++ server/package.json | 19 + server/server.js | 151 ++++++ web/index.html | 885 ++++++++++++++++++++++++++++++++ 10 files changed, 2514 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 apps-script/Code.gs create mode 100644 install.sh create mode 100644 server/.env.example create mode 100644 server/monitor-check.service create mode 100644 server/nginx-monitor-check.conf create mode 100644 server/package.json create mode 100644 server/server.js create mode 100644 web/index.html diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ed438e8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +# Geheimnisse und lokale Konfiguration +server/.env +*.pem +*.key +ZUGANGSDATEN.txt + +# Abhängigkeiten und Laufzeitdaten +node_modules/ +uploads/ + +# Gepackte Stände – werden bei Bedarf neu erzeugt +*.tar.gz + +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..d55e20c --- /dev/null +++ b/README.md @@ -0,0 +1,377 @@ +# Monitor-Check + +**Bildschirme, Lautsprecher und Übersetzungsanlagen vor Ort per Handy erfassen und prüfen.** + +Bei Veranstaltungen mit vielen Monitoren, z. B. in Foyers, Nebenräumen oder +Übertragungssälen, muss vorher jedes Gerät einmal geprüft werden: Kommt Ton? +Kommt Bild? Läuft die richtige Sprache? Monitor-Check macht daraus eine kleine +Web-App fürs Handy. Ihr geht von Gerät zu Gerät, macht ein Foto, tippt drei +Mal auf JA / NEIN / n.A., und die Übersicht zeigt sofort, was funktioniert und +wo jemand nachsehen muss. + +``` +Handy (Browser) + ├── Foto ──────────────► dein Server (Node) ──► /uploads/…jpg + └── Daten ──────────────► Apps-Script Web-App ──► Google Tabelle +``` + +Die **Daten liegen in einer Google Tabelle**, die ihr selbst besitzt. Dort +könnt ihr filtern, drucken oder teilen. Die **Oberfläche und die Fotos liegen +auf eurem eigenen Server**. Im Repo sind keine Zugangsdaten enthalten: Token, +Tabellen-URL und Domain richtet ihr bei der Installation selbst ein. + +### Was das Tool kann + +- **Erfassen in Sekunden:** Bezeichnung, Standort, Foto direkt aus der Kamera, Status +- **Automatischer Status** aus den drei Prüfpunkten *Audio*, *Bild* und *Sprache* +- **Übersicht** mit Kacheln, Suche und Statusfilter +- **Fortlaufende IDs** (`MON-001`, `MON-002` …), die nie doppelt vergeben werden +- **Protokoll** jeder Änderung mit Zeit und Person im Blatt `Protokoll` +- **Offline-Warteschlange:** Änderungen ohne Netz werden nachgereicht +- **Als App installierbar** über „Zum Home-Bildschirm“ + +### So kommt ihr zum Laufen + +1. **Google Tabelle** anlegen und das Apps Script einfügen ([Teil 1](#teil-1--google-tabelle-einrichten)) +2. **Server** mit `install.sh` aufsetzen ([Teil 2](#teil-2--server-aufsetzen)) +3. **Am Handy** die Seite öffnen und im Setup-Tab URL und Token eintragen ([Teil 3](#teil-3--oberfläche-verbinden)) + +Dafür braucht ihr ein Google-Konto, einen Linux-Server oder LXC-Container +(Ubuntu 22.04/24.04) und eine Domain mit HTTPS. Ohne HTTPS gibt das Handy die +Kamera nicht frei. + +### Anpassen + +| Was | Wo | +|---|---| +| ID-Präfix (`MON`) und Stellenzahl | `apps-script/Code.gs` → `CONFIG.ID_PREFIX`, `CONFIG.ID_PAD` | +| Namen der Tabellenblätter | `apps-script/Code.gs` → `CONFIG.SHEET_NAME`, `CONFIG.LOG_SHEET_NAME` | +| Statusbezeichnungen | `apps-script/Code.gs` → `STATUS` und gleichlautend in `web/index.html` | +| Zugangsdaten am Gerät vorbelegen | `web/index.html` → `DEFAULTS` (nicht committen, wenn echte Werte drinstehen) | +| Bildgröße, Upload-Limit, Port | `server/.env` (Vorlage: `server/.env.example`) | + +## Statusregeln + +Jeder Eintrag hat drei Prüfpunkte: **Audio**, **Bild**, **Sprache** – jeweils `JA`, `NEIN` oder `n.A.` + +| Eingabe | Ergebnis | +|---|---| +| alle bewerteten Punkte `JA` | **Funktionsfähig** | +| teilweise `NEIN` | **Teilweise funktionsfähig** | +| alle bewerteten Punkte `NEIN` | **Fehlerhaft** | +| alle drei `n.A.` | **Deaktiviert** | +| nichts erfasst | **Nicht geprüft** | + +Ein einzelnes `n.A.` schließt nur diesen Punkt aus der Bewertung aus und zählt nie als Fehler. Beispiel: `n.A. / NEIN / NEIN` → **Fehlerhaft** (die zwei bewerteten Punkte sind beide nein), `n.A. / JA / NEIN` → **Teilweise funktionsfähig**. + +Die Regeln stehen identisch in `Code.gs` (`berechneStatus`) und in `index.html` – das Sheet bleibt die maßgebliche Quelle, das Frontend zeigt die Vorschau sofort an. + +--- + +## Teil 1 – Google Tabelle einrichten + +1. Neue Tabelle auf [sheets.new](https://sheets.new) anlegen, z. B. „Monitore Veranstaltung 2026". +2. **Erweiterungen → Apps Script**. Den Standardinhalt von `Code.gs` löschen und den Inhalt aus `apps-script/Code.gs` einfügen. +3. Ganz oben `CONFIG.API_TOKEN` durch ein langes Zufallstoken ersetzen. Erzeugen z. B. mit: + ```bash + openssl rand -hex 24 + ``` + Dieses Token brauchst du gleich noch zweimal (Server und Oberfläche). +4. Speichern. Im Editor die Funktion `testStatusLogik` auswählen und **Ausführen** – beim ersten Mal Zugriff freigeben. Im Ausführungsprotokoll muss „Alle 9 Testfälle bestanden" stehen. +5. Zurück in der Tabelle: Seite neu laden, dann Menü **Monitor-Check → Tabelle einrichten**. Damit entstehen die Blätter `Monitore` und `Protokoll` samt Spaltenbreiten und Dropdowns. +6. Im Apps-Script-Editor: **Bereitstellen → Neue Bereitstellung → Typ: Web-App** + - Beschreibung: `Monitor-Check API` + - **Ausführen als: Ich** + - **Zugriff: Jeder** ← zwingend, sonst antwortet die API mit einer HTML-Anmeldeseite + - Bereitstellen, Berechtigungen bestätigen, die `…/exec`-URL kopieren. + +> Nach jeder Änderung an `Code.gs`: **Bereitstellen → Bereitstellungen verwalten → Bearbeiten → Version: Neu → Bereitstellen**. Die URL bleibt dabei gleich. + +--- + +## Teil 2 – Server aufsetzen + +**Der schnelle Weg:** Projektordner auf den Server kopieren und `install.sh` laufen lassen – das erledigt alles ab Abschnitt 2.2. + +```bash +# auf deinem Rechner (IP anpassen) +scp -r . root@192.168.1.50:/root/monitor-check-quelle + +# auf dem Server +cd /root/monitor-check-quelle +sudo bash install.sh +``` + +Das Skript fragt nach Domain und E-Mail, erzeugt ein API-Token, installiert Node 20, nginx, den Dienst und holt das Let's-Encrypt-Zertifikat. Am Ende stehen alle Zugangsdaten auf dem Bildschirm und in `/opt/monitor-check/ZUGANGSDATEN.txt`. + +Ohne Rückfragen: + +```bash +sudo bash install.sh --domain monitore.deine-domain.de --email du@deine-domain.de --yes +``` + +Nützliche Schalter: `--dry-run` zeigt nur, was passieren würde. `--update` spielt nach Änderungen an `web/` oder `server/` die neuen Dateien ein und startet den Dienst neu. `--no-tls` und `--no-firewall` überspringen Zertifikat bzw. ufw. `--help` listet alles auf. + +### Hinter einem vorgeschalteten Reverse-Proxy + +Wenn TLS bereits auf einem vorgelagerten nginx liegt, übernimmt der Container nur HTTP: + +```bash +sudo bash install.sh --behind-proxy --public-url https://monitore.deine-domain.de --yes +``` + +Das überspringt certbot, lässt den Container auf Port 80 lauschen und setzt `PUBLIC_URL` trotzdem auf **https** – wichtig, denn diese Adresse landet als Bild-Link in der Tabelle. Bei `http://` blockiert der Browser die Bilder später als Mixed Content. + +Am Ende schreibt das Skript eine passende Vorlage nach `/opt/monitor-check/PROXY-SNIPPET.conf`, die du auf dem vorgelagerten Proxy einsetzen kannst. Ein Punkt daraus entscheidet über Erfolg oder Frust: + +```nginx +client_max_body_size 20m; +``` + +Fehlt die Zeile im **äußeren** Proxy, brechen Foto-Uploads mit `413 Request Entity Too Large` ab – der Container selbst ist dann gar nicht schuld. + +Der lokale nginx im Container darf auch ganz entfallen; dann hört Node direkt auf `0.0.0.0:8080` und der Proxy zielt dorthin: + +```bash +sudo bash install.sh --behind-proxy --no-nginx --public-url https://monitore.deine-domain.de --yes +``` + +Der Unterschied ist klein: Mit lokalem nginx werden die Fotos direkt von der Platte ausgeliefert statt durch Node. Bei ein paar hundert Bildern spielt das keine Rolle – wähle, was besser in deine Landschaft passt. + +Läuft das Skript durch, geht es direkt bei **Teil 3** weiter. Die Abschnitte 2.2 bis 2.7 beschreiben dieselben Schritte von Hand – nützlich zum Nachvollziehen oder Reparieren. + +### 2.1 Container erstellen (auf dem Proxmox-Host) + +Getestet mit Ubuntu 22.04/24.04 in einem unprivilegierten LXC-Container (Proxmox). Alle Befehle als `root` im Container. + +```bash +pct create 120 local:vztmpl/ubuntu-24.04-standard_24.04-2_amd64.tar.zst \ + --hostname monitor-check \ + --cores 2 --memory 1024 --swap 512 \ + --rootfs local-lvm:8 \ + --net0 name=eth0,bridge=vmbr0,ip=dhcp \ + --features nesting=1 \ + --unprivileged 1 \ + --onboot 1 +pct start 120 +pct enter 120 +``` + +Ein bestehender Container tut es genauso – dann direkt bei 2.2 weiter. + +### 2.2 Grundpakete + +```bash +apt update && apt -y upgrade +apt -y install curl git nginx ca-certificates + +# Node.js 20 LTS +curl -fsSL https://deb.nodesource.com/setup_20.x | bash - +apt -y install nodejs +node -v # sollte v20.x zeigen +``` + +### 2.3 Benutzer und Verzeichnisse + +```bash +adduser --system --group --home /opt/monitor-check monitor +mkdir -p /opt/monitor-check /var/lib/monitor-check/uploads +``` + +### 2.4 Dateien einspielen + +Ordner `server/` und `web/` aus diesem Projekt nach `/opt/monitor-check/` kopieren – z. B. vom Arbeitsrechner aus: + +```bash +# auf deinem Rechner, IP anpassen +scp -r server web root@192.168.1.50:/opt/monitor-check/ +``` + +Dann im Container: + +```bash +cd /opt/monitor-check/server +npm install --omit=dev +``` + +`sharp` bringt fertige Binärpakete mit; sollte der Build doch starten, hilft `apt -y install build-essential python3`. + +### 2.5 Konfiguration + +```bash +cd /opt/monitor-check/server +cp .env.example .env +nano .env +``` + +Anzupassen: + +```ini +PORT=8080 +HOST=127.0.0.1 +API_TOKEN= +PUBLIC_URL=https://monitore.deine-domain.de +UPLOAD_DIR=/var/lib/monitor-check/uploads +WEB_DIR=/opt/monitor-check/web +ALLOWED_ORIGINS=https://monitore.deine-domain.de +``` + +`PUBLIC_URL` ist wichtig: genau diese Adresse landet als Bild-Link in der Tabelle. + +```bash +chown -R monitor:monitor /opt/monitor-check /var/lib/monitor-check +chmod 600 /opt/monitor-check/server/.env +``` + +### 2.6 Als Dienst starten + +```bash +cp /opt/monitor-check/server/monitor-check.service /etc/systemd/system/ +systemctl daemon-reload +systemctl enable --now monitor-check +systemctl status monitor-check --no-pager +curl -s localhost:8080/gesundheit # {"ok":true,…} +``` + +Logs mitlesen: `journalctl -u monitor-check -f` + +### 2.7 nginx und HTTPS + +```bash +cp /opt/monitor-check/server/nginx-monitor-check.conf /etc/nginx/sites-available/monitor-check +sed -i 's/monitore.example.org/monitore.deine-domain.de/' /etc/nginx/sites-available/monitor-check +ln -sf ../sites-available/monitor-check /etc/nginx/sites-enabled/ +rm -f /etc/nginx/sites-enabled/default +nginx -t && systemctl reload nginx + +# Zertifikat (DNS muss vorher auf den Server zeigen) +apt -y install certbot python3-certbot-nginx +certbot --nginx -d monitore.deine-domain.de +``` + +**HTTPS ist Pflicht.** Ohne TLS gibt die Handy-Kamera im Browser kein Bild frei und `fetch` zu Google wird von der Seite aus blockiert. + +Optional Firewall: + +```bash +apt -y install ufw +ufw allow 22,80,443/tcp && ufw --force enable +``` + +--- + +## Teil 3 – Oberfläche verbinden + +`https://monitore.deine-domain.de` am Handy öffnen. Beim ersten Start landest du im Tab **Setup**: + +| Feld | Wert | +|---|---| +| Apps-Script Web-App URL | die `…/exec`-URL aus Schritt 1.6 | +| API-Token | dasselbe Token wie in `Code.gs` und `.env` | +| Upload-Endpoint | `https://monitore.deine-domain.de/api/upload` | +| Dein Name | wird je Prüfung als „Prüfer" gespeichert | + +**Speichern**, dann **Verbindung testen** – die Meldung „Verbindung ok" bestätigt Schritt 1 und 2. + +Die Werte liegen im `localStorage` des Browsers, also pro Gerät einmal eintragen. Willst du das vorbelegen, trage sie in `web/index.html` oben im Block `DEFAULTS` ein und lade die Datei neu auf den Server. + +Auf dem iPhone: Teilen → **Zum Home-Bildschirm**. Danach startet das Tool wie eine App im Vollbild. + +--- + +## Bedienung + +**Erfassen** – Bezeichnung eingeben, optional Standort, aufs Fotofeld tippen (öffnet direkt die Kamera). Der Status lässt sich sofort mitsetzen oder später nachtragen. Die ID vergibt das Skript automatisch: `MON-001`, `MON-002`, … + +**Übersicht** – Kacheln zeigen die Verteilung, darunter Suche und Statusfilter. Ein Tippen auf einen Eintrag öffnet die Detailansicht mit den drei Schaltern. Erneutes Tippen auf einen bereits gewählten Wert hebt die Auswahl wieder auf. + +**Löschen** – In der Detailansicht ganz unten „Eintrag löschen", nach Rückfrage verschwindet die Zeile aus der Tabelle. Alternativ die Zeile direkt im Sheet löschen. Vergebene IDs werden dabei nie erneut vergeben, auch nicht die des zuletzt angelegten Eintrags – der Zähler steht in den Skript-Eigenschaften. Das zugehörige Foto bleibt auf dem Server liegen; siehe „Wartung" zum Aufräumen. + +**Offline** – Bei Verbindungsabbruch wandern Neuanlagen und Statusmeldungen in eine lokale Warteschlange (Anzeige oben rechts) und gehen automatisch raus, sobald das Netz zurück ist. Manuell auslösen im Setup-Tab. Fotos brauchen dagegen eine Verbindung – ohne Netz den Eintrag ohne Bild anlegen und das Foto später über die Tabelle nachtragen. + +--- + +## Tabellenstruktur + +Blatt `Monitore`: + +| Spalte | Inhalt | +|---|---| +| A ID | `MON-001` fortlaufend | +| B Bezeichnung | Freitext | +| C Standort | Freitext | +| D Bild-URL | Link auf dein `/uploads/…` | +| E Audio / F Bild / G Sprache | `JA` / `NEIN` / `n.A.` | +| H Status | automatisch berechnet, Zeile wird eingefärbt | +| I Notiz | Freitext | +| J Erfasst am, K Geprüft am, L Geprüft von | Zeitstempel und Person | + +Blatt `Protokoll` schreibt jede Änderung mit – Zeitpunkt, ID, Aktion, Status, Person. + +Direkt in der Tabelle bearbeitete Werte werden nicht automatisch neu bewertet. Dafür gibt es **Monitor-Check → Alle Status neu berechnen**. + +Bilder als Vorschau in der Tabelle: eine leere Spalte ergänzen und dort +`=BILD(D2)` eintragen. + +--- + +## Wartung + +```bash +# Dienst neu starten (nach Änderungen an server.js oder .env) +systemctl restart monitor-check + +# Oberfläche und Server aktualisieren – neue Dateien hochladen, dann +sudo bash install.sh --update +# Browser-Cache: Seite mit gedrücktem Reload neu laden + +# Platzbedarf der Fotos +du -sh /var/lib/monitor-check/uploads + +# Sicherung der Bilder +tar czf uploads-$(date +%F).tar.gz -C /var/lib/monitor-check uploads +``` + +Verwaiste Fotos gelöschter Einträge aufräumen: Spalte D des Sheets als CSV exportieren, auf den Server legen und abgleichen. + +```bash +# behaelt nur Dateien, die noch in der Tabelle verlinkt sind +cd /var/lib/monitor-check/uploads +grep -o '[^/]*\.jpg' /root/bild-urls.csv | sort -u > /tmp/benutzt.txt +ls *.jpg | sort > /tmp/vorhanden.txt +comm -23 /tmp/vorhanden.txt /tmp/benutzt.txt # erst anschauen +comm -23 /tmp/vorhanden.txt /tmp/benutzt.txt | xargs -r rm -- # dann loeschen +``` + +Bilder werden auf max. 1600 px verkleinert und als JPEG (Qualität 78) gespeichert – rund 150–400 kB pro Foto. 1000 Einträge belegen also etwa 300 MB. Anpassbar über `MAX_IMAGE_PX` und `JPEG_QUALITY` in der `.env`. + +## Fehlersuche + +| Symptom | Ursache | +|---|---| +| „Unerwartete Antwort – Web-App-Zugriff auf ‚Jeder' gesetzt?" | Bereitstellung steht auf „Nur ich". In den Bereitstellungen auf **Jeder** ändern. | +| „Nicht autorisiert" | Token stimmt nicht überein. Muss in `Code.gs`, `.env` und im Setup-Tab identisch sein. | +| Änderungen an `Code.gs` wirken nicht | Neue **Version** bereitstellen, nicht nur speichern. | +| Kamera öffnet nicht | Seite läuft über `http` statt `https`. | +| Upload bricht bei großen Fotos ab | `client_max_body_size` in **jedem** nginx davor und `MAX_UPLOAD_MB` in der `.env` erhöhen. | +| Bilder erscheinen nicht, Konsole meldet Mixed Content | `PUBLIC_URL` steht auf `http://`, die Seite läuft über `https://`. In der `.env` korrigieren, Dienst neu starten. Bereits gespeicherte Links im Sheet per Suchen-und-Ersetzen anpassen. | +| Bild-Link im Sheet zeigt `127.0.0.1` | `PUBLIC_URL` in der `.env` setzen und Dienst neu starten. | +| Nichts geht mehr | `journalctl -u monitor-check -n 50 --no-pager` | + +## Sicherheit + +Das Token schützt die Schnittstelle gegen zufällige Zugriffe, ist aber kein Login – wer die URL und das Token hat, kann schreiben. Für eine interne Veranstaltung reicht das. Soll es enger sein, lege in nginx zusätzlich eine Basic-Auth über `location /` (nicht über `/api/upload`, sonst scheitert der Upload aus der Seite heraus) oder beschränke den Vhost auf das Veranstaltungs-Netz per `allow`/`deny`. + +--- + +## Dateien + +| Datei | Zweck | +|---|---| +| `install.sh` | richtet den Server komplett ein (Ubuntu 22.04/24.04) | +| `apps-script/Code.gs` | Backend an der Google Tabelle, JSON-API, Statusberechnung | +| `web/index.html` | die komplette Oberfläche in einer Datei | +| `server/server.js` | Foto-Upload und Auslieferung der Oberfläche | +| `server/.env.example` | Konfigurationsvorlage, wird zur `.env` (nicht im Repo) | +| `server/monitor-check.service` | systemd-Unit | +| `server/nginx-monitor-check.conf` | nginx-Vhost mit TLS-Vorbereitung | diff --git a/apps-script/Code.gs b/apps-script/Code.gs new file mode 100644 index 0000000..70b9f32 --- /dev/null +++ b/apps-script/Code.gs @@ -0,0 +1,452 @@ +/** + * Monitor-Check – Google Apps Script Backend + * ------------------------------------------ + * Stellt eine JSON-API fuer das Web-Tool bereit. + * Deployment: Bereitstellen > Neue Bereitstellung > Web-App + * Ausfuehren als: Ich + * Zugriff: Jeder + * + * WICHTIG: API_TOKEN unten aendern und identisch im Frontend eintragen. + */ + +var CONFIG = { + SHEET_NAME: 'Monitore', + LOG_SHEET_NAME: 'Protokoll', + API_TOKEN: 'BITTE-AENDERN-langes-zufaelliges-token', + ID_PREFIX: 'MON', + ID_PAD: 3 +}; + +var HEADERS = [ + 'ID', + 'Bezeichnung', + 'Standort', + 'Bild-URL', + 'Audio', + 'Bild', + 'Sprache', + 'Status', + 'Notiz', + 'Erfasst am', + 'Geprueft am', + 'Geprueft von' +]; + +var COL = { + ID: 0, BEZEICHNUNG: 1, STANDORT: 2, BILD_URL: 3, + AUDIO: 4, BILD: 5, SPRACHE: 6, STATUS: 7, + NOTIZ: 8, ERFASST: 9, GEPRUEFT: 10, PRUEFER: 11 +}; + +/* ------------------------------------------------------------------ */ +/* Status-Logik */ +/* ------------------------------------------------------------------ */ + +var STATUS = { + OK: 'Funktionsfähig', + TEIL: 'Teilweise funktionsfähig', + FEHLER: 'Fehlerhaft', + AUS: 'Deaktiviert', + OFFEN: 'Nicht geprüft' +}; + +/** + * Regeln (audio/bild/sprache jeweils 'JA' | 'NEIN' | 'n.A.' | '') + * - Felder mit 'n.A.' werden aus der Bewertung ausgeschlossen. + * - Alle drei 'n.A.' -> Deaktiviert + * - Kein Feld gesetzt -> Nicht geprueft + * - Alle bewerteten Felder JA -> Funktionsfaehig + * - Alle bewerteten Felder NEIN -> Fehlerhaft + * - Gemischt -> Teilweise funktionsfaehig + */ +function berechneStatus(audio, bild, sprache) { + var werte = [norm_(audio), norm_(bild), norm_(sprache)]; + + var gesetzt = werte.filter(function (w) { return w !== ''; }); + if (gesetzt.length === 0) return STATUS.OFFEN; + + var na = werte.filter(function (w) { return w === 'n.A.'; }).length; + if (na === 3) return STATUS.AUS; + + var bewertet = werte.filter(function (w) { return w === 'JA' || w === 'NEIN'; }); + if (bewertet.length === 0) return STATUS.AUS; + + var ja = bewertet.filter(function (w) { return w === 'JA'; }).length; + var nein = bewertet.filter(function (w) { return w === 'NEIN'; }).length; + + if (nein === 0) return STATUS.OK; + if (ja === 0) return STATUS.FEHLER; + return STATUS.TEIL; +} + +/** + * Vereinheitlicht Statustexte. Aeltere Zeilen wurden ohne Umlaute geschrieben + * ("Funktionsfaehig"); beide Schreibweisen ergeben denselben Status. + */ +function statusKanon_(v) { + var k = String(v == null ? '' : v).toLowerCase().trim() + .replace(/ä/g, 'ae').replace(/ö/g, 'oe').replace(/ü/g, 'ue').replace(/ß/g, 'ss'); + if (k === 'funktionsfaehig') return STATUS.OK; + if (k === 'teilweise funktionsfaehig') return STATUS.TEIL; + if (k === 'fehlerhaft') return STATUS.FEHLER; + if (k === 'deaktiviert') return STATUS.AUS; + return STATUS.OFFEN; +} + +function norm_(v) { + if (v === null || v === undefined) return ''; + var s = String(v).trim().toUpperCase(); + if (s === '') return ''; + if (s === 'JA' || s === 'J' || s === 'YES' || s === 'TRUE') return 'JA'; + if (s === 'NEIN' || s === 'N' || s === 'NO' || s === 'FALSE') return 'NEIN'; + if (s === 'N.A.' || s === 'NA' || s === 'N/A' || s === 'N.A') return 'n.A.'; + return ''; +} + +/* ------------------------------------------------------------------ */ +/* HTTP-Einstieg */ +/* ------------------------------------------------------------------ */ + +function doGet(e) { + return handle_(e, (e && e.parameter) ? e.parameter : {}); +} + +function doPost(e) { + var payload = {}; + try { + if (e && e.postData && e.postData.contents) { + payload = JSON.parse(e.postData.contents); + } + } catch (err) { + return json_({ ok: false, error: 'Ungueltiges JSON' }); + } + return handle_(e, payload); +} + +function handle_(e, p) { + try { + if (String(p.token || '') !== CONFIG.API_TOKEN) { + return json_({ ok: false, error: 'Nicht autorisiert' }); + } + switch (String(p.action || '')) { + case 'ping': return json_({ ok: true, version: 1, time: new Date().toISOString() }); + case 'list': return json_({ ok: true, items: listeEintraege_() }); + case 'create': return json_(erstelleEintrag_(p)); + case 'status': return json_(setzeStatus_(p)); + case 'update': return json_(aktualisiereEintrag_(p)); + case 'archive': return json_(archiviereEintrag_(p)); + case 'delete': return json_(loescheEintrag_(p)); + default: return json_({ ok: false, error: 'Unbekannte Aktion' }); + } + } catch (err) { + return json_({ ok: false, error: String(err && err.message ? err.message : err) }); + } +} + +function json_(obj) { + return ContentService + .createTextOutput(JSON.stringify(obj)) + .setMimeType(ContentService.MimeType.JSON); +} + +/* ------------------------------------------------------------------ */ +/* Sheet-Zugriff */ +/* ------------------------------------------------------------------ */ + +function sheet_() { + var ss = SpreadsheetApp.getActiveSpreadsheet(); + var sh = ss.getSheetByName(CONFIG.SHEET_NAME); + if (!sh) { + sh = ss.insertSheet(CONFIG.SHEET_NAME); + } + if (sh.getLastRow() === 0 || String(sh.getRange(1, 1).getValue()).trim() === '') { + sh.getRange(1, 1, 1, HEADERS.length).setValues([HEADERS]); + sh.getRange(1, 1, 1, HEADERS.length).setFontWeight('bold'); + sh.setFrozenRows(1); + } + return sh; +} + +function daten_() { + var sh = sheet_(); + var last = sh.getLastRow(); + if (last < 2) return []; + return sh.getRange(2, 1, last - 1, HEADERS.length).getValues(); +} + +function zeileFuerId_(id) { + var rows = daten_(); + for (var i = 0; i < rows.length; i++) { + if (String(rows[i][COL.ID]).trim() === String(id).trim()) { + return { row: i + 2, values: rows[i] }; + } + } + return null; +} + +function listeEintraege_() { + return daten_() + .filter(function (r) { return String(r[COL.ID]).trim() !== ''; }) + .map(function (r) { + return { + id: String(r[COL.ID]), + bezeichnung: String(r[COL.BEZEICHNUNG] || ''), + standort: String(r[COL.STANDORT] || ''), + bildUrl: String(r[COL.BILD_URL] || ''), + audio: norm_(r[COL.AUDIO]), + bild: norm_(r[COL.BILD]), + sprache: norm_(r[COL.SPRACHE]), + status: statusKanon_(r[COL.STATUS]), + notiz: String(r[COL.NOTIZ] || ''), + erfasstAm: iso_(r[COL.ERFASST]), + geprueftAm: iso_(r[COL.GEPRUEFT]), + pruefer: String(r[COL.PRUEFER] || '') + }; + }); +} + +function iso_(v) { + if (!v) return ''; + if (v instanceof Date) return v.toISOString(); + return String(v); +} + +/* ------------------------------------------------------------------ */ +/* Aktionen */ +/* ------------------------------------------------------------------ */ + +function erstelleEintrag_(p) { + var bez = String(p.bezeichnung || '').trim(); + if (!bez) return { ok: false, error: 'Bezeichnung fehlt' }; + + var lock = LockService.getScriptLock(); + lock.waitLock(20000); + try { + var sh = sheet_(); + var id = naechsteId_(sh); + var jetzt = new Date(); + + var audio = norm_(p.audio); + var bild = norm_(p.bild); + var sprache = norm_(p.sprache); + var status = berechneStatus(audio, bild, sprache); + var geprueft = status === STATUS.OFFEN ? '' : jetzt; + + var zeile = []; + zeile[COL.ID] = id; + zeile[COL.BEZEICHNUNG] = bez; + zeile[COL.STANDORT] = String(p.standort || '').trim(); + zeile[COL.BILD_URL] = String(p.bildUrl || '').trim(); + zeile[COL.AUDIO] = audio; + zeile[COL.BILD] = bild; + zeile[COL.SPRACHE] = sprache; + zeile[COL.STATUS] = status; + zeile[COL.NOTIZ] = String(p.notiz || '').trim(); + zeile[COL.ERFASST] = jetzt; + zeile[COL.GEPRUEFT] = geprueft; + zeile[COL.PRUEFER] = String(p.pruefer || '').trim(); + + sh.appendRow(zeile); + faerbeZeile_(sh, sh.getLastRow(), status); + protokolliere_(id, 'angelegt', status, p.pruefer); + + return { ok: true, id: id, status: status }; + } finally { + lock.releaseLock(); + } +} + +/** + * Naechste freie ID. Der hoechste je vergebene Zaehler wird zusaetzlich in den + * Skript-Eigenschaften gemerkt, damit IDs geloeschter Eintraege nie erneut + * vergeben werden. + */ +function naechsteId_(sh) { + var props = PropertiesService.getScriptProperties(); + var max = parseInt(props.getProperty('lastId') || '0', 10); + if (isNaN(max)) max = 0; + + var rows = daten_(); + var re = new RegExp('^' + CONFIG.ID_PREFIX + '-(\\d+)$'); + for (var i = 0; i < rows.length; i++) { + var m = re.exec(String(rows[i][COL.ID]).trim()); + if (m) { + var n = parseInt(m[1], 10); + if (n > max) max = n; + } + } + + var nummer = max + 1; + props.setProperty('lastId', String(nummer)); + + var next = String(nummer); + while (next.length < CONFIG.ID_PAD) next = '0' + next; + return CONFIG.ID_PREFIX + '-' + next; +} + +function setzeStatus_(p) { + var treffer = zeileFuerId_(p.id); + if (!treffer) return { ok: false, error: 'ID nicht gefunden: ' + p.id }; + + var sh = sheet_(); + var audio = norm_(p.audio); + var bild = norm_(p.bild); + var sprache = norm_(p.sprache); + var status = berechneStatus(audio, bild, sprache); + var jetzt = new Date(); + + sh.getRange(treffer.row, COL.AUDIO + 1, 1, 4) + .setValues([[audio, bild, sprache, status]]); + if (p.notiz !== undefined) { + sh.getRange(treffer.row, COL.NOTIZ + 1).setValue(String(p.notiz || '')); + } + sh.getRange(treffer.row, COL.GEPRUEFT + 1, 1, 2) + .setValues([[jetzt, String(p.pruefer || '')]]); + + faerbeZeile_(sh, treffer.row, status); + protokolliere_(p.id, 'Status gesetzt', status, p.pruefer); + + return { ok: true, id: String(p.id), status: status, geprueftAm: jetzt.toISOString() }; +} + +function aktualisiereEintrag_(p) { + var treffer = zeileFuerId_(p.id); + if (!treffer) return { ok: false, error: 'ID nicht gefunden: ' + p.id }; + var sh = sheet_(); + + if (p.bezeichnung !== undefined) sh.getRange(treffer.row, COL.BEZEICHNUNG + 1).setValue(String(p.bezeichnung)); + if (p.standort !== undefined) sh.getRange(treffer.row, COL.STANDORT + 1).setValue(String(p.standort)); + if (p.bildUrl !== undefined) sh.getRange(treffer.row, COL.BILD_URL + 1).setValue(String(p.bildUrl)); + if (p.notiz !== undefined) sh.getRange(treffer.row, COL.NOTIZ + 1).setValue(String(p.notiz)); + + protokolliere_(p.id, 'bearbeitet', '', p.pruefer); + return { ok: true, id: String(p.id) }; +} + +function archiviereEintrag_(p) { + var treffer = zeileFuerId_(p.id); + if (!treffer) return { ok: false, error: 'ID nicht gefunden: ' + p.id }; + var sh = sheet_(); + sh.getRange(treffer.row, COL.AUDIO + 1, 1, 4).setValues([['n.A.', 'n.A.', 'n.A.', STATUS.AUS]]); + faerbeZeile_(sh, treffer.row, STATUS.AUS); + protokolliere_(p.id, 'deaktiviert', STATUS.AUS, p.pruefer); + return { ok: true, id: String(p.id), status: STATUS.AUS }; +} + +function loescheEintrag_(p) { + var treffer = zeileFuerId_(p.id); + if (!treffer) return { ok: false, error: 'ID nicht gefunden: ' + p.id }; + + var lock = LockService.getScriptLock(); + lock.waitLock(20000); + try { + // Zeile koennte sich zwischenzeitlich verschoben haben – erneut suchen. + treffer = zeileFuerId_(p.id); + if (!treffer) return { ok: false, error: 'ID nicht gefunden: ' + p.id }; + + var bez = String(treffer.values[COL.BEZEICHNUNG] || ''); + var bild = String(treffer.values[COL.BILD_URL] || ''); + sheet_().deleteRow(treffer.row); + protokolliere_(p.id, 'geloescht (' + bez + ')', bild, p.pruefer); + return { ok: true, id: String(p.id) }; + } finally { + lock.releaseLock(); + } +} + +/* ------------------------------------------------------------------ */ +/* Darstellung + Protokoll */ +/* ------------------------------------------------------------------ */ + +function faerbeZeile_(sh, row, status) { + var farben = {}; + farben[STATUS.OK] = '#e6f4ea'; + farben[STATUS.TEIL] = '#fef7e0'; + farben[STATUS.FEHLER] = '#fce8e6'; + farben[STATUS.AUS] = '#f1f3f4'; + farben[STATUS.OFFEN] = '#ffffff'; + sh.getRange(row, 1, 1, HEADERS.length).setBackground(farben[status] || '#ffffff'); +} + +function protokolliere_(id, aktion, status, pruefer) { + try { + var ss = SpreadsheetApp.getActiveSpreadsheet(); + var log = ss.getSheetByName(CONFIG.LOG_SHEET_NAME); + if (!log) { + log = ss.insertSheet(CONFIG.LOG_SHEET_NAME); + log.appendRow(['Zeitpunkt', 'ID', 'Aktion', 'Status', 'Person']); + log.getRange(1, 1, 1, 5).setFontWeight('bold'); + log.setFrozenRows(1); + } + log.appendRow([new Date(), String(id || ''), String(aktion || ''), String(status || ''), String(pruefer || '')]); + } catch (err) { + // Protokoll ist optional – Fehler hier duerfen die Aktion nicht abbrechen. + } +} + +/* ------------------------------------------------------------------ */ +/* Einmalige Einrichtung / Menue */ +/* ------------------------------------------------------------------ */ + +function onOpen() { + SpreadsheetApp.getUi() + .createMenu('Monitor-Check') + .addItem('Tabelle einrichten', 'einrichten') + .addItem('Alle Status neu berechnen', 'statusNeuBerechnen') + .addToUi(); +} + +function einrichten() { + var sh = sheet_(); + sh.setColumnWidth(COL.ID + 1, 90); + sh.setColumnWidth(COL.BEZEICHNUNG + 1, 200); + sh.setColumnWidth(COL.STANDORT + 1, 160); + sh.setColumnWidth(COL.BILD_URL + 1, 260); + sh.setColumnWidth(COL.STATUS + 1, 190); + sh.setColumnWidth(COL.NOTIZ + 1, 260); + + var jn = SpreadsheetApp.newDataValidation() + .requireValueInList(['JA', 'NEIN', 'n.A.'], true) + .setAllowInvalid(false) + .build(); + sh.getRange(2, COL.AUDIO + 1, 1000, 3).setDataValidation(jn); + + protokolliere_('', 'Tabelle eingerichtet', '', Session.getActiveUser().getEmail()); + SpreadsheetApp.getUi().alert('Tabelle "' + CONFIG.SHEET_NAME + '" ist eingerichtet.'); +} + +function statusNeuBerechnen() { + var sh = sheet_(); + var rows = daten_(); + for (var i = 0; i < rows.length; i++) { + if (String(rows[i][COL.ID]).trim() === '') continue; + var s = berechneStatus(rows[i][COL.AUDIO], rows[i][COL.BILD], rows[i][COL.SPRACHE]); + sh.getRange(i + 2, COL.STATUS + 1).setValue(s); + faerbeZeile_(sh, i + 2, s); + } +} + +/* ------------------------------------------------------------------ */ +/* Selbsttest (im Editor ausfuehrbar) */ +/* ------------------------------------------------------------------ */ + +function testStatusLogik() { + var faelle = [ + [['JA', 'JA', 'JA'], STATUS.OK], + [['NEIN', 'NEIN', 'NEIN'], STATUS.FEHLER], + [['JA', 'NEIN', 'JA'], STATUS.TEIL], + [['JA', 'NEIN', 'NEIN'], STATUS.TEIL], + [['n.A.', 'n.A.', 'n.A.'], STATUS.AUS], + [['n.A.', 'JA', 'JA'], STATUS.OK], + [['n.A.', 'NEIN', 'NEIN'], STATUS.FEHLER], + [['n.A.', 'JA', 'NEIN'], STATUS.TEIL], + [['', '', ''], STATUS.OFFEN] + ]; + var fehler = []; + faelle.forEach(function (f) { + var ist = berechneStatus(f[0][0], f[0][1], f[0][2]); + if (ist !== f[1]) fehler.push(f[0].join('/') + ' -> ' + ist + ' (erwartet ' + f[1] + ')'); + }); + if (fehler.length) throw new Error('Fehlgeschlagen:\n' + fehler.join('\n')); + Logger.log('Alle ' + faelle.length + ' Testfaelle bestanden.'); +} diff --git a/install.sh b/install.sh new file mode 100644 index 0000000..24f2e91 --- /dev/null +++ b/install.sh @@ -0,0 +1,519 @@ +#!/usr/bin/env bash +# +# Monitor-Check – Installationsskript fuer Ubuntu 24.04 (funktioniert auch auf 22.04) +# +# sudo bash install.sh # gefuehrte Installation +# sudo bash install.sh --domain m.example.de --email ich@example.de --yes +# sudo bash install.sh --update # nur Dateien aktualisieren + Neustart +# sudo bash install.sh --dry-run # nur zeigen, was passieren wuerde +# +# Das Skript erwartet die Ordner server/ und web/ neben sich. + +set -euo pipefail + +# ------------------------------------------------------------------ Konstanten +APP_NAME="monitor-check" +APP_USER="monitor" +APP_DIR="/opt/${APP_NAME}" +DATA_DIR="/var/lib/${APP_NAME}" +UPLOAD_DIR="${DATA_DIR}/uploads" +SERVICE_FILE="/etc/systemd/system/${APP_NAME}.service" +NGINX_FILE="/etc/nginx/sites-available/${APP_NAME}" +NODE_MAJOR=20 +SRC_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# ------------------------------------------------------------------ Parameter +DOMAIN="" +EMAIL="" +TOKEN="" +PORT="8080" +MAX_UPLOAD_MB="15" +ASSUME_YES=0 +DRY_RUN=0 +MODE="install" +USE_TLS=1 +USE_FIREWALL=1 +USE_NGINX=1 +PUBLIC_URL_OVERRIDE="" +BEHIND_PROXY=0 + +# ------------------------------------------------------------------ Ausgabe +if [[ -t 1 ]]; then + B="\033[1m"; DIM="\033[2m"; GRN="\033[32m"; YLW="\033[33m"; RED="\033[31m"; RST="\033[0m" +else + B=""; DIM=""; GRN=""; YLW=""; RED=""; RST="" +fi +schritt() { printf "\n${B}==> %s${RST}\n" "$*"; } +info() { printf " %s\n" "$*"; } +ok() { printf " ${GRN}✓${RST} %s\n" "$*"; } +warn() { printf " ${YLW}!${RST} %s\n" "$*"; } +fehler() { printf "\n${RED}Fehler:${RST} %s\n\n" "$*" >&2; exit 1; } + +run() { + if [[ $DRY_RUN -eq 1 ]]; then + printf " ${DIM}[dry-run] %s${RST}\n" "$*" + else + "$@" + fi +} + +# schreibt stdin nach $1 +# Im Testlauf: mit MC_DRY_OUT=/pfad werden die erzeugten Dateien dort abgelegt. +schreibe() { + local ziel="$1" + if [[ $DRY_RUN -eq 1 ]]; then + printf " ${DIM}[dry-run] schreibe %s${RST}\n" "$ziel" + if [[ -n "${MC_DRY_OUT:-}" ]]; then + mkdir -p "${MC_DRY_OUT}$(dirname "$ziel")" + cat > "${MC_DRY_OUT}${ziel}" + else + cat >/dev/null + fi + else + cat > "$ziel" + fi +} + +hilfe() { + sed -n '3,10p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//' + cat <<'EOF' + +Optionen: + --domain Domain, unter der das Tool erreichbar ist + --email E-Mail fuer Let's Encrypt + --token API-Token (sonst wird eins erzeugt) + --port interner Node-Port (Standard 8080) + --max-upload maximale Fotogroesse in MB (Standard 15) + --behind-proxy hinter vorgeschaltetem Reverse-Proxy (kein certbot), + zusammen mit --public-url verwenden + --public-url oeffentliche Adresse, z. B. https://monitore.example.de + (landet als Bild-Link in der Tabelle) + --no-nginx kein nginx im Container, Node direkt auf 0.0.0.0: + --no-tls kein Zertifikat anfordern + --no-firewall ufw nicht konfigurieren + --update nur Anwendungsdateien erneuern und Dienst neu starten + --yes keine Rueckfragen + --dry-run nichts veraendern, nur anzeigen + --help diese Hilfe +EOF + exit 0 +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --domain) DOMAIN="${2:-}"; shift 2 ;; + --email) EMAIL="${2:-}"; shift 2 ;; + --token) TOKEN="${2:-}"; shift 2 ;; + --port) PORT="${2:-}"; shift 2 ;; + --max-upload) MAX_UPLOAD_MB="${2:-}"; shift 2 ;; + --public-url) PUBLIC_URL_OVERRIDE="${2:-}"; shift 2 ;; + --behind-proxy) BEHIND_PROXY=1; USE_TLS=0; shift ;; + --no-nginx) USE_NGINX=0; shift ;; + --no-tls) USE_TLS=0; shift ;; + --no-firewall) USE_FIREWALL=0; shift ;; + --update) MODE="update"; shift ;; + --yes|-y) ASSUME_YES=1; shift ;; + --dry-run) DRY_RUN=1; shift ;; + --help|-h) hilfe ;; + *) fehler "Unbekannte Option: $1 (--help zeigt die Uebersicht)" ;; + esac +done + +# ------------------------------------------------------------------ Vorpruefung +[[ $EUID -eq 0 || $DRY_RUN -eq 1 ]] || fehler "Bitte als root ausfuehren: sudo bash install.sh" +[[ -d "${SRC_DIR}/server" && -d "${SRC_DIR}/web" ]] || \ + fehler "Ordner server/ und web/ nicht gefunden neben ${BASH_SOURCE[0]}" +[[ -f "${SRC_DIR}/server/server.js" ]] || fehler "server/server.js fehlt." +[[ -f "${SRC_DIR}/web/index.html" ]] || fehler "web/index.html fehlt." + +if [[ -r /etc/os-release ]]; then + . /etc/os-release + [[ "${ID:-}" == "ubuntu" || "${ID_LIKE:-}" == *debian* ]] || \ + warn "Nicht getestetes System (${PRETTY_NAME:-unbekannt}) – weiter auf eigenes Risiko." +fi + +printf "\n${B}Monitor-Check – Installation${RST}\n" +[[ $DRY_RUN -eq 1 ]] && printf "${YLW}Testlauf: es wird nichts veraendert.${RST}\n" || true + +# ------------------------------------------------------------------ Update-Modus +if [[ "$MODE" == "update" ]]; then + [[ -d "$APP_DIR" ]] || fehler "${APP_DIR} existiert nicht – zuerst normal installieren." + schritt "Anwendungsdateien erneuern" + run rsync -a --delete --exclude node_modules --exclude .env \ + "${SRC_DIR}/server/" "${APP_DIR}/server/" 2>/dev/null || \ + run cp -r "${SRC_DIR}/server/." "${APP_DIR}/server/" + run cp -r "${SRC_DIR}/web/." "${APP_DIR}/web/" + run chown -R "${APP_USER}:${APP_USER}" "$APP_DIR" + run bash -c "cd '${APP_DIR}/server' && npm install --omit=dev --no-audit --no-fund" + run systemctl restart "$APP_NAME" + ok "Aktualisiert. Status: systemctl status ${APP_NAME}" + exit 0 +fi + +# ------------------------------------------------------------------ Abfragen +frage() { # frage + local __var="$1" __text="$2" __default="${3:-}" __eingabe="" + local __aktuell="${!__var}" + if [[ -n "$__aktuell" ]]; then return; fi + if [[ $ASSUME_YES -eq 1 || ! -t 0 ]]; then + printf -v "$__var" '%s' "$__default"; return + fi + if [[ -n "$__default" ]]; then + read -r -p " ${__text} [${__default}]: " __eingabe || true + else + read -r -p " ${__text}: " __eingabe || true + fi + printf -v "$__var" '%s' "${__eingabe:-$__default}" +} + +schritt "Angaben" + +# Token aus bestehender Installation weiterverwenden +if [[ -z "$TOKEN" && -f "${APP_DIR}/server/.env" ]]; then + TOKEN="$(grep -E '^API_TOKEN=' "${APP_DIR}/server/.env" | cut -d= -f2- || true)" + [[ -n "$TOKEN" ]] && ok "Vorhandenes API-Token wird weiterverwendet." || true +fi +if [[ -z "$TOKEN" ]]; then + TOKEN="$(openssl rand -hex 24 2>/dev/null || head -c 24 /dev/urandom | od -An -tx1 | tr -d ' \n')" + ok "Neues API-Token erzeugt." +fi + +# Hinter einem vorgeschalteten Proxy uebernimmt dieser TLS und Zertifikat. +[[ $USE_NGINX -eq 0 ]] && USE_TLS=0 || true +[[ $BEHIND_PROXY -eq 1 ]] && USE_TLS=0 || true + +if [[ -z "$PUBLIC_URL_OVERRIDE" ]]; then + frage DOMAIN "Domain (leer lassen fuer Zugriff nur ueber IP)" "" +fi + +if [[ -n "$DOMAIN" && $USE_TLS -eq 1 ]]; then + frage EMAIL "E-Mail fuer Let's Encrypt (leer = kein Zertifikat)" "" + [[ -z "$EMAIL" ]] && USE_TLS=0 || true +else + USE_TLS=0 +fi + +if [[ -n "$PUBLIC_URL_OVERRIDE" ]]; then + PUBLIC_URL="${PUBLIC_URL_OVERRIDE%/}" + [[ "$PUBLIC_URL" =~ ^https?:// ]] || fehler "--public-url muss mit http:// oder https:// beginnen." +elif [[ -n "$DOMAIN" ]]; then + PUBLIC_URL="$([[ $USE_TLS -eq 1 ]] && echo "https://${DOMAIN}" || echo "http://${DOMAIN}")" +else + IP="$(hostname -I 2>/dev/null | awk '{print $1}')" + [[ -z "$IP" ]] && IP="127.0.0.1" || true + PUBLIC_URL="http://${IP}" + warn "Ohne Domain laeuft alles ueber http – die Handy-Kamera bleibt dann gesperrt." +fi + +# Ohne nginx im Container muss Node von aussen erreichbar sein. +if [[ $USE_NGINX -eq 1 ]]; then BIND_HOST="127.0.0.1"; else BIND_HOST="0.0.0.0"; fi + +# Hostname aus der oeffentlichen URL – fuer die Proxy-Vorlage +PUBLIC_HOST="${PUBLIC_URL#*://}"; PUBLIC_HOST="${PUBLIC_HOST%%/*}" + +CONTAINER_IP="$(hostname -I 2>/dev/null | awk '{print $1}')" +[[ -z "$CONTAINER_IP" ]] && CONTAINER_IP="" || true +UPSTREAM="$([[ $USE_NGINX -eq 1 ]] && echo "http://${CONTAINER_IP}:80" || echo "http://${CONTAINER_IP}:${PORT}")" + +cat <} + Oeffentliche URL: ${PUBLIC_URL} + TLS : $([[ $USE_TLS -eq 1 ]] && echo "Let's Encrypt im Container" || echo "nicht im Container (Proxy oder http)") + nginx lokal : $([[ $USE_NGINX -eq 1 ]] && echo "ja, Port 80" || echo "nein, Node direkt") + Node hoert auf: ${BIND_HOST}:${PORT} + Verzeichnisse : ${APP_DIR} , ${DATA_DIR} + API-Token : ${TOKEN} + +EOF + +if [[ $ASSUME_YES -eq 0 && $DRY_RUN -eq 0 && -t 0 ]]; then + read -r -p " Fortfahren? [J/n] " a || true + [[ "${a:-J}" =~ ^([JjYy]|)$ ]] || { echo " Abgebrochen."; exit 0; } +fi + +# ------------------------------------------------------------------ Pakete +schritt "Systempakete installieren" +export DEBIAN_FRONTEND=noninteractive +run apt-get update -qq +if [[ $USE_NGINX -eq 1 ]]; then + run apt-get install -y -qq curl ca-certificates gnupg nginx openssl rsync +else + run apt-get install -y -qq curl ca-certificates gnupg openssl rsync +fi +ok "Basispakete vorhanden." + +# Node.js +NODE_OK=0 +if command -v node >/dev/null 2>&1; then + NODE_VER="$(node -p 'process.versions.node.split(".")[0]' 2>/dev/null || echo 0)" + [[ "$NODE_VER" -ge 18 ]] && NODE_OK=1 && ok "Node.js $(node -v) bereits installiert." || true +fi +if [[ $NODE_OK -eq 0 ]]; then + info "Node.js ${NODE_MAJOR} LTS wird eingerichtet …" + run bash -c "curl -fsSL https://deb.nodesource.com/setup_${NODE_MAJOR}.x | bash -" + run apt-get install -y -qq nodejs + ok "Node.js installiert." +fi + +# ------------------------------------------------------------------ Benutzer +schritt "Benutzer und Verzeichnisse" +if id "$APP_USER" >/dev/null 2>&1; then + ok "Benutzer ${APP_USER} existiert." +else + run adduser --system --group --home "$APP_DIR" --no-create-home "$APP_USER" + ok "Benutzer ${APP_USER} angelegt." +fi +run mkdir -p "${APP_DIR}/server" "${APP_DIR}/web" "$UPLOAD_DIR" + +# ------------------------------------------------------------------ Dateien +schritt "Anwendung kopieren" +run rsync -a --exclude node_modules --exclude .env --exclude uploads \ + "${SRC_DIR}/server/" "${APP_DIR}/server/" +run rsync -a "${SRC_DIR}/web/" "${APP_DIR}/web/" +ok "Dateien liegen in ${APP_DIR}." + +schritt "Node-Abhaengigkeiten installieren" +info "Das dauert beim ersten Mal ein bis zwei Minuten (sharp)." +run bash -c "cd '${APP_DIR}/server' && npm install --omit=dev --no-audit --no-fund" +ok "Abhaengigkeiten installiert." + +# ------------------------------------------------------------------ Konfiguration +schritt "Konfiguration schreiben" +if [[ -f "${APP_DIR}/server/.env" && $DRY_RUN -eq 0 ]]; then + cp "${APP_DIR}/server/.env" "${APP_DIR}/server/.env.bak.$(date +%s)" + info "Bisherige .env gesichert." +fi +schreibe "${APP_DIR}/server/.env" </dev/null 2>&1; then + ok "Node-Dienst antwortet." + else + warn "Node-Dienst antwortet nicht – pruefen mit: journalctl -u ${APP_NAME} -n 40 --no-pager" + fi + if [[ $USE_NGINX -eq 1 ]]; then + if curl -fsS -o /dev/null "http://127.0.0.1/" 2>/dev/null; then + ok "nginx liefert die Oberflaeche aus." + else + warn "nginx antwortet lokal nicht wie erwartet." + fi + fi +fi + +# ------------------------------------------------------------------ Proxy-Vorlage +PROXY_DATEI="${APP_DIR}/PROXY-SNIPPET.conf" +if [[ $USE_TLS -eq 0 ]]; then + schritt "Vorlage fuer den vorgeschalteten Proxy" + schreibe "$PROXY_DATEI" < "$ZUSAMMENFASSUNG" < Apps Script, Inhalt von + apps-script/Code.gs einfuegen und CONFIG.API_TOKEN auf das Token oben setzen. + 2. Bereitstellen > Neue Bereitstellung > Web-App + Ausfuehren als: Ich | Zugriff: ${B}Jeder${RST} + Die .../exec-URL kopieren. + 3. ${PUBLIC_URL} am Handy oeffnen, Tab "Setup": + Apps-Script-URL, Token und Upload-Endpoint eintragen, dann + "Verbindung testen". + +${B}Nuetzlich:${RST} + systemctl status ${APP_NAME} + journalctl -u ${APP_NAME} -f + sudo bash install.sh --update # nach Aenderungen an web/ oder server/ + +EOF diff --git a/server/.env.example b/server/.env.example new file mode 100644 index 0000000..040250d --- /dev/null +++ b/server/.env.example @@ -0,0 +1,26 @@ +# Kopieren nach .env und anpassen +PORT=8080 +HOST=127.0.0.1 + +# Muss identisch sein mit CONFIG.API_TOKEN in apps-script/Code.gs +API_TOKEN=BITTE-AENDERN-langes-zufaelliges-token + +# Oeffentliche Basis-URL (ohne Slash am Ende) – landet als Bild-URL im Sheet +PUBLIC_URL=https://monitore.example.org + +# Pfade +UPLOAD_DIR=/var/lib/monitor-check/uploads +WEB_DIR=/opt/monitor-check/web + +# Grenzen +MAX_UPLOAD_MB=15 +MAX_IMAGE_PX=1600 +JPEG_QUALITY=78 + +# Kommagetrennt, oder * fuer alle +ALLOWED_ORIGINS=* + +# Anzahl vorgeschalteter Proxys (eigener nginx = 1, plus externer Proxy = 2) +TRUST_PROXY=1 +# Uploads pro Minute und IP +RATE_LIMIT=300 diff --git a/server/monitor-check.service b/server/monitor-check.service new file mode 100644 index 0000000..1988c30 --- /dev/null +++ b/server/monitor-check.service @@ -0,0 +1,32 @@ +[Unit] +Description=Monitor-Check Upload- und Web-Server +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=monitor +Group=monitor +WorkingDirectory=/opt/monitor-check/server +EnvironmentFile=/opt/monitor-check/server/.env +ExecStart=/usr/bin/node /opt/monitor-check/server/server.js +Restart=always +RestartSec=3 + +# Haertung +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=strict +ProtectHome=true +ReadWritePaths=/var/lib/monitor-check +ProtectKernelTunables=true +ProtectControlGroups=true +RestrictSUIDSGID=true +LockPersonality=true + +StandardOutput=journal +StandardError=journal +SyslogIdentifier=monitor-check + +[Install] +WantedBy=multi-user.target diff --git a/server/nginx-monitor-check.conf b/server/nginx-monitor-check.conf new file mode 100644 index 0000000..6ba6ba1 --- /dev/null +++ b/server/nginx-monitor-check.conf @@ -0,0 +1,39 @@ +# /etc/nginx/sites-available/monitor-check +# Danach: ln -s ../sites-available/monitor-check /etc/nginx/sites-enabled/ +# nginx -t && systemctl reload nginx +# certbot --nginx -d monitore.example.org + +server { + listen 80; + listen [::]:80; + server_name monitore.example.org; + + # Uploads vom Handy koennen gross sein + client_max_body_size 20m; + + # Sicherheits-Header + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "SAMEORIGIN" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + + location / { + proxy_pass http://127.0.0.1:8080; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 120s; + } + + # Hochgeladene Bilder direkt von der Platte ausliefern (schneller als durch Node) + location /uploads/ { + alias /var/lib/monitor-check/uploads/; + expires 30d; + add_header Cache-Control "public, immutable"; + add_header X-Content-Type-Options "nosniff" always; + access_log off; + # keine Ausfuehrung von hochgeladenen Dateien + location ~* \.(php|pl|py|cgi|sh)$ { deny all; } + } +} diff --git a/server/package.json b/server/package.json new file mode 100644 index 0000000..08a3284 --- /dev/null +++ b/server/package.json @@ -0,0 +1,19 @@ +{ + "name": "monitor-check-server", + "version": "1.0.0", + "description": "Upload- und Hosting-Server fuer das Monitor-Check-Tool", + "main": "server.js", + "private": true, + "scripts": { + "start": "node server.js" + }, + "engines": { + "node": ">=18" + }, + "dependencies": { + "dotenv": "^16.4.5", + "express": "^4.19.2", + "multer": "^1.4.5-lts.1", + "sharp": "^0.33.4" + } +} diff --git a/server/server.js b/server/server.js new file mode 100644 index 0000000..4f55158 --- /dev/null +++ b/server/server.js @@ -0,0 +1,151 @@ +/** + * Monitor-Check – Upload- und Hosting-Server + * ------------------------------------------ + * - liefert die Weboberflaeche (web/index.html) aus + * - nimmt Fotos entgegen, verkleinert sie und legt sie unter /uploads ab + * - gibt die oeffentliche URL des Bildes zurueck + * + * Start: node server.js + * Konfig: .env (siehe .env.example) + */ + +const express = require('express'); +const multer = require('multer'); +const sharp = require('sharp'); +const path = require('path'); +const fs = require('fs'); +const crypto = require('crypto'); + +require('dotenv').config(); + +const PORT = parseInt(process.env.PORT || '8080', 10); +const HOST = process.env.HOST || '0.0.0.0'; +const TOKEN = process.env.API_TOKEN || ''; +const PUBLIC_URL = (process.env.PUBLIC_URL || '').replace(/\/+$/, ''); +const UPLOAD_DIR = path.resolve(process.env.UPLOAD_DIR || path.join(__dirname, 'uploads')); +const WEB_DIR = path.resolve(process.env.WEB_DIR || path.join(__dirname, '..', 'web')); +const MAX_MB = parseInt(process.env.MAX_UPLOAD_MB || '15', 10); +const MAX_PX = parseInt(process.env.MAX_IMAGE_PX || '1600', 10); +const JPEG_Q = parseInt(process.env.JPEG_QUALITY || '78', 10); +const ALLOWED_ORIGINS = (process.env.ALLOWED_ORIGINS || '*') + .split(',').map(s => s.trim()).filter(Boolean); + +fs.mkdirSync(UPLOAD_DIR, { recursive: true }); + +const app = express(); +app.disable('x-powered-by'); +// Anzahl der vorgeschalteten Proxys. Hinter einem zusaetzlichen externen +// Reverse-Proxy auf 2 setzen, sonst landen alle Clients in einem Rate-Limit-Topf. +app.set('trust proxy', parseInt(process.env.TRUST_PROXY || '1', 10)); + +/* ---------- CORS ---------- */ +app.use((req, res, next) => { + const origin = req.headers.origin; + if (ALLOWED_ORIGINS.includes('*')) { + res.setHeader('Access-Control-Allow-Origin', '*'); + } else if (origin && ALLOWED_ORIGINS.includes(origin)) { + res.setHeader('Access-Control-Allow-Origin', origin); + res.setHeader('Vary', 'Origin'); + } + res.setHeader('Access-Control-Allow-Methods', 'GET,POST,OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type,X-Token'); + if (req.method === 'OPTIONS') return res.sendStatus(204); + next(); +}); + +/* ---------- Einfache Rate-Begrenzung ---------- */ +const treffer = new Map(); +function rateLimit(max, fensterMs) { + return (req, res, next) => { + const key = req.ip; + const jetzt = Date.now(); + const e = treffer.get(key) || { n: 0, bis: jetzt + fensterMs }; + if (jetzt > e.bis) { e.n = 0; e.bis = jetzt + fensterMs; } + e.n++; + treffer.set(key, e); + if (e.n > max) return res.status(429).json({ ok: false, error: 'Zu viele Anfragen' }); + next(); + }; +} +setInterval(() => { + const jetzt = Date.now(); + for (const [k, v] of treffer) if (jetzt > v.bis) treffer.delete(k); +}, 60000).unref(); + +/* ---------- Token-Pruefung ---------- */ +function pruefeToken(req, res, next) { + if (!TOKEN) return next(); // ohne Token konfiguriert = offen + const mit = req.get('X-Token') || req.query.token || ''; + const a = Buffer.from(String(mit)); + const b = Buffer.from(TOKEN); + const ok = a.length === b.length && crypto.timingSafeEqual(a, b); + if (!ok) return res.status(401).json({ ok: false, error: 'Nicht autorisiert' }); + next(); +} + +/* ---------- Upload ---------- */ +const upload = multer({ + storage: multer.memoryStorage(), + limits: { fileSize: MAX_MB * 1024 * 1024, files: 1 }, + fileFilter: (req, file, cb) => { + if (!/^image\//.test(file.mimetype)) return cb(new Error('Nur Bilddateien erlaubt')); + cb(null, true); + } +}); + +app.post('/api/upload', rateLimit(parseInt(process.env.RATE_LIMIT || '300', 10), 60000), pruefeToken, (req, res) => { + upload.single('datei')(req, res, async (err) => { + if (err) { + const code = err.code === 'LIMIT_FILE_SIZE' ? 413 : 400; + return res.status(code).json({ + ok: false, + error: err.code === 'LIMIT_FILE_SIZE' ? `Datei groesser als ${MAX_MB} MB` : err.message + }); + } + if (!req.file) return res.status(400).json({ ok: false, error: 'Keine Datei empfangen' }); + + try { + const name = `${new Date().toISOString().slice(0, 10)}_${crypto.randomBytes(6).toString('hex')}.jpg`; + const ziel = path.join(UPLOAD_DIR, name); + + await sharp(req.file.buffer, { failOn: 'none' }) + .rotate() // EXIF-Ausrichtung anwenden + .resize({ width: MAX_PX, height: MAX_PX, fit: 'inside', withoutEnlargement: true }) + .jpeg({ quality: JPEG_Q, mozjpeg: true }) + .toFile(ziel); + + const basis = PUBLIC_URL || `${req.protocol}://${req.get('host')}`; + const url = `${basis}/uploads/${name}`; + const { size } = fs.statSync(ziel); + + console.log(`[upload] ${name} ${(size / 1024).toFixed(0)} kB von ${req.ip}`); + res.json({ ok: true, url, datei: name, bytes: size }); + } catch (e) { + console.error('[upload] Fehler:', e.message); + res.status(500).json({ ok: false, error: 'Bild konnte nicht verarbeitet werden' }); + } + }); +}); + +/* ---------- Statisches Ausliefern ---------- */ +app.use('/uploads', express.static(UPLOAD_DIR, { + maxAge: '30d', + immutable: true, + index: false, + setHeaders: (res) => res.setHeader('X-Content-Type-Options', 'nosniff') +})); + +app.use('/', express.static(WEB_DIR, { extensions: ['html'], maxAge: '5m' })); + +app.get('/gesundheit', (req, res) => { + res.json({ ok: true, zeit: new Date().toISOString(), uploads: fs.readdirSync(UPLOAD_DIR).length }); +}); + +app.use((req, res) => res.status(404).json({ ok: false, error: 'Nicht gefunden' })); + +app.listen(PORT, HOST, () => { + console.log(`Monitor-Check laeuft auf http://${HOST}:${PORT}`); + console.log(` Weboberflaeche: ${WEB_DIR}`); + console.log(` Uploads: ${UPLOAD_DIR}`); + console.log(` Token: ${TOKEN ? 'aktiv' : 'NICHT gesetzt (offen!)'}`); +}); diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..4f8ef24 --- /dev/null +++ b/web/index.html @@ -0,0 +1,885 @@ + + + + + + +Monitor-Check + + + + +
+

Monitor-Check

+ bereit +
+ +
+ +
+
+

Neuer Eintrag

+ +
+ + +
+ +
+ + +
+ +
+ +
+
Tippen zum Fotografieren
+ + +
+ +

Wird auf deinen Server geladen, das Sheet speichert die URL.

+
+ +
+ +
+
+ Nicht geprüft + wird automatisch berechnet +
+
+ +
+ + +
+ + +
+
+ + +
+
+
+ + +
+
+
+
+ + +
+
+

Verbindung

+
+ + +
+
+ + +
+
+ + +

Leer lassen, wenn ohne Foto gearbeitet wird.

+
+
+ + +
+
+ + +
+
+ +
+

Statusregeln

+
+
Audio / Bild / Sprache alle JA
Funktionsfähig
+
Teilweise NEIN
Teilweise
+
Alle bewerteten NEIN
Fehlerhaft
+
Alle drei n.A.
Deaktiviert
+
Nichts erfasst
Nicht geprüft
+
+

Einzelne Felder mit „n.A.“ werden aus der Bewertung ausgeschlossen und zählen nicht als Fehler.

+
+ +
+

Offline-Warteschlange

+

–

+
+ + +
+
+
+
+ + + + +
+
+
+
+ +
+ + + +