diff --git a/internal/handler/admin.go b/internal/handler/admin.go index c9802a5..91c78ff 100644 --- a/internal/handler/admin.go +++ b/internal/handler/admin.go @@ -31,7 +31,66 @@ func listUsersHandler(svc *service.Container) gin.HandlerFunc { for i := range users { users[i].PopulateComputedFields() } - c.JSON(http.StatusOK, users) + maxUsers, err := service.LoadMaxUsers(c.Request.Context(), svc.Repo) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{ + "users": users, + "max_users": maxUsers, + "current_users": len(users), + }) + } +} + +type updateUserLimitReq struct { + MaxUsers int `json:"max_users" binding:"required"` +} + +func getUserLimitHandler(svc *service.Container) gin.HandlerFunc { + return func(c *gin.Context) { + maxUsers, err := service.LoadMaxUsers(c.Request.Context(), svc.Repo) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + currentUsers, err := svc.Repo.User.Count(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{ + "max_users": maxUsers, + "current_users": currentUsers, + }) + } +} + +func updateUserLimitHandler(svc *service.Container) gin.HandlerFunc { + return func(c *gin.Context) { + var req updateUserLimitReq + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + if err := service.SaveMaxUsers(c.Request.Context(), svc.Repo, req.MaxUsers); err != nil { + if errors.Is(err, service.ErrInvalidMaxUsers) { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + currentUsers, err := svc.Repo.User.Count(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{ + "max_users": req.MaxUsers, + "current_users": currentUsers, + }) } } @@ -288,7 +347,12 @@ func writeUserMutationError(c *gin.Context, svc *service.Container, err error) { case errors.Is(err, service.ErrUsernameTaken): c.JSON(http.StatusConflict, gin.H{"error": "username already taken"}) case errors.Is(err, service.ErrUserLimitReached): - c.JSON(http.StatusBadRequest, gin.H{"error": "user limit reached", "max_users": service.UserLimit}) + maxUsers, loadErr := service.LoadMaxUsers(c.Request.Context(), svc.Repo) + if loadErr != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": loadErr.Error()}) + return + } + c.JSON(http.StatusBadRequest, gin.H{"error": "user limit reached", "max_users": maxUsers}) default: c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) } diff --git a/internal/handler/admin_user_limit_test.go b/internal/handler/admin_user_limit_test.go new file mode 100644 index 0000000..849f046 --- /dev/null +++ b/internal/handler/admin_user_limit_test.go @@ -0,0 +1,55 @@ +package handler + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "github.com/glebarez/sqlite" + "go.uber.org/zap" + "gorm.io/gorm" + + "github.com/truewhile/MeBox/internal/model" + "github.com/truewhile/MeBox/internal/repository" + "github.com/truewhile/MeBox/internal/service" +) + +func TestUserLimitHandlers(t *testing.T) { + gin.SetMode(gin.TestMode) + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err := db.AutoMigrate(model.AllModels()...); err != nil { + t.Fatal(err) + } + repos := repository.New(db) + cfg := &service.Container{Repo: repos, Auth: service.NewAuthService(nil, zap.NewNop(), repos, service.NewTokenService(nil, zap.NewNop(), repos), service.NewPermissionService(zap.NewNop(), repos))} + router := gin.New() + router.GET("/admin/users/limit", getUserLimitHandler(cfg)) + router.PUT("/admin/users/limit", updateUserLimitHandler(cfg)) + + req := httptest.NewRequest(http.MethodGet, "/admin/users/limit", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("GET limit status = %d body=%s", w.Code, w.Body.String()) + } + if !strings.Contains(w.Body.String(), `"max_users":20`) { + t.Fatalf("expected default max_users=20, got %s", w.Body.String()) + } + + req = httptest.NewRequest(http.MethodPut, "/admin/users/limit", strings.NewReader(`{"max_users":42}`)) + req.Header.Set("Content-Type", "application/json") + w = httptest.NewRecorder() + router.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("PUT limit status = %d body=%s", w.Code, w.Body.String()) + } + got, err := service.LoadMaxUsers(t.Context(), repos) + if err != nil || got != 42 { + t.Fatalf("stored max users = %d err=%v, want 42", got, err) + } +} diff --git a/internal/handler/auth.go b/internal/handler/auth.go index 1afabcf..2be8c0c 100644 --- a/internal/handler/auth.go +++ b/internal/handler/auth.go @@ -68,6 +68,15 @@ func registerHandler(svc *service.Container) gin.HandlerFunc { c.JSON(http.StatusConflict, gin.H{"error": "username taken"}) return } + if errors.Is(err, service.ErrUserLimitReached) { + maxUsers, loadErr := service.LoadMaxUsers(c.Request.Context(), svc.Repo) + if loadErr != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": loadErr.Error()}) + return + } + c.JSON(http.StatusBadRequest, gin.H{"error": "user limit reached", "max_users": maxUsers}) + return + } c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } diff --git a/internal/handler/emby_session_routes_test.go b/internal/handler/emby_session_routes_test.go index 812b36e..a85f237 100644 --- a/internal/handler/emby_session_routes_test.go +++ b/internal/handler/emby_session_routes_test.go @@ -213,10 +213,13 @@ func TestEmbyAuthenticatedRequestRefreshesRealtimeUserActivity(t *testing.T) { if w.Code != http.StatusOK { t.Fatalf("admin users status: %d body=%s", w.Code, w.Body.String()) } - var users []model.User - if err := json.Unmarshal(w.Body.Bytes(), &users); err != nil { + var payload struct { + Users []model.User `json:"users"` + } + if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil { t.Fatalf("decode users: %v", err) } + users := payload.Users if len(users) != 1 { t.Fatalf("users = %#v", users) } diff --git a/internal/handler/routes_admin.go b/internal/handler/routes_admin.go index 67809cf..351bb35 100644 --- a/internal/handler/routes_admin.go +++ b/internal/handler/routes_admin.go @@ -102,6 +102,8 @@ func registerAdminStrmRoutes(admin *gin.RouterGroup, svc *service.Container) { } func registerAdminUserRoutes(admin *gin.RouterGroup, svc *service.Container) { + admin.GET("/users/limit", getUserLimitHandler(svc)) + admin.PUT("/users/limit", updateUserLimitHandler(svc)) admin.GET("/users", listUsersHandler(svc)) admin.POST("/users", createUserHandler(svc)) admin.PATCH("/users/:id", updateUserHandler(svc)) diff --git a/internal/service/auth.go b/internal/service/auth.go index 27c29db..0048f3e 100644 --- a/internal/service/auth.go +++ b/internal/service/auth.go @@ -40,14 +40,8 @@ var ( ErrUserExpired = errors.New("user account has expired") ) -// MaxUsers 是单实例允许的最大用户数(开源版本固定上限)。 -const MaxUsers = OpenSourceUserLimit - -// OpenSourceUserLimit 是开源版本的用户数上限。 -const OpenSourceUserLimit = 20 - -// UserLimit 是注册/邀请码发放时的固定用户数上限(授权管理已移除,固定为开源上限)。 -const UserLimit = OpenSourceUserLimit +// MaxUsers is kept for tests that seed up to the default cap. +const MaxUsers = DefaultMaxUsers // SeedAdmin makes sure at least one admin user exists. It mirrors the // legacy default behaviour: if no admin row is found we create @@ -103,9 +97,13 @@ func (s *AuthService) Register(ctx context.Context, username, password string) ( if err := s.repo.User.ReleaseDeletedUsername(ctx, username); err != nil { return nil, nil, err } + limit, err := LoadMaxUsers(ctx, s.repo) + if err != nil { + return nil, nil, err + } if n, err := s.repo.User.Count(ctx); err != nil { return nil, nil, err - } else if n >= UserLimit { + } else if n >= int64(limit) { return nil, nil, ErrUserLimitReached } hash, err := hashPassword(password) diff --git a/internal/service/user_limits.go b/internal/service/user_limits.go new file mode 100644 index 0000000..84f4935 --- /dev/null +++ b/internal/service/user_limits.go @@ -0,0 +1,71 @@ +package service + +import ( + "context" + "errors" + "fmt" + "strconv" + "strings" + + "github.com/truewhile/MeBox/internal/repository" +) + +const ( + // SettingMaxUsers stores the per-instance user cap in the settings table. + SettingMaxUsers = "user.max_users" + + // DefaultMaxUsers is used when the setting is missing or invalid. + DefaultMaxUsers = 20 + + // MaxUsersHardCap prevents absurd values from admin input. + MaxUsersHardCap = 10000 +) + +var ( + ErrInvalidMaxUsers = errors.New("invalid max users") +) + +// LoadMaxUsers reads the configured user cap, falling back to DefaultMaxUsers. +func LoadMaxUsers(ctx context.Context, repo *repository.Container) (int, error) { + if repo == nil || repo.Setting == nil { + return DefaultMaxUsers, nil + } + raw, err := repo.Setting.Get(ctx, SettingMaxUsers) + if err != nil { + return DefaultMaxUsers, err + } + return parseMaxUsersSetting(raw), nil +} + +func parseMaxUsersSetting(raw string) int { + n, err := strconv.Atoi(strings.TrimSpace(raw)) + if err != nil || n < 1 { + return DefaultMaxUsers + } + if n > MaxUsersHardCap { + return MaxUsersHardCap + } + return n +} + +// ValidateMaxUsers checks an admin-provided cap before persisting it. +func ValidateMaxUsers(n int) error { + if n < 1 { + return fmt.Errorf("%w: must be at least 1", ErrInvalidMaxUsers) + } + if n > MaxUsersHardCap { + return fmt.Errorf("%w: must be at most %d", ErrInvalidMaxUsers, MaxUsersHardCap) + } + return nil +} + +// SaveMaxUsers persists the user cap in settings. +func SaveMaxUsers(ctx context.Context, repo *repository.Container, n int) error { + if err := ValidateMaxUsers(n); err != nil { + return err + } + if repo == nil || repo.Setting == nil { + return errors.New("settings repository unavailable") + } + return repo.Setting.Set(ctx, SettingMaxUsers, strconv.Itoa(n)) +} diff --git a/internal/service/user_limits_test.go b/internal/service/user_limits_test.go new file mode 100644 index 0000000..59bdde6 --- /dev/null +++ b/internal/service/user_limits_test.go @@ -0,0 +1,67 @@ +package service + +import ( + "context" + "fmt" + "testing" + + "github.com/truewhile/MeBox/internal/model" +) + +func TestLoadMaxUsersDefaultsToTwenty(t *testing.T) { + ctx := context.Background() + repos, _, _, _ := newAuthTestServices(t) + got, err := LoadMaxUsers(ctx, repos) + if err != nil { + t.Fatal(err) + } + if got != DefaultMaxUsers { + t.Fatalf("max users = %d, want %d", got, DefaultMaxUsers) + } +} + +func TestSaveAndLoadMaxUsers(t *testing.T) { + ctx := context.Background() + repos, _, _, _ := newAuthTestServices(t) + if err := SaveMaxUsers(ctx, repos, 50); err != nil { + t.Fatal(err) + } + got, err := LoadMaxUsers(ctx, repos) + if err != nil { + t.Fatal(err) + } + if got != 50 { + t.Fatalf("max users = %d, want 50", got) + } +} + +func TestValidateMaxUsersRejectsInvalid(t *testing.T) { + if err := ValidateMaxUsers(0); err == nil { + t.Fatal("expected error for 0") + } + if err := ValidateMaxUsers(MaxUsersHardCap + 1); err == nil { + t.Fatal("expected error above hard cap") + } +} + +func TestRegisterRespectsConfiguredUserLimit(t *testing.T) { + ctx := context.Background() + repos, auth, _, _ := newAuthTestServices(t) + if err := SaveMaxUsers(ctx, repos, 3); err != nil { + t.Fatal(err) + } + for i := 0; i < 3; i++ { + if err := repos.User.Create(ctx, &model.User{ + Username: fmt.Sprintf("user-%02d", i), + PasswordHash: "hash", + Role: "user", + Tier: "free", + }); err != nil { + t.Fatal(err) + } + } + _, _, err := auth.Register(ctx, "overflow", "password") + if err == nil { + t.Fatal("expected user limit error") + } +} diff --git a/web/src/api/admin.ts b/web/src/api/admin.ts index 925d627..98f3d86 100644 --- a/web/src/api/admin.ts +++ b/web/src/api/admin.ts @@ -64,8 +64,24 @@ export interface SystemUpdateStatus { started_at?: string } +export interface UserListResponse { + users: User[] + max_users: number + current_users: number +} + +export interface UserLimitResponse { + max_users: number + current_users: number +} + export const adminAPI = { - listUsers: () => api.get('/admin/users').then((r) => r.data), + listUsers: () => api.get('/admin/users').then((r) => r.data), + + getUserLimit: () => api.get('/admin/users/limit').then((r) => r.data), + + updateUserLimit: (maxUsers: number) => + api.put('/admin/users/limit', { max_users: maxUsers }).then((r) => r.data), createUser: (payload: { username: string; password: string }) => api.post('/admin/users', payload).then((r) => r.data), diff --git a/web/src/pages/AdminUsersForm.tsx b/web/src/pages/AdminUsersForm.tsx index 2033b41..6c1e090 100644 --- a/web/src/pages/AdminUsersForm.tsx +++ b/web/src/pages/AdminUsersForm.tsx @@ -1,12 +1,16 @@ import { FormEvent } from 'react' -import { Plus } from 'lucide-react' +import { Plus, Save } from 'lucide-react' type AdminUsersFormProps = { usersCount: number - userLimitLabel: string + maxUsers: number + maxUsersDraft: string + savingLimit: boolean username: string password: string userLimitReached: boolean + onMaxUsersDraftChange: (value: string) => void + onSaveMaxUsers: () => void onUsernameChange: (value: string) => void onPasswordChange: (value: string) => void onSubmit: (e: FormEvent) => void @@ -14,27 +18,61 @@ type AdminUsersFormProps = { export function AdminUsersForm({ usersCount, - userLimitLabel, + maxUsers, + maxUsersDraft, + savingLimit, username, password, userLimitReached, + onMaxUsersDraftChange, + onSaveMaxUsers, onUsernameChange, onPasswordChange, onSubmit, }: AdminUsersFormProps) { + const draftValue = Number(maxUsersDraft) + const limitChanged = maxUsersDraft.trim() !== '' && Number.isFinite(draftValue) && draftValue !== maxUsers + return (

用户管理

- 已创建 {usersCount}/{userLimitLabel} 个用户;新增用户默认只有媒体库浏览、播放、外部播放器与第三方客户端观看权限。 + 已创建 {usersCount}/{maxUsers} 个用户;新增用户默认只有媒体库浏览、播放、外部播放器与第三方客户端观看权限。

默认管理员不可删除 · 最高权限
+ +
+ + +

+ 默认 20 人。达到上限后无法继续注册或添加用户;若当前用户数已超过新上限,已有账号不受影响,但需删除部分用户后才能继续添加。 +

+
+ ([]) + const [maxUsers, setMaxUsers] = useState(DEFAULT_MAX_USERS) + const [maxUsersDraft, setMaxUsersDraft] = useState(String(DEFAULT_MAX_USERS)) + const [savingLimit, setSavingLimit] = useState(false) const [username, setUsername] = useState('') const [password, setPassword] = useState('') const [editingID, setEditingID] = useState(null) @@ -19,7 +24,10 @@ export function AdminUsersPanel() { const [configuringLibrariesUser, setConfiguringLibrariesUser] = useState(null) const refresh = async () => { - setUsers(await adminAPI.listUsers()) + const data = await adminAPI.listUsers() + setUsers(data.users) + setMaxUsers(data.max_users) + setMaxUsersDraft(String(data.max_users)) } useEffect(() => { refresh().catch(() => undefined) @@ -27,9 +35,34 @@ export function AdminUsersPanel() { return () => window.clearInterval(timer) }, []) - const maxUsers = 20 const userLimitReached = users.length >= maxUsers - const userLimitLabel = String(maxUsers) + + const handleSaveMaxUsers = async () => { + const next = Number(maxUsersDraft) + if (!Number.isFinite(next) || next < 1) { + toast.error('用户上限至少为 1') + return + } + if (next > 10000) { + toast.error('用户上限不能超过 10000') + return + } + if (next === maxUsers) return + setSavingLimit(true) + try { + const data = await adminAPI.updateUserLimit(next) + setMaxUsers(data.max_users) + setMaxUsersDraft(String(data.max_users)) + toast.success(`用户上限已更新为 ${data.max_users} 人`) + } catch (err: unknown) { + const msg = + (err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? + '保存用户上限失败' + toast.error(msg) + } finally { + setSavingLimit(false) + } + } const handleCreate = async (e: FormEvent) => { e.preventDefault() @@ -132,10 +165,14 @@ export function AdminUsersPanel() {