Files
Aether/backend/internal/office/handler.go
T
marcandClaude Opus 5 944b2c63e2 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>
2026-09-16 17:36:31 +02:00

311 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package office
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"github.com/rs/zerolog/log"
"aether/internal/acl"
"aether/internal/auth"
"aether/internal/files"
"aether/internal/storage"
"aether/pkg/response"
)
// maxSaveBytes begrenzt, was der Office-Server zurückschreiben darf.
//
// Ein Textdokument, das größer ist, hat kein Mensch geschrieben. Ohne Grenze
// wäre der Endpunkt eine Möglichkeit, die Platte zu füllen – er ist der
// einzige, der ohne Sitzung schreibt.
const maxSaveBytes = 512 << 20
type Handler struct {
client *Client
signer *Signer
files *files.Store
blobs storage.Storage
acl *acl.Store
// baseURL ist die Adresse dieser Anwendung im **Browser**. Sie steht als
// PostMessageOrigin in der Auskunft – der Office-Server prüft damit, an
// wen er Nachrichten aus dem Rahmen schicken darf.
baseURL string
// wopiURL ist die Adresse, unter der der **Office-Server** hierher findet.
// Aus seinem Container heraus ist „localhost" er selbst.
wopiURL string
}
func NewHandler(c *Client, s *Signer, fileStore *files.Store, blobs storage.Storage, aclStore *acl.Store, publicURL, wopiURL string) *Handler {
if wopiURL == "" {
wopiURL = publicURL
}
return &Handler{
client: c, signer: s, files: fileStore, blobs: blobs, acl: aclStore,
baseURL: strings.TrimRight(publicURL, "/"),
wopiURL: strings.TrimRight(wopiURL, "/"),
}
}
// Enabled sagt der Oberfläche, ob es die Bearbeitung überhaupt gibt. Ein
// nil-Handler heißt „abgeschaltet" – dann ist die Antwort eine Auskunft, kein
// Fehler.
func (h *Handler) Enabled() bool { return h != nil && h.client != nil }
// ── Vom Browser aufgerufen ───────────────────────────────────────────────────
// Session beantwortet GET /api/v1/office/session/{id}.
//
// Gibt zurück, wohin der Browser den Rahmen zeigen soll und mit welchem Token.
// Das Token entsteht **hier**, im angemeldeten Zusammenhang – der WOPI-Weg
// selbst kennt die Sitzung nicht.
func (h *Handler) Session(w http.ResponseWriter, r *http.Request) {
if !h.Enabled() {
response.JSON(w, http.StatusOK, map[string]any{"enabled": false})
return
}
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
response.Error(w, http.StatusNotFound, "not found")
return
}
user := auth.UserFromContext(r.Context())
if user == nil {
response.Error(w, http.StatusUnauthorized, "not signed in")
return
}
// Zugriff auflösen: eigene Datei, geliehene oder Gruppenordner. Wer nur
// lesen darf, bekommt ein Token ohne Schreibrecht – nicht etwa gar keins.
access, err := h.acl.ResolveFile(r.Context(), user.ID, id)
if err != nil {
response.Error(w, http.StatusNotFound, "not found")
return
}
f, err := h.files.GetFile(r.Context(), id, access.OwnerID)
if err != nil {
response.Error(w, http.StatusNotFound, "not found")
return
}
if !Editable(f.Name) {
response.Error(w, http.StatusUnsupportedMediaType, "this file type cannot be opened in Office")
return
}
write := access.Permission == acl.PermWrite
// 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")
return
}
token, exp := h.signer.Issue(f.ID, user.ID, write)
wopiSrc := fmt.Sprintf("%s/wopi/files/%s", h.wopiURL, f.ID)
// Die Adresse aus der Ermittlungsliste trägt Platzhalter in <>-Klammern
// („<ui=UI_LLCC&>"). Alles, was wir nicht setzen, muss weg – sonst
// schickt der Browser die Klammern mit.
src = stripPlaceholders(src)
sep := "?"
if strings.Contains(src, "?") {
sep = "&"
}
// **Vollständig kodiert**, nicht nur die auffälligen Zeichen: Collabora
// weist eine unkodierte WOPISrc zurück („WOPISrc must be URI-encoded.
// This is highly problematic with proxies, load balancers, and when
// tunneling") – Doppelpunkt und Schrägstrich eingeschlossen.
src = fmt.Sprintf("%s%sWOPISrc=%s", src, sep, url.QueryEscape(wopiSrc))
response.JSON(w, http.StatusOK, map[string]any{
"enabled": true,
"provider": h.client.Provider(),
"url": src,
"token": token,
"token_ttl": exp.UnixMilli(),
"can_write": write,
"file_name": f.Name,
})
}
// ── Vom Office-Server aufgerufen (WOPI) ──────────────────────────────────────
// authorize prüft das Token und holt Datei und Zugriffskontext.
//
// Absichtlich ohne Sitzung und ohne CSRF: Diese Aufrufe kommen vom
// Office-Server, nicht aus einem Browser. Das Token ist die einzige Berechtigung
// – und es gilt für **genau eine Datei**, damit ein entwendetes Token nicht
// zum Generalschlüssel wird.
func (h *Handler) authorize(w http.ResponseWriter, r *http.Request, needWrite bool) (*files.File, acl.Access, bool) {
raw := r.URL.Query().Get("access_token")
if raw == "" {
raw = strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
}
access, err := h.signer.Verify(raw)
if err != nil {
// 401, nicht 403: Der Office-Server versucht es damit gar nicht erst
// erneut, statt in eine Schleife zu laufen.
http.Error(w, "invalid or expired token", http.StatusUnauthorized)
return nil, acl.Access{}, false
}
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil || id != access.FileID {
// Das Token gehört zu einer anderen Datei. 404 statt 403 – sonst
// verriete die Antwort, dass es die Datei gibt.
http.Error(w, "not found", http.StatusNotFound)
return nil, acl.Access{}, false
}
if needWrite && !access.Write {
http.Error(w, "read only", http.StatusForbidden)
return nil, acl.Access{}, false
}
// Die Rechte werden **bei jedem Aufruf neu** aufgelöst, nicht aus dem
// Token übernommen: Eine zurückgezogene Freigabe soll sofort wirken und
// nicht erst, wenn das Token abläuft.
ctxAccess, err := h.acl.ResolveFile(r.Context(), access.UserID, id)
if err != nil {
http.Error(w, "not found", http.StatusNotFound)
return nil, acl.Access{}, false
}
if needWrite && ctxAccess.Permission != acl.PermWrite {
http.Error(w, "read only", http.StatusForbidden)
return nil, acl.Access{}, false
}
f, err := h.files.GetFile(r.Context(), id, ctxAccess.OwnerID)
if err != nil {
http.Error(w, "not found", http.StatusNotFound)
return nil, acl.Access{}, false
}
return f, ctxAccess, true
}
// CheckFileInfo beantwortet GET /wopi/files/{id} – die Auskunft über die Datei.
func (h *Handler) CheckFileInfo(w http.ResponseWriter, r *http.Request) {
f, access, ok := h.authorize(w, r, false)
if !ok {
return
}
canWrite := access.Permission == acl.PermWrite
// **Ohne die Hülle `{"data": …}` dieser Anwendung.** WOPI ist ein fremdes
// Protokoll mit festem Antwortformat; ein Feld drumherum verstünde der
// Office-Server nicht.
writeRawJSON(w, map[string]any{
"BaseFileName": f.Name,
"Size": f.Size,
"Version": fmt.Sprintf("%d", f.Version),
// Wer speichert, steht später in der Versionshistorie – dort soll ein
// Name stehen und nicht eine Kennung.
"OwnerId": f.OwnerID.String(),
"UserId": access.OwnerID.String(),
"UserFriendlyName": access.OwnerUsername,
"UserCanWrite": canWrite,
"UserCanNotWriteRelative": true,
// Kein „Speichern unter", kein Drucken-nach-Hause, keine Verweise auf
// fremde Dienste: Was die Datei verlassen könnte, bleibt aus.
"DisablePrint": false,
"DisableExport": false,
"DisableCopy": false,
"HideSaveOption": !canWrite,
"HideExportOption": false,
"PostMessageOrigin": h.baseURL,
"LastModifiedTime": f.UpdatedAt.UTC().Format(time.RFC3339),
})
}
// GetFile beantwortet GET /wopi/files/{id}/contents.
func (h *Handler) GetFile(w http.ResponseWriter, r *http.Request) {
f, _, ok := h.authorize(w, r, false)
if !ok {
return
}
rc, err := h.blobs.Get(r.Context(), f.StorageKey)
if err != nil {
http.Error(w, "storage error", http.StatusInternalServerError)
return
}
defer rc.Close()
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("X-WOPI-ItemVersion", fmt.Sprintf("%d", f.Version))
if _, err := io.Copy(w, rc); err != nil {
log.Ctx(r.Context()).Warn().Err(err).Msg("office: sending file failed")
}
}
// PutFile beantwortet POST /wopi/files/{id}/contents – das Speichern.
func (h *Handler) PutFile(w http.ResponseWriter, r *http.Request) {
f, access, ok := h.authorize(w, r, true)
if !ok {
return
}
body := http.MaxBytesReader(w, r.Body, maxSaveBytes)
defer body.Close()
updated, err := files.ReplaceContent(r.Context(), h.files, h.blobs, f, access, body)
if err != nil {
if errors.Is(err, files.ErrQuotaExceeded) {
// 413 sagt dem Office-Server, dass es an der Größe lag – er zeigt
// das dem Menschen an, statt still nicht zu speichern.
http.Error(w, "storage quota exceeded", http.StatusRequestEntityTooLarge)
return
}
log.Ctx(r.Context()).Error().Err(err).Str("file_id", f.ID.String()).
Msg("office: saving failed")
http.Error(w, "could not save", http.StatusInternalServerError)
return
}
w.Header().Set("X-WOPI-ItemVersion", fmt.Sprintf("%d", updated.Version))
w.WriteHeader(http.StatusOK)
}
// stripPlaceholders entfernt die <…>-Platzhalter aus einer urlsrc.
//
// Die Ermittlungsliste liefert z. B.
//
// https://office/browser/dist/cool.html?<ui=UI_LLCC&><rs=DC_LLCC&>
//
// Jeder Platzhalter, den der Host nicht ersetzt, muss weg – sonst landen die
// spitzen Klammern in der Adresse und der Server lehnt sie ab.
func stripPlaceholders(src string) string {
var b strings.Builder
depth := 0
for _, r := range src {
switch r {
case '<':
depth++
case '>':
if depth > 0 {
depth--
}
default:
if depth == 0 {
b.WriteRune(r)
}
}
}
out := b.String()
// Ein zurückbleibendes „?" oder „&" am Ende stört keinen Server, ist aber
// unsauber in der Adresszeile.
return strings.TrimRight(out, "?&")
}
// writeRawJSON schreibt ein Objekt ohne die Hülle dieser Anwendung.
func writeRawJSON(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(v)
}