chore: 初始化 Cloudreve 源码仓库

This commit is contained in:
2026-09-13 11:28:59 +08:00
commit 2a7b03ab1d
489 changed files with 163867 additions and 0 deletions
+559
View File
@@ -0,0 +1,559 @@
package admin
import (
"context"
"path"
"strconv"
"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/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/setting"
"github.com/gin-gonic/gin"
"github.com/samber/lo"
)
// FileService 文件ID服务
type FileService struct {
ID uint `uri:"id" json:"id" binding:"required"`
}
// FileBatchService 文件批量操作服务
type FileBatchService struct {
ID []uint `json:"id" binding:"min=1"`
Force bool `json:"force"`
UnlinkOnly bool `json:"unlink"`
}
// ListFolderService 列目录结构
type ListFolderService struct {
Path string `uri:"path" binding:"required,max=65535"`
ID uint `uri:"id" binding:"required"`
Type string `uri:"type" binding:"eq=policy|eq=user"`
}
// List 列出指定路径下的目录
func (service *ListFolderService) List(c *gin.Context) serializer.Response {
//if service.Type == "policy" {
// // 列取存储策略中的目录
// policy, err := model.GetPolicyByID(service.ID)
// if err != nil {
// return serializer.ErrDeprecated(serializer.CodePolicyNotExist, "", err)
// }
//
// // 创建文件系统
// fs, err := filesystem.NewAnonymousFileSystem()
// if err != nil {
// return serializer.ErrDeprecated(serializer.CodeCreateFSError, "", err)
// }
// defer fs.Recycle()
//
// // 列取存储策略中的文件
// fs.Policy = &policy
// res, err := fs.ListPhysical(c.Request.Context(), service.Path)
// if err != nil {
// return serializer.ErrDeprecated(serializer.CodeListFilesError, "", err)
// }
//
// return serializer.Response{
// Data: serializer.BuildObjectList(0, res, nil),
// }
//
//}
//
//// 列取用户空间目录
//// 查找用户
//user, err := model.GetUserByID(service.ID)
//if err != nil {
// return serializer.ErrDeprecated(serializer.CodeUserNotFound, "", err)
//}
//
//// 创建文件系统
//fs, err := filesystem.NewFileSystem(&user)
//if err != nil {
// return serializer.ErrDeprecated(serializer.CodeCreateFSError, "", err)
//}
//defer fs.Recycle()
//
//// 列取目录
//res, err := fs.List(c.Request.Context(), service.Path, nil)
//if err != nil {
// return serializer.ErrDeprecated(serializer.CodeListFilesError, "", err)
//}
//return serializer.Response{
// Data: serializer.BuildObjectList(0, res, nil),
//}
return serializer.Response{}
}
// Delete 删除文件
func (service *FileBatchService) Delete(c *gin.Context) serializer.Response {
//files, err := model.GetFilesByIDs(service.ID, 0)
//if err != nil {
// return serializer.DBErrDeprecated("Failed to list files for deleting", err)
//}
//
//// 根据用户分组
//userFile := make(map[uint][]model.File)
//for i := 0; i < len(files); i++ {
// if _, ok := userFile[files[i].UserID]; !ok {
// userFile[files[i].UserID] = []model.File{}
// }
// userFile[files[i].UserID] = append(userFile[files[i].UserID], files[i])
//}
//
//// 异步执行删除
//go func(files map[uint][]model.File) {
// for uid, file := range files {
// var (
// fs *filesystem.FileSystem
// err error
// )
// user, err := model.GetUserByID(uid)
// if err != nil {
// fs, err = filesystem.NewAnonymousFileSystem()
// if err != nil {
// continue
// }
// } else {
// fs, err = filesystem.NewFileSystem(&user)
// if err != nil {
// fs.Recycle()
// continue
// }
// }
//
// // 汇总文件ID
// ids := make([]uint, 0, len(file))
// for i := 0; i < len(file); i++ {
// ids = append(ids, file[i].ID)
// }
//
// // 执行删除
// fs.Delete(context.Background(), []uint{}, ids, service.Force, service.UnlinkOnly)
// fs.Recycle()
// }
//}(userFile)
// 分组执行删除
return serializer.Response{}
}
const (
fileNameCondition = "file_name"
fileUserCondition = "file_user"
filePolicyCondition = "file_policy"
fileMetadataCondition = "file_metadata"
fileSharedCondition = "file_shared"
fileDirectLinkCondition = "file_direct_link"
)
func (service *AdminListService) Files(c *gin.Context) (*ListFileResponse, error) {
dep := dependency.FromContext(c)
hasher := dep.HashIDEncoder()
fileClient := dep.FileClient()
ctx := context.WithValue(c, inventory.LoadFileEntity{}, true)
ctx = context.WithValue(ctx, inventory.LoadFileMetadata{}, true)
ctx = context.WithValue(ctx, inventory.LoadFileShare{}, true)
ctx = context.WithValue(ctx, inventory.LoadFileUser{}, true)
ctx = context.WithValue(ctx, inventory.LoadFileDirectLink{}, true)
var (
err error
userID int
policyID int
metadata string
shared bool
directLink bool
)
if service.Conditions[fileUserCondition] != "" {
userID, err = strconv.Atoi(service.Conditions[fileUserCondition])
if err != nil {
return nil, serializer.NewError(serializer.CodeParamErr, "Invalid user ID", err)
}
}
if service.Conditions[filePolicyCondition] != "" {
policyID, err = strconv.Atoi(service.Conditions[filePolicyCondition])
if err != nil {
return nil, serializer.NewError(serializer.CodeParamErr, "Invalid policy ID", err)
}
}
if service.Conditions[fileMetadataCondition] != "" {
metadata = service.Conditions[fileMetadataCondition]
}
if service.Conditions[fileSharedCondition] != "" && setting.IsTrueValue(service.Conditions[fileSharedCondition]) {
shared = true
}
if service.Conditions[fileDirectLinkCondition] != "" && setting.IsTrueValue(service.Conditions[fileDirectLinkCondition]) {
directLink = true
}
res, err := fileClient.FlattenListFiles(ctx, &inventory.FlattenListFileParameters{
PaginationArgs: &inventory.PaginationArgs{
Page: service.Page - 1,
PageSize: service.PageSize,
OrderBy: service.OrderBy,
Order: inventory.OrderDirection(service.OrderDirection),
},
UserID: userID,
StoragePolicyID: policyID,
Name: service.Conditions[fileNameCondition],
HasMetadata: metadata,
Shared: shared,
HasDirectLink: directLink,
})
if err != nil {
return nil, serializer.NewError(serializer.CodeDBError, "Failed to list files", err)
}
return &ListFileResponse{
Pagination: res.PaginationResults,
Files: lo.Map(res.Files, func(file *ent.File, _ int) GetFileResponse {
return GetFileResponse{
File: file,
UserHashID: hashid.EncodeUserID(hasher, file.OwnerID),
FileHashID: hashid.EncodeFileID(hasher, file.ID),
}
}),
}, nil
}
type (
SingleFileService struct {
ID int `uri:"id" json:"id" binding:"required"`
}
SingleFileParamCtx struct{}
)
func (service *SingleFileService) Get(c *gin.Context) (*GetFileResponse, error) {
dep := dependency.FromContext(c)
hasher := dep.HashIDEncoder()
fileClient := dep.FileClient()
ctx := context.WithValue(c, inventory.LoadFileEntity{}, true)
ctx = context.WithValue(ctx, inventory.LoadFileMetadata{}, true)
ctx = context.WithValue(ctx, inventory.LoadFileShare{}, true)
ctx = context.WithValue(ctx, inventory.LoadFileUser{}, true)
ctx = context.WithValue(ctx, inventory.LoadEntityUser{}, true)
ctx = context.WithValue(ctx, inventory.LoadEntityStoragePolicy{}, true)
ctx = context.WithValue(ctx, inventory.LoadFileDirectLink{}, true)
file, err := fileClient.GetByID(ctx, service.ID)
if err != nil {
if ent.IsNotFound(err) {
return nil, serializer.NewError(serializer.CodeNotFound, "File not found", nil)
}
return nil, serializer.NewError(serializer.CodeDBError, "Failed to get file", err)
}
directLinkMap := make(map[int]string)
siteURL := dep.SettingProvider().SiteURL(c)
for _, directLink := range file.Edges.DirectLinks {
directLinkMap[directLink.ID] = routes.MasterDirectLink(siteURL, hashid.EncodeSourceLinkID(hasher, directLink.ID), directLink.Name).String()
}
return &GetFileResponse{
File: file,
UserHashID: hashid.EncodeUserID(hasher, file.OwnerID),
FileHashID: hashid.EncodeFileID(hasher, file.ID),
DirectLinkMap: directLinkMap,
}, nil
}
type (
UpsertFileService struct {
File *ent.File `json:"file" binding:"required"`
}
UpsertFileParamCtx struct{}
)
func (s *UpsertFileService) Update(c *gin.Context) (*GetFileResponse, error) {
dep := dependency.FromContext(c)
fileClient := dep.FileClient()
fc, tx, ctx, err := inventory.WithTx(c, fileClient)
if err != nil {
return nil, serializer.NewError(serializer.CodeDBError, "Failed to start transaction", err)
}
newFile, err := fc.Update(ctx, s.File)
if err != nil {
_ = inventory.Rollback(tx)
return nil, serializer.NewError(serializer.CodeDBError, "Failed to update file", err)
}
if err := inventory.Commit(tx); err != nil {
return nil, serializer.NewError(serializer.CodeDBError, "Failed to commit transaction", err)
}
service := &SingleFileService{ID: newFile.ID}
return service.Get(c)
}
func (s *SingleFileService) Url(c *gin.Context) (string, error) {
dep := dependency.FromContext(c)
fileClient := dep.FileClient()
ctx := context.WithValue(c, inventory.LoadFileEntity{}, true)
file, err := fileClient.GetByID(ctx, s.ID)
if err != nil {
return "", serializer.NewError(serializer.CodeDBError, "Failed to get file", err)
}
// find primary entity
var primaryEntity *ent.Entity
for _, entity := range file.Edges.Entities {
if entity.Type == int(types.EntityTypeVersion) && entity.ID == file.PrimaryEntity {
primaryEntity = entity
break
}
}
if primaryEntity == nil {
return "", serializer.NewError(serializer.CodeNotFound, "Primary entity not exist", nil)
}
// find policy
policy, err := dep.StoragePolicyClient().GetPolicyByID(ctx, primaryEntity.StoragePolicyEntities)
if err != nil {
return "", serializer.NewError(serializer.CodeDBError, "Failed to get policy", err)
}
m := manager.NewFileManager(dep, inventory.UserFromContext(c))
defer m.Recycle()
driver, err := m.GetStorageDriver(ctx, policy)
if err != nil {
return "", serializer.NewError(serializer.CodeInternalSetting, "Failed to get storage driver", err)
}
es := entitysource.NewEntitySource(fs.NewEntity(primaryEntity), driver, policy, dep.GeneralAuth(),
dep.SettingProvider(), dep.HashIDEncoder(), dep.RequestClient(), dep.Logger(), dep.ConfigProvider(), dep.MimeDetector(ctx), dep.EncryptorFactory(ctx))
expire := time.Now().Add(time.Hour * 1)
url, err := es.Url(ctx, entitysource.WithExpire(&expire), entitysource.WithDisplayName(file.Name))
if err != nil {
return "", serializer.NewError(serializer.CodeInternalSetting, "Failed to get url", err)
}
return url.Url, nil
}
type (
BatchFileService struct {
IDs []int `json:"ids" binding:"min=1"`
}
BatchFileParamCtx struct{}
)
func (s *BatchFileService) Delete(c *gin.Context) error {
dep := dependency.FromContext(c)
fileClient := dep.FileClient()
ctx := context.WithValue(c, inventory.LoadFileEntity{}, true)
files, _, err := fileClient.GetByIDs(ctx, s.IDs, 0)
if err != nil {
return serializer.NewError(serializer.CodeDBError, "Failed to get files", err)
}
fc, tx, ctx, err := inventory.WithTx(c, fileClient)
if err != nil {
return serializer.NewError(serializer.CodeDBError, "Failed to start transaction", err)
}
_, diff, err := fc.Delete(ctx, files, nil)
if err != nil {
_ = inventory.Rollback(tx)
return serializer.NewError(serializer.CodeDBError, "Failed to delete files", err)
}
tx.AppendStorageDiff(diff)
if err := inventory.CommitWithStorageDiff(ctx, tx, dep.Logger(), dep.UserClient()); err != nil {
return serializer.NewError(serializer.CodeDBError, "Failed to commit transaction", err)
}
return nil
}
const (
entityUserCondition = "entity_user"
entityPolicyCondition = "entity_policy"
entityTypeCondition = "entity_type"
)
func (s *AdminListService) Entities(c *gin.Context) (*ListEntityResponse, error) {
dep := dependency.FromContext(c)
fileClient := dep.FileClient()
hasher := dep.HashIDEncoder()
ctx := context.WithValue(c, inventory.LoadEntityUser{}, true)
ctx = context.WithValue(ctx, inventory.LoadEntityStoragePolicy{}, true)
var (
userID int
policyID int
err error
entityType *types.EntityType
)
if s.Conditions[entityUserCondition] != "" {
userID, err = strconv.Atoi(s.Conditions[entityUserCondition])
if err != nil {
return nil, serializer.NewError(serializer.CodeParamErr, "Invalid user ID", err)
}
}
if s.Conditions[entityPolicyCondition] != "" {
policyID, err = strconv.Atoi(s.Conditions[entityPolicyCondition])
if err != nil {
return nil, serializer.NewError(serializer.CodeParamErr, "Invalid policy ID", err)
}
}
if s.Conditions[entityTypeCondition] != "" {
typeId, err := strconv.Atoi(s.Conditions[entityTypeCondition])
if err != nil {
return nil, serializer.NewError(serializer.CodeParamErr, "Invalid entity type", err)
}
t := types.EntityType(typeId)
entityType = &t
}
res, err := fileClient.ListEntities(ctx, &inventory.ListEntityParameters{
PaginationArgs: &inventory.PaginationArgs{
Page: s.Page - 1,
PageSize: s.PageSize,
OrderBy: s.OrderBy,
Order: inventory.OrderDirection(s.OrderDirection),
},
UserID: userID,
StoragePolicyID: policyID,
EntityType: entityType,
})
if err != nil {
return nil, serializer.NewError(serializer.CodeDBError, "Failed to list entities", err)
}
return &ListEntityResponse{
Pagination: res.PaginationResults,
Entities: lo.Map(res.Entities, func(entity *ent.Entity, _ int) GetEntityResponse {
return GetEntityResponse{
Entity: entity,
UserHashID: hashid.EncodeUserID(hasher, entity.CreatedBy),
}
}),
}, nil
}
type (
SingleEntityService struct {
ID int `uri:"id" json:"id" binding:"required"`
}
SingleEntityParamCtx struct{}
)
func (s *SingleEntityService) Get(c *gin.Context) (*GetEntityResponse, error) {
dep := dependency.FromContext(c)
fileClient := dep.FileClient()
hasher := dep.HashIDEncoder()
ctx := context.WithValue(c, inventory.LoadEntityUser{}, true)
ctx = context.WithValue(ctx, inventory.LoadEntityStoragePolicy{}, true)
ctx = context.WithValue(ctx, inventory.LoadEntityFile{}, true)
ctx = context.WithValue(ctx, inventory.LoadFileUser{}, true)
userHashIDMap := make(map[int]string)
entity, err := fileClient.GetEntityByID(ctx, s.ID)
if err != nil {
if ent.IsNotFound(err) {
return nil, serializer.NewError(serializer.CodeNotFound, "Entity not found", nil)
}
return nil, serializer.NewError(serializer.CodeDBError, "Failed to get entity", err)
}
for _, file := range entity.Edges.File {
userHashIDMap[file.OwnerID] = hashid.EncodeUserID(hasher, file.OwnerID)
}
return &GetEntityResponse{
Entity: entity,
UserHashID: hashid.EncodeUserID(hasher, entity.CreatedBy),
UserHashIDMap: userHashIDMap,
}, nil
}
type (
BatchEntityService struct {
IDs []int `json:"ids" binding:"min=1"`
Force bool `json:"force"`
}
BatchEntityParamCtx struct{}
)
func (s *BatchEntityService) Delete(c *gin.Context) error {
dep := dependency.FromContext(c)
m := manager.NewFileManager(dep, inventory.UserFromContext(c))
defer m.Recycle()
err := m.RecycleEntities(c.Request.Context(), s.Force, s.IDs...)
if err != nil {
return serializer.NewError(serializer.CodeDBError, "Failed to recycle entities", err)
}
return nil
}
func (s *SingleEntityService) Url(c *gin.Context) (string, error) {
dep := dependency.FromContext(c)
fileClient := dep.FileClient()
entity, err := fileClient.GetEntityByID(c, s.ID)
if err != nil {
return "", serializer.NewError(serializer.CodeDBError, "Failed to get file", err)
}
// find policy
policy, err := dep.StoragePolicyClient().GetPolicyByID(c, entity.StoragePolicyEntities)
if err != nil {
return "", serializer.NewError(serializer.CodeDBError, "Failed to get policy", err)
}
m := manager.NewFileManager(dep, inventory.UserFromContext(c))
defer m.Recycle()
driver, err := m.GetStorageDriver(c, policy)
if err != nil {
return "", serializer.NewError(serializer.CodeInternalSetting, "Failed to get storage driver", err)
}
es := entitysource.NewEntitySource(fs.NewEntity(entity), driver, policy, dep.GeneralAuth(),
dep.SettingProvider(), dep.HashIDEncoder(), dep.RequestClient(), dep.Logger(), dep.ConfigProvider(), dep.MimeDetector(c), dep.EncryptorFactory(c))
expire := time.Now().Add(time.Hour * 1)
url, err := es.Url(c, entitysource.WithDownload(true), entitysource.WithExpire(&expire), entitysource.WithDisplayName(path.Base(entity.Source)))
if err != nil {
return "", serializer.NewError(serializer.CodeInternalSetting, "Failed to get url", err)
}
return url.Url, nil
}
+192
View File
@@ -0,0 +1,192 @@
package admin
import (
"context"
"strconv"
"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/serializer"
"github.com/gin-gonic/gin"
)
// AddGroupService 用户组添加服务
type AddGroupService struct {
//Group model.Group `json:"group" binding:"required"`
}
// GroupService 用户组ID服务
type GroupService struct {
ID uint `uri:"id" json:"id" binding:"required"`
}
// Get 获取用户组详情
func (service *GroupService) Get() serializer.Response {
//group, err := model.GetGroupByID(service.ID)
//if err != nil {
// return serializer.ErrDeprecated(serializer.CodeGroupNotFound, "", err)
//}
//
//return serializer.Response{Data: group}
return serializer.Response{}
}
// Delete 删除用户组
func (service *GroupService) Delete() serializer.Response {
//// 查找用户组
//group, err := model.GetGroupByID(service.ID)
//if err != nil {
// return serializer.ErrDeprecated(serializer.CodeGroupNotFound, "", err)
//}
//
//// 是否为系统用户组
//if group.ID <= 3 {
// return serializer.ErrDeprecated(serializer.CodeInvalidActionOnSystemGroup, "", err)
//}
//
//// 检查是否有用户使用
//total := 0
//row := model.DB.Model(&model.User{}).Where("group_id = ?", service.ID).
// Select("count(id)").Row()
//row.Scan(&total)
//if total > 0 {
// return serializer.ErrDeprecated(serializer.CodeGroupUsedByUser, strconv.Itoa(total), nil)
//}
//
//model.DB.Delete(&group)
return serializer.Response{}
}
func (service *SingleGroupService) Delete(c *gin.Context) error {
if service.ID <= 3 {
return serializer.NewError(serializer.CodeInvalidActionOnSystemGroup, "", nil)
}
dep := dependency.FromContext(c)
groupClient := dep.GroupClient()
// Any user still under this group?
users, err := groupClient.CountUsers(c, int(service.ID))
if err != nil {
return serializer.NewError(serializer.CodeDBError, "Failed to count users", err)
}
if users > 0 {
return serializer.NewError(serializer.CodeGroupUsedByUser, strconv.Itoa(users), nil)
}
err = groupClient.Delete(c, service.ID)
if err != nil {
return serializer.NewError(serializer.CodeDBError, "Failed to delete group", err)
}
return nil
}
func (s *AdminListService) List(c *gin.Context) (*ListGroupResponse, error) {
dep := dependency.FromContext(c)
groupClient := dep.GroupClient()
ctx := context.WithValue(c, inventory.LoadGroupPolicy{}, true)
res, err := groupClient.ListGroups(ctx, &inventory.ListGroupParameters{
PaginationArgs: &inventory.PaginationArgs{
Page: s.Page - 1,
PageSize: s.PageSize,
OrderBy: s.OrderBy,
Order: inventory.OrderDirection(s.OrderDirection),
},
})
if err != nil {
return nil, serializer.NewError(serializer.CodeDBError, "Failed to list groups", err)
}
return &ListGroupResponse{
Pagination: res.PaginationResults,
Groups: res.Groups,
}, nil
}
type (
SingleGroupService struct {
ID int `uri:"id" json:"id" binding:"required"`
}
SingleGroupParamCtx struct{}
)
const (
countUserQuery = "countUser"
)
func (s *SingleGroupService) Get(c *gin.Context) (*GetGroupResponse, error) {
dep := dependency.FromContext(c)
groupClient := dep.GroupClient()
ctx := context.WithValue(c, inventory.LoadGroupPolicy{}, true)
group, err := groupClient.GetByID(ctx, s.ID)
if err != nil {
return nil, serializer.NewError(serializer.CodeDBError, "Failed to get group", err)
}
res := &GetGroupResponse{Group: group}
if c.Query(countUserQuery) != "" {
totalUsers, err := groupClient.CountUsers(ctx, int(s.ID))
if err != nil {
return nil, serializer.NewError(serializer.CodeDBError, "Failed to count users", err)
}
res.TotalUsers = totalUsers
}
return res, nil
}
type (
UpsertGroupService struct {
Group *ent.Group `json:"group" binding:"required"`
}
UpsertGroupParamCtx struct{}
)
func (s *UpsertGroupService) Update(c *gin.Context) (*GetGroupResponse, error) {
dep := dependency.FromContext(c)
groupClient := dep.GroupClient()
if s.Group.ID == 0 {
return nil, serializer.NewError(serializer.CodeParamErr, "ID is required", nil)
}
// Initial admin group have to be admin
if s.Group.ID == 1 && !s.Group.Permissions.Enabled(int(types.GroupPermissionIsAdmin)) {
return nil, serializer.NewError(serializer.CodeParamErr, "Initial admin group have to be admin", nil)
}
group, err := groupClient.Upsert(c, s.Group)
if err != nil {
return nil, serializer.NewError(serializer.CodeDBError, "Failed to update group", err)
}
service := &SingleGroupService{ID: group.ID}
return service.Get(c)
}
func (s *UpsertGroupService) Create(c *gin.Context) (*GetGroupResponse, error) {
dep := dependency.FromContext(c)
groupClient := dep.GroupClient()
if s.Group.ID > 0 {
return nil, serializer.NewError(serializer.CodeParamErr, "ID must be 0", nil)
}
group, err := groupClient.Upsert(c, s.Group)
if err != nil {
return nil, serializer.NewError(serializer.CodeDBError, "Failed to create group", err)
}
service := &SingleGroupService{ID: group.ID}
return service.Get(c)
}
+26
View File
@@ -0,0 +1,26 @@
package admin
import (
"github.com/cloudreve/Cloudreve/v4/pkg/serializer"
)
// AdminListService 仪表盘列条目服务
type (
AdminListService struct {
Page int `json:"page" binding:"min=1"`
PageSize int `json:"page_size" binding:"min=1,required"`
OrderBy string `json:"order_by"`
OrderDirection string `json:"order_direction"`
Conditions map[string]string `json:"conditions"`
Searches map[string]string `json:"searches"`
}
AdminListServiceParamsCtx struct{}
)
// GroupList 获取用户组列表
func (service *NoParamService) GroupList() serializer.Response {
//var res []model.Group
//model.DB.Model(&model.Group{}).Find(&res)
//return serializer.Response{Data: res}
return serializer.Response{}
}
+256
View File
@@ -0,0 +1,256 @@
package admin
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/url"
"strings"
"time"
"github.com/cloudreve/Cloudreve/v4/application/dependency"
"github.com/cloudreve/Cloudreve/v4/ent"
"github.com/cloudreve/Cloudreve/v4/ent/node"
"github.com/cloudreve/Cloudreve/v4/inventory"
"github.com/cloudreve/Cloudreve/v4/pkg/auth"
"github.com/cloudreve/Cloudreve/v4/pkg/cluster"
"github.com/cloudreve/Cloudreve/v4/pkg/cluster/routes"
"github.com/cloudreve/Cloudreve/v4/pkg/downloader"
"github.com/cloudreve/Cloudreve/v4/pkg/downloader/slave"
"github.com/cloudreve/Cloudreve/v4/pkg/request"
"github.com/cloudreve/Cloudreve/v4/pkg/serializer"
"github.com/cloudreve/Cloudreve/v4/pkg/setting"
"github.com/gin-gonic/gin"
"github.com/samber/lo"
)
const (
nodeStatusCondition = "node_status"
)
func (service *AdminListService) Nodes(c *gin.Context) (*ListNodeResponse, error) {
dep := dependency.FromContext(c)
nodeClient := dep.NodeClient()
ctx := context.WithValue(c, inventory.LoadNodeStoragePolicy{}, true)
res, err := nodeClient.ListNodes(ctx, &inventory.ListNodeParameters{
PaginationArgs: &inventory.PaginationArgs{
Page: service.Page - 1,
PageSize: service.PageSize,
OrderBy: service.OrderBy,
Order: inventory.OrderDirection(service.OrderDirection),
},
Status: node.Status(service.Conditions[nodeStatusCondition]),
})
if err != nil {
return nil, serializer.NewError(serializer.CodeDBError, "Failed to list nodes", err)
}
return &ListNodeResponse{Nodes: res.Nodes, Pagination: res.PaginationResults}, nil
}
type (
SingleNodeService struct {
ID int `uri:"id" json:"id" binding:"required"`
}
SingleNodeParamCtx struct{}
)
func (service *SingleNodeService) Get(c *gin.Context) (*GetNodeResponse, error) {
dep := dependency.FromContext(c)
nodeClient := dep.NodeClient()
ctx := context.WithValue(c, inventory.LoadNodeStoragePolicy{}, true)
node, err := nodeClient.GetNodeById(ctx, service.ID)
if err != nil {
return nil, serializer.NewError(serializer.CodeDBError, "Failed to get node", err)
}
return &GetNodeResponse{Node: node}, nil
}
type (
TestNodeService struct {
Node *ent.Node `json:"node" binding:"required"`
}
TestNodeParamCtx struct{}
)
func (service *TestNodeService) Test(c *gin.Context) error {
dep := dependency.FromContext(c)
settings := dep.SettingProvider()
slave, err := url.Parse(service.Node.Server)
if err != nil {
return serializer.NewError(serializer.CodeParamErr, "Failed to parse node URL", err)
}
primaryURL := settings.SiteURL(setting.UseFirstSiteUrl(c)).String()
body := map[string]string{
"callback": primaryURL,
}
bodyByte, _ := json.Marshal(body)
r := dep.RequestClient()
res, err := r.Request(
"POST",
routes.SlavePingRoute(slave),
bytes.NewReader(bodyByte),
request.WithTimeout(time.Duration(10)*time.Second),
request.WithCredential(
auth.HMACAuth{SecretKey: []byte(service.Node.SlaveKey)},
int64(settings.SlaveRequestSignTTL(c)),
),
request.WithSlaveMeta(int(service.Node.ID)),
request.WithMasterMeta(settings.SiteBasic(c).ID, primaryURL),
request.WithCorrelationID(),
).CheckHTTPResponse(http.StatusOK).DecodeResponse()
if err != nil {
return serializer.NewError(serializer.CodeParamErr, "Failed to connect to node: "+err.Error(), nil)
}
if res.Code != 0 {
return serializer.NewError(serializer.CodeParamErr, "Successfully connected to slave node, but slave returns: "+res.Msg, nil)
}
return nil
}
type (
TestNodeDownloaderService struct {
Node *ent.Node `json:"node" binding:"required"`
}
TestNodeDownloaderParamCtx struct{}
)
func (service *TestNodeDownloaderService) Test(c *gin.Context) (string, error) {
dep := dependency.FromContext(c)
settings := dep.SettingProvider()
var (
dl downloader.Downloader
err error
)
if service.Node.Type == node.TypeMaster {
dl, err = cluster.NewDownloader(c, dep.RequestClient(request.WithContext(c)), dep.SettingProvider(), service.Node.Settings)
} else {
dl = slave.NewSlaveDownloader(dep.RequestClient(
request.WithContext(c),
request.WithCorrelationID(),
request.WithSlaveMeta(service.Node.ID),
request.WithMasterMeta(settings.SiteBasic(c).ID, settings.SiteURL(setting.UseFirstSiteUrl(c)).String()),
request.WithCredential(auth.HMACAuth{[]byte(service.Node.SlaveKey)}, int64(settings.SlaveRequestSignTTL(c))),
request.WithEndpoint(service.Node.Server),
), service.Node.Settings)
}
if err != nil {
return "", serializer.NewError(serializer.CodeParamErr, "Failed to create downloader", err)
}
version, err := dl.Test(c)
if err != nil {
return "", serializer.NewError(serializer.CodeParamErr, "Failed to test downloader: "+err.Error(), nil)
}
return version, nil
}
type (
UpsertNodeService struct {
Node *ent.Node `json:"node" binding:"required"`
}
UpsertNodeParamCtx struct{}
)
func (s *UpsertNodeService) Update(c *gin.Context) (*GetNodeResponse, error) {
dep := dependency.FromContext(c)
nodeClient := dep.NodeClient()
if s.Node.ID == 0 {
return nil, serializer.NewError(serializer.CodeParamErr, "ID is required", nil)
}
node, err := nodeClient.Upsert(c, s.Node)
if err != nil {
return nil, serializer.NewError(serializer.CodeDBError, "Failed to update node", err)
}
// reload node pool
np, err := dep.NodePool(c)
if err != nil {
return nil, serializer.NewError(serializer.CodeInternalSetting, "Failed to get node pool", err)
}
np.Upsert(c, node)
// Clear policy cache since some this node maybe cached by some storage policy
kv := dep.KV()
kv.Delete(inventory.StoragePolicyCacheKey)
service := &SingleNodeService{ID: node.ID}
return service.Get(c)
}
func (s *UpsertNodeService) Create(c *gin.Context) (*GetNodeResponse, error) {
dep := dependency.FromContext(c)
nodeClient := dep.NodeClient()
if s.Node.ID > 0 {
return nil, serializer.NewError(serializer.CodeParamErr, "ID must be 0", nil)
}
node, err := nodeClient.Upsert(c, s.Node)
if err != nil {
return nil, serializer.NewError(serializer.CodeDBError, "Failed to create node", err)
}
// reload node pool
np, err := dep.NodePool(c)
if err != nil {
return nil, serializer.NewError(serializer.CodeInternalSetting, "Failed to get node pool", err)
}
np.Upsert(c, node)
service := &SingleNodeService{ID: node.ID}
return service.Get(c)
}
func (s *SingleNodeService) Delete(c *gin.Context) error {
dep := dependency.FromContext(c)
nodeClient := dep.NodeClient()
ctx := context.WithValue(c, inventory.LoadNodeStoragePolicy{}, true)
existing, err := nodeClient.GetNodeById(ctx, s.ID)
if err != nil {
return serializer.NewError(serializer.CodeDBError, "Failed to get node", err)
}
if existing.Type == node.TypeMaster {
return serializer.NewError(serializer.CodeInvalidActionOnSystemNode, "", nil)
}
if len(existing.Edges.StoragePolicy) > 0 {
return serializer.NewError(
serializer.CodeNodeUsedByStoragePolicy,
strings.Join(lo.Map(existing.Edges.StoragePolicy, func(i *ent.StoragePolicy, _ int) string {
return i.Name
}), ", "),
nil,
)
}
// insert dummpy disabled node in nodepool to evict it
disabledNode := &ent.Node{
ID: s.ID,
Type: node.TypeSlave,
Status: node.StatusSuspended,
}
np, err := dep.NodePool(c)
if err != nil {
return serializer.NewError(serializer.CodeInternalSetting, "Failed to get node pool", err)
}
np.Upsert(c, disabledNode)
return nodeClient.Delete(c, s.ID)
}
+219
View File
@@ -0,0 +1,219 @@
package admin
import (
"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/serializer"
"github.com/gin-gonic/gin"
"github.com/samber/lo"
)
// System OAuth client GUIDs that cannot be deleted
var systemOAuthClientGUIDs = []string{
inventory.OAuthClientDesktopGUID,
inventory.OAuthClientiOSGUID,
}
type (
SingleOAuthClientService struct {
ID int `uri:"id" json:"id" binding:"required"`
}
SingleOAuthClientParamCtx struct{}
)
type (
// UpsertOAuthClientRequest expands ent.OAuthClient fields to avoid json:"-"
// redaction on Secret field, which prevents it from being bound from JSON input.
UpsertOAuthClientRequest struct {
ID int `json:"id,omitempty"`
GUID string `json:"guid,omitempty"`
Secret string `json:"secret,omitempty"`
Name string `json:"name,omitempty"`
HomepageURL string `json:"homepage_url,omitempty"`
RedirectUris []string `json:"redirect_uris,omitempty"`
Scopes []string `json:"scopes,omitempty"`
Props *types.OAuthClientProps `json:"props,omitempty"`
IsEnabled bool `json:"is_enabled"`
}
UpsertOAuthClientService struct {
Client *UpsertOAuthClientRequest `json:"client" binding:"required"`
}
UpsertOAuthClientParamCtx struct{}
)
type (
BatchOAuthClientService struct {
IDs []int `json:"ids" binding:"required"`
}
BatchOAuthClientParamCtx struct{}
)
// OAuthClients lists OAuth clients with pagination
func (s *AdminListService) OAuthClients(c *gin.Context) (*ListOAuthClientResponse, error) {
dep := dependency.FromContext(c)
oauthClient := dep.OAuthClientClient()
var isEnabled *bool
if enabledStr, ok := s.Conditions["is_enabled"]; ok {
enabled := enabledStr == "true"
isEnabled = &enabled
}
res, err := oauthClient.List(c, &inventory.ListOAuthClientArgs{
PaginationArgs: &inventory.PaginationArgs{
Page: s.Page - 1,
PageSize: s.PageSize,
OrderBy: s.OrderBy,
Order: inventory.OrderDirection(s.OrderDirection),
},
Name: s.Searches["name"],
IsEnabled: isEnabled,
})
if err != nil {
return nil, serializer.NewError(serializer.CodeDBError, "Failed to list OAuth clients", err)
}
clients := lo.Map(res.Clients, func(client *ent.OAuthClient, _ int) GetOAuthClientResponse {
return GetOAuthClientResponse{
OAuthClient: client,
IsSystem: lo.Contains(systemOAuthClientGUIDs, client.GUID),
}
})
return &ListOAuthClientResponse{
Pagination: res.PaginationResults,
Clients: clients,
}, nil
}
func (s *SingleOAuthClientService) Get(c *gin.Context) (*GetOAuthClientResponse, error) {
dep := dependency.FromContext(c)
oauthClient := dep.OAuthClientClient()
client, err := oauthClient.GetByID(c, s.ID)
if err != nil {
return nil, serializer.NewError(serializer.CodeNotFound, "OAuth client not found", err)
}
res := &GetOAuthClientResponse{
OAuthClient: client,
IsSystem: lo.Contains(systemOAuthClientGUIDs, client.GUID),
}
// Count grants
grants, err := oauthClient.CountGrants(c, s.ID)
if err != nil {
return nil, serializer.NewError(serializer.CodeDBError, "Failed to count grants", err)
}
res.TotalGrants = grants
return res, nil
}
func (r *UpsertOAuthClientRequest) toEnt() *ent.OAuthClient {
return &ent.OAuthClient{
ID: r.ID,
GUID: r.GUID,
Secret: r.Secret,
Name: r.Name,
HomepageURL: r.HomepageURL,
RedirectUris: r.RedirectUris,
Scopes: r.Scopes,
Props: r.Props,
IsEnabled: r.IsEnabled,
}
}
func (s *UpsertOAuthClientService) Create(c *gin.Context) (*GetOAuthClientResponse, error) {
dep := dependency.FromContext(c)
oauthClient := dep.OAuthClientClient()
if s.Client.ID > 0 {
return nil, serializer.NewError(serializer.CodeParamErr, "ID must be 0 for creating new OAuth client", nil)
}
client, err := oauthClient.Create(c, s.Client.toEnt())
if err != nil {
return nil, serializer.NewError(serializer.CodeDBError, "Failed to create OAuth client", err)
}
service := &SingleOAuthClientService{ID: client.ID}
return service.Get(c)
}
func (s *UpsertOAuthClientService) Update(c *gin.Context) (*GetOAuthClientResponse, error) {
dep := dependency.FromContext(c)
oauthClient := dep.OAuthClientClient()
if s.Client.ID == 0 {
return nil, serializer.NewError(serializer.CodeParamErr, "ID is required", nil)
}
// Check if this is a system client
existing, err := oauthClient.GetByID(c, s.Client.ID)
if err != nil {
return nil, serializer.NewError(serializer.CodeNotFound, "OAuth client not found", err)
}
// System clients cannot change GUID
if lo.Contains(systemOAuthClientGUIDs, existing.GUID) {
s.Client.GUID = existing.GUID
}
_, err = oauthClient.Update(c, s.Client.toEnt())
if err != nil {
return nil, serializer.NewError(serializer.CodeDBError, "Failed to update OAuth client", err)
}
service := &SingleOAuthClientService{ID: s.Client.ID}
return service.Get(c)
}
func (s *SingleOAuthClientService) Delete(c *gin.Context) error {
dep := dependency.FromContext(c)
oauthClient := dep.OAuthClientClient()
// Check if client exists
client, err := oauthClient.GetByID(c, s.ID)
if err != nil {
return serializer.NewError(serializer.CodeNotFound, "OAuth client not found", err)
}
// Check if this is a system client
if lo.Contains(systemOAuthClientGUIDs, client.GUID) {
return serializer.NewError(serializer.CodeInvalidActionOnSystemGroup, "Cannot delete system OAuth client", nil)
}
err = oauthClient.Delete(c, s.ID)
if err != nil {
return serializer.NewError(serializer.CodeDBError, "Failed to delete OAuth client", err)
}
return nil
}
func (s *BatchOAuthClientService) Delete(c *gin.Context) error {
dep := dependency.FromContext(c)
oauthClient := dep.OAuthClientClient()
for _, id := range s.IDs {
// Check if client exists
client, err := oauthClient.GetByID(c, id)
if err != nil {
continue // Skip non-existent clients
}
// Check if this is a system client
if lo.Contains(systemOAuthClientGUIDs, client.GUID) {
continue // Skip system clients
}
// Delete the client (including grants)
oauthClient.Delete(c, id)
}
return nil
}
+527
View File
@@ -0,0 +1,527 @@
package admin
import (
"context"
"errors"
"fmt"
"net/url"
"strconv"
"strings"
"time"
"github.com/cloudreve/Cloudreve/v4/application/constants"
"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/credmanager"
"github.com/cloudreve/Cloudreve/v4/pkg/filemanager/driver/cos"
"github.com/cloudreve/Cloudreve/v4/pkg/filemanager/driver/ks3"
"github.com/cloudreve/Cloudreve/v4/pkg/filemanager/driver/obs"
"github.com/cloudreve/Cloudreve/v4/pkg/filemanager/driver/onedrive"
"github.com/cloudreve/Cloudreve/v4/pkg/filemanager/driver/oss"
"github.com/cloudreve/Cloudreve/v4/pkg/filemanager/driver/s3"
"github.com/cloudreve/Cloudreve/v4/pkg/filemanager/manager"
"github.com/cloudreve/Cloudreve/v4/pkg/logging"
"github.com/cloudreve/Cloudreve/v4/pkg/util"
"github.com/cloudreve/Cloudreve/v4/pkg/request"
"github.com/cloudreve/Cloudreve/v4/pkg/serializer"
"github.com/gin-gonic/gin"
)
// PathTestService 本地路径测试服务
type PathTestService struct {
Path string `json:"path" binding:"required"`
}
// SlaveTestService 从机测试服务
type SlaveTestService struct {
Secret string `json:"secret" binding:"required"`
Server string `json:"server" binding:"required"`
}
type (
SlavePingParameterCtx struct{}
// SlavePingService ping slave node
SlavePingService struct {
Callback string `json:"callback" binding:"required"`
}
)
// AddPolicyService 存储策略添加服务
type AddPolicyService struct {
//Policy model.Policy `json:"policy" binding:"required"`
}
// PolicyService 存储策略ID服务
type PolicyService struct {
ID uint `uri:"id" json:"id" binding:"required"`
Region string `json:"region"`
}
// Delete 删除存储策略
func (service *SingleStoragePolicyService) Delete(c *gin.Context) error {
// 禁止删除默认策略
if service.ID == 1 {
return serializer.NewError(serializer.CodeDeleteDefaultPolicy, "", nil)
}
dep := dependency.FromContext(c)
storagePolicyClient := dep.StoragePolicyClient()
ctx := context.WithValue(c, inventory.LoadStoragePolicyGroup{}, true)
ctx = context.WithValue(ctx, inventory.SkipStoragePolicyCache{}, true)
policy, err := storagePolicyClient.GetPolicyByID(ctx, service.ID)
if err != nil {
return serializer.NewError(serializer.CodePolicyNotExist, "", err)
}
// If policy is used by groups, return error
if len(policy.Edges.Groups) > 0 {
return serializer.NewError(serializer.CodePolicyUsedByGroups, strconv.Itoa(len(policy.Edges.Groups)), nil)
}
used, err := dep.FileClient().IsStoragePolicyUsedByEntities(ctx, service.ID)
if err != nil {
return serializer.NewError(serializer.CodeDBError, "Failed to check if policy is used by entities", err)
}
if used {
return serializer.NewError(serializer.CodePolicyUsedByFiles, "", nil)
}
err = storagePolicyClient.Delete(ctx, policy)
if err != nil {
return serializer.NewError(serializer.CodeDBError, "Failed to delete policy", err)
}
return nil
}
// Test 从机响应ping
func (service *SlavePingService) Test(c *gin.Context) error {
master, err := url.Parse(service.Callback)
if err != nil {
return serializer.NewError(serializer.CodeParamErr, "Failed to parse callback url", err)
}
dep := dependency.FromContext(c)
r := dep.RequestClient()
res, err := r.Request(
"GET",
routes.MasterPingUrl(master).String(),
nil,
request.WithContext(c),
request.WithLogger(logging.FromContext(c)),
request.WithCorrelationID(),
request.WithTimeout(time.Duration(10)*time.Second),
).DecodeResponse()
if err != nil {
return serializer.NewError(serializer.CodeSlavePingMaster, err.Error(), nil)
}
version := constants.BackendVersion
if strings.TrimSuffix(res.Data.(string), "-pro") != version {
return serializer.NewError(serializer.CodeVersionMismatch, "Master: "+res.Data.(string)+", Slave: "+version, nil)
}
return nil
}
// Test 测试从机通信
func (service *SlaveTestService) Test() serializer.Response {
//slave, err := url.Parse(service.Server)
//if err != nil {
// return serializer.ParamErrDeprecated("Failed to parse slave node server URL: "+err.Error(), nil)
//}
//
//controller, _ := url.Parse("/api/v3/slave/ping")
//
//// 请求正文
//body := map[string]string{
// "callback": model.GetSiteURL().String(),
//}
//bodyByte, _ := json.Marshal(body)
//
//r := request.NewClientDeprecated()
//res, err := r.Request(
// "POST",
// slave.ResolveReference(controller).String(),
// bytes.NewReader(bodyByte),
// request.WithTimeout(time.Duration(10)*time.Second),
// request.WithCredential(
// auth.HMACAuth{SecretKey: []byte(service.Secret)},
// int64(model.GetIntSetting("slave_api_timeout", 60)),
// ),
//).DecodeResponse()
//if err != nil {
// return serializer.ParamErrDeprecated("Failed to connect to slave node: "+err.Error(), nil)
//}
//
//if res.Code != 0 {
// return serializer.ParamErrDeprecated("Successfully connected to slave node, but slave returns: "+res.Msg, nil)
//}
return serializer.Response{}
}
// Test 测试本地路径
func (service *PathTestService) Test() serializer.Response {
//policy := model.Policy{DirNameRule: service.Path}
//path := policy.GeneratePath(1, "/My File")
//path = filepath.Join(path, "test.txt")
//file, err := util.CreatNestedFile(util.RelativePath(path))
//if err != nil {
// return serializer.ParamErrDeprecated(fmt.Sprintf("Failed to create \"%s\": %s", path, err.Error()), nil)
//}
//
//file.Close()
//os.Remove(path)
return serializer.Response{}
}
const (
policyTypeCondition = "policy_type"
)
// Policies 列出存储策略
func (service *AdminListService) Policies(c *gin.Context) (*ListPolicyResponse, error) {
dep := dependency.FromContext(c)
storagePolicyClient := dep.StoragePolicyClient()
ctx := context.WithValue(c, inventory.LoadStoragePolicyGroup{}, true)
res, err := storagePolicyClient.ListPolicies(ctx, &inventory.ListPolicyParameters{
PaginationArgs: &inventory.PaginationArgs{
Page: service.Page - 1,
PageSize: service.PageSize,
OrderBy: service.OrderBy,
Order: inventory.OrderDirection(service.OrderDirection),
},
Type: types.PolicyType(service.Conditions[policyTypeCondition]),
})
if err != nil {
return nil, serializer.NewError(serializer.CodeDBError, "Failed to list policies", err)
}
return &ListPolicyResponse{
Pagination: res.PaginationResults,
Policies: res.Policies,
}, nil
}
type (
SingleStoragePolicyService struct {
ID int `uri:"id" json:"id" binding:"required"`
}
GetStoragePolicyParamCtx struct{}
)
const (
countEntityQuery = "countEntity"
)
func (service *SingleStoragePolicyService) Get(c *gin.Context) (*GetStoragePolicyResponse, error) {
dep := dependency.FromContext(c)
storagePolicyClient := dep.StoragePolicyClient()
ctx := context.WithValue(c, inventory.LoadStoragePolicyGroup{}, true)
ctx = context.WithValue(ctx, inventory.SkipStoragePolicyCache{}, true)
policy, err := storagePolicyClient.GetPolicyByID(ctx, service.ID)
if err != nil {
return nil, serializer.NewError(serializer.CodeDBError, "Failed to get policy", err)
}
res := &GetStoragePolicyResponse{StoragePolicy: policy}
if c.Query(countEntityQuery) != "" {
count, size, err := dep.FileClient().CountEntityByStoragePolicyID(ctx, service.ID)
if err != nil {
return nil, serializer.NewError(serializer.CodeDBError, "Failed to count entities", err)
}
res.EntitiesCount = count
res.EntitiesSize = size
}
return res, nil
}
type (
CreateStoragePolicyService struct {
Policy *ent.StoragePolicy `json:"policy" binding:"required"`
}
CreateStoragePolicyParamCtx struct{}
)
func (service *CreateStoragePolicyService) Create(c *gin.Context) (*GetStoragePolicyResponse, error) {
dep := dependency.FromContext(c)
storagePolicyClient := dep.StoragePolicyClient()
if service.Policy.Type == types.PolicyTypeLocal {
service.Policy.DirNameRule = util.DataPath("uploads/{uid}/{path}")
}
service.Policy.ID = 0
policy, err := storagePolicyClient.Upsert(c, service.Policy)
if err != nil {
return nil, serializer.NewError(serializer.CodeDBError, "Failed to create policy", err)
}
return &GetStoragePolicyResponse{StoragePolicy: policy}, nil
}
type (
UpdateStoragePolicyService struct {
Policy *ent.StoragePolicy `json:"policy" binding:"required"`
}
UpdateStoragePolicyParamCtx struct{}
)
func (service *UpdateStoragePolicyService) Update(c *gin.Context) (*GetStoragePolicyResponse, error) {
dep := dependency.FromContext(c)
storagePolicyClient := dep.StoragePolicyClient()
id := c.Param("id")
if id == "" {
return nil, serializer.NewError(serializer.CodeParamErr, "ID is required", nil)
}
idInt, err := strconv.Atoi(id)
if err != nil {
return nil, serializer.NewError(serializer.CodeParamErr, "Invalid ID", err)
}
service.Policy.ID = idInt
sc, tx, ctx, err := inventory.WithTx(c, storagePolicyClient)
if err != nil {
return nil, serializer.NewError(serializer.CodeDBError, "Failed to create transaction", err)
}
_, err = sc.Upsert(ctx, service.Policy)
if err != nil {
_ = inventory.Rollback(tx)
return nil, serializer.NewError(serializer.CodeDBError, "Failed to update policy", err)
}
if err := inventory.Commit(tx); err != nil {
return nil, serializer.NewError(serializer.CodeDBError, "Failed to commit transaction", err)
}
_ = dep.KV().Delete(manager.EntityUrlCacheKeyPrefix)
s := SingleStoragePolicyService{ID: idInt}
return s.Get(c)
}
type (
CreateStoragePolicyCorsService struct {
Policy *ent.StoragePolicy `json:"policy" binding:"required"`
}
CreateStoragePolicyCorsParamCtx struct{}
)
func (service *CreateStoragePolicyCorsService) Create(c *gin.Context) error {
dep := dependency.FromContext(c)
switch service.Policy.Type {
case types.PolicyTypeOss:
handler, err := oss.New(c, service.Policy, dep.SettingProvider(), dep.ConfigProvider(), dep.Logger(), dep.MimeDetector(c))
if err != nil {
return serializer.NewError(serializer.CodeDBError, "Failed to create oss driver", err)
}
if err := handler.CORS(); err != nil {
return serializer.NewError(serializer.CodeInternalSetting, "Failed to create cors: "+err.Error(), err)
}
return nil
case types.PolicyTypeCos:
handler, err := cos.New(c, service.Policy, dep.SettingProvider(), dep.ConfigProvider(), dep.Logger(), dep.MimeDetector(c))
if err != nil {
return serializer.NewError(serializer.CodeDBError, "Failed to create cos driver", err)
}
if err := handler.CORS(); err != nil {
return serializer.NewError(serializer.CodeInternalSetting, "Failed to create cors: "+err.Error(), err)
}
return nil
case types.PolicyTypeS3:
handler, err := s3.New(c, service.Policy, dep.SettingProvider(), dep.ConfigProvider(), dep.Logger(), dep.MimeDetector(c))
if err != nil {
return serializer.NewError(serializer.CodeDBError, "Failed to create s3 driver", err)
}
if err := handler.CORS(); err != nil {
return serializer.NewError(serializer.CodeInternalSetting, "Failed to create cors: "+err.Error(), err)
}
return nil
case types.PolicyTypeKs3:
handler, err := ks3.New(c, service.Policy, dep.SettingProvider(), dep.ConfigProvider(), dep.Logger(), dep.MimeDetector(c))
if err != nil {
return serializer.NewError(serializer.CodeDBError, "Failed to create ks3 driver", err)
}
if err := handler.CORS(); err != nil {
return serializer.NewError(serializer.CodeInternalSetting, "Failed to create cors: "+err.Error(), err)
}
return nil
case types.PolicyTypeObs:
handler, err := obs.New(c, service.Policy, dep.SettingProvider(), dep.ConfigProvider(), dep.Logger(), dep.MimeDetector(c))
if err != nil {
return serializer.NewError(serializer.CodeDBError, "Failed to create obs driver", err)
}
if err := handler.CORS(); err != nil {
return serializer.NewError(serializer.CodeInternalSetting, "Failed to create cors: "+err.Error(), err)
}
return nil
default:
return serializer.NewError(serializer.CodeParamErr, "Unsupported policy type", nil)
}
}
type (
GetOauthRedirectService struct {
ID int `json:"id" binding:"required"`
Secret string `json:"secret" binding:"required"`
AppID string `json:"app_id" binding:"required"`
}
GetOauthRedirectParamCtx struct{}
)
// GetOAuth 获取 OneDrive OAuth 地址
func (service *GetOauthRedirectService) GetOAuth(c *gin.Context) (string, error) {
dep := dependency.FromContext(c)
storagePolicyClient := dep.StoragePolicyClient()
policy, err := storagePolicyClient.GetPolicyByID(c, service.ID)
if err != nil || policy.Type != types.PolicyTypeOd {
return "", serializer.NewError(serializer.CodePolicyNotExist, "", nil)
}
// Update to latest redirect url
policy.Settings.OauthRedirect = routes.MasterPolicyOAuthCallback(dep.SettingProvider().SiteURL(c)).String()
policy.SecretKey = service.Secret
policy.BucketName = service.AppID
policy, err = storagePolicyClient.Upsert(c, policy)
if err != nil {
return "", serializer.NewError(serializer.CodeDBError, "Failed to update policy", err)
}
client := onedrive.NewClient(policy, dep.RequestClient(), dep.CredManager(), dep.Logger(), dep.SettingProvider(), 0)
redirect := client.OAuthURL(context.Background(), []string{
"offline_access",
"files.readwrite.all",
})
return redirect, nil
}
func GetPolicyOAuthURL(c *gin.Context) string {
dep := dependency.FromContext(c)
return routes.MasterPolicyOAuthCallback(dep.SettingProvider().SiteURL(c)).String()
}
// GetOauthCredentialStatus returns last refresh time of oauth credential
func (service *SingleStoragePolicyService) GetOauthCredentialStatus(c *gin.Context) (*OauthCredentialStatus, error) {
dep := dependency.FromContext(c)
storagePolicyClient := dep.StoragePolicyClient()
policy, err := storagePolicyClient.GetPolicyByID(c, service.ID)
if err != nil || policy.Type != types.PolicyTypeOd {
return nil, serializer.NewError(serializer.CodePolicyNotExist, "", nil)
}
if policy.AccessKey == "" {
return &OauthCredentialStatus{Valid: false}, nil
}
token, err := dep.CredManager().Obtain(c, onedrive.CredentialKey(policy.ID))
if err != nil {
if errors.Is(err, credmanager.ErrNotFound) {
return &OauthCredentialStatus{Valid: false}, nil
}
return nil, serializer.NewError(serializer.CodeDBError, "Failed to get credential", err)
}
return &OauthCredentialStatus{Valid: true, LastRefreshTime: token.RefreshedAt()}, nil
}
type (
FinishOauthCallbackService struct {
Code string `json:"code" binding:"required"`
State string `json:"state" binding:"required"`
}
FinishOauthCallbackParamCtx struct{}
)
func (service *FinishOauthCallbackService) Finish(c *gin.Context) error {
dep := dependency.FromContext(c)
storagePolicyClient := dep.StoragePolicyClient()
policyId, err := strconv.Atoi(service.State)
if err != nil {
return serializer.NewError(serializer.CodeParamErr, "Invalid state", err)
}
policy, err := storagePolicyClient.GetPolicyByID(c, policyId)
if err != nil {
return serializer.NewError(serializer.CodePolicyNotExist, "", nil)
}
if policy.Type != types.PolicyTypeOd {
return serializer.NewError(serializer.CodeParamErr, "Invalid policy type", nil)
}
client := onedrive.NewClient(policy, dep.RequestClient(), dep.CredManager(), dep.Logger(), dep.SettingProvider(), 0)
credential, err := client.ObtainToken(c, onedrive.WithCode(service.Code))
if err != nil {
return serializer.NewError(serializer.CodeParamErr, "Failed to obtain token: "+err.Error(), err)
}
credManager := dep.CredManager()
err = credManager.Upsert(c, credential)
if err != nil {
return serializer.NewError(serializer.CodeInternalSetting, "Failed to upsert credential", err)
}
_, err = credManager.Obtain(c, onedrive.CredentialKey(policy.ID))
if err != nil {
return serializer.NewError(serializer.CodeInternalSetting, "Failed to obtain credential", err)
}
return nil
}
func (service *SingleStoragePolicyService) GetSharePointDriverRoot(c *gin.Context) (string, error) {
dep := dependency.FromContext(c)
storagePolicyClient := dep.StoragePolicyClient()
policy, err := storagePolicyClient.GetPolicyByID(c, service.ID)
if err != nil {
return "", serializer.NewError(serializer.CodePolicyNotExist, "", nil)
}
if policy.Type != types.PolicyTypeOd {
return "", serializer.NewError(serializer.CodeParamErr, "Invalid policy type", nil)
}
client := onedrive.NewClient(policy, dep.RequestClient(), dep.CredManager(), dep.Logger(), dep.SettingProvider(), 0)
root, err := client.GetSiteIDByURL(c, c.Query("url"))
if err != nil {
return "", serializer.NewError(serializer.CodeInternalSetting, "Failed to get site id", err)
}
return fmt.Sprintf("sites/%s/drive", root), nil
}
+154
View File
@@ -0,0 +1,154 @@
package admin
import (
"encoding/gob"
"time"
"github.com/cloudreve/Cloudreve/v4/ent"
"github.com/cloudreve/Cloudreve/v4/inventory"
"github.com/cloudreve/Cloudreve/v4/pkg/filemanager/fs"
"github.com/cloudreve/Cloudreve/v4/pkg/queue"
"github.com/cloudreve/Cloudreve/v4/pkg/setting"
)
type ListShareResponse struct {
Pagination *inventory.PaginationResults `json:"pagination"`
Shares []GetShareResponse `json:"shares"`
}
type GetShareResponse struct {
*ent.Share
UserHashID string `json:"user_hash_id,omitempty"`
ShareLink string `json:"share_link,omitempty"`
}
type ListTaskResponse struct {
Pagination *inventory.PaginationResults `json:"pagination"`
Tasks []GetTaskResponse `json:"tasks"`
}
type GetTaskResponse struct {
*ent.Task
UserHashID string `json:"user_hash_id,omitempty"`
TaskHashID string `json:"task_hash_id,omitempty"`
Summary *queue.Summary `json:"summary,omitempty"`
Node *ent.Node `json:"node,omitempty"`
}
type ListEntityResponse struct {
Pagination *inventory.PaginationResults `json:"pagination"`
Entities []GetEntityResponse `json:"entities"`
}
type GetEntityResponse struct {
*ent.Entity
UserHashID string `json:"user_hash_id,omitempty"`
UserHashIDMap map[int]string `json:"user_hash_id_map,omitempty"`
}
type ListFileResponse struct {
Pagination *inventory.PaginationResults `json:"pagination"`
Files []GetFileResponse `json:"files"`
}
type GetFileResponse struct {
*ent.File
UserHashID string `json:"user_hash_id,omitempty"`
FileHashID string `json:"file_hash_id,omitempty"`
DirectLinkMap map[int]string `json:"direct_link_map,omitempty"`
}
type ListUserResponse struct {
Pagination *inventory.PaginationResults `json:"pagination"`
Users []GetUserResponse `json:"users"`
}
type GetUserResponse struct {
*ent.User
HashID string `json:"hash_id,omitempty"`
TwoFAEnabled bool `json:"two_fa_enabled,omitempty"`
Capacity *fs.Capacity `json:"capacity,omitempty"`
}
type GetNodeResponse struct {
*ent.Node
}
type GetGroupResponse struct {
*ent.Group
TotalUsers int `json:"total_users"`
}
type OauthCredentialStatus struct {
Valid bool `json:"valid"`
LastRefreshTime *time.Time `json:"last_refresh_time"`
}
type GetStoragePolicyResponse struct {
*ent.StoragePolicy
EntitiesCount int `json:"entities_count,omitempty"`
EntitiesSize int `json:"entities_size,omitempty"`
}
type ListNodeResponse struct {
Pagination *inventory.PaginationResults `json:"pagination"`
Nodes []*ent.Node `json:"nodes"`
}
type ListPolicyResponse struct {
Pagination *inventory.PaginationResults `json:"pagination"`
Policies []*ent.StoragePolicy `json:"policies"`
}
type QueueMetric struct {
Name setting.QueueType `json:"name"`
BusyWorkers int `json:"busy_workers"`
SuccessTasks int `json:"success_tasks"`
FailureTasks int `json:"failure_tasks"`
SubmittedTasks int `json:"submitted_tasks"`
SuspendingTasks int `json:"suspending_tasks"`
}
type ListGroupResponse struct {
Groups []*ent.Group `json:"groups"`
Pagination *inventory.PaginationResults `json:"pagination"`
}
type ListOAuthClientResponse struct {
Clients []GetOAuthClientResponse `json:"clients"`
Pagination *inventory.PaginationResults `json:"pagination"`
}
type GetOAuthClientResponse struct {
*ent.OAuthClient
IsSystem bool `json:"is_system"`
TotalGrants int `json:"total_grants,omitempty"`
}
type HomepageSummary struct {
MetricsSummary *MetricsSummary `json:"metrics_summary"`
SiteURls []string `json:"site_urls"`
Version *Version `json:"version"`
}
type MetricsSummary struct {
Dates []time.Time `json:"dates"`
Files []int `json:"files"`
Users []int `json:"users"`
Shares []int `json:"shares"`
FileTotal int `json:"file_total"`
UserTotal int `json:"user_total"`
ShareTotal int `json:"share_total"`
EntitiesTotal int `json:"entities_total"`
GeneratedAt time.Time `json:"generated_at"`
}
type Version struct {
Version string `json:"version"`
Pro bool `json:"pro"`
Commit string `json:"commit"`
}
func init() {
gob.Register(MetricsSummary{})
}
+161
View File
@@ -0,0 +1,161 @@
package admin
import (
"context"
"strconv"
"strings"
"github.com/cloudreve/Cloudreve/v4/application/dependency"
"github.com/cloudreve/Cloudreve/v4/ent"
"github.com/cloudreve/Cloudreve/v4/inventory"
"github.com/cloudreve/Cloudreve/v4/pkg/cluster/routes"
"github.com/cloudreve/Cloudreve/v4/pkg/hashid"
"github.com/cloudreve/Cloudreve/v4/pkg/serializer"
"github.com/gin-gonic/gin"
"github.com/samber/lo"
)
const (
shareUserIDCondition = "share_user_id"
shareFileIDCondition = "share_file_id"
shareIDCondition = "share_id"
)
func (s *AdminListService) Shares(c *gin.Context) (*ListShareResponse, error) {
dep := dependency.FromContext(c)
shareClient := dep.ShareClient()
hasher := dep.HashIDEncoder()
var (
err error
userID int
fileID int
shareIDs []int
)
if s.Conditions[shareUserIDCondition] != "" {
userID, err = strconv.Atoi(s.Conditions[shareUserIDCondition])
if err != nil {
return nil, serializer.NewError(serializer.CodeParamErr, "Invalid share user ID", err)
}
}
if s.Conditions[shareFileIDCondition] != "" {
fileID, err = strconv.Atoi(s.Conditions[shareFileIDCondition])
if err != nil {
return nil, serializer.NewError(serializer.CodeParamErr, "Invalid share file ID", err)
}
}
if s.Conditions[shareIDCondition] != "" {
shareIdStrs := strings.Split(s.Conditions[shareIDCondition], ",")
for _, shareIdStr := range shareIdStrs {
shareID, err := strconv.Atoi(shareIdStr)
if err != nil {
return nil, serializer.NewError(serializer.CodeParamErr, "Invalid share ID", err)
}
shareIDs = append(shareIDs, shareID)
}
}
ctx := context.WithValue(c, inventory.LoadShareFile{}, true)
ctx = context.WithValue(ctx, inventory.LoadShareUser{}, true)
res, err := shareClient.List(ctx, &inventory.ListShareArgs{
PaginationArgs: &inventory.PaginationArgs{
Page: s.Page - 1,
PageSize: s.PageSize,
OrderBy: s.OrderBy,
Order: inventory.OrderDirection(s.OrderDirection),
},
UserID: userID,
FileID: fileID,
ShareIDs: shareIDs,
})
if err != nil {
return nil, serializer.NewError(serializer.CodeDBError, "Failed to list shares", err)
}
siteUrl := dep.SettingProvider().SiteURL(c)
return &ListShareResponse{
Pagination: res.PaginationResults,
Shares: lo.Map(res.Shares, func(share *ent.Share, _ int) GetShareResponse {
var (
uid string
shareLink string
)
if share.Edges.User != nil {
uid = hashid.EncodeUserID(hasher, share.Edges.User.ID)
}
shareLink = routes.MasterShareUrl(siteUrl, hashid.EncodeShareID(hasher, share.ID), share.Password).String()
return GetShareResponse{
Share: share,
UserHashID: uid,
ShareLink: shareLink,
}
}),
}, nil
}
type (
SingleShareService struct {
ShareID int `uri:"id" binding:"required"`
}
SingleShareParamCtx struct{}
)
func (s *SingleShareService) Get(c *gin.Context) (*GetShareResponse, error) {
dep := dependency.FromContext(c)
shareClient := dep.ShareClient()
hasher := dep.HashIDEncoder()
ctx := context.WithValue(c, inventory.LoadShareFile{}, true)
ctx = context.WithValue(ctx, inventory.LoadShareUser{}, true)
share, err := shareClient.GetByID(ctx, s.ShareID)
if err != nil {
return nil, serializer.NewError(serializer.CodeDBError, "Failed to get share", err)
}
var (
uid string
shareLink string
)
if share.Edges.User != nil {
uid = hashid.EncodeUserID(hasher, share.Edges.User.ID)
}
siteUrl := dep.SettingProvider().SiteURL(c)
shareLink = routes.MasterShareUrl(siteUrl, hashid.EncodeShareID(hasher, share.ID), share.Password).String()
return &GetShareResponse{
Share: share,
UserHashID: uid,
ShareLink: shareLink,
}, nil
}
type (
BatchShareService struct {
ShareIDs []int `json:"ids" binding:"required"`
}
BatchShareParamCtx struct{}
)
func (s *BatchShareService) Delete(c *gin.Context) error {
dep := dependency.FromContext(c)
shareClient := dep.ShareClient()
if err := shareClient.DeleteBatch(c, s.ShareIDs); err != nil {
return serializer.NewError(serializer.CodeDBError, "Failed to delete shares", err)
}
return nil
}
+433
View File
@@ -0,0 +1,433 @@
package admin
import (
"context"
"encoding/gob"
"encoding/json"
"fmt"
"net/url"
"reflect"
"strings"
"time"
"github.com/cloudreve/Cloudreve/v4/application/constants"
"github.com/cloudreve/Cloudreve/v4/application/dependency"
"github.com/cloudreve/Cloudreve/v4/inventory"
"github.com/cloudreve/Cloudreve/v4/pkg/filemanager/manager"
"github.com/cloudreve/Cloudreve/v4/pkg/serializer"
"github.com/cloudreve/Cloudreve/v4/pkg/setting"
"github.com/cloudreve/Cloudreve/v4/pkg/thumb"
"github.com/cloudreve/Cloudreve/v4/pkg/util"
"github.com/gin-gonic/gin"
"github.com/samber/lo"
)
func init() {
gob.Register(map[string]interface{}{})
gob.Register(map[string]string{})
}
// NoParamService 无需参数的服务
type NoParamService struct {
}
// BatchSettingChangeService 设定批量更改服务
type BatchSettingChangeService struct {
Options []SettingChangeService `json:"options"`
}
// SettingChangeService 设定更改服务
type SettingChangeService struct {
Key string `json:"key" binding:"required"`
Value string `json:"value"`
}
// Change 批量更改站点设定
func (service *BatchSettingChangeService) Change() serializer.Response {
//cacheClean := make([]string, 0, len(service.Options))
//tx := model.DB.Begin()
//
//for _, setting := range service.Options {
//
// if err := tx.Model(&model.Setting{}).Where("name = ?", setting.Key).Update("value", setting.Value).Error; err != nil {
// cache.Deletes(cacheClean, "setting_")
// tx.Rollback()
// return serializer.ErrDeprecated(serializer.CodeUpdateSetting, "Setting "+setting.Key+" failed to update", err)
// }
//
// cacheClean = append(cacheClean, setting.Key)
//}
//
//if err := tx.Commit().Error; err != nil {
// return serializer.DBErrDeprecated("Failed to update setting", err)
//}
//
//cache.Deletes(cacheClean, "setting_")
return serializer.Response{}
}
const (
SummaryRangeDays = 12
MetricCacheKey = "admin_summary"
metricErrMsg = "Failed to generate metrics summary"
)
type (
SummaryService struct {
Generate bool `form:"generate"`
}
SummaryParamCtx struct{}
)
// Summary 获取站点统计概况
func (s *SummaryService) Summary(c *gin.Context) (*HomepageSummary, error) {
dep := dependency.FromContext(c)
kv := dep.KV()
res := &HomepageSummary{
Version: &Version{
Version: constants.BackendVersion,
Pro: constants.IsProBool,
Commit: constants.LastCommit,
},
SiteURls: lo.Map(dep.SettingProvider().AllSiteURLs(c), func(item *url.URL, index int) string {
return item.String()
}),
}
if summary, ok := kv.Get(MetricCacheKey); ok {
summaryCasted := summary.(MetricsSummary)
res.MetricsSummary = &summaryCasted
return res, nil
}
if !s.Generate {
return res, nil
}
summary := &MetricsSummary{
Files: make([]int, SummaryRangeDays),
Users: make([]int, SummaryRangeDays),
Shares: make([]int, SummaryRangeDays),
Dates: make([]time.Time, SummaryRangeDays),
GeneratedAt: time.Now(),
}
fileClient := dep.FileClient()
userClient := dep.UserClient()
shareClient := dep.ShareClient()
toRound := time.Now()
timeBase := time.Date(toRound.Year(), toRound.Month(), toRound.Day()+1, 0, 0, 0, 0, toRound.Location())
for day := range summary.Files {
start := timeBase.Add(-time.Duration(SummaryRangeDays-day) * time.Hour * 24)
end := timeBase.Add(-time.Duration(SummaryRangeDays-day-1) * time.Hour * 24)
summary.Dates[day] = start
fileTotal, err := fileClient.CountByTimeRange(c, &start, &end)
if err != nil {
return nil, serializer.NewError(serializer.CodeDBError, metricErrMsg, nil)
}
userTotal, err := userClient.CountByTimeRange(c, &start, &end)
if err != nil {
return nil, serializer.NewError(serializer.CodeDBError, metricErrMsg, nil)
}
shareTotal, err := shareClient.CountByTimeRange(c, &start, &end)
if err != nil {
return nil, serializer.NewError(serializer.CodeDBError, metricErrMsg, nil)
}
summary.Files[day] = fileTotal
summary.Users[day] = userTotal
summary.Shares[day] = shareTotal
}
var err error
summary.FileTotal, err = fileClient.CountByTimeRange(c, nil, nil)
if err != nil {
return nil, serializer.NewError(serializer.CodeDBError, metricErrMsg, nil)
}
summary.UserTotal, err = userClient.CountByTimeRange(c, nil, nil)
if err != nil {
return nil, serializer.NewError(serializer.CodeDBError, metricErrMsg, nil)
}
summary.ShareTotal, err = shareClient.CountByTimeRange(c, nil, nil)
if err != nil {
return nil, serializer.NewError(serializer.CodeDBError, metricErrMsg, nil)
}
summary.EntitiesTotal, err = fileClient.CountEntityByTimeRange(c, nil, nil)
if err != nil {
return nil, serializer.NewError(serializer.CodeDBError, metricErrMsg, nil)
}
_ = kv.Set(MetricCacheKey, *summary, 86400)
res.MetricsSummary = summary
return res, nil
}
// ThumbGeneratorTestService 缩略图生成测试服务
type (
ThumbGeneratorTestService struct {
Name string `json:"name" binding:"required"`
Executable string `json:"executable" binding:"required"`
}
ThumbGeneratorTestParamCtx struct{}
)
// Test 通过获取生成器版本来测试
func (s *ThumbGeneratorTestService) Test(c *gin.Context) (string, error) {
version, err := thumb.TestGenerator(c, s.Name, s.Executable)
if err != nil {
return "", serializer.NewError(serializer.CodeParamErr, "Failed to invoke generator: "+err.Error(), err)
}
return version, nil
}
type (
GetSettingService struct {
Keys []string `json:"keys" binding:"required"`
}
GetSettingParamCtx struct{}
)
func (s *GetSettingService) GetSetting(c *gin.Context) (map[string]string, error) {
dep := dependency.FromContext(c)
res, err := dep.SettingClient().Gets(c, lo.Filter(s.Keys, func(item string, index int) bool {
_, ok := inventory.RedactedSettings[strings.ToLower(item)]
return !ok
}))
if err != nil {
return nil, serializer.NewError(serializer.CodeDBError, "Failed to get settings", err)
}
return res, nil
}
type (
SetSettingService struct {
Settings map[string]string `json:"settings" binding:"required"`
}
SetSettingParamCtx struct{}
SettingPreProcessor func(ctx context.Context, settings map[string]string) error
SettingPostProcessor func(ctx context.Context, settings map[string]string) error
)
var (
preprocessors = map[string]SettingPreProcessor{
"siteURL": siteUrlPreProcessor,
"mime_mapping": mimeMappingPreProcessor,
"secret_key": secretKeyPreProcessor,
}
postprocessors = map[string]SettingPostProcessor{
"mime_mapping": mimeMappingPostProcessor,
"media_meta_exif": mediaMetaPostProcessor,
"media_meta_music": mediaMetaPostProcessor,
"media_meta_ffprobe": mediaMetaPostProcessor,
"smtpUser": emailPostProcessor,
"smtpPass": emailPostProcessor,
"smtpHost": emailPostProcessor,
"smtpPort": emailPostProcessor,
"smtpEncryption": emailPostProcessor,
"smtpFrom": emailPostProcessor,
"replyTo": emailPostProcessor,
"fromName": emailPostProcessor,
"fromAdress": emailPostProcessor,
"queue_media_meta_worker_num": mediaMetaQueuePostProcessor,
"queue_media_meta_max_execution": mediaMetaQueuePostProcessor,
"queue_media_meta_backoff_factor": mediaMetaQueuePostProcessor,
"queue_media_meta_backoff_max_duration": mediaMetaQueuePostProcessor,
"queue_media_meta_max_retry": mediaMetaQueuePostProcessor,
"queue_media_meta_retry_delay": mediaMetaQueuePostProcessor,
"queue_thumb_worker_num": thumbQueuePostProcessor,
"queue_thumb_max_execution": thumbQueuePostProcessor,
"queue_thumb_backoff_factor": thumbQueuePostProcessor,
"queue_thumb_backoff_max_duration": thumbQueuePostProcessor,
"queue_thumb_max_retry": thumbQueuePostProcessor,
"queue_thumb_retry_delay": thumbQueuePostProcessor,
"queue_recycle_worker_num": entityRecycleQueuePostProcessor,
"queue_recycle_max_execution": entityRecycleQueuePostProcessor,
"queue_recycle_backoff_factor": entityRecycleQueuePostProcessor,
"queue_recycle_backoff_max_duration": entityRecycleQueuePostProcessor,
"queue_recycle_max_retry": entityRecycleQueuePostProcessor,
"queue_recycle_retry_delay": entityRecycleQueuePostProcessor,
"queue_io_intense_worker_num": ioIntenseQueuePostProcessor,
"queue_io_intense_max_execution": ioIntenseQueuePostProcessor,
"queue_io_intense_backoff_factor": ioIntenseQueuePostProcessor,
"queue_io_intense_backoff_max_duration": ioIntenseQueuePostProcessor,
"queue_io_intense_max_retry": ioIntenseQueuePostProcessor,
"queue_io_intense_retry_delay": ioIntenseQueuePostProcessor,
"queue_remote_download_worker_num": remoteDownloadQueuePostProcessor,
"queue_remote_download_max_execution": remoteDownloadQueuePostProcessor,
"queue_remote_download_backoff_factor": remoteDownloadQueuePostProcessor,
"queue_remote_download_backoff_max_duration": remoteDownloadQueuePostProcessor,
"queue_remote_download_max_retry": remoteDownloadQueuePostProcessor,
"queue_remote_download_retry_delay": remoteDownloadQueuePostProcessor,
"secret_key": secretKeyPostProcessor,
"fts_meilisearch_embed_config": meilisearchPostProcessor,
"fts_meilisearch_endpoint": meilisearchPostProcessor,
"fts_meilisearch_api_key": meilisearchPostProcessor,
"fts_meilisearch_embed_enabled": meilisearchPostProcessor,
"fts_meilisearch_page_size": meilisearchPostProcessor,
"fts_tika_endpoint": tikaPostProcessor,
"fts_tika_exts": tikaPostProcessor,
"fts_tika_max_file_size": tikaPostProcessor,
}
)
func (s *SetSettingService) SetSetting(c *gin.Context) (map[string]string, error) {
dep := dependency.FromContext(c)
kv := dep.KV()
settingClient := dep.SettingClient()
// Preprocess settings
allPreprocessors := make(map[string]SettingPreProcessor)
allPostprocessors := make(map[string]SettingPostProcessor)
for k, _ := range s.Settings {
if preprocessor, ok := preprocessors[k]; ok {
fnName := reflect.TypeOf(preprocessor).Name()
if _, ok := allPreprocessors[fnName]; !ok {
allPreprocessors[fnName] = preprocessor
}
}
if postprocessor, ok := postprocessors[k]; ok {
fnName := reflect.TypeOf(postprocessor).Name()
if _, ok := allPostprocessors[fnName]; !ok {
allPostprocessors[fnName] = postprocessor
}
}
}
// Execute all preprocessors
for _, preprocessor := range allPreprocessors {
if err := preprocessor(c, s.Settings); err != nil {
return nil, serializer.NewError(serializer.CodeParamErr, "Failed to validate settings", err)
}
}
// Save to db
sc, tx, ctx, err := inventory.WithTx(c, settingClient)
if err != nil {
return nil, serializer.NewError(serializer.CodeDBError, "Failed to create transaction", err)
}
if err := sc.Set(ctx, s.Settings); err != nil {
_ = inventory.Rollback(tx)
return nil, serializer.NewError(serializer.CodeDBError, "Failed to save settings", err)
}
if err := inventory.Commit(tx); err != nil {
return nil, serializer.NewError(serializer.CodeDBError, "Failed to commit transaction", err)
}
// Clean cache
if err := kv.Delete(setting.KvSettingPrefix, lo.Keys(s.Settings)...); err != nil {
return nil, serializer.NewError(serializer.CodeInternalSetting, "Failed to clear cache", err)
}
// Execute post preprocessors
for _, postprocessor := range allPostprocessors {
if err := postprocessor(ctx, s.Settings); err != nil {
return nil, serializer.NewError(serializer.CodeParamErr, "Failed to post process settings", err)
}
}
return s.Settings, nil
}
func siteUrlPreProcessor(ctx context.Context, settings map[string]string) error {
siteURL := settings["siteURL"]
urls := strings.Split(siteURL, ",")
for index, u := range urls {
urlParsed, err := url.Parse(u)
if err != nil {
return fmt.Errorf("Failed to parse siteURL %q: %w", u, err)
}
urls[index] = urlParsed.String()
}
settings["siteURL"] = strings.Join(urls, ",")
return nil
}
func secretKeyPreProcessor(ctx context.Context, settings map[string]string) error {
settings["secret_key"] = util.RandStringRunesCrypto(256)
return nil
}
func mimeMappingPreProcessor(ctx context.Context, settings map[string]string) error {
var mapping map[string]string
if err := json.Unmarshal([]byte(settings["mime_mapping"]), &mapping); err != nil {
return serializer.NewError(serializer.CodeParamErr, "Invalid mime mapping", err)
}
return nil
}
func mimeMappingPostProcessor(ctx context.Context, settings map[string]string) error {
dep := dependency.FromContext(ctx)
dep.MimeDetector(context.WithValue(ctx, dependency.ReloadCtx{}, true))
return nil
}
func mediaMetaPostProcessor(ctx context.Context, settings map[string]string) error {
dep := dependency.FromContext(ctx)
dep.MediaMetaExtractor(context.WithValue(ctx, dependency.ReloadCtx{}, true))
return nil
}
func emailPostProcessor(ctx context.Context, settings map[string]string) error {
dep := dependency.FromContext(ctx)
dep.EmailClient(context.WithValue(ctx, dependency.ReloadCtx{}, true))
return nil
}
func mediaMetaQueuePostProcessor(ctx context.Context, settings map[string]string) error {
dep := dependency.FromContext(ctx)
dep.MediaMetaQueue(context.WithValue(ctx, dependency.ReloadCtx{}, true)).Start()
return nil
}
func ioIntenseQueuePostProcessor(ctx context.Context, settings map[string]string) error {
dep := dependency.FromContext(ctx)
dep.IoIntenseQueue(context.WithValue(ctx, dependency.ReloadCtx{}, true)).Start()
return nil
}
func remoteDownloadQueuePostProcessor(ctx context.Context, settings map[string]string) error {
dep := dependency.FromContext(ctx)
dep.RemoteDownloadQueue(context.WithValue(ctx, dependency.ReloadCtx{}, true)).Start()
return nil
}
func entityRecycleQueuePostProcessor(ctx context.Context, settings map[string]string) error {
dep := dependency.FromContext(ctx)
dep.EntityRecycleQueue(context.WithValue(ctx, dependency.ReloadCtx{}, true)).Start()
return nil
}
func thumbQueuePostProcessor(ctx context.Context, settings map[string]string) error {
dep := dependency.FromContext(ctx)
dep.ThumbQueue(context.WithValue(ctx, dependency.ReloadCtx{}, true)).Start()
return nil
}
func secretKeyPostProcessor(ctx context.Context, settings map[string]string) error {
dep := dependency.FromContext(ctx)
dep.KV().Delete(manager.EntityUrlCacheKeyPrefix)
settings["secret_key"] = ""
return nil
}
func meilisearchPostProcessor(ctx context.Context, settings map[string]string) error {
dep := dependency.FromContext(ctx)
dep.SearchIndexer(context.WithValue(ctx, dependency.ReloadCtx{}, true))
return nil
}
func tikaPostProcessor(ctx context.Context, settings map[string]string) error {
dep := dependency.FromContext(ctx)
dep.TextExtractor(context.WithValue(ctx, dependency.ReloadCtx{}, true))
return nil
}
+282
View File
@@ -0,0 +1,282 @@
package admin
import (
"context"
"strconv"
"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/pkg/hashid"
"github.com/cloudreve/Cloudreve/v4/pkg/queue"
"github.com/cloudreve/Cloudreve/v4/pkg/serializer"
"github.com/cloudreve/Cloudreve/v4/pkg/setting"
"github.com/gin-gonic/gin"
"github.com/gofrs/uuid"
"github.com/samber/lo"
)
func GetQueueMetrics(c *gin.Context) ([]QueueMetric, error) {
res := []QueueMetric{}
dep := dependency.FromContext(c)
mediaMeta := dep.MediaMetaQueue(c)
entityRecycle := dep.EntityRecycleQueue(c)
ioIntense := dep.IoIntenseQueue(c)
remoteDownload := dep.RemoteDownloadQueue(c)
thumb := dep.ThumbQueue(c)
res = append(res, QueueMetric{
Name: setting.QueueTypeMediaMeta,
BusyWorkers: mediaMeta.BusyWorkers(),
SuccessTasks: mediaMeta.SuccessTasks(),
FailureTasks: mediaMeta.FailureTasks(),
SubmittedTasks: mediaMeta.SubmittedTasks(),
SuspendingTasks: mediaMeta.SuspendingTasks(),
})
res = append(res, QueueMetric{
Name: setting.QueueTypeEntityRecycle,
BusyWorkers: entityRecycle.BusyWorkers(),
SuccessTasks: entityRecycle.SuccessTasks(),
FailureTasks: entityRecycle.FailureTasks(),
SubmittedTasks: entityRecycle.SubmittedTasks(),
SuspendingTasks: entityRecycle.SuspendingTasks(),
})
res = append(res, QueueMetric{
Name: setting.QueueTypeIOIntense,
BusyWorkers: ioIntense.BusyWorkers(),
SuccessTasks: ioIntense.SuccessTasks(),
FailureTasks: ioIntense.FailureTasks(),
SubmittedTasks: ioIntense.SubmittedTasks(),
SuspendingTasks: ioIntense.SuspendingTasks(),
})
res = append(res, QueueMetric{
Name: setting.QueueTypeRemoteDownload,
BusyWorkers: remoteDownload.BusyWorkers(),
SuccessTasks: remoteDownload.SuccessTasks(),
FailureTasks: remoteDownload.FailureTasks(),
SubmittedTasks: remoteDownload.SubmittedTasks(),
SuspendingTasks: remoteDownload.SuspendingTasks(),
})
res = append(res, QueueMetric{
Name: setting.QueueTypeThumb,
BusyWorkers: thumb.BusyWorkers(),
SuccessTasks: thumb.SuccessTasks(),
FailureTasks: thumb.FailureTasks(),
SubmittedTasks: thumb.SubmittedTasks(),
SuspendingTasks: thumb.SuspendingTasks(),
})
return res, nil
}
const (
taskTypeCondition = "task_type"
taskStatusCondition = "task_status"
taskCorrelationIDCondition = "task_correlation_id"
taskUserIDCondition = "task_user_id"
)
func (s *AdminListService) Tasks(c *gin.Context) (*ListTaskResponse, error) {
dep := dependency.FromContext(c)
taskClient := dep.TaskClient()
hasher := dep.HashIDEncoder()
var (
err error
userID int
correlationID *uuid.UUID
status []task.Status
taskType []string
)
if s.Conditions[taskTypeCondition] != "" {
taskType = []string{s.Conditions[taskTypeCondition]}
}
if s.Conditions[taskStatusCondition] != "" {
status = []task.Status{task.Status(s.Conditions[taskStatusCondition])}
}
if s.Conditions[taskCorrelationIDCondition] != "" {
cid, err := uuid.FromString(s.Conditions[taskCorrelationIDCondition])
if err != nil {
return nil, serializer.NewError(serializer.CodeParamErr, "Invalid task correlation ID", err)
}
correlationID = &cid
}
if s.Conditions[taskUserIDCondition] != "" {
userID, err = strconv.Atoi(s.Conditions[taskUserIDCondition])
if err != nil {
return nil, serializer.NewError(serializer.CodeParamErr, "Invalid task user ID", err)
}
}
ctx := context.WithValue(c, inventory.LoadTaskUser{}, true)
res, err := taskClient.List(ctx, &inventory.ListTaskArgs{
PaginationArgs: &inventory.PaginationArgs{
Page: s.Page - 1,
PageSize: s.PageSize,
OrderBy: s.OrderBy,
Order: inventory.OrderDirection(s.OrderDirection),
},
UserID: userID,
CorrelationID: correlationID,
Types: taskType,
Status: status,
})
if err != nil {
return nil, serializer.NewError(serializer.CodeDBError, "Failed to list 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().GetNodeByIds(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
}
return &ListTaskResponse{
Pagination: res.PaginationResults,
Tasks: lo.Map(res.Tasks, func(task *ent.Task, i int) GetTaskResponse {
var (
uid string
node *ent.Node
summary *queue.Summary
)
if task.Edges.User != nil {
uid = hashid.EncodeUserID(hasher, task.Edges.User.ID)
}
t := tasks[i]
summary = t.Summarize(hasher)
if summary != nil && summary.NodeID > 0 {
node = nodeMap[summary.NodeID]
}
return GetTaskResponse{
Task: task,
TaskHashID: hashid.EncodeTaskID(hasher, task.ID),
UserHashID: uid,
Node: node,
Summary: summary,
}
}),
}, nil
}
type (
SingleTaskService struct {
ID int `uri:"id" json:"id" binding:"required"`
}
SingleTaskParamCtx struct{}
)
func (s *SingleTaskService) Get(c *gin.Context) (*GetTaskResponse, error) {
dep := dependency.FromContext(c)
taskClient := dep.TaskClient()
hasher := dep.HashIDEncoder()
ctx := context.WithValue(c, inventory.LoadTaskUser{}, true)
task, err := taskClient.GetTaskByID(ctx, s.ID)
if err != nil {
return nil, serializer.NewError(serializer.CodeDBError, "Failed to get task", err)
}
t, err := queue.NewTaskFromModel(task)
if err != nil {
return nil, serializer.NewError(serializer.CodeDBError, "Failed to parse task", err)
}
summary := t.Summarize(hasher)
var (
node *ent.Node
userHashID string
)
if summary != nil && summary.NodeID > 0 {
node, _ = dep.NodeClient().GetNodeById(c, summary.NodeID)
}
if task.Edges.User != nil {
userHashID = hashid.EncodeUserID(hasher, task.Edges.User.ID)
}
return &GetTaskResponse{
Task: task,
Summary: summary,
Node: node,
UserHashID: userHashID,
TaskHashID: hashid.EncodeTaskID(hasher, task.ID),
}, nil
}
type (
BatchTaskService struct {
IDs []int `json:"ids" binding:"required"`
}
BatchTaskParamCtx struct{}
)
func (s *BatchTaskService) Delete(c *gin.Context) error {
dep := dependency.FromContext(c)
taskClient := dep.TaskClient()
err := taskClient.DeleteByIDs(c, s.IDs...)
if err != nil {
return serializer.NewError(serializer.CodeDBError, "Failed to delete tasks", err)
}
return nil
}
type (
CleanupTaskService struct {
NotAfter time.Time `json:"not_after" binding:"required"`
Types []string `json:"types"`
Status []task.Status `json:"status"`
}
CleanupTaskParameterCtx struct{}
)
func (s *CleanupTaskService) CleanupTask(c *gin.Context) error {
dep := dependency.FromContext(c)
taskClient := dep.TaskClient()
if len(s.Status) == 0 {
s.Status = []task.Status{task.StatusCanceled, task.StatusCompleted, task.StatusError}
}
if err := taskClient.DeleteBy(c, &inventory.DeleteTaskArgs{
NotAfter: s.NotAfter,
Types: s.Types,
Status: s.Status,
}); err != nil {
return serializer.NewError(serializer.CodeDBError, "Failed to cleanup tasks", err)
}
return nil
}
+185
View File
@@ -0,0 +1,185 @@
package admin
import (
"encoding/hex"
"errors"
"net/http"
"strconv"
"github.com/cloudreve/Cloudreve/v4/inventory/types"
"github.com/cloudreve/Cloudreve/v4/application/dependency"
"github.com/cloudreve/Cloudreve/v4/pkg/boolset"
"github.com/cloudreve/Cloudreve/v4/pkg/filemanager/manager"
request2 "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/wopi"
"github.com/gin-gonic/gin"
"github.com/wneessen/go-mail"
)
type (
HashIDService struct {
ID int `json:"id"`
Type int `json:"type"`
HashID string `json:"hash_id"`
}
HashIDParamCtx struct{}
)
func (service *HashIDService) Encode(c *gin.Context) (string, error) {
dep := dependency.FromContext(c)
res, err := dep.HashIDEncoder().Encode([]int{service.ID, service.Type})
if err != nil {
return "", err
}
return res, nil
}
func (service *HashIDService) Decode(c *gin.Context) (int, error) {
dep := dependency.FromContext(c)
res, err := dep.HashIDEncoder().Decode(service.HashID, service.Type)
if err != nil {
return 0, err
}
return res, nil
}
type (
BsEncodeService struct {
Bool []int `json:"bool"`
}
BsEncodeParamCtx struct{}
BsEncodeRes struct {
Hex string
B64 []byte
}
)
func (service *BsEncodeService) Encode(c *gin.Context) (*BsEncodeRes, error) {
bs := &boolset.BooleanSet{}
for _, v := range service.Bool {
boolset.Set(v, true, bs)
}
res, err := bs.MarshalBinary()
if err != nil {
return nil, err
}
return &BsEncodeRes{
Hex: hex.EncodeToString(res),
B64: res,
}, nil
}
type (
BsDecodeService struct {
Code string `json:"code"`
}
BsDecodeParamCtx struct{}
BsDecodeRes struct {
Bool []int `json:"bool"`
}
)
func (service *BsDecodeService) Decode(c *gin.Context) (*BsDecodeRes, error) {
bs, err := boolset.FromString(service.Code)
if err != nil {
return nil, err
}
res := []int{}
for i := 0; i < len(*bs)*8; i++ {
if bs.Enabled(i) {
res = append(res, i)
}
}
return &BsDecodeRes{
Bool: res,
}, nil
}
type (
FetchWOPIDiscoveryService struct {
Endpoint string `form:"endpoint" binding:"required"`
}
FetchWOPIDiscoveryParamCtx struct{}
)
func (s *FetchWOPIDiscoveryService) Fetch(c *gin.Context) (*types.ViewerGroup, error) {
dep := dependency.FromContext(c)
requestClient := dep.RequestClient(request2.WithContext(c), request2.WithLogger(dep.Logger()))
content, err := requestClient.Request("GET", s.Endpoint, nil).CheckHTTPResponse(http.StatusOK).GetResponse()
if err != nil {
return nil, serializer.NewError(serializer.CodeInternalSetting, "WOPI endpoint id unavailable", err)
}
vg, err := wopi.DiscoveryXmlToViewerGroup(content)
if err != nil {
return nil, serializer.NewError(serializer.CodeParamErr, "Failed to parse WOPI response", err)
}
return vg, nil
}
type (
TestSMTPService struct {
Settings map[string]string `json:"settings" binding:"required"`
To string `json:"to" binding:"required,email"`
}
TestSMTPParamCtx struct{}
)
func (s *TestSMTPService) Test(c *gin.Context) error {
port, err := strconv.Atoi(s.Settings["smtpPort"])
if err != nil {
return serializer.NewError(serializer.CodeParamErr, "Invalid SMTP port", err)
}
opts := []mail.Option{
mail.WithPort(port),
mail.WithSMTPAuth(mail.SMTPAuthAutoDiscover), mail.WithTLSPortPolicy(mail.TLSOpportunistic),
mail.WithUsername(s.Settings["smtpUser"]), mail.WithPassword(s.Settings["smtpPass"]),
}
if setting.IsTrueValue(s.Settings["smtpEncryption"]) {
opts = append(opts, mail.WithSSL())
}
d, diaErr := mail.NewClient(s.Settings["smtpHost"], opts...)
if diaErr != nil {
return serializer.NewError(serializer.CodeInternalSetting, "Failed to create SMTP client: "+diaErr.Error(), diaErr)
}
m := mail.NewMsg()
if err := m.FromFormat(s.Settings["fromName"], s.Settings["fromAdress"]); err != nil {
return serializer.NewError(serializer.CodeInternalSetting, "Failed to set FROM address: "+err.Error(), err)
}
m.ReplyToFormat(s.Settings["fromName"], s.Settings["replyTo"])
m.To(s.To)
m.Subject("Cloudreve SMTP Test")
m.SetMessageID()
m.SetBodyString(mail.TypeTextHTML, "This is a test email from Cloudreve.")
err = d.DialAndSendWithContext(c, m)
if err != nil {
// Check if this is an SMTP RESET error after successful delivery
var sendErr *mail.SendError
var errParsed = errors.As(err, &sendErr)
if errParsed && sendErr.Reason == mail.ErrSMTPReset {
return nil // Don't treat this as a delivery failure since mail was sent
}
return serializer.NewError(serializer.CodeInternalSetting, "Failed to send test email: "+err.Error(), err)
}
return nil
}
func ClearEntityUrlCache(c *gin.Context) {
dep := dependency.FromContext(c)
dep.KV().Delete(manager.EntityUrlCacheKeyPrefix)
}
+254
View File
@@ -0,0 +1,254 @@
package admin
import (
"context"
"strconv"
"github.com/cloudreve/Cloudreve/v4/application/dependency"
"github.com/cloudreve/Cloudreve/v4/ent"
"github.com/cloudreve/Cloudreve/v4/ent/user"
"github.com/cloudreve/Cloudreve/v4/inventory"
"github.com/cloudreve/Cloudreve/v4/inventory/types"
"github.com/cloudreve/Cloudreve/v4/pkg/filemanager/manager"
"github.com/cloudreve/Cloudreve/v4/pkg/hashid"
"github.com/cloudreve/Cloudreve/v4/pkg/serializer"
"github.com/gin-gonic/gin"
"github.com/samber/lo"
)
// AddUserService 用户添加服务
type AddUserService struct {
//User model.User `json:"User" binding:"required"`
Password string `json:"password"`
}
// UserService 用户ID服务
type UserService struct {
ID uint `uri:"id" json:"id" binding:"required"`
}
// UserBatchService 用户批量操作服务
type UserBatchService struct {
ID []uint `json:"id" binding:"min=1"`
}
const (
userStatusCondition = "user_status"
userGroupCondition = "user_group"
userNickCondition = "user_nick"
userEmailCondition = "user_email"
)
func (service *AdminListService) Users(c *gin.Context) (*ListUserResponse, error) {
dep := dependency.FromContext(c)
hasher := dep.HashIDEncoder()
userClient := dep.UserClient()
ctx := context.WithValue(c, inventory.LoadUserGroup{}, true)
ctx = context.WithValue(ctx, inventory.LoadUserPasskey{}, true)
var (
err error
groupID int
)
if service.Conditions[userGroupCondition] != "" {
groupID, err = strconv.Atoi(service.Conditions[userGroupCondition])
if err != nil {
return nil, serializer.NewError(serializer.CodeParamErr, "Invalid group ID", err)
}
}
res, err := userClient.ListUsers(ctx, &inventory.ListUserParameters{
PaginationArgs: &inventory.PaginationArgs{
Page: service.Page - 1,
PageSize: service.PageSize,
OrderBy: service.OrderBy,
Order: inventory.OrderDirection(service.OrderDirection),
},
Status: user.Status(service.Conditions[userStatusCondition]),
GroupID: groupID,
Nick: service.Conditions[userNickCondition],
Email: service.Conditions[userEmailCondition],
})
if err != nil {
return nil, serializer.NewError(serializer.CodeDBError, "Failed to list users", err)
}
return &ListUserResponse{
Pagination: res.PaginationResults,
Users: lo.Map(res.Users, func(user *ent.User, _ int) GetUserResponse {
return GetUserResponse{
User: user,
HashID: hashid.EncodeUserID(hasher, user.ID),
TwoFAEnabled: user.TwoFactorSecret != "",
}
}),
}, nil
}
type (
SingleUserService struct {
ID int `uri:"id" json:"id" binding:"required"`
}
SingleUserParamCtx struct{}
)
func (service *SingleUserService) Get(c *gin.Context) (*GetUserResponse, error) {
dep := dependency.FromContext(c)
hasher := dep.HashIDEncoder()
userClient := dep.UserClient()
ctx := context.WithValue(c, inventory.LoadUserGroup{}, true)
ctx = context.WithValue(ctx, inventory.LoadUserPasskey{}, true)
user, err := userClient.GetByID(ctx, service.ID)
if err != nil {
return nil, serializer.NewError(serializer.CodeDBError, "Failed to get user", err)
}
m := manager.NewFileManager(dep, user)
capacity, err := m.Capacity(ctx)
if err != nil {
return nil, serializer.NewError(serializer.CodeInternalSetting, "Failed to get user capacity", err)
}
return &GetUserResponse{
User: user,
HashID: hashid.EncodeUserID(hasher, user.ID),
TwoFAEnabled: user.TwoFactorSecret != "",
Capacity: capacity,
}, nil
}
func (service *SingleUserService) CalibrateStorage(c *gin.Context) (*GetUserResponse, error) {
dep := dependency.FromContext(c)
userClient := dep.UserClient()
ctx := context.WithValue(c, inventory.LoadUserGroup{}, true)
_, err := userClient.CalculateStorage(ctx, service.ID)
if err != nil {
return nil, serializer.NewError(serializer.CodeDBError, "Failed to calculate storage", err)
}
subService := &SingleUserService{ID: service.ID}
return subService.Get(c)
}
type (
UpsertUserService struct {
User *ent.User `json:"user" binding:"required"`
Password string `json:"password"`
TwoFA string `json:"two_fa"`
}
UpsertUserParamCtx struct{}
)
func (s *UpsertUserService) Update(c *gin.Context) (*GetUserResponse, error) {
dep := dependency.FromContext(c)
userClient := dep.UserClient()
ctx := context.WithValue(c, inventory.LoadUserGroup{}, true)
existing, err := userClient.GetByID(ctx, s.User.ID)
if err != nil {
return nil, serializer.NewError(serializer.CodeDBError, "Failed to get user", err)
}
if s.User.ID == 1 && existing.Edges.Group.Permissions.Enabled(int(types.GroupPermissionIsAdmin)) {
if s.User.GroupUsers != existing.GroupUsers {
return nil, serializer.NewError(serializer.CodeInvalidActionOnDefaultUser, "Cannot change default user's group", nil)
}
if s.User.Status != user.StatusActive {
return nil, serializer.NewError(serializer.CodeInvalidActionOnDefaultUser, "Cannot change default user's status", nil)
}
}
if s.Password != "" && len(s.Password) > 128 {
return nil, serializer.NewError(serializer.CodeParamErr, "Password too long", nil)
}
newUser, err := userClient.Upsert(ctx, s.User, s.Password, s.TwoFA)
if err != nil {
return nil, serializer.NewError(serializer.CodeDBError, "Failed to update user", err)
}
service := &SingleUserService{ID: newUser.ID}
return service.Get(c)
}
func (s *UpsertUserService) Create(c *gin.Context) (*GetUserResponse, error) {
dep := dependency.FromContext(c)
userClient := dep.UserClient()
if s.Password == "" {
return nil, serializer.NewError(serializer.CodeParamErr, "Password is required", nil)
}
if s.User.ID != 0 {
return nil, serializer.NewError(serializer.CodeParamErr, "ID must be 0", nil)
}
user, err := userClient.Upsert(c, s.User, s.Password, s.TwoFA)
if err != nil {
return nil, serializer.NewError(serializer.CodeDBError, "Failed to create user", err)
}
service := &SingleUserService{ID: user.ID}
return service.Get(c)
}
type (
BatchUserService struct {
IDs []int `json:"ids" binding:"min=1"`
}
BatchUserParamCtx struct{}
)
func (s *BatchUserService) Delete(c *gin.Context) error {
dep := dependency.FromContext(c)
userClient := dep.UserClient()
fileClient := dep.FileClient()
current := inventory.UserFromContext(c)
ae := serializer.NewAggregateError()
for _, id := range s.IDs {
if current.ID == id || id == 1 {
ae.Add(strconv.Itoa(id), serializer.NewError(serializer.CodeInvalidActionOnDefaultUser, "Cannot delete current user", nil))
continue
}
fc, tx, ctx, err := inventory.WithTx(c, fileClient)
if err != nil {
ae.Add(strconv.Itoa(id), serializer.NewError(serializer.CodeDBError, "Failed to start transaction", err))
continue
}
uc, _, ctx, err := inventory.WithTx(ctx, userClient)
if err != nil {
ae.Add(strconv.Itoa(id), serializer.NewError(serializer.CodeDBError, "Failed to start transaction", err))
continue
}
if err := fc.DeleteByUser(ctx, id); err != nil {
_ = inventory.Rollback(tx)
ae.Add(strconv.Itoa(id), serializer.NewError(serializer.CodeDBError, "Failed to delete user files", err))
continue
}
if err := uc.Delete(ctx, id); err != nil {
_ = inventory.Rollback(tx)
ae.Add(strconv.Itoa(id), serializer.NewError(serializer.CodeDBError, "Failed to delete user", err))
continue
}
if err := inventory.Commit(tx); err != nil {
ae.Add(strconv.Itoa(id), serializer.NewError(serializer.CodeDBError, "Failed to commit transaction", err))
continue
}
}
return ae.Aggregate()
}
+253
View File
@@ -0,0 +1,253 @@
package basic
import (
"sort"
"strings"
"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/setting"
"github.com/cloudreve/Cloudreve/v4/pkg/thumb"
"github.com/cloudreve/Cloudreve/v4/service/user"
"github.com/gin-gonic/gin"
"github.com/mojocn/base64Captcha"
)
// SiteConfig 站点全局设置序列
type SiteConfig struct {
// Basic Section
InstanceID string `json:"instance_id,omitempty"`
SiteName string `json:"title,omitempty"`
Themes string `json:"themes,omitempty"`
DefaultTheme string `json:"default_theme,omitempty"`
User *user.User `json:"user,omitempty"`
Logo string `json:"logo,omitempty"`
LogoLight string `json:"logo_light,omitempty"`
CustomNavItems []setting.CustomNavItem `json:"custom_nav_items,omitempty"`
CustomHTML *setting.CustomHTML `json:"custom_html,omitempty"`
// Login Section
LoginCaptcha bool `json:"login_captcha,omitempty"`
RegCaptcha bool `json:"reg_captcha,omitempty"`
ForgetCaptcha bool `json:"forget_captcha,omitempty"`
Authn bool `json:"authn,omitempty"`
ReCaptchaKey string `json:"captcha_ReCaptchaKey,omitempty"`
CaptchaType setting.CaptchaType `json:"captcha_type,omitempty"`
TurnstileSiteID string `json:"turnstile_site_id,omitempty"`
CapInstanceURL string `json:"captcha_cap_instance_url,omitempty"`
CapSiteKey string `json:"captcha_cap_site_key,omitempty"`
CapAssetServer string `json:"captcha_cap_asset_server,omitempty"`
RegisterEnabled bool `json:"register_enabled,omitempty"`
TosUrl string `json:"tos_url,omitempty"`
PrivacyPolicyUrl string `json:"privacy_policy_url,omitempty"`
// Explorer section
Icons string `json:"icons,omitempty"`
EmojiPreset string `json:"emoji_preset,omitempty"`
MapProvider setting.MapProvider `json:"map_provider,omitempty"`
GoogleMapTileType setting.MapGoogleTileType `json:"google_map_tile_type,omitempty"`
MapboxAK string `json:"mapbox_ak,omitempty"`
FileViewers []types.ViewerGroup `json:"file_viewers,omitempty"`
DefaultViewerMapping types.DefaultViewerMapping `json:"default_viewer_mapping,omitempty"`
MaxBatchSize int `json:"max_batch_size,omitempty"`
ThumbnailWidth int `json:"thumbnail_width,omitempty"`
ThumbnailHeight int `json:"thumbnail_height,omitempty"`
CustomProps []types.CustomProps `json:"custom_props,omitempty"`
ShowEncryptionStatus bool `json:"show_encryption_status,omitempty"`
FullTextSearch bool `json:"full_text_search,omitempty"`
// Thumbnail section
ThumbExts []string `json:"thumb_exts,omitempty"`
// App settings
AppPromotion bool `json:"app_promotion,omitempty"`
DesktopAppPromotion bool `json:"desktop_app_promotion,omitempty"`
//EmailActive bool `json:"emailActive"`
//QQLogin bool `json:"QQLogin"`
//ScoreEnabled bool `json:"score_enabled"`
//ShareScoreRate string `json:"share_score_rate"`
//HomepageViewMethod string `json:"home_view_method"`
//ShareViewMethod string `json:"share_view_method"`
//WopiExts []string `json:"wopi_exts"`
//AppFeedbackLink string `json:"app_feedback"`
//AppForumLink string `json:"app_forum"`
}
type (
GetSettingService struct {
Section string `uri:"section" binding:"required"`
}
GetSettingParamCtx struct{}
)
func (s *GetSettingService) GetSiteConfig(c *gin.Context) (*SiteConfig, error) {
dep := dependency.FromContext(c)
settings := dep.SettingProvider()
switch s.Section {
case "login":
legalDocs := settings.LegalDocuments(c)
return &SiteConfig{
LoginCaptcha: settings.LoginCaptchaEnabled(c),
RegCaptcha: settings.RegCaptchaEnabled(c),
ForgetCaptcha: settings.ForgotPasswordCaptchaEnabled(c),
Authn: settings.AuthnEnabled(c),
RegisterEnabled: settings.RegisterEnabled(c),
PrivacyPolicyUrl: legalDocs.PrivacyPolicy,
TosUrl: legalDocs.TermsOfService,
}, nil
case "explorer":
explorerSettings := settings.ExplorerFrontendSettings(c)
mapSettings := settings.MapSetting(c)
fileViewers := settings.FileViewers(c)
customProps := settings.CustomProps(c)
maxBatchSize := settings.MaxBatchedFile(c)
showEncryptionStatus := settings.ShowEncryptionStatus(c)
w, h := settings.ThumbSize(c)
for i := range fileViewers {
for j := range fileViewers[i].Viewers {
fileViewers[i].Viewers[j].WopiActions = nil
}
}
return &SiteConfig{
MaxBatchSize: maxBatchSize,
FileViewers: fileViewers,
DefaultViewerMapping: settings.DefaultViewerMapping(c),
Icons: explorerSettings.Icons,
MapProvider: mapSettings.Provider,
GoogleMapTileType: mapSettings.GoogleTileType,
MapboxAK: mapSettings.MapboxAK,
ThumbnailWidth: w,
ThumbnailHeight: h,
CustomProps: customProps,
ShowEncryptionStatus: showEncryptionStatus,
FullTextSearch: settings.FTSEnabled(c),
}, nil
case "emojis":
emojis := settings.EmojiPresets(c)
return &SiteConfig{
EmojiPreset: emojis,
}, nil
case "app":
appSetting := settings.AppSetting(c)
return &SiteConfig{
AppPromotion: appSetting.Promotion,
DesktopAppPromotion: appSetting.DesktopPromotion,
}, nil
case "thumb":
// Return supported thumbnail extensions from enabled generators.
exts := map[string]bool{}
if settings.BuiltinThumbGeneratorEnabled(c) {
for _, e := range thumb.BuiltinSupportedExts {
exts[e] = true
}
}
if settings.FFMpegThumbGeneratorEnabled(c) {
for _, e := range settings.FFMpegThumbExts(c) {
exts[strings.ToLower(e)] = true
}
}
if settings.VipsThumbGeneratorEnabled(c) {
for _, e := range settings.VipsThumbExts(c) {
exts[strings.ToLower(e)] = true
}
}
if settings.LibreOfficeThumbGeneratorEnabled(c) {
for _, e := range settings.LibreOfficeThumbExts(c) {
exts[strings.ToLower(e)] = true
}
}
if settings.MusicCoverThumbGeneratorEnabled(c) {
for _, e := range settings.MusicCoverThumbExts(c) {
exts[strings.ToLower(e)] = true
}
}
if settings.LibRawThumbGeneratorEnabled(c) {
for _, e := range settings.LibRawThumbExts(c) {
exts[strings.ToLower(e)] = true
}
}
// map -> sorted slice
result := make([]string, 0, len(exts))
for e := range exts {
result = append(result, e)
}
sort.Strings(result)
return &SiteConfig{ThumbExts: result}, nil
default:
break
}
u := inventory.UserFromContext(c)
siteBasic := settings.SiteBasic(c)
themes := settings.Theme(c)
userRes := user.BuildUser(u, dep.HashIDEncoder())
logo := settings.Logo(c)
reCaptcha := settings.ReCaptcha(c)
capCaptcha := settings.CapCaptcha(c)
appSetting := settings.AppSetting(c)
customNavItems := settings.CustomNavItems(c)
customHTML := settings.CustomHTML(c)
return &SiteConfig{
InstanceID: siteBasic.ID,
SiteName: siteBasic.Name,
Themes: themes.Themes,
DefaultTheme: themes.DefaultTheme,
User: &userRes,
Logo: logo.Normal,
LogoLight: logo.Light,
CaptchaType: settings.CaptchaType(c),
TurnstileSiteID: settings.TurnstileCaptcha(c).Key,
ReCaptchaKey: reCaptcha.Key,
CapInstanceURL: capCaptcha.InstanceURL,
CapSiteKey: capCaptcha.SiteKey,
CapAssetServer: capCaptcha.AssetServer,
AppPromotion: appSetting.Promotion,
CustomNavItems: customNavItems,
CustomHTML: customHTML,
}, nil
}
const (
CaptchaSessionPrefix = "captcha_session_"
CaptchaTTL = 1800 // 30 minutes
)
type (
CaptchaResponse struct {
Image string `json:"image"`
Ticket string `json:"ticket"`
}
)
// GetCaptchaImage generates captcha session
func GetCaptchaImage(c *gin.Context) *CaptchaResponse {
dep := dependency.FromContext(c)
captchaSettings := dep.SettingProvider().Captcha(c)
var configD = base64Captcha.ConfigCharacter{
Height: captchaSettings.Height,
Width: captchaSettings.Width,
Mode: int(captchaSettings.Mode),
ComplexOfNoiseText: captchaSettings.ComplexOfNoiseText,
ComplexOfNoiseDot: captchaSettings.ComplexOfNoiseDot,
IsShowHollowLine: captchaSettings.IsShowHollowLine,
IsShowNoiseDot: captchaSettings.IsShowNoiseDot,
IsShowNoiseText: captchaSettings.IsShowNoiseText,
IsShowSlimeLine: captchaSettings.IsShowSlimeLine,
IsShowSineLine: captchaSettings.IsShowSineLine,
CaptchaLen: captchaSettings.Length,
}
// 生成验证码
idKeyD, capD := base64Captcha.GenerateCaptcha("", configD)
base64stringD := base64Captcha.CaptchaWriteToBase64Encoding(capD)
return &CaptchaResponse{
Image: base64stringD,
Ticket: idKeyD,
}
}
+123
View File
@@ -0,0 +1,123 @@
package callback
import (
"github.com/cloudreve/Cloudreve/v4/pkg/serializer"
"github.com/gin-gonic/gin"
)
// OauthService OAuth 存储策略授权回调服务
type OauthService struct {
Code string `form:"code"`
Error string `form:"error"`
ErrorMsg string `form:"error_description"`
Scope string `form:"scope"`
}
//
//// GDriveAuth Google Drive 更新认证信息
//func (service *OauthService) GDriveAuth(c *gin.Context) serializer.Response {
// if service.Error != "" {
// return serializer.ParamErrDeprecated(service.Error, nil)
// }
//
// // validate required scope
// if missing, found := lo.Find[string](googledrive.RequiredScope, func(item string) bool {
// return !strings.Contains(service.Scope, item)
// }); found {
// return serializer.ParamErrDeprecated(fmt.Sprintf("Missing required scope: %s", missing), nil)
// }
//
// policyID, ok := util.GetSession(c, "googledrive_oauth_policy").(uint)
// if !ok {
// return serializer.ErrDeprecated(serializer.CodeNotFound, "", nil)
// }
//
// util.DeleteSession(c, "googledrive_oauth_policy")
//
// policy, err := model.GetPolicyByID(policyID)
// if err != nil {
// return serializer.ErrDeprecated(serializer.CodePolicyNotExist, "", nil)
// }
//
// client, err := googledrive.NewClient(&policy)
// if err != nil {
// return serializer.ErrDeprecated(serializer.CodeInternalSetting, "Failed to initialize Google Drive client", err)
// }
//
// credential, err := client.ObtainToken(c, service.Code, "")
// if err != nil {
// return serializer.ErrDeprecated(serializer.CodeInternalSetting, "Failed to fetch AccessToken", err)
// }
//
// // 更新存储策略的 RefreshToken
// client.Policy.AccessKey = credential.RefreshToken
// if err := client.Policy.SaveAndClearCache(); err != nil {
// return serializer.DBErrDeprecated("Failed to update RefreshToken", err)
// }
//
// cache.Deletes([]string{client.Policy.AccessKey}, googledrive.TokenCachePrefix)
// return serializer.Response{}
//}
// OdAuth OneDrive 更新认证信息
func (service *OauthService) OdAuth(c *gin.Context) serializer.Response {
//if service.Error != "" {
// return serializer.ParamErrDeprecated(service.ErrorMsg, nil)
//}
//
//policyID, ok := util.GetSession(c, "onedrive_oauth_policy").(uint)
//if !ok {
// return serializer.ErrDeprecated(serializer.CodeNotFound, "", nil)
//}
//
//util.DeleteSession(c, "onedrive_oauth_policy")
//
//policy, err := model.GetPolicyByID(policyID)
//if err != nil {
// return serializer.ErrDeprecated(serializer.CodePolicyNotExist, "", nil)
//}
//
//client, err := onedrive.NewClient(&policy)
//if err != nil {
// return serializer.ErrDeprecated(serializer.CodeInternalSetting, "Failed to initialize OneDrive client", err)
//}
//
//credential, err := client.ObtainToken(c, onedrive.WithCode(service.Code))
//if err != nil {
// return serializer.ErrDeprecated(serializer.CodeInternalSetting, "Failed to fetch AccessToken", err)
//}
//
//// 更新存储策略的 RefreshToken
//client.Policy.AccessKey = credential.RefreshToken
//if err := client.Policy.SaveAndClearCache(); err != nil {
// return serializer.DBErrDeprecated("Failed to update RefreshToken", err)
//}
//
//cache.Deletes([]string{client.Policy.AccessKey}, "onedrive_")
//if client.Policy.OptionsSerialized.OdDriver != "" && strings.Contains(client.Policy.OptionsSerialized.OdDriver, "http") {
// if err := querySharePointSiteID(c, client.Policy); err != nil {
// return serializer.ErrDeprecated(serializer.CodeInternalSetting, "Failed to query SharePoint basic ID", err)
// }
//}
return serializer.Response{}
}
//func querySharePointSiteID(ctx context.Context, policy *model.Policy) error {
//client, err := onedrive.NewClient(policy)
//if err != nil {
// return err
//}
//
//id, err := client.GetSiteIDByURL(ctx, client.Policy.OptionsSerialized.OdDriver)
//if err != nil {
// return err
//}
//
//client.Policy.OptionsSerialized.OdDriver = fmt.Sprintf("sites/%s/drive", id)
//if err := client.Policy.SaveAndClearCache(); err != nil {
// return err
//}
//return nil
//}
+145
View File
@@ -0,0 +1,145 @@
package callback
import (
"fmt"
"github.com/cloudreve/Cloudreve/v4/application/dependency"
"github.com/cloudreve/Cloudreve/v4/inventory"
"github.com/cloudreve/Cloudreve/v4/pkg/filemanager/driver/onedrive"
"github.com/cloudreve/Cloudreve/v4/pkg/filemanager/fs"
"github.com/cloudreve/Cloudreve/v4/pkg/filemanager/manager"
"github.com/gin-gonic/gin"
)
// RemoteUploadCallbackService 远程存储上传回调请求服务
type RemoteUploadCallbackService struct {
}
// UploadCallbackService OOS/七牛云存储上传回调请求服务
type UploadCallbackService struct {
Name string `json:"name"`
SourceName string `json:"source_name"`
PicInfo string `json:"pic_info"`
Size int64 `json:"size"`
}
// UpyunCallbackService 又拍云上传回调请求服务
type UpyunCallbackService struct {
Code int `form:"code" binding:"required"`
Message string `form:"message" binding:"required"`
SourceName string `form:"url" binding:"required"`
Width string `form:"image-width"`
Height string `form:"image-height"`
Size uint64 `form:"file_size"`
}
// OneDriveCallback OneDrive 客户端回调正文
type OneDriveCallback struct {
Meta *onedrive.FileInfo
}
// S3Callback S3 客户端回调正文
type S3Callback struct {
}
// ProcessCallback 处理上传结果回调
func ProcessCallback(c *gin.Context) error {
dep := dependency.FromContext(c)
user := inventory.UserFromContext(c)
m := manager.NewFileManager(dep, user)
defer m.Recycle()
// 获取上传会话
uploadSession := c.MustGet(manager.UploadSessionCtx).(*fs.UploadSession)
_, err := m.CompleteUpload(c, uploadSession)
if err != nil {
return fmt.Errorf("failed to complete upload: %w", err)
}
return nil
}
//// PreProcess 对OneDrive客户端回调进行预处理验证
//func (service *OneDriveCallback) PreProcess(c *gin.Context) serializer.Response {
// // 创建文件系统
// fs, err := filesystem.NewFileSystemFromCallback(c)
// if err != nil {
// return serializer.ErrDeprecated(serializer.CodeCreateFSError, "", err)
// }
// defer fs.Recycle()
//
// // 获取回调会话
// uploadSession := c.MustGet(filesystem.UploadSessionCtx).(*serializer.UploadSession)
//
// // 获取文件信息
// info, err := fs.Handler.(onedrive.Driver).Client.Meta(context.Background(), "", uploadSession.SavePath)
// if err != nil {
// return serializer.ErrDeprecated(serializer.CodeQueryMetaFailed, "", err)
// }
//
// // 验证与回调会话中是否一致
// actualPath := strings.TrimPrefix(uploadSession.SavePath, "/")
// isSizeCheckFailed := uploadSession.Size != info.Size
//
// // SharePoint 会对 Office 文档增加 meta data 导致文件大小不一致,这里增加 1 MB 宽容
// // See: https://github.com/OneDrive/onedrive-api-docs/issues/935
// if (strings.Contains(fs.Policy.OptionsSerialized.OdDriver, "sharepoint.com") || strings.Contains(fs.Policy.OptionsSerialized.OdDriver, "sharepoint.cn")) && isSizeCheckFailed && (info.Size > uploadSession.Size) && (info.Size-uploadSession.Size <= 1048576) {
// isSizeCheckFailed = false
// }
//
// if isSizeCheckFailed || !strings.EqualFold(info.GetSourcePath(), actualPath) {
// fs.Handler.(onedrive.Driver).Client.Delete(context.Background(), []string{info.GetSourcePath()})
// return serializer.ErrDeprecated(serializer.CodeMetaMismatch, "", err)
// }
// service.Meta = info
// return ProcessCallback(c)
//}
//
//
//// PreProcess 对S3客户端回调进行预处理
//func (service *S3Callback) PreProcess(c *gin.Context) serializer.Response {
// // 创建文件系统
// fs, err := filesystem.NewFileSystemFromCallback(c)
// if err != nil {
// return serializer.ErrDeprecated(serializer.CodeCreateFSError, "", err)
// }
// defer fs.Recycle()
//
// // 获取回调会话
// uploadSession := c.MustGet(filesystem.UploadSessionCtx).(*serializer.UploadSession)
//
// // 获取文件信息
// info, err := fs.Handler.(*s3.Driver).Meta(context.Background(), uploadSession.SavePath)
// if err != nil {
// return serializer.ErrDeprecated(serializer.CodeMetaMismatch, "", err)
// }
//
// // 验证实际文件信息与回调会话中是否一致
// if uploadSession.Size != info.Size {
// return serializer.ErrDeprecated(serializer.CodeMetaMismatch, "", err)
// }
//
// return ProcessCallback(service, c)
//}
//
//// PreProcess 对OneDrive客户端回调进行预处理验证
//func (service *UploadCallbackService) PreProcess(c *gin.Context) serializer.Response {
// // 创建文件系统
// fs, err := filesystem.NewFileSystemFromCallback(c)
// if err != nil {
// return serializer.ErrDeprecated(serializer.CodeCreateFSError, "", err)
// }
// defer fs.Recycle()
//
// // 获取回调会话
// uploadSession := c.MustGet(filesystem.UploadSessionCtx).(*serializer.UploadSession)
//
// // 验证文件大小
// if uploadSession.Size != service.Size {
// fs.Handler.Delete(context.Background(), []string{uploadSession.SavePath})
// return serializer.ErrDeprecated(serializer.CodeMetaMismatch, "", err)
// }
//
// return ProcessCallback(service, c)
//}
+118
View File
@@ -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
}
+119
View File
@@ -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()
}
}
}
+785
View File
@@ -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
}
+37
View File
@@ -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...)
}
+75
View File
@@ -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
}
+555
View File
@@ -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")
+270
View File
@@ -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
}
+239
View File
@@ -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)
}
+446
View File
@@ -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
}
+483
View File
@@ -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
}
+1
View File
@@ -0,0 +1 @@
package node
+120
View File
@@ -0,0 +1,120 @@
package node
import (
"context"
"github.com/cloudreve/Cloudreve/v4/application/dependency"
"github.com/cloudreve/Cloudreve/v4/inventory"
"github.com/cloudreve/Cloudreve/v4/pkg/credmanager"
"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/cloudreve/Cloudreve/v4/pkg/util"
"github.com/gin-gonic/gin"
)
type SlaveNotificationService struct {
Subject string `uri:"subject" binding:"required"`
}
type (
OauthCredentialParamCtx struct{}
OauthCredentialService struct {
ID string `uri:"id" binding:"required"`
}
)
// Get 获取主机Oauth策略的AccessToken
func (s *OauthCredentialService) Get(c *gin.Context) (*credmanager.CredentialResponse, error) {
dep := dependency.FromContext(c)
credManager := dep.CredManager()
cred, err := credManager.Obtain(c, s.ID)
if cred == nil || err != nil {
return nil, serializer.NewError(serializer.CodeNotFound, "Credential not found", err)
}
return &credmanager.CredentialResponse{
Token: cred.String(),
ExpireAt: cred.Expiry(),
}, nil
}
type (
StatelessPrepareUploadParamCtx struct{}
)
func StatelessPrepareUpload(s *fs.StatelessPrepareUploadService, c *gin.Context) (*fs.StatelessPrepareUploadResponse, error) {
dep := dependency.FromContext(c)
userClient := dep.UserClient()
user, err := userClient.GetLoginUserByID(c, s.UserID)
if err != nil {
return nil, err
}
ctx := context.WithValue(c.Request.Context(), inventory.UserCtx{}, user)
fm := manager.NewFileManager(dep, user)
uploadSession, err := fm.PrepareUpload(ctx, s.UploadRequest)
if err != nil {
return nil, err
}
return &fs.StatelessPrepareUploadResponse{
Session: uploadSession,
Req: s.UploadRequest,
}, nil
}
type (
StatelessCompleteUploadParamCtx struct{}
)
func StatelessCompleteUpload(s *fs.StatelessCompleteUploadService, c *gin.Context) (fs.File, error) {
dep := dependency.FromContext(c)
userClient := dep.UserClient()
user, err := userClient.GetLoginUserByID(c, s.UserID)
if err != nil {
return nil, err
}
util.WithValue(c, inventory.UserCtx{}, user)
fm := manager.NewFileManager(dep, user)
return fm.CompleteUpload(c, s.UploadSession)
}
type (
StatelessOnUploadFailedParamCtx struct{}
)
func StatelessOnUploadFailed(s *fs.StatelessOnUploadFailedService, c *gin.Context) error {
dep := dependency.FromContext(c)
userClient := dep.UserClient()
user, err := userClient.GetLoginUserByID(c, s.UserID)
if err != nil {
return err
}
util.WithValue(c, inventory.UserCtx{}, user)
fm := manager.NewFileManager(dep, user)
fm.OnUploadFailed(c, s.UploadSession)
return nil
}
type StatelessCreateFileParamCtx struct{}
func StatelessCreateFile(s *fs.StatelessCreateFileService, c *gin.Context) error {
dep := dependency.FromContext(c)
userClient := dep.UserClient()
user, err := userClient.GetLoginUserByID(c, s.UserID)
if err != nil {
return err
}
uri, err := fs.NewUriFromString(s.Path)
if err != nil {
return err
}
util.WithValue(c, inventory.UserCtx{}, user)
fm := manager.NewFileManager(dep, user)
_, err = fm.Create(c, uri, s.Type)
return err
}
+150
View File
@@ -0,0 +1,150 @@
package node
import (
"context"
"fmt"
"os"
"strconv"
"github.com/cloudreve/Cloudreve/v4/application/dependency"
"github.com/cloudreve/Cloudreve/v4/ent/task"
"github.com/cloudreve/Cloudreve/v4/inventory/types"
"github.com/cloudreve/Cloudreve/v4/pkg/cluster"
"github.com/cloudreve/Cloudreve/v4/pkg/cluster/routes"
"github.com/cloudreve/Cloudreve/v4/pkg/filemanager/workflows"
"github.com/cloudreve/Cloudreve/v4/pkg/logging"
"github.com/cloudreve/Cloudreve/v4/pkg/queue"
"github.com/cloudreve/Cloudreve/v4/pkg/serializer"
"github.com/gin-gonic/gin"
)
type (
CreateSlaveTaskParamCtx struct{}
)
func CreateTaskInSlave(s *cluster.CreateSlaveTask, c *gin.Context) (int, error) {
dep := dependency.FromContext(c)
registry := dep.TaskRegistry()
props, err := slaveTaskPropsFromContext(c)
if err != nil {
return 0, serializer.NewError(serializer.CodeParamErr, "failed to get master props from header", err)
}
var t queue.Task
switch s.Type {
case queue.SlaveUploadTaskType:
t = workflows.NewSlaveUploadTask(c, props, registry.NextID(), s.State)
case queue.SlaveCreateArchiveTaskType:
t = workflows.NewSlaveCreateArchiveTask(c, props, registry.NextID(), s.State)
case queue.SlaveExtractArchiveType:
t = workflows.NewSlaveExtractArchiveTask(c, props, registry.NextID(), s.State)
default:
return 0, serializer.NewError(serializer.CodeParamErr, "type not supported", nil)
}
if err := dep.SlaveQueue(c).QueueTask(c, t); err != nil {
return 0, serializer.NewError(serializer.CodeInternalSetting, "failed to queue task", err)
}
registry.Set(t.ID(), t)
return t.ID(), nil
}
type (
GetSlaveTaskParamCtx struct{}
GetSlaveTaskService struct {
ID int `uri:"id" binding:"required"`
}
)
func (s *GetSlaveTaskService) Get(c *gin.Context) (*cluster.SlaveTaskSummary, error) {
dep := dependency.FromContext(c)
registry := dep.TaskRegistry()
t, ok := registry.Get(s.ID)
if !ok {
return nil, serializer.NewError(serializer.CodeNotFound, "task not found", nil)
}
status := t.Status()
_, clearOnComplete := c.GetQuery(routes.SlaveClearTaskRegistryQuery)
if clearOnComplete && status == task.StatusCompleted ||
status == task.StatusError ||
status == task.StatusCanceled {
registry.Delete(s.ID)
}
res := &cluster.SlaveTaskSummary{
Status: status,
PrivateState: t.State(),
Progress: t.Progress(c),
}
err := t.Error()
if err != nil {
res.Error = err.Error()
}
return res, nil
}
func slaveTaskPropsFromContext(ctx context.Context) (*types.SlaveTaskProps, error) {
nodeIdStr, ok := ctx.Value(cluster.SlaveNodeIDCtx{}).(string)
if !ok {
return nil, fmt.Errorf("failed to get node ID from context")
}
nodeId, err := strconv.Atoi(nodeIdStr)
if err != nil {
return nil, fmt.Errorf("failed to convert node ID to int: %w", err)
}
masterSiteUrl := cluster.MasterSiteUrlFromContext(ctx)
if masterSiteUrl == "" {
return nil, fmt.Errorf("failed to get master site URL from context")
}
masterSiteVersion, ok := ctx.Value(cluster.MasterSiteVersionCtx{}).(string)
if !ok {
return nil, fmt.Errorf("failed to get master site version from context")
}
masterSiteId, ok := ctx.Value(cluster.MasterSiteIDCtx{}).(string)
if !ok {
return nil, fmt.Errorf("failed to convert master site ID to int: %w", err)
}
props := &types.SlaveTaskProps{
NodeID: nodeId,
MasterSiteID: masterSiteId,
MasterSiteURl: masterSiteUrl,
MasterSiteVersion: masterSiteVersion,
}
return props, nil
}
type (
FolderCleanupParamCtx struct{}
)
func Cleanup(args *cluster.FolderCleanup, c *gin.Context) error {
l := logging.FromContext(c)
ae := serializer.NewAggregateError()
for _, p := range args.Path {
l.Info("Cleaning up folder %q", p)
if err := os.RemoveAll(p); err != nil {
l.Warning("Failed to clean up folder %q: %s", p, err)
ae.Add(p, err)
}
}
return ae.Aggregate()
}
type (
CreateSlaveDownloadTaskParamCtx struct{}
GetSlaveDownloadTaskParamCtx struct{}
CancelSlaveDownloadTaskParamCtx struct{}
SelectSlaveDownloadFilesParamCtx struct{}
TestSlaveDownloadParamCtx struct{}
)
+315
View File
@@ -0,0 +1,315 @@
package oauth
import (
"crypto/sha256"
"encoding/base64"
"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/auth"
"github.com/cloudreve/Cloudreve/v4/pkg/cluster/routes"
"github.com/cloudreve/Cloudreve/v4/pkg/hashid"
"github.com/cloudreve/Cloudreve/v4/pkg/serializer"
"github.com/cloudreve/Cloudreve/v4/pkg/util"
"github.com/gin-gonic/gin"
"github.com/samber/lo"
)
type (
GetAppRegistrationParamCtx struct{}
GetAppRegistrationService struct {
AppID string `uri:"app_id" binding:"required"`
}
)
func (s *GetAppRegistrationService) Get(c *gin.Context) (*AppRegistration, error) {
dep := dependency.FromContext(c)
oAuthClient := dep.OAuthClientClient()
app, err := oAuthClient.GetByGUIDWithGrants(c, s.AppID, inventory.UserIDFromContext(c))
if err != nil {
return nil, serializer.NewError(serializer.CodeNotFound, "App not found", err)
}
var grant *ent.OAuthGrant
if len(app.Edges.Grants) == 1 {
grant = app.Edges.Grants[0]
}
return BuildAppRegistration(app, grant), nil
}
type (
GrantParamCtx struct{}
GrantService struct {
ClientID string `json:"client_id" binding:"required"`
ResponseType string `json:"response_type" binding:"required,eq=code"`
RedirectURI string `json:"redirect_uri" binding:"required"`
State string `json:"state" binding:"max=4096"`
Scope string `json:"scope" binding:"required"`
CodeChallenge string `json:"code_challenge" binding:"max=255"`
CodeChallengeMethod string `json:"code_challenge_method" binding:"omitempty,eq=S256"`
}
)
func (s *GrantService) Get(c *gin.Context) (*GrantResponse, error) {
dep := dependency.FromContext(c)
user := inventory.UserFromContext(c)
kv := dep.KV()
oAuthClient := dep.OAuthClientClient()
if s.CodeChallenge != "" && s.CodeChallengeMethod == "" {
s.CodeChallengeMethod = "S256"
}
// 1. Get app registration and grant
app, err := oAuthClient.GetByGUIDWithGrants(c, s.ClientID, user.ID)
if err != nil {
return nil, serializer.NewError(serializer.CodeNotFound, "App not found", err)
}
// 2. Validate redirect URL: must match one of the registered redirect URIs
redirectValid := false
for _, uri := range app.RedirectUris {
if uri == s.RedirectURI {
redirectValid = true
break
}
}
if !redirectValid {
return nil, serializer.NewError(serializer.CodeParamErr, "Invalid redirect URI", nil)
}
// Parse requested scopes (space-separated per OAuth 2.0 spec)
requestedScopes := strings.Split(s.Scope, " ")
// Validate requested scopes: must be a subset of registered app scopes
if !auth.ValidateScopes(requestedScopes, app.Scopes) {
return nil, serializer.NewError(serializer.CodeParamErr, "Invalid scope requested", nil)
}
// Must have openid scope
if !lo.Contains(requestedScopes, types.ScopeOpenID) {
return nil, serializer.NewError(serializer.CodeParamErr, "openid scope required", nil)
}
// 3. Create/update grant
if err := oAuthClient.UpsertGrant(c, user.ID, app.ID, requestedScopes); err != nil {
return nil, serializer.NewError(serializer.CodeDBError, "Failed to create grant", err)
}
// 4. Generate code and save required state into KV for future token exchange request.
code := util.RandStringRunesCrypto(128)
authCode := &AuthorizationCode{
ClientID: s.ClientID,
UserID: user.ID,
Scopes: requestedScopes,
RedirectURI: s.RedirectURI,
CodeChallenge: s.CodeChallenge,
}
// Store auth code in KV with 10 minute TTL
if err := kv.Set(authCodeKey(code), authCode, 600); err != nil {
return nil, serializer.NewError(serializer.CodeCacheOperation, "Failed to store authorization code", err)
}
return &GrantResponse{
Code: code,
State: s.State,
}, nil
}
type (
ExchangeTokenParamCtx struct{}
ExchangeTokenService struct {
ClientID string `form:"client_id" binding:"required"`
ClientSecret string `form:"client_secret" binding:"required"`
GrantType string `form:"grant_type" binding:"required,eq=authorization_code"`
Code string `form:"code" binding:"required"`
CodeVerifier string `form:"code_verifier"`
}
)
func (s *ExchangeTokenService) Exchange(c *gin.Context) (*TokenResponse, error) {
dep := dependency.FromContext(c)
kv := dep.KV()
oAuthClient := dep.OAuthClientClient()
userClient := dep.UserClient()
tokenAuth := dep.TokenAuth()
// 1. Retrieve and validate authorization code from KV
codeKey := authCodeKey(s.Code)
authCodeRaw, ok := kv.Get(codeKey)
if !ok {
return nil, serializer.NewError(serializer.CodeCredentialInvalid, "Invalid or expired authorization code", nil)
}
authCode, ok := authCodeRaw.(*AuthorizationCode)
if !ok {
return nil, serializer.NewError(serializer.CodeCredentialInvalid, "Invalid authorization code", nil)
}
// Delete the code immediately to prevent replay attacks
_ = kv.Delete("", codeKey)
// 2. Validate client_id matches the one in authorization code
if authCode.ClientID != s.ClientID {
return nil, serializer.NewError(serializer.CodeCredentialInvalid, "Client ID mismatch", nil)
}
// 3. Verify PKCE: SHA256(code_verifier) should match code_challenge
if authCode.CodeChallenge != "" {
verifierHash := sha256.Sum256([]byte(s.CodeVerifier))
expectedChallenge := base64.RawURLEncoding.EncodeToString(verifierHash[:])
if expectedChallenge != authCode.CodeChallenge {
return nil, serializer.NewError(serializer.CodeCredentialInvalid, "Invalid code verifier", nil)
}
}
// 4. Validate client secret
app, err := oAuthClient.GetByGUID(c, s.ClientID)
if err != nil {
return nil, serializer.NewError(serializer.CodeNotFound, "App not found", err)
}
if app.Secret != s.ClientSecret {
return nil, serializer.NewError(serializer.CodeCredentialInvalid, "Invalid client secret", nil)
}
// 5. Validate scopes are still valid for this app
if !auth.ValidateScopes(authCode.Scopes, app.Scopes) {
return nil, serializer.NewError(serializer.CodeParamErr, "Invalid scope", nil)
}
// 6. Get user
user, err := userClient.GetActiveByID(c, authCode.UserID)
if err != nil {
return nil, serializer.NewError(serializer.CodeUserNotFound, "User not found", err)
}
// 7. Determine refresh token TTL override from app settings
var refreshTTLOverride time.Duration
if app.Props != nil && app.Props.RefreshTokenTTL > 0 {
refreshTTLOverride = time.Duration(app.Props.RefreshTokenTTL) * time.Second
}
// 8. Issue tokens
token, err := tokenAuth.Issue(c, &auth.IssueTokenArgs{
User: user,
ClientID: s.ClientID,
Scopes: authCode.Scopes,
RefreshTTLOverride: refreshTTLOverride,
})
if err != nil {
return nil, serializer.NewError(serializer.CodeCredentialInvalid, "Failed to issue token", err)
}
// 9. Update grant last used at
if err := oAuthClient.UpdateGrantLastUsedAt(c, user.ID, app.ID); err != nil {
dep.Logger().Warning("Failed to update grant last used at: %s", err)
}
// 10.
// 11. Build response, only include refresh token if offline_access scope is present
resp := &TokenResponse{
AccessToken: token.AccessToken,
TokenType: "Bearer",
ExpiresIn: int64(time.Until(token.AccessExpires).Seconds()),
RefreshTokenExpiresIn: int64(time.Until(token.RefreshExpires).Seconds()),
Scope: strings.Join(authCode.Scopes, " "),
}
for _, scope := range authCode.Scopes {
if scope == types.ScopeOfflineAccess {
resp.RefreshToken = token.RefreshToken
break
}
}
return resp, nil
}
type (
DeleteOAuthGrantParamCtx struct{}
DeleteOAuthGrantService struct {
AppID string `uri:"app_id" binding:"required"`
}
)
func (s *DeleteOAuthGrantService) Delete(c *gin.Context) error {
dep := dependency.FromContext(c)
user := inventory.UserFromContext(c)
oAuthClient := dep.OAuthClientClient()
// Delete the grant - the method validates that the grant belongs to the current user
deleted, err := oAuthClient.DeleteGrantByUserAndClientGUID(c, user.ID, s.AppID)
if err != nil {
return serializer.NewError(serializer.CodeDBError, "Failed to delete OAuth grant", err)
}
if !deleted {
return serializer.NewError(serializer.CodeNotFound, "OAuth grant not found", nil)
}
return nil
}
type (
UserInfoParamCtx struct{}
UserInfoService struct{}
)
// GetUserInfo returns OpenID Connect userinfo based on the access token's scopes.
// The response fields are conditionally included based on granted scopes:
// - openid: sub (always required)
// - profile: name, preferred_username, picture, updated_at
// - email: email, email_verified
func (s *UserInfoService) GetUserInfo(c *gin.Context) (*UserInfoResponse, error) {
dep := dependency.FromContext(c)
u := inventory.UserFromContext(c)
hashIDEncoder := dep.HashIDEncoder()
// 1. Get and parse the access token from Authorization header
hasScopes, scopes := auth.GetScopesFromContext(c)
if hasScopes {
// 2. Verify openid scope is present (required for userinfo endpoint)
hasOpenID := false
for _, scope := range scopes {
if scope == types.ScopeOpenID {
hasOpenID = true
break
}
}
if !hasOpenID {
return nil, serializer.NewError(serializer.CodeNoPermissionErr, "openid scope required", nil)
}
} else {
scopes = []string{types.ScopeOpenID, types.ScopeProfile, types.ScopeEmail}
}
// 4. Build response based on scopes
resp := &UserInfoResponse{
Sub: hashid.EncodeUserID(hashIDEncoder, u.ID),
}
// Check scopes and populate fields accordingly
for _, scope := range scopes {
switch scope {
case types.ScopeProfile:
siteUrl := dep.SettingProvider().SiteURL(c)
resp.Name = u.Nick
resp.PreferredUsername = u.Nick
resp.Picture = routes.MasterUserAvatarUrl(siteUrl, hashid.EncodeUserID(hashIDEncoder, u.ID)).String()
resp.UpdatedAt = u.UpdatedAt.Unix()
case types.ScopeEmail:
resp.Email = u.Email
resp.EmailVerified = true // Users in Cloudreve have verified emails
}
}
return resp, nil
}
+89
View File
@@ -0,0 +1,89 @@
package oauth
import (
"encoding/gob"
"github.com/cloudreve/Cloudreve/v4/ent"
)
const (
authCodeKeyPrefix = "oauth_code_"
)
type AppRegistration struct {
ID string `json:"id"`
Name string `json:"name"`
HomepageURL string `json:"homepage_url,omitempty"`
Icon string `json:"icon,omitempty"`
Description string `json:"description,omitempty"`
ConstentedScopes []string `json:"consented_scopes,omitempty"`
}
func BuildAppRegistration(app *ent.OAuthClient, grant *ent.OAuthGrant) *AppRegistration {
res := &AppRegistration{
ID: app.GUID,
Name: app.Name,
HomepageURL: app.HomepageURL,
}
if app.Props != nil {
res.Description = app.Props.Description
res.Icon = app.Props.Icon
}
if grant != nil {
res.ConstentedScopes = grant.Scopes
}
return res
}
type GrantResponse struct {
Code string `json:"code"`
State string `json:"state"`
}
// TokenResponse represents the OAuth token response.
type TokenResponse struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
ExpiresIn int64 `json:"expires_in"`
RefreshTokenExpiresIn int64 `json:"refresh_token_expires_in"`
RefreshToken string `json:"refresh_token,omitempty"`
Scope string `json:"scope"`
}
// UserInfoResponse represents the OpenID Connect userinfo response.
// Fields are conditionally included based on granted scopes.
type UserInfoResponse struct {
// Always included (openid scope)
Sub string `json:"sub"`
// Profile scope
Name string `json:"name,omitempty"`
PreferredUsername string `json:"preferred_username,omitempty"`
Picture string `json:"picture,omitempty"`
UpdatedAt int64 `json:"updated_at,omitempty"`
// Email scope
Email string `json:"email,omitempty"`
EmailVerified bool `json:"email_verified,omitempty"`
}
// AuthorizationCode represents the data stored in KV for an OAuth authorization code.
// Used for PKCE verification during token exchange.
type AuthorizationCode struct {
ClientID string `json:"client_id"`
UserID int `json:"user_id"`
Scopes []string `json:"scopes"`
RedirectURI string `json:"redirect_uri"`
CodeChallenge string `json:"code_challenge"`
}
func authCodeKey(code string) string {
return authCodeKeyPrefix + code
}
func init() {
gob.Register(&AuthorizationCode{})
}
+44
View File
@@ -0,0 +1,44 @@
package setting
import (
"github.com/cloudreve/Cloudreve/v4/ent"
"github.com/cloudreve/Cloudreve/v4/inventory"
"github.com/cloudreve/Cloudreve/v4/pkg/boolset"
"github.com/cloudreve/Cloudreve/v4/pkg/hashid"
"github.com/samber/lo"
"time"
)
type ListDavAccountResponse struct {
Accounts []DavAccount `json:"accounts"`
Pagination *inventory.PaginationResults `json:"pagination"`
}
func BuildListDavAccountResponse(res *inventory.ListDavAccountResult, hasher hashid.Encoder) *ListDavAccountResponse {
return &ListDavAccountResponse{
Accounts: lo.Map(res.Accounts, func(item *ent.DavAccount, index int) DavAccount {
return BuildDavAccount(item, hasher)
}),
Pagination: res.PaginationResults,
}
}
type DavAccount struct {
ID string `json:"id"`
CreatedAt time.Time `json:"created_at"`
Name string `json:"name"`
Uri string `json:"uri"`
Password string `json:"password"`
Options *boolset.BooleanSet `json:"options"`
}
func BuildDavAccount(account *ent.DavAccount, hasher hashid.Encoder) DavAccount {
return DavAccount{
ID: hashid.EncodeDavAccountID(hasher, account.ID),
CreatedAt: account.CreatedAt,
Name: account.Name,
Uri: account.URI,
Password: account.Password,
Options: account.Options,
}
}
+204
View File
@@ -0,0 +1,204 @@
package setting
import (
"github.com/cloudreve/Cloudreve/v4/application/constants"
"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/boolset"
"github.com/cloudreve/Cloudreve/v4/pkg/filemanager/fs"
"github.com/cloudreve/Cloudreve/v4/pkg/hashid"
"github.com/cloudreve/Cloudreve/v4/pkg/serializer"
"github.com/cloudreve/Cloudreve/v4/pkg/util"
"github.com/gin-gonic/gin"
)
// WebDAVAccountService WebDAV 账号管理服务
type WebDAVAccountService struct {
ID uint `uri:"id" binding:"required,min=1"`
}
// WebDAVAccountCreateService WebDAV 账号创建服务
type WebDAVAccountCreateService struct {
Path string `json:"path" binding:"required,min=1,max=65535"`
Name string `json:"name" binding:"required,min=1,max=255"`
}
// WebDAVAccountUpdateReadonlyService WebDAV 修改只读性服务
type WebDAVAccountUpdateReadonlyService struct {
ID uint `json:"id" binding:"required,min=1"`
Readonly bool `json:"readonly"`
}
// WebDAVMountCreateService WebDAV 挂载创建服务
type WebDAVMountCreateService struct {
Path string `json:"path" binding:"required,min=1,max=65535"`
Policy string `json:"policy" binding:"required,min=1"`
}
//// Unmount 取消目录挂载
//func (service *WebDAVListService) Unmount(c *gin.Context, user *model.User) serializer.Response {
// folderID, _ := c.Get("object_id")
// folder, err := model.GetFoldersByIDs([]uint{folderID.(uint)}, user.ID)
// if err != nil || len(folder) == 0 {
// return serializer.ErrDeprecated(serializer.CodeParentNotExist, "", err)
// }
//
// if err := folder[0].Mount(0); err != nil {
// return serializer.DBErrDeprecated("Failed to update folder record", err)
// }
//
// return serializer.Response{}
//}
type (
ListDavAccountsService struct {
PageSize int `form:"page_size" binding:"required,min=10,max=100"`
NextPageToken string `form:"next_page_token"`
}
ListDavAccountParamCtx struct{}
)
// Accounts 列出WebDAV账号
func (service *ListDavAccountsService) List(c *gin.Context) (*ListDavAccountResponse, error) {
dep := dependency.FromContext(c)
user := inventory.UserFromContext(c)
hasher := dep.HashIDEncoder()
davAccountClient := dep.DavAccountClient()
args := &inventory.ListDavAccountArgs{
UserID: user.ID,
PaginationArgs: &inventory.PaginationArgs{
UseCursorPagination: true,
PageSize: service.PageSize,
PageToken: service.NextPageToken,
},
}
res, err := davAccountClient.List(c, args)
if err != nil {
return nil, serializer.NewError(serializer.CodeDBError, "Failed to list dav accounts", err)
}
return BuildListDavAccountResponse(res, hasher), nil
}
type (
CreateDavAccountService struct {
Uri string `json:"uri" binding:"required"`
Name string `json:"name" binding:"required,min=1,max=255"`
Readonly bool `json:"readonly"`
Proxy bool `json:"proxy"`
DisableSysFiles bool `json:"disable_sys_files"`
}
CreateDavAccountParamCtx struct{}
)
// Create 创建WebDAV账号
func (service *CreateDavAccountService) Create(c *gin.Context) (*DavAccount, error) {
dep := dependency.FromContext(c)
user := inventory.UserFromContext(c)
bs, err := service.validateAndGetBs(user)
if err != nil {
return nil, err
}
davAccountClient := dep.DavAccountClient()
account, err := davAccountClient.Create(c, &inventory.CreateDavAccountParams{
UserID: user.ID,
Name: service.Name,
URI: service.Uri,
Password: util.RandString(32, util.RandomLowerCases),
Options: bs,
})
if err != nil {
return nil, serializer.NewError(serializer.CodeDBError, "Failed to create dav account", err)
}
accountRes := BuildDavAccount(account, dep.HashIDEncoder())
return &accountRes, nil
}
// Update updates an existing account
func (service *CreateDavAccountService) Update(c *gin.Context) (*DavAccount, error) {
dep := dependency.FromContext(c)
user := inventory.UserFromContext(c)
accountId := hashid.FromContext(c)
// Find existing account
davAccountClient := dep.DavAccountClient()
account, err := davAccountClient.GetByIDAndUserID(c, accountId, user.ID)
if err != nil {
return nil, serializer.NewError(serializer.CodeNotFound, "Account not exist", err)
}
bs, err := service.validateAndGetBs(user)
if err != nil {
return nil, err
}
// Update account
account, err = davAccountClient.Update(c, accountId, &inventory.CreateDavAccountParams{
Name: service.Name,
URI: service.Uri,
Options: bs,
})
if err != nil {
return nil, serializer.NewError(serializer.CodeDBError, "Failed to update dav account", err)
}
accountRes := BuildDavAccount(account, dep.HashIDEncoder())
return &accountRes, nil
}
func (service *CreateDavAccountService) validateAndGetBs(user *ent.User) (*boolset.BooleanSet, error) {
if !user.Edges.Group.Permissions.Enabled(int(types.GroupPermissionWebDAV)) {
return nil, serializer.NewError(serializer.CodeGroupNotAllowed, "WebDAV is not enabled for this user group", nil)
}
uri, err := fs.NewUriFromString(service.Uri)
if err != nil {
return nil, serializer.NewError(serializer.CodeParamErr, "Invalid URI", err)
}
// Only "my" and "share" fs is allowed in WebDAV
if uriFs := uri.FileSystem(); uri.SearchParameters() != nil ||
(uriFs != constants.FileSystemMy && uriFs != constants.FileSystemShare) {
return nil, serializer.NewError(serializer.CodeParamErr, "Invalid URI", nil)
}
bs := boolset.BooleanSet{}
if service.Readonly {
boolset.Set(types.DavAccountReadOnly, true, &bs)
}
if service.DisableSysFiles {
boolset.Set(types.DavAccountDisableSysFiles, true, &bs)
}
if service.Proxy && user.Edges.Group.Permissions.Enabled(int(types.GroupPermissionWebDAVProxy)) {
boolset.Set(types.DavAccountProxy, true, &bs)
}
return &bs, nil
}
func DeleteDavAccount(c *gin.Context) error {
dep := dependency.FromContext(c)
user := inventory.UserFromContext(c)
accountId := hashid.FromContext(c)
// Find existing account
davAccountClient := dep.DavAccountClient()
_, err := davAccountClient.GetByIDAndUserID(c, accountId, user.ID)
if err != nil {
return serializer.NewError(serializer.CodeNotFound, "Account not exist", err)
}
if err := davAccountClient.Delete(c, accountId); err != nil {
return serializer.NewError(serializer.CodeDBError, "Failed to delete dav account", err)
}
return nil
}
+126
View File
@@ -0,0 +1,126 @@
package share
import (
"context"
"fmt"
"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/filemanager/fs"
"github.com/cloudreve/Cloudreve/v4/pkg/filemanager/manager"
"github.com/cloudreve/Cloudreve/v4/pkg/hashid"
"github.com/cloudreve/Cloudreve/v4/pkg/serializer"
"github.com/cloudreve/Cloudreve/v4/service/explorer"
"github.com/gin-gonic/gin"
)
type (
// ShareCreateService 创建新分享服务
ShareCreateService struct {
Uri string `json:"uri" binding:"required"`
IsPrivate bool `json:"is_private"`
Password string `json:"password" binding:"omitempty,max=32,alphanum"`
RemainDownloads int `json:"downloads"`
Expire int `json:"expire"`
ShareView bool `json:"share_view"`
ShowReadMe bool `json:"show_readme"`
}
ShareCreateParamCtx struct{}
BatchDeleteShareService struct {
ShareIDs []string `json:"ids" binding:"required"`
}
BatchDeleteParamCtx struct{}
)
func (service *BatchDeleteShareService) Delete(c *gin.Context) error {
dep := dependency.FromContext(c)
uid := inventory.UserIDFromContext(c)
shareClient := dep.ShareClient()
var ids []int
for _, v := range service.ShareIDs {
id, err := dep.HashIDEncoder().Decode(v, hashid.ShareID)
if err != nil {
return fmt.Errorf("failed to decode hash id %q: %w", v, err)
}
ids = append(ids, id)
}
if err := shareClient.DeleteBatchByUserID(c, uid, ids); err != nil {
return serializer.NewError(serializer.CodeDBError, "Failed to delete shares", err)
}
return nil
}
// Upsert 创建或更新分享
func (service *ShareCreateService) Upsert(c *gin.Context, existed int) (string, error) {
dep := dependency.FromContext(c)
user := inventory.UserFromContext(c)
m := manager.NewFileManager(dep, user)
defer m.Recycle()
// Check group permission for creating share link
if !user.Edges.Group.Permissions.Enabled(int(types.GroupPermissionShare)) {
return "", serializer.NewError(serializer.CodeGroupNotAllowed, "Group permission denied", nil)
}
uri, err := fs.NewUriFromString(service.Uri)
if err != nil {
return "", serializer.NewError(serializer.CodeParamErr, "unknown uri", err)
}
var expires *time.Time
if service.Expire > 0 {
expires = new(time.Time)
*expires = time.Now().Add(time.Duration(service.Expire) * time.Second)
}
share, err := m.CreateOrUpdateShare(c, uri, &manager.CreateShareArgs{
IsPrivate: service.IsPrivate,
Password: service.Password,
RemainDownloads: service.RemainDownloads,
Expire: expires,
ExistedShareID: existed,
ShareView: service.ShareView,
ShowReadMe: service.ShowReadMe,
})
if err != nil {
return "", err
}
base := dep.SettingProvider().SiteURL(c)
return explorer.BuildShareLink(share, dep.HashIDEncoder(), base, true), nil
}
func DeleteShare(c *gin.Context, shareId int) error {
dep := dependency.FromContext(c)
user := inventory.UserFromContext(c)
shareClient := dep.ShareClient()
ctx := context.WithValue(c, inventory.LoadShareFile{}, true)
var (
share *ent.Share
err error
)
if user.Edges.Group.Permissions.Enabled(int(types.GroupPermissionIsAdmin)) {
share, err = shareClient.GetByID(ctx, shareId)
} else {
share, err = shareClient.GetByIDUser(ctx, shareId, user.ID)
}
if err != nil {
return serializer.NewError(serializer.CodeNotFound, "share not found", err)
}
if err := shareClient.Delete(c, share.ID); err != nil {
return serializer.NewError(serializer.CodeDBError, "Failed to delete share", err)
}
return nil
}
+48
View File
@@ -0,0 +1,48 @@
package share
import (
"context"
"net/url"
"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/filemanager/fs"
"github.com/cloudreve/Cloudreve/v4/pkg/filemanager/fs/dbfs"
"github.com/cloudreve/Cloudreve/v4/pkg/hashid"
"github.com/cloudreve/Cloudreve/v4/service/explorer"
"github.com/samber/lo"
)
type ListShareResponse struct {
Shares []explorer.Share `json:"shares"`
Pagination *inventory.PaginationResults `json:"pagination"`
}
func BuildListShareResponse(ctx context.Context, res *inventory.ListShareResult, hasher hashid.Encoder, base *url.URL, requester *ent.User, unlocked bool) *ListShareResponse {
var infos []explorer.Share
for _, share := range res.Shares {
expired := inventory.IsValidShare(share) != nil
shareName := share.Edges.File.Name
if share.Edges.File.FileChildren == 0 && len(share.Edges.File.Edges.Metadata) >= 0 {
// For files in trash bin, read the real name from metadata
restoreUri, found := lo.Find(share.Edges.File.Edges.Metadata, func(m *ent.Metadata) bool {
return m.Name == dbfs.MetadataRestoreUri
})
if found {
uri, err := fs.NewUriFromString(restoreUri.Value)
if err == nil {
shareName = uri.Name()
}
}
}
infos = append(infos, *explorer.BuildShare(ctx, share, base, hasher, requester, share.Edges.User, shareName,
types.FileType(share.Edges.File.Type), unlocked, expired))
}
return &ListShareResponse{
Shares: infos,
Pagination: res.PaginationResults,
}
}
+192
View File
@@ -0,0 +1,192 @@
package share
import (
"context"
"strings"
"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/manager"
"github.com/cloudreve/Cloudreve/v4/pkg/hashid"
"github.com/cloudreve/Cloudreve/v4/pkg/serializer"
"github.com/cloudreve/Cloudreve/v4/service/explorer"
"github.com/gin-gonic/gin"
)
type (
ShortLinkRedirectService struct {
ID string `uri:"id" binding:"required"`
Password string `uri:"password"`
}
ShortLinkRedirectParamCtx struct{}
)
func (s *ShortLinkRedirectService) RedirectTo(c *gin.Context) string {
shareLongUrl := routes.MasterShareLongUrl(s.ID, s.Password)
shortLinkQuery := c.Request.URL.Query() // Query in ShortLink, adapt to Cloudreve V3
shareLongUrlQuery := shareLongUrl.Query()
userSpecifiedPath := shortLinkQuery.Get("path")
if userSpecifiedPath != "" {
masterPath := shareLongUrlQuery.Get("path")
masterPath += "/" + strings.TrimPrefix(userSpecifiedPath, "/")
shareLongUrlQuery.Set("path", masterPath)
}
shortLinkQuery.Del("path") // 防止用户指定的 Path 就是空字符串
for k, vals := range shortLinkQuery {
shareLongUrlQuery[k] = append(shareLongUrlQuery[k], vals...)
}
shareLongUrl.RawQuery = shareLongUrlQuery.Encode()
return shareLongUrl.String()
}
type (
ShareInfoService struct {
Password string `form:"password"`
CountViews bool `form:"count_views"`
OwnerExtended bool `form:"owner_extended"`
}
ShareInfoParamCtx struct{}
)
func (s *ShareInfoService) Get(c *gin.Context) (*explorer.Share, error) {
dep := dependency.FromContext(c)
u := inventory.UserFromContext(c)
shareClient := dep.ShareClient()
ctx := context.WithValue(c, inventory.LoadShareUser{}, true)
ctx = context.WithValue(ctx, inventory.LoadShareFile{}, true)
share, err := shareClient.GetByID(ctx, hashid.FromContext(c))
if err != nil {
if ent.IsNotFound(err) {
return nil, serializer.NewError(serializer.CodeNotFound, "Share not found", nil)
}
return nil, serializer.NewError(serializer.CodeDBError, "Failed to get share", err)
}
if err := inventory.IsValidShare(share); err != nil {
return nil, serializer.NewError(serializer.CodeNotFound, "Share link expired", err)
}
if s.CountViews {
_ = shareClient.Viewed(c, share)
}
unlocked := true
// Share requires password
if share.Password != "" && s.Password != share.Password && share.Edges.User.ID != u.ID {
unlocked = false
}
base := dep.SettingProvider().SiteURL(c)
res := explorer.BuildShare(c, share, base, dep.HashIDEncoder(), u, share.Edges.User, share.Edges.File.Name,
types.FileType(share.Edges.File.Type), unlocked, false)
if s.OwnerExtended && share.Edges.User.ID == u.ID {
// Add more information about the shared file
m := manager.NewFileManager(dep, u)
defer m.Recycle()
shareUri, err := fs.NewUriFromString(fs.NewShareUri(res.ID, s.Password))
if err != nil {
return nil, serializer.NewError(serializer.CodeInternalSetting, "Invalid share url", err)
}
root, err := m.Get(c, shareUri)
if err != nil {
return nil, serializer.NewError(serializer.CodeNotFound, "File not found", err)
}
res.SourceUri = root.Uri(true).String()
}
return res, nil
}
type (
ListShareService struct {
PageSize int `form:"page_size" binding:"required,min=10,max=100"`
OrderBy string `uri:"order_by" form:"order_by" json:"order_by"`
OrderDirection string `uri:"order_direction" form:"order_direction" json:"order_direction"`
NextPageToken string `form:"next_page_token"`
}
ListShareParamCtx struct{}
)
func (s *ListShareService) List(c *gin.Context) (*ListShareResponse, error) {
dep := dependency.FromContext(c)
user := inventory.UserFromContext(c)
hasher := dep.HashIDEncoder()
shareClient := dep.ShareClient()
args := &inventory.ListShareArgs{
PaginationArgs: &inventory.PaginationArgs{
UseCursorPagination: true,
PageToken: s.NextPageToken,
PageSize: s.PageSize,
Order: inventory.OrderDirection(s.OrderDirection),
OrderBy: s.OrderBy,
},
UserID: user.ID,
}
ctx := context.WithValue(c, inventory.LoadShareUser{}, true)
ctx = context.WithValue(ctx, inventory.LoadShareFile{}, true)
ctx = context.WithValue(ctx, inventory.LoadFileMetadata{}, true)
res, err := shareClient.List(ctx, args)
if err != nil {
return nil, serializer.NewError(serializer.CodeDBError, "Failed to list shares", err)
}
base := dep.SettingProvider().SiteURL(ctx)
return BuildListShareResponse(ctx, res, hasher, base, user, true), nil
}
func (s *ListShareService) ListInUserProfile(c *gin.Context, uid int) (*ListShareResponse, error) {
dep := dependency.FromContext(c)
user := inventory.UserFromContext(c)
hasher := dep.HashIDEncoder()
shareClient := dep.ShareClient()
targetUser, err := dep.UserClient().GetActiveByID(c, uid)
if err != nil {
return nil, serializer.NewError(serializer.CodeDBError, "Failed to get user", err)
}
if targetUser.Settings != nil && targetUser.Settings.ShareLinksInProfile == types.ProfileHideShare {
return nil, serializer.NewError(serializer.CodeParamErr, "User has disabled share links in profile", nil)
}
publicOnly := targetUser.Settings == nil || targetUser.Settings.ShareLinksInProfile == types.ProfilePublicShareOnly
args := &inventory.ListShareArgs{
PaginationArgs: &inventory.PaginationArgs{
UseCursorPagination: true,
PageToken: s.NextPageToken,
PageSize: s.PageSize,
Order: inventory.OrderDirection(s.OrderDirection),
OrderBy: s.OrderBy,
},
UserID: uid,
PublicOnly: publicOnly,
}
ctx := context.WithValue(c, inventory.LoadShareUser{}, true)
ctx = context.WithValue(ctx, inventory.LoadShareFile{}, true)
ctx = context.WithValue(ctx, inventory.LoadFileMetadata{}, true)
res, err := shareClient.List(ctx, args)
if err != nil {
return nil, serializer.NewError(serializer.CodeDBError, "Failed to list shares", err)
}
base := dep.SettingProvider().SiteURL(ctx)
return BuildListShareResponse(ctx, res, hasher, base, user, false), nil
}
+67
View File
@@ -0,0 +1,67 @@
package user
import (
"context"
"github.com/cloudreve/Cloudreve/v4/application/dependency"
"github.com/cloudreve/Cloudreve/v4/ent"
"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/hashid"
"github.com/cloudreve/Cloudreve/v4/pkg/serializer"
"github.com/gin-gonic/gin"
"github.com/samber/lo"
)
func GetUser(c *gin.Context) (*ent.User, error) {
uid := hashid.FromContext(c)
dep := dependency.FromContext(c)
userClient := dep.UserClient()
ctx := context.WithValue(c, inventory.LoadUserGroup{}, true)
return userClient.GetByID(ctx, uid)
}
func GetUserCapacity(c *gin.Context) (*fs.Capacity, error) {
user := inventory.UserFromContext(c)
dep := dependency.FromContext(c)
m := manager.NewFileManager(dep, user)
defer m.Recycle()
return m.Capacity(c)
}
type (
SearchUserService struct {
Keyword string `form:"keyword" binding:"required,min=2"`
}
SearchUserParamCtx struct{}
)
const resultLimit = 10
func (s *SearchUserService) Search(c *gin.Context) ([]*ent.User, error) {
dep := dependency.FromContext(c)
userClient := dep.UserClient()
res, err := userClient.SearchActive(c, resultLimit, s.Keyword)
if err != nil {
return nil, serializer.NewError(serializer.CodeDBError, "Failed to search user", err)
}
return res, nil
}
// ListAllGroups lists all groups.
func ListAllGroups(c *gin.Context) ([]*ent.Group, error) {
dep := dependency.FromContext(c)
groupClient := dep.GroupClient()
res, err := groupClient.ListAll(c)
if err != nil {
return nil, serializer.NewError(serializer.CodeDBError, "Failed to list all groups", err)
}
res = lo.Filter(res, func(g *ent.Group, index int) bool {
return g.ID != inventory.AnonymousGroupID
})
return res, nil
}
+265
View File
@@ -0,0 +1,265 @@
package user
import (
"context"
"fmt"
"github.com/cloudreve/Cloudreve/v4/application/dependency"
"github.com/cloudreve/Cloudreve/v4/ent"
"github.com/cloudreve/Cloudreve/v4/ent/user"
"github.com/cloudreve/Cloudreve/v4/inventory"
"github.com/cloudreve/Cloudreve/v4/pkg/auth"
"github.com/cloudreve/Cloudreve/v4/pkg/cluster/routes"
"github.com/cloudreve/Cloudreve/v4/pkg/email"
"github.com/cloudreve/Cloudreve/v4/pkg/hashid"
"github.com/cloudreve/Cloudreve/v4/pkg/serializer"
"github.com/cloudreve/Cloudreve/v4/pkg/util"
"github.com/gin-gonic/gin"
"github.com/gofrs/uuid"
"github.com/pquerna/otp/totp"
)
// LoginParameterCtx define key fore UserLoginService
type LoginParameterCtx struct{}
// UserLoginService 管理用户登录的服务
type UserLoginService struct {
UserName string `form:"email" json:"email" binding:"required,email"`
Password string `form:"password" json:"password" binding:"required,min=4,max=128"`
}
type (
// UserResetService 密码重设服务
UserResetService struct {
Password string `form:"password" json:"password" binding:"required,min=6,max=128"`
Secret string `json:"secret" binding:"required"`
}
UserResetParameterCtx struct{}
)
// Reset 重设密码
func (service *UserResetService) Reset(c *gin.Context) (*User, error) {
dep := dependency.FromContext(c)
userClient := dep.UserClient()
kv := dep.KV()
uid := hashid.FromContext(c)
resetSession, ok := kv.Get(fmt.Sprintf("user_reset_%d", uid))
if !ok || resetSession.(string) != service.Secret {
return nil, serializer.NewError(serializer.CodeTempLinkExpired, "Link is expired", nil)
}
if err := kv.Delete(fmt.Sprintf("user_reset_%d", uid)); err != nil {
return nil, serializer.NewError(serializer.CodeInternalSetting, "Failed to delete reset session", err)
}
u, err := userClient.GetActiveByID(c, uid)
if err != nil {
return nil, serializer.NewError(serializer.CodeUserNotFound, "User not found", err)
}
u, err = userClient.UpdatePassword(c, u, service.Password)
if err != nil {
return nil, serializer.NewError(serializer.CodeInternalSetting, "Failed to update password", err)
}
userRes := BuildUser(u, dep.HashIDEncoder())
return &userRes, nil
}
type (
// UserResetEmailService 发送密码重设邮件服务
UserResetEmailService struct {
UserName string `form:"email" json:"email" binding:"required,email"`
Language string `form:"language" json:"language"`
}
UserResetEmailParameterCtx struct{}
)
const userResetPrefix = "user_reset_"
// Reset 发送密码重设邮件
func (service *UserResetEmailService) Reset(c *gin.Context) error {
dep := dependency.FromContext(c)
userClient := dep.UserClient()
u, err := userClient.GetByEmail(c, service.UserName)
if err != nil {
return serializer.NewError(serializer.CodeUserNotFound, "User not found", err)
}
if u.Status == user.StatusManualBanned || u.Status == user.StatusSysBanned {
return serializer.NewError(serializer.CodeUserBaned, "This user is banned", nil)
}
if u.Status == user.StatusInactive {
return serializer.NewError(serializer.CodeUserNotActivated, "This user is not activated", nil)
}
secret := util.RandStringRunesCrypto(32)
if err := dep.KV().Set(fmt.Sprintf("%s%d", userResetPrefix, u.ID), secret, 3600); err != nil {
return serializer.NewError(serializer.CodeInternalSetting, "Failed to create reset session", err)
}
base := dep.SettingProvider().SiteURL(c)
resetUrl := routes.MasterUserResetUrl(base)
queries := resetUrl.Query()
queries.Add("id", hashid.EncodeUserID(dep.HashIDEncoder(), u.ID))
queries.Add("secret", secret)
resetUrl.RawQuery = queries.Encode()
title, body, err := email.NewResetEmail(c, dep.SettingProvider(), u, resetUrl.String())
if err != nil {
return serializer.NewError(serializer.CodeFailedSendEmail, "Failed to send activation email", err)
}
if err := dep.EmailClient(c).Send(c, u.Email, title, body); err != nil {
return serializer.NewError(serializer.CodeFailedSendEmail, "Failed to send activation email", err)
}
return nil
}
// Login 用户登录函数
func (service *UserLoginService) Login(c *gin.Context) (*ent.User, string, error) {
dep := dependency.FromContext(c)
userClient := dep.UserClient()
ctx := context.WithValue(c, inventory.LoadUserGroup{}, true)
expectedUser, err := userClient.GetByEmail(ctx, service.UserName)
// 一系列校验
if err != nil {
err = serializer.NewError(serializer.CodeInvalidPassword, "Incorrect password or email address", err)
} else if checkErr := inventory.CheckPassword(expectedUser, service.Password); checkErr != nil {
err = serializer.NewError(serializer.CodeInvalidPassword, "Incorrect password or email address", err)
} else if expectedUser.Status == user.StatusManualBanned || expectedUser.Status == user.StatusSysBanned {
err = serializer.NewError(serializer.CodeUserBaned, "This account has been blocked", nil)
} else if expectedUser.Status == user.StatusInactive {
err = serializer.NewError(serializer.CodeUserNotActivated, "This account is not activated", nil)
}
if err != nil {
return nil, "", err
}
if expectedUser.TwoFactorSecret != "" {
twoFaSessionID := uuid.Must(uuid.NewV4())
dep.KV().Set(fmt.Sprintf("user_2fa_%s", twoFaSessionID), expectedUser.ID, 600)
return expectedUser, twoFaSessionID.String(), nil
}
return expectedUser, "", nil
}
type (
LoginLogCtx struct{}
)
func IssueToken(c *gin.Context) (*BuiltinLoginResponse, error) {
dep := dependency.FromContext(c)
u := inventory.UserFromContext(c)
token, err := dep.TokenAuth().Issue(c, &auth.IssueTokenArgs{
User: u,
RootTokenID: nil,
})
if err != nil {
return nil, serializer.NewError(serializer.CodeEncryptError, "Failed to issue token pair", err)
}
return &BuiltinLoginResponse{
User: BuildUser(u, dep.HashIDEncoder()),
Token: *token,
}, nil
}
// RefreshTokenParameterCtx define key fore RefreshTokenService
type RefreshTokenParameterCtx struct{}
// RefreshTokenService refresh token service
type RefreshTokenService struct {
RefreshToken string `json:"refresh_token" binding:"required"`
}
func (s *RefreshTokenService) Refresh(c *gin.Context) (*auth.Token, error) {
dep := dependency.FromContext(c)
token, err := dep.TokenAuth().Refresh(c, s.RefreshToken)
if err != nil {
return nil, serializer.NewError(serializer.CodeCredentialInvalid, "Failed to issue token pair", err)
}
return token, nil
}
func (s *RefreshTokenService) Delete(c *gin.Context) (string, error) {
dep := dependency.FromContext(c)
claims, err := dep.TokenAuth().Claims(c, s.RefreshToken)
if err != nil {
return "", serializer.NewError(serializer.CodeCredentialInvalid, "Failed to parse token", err)
}
// Block root token
if claims.RootTokenID != nil {
tokenSettings := dep.SettingProvider().TokenAuth(c)
dep.KV().Set(fmt.Sprintf("%s%s", auth.RevokeTokenPrefix, claims.RootTokenID.String()), true, int(tokenSettings.RefreshTokenTTL.Seconds()+10))
}
return "", nil
}
type (
OtpValidationParameterCtx struct{}
OtpValidationService struct {
OTP string `json:"otp" binding:"required"`
SessionID string `json:"session_id" binding:"required"`
}
)
// Login 用户登录函数
func (service *OtpValidationService) Verify2FA(c *gin.Context) (*ent.User, error) {
dep := dependency.FromContext(c)
kv := dep.KV()
sessionRaw, ok := kv.Get(fmt.Sprintf("user_2fa_%s", service.SessionID))
if !ok {
return nil, serializer.NewError(serializer.CodeNotFound, "Session not found", nil)
}
uid := sessionRaw.(int)
ctx := context.WithValue(c, inventory.LoadUserGroup{}, true)
expectedUser, err := dep.UserClient().GetByID(ctx, uid)
if err != nil {
return nil, serializer.NewError(serializer.CodeNotFound, "User not found", err)
}
if expectedUser.TwoFactorSecret != "" {
if !totp.Validate(service.OTP, expectedUser.TwoFactorSecret) {
err := serializer.NewError(serializer.Code2FACodeErr, "Incorrect 2FA code", nil)
return nil, err
}
}
kv.Delete("user_2fa_", service.SessionID)
return expectedUser, nil
}
type (
PrepareLoginParameterCtx struct{}
PrepareLoginService struct {
Email string `form:"email" binding:"required,email"`
}
)
func (service *PrepareLoginService) Prepare(c *gin.Context) (*PrepareLoginResponse, error) {
dep := dependency.FromContext(c)
ctx := context.WithValue(c, inventory.LoadUserPasskey{}, true)
expectedUser, err := dep.UserClient().GetByEmail(ctx, service.Email)
if err != nil {
return nil, serializer.NewError(serializer.CodeNotFound, "User not found", err)
}
return &PrepareLoginResponse{
WebAuthnEnabled: len(expectedUser.Edges.Passkey) > 0,
PasswordEnabled: expectedUser.Password != "",
}, nil
}
+288
View File
@@ -0,0 +1,288 @@
package user
import (
"context"
"encoding/base64"
"encoding/gob"
"errors"
"fmt"
"github.com/cloudreve/Cloudreve/v4/application/dependency"
"github.com/cloudreve/Cloudreve/v4/ent"
"github.com/cloudreve/Cloudreve/v4/inventory"
"github.com/cloudreve/Cloudreve/v4/pkg/hashid"
"github.com/cloudreve/Cloudreve/v4/pkg/serializer"
"github.com/cloudreve/Cloudreve/v4/pkg/util"
"github.com/gin-gonic/gin"
"github.com/go-webauthn/webauthn/protocol"
"github.com/go-webauthn/webauthn/webauthn"
"github.com/gofrs/uuid"
"github.com/samber/lo"
"strconv"
"strings"
)
func init() {
gob.Register(webauthn.SessionData{})
}
type authnUser struct {
hasher hashid.Encoder
u *ent.User
credentials []*ent.Passkey
}
func (a *authnUser) WebAuthnID() []byte {
return []byte(hashid.EncodeUserID(a.hasher, a.u.ID))
}
func (a *authnUser) WebAuthnName() string {
return a.u.Email
}
func (a *authnUser) WebAuthnDisplayName() string {
return a.u.Nick
}
func (a *authnUser) WebAuthnCredentials() []webauthn.Credential {
if a.credentials == nil {
return nil
}
return lo.Map(a.credentials, func(item *ent.Passkey, index int) webauthn.Credential {
return *item.Credential
})
}
const (
authnSessionKey = "authn_session_"
)
func PreparePasskeyLogin(c *gin.Context) (*PreparePasskeyLoginResponse, error) {
dep := dependency.FromContext(c)
webAuthn, err := dep.WebAuthn(c)
if err != nil {
return nil, serializer.NewError(serializer.CodeInternalSetting, "Failed to initialize WebAuthn", err)
}
options, sessionData, err := webAuthn.BeginDiscoverableLogin()
if err != nil {
return nil, serializer.NewError(serializer.CodeInitializeAuthn, "Failed to begin registration", err)
}
sessionID := uuid.Must(uuid.NewV4()).String()
if err := dep.KV().Set(fmt.Sprintf("%s%s", authnSessionKey, sessionID), *sessionData, 300); err != nil {
return nil, serializer.NewError(serializer.CodeInternalSetting, "Failed to store session data", err)
}
return &PreparePasskeyLoginResponse{
Options: options,
SessionID: sessionID,
}, nil
}
type (
FinishPasskeyLoginParameterCtx struct{}
FinishPasskeyLoginService struct {
Response string `json:"response" binding:"required"`
SessionID string `json:"session_id" binding:"required"`
}
)
func (s *FinishPasskeyLoginService) FinishPasskeyLogin(c *gin.Context) (*ent.User, error) {
dep := dependency.FromContext(c)
kv := dep.KV()
userClient := dep.UserClient()
sessionDataRaw, ok := kv.Get(fmt.Sprintf("%s%s", authnSessionKey, s.SessionID))
if !ok {
return nil, serializer.NewError(serializer.CodeNotFound, "Session not found", nil)
}
_ = kv.Delete(authnSessionKey, s.Response)
webAuthn, err := dep.WebAuthn(c)
if err != nil {
return nil, serializer.NewError(serializer.CodeInternalSetting, "Failed to initialize WebAuthn", err)
}
sessionData := sessionDataRaw.(webauthn.SessionData)
pcc, err := protocol.ParseCredentialRequestResponseBody(strings.NewReader(s.Response))
if err != nil {
return nil, serializer.NewError(serializer.CodeParamErr, "Failed to parse request", err)
}
var loginedUser *ent.User
discoverUserHandle := func(rawID, userHandle []byte) (user webauthn.User, err error) {
uid, err := dep.HashIDEncoder().Decode(string(userHandle), hashid.UserID)
if err != nil {
return nil, err
}
ctx := context.WithValue(c, inventory.LoadUserPasskey{}, true)
ctx = context.WithValue(ctx, inventory.LoadUserGroup{}, true)
u, err := userClient.GetLoginUserByID(ctx, uid)
if err != nil {
return nil, serializer.NewError(serializer.CodeDBError, "Failed to get user", err)
}
if inventory.IsAnonymousUser(u) {
return nil, errors.New("anonymous user")
}
loginedUser = u
return &authnUser{u: u, hasher: dep.HashIDEncoder(), credentials: u.Edges.Passkey}, nil
}
credential, err := webAuthn.ValidateDiscoverableLogin(discoverUserHandle, sessionData, pcc)
if err != nil {
return nil, serializer.NewError(serializer.CodeWebAuthnCredentialError, "Failed to validate login", err)
}
// Find the credential just used
usedCredentialId := base64.StdEncoding.EncodeToString(credential.ID)
usedCredential, found := lo.Find(loginedUser.Edges.Passkey, func(item *ent.Passkey) bool {
return item.CredentialID == usedCredentialId
})
if !found {
return nil, serializer.NewError(serializer.CodeInternalSetting, "Passkey login passed but credential used is unknown", nil)
}
// Update used at
if err := userClient.MarkPasskeyUsed(c, loginedUser.ID, usedCredential.CredentialID); err != nil {
return nil, serializer.NewError(serializer.CodeDBError, "Failed to update passkey", err)
}
return loginedUser, nil
}
func PreparePasskeyRegister(c *gin.Context) (*protocol.CredentialCreation, error) {
dep := dependency.FromContext(c)
userClient := dep.UserClient()
u := inventory.UserFromContext(c)
existingKeys, err := userClient.ListPasskeys(c, u.ID)
if err != nil {
return nil, serializer.NewError(serializer.CodeDBError, "Failed to list passkeys", err)
}
webAuthn, err := dep.WebAuthn(c)
if err != nil {
return nil, serializer.NewError(serializer.CodeInternalSetting, "Failed to initialize WebAuthn", err)
}
authSelect := protocol.AuthenticatorSelection{
RequireResidentKey: protocol.ResidentKeyRequired(),
UserVerification: protocol.VerificationPreferred,
}
options, sessionData, err := webAuthn.BeginRegistration(
&authnUser{u: u, hasher: dep.HashIDEncoder()},
webauthn.WithAuthenticatorSelection(authSelect),
webauthn.WithExclusions(lo.Map(existingKeys, func(item *ent.Passkey, index int) protocol.CredentialDescriptor {
return protocol.CredentialDescriptor{
Type: protocol.PublicKeyCredentialType,
CredentialID: item.Credential.ID,
Transport: item.Credential.Transport,
AttestationType: item.Credential.AttestationType,
}
})),
)
if err != nil {
return nil, serializer.NewError(serializer.CodeInitializeAuthn, "Failed to begin registration", err)
}
if err := dep.KV().Set(fmt.Sprintf("%s%d", authnSessionKey, u.ID), *sessionData, 300); err != nil {
return nil, serializer.NewError(serializer.CodeInternalSetting, "Failed to store session data", err)
}
return options, nil
}
type (
FinishPasskeyRegisterParameterCtx struct{}
FinishPasskeyRegisterService struct {
Response string `json:"response" binding:"required"`
Name string `json:"name" binding:"required"`
UA string `json:"ua" binding:"required"`
}
)
func (s *FinishPasskeyRegisterService) FinishPasskeyRegister(c *gin.Context) (*Passkey, error) {
dep := dependency.FromContext(c)
kv := dep.KV()
u := inventory.UserFromContext(c)
sessionDataRaw, ok := kv.Get(fmt.Sprintf("%s%d", authnSessionKey, u.ID))
if !ok {
return nil, serializer.NewError(serializer.CodeNotFound, "Session not found", nil)
}
_ = kv.Delete(authnSessionKey, strconv.Itoa(u.ID))
webAuthn, err := dep.WebAuthn(c)
if err != nil {
return nil, serializer.NewError(serializer.CodeInternalSetting, "Failed to initialize WebAuthn", err)
}
sessionData := sessionDataRaw.(webauthn.SessionData)
pcc, err := protocol.ParseCredentialCreationResponseBody(strings.NewReader(s.Response))
if err != nil {
return nil, serializer.NewError(serializer.CodeParamErr, "Failed to parse request", err)
}
credential, err := webAuthn.CreateCredential(&authnUser{u: u, hasher: dep.HashIDEncoder()}, sessionData, pcc)
if err != nil {
return nil, serializer.NewError(serializer.CodeWebAuthnCredentialError, "Failed to finish registration", err)
}
client := dep.UAParser().Parse(s.UA)
name := util.Replace(map[string]string{
"{os}": client.Os.Family,
"{browser}": client.UserAgent.Family,
}, s.Name)
passkey, err := dep.UserClient().AddPasskey(c, u.ID, name, credential)
if err != nil {
return nil, serializer.NewError(serializer.CodeDBError, "Failed to add passkey", err)
}
res := BuildPasskey(passkey)
return &res, nil
}
type (
DeletePasskeyService struct {
ID string `form:"id" binding:"required"`
}
DeletePasskeyParameterCtx struct{}
)
func (s *DeletePasskeyService) DeletePasskey(c *gin.Context) error {
dep := dependency.FromContext(c)
u := inventory.UserFromContext(c)
userClient := dep.UserClient()
existingKeys, err := userClient.ListPasskeys(c, u.ID)
if err != nil {
return serializer.NewError(serializer.CodeDBError, "Failed to list passkeys", err)
}
var existing *ent.Passkey
for _, key := range existingKeys {
if key.CredentialID == s.ID {
existing = key
break
}
}
if existing == nil {
return serializer.NewError(serializer.CodeNotFound, "Passkey not found", nil)
}
if err := userClient.RemovePasskey(c, u.ID, s.ID); err != nil {
return serializer.NewError(serializer.CodeDBError, "Failed to delete passkey", err)
}
return nil
}
+148
View File
@@ -0,0 +1,148 @@
package user
import (
"context"
"errors"
"strings"
"time"
"github.com/cloudreve/Cloudreve/v4/application/dependency"
"github.com/cloudreve/Cloudreve/v4/ent"
"github.com/cloudreve/Cloudreve/v4/ent/user"
"github.com/cloudreve/Cloudreve/v4/inventory"
"github.com/cloudreve/Cloudreve/v4/pkg/auth"
"github.com/cloudreve/Cloudreve/v4/pkg/cluster/routes"
"github.com/cloudreve/Cloudreve/v4/pkg/email"
"github.com/cloudreve/Cloudreve/v4/pkg/hashid"
"github.com/cloudreve/Cloudreve/v4/pkg/serializer"
"github.com/cloudreve/Cloudreve/v4/pkg/util"
"github.com/gin-gonic/gin"
)
// RegisterParameterCtx define key fore UserRegisterService
type RegisterParameterCtx struct{}
// UserRegisterService 管理用户注册的服务
type UserRegisterService struct {
UserName string `form:"email" json:"email" binding:"required,email"`
Password string `form:"password" json:"password" binding:"required,min=6,max=128"`
Language string `form:"language" json:"language"`
}
// Register 新用户注册
func (service *UserRegisterService) Register(c *gin.Context) serializer.Response {
dep := dependency.FromContext(c)
settings := dep.SettingProvider()
isEmailRequired := settings.EmailActivationEnabled(c)
args := &inventory.NewUserArgs{
Email: strings.ToLower(service.UserName),
PlainPassword: service.Password,
Status: user.StatusActive,
GroupID: settings.DefaultGroup(c),
Language: service.Language,
}
if isEmailRequired {
args.Status = user.StatusInactive
}
userClient := dep.UserClient()
uc, tx, _, err := inventory.WithTx(c, userClient)
if err != nil {
return serializer.DBErr(c, "Failed to start transaction", err)
}
expectedUser, err := uc.Create(c, args)
if expectedUser != nil {
util.WithValue(c, inventory.UserCtx{}, expectedUser)
}
if err != nil {
_ = inventory.Rollback(tx)
if errors.Is(err, inventory.ErrUserEmailExisted) {
return serializer.ErrWithDetails(c, serializer.CodeEmailExisted, "Email already in use", err)
}
if errors.Is(err, inventory.ErrInactiveUserExisted) {
if err := sendActivationEmail(c, dep, expectedUser); err != nil {
return serializer.ErrWithDetails(c, serializer.CodeNotSet, "", err)
}
return serializer.ErrWithDetails(c, serializer.CodeEmailSent, "User is not activated, activation email has been resent", nil)
}
return serializer.DBErr(c, "Failed to insert user row", err)
}
if err := inventory.Commit(tx); err != nil {
return serializer.DBErr(c, "Failed to commit user row", err)
}
if isEmailRequired {
if err := sendActivationEmail(c, dep, expectedUser); err != nil {
return serializer.ErrWithDetails(c, serializer.CodeNotSet, "", err)
}
return serializer.Response{Code: serializer.CodeNotFullySuccess}
}
return serializer.Response{Data: BuildUser(expectedUser, dep.HashIDEncoder())}
}
func sendActivationEmail(ctx context.Context, dep dependency.Dep, newUser *ent.User) error {
base := dep.SettingProvider().SiteURL(ctx)
userID := hashid.EncodeUserID(dep.HashIDEncoder(), newUser.ID)
ttl := time.Now().Add(time.Duration(24) * time.Hour)
activateURL, err := auth.SignURI(ctx, dep.GeneralAuth(), routes.MasterUserActivateAPIUrl(base, userID).String(), &ttl)
if err != nil {
return serializer.NewError(serializer.CodeEncryptError, "Failed to sign the activation link", err)
}
// 取得签名
credential := activateURL.Query().Get("sign")
// 生成对用户访问的激活地址
finalURL := routes.MasterUserActivateUrl(base)
queries := finalURL.Query()
queries.Add("id", userID)
queries.Add("sign", credential)
finalURL.RawQuery = queries.Encode()
// 返送激活邮件
title, body, err := email.NewActivationEmail(ctx, dep.SettingProvider(), newUser, finalURL.String())
if err != nil {
return serializer.NewError(serializer.CodeFailedSendEmail, "Failed to send activation email", err)
}
if err := dep.EmailClient(ctx).Send(ctx, newUser.Email, title, body); err != nil {
return serializer.NewError(serializer.CodeFailedSendEmail, "Failed to send activation email", err)
}
return nil
}
// ActivateUser 激活用户
func ActivateUser(c *gin.Context) serializer.Response {
uid := hashid.FromContext(c)
dep := dependency.FromContext(c)
userClient := dep.UserClient()
// 查找待激活用户
inactiveUser, err := userClient.GetByID(c, uid)
if err != nil {
return serializer.ErrWithDetails(c, serializer.CodeUserNotFound, "User not fount", err)
}
// 检查状态
if inactiveUser.Status != user.StatusInactive {
return serializer.ErrWithDetails(c, serializer.CodeUserCannotActivate, "This user cannot be activated", nil)
}
// 激活用户
activeUser, err := userClient.SetStatus(c, inactiveUser, user.StatusActive)
if err != nil {
return serializer.DBErr(c, "Failed to update user", err)
}
util.WithValue(c, inventory.UserCtx{}, activeUser)
return serializer.Response{Data: BuildUser(activeUser, dep.HashIDEncoder())}
}
+261
View File
@@ -0,0 +1,261 @@
package user
import (
"context"
"fmt"
"time"
"github.com/cloudreve/Cloudreve/v4/application/dependency"
"github.com/cloudreve/Cloudreve/v4/ent"
"github.com/cloudreve/Cloudreve/v4/ent/user"
"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/hashid"
"github.com/go-webauthn/webauthn/protocol"
"github.com/go-webauthn/webauthn/webauthn"
"github.com/samber/lo"
"github.com/ua-parser/uap-go/uaparser"
)
type PreparePasskeyLoginResponse struct {
Options *protocol.CredentialAssertion `json:"options"`
SessionID string `json:"session_id"`
}
type UserSettings struct {
VersionRetentionEnabled bool `json:"version_retention_enabled"`
VersionRetentionExt []string `json:"version_retention_ext,omitempty"`
VersionRetentionMax int `json:"version_retention_max,omitempty"`
Paswordless bool `json:"passwordless"`
TwoFAEnabled bool `json:"two_fa_enabled"`
Passkeys []Passkey `json:"passkeys,omitempty"`
DisableViewSync bool `json:"disable_view_sync"`
ShareLinksInProfile string `json:"share_links_in_profile"`
OAuthGrants []OauthGrant `json:"oauth_grants,omitempty"`
}
func BuildUserSettings(u *ent.User, passkeys []*ent.Passkey, parser *uaparser.Parser, grants []*ent.OAuthGrant) *UserSettings {
return &UserSettings{
VersionRetentionEnabled: u.Settings.VersionRetention,
VersionRetentionExt: u.Settings.VersionRetentionExt,
VersionRetentionMax: u.Settings.VersionRetentionMax,
TwoFAEnabled: u.TwoFactorSecret != "",
Paswordless: u.Password == "",
Passkeys: lo.Map(passkeys, func(item *ent.Passkey, index int) Passkey {
return BuildPasskey(item)
}),
DisableViewSync: u.Settings.DisableViewSync,
ShareLinksInProfile: string(u.Settings.ShareLinksInProfile),
OAuthGrants: lo.Map(grants, func(item *ent.OAuthGrant, index int) OauthGrant {
return BuildOauthGrant(item)
}),
}
}
type Passkey struct {
ID string `json:"id"`
Name string `json:"name"`
UsedAt *time.Time `json:"used_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
func BuildPasskey(passkey *ent.Passkey) Passkey {
return Passkey{
ID: passkey.CredentialID,
Name: passkey.Name,
UsedAt: passkey.UsedAt,
CreatedAt: passkey.CreatedAt,
}
}
// Node option for handling workflows.
type Node struct {
ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
Capabilities *boolset.BooleanSet `json:"capabilities"`
}
// BuildNodes serialize a list of nodes.
func BuildNodes(nodes []*ent.Node, idEncoder hashid.Encoder) []*Node {
res := make([]*Node, 0, len(nodes))
for _, v := range nodes {
res = append(res, BuildNode(v, idEncoder))
}
return res
}
// BuildNode serialize a node.
func BuildNode(node *ent.Node, idEncoder hashid.Encoder) *Node {
return &Node{
ID: hashid.EncodeNodeID(idEncoder, node.ID),
Name: node.Name,
Type: string(node.Type),
Capabilities: node.Capabilities,
}
}
// BuiltinLoginResponse response for a successful login for builtin auth provider.
type BuiltinLoginResponse struct {
User User `json:"user"`
Token auth.Token `json:"token"`
}
// User 用户序列化器
type User struct {
ID string `json:"id"`
Email string `json:"email,omitempty"`
Nickname string `json:"nickname"`
Status user.Status `json:"status,omitempty"`
Avatar string `json:"avatar,omitempty"`
CreatedAt time.Time `json:"created_at"`
PreferredTheme string `json:"preferred_theme,omitempty"`
Anonymous bool `json:"anonymous,omitempty"`
Group *Group `json:"group,omitempty"`
Pined []types.PinedFile `json:"pined,omitempty"`
Language string `json:"language,omitempty"`
DisableViewSync bool `json:"disable_view_sync,omitempty"`
ShareLinksInProfile types.ShareLinksInProfileLevel `json:"share_links_in_profile,omitempty"`
}
type Group struct {
ID string `json:"id"`
Name string `json:"name"`
Permission *boolset.BooleanSet `json:"permission,omitempty"`
DirectLinkBatchSize int `json:"direct_link_batch_size,omitempty"`
TrashRetention int `json:"trash_retention,omitempty"`
}
type storage struct {
Used uint64 `json:"used"`
Free uint64 `json:"free"`
Total uint64 `json:"total"`
}
// WebAuthnCredentials 外部验证器凭证
type WebAuthnCredentials struct {
ID []byte `json:"id"`
FingerPrint string `json:"fingerprint"`
}
type PrepareLoginResponse struct {
WebAuthnEnabled bool `json:"webauthn_enabled"`
PasswordEnabled bool `json:"password_enabled"`
}
// BuildWebAuthnList 构建设置页面凭证列表
func BuildWebAuthnList(credentials []webauthn.Credential) []WebAuthnCredentials {
res := make([]WebAuthnCredentials, 0, len(credentials))
for _, v := range credentials {
credential := WebAuthnCredentials{
ID: v.ID,
FingerPrint: fmt.Sprintf("% X", v.Authenticator.AAGUID),
}
res = append(res, credential)
}
return res
}
// BuildUser 序列化用户
func BuildUser(user *ent.User, idEncoder hashid.Encoder) User {
return User{
ID: hashid.EncodeUserID(idEncoder, user.ID),
Email: user.Email,
Nickname: user.Nick,
Status: user.Status,
Avatar: user.Avatar,
CreatedAt: user.CreatedAt,
PreferredTheme: user.Settings.PreferredTheme,
Anonymous: user.ID == 0,
Group: BuildGroup(user.Edges.Group, idEncoder),
Pined: user.Settings.Pined,
Language: user.Settings.Language,
DisableViewSync: user.Settings.DisableViewSync,
ShareLinksInProfile: user.Settings.ShareLinksInProfile,
}
}
type OauthGrant struct {
ClientID string `json:"client_id"`
ClientName string `json:"client_name"`
ClientLogo string `json:"client_logo"`
Scopes []string `json:"scopes"`
LastUsedAt *time.Time `json:"last_used_at"`
}
func BuildOauthGrant(grant *ent.OAuthGrant) OauthGrant {
res := OauthGrant{
Scopes: grant.Scopes,
LastUsedAt: grant.LastUsedAt,
}
if grant.Edges.Client != nil {
res.ClientID = grant.Edges.Client.GUID
res.ClientName = grant.Edges.Client.Name
if grant.Edges.Client.Props != nil {
res.ClientLogo = grant.Edges.Client.Props.Icon
}
}
return res
}
func BuildGroup(group *ent.Group, idEncoder hashid.Encoder) *Group {
if group == nil {
return nil
}
return &Group{
ID: hashid.EncodeGroupID(idEncoder, group.ID),
Name: group.Name,
Permission: group.Permissions,
DirectLinkBatchSize: group.Settings.SourceBatchSize,
TrashRetention: group.Settings.TrashRetention,
}
}
const sensitiveTag = "redacted"
const (
RedactLevelAnonymous = iota
RedactLevelUser
)
// BuildUserRedacted Serialize a user without sensitive information.
// The email is only exposed when the requester is a signed-in user (level >=
// RedactLevelUser) and the `expose_user_email` setting is enabled.
func BuildUserRedacted(ctx context.Context, u *ent.User, level int, idEncoder hashid.Encoder) User {
userRaw := BuildUser(u, idEncoder)
user := User{
ID: userRaw.ID,
Nickname: userRaw.Nickname,
Avatar: userRaw.Avatar,
CreatedAt: userRaw.CreatedAt,
ShareLinksInProfile: userRaw.ShareLinksInProfile,
}
if userRaw.Group != nil {
user.Group = RedactedGroup(userRaw.Group)
}
if level == RedactLevelUser && dependency.FromContext(ctx).SettingProvider().ExposeUserEmail(ctx) {
user.Email = userRaw.Email
}
return user
}
// BuildGroupRedacted Serialize a group without sensitive information.
func RedactedGroup(g *Group) *Group {
if g == nil {
return nil
}
return &Group{
ID: g.ID,
Name: g.Name,
}
}
+337
View File
@@ -0,0 +1,337 @@
package user
import (
"context"
"crypto/md5"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"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/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/thumb"
"github.com/cloudreve/Cloudreve/v4/pkg/util"
"github.com/gin-gonic/gin"
"github.com/pquerna/otp/totp"
)
const (
twoFaEnableSessionKey = "2fa_init_"
)
// Init2FA 初始化二步验证
func Init2FA(c *gin.Context) (string, error) {
dep := dependency.FromContext(c)
user := inventory.UserFromContext(c)
key, err := totp.Generate(totp.GenerateOpts{
Issuer: "Cloudreve",
AccountName: user.Email,
})
if err != nil {
return "", serializer.NewError(serializer.CodeInternalSetting, "Failed to generate TOTP secret", err)
}
if err := dep.KV().Set(fmt.Sprintf("%s%d", twoFaEnableSessionKey, user.ID), key.Secret(), 600); err != nil {
return "", serializer.NewError(serializer.CodeInternalSetting, "Failed to store TOTP session", err)
}
return key.Secret(), nil
}
type (
// AvatarService Service to get avatar
GetAvatarService struct {
NoCache bool `form:"nocache"`
}
GetAvatarServiceParamsCtx struct{}
)
const (
GravatarAvatar = "gravatar"
FileAvatar = "file"
)
// Get 获取用户头像
func (service *GetAvatarService) Get(c *gin.Context) error {
dep := dependency.FromContext(c)
settings := dep.SettingProvider()
// 查找目标用户
uid := hashid.FromContext(c)
userClient := dep.UserClient()
user, err := userClient.GetByID(c, uid)
if err != nil {
return serializer.NewError(serializer.CodeUserNotFound, "", err)
}
if !service.NoCache {
c.Header("Cache-Control", fmt.Sprintf("public, max-age=%d", settings.PublicResourceMaxAge(c)))
}
// 未设定头像时,返回404错误
if user.Avatar == "" {
c.Status(404)
return nil
}
avatarSettings := settings.Avatar(c)
// Gravatar 头像重定向
if user.Avatar == GravatarAvatar {
gravatarRoot, err := url.Parse(avatarSettings.Gravatar)
if err != nil {
return serializer.NewError(serializer.CodeInternalSetting, "Failed to parse Gravatar server", err)
}
email_lowered := strings.ToLower(user.Email)
has := md5.Sum([]byte(email_lowered))
avatar, _ := url.Parse(fmt.Sprintf("/avatar/%x?d=mm&s=200", has))
c.Redirect(http.StatusFound, gravatarRoot.ResolveReference(avatar).String())
return nil
}
// 本地文件头像
if user.Avatar == FileAvatar {
avatarRoot := util.DataPath(avatarSettings.Path)
avatar, err := os.Open(filepath.Join(avatarRoot, fmt.Sprintf("avatar_%d.png", user.ID)))
if err != nil {
dep.Logger().Warning("Failed to open avatar file", err)
c.Status(404)
}
defer avatar.Close()
http.ServeContent(c.Writer, c.Request, "avatar.png", user.UpdatedAt, avatar)
return nil
}
c.Status(404)
return nil
}
// Settings 获取用户设定
func GetUserSettings(c *gin.Context) (*UserSettings, error) {
dep := dependency.FromContext(c)
u := inventory.UserFromContext(c)
userClient := dep.UserClient()
passkeys, err := userClient.ListPasskeys(c, u.ID)
if err != nil {
return nil, serializer.NewError(serializer.CodeDBError, "Failed to get user passkey", err)
}
ctx := context.WithValue(c, inventory.LoadOAuthGrantClient{}, true)
grants, err := dep.OAuthClientClient().GetGrantsByUserID(ctx, u.ID)
if err != nil {
return nil, serializer.NewError(serializer.CodeDBError, "Failed to get user OAuth grants", err)
}
return BuildUserSettings(u, passkeys, dep.UAParser(), grants), nil
// 用户组有效期
//return serializer.Response{
// Data: map[string]interface{}{
// "uid": user.ID,
// "qq": user.OpenID != "",
// "homepage": !user.OptionsSerialized.ProfileOff,
// "two_factor": user.TwoFactor != "",
// "prefer_theme": user.OptionsSerialized.PreferredTheme,
// "themes": model.GetSettingByName("themes"),
// "group_expires": groupExpires,
// "authn": serializer.BuildWebAuthnList(user.WebAuthnCredentials()),
// },
//}
}
func UpdateUserAvatar(c *gin.Context) error {
dep := dependency.FromContext(c)
u := inventory.UserFromContext(c)
settings := dep.SettingProvider()
avatarSettings := settings.AvatarProcess(c)
if c.Request.ContentLength == -1 || c.Request.ContentLength > avatarSettings.MaxFileSize {
request.BlackHole(c.Request.Body)
return serializer.NewError(serializer.CodeFileTooLarge, "", nil)
}
if c.Request.ContentLength == 0 {
// Use Gravatar for empty body
if _, err := dep.UserClient().UpdateAvatar(c, u, GravatarAvatar); err != nil {
return serializer.NewError(serializer.CodeDBError, "Failed to update user avatar", err)
}
return nil
}
return updateAvatarFile(c, u, c.GetHeader("Content-Type"), c.Request.Body, avatarSettings)
}
func updateAvatarFile(ctx context.Context, u *ent.User, contentType string, file io.Reader, avatarSettings *setting.AvatarProcess) error {
dep := dependency.FromContext(ctx)
// Detect ext from content type
ext := "png"
switch contentType {
case "image/jpeg", "image/jpg":
ext = "jpg"
case "image/gif":
ext = "gif"
}
avatar, err := thumb.NewThumbFromFile(file, ext)
if err != nil {
return serializer.NewError(serializer.CodeParamErr, "Invalid image", err)
}
// Resize and save avatar
avatar.CreateAvatar(avatarSettings.MaxWidth)
avatarRoot := util.DataPath(avatarSettings.Path)
f, err := util.CreatNestedFile(filepath.Join(avatarRoot, fmt.Sprintf("avatar_%d.png", u.ID)))
if err != nil {
return serializer.NewError(serializer.CodeIOFailed, "Failed to create avatar file", err)
}
defer f.Close()
if err := avatar.Save(f, &setting.ThumbEncode{
Quality: 100,
Format: "png",
}); err != nil {
return serializer.NewError(serializer.CodeIOFailed, "Failed to save avatar file", err)
}
if _, err := dep.UserClient().UpdateAvatar(ctx, u, FileAvatar); err != nil {
return serializer.NewError(serializer.CodeDBError, "Failed to update user avatar", err)
}
return nil
}
type (
PatchUserSetting struct {
Nick *string `json:"nick" binding:"omitempty,min=1,max=255"`
Language *string `json:"language" binding:"omitempty,min=1,max=255"`
PreferredTheme *string `json:"preferred_theme" binding:"omitempty,hexcolor|rgb|rgba|hsl"`
VersionRetentionEnabled *bool `json:"version_retention_enabled" binding:"omitempty"`
VersionRetentionExt *[]string `json:"version_retention_ext" binding:"omitempty"`
VersionRetentionMax *int `json:"version_retention_max" binding:"omitempty,min=0"`
CurrentPassword *string `json:"current_password" binding:"omitempty,min=4,max=128"`
NewPassword *string `json:"new_password" binding:"omitempty,min=6,max=128"`
TwoFAEnabled *bool `json:"two_fa_enabled" binding:"omitempty"`
TwoFACode *string `json:"two_fa_code" binding:"omitempty"`
DisableViewSync *bool `json:"disable_view_sync" binding:"omitempty"`
ShareLinksInProfile *string `json:"share_links_in_profile" binding:"omitempty"`
}
PatchUserSettingParamsCtx struct{}
)
func (s *PatchUserSetting) Patch(c *gin.Context) error {
dep := dependency.FromContext(c)
u := inventory.UserFromContext(c)
userClient := dep.UserClient()
saveSetting := false
if s.Nick != nil {
if _, err := userClient.UpdateNickname(c, u, *s.Nick); err != nil {
return serializer.NewError(serializer.CodeDBError, "Failed to update user nick", err)
}
}
if s.Language != nil {
u.Settings.Language = *s.Language
saveSetting = true
}
if s.PreferredTheme != nil {
u.Settings.PreferredTheme = *s.PreferredTheme
saveSetting = true
}
if s.VersionRetentionEnabled != nil {
u.Settings.VersionRetention = *s.VersionRetentionEnabled
saveSetting = true
}
if s.VersionRetentionExt != nil {
u.Settings.VersionRetentionExt = *s.VersionRetentionExt
saveSetting = true
}
if s.VersionRetentionMax != nil {
u.Settings.VersionRetentionMax = *s.VersionRetentionMax
saveSetting = true
}
if s.DisableViewSync != nil {
u.Settings.DisableViewSync = *s.DisableViewSync
saveSetting = true
}
if s.ShareLinksInProfile != nil {
u.Settings.ShareLinksInProfile = types.ShareLinksInProfileLevel(*s.ShareLinksInProfile)
saveSetting = true
}
if s.CurrentPassword != nil && s.NewPassword != nil {
if err := auth.CheckScope(c, types.ScopeUserSecurityInfoWrite); err != nil {
return err
}
if err := inventory.CheckPassword(u, *s.CurrentPassword); err != nil {
return serializer.NewError(serializer.CodeIncorrectPassword, "Incorrect password", err)
}
if _, err := userClient.UpdatePassword(c, u, *s.NewPassword); err != nil {
return serializer.NewError(serializer.CodeDBError, "Failed to update user password", err)
}
}
if s.TwoFAEnabled != nil {
if err := auth.CheckScope(c, types.ScopeUserSecurityInfoWrite); err != nil {
return err
}
if *s.TwoFAEnabled {
kv := dep.KV()
secret, ok := kv.Get(fmt.Sprintf("%s%d", twoFaEnableSessionKey, u.ID))
if !ok {
return serializer.NewError(serializer.CodeInternalSetting, "You have not initiated 2FA session", nil)
}
if !totp.Validate(*s.TwoFACode, secret.(string)) {
return serializer.NewError(serializer.Code2FACodeErr, "Incorrect 2FA code", nil)
}
if _, err := userClient.UpdateTwoFASecret(c, u, secret.(string)); err != nil {
return serializer.NewError(serializer.CodeDBError, "Failed to update user 2FA", err)
}
} else {
if !totp.Validate(*s.TwoFACode, u.TwoFactorSecret) {
return serializer.NewError(serializer.Code2FACodeErr, "Incorrect 2FA code", nil)
}
if _, err := userClient.UpdateTwoFASecret(c, u, ""); err != nil {
return serializer.NewError(serializer.CodeDBError, "Failed to update user 2FA", err)
}
}
}
if saveSetting {
if err := userClient.SaveSettings(c, u); err != nil {
return serializer.NewError(serializer.CodeDBError, "Failed to update user settings", err)
}
}
return nil
}