feat(office): mobileEdit am Telefon, PDF-Umweg entfällt
Ein Office-Dokument öffnete am Telefon als gewandeltes PDF, weil OnlyOffices Oberfläche auf 400 Pixeln nichts vom Dokument übrig lässt. Die Begründung war falsch: Die Ermittlungsliste bietet zu jeder Endung mehrere Aktionen an, und OnlyOffice/Euro-Office führen neben `edit` auch `mobileEdit` – dieselbe Adresse mit `?mobile=1`, dahinter eine für kleine Schirme gebaute Oberfläche. `EditURL` kannte nur `edit` und `view`; die Handy-Fassung lag die ganze Zeit in der Tabelle und wurde nie abgerufen. Entschieden wird am Zeigegerät, nicht an der Fensterbreite – ein schmales Fenster auf dem Schreibtisch ist immer noch eine Maus. Mit dem Anlass fällt die Notlösung: preview.go (229 Zeilen), convert.go (271) samt Test, files.preview_version (Migration 000026), die Sonderabfrage im GC, die Route und dreizehn Fundstellen im Frontend. convert.go hatte keinen anderen Aufrufer – die gesamte Umwandlung nach PDF existierte allein für diese Ansicht. Wer ein PDF braucht, exportiert es im Editor. Die Dateien unter previews/ sammelt der GC ein. Zwei Fehler, die dabei sichtbar wurden: fix(history): „Edit" schloss die Datei, statt den Editor zu öffnen. Beim Wechsel räumt React erst die alte Ansicht ab; der Aufräumer von useBackClose ruft history.back(), das verzögert wirkt und deshalb den Eintrag verbrauchte, den der Editor gerade angelegt hatte. Ein Zähler für selbst ausgelöste Schritte verschluckt genau diese popstate. fix(filetypes): `.txt` ging gar nicht auf. Es stand in officeExtensions und lief damit in den Office-Zweig statt in den eingebauten Text-Editor – am Telefon in die PDF-Umwandlung, die daran scheiterte. `md`, `log` und `rst` standen nie drin; das war die Asymmetrie. Dazu Euro-Office als Compose-Profil (Abbild 6,99 GB, nachgemessen – die Ankündigung nennt 5) und die Android-App als Idee. Geprüft: go build/vet sauber, tsc sauber, 37 Vitest-Tests, alle 234 Prüfungen der sieben Selbsttest-Suiten grün. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
6042f6f2f6
commit
944b2c63e2
23 files changed
+284
-788
No files matched your search
@@ -511,7 +511,6 @@ func main() {
|
||||
r.Get("/office/session/{id}", officeHandler.Session)
|
||||
// Dasselbe Dokument als PDF – zum Ansehen, ohne den Editor. Am
|
||||
// Telefon der Normalfall.
|
||||
r.Get("/files/{id}/preview.pdf", officeHandler.PreviewPDF)
|
||||
r.Patch("/files/{id}/versioning", fileHandler.SetVersioning)
|
||||
r.Get("/files/{id}/versions", fileHandler.ListVersions)
|
||||
r.Get("/files/{id}/versions/{versionId}/download", fileHandler.DownloadVersion)
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
-- Zurück: Die Spalte kommt leer wieder. Ein Wert wäre gelogen – die PDFs
|
||||
-- unter previews/ hat der GC inzwischen abgeräumt, und 0 heißt ohnehin
|
||||
-- „noch nichts gewandelt".
|
||||
ALTER TABLE files ADD COLUMN IF NOT EXISTS preview_version INT NOT NULL DEFAULT 0;
|
||||
@@ -0,0 +1,11 @@
|
||||
-- Die Vorschau als PDF ist entfallen (16.09.2026).
|
||||
--
|
||||
-- Office-Dokumente öffnen jetzt direkt im Editor – am Telefon in dessen
|
||||
-- eigener Handy-Fassung (`mobileEdit`). Der Umweg über ein gewandeltes PDF
|
||||
-- war eine Notlösung für eine Oberfläche, die Aether selbst falsch
|
||||
-- angefordert hatte; mit ihm fällt auch die Spalte weg, die vermerkte, zu
|
||||
-- welcher Fassung das abgelegte PDF gehörte.
|
||||
--
|
||||
-- Die Dateien unter previews/ räumt der GC ab: Ohne diese Spalte kennt sie
|
||||
-- keine Zeile mehr, und was keine Zeile kennt, ist für ihn verwaist.
|
||||
ALTER TABLE files DROP COLUMN IF EXISTS preview_version;
|
||||
@@ -537,29 +537,7 @@ func isUniqueViolation(err error) bool {
|
||||
// PreviewVersion sagt, aus welcher Fassung das zwischengespeicherte PDF
|
||||
// entstanden ist – 0 heißt: es gibt keins.
|
||||
//
|
||||
// Eine eigene kleine Abfrage statt einer Spalte in `fileCols`: Der Wert wird
|
||||
// an genau einer Stelle gebraucht und ginge sonst durch jeden Scan, jede
|
||||
// Liste und jede Suche mit.
|
||||
func (s *Store) PreviewVersion(ctx context.Context, id uuid.UUID) (int, error) {
|
||||
var v int
|
||||
err := s.db.QueryRow(ctx, `SELECT preview_version FROM files WHERE id = $1`, id).Scan(&v)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return 0, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("preview version: %w", err)
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// SetPreviewVersion vermerkt, dass das abgelegte PDF zu dieser Fassung gehört.
|
||||
func (s *Store) SetPreviewVersion(ctx context.Context, id uuid.UUID, version int) error {
|
||||
_, err := s.db.Exec(ctx, `UPDATE files SET preview_version = $2 WHERE id = $1`, id, version)
|
||||
if err != nil {
|
||||
return fmt.Errorf("set preview version: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// FilesWithPreview liefert die Kennungen aller Dateien, zu denen ein PDF
|
||||
// abgelegt ist. Der GC braucht das: Ohne diese Liste hielte er jedes
|
||||
|
||||
@@ -73,6 +73,11 @@ func (c *Collector) Run(ctx context.Context) (Result, error) {
|
||||
|
||||
// "tmp/" bleibt außen vor: Das gehört dem Upload-Pfad und wird dort
|
||||
// aufgeräumt; ein GC, der dort mitmischt, zerrisse laufende Uploads.
|
||||
// "previews" steht hier weiterhin, obwohl nichts mehr hineinschreibt: Die
|
||||
// als PDF gewandelten Office-Dokumente sind am 16.09.2026 entfallen, und
|
||||
// die zurückgebliebenen Dateien kennt keine Datenbankzeile mehr. Genau so
|
||||
// sollen sie eingesammelt werden. Nimmt man den Ordner aus der Liste,
|
||||
// bleiben sie für immer liegen.
|
||||
for _, prefix := range []string{"users", "thumbs", "branding", "avatars", "previews"} {
|
||||
err := c.storage.Walk(ctx, prefix, func(key string, info storage.FileInfo) error {
|
||||
res.Scanned++
|
||||
@@ -154,27 +159,6 @@ func (c *Collector) referencedKeys(ctx context.Context) (map[string]bool, error)
|
||||
return nil, fmt.Errorf("iterate file keys: %w", err)
|
||||
}
|
||||
|
||||
// Die als PDF angesehenen Office-Dokumente. Ohne diesen Griff hielte der
|
||||
// GC jedes Vorschau-PDF für herrenlos und löschte es nach einem Tag – der
|
||||
// Fehler, der uns beim Logo und beim Favicon schon passiert ist.
|
||||
prevRows, err := c.db.Query(ctx,
|
||||
`SELECT id::text FROM files WHERE preview_version > 0`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query preview keys: %w", err)
|
||||
}
|
||||
for prevRows.Next() {
|
||||
var id string
|
||||
if err := prevRows.Scan(&id); err != nil {
|
||||
prevRows.Close()
|
||||
return nil, fmt.Errorf("scan preview key: %w", err)
|
||||
}
|
||||
keys["previews/"+id+".pdf"] = true
|
||||
}
|
||||
prevRows.Close()
|
||||
if err := prevRows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate preview keys: %w", err)
|
||||
}
|
||||
|
||||
// Logo und App-Symbol haben keine Zeile in files, sondern stehen in den
|
||||
// Einstellungen. Ohne diese Abfrage hielte der GC sie für verwaist, sobald
|
||||
// "branding" mit durchsucht wird – und löschte sie beim nächsten Lauf.
|
||||
|
||||
@@ -1,271 +0,0 @@
|
||||
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))
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -128,15 +128,33 @@ func (c *Client) Provider() string { return c.provider }
|
||||
//
|
||||
// Fällt auf „view" zurück, wenn der Server für diesen Typ kein Bearbeiten
|
||||
// anbietet – lesen ist besser als eine Fehlermeldung.
|
||||
func (c *Client) EditURL(ctx context.Context, ext string, write bool) (string, error) {
|
||||
func (c *Client) EditURL(ctx context.Context, ext string, write, mobile bool) (string, error) {
|
||||
if err := c.refresh(ctx); err != nil {
|
||||
return "", err
|
||||
}
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
// Am Finger zuerst die Handy-Fassung.
|
||||
//
|
||||
// Die Ermittlungsliste bietet zu jeder Endung mehrere Aktionen an –
|
||||
// OnlyOffice und Euro-Office nennen neben `edit`/`view` auch
|
||||
// `mobileEdit`/`mobileView`, und dahinter steckt eine eigene, für kleine
|
||||
// Schirme gebaute Oberfläche. Aether hat bisher immer die
|
||||
// Schreibtisch-Fassung angefordert; am Telefon blieb vom Dokument
|
||||
// zwischen Menüband und Seitenspalten ein Rechteck übrig. Genau das war
|
||||
// der Anlass für den PDF-Umweg – eine Notlösung für eine Oberfläche, die
|
||||
// wir selbst falsch bestellt hatten.
|
||||
//
|
||||
// Collabora kennt diese Aktionen nicht; dort greift schlicht der nächste
|
||||
// Eintrag, und es bleibt beim bisherigen Verhalten.
|
||||
order := []string{"edit", "view"}
|
||||
if !write {
|
||||
switch {
|
||||
case mobile && write:
|
||||
order = []string{"mobileEdit", "edit", "mobileView", "view"}
|
||||
case mobile:
|
||||
order = []string{"mobileView", "view", "mobileEdit", "edit"}
|
||||
case !write:
|
||||
order = []string{"view", "edit"}
|
||||
}
|
||||
for _, action := range order {
|
||||
|
||||
@@ -100,7 +100,12 @@ func (h *Handler) Session(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
write := access.Permission == acl.PermWrite
|
||||
src, err := h.client.EditURL(r.Context(), Extension(f.Name), write)
|
||||
// Finger oder Maus? Das entscheidet die Oberfläche, nicht der Server:
|
||||
// Ein schmales Fenster auf dem Schreibtisch ist immer noch eine Maus.
|
||||
// Deshalb fragen wir nicht die Kennung des Browsers, sondern lassen die
|
||||
// Anwendung sagen, was sie über ihr Zeigegerät weiß.
|
||||
mobile := r.URL.Query().Get("mobile") == "1"
|
||||
src, err := h.client.EditURL(r.Context(), Extension(f.Name), write, mobile)
|
||||
if err != nil {
|
||||
log.Ctx(r.Context()).Warn().Err(err).Msg("office: discovery failed")
|
||||
response.Error(w, http.StatusBadGateway, "the office server is not reachable")
|
||||
|
||||
@@ -1,229 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -231,6 +231,5 @@ func (s *Scanner) remove(ctx context.Context, id uuid.UUID) error {
|
||||
_ = s.storage.Delete(ctx, k)
|
||||
}
|
||||
_ = s.storage.Delete(ctx, "thumbs/"+id.String()+".jpg")
|
||||
_ = s.storage.Delete(ctx, "previews/"+id.String()+".pdf")
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
-- Zurück: Die Spalte kommt leer wieder. Ein Wert wäre gelogen – die PDFs
|
||||
-- unter previews/ hat der GC inzwischen abgeräumt, und 0 heißt ohnehin
|
||||
-- „noch nichts gewandelt".
|
||||
ALTER TABLE files ADD COLUMN IF NOT EXISTS preview_version INT NOT NULL DEFAULT 0;
|
||||
@@ -0,0 +1,11 @@
|
||||
-- Die Vorschau als PDF ist entfallen (16.09.2026).
|
||||
--
|
||||
-- Office-Dokumente öffnen jetzt direkt im Editor – am Telefon in dessen
|
||||
-- eigener Handy-Fassung (`mobileEdit`). Der Umweg über ein gewandeltes PDF
|
||||
-- war eine Notlösung für eine Oberfläche, die Aether selbst falsch
|
||||
-- angefordert hatte; mit ihm fällt auch die Spalte weg, die vermerkte, zu
|
||||
-- welcher Fassung das abgelegte PDF gehörte.
|
||||
--
|
||||
-- Die Dateien unter previews/ räumt der GC ab: Ohne diese Spalte kennt sie
|
||||
-- keine Zeile mehr, und was keine Zeile kennt, ist für ihn verwaist.
|
||||
ALTER TABLE files DROP COLUMN IF EXISTS preview_version;
|
||||
Reference in new issue
Block a user