You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
1127 lines
35 KiB
1127 lines
35 KiB
package dbfs
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"path"
|
|
"path/filepath"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/cloudreve/Cloudreve/v4/application/constants"
|
|
"github.com/cloudreve/Cloudreve/v4/ent"
|
|
"github.com/cloudreve/Cloudreve/v4/ent/storagepolicy"
|
|
"github.com/cloudreve/Cloudreve/v4/inventory"
|
|
"github.com/cloudreve/Cloudreve/v4/inventory/types"
|
|
"github.com/cloudreve/Cloudreve/v4/pkg/activity"
|
|
"github.com/cloudreve/Cloudreve/v4/pkg/cache"
|
|
"github.com/cloudreve/Cloudreve/v4/pkg/filemanager/encrypt"
|
|
"github.com/cloudreve/Cloudreve/v4/pkg/filemanager/eventhub"
|
|
"github.com/cloudreve/Cloudreve/v4/pkg/filemanager/fs"
|
|
"github.com/cloudreve/Cloudreve/v4/pkg/filemanager/lock"
|
|
"github.com/cloudreve/Cloudreve/v4/pkg/hashid"
|
|
"github.com/cloudreve/Cloudreve/v4/pkg/logging"
|
|
"github.com/cloudreve/Cloudreve/v4/pkg/serializer"
|
|
"github.com/cloudreve/Cloudreve/v4/pkg/setting"
|
|
"github.com/gofrs/uuid"
|
|
"github.com/samber/lo"
|
|
"math"
|
|
)
|
|
|
|
const (
|
|
ContextHintHeader = constants.CrHeaderPrefix + "Context-Hint"
|
|
NavigatorStateCachePrefix = "navigator_state_"
|
|
ContextHintTTL = 5 * 60 // 5 minutes
|
|
|
|
folderSummaryCachePrefix = "folder_summary_"
|
|
defaultPageSize = 100
|
|
)
|
|
|
|
type (
|
|
ContextHintCtxKey struct{}
|
|
ByPassOwnerCheckCtxKey struct{}
|
|
// IsDownloadCtxKey marks the request as an explicit file download (as
|
|
// opposed to an inline preview fetch). Navigator hooks consult it.
|
|
IsDownloadCtxKey struct{}
|
|
// PurchaseTicketCtxKey carries a paid-share resume ticket used to
|
|
// restore download access after session loss.
|
|
PurchaseTicketCtxKey struct{}
|
|
// ExpectedSourceIDsCtxKey carries source-file identity preconditions
|
|
// for move/copy/rename: a []int positionally aligned with the source
|
|
// URI list. An entry of 0 disables the check for that position (#3565).
|
|
ExpectedSourceIDsCtxKey struct{}
|
|
// MoveConflictCtxKey carries the on-conflict policy for move/copy:
|
|
// "skip" drops colliding entries, "overwrite" deletes the colliding
|
|
// destination first. Empty keeps the fail-fast default (#3159).
|
|
MoveConflictCtxKey struct{}
|
|
)
|
|
|
|
const (
|
|
MoveConflictSkip = "skip"
|
|
MoveConflictOverwrite = "overwrite"
|
|
)
|
|
|
|
// moveConflictMode returns the configured on-conflict policy.
|
|
func moveConflictMode(ctx context.Context) string {
|
|
mode, _ := ctx.Value(MoveConflictCtxKey{}).(string)
|
|
return mode
|
|
}
|
|
|
|
// WithExpectedSourceIDs records the expected database IDs of the source
|
|
// files so a delayed/retried request cannot silently operate on a new
|
|
// file that reused the same path.
|
|
func WithExpectedSourceIDs(ctx context.Context, ids []int) context.Context {
|
|
return context.WithValue(ctx, ExpectedSourceIDsCtxKey{}, ids)
|
|
}
|
|
|
|
// sourceIDMismatch reports whether the resolved file violates the
|
|
// expected-source precondition at the given position.
|
|
func sourceIDMismatch(ctx context.Context, pos int, actual int) bool {
|
|
expected, ok := ctx.Value(ExpectedSourceIDsCtxKey{}).([]int)
|
|
if !ok || pos >= len(expected) || expected[pos] == 0 {
|
|
return false
|
|
}
|
|
return expected[pos] != actual
|
|
}
|
|
|
|
// writePermitted reports whether the user may mutate file under the given
|
|
// capability. File owners are always permitted; non-owners (e.g. share
|
|
// visitors) require the capability in the file's resolved capability set,
|
|
// which for share file systems is derived from the share's props.
|
|
func (f *DBFS) writePermitted(file *File, capability NavigatorCapability) bool {
|
|
if file.Owner().ID == f.user.ID {
|
|
return true
|
|
}
|
|
caps := file.Capabilities()
|
|
return caps != nil && caps.Enabled(int(capability))
|
|
}
|
|
|
|
// DBFSDependencies groups the collaborators wired into a DBFS instance so the
|
|
// constructor call site names each dependency.
|
|
type DBFSDependencies struct {
|
|
FileClient inventory.FileClient
|
|
ShareClient inventory.ShareClient
|
|
AclClient inventory.AclClient
|
|
VasClient inventory.VasClient
|
|
ActivityClient inventory.ActivityClient
|
|
UserClient inventory.UserClient
|
|
StoragePolicyClient inventory.StoragePolicyClient
|
|
DirectLinkClient inventory.DirectLinkClient
|
|
Logger logging.Logger
|
|
LockSystem lock.LockSystem
|
|
SettingProvider setting.Provider
|
|
Hasher hashid.Encoder
|
|
Cache cache.Driver
|
|
StateKV cache.Driver
|
|
EncryptorFactory encrypt.CryptorFactory
|
|
EventHub eventhub.EventHub
|
|
}
|
|
|
|
func NewDatabaseFS(u *ent.User, deps DBFSDependencies) fs.FileSystem {
|
|
return &DBFS{
|
|
user: u,
|
|
navigators: make(map[string]Navigator),
|
|
fileClient: deps.FileClient,
|
|
shareClient: deps.ShareClient,
|
|
aclClient: deps.AclClient,
|
|
vasClient: deps.VasClient,
|
|
activityClient: deps.ActivityClient,
|
|
l: deps.Logger,
|
|
ls: deps.LockSystem,
|
|
settingClient: deps.SettingProvider,
|
|
storagePolicyClient: deps.StoragePolicyClient,
|
|
hasher: deps.Hasher,
|
|
userClient: deps.UserClient,
|
|
cache: deps.Cache,
|
|
stateKv: deps.StateKV,
|
|
directLinkClient: deps.DirectLinkClient,
|
|
encryptorFactory: deps.EncryptorFactory,
|
|
eventHub: deps.EventHub,
|
|
}
|
|
}
|
|
|
|
type DBFS struct {
|
|
user *ent.User
|
|
navigators map[string]Navigator
|
|
fileClient inventory.FileClient
|
|
userClient inventory.UserClient
|
|
storagePolicyClient inventory.StoragePolicyClient
|
|
shareClient inventory.ShareClient
|
|
aclClient inventory.AclClient
|
|
vasClient inventory.VasClient
|
|
activityClient inventory.ActivityClient
|
|
directLinkClient inventory.DirectLinkClient
|
|
l logging.Logger
|
|
ls lock.LockSystem
|
|
settingClient setting.Provider
|
|
hasher hashid.Encoder
|
|
cache cache.Driver
|
|
stateKv cache.Driver
|
|
mu sync.Mutex
|
|
encryptorFactory encrypt.CryptorFactory
|
|
eventHub eventhub.EventHub
|
|
}
|
|
|
|
func (f *DBFS) Recycle() {
|
|
for _, navigator := range f.navigators {
|
|
navigator.Recycle()
|
|
}
|
|
}
|
|
|
|
func (f *DBFS) GetEntity(ctx context.Context, entityID int) (fs.Entity, error) {
|
|
if entityID == 0 {
|
|
return fs.NewEmptyEntity(f.user), nil
|
|
}
|
|
|
|
files, _, err := f.fileClient.GetEntitiesByIDs(ctx, []int{entityID}, 0)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get entity: %w", err)
|
|
}
|
|
|
|
if len(files) == 0 {
|
|
return nil, fs.ErrEntityNotExist
|
|
}
|
|
|
|
return fs.NewEntity(files[0]), nil
|
|
|
|
}
|
|
|
|
func (f *DBFS) List(ctx context.Context, path *fs.URI, opts ...fs.Option) (fs.File, *fs.ListFileResult, error) {
|
|
o := newDbfsOption()
|
|
for _, opt := range opts {
|
|
o.apply(opt)
|
|
}
|
|
|
|
// Get navigator
|
|
navigator, err := f.getNavigator(ctx, path, NavigatorCapabilityListChildren)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
searchParams := path.SearchParameters()
|
|
isSearching := searchParams != nil
|
|
|
|
parent, err := f.getFileByPath(ctx, navigator, path)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("parent not exist: %w", err)
|
|
}
|
|
|
|
pageSize := 0
|
|
orderDirection := ""
|
|
orderBy := ""
|
|
|
|
view := navigator.GetView(ctx, parent)
|
|
if view != nil {
|
|
pageSize = view.PageSize
|
|
orderDirection = view.OrderDirection
|
|
orderBy = view.Order
|
|
}
|
|
|
|
if o.PageSize > 0 {
|
|
pageSize = o.PageSize
|
|
}
|
|
if o.OrderDirection != "" {
|
|
orderDirection = o.OrderDirection
|
|
}
|
|
if o.OrderBy != "" {
|
|
orderBy = o.OrderBy
|
|
}
|
|
|
|
// Validate pagination args
|
|
props := navigator.Capabilities(isSearching)
|
|
if pageSize > props.MaxPageSize {
|
|
pageSize = props.MaxPageSize
|
|
} else if pageSize == 0 {
|
|
pageSize = defaultPageSize
|
|
}
|
|
|
|
if view != nil {
|
|
view.PageSize = pageSize
|
|
view.OrderDirection = orderDirection
|
|
view.Order = orderBy
|
|
}
|
|
|
|
var hintId *uuid.UUID
|
|
if o.generateContextHint {
|
|
newHintId := uuid.Must(uuid.NewV4())
|
|
hintId = &newHintId
|
|
}
|
|
|
|
if o.loadFilePublicMetadata {
|
|
ctx = context.WithValue(ctx, inventory.LoadFilePublicMetadata{}, true)
|
|
}
|
|
if o.loadFileShareIfOwned && parent != nil && parent.OwnerID() == f.user.ID {
|
|
ctx = context.WithValue(ctx, inventory.LoadFileShare{}, true)
|
|
}
|
|
|
|
var streamCallback func([]*File)
|
|
if o.streamListResponseCallback != nil {
|
|
streamCallback = func(files []*File) {
|
|
o.streamListResponseCallback(parent, lo.Map(files, func(item *File, index int) fs.File {
|
|
return item
|
|
}))
|
|
}
|
|
}
|
|
|
|
children, err := navigator.Children(ctx, parent, &ListArgs{
|
|
Page: &inventory.PaginationArgs{
|
|
Page: o.FsOption.Page,
|
|
PageSize: pageSize,
|
|
OrderBy: orderBy,
|
|
Order: inventory.OrderDirection(orderDirection),
|
|
UseCursorPagination: o.useCursorPagination,
|
|
PageToken: o.pageToken,
|
|
},
|
|
Search: searchParams,
|
|
StreamCallback: streamCallback,
|
|
})
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("failed to get children: %w", err)
|
|
}
|
|
|
|
var storagePolicy *ent.StoragePolicy
|
|
if parent != nil {
|
|
storagePolicy, err = f.getPreferredPolicy(ctx, parent)
|
|
if err != nil {
|
|
f.l.Warning("Failed to get preferred policy: %v", err)
|
|
}
|
|
}
|
|
|
|
return parent, &fs.ListFileResult{
|
|
Files: lo.Map(children.Files, func(item *File, index int) fs.File {
|
|
return item
|
|
}),
|
|
Props: props,
|
|
Pagination: children.Pagination,
|
|
ContextHint: hintId,
|
|
RecursionLimitReached: children.RecursionLimitReached,
|
|
MixedType: children.MixedType,
|
|
SingleFileView: children.SingleFileView,
|
|
Parent: parent,
|
|
StoragePolicy: storagePolicy,
|
|
View: view,
|
|
}, nil
|
|
}
|
|
|
|
func (f *DBFS) Capacity(ctx context.Context, u *ent.User) (*fs.Capacity, error) {
|
|
// First, get user's available storage packs
|
|
var (
|
|
res = &fs.Capacity{}
|
|
)
|
|
|
|
requesterGroup := inventory.EffectiveGroup(u)
|
|
if requesterGroup == nil {
|
|
return nil, serializer.NewError(serializer.CodeDBError, "Failed to get user's group", nil)
|
|
}
|
|
|
|
res.Used = f.user.Storage
|
|
res.Total = requesterGroup.MaxStorage
|
|
|
|
if f.vasClient != nil {
|
|
if bonus, err := f.vasClient.StorageBonus(ctx, u.ID); err == nil {
|
|
res.Total += bonus
|
|
}
|
|
}
|
|
return res, nil
|
|
}
|
|
|
|
func (f *DBFS) CreateEntity(ctx context.Context, file fs.File, policy *ent.StoragePolicy,
|
|
entityType types.EntityType, req *fs.UploadRequest, opts ...fs.Option) (fs.Entity, error) {
|
|
o := newDbfsOption()
|
|
for _, opt := range opts {
|
|
o.apply(opt)
|
|
}
|
|
|
|
// If uploader specified previous latest version ID (etag), we should check if it's still valid.
|
|
if o.previousVersion != "" {
|
|
entityId, err := f.hasher.Decode(o.previousVersion, hashid.EntityID)
|
|
if err != nil {
|
|
return nil, serializer.NewError(serializer.CodeParamErr, "Unknown version ID", err)
|
|
}
|
|
|
|
entities, err := file.(*File).Model.Edges.EntitiesOrErr()
|
|
if err != nil || entities == nil {
|
|
return nil, fmt.Errorf("create entity: previous entities not load")
|
|
}
|
|
|
|
// File is stale during edit if the latest entity is not the same as the one specified by uploader.
|
|
if e := file.PrimaryEntity(); e == nil || e.ID() != entityId {
|
|
return nil, fs.ErrStaleVersion
|
|
}
|
|
}
|
|
|
|
fc, tx, ctx, err := inventory.WithTx(ctx, f.fileClient)
|
|
if err != nil {
|
|
return nil, serializer.NewError(serializer.CodeDBError, "Failed to start transaction", err)
|
|
}
|
|
|
|
fileModel := file.(*File).Model
|
|
if o.removeStaleEntities {
|
|
storageDiff, err := fc.RemoveStaleEntities(ctx, fileModel)
|
|
if err != nil {
|
|
_ = inventory.Rollback(tx)
|
|
return nil, serializer.NewError(serializer.CodeDBError, "Failed to remove stale entities", err)
|
|
}
|
|
|
|
tx.AppendStorageDiff(storageDiff)
|
|
}
|
|
|
|
entity, storageDiff, err := fc.CreateEntity(ctx, fileModel, &inventory.EntityParameters{
|
|
OwnerID: file.(*File).Owner().ID,
|
|
EntityType: entityType,
|
|
StoragePolicyID: policy.ID,
|
|
Source: req.Props.SavePath,
|
|
Size: req.Props.Size,
|
|
UploadSessionID: uuid.FromStringOrNil(o.UploadRequest.Props.UploadSessionID),
|
|
EncryptMetadata: o.encryptMetadata,
|
|
})
|
|
if err != nil {
|
|
_ = inventory.Rollback(tx)
|
|
|
|
return nil, serializer.NewError(serializer.CodeDBError, "Failed to create entity", err)
|
|
}
|
|
tx.AppendStorageDiff(storageDiff)
|
|
|
|
if err := inventory.CommitWithStorageDiff(ctx, tx, f.l, f.userClient); err != nil {
|
|
return nil, serializer.NewError(serializer.CodeDBError, "Failed to commit create change", err)
|
|
}
|
|
|
|
return fs.NewEntity(entity), nil
|
|
}
|
|
|
|
func (f *DBFS) SharedAddressTranslation(ctx context.Context, path *fs.URI, opts ...fs.Option) (fs.File, *fs.URI, error) {
|
|
o := newDbfsOption()
|
|
for _, opt := range opts {
|
|
o.apply(opt)
|
|
}
|
|
|
|
// Get navigator
|
|
navigator, err := f.getNavigator(ctx, path, o.requiredCapabilities...)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
ctx = context.WithValue(ctx, inventory.LoadFilePublicMetadata{}, true)
|
|
if o.loadFileEntities {
|
|
ctx = context.WithValue(ctx, inventory.LoadFileEntity{}, true)
|
|
}
|
|
|
|
uriTranslation := func(target *File, rebase bool) (fs.File, *fs.URI, error) {
|
|
// Translate shared address to real address
|
|
metadata := target.Metadata()
|
|
if metadata == nil {
|
|
if err := f.fileClient.QueryMetadata(ctx, target.Model); err != nil {
|
|
return nil, nil, fmt.Errorf("failed to query metadata: %w", err)
|
|
}
|
|
metadata = target.Metadata()
|
|
}
|
|
redirect, ok := metadata[MetadataSharedRedirect]
|
|
if !ok {
|
|
return nil, nil, fmt.Errorf("missing metadata %s in symbolic folder %s", MetadataSharedRedirect, path)
|
|
}
|
|
|
|
redirectUri, err := fs.NewUriFromString(redirect)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("invalid redirect uri %s in symbolic folder %s", redirect, path)
|
|
}
|
|
newUri := redirectUri
|
|
if rebase {
|
|
newUri = redirectUri.Rebase(path, target.Uri(false))
|
|
}
|
|
return f.SharedAddressTranslation(ctx, newUri, opts...)
|
|
}
|
|
|
|
target, err := f.getFileByPath(ctx, navigator, path)
|
|
if err != nil {
|
|
if errors.Is(err, ErrSymbolicFolderFound) && target.Type() == types.FileTypeFolder {
|
|
return uriTranslation(target, true)
|
|
}
|
|
|
|
if !ent.IsNotFound(err) {
|
|
return nil, nil, fmt.Errorf("failed to get target file: %w", err)
|
|
}
|
|
|
|
// Request URI does not exist, return most recent ancestor
|
|
return target, path, err
|
|
}
|
|
|
|
if target.IsSymbolic() {
|
|
return uriTranslation(target, false)
|
|
}
|
|
|
|
return target, path, nil
|
|
}
|
|
|
|
func (f *DBFS) Get(ctx context.Context, path *fs.URI, opts ...fs.Option) (fs.File, error) {
|
|
o := newDbfsOption()
|
|
for _, opt := range opts {
|
|
o.apply(opt)
|
|
}
|
|
|
|
// Get navigator
|
|
navigator, err := f.getNavigator(ctx, path, o.requiredCapabilities...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if o.loadFilePublicMetadata || o.extendedInfo {
|
|
ctx = context.WithValue(ctx, inventory.LoadFilePublicMetadata{}, true)
|
|
}
|
|
|
|
if o.loadFileEntities || o.extendedInfo || o.loadFolderSummary {
|
|
ctx = context.WithValue(ctx, inventory.LoadFileEntity{}, true)
|
|
}
|
|
|
|
if o.extendedInfo {
|
|
ctx = context.WithValue(ctx, inventory.LoadFileDirectLink{}, true)
|
|
}
|
|
|
|
if o.loadFileShareIfOwned {
|
|
ctx = context.WithValue(ctx, inventory.LoadFileShare{}, true)
|
|
}
|
|
|
|
if o.loadEntityUser {
|
|
ctx = context.WithValue(ctx, inventory.LoadEntityUser{}, true)
|
|
}
|
|
|
|
// Get target file
|
|
target, err := f.getFileByPath(ctx, navigator, path)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get target file: %w", err)
|
|
}
|
|
|
|
if o.notRoot && (target == nil || target.IsRootFolder()) {
|
|
return nil, fs.ErrNotSupportedAction.WithError(fmt.Errorf("cannot operate root file"))
|
|
}
|
|
|
|
if o.extendedInfo && target != nil {
|
|
extendedInfo := &fs.FileExtendedInfo{
|
|
StorageUsed: target.SizeUsed(),
|
|
EntityStoragePolicies: make(map[int]*ent.StoragePolicy),
|
|
}
|
|
|
|
if f.user.ID == target.OwnerID() {
|
|
extendedInfo.DirectLinks = target.Model.Edges.DirectLinks
|
|
}
|
|
|
|
policyID := target.PolicyID()
|
|
if policyID > 0 {
|
|
policy, err := f.storagePolicyClient.GetPolicyByID(ctx, policyID)
|
|
if err == nil {
|
|
extendedInfo.StoragePolicy = policy
|
|
}
|
|
}
|
|
|
|
target.FileExtendedInfo = extendedInfo
|
|
if target.OwnerID() == f.user.ID || inventory.EffectiveGroup(f.user).Permissions.Enabled(int(types.GroupPermissionIsAdmin)) {
|
|
target.FileExtendedInfo.Shares = target.Model.Edges.Shares
|
|
if target.Model.Props != nil {
|
|
target.FileExtendedInfo.View = target.Model.Props.View
|
|
}
|
|
}
|
|
|
|
entities := target.Entities()
|
|
for _, entity := range entities {
|
|
if _, ok := extendedInfo.EntityStoragePolicies[entity.PolicyID()]; !ok {
|
|
policy, err := f.storagePolicyClient.GetPolicyByID(ctx, entity.PolicyID())
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get policy: %w", err)
|
|
}
|
|
|
|
extendedInfo.EntityStoragePolicies[entity.PolicyID()] = policy
|
|
}
|
|
}
|
|
}
|
|
|
|
// Calculate folder summary if requested
|
|
if o.loadFolderSummary && target != nil && target.Type() == types.FileTypeFolder {
|
|
if _, ok := ctx.Value(ByPassOwnerCheckCtxKey{}).(bool); !ok && !f.writePermitted(target, NavigatorCapabilityRenameFile) {
|
|
return nil, fs.ErrOwnerOnly
|
|
}
|
|
|
|
// first, try to load from cache
|
|
summary, ok := f.cache.Get(fmt.Sprintf("%s%d", folderSummaryCachePrefix, target.ID()))
|
|
if ok {
|
|
summaryTyped := summary.(fs.FolderSummary)
|
|
target.FileFolderSummary = &summaryTyped
|
|
} else {
|
|
// cache miss, walk the folder to get the summary
|
|
newSummary := &fs.FolderSummary{Completed: true}
|
|
if inventory.EffectiveGroup(f.user) == nil {
|
|
return nil, fmt.Errorf("user group not loaded")
|
|
}
|
|
limit := max(inventory.EffectiveGroup(f.user).Settings.MaxWalkedFiles, 1)
|
|
|
|
// disable load metadata to speed up
|
|
ctxWalk := context.WithValue(ctx, inventory.LoadFilePublicMetadata{}, false)
|
|
if err := navigator.Walk(ctxWalk, []*File{target}, limit, math.MaxInt, func(files []*File, l int) error {
|
|
for _, file := range files {
|
|
if file.ID() == target.ID() {
|
|
continue
|
|
}
|
|
if file.Type() == types.FileTypeFile {
|
|
newSummary.Files++
|
|
} else {
|
|
newSummary.Folders++
|
|
}
|
|
|
|
newSummary.Size += file.SizeUsed()
|
|
}
|
|
return nil
|
|
}); err != nil {
|
|
if !errors.Is(err, ErrFileCountLimitedReached) {
|
|
return nil, fmt.Errorf("failed to walk: %w", err)
|
|
}
|
|
|
|
newSummary.Completed = false
|
|
}
|
|
|
|
// cache the summary
|
|
newSummary.CalculatedAt = time.Now()
|
|
f.cache.Set(fmt.Sprintf("%s%d", folderSummaryCachePrefix, target.ID()), *newSummary, f.settingClient.FolderPropsCacheTTL(ctx))
|
|
target.FileFolderSummary = newSummary
|
|
}
|
|
}
|
|
|
|
if target == nil {
|
|
return nil, fmt.Errorf("cannot get root file with nil root")
|
|
}
|
|
|
|
return target, nil
|
|
}
|
|
|
|
func (f *DBFS) CheckCapability(ctx context.Context, uri *fs.URI, opts ...fs.Option) error {
|
|
o := newDbfsOption()
|
|
for _, opt := range opts {
|
|
o.apply(opt)
|
|
}
|
|
|
|
// Get navigator
|
|
_, err := f.getNavigator(ctx, uri, o.requiredCapabilities...)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (f *DBFS) Walk(ctx context.Context, path *fs.URI, depth int, walk fs.WalkFunc, opts ...fs.Option) error {
|
|
o := newDbfsOption()
|
|
for _, opt := range opts {
|
|
o.apply(opt)
|
|
}
|
|
|
|
if o.loadFilePublicMetadata {
|
|
ctx = context.WithValue(ctx, inventory.LoadFilePublicMetadata{}, true)
|
|
}
|
|
|
|
if o.loadFileEntities {
|
|
ctx = context.WithValue(ctx, inventory.LoadFileEntity{}, true)
|
|
}
|
|
|
|
// Get navigator
|
|
navigator, err := f.getNavigator(ctx, path, o.requiredCapabilities...)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
target, err := f.getFileByPath(ctx, navigator, path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Require Read permission
|
|
if _, ok := ctx.Value(ByPassOwnerCheckCtxKey{}).(bool); !ok && target.OwnerID() != f.user.ID {
|
|
return fs.ErrOwnerOnly
|
|
}
|
|
|
|
// Walk
|
|
if inventory.EffectiveGroup(f.user) == nil {
|
|
return fmt.Errorf("user group not loaded")
|
|
}
|
|
limit := max(inventory.EffectiveGroup(f.user).Settings.MaxWalkedFiles, 1)
|
|
|
|
if err := navigator.Walk(ctx, []*File{target}, limit, depth, func(files []*File, l int) error {
|
|
for _, file := range files {
|
|
if err := walk(file, l); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}); err != nil {
|
|
return fmt.Errorf("failed to walk: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (f *DBFS) ExecuteNavigatorHooks(ctx context.Context, hookType fs.HookType, file fs.File) error {
|
|
navigator, err := f.getNavigator(ctx, file.Uri(false))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if dbfsFile, ok := file.(*File); ok {
|
|
return navigator.ExecuteHook(ctx, hookType, dbfsFile)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// createFile creates a file with given name and type under given parent folder
|
|
func (f *DBFS) createFile(ctx context.Context, parent *File, name string, fileType types.FileType, o *dbfsOption) (*File, error) {
|
|
createFileArgs := &inventory.CreateFileParameters{
|
|
FileType: fileType,
|
|
Name: name,
|
|
MetadataPrivateMask: make(map[string]bool),
|
|
Metadata: make(map[string]string),
|
|
IsSymbolic: o.isSymbolicLink,
|
|
}
|
|
|
|
if o.Metadata != nil {
|
|
for k, v := range o.Metadata {
|
|
createFileArgs.Metadata[k] = v
|
|
}
|
|
}
|
|
|
|
if o.preferredStoragePolicy != nil {
|
|
createFileArgs.StoragePolicyID = o.preferredStoragePolicy.ID
|
|
} else {
|
|
// get preferred storage policy
|
|
policy, err := f.getPreferredPolicy(ctx, parent)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
createFileArgs.StoragePolicyID = policy.ID
|
|
}
|
|
|
|
if o.UploadRequest != nil {
|
|
createFileArgs.EntityParameters = &inventory.EntityParameters{
|
|
EntityType: types.EntityTypeVersion,
|
|
Source: o.UploadRequest.Props.SavePath,
|
|
Size: o.UploadRequest.Props.Size,
|
|
ModifiedAt: o.UploadRequest.Props.LastModified,
|
|
UploadSessionID: uuid.FromStringOrNil(o.UploadRequest.Props.UploadSessionID),
|
|
Importing: o.UploadRequest.ImportFrom != nil,
|
|
EncryptMetadata: o.encryptMetadata,
|
|
Hash: o.UploadRequest.Props.Hash,
|
|
}
|
|
}
|
|
if o.existingEntity != nil {
|
|
if createFileArgs.EntityParameters == nil {
|
|
createFileArgs.EntityParameters = &inventory.EntityParameters{}
|
|
}
|
|
createFileArgs.EntityParameters.LinkedEntityID = o.existingEntity.ID
|
|
}
|
|
|
|
// Start transaction to create files
|
|
fc, tx, ctx, err := inventory.WithTx(ctx, f.fileClient)
|
|
if err != nil {
|
|
return nil, serializer.NewError(serializer.CodeDBError, "Failed to start transaction", err)
|
|
}
|
|
|
|
file, entity, storageDiff, err := fc.CreateFile(ctx, parent.Model, createFileArgs)
|
|
if err != nil {
|
|
_ = inventory.Rollback(tx)
|
|
if ent.IsConstraintError(err) {
|
|
return nil, fs.ErrFileExisted.WithError(err)
|
|
}
|
|
|
|
return nil, serializer.NewError(serializer.CodeDBError, "Failed to create file", err)
|
|
}
|
|
|
|
tx.AppendStorageDiff(storageDiff)
|
|
if err := inventory.CommitWithStorageDiff(ctx, tx, f.l, f.userClient); err != nil {
|
|
return nil, serializer.NewError(serializer.CodeDBError, "Failed to commit create change", err)
|
|
}
|
|
|
|
file.SetEntities([]*ent.Entity{entity})
|
|
newFile := newFile(parent, file)
|
|
f.emitFileCreated(ctx, newFile)
|
|
f.record(ctx, types.EventFileCreate, activity.File(newFile.ID()), activity.Extra(map[string]any{"uri": newFile.Uri(false).String()}))
|
|
return newFile, nil
|
|
}
|
|
|
|
func (f *DBFS) generateEncryptMetadata(ctx context.Context, uploadRequest *fs.UploadRequest, policy *ent.StoragePolicy) (*types.EncryptMetadata, error) {
|
|
relayEnabled := policy.Settings != nil && policy.Settings.Relay
|
|
if (len(uploadRequest.Props.EncryptionSupported) > 0 && uploadRequest.Props.EncryptionSupported[0] == types.CipherAES256CTR) || relayEnabled {
|
|
encryptor, err := f.encryptorFactory(types.CipherAES256CTR)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get encryptor: %w", err)
|
|
}
|
|
|
|
return encryptor.GenerateMetadata(ctx)
|
|
}
|
|
|
|
return nil, nil
|
|
}
|
|
|
|
// getPreferredPolicy tries to get the preferred storage policy for the given file.
|
|
func (f *DBFS) getPreferredPolicy(ctx context.Context, file *File) (*ent.StoragePolicy, error) {
|
|
return f.getPreferredPolicyForSize(ctx, file, 0)
|
|
}
|
|
|
|
// getPreferredPolicyForSize is getPreferredPolicy with the incoming upload
|
|
// size, so weighted-capacity selection can exclude policies without headroom.
|
|
func (f *DBFS) getPreferredPolicyForSize(ctx context.Context, file *File, size int64) (*ent.StoragePolicy, error) {
|
|
owner := file.Owner()
|
|
ownerGroup := inventory.EffectiveGroup(owner)
|
|
if ownerGroup == nil {
|
|
return nil, fmt.Errorf("owner group not loaded")
|
|
}
|
|
|
|
sc, _ := inventory.InheritTx(ctx, f.storagePolicyClient)
|
|
allowed, err := sc.ListByGroups(ctx, inventory.GroupsOf(owner))
|
|
if err != nil {
|
|
return nil, serializer.NewError(serializer.CodeDBError, "Failed to get available storage policies", err)
|
|
}
|
|
if len(allowed) == 0 {
|
|
return nil, serializer.NewError(serializer.CodeDBError, "No active storage policy available for the group", nil)
|
|
}
|
|
|
|
candidate := f.pickPolicy(ctx, file, owner, allowed, size)
|
|
|
|
// A load_balance policy resolves to one of its weighted children before
|
|
// any storage driver sees it.
|
|
if candidate.Type == types.PolicyTypeLoadBalance {
|
|
child, err := sc.ResolveLoadBalance(ctx, candidate)
|
|
if err != nil {
|
|
return nil, serializer.NewError(serializer.CodeDBError, "Failed to resolve load-balanced storage policy", err)
|
|
}
|
|
candidate = child
|
|
}
|
|
|
|
return candidate, nil
|
|
}
|
|
|
|
// pickPolicy chooses among the group's allowed policies: the nearest ancestor
|
|
// directory carrying a preferred-policy marker wins; next the owner's own
|
|
// preferred_policy setting (applied only in their own tree); with the group's
|
|
// weighted_policies flag on, the allowed policy with the most free capacity
|
|
// that fits the file; finally the group default, or the first allowed policy
|
|
// when none is configured.
|
|
func (f *DBFS) pickPolicy(ctx context.Context, file *File, owner *ent.User, allowed []*ent.StoragePolicy, size int64) *ent.StoragePolicy {
|
|
inAllowed := func(id int) *ent.StoragePolicy {
|
|
for _, p := range allowed {
|
|
if p.ID == id {
|
|
return p
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// The nearest ancestor directory with a preference wins; if its choice is
|
|
// no longer usable, the preference is ignored rather than inherited from
|
|
// a further ancestor. Ancestor metadata may not be eager-loaded, so each
|
|
// level is loaded lazily until a marker is found.
|
|
for _, ancestor := range file.AncestorsChain() {
|
|
if _, err := ancestor.Model.Edges.MetadataOrErr(); err != nil {
|
|
if err := f.fileClient.QueryMetadata(ctx, ancestor.Model); err != nil {
|
|
continue
|
|
}
|
|
}
|
|
raw := ancestor.Metadata()[MetadataPreferredPolicy]
|
|
if raw == "" {
|
|
continue
|
|
}
|
|
if id, err := f.hasher.Decode(raw, hashid.PolicyID); err == nil {
|
|
if p := inAllowed(id); p != nil {
|
|
return p
|
|
}
|
|
}
|
|
break
|
|
}
|
|
|
|
if f.user.ID == owner.ID && owner.Settings != nil && owner.Settings.PreferredPolicy > 0 {
|
|
if p := inAllowed(owner.Settings.PreferredPolicy); p != nil {
|
|
return p
|
|
}
|
|
}
|
|
|
|
if ownerGroup := inventory.EffectiveGroup(owner); ownerGroup != nil && ownerGroup.Settings != nil && ownerGroup.Settings.WeightedPolicies {
|
|
if p := f.pickByFreeCapacity(ctx, allowed, size); p != nil {
|
|
return p
|
|
}
|
|
}
|
|
|
|
if p := inAllowed(inventory.EffectiveGroup(owner).StoragePolicyID); p != nil {
|
|
return p
|
|
}
|
|
return allowed[0]
|
|
}
|
|
|
|
// pickByFreeCapacity returns the allowed policy with the most remaining
|
|
// MaxTotalSize headroom that still fits size. Uncapped and suspended
|
|
// policies are not weighed; nil is returned when nothing qualifies.
|
|
func (f *DBFS) pickByFreeCapacity(ctx context.Context, allowed []*ent.StoragePolicy, size int64) *ent.StoragePolicy {
|
|
var best *ent.StoragePolicy
|
|
bestFree := int64(-1)
|
|
for _, p := range allowed {
|
|
if p.Settings == nil || p.Settings.MaxTotalSize <= 0 || p.Status == storagepolicy.StatusSuspended {
|
|
continue
|
|
}
|
|
_, used, err := f.fileClient.CountEntityByStoragePolicyID(ctx, p.ID)
|
|
if err != nil {
|
|
f.l.Warning("Failed to weigh storage policy %d usage: %s", p.ID, err)
|
|
continue
|
|
}
|
|
free := p.Settings.MaxTotalSize - int64(used)
|
|
if free >= size && free > bestFree {
|
|
best, bestFree = p, free
|
|
}
|
|
}
|
|
return best
|
|
}
|
|
|
|
func (f *DBFS) getFileByPath(ctx context.Context, navigator Navigator, path *fs.URI) (*File, error) {
|
|
file, err := navigator.To(ctx, path)
|
|
if err != nil && errors.Is(err, ErrFsNotInitialized) {
|
|
// Initialize file system for user if root folder does not exist.
|
|
uid := path.ID(hashid.EncodeUserID(f.hasher, f.user.ID))
|
|
uidInt, err := f.hasher.Decode(uid, hashid.UserID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to decode user ID: %w", err)
|
|
}
|
|
|
|
if err := f.initFs(ctx, uidInt); err != nil {
|
|
return nil, fmt.Errorf("failed to initialize file system: %w", err)
|
|
}
|
|
return navigator.To(ctx, path)
|
|
}
|
|
|
|
return file, err
|
|
}
|
|
|
|
// initFs initializes the file system for the user.
|
|
func (f *DBFS) initFs(ctx context.Context, uid int) error {
|
|
f.l.Info("Initialize database file system for user %q", f.user.Email)
|
|
root, err := f.fileClient.CreateFolder(ctx, nil,
|
|
&inventory.CreateFolderParameters{
|
|
Owner: uid,
|
|
Name: inventory.RootFolderName,
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create root folder: %w", err)
|
|
}
|
|
|
|
f.seedDefaultShares(ctx, uid, root)
|
|
|
|
return nil
|
|
}
|
|
|
|
// seedDefaultShares materializes share shortcuts configured in the
|
|
// `default_shares` setting into the root of a newly created file system.
|
|
// Invalid or expired shares are skipped without failing initialization.
|
|
func (f *DBFS) seedDefaultShares(ctx context.Context, uid int, root *ent.File) {
|
|
shareIDs := f.settingClient.DefaultShares(ctx)
|
|
if len(shareIDs) == 0 {
|
|
return
|
|
}
|
|
|
|
shareCtx := context.WithValue(ctx, inventory.LoadShareFile{}, true)
|
|
shareCtx = context.WithValue(shareCtx, inventory.LoadShareUser{}, true)
|
|
|
|
// Symbolic entries carry no entities, but the storage_policy_files
|
|
// column is non-nullable; bind to the new user's group policy.
|
|
policyID := 0
|
|
userCtx := context.WithValue(ctx, inventory.LoadUserGroup{}, true)
|
|
if owner, err := f.userClient.GetByID(userCtx, uid); err == nil {
|
|
if group := owner.Edges.Group; group != nil {
|
|
if policy, err := f.storagePolicyClient.GetByGroup(ctx, group); err == nil && policy != nil {
|
|
policyID = policy.ID
|
|
}
|
|
}
|
|
if group := inventory.EffectiveGroup(owner); group != nil && group.Settings != nil && len(group.Settings.DefaultPinned) > 0 {
|
|
shareIDs = lo.Union(shareIDs, group.Settings.DefaultPinned)
|
|
}
|
|
}
|
|
|
|
for _, sid := range shareIDs {
|
|
s, err := f.shareClient.GetByID(shareCtx, sid)
|
|
if err != nil || s == nil || s.Edges.File == nil {
|
|
f.l.Warning("[DBFS] Skip invalid default share %d: %v", sid, err)
|
|
continue
|
|
}
|
|
if err := inventory.IsValidShare(s); err != nil {
|
|
f.l.Warning("[DBFS] Skip default share %d: %v", sid, err)
|
|
continue
|
|
}
|
|
|
|
metadata := map[string]string{
|
|
MetadataSharedRedirect: fs.NewShareUri(hashid.EncodeShareID(f.hasher, s.ID), ""),
|
|
}
|
|
if s.Edges.User != nil {
|
|
metadata[MetadataSharedOwner] = hashid.EncodeUserID(f.hasher, s.Edges.User.ID)
|
|
}
|
|
|
|
if s.Edges.File.Type == int(types.FileTypeFolder) {
|
|
_, err = f.fileClient.CreateFolder(ctx, root, &inventory.CreateFolderParameters{
|
|
Owner: uid,
|
|
Name: s.Edges.File.Name,
|
|
IsSymbolic: true,
|
|
Metadata: metadata,
|
|
})
|
|
} else {
|
|
_, _, _, err = f.fileClient.CreateFile(ctx, root, &inventory.CreateFileParameters{
|
|
FileType: types.FileTypeFile,
|
|
Name: s.Edges.File.Name,
|
|
IsSymbolic: true,
|
|
StoragePolicyID: policyID,
|
|
Metadata: metadata,
|
|
})
|
|
}
|
|
if err != nil {
|
|
f.l.Warning("[DBFS] Failed to seed default share %d: %v", sid, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (f *DBFS) getNavigator(ctx context.Context, path *fs.URI, requiredCapabilities ...NavigatorCapability) (Navigator, error) {
|
|
pathFs := path.FileSystem()
|
|
config := f.settingClient.DBFS(ctx)
|
|
navigatorId := f.navigatorId(path)
|
|
var (
|
|
res Navigator
|
|
)
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
if navigator, ok := f.navigators[navigatorId]; ok {
|
|
res = navigator
|
|
} else {
|
|
var n Navigator
|
|
switch pathFs {
|
|
case constants.FileSystemMy:
|
|
n = NewMyNavigator(f.user, f.fileClient, f.userClient, f.l, config, f.hasher)
|
|
case constants.FileSystemShare:
|
|
n = NewShareNavigator(f.user, f.fileClient, f.shareClient, f.aclClient, f.vasClient, f.l, config, f.hasher)
|
|
case constants.FileSystemTrash:
|
|
n = NewTrashNavigator(f.user, f.fileClient, f.l, config, f.hasher)
|
|
case constants.FileSystemSharedWithMe:
|
|
n = NewSharedWithMeNavigator(f.user, f.fileClient, f.aclClient, f.l, config, f.hasher)
|
|
default:
|
|
return nil, fmt.Errorf("unknown file system %q", pathFs)
|
|
}
|
|
|
|
// retrieve state if context hint is provided
|
|
if stateID, ok := ctx.Value(ContextHintCtxKey{}).(uuid.UUID); ok && stateID != uuid.Nil {
|
|
cacheKey := NavigatorStateCachePrefix + stateID.String() + "_" + navigatorId
|
|
if stateRaw, ok := f.stateKv.Get(cacheKey); ok {
|
|
if err := n.RestoreState(stateRaw.(State)); err != nil {
|
|
f.l.Warning("Failed to restore state for navigator %q: %s", navigatorId, err)
|
|
} else {
|
|
f.l.Info("Navigator %q restored state (%q) successfully", navigatorId, stateID)
|
|
}
|
|
} else {
|
|
// State expire, refresh it
|
|
n.PersistState(f.stateKv, cacheKey)
|
|
}
|
|
}
|
|
|
|
f.navigators[navigatorId] = n
|
|
res = n
|
|
}
|
|
|
|
// Check fs capabilities
|
|
capabilities := res.Capabilities(false).Capability
|
|
for _, capability := range requiredCapabilities {
|
|
if !capabilities.Enabled(int(capability)) {
|
|
return nil, fs.ErrNotSupportedAction.WithError(fmt.Errorf("action %v is not supported under current fs", capability))
|
|
}
|
|
}
|
|
|
|
// Wrap with the private-space gate. The check must re-evaluate on every
|
|
// call, so the wrapper is applied per request rather than cached.
|
|
return &vaultNavigator{Navigator: res, fs: f, roots: map[int]int{}}, nil
|
|
}
|
|
|
|
func (f *DBFS) navigatorId(path *fs.URI) string {
|
|
uidHashed := hashid.EncodeUserID(f.hasher, f.user.ID)
|
|
switch path.FileSystem() {
|
|
case constants.FileSystemMy:
|
|
return fmt.Sprintf("%s/%s/%d", constants.FileSystemMy, path.ID(uidHashed), f.user.ID)
|
|
case constants.FileSystemShare:
|
|
return fmt.Sprintf("%s/%s/%d", constants.FileSystemShare, path.ID(uidHashed), f.user.ID)
|
|
case constants.FileSystemTrash:
|
|
return fmt.Sprintf("%s/%s", constants.FileSystemTrash, path.ID(uidHashed))
|
|
case constants.FileSystemSharedWithMe:
|
|
// Per-subtree navigators keep the resolved ACL capability set from
|
|
// going stale when a request touches several shared roots.
|
|
scope := ""
|
|
if elements := path.Elements(); len(elements) > 0 {
|
|
scope = "/" + elements[0]
|
|
}
|
|
return fmt.Sprintf("%s%s/%d", constants.FileSystemSharedWithMe, scope, f.user.ID)
|
|
default:
|
|
return fmt.Sprintf("%s/%s/%d", path.FileSystem(), path.ID(uidHashed), f.user.ID)
|
|
}
|
|
}
|
|
|
|
// generateSavePath generates the physical save path for the upload request.
|
|
func generateSavePath(policy *ent.StoragePolicy, req *fs.UploadRequest, user *ent.User) string {
|
|
currentTime := time.Now()
|
|
dynamicReplace := func(rule string, pathAvailable bool) string {
|
|
return fs.ReplaceMagicVar(rule, fs.MagicVarProps{
|
|
FsSeparator: fs.Separator,
|
|
PathAvailable: pathAvailable,
|
|
Time: currentTime,
|
|
UserID: user.ID,
|
|
OriginName: req.Props.Uri.Name(),
|
|
OriginPath: req.Props.Uri.Dir(),
|
|
})
|
|
}
|
|
|
|
dirRule := policy.DirNameRule
|
|
dirRule = filepath.ToSlash(dirRule)
|
|
dirRule = dynamicReplace(dirRule, true)
|
|
|
|
nameRule := policy.FileNameRule
|
|
nameRule = dynamicReplace(nameRule, false)
|
|
|
|
return path.Join(path.Clean(dirRule), nameRule)
|
|
}
|
|
|
|
func canMoveOrCopyTo(src, dst *fs.URI, isCopy bool) bool {
|
|
if isCopy {
|
|
if src.FileSystem() == constants.FileSystemShare {
|
|
// Copy within the same share is allowed; write permission on the
|
|
// destination is enforced separately.
|
|
return dst.FileSystem() == constants.FileSystemShare && src.ID("") != "" && src.ID("") == dst.ID("")
|
|
}
|
|
return src.FileSystem() == dst.FileSystem() && src.FileSystem() == constants.FileSystemMy
|
|
} else {
|
|
switch src.FileSystem() {
|
|
case constants.FileSystemMy:
|
|
return dst.FileSystem() == constants.FileSystemMy || dst.FileSystem() == constants.FileSystemTrash
|
|
case constants.FileSystemTrash:
|
|
return dst.FileSystem() == constants.FileSystemMy
|
|
case constants.FileSystemShare:
|
|
// Move within the same share is allowed; cross-share moves are not.
|
|
return dst.FileSystem() == constants.FileSystemShare && src.ID("") != "" && src.ID("") == dst.ID("")
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
func allAncestors(targets []*File) []*ent.File {
|
|
return lo.Map(
|
|
lo.UniqBy(
|
|
lo.FlatMap(targets, func(value *File, index int) []*File {
|
|
return value.Ancestors()
|
|
}),
|
|
func(item *File) int {
|
|
return item.ID()
|
|
},
|
|
),
|
|
func(item *File, index int) *ent.File {
|
|
return item.Model
|
|
},
|
|
)
|
|
}
|
|
|
|
func WithBypassOwnerCheck(ctx context.Context) context.Context {
|
|
return context.WithValue(ctx, ByPassOwnerCheckCtxKey{}, true)
|
|
}
|