mirror of https://github.com/cloudreve/Cloudreve
feat(wopi): implement required rest api as a WOPI host
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: "smtpUser", Value: `no-reply@acg.blue`, Type: "mail"},
|
||||||
{Name: "smtpPass", Value: ``, Type: "mail"},
|
{Name: "smtpPass", Value: ``, Type: "mail"},
|
||||||
{Name: "smtpEncryption", Value: `0`, 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: "archive_timeout", Value: `600`, Type: "timeout"},
|
||||||
{Name: "download_timeout", Value: `600`, Type: "timeout"},
|
{Name: "download_timeout", Value: `600`, Type: "timeout"},
|
||||||
{Name: "preview_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_enabled", Value: "0", Type: "wopi"},
|
||||||
{Name: "wopi_endpoint", Value: "", Type: "wopi"},
|
{Name: "wopi_endpoint", Value: "", Type: "wopi"},
|
||||||
{Name: "wopi_max_size", Value: "52428800", 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"`
|
Parent uint `json:"parent"`
|
||||||
Error string `json:"error,omitempty"`
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
c.mu.RUnlock()
|
c.mu.RLock()
|
||||||
defer c.mu.RUnlock()
|
defer c.mu.RUnlock()
|
||||||
exts := make([]string, 0, len(c.actions))
|
exts := make([]string, 0, len(c.actions))
|
||||||
for ext, actions := range c.actions {
|
for ext, actions := range c.actions {
|
||||||
|
|
|
@ -3,6 +3,7 @@ package wopi
|
||||||
import (
|
import (
|
||||||
"encoding/gob"
|
"encoding/gob"
|
||||||
"encoding/xml"
|
"encoding/xml"
|
||||||
|
"net/url"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Response content from discovery endpoint.
|
// Response content from discovery endpoint.
|
||||||
|
@ -49,10 +50,21 @@ type Action struct {
|
||||||
Newext string `xml:"newext,attr"`
|
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() {
|
func init() {
|
||||||
gob.Register(WopiDiscovery{})
|
gob.Register(WopiDiscovery{})
|
||||||
gob.Register(Action{})
|
gob.Register(Action{})
|
||||||
}
|
gob.Register(SessionCache{})
|
||||||
|
|
||||||
type Session struct {
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,12 +5,15 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
model "github.com/cloudreve/Cloudreve/v3/models"
|
model "github.com/cloudreve/Cloudreve/v3/models"
|
||||||
"github.com/cloudreve/Cloudreve/v3/pkg/cache"
|
"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/request"
|
||||||
"github.com/cloudreve/Cloudreve/v3/pkg/util"
|
"github.com/cloudreve/Cloudreve/v3/pkg/util"
|
||||||
|
"github.com/gofrs/uuid"
|
||||||
"net/url"
|
"net/url"
|
||||||
"path"
|
"path"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Client interface {
|
type Client interface {
|
||||||
|
@ -43,7 +46,21 @@ var (
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
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.
|
// Init initializes a new global WOPI client.
|
||||||
|
@ -53,6 +70,7 @@ func Init() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cache.Deletes([]string{DiscoverResponseCacheKey}, "")
|
||||||
wopiClient, err := NewClient(settings["wopi_endpoint"], cache.Store, request.NewClient())
|
wopiClient, err := NewClient(settings["wopi_endpoint"], cache.Store, request.NewClient())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
util.Log().Error("Failed to initialize WOPI client: %s", err)
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
c.mu.RLock()
|
||||||
|
defer c.mu.RUnlock()
|
||||||
|
|
||||||
ext := path.Ext(file.Name)
|
ext := path.Ext(file.Name)
|
||||||
availableActions, ok := c.actions[ext]
|
availableActions, ok := c.actions[ext]
|
||||||
if !ok {
|
if !ok {
|
||||||
|
@ -107,17 +128,46 @@ func (c *client) NewSession(user *model.User, file *model.File, action ActonType
|
||||||
|
|
||||||
actionConfig, ok := availableActions[string(action)]
|
actionConfig, ok := availableActions[string(action)]
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, ErrActionNotSupported
|
// Preferred action not available, fallback to view only action
|
||||||
|
if actionConfig, ok = availableActions[string(ActionPreview)]; !ok {
|
||||||
|
return nil, ErrActionNotSupported
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
actionUrl, err := generateActionUrl(actionConfig.Urlsrc, "")
|
// 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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Println(actionUrl)
|
actionUrl, err := generateActionUrl(actionConfig.Urlsrc, baseURL.ResolveReference(linkPath).String())
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
return nil, nil
|
// 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
// 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()
|
queries := actionUrl.Query()
|
||||||
|
srcReplaced := false
|
||||||
queryReplaced := url.Values{}
|
queryReplaced := url.Values{}
|
||||||
for k := range queries {
|
for k := range queries {
|
||||||
if placeholder, ok := queryPlaceholders[queries.Get(k)]; ok {
|
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 {
|
if queries.Get(k) == wopiSrcPlaceholder {
|
||||||
queryReplaced.Set(k, fileSrc)
|
queryReplaced.Set(k, fileSrc)
|
||||||
|
srcReplaced = true
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
queryReplaced.Set(k, queries.Get(k))
|
queryReplaced.Set(k, queries.Get(k))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !srcReplaced {
|
||||||
|
queryReplaced.Set(wopiSrcParamDefault, fileSrc)
|
||||||
|
}
|
||||||
|
|
||||||
actionUrl.RawQuery = queryReplaced.Encode()
|
actionUrl.RawQuery = queryReplaced.Encode()
|
||||||
return actionUrl, nil
|
return actionUrl, nil
|
||||||
}
|
}
|
||||||
|
|
|
@ -236,7 +236,7 @@ func GetDocPreview(c *gin.Context) {
|
||||||
|
|
||||||
var service explorer.FileIDService
|
var service explorer.FileIDService
|
||||||
if err := c.ShouldBindUri(&service); err == nil {
|
if err := c.ShouldBindUri(&service); err == nil {
|
||||||
res := service.CreateDocPreviewSession(ctx, c)
|
res := service.CreateDocPreviewSession(ctx, c, true)
|
||||||
c.JSON(200, res)
|
c.JSON(200, res)
|
||||||
} else {
|
} else {
|
||||||
c.JSON(200, ErrorResponse(err))
|
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 (
|
import (
|
||||||
"github.com/cloudreve/Cloudreve/v3/middleware"
|
"github.com/cloudreve/Cloudreve/v3/middleware"
|
||||||
"github.com/cloudreve/Cloudreve/v3/pkg/auth"
|
"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/cluster"
|
||||||
"github.com/cloudreve/Cloudreve/v3/pkg/conf"
|
"github.com/cloudreve/Cloudreve/v3/pkg/conf"
|
||||||
"github.com/cloudreve/Cloudreve/v3/pkg/hashid"
|
"github.com/cloudreve/Cloudreve/v3/pkg/hashid"
|
||||||
"github.com/cloudreve/Cloudreve/v3/pkg/util"
|
"github.com/cloudreve/Cloudreve/v3/pkg/util"
|
||||||
|
wopi2 "github.com/cloudreve/Cloudreve/v3/pkg/wopi"
|
||||||
"github.com/cloudreve/Cloudreve/v3/routers/controllers"
|
"github.com/cloudreve/Cloudreve/v3/routers/controllers"
|
||||||
"github.com/gin-contrib/cors"
|
"github.com/gin-contrib/cors"
|
||||||
"github.com/gin-contrib/gzip"
|
"github.com/gin-contrib/gzip"
|
||||||
|
@ -385,6 +387,22 @@ func InitMasterRouter() *gin.Engine {
|
||||||
v3.Group("share").GET("search", controllers.SearchShare)
|
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 := v3.Group("")
|
||||||
auth.Use(middleware.AuthRequired())
|
auth.Use(middleware.AuthRequired())
|
||||||
|
|
|
@ -18,6 +18,7 @@ import (
|
||||||
"github.com/cloudreve/Cloudreve/v3/pkg/filesystem"
|
"github.com/cloudreve/Cloudreve/v3/pkg/filesystem"
|
||||||
"github.com/cloudreve/Cloudreve/v3/pkg/filesystem/fsctx"
|
"github.com/cloudreve/Cloudreve/v3/pkg/filesystem/fsctx"
|
||||||
"github.com/cloudreve/Cloudreve/v3/pkg/serializer"
|
"github.com/cloudreve/Cloudreve/v3/pkg/serializer"
|
||||||
|
"github.com/cloudreve/Cloudreve/v3/pkg/wopi"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -192,7 +193,7 @@ func (service *FileAnonymousGetService) Source(ctx context.Context, c *gin.Conte
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateDocPreviewSession 创建DOC文件预览会话,返回预览地址
|
// 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)
|
fs, err := filesystem.NewFileSystemFromContext(c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -226,18 +227,47 @@ func (service *FileIDService) CreateDocPreviewSession(ctx context.Context, c *gi
|
||||||
return serializer.Err(serializer.CodeNotSet, err.Error(), err)
|
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))
|
srcB64 := base64.StdEncoding.EncodeToString([]byte(downloadURL))
|
||||||
srcEncoded := url.QueryEscape(downloadURL)
|
srcEncoded := url.QueryEscape(downloadURL)
|
||||||
srcB64Encoded := url.QueryEscape(srcB64)
|
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{
|
return serializer.Response{
|
||||||
Code: 0,
|
Code: 0,
|
||||||
Data: util.Replace(map[string]string{
|
Data: resp,
|
||||||
"{$src}": srcEncoded,
|
|
||||||
"{$srcB64}": srcB64Encoded,
|
|
||||||
"{$name}": url.QueryEscape(fs.FileTarget[0].Name),
|
|
||||||
}, model.GetSettingByName("office_preview_service")),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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{}
|
subService := explorer.FileIDService{}
|
||||||
|
|
||||||
return subService.CreateDocPreviewSession(ctx, c)
|
return subService.CreateDocPreviewSession(ctx, c, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
// List 列出分享的目录下的对象
|
// List 列出分享的目录下的对象
|
||||||
|
|
Loading…
Reference in New Issue