hocuspocus lol 2
This commit is contained in:
parent
cf087e637e
commit
f1dbea8db3
@ -160,6 +160,15 @@ ULTID_PUBLIC_URL=http://{{DOMAIN}}
|
|||||||
# Base URL for public share links (default: {ULTID_PUBLIC_URL}/drive → /drive/s/{token})
|
# Base URL for public share links (default: {ULTID_PUBLIC_URL}/drive → /drive/s/{token})
|
||||||
# DRIVE_PUBLIC_URL=http://{{DOMAIN}}/drive
|
# 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)
|
# Jitsi Meet (Visioconference)
|
||||||
# Mode local : Jitsi deploye dans la stack
|
# Mode local : Jitsi deploye dans la stack
|
||||||
|
|||||||
@ -65,6 +65,24 @@ server {
|
|||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
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)
|
# Ultimail OIDC post-login — before Authentik /auth/ (path collision)
|
||||||
location ^~ /auth/complete {
|
location ^~ /auth/complete {
|
||||||
resolver 127.0.0.11 valid=10s ipv6=off;
|
resolver 127.0.0.11 valid=10s ipv6=off;
|
||||||
|
|||||||
@ -105,6 +105,12 @@ func defaultOrgPolicy() map[string]any {
|
|||||||
"jwt_secret": "",
|
"jwt_secret": "",
|
||||||
"jwt_header": "Authorization",
|
"jwt_header": "Authorization",
|
||||||
},
|
},
|
||||||
|
"richtext": map[string]any{
|
||||||
|
"enabled": true,
|
||||||
|
"storage_mode": "sidecar",
|
||||||
|
"export_mirror_format": "",
|
||||||
|
"hocuspocus_url": "",
|
||||||
|
},
|
||||||
"plugins": []any{
|
"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": "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"},
|
map[string]any{"id": "contact-discovery", "name": "Découverte contacts", "description": "Enrichissement IA et signatures détectées.", "enabled": true, "version": "1.0.0"},
|
||||||
|
|||||||
@ -25,9 +25,14 @@ import (
|
|||||||
type Handler struct {
|
type Handler struct {
|
||||||
svc *Service
|
svc *Service
|
||||||
publicOffice PublicOfficeAPI
|
publicOffice PublicOfficeAPI
|
||||||
|
publicRichText PublicRichTextAPI
|
||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PublicRichTextAPI interface {
|
||||||
|
RegisterPublicShareRoutes(r chi.Router)
|
||||||
|
}
|
||||||
|
|
||||||
func NewHandler(nc *nextcloud.Client, hub *realtime.Hub, db *pgxpool.Pool) *Handler {
|
func NewHandler(nc *nextcloud.Client, hub *realtime.Hub, db *pgxpool.Pool) *Handler {
|
||||||
return NewHandlerWithService(NewService(nc, hub, db))
|
return NewHandlerWithService(NewService(nc, hub, db))
|
||||||
}
|
}
|
||||||
@ -43,6 +48,10 @@ func (h *Handler) SetPublicOffice(api PublicOfficeAPI) {
|
|||||||
h.publicOffice = api
|
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) {
|
func (h *Handler) nextcloudUser(w http.ResponseWriter, r *http.Request, claims *auth.Claims) (string, bool) {
|
||||||
userID, err := h.svc.EnsureNextcloudUser(r.Context(), claims)
|
userID, err := h.svc.EnsureNextcloudUser(r.Context(), claims)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@ -24,6 +24,9 @@ func (h *Handler) PublicRoutes() chi.Router {
|
|||||||
if h.publicOffice != nil {
|
if h.publicOffice != nil {
|
||||||
h.publicOffice.RegisterPublicShareRoutes(r)
|
h.publicOffice.RegisterPublicShareRoutes(r)
|
||||||
}
|
}
|
||||||
|
if h.publicRichText != nil {
|
||||||
|
h.publicRichText.RegisterPublicShareRoutes(r)
|
||||||
|
}
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -97,6 +97,7 @@ func (s *Service) ListFilterCorpus(ctx context.Context, userID, path string) (Fi
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return FilesList{}, mapDriveError(err)
|
return FilesList{}, mapDriveError(err)
|
||||||
}
|
}
|
||||||
|
files = nextcloud.FilterHiddenUltidocSidecars(files)
|
||||||
total := int64(len(files))
|
total := int64(len(files))
|
||||||
return FilesList{
|
return FilesList{
|
||||||
Files: files,
|
Files: files,
|
||||||
@ -112,7 +113,7 @@ func (s *Service) ListFiles(ctx context.Context, userID, path string, params que
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return FilesList{}, mapDriveError(err)
|
return FilesList{}, mapDriveError(err)
|
||||||
}
|
}
|
||||||
filtered := filterFiles(files, params.Q)
|
filtered := visibleDriveFiles(files, params.Q)
|
||||||
page, total := paginate.Slice(filtered, params.Offset(), params.Limit())
|
page, total := paginate.Slice(filtered, params.Offset(), params.Limit())
|
||||||
return FilesList{
|
return FilesList{
|
||||||
Files: page,
|
Files: page,
|
||||||
@ -125,7 +126,7 @@ func (s *Service) ListTrash(ctx context.Context, userID string, params query.Lis
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return FilesList{}, mapDriveError(err)
|
return FilesList{}, mapDriveError(err)
|
||||||
}
|
}
|
||||||
filtered := filterFiles(files, params.Q)
|
filtered := visibleDriveFiles(files, params.Q)
|
||||||
page, total := paginate.Slice(filtered, params.Offset(), params.Limit())
|
page, total := paginate.Slice(filtered, params.Offset(), params.Limit())
|
||||||
return FilesList{
|
return FilesList{
|
||||||
Files: page,
|
Files: page,
|
||||||
@ -138,7 +139,7 @@ func (s *Service) ListRecent(ctx context.Context, userID string, params query.Li
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return FilesList{}, mapDriveError(err)
|
return FilesList{}, mapDriveError(err)
|
||||||
}
|
}
|
||||||
filtered := filterFiles(files, params.Q)
|
filtered := visibleDriveFiles(files, params.Q)
|
||||||
page, total := paginate.Slice(filtered, params.Offset(), params.Limit())
|
page, total := paginate.Slice(filtered, params.Offset(), params.Limit())
|
||||||
return FilesList{
|
return FilesList{
|
||||||
Files: page,
|
Files: page,
|
||||||
@ -151,7 +152,7 @@ func (s *Service) ListSharedWithMe(ctx context.Context, userID string, params qu
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return FilesList{}, mapDriveError(err)
|
return FilesList{}, mapDriveError(err)
|
||||||
}
|
}
|
||||||
filtered := filterFiles(files, params.Q)
|
filtered := visibleDriveFiles(files, params.Q)
|
||||||
page, total := paginate.Slice(filtered, params.Offset(), params.Limit())
|
page, total := paginate.Slice(filtered, params.Offset(), params.Limit())
|
||||||
return FilesList{
|
return FilesList{
|
||||||
Files: page,
|
Files: page,
|
||||||
@ -175,7 +176,7 @@ func (s *Service) ListStarred(ctx context.Context, userID, basePath string, para
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return FilesList{}, mapDriveError(err)
|
return FilesList{}, mapDriveError(err)
|
||||||
}
|
}
|
||||||
filtered := filterFiles(starred, params.Q)
|
filtered := visibleDriveFiles(starred, params.Q)
|
||||||
page, total := paginate.Slice(filtered, params.Offset(), limit)
|
page, total := paginate.Slice(filtered, params.Offset(), limit)
|
||||||
return FilesList{
|
return FilesList{
|
||||||
Files: page,
|
Files: page,
|
||||||
@ -402,6 +403,9 @@ func (s *Service) GetPublicShare(ctx context.Context, token, path, password stri
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, mapPublicShareError(err)
|
return nil, mapPublicShareError(err)
|
||||||
}
|
}
|
||||||
|
if view != nil && len(view.Files) > 0 {
|
||||||
|
view.Files = nextcloud.FilterHiddenUltidocSidecars(view.Files)
|
||||||
|
}
|
||||||
s.recordPublicShareAccess(ctx, token)
|
s.recordPublicShareAccess(ctx, token)
|
||||||
return view, nil
|
return view, nil
|
||||||
}
|
}
|
||||||
@ -521,7 +525,7 @@ func (s *Service) Search(ctx context.Context, userID string, opts SearchOptions,
|
|||||||
return FilesList{}, mapDriveError(err)
|
return FilesList{}, mapDriveError(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
page, total := paginate.Slice(files, params.Offset(), limit)
|
page, total := paginate.Slice(nextcloud.FilterHiddenUltidocSidecars(files), params.Offset(), limit)
|
||||||
return FilesList{
|
return FilesList{
|
||||||
Files: page,
|
Files: page,
|
||||||
Pagination: params.Meta(&total),
|
Pagination: params.Meta(&total),
|
||||||
@ -576,6 +580,10 @@ func filterFiles(files []nextcloud.FileInfo, q string) []nextcloud.FileInfo {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func visibleDriveFiles(files []nextcloud.FileInfo, q string) []nextcloud.FileInfo {
|
||||||
|
return filterFiles(nextcloud.FilterHiddenUltidocSidecars(files), q)
|
||||||
|
}
|
||||||
|
|
||||||
type ChunkUpload struct {
|
type ChunkUpload struct {
|
||||||
Index int
|
Index int
|
||||||
Total int
|
Total int
|
||||||
|
|||||||
@ -30,6 +30,7 @@ type publicOfficeSessionRequest struct {
|
|||||||
Mode string `json:"mode"`
|
Mode string `json:"mode"`
|
||||||
Password string `json:"password"`
|
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 {
|
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) {
|
if mode == "edit" && !nextcloud.PublicShareCanUpdate(perms) {
|
||||||
mode = "view"
|
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 {
|
if err != nil {
|
||||||
h.logger.Error("public editor config", "error", err)
|
h.logger.Error("public editor config", "error", err)
|
||||||
apivalidate.WriteInternal(w, r)
|
apivalidate.WriteInternal(w, r)
|
||||||
|
|||||||
@ -15,7 +15,7 @@ type PublicShareAccess struct {
|
|||||||
Password string
|
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)
|
token = strings.TrimSpace(token)
|
||||||
filePath = normalizePath(filePath)
|
filePath = normalizePath(filePath)
|
||||||
if token == "" || filePath == "" {
|
if token == "" || filePath == "" {
|
||||||
@ -45,11 +45,15 @@ func (s *Service) PublicEditorConfig(ctx context.Context, token, filePath, mode,
|
|||||||
editorUserID = "public:" + editorUserID
|
editorUserID = "public:" + editorUserID
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if guestName == "" {
|
||||||
|
guestName = "Invité"
|
||||||
|
}
|
||||||
|
|
||||||
config, err := buildEditorConfig(buildEditorConfigInput{
|
config, err := buildEditorConfig(buildEditorConfigInput{
|
||||||
filePath: filePath,
|
filePath: filePath,
|
||||||
mode: mode,
|
mode: mode,
|
||||||
editorUserID: editorUserID,
|
editorUserID: editorUserID,
|
||||||
userName: "Invité",
|
userName: guestName,
|
||||||
documentKey: s.keys.current(rev.FileID),
|
documentKey: s.keys.current(rev.FileID),
|
||||||
downloadURL: downloadURL,
|
downloadURL: downloadURL,
|
||||||
callbackURL: callbackURL,
|
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
|
UltidPublicURL string
|
||||||
DrivePublicURL string
|
DrivePublicURL string
|
||||||
|
|
||||||
|
// Rich text editor (TipTap + Hocuspocus)
|
||||||
|
RichTextEnabled bool
|
||||||
|
HocuspocusPublicURL string
|
||||||
|
HocuspocusSecret string
|
||||||
|
RichTextStorageMode string
|
||||||
|
RichTextExportMirror string
|
||||||
|
|
||||||
// Jitsi
|
// Jitsi
|
||||||
JitsiEnabled bool
|
JitsiEnabled bool
|
||||||
JitsiDomain string
|
JitsiDomain string
|
||||||
@ -181,6 +188,11 @@ func Load() (*Config, error) {
|
|||||||
OnlyOfficeJWTSecret: secrets.Env("ONLYOFFICE_JWT_SECRET"),
|
OnlyOfficeJWTSecret: secrets.Env("ONLYOFFICE_JWT_SECRET"),
|
||||||
UltidPublicURL: envOrDefault("ULTID_PUBLIC_URL", "http://localhost"),
|
UltidPublicURL: envOrDefault("ULTID_PUBLIC_URL", "http://localhost"),
|
||||||
DrivePublicURL: drivePublicURL(),
|
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),
|
JitsiEnabled: envBool("JITSI_ENABLED", true),
|
||||||
JitsiDomain: envOrDefault("JITSI_DOMAIN", "meet.jitsi"),
|
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)
|
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{
|
resp, err := c.publicShareRequest(ctx, "PROPFIND", token, filePath, strings.NewReader(propfindPublicRevisionBody), password, map[string]string{
|
||||||
"Depth": "0",
|
"Depth": "0",
|
||||||
"Content-Type": "application/xml",
|
"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"
|
meetapi "github.com/ultisuite/ulti-backend/internal/api/meet"
|
||||||
"github.com/ultisuite/ulti-backend/internal/api/middleware"
|
"github.com/ultisuite/ulti-backend/internal/api/middleware"
|
||||||
"github.com/ultisuite/ulti-backend/internal/api/office"
|
"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"
|
photosapi "github.com/ultisuite/ulti-backend/internal/api/photos"
|
||||||
usersapi "github.com/ultisuite/ulti-backend/internal/api/users"
|
usersapi "github.com/ultisuite/ulti-backend/internal/api/users"
|
||||||
"github.com/ultisuite/ulti-backend/internal/automation"
|
"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)))
|
r.Mount("/api/v1/office", officeHandler.Routes(middleware.Auth(verifierHolder, pool, auditLogger, orgPolicyLoader)))
|
||||||
driveHandler.SetPublicOffice(officeHandler)
|
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 {
|
if driveHandler != nil {
|
||||||
r.Mount("/api/v1/drive/public", driveHandler.PublicRoutes())
|
r.Mount("/api/v1/drive/public", driveHandler.PublicRoutes())
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user