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 src, err := h.client.EditURL(r.Context(), Extension(f.Name), write) 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 // („"). 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? // // 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) }