hocuspocus lol 2
This commit is contained in:
parent
3b387e7e4a
commit
d4ccf7eb6e
@ -160,6 +160,15 @@ ULTID_PUBLIC_URL=http://{{DOMAIN}}
|
||||
# Base URL for public share links (default: {ULTID_PUBLIC_URL}/drive → /drive/s/{token})
|
||||
# DRIVE_PUBLIC_URL=http://{{DOMAIN}}/drive
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Rich text editor (TipTap + Hocuspocus)
|
||||
# -----------------------------------------------------------------------------
|
||||
RICHTEXT_ENABLED=true
|
||||
HOCUSPOCUS_PUBLIC_URL=ws://{{DOMAIN}}/collab
|
||||
HOCUSPOCUS_SECRET=changeme-hocuspocus-secret
|
||||
RICHTEXT_STORAGE_MODE=sidecar
|
||||
# RICHTEXT_EXPORT_MIRROR=docx
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Jitsi Meet (Visioconference)
|
||||
# Mode local : Jitsi deploye dans la stack
|
||||
|
||||
@ -65,6 +65,24 @@ server {
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# TipTap / Hocuspocus — proxy WS without redirect (301 breaks upgrade)
|
||||
location /collab {
|
||||
resolver 127.0.0.11 valid=10s ipv6=off;
|
||||
set $hocuspocus_upstream host.docker.internal:1234;
|
||||
|
||||
rewrite ^/collab/?(.*)$ /$1 break;
|
||||
proxy_pass http://$hocuspocus_upstream;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 86400s;
|
||||
proxy_send_timeout 86400s;
|
||||
}
|
||||
|
||||
# Ultimail OIDC post-login — before Authentik /auth/ (path collision)
|
||||
location ^~ /auth/complete {
|
||||
resolver 127.0.0.11 valid=10s ipv6=off;
|
||||
|
||||
@ -105,6 +105,12 @@ func defaultOrgPolicy() map[string]any {
|
||||
"jwt_secret": "",
|
||||
"jwt_header": "Authorization",
|
||||
},
|
||||
"richtext": map[string]any{
|
||||
"enabled": true,
|
||||
"storage_mode": "sidecar",
|
||||
"export_mirror_format": "",
|
||||
"hocuspocus_url": "",
|
||||
},
|
||||
"plugins": []any{
|
||||
map[string]any{"id": "mail-automation", "name": "Automatisations mail", "description": "Règles, webhooks et tri IA sur la réception.", "enabled": true, "version": "1.0.0"},
|
||||
map[string]any{"id": "contact-discovery", "name": "Découverte contacts", "description": "Enrichissement IA et signatures détectées.", "enabled": true, "version": "1.0.0"},
|
||||
|
||||
@ -23,9 +23,14 @@ import (
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
svc *Service
|
||||
publicOffice PublicOfficeAPI
|
||||
logger *slog.Logger
|
||||
svc *Service
|
||||
publicOffice PublicOfficeAPI
|
||||
publicRichText PublicRichTextAPI
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
type PublicRichTextAPI interface {
|
||||
RegisterPublicShareRoutes(r chi.Router)
|
||||
}
|
||||
|
||||
func NewHandler(nc *nextcloud.Client, hub *realtime.Hub, db *pgxpool.Pool) *Handler {
|
||||
@ -43,6 +48,10 @@ func (h *Handler) SetPublicOffice(api PublicOfficeAPI) {
|
||||
h.publicOffice = api
|
||||
}
|
||||
|
||||
func (h *Handler) SetPublicRichText(api PublicRichTextAPI) {
|
||||
h.publicRichText = api
|
||||
}
|
||||
|
||||
func (h *Handler) nextcloudUser(w http.ResponseWriter, r *http.Request, claims *auth.Claims) (string, bool) {
|
||||
userID, err := h.svc.EnsureNextcloudUser(r.Context(), claims)
|
||||
if err != nil {
|
||||
|
||||
@ -24,6 +24,9 @@ func (h *Handler) PublicRoutes() chi.Router {
|
||||
if h.publicOffice != nil {
|
||||
h.publicOffice.RegisterPublicShareRoutes(r)
|
||||
}
|
||||
if h.publicRichText != nil {
|
||||
h.publicRichText.RegisterPublicShareRoutes(r)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
|
||||
@ -97,6 +97,7 @@ func (s *Service) ListFilterCorpus(ctx context.Context, userID, path string) (Fi
|
||||
if err != nil {
|
||||
return FilesList{}, mapDriveError(err)
|
||||
}
|
||||
files = nextcloud.FilterHiddenUltidocSidecars(files)
|
||||
total := int64(len(files))
|
||||
return FilesList{
|
||||
Files: files,
|
||||
@ -112,7 +113,7 @@ func (s *Service) ListFiles(ctx context.Context, userID, path string, params que
|
||||
if err != nil {
|
||||
return FilesList{}, mapDriveError(err)
|
||||
}
|
||||
filtered := filterFiles(files, params.Q)
|
||||
filtered := visibleDriveFiles(files, params.Q)
|
||||
page, total := paginate.Slice(filtered, params.Offset(), params.Limit())
|
||||
return FilesList{
|
||||
Files: page,
|
||||
@ -125,7 +126,7 @@ func (s *Service) ListTrash(ctx context.Context, userID string, params query.Lis
|
||||
if err != nil {
|
||||
return FilesList{}, mapDriveError(err)
|
||||
}
|
||||
filtered := filterFiles(files, params.Q)
|
||||
filtered := visibleDriveFiles(files, params.Q)
|
||||
page, total := paginate.Slice(filtered, params.Offset(), params.Limit())
|
||||
return FilesList{
|
||||
Files: page,
|
||||
@ -138,7 +139,7 @@ func (s *Service) ListRecent(ctx context.Context, userID string, params query.Li
|
||||
if err != nil {
|
||||
return FilesList{}, mapDriveError(err)
|
||||
}
|
||||
filtered := filterFiles(files, params.Q)
|
||||
filtered := visibleDriveFiles(files, params.Q)
|
||||
page, total := paginate.Slice(filtered, params.Offset(), params.Limit())
|
||||
return FilesList{
|
||||
Files: page,
|
||||
@ -151,7 +152,7 @@ func (s *Service) ListSharedWithMe(ctx context.Context, userID string, params qu
|
||||
if err != nil {
|
||||
return FilesList{}, mapDriveError(err)
|
||||
}
|
||||
filtered := filterFiles(files, params.Q)
|
||||
filtered := visibleDriveFiles(files, params.Q)
|
||||
page, total := paginate.Slice(filtered, params.Offset(), params.Limit())
|
||||
return FilesList{
|
||||
Files: page,
|
||||
@ -175,7 +176,7 @@ func (s *Service) ListStarred(ctx context.Context, userID, basePath string, para
|
||||
if err != nil {
|
||||
return FilesList{}, mapDriveError(err)
|
||||
}
|
||||
filtered := filterFiles(starred, params.Q)
|
||||
filtered := visibleDriveFiles(starred, params.Q)
|
||||
page, total := paginate.Slice(filtered, params.Offset(), limit)
|
||||
return FilesList{
|
||||
Files: page,
|
||||
@ -402,6 +403,9 @@ func (s *Service) GetPublicShare(ctx context.Context, token, path, password stri
|
||||
if err != nil {
|
||||
return nil, mapPublicShareError(err)
|
||||
}
|
||||
if view != nil && len(view.Files) > 0 {
|
||||
view.Files = nextcloud.FilterHiddenUltidocSidecars(view.Files)
|
||||
}
|
||||
s.recordPublicShareAccess(ctx, token)
|
||||
return view, nil
|
||||
}
|
||||
@ -521,7 +525,7 @@ func (s *Service) Search(ctx context.Context, userID string, opts SearchOptions,
|
||||
return FilesList{}, mapDriveError(err)
|
||||
}
|
||||
|
||||
page, total := paginate.Slice(files, params.Offset(), limit)
|
||||
page, total := paginate.Slice(nextcloud.FilterHiddenUltidocSidecars(files), params.Offset(), limit)
|
||||
return FilesList{
|
||||
Files: page,
|
||||
Pagination: params.Meta(&total),
|
||||
@ -576,6 +580,10 @@ func filterFiles(files []nextcloud.FileInfo, q string) []nextcloud.FileInfo {
|
||||
return out
|
||||
}
|
||||
|
||||
func visibleDriveFiles(files []nextcloud.FileInfo, q string) []nextcloud.FileInfo {
|
||||
return filterFiles(nextcloud.FilterHiddenUltidocSidecars(files), q)
|
||||
}
|
||||
|
||||
type ChunkUpload struct {
|
||||
Index int
|
||||
Total int
|
||||
|
||||
@ -29,7 +29,8 @@ type publicOfficeSessionRequest struct {
|
||||
Path string `json:"path"`
|
||||
Mode string `json:"mode"`
|
||||
Password string `json:"password"`
|
||||
GuestID string `json:"guest_id"`
|
||||
GuestID string `json:"guest_id"`
|
||||
GuestName string `json:"guest_name"`
|
||||
}
|
||||
|
||||
func publicSharePassword(r *http.Request) string {
|
||||
@ -72,7 +73,7 @@ func (h *Handler) PublicShareSession(w http.ResponseWriter, r *http.Request) {
|
||||
if mode == "edit" && !nextcloud.PublicShareCanUpdate(perms) {
|
||||
mode = "view"
|
||||
}
|
||||
cfg, err := h.svc.PublicEditorConfig(r.Context(), token, req.Path, mode, password, req.GuestID)
|
||||
cfg, err := h.svc.PublicEditorConfig(r.Context(), token, req.Path, mode, password, req.GuestID, req.GuestName)
|
||||
if err != nil {
|
||||
h.logger.Error("public editor config", "error", err)
|
||||
apivalidate.WriteInternal(w, r)
|
||||
|
||||
@ -15,7 +15,7 @@ type PublicShareAccess struct {
|
||||
Password string
|
||||
}
|
||||
|
||||
func (s *Service) PublicEditorConfig(ctx context.Context, token, filePath, mode, password, guestID string) (map[string]any, error) {
|
||||
func (s *Service) PublicEditorConfig(ctx context.Context, token, filePath, mode, password, guestID, guestName string) (map[string]any, error) {
|
||||
token = strings.TrimSpace(token)
|
||||
filePath = normalizePath(filePath)
|
||||
if token == "" || filePath == "" {
|
||||
@ -45,11 +45,15 @@ func (s *Service) PublicEditorConfig(ctx context.Context, token, filePath, mode,
|
||||
editorUserID = "public:" + editorUserID
|
||||
}
|
||||
|
||||
if guestName == "" {
|
||||
guestName = "Invité"
|
||||
}
|
||||
|
||||
config, err := buildEditorConfig(buildEditorConfigInput{
|
||||
filePath: filePath,
|
||||
mode: mode,
|
||||
editorUserID: editorUserID,
|
||||
userName: "Invité",
|
||||
userName: guestName,
|
||||
documentKey: s.keys.current(rev.FileID),
|
||||
downloadURL: downloadURL,
|
||||
callbackURL: callbackURL,
|
||||
|
||||
29
internal/api/richtext/collab_room.go
Normal file
29
internal/api/richtext/collab_room.go
Normal file
@ -0,0 +1,29 @@
|
||||
package richtext
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// resolveCollabRoomID returns a stable Hocuspocus room for a document on the owner's drive.
|
||||
// Auth sessions, public share links, and guests all join the same room when editing the same sidecar.
|
||||
func (s *Service) resolveCollabRoomID(ctx context.Context, ownerID, ownerSidecarPath string) (string, error) {
|
||||
ownerID = strings.TrimSpace(ownerID)
|
||||
ownerSidecarPath = normalizePath(ownerSidecarPath)
|
||||
if ownerID == "" || ownerSidecarPath == "" {
|
||||
return "", fmt.Errorf("collab room: missing owner or path")
|
||||
}
|
||||
if rev, err := s.nc.FileRevision(ctx, ownerID, ownerSidecarPath); err == nil && rev.FileID > 0 {
|
||||
return fmt.Sprintf("rt:%s:%d", ownerID, rev.FileID), nil
|
||||
}
|
||||
return fmt.Sprintf("rt:%s:%s", ownerID, hashPath(ownerSidecarPath)), nil
|
||||
}
|
||||
|
||||
func (s *Service) ownerSidecarPathForPublic(ctx context.Context, token, password, clientCanonical, displayName string) (ownerID, ownerPath string, err error) {
|
||||
binding, err := s.nc.ResolvePublicShareBinding(ctx, token, password)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return binding.OwnerID, binding.OwnerPathForClient(clientCanonical, displayName), nil
|
||||
}
|
||||
130
internal/api/richtext/document.go
Normal file
130
internal/api/richtext/document.go
Normal file
@ -0,0 +1,130 @@
|
||||
package richtext
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const schemaVersion = 1
|
||||
|
||||
// UltiDoc is the canonical on-disk format for TipTap documents.
|
||||
type UltiDoc struct {
|
||||
SchemaVersion int `json:"schemaVersion"`
|
||||
Editor string `json:"editor"`
|
||||
Source *UltiDocSource `json:"source,omitempty"`
|
||||
Content json.RawMessage `json:"content"`
|
||||
YjsState string `json:"yjsState,omitempty"`
|
||||
UpdatedAt string `json:"updatedAt,omitempty"`
|
||||
}
|
||||
|
||||
type UltiDocSource struct {
|
||||
Path string `json:"path"`
|
||||
Mime string `json:"mime,omitempty"`
|
||||
ImportedAt string `json:"importedAt,omitempty"`
|
||||
}
|
||||
|
||||
func emptyDocContent() json.RawMessage {
|
||||
return json.RawMessage(`{"type":"doc","content":[{"type":"paragraph"}]}`)
|
||||
}
|
||||
|
||||
func NewUltiDoc(content json.RawMessage, source *UltiDocSource) UltiDoc {
|
||||
if len(content) == 0 {
|
||||
content = emptyDocContent()
|
||||
}
|
||||
return UltiDoc{
|
||||
SchemaVersion: schemaVersion,
|
||||
Editor: "tiptap",
|
||||
Source: source,
|
||||
Content: content,
|
||||
UpdatedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
}
|
||||
}
|
||||
|
||||
func ParseUltiDoc(raw []byte) (UltiDoc, error) {
|
||||
var doc UltiDoc
|
||||
if err := json.Unmarshal(raw, &doc); err != nil {
|
||||
return UltiDoc{}, err
|
||||
}
|
||||
if doc.SchemaVersion == 0 {
|
||||
doc.SchemaVersion = schemaVersion
|
||||
}
|
||||
if doc.Editor == "" {
|
||||
doc.Editor = "tiptap"
|
||||
}
|
||||
if len(doc.Content) == 0 {
|
||||
doc.Content = emptyDocContent()
|
||||
}
|
||||
return doc, nil
|
||||
}
|
||||
|
||||
func (d UltiDoc) Marshal() ([]byte, error) {
|
||||
d.UpdatedAt = time.Now().UTC().Format(time.RFC3339)
|
||||
if d.SchemaVersion == 0 {
|
||||
d.SchemaVersion = schemaVersion
|
||||
}
|
||||
if d.Editor == "" {
|
||||
d.Editor = "tiptap"
|
||||
}
|
||||
return json.MarshalIndent(d, "", " ")
|
||||
}
|
||||
|
||||
func textToDocContent(text string) json.RawMessage {
|
||||
paragraphs := strings.Split(strings.ReplaceAll(text, "\r\n", "\n"), "\n")
|
||||
nodes := make([]map[string]any, 0, len(paragraphs))
|
||||
for _, para := range paragraphs {
|
||||
node := map[string]any{"type": "paragraph"}
|
||||
if strings.TrimSpace(para) != "" {
|
||||
node["content"] = []map[string]any{
|
||||
{"type": "text", "text": para},
|
||||
}
|
||||
}
|
||||
nodes = append(nodes, node)
|
||||
}
|
||||
if len(nodes) == 0 {
|
||||
nodes = append(nodes, map[string]any{"type": "paragraph"})
|
||||
}
|
||||
raw, _ := json.Marshal(map[string]any{"type": "doc", "content": nodes})
|
||||
return raw
|
||||
}
|
||||
|
||||
func htmlToDocContent(html string) json.RawMessage {
|
||||
// Minimal bridge: strip tags naively for bootstrap; rich import uses client/docx pipeline.
|
||||
text := strings.TrimSpace(html)
|
||||
if text == "" {
|
||||
return emptyDocContent()
|
||||
}
|
||||
return textToDocContent(text)
|
||||
}
|
||||
|
||||
func ImportBytes(name, mime string, body []byte) (json.RawMessage, error) {
|
||||
ext := strings.ToLower(name)
|
||||
if dot := strings.LastIndex(ext, "."); dot >= 0 {
|
||||
ext = ext[dot+1:]
|
||||
}
|
||||
switch ext {
|
||||
case "ultidoc", "json":
|
||||
if strings.HasSuffix(strings.ToLower(name), UltidocExtension) || strings.Contains(name, "ultidoc") {
|
||||
doc, err := ParseUltiDoc(body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return doc.Content, nil
|
||||
}
|
||||
var generic map[string]any
|
||||
if err := json.Unmarshal(body, &generic); err == nil {
|
||||
if t, _ := generic["type"].(string); t == "doc" {
|
||||
return json.RawMessage(body), nil
|
||||
}
|
||||
}
|
||||
case "txt", "log", "ini", "conf", "cfg", "env":
|
||||
return textToDocContent(string(body)), nil
|
||||
case "md", "markdown":
|
||||
return textToDocContent(string(body)), nil
|
||||
case "html", "htm":
|
||||
return htmlToDocContent(string(body)), nil
|
||||
}
|
||||
_ = mime
|
||||
return nil, fmt.Errorf("import requires client conversion for %q", name)
|
||||
}
|
||||
274
internal/api/richtext/handlers.go
Normal file
274
internal/api/richtext/handlers.go
Normal file
@ -0,0 +1,274 @@
|
||||
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).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"`
|
||||
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
|
||||
}
|
||||
doc := NewUltiDoc(req.Document, nil)
|
||||
doc.YjsState = req.YjsState
|
||||
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
|
||||
}
|
||||
if err := h.svc.SaveDocument(r.Context(), user, path, raw, 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)
|
||||
var raw []byte
|
||||
if len(payload.Document) > 0 {
|
||||
doc := NewUltiDoc(payload.Document, nil)
|
||||
doc.YjsState = payload.YjsState
|
||||
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()}
|
||||
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)
|
||||
}
|
||||
113
internal/api/richtext/jwt.go
Normal file
113
internal/api/richtext/jwt.go
Normal file
@ -0,0 +1,113 @@
|
||||
package richtext
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
type roomTokenPayload struct {
|
||||
Room string `json:"room"`
|
||||
Path string `json:"path"`
|
||||
User string `json:"user"`
|
||||
Sub string `json:"sub"`
|
||||
Name string `json:"name"`
|
||||
Mode string `json:"mode"`
|
||||
Expires int64 `json:"exp"`
|
||||
}
|
||||
|
||||
func signRoomToken(payload roomTokenPayload, secret string) (string, error) {
|
||||
if secret == "" {
|
||||
return "", nil
|
||||
}
|
||||
return signJWT(payload, secret)
|
||||
}
|
||||
|
||||
func VerifyRoomToken(token, secret string) (roomTokenPayload, error) {
|
||||
var out roomTokenPayload
|
||||
if secret == "" {
|
||||
return out, fmt.Errorf("missing secret")
|
||||
}
|
||||
raw, err := verifyJWT(token, secret)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
if exp, ok := raw["exp"].(float64); ok && int64(exp) < time.Now().Unix() {
|
||||
return out, fmt.Errorf("token expired")
|
||||
}
|
||||
b, _ := json.Marshal(raw)
|
||||
_ = json.Unmarshal(b, &out)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func signJWT(payload any, secret string) (string, error) {
|
||||
if secret == "" {
|
||||
return "", nil
|
||||
}
|
||||
header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"HS256","typ":"JWT"}`))
|
||||
bodyBytes, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
body := base64.RawURLEncoding.EncodeToString(bodyBytes)
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
_, _ = mac.Write([]byte(header + "." + body))
|
||||
sig := base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
||||
return header + "." + body + "." + sig, nil
|
||||
}
|
||||
|
||||
func verifyJWT(token, secret string) (map[string]any, error) {
|
||||
if secret == "" || token == "" {
|
||||
return nil, fmt.Errorf("missing token or secret")
|
||||
}
|
||||
parts := splitJWT(token)
|
||||
if len(parts) != 3 {
|
||||
return nil, fmt.Errorf("invalid token")
|
||||
}
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
_, _ = mac.Write([]byte(parts[0] + "." + parts[1]))
|
||||
expected := base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
||||
if !hmac.Equal([]byte(expected), []byte(parts[2])) {
|
||||
return nil, fmt.Errorf("invalid signature")
|
||||
}
|
||||
raw, err := base64.RawURLEncoding.DecodeString(parts[1])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(raw, &payload); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
func splitJWT(token string) []string {
|
||||
var parts []string
|
||||
start := 0
|
||||
for i := 0; i < len(token); i++ {
|
||||
if token[i] == '.' {
|
||||
parts = append(parts, token[start:i])
|
||||
start = i + 1
|
||||
}
|
||||
}
|
||||
parts = append(parts, token[start:])
|
||||
return parts
|
||||
}
|
||||
|
||||
func sha256Hex(b []byte) string {
|
||||
sum := sha256.Sum256(b)
|
||||
return hexEncode(sum[:])
|
||||
}
|
||||
|
||||
func hexEncode(b []byte) string {
|
||||
const hexdigits = "0123456789abcdef"
|
||||
out := make([]byte, len(b)*2)
|
||||
for i, v := range b {
|
||||
out[i*2] = hexdigits[v>>4]
|
||||
out[i*2+1] = hexdigits[v&0x0f]
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
74
internal/api/richtext/paths.go
Normal file
74
internal/api/richtext/paths.go
Normal file
@ -0,0 +1,74 @@
|
||||
package richtext
|
||||
|
||||
import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
const UltidocExtension = "ultidoc.json"
|
||||
|
||||
// Config holds rich-text editor integration settings.
|
||||
type Config struct {
|
||||
Enabled bool
|
||||
HocuspocusPublicURL string
|
||||
HocuspocusSecret string
|
||||
APIInternalURL string
|
||||
StorageMode string // sidecar | overwrite
|
||||
ExportMirrorFormat string // "" | docx
|
||||
}
|
||||
|
||||
func normalizePath(p string) string {
|
||||
p = strings.TrimSpace(p)
|
||||
if p == "" {
|
||||
return "/"
|
||||
}
|
||||
if !strings.HasPrefix(p, "/") {
|
||||
p = "/" + p
|
||||
}
|
||||
return strings.ReplaceAll(p, "//", "/")
|
||||
}
|
||||
|
||||
func fileNameFromPath(p string) string {
|
||||
p = normalizePath(p)
|
||||
if p == "/" {
|
||||
return ""
|
||||
}
|
||||
if i := strings.LastIndex(p, "/"); i >= 0 {
|
||||
return p[i+1:]
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func isUltidocPath(path string) bool {
|
||||
return strings.HasSuffix(strings.ToLower(path), "."+UltidocExtension)
|
||||
}
|
||||
|
||||
func sidecarPathForSource(sourcePath string) string {
|
||||
sourcePath = normalizePath(sourcePath)
|
||||
dir := "/"
|
||||
name := strings.TrimPrefix(sourcePath, "/")
|
||||
if i := strings.LastIndex(name, "/"); i >= 0 {
|
||||
dir = "/" + name[:i]
|
||||
name = name[i+1:]
|
||||
}
|
||||
base := name
|
||||
if dot := strings.LastIndex(name, "."); dot > 0 {
|
||||
base = name[:dot]
|
||||
}
|
||||
sidecar := base + "." + UltidocExtension
|
||||
if dir == "/" {
|
||||
return "/" + sidecar
|
||||
}
|
||||
return dir + "/" + sidecar
|
||||
}
|
||||
|
||||
func parentDir(path string) string {
|
||||
path = normalizePath(path)
|
||||
if path == "/" {
|
||||
return "/"
|
||||
}
|
||||
idx := strings.LastIndex(path, "/")
|
||||
if idx <= 0 {
|
||||
return "/"
|
||||
}
|
||||
return path[:idx]
|
||||
}
|
||||
205
internal/api/richtext/public_handlers.go
Normal file
205
internal/api/richtext/public_handlers.go
Normal file
@ -0,0 +1,205 @@
|
||||
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
|
||||
}
|
||||
if err := h.svc.SavePublicDocumentLegacy(r.Context(), token, path, password, raw); 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)
|
||||
}
|
||||
209
internal/api/richtext/public_share.go
Normal file
209
internal/api/richtext/public_share.go
Normal file
@ -0,0 +1,209 @@
|
||||
package richtext
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type PublicSessionResult struct {
|
||||
SessionResult
|
||||
DocumentURL string `json:"documentUrl,omitempty"`
|
||||
SaveURL string `json:"saveUrl,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Service) CreatePublicSession(ctx context.Context, token, filePath, mode, password, guestID, guestName, displayName string) (*PublicSessionResult, error) {
|
||||
if !s.Cfg.Enabled {
|
||||
return nil, fmt.Errorf("rich text editor disabled")
|
||||
}
|
||||
filePath = normalizePath(filePath)
|
||||
canonical, source, importRequired, err := s.resolvePublicCanonicalPath(ctx, token, filePath, password, displayName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ownerID, ownerPath, err := s.ownerSidecarPathForPublic(ctx, token, password, canonical, displayName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
roomID, err := s.resolveCollabRoomID(ctx, ownerID, ownerPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tokenJWT, err := signRoomToken(roomTokenPayload{
|
||||
Room: roomID,
|
||||
Path: canonical,
|
||||
User: "public:" + token,
|
||||
Sub: guestID,
|
||||
Name: guestName,
|
||||
Mode: mode,
|
||||
Expires: time.Now().Add(8 * time.Hour).Unix(),
|
||||
}, s.Cfg.HocuspocusSecret)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
apiBase := strings.TrimRight(s.Cfg.APIInternalURL, "/")
|
||||
sig, _ := signPublicDocAccess(token, canonical, password, s.Cfg.HocuspocusSecret)
|
||||
docURL := fmt.Sprintf("%s/api/v1/drive/public/shares/%s/richtext/document?path=%s&password=%s&sig=%s",
|
||||
apiBase, url.PathEscape(token), url.QueryEscape(canonical), url.QueryEscape(password), url.QueryEscape(sig))
|
||||
saveURL := docURL
|
||||
|
||||
wsURL := strings.TrimSpace(s.Cfg.HocuspocusPublicURL)
|
||||
collab := wsURL != "" && s.Cfg.HocuspocusSecret != ""
|
||||
|
||||
return &PublicSessionResult{
|
||||
SessionResult: SessionResult{
|
||||
RoomID: roomID,
|
||||
CanonicalPath: canonical,
|
||||
SourcePath: source,
|
||||
WsURL: wsURL,
|
||||
Token: tokenJWT,
|
||||
Mode: mode,
|
||||
ImportRequired: importRequired,
|
||||
Collaboration: collab,
|
||||
},
|
||||
DocumentURL: docURL,
|
||||
SaveURL: saveURL,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) resolvePublicCanonicalPath(ctx context.Context, token, filePath, password, displayName string) (canonical, source string, importRequired bool, err error) {
|
||||
filePath = normalizePath(filePath)
|
||||
source = filePath
|
||||
if source == "/" {
|
||||
source = s.publicClientSourcePath(ctx, token, password, filePath, displayName)
|
||||
}
|
||||
|
||||
if isUltidocPath(filePath) {
|
||||
return filePath, "", false, nil
|
||||
}
|
||||
if isUltidocPath(source) {
|
||||
return source, "", false, nil
|
||||
}
|
||||
|
||||
sidecar := sidecarPathForSource(source)
|
||||
|
||||
if s.publicSidecarExists(ctx, token, password, sidecar, displayName) {
|
||||
return sidecar, source, false, nil
|
||||
}
|
||||
|
||||
if _, err := s.publicFileExists(ctx, token, source, password); err != nil {
|
||||
return "", "", false, fmt.Errorf("file not found")
|
||||
}
|
||||
|
||||
return sidecar, source, true, nil
|
||||
}
|
||||
|
||||
func (s *Service) publicClientSourcePath(ctx context.Context, token, password, clientPath, displayName string) string {
|
||||
binding, err := s.nc.ResolvePublicShareBinding(ctx, token, password)
|
||||
if err == nil {
|
||||
return binding.ClientSourcePath(clientPath, displayName)
|
||||
}
|
||||
if name := strings.TrimSpace(displayName); name != "" {
|
||||
return normalizePath("/" + name)
|
||||
}
|
||||
return clientPath
|
||||
}
|
||||
|
||||
func (s *Service) publicSidecarExists(ctx context.Context, token, password, clientSidecar, displayName string) bool {
|
||||
if _, err := s.publicFileExists(ctx, token, clientSidecar, password); err == nil {
|
||||
return true
|
||||
}
|
||||
binding, err := s.nc.ResolvePublicShareBinding(ctx, token, password)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
ownerPath := binding.OwnerPathForClient(clientSidecar, displayName)
|
||||
if _, err := s.nc.FileRevision(ctx, binding.OwnerID, ownerPath); err == nil {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *Service) ImportPublicDocument(ctx context.Context, token, password, displayName string, req ImportRequest) (string, error) {
|
||||
source := normalizePath(req.SourcePath)
|
||||
canonical, _, importRequired, err := s.resolvePublicCanonicalPath(ctx, token, source, password, displayName)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !importRequired {
|
||||
return canonical, nil
|
||||
}
|
||||
|
||||
var content json.RawMessage
|
||||
if len(req.Content) > 0 {
|
||||
content = req.Content
|
||||
} else {
|
||||
body, err := s.LoadPublicDocument(ctx, token, source, password, displayName)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
name := fileNameFromPath(source)
|
||||
content, err = ImportBytes(name, "", body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
doc := NewUltiDoc(content, &UltiDocSource{
|
||||
Path: source,
|
||||
ImportedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
})
|
||||
payload, err := doc.Marshal()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := s.SavePublicDocument(ctx, token, canonical, password, displayName, payload); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return canonical, nil
|
||||
}
|
||||
|
||||
func (s *Service) publicFileExists(ctx context.Context, token, path, password string) (bool, error) {
|
||||
_, err := s.nc.PublicShareFileRevision(ctx, token, path, password)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (s *Service) LoadPublicDocument(ctx context.Context, token, clientPath, password, displayName string) ([]byte, error) {
|
||||
if binding, err := s.nc.ResolvePublicShareBinding(ctx, token, password); err == nil {
|
||||
ownerPath := binding.OwnerPathForClient(clientPath, displayName)
|
||||
body, _, err := s.nc.Download(ctx, binding.OwnerID, ownerPath)
|
||||
if err == nil {
|
||||
defer body.Close()
|
||||
return io.ReadAll(body)
|
||||
}
|
||||
}
|
||||
body, _, err := s.nc.DownloadPublicShare(ctx, token, clientPath, password)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer body.Close()
|
||||
return io.ReadAll(body)
|
||||
}
|
||||
|
||||
func (s *Service) SavePublicDocument(ctx context.Context, token, clientPath, password, displayName string, raw []byte) error {
|
||||
if binding, err := s.nc.ResolvePublicShareBinding(ctx, token, password); err == nil {
|
||||
ownerPath := binding.OwnerPathForClient(clientPath, displayName)
|
||||
reader := strings.NewReader(string(raw))
|
||||
if err := s.nc.Upload(ctx, binding.OwnerID, ownerPath, reader, "application/json"); err == nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
reader := strings.NewReader(string(raw))
|
||||
return s.nc.UploadPublicShare(ctx, token, clientPath, password, reader, "application/json")
|
||||
}
|
||||
|
||||
func (s *Service) LoadPublicDocumentLegacy(ctx context.Context, token, path, password string) ([]byte, error) {
|
||||
return s.LoadPublicDocument(ctx, token, path, password, "")
|
||||
}
|
||||
|
||||
func (s *Service) SavePublicDocumentLegacy(ctx context.Context, token, path, password string, raw []byte) error {
|
||||
return s.SavePublicDocument(ctx, token, path, password, "", raw)
|
||||
}
|
||||
326
internal/api/richtext/service.go
Normal file
326
internal/api/richtext/service.go
Normal file
@ -0,0 +1,326 @@
|
||||
package richtext
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/ultisuite/ulti-backend/internal/auth"
|
||||
"github.com/ultisuite/ulti-backend/internal/nextcloud"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
nc *nextcloud.Client
|
||||
Cfg Config
|
||||
hub fileChangePublisher
|
||||
}
|
||||
|
||||
type fileChangePublisher interface {
|
||||
PublishFileChanged(platformUserID, path string)
|
||||
}
|
||||
|
||||
func NewService(nc *nextcloud.Client, cfg Config, hub fileChangePublisher) *Service {
|
||||
if cfg.StorageMode == "" {
|
||||
cfg.StorageMode = "sidecar"
|
||||
}
|
||||
return &Service{nc: nc, Cfg: cfg, hub: hub}
|
||||
}
|
||||
|
||||
func (s *Service) EnsureNextcloudUser(ctx context.Context, claims *auth.Claims) (string, error) {
|
||||
return s.nc.EnsurePrincipal(ctx, claims.Email, claims.Sub, claims.Name)
|
||||
}
|
||||
|
||||
type SessionResult struct {
|
||||
RoomID string `json:"roomId"`
|
||||
CanonicalPath string `json:"canonicalPath"`
|
||||
SourcePath string `json:"sourcePath,omitempty"`
|
||||
WsURL string `json:"wsUrl"`
|
||||
Token string `json:"token"`
|
||||
Mode string `json:"mode"`
|
||||
ImportRequired bool `json:"importRequired"`
|
||||
Collaboration bool `json:"collaboration"`
|
||||
}
|
||||
|
||||
func (s *Service) CreateSession(ctx context.Context, ncUser, filePath, mode, editorUserID, editorName string) (*SessionResult, error) {
|
||||
if !s.Cfg.Enabled {
|
||||
return nil, fmt.Errorf("rich text editor disabled")
|
||||
}
|
||||
filePath = normalizePath(filePath)
|
||||
if mode == "" {
|
||||
mode = "edit"
|
||||
}
|
||||
|
||||
canonical, source, importRequired, err := s.resolveCanonicalPath(ctx, ncUser, filePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
roomID, err := s.resolveCollabRoomID(ctx, ncUser, canonical)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
token, err := signRoomToken(roomTokenPayload{
|
||||
Room: roomID,
|
||||
Path: canonical,
|
||||
User: ncUser,
|
||||
Sub: editorUserID,
|
||||
Name: editorName,
|
||||
Mode: mode,
|
||||
Expires: time.Now().Add(8 * time.Hour).Unix(),
|
||||
}, s.Cfg.HocuspocusSecret)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
wsURL := strings.TrimSpace(s.Cfg.HocuspocusPublicURL)
|
||||
collab := wsURL != "" && s.Cfg.HocuspocusSecret != ""
|
||||
|
||||
return &SessionResult{
|
||||
RoomID: roomID,
|
||||
CanonicalPath: canonical,
|
||||
SourcePath: source,
|
||||
WsURL: wsURL,
|
||||
Token: token,
|
||||
Mode: mode,
|
||||
ImportRequired: importRequired,
|
||||
Collaboration: collab,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) resolveCanonicalPath(ctx context.Context, ncUser, filePath string) (canonical, source string, importRequired bool, err error) {
|
||||
if isUltidocPath(filePath) {
|
||||
return filePath, "", false, nil
|
||||
}
|
||||
|
||||
sidecar := sidecarPathForSource(filePath)
|
||||
if s.Cfg.StorageMode == "overwrite" {
|
||||
target := sidecarPathForSource(filePath)
|
||||
if _, err := s.nc.FileRevision(ctx, ncUser, target); err == nil {
|
||||
return target, filePath, false, nil
|
||||
}
|
||||
// Will import and optionally move — treat as import required if source exists.
|
||||
if _, err := s.nc.FileRevision(ctx, ncUser, filePath); err == nil {
|
||||
return target, filePath, true, nil
|
||||
}
|
||||
return "", "", false, fmt.Errorf("file not found: %s", filePath)
|
||||
}
|
||||
|
||||
if _, err := s.nc.FileRevision(ctx, ncUser, sidecar); err == nil {
|
||||
return sidecar, filePath, false, nil
|
||||
}
|
||||
if _, err := s.nc.FileRevision(ctx, ncUser, filePath); err != nil {
|
||||
return "", "", false, fmt.Errorf("file not found: %s", filePath)
|
||||
}
|
||||
return sidecar, filePath, true, nil
|
||||
}
|
||||
|
||||
func (s *Service) LoadDocument(ctx context.Context, ncUser, path string) ([]byte, error) {
|
||||
path = normalizePath(path)
|
||||
body, _, err := s.nc.Download(ctx, ncUser, path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer body.Close()
|
||||
return io.ReadAll(body)
|
||||
}
|
||||
|
||||
func (s *Service) SaveDocument(ctx context.Context, ncUser, path string, raw []byte, platformUserID string) error {
|
||||
path = normalizePath(path)
|
||||
reader := strings.NewReader(string(raw))
|
||||
if strings.HasPrefix(ncUser, "public:") {
|
||||
token := strings.TrimPrefix(ncUser, "public:")
|
||||
return s.SavePublicDocumentLegacy(ctx, token, path, "", raw)
|
||||
}
|
||||
if err := s.nc.Upload(ctx, ncUser, path, reader, "application/json"); err != nil {
|
||||
return err
|
||||
}
|
||||
if s.hub != nil && platformUserID != "" {
|
||||
s.hub.PublishFileChanged(platformUserID, path)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) LoadDocumentForUser(ctx context.Context, ncUser, path string) ([]byte, error) {
|
||||
path = normalizePath(path)
|
||||
if strings.HasPrefix(ncUser, "public:") {
|
||||
token := strings.TrimPrefix(ncUser, "public:")
|
||||
return s.LoadPublicDocumentLegacy(ctx, token, path, "")
|
||||
}
|
||||
return s.LoadDocument(ctx, ncUser, path)
|
||||
}
|
||||
|
||||
type ImportRequest struct {
|
||||
SourcePath string `json:"source_path"`
|
||||
Content json.RawMessage `json:"content,omitempty"`
|
||||
YjsState string `json:"yjsState,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Service) ImportDocument(ctx context.Context, ncUser, platformUserID string, req ImportRequest) (string, error) {
|
||||
source := normalizePath(req.SourcePath)
|
||||
canonical, _, importRequired, err := s.resolveCanonicalPath(ctx, ncUser, source)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !importRequired && len(req.Content) == 0 {
|
||||
return canonical, nil
|
||||
}
|
||||
|
||||
var content json.RawMessage
|
||||
if len(req.Content) > 0 {
|
||||
content = req.Content
|
||||
} else {
|
||||
body, _, err := s.nc.Download(ctx, ncUser, source)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
raw, err := io.ReadAll(body)
|
||||
body.Close()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
name := fileNameFromPath(source)
|
||||
mime := ""
|
||||
content, err = ImportBytes(name, mime, raw)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
doc := NewUltiDoc(content, &UltiDocSource{
|
||||
Path: source,
|
||||
ImportedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
})
|
||||
if req.YjsState != "" {
|
||||
doc.YjsState = req.YjsState
|
||||
}
|
||||
payload, err := doc.Marshal()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if err := s.SaveDocument(ctx, ncUser, canonical, payload, platformUserID); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if s.Cfg.StorageMode == "overwrite" && source != canonical {
|
||||
if err := s.nc.Move(ctx, ncUser, source, canonical); err != nil {
|
||||
return canonical, nil
|
||||
}
|
||||
}
|
||||
|
||||
if source != canonical {
|
||||
if err := replicateFileShares(ctx, s.nc, ncUser, source, canonical); err != nil {
|
||||
return canonical, fmt.Errorf("save ok but share replication failed: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return canonical, nil
|
||||
}
|
||||
|
||||
func replicateFileShares(ctx context.Context, nc *nextcloud.Client, userID, fromPath, toPath string) error {
|
||||
shares, err := nc.ListShares(ctx, userID, fromPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, sh := range shares {
|
||||
opts := nextcloud.CreateShareOptions{
|
||||
ShareType: sh.ShareType,
|
||||
Permissions: sh.Permissions,
|
||||
ShareWith: sh.ShareWith,
|
||||
ExpireDate: sh.ExpiresAt,
|
||||
Note: sh.Note,
|
||||
Label: sh.Label,
|
||||
}
|
||||
if _, err := nc.CreateShare(ctx, userID, toPath, opts); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type ExportRequest struct {
|
||||
Path string `json:"path"`
|
||||
Format string `json:"format"`
|
||||
}
|
||||
|
||||
func (s *Service) ExportDocument(ctx context.Context, ncUser, path, format string) ([]byte, string, error) {
|
||||
path = normalizePath(path)
|
||||
raw, err := s.LoadDocument(ctx, ncUser, path)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
doc, err := ParseUltiDoc(raw)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
format = strings.ToLower(strings.TrimSpace(format))
|
||||
switch format {
|
||||
case "json", "ultidoc":
|
||||
out, err := json.MarshalIndent(doc, "", " ")
|
||||
return out, "application/json", err
|
||||
case "txt", "text":
|
||||
return []byte(contentToPlainText(doc.Content)), "text/plain; charset=utf-8", nil
|
||||
case "html", "htm":
|
||||
return []byte(contentToHTML(doc.Content)), "text/html; charset=utf-8", nil
|
||||
default:
|
||||
return nil, "", fmt.Errorf("unsupported export format %q", format)
|
||||
}
|
||||
}
|
||||
|
||||
func contentToPlainText(content json.RawMessage) string {
|
||||
var node map[string]any
|
||||
if err := json.Unmarshal(content, &node); err != nil {
|
||||
return ""
|
||||
}
|
||||
var b strings.Builder
|
||||
extractText(node, &b)
|
||||
return strings.TrimSpace(b.String())
|
||||
}
|
||||
|
||||
func extractText(node map[string]any, b *strings.Builder) {
|
||||
if t, ok := node["text"].(string); ok {
|
||||
b.WriteString(t)
|
||||
}
|
||||
children, _ := node["content"].([]any)
|
||||
for _, ch := range children {
|
||||
if m, ok := ch.(map[string]any); ok {
|
||||
extractText(m, b)
|
||||
}
|
||||
}
|
||||
if typ, _ := node["type"].(string); typ == "paragraph" || typ == "heading" {
|
||||
b.WriteString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
func contentToHTML(content json.RawMessage) string {
|
||||
text := contentToPlainText(content)
|
||||
if text == "" {
|
||||
return "<p></p>"
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString("<html><body>")
|
||||
for _, line := range strings.Split(text, "\n") {
|
||||
b.WriteString("<p>")
|
||||
b.WriteString(htmlEscape(line))
|
||||
b.WriteString("</p>")
|
||||
}
|
||||
b.WriteString("</body></html>")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func htmlEscape(s string) string {
|
||||
r := strings.NewReplacer("&", "&", "<", "<", ">", ">", `"`, """)
|
||||
return r.Replace(s)
|
||||
}
|
||||
|
||||
func hashPath(p string) string {
|
||||
h := sha256Hex([]byte(normalizePath(p)))
|
||||
if len(h) > 16 {
|
||||
return h[:16]
|
||||
}
|
||||
return h
|
||||
}
|
||||
@ -69,6 +69,13 @@ type Config struct {
|
||||
UltidPublicURL string
|
||||
DrivePublicURL string
|
||||
|
||||
// Rich text editor (TipTap + Hocuspocus)
|
||||
RichTextEnabled bool
|
||||
HocuspocusPublicURL string
|
||||
HocuspocusSecret string
|
||||
RichTextStorageMode string
|
||||
RichTextExportMirror string
|
||||
|
||||
// Jitsi
|
||||
JitsiEnabled bool
|
||||
JitsiDomain string
|
||||
@ -179,8 +186,13 @@ func Load() (*Config, error) {
|
||||
OnlyOfficePublicURL: envOrDefault("ONLYOFFICE_PUBLIC_URL", "http://localhost/office"),
|
||||
OnlyOfficeAPIInternalURL: envOrDefault("ONLYOFFICE_API_INTERNAL_URL", "http://ultid:8080"),
|
||||
OnlyOfficeJWTSecret: secrets.Env("ONLYOFFICE_JWT_SECRET"),
|
||||
UltidPublicURL: envOrDefault("ULTID_PUBLIC_URL", "http://localhost"),
|
||||
DrivePublicURL: drivePublicURL(),
|
||||
UltidPublicURL: envOrDefault("ULTID_PUBLIC_URL", "http://localhost"),
|
||||
DrivePublicURL: drivePublicURL(),
|
||||
RichTextEnabled: envBool("RICHTEXT_ENABLED", true),
|
||||
HocuspocusPublicURL: envOrDefault("HOCUSPOCUS_PUBLIC_URL", "ws://localhost:1234"),
|
||||
HocuspocusSecret: secrets.Env("HOCUSPOCUS_SECRET"),
|
||||
RichTextStorageMode: envOrDefault("RICHTEXT_STORAGE_MODE", "sidecar"),
|
||||
RichTextExportMirror: envOrDefault("RICHTEXT_EXPORT_MIRROR", ""),
|
||||
|
||||
JitsiEnabled: envBool("JITSI_ENABLED", true),
|
||||
JitsiDomain: envOrDefault("JITSI_DOMAIN", "meet.jitsi"),
|
||||
|
||||
79
internal/nextcloud/drive_hidden.go
Normal file
79
internal/nextcloud/drive_hidden.go
Normal file
@ -0,0 +1,79 @@
|
||||
package nextcloud
|
||||
|
||||
import "strings"
|
||||
|
||||
const ultidocSidecarSuffix = ".ultidoc.json"
|
||||
|
||||
// IsUltidocSidecarName reports whether name is a TipTap sidecar file.
|
||||
func IsUltidocSidecarName(name string) bool {
|
||||
return strings.HasSuffix(strings.ToLower(strings.TrimSpace(name)), ultidocSidecarSuffix)
|
||||
}
|
||||
|
||||
func documentBaseName(name string) string {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return ""
|
||||
}
|
||||
lower := strings.ToLower(name)
|
||||
if strings.HasSuffix(lower, ultidocSidecarSuffix) {
|
||||
return name[:len(name)-len(ultidocSidecarSuffix)]
|
||||
}
|
||||
if i := strings.LastIndex(name, "."); i > 0 {
|
||||
return name[:i]
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
func sidecarSourceKey(filePath string) string {
|
||||
dir := parentDirPath(filePath)
|
||||
base := strings.ToLower(documentBaseName(fileNameFromPath(filePath)))
|
||||
return dir + "\x00" + base
|
||||
}
|
||||
|
||||
func parentDirPath(filePath string) string {
|
||||
filePath = NormalizeClientPath(filePath)
|
||||
if filePath == "/" {
|
||||
return "/"
|
||||
}
|
||||
i := strings.LastIndex(filePath, "/")
|
||||
if i <= 0 {
|
||||
return "/"
|
||||
}
|
||||
return filePath[:i]
|
||||
}
|
||||
|
||||
func fileNameFromPath(p string) string {
|
||||
p = NormalizeClientPath(p)
|
||||
if i := strings.LastIndex(p, "/"); i >= 0 {
|
||||
return p[i+1:]
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// FilterHiddenUltidocSidecars removes .ultidoc.json sidecars when the source document
|
||||
// is present in the same listing (sidecar storage mode). Orphan sidecars (overwrite mode)
|
||||
// remain visible.
|
||||
func FilterHiddenUltidocSidecars(files []FileInfo) []FileInfo {
|
||||
if len(files) == 0 {
|
||||
return files
|
||||
}
|
||||
|
||||
sources := make(map[string]struct{}, len(files))
|
||||
for _, f := range files {
|
||||
if f.Type == "directory" || IsUltidocSidecarName(f.Name) {
|
||||
continue
|
||||
}
|
||||
sources[sidecarSourceKey(f.Path)] = struct{}{}
|
||||
}
|
||||
|
||||
out := make([]FileInfo, 0, len(files))
|
||||
for _, f := range files {
|
||||
if f.Type != "directory" && IsUltidocSidecarName(f.Name) {
|
||||
if _, ok := sources[sidecarSourceKey(f.Path)]; ok {
|
||||
continue
|
||||
}
|
||||
}
|
||||
out = append(out, f)
|
||||
}
|
||||
return out
|
||||
}
|
||||
21
internal/nextcloud/drive_hidden_test.go
Normal file
21
internal/nextcloud/drive_hidden_test.go
Normal file
@ -0,0 +1,21 @@
|
||||
package nextcloud
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestFilterHiddenUltidocSidecars(t *testing.T) {
|
||||
files := []FileInfo{
|
||||
{Path: "/docs/report.docx", Name: "report.docx", Type: "file"},
|
||||
{Path: "/docs/report.ultidoc.json", Name: "report.ultidoc.json", Type: "file"},
|
||||
{Path: "/solo.ultidoc.json", Name: "solo.ultidoc.json", Type: "file"},
|
||||
{Path: "/docs", Name: "docs", Type: "directory"},
|
||||
}
|
||||
out := FilterHiddenUltidocSidecars(files)
|
||||
if len(out) != 3 {
|
||||
t.Fatalf("len(out) = %d, want 3", len(out))
|
||||
}
|
||||
for _, f := range out {
|
||||
if f.Name == "report.ultidoc.json" {
|
||||
t.Fatal("sidecar should be hidden when source exists in listing")
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -347,6 +347,18 @@ func (c *Client) PublicShareFileRevision(ctx context.Context, token, filePath, p
|
||||
}
|
||||
filePath = NormalizeClientPath(filePath)
|
||||
|
||||
rev, err := c.publicShareFileRevisionAt(ctx, token, filePath, password)
|
||||
if err == nil {
|
||||
return rev, nil
|
||||
}
|
||||
var statusErr *HTTPStatusError
|
||||
if errors.As(err, &statusErr) && statusErr.StatusCode == http.StatusNotFound && filePath != "/" {
|
||||
return c.publicShareFileRevisionAt(ctx, token, "/", password)
|
||||
}
|
||||
return FileRevision{}, err
|
||||
}
|
||||
|
||||
func (c *Client) publicShareFileRevisionAt(ctx context.Context, token, filePath, password string) (FileRevision, error) {
|
||||
resp, err := c.publicShareRequest(ctx, "PROPFIND", token, filePath, strings.NewReader(propfindPublicRevisionBody), password, map[string]string{
|
||||
"Depth": "0",
|
||||
"Content-Type": "application/xml",
|
||||
|
||||
122
internal/nextcloud/public_share_binding.go
Normal file
122
internal/nextcloud/public_share_binding.go
Normal file
@ -0,0 +1,122 @@
|
||||
package nextcloud
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"path"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// PublicShareBinding maps a public link token to owner storage paths.
|
||||
type PublicShareBinding struct {
|
||||
Token string
|
||||
OwnerID string
|
||||
SharePath string // OCS path, e.g. /Documents/hello.docx or /Documents
|
||||
ItemType string // file | folder
|
||||
}
|
||||
|
||||
func (b *PublicShareBinding) IsSingleFile() bool {
|
||||
return strings.EqualFold(strings.TrimSpace(b.ItemType), "file")
|
||||
}
|
||||
|
||||
// ClientSourcePath resolves the logical client path for a shared item.
|
||||
// Single-file shares expose WebDAV at "/" but editors use /filename.
|
||||
func (b *PublicShareBinding) ClientSourcePath(clientPath, displayName string) string {
|
||||
clientPath = NormalizeClientPath(clientPath)
|
||||
if clientPath != "/" {
|
||||
return clientPath
|
||||
}
|
||||
if b != nil && b.IsSingleFile() && b.SharePath != "" {
|
||||
return NormalizeClientPath("/" + path.Base(strings.TrimPrefix(b.SharePath, "/")))
|
||||
}
|
||||
if name := strings.TrimSpace(displayName); name != "" {
|
||||
return NormalizeClientPath("/" + name)
|
||||
}
|
||||
return "/"
|
||||
}
|
||||
|
||||
// OwnerPathForClient maps a client-facing path to the owner's Nextcloud path.
|
||||
func (b *PublicShareBinding) OwnerPathForClient(clientPath, displayName string) string {
|
||||
if b == nil {
|
||||
return NormalizeClientPath(clientPath)
|
||||
}
|
||||
src := b.ClientSourcePath(clientPath, displayName)
|
||||
rel := strings.TrimPrefix(src, "/")
|
||||
sharePath := NormalizeClientPath(b.SharePath)
|
||||
if b.IsSingleFile() {
|
||||
dir := path.Dir(strings.TrimPrefix(sharePath, "/"))
|
||||
if dir == "." || dir == "" {
|
||||
return NormalizeClientPath("/" + rel)
|
||||
}
|
||||
return NormalizeClientPath(path.Join("/", dir, rel))
|
||||
}
|
||||
if sharePath == "/" || sharePath == "" {
|
||||
return NormalizeClientPath("/" + rel)
|
||||
}
|
||||
return NormalizeClientPath(path.Join(sharePath, rel))
|
||||
}
|
||||
|
||||
func normalizeOCSSharePath(p string) string {
|
||||
p = strings.TrimSpace(p)
|
||||
if p == "" {
|
||||
return "/"
|
||||
}
|
||||
if !strings.HasPrefix(p, "/") {
|
||||
p = "/" + p
|
||||
}
|
||||
return NormalizeClientPath(p)
|
||||
}
|
||||
|
||||
// ResolvePublicShareBinding resolves owner + OCS share metadata for a public token.
|
||||
func (c *Client) ResolvePublicShareBinding(ctx context.Context, token, password string) (*PublicShareBinding, error) {
|
||||
token = strings.TrimSpace(token)
|
||||
if token == "" {
|
||||
return nil, ErrInvalidPublicShare
|
||||
}
|
||||
ownerID, err := c.getPublicShareOwnerID(ctx, token, password)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ownerID = strings.TrimSpace(ownerID)
|
||||
if ownerID == "" {
|
||||
return nil, fmt.Errorf("public share owner not found")
|
||||
}
|
||||
|
||||
shares, err := c.ListShares(ctx, ownerID, "")
|
||||
if err == nil {
|
||||
for _, sh := range shares {
|
||||
if strings.TrimSpace(sh.Token) != token {
|
||||
continue
|
||||
}
|
||||
itemType := strings.TrimSpace(sh.ItemType)
|
||||
if itemType == "" {
|
||||
itemType = "folder"
|
||||
}
|
||||
return &PublicShareBinding{
|
||||
Token: token,
|
||||
OwnerID: ownerID,
|
||||
SharePath: normalizeOCSSharePath(sh.Path),
|
||||
ItemType: itemType,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
view, err := c.GetPublicShare(ctx, token, "/", password)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("public share token not found: %w", err)
|
||||
}
|
||||
itemType := strings.TrimSpace(view.ItemType)
|
||||
if itemType == "" {
|
||||
itemType = "folder"
|
||||
}
|
||||
sharePath := "/"
|
||||
if itemType == "file" && strings.TrimSpace(view.Name) != "" {
|
||||
sharePath = normalizeOCSSharePath("/" + view.Name)
|
||||
}
|
||||
return &PublicShareBinding{
|
||||
Token: token,
|
||||
OwnerID: ownerID,
|
||||
SharePath: sharePath,
|
||||
ItemType: itemType,
|
||||
}, nil
|
||||
}
|
||||
39
internal/nextcloud/public_share_binding_test.go
Normal file
39
internal/nextcloud/public_share_binding_test.go
Normal file
@ -0,0 +1,39 @@
|
||||
package nextcloud
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestPublicShareBindingClientSourcePath(t *testing.T) {
|
||||
b := &PublicShareBinding{
|
||||
SharePath: "/Documents/hello.docx",
|
||||
ItemType: "file",
|
||||
}
|
||||
if got := b.ClientSourcePath("/", ""); got != "/hello.docx" {
|
||||
t.Fatalf("single file empty display: got %q", got)
|
||||
}
|
||||
if got := b.ClientSourcePath("/hello.docx", ""); got != "/hello.docx" {
|
||||
t.Fatalf("explicit path: got %q", got)
|
||||
}
|
||||
|
||||
folder := &PublicShareBinding{SharePath: "/Documents", ItemType: "folder"}
|
||||
if got := folder.ClientSourcePath("/", "notes.txt"); got != "/notes.txt" {
|
||||
t.Fatalf("folder root display: got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicShareBindingOwnerPathForClient(t *testing.T) {
|
||||
b := &PublicShareBinding{
|
||||
SharePath: "/Documents/hello.docx",
|
||||
ItemType: "file",
|
||||
}
|
||||
if got := b.OwnerPathForClient("/", ""); got != "/Documents/hello.docx" {
|
||||
t.Fatalf("source owner path: got %q", got)
|
||||
}
|
||||
if got := b.OwnerPathForClient("/hello.ultidoc.json", ""); got != "/Documents/hello.ultidoc.json" {
|
||||
t.Fatalf("sidecar owner path: got %q", got)
|
||||
}
|
||||
|
||||
folder := &PublicShareBinding{SharePath: "/Documents", ItemType: "folder"}
|
||||
if got := folder.OwnerPathForClient("/hello.docx", ""); got != "/Documents/hello.docx" {
|
||||
t.Fatalf("folder file owner path: got %q", got)
|
||||
}
|
||||
}
|
||||
@ -26,6 +26,7 @@ import (
|
||||
meetapi "github.com/ultisuite/ulti-backend/internal/api/meet"
|
||||
"github.com/ultisuite/ulti-backend/internal/api/middleware"
|
||||
"github.com/ultisuite/ulti-backend/internal/api/office"
|
||||
"github.com/ultisuite/ulti-backend/internal/api/richtext"
|
||||
photosapi "github.com/ultisuite/ulti-backend/internal/api/photos"
|
||||
usersapi "github.com/ultisuite/ulti-backend/internal/api/users"
|
||||
"github.com/ultisuite/ulti-backend/internal/automation"
|
||||
@ -287,6 +288,19 @@ func New(ctx context.Context, cfg *config.Config, opts Options) (*App, error) {
|
||||
r.Mount("/api/v1/office", officeHandler.Routes(middleware.Auth(verifierHolder, pool, auditLogger, orgPolicyLoader)))
|
||||
driveHandler.SetPublicOffice(officeHandler)
|
||||
}
|
||||
if ncClient != nil && cfg.RichTextEnabled && driveSvc != nil {
|
||||
rtSvc := richtext.NewService(ncClient, richtext.Config{
|
||||
Enabled: true,
|
||||
HocuspocusPublicURL: cfg.HocuspocusPublicURL,
|
||||
HocuspocusSecret: cfg.HocuspocusSecret,
|
||||
APIInternalURL: cfg.OnlyOfficeAPIInternalURL,
|
||||
StorageMode: cfg.RichTextStorageMode,
|
||||
ExportMirrorFormat: cfg.RichTextExportMirror,
|
||||
}, driveSvc)
|
||||
rtHandler := richtext.NewHandler(rtSvc, driveSvc)
|
||||
r.Mount("/api/v1/richtext", rtHandler.Routes(middleware.Auth(verifierHolder, pool, auditLogger, orgPolicyLoader)))
|
||||
driveHandler.SetPublicRichText(rtHandler)
|
||||
}
|
||||
if driveHandler != nil {
|
||||
r.Mount("/api/v1/drive/public", driveHandler.PublicRoutes())
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user