package richtext import ( "encoding/json" "io" "log/slog" "net/http" "strings" "github.com/go-chi/chi/v5" "github.com/ultisuite/ulti-backend/internal/api/apiresponse" "github.com/ultisuite/ulti-backend/internal/api/apivalidate" "github.com/ultisuite/ulti-backend/internal/api/drive" "github.com/ultisuite/ulti-backend/internal/api/middleware" "github.com/ultisuite/ulti-backend/internal/permission" ) type Handler struct { svc *Service drive *drive.Service logger *slog.Logger } func NewHandler(svc *Service, driveSvc *drive.Service) *Handler { return &Handler{ svc: svc, drive: driveSvc, logger: slog.Default().With("component", "richtext-api"), } } func (h *Handler) Routes(authMiddleware func(http.Handler) http.Handler) chi.Router { r := chi.NewRouter() r.Get("/document", h.ServeDocument) r.Put("/document", h.PutDocument) r.Post("/hooks/store", h.HookStore) r.Get("/internal/document", h.InternalLoadDocument) r.Group(func(pr chi.Router) { pr.Use(authMiddleware) read := middleware.RequirePermission(permission.ResourceDrive, permission.LevelRead) write := middleware.RequirePermission(permission.ResourceDrive, permission.LevelWrite) pr.With(read).Post("/session", h.CreateSession) pr.With(read).Post("/import", h.Import) pr.With(read).Post("/export", h.Export) pr.With(write).Post("/assets", h.UploadAsset) pr.With(write).Put("/save", h.Save) }) return r } func (h *Handler) RegisterPublicShareRoutes(r chi.Router) { r.Post("/shares/{token}/richtext/session", h.PublicShareSession) r.Post("/shares/{token}/richtext/import", h.PublicShareImport) r.Get("/shares/{token}/richtext/document", h.PublicShareDocument) r.Put("/shares/{token}/richtext/document", h.PublicSharePutDocument) } type sessionRequest struct { Path string `json:"path"` Mode string `json:"mode"` } func (h *Handler) CreateSession(w http.ResponseWriter, r *http.Request) { claims := middleware.ClaimsFromContext(r.Context()) ncUser, err := h.svc.EnsureNextcloudUser(r.Context(), claims) if err != nil { apivalidate.WriteInternal(w, r) return } var req sessionRequest if err := apivalidate.DecodeJSON(w, r, 32<<10, &req); err != nil { return } if strings.TrimSpace(req.Path) == "" { apivalidate.WriteValidationError(w, r, apivalidate.NewValidationError( apivalidate.FieldDetail{Field: "path", Message: "required"}, )) return } mode := strings.TrimSpace(req.Mode) if mode == "" { mode = "edit" } result, err := h.svc.CreateSession(r.Context(), ncUser, req.Path, mode, claims.Sub, claims.Name) if err != nil { h.logger.Error("richtext session", "error", err) apiresponse.WriteError(w, r, http.StatusBadRequest, apiresponse.CodeInvalidRequest, err.Error(), nil) return } apiresponse.WriteJSON(w, http.StatusOK, result) } func (h *Handler) Import(w http.ResponseWriter, r *http.Request) { claims := middleware.ClaimsFromContext(r.Context()) ncUser, err := h.svc.EnsureNextcloudUser(r.Context(), claims) if err != nil { apivalidate.WriteInternal(w, r) return } var req ImportRequest if err := apivalidate.DecodeJSON(w, r, 32<<20, &req); err != nil { return } canonical, err := h.svc.ImportDocument(r.Context(), ncUser, claims.Sub, req) if err != nil { apiresponse.WriteError(w, r, http.StatusBadRequest, apiresponse.CodeInvalidRequest, err.Error(), nil) return } apiresponse.WriteJSON(w, http.StatusOK, map[string]string{"canonical_path": canonical}) } func (h *Handler) Export(w http.ResponseWriter, r *http.Request) { claims := middleware.ClaimsFromContext(r.Context()) ncUser, err := h.svc.EnsureNextcloudUser(r.Context(), claims) if err != nil { apivalidate.WriteInternal(w, r) return } var req ExportRequest if err := apivalidate.DecodeJSON(w, r, 32<<10, &req); err != nil { return } body, ct, err := h.svc.ExportDocument(r.Context(), ncUser, req.Path, req.Format) if err != nil { apiresponse.WriteError(w, r, http.StatusBadRequest, apiresponse.CodeInvalidRequest, err.Error(), nil) return } w.Header().Set("Content-Type", ct) w.WriteHeader(http.StatusOK) _, _ = w.Write(body) } type saveRequest struct { Path string `json:"path"` Document json.RawMessage `json:"document"` PageSetup json.RawMessage `json:"pageSetup,omitempty"` YjsState string `json:"yjsState,omitempty"` } func (h *Handler) Save(w http.ResponseWriter, r *http.Request) { claims := middleware.ClaimsFromContext(r.Context()) ncUser, err := h.svc.EnsureNextcloudUser(r.Context(), claims) if err != nil { apivalidate.WriteInternal(w, r) return } var req saveRequest if err := apivalidate.DecodeJSON(w, r, 32<<20, &req); err != nil { return } path := normalizePath(req.Path) existingRaw, _ := h.svc.LoadDocument(r.Context(), ncUser, path) var existing UltiDoc if len(existingRaw) > 0 { if parsed, err := ParseUltiDoc(existingRaw); err == nil { existing = parsed } } var doc UltiDoc switch { case len(req.Document) > 0: doc = NewUltiDoc(req.Document, nil) case len(req.PageSetup) > 0: if existing.SchemaVersion > 0 { doc = existing } else { doc = NewUltiDoc(nil, nil) } default: apivalidate.WriteValidationError(w, r, apivalidate.NewValidationError( apivalidate.FieldDetail{Field: "document", Message: "document or pageSetup required"}, )) return } if len(req.PageSetup) > 0 { var pageSetup UltiDocPageSetup if err := json.Unmarshal(req.PageSetup, &pageSetup); err == nil { doc.PageSetup = &pageSetup } } if req.YjsState != "" { doc.YjsState = req.YjsState } preserveUltiDocMetadata(&doc, existing) payload, err := doc.Marshal() if err != nil { apivalidate.WriteInternal(w, r) return } if err := h.svc.SaveDocument(r.Context(), ncUser, req.Path, payload, claims.Sub); err != nil { apivalidate.WriteInternal(w, r) return } apiresponse.WriteJSON(w, http.StatusOK, map[string]string{"path": normalizePath(req.Path)}) } func (h *Handler) ServeDocument(w http.ResponseWriter, r *http.Request) { sig := strings.TrimSpace(r.URL.Query().Get("sig")) path := strings.TrimSpace(r.URL.Query().Get("path")) user := strings.TrimSpace(r.URL.Query().Get("user")) if h.svc.Cfg.HocuspocusSecret != "" { if _, err := verifyDocAccessSig(sig, user, path, h.svc.Cfg.HocuspocusSecret); err != nil { http.Error(w, "forbidden", http.StatusForbidden) return } } body, err := h.svc.LoadDocumentForUser(r.Context(), user, path) if err != nil { http.Error(w, "not found", http.StatusNotFound) return } w.Header().Set("Content-Type", "application/json") _, _ = w.Write(body) } func (h *Handler) PutDocument(w http.ResponseWriter, r *http.Request) { sig := strings.TrimSpace(r.URL.Query().Get("sig")) path := strings.TrimSpace(r.URL.Query().Get("path")) user := strings.TrimSpace(r.URL.Query().Get("user")) platformUser := strings.TrimSpace(r.URL.Query().Get("sub")) if h.svc.Cfg.HocuspocusSecret != "" { if _, err := verifyDocAccessSig(sig, user, path, h.svc.Cfg.HocuspocusSecret); err != nil { http.Error(w, "forbidden", http.StatusForbidden) return } } raw, err := io.ReadAll(r.Body) if err != nil { apivalidate.WriteInternal(w, r) return } existingRaw, _ := h.svc.LoadDocumentForUser(r.Context(), user, path) var existing UltiDoc if len(existingRaw) > 0 { if parsed, parseErr := ParseUltiDoc(existingRaw); parseErr == nil { existing = parsed } } doc, err := ApplyUltiDocPatch(existing, raw) if err != nil { apivalidate.WriteValidationError(w, r, apivalidate.NewValidationError( apivalidate.FieldDetail{Field: "document", Message: "invalid JSON"}, )) return } payload, err := doc.Marshal() if err != nil { apivalidate.WriteInternal(w, r) return } if err := h.svc.SaveDocument(r.Context(), user, path, payload, platformUser); err != nil { http.Error(w, "save failed", http.StatusInternalServerError) return } w.WriteHeader(http.StatusNoContent) } func (h *Handler) InternalLoadDocument(w http.ResponseWriter, r *http.Request) { secret := r.Header.Get("X-Hocuspocus-Secret") if h.svc.Cfg.HocuspocusSecret != "" && secret != h.svc.Cfg.HocuspocusSecret { http.Error(w, "forbidden", http.StatusForbidden) return } user := strings.TrimSpace(r.URL.Query().Get("user")) path := strings.TrimSpace(r.URL.Query().Get("path")) body, err := h.svc.LoadDocumentForUser(r.Context(), user, path) if err != nil { http.Error(w, "not found", http.StatusNotFound) return } w.Header().Set("Content-Type", "application/json") _, _ = w.Write(body) } type hookStorePayload struct { Room string `json:"room"` Path string `json:"path"` User string `json:"user"` Sub string `json:"sub"` YjsState string `json:"yjsState"` Document json.RawMessage `json:"document,omitempty"` } func (h *Handler) HookStore(w http.ResponseWriter, r *http.Request) { secret := r.Header.Get("X-Hocuspocus-Secret") if h.svc.Cfg.HocuspocusSecret != "" && secret != h.svc.Cfg.HocuspocusSecret { http.Error(w, "forbidden", http.StatusForbidden) return } var payload hookStorePayload if err := apivalidate.DecodeJSON(w, r, 32<<20, &payload); err != nil { return } path := normalizePath(payload.Path) existingRaw, _ := h.svc.LoadDocumentForUser(r.Context(), payload.User, path) var existing UltiDoc if len(existingRaw) > 0 { if parsed, err := ParseUltiDoc(existingRaw); err == nil { existing = parsed } } var raw []byte if len(payload.Document) > 0 { doc := NewUltiDoc(payload.Document, nil) doc.YjsState = payload.YjsState preserveUltiDocMetadata(&doc, existing) if isEmptyDocContent(doc.Content) && len(existingRaw) > 0 && !isEmptyDocContent(existing.Content) { w.WriteHeader(http.StatusNoContent) return } var err error raw, err = doc.Marshal() if err != nil { apivalidate.WriteInternal(w, r) return } } else if payload.YjsState != "" { doc := UltiDoc{SchemaVersion: schemaVersion, Editor: "tiptap", YjsState: payload.YjsState, Content: emptyDocContent()} preserveUltiDocMetadata(&doc, existing) if isEmptyDocContent(doc.Content) && len(existingRaw) > 0 && !isEmptyDocContent(existing.Content) { w.WriteHeader(http.StatusNoContent) return } var err error raw, err = doc.Marshal() if err != nil { apivalidate.WriteInternal(w, r) return } } else { apivalidate.WriteValidationError(w, r, apivalidate.NewValidationError( apivalidate.FieldDetail{Field: "document", Message: "required"}, )) return } if err := h.svc.SaveDocument(r.Context(), payload.User, path, raw, payload.Sub); err != nil { h.logger.Error("hook store", "error", err, "path", path) apivalidate.WriteInternal(w, r) return } w.WriteHeader(http.StatusNoContent) } type assetUploadRequest struct { Path string `json:"path"` DataURL string `json:"dataUrl"` } func (h *Handler) UploadAsset(w http.ResponseWriter, r *http.Request) { claims := middleware.ClaimsFromContext(r.Context()) ncUser, err := h.svc.EnsureNextcloudUser(r.Context(), claims) if err != nil { apivalidate.WriteInternal(w, r) return } var req assetUploadRequest if err := apivalidate.DecodeJSON(w, r, 16<<20, &req); err != nil { return } if strings.TrimSpace(req.Path) == "" || strings.TrimSpace(req.DataURL) == "" { apivalidate.WriteValidationError(w, r, apivalidate.NewValidationError( apivalidate.FieldDetail{Field: "path", Message: "required"}, apivalidate.FieldDetail{Field: "dataUrl", Message: "required"}, )) return } result, err := h.svc.UploadGraphicAsset(r.Context(), ncUser, req.Path, req.DataURL) if err != nil { apiresponse.WriteError(w, r, http.StatusBadRequest, apiresponse.CodeInvalidRequest, err.Error(), nil) return } apiresponse.WriteJSON(w, http.StatusOK, result) }