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

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