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 }