package office import ( "context" "crypto/sha256" "encoding/hex" "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 } func NewService(nc *nextcloud.Client, cfg Config) *Service { return &Service{nc: nc, Cfg: cfg} } 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, userName string) (map[string]any, error) { filePath = normalizePath(filePath) docType := documentType(filePath) key := documentKey(ncUser, filePath) apiBase := strings.TrimRight(s.Cfg.APIInternalURL, "/") sig := "" if s.Cfg.JWTSecret != "" { var err error 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, "") edit := mode == "edit" document := map[string]any{ "fileType": fileExt(filePath), "key": key, "title": path.Base(filePath), "url": 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": mode, "user": map[string]any{ "id": ncUser, "name": userName, }, "callbackUrl": callbackURL, "customization": map[string]any{ "forcesave": true, }, } config := map[string]any{ "documentType": docType, "document": document, "editorConfig": editorCfg, } 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) } func documentKey(ncUser, filePath string) string { h := sha256.Sum256([]byte(ncUser + "|" + filePath + "|" + time.Now().Format("2006-01-02"))) return hex.EncodeToString(h[:16]) } 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 }