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:
1 parent
84ae570305
commit
9c5cda75d7
22 files changed
+959
-12
No files matched your search
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user