432 lines
13 KiB
Go
432 lines
13 KiB
Go
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(read).Get("/fonts", h.ListFonts)
|
|
pr.With(read).Get("/user-paragraph-styles", h.GetUserParagraphStyles)
|
|
pr.With(write).Put("/user-paragraph-styles", h.PutUserParagraphStyles)
|
|
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"`
|
|
ParagraphStyles json.RawMessage `json:"paragraphStyles,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)
|
|
}
|
|
case len(req.ParagraphStyles) > 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, pageSetup or paragraphStyles required"},
|
|
))
|
|
return
|
|
}
|
|
|
|
if len(req.PageSetup) > 0 {
|
|
var pageSetup UltiDocPageSetup
|
|
if err := json.Unmarshal(req.PageSetup, &pageSetup); err == nil {
|
|
doc.PageSetup = &pageSetup
|
|
}
|
|
}
|
|
if len(req.ParagraphStyles) > 0 {
|
|
var paragraphStyles UltiDocParagraphStyles
|
|
if err := json.Unmarshal(req.ParagraphStyles, ¶graphStyles); err == nil {
|
|
doc.ParagraphStyles = ¶graphStyles
|
|
}
|
|
}
|
|
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)
|
|
}
|
|
|
|
func (h *Handler) ListFonts(w http.ResponseWriter, r *http.Request) {
|
|
fonts := h.svc.ListFonts(r.Context())
|
|
apiresponse.WriteJSON(w, http.StatusOK, map[string]any{"fonts": fonts})
|
|
}
|
|
|
|
func (h *Handler) GetUserParagraphStyles(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
|
|
}
|
|
styles, err := h.svc.LoadUserParagraphStyles(r.Context(), ncUser)
|
|
if err != nil {
|
|
apivalidate.WriteInternal(w, r)
|
|
return
|
|
}
|
|
apiresponse.WriteJSON(w, http.StatusOK, styles)
|
|
}
|
|
|
|
func (h *Handler) PutUserParagraphStyles(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 styles UltiDocParagraphStyles
|
|
if err := apivalidate.DecodeJSON(w, r, 512<<10, &styles); err != nil {
|
|
return
|
|
}
|
|
if err := h.svc.SaveUserParagraphStyles(r.Context(), ncUser, styles); err != nil {
|
|
apivalidate.WriteInternal(w, r)
|
|
return
|
|
}
|
|
apiresponse.WriteJSON(w, http.StatusOK, styles)
|
|
}
|