package office import ( "context" "fmt" "io" "net/url" "path" "strings" "time" "github.com/ultisuite/ulti-backend/internal/auth" "github.com/ultisuite/ulti-backend/internal/nextcloud" ) type Config struct { Enabled bool DocumentURL string // OnlyOffice Document Server API base (e.g. http://onlyoffice) PublicURL string // Browser-facing Document Server URL APIInternalURL string // ultid base reachable from OnlyOffice container (doc fetch + callback) JWTSecret string } type Service struct { nc *nextcloud.Client Cfg Config keys *documentKeyStore } func NewService(nc *nextcloud.Client, cfg Config) *Service { return &Service{ nc: nc, Cfg: cfg, keys: newDocumentKeyStore(), } } func (s *Service) PublicURL() string { return strings.TrimRight(s.Cfg.PublicURL, "/") } func (s *Service) EnsureNextcloudUser(ctx context.Context, claims *auth.Claims) (string, error) { return s.nc.EnsurePrincipal(ctx, claims.Email, claims.Sub, claims.Name) } func (s *Service) EditorConfig(ctx context.Context, ncUser, filePath, mode, editorUserID, userName string) (map[string]any, error) { filePath = normalizePath(filePath) rev, err := s.nc.FileRevision(ctx, ncUser, filePath) if err != nil { return nil, fmt.Errorf("resolve file revision: %w", err) } apiBase := strings.TrimRight(s.Cfg.APIInternalURL, "/") sig := "" if s.Cfg.JWTSecret != "" { sig, err = signDocAccess(ncUser, filePath, s.Cfg.JWTSecret) if err != nil { return nil, err } } downloadURL := buildOfficeEndpointURL(apiBase, "/api/v1/office/document", ncUser, filePath, sig) callbackURL := buildOfficeEndpointURL(apiBase, "/api/v1/office/callback", ncUser, filePath, "") return buildEditorConfig(buildEditorConfigInput{ filePath: filePath, mode: mode, editorUserID: editorUserID, userName: userName, documentKey: s.keys.current(rev.FileID), downloadURL: downloadURL, callbackURL: callbackURL, }) } func (s *Service) RotateDocumentKeyAfterSave(ctx context.Context, ncUser, filePath string) { rev, err := s.nc.FileRevision(ctx, ncUser, filePath) if err != nil { return } s.keys.rotateAfterSave(rev.FileID) } func (s *Service) RotatePublicDocumentKeyAfterSave(ctx context.Context, token, filePath, password string) { rev, err := s.nc.PublicShareFileRevision(ctx, token, filePath, password) if err != nil { return } s.keys.rotateAfterSave(rev.FileID) } func (s *Service) wrapEditorConfig(config map[string]any) (map[string]any, error) { return wrapConfig(config, s.Cfg.JWTSecret) } func (s *Service) OpenDocument(ctx context.Context, ncUser, filePath string) (io.ReadCloser, string, error) { return s.nc.Download(ctx, ncUser, normalizePath(filePath)) } func (s *Service) SaveDocument(ctx context.Context, ncUser, filePath string, body io.Reader, contentType string) error { return s.nc.Upload(ctx, ncUser, normalizePath(filePath), body, contentType) } type buildEditorConfigInput struct { filePath string mode string editorUserID string userName string documentKey string downloadURL string callbackURL string } func buildEditorConfig(in buildEditorConfigInput) (map[string]any, error) { docType := documentType(in.filePath) edit := in.mode == "edit" document := map[string]any{ "fileType": fileExt(in.filePath), "key": in.documentKey, "title": path.Base(in.filePath), "url": in.downloadURL, "permissions": map[string]any{ "comment": true, "copy": true, "deleteCommentAuthorOnly": false, "download": true, "edit": edit, "editCommentAuthorOnly": false, "fillForms": edit, "modifyContentControl": edit, "modifyFilter": edit, "print": true, "review": true, }, } editorCfg := map[string]any{ "mode": in.mode, "user": map[string]any{ "id": in.editorUserID, "name": in.userName, }, "callbackUrl": in.callbackURL, "coEditing": map[string]any{ "mode": "fast", "change": false, }, "customization": map[string]any{ "autosave": true, "forcesave": true, }, } config := map[string]any{ "documentType": docType, "document": document, "editorConfig": editorCfg, } return config, nil } func documentType(filePath string) string { ext := strings.ToLower(path.Ext(filePath)) switch ext { case ".xlsx", ".xls", ".xlsb", ".xlsm", ".xlt", ".xltm", ".xltx", ".ods", ".ots", ".csv", ".tsv", ".fods", ".et", ".ett", ".sxc": return "cell" case ".pptx", ".ppt", ".pptm", ".pot", ".potm", ".potx", ".pps", ".ppsm", ".ppsx", ".odp", ".otp", ".odg", ".fodp", ".dps", ".dpt", ".sxi": return "slide" case ".vsdm", ".vsdx", ".vssm", ".vssx", ".vstm", ".vstx": return "diagram" default: return "word" } } func fileExt(filePath string) string { return strings.TrimPrefix(strings.ToLower(path.Ext(filePath)), ".") } func normalizePath(p string) string { p = strings.TrimSpace(p) if p == "" { return "/" } if !strings.HasPrefix(p, "/") { p = "/" + p } return p } func buildOfficeEndpointURL(base, endpoint, ncUser, filePath, sig string) string { q := url.Values{} q.Set("path", normalizePath(filePath)) q.Set("user", ncUser) if sig != "" { q.Set("sig", sig) } return strings.TrimRight(base, "/") + endpoint + "?" + q.Encode() } func signDocAccess(ncUser, filePath, secret string) (string, error) { payload := map[string]any{ "user": ncUser, "path": normalizePath(filePath), "exp": time.Now().Add(2 * time.Hour).Unix(), } return signJWT(payload, secret) } func VerifyDocAccess(ncUser, filePath, sig, secret string) bool { payload, err := verifyJWT(sig, secret) if err != nil { return false } if payload["user"] != ncUser || payload["path"] != normalizePath(filePath) { return false } if exp, ok := payload["exp"].(float64); ok && int64(exp) < time.Now().Unix() { return false } return true }