feat(wopi): implement required rest api as a WOPI host

pull/1626/head
HFO4 1 year ago
parent 4541400755
commit 1c922ac981

@ -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()
}
}

@ -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"},
}

@ -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
}

@ -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 {

@ -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{})
}

@ -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
}

@ -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))

@ -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
}
}

@ -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())

@ -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,
}
}

@ -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
}

@ -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 列出分享的目录下的对象

Loading…
Cancel
Save