feat(office): Dokumente am Telefon als PDF ansehen statt bearbeiten

Am Finger öffnet ein Office-Dokument jetzt das gewandelte PDF im eigenen
Betrachter; "Edit" daneben führt trotzdem in den Editor. Mit Maus ändert
sich nichts.

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
marcandClaude Opus 5 committed 2026-08-31 23:30:49 +02:00
1 parent 84ae570305
commit 9c5cda75d7
22 files changed
+959 -12

No files matched your search

+229
View File
@@ -0,0 +1,229 @@
package office
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"net/http"
"strings"
"sync"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"github.com/rs/zerolog/log"
"aether/internal/auth"
"aether/internal/files"
"aether/pkg/response"
)
// Ein Office-Dokument als PDF ansehen, ohne den Editor.
//
// Am Telefon ist das der Normalfall: nachsehen, nicht schreiben. Der Editor
// bleibt einen Knopfdruck entfernt.
//
// ── Warum es je Datei nur **ein** PDF gibt ──────────────────────────────────
//
// Der Schlüssel ist `previews/<datei-id>.pdf` und trägt die Fassungsnummer
// **nicht** im Namen. Eine neue Fassung überschreibt das alte PDF, statt ein
// zweites danebenzulegen – häufen kann sich hier also nichts, das ist keine
// Frage des Aufräumens, sondern des Schlüssels.
//
// Ob das abgelegte PDF noch zum Inhalt passt, sagt `files.preview_version`.
// Steht dort eine andere Zahl als die aktuelle Fassung, wird neu gewandelt und
// überschrieben. Das ist derselbe Gedanke wie beim Vorschaubild, nur dass
// dieses beim Hochladen entsteht und das PDF erst, wenn es jemand ansieht.
//
// ── Was das für die Platte heißt ────────────────────────────────────────────
//
// Ein PDF entsteht nur für Dokumente, die wirklich jemand angesehen hat, und
// es zählt nicht gegen das Kontingent: Es ist abgeleitet, niemand hat es
// hochgeladen. Beim endgültigen Löschen verschwindet es mit, und der GC kennt
// den Ordner – beides ist nötig, und beides einzeln geprüft.
// previewKey ist der Ablageort. Öffentlich, weil Papierkorb und GC ihn
// ebenfalls kennen müssen.
func PreviewKey(fileID string) string { return "previews/" + fileID + ".pdf" }
// PreviewPrefix ist der Ordner, den der GC durchgeht.
const PreviewPrefix = "previews"
// maxPreviewSource: Größere Dateien werden nicht gewandelt.
//
// Nicht aus Sparsamkeit: Ein Dokument dieser Größe beschäftigt den
// Office-Server minutenlang, und das auf einen Fingertipp hin. Wer so etwas
// ansehen will, bekommt den Editor angeboten – dort lädt es Stück für Stück.
const maxPreviewSource = 100 << 20
// Ein Riegel je Datei: Tippt jemand zweimal, wandelt der Server nicht zweimal.
//
// Ohne das ist es kein Fehler, nur Verschwendung – beide Läufe schreiben
// denselben Inhalt, und geschrieben wird atomar. Der Riegel spart die zweite
// Umwandlung, nicht die Richtigkeit.
var previewLocks sync.Map // fileID -> *sync.Mutex
func lockPreview(id string) func() {
m, _ := previewLocks.LoadOrStore(id, &sync.Mutex{})
mu := m.(*sync.Mutex)
mu.Lock()
return mu.Unlock
}
// GET /api/v1/files/{id}/preview.pdf
func (h *Handler) PreviewPDF(w http.ResponseWriter, r *http.Request) {
if !h.Enabled() || !h.client.CanConvert() {
response.Error(w, http.StatusNotImplemented, "this server cannot convert documents")
return
}
f, userID, ok := h.resolveSession(w, r)
if !ok {
return
}
if !Editable(f.Name) {
response.Error(w, http.StatusUnsupportedMediaType, "this file type has no office preview")
return
}
if f.Size > maxPreviewSource {
// 413 und nicht 500: Die Oberfläche unterscheidet daran, ob sie den
// Editor anbieten soll oder einen Fehler zeigt.
response.Error(w, http.StatusRequestEntityTooLarge,
"this document is too large to be shown as a PDF")
return
}
unlock := lockPreview(f.ID.String())
defer unlock()
key := PreviewKey(f.ID.String())
have, err := h.files.PreviewVersion(r.Context(), f.ID)
if err != nil && !errors.Is(err, files.ErrNotFound) {
response.Error(w, http.StatusInternalServerError, "database error")
return
}
if have == f.Version {
if rc, err := h.blobs.Get(r.Context(), key); err == nil {
defer rc.Close()
servePDF(w, f.Name, f.Version)
_, _ = io.Copy(w, rc)
return
}
// Vermerk vorhanden, Datei weg – etwa, weil der GC sie geholt hat oder
// jemand im Datenordner aufgeräumt hat. Kein Fehler, nur ein Grund,
// sie neu zu erzeugen.
log.Ctx(r.Context()).Info().Str("file_id", f.ID.String()).
Msg("office preview: vermerkt, aber nicht auf der Platte – wird neu gewandelt")
}
pdf, err := h.convert(r.Context(), f, userID)
if err != nil {
log.Ctx(r.Context()).Warn().Err(err).Str("file_id", f.ID.String()).
Msg("office preview: Umwandlung fehlgeschlagen")
response.Error(w, http.StatusBadGateway, "the document could not be converted")
return
}
// Erst der Blob, dann der Vermerk. Andersherum zeigte die Datenbank auf
// etwas, das es noch nicht gibt – und der nächste Abruf lieferte nichts,
// ohne es zu merken.
if _, _, err := h.blobs.Put(r.Context(), key, bytes.NewReader(pdf)); err != nil {
log.Ctx(r.Context()).Warn().Err(err).Msg("office preview: konnte nicht abgelegt werden")
} else if err := h.files.SetPreviewVersion(r.Context(), f.ID, f.Version); err != nil {
log.Ctx(r.Context()).Warn().Err(err).Msg("office preview: Vermerk fehlgeschlagen")
}
servePDF(w, f.Name, f.Version)
_, _ = w.Write(pdf)
}
// convert holt das PDF beim Office-Server – auf dem Weg, den der Anbieter
// versteht.
func (h *Handler) convert(ctx context.Context, f *files.File, userID uuid.UUID) ([]byte, error) {
ext := Extension(f.Name)
// Der Schlüssel muss sich mit dem Inhalt ändern: OnlyOffice speichert
// selbst danach zwischen und lieferte sonst nach einer Bearbeitung das
// alte Dokument aus.
key := fmt.Sprintf("%s-%d", f.ID.String(), f.Version)
switch h.client.Provider() {
case "collabora":
// Collabora bekommt die Bytes. Der Umweg über eine Adresse wäre hier
// keiner: Es müsste sie abrufen, also bräuchte es dieselbe
// Erreichbarkeit – und ein Token mehr in der Welt.
rc, err := h.blobs.Get(ctx, f.StorageKey)
if err != nil {
return nil, fmt.Errorf("read source: %w", err)
}
defer rc.Close()
return h.client.ConvertToPDF(ctx, ext, key, f.Name, "", rc)
default:
// OnlyOffice holt die Datei selbst – über denselben WOPI-Weg, den es
// zum Bearbeiten ohnehin geht. Das Token gilt kurz und nur für diese
// Datei; ein zweiter, offener Abholpunkt entsteht dadurch nicht.
token, _ := h.signer.Issue(f.ID, userID, false)
src := fmt.Sprintf("%s/wopi/files/%s/contents?access_token=%s",
h.wopiURL, f.ID, token)
return h.client.ConvertToPDF(ctx, ext, key, f.Name, src, nil)
}
}
func servePDF(w http.ResponseWriter, name string, version int) {
w.Header().Set("Content-Type", "application/pdf")
w.Header().Set("X-Content-Type-Options", "nosniff")
// Anzeigen, nicht herunterladen – und unter dem Namen des Dokuments.
w.Header().Set("Content-Disposition",
fmt.Sprintf(`inline; filename="%s.pdf"`, sanitizeFilename(name)))
// Privat: Das PDF trägt denselben Inhalt wie das Dokument und gehört in
// keinen gemeinsamen Zwischenspeicher. Die Fassung steht in der Adresse,
// der Browser holt es nach einer Änderung also von selbst neu.
w.Header().Set("Cache-Control", "private, max-age=3600")
w.Header().Set("X-Document-Version", fmt.Sprintf("%d", version))
}
// sanitizeFilename nimmt einem Dateinamen alles, was in einer Kopfzeile
// gefährlich wäre – Anführungszeichen und Zeilenumbrüche.
func sanitizeFilename(name string) string {
name = strings.NewReplacer(`"`, "", "\r", "", "\n", "", ";", "").Replace(name)
if i := strings.LastIndexByte(name, '.'); i > 0 {
name = name[:i]
}
if name == "" {
name = "document"
}
return name
}
// resolveSession löst eine Anfrage **aus dem Browser** auf – mit Sitzung, im
// Gegensatz zu `authorize`, das den WOPI-Weg des Office-Servers bedient.
//
// Getrennt gehalten, weil die beiden Wege verschiedene Berechtigungen haben
// und das nie durcheinandergeraten darf: Hier zählt die Anmeldung, dort ein
// Token für genau eine Datei.
func (h *Handler) resolveSession(w http.ResponseWriter, r *http.Request) (*files.File, uuid.UUID, bool) {
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
response.Error(w, http.StatusNotFound, "not found")
return nil, uuid.Nil, false
}
user := auth.UserFromContext(r.Context())
if user == nil {
response.Error(w, http.StatusUnauthorized, "not signed in")
return nil, uuid.Nil, false
}
// 404 auch bei fehlendem Zugriff: Ein „verboten" verriete, dass es die
// Datei gibt.
access, err := h.acl.ResolveFile(r.Context(), user.ID, id)
if err != nil {
response.Error(w, http.StatusNotFound, "not found")
return nil, uuid.Nil, false
}
f, err := h.files.GetFile(r.Context(), id, access.OwnerID)
if err != nil {
response.Error(w, http.StatusNotFound, "not found")
return nil, uuid.Nil, false
}
return f, user.ID, true
}