|
|
|
package wopi
|
|
|
|
|
|
|
|
import (
|
|
|
|
"errors"
|
|
|
|
"fmt"
|
|
|
|
model "github.com/cloudreve/Cloudreve/v3/models"
|
|
|
|
"github.com/cloudreve/Cloudreve/v3/pkg/cache"
|
|
|
|
"github.com/cloudreve/Cloudreve/v3/pkg/hashid"
|
|
|
|
"github.com/cloudreve/Cloudreve/v3/pkg/request"
|
|
|
|
"github.com/cloudreve/Cloudreve/v3/pkg/util"
|
|
|
|
"github.com/gofrs/uuid"
|
|
|
|
"net/url"
|
|
|
|
"path"
|
|
|
|
"strings"
|
|
|
|
"sync"
|
|
|
|
"time"
|
|
|
|
)
|
|
|
|
|
|
|
|
type Client interface {
|
|
|
|
// NewSession creates a new document session with access token.
|
|
|
|
NewSession(uid uint, file *model.File, action ActonType) (*Session, error)
|
|
|
|
// AvailableExts returns a list of file extensions that are supported by WOPI.
|
|
|
|
AvailableExts() []string
|
|
|
|
}
|
|
|
|
|
|
|
|
var (
|
|
|
|
ErrActionNotSupported = errors.New("action not supported by current wopi endpoint")
|
|
|
|
|
|
|
|
Default Client
|
|
|
|
DefaultMu sync.Mutex
|
|
|
|
|
|
|
|
queryPlaceholders = map[string]string{
|
|
|
|
"BUSINESS_USER": "",
|
|
|
|
"DC_LLCC": "lng",
|
|
|
|
"DISABLE_ASYNC": "",
|
|
|
|
"DISABLE_CHAT": "",
|
|
|
|
"EMBEDDED": "true",
|
|
|
|
"FULLSCREEN": "true",
|
|
|
|
"HOST_SESSION_ID": "",
|
|
|
|
"SESSION_CONTEXT": "",
|
|
|
|
"RECORDING": "",
|
|
|
|
"THEME_ID": "darkmode",
|
|
|
|
"UI_LLCC": "lng",
|
|
|
|
"VALIDATOR_TEST_CATEGORY": "",
|
|
|
|
}
|
|
|
|
)
|
|
|
|
|
|
|
|
const (
|
|
|
|
SessionCachePrefix = "wopi_session_"
|
|
|
|
AccessTokenQuery = "access_token"
|
|
|
|
OverwriteHeader = wopiHeaderPrefix + "Override"
|
|
|
|
ServerErrorHeader = wopiHeaderPrefix + "ServerError"
|
|
|
|
RenameRequestHeader = wopiHeaderPrefix + "RequestedName"
|
|
|
|
|
|
|
|
MethodLock = "LOCK"
|
|
|
|
MethodUnlock = "UNLOCK"
|
|
|
|
MethodRefreshLock = "REFRESH_LOCK"
|
|
|
|
MethodRename = "RENAME_FILE"
|
|
|
|
|
|
|
|
wopiSrcPlaceholder = "WOPI_SOURCE"
|
|
|
|
wopiSrcParamDefault = "WOPISrc"
|
|
|
|
languageParamDefault = "lang"
|
|
|
|
sessionExpiresPadding = 10
|
|
|
|
wopiHeaderPrefix = "X-WOPI-"
|
|
|
|
)
|
|
|
|
|
|
|
|
// Init initializes a new global WOPI client.
|
|
|
|
func Init() {
|
|
|
|
settings := model.GetSettingByNames("wopi_endpoint", "wopi_enabled")
|
|
|
|
if !model.IsTrueVal(settings["wopi_enabled"]) {
|
|
|
|
DefaultMu.Lock()
|
|
|
|
Default = nil
|
|
|
|
DefaultMu.Unlock()
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
cache.Deletes([]string{DiscoverResponseCacheKey}, "")
|
|
|
|
wopiClient, err := NewClient(settings["wopi_endpoint"], cache.Store, request.NewClient())
|
|
|
|
if err != nil {
|
|
|
|
util.Log().Error("Failed to initialize WOPI client: %s", err)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
DefaultMu.Lock()
|
|
|
|
Default = wopiClient
|
|
|
|
DefaultMu.Unlock()
|
|
|
|
}
|
|
|
|
|
|
|
|
type client struct {
|
|
|
|
cache cache.Driver
|
|
|
|
http request.Client
|
|
|
|
mu sync.RWMutex
|
|
|
|
|
|
|
|
discovery *WopiDiscovery
|
|
|
|
actions map[string]map[string]Action
|
|
|
|
|
|
|
|
config
|
|
|
|
}
|
|
|
|
|
|
|
|
type config struct {
|
|
|
|
discoveryEndpoint *url.URL
|
|
|
|
}
|
|
|
|
|
|
|
|
func NewClient(endpoint string, cache cache.Driver, http request.Client) (Client, error) {
|
|
|
|
endpointUrl, err := url.Parse(endpoint)
|
|
|
|
if err != nil {
|
|
|
|
return nil, fmt.Errorf("failed to parse WOPI endpoint: %s", err)
|
|
|
|
}
|
|
|
|
|
|
|
|
return &client{
|
|
|
|
cache: cache,
|
|
|
|
http: http,
|
|
|
|
config: config{
|
|
|
|
discoveryEndpoint: endpointUrl,
|
|
|
|
},
|
|
|
|
}, nil
|
|
|
|
}
|
|
|
|
|
|
|
|
func (c *client) NewSession(uid uint, file *model.File, action ActonType) (*Session, error) {
|
|
|
|
if err := c.checkDiscovery(); err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
|
|
|
|
c.mu.RLock()
|
|
|
|
defer c.mu.RUnlock()
|
|
|
|
|
|
|
|
ext := path.Ext(file.Name)
|
|
|
|
availableActions, ok := c.actions[ext]
|
|
|
|
if !ok {
|
|
|
|
return nil, ErrActionNotSupported
|
|
|
|
}
|
|
|
|
|
|
|
|
var (
|
|
|
|
actionConfig Action
|
|
|
|
)
|
|
|
|
fallbackOrder := []ActonType{action, ActionPreview, ActionPreviewFallback, ActionEdit}
|
|
|
|
for _, a := range fallbackOrder {
|
|
|
|
if actionConfig, ok = availableActions[string(a)]; ok {
|
|
|
|
break
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
if actionConfig.Urlsrc == "" {
|
|
|
|
return nil, ErrActionNotSupported
|
|
|
|
}
|
|
|
|
|
|
|
|
// Generate WOPI REST endpoint for given file
|
|
|
|
baseURL := model.GetSiteURL()
|
|
|
|
linkPath, err := url.Parse(fmt.Sprintf("/api/v3/wopi/files/%s", hashid.HashID(file.ID, hashid.FileID)))
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
|
|
|
|
actionUrl, err := generateActionUrl(actionConfig.Urlsrc, baseURL.ResolveReference(linkPath).String())
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
|
|
|
|
// Create document session
|
|
|
|
sessionID := uuid.Must(uuid.NewV4())
|
|
|
|
token := util.RandStringRunes(64)
|
|
|
|
ttl := model.GetIntSetting("wopi_session_timeout", 36000)
|
|
|
|
session := &SessionCache{
|
|
|
|
AccessToken: fmt.Sprintf("%s.%s", sessionID, token),
|
|
|
|
FileID: file.ID,
|
|
|
|
UserID: uid,
|
|
|
|
Action: action,
|
|
|
|
}
|
|
|
|
err = c.cache.Set(SessionCachePrefix+sessionID.String(), *session, ttl)
|
|
|
|
if err != nil {
|
|
|
|
return nil, fmt.Errorf("failed to create document session: %w", err)
|
|
|
|
}
|
|
|
|
|
|
|
|
sessionRes := &Session{
|
|
|
|
AccessToken: session.AccessToken,
|
|
|
|
ActionURL: actionUrl,
|
|
|
|
AccessTokenTTL: time.Now().Add(time.Duration(ttl-sessionExpiresPadding) * time.Second).UnixMilli(),
|
|
|
|
}
|
|
|
|
|
|
|
|
return sessionRes, nil
|
|
|
|
}
|
|
|
|
|
|
|
|
// Replace query parameters in action URL template. Some placeholders need to be replaced
|
|
|
|
// at the frontend, e.g. `THEME_ID`.
|
|
|
|
func generateActionUrl(src string, fileSrc string) (*url.URL, error) {
|
|
|
|
src = strings.ReplaceAll(src, "<", "")
|
|
|
|
src = strings.ReplaceAll(src, ">", "")
|
|
|
|
actionUrl, err := url.Parse(src)
|
|
|
|
if err != nil {
|
|
|
|
return nil, fmt.Errorf("failed to parse action url: %s", err)
|
|
|
|
}
|
|
|
|
|
|
|
|
queries := actionUrl.Query()
|
|
|
|
srcReplaced := false
|
|
|
|
queryReplaced := url.Values{}
|
|
|
|
for k := range queries {
|
|
|
|
if placeholder, ok := queryPlaceholders[queries.Get(k)]; ok {
|
|
|
|
if placeholder != "" {
|
|
|
|
queryReplaced.Set(k, placeholder)
|
|
|
|
}
|
|
|
|
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
|
|
|
|
if queries.Get(k) == wopiSrcPlaceholder {
|
|
|
|
queryReplaced.Set(k, fileSrc)
|
|
|
|
srcReplaced = true
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
|
|
|
|
queryReplaced.Set(k, queries.Get(k))
|
|
|
|
}
|
|
|
|
|
|
|
|
if !srcReplaced {
|
|
|
|
queryReplaced.Set(wopiSrcParamDefault, fileSrc)
|
|
|
|
}
|
|
|
|
|
|
|
|
// LibreOffice require this flag to show correct language
|
|
|
|
queryReplaced.Set(languageParamDefault, "lng")
|
|
|
|
|
|
|
|
actionUrl.RawQuery = queryReplaced.Encode()
|
|
|
|
return actionUrl, nil
|
|
|
|
}
|