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>
311 lines
11 KiB
Go
311 lines
11 KiB
Go
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)
|
||
}
|