diff --git a/middleware/wopi.go b/middleware/wopi.go new file mode 100644 index 0000000..41b8c01 --- /dev/null +++ b/middleware/wopi.go @@ -0,0 +1,70 @@ +package middleware + +import ( + model "github.com/cloudreve/Cloudreve/v3/models" + "github.com/cloudreve/Cloudreve/v3/pkg/cache" + "github.com/cloudreve/Cloudreve/v3/pkg/wopi" + "github.com/gin-gonic/gin" + "net/http" + "strings" +) + +const ( + WopiSessionCtx = "wopi_session" +) + +// WopiWriteAccess validates if write access is obtained. +func WopiWriteAccess() gin.HandlerFunc { + return func(c *gin.Context) { + session := c.MustGet(WopiSessionCtx).(*wopi.SessionCache) + if session.Action != wopi.ActionEdit { + c.Status(http.StatusNotFound) + c.Header(wopi.ServerErrorHeader, "read-only access") + c.Abort() + return + } + + c.Next() + } +} + +func WopiAccessValidation(w wopi.Client, store cache.Driver) gin.HandlerFunc { + return func(c *gin.Context) { + accessToken := strings.Split(c.Query(wopi.AccessTokenQuery), ".") + if len(accessToken) != 2 { + c.Status(http.StatusForbidden) + c.Header(wopi.ServerErrorHeader, "malformed access token") + c.Abort() + return + } + + sessionRaw, exist := store.Get(wopi.SessionCachePrefix + accessToken[0]) + if !exist { + c.Status(http.StatusForbidden) + c.Header(wopi.ServerErrorHeader, "invalid access token") + c.Abort() + return + } + + session := sessionRaw.(wopi.SessionCache) + user, err := model.GetActiveUserByID(session.UserID) + if err != nil { + c.Status(http.StatusInternalServerError) + c.Header(wopi.ServerErrorHeader, "user not found") + c.Abort() + return + } + + fileID := c.MustGet("object_id").(uint) + if fileID != session.FileID { + c.Status(http.StatusInternalServerError) + c.Header(wopi.ServerErrorHeader, "file not found") + c.Abort() + return + } + + c.Set("user", &user) + c.Set(WopiSessionCtx, &session) + c.Next() + } +} diff --git a/models/defaults.go b/models/defaults.go index 7246980..6cffbb4 100644 --- a/models/defaults.go +++ b/models/defaults.go @@ -25,7 +25,7 @@ var defaultSettings = []Setting{ {Name: "smtpUser", Value: `no-reply@acg.blue`, Type: "mail"}, {Name: "smtpPass", Value: ``, Type: "mail"}, {Name: "smtpEncryption", Value: `0`, Type: "mail"}, - {Name: "maxEditSize", Value: `4194304`, Type: "file_edit"}, + {Name: "maxEditSize", Value: `52428800`, Type: "file_edit"}, {Name: "archive_timeout", Value: `600`, Type: "timeout"}, {Name: "download_timeout", Value: `600`, Type: "timeout"}, {Name: "preview_timeout", Value: `600`, Type: "timeout"}, @@ -118,5 +118,5 @@ Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; verti {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"}, + {Name: "wopi_session_timeout", Value: "36000", Type: "wopi"}, } diff --git a/pkg/serializer/explorer.go b/pkg/serializer/explorer.go index afa5ac4..4f94b09 100644 --- a/pkg/serializer/explorer.go +++ b/pkg/serializer/explorer.go @@ -84,3 +84,47 @@ type Sources struct { Parent uint `json:"parent"` Error string `json:"error,omitempty"` } + +// DocPreviewSession 文档预览会话响应 +type DocPreviewSession struct { + URL string `json:"url"` + AccessToken string `json:"access_token,omitempty"` + AccessTokenTTL int64 `json:"access_token_ttl,omitempty"` +} + +// WopiFileInfo Response for `CheckFileInfo` +type WopiFileInfo struct { + // Required + BaseFileName string + Version string + + // Breadcrumb + BreadcrumbBrandName string + BreadcrumbBrandUrl string + BreadcrumbFolderName string + BreadcrumbFolderUrl string + + // Post Message + FileSharingPostMessage bool + ClosePostMessage bool + PostMessageOrigin string + + // Other miscellaneous properties + FileNameMaxLength int + LastModifiedTime string + + // User metadata + IsAnonymousUser bool + UserFriendlyName string + UserId string + + // Permission + ReadOnly bool + UserCanRename bool + UserCanReview bool + UserCanWrite bool + + SupportsRename bool + SupportsReviewing bool + SupportsUpdate bool +} diff --git a/pkg/wopi/discovery.go b/pkg/wopi/discovery.go index 8f53732..20467b1 100644 --- a/pkg/wopi/discovery.go +++ b/pkg/wopi/discovery.go @@ -27,7 +27,7 @@ func (c *client) AvailableExts() []string { return nil } - c.mu.RUnlock() + c.mu.RLock() defer c.mu.RUnlock() exts := make([]string, 0, len(c.actions)) for ext, actions := range c.actions { diff --git a/pkg/wopi/types.go b/pkg/wopi/types.go index 69cf239..a9425f4 100644 --- a/pkg/wopi/types.go +++ b/pkg/wopi/types.go @@ -3,6 +3,7 @@ package wopi import ( "encoding/gob" "encoding/xml" + "net/url" ) // Response content from discovery endpoint. @@ -49,10 +50,21 @@ type Action struct { Newext string `xml:"newext,attr"` } +type Session struct { + AccessToken string + AccessTokenTTL int64 + ActionURL *url.URL +} + +type SessionCache struct { + AccessToken string + FileID uint + UserID uint + Action ActonType +} + func init() { gob.Register(WopiDiscovery{}) gob.Register(Action{}) -} - -type Session struct { + gob.Register(SessionCache{}) } diff --git a/pkg/wopi/wopi.go b/pkg/wopi/wopi.go index ba0211a..6120cf3 100644 --- a/pkg/wopi/wopi.go +++ b/pkg/wopi/wopi.go @@ -5,12 +5,15 @@ import ( "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 { @@ -43,7 +46,21 @@ var ( ) const ( - wopiSrcPlaceholder = "WOPI_SOURCE" + 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" + sessionExpiresPadding = 10 + wopiHeaderPrefix = "X-WOPI-" ) // Init initializes a new global WOPI client. @@ -53,6 +70,7 @@ func Init() { 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) @@ -99,6 +117,9 @@ func (c *client) NewSession(user *model.User, file *model.File, action ActonType return nil, err } + c.mu.RLock() + defer c.mu.RUnlock() + ext := path.Ext(file.Name) availableActions, ok := c.actions[ext] if !ok { @@ -107,17 +128,46 @@ func (c *client) NewSession(user *model.User, file *model.File, action ActonType actionConfig, ok := availableActions[string(action)] if !ok { - return nil, ErrActionNotSupported + // Preferred action not available, fallback to view only action + if actionConfig, ok = availableActions[string(ActionPreview)]; !ok { + 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, "") + actionUrl, err := generateActionUrl(actionConfig.Urlsrc, baseURL.ResolveReference(linkPath).String()) if err != nil { return nil, err } - fmt.Println(actionUrl) + // 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: user.ID, + Action: action, + } + err = cache.Set(SessionCachePrefix+sessionID.String(), *session, ttl) + if err != nil { + return nil, fmt.Errorf("failed to create document session: %s", err) + } - return nil, nil + 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 @@ -131,6 +181,7 @@ func generateActionUrl(src string, fileSrc string) (*url.URL, error) { } queries := actionUrl.Query() + srcReplaced := false queryReplaced := url.Values{} for k := range queries { if placeholder, ok := queryPlaceholders[queries.Get(k)]; ok { @@ -143,12 +194,17 @@ func generateActionUrl(src string, fileSrc string) (*url.URL, error) { if queries.Get(k) == wopiSrcPlaceholder { queryReplaced.Set(k, fileSrc) + srcReplaced = true continue } queryReplaced.Set(k, queries.Get(k)) } + if !srcReplaced { + queryReplaced.Set(wopiSrcParamDefault, fileSrc) + } + actionUrl.RawQuery = queryReplaced.Encode() return actionUrl, nil } diff --git a/routers/controllers/file.go b/routers/controllers/file.go index 8caadc2..0e7c206 100644 --- a/routers/controllers/file.go +++ b/routers/controllers/file.go @@ -236,7 +236,7 @@ func GetDocPreview(c *gin.Context) { var service explorer.FileIDService if err := c.ShouldBindUri(&service); err == nil { - res := service.CreateDocPreviewSession(ctx, c) + res := service.CreateDocPreviewSession(ctx, c, true) c.JSON(200, res) } else { c.JSON(200, ErrorResponse(err)) diff --git a/routers/controllers/wopi.go b/routers/controllers/wopi.go new file mode 100644 index 0000000..23eea15 --- /dev/null +++ b/routers/controllers/wopi.go @@ -0,0 +1,77 @@ +package controllers + +import ( + "context" + "github.com/cloudreve/Cloudreve/v3/pkg/serializer" + "github.com/cloudreve/Cloudreve/v3/pkg/wopi" + "github.com/cloudreve/Cloudreve/v3/service/explorer" + "github.com/gin-gonic/gin" + "net/http" +) + +// CheckFileInfo Get file info +func CheckFileInfo(c *gin.Context) { + var service explorer.WopiService + res, err := service.FileInfo(c) + if err != nil { + c.Status(http.StatusInternalServerError) + c.Header(wopi.ServerErrorHeader, err.Error()) + return + } + + c.JSON(200, res) +} + +// GetFile Get file content +func GetFile(c *gin.Context) { + var service explorer.WopiService + err := service.GetFile(c) + if err != nil { + c.Status(http.StatusInternalServerError) + c.Header(wopi.ServerErrorHeader, err.Error()) + return + } +} + +// PutFile Puts file content +func PutFile(c *gin.Context) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + service := &explorer.FileIDService{} + res := service.PutContent(ctx, c) + switch res.Code { + case serializer.CodeFileTooLarge: + c.Status(http.StatusRequestEntityTooLarge) + c.Header(wopi.ServerErrorHeader, res.Error) + case serializer.CodeNotFound: + c.Status(http.StatusNotFound) + c.Header(wopi.ServerErrorHeader, res.Error) + case 0: + c.Status(http.StatusOK) + default: + c.Status(http.StatusInternalServerError) + c.Header(wopi.ServerErrorHeader, res.Error) + } +} + +// ModifyFile Modify file properties +func ModifyFile(c *gin.Context) { + action := c.GetHeader(wopi.OverwriteHeader) + switch action { + case wopi.MethodLock, wopi.MethodRefreshLock, wopi.MethodUnlock: + c.Status(http.StatusOK) + return + case wopi.MethodRename: + var service explorer.WopiService + err := service.Rename(c) + if err != nil { + c.Status(http.StatusInternalServerError) + c.Header(wopi.ServerErrorHeader, err.Error()) + return + } + default: + c.Status(http.StatusNotImplemented) + return + } +} diff --git a/routers/router.go b/routers/router.go index e181dc8..53b1683 100644 --- a/routers/router.go +++ b/routers/router.go @@ -3,10 +3,12 @@ package routers import ( "github.com/cloudreve/Cloudreve/v3/middleware" "github.com/cloudreve/Cloudreve/v3/pkg/auth" + "github.com/cloudreve/Cloudreve/v3/pkg/cache" "github.com/cloudreve/Cloudreve/v3/pkg/cluster" "github.com/cloudreve/Cloudreve/v3/pkg/conf" "github.com/cloudreve/Cloudreve/v3/pkg/hashid" "github.com/cloudreve/Cloudreve/v3/pkg/util" + wopi2 "github.com/cloudreve/Cloudreve/v3/pkg/wopi" "github.com/cloudreve/Cloudreve/v3/routers/controllers" "github.com/gin-contrib/cors" "github.com/gin-contrib/gzip" @@ -385,6 +387,22 @@ func InitMasterRouter() *gin.Engine { v3.Group("share").GET("search", controllers.SearchShare) } + wopi := v3.Group( + "wopi", + middleware.HashID(hashid.FileID), + middleware.WopiAccessValidation(wopi2.Default, cache.Store), + ) + { + // 获取文件信息 + wopi.GET("files/:id", controllers.CheckFileInfo) + // 获取文件内容 + wopi.GET("files/:id/contents", controllers.GetFile) + // 更新文件内容 + wopi.POST("files/:id/contents", middleware.WopiWriteAccess(), controllers.PutFile) + // 通用文件操作 + wopi.POST("files/:id", middleware.WopiWriteAccess(), controllers.ModifyFile) + } + // 需要登录保护的 auth := v3.Group("") auth.Use(middleware.AuthRequired()) diff --git a/service/explorer/file.go b/service/explorer/file.go index b6d5f58..6826d22 100644 --- a/service/explorer/file.go +++ b/service/explorer/file.go @@ -18,6 +18,7 @@ import ( "github.com/cloudreve/Cloudreve/v3/pkg/filesystem" "github.com/cloudreve/Cloudreve/v3/pkg/filesystem/fsctx" "github.com/cloudreve/Cloudreve/v3/pkg/serializer" + "github.com/cloudreve/Cloudreve/v3/pkg/wopi" "github.com/gin-gonic/gin" ) @@ -192,7 +193,7 @@ func (service *FileAnonymousGetService) Source(ctx context.Context, c *gin.Conte } // CreateDocPreviewSession 创建DOC文件预览会话,返回预览地址 -func (service *FileIDService) CreateDocPreviewSession(ctx context.Context, c *gin.Context) serializer.Response { +func (service *FileIDService) CreateDocPreviewSession(ctx context.Context, c *gin.Context, editable bool) serializer.Response { // 创建文件系统 fs, err := filesystem.NewFileSystemFromContext(c) if err != nil { @@ -226,18 +227,47 @@ func (service *FileIDService) CreateDocPreviewSession(ctx context.Context, c *gi return serializer.Err(serializer.CodeNotSet, err.Error(), err) } + var resp serializer.DocPreviewSession + + // Use WOPI preview if available + if model.IsTrueVal(model.GetSettingByName("wopi_enabled")) && wopi.Default != nil { + maxSize := model.GetIntSetting("maxEditSize", 0) + if maxSize > 0 && fs.FileTarget[0].Size > uint64(maxSize) { + return serializer.Err(serializer.CodeFileTooLarge, "", nil) + } + + action := wopi.ActionPreview + if editable { + action = wopi.ActionEdit + } + + session, err := wopi.Default.NewSession(fs.User, &fs.FileTarget[0], action) + if err != nil { + return serializer.Err(serializer.CodeInternalSetting, "Failed to create WOPI session", err) + } + + resp.URL = session.ActionURL.String() + resp.AccessTokenTTL = session.AccessTokenTTL + resp.AccessToken = session.AccessToken + return serializer.Response{ + Code: 0, + Data: resp, + } + } + // 生成最终的预览器地址 srcB64 := base64.StdEncoding.EncodeToString([]byte(downloadURL)) srcEncoded := url.QueryEscape(downloadURL) srcB64Encoded := url.QueryEscape(srcB64) + resp.URL = util.Replace(map[string]string{ + "{$src}": srcEncoded, + "{$srcB64}": srcB64Encoded, + "{$name}": url.QueryEscape(fs.FileTarget[0].Name), + }, model.GetSettingByName("office_preview_service")) return serializer.Response{ Code: 0, - Data: util.Replace(map[string]string{ - "{$src}": srcEncoded, - "{$srcB64}": srcB64Encoded, - "{$name}": url.QueryEscape(fs.FileTarget[0].Name), - }, model.GetSettingByName("office_preview_service")), + Data: resp, } } diff --git a/service/explorer/wopi.go b/service/explorer/wopi.go new file mode 100644 index 0000000..d959b26 --- /dev/null +++ b/service/explorer/wopi.go @@ -0,0 +1,136 @@ +package explorer + +import ( + "errors" + "fmt" + "github.com/cloudreve/Cloudreve/v3/middleware" + model "github.com/cloudreve/Cloudreve/v3/models" + "github.com/cloudreve/Cloudreve/v3/pkg/filesystem" + "github.com/cloudreve/Cloudreve/v3/pkg/hashid" + "github.com/cloudreve/Cloudreve/v3/pkg/serializer" + "github.com/cloudreve/Cloudreve/v3/pkg/wopi" + "github.com/gin-gonic/gin" + "net/http" + "time" +) + +type WopiService struct { +} + +func (service *WopiService) Rename(c *gin.Context) error { + fs, _, err := service.prepareFs(c) + if err != nil { + return err + } + + defer fs.Recycle() + + return fs.Rename(c, []uint{}, []uint{c.MustGet("object_id").(uint)}, c.GetHeader(wopi.RenameRequestHeader)) +} + +func (service *WopiService) GetFile(c *gin.Context) error { + fs, _, err := service.prepareFs(c) + if err != nil { + return err + } + + defer fs.Recycle() + + resp, err := fs.Preview(c, fs.FileTarget[0].ID, true) + if err != nil { + return fmt.Errorf("failed to pull file content: %w", err) + } + + // 重定向到文件源 + if resp.Redirect { + return fmt.Errorf("redirect not supported in WOPI") + } + + // 直接返回文件内容 + defer resp.Content.Close() + + c.Header("Cache-Control", "no-cache") + http.ServeContent(c.Writer, c.Request, fs.FileTarget[0].Name, fs.FileTarget[0].UpdatedAt, resp.Content) + return nil +} + +func (service *WopiService) FileInfo(c *gin.Context) (*serializer.WopiFileInfo, error) { + fs, session, err := service.prepareFs(c) + if err != nil { + return nil, err + } + + defer fs.Recycle() + + parent, err := model.GetFoldersByIDs([]uint{fs.FileTarget[0].FolderID}, fs.User.ID) + if err != nil { + return nil, err + } + + if len(parent) == 0 { + return nil, fmt.Errorf("failed to find parent folder") + } + + parent[0].TraceRoot() + siteUrl := model.GetSiteURL() + + // Generate url for parent folder + parentUrl := model.GetSiteURL() + parentUrl.Path = "/home" + query := parentUrl.Query() + query.Set("path", parent[0].Position) + parentUrl.RawQuery = query.Encode() + + info := &serializer.WopiFileInfo{ + BaseFileName: fs.FileTarget[0].Name, + Version: fs.FileTarget[0].Model.UpdatedAt.String(), + BreadcrumbBrandName: model.GetSettingByName("siteName"), + BreadcrumbBrandUrl: siteUrl.String(), + FileSharingPostMessage: false, + PostMessageOrigin: "*", + FileNameMaxLength: 256, + LastModifiedTime: fs.FileTarget[0].Model.UpdatedAt.Format(time.RFC3339), + IsAnonymousUser: true, + ReadOnly: true, + ClosePostMessage: true, + } + + if session.Action == wopi.ActionEdit { + info.FileSharingPostMessage = true + info.IsAnonymousUser = false + info.SupportsRename = true + info.SupportsReviewing = true + info.SupportsUpdate = true + info.UserFriendlyName = fs.User.Nick + info.UserId = hashid.HashID(fs.User.ID, hashid.UserID) + info.UserCanRename = true + info.UserCanReview = true + info.UserCanWrite = true + info.ReadOnly = false + info.BreadcrumbFolderName = parent[0].Name + info.BreadcrumbFolderUrl = parentUrl.String() + } + + return info, nil +} + +func (service *WopiService) prepareFs(c *gin.Context) (*filesystem.FileSystem, *wopi.SessionCache, error) { + // 创建文件系统 + fs, err := filesystem.NewFileSystemFromContext(c) + if err != nil { + return nil, nil, err + } + + session := c.MustGet(middleware.WopiSessionCtx).(*wopi.SessionCache) + if err := fs.SetTargetFileByIDs([]uint{session.FileID}); err != nil { + fs.Recycle() + return nil, nil, fmt.Errorf("failed to find file: %w", err) + } + + maxSize := model.GetIntSetting("maxEditSize", 0) + if maxSize > 0 && fs.FileTarget[0].Size > uint64(maxSize) { + return nil, nil, errors.New("file too large") + } + + return fs, session, nil +} diff --git a/service/share/visit.go b/service/share/visit.go index 86627da..caad060 100644 --- a/service/share/visit.go +++ b/service/share/visit.go @@ -221,7 +221,7 @@ func (service *Service) CreateDocPreviewSession(c *gin.Context) serializer.Respo } subService := explorer.FileIDService{} - return subService.CreateDocPreviewSession(ctx, c) + return subService.CreateDocPreviewSession(ctx, c, false) } // List 列出分享的目录下的对象