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