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

+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))
}