- 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.
237 lines
6.3 KiB
Go
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
|
|
}
|