chore: 初始化 Cloudreve 源码仓库
This commit is contained in:
@@ -0,0 +1,118 @@
|
||||
package explorer
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/cloudreve/Cloudreve/v4/application/dependency"
|
||||
"github.com/cloudreve/Cloudreve/v4/inventory"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/cluster/routes"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/filemanager/fs"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/filemanager/manager"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/filemanager/manager/entitysource"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/hashid"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/serializer"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type (
|
||||
EntityDownloadParameterCtx struct{}
|
||||
EntityDownloadService struct {
|
||||
Name string `uri:"name" binding:"required"`
|
||||
SpeedLimit int64 `uri:"speed"`
|
||||
Src string `uri:"src"`
|
||||
}
|
||||
)
|
||||
|
||||
// Serve serves file content
|
||||
func (s *EntityDownloadService) Serve(c *gin.Context) error {
|
||||
dep := dependency.FromContext(c)
|
||||
user := inventory.UserFromContext(c)
|
||||
m := manager.NewFileManager(dep, user)
|
||||
defer m.Recycle()
|
||||
|
||||
entitySource, err := m.GetEntitySource(c, hashid.FromContext(c))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get entity source: %w", err)
|
||||
}
|
||||
|
||||
defer entitySource.Close()
|
||||
|
||||
// Set cache header for public resource
|
||||
settings := dep.SettingProvider()
|
||||
maxAge := settings.PublicResourceMaxAge(c)
|
||||
c.Header("Cache-Control", fmt.Sprintf("public, max-age=%d", maxAge))
|
||||
|
||||
isDownload := c.Query(routes.IsDownloadQuery) != ""
|
||||
isThumb := c.Query(routes.IsThumbQuery) != ""
|
||||
entitySource.Serve(c.Writer, c.Request,
|
||||
entitysource.WithSpeedLimit(s.SpeedLimit),
|
||||
entitysource.WithDownload(isDownload),
|
||||
entitysource.WithDisplayName(s.Name),
|
||||
entitysource.WithContext(c),
|
||||
entitysource.WithThumb(isThumb),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
type (
|
||||
SetCurrentVersionParamCtx struct{}
|
||||
SetCurrentVersionService struct {
|
||||
Uri string `uri:"uri" binding:"required"`
|
||||
Version string `uri:"version" binding:"required"`
|
||||
}
|
||||
)
|
||||
|
||||
// Set sets the current version of the file
|
||||
func (s *SetCurrentVersionService) Set(c *gin.Context) error {
|
||||
dep := dependency.FromContext(c)
|
||||
user := inventory.UserFromContext(c)
|
||||
m := manager.NewFileManager(dep, user)
|
||||
defer m.Recycle()
|
||||
|
||||
uri, err := fs.NewUriFromString(s.Uri)
|
||||
if err != nil {
|
||||
return serializer.NewError(serializer.CodeParamErr, "unknown uri", err)
|
||||
}
|
||||
|
||||
versionId, err := dep.HashIDEncoder().Decode(s.Version, hashid.EntityID)
|
||||
if err != nil {
|
||||
return serializer.NewError(serializer.CodeParamErr, "unknown version id", err)
|
||||
}
|
||||
|
||||
if err := m.SetCurrentVersion(c, uri, versionId); err != nil {
|
||||
return fmt.Errorf("failed to set current version: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type (
|
||||
DeleteVersionParamCtx struct{}
|
||||
DeleteVersionService struct {
|
||||
Uri string `uri:"uri" binding:"required"`
|
||||
Version string `uri:"version" binding:"required"`
|
||||
}
|
||||
)
|
||||
|
||||
// Delete deletes the version of the file
|
||||
func (s *DeleteVersionService) Delete(c *gin.Context) error {
|
||||
dep := dependency.FromContext(c)
|
||||
user := inventory.UserFromContext(c)
|
||||
m := manager.NewFileManager(dep, user)
|
||||
defer m.Recycle()
|
||||
|
||||
uri, err := fs.NewUriFromString(s.Uri)
|
||||
if err != nil {
|
||||
return serializer.NewError(serializer.CodeParamErr, "unknown uri", err)
|
||||
}
|
||||
|
||||
versionId, err := dep.HashIDEncoder().Decode(s.Version, hashid.EntityID)
|
||||
if err != nil {
|
||||
return serializer.NewError(serializer.CodeParamErr, "unknown version id", err)
|
||||
}
|
||||
|
||||
if err := m.DeleteVersion(c, uri, versionId); err != nil {
|
||||
return fmt.Errorf("failed to delete version: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package explorer
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/cloudreve/Cloudreve/v4/application/dependency"
|
||||
"github.com/cloudreve/Cloudreve/v4/inventory"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/auth/requestinfo"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/filemanager/fs"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/filemanager/manager"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/logging"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/serializer"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gofrs/uuid"
|
||||
)
|
||||
|
||||
type (
|
||||
ExplorerEventService struct {
|
||||
Uri string `form:"uri" binding:"required"`
|
||||
}
|
||||
ExplorerEventParamCtx struct{}
|
||||
)
|
||||
|
||||
func (s *ExplorerEventService) HandleExplorerEventsPush(c *gin.Context) error {
|
||||
dep := dependency.FromContext(c)
|
||||
user := inventory.UserFromContext(c)
|
||||
m := manager.NewFileManager(dep, user)
|
||||
l := logging.FromContext(c)
|
||||
defer m.Recycle()
|
||||
|
||||
uri, err := fs.NewUriFromString(s.Uri)
|
||||
if err != nil {
|
||||
return serializer.NewError(serializer.CodeParamErr, "Unknown uri", err)
|
||||
}
|
||||
|
||||
// Make sure target is a valid folder that the user can listen to
|
||||
parent, listRes, err := m.List(c, uri, &manager.ListArgs{
|
||||
Page: 0,
|
||||
PageSize: 1,
|
||||
})
|
||||
if err != nil {
|
||||
return serializer.NewError(serializer.CodeParamErr, "Requested uri not available", err)
|
||||
}
|
||||
|
||||
// Reject event subscriptions on single-file views (e.g. single-file shares).
|
||||
// The listed parent is the underlying owner-side folder containing the file,
|
||||
// while the subscriber is only authorized to observe the shared file itself.
|
||||
// Subscribing to that folder topic would leak events about unshared siblings.
|
||||
if listRes != nil && listRes.SingleFileView {
|
||||
return serializer.NewError(serializer.CodeNoPermissionErr, "Event subscriptions are not supported on this view", nil)
|
||||
}
|
||||
|
||||
requestInfo := requestinfo.RequestInfoFromContext(c)
|
||||
if requestInfo.ClientID == "" {
|
||||
return serializer.NewError(serializer.CodeParamErr, "Client ID is required", nil)
|
||||
}
|
||||
|
||||
// Client ID must be a valid UUID
|
||||
if _, err := uuid.FromString(requestInfo.ClientID); err != nil {
|
||||
return serializer.NewError(serializer.CodeParamErr, "Invalid client ID", err)
|
||||
}
|
||||
|
||||
// Subscribe
|
||||
eventHub := dep.EventHub()
|
||||
rx, resumed, err := eventHub.Subscribe(c, parent.ID(), requestInfo.ClientID)
|
||||
if err != nil {
|
||||
return serializer.NewError(serializer.CodeInternalSetting, "Failed to subscribe to events", err)
|
||||
}
|
||||
|
||||
// SSE Headers
|
||||
c.Writer.Header().Set("Content-Type", "text/event-stream")
|
||||
c.Writer.Header().Set("Cache-Control", "no-cache")
|
||||
c.Writer.Header().Set("Connection", "keep-alive")
|
||||
c.Writer.Header().Set("X-Accel-Buffering", "no")
|
||||
|
||||
keepAliveTicker := time.NewTicker(30 * time.Second)
|
||||
defer keepAliveTicker.Stop()
|
||||
|
||||
if resumed {
|
||||
c.SSEvent("resumed", nil)
|
||||
c.Writer.Flush()
|
||||
} else {
|
||||
c.SSEvent("subscribed", nil)
|
||||
c.Writer.Flush()
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
// TODO: close connection after access token expired
|
||||
case <-c.Request.Context().Done():
|
||||
// Server shutdown or request cancelled
|
||||
eventHub.Unsubscribe(c, parent.ID(), requestInfo.ClientID)
|
||||
l.Debug("Request context done, unsubscribed from event hub")
|
||||
return nil
|
||||
case <-c.Writer.CloseNotify():
|
||||
eventHub.Unsubscribe(c, parent.ID(), requestInfo.ClientID)
|
||||
l.Debug("Unsubscribed from event hub")
|
||||
return nil
|
||||
case evt, ok := <-rx:
|
||||
if !ok {
|
||||
// Channel closed, EventHub is shutting down
|
||||
l.Debug("Event hub closed, disconnecting client")
|
||||
return nil
|
||||
}
|
||||
c.SSEvent("event", evt)
|
||||
l.Debug("Event sent: %+v", evt)
|
||||
c.Writer.Flush()
|
||||
if c.Errors.Last() != nil {
|
||||
l.Error("Error occurred: %+v", c.Errors.Last().Error())
|
||||
eventHub.Unsubscribe(c, parent.ID(), requestInfo.ClientID)
|
||||
l.Debug("Unsubscribed from event hub")
|
||||
return nil
|
||||
}
|
||||
case <-keepAliveTicker.C:
|
||||
c.SSEvent("keep-alive", nil)
|
||||
c.Writer.Flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,785 @@
|
||||
package explorer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/gob"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/cloudreve/Cloudreve/v4/application/dependency"
|
||||
"github.com/cloudreve/Cloudreve/v4/inventory"
|
||||
"github.com/cloudreve/Cloudreve/v4/inventory/types"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/auth"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/cluster/routes"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/filemanager/fs"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/filemanager/fs/dbfs"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/filemanager/manager"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/filemanager/manager/entitysource"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/hashid"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/request"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/serializer"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/setting"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/util"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gofrs/uuid"
|
||||
"github.com/samber/lo"
|
||||
)
|
||||
|
||||
// SingleFileService 对单文件进行操作的五福,path为文件完整路径
|
||||
type SingleFileService struct {
|
||||
Path string `uri:"path" json:"path" binding:"required,min=1,max=65535"`
|
||||
}
|
||||
|
||||
// FileIDService 通过文件ID对文件进行操作的服务
|
||||
type FileIDService struct {
|
||||
}
|
||||
|
||||
// FileAnonymousGetService 匿名(外链)获取文件服务
|
||||
type FileAnonymousGetService struct {
|
||||
ID uint `uri:"id" binding:"required,min=1"`
|
||||
Name string `uri:"name" binding:"required"`
|
||||
}
|
||||
|
||||
func init() {
|
||||
gob.Register(ArchiveDownloadSession{})
|
||||
}
|
||||
|
||||
// ArchiveService 文件流式打包下載服务
|
||||
type (
|
||||
ArchiveService struct {
|
||||
ID string `uri:"sessionID" binding:"required"`
|
||||
}
|
||||
ArchiveParamCtx struct{}
|
||||
)
|
||||
|
||||
// DownloadArchived 通过预签名 URL 打包下载
|
||||
func (service *ArchiveService) DownloadArchived(c *gin.Context) error {
|
||||
dep := dependency.FromContext(c)
|
||||
archiveSessionRaw, found := dep.KV().Get(ArchiveDownloadSessionPrefix + service.ID)
|
||||
if !found {
|
||||
return serializer.NewError(serializer.CodeNotFound, "Archive session not exist", nil)
|
||||
}
|
||||
|
||||
// Switch to user context
|
||||
archiveSession := archiveSessionRaw.(ArchiveDownloadSession)
|
||||
requester, err := dep.UserClient().GetLoginUserByID(c, archiveSession.RequesterID)
|
||||
if err != nil {
|
||||
return serializer.NewError(serializer.CodeNotFound, "Requester not found", err)
|
||||
}
|
||||
|
||||
util.WithValue(c, inventory.UserCtx{}, requester)
|
||||
|
||||
fm := manager.NewFileManager(dep, requester)
|
||||
defer fm.Recycle()
|
||||
|
||||
// 开始打包
|
||||
c.Header("Content-Disposition", "attachment;")
|
||||
c.Header("Content-Type", "application/zip")
|
||||
|
||||
if _, err := fm.CreateArchive(c, archiveSession.Uris, c.Writer); err != nil {
|
||||
return serializer.NewError(serializer.CodeIOFailed, "Failed to create archive", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type (
|
||||
GetDirectLinkParamCtx struct{}
|
||||
GetDirectLinkService struct {
|
||||
Uris []string `json:"uris" binding:"required,min=1"`
|
||||
}
|
||||
)
|
||||
|
||||
func (s *GetDirectLinkService) GetUris() []string {
|
||||
return s.Uris
|
||||
}
|
||||
|
||||
// Sources 批量获取对象的外链
|
||||
func (s *GetDirectLinkService) Get(c *gin.Context) ([]DirectLinkResponse, error) {
|
||||
dep := dependency.FromContext(c)
|
||||
u := inventory.UserFromContext(c)
|
||||
|
||||
if u.Edges.Group.Settings.SourceBatchSize == 0 {
|
||||
return nil, serializer.NewError(serializer.CodeGroupNotAllowed, "", nil)
|
||||
}
|
||||
|
||||
if len(s.Uris) > u.Edges.Group.Settings.SourceBatchSize {
|
||||
return nil, serializer.NewError(serializer.CodeBatchSourceSize, "", nil)
|
||||
}
|
||||
|
||||
m := manager.NewFileManager(dep, u)
|
||||
defer m.Recycle()
|
||||
|
||||
uris, err := fs.NewUriFromStrings(s.Uris...)
|
||||
if err != nil {
|
||||
return nil, serializer.NewError(serializer.CodeParamErr, "unknown uri", err)
|
||||
}
|
||||
|
||||
res, err := m.GetDirectLink(c, uris...)
|
||||
return BuildDirectLinkResponse(res), err
|
||||
}
|
||||
|
||||
func DeleteDirectLink(c *gin.Context) error {
|
||||
dep := dependency.FromContext(c)
|
||||
user := inventory.UserFromContext(c)
|
||||
m := manager.NewFileManager(dep, user)
|
||||
defer m.Recycle()
|
||||
|
||||
linkId := hashid.FromContext(c)
|
||||
linkClient := dep.DirectLinkClient()
|
||||
ctx := context.WithValue(c, inventory.LoadDirectLinkFile{}, true)
|
||||
link, err := linkClient.GetByID(ctx, linkId)
|
||||
if err != nil || link.Edges.File == nil {
|
||||
return serializer.NewError(serializer.CodeNotFound, "Direct link not found", err)
|
||||
}
|
||||
|
||||
if link.Edges.File.OwnerID != user.ID {
|
||||
return serializer.NewError(serializer.CodeNotFound, "Direct link not found", err)
|
||||
}
|
||||
|
||||
if err := linkClient.Delete(c, link.ID); err != nil {
|
||||
return serializer.NewError(serializer.CodeDBError, "Failed to delete direct link", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type (
|
||||
// ListFileParameterCtx define key fore ListFileService
|
||||
ListFileParameterCtx struct{}
|
||||
|
||||
// ListFileService stores parameters for list file by URI
|
||||
ListFileService struct {
|
||||
Uri string `uri:"uri" form:"uri" json:"uri" binding:"required"`
|
||||
Page int `uri:"page" form:"page" json:"page" binding:"min=0"`
|
||||
PageSize int `uri:"page_size" form:"page_size" json:"page_size"`
|
||||
OrderBy string `uri:"order_by" form:"order_by" json:"order_by"`
|
||||
OrderDirection string `uri:"order_direction" form:"order_direction" json:"order_direction"`
|
||||
NextPageToken string `uri:"next_page_token" form:"next_page_token" json:"next_page_token"`
|
||||
}
|
||||
)
|
||||
|
||||
// List all files for given path
|
||||
func (service *ListFileService) List(c *gin.Context) (*ListResponse, error) {
|
||||
dep := dependency.FromContext(c)
|
||||
user := inventory.UserFromContext(c)
|
||||
m := manager.NewFileManager(dep, user)
|
||||
defer m.Recycle()
|
||||
|
||||
uri, err := fs.NewUriFromString(service.Uri)
|
||||
if err != nil {
|
||||
return nil, serializer.NewError(serializer.CodeParamErr, "unknown uri", err)
|
||||
}
|
||||
|
||||
pageSize := service.PageSize
|
||||
streamed := false
|
||||
hasher := dep.HashIDEncoder()
|
||||
parent, res, err := m.List(c, uri, &manager.ListArgs{
|
||||
Page: service.Page,
|
||||
PageSize: pageSize,
|
||||
Order: service.OrderBy,
|
||||
OrderDirection: service.OrderDirection,
|
||||
PageToken: service.NextPageToken,
|
||||
StreamResponseCallback: func(parent fs.File, files []fs.File) {
|
||||
if !streamed {
|
||||
WriteEventSourceHeader(c)
|
||||
streamed = true
|
||||
}
|
||||
|
||||
WriteEventSource(c, "file", lo.Map(files, func(file fs.File, index int) *FileResponse {
|
||||
return BuildFileResponse(c, user, file, hasher, nil)
|
||||
}))
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
listResponse := BuildListResponse(c, user, parent, res, hasher)
|
||||
if streamed {
|
||||
WriteEventSource(c, "list", listResponse)
|
||||
return nil, ErrSSETakeOver
|
||||
}
|
||||
|
||||
return listResponse, nil
|
||||
}
|
||||
|
||||
type (
|
||||
CreateFileParameterCtx struct{}
|
||||
CreateFileService struct {
|
||||
Uri string `json:"uri" binding:"required"`
|
||||
Type string `json:"type" binding:"required,eq=file|eq=folder"`
|
||||
Metadata map[string]string `json:"metadata"`
|
||||
ErrOnConflict bool `json:"err_on_conflict"`
|
||||
}
|
||||
)
|
||||
|
||||
func (service *CreateFileService) Create(c *gin.Context) (*FileResponse, error) {
|
||||
dep := dependency.FromContext(c)
|
||||
user := inventory.UserFromContext(c)
|
||||
m := manager.NewFileManager(dep, user)
|
||||
defer m.Recycle()
|
||||
|
||||
uri, err := fs.NewUriFromString(service.Uri)
|
||||
if err != nil {
|
||||
return nil, serializer.NewError(serializer.CodeParamErr, "unknown uri", err)
|
||||
}
|
||||
|
||||
fileType := types.FileTypeFromString(service.Type)
|
||||
opts := []fs.Option{
|
||||
fs.WithMetadata(service.Metadata),
|
||||
}
|
||||
if service.ErrOnConflict {
|
||||
opts = append(opts, dbfs.WithErrorOnConflict())
|
||||
}
|
||||
file, err := m.Create(c, uri, fileType, opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return BuildFileResponse(c, user, file, dep.HashIDEncoder(), nil), nil
|
||||
}
|
||||
|
||||
type (
|
||||
RenameFileParameterCtx struct{}
|
||||
RenameFileService struct {
|
||||
Uri string `json:"uri" binding:"required"`
|
||||
NewName string `json:"new_name" binding:"required,min=1,max=255"`
|
||||
}
|
||||
)
|
||||
|
||||
func (service *RenameFileService) Rename(c *gin.Context) (*FileResponse, error) {
|
||||
dep := dependency.FromContext(c)
|
||||
user := inventory.UserFromContext(c)
|
||||
m := manager.NewFileManager(dep, user)
|
||||
defer m.Recycle()
|
||||
|
||||
uri, err := fs.NewUriFromString(service.Uri)
|
||||
if err != nil {
|
||||
return nil, serializer.NewError(serializer.CodeParamErr, "unknown uri", err)
|
||||
}
|
||||
|
||||
file, err := m.Rename(c, uri, service.NewName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return BuildFileResponse(c, user, file, dep.HashIDEncoder(), nil), nil
|
||||
}
|
||||
|
||||
type (
|
||||
MoveFileParameterCtx struct{}
|
||||
MoveFileService struct {
|
||||
Uris []string `json:"uris" binding:"required,min=1"`
|
||||
Dst string `json:"dst" binding:"required"`
|
||||
Copy bool `json:"copy"`
|
||||
}
|
||||
)
|
||||
|
||||
func (s *MoveFileService) GetUris() []string {
|
||||
return s.Uris
|
||||
}
|
||||
|
||||
func (s *MoveFileService) Move(c *gin.Context) error {
|
||||
dep := dependency.FromContext(c)
|
||||
user := inventory.UserFromContext(c)
|
||||
m := manager.NewFileManager(dep, user)
|
||||
defer m.Recycle()
|
||||
|
||||
uris, err := fs.NewUriFromStrings(s.Uris...)
|
||||
if err != nil {
|
||||
return serializer.NewError(serializer.CodeParamErr, "unknown uri", err)
|
||||
}
|
||||
|
||||
dst, err := fs.NewUriFromString(s.Dst)
|
||||
if err != nil {
|
||||
return serializer.NewError(serializer.CodeParamErr, "unknown destination uri", err)
|
||||
}
|
||||
|
||||
return m.MoveOrCopy(c, uris, dst, s.Copy)
|
||||
}
|
||||
|
||||
type (
|
||||
FileUpdateParameterCtx struct{}
|
||||
FileUpdateService struct {
|
||||
Uri string `form:"uri" binding:"required"`
|
||||
Previous string `form:"previous"`
|
||||
}
|
||||
)
|
||||
|
||||
func (service *FileUpdateService) PutContent(c *gin.Context, ls fs.LockSession) (*FileResponse, error) {
|
||||
dep := dependency.FromContext(c)
|
||||
settings := dep.SettingProvider()
|
||||
// 取得文件大小
|
||||
rc, fileSize, err := request.SniffContentLength(c.Request)
|
||||
if err != nil {
|
||||
return nil, serializer.NewError(serializer.CodeParamErr, "invalid content length", err)
|
||||
}
|
||||
|
||||
if fileSize > settings.MaxOnlineEditSize(c) {
|
||||
return nil, fs.ErrFileSizeTooBig
|
||||
}
|
||||
|
||||
uri, err := fs.NewUriFromString(service.Uri)
|
||||
if err != nil {
|
||||
return nil, serializer.NewError(serializer.CodeParamErr, "unknown uri", err)
|
||||
}
|
||||
|
||||
fileData := &fs.UploadRequest{
|
||||
Props: &fs.UploadProps{
|
||||
Uri: uri,
|
||||
PreviousVersion: service.Previous,
|
||||
Size: fileSize,
|
||||
},
|
||||
File: rc,
|
||||
Mode: fs.ModeOverwrite,
|
||||
}
|
||||
|
||||
user := inventory.UserFromContext(c)
|
||||
m := manager.NewFileManager(dep, user)
|
||||
defer m.Recycle()
|
||||
|
||||
// Update file
|
||||
var ctx context.Context = c
|
||||
if ls != nil {
|
||||
ctx = fs.LockSessionToContext(c, ls)
|
||||
}
|
||||
res, err := m.Update(ctx, fileData)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to update file: %w", err)
|
||||
}
|
||||
|
||||
return BuildFileResponse(c, user, res, dep.HashIDEncoder(), nil), nil
|
||||
}
|
||||
|
||||
type (
|
||||
FileURLParameterCtx struct{}
|
||||
FileURLService struct {
|
||||
Uris []string `json:"uris" binding:"required"`
|
||||
Download bool `json:"download"`
|
||||
Redirect bool `json:"redirect"` // Only works if Uris count is 1.
|
||||
Entity string `json:"entity"` // Only works if Uris count is 1.
|
||||
UsePrimarySiteURL bool `json:"use_primary_site_url"`
|
||||
SkipError bool `json:"skip_error"`
|
||||
Archive bool `json:"archive"`
|
||||
NoCache bool `json:"no_cache"`
|
||||
}
|
||||
FileURLResponse struct {
|
||||
Urls []manager.EntityUrl `json:"urls"`
|
||||
Expires *time.Time `json:"expires"`
|
||||
}
|
||||
ArchiveDownloadSession struct {
|
||||
Uris []*fs.URI `json:"uris"`
|
||||
RequesterID int `json:"requester_id"`
|
||||
}
|
||||
)
|
||||
|
||||
const (
|
||||
ArchiveDownloadSessionPrefix = "archive_"
|
||||
)
|
||||
|
||||
func (s *FileURLService) GetUris() []string {
|
||||
return s.Uris
|
||||
}
|
||||
|
||||
// GetArchiveDownloadSession generates temporary download session for archive download.
|
||||
func (s *FileURLService) GetArchiveDownloadSession(c *gin.Context) (*FileURLResponse, error) {
|
||||
dep := dependency.FromContext(c)
|
||||
settings := dep.SettingProvider()
|
||||
user := inventory.UserFromContext(c)
|
||||
|
||||
uris, err := fs.NewUriFromStrings(s.Uris...)
|
||||
if err != nil {
|
||||
return nil, serializer.NewError(serializer.CodeParamErr, "unknown uri", err)
|
||||
}
|
||||
|
||||
if !user.Edges.Group.Permissions.Enabled(int(types.GroupPermissionArchiveDownload)) {
|
||||
return nil, serializer.NewError(serializer.CodeGroupNotAllowed, "", nil)
|
||||
}
|
||||
|
||||
// Create archive download session
|
||||
archiveSession := &ArchiveDownloadSession{
|
||||
Uris: uris,
|
||||
RequesterID: user.ID,
|
||||
}
|
||||
sessionId := uuid.Must(uuid.NewV4()).String()
|
||||
ttl := settings.ArchiveDownloadSessionTTL(c)
|
||||
expire := time.Now().Add(time.Duration(ttl) * time.Second)
|
||||
if err := dep.KV().Set(ArchiveDownloadSessionPrefix+sessionId, *archiveSession, ttl); err != nil {
|
||||
return nil, serializer.NewError(serializer.CodeInternalSetting, "failed to create archive download session", err)
|
||||
}
|
||||
|
||||
base := settings.SiteURL(c)
|
||||
downloadUrl := routes.MasterArchiveDownloadUrl(base, sessionId)
|
||||
finalUrl, err := auth.SignURI(c, dep.GeneralAuth(), downloadUrl.String(), &expire)
|
||||
if err != nil {
|
||||
return nil, serializer.NewError(serializer.CodeInternalSetting, "failed to sign archive download url", err)
|
||||
}
|
||||
|
||||
return &FileURLResponse{
|
||||
Urls: []manager.EntityUrl{{Url: finalUrl.String()}},
|
||||
Expires: &expire,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *FileURLService) Get(c *gin.Context) (*FileURLResponse, error) {
|
||||
if s.Archive {
|
||||
return s.GetArchiveDownloadSession(c)
|
||||
}
|
||||
|
||||
dep := dependency.FromContext(c)
|
||||
settings := dep.SettingProvider()
|
||||
user := inventory.UserFromContext(c)
|
||||
m := manager.NewFileManager(dep, user)
|
||||
defer m.Recycle()
|
||||
|
||||
uris, err := fs.NewUriFromStrings(s.Uris...)
|
||||
if err != nil {
|
||||
return nil, serializer.NewError(serializer.CodeParamErr, "unknown uri", err)
|
||||
}
|
||||
|
||||
// Request entity URL
|
||||
expire := time.Now().Add(settings.EntityUrlValidDuration(c))
|
||||
urlReq := lo.Map(uris, func(uri *fs.URI, _ int) manager.GetEntityUrlArgs {
|
||||
return manager.GetEntityUrlArgs{
|
||||
URI: uri,
|
||||
PreferredEntityID: s.Entity,
|
||||
}
|
||||
})
|
||||
|
||||
var ctx context.Context = c
|
||||
if s.UsePrimarySiteURL {
|
||||
ctx = setting.UseFirstSiteUrl(ctx)
|
||||
}
|
||||
|
||||
res, earliestExpire, err := m.GetEntityUrls(ctx, urlReq,
|
||||
fs.WithDownloadSpeed(int64(user.Edges.Group.SpeedLimit)),
|
||||
fs.WithIsDownload(s.Download),
|
||||
fs.WithNoCache(s.NoCache),
|
||||
fs.WithUrlExpire(&expire),
|
||||
)
|
||||
if err != nil && !s.SkipError {
|
||||
return nil, fmt.Errorf("failed to get entity url: %w", err)
|
||||
}
|
||||
|
||||
//if !s.NoCache && earliestExpire != nil {
|
||||
// // Set cache header
|
||||
// cacheTTL := int(earliestExpire.Sub(time.Now()).Seconds() - float64(settings.EntityUrlCacheMargin(c)))
|
||||
// if cacheTTL > 0 {
|
||||
// c.Header("Cache-Control", fmt.Sprintf("private, max-age=%d", cacheTTL))
|
||||
// }
|
||||
//}
|
||||
|
||||
if s.Redirect && len(uris) == 1 {
|
||||
c.Redirect(http.StatusFound, res[0].Url)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return &FileURLResponse{
|
||||
Urls: res,
|
||||
Expires: earliestExpire,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type (
|
||||
FileThumbParameterCtx struct{}
|
||||
FileThumbService struct {
|
||||
Uri string `form:"uri" binding:"required"`
|
||||
}
|
||||
FileThumbResponse struct {
|
||||
Url string `json:"url"`
|
||||
Expires *time.Time `json:"expires"`
|
||||
}
|
||||
)
|
||||
|
||||
// Get redirect to thumb file.
|
||||
func (s *FileThumbService) Get(c *gin.Context) (*FileThumbResponse, error) {
|
||||
dep := dependency.FromContext(c)
|
||||
user := inventory.UserFromContext(c)
|
||||
m := manager.NewFileManager(dep, user)
|
||||
defer m.Recycle()
|
||||
|
||||
uri, err := fs.NewUriFromString(s.Uri)
|
||||
if err != nil {
|
||||
return nil, serializer.NewError(serializer.CodeParamErr, "unknown uri", err)
|
||||
}
|
||||
|
||||
// Get thumbnail
|
||||
thumb, err := m.Thumbnail(c, uri)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get thumbnail: %w", err)
|
||||
}
|
||||
|
||||
expire := time.Now().Add(dep.SettingProvider().EntityUrlValidDuration(c))
|
||||
thumbUrl, err := thumb.Url(c, entitysource.WithExpire(&expire))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get thumbnail url: %w", err)
|
||||
}
|
||||
|
||||
return &FileThumbResponse{
|
||||
Url: thumbUrl.Url,
|
||||
Expires: thumbUrl.ExpireAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type (
|
||||
DeleteFileParameterCtx struct{}
|
||||
DeleteFileService struct {
|
||||
Uris []string `json:"uris" binding:"required,min=1"`
|
||||
UnlinkOnly bool `json:"unlink"`
|
||||
SkipSoftDelete bool `json:"skip_soft_delete"`
|
||||
}
|
||||
)
|
||||
|
||||
func (s *DeleteFileService) GetUris() []string {
|
||||
return s.Uris
|
||||
}
|
||||
|
||||
func (s *DeleteFileService) Delete(c *gin.Context) error {
|
||||
dep := dependency.FromContext(c)
|
||||
user := inventory.UserFromContext(c)
|
||||
m := manager.NewFileManager(dep, user)
|
||||
defer m.Recycle()
|
||||
|
||||
uris, err := fs.NewUriFromStrings(s.Uris...)
|
||||
if err != nil {
|
||||
return serializer.NewError(serializer.CodeParamErr, "unknown uri", err)
|
||||
}
|
||||
|
||||
if s.UnlinkOnly && !user.Edges.Group.Permissions.Enabled(int(types.GroupPermissionAdvanceDelete)) {
|
||||
return serializer.NewError(serializer.CodeNoPermissionErr, "advance delete permission is required", nil)
|
||||
}
|
||||
|
||||
// Delete file
|
||||
if err = m.Delete(c, uris, fs.WithUnlinkOnly(s.UnlinkOnly), fs.WithSkipSoftDelete(s.SkipSoftDelete)); err != nil {
|
||||
return fmt.Errorf("failed to delete file: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *DeleteFileService) Restore(c *gin.Context) error {
|
||||
dep := dependency.FromContext(c)
|
||||
user := inventory.UserFromContext(c)
|
||||
m := manager.NewFileManager(dep, user)
|
||||
defer m.Recycle()
|
||||
|
||||
uris, err := fs.NewUriFromStrings(s.Uris...)
|
||||
if err != nil {
|
||||
return serializer.NewError(serializer.CodeParamErr, "unknown uri", err)
|
||||
}
|
||||
|
||||
// Delete file
|
||||
if err = m.Restore(c, uris...); err != nil {
|
||||
return fmt.Errorf("failed to restore file: %w", err)
|
||||
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// EmptyTrash hard-deletes every top-level item in the current user's trash bin.
|
||||
func EmptyTrash(c *gin.Context) error {
|
||||
dep := dependency.FromContext(c)
|
||||
user := inventory.UserFromContext(c)
|
||||
m := manager.NewFileManager(dep, user)
|
||||
defer m.Recycle()
|
||||
|
||||
if err := m.EmptyTrash(c); err != nil {
|
||||
return fmt.Errorf("failed to empty trash: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type (
|
||||
UnlockFileParameterCtx struct{}
|
||||
UnlockFileService struct {
|
||||
Tokens []string `json:"tokens" binding:"required,max=16384"`
|
||||
}
|
||||
)
|
||||
|
||||
func (s *UnlockFileService) Unlock(c *gin.Context) error {
|
||||
dep := dependency.FromContext(c)
|
||||
user := inventory.UserFromContext(c)
|
||||
m := manager.NewFileManager(dep, user)
|
||||
defer m.Recycle()
|
||||
|
||||
// Unlock file
|
||||
if err := m.Unlock(c, s.Tokens...); err != nil {
|
||||
return serializer.NewError(serializer.CodeParamErr, "failed to unlock file", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type (
|
||||
GetFileInfoParameterCtx struct{}
|
||||
GetFileInfoService struct {
|
||||
Uri string `form:"uri"`
|
||||
ID string `form:"id"`
|
||||
ExtendedInfo bool `form:"extended"`
|
||||
FolderSummary bool `form:"folder_summary"`
|
||||
}
|
||||
)
|
||||
|
||||
func (s *GetFileInfoService) Get(c *gin.Context) (*FileResponse, error) {
|
||||
dep := dependency.FromContext(c)
|
||||
user := inventory.UserFromContext(c)
|
||||
m := manager.NewFileManager(dep, user)
|
||||
defer m.Recycle()
|
||||
|
||||
if s.ID != "" && s.Uri == "" {
|
||||
fileId, err := dep.HashIDEncoder().Decode(s.ID, hashid.FileID)
|
||||
if err != nil {
|
||||
return nil, serializer.NewError(serializer.CodeParamErr, "unknown file id", err)
|
||||
}
|
||||
|
||||
file, err := m.TraverseFile(c, fileId)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to traverse file: %w", err)
|
||||
}
|
||||
|
||||
s.Uri = file.Uri(true).String()
|
||||
}
|
||||
|
||||
if s.Uri == "" {
|
||||
return nil, serializer.NewError(serializer.CodeParamErr, "uri is required", nil)
|
||||
}
|
||||
|
||||
uri, err := fs.NewUriFromString(s.Uri)
|
||||
if err != nil {
|
||||
return nil, serializer.NewError(serializer.CodeParamErr, "unknown uri", err)
|
||||
}
|
||||
|
||||
opts := []fs.Option{dbfs.WithFilePublicMetadata(), dbfs.WithNotRoot()}
|
||||
if s.ExtendedInfo {
|
||||
opts = append(opts, dbfs.WithExtendedInfo(), dbfs.WithEntityUser(), dbfs.WithFileShareIfOwned())
|
||||
}
|
||||
if s.FolderSummary {
|
||||
opts = append(opts, dbfs.WithLoadFolderSummary())
|
||||
}
|
||||
|
||||
file, err := m.Get(c, uri, opts...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get file: %w", err)
|
||||
}
|
||||
|
||||
if file == nil {
|
||||
return nil, serializer.NewError(serializer.CodeNotFound, "file not found", nil)
|
||||
}
|
||||
|
||||
return BuildFileResponse(c, user, file, dep.HashIDEncoder(), nil), nil
|
||||
}
|
||||
|
||||
func RedirectDirectLink(c *gin.Context, name string, download bool) error {
|
||||
dep := dependency.FromContext(c)
|
||||
settings := dep.SettingProvider()
|
||||
|
||||
sourceLinkID := hashid.FromContext(c)
|
||||
ctx := context.WithValue(c, inventory.LoadDirectLinkFile{}, true)
|
||||
ctx = context.WithValue(ctx, inventory.LoadFileEntity{}, true)
|
||||
ctx = context.WithValue(ctx, inventory.LoadFileUser{}, true)
|
||||
ctx = context.WithValue(ctx, inventory.LoadUserGroup{}, true)
|
||||
dl, err := dep.DirectLinkClient().GetByNameID(ctx, sourceLinkID, name)
|
||||
if err != nil {
|
||||
return serializer.NewError(serializer.CodeNotFound, "direct link not found", err)
|
||||
}
|
||||
|
||||
m := manager.NewFileManager(dep, dl.Edges.File.Edges.Owner)
|
||||
defer m.Recycle()
|
||||
|
||||
// Request entity URL
|
||||
expire := time.Now().Add(settings.EntityUrlValidDuration(c))
|
||||
res, earliestExpire, err := m.GetUrlForRedirectedDirectLink(c, dl,
|
||||
fs.WithUrlExpire(&expire),
|
||||
fs.WithIsDownload(download),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
c.Redirect(http.StatusFound, res)
|
||||
c.Header("Cache-Control", fmt.Sprintf("public, max-age=%d", int(earliestExpire.Sub(time.Now()).Seconds())))
|
||||
return nil
|
||||
}
|
||||
|
||||
type (
|
||||
PatchViewParameterCtx struct{}
|
||||
PatchViewService struct {
|
||||
Uri string `json:"uri" binding:"required"`
|
||||
View *types.ExplorerView `json:"view"`
|
||||
}
|
||||
)
|
||||
|
||||
func (s *PatchViewService) Patch(c *gin.Context) error {
|
||||
dep := dependency.FromContext(c)
|
||||
user := inventory.UserFromContext(c)
|
||||
m := manager.NewFileManager(dep, user)
|
||||
defer m.Recycle()
|
||||
|
||||
uri, err := fs.NewUriFromString(s.Uri)
|
||||
if err != nil {
|
||||
return serializer.NewError(serializer.CodeParamErr, "unknown uri", err)
|
||||
}
|
||||
|
||||
if err := m.PatchView(c, uri, s.View); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type (
|
||||
ArchiveListFilesParamCtx struct{}
|
||||
ArchiveListFilesService struct {
|
||||
Uri string `form:"uri" binding:"required"`
|
||||
Entity string `form:"entity"`
|
||||
TextEncoding string `form:"text_encoding"`
|
||||
}
|
||||
)
|
||||
|
||||
func (s *ArchiveListFilesService) List(c *gin.Context) (*ArchiveListFilesResponse, error) {
|
||||
dep := dependency.FromContext(c)
|
||||
user := inventory.UserFromContext(c)
|
||||
m := manager.NewFileManager(dep, user)
|
||||
defer m.Recycle()
|
||||
if !user.Edges.Group.Permissions.Enabled(int(types.GroupPermissionArchiveTask)) {
|
||||
return nil, serializer.NewError(serializer.CodeGroupNotAllowed, "Group not allowed to extract archive files", nil)
|
||||
}
|
||||
|
||||
uri, err := fs.NewUriFromString(s.Uri)
|
||||
if err != nil {
|
||||
return nil, serializer.NewError(serializer.CodeParamErr, "unknown uri", err)
|
||||
}
|
||||
|
||||
files, err := m.ListArchiveFiles(c, uri, s.Entity, s.TextEncoding)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list archive files: %w", err)
|
||||
}
|
||||
|
||||
return BuildArchiveListFilesResponse(files), nil
|
||||
}
|
||||
|
||||
type (
|
||||
FulltextSearchParamCtx struct{}
|
||||
FulltextSearchService struct {
|
||||
Query string `form:"query" binding:"required"`
|
||||
Offset int `form:"offset"`
|
||||
}
|
||||
)
|
||||
|
||||
func (s *FulltextSearchService) Search(c *gin.Context) (*FullTextSearchResults, error) {
|
||||
dep := dependency.FromContext(c)
|
||||
user := inventory.UserFromContext(c)
|
||||
m := manager.NewFileManager(dep, user)
|
||||
defer m.Recycle()
|
||||
|
||||
results, err := m.SearchFullText(c, s.Query, s.Offset)
|
||||
if err != nil {
|
||||
return nil, serializer.NewError(serializer.CodeInternalSetting, "failed to search full text", err)
|
||||
}
|
||||
|
||||
return BuildFullTextSearchResults(c, user, dep.HashIDEncoder(), results), nil
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package explorer
|
||||
|
||||
import (
|
||||
"github.com/cloudreve/Cloudreve/v4/application/dependency"
|
||||
"github.com/cloudreve/Cloudreve/v4/inventory"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/filemanager/fs"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/filemanager/manager"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/serializer"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type (
|
||||
PatchMetadataService struct {
|
||||
Uris []string `json:"uris" binding:"required"`
|
||||
Patches []fs.MetadataPatch `json:"patches" binding:"required,dive"`
|
||||
}
|
||||
|
||||
PatchMetadataParameterCtx struct{}
|
||||
)
|
||||
|
||||
func (s *PatchMetadataService) GetUris() []string {
|
||||
return s.Uris
|
||||
}
|
||||
|
||||
func (s *PatchMetadataService) Patch(c *gin.Context) error {
|
||||
dep := dependency.FromContext(c)
|
||||
user := inventory.UserFromContext(c)
|
||||
m := manager.NewFileManager(dep, user)
|
||||
defer m.Recycle()
|
||||
|
||||
uris, err := fs.NewUriFromStrings(s.Uris...)
|
||||
if err != nil {
|
||||
return serializer.NewError(serializer.CodeParamErr, "unknown uri", err)
|
||||
}
|
||||
|
||||
return m.PatchMedata(c, uris, s.Patches...)
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package explorer
|
||||
|
||||
import (
|
||||
"github.com/cloudreve/Cloudreve/v4/application/dependency"
|
||||
"github.com/cloudreve/Cloudreve/v4/inventory"
|
||||
"github.com/cloudreve/Cloudreve/v4/inventory/types"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/filemanager/fs"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/serializer"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/samber/lo"
|
||||
)
|
||||
|
||||
type (
|
||||
PinFileService struct {
|
||||
Uri string `json:"uri" binding:"required"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
PinFileParameterCtx struct{}
|
||||
)
|
||||
|
||||
// PinFileService pins new uri to sidebar
|
||||
func (service *PinFileService) PinFile(c *gin.Context) error {
|
||||
dep := dependency.FromContext(c)
|
||||
user := inventory.UserFromContext(c)
|
||||
userClient := dep.UserClient()
|
||||
|
||||
uri, err := fs.NewUriFromString(service.Uri)
|
||||
if err != nil {
|
||||
return serializer.NewError(serializer.CodeParamErr, "unknown uri", err)
|
||||
}
|
||||
|
||||
uriStr := uri.String()
|
||||
for _, pin := range user.Settings.Pined {
|
||||
if pin.Uri == uriStr {
|
||||
if pin.Name != service.Name {
|
||||
return serializer.NewError(serializer.CodeObjectExist, "uri already pinned with different name", nil)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
user.Settings.Pined = append(user.Settings.Pined, types.PinedFile{
|
||||
Uri: uriStr,
|
||||
Name: service.Name,
|
||||
})
|
||||
if err := userClient.SaveSettings(c, user); err != nil {
|
||||
return serializer.NewError(serializer.CodeDBError, "failed to save settings", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// UnpinFile removes uri from sidebar
|
||||
func (service *PinFileService) UnpinFile(c *gin.Context) error {
|
||||
dep := dependency.FromContext(c)
|
||||
user := inventory.UserFromContext(c)
|
||||
userClient := dep.UserClient()
|
||||
|
||||
uri, err := fs.NewUriFromString(service.Uri)
|
||||
if err != nil {
|
||||
return serializer.NewError(serializer.CodeParamErr, "unknown uri", err)
|
||||
}
|
||||
|
||||
uriStr := uri.String()
|
||||
user.Settings.Pined = lo.Filter(user.Settings.Pined, func(pin types.PinedFile, index int) bool {
|
||||
return pin.Uri != uriStr
|
||||
})
|
||||
|
||||
if err := userClient.SaveSettings(c, user); err != nil {
|
||||
return serializer.NewError(serializer.CodeDBError, "failed to save settings", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,555 @@
|
||||
package explorer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/cloudreve/Cloudreve/v4/application/dependency"
|
||||
"github.com/cloudreve/Cloudreve/v4/ent"
|
||||
"github.com/cloudreve/Cloudreve/v4/inventory"
|
||||
"github.com/cloudreve/Cloudreve/v4/inventory/types"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/auth"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/boolset"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/cluster/routes"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/filemanager/fs"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/filemanager/manager"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/hashid"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/queue"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/util"
|
||||
"github.com/cloudreve/Cloudreve/v4/service/user"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gofrs/uuid"
|
||||
"github.com/samber/lo"
|
||||
)
|
||||
|
||||
type FullTextSearchResults struct {
|
||||
Hits []FullTextSearchResult `json:"hits"`
|
||||
Total int64 `json:"total"`
|
||||
}
|
||||
|
||||
func BuildFullTextSearchResults(ctx context.Context, user *ent.User, hasher hashid.Encoder, results *manager.FullTextSearchResults) *FullTextSearchResults {
|
||||
return &FullTextSearchResults{
|
||||
Hits: lo.Map(results.Hits, func(result manager.FullTextSearchResult, index int) FullTextSearchResult {
|
||||
return FullTextSearchResult{
|
||||
File: *BuildFileResponse(ctx, user, result.File, hasher, nil),
|
||||
Content: result.Content,
|
||||
}
|
||||
}),
|
||||
Total: results.Total,
|
||||
}
|
||||
}
|
||||
|
||||
type FullTextSearchResult struct {
|
||||
File FileResponse `json:"file"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
type ArchiveListFilesResponse struct {
|
||||
Files []manager.ArchivedFile `json:"files"`
|
||||
}
|
||||
|
||||
func BuildArchiveListFilesResponse(files []manager.ArchivedFile) *ArchiveListFilesResponse {
|
||||
return &ArchiveListFilesResponse{
|
||||
Files: files,
|
||||
}
|
||||
}
|
||||
|
||||
type PutRelativeResponse struct {
|
||||
Name string
|
||||
Url string
|
||||
}
|
||||
|
||||
type DirectLinkResponse struct {
|
||||
Link string `json:"link"`
|
||||
FileUrl string `json:"file_url"`
|
||||
}
|
||||
|
||||
func BuildDirectLinkResponse(links []manager.DirectLink) []DirectLinkResponse {
|
||||
if len(links) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var res []DirectLinkResponse
|
||||
for _, link := range links {
|
||||
res = append(res, DirectLinkResponse{
|
||||
Link: link.Url,
|
||||
FileUrl: link.File.Uri(false).String(),
|
||||
})
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
const PathMyRedacted = "redacted"
|
||||
|
||||
type TaskResponse struct {
|
||||
CreatedAt time.Time `json:"created_at,"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ID string `json:"id"`
|
||||
Status string `json:"status"`
|
||||
Type string `json:"type"`
|
||||
Node *user.Node `json:"node,omitempty"`
|
||||
Summary *queue.Summary `json:"summary,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
ErrorHistory []string `json:"error_history,omitempty"`
|
||||
Duration int64 `json:"duration,omitempty"`
|
||||
ResumeTime int64 `json:"resume_time,omitempty"`
|
||||
RetryCount int `json:"retry_count,omitempty"`
|
||||
}
|
||||
|
||||
type TaskListResponse struct {
|
||||
Tasks []TaskResponse `json:"tasks"`
|
||||
Pagination *inventory.PaginationResults `json:"pagination"`
|
||||
}
|
||||
|
||||
func BuildTaskListResponse(tasks []queue.Task, res *inventory.ListTaskResult, nodeMap map[int]*ent.Node, hasher hashid.Encoder) *TaskListResponse {
|
||||
return &TaskListResponse{
|
||||
Pagination: res.PaginationResults,
|
||||
Tasks: lo.Map(tasks, func(t queue.Task, index int) TaskResponse {
|
||||
var (
|
||||
node *ent.Node
|
||||
s = t.Summarize(hasher)
|
||||
)
|
||||
|
||||
if s.NodeID > 0 {
|
||||
node = nodeMap[s.NodeID]
|
||||
}
|
||||
return *BuildTaskResponse(t, node, hasher)
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
func BuildTaskResponse(task queue.Task, node *ent.Node, hasher hashid.Encoder) *TaskResponse {
|
||||
model := task.Model()
|
||||
t := &TaskResponse{
|
||||
Status: string(task.Status()),
|
||||
CreatedAt: model.CreatedAt,
|
||||
UpdatedAt: model.UpdatedAt,
|
||||
ID: hashid.EncodeTaskID(hasher, task.ID()),
|
||||
Type: task.Type(),
|
||||
Summary: task.Summarize(hasher),
|
||||
Error: auth.RedactSensitiveValues(model.PublicState.Error),
|
||||
ErrorHistory: lo.Map(model.PublicState.ErrorHistory, func(s string, index int) string {
|
||||
return auth.RedactSensitiveValues(s)
|
||||
}),
|
||||
Duration: model.PublicState.ExecutedDuration.Milliseconds(),
|
||||
ResumeTime: model.PublicState.ResumeTime,
|
||||
RetryCount: model.PublicState.RetryCount,
|
||||
}
|
||||
|
||||
if node != nil {
|
||||
t.Node = user.BuildNode(node, hasher)
|
||||
}
|
||||
|
||||
return t
|
||||
}
|
||||
|
||||
type UploadSessionResponse struct {
|
||||
SessionID string `json:"session_id"`
|
||||
UploadID string `json:"upload_id"`
|
||||
ChunkSize int64 `json:"chunk_size"` // 分块大小,0 为部分快
|
||||
Expires int64 `json:"expires"` // 上传凭证过期时间, Unix 时间戳
|
||||
UploadURLs []string `json:"upload_urls,omitempty"`
|
||||
Credential string `json:"credential,omitempty"`
|
||||
AccessKey string `json:"ak,omitempty"`
|
||||
KeyTime string `json:"keyTime,omitempty"` // COS用有效期
|
||||
CompleteURL string `json:"completeURL,omitempty"`
|
||||
StoragePolicy *StoragePolicy `json:"storage_policy,omitempty"`
|
||||
Uri string `json:"uri"`
|
||||
CallbackSecret string `json:"callback_secret"`
|
||||
MimeType string `json:"mime_type,omitempty"`
|
||||
UploadPolicy string `json:"upload_policy,omitempty"`
|
||||
EncryptMetadata *types.EncryptMetadata `json:"encrypt_metadata,omitempty"`
|
||||
}
|
||||
|
||||
func BuildUploadSessionResponse(session *fs.UploadCredential, hasher hashid.Encoder) *UploadSessionResponse {
|
||||
res := &UploadSessionResponse{
|
||||
SessionID: session.SessionID,
|
||||
ChunkSize: session.ChunkSize,
|
||||
Expires: session.Expires,
|
||||
UploadURLs: session.UploadURLs,
|
||||
Credential: session.Credential,
|
||||
CompleteURL: session.CompleteURL,
|
||||
Uri: session.Uri,
|
||||
UploadID: session.UploadID,
|
||||
StoragePolicy: BuildStoragePolicy(session.StoragePolicy, hasher),
|
||||
CallbackSecret: session.CallbackSecret,
|
||||
MimeType: session.MimeType,
|
||||
UploadPolicy: session.UploadPolicy,
|
||||
EncryptMetadata: session.EncryptMetadata,
|
||||
}
|
||||
|
||||
if session.EncryptMetadata != nil {
|
||||
res.EncryptMetadata = &types.EncryptMetadata{
|
||||
Algorithm: session.EncryptMetadata.Algorithm,
|
||||
KeyPlainText: session.EncryptMetadata.KeyPlainText,
|
||||
IV: session.EncryptMetadata.IV,
|
||||
}
|
||||
}
|
||||
|
||||
return res
|
||||
}
|
||||
|
||||
// WopiFileInfo Response for `CheckFileInfo`
|
||||
type WopiFileInfo struct {
|
||||
// Required
|
||||
BaseFileName string
|
||||
Version string
|
||||
Size int64
|
||||
|
||||
// Breadcrumb
|
||||
BreadcrumbBrandName string
|
||||
BreadcrumbBrandUrl string
|
||||
BreadcrumbFolderName string
|
||||
BreadcrumbFolderUrl string
|
||||
|
||||
// Post Message
|
||||
FileSharingPostMessage bool
|
||||
FileVersionPostMessage bool
|
||||
ClosePostMessage bool
|
||||
PostMessageOrigin string
|
||||
|
||||
// Other miscellaneous properties
|
||||
FileNameMaxLength int
|
||||
LastModifiedTime string
|
||||
|
||||
// User metadata
|
||||
IsAnonymousUser bool
|
||||
UserFriendlyName string
|
||||
UserId string
|
||||
OwnerId string
|
||||
|
||||
// Permission
|
||||
ReadOnly bool
|
||||
UserCanRename bool
|
||||
UserCanReview bool
|
||||
UserCanWrite bool
|
||||
UserCanNotWriteRelative bool
|
||||
|
||||
SupportsRename bool
|
||||
SupportsReviewing bool
|
||||
SupportsUpdate bool
|
||||
SupportsLocks bool
|
||||
|
||||
EnableShare bool
|
||||
}
|
||||
|
||||
type ViewerSessionResponse struct {
|
||||
Session *manager.ViewerSession `json:"session"`
|
||||
WopiSrc string `json:"wopi_src,omitempty"`
|
||||
}
|
||||
|
||||
type ListResponse struct {
|
||||
Files []FileResponse `json:"files"`
|
||||
Parent FileResponse `json:"parent,omitempty"`
|
||||
Pagination *inventory.PaginationResults `json:"pagination"`
|
||||
Props *fs.NavigatorProps `json:"props"`
|
||||
// ContextHint is used to speed up following operations under this listed directory.
|
||||
// It persists some intermedia state so that the following request don't need to query database again.
|
||||
// All the operations under this directory that supports context hint should carry this value in header
|
||||
// as X-Cr-Context-Hint.
|
||||
ContextHint *uuid.UUID `json:"context_hint"`
|
||||
RecursionLimitReached bool `json:"recursion_limit_reached,omitempty"`
|
||||
MixedType bool `json:"mixed_type"`
|
||||
SingleFileView bool `json:"single_file_view,omitempty"`
|
||||
StoragePolicy *StoragePolicy `json:"storage_policy,omitempty"`
|
||||
View *types.ExplorerView `json:"view,omitempty"`
|
||||
}
|
||||
|
||||
type FileResponse struct {
|
||||
Type int `json:"type"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Size int64 `json:"size"`
|
||||
Metadata map[string]string `json:"metadata"`
|
||||
Path string `json:"path,omitempty"`
|
||||
Shared bool `json:"shared,omitempty"`
|
||||
Capability *boolset.BooleanSet `json:"capability,omitempty"`
|
||||
Owned bool `json:"owned,omitempty"`
|
||||
PrimaryEntity string `json:"primary_entity,omitempty"`
|
||||
|
||||
FolderSummary *fs.FolderSummary `json:"folder_summary,omitempty"`
|
||||
ExtendedInfo *ExtendedInfo `json:"extended_info,omitempty"`
|
||||
}
|
||||
|
||||
type ExtendedInfo struct {
|
||||
StoragePolicy *StoragePolicy `json:"storage_policy,omitempty"`
|
||||
StorageUsed int64 `json:"storage_used"`
|
||||
Shares []Share `json:"shares,omitempty"`
|
||||
Entities []Entity `json:"entities,omitempty"`
|
||||
View *types.ExplorerView `json:"view,omitempty"`
|
||||
DirectLinks []DirectLink `json:"direct_links,omitempty"`
|
||||
}
|
||||
|
||||
type DirectLink struct {
|
||||
ID string `json:"id"`
|
||||
URL string `json:"url"`
|
||||
Downloaded int `json:"downloaded"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type StoragePolicy struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
AllowedSuffix []string `json:"allowed_suffix,omitempty"`
|
||||
DeniedSuffix []string `json:"denied_suffix,omitempty"`
|
||||
AllowedNameRegexp string `json:"allowed_name_regexp,omitempty"`
|
||||
DeniedNameRegexp string `json:"denied_name_regexp,omitempty"`
|
||||
Type types.PolicyType `json:"type"`
|
||||
MaxSize int64 `json:"max_size"`
|
||||
Relay bool `json:"relay,omitempty"`
|
||||
ChunkConcurrency int `json:"chunk_concurrency,omitempty"`
|
||||
Encryption bool `json:"encryption,omitempty"`
|
||||
}
|
||||
|
||||
type Entity struct {
|
||||
ID string `json:"id"`
|
||||
Size int64 `json:"size"`
|
||||
Type types.EntityType `json:"type"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
StoragePolicy *StoragePolicy `json:"storage_policy,omitempty"`
|
||||
CreatedBy *user.User `json:"created_by,omitempty"`
|
||||
EncryptedWith types.Cipher `json:"encrypted_with,omitempty"`
|
||||
}
|
||||
|
||||
type Share struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name,omitempty"`
|
||||
RemainDownloads *int `json:"remain_downloads,omitempty"`
|
||||
Visited int `json:"visited"`
|
||||
Downloaded int `json:"downloaded,omitempty"`
|
||||
Expires *time.Time `json:"expires,omitempty"`
|
||||
Unlocked bool `json:"unlocked"`
|
||||
PasswordProtected bool `json:"password_protected,omitempty"`
|
||||
SourceType *types.FileType `json:"source_type,omitempty"`
|
||||
Owner user.User `json:"owner"`
|
||||
CreatedAt time.Time `json:"created_at,omitempty"`
|
||||
Expired bool `json:"expired"`
|
||||
Url string `json:"url"`
|
||||
ShowReadMe bool `json:"show_readme,omitempty"`
|
||||
Size int64 `json:"size"`
|
||||
|
||||
// Only viewable by owner
|
||||
IsPrivate bool `json:"is_private,omitempty"`
|
||||
Password string `json:"password,omitempty"`
|
||||
ShareView bool `json:"share_view,omitempty"`
|
||||
|
||||
// Only viewable if explicitly unlocked by owner
|
||||
SourceUri string `json:"source_uri,omitempty"`
|
||||
}
|
||||
|
||||
func BuildShare(ctx context.Context, s *ent.Share, base *url.URL, hasher hashid.Encoder, requester *ent.User, owner *ent.User,
|
||||
name string, t types.FileType, unlocked bool, expired bool) *Share {
|
||||
redactLevel := user.RedactLevelAnonymous
|
||||
if !inventory.IsAnonymousUser(requester) {
|
||||
redactLevel = user.RedactLevelUser
|
||||
}
|
||||
res := Share{
|
||||
Name: name,
|
||||
ID: hashid.EncodeShareID(hasher, s.ID),
|
||||
Unlocked: unlocked,
|
||||
Owner: user.BuildUserRedacted(ctx, owner, redactLevel, hasher),
|
||||
Expired: inventory.IsShareExpired(s) != nil || expired,
|
||||
Url: BuildShareLink(s, hasher, base, unlocked),
|
||||
CreatedAt: s.CreatedAt,
|
||||
Visited: s.Views,
|
||||
SourceType: util.ToPtr(t),
|
||||
PasswordProtected: s.Password != "",
|
||||
}
|
||||
|
||||
if unlocked {
|
||||
res.RemainDownloads = s.RemainDownloads
|
||||
res.Downloaded = s.Downloads
|
||||
res.Expires = s.Expires
|
||||
res.Password = s.Password
|
||||
res.ShowReadMe = s.Props != nil && s.Props.ShowReadMe
|
||||
|
||||
if t == types.FileTypeFile && s.Edges.File != nil {
|
||||
res.Size = s.Edges.File.Size
|
||||
}
|
||||
}
|
||||
|
||||
if requester.ID == owner.ID {
|
||||
res.IsPrivate = s.Password != ""
|
||||
res.ShareView = s.Props != nil && s.Props.ShareView
|
||||
}
|
||||
|
||||
return &res
|
||||
}
|
||||
|
||||
func BuildListResponse(ctx context.Context, u *ent.User, parent fs.File, res *fs.ListFileResult, hasher hashid.Encoder) *ListResponse {
|
||||
r := &ListResponse{
|
||||
Files: lo.Map(res.Files, func(f fs.File, index int) FileResponse {
|
||||
return *BuildFileResponse(ctx, u, f, hasher, res.Props.Capability)
|
||||
}),
|
||||
Pagination: res.Pagination,
|
||||
Props: res.Props,
|
||||
ContextHint: res.ContextHint,
|
||||
RecursionLimitReached: res.RecursionLimitReached,
|
||||
MixedType: res.MixedType,
|
||||
SingleFileView: res.SingleFileView,
|
||||
StoragePolicy: BuildStoragePolicy(res.StoragePolicy, hasher),
|
||||
View: res.View,
|
||||
}
|
||||
|
||||
if !res.Parent.IsNil() {
|
||||
r.Parent = *BuildFileResponse(ctx, u, res.Parent, hasher, res.Props.Capability)
|
||||
}
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
func BuildFileResponse(ctx context.Context, u *ent.User, f fs.File, hasher hashid.Encoder, cap *boolset.BooleanSet) *FileResponse {
|
||||
var owner *ent.User
|
||||
if f != nil {
|
||||
owner = f.Owner()
|
||||
}
|
||||
|
||||
if cap == nil {
|
||||
cap = f.Capabilities()
|
||||
}
|
||||
|
||||
res := &FileResponse{
|
||||
Type: int(f.Type()),
|
||||
ID: hashid.EncodeFileID(hasher, f.ID()),
|
||||
Name: f.DisplayName(),
|
||||
CreatedAt: f.CreatedAt(),
|
||||
UpdatedAt: f.UpdatedAt(),
|
||||
Size: f.Size(),
|
||||
Metadata: f.Metadata(),
|
||||
Path: f.Uri(false).String(),
|
||||
Shared: f.Shared(),
|
||||
Capability: cap,
|
||||
Owned: owner == nil || owner.ID == u.ID,
|
||||
FolderSummary: f.FolderSummary(),
|
||||
ExtendedInfo: BuildExtendedInfo(ctx, u, f, hasher),
|
||||
PrimaryEntity: hashid.EncodeEntityID(hasher, f.PrimaryEntityID()),
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func BuildExtendedInfo(ctx context.Context, u *ent.User, f fs.File, hasher hashid.Encoder) *ExtendedInfo {
|
||||
extendedInfo := f.ExtendedInfo()
|
||||
if extendedInfo == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
dep := dependency.FromContext(ctx)
|
||||
base := dep.SettingProvider().SiteURL(ctx)
|
||||
|
||||
ext := &ExtendedInfo{
|
||||
StoragePolicy: BuildStoragePolicy(extendedInfo.StoragePolicy, hasher),
|
||||
StorageUsed: extendedInfo.StorageUsed,
|
||||
Entities: lo.Map(f.Entities(), func(e fs.Entity, index int) Entity {
|
||||
return BuildEntity(ctx, extendedInfo, e, hasher)
|
||||
}),
|
||||
DirectLinks: lo.Map(extendedInfo.DirectLinks, func(d *ent.DirectLink, index int) DirectLink {
|
||||
return BuildDirectLink(d, hasher, base)
|
||||
}),
|
||||
}
|
||||
|
||||
if u.ID == f.OwnerID() {
|
||||
// Only owner can see the shares settings.
|
||||
ext.Shares = lo.Map(extendedInfo.Shares, func(s *ent.Share, index int) Share {
|
||||
return *BuildShare(ctx, s, base, hasher, u, u, f.DisplayName(), f.Type(), true, false)
|
||||
})
|
||||
ext.View = extendedInfo.View
|
||||
}
|
||||
|
||||
return ext
|
||||
}
|
||||
|
||||
func BuildDirectLink(d *ent.DirectLink, hasher hashid.Encoder, base *url.URL) DirectLink {
|
||||
return DirectLink{
|
||||
ID: hashid.EncodeSourceLinkID(hasher, d.ID),
|
||||
URL: routes.MasterDirectLink(base, hashid.EncodeSourceLinkID(hasher, d.ID), d.Name).String(),
|
||||
Downloaded: d.Downloads,
|
||||
CreatedAt: d.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func BuildEntity(ctx context.Context, extendedInfo *fs.FileExtendedInfo, e fs.Entity, hasher hashid.Encoder) Entity {
|
||||
var u *user.User
|
||||
createdBy := e.CreatedBy()
|
||||
if createdBy != nil {
|
||||
userRedacted := user.BuildUserRedacted(ctx, e.CreatedBy(), user.RedactLevelAnonymous, hasher)
|
||||
u = &userRedacted
|
||||
}
|
||||
|
||||
encryptedWith := types.Cipher("")
|
||||
if e.Encrypted() {
|
||||
encryptedWith = e.Props().EncryptMetadata.Algorithm
|
||||
}
|
||||
|
||||
return Entity{
|
||||
ID: hashid.EncodeEntityID(hasher, e.ID()),
|
||||
Type: e.Type(),
|
||||
CreatedAt: e.CreatedAt(),
|
||||
StoragePolicy: BuildStoragePolicy(extendedInfo.EntityStoragePolicies[e.PolicyID()], hasher),
|
||||
Size: e.Size(),
|
||||
CreatedBy: u,
|
||||
EncryptedWith: encryptedWith,
|
||||
}
|
||||
}
|
||||
|
||||
func BuildShareLink(s *ent.Share, hasher hashid.Encoder, base *url.URL, unlocked bool) string {
|
||||
shareId := hashid.EncodeShareID(hasher, s.ID)
|
||||
if unlocked {
|
||||
return routes.MasterShareUrl(base, shareId, s.Password).String()
|
||||
}
|
||||
return routes.MasterShareUrl(base, shareId, "").String()
|
||||
}
|
||||
|
||||
func BuildStoragePolicy(sp *ent.StoragePolicy, hasher hashid.Encoder) *StoragePolicy {
|
||||
if sp == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
res := &StoragePolicy{
|
||||
ID: hashid.EncodePolicyID(hasher, sp.ID),
|
||||
Name: sp.Name,
|
||||
Type: types.PolicyType(sp.Type),
|
||||
MaxSize: sp.MaxSize,
|
||||
Relay: sp.Settings.Relay,
|
||||
ChunkConcurrency: sp.Settings.ChunkConcurrency,
|
||||
Encryption: sp.Settings.Encryption,
|
||||
}
|
||||
|
||||
if sp.Settings.IsFileTypeDenyList {
|
||||
res.DeniedSuffix = sp.Settings.FileType
|
||||
} else {
|
||||
res.AllowedSuffix = sp.Settings.FileType
|
||||
}
|
||||
|
||||
if sp.Settings.NameRegexp != "" {
|
||||
if sp.Settings.IsNameRegexpDenyList {
|
||||
res.DeniedNameRegexp = sp.Settings.NameRegexp
|
||||
} else {
|
||||
res.AllowedNameRegexp = sp.Settings.NameRegexp
|
||||
}
|
||||
}
|
||||
|
||||
return res
|
||||
}
|
||||
|
||||
func WriteEventSourceHeader(c *gin.Context) {
|
||||
c.Header("Content-Type", "text/event-stream")
|
||||
c.Header("Cache-Control", "no-cache")
|
||||
c.Header("X-Accel-Buffering", "no")
|
||||
}
|
||||
|
||||
// WriteEventSource writes a Server-Sent Event to the client.
|
||||
func WriteEventSource(c *gin.Context, event string, data any) {
|
||||
c.Writer.Write([]byte(fmt.Sprintf("event: %s\n", event)))
|
||||
c.Writer.Write([]byte("data:"))
|
||||
json.NewEncoder(c.Writer).Encode(data)
|
||||
c.Writer.Write([]byte("\n"))
|
||||
c.Writer.Flush()
|
||||
}
|
||||
|
||||
var ErrSSETakeOver = errors.New("SSE take over")
|
||||
@@ -0,0 +1,270 @@
|
||||
package explorer
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/cloudreve/Cloudreve/v4/application/dependency"
|
||||
"github.com/cloudreve/Cloudreve/v4/inventory/types"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/cluster/routes"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/filemanager/driver"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/filemanager/driver/local"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/filemanager/fs"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/filemanager/manager"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/filemanager/manager/entitysource"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/mediameta"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/serializer"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/samber/lo"
|
||||
)
|
||||
|
||||
// SlaveDownloadService 从机文件下載服务
|
||||
type SlaveDownloadService struct {
|
||||
PathEncoded string `uri:"path" binding:"required"`
|
||||
Name string `uri:"name" binding:"required"`
|
||||
Speed int `uri:"speed" binding:"min=0"`
|
||||
}
|
||||
|
||||
// SlaveFileService 从机单文件文件相关服务
|
||||
type SlaveFileService struct {
|
||||
PathEncoded string `uri:"path" binding:"required"`
|
||||
Ext string `uri:"ext"`
|
||||
}
|
||||
|
||||
// SlaveFilesService 从机多文件相关服务
|
||||
type SlaveFilesService struct {
|
||||
Files []string `json:"files" binding:"required,gt=0"`
|
||||
}
|
||||
|
||||
// SlaveServe serves file content
|
||||
func (s *EntityDownloadService) SlaveServe(c *gin.Context) error {
|
||||
dep := dependency.FromContext(c)
|
||||
m := manager.NewFileManager(dep, nil)
|
||||
defer m.Recycle()
|
||||
|
||||
src, err := base64.URLEncoding.DecodeString(s.Src)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to decode src: %w", err)
|
||||
}
|
||||
|
||||
entity, err := local.NewLocalFileEntity(types.EntityTypeVersion, string(src))
|
||||
if err != nil {
|
||||
return fs.ErrPathNotExist.WithError(err)
|
||||
}
|
||||
|
||||
entitySource, err := m.GetEntitySource(c, 0, fs.WithEntity(entity))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get entity source: %w", err)
|
||||
}
|
||||
|
||||
defer entitySource.Close()
|
||||
|
||||
// Set cache header for public resource
|
||||
settings := dep.SettingProvider()
|
||||
maxAge := settings.PublicResourceMaxAge(c)
|
||||
c.Header("Cache-Control", fmt.Sprintf("public, max-age=%d", maxAge))
|
||||
|
||||
isDownload := c.Query(routes.IsDownloadQuery) != ""
|
||||
entitySource.Serve(c.Writer, c.Request,
|
||||
entitysource.WithSpeedLimit(s.SpeedLimit),
|
||||
entitysource.WithDownload(isDownload),
|
||||
entitysource.WithDisplayName(s.Name),
|
||||
entitysource.WithContext(c),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
type (
|
||||
SlaveCreateUploadSessionParamCtx struct{}
|
||||
// SlaveCreateUploadSessionService 从机上传会话服务
|
||||
SlaveCreateUploadSessionService struct {
|
||||
Session fs.UploadSession `json:"session" binding:"required"`
|
||||
Overwrite bool `json:"overwrite"`
|
||||
}
|
||||
)
|
||||
|
||||
// Create 从机创建上传会话
|
||||
func (service *SlaveCreateUploadSessionService) Create(c *gin.Context) error {
|
||||
mode := fs.ModeNone
|
||||
if service.Overwrite {
|
||||
mode = fs.ModeOverwrite
|
||||
}
|
||||
|
||||
req := &fs.UploadRequest{
|
||||
Mode: mode,
|
||||
Props: service.Session.Props.Copy(),
|
||||
}
|
||||
|
||||
dep := dependency.FromContext(c)
|
||||
m := manager.NewFileManager(dep, nil)
|
||||
_, err := m.CreateUploadSession(c, req, fs.WithUploadSession(&service.Session))
|
||||
if err != nil {
|
||||
return serializer.NewError(serializer.CodeCacheOperation, "Failed to create upload session in slave node", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type (
|
||||
SlaveMetaParamCtx struct{}
|
||||
SlaveMetaService struct {
|
||||
Src string `uri:"src" binding:"required"`
|
||||
Ext string `uri:"ext" binding:"required"`
|
||||
}
|
||||
)
|
||||
|
||||
// MediaMeta retrieves media metadata
|
||||
func (s *SlaveMetaService) MediaMeta(c *gin.Context) ([]driver.MediaMeta, error) {
|
||||
dep := dependency.FromContext(c)
|
||||
m := manager.NewFileManager(dep, nil)
|
||||
defer m.Recycle()
|
||||
|
||||
src, err := base64.URLEncoding.DecodeString(s.Src)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decode src: %w", err)
|
||||
}
|
||||
|
||||
entity, err := local.NewLocalFileEntity(types.EntityTypeVersion, string(src))
|
||||
if err != nil {
|
||||
return nil, fs.ErrPathNotExist.WithError(err)
|
||||
}
|
||||
|
||||
entitySource, err := m.GetEntitySource(c, 0, fs.WithEntity(entity))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get entity source: %w", err)
|
||||
}
|
||||
defer entitySource.Close()
|
||||
|
||||
language := c.Query("language")
|
||||
extractor := dep.MediaMetaExtractor(c)
|
||||
res, err := extractor.Extract(c, s.Ext, entitySource, mediameta.WithLanguage(language))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to extract media meta: %w", err)
|
||||
}
|
||||
|
||||
return res, nil
|
||||
}
|
||||
|
||||
type (
|
||||
SlaveThumbParamCtx struct{}
|
||||
SlaveThumbService struct {
|
||||
Src string `uri:"src" binding:"required"`
|
||||
Ext string `uri:"ext" binding:"required"`
|
||||
}
|
||||
)
|
||||
|
||||
func (s *SlaveThumbService) Thumb(c *gin.Context) error {
|
||||
dep := dependency.FromContext(c)
|
||||
m := manager.NewFileManager(dep, nil)
|
||||
defer m.Recycle()
|
||||
|
||||
src, err := base64.URLEncoding.DecodeString(s.Src)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to decode src: %w", err)
|
||||
}
|
||||
|
||||
settings := dep.SettingProvider()
|
||||
var entity fs.Entity
|
||||
entity, err = local.NewLocalFileEntity(types.EntityTypeThumbnail, string(src)+settings.ThumbSlaveSidecarSuffix(c))
|
||||
if err != nil {
|
||||
srcEntity, err := local.NewLocalFileEntity(types.EntityTypeVersion, string(src))
|
||||
if err != nil {
|
||||
return fs.ErrPathNotExist.WithError(err)
|
||||
}
|
||||
|
||||
entity, err = m.SubmitAndAwaitThumbnailTask(c, nil, s.Ext, srcEntity)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to submit and await thumbnail task: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
entitySource, err := m.GetEntitySource(c, 0, fs.WithEntity(entity))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get thumb entity source: %w", err)
|
||||
}
|
||||
|
||||
defer entitySource.Close()
|
||||
|
||||
// Set cache header for public resource
|
||||
maxAge := settings.PublicResourceMaxAge(c)
|
||||
c.Header("Cache-Control", fmt.Sprintf("public, max-age=%d", maxAge))
|
||||
|
||||
entitySource.Serve(c.Writer, c.Request,
|
||||
entitysource.WithContext(c),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
type (
|
||||
SlaveDeleteUploadSessionParamCtx struct{}
|
||||
SlaveDeleteUploadSessionService struct {
|
||||
ID string `uri:"sessionId" binding:"required"`
|
||||
}
|
||||
)
|
||||
|
||||
// Delete deletes an upload session from slave node
|
||||
func (service *SlaveDeleteUploadSessionService) Delete(c *gin.Context) error {
|
||||
dep := dependency.FromContext(c)
|
||||
m := manager.NewFileManager(dep, nil)
|
||||
defer m.Recycle()
|
||||
|
||||
err := m.CancelUploadSession(c, nil, service.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("slave failed to delete upload session: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type (
|
||||
SlaveDeleteFileParamCtx struct{}
|
||||
SlaveDeleteFileService struct {
|
||||
Files []string `json:"files" binding:"required,gt=0"`
|
||||
}
|
||||
)
|
||||
|
||||
func (service *SlaveDeleteFileService) Delete(c *gin.Context) ([]string, error) {
|
||||
dep := dependency.FromContext(c)
|
||||
m := manager.NewFileManager(dep, nil)
|
||||
defer m.Recycle()
|
||||
d := m.LocalDriver(nil)
|
||||
|
||||
// Try to delete thumbnail sidecar
|
||||
sidecarSuffix := dep.SettingProvider().ThumbSlaveSidecarSuffix(c)
|
||||
failed, err := d.Delete(c, lo.Map(service.Files, func(item string, index int) string {
|
||||
return item + sidecarSuffix
|
||||
})...)
|
||||
if err != nil {
|
||||
dep.Logger().Warning("Failed to delete thumbnail sidecar [%s]: %s", strings.Join(failed, ", "), err)
|
||||
}
|
||||
|
||||
failed, err = d.Delete(c, service.Files...)
|
||||
if err != nil {
|
||||
return failed, fmt.Errorf("slave failed to delete file: %w", err)
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
type (
|
||||
SlaveListParamCtx struct{}
|
||||
SlaveListService struct {
|
||||
Path string `form:"path" binding:"required"`
|
||||
Recursive bool `form:"recursive"`
|
||||
}
|
||||
)
|
||||
|
||||
func (s *SlaveListService) List(c *gin.Context) ([]fs.PhysicalObject, error) {
|
||||
dep := dependency.FromContext(c)
|
||||
m := manager.NewFileManager(dep, nil)
|
||||
defer m.Recycle()
|
||||
d := m.LocalDriver(nil)
|
||||
|
||||
objects, err := d.List(c, s.Path, func(i int) {}, s.Recursive)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list files: %w", err)
|
||||
}
|
||||
|
||||
return objects, nil
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
package explorer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/cloudreve/Cloudreve/v4/application/dependency"
|
||||
"github.com/cloudreve/Cloudreve/v4/inventory"
|
||||
"github.com/cloudreve/Cloudreve/v4/inventory/types"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/cluster"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/filemanager/fs"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/filemanager/manager"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/hashid"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/request"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/serializer"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// CreateUploadSessionService 获取上传凭证服务
|
||||
type (
|
||||
CreateUploadSessionParameterCtx struct{}
|
||||
CreateUploadSessionService struct {
|
||||
Uri string `json:"uri" binding:"required"`
|
||||
Size int64 `json:"size" binding:"min=0"`
|
||||
LastModified int64 `json:"last_modified"`
|
||||
MimeType string `json:"mime_type"`
|
||||
PolicyID string `json:"policy_id"`
|
||||
Metadata map[string]string `json:"metadata" binding:"max=256"`
|
||||
EntityType string `json:"entity_type" binding:"eq=|eq=live_photo|eq=version"`
|
||||
EncryptionSupported []types.Cipher `json:"encryption_supported"`
|
||||
Previous string `form:"previous"`
|
||||
}
|
||||
)
|
||||
|
||||
// Create 创建新的上传会话
|
||||
func (service *CreateUploadSessionService) Create(c context.Context) (*UploadSessionResponse, error) {
|
||||
dep := dependency.FromContext(c)
|
||||
user := inventory.UserFromContext(c)
|
||||
m := manager.NewFileManager(dep, user)
|
||||
defer m.Recycle()
|
||||
|
||||
uri, err := fs.NewUriFromString(service.Uri)
|
||||
if err != nil {
|
||||
return nil, serializer.NewError(serializer.CodeParamErr, "unknown uri", err)
|
||||
}
|
||||
|
||||
var entityType *types.EntityType
|
||||
switch service.EntityType {
|
||||
case "live_photo":
|
||||
livePhoto := types.EntityTypeLivePhoto
|
||||
entityType = &livePhoto
|
||||
case "version":
|
||||
version := types.EntityTypeVersion
|
||||
entityType = &version
|
||||
}
|
||||
|
||||
hasher := dep.HashIDEncoder()
|
||||
policyId := 0
|
||||
if service.PolicyID != "" {
|
||||
policyId, err = hasher.Decode(service.PolicyID, hashid.PolicyID)
|
||||
if err != nil {
|
||||
return nil, serializer.NewError(serializer.CodeParamErr, "unknown policy id", err)
|
||||
}
|
||||
}
|
||||
|
||||
uploadRequest := &fs.UploadRequest{
|
||||
Props: &fs.UploadProps{
|
||||
Uri: uri,
|
||||
Size: service.Size,
|
||||
|
||||
PreviousVersion: service.Previous,
|
||||
MimeType: service.MimeType,
|
||||
Metadata: service.Metadata,
|
||||
EntityType: entityType,
|
||||
PreferredStoragePolicy: policyId,
|
||||
EncryptionSupported: service.EncryptionSupported,
|
||||
ClientSideEncrypted: len(service.EncryptionSupported) > 0,
|
||||
},
|
||||
}
|
||||
|
||||
if service.LastModified > 0 {
|
||||
lastModified := time.UnixMilli(service.LastModified)
|
||||
uploadRequest.Props.LastModified = &lastModified
|
||||
}
|
||||
|
||||
credential, err := m.CreateUploadSession(c, uploadRequest)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return BuildUploadSessionResponse(credential, hasher), nil
|
||||
}
|
||||
|
||||
type (
|
||||
UploadParameterCtx struct{}
|
||||
// UploadService 本机及从机策略上传服务
|
||||
UploadService struct {
|
||||
ID string `uri:"sessionId" binding:"required"`
|
||||
Index int `uri:"index" form:"index" binding:"min=0"`
|
||||
}
|
||||
)
|
||||
|
||||
// LocalUpload 处理本机文件分片上传
|
||||
func (service *UploadService) LocalUpload(c *gin.Context) error {
|
||||
dep := dependency.FromContext(c)
|
||||
kv := dep.KV()
|
||||
|
||||
uploadSessionRaw, ok := kv.Get(manager.UploadSessionCachePrefix + service.ID)
|
||||
if !ok {
|
||||
return serializer.NewError(serializer.CodeUploadSessionExpired, "", nil)
|
||||
}
|
||||
|
||||
uploadSession := uploadSessionRaw.(fs.UploadSession)
|
||||
|
||||
user := inventory.UserFromContext(c)
|
||||
m := manager.NewFileManager(dep, user)
|
||||
defer m.Recycle()
|
||||
|
||||
if uploadSession.UID != user.ID {
|
||||
return serializer.NewError(serializer.CodeUploadSessionExpired, "", nil)
|
||||
}
|
||||
|
||||
// Confirm upload session and chunk index
|
||||
placeholder, err := m.ConfirmUploadSession(c, &uploadSession, service.Index)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return processChunkUpload(c, m, &uploadSession, service.Index, placeholder, fs.ModeOverwrite)
|
||||
}
|
||||
|
||||
// SlaveUpload 处理从机文件分片上传
|
||||
func (service *UploadService) SlaveUpload(c *gin.Context) error {
|
||||
dep := dependency.FromContext(c)
|
||||
kv := dep.KV()
|
||||
|
||||
uploadSessionRaw, ok := kv.Get(manager.UploadSessionCachePrefix + service.ID)
|
||||
if !ok {
|
||||
return serializer.NewError(serializer.CodeUploadSessionExpired, "", nil)
|
||||
}
|
||||
|
||||
uploadSession := uploadSessionRaw.(fs.UploadSession)
|
||||
uploadSession.Props.ClientSideEncrypted = true
|
||||
|
||||
// Parse chunk index from query
|
||||
service.Index, _ = strconv.Atoi(c.Query("chunk"))
|
||||
|
||||
m := manager.NewFileManager(dep, nil)
|
||||
defer m.Recycle()
|
||||
|
||||
return processChunkUpload(c, m, &uploadSession, service.Index, nil, fs.ModeOverwrite)
|
||||
}
|
||||
|
||||
func processChunkUpload(c *gin.Context, m manager.FileManager, session *fs.UploadSession, index int, file fs.File, mode fs.WriteMode) error {
|
||||
// 取得并校验文件大小是否符合分片要求
|
||||
chunkSize := session.ChunkSize
|
||||
isLastChunk := session.ChunkSize == 0 || int64(index+1)*chunkSize >= session.Props.Size
|
||||
expectedLength := chunkSize
|
||||
if isLastChunk {
|
||||
expectedLength = session.Props.Size - int64(index)*chunkSize
|
||||
}
|
||||
|
||||
rc, fileSize, err := request.SniffContentLength(c.Request)
|
||||
if err != nil || (expectedLength != fileSize) {
|
||||
return serializer.NewError(
|
||||
serializer.CodeInvalidContentLength,
|
||||
fmt.Sprintf("Invalid Content-Length (expected: %d)", expectedLength),
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
// 非首个分片时需要允许覆盖
|
||||
if index > 0 {
|
||||
mode |= fs.ModeOverwrite
|
||||
}
|
||||
|
||||
req := &fs.UploadRequest{
|
||||
File: rc,
|
||||
Offset: chunkSize * int64(index),
|
||||
Props: session.Props.Copy(),
|
||||
Mode: mode,
|
||||
}
|
||||
|
||||
// 执行上传
|
||||
ctx := context.WithValue(c, cluster.SlaveNodeIDCtx{}, strconv.Itoa(session.Policy.NodeID))
|
||||
err = m.Upload(ctx, req, session.Policy, session)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if rc, ok := req.File.(request.LimitReaderCloser); ok {
|
||||
if rc.Count() != expectedLength {
|
||||
err := fmt.Errorf("uploaded data(%d) does not match purposed size(%d)", rc.Count(), req.Props.Size)
|
||||
return serializer.NewError(serializer.CodeIOFailed, "Uploaded data does not match purposed size", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Finish upload only after every chunk has been received. When the client
|
||||
// uploads chunks concurrently the last-indexed chunk can arrive before some
|
||||
// earlier chunks; relying solely on isLastChunk would trigger CompleteUpload
|
||||
// prematurely and truncate later-arriving chunks.
|
||||
allReceived, err := m.MarkChunkUploaded(ctx, session, index)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if allReceived {
|
||||
_, err := m.CompleteUpload(ctx, session)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to complete upload: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type (
|
||||
DeleteUploadSessionParameterCtx struct{}
|
||||
DeleteUploadSessionService struct {
|
||||
ID string `json:"id" binding:"required"`
|
||||
Uri string `json:"uri" binding:"required"`
|
||||
}
|
||||
)
|
||||
|
||||
// Delete deletes the specified upload session
|
||||
func (service *DeleteUploadSessionService) Delete(c *gin.Context) error {
|
||||
dep := dependency.FromContext(c)
|
||||
user := inventory.UserFromContext(c)
|
||||
m := manager.NewFileManager(dep, user)
|
||||
defer m.Recycle()
|
||||
|
||||
uri, err := fs.NewUriFromString(service.Uri)
|
||||
if err != nil {
|
||||
return serializer.NewError(serializer.CodeParamErr, "unknown uri", err)
|
||||
}
|
||||
|
||||
return m.CancelUploadSession(c, uri, service.ID)
|
||||
}
|
||||
@@ -0,0 +1,446 @@
|
||||
package explorer
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/cloudreve/Cloudreve/v4/application/constants"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/cloudreve/Cloudreve/v4/application/dependency"
|
||||
"github.com/cloudreve/Cloudreve/v4/ent"
|
||||
"github.com/cloudreve/Cloudreve/v4/inventory"
|
||||
"github.com/cloudreve/Cloudreve/v4/inventory/types"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/cluster/routes"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/filemanager/fs"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/filemanager/fs/dbfs"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/filemanager/lock"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/filemanager/manager"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/filemanager/manager/entitysource"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/hashid"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/serializer"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/wopi"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type WopiService struct {
|
||||
}
|
||||
|
||||
func prepareFs(c *gin.Context) (*fs.URI, manager.FileManager, *ent.User, *manager.ViewerSessionCache, dependency.Dep, error) {
|
||||
dep := dependency.FromContext(c)
|
||||
user := inventory.UserFromContext(c)
|
||||
m := manager.NewFileManager(dep, user)
|
||||
defer m.Recycle()
|
||||
|
||||
viewerSession := manager.ViewerSessionFromContext(c)
|
||||
uri, err := fs.NewUriFromString(viewerSession.Uri)
|
||||
if err != nil {
|
||||
return nil, nil, nil, nil, nil, serializer.NewError(serializer.CodeParamErr, "unknown uri", err)
|
||||
}
|
||||
|
||||
return uri, m, user, viewerSession, dep, nil
|
||||
}
|
||||
|
||||
func (service *WopiService) Unlock(c *gin.Context) error {
|
||||
_, m, _, _, dep, err := prepareFs(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
l := dep.Logger()
|
||||
|
||||
lockToken := c.GetHeader(wopi.LockTokenHeader)
|
||||
if err = m.Unlock(c, lockToken); err != nil {
|
||||
l.Debug("WOPI unlock, not locked or not match: %w", err)
|
||||
c.Status(http.StatusConflict)
|
||||
c.Header(wopi.LockTokenHeader, "")
|
||||
return nil
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (service *WopiService) RefreshLock(c *gin.Context) error {
|
||||
uri, m, _, _, dep, err := prepareFs(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
l := dep.Logger()
|
||||
|
||||
// Make sure file exists and readable
|
||||
file, err := m.Get(c, uri, dbfs.WithRequiredCapabilities(dbfs.NavigatorCapabilityLockFile), dbfs.WithNotRoot())
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get file: %w", err)
|
||||
}
|
||||
|
||||
lockToken := c.GetHeader(wopi.LockTokenHeader)
|
||||
release, _, err := m.ConfirmLock(c, file, file.Uri(false), lockToken)
|
||||
if err != nil {
|
||||
// File not locked for token not match
|
||||
|
||||
l.Debug("WOPI refresh lock, not locked or not match: %w", err)
|
||||
c.Status(http.StatusConflict)
|
||||
c.Header(wopi.LockTokenHeader, "")
|
||||
return nil
|
||||
|
||||
}
|
||||
|
||||
// refresh lock
|
||||
release()
|
||||
_, err = m.Refresh(c, wopi.LockDuration, lockToken)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
c.Header(wopi.LockTokenHeader, lockToken)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (service *WopiService) Lock(c *gin.Context) error {
|
||||
uri, m, user, viewerSession, dep, err := prepareFs(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
l := dep.Logger()
|
||||
|
||||
// Make sure file exists and readable
|
||||
file, err := m.Get(c, uri, dbfs.WithRequiredCapabilities(dbfs.NavigatorCapabilityLockFile), dbfs.WithNotRoot())
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get file: %w", err)
|
||||
}
|
||||
|
||||
lockToken := c.GetHeader(wopi.LockTokenHeader)
|
||||
release, _, err := m.ConfirmLock(c, file, file.Uri(false), lockToken)
|
||||
if err != nil {
|
||||
// File not locked for token not match
|
||||
|
||||
// Try to lock using given token
|
||||
app := lock.Application{
|
||||
Type: string(fs.ApplicationViewer),
|
||||
ViewerID: viewerSession.ViewerID,
|
||||
}
|
||||
_, err = m.Lock(c, wopi.LockDuration, user, true, app, file.Uri(false), lockToken)
|
||||
if err != nil {
|
||||
// Token not match
|
||||
var lockConflict lock.ConflictError
|
||||
if errors.As(err, &lockConflict) {
|
||||
c.Status(http.StatusConflict)
|
||||
c.Header(wopi.LockTokenHeader, lockConflict[0].Token)
|
||||
|
||||
l.Debug("WOPI lock, lock conflict: %w", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("failed to lock file: %w", err)
|
||||
}
|
||||
|
||||
// Lock success, return the token
|
||||
c.Header(wopi.LockTokenHeader, lockToken)
|
||||
return nil
|
||||
|
||||
}
|
||||
|
||||
// refresh lock
|
||||
release()
|
||||
_, err = m.Refresh(c, wopi.LockDuration, lockToken)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
c.Header(wopi.LockTokenHeader, lockToken)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (service *WopiService) PutContent(c *gin.Context, isPutRelative bool) error {
|
||||
uri, m, user, viewerSession, _, err := prepareFs(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Make sure file exists and readable
|
||||
file, err := m.Get(c, uri, dbfs.WithRequiredCapabilities(dbfs.NavigatorCapabilityUploadFile), dbfs.WithNotRoot())
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get file: %w", err)
|
||||
}
|
||||
|
||||
var lockSession fs.LockSession
|
||||
lockToken := c.GetHeader(wopi.LockTokenHeader)
|
||||
if lockToken != "" {
|
||||
// File not locked for token not match
|
||||
|
||||
release, ls, err := m.ConfirmLock(c, file, file.Uri(false), lockToken)
|
||||
if err != nil {
|
||||
// File not locked for token not match
|
||||
|
||||
// Try to lock using given token
|
||||
app := lock.Application{
|
||||
Type: string(fs.ApplicationViewer),
|
||||
ViewerID: viewerSession.ViewerID,
|
||||
}
|
||||
ls, err := m.Lock(c, wopi.LockDuration, user, true, app, file.Uri(false), lockToken)
|
||||
if err != nil {
|
||||
// Token not match
|
||||
// If the file is currently locked and the X-WOPI-Lock value doesn't match the lock currently on the file, the host must
|
||||
//
|
||||
// Return a lock mismatch response (409 Conflict)
|
||||
// Include an X-WOPI-Lock response header containing the value of the current lock on the file.
|
||||
var lockConflict lock.ConflictError
|
||||
if errors.As(err, &lockConflict) {
|
||||
c.Status(http.StatusConflict)
|
||||
c.Header(wopi.LockTokenHeader, lockConflict[0].Token)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("failed to lock file: %w", err)
|
||||
}
|
||||
|
||||
// In cases where the file is unlocked, the host must set X-WOPI-Lock to the empty string.
|
||||
c.Header(wopi.LockTokenHeader, "")
|
||||
_ = m.Unlock(c, ls.LastToken())
|
||||
} else {
|
||||
defer release()
|
||||
}
|
||||
|
||||
lockSession = ls
|
||||
}
|
||||
|
||||
fileUri := viewerSession.Uri
|
||||
if isPutRelative {
|
||||
// If the header contains only a file extension (starts with a period), then the resulting file name will consist of this extension and the initial file name without extension.
|
||||
// If the header contains a full file name, then it will be a name for the resulting file.
|
||||
fileName, err := wopi.UTF7Decode(c.GetHeader(wopi.SuggestedTargetHeader))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to decode X-WOPI-SuggestedTarget header (UTF-7): %w", err)
|
||||
}
|
||||
|
||||
// X-WOPI-SuggestedTarget is a filename, not a path. Reject any value
|
||||
// that could traverse out of the source file's directory.
|
||||
if !isValidWopiSuggestedTarget(fileName) {
|
||||
c.Status(http.StatusBadRequest)
|
||||
c.Header(wopi.InvalidFileNameHeader, "Invalid target file name")
|
||||
return nil
|
||||
}
|
||||
|
||||
fileUriParsed, err := fs.NewUriFromString(fileUri)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse file uri: %w", err)
|
||||
}
|
||||
|
||||
if strings.HasPrefix(fileName, ".") {
|
||||
fileName = strings.TrimSuffix(fileUriParsed.Name(), filepath.Ext(fileUriParsed.Name())) + fileName
|
||||
}
|
||||
fileUri = fileUriParsed.DirUri().JoinRaw(fileName).String()
|
||||
}
|
||||
|
||||
subService := FileUpdateService{
|
||||
Uri: fileUri,
|
||||
}
|
||||
|
||||
res, err := subService.PutContent(c, lockSession)
|
||||
if err != nil {
|
||||
var appErr serializer.AppError
|
||||
if errors.As(err, &appErr) {
|
||||
switch appErr.Code {
|
||||
case serializer.CodeFileTooLarge:
|
||||
c.Status(http.StatusRequestEntityTooLarge)
|
||||
c.Header(wopi.ServerErrorHeader, err.Error())
|
||||
case serializer.CodeNotFound:
|
||||
c.Status(http.StatusNotFound)
|
||||
c.Header(wopi.ServerErrorHeader, err.Error())
|
||||
case 0:
|
||||
c.Status(http.StatusOK)
|
||||
default:
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
c.Header(wopi.ItemVersionHeader, res.PrimaryEntity)
|
||||
if isPutRelative {
|
||||
c.JSON(http.StatusOK, PutRelativeResponse{
|
||||
Name: res.Name,
|
||||
})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// isValidWopiSuggestedTarget enforces that X-WOPI-SuggestedTarget is a bare
|
||||
// filename (or extension prefixed with ".") and cannot traverse out of the
|
||||
// source file's directory once joined onto it.
|
||||
func isValidWopiSuggestedTarget(name string) bool {
|
||||
if name == "" || name == "." || name == ".." {
|
||||
return false
|
||||
}
|
||||
if strings.ContainsAny(name, "/\\") {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (service *WopiService) GetFile(c *gin.Context) error {
|
||||
uri, m, _, viewerSession, dep, err := prepareFs(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Make sure file exists and readable
|
||||
file, err := m.Get(c, uri, dbfs.WithExtendedInfo(), dbfs.WithRequiredCapabilities(dbfs.NavigatorCapabilityDownloadFile), dbfs.WithNotRoot())
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get file: %w", err)
|
||||
}
|
||||
|
||||
versionType := types.EntityTypeVersion
|
||||
find, targetEntity := fs.FindDesiredEntity(file, viewerSession.Version, dep.HashIDEncoder(), &versionType)
|
||||
if !find {
|
||||
return serializer.NewError(serializer.CodeNotFound, "version not found", nil)
|
||||
}
|
||||
|
||||
if targetEntity.Size() > dep.SettingProvider().MaxOnlineEditSize(c) {
|
||||
return fs.ErrFileSizeTooBig
|
||||
}
|
||||
|
||||
entitySource, err := m.GetEntitySource(c, targetEntity.ID(), fs.WithEntity(targetEntity))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get entity source: %w", err)
|
||||
}
|
||||
|
||||
defer entitySource.Close()
|
||||
|
||||
entitySource.Serve(c.Writer, c.Request,
|
||||
entitysource.WithContext(c),
|
||||
)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (service *WopiService) FileInfo(c *gin.Context) (*WopiFileInfo, error) {
|
||||
uri, m, user, viewerSession, dep, err := prepareFs(c)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
hasher := dep.HashIDEncoder()
|
||||
settings := dep.SettingProvider()
|
||||
|
||||
opts := []fs.Option{
|
||||
dbfs.WithFilePublicMetadata(),
|
||||
dbfs.WithExtendedInfo(),
|
||||
dbfs.WithNotRoot(),
|
||||
dbfs.WithRequiredCapabilities(dbfs.NavigatorCapabilityDownloadFile, dbfs.NavigatorCapabilityInfo),
|
||||
}
|
||||
file, err := m.Get(c, uri, opts...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get file: %w", err)
|
||||
}
|
||||
|
||||
if file == nil {
|
||||
return nil, serializer.NewError(serializer.CodeNotFound, "file not found", nil)
|
||||
}
|
||||
|
||||
versionType := types.EntityTypeVersion
|
||||
find, targetEntity := fs.FindDesiredEntity(file, viewerSession.Version, hasher, &versionType)
|
||||
if !find {
|
||||
return nil, serializer.NewError(serializer.CodeNotFound, "version not found", nil)
|
||||
}
|
||||
|
||||
canEdit := file.PrimaryEntityID() == targetEntity.ID() && file.OwnerID() == user.ID && uri.FileSystem() == constants.FileSystemMy
|
||||
cantPutRelative := !canEdit
|
||||
siteUrl := settings.SiteURL(c)
|
||||
info := &WopiFileInfo{
|
||||
BaseFileName: file.DisplayName(),
|
||||
Version: hashid.EncodeEntityID(hasher, targetEntity.ID()),
|
||||
BreadcrumbBrandName: settings.SiteBasic(c).Name,
|
||||
BreadcrumbBrandUrl: siteUrl.String(),
|
||||
FileSharingPostMessage: file.OwnerID() == user.ID,
|
||||
EnableShare: file.OwnerID() == user.ID,
|
||||
FileVersionPostMessage: true,
|
||||
ClosePostMessage: true,
|
||||
PostMessageOrigin: "*",
|
||||
FileNameMaxLength: dbfs.MaxFileNameLength,
|
||||
LastModifiedTime: file.UpdatedAt().Format(time.RFC3339),
|
||||
IsAnonymousUser: inventory.IsAnonymousUser(user),
|
||||
UserFriendlyName: user.Nick,
|
||||
UserId: hashid.EncodeUserID(hasher, user.ID),
|
||||
ReadOnly: !canEdit,
|
||||
Size: targetEntity.Size(),
|
||||
OwnerId: hashid.EncodeUserID(hasher, file.OwnerID()),
|
||||
SupportsRename: true,
|
||||
SupportsReviewing: true,
|
||||
SupportsLocks: true,
|
||||
UserCanReview: canEdit,
|
||||
UserCanWrite: canEdit,
|
||||
UserCanNotWriteRelative: cantPutRelative,
|
||||
BreadcrumbFolderName: uri.Dir(),
|
||||
BreadcrumbFolderUrl: routes.FrontendHomeUrl(siteUrl, uri.DirUri().String()).String(),
|
||||
}
|
||||
|
||||
return info, nil
|
||||
}
|
||||
|
||||
type (
|
||||
CreateViewerSessionService struct {
|
||||
Uri string `json:"uri" form:"uri" binding:"required"`
|
||||
Version string `json:"version" form:"version"`
|
||||
ViewerID string `json:"viewer_id" form:"viewer_id" binding:"required"`
|
||||
PreferredAction types.ViewerAction `json:"preferred_action" form:"preferred_action" binding:"required"`
|
||||
}
|
||||
CreateViewerSessionParamCtx struct{}
|
||||
)
|
||||
|
||||
func (s *CreateViewerSessionService) Create(c *gin.Context) (*ViewerSessionResponse, error) {
|
||||
dep := dependency.FromContext(c)
|
||||
user := inventory.UserFromContext(c)
|
||||
m := manager.NewFileManager(dep, user)
|
||||
defer m.Recycle()
|
||||
|
||||
uri, err := fs.NewUriFromString(s.Uri)
|
||||
if err != nil {
|
||||
return nil, serializer.NewError(serializer.CodeParamErr, "unknown uri", err)
|
||||
}
|
||||
|
||||
// Find the given viewer
|
||||
viewers := dep.SettingProvider().FileViewers(c)
|
||||
var targetViewer *types.Viewer
|
||||
for _, group := range viewers {
|
||||
for _, viewer := range group.Viewers {
|
||||
if viewer.ID == s.ViewerID && !viewer.Disabled {
|
||||
targetViewer = &viewer
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if targetViewer != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if targetViewer == nil {
|
||||
return nil, serializer.NewError(serializer.CodeParamErr, "unknown viewer id", err)
|
||||
}
|
||||
|
||||
viewerSession, err := m.CreateViewerSession(c, uri, s.Version, targetViewer)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
res := &ViewerSessionResponse{Session: viewerSession}
|
||||
if targetViewer.Type == types.ViewerTypeWopi {
|
||||
// For WOPI viewer, generate WOPI src
|
||||
wopiSrc, err := wopi.GenerateWopiSrc(c, s.PreferredAction, targetViewer, viewerSession)
|
||||
if err != nil {
|
||||
return nil, serializer.NewError(serializer.CodeInternalSetting, "failed to generate wopi src", err)
|
||||
}
|
||||
res.WopiSrc = wopiSrc.String()
|
||||
}
|
||||
|
||||
return res, nil
|
||||
}
|
||||
@@ -0,0 +1,483 @@
|
||||
package explorer
|
||||
|
||||
import (
|
||||
"encoding/gob"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/hashid"
|
||||
"time"
|
||||
|
||||
"github.com/cloudreve/Cloudreve/v4/application/dependency"
|
||||
"github.com/cloudreve/Cloudreve/v4/ent"
|
||||
"github.com/cloudreve/Cloudreve/v4/ent/task"
|
||||
"github.com/cloudreve/Cloudreve/v4/inventory"
|
||||
"github.com/cloudreve/Cloudreve/v4/inventory/types"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/downloader"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/filemanager/fs"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/filemanager/fs/dbfs"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/filemanager/manager"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/filemanager/workflows"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/queue"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/serializer"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gofrs/uuid"
|
||||
"github.com/samber/lo"
|
||||
"golang.org/x/tools/container/intsets"
|
||||
)
|
||||
|
||||
// ItemMoveService 处理多文件/目录移动
|
||||
type ItemMoveService struct {
|
||||
SrcDir string `json:"src_dir" binding:"required,min=1,max=65535"`
|
||||
Src ItemIDService `json:"src"`
|
||||
Dst string `json:"dst" binding:"required,min=1,max=65535"`
|
||||
}
|
||||
|
||||
// ItemRenameService 处理多文件/目录重命名
|
||||
type ItemRenameService struct {
|
||||
Src ItemIDService `json:"src"`
|
||||
NewName string `json:"new_name" binding:"required,min=1,max=255"`
|
||||
}
|
||||
|
||||
// ItemService 处理多文件/目录相关服务
|
||||
type ItemService struct {
|
||||
Items []uint `json:"items"`
|
||||
Dirs []uint `json:"dirs"`
|
||||
}
|
||||
|
||||
// ItemIDService 处理多文件/目录相关服务,字段值为HashID,可通过Raw()方法获取原始ID
|
||||
type ItemIDService struct {
|
||||
Items []string `json:"items"`
|
||||
Dirs []string `json:"dirs"`
|
||||
Source *ItemService
|
||||
Force bool `json:"force"`
|
||||
UnlinkOnly bool `json:"unlink"`
|
||||
}
|
||||
|
||||
// ItemDecompressService 文件解压缩任务服务
|
||||
type ItemDecompressService struct {
|
||||
Src string `json:"src"`
|
||||
Dst string `json:"dst" binding:"required,min=1,max=65535"`
|
||||
Encoding string `json:"encoding"`
|
||||
}
|
||||
|
||||
// ItemPropertyService 获取对象属性服务
|
||||
type ItemPropertyService struct {
|
||||
ID string `binding:"required"`
|
||||
TraceRoot bool `form:"trace_root"`
|
||||
IsFolder bool `form:"is_folder"`
|
||||
}
|
||||
|
||||
func init() {
|
||||
gob.Register(ItemIDService{})
|
||||
}
|
||||
|
||||
type (
|
||||
DownloadWorkflowService struct {
|
||||
Src []string `json:"src"`
|
||||
SrcFile string `json:"src_file"`
|
||||
Dst string `json:"dst" binding:"required"`
|
||||
}
|
||||
CreateDownloadParamCtx struct{}
|
||||
)
|
||||
|
||||
func (service *DownloadWorkflowService) CreateDownloadTask(c *gin.Context) ([]*TaskResponse, error) {
|
||||
dep := dependency.FromContext(c)
|
||||
user := inventory.UserFromContext(c)
|
||||
hasher := dep.HashIDEncoder()
|
||||
m := manager.NewFileManager(dep, user)
|
||||
defer m.Recycle()
|
||||
|
||||
if !user.Edges.Group.Permissions.Enabled(int(types.GroupPermissionRemoteDownload)) {
|
||||
return nil, serializer.NewError(serializer.CodeGroupNotAllowed, "Group not allowed to download files", nil)
|
||||
}
|
||||
|
||||
// Src must be set
|
||||
if service.SrcFile == "" && len(service.Src) == 0 {
|
||||
return nil, serializer.NewError(serializer.CodeParamErr, "No source files", nil)
|
||||
}
|
||||
|
||||
// Only one of src and src_file can be set
|
||||
if service.SrcFile != "" && len(service.Src) > 0 {
|
||||
return nil, serializer.NewError(serializer.CodeParamErr, "Invalid source files", nil)
|
||||
}
|
||||
|
||||
dst, err := fs.NewUriFromString(service.Dst)
|
||||
if err != nil {
|
||||
return nil, serializer.NewError(serializer.CodeParamErr, "Invalid destination", err)
|
||||
}
|
||||
|
||||
// Validate dst
|
||||
_, err = m.Get(c, dst, dbfs.WithRequiredCapabilities(dbfs.NavigatorCapabilityCreateFile))
|
||||
if err != nil {
|
||||
return nil, serializer.NewError(serializer.CodeParamErr, "Invalid destination", err)
|
||||
}
|
||||
|
||||
// 检查批量任务数量
|
||||
limit := user.Edges.Group.Settings.Aria2BatchSize
|
||||
if limit > 0 && len(service.Src) > limit {
|
||||
return nil, serializer.NewError(serializer.CodeBatchAria2Size, "", nil)
|
||||
}
|
||||
|
||||
// Validate src file
|
||||
if service.SrcFile != "" {
|
||||
src, err := fs.NewUriFromString(service.SrcFile)
|
||||
if err != nil {
|
||||
return nil, serializer.NewError(serializer.CodeParamErr, "Invalid source file uri", err)
|
||||
}
|
||||
|
||||
_, err = m.Get(c, src, dbfs.WithRequiredCapabilities(dbfs.NavigatorCapabilityDownloadFile))
|
||||
if err != nil {
|
||||
return nil, serializer.NewError(serializer.CodeParamErr, "Invalid source file", err)
|
||||
}
|
||||
}
|
||||
|
||||
// batch creating tasks
|
||||
ae := serializer.NewAggregateError()
|
||||
tasks := make([]queue.Task, 0, len(service.Src))
|
||||
for _, src := range service.Src {
|
||||
if src == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
t, err := workflows.NewRemoteDownloadTask(c, src, service.SrcFile, service.Dst)
|
||||
if err != nil {
|
||||
ae.Add(src, err)
|
||||
continue
|
||||
}
|
||||
|
||||
if err := dep.RemoteDownloadQueue(c).QueueTask(c, t); err != nil {
|
||||
ae.Add(src, err)
|
||||
}
|
||||
|
||||
tasks = append(tasks, t)
|
||||
}
|
||||
|
||||
if service.SrcFile != "" {
|
||||
t, err := workflows.NewRemoteDownloadTask(c, "", service.SrcFile, service.Dst)
|
||||
if err != nil {
|
||||
ae.Add(service.SrcFile, err)
|
||||
}
|
||||
|
||||
if err := dep.RemoteDownloadQueue(c).QueueTask(c, t); err != nil {
|
||||
ae.Add(service.SrcFile, err)
|
||||
}
|
||||
|
||||
tasks = append(tasks, t)
|
||||
}
|
||||
|
||||
return lo.Map(tasks, func(item queue.Task, index int) *TaskResponse {
|
||||
return BuildTaskResponse(item, nil, hasher)
|
||||
}), ae.Aggregate()
|
||||
}
|
||||
|
||||
type (
|
||||
ArchiveWorkflowService struct {
|
||||
Src []string `json:"src" binding:"required"`
|
||||
Dst string `json:"dst" binding:"required"`
|
||||
Encoding string `json:"encoding"`
|
||||
Password string `json:"password"`
|
||||
FileMask []string `json:"file_mask"`
|
||||
}
|
||||
CreateArchiveParamCtx struct{}
|
||||
)
|
||||
|
||||
func (service *ArchiveWorkflowService) CreateExtractTask(c *gin.Context) (*TaskResponse, error) {
|
||||
dep := dependency.FromContext(c)
|
||||
user := inventory.UserFromContext(c)
|
||||
hasher := dep.HashIDEncoder()
|
||||
m := manager.NewFileManager(dep, user)
|
||||
defer m.Recycle()
|
||||
|
||||
if !user.Edges.Group.Permissions.Enabled(int(types.GroupPermissionArchiveTask)) {
|
||||
return nil, serializer.NewError(serializer.CodeGroupNotAllowed, "Group not allowed to compress files", nil)
|
||||
}
|
||||
|
||||
dst, err := fs.NewUriFromString(service.Dst)
|
||||
if err != nil {
|
||||
return nil, serializer.NewError(serializer.CodeParamErr, "Invalid destination", err)
|
||||
}
|
||||
|
||||
if len(service.Src) == 0 {
|
||||
return nil, serializer.NewError(serializer.CodeParamErr, "No source files", nil)
|
||||
}
|
||||
|
||||
// Validate destination
|
||||
if _, err := m.Get(c, dst, dbfs.WithRequiredCapabilities(dbfs.NavigatorCapabilityCreateFile)); err != nil {
|
||||
return nil, serializer.NewError(serializer.CodeParamErr, "Invalid destination", err)
|
||||
}
|
||||
|
||||
// Create task
|
||||
t, err := workflows.NewExtractArchiveTask(c, service.Src[0], service.Dst, service.Encoding, service.Password, service.FileMask)
|
||||
if err != nil {
|
||||
return nil, serializer.NewError(serializer.CodeCreateTaskError, "Failed to create task", err)
|
||||
}
|
||||
|
||||
if err := dep.IoIntenseQueue(c).QueueTask(c, t); err != nil {
|
||||
return nil, serializer.NewError(serializer.CodeCreateTaskError, "Failed to queue task", err)
|
||||
}
|
||||
|
||||
return BuildTaskResponse(t, nil, hasher), nil
|
||||
}
|
||||
|
||||
// CreateCompressTask Create task to create an archive file
|
||||
func (service *ArchiveWorkflowService) CreateCompressTask(c *gin.Context) (*TaskResponse, error) {
|
||||
dep := dependency.FromContext(c)
|
||||
user := inventory.UserFromContext(c)
|
||||
hasher := dep.HashIDEncoder()
|
||||
m := manager.NewFileManager(dep, user)
|
||||
defer m.Recycle()
|
||||
|
||||
if !user.Edges.Group.Permissions.Enabled(int(types.GroupPermissionArchiveTask)) {
|
||||
return nil, serializer.NewError(serializer.CodeGroupNotAllowed, "Group not allowed to compress files", nil)
|
||||
}
|
||||
|
||||
dst, err := fs.NewUriFromString(service.Dst)
|
||||
if err != nil {
|
||||
return nil, serializer.NewError(serializer.CodeParamErr, "Invalid destination", err)
|
||||
}
|
||||
|
||||
// Create a placeholder file then delete it to validate the destination
|
||||
session, err := m.PrepareUpload(c, &fs.UploadRequest{
|
||||
Props: &fs.UploadProps{
|
||||
Uri: dst,
|
||||
Size: 0,
|
||||
UploadSessionID: uuid.Must(uuid.NewV4()).String(),
|
||||
ExpireAt: time.Now().Add(time.Second * 3600),
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m.OnUploadFailed(c, session)
|
||||
|
||||
// Create task
|
||||
t, err := workflows.NewCreateArchiveTask(c, service.Src, service.Dst)
|
||||
if err != nil {
|
||||
return nil, serializer.NewError(serializer.CodeCreateTaskError, "Failed to create task", err)
|
||||
}
|
||||
|
||||
if err := dep.IoIntenseQueue(c).QueueTask(c, t); err != nil {
|
||||
return nil, serializer.NewError(serializer.CodeCreateTaskError, "Failed to queue task", err)
|
||||
}
|
||||
|
||||
return BuildTaskResponse(t, nil, hasher), nil
|
||||
}
|
||||
|
||||
type (
|
||||
ImportWorkflowService struct {
|
||||
Src string `json:"src" binding:"required"`
|
||||
Dst string `json:"dst" binding:"required"`
|
||||
ExtractMediaMeta bool `json:"extract_media_meta"`
|
||||
UserID string `json:"user_id" binding:"required"`
|
||||
Recursive bool `json:"recursive"`
|
||||
PolicyID int `json:"policy_id" binding:"required"`
|
||||
}
|
||||
CreateImportParamCtx struct{}
|
||||
)
|
||||
|
||||
func (service *ImportWorkflowService) CreateImportTask(c *gin.Context) (*TaskResponse, error) {
|
||||
dep := dependency.FromContext(c)
|
||||
user := inventory.UserFromContext(c)
|
||||
hasher := dep.HashIDEncoder()
|
||||
m := manager.NewFileManager(dep, user)
|
||||
defer m.Recycle()
|
||||
|
||||
if !user.Edges.Group.Permissions.Enabled(int(types.GroupPermissionIsAdmin)) {
|
||||
return nil, serializer.NewError(serializer.CodeGroupNotAllowed, "Only admin can import files", nil)
|
||||
}
|
||||
|
||||
userId, err := hasher.Decode(service.UserID, hashid.UserID)
|
||||
if err != nil {
|
||||
return nil, serializer.NewError(serializer.CodeParamErr, "Invalid user id", err)
|
||||
}
|
||||
|
||||
owner, err := dep.UserClient().GetLoginUserByID(c, userId)
|
||||
if err != nil || owner.ID == 0 {
|
||||
return nil, serializer.NewError(serializer.CodeDBError, "Failed to get user", err)
|
||||
}
|
||||
|
||||
dst, err := fs.NewUriFromString(fs.NewMyUri(service.UserID))
|
||||
if err != nil {
|
||||
return nil, serializer.NewError(serializer.CodeParamErr, "Invalid destination", err)
|
||||
}
|
||||
|
||||
// Create task
|
||||
t, err := workflows.NewImportTask(c, owner, service.Src, service.Recursive, dst.Join(service.Dst).String(), service.PolicyID)
|
||||
if err != nil {
|
||||
return nil, serializer.NewError(serializer.CodeCreateTaskError, "Failed to create task", err)
|
||||
}
|
||||
|
||||
if err := dep.IoIntenseQueue(c).QueueTask(c, t); err != nil {
|
||||
return nil, serializer.NewError(serializer.CodeCreateTaskError, "Failed to queue task", err)
|
||||
}
|
||||
|
||||
return BuildTaskResponse(t, nil, hasher), nil
|
||||
}
|
||||
|
||||
type (
|
||||
ListTaskService struct {
|
||||
PageSize int `form:"page_size" binding:"required,min=10,max=100"`
|
||||
Category string `form:"category" binding:"required,eq=general|eq=downloading|eq=downloaded"`
|
||||
NextPageToken string `form:"next_page_token"`
|
||||
}
|
||||
ListTaskParamCtx struct{}
|
||||
)
|
||||
|
||||
func (service *ListTaskService) ListTasks(c *gin.Context) (*TaskListResponse, error) {
|
||||
dep := dependency.FromContext(c)
|
||||
user := inventory.UserFromContext(c)
|
||||
hasher := dep.HashIDEncoder()
|
||||
taskClient := dep.TaskClient()
|
||||
|
||||
args := &inventory.ListTaskArgs{
|
||||
PaginationArgs: &inventory.PaginationArgs{
|
||||
UseCursorPagination: true,
|
||||
PageToken: service.NextPageToken,
|
||||
PageSize: service.PageSize,
|
||||
},
|
||||
Types: []string{queue.CreateArchiveTaskType, queue.ExtractArchiveTaskType, queue.RelocateTaskType, queue.ImportTaskType},
|
||||
UserID: user.ID,
|
||||
}
|
||||
|
||||
if service.Category != "general" {
|
||||
args.Types = []string{queue.RemoteDownloadTaskType}
|
||||
if service.Category == "downloading" {
|
||||
args.PageSize = intsets.MaxInt
|
||||
args.Status = []task.Status{task.StatusSuspending, task.StatusProcessing, task.StatusQueued}
|
||||
} else if service.Category == "downloaded" {
|
||||
args.Status = []task.Status{task.StatusCanceled, task.StatusError, task.StatusCompleted}
|
||||
}
|
||||
}
|
||||
|
||||
// Get tasks
|
||||
res, err := taskClient.List(c, args)
|
||||
if err != nil {
|
||||
return nil, serializer.NewError(serializer.CodeDBError, "Failed to query tasks", err)
|
||||
}
|
||||
|
||||
tasks := make([]queue.Task, 0, len(res.Tasks))
|
||||
nodeMap := make(map[int]*ent.Node)
|
||||
for _, t := range res.Tasks {
|
||||
task, err := queue.NewTaskFromModel(t)
|
||||
if err != nil {
|
||||
return nil, serializer.NewError(serializer.CodeDBError, "Failed to parse task", err)
|
||||
}
|
||||
|
||||
summary := task.Summarize(hasher)
|
||||
if summary != nil && summary.NodeID > 0 {
|
||||
if _, ok := nodeMap[summary.NodeID]; !ok {
|
||||
nodeMap[summary.NodeID] = nil
|
||||
}
|
||||
}
|
||||
tasks = append(tasks, task)
|
||||
}
|
||||
|
||||
// Get nodes
|
||||
nodes, err := dep.NodeClient().ListActiveNodes(c, lo.Keys(nodeMap))
|
||||
if err != nil {
|
||||
return nil, serializer.NewError(serializer.CodeDBError, "Failed to query nodes", err)
|
||||
}
|
||||
for _, n := range nodes {
|
||||
nodeMap[n.ID] = n
|
||||
}
|
||||
|
||||
// Build response
|
||||
return BuildTaskListResponse(tasks, res, nodeMap, hasher), nil
|
||||
}
|
||||
|
||||
func TaskPhaseProgress(c *gin.Context, taskID int) (queue.Progresses, error) {
|
||||
dep := dependency.FromContext(c)
|
||||
u := inventory.UserFromContext(c)
|
||||
r := dep.TaskRegistry()
|
||||
t, found := r.Get(taskID)
|
||||
if !found || (t.Owner().ID != u.ID && !u.Edges.Group.Permissions.Enabled(int(types.GroupPermissionIsAdmin))) {
|
||||
return queue.Progresses{}, nil
|
||||
}
|
||||
|
||||
return t.Progress(c), nil
|
||||
}
|
||||
|
||||
func CancelDownloadTask(c *gin.Context, taskID int) error {
|
||||
dep := dependency.FromContext(c)
|
||||
u := inventory.UserFromContext(c)
|
||||
r := dep.TaskRegistry()
|
||||
t, found := r.Get(taskID)
|
||||
if !found || t.Owner().ID != u.ID {
|
||||
return serializer.NewError(serializer.CodeNotFound, "Task not found", nil)
|
||||
}
|
||||
|
||||
if downloadTask, ok := t.(*workflows.RemoteDownloadTask); ok {
|
||||
if err := downloadTask.CancelDownload(c); err != nil {
|
||||
return serializer.NewError(serializer.CodeInternalSetting, "Failed to cancel download task", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type (
|
||||
SetDownloadFilesService struct {
|
||||
Files []*downloader.SetFileToDownloadArgs `json:"files" binding:"required"`
|
||||
}
|
||||
SetDownloadFilesParamCtx struct{}
|
||||
)
|
||||
|
||||
func (service *SetDownloadFilesService) SetDownloadFiles(c *gin.Context, taskID int) error {
|
||||
dep := dependency.FromContext(c)
|
||||
u := inventory.UserFromContext(c)
|
||||
r := dep.TaskRegistry()
|
||||
|
||||
t, found := r.Get(taskID)
|
||||
if !found || t.Owner().ID != u.ID {
|
||||
return serializer.NewError(serializer.CodeNotFound, "Task not found", nil)
|
||||
}
|
||||
|
||||
status := t.Status()
|
||||
summary := t.Summarize(dep.HashIDEncoder())
|
||||
// Task must be in processing state
|
||||
if status != task.StatusSuspending && status != task.StatusProcessing {
|
||||
return serializer.NewError(serializer.CodeNotFound, "Task not in processing state", nil)
|
||||
}
|
||||
|
||||
// Task must in monitoring loop
|
||||
if summary.Phase != workflows.RemoteDownloadTaskPhaseMonitor {
|
||||
return serializer.NewError(serializer.CodeNotFound, "Task not in monitoring loop", nil)
|
||||
}
|
||||
|
||||
if downloadTask, ok := t.(*workflows.RemoteDownloadTask); ok {
|
||||
if err := downloadTask.SetDownloadTarget(c, service.Files...); err != nil {
|
||||
return serializer.NewError(serializer.CodeInternalSetting, "Failed to set download files", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type (
|
||||
RebuildFTSIndexWorkflowService struct {
|
||||
FilteredStoragePolicy []int `json:"filtered_storage_policy"`
|
||||
}
|
||||
CreateRebuildFTSIndexParamCtx struct{}
|
||||
)
|
||||
|
||||
func (service *RebuildFTSIndexWorkflowService) CreateRebuildFTSIndexTask(c *gin.Context) (*TaskResponse, error) {
|
||||
dep := dependency.FromContext(c)
|
||||
user := inventory.UserFromContext(c)
|
||||
hasher := dep.HashIDEncoder()
|
||||
m := manager.NewFileManager(dep, user)
|
||||
defer m.Recycle()
|
||||
|
||||
if !user.Edges.Group.Permissions.Enabled(int(types.GroupPermissionIsAdmin)) {
|
||||
return nil, serializer.NewError(serializer.CodeGroupNotAllowed, "Only admin can import files", nil)
|
||||
}
|
||||
|
||||
// Create task
|
||||
t, err := workflows.NewRebuildIndexTask(c, user, service.FilteredStoragePolicy)
|
||||
if err != nil {
|
||||
return nil, serializer.NewError(serializer.CodeCreateTaskError, "Failed to create task", err)
|
||||
}
|
||||
|
||||
if err := dep.MediaMetaQueue(c).QueueTask(c, t); err != nil {
|
||||
return nil, serializer.NewError(serializer.CodeCreateTaskError, "Failed to queue task", err)
|
||||
}
|
||||
|
||||
return BuildTaskResponse(t, nil, hasher), nil
|
||||
}
|
||||
Reference in New Issue
Block a user