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>
272 lines
10 KiB
Go
272 lines
10 KiB
Go
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))
|
||
}
|