Merge pull request #195 from Dvorinka/feat/private-space
feat: private space (vault) with password-gated file access (#109)pull/3587/head
commit
43141d93c8
File diff suppressed because one or more lines are too long
@ -0,0 +1,303 @@
|
|||||||
|
package dbfs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/cloudreve/Cloudreve/v4/ent"
|
||||||
|
"github.com/cloudreve/Cloudreve/v4/pkg/filemanager/fs"
|
||||||
|
"github.com/samber/lo"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// MetadataVault marks a root-level folder as the owner's private space.
|
||||||
|
// Contents are not flagged individually; membership is decided by the
|
||||||
|
// ancestor chain so it stays correct across moves and copies.
|
||||||
|
MetadataVault = MetadataSysPrefix + "vault"
|
||||||
|
|
||||||
|
// VaultUnlockCachePrefix prefixes the cache key recording an unlocked
|
||||||
|
// private space for a user ID.
|
||||||
|
VaultUnlockCachePrefix = "vault_unlocked_"
|
||||||
|
|
||||||
|
// VaultUnlockTTL is the unlock validity in seconds.
|
||||||
|
VaultUnlockTTL = 1800
|
||||||
|
|
||||||
|
// maxVaultAncestorDepth bounds lazy ancestor resolution for files whose
|
||||||
|
// parent chain is not materialized (flattened/search listings).
|
||||||
|
maxVaultAncestorDepth = 64
|
||||||
|
)
|
||||||
|
|
||||||
|
// vaultRootID returns the private-space root folder ID owned by ownerID, or 0.
|
||||||
|
func (f *DBFS) vaultRootID(ctx context.Context, ownerID int) int {
|
||||||
|
if f.user != nil && ownerID == f.user.ID {
|
||||||
|
return f.user.VaultFolder
|
||||||
|
}
|
||||||
|
if f.userClient == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
u, err := f.userClient.GetByID(ctx, ownerID)
|
||||||
|
if err != nil || u == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return u.VaultFolder
|
||||||
|
}
|
||||||
|
|
||||||
|
// vaultUnlocked reports whether the current user's vault session is active.
|
||||||
|
func (f *DBFS) vaultUnlocked() bool {
|
||||||
|
if f.cache == nil || f.user == nil || f.user.VaultFolder <= 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
_, ok := f.cache.Get(VaultUnlockCachePrefix + strconv.Itoa(f.user.ID))
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// chainInVault checks the materialized ancestor chain only.
|
||||||
|
func chainInVault(f *File, rootID int) bool {
|
||||||
|
if rootID <= 0 || f.Model.ID == rootID {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for p := f.Parent; p != nil && p.Model != nil; p = p.Parent {
|
||||||
|
if p.Model.ID == rootID {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// fileInVault reports whether the file sits inside the vault rooted at
|
||||||
|
// rootID. When the parent chain is not materialized it walks file_children
|
||||||
|
// links upward in bounded steps.
|
||||||
|
func (f *DBFS) fileInVault(ctx context.Context, file *File, rootID int) (bool, error) {
|
||||||
|
if rootID <= 0 || file == nil || file.Model == nil || file.Model.ID == rootID {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
if file.Parent != nil || file.Model.FileChildren == 0 {
|
||||||
|
return chainInVault(file, rootID), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
pid := file.Model.FileChildren
|
||||||
|
for depth := 0; pid > 0 && depth < maxVaultAncestorDepth; depth++ {
|
||||||
|
if pid == rootID {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
parents, _, err := f.fileClient.GetByIDs(ctx, []int{pid}, 0)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if len(parents) == 0 {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
pid = parents[0].FileChildren
|
||||||
|
}
|
||||||
|
return pid == rootID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// requireVaultUnlocked allows the operation only for the vault owner with an
|
||||||
|
// active unlock session; non-owners get a not-found error so vault contents
|
||||||
|
// are not revealed.
|
||||||
|
func (f *DBFS) requireVaultUnlocked(file *File) error {
|
||||||
|
if f.user.ID == file.Model.OwnerID {
|
||||||
|
if f.vaultUnlocked() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return ErrVaultLocked
|
||||||
|
}
|
||||||
|
return fs.ErrPathNotExist
|
||||||
|
}
|
||||||
|
|
||||||
|
// requireVaultAccess gates access to the file itself: only files strictly
|
||||||
|
// inside the vault are gated, the vault root stays resolvable so it can serve
|
||||||
|
// as the unlock entry point.
|
||||||
|
func (f *DBFS) requireVaultAccess(ctx context.Context, file *File) error {
|
||||||
|
if file == nil || file.Model == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
inside, err := f.fileInVault(ctx, file, f.vaultRootID(ctx, file.Model.OwnerID))
|
||||||
|
if err != nil || !inside {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return f.requireVaultUnlocked(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
// requireVaultEntry gates navigation into a folder: the vault root and
|
||||||
|
// everything inside it require an unlocked vault.
|
||||||
|
func (f *DBFS) requireVaultEntry(ctx context.Context, file *File) error {
|
||||||
|
if file == nil || file.Model == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if file.Model.ID == f.vaultRootID(ctx, file.Model.OwnerID) {
|
||||||
|
return f.requireVaultUnlocked(file)
|
||||||
|
}
|
||||||
|
return f.requireVaultAccess(ctx, file)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsInPrivateSpace implements fs.FileSystem.
|
||||||
|
func (f *DBFS) IsInPrivateSpace(ctx context.Context, file fs.File) (bool, error) {
|
||||||
|
dbfsFile, ok := file.(*File)
|
||||||
|
if !ok || dbfsFile == nil || dbfsFile.Model == nil {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return f.fileInVault(ctx, dbfsFile, f.vaultRootID(ctx, dbfsFile.Model.OwnerID))
|
||||||
|
}
|
||||||
|
|
||||||
|
// vaultNavigator wraps any Navigator and gates access to files located inside
|
||||||
|
// the file owner's private space. The vault is a regular folder at the user's
|
||||||
|
// root; a file is "in vault" when its ancestor chain contains the vault
|
||||||
|
// folder ID.
|
||||||
|
type vaultNavigator struct {
|
||||||
|
Navigator
|
||||||
|
fs *DBFS
|
||||||
|
roots map[int]int // ownerID -> vault folder ID, resolved lazily per request
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *vaultNavigator) vaultRootID(ctx context.Context, ownerID int) int {
|
||||||
|
if id, ok := v.roots[ownerID]; ok {
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
id := v.fs.vaultRootID(ctx, ownerID)
|
||||||
|
v.roots[ownerID] = id
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *vaultNavigator) To(ctx context.Context, path *fs.URI) (*File, error) {
|
||||||
|
file, err := v.Navigator.To(ctx, path)
|
||||||
|
// To returns the deepest existing ancestor together with NotFound for
|
||||||
|
// missing targets — preserve that partial result, but still gate it: the
|
||||||
|
// ancestor itself may sit inside a locked vault.
|
||||||
|
if file != nil && file.Model != nil {
|
||||||
|
if verr := v.fs.requireVaultAccess(ctx, file); verr != nil {
|
||||||
|
return nil, verr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return file, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *vaultNavigator) Children(ctx context.Context, parent *File, args *ListArgs) (*ListResult, error) {
|
||||||
|
if parent != nil && parent.Model != nil {
|
||||||
|
if parent.Model.ID == v.vaultRootID(ctx, parent.Model.OwnerID) {
|
||||||
|
if err := v.fs.requireVaultUnlocked(parent); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
} else if err := v.fs.requireVaultAccess(ctx, parent); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
res, err := v.Navigator.Children(ctx, parent, args)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
filtered, err := v.filterVaulted(ctx, res.Files)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
res.Files = filtered
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// filterVaulted removes vaulted files the current user may not see from a
|
||||||
|
// listing, preserving order. Files without a materialized parent chain are
|
||||||
|
// resolved in batched levels to keep the query count bounded.
|
||||||
|
func (v *vaultNavigator) filterVaulted(ctx context.Context, files []*File) ([]*File, error) {
|
||||||
|
drop := make(map[*File]bool)
|
||||||
|
pending := make(map[*File]int) // file -> ancestor ID currently under examination
|
||||||
|
rootOf := make(map[*File]int)
|
||||||
|
|
||||||
|
for _, f := range files {
|
||||||
|
if f == nil || f.Model == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rootID := v.vaultRootID(ctx, f.Model.OwnerID)
|
||||||
|
rootOf[f] = rootID
|
||||||
|
if rootID <= 0 || f.Model.ID == rootID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if f.Parent != nil || f.Model.FileChildren == 0 {
|
||||||
|
drop[f] = chainInVault(f, rootID) && !v.accessible(f)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
pending[f] = f.Model.FileChildren
|
||||||
|
}
|
||||||
|
|
||||||
|
for depth := 0; len(pending) > 0 && depth < maxVaultAncestorDepth; depth++ {
|
||||||
|
// Fast path: pending ancestor equal to the file's vault root resolves
|
||||||
|
// without a query.
|
||||||
|
ids := make(map[int]bool)
|
||||||
|
for f, pid := range pending {
|
||||||
|
if pid == rootOf[f] {
|
||||||
|
drop[f] = !v.accessible(f)
|
||||||
|
delete(pending, f)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ids[pid] = true
|
||||||
|
}
|
||||||
|
if len(pending) == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
parents, err := v.getAllByIDs(ctx, lo.Keys(ids))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for f, pid := range pending {
|
||||||
|
p, ok := parents[pid]
|
||||||
|
if !ok || p.FileChildren == 0 {
|
||||||
|
delete(pending, f)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
pending[f] = p.FileChildren
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Anything still unresolved past the depth cap is dropped fail-closed.
|
||||||
|
for f := range pending {
|
||||||
|
drop[f] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
res := make([]*File, 0, len(files))
|
||||||
|
for _, f := range files {
|
||||||
|
if !drop[f] {
|
||||||
|
res = append(res, f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// accessible reports whether the current user may access a vaulted file.
|
||||||
|
func (v *vaultNavigator) accessible(f *File) bool {
|
||||||
|
return v.fs.user.ID == f.Model.OwnerID && v.fs.vaultUnlocked()
|
||||||
|
}
|
||||||
|
|
||||||
|
// getAllByIDs fetches all given IDs, following GetByIDs pagination.
|
||||||
|
func (v *vaultNavigator) getAllByIDs(ctx context.Context, ids []int) (map[int]*ent.File, error) {
|
||||||
|
res := make(map[int]*ent.File, len(ids))
|
||||||
|
for page := 0; page >= 0; {
|
||||||
|
files, next, err := v.fs.fileClient.GetByIDs(ctx, ids, page)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, fm := range files {
|
||||||
|
res[fm.ID] = fm
|
||||||
|
}
|
||||||
|
page = next
|
||||||
|
}
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *vaultNavigator) Walk(ctx context.Context, levelFiles []*File, limit, depth int, fn WalkFunc) error {
|
||||||
|
for _, f := range levelFiles {
|
||||||
|
if f == nil || f.Model == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := v.fs.requireVaultEntry(ctx, f); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return v.Navigator.Walk(ctx, levelFiles, limit, depth, fn)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *vaultNavigator) ExecuteHook(ctx context.Context, hookType fs.HookType, file *File) error {
|
||||||
|
if err := v.fs.requireVaultAccess(ctx, file); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return v.Navigator.ExecuteHook(ctx, hookType, file)
|
||||||
|
}
|
||||||
@ -0,0 +1,212 @@
|
|||||||
|
package dbfs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"strconv"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/cloudreve/Cloudreve/v4/ent"
|
||||||
|
"github.com/cloudreve/Cloudreve/v4/ent/enttest"
|
||||||
|
"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/cache"
|
||||||
|
"github.com/cloudreve/Cloudreve/v4/pkg/conf"
|
||||||
|
"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/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// vaultFixture builds a DBFS for an owner with a private space containing one
|
||||||
|
// file, plus a normal sibling file for contrast.
|
||||||
|
func vaultFixture(t *testing.T, client *ent.Client) (*ent.User, *ent.File, *ent.File, *DBFS, *cache.MemoStore) {
|
||||||
|
t.Helper()
|
||||||
|
ctx := context.Background()
|
||||||
|
l := logging.NewConsoleLogger(logging.LevelError)
|
||||||
|
hasher, err := hashid.New("vault-test-salt")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
p := client.StoragePolicy.Create().SetName("local").SetType("local").
|
||||||
|
SetStatus("active").SetSettings(&types.PolicySetting{}).SaveX(ctx)
|
||||||
|
group := client.Group.Create().SetName("g").SetPermissions(&boolset.BooleanSet{}).
|
||||||
|
SetMaxStorage(0).SetStoragePolicies(p).SaveX(ctx)
|
||||||
|
u := client.User.Create().SetEmail("v@example.com").SetNick("v").SetGroup(group).SaveX(ctx)
|
||||||
|
u.SetGroup(group)
|
||||||
|
root := client.File.Create().SetName(inventory.RootFolderName).
|
||||||
|
SetType(int(types.FileTypeFolder)).SetOwner(u).SaveX(ctx)
|
||||||
|
|
||||||
|
vault := client.File.Create().SetName("Private space").
|
||||||
|
SetType(int(types.FileTypeFolder)).SetOwner(u).SetParent(root).SaveX(ctx)
|
||||||
|
client.Metadata.Create().SetName(MetadataVault).SetValue("1").
|
||||||
|
SetFile(vault).SetIsPublic(true).SaveX(ctx)
|
||||||
|
|
||||||
|
secret := client.File.Create().SetName("secret.txt").
|
||||||
|
SetType(int(types.FileTypeFile)).SetOwner(u).SetParent(vault).SaveX(ctx)
|
||||||
|
client.File.Create().SetName("normal.txt").
|
||||||
|
SetType(int(types.FileTypeFile)).SetOwner(u).SetParent(root).SaveX(ctx)
|
||||||
|
|
||||||
|
u = client.User.UpdateOne(u).SetVaultFolder(vault.ID).SaveX(ctx)
|
||||||
|
u.SetGroup(group)
|
||||||
|
|
||||||
|
kv := cache.NewMemoStore("", l)
|
||||||
|
f := &DBFS{
|
||||||
|
user: u,
|
||||||
|
navigators: make(map[string]Navigator),
|
||||||
|
fileClient: inventory.NewFileClient(client, conf.SQLiteDB, hasher),
|
||||||
|
userClient: inventory.NewUserClient(client),
|
||||||
|
storagePolicyClient: inventory.NewStoragePolicyClient(client, nil),
|
||||||
|
settingClient: dedupSettingProvider{scope: "off"},
|
||||||
|
hasher: hasher,
|
||||||
|
l: l,
|
||||||
|
ls: lock.NewMemLS(hasher, l),
|
||||||
|
cache: kv,
|
||||||
|
eventHub: stubEventHub{},
|
||||||
|
}
|
||||||
|
return u, vault, secret, f, kv
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVaultLockedAccess(t *testing.T) {
|
||||||
|
client := enttest.Open(t, "sqlite3", "file:"+t.Name()+"?mode=memory&cache=shared")
|
||||||
|
t.Cleanup(func() { require.NoError(t, client.Close()) })
|
||||||
|
_, _, _, f, _ := vaultFixture(t, client)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Resolving a file inside the locked vault fails.
|
||||||
|
uri, err := fs.NewUriFromString("cloudreve://my/Private%20space/secret.txt")
|
||||||
|
require.NoError(t, err)
|
||||||
|
_, err = f.Get(ctx, uri)
|
||||||
|
require.Error(t, err)
|
||||||
|
var appErr serializer.AppError
|
||||||
|
require.True(t, errors.As(err, &appErr))
|
||||||
|
require.Equal(t, serializer.CodeVaultLocked, appErr.ErrCode())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVaultRootResolvableWhenLocked(t *testing.T) {
|
||||||
|
client := enttest.Open(t, "sqlite3", "file:"+t.Name()+"?mode=memory&cache=shared")
|
||||||
|
t.Cleanup(func() { require.NoError(t, client.Close()) })
|
||||||
|
_, vault, _, f, _ := vaultFixture(t, client)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// The vault root itself resolves so it can serve as the unlock entry.
|
||||||
|
uri, err := fs.NewUriFromString("cloudreve://my/Private%20space")
|
||||||
|
require.NoError(t, err)
|
||||||
|
got, err := f.Get(ctx, uri)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, vault.ID, got.ID())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVaultChildrenGated(t *testing.T) {
|
||||||
|
client := enttest.Open(t, "sqlite3", "file:"+t.Name()+"?mode=memory&cache=shared")
|
||||||
|
t.Cleanup(func() { require.NoError(t, client.Close()) })
|
||||||
|
_, _, _, f, kv := vaultFixture(t, client)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
uri, err := fs.NewUriFromString("cloudreve://my/Private%20space")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Locked: listing vault contents is rejected.
|
||||||
|
_, _, err = f.List(ctx, uri)
|
||||||
|
require.Error(t, err)
|
||||||
|
var appErr serializer.AppError
|
||||||
|
require.True(t, errors.As(err, &appErr))
|
||||||
|
require.Equal(t, serializer.CodeVaultLocked, appErr.ErrCode())
|
||||||
|
|
||||||
|
// Unlocked: contents are returned.
|
||||||
|
require.NoError(t, kv.Set(VaultUnlockCachePrefix+strconv.Itoa(f.user.ID), 1, VaultUnlockTTL))
|
||||||
|
_, res, err := f.List(ctx, uri)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, res.Files, 1)
|
||||||
|
require.Equal(t, "secret.txt", res.Files[0].Name())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVaultSearchFiltered(t *testing.T) {
|
||||||
|
client := enttest.Open(t, "sqlite3", "file:"+t.Name()+"?mode=memory&cache=shared")
|
||||||
|
t.Cleanup(func() { require.NoError(t, client.Close()) })
|
||||||
|
_, _, _, f, kv := vaultFixture(t, client)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Root-level search: vaulted files are hidden while locked.
|
||||||
|
uri, err := fs.NewUriFromString("cloudreve://my/?name=txt")
|
||||||
|
require.NoError(t, err)
|
||||||
|
_, res, err := f.List(ctx, uri)
|
||||||
|
require.NoError(t, err)
|
||||||
|
names := make([]string, 0, len(res.Files))
|
||||||
|
for _, fi := range res.Files {
|
||||||
|
names = append(names, fi.Name())
|
||||||
|
}
|
||||||
|
require.Contains(t, names, "normal.txt")
|
||||||
|
require.NotContains(t, names, "secret.txt")
|
||||||
|
|
||||||
|
// Unlocked: vaulted files appear in results.
|
||||||
|
require.NoError(t, kv.Set(VaultUnlockCachePrefix+strconv.Itoa(f.user.ID), 1, VaultUnlockTTL))
|
||||||
|
_, res, err = f.List(ctx, uri)
|
||||||
|
require.NoError(t, err)
|
||||||
|
names = names[:0]
|
||||||
|
for _, fi := range res.Files {
|
||||||
|
names = append(names, fi.Name())
|
||||||
|
}
|
||||||
|
require.Contains(t, names, "secret.txt")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVaultNonOwnerDenied(t *testing.T) {
|
||||||
|
client := enttest.Open(t, "sqlite3", "file:"+t.Name()+"?mode=memory&cache=shared")
|
||||||
|
t.Cleanup(func() { require.NoError(t, client.Close()) })
|
||||||
|
owner, vault, secret, f, kv := vaultFixture(t, client)
|
||||||
|
ctx := context.Background()
|
||||||
|
require.NoError(t, kv.Set(VaultUnlockCachePrefix+strconv.Itoa(owner.ID), 1, VaultUnlockTTL))
|
||||||
|
|
||||||
|
// Even with the owner's session unlocked, a different user never passes
|
||||||
|
// the vault gate; the response pretends the path does not exist.
|
||||||
|
other := client.User.Create().SetEmail("o@example.com").SetNick("o").
|
||||||
|
SetGroup(owner.Edges.Group).SaveX(ctx)
|
||||||
|
otherFs := &DBFS{user: other, cache: kv}
|
||||||
|
|
||||||
|
secretFile := newFile(nil, secret)
|
||||||
|
err := otherFs.requireVaultUnlocked(secretFile)
|
||||||
|
require.Error(t, err)
|
||||||
|
var appErr serializer.AppError
|
||||||
|
require.True(t, errors.As(err, &appErr))
|
||||||
|
require.Equal(t, serializer.CodeParentNotExist, appErr.ErrCode())
|
||||||
|
|
||||||
|
// Owner with unlock session passes.
|
||||||
|
require.NoError(t, f.requireVaultUnlocked(secretFile))
|
||||||
|
// Owner without unlock session gets the dedicated locked code.
|
||||||
|
kv.Delete(VaultUnlockCachePrefix, strconv.Itoa(owner.ID))
|
||||||
|
err = f.requireVaultUnlocked(secretFile)
|
||||||
|
require.True(t, errors.As(err, &appErr))
|
||||||
|
require.Equal(t, serializer.CodeVaultLocked, appErr.ErrCode())
|
||||||
|
|
||||||
|
_ = vault
|
||||||
|
_ = ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVaultShareCreationBlocked(t *testing.T) {
|
||||||
|
client := enttest.Open(t, "sqlite3", "file:"+t.Name()+"?mode=memory&cache=shared")
|
||||||
|
t.Cleanup(func() { require.NoError(t, client.Close()) })
|
||||||
|
_, _, _, f, kv := vaultFixture(t, client)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Even while unlocked, vaulted files cannot be shared.
|
||||||
|
require.NoError(t, kv.Set(VaultUnlockCachePrefix+strconv.Itoa(f.user.ID), 1, VaultUnlockTTL))
|
||||||
|
uri, err := fs.NewUriFromString("cloudreve://my/Private%20space/secret.txt")
|
||||||
|
require.NoError(t, err)
|
||||||
|
file, err := f.Get(ctx, uri)
|
||||||
|
require.NoError(t, err)
|
||||||
|
inside, err := f.IsInPrivateSpace(ctx, file)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, inside)
|
||||||
|
|
||||||
|
// Normal files are not affected.
|
||||||
|
require.Equal(t, "secret.txt", file.Name())
|
||||||
|
normalUri, err := fs.NewUriFromString("cloudreve://my/normal.txt")
|
||||||
|
require.NoError(t, err)
|
||||||
|
normalFile, err := f.Get(ctx, normalUri)
|
||||||
|
require.NoError(t, err)
|
||||||
|
inside, err = f.IsInPrivateSpace(ctx, normalFile)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.False(t, inside)
|
||||||
|
}
|
||||||
@ -0,0 +1,144 @@
|
|||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"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/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/filemanager/manager"
|
||||||
|
"github.com/cloudreve/Cloudreve/v4/pkg/serializer"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// VaultFolderName is the default name of the private-space root folder.
|
||||||
|
VaultFolderName = "Private space"
|
||||||
|
)
|
||||||
|
|
||||||
|
type (
|
||||||
|
// VaultSetupParameterCtx marks the private-space setup route.
|
||||||
|
VaultSetupParameterCtx struct{}
|
||||||
|
// VaultUnlockParameterCtx marks the private-space unlock/lock routes.
|
||||||
|
VaultUnlockParameterCtx struct{}
|
||||||
|
// VaultDisableParameterCtx marks the private-space disable route.
|
||||||
|
VaultDisableParameterCtx struct{}
|
||||||
|
|
||||||
|
// VaultSetupService enables the private space for the current user by
|
||||||
|
// setting an independent vault password and creating the vault folder.
|
||||||
|
VaultSetupService struct {
|
||||||
|
Password string `json:"password" binding:"required,min=6,max=128"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// VaultUnlockService verifies the vault password and opens an unlock
|
||||||
|
// session for subsequent file operations.
|
||||||
|
VaultUnlockService struct {
|
||||||
|
Password string `json:"password" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// VaultDisableService turns off the private space. The vault folder and
|
||||||
|
// its contents stay as regular files; only the gating is removed.
|
||||||
|
VaultDisableService struct {
|
||||||
|
Password string `json:"password" binding:"required"`
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Setup enables the private space.
|
||||||
|
func (service *VaultSetupService) Setup(c *gin.Context) error {
|
||||||
|
dep := dependency.FromContext(c)
|
||||||
|
u := inventory.UserFromContext(c)
|
||||||
|
if u.VaultFolder > 0 {
|
||||||
|
return serializer.NewError(serializer.CodeConflict, "private space is already enabled", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
digest, err := inventory.DigestPassword(service.Password)
|
||||||
|
if err != nil {
|
||||||
|
return serializer.NewError(serializer.CodeInternalSetting, "Failed to hash vault password", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
m := manager.NewFileManager(dep, u)
|
||||||
|
defer m.Recycle()
|
||||||
|
|
||||||
|
uri, err := fs.NewUriFromString(constants.CloudreveScheme + "://" + string(constants.FileSystemMy) + "/" + VaultFolderName)
|
||||||
|
if err != nil {
|
||||||
|
return serializer.NewError(serializer.CodeInternalSetting, "Failed to build vault path", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
folder, err := m.Create(c, uri, types.FileTypeFolder)
|
||||||
|
if err != nil {
|
||||||
|
return serializer.NewError(serializer.CodeDBError, "Failed to create private space folder", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
folderModel, err := dep.FileClient().GetByID(c, folder.ID())
|
||||||
|
if err != nil {
|
||||||
|
return serializer.NewError(serializer.CodeDBError, "Failed to load private space folder", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := dep.FileClient().UpsertMetadata(c, folderModel, map[string]string{
|
||||||
|
dbfs.MetadataVault: "1",
|
||||||
|
}, nil); err != nil {
|
||||||
|
return serializer.NewError(serializer.CodeDBError, "Failed to mark private space folder", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := dep.UserClient().UpdateVault(c, u, digest, folder.ID()); err != nil {
|
||||||
|
return serializer.NewError(serializer.CodeDBError, "Failed to enable private space", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unlock verifies the vault password and opens the unlock session.
|
||||||
|
func (service *VaultUnlockService) Unlock(c *gin.Context) error {
|
||||||
|
dep := dependency.FromContext(c)
|
||||||
|
u := inventory.UserFromContext(c)
|
||||||
|
if u.VaultFolder <= 0 {
|
||||||
|
return serializer.NewError(serializer.CodeNotFound, "private space is not enabled", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := inventory.CheckVaultPassword(u, service.Password); err != nil {
|
||||||
|
return serializer.NewError(serializer.CodeInvalidPassword, "Incorrect vault password", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := dep.KV().Set(dbfs.VaultUnlockCachePrefix+strconv.Itoa(u.ID), 1, dbfs.VaultUnlockTTL); err != nil {
|
||||||
|
return serializer.NewError(serializer.CodeCacheOperation, "Failed to open vault session", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lock closes the unlock session immediately.
|
||||||
|
func (service *VaultUnlockService) Lock(c *gin.Context) error {
|
||||||
|
dep := dependency.FromContext(c)
|
||||||
|
u := inventory.UserFromContext(c)
|
||||||
|
dep.KV().Delete(dbfs.VaultUnlockCachePrefix, strconv.Itoa(u.ID))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disable verifies the vault password and turns off the private space.
|
||||||
|
func (service *VaultDisableService) Disable(c *gin.Context) error {
|
||||||
|
dep := dependency.FromContext(c)
|
||||||
|
u := inventory.UserFromContext(c)
|
||||||
|
if u.VaultFolder <= 0 {
|
||||||
|
return serializer.NewError(serializer.CodeNotFound, "private space is not enabled", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := inventory.CheckVaultPassword(u, service.Password); err != nil {
|
||||||
|
return serializer.NewError(serializer.CodeInvalidPassword, "Incorrect vault password", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if folder, err := dep.FileClient().GetByID(c, u.VaultFolder); err == nil && folder != nil {
|
||||||
|
if err := dep.FileClient().RemoveMetadata(c, folder, dbfs.MetadataVault); err != nil {
|
||||||
|
return serializer.NewError(serializer.CodeDBError, "Failed to unmark private space folder", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := dep.UserClient().UpdateVault(c, u, "", 0); err != nil {
|
||||||
|
return serializer.NewError(serializer.CodeDBError, "Failed to disable private space", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
dep.KV().Delete(dbfs.VaultUnlockCachePrefix, strconv.Itoa(u.ID))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
Loading…
Reference in new issue