Files
Aether/backend/internal/office/handler.go
T
HoneyandClaude Opus 5 31a027c93a feat(aether): initial commit – self-hosted Cloud, Phasen 1–7
Go-Backend (chi, pgx, golang-migrate) + React/TS-Frontend als PWA.
Enthalten: Auth inkl. OIDC, Dateien mit Versionen und Papierkorb,
Freigaben (Links + intern), WebDAV, Gruppenordner, Admin/Theming,
Volltextsuche mit OCR, eigener PDF-Betrachter und WOPI-Office
(Collabora/OnlyOffice).

Nicht im Repo: deploy/.env (Sitzungsschlüssel, DB- und Admin-Passwort)
sowie alle Daten- und Build-Verzeichnisse – siehe .gitignore.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 09:25:36 +02:00

306 lines
10 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
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
// („<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)
}