Init V4 community edition (#2265)
* Init V4 community edition * Init V4 community edition
This commit is contained in:
@@ -3,9 +3,9 @@ package wopi
|
||||
import (
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"github.com/cloudreve/Cloudreve/v3/pkg/util"
|
||||
"net/http"
|
||||
"strings"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/setting"
|
||||
"github.com/gofrs/uuid"
|
||||
"github.com/samber/lo"
|
||||
)
|
||||
|
||||
type ActonType string
|
||||
@@ -16,86 +16,53 @@ var (
|
||||
ActionEdit = ActonType("edit")
|
||||
)
|
||||
|
||||
const (
|
||||
DiscoverResponseCacheKey = "wopi_discover"
|
||||
DiscoverRefreshDuration = 24 * 3600 // 24 hrs
|
||||
)
|
||||
|
||||
func (c *client) AvailableExts() []string {
|
||||
if err := c.checkDiscovery(); err != nil {
|
||||
util.Log().Error("Failed to check WOPI discovery: %s", err)
|
||||
return nil
|
||||
func DiscoveryXmlToViewerGroup(xmlStr string) (*setting.ViewerGroup, error) {
|
||||
var discovery WopiDiscovery
|
||||
if err := xml.Unmarshal([]byte(xmlStr), &discovery); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse WOPI discovery XML: %w", err)
|
||||
}
|
||||
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
exts := make([]string, 0, len(c.actions))
|
||||
for ext, actions := range c.actions {
|
||||
_, previewable := actions[string(ActionPreview)]
|
||||
_, editable := actions[string(ActionEdit)]
|
||||
_, previewableFallback := actions[string(ActionPreviewFallback)]
|
||||
|
||||
if previewable || editable || previewableFallback {
|
||||
exts = append(exts, strings.TrimPrefix(ext, "."))
|
||||
}
|
||||
group := &setting.ViewerGroup{
|
||||
Viewers: make([]setting.Viewer, 0, len(discovery.NetZone.App)),
|
||||
}
|
||||
|
||||
return exts
|
||||
}
|
||||
|
||||
// checkDiscovery checks if discovery content is needed to be refreshed.
|
||||
// If so, it will refresh discovery content.
|
||||
func (c *client) checkDiscovery() error {
|
||||
c.mu.RLock()
|
||||
if c.discovery == nil {
|
||||
c.mu.RUnlock()
|
||||
return c.refreshDiscovery()
|
||||
}
|
||||
|
||||
c.mu.RUnlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// refresh Discovery action configs.
|
||||
func (c *client) refreshDiscovery() error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
cached, exist := c.cache.Get(DiscoverResponseCacheKey)
|
||||
if exist {
|
||||
cachedDiscovery := cached.(WopiDiscovery)
|
||||
c.discovery = &cachedDiscovery
|
||||
} else {
|
||||
res, err := c.http.Request("GET", c.config.discoveryEndpoint.String(), nil).
|
||||
CheckHTTPResponse(http.StatusOK).GetResponse()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to request discovery endpoint: %w", err)
|
||||
for _, app := range discovery.NetZone.App {
|
||||
viewer := setting.Viewer{
|
||||
ID: uuid.Must(uuid.NewV4()).String(),
|
||||
DisplayName: app.Name,
|
||||
Type: setting.ViewerTypeWopi,
|
||||
Icon: app.FavIconUrl,
|
||||
WopiActions: make(map[string]map[setting.ViewerAction]string),
|
||||
}
|
||||
|
||||
if err := xml.Unmarshal([]byte(res), &c.discovery); err != nil {
|
||||
return fmt.Errorf("failed to parse response discovery endpoint: %w", err)
|
||||
}
|
||||
|
||||
if err := c.cache.Set(DiscoverResponseCacheKey, *c.discovery, DiscoverRefreshDuration); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// construct actions map
|
||||
c.actions = make(map[string]map[string]Action)
|
||||
for _, app := range c.discovery.NetZone.App {
|
||||
for _, action := range app.Action {
|
||||
if action.Ext == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if _, ok := c.actions["."+action.Ext]; !ok {
|
||||
c.actions["."+action.Ext] = make(map[string]Action)
|
||||
if _, ok := viewer.WopiActions[action.Ext]; !ok {
|
||||
viewer.WopiActions[action.Ext] = make(map[setting.ViewerAction]string)
|
||||
}
|
||||
|
||||
c.actions["."+action.Ext][action.Name] = action
|
||||
if action.Name == string(ActionPreview) {
|
||||
viewer.WopiActions[action.Ext][setting.ViewerActionView] = action.Urlsrc
|
||||
} else if action.Name == string(ActionPreviewFallback) {
|
||||
viewer.WopiActions[action.Ext][setting.ViewerActionView] = action.Urlsrc
|
||||
} else if action.Name == string(ActionEdit) {
|
||||
viewer.WopiActions[action.Ext][setting.ViewerActionEdit] = action.Urlsrc
|
||||
} else if len(viewer.WopiActions[action.Ext]) == 0 {
|
||||
delete(viewer.WopiActions, action.Ext)
|
||||
}
|
||||
}
|
||||
|
||||
viewer.Exts = lo.MapToSlice(viewer.WopiActions, func(key string, value map[setting.ViewerAction]string) string {
|
||||
return key
|
||||
})
|
||||
|
||||
if len(viewer.WopiActions) > 0 {
|
||||
group.Viewers = append(group.Viewers, viewer)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
return group, nil
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -63,6 +63,10 @@ type SessionCache struct {
|
||||
Action ActonType
|
||||
}
|
||||
|
||||
const (
|
||||
WopiSessionCtx = "wopi_session"
|
||||
)
|
||||
|
||||
func init() {
|
||||
gob.Register(WopiDiscovery{})
|
||||
gob.Register(Action{})
|
||||
|
||||
151
pkg/wopi/wopi.go
151
pkg/wopi/wopi.go
@@ -1,34 +1,22 @@
|
||||
package wopi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"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"
|
||||
"github.com/cloudreve/Cloudreve/v4/application/dependency"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/cluster/routes"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/filemanager/manager"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/hashid"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/setting"
|
||||
"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",
|
||||
@@ -48,136 +36,55 @@ var (
|
||||
const (
|
||||
SessionCachePrefix = "wopi_session_"
|
||||
AccessTokenQuery = "access_token"
|
||||
OverwriteHeader = wopiHeaderPrefix + "Override"
|
||||
ServerErrorHeader = wopiHeaderPrefix + "ServerError"
|
||||
RenameRequestHeader = wopiHeaderPrefix + "RequestedName"
|
||||
OverwriteHeader = WopiHeaderPrefix + "Override"
|
||||
ServerErrorHeader = WopiHeaderPrefix + "ServerError"
|
||||
RenameRequestHeader = WopiHeaderPrefix + "RequestedName"
|
||||
LockTokenHeader = WopiHeaderPrefix + "Lock"
|
||||
ItemVersionHeader = WopiHeaderPrefix + "ItemVersion"
|
||||
|
||||
MethodLock = "LOCK"
|
||||
MethodUnlock = "UNLOCK"
|
||||
MethodRefreshLock = "REFRESH_LOCK"
|
||||
MethodRename = "RENAME_FILE"
|
||||
|
||||
wopiSrcPlaceholder = "WOPI_SOURCE"
|
||||
wopiSrcParamDefault = "WOPISrc"
|
||||
languageParamDefault = "lang"
|
||||
sessionExpiresPadding = 10
|
||||
wopiHeaderPrefix = "X-WOPI-"
|
||||
wopiSrcPlaceholder = "WOPI_SOURCE"
|
||||
wopiSrcParamDefault = "WOPISrc"
|
||||
languageParamDefault = "lang"
|
||||
WopiHeaderPrefix = "X-WOPI-"
|
||||
|
||||
LockDuration = time.Duration(30) * time.Minute
|
||||
)
|
||||
|
||||
// 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
|
||||
}
|
||||
func GenerateWopiSrc(ctx context.Context, action setting.ViewerAction, viewer *setting.Viewer, viewerSession *manager.ViewerSession) (*url.URL, error) {
|
||||
dep := dependency.FromContext(ctx)
|
||||
base := dep.SettingProvider().SiteURL(setting.UseFirstSiteUrl(ctx))
|
||||
hasher := dep.HashIDEncoder()
|
||||
|
||||
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]
|
||||
availableActions, ok := viewer.WopiActions[viewerSession.File.Ext()]
|
||||
if !ok {
|
||||
return nil, ErrActionNotSupported
|
||||
}
|
||||
|
||||
var (
|
||||
actionConfig Action
|
||||
src string
|
||||
)
|
||||
fallbackOrder := []ActonType{action, ActionPreview, ActionPreviewFallback, ActionEdit}
|
||||
fallbackOrder := []setting.ViewerAction{action, setting.ViewerActionView, setting.ViewerActionEdit}
|
||||
for _, a := range fallbackOrder {
|
||||
if actionConfig, ok = availableActions[string(a)]; ok {
|
||||
if src, ok = availableActions[a]; ok {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if actionConfig.Urlsrc == "" {
|
||||
if src == "" {
|
||||
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)))
|
||||
actionUrl, err := generateActionUrl(src,
|
||||
routes.MasterWopiSrc(base, hashid.EncodeFileID(hasher, viewerSession.File.ID())).String())
|
||||
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
|
||||
return actionUrl, nil
|
||||
}
|
||||
|
||||
// Replace query parameters in action URL template. Some placeholders need to be replaced
|
||||
|
||||
@@ -1,184 +0,0 @@
|
||||
package wopi
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
model "github.com/cloudreve/Cloudreve/v3/models"
|
||||
"github.com/cloudreve/Cloudreve/v3/pkg/cache"
|
||||
"github.com/cloudreve/Cloudreve/v3/pkg/mocks/cachemock"
|
||||
"github.com/cloudreve/Cloudreve/v3/pkg/mocks/requestmock"
|
||||
"github.com/cloudreve/Cloudreve/v3/pkg/request"
|
||||
"github.com/jinzhu/gorm"
|
||||
"github.com/stretchr/testify/assert"
|
||||
testMock "github.com/stretchr/testify/mock"
|
||||
"net/url"
|
||||
"testing"
|
||||
)
|
||||
|
||||
var mock sqlmock.Sqlmock
|
||||
|
||||
// TestMain 初始化数据库Mock
|
||||
func TestMain(m *testing.M) {
|
||||
var db *sql.DB
|
||||
var err error
|
||||
db, mock, err = sqlmock.New()
|
||||
if err != nil {
|
||||
panic("An error was not expected when opening a stub database connection")
|
||||
}
|
||||
model.DB, _ = gorm.Open("mysql", db)
|
||||
defer db.Close()
|
||||
m.Run()
|
||||
}
|
||||
|
||||
func TestNewSession(t *testing.T) {
|
||||
a := assert.New(t)
|
||||
endpoint, _ := url.Parse("http://localhost:8001/hosting/discovery")
|
||||
client := &client{
|
||||
cache: cache.NewMemoStore(),
|
||||
config: config{
|
||||
discoveryEndpoint: endpoint,
|
||||
},
|
||||
}
|
||||
|
||||
// Discovery failed
|
||||
{
|
||||
expectedErr := errors.New("error")
|
||||
mockHttp := &requestmock.RequestMock{}
|
||||
client.http = mockHttp
|
||||
mockHttp.On(
|
||||
"Request",
|
||||
"GET",
|
||||
endpoint.String(),
|
||||
testMock.Anything,
|
||||
testMock.Anything,
|
||||
).Return(&request.Response{
|
||||
Err: expectedErr,
|
||||
})
|
||||
res, err := client.NewSession(0, &model.File{}, ActionPreview)
|
||||
a.Nil(res)
|
||||
a.ErrorIs(err, expectedErr)
|
||||
mockHttp.AssertExpectations(t)
|
||||
}
|
||||
|
||||
// not supported ext
|
||||
{
|
||||
client.discovery = &WopiDiscovery{}
|
||||
client.actions = make(map[string]map[string]Action)
|
||||
res, err := client.NewSession(0, &model.File{}, ActionPreview)
|
||||
a.Nil(res)
|
||||
a.ErrorIs(err, ErrActionNotSupported)
|
||||
}
|
||||
|
||||
// preferred action not supported
|
||||
{
|
||||
client.discovery = &WopiDiscovery{}
|
||||
client.actions = map[string]map[string]Action{
|
||||
".doc": {},
|
||||
}
|
||||
res, err := client.NewSession(0, &model.File{Name: "1.doc"}, ActionPreview)
|
||||
a.Nil(res)
|
||||
a.ErrorIs(err, ErrActionNotSupported)
|
||||
}
|
||||
|
||||
// src url cannot be parsed
|
||||
{
|
||||
client.discovery = &WopiDiscovery{}
|
||||
client.actions = map[string]map[string]Action{
|
||||
".doc": {
|
||||
string(ActionPreviewFallback): Action{
|
||||
Urlsrc: string([]byte{0x7f}),
|
||||
},
|
||||
},
|
||||
}
|
||||
res, err := client.NewSession(0, &model.File{Name: "1.doc"}, ActionEdit)
|
||||
a.Nil(res)
|
||||
a.ErrorContains(err, "invalid control character in URL")
|
||||
}
|
||||
|
||||
// all pass - default placeholder
|
||||
{
|
||||
client.discovery = &WopiDiscovery{}
|
||||
client.actions = map[string]map[string]Action{
|
||||
".doc": {
|
||||
string(ActionPreviewFallback): Action{
|
||||
Urlsrc: "https://doc.com/doc",
|
||||
},
|
||||
},
|
||||
}
|
||||
res, err := client.NewSession(0, &model.File{Name: "1.doc"}, ActionEdit)
|
||||
a.NotNil(res)
|
||||
a.NoError(err)
|
||||
resUrl := res.ActionURL.String()
|
||||
a.Contains(resUrl, wopiSrcParamDefault)
|
||||
}
|
||||
|
||||
// all pass - with placeholders
|
||||
{
|
||||
client.discovery = &WopiDiscovery{}
|
||||
client.actions = map[string]map[string]Action{
|
||||
".doc": {
|
||||
string(ActionPreviewFallback): Action{
|
||||
Urlsrc: "https://doc.com/doc?origin=preserved&<dc=DC_LLCC&><notsuported=DISABLE_ASYNC&><src=WOPI_SOURCE&>",
|
||||
},
|
||||
},
|
||||
}
|
||||
res, err := client.NewSession(0, &model.File{Name: "1.doc"}, ActionEdit)
|
||||
a.NotNil(res)
|
||||
a.NoError(err)
|
||||
resUrl := res.ActionURL.String()
|
||||
a.Contains(resUrl, "origin=preserved")
|
||||
a.Contains(resUrl, "dc=lng")
|
||||
a.Contains(resUrl, "src=")
|
||||
a.NotContains(resUrl, "notsuported")
|
||||
}
|
||||
|
||||
// cache operation failed
|
||||
{
|
||||
mockCache := &cachemock.CacheClientMock{}
|
||||
expectedErr := errors.New("error")
|
||||
client.cache = mockCache
|
||||
client.discovery = &WopiDiscovery{}
|
||||
client.actions = map[string]map[string]Action{
|
||||
".doc": {
|
||||
string(ActionPreviewFallback): Action{
|
||||
Urlsrc: "https://doc.com/doc",
|
||||
},
|
||||
},
|
||||
}
|
||||
mockCache.On("Set", testMock.Anything, testMock.Anything, testMock.Anything).Return(expectedErr)
|
||||
res, err := client.NewSession(0, &model.File{Name: "1.doc"}, ActionEdit)
|
||||
a.Nil(res)
|
||||
a.ErrorIs(err, expectedErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInit(t *testing.T) {
|
||||
a := assert.New(t)
|
||||
|
||||
// not enabled
|
||||
{
|
||||
a.Nil(Default)
|
||||
Default = &client{}
|
||||
Init()
|
||||
a.Nil(Default)
|
||||
}
|
||||
|
||||
// throw error
|
||||
{
|
||||
a.Nil(Default)
|
||||
cache.Set("setting_wopi_enabled", "1", 0)
|
||||
cache.Set("setting_wopi_endpoint", string([]byte{0x7f}), 0)
|
||||
Init()
|
||||
a.Nil(Default)
|
||||
}
|
||||
|
||||
// all pass
|
||||
{
|
||||
a.Nil(Default)
|
||||
cache.Set("setting_wopi_enabled", "1", 0)
|
||||
cache.Set("setting_wopi_endpoint", "", 0)
|
||||
Init()
|
||||
a.NotNil(Default)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user