package richtext import ( "encoding/json" "io" "net/http" "strings" "time" "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/nextcloud" ) type publicSessionRequest struct { Path string `json:"path"` Mode string `json:"mode"` Password string `json:"password"` GuestID string `json:"guest_id"` GuestName string `json:"guest_name"` DisplayName string `json:"display_name"` } func (h *Handler) PublicShareSession(w http.ResponseWriter, r *http.Request) { token := strings.TrimSpace(chi.URLParam(r, "token")) if token == "" { apivalidate.WriteNotFound(w, r, "not found") return } var req publicSessionRequest 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 } password := strings.TrimSpace(req.Password) perms, err := h.svc.nc.EffectivePublicSharePermissions(r.Context(), token, req.Path, password) if err != nil { apivalidate.WriteInternal(w, r) return } if !nextcloud.PublicShareCanRead(perms) { http.Error(w, "forbidden", http.StatusForbidden) return } mode := strings.TrimSpace(req.Mode) if mode == "" { mode = "edit" } if mode == "edit" && !nextcloud.PublicShareCanUpdate(perms) { mode = "view" } guestID := strings.TrimSpace(req.GuestID) if guestID == "" { guestID = "public-guest" } else { guestID = "public:" + guestID } guestName := strings.TrimSpace(req.GuestName) if guestName == "" { guestName = "Invité" } result, err := h.svc.CreatePublicSession(r.Context(), token, req.Path, mode, password, guestID, guestName, strings.TrimSpace(req.DisplayName)) if err != nil { h.logger.Error("public richtext session", "error", err) apivalidate.WriteInternal(w, r) return } result.Mode = mode apiresponse.WriteJSON(w, http.StatusOK, result) } type publicImportRequest struct { Path string `json:"path"` SourcePath string `json:"source_path"` Password string `json:"password"` DisplayName string `json:"display_name"` Content json.RawMessage `json:"content"` } func (h *Handler) PublicShareImport(w http.ResponseWriter, r *http.Request) { token := strings.TrimSpace(chi.URLParam(r, "token")) if token == "" { apivalidate.WriteNotFound(w, r, "not found") return } var req publicImportRequest if err := apivalidate.DecodeJSON(w, r, 32<<20, &req); err != nil { return } password := strings.TrimSpace(req.Password) source := strings.TrimSpace(req.SourcePath) if source == "" { source = strings.TrimSpace(req.Path) } if source == "" { apivalidate.WriteValidationError(w, r, apivalidate.NewValidationError( apivalidate.FieldDetail{Field: "source_path", Message: "required"}, )) return } perms, err := h.svc.nc.EffectivePublicSharePermissions(r.Context(), token, source, password) if err != nil || !nextcloud.PublicShareCanUpdate(perms) { http.Error(w, "forbidden", http.StatusForbidden) return } canonical, err := h.svc.ImportPublicDocument(r.Context(), token, password, strings.TrimSpace(req.DisplayName), ImportRequest{ SourcePath: source, Content: req.Content, }) 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) PublicShareDocument(w http.ResponseWriter, r *http.Request) { token := strings.TrimSpace(chi.URLParam(r, "token")) path := strings.TrimSpace(r.URL.Query().Get("path")) password := strings.TrimSpace(r.URL.Query().Get("password")) sig := strings.TrimSpace(r.URL.Query().Get("sig")) if h.svc.Cfg.HocuspocusSecret != "" && !verifyPublicDocAccess(token, path, password, sig, h.svc.Cfg.HocuspocusSecret) { http.Error(w, "forbidden", http.StatusForbidden) return } body, err := h.svc.LoadPublicDocumentLegacy(r.Context(), token, path, password) if err != nil { http.Error(w, "not found", http.StatusNotFound) return } w.Header().Set("Content-Type", "application/json") _, _ = w.Write(body) } func (h *Handler) PublicSharePutDocument(w http.ResponseWriter, r *http.Request) { token := strings.TrimSpace(chi.URLParam(r, "token")) path := strings.TrimSpace(r.URL.Query().Get("path")) password := strings.TrimSpace(r.URL.Query().Get("password")) sig := strings.TrimSpace(r.URL.Query().Get("sig")) if h.svc.Cfg.HocuspocusSecret != "" && !verifyPublicDocAccess(token, path, password, sig, h.svc.Cfg.HocuspocusSecret) { http.Error(w, "forbidden", http.StatusForbidden) return } perms, err := h.svc.nc.EffectivePublicSharePermissions(r.Context(), token, path, password) if err != nil || !nextcloud.PublicShareCanUpdate(perms) { http.Error(w, "forbidden", http.StatusForbidden) return } raw, err := io.ReadAll(r.Body) if err != nil { apivalidate.WriteInternal(w, r) return } existingRaw, loadErr := h.svc.LoadPublicDocumentLegacy(r.Context(), token, path, password) var existing UltiDoc if loadErr == nil && 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.SavePublicDocumentLegacy(r.Context(), token, path, password, payload); err != nil { http.Error(w, "save failed", http.StatusInternalServerError) return } w.WriteHeader(http.StatusNoContent) } func verifyDocAccessSig(sig, user, path, secret string) (map[string]any, error) { if secret == "" { return map[string]any{}, nil } return verifyJWT(sig, secret) } func verifyPublicDocAccess(token, filePath, password, sig, secret string) bool { if secret == "" { return true } payload, err := verifyJWT(sig, secret) if err != nil { return false } if payload["token"] != strings.TrimSpace(token) || payload["path"] != normalizePath(filePath) { return false } if pw, _ := payload["password"].(string); pw != password { return false } if exp, ok := payload["exp"].(float64); ok && int64(exp) < time.Now().Unix() { return false } return true } func signPublicDocAccess(token, filePath, password, secret string) (string, error) { payload := map[string]any{ "token": strings.TrimSpace(token), "path": normalizePath(filePath), "password": password, "exp": time.Now().Add(2 * time.Hour).Unix(), } return signJWT(payload, secret) }