feat(user): ban reason and configurable sub-address chars (#2478, #3171)

- User.ban_reason is stored via admin upsert, shown to the user when a
  banned login/reset/SSO attempt is rejected, and cleared on unban.
  The 40017 error template gains a {{message}} slot for it.
- email_disable_subaddress now honors email_subaddress_chars — an
  admin-configurable set of local-part separators (covers iCloud-style
  '-' aliases), defaulting to '+' so existing installs keep their
  behavior.

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/3582/head
Tomas Dvorak 2 weeks ago
parent 1fbad3cd37
commit 24c751961e

File diff suppressed because one or more lines are too long

@ -484,6 +484,7 @@ var (
{Name: "password", Type: field.TypeString, Nullable: true},
{Name: "status", Type: field.TypeEnum, Enums: []string{"active", "inactive", "manual_banned", "sys_banned"}, Default: "active"},
{Name: "ban_expires", Type: field.TypeTime, Nullable: true},
{Name: "ban_reason", Type: field.TypeString, Nullable: true, Size: 2147483647},
{Name: "storage", Type: field.TypeInt64, Default: 0},
{Name: "two_factor_secret", Type: field.TypeString, Nullable: true},
{Name: "avatar", Type: field.TypeString, Nullable: true},
@ -498,7 +499,7 @@ var (
ForeignKeys: []*schema.ForeignKey{
{
Symbol: "users_groups_users",
Columns: []*schema.Column{UsersColumns[13]},
Columns: []*schema.Column{UsersColumns[14]},
RefColumns: []*schema.Column{GroupsColumns[0]},
OnDelete: schema.NoAction,
},

@ -15173,6 +15173,7 @@ type UserMutation struct {
password *string
status *user.Status
ban_expires *time.Time
ban_reason *string
storage *int64
addstorage *int64
two_factor_secret *string
@ -15635,6 +15636,55 @@ func (m *UserMutation) ResetBanExpires() {
delete(m.clearedFields, user.FieldBanExpires)
}
// SetBanReason sets the "ban_reason" field.
func (m *UserMutation) SetBanReason(s string) {
m.ban_reason = &s
}
// BanReason returns the value of the "ban_reason" field in the mutation.
func (m *UserMutation) BanReason() (r string, exists bool) {
v := m.ban_reason
if v == nil {
return
}
return *v, true
}
// OldBanReason returns the old "ban_reason" 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) OldBanReason(ctx context.Context) (v string, err error) {
if !m.op.Is(OpUpdateOne) {
return v, errors.New("OldBanReason is only allowed on UpdateOne operations")
}
if m.id == nil || m.oldValue == nil {
return v, errors.New("OldBanReason requires an ID field in the mutation")
}
oldValue, err := m.oldValue(ctx)
if err != nil {
return v, fmt.Errorf("querying old value for OldBanReason: %w", err)
}
return oldValue.BanReason, nil
}
// ClearBanReason clears the value of the "ban_reason" field.
func (m *UserMutation) ClearBanReason() {
m.ban_reason = nil
m.clearedFields[user.FieldBanReason] = struct{}{}
}
// BanReasonCleared returns if the "ban_reason" field was cleared in this mutation.
func (m *UserMutation) BanReasonCleared() bool {
_, ok := m.clearedFields[user.FieldBanReason]
return ok
}
// ResetBanReason resets all changes to the "ban_reason" field.
func (m *UserMutation) ResetBanReason() {
m.ban_reason = nil
delete(m.clearedFields, user.FieldBanReason)
}
// SetStorage sets the "storage" field.
func (m *UserMutation) SetStorage(i int64) {
m.storage = &i
@ -16380,7 +16430,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, 13)
fields := make([]string, 0, 14)
if m.created_at != nil {
fields = append(fields, user.FieldCreatedAt)
}
@ -16405,6 +16455,9 @@ func (m *UserMutation) Fields() []string {
if m.ban_expires != nil {
fields = append(fields, user.FieldBanExpires)
}
if m.ban_reason != nil {
fields = append(fields, user.FieldBanReason)
}
if m.storage != nil {
fields = append(fields, user.FieldStorage)
}
@ -16444,6 +16497,8 @@ func (m *UserMutation) Field(name string) (ent.Value, bool) {
return m.Status()
case user.FieldBanExpires:
return m.BanExpires()
case user.FieldBanReason:
return m.BanReason()
case user.FieldStorage:
return m.Storage()
case user.FieldTwoFactorSecret:
@ -16479,6 +16534,8 @@ func (m *UserMutation) OldField(ctx context.Context, name string) (ent.Value, er
return m.OldStatus(ctx)
case user.FieldBanExpires:
return m.OldBanExpires(ctx)
case user.FieldBanReason:
return m.OldBanReason(ctx)
case user.FieldStorage:
return m.OldStorage(ctx)
case user.FieldTwoFactorSecret:
@ -16554,6 +16611,13 @@ func (m *UserMutation) SetField(name string, value ent.Value) error {
}
m.SetBanExpires(v)
return nil
case user.FieldBanReason:
v, ok := value.(string)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
m.SetBanReason(v)
return nil
case user.FieldStorage:
v, ok := value.(int64)
if !ok {
@ -16643,6 +16707,9 @@ func (m *UserMutation) ClearedFields() []string {
if m.FieldCleared(user.FieldBanExpires) {
fields = append(fields, user.FieldBanExpires)
}
if m.FieldCleared(user.FieldBanReason) {
fields = append(fields, user.FieldBanReason)
}
if m.FieldCleared(user.FieldTwoFactorSecret) {
fields = append(fields, user.FieldTwoFactorSecret)
}
@ -16675,6 +16742,9 @@ func (m *UserMutation) ClearField(name string) error {
case user.FieldBanExpires:
m.ClearBanExpires()
return nil
case user.FieldBanReason:
m.ClearBanReason()
return nil
case user.FieldTwoFactorSecret:
m.ClearTwoFactorSecret()
return nil
@ -16716,6 +16786,9 @@ func (m *UserMutation) ResetField(name string) error {
case user.FieldBanExpires:
m.ResetBanExpires()
return nil
case user.FieldBanReason:
m.ResetBanReason()
return nil
case user.FieldStorage:
m.ResetStorage()
return nil

@ -415,11 +415,11 @@ func init() {
// user.NickValidator is a validator for the "nick" field. It is called by the builders before save.
user.NickValidator = userDescNick.Validators[0].(func(string) error)
// userDescStorage is the schema descriptor for storage field.
userDescStorage := userFields[5].Descriptor()
userDescStorage := userFields[6].Descriptor()
// user.DefaultStorage holds the default value on creation for the storage field.
user.DefaultStorage = userDescStorage.Default.(int64)
// userDescSettings is the schema descriptor for settings field.
userDescSettings := userFields[8].Descriptor()
userDescSettings := userFields[9].Descriptor()
// user.DefaultSettings holds the default value on creation for the settings field.
user.DefaultSettings = userDescSettings.Default.(*types.UserSetting)
}

@ -29,6 +29,9 @@ func (User) Fields() []ent.Field {
field.Time("ban_expires").
Optional().
Nillable(),
// ban_reason is shown to the user when a banned login is rejected.
field.Text("ban_reason").
Optional(),
field.Int64("storage").
Default(0),
field.String("two_factor_secret").

@ -36,6 +36,8 @@ type User struct {
Status user.Status `json:"status,omitempty"`
// BanExpires holds the value of the "ban_expires" field.
BanExpires *time.Time `json:"ban_expires,omitempty"`
// BanReason holds the value of the "ban_reason" field.
BanReason string `json:"ban_reason,omitempty"`
// Storage holds the value of the "storage" field.
Storage int64 `json:"storage,omitempty"`
// TwoFactorSecret holds the value of the "two_factor_secret" field.
@ -171,7 +173,7 @@ func (*User) scanValues(columns []string) ([]any, error) {
values[i] = new([]byte)
case user.FieldID, user.FieldStorage, user.FieldGroupUsers:
values[i] = new(sql.NullInt64)
case user.FieldEmail, user.FieldNick, user.FieldPassword, user.FieldStatus, user.FieldTwoFactorSecret, user.FieldAvatar:
case user.FieldEmail, user.FieldNick, user.FieldPassword, user.FieldStatus, user.FieldBanReason, user.FieldTwoFactorSecret, user.FieldAvatar:
values[i] = new(sql.NullString)
case user.FieldCreatedAt, user.FieldUpdatedAt, user.FieldDeletedAt, user.FieldBanExpires:
values[i] = new(sql.NullTime)
@ -246,6 +248,12 @@ func (u *User) assignValues(columns []string, values []any) error {
u.BanExpires = new(time.Time)
*u.BanExpires = value.Time
}
case user.FieldBanReason:
if value, ok := values[i].(*sql.NullString); !ok {
return fmt.Errorf("unexpected type %T for field ban_reason", values[i])
} else if value.Valid {
u.BanReason = value.String
}
case user.FieldStorage:
if value, ok := values[i].(*sql.NullInt64); !ok {
return fmt.Errorf("unexpected type %T for field storage", values[i])
@ -386,6 +394,9 @@ func (u *User) String() string {
builder.WriteString(v.Format(time.ANSIC))
}
builder.WriteString(", ")
builder.WriteString("ban_reason=")
builder.WriteString(u.BanReason)
builder.WriteString(", ")
builder.WriteString("storage=")
builder.WriteString(fmt.Sprintf("%v", u.Storage))
builder.WriteString(", ")

@ -33,6 +33,8 @@ const (
FieldStatus = "status"
// FieldBanExpires holds the string denoting the ban_expires field in the database.
FieldBanExpires = "ban_expires"
// FieldBanReason holds the string denoting the ban_reason field in the database.
FieldBanReason = "ban_reason"
// FieldStorage holds the string denoting the storage field in the database.
FieldStorage = "storage"
// FieldTwoFactorSecret holds the string denoting the two_factor_secret field in the database.
@ -139,6 +141,7 @@ var Columns = []string{
FieldPassword,
FieldStatus,
FieldBanExpires,
FieldBanReason,
FieldStorage,
FieldTwoFactorSecret,
FieldAvatar,
@ -256,6 +259,11 @@ func ByBanExpires(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldBanExpires, opts...).ToFunc()
}
// ByBanReason orders the results by the ban_reason field.
func ByBanReason(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldBanReason, opts...).ToFunc()
}
// ByStorage orders the results by the storage field.
func ByStorage(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldStorage, opts...).ToFunc()

@ -90,6 +90,11 @@ func BanExpires(v time.Time) predicate.User {
return predicate.User(sql.FieldEQ(FieldBanExpires, v))
}
// BanReason applies equality check predicate on the "ban_reason" field. It's identical to BanReasonEQ.
func BanReason(v string) predicate.User {
return predicate.User(sql.FieldEQ(FieldBanReason, v))
}
// Storage applies equality check predicate on the "storage" field. It's identical to StorageEQ.
func Storage(v int64) predicate.User {
return predicate.User(sql.FieldEQ(FieldStorage, v))
@ -515,6 +520,81 @@ func BanExpiresNotNil() predicate.User {
return predicate.User(sql.FieldNotNull(FieldBanExpires))
}
// BanReasonEQ applies the EQ predicate on the "ban_reason" field.
func BanReasonEQ(v string) predicate.User {
return predicate.User(sql.FieldEQ(FieldBanReason, v))
}
// BanReasonNEQ applies the NEQ predicate on the "ban_reason" field.
func BanReasonNEQ(v string) predicate.User {
return predicate.User(sql.FieldNEQ(FieldBanReason, v))
}
// BanReasonIn applies the In predicate on the "ban_reason" field.
func BanReasonIn(vs ...string) predicate.User {
return predicate.User(sql.FieldIn(FieldBanReason, vs...))
}
// BanReasonNotIn applies the NotIn predicate on the "ban_reason" field.
func BanReasonNotIn(vs ...string) predicate.User {
return predicate.User(sql.FieldNotIn(FieldBanReason, vs...))
}
// BanReasonGT applies the GT predicate on the "ban_reason" field.
func BanReasonGT(v string) predicate.User {
return predicate.User(sql.FieldGT(FieldBanReason, v))
}
// BanReasonGTE applies the GTE predicate on the "ban_reason" field.
func BanReasonGTE(v string) predicate.User {
return predicate.User(sql.FieldGTE(FieldBanReason, v))
}
// BanReasonLT applies the LT predicate on the "ban_reason" field.
func BanReasonLT(v string) predicate.User {
return predicate.User(sql.FieldLT(FieldBanReason, v))
}
// BanReasonLTE applies the LTE predicate on the "ban_reason" field.
func BanReasonLTE(v string) predicate.User {
return predicate.User(sql.FieldLTE(FieldBanReason, v))
}
// BanReasonContains applies the Contains predicate on the "ban_reason" field.
func BanReasonContains(v string) predicate.User {
return predicate.User(sql.FieldContains(FieldBanReason, v))
}
// BanReasonHasPrefix applies the HasPrefix predicate on the "ban_reason" field.
func BanReasonHasPrefix(v string) predicate.User {
return predicate.User(sql.FieldHasPrefix(FieldBanReason, v))
}
// BanReasonHasSuffix applies the HasSuffix predicate on the "ban_reason" field.
func BanReasonHasSuffix(v string) predicate.User {
return predicate.User(sql.FieldHasSuffix(FieldBanReason, v))
}
// BanReasonIsNil applies the IsNil predicate on the "ban_reason" field.
func BanReasonIsNil() predicate.User {
return predicate.User(sql.FieldIsNull(FieldBanReason))
}
// BanReasonNotNil applies the NotNil predicate on the "ban_reason" field.
func BanReasonNotNil() predicate.User {
return predicate.User(sql.FieldNotNull(FieldBanReason))
}
// BanReasonEqualFold applies the EqualFold predicate on the "ban_reason" field.
func BanReasonEqualFold(v string) predicate.User {
return predicate.User(sql.FieldEqualFold(FieldBanReason, v))
}
// BanReasonContainsFold applies the ContainsFold predicate on the "ban_reason" field.
func BanReasonContainsFold(v string) predicate.User {
return predicate.User(sql.FieldContainsFold(FieldBanReason, v))
}
// StorageEQ applies the EQ predicate on the "storage" field.
func StorageEQ(v int64) predicate.User {
return predicate.User(sql.FieldEQ(FieldStorage, v))

@ -128,6 +128,20 @@ func (uc *UserCreate) SetNillableBanExpires(t *time.Time) *UserCreate {
return uc
}
// SetBanReason sets the "ban_reason" field.
func (uc *UserCreate) SetBanReason(s string) *UserCreate {
uc.mutation.SetBanReason(s)
return uc
}
// SetNillableBanReason sets the "ban_reason" field if the given value is not nil.
func (uc *UserCreate) SetNillableBanReason(s *string) *UserCreate {
if s != nil {
uc.SetBanReason(*s)
}
return uc
}
// SetStorage sets the "storage" field.
func (uc *UserCreate) SetStorage(i int64) *UserCreate {
uc.mutation.SetStorage(i)
@ -486,6 +500,10 @@ func (uc *UserCreate) createSpec() (*User, *sqlgraph.CreateSpec) {
_spec.SetField(user.FieldBanExpires, field.TypeTime, value)
_node.BanExpires = &value
}
if value, ok := uc.mutation.BanReason(); ok {
_spec.SetField(user.FieldBanReason, field.TypeString, value)
_node.BanReason = value
}
if value, ok := uc.mutation.Storage(); ok {
_spec.SetField(user.FieldStorage, field.TypeInt64, value)
_node.Storage = value
@ -801,6 +819,24 @@ func (u *UserUpsert) ClearBanExpires() *UserUpsert {
return u
}
// SetBanReason sets the "ban_reason" field.
func (u *UserUpsert) SetBanReason(v string) *UserUpsert {
u.Set(user.FieldBanReason, v)
return u
}
// UpdateBanReason sets the "ban_reason" field to the value that was provided on create.
func (u *UserUpsert) UpdateBanReason() *UserUpsert {
u.SetExcluded(user.FieldBanReason)
return u
}
// ClearBanReason clears the value of the "ban_reason" field.
func (u *UserUpsert) ClearBanReason() *UserUpsert {
u.SetNull(user.FieldBanReason)
return u
}
// SetStorage sets the "storage" field.
func (u *UserUpsert) SetStorage(v int64) *UserUpsert {
u.Set(user.FieldStorage, v)
@ -1049,6 +1085,27 @@ func (u *UserUpsertOne) ClearBanExpires() *UserUpsertOne {
})
}
// SetBanReason sets the "ban_reason" field.
func (u *UserUpsertOne) SetBanReason(v string) *UserUpsertOne {
return u.Update(func(s *UserUpsert) {
s.SetBanReason(v)
})
}
// UpdateBanReason sets the "ban_reason" field to the value that was provided on create.
func (u *UserUpsertOne) UpdateBanReason() *UserUpsertOne {
return u.Update(func(s *UserUpsert) {
s.UpdateBanReason()
})
}
// ClearBanReason clears the value of the "ban_reason" field.
func (u *UserUpsertOne) ClearBanReason() *UserUpsertOne {
return u.Update(func(s *UserUpsert) {
s.ClearBanReason()
})
}
// SetStorage sets the "storage" field.
func (u *UserUpsertOne) SetStorage(v int64) *UserUpsertOne {
return u.Update(func(s *UserUpsert) {
@ -1482,6 +1539,27 @@ func (u *UserUpsertBulk) ClearBanExpires() *UserUpsertBulk {
})
}
// SetBanReason sets the "ban_reason" field.
func (u *UserUpsertBulk) SetBanReason(v string) *UserUpsertBulk {
return u.Update(func(s *UserUpsert) {
s.SetBanReason(v)
})
}
// UpdateBanReason sets the "ban_reason" field to the value that was provided on create.
func (u *UserUpsertBulk) UpdateBanReason() *UserUpsertBulk {
return u.Update(func(s *UserUpsert) {
s.UpdateBanReason()
})
}
// ClearBanReason clears the value of the "ban_reason" field.
func (u *UserUpsertBulk) ClearBanReason() *UserUpsertBulk {
return u.Update(func(s *UserUpsert) {
s.ClearBanReason()
})
}
// SetStorage sets the "storage" field.
func (u *UserUpsertBulk) SetStorage(v int64) *UserUpsertBulk {
return u.Update(func(s *UserUpsert) {

@ -146,6 +146,26 @@ func (uu *UserUpdate) ClearBanExpires() *UserUpdate {
return uu
}
// SetBanReason sets the "ban_reason" field.
func (uu *UserUpdate) SetBanReason(s string) *UserUpdate {
uu.mutation.SetBanReason(s)
return uu
}
// SetNillableBanReason sets the "ban_reason" field if the given value is not nil.
func (uu *UserUpdate) SetNillableBanReason(s *string) *UserUpdate {
if s != nil {
uu.SetBanReason(*s)
}
return uu
}
// ClearBanReason clears the value of the "ban_reason" field.
func (uu *UserUpdate) ClearBanReason() *UserUpdate {
uu.mutation.ClearBanReason()
return uu
}
// SetStorage sets the "storage" field.
func (uu *UserUpdate) SetStorage(i int64) *UserUpdate {
uu.mutation.ResetStorage()
@ -650,6 +670,12 @@ func (uu *UserUpdate) sqlSave(ctx context.Context) (n int, err error) {
if uu.mutation.BanExpiresCleared() {
_spec.ClearField(user.FieldBanExpires, field.TypeTime)
}
if value, ok := uu.mutation.BanReason(); ok {
_spec.SetField(user.FieldBanReason, field.TypeString, value)
}
if uu.mutation.BanReasonCleared() {
_spec.ClearField(user.FieldBanReason, field.TypeString)
}
if value, ok := uu.mutation.Storage(); ok {
_spec.SetField(user.FieldStorage, field.TypeInt64, value)
}
@ -1191,6 +1217,26 @@ func (uuo *UserUpdateOne) ClearBanExpires() *UserUpdateOne {
return uuo
}
// SetBanReason sets the "ban_reason" field.
func (uuo *UserUpdateOne) SetBanReason(s string) *UserUpdateOne {
uuo.mutation.SetBanReason(s)
return uuo
}
// SetNillableBanReason sets the "ban_reason" field if the given value is not nil.
func (uuo *UserUpdateOne) SetNillableBanReason(s *string) *UserUpdateOne {
if s != nil {
uuo.SetBanReason(*s)
}
return uuo
}
// ClearBanReason clears the value of the "ban_reason" field.
func (uuo *UserUpdateOne) ClearBanReason() *UserUpdateOne {
uuo.mutation.ClearBanReason()
return uuo
}
// SetStorage sets the "storage" field.
func (uuo *UserUpdateOne) SetStorage(i int64) *UserUpdateOne {
uuo.mutation.ResetStorage()
@ -1725,6 +1771,12 @@ func (uuo *UserUpdateOne) sqlSave(ctx context.Context) (_node *User, err error)
if uuo.mutation.BanExpiresCleared() {
_spec.ClearField(user.FieldBanExpires, field.TypeTime)
}
if value, ok := uuo.mutation.BanReason(); ok {
_spec.SetField(user.FieldBanReason, field.TypeString, value)
}
if uuo.mutation.BanReasonCleared() {
_spec.ClearField(user.FieldBanReason, field.TypeString)
}
if value, ok := uuo.mutation.Storage(); ok {
_spec.SetField(user.FieldStorage, field.TypeInt64, value)
}

@ -54,7 +54,7 @@
"40014": "Exceed batch size limit of getting source link.",
"40015": "Exceed aria2 batch size limit.",
"40016": "Path not found.",
"40017": "This account has been blocked.",
"40017": "This account has been blocked. {{message}}",
"40018": "This account is not activated.",
"40019": "This feature is not enabled.",
"40020": "Invalid or expired credential.",

@ -1388,7 +1388,9 @@
"calibrateStorage": "Calibrate storage",
"calibrateStorageSuccess": "Storage calibrated successfully.",
"banExpires": "Ban expires",
"banExpiresDes": "Optional. The ban lifts automatically after this time; empty means permanent."
"banExpiresDes": "Optional. The ban lifts automatically after this time; empty means permanent.",
"banReason": "Ban reason",
"banReasonDes": "Optional. Shown to the user when a banned login is rejected."
},
"file": {
"deleteXFiles": "Delete {{num}} files",
@ -1763,7 +1765,9 @@
"customPaymentEndpointDes": "URL to be requested when creating a payment order.",
"appFeedback": "Feedback URL",
"appForum": "User forum URL",
"appLinkDes": "Will be displayed in mobile client, leave empty to hide menu item. This setting will take effect only if VOL license is valid."
"appLinkDes": "Will be displayed in mobile client, leave empty to hide menu item. This setting will take effect only if VOL license is valid.",
"subAddressChars": "Sub-address characters",
"subAddressCharsDes": "Characters treated as sub-address separators in the local part of the email (e.g. <code>+-.</code> for Gmail/iCloud-style aliases). Empty defaults to <code>+</code>."
},
"pro": {
"title": "Pro edition exclusive features",

@ -54,7 +54,7 @@
"40014": "超出批量获取外链限制",
"40015": "超出最大离线下载任务数量限制",
"40016": "路径不存在",
"40017": "该账号已被封禁",
"40017": "该账户已被封禁。{{message}}",
"40018": "该账号未激活",
"40019": "此功能未启用",
"40020": "凭证无效或过期",
@ -103,5 +103,6 @@
"50010": "目标节点不可用",
"50011": "文件元信息查询失败"
},
"delete": "删除"
"delete": "删除",
"40017": "该账号已被封禁。{{message}}"
}

@ -1388,7 +1388,9 @@
"calibrateStorage": "校准存储空间",
"calibrateStorageSuccess": "存储空间校准成功",
"banExpires": "封禁截止时间",
"banExpiresDes": "可选。到期后自动解除封禁;留空表示永久封禁。"
"banExpiresDes": "可选。到期后自动解除封禁;留空表示永久封禁。",
"banReason": "封禁原因",
"banReasonDes": "可选。封禁用户登录被拒绝时向其展示。"
},
"file": {
"deleteXFiles": "删除 {{num}} 个文件",
@ -1762,7 +1764,9 @@
"customPaymentEndpointDes": "创建支付订单时请求的接口 URL。",
"appFeedback": "反馈页面 URL",
"appForum": "用户论坛 URL",
"appLinkDes": "用于在 App 设置页面展示,留空即不展示链接按钮,仅当 VOL 授权有效时此项设置才会生效。"
"appLinkDes": "用于在 App 设置页面展示,留空即不展示链接按钮,仅当 VOL 授权有效时此项设置才会生效。",
"subAddressChars": "子地址字符",
"subAddressCharsDes": "邮箱用户名部分中视为子地址分隔符的字符(如 <code>+-.</code>,覆盖 Gmail/iCloud 风格别名)。留空默认为 <code>+</code>。"
},
"pro": {
"title": "Pro 版本专属功能",

@ -264,6 +264,7 @@ export interface User extends CommonMixin {
credit?: number;
group_expires?: string;
ban_expires?: string;
ban_reason?: string;
notify_date?: string;
group_users?: number;
previous_group?: number;

@ -201,6 +201,7 @@ const Settings = () => {
"email_filter_mode",
"email_filter_list",
"email_disable_subaddress",
"email_subaddress_chars",
"siteURL",
]}
>

@ -251,6 +251,21 @@ const UserSession = () => {
</NoMarginHelperText>
</FormControl>
</SettingForm>
{isTrueVal(values.email_disable_subaddress) && (
<SettingForm title={t("vas.subAddressChars")} lgWidth={5}>
<FormControl fullWidth>
<DenseFilledTextField
fullWidth
placeholder="+"
value={values.email_subaddress_chars ?? ""}
onChange={(e) => setSettings({ email_subaddress_chars: e.target.value })}
/>
<NoMarginHelperText>
<Trans i18nKey="vas.subAddressCharsDes" ns={"dashboard"} components={[<Code />]} />
</NoMarginHelperText>
</FormControl>
</SettingForm>
)}
</SettingSectionContent>
</SettingSection>
<SettingSection>

@ -204,16 +204,26 @@ const UserForm = ({ reload, setLoading }: { reload: () => void; setLoading: (loa
</FormControl>
</SettingForm>
{banned && (
<SettingForm title={t("user.banExpires")} noContainer lgWidth={6}>
<DenseFilledTextField
fullWidth
type="datetime-local"
value={toLocalInput(values.ban_expires)}
onChange={onBanExpiresChange}
slotProps={{ inputLabel: { shrink: true } }}
/>
<NoMarginHelperText>{t("user.banExpiresDes")}</NoMarginHelperText>
</SettingForm>
<>
<SettingForm title={t("user.banExpires")} noContainer lgWidth={6}>
<DenseFilledTextField
fullWidth
type="datetime-local"
value={toLocalInput(values.ban_expires)}
onChange={onBanExpiresChange}
slotProps={{ inputLabel: { shrink: true } }}
/>
<NoMarginHelperText>{t("user.banExpiresDes")}</NoMarginHelperText>
</SettingForm>
<SettingForm title={t("user.banReason")} noContainer lgWidth={6}>
<DenseFilledTextField
fullWidth
value={values.ban_reason ?? ""}
onChange={(e) => setUser((prev) => ({ ...prev, ban_reason: e.target.value }))}
/>
<NoMarginHelperText>{t("user.banReasonDes")}</NoMarginHelperText>
</SettingForm>
</>
)}
<SettingForm title={t("user.group")} noContainer lgWidth={6}>
<GroupSelectionInput value={values.group_users?.toString() ?? ""} onChange={onGroupChange} fullWidth />

@ -76,18 +76,23 @@ func TestUpsertBanExpires(t *testing.T) {
u := client.User.Create().SetEmail("upsert-ban@example.com").SetNick("u").
SetStatus(entuser.StatusActive).SetGroup(group).SaveX(ctx)
// Ban with expiry
// Ban with expiry and reason
u.Status = entuser.StatusManualBanned
u.BanExpires = &future
u.BanReason = "spam"
_, err := uc.Upsert(ctx, u, "", "")
require.NoError(t, err)
got := client.User.GetX(ctx, u.ID)
require.Equal(t, future, got.BanExpires.UTC())
require.Equal(t, "spam", got.BanReason)
// Unban clears the expiry
// Unban clears the expiry and reason
u.Status = entuser.StatusActive
u.BanExpires = nil
u.BanReason = ""
_, err = uc.Upsert(ctx, u, "", "")
require.NoError(t, err)
require.Nil(t, client.User.GetX(ctx, u.ID).BanExpires)
got = client.User.GetX(ctx, u.ID)
require.Nil(t, got.BanExpires)
require.Equal(t, "", got.BanReason)
}

@ -602,8 +602,10 @@ func (c *userClient) Upsert(ctx context.Context, u *ent.User, password, twoFa st
if u.Status == user.StatusManualBanned || u.Status == user.StatusSysBanned {
q.SetNillableBanExpires(u.BanExpires)
q.SetBanReason(u.BanReason)
} else {
q.ClearBanExpires()
q.ClearBanReason()
}
if password != "" {

@ -932,6 +932,7 @@ func (s *settingProvider) EmailFilter(ctx context.Context) *EmailFilter {
Mode: mode,
List: list,
DisableSubAddress: s.getBoolean(ctx, "email_disable_subaddress", false),
SubAddressChars: s.getString(ctx, "email_subaddress_chars", "+"),
}
}

@ -97,6 +97,9 @@ type EmailFilter struct {
Mode EmailFilterMode
List []string
DisableSubAddress bool
// SubAddressChars are the characters treated as sub-address separators in
// the local part when DisableSubAddress is on. Empty falls back to "+".
SubAddressChars string
}
type DBFS struct {

@ -93,7 +93,7 @@ func (service *UserResetEmailService) Reset(c *gin.Context) error {
}
if u.Status == user.StatusManualBanned || u.Status == user.StatusSysBanned {
return serializer.NewError(serializer.CodeUserBaned, "This user is banned", nil)
return banError(u, "This user is banned")
}
if u.Status == user.StatusInactive {
@ -141,7 +141,7 @@ func (service *UserLoginService) Login(c *gin.Context) (*ent.User, string, error
} else if checkErr := inventory.CheckPassword(expectedUser, service.Password); checkErr != nil {
err = serializer.NewError(serializer.CodeInvalidPassword, "Incorrect password or email address", err)
} else if expectedUser.Status == user.StatusManualBanned || expectedUser.Status == user.StatusSysBanned {
err = serializer.NewError(serializer.CodeUserBaned, "This account has been blocked", nil)
err = banError(expectedUser, "This account has been blocked")
} else if expectedUser.Status == user.StatusInactive {
err = serializer.NewError(serializer.CodeUserNotActivated, "This account is not activated", nil)
}

@ -312,10 +312,21 @@ func ssoResolveUser(c *gin.Context, dep dependency.Dep, sso *setting.SSO, email,
return newUser, nil
}
// banError renders the ban error. When a ban reason is configured it is
// sent as the message so the frontend can render it next to the
// localized "blocked" text (#2478).
func banError(u *ent.User, fallback string) error {
msg := fallback
if u.BanReason != "" {
msg = u.BanReason
}
return serializer.NewError(serializer.CodeUserBaned, msg, nil)
}
func checkUserStatus(u *ent.User) error {
switch u.Status {
case user.StatusSysBanned, user.StatusManualBanned:
return serializer.NewError(serializer.CodeUserBaned, "User is banned", nil)
return banError(u, "User is banned")
case user.StatusInactive:
return serializer.NewError(serializer.CodeUserNotActivated, "User is not activated", nil)
}
@ -330,8 +341,14 @@ func CheckEmailAllowed(filter *setting.EmailFilter, email string) error {
return serializer.NewError(serializer.CodeParamErr, "Invalid email", nil)
}
if filter.DisableSubAddress && strings.Contains(local, "+") {
return serializer.NewError(serializer.CodeParamErr, "Sub-address emails are not allowed", nil)
if filter.DisableSubAddress {
chars := filter.SubAddressChars
if chars == "" {
chars = "+"
}
if strings.ContainsAny(local, chars) {
return serializer.NewError(serializer.CodeParamErr, "Sub-address emails are not allowed", nil)
}
}
domain = strings.ToLower(domain)

@ -103,6 +103,35 @@ func TestCheckEmailAllowed(t *testing.T) {
},
email: "a+tag@example.com",
},
{
name: "custom sub-address chars rejected",
filter: &setting.EmailFilter{
Mode: setting.EmailFilterDisabled,
DisableSubAddress: true,
SubAddressChars: "+-.",
},
email: "a-tag@example.com",
wantErr: true,
code: serializer.CodeParamErr,
},
{
name: "custom sub-address chars allow plain local part",
filter: &setting.EmailFilter{
Mode: setting.EmailFilterDisabled,
DisableSubAddress: true,
SubAddressChars: "+-.",
},
email: "user@example.com",
},
{
name: "dot in domain not treated as sub-address",
filter: &setting.EmailFilter{
Mode: setting.EmailFilterDisabled,
DisableSubAddress: true,
SubAddressChars: "+-.",
},
email: "user@mail.example.com",
},
{
name: "invalid email rejected",
filter: &setting.EmailFilter{Mode: setting.EmailFilterDisabled},

Loading…
Cancel
Save