You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
cloudreve/pkg/wopi/wopi.go

224 lines
5.5 KiB

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
}