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>
230 lines
8.5 KiB
Go
230 lines
8.5 KiB
Go
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
|
||
}
|