ultisuite-backend/internal/api/office/service.go
R3D347HR4Y 71b716edba
Some checks are pending
CI / Go tests (push) Waiting to run
CI / Integration tests (push) Waiting to run
CI / DB migrations (push) Waiting to run
feat(office): add display name support for public share sessions
- Updated publicOfficeSessionRequest to include a new DisplayName field.
- Modified PublicEditorConfig to accept and utilize the display name for editor configuration.
- Implemented editorLabelPath function to determine the correct file name for single-file public shares.
- Added unit tests for editor label path and build editor config functionalities.
2026-06-15 11:10:14 +02:00

237 lines
6.3 KiB
Go

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
displayName string
mode string
editorUserID string
userName string
documentKey string
downloadURL string
callbackURL string
}
// editorLabelPath picks the name used for OnlyOffice fileType/documentType/title.
// Single-file public shares use WebDAV path "/" — displayName carries the real filename.
func editorLabelPath(filePath, displayName string) string {
if ext := path.Ext(filePath); ext != "" {
return filePath
}
if name := strings.TrimSpace(displayName); name != "" {
return name
}
return filePath
}
func buildEditorConfig(in buildEditorConfigInput) (map[string]any, error) {
labelPath := editorLabelPath(in.filePath, in.displayName)
docType := documentType(labelPath)
edit := in.mode == "edit"
document := map[string]any{
"fileType": fileExt(labelPath),
"key": in.documentKey,
"title": path.Base(labelPath),
"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
}