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

+7 -1
View File
@@ -216,7 +216,7 @@ func main() {
var officeHandler *office.Handler
if cfg.OfficeEnabled() {
client, err := office.NewClient(cfg.OfficeProvider, cfg.OfficeURL, cfg.OfficePublicURL,
cfg.OfficeAllowedHosts, cfg.OfficeInsecureTLS)
cfg.OfficeAllowedHosts, cfg.OfficeInsecureTLS, cfg.OfficeJWTSecret)
if err != nil {
// Kein Fatal: Eine falsche Office-Adresse soll die Cloud nicht am
// Starten hindern. Dateien bleiben erreichbar, nur das Bearbeiten
@@ -449,6 +449,9 @@ func main() {
// Sitzung fuer den Office-Rahmen: Hier entsteht das WOPI-Token, im
// angemeldeten Zusammenhang.
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)
@@ -529,6 +532,9 @@ func main() {
r.Get("/admin/audit", adminHandler.ListAudit)
r.Get("/admin/audit/actions", adminHandler.ListAuditActions)
r.Post("/admin/gc", adminHandler.RunGC)
// Was Vorschaubilder, Profilbilder und gewandelte Dokumente belegen –
// Platz, der in keinem Kontingent auftaucht.
r.Get("/admin/storage/derived", adminHandler.DerivedStorage)
// Immer erreichbar, auch wenn OCR aus ist – der Handler antwortet dann
// mit enabled:false. Eine Route, die je nach Konfiguration 404 liefert,
// wäre für die Oberfläche nicht von einem Fehler zu unterscheiden.
@@ -0,0 +1 @@
ALTER TABLE files DROP COLUMN IF EXISTS preview_version;
@@ -0,0 +1,9 @@
-- Die Fassung, aus der das zwischengespeicherte PDF entstanden ist.
--
-- Der Blob heißt `previews/<datei-id>.pdf` – **ohne** Fassungsnummer im
-- Namen. Damit kann es je Datei nie mehr als eine Vorschau geben: Eine neue
-- Fassung überschreibt die alte, statt eine zweite danebenzulegen. Ob die
-- vorhandene noch zum Inhalt passt, sagt diese Spalte.
--
-- 0 heißt "keine". Dateifassungen zählen ab 1.
ALTER TABLE files ADD COLUMN IF NOT EXISTS preview_version INT NOT NULL DEFAULT 0;
+21
View File
@@ -97,3 +97,24 @@ func (h *Handler) RunGC(w http.ResponseWriter, r *http.Request) {
"skipped_young": res.SkippedYoung,
})
}
// GET /api/v1/admin/storage/derived – Platzbedarf der abgeleiteten Daten.
//
// Getrennt vom Aufräumen, weil es eine Auskunft ist und keine Handlung: Man
// soll nachsehen können, ohne etwas anzustoßen.
func (h *Handler) DerivedStorage(w http.ResponseWriter, r *http.Request) {
if _, ok := requireAdmin(r); !ok {
response.Error(w, http.StatusForbidden, "admin required")
return
}
if h.gc == nil {
response.Error(w, http.StatusServiceUnavailable, "not available")
return
}
usage, err := h.gc.Derived(r.Context())
if err != nil {
response.Error(w, http.StatusInternalServerError, "could not read storage")
return
}
response.JSON(w, http.StatusOK, usage)
}
+5
View File
@@ -104,6 +104,10 @@ func (h *Handler) EmptyTrash(w http.ResponseWriter, r *http.Request) {
}
for _, id := range fileIDs {
_ = h.storage.Delete(r.Context(), "thumbs/"+id.String()+".jpg")
// Das als PDF gewandelte Office-Dokument. Es trägt denselben Inhalt
// wie die Datei – bleibt es liegen, überlebt der Inhalt das endgültige
// Löschen.
_ = h.storage.Delete(r.Context(), "previews/"+id.String()+".pdf")
}
log.Ctx(r.Context()).Info().
@@ -139,6 +143,7 @@ func (h *Handler) HardDelete(w http.ResponseWriter, r *http.Request) {
_ = h.storage.Delete(r.Context(), key)
}
_ = h.storage.Delete(r.Context(), "thumbs/"+id.String()+".jpg")
_ = h.storage.Delete(r.Context(), "previews/"+id.String()+".pdf")
h.logFile(r, audit.ActionHardDelete, id.String(), "", "permanently deleted from trash")
response.NoContent(w)
}
+47
View File
@@ -533,3 +533,50 @@ func isUniqueViolation(err error) bool {
}
return false
}
// 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
// Vorschau-PDF für herrenlos und löschte es nach einem Tag weg.
func (s *Store) FilesWithPreview(ctx context.Context) ([]string, error) {
rows, err := s.db.Query(ctx, `SELECT id::text FROM files WHERE preview_version > 0`)
if err != nil {
return nil, fmt.Errorf("files with preview: %w", err)
}
defer rows.Close()
var ids []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, err
}
ids = append(ids, id)
}
return ids, rows.Err()
}
+57 -1
View File
@@ -72,7 +72,7 @@ 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.
for _, prefix := range []string{"users", "thumbs", "branding", "avatars"} {
for _, prefix := range []string{"users", "thumbs", "branding", "avatars", "previews"} {
err := c.storage.Walk(ctx, prefix, func(key string, info storage.FileInfo) error {
res.Scanned++
if referenced[key] {
@@ -153,6 +153,27 @@ 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.
@@ -263,3 +284,38 @@ func (c *Collector) Start(ctx context.Context, onDone func(Result)) {
}
}()
}
// DerivedUsage: Wie viel Platz die **abgeleiteten** Daten belegen.
//
// Vorschaubilder, Profilbilder und die als PDF gewandelten Office-Dokumente
// zählen nicht gegen das Kontingent eines Kontos – niemand hat sie
// hochgeladen, und sie entstehen jederzeit neu. Platte kosten sie trotzdem,
// und in der Anzeige „X von Y GB belegt" tauchen sie nicht auf.
//
// Genau deshalb steht die Zahl hier: Ein Ordner, der wächst, ohne dass ihn
// irgendwo jemand sieht, ist eine stille Überraschung. Sichtbar ist sie eine
// Größe, über die man entscheiden kann.
type DerivedUsage struct {
Prefix string `json:"prefix"`
Files int `json:"files"`
Bytes int64 `json:"bytes"`
}
// Derived zählt die abgeleiteten Ordner durch. Reines Lesen – dieser Aufruf
// löscht nichts.
func (c *Collector) Derived(ctx context.Context) ([]DerivedUsage, error) {
out := make([]DerivedUsage, 0, 3)
for _, prefix := range []string{"thumbs", "previews", "avatars"} {
u := DerivedUsage{Prefix: prefix}
err := c.storage.Walk(ctx, prefix, func(_ string, info storage.FileInfo) error {
u.Files++
u.Bytes += info.Size
return nil
})
if err != nil {
return nil, fmt.Errorf("walk %s: %w", prefix, err)
}
out = append(out, u)
}
return out, nil
}
+271
View File
@@ -0,0 +1,271 @@
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))
}
+122
View File
@@ -0,0 +1,122 @@
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)
}
}
}
+6 -2
View File
@@ -48,7 +48,10 @@ type Client struct {
// selbst sieht – im Compose-Netz also `collabora:9980`. Die schickt man
// keinem Browser.
publicBase string
http *http.Client
// jwtSecret signiert Aufrufe an die **eigene** API von OnlyOffice (die
// Umwandlung). Der WOPI-Weg braucht ihn nicht, der hat eigene Token.
jwtSecret string
http *http.Client
mu sync.Mutex
actions map[string]string // ".docx|edit" -> urlsrc
@@ -62,7 +65,7 @@ type Client struct {
// auf – ohne Grenze wird daraus ein Weg ins interne Netz, sobald jemand die
// Konfiguration beeinflussen kann (SSRF). Die Adresse aus OFFICE_URL zählt
// immer dazu, sonst wäre die Voreinstellung unbrauchbar.
func NewClient(provider, baseURL, publicURL string, allowedHosts []string, insecureTLS bool) (*Client, error) {
func NewClient(provider, baseURL, publicURL string, allowedHosts []string, insecureTLS bool, jwtSecret string) (*Client, error) {
u, err := url.Parse(baseURL)
if err != nil || u.Host == "" {
return nil, fmt.Errorf("OFFICE_URL is not a valid address: %q", baseURL)
@@ -96,6 +99,7 @@ func NewClient(provider, baseURL, publicURL string, allowedHosts []string, insec
provider: provider,
baseURL: strings.TrimRight(baseURL, "/"),
publicBase: strings.TrimRight(publicURL, "/"),
jwtSecret: jwtSecret,
http: &http.Client{Timeout: 20 * time.Second, Transport: transport},
}, nil
}
+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
}
+6
View File
@@ -113,6 +113,11 @@ type Config struct {
// OfficeInsecureTLS erlaubt ein selbstsigniertes Zertifikat am
// Office-Server. Nur für den Testaufbau auf demselben Rechner.
OfficeInsecureTLS bool
// OfficeJWTSecret signiert Aufrufe an die eigene API von OnlyOffice und
// Euro-Office – gebraucht wird das für die Umwandlung nach PDF. Collabora
// kennt es nicht. Fehlt es, während der Server `JWT_ENABLED` hat,
// antwortet er auf jede Umwandlung mit `{"error":-8}`.
OfficeJWTSecret string
}
// splitList zerlegt eine kommagetrennte Liste und wirft Leeres weg.
@@ -199,6 +204,7 @@ func Load() (*Config, error) {
OfficeProvider: strings.ToLower(strings.TrimSpace(getEnv("OFFICE_PROVIDER", ""))),
OfficeURL: strings.TrimRight(getEnv("OFFICE_URL", ""), "/"),
OfficeJWTSecret: getEnv("OFFICE_JWT_SECRET", ""),
OfficePublicURL: strings.TrimRight(getEnv("OFFICE_PUBLIC_URL", ""), "/"),
HoneybarOrigin: strings.TrimRight(getEnv("HONEYBAR_ORIGIN", ""), "/"),
HoneybarKey: getEnv("HONEYBAR_KEY", ""),
@@ -0,0 +1 @@
ALTER TABLE files DROP COLUMN IF EXISTS preview_version;
@@ -0,0 +1,9 @@
-- Die Fassung, aus der das zwischengespeicherte PDF entstanden ist.
--
-- Der Blob heißt `previews/<datei-id>.pdf` – **ohne** Fassungsnummer im
-- Namen. Damit kann es je Datei nie mehr als eine Vorschau geben: Eine neue
-- Fassung überschreibt die alte, statt eine zweite danebenzulegen. Ob die
-- vorhandene noch zum Inhalt passt, sagt diese Spalte.
--
-- 0 heißt "keine". Dateifassungen zählen ab 1.
ALTER TABLE files ADD COLUMN IF NOT EXISTS preview_version INT NOT NULL DEFAULT 0;