feat: API 基建——/api JSON 接口统一契约与认证分支
- handlers/api.go:APIOK/APIError/bindJSON,统一 {ok,redirect,data} /
{ok:false,code,error} 响应契约(code=i18n 键,error 按请求语言翻译)
- i18n:新增 api_error/api_unauthorized/api_forbidden/api_invalid_request(中英)
- middleware/auth.go:AuthRequired/AdminRequired 按 /api 前缀分支:
JSON 401/403(页面保持 302),新增 isAPIRequest + apiAuthError
- main.go:路由注册提取为 registerRoutes;建立 /api 分组并搬移附件三件套
(admin/my)与 /api/profile/avatar(旧 /admin|my/articles/attachments、
/profile/avatar 路由移除)
- handlers/login_ratelimit.go:loginRateLimiter 导出为 LoginRateLimiter
- templates/layouts/base.html:blogAPI/blogForm/blogShowError 共享 fetch 助手
- main_test.go:TestRegisterRoutesSmoke 冒烟测试(注册期 gin 静态/参数
冲突即 panic + 关键 /api 路由断言)
- go build/vet/test ./... 全绿
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
# API 化改造 Todo
|
||||
|
||||
操作接口改为 `/api/` 前缀的 JSON API(POST/PUT/DELETE、码+文案错误响应),页面路由保持不变。
|
||||
|
||||
## 基建(high)
|
||||
|
||||
- [x] 1. 新建 `handlers/api.go`:`APIError(c, status, trKey)`、`APIOK(c, redirect, data)`、JSON 绑定/解析 helper
|
||||
- [x] 2. `middleware/auth.go`:`AuthRequired`/`AdminRequired` 按 `/api` 路径前缀分支 401/403 JSON(非 API 保持 302)
|
||||
- [x] 3. `main.go`:注册 `/api` 分组(auth/article/admin/profile/my 子组)+ gin 静态/参数路由冲突冒烟测试(新增 `TestRegisterRoutesSmoke`,含附件端点搬移,`LoginRateLimiter` 导出)
|
||||
- [x] 4. `templates/layouts/base.html`:增加 `blogAPI`/`blogForm`/`blogShowError` 共享 fetch 助手(CSRF 头、`e.submitter` 状态按钮、bool 复选框转换)
|
||||
|
||||
## 零行为搬移(medium)
|
||||
|
||||
- [ ] 5. 附件三件套(admin/my)+ `/api/profile/avatar` 换注册路径
|
||||
- [ ] 6. 搬移端点模板更新:`article_create.html` 3 处 fetch URL、`profile.html` 1 处
|
||||
|
||||
## 功能改造(medium)
|
||||
|
||||
- [ ] 7. 认证 API:`auth.go` Login/Register/Logout JSON 化(429 限流、会话轮换保留)
|
||||
- [ ] 8. 认证模板:`login.html`/`register.html` 改 fetch + 错误 div `id`;base.html logout 改 fetch
|
||||
- [ ] 9. 评论 API:`comment.go` PostComment JSON 化(校验码复用 i18n 键)
|
||||
- [ ] 10. 评论 API:`admin_comment.go` approve/reject/delete JSON 化
|
||||
- [ ] 11. 评论模板:`article.html` 评论表单 fetch、`comment_list.html` 操作后 reload
|
||||
- [ ] 12. 文章 CRUD API:`article.go`/`my_articles.go` 去 renderForm/Redirect 改 JSON(表单字段加 json tag)
|
||||
- [ ] 13. 文章模板:`article_create.html`/`my_article_form.html`/`article_list.html`/`my_articles.html` 改 fetch(`easyMDE.value()`、`e.submitter`)
|
||||
- [ ] 14. 用户 API:`admin_user.go` UserCreate/Update/Delete JSON 化(自防/最后管理员拦截改 403)
|
||||
- [ ] 15. 用户模板:`user_form.html`/`user_list.html` 改 fetch
|
||||
- [ ] 16. 设置 API:`settings.go` 5 组 save JSON 化;site favicon 拆出 `POST /api/admin/settings/site/favicon`
|
||||
- [ ] 17. 设置模板:`settings_site`/`settings_navlinks`/`settings_upload`/`settings_download`/`settings_comment` 5 页改 fetch
|
||||
- [ ] 18. 个人资料 API:`profile.go` UpdateProfile JSON 化(头像走 `/api/profile/avatar`)
|
||||
- [ ] 19. 个人资料模板:`profile.html` 文本表单改 fetch
|
||||
|
||||
## 测试与收尾(high)
|
||||
|
||||
- [ ] 20. 新增 `handlers/api_test.go`:happy path / 校验码 / 401 / 403 / CSRF 头 / 429 / 409
|
||||
- [ ] 21. 更新 `security_test.go`/`p2_validation_test.go`/`p3_upload_test.go`/`session_upload_security_test.go` 到新 URL 与 JSON 断言
|
||||
- [ ] 22. `go build ./... && go vet ./... && go test ./...` 全绿;README 路由表同步
|
||||
|
||||
## 执行说明
|
||||
|
||||
- 每步只改路由/响应层,不动解析校验逻辑(JSON binding 替换 PostForm 读取),单步可编译可测
|
||||
- 并发状态:单一编辑者(本会话从第 1 项开始逐步执行)
|
||||
@@ -0,0 +1,52 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// APIOK 返回统一成功响应。
|
||||
// redirect 非空时附带相对跳转地址(原 302 目标,可含 ?saved=1 等 query);
|
||||
// data 额外字段会合并进响应。
|
||||
func APIOK(c *gin.Context, redirect string, data gin.H) {
|
||||
resp := gin.H{"ok": true}
|
||||
if redirect != "" {
|
||||
resp["redirect"] = redirect
|
||||
}
|
||||
for k, v := range data {
|
||||
resp[k] = v
|
||||
}
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// APIError 返回统一错误响应:{ok:false, code, error}。
|
||||
// trKey 是 i18n 键,直接作为 code 返回;error 为该键按请求语言的翻译文案。
|
||||
// trKey 为空时回退到通用键 "api_error"。
|
||||
func APIError(c *gin.Context, status int, trKey string) {
|
||||
tr := getTr(c)
|
||||
code := trKey
|
||||
if code == "" {
|
||||
code = "api_error"
|
||||
}
|
||||
msg := tr[code]
|
||||
if msg == "" {
|
||||
msg = tr["api_error"]
|
||||
}
|
||||
c.JSON(status, gin.H{
|
||||
"ok": false,
|
||||
"code": code,
|
||||
"error": msg,
|
||||
})
|
||||
}
|
||||
|
||||
// bindJSON 将 JSON 请求体绑定到 v。
|
||||
// 绑定失败时返回 400 + api_invalid_request,并返回 false。
|
||||
// 使用前必须保证请求是 JSON(Content-Type: application/json)。
|
||||
func bindJSON(c *gin.Context, v interface{}) bool {
|
||||
if err := c.ShouldBindJSON(v); err != nil {
|
||||
APIError(c, http.StatusBadRequest, "api_invalid_request")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
+1
-1
@@ -36,7 +36,7 @@ func LoginPage() gin.HandlerFunc {
|
||||
// Login 处理登录表单提交。它对每个 IP+用户名实施速率限制
|
||||
// (SECURITY_TODO #10),对于不存在的用户名会执行一次虚拟 bcrypt 比较,
|
||||
// 使耗时不会暴露用户名是否存在(SECURITY_TODO #25)。
|
||||
func Login(db *gorm.DB, limiter *loginRateLimiter) gin.HandlerFunc {
|
||||
func Login(db *gorm.DB, limiter *LoginRateLimiter) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
username := c.PostForm("username")
|
||||
password := c.PostForm("password")
|
||||
|
||||
@@ -18,8 +18,8 @@ const (
|
||||
dummyHashCost = 12
|
||||
)
|
||||
|
||||
// loginRateLimiter 按键("IP|username")跟踪连续的登录失败次数。
|
||||
type loginRateLimiter struct {
|
||||
// LoginRateLimiter 按键("IP|username")跟踪连续的登录失败次数。
|
||||
type LoginRateLimiter struct {
|
||||
mu sync.Mutex
|
||||
entries map[string]*loginRateEntry
|
||||
}
|
||||
@@ -31,15 +31,15 @@ type loginRateEntry struct {
|
||||
}
|
||||
|
||||
// NewLoginLimiter 为登录端点创建空的速率限制器。
|
||||
func NewLoginLimiter() *loginRateLimiter {
|
||||
return &loginRateLimiter{entries: make(map[string]*loginRateEntry)}
|
||||
func NewLoginLimiter() *LoginRateLimiter {
|
||||
return &LoginRateLimiter{entries: make(map[string]*loginRateEntry)}
|
||||
}
|
||||
|
||||
func (l *loginRateLimiter) now() time.Time { return time.Now() }
|
||||
func (l *LoginRateLimiter) now() time.Time { return time.Now() }
|
||||
|
||||
// Allow 报告该键是否允许再次尝试登录。锁定期窗口已过期的键会在此释放;
|
||||
// 仅计数失败次数的键保留其计数。
|
||||
func (l *loginRateLimiter) Allow(key string) bool {
|
||||
func (l *LoginRateLimiter) Allow(key string) bool {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
now := l.now()
|
||||
@@ -57,7 +57,7 @@ func (l *loginRateLimiter) Allow(key string) bool {
|
||||
|
||||
// Fail 记录该键的一次失败尝试,并返回锁定生效前剩余的可尝试次数
|
||||
// (0 = 刚刚被锁定)。
|
||||
func (l *loginRateLimiter) Fail(key string) (remaining int) {
|
||||
func (l *LoginRateLimiter) Fail(key string) (remaining int) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
now := l.now()
|
||||
@@ -82,7 +82,7 @@ func (l *loginRateLimiter) Fail(key string) (remaining int) {
|
||||
}
|
||||
|
||||
// Reset 在登录成功后清除失败计数。
|
||||
func (l *loginRateLimiter) Reset(key string) {
|
||||
func (l *LoginRateLimiter) Reset(key string) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
delete(l.entries, key)
|
||||
@@ -90,7 +90,7 @@ func (l *loginRateLimiter) Reset(key string) {
|
||||
|
||||
// sweep 限制映射大小,防止攻击者通过制造大量键使限流器无限增长。
|
||||
// 过期的条目(若无过期条目,则移除最少访问的条目)会被逐出。
|
||||
func (l *loginRateLimiter) sweep(now time.Time) {
|
||||
func (l *LoginRateLimiter) sweep(now time.Time) {
|
||||
if len(l.entries) <= maxTrackedKeys {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ type securityTestEnv struct {
|
||||
router *gin.Engine
|
||||
db *gorm.DB
|
||||
storageDir string
|
||||
limiter *loginRateLimiter
|
||||
limiter *LoginRateLimiter
|
||||
}
|
||||
|
||||
var csrfTokenRe = regexp.MustCompile(`name="_csrf" value="([^"]+)"`)
|
||||
|
||||
@@ -443,6 +443,12 @@ var translations = map[Lang]map[string]string{
|
||||
"analytics_showing": "Showing",
|
||||
"analytics_of": "of",
|
||||
"analytics_load_more": "Load More",
|
||||
|
||||
// API(/api/* JSON 接口通用)
|
||||
"api_error": "Request failed.",
|
||||
"api_unauthorized": "Please sign in first.",
|
||||
"api_forbidden": "You do not have permission to perform this action.",
|
||||
"api_invalid_request": "Invalid request body.",
|
||||
},
|
||||
ZH: {
|
||||
// 导航
|
||||
@@ -871,6 +877,12 @@ var translations = map[Lang]map[string]string{
|
||||
"analytics_showing": "显示",
|
||||
"analytics_of": "共",
|
||||
"analytics_load_more": "加载更多",
|
||||
|
||||
// API(/api/* JSON 接口通用)
|
||||
"api_error": "请求失败,请稍后重试。",
|
||||
"api_unauthorized": "请先登录。",
|
||||
"api_forbidden": "您没有权限执行此操作。",
|
||||
"api_invalid_request": "请求参数格式不正确。",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,8 @@ import (
|
||||
"github.com/gin-contrib/sessions/cookie"
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"go_blog/config"
|
||||
"go_blog/handlers"
|
||||
"go_blog/middleware"
|
||||
@@ -110,116 +112,7 @@ func main() {
|
||||
router.Use(middleware.SetUserContext(db))
|
||||
|
||||
// 8. 注册路由。
|
||||
router.GET("/", handlers.HomePage(db))
|
||||
router.GET("/search", handlers.SearchPage(db))
|
||||
router.GET("/api/articles", handlers.HomeArticlesAPI(db))
|
||||
router.GET("/rss", handlers.RSSFeed(db))
|
||||
router.GET("/feed", handlers.RSSFeed(db))
|
||||
router.GET("/login", handlers.LoginPage())
|
||||
router.POST("/login", handlers.Login(db, loginLimiter))
|
||||
router.GET("/register", handlers.RegisterPage(db))
|
||||
router.POST("/register", handlers.Register(db))
|
||||
router.POST("/logout", handlers.Logout())
|
||||
router.GET("/article/:slug", handlers.ArticleDetail(db))
|
||||
router.POST("/article/:slug/comments", handlers.PostComment(db))
|
||||
|
||||
// 受保护的后台路由(仅管理员角色)。
|
||||
admin := router.Group("/admin")
|
||||
admin.Use(middleware.AuthRequired(db), middleware.AdminRequired(db))
|
||||
{
|
||||
admin.GET("", handlers.AdminDashboard(db))
|
||||
admin.GET("/articles", handlers.ArticleListPage(db))
|
||||
admin.GET("/articles/new", handlers.ArticleCreatePage(db))
|
||||
admin.POST("/articles/new", handlers.ArticleCreate(db))
|
||||
admin.GET("/articles/:id/edit", handlers.ArticleEditPage(db))
|
||||
admin.POST("/articles/:id/edit", handlers.ArticleUpdate(db))
|
||||
admin.POST("/articles/:id/delete", handlers.ArticleDelete(db))
|
||||
|
||||
}
|
||||
|
||||
// 受保护的后台评论管理路由(仅管理员角色)。
|
||||
comments := router.Group("/admin/comments")
|
||||
comments.Use(middleware.AuthRequired(db), middleware.AdminRequired(db))
|
||||
{
|
||||
comments.GET("", handlers.CommentListPage(db))
|
||||
comments.POST("/:id/approve", handlers.CommentApprove(db))
|
||||
comments.POST("/:id/reject", handlers.CommentReject(db))
|
||||
comments.POST("/:id/delete", handlers.CommentDelete(db))
|
||||
}
|
||||
|
||||
// 受保护的后台用户管理路由(仅管理员角色)。
|
||||
users := router.Group("/admin/users")
|
||||
users.Use(middleware.AuthRequired(db), middleware.AdminRequired(db))
|
||||
{
|
||||
users.GET("", handlers.UserListPage(db))
|
||||
users.GET("/new", handlers.UserCreatePage(db))
|
||||
users.POST("/new", handlers.UserCreate(db))
|
||||
users.GET("/:id/edit", handlers.UserEditPage(db))
|
||||
users.POST("/:id/edit", handlers.UserUpdate(db))
|
||||
users.POST("/:id/delete", handlers.UserDelete(db))
|
||||
}
|
||||
|
||||
// 受保护的文章附件路由(仅管理员角色)。
|
||||
attachments := router.Group("/admin/articles")
|
||||
attachments.Use(middleware.AuthRequired(db), middleware.AdminRequired(db))
|
||||
{
|
||||
attachments.POST("/attachments", handlers.UploadAttachment(db, cfg.Path))
|
||||
attachments.POST("/attachments/:id/delete", handlers.DeleteAttachment(db, cfg.Path))
|
||||
attachments.GET("/:id/attachments", handlers.ListAttachments(db))
|
||||
}
|
||||
|
||||
// 受保护的后台设置路由(平台配置)。
|
||||
settings := router.Group("/admin/settings")
|
||||
settings.Use(middleware.AuthRequired(db), middleware.AdminRequired(db))
|
||||
{
|
||||
settings.GET("/site", handlers.SiteSettingsPage(db))
|
||||
settings.POST("/site", handlers.SiteSettingsSave(db, cfg.Path))
|
||||
settings.GET("/navlinks", handlers.NavLinksSettingsPage(db))
|
||||
settings.POST("/navlinks", handlers.NavLinksSettingsSave(db))
|
||||
settings.GET("/upload", handlers.UploadSettingsPage(db))
|
||||
settings.POST("/upload", handlers.UploadSettingsSave(db))
|
||||
settings.GET("/download", handlers.DownloadSettingsPage(db))
|
||||
settings.POST("/download", handlers.DownloadSettingsSave(db))
|
||||
settings.GET("/comments", handlers.CommentSettingsPage(db))
|
||||
settings.POST("/comments", handlers.CommentSettingsSave(db))
|
||||
}
|
||||
|
||||
// 受保护的后台统计路由(读取统计信息)。
|
||||
analytics := router.Group("/admin/analytics")
|
||||
analytics.Use(middleware.AuthRequired(db), middleware.AdminRequired(db))
|
||||
{
|
||||
analytics.GET("/views", handlers.ViewAnalyticsPage(db))
|
||||
}
|
||||
|
||||
// 受保护的个人资料路由。
|
||||
profile := router.Group("/profile")
|
||||
profile.Use(middleware.AuthRequired(db))
|
||||
{
|
||||
profile.GET("", handlers.ProfilePage(db))
|
||||
profile.POST("", handlers.UpdateProfile(db, cfg.Path))
|
||||
profile.POST("/avatar", handlers.UploadAvatar(db, cfg.Path))
|
||||
}
|
||||
|
||||
// 受保护的用户文章管理路由(面向非管理员用户)。
|
||||
myArticles := router.Group("/my")
|
||||
myArticles.Use(middleware.AuthRequired(db))
|
||||
{
|
||||
myArticles.GET("/articles", handlers.MyArticlesPage(db))
|
||||
myArticles.GET("/articles/new", handlers.MyArticleCreatePage(db))
|
||||
myArticles.POST("/articles/new", handlers.MyArticleCreate(db))
|
||||
myArticles.GET("/articles/:id/edit", handlers.MyArticleEditPage(db))
|
||||
myArticles.POST("/articles/:id/edit", handlers.MyArticleUpdate(db))
|
||||
myArticles.POST("/articles/:id/delete", handlers.MyArticleDelete(db))
|
||||
}
|
||||
|
||||
// 用户文章的受保护附件路由。
|
||||
myAttachments := router.Group("/my/articles")
|
||||
myAttachments.Use(middleware.AuthRequired(db))
|
||||
{
|
||||
myAttachments.POST("/attachments", handlers.UploadAttachment(db, cfg.Path))
|
||||
myAttachments.POST("/attachments/:id/delete", handlers.DeleteAttachment(db, cfg.Path))
|
||||
myAttachments.GET("/:id/attachments", handlers.ListAttachments(db))
|
||||
}
|
||||
registerRoutes(router, cfg, db, loginLimiter)
|
||||
|
||||
// 9. 启动服务器。
|
||||
webPort := cfg.Web.Port
|
||||
@@ -259,6 +152,135 @@ func main() {
|
||||
select {}
|
||||
}
|
||||
|
||||
// registerRoutes 注册全部业务路由。独立成函数便于测试:
|
||||
// 签名包含 db 与 loginLimiter,但注册阶段不会触碰它们(handler 是惰性工厂),
|
||||
// 因此冒烟测试可传 nil。
|
||||
func registerRoutes(router *gin.Engine, cfg *config.Config, db *gorm.DB, loginLimiter *handlers.LoginRateLimiter) {
|
||||
// 公开页面。
|
||||
router.GET("/", handlers.HomePage(db))
|
||||
router.GET("/search", handlers.SearchPage(db))
|
||||
router.GET("/rss", handlers.RSSFeed(db))
|
||||
router.GET("/feed", handlers.RSSFeed(db))
|
||||
router.GET("/login", handlers.LoginPage())
|
||||
router.POST("/login", handlers.Login(db, loginLimiter))
|
||||
router.GET("/register", handlers.RegisterPage(db))
|
||||
router.POST("/register", handlers.Register(db))
|
||||
router.POST("/logout", handlers.Logout())
|
||||
router.GET("/article/:slug", handlers.ArticleDetail(db))
|
||||
router.POST("/article/:slug/comments", handlers.PostComment(db))
|
||||
|
||||
// 公开 JSON API。
|
||||
api := router.Group("/api")
|
||||
{
|
||||
api.GET("/articles", handlers.HomeArticlesAPI(db))
|
||||
}
|
||||
|
||||
// 受保护的后台路由(仅管理员角色)。
|
||||
admin := router.Group("/admin")
|
||||
admin.Use(middleware.AuthRequired(db), middleware.AdminRequired(db))
|
||||
{
|
||||
admin.GET("", handlers.AdminDashboard(db))
|
||||
admin.GET("/articles", handlers.ArticleListPage(db))
|
||||
admin.GET("/articles/new", handlers.ArticleCreatePage(db))
|
||||
admin.POST("/articles/new", handlers.ArticleCreate(db))
|
||||
admin.GET("/articles/:id/edit", handlers.ArticleEditPage(db))
|
||||
admin.POST("/articles/:id/edit", handlers.ArticleUpdate(db))
|
||||
admin.POST("/articles/:id/delete", handlers.ArticleDelete(db))
|
||||
}
|
||||
|
||||
// 受保护的后台评论管理路由(仅管理员角色)。
|
||||
comments := router.Group("/admin/comments")
|
||||
comments.Use(middleware.AuthRequired(db), middleware.AdminRequired(db))
|
||||
{
|
||||
comments.GET("", handlers.CommentListPage(db))
|
||||
comments.POST("/:id/approve", handlers.CommentApprove(db))
|
||||
comments.POST("/:id/reject", handlers.CommentReject(db))
|
||||
comments.POST("/:id/delete", handlers.CommentDelete(db))
|
||||
}
|
||||
|
||||
// 受保护的后台用户管理路由(仅管理员角色)。
|
||||
users := router.Group("/admin/users")
|
||||
users.Use(middleware.AuthRequired(db), middleware.AdminRequired(db))
|
||||
{
|
||||
users.GET("", handlers.UserListPage(db))
|
||||
users.GET("/new", handlers.UserCreatePage(db))
|
||||
users.POST("/new", handlers.UserCreate(db))
|
||||
users.GET("/:id/edit", handlers.UserEditPage(db))
|
||||
users.POST("/:id/edit", handlers.UserUpdate(db))
|
||||
users.POST("/:id/delete", handlers.UserDelete(db))
|
||||
}
|
||||
|
||||
// 受保护的后台文章附件 API / 路由(仅管理员角色)。
|
||||
// 注意 /api/admin/articles/attachments 的静态段与 /:id 参数段共存,
|
||||
// gin 对静态段优先,无冲突(由 main_test.go 冒烟测试验证)。
|
||||
adminAPI := router.Group("/api/admin")
|
||||
adminAPI.Use(middleware.AuthRequired(db), middleware.AdminRequired(db))
|
||||
{
|
||||
adminAPI.POST("/articles/attachments", handlers.UploadAttachment(db, cfg.Path))
|
||||
adminAPI.DELETE("/articles/attachments/:id", handlers.DeleteAttachment(db, cfg.Path))
|
||||
adminAPI.GET("/articles/:id/attachments", handlers.ListAttachments(db))
|
||||
}
|
||||
|
||||
// 受保护的后台设置路由(平台配置)。
|
||||
settings := router.Group("/admin/settings")
|
||||
settings.Use(middleware.AuthRequired(db), middleware.AdminRequired(db))
|
||||
{
|
||||
settings.GET("/site", handlers.SiteSettingsPage(db))
|
||||
settings.POST("/site", handlers.SiteSettingsSave(db, cfg.Path))
|
||||
settings.GET("/navlinks", handlers.NavLinksSettingsPage(db))
|
||||
settings.POST("/navlinks", handlers.NavLinksSettingsSave(db))
|
||||
settings.GET("/upload", handlers.UploadSettingsPage(db))
|
||||
settings.POST("/upload", handlers.UploadSettingsSave(db))
|
||||
settings.GET("/download", handlers.DownloadSettingsPage(db))
|
||||
settings.POST("/download", handlers.DownloadSettingsSave(db))
|
||||
settings.GET("/comments", handlers.CommentSettingsPage(db))
|
||||
settings.POST("/comments", handlers.CommentSettingsSave(db))
|
||||
}
|
||||
|
||||
// 受保护的后台统计路由(读取统计信息)。
|
||||
analytics := router.Group("/admin/analytics")
|
||||
analytics.Use(middleware.AuthRequired(db), middleware.AdminRequired(db))
|
||||
{
|
||||
analytics.GET("/views", handlers.ViewAnalyticsPage(db))
|
||||
}
|
||||
|
||||
// 受保护的个人资料路由。
|
||||
profile := router.Group("/profile")
|
||||
profile.Use(middleware.AuthRequired(db))
|
||||
{
|
||||
profile.GET("", handlers.ProfilePage(db))
|
||||
profile.POST("", handlers.UpdateProfile(db, cfg.Path))
|
||||
}
|
||||
|
||||
profileAPI := router.Group("/api/profile")
|
||||
profileAPI.Use(middleware.AuthRequired(db))
|
||||
{
|
||||
profileAPI.POST("/avatar", handlers.UploadAvatar(db, cfg.Path))
|
||||
profileAPI.POST("", handlers.UpdateProfile(db, cfg.Path))
|
||||
}
|
||||
|
||||
// 受保护的用户文章管理路由(面向非管理员用户)。
|
||||
myArticles := router.Group("/my")
|
||||
myArticles.Use(middleware.AuthRequired(db))
|
||||
{
|
||||
myArticles.GET("/articles", handlers.MyArticlesPage(db))
|
||||
myArticles.GET("/articles/new", handlers.MyArticleCreatePage(db))
|
||||
myArticles.POST("/articles/new", handlers.MyArticleCreate(db))
|
||||
myArticles.GET("/articles/:id/edit", handlers.MyArticleEditPage(db))
|
||||
myArticles.POST("/articles/:id/edit", handlers.MyArticleUpdate(db))
|
||||
myArticles.POST("/articles/:id/delete", handlers.MyArticleDelete(db))
|
||||
}
|
||||
|
||||
// 用户文章的受保护附件 API(仅登录用户)。
|
||||
myAPI := router.Group("/api/my/articles")
|
||||
myAPI.Use(middleware.AuthRequired(db))
|
||||
{
|
||||
myAPI.POST("/attachments", handlers.UploadAttachment(db, cfg.Path))
|
||||
myAPI.DELETE("/attachments/:id", handlers.DeleteAttachment(db, cfg.Path))
|
||||
myAPI.GET("/:id/attachments", handlers.ListAttachments(db))
|
||||
}
|
||||
}
|
||||
|
||||
// registerUploadRoutes 在 /uploads 组下暴露公开的上传子目录:avatars、
|
||||
// logos,以及配置的附件存储目录(外加向后兼容的默认 "attachments")。
|
||||
// 存储根目录绝不挂载——其中还包含 SQLite 数据库文件,
|
||||
|
||||
@@ -8,6 +8,9 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"go_blog/config"
|
||||
"go_blog/handlers"
|
||||
)
|
||||
|
||||
// newUploadsRouter 在模拟真实布局的临时存储根目录上构建
|
||||
@@ -127,3 +130,38 @@ func TestUploadsWhitelistStorageDirDedup(t *testing.T) {
|
||||
t.Fatalf("GET /uploads/avatars/me.jpg = %d, want 200", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegisterRoutesSmoke 通过完整的路由注册冒烟测试:
|
||||
// 1. 路由冲突(静态段 attachment 与 :id 参数段共存)会在此处 panic;
|
||||
// 2. 断言 /api 搬移端点已在正确的 HTTP 方法下注册。
|
||||
func TestRegisterRoutesSmoke(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
registerRoutes(r, &config.Config{}, nil, handlers.NewLoginLimiter())
|
||||
|
||||
want := map[string]string{
|
||||
// 既有 JSON API。
|
||||
"GET /api/articles": "",
|
||||
// 搬移的附件/头像端点。
|
||||
"POST /api/admin/articles/attachments": "",
|
||||
"DELETE /api/admin/articles/attachments/:id": "",
|
||||
"GET /api/admin/articles/:id/attachments": "",
|
||||
"POST /api/profile/avatar": "",
|
||||
"POST /api/profile": "",
|
||||
"POST /api/my/articles/attachments": "",
|
||||
"DELETE /api/my/articles/attachments/:id": "",
|
||||
"GET /api/my/articles/:id/attachments": "",
|
||||
}
|
||||
for route := range want {
|
||||
found := false
|
||||
for _, rt := range r.Routes() {
|
||||
if rt.Method+" "+rt.Path == route {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("route %s not registered", route)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-contrib/sessions"
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -11,6 +12,30 @@ import (
|
||||
"go_blog/models"
|
||||
)
|
||||
|
||||
// isAPIRequest 报告请求是否命中 /api 前缀的 JSON 接口。
|
||||
// 认证失败时 API 返回 JSON 错误,页面则保持 302 重定向。
|
||||
func isAPIRequest(c *gin.Context) bool {
|
||||
return strings.HasPrefix(c.Request.URL.Path, "/api")
|
||||
}
|
||||
|
||||
// apiAuthError 以 API 错误格式终止请求(401 未认证 / 403 无权限)。
|
||||
// 文案按请求语言翻译(SetUserContext 已在全局中间件中注入 tr)。
|
||||
func apiAuthError(c *gin.Context, status int, trKey string) {
|
||||
tr, _ := c.Get("tr")
|
||||
m, _ := tr.(map[string]string)
|
||||
code := trKey
|
||||
msg := m[code]
|
||||
if msg == "" {
|
||||
code = "api_error"
|
||||
msg = m["api_error"]
|
||||
}
|
||||
c.AbortWithStatusJSON(status, gin.H{
|
||||
"ok": false,
|
||||
"code": code,
|
||||
"error": msg,
|
||||
})
|
||||
}
|
||||
|
||||
// sessionUserID 从会话中提取已登录用户的数值 ID,
|
||||
// 兼容 int/uint/int64/float64 的存储类型。若不存在或类型不符,ok=false。
|
||||
func sessionUserID(session sessions.Session) (uint, bool) {
|
||||
@@ -56,6 +81,10 @@ func AuthRequired(db *gorm.DB) gin.HandlerFunc {
|
||||
session := sessions.Default(c)
|
||||
uid, ok := sessionUserID(session)
|
||||
if !ok {
|
||||
if isAPIRequest(c) {
|
||||
apiAuthError(c, http.StatusUnauthorized, "api_unauthorized")
|
||||
return
|
||||
}
|
||||
c.Redirect(http.StatusFound, "/login")
|
||||
c.Abort()
|
||||
return
|
||||
@@ -64,6 +93,10 @@ func AuthRequired(db *gorm.DB) gin.HandlerFunc {
|
||||
if err := db.First(&user, uid).Error; err != nil || user.Status != models.StatusNormal {
|
||||
// 账户已不可用——销毁会话,防止过期 Cookie 被重放。
|
||||
clearUserSession(session)
|
||||
if isAPIRequest(c) {
|
||||
apiAuthError(c, http.StatusUnauthorized, "api_unauthorized")
|
||||
return
|
||||
}
|
||||
c.Redirect(http.StatusFound, "/login")
|
||||
c.Abort()
|
||||
return
|
||||
@@ -80,12 +113,20 @@ func AdminRequired(db *gorm.DB) gin.HandlerFunc {
|
||||
session := sessions.Default(c)
|
||||
uid, ok := sessionUserID(session)
|
||||
if !ok {
|
||||
if isAPIRequest(c) {
|
||||
apiAuthError(c, http.StatusUnauthorized, "api_unauthorized")
|
||||
return
|
||||
}
|
||||
c.Redirect(http.StatusFound, "/login")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
var user models.User
|
||||
if err := db.First(&user, uid).Error; err != nil || user.Role != models.RoleAdmin {
|
||||
if isAPIRequest(c) {
|
||||
apiAuthError(c, http.StatusForbidden, "api_forbidden")
|
||||
return
|
||||
}
|
||||
c.Redirect(http.StatusFound, "/admin")
|
||||
c.Abort()
|
||||
return
|
||||
|
||||
@@ -176,6 +176,54 @@
|
||||
</div>
|
||||
</footer>
|
||||
<script>
|
||||
// ---- Shared API helpers(/api/* JSON 接口统一入口) ----
|
||||
window.blogAPI = function (method, url, body) {
|
||||
var meta = document.querySelector('meta[name="csrf-token"]');
|
||||
var csrf = meta ? meta.getAttribute('content') : '';
|
||||
var opts = {
|
||||
method: method,
|
||||
headers: {
|
||||
'X-CSRF-Token': csrf,
|
||||
'Accept': 'application/json'
|
||||
},
|
||||
credentials: 'same-origin'
|
||||
};
|
||||
if (body !== undefined) {
|
||||
opts.headers['Content-Type'] = 'application/json';
|
||||
opts.body = JSON.stringify(body);
|
||||
}
|
||||
return fetch(url, opts).then(function (r) {
|
||||
return r.json().catch(function () {
|
||||
return { ok: false, code: 'api_error', error: r.statusText };
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// 将表单序列化为 JSON 数据对象:
|
||||
// - 文本/select/textarea 从 FormData 取值(checkboxes 在下述循环覆盖)
|
||||
// - checkbox 一律转 bool(未选中也发送 false,匹配服务端 JSON 绑定)
|
||||
// - 提交按钮(如文章表单的草稿/发布 name=status)取自 submitter
|
||||
window.blogForm = function (form, submitter) {
|
||||
var data = {};
|
||||
new FormData(form).forEach(function (v, k) {
|
||||
if (k === '_csrf') return;
|
||||
if (!(k in data)) data[k] = v;
|
||||
});
|
||||
Array.prototype.forEach.call(form.querySelectorAll('input[type="checkbox"]'), function (el) {
|
||||
if (el.name) data[el.name] = el.checked;
|
||||
});
|
||||
if (submitter && submitter.name) {
|
||||
data[submitter.name] = submitter.value;
|
||||
}
|
||||
return data;
|
||||
};
|
||||
|
||||
// 在指定错误 div 中显示 API 错误文案。
|
||||
window.blogShowError = function (divId, msg) {
|
||||
var el = document.getElementById(divId);
|
||||
if (el) { el.textContent = msg; el.classList.remove('hidden'); }
|
||||
};
|
||||
|
||||
function toggleDropdown() {
|
||||
var menu = document.getElementById('dropdownMenu');
|
||||
menu.classList.toggle('hidden');
|
||||
|
||||
Reference in New Issue
Block a user