Compare commits

..
14 Commits
Author SHA1 Message Date
marcandClaude Opus 5 471679ff64 refactor(repo): deploy/ heißt jetzt build/ – der Name sagte das Gegenteil
"deploy" klang nach Ausrollen, drin steht das Gegenteil: das Bau-Rezept
und das Compose, das aus dem Quelltext baut. Ausgerollt wird mit
docker-compose.example.yml im Wurzelverzeichnis. Marc ist genau darüber
gestolpert.

Zwei Dinge mussten dafür vorher fest werden, sonst hätte das Umbenennen
die Entwicklungsdatenbank gekostet: Compose leitet den Projektnamen aus
dem Ordnernamen ab und hätte unter "build" ein neues, leeres Projekt mit
neuem, leerem Volume angelegt. Der Projektname steht deshalb jetzt im
Compose (name: aether), und das Volume trägt seinen alten Namen
ausdrücklich weiter (deploy_postgres_data) – unschön, aber die Daten
bleiben.

Ebenfalls angepasst: .gitignore. Dort stand deploy/.env; nach dem
Umbenennen wäre build/.env nur noch von der allgemeinen .env-Zeile
gedeckt gewesen.

Nachgeprüft nach dem Umzug: Abbild baut, Stapel startet, 113 Dateien in
der Datenbank wie vorher, Volume unverändert deploy_postgres_data,
GET / → 200.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 09:24:09 +02:00
marcandClaude Opus 5 a7a67da040 docs(config): eine .env.example statt zweier, die auseinanderliefen
Es gab sie doppelt: schlank im Wurzelverzeichnis (für den Betrieb) und
vollständig unter deploy/ (für die Entwicklung). Gedacht war das als
Arbeitsteilung, geworden ist es Drift. Der produktiven fehlten zwölf
Schlüssel – darunter SESSION_MAX_AGE, STORAGE_PATH und die
HoneyOS-Leiste –, und sie empfahl für OFFICE_WOPI_URL weiterhin
app:8080, also genau den Wert, an dem Collabora den Rahmen verweigert.

Jetzt eine Datei im Wurzelverzeichnis für beides, nach Abschnitten
geordnet, mit einem eigenen Abschnitt für die vier Werte, die nur den
Entwicklungs-Compose betreffen. deploy/.env.example ist weg, die
Verweise in README, TESTING und deploy/docker-compose.yml zeigen auf die
verbliebene Datei.

Gegengeprüft: Jeder Schlüssel, den config.go liest, kommt darin vor
(40 Stück, kein Rest). Ein Container mit dieser Datei als env_file
liefert die erwarteten Werte, auch den maskierten OFFICE_ALIASGROUP.

Dabei selbst in die dokumentierte Falle getappt und wieder heraus:
"OFFICE_PROVIDER=  # collabora | onlyoffice" macht den Kommentar zum
Wert. Der Test aus TESTING.md hat es gefunden.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 00:59:00 +02:00
marcandClaude Opus 5 aa8af418f2 docs(plan): gleitende Sitzung und der Fund zu store.MaxAge nachgetragen
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 23:56:09 +02:00
marcandClaude Opus 5 093ff5140e feat(auth): gleitende Sitzung – Frist ab letzter Benutzung statt ab Anmeldung
Marc flog trotz täglicher Nutzung heraus: Die Frist lief ab dem
Anmelden. Ein Ablauf, der den Anwesenden trifft und nicht den
Abwesenden, erkauft keine Sicherheit, sondern nur Ärger.

Jetzt verlängert jeder angemeldete Aufruf die Sitzung – höchstens einmal
pro Stunde, sonst trüge jede Antwort ein Set-Cookie (beim Laden einer
Dateiliste dreißig Stück). Vorgabe 30 Tage ab letzter Benutzung;
bestehende .env-Werte bleiben unangetastet.

security(auth): Frist galt nur im Browser, nicht für die Signatur

Beim Bauen gefunden, war schon vorher drin: NewSessionStore ersetzte
store.Options, ohne store.MaxAge() zu rufen. Die Codecs behielten damit
gorillas Voreinstellung von 30 Tagen. Wirkung: Der Browser warf das
Cookie nach SESSION_MAX_AGE weg – wer den Wert vorher herauskopiert
hatte, konnte ihn 30 Tage lang weiterbenutzen. Wer die Frist kürzte, um
sie zu kürzen, hatte nichts gekürzt.

Mit Test festgehalten, und der Test ist gegengeprüft: Ohne den Aufruf
schlägt er fehl ("abgelaufene Sitzung gilt weiter"), mit ihm läuft er
durch.

Ein Fehler steckte dabei zuerst im Test selbst: gorilla legt die
entschlüsselte Sitzung im Zusammenhang der Anfrage ab. Zweimal dieselbe
Anfrage zu befragen prüfte den Zwischenspeicher, nicht die Frist. Jetzt
baut jede Prüfung eine frische Anfrage.

Live gegengeprüft: Anmeldung setzt das Cookie, drei angemeldete Aufrufe
danach setzen keins (die Bremse greift).

Dazu die gemessenen Leerlaufwerte der Office-Server im README statt der
Zahlen aus deren Dokumentation (Collabora 51 MB, OnlyOffice 396 MB).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 01:14:13 +02:00
marcandClaude Opus 5 6c53651568 fix(office): PDF am Telefon nur bei OnlyOffice, Collabora behält den Editor
Die Regel galt für beide Anbieter – das war falsch. Marcs Kritik betraf
nur OnlyOffice: Es baut auf dem Telefon dieselbe Oberfläche auf wie am
PC. Collabora bringt für kleine Schirme eine eigene Ansicht mit, die
taugt; dort wäre der Umweg über ein PDF ein Rückschritt.

Der Anbieter steht jetzt in der öffentlichen Konfiguration
(office_provider), und die Oberfläche entscheidet daran. Der Endpunkt
selbst kann weiterhin beides.

Nachgeholt: Der ganze Weg ist jetzt auch mit Collabora gemessen, nicht
nur mit OnlyOffice – gegen die Dev-Instanz, die damit läuft.
  erster Abruf 0,347 s, zweiter 0,009 s, eine Datei auf der Platte
  (OnlyOffice zum Vergleich: 1,05 s – es muss die Datei erst abholen)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 01:07:08 +02:00
marcandClaude Opus 5 065372359b feat(pdf): Zoom in festen Fünferschritten statt in fünf Prozent
Gemeint waren runde Zahlen: 65, 70, 75, 80 … Ein fester Faktor ist
rechnerisch die sauberere Größe – er fühlt sich unten wie oben gleich an
–, aber abzulesen war er nicht: Die Anzeige sprang um drei Punkte, dann
um vier, dann um sechs. Wer eine Zahl sieht, erwartet ein Raster.

Weil fünf Punkte bei 25 % ein Fünftel und bei 400 % ein Hundertstel
sind, kann die Schwelle der Geste nicht mehr fest sein. Verlangt wird
jetzt genau die Fingerbewegung, die dem nächsten Schritt entspricht,
mit zwei Prozent als Untergrenze gegen Zittern.

Ein Fehler, den erst die Messung zeigte: 1,1 × 100 ergibt in Fließkomma
110.00000000000001, und die "nächste kleinere Fünferzahl" davon ist
wieder 110. Der Zoom blieb deshalb nach genau einer Stufe stehen –
sichtbar nur beim Zuziehen, weil es aufwärts eine Stufe später gegriffen
hätte. Vor der Rechnung wird jetzt gerundet.

Gemessen, eine lange Geste in jede Richtung:
  auf:  65 → 70 → 75 → 80 → 85 → 90 → 95 → 100 → 105 → 110 → 115 → 120 → 125 → 130
  zu:   125 → 120 → 115 → 110 → 105 → 100 → 95 → 90 → 85 → 80 → 75

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 00:57:31 +02:00
marcandClaude Opus 5 ded1134f6a feat(pdf): Zoomstufen von 10 % auf 5 % verfeinert
Marc las am Telefon Sprünge von acht Prozentpunkten – die Stufe ist
relativ, nicht in Punkten: Bei 65 % sind zehn Prozent sieben Punkte, bei
200 % zwanzig. Fünf Prozent treffen das Gemeinte besser.

Die Schwelle der Geste folgt der Stufenweite (vorher 8 % / 7 %), sonst
ließe sich die feinere Weite mit den Fingern gar nicht treffen.

Kein Ruckeln dadurch: Die Zeichenschleife bricht eine laufende
Darstellung ab, sobald sich der Maßstab ändert – von zwanzig Stufen
einer Geste wird nur die letzte fertig gemalt.

Gemessen, dieselbe Geste:
  65 → 68 → 71 → 75 → 79 → 83 → 87 → 91 → 96 → 101 → 106 → 111 → 117 → 123 → 129 %

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 00:52:37 +02:00
marcandClaude Opus 5 9c5cda75d7 feat(office): Dokumente am Telefon als PDF ansehen statt bearbeiten
Am Finger öffnet ein Office-Dokument jetzt das gewandelte PDF im eigenen
Betrachter; "Edit" daneben führt trotzdem in den Editor. Mit Maus ändert
sich nichts.

Der Web-Editor ist eine vollständige Textverarbeitung – zwischen seinen
Leisten bleibt auf 400 Pixeln vom Dokument ein Rechteck. Collabora und
OnlyOffice verhalten sich gleich; deshalb kein besserer Bedienmodus,
sondern ein anderes Werkzeug. Gewandelt wird von dem Server, der auch
bearbeitet: Sonst sähe man unterwegs ein LibreOffice-Layout eines
Dokuments, für das man OnlyOffice gewählt hat.

Je Dokument gibt es GENAU EIN PDF, und das ist keine Frage des
Aufräumens, sondern des Schlüssels: previews/<id>.pdf trägt die Fassung
nicht im Namen, eine neue Fassung überschreibt. files.preview_version
sagt, ob das Abgelegte noch passt.

Sicherheit: Die Adresse, die OnlyOffice zurückgibt, wird von uns
abgerufen – der Wähler des Transports lässt nur OFFICE_ALLOWED_HOSTS zu,
zusätzlich eine ausdrückliche Prüfung mit Test. JWT-Signatur (HS256) für
die eigene API des Dokumentenservers. Fremde Kennung 404, ohne Anmeldung
401. Das Ergebnis muss mit %PDF- beginnen, sonst landete eine
Fehlerseite im Zwischenspeicher.

Gemessen gegen einen echten OnlyOffice-Server im Docker-Netz:
  erster Abruf 1,05 s -> zweiter 0,009 s
  erneut hochgeladen -> Fassung 2, preview_version 2, weiterhin 1 Datei
  GC: herrenloses PDF von vorgestern weg, echtes bleibt
  endgültig gelöscht -> PDF weg; Papierkorb -> PDF bleibt
  ohne Anmeldung 401, fremde Kennung 404, Nicht-Office 415

Dazu im Admin-Bereich neu: Platzbedarf der abgeleiteten Daten
(Vorschaubilder, PDFs, Profilbilder). Sie zählen gegen kein Kontingent –
ein Ordner, der wächst, ohne dass ihn jemand sieht, ist eine stille
Überraschung.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 23:30:49 +02:00
marcandClaude Opus 5 84ae570305 docs(office): aliasgroup1 und OFFICE_WOPI_URL richtiggestellt
Der Collabora-Schnipsel empfahl `aliasgroup1: http://app:8080` und
`OFFICE_WOPI_URL=http://app:8080`. Beides kann nicht funktionieren,
sobald der Browser die Cloud unter einer anderen Adresse sieht als der
Docker-Dienstname – also immer:

  * Collabora baut aus WOPISrc sein frame-ancestors. Steht dort der
    interne Name, lehnt der Browser die Einbettung ab.
  * aliasgroup1 ist die Positivliste der WOPI-Hosts. Steht der Host
    nicht drin: "No authorized hosts found matching the target host".

Beide Zeilen nennen jetzt die öffentliche Adresse, aliasgroup1 als
regulären Ausdruck mit Port. Dazu die Begründung im Text (samt der
Folge, dass Collabora die Datei dann über den Reverse Proxy holt), zwei
Zeilen in der Fehlertabelle und derselbe Hinweis in der .env.example.

Außerdem der Kenntnisstand zu OnlyOffice nachgezogen: Start, Umwandlung
nach PDF und JWT sind hier inzwischen gemessen, der WOPI-Kreis nicht.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 22:27:20 +02:00
marcandClaude Opus 5 0723d853f0 feat(pdf): Finger und Mausrad zoomen in Zehnteln statt in Vierteln
Die Leiter der Knöpfe (0,5 / 0,75 / 1 / 1,25 / …) ist für eine Geste zu
grob – man schießt über die Größe hinaus, die man gerade sucht. Finger
und Mausrad gehen jetzt zehn Prozent pro Schritt, die Knöpfe behalten
ihre Leiter: Ein Klick soll etwas bewegen, eine Geste ist ein Regler.

Multiplikativ, nicht in festen Prozentpunkten: Von 50 auf 60 % ist ein
Fünftel mehr, von 300 auf 310 % ein Dreißigstel. Zehn Prozent vom
aktuellen Wert fühlen sich überall gleich an. Auf ganze Prozent
gerundet, sonst stünde 71,50000000000001 % in der Leiste.

Gemessen, dieselbe Geste wie vorher: 65 % -> 116 % statt 65 % -> 125 %,
zurück auf 71 % statt 50 %.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 21:17:57 +02:00
marcandClaude Opus 5 574ba3e6c5 feat(mobile): Zurück-Geste schließt die Ansicht, zwei Finger zoomen das PDF
Zwei Dinge, die am Telefon fehlten – beide in der installierten App
besonders schmerzhaft, weil es dort keine Adresszeile als Rückweg gibt.

1. Zurück schloss bisher die Anwendung, nicht die Vorschau. Vorschau,
   Office-Editor und die aufgeschobene Seitenleiste kamen im Verlauf des
   Browsers gar nicht vor: Der einzige Eintrag war die Dateiliste. Jetzt
   legt jede bildschirmfüllende Ansicht beim Öffnen einen leeren Eintrag
   an (die Adresse bleibt gleich – eine Vorschau ist kein Ort für ein
   Lesezeichen) und verbraucht ihn beim Schließen wieder, egal ob über
   die Geste, Escape oder den Knopf. Ein Stapel sorgt dafür, dass bei
   übereinanderliegenden Ansichten nur die oberste reagiert.

   Collabora blendet auf kleinen Schirmen seinen eigenen Schließen-Knopf
   aus (closeButtonEnabled && !isSmallScreenDevice) – ohne diesen Weg
   käme man aus einem Dokument am Telefon nur über die Kopfzeile heraus.

2. Die Zusammenziehgeste vergrößerte die ganze Seite statt des PDFs:
   Leiste und Knöpfe wuchsen mit, das Dokument blieb grob. touch-action
   nimmt dem Browser die Geste, ein eigener Zuhörer setzt unsere
   Zoomstufen an ihre Stelle, verankert an der Mitte zwischen den
   Fingern – dieselbe Regel wie bei Strg+Mausrad.

Gemessen in Chromium mit Touch-Emulation, 412x900:
  Vorschau offen -> Verlauf 2->3, Zurück -> Vorschau zu, Anwendung bleibt
  Esc -> Eintrag wieder weg (ein weiteres Zurück verlässt die App)
  Aufziehen 65% -> 125%, Zuziehen -> 50%, visualViewport.scale bleibt 1

Ein Befund aus dieser Messung steckt im Code: Zuerst kam pro Geste nur
eine Stufe, weil sich der Zuhörer bei jeder Zoomänderung neu anmeldete
und der gemerkte Fingerabstand dabei verlorenging. Abstand und Maßstab
liegen deshalb in Referenzen, der Zuhörer meldet sich einmal an.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 20:25:59 +02:00
marcandClaude Opus 5 face318131 docs(plan): Idee notiert – Dateien im Papierkorb ansehen (vertagt)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 15:01:21 +02:00
marcandClaude Opus 5 105b1a0e74 fix(trash): Vorschaubilder auch im Papierkorb ausliefern
GetThumbnail schlug die Datei mit is_deleted = false nach – im
Papierkorb also 404, und dort stand für jedes Bild nur ein graues
Quadrat. Man entschied über "endgültig löschen", ohne zu sehen, was es
war.

Schlägt der erste Griff fehl, wird jetzt im Papierkorb nachgesehen. Das
Vorschaubild überlebt das weiche Löschen ohnehin: Entfernt wird es erst
beim endgültigen Löschen, und der GC zählt es bis dahin als benutzt.

Geprüft gegen die laufende Instanz: Bild hochladen, löschen,
Vorschaubild abrufen -> 200, image/jpeg, 64x64.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 14:21:55 +02:00
marcandClaude Opus 5 451f359734 feat(uploads): fertige Meldungen verschwinden von selbst
Nach einem Stapel-Upload blieb unten rechts für jede Datei eine Karte
stehen, bis man sie einzeln wegklickte oder die Seite neu lud. Bei
vielen Dateien war der Bildschirm zu.

Fertige Meldungen räumen sich jetzt nach vier Sekunden selbst ab.
Fehler bleiben: Sie tragen die einzige Auskunft, die es sonst nirgends
gibt. Zusätzlich stehen höchstens vier Karten gleichzeitig, der Rest
wird gezählt – der Bildschirm läuft ja schon während des Hochladens
voll, nicht erst danach. Gescheiterte Dateien stehen dabei oben.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 19:14:13 +02:00
39 changed files with 1787 additions and 221 deletions

No files matched your search

+113 -35
View File
@@ -1,14 +1,21 @@
# Aether – das Nötigste für eine neue Umgebung. cp .env.example .env # Aether – alle Einstellungen. cp .env.example .env
#
# Diese Datei gilt für beides: den Betrieb mit dem fertigen Abbild
# (docker-compose.example.yml hier im Wurzelverzeichnis) und die Entwicklung
# aus dem Quelltext (build/docker-compose.yml, liest build/.env).
# Für die Entwicklung: cp .env.example build/.env
# #
# Zwei Fallen beim Bearbeiten: # Zwei Fallen beim Bearbeiten:
# $ im Wert → als $$ schreiben, sonst schneidet Compose ab # $ im Wert → als $$ schreiben, sonst schneidet Compose ab
# Kommentar hinter einem LEEREN Wert → der Kommentar wird zum Wert # Kommentar hinter einem LEEREN Wert → der Kommentar wird zum Wert
# Erklärungen: README.md, Abschnitt „Einstellungen" # Erklärungen und Fallstricke: README.md, Abschnitt „Einstellungen"
# Alle Schalter: deploy/.env.example
# ── Pflicht ─────────────────────────────────────────────────────────────────── # ══ Pflicht ═══════════════════════════════════════════════════════════════════
APP_ENV=production
# Adresse, unter der der Browser die Cloud sieht. Ohne Schrägstrich am Ende. APP_ENV=production # development | production
# Adresse, unter der der BROWSER die Cloud sieht. Ohne Schrägstrich am Ende.
# Ein falscher Wert startet klaglos und fällt erst auf, wenn ein Freigabe-Link
# oder ein OIDC-Rücksprung ins Leere zeigt.
PUBLIC_URL=https://cloud.example.com PUBLIC_URL=https://cloud.example.com
# openssl rand -hex 32 # openssl rand -hex 32
SESSION_SECRET= SESSION_SECRET=
@@ -28,57 +35,128 @@ DATA_PATH=/pfad/zu/aether-data
APP_UID=1000 APP_UID=1000
APP_GID=1000 APP_GID=1000
# ── Reverse Proxy ───────────────────────────────────────────────────────────── # ══ Erster Start ══════════════════════════════════════════════════════════════
# Nur diesen Adressen wird X-Forwarded-For/-Proto geglaubt. # Legt den Admin an. Danach ADMIN_PASSWORD leeren – das Konto bleibt.
TRUSTED_PROXIES=172.18.0.1
# Port auf dem Wirt. Im Container bleibt es 8080.
APP_PORT=8080
# Wer den Port erreichen darf. 127.0.0.1 = nur dieser Rechner (Vorgabe,
# davor gehört der Proxy). 0.0.0.0 = das ganze Netz – dann unverschlüsselt.
APP_BIND=127.0.0.1
# Wie viele frühere Fassungen einer Datei aufgehoben werden. Gilt nur für
# Dateien, an denen „Keep versions" eingeschaltet ist – Standard ist aus.
MAX_VERSIONS_KEPT=5
# ── Erster Start ──────────────────────────────────────────────────────────────
# Legt den Admin an. Danach ADMIN_PASSWORD leeren.
# Auch hier: ein $ muss $$ heißen (siehe DB_PASSWORD). # Auch hier: ein $ muss $$ heißen (siehe DB_PASSWORD).
ADMIN_USERNAME=admin ADMIN_USERNAME=admin
ADMIN_PASSWORD= ADMIN_PASSWORD=
# ── Anmeldung über einen Identitätsanbieter (optional) ─────────────────────── # ══ Reverse Proxy ═════════════════════════════════════════════════════════════
# Leerer OIDC_ISSUER = aus. Sonst die vollständige Adresse des Anbieters, # IPs/CIDRs der Proxys, denen X-Forwarded-For/-Proto geglaubt wird. Steht hier
# z. B. https://id.example.com – nicht seinen Namen. Falsch gesetzt bricht # das Falsche, sieht die Anwendung überall die IP des Proxys – Rate-Limit und
# der Start ab. # Audit-Log werden damit wertlos, ohne dass etwas nach Fehler aussieht.
TRUSTED_PROXIES=172.18.0.1
# Port auf dem Wirt. Im Container bleibt es 8080.
APP_PORT=8080
# Wer den Port erreichen darf. 127.0.0.1 = nur dieser Rechner (Vorgabe, davor
# gehört der Proxy). 0.0.0.0 = das ganze Netz – dann unverschlüsselt.
APP_BIND=127.0.0.1
APP_LISTEN_ADDR=:8080 # im Container; der Proxy leitet hierher
# ══ Sitzung ═══════════════════════════════════════════════════════════════════
# Wie lange eine Anmeldung gilt – **ab der letzten Benutzung**, nicht ab dem
# Anmelden. Wer die Cloud regelmäßig öffnet, bleibt angemeldet; wer sie so
# lange nicht anfasst, meldet sich neu an. 2592000 = 30 Tage.
# #
# Kommentar NIE hinter einen leeren Wert setzen: `OIDC_ISSUER= # text` macht # Kürzer ist sinnvoll, wenn mehrere Leute denselben Rechner benutzen. Alle
# den Text zum Wert. Hinter einem gefüllten Wert ist er dagegen harmlos. # Sitzungen sofort beenden: SESSION_SECRET wechseln und neu starten.
SESSION_MAX_AGE=2592000 # Sekunden
# ══ Speicher ══════════════════════════════════════════════════════════════════
STORAGE_PATH=/data # im Container
TMP_PATH=/data/tmp
# Frühere Fassungen je Datei. Greift nur bei eingeschaltetem „Keep versions".
MAX_VERSIONS_KEPT=5
# ══ Anmeldung über einen Identitätsanbieter (optional) ════════════════════════
# Leerer OIDC_ISSUER = aus. Sonst die vollständige ADRESSE des Anbieters,
# z. B. https://id.example.com – nicht seinen Namen. Falsch gesetzt bricht der
# Start ab.
OIDC_ISSUER= OIDC_ISSUER=
OIDC_CLIENT_ID=aether OIDC_CLIENT_ID=aether
OIDC_CLIENT_SECRET= OIDC_CLIENT_SECRET=
# Muss exakt <PUBLIC_URL>/api/v1/auth/oidc/callback sein. # Muss EXAKT <PUBLIC_URL>/api/v1/auth/oidc/callback sein.
OIDC_REDIRECT_URL=https://cloud.example.com/api/v1/auth/oidc/callback OIDC_REDIRECT_URL=https://cloud.example.com/api/v1/auth/oidc/callback
# Bestehendes Konto verbinden statt ein zweites anzulegen. # Bestehendes Konto gleichen Namens mit der Identität verbinden, statt ein
# VOR der ersten Anmeldung setzen – danach ist es zu spät. # zweites anzulegen. VOR der ersten Anmeldung setzen – danach ist es zu spät.
# Nur bei einem Anbieter, dem die Namen gehören: Bei freier Registrierung wäre
# es die Übernahme fremder Konten.
OIDC_LINK_BY_USERNAME=false OIDC_LINK_BY_USERNAME=false
# Beschriftung des Knopfes auf der Anmeldeseite. # Beschriftung des Knopfes auf der Anmeldeseite.
OIDC_BUTTON_LABEL=Sign in with SSO / OIDC OIDC_BUTTON_LABEL=Sign in with SSO / OIDC
# ── Office (optional) ───────────────────────────────────────────────────────── # ══ Office (optional) ═════════════════════════════════════════════════════════
# Leer = aus, auch wenn ein Office-Container läuft. # Leer = aus, auch wenn ein Office-Container läuft.
# Schnipsel für Collabora, OnlyOffice und Euro-Office: README.md # Compose-Schnipsel für Collabora, OnlyOffice und Euro-Office: README.md
# collabora | onlyoffice (auch Euro-Office)
OFFICE_PROVIDER= OFFICE_PROVIDER=
# Drei Adressen für drei Richtungen – die häufigste Fehlerquelle.
#
# Backend → Office. Port je nach Anbieter: # Backend → Office. Port je nach Anbieter:
# Collabora http://office:9980 # Collabora http://office:9980
# OnlyOffice/Euro-Office http://office # OnlyOffice/Euro-Office http://office
OFFICE_URL=http://office:9980 OFFICE_URL=http://office:9980
# Browser → Office. Deine Domain oder LAN-IP, nicht der Compose-Name. # Browser → Office. Deine Domain oder LAN-IP, nicht der Compose-Name.
OFFICE_PUBLIC_URL=https://office.example.com OFFICE_PUBLIC_URL=https://office.example.com
# Office → Aether. Weder localhost noch der veröffentlichte Port. # Office → Aether.
OFFICE_WOPI_URL=http://app:8080 #
# Gegen SSRF: was das Backend anfragen darf. # **Bei Collabora die öffentliche Adresse der Cloud**, nicht app:8080: Aus
# diesem Wert baut Collabora sein frame-ancestors, und der Browser darf den
# Rahmen sonst nicht zeigen ("Framing … violates 'frame-ancestors …'").
# Collabora holt die Datei dann über deinen Reverse Proxy.
OFFICE_WOPI_URL=https://cloud.example.com
# Gegen SSRF: was das Backend anfragen darf. Der Host aus OFFICE_URL zählt
# immer dazu.
OFFICE_ALLOWED_HOSTS=office OFFICE_ALLOWED_HOSTS=office
# Nur OnlyOffice/Euro-Office, mind. 32 Zeichen: openssl rand -hex 32 # Nur OnlyOffice/Euro-Office, mind. 32 Zeichen: openssl rand -hex 32
# Muss mit JWT_SECRET des Office-Containers übereinstimmen. Ohne ihn schlägt
# das Wandeln nach PDF fehl (der Server antwortet `{"error":-8}`). Für den
# WOPI-Weg wird er nicht gebraucht, der hat eigene Token.
OFFICE_JWT_SECRET= OFFICE_JWT_SECRET=
OFFICE_INSECURE_TLS=false # nur für Tests mit selbstsigniertem Zertifikat
# ══ Volltextsuche (OCR) ═══════════════════════════════════════════════════════
# Standardmäßig an; fehlen ocrmypdf/tesseract, schaltet es sich selbst ab.
# OCR_ENABLED=false
# OCR_LANGUAGES=deu+eng # weitere brauchen ein apk-Paket im Dockerfile
# OCR_MAX_PAGES=50 # 0 = unbegrenzt; rund 1 Minute je Seite
# OCR_TIMEOUT=10m # Notbremse je Datei
# ══ App-Umschalter (optional) ═════════════════════════════════════════════════
# Andere selbst gehostete Apps im Menü der Seitenleiste.
# EXTERNAL_APPS=InkNote|https://notes.example.com|📝
# ══ HoneyOS-Leiste (optional) ═════════════════════════════════════════════════
# Ursprung, von dem die Leiste geladen wird. Wird an zwei Stellen gebraucht und
# kommt deshalb aus einer Quelle: die CSP erlaubt ihn (script-src +
# connect-src), und die Oberfläche lädt das Skript von dort nach.
# Wirkt beim **Neustart** – kein Neubau nötig.
# Leer = keine Leiste, und die CSP bleibt so eng wie ohne sie.
HONEYBAR_ORIGIN=
# Ausweis, mit dem sich die Leiste die App-Liste holt. Muss zu HONEYOS_APPS_KEY
# drüben passen. Nur nötig, wenn HoneyOS auf einer anderen Site liegt als
# Aether – dann schickt der Browser die HoneyOS-Sitzung nicht mit.
HONEYBAR_KEY=
# ══ Nur für build/docker-compose.yml ══════════════════════════════════════════
# Diese vier steuern den Office-Container aus dem Entwicklungs-Compose. Beim
# Betrieb mit eigenem Office-Container stehen sie in dessen Compose-Datei.
#
# Wer seine Dateien in Collabora öffnen darf – derselbe Host wie in
# OFFICE_WOPI_URL, aber als regulärer Ausdruck: Punkte maskieren, Port
# anhängen. Fehlt er, lehnt Collabora ab:
# "No authorized hosts found matching the target host […] in config"
OFFICE_ALIASGROUP=https://cloud\.example\.com:443
OFFICE_SSL=false
OFFICE_SSL_TERMINATION=false # true nur, wenn davor wirklich HTTPS endet
OFFICE_PORT=9980
# ══ Später ════════════════════════════════════════════════════════════════════
# MEILISEARCH_URL=http://meilisearch:7700
# MEILISEARCH_KEY=
# ══ WebDAV ════════════════════════════════════════════════════════════════════
# Läuft unter /dav/, kein Schalter nötig. Der Proxy muss
# PROPFIND/MKCOL/MOVE/COPY durchreichen. Anmeldung ausschließlich mit
# App-Passwörtern, nie mit dem Kontopasswort.
+1 -1
View File
@@ -1,5 +1,5 @@
# Secrets # Secrets
deploy/.env build/.env
.env .env
# Aus den Beispielen kopiert – gehört in die Umgebung, nicht ins Repo. # Aus den Beispielen kopiert – gehört in die Umgebung, nicht ins Repo.
+2 -2
View File
@@ -36,7 +36,7 @@ aether/
│ ├── pages/ │ ├── pages/
│ ├── api/ # Typed API-Client │ ├── api/ # Typed API-Client
│ └── hooks/ │ └── hooks/
├── deploy/ # Docker Compose, Dockerfile, .env.example ├── build/ # Dockerfile + Compose zum Bauen aus dem Quelltext
└── CLAUDE.md └── CLAUDE.md
``` ```
@@ -156,7 +156,7 @@ Kein Commit ohne grüne Tests.
## Docker & Secrets ## Docker & Secrets
- **Backend immer via Docker starten/laufen lassen (`docker compose ...` in `deploy/`), nie `go run`.** Der Host hat kein Go-Toolchain installiert; Builds/Compile-Checks laufen über das `golang:1.25-alpine`-Image. Migrations + DB-Verbindung sind auf die Compose-Umgebung ausgelegt. - **Backend immer via Docker starten/laufen lassen (`docker compose -f build/docker-compose.yml ...`), nie `go run`.** Der Host hat kein Go-Toolchain installiert; Builds/Compile-Checks laufen über das `golang:1.25-alpine`-Image. Migrations + DB-Verbindung sind auf die Compose-Umgebung ausgelegt.
- Secrets **nur** über `.env` / Compose-Env – nie im Image oder Repo - Secrets **nur** über `.env` / Compose-Env – nie im Image oder Repo
- Multi-Stage-Build: Builder-Stage (Go-Compiler) → Alpine final - Multi-Stage-Build: Builder-Stage (Go-Compiler) → Alpine final
- `.env` ist in `.gitignore`; `.env.example` zeigt alle Keys ohne Werte - `.env` ist in `.gitignore`; `.env.example` zeigt alle Keys ohne Werte
+131 -32
View File
@@ -41,8 +41,15 @@ Zwei Wege, je nachdem was du vorhast:
| | | | | |
|---|---| |---|---|
| **Betreiben** | `docker-compose.example.yml` + `.env.example` im Wurzelverzeichnis. Zieht das fertige Abbild, braucht keinen Quelltext. | | **Betreiben** | `docker-compose.example.yml` im Wurzelverzeichnis. Zieht das fertige Abbild, braucht keinen Quelltext. |
| **Entwickeln** | `deploy/docker-compose.yml` + `deploy/.env.example`. Baut aus dem Quelltext, kennt alle Schalter. | | **Entwickeln** | `build/docker-compose.yml`. Baut aus dem Quelltext. |
Die **`.env.example` im Wurzelverzeichnis gilt für beide** – eine Datei, alle
Einstellungen. Sie lag bis zum 02.09.2026 doppelt vor (einmal schlank hier,
einmal vollständig im Bau-Ordner), und die beiden liefen auseinander: Der
produktiven fehlten am Ende zwölf Schlüssel, darunter `SESSION_MAX_AGE`, und
sie empfahl für `OFFICE_WOPI_URL` einen Wert, der mit Collabora nicht
funktioniert.
### 1. Dateien holen ### 1. Dateien holen
@@ -66,7 +73,7 @@ cd aether
```bash ```bash
cp .env.example .env # beim Betreiben cp .env.example .env # beim Betreiben
cp deploy/.env.example deploy/.env # beim Entwickeln cp .env.example build/.env # beim Entwickeln (dorthin schaut das Compose)
``` ```
Ausfüllen musst du mindestens: Ausfüllen musst du mindestens:
@@ -83,9 +90,10 @@ ADMIN_USERNAME=admin
ADMIN_PASSWORD= # nach dem ersten Start wieder leeren ADMIN_PASSWORD= # nach dem ersten Start wieder leeren
``` ```
Die schlanke `.env.example` im Wurzelverzeichnis führt nur diese Pflichtwerte Alles Weitere – Sitzungsdauer, OIDC, Office, OCR, App-Umschalter – steht mit
und Office. Alles Weitere – OIDC, OCR, Vorschaubilder, Kontingente – steht mit Erklärung in derselben Datei, nach Abschnitten geordnet. Der letzte Abschnitt
Erklärung in `deploy/.env.example`. gilt nur für den Entwicklungs-Compose; beim Betrieb mit eigenem
Office-Container stehen diese Werte in dessen Compose-Datei.
### 3. Starten ### 3. Starten
@@ -102,8 +110,8 @@ Beim ersten Start:
Aus dem Quelltext bauen statt ziehen: Aus dem Quelltext bauen statt ziehen:
```bash ```bash
docker compose -f deploy/docker-compose.yml build app docker compose -f build/docker-compose.yml build app
docker compose -f deploy/docker-compose.yml up -d docker compose -f build/docker-compose.yml up -d
``` ```
Aktualisieren: Aktualisieren:
@@ -121,14 +129,8 @@ Direkt (ohne Proxy): http://localhost:8080/health
## Einstellungen ## Einstellungen
Zwei Beispieldateien, je nach Zweck: **Eine** Beispieldatei: `.env.example` im Wurzelverzeichnis, für Betrieb wie
Entwicklung. Sie ist knapp gehalten – was dahintersteckt, steht hier.
| | |
|---|---|
| `.env.example` (Wurzel) | das Nötigste zum Betreiben |
| `deploy/.env.example` | alle Schalter, auch die optionalen |
Beide sind knapp gehalten – was dahintersteckt, steht hier.
### Was schiefgeht, wenn man es falsch setzt ### Was schiefgeht, wenn man es falsch setzt
@@ -181,6 +183,18 @@ auf, wenn ein Freigabe-Link oder ein OIDC-Rücksprung ins Leere zeigt.
hier das Falsche, sieht die Anwendung überall die IP des Proxys – Rate-Limit hier das Falsche, sieht die Anwendung überall die IP des Proxys – Rate-Limit
und Audit-Log werden damit wertlos, ohne dass irgendetwas nach Fehler aussieht. und Audit-Log werden damit wertlos, ohne dass irgendetwas nach Fehler aussieht.
**`SESSION_MAX_AGE`** ist die Frist **ab der letzten Benutzung**, nicht ab dem
Anmelden. Wer die Cloud regelmäßig öffnet, bleibt angemeldet; wer sie 30 Tage
nicht anfasst, meldet sich neu an. Verlängert wird höchstens einmal pro
Stunde – sonst trüge jede Antwort ein `Set-Cookie`.
Was das kostet, damit es niemanden überrascht: Ein Cookie, das jemand aus
einem fremden Browser kopiert, verlängert sich beim Benutzen mit. Es gibt
keine Sitzungsliste auf dem Server, die man widerrufen könnte – das ist der
Preis dafür, ohne Sitzungstabelle auszukommen. **Alle** Sitzungen sofort
beenden heißt: `SESSION_SECRET` ändern und neu starten. Bei einem gemeinsam
genutzten Rechner ist eine kürzere Frist die bessere Wahl.
**`ADMIN_PASSWORD`** legt beim allerersten Start den Admin an. Danach leeren: **`ADMIN_PASSWORD`** legt beim allerersten Start den Admin an. Danach leeren:
Das Konto bleibt, das Passwort stünde sonst dauerhaft in einer Datei. Das Konto bleibt, das Passwort stünde sonst dauerhaft in einer Datei.
@@ -299,9 +313,9 @@ nur `GET`/`POST`, und dann scheitert das Einbinden mit einem `405`.
## Abbild bauen und veröffentlichen ## Abbild bauen und veröffentlichen
```bash ```bash
# im Wurzelverzeichnis des Projekts, nicht in deploy/ # im Wurzelverzeichnis des Projekts, nicht in build/
IMG=gitea.honeyy.duckdns.org/docker/aether IMG=gitea.honeyy.duckdns.org/docker/aether
docker build -f deploy/Dockerfile -t $IMG:$(date +%Y.%m.%d) -t $IMG:latest . docker build -f build/Dockerfile -t $IMG:$(date +%Y.%m.%d) -t $IMG:latest .
docker push $IMG:$(date +%Y.%m.%d) docker push $IMG:$(date +%Y.%m.%d)
docker push $IMG:latest docker push $IMG:latest
``` ```
@@ -381,7 +395,7 @@ aether/
│ ├── components/ │ ├── components/
│ ├── hooks/ │ ├── hooks/
│ └── pages/ │ └── pages/
├── deploy/ ├── build/
│ ├── docker-compose.yml # app + postgres (kein Proxy-Container) │ ├── docker-compose.yml # app + postgres (kein Proxy-Container)
│ ├── Dockerfile │ ├── Dockerfile
│ └── .env.example │ └── .env.example
@@ -520,9 +534,14 @@ entsteht der Container dann nie, ohne jede Fehlermeldung.
image: collabora/code:latest image: collabora/code:latest
restart: unless-stopped restart: unless-stopped
environment: environment:
# Wer seine Dateien hier öffnen darf. Punkte maskieren – Collabora liest # Wer seine Dateien hier öffnen darf – **die Adresse, unter der dein
# den Wert als regulären Ausdruck. # Browser die Cloud sieht**, nicht der interne Dienstname. Collabora
aliasgroup1: http://app:8080 # liest den Wert als regulären Ausdruck, Punkte müssen also maskiert
# werden, und der Port gehört dazu (443 bei HTTPS).
#
# Steht hier der falsche Wert, meldet das Log beim Öffnen:
# No authorized hosts found matching the target host […] in config
aliasgroup1: https://cloud\.example\.com:443
# ssl.enable – spricht Collabora selbst HTTPS? # ssl.enable – spricht Collabora selbst HTTPS?
# ssl.termination – endet HTTPS davor, an deinem Reverse Proxy? # ssl.termination – endet HTTPS davor, an deinem Reverse Proxy?
# Steht termination auf true ohne HTTPS davor, verlangt Collabora eine # Steht termination auf true ohne HTTPS davor, verlangt Collabora eine
@@ -543,10 +562,34 @@ entsteht der Container dann nie, ohne jede Fehlermeldung.
OFFICE_PROVIDER=collabora OFFICE_PROVIDER=collabora
OFFICE_URL=http://office:9980 OFFICE_URL=http://office:9980
OFFICE_PUBLIC_URL=https://office.example.com OFFICE_PUBLIC_URL=https://office.example.com
OFFICE_WOPI_URL=http://app:8080 # **Öffentliche Adresse, nicht app:8080** – Begründung direkt darunter.
OFFICE_WOPI_URL=https://cloud.example.com
OFFICE_ALLOWED_HOSTS=office OFFICE_ALLOWED_HOSTS=office
``` ```
> **Warum bei Collabora die öffentliche Adresse und nicht `app:8080`?**
>
> Weil Collabora aus dieser Adresse seine eigene Sicherheitsrichtlinie baut.
> Gemessen: Es setzt `frame-ancestors` auf *seinen* Host **plus den Host aus
> `WOPISrc`* – und `WOPISrc` ist genau `OFFICE_WOPI_URL`. Steht dort der
> interne Dienstname, erlaubt der Editor die Einbettung nur einer Seite, die
> unter `app:8080` läuft. Dein Browser kommt aber von `cloud.example.com`, und
> er lehnt ab:
>
> ```
> Framing 'https://office.example.com/' violates
> 'frame-ancestors office.example.com:* app:8080'
> ```
>
> Die Folge davon muss man wollen: Collabora holt die Datei dann **über deinen
> Reverse Proxy**, nicht quer durchs Docker-Netz. Der Container braucht dafür
> Namensauflösung nach draußen und einen Weg zurück – bei einem Proxy auf
> einem anderen Rechner ist das der normale Weg über das Internet.
>
> `aliasgroup1` oben muss **denselben** Host nennen. Zwei Schalter, eine
> Wahrheit: Der eine entscheidet, ob der Browser den Rahmen zeigen darf, der
> andere, ob Collabora die Anfrage überhaupt annimmt.
### Schnipsel 2: OnlyOffice Docs ### Schnipsel 2: OnlyOffice Docs
```yaml ```yaml
@@ -598,10 +641,14 @@ heißen gleich. Für uns ist es deshalb **derselbe Anbieter** –
Die `.env` ist dieselbe wie bei OnlyOffice. Rechne mit **5 GB** allein für das Die `.env` ist dieselbe wie bei OnlyOffice. Rechne mit **5 GB** allein für das
Abbild. Abbild.
> **Ungeprüft.** Collabora ist hier gemessen worden, den Kreis von Tippen bis > **Teilweise geprüft.** Collabora ist hier gemessen worden, den Kreis von
> Speichern eingeschlossen. Die beiden anderen Schnipsel stehen nach ihrer > Tippen bis Speichern eingeschlossen. OnlyOffice lief inzwischen auf dieser
> Dokumentation und dem gemeinsamen WOPI-Weg, sind auf dieser Anlage aber nie > Anlage – gemessen sind sein Start, die Umwandlung nach PDF und die
> gelaufen – für OnlyOffice fehlt schlicht der Arbeitsspeicher. > JWT-Signatur (ohne Signatur antwortet es mit `{"error":-8}`); im Leerlauf
> braucht es 396 MB, nach jedem Start belegt es für einige Minuten einen Kern,
> weil es seine eigenen Web-Dateien packt. **Nicht** gemessen ist dort der
> WOPI-Kreis vom Tippen bis zum Speichern. Euro-Office steht nach der
> Dokumentation und dem gemeinsamen Weg, ist hier nie gelaufen.
> >
> Wenn der Editor dort leer bleibt, sind das die zwei Stellen, an denen es > Wenn der Editor dort leer bleibt, sind das die zwei Stellen, an denen es
> nach Aktenlage hakt: > nach Aktenlage hakt:
@@ -614,9 +661,53 @@ Abbild.
> Signatur **nicht** – erlaubt, aber falls es dort eine Pflicht gibt, wäre > Signatur **nicht** – erlaubt, aber falls es dort eine Pflicht gibt, wäre
> das der Ort. > das der Ort.
> >
> `JWT_SECRET` betrifft nach der Dokumentation die eigene API des > `JWT_SECRET` betrifft die eigene API des Dokumentenservers, nicht den
> Dokumentenservers, nicht den WOPI-Weg – unser Adapter signiert damit nichts. > WOPI-Weg – unser Adapter signiert damit nichts. Nachgemessen: `POST
> Der Schlüssel gehört trotzdem gesetzt, sonst läuft der Server offen. > /ConvertService.ashx` ohne Signatur antwortet `{"error":-8}`, mit Signatur
> (HS256, entweder als Feld `token` im Rumpf oder als `Authorization: Bearer`
> über `{"payload": …}`) kommt das Ergebnis. Der Schlüssel gehört also
> gesetzt – sonst läuft die API offen.
### Am Telefon: bei OnlyOffice ansehen statt bearbeiten
Tippt man mit dem **Finger** auf ein Office-Dokument, hängt es vom Anbieter ab,
was aufgeht:
| Anbieter | am Telefon |
|---|---|
| **Collabora** | der Editor – seine eigene Handy-Ansicht taugt |
| **OnlyOffice / Euro-Office** | das Dokument als PDF; „Edit" führt trotzdem hinein |
Mit **Maus** ändert sich nichts: Dort geht immer der Editor auf, auch in einem
schmalen Fenster. Ein schmales Fenster auf dem Schreibtisch ist immer noch
eine Tastatur.
Der Unterschied ist keine Geschmacksfrage. OnlyOffice baut auf dem Telefon
dieselbe Oberfläche auf wie am PC – zwischen Menüband, Formatleisten und
Eigenschaftenbereich bleibt auf 400 Pixeln vom Dokument ein Rechteck übrig.
Collabora hat für kleine Schirme eine eigene Ansicht und braucht den Umweg
nicht.
Der Endpunkt selbst gilt für beide: `GET /api/v1/files/{id}/preview.pdf`
wandelt bei Collabora über `/cool/convert-to/pdf` (gemessen 0,35 s) und bei
OnlyOffice über `/ConvertService.ashx` (1,05 s). Nur *wann* er benutzt wird,
entscheidet der Anbieter.
Gewandelt wird von **demselben** Server, der auch bearbeitet: Wer OnlyOffice
wegen der Treue zu MS-Office-Formaten nimmt, soll unterwegs nicht ein
LibreOffice-Layout desselben Dokuments sehen.
| | |
|---|---|
| Ablage | `previews/<datei-id>.pdf` – **eine** Datei je Dokument, wird überschrieben |
| Gültigkeit | `files.preview_version`; neue Fassung ⇒ neu gewandelt |
| Kontingent | zählt **nicht** mit – abgeleitet, niemand hat es hochgeladen |
| Aufräumen | endgültiges Löschen nimmt es mit; der GC kennt den Ordner |
| Grenze | über 100 MB wird nicht gewandelt (`413`), die Oberfläche bietet den Editor an |
| Nur OnlyOffice | braucht `OFFICE_JWT_SECRET` – ohne Signatur `{"error":-8}` |
Wie viel Platz das belegt, steht im Admin-Bereich unter *Audit* neben der
Bereinigung, zusammen mit Vorschau- und Profilbildern.
### Welche Dateien, und wie gut ### Welche Dateien, und wie gut
@@ -671,6 +762,8 @@ Alles hier ist beim Testen tatsächlich passiert, in dieser Reihenfolge:
| Rahmen bleibt leer, Konsole: *„Framing … violates default-src 'self'"* | Unsere eigene Sicherheitsrichtlinie. `frame-src` fällt auf `default-src` zurück; der Office-Ursprung muss ausdrücklich erlaubt sein. Passiert automatisch, sobald `OFFICE_PROVIDER` gesetzt ist. | | Rahmen bleibt leer, Konsole: *„Framing … violates default-src 'self'"* | Unsere eigene Sicherheitsrichtlinie. `frame-src` fällt auf `default-src` zurück; der Office-Ursprung muss ausdrücklich erlaubt sein. Passiert automatisch, sobald `OFFICE_PROVIDER` gesetzt ist. |
| Editor lädt, Dokument bleibt weiß | `ssl.termination=true` ohne HTTPS davor. Collabora sagt dem Browser dann `wss://`, das es nicht gibt. Im HTTP-Test `OFFICE_SSL_TERMINATION=false`. | | Editor lädt, Dokument bleibt weiß | `ssl.termination=true` ohne HTTPS davor. Collabora sagt dem Browser dann `wss://`, das es nicht gibt. Im HTTP-Test `OFFICE_SSL_TERMINATION=false`. |
| Log: *„CheckFileInfo returned 0 … ECONNREFUSED"* | `OFFICE_WOPI_URL` zeigt auf `127.0.0.1:8090`. Der Port ist auf loopback gebunden – aus einem Container nicht erreichbar. Im Compose den Dienstnamen nehmen. | | Log: *„CheckFileInfo returned 0 … ECONNREFUSED"* | `OFFICE_WOPI_URL` zeigt auf `127.0.0.1:8090`. Der Port ist auf loopback gebunden – aus einem Container nicht erreichbar. Im Compose den Dienstnamen nehmen. |
| Konsole: *„Framing … violates 'frame-ancestors … app:8080'"* | `OFFICE_WOPI_URL` steht auf dem internen Dienstnamen. Collabora baut daraus seine Richtlinie und erlaubt dann nur eine Einbettung von dort. Die öffentliche Adresse der Cloud eintragen. |
| Log: *„No authorized hosts found matching the target host …"* | `aliasgroup1` kennt diesen Host nicht. Öffentliche Adresse eintragen, Punkte maskieren, Port anhängen – und den **Office**-Container neu erzeugen, nicht nur die App. Prüfen mit `docker compose logs office \| grep "trusted WOPI host"`. |
| Log: *„WOPISrc must be URI-encoded"* | Behoben; die Adresse wird vollständig kodiert. | | Log: *„WOPISrc must be URI-encoded"* | Behoben; die Adresse wird vollständig kodiert. |
| Log: *„Failed to exec coolmount … needs CAP_SYS_ADMIN"* | Gelöst über `--o:mount_jail_tree=false` im Compose. | | Log: *„Failed to exec coolmount … needs CAP_SYS_ADMIN"* | Gelöst über `--o:mount_jail_tree=false` im Compose. |
| Fehlende Symbole (404 auf `.svg`) | Lücken im Collabora-Image selbst. Kosmetisch. | | Fehlende Symbole (404 auf `.svg`) | Lücken im Collabora-Image selbst. Kosmetisch. |
@@ -692,8 +785,14 @@ Der Office-Server ist der mit Abstand hungrigste Teil dieser Anlage:
| | Bedarf | Bemerkung | | | Bedarf | Bemerkung |
|---|---|---| |---|---|---|
| **Collabora (CODE)** | ~1 GB im Leerlauf, +100–200 MB je offenem Dokument | LibreOffice-Kern, startet pro Dokument einen Prozess | | **Collabora (CODE)** | **51 MB im Leerlauf** (gemessen), +100–200 MB je offenem Dokument | LibreOffice-Kern, startet pro Dokument einen Prozess |
| **OnlyOffice Docs** | 2 GB Minimum, **4 GB empfohlen**, dazu Swap | bringt eigenes Postgres, RabbitMQ und Node mit | | **OnlyOffice Docs** | **396 MB im Leerlauf** (gemessen), 2 GB Minimum unter Last, **4 GB empfohlen** | bringt eigenes Postgres, RabbitMQ und Node mit |
Die Leerlaufwerte sind auf dieser Anlage nachgemessen und liegen deutlich
unter dem, was beide Projekte als Mindestbedarf nennen – gemeint ist dort der
Betrieb *mit* offenen Dokumenten. Zum Abbild auf der Platte: Collabora 1,9 GB,
OnlyOffice 4,9 GB. Und OnlyOffice zieht nach **jedem** Start für einige
Minuten einen ganzen Kern, weil es seine eigenen Web-Dateien packt.
| **Euro-Office** | wie OnlyOffice, Abbild rund 5 GB | derselbe Unterbau | | **Euro-Office** | wie OnlyOffice, Abbild rund 5 GB | derselbe Unterbau |
Dazu kommt, was schon läuft: Postgres, Aether und – nicht unterschätzen – die Dazu kommt, was schon läuft: Postgres, Aether und – nicht unterschätzen – die
+3 -2
View File
@@ -721,12 +721,13 @@ Hinter einem **gefüllten** Wert ist ein Kommentar harmlos. Hinter einem
den OIDC-Aufbau mit dem Kommentartext als Adresse – und weil ein Fehlschlag den OIDC-Aufbau mit dem Kommentartext als Adresse – und weil ein Fehlschlag
dort den Start abbricht, kam die Cloud nicht hoch. dort den Start abbricht, kam die Cloud nicht hoch.
In `deploy/.env.example` steckten vier weitere solche Zeilen, darunter In der (inzwischen zusammengeführten) `.env.example` steckten vier weitere
solche Zeilen, darunter
`OFFICE_PROVIDER= # collabora | onlyoffice`: Office wäre mit Unsinn als `OFFICE_PROVIDER= # collabora | onlyoffice`: Office wäre mit Unsinn als
Anbieter eingeschaltet worden. Alle vier umgestellt, der Kommentar steht jetzt Anbieter eingeschaltet worden. Alle vier umgestellt, der Kommentar steht jetzt
darüber. darüber.
- [ ] `grep -nE '^[A-Z_]+=[[:space:]]*#' .env.example deploy/.env.example` - [ ] `grep -nE '^[A-Z_]+=[[:space:]]*#' .env.example`
liefert nichts 🔎 liefert nichts 🔎
- [ ] Frische Instanz allein aus den Beispieldateien startet 🔎 - [ ] Frische Instanz allein aus den Beispieldateien startet 🔎
+17 -1
View File
@@ -216,7 +216,7 @@ func main() {
var officeHandler *office.Handler var officeHandler *office.Handler
if cfg.OfficeEnabled() { if cfg.OfficeEnabled() {
client, err := office.NewClient(cfg.OfficeProvider, cfg.OfficeURL, cfg.OfficePublicURL, client, err := office.NewClient(cfg.OfficeProvider, cfg.OfficeURL, cfg.OfficePublicURL,
cfg.OfficeAllowedHosts, cfg.OfficeInsecureTLS) cfg.OfficeAllowedHosts, cfg.OfficeInsecureTLS, cfg.OfficeJWTSecret)
if err != nil { if err != nil {
// Kein Fatal: Eine falsche Office-Adresse soll die Cloud nicht am // Kein Fatal: Eine falsche Office-Adresse soll die Cloud nicht am
// Starten hindern. Dateien bleiben erreichbar, nur das Bearbeiten // Starten hindern. Dateien bleiben erreichbar, nur das Bearbeiten
@@ -396,6 +396,16 @@ func main() {
// Damit die Oberflaeche den Menuepunkt „Edit in Office" gar nicht // Damit die Oberflaeche den Menuepunkt „Edit in Office" gar nicht
// erst anbietet, wenn es keinen Office-Server gibt. // erst anbietet, wenn es keinen Office-Server gibt.
"office_enabled": officeHandler.Enabled(), "office_enabled": officeHandler.Enabled(),
// Welcher Anbieter – die Oberflaeche entscheidet daran, ob ein
// Dokument am Telefon im Editor oder als PDF aufgeht. Collabora
// hat eine brauchbare Handy-Ansicht, OnlyOffice baut dort die
// PC-Oberflaeche auf. Ohne Office-Server ein leerer Wert.
"office_provider": func() string {
if officeHandler.Enabled() {
return cfg.OfficeProvider
}
return ""
}(),
// Damit der Verlaufs-Dialog dieselbe Zahl nennt, die der Server // Damit der Verlaufs-Dialog dieselbe Zahl nennt, die der Server
// anwendet. Vorher stand sie zweimal im Quelltext, mit einem // anwendet. Vorher stand sie zweimal im Quelltext, mit einem
// Kommentar „muss zusammenpassen" – das ist keine Zusicherung. // Kommentar „muss zusammenpassen" – das ist keine Zusicherung.
@@ -449,6 +459,9 @@ func main() {
// Sitzung fuer den Office-Rahmen: Hier entsteht das WOPI-Token, im // Sitzung fuer den Office-Rahmen: Hier entsteht das WOPI-Token, im
// angemeldeten Zusammenhang. // angemeldeten Zusammenhang.
r.Get("/office/session/{id}", officeHandler.Session) r.Get("/office/session/{id}", officeHandler.Session)
// Dasselbe Dokument als PDF – zum Ansehen, ohne den Editor. Am
// Telefon der Normalfall.
r.Get("/files/{id}/preview.pdf", officeHandler.PreviewPDF)
r.Patch("/files/{id}/versioning", fileHandler.SetVersioning) r.Patch("/files/{id}/versioning", fileHandler.SetVersioning)
r.Get("/files/{id}/versions", fileHandler.ListVersions) r.Get("/files/{id}/versions", fileHandler.ListVersions)
r.Get("/files/{id}/versions/{versionId}/download", fileHandler.DownloadVersion) r.Get("/files/{id}/versions/{versionId}/download", fileHandler.DownloadVersion)
@@ -529,6 +542,9 @@ func main() {
r.Get("/admin/audit", adminHandler.ListAudit) r.Get("/admin/audit", adminHandler.ListAudit)
r.Get("/admin/audit/actions", adminHandler.ListAuditActions) r.Get("/admin/audit/actions", adminHandler.ListAuditActions)
r.Post("/admin/gc", adminHandler.RunGC) r.Post("/admin/gc", adminHandler.RunGC)
// Was Vorschaubilder, Profilbilder und gewandelte Dokumente belegen –
// Platz, der in keinem Kontingent auftaucht.
r.Get("/admin/storage/derived", adminHandler.DerivedStorage)
// Immer erreichbar, auch wenn OCR aus ist – der Handler antwortet dann // Immer erreichbar, auch wenn OCR aus ist – der Handler antwortet dann
// mit enabled:false. Eine Route, die je nach Konfiguration 404 liefert, // mit enabled:false. Eine Route, die je nach Konfiguration 404 liefert,
// wäre für die Oberfläche nicht von einem Fehler zu unterscheiden. // wäre für die Oberfläche nicht von einem Fehler zu unterscheiden.
@@ -0,0 +1 @@
ALTER TABLE files DROP COLUMN IF EXISTS preview_version;
@@ -0,0 +1,9 @@
-- Die Fassung, aus der das zwischengespeicherte PDF entstanden ist.
--
-- Der Blob heißt `previews/<datei-id>.pdf` – **ohne** Fassungsnummer im
-- Namen. Damit kann es je Datei nie mehr als eine Vorschau geben: Eine neue
-- Fassung überschreibt die alte, statt eine zweite danebenzulegen. Ob die
-- vorhandene noch zum Inhalt passt, sagt diese Spalte.
--
-- 0 heißt "keine". Dateifassungen zählen ab 1.
ALTER TABLE files ADD COLUMN IF NOT EXISTS preview_version INT NOT NULL DEFAULT 0;
+21
View File
@@ -97,3 +97,24 @@ func (h *Handler) RunGC(w http.ResponseWriter, r *http.Request) {
"skipped_young": res.SkippedYoung, "skipped_young": res.SkippedYoung,
}) })
} }
// GET /api/v1/admin/storage/derived – Platzbedarf der abgeleiteten Daten.
//
// Getrennt vom Aufräumen, weil es eine Auskunft ist und keine Handlung: Man
// soll nachsehen können, ohne etwas anzustoßen.
func (h *Handler) DerivedStorage(w http.ResponseWriter, r *http.Request) {
if _, ok := requireAdmin(r); !ok {
response.Error(w, http.StatusForbidden, "admin required")
return
}
if h.gc == nil {
response.Error(w, http.StatusServiceUnavailable, "not available")
return
}
usage, err := h.gc.Derived(r.Context())
if err != nil {
response.Error(w, http.StatusInternalServerError, "could not read storage")
return
}
response.JSON(w, http.StatusOK, usage)
}
+6
View File
@@ -26,6 +26,12 @@ func RequireAuth(ss *SessionStore, store *Store) func(http.Handler) http.Handler
response.Error(w, http.StatusUnauthorized, "authentication required") response.Error(w, http.StatusUnauthorized, "authentication required")
return return
} }
// Die Frist läuft ab der letzten Benutzung, nicht ab dem
// Anmelden. **Vor** dem Handler, weil danach der Rumpf der
// Antwort schon geschrieben sein kann – ein Set-Cookie käme dann
// zu spät und fiele stillschweigend unter den Tisch.
ss.Touch(w, r)
ctx := context.WithValue(r.Context(), keyUser, user) ctx := context.WithValue(r.Context(), keyUser, user)
next.ServeHTTP(w, r.WithContext(ctx)) next.ServeHTTP(w, r.WithContext(ctx))
}) })
+59 -2
View File
@@ -5,6 +5,7 @@ import (
"encoding/base64" "encoding/base64"
"net/http" "net/http"
"strings" "strings"
"time"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/gorilla/sessions" "github.com/gorilla/sessions"
@@ -15,10 +16,22 @@ import (
const ( const (
sessionName = "aether_session" sessionName = "aether_session"
sessionKeyUser = "user_id" sessionKeyUser = "user_id"
csrfCookieName = "aether_csrf" // sessionKeyTouched hält fest, wann die Sitzung zuletzt verlängert wurde.
csrfHeaderName = "X-CSRF-Token" // Ohne diesen Wert müsste jede einzelne Anfrage ein neues Cookie setzen.
sessionKeyTouched = "touched_at"
csrfCookieName = "aether_csrf"
csrfHeaderName = "X-CSRF-Token"
) )
// refreshInterval: Wie oft eine benutzte Sitzung höchstens erneuert wird.
//
// Die Frist gleitet – aber sie muss nicht bei jedem Klick nachgezogen werden.
// Ohne diese Bremse trüge **jede** Antwort ein `Set-Cookie`, und beim Laden
// einer Dateiliste sind das schnell dreißig Stück. Einmal pro Stunde reicht:
// Der Unterschied zur Frist ist eine Stunde, und die fällt bei Tagen nicht
// ins Gewicht.
var refreshInterval = time.Hour
type SessionStore struct { type SessionStore struct {
store *sessions.CookieStore store *sessions.CookieStore
maxAge int maxAge int
@@ -32,6 +45,15 @@ func NewSessionStore(secret string, maxAge int) *SessionStore {
HttpOnly: true, HttpOnly: true,
SameSite: http.SameSiteLaxMode, SameSite: http.SameSiteLaxMode,
} }
// **Zwei Fristen, nicht eine.** `Options.MaxAge` steht nur im Cookie – der
// Browser wirft es dann weg. Wie lange der *Inhalt* gilt, entscheidet die
// Signatur, und die hängt an den Codecs.
//
// Ohne diesen Aufruf blieb dort gorillas Voreinstellung von 30 Tagen
// stehen: Ein Cookie, das jemand aus einem Browser kopiert hat, wäre einen
// Monat lang gültig geblieben, obwohl SESSION_MAX_AGE auf einen Tag stand.
// Wer die Frist kürzt, um sie zu kürzen, hätte nichts gekürzt.
store.MaxAge(maxAge)
return &SessionStore{store: store, maxAge: maxAge} return &SessionStore{store: store, maxAge: maxAge}
} }
@@ -41,10 +63,45 @@ func (ss *SessionStore) SetUser(w http.ResponseWriter, r *http.Request, userID u
sess, _ = ss.store.New(r, sessionName) sess, _ = ss.store.New(r, sessionName)
} }
sess.Values[sessionKeyUser] = userID.String() sess.Values[sessionKeyUser] = userID.String()
sess.Values[sessionKeyTouched] = time.Now().Unix()
sess.Options.Secure = middleware.IsHTTPS(r) sess.Options.Secure = middleware.IsHTTPS(r)
return ss.store.Save(r, w, sess) return ss.store.Save(r, w, sess)
} }
// Touch verlängert eine benutzte Sitzung.
//
// **Warum überhaupt:** Bisher lief die Frist ab dem *Anmelden* – wer die Cloud
// täglich benutzte, flog nach 24 Stunden trotzdem heraus, mitten in der
// Arbeit. Das ist die Sorte Ablauf, die keine Sicherheit bringt: Sie trifft
// den, der da ist, und nicht den, der weg ist.
//
// Jetzt läuft sie ab der *letzten Benutzung*. Wer die Cloud regelmäßig
// öffnet, bleibt angemeldet; wer sie einen Monat nicht anfasst, muss sich neu
// anmelden – und genau dieser zweite Fall ist der, um den es bei einem
// abgelaufenen Cookie geht.
//
// **Was das kostet, ehrlich gesagt:** Ein entwendetes Cookie, das weiter
// benutzt wird, verlängert sich mit. Ein Sitzungscookie hat keine Liste auf
// dem Server, die man widerrufen könnte – das ist der Preis dafür, ohne
// Sitzungstabelle auszukommen. Wer eine Sitzung sofort beenden will, ändert
// SESSION_SECRET; dann sind alle ungültig.
func (ss *SessionStore) Touch(w http.ResponseWriter, r *http.Request) {
sess, err := ss.store.Get(r, sessionName)
if err != nil || sess.IsNew {
return
}
if last, ok := sess.Values[sessionKeyTouched].(int64); ok {
if time.Since(time.Unix(last, 0)) < refreshInterval {
return
}
}
sess.Values[sessionKeyTouched] = time.Now().Unix()
sess.Options.Secure = middleware.IsHTTPS(r)
// Ein Fehlschlag ist keiner, der den Aufruf scheitern lassen darf: Die
// Sitzung gilt weiter, sie wurde nur nicht verlängert.
_ = ss.store.Save(r, w, sess)
}
func (ss *SessionStore) GetUserID(r *http.Request) (uuid.UUID, bool) { func (ss *SessionStore) GetUserID(r *http.Request) (uuid.UUID, bool) {
sess, err := ss.store.Get(r, sessionName) sess, err := ss.store.Get(r, sessionName)
if err != nil { if err != nil {
+125
View File
@@ -0,0 +1,125 @@
package auth
import (
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/google/uuid"
)
const testSecret = "test-geheimnis-mit-mehr-als-32-zeichen!!"
// Anmelden und die gesetzten Cookies zurückgeben.
func signIn(t *testing.T, ss *SessionStore) []*http.Cookie {
t.Helper()
w := httptest.NewRecorder()
r := httptest.NewRequest(http.MethodGet, "/", nil)
if err := ss.SetUser(w, r, uuid.New()); err != nil {
t.Fatalf("SetUser: %v", err)
}
return w.Result().Cookies()
}
// Eine **frische** Anfrage mit diesen Cookies.
//
// Jedes Mal eine neue, und das ist keine Umständlichkeit: gorilla legt die
// entschlüsselte Sitzung im Zusammenhang der Anfrage ab und gibt sie beim
// zweiten Zugriff unverändert zurück. Zweimal dieselbe Anfrage zu befragen
// prüfte also den Zwischenspeicher und nicht die Frist – der erste Entwurf
// dieses Tests ist genau darauf hereingefallen.
func withCookies(cookies []*http.Cookie) *http.Request {
r := httptest.NewRequest(http.MethodGet, "/", nil)
for _, c := range cookies {
r.AddCookie(c)
}
return r
}
func signedIn(t *testing.T, ss *SessionStore) *http.Request {
t.Helper()
return withCookies(signIn(t, ss))
}
// Die Frist muss auch für den **Inhalt** gelten, nicht nur für das Cookie.
//
// Der Browser wirft ein abgelaufenes Cookie von selbst weg – wer es vorher
// herauskopiert hat, aber nicht. Ohne store.MaxAge() blieb die Signatur 30
// Tage gültig, egal was in SESSION_MAX_AGE stand.
func TestSessionExpiresAfterMaxAge(t *testing.T) {
ss := NewSessionStore(testSecret, 1) // eine Sekunde
cookies := signIn(t, ss)
if _, ok := ss.GetUserID(withCookies(cookies)); !ok {
t.Fatal("frische Sitzung wurde nicht erkannt")
}
time.Sleep(2100 * time.Millisecond)
if _, ok := ss.GetUserID(withCookies(cookies)); ok {
t.Error("abgelaufene Sitzung gilt weiter – die Frist wirkt nur im Browser")
}
}
// Nicht bei jedem Aufruf ein neues Cookie: Sonst trüge jede Antwort ein
// Set-Cookie, und beim Laden einer Dateiliste sind das dreißig Stück.
func TestTouchIsThrottled(t *testing.T) {
ss := NewSessionStore(testSecret, 3600)
r := signedIn(t, ss)
w := httptest.NewRecorder()
ss.Touch(w, r)
if len(w.Result().Cookies()) != 0 {
t.Error("Sitzung wurde sofort erneuert, obwohl sie eben erst entstand")
}
}
// Nach Ablauf der Bremse verlängert sie sich – das ist der eigentliche Zweck.
func TestTouchExtendsSession(t *testing.T) {
old := refreshInterval
refreshInterval = 0
defer func() { refreshInterval = old }()
ss := NewSessionStore(testSecret, 3600)
r := signedIn(t, ss)
w := httptest.NewRecorder()
ss.Touch(w, r)
cookies := w.Result().Cookies()
if len(cookies) == 0 {
t.Fatal("Sitzung wurde nicht verlängert")
}
// Das erneuerte Cookie muss dieselbe Anmeldung tragen.
next := httptest.NewRequest(http.MethodGet, "/", nil)
for _, c := range cookies {
next.AddCookie(c)
}
if _, ok := ss.GetUserID(next); !ok {
t.Error("nach dem Verlängern ist niemand mehr angemeldet")
}
}
// Eine Anfrage ohne Cookie darf keine Sitzung erfinden.
func TestTouchIgnoresAnonymous(t *testing.T) {
ss := NewSessionStore(testSecret, 3600)
w := httptest.NewRecorder()
ss.Touch(w, httptest.NewRequest(http.MethodGet, "/", nil))
if len(w.Result().Cookies()) != 0 {
t.Error("für einen Unangemeldeten wurde ein Cookie gesetzt")
}
}
// Abmelden muss stärker sein als Verlängern.
func TestClearEndsSession(t *testing.T) {
ss := NewSessionStore(testSecret, 3600)
r := signedIn(t, ss)
w := httptest.NewRecorder()
if err := ss.Clear(w, r); err != nil {
t.Fatalf("Clear: %v", err)
}
for _, c := range w.Result().Cookies() {
if c.Name == sessionName && c.MaxAge >= 0 {
t.Errorf("Abmelden löscht das Cookie nicht (MaxAge=%d)", c.MaxAge)
}
}
}
+7
View File
@@ -715,6 +715,13 @@ func (h *Handler) GetThumbnail(w http.ResponseWriter, r *http.Request) {
return return
} }
f, err := h.store.GetFile(r.Context(), id, owner.OwnerID) f, err := h.store.GetFile(r.Context(), id, owner.OwnerID)
if errors.Is(err, ErrNotFound) {
// Auch aus dem Papierkorb ausliefern. Das Vorschaubild überlebt das
// weiche Löschen (entfernt wird es erst beim endgültigen), und ohne
// diesen zweiten Griff stünde dort nur ein graues Quadrat – man
// entschiede über „endgültig löschen", ohne zu sehen, was es war.
f, err = h.store.GetTrashedFile(r.Context(), id, owner.OwnerID)
}
if errors.Is(err, ErrNotFound) { if errors.Is(err, ErrNotFound) {
response.Error(w, http.StatusNotFound, "file not found") response.Error(w, http.StatusNotFound, "file not found")
return return
+5
View File
@@ -104,6 +104,10 @@ func (h *Handler) EmptyTrash(w http.ResponseWriter, r *http.Request) {
} }
for _, id := range fileIDs { for _, id := range fileIDs {
_ = h.storage.Delete(r.Context(), "thumbs/"+id.String()+".jpg") _ = h.storage.Delete(r.Context(), "thumbs/"+id.String()+".jpg")
// Das als PDF gewandelte Office-Dokument. Es trägt denselben Inhalt
// wie die Datei – bleibt es liegen, überlebt der Inhalt das endgültige
// Löschen.
_ = h.storage.Delete(r.Context(), "previews/"+id.String()+".pdf")
} }
log.Ctx(r.Context()).Info(). log.Ctx(r.Context()).Info().
@@ -139,6 +143,7 @@ func (h *Handler) HardDelete(w http.ResponseWriter, r *http.Request) {
_ = h.storage.Delete(r.Context(), key) _ = h.storage.Delete(r.Context(), key)
} }
_ = h.storage.Delete(r.Context(), "thumbs/"+id.String()+".jpg") _ = h.storage.Delete(r.Context(), "thumbs/"+id.String()+".jpg")
_ = h.storage.Delete(r.Context(), "previews/"+id.String()+".pdf")
h.logFile(r, audit.ActionHardDelete, id.String(), "", "permanently deleted from trash") h.logFile(r, audit.ActionHardDelete, id.String(), "", "permanently deleted from trash")
response.NoContent(w) response.NoContent(w)
} }
+47
View File
@@ -533,3 +533,50 @@ func isUniqueViolation(err error) bool {
} }
return false return false
} }
// PreviewVersion sagt, aus welcher Fassung das zwischengespeicherte PDF
// entstanden ist – 0 heißt: es gibt keins.
//
// Eine eigene kleine Abfrage statt einer Spalte in `fileCols`: Der Wert wird
// an genau einer Stelle gebraucht und ginge sonst durch jeden Scan, jede
// Liste und jede Suche mit.
func (s *Store) PreviewVersion(ctx context.Context, id uuid.UUID) (int, error) {
var v int
err := s.db.QueryRow(ctx, `SELECT preview_version FROM files WHERE id = $1`, id).Scan(&v)
if errors.Is(err, pgx.ErrNoRows) {
return 0, ErrNotFound
}
if err != nil {
return 0, fmt.Errorf("preview version: %w", err)
}
return v, nil
}
// SetPreviewVersion vermerkt, dass das abgelegte PDF zu dieser Fassung gehört.
func (s *Store) SetPreviewVersion(ctx context.Context, id uuid.UUID, version int) error {
_, err := s.db.Exec(ctx, `UPDATE files SET preview_version = $2 WHERE id = $1`, id, version)
if err != nil {
return fmt.Errorf("set preview version: %w", err)
}
return nil
}
// FilesWithPreview liefert die Kennungen aller Dateien, zu denen ein PDF
// abgelegt ist. Der GC braucht das: Ohne diese Liste hielte er jedes
// Vorschau-PDF für herrenlos und löschte es nach einem Tag weg.
func (s *Store) FilesWithPreview(ctx context.Context) ([]string, error) {
rows, err := s.db.Query(ctx, `SELECT id::text FROM files WHERE preview_version > 0`)
if err != nil {
return nil, fmt.Errorf("files with preview: %w", err)
}
defer rows.Close()
var ids []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, err
}
ids = append(ids, id)
}
return ids, rows.Err()
}
+57 -1
View File
@@ -72,7 +72,7 @@ func (c *Collector) Run(ctx context.Context) (Result, error) {
// "tmp/" bleibt außen vor: Das gehört dem Upload-Pfad und wird dort // "tmp/" bleibt außen vor: Das gehört dem Upload-Pfad und wird dort
// aufgeräumt; ein GC, der dort mitmischt, zerrisse laufende Uploads. // aufgeräumt; ein GC, der dort mitmischt, zerrisse laufende Uploads.
for _, prefix := range []string{"users", "thumbs", "branding", "avatars"} { for _, prefix := range []string{"users", "thumbs", "branding", "avatars", "previews"} {
err := c.storage.Walk(ctx, prefix, func(key string, info storage.FileInfo) error { err := c.storage.Walk(ctx, prefix, func(key string, info storage.FileInfo) error {
res.Scanned++ res.Scanned++
if referenced[key] { if referenced[key] {
@@ -153,6 +153,27 @@ func (c *Collector) referencedKeys(ctx context.Context) (map[string]bool, error)
return nil, fmt.Errorf("iterate file keys: %w", err) return nil, fmt.Errorf("iterate file keys: %w", err)
} }
// Die als PDF angesehenen Office-Dokumente. Ohne diesen Griff hielte der
// GC jedes Vorschau-PDF für herrenlos und löschte es nach einem Tag – der
// Fehler, der uns beim Logo und beim Favicon schon passiert ist.
prevRows, err := c.db.Query(ctx,
`SELECT id::text FROM files WHERE preview_version > 0`)
if err != nil {
return nil, fmt.Errorf("query preview keys: %w", err)
}
for prevRows.Next() {
var id string
if err := prevRows.Scan(&id); err != nil {
prevRows.Close()
return nil, fmt.Errorf("scan preview key: %w", err)
}
keys["previews/"+id+".pdf"] = true
}
prevRows.Close()
if err := prevRows.Err(); err != nil {
return nil, fmt.Errorf("iterate preview keys: %w", err)
}
// Logo und App-Symbol haben keine Zeile in files, sondern stehen in den // Logo und App-Symbol haben keine Zeile in files, sondern stehen in den
// Einstellungen. Ohne diese Abfrage hielte der GC sie für verwaist, sobald // Einstellungen. Ohne diese Abfrage hielte der GC sie für verwaist, sobald
// "branding" mit durchsucht wird – und löschte sie beim nächsten Lauf. // "branding" mit durchsucht wird – und löschte sie beim nächsten Lauf.
@@ -263,3 +284,38 @@ func (c *Collector) Start(ctx context.Context, onDone func(Result)) {
} }
}() }()
} }
// DerivedUsage: Wie viel Platz die **abgeleiteten** Daten belegen.
//
// Vorschaubilder, Profilbilder und die als PDF gewandelten Office-Dokumente
// zählen nicht gegen das Kontingent eines Kontos – niemand hat sie
// hochgeladen, und sie entstehen jederzeit neu. Platte kosten sie trotzdem,
// und in der Anzeige „X von Y GB belegt" tauchen sie nicht auf.
//
// Genau deshalb steht die Zahl hier: Ein Ordner, der wächst, ohne dass ihn
// irgendwo jemand sieht, ist eine stille Überraschung. Sichtbar ist sie eine
// Größe, über die man entscheiden kann.
type DerivedUsage struct {
Prefix string `json:"prefix"`
Files int `json:"files"`
Bytes int64 `json:"bytes"`
}
// Derived zählt die abgeleiteten Ordner durch. Reines Lesen – dieser Aufruf
// löscht nichts.
func (c *Collector) Derived(ctx context.Context) ([]DerivedUsage, error) {
out := make([]DerivedUsage, 0, 3)
for _, prefix := range []string{"thumbs", "previews", "avatars"} {
u := DerivedUsage{Prefix: prefix}
err := c.storage.Walk(ctx, prefix, func(_ string, info storage.FileInfo) error {
u.Files++
u.Bytes += info.Size
return nil
})
if err != nil {
return nil, fmt.Errorf("walk %s: %w", prefix, err)
}
out = append(out, u)
}
return out, nil
}
+271
View File
@@ -0,0 +1,271 @@
package office
import (
"bytes"
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"net/url"
"strings"
"time"
)
// Ein Dokument nach PDF wandeln – dafür, es *anzusehen*, statt es zu
// bearbeiten.
//
// **Warum überhaupt:** Am Telefon ist der Office-Editor unbrauchbar. Er ist
// eine vollständige Textverarbeitung; Menüband, Formatleisten und
// Eigenschaftenbereich sind nicht Beiwerk, sondern das Produkt. Auf 400 Pixeln
// bleibt für das Dokument übrig, was die Leisten nicht brauchen. Das gilt für
// Collabora wie für OnlyOffice – Marc hat beide ausprobiert, mit demselben
// Ergebnis. Ein PDF im eigenen Betrachter ist für „kurz nachsehen" das
// bessere Werkzeug, und für „kurz nachsehen" ist man unterwegs da.
//
// **Warum derselbe Server, der auch bearbeitet:** Wer OnlyOffice wegen der
// Treue zu MS-Office-Formaten nimmt, aber Collabora das PDF rechnen ließe,
// sähe unterwegs ein LibreOffice-Layout desselben Dokuments. Zwei Geräte,
// zwei Umbrüche – ausgerechnet bei Unterlagen, bei denen es auf die Seite
// ankommt. Die Umwandlung gehört deshalb zu dem Anbieter, der auch bearbeitet.
//
// Die beiden Anbieter gehen dabei verschiedene Wege, und der Unterschied ist
// grundsätzlich:
//
// - **Collabora** nimmt die Datei entgegen und antwortet mit dem PDF. Ein
// Aufruf, keine Anmeldung.
// - **OnlyOffice** nimmt eine *Adresse* entgegen, holt die Datei selbst ab
// und antwortet mit einer Adresse, unter der das Ergebnis liegt. Also
// zwei Aufrufe, eine Signatur, und ein zweiter Ort, dem man nicht trauen
// darf (siehe unten).
// convertTimeout: So lange darf eine Umwandlung dauern.
//
// Gemessen an dieser Anlage braucht ein gewöhnliches Textdokument etwa eine
// Sekunde. Eine Minute ist keine Erwartung, sondern eine Notbremse: Hängt der
// Office-Server, soll die Anfrage enden und nicht der Server mit ihr.
const convertTimeout = 60 * time.Second
// jwtLifetime: Wie lange die Signatur an OnlyOffice gilt. Sie wird für genau
// einen Aufruf ausgestellt.
const jwtLifetime = 2 * time.Minute
// ConvertToPDF wandelt ein Dokument um.
//
// `src` ist die Adresse, unter der der Office-Server die Datei abholen kann –
// nur OnlyOffice braucht sie. `body` sind die Bytes selbst – nur Collabora
// braucht die. Der Aufrufer stellt beides bereit; welcher Weg genommen wird,
// entscheidet der Anbieter, nicht der Aufrufer.
func (c *Client) ConvertToPDF(ctx context.Context, ext, key, name, src string, body io.Reader) ([]byte, error) {
ctx, cancel := context.WithTimeout(ctx, convertTimeout)
defer cancel()
switch c.provider {
case "collabora":
return c.convertCollabora(ctx, name, body)
case "onlyoffice":
return c.convertOnlyOffice(ctx, ext, key, name, src)
}
return nil, fmt.Errorf("office: provider %q cannot convert", c.provider)
}
// CanConvert sagt, ob dieser Anbieter das Wandeln überhaupt anbietet. Für die
// Oberfläche: Ohne das gäbe es einen Knopf, der nur Fehler erzeugt.
func (c *Client) CanConvert() bool {
return c != nil && (c.provider == "collabora" || c.provider == "onlyoffice")
}
// ── Collabora ────────────────────────────────────────────────────────────────
func (c *Client) convertCollabora(ctx context.Context, name string, body io.Reader) ([]byte, error) {
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
// Der Feldname ist vorgeschrieben („data"), der Dateiname bestimmt, wie
// Collabora den Typ erkennt – ohne Endung rät es und liegt daneben.
part, err := mw.CreateFormFile("data", name)
if err != nil {
return nil, fmt.Errorf("build request: %w", err)
}
if _, err := io.Copy(part, body); err != nil {
return nil, fmt.Errorf("read source: %w", err)
}
if err := mw.Close(); err != nil {
return nil, fmt.Errorf("build request: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
c.baseURL+"/cool/convert-to/pdf", &buf)
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", mw.FormDataContentType())
resp, err := c.http.Do(req)
if err != nil {
return nil, fmt.Errorf("convert: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("convert: office server answered %d", resp.StatusCode)
}
// Collabora antwortet mit dem PDF selbst, nicht mit einer Auskunft.
return readPDF(resp.Body)
}
// ── OnlyOffice / Euro-Office ────────────────────────────────────────────────
type convertResponse struct {
FileURL string `json:"fileUrl"`
FileType string `json:"fileType"`
Percent int `json:"percent"`
EndConvert bool `json:"endConvert"`
// Error ist eine Zahl, keine Meldung: -8 heißt „Signatur fehlt oder ist
// falsch", -4 „Quelle nicht erreichbar". Die Zahl steht deshalb im
// Fehlertext, sonst sucht man im Log des anderen Servers.
Error int `json:"error"`
}
func (c *Client) convertOnlyOffice(ctx context.Context, ext, key, name, src string) ([]byte, error) {
if src == "" {
return nil, fmt.Errorf("convert: no source address for the document server")
}
payload := map[string]any{
// Synchron: Die Umwandlung dauert Sekunden, und ein zweiter Aufruf zum
// Nachfragen wäre ein Zustand mehr, den jemand verwalten muss.
"async": false,
"filetype": strings.TrimPrefix(ext, "."),
"key": key,
"outputtype": "pdf",
"title": name,
"url": src,
}
if c.jwtSecret != "" {
// Die Signatur trägt genau die Nutzlast, die auch gesendet wird –
// gemessen: ohne sie antwortet der Server `{"error":-8}`.
payload["token"] = signJWT(c.jwtSecret, payload)
}
raw, err := json.Marshal(payload)
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
c.baseURL+"/ConvertService.ashx", bytes.NewReader(raw))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
// Ohne diesen Kopf antwortet der Server in XML.
req.Header.Set("Accept", "application/json")
resp, err := c.http.Do(req)
if err != nil {
return nil, fmt.Errorf("convert: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("convert: document server answered %d", resp.StatusCode)
}
var out convertResponse
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&out); err != nil {
return nil, fmt.Errorf("convert: unreadable answer: %w", err)
}
if out.Error != 0 {
return nil, fmt.Errorf("convert: document server error %d", out.Error)
}
if !out.EndConvert || out.FileURL == "" {
return nil, fmt.Errorf("convert: document server did not finish (%d%%)", out.Percent)
}
// **Die zurückgegebene Adresse ist nicht vertrauenswürdig.** Sie kommt aus
// der Antwort eines anderen Servers, und wir würden sie gleich abrufen –
// das ist die Stelle, an der aus einer Umwandlung ein Zugriff auf
// 169.254.169.254 würde. Der Wähler des Transports lässt ohnehin nur
// Adressen aus OFFICE_ALLOWED_HOSTS zu; hier steht die Prüfung trotzdem
// noch einmal ausdrücklich, weil eine klare Meldung mehr wert ist als ein
// abgewiesener Verbindungsversuch tief im Netzstapel.
if err := c.sameHostAsServer(out.FileURL); err != nil {
return nil, err
}
get, err := http.NewRequestWithContext(ctx, http.MethodGet, out.FileURL, nil)
if err != nil {
return nil, err
}
res2, err := c.http.Do(get)
if err != nil {
return nil, fmt.Errorf("fetch result: %w", err)
}
defer res2.Body.Close()
if res2.StatusCode != http.StatusOK {
return nil, fmt.Errorf("fetch result: answered %d", res2.StatusCode)
}
return readPDF(res2.Body)
}
// sameHostAsServer prüft, dass eine Adresse auf denselben Rechner zeigt wie
// der Office-Server selbst.
func (c *Client) sameHostAsServer(raw string) error {
u, err := url.Parse(raw)
if err != nil {
return fmt.Errorf("convert: unreadable result address")
}
base, err := url.Parse(c.baseURL)
if err != nil {
return fmt.Errorf("convert: OFFICE_URL is not a valid address")
}
if !strings.EqualFold(u.Hostname(), base.Hostname()) {
return fmt.Errorf("convert: result address points elsewhere (%q), refused", u.Hostname())
}
return nil
}
// ── gemeinsam ────────────────────────────────────────────────────────────────
// maxPDFBytes begrenzt, was als Ergebnis angenommen wird. Ohne Grenze
// bestimmte der andere Server, wie viel Speicher wir belegen.
const maxPDFBytes = 256 << 20
func readPDF(r io.Reader) ([]byte, error) {
data, err := io.ReadAll(io.LimitReader(r, maxPDFBytes+1))
if err != nil {
return nil, fmt.Errorf("read result: %w", err)
}
if len(data) > maxPDFBytes {
return nil, fmt.Errorf("read result: converted file is too large")
}
// Kein Vertrauen auf den Content-Type: Collabora liefert
// `application/octet-stream`. Die ersten Bytes sind die Auskunft, die
// zählt – und sie verhindert, dass eine Fehlerseite als PDF abgelegt wird.
if !bytes.HasPrefix(data, []byte("%PDF-")) {
return nil, fmt.Errorf("read result: answer is not a PDF")
}
return data, nil
}
// signJWT baut ein HS256-Token über die Nutzlast.
//
// Von Hand statt mit einer Bibliothek: Es sind zwölf Zeilen, und die einzige
// Alternative wäre eine weitere Abhängigkeit für einen einzigen Aufruf.
func signJWT(secret string, payload map[string]any) string {
claims := make(map[string]any, len(payload)+1)
for k, v := range payload {
claims[k] = v
}
claims["exp"] = time.Now().Add(jwtLifetime).Unix()
enc := func(v any) string {
b, _ := json.Marshal(v)
return base64.RawURLEncoding.EncodeToString(b)
}
head := enc(map[string]string{"alg": "HS256", "typ": "JWT"})
body := enc(claims)
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(head + "." + body))
return head + "." + body + "." +
base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
}
+122
View File
@@ -0,0 +1,122 @@
package office
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"strings"
"testing"
)
// Die drei Stellen, an denen ein Fehler **still** wäre: eine Signatur, die
// nicht stimmt (der Server sagt nur „-8"), eine Fehlerseite, die als PDF
// abgelegt wird, und eine Ergebnisadresse, die woanders hinzeigt.
func TestSignJWTIsVerifiable(t *testing.T) {
const secret = "geheimnis-mit-mindestens-32-zeichen!!"
token := signJWT(secret, map[string]any{"outputtype": "pdf", "key": "abc"})
parts := strings.Split(token, ".")
if len(parts) != 3 {
t.Fatalf("Token hat %d Teile, erwartet 3", len(parts))
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(parts[0] + "." + parts[1]))
want := base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
if parts[2] != want {
t.Errorf("Signatur stimmt nicht:\n ist: %s\n soll: %s", parts[2], want)
}
head, err := base64.RawURLEncoding.DecodeString(parts[0])
if err != nil {
t.Fatalf("Kopf nicht dekodierbar: %v", err)
}
var h map[string]string
if err := json.Unmarshal(head, &h); err != nil {
t.Fatalf("Kopf ist kein JSON: %v", err)
}
if h["alg"] != "HS256" {
t.Errorf("alg = %q, erwartet HS256", h["alg"])
}
body, _ := base64.RawURLEncoding.DecodeString(parts[1])
var claims map[string]any
if err := json.Unmarshal(body, &claims); err != nil {
t.Fatalf("Nutzlast ist kein JSON: %v", err)
}
if claims["key"] != "abc" || claims["outputtype"] != "pdf" {
t.Errorf("Nutzlast fehlt oder ist verändert: %v", claims)
}
// Ohne Ablauf gälte ein abgefangenes Token für immer.
if _, ok := claims["exp"]; !ok {
t.Error("exp fehlt – das Token liefe nie ab")
}
// Die eigene Signatur darf nicht Teil der signierten Nutzlast sein.
if _, ok := claims["token"]; ok {
t.Error("die Signatur signiert sich selbst mit")
}
}
func TestReadPDFRejectsNonPDF(t *testing.T) {
cases := map[string]string{
"Fehlerseite": "<html><body>500 Internal Server Error</body></html>",
"leer": "",
"JSON-Fehler": `{"error":-4}`,
"fast ein PDF": "%PD",
}
for name, body := range cases {
if _, err := readPDF(strings.NewReader(body)); err == nil {
t.Errorf("%s wurde als PDF angenommen", name)
}
}
// Ein echtes PDF muss durchkommen – sonst prüft der Test nichts.
if _, err := readPDF(strings.NewReader("%PDF-1.7\nInhalt")); err != nil {
t.Errorf("echtes PDF abgelehnt: %v", err)
}
}
func TestSameHostAsServerRefusesForeignAddresses(t *testing.T) {
c := &Client{baseURL: "http://office:80"}
// Die Ergebnisadresse kommt aus der Antwort eines anderen Servers. Genau
// hier würde aus einer Umwandlung ein Zugriff auf einen fremden Dienst.
foreign := []string{
"http://169.254.169.254/latest/meta-data/",
"http://localhost:8080/api/v1/admin/users",
"http://office.evil.example.com/x.pdf",
"file:///etc/passwd",
"://kaputt",
}
for _, raw := range foreign {
if err := c.sameHostAsServer(raw); err == nil {
t.Errorf("fremde Adresse angenommen: %s", raw)
}
}
for _, ok := range []string{
"http://office/cache/files/x.pdf",
"http://office:80/cache/files/x.pdf",
"http://OFFICE/cache/files/x.pdf", // Groß-/Kleinschreibung zählt nicht
} {
if err := c.sameHostAsServer(ok); err != nil {
t.Errorf("eigene Adresse abgelehnt: %s (%v)", ok, err)
}
}
}
func TestSanitizeFilename(t *testing.T) {
cases := map[string]string{
`Rechnung "2026".docx`: "Rechnung 2026",
// Zeilenumbrüche raus – sie könnten eine zweite Kopfzeile erfinden.
"kaputt\r\nInjected: x.odt": "kaputtInjected: x",
".odt": ".odt",
"": "document",
}
for in, want := range cases {
if got := sanitizeFilename(in); got != want {
t.Errorf("sanitizeFilename(%q) = %q, erwartet %q", in, got, want)
}
}
}
+6 -2
View File
@@ -48,7 +48,10 @@ type Client struct {
// selbst sieht – im Compose-Netz also `collabora:9980`. Die schickt man // selbst sieht – im Compose-Netz also `collabora:9980`. Die schickt man
// keinem Browser. // keinem Browser.
publicBase string publicBase string
http *http.Client // jwtSecret signiert Aufrufe an die **eigene** API von OnlyOffice (die
// Umwandlung). Der WOPI-Weg braucht ihn nicht, der hat eigene Token.
jwtSecret string
http *http.Client
mu sync.Mutex mu sync.Mutex
actions map[string]string // ".docx|edit" -> urlsrc actions map[string]string // ".docx|edit" -> urlsrc
@@ -62,7 +65,7 @@ type Client struct {
// auf – ohne Grenze wird daraus ein Weg ins interne Netz, sobald jemand die // auf – ohne Grenze wird daraus ein Weg ins interne Netz, sobald jemand die
// Konfiguration beeinflussen kann (SSRF). Die Adresse aus OFFICE_URL zählt // Konfiguration beeinflussen kann (SSRF). Die Adresse aus OFFICE_URL zählt
// immer dazu, sonst wäre die Voreinstellung unbrauchbar. // immer dazu, sonst wäre die Voreinstellung unbrauchbar.
func NewClient(provider, baseURL, publicURL string, allowedHosts []string, insecureTLS bool) (*Client, error) { func NewClient(provider, baseURL, publicURL string, allowedHosts []string, insecureTLS bool, jwtSecret string) (*Client, error) {
u, err := url.Parse(baseURL) u, err := url.Parse(baseURL)
if err != nil || u.Host == "" { if err != nil || u.Host == "" {
return nil, fmt.Errorf("OFFICE_URL is not a valid address: %q", baseURL) return nil, fmt.Errorf("OFFICE_URL is not a valid address: %q", baseURL)
@@ -96,6 +99,7 @@ func NewClient(provider, baseURL, publicURL string, allowedHosts []string, insec
provider: provider, provider: provider,
baseURL: strings.TrimRight(baseURL, "/"), baseURL: strings.TrimRight(baseURL, "/"),
publicBase: strings.TrimRight(publicURL, "/"), publicBase: strings.TrimRight(publicURL, "/"),
jwtSecret: jwtSecret,
http: &http.Client{Timeout: 20 * time.Second, Transport: transport}, http: &http.Client{Timeout: 20 * time.Second, Transport: transport},
}, nil }, nil
} }
+229
View File
@@ -0,0 +1,229 @@
package office
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"net/http"
"strings"
"sync"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"github.com/rs/zerolog/log"
"aether/internal/auth"
"aether/internal/files"
"aether/pkg/response"
)
// Ein Office-Dokument als PDF ansehen, ohne den Editor.
//
// Am Telefon ist das der Normalfall: nachsehen, nicht schreiben. Der Editor
// bleibt einen Knopfdruck entfernt.
//
// ── Warum es je Datei nur **ein** PDF gibt ──────────────────────────────────
//
// Der Schlüssel ist `previews/<datei-id>.pdf` und trägt die Fassungsnummer
// **nicht** im Namen. Eine neue Fassung überschreibt das alte PDF, statt ein
// zweites danebenzulegen – häufen kann sich hier also nichts, das ist keine
// Frage des Aufräumens, sondern des Schlüssels.
//
// Ob das abgelegte PDF noch zum Inhalt passt, sagt `files.preview_version`.
// Steht dort eine andere Zahl als die aktuelle Fassung, wird neu gewandelt und
// überschrieben. Das ist derselbe Gedanke wie beim Vorschaubild, nur dass
// dieses beim Hochladen entsteht und das PDF erst, wenn es jemand ansieht.
//
// ── Was das für die Platte heißt ────────────────────────────────────────────
//
// Ein PDF entsteht nur für Dokumente, die wirklich jemand angesehen hat, und
// es zählt nicht gegen das Kontingent: Es ist abgeleitet, niemand hat es
// hochgeladen. Beim endgültigen Löschen verschwindet es mit, und der GC kennt
// den Ordner – beides ist nötig, und beides einzeln geprüft.
// previewKey ist der Ablageort. Öffentlich, weil Papierkorb und GC ihn
// ebenfalls kennen müssen.
func PreviewKey(fileID string) string { return "previews/" + fileID + ".pdf" }
// PreviewPrefix ist der Ordner, den der GC durchgeht.
const PreviewPrefix = "previews"
// maxPreviewSource: Größere Dateien werden nicht gewandelt.
//
// Nicht aus Sparsamkeit: Ein Dokument dieser Größe beschäftigt den
// Office-Server minutenlang, und das auf einen Fingertipp hin. Wer so etwas
// ansehen will, bekommt den Editor angeboten – dort lädt es Stück für Stück.
const maxPreviewSource = 100 << 20
// Ein Riegel je Datei: Tippt jemand zweimal, wandelt der Server nicht zweimal.
//
// Ohne das ist es kein Fehler, nur Verschwendung – beide Läufe schreiben
// denselben Inhalt, und geschrieben wird atomar. Der Riegel spart die zweite
// Umwandlung, nicht die Richtigkeit.
var previewLocks sync.Map // fileID -> *sync.Mutex
func lockPreview(id string) func() {
m, _ := previewLocks.LoadOrStore(id, &sync.Mutex{})
mu := m.(*sync.Mutex)
mu.Lock()
return mu.Unlock
}
// GET /api/v1/files/{id}/preview.pdf
func (h *Handler) PreviewPDF(w http.ResponseWriter, r *http.Request) {
if !h.Enabled() || !h.client.CanConvert() {
response.Error(w, http.StatusNotImplemented, "this server cannot convert documents")
return
}
f, userID, ok := h.resolveSession(w, r)
if !ok {
return
}
if !Editable(f.Name) {
response.Error(w, http.StatusUnsupportedMediaType, "this file type has no office preview")
return
}
if f.Size > maxPreviewSource {
// 413 und nicht 500: Die Oberfläche unterscheidet daran, ob sie den
// Editor anbieten soll oder einen Fehler zeigt.
response.Error(w, http.StatusRequestEntityTooLarge,
"this document is too large to be shown as a PDF")
return
}
unlock := lockPreview(f.ID.String())
defer unlock()
key := PreviewKey(f.ID.String())
have, err := h.files.PreviewVersion(r.Context(), f.ID)
if err != nil && !errors.Is(err, files.ErrNotFound) {
response.Error(w, http.StatusInternalServerError, "database error")
return
}
if have == f.Version {
if rc, err := h.blobs.Get(r.Context(), key); err == nil {
defer rc.Close()
servePDF(w, f.Name, f.Version)
_, _ = io.Copy(w, rc)
return
}
// Vermerk vorhanden, Datei weg – etwa, weil der GC sie geholt hat oder
// jemand im Datenordner aufgeräumt hat. Kein Fehler, nur ein Grund,
// sie neu zu erzeugen.
log.Ctx(r.Context()).Info().Str("file_id", f.ID.String()).
Msg("office preview: vermerkt, aber nicht auf der Platte – wird neu gewandelt")
}
pdf, err := h.convert(r.Context(), f, userID)
if err != nil {
log.Ctx(r.Context()).Warn().Err(err).Str("file_id", f.ID.String()).
Msg("office preview: Umwandlung fehlgeschlagen")
response.Error(w, http.StatusBadGateway, "the document could not be converted")
return
}
// Erst der Blob, dann der Vermerk. Andersherum zeigte die Datenbank auf
// etwas, das es noch nicht gibt – und der nächste Abruf lieferte nichts,
// ohne es zu merken.
if _, _, err := h.blobs.Put(r.Context(), key, bytes.NewReader(pdf)); err != nil {
log.Ctx(r.Context()).Warn().Err(err).Msg("office preview: konnte nicht abgelegt werden")
} else if err := h.files.SetPreviewVersion(r.Context(), f.ID, f.Version); err != nil {
log.Ctx(r.Context()).Warn().Err(err).Msg("office preview: Vermerk fehlgeschlagen")
}
servePDF(w, f.Name, f.Version)
_, _ = w.Write(pdf)
}
// convert holt das PDF beim Office-Server – auf dem Weg, den der Anbieter
// versteht.
func (h *Handler) convert(ctx context.Context, f *files.File, userID uuid.UUID) ([]byte, error) {
ext := Extension(f.Name)
// Der Schlüssel muss sich mit dem Inhalt ändern: OnlyOffice speichert
// selbst danach zwischen und lieferte sonst nach einer Bearbeitung das
// alte Dokument aus.
key := fmt.Sprintf("%s-%d", f.ID.String(), f.Version)
switch h.client.Provider() {
case "collabora":
// Collabora bekommt die Bytes. Der Umweg über eine Adresse wäre hier
// keiner: Es müsste sie abrufen, also bräuchte es dieselbe
// Erreichbarkeit – und ein Token mehr in der Welt.
rc, err := h.blobs.Get(ctx, f.StorageKey)
if err != nil {
return nil, fmt.Errorf("read source: %w", err)
}
defer rc.Close()
return h.client.ConvertToPDF(ctx, ext, key, f.Name, "", rc)
default:
// OnlyOffice holt die Datei selbst – über denselben WOPI-Weg, den es
// zum Bearbeiten ohnehin geht. Das Token gilt kurz und nur für diese
// Datei; ein zweiter, offener Abholpunkt entsteht dadurch nicht.
token, _ := h.signer.Issue(f.ID, userID, false)
src := fmt.Sprintf("%s/wopi/files/%s/contents?access_token=%s",
h.wopiURL, f.ID, token)
return h.client.ConvertToPDF(ctx, ext, key, f.Name, src, nil)
}
}
func servePDF(w http.ResponseWriter, name string, version int) {
w.Header().Set("Content-Type", "application/pdf")
w.Header().Set("X-Content-Type-Options", "nosniff")
// Anzeigen, nicht herunterladen – und unter dem Namen des Dokuments.
w.Header().Set("Content-Disposition",
fmt.Sprintf(`inline; filename="%s.pdf"`, sanitizeFilename(name)))
// Privat: Das PDF trägt denselben Inhalt wie das Dokument und gehört in
// keinen gemeinsamen Zwischenspeicher. Die Fassung steht in der Adresse,
// der Browser holt es nach einer Änderung also von selbst neu.
w.Header().Set("Cache-Control", "private, max-age=3600")
w.Header().Set("X-Document-Version", fmt.Sprintf("%d", version))
}
// sanitizeFilename nimmt einem Dateinamen alles, was in einer Kopfzeile
// gefährlich wäre – Anführungszeichen und Zeilenumbrüche.
func sanitizeFilename(name string) string {
name = strings.NewReplacer(`"`, "", "\r", "", "\n", "", ";", "").Replace(name)
if i := strings.LastIndexByte(name, '.'); i > 0 {
name = name[:i]
}
if name == "" {
name = "document"
}
return name
}
// resolveSession löst eine Anfrage **aus dem Browser** auf – mit Sitzung, im
// Gegensatz zu `authorize`, das den WOPI-Weg des Office-Servers bedient.
//
// Getrennt gehalten, weil die beiden Wege verschiedene Berechtigungen haben
// und das nie durcheinandergeraten darf: Hier zählt die Anmeldung, dort ein
// Token für genau eine Datei.
func (h *Handler) resolveSession(w http.ResponseWriter, r *http.Request) (*files.File, uuid.UUID, bool) {
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
response.Error(w, http.StatusNotFound, "not found")
return nil, uuid.Nil, false
}
user := auth.UserFromContext(r.Context())
if user == nil {
response.Error(w, http.StatusUnauthorized, "not signed in")
return nil, uuid.Nil, false
}
// 404 auch bei fehlendem Zugriff: Ein „verboten" verriete, dass es die
// Datei gibt.
access, err := h.acl.ResolveFile(r.Context(), user.ID, id)
if err != nil {
response.Error(w, http.StatusNotFound, "not found")
return nil, uuid.Nil, false
}
f, err := h.files.GetFile(r.Context(), id, access.OwnerID)
if err != nil {
response.Error(w, http.StatusNotFound, "not found")
return nil, uuid.Nil, false
}
return f, user.ID, true
}
+18 -1
View File
@@ -113,6 +113,11 @@ type Config struct {
// OfficeInsecureTLS erlaubt ein selbstsigniertes Zertifikat am // OfficeInsecureTLS erlaubt ein selbstsigniertes Zertifikat am
// Office-Server. Nur für den Testaufbau auf demselben Rechner. // Office-Server. Nur für den Testaufbau auf demselben Rechner.
OfficeInsecureTLS bool OfficeInsecureTLS bool
// OfficeJWTSecret signiert Aufrufe an die eigene API von OnlyOffice und
// Euro-Office – gebraucht wird das für die Umwandlung nach PDF. Collabora
// kennt es nicht. Fehlt es, während der Server `JWT_ENABLED` hat,
// antwortet er auf jede Umwandlung mit `{"error":-8}`.
OfficeJWTSecret string
} }
// splitList zerlegt eine kommagetrennte Liste und wirft Leeres weg. // splitList zerlegt eine kommagetrennte Liste und wirft Leeres weg.
@@ -199,6 +204,7 @@ func Load() (*Config, error) {
OfficeProvider: strings.ToLower(strings.TrimSpace(getEnv("OFFICE_PROVIDER", ""))), OfficeProvider: strings.ToLower(strings.TrimSpace(getEnv("OFFICE_PROVIDER", ""))),
OfficeURL: strings.TrimRight(getEnv("OFFICE_URL", ""), "/"), OfficeURL: strings.TrimRight(getEnv("OFFICE_URL", ""), "/"),
OfficeJWTSecret: getEnv("OFFICE_JWT_SECRET", ""),
OfficePublicURL: strings.TrimRight(getEnv("OFFICE_PUBLIC_URL", ""), "/"), OfficePublicURL: strings.TrimRight(getEnv("OFFICE_PUBLIC_URL", ""), "/"),
HoneybarOrigin: strings.TrimRight(getEnv("HONEYBAR_ORIGIN", ""), "/"), HoneybarOrigin: strings.TrimRight(getEnv("HONEYBAR_ORIGIN", ""), "/"),
HoneybarKey: getEnv("HONEYBAR_KEY", ""), HoneybarKey: getEnv("HONEYBAR_KEY", ""),
@@ -216,10 +222,21 @@ func Load() (*Config, error) {
OIDCLinkByUsername: getEnv("OIDC_LINK_BY_USERNAME", "false") == "true", OIDCLinkByUsername: getEnv("OIDC_LINK_BY_USERNAME", "false") == "true",
} }
maxAge, err := strconv.Atoi(getEnv("SESSION_MAX_AGE", "86400")) // 30 Tage, und die Frist **gleitet**: Sie zählt ab der letzten Benutzung,
// nicht ab dem Anmelden (siehe auth.SessionStore.Touch). Vorher stand hier
// ein Tag ab Anmeldung – wer täglich arbeitete, flog trotzdem täglich
// heraus. Ein Ablauf, der den Anwesenden trifft und nicht den Abwesenden,
// erkauft keine Sicherheit, sondern nur Ärger.
//
// Kürzer ist jederzeit einstellbar; bei einem gemeinsam genutzten Rechner
// wäre das sinnvoll.
maxAge, err := strconv.Atoi(getEnv("SESSION_MAX_AGE", "2592000"))
if err != nil { if err != nil {
return nil, fmt.Errorf("invalid SESSION_MAX_AGE: %w", err) return nil, fmt.Errorf("invalid SESSION_MAX_AGE: %w", err)
} }
if maxAge <= 0 {
return nil, fmt.Errorf("SESSION_MAX_AGE must be greater than 0 (seconds)")
}
cfg.SessionMaxAge = maxAge cfg.SessionMaxAge = maxAge
if cfg.SessionSecret == "" { if cfg.SessionSecret == "" {
@@ -0,0 +1 @@
ALTER TABLE files DROP COLUMN IF EXISTS preview_version;
@@ -0,0 +1,9 @@
-- Die Fassung, aus der das zwischengespeicherte PDF entstanden ist.
--
-- Der Blob heißt `previews/<datei-id>.pdf` – **ohne** Fassungsnummer im
-- Namen. Damit kann es je Datei nie mehr als eine Vorschau geben: Eine neue
-- Fassung überschreibt die alte, statt eine zweite danebenzulegen. Ob die
-- vorhandene noch zum Inhalt passt, sagt diese Spalte.
--
-- 0 heißt "keine". Dateifassungen zählen ab 1.
ALTER TABLE files ADD COLUMN IF NOT EXISTS preview_version INT NOT NULL DEFAULT 0;
File renamed without changes.
@@ -1,13 +1,22 @@
# Aether – Entwicklung: aus dem Quelltext bauen. # Aether – Entwicklung: aus dem Quelltext bauen.
# #
# docker compose -f deploy/docker-compose.yml build app # docker compose -f build/docker-compose.yml build app
# docker compose -f deploy/docker-compose.yml up -d # docker compose -f build/docker-compose.yml up -d
# #
# Zum **Betreiben** ist docker-compose.example.yml im Wurzelverzeichnis # Zum **Betreiben** ist docker-compose.example.yml im Wurzelverzeichnis
# gedacht: schlanker und ohne Quelltext, es zieht das fertige Abbild. # gedacht: schlanker und ohne Quelltext, es zieht das fertige Abbild.
# #
# Die Einstellungen kommen aus build/.env – als Vorlage dient die eine
# .env.example im Wurzelverzeichnis: cp .env.example build/.env
#
# Erklärungen zu allem hier: README.md # Erklärungen zu allem hier: README.md
# Der Projektname steht hier fest und wird **nicht** aus dem Ordnernamen
# abgeleitet. Sonst hinge er daran, wie der Ordner gerade heißt: Beim
# Umbenennen von deploy/ nach build/ hätte Compose ein neues, leeres Projekt
# angelegt – mit einer neuen, leeren Datenbank.
name: aether
services: services:
app: app:
container_name: aether-app container_name: aether-app
@@ -15,7 +24,7 @@ services:
# Kontext ist das Wurzelverzeichnis – das Abbild braucht backend/ und # Kontext ist das Wurzelverzeichnis – das Abbild braucht backend/ und
# frontend/, nicht nur diesen Ordner. # frontend/, nicht nur diesen Ordner.
context: .. context: ..
dockerfile: deploy/Dockerfile dockerfile: build/Dockerfile
image: gitea.honeyy.duckdns.org/docker/aether:latest image: gitea.honeyy.duckdns.org/docker/aether:latest
restart: unless-stopped restart: unless-stopped
env_file: .env env_file: .env
@@ -85,6 +94,10 @@ services:
volumes: volumes:
postgres_data: postgres_data:
# Fester Name, aus demselben Grund. Er stammt noch aus der Zeit, als der
# Ordner „deploy" hieß – umbenennen hieße hier: neues Volume, leere
# Datenbank. Der unschöne Name ist der Preis dafür, die Daten zu behalten.
name: deploy_postgres_data
# "data" ist bewusst weg – die Dateien liegen unter DATA_PATH auf dem Wirt. # "data" ist bewusst weg – die Dateien liegen unter DATA_PATH auf dem Wirt.
# Die Datenbank bleibt ein benanntes Volume: An ihre Dateien geht man nicht # Die Datenbank bleibt ein benanntes Volume: An ihre Dateien geht man nicht
# von Hand, und Postgres mag fremde Besitzer nicht. # von Hand, und Postgres mag fremde Besitzer nicht.
+21
View File
@@ -273,6 +273,27 @@ Geplante Ideen (noch nicht umgesetzt):
- Glasmorphismus-Theme (transluzente Sidebar/Cards mit Blur-Effekt) - Glasmorphismus-Theme (transluzente Sidebar/Cards mit Blur-Effekt)
- Logo und App-Symbol bereits in Phase 4.5/D umgesetzt - Logo und App-Symbol bereits in Phase 4.5/D umgesetzt
- ✅ **Gleitende Sitzung** (`internal/auth/session.go`, 01.09.2026, Wunsch von Marc: „der Login speichert sich nicht"). Die Frist zählt ab der **letzten Benutzung** statt ab dem Anmelden; Vorgabe 30 Tage. Verlängert wird höchstens einmal pro Stunde – ohne diese Bremse trüge jede Antwort ein `Set-Cookie`, und beim Laden einer Dateiliste sind das dreißig.
- **Warum das keine Bequemlichkeit auf Kosten der Sicherheit ist:** Der alte Ablauf traf den *Anwesenden* und nicht den *Abwesenden* – wer täglich arbeitete, flog täglich heraus, wer das Konto vergaß, blieb genauso lange angemeldet. Ein Ablauf ab letzter Benutzung trifft genau den Fall, um den es geht.
- **Der Preis steht in der Doku, nicht nur im Kopf:** Ein aus einem fremden Browser kopiertes Cookie verlängert sich beim Benutzen mit. Es gibt keine Sitzungsliste auf dem Server, die man widerrufen könnte – das ist der Preis dafür, ohne Sitzungstabelle auszukommen. Alle Sitzungen beenden = `SESSION_SECRET` wechseln.
- ⚠️ **Dabei gefunden, war schon vorher drin:** `NewSessionStore` ersetzte `store.Options`, ohne `store.MaxAge()` zu rufen. Es gibt aber **zwei** Fristen – die im Cookie (der Browser wirft es weg) und die in der Signatur (der Server lehnt es ab). Nur die erste war gesetzt, die zweite stand auf gorillas Voreinstellung von 30 Tagen. Ein herauskopiertes Cookie blieb damit einen Monat gültig, obwohl `SESSION_MAX_AGE` auf einen Tag stand: Wer die Frist kürzte, um sie zu kürzen, hatte nichts gekürzt. Behoben und mit Test festgehalten – der Test ist gegengeprüft, ohne den Aufruf schlägt er fehl.
- **Bestehende `.env`-Werte bleiben unangetastet.** Wer die 30 Tage will, trägt sie selbst ein; die neue Vorgabe gilt nur für frische Installationen.
- ✅ **Office-Dokumente am Telefon als PDF ansehen** (`internal/office/convert.go` + `preview.go`, Migration 000020, 31.08.2026, Wunsch von Marc). Am Finger öffnet ein `.docx` **bei OnlyOffice** nicht mehr den Editor, sondern das gewandelte PDF im eigenen Betrachter; „Edit" daneben führt trotzdem hinein. Am Schreibtisch bleibt alles wie es war.
- **Bei Collabora bleibt der Editor.** Zuerst galt die Regel für beide Anbieter – falsch, und Marc hat es sofort gesehen: Seine Kritik betraf nur OnlyOffice. Collabora bringt für kleine Schirme eine eigene Ansicht mit, die taugt; dort wäre der Umweg über ein PDF ein Rückschritt. Der Anbieter steht deshalb in der öffentlichen Konfiguration (`office_provider`), und die Oberfläche entscheidet daran. Der Endpunkt selbst kann beides und ist mit beiden gemessen (Collabora 0,35 s, OnlyOffice 1,05 s).
- **Warum überhaupt:** Der Web-Editor ist eine vollständige Textverarbeitung. Menüband, Formatleisten und Eigenschaftenbereich sind nicht Beiwerk, sondern das Produkt – auf 400 Pixeln bleibt für das Dokument ein Rechteck. Marc hat Collabora **und** OnlyOffice ausprobiert, mit demselben Ergebnis; das ist der Grund, aus dem hier kein besserer Bedienmodus gesucht wurde, sondern ein anderes Werkzeug. Nextcloud löst es in seiner App genauso: ansehen statt bearbeiten.
- **Gewandelt wird von dem Server, der auch bearbeitet.** Wer OnlyOffice wegen der Treue zu MS-Office-Formaten nimmt, aber Collabora das PDF rechnen ließe, sähe unterwegs ein LibreOffice-Layout desselben Dokuments – zwei Geräte, zwei Umbrüche, ausgerechnet bei Unterlagen. Beide Wege sind deshalb gebaut: Collabora nimmt die Bytes (`/cool/convert-to/pdf`), OnlyOffice eine Adresse, holt die Datei selbst und antwortet mit einer Adresse (`/ConvertService.ashx`, HS256-signiert).
- **Je Datei gibt es genau ein PDF, und das ist keine Frage des Aufräumens, sondern des Schlüssels:** `previews/<id>.pdf` trägt die Fassung **nicht** im Namen. Eine neue Fassung überschreibt, statt danebenzulegen. Ob das Abgelegte noch passt, sagt `files.preview_version`. Gemessen: Nach dem erneuten Hochladen stand die Datei auf Fassung 2, `preview_version` auf 2, und im Ordner lag weiterhin genau eine Datei.
- **Der GC kennt den Ordner – und die Referenz.** Beides musste sein: ohne Präfix wächst er ewig, ohne Referenz löscht der GC das PDF nach einem Tag weg. Genau dieser zweite Fehler ist beim Logo und beim Favicon schon einmal passiert. Beide Richtungen einzeln geprüft (herrenloses PDF von vorgestern → weg; echtes → bleibt).
- **Die zurückgegebene Adresse ist nicht vertrauenswürdig.** OnlyOffice antwortet nicht mit dem PDF, sondern mit einer Adresse, die wir gleich abrufen – das ist die Stelle, an der aus einer Umwandlung ein Zugriff auf `169.254.169.254` würde. Der Wähler des Transports lässt ohnehin nur `OFFICE_ALLOWED_HOSTS` zu; die ausdrückliche Prüfung steht zusätzlich da und ist mit Test abgedeckt.
- **Zählt gegen kein Kontingent** (Entscheidung von Marc): Es ist abgeleitet, niemand hat es hochgeladen. Platte kostet es trotzdem – deshalb steht der Platzbedarf jetzt im Admin-Bereich neben der Bereinigung, zusammen mit Vorschau- und Profilbildern. Ein Ordner, der wächst, ohne dass ihn irgendwo jemand sieht, ist eine stille Überraschung.
- Gemessen an dieser Anlage: erster Abruf 1,05 s, zweiter 0,009 s. Fremde Kennung `404`, ohne Anmeldung `401`, Nicht-Office-Datei `415`, zu große Datei `413` (die Oberfläche bietet daraufhin den Editor an).
- ⏸️ **Im Papierkorb ansehen, was es war – vertagt** (26.08.2026, Wunsch von Marc: „erstmal noch nicht wichtig"). Die kleinen Vorschaubilder sind seit dem 26.08. wieder da (`GetThumbnail` sah nur in nicht-gelöschten Dateien nach). Gemeint ist der Schritt danach: eine Datei im Papierkorb **öffnen** und ansehen, bevor man „Delete forever" drückt.
- **Warum es überhaupt eine Idee ist:** Der Papierkorb ist die einzige Ansicht ohne Vorschau – ausgerechnet die, in der die Entscheidung unwiderruflich ist. Wer nach zwei Wochen aufräumt, sieht einen Namen wie `IMG_2481.jpg` und rät.
- **Wo es hängt:** an derselben Stelle wie eben das Vorschaubild. `Download` schlägt die Datei mit `is_deleted = false` nach (`handler.go:411`), der Vorschau-Dialog hängt außerdem an der Dateiliste, nicht an einem einzelnen Eintrag – er blättert zum nächsten Bild und kennt Umbenennen, Teilen, Löschen. Im Papierkorb wäre davon fast nichts sinnvoll.
- **Der Zuschnitt wäre deshalb enger:** nur ansehen, kein Blättern, keine Bearbeitung – und der Download-Pfad müsste für Gelöschtes gezielt geöffnet werden, nicht pauschal. Sonst ließe sich eine gelöschte Datei über ihre Kennung genauso herunterladen wie eine lebende, und der Papierkorb wäre kein Zwischenschritt mehr, sondern ein zweiter Ordner.
**OIDC-Durchgang (23.08.2026)** **OIDC-Durchgang (23.08.2026)**
Vor dem Produktivgang durchgesehen. Zwei Befunde, beide vorher unsichtbar: Vor dem Produktivgang durchgesehen. Zwei Befunde, beide vorher unsichtbar:
-124
View File
@@ -1,124 +0,0 @@
# Aether – alle Schalter. cp deploy/.env.example deploy/.env
#
# Zwei Fallen beim Bearbeiten:
# $ im Wert → als $$ schreiben, sonst schneidet Compose ab
# Kommentar hinter einem LEEREN Wert → der Kommentar wird zum Wert
# Erklärungen und Fallstricke: README.md, Abschnitt „Einstellungen"
#
# Für den reinen Betrieb genügt die kürzere .env.example im
# Wurzelverzeichnis – hier steht zusätzlich alles Optionale.
# ── App ───────────────────────────────────────────────────────────────────────
APP_ENV=production # development | production
APP_LISTEN_ADDR=:8080 # im Container; der Proxy leitet hierher
PUBLIC_URL=https://cloud.example.com
# IPs/CIDRs der Proxys, denen X-Forwarded-For/-Proto geglaubt wird.
TRUSTED_PROXIES=172.18.0.1
# ── Datenbank ─────────────────────────────────────────────────────────────────
# DB_HOST ist der Dienstname aus dem Compose, nicht localhost.
DB_HOST=postgres
DB_PORT=5432
DB_NAME=aether
DB_USER=aether
# Jedes Zeichen erlaubt – nur ein $ muss $$ heißen oder der Wert in
# 'einfache Anführungszeichen'. Sonst schneidet Compose lautlos ab. (README)
DB_PASSWORD=changeme_strong_password
# ── Sitzung ───────────────────────────────────────────────────────────────────
SESSION_SECRET=changeme_generate_with_openssl_rand_hex_32 # openssl rand -hex 32
SESSION_MAX_AGE=86400 # Sekunden
# ── Speicher ──────────────────────────────────────────────────────────────────
STORAGE_PATH=/data # im Container
TMP_PATH=/data/tmp
# Verzeichnis auf dem Wirt. Nur lesend anfassen – zum Hineinlegen: WebDAV.
DATA_PATH=/pfad/zu/aether-data
# `id -u` / `id -g` – sonst gehören die Dateien einem Container-Nutzer.
APP_UID=1000
APP_GID=1000
# Frühere Fassungen je Datei. Greift nur bei eingeschaltetem „Keep versions".
MAX_VERSIONS_KEPT=5
# Port auf dem Wirt. Im Container bleibt es 8080.
APP_PORT=8080
# Wer den Port erreichen darf. 127.0.0.1 = nur dieser Rechner (Vorgabe).
# 0.0.0.0 = das ganze Netz – dann unverschlüsselt.
APP_BIND=127.0.0.1
# ── Erster Start ──────────────────────────────────────────────────────────────
# Legt den Admin an. Danach ADMIN_PASSWORD leeren.
# Auch hier: ein $ muss $$ heißen (siehe DB_PASSWORD).
ADMIN_USERNAME=admin
ADMIN_PASSWORD=changeme_strong_admin_password
# ── OIDC (optional) ───────────────────────────────────────────────────────────
# Leerer OIDC_ISSUER = aus. OIDC_REDIRECT_URL muss exakt
# <PUBLIC_URL>/api/v1/auth/oidc/callback sein und beim Anbieter eingetragen.
# OIDC_ISSUER=https://your-idp.example.com # Adresse, nicht der Name
# OIDC_CLIENT_ID=aether
# OIDC_CLIENT_SECRET=
# OIDC_REDIRECT_URL=https://cloud.example.com/api/v1/auth/oidc/callback
# Bestehendes Konto verbinden statt ein zweites anzulegen.
# VOR der ersten Anmeldung setzen – danach ist es zu spät.
# OIDC_LINK_BY_USERNAME=true
# Beschriftung des Knopfes auf der Anmeldeseite.
# OIDC_BUTTON_LABEL=Anmelden mit PocketID
# ── HoneyOS-Leiste ────────────────────────────────────────────────────────────
# Ursprung, von dem die Leiste geladen wird. Wird an zwei Stellen gebraucht und
# kommt deshalb aus einer Quelle: die CSP erlaubt ihn (script-src +
# connect-src), und die Oberflaeche laedt das Skript von dort nach.
#
# Wirkt beim **Neustart** — kein Neubau noetig.
# Leer = keine Leiste, und die CSP bleibt so eng wie ohne sie.
HONEYBAR_ORIGIN=https://os.example.com
# Ausweis, mit dem sich die Leiste die App-Liste holt. Muss zu HONEYOS_APPS_KEY
# drueben passen. Nur noetig, wenn HoneyOS auf einer anderen Site liegt als
# Aether — dann schickt der Browser die HoneyOS-Sitzung nicht mit.
#
# Gleiche Site (os.beispiel.duckdns.org + cloud.beispiel.duckdns.org): kann leer
# bleiben, die Sitzung reicht.
HONEYBAR_KEY=
# ── Volltextsuche (OCR) ───────────────────────────────────────────────────────
# Standardmäßig an; fehlen ocrmypdf/tesseract, schaltet es sich selbst ab.
# OCR_ENABLED=false
# OCR_LANGUAGES=deu+eng # weitere brauchen ein apk-Paket im Dockerfile
# OCR_MAX_PAGES=50 # 0 = unbegrenzt; rund 1 Minute je Seite
# OCR_TIMEOUT=10m # Notbremse je Datei
# ── App-Umschalter ────────────────────────────────────────────────────────────
# Andere selbst gehostete Apps im Menü der Seitenleiste.
# Format: Name|URL|Emoji – mehrere durch Komma. Leer = kein Menü.
EXTERNAL_APPS=
# ── Office (optional) ─────────────────────────────────────────────────────────
# Leer = aus, auch wenn ein Office-Container läuft. Schnipsel: README.md
# collabora | onlyoffice (auch Euro-Office)
OFFICE_PROVIDER=
# Backend → Office. Collabora lauscht auf 9980, OnlyOffice/Euro-Office auf 80.
OFFICE_URL=
# Browser → Office; leer = wie OFFICE_URL
OFFICE_PUBLIC_URL=
# Office → Aether; leer = PUBLIC_URL.
# Weder localhost noch der veröffentlichte Port – beides scheitert.
OFFICE_WOPI_URL=
# Gegen SSRF; der Host aus OFFICE_URL zählt immer dazu.
OFFICE_ALLOWED_HOSTS=
# Nur OnlyOffice/Euro-Office, mind. 32 Zeichen.
OFFICE_JWT_SECRET=
OFFICE_INSECURE_TLS=false # nur für Tests mit selbstsigniertem Zertifikat
# Nur für den Collabora-Container aus deploy/docker-compose.yml:
OFFICE_ALIASGROUP=http://app:8080
OFFICE_SSL=false
OFFICE_SSL_TERMINATION=false # true nur, wenn davor wirklich HTTPS endet
OFFICE_PORT=9980
# ── Später ────────────────────────────────────────────────────────────────────
# MEILISEARCH_URL=http://meilisearch:7700
# MEILISEARCH_KEY=
# ── WebDAV ────────────────────────────────────────────────────────────────────
# Läuft unter /dav/, kein Schalter nötig. Der Proxy muss
# PROPFIND/MKCOL/MOVE/COPY durchreichen.
+1 -1
View File
@@ -16,7 +16,7 @@
# `docker compose stop office`. # `docker compose stop office`.
# #
# Hintergrund und Fallstricke: README.md # Hintergrund und Fallstricke: README.md
# Aus dem Quelltext bauen statt ziehen: deploy/docker-compose.yml # Aus dem Quelltext bauen statt ziehen: build/docker-compose.yml
services: services:
app: app:
+17
View File
@@ -225,6 +225,8 @@ export interface PublicConfig {
/** Gibt es einen Office-Server? Sonst bietet das Menue das Bearbeiten gar /** Gibt es einen Office-Server? Sonst bietet das Menue das Bearbeiten gar
* nicht erst an – ein Punkt, der immer scheitert, ist schlimmer als keiner. */ * nicht erst an – ein Punkt, der immer scheitert, ist schlimmer als keiner. */
office_enabled?: boolean office_enabled?: boolean
/** 'collabora' | 'onlyoffice' | '' – entscheidet über die Handy-Ansicht. */
office_provider?: string
/** Wie viele frühere Fassungen der Server aufhebt (MAX_VERSIONS_KEPT). */ /** Wie viele frühere Fassungen der Server aufhebt (MAX_VERSIONS_KEPT). */
max_versions_kept?: number max_versions_kept?: number
/** Beschriftung des SSO-Knopfes (OIDC_BUTTON_LABEL). Leer, wenn OIDC aus ist. */ /** Beschriftung des SSO-Knopfes (OIDC_BUTTON_LABEL). Leer, wenn OIDC aus ist. */
@@ -601,6 +603,17 @@ export const filesApi = {
* Gemessen: dreimal dieselbe Datei hochgeladen ergibt dreimal dieselbe ID * Gemessen: dreimal dieselbe Datei hochgeladen ergibt dreimal dieselbe ID
* mit den Fassungen 1, 2, 3. * mit den Fassungen 1, 2, 3.
*/ */
/**
* Ein Office-Dokument als PDF – zum Ansehen, ohne den Editor.
*
* Die Fassung steht in der Adresse: Nach einer Bearbeitung ändert sie sich,
* und der Browser holt das PDF von selbst neu, statt ein Dokument von
* gestern aus seinem Zwischenspeicher zu zeigen. Derselbe Kniff wie beim
* Vorschaubild.
*/
officePreview: (id: string, version?: number) =>
`/api/v1/files/${id}/preview.pdf${version ? `?v=${version}` : ''}`,
thumbnail: (id: string, version?: number | string) => thumbnail: (id: string, version?: number | string) =>
`/api/v1/files/${id}/thumbnail${version ? `?v=${encodeURIComponent(version)}` : ''}`, `/api/v1/files/${id}/thumbnail${version ? `?v=${encodeURIComponent(version)}` : ''}`,
@@ -838,6 +851,10 @@ export const adminApi = {
return request<{ entries: AuditEntry[]; total: number }>('GET', `/admin/audit${suffix}`) return request<{ entries: AuditEntry[]; total: number }>('GET', `/admin/audit${suffix}`)
}, },
listAuditActions: () => request<string[]>('GET', '/admin/audit/actions'), listAuditActions: () => request<string[]>('GET', '/admin/audit/actions'),
/** Platzbedarf der abgeleiteten Daten – zählt gegen kein Kontingent. */
derivedStorage: () =>
request<{ prefix: string; files: number; bytes: number }[]>('GET', '/admin/storage/derived'),
runCleanup: () => request<{ runCleanup: () => request<{
scanned: number; orphans: number; deleted: number scanned: number; orphans: number; deleted: number
freed_bytes: number; skipped_young: number freed_bytes: number; skipped_young: number
+42 -5
View File
@@ -1,6 +1,7 @@
import { lazy, Suspense, useEffect, useState, useSyncExternalStore } from 'react' import { lazy, Suspense, useEffect, useState, useSyncExternalStore } from 'react'
import { filesApi, APIError, type FileItem } from '../api/client' import { filesApi, APIError, type FileItem } from '../api/client'
import { fileKind } from './filetypes' import { fileKind } from './filetypes'
import { useBackClose } from '../hooks/useBackClose'
/** /**
* Nachgeladen, nicht mitgeliefert: PDF.js wiegt rund eine halbe Megabyte im * Nachgeladen, nicht mitgeliefert: PDF.js wiegt rund eine halbe Megabyte im
@@ -18,6 +19,17 @@ interface Props {
onClose: () => void onClose: () => void
/** Nach dem Speichern: Liste neu holen, damit Größe und Datum stimmen. */ /** Nach dem Speichern: Liste neu holen, damit Größe und Datum stimmen. */
onSaved?: () => void onSaved?: () => void
/**
* Ein Office-Dokument als PDF zeigen statt im Editor.
*
* Am Telefon der Normalfall: Der Editor ist dort eine vollständige
* Textverarbeitung, und vom Dokument bleibt zwischen seinen Leisten ein
* Rechteck übrig. Zum Nachsehen – und das ist unterwegs fast immer der
* Anlass – ist ein PDF im eigenen Betrachter das bessere Werkzeug.
*/
asOfficePdf?: boolean
/** „Trotzdem bearbeiten" – nur gesetzt, wenn es einen Editor gibt. */
onEdit?: () => void
/** /**
* Suchwort, mit dem die Datei gefunden wurde. Der PDF-Betrachter springt * Suchwort, mit dem die Datei gefunden wurde. Der PDF-Betrachter springt
* damit zur Fundstelle, statt auf Seite 1 zu beginnen – der Grund, aus dem * damit zur Fundstelle, statt auf Seite 1 zu beginnen – der Grund, aus dem
@@ -89,10 +101,12 @@ export function canPreview(file: FileItem): boolean {
return kindOf(file.mime_type, file.name) !== 'none' return kindOf(file.mime_type, file.name) !== 'none'
} }
export default function FilePreview({ file, onPrev, onNext, onClose, onSaved, searchTerm }: Props) { export default function FilePreview({ file, onPrev, onNext, onClose, onSaved, searchTerm, asOfficePdf, onEdit }: Props) {
const kind = kindOf(file.mime_type, file.name) // Ein Office-Dokument ist für den Browser sonst „nichts anzeigbares" – als
// gewandeltes PDF ist es eins.
const kind = asOfficePdf ? 'pdf' : kindOf(file.mime_type, file.name)
const narrow = useNarrow() const narrow = useNarrow()
const src = filesApi.inline(file.id) const src = asOfficePdf ? filesApi.officePreview(file.id, file.version) : filesApi.inline(file.id)
const [text, setText] = useState<string | null>(null) const [text, setText] = useState<string | null>(null)
const [tooBig, setTooBig] = useState(false) const [tooBig, setTooBig] = useState(false)
const [editing, setEditing] = useState(false) const [editing, setEditing] = useState(false)
@@ -163,11 +177,17 @@ export default function FilePreview({ file, onPrev, onNext, onClose, onSaved, se
} }
} }
function tryClose() { function tryClose(): boolean {
if (dirty && !confirm('Discard your changes?')) return if (dirty && !confirm('Discard your changes?')) return false
onClose() onClose()
return true
} }
// Zurück-Geste und Zurück-Taste schließen die Vorschau, statt die Anwendung
// zu verlassen. Wichtig in der installierten App: Dort gibt es keinen
// anderen Weg zurück.
useBackClose({ active: true, onClose: tryClose })
return ( return (
<div <div
onClick={tryClose} onClick={tryClose}
@@ -198,6 +218,23 @@ export default function FilePreview({ file, onPrev, onNext, onClose, onSaved, se
{file.name} {file.name}
</span> </span>
{/* Der Weg in den Editor bleibt offen – eine Ansicht ohne Ausweg wäre
eine Bevormundung. Er steht bewusst *neben* dem Namen und nicht
als Hauptknopf: Am Telefon ist Nachsehen der Anlass, Bearbeiten
die Ausnahme. */}
{asOfficePdf && onEdit && (
<button
onClick={onEdit}
style={{
padding: '6px 12px', borderRadius: 8, fontSize: 12, flexShrink: 0,
border: '1px solid var(--color-border)', background: 'transparent',
color: 'var(--color-text)', cursor: 'pointer', whiteSpace: 'nowrap',
}}
>
Edit
</button>
)}
{kind === 'text' && !tooBig && text !== null && ( {kind === 'text' && !tooBig && text !== null && (
editing ? ( editing ? (
<> <>
+1 -1
View File
@@ -34,7 +34,7 @@ function FolderIcon({ size = 24, group = false }: { size?: number; group?: boole
* nicht zweimal auf denselben Pixel tippt. Der Doppelklick ist eine * nicht zweimal auf denselben Pixel tippt. Der Doppelklick ist eine
* Mausgeste; am Finger ist er nie angekommen. * Mausgeste; am Finger ist er nie angekommen.
*/ */
function useCoarsePointer(): boolean { export function useCoarsePointer(): boolean {
const [coarse, setCoarse] = useState( const [coarse, setCoarse] = useState(
() => typeof matchMedia === 'function' && matchMedia('(pointer: coarse)').matches, () => typeof matchMedia === 'function' && matchMedia('(pointer: coarse)').matches,
) )
+7
View File
@@ -1,5 +1,6 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { officeApi, type FileItem, type OfficeSession } from '../api/client' import { officeApi, type FileItem, type OfficeSession } from '../api/client'
import { useBackClose } from '../hooks/useBackClose'
/** /**
* Ein Dokument im Office-Server bearbeiten. * Ein Dokument im Office-Server bearbeiten.
@@ -41,6 +42,12 @@ export default function OfficeEditor({ file, onClose }: {
// ein Schritt, der nichts entscheidet. // ein Schritt, der nichts entscheidet.
useEffect(() => { if (session) formRef.current?.submit() }, [session]) useEffect(() => { if (session) formRef.current?.submit() }, [session])
// Die Zurück-Geste schließt das Dokument. Auf dem Telefon ist sie die
// gewohnte Bewegung, und Collabora blendet seinen eigenen Schließen-Knopf
// auf kleinen Schirmen aus – ohne das käme man aus dem Editor nur über die
// Kopfzeile heraus.
useBackClose({ active: true, onClose })
useEffect(() => { useEffect(() => {
function onKey(e: KeyboardEvent) { function onKey(e: KeyboardEvent) {
// Escape schließt nur, solange der Rahmen nicht den Tastendruck hat – // Escape schließt nur, solange der Rahmen nicht den Tastendruck hat –
+159 -2
View File
@@ -39,9 +39,26 @@ pdfjs.GlobalWorkerOptions.workerSrc = workerUrl
*/ */
const standardFontDataUrl = '/pdfjs/standard_fonts/' const standardFontDataUrl = '/pdfjs/standard_fonts/'
/** Zoomstufen des Knopfes. „Fit" liegt außerhalb und wird gerechnet. */ /** Zoomstufen der **Knöpfe**. „Fit" liegt außerhalb und wird gerechnet. */
const zoomSteps = [0.5, 0.75, 1, 1.25, 1.5, 2, 3] const zoomSteps = [0.5, 0.75, 1, 1.25, 1.5, 2, 3]
/**
* Zoomschritt für Finger und Mausrad: **fünf Prozentpunkte**, auf runde
* Zahlen gerastet – 65, 70, 75, 80 …
*
* Zuerst war es ein fester Faktor (fünf Prozent *vom aktuellen Wert*).
* Rechnerisch ist das die sauberere Größe: Sie fühlt sich unten wie oben
* gleich an. Abzulesen war sie aber nicht – die Anzeige sprang um drei
* Punkte, dann um vier, dann um sechs. Wer eine Zahl sieht, erwartet ein
* Raster, und das hier ist eines.
*
* Der Preis steht dazu: Bei 400 % ist ein Schritt gut ein Prozent, bei 25 %
* ein Fünftel. Das gleicht die Geste aus, nicht die Rasterung – siehe unten.
*/
const zoomStepPercent = 5
const minScale = 0.25
const maxScale = 4
/** /**
* Innenabstand der Seitenliste. Am Telefon fast keiner: Dort ist die Breite * Innenabstand der Seitenliste. Am Telefon fast keiner: Dort ist die Breite
* das knappe Gut, und ein 16-Pixel-Rand links und rechts kostet zusammen mehr * das knappe Gut, und ein 16-Pixel-Rand links und rechts kostet zusammen mehr
@@ -603,6 +620,11 @@ export default function PdfViewer({ url, fileId, searchTerm }: Props) {
* anspringt. * anspringt.
*/ */
const keepAnchor = useRef<{ px: number; py: number; cx: number; cy: number; from: number } | null>(null) const keepAnchor = useRef<{ px: number; py: number; cx: number; cy: number; from: number } | null>(null)
/** Fingerabstand, gegen den die Zoomgeste misst. Null heißt: keine Geste. */
const pinchBase = useRef(0)
/** Der aktuelle Maßstab, aus einem Zuhörer heraus lesbar. */
const scaleRef = useRef(effScale)
scaleRef.current = effScale
useEffect(() => { useEffect(() => {
const anchor = keepAnchor.current const anchor = keepAnchor.current
if (anchor) { if (anchor) {
@@ -628,6 +650,30 @@ export default function PdfViewer({ url, fileId, searchTerm }: Props) {
: [...zoomSteps].reverse().find(s => s < now - 0.001) : [...zoomSteps].reverse().find(s => s < now - 0.001)
} }
/**
* Die nächste runde Fünferzahl, für Finger und Mausrad. Undefined am
* Anschlag.
*
* Gerechnet wird in Prozentpunkten und nicht in Bruchteilen: Der Nutzer
* liest Prozente, und ein Raster, das nur *fast* auf 70 trifft, ist keins.
* Aus einer krummen Ausgangsgröße – „einpassen" ergibt zum Beispiel 65,3 %
* – führt der erste Schritt auf die nächste runde Zahl, danach geht es in
* Fünfern weiter.
*/
function nextFine(now: number, dir: 1 | -1): number | undefined {
// **Gerundet, bevor gerechnet wird.** 1,1 × 100 ergibt in Fließkomma
// 110.00000000000001, und daraus wird die „nächste kleinere Fünferzahl"
// wieder 110 – der Zoom blieb dann stehen. Gemessen: Beim Zuziehen kam
// genau eine Stufe, danach nichts mehr.
const p = Math.round(now * 100 * 1e6) / 1e6
const step = zoomStepPercent
const raw = dir > 0
? Math.floor(p / step + 1) * step
: Math.ceil(p / step - 1) * step
const next = Math.min(maxScale * 100, Math.max(minScale * 100, raw)) / 100
return Math.abs(next - now) < 0.001 ? undefined : next
}
function zoom(dir: 1 | -1) { function zoom(dir: 1 | -1) {
const next = nextStep(effScale, dir) const next = nextStep(effScale, dir)
if (next) { keepPage.current = current; setScale(next) } if (next) { keepPage.current = current; setScale(next) }
@@ -651,7 +697,7 @@ export default function PdfViewer({ url, fileId, searchTerm }: Props) {
const root2 = scrollRef.current const root2 = scrollRef.current
if (!root2) return if (!root2) return
const dir: 1 | -1 = e.deltaY < 0 ? 1 : -1 const dir: 1 | -1 = e.deltaY < 0 ? 1 : -1
const next = nextStep(effScale, dir) const next = nextFine(effScale, dir)
if (!next) return if (!next) return
const r = root2.getBoundingClientRect() const r = root2.getBoundingClientRect()
const cx = e.clientX - r.left const cx = e.clientX - r.left
@@ -666,6 +712,113 @@ export default function PdfViewer({ url, fileId, searchTerm }: Props) {
return () => root.removeEventListener('wheel', onWheel) return () => root.removeEventListener('wheel', onWheel)
}, [effScale]) }, [effScale])
/**
* Zwei Finger zoomen das Dokument – nicht die Seite.
*
* Bisher vergrößerte die Zusammenziehgeste die **Anwendung**: Leiste,
* Knöpfe und Suchfeld wuchsen mit, das PDF blieb so grob wie vorher, und
* hinterher stand alles verschoben. Das ist der Grund, aus dem am Telefon
* bisher nur die Knöpfe blieben.
*
* Zwei Teile gehören dazu, und einer allein genügt nicht: `touch-action`
* unten am Behälter nimmt dem Browser die eigene Zoomgeste (Ziehen mit
* einem Finger bleibt erlaubt), und dieser Zuhörer setzt an ihre Stelle
* unsere Stufen.
*
* **In Zwanzigsteln, nicht stufenlos.** Der Zoom rastet auf fünf Prozent
* des aktuellen Werts, sobald der Fingerabstand um ebenso viel gewachsen
* oder geschrumpft ist. Danach zählt der neue Abstand als Anfang, eine
* lange Geste geht also über viele Stufen weiter.
*
* Dass daraus keine Ruckelei wird, liegt nicht an dieser Stelle: Die
* Zeichenschleife bricht eine laufende Darstellung ab, sobald sich der
* Maßstab ändert. Von zwanzig Stufen einer Geste wird also nur die letzte
* fertig gemalt.
*
* Die Leiter der Knöpfe (0,5 / 0,75 / 1 / …) wäre hier zu grob: Ein
* Viertelsprung schießt über die Größe hinaus, die man mit den Fingern
* gerade sucht.
*/
useEffect(() => {
const root = scrollRef.current
if (!root) return
function distance(t: TouchList) {
const dx = t[0].clientX - t[1].clientX
const dy = t[0].clientY - t[1].clientY
return Math.hypot(dx, dy)
}
function onStart(e: TouchEvent) {
pinchBase.current = e.touches.length === 2 ? distance(e.touches) : 0
}
function onMove(e: TouchEvent) {
const base = pinchBase.current
if (e.touches.length !== 2 || !base) return
// Verhindert, dass die Geste nebenbei die Liste weiterschiebt.
e.preventDefault()
const now = distance(e.touches)
const ratio = now / base
const dir: 1 | -1 | 0 = ratio > 1 ? 1 : ratio < 1 ? -1 : 0
if (!dir) return
const from = scaleRef.current
const next = nextFine(from, dir)
if (!next) return
/**
* **Die Finger geben den Maßstab vor, nicht eine feste Schwelle.**
*
* Fünf Prozentpunkte sind bei 25 % ein Fünftel und bei 400 % ein
* Hundertstel. Eine feste Schwelle wäre deshalb an einem Ende zu träge
* und am anderen zu nervös: Von 100 auf 400 zu ziehen dauerte ewig,
* unten spränge es bei der kleinsten Bewegung.
*
* Verlangt wird stattdessen genau die Bewegung, die dem nächsten
* Schritt entspricht – zwei Prozent Fingerweg als Untergrenze, damit
* ein Zittern in der Hand nicht schon zoomt.
*/
const needed = next / from
if (dir > 0 && ratio < Math.max(1.02, needed)) return
if (dir < 0 && ratio > Math.min(0.98, needed)) return
pinchBase.current = now
const root2 = scrollRef.current
if (!root2) return
// Festgehalten wird die Mitte zwischen den Fingern – dieselbe Regel wie
// beim Mausrad, nur dass der Zeiger hier aus zwei Punkten entsteht.
const r = root2.getBoundingClientRect()
const cx = (e.touches[0].clientX + e.touches[1].clientX) / 2 - r.left
const cy = (e.touches[0].clientY + e.touches[1].clientY) / 2 - r.top
keepAnchor.current = {
px: root2.scrollLeft + cx, py: root2.scrollTop + cy,
cx, cy, from,
}
setScale(next)
}
function onEnd(e: TouchEvent) {
if (e.touches.length < 2) pinchBase.current = 0
}
// Nicht passiv: Ein passiver Zuhörer dürfte `preventDefault` nicht rufen.
root.addEventListener('touchstart', onStart, { passive: true })
root.addEventListener('touchmove', onMove, { passive: false })
root.addEventListener('touchend', onEnd, { passive: true })
root.addEventListener('touchcancel', onEnd, { passive: true })
return () => {
root.removeEventListener('touchstart', onStart)
root.removeEventListener('touchmove', onMove)
root.removeEventListener('touchend', onEnd)
root.removeEventListener('touchcancel', onEnd)
}
// Ohne Abhängigkeiten: Der Zuhörer meldet sich **einmal** an. Hinge er am
// Maßstab, meldete er sich nach jeder Stufe neu an – mitten in der Geste,
// und der gemerkte Fingerabstand wäre weg. Gemessen: Eine lange Geste kam
// dann über genau eine Stufe nicht hinaus.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const pages = useMemo(() => sizes.map((_, i) => i + 1), [sizes]) const pages = useMemo(() => sizes.map((_, i) => i + 1), [sizes])
/** Verkleinern, Prozentzahl (zugleich „einpassen"), Vergrößern. Einmal /** Verkleinern, Prozentzahl (zugleich „einpassen"), Vergrößern. Einmal
@@ -880,6 +1033,10 @@ export default function PdfViewer({ url, fileId, searchTerm }: Props) {
// Auf dem Handy zieht man das Dokument mit dem Finger; die // Auf dem Handy zieht man das Dokument mit dem Finger; die
// Vorschau darunter darf dabei nicht mitwandern. // Vorschau darunter darf dabei nicht mitwandern.
overscrollBehavior: 'contain', overscrollBehavior: 'contain',
// Ziehen ja, Zoomen nein: Die Zusammenziehgeste gehört uns (siehe
// den Zuhörer oben), sonst vergrößerte der Browser die ganze Seite
// samt Leiste und Knöpfen.
touchAction: 'pan-x pan-y',
}} }}
> >
{!doc && ( {!doc && (
+9
View File
@@ -3,6 +3,7 @@ import { filesApi } from '../api/client'
import type { FolderItem, User } from '../api/client' import type { FolderItem, User } from '../api/client'
import { useNavHistory, type View } from '../hooks/useNavHistory' import { useNavHistory, type View } from '../hooks/useNavHistory'
import { useEdgeSwipe } from '../hooks/useEdgeSwipe' import { useEdgeSwipe } from '../hooks/useEdgeSwipe'
import { useBackClose } from '../hooks/useBackClose'
import Sidebar from './Sidebar' import Sidebar from './Sidebar'
import FileBrowser from '../pages/FileBrowser' import FileBrowser from '../pages/FileBrowser'
import TrashPage from '../pages/TrashPage' import TrashPage from '../pages/TrashPage'
@@ -34,6 +35,14 @@ export default function Shell({ user, onLogout, onUserChanged }: Props) {
onOpen: useCallback(() => setSidebarOpen(true), []), onOpen: useCallback(() => setSidebarOpen(true), []),
onClose: useCallback(() => setSidebarOpen(false), []), onClose: useCallback(() => setSidebarOpen(false), []),
}) })
// Ist die Leiste am Telefon aufgeschoben, holt die Zurück-Geste sie
// zurück – sie ist dann das Oberste auf dem Schirm, und genau darauf zielt
// die Bewegung.
useBackClose({
active: sidebarOpen,
onClose: useCallback(() => { setSidebarOpen(false) }, []),
})
// Hochgezählt, wenn etwas auf den Papierkorb gezogen wurde – die Dateiliste // Hochgezählt, wenn etwas auf den Papierkorb gezogen wurde – die Dateiliste
// hängt daran und holt sich daraufhin neu. // hängt daran und holt sich daraufhin neu.
const [trashBump, setTrashBump] = useState(0) const [trashBump, setTrashBump] = useState(0)
+70 -1
View File
@@ -1,3 +1,5 @@
import { useEffect, useRef } from 'react'
interface UploadItem { interface UploadItem {
id: string id: string
name: string name: string
@@ -13,12 +15,68 @@ interface Props {
export type { UploadItem } export type { UploadItem }
/**
* Wie lange eine fertige Meldung stehen bleibt.
*
* Lang genug, um sie im Vorbeigehen zu lesen, kurz genug, dass beim Hochladen
* vieler Dateien nicht der halbe Bildschirm zuwächst.
*/
const dismissAfter = 4000
/**
* So viele Meldungen zeigen wir gleichzeitig, der Rest wird gezählt.
*
* Bei fünfzig Dateien auf einmal wäre die Liste sonst länger als das Fenster –
* und das gilt schon während des Hochladens, nicht erst danach.
*/
const maxVisible = 4
export default function UploadQueue({ items, onDismiss }: Props) { export default function UploadQueue({ items, onDismiss }: Props) {
// Der Aufrufer gibt bei jedem Rendern eine neue Funktion herein. Über eine
// Referenz gelesen, muss der Effekt darauf nicht erneut laufen – sonst
// begännen die Fristen bei jedem Fortschrittsschritt von vorn.
const dismiss = useRef(onDismiss)
dismiss.current = onDismiss
const timers = useRef(new Map<string, ReturnType<typeof setTimeout>>())
useEffect(() => {
for (const item of items) {
// Fehler bleiben stehen. Sie sind das Einzige, was hier eine Auskunft
// trägt, die man sonst nirgends wiederfindet – die verschwindet nicht
// von selbst.
if (item.status !== 'done') continue
if (timers.current.has(item.id)) continue
timers.current.set(item.id, setTimeout(() => {
timers.current.delete(item.id)
dismiss.current(item.id)
}, dismissAfter))
}
// Aufräumen, wenn eine Meldung von Hand weggeklickt wurde: Ihre Frist läuft
// sonst weiter und rührte später an eine Kennung, die es nicht mehr gibt.
const live = new Set(items.map(i => i.id))
for (const [id, timer] of timers.current) {
if (!live.has(id)) { clearTimeout(timer); timers.current.delete(id) }
}
}, [items])
// Beim Verlassen der Seite alle Fristen abräumen.
const all = timers.current
useEffect(() => () => { for (const t of all.values()) clearTimeout(t) }, [all])
if (items.length === 0) return null if (items.length === 0) return null
// Fehler zuerst: Wenn mehr Meldungen anstehen als Platz ist, darf gerade die
// eine gescheiterte Datei nicht diejenige sein, die unter den Tisch fällt.
const failed = items.filter(i => i.status === 'error')
const rest = items.filter(i => i.status !== 'error')
const visible = [...failed, ...rest].slice(0, maxVisible)
const hidden = items.length - visible.length
return ( return (
<div className="fixed bottom-4 right-4 w-72 space-y-2 z-40"> <div className="fixed bottom-4 right-4 w-72 space-y-2 z-40">
{items.map(item => ( {visible.map(item => (
<div <div
key={item.id} key={item.id}
className="rounded-xl p-3 text-sm shadow-lg border" className="rounded-xl p-3 text-sm shadow-lg border"
@@ -52,6 +110,17 @@ export default function UploadQueue({ items, onDismiss }: Props) {
)} )}
</div> </div>
))} ))}
{hidden > 0 && (
<div
className="rounded-xl px-3 py-2 text-xs shadow-lg border text-center"
style={{
background: 'var(--color-surface)', borderColor: 'var(--color-border)',
color: 'var(--color-text-muted)',
}}>
+{hidden} more
</div>
)}
</div> </div>
) )
} }
+12 -1
View File
@@ -25,6 +25,14 @@ interface AppSettingsValue {
globalColors: { primary?: string; accent?: string } globalColors: { primary?: string; accent?: string }
/** Gibt es einen Office-Server? Steuert nur, ob der Menuepunkt erscheint. */ /** Gibt es einen Office-Server? Steuert nur, ob der Menuepunkt erscheint. */
officeEnabled: boolean officeEnabled: boolean
/**
* Welcher Anbieter – 'collabora', 'onlyoffice' oder leer.
*
* Die Oberfläche braucht das, weil sich die beiden am Telefon verschieden
* verhalten: Collabora hat eine eigene Handy-Ansicht, die taugt; OnlyOffice
* baut dort dieselbe Oberfläche auf wie am PC.
*/
officeProvider: string
/** Wie viele frühere Fassungen der Server aufhebt. Kommt vom Server, damit /** Wie viele frühere Fassungen der Server aufhebt. Kommt vom Server, damit
* der Dialog nicht eine andere Zahl nennt als die, die angewendet wird. */ * der Dialog nicht eine andere Zahl nennt als die, die angewendet wird. */
maxVersionsKept: number maxVersionsKept: number
@@ -34,6 +42,7 @@ interface AppSettingsValue {
const Ctx = createContext<AppSettingsValue>({ const Ctx = createContext<AppSettingsValue>({
cloudName: 'Aether', cloudName: 'Aether',
officeEnabled: false, officeEnabled: false,
officeProvider: '',
maxVersionsKept: 5, maxVersionsKept: 5,
logoUrl: '', logoUrl: '',
faviconUrl: '', faviconUrl: '',
@@ -89,6 +98,7 @@ export function AppSettingsProvider({ children }: { children: ReactNode }) {
const [appIconUrl, setAppIconUrl] = useState('') const [appIconUrl, setAppIconUrl] = useState('')
const [globalColors, setGlobalColors] = useState<{ primary?: string; accent?: string }>({}) const [globalColors, setGlobalColors] = useState<{ primary?: string; accent?: string }>({})
const [officeEnabled, setOfficeEnabled] = useState(false) const [officeEnabled, setOfficeEnabled] = useState(false)
const [officeProvider, setOfficeProvider] = useState('')
const [maxVersionsKept, setMaxVersionsKept] = useState(5) const [maxVersionsKept, setMaxVersionsKept] = useState(5)
const refresh = useCallback(async () => { const refresh = useCallback(async () => {
@@ -101,6 +111,7 @@ export function AppSettingsProvider({ children }: { children: ReactNode }) {
applyFavicon(cfg.favicon_url ?? '', cfg.apple_touch_icon_url ?? '') applyFavicon(cfg.favicon_url ?? '', cfg.apple_touch_icon_url ?? '')
setGlobalColors({ primary: cfg.primary_color, accent: cfg.accent_color }) setGlobalColors({ primary: cfg.primary_color, accent: cfg.accent_color })
setOfficeEnabled(!!cfg.office_enabled) setOfficeEnabled(!!cfg.office_enabled)
setOfficeProvider(cfg.office_provider ?? '')
if (typeof cfg.max_versions_kept === 'number' && cfg.max_versions_kept > 0) { if (typeof cfg.max_versions_kept === 'number' && cfg.max_versions_kept > 0) {
setMaxVersionsKept(cfg.max_versions_kept) setMaxVersionsKept(cfg.max_versions_kept)
} }
@@ -116,7 +127,7 @@ export function AppSettingsProvider({ children }: { children: ReactNode }) {
useEffect(() => { void refresh() }, [refresh]) useEffect(() => { void refresh() }, [refresh])
return ( return (
<Ctx.Provider value={{ cloudName, logoUrl, faviconUrl, appIconUrl, globalColors, officeEnabled, maxVersionsKept, refresh }}>{children}</Ctx.Provider> <Ctx.Provider value={{ cloudName, logoUrl, faviconUrl, appIconUrl, globalColors, officeEnabled, officeProvider, maxVersionsKept, refresh }}>{children}</Ctx.Provider>
) )
} }
+90
View File
@@ -0,0 +1,90 @@
import { useEffect, useRef } from 'react'
/**
* Die Zurück-Taste schließt die oberste bildschirmfüllende Ansicht, statt die
* Anwendung zu verlassen.
*
* Auf einem Telefon ist „zurück" die Geste vom Rand, und in der installierten
* App ist sie der einzige Weg nach draußen – ohne Adresszeile, ohne Reiter.
* Wer ein Bild ansieht und zurückwischt, meint das Bild. Bisher fiel er aus
* der Anwendung, weil Vorschau und Editor im Verlauf des Browsers gar nicht
* vorkamen: Der einzige Eintrag war die Dateiliste, und der davor war der
* Startbildschirm.
*
* Der Kniff ist ein **leerer Eintrag** im Verlauf, der beim Öffnen entsteht.
* Die Adresse bleibt dabei gleich – die Vorschau ist kein Ort, auf den ein
* Lesezeichen zeigen soll. Zurück verbraucht diesen Eintrag, wir schließen,
* und der Ort darunter ist unverändert derselbe.
*
* **Der Stapel ist nötig, nicht schön:** Liegt der Office-Editor über der
* Vorschau, hören beide auf dieselbe Zurück-Taste. Ohne Rangfolge schlössen
* sich beide auf einmal. Deshalb weiß jeder Eintrag, der wievielte er ist,
* und nur der oberste reagiert.
*/
/** Kennungen der offenen Ansichten, unterste zuerst. */
let stack: number[] = []
let seq = 0
/** Wir merken uns unseren Eintrag im State des Browsers, um ihn beim
* Zurückkommen wiederzuerkennen. */
const stateKey = 'aether:overlay'
interface Options {
/** Ist die Ansicht offen? Der Eintrag entsteht beim Wechsel auf `true`. */
active: boolean
/**
* Schließen. Gibt die Funktion `false` zurück, bleibt die Ansicht offen –
* dann legen wir den Eintrag wieder an, sonst führte die nächste
* Zurück-Geste aus der Anwendung heraus. Das ist der Fall „ungespeicherte
* Änderungen verwerfen?" mit „Abbrechen".
*/
onClose: () => boolean | void
}
export function useBackClose({ active, onClose }: Options) {
// Über eine Referenz gelesen: Der Aufrufer gibt bei jedem Rendern eine neue
// Funktion herein, und der Eintrag darf davon nicht neu entstehen.
const close = useRef(onClose)
close.current = onClose
useEffect(() => {
if (!active) return
const id = ++seq
stack.push(id)
// Den bisherigen State mitnehmen: Darin steckt die Ansicht der Anwendung
// (siehe useNavHistory). Ginge er verloren, stünde man nach dem
// Schließen in der Wurzel statt im Ordner.
window.history.pushState({ ...window.history.state, [stateKey]: id }, '')
function push() {
window.history.pushState({ ...window.history.state, [stateKey]: id }, '')
}
function onPop() {
// Nur der oberste reagiert. Ein tiefer liegender Eintrag hat seinen
// eigenen Zurück-Schritt noch vor sich.
if (stack.at(-1) !== id) return
stack = stack.filter(x => x !== id)
if (close.current() === false) {
stack.push(id)
push()
}
}
window.addEventListener('popstate', onPop)
return () => {
window.removeEventListener('popstate', onPop)
// Geschlossen wurde über einen Knopf oder die Escape-Taste: Unser
// Eintrag steht noch im Verlauf und muss weg, sonst bräuchte die
// Zurück-Geste danach einen Schritt ins Leere.
if (stack.at(-1) === id) {
stack = stack.filter(x => x !== id)
window.history.back()
} else {
stack = stack.filter(x => x !== id)
}
}
}, [active])
}
+34 -4
View File
@@ -7,7 +7,7 @@ import {
import Breadcrumb from '../components/Breadcrumb' import Breadcrumb from '../components/Breadcrumb'
import SearchBox from '../components/SearchBox' import SearchBox from '../components/SearchBox'
import PageHeader from '../components/PageHeader' import PageHeader from '../components/PageHeader'
import GridView, { ListView } from '../components/FileViews' import GridView, { ListView, useCoarsePointer } from '../components/FileViews'
import FilePreview, { canPreview } from '../components/FilePreview' import FilePreview, { canPreview } from '../components/FilePreview'
import ContextMenu, { type MenuItem } from '../components/ContextMenu' import ContextMenu, { type MenuItem } from '../components/ContextMenu'
import UploadQueue, { type UploadItem } from '../components/UploadQueue' import UploadQueue, { type UploadItem } from '../components/UploadQueue'
@@ -149,9 +149,14 @@ export default function FileBrowser({
const [viewMode, setViewMode] = useState<ViewMode>(() => loadPref('aether:view', 'grid')) const [viewMode, setViewMode] = useState<ViewMode>(() => loadPref('aether:view', 'grid'))
const [sort, setSort] = useState<Sort>(() => loadPref('aether:sort', 'name-asc')) const [sort, setSort] = useState<Sort>(() => loadPref('aether:sort', 'name-asc'))
const [preview, setPreview] = useState<FileItem | null>(null) const [preview, setPreview] = useState<FileItem | null>(null)
/** Office-Dokument als PDF – am Telefon der übliche Weg hinein. */
const [officePreview, setOfficePreview] = useState<FileItem | null>(null)
/** Datei, die gerade im Office-Server bearbeitet wird. */ /** Datei, die gerade im Office-Server bearbeitet wird. */
const [editing, setEditing] = useState<FileItem | null>(null) const [editing, setEditing] = useState<FileItem | null>(null)
const { officeEnabled } = useAppSettings() const { officeEnabled, officeProvider } = useAppSettings()
/** Finger oder Maus? Entscheidet, ob ein Office-Dokument als PDF oder im
* Editor aufgeht. */
const touchDevice = useCoarsePointer()
/** Datei, zu der gerade aus der Suche gesprungen wurde – siehe openHitFile. /** Datei, zu der gerade aus der Suche gesprungen wurde – siehe openHitFile.
* Die Markierung verschwindet von selbst; ein Rahmen, der bleibt, sähe * Die Markierung verschwindet von selbst; ein Rahmen, der bleibt, sähe
@@ -310,8 +315,24 @@ export default function FileBrowser({
* ansieht; heruntergeladen wird, was der Browser nicht zeigen kann. * ansieht; heruntergeladen wird, was der Browser nicht zeigen kann.
*/ */
function openFile(file: FileItem) { function openFile(file: FileItem) {
if (officeEnabled && isOfficeFile(file.name)) setEditing(file) if (officeEnabled && isOfficeFile(file.name)) {
else if (canPreview(file)) setPreview(file) // **Nur bei OnlyOffice** öffnet ein Dokument am Telefon als PDF.
//
// Der Unterschied ist gemessen, nicht vermutet: Collabora bringt eine
// eigene Handy-Ansicht mit, die taugt – dort ist der Editor der
// richtige Weg. OnlyOffice baut auf dem Telefon dieselbe Oberfläche
// auf wie am PC; zwischen Menüband, Formatleisten und
// Eigenschaftenbereich bleibt vom Dokument ein Rechteck übrig, und
// genau das war der Anlass für die PDF-Ansicht.
//
// Die Entscheidung fällt am **Zeigegerät**, nicht an der Fensterbreite:
// Ein schmales Fenster auf dem Schreibtisch ist immer noch eine Maus
// und eine Tastatur, und dort will man bearbeiten.
if (touchDevice && officeProvider === 'onlyoffice') setOfficePreview(file)
else setEditing(file)
return
}
if (canPreview(file)) setPreview(file)
else downloadFile(file) else downloadFile(file)
} }
@@ -984,6 +1005,15 @@ export default function FileBrowser({
/> />
)} )}
{officePreview && (
<FilePreview
file={officePreview}
asOfficePdf
onEdit={() => { const f = officePreview; setOfficePreview(null); setEditing(f) }}
onClose={() => setOfficePreview(null)}
/>
)}
{preview && ( {preview && (
<FilePreview <FilePreview
file={preview} file={preview}
+48
View File
@@ -147,6 +147,14 @@ function IndexingStatus() {
function StorageCleanup() { function StorageCleanup() {
const [running, setRunning] = useState(false) const [running, setRunning] = useState(false)
const [result, setResult] = useState<string | null>(null) const [result, setResult] = useState<string | null>(null)
/** Was Vorschaubilder, Profilbilder und gewandelte Dokumente belegen.
* Steht hier, weil es sonst nirgends steht: Diese Dateien zählen gegen kein
* Kontingent, ihr Ordner könnte also unbemerkt wachsen. */
const [derived, setDerived] = useState<{ prefix: string; files: number; bytes: number }[]>([])
useEffect(() => {
adminApi.derivedStorage().then(setDerived).catch(() => {})
}, [])
async function run() { async function run() {
setRunning(true) setRunning(true)
@@ -166,6 +174,8 @@ function StorageCleanup() {
} }
return ( return (
<>
<DerivedStorage items={derived} />
<div style={{ <div style={{
display: 'flex', alignItems: 'center', gap: 12, marginBottom: 16, display: 'flex', alignItems: 'center', gap: 12, marginBottom: 16,
padding: '12px 14px', borderRadius: 10, padding: '12px 14px', borderRadius: 10,
@@ -192,6 +202,44 @@ function StorageCleanup() {
{running ? 'Running…' : 'Run now'} {running ? 'Running…' : 'Run now'}
</button> </button>
</div> </div>
</>
)
}
/** Namen, die etwas sagen – „thumbs" steht nirgends auf der Oberfläche. */
const derivedLabels: Record<string, string> = {
thumbs: 'Thumbnails',
previews: 'Office documents as PDF',
avatars: 'Profile pictures',
}
function DerivedStorage({ items }: { items: { prefix: string; files: number; bytes: number }[] }) {
const total = items.reduce((n, i) => n + i.bytes, 0)
if (items.length === 0) return null
return (
<div style={{
marginBottom: 16, padding: '12px 14px', borderRadius: 10,
border: '1px solid var(--color-border)', background: 'var(--color-surface-2)',
}}>
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text)' }}>
Generated files — {formatBytes(total)}
</div>
<div style={{ fontSize: 11, color: 'var(--color-text-muted)', margin: '2px 0 8px' }}>
Created by the server, not uploaded by anyone. They count against no quota
and are rebuilt when needed — deleting them is safe.
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '4px 20px' }}>
{items.map(i => (
<span key={i.prefix} style={{ fontSize: 11, color: 'var(--color-text-muted)' }}>
{derivedLabels[i.prefix] ?? i.prefix}:{' '}
<span style={{ color: 'var(--color-text)' }}>
{formatBytes(i.bytes)}
</span>{' '}
({i.files})
</span>
))}
</div>
</div>
) )
} }