Visitenkarten-Scanner: Stapelscan, Extraktion, Übersicht, vCard-Export

Selbst gehostete PWA, die Visitenkarten von einem Foto freistellt, ausliest
und als Kontakt bereitstellt.

- Stapelscan: OpenCV findet die Kartenrechtecke über mehrere Binärmasken,
  entzerrt sie perspektivisch und schneidet sie einzeln aus. Ohne Fund gilt
  das ganze Foto als eine Karte.
- Extraktion: ein Aufruf je Zuschnitt an das Vision-Modell mit
  JSON-Schema. Kein vorgeschaltetes OCR - das würde Layout und
  Schriftgrößen wegwerfen, aus denen die Feldzuordnung entsteht.
- Metadaten: Aufnahmezeit und GPS aus den EXIF-Daten des Fotos, Ortsname
  über Nominatim, Browserstandort nur als Rückfallebene.
- Übersicht mit Volltextsuche und Filtern, Detailansicht mit Korrekturmaske.
- Notizfeld je Karte, Erinnerungen per Mail inklusive Nachholen verpasster
  Termine nach einem Neustart.
- vCard 3.0 einzeln und als Sammeldatei, Karte gilt danach als exportiert.
- Anmeldung über ein Passwort, Sitzung als signiertes Cookie.
- Deployment per Tag-Push nach den Konventionen in DEPLOY.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Lucas Orth
2026-09-06 11:45:56 +02:00
commit ab993e98e1
37 changed files with 3458 additions and 0 deletions

185
DEPLOY.md Normal file
View File

@@ -0,0 +1,185 @@
# Deployment-Konventionen
Verbindlich fuer jede App, die auf dem VPS per Tag-Push deployt wird.
Abweichungen brauchen einen Grund, der hier nicht steht.
## Ablauf
```
git tag v1.2.3 && git push --tags
-> Gitea Actions (act_runner auf dem VPS)
-> npm ci / build / test <- rot bricht ab, nichts wird deployt
-> .env aus Repo-Secret DOTENV
-> docker compose up -d --build --wait
```
Kein SCP, kein manuelles Neubauen. Der Code landet nie im Dateisystem des
Hosts - der Job-Container klont, baut gegen den Docker-Daemon des Hosts und
startet den Container.
## Infrastruktur (existiert bereits, nicht neu anlegen)
| Ding | Wert |
|---|---|
| Git | Gitea, `https://gitea.lucas-orth.de`, Container `gitea`, intern `:3000` |
| Runner | `act_runner`, Container `gitea-runner`, Label `ubuntu-latest` |
| Netz CI | `gitea-ci` - Gitea + Runner + Job-Container |
| Netz Web | `nginx-proxy-manager_default` - Proxy + alle App-Container |
| Job-Image | `catthehacker/ubuntu:act-latest` (node + docker-cli + compose) |
Die beiden Netze sind bewusst getrennt: der Runner spricht mit Gitea, nicht
mit dem Proxy. App-Container haengen im Web-Netz, nicht im CI-Netz.
## docker-compose.yml
```yaml
# Fester Projektname. Ohne das leitet Compose ihn aus dem Verzeichnisnamen
# ab - und der ist im CI-Job ein anderer als auf dem Host.
name: business-card-scanner
services:
app:
# Versionstag ermoeglicht Rollback ohne Rebuild.
image: business-card-scanner:${IMAGE_TAG:-latest}
build: .
# Fester Name: das ist die Adresse, auf die der Proxy zeigt.
container_name: business-card-scanner
restart: unless-stopped
# Wird im Workflow aus dem Secret DOTENV erzeugt.
env_file: .env
networks:
- nginx-proxy-manager_default
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:8080/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
interval: 60s
timeout: 5s
retries: 3
networks:
nginx-proxy-manager_default:
external: true
volumes:
business-card-scanner-data:
# Fester Name, damit das Volume nicht am Projektnamen haengt.
name: business-card-scanner-data
```
**Kein `ports:`.** Der Proxy erreicht den Container ueber das gemeinsame Netz
unter seinem Container-Namen. Ein Port-Mapping wuerde den Dienst zusaetzlich
offen ans Internet haengen.
**Healthcheck ist Pflicht.** Der Deploy-Schritt nutzt `--wait` und macht ihn
zum Gate. Ohne Healthcheck wird der Lauf gruen, auch wenn der Container in
einer Crash-Schleife haengt.
**Keine relativen Bind-Mounts** (`./data:/data`). Compose laeuft im
Job-Container, der Pfad wuerde auf dem Host ins Leere zeigen. Persistente
Daten gehoeren in ein named volume mit festem `name:`.
## .gitea/workflows/deploy.yml
```yaml
name: Build & Deploy
on:
push:
tags:
- 'v*'
jobs:
deploy:
runs-on: ubuntu-latest
steps:
# Intern klonen. Die oeffentliche Domain ist aus Containern heraus
# NICHT erreichbar: Gitea laeuft auf demselben Host, das Paket geht
# an die oeffentliche IP und findet nicht zurueck (NAT-Hairpin).
- uses: actions/checkout@v4
with:
github-server-url: http://gitea:3000
# --- Build & Tests, projektspezifisch ---
- name: Tag ermitteln
run: echo "IMAGE_TAG=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV
- name: .env aus Secret erzeugen
env:
DOTENV: ${{ secrets.DOTENV }}
run: |
if [ -z "$DOTENV" ]; then
echo "Secret DOTENV ist leer oder nicht gesetzt."
exit 1
fi
umask 077
echo "$DOTENV" > .env
- name: Deployen
run: docker compose up -d --build --remove-orphans --wait --wait-timeout 180
- name: Alte Layer aufraeumen
run: docker image prune -f
```
Tests gehoeren **vor** den Deploy-Schritt. Ein roter Lauf darf den Server
nicht anfassen.
## Dockerfile
- Multi-Stage: Build-Abhaengigkeiten landen nicht im finalen Image.
- `USER node` bzw. non-root im finalen Stage.
- `EXPOSE 8080` dokumentiert den internen Port (oeffnet nichts).
- Ein Health-Endpoint (`/healthz`), auf den der Healthcheck zeigt.
## Secrets
Ein einziges Repo-Secret **`DOTENV`** mit dem kompletten `.env`-Inhalt,
mehrzeilig. Anlegen unter Repo -> Einstellungen -> Actions -> Secrets.
Gitea speichert Secrets write-only: nach dem Anlegen nicht mehr lesbar, nur
ersetzbar. Die kanonische Fassung gehoert deshalb in den Passwortmanager,
Gitea haelt nur die Arbeitskopie fuer die Pipeline.
Der Wert wird ueber `env:` an die Shell gereicht, nie direkt interpoliert -
sonst zerlegt der Shell-Parser Werte mit `$`, Backticks oder Quotes.
## Nginx Proxy Manager
Neuer Proxy Host:
| Feld | Wert |
|---|---|
| Domain Names | `business-card-scanner.lucas-orth.de` |
| Scheme | `http` |
| Forward Hostname / IP | `business-card-scanner` (= `container_name`) |
| Forward Port | `8080` |
Danach Let's-Encrypt-Zertifikat ausstellen und "Force SSL" aktivieren.
Weil der Container-Name die Adresse ist: aendert sich `container_name`,
zeigt der Proxy-Host ins Leere.
## Bestehende App umstellen
Fuer dieses Projekt nicht relevant - es lief nie manuell auf dem Server.
## Erstes Deployment einer App
1. Repo in Gitea anlegen, pushen.
2. Secret `DOTENV` setzen.
3. Pruefen, ob schon ein Container mit dem Namen laeuft
(`docker ps -a --filter name=business-card-scanner`). Falls ja: im alten Verzeichnis
einmal `docker compose down`, sonst kollidiert der feste Container-Name.
4. Proxy Host im NPM anlegen.
5. `git tag v1.0.0 && git push origin v1.0.0`
## Fallstricke
| Symptom | Ursache |
|---|---|
| Job haengt beim Checkout | `github-server-url` fehlt, klont ueber die oeffentliche Domain |
| `network not found` | App-Container im CI-Netz statt im Web-Netz (oder umgekehrt) |
| Zweiter Container statt Update | `name:` im Compose fehlt, Projektname weicht ab |
| Lauf gruen, App tot | `--wait` fehlt oder kein Healthcheck definiert |
| Volume leer nach Umstellung | Projektname geaendert, altes Volume hiess anders |
| Proxy liefert 502 | `container_name` geaendert oder Container nicht im Web-Netz |