diff --git a/bootstrap/init.go b/bootstrap/init.go index 0f93399..f959d5e 100644 --- a/bootstrap/init.go +++ b/bootstrap/init.go @@ -12,6 +12,7 @@ import ( "github.com/cloudreve/Cloudreve/v3/pkg/email" "github.com/cloudreve/Cloudreve/v3/pkg/mq" "github.com/cloudreve/Cloudreve/v3/pkg/task" + "github.com/cloudreve/Cloudreve/v3/pkg/wopi" "github.com/gin-gonic/gin" "io/fs" ) @@ -95,6 +96,12 @@ func Init(path string, statics fs.FS) { auth.Init() }, }, + { + "master", + func() { + wopi.Init() + }, + }, } for _, dependency := range dependencies { diff --git a/models/defaults.go b/models/defaults.go index 3090016..7246980 100644 --- a/models/defaults.go +++ b/models/defaults.go @@ -115,4 +115,8 @@ Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; verti {Name: "office_preview_service", Value: "https://view.officeapps.live.com/op/view.aspx?src={$src}", Type: "preview"}, {Name: "show_app_promotion", Value: "1", Type: "mobile"}, {Name: "public_resource_maxage", Value: "86400", Type: "timeout"}, + {Name: "wopi_enabled", Value: "0", Type: "wopi"}, + {Name: "wopi_endpoint", Value: "", Type: "wopi"}, + {Name: "wopi_max_size", Value: "52428800", Type: "wopi"}, + {Name: "wopi_session_timeout", Value: "43200", Type: "wopi"}, } diff --git a/pkg/serializer/setting.go b/pkg/serializer/setting.go index 2c1a345..7e4ce00 100644 --- a/pkg/serializer/setting.go +++ b/pkg/serializer/setting.go @@ -7,22 +7,23 @@ import ( // SiteConfig 站点全局设置序列 type SiteConfig struct { - SiteName string `json:"title"` - LoginCaptcha bool `json:"loginCaptcha"` - RegCaptcha bool `json:"regCaptcha"` - ForgetCaptcha bool `json:"forgetCaptcha"` - EmailActive bool `json:"emailActive"` - Themes string `json:"themes"` - DefaultTheme string `json:"defaultTheme"` - HomepageViewMethod string `json:"home_view_method"` - ShareViewMethod string `json:"share_view_method"` - Authn bool `json:"authn"` - User User `json:"user"` - ReCaptchaKey string `json:"captcha_ReCaptchaKey"` - CaptchaType string `json:"captcha_type"` - TCaptchaCaptchaAppId string `json:"tcaptcha_captcha_app_id"` - RegisterEnabled bool `json:"registerEnabled"` - AppPromotion bool `json:"app_promotion"` + SiteName string `json:"title"` + LoginCaptcha bool `json:"loginCaptcha"` + RegCaptcha bool `json:"regCaptcha"` + ForgetCaptcha bool `json:"forgetCaptcha"` + EmailActive bool `json:"emailActive"` + Themes string `json:"themes"` + DefaultTheme string `json:"defaultTheme"` + HomepageViewMethod string `json:"home_view_method"` + ShareViewMethod string `json:"share_view_method"` + Authn bool `json:"authn"` + User User `json:"user"` + ReCaptchaKey string `json:"captcha_ReCaptchaKey"` + CaptchaType string `json:"captcha_type"` + TCaptchaCaptchaAppId string `json:"tcaptcha_captcha_app_id"` + RegisterEnabled bool `json:"registerEnabled"` + AppPromotion bool `json:"app_promotion"` + WopiExts []string `json:"wopi_exts"` } type task struct { @@ -60,7 +61,7 @@ func checkSettingValue(setting map[string]string, key string) string { } // BuildSiteConfig 站点全局设置 -func BuildSiteConfig(settings map[string]string, user *model.User) Response { +func BuildSiteConfig(settings map[string]string, user *model.User, wopiExts []string) Response { var userRes User if user != nil { userRes = BuildUser(*user) @@ -85,6 +86,7 @@ func BuildSiteConfig(settings map[string]string, user *model.User) Response { TCaptchaCaptchaAppId: checkSettingValue(settings, "captcha_TCaptcha_CaptchaAppId"), RegisterEnabled: model.IsTrueVal(checkSettingValue(settings, "register_enabled")), AppPromotion: model.IsTrueVal(checkSettingValue(settings, "show_app_promotion")), + WopiExts: wopiExts, }} return res } diff --git a/pkg/serializer/setting_test.go b/pkg/serializer/setting_test.go index 04fb8f6..680edb6 100644 --- a/pkg/serializer/setting_test.go +++ b/pkg/serializer/setting_test.go @@ -18,10 +18,10 @@ func TestCheckSettingValue(t *testing.T) { func TestBuildSiteConfig(t *testing.T) { asserts := assert.New(t) - res := BuildSiteConfig(map[string]string{"not exist": ""}, &model.User{}) + res := BuildSiteConfig(map[string]string{"not exist": ""}, &model.User{}, nil) asserts.Equal("", res.Data.(SiteConfig).SiteName) - res = BuildSiteConfig(map[string]string{"siteName": "123"}, &model.User{}) + res = BuildSiteConfig(map[string]string{"siteName": "123"}, &model.User{}, nil) asserts.Equal("123", res.Data.(SiteConfig).SiteName) // 非空用户 @@ -29,7 +29,7 @@ func TestBuildSiteConfig(t *testing.T) { Model: gorm.Model{ ID: 5, }, - }) + }, nil) asserts.Len(res.Data.(SiteConfig).User.ID, 4) } diff --git a/pkg/wopi/discovery.go b/pkg/wopi/discovery.go index 21c0d92..8f53732 100644 --- a/pkg/wopi/discovery.go +++ b/pkg/wopi/discovery.go @@ -4,7 +4,9 @@ import ( "encoding/xml" "fmt" "github.com/cloudreve/Cloudreve/v3/pkg/cache" + "github.com/cloudreve/Cloudreve/v3/pkg/util" "net/http" + "strings" ) type ActonType string @@ -19,6 +21,27 @@ const ( 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 + } + + c.mu.RUnlock() + 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)] + + if previewable || editable { + exts = append(exts, strings.TrimPrefix(ext, ".")) + } + } + + return exts +} + // checkDiscovery checks if discovery content is needed to be refreshed. // If so, it will refresh discovery content. func (c *client) checkDiscovery() error { diff --git a/pkg/wopi/wopi.go b/pkg/wopi/wopi.go index 999dc56..ba0211a 100644 --- a/pkg/wopi/wopi.go +++ b/pkg/wopi/wopi.go @@ -6,6 +6,7 @@ import ( model "github.com/cloudreve/Cloudreve/v3/models" "github.com/cloudreve/Cloudreve/v3/pkg/cache" "github.com/cloudreve/Cloudreve/v3/pkg/request" + "github.com/cloudreve/Cloudreve/v3/pkg/util" "net/url" "path" "strings" @@ -13,11 +14,18 @@ import ( ) type Client interface { + // NewSession creates a new document session with access token. + NewSession(user *model.User, 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", @@ -38,6 +46,24 @@ const ( wopiSrcPlaceholder = "WOPI_SOURCE" ) +// Init initializes a new global WOPI client. +func Init() { + settings := model.GetSettingByNames("wopi_endpoint", "wopi_enabled") + if !model.IsTrueVal(settings["wopi_enabled"]) { + return + } + + 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 @@ -53,6 +79,21 @@ 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(user *model.User, file *model.File, action ActonType) (*Session, error) { if err := c.checkDiscovery(); err != nil { return nil, err @@ -79,6 +120,8 @@ func (c *client) NewSession(user *model.User, file *model.File, action ActonType return nil, 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, ">", "") diff --git a/routers/controllers/site.go b/routers/controllers/site.go index d462a9d..c4a3508 100644 --- a/routers/controllers/site.go +++ b/routers/controllers/site.go @@ -5,6 +5,7 @@ import ( "github.com/cloudreve/Cloudreve/v3/pkg/conf" "github.com/cloudreve/Cloudreve/v3/pkg/serializer" "github.com/cloudreve/Cloudreve/v3/pkg/util" + "github.com/cloudreve/Cloudreve/v3/pkg/wopi" "github.com/gin-gonic/gin" "github.com/mojocn/base64Captcha" ) @@ -30,14 +31,19 @@ func SiteConfig(c *gin.Context) { "show_app_promotion", ) + var wopiExts []string + if wopi.Default != nil { + wopiExts = wopi.Default.AvailableExts() + } + // 如果已登录,则同时返回用户信息和标签 user, _ := c.Get("user") if user, ok := user.(*model.User); ok { - c.JSON(200, serializer.BuildSiteConfig(siteConfig, user)) + c.JSON(200, serializer.BuildSiteConfig(siteConfig, user, wopiExts)) return } - c.JSON(200, serializer.BuildSiteConfig(siteConfig, nil)) + c.JSON(200, serializer.BuildSiteConfig(siteConfig, nil, wopiExts)) } // Ping 状态检查页面