diff --git a/README.md b/README.md index d0b205e..b491026 100644 --- a/README.md +++ b/README.md @@ -238,7 +238,7 @@ mkdir -p data cache media downloads ```bash cat > .env <<'EOF' # 固定版本;需要升级时改成新的 MediaStationGo-vX.Y.Z 后执行 docker compose pull && docker compose up -d -MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.27 +MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.28 MEDIASTATION_HTTP_PORT=18080 # 程序数据和缓存建议放在 MediaStationGo 部署目录下,便于备份和迁移。 @@ -307,7 +307,7 @@ vim docker-compose.yml # # 镜像版本: # 默认拉取 latest;如需固定版本,创建 .env 并写入: -# MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.27 +# MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.28 # # 路径映射总览: # /data 程序数据目录。保存 SQLite 数据库、JWT secret、系统配置等,必须持久化。 @@ -516,7 +516,7 @@ docker compose up -d ```bash cat > .env <<'EOF' -MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.27 +MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.28 MEDIASTATION_HTTP_PORT=18080 MEDIASTATION_DATA_DIR=./data MEDIASTATION_CACHE_DIR=./cache @@ -779,26 +779,26 @@ cd MediaStationGo | 平台 | 包名示例 | | --- | --- | -| Linux x86_64 | `MediaStationGo-v0.0.27-linux-amd64.tar.gz` | -| Linux ARM64 | `MediaStationGo-v0.0.27-linux-arm64.tar.gz` | -| Windows x86_64 | `MediaStationGo-v0.0.27-windows-amd64.zip` | -| macOS Intel | `MediaStationGo-v0.0.27-darwin-amd64.tar.gz` | -| macOS Apple Silicon | `MediaStationGo-v0.0.27-darwin-arm64.tar.gz` | +| Linux x86_64 | `MediaStationGo-v0.0.28-linux-amd64.tar.gz` | +| Linux ARM64 | `MediaStationGo-v0.0.28-linux-arm64.tar.gz` | +| Windows x86_64 | `MediaStationGo-v0.0.28-windows-amd64.zip` | +| macOS Intel | `MediaStationGo-v0.0.28-darwin-amd64.tar.gz` | +| macOS Apple Silicon | `MediaStationGo-v0.0.28-darwin-arm64.tar.gz` | 部署步骤: ```bash # Linux 示例 -tar -xzf MediaStationGo-v0.0.27-linux-amd64.tar.gz -cd MediaStationGo-v0.0.27-linux-amd64 +tar -xzf MediaStationGo-v0.0.28-linux-amd64.tar.gz +cd MediaStationGo-v0.0.28-linux-amd64 MEDIASTATION_APP_PORT=18080 ./mediastation-go ``` Windows: ```powershell -Expand-Archive .\MediaStationGo-v0.0.27-windows-amd64.zip -cd .\MediaStationGo-v0.0.27-windows-amd64 +Expand-Archive .\MediaStationGo-v0.0.28-windows-amd64.zip +cd .\MediaStationGo-v0.0.28-windows-amd64 $env:MEDIASTATION_APP_PORT = "18080" .\mediastation-go.exe ``` diff --git a/README_EN.md b/README_EN.md index c8a6ea8..d341f34 100644 --- a/README_EN.md +++ b/README_EN.md @@ -235,7 +235,7 @@ mkdir -p data cache media downloads ```bash cat > .env <<'EOF' -MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.27 +MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.28 MEDIASTATION_HTTP_PORT=18080 MEDIASTATION_DATA_DIR=./data MEDIASTATION_CACHE_DIR=./cache @@ -344,7 +344,7 @@ For production, pin a specific release tag instead of using `latest`. Recommende ```bash cat > .env <<'EOF' -MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.27 +MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.28 MEDIASTATION_HTTP_PORT=18080 MEDIASTATION_DATA_DIR=./data MEDIASTATION_CACHE_DIR=./cache @@ -593,25 +593,25 @@ Each release provides multi-platform archives: | Platform | Package example | | --- | --- | -| Linux x86_64 | `MediaStationGo-v0.0.27-linux-amd64.tar.gz` | -| Linux ARM64 | `MediaStationGo-v0.0.27-linux-arm64.tar.gz` | -| Windows x86_64 | `MediaStationGo-v0.0.27-windows-amd64.zip` | -| macOS Intel | `MediaStationGo-v0.0.27-darwin-amd64.tar.gz` | -| macOS Apple Silicon | `MediaStationGo-v0.0.27-darwin-arm64.tar.gz` | +| Linux x86_64 | `MediaStationGo-v0.0.28-linux-amd64.tar.gz` | +| Linux ARM64 | `MediaStationGo-v0.0.28-linux-arm64.tar.gz` | +| Windows x86_64 | `MediaStationGo-v0.0.28-windows-amd64.zip` | +| macOS Intel | `MediaStationGo-v0.0.28-darwin-amd64.tar.gz` | +| macOS Apple Silicon | `MediaStationGo-v0.0.28-darwin-arm64.tar.gz` | Linux example: ```bash -tar -xzf MediaStationGo-v0.0.27-linux-amd64.tar.gz -cd MediaStationGo-v0.0.27-linux-amd64 +tar -xzf MediaStationGo-v0.0.28-linux-amd64.tar.gz +cd MediaStationGo-v0.0.28-linux-amd64 MEDIASTATION_APP_PORT=18080 ./mediastation-go ``` Windows example: ```powershell -Expand-Archive .\MediaStationGo-v0.0.27-windows-amd64.zip -cd .\MediaStationGo-v0.0.27-windows-amd64 +Expand-Archive .\MediaStationGo-v0.0.28-windows-amd64.zip +cd .\MediaStationGo-v0.0.28-windows-amd64 $env:MEDIASTATION_APP_PORT = "18080" .\mediastation-go.exe ``` diff --git a/docker-compose.yml b/docker-compose.yml index c3056bc..845fecf 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -17,7 +17,7 @@ # # 镜像版本: # 默认拉取 latest;如需固定版本,创建 .env 并写入: -# MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.27 +# MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.28 # # 路径映射总览: # /data 程序数据目录。保存 SQLite 数据库、JWT secret、系统配置等,必须持久化。 diff --git a/internal/handler/admin.go b/internal/handler/admin.go index 99917b5..7938801 100644 --- a/internal/handler/admin.go +++ b/internal/handler/admin.go @@ -63,6 +63,10 @@ type adminUpdateUserReq struct { Username string `json:"username" binding:"required"` } +type adminResetPasswordReq struct { + Password string `json:"password" binding:"required,min=6"` +} + func updateUserHandler(svc *service.Container) gin.HandlerFunc { return func(c *gin.Context) { var req adminUpdateUserReq @@ -132,6 +136,21 @@ func deleteUserHandler(svc *service.Container) gin.HandlerFunc { } } +func resetUserPasswordHandler(svc *service.Container) gin.HandlerFunc { + return func(c *gin.Context) { + var req adminResetPasswordReq + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + if err := svc.Auth.ResetPassword(c.Request.Context(), c.Param("id"), req.Password); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"ok": true}) + } +} + func annotateProtectedUsers(ctx context.Context, svc *service.Container, users []model.User) error { firstAdmin, err := svc.Repo.User.FirstAdmin(ctx) if err != nil || firstAdmin == nil { diff --git a/internal/handler/handler.go b/internal/handler/handler.go index 8a712f7..ac3a997 100644 --- a/internal/handler/handler.go +++ b/internal/handler/handler.go @@ -280,6 +280,7 @@ func Register(r *gin.Engine, cfg *config.Config, log *zap.Logger, svc *service.C admin.GET("/users", listUsersHandler(svc)) admin.POST("/users", createUserHandler(svc)) admin.PATCH("/users/:id", updateUserHandler(svc)) + admin.PATCH("/users/:id/password", resetUserPasswordHandler(svc)) admin.PATCH("/users/:id/role", adminUpdateRoleHandler(svc)) admin.DELETE("/users/:id", deleteUserHandler(svc)) admin.GET("/settings", listSettingsHandler(svc)) diff --git a/internal/repository/repository.go b/internal/repository/repository.go index 75fe4a3..e0f21e4 100644 --- a/internal/repository/repository.go +++ b/internal/repository/repository.go @@ -81,6 +81,27 @@ func (r *UserRepository) Create(ctx context.Context, u *model.User) error { return r.db.WithContext(ctx).Create(u).Error } +// ReleaseDeletedUsername renames soft-deleted rows that still hold a unique +// username so the same account name can be created again. +func (r *UserRepository) ReleaseDeletedUsername(ctx context.Context, username string) error { + if username == "" { + return nil + } + released := username + "__deleted__" + time.Now().Format("20060102150405.000000000") + if len(released) > 64 { + sum := sha256.Sum256([]byte(released)) + released = username + if len(released) > 43 { + released = released[:43] + } + released += "__deleted__" + hex.EncodeToString(sum[:])[:10] + } + return r.db.WithContext(ctx).Unscoped(). + Model(&model.User{}). + Where("username = ? AND deleted_at IS NOT NULL", username). + Update("username", released).Error +} + // FindByUsername returns the user matching username, or (nil, nil) when absent. func (r *UserRepository) FindByUsername(ctx context.Context, username string) (*model.User, error) { var u model.User @@ -161,9 +182,31 @@ func (r *UserRepository) TouchLogin(ctx context.Context, id string) error { Update("last_login_at", &now).Error } -// Delete removes a user (soft-delete via gorm.DeletedAt). +// Delete removes a user (soft-delete via gorm.DeletedAt), releases the unique +// username, and drops Telegram bindings so future re-created users bind cleanly. func (r *UserRepository) Delete(ctx context.Context, id string) error { - return r.db.WithContext(ctx).Delete(&model.User{}, "id = ?", id).Error + return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var user model.User + if err := tx.Where("id = ?", id).First(&user).Error; err != nil { + return err + } + if err := tx.Unscoped().Where("user_id = ?", id).Delete(&model.TelegramBinding{}).Error; err != nil { + return err + } + released := user.Username + "__deleted__" + time.Now().Format("20060102150405.000000000") + if len(released) > 64 { + sum := sha256.Sum256([]byte(user.ID + user.Username)) + base := user.Username + if len(base) > 43 { + base = base[:43] + } + released = base + "__deleted__" + hex.EncodeToString(sum[:])[:10] + } + if err := tx.Model(&model.User{}).Where("id = ?", id).Update("username", released).Error; err != nil { + return err + } + return tx.Delete(&model.User{}, "id = ?", id).Error + }) } // ─── Library ───────────────────────────────────────────────────────────────── diff --git a/internal/service/auth.go b/internal/service/auth.go index 4ab1206..ae827fc 100644 --- a/internal/service/auth.go +++ b/internal/service/auth.go @@ -95,6 +95,9 @@ func (s *AuthService) Register(ctx context.Context, username, password string) ( } else if existing != nil { return nil, nil, ErrUsernameTaken } + if err := s.repo.User.ReleaseDeletedUsername(ctx, username); err != nil { + return nil, nil, err + } if n, err := s.repo.User.Count(ctx); err != nil { return nil, nil, err } else if n >= LicensedMaxUsers(ctx, s.repo) { @@ -181,6 +184,29 @@ func (s *AuthService) ChangePassword(ctx context.Context, userID, oldPwd, newPwd return s.repo.User.UpdatePassword(ctx, userID, hash) } +// ResetPassword lets an administrator set a new password without knowing the +// user's old password. +func (s *AuthService) ResetPassword(ctx context.Context, userID, newPwd string) error { + if strings.TrimSpace(userID) == "" { + return errors.New("missing user id") + } + if strings.TrimSpace(newPwd) == "" || len(newPwd) < 6 { + return errors.New("new password must be at least 6 characters") + } + u, err := s.repo.User.FindByID(ctx, userID) + if err != nil { + return err + } + if u == nil { + return errors.New("user not found") + } + hash, err := hashPassword(newPwd) + if err != nil { + return err + } + return s.repo.User.UpdatePassword(ctx, userID, hash) +} + // VerifyPassword checks a user's current password without mutating account // state. It is used for sensitive self-service actions such as hiding adult // libraries or deleting play profiles. diff --git a/internal/service/auth_user_limits_test.go b/internal/service/auth_user_limits_test.go index 154588f..370b1e7 100644 --- a/internal/service/auth_user_limits_test.go +++ b/internal/service/auth_user_limits_test.go @@ -21,7 +21,7 @@ func newAuthTestServices(t *testing.T) (*repository.Container, *AuthService, *Pr if err != nil { t.Fatal(err) } - if err := db.AutoMigrate(&model.User{}, &model.UserPermission{}, &model.RefreshToken{}); err != nil { + if err := db.AutoMigrate(&model.User{}, &model.UserPermission{}, &model.RefreshToken{}, &model.TelegramBinding{}); err != nil { t.Fatal(err) } repos := repository.New(db) @@ -66,6 +66,87 @@ func TestRegisterDefaultsAdultLibrariesHidden(t *testing.T) { } } +func TestDeletedUserCanBeRecreatedWithSameUsername(t *testing.T) { + ctx := context.Background() + repos, auth, _, _ := newAuthTestServices(t) + user, _, err := auth.Register(ctx, "viewer", "old-password") + if err != nil { + t.Fatalf("register old user: %v", err) + } + if err := repos.DB.Create(&model.TelegramBinding{ + TelegramUserID: 10001, + TelegramName: "@viewer", + ChatID: 10001, + UserID: user.ID, + }).Error; err != nil { + t.Fatalf("create telegram binding: %v", err) + } + if err := repos.User.Delete(ctx, user.ID); err != nil { + t.Fatalf("delete user: %v", err) + } + + next, _, err := auth.Register(ctx, "viewer", "new-password") + if err != nil { + t.Fatalf("register same username after delete: %v", err) + } + if next.ID == user.ID { + t.Fatal("recreated user should be a new account row") + } + if _, err := auth.Login(ctx, "viewer", "new-password"); err != nil { + t.Fatalf("login recreated user: %v", err) + } + var bindings int64 + if err := repos.DB.Model(&model.TelegramBinding{}).Where("telegram_user_id = ?", 10001).Count(&bindings).Error; err != nil { + t.Fatalf("count bindings: %v", err) + } + if bindings != 0 { + t.Fatalf("deleted user telegram bindings should be removed, got %d", bindings) + } +} + +func TestRegisterReleasesLegacySoftDeletedUsername(t *testing.T) { + ctx := context.Background() + repos, auth, _, _ := newAuthTestServices(t) + if err := repos.User.Create(ctx, &model.User{ + Username: "legacy", + PasswordHash: "hash", + Role: "user", + Tier: "free", + }); err != nil { + t.Fatal(err) + } + legacy, err := repos.User.FindByUsername(ctx, "legacy") + if err != nil || legacy == nil { + t.Fatalf("find legacy user: %v", err) + } + if err := repos.DB.Delete(&model.User{}, "id = ?", legacy.ID).Error; err != nil { + t.Fatalf("legacy soft delete: %v", err) + } + + if _, _, err := auth.Register(ctx, "legacy", "new-password"); err != nil { + t.Fatalf("register should release old soft-deleted username: %v", err) + } +} + +func TestAdminResetPasswordAllowsLoginWithNewPassword(t *testing.T) { + ctx := context.Background() + _, auth, _, _ := newAuthTestServices(t) + user, _, err := auth.Register(ctx, "viewer", "old-password") + if err != nil { + t.Fatalf("register: %v", err) + } + + if err := auth.ResetPassword(ctx, user.ID, "new-password"); err != nil { + t.Fatalf("reset password: %v", err) + } + if _, err := auth.Login(ctx, "viewer", "old-password"); !errors.Is(err, ErrInvalidCredentials) { + t.Fatalf("old password should fail, got %v", err) + } + if _, err := auth.Login(ctx, "viewer", "new-password"); err != nil { + t.Fatalf("new password should login: %v", err) + } +} + func TestDefaultPermissionsAreViewerOnly(t *testing.T) { perms := DefaultPermissions("user-1") if !perms.CanViewDashboard || !perms.CanPlayMedia || !perms.CanExternalPlayer { diff --git a/internal/service/telegram_bot.go b/internal/service/telegram_bot.go index 52bf8f2..e8f0951 100644 --- a/internal/service/telegram_bot.go +++ b/internal/service/telegram_bot.go @@ -231,14 +231,18 @@ func (s *TelegramBotService) cmdStart(ctx context.Context, msg *TelegramMessage, if len(args) == 0 { if binding := s.telegramBinding(ctx, msg.From.ID); binding != nil { user, _ := s.repo.User.FindByID(ctx, binding.UserID) + if user == nil { + _ = s.repo.DB.WithContext(ctx).Unscoped().Delete(&model.TelegramBinding{}, "id = ?", binding.ID).Error + return telegramCommandReply{Text: "之前绑定的媒体中心账号已不存在,请重新绑定:\n/start 用户名 密码"} + } status := "未隐藏" - if user != nil && user.HideAdult { + if user.HideAdult { status = "已隐藏" } return telegramCommandReply{ Text: fmt.Sprintf("MediaStationGo 已绑定\n\n你好 %s,当前账号:%s\n成人目录:%s", name, userNameOrFallback(user), status), Buttons: [][]telegramInlineButton{{{ - Text: map[bool]string{true: "显示成人目录", false: "隐藏成人目录"}[user != nil && user.HideAdult], + Text: map[bool]string{true: "显示成人目录", false: "隐藏成人目录"}[user.HideAdult], Data: "adult_toggle", }}}, } @@ -895,6 +899,9 @@ func (s *TelegramBotService) upsertTelegramBinding(ctx context.Context, msg *Tel if err != nil && err != gorm.ErrRecordNotFound { return err } + if err := s.repo.DB.WithContext(ctx).Unscoped().Where("telegram_user_id = ?", int64(msg.From.ID)).Delete(&model.TelegramBinding{}).Error; err != nil { + return err + } return s.repo.DB.WithContext(ctx).Create(&model.TelegramBinding{ TelegramUserID: int64(msg.From.ID), TelegramName: name, diff --git a/internal/service/telegram_bot_user_test.go b/internal/service/telegram_bot_user_test.go new file mode 100644 index 0000000..f0dc1b9 --- /dev/null +++ b/internal/service/telegram_bot_user_test.go @@ -0,0 +1,39 @@ +package service + +import ( + "strings" + "testing" + + "go.uber.org/zap" + + "github.com/ShukeBta/MediaStationGo/internal/model" +) + +func TestTelegramStartClearsStaleUserBinding(t *testing.T) { + repos, _, _, _ := newAuthTestServices(t) + if err := repos.DB.Create(&model.TelegramBinding{ + TelegramUserID: 20001, + TelegramName: "@viewer", + ChatID: 20001, + UserID: "deleted-user", + }).Error; err != nil { + t.Fatalf("create binding: %v", err) + } + bot := NewTelegramBotService(zap.NewNop(), repos, nil) + + reply := bot.cmdStart(t.Context(), &TelegramMessage{ + From: TelegramUser{ID: 20001, Username: "viewer", FirstName: "Viewer"}, + Chat: TelegramChat{ID: 20001, Type: "private"}, + }, nil) + + if !strings.Contains(reply.Text, "已不存在") { + t.Fatalf("expected stale binding message, got %q", reply.Text) + } + var count int64 + if err := repos.DB.Model(&model.TelegramBinding{}).Where("telegram_user_id = ?", 20001).Count(&count).Error; err != nil { + t.Fatalf("count binding: %v", err) + } + if count != 0 { + t.Fatalf("stale binding should be removed, got %d", count) + } +} diff --git a/web/src/api/admin.ts b/web/src/api/admin.ts index 3a9e35e..c8ef449 100644 --- a/web/src/api/admin.ts +++ b/web/src/api/admin.ts @@ -10,6 +10,9 @@ export const adminAPI = { updateUser: (id: string, payload: { username: string }) => api.patch(`/admin/users/${id}`, payload).then((r) => r.data), + resetUserPassword: (id: string, password: string) => + api.patch(`/admin/users/${id}/password`, { password }).then((r) => r.data), + deleteUser: (id: string) => api.delete(`/admin/users/${id}`).then((r) => r.data), listSettings: () => api.get('/admin/settings').then((r) => r.data), diff --git a/web/src/pages/AdminPage.tsx b/web/src/pages/AdminPage.tsx index e41bc45..f81f8e9 100644 --- a/web/src/pages/AdminPage.tsx +++ b/web/src/pages/AdminPage.tsx @@ -1,6 +1,6 @@ import { FormEvent, useEffect, useState } from 'react' import toast from 'react-hot-toast' -import { Pencil, Plus, ShieldCheck, Trash2, X } from 'lucide-react' +import { KeyRound, Pencil, Plus, ShieldCheck, Trash2, X } from 'lucide-react' import { adminAPI } from '../api/admin' import { libraryAPI } from '../api/library' @@ -8,6 +8,7 @@ import type { Library, User } from '../types' import { APIConfigsPanel } from '../components/APIConfigsPanel' import { ManagementShortcuts } from '../components/ManagementShortcuts' import { confirmAction } from '../components/ConfirmDialog' +import { requestPassword } from '../components/PasswordDialog' export function AdminPage() { const [tab, setTab] = useState<'library' | 'users' | 'api'>('library') @@ -206,6 +207,28 @@ function UsersPanel() { } } + const resetPassword = async (u: User) => { + const nextPassword = await requestPassword({ + title: `重置 ${u.username} 的密码`, + message: '请输入新的临时密码,至少 6 位。保存后该用户可立即使用新密码登录 Web、Bot 与第三方客户端。', + confirmText: '重置密码', + }) + if (!nextPassword) return + if (nextPassword.length < 6) { + toast.error('新密码至少 6 位') + return + } + try { + await adminAPI.resetUserPassword(u.id, nextPassword) + toast.success('密码已重置') + } catch (err: unknown) { + const msg = + (err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? + '重置密码失败' + toast.error(msg) + } + } + return (
@@ -303,6 +326,13 @@ function UsersPanel() { )} +