feat(权限组): 增加账号配额与邮件发送限制

- 为权限组与用户新增配额字段,支持附件大小、SMTP/IMAP/POP3 频率限制。
- 后端补充权限组配额校验、迁移与 SMTP 发送限流,邮件发送/定时发送时统一生效。
- 前端在管理页、邮件撰写页和个人资料页展示并编辑配额信息。
This commit is contained in:
LanQin_
2026-06-23 11:09:36 +08:00
parent 34d35e8f04
commit 36b4c4c60f
11 changed files with 666 additions and 51 deletions
+41
View File
@@ -120,6 +120,7 @@ func (a *App) migrate(ctx context.Context) error {
name TEXT NOT NULL UNIQUE,
description TEXT NOT NULL DEFAULT '',
permissions_json TEXT NOT NULL DEFAULT '[]',
limits_json TEXT NOT NULL DEFAULT '{"maxAttachmentMb":25,"smtpDailyLimit":200,"smtpMinuteLimit":20,"imapMinuteLimit":200,"pop3MinuteLimit":150}',
system INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
@@ -254,6 +255,13 @@ func (a *App) migrate(ctx context.Context) error {
sent_at TEXT
)`,
`CREATE INDEX IF NOT EXISTS idx_scheduled_sends_due ON scheduled_sends(status, send_at)`,
`CREATE TABLE IF NOT EXISTS smtp_send_events (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
mailbox_id TEXT NOT NULL REFERENCES mailboxes(id) ON DELETE CASCADE,
created_at TEXT NOT NULL
)`,
`CREATE INDEX IF NOT EXISTS idx_smtp_send_events_user_created ON smtp_send_events(user_id, created_at)`,
`CREATE TABLE IF NOT EXISTS contacts (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
@@ -343,12 +351,45 @@ func (a *App) migrate(ctx context.Context) error {
if err := a.migrateLegacyBootstrapMailbox(ctx); err != nil {
return err
}
if err := a.migratePermissionGroupLimits(ctx); err != nil {
return err
}
if err := a.ensureDefaultPermissionGroups(ctx); err != nil {
return err
}
return nil
}
func (a *App) migratePermissionGroupLimits(ctx context.Context) error {
rows, err := a.db.QueryContext(ctx, `PRAGMA table_info(permission_groups)`)
if err != nil {
return err
}
hasLimits := false
for rows.Next() {
var cid int
var name, typ string
var notnull int
var dflt any
var pk int
if err := rows.Scan(&cid, &name, &typ, &notnull, &dflt, &pk); err != nil {
rows.Close()
return err
}
if name == "limits_json" {
hasLimits = true
}
}
if err := rows.Close(); err != nil {
return err
}
if hasLimits {
return nil
}
_, err = a.db.ExecContext(ctx, `ALTER TABLE permission_groups ADD COLUMN limits_json TEXT NOT NULL DEFAULT '{"maxAttachmentMb":25,"smtpDailyLimit":200,"smtpMinuteLimit":20,"imapMinuteLimit":200,"pop3MinuteLimit":150}'`)
return err
}
// migrateLegacyBootstrapMailbox removes mailboxes created by an older version of seed()
// that implicitly created an admin mailbox with display_name "LanQin Admin".
// Current seed() creates mailboxes with display_name = admin email, so this migration
+85
View File
@@ -183,6 +183,20 @@ func updateRegularPermissionGroup(t *testing.T, admin *testClient, permissions [
return group
}
func updateRegularPermissionGroupWithLimits(t *testing.T, admin *testClient, permissions []string, limits PermissionLimits) PermissionGroup {
t.Helper()
var group PermissionGroup
if code := admin.do("POST", "/api/admin/permission-groups/"+PermissionGroupRegular, map[string]any{
"name": "Regular Users",
"description": "Default permissions for regular users",
"permissions": permissions,
"limits": limits,
}, &group); code != http.StatusOK {
t.Fatalf("update regular permission group limits code=%d group=%+v", code, group)
}
return group
}
func systemSettingsPayload(settings SystemSettings) map[string]any {
return map[string]any{
"publicHostname": settings.PublicHostname,
@@ -380,6 +394,70 @@ func TestScheduleSendQueuesFutureMessage(t *testing.T) {
}
}
func TestPermissionGroupMailLimits(t *testing.T) {
a := newTestApp(t)
ts := httptest.NewServer(a.Router())
defer ts.Close()
admin := &testClient{t: t, server: ts}
var login map[string]any
if code := admin.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, &login); code != http.StatusOK {
t.Fatalf("admin login code=%d body=%v", code, login)
}
updateRegularPermissionGroupWithLimits(t, admin, regularUserDefaultPermissions(), PermissionLimits{MaxAttachmentMB: 1, SMTPDailyLimit: 10, SMTPMinuteLimit: 1, IMAPMinuteLimit: 1, POP3MinuteLimit: 1})
domainID := mustDefaultDomainID(t, a)
sender := createTestMailbox(t, admin, domainID, "limited-sender", "Limited Sender", "Password123!", nil)
recipient := createTestMailbox(t, admin, domainID, "limited-recipient", "Limited Recipient", "Password123!", nil)
user := &testClient{t: t, server: ts}
if code := user.do("POST", "/api/auth/login", map[string]string{"email": sender.Address, "password": "Password123!"}, &login); code != http.StatusOK {
t.Fatalf("user login code=%d", code)
}
var me struct {
User User `json:"user"`
}
if code := user.do("GET", "/api/me", nil, &me); code != http.StatusOK {
t.Fatalf("me code=%d user=%+v", code, me.User)
}
if me.User.Limits.MaxAttachmentMB != 1 || me.User.Limits.SMTPMinuteLimit != 1 || me.User.Limits.IMAPMinuteLimit != 1 || me.User.Limits.POP3MinuteLimit != 1 {
t.Fatalf("user limits not attached: %+v", me.User.Limits)
}
tooLargeAttachment := base64.StdEncoding.EncodeToString(bytes.Repeat([]byte("x"), 1024*1024+1))
var errBody map[string]any
if code := user.do("POST", "/api/mail/send", map[string]any{
"mailboxId": sender.ID,
"to": []string{recipient.Address},
"subject": "too large",
"text": "body",
"html": "<p>body</p>",
"attachments": []map[string]string{{
"filename": "large.bin",
"contentType": "application/octet-stream",
"contentBase64": tooLargeAttachment,
}},
}, &errBody); code != http.StatusBadRequest {
t.Fatalf("oversized attachment should be rejected code=%d body=%v", code, errBody)
}
var sent MailMessage
payload := map[string]any{
"mailboxId": sender.ID,
"to": []string{recipient.Address},
"subject": "first limited send",
"text": "body",
"html": "<p>body</p>",
}
if code := user.do("POST", "/api/mail/send", payload, &sent); code != http.StatusCreated {
t.Fatalf("first send code=%d msg=%+v", code, sent)
}
payload["subject"] = "second limited send"
if code := user.do("POST", "/api/mail/send", payload, &errBody); code != http.StatusTooManyRequests {
t.Fatalf("smtp minute limit should reject second send code=%d body=%v", code, errBody)
}
}
func TestOpenRegistrationCreatesLoginUserOnly(t *testing.T) {
a := newTestApp(t)
ts := httptest.NewServer(a.Router())
@@ -925,6 +1003,9 @@ func TestFixedRolesProtectAdminRoutesAndDefaultAdmin(t *testing.T) {
t.Fatalf("missing fixed permission group %s in %+v", group.ID, groups.Items)
}
}
if groupByID[PermissionGroupRegular].Limits != defaultPermissionLimits() {
t.Fatalf("regular group limits=%+v want %+v", groupByID[PermissionGroupRegular].Limits, defaultPermissionLimits())
}
if groups.Items[0].ID != PermissionGroupSuperAdmin || groups.Items[1].ID != PermissionGroupRegular {
t.Fatalf("unexpected fixed permission groups: %+v", groups.Items)
}
@@ -938,9 +1019,13 @@ func TestFixedRolesProtectAdminRoutesAndDefaultAdmin(t *testing.T) {
"name": "Mailbox Viewers",
"description": "Can view mailboxes only",
"permissions": []string{PermissionAdminOverview, PermissionMailboxesView},
"limits": PermissionLimits{MaxAttachmentMB: 5, SMTPDailyLimit: 8, SMTPMinuteLimit: 2, IMAPMinuteLimit: 5, POP3MinuteLimit: 3},
}, &customGroup); code != http.StatusCreated {
t.Fatalf("custom permission group creation code=%d group=%+v", code, customGroup)
}
if customGroup.Limits.MaxAttachmentMB != 5 || customGroup.Limits.SMTPDailyLimit != 8 || customGroup.Limits.SMTPMinuteLimit != 2 || customGroup.Limits.IMAPMinuteLimit != 5 || customGroup.Limits.POP3MinuteLimit != 3 {
t.Fatalf("custom permission group limits=%+v", customGroup.Limits)
}
if customGroup.System || customGroup.ID == "" || !userHasPermission(&User{Role: "user", Permissions: customGroup.Permissions}, PermissionMailboxesView) || userHasPermission(&User{Role: "user", Permissions: customGroup.Permissions}, PermissionMailboxesCreate) {
t.Fatalf("custom permission group permissions=%+v", customGroup)
}
+114 -3
View File
@@ -391,7 +391,7 @@ func (a *App) handleMailSend(w http.ResponseWriter, r *http.Request) {
respondError(w, http.StatusNotFound, "mailbox not found")
return
}
msg, err := a.sendMailNow(r.Context(), mb, req)
msg, err := a.sendMailNow(r.Context(), currentUser(r), mb, req)
if err != nil {
if errors.Is(err, errNoRecipients) {
badRequest(w, err)
@@ -401,6 +401,14 @@ func (a *App) handleMailSend(w http.ResponseWriter, r *http.Request) {
badRequest(w, err)
return
}
if errors.Is(err, errAttachmentTooLarge) {
badRequest(w, err)
return
}
if errors.Is(err, errSMTPRateLimited) {
respondError(w, http.StatusTooManyRequests, err.Error())
return
}
if strings.HasPrefix(err.Error(), "smtp delivery failed:") {
respondError(w, http.StatusBadGateway, err.Error())
return
@@ -413,8 +421,13 @@ func (a *App) handleMailSend(w http.ResponseWriter, r *http.Request) {
var errNoRecipients = errors.New("at least one recipient is required")
var errInvalidMIME = errors.New("invalid mime message")
var errAttachmentTooLarge = errors.New("attachment size exceeds permission limit")
var errSMTPRateLimited = errors.New("smtp send rate limit exceeded")
func (a *App) sendMailNow(ctx context.Context, mb *Mailbox, req mailComposeInput) (*MailMessage, error) {
func (a *App) sendMailNow(ctx context.Context, user *User, mb *Mailbox, req mailComposeInput) (*MailMessage, error) {
if err := validateAttachmentLimit(req.Attachments, userLimits(user)); err != nil {
return nil, err
}
req.To, req.CC, req.BCC = dedupeEmails(req.To), dedupeEmails(req.CC), dedupeEmails(req.BCC)
allRecipients := append(append([]string{}, req.To...), append(req.CC, req.BCC...)...)
if len(allRecipients) == 0 {
@@ -439,6 +452,9 @@ func (a *App) sendMailNow(ctx context.Context, mb *Mailbox, req mailComposeInput
if err != nil {
return nil, fmt.Errorf("%w: %v", errInvalidMIME, err)
}
if err := a.recordSMTPRate(ctx, user, mb); err != nil {
return nil, err
}
if a.cfg.SMTPHost != "" {
if err := a.sendSMTP(mb.Address, allRecipients, mimeBytes); err != nil {
return nil, fmt.Errorf("smtp delivery failed: %w", err)
@@ -505,6 +521,83 @@ func (a *App) sendMailNow(ctx context.Context, mb *Mailbox, req mailComposeInput
return msg, nil
}
func userLimits(user *User) PermissionLimits {
if user == nil {
return defaultPermissionLimits()
}
return user.Limits
}
func validateAttachmentLimit(attachments []AttachmentInput, limits PermissionLimits) error {
limitBytes := attachmentLimitBytes(limits)
if limitBytes == 0 {
return nil
}
for _, att := range attachments {
decodedLen, err := decodedBase64Len(att.ContentBase64)
if err != nil {
return fmt.Errorf("%w: %v", errInvalidMIME, err)
}
if decodedLen > limitBytes {
return fmt.Errorf("%w: max %d MB", errAttachmentTooLarge, limits.MaxAttachmentMB)
}
}
return nil
}
func decodedBase64Len(value string) (int64, error) {
value = strings.TrimSpace(value)
if value == "" {
return 0, nil
}
data, err := base64.StdEncoding.DecodeString(value)
if err != nil {
return 0, err
}
return int64(len(data)), nil
}
func (a *App) recordSMTPRate(ctx context.Context, user *User, mb *Mailbox) error {
if user == nil || mb == nil || user.Role == "admin" {
return nil
}
limits := user.Limits
if limits.SMTPDailyLimit == 0 && limits.SMTPMinuteLimit == 0 {
return nil
}
now := a.now().UTC()
tx, err := a.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.ExecContext(ctx, `DELETE FROM smtp_send_events WHERE created_at<?`, now.Add(-24*time.Hour).Format(time.RFC3339Nano)); err != nil {
return err
}
if limits.SMTPDailyLimit > 0 {
var count int
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM smtp_send_events WHERE user_id=? AND created_at>=?`, user.ID, now.Add(-24*time.Hour).Format(time.RFC3339Nano)).Scan(&count); err != nil {
return err
}
if count >= limits.SMTPDailyLimit {
return fmt.Errorf("%w: daily limit %d", errSMTPRateLimited, limits.SMTPDailyLimit)
}
}
if limits.SMTPMinuteLimit > 0 {
var count int
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM smtp_send_events WHERE user_id=? AND created_at>=?`, user.ID, now.Add(-time.Minute).Format(time.RFC3339Nano)).Scan(&count); err != nil {
return err
}
if count >= limits.SMTPMinuteLimit {
return fmt.Errorf("%w: per-minute limit %d", errSMTPRateLimited, limits.SMTPMinuteLimit)
}
}
if _, err := tx.ExecContext(ctx, `INSERT INTO smtp_send_events(id,user_id,mailbox_id,created_at) VALUES(?,?,?,?)`, newID("smtp"), user.ID, mb.ID, now.Format(time.RFC3339Nano)); err != nil {
return err
}
return tx.Commit()
}
func (a *App) handleSaveDraft(w http.ResponseWriter, r *http.Request) {
var req mailDraftInput
if err := decodeJSON(r, &req); err != nil {
@@ -516,6 +609,16 @@ func (a *App) handleSaveDraft(w http.ResponseWriter, r *http.Request) {
respondError(w, http.StatusNotFound, "mailbox not found")
return
}
if req.Attachments != nil {
if err := validateAttachmentLimit(*req.Attachments, userLimits(currentUser(r))); err != nil {
if errors.Is(err, errAttachmentTooLarge) || errors.Is(err, errInvalidMIME) {
badRequest(w, err)
return
}
respondError(w, http.StatusInternalServerError, err.Error())
return
}
}
compose := mailComposeInput{MailboxID: req.MailboxID, To: req.To, CC: req.CC, BCC: req.BCC, Subject: req.Subject, Text: req.Text, HTML: req.HTML}
compose.To, compose.CC, compose.BCC = dedupeEmails(compose.To), dedupeEmails(compose.CC), dedupeEmails(compose.BCC)
subject := strings.TrimSpace(compose.Subject)
@@ -687,6 +790,14 @@ func (a *App) handleScheduleSend(w http.ResponseWriter, r *http.Request) {
return
}
compose := mailComposeInput{MailboxID: req.MailboxID, To: req.To, CC: req.CC, BCC: req.BCC, Subject: req.Subject, Text: req.Text, HTML: req.HTML, Attachments: req.Attachments}
if err := validateAttachmentLimit(compose.Attachments, userLimits(currentUser(r))); err != nil {
if errors.Is(err, errAttachmentTooLarge) || errors.Is(err, errInvalidMIME) {
badRequest(w, err)
return
}
respondError(w, http.StatusInternalServerError, err.Error())
return
}
compose.To, compose.CC, compose.BCC = dedupeEmails(compose.To), dedupeEmails(compose.CC), dedupeEmails(compose.BCC)
if len(append(append([]string{}, compose.To...), append(compose.CC, compose.BCC...)...)) == 0 {
badRequest(w, errNoRecipients)
@@ -823,7 +934,7 @@ func (a *App) processScheduledSend(ctx context.Context, id, mailboxID, draftID,
return
}
compose := mailComposeInput{MailboxID: payload.MailboxID, To: payload.To, CC: payload.CC, BCC: payload.BCC, Subject: payload.Subject, Text: payload.Text, HTML: payload.HTML, Attachments: payload.Attachments}
if _, err := a.sendMailNow(ctx, mb, compose); err != nil {
if _, err := a.sendMailNow(ctx, user, mb, compose); err != nil {
a.markScheduledSendFailed(ctx, id, err.Error())
return
}
@@ -14,7 +14,7 @@ func (a *App) handlePermissionCatalog(w http.ResponseWriter, r *http.Request) {
}
func (a *App) handleListPermissionGroups(w http.ResponseWriter, r *http.Request) {
rows, err := a.db.QueryContext(r.Context(), `SELECT id,name,description,permissions_json,system,created_at,updated_at
rows, err := a.db.QueryContext(r.Context(), `SELECT id,name,description,permissions_json,limits_json,system,created_at,updated_at
FROM permission_groups
ORDER BY created_at ASC,name ASC`)
if err != nil {
@@ -25,14 +25,15 @@ func (a *App) handleListPermissionGroups(w http.ResponseWriter, r *http.Request)
items := []PermissionGroup{}
for rows.Next() {
var item PermissionGroup
var raw, created, updated string
var rawPermissions, rawLimits, created, updated string
var system int
if err := rows.Scan(&item.ID, &item.Name, &item.Description, &raw, &system, &created, &updated); err != nil {
if err := rows.Scan(&item.ID, &item.Name, &item.Description, &rawPermissions, &rawLimits, &system, &created, &updated); err != nil {
rows.Close()
respondError(w, http.StatusInternalServerError, "failed to scan permission groups")
return
}
item.Permissions = decodeStoredPermissions(raw)
item.Permissions = decodeStoredPermissions(rawPermissions)
item.Limits = decodeStoredLimits(rawLimits)
item.System = intBool(system)
item.CreatedAt = parseTime(created)
item.UpdatedAt = parseTime(updated)
@@ -83,9 +84,10 @@ func (a *App) handleListPermissionGroups(w http.ResponseWriter, r *http.Request)
func (a *App) handleCreatePermissionGroup(w http.ResponseWriter, r *http.Request) {
var req struct {
Name string `json:"name"`
Description string `json:"description"`
Permissions []string `json:"permissions"`
Name string `json:"name"`
Description string `json:"description"`
Permissions []string `json:"permissions"`
Limits *PermissionLimits `json:"limits"`
}
if err := decodeJSON(r, &req); err != nil {
badRequest(w, err)
@@ -105,10 +107,23 @@ func (a *App) handleCreatePermissionGroup(w http.ResponseWriter, r *http.Request
respondError(w, http.StatusForbidden, "cannot grant permissions you do not hold")
return
}
limits := defaultPermissionLimits()
if req.Limits != nil {
var err error
limits, err = normalizePermissionLimits(*req.Limits)
if err != nil {
badRequest(w, err)
return
}
}
if !actorCanGrantLimits(currentUser(r), limits) {
respondError(w, http.StatusForbidden, "cannot grant limits above your own")
return
}
id := newID("pg")
now := a.now().UTC().Format(time.RFC3339Nano)
if _, err := a.db.ExecContext(r.Context(), `INSERT INTO permission_groups(id,name,description,permissions_json,system,created_at,updated_at)
VALUES(?,?,?,?,0,?,?)`, id, name, strings.TrimSpace(req.Description), encodePermissions(permissions), now, now); err != nil {
if _, err := a.db.ExecContext(r.Context(), `INSERT INTO permission_groups(id,name,description,permissions_json,limits_json,system,created_at,updated_at)
VALUES(?,?,?,?,?,0,?,?)`, id, name, strings.TrimSpace(req.Description), encodePermissions(permissions), encodePermissionLimits(limits), now, now); err != nil {
badRequest(w, err)
return
}
@@ -132,9 +147,10 @@ func (a *App) handleUpdatePermissionGroup(w http.ResponseWriter, r *http.Request
return
}
var req struct {
Name string `json:"name"`
Description string `json:"description"`
Permissions []string `json:"permissions"`
Name string `json:"name"`
Description string `json:"description"`
Permissions []string `json:"permissions"`
Limits *PermissionLimits `json:"limits"`
}
if err := decodeJSON(r, &req); err != nil {
badRequest(w, err)
@@ -154,8 +170,28 @@ func (a *App) handleUpdatePermissionGroup(w http.ResponseWriter, r *http.Request
respondError(w, http.StatusForbidden, "cannot grant permissions you do not hold")
return
}
if _, err := a.db.ExecContext(r.Context(), `UPDATE permission_groups SET name=?,description=?,permissions_json=?,updated_at=? WHERE id=?`,
name, strings.TrimSpace(req.Description), encodePermissions(permissions), a.now().UTC().Format(time.RFC3339Nano), id); err != nil {
limits := defaultPermissionLimits()
if req.Limits != nil {
var err error
limits, err = normalizePermissionLimits(*req.Limits)
if err != nil {
badRequest(w, err)
return
}
} else {
var rawLimits string
if err := a.db.QueryRowContext(r.Context(), `SELECT limits_json FROM permission_groups WHERE id=?`, id).Scan(&rawLimits); err != nil {
respondError(w, http.StatusNotFound, "permission group not found")
return
}
limits = decodeStoredLimits(rawLimits)
}
if !actorCanGrantLimits(currentUser(r), limits) {
respondError(w, http.StatusForbidden, "cannot grant limits above your own")
return
}
if _, err := a.db.ExecContext(r.Context(), `UPDATE permission_groups SET name=?,description=?,permissions_json=?,limits_json=?,updated_at=? WHERE id=?`,
name, strings.TrimSpace(req.Description), encodePermissions(permissions), encodePermissionLimits(limits), a.now().UTC().Format(time.RFC3339Nano), id); err != nil {
badRequest(w, err)
return
}
+238 -19
View File
@@ -125,14 +125,125 @@ type PermissionGroupSummary struct {
}
type PermissionGroup struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Permissions []string `json:"permissions"`
System bool `json:"system"`
UserCount int `json:"userCount"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Permissions []string `json:"permissions"`
Limits PermissionLimits `json:"limits"`
System bool `json:"system"`
UserCount int `json:"userCount"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
type PermissionLimits struct {
MaxAttachmentMB int `json:"maxAttachmentMb"`
SMTPDailyLimit int `json:"smtpDailyLimit"`
SMTPMinuteLimit int `json:"smtpMinuteLimit"`
IMAPMinuteLimit int `json:"imapMinuteLimit"`
POP3MinuteLimit int `json:"pop3MinuteLimit"`
}
func defaultPermissionLimits() PermissionLimits {
return PermissionLimits{
MaxAttachmentMB: 25,
SMTPDailyLimit: 200,
SMTPMinuteLimit: 20,
IMAPMinuteLimit: 200,
POP3MinuteLimit: 150,
}
}
func normalizePermissionLimits(limits PermissionLimits) (PermissionLimits, error) {
if limits.MaxAttachmentMB < 0 {
return PermissionLimits{}, errors.New("maxAttachmentMb cannot be negative")
}
if limits.SMTPDailyLimit < 0 {
return PermissionLimits{}, errors.New("smtpDailyLimit cannot be negative")
}
if limits.SMTPMinuteLimit < 0 {
return PermissionLimits{}, errors.New("smtpMinuteLimit cannot be negative")
}
if limits.IMAPMinuteLimit < 0 {
return PermissionLimits{}, errors.New("imapMinuteLimit cannot be negative")
}
if limits.POP3MinuteLimit < 0 {
return PermissionLimits{}, errors.New("pop3MinuteLimit cannot be negative")
}
return limits, nil
}
func decodeStoredLimits(value string) PermissionLimits {
limits := defaultPermissionLimits()
if strings.TrimSpace(value) == "" {
return limits
}
_ = json.Unmarshal([]byte(value), &limits)
normalized, err := normalizePermissionLimits(limits)
if err != nil {
return defaultPermissionLimits()
}
return normalized
}
func encodePermissionLimits(limits PermissionLimits) string {
normalized, err := normalizePermissionLimits(limits)
if err != nil {
normalized = defaultPermissionLimits()
}
data, _ := json.Marshal(normalized)
return string(data)
}
func mergePermissionLimits(left, right PermissionLimits) PermissionLimits {
return PermissionLimits{
MaxAttachmentMB: mergeLimitValue(left.MaxAttachmentMB, right.MaxAttachmentMB),
SMTPDailyLimit: mergeLimitValue(left.SMTPDailyLimit, right.SMTPDailyLimit),
SMTPMinuteLimit: mergeLimitValue(left.SMTPMinuteLimit, right.SMTPMinuteLimit),
IMAPMinuteLimit: mergeLimitValue(left.IMAPMinuteLimit, right.IMAPMinuteLimit),
POP3MinuteLimit: mergeLimitValue(left.POP3MinuteLimit, right.POP3MinuteLimit),
}
}
func mergeLimitValue(left, right int) int {
if left == 0 || right == 0 {
return 0
}
if right > left {
return right
}
return left
}
func actorCanGrantLimits(actor *User, limits PermissionLimits) bool {
if actor == nil {
return false
}
if actor.Role == "admin" {
return true
}
return canGrantLimitValue(actor.Limits.MaxAttachmentMB, limits.MaxAttachmentMB) &&
canGrantLimitValue(actor.Limits.SMTPDailyLimit, limits.SMTPDailyLimit) &&
canGrantLimitValue(actor.Limits.SMTPMinuteLimit, limits.SMTPMinuteLimit) &&
canGrantLimitValue(actor.Limits.IMAPMinuteLimit, limits.IMAPMinuteLimit) &&
canGrantLimitValue(actor.Limits.POP3MinuteLimit, limits.POP3MinuteLimit)
}
func canGrantLimitValue(actorLimit, requestedLimit int) bool {
if actorLimit == 0 {
return true
}
if requestedLimit == 0 {
return false
}
return requestedLimit <= actorLimit
}
func attachmentLimitBytes(limits PermissionLimits) int64 {
if limits.MaxAttachmentMB <= 0 {
return 0
}
return int64(limits.MaxAttachmentMB) * 1024 * 1024
}
var legacyPermissionExpansions = map[string][]string{
@@ -314,6 +425,7 @@ func defaultPermissionGroups() []PermissionGroup {
Name: "超级管理员",
Description: "拥有全部后台权限,由用户身份决定,不通过权限组分配。",
Permissions: allPermissionKeys(),
Limits: PermissionLimits{},
System: true,
},
{
@@ -321,6 +433,7 @@ func defaultPermissionGroups() []PermissionGroup {
Name: "普通用户",
Description: "仅可使用自己的邮箱功能,不包含后台权限。",
Permissions: regularUserDefaultPermissions(),
Limits: defaultPermissionLimits(),
System: true,
},
}
@@ -386,15 +499,15 @@ func (a *App) ensureDefaultPermissionGroups(ctx context.Context) error {
if _, err := a.db.ExecContext(ctx, `UPDATE permission_groups SET name=name || ' (' || id || ')' WHERE name=? AND id<>?`, item.Name, item.ID); err != nil {
return err
}
query := `INSERT INTO permission_groups(id,name,description,permissions_json,system,created_at,updated_at)
VALUES(?,?,?,?,?,?,?)
ON CONFLICT(id) DO UPDATE SET name=excluded.name, description=excluded.description, permissions_json=excluded.permissions_json, system=excluded.system, updated_at=excluded.updated_at`
query := `INSERT INTO permission_groups(id,name,description,permissions_json,limits_json,system,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?)
ON CONFLICT(id) DO UPDATE SET name=excluded.name, description=excluded.description, permissions_json=excluded.permissions_json, limits_json=excluded.limits_json, system=excluded.system, updated_at=excluded.updated_at`
if item.ID == PermissionGroupRegular {
query = `INSERT INTO permission_groups(id,name,description,permissions_json,system,created_at,updated_at)
VALUES(?,?,?,?,?,?,?)
query = `INSERT INTO permission_groups(id,name,description,permissions_json,limits_json,system,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?)
ON CONFLICT(id) DO UPDATE SET name=excluded.name, description=excluded.description, system=excluded.system, updated_at=excluded.updated_at`
}
if _, err := a.db.ExecContext(ctx, query, item.ID, item.Name, item.Description, encodePermissions(item.Permissions), boolInt(item.System), now, now); err != nil {
if _, err := a.db.ExecContext(ctx, query, item.ID, item.Name, item.Description, encodePermissions(item.Permissions), encodePermissionLimits(item.Limits), boolInt(item.System), now, now); err != nil {
return err
}
}
@@ -475,11 +588,16 @@ func (a *App) attachUserAuthorization(ctx context.Context, u *User) error {
if err != nil {
return err
}
limits, err := a.limitsForUser(ctx, u.ID, u.Role)
if err != nil {
return err
}
groupIDs, groups, err := a.permissionGroupsForUser(ctx, u.ID, u.Role)
if err != nil {
return err
}
u.Permissions = permissions
u.Limits = limits
u.PermissionGroupIDs = groupIDs
u.PermissionGroups = groups
u.Protected = a.isDefaultAdminUser(u)
@@ -526,6 +644,59 @@ func (a *App) permissionsForUser(ctx context.Context, userID, role string) ([]st
return out, nil
}
func (a *App) limitsForUser(ctx context.Context, userID, role string) (PermissionLimits, error) {
if role == "admin" {
return PermissionLimits{}, nil
}
limits, ok, err := a.regularGroupLimits(ctx, nil)
if err != nil {
return PermissionLimits{}, err
}
if !ok {
limits = defaultPermissionLimits()
}
rows, err := a.db.QueryContext(ctx, `SELECT pg.id,pg.limits_json
FROM permission_groups pg
JOIN user_permission_groups upg ON upg.group_id=pg.id
WHERE upg.user_id=?`, userID)
if err != nil {
return PermissionLimits{}, err
}
defer rows.Close()
for rows.Next() {
var groupID, raw string
if err := rows.Scan(&groupID, &raw); err != nil {
return PermissionLimits{}, err
}
if !isAssignablePermissionGroupID(groupID) {
continue
}
limits = mergePermissionLimits(limits, decodeStoredLimits(raw))
}
if err := rows.Err(); err != nil {
return PermissionLimits{}, err
}
return limits, nil
}
func (a *App) regularGroupLimits(ctx context.Context, tx *sql.Tx) (PermissionLimits, bool, error) {
var raw string
query := `SELECT limits_json FROM permission_groups WHERE id=?`
var err error
if tx != nil {
err = tx.QueryRowContext(ctx, query, PermissionGroupRegular).Scan(&raw)
} else {
err = a.db.QueryRowContext(ctx, query, PermissionGroupRegular).Scan(&raw)
}
if errors.Is(err, sql.ErrNoRows) {
return PermissionLimits{}, false, nil
}
if err != nil {
return PermissionLimits{}, false, err
}
return decodeStoredLimits(raw), true, nil
}
func (a *App) addRegularGroupPermissions(ctx context.Context, tx *sql.Tx, seen map[string]bool) error {
var raw string
query := `SELECT permissions_json FROM permission_groups WHERE id=?`
@@ -568,6 +739,21 @@ func (a *App) effectivePermissionsForUserGroups(ctx context.Context, tx *sql.Tx,
return out, nil
}
func (a *App) effectiveLimitsForUserGroups(ctx context.Context, tx *sql.Tx, groupIDs []string) (PermissionLimits, error) {
limits, ok, err := a.regularGroupLimits(ctx, tx)
if err != nil {
return PermissionLimits{}, err
}
if !ok {
limits = defaultPermissionLimits()
}
groupLimits, err := a.limitsForGroupIDs(ctx, tx, groupIDs)
if err != nil {
return PermissionLimits{}, err
}
return mergePermissionLimits(limits, groupLimits), nil
}
func (a *App) permissionGroupsForUser(ctx context.Context, userID, role string) ([]string, []PermissionGroupSummary, error) {
if role == "admin" {
group := PermissionGroupSummary{ID: PermissionGroupSuperAdmin, Name: "超级管理员"}
@@ -742,6 +928,31 @@ func (a *App) permissionsForGroupIDs(ctx context.Context, tx *sql.Tx, groupIDs [
return out, nil
}
func (a *App) limitsForGroupIDs(ctx context.Context, tx *sql.Tx, groupIDs []string) (PermissionLimits, error) {
limits := PermissionLimits{MaxAttachmentMB: 1, SMTPDailyLimit: 1, SMTPMinuteLimit: 1, IMAPMinuteLimit: 1, POP3MinuteLimit: 1}
for _, groupID := range cleanIDList(groupIDs) {
if !isAssignablePermissionGroupID(groupID) {
return PermissionLimits{}, fmt.Errorf("permission group not assignable: %s", groupID)
}
var raw string
query := `SELECT limits_json FROM permission_groups WHERE id=?`
var err error
if tx != nil {
err = tx.QueryRowContext(ctx, query, groupID).Scan(&raw)
} else {
err = a.db.QueryRowContext(ctx, query, groupID).Scan(&raw)
}
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return PermissionLimits{}, fmt.Errorf("permission group not found: %s", groupID)
}
return PermissionLimits{}, err
}
limits = mergePermissionLimits(limits, decodeStoredLimits(raw))
}
return limits, nil
}
func (a *App) setUserPermissionGroups(ctx context.Context, tx *sql.Tx, userID string, groupIDs []string, actor *User) error {
groupIDs = cleanIDList(groupIDs)
for _, groupID := range groupIDs {
@@ -756,6 +967,13 @@ func (a *App) setUserPermissionGroups(ctx context.Context, tx *sql.Tx, userID st
if !actorCanGrantPermissions(actor, groupPermissions) {
return errors.New("cannot assign permissions you do not hold")
}
groupLimits, err := a.effectiveLimitsForUserGroups(ctx, tx, groupIDs)
if err != nil {
return err
}
if !actorCanGrantLimits(actor, groupLimits) {
return errors.New("cannot assign limits above your own")
}
exec := func(query string, args ...any) error {
var err error
if tx != nil {
@@ -778,18 +996,19 @@ func (a *App) setUserPermissionGroups(ctx context.Context, tx *sql.Tx, userID st
}
func (a *App) permissionGroupByID(ctx context.Context, id string) (*PermissionGroup, error) {
row := a.db.QueryRowContext(ctx, `SELECT pg.id,pg.name,pg.description,pg.permissions_json,pg.system,pg.created_at,pg.updated_at,COUNT(upg.user_id)
row := a.db.QueryRowContext(ctx, `SELECT pg.id,pg.name,pg.description,pg.permissions_json,pg.limits_json,pg.system,pg.created_at,pg.updated_at,COUNT(upg.user_id)
FROM permission_groups pg
LEFT JOIN user_permission_groups upg ON upg.group_id=pg.id
WHERE pg.id=?
GROUP BY pg.id,pg.name,pg.description,pg.permissions_json,pg.system,pg.created_at,pg.updated_at`, id)
GROUP BY pg.id,pg.name,pg.description,pg.permissions_json,pg.limits_json,pg.system,pg.created_at,pg.updated_at`, id)
var group PermissionGroup
var raw, created, updated string
var rawPermissions, rawLimits, created, updated string
var system int
if err := row.Scan(&group.ID, &group.Name, &group.Description, &raw, &system, &created, &updated, &group.UserCount); err != nil {
if err := row.Scan(&group.ID, &group.Name, &group.Description, &rawPermissions, &rawLimits, &system, &created, &updated, &group.UserCount); err != nil {
return nil, err
}
group.Permissions = decodeStoredPermissions(raw)
group.Permissions = decodeStoredPermissions(rawPermissions)
group.Limits = decodeStoredLimits(rawLimits)
group.System = intBool(system)
group.CreatedAt = parseTime(created)
group.UpdatedAt = parseTime(updated)
+1
View File
@@ -11,6 +11,7 @@ type User struct {
Protected bool `json:"protected"`
TwoFactorEnabled bool `json:"twoFactorEnabled"`
Permissions []string `json:"permissions"`
Limits PermissionLimits `json:"limits"`
PermissionGroupIDs []string `json:"permissionGroupIds"`
PermissionGroups []PermissionGroupSummary `json:"permissionGroups"`
CreatedAt time.Time `json:"createdAt"`