From 4dc196b0551f38432f98ef3544ba301a47264d05 Mon Sep 17 00:00:00 2001 From: freefire Date: Sat, 29 Aug 2026 06:40:19 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20SSR=20=E5=BF=98=E8=AE=B0=E5=AF=86?= =?UTF-8?q?=E7=A0=81=E4=B8=8E=20Admin=20SMTP=20=E8=AE=BE=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 邮箱验证码重置密码(吊销 session);后台可配 SMTP 并发送测试信。 Co-authored-by: Cursor --- docs/rebuild-spec/02-features.md | 4 +- docs/rebuild-spec/06-pages-ux.md | 6 +- .../rebuild-spec/08-gitea-ssr-architecture.md | 4 +- routers/setup.go | 1 + routers/web/admin.go | 63 ++++++++++++ routers/web/auth.go | 98 +++++++++++++++++++ routers/web/chrome.go | 1 + routers/web/home.go | 5 + templates/admin/settings.tmpl | 26 +++++ templates/auth/forgot_password.tmpl | 33 +++++++ templates/auth/login.tmpl | 2 +- 11 files changed, 235 insertions(+), 8 deletions(-) create mode 100644 templates/auth/forgot_password.tmpl diff --git a/docs/rebuild-spec/02-features.md b/docs/rebuild-spec/02-features.md index 3d56341..5f6a67a 100644 --- a/docs/rebuild-spec/02-features.md +++ b/docs/rebuild-spec/02-features.md @@ -32,7 +32,7 @@ - [x] 注册(用户名、密码、昵称、邮箱;可选邮箱验证码)— SSR `/register` - [ ] 图形验证码接口(注册流程) - [x] 登录 / 登出(opaque session Cookie `jiang13_session`)— SSR;SameSite=Lax;登出/禁言/改密吊销 -- [ ] 忘记密码:邮箱验证码 + 重置 +- [x] 忘记密码:邮箱验证码 + 重置 — SSR `/forgot-password`(依赖 SMTP 就绪) - [x] 注册配置:邮件就绪时强制验证码;安装后开放注册(不依赖 SMTP) - [x] ~~首个用户自动成为管理员~~ → 改为仅 `/install` 创建管理员 - [x] 个人中心:改昵称、签名、密码、上传头像、积分钱包/签到/抽奖 — SSR `/profile`(裁剪未做) @@ -188,7 +188,7 @@ - [x] 仪表盘:用户/帖/板块计数 + 待审帖/评 — SSR `/admin/dashboard`(举报/友链待迁) - [x] 敏感词:`forum_settings.filter_words` 读写 + 热更 — SSR `/admin/settings` - [x] 基础限流(post/comment/register/login/window)— SSR;完整 Limits 字数等未迁 -- [ ] SMTP 配置与测试信 +- [x] SMTP 配置与测试信 — SSR `/admin/settings` 邮件区 - [x] 站点品牌文案:名称、标语、简介、keywords、Logo 字标、ICP — SSR(Logo/Favicon/OG 上传未迁) - [ ] SQLite 一键备份与下载 diff --git a/docs/rebuild-spec/06-pages-ux.md b/docs/rebuild-spec/06-pages-ux.md index 4f79ccf..1acb02e 100644 --- a/docs/rebuild-spec/06-pages-ux.md +++ b/docs/rebuild-spec/06-pages-ux.md @@ -18,7 +18,7 @@ |------|------|-----| | `/login` | 登录 / 登出 | 已迁 | | `/register` | 注册(邮件就绪时要验证码) | 已迁 | -| `/forgot-password` | 忘记密码 | 未迁 | +| `/forgot-password` | 忘记密码 | 已迁 | ### 1.2 前台 @@ -47,11 +47,11 @@ | `/admin/dashboard` | 概览计数 | 已迁 | | `/admin/boards` | 板块 CRUD | 已迁 | | `/admin/moderation` | 待审帖/评 通过/拒绝 | 已迁 | -| `/admin/settings` | 品牌 + 基础限流 + 敏感词 | 已迁 | +| `/admin/settings` | 品牌 + 基础限流 + 敏感词 + SMTP | 已迁 | | `/admin/friend-links` | 品牌友链、申请审核、入口开关 | 已迁 | | `/admin/login` | 重定向前台登录 | 已迁 | -未迁(原 SPA):reports / users / badges / media / pages / SMTP / 完整 Limits 等。 +未迁(原 SPA):reports / users / badges / media / pages / 完整 Limits 等。 --- diff --git a/docs/rebuild-spec/08-gitea-ssr-architecture.md b/docs/rebuild-spec/08-gitea-ssr-architecture.md index 0b64a8d..d320ac5 100644 --- a/docs/rebuild-spec/08-gitea-ssr-architecture.md +++ b/docs/rebuild-spec/08-gitea-ssr-architecture.md @@ -81,7 +81,7 @@ web_src/ → public/assets/ ### 已迁路径(摘要) -公开写:`/install`、`/login`、`/logout`、`/register`、`/compose`(含门控插入)、`/post/:id/edit`、帖详情评论/赞/藏、积分解锁 `POST /post/:id/unlock`。 +公开写:`/install`、`/login`、`/logout`、`/register`、`/forgot-password`(发码/重置)、`/compose`(含门控插入)、`/post/:id/edit`、帖详情评论/赞/藏、积分解锁 `POST /post/:id/unlock`。 个人闭环:`/profile`(昵称/签名/密码/头像、积分钱包/签到/抽奖)、`/user/:id`、`/favorites`。 `POST /profile/checkin`、`POST /profile/lottery`(CSRF + PRG)。 @@ -90,4 +90,4 @@ web_src/ → public/assets/ 友链:`/links`(列表、登录申请/取消、Logo 上传);Admin `/admin/friend-links`(品牌增删、审核、入口开关)。 -Admin:`/admin/dashboard`、`/admin/boards`、`/admin/moderation`、`/admin/settings`(品牌/限流/敏感词)、`/admin/friend-links`。 \ No newline at end of file +Admin:`/admin/dashboard`、`/admin/boards`、`/admin/moderation`、`/admin/settings`(品牌/限流/敏感词/SMTP)、`/admin/friend-links`。 \ No newline at end of file diff --git a/routers/setup.go b/routers/setup.go index 2580e47..e5165dc 100644 --- a/routers/setup.go +++ b/routers/setup.go @@ -102,6 +102,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) { Limiter: limiter, EmailCode: emailCodeSvc, Store: uploadStore, Points: services.NewPointsService(), FriendLink: friendLinkApplySvc, + Mail: mailSvc, }, authMW) r.GET("/media/thumb/*filepath", h.ServeImageThumb) diff --git a/routers/web/admin.go b/routers/web/admin.go index 5ea7b95..eaa9cb9 100644 --- a/routers/web/admin.go +++ b/routers/web/admin.go @@ -408,6 +408,9 @@ type adminSettingsData struct { RateWindow int FilterWords string FilterCount int + Mail services.MailConfig + MailReady bool + TestTo string } // AdminSettingsGet 设置页 @@ -419,6 +422,7 @@ func (d Deps) AdminSettingsGet(c *gin.Context) { func (d Deps) renderAdminSettings(ctx *webctx.Context, errMsg string) { words := d.Settings.FilterWordsContent() lim := d.Settings.Limits() + mail := d.Settings.MailConfigPublic() data := adminSettingsData{ AdminChrome: d.adminChrome(ctx, "站点设置", "settings"), Brand: d.Settings.SiteBranding(), @@ -429,6 +433,8 @@ func (d Deps) renderAdminSettings(ctx *webctx.Context, errMsg string) { RateWindow: lim.RateLimitWindowSec, FilterWords: words, FilterCount: services.CountFilterWords(words), + Mail: mail, + MailReady: d.Settings.MailReady(), } data.Error = errMsg ctx.HTML(http.StatusOK, "admin/settings", data) @@ -498,3 +504,60 @@ func (d Deps) AdminSettingsFilterWordsPost(c *gin.Context) { ctx.SetFlash(fmt.Sprintf("敏感词已更新(有效词 %d 个)· %s", services.CountFilterWords(content), time.Now().Format("15:04:05"))) ctx.Redirect("/admin/settings") } + +// AdminSettingsMailPost 保存 SMTP +func (d Deps) AdminSettingsMailPost(c *gin.Context) { + ctx := d.ctx(c) + if !ctx.CheckCSRF() { + d.renderAdminSettings(ctx, "无效请求,请重试") + return + } + port, _ := strconv.Atoi(c.PostForm("port")) + cfg := services.MailConfig{ + Enabled: c.PostForm("enabled") == "1" || c.PostForm("enabled") == "on", + Host: strings.TrimSpace(c.PostForm("host")), + Port: port, + Username: strings.TrimSpace(c.PostForm("username")), + Password: c.PostForm("password"), + From: strings.TrimSpace(c.PostForm("from")), + FromName: strings.TrimSpace(c.PostForm("from_name")), + Encryption: strings.TrimSpace(c.PostForm("encryption")), + } + if err := d.Settings.UpdateMailConfig(cfg); err != nil { + d.renderAdminSettings(ctx, err.Error()) + return + } + ctx.SetFlash("邮件设置已保存") + ctx.Redirect("/admin/settings") +} + +// AdminSettingsMailTestPost 发送测试信 +func (d Deps) AdminSettingsMailTestPost(c *gin.Context) { + ctx := d.ctx(c) + to := strings.TrimSpace(c.PostForm("test_to")) + if !ctx.CheckCSRF() { + d.renderAdminSettings(ctx, "无效请求,请重试") + return + } + if err := services.ValidateEmail(to); err != nil { + d.renderAdminSettings(ctx, err.Error()) + return + } + if !d.Settings.MailReady() { + d.renderAdminSettings(ctx, services.ErrMailNotConfigured.Error()) + return + } + if d.Mail == nil { + d.renderAdminSettings(ctx, "邮件服务未就绪") + return + } + siteName := d.Settings.SiteBranding().Name + err := d.Mail.Send(services.NormalizeEmail(to), "邮件配置测试", + fmt.Sprintf("这是一封来自%s的测试邮件,说明 SMTP 配置正常。", siteName)) + if err != nil { + d.renderAdminSettings(ctx, err.Error()) + return + } + ctx.SetFlash("测试邮件已发送至 " + to) + ctx.Redirect("/admin/settings") +} diff --git a/routers/web/auth.go b/routers/web/auth.go index 08a9952..65c554d 100644 --- a/routers/web/auth.go +++ b/routers/web/auth.go @@ -206,3 +206,101 @@ func (d Deps) renderRegisterWithChrome(ctx *webctx.Context, chrome PageChrome, e RequireEmailCode: mailReady, }) } + +type forgotPasswordData struct { + PageChrome + Email string + MailReady bool +} + +// ForgotPasswordGet 忘记密码页 +func (d Deps) ForgotPasswordGet(c *gin.Context) { + ctx := d.ctx(c) + if ctx.IsSigned() { + ctx.Redirect("/") + return + } + d.renderForgotPassword(ctx, "", "") +} + +// ForgotPasswordSendCode 发送重置验证码 +func (d Deps) ForgotPasswordSendCode(c *gin.Context) { + ctx := d.ctx(c) + if ctx.IsSigned() { + ctx.Redirect("/") + return + } + email := strings.TrimSpace(c.PostForm("email")) + if !ctx.CheckCSRF() { + d.renderForgotPassword(ctx, "无效请求,请重试", email) + return + } + if d.Limiter != nil && !d.Limiter.Allow("login", c.ClientIP()) { + d.renderForgotPassword(ctx, "操作过于频繁,请稍后再试", email) + return + } + if d.EmailCode == nil || !d.Settings.MailReady() { + d.renderForgotPassword(ctx, services.ErrMailNotConfigured.Error(), email) + return + } + if err := d.EmailCode.SendResetCode(email); err != nil { + d.renderForgotPassword(ctx, err.Error(), email) + return + } + chrome := d.chrome(ctx, "忘记密码 · "+d.Settings.SiteBranding().Name, "", "") + chrome.Flash = "若该邮箱已注册,验证码将发送到邮箱" + d.renderForgotPasswordWithChrome(ctx, chrome, "", email) +} + +// ForgotPasswordPost 提交重置 +func (d Deps) ForgotPasswordPost(c *gin.Context) { + ctx := d.ctx(c) + if ctx.IsSigned() { + ctx.Redirect("/") + return + } + email := strings.TrimSpace(c.PostForm("email")) + if !ctx.CheckCSRF() { + d.renderForgotPassword(ctx, "无效请求,请重试", email) + return + } + if d.Limiter != nil && !d.Limiter.Allow("login", c.ClientIP()) { + d.renderForgotPassword(ctx, "操作过于频繁,请稍后再试", email) + return + } + if !d.Settings.MailReady() || d.EmailCode == nil { + d.renderForgotPassword(ctx, services.ErrMailNotConfigured.Error(), email) + return + } + code := strings.TrimSpace(c.PostForm("email_code")) + if !d.EmailCode.VerifyPurpose(services.EmailCodePurposeReset, email, code) { + d.renderForgotPassword(ctx, services.ErrEmailCodeInvalid.Error(), email) + return + } + pass := c.PostForm("new_password") + pass2 := c.PostForm("new_password2") + if pass != pass2 { + d.renderForgotPassword(ctx, "两次输入的新密码不一致", email) + return + } + if err := d.User.ResetPasswordByEmail(email, pass); err != nil { + d.renderForgotPassword(ctx, err.Error(), email) + return + } + ctx.SetFlash("密码已重置,请使用新密码登录") + ctx.Redirect("/login") +} + +func (d Deps) renderForgotPassword(ctx *webctx.Context, errMsg, email string) { + chrome := d.chrome(ctx, "忘记密码 · "+d.Settings.SiteBranding().Name, "", "") + d.renderForgotPasswordWithChrome(ctx, chrome, errMsg, email) +} + +func (d Deps) renderForgotPasswordWithChrome(ctx *webctx.Context, chrome PageChrome, errMsg, email string) { + chrome.Error = errMsg + ctx.HTML(http.StatusOK, "auth/forgot_password", forgotPasswordData{ + PageChrome: chrome, + Email: email, + MailReady: d.Settings.MailReady(), + }) +} diff --git a/routers/web/chrome.go b/routers/web/chrome.go index e6ce8c9..cbc81e0 100644 --- a/routers/web/chrome.go +++ b/routers/web/chrome.go @@ -25,6 +25,7 @@ type Deps struct { Store *services.UploadStore Points *services.PointsService FriendLink *services.FriendLinkApplyService + Mail *services.MailService } // BoardView 侧栏 diff --git a/routers/web/home.go b/routers/web/home.go index 8831eb5..3e2efd2 100644 --- a/routers/web/home.go +++ b/routers/web/home.go @@ -29,6 +29,9 @@ func Register(r *gin.Engine, deps Deps, authMW *auth.AuthMiddleware) { g.GET("/register", deps.RegisterGet) g.POST("/register", deps.RegisterPost) g.POST("/register/send-code", deps.RegisterSendCode) + g.GET("/forgot-password", deps.ForgotPasswordGet) + g.POST("/forgot-password", deps.ForgotPasswordPost) + g.POST("/forgot-password/send-code", deps.ForgotPasswordSendCode) g.GET("/compose", authMW.RequireAuth(), deps.ComposeGet) g.POST("/compose", authMW.RequireAuth(), deps.ComposePost) g.POST("/compose/upload", authMW.RequireAuth(), deps.ComposeUpload) @@ -51,6 +54,8 @@ func Register(r *gin.Engine, deps Deps, authMW *auth.AuthMiddleware) { admin.POST("/settings/brand", deps.AdminSettingsBrandPost) admin.POST("/settings/limits", deps.AdminSettingsLimitsPost) admin.POST("/settings/filter-words", deps.AdminSettingsFilterWordsPost) + admin.POST("/settings/mail", deps.AdminSettingsMailPost) + admin.POST("/settings/mail/test", deps.AdminSettingsMailTestPost) admin.GET("/friend-links", deps.AdminFriendLinksGet) admin.POST("/friend-links/settings", deps.AdminFriendLinksSettingsPost) admin.POST("/friend-links/brand", deps.AdminFriendLinksBrandAddPost) diff --git a/templates/admin/settings.tmpl b/templates/admin/settings.tmpl index f3c0b95..f236e41 100644 --- a/templates/admin/settings.tmpl +++ b/templates/admin/settings.tmpl @@ -38,6 +38,32 @@ + +

邮件 SMTP

+

启用后可用于注册验证码、忘记密码重置与通知。{{if .MailReady}}当前:已就绪。{{else}}当前:未就绪。{{end}}{{if .Mail.HasPassword}} 已保存密码。{{end}}

+
+ + + + + + + + + + +
+
+ + + +
{{template "base/footer" .}} {{end}} diff --git a/templates/auth/forgot_password.tmpl b/templates/auth/forgot_password.tmpl new file mode 100644 index 0000000..374ba3c --- /dev/null +++ b/templates/auth/forgot_password.tmpl @@ -0,0 +1,33 @@ +{{define "auth/forgot_password"}} +{{template "base/head" .}} +
+

忘记密码

+ {{template "base/alert" .}} + {{if .MailReady}} +
+ + + + + + +
+

若该邮箱已注册,验证码将发送到邮箱。重置后其它设备会话将失效。

+ {{else}} +

邮件服务未配置,无法通过邮箱重置密码。请联系站长在后台配置 SMTP,或使用其它方式找回。

+ {{end}} +

返回登录 · 返回首页

+
+{{template "base/footer" .}} +{{end}} diff --git a/templates/auth/login.tmpl b/templates/auth/login.tmpl index a9542b1..5a59185 100644 --- a/templates/auth/login.tmpl +++ b/templates/auth/login.tmpl @@ -14,7 +14,7 @@ -

没有账号?注册 · 返回首页

+

没有账号?注册 · 忘记密码 · 返回首页

{{template "base/footer" .}} {{end}}