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>
306 lines
10 KiB
Go
306 lines
10 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
|
||
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)
|
||
}
|