ultisuite-backend/internal/api/office/service.go
2026-06-04 00:12:11 +02:00

175 lines
4.7 KiB
Go

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
}