feat(office): mobileEdit am Telefon, PDF-Umweg entfällt
Ein Office-Dokument öffnete am Telefon als gewandeltes PDF, weil OnlyOffices Oberfläche auf 400 Pixeln nichts vom Dokument übrig lässt. Die Begründung war falsch: Die Ermittlungsliste bietet zu jeder Endung mehrere Aktionen an, und OnlyOffice/Euro-Office führen neben `edit` auch `mobileEdit` – dieselbe Adresse mit `?mobile=1`, dahinter eine für kleine Schirme gebaute Oberfläche. `EditURL` kannte nur `edit` und `view`; die Handy-Fassung lag die ganze Zeit in der Tabelle und wurde nie abgerufen. Entschieden wird am Zeigegerät, nicht an der Fensterbreite – ein schmales Fenster auf dem Schreibtisch ist immer noch eine Maus. Mit dem Anlass fällt die Notlösung: 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. Die Dateien unter previews/ sammelt der GC ein. Zwei Fehler, die dabei sichtbar wurden: fix(history): „Edit" schloss die Datei, statt den Editor zu öffnen. Beim Wechsel räumt React erst die alte Ansicht ab; der Aufräumer von useBackClose ruft history.back(), das verzögert wirkt und deshalb den Eintrag verbrauchte, den der Editor gerade angelegt hatte. Ein Zähler für selbst ausgelöste Schritte verschluckt genau diese popstate. fix(filetypes): `.txt` ging gar nicht auf. Es stand in officeExtensions und lief damit in den Office-Zweig statt in den eingebauten Text-Editor – am Telefon in die PDF-Umwandlung, die daran scheiterte. `md`, `log` und `rst` standen nie drin; das war die Asymmetrie. Dazu Euro-Office als Compose-Profil (Abbild 6,99 GB, nachgemessen – die Ankündigung nennt 5) und die Android-App als Idee. Geprüft: go build/vet sauber, tsc sauber, 37 Vitest-Tests, alle 234 Prüfungen der sieben Selbsttest-Suiten grün. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
6042f6f2f6
commit
944b2c63e2
23 files changed
+284
-788
No files matched your search
+1
-1
@@ -1,5 +1,5 @@
|
||||
# Secrets
|
||||
build/.env
|
||||
build/.env*
|
||||
.env
|
||||
|
||||
# Aus den Beispielen kopiert – gehört in die Umgebung, nicht ins Repo.
|
||||
|
||||
@@ -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.
|
||||
@@ -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/<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.
|
||||
> **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
|
||||
|
||||
+75
-2
@@ -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**
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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": "<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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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/<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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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
|
||||
|
||||
+19
-14
@@ -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<OfficeSession>('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<OfficeSession>('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)}` : ''}`,
|
||||
|
||||
|
||||
@@ -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<string | null>(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}
|
||||
</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 && (
|
||||
editing ? (
|
||||
<>
|
||||
|
||||
@@ -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<OfficeSession | null>(null)
|
||||
const [error, setError] = useState('')
|
||||
const formRef = useRef<HTMLFormElement>(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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<ViewMode>(() => loadPref('aether:view', 'grid'))
|
||||
const [sort, setSort] = useState<Sort>(() => loadPref('aether:sort', 'name-asc'))
|
||||
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. */
|
||||
const [editing, setEditing] = useState<FileItem | null>(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 && (
|
||||
<FilePreview
|
||||
file={officePreview}
|
||||
asOfficePdf
|
||||
onEdit={() => { const f = officePreview; setOfficePreview(null); setEditing(f) }}
|
||||
onClose={() => setOfficePreview(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{preview && (
|
||||
<FilePreview
|
||||
file={preview}
|
||||
|
||||
Reference in new issue
Block a user