feat: private space (vault) with password-gated file access (#109)
Adds an opt-in private space: a flagged root folder whose contents are
inaccessible until unlocked with a separate vault password. Membership is
determined by ancestry (no per-file flag maintenance), so moves and copies
stay correct automatically.
Backend:
- user.vault_password + user.vault_folder ent fields (sensitive digest)
- vaultNavigator decorator gating To/Children/Walk/ExecuteHook, with lazy
ancestor resolution for chain-less search results
- POST/PUT/DELETE /api/v4/user/vault{,/unlock} with rate-limited unlock
(10/hour) and 30-minute cache-backed unlock sessions
- vault files rejected from share creation and direct links
- search results filtered while locked; vault root stays resolvable as
the unlock entry point
- user settings expose vault_enabled/vault_unlocked
Frontend:
- unlock prompt in ExplorerError on CodeVaultLocked (40093)
- Private space section in security settings (setup/unlock/lock/disable)
- lock badge on vault folder icon
- en-US + zh-CN locales
Authored By: TDvorak <info@tdvorak.dev>
Generated with [Devin](https://devin.ai)
Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
pull/3587/head
parent
831f40a0dc
commit
d7728c896c
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