Merge remote-tracking branch 'origin/master' into feat/saved-share-links

# Conflicts:
#	ROADMAP.md
#	frontend/src/redux/thunks/share.ts
pull/3587/head
Tomas Dvorak 2 weeks ago
commit 05e8781b7b

@ -210,8 +210,11 @@ Order = user-visible value first; each ships with backend + UI + tests.
- [ ] #200 (upstream #3586) — Pro crash on SIGHUP; log shows a clean signal-driven shutdown, no stack trace — watch for a CE repro, likely not actionable yet
- [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] Download URL shuffling (#173) — `download_cdn_shuffle` distributes generated download URLs randomly across the site URL + `download_cdn_routes` endpoints (`setting.DownloadURLBase`, honors `UseFirstSiteUrl`); covers entity downloads, archive sessions, and redirect-type direct links; manual route picker hidden client-side while active
- [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)
- [x] Saved share links (#147) — `POST /file/create` accepts `type: share` + `share_id`/`share_password`, materializing a symbolic shortcut (`sys:shared_redirect`) that lists under My Files and Shared with me; "Save to my files" in the share popover + "Save share link" dialog on /shares
- [x] Share `hide_readme` option (upstream #2729 item 6) — `ShareProps.HideReadMe` (only meaningful with `ShowReadMe`); share navigator filters `README.md`/`README.txt` (case-insensitive) from listings while direct-path resolution stays open for the readme viewer; `detectReadMe` URI fallback now probes unconditionally; owner-only `hide_readme` on share responses; Share dialog nested checkbox
- [x] 2FA recovery codes (upstream #2729 item 3) — `user.two_factor_backup_codes` sensitive JSON of `salt:sha256` digests; `PUT /user/setting/2fa/backup` regenerates 10 one-time codes behind a valid TOTP (rate-limited 5/h); `Verify2FA` falls back to single-use code consumption on TOTP failure; codes invalidated on secret rotation/disable; login phase gains a recovery-code input mode; security settings show remaining count + regenerate dialog
## 6. Phase D — desktop, all platforms

File diff suppressed because one or more lines are too long

@ -789,6 +789,7 @@ var (
{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: "two_factor_backup_codes", Type: field.TypeJSON, Nullable: true},
{Name: "avatar", Type: field.TypeString, Nullable: true},
{Name: "settings", Type: field.TypeJSON, Nullable: true},
{Name: "group_users", Type: field.TypeInt},
@ -801,7 +802,7 @@ var (
ForeignKeys: []*schema.ForeignKey{
{
Symbol: "users_groups_users",
Columns: []*schema.Column{UsersColumns[18]},
Columns: []*schema.Column{UsersColumns[19]},
RefColumns: []*schema.Column{GroupsColumns[0]},
OnDelete: schema.NoAction,
},

@ -24343,74 +24343,76 @@ func (m *TaskMutation) ResetEdge(name string) error {
// UserMutation represents an operation that mutates the User nodes in the graph.
type UserMutation struct {
config
op Op
typ string
id *int
created_at *time.Time
updated_at *time.Time
deleted_at *time.Time
email *string
nick *string
password *string
status *user.Status
ban_expires *time.Time
ban_reason *string
last_login *time.Time
storage *int64
addstorage *int64
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{}
group *int
clearedgroup bool
files map[int]struct{}
removedfiles map[int]struct{}
clearedfiles bool
dav_accounts map[int]struct{}
removeddav_accounts map[int]struct{}
cleareddav_accounts bool
shares map[int]struct{}
removedshares map[int]struct{}
clearedshares bool
passkey map[int]struct{}
removedpasskey map[int]struct{}
clearedpasskey bool
tasks map[int]struct{}
removedtasks map[int]struct{}
clearedtasks bool
fsevents map[int]struct{}
removedfsevents map[int]struct{}
clearedfsevents bool
entities map[int]struct{}
removedentities map[int]struct{}
clearedentities bool
oauth_grants map[int]struct{}
removedoauth_grants map[int]struct{}
clearedoauth_grants bool
credit_txns map[int]struct{}
removedcredit_txns map[int]struct{}
clearedcredit_txns bool
redeemed_codes map[int]struct{}
removedredeemed_codes map[int]struct{}
clearedredeemed_codes bool
grants map[int]struct{}
removedgrants map[int]struct{}
clearedgrants bool
share_purchases map[int]struct{}
removedshare_purchases map[int]struct{}
clearedshare_purchases bool
sso_bindings map[int]struct{}
removedsso_bindings map[int]struct{}
clearedsso_bindings bool
done bool
oldValue func(context.Context) (*User, error)
predicates []predicate.User
op Op
typ string
id *int
created_at *time.Time
updated_at *time.Time
deleted_at *time.Time
email *string
nick *string
password *string
status *user.Status
ban_expires *time.Time
ban_reason *string
last_login *time.Time
storage *int64
addstorage *int64
credits *int64
addcredits *int64
two_factor_secret *string
vault_password *string
vault_folder *int
addvault_folder *int
two_factor_backup_codes *[]string
appendtwo_factor_backup_codes []string
avatar *string
settings **types.UserSetting
clearedFields map[string]struct{}
group *int
clearedgroup bool
files map[int]struct{}
removedfiles map[int]struct{}
clearedfiles bool
dav_accounts map[int]struct{}
removeddav_accounts map[int]struct{}
cleareddav_accounts bool
shares map[int]struct{}
removedshares map[int]struct{}
clearedshares bool
passkey map[int]struct{}
removedpasskey map[int]struct{}
clearedpasskey bool
tasks map[int]struct{}
removedtasks map[int]struct{}
clearedtasks bool
fsevents map[int]struct{}
removedfsevents map[int]struct{}
clearedfsevents bool
entities map[int]struct{}
removedentities map[int]struct{}
clearedentities bool
oauth_grants map[int]struct{}
removedoauth_grants map[int]struct{}
clearedoauth_grants bool
credit_txns map[int]struct{}
removedcredit_txns map[int]struct{}
clearedcredit_txns bool
redeemed_codes map[int]struct{}
removedredeemed_codes map[int]struct{}
clearedredeemed_codes bool
grants map[int]struct{}
removedgrants map[int]struct{}
clearedgrants bool
share_purchases map[int]struct{}
removedshare_purchases map[int]struct{}
clearedshare_purchases bool
sso_bindings map[int]struct{}
removedsso_bindings map[int]struct{}
clearedsso_bindings bool
done bool
oldValue func(context.Context) (*User, error)
predicates []predicate.User
}
var _ ent.Mutation = (*UserMutation)(nil)
@ -25216,6 +25218,71 @@ func (m *UserMutation) ResetVaultFolder() {
delete(m.clearedFields, user.FieldVaultFolder)
}
// SetTwoFactorBackupCodes sets the "two_factor_backup_codes" field.
func (m *UserMutation) SetTwoFactorBackupCodes(s []string) {
m.two_factor_backup_codes = &s
m.appendtwo_factor_backup_codes = nil
}
// TwoFactorBackupCodes returns the value of the "two_factor_backup_codes" field in the mutation.
func (m *UserMutation) TwoFactorBackupCodes() (r []string, exists bool) {
v := m.two_factor_backup_codes
if v == nil {
return
}
return *v, true
}
// OldTwoFactorBackupCodes returns the old "two_factor_backup_codes" 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) OldTwoFactorBackupCodes(ctx context.Context) (v []string, err error) {
if !m.op.Is(OpUpdateOne) {
return v, errors.New("OldTwoFactorBackupCodes is only allowed on UpdateOne operations")
}
if m.id == nil || m.oldValue == nil {
return v, errors.New("OldTwoFactorBackupCodes requires an ID field in the mutation")
}
oldValue, err := m.oldValue(ctx)
if err != nil {
return v, fmt.Errorf("querying old value for OldTwoFactorBackupCodes: %w", err)
}
return oldValue.TwoFactorBackupCodes, nil
}
// AppendTwoFactorBackupCodes adds s to the "two_factor_backup_codes" field.
func (m *UserMutation) AppendTwoFactorBackupCodes(s []string) {
m.appendtwo_factor_backup_codes = append(m.appendtwo_factor_backup_codes, s...)
}
// AppendedTwoFactorBackupCodes returns the list of values that were appended to the "two_factor_backup_codes" field in this mutation.
func (m *UserMutation) AppendedTwoFactorBackupCodes() ([]string, bool) {
if len(m.appendtwo_factor_backup_codes) == 0 {
return nil, false
}
return m.appendtwo_factor_backup_codes, true
}
// ClearTwoFactorBackupCodes clears the value of the "two_factor_backup_codes" field.
func (m *UserMutation) ClearTwoFactorBackupCodes() {
m.two_factor_backup_codes = nil
m.appendtwo_factor_backup_codes = nil
m.clearedFields[user.FieldTwoFactorBackupCodes] = struct{}{}
}
// TwoFactorBackupCodesCleared returns if the "two_factor_backup_codes" field was cleared in this mutation.
func (m *UserMutation) TwoFactorBackupCodesCleared() bool {
_, ok := m.clearedFields[user.FieldTwoFactorBackupCodes]
return ok
}
// ResetTwoFactorBackupCodes resets all changes to the "two_factor_backup_codes" field.
func (m *UserMutation) ResetTwoFactorBackupCodes() {
m.two_factor_backup_codes = nil
m.appendtwo_factor_backup_codes = nil
delete(m.clearedFields, user.FieldTwoFactorBackupCodes)
}
// SetAvatar sets the "avatar" field.
func (m *UserMutation) SetAvatar(s string) {
m.avatar = &s
@ -26126,7 +26193,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, 18)
fields := make([]string, 0, 19)
if m.created_at != nil {
fields = append(fields, user.FieldCreatedAt)
}
@ -26172,6 +26239,9 @@ func (m *UserMutation) Fields() []string {
if m.vault_folder != nil {
fields = append(fields, user.FieldVaultFolder)
}
if m.two_factor_backup_codes != nil {
fields = append(fields, user.FieldTwoFactorBackupCodes)
}
if m.avatar != nil {
fields = append(fields, user.FieldAvatar)
}
@ -26219,6 +26289,8 @@ func (m *UserMutation) Field(name string) (ent.Value, bool) {
return m.VaultPassword()
case user.FieldVaultFolder:
return m.VaultFolder()
case user.FieldTwoFactorBackupCodes:
return m.TwoFactorBackupCodes()
case user.FieldAvatar:
return m.Avatar()
case user.FieldSettings:
@ -26264,6 +26336,8 @@ func (m *UserMutation) OldField(ctx context.Context, name string) (ent.Value, er
return m.OldVaultPassword(ctx)
case user.FieldVaultFolder:
return m.OldVaultFolder(ctx)
case user.FieldTwoFactorBackupCodes:
return m.OldTwoFactorBackupCodes(ctx)
case user.FieldAvatar:
return m.OldAvatar(ctx)
case user.FieldSettings:
@ -26384,6 +26458,13 @@ func (m *UserMutation) SetField(name string, value ent.Value) error {
}
m.SetVaultFolder(v)
return nil
case user.FieldTwoFactorBackupCodes:
v, ok := value.([]string)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
m.SetTwoFactorBackupCodes(v)
return nil
case user.FieldAvatar:
v, ok := value.(string)
if !ok {
@ -26498,6 +26579,9 @@ func (m *UserMutation) ClearedFields() []string {
if m.FieldCleared(user.FieldVaultFolder) {
fields = append(fields, user.FieldVaultFolder)
}
if m.FieldCleared(user.FieldTwoFactorBackupCodes) {
fields = append(fields, user.FieldTwoFactorBackupCodes)
}
if m.FieldCleared(user.FieldAvatar) {
fields = append(fields, user.FieldAvatar)
}
@ -26542,6 +26626,9 @@ func (m *UserMutation) ClearField(name string) error {
case user.FieldVaultFolder:
m.ClearVaultFolder()
return nil
case user.FieldTwoFactorBackupCodes:
m.ClearTwoFactorBackupCodes()
return nil
case user.FieldAvatar:
m.ClearAvatar()
return nil
@ -26601,6 +26688,9 @@ func (m *UserMutation) ResetField(name string) error {
case user.FieldVaultFolder:
m.ResetVaultFolder()
return nil
case user.FieldTwoFactorBackupCodes:
m.ResetTwoFactorBackupCodes()
return nil
case user.FieldAvatar:
m.ResetAvatar()
return nil

@ -690,7 +690,7 @@ func init() {
// 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[13].Descriptor()
userDescSettings := userFields[14].Descriptor()
// user.DefaultSettings holds the default value on creation for the settings field.
user.DefaultSettings = userDescSettings.Default.(*types.UserSetting)
usergrantMixin := schema.UserGrant{}.Mixin()

@ -55,6 +55,11 @@ func (User) Fields() []ent.Field {
field.Int("vault_folder").
Optional().
Default(0),
// two_factor_backup_codes holds salt:sha256 digests of one-time
// recovery codes, same store format as account passwords.
field.JSON("two_factor_backup_codes", []string{}).
Sensitive().
Optional(),
field.String("avatar").
Optional(),
field.JSON("settings", &types.UserSetting{}).

@ -50,6 +50,8 @@ type User struct {
VaultPassword string `json:"-"`
// VaultFolder holds the value of the "vault_folder" field.
VaultFolder int `json:"vault_folder,omitempty"`
// TwoFactorBackupCodes holds the value of the "two_factor_backup_codes" field.
TwoFactorBackupCodes []string `json:"-"`
// Avatar holds the value of the "avatar" field.
Avatar string `json:"avatar,omitempty"`
// Settings holds the value of the "settings" field.
@ -232,7 +234,7 @@ func (*User) scanValues(columns []string) ([]any, error) {
values := make([]any, len(columns))
for i := range columns {
switch columns[i] {
case user.FieldSettings:
case user.FieldTwoFactorBackupCodes, user.FieldSettings:
values[i] = new([]byte)
case user.FieldID, user.FieldStorage, user.FieldCredits, user.FieldVaultFolder, user.FieldGroupUsers:
values[i] = new(sql.NullInt64)
@ -354,6 +356,14 @@ func (u *User) assignValues(columns []string, values []any) error {
} else if value.Valid {
u.VaultFolder = int(value.Int64)
}
case user.FieldTwoFactorBackupCodes:
if value, ok := values[i].(*[]byte); !ok {
return fmt.Errorf("unexpected type %T for field two_factor_backup_codes", values[i])
} else if value != nil && len(*value) > 0 {
if err := json.Unmarshal(*value, &u.TwoFactorBackupCodes); err != nil {
return fmt.Errorf("unmarshal field two_factor_backup_codes: %w", err)
}
}
case user.FieldAvatar:
if value, ok := values[i].(*sql.NullString); !ok {
return fmt.Errorf("unexpected type %T for field avatar", values[i])
@ -528,6 +538,8 @@ func (u *User) String() string {
builder.WriteString("vault_folder=")
builder.WriteString(fmt.Sprintf("%v", u.VaultFolder))
builder.WriteString(", ")
builder.WriteString("two_factor_backup_codes=<sensitive>")
builder.WriteString(", ")
builder.WriteString("avatar=")
builder.WriteString(u.Avatar)
builder.WriteString(", ")

@ -47,6 +47,8 @@ const (
FieldVaultPassword = "vault_password"
// FieldVaultFolder holds the string denoting the vault_folder field in the database.
FieldVaultFolder = "vault_folder"
// FieldTwoFactorBackupCodes holds the string denoting the two_factor_backup_codes field in the database.
FieldTwoFactorBackupCodes = "two_factor_backup_codes"
// FieldAvatar holds the string denoting the avatar field in the database.
FieldAvatar = "avatar"
// FieldSettings holds the string denoting the settings field in the database.
@ -201,6 +203,7 @@ var Columns = []string{
FieldTwoFactorSecret,
FieldVaultPassword,
FieldVaultFolder,
FieldTwoFactorBackupCodes,
FieldAvatar,
FieldSettings,
FieldGroupUsers,

@ -945,6 +945,16 @@ func VaultFolderNotNil() predicate.User {
return predicate.User(sql.FieldNotNull(FieldVaultFolder))
}
// TwoFactorBackupCodesIsNil applies the IsNil predicate on the "two_factor_backup_codes" field.
func TwoFactorBackupCodesIsNil() predicate.User {
return predicate.User(sql.FieldIsNull(FieldTwoFactorBackupCodes))
}
// TwoFactorBackupCodesNotNil applies the NotNil predicate on the "two_factor_backup_codes" field.
func TwoFactorBackupCodesNotNil() predicate.User {
return predicate.User(sql.FieldNotNull(FieldTwoFactorBackupCodes))
}
// AvatarEQ applies the EQ predicate on the "avatar" field.
func AvatarEQ(v string) predicate.User {
return predicate.User(sql.FieldEQ(FieldAvatar, v))

@ -231,6 +231,12 @@ func (uc *UserCreate) SetNillableVaultFolder(i *int) *UserCreate {
return uc
}
// SetTwoFactorBackupCodes sets the "two_factor_backup_codes" field.
func (uc *UserCreate) SetTwoFactorBackupCodes(s []string) *UserCreate {
uc.mutation.SetTwoFactorBackupCodes(s)
return uc
}
// SetAvatar sets the "avatar" field.
func (uc *UserCreate) SetAvatar(s string) *UserCreate {
uc.mutation.SetAvatar(s)
@ -675,6 +681,10 @@ func (uc *UserCreate) createSpec() (*User, *sqlgraph.CreateSpec) {
_spec.SetField(user.FieldVaultFolder, field.TypeInt, value)
_node.VaultFolder = value
}
if value, ok := uc.mutation.TwoFactorBackupCodes(); ok {
_spec.SetField(user.FieldTwoFactorBackupCodes, field.TypeJSON, value)
_node.TwoFactorBackupCodes = value
}
if value, ok := uc.mutation.Avatar(); ok {
_spec.SetField(user.FieldAvatar, field.TypeString, value)
_node.Avatar = value
@ -1194,6 +1204,24 @@ func (u *UserUpsert) ClearVaultFolder() *UserUpsert {
return u
}
// SetTwoFactorBackupCodes sets the "two_factor_backup_codes" field.
func (u *UserUpsert) SetTwoFactorBackupCodes(v []string) *UserUpsert {
u.Set(user.FieldTwoFactorBackupCodes, v)
return u
}
// UpdateTwoFactorBackupCodes sets the "two_factor_backup_codes" field to the value that was provided on create.
func (u *UserUpsert) UpdateTwoFactorBackupCodes() *UserUpsert {
u.SetExcluded(user.FieldTwoFactorBackupCodes)
return u
}
// ClearTwoFactorBackupCodes clears the value of the "two_factor_backup_codes" field.
func (u *UserUpsert) ClearTwoFactorBackupCodes() *UserUpsert {
u.SetNull(user.FieldTwoFactorBackupCodes)
return u
}
// SetAvatar sets the "avatar" field.
func (u *UserUpsert) SetAvatar(v string) *UserUpsert {
u.Set(user.FieldAvatar, v)
@ -1560,6 +1588,27 @@ func (u *UserUpsertOne) ClearVaultFolder() *UserUpsertOne {
})
}
// SetTwoFactorBackupCodes sets the "two_factor_backup_codes" field.
func (u *UserUpsertOne) SetTwoFactorBackupCodes(v []string) *UserUpsertOne {
return u.Update(func(s *UserUpsert) {
s.SetTwoFactorBackupCodes(v)
})
}
// UpdateTwoFactorBackupCodes sets the "two_factor_backup_codes" field to the value that was provided on create.
func (u *UserUpsertOne) UpdateTwoFactorBackupCodes() *UserUpsertOne {
return u.Update(func(s *UserUpsert) {
s.UpdateTwoFactorBackupCodes()
})
}
// ClearTwoFactorBackupCodes clears the value of the "two_factor_backup_codes" field.
func (u *UserUpsertOne) ClearTwoFactorBackupCodes() *UserUpsertOne {
return u.Update(func(s *UserUpsert) {
s.ClearTwoFactorBackupCodes()
})
}
// SetAvatar sets the "avatar" field.
func (u *UserUpsertOne) SetAvatar(v string) *UserUpsertOne {
return u.Update(func(s *UserUpsert) {
@ -2105,6 +2154,27 @@ func (u *UserUpsertBulk) ClearVaultFolder() *UserUpsertBulk {
})
}
// SetTwoFactorBackupCodes sets the "two_factor_backup_codes" field.
func (u *UserUpsertBulk) SetTwoFactorBackupCodes(v []string) *UserUpsertBulk {
return u.Update(func(s *UserUpsert) {
s.SetTwoFactorBackupCodes(v)
})
}
// UpdateTwoFactorBackupCodes sets the "two_factor_backup_codes" field to the value that was provided on create.
func (u *UserUpsertBulk) UpdateTwoFactorBackupCodes() *UserUpsertBulk {
return u.Update(func(s *UserUpsert) {
s.UpdateTwoFactorBackupCodes()
})
}
// ClearTwoFactorBackupCodes clears the value of the "two_factor_backup_codes" field.
func (u *UserUpsertBulk) ClearTwoFactorBackupCodes() *UserUpsertBulk {
return u.Update(func(s *UserUpsert) {
s.ClearTwoFactorBackupCodes()
})
}
// SetAvatar sets the "avatar" field.
func (u *UserUpsertBulk) SetAvatar(v string) *UserUpsertBulk {
return u.Update(func(s *UserUpsert) {

@ -10,6 +10,7 @@ import (
"entgo.io/ent/dialect/sql"
"entgo.io/ent/dialect/sql/sqlgraph"
"entgo.io/ent/dialect/sql/sqljson"
"entgo.io/ent/schema/field"
"github.com/cloudreve/Cloudreve/v4/ent/credittxn"
"github.com/cloudreve/Cloudreve/v4/ent/davaccount"
@ -300,6 +301,24 @@ func (uu *UserUpdate) ClearVaultFolder() *UserUpdate {
return uu
}
// SetTwoFactorBackupCodes sets the "two_factor_backup_codes" field.
func (uu *UserUpdate) SetTwoFactorBackupCodes(s []string) *UserUpdate {
uu.mutation.SetTwoFactorBackupCodes(s)
return uu
}
// AppendTwoFactorBackupCodes appends s to the "two_factor_backup_codes" field.
func (uu *UserUpdate) AppendTwoFactorBackupCodes(s []string) *UserUpdate {
uu.mutation.AppendTwoFactorBackupCodes(s)
return uu
}
// ClearTwoFactorBackupCodes clears the value of the "two_factor_backup_codes" field.
func (uu *UserUpdate) ClearTwoFactorBackupCodes() *UserUpdate {
uu.mutation.ClearTwoFactorBackupCodes()
return uu
}
// SetAvatar sets the "avatar" field.
func (uu *UserUpdate) SetAvatar(s string) *UserUpdate {
uu.mutation.SetAvatar(s)
@ -988,6 +1007,17 @@ func (uu *UserUpdate) sqlSave(ctx context.Context) (n int, err error) {
if uu.mutation.VaultFolderCleared() {
_spec.ClearField(user.FieldVaultFolder, field.TypeInt)
}
if value, ok := uu.mutation.TwoFactorBackupCodes(); ok {
_spec.SetField(user.FieldTwoFactorBackupCodes, field.TypeJSON, value)
}
if value, ok := uu.mutation.AppendedTwoFactorBackupCodes(); ok {
_spec.AddModifier(func(u *sql.UpdateBuilder) {
sqljson.Append(u, user.FieldTwoFactorBackupCodes, value)
})
}
if uu.mutation.TwoFactorBackupCodesCleared() {
_spec.ClearField(user.FieldTwoFactorBackupCodes, field.TypeJSON)
}
if value, ok := uu.mutation.Avatar(); ok {
_spec.SetField(user.FieldAvatar, field.TypeString, value)
}
@ -1891,6 +1921,24 @@ func (uuo *UserUpdateOne) ClearVaultFolder() *UserUpdateOne {
return uuo
}
// SetTwoFactorBackupCodes sets the "two_factor_backup_codes" field.
func (uuo *UserUpdateOne) SetTwoFactorBackupCodes(s []string) *UserUpdateOne {
uuo.mutation.SetTwoFactorBackupCodes(s)
return uuo
}
// AppendTwoFactorBackupCodes appends s to the "two_factor_backup_codes" field.
func (uuo *UserUpdateOne) AppendTwoFactorBackupCodes(s []string) *UserUpdateOne {
uuo.mutation.AppendTwoFactorBackupCodes(s)
return uuo
}
// ClearTwoFactorBackupCodes clears the value of the "two_factor_backup_codes" field.
func (uuo *UserUpdateOne) ClearTwoFactorBackupCodes() *UserUpdateOne {
uuo.mutation.ClearTwoFactorBackupCodes()
return uuo
}
// SetAvatar sets the "avatar" field.
func (uuo *UserUpdateOne) SetAvatar(s string) *UserUpdateOne {
uuo.mutation.SetAvatar(s)
@ -2609,6 +2657,17 @@ func (uuo *UserUpdateOne) sqlSave(ctx context.Context) (_node *User, err error)
if uuo.mutation.VaultFolderCleared() {
_spec.ClearField(user.FieldVaultFolder, field.TypeInt)
}
if value, ok := uuo.mutation.TwoFactorBackupCodes(); ok {
_spec.SetField(user.FieldTwoFactorBackupCodes, field.TypeJSON, value)
}
if value, ok := uuo.mutation.AppendedTwoFactorBackupCodes(); ok {
_spec.AddModifier(func(u *sql.UpdateBuilder) {
sqljson.Append(u, user.FieldTwoFactorBackupCodes, value)
})
}
if uuo.mutation.TwoFactorBackupCodesCleared() {
_spec.ClearField(user.FieldTwoFactorBackupCodes, field.TypeJSON)
}
if value, ok := uuo.mutation.Avatar(); ok {
_spec.SetField(user.FieldAvatar, field.TypeString, value)
}

@ -70,7 +70,10 @@
"sso_token_invalid": "Failed to verify the identity response.",
"sso_no_email": "The identity provider did not return an email address.",
"sso_account_unavailable": "No account is available for this identity. Registration may be restricted."
}
},
"inputBackupCode": "Enter one of your recovery codes",
"useBackupCode": "Use a recovery code instead",
"use2FACode": "Use authenticator code instead"
},
"oauth": {
"authorize": "Authorize Application",
@ -688,7 +691,9 @@
"deleteViewSetting": "Delete view setting",
"shareTargets": "Share targets",
"shareTargetsCount_one": "{{count}} file selected",
"shareTargetsCount_other": "{{count}} files selected"
"shareTargetsCount_other": "{{count}} files selected",
"hideReadme": "Hide readme file",
"hideReadmeDes": "Keep the README file itself out of the share listing. Visitors still see the rendered readme."
},
"uploader": {
"fileCopyName": "Copy of ",
@ -1153,7 +1158,11 @@
"deleteTagDes": "Remove tag \"{{name}}\" from {{count}} file(s)? The files themselves are kept.",
"tagUpdated": "Tag updated",
"tagDeleted": "Tag deleted",
"tagNameInvalid": "Tag name cannot be empty or contain \":\""
"tagNameInvalid": "Tag name cannot be empty or contain \":\"",
"backup2FACodes": "Recovery codes",
"backupCodesDes": "Recovery codes let you sign in when your authenticator is unavailable. Generating a new batch invalidates all previous codes.",
"backupCodesWarning": "Save these codes now. Each can be used once and they will not be shown again.",
"copyCodes": "Copy codes"
},
"vas": {
"points": "Points",

@ -854,6 +854,8 @@
"ssoRegisterEnabledDes": "Automatically create a local account when a user signs in via SSO for the first time. The sign-up email filter below also applies.",
"downloadCdnRoutes": "Download CDN routes",
"downloadCdnRoutesDes": "Alternative download endpoints offered to users, one per line in name=url format (e.g. Line 1=https://cdn1.example.com). Routes must proxy the full request path and query back to this site so signed URLs stay valid; CORS headers are required for in-browser downloads.",
"downloadCdnShuffle": "Shuffle download routes",
"downloadCdnShuffleDes": "Randomly distribute each generated download link across the site URL and all configured CDN routes, so batch exports to download managers hit different endpoints. The manual route picker is hidden while enabled.",
"ssoAutoRedirect": "Auto redirect to SSO",
"ssoAutoRedirectDes": "Skip the login form and send visitors straight to the identity provider. Append <0>?nosso=1</0> to the login URL to reach the password form (e.g. for admin recovery).",
"ssoCallbackUrl": "Callback URL",

@ -70,7 +70,10 @@
"sso_token_invalid": "身份响应验证失败。",
"sso_no_email": "身份提供方未返回邮箱地址。",
"sso_account_unavailable": "此身份没有可用账号,注册可能受限。"
}
},
"inputBackupCode": "请输入您的备用恢复代码",
"useBackupCode": "改用备用恢复代码",
"use2FACode": "改用验证器验证码"
},
"oauth": {
"authorize": "授权应用",
@ -688,7 +691,9 @@
"deleteViewSetting": "删除视图设置",
"shareTargets": "分享对象",
"shareTargetsCount_one": "已选择 {{count}} 个项目",
"shareTargetsCount_other": "已选择 {{count}} 个项目"
"shareTargetsCount_other": "已选择 {{count}} 个项目",
"hideReadme": "隐藏 README 文件",
"hideReadmeDes": "在分享文件列表中隐藏 README 文件本身,访客仍可看到渲染后的说明内容。"
},
"uploader": {
"fileCopyName": "副本_",
@ -1153,7 +1158,11 @@
"deleteTagDes": "将标签「{{name}}」从 {{count}} 个文件中移除?文件本身不会被删除。",
"tagUpdated": "标签已更新",
"tagDeleted": "标签已删除",
"tagNameInvalid": "标签名不能为空或包含「:」"
"tagNameInvalid": "标签名不能为空或包含「:」",
"backup2FACodes": "备用恢复代码",
"backupCodesDes": "当验证器不可用时,可使用备用恢复代码登录。重新生成将使所有旧代码失效。",
"backupCodesWarning": "请立即保存这些代码。每个代码仅可使用一次,且不会再次显示。",
"copyCodes": "复制代码"
},
"vas": {
"points": "积分",

@ -854,6 +854,8 @@
"ssoRegisterEnabledDes": "用户首次通过 SSO 登录时自动创建本地账号。下方的注册邮箱过滤规则同样适用。",
"downloadCdnRoutes": "下载 CDN 线路",
"downloadCdnRoutesDes": "供用户选择的备用下载端点,每行一条,格式为 名称=URL(例如 线路1=https://cdn1.example.com)。线路需将完整的请求路径与查询串代理回本站以保证签名有效;浏览器内下载需要线路配置 CORS 头。",
"downloadCdnShuffle": "随机分发下载线路",
"downloadCdnShuffleDes": "启用后,每条生成的下载链接将随机分发到本站地址或任一已配置的 CDN 线路,批量导出到下载器时不同链接会命中不同端点。启用期间将隐藏手动选择线路。",
"ssoAutoRedirect": "自动跳转至 SSO",
"ssoAutoRedirectDes": "跳过登录表单,直接跳转至身份提供商。在登录地址后附加 <0>?nosso=1</0> 可进入密码登录表单(例如管理员账户恢复)。",
"ssoCallbackUrl": "回调地址",

@ -1519,6 +1519,23 @@ export function get2FAInitSecret(): ThunkResponse<string> {
};
}
export function regenerate2FABackupCodes(two_fa_code: string): ThunkResponse<string[]> {
return async (dispatch, _getState) => {
return await dispatch(
send(
`/user/setting/2fa/backup`,
{
method: "PUT",
data: { two_fa_code },
},
{
...defaultOpts,
},
),
);
};
}
export function sendPreparePasskeyRegistration(): ThunkResponse<PasskeyCredentialOption> {
return async (dispatch, _getState) => {
return await dispatch(

@ -90,6 +90,7 @@ export interface Share {
source_uri?: string;
password?: string;
show_readme?: boolean;
hide_readme?: boolean;
allow_upload?: boolean;
allow_edit?: boolean;
preview_only?: boolean;
@ -360,6 +361,7 @@ export interface ShareCreateService {
expire?: number;
share_view?: boolean;
show_readme?: boolean;
hide_readme?: boolean;
allow_upload?: boolean;
allow_edit?: boolean;
preview_only?: boolean;

@ -34,6 +34,7 @@ export interface SiteConfig {
sso_auto_redirect?: boolean;
qq_connect_enabled?: boolean;
download_cdn_routes?: { name: string; url: string }[];
download_cdn_shuffle?: boolean;
abuse_captcha?: boolean;
upload_dedup?: boolean;
allow_select_node?: boolean;

@ -142,6 +142,7 @@ export interface UserSettings {
version_retention_max?: number;
passwordless: boolean;
two_fa_enabled: boolean;
two_factor_backup_count?: number;
passkeys?: Passkey[];
disable_view_sync: boolean;
share_links_in_profile: ShareLinksInProfileLevel;

@ -165,6 +165,7 @@ const Settings = () => {
"siteDes",
"siteURL",
"download_cdn_routes",
"download_cdn_shuffle",
"siteScript",
"pwa_small_icon",
"pwa_medium_icon",

@ -65,6 +65,24 @@ const SiteInformation = () => {
<NoMarginHelperText>{t("settings.downloadCdnRoutesDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
<SettingForm lgWidth={5}>
<FormControl fullWidth>
<FormControlLabel
control={
<Switch
checked={isTrueVal(values.download_cdn_shuffle)}
onChange={(e) =>
setSettings({
download_cdn_shuffle: e.target.checked ? "1" : "0",
})
}
/>
}
label={t("settings.downloadCdnShuffle")}
/>
<NoMarginHelperText>{t("settings.downloadCdnShuffleDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
<SettingForm title={t("settings.customFooterHTML")} lgWidth={5}>
<FormControl fullWidth>
<DenseFilledTextField

@ -61,6 +61,7 @@ const shareToSetting = (share: ShareModel, t: TFunction): ShareSetting => {
use_custom_password: true,
share_view: share.share_view,
show_readme: share.show_readme,
hide_readme: share.hide_readme,
allow_upload: share.allow_upload,
allow_edit: share.allow_edit,
preview_only: share.preview_only,

@ -85,6 +85,7 @@ export interface ShareSetting {
password?: string;
share_view?: boolean;
show_readme?: boolean;
hide_readme?: boolean;
allow_upload?: boolean;
allow_edit?: boolean;
preview_only?: boolean;
@ -399,6 +400,16 @@ const ShareSettingContent = ({ setting, file, editing, onSettingChange }: ShareS
</AccordionSummary>
<AccordionDetails>
<Trans i18nKey="application:modals.showReadmeDes" components={[<Code />]} />
<StyledListItemButton disabled={!setting.show_readme}>
<ListItemText primary={t("application:modals.hideReadme")} secondary={t("application:modals.hideReadmeDes")} />
<ListItemSecondaryAction>
<Checkbox
checked={setting.show_readme && setting.hide_readme}
disabled={!setting.show_readme}
onChange={() => onSettingChange({ ...setting, hide_readme: !setting.hide_readme })}
/>
</ListItemSecondaryAction>
</StyledListItemButton>
</AccordionDetails>
</Accordion>
</>

@ -1,8 +1,8 @@
import { useTranslation } from "react-i18next";
import { useAppSelector } from "../../../../redux/hooks.ts";
import { Control } from "../Signin/SignIn.tsx";
import { FormControl, styled, Typography } from "@mui/material";
import { Button, FormControl, styled, TextField, Typography } from "@mui/material";
import { MuiOtpInput } from "mui-one-time-password-input";
import { useState } from "react";
interface Phase2FAProps {
control?: Control;
@ -20,19 +20,43 @@ const MuiOtpInputStyled = styled(MuiOtpInput)`
const Phase2FA = ({ control, otp, onOtpChange, loading }: Phase2FAProps) => {
const { t } = useTranslation();
const regEnabled = useAppSelector((state) => state.siteConfig.login.config.register_enabled);
const [useBackup, setUseBackup] = useState(false);
const toggleMode = () => {
setUseBackup((v) => !v);
onOtpChange("");
};
return (
<>
<Typography color={"text.secondary"}>{t("login.input2FACode")}</Typography>
<Typography color={"text.secondary"}>
{useBackup ? t("login.inputBackupCode") : t("login.input2FACode")}
</Typography>
<FormControl variant="standard" margin="normal" required fullWidth>
<MuiOtpInputStyled
TextFieldsProps={{ disabled: loading }}
autoFocus
length={6}
value={otp}
onChange={onOtpChange}
/>
{useBackup ? (
<TextField
variant="standard"
autoFocus
fullWidth
disabled={loading}
placeholder="xxxx-xxxx"
value={otp}
onChange={(e) => onOtpChange(e.target.value)}
slotProps={{ htmlInput: { style: { textAlign: "center", letterSpacing: 2 } } }}
/>
) : (
<MuiOtpInputStyled
TextFieldsProps={{ disabled: loading }}
autoFocus
length={6}
value={otp}
onChange={onOtpChange}
/>
)}
</FormControl>
<Button size="small" onClick={toggleMode} disabled={loading} sx={{ mb: 1 }}>
{useBackup ? t("login.use2FACode") : t("login.useBackupCode")}
</Button>
{control?.submit}
{control?.back}

@ -297,7 +297,9 @@ const EmailLogin = ({ oauthConsent }: SignInProps) => {
};
useEffect(() => {
if (otp.length === 6) {
// Auto-submit only a complete numeric TOTP; alphanumeric recovery
// codes are submitted via the button.
if (/^\d{6}$/.test(otp)) {
submit();
}
}, [otp]);

@ -0,0 +1,148 @@
import DraggableDialog from "../../../Dialogs/DraggableDialog.tsx";
import { useTranslation } from "react-i18next";
import { useSnackbar } from "notistack";
import { useAppDispatch } from "../../../../redux/hooks.ts";
import { useEffect, useState } from "react";
import { Box, DialogContent, FormControl, Grid2, IconButton, Stack, styled, Tooltip, Typography } from "@mui/material";
import { CSSTransition, SwitchTransition } from "react-transition-group";
import AutoHeight from "../../../Common/AutoHeight.tsx";
import FacebookCircularProgress from "../../../Common/CircularProgress.tsx";
import { regenerate2FABackupCodes } from "../../../../api/api.ts";
import { copyToClipboard } from "../../../../util";
import { MuiOtpInput } from "mui-one-time-password-input";
import CopyOutlined from "../../../Icons/CopyOutlined.tsx";
export interface Backup2FACodesDialogProps {
open?: boolean;
onClose: () => void;
onCodesRegenerated: (count: number) => void;
}
const MuiOtpInputStyled = styled(MuiOtpInput)`
display: flex;
gap: 8px;
max-width: 650px;
margin-inline: auto;
`;
const Backup2FACodesDialog = ({ open, onClose, onCodesRegenerated }: Backup2FACodesDialogProps) => {
const { t } = useTranslation();
const { enqueueSnackbar } = useSnackbar();
const dispatch = useAppDispatch();
const [loading, setLoading] = useState(false);
const [code, setCode] = useState("");
const [codes, setCodes] = useState<string[] | null>(null);
useEffect(() => {
if (open) {
setLoading(false);
setCode("");
setCodes(null);
}
}, [open]);
useEffect(() => {
if (code.length === 6 && !codes) {
setLoading(true);
dispatch(regenerate2FABackupCodes(code))
.then((res) => {
setCodes(res);
onCodesRegenerated(res.length);
})
.catch(() => {
setCode("");
})
.finally(() => {
setLoading(false);
});
}
}, [code]);
return (
<DraggableDialog
title={t("application:setting.backup2FACodes")}
showCancel
hideOk
showActions
dialogProps={{
open: !!open,
onClose: onClose,
fullWidth: true,
maxWidth: "xs",
}}
>
<DialogContent>
<AutoHeight>
<SwitchTransition>
<CSSTransition
addEndListener={(node, done) => node.addEventListener("transitionend", done, false)}
classNames="fade"
key={`${loading}-${codes != null}`}
>
<Box>
{loading && (
<Box
sx={{
pt: 3,
height: "100%",
display: "flex",
justifyContent: "center",
alignItems: "center",
}}
>
<FacebookCircularProgress />
</Box>
)}
{!loading && !codes && (
<Stack spacing={1}>
<Typography variant={"body2"}>{t("setting.backupCodesDes")}</Typography>
<Typography variant={"body2"}>{t("setting.inputCurrent2FACode")}</Typography>
<FormControl variant="standard" margin="normal" required>
<MuiOtpInputStyled
TextFieldsProps={{ disabled: loading }}
autoFocus
length={6}
value={code}
onChange={setCode}
/>
</FormControl>
</Stack>
)}
{!loading && codes && (
<Stack spacing={1}>
<Typography variant={"body2"} color="warning.main">
{t("setting.backupCodesWarning")}
</Typography>
<Grid2 container spacing={0.5} sx={{ fontFamily: "monospace" }}>
{codes.map((c) => (
<Grid2 key={c} size={6}>
{c}
</Grid2>
))}
</Grid2>
<Box sx={{ display: "flex", justifyContent: "flex-end" }}>
<Tooltip title={t("setting.copyCodes")}>
<IconButton
size="small"
onClick={() => {
copyToClipboard(codes.join("\n"));
enqueueSnackbar({ message: t("setting.copied"), variant: "success" });
}}
>
<CopyOutlined fontSize="small" />
</IconButton>
</Tooltip>
</Box>
</Stack>
)}
</Box>
</CSSTransition>
</SwitchTransition>
</AutoHeight>
</DialogContent>
</DraggableDialog>
);
};
export default Backup2FACodesDialog;

@ -42,10 +42,12 @@ 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 LockClosedKey from "../../../Icons/LockClosedKey.tsx";
import Open from "../../../Icons/Open.tsx";
import ShieldLock from "../../../Icons/ShieldLock.tsx";
import { ProfileSettingProps } from "../ProfileSetting.tsx";
import SettingForm from "../SettingForm.tsx";
import Backup2FACodesDialog from "./Backup2FACodesDialog.tsx";
import Disable2FADialog from "./Disable2FADialog.tsx";
import Enable2FADialog from "./Enable2FADialog.tsx";
import PasskeyList from "./PasskeyList.tsx";
@ -415,6 +417,7 @@ const SecuritySetting = ({ setting, setSetting }: ProfileSettingProps) => {
const [repeatPassword, setRepeatPassword] = useState("");
const [enable2FAOpen, setEnable2FAOpen] = useState(false);
const [disable2FAOpen, setDisable2FAOpen] = useState(false);
const [backup2FAOpen, setBackup2FAOpen] = useState(false);
const submitResetPassword = () => {
if (!resetPwdFormRef.current) {
@ -581,6 +584,17 @@ const SecuritySetting = ({ setting, setSetting }: ProfileSettingProps) => {
>
{t(`setting.${setting.two_fa_enabled ? "disable" : "enable"}2FA`)}
</SecondaryButton>
{setting.two_fa_enabled && (
<SecondaryButton
sx={{ mt: 1, ml: 1 }}
variant={"outlined"}
startIcon={<LockClosedKey />}
onClick={() => setBackup2FAOpen(true)}
>
{t("setting.backup2FACodes")}
{typeof setting.two_factor_backup_count === "number" && ` (${setting.two_factor_backup_count})`}
</SecondaryButton>
)}
</SettingForm>
{authEnabled && (
<SettingForm title={t("setting.hardwareAuthenticator")}>
@ -624,6 +638,11 @@ const SecuritySetting = ({ setting, setSetting }: ProfileSettingProps) => {
onClose={() => setDisable2FAOpen(false)}
on2FADisabled={on2FAChange(false)}
/>
<Backup2FACodesDialog
open={backup2FAOpen}
onClose={() => setBackup2FAOpen(false)}
onCodesRegenerated={(count) => setSetting({ ...setting, two_factor_backup_count: count })}
/>
</Stack>
);
};

@ -137,7 +137,14 @@ export function backendBatchDownload(files: FileResponse[]): AppThunk {
// signature stays valid through the CDN. Cancel falls back to direct.
export function pickDownloadRoute(url: string): AppThunk<Promise<string>> {
return async (dispatch, getState) => {
const routes = getState().siteConfig.basic.config.download_cdn_routes;
const config = getState().siteConfig.basic.config;
// With server-side shuffling the URL already points at a random
// endpoint; the manual picker would only confuse (#173).
if (config.download_cdn_shuffle) {
return url;
}
const routes = config.download_cdn_routes;
if (!routes || routes.length === 0) {
return url;
}

@ -2,7 +2,6 @@ import i18next from "i18next";
import { closeSnackbar, enqueueSnackbar, SnackbarKey } from "notistack";
import {
getFileInfo,
getFileList,
getShareInfo,
sendCreateFile,
sendCreateShare,
@ -11,7 +10,6 @@ import {
import { FileResponse, Share, ShareCreateService } from "../../api/explorer.ts";
import { DefaultCloseAction, OpenReadMeAction } from "../../component/Common/Snackbar/snackbar.tsx";
import { ShareSetting } from "../../component/FileManager/Dialogs/Share/ShareSetting.tsx";
import { getPaginationState } from "../../component/FileManager/Pagination/PaginationFooter.tsx";
import CrUri, { Filesystem } from "../../util/uri.ts";
import { fileUpdated } from "../fileManagerSlice.ts";
import {
@ -39,6 +37,7 @@ export function createOrUpdateShareLink(
password: setting.password,
share_view: setting.share_view,
show_readme: setting.show_readme,
hide_readme: setting.show_readme ? setting.hide_readme : false,
allow_upload: setting.allow_upload || setting.allow_edit,
allow_edit: setting.allow_edit,
preview_only: setting.preview_only,
@ -202,7 +201,7 @@ const supportedReadMeFiles = ["README.md", "README.txt"];
export function detectReadMe(index: number, isTablet: boolean): AppThunk<Promise<void>> {
return async (dispatch, getState) => {
const { files: list, pagination } = getState().fileManager[index]?.list ?? {};
const { files: list } = getState().fileManager[index]?.list ?? {};
if (list) {
// Find readme file from highest to lowest priority
for (const readmeFile of supportedReadMeFiles) {
@ -214,10 +213,11 @@ export function detectReadMe(index: number, isTablet: boolean): AppThunk<Promise
}
}
// Not found in current file list, try to get file directly
// Not found in current file list, try to get file directly. Always
// probe: the readme may be filtered out of the listing entirely
// (hide_readme) or live on a page we have not fetched yet.
const path = getState().fileManager[index]?.pure_path;
const hasMorePages = getPaginationState(pagination).moreItems;
if (path && hasMorePages) {
if (path) {
const uri = new CrUri(path);
for (const readmeFile of supportedReadMeFiles) {
try {

@ -572,6 +572,7 @@ var DefaultSettings = map[string]string{
"qq_connect_register_enabled": "1",
"upload_dedup_scope": "owner",
"download_cdn_routes": "",
"download_cdn_shuffle": "0",
"email_filter_mode": "0",
"email_filter_list": "",
"email_disable_subaddress": "0",

@ -0,0 +1,95 @@
package inventory
import (
"context"
"fmt"
"testing"
"github.com/cloudreve/Cloudreve/v4/ent"
"github.com/cloudreve/Cloudreve/v4/ent/enttest"
"github.com/cloudreve/Cloudreve/v4/pkg/boolset"
"github.com/stretchr/testify/require"
)
func backupCodeUser(t *testing.T, client *ent.Client) *ent.User {
t.Helper()
group := client.Group.Create().SetName("g").SetPermissions(&boolset.BooleanSet{}).SaveX(context.Background())
return client.User.Create().
SetEmail(fmt.Sprintf("%s@example.com", t.Name())).
SetNick("u").
SetGroup(group).
SetTwoFactorSecret("SECRET").
SaveX(context.Background())
}
func storeCodes(t *testing.T, c UserClient, u *ent.User, codes ...string) *ent.User {
t.Helper()
digests := make([]string, 0, len(codes))
for _, code := range codes {
d, err := DigestPassword(code)
require.NoError(t, err)
digests = append(digests, d)
}
u, err := c.UpdateTwoFABackupCodes(context.Background(), u, digests)
require.NoError(t, err)
return u
}
func TestTwoFABackupCodeConsume(t *testing.T) {
client := enttest.Open(t, "sqlite3", "file:"+t.Name()+"?mode=memory&cache=shared")
defer client.Close()
c := NewUserClient(client)
ctx := context.Background()
u := backupCodeUser(t, client)
u = storeCodes(t, c, u, "abcd1234", "wxyz5678")
require.Len(t, u.TwoFactorBackupCodes, 2)
// Wrong code consumes nothing.
ok, err := c.ConsumeTwoFABackupCode(ctx, u, "nope0000")
require.NoError(t, err)
require.False(t, ok)
// Correct code consumes exactly once; separators and case are normalized.
ok, err = c.ConsumeTwoFABackupCode(ctx, u, "ABCD-1234")
require.NoError(t, err)
require.True(t, ok)
u = client.User.GetX(ctx, u.ID)
require.Len(t, u.TwoFactorBackupCodes, 1)
// Replay is rejected.
ok, err = c.ConsumeTwoFABackupCode(ctx, u, "abcd1234")
require.NoError(t, err)
require.False(t, ok)
// Sibling code still works.
ok, err = c.ConsumeTwoFABackupCode(ctx, u, "wxyz5678")
require.NoError(t, err)
require.True(t, ok)
u = client.User.GetX(ctx, u.ID)
require.Empty(t, u.TwoFactorBackupCodes)
}
func TestTwoFABackupCodesClearedWithSecret(t *testing.T) {
client := enttest.Open(t, "sqlite3", "file:"+t.Name()+"?mode=memory&cache=shared")
defer client.Close()
c := NewUserClient(client)
ctx := context.Background()
u := backupCodeUser(t, client)
u = storeCodes(t, c, u, "abcd1234")
// Disabling 2FA invalidates outstanding recovery codes.
u, err := c.UpdateTwoFASecret(ctx, u, "")
require.NoError(t, err)
require.Empty(t, u.TwoFactorBackupCodes)
// Rotating the secret also invalidates them.
u = storeCodes(t, c, u, "wxyz5678")
u, err = c.UpdateTwoFASecret(ctx, u, "NEWSECRET")
require.NoError(t, err)
require.Equal(t, "NEWSECRET", u.TwoFactorSecret)
require.Empty(t, u.TwoFactorBackupCodes)
}

@ -287,6 +287,9 @@ type (
ShareView bool `json:"share_view,omitempty"`
// Whether to automatically show readme file in share view
ShowReadMe bool `json:"show_read_me,omitempty"`
// Whether to hide the readme file itself from the share listing
// (only meaningful together with ShowReadMe)
HideReadMe bool `json:"hide_readme,omitempty"`
// Whether share visitors can upload new files into the shared folder
AllowUpload bool `json:"allow_upload,omitempty"`
// Whether share visitors can rename, move and delete files (implies upload)

@ -100,6 +100,12 @@ type (
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)
// UpdateTwoFABackupCodes replaces the stored one-time recovery code
// digests (salt:digest store format, same as account passwords).
UpdateTwoFABackupCodes(ctx context.Context, u *ent.User, digests []string) (*ent.User, error)
// ConsumeTwoFABackupCode verifies a plaintext recovery code against the
// stored digests; on match the digest is removed and true is returned.
ConsumeTwoFABackupCode(ctx context.Context, u *ent.User, code string) (bool, error)
// ListPasskeys list user's passkeys.
ListPasskeys(ctx context.Context, uid int) ([]*ent.Passkey, error)
// AddPasskey add passkey to user.
@ -180,10 +186,55 @@ func (c *userClient) UpdateAvatar(ctx context.Context, u *ent.User, avatar strin
}
func (c *userClient) UpdateTwoFASecret(ctx context.Context, u *ent.User, secret string) (*ent.User, error) {
// Recovery codes are bound to the 2FA lifecycle: they are invalidated
// whenever the secret is cleared or rotated.
stm := c.client.User.UpdateOne(u).ClearTwoFactorBackupCodes()
if secret == "" {
return c.client.User.UpdateOne(u).ClearTwoFactorSecret().Save(ctx)
return stm.ClearTwoFactorSecret().Save(ctx)
}
return c.client.User.UpdateOne(u).SetTwoFactorSecret(secret).Save(ctx)
return stm.SetTwoFactorSecret(secret).Save(ctx)
}
func (c *userClient) UpdateTwoFABackupCodes(ctx context.Context, u *ent.User, digests []string) (*ent.User, error) {
if len(digests) == 0 {
return c.client.User.UpdateOne(u).ClearTwoFactorBackupCodes().Save(ctx)
}
return c.client.User.UpdateOne(u).SetTwoFactorBackupCodes(digests).Save(ctx)
}
func (c *userClient) ConsumeTwoFABackupCode(ctx context.Context, u *ent.User, code string) (bool, error) {
normalized := normalizeBackupCode(code)
if normalized == "" {
return false, nil
}
remaining := make([]string, 0, len(u.TwoFactorBackupCodes))
matched := false
for _, digest := range u.TwoFactorBackupCodes {
if !matched && CheckPassword(&ent.User{Password: digest}, normalized) == nil {
matched = true
continue
}
remaining = append(remaining, digest)
}
if !matched {
return false, nil
}
if _, err := c.UpdateTwoFABackupCodes(ctx, u, remaining); err != nil {
return false, err
}
return true, nil
}
// normalizeBackupCode strips separators and case so users can enter codes
// with or without the display hyphenation.
func normalizeBackupCode(code string) string {
return strings.ToLower(strings.Map(func(r rune) rune {
if r == '-' || r == ' ' {
return -1
}
return r
}, code))
}
func (c *userClient) UpdatePassword(ctx context.Context, u *ent.User, newPassword string) (*ent.User, error) {
@ -769,9 +820,10 @@ 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.
// DigestPassword returns the salt:digest store format used for account
// passwords, private-space passwords, and 2FA recovery codes. Exported for
// credential writers outside this package; verification goes through
// CheckPassword.
func DigestPassword(password string) (string, error) {
return digestPassword(password)
}

@ -3,6 +3,7 @@ package dbfs
import (
"context"
"fmt"
"strings"
"github.com/cloudreve/Cloudreve/v4/application/constants"
"github.com/cloudreve/Cloudreve/v4/ent"
@ -15,6 +16,7 @@ import (
"github.com/cloudreve/Cloudreve/v4/pkg/logging"
"github.com/cloudreve/Cloudreve/v4/pkg/serializer"
"github.com/cloudreve/Cloudreve/v4/pkg/setting"
"github.com/samber/lo"
)
var (
@ -367,7 +369,26 @@ func (n *shareNavigator) Children(ctx context.Context, parent *File, args *ListA
}, nil
}
return n.baseNavigator.children(ctx, parent, args)
res, err := n.baseNavigator.children(ctx, parent, args)
if err != nil {
return nil, err
}
// Shares with a rendered readme can keep the file itself out of the
// listing; it stays reachable by direct path for the readme viewer.
if n.share != nil && n.share.Props != nil && n.share.Props.ShowReadMe && n.share.Props.HideReadMe {
res.Files = lo.Filter(res.Files, func(f *File, _ int) bool {
return !readMeFileNames[strings.ToUpper(f.Name())]
})
}
return res, nil
}
// readMeFileNames mirrors the frontend's detection priority list; entries
// are uppercase for case-insensitive matching.
var readMeFileNames = map[string]bool{
"README.MD": true,
"README.TXT": true,
}
// linkSharedFile attaches a linked file of a multi-file share under the

@ -0,0 +1,94 @@
package dbfs
import (
"context"
"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/filemanager/fs"
"github.com/cloudreve/Cloudreve/v4/pkg/hashid"
"github.com/samber/lo"
"github.com/stretchr/testify/require"
)
// readmeShareFixture seeds an owner with a folder containing a README.md and
// a regular file, shared via a folder share with the given props.
func readmeShareFixture(t *testing.T, client *ent.Client, hasher hashid.Encoder, props *types.ShareProps) (*ent.User, *ent.Share) {
t.Helper()
ctx := context.Background()
policy := client.StoragePolicy.Create().SetName("local").SetType("local").SaveX(ctx)
permissions := &boolset.BooleanSet{}
boolset.Sets(map[types.GroupPermission]bool{
types.GroupPermissionShare: true,
types.GroupPermissionShareDownload: true,
}, permissions)
group := client.Group.Create().SetName("g").SetPermissions(permissions).
SetStoragePolicies(policy).SaveX(ctx)
owner := client.User.Create().SetEmail("owner@example.com").SetNick("o").SetGroup(group).SaveX(ctx)
ownerRoot := client.File.Create().SetName(inventory.RootFolderName).
SetType(int(types.FileTypeFolder)).SetOwner(owner).SaveX(ctx)
dir := client.File.Create().SetName("docs").SetType(int(types.FileTypeFolder)).
SetOwner(owner).SetParent(ownerRoot).SaveX(ctx)
client.File.Create().SetName("README.md").SetType(int(types.FileTypeFile)).
SetOwner(owner).SetParent(dir).SaveX(ctx)
client.File.Create().SetName("readme.txt").SetType(int(types.FileTypeFile)).
SetOwner(owner).SetParent(dir).SaveX(ctx)
client.File.Create().SetName("notes.txt").SetType(int(types.FileTypeFile)).
SetOwner(owner).SetParent(dir).SaveX(ctx)
share := client.Share.Create().SetUser(owner).SetFile(dir).SetProps(props).SaveX(ctx)
visitor := client.User.Create().SetEmail("visitor@example.com").SetNick("v").SetGroup(group).SaveX(ctx)
visitor.SetGroup(group)
return visitor, share
}
func TestShareHideReadMe(t *testing.T) {
newNav := func(t *testing.T, props *types.ShareProps) (Navigator, *fs.URI, *ent.Client) {
client := enttest.Open(t, "sqlite3", "file:"+t.Name()+"?mode=memory&cache=shared")
t.Cleanup(func() { require.NoError(t, client.Close()) })
hasher, err := hashid.New("seed-test-salt")
require.NoError(t, err)
visitor, share := readmeShareFixture(t, client, hasher, props)
nav := multiShareNavigator(t, client, hasher, visitor)
uri, err := fs.NewUriFromString(fs.NewShareUri(hashid.EncodeShareID(hasher, share.ID), ""))
require.NoError(t, err)
return nav, uri, client
}
names := func(t *testing.T, nav Navigator, uri *fs.URI) []string {
t.Helper()
root, err := nav.To(context.Background(), uri)
require.NoError(t, err)
res, err := nav.Children(context.Background(), root, &ListArgs{Page: &inventory.PaginationArgs{PageSize: 100}})
require.NoError(t, err)
return lo.Map(res.Files, func(f *File, _ int) string { return f.Name() })
}
t.Run("hidden when both props set", func(t *testing.T) {
nav, uri, _ := newNav(t, &types.ShareProps{ShowReadMe: true, HideReadMe: true})
require.ElementsMatch(t, []string{"notes.txt"}, names(t, nav, uri))
// Hidden files remain resolvable by path for the readme viewer.
f, err := nav.To(context.Background(), uri.Join("README.md"))
require.NoError(t, err)
require.Equal(t, "README.md", f.Name())
})
t.Run("listed without hide prop", func(t *testing.T) {
nav, uri, _ := newNav(t, &types.ShareProps{ShowReadMe: true})
require.ElementsMatch(t, []string{"README.md", "readme.txt", "notes.txt"}, names(t, nav, uri))
})
t.Run("listed without readme props", func(t *testing.T) {
nav, uri, _ := newNav(t, &types.ShareProps{})
require.ElementsMatch(t, []string{"README.md", "readme.txt", "notes.txt"}, names(t, nav, uri))
})
}

@ -612,7 +612,13 @@ func (f *entitySource) Url(ctx context.Context, opts ...EntitySourceOption) (*En
// 4. The entity is encrypted and internal proxy not disabled by option
handlerCapability := f.handler.Capabilities()
if f.ShouldInternalProxy() {
// Download URLs may be distributed across the site URL and
// configured CDN routes (#173); preview/thumb URLs stay on the
// resolved site URL to avoid cross-origin viewer breakage.
siteUrl := f.settings.SiteURL(ctx)
if f.o.IsDownload {
siteUrl = f.settings.DownloadURLBase(ctx)
}
base := routes.MasterFileContentUrl(
siteUrl,
hashid.EncodeEntityID(f.hasher, f.e.ID()),

@ -124,6 +124,7 @@ type (
Expire *time.Time
ShareView bool
ShowReadMe bool
HideReadMe bool
AllowUpload bool
AllowEdit bool
PreviewOnly bool

@ -381,6 +381,7 @@ func (l *manager) CreateOrUpdateShare(ctx context.Context, paths []*fs.URI, args
props := &types.ShareProps{
ShareView: args.ShareView,
ShowReadMe: args.ShowReadMe,
HideReadMe: args.HideReadMe && args.ShowReadMe,
AllowUpload: args.AllowUpload || args.AllowEdit,
AllowEdit: args.AllowEdit,
PreviewOnly: args.PreviewOnly,

@ -5,6 +5,7 @@ import (
"encoding/base64"
"encoding/json"
"fmt"
"math/rand/v2"
"net/url"
"sort"
"strconv"
@ -260,6 +261,15 @@ type (
// DownloadCDNRoutes returns the configured alternative download
// endpoints users can pick from (e.g. CDN mirrors of the site).
DownloadCDNRoutes(ctx context.Context) []CDNRoute
// DownloadCDNShuffle returns true if generated download URLs should be
// distributed randomly across the site URL and all CDN routes.
DownloadCDNShuffle(ctx context.Context) bool
// DownloadURLBase returns the base URL used when generating file
// download URLs. With `download_cdn_shuffle` enabled and CDN routes
// configured, an endpoint is picked uniformly at random from the
// primary site URL plus all routes; otherwise it falls back to
// SiteURL. A context pinned by UseFirstSiteUrl always returns SiteURL.
DownloadURLBase(ctx context.Context) *url.URL
// AuditLogEnabled returns true if the given audit event type is
// recorded. An empty/unset list records everything.
AuditLogEnabled(ctx context.Context, eventType int) bool
@ -1064,6 +1074,32 @@ func (s *settingProvider) DownloadCDNRoutes(ctx context.Context) []CDNRoute {
return routes
}
func (s *settingProvider) DownloadCDNShuffle(ctx context.Context) bool {
return s.getBoolean(ctx, "download_cdn_shuffle", false)
}
func (s *settingProvider) DownloadURLBase(ctx context.Context) *url.URL {
if _, pinned := ctx.Value(UseFirstSiteUrlCtxKey{}).(bool); pinned {
return s.SiteURL(ctx)
}
if !s.DownloadCDNShuffle(ctx) {
return s.SiteURL(ctx)
}
routes := s.DownloadCDNRoutes(ctx)
if len(routes) == 0 {
return s.SiteURL(ctx)
}
pool := make([]*url.URL, 0, len(routes)+1)
pool = append(pool, s.SiteURL(ctx))
for _, r := range routes {
if u, err := url.Parse(r.URL); err == nil && u.Scheme != "" && u.Host != "" {
pool = append(pool, u)
}
}
return pool[rand.IntN(len(pool))]
}
func (s *settingProvider) ShareDefaults(ctx context.Context) *ShareDefaults {
level := types.ShareLinksInProfileLevel(s.getString(ctx, "default_share_links_in_profile", ""))
switch level {

@ -35,3 +35,52 @@ func TestDownloadCDNRoutes(t *testing.T) {
{Name: "cdn2", URL: "https://cdn2.example.com/base"},
}, routes)
}
func TestDownloadURLBase(t *testing.T) {
ctx := context.Background()
// Shuffle disabled: always the resolved site URL.
p := NewProvider(stubAdapter{
"siteURL": "https://a.example.com,https://b.example.com",
"download_cdn_routes": "cdn1=https://cdn1.example.com",
})
require.Equal(t, "https://a.example.com", p.DownloadURLBase(ctx).String())
// Shuffle enabled without routes: still the site URL.
p = NewProvider(stubAdapter{
"siteURL": "https://a.example.com",
"download_cdn_shuffle": "1",
})
require.Equal(t, "https://a.example.com", p.DownloadURLBase(ctx).String())
// Shuffle enabled: every draw lands on site URL or a configured route,
// and all endpoints are reached over enough draws.
p = NewProvider(stubAdapter{
"siteURL": "https://a.example.com",
"download_cdn_shuffle": "1",
"download_cdn_routes": "cdn1=https://cdn1.example.com\ncdn2=https://cdn2.example.com",
})
seen := map[string]bool{}
for i := 0; i < 300; i++ {
seen[p.DownloadURLBase(ctx).String()] = true
}
require.Equal(t, map[string]bool{
"https://a.example.com": true,
"https://cdn1.example.com": true,
"https://cdn2.example.com": true,
}, seen)
// UseFirstSiteUrl pins the primary site URL even with shuffle on.
pinned := context.WithValue(ctx, UseFirstSiteUrlCtxKey{}, true)
require.Equal(t, "https://a.example.com", p.DownloadURLBase(pinned).String())
// Invalid route entries never leak into the pool.
p = NewProvider(stubAdapter{
"siteURL": "https://a.example.com",
"download_cdn_shuffle": "1",
"download_cdn_routes": "bad=ftp://x.example.com",
})
for i := 0; i < 50; i++ {
require.Equal(t, "https://a.example.com", p.DownloadURLBase(ctx).String())
}
}

@ -323,6 +323,20 @@ func UserInit2FA(c *gin.Context) {
})
}
// UserBackup2FA regenerates one-time 2FA recovery codes. Plaintext codes are
// returned once; only digests are persisted.
func UserBackup2FA(c *gin.Context) {
service := c.MustGet(user.Backup2FAParameterCtx{}).(*user.Backup2FAService)
codes, err := service.Process(c)
if respondErr(c, err) {
return
}
c.JSON(200, serializer.Response{
Data: codes,
})
}
// UserPerformCopySession copy to create new session or refresh current session
func UserPerformCopySession(c *gin.Context) {
//var service user.CopySessionService

@ -1541,6 +1541,13 @@ func initMasterRouter(dep dependency.Dep) *gin.Engine {
)
// 获得二步验证初始化信息
setting.GET("2fa", controllers.UserInit2FA)
// 重新生成二步验证备用代码
setting.PUT("2fa/backup",
middleware.RequiredScopes(types.ScopeUserSecurityInfoWrite),
middleware.RateLimitByIP("backup_2fa", 5, time.Hour),
controllers.FromJSON[usersvc.Backup2FAService](usersvc.Backup2FAParameterCtx{}),
controllers.UserBackup2FA,
)
// 请求更换邮箱(向新地址发送确认链接)
setting.POST("email",
middleware.RequiredScopes(types.ScopeUserSecurityInfoWrite),

@ -62,6 +62,7 @@ func TestMasterRouteWiring(t *testing.T) {
"PUT /api/v4/user/vault/unlock",
"DELETE /api/v4/user/vault/unlock",
"DELETE /api/v4/user/vault",
"PUT /api/v4/user/setting/2fa/backup",
"GET /f/:id/:name",
}
for _, e := range expected {

@ -58,6 +58,11 @@ type SiteConfig struct {
// can offer a download-route picker (#2987).
DownloadCDNRoutes []setting.CDNRoute `json:"download_cdn_routes,omitempty"`
// DownloadCDNShuffle tells clients generated download URLs are already
// spread across the site URL and all CDN routes server-side (#173), so
// the manual route picker can be skipped.
DownloadCDNShuffle bool `json:"download_cdn_shuffle,omitempty"`
// AbuseCaptcha controls whether the report-abuse dialog shows captcha.
AbuseCaptcha bool `json:"abuse_captcha,omitempty"`
@ -260,6 +265,7 @@ func (s *GetSettingService) GetSiteConfig(c *gin.Context) (*SiteConfig, error) {
ShareDefaultPrivate: shareDefaults.PrivateByDefault,
DefaultShareLinksInProfile: string(shareDefaults.LinksInProfile),
DownloadCDNRoutes: settings.DownloadCDNRoutes(c),
DownloadCDNShuffle: settings.DownloadCDNShuffle(c),
AbuseCaptcha: settings.AbuseCaptchaEnabled(c),
UploadDedup: settings.DBFS(c).DedupScope != "off",
TaskNodes: taskNodes,

@ -518,7 +518,7 @@ func (s *FileURLService) GetArchiveDownloadSession(c *gin.Context) (*FileURLResp
return nil, serializer.NewError(serializer.CodeInternalSetting, "failed to create archive download session", err)
}
base := settings.SiteURL(c)
base := settings.DownloadURLBase(c)
downloadUrl := routes.MasterArchiveDownloadUrl(base, sessionId)
finalUrl, err := auth.SignURI(c, dep.GeneralAuth(), downloadUrl.String(), &expire)
if err != nil {

@ -347,6 +347,7 @@ type Share struct {
PreviewOnly bool `json:"preview_only,omitempty"`
UploadOnly bool `json:"upload_only,omitempty"`
Note string `json:"note,omitempty"`
HideReadMe bool `json:"hide_readme,omitempty"`
// Only viewable if explicitly unlocked by owner
SourceUri string `json:"source_uri,omitempty"`
@ -415,6 +416,7 @@ func BuildShare(ctx context.Context, s *ent.Share, base *url.URL, hasher hashid.
res.UploadOnly = s.Props.UploadOnly
// Owner-private note; never sent to share visitors (#3570).
res.Note = s.Props.Note
res.HideReadMe = s.Props.HideReadMe
}
}

@ -29,6 +29,7 @@ type (
Expire int `json:"expire"`
ShareView bool `json:"share_view"`
ShowReadMe bool `json:"show_readme"`
HideReadMe bool `json:"hide_readme"`
AllowUpload bool `json:"allow_upload"`
AllowEdit bool `json:"allow_edit"`
PreviewOnly bool `json:"preview_only"`
@ -127,6 +128,7 @@ func (service *ShareCreateService) Upsert(c *gin.Context, existed int) (string,
ExistedShareID: existed,
ShareView: service.ShareView,
ShowReadMe: service.ShowReadMe,
HideReadMe: service.HideReadMe,
AllowUpload: service.AllowUpload,
AllowEdit: service.AllowEdit,
PreviewOnly: service.PreviewOnly,

@ -268,8 +268,14 @@ func (service *OtpValidationService) Verify2FA(c *gin.Context) (*ent.User, error
if expectedUser.TwoFactorSecret != "" {
if !totp.Validate(service.OTP, expectedUser.TwoFactorSecret) {
err := serializer.NewError(serializer.Code2FACodeErr, "Incorrect 2FA code", nil)
return nil, err
// TOTP failed — fall back to one-time recovery codes.
ok, err := dep.UserClient().ConsumeTwoFABackupCode(c, expectedUser, service.OTP)
if err != nil {
return nil, serializer.NewError(serializer.CodeDBError, "Failed to verify recovery code", err)
}
if !ok {
return nil, serializer.NewError(serializer.Code2FACodeErr, "Incorrect 2FA code", nil)
}
}
}

@ -29,6 +29,7 @@ type UserSettings struct {
VersionRetentionMax int `json:"version_retention_max,omitempty"`
Paswordless bool `json:"passwordless"`
TwoFAEnabled bool `json:"two_fa_enabled"`
TwoFABackupCount int `json:"two_factor_backup_count"`
Passkeys []Passkey `json:"passkeys,omitempty"`
DisableViewSync bool `json:"disable_view_sync"`
ShareLinksInProfile string `json:"share_links_in_profile"`
@ -57,6 +58,7 @@ func BuildUserSettings(u *ent.User, passkeys []*ent.Passkey, parser *uaparser.Pa
VersionRetentionExt: u.Settings.VersionRetentionExt,
VersionRetentionMax: u.Settings.VersionRetentionMax,
TwoFAEnabled: u.TwoFactorSecret != "",
TwoFABackupCount: len(u.TwoFactorBackupCodes),
Paswordless: u.Password == "",
Passkeys: lo.Map(passkeys, func(item *ent.Passkey, index int) Passkey {
return BuildPasskey(item)

@ -3,6 +3,7 @@ package user
import (
"context"
"crypto/md5"
"crypto/rand"
"fmt"
"io"
"net/http"
@ -33,6 +34,10 @@ import (
const (
twoFaEnableSessionKey = "2fa_init_"
// backupCodeCount is the number of one-time recovery codes issued per batch.
backupCodeCount = 10
// backupCodeAlphabet excludes ambiguous glyphs (0/o, 1/l/i).
backupCodeAlphabet = "abcdefghjkmnpqrstuvwxyz23456789"
)
// Init2FA 初始化二步验证
@ -55,6 +60,72 @@ func Init2FA(c *gin.Context) (string, error) {
return key.Secret(), nil
}
type (
// Backup2FAService regenerates one-time 2FA recovery codes.
Backup2FAService struct {
TwoFACode string `json:"two_fa_code" binding:"required"`
}
Backup2FAParameterCtx struct{}
)
// Process generates a fresh batch of recovery codes for the current user.
// Requires 2FA to be enabled and a valid current TOTP code, the same trust
// bar as disabling 2FA. Plaintext codes are returned once and only their
// digests are persisted.
func (service *Backup2FAService) Process(c *gin.Context) ([]string, error) {
dep := dependency.FromContext(c)
u := inventory.UserFromContext(c)
if err := auth.CheckScope(c, types.ScopeUserSecurityInfoWrite); err != nil {
return nil, err
}
if u.TwoFactorSecret == "" {
return nil, serializer.NewError(serializer.CodeFeatureNotEnabled, "2FA is not enabled", nil)
}
if !totp.Validate(service.TwoFACode, u.TwoFactorSecret) {
return nil, serializer.NewError(serializer.Code2FACodeErr, "Incorrect 2FA code", nil)
}
codes := make([]string, 0, backupCodeCount)
digests := make([]string, 0, backupCodeCount)
for i := 0; i < backupCodeCount; i++ {
code, err := generateBackupCode()
if err != nil {
return nil, serializer.NewError(serializer.CodeInternalSetting, "Failed to generate recovery codes", err)
}
digest, err := inventory.DigestPassword(code)
if err != nil {
return nil, serializer.NewError(serializer.CodeInternalSetting, "Failed to hash recovery codes", err)
}
codes = append(codes, code[:4]+"-"+code[4:])
digests = append(digests, digest)
}
if _, err := dep.UserClient().UpdateTwoFABackupCodes(c, u, digests); err != nil {
return nil, serializer.NewError(serializer.CodeDBError, "Failed to store recovery codes", err)
}
activity.Record(c, dep.SettingProvider(), dep.ActivityClient(), types.EventEnable2FA,
activity.Extra(map[string]any{"recovery_codes": true}))
return codes, nil
}
// generateBackupCode returns an 8-char code drawn from an unambiguous
// alphabet (~41 bits of entropy per code).
func generateBackupCode() (string, error) {
buf := make([]byte, 8)
if _, err := rand.Read(buf); err != nil {
return "", err
}
code := make([]byte, 8)
for i, b := range buf {
code[i] = backupCodeAlphabet[int(b)%len(backupCodeAlphabet)]
}
return string(code), nil
}
type (
// AvatarService Service to get avatar
GetAvatarService struct {

@ -0,0 +1,89 @@
package user
import (
"context"
"net/http/httptest"
"testing"
"time"
"github.com/cloudreve/Cloudreve/v4/application/dependency"
"github.com/cloudreve/Cloudreve/v4/ent"
"github.com/cloudreve/Cloudreve/v4/ent/enttest"
"github.com/cloudreve/Cloudreve/v4/inventory"
"github.com/cloudreve/Cloudreve/v4/pkg/boolset"
"github.com/cloudreve/Cloudreve/v4/pkg/conf"
"github.com/cloudreve/Cloudreve/v4/pkg/logging"
"github.com/cloudreve/Cloudreve/v4/pkg/serializer"
"github.com/cloudreve/Cloudreve/v4/pkg/util"
"github.com/gin-gonic/gin"
"github.com/pquerna/otp/totp"
"github.com/stretchr/testify/require"
)
func TestBackup2FAProcess(t *testing.T) {
gin.SetMode(gin.TestMode)
client := enttest.Open(t, "sqlite3", "file:"+t.Name()+"?mode=memory&cache=shared")
t.Cleanup(func() { require.NoError(t, client.Close()) })
ctx := context.Background()
logger := logging.NewConsoleLogger(logging.LevelError)
cfg, err := conf.NewIniConfigProvider(t.TempDir()+"/conf.ini", logger)
require.NoError(t, err)
key, err := totp.Generate(totp.GenerateOpts{Issuer: "test", AccountName: "b@example.com"})
require.NoError(t, err)
group := client.Group.Create().SetName("g").SetPermissions(&boolset.BooleanSet{}).SaveX(ctx)
u := client.User.Create().
SetEmail("b@example.com").SetNick("b").SetGroup(group).
SetTwoFactorSecret(key.Secret()).
SaveX(ctx)
no2fa := client.User.Create().
SetEmail("n@example.com").SetNick("n").SetGroup(group).
SaveX(ctx)
dep := dependency.NewDependency(
dependency.WithDbClient(client),
dependency.WithConfigProvider(cfg),
dependency.WithLogger(logger),
dependency.WithSettingProvider(cronSettingProvider{}),
)
newCtx := func(u *ent.User) *gin.Context {
engine := gin.New()
engine.ContextWithFallback = true
c := gin.CreateTestContextOnly(httptest.NewRecorder(), engine)
c.Request = httptest.NewRequest("PUT", "/", nil)
util.WithValue(c, dependency.DepCtx{}, dep)
util.WithValue(c, inventory.UserCtx{}, u)
return c
}
appErr := func(err error) serializer.AppError {
t.Helper()
var ae serializer.AppError
require.ErrorAs(t, err, &ae)
return ae
}
// 2FA not enabled -> rejected before TOTP validation.
_, err = (&Backup2FAService{TwoFACode: "000000"}).Process(newCtx(no2fa))
require.Error(t, err)
require.Equal(t, serializer.CodeFeatureNotEnabled, appErr(err).Code)
// Wrong TOTP -> rejected, no codes stored.
_, err = (&Backup2FAService{TwoFACode: "000000"}).Process(newCtx(u))
require.Error(t, err)
require.Equal(t, serializer.Code2FACodeErr, appErr(err).Code)
// Valid TOTP -> 10 hyphenated codes, digests persisted.
valid, err := totp.GenerateCode(key.Secret(), time.Now())
require.NoError(t, err)
codes, err := (&Backup2FAService{TwoFACode: valid}).Process(newCtx(u))
require.NoError(t, err)
require.Len(t, codes, 10)
for _, code := range codes {
require.Len(t, code, 9)
require.Equal(t, '-', rune(code[4]))
}
require.Len(t, client.User.GetX(ctx, u.ID).TwoFactorBackupCodes, 10)
}
Loading…
Cancel
Save