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
Tomas Dvorak 2 weeks ago
parent 831f40a0dc
commit d7728c896c

@ -207,6 +207,7 @@ Order = user-visible value first; each ships with backend + UI + tests.
- #3454 (PG FK on upload) is **Pro-only** — `audit_logs` doesn't exist in this codebase. When B.5 adds our own audit log: insert the audit row in the same tx *after* the file row, never before.
- [x] `desloppify` pass — 73 review items dispositioned (46 fixed, 27 honestly skipped), strict score 77.1 (was 18.9); scorecard lives in README. `security-reviewer` pass done incrementally per batch (OAuth secrets, SSRF, process exec, path safety)
- [x] Tag management page (upstream #2962) — owner-scoped `tag:` metadata stats/rename/recolor/delete in `inventory.FileClient`, `GET/PATCH/DELETE /file/tag` routes, Settings → Tags tab with merge-on-rename semantics
- [x] Private space / vault (upstream #3447) — opt-in root folder flagged `sys:vault`; ancestry-based membership (zero flag maintenance; chain-less search results resolved lazily via `file_children`); `vaultNavigator` decorator gating `To`/`Children`/`Walk`/`ExecuteHook`; separate vault password (`salt:sha256`, sensitive) + 30-min cache-backed unlock session, unlock rate-limited 10/h; vault content never shareable and never direct-linkable; search filtered while locked; `vault_enabled`/`vault_unlocked` in user settings; unlock prompt in `ExplorerError`, Private space section in security settings, lock badge on vault folder (#195)
## 6. Phase D — desktop, all platforms

File diff suppressed because one or more lines are too long

@ -787,6 +787,8 @@ var (
{Name: "storage", Type: field.TypeInt64, Default: 0},
{Name: "credits", Type: field.TypeInt64, Default: 0},
{Name: "two_factor_secret", Type: field.TypeString, Nullable: true},
{Name: "vault_password", Type: field.TypeString, Nullable: true},
{Name: "vault_folder", Type: field.TypeInt, Nullable: true, Default: 0},
{Name: "avatar", Type: field.TypeString, Nullable: true},
{Name: "settings", Type: field.TypeJSON, Nullable: true},
{Name: "group_users", Type: field.TypeInt},
@ -799,7 +801,7 @@ var (
ForeignKeys: []*schema.ForeignKey{
{
Symbol: "users_groups_users",
Columns: []*schema.Column{UsersColumns[16]},
Columns: []*schema.Column{UsersColumns[18]},
RefColumns: []*schema.Column{GroupsColumns[0]},
OnDelete: schema.NoAction,
},

@ -24361,6 +24361,9 @@ type UserMutation struct {
credits *int64
addcredits *int64
two_factor_secret *string
vault_password *string
vault_folder *int
addvault_folder *int
avatar *string
settings **types.UserSetting
clearedFields map[string]struct{}
@ -25094,6 +25097,125 @@ func (m *UserMutation) ResetTwoFactorSecret() {
delete(m.clearedFields, user.FieldTwoFactorSecret)
}
// SetVaultPassword sets the "vault_password" field.
func (m *UserMutation) SetVaultPassword(s string) {
m.vault_password = &s
}
// VaultPassword returns the value of the "vault_password" field in the mutation.
func (m *UserMutation) VaultPassword() (r string, exists bool) {
v := m.vault_password
if v == nil {
return
}
return *v, true
}
// OldVaultPassword returns the old "vault_password" field's value of the User entity.
// If the User object wasn't provided to the builder, the object is fetched from the database.
// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
func (m *UserMutation) OldVaultPassword(ctx context.Context) (v string, err error) {
if !m.op.Is(OpUpdateOne) {
return v, errors.New("OldVaultPassword is only allowed on UpdateOne operations")
}
if m.id == nil || m.oldValue == nil {
return v, errors.New("OldVaultPassword requires an ID field in the mutation")
}
oldValue, err := m.oldValue(ctx)
if err != nil {
return v, fmt.Errorf("querying old value for OldVaultPassword: %w", err)
}
return oldValue.VaultPassword, nil
}
// ClearVaultPassword clears the value of the "vault_password" field.
func (m *UserMutation) ClearVaultPassword() {
m.vault_password = nil
m.clearedFields[user.FieldVaultPassword] = struct{}{}
}
// VaultPasswordCleared returns if the "vault_password" field was cleared in this mutation.
func (m *UserMutation) VaultPasswordCleared() bool {
_, ok := m.clearedFields[user.FieldVaultPassword]
return ok
}
// ResetVaultPassword resets all changes to the "vault_password" field.
func (m *UserMutation) ResetVaultPassword() {
m.vault_password = nil
delete(m.clearedFields, user.FieldVaultPassword)
}
// SetVaultFolder sets the "vault_folder" field.
func (m *UserMutation) SetVaultFolder(i int) {
m.vault_folder = &i
m.addvault_folder = nil
}
// VaultFolder returns the value of the "vault_folder" field in the mutation.
func (m *UserMutation) VaultFolder() (r int, exists bool) {
v := m.vault_folder
if v == nil {
return
}
return *v, true
}
// OldVaultFolder returns the old "vault_folder" field's value of the User entity.
// If the User object wasn't provided to the builder, the object is fetched from the database.
// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
func (m *UserMutation) OldVaultFolder(ctx context.Context) (v int, err error) {
if !m.op.Is(OpUpdateOne) {
return v, errors.New("OldVaultFolder is only allowed on UpdateOne operations")
}
if m.id == nil || m.oldValue == nil {
return v, errors.New("OldVaultFolder requires an ID field in the mutation")
}
oldValue, err := m.oldValue(ctx)
if err != nil {
return v, fmt.Errorf("querying old value for OldVaultFolder: %w", err)
}
return oldValue.VaultFolder, nil
}
// AddVaultFolder adds i to the "vault_folder" field.
func (m *UserMutation) AddVaultFolder(i int) {
if m.addvault_folder != nil {
*m.addvault_folder += i
} else {
m.addvault_folder = &i
}
}
// AddedVaultFolder returns the value that was added to the "vault_folder" field in this mutation.
func (m *UserMutation) AddedVaultFolder() (r int, exists bool) {
v := m.addvault_folder
if v == nil {
return
}
return *v, true
}
// ClearVaultFolder clears the value of the "vault_folder" field.
func (m *UserMutation) ClearVaultFolder() {
m.vault_folder = nil
m.addvault_folder = nil
m.clearedFields[user.FieldVaultFolder] = struct{}{}
}
// VaultFolderCleared returns if the "vault_folder" field was cleared in this mutation.
func (m *UserMutation) VaultFolderCleared() bool {
_, ok := m.clearedFields[user.FieldVaultFolder]
return ok
}
// ResetVaultFolder resets all changes to the "vault_folder" field.
func (m *UserMutation) ResetVaultFolder() {
m.vault_folder = nil
m.addvault_folder = nil
delete(m.clearedFields, user.FieldVaultFolder)
}
// SetAvatar sets the "avatar" field.
func (m *UserMutation) SetAvatar(s string) {
m.avatar = &s
@ -26004,7 +26126,7 @@ func (m *UserMutation) Type() string {
// order to get all numeric fields that were incremented/decremented, call
// AddedFields().
func (m *UserMutation) Fields() []string {
fields := make([]string, 0, 16)
fields := make([]string, 0, 18)
if m.created_at != nil {
fields = append(fields, user.FieldCreatedAt)
}
@ -26044,6 +26166,12 @@ func (m *UserMutation) Fields() []string {
if m.two_factor_secret != nil {
fields = append(fields, user.FieldTwoFactorSecret)
}
if m.vault_password != nil {
fields = append(fields, user.FieldVaultPassword)
}
if m.vault_folder != nil {
fields = append(fields, user.FieldVaultFolder)
}
if m.avatar != nil {
fields = append(fields, user.FieldAvatar)
}
@ -26087,6 +26215,10 @@ func (m *UserMutation) Field(name string) (ent.Value, bool) {
return m.Credits()
case user.FieldTwoFactorSecret:
return m.TwoFactorSecret()
case user.FieldVaultPassword:
return m.VaultPassword()
case user.FieldVaultFolder:
return m.VaultFolder()
case user.FieldAvatar:
return m.Avatar()
case user.FieldSettings:
@ -26128,6 +26260,10 @@ func (m *UserMutation) OldField(ctx context.Context, name string) (ent.Value, er
return m.OldCredits(ctx)
case user.FieldTwoFactorSecret:
return m.OldTwoFactorSecret(ctx)
case user.FieldVaultPassword:
return m.OldVaultPassword(ctx)
case user.FieldVaultFolder:
return m.OldVaultFolder(ctx)
case user.FieldAvatar:
return m.OldAvatar(ctx)
case user.FieldSettings:
@ -26234,6 +26370,20 @@ func (m *UserMutation) SetField(name string, value ent.Value) error {
}
m.SetTwoFactorSecret(v)
return nil
case user.FieldVaultPassword:
v, ok := value.(string)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
m.SetVaultPassword(v)
return nil
case user.FieldVaultFolder:
v, ok := value.(int)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
m.SetVaultFolder(v)
return nil
case user.FieldAvatar:
v, ok := value.(string)
if !ok {
@ -26269,6 +26419,9 @@ func (m *UserMutation) AddedFields() []string {
if m.addcredits != nil {
fields = append(fields, user.FieldCredits)
}
if m.addvault_folder != nil {
fields = append(fields, user.FieldVaultFolder)
}
return fields
}
@ -26281,6 +26434,8 @@ func (m *UserMutation) AddedField(name string) (ent.Value, bool) {
return m.AddedStorage()
case user.FieldCredits:
return m.AddedCredits()
case user.FieldVaultFolder:
return m.AddedVaultFolder()
}
return nil, false
}
@ -26304,6 +26459,13 @@ func (m *UserMutation) AddField(name string, value ent.Value) error {
}
m.AddCredits(v)
return nil
case user.FieldVaultFolder:
v, ok := value.(int)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
m.AddVaultFolder(v)
return nil
}
return fmt.Errorf("unknown User numeric field %s", name)
}
@ -26330,6 +26492,12 @@ func (m *UserMutation) ClearedFields() []string {
if m.FieldCleared(user.FieldTwoFactorSecret) {
fields = append(fields, user.FieldTwoFactorSecret)
}
if m.FieldCleared(user.FieldVaultPassword) {
fields = append(fields, user.FieldVaultPassword)
}
if m.FieldCleared(user.FieldVaultFolder) {
fields = append(fields, user.FieldVaultFolder)
}
if m.FieldCleared(user.FieldAvatar) {
fields = append(fields, user.FieldAvatar)
}
@ -26368,6 +26536,12 @@ func (m *UserMutation) ClearField(name string) error {
case user.FieldTwoFactorSecret:
m.ClearTwoFactorSecret()
return nil
case user.FieldVaultPassword:
m.ClearVaultPassword()
return nil
case user.FieldVaultFolder:
m.ClearVaultFolder()
return nil
case user.FieldAvatar:
m.ClearAvatar()
return nil
@ -26421,6 +26595,12 @@ func (m *UserMutation) ResetField(name string) error {
case user.FieldTwoFactorSecret:
m.ResetTwoFactorSecret()
return nil
case user.FieldVaultPassword:
m.ResetVaultPassword()
return nil
case user.FieldVaultFolder:
m.ResetVaultFolder()
return nil
case user.FieldAvatar:
m.ResetAvatar()
return nil

@ -685,8 +685,12 @@ func init() {
userDescCredits := userFields[8].Descriptor()
// user.DefaultCredits holds the default value on creation for the credits field.
user.DefaultCredits = userDescCredits.Default.(int64)
// userDescVaultFolder is the schema descriptor for vault_folder field.
userDescVaultFolder := userFields[11].Descriptor()
// user.DefaultVaultFolder holds the default value on creation for the vault_folder field.
user.DefaultVaultFolder = userDescVaultFolder.Default.(int)
// userDescSettings is the schema descriptor for settings field.
userDescSettings := userFields[11].Descriptor()
userDescSettings := userFields[13].Descriptor()
// user.DefaultSettings holds the default value on creation for the settings field.
user.DefaultSettings = userDescSettings.Default.(*types.UserSetting)
usergrantMixin := schema.UserGrant{}.Mixin()

@ -45,6 +45,16 @@ func (User) Fields() []ent.Field {
field.String("two_factor_secret").
Sensitive().
Optional(),
// vault_password gates the user's private space; same salt:digest
// format as the account password but independent from it.
field.String("vault_password").
Sensitive().
Optional(),
// vault_folder is the file ID of the user's private-space root
// folder. 0 means the feature is not set up.
field.Int("vault_folder").
Optional().
Default(0),
field.String("avatar").
Optional(),
field.JSON("settings", &types.UserSetting{}).

@ -46,6 +46,10 @@ type User struct {
Credits int64 `json:"credits,omitempty"`
// TwoFactorSecret holds the value of the "two_factor_secret" field.
TwoFactorSecret string `json:"-"`
// VaultPassword holds the value of the "vault_password" field.
VaultPassword string `json:"-"`
// VaultFolder holds the value of the "vault_folder" field.
VaultFolder int `json:"vault_folder,omitempty"`
// Avatar holds the value of the "avatar" field.
Avatar string `json:"avatar,omitempty"`
// Settings holds the value of the "settings" field.
@ -230,9 +234,9 @@ func (*User) scanValues(columns []string) ([]any, error) {
switch columns[i] {
case user.FieldSettings:
values[i] = new([]byte)
case user.FieldID, user.FieldStorage, user.FieldCredits, user.FieldGroupUsers:
case user.FieldID, user.FieldStorage, user.FieldCredits, user.FieldVaultFolder, user.FieldGroupUsers:
values[i] = new(sql.NullInt64)
case user.FieldEmail, user.FieldNick, user.FieldPassword, user.FieldStatus, user.FieldBanReason, user.FieldTwoFactorSecret, user.FieldAvatar:
case user.FieldEmail, user.FieldNick, user.FieldPassword, user.FieldStatus, user.FieldBanReason, user.FieldTwoFactorSecret, user.FieldVaultPassword, user.FieldAvatar:
values[i] = new(sql.NullString)
case user.FieldCreatedAt, user.FieldUpdatedAt, user.FieldDeletedAt, user.FieldBanExpires, user.FieldLastLogin:
values[i] = new(sql.NullTime)
@ -338,6 +342,18 @@ func (u *User) assignValues(columns []string, values []any) error {
} else if value.Valid {
u.TwoFactorSecret = value.String
}
case user.FieldVaultPassword:
if value, ok := values[i].(*sql.NullString); !ok {
return fmt.Errorf("unexpected type %T for field vault_password", values[i])
} else if value.Valid {
u.VaultPassword = value.String
}
case user.FieldVaultFolder:
if value, ok := values[i].(*sql.NullInt64); !ok {
return fmt.Errorf("unexpected type %T for field vault_folder", values[i])
} else if value.Valid {
u.VaultFolder = int(value.Int64)
}
case user.FieldAvatar:
if value, ok := values[i].(*sql.NullString); !ok {
return fmt.Errorf("unexpected type %T for field avatar", values[i])
@ -507,6 +523,11 @@ func (u *User) String() string {
builder.WriteString(", ")
builder.WriteString("two_factor_secret=<sensitive>")
builder.WriteString(", ")
builder.WriteString("vault_password=<sensitive>")
builder.WriteString(", ")
builder.WriteString("vault_folder=")
builder.WriteString(fmt.Sprintf("%v", u.VaultFolder))
builder.WriteString(", ")
builder.WriteString("avatar=")
builder.WriteString(u.Avatar)
builder.WriteString(", ")

@ -43,6 +43,10 @@ const (
FieldCredits = "credits"
// FieldTwoFactorSecret holds the string denoting the two_factor_secret field in the database.
FieldTwoFactorSecret = "two_factor_secret"
// FieldVaultPassword holds the string denoting the vault_password field in the database.
FieldVaultPassword = "vault_password"
// FieldVaultFolder holds the string denoting the vault_folder field in the database.
FieldVaultFolder = "vault_folder"
// FieldAvatar holds the string denoting the avatar field in the database.
FieldAvatar = "avatar"
// FieldSettings holds the string denoting the settings field in the database.
@ -195,6 +199,8 @@ var Columns = []string{
FieldStorage,
FieldCredits,
FieldTwoFactorSecret,
FieldVaultPassword,
FieldVaultFolder,
FieldAvatar,
FieldSettings,
FieldGroupUsers,
@ -232,6 +238,8 @@ var (
DefaultStorage int64
// DefaultCredits holds the default value on creation for the "credits" field.
DefaultCredits int64
// DefaultVaultFolder holds the default value on creation for the "vault_folder" field.
DefaultVaultFolder int
// DefaultSettings holds the default value on creation for the "settings" field.
DefaultSettings *types.UserSetting
)
@ -337,6 +345,16 @@ func ByTwoFactorSecret(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldTwoFactorSecret, opts...).ToFunc()
}
// ByVaultPassword orders the results by the vault_password field.
func ByVaultPassword(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldVaultPassword, opts...).ToFunc()
}
// ByVaultFolder orders the results by the vault_folder field.
func ByVaultFolder(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldVaultFolder, opts...).ToFunc()
}
// ByAvatar orders the results by the avatar field.
func ByAvatar(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldAvatar, opts...).ToFunc()

@ -115,6 +115,16 @@ func TwoFactorSecret(v string) predicate.User {
return predicate.User(sql.FieldEQ(FieldTwoFactorSecret, v))
}
// VaultPassword applies equality check predicate on the "vault_password" field. It's identical to VaultPasswordEQ.
func VaultPassword(v string) predicate.User {
return predicate.User(sql.FieldEQ(FieldVaultPassword, v))
}
// VaultFolder applies equality check predicate on the "vault_folder" field. It's identical to VaultFolderEQ.
func VaultFolder(v int) predicate.User {
return predicate.User(sql.FieldEQ(FieldVaultFolder, v))
}
// Avatar applies equality check predicate on the "avatar" field. It's identical to AvatarEQ.
func Avatar(v string) predicate.User {
return predicate.User(sql.FieldEQ(FieldAvatar, v))
@ -810,6 +820,131 @@ func TwoFactorSecretContainsFold(v string) predicate.User {
return predicate.User(sql.FieldContainsFold(FieldTwoFactorSecret, v))
}
// VaultPasswordEQ applies the EQ predicate on the "vault_password" field.
func VaultPasswordEQ(v string) predicate.User {
return predicate.User(sql.FieldEQ(FieldVaultPassword, v))
}
// VaultPasswordNEQ applies the NEQ predicate on the "vault_password" field.
func VaultPasswordNEQ(v string) predicate.User {
return predicate.User(sql.FieldNEQ(FieldVaultPassword, v))
}
// VaultPasswordIn applies the In predicate on the "vault_password" field.
func VaultPasswordIn(vs ...string) predicate.User {
return predicate.User(sql.FieldIn(FieldVaultPassword, vs...))
}
// VaultPasswordNotIn applies the NotIn predicate on the "vault_password" field.
func VaultPasswordNotIn(vs ...string) predicate.User {
return predicate.User(sql.FieldNotIn(FieldVaultPassword, vs...))
}
// VaultPasswordGT applies the GT predicate on the "vault_password" field.
func VaultPasswordGT(v string) predicate.User {
return predicate.User(sql.FieldGT(FieldVaultPassword, v))
}
// VaultPasswordGTE applies the GTE predicate on the "vault_password" field.
func VaultPasswordGTE(v string) predicate.User {
return predicate.User(sql.FieldGTE(FieldVaultPassword, v))
}
// VaultPasswordLT applies the LT predicate on the "vault_password" field.
func VaultPasswordLT(v string) predicate.User {
return predicate.User(sql.FieldLT(FieldVaultPassword, v))
}
// VaultPasswordLTE applies the LTE predicate on the "vault_password" field.
func VaultPasswordLTE(v string) predicate.User {
return predicate.User(sql.FieldLTE(FieldVaultPassword, v))
}
// VaultPasswordContains applies the Contains predicate on the "vault_password" field.
func VaultPasswordContains(v string) predicate.User {
return predicate.User(sql.FieldContains(FieldVaultPassword, v))
}
// VaultPasswordHasPrefix applies the HasPrefix predicate on the "vault_password" field.
func VaultPasswordHasPrefix(v string) predicate.User {
return predicate.User(sql.FieldHasPrefix(FieldVaultPassword, v))
}
// VaultPasswordHasSuffix applies the HasSuffix predicate on the "vault_password" field.
func VaultPasswordHasSuffix(v string) predicate.User {
return predicate.User(sql.FieldHasSuffix(FieldVaultPassword, v))
}
// VaultPasswordIsNil applies the IsNil predicate on the "vault_password" field.
func VaultPasswordIsNil() predicate.User {
return predicate.User(sql.FieldIsNull(FieldVaultPassword))
}
// VaultPasswordNotNil applies the NotNil predicate on the "vault_password" field.
func VaultPasswordNotNil() predicate.User {
return predicate.User(sql.FieldNotNull(FieldVaultPassword))
}
// VaultPasswordEqualFold applies the EqualFold predicate on the "vault_password" field.
func VaultPasswordEqualFold(v string) predicate.User {
return predicate.User(sql.FieldEqualFold(FieldVaultPassword, v))
}
// VaultPasswordContainsFold applies the ContainsFold predicate on the "vault_password" field.
func VaultPasswordContainsFold(v string) predicate.User {
return predicate.User(sql.FieldContainsFold(FieldVaultPassword, v))
}
// VaultFolderEQ applies the EQ predicate on the "vault_folder" field.
func VaultFolderEQ(v int) predicate.User {
return predicate.User(sql.FieldEQ(FieldVaultFolder, v))
}
// VaultFolderNEQ applies the NEQ predicate on the "vault_folder" field.
func VaultFolderNEQ(v int) predicate.User {
return predicate.User(sql.FieldNEQ(FieldVaultFolder, v))
}
// VaultFolderIn applies the In predicate on the "vault_folder" field.
func VaultFolderIn(vs ...int) predicate.User {
return predicate.User(sql.FieldIn(FieldVaultFolder, vs...))
}
// VaultFolderNotIn applies the NotIn predicate on the "vault_folder" field.
func VaultFolderNotIn(vs ...int) predicate.User {
return predicate.User(sql.FieldNotIn(FieldVaultFolder, vs...))
}
// VaultFolderGT applies the GT predicate on the "vault_folder" field.
func VaultFolderGT(v int) predicate.User {
return predicate.User(sql.FieldGT(FieldVaultFolder, v))
}
// VaultFolderGTE applies the GTE predicate on the "vault_folder" field.
func VaultFolderGTE(v int) predicate.User {
return predicate.User(sql.FieldGTE(FieldVaultFolder, v))
}
// VaultFolderLT applies the LT predicate on the "vault_folder" field.
func VaultFolderLT(v int) predicate.User {
return predicate.User(sql.FieldLT(FieldVaultFolder, v))
}
// VaultFolderLTE applies the LTE predicate on the "vault_folder" field.
func VaultFolderLTE(v int) predicate.User {
return predicate.User(sql.FieldLTE(FieldVaultFolder, v))
}
// VaultFolderIsNil applies the IsNil predicate on the "vault_folder" field.
func VaultFolderIsNil() predicate.User {
return predicate.User(sql.FieldIsNull(FieldVaultFolder))
}
// VaultFolderNotNil applies the NotNil predicate on the "vault_folder" field.
func VaultFolderNotNil() predicate.User {
return predicate.User(sql.FieldNotNull(FieldVaultFolder))
}
// AvatarEQ applies the EQ predicate on the "avatar" field.
func AvatarEQ(v string) predicate.User {
return predicate.User(sql.FieldEQ(FieldAvatar, v))

@ -203,6 +203,34 @@ func (uc *UserCreate) SetNillableTwoFactorSecret(s *string) *UserCreate {
return uc
}
// SetVaultPassword sets the "vault_password" field.
func (uc *UserCreate) SetVaultPassword(s string) *UserCreate {
uc.mutation.SetVaultPassword(s)
return uc
}
// SetNillableVaultPassword sets the "vault_password" field if the given value is not nil.
func (uc *UserCreate) SetNillableVaultPassword(s *string) *UserCreate {
if s != nil {
uc.SetVaultPassword(*s)
}
return uc
}
// SetVaultFolder sets the "vault_folder" field.
func (uc *UserCreate) SetVaultFolder(i int) *UserCreate {
uc.mutation.SetVaultFolder(i)
return uc
}
// SetNillableVaultFolder sets the "vault_folder" field if the given value is not nil.
func (uc *UserCreate) SetNillableVaultFolder(i *int) *UserCreate {
if i != nil {
uc.SetVaultFolder(*i)
}
return uc
}
// SetAvatar sets the "avatar" field.
func (uc *UserCreate) SetAvatar(s string) *UserCreate {
uc.mutation.SetAvatar(s)
@ -498,6 +526,10 @@ func (uc *UserCreate) defaults() error {
v := user.DefaultCredits
uc.mutation.SetCredits(v)
}
if _, ok := uc.mutation.VaultFolder(); !ok {
v := user.DefaultVaultFolder
uc.mutation.SetVaultFolder(v)
}
if _, ok := uc.mutation.Settings(); !ok {
v := user.DefaultSettings
uc.mutation.SetSettings(v)
@ -635,6 +667,14 @@ func (uc *UserCreate) createSpec() (*User, *sqlgraph.CreateSpec) {
_spec.SetField(user.FieldTwoFactorSecret, field.TypeString, value)
_node.TwoFactorSecret = value
}
if value, ok := uc.mutation.VaultPassword(); ok {
_spec.SetField(user.FieldVaultPassword, field.TypeString, value)
_node.VaultPassword = value
}
if value, ok := uc.mutation.VaultFolder(); ok {
_spec.SetField(user.FieldVaultFolder, field.TypeInt, value)
_node.VaultFolder = value
}
if value, ok := uc.mutation.Avatar(); ok {
_spec.SetField(user.FieldAvatar, field.TypeString, value)
_node.Avatar = value
@ -1112,6 +1152,48 @@ func (u *UserUpsert) ClearTwoFactorSecret() *UserUpsert {
return u
}
// SetVaultPassword sets the "vault_password" field.
func (u *UserUpsert) SetVaultPassword(v string) *UserUpsert {
u.Set(user.FieldVaultPassword, v)
return u
}
// UpdateVaultPassword sets the "vault_password" field to the value that was provided on create.
func (u *UserUpsert) UpdateVaultPassword() *UserUpsert {
u.SetExcluded(user.FieldVaultPassword)
return u
}
// ClearVaultPassword clears the value of the "vault_password" field.
func (u *UserUpsert) ClearVaultPassword() *UserUpsert {
u.SetNull(user.FieldVaultPassword)
return u
}
// SetVaultFolder sets the "vault_folder" field.
func (u *UserUpsert) SetVaultFolder(v int) *UserUpsert {
u.Set(user.FieldVaultFolder, v)
return u
}
// UpdateVaultFolder sets the "vault_folder" field to the value that was provided on create.
func (u *UserUpsert) UpdateVaultFolder() *UserUpsert {
u.SetExcluded(user.FieldVaultFolder)
return u
}
// AddVaultFolder adds v to the "vault_folder" field.
func (u *UserUpsert) AddVaultFolder(v int) *UserUpsert {
u.Add(user.FieldVaultFolder, v)
return u
}
// ClearVaultFolder clears the value of the "vault_folder" field.
func (u *UserUpsert) ClearVaultFolder() *UserUpsert {
u.SetNull(user.FieldVaultFolder)
return u
}
// SetAvatar sets the "avatar" field.
func (u *UserUpsert) SetAvatar(v string) *UserUpsert {
u.Set(user.FieldAvatar, v)
@ -1429,6 +1511,55 @@ func (u *UserUpsertOne) ClearTwoFactorSecret() *UserUpsertOne {
})
}
// SetVaultPassword sets the "vault_password" field.
func (u *UserUpsertOne) SetVaultPassword(v string) *UserUpsertOne {
return u.Update(func(s *UserUpsert) {
s.SetVaultPassword(v)
})
}
// UpdateVaultPassword sets the "vault_password" field to the value that was provided on create.
func (u *UserUpsertOne) UpdateVaultPassword() *UserUpsertOne {
return u.Update(func(s *UserUpsert) {
s.UpdateVaultPassword()
})
}
// ClearVaultPassword clears the value of the "vault_password" field.
func (u *UserUpsertOne) ClearVaultPassword() *UserUpsertOne {
return u.Update(func(s *UserUpsert) {
s.ClearVaultPassword()
})
}
// SetVaultFolder sets the "vault_folder" field.
func (u *UserUpsertOne) SetVaultFolder(v int) *UserUpsertOne {
return u.Update(func(s *UserUpsert) {
s.SetVaultFolder(v)
})
}
// AddVaultFolder adds v to the "vault_folder" field.
func (u *UserUpsertOne) AddVaultFolder(v int) *UserUpsertOne {
return u.Update(func(s *UserUpsert) {
s.AddVaultFolder(v)
})
}
// UpdateVaultFolder sets the "vault_folder" field to the value that was provided on create.
func (u *UserUpsertOne) UpdateVaultFolder() *UserUpsertOne {
return u.Update(func(s *UserUpsert) {
s.UpdateVaultFolder()
})
}
// ClearVaultFolder clears the value of the "vault_folder" field.
func (u *UserUpsertOne) ClearVaultFolder() *UserUpsertOne {
return u.Update(func(s *UserUpsert) {
s.ClearVaultFolder()
})
}
// SetAvatar sets the "avatar" field.
func (u *UserUpsertOne) SetAvatar(v string) *UserUpsertOne {
return u.Update(func(s *UserUpsert) {
@ -1925,6 +2056,55 @@ func (u *UserUpsertBulk) ClearTwoFactorSecret() *UserUpsertBulk {
})
}
// SetVaultPassword sets the "vault_password" field.
func (u *UserUpsertBulk) SetVaultPassword(v string) *UserUpsertBulk {
return u.Update(func(s *UserUpsert) {
s.SetVaultPassword(v)
})
}
// UpdateVaultPassword sets the "vault_password" field to the value that was provided on create.
func (u *UserUpsertBulk) UpdateVaultPassword() *UserUpsertBulk {
return u.Update(func(s *UserUpsert) {
s.UpdateVaultPassword()
})
}
// ClearVaultPassword clears the value of the "vault_password" field.
func (u *UserUpsertBulk) ClearVaultPassword() *UserUpsertBulk {
return u.Update(func(s *UserUpsert) {
s.ClearVaultPassword()
})
}
// SetVaultFolder sets the "vault_folder" field.
func (u *UserUpsertBulk) SetVaultFolder(v int) *UserUpsertBulk {
return u.Update(func(s *UserUpsert) {
s.SetVaultFolder(v)
})
}
// AddVaultFolder adds v to the "vault_folder" field.
func (u *UserUpsertBulk) AddVaultFolder(v int) *UserUpsertBulk {
return u.Update(func(s *UserUpsert) {
s.AddVaultFolder(v)
})
}
// UpdateVaultFolder sets the "vault_folder" field to the value that was provided on create.
func (u *UserUpsertBulk) UpdateVaultFolder() *UserUpsertBulk {
return u.Update(func(s *UserUpsert) {
s.UpdateVaultFolder()
})
}
// ClearVaultFolder clears the value of the "vault_folder" field.
func (u *UserUpsertBulk) ClearVaultFolder() *UserUpsertBulk {
return u.Update(func(s *UserUpsert) {
s.ClearVaultFolder()
})
}
// SetAvatar sets the "avatar" field.
func (u *UserUpsertBulk) SetAvatar(v string) *UserUpsertBulk {
return u.Update(func(s *UserUpsert) {

@ -253,6 +253,53 @@ func (uu *UserUpdate) ClearTwoFactorSecret() *UserUpdate {
return uu
}
// SetVaultPassword sets the "vault_password" field.
func (uu *UserUpdate) SetVaultPassword(s string) *UserUpdate {
uu.mutation.SetVaultPassword(s)
return uu
}
// SetNillableVaultPassword sets the "vault_password" field if the given value is not nil.
func (uu *UserUpdate) SetNillableVaultPassword(s *string) *UserUpdate {
if s != nil {
uu.SetVaultPassword(*s)
}
return uu
}
// ClearVaultPassword clears the value of the "vault_password" field.
func (uu *UserUpdate) ClearVaultPassword() *UserUpdate {
uu.mutation.ClearVaultPassword()
return uu
}
// SetVaultFolder sets the "vault_folder" field.
func (uu *UserUpdate) SetVaultFolder(i int) *UserUpdate {
uu.mutation.ResetVaultFolder()
uu.mutation.SetVaultFolder(i)
return uu
}
// SetNillableVaultFolder sets the "vault_folder" field if the given value is not nil.
func (uu *UserUpdate) SetNillableVaultFolder(i *int) *UserUpdate {
if i != nil {
uu.SetVaultFolder(*i)
}
return uu
}
// AddVaultFolder adds i to the "vault_folder" field.
func (uu *UserUpdate) AddVaultFolder(i int) *UserUpdate {
uu.mutation.AddVaultFolder(i)
return uu
}
// ClearVaultFolder clears the value of the "vault_folder" field.
func (uu *UserUpdate) ClearVaultFolder() *UserUpdate {
uu.mutation.ClearVaultFolder()
return uu
}
// SetAvatar sets the "avatar" field.
func (uu *UserUpdate) SetAvatar(s string) *UserUpdate {
uu.mutation.SetAvatar(s)
@ -926,6 +973,21 @@ func (uu *UserUpdate) sqlSave(ctx context.Context) (n int, err error) {
if uu.mutation.TwoFactorSecretCleared() {
_spec.ClearField(user.FieldTwoFactorSecret, field.TypeString)
}
if value, ok := uu.mutation.VaultPassword(); ok {
_spec.SetField(user.FieldVaultPassword, field.TypeString, value)
}
if uu.mutation.VaultPasswordCleared() {
_spec.ClearField(user.FieldVaultPassword, field.TypeString)
}
if value, ok := uu.mutation.VaultFolder(); ok {
_spec.SetField(user.FieldVaultFolder, field.TypeInt, value)
}
if value, ok := uu.mutation.AddedVaultFolder(); ok {
_spec.AddField(user.FieldVaultFolder, field.TypeInt, value)
}
if uu.mutation.VaultFolderCleared() {
_spec.ClearField(user.FieldVaultFolder, field.TypeInt)
}
if value, ok := uu.mutation.Avatar(); ok {
_spec.SetField(user.FieldAvatar, field.TypeString, value)
}
@ -1782,6 +1844,53 @@ func (uuo *UserUpdateOne) ClearTwoFactorSecret() *UserUpdateOne {
return uuo
}
// SetVaultPassword sets the "vault_password" field.
func (uuo *UserUpdateOne) SetVaultPassword(s string) *UserUpdateOne {
uuo.mutation.SetVaultPassword(s)
return uuo
}
// SetNillableVaultPassword sets the "vault_password" field if the given value is not nil.
func (uuo *UserUpdateOne) SetNillableVaultPassword(s *string) *UserUpdateOne {
if s != nil {
uuo.SetVaultPassword(*s)
}
return uuo
}
// ClearVaultPassword clears the value of the "vault_password" field.
func (uuo *UserUpdateOne) ClearVaultPassword() *UserUpdateOne {
uuo.mutation.ClearVaultPassword()
return uuo
}
// SetVaultFolder sets the "vault_folder" field.
func (uuo *UserUpdateOne) SetVaultFolder(i int) *UserUpdateOne {
uuo.mutation.ResetVaultFolder()
uuo.mutation.SetVaultFolder(i)
return uuo
}
// SetNillableVaultFolder sets the "vault_folder" field if the given value is not nil.
func (uuo *UserUpdateOne) SetNillableVaultFolder(i *int) *UserUpdateOne {
if i != nil {
uuo.SetVaultFolder(*i)
}
return uuo
}
// AddVaultFolder adds i to the "vault_folder" field.
func (uuo *UserUpdateOne) AddVaultFolder(i int) *UserUpdateOne {
uuo.mutation.AddVaultFolder(i)
return uuo
}
// ClearVaultFolder clears the value of the "vault_folder" field.
func (uuo *UserUpdateOne) ClearVaultFolder() *UserUpdateOne {
uuo.mutation.ClearVaultFolder()
return uuo
}
// SetAvatar sets the "avatar" field.
func (uuo *UserUpdateOne) SetAvatar(s string) *UserUpdateOne {
uuo.mutation.SetAvatar(s)
@ -2485,6 +2594,21 @@ func (uuo *UserUpdateOne) sqlSave(ctx context.Context) (_node *User, err error)
if uuo.mutation.TwoFactorSecretCleared() {
_spec.ClearField(user.FieldTwoFactorSecret, field.TypeString)
}
if value, ok := uuo.mutation.VaultPassword(); ok {
_spec.SetField(user.FieldVaultPassword, field.TypeString, value)
}
if uuo.mutation.VaultPasswordCleared() {
_spec.ClearField(user.FieldVaultPassword, field.TypeString)
}
if value, ok := uuo.mutation.VaultFolder(); ok {
_spec.SetField(user.FieldVaultFolder, field.TypeInt, value)
}
if value, ok := uuo.mutation.AddedVaultFolder(); ok {
_spec.AddField(user.FieldVaultFolder, field.TypeInt, value)
}
if uuo.mutation.VaultFolderCleared() {
_spec.ClearField(user.FieldVaultFolder, field.TypeInt)
}
if value, ok := uuo.mutation.Avatar(); ok {
_spec.SetField(user.FieldAvatar, field.TypeString, value)
}

@ -773,6 +773,25 @@
"dropFileHere": "Drop file to upload",
"etaSuffix": ", ETA {{eta}}"
},
"vault": {
"title": "Private space",
"description": "Files in your private space are hidden from browsing and search until you unlock it with a separate password. Vault files cannot be shared or accessed via WebDAV.",
"enable": "Enable private space",
"enabled": "Private space enabled",
"disable": "Disable",
"disabled": "Private space disabled",
"disableDescription": "Disabling removes the lock; the folder and its contents remain as regular files.",
"confirmDisable": "Confirm disable",
"setPassword": "Set vault password",
"confirmPassword": "Vault password",
"enterPassword": "Vault password",
"lockNow": "Lock now",
"locked": "Private space locked",
"unlocked": "Unlocked",
"lockedState": "Locked",
"lockedTitle": "This folder is in your private space. Unlock to continue.",
"badge": "Private space"
},
"share": {
"statistics": "Statistics",
"expireAt": "Expire <0></0>",

@ -773,6 +773,25 @@
"dropFileHere": "松开鼠标开始上传",
"etaSuffix": ",预计剩余 {{eta}}"
},
"vault": {
"title": "私密空间",
"description": "私密空间中的文件在解锁前不会出现在浏览与搜索结果中,使用独立密码解锁。空间内文件无法分享,也无法通过 WebDAV 访问。",
"enable": "启用私密空间",
"enabled": "私密空间已启用",
"disable": "关闭",
"disabled": "私密空间已关闭",
"disableDescription": "关闭将解除锁定;文件夹及其内容会保留为普通文件。",
"confirmDisable": "确认关闭",
"setPassword": "设置私密空间密码",
"confirmPassword": "私密空间密码",
"enterPassword": "私密空间密码",
"lockNow": "立即锁定",
"locked": "私密空间已锁定",
"unlocked": "已解锁",
"lockedState": "已锁定",
"lockedTitle": "此文件夹位于私密空间内,请解锁后继续。",
"badge": "私密空间"
},
"share": {
"statistics": "统计",
"expireAt": "<0></0>过期",

@ -2674,6 +2674,73 @@ export function sendUnbindSso(provider: string): ThunkResponse {
};
}
export function sendVaultSetup(password: string): ThunkResponse {
return async (dispatch, _getState) => {
return await dispatch(
send(
"/user/vault",
{
method: "POST",
data: { password },
},
{
...defaultOpts,
},
),
);
};
}
export function sendVaultUnlock(password: string): ThunkResponse {
return async (dispatch, _getState) => {
return await dispatch(
send(
"/user/vault/unlock",
{
method: "PUT",
data: { password },
},
{
...defaultOpts,
},
),
);
};
}
export function sendVaultLock(): ThunkResponse {
return async (dispatch, _getState) => {
return await dispatch(
send(
"/user/vault/unlock",
{
method: "DELETE",
},
{
...defaultOpts,
},
),
);
};
}
export function sendVaultDisable(password: string): ThunkResponse {
return async (dispatch, _getState) => {
return await dispatch(
send(
"/user/vault",
{
method: "DELETE",
data: { password },
},
{
...defaultOpts,
},
),
);
};
}
export function sendFullTextSearch(query: string, offset?: number): ThunkResponse {
const params = new URLSearchParams();
params.set("query", query);

@ -179,6 +179,7 @@ export const Metadata = {
thumbDisabled: "thumb:disabled",
restore_uri: "sys:restore_uri",
expected_collect_time: "sys:expected_collect_time",
vault: "sys:vault",
full_text_index: "sys:fulltext_index",
preferred_policy: "sys:preferred_policy",

@ -141,6 +141,7 @@ export const Code = {
BatchOperationNotFullyCompleted: 40081,
DomainNotLicensed: 40087,
AnonymouseAccessDenied: 40088,
VaultLocked: 40093,
CodeLoginRequired: 401,
PermissionDenied: 403,
NodeFound: 404,

@ -151,6 +151,8 @@ export interface UserSettings {
preferred_policy?: string;
oauth_grants?: OAuthGrant[];
linked_accounts?: LinkedAccount[];
vault_enabled: boolean;
vault_unlocked: boolean;
}
export interface LinkedAccount {

@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { AppError, Code, Response } from "../../../api/request.ts";
import { useAppDispatch, useAppSelector } from "../../../redux/hooks.ts";
import { navigateToPath, retrySharePassword } from "../../../redux/thunks/filemanager.ts";
import { navigateToPath, retrySharePassword, unlockVault } from "../../../redux/thunks/filemanager.ts";
import { Filesystem } from "../../../util/uri.ts";
import { FilledTextField, SecondaryButton } from "../../Common/StyledComponents.tsx";
import ArrowLeft from "../../Icons/ArrowLeft.tsx";
@ -51,6 +51,65 @@ const RetryPassword = () => {
);
};
const RetryVaultUnlock = () => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const fmIndex = useContext(FmIndexContext);
const [password, setPassword] = useState("");
const [error, setError] = useState<string | undefined>(undefined);
const [loading, setLoading] = useState(false);
const unlock = useCallback(async () => {
setLoading(true);
setError(undefined);
try {
await dispatch(unlockVault(fmIndex, password));
} catch (e) {
setError(e instanceof Error ? e.message : undefined);
} finally {
setLoading(false);
}
}, [dispatch, fmIndex, password]);
return (
<Box sx={{ textAlign: "center" }} onClick={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}>
<LockClosed sx={{ fontSize: 80 }} color={"action"} />
<Typography color={"text.secondary"} sx={{ mt: 1 }}>
{t("application:vault.lockedTitle")}
</Typography>
<Box sx={{ mt: 1 }}>
<FilledTextField
variant={"filled"}
autoFocus
type={"password"}
value={password}
error={!!error}
helperText={error}
onChange={(e) => setPassword(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && password != "") {
unlock();
}
}}
label={t("application:vault.enterPassword")}
/>
<Button
disabled={password == "" || loading}
onClick={unlock}
variant={"contained"}
sx={{ ml: 1, height: "56px" }}
>
<ArrowLeft
sx={{
transform: "scaleX(-1)",
}}
/>
</Button>
</Box>
</Box>
);
};
const ExplorerError = memo(
React.forwardRef(({ error, ...rest }: ExplorerErrorProps, ref) => {
const dispatch = useAppDispatch();
@ -88,6 +147,8 @@ const ExplorerError = memo(
);
case Code.IncorrectPassword:
return <RetryPassword />;
case Code.VaultLocked:
return <RetryVaultUnlock />;
// @ts-ignore
case Code.NodeFound:
if (fs == Filesystem.share) {

@ -3,6 +3,7 @@ import { forwardRef, memo, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { FileResponse, FileType, Metadata } from "../../../api/explorer.ts";
import UserAvatar from "../../Common/User/UserAvatar.tsx";
import LockClosed from "../../Icons/LockClosed.tsx";
import ShareAndroid from "../../Icons/ShareAndroid.tsx";
import EmojiIcon from "./EmojiIcon.tsx";
import FileTypeIcon from "./FileTypeIcon.tsx";
@ -75,7 +76,21 @@ const FileIcon = memo(
}, [file, iconColor, iconProps, notLoaded]);
const badgeContent = useMemo(() => {
const avatarSize = variantAvatarSize[variant];
if (file?.metadata?.[Metadata.share_redirect]) {
if (file?.metadata?.[Metadata.vault]) {
return (
<Tooltip title={t("application:vault.badge")}>
<Avatar
sx={{
width: avatarSize,
height: avatarSize,
bgcolor: (theme) => theme.palette.background.default,
}}
>
<LockClosed sx={{ fontSize: `${avatarSize - 4}px!important` }} color={"action"} />
</Avatar>
</Tooltip>
);
} else if (file?.metadata?.[Metadata.share_redirect]) {
return (
<UserAvatar
overwriteTextSize

@ -10,6 +10,7 @@ import {
ListItemSecondaryAction,
Stack,
styled,
Typography,
useMediaQuery,
useTheme,
} from "@mui/material";
@ -17,7 +18,14 @@ import { useSnackbar } from "notistack";
import React, { useState } from "react";
import { Trans, useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { sendRevokeOAuthGrant, sendUnbindSso, sendUpdateUserSetting } from "../../../../api/api.ts";
import {
sendRevokeOAuthGrant,
sendUnbindSso,
sendUpdateUserSetting,
sendVaultDisable,
sendVaultLock,
sendVaultSetup,
} from "../../../../api/api.ts";
import { LinkedAccount, OAuthGrant, Passkey, UserSettings } from "../../../../api/user.ts";
import { useAppDispatch, useAppSelector } from "../../../../redux/hooks.ts";
import { confirmOperation } from "../../../../redux/thunks/dialog.ts";
@ -33,7 +41,9 @@ import TimeBadge from "../../../Common/TimeBadge.tsx";
import AppsListOutlined from "../../../Icons/AppsListOutlined.tsx";
import Dismiss from "../../../Icons/Dismiss.tsx";
import Edit from "../../../Icons/Edit.tsx";
import LockClosed from "../../../Icons/LockClosed.tsx";
import Open from "../../../Icons/Open.tsx";
import ShieldLock from "../../../Icons/ShieldLock.tsx";
import { ProfileSettingProps } from "../ProfileSetting.tsx";
import SettingForm from "../SettingForm.tsx";
import Disable2FADialog from "./Disable2FADialog.tsx";
@ -230,6 +240,162 @@ const LinkedAccountItem = ({
);
};
const VaultSetting = ({ setting, setSetting }: ProfileSettingProps) => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const { enqueueSnackbar } = useSnackbar();
const [password, setPassword] = useState("");
const [repeat, setRepeat] = useState("");
const [showDisable, setShowDisable] = useState(false);
const [disablePassword, setDisablePassword] = useState("");
const [loading, setLoading] = useState(false);
const notify = (variant: "success" | "warning", message: string) =>
enqueueSnackbar({ variant, message, action: DefaultCloseAction });
const onSetup = () => {
if (password !== repeat) {
notify("warning", t("login.passwordNotMatch"));
return;
}
setLoading(true);
dispatch(sendVaultSetup(password))
.then(() => {
setSetting({ ...setting, vault_enabled: true });
setPassword("");
setRepeat("");
notify("success", t("vault.enabled"));
})
.finally(() => setLoading(false));
};
const onLock = () => {
setLoading(true);
dispatch(sendVaultLock())
.then(() => {
setSetting({ ...setting, vault_unlocked: false });
notify("success", t("vault.locked"));
})
.finally(() => setLoading(false));
};
const onDisable = () => {
setLoading(true);
dispatch(sendVaultDisable(disablePassword))
.then(() => {
setSetting({ ...setting, vault_enabled: false, vault_unlocked: false });
setShowDisable(false);
setDisablePassword("");
notify("success", t("vault.disabled"));
})
.finally(() => setLoading(false));
};
return (
<SettingForm
title={
<Box>
{t("vault.title")}
{setting.vault_enabled && (
<SquareChip
sx={{
ml: 1,
height: "initial",
fontSize: (theme) => theme.typography.caption.fontSize,
}}
color={setting.vault_unlocked ? "success" : "default"}
size={"small"}
variant={"outlined"}
label={t(`vault.${setting.vault_unlocked ? "unlocked" : "lockedState"}`)}
/>
)}
</Box>
}
lgWidth={5}
>
<Typography variant={"body2"} color={"text.secondary"}>
{t("vault.description")}
</Typography>
{!setting.vault_enabled && (
<Stack spacing={2} sx={{ mt: 2 }}>
<DenseFilledTextField
required
label={t("vault.setPassword")}
fullWidth
value={password}
onChange={(e) => setPassword(e.target.value)}
inputProps={{ type: "password", minLength: 6, maxLength: 128 }}
/>
<DenseFilledTextField
required
label={t("login.repeatPassword")}
fullWidth
value={repeat}
onChange={(e) => setRepeat(e.target.value)}
inputProps={{ type: "password", minLength: 6, maxLength: 128 }}
/>
<Box>
<LoadingButton
variant={"contained"}
onClick={onSetup}
loading={loading}
disabled={password.length < 6}
startIcon={<ShieldLock />}
>
<span>{t("vault.enable")}</span>
</LoadingButton>
</Box>
</Stack>
)}
{setting.vault_enabled && (
<Box sx={{ mt: 1 }}>
<Stack direction={"row"} spacing={1}>
{setting.vault_unlocked && (
<SecondaryButton variant={"contained"} onClick={onLock} disabled={loading} startIcon={<LockClosed />}>
{t("vault.lockNow")}
</SecondaryButton>
)}
<SecondaryButton
variant={"contained"}
color={"error"}
onClick={() => setShowDisable(!showDisable)}
startIcon={<Dismiss />}
>
{t("vault.disable")}
</SecondaryButton>
</Stack>
<Collapse in={showDisable} unmountOnExit>
<Stack spacing={2} sx={{ mt: 2 }}>
<Typography variant={"body2"} color={"text.secondary"}>
{t("vault.disableDescription")}
</Typography>
<DenseFilledTextField
required
label={t("vault.confirmPassword")}
fullWidth
value={disablePassword}
onChange={(e) => setDisablePassword(e.target.value)}
inputProps={{ type: "password" }}
/>
<Box>
<LoadingButton
variant={"contained"}
color={"error"}
onClick={onDisable}
loading={loading}
disabled={disablePassword == ""}
>
<span>{t("vault.confirmDisable")}</span>
</LoadingButton>
</Box>
</Stack>
</Collapse>
</Box>
)}
</SettingForm>
);
};
const SecuritySetting = ({ setting, setSetting }: ProfileSettingProps) => {
const { t } = useTranslation();
const theme = useTheme();
@ -451,6 +617,7 @@ const SecuritySetting = ({ setting, setSetting }: ProfileSettingProps) => {
)}
</SettingForm>
)}
<VaultSetting setting={setting} setSetting={setSetting} />
<Enable2FADialog open={enable2FAOpen} onClose={() => setEnable2FAOpen(false)} on2FAEnabled={on2FAChange(true)} />
<Disable2FADialog
open={disable2FAOpen}

@ -1,5 +1,5 @@
import dayjs from "dayjs";
import { getFileInfo, getFileList, getUserCapacity, sendPatchViewSync } from "../../api/api.ts";
import { getFileInfo, getFileList, getUserCapacity, sendPatchViewSync, sendVaultUnlock } from "../../api/api.ts";
import { ExplorerView, FileResponse, FileType, ListResponse, Metadata } from "../../api/explorer.ts";
import { getActionOpt } from "../../component/FileManager/ContextMenu/useActionDisplayOpt.ts";
import { ListViewColumnSetting } from "../../component/FileManager/Explorer/ListView/Column.tsx";
@ -503,6 +503,15 @@ export function retrySharePassword(index: number, password: string): AppThunk {
};
}
// unlockVault verifies the private-space password and retries the current
// listing once the unlock session is opened server-side.
export function unlockVault(index: number, password: string): AppThunk<Promise<void>> {
return async (dispatch, _getState) => {
await dispatch(sendVaultUnlock(password));
dispatch(refreshFileList(index));
};
}
export function searchMetadata(
index: number,
metaKey: string,

@ -94,6 +94,10 @@ type (
UpdateNickname(ctx context.Context, u *ent.User, name string) (*ent.User, error)
// UpdatePassword updates user password.
UpdatePassword(ctx context.Context, u *ent.User, newPassword string) (*ent.User, error)
// UpdateVault sets or clears the user's private-space credential and
// root folder ID. passwordDigest must be produced by DigestPassword;
// pass an empty digest and folderID 0 to disable the vault.
UpdateVault(ctx context.Context, u *ent.User, passwordDigest string, folderID int) (*ent.User, error)
// UpdateTwoFASecret updates user two factor secret.
UpdateTwoFASecret(ctx context.Context, u *ent.User, secret string) (*ent.User, error)
// ListPasskeys list user's passkeys.
@ -191,6 +195,16 @@ func (c *userClient) UpdatePassword(ctx context.Context, u *ent.User, newPasswor
return c.client.User.UpdateOne(u).SetPassword(digest).Save(ctx)
}
func (c *userClient) UpdateVault(ctx context.Context, u *ent.User, passwordDigest string, folderID int) (*ent.User, error) {
stm := c.client.User.UpdateOne(u).SetVaultFolder(folderID)
if passwordDigest == "" {
stm = stm.ClearVaultPassword()
} else {
stm = stm.SetVaultPassword(passwordDigest)
}
return stm.Save(ctx)
}
func (c *userClient) SetClient(newClient *ent.Client) TxOperator {
return &userClient{client: newClient}
}
@ -690,6 +704,13 @@ func IsAnonymousUser(u *ent.User) bool {
return u.ID == 0
}
// CheckVaultPassword verifies the private-space password against the vault
// digest stored on the user record. Returns ErrorIncorrectPassword on
// mismatch, same contract as CheckPassword.
func CheckVaultPassword(u *ent.User, password string) error {
return CheckPassword(&ent.User{Password: u.VaultPassword}, password)
}
// CheckPassword 根据明文校验密码
func CheckPassword(u *ent.User, password string) error {
// 根据存储密码拆分为 Salt 和 Digest
@ -748,6 +769,13 @@ func withUserEagerLoading(ctx context.Context, q *ent.UserQuery) *ent.UserQuery
return q
}
// DigestPassword returns the salt:digest store format used for account and
// private-space passwords. Exported for credential writers outside this
// package; verification goes through CheckPassword.
func DigestPassword(password string) (string, error) {
return digestPassword(password)
}
func digestPassword(password string) (string, error) {
//生成16位 Salt
salt := util.RandStringRunesCrypto(32)

@ -993,7 +993,9 @@ func (f *DBFS) getNavigator(ctx context.Context, path *fs.URI, requiredCapabilit
}
}
return res, nil
// 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 {

@ -768,6 +768,14 @@ func (f *DBFS) GetFileFromDirectLink(ctx context.Context, dl *ent.DirectLink) (f
return nil, serializer.NewError(serializer.CodeNotFound, "direct link not found", err)
}
// Files inside a private space are never served through direct links;
// there is no session to carry an unlock state.
if inside, err := f.fileInVault(ctx, file, f.vaultRootID(ctx, fileModel.OwnerID)); err != nil {
return nil, err
} else if inside {
return nil, fs.ErrDirectLinkInvalid
}
return file, nil
}
@ -799,6 +807,10 @@ func (f *DBFS) TraverseFile(ctx context.Context, fileID int) (fs.File, error) {
rootUri = newTrashUri(root.Name())
}
if err := f.requireVaultAccess(ctx, file); err != nil {
return nil, err
}
navigator, err := f.getNavigator(ctx, rootUri)
if err != nil {
return nil, fmt.Errorf("failed to get navigator for root file: %w", err)

@ -52,6 +52,7 @@ var (
ErrFileCountLimitedReached = serializer.NewError(serializer.CodeFileCountLimitedReached, "Walked file count reached limit", nil)
ErrSymbolicFolderFound = serializer.NewError(serializer.CodeNoPermissionErr, "Symbolic folder cannot be walked into", nil)
ErrLoginRequired = serializer.NewError(serializer.CodeCheckLogin, "Login required", nil)
ErrVaultLocked = serializer.NewError(serializer.CodeVaultLocked, "Private space is locked", nil)
fullOrderByOption = []string{"name", "size", "updated_at", "created_at"}
searchLimitedOrderByOption = []string{"created_at"}

@ -30,7 +30,7 @@ type dedupSettingProvider struct {
}
func (p dedupSettingProvider) DBFS(context.Context) *setting.DBFS {
return &setting.DBFS{DedupScope: p.scope, MaxPageSize: 200}
return &setting.DBFS{DedupScope: p.scope, MaxPageSize: 200, MaxRecursiveSearchedFolder: 100}
}
func (p dedupSettingProvider) AuditLogEnabled(context.Context, int) bool {

@ -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)
}

@ -66,6 +66,10 @@ type (
SharedAddressTranslation(ctx context.Context, path *URI, opts ...Option) (File, *URI, error)
// ExecuteNavigatorHooks executes hooks of given type on a file for navigator based custom hooks.
ExecuteNavigatorHooks(ctx context.Context, hookType HookType, file File) error
// IsInPrivateSpace reports whether the file sits inside its owner's
// private space. Used to reject operations that must never apply to
// vaulted files regardless of the unlock state (e.g. sharing).
IsInPrivateSpace(ctx context.Context, file File) (bool, error)
}
FileManager interface {

@ -333,6 +333,14 @@ func (l *manager) CreateOrUpdateShare(ctx context.Context, paths []*fs.URI, args
return nil, serializer.NewError(serializer.CodeNoPermissionErr, "cannot share symbolic file", nil)
}
// Files in the owner's private space are never shareable, even while
// the vault is unlocked.
if inside, err := l.fs.IsInPrivateSpace(ctx, file); err != nil {
return nil, serializer.NewError(serializer.CodeDBError, "failed to check private space", err)
} else if inside {
return nil, serializer.NewError(serializer.CodeNoPermissionErr, "files in private space cannot be shared", nil)
}
if _, ok := seen[file.ID()]; !ok {
seen[file.ID()] = struct{}{}
files = append(files, file)

@ -263,6 +263,9 @@ const (
CodeInvitationCodeRequired = 40091
// CodeInvitationCodeInvalid 邀请码无效、过期或已用完
CodeInvitationCodeInvalid = 40092
// CodeVaultLocked 私密空间未解锁
CodeVaultLocked = 40093
// CodeDBError 数据库操作失败
CodeDBError = 50001
// CodeEncryptError 加密失败

@ -382,6 +382,50 @@ func UserUnbindSso(c *gin.Context) {
c.JSON(200, serializer.Response{})
}
// UserVaultSetup enables the caller's private space.
func UserVaultSetup(c *gin.Context) {
service := ParametersFromContext[*user.VaultSetupService](c, user.VaultSetupParameterCtx{})
err := service.Setup(c)
if respondErr(c, err) {
return
}
c.JSON(200, serializer.Response{})
}
// UserVaultUnlock opens the caller's private-space unlock session.
func UserVaultUnlock(c *gin.Context) {
service := ParametersFromContext[*user.VaultUnlockService](c, user.VaultUnlockParameterCtx{})
err := service.Unlock(c)
if respondErr(c, err) {
return
}
c.JSON(200, serializer.Response{})
}
// UserVaultLock closes the caller's private-space unlock session.
func UserVaultLock(c *gin.Context) {
service := ParametersFromContext[*user.VaultUnlockService](c, user.VaultUnlockParameterCtx{})
err := service.Lock(c)
if respondErr(c, err) {
return
}
c.JSON(200, serializer.Response{})
}
// UserVaultDisable turns off the caller's private space.
func UserVaultDisable(c *gin.Context) {
service := ParametersFromContext[*user.VaultDisableService](c, user.VaultDisableParameterCtx{})
err := service.Disable(c)
if respondErr(c, err) {
return
}
c.JSON(200, serializer.Response{})
}
// UserSSOExchange trades the one-time ticket for a session token pair.
func UserSSOExchange(c *gin.Context) {
service := ParametersFromContext[*user.SSOExchangeService](c, user.SSOExchangeParameterCtx{})

@ -1556,6 +1556,36 @@ func initMasterRouter(dep dependency.Dep) *gin.Engine {
)
}
// 私密空间
vault := user.Group("vault")
{
// 启用私密空间
vault.POST("",
middleware.RequiredScopes(types.ScopeUserSecurityInfoWrite),
controllers.FromJSON[usersvc.VaultSetupService](usersvc.VaultSetupParameterCtx{}),
controllers.UserVaultSetup,
)
// 解锁私密空间
vault.PUT("unlock",
middleware.RequiredScopes(types.ScopeUserSecurityInfoWrite),
middleware.RateLimitByIP("vault_unlock", 10, time.Hour),
controllers.FromJSON[usersvc.VaultUnlockService](usersvc.VaultUnlockParameterCtx{}),
controllers.UserVaultUnlock,
)
// 立即锁定私密空间
vault.DELETE("unlock",
middleware.RequiredScopes(types.ScopeUserSecurityInfoWrite),
controllers.FromJSON[usersvc.VaultUnlockService](usersvc.VaultUnlockParameterCtx{}),
controllers.UserVaultLock,
)
// 关闭私密空间
vault.DELETE("",
middleware.RequiredScopes(types.ScopeUserSecurityInfoWrite),
controllers.FromJSON[usersvc.VaultDisableService](usersvc.VaultDisableParameterCtx{}),
controllers.UserVaultDisable,
)
}
// 积分与兑换
credit := user.Group("credit")
{

@ -58,6 +58,10 @@ func TestMasterRouteWiring(t *testing.T) {
"GET /api/v4/session/qq/login",
"GET /api/v4/session/qq/callback",
"DELETE /api/v4/user/setting/sso_binding/:provider",
"POST /api/v4/user/vault",
"PUT /api/v4/user/vault/unlock",
"DELETE /api/v4/user/vault/unlock",
"DELETE /api/v4/user/vault",
"GET /f/:id/:name",
}
for _, e := range expected {

@ -38,6 +38,10 @@ type UserSettings struct {
PreferredPolicy string `json:"preferred_policy,omitempty"`
OAuthGrants []OauthGrant `json:"oauth_grants,omitempty"`
LinkedAccounts []LinkedAccount `json:"linked_accounts,omitempty"`
// VaultEnabled reports whether the private space is set up.
VaultEnabled bool `json:"vault_enabled"`
// VaultUnlocked reports whether an unlock session is currently active.
VaultUnlocked bool `json:"vault_unlocked"`
}
// LinkedAccount is an external identity bound to the local account
@ -68,6 +72,7 @@ func BuildUserSettings(u *ent.User, passkeys []*ent.Passkey, parser *uaparser.Pa
LinkedAccounts: lo.Map(bindings, func(item *ent.SsoBinding, index int) LinkedAccount {
return LinkedAccount{Provider: item.Provider, CreatedAt: item.CreatedAt}
}),
VaultEnabled: u.VaultFolder > 0,
}
}

@ -10,6 +10,7 @@ import (
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"github.com/cloudreve/Cloudreve/v4/application/dependency"
@ -18,6 +19,7 @@ import (
"github.com/cloudreve/Cloudreve/v4/inventory/types"
"github.com/cloudreve/Cloudreve/v4/pkg/activity"
"github.com/cloudreve/Cloudreve/v4/pkg/auth"
"github.com/cloudreve/Cloudreve/v4/pkg/filemanager/fs/dbfs"
"github.com/cloudreve/Cloudreve/v4/pkg/hashid"
"github.com/cloudreve/Cloudreve/v4/pkg/request"
"github.com/cloudreve/Cloudreve/v4/pkg/serializer"
@ -149,6 +151,9 @@ func GetUserSettings(c *gin.Context) (*UserSettings, error) {
if u.Settings.PreferredPolicy > 0 {
res.PreferredPolicy = hashid.EncodePolicyID(dep.HashIDEncoder(), u.Settings.PreferredPolicy)
}
if res.VaultEnabled {
_, res.VaultUnlocked = dep.KV().Get(dbfs.VaultUnlockCachePrefix + strconv.Itoa(u.ID))
}
return res, nil
// 用户组有效期

@ -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…
Cancel
Save