diff --git a/.gitignore b/.gitignore index 01c71c7..0ab1315 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ # Secrets -build/.env +build/.env* .env # Aus den Beispielen kopiert – gehört in die Umgebung, nicht ins Repo. diff --git a/IDEAS.md b/IDEAS.md index b052cf2..fa06d06 100644 --- a/IDEAS.md +++ b/IDEAS.md @@ -96,3 +96,35 @@ kein Einstreuen in den Monat. **Gegentest, falls die Einschätzung falsch ist:** DAVx5 öffnen, Aether-Konto antippen. Gibt es dort einen Reiter „Aufgabenlisten" mit Einträgen, ohne dass eine der genannten Apps installiert ist, stimmt das oben nicht. + +--- + +## Eine Android-App — notiert (15.09.2026) + +Eine einfache eigene App fürs Telefon, statt der installierten Weboberfläche. + +**Warum das nicht offensichtlich ist:** Die Weboberfläche *ist* am Telefon +schon installierbar, hat ein eigenes Symbol, läuft ohne Adressleiste und kennt +die sicheren Bereiche neben Uhr und Wischbalken. Eine App, die nur dasselbe in +einem Rahmen zeigt, wäre Arbeit ohne Gewinn. + +**Was eine echte App kann, was die Weboberfläche nicht kann** — und nur dafür +lohnt sie sich: + +* **Fotos automatisch hochladen.** Der eigentliche Grund, warum Leute die + Nextcloud-App installieren. Der Browser darf im Hintergrund nicht auf die + Galerie, eine App schon. +* **„Teilen mit → Aether"** aus jeder anderen App heraus. Ein Anhang aus dem + Mailprogramm landet in einem Tippen in der Cloud. +* **Hochladen, das weiterläuft**, wenn der Schirm aus ist. Ein Browser-Tab + wird nach Sekunden angehalten; ein großer Upload bricht deshalb ab. +* **Dateien im Android-Dateimanager** über einen Storage Access Provider. + +**Was es kosten würde:** Ein zweiter Stapel (Kotlin oder Flutter), eine zweite +Veröffentlichungskette, und jede Serveränderung ist ab dann an zwei Stellen +nachzuziehen. Die bestehende REST-Schnittstelle plus App-Passwörter genügen +als Unterbau, ein neues Backend wäre es nicht. + +**Der Anlass wäre: automatischer Fotoupload.** Solange das nicht gebraucht +wird, ist die installierte Weboberfläche der bessere Weg — sie ist da, sie ist +gepflegt, und sie ist genau eine Codebasis. diff --git a/README.md b/README.md index 29cea0a..9c948e7 100644 --- a/README.md +++ b/README.md @@ -784,7 +784,7 @@ heißen gleich. Für uns ist es deshalb **derselbe Anbieter** – - "${APP_BIND:-0.0.0.0}:9980:80" ``` -Die `.env` ist dieselbe wie bei OnlyOffice. Rechne mit **5 GB** allein für das +Die `.env` ist dieselbe wie bei OnlyOffice. Rechne mit **7 GB** allein für das Abbild. > **Teilweise geprüft.** Collabora ist hier gemessen worden, den Kreis von @@ -814,46 +814,38 @@ Abbild. > ü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 +### Am Telefon: die Handy-Fassung des Editors -Tippt man mit dem **Finger** auf ein Office-Dokument, hängt es vom Anbieter ab, -was aufgeht: +Ein Office-Dokument öffnet **überall im Editor** – am Schreibtisch wie am +Telefon. Am Telefon fordert Aether dabei die Handy-Fassung an, wo der +Office-Server eine anbietet: | Anbieter | am Telefon | |---|---| -| **Collabora** | der Editor – seine eigene Handy-Ansicht taugt | -| **OnlyOffice / Euro-Office** | das Dokument als PDF; „Edit" führt trotzdem hinein | +| **Collabora** | seine eigene Handy-Ansicht (kennt keine getrennte Aktion) | +| **OnlyOffice / Euro-Office** | die WOPI-Aktion `mobileEdit` | -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. +Die Ermittlungsliste nennt zu jeder Endung mehrere Aktionen. OnlyOffice und +Euro-Office führen neben `edit`/`view` auch `mobileEdit`/`mobileView`, und +dahinter steckt eine eigene, für kleine Schirme gebaute Oberfläche – dieselbe +Adresse mit `?mobile=1`. Aether hat bis zum 16.09.2026 immer die +Schreibtisch-Fassung angefordert; am Telefon blieb zwischen Menüband, +Formatleisten und Eigenschaftenbereich vom Dokument ein Rechteck übrig. -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. +Entschieden wird am **Zeigegerät**, nicht an der Fensterbreite: Ein schmales +Fenster auf dem Schreibtisch ist immer noch eine Maus und eine Tastatur. Die +Oberfläche schickt dazu `?mobile=1` an `/api/v1/office/session/{id}`. -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/.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. +> **Entfallen am 16.09.2026: die PDF-Ansicht.** Ein Office-Dokument wurde am +> Telefon zuvor als gewandeltes PDF gezeigt, mit einem „Edit"-Knopf hinein. +> Das war eine Notlösung für eine Oberfläche, die Aether selbst falsch +> angefordert hatte – mit `mobileEdit` ist der Anlass weg. Mit ihr sind +> `internal/office/preview.go`, `convert.go` und die Spalte +> `files.preview_version` gefallen (Migration 000026); die Dateien unter +> `previews/` sammelt der GC ein. Wer ein PDF braucht, exportiert es im +> Editor. Die Umwandlung im Hintergrund (`/cool/convert-to/pdf` bzw. +> `/ConvertService.ashx`) hatte keinen anderen Aufrufer und ist damit +> ebenfalls entfallen. ### Welche Dateien, und wie gut @@ -939,7 +931,7 @@ 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 **6,99 GB** (hier nachgemessen) | derselbe Unterbau | Dazu kommt, was schon läuft: Postgres, Aether und – nicht unterschätzen – die **Texterkennung**. ocrmypdf und ghostscript nehmen sich bei einem großen Scan @@ -954,7 +946,8 @@ kurzzeitig mehrere hundert Megabyte. - **Euro-Office**, wenn du OnlyOffices Treue willst, aber nicht seine Herkunft. Seit Juni 2026 zu haben, von Nextcloud als Standard eingesetzt, derselbe WOPI-Weg. Der Haken ist derselbe wie bei OnlyOffice: Hunger nach - Arbeitsspeicher, und das Abbild wiegt rund 5 GB. + Arbeitsspeicher, und das Abbild wiegt **6,99 GB** – nachgemessen, nicht + geschätzt; die Ankündigung nennt weniger. **Auf deinen 6 GB ist die Rechnung schnell gemacht.** Postgres, Aether und die Texterkennung liegen schon darauf; für OnlyOffice oder Euro-Office bliebe zu diff --git a/TESTING.md b/TESTING.md index 81a82e3..8422571 100644 --- a/TESTING.md +++ b/TESTING.md @@ -1356,8 +1356,9 @@ Die Daten liegen seit dem 23.08.2026 in einem gewöhnlichen Verzeichnis ## Office – Collabora / OnlyOffice / Euro-Office (24.08.2026) Aether ist WOPI-Host; der Office-Server holt die Datei selbst ab und schreibt -sie zurück. **Gemessen wurde mit Collabora**; OnlyOffice und Euro-Office -sprechen dieselbe Schnittstelle, sind hier aber nie gelaufen (Arbeitsspeicher). +sie zurück. Gemessen wurde mit **Collabora** und – seit dem 16.09.2026 – mit +**Euro-Office**, beide auf dieser Anlage. OnlyOffice lief hier nur bis zur +Ermittlung; Euro-Office ist sein Fork und spricht dieselbe Schnittstelle. ### Öffnen - [ ] Rechtsklick auf .odt/.docx/.ods → „Edit in Office…" erscheint 🔎 @@ -1395,6 +1396,78 @@ sprechen dieselbe Schnittstelle, sind hier aber nie gelaufen (Arbeitsspeicher). --- +## Office am Telefon: `mobileEdit` statt PDF-Umweg (16.09.2026) + +Ein Office-Dokument öffnete am Telefon als gewandeltes PDF, mit einem +„Edit"-Knopf hinein. Begründet war das mit OnlyOffices Oberfläche, die auf +400 Pixeln nichts vom Dokument übrig lässt. + +**Die Begründung war falsch – wir hatten die falsche Oberfläche bestellt.** +Die Ermittlungsliste bietet zu jeder Endung mehrere Aktionen an, und für +`.docx` stehen dort acht. Nachgesehen im laufenden Euro-Office: + + edit …/hosting/wopi/word/edit + mobileEdit …/hosting/wopi/word/edit?mobile=1 + +`EditURL` kannte nur `edit` und `view`. Die Handy-Fassung lag die ganze Zeit +in der Tabelle und wurde nie abgerufen. + +- [x] Am Telefon öffnet ein `.docx` in der Handy-Oberfläche 🔎 +- [x] Am Schreibtisch unverändert die Schreibtisch-Fassung 🔎 +- [x] Schmales Fenster am Schreibtisch → weiterhin Schreibtisch-Fassung + (entschieden wird am Zeigegerät, nicht an der Breite) +- [x] Collabora kennt die Aktionen nicht → fällt auf `edit` zurück +- [ ] Nur-Lese-Freigabe am Telefon → `mobileView` + +**Vorher geprüft, ob es überhaupt etwas ändert.** Beide Aktionen zeigen auf +denselben Pfad; erst der Vergleich der Adressen zeigte das `?mobile=1`. Ohne +diesen Blick wäre der Umbau eine Umbenennung gewesen. + +### Die PDF-Ansicht ist entfallen + +Mit dem Anlass fiel die Notlösung: `internal/office/preview.go` (229 Zeilen), +`convert.go` (271) samt Test, `files.preview_version` (Migration 000026), die +Sonderabfrage im GC, die Route und dreizehn Fundstellen im Frontend. + +`convert.go` hatte **keinen anderen Aufrufer** – die gesamte Umwandlung nach +PDF existierte allein für diese Ansicht. Wer ein PDF braucht, exportiert es +im Editor. + +- [x] Alle 234 Prüfungen der sieben Selbsttest-Suiten weiterhin grün +- [ ] `previews/` ist nach einem GC-Lauf leer (24 h Schonfrist beachten) +- [ ] Admin → abgeleitete Daten zeigt danach 0 für `previews` + +### Der Zurück-Fehler dahinter + +Auf „Edit" in der PDF-Ansicht schloss sich die Datei, statt den Editor zu +öffnen. Ursache war kein Office, sondern der Verlauf: Beim Wechsel räumt React +erst die alte Ansicht ab, dann baut es die neue auf. Der Aufräumer von +`useBackClose` ruft `history.back()` – das wirkt aber **verzögert** und +verbrauchte deshalb den Eintrag, den der Editor gerade angelegt hatte. + +Behoben mit einem Zähler für selbst ausgelöste Schritte: So viele `popstate` +werden verschluckt. Die Geste des Nutzers erkennt man daran, dass keiner +offen ist. + +- [x] Aus der Vorschau in den Editor wechseln → der Editor bleibt offen 🔎 +- [ ] Zurück-Geste im Editor → schließt den Editor, nicht die App +- [ ] Zwei Ebenen (Vorschau unter Editor) → jede Geste schließt genau eine +- [ ] Ungespeicherte Änderungen: „Abbrechen" lässt die Ansicht offen + +### `.txt` ging gar nicht auf + +`txt` stand in `officeExtensions`. Eine 12-Byte-Notiz lief damit in den +Office-Zweig statt in den eingebauten Text-Editor – und am Telefon in die +PDF-Umwandlung, die daran scheiterte. `md`, `log` und `rst` standen nie drin; +das war die Asymmetrie, über die man stolperte. + +- [x] `.txt` antippen → eingebauter Text-Editor, Bearbeiten und Speichern 🔎 +- [x] `.md` unverändert +- [ ] `.csv` weiterhin im Tabellenprogramm (bleibt bewusst Office) +- [ ] „Edit in Office…" erscheint für `.txt` nicht mehr im Kontextmenü + +--- + ## Absender / Korrespondent (23.08.2026) Das zweite Ordnungsmerkmal neben den Etiketten. Unterschied: genau **einer** diff --git a/backend/cmd/aether/main.go b/backend/cmd/aether/main.go index e851cba..da22416 100644 --- a/backend/cmd/aether/main.go +++ b/backend/cmd/aether/main.go @@ -511,7 +511,6 @@ func main() { 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.Get("/files/{id}/versions", fileHandler.ListVersions) r.Get("/files/{id}/versions/{versionId}/download", fileHandler.DownloadVersion) diff --git a/backend/db/migrations/000026_drop_office_preview.down.sql b/backend/db/migrations/000026_drop_office_preview.down.sql new file mode 100644 index 0000000..938cbb2 --- /dev/null +++ b/backend/db/migrations/000026_drop_office_preview.down.sql @@ -0,0 +1,4 @@ +-- Zurück: Die Spalte kommt leer wieder. Ein Wert wäre gelogen – die PDFs +-- unter previews/ hat der GC inzwischen abgeräumt, und 0 heißt ohnehin +-- „noch nichts gewandelt". +ALTER TABLE files ADD COLUMN IF NOT EXISTS preview_version INT NOT NULL DEFAULT 0; diff --git a/backend/db/migrations/000026_drop_office_preview.up.sql b/backend/db/migrations/000026_drop_office_preview.up.sql new file mode 100644 index 0000000..b76e1e7 --- /dev/null +++ b/backend/db/migrations/000026_drop_office_preview.up.sql @@ -0,0 +1,11 @@ +-- Die Vorschau als PDF ist entfallen (16.09.2026). +-- +-- Office-Dokumente öffnen jetzt direkt im Editor – am Telefon in dessen +-- eigener Handy-Fassung (`mobileEdit`). Der Umweg über ein gewandeltes PDF +-- war eine Notlösung für eine Oberfläche, die Aether selbst falsch +-- angefordert hatte; mit ihm fällt auch die Spalte weg, die vermerkte, zu +-- welcher Fassung das abgelegte PDF gehörte. +-- +-- Die Dateien unter previews/ räumt der GC ab: Ohne diese Spalte kennt sie +-- keine Zeile mehr, und was keine Zeile kennt, ist für ihn verwaist. +ALTER TABLE files DROP COLUMN IF EXISTS preview_version; diff --git a/backend/internal/files/store.go b/backend/internal/files/store.go index d3cfdb4..7ad32a0 100644 --- a/backend/internal/files/store.go +++ b/backend/internal/files/store.go @@ -537,29 +537,7 @@ func isUniqueViolation(err error) bool { // 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 diff --git a/backend/internal/gc/gc.go b/backend/internal/gc/gc.go index 5c365fe..e73f7ef 100644 --- a/backend/internal/gc/gc.go +++ b/backend/internal/gc/gc.go @@ -73,6 +73,11 @@ func (c *Collector) Run(ctx context.Context) (Result, error) { // "tmp/" bleibt außen vor: Das gehört dem Upload-Pfad und wird dort // aufgeräumt; ein GC, der dort mitmischt, zerrisse laufende Uploads. + // "previews" steht hier weiterhin, obwohl nichts mehr hineinschreibt: Die + // als PDF gewandelten Office-Dokumente sind am 16.09.2026 entfallen, und + // die zurückgebliebenen Dateien kennt keine Datenbankzeile mehr. Genau so + // sollen sie eingesammelt werden. Nimmt man den Ordner aus der Liste, + // bleiben sie für immer liegen. for _, prefix := range []string{"users", "thumbs", "branding", "avatars", "previews"} { err := c.storage.Walk(ctx, prefix, func(key string, info storage.FileInfo) error { res.Scanned++ @@ -154,27 +159,6 @@ func (c *Collector) referencedKeys(ctx context.Context) (map[string]bool, error) 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 // Einstellungen. Ohne diese Abfrage hielte der GC sie für verwaist, sobald // "branding" mit durchsucht wird – und löschte sie beim nächsten Lauf. diff --git a/backend/internal/office/convert.go b/backend/internal/office/convert.go deleted file mode 100644 index 4a2fbd1..0000000 --- a/backend/internal/office/convert.go +++ /dev/null @@ -1,271 +0,0 @@ -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)) -} diff --git a/backend/internal/office/convert_test.go b/backend/internal/office/convert_test.go deleted file mode 100644 index 87de123..0000000 --- a/backend/internal/office/convert_test.go +++ /dev/null @@ -1,122 +0,0 @@ -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": "500 Internal Server Error", - "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) - } - } -} diff --git a/backend/internal/office/discovery.go b/backend/internal/office/discovery.go index d756b89..7be41f9 100644 --- a/backend/internal/office/discovery.go +++ b/backend/internal/office/discovery.go @@ -128,15 +128,33 @@ func (c *Client) Provider() string { return c.provider } // // Fällt auf „view" zurück, wenn der Server für diesen Typ kein Bearbeiten // anbietet – lesen ist besser als eine Fehlermeldung. -func (c *Client) EditURL(ctx context.Context, ext string, write bool) (string, error) { +func (c *Client) EditURL(ctx context.Context, ext string, write, mobile bool) (string, error) { if err := c.refresh(ctx); err != nil { return "", err } c.mu.Lock() defer c.mu.Unlock() + // Am Finger zuerst die Handy-Fassung. + // + // Die Ermittlungsliste bietet zu jeder Endung mehrere Aktionen an – + // OnlyOffice und Euro-Office nennen neben `edit`/`view` auch + // `mobileEdit`/`mobileView`, und dahinter steckt eine eigene, für kleine + // Schirme gebaute Oberfläche. Aether hat bisher immer die + // Schreibtisch-Fassung angefordert; am Telefon blieb vom Dokument + // zwischen Menüband und Seitenspalten ein Rechteck übrig. Genau das war + // der Anlass für den PDF-Umweg – eine Notlösung für eine Oberfläche, die + // wir selbst falsch bestellt hatten. + // + // Collabora kennt diese Aktionen nicht; dort greift schlicht der nächste + // Eintrag, und es bleibt beim bisherigen Verhalten. order := []string{"edit", "view"} - if !write { + switch { + case mobile && write: + order = []string{"mobileEdit", "edit", "mobileView", "view"} + case mobile: + order = []string{"mobileView", "view", "mobileEdit", "edit"} + case !write: order = []string{"view", "edit"} } for _, action := range order { diff --git a/backend/internal/office/handler.go b/backend/internal/office/handler.go index 2ed8b79..3129cd6 100644 --- a/backend/internal/office/handler.go +++ b/backend/internal/office/handler.go @@ -100,7 +100,12 @@ func (h *Handler) Session(w http.ResponseWriter, r *http.Request) { } write := access.Permission == acl.PermWrite - src, err := h.client.EditURL(r.Context(), Extension(f.Name), write) + // Finger oder Maus? Das entscheidet die Oberfläche, nicht der Server: + // Ein schmales Fenster auf dem Schreibtisch ist immer noch eine Maus. + // Deshalb fragen wir nicht die Kennung des Browsers, sondern lassen die + // Anwendung sagen, was sie über ihr Zeigegerät weiß. + mobile := r.URL.Query().Get("mobile") == "1" + src, err := h.client.EditURL(r.Context(), Extension(f.Name), write, mobile) if err != nil { log.Ctx(r.Context()).Warn().Err(err).Msg("office: discovery failed") response.Error(w, http.StatusBadGateway, "the office server is not reachable") diff --git a/backend/internal/office/preview.go b/backend/internal/office/preview.go deleted file mode 100644 index 006465d..0000000 --- a/backend/internal/office/preview.go +++ /dev/null @@ -1,229 +0,0 @@ -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/.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 -} diff --git a/backend/internal/rescan/apply.go b/backend/internal/rescan/apply.go index 39c059b..bc45ed4 100644 --- a/backend/internal/rescan/apply.go +++ b/backend/internal/rescan/apply.go @@ -231,6 +231,5 @@ func (s *Scanner) remove(ctx context.Context, id uuid.UUID) error { _ = s.storage.Delete(ctx, k) } _ = s.storage.Delete(ctx, "thumbs/"+id.String()+".jpg") - _ = s.storage.Delete(ctx, "previews/"+id.String()+".pdf") return nil } diff --git a/backend/pkg/db/migrations/000026_drop_office_preview.down.sql b/backend/pkg/db/migrations/000026_drop_office_preview.down.sql new file mode 100644 index 0000000..938cbb2 --- /dev/null +++ b/backend/pkg/db/migrations/000026_drop_office_preview.down.sql @@ -0,0 +1,4 @@ +-- Zurück: Die Spalte kommt leer wieder. Ein Wert wäre gelogen – die PDFs +-- unter previews/ hat der GC inzwischen abgeräumt, und 0 heißt ohnehin +-- „noch nichts gewandelt". +ALTER TABLE files ADD COLUMN IF NOT EXISTS preview_version INT NOT NULL DEFAULT 0; diff --git a/backend/pkg/db/migrations/000026_drop_office_preview.up.sql b/backend/pkg/db/migrations/000026_drop_office_preview.up.sql new file mode 100644 index 0000000..b76e1e7 --- /dev/null +++ b/backend/pkg/db/migrations/000026_drop_office_preview.up.sql @@ -0,0 +1,11 @@ +-- Die Vorschau als PDF ist entfallen (16.09.2026). +-- +-- Office-Dokumente öffnen jetzt direkt im Editor – am Telefon in dessen +-- eigener Handy-Fassung (`mobileEdit`). Der Umweg über ein gewandeltes PDF +-- war eine Notlösung für eine Oberfläche, die Aether selbst falsch +-- angefordert hatte; mit ihm fällt auch die Spalte weg, die vermerkte, zu +-- welcher Fassung das abgelegte PDF gehörte. +-- +-- Die Dateien unter previews/ räumt der GC ab: Ohne diese Spalte kennt sie +-- keine Zeile mehr, und was keine Zeile kennt, ist für ihn verwaist. +ALTER TABLE files DROP COLUMN IF EXISTS preview_version; diff --git a/build/docker-compose.yml b/build/docker-compose.yml index 023f39e..a6c0e37 100644 --- a/build/docker-compose.yml +++ b/build/docker-compose.yml @@ -102,6 +102,37 @@ services: ports: - "${APP_BIND:-127.0.0.1}:${OFFICE_PORT:-9980}:9980" + # Euro-Office – OnlyOffice-Fork (Nextcloud/IONOS, seit 09.06.2026). Für + # Aether ist es derselbe Anbieter wie OnlyOffice (`OFFICE_PROVIDER=onlyoffice`), + # nur ein anderes Abbild: Die WOPI-Schnittstelle und die Variablen heißen + # gleich. + # + # Eigenes Profil, damit es neben Collabora stehen kann, ohne sich den Port + # zu teilen: ... --profile euro up -d + # + # Rechne mit rund 5 GB fürs Abbild und 400 MB im Leerlauf. + euro: + container_name: aether-euro + image: ghcr.io/euro-office/documentserver:latest + profiles: ["euro"] + restart: unless-stopped + environment: + WOPI_ENABLED: "true" + # Ohne Signatur antwortet der Server mit {"error":-8} – und zwar erst + # im Editor, nicht schon bei der Ermittlung. Der Rahmen bleibt dann leer. + JWT_ENABLED: "true" + JWT_SECRET: ${OFFICE_JWT_SECRET} + # Die Beispiel-App gehört nicht ins Netz. + EXAMPLE_ENABLED: "false" + # Es muss app:8080 erreichen – eine private Adresse, die es sonst + # von sich aus ablehnt. + ALLOW_PRIVATE_IP_ADDRESS: "true" + networks: [internal] + ports: + # 9981, damit Collabora auf 9980 bleiben kann. Anders als dort ist der + # Port hier **80** im Container, nicht 9980. + - "${APP_BIND:-127.0.0.1}:${EURO_PORT:-9981}:80" + volumes: postgres_data: # Fester Name, aus demselben Grund. Er stammt noch aus der Zeit, als der diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 482d4a0..06bbbb9 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -591,8 +591,21 @@ export interface OfficeSession { * damit das Kontextmenue den Punkt gar nicht erst anbietet, wo er nicht ginge – * entschieden wird serverseitig. */ +/** + * Endungen, die im Office-Server aufgehen sollen. + * + * **`txt` steht hier bewusst nicht mehr drin** (seit 16.09.2026). Es stand es + * einmal, weil der Office-Server eine Textdatei durchaus öffnen kann – nur + * hat Aether einen eingebauten Text-Editor, der sofort da ist, keinen Server + * braucht und direkt speichert. Mit `txt` in dieser Liste lief eine + * 12-Byte-Notiz stattdessen in den Office-Zweig und ging am Telefon gar nicht + * auf. `md`, `log` und `rst` standen nie drin – das war die Asymmetrie. + * + * `csv` bleibt: Eine Tabelle ist im Tabellenprogramm besser aufgehoben als in + * einem Textfeld. + */ const officeExtensions = new Set([ - 'odt', 'docx', 'doc', 'rtf', 'txt', + 'odt', 'docx', 'doc', 'rtf', 'ods', 'xlsx', 'xls', 'csv', 'odp', 'pptx', 'ppt', 'odg', @@ -604,8 +617,11 @@ export function isOfficeFile(name: string): boolean { } export const officeApi = { - session: (fileId: string) => - request('GET', `/office/session/${fileId}`), + /** `mobile` wählt die Handy-Fassung des Editors, wo der Office-Server eine + * anbietet (OnlyOffice/Euro-Office: `mobileEdit`). Collabora kennt sie + * nicht und bleibt unverändert. */ + session: (fileId: string, mobile = false) => + request('GET', `/office/session/${fileId}${mobile ? '?mobile=1' : ''}`), } export const ocrApi = { @@ -651,17 +667,6 @@ export const filesApi = { * Gemessen: dreimal dieselbe Datei hochgeladen ergibt dreimal dieselbe ID * 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) => `/api/v1/files/${id}/thumbnail${version ? `?v=${encodeURIComponent(version)}` : ''}`, diff --git a/frontend/src/components/FilePreview.tsx b/frontend/src/components/FilePreview.tsx index da4c1c9..2ff719c 100644 --- a/frontend/src/components/FilePreview.tsx +++ b/frontend/src/components/FilePreview.tsx @@ -19,17 +19,6 @@ interface Props { onClose: () => void /** Nach dem Speichern: Liste neu holen, damit Größe und Datum stimmen. */ 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 * damit zur Fundstelle, statt auf Seite 1 zu beginnen – der Grund, aus dem @@ -101,12 +90,10 @@ export function canPreview(file: FileItem): boolean { return kindOf(file.mime_type, file.name) !== 'none' } -export default function FilePreview({ file, onPrev, onNext, onClose, onSaved, searchTerm, asOfficePdf, onEdit }: Props) { - // 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) +export default function FilePreview({ file, onPrev, onNext, onClose, onSaved, searchTerm }: Props) { + const kind = kindOf(file.mime_type, file.name) const narrow = useNarrow() - const src = asOfficePdf ? filesApi.officePreview(file.id, file.version) : filesApi.inline(file.id) + const src = filesApi.inline(file.id) const [text, setText] = useState(null) const [tooBig, setTooBig] = useState(false) const [editing, setEditing] = useState(false) @@ -218,23 +205,6 @@ export default function FilePreview({ file, onPrev, onNext, onClose, onSaved, se {file.name} - {/* 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 && ( - - )} - {kind === 'text' && !tooBig && text !== null && ( editing ? ( <> diff --git a/frontend/src/components/OfficeEditor.tsx b/frontend/src/components/OfficeEditor.tsx index 81c2adc..c991ba0 100644 --- a/frontend/src/components/OfficeEditor.tsx +++ b/frontend/src/components/OfficeEditor.tsx @@ -1,6 +1,7 @@ import { useEffect, useRef, useState } from 'react' import { officeApi, type FileItem, type OfficeSession } from '../api/client' import { useBackClose } from '../hooks/useBackClose' +import { useCoarsePointer } from './FileViews' /** * Ein Dokument im Office-Server bearbeiten. @@ -21,10 +22,13 @@ export default function OfficeEditor({ file, onClose }: { const [session, setSession] = useState(null) const [error, setError] = useState('') const formRef = useRef(null) + // Am Zeigegerät, nicht an der Fensterbreite: Ein schmales Fenster auf dem + // Schreibtisch ist immer noch eine Maus und eine Tastatur. + const coarse = useCoarsePointer() useEffect(() => { let alive = true - officeApi.session(file.id) + officeApi.session(file.id, coarse) .then(s => { if (!alive) return if (!s.enabled) { setError('Office editing is switched off on this server.'); return } @@ -35,7 +39,7 @@ export default function OfficeEditor({ file, onClose }: { setError(err instanceof Error ? err.message : 'Could not open the editor') }) return () => { alive = false } - }, [file.id]) + }, [file.id, coarse]) // Das Formular abschicken, sobald es steht. Ein useEffect statt eines // autoSubmit-Attributs: HTML kennt keines, und ein Klick des Nutzers wäre diff --git a/frontend/src/hooks/useBackClose.ts b/frontend/src/hooks/useBackClose.ts index 19e6ea7..41a4dc3 100644 --- a/frontend/src/hooks/useBackClose.ts +++ b/frontend/src/hooks/useBackClose.ts @@ -26,6 +26,23 @@ import { useEffect, useRef } from 'react' let stack: number[] = [] let seq = 0 +/** + * Wie viele `popstate` noch von **uns selbst** stammen und nicht vom Nutzer. + * + * Der Aufräumer unten ruft `history.back()`, wenn eine Ansicht über einen + * Knopf geschlossen wurde – ihr Eintrag muss ja weg. Nur wirkt `back()` nicht + * sofort, sondern erst im nächsten Durchlauf. Öffnet in derselben Runde eine + * *andere* Ansicht (Vorschau → „Edit" → Editor), liegt deren frischer Eintrag + * dann schon obenauf – und das verspätete `back()` verbraucht ihn. Der Editor + * bekam ein `popstate`, hielt es für die Zurück-Geste und schloss sich sofort + * wieder: Aus „Edit" wurde „Datei zu". + * + * Deshalb zählen wir die selbst ausgelösten Schritte mit und verschlucken + * genau so viele `popstate`. Die Zurück-Geste des Nutzers erkennt man daran, + * dass keiner offen ist. + */ +let eigeneSchritte = 0 + /** Wir merken uns unseren Eintrag im State des Browsers, um ihn beim * Zurückkommen wiederzuerkennen. */ const stateKey = 'aether:overlay' @@ -63,6 +80,12 @@ export function useBackClose({ active, onClose }: Options) { } function onPop() { + // Von uns selbst ausgelöst? Dann ist nichts zu schließen – der Schritt + // gehört zu einer Ansicht, die bereits weg ist. + if (eigeneSchritte > 0) { + eigeneSchritte-- + return + } // Nur der oberste reagiert. Ein tiefer liegender Eintrag hat seinen // eigenen Zurück-Schritt noch vor sich. if (stack.at(-1) !== id) return @@ -81,6 +104,7 @@ export function useBackClose({ active, onClose }: Options) { // Zurück-Geste danach einen Schritt ins Leere. if (stack.at(-1) === id) { stack = stack.filter(x => x !== id) + eigeneSchritte++ window.history.back() } else { stack = stack.filter(x => x !== id) diff --git a/frontend/src/pages/FileBrowser.tsx b/frontend/src/pages/FileBrowser.tsx index bc2ee98..0792696 100644 --- a/frontend/src/pages/FileBrowser.tsx +++ b/frontend/src/pages/FileBrowser.tsx @@ -6,7 +6,7 @@ import { } from '../api/client' import Breadcrumb from '../components/Breadcrumb' import PageHeader from '../components/PageHeader' -import GridView, { ListView, useCoarsePointer } from '../components/FileViews' +import GridView, { ListView } from '../components/FileViews' import FilePreview, { canPreview, kindOf } from '../components/FilePreview' import ContextMenu, { type MenuItem } from '../components/ContextMenu' import UploadQueue from '../components/UploadQueue' @@ -98,14 +98,9 @@ export default function FileBrowser({ const [viewMode, setViewMode] = useState(() => loadPref('aether:view', 'grid')) const [sort, setSort] = useState(() => loadPref('aether:sort', 'name-asc')) const [preview, setPreview] = useState(null) - /** Office-Dokument als PDF – am Telefon der übliche Weg hinein. */ - const [officePreview, setOfficePreview] = useState(null) /** Datei, die gerade im Office-Server bearbeitet wird. */ const [editing, setEditing] = useState(null) - const { officeEnabled, officeProvider } = useAppSettings() - /** Finger oder Maus? Entscheidet, ob ein Office-Dokument als PDF oder im - * Editor aufgeht. */ - const touchDevice = useCoarsePointer() + const { officeEnabled } = useAppSettings() /** Etiketten je Datei-ID – kommen mit der Ordnerliste in einer Abfrage mit, * nicht einzeln je Kachel. */ @@ -354,20 +349,7 @@ export default function FileBrowser({ */ function openFile(file: FileItem) { if (officeEnabled && isOfficeFile(file.name)) { - // **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) + setEditing(file) return } if (canPreview(file)) setPreview(file) @@ -643,15 +625,6 @@ export default function FileBrowser({ /> )} - {officePreview && ( - { const f = officePreview; setOfficePreview(null); setEditing(f) }} - onClose={() => setOfficePreview(null)} - /> - )} - {preview && (