From f307781f58cc7ff4145ddaff8fd2ffa60b13f775 Mon Sep 17 00:00:00 2001 From: kevin Date: Thu, 27 Aug 2026 19:03:03 +0800 Subject: [PATCH] =?UTF-8?q?docs:=20=E5=85=A8=E9=83=A8=20Go=20=E4=BB=A3?= =?UTF-8?q?=E7=A0=81=E6=B3=A8=E9=87=8A=E6=B1=89=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 48 个 Go 文件所有注释(行注释/块注释/行尾注释,含 _test.go)翻译为中文 - 保留技术标识符:SECURITY_TODO(n)、unsafe-inline、sqlite/mysql、路由参数等 - 代码、字符串字面量、日志消息保持英文原文,零逻辑改动 - go build/vet 通过,go test -count=1 ./... 全绿 --- config/config.go | 62 +++++----- config/config_test.go | 4 +- handlers/admin.go | 6 +- handlers/admin_analytics.go | 14 +-- handlers/admin_comment.go | 26 ++--- handlers/admin_user.go | 72 ++++++------ handlers/article.go | 143 +++++++++++------------ handlers/attachment.go | 82 ++++++------- handlers/auth.go | 52 ++++----- handlers/comment.go | 89 +++++++------- handlers/helpers.go | 27 ++--- handlers/home.go | 106 ++++++++--------- handlers/login_ratelimit.go | 45 ++++--- handlers/my_articles.go | 22 ++-- handlers/p2_validation_test.go | 66 +++++------ handlers/p3_upload_test.go | 31 +++-- handlers/profile.go | 87 +++++++------- handlers/rss.go | 53 +++++---- handlers/security_test.go | 78 ++++++------- handlers/session_upload_security_test.go | 48 ++++---- handlers/settings.go | 102 ++++++++-------- handlers/upload_validator.go | 36 +++--- i18n/i18n.go | 64 +++++----- main.go | 115 +++++++++--------- main_test.go | 18 +-- middleware/auth.go | 61 +++++----- middleware/clientip_test.go | 14 +-- middleware/csrf.go | 40 +++---- middleware/csrf_test.go | 13 +-- middleware/https.go | 11 +- middleware/security_headers.go | 23 ++-- middleware/security_headers_test.go | 14 +-- models/article.go | 38 +++--- models/article_tag.go | 4 +- models/article_view.go | 14 +-- models/attachment.go | 35 +++--- models/bot_detector.go | 6 +- models/comment.go | 34 +++--- models/comment_config.go | 24 ++-- models/config_cache.go | 37 +++--- models/db.go | 26 ++--- models/db_test.go | 9 +- models/nav_link.go | 19 ++- models/seed.go | 27 ++--- models/site_setting.go | 71 ++++++----- models/tag.go | 28 ++--- models/upload_config.go | 50 ++++---- models/user.go | 17 ++- 48 files changed, 983 insertions(+), 1080 deletions(-) diff --git a/config/config.go b/config/config.go index f0ced64..134e8dd 100644 --- a/config/config.go +++ b/config/config.go @@ -11,7 +11,7 @@ import ( "gopkg.in/yaml.v3" ) -// Config holds all application configuration. +// Config 保存全部应用配置。 type Config struct { Database DatabaseConfig `yaml:"database"` Web WebConfig `yaml:"web"` @@ -19,33 +19,32 @@ type Config struct { Secret string `yaml:"secret"` } -// DatabaseConfig holds database-specific configuration. +// DatabaseConfig 保存数据库相关配置。 type DatabaseConfig struct { - Type string `yaml:"type"` // "sqlite" (default) or "mysql" - DSN string `yaml:"dsn"` // MySQL connection string (required when type is "mysql") + Type string `yaml:"type"` // "sqlite"(默认)或 "mysql" + DSN string `yaml:"dsn"` // MySQL 连接字符串(type 为 "mysql" 时必填) } -// WebConfig holds web-server listening configuration. +// WebConfig 保存 Web 服务器监听配置。 type WebConfig struct { - Port string `yaml:"port"` // TCP port, "" or "0" to disable - Socket string `yaml:"socket"` // Unix socket path, "" to disable - // TrustedProxies lists proxy IPs/CIDRs whose X-Forwarded-For / - // X-Forwarded-Proto headers are trusted (e.g. the Caddy/nginx box in - // front of the app). Defaults to loopback. If the app is exposed - // directly to clients, leave the default so client-supplied - // X-Forwarded-For cannot spoof the logged IP. + Port string `yaml:"port"` // TCP 端口,"" 或 "0" 表示禁用 + Socket string `yaml:"socket"` // Unix Socket 路径,"" 表示禁用 + // TrustedProxies 列出可信代理 IP/CIDR,这些代理的 X-Forwarded-For / + // X-Forwarded-Proto 请求头将被信任(例如位于应用前方的 Caddy/nginx + // 服务器)。默认为回环地址。如果应用直接暴露给客户端,请保持默认值, + // 以免客户端伪造 X-Forwarded-For 欺骗记录中的 IP。 TrustedProxies []string `yaml:"trusted_proxies"` } -// defaultTrustedProxies is used when the config omits trusted_proxies. +// defaultTrustedProxies 在配置中省略 trusted_proxies 时使用。 var defaultTrustedProxies = []string{"127.0.0.1", "::1"} const defaultPort = "8080" -// mysqlExampleDSN is written into new config files as a reference. +// mysqlExampleDSN 会写入新建的配置文件,作为参考示例。 const mysqlExampleDSN = "user:password@tcp(127.0.0.1:3306)/blog_go?charset=utf8mb4&parseTime=True&loc=Local" -// getConfigPath returns the OS-aware config directory and config file path. +// getConfigPath 返回按操作系统区分的配置目录和配置文件路径。 func getConfigPath() (dir, file string) { switch runtime.GOOS { case "windows": @@ -59,7 +58,7 @@ func getConfigPath() (dir, file string) { return } -// getDefaultStoragePath returns the OS-aware default storage path. +// getDefaultStoragePath 返回按操作系统区分的默认存储路径。 func getDefaultStoragePath() string { switch runtime.GOOS { case "windows": @@ -71,9 +70,8 @@ func getDefaultStoragePath() string { } } -// generateSecret returns a cryptographically random hex string for the -// session secret. A failure of crypto/rand is unrecoverable, so the program -// terminates instead of falling back to a predictable value. +// generateSecret 为会话密钥生成密码学随机的十六进制字符串。 +// crypto/rand 的失败无法恢复,因此程序将直接终止,而不会退回到可预测的值。 func generateSecret() string { b := make([]byte, 32) if _, err := rand.Read(b); err != nil { @@ -82,15 +80,16 @@ func generateSecret() string { return hex.EncodeToString(b) } -// getDefaultSocketPath returns the OS-aware default unix socket path. +// getDefaultSocketPath 返回按操作系统区分的默认 Unix Socket 路径。 func getDefaultSocketPath() string { if runtime.GOOS == "linux" { return "/run/blog_go/web.sock" } return "" } -// LoadConfig reads the config file or creates one with defaults. -// If customPath is non-empty, it overrides the OS-aware config file path. + +// LoadConfig 读取配置文件;若不存在则按默认值创建。 +// 若 customPath 非空,则覆盖按操作系统区分的配置文件路径。 func LoadConfig(customPath string) *Config { configDir, configFile := getConfigPath() if customPath != "" { @@ -99,7 +98,7 @@ func LoadConfig(customPath string) *Config { } defaultPath := getDefaultStoragePath() - // Check if config file exists; create with defaults if not. + // 检查配置文件是否存在;不存在则按默认值创建。 if _, err := os.Stat(configFile); os.IsNotExist(err) { log.Printf("Config file not found at %s, creating with defaults...", configFile) if err := os.MkdirAll(configDir, 0755); err != nil { @@ -127,13 +126,13 @@ func LoadConfig(customPath string) *Config { if err := os.WriteFile(configFile, data, 0640); err != nil { log.Fatalf("Failed to write config file %s: %v", configFile, err) } - // SECURITY_TODO #11: the config file holds the session secret; keep it - // owner-readable only (install_linux.sh already applies 0640). + // SECURITY_TODO #11:配置文件保存会话密钥;仅允许所有者读取 + // (install_linux.sh 已应用 0640 权限)。 log.Printf("Default config created at %s", configFile) return cfg } - // Read existing config file. + // 读取现有配置文件。 data, err := os.ReadFile(configFile) if err != nil { log.Fatalf("Failed to read config file %s: %v", configFile, err) @@ -147,10 +146,10 @@ func LoadConfig(customPath string) *Config { return applyDefaults(cfg, defaultPath, configFile) } -// applyDefaults fills zero-value fields with sensible defaults. +// applyDefaults 以合理的默认值填充零值字段。 func applyDefaults(cfg *Config, defaultPath, configFile string) *Config { - // If the entire web block is empty (old config without "web" key), - // fill default port so the app still starts on 8080. + // 如果整个 web 块为空(旧配置中没有 "web" 键), + // 填充默认端口,使应用仍能从 8080 启动。 if cfg.Web.Port == "" && cfg.Web.Socket == "" { cfg.Web.Port = defaultPort } @@ -164,9 +163,8 @@ func applyDefaults(cfg *Config, defaultPath, configFile string) *Config { cfg.Path = defaultPath } if cfg.Secret == "" { - // The config file exists but has no secret. Refuse to start: a - // silently generated fallback would either be predictable (old - // hostname+pid scheme) or invalidate all sessions on every restart. + // 配置文件存在但没有密钥。拒绝启动:静默生成的回退值要么 + // 可预测(旧的 hostname+pid 方案),要么导致每次重启都使所有会话失效。 log.Fatalf("Config file %s is missing a session secret. "+ "Add a random value, e.g. `secret: %s`, and restart.", configFile, generateSecret()) diff --git a/config/config_test.go b/config/config_test.go index 67ad3eb..586067f 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -6,8 +6,8 @@ import ( "testing" ) -// TestConfigFileCreatedNotWorldReadable covers SECURITY_TODO #11: the config -// file (which embeds the session secret) must not be group/world readable. +// TestConfigFileCreatedNotWorldReadable 覆盖 SECURITY_TODO #11: +// 配置文件(内嵌会话密钥)不得被组/其他用户读取。 func TestConfigFileCreatedNotWorldReadable(t *testing.T) { path := filepath.Join(t.TempDir(), "config.yaml") LoadConfig(path) diff --git a/handlers/admin.go b/handlers/admin.go index 31f8c02..bdd846a 100644 --- a/handlers/admin.go +++ b/handlers/admin.go @@ -9,7 +9,7 @@ import ( "gorm.io/gorm" ) -// AdminDashboard renders the protected admin dashboard. +// AdminDashboard 渲染受保护的管理后台首页。 func AdminDashboard(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { tr := getTr(c) @@ -18,14 +18,14 @@ func AdminDashboard(db *gorm.DB) gin.HandlerFunc { data["Title"] = tr["dash_page_title"] data["Username"] = username - // Article counts: total and published. + // 文章数量:总数与已发布数。 var postCount, publishedCount int64 db.Model(&models.Article{}).Count(&postCount) db.Model(&models.Article{}).Where("status = ?", models.ArticlePublished).Count(&publishedCount) data["PostCount"] = postCount data["PublishedCount"] = publishedCount - // User count for the stats card. + // 统计卡片中的用户数。 var userCount int64 db.Model(&models.User{}).Count(&userCount) data["UserCount"] = userCount diff --git a/handlers/admin_analytics.go b/handlers/admin_analytics.go index fb5cb94..fcda684 100644 --- a/handlers/admin_analytics.go +++ b/handlers/admin_analytics.go @@ -10,12 +10,12 @@ import ( "gorm.io/gorm" ) -// ViewAnalyticsPage renders the admin analytics page showing article view statistics. +// ViewAnalyticsPage 渲染展示文章浏览统计的后台分析页面。 func ViewAnalyticsPage(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { tr := getTr(c) - // Parse filters from query params + // 从查询参数解析筛选条件 articleTitle := c.Query("article_title") userIDStr := c.Query("user_id") ip := c.Query("ip") @@ -27,13 +27,13 @@ func ViewAnalyticsPage(db *gorm.DB) gin.HandlerFunc { } } - // Build query for view records + // 构建浏览记录查询 query := db.Model(&models.ArticleView{}). Preload("Article"). Preload("User"). Order("created_at DESC") - // Filter by article title (join with articles table) + // 按文章标题筛选(联表 articles) if articleTitle != "" { query = query.Joins("JOIN articles ON article_views.article_id = articles.id"). Where("articles.title LIKE ?", "%"+articleTitle+"%") @@ -48,7 +48,7 @@ func ViewAnalyticsPage(db *gorm.DB) gin.HandlerFunc { query = query.Where("is_bot = ?", false) } - // Pagination + // 分页 pageSize := 50 offset := (page - 1) * pageSize var views []models.ArticleView @@ -58,7 +58,7 @@ func ViewAnalyticsPage(db *gorm.DB) gin.HandlerFunc { hasMore := int64(offset+len(views)) < totalCount - // Calculate global statistics + // 计算全局统计 var stats struct { TotalViews int64 UniqueIPs int64 @@ -72,7 +72,7 @@ func ViewAnalyticsPage(db *gorm.DB) gin.HandlerFunc { db.Model(&models.ArticleView{}).Distinct("ip").Count(&stats.UniqueIPs) db.Model(&models.ArticleView{}).Where("user_id IS NOT NULL").Distinct("user_id").Count(&stats.UniqueUsers) - // Per-article statistics + // 按文章统计 type ArticleStat struct { ArticleID uint Title string diff --git a/handlers/admin_comment.go b/handlers/admin_comment.go index 1d6d0b9..6f65442 100644 --- a/handlers/admin_comment.go +++ b/handlers/admin_comment.go @@ -10,10 +10,10 @@ import ( "gorm.io/gorm" ) -// adminCommentPageSize bounds the number of comments shown per admin page. +// adminCommentPageSize 限制后台每页显示的评论数量。 const adminCommentPageSize = 30 -// commentListView is a Comment plus derived display fields for the admin list. +// commentListView 是 Comment 加上后台列表所需的派生展示字段。 type commentListView struct { models.Comment GravatarURL string @@ -26,8 +26,8 @@ type commentListView struct { ArticleSlug string } -// CommentListPage renders the admin comment moderation list, filtered by -// status via ?status=pending|approved|rejected|all. +// CommentListPage 渲染后台评论审核列表,可通过 +// ?status=pending|approved|rejected|all 按状态筛选。 func CommentListPage(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { tr := getTr(c) @@ -43,7 +43,7 @@ func CommentListPage(db *gorm.DB) gin.HandlerFunc { case "rejected": q = q.Where("status = ?", models.CommentRejected) case "all": - // no filter + // 不过滤 default: // pending status = "pending" q = q.Where("status = ?", models.CommentPending) @@ -52,7 +52,7 @@ func CommentListPage(db *gorm.DB) gin.HandlerFunc { var comments []models.Comment q.Limit(adminCommentPageSize).Find(&comments) - // Pull referenced articles in one query to avoid N+1. + // 用一次查询拉取引用的文章,避免 N+1。 ids := make(map[uint]struct{}) for _, cm := range comments { ids[cm.ArticleID] = struct{}{} @@ -71,9 +71,8 @@ func CommentListPage(db *gorm.DB) gin.HandlerFunc { } views := make([]commentListView, 0, len(comments)) - // SECURITY_TODO #15: the admin list follows the platform switch — - // no Gravatar request when it is disabled (front-end placeholder - // used instead). + // SECURITY_TODO #15:后台列表遵循平台开关——关闭时不发起 Gravatar + // 请求(改为使用前端占位头像)。 useGravatar := models.GetCommentConfig().UseGravatar for _, cm := range comments { gravURL := "" @@ -105,7 +104,7 @@ func CommentListPage(db *gorm.DB) gin.HandlerFunc { views = append(views, v) } - // Pending count for the tab badge. + // 标签徽章使用的待审数量。 var pendingCount int64 db.Model(&models.Comment{}).Where("status = ?", models.CommentPending).Count(&pendingCount) @@ -130,7 +129,7 @@ func CommentListPage(db *gorm.DB) gin.HandlerFunc { } } -// CommentApprove marks a comment approved. +// CommentApprove 将评论标记为通过。 func CommentApprove(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { if id := c.Param("id"); id != "" { @@ -140,8 +139,7 @@ func CommentApprove(db *gorm.DB) gin.HandlerFunc { } } -// CommentReject marks a comment rejected (hidden from the front end, retained -// in the admin list). +// CommentReject 将评论标记为拒绝(前端隐藏,后台列表保留)。 func CommentReject(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { if id := c.Param("id"); id != "" { @@ -151,7 +149,7 @@ func CommentReject(db *gorm.DB) gin.HandlerFunc { } } -// CommentDelete soft-deletes a comment. +// CommentDelete 软删除一条评论。 func CommentDelete(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { if id := c.Param("id"); id != "" { diff --git a/handlers/admin_user.go b/handlers/admin_user.go index aef988c..ba64fad 100644 --- a/handlers/admin_user.go +++ b/handlers/admin_user.go @@ -13,8 +13,7 @@ import ( "gorm.io/gorm" ) -// userForm holds the posted user fields plus rendering metadata for the shared -// create/edit form template. +// userForm 保存提交的用户字段,外加共享创建/编辑表单模板的渲染元数据。 type userForm struct { ID uint Username string @@ -22,7 +21,7 @@ type userForm struct { DisplayName string Email string Gender string - Birthday string // YYYY-MM-DD from the date input + Birthday string // 来自日期输入的 YYYY-MM-DD Role string Status int IsEdit bool @@ -30,8 +29,8 @@ type userForm struct { TitleText string } -// userListView augments a User with pre-rendered labels/badges so the template -// never invokes methods on an interface{}-wrapped struct. +// userListView 为 User 附加预渲染的标签/徽章, +// 使模板绝不调用包装为 interface{} 的结构体上的方法。 type userListView struct { models.User RoleLabel string @@ -40,10 +39,10 @@ type userListView struct { StatusBadge string } -// parseUserForm reads the user form fields from the request. +// parseUserForm 从请求中读取用户表单字段。 func parseUserForm(c *gin.Context) userForm { - // Status is always sent from the (0/1/2/3)发送;缺省视为正常, + // 但显式的 0(禁用)必须保留。 status := models.StatusNormal if raw := strings.TrimSpace(c.PostForm("status")); raw != "" { if n, err := strconv.Atoi(raw); err == nil { @@ -63,7 +62,7 @@ func parseUserForm(c *gin.Context) userForm { } } -// uintFormID parses a route :id into a uint (0 when absent/invalid). +// uintFormID 将路由 :id 解析为 uint(不存在/非法时为 0)。 func uintFormID(s string) uint { if s == "" { return 0 @@ -75,8 +74,8 @@ func uintFormID(s string) uint { return uint(n) } -// applyUserFormToData writes the form values into the template data map so the -// form is repopulated on render (initial load or validation error). +// applyUserFormToData 将表单值写入模板数据映射, +// 使渲染时表单被重新填充(初次加载或校验错误)。 func applyUserFormToData(data gin.H, f userForm) { data["FormID"] = f.ID data["FormUsername"] = f.Username @@ -92,8 +91,7 @@ func applyUserFormToData(data gin.H, f userForm) { data["FormTitleText"] = f.TitleText } -// renderUserForm renders the shared user form template with the given form -// values and optional error message. +// renderUserForm 使用给定的表单值和可选的错误消息渲染共享的用户表单模板。 func renderUserForm(c *gin.Context, f userForm, errMsg string) { tr := getTr(c) data := DefaultData(c) @@ -101,7 +99,7 @@ func renderUserForm(c *gin.Context, f userForm, errMsg string) { if errMsg != "" { data["Error"] = errMsg } - // Role/status options for the 元素的角色/状态选项。 data["RoleAdmin"] = models.RoleAdmin data["RoleAuthor"] = models.RoleAuthor data["StatusNormal"] = models.StatusNormal @@ -115,7 +113,7 @@ func renderUserForm(c *gin.Context, f userForm, errMsg string) { c.HTML(http.StatusOK, "user_form", data) } -// UserListPage renders the admin user management list. +// UserListPage 渲染后台用户管理列表。 func UserListPage(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { tr := getTr(c) @@ -179,7 +177,7 @@ func UserListPage(db *gorm.DB) gin.HandlerFunc { } } -// UserCreatePage renders the empty user creation form. +// UserCreatePage 渲染空白用户创建表单。 func UserCreatePage(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { tr := getTr(c) @@ -193,7 +191,7 @@ func UserCreatePage(db *gorm.DB) gin.HandlerFunc { } } -// UserCreate handles POST to create a new user. +// UserCreate 处理 POST 创建新用户。 func UserCreate(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { tr := getTr(c) @@ -210,12 +208,12 @@ func UserCreate(db *gorm.DB) gin.HandlerFunc { renderUserForm(c, f, tr["user_password_required"]) return } - // SECURITY (#23): enforce the platform minimum password length. + // SECURITY (#23):执行平台最小密码长度。 if !validatePassword(f.Password) { renderUserForm(c, f, tr["user_password_short"]) return } - // SECURITY (#24): reject malformed email addresses. + // SECURITY (#24):拒绝格式非法的邮箱地址。 if !validateEmail(f.Email) { renderUserForm(c, f, tr["user_email_invalid"]) return @@ -224,7 +222,7 @@ func UserCreate(db *gorm.DB) gin.HandlerFunc { f.Role = models.RoleAuthor } - // Username must be unique. + // 用户名必须唯一。 var exists int64 db.Model(&models.User{}).Where("username = ?", f.Username).Count(&exists) if exists > 0 { @@ -255,13 +253,12 @@ func UserCreate(db *gorm.DB) gin.HandlerFunc { } } -// UserEditPage renders the user edit form prefilled with an existing user. +// UserEditPage 渲染预填现有用户的编辑表单。 func UserEditPage(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { tr := getTr(c) - // SECURITY (#19): the route param must be parsed to a numeric id - // before touching GORM — a raw string passed as the single cond to - // First() is interpolated as a SQL WHERE clause. + // SECURITY (#19):路由参数必须先解析为数值 id 再交给 GORM—— + // 原始字符串作为单一条件传给 First() 时会按 SQL WHERE 子句拼接。 id := uintFormID(c.Param("id")) if id == 0 { c.Redirect(http.StatusFound, "/admin/users") @@ -293,13 +290,12 @@ func UserEditPage(db *gorm.DB) gin.HandlerFunc { } } -// UserUpdate handles POST to update an existing user. +// UserUpdate 处理 POST 更新现有用户。 func UserUpdate(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { tr := getTr(c) f := parseUserForm(c) - // SECURITY (#19): reject non-numeric ids before touching GORM (see - // UserEditPage). + // SECURITY (#19):在触碰 GORM 前拒绝非数值 id(参见 UserEditPage)。 if f.ID == 0 { c.Redirect(http.StatusFound, "/admin/users") return @@ -314,12 +310,11 @@ func UserUpdate(db *gorm.DB) gin.HandlerFunc { return } - // Keep the original username (it is the login key + article FK source). + // 保留原用户名(它是登录键 + 文章外键的来源)。 f.Username = user.Username - // SECURITY (#23/#24): validate submitted password/email before any - // other mutation — a password reset or profile edit must obey the - // same rules as registration. + // SECURITY (#23/#24):在进行任何其他修改前校验提交的密码/邮箱—— + // 密码重置或个人资料编辑必须遵守与注册相同的规则。 if f.Password != "" && !validatePassword(f.Password) { renderUserForm(c, f, tr["user_password_short"]) return @@ -332,7 +327,7 @@ func UserUpdate(db *gorm.DB) gin.HandlerFunc { currentID := userIDFromSession(c) isSelf := user.ID == currentID - // Self-protection: cannot disable/lock your own account. + // 自我保护:不能禁用/锁定自己的账户。 if isSelf && f.Status != models.StatusNormal { f.DisplayName = user.DisplayName f.Email = user.Email @@ -346,7 +341,7 @@ func UserUpdate(db *gorm.DB) gin.HandlerFunc { return } - // Self-protection: cannot demote the last remaining admin. + // 自我保护:不能降级最后一位管理员。 if user.Role == models.RoleAdmin && f.Role != models.RoleAdmin { var adminCount int64 db.Model(&models.User{}).Where("role = ?", models.RoleAdmin).Count(&adminCount) @@ -370,7 +365,7 @@ func UserUpdate(db *gorm.DB) gin.HandlerFunc { user.Birthday = nil } - // Optional password reset (leave blank to keep current). + // 可选密码重置(留空表示保持当前密码)。 if f.Password != "" { if err := user.SetPassword(f.Password); err != nil { renderUserForm(c, f, tr["article_error"]) @@ -386,11 +381,10 @@ func UserUpdate(db *gorm.DB) gin.HandlerFunc { } } -// UserDelete soft-deletes a user, with self-protection and last-admin guards. +// UserDelete 软删除用户,带自我保护和最后管理员防线。 func UserDelete(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { - // SECURITY (#19): reject non-numeric ids before touching GORM (see - // UserEditPage). + // SECURITY (#19):在触碰 GORM 前拒绝非数值 id(参见 UserEditPage)。 targetID := uintFormID(c.Param("id")) if targetID == 0 { c.Redirect(http.StatusFound, "/admin/users") @@ -404,12 +398,12 @@ func UserDelete(db *gorm.DB) gin.HandlerFunc { return } - // Cannot delete yourself. + // 不能删除自己。 if targetID == currentID { c.Redirect(http.StatusFound, "/admin/users?error=self_disable") return } - // Cannot delete the last admin. + // 不能删除最后一位管理员。 if user.Role == models.RoleAdmin { var adminCount int64 db.Model(&models.User{}).Where("role = ?", models.RoleAdmin).Count(&adminCount) diff --git a/handlers/article.go b/handlers/article.go index 88fc25d..7f8f3ad 100644 --- a/handlers/article.go +++ b/handlers/article.go @@ -17,34 +17,33 @@ import ( "gorm.io/gorm" ) -// slugSepRe matches runs of non-word characters (anything that is not a -// letter or digit); these become single dashes. +// slugSepRe 匹配连续的非单词字符(任何非字母或数字的字符); +// 这些字符会被替换为单个连字符。 var slugSepRe = regexp.MustCompile(`[^\p{L}\p{N}]+`) -// slugDashRe matches runs of dashes to collapse. +// slugDashRe 匹配连续连字符,用于折叠。 var slugDashRe = regexp.MustCompile(`-{2,}`) -// slugAsciiRe matches a slug made only of URL-safe ASCII letters, digits and -// dashes. Slugs containing other characters (e.g. CJK, or Unicode lowercase -// quirks like the Turkish dotless i) are not URL-stable and are discarded in -// favor of a "post-" fallback. +// slugAsciiRe 匹配仅由 URL 安全的 ASCII 字母、数字和连字符组成的 slug。 +// 包含其他字符的 slug(如 CJK,或类似土耳其无点 i 的 Unicode 小写 +// 癖好)在 URL 中不稳定,会被丢弃,转而使用 "post-" 回退方案。 var slugAsciiRe = regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+)*$`) -// randomToken returns a 32-byte hex token used to own pending attachments on -// the article-create page until the article is saved. +// randomToken 返回 32 字节十六进制令牌,用于在文章保存前于创建页面上 +// 持有待处理的附件归属。 func randomToken() string { b := make([]byte, 32) if _, err := rand.Read(b); err != nil { - // Extremely unlikely; fall back to a time-based value. + // 极罕见;回退到基于时间的值。 return strconv.FormatInt(time.Now().UnixNano(), 16) } return hex.EncodeToString(b) } -// generateSlug converts a title into a URL-friendly ASCII slug. Returns "" when -// the title yields no URL-safe ASCII characters (the caller must fall back, -// e.g. to "post-"). Non-ASCII letters are intentionally dropped rather -// than kept, because raw CJK in a URL slug is not stable. +// generateSlug 将标题转换为 URL 友好的 ASCII slug。 +// 当标题无法产生任何 URL 安全的 ASCII 字符时返回 ""(调用方必须回退, +// 例如使用 "post-")。非 ASCII 字母会被有意丢弃而非保留, +// 因为 URL slug 中的原始 CJK 字符不稳定。 func generateSlug(title string) string { s := strings.ToLower(strings.TrimSpace(title)) s = slugSepRe.ReplaceAllString(s, "-") @@ -56,9 +55,9 @@ func generateSlug(title string) string { return s } -// fallbackSlug returns a non-empty slug when title-derived slugs are empty -// (e.g. a title of only punctuation/whitespace, or all-stripped). Uses the -// article ID when available, else a short token. +// fallbackSlug 在由标题派生的 slug 为空时返回非空 slug +//(例如标题只包含标点/空白,或全部被剔除)。可用时使用文章 ID, +// 否则使用短令牌。 func fallbackSlug(id uint) string { if id > 0 { return fmt.Sprintf("post-%d", id) @@ -66,8 +65,8 @@ func fallbackSlug(id uint) string { return "post-" + randomToken()[:8] } -// articleForm holds the parsed article form fields, shared by the create and -// edit handlers and their validation-error repopulation paths. +// articleForm 保存解析后的文章表单字段,由创建和编辑处理器 +// 及其校验错误回填路径共用。 type articleForm struct { Title string Slug string @@ -76,15 +75,15 @@ type articleForm struct { Cover string StatusStr string IsTop bool - PublishedAt string // datetime-local format: "2006-01-02T15:04" - Tags string // comma-separated tag names - Action string // form action URL - TitleText string // page heading text (create vs edit) - ArticleID uint // existing article ID (edit page); 0 on create - SessionToken string // pending-attachment ownership token (create page) + PublishedAt string // datetime-local 格式:"2006-01-02T15:04" + Tags string // 逗号分隔的标签名 + Action string // 表单提交 URL + TitleText string // 页面标题文字(创建还是编辑) + ArticleID uint // 已有文章 ID(编辑页面);创建时为 0 + SessionToken string // 待处理附件的归属令牌(创建页面) } -// parseArticleForm reads and trims the article form fields from the request. +// parseArticleForm 从请求中读取并去除空白后的文章表单字段。 func parseArticleForm(c *gin.Context) articleForm { return articleForm{ Title: strings.TrimSpace(c.PostForm("title")), @@ -99,8 +98,8 @@ func parseArticleForm(c *gin.Context) articleForm { } } -// applyFormToData writes the form field values into the template data map so -// the form is repopulated on render (initial load or validation error). +// applyFormToData 将表单字段值写入模板数据映射,使渲染时表单被重新填充 +//(初次加载或校验错误)。 func applyFormToData(data gin.H, f articleForm) { data["FormTitle"] = f.Title data["FormSlug"] = f.Slug @@ -117,8 +116,7 @@ func applyFormToData(data gin.H, f articleForm) { data["SessionToken"] = f.SessionToken } -// renderArticleForm renders the shared article form template with the given -// form values and optional error message. +// renderArticleForm 使用给定的表单值和可选的错误消息渲染共享的文章表单模板。 func renderArticleForm(c *gin.Context, db *gorm.DB, f articleForm, errMsg string) { data := DefaultData(c) data["Title"] = f.TitleText @@ -129,8 +127,8 @@ func renderArticleForm(c *gin.Context, db *gorm.DB, f articleForm, errMsg string c.HTML(http.StatusOK, "article_create", data) } -// sessionAuthorID extracts the logged-in user's ID from the session, defending -// against int/uint/int64/float64 storage. Returns ok=false if absent. +// sessionAuthorID 从会话中提取已登录用户的 ID,兼容 int/uint/int64/float64 +// 存储类型。不存在时返回 ok=false。 func sessionAuthorID(c *gin.Context) (uint, bool) { session := sessions.Default(c) userID := session.Get("user_id") @@ -151,8 +149,8 @@ func sessionAuthorID(c *gin.Context) (uint, bool) { } } -// statusFromForm parses the status string ("0" draft, "1" published) and -// returns the model status constant, defaulting to draft. +// statusFromForm 解析状态字符串("0" 草稿、"1" 已发布), +// 返回模型状态常量,默认为草稿。 func statusFromForm(statusStr string) int { if statusStr == "1" { return models.ArticlePublished @@ -160,13 +158,13 @@ func statusFromForm(statusStr string) int { return models.ArticleDraft } -// parsePublishedAt parses the datetime-local format ("2006-01-02T15:04") from -// the form into a time.Time pointer. Returns nil if the string is empty or invalid. +// parsePublishedAt 将表单中的 datetime-local 格式("2006-01-02T15:04")解析为 +// time.Time 指针。字符串为空或非法时返回 nil。 func parsePublishedAt(publishedAtStr string) *time.Time { if publishedAtStr == "" { return nil } - // datetime-local format: "2006-01-02T15:04" + // datetime-local 格式:"2006-01-02T15:04" t, err := time.ParseInLocation("2006-01-02T15:04", publishedAtStr, time.Local) if err != nil { return nil @@ -174,8 +172,8 @@ func parsePublishedAt(publishedAtStr string) *time.Time { return &t } -// formatPublishedAt formats a time.Time pointer into datetime-local format for the form. -// Returns empty string if the pointer is nil. +// formatPublishedAt 将 time.Time 指针格式化为表单所需的 datetime-local 格式。 +// 指针为 nil 时返回空字符串。 func formatPublishedAt(t *time.Time) string { if t == nil { return "" @@ -183,7 +181,7 @@ func formatPublishedAt(t *time.Time) string { return t.Local().Format("2006-01-02T15:04") } -// parseTags splits comma-separated tag string and returns tag names. +// parseTags 按逗号拆分标签字符串并返回标签名。 func parseTags(tagStr string) []string { if tagStr == "" { return []string{} @@ -199,19 +197,19 @@ func parseTags(tagStr string) []string { return tags } -// syncArticleTags associates tags with an article (find or create tags). +// syncArticleTags 将标签与文章关联(查找或创建标签)。 func syncArticleTags(db *gorm.DB, article *models.Article, tagNames []string) error { - // Clear existing tags + // 清除现有标签 if err := db.Model(article).Association("Tags").Clear(); err != nil { return err } - // If no tags, we're done + // 若没有标签,则完成 if len(tagNames) == 0 { return nil } - // Find or create each tag and associate with article + // 查找或创建每个标签并关联到文章 var tags []models.Tag for _, name := range tagNames { tag, err := models.FindOrCreateTag(db, name, name) @@ -223,14 +221,14 @@ func syncArticleTags(db *gorm.DB, article *models.Article, tagNames []string) er } } - // Associate tags with article + // 将标签关联到文章 if len(tags) > 0 { if err := db.Model(article).Association("Tags").Append(tags); err != nil { return err } } - // Update tag counts + // 更新标签计数 for _, tag := range tags { models.UpdateTagCount(db, tag.ID) } @@ -238,7 +236,7 @@ func syncArticleTags(db *gorm.DB, article *models.Article, tagNames []string) er return nil } -// formatArticleTags converts article tags to comma-separated string for form. +// formatArticleTags 将文章标签转换为逗号分隔的字符串以填充表单。 func formatArticleTags(tags []models.Tag) string { if len(tags) == 0 { return "" @@ -250,7 +248,7 @@ func formatArticleTags(tags []models.Tag) string { return strings.Join(names, ", ") } -// ArticleCreatePage renders the article creation form. +// ArticleCreatePage 渲染文章创建表单。 func ArticleCreatePage(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { tr := getTr(c) @@ -263,7 +261,7 @@ func ArticleCreatePage(db *gorm.DB) gin.HandlerFunc { } } -// ArticleCreate handles the POST request to create a new article. +// ArticleCreate 处理创建新文章的 POST 请求。 func ArticleCreate(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { tr := getTr(c) @@ -272,7 +270,7 @@ func ArticleCreate(db *gorm.DB) gin.HandlerFunc { f.TitleText = tr["article_create_title"] f.SessionToken = strings.TrimSpace(c.PostForm("session_token")) - // Validate required fields. + // 校验必填字段。 if f.Title == "" { renderArticleForm(c, db, f, tr["article_title_required"]) return @@ -282,11 +280,11 @@ func ArticleCreate(db *gorm.DB) gin.HandlerFunc { return } - // Auto-generate slug if empty. + // 为空时自动生成 slug。 if f.Slug == "" { f.Slug = generateSlug(f.Title) - // Title had no usable characters (e.g. only punctuation). Use a - // temporary token-based slug now; refine to post- after insert. + // 标题没有可用字符(例如只有标点)。先使用临时的基于令牌的 + // slug;插入后再完善为 post-。 if f.Slug == "" { f.Slug = fallbackSlug(0) } @@ -302,10 +300,10 @@ func ArticleCreate(db *gorm.DB) gin.HandlerFunc { var publishedAt *time.Time if f.PublishedAt != "" { - // User provided a custom published time + // 用户提供了自定义发布时间 publishedAt = parsePublishedAt(f.PublishedAt) } else if status == models.ArticlePublished { - // Auto-stamp with current time if publishing without custom time + // 未提供自定义时间而发布时,自动盖上当前时间 now := time.Now() publishedAt = &now } @@ -327,32 +325,32 @@ func ArticleCreate(db *gorm.DB) gin.HandlerFunc { return } - // Refine a token-based placeholder slug to the readable post- form. + // 将基于令牌的占位 slug 完善为可读的 post- 形式。 if strings.HasPrefix(f.Slug, "post-") && len(f.Slug) > 9 { if newSlug := fallbackSlug(article.ID); newSlug != "" { db.Model(&article).Update("slug", newSlug) } } - // Sync article tags + // 同步文章标签 tagNames := parseTags(f.Tags) if err := syncArticleTags(db, &article, tagNames); err != nil { - // Log error but don't fail the article creation - // The article is already created, tags are optional + // 记录错误但不影响文章创建 + // 文章已经创建,标签是可选内容 } - // Bind any attachments uploaded during creation (plan A: pending rows - // owned by session_token, article_id=0). + // 绑定创建期间上传的任何附件(方案 A:由 session_token 持有、 + // article_id=0 的待处理行)。 if f.SessionToken != "" { _ = BindPendingAttachments(db, f.SessionToken, article.ID) } - // Success: redirect to dashboard. + // 成功:重定向到管理后台。 c.Redirect(http.StatusFound, "/admin") } } -// ArticleListPage renders the admin article management list. +// ArticleListPage 渲染后台文章管理列表。 func ArticleListPage(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { tr := getTr(c) @@ -365,7 +363,7 @@ func ArticleListPage(db *gorm.DB) gin.HandlerFunc { } } -// ArticleEditPage renders the shared form prefilled with an existing article. +// ArticleEditPage 渲染预填现有文章的共享表单。 func ArticleEditPage(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { tr := getTr(c) @@ -377,7 +375,7 @@ func ArticleEditPage(db *gorm.DB) gin.HandlerFunc { return } - // Preload tags for the article + // 预加载文章标签 db.Model(&article).Association("Tags").Find(&article.Tags) renderArticleForm(c, db, articleForm{ @@ -397,7 +395,7 @@ func ArticleEditPage(db *gorm.DB) gin.HandlerFunc { } } -// ArticleUpdate handles the POST request to update an existing article. +// ArticleUpdate 处理更新现有文章的 POST 请求。 func ArticleUpdate(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { tr := getTr(c) @@ -431,13 +429,13 @@ func ArticleUpdate(db *gorm.DB) gin.HandlerFunc { newStatus := statusFromForm(f.StatusStr) - // Handle published_at: use form value if provided, otherwise auto-stamp on first publish. + // 处理 published_at:若提供了表单值则使用,否则在首次发布时自动盖章。 var publishedAt *time.Time if f.PublishedAt != "" { - // User provided a custom published time + // 用户提供了自定义发布时间 publishedAt = parsePublishedAt(f.PublishedAt) } else { - // Auto-stamp the publish time the first time an article is published. + // 文章首次发布时自动盖上发布时间。 wasPublished := article.Status == models.ArticlePublished publishedAt = article.PublishedAt if newStatus == models.ArticlePublished && !wasPublished && publishedAt == nil { @@ -462,18 +460,17 @@ func ArticleUpdate(db *gorm.DB) gin.HandlerFunc { return } - // Sync article tags + // 同步文章标签 tagNames := parseTags(f.Tags) if err := syncArticleTags(db, &article, tagNames); err != nil { - // Log error but don't fail the article update + // 记录错误但不影响文章更新 } c.Redirect(http.StatusFound, "/admin/articles") } } -// ArticleDelete soft-deletes an article (GORM fills DeletedAt) and redirects -// back to the management list. +// ArticleDelete 软删除文章(GORM 填充 DeletedAt)并重定向回管理列表。 func ArticleDelete(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { id := c.Param("id") diff --git a/handlers/attachment.go b/handlers/attachment.go index c16280c..9947138 100644 --- a/handlers/attachment.go +++ b/handlers/attachment.go @@ -16,8 +16,7 @@ import ( "go_blog/models" ) -// attachmentsDir returns the on-disk directory for attachments under the given -// storage path, honoring the configured storage_dir. +// attachmentsDir 返回给定存储路径下附件的磁盘目录,遵循配置的 storage_dir。 func attachmentsDir(storagePath string) string { dir := models.GetUploadConfig().StorageDir if dir == "" { @@ -26,8 +25,8 @@ func attachmentsDir(storagePath string) string { return filepath.Join(storagePath, dir) } -// attachmentRelPath returns the path of an attachment relative to the storage -// root, e.g. "attachments/" — used to build /uploads URLs. +// attachmentRelPath 返回附件相对于存储根目录的路径, +// 例如 "attachments/"——用于构建 /uploads URL。 func attachmentRelPath(stored string) string { dir := models.GetUploadConfig().StorageDir if dir == "" { @@ -36,9 +35,8 @@ func attachmentRelPath(stored string) string { return dir + "/" + stored } -// attachmentURL builds the public URL for an attachment: the default download -// base URL (if configured) joined with the relative path, else the local -// /uploads path served by the app. +// attachmentURL 构建附件的公开 URL:默认下载基础 URL(若已配置) +// 与相对路径拼接,否则使用应用提供的本地 /uploads 路径。 func attachmentURL(stored string) string { rel := attachmentRelPath(stored) if base := models.DefaultDownloadBaseURL(); base != "" { @@ -47,19 +45,18 @@ func attachmentURL(stored string) string { return "/uploads/" + rel } -// ---------------- Upload ---------------- +// ---------------- 上传 ---------------- -// currentUserIsAdmin reports whether the authenticated user has the admin -// role, based on the context populated by the SetUserContext middleware. +// currentUserIsAdmin 基于 SetUserContext 中间件填充的上下文, +// 报告已认证用户是否具有管理员角色。 func currentUserIsAdmin(c *gin.Context) bool { role, _ := c.Get("role") r, _ := role.(string) return r == models.RoleAdmin } -// canManageArticle reports whether the current user may attach files to (or -// manage attachments of) the given article: admins always, the article -// author otherwise. +// canManageArticle 报告当前用户是否为给定文章附加文件(或管理其附件)的 +// 合法用户:管理员始终允许,否则仅允许文章作者。 func canManageArticle(c *gin.Context, db *gorm.DB, articleID uint) bool { if currentUserIsAdmin(c) { return true @@ -75,10 +72,9 @@ func canManageArticle(c *gin.Context, db *gorm.DB, articleID uint) bool { return article.AuthorID == uid } -// UploadAttachment handles AJAX attachment uploads from the article create/edit -// form. The request carries either a real article_id (edit page) or a -// session_token (create page, pending binding). Files are content-addressed by -// SHA-256 for on-disk deduplication. +// UploadAttachment 处理来自文章创建/编辑表单的 AJAX 附件上传。 +// 请求携带真实的 article_id(编辑页)或 session_token(创建页,待绑定)。 +// 文件按 SHA-256 内容寻址,实现磁盘去重。 func UploadAttachment(db *gorm.DB, storagePath string) gin.HandlerFunc { return func(c *gin.Context) { uploaderID, ok := sessionAuthorID(c) @@ -89,13 +85,13 @@ func UploadAttachment(db *gorm.DB, storagePath string) gin.HandlerFunc { articleID := parseUintForm(c, "article_id") token := strings.TrimSpace(c.PostForm("session_token")) - // On the create page the article does not exist yet; require a token. + // 创建页面上文章尚不存在;要求提供令牌。 if articleID == 0 && token == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "missing session_token"}) return } - // Ownership check: a non-admin may only attach to their own articles. + // 所有权检查:非管理员只能附加到自己的文章。 if articleID != 0 && !canManageArticle(c, db, articleID) { c.JSON(http.StatusForbidden, gin.H{"error": "forbidden"}) return @@ -108,7 +104,7 @@ func UploadAttachment(db *gorm.DB, storagePath string) gin.HandlerFunc { } defer file.Close() - // Validate against the platform upload policy (switch + type + size). + // 依据平台上传策略校验(总开关 + 类型 + 大小)。 check := ValidateUpload(header) if !check.OK { if !models.GetUploadConfig().Enabled { @@ -123,17 +119,15 @@ func UploadAttachment(db *gorm.DB, storagePath string) gin.HandlerFunc { return } - // Read fully: needed for the content hash (dedup) and for magic-byte - // content validation (#14). + // 完整读取:用于内容哈希(去重)和魔数内容校验(#14)。 content, err := io.ReadAll(file) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read file"}) return } - // SECURITY_TODO #14: the bytes must match the MIME type configured - // for the claimed extension (magic-byte check against the header - // policy). A .txt named file carrying PNG bytes is rejected. + // SECURITY_TODO #14:文件字节必须与声明扩展名配置的 MIME 类型匹配 + //(按头部策略进行魔数校验)。名为 .txt 却携带 PNG 字节的文件将被拒绝。 if !contentMatchesType(check.Type, content) { c.JSON(http.StatusBadRequest, gin.H{"error": "file content does not match its declared type"}) return @@ -142,7 +136,7 @@ func UploadAttachment(db *gorm.DB, storagePath string) gin.HandlerFunc { sum := sha256.Sum256(content) stored := hex.EncodeToString(sum[:]) - // Deduplicate on disk: only write when the file is absent. + // 磁盘去重:仅在文件不存在时才写入。 dir := attachmentsDir(storagePath) if err := os.MkdirAll(dir, 0755); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create storage dir"}) @@ -184,12 +178,11 @@ func UploadAttachment(db *gorm.DB, storagePath string) gin.HandlerFunc { } } -// ---------------- Delete ---------------- +// ---------------- 删除 ---------------- -// DeleteAttachment soft-deletes an attachment record and removes the on-disk -// file only when no remaining records reference it (reference counting, since -// content-addressed files may be shared). Only admins, the uploader, or the -// author of the article the file is attached to may delete it. +// DeleteAttachment 软删除附件记录,仅当没有其余记录引用时才删除磁盘文件 +// (引用计数,因为内容寻址的文件可能被共享)。只有管理员、上传者或 +// 文件所在文章的作者可以删除。 func DeleteAttachment(db *gorm.DB, storagePath string) gin.HandlerFunc { return func(c *gin.Context) { id := parseUintParam(c, "id") @@ -199,7 +192,7 @@ func DeleteAttachment(db *gorm.DB, storagePath string) gin.HandlerFunc { return } - // Ownership check: admin, uploader, or the owning article's author. + // 所有权检查:管理员、上传者或所属文章的作者。 if !currentUserIsAdmin(c) { uid, ok := sessionAuthorID(c) owned := ok && att.UploaderID == uid @@ -218,21 +211,20 @@ func DeleteAttachment(db *gorm.DB, storagePath string) gin.HandlerFunc { return } - // Reference count: any other (non-deleted) rows pointing at this file? + // 引用计数:还有任何其他(未删除)行指向此文件吗? var count int64 db.Model(&models.Attachment{}).Where("stored_name = ?", stored).Count(&count) if count == 0 { - os.Remove(filepath.Join(attachmentsDir(storagePath), stored)) // ignore error + os.Remove(filepath.Join(attachmentsDir(storagePath), stored)) // 忽略错误 } c.JSON(http.StatusOK, gin.H{"ok": true}) } } -// ---------------- List ---------------- +// ---------------- 列表 ---------------- -// ListAttachments returns the attachments for an article as JSON (used by the -// edit page to repopulate the list on load). Only the article's author (or an -// admin) may list them. +// ListAttachments 以 JSON 返回文章的附件(供编辑页加载时重新填充列表)。 +// 只有文章作者(或管理员)可以列出。 func ListAttachments(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { articleID := parseUintParam(c, "id") @@ -262,11 +254,11 @@ func ListAttachments(db *gorm.DB) gin.HandlerFunc { } } -// ---------------- Binding (plan A) ---------------- +// ---------------- 绑定(方案 A)---------------- -// BindPendingAttachments attaches attachments uploaded during article creation -// (owned by session_token, article_id=0) to a newly created article. Called by -// ArticleCreate after the article row is saved. +// BindPendingAttachments 将文章创建期间上传的附件 +// (由 session_token 持有、article_id=0)绑定到新创建的文章。 +// 由 ArticleCreate 在保存文章行之后调用。 func BindPendingAttachments(db *gorm.DB, token string, articleID uint) error { if token == "" { return nil @@ -276,9 +268,9 @@ func BindPendingAttachments(db *gorm.DB, token string, articleID uint) error { Updates(map[string]interface{}{"article_id": articleID, "session_token": ""}).Error } -// ---------------- helpers ---------------- +// ---------------- 辅助函数 ---------------- -// parseUintForm parses a uint form field, tolerating empty/invalid input. +// parseUintForm 解析 uint 表单字段,容忍空/非法输入。 func parseUintForm(c *gin.Context, field string) uint { v := strings.TrimSpace(c.PostForm(field)) if v == "" { @@ -289,7 +281,7 @@ func parseUintForm(c *gin.Context, field string) uint { return n } -// parseUintParam parses a uint route param. +// parseUintParam 解析 uint 路由参数。 func parseUintParam(c *gin.Context, name string) uint { var n uint _, _ = fmt.Sscanf(c.Param(name), "%d", &n) diff --git a/handlers/auth.go b/handlers/auth.go index e02ab11..af6b5cd 100644 --- a/handlers/auth.go +++ b/handlers/auth.go @@ -12,7 +12,7 @@ import ( "go_blog/models" ) -// LoginPage renders the login form. +// LoginPage 渲染登录表单。 func LoginPage() gin.HandlerFunc { return func(c *gin.Context) { tr := getTr(c) @@ -24,7 +24,7 @@ func LoginPage() gin.HandlerFunc { if c.Query("error") == "locked" { data["Error"] = tr["login_locked"] } - // Check if registration is allowed from site settings + // 根据站点设置检查是否允许注册 siteSetting, _ := c.Get("site_setting") if s, ok := siteSetting.(*models.SiteSetting); ok && s != nil { data["AllowRegistration"] = s.AllowRegistration @@ -33,10 +33,9 @@ func LoginPage() gin.HandlerFunc { } } -// Login processes the login form submission. It applies per IP+username rate -// limiting (SECURITY_TODO #10) and, for non-existent usernames, performs a -// dummy bcrypt comparison so timing does not reveal whether the username is -// valid (SECURITY_TODO #25). +// Login 处理登录表单提交。它对每个 IP+用户名实施速率限制 +// (SECURITY_TODO #10),对于不存在的用户名会执行一次虚拟 bcrypt 比较, +// 使耗时不会暴露用户名是否存在(SECURITY_TODO #25)。 func Login(db *gorm.DB, limiter *loginRateLimiter) gin.HandlerFunc { return func(c *gin.Context) { username := c.PostForm("username") @@ -50,9 +49,8 @@ func Login(db *gorm.DB, limiter *loginRateLimiter) gin.HandlerFunc { var user models.User if err := db.Where("username = ?", username).First(&user).Error; err != nil { - // Constant-time: burn the same amount of work a real password - // check would (bcrypt compare) before failing, so timing does - // not reveal whether the username exists. + // 常量时间:失败前执行与真实密码校验等量的工作(bcrypt 比较), + // 使耗时不会暴露用户名是否存在。 limiter.Fail(key) _ = bcrypt.CompareHashAndPassword(dummyHash, []byte(password)) c.Redirect(http.StatusFound, "/login?error=1") @@ -65,19 +63,18 @@ func Login(db *gorm.DB, limiter *loginRateLimiter) gin.HandlerFunc { return } - // Refuse login for non-normal accounts (disabled / locked / unactivated). + // 拒绝非正常状态账户登录(已禁用 / 已锁定 / 未激活)。 if user.Status != models.StatusNormal { c.Redirect(http.StatusFound, "/login?error=1") return } - // Success: reset the failure counter for this key. + // 成功:重置此键的失败计数。 limiter.Reset(key) - // Rotate the session on privilege change to prevent session - // fixation: drop all pre-authentication state, keep only the - // harmless UI preferences (language and CSRF token so forms - // already rendered in other tabs stay valid). + // 权限变更时轮换会话,防止会话固定攻击: + // 丢弃全部登录前状态,仅保留无害的 UI 偏好(语言和 CSRF 令牌, + // 使其他标签页中已渲染的表单仍然有效)。 session := sessions.Default(c) lang, _ := session.Get("lang").(string) csrfTok, _ := session.Get("csrf_token").(string) @@ -95,7 +92,7 @@ func Login(db *gorm.DB, limiter *loginRateLimiter) gin.HandlerFunc { return } - // Redirect based on user role: admins to /admin, others to home + // 根据用户角色重定向:管理员到 /admin,其他用户到首页 if user.Role == models.RoleAdmin { c.Redirect(http.StatusFound, "/admin") } else { @@ -104,7 +101,7 @@ func Login(db *gorm.DB, limiter *loginRateLimiter) gin.HandlerFunc { } } -// Logout clears the session and redirects home. +// Logout 清除会话并重定向回首页。 func Logout() gin.HandlerFunc { return func(c *gin.Context) { session := sessions.Default(c) @@ -114,10 +111,10 @@ func Logout() gin.HandlerFunc { } } -// RegisterPage renders the registration form (only when registration is enabled). +// RegisterPage 渲染注册表单(仅在启用注册时可用)。 func RegisterPage(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { - // Check if registration is allowed + // 检查是否允许注册 var s models.SiteSetting if err := db.First(&s, 1).Error; err != nil || !s.AllowRegistration { c.Redirect(http.StatusFound, "/login") @@ -136,10 +133,10 @@ func RegisterPage(db *gorm.DB) gin.HandlerFunc { } } -// Register processes the registration form submission. +// Register 处理注册表单提交。 func Register(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { - // Check if registration is allowed + // 检查是否允许注册 var s models.SiteSetting if err := db.First(&s, 1).Error; err != nil || !s.AllowRegistration { c.Redirect(http.StatusFound, "/login") @@ -152,7 +149,7 @@ func Register(db *gorm.DB) gin.HandlerFunc { email := strings.TrimSpace(c.PostForm("email")) displayName := strings.TrimSpace(c.PostForm("display_name")) - // Validate inputs + // 校验输入 if username == "" || password == "" { c.Redirect(http.StatusFound, "/register?error=register_required") return @@ -173,20 +170,20 @@ func Register(db *gorm.DB) gin.HandlerFunc { return } - // SECURITY (#24): reject malformed email addresses (optional field). + // SECURITY (#24):拒绝格式非法的邮箱地址(可选字段)。 if !validateEmail(email) { c.Redirect(http.StatusFound, "/register?error=register_email_invalid") return } - // Check if username already exists + // 检查用户名是否已存在 var existingUser models.User if err := db.Where("username = ?", username).First(&existingUser).Error; err == nil { c.Redirect(http.StatusFound, "/register?error=user_username_exists") return } - // Create new user + // 创建新用户 user := models.User{ Username: username, Email: email, @@ -209,8 +206,7 @@ func Register(db *gorm.DB) gin.HandlerFunc { return } - // Auto-login after successful registration (with session - // rotation, mirroring the login handler). + // 注册成功后自动登录(与登录处理器一致的会话轮换)。 session := sessions.Default(c) lang, _ := session.Get("lang").(string) csrfTok, _ := session.Get("csrf_token").(string) @@ -228,7 +224,7 @@ func Register(db *gorm.DB) gin.HandlerFunc { return } - // Redirect to home page + // 重定向到首页 c.Redirect(http.StatusFound, "/") } } diff --git a/handlers/comment.go b/handlers/comment.go index b2322bf..133e588 100644 --- a/handlers/comment.go +++ b/handlers/comment.go @@ -20,26 +20,24 @@ import ( "go_blog/models" ) -// MaxCommentLength bounds the size of a single comment body, in characters. +// MaxCommentLength 限制单条评论正文的长度(字符数)。 const MaxCommentLength = 4000 -// guestCookieName is the long-lived cookie used to identify anonymous -// commenters so they can see their own pending/private comments. +// guestCookieName 是用于标识匿名评论者的长期 Cookie, +// 使其能看到自己的待审/私密评论。 const guestCookieName = "comment_uid" -const guestCookieMaxAge = 365 * 24 * 3600 // one year +const guestCookieMaxAge = 365 * 24 * 3600 // 一年 -// htmlTagPattern matches any HTML/XML tag so it can be stripped from comment -// markdown before storage. Markdown syntax itself contains no angle brackets -// in a form that would collide (the only such construct is autolinks like -// , which are rare in comments and acceptable to lose). +// htmlTagPattern 匹配所有 HTML/XML 标签,以便在存储前从评论 Markdown 中剥除。 +// Markdown 语法本身不含会冲突的尖括号形式(唯一类似结构是 +// 这样的自动链接,在评论中很少见,可以接受丢失)。 var htmlTagPattern = regexp.MustCompile(`(?is)<[^>]+>`) -// dangerousSchemePattern matches dangerous URL schemes inside markdown link -// targets that could execute script when rendered to innerHTML. +// dangerousSchemePattern 匹配 Markdown 链接目标中的危险 URL 协议, +// 这些协议在渲染为 innerHTML 时可能执行脚本。 var dangerousSchemePattern = regexp.MustCompile(`(?i)\b(javascript|vbscript|data:text/html)\s*:`) -// commentForm holds the parsed values of a submitted comment form so the -// template can refill the inputs after a validation failure. +// commentForm 保存已提交评论表单的解析值,使校验失败后模板能重新填充输入。 type commentForm struct { Name string Email string @@ -49,51 +47,48 @@ type commentForm struct { ParentID string } -// sanitizeMarkdown strips HTML tags and dangerous URL schemes from a comment -// body before storage. Markdown syntax is preserved so the frontend can render -// it. This is the first of two XSS defenses; the frontend also runs the output -// through marked + DOMPurify. +// sanitizeMarkdown 在存储前从评论正文中剥除 HTML 标签与危险 URL 协议。 +// Markdown 语法被保留,以便前端渲染。这是两道 XSS 防御中的第一道; +// 前端还会将输出经过 marked + DOMPurify 处理。 func sanitizeMarkdown(s string) string { s = htmlTagPattern.ReplaceAllString(s, "") s = dangerousSchemePattern.ReplaceAllString(s, "#") - // Collapse runs of more than two newlines. + // 折叠超过两个连续换行符的序列。 s = regexp.MustCompile(`\n{3,}`).ReplaceAllString(s, "\n\n") return strings.TrimSpace(s) } -// newGuestToken generates a random hex token for an anonymous commenter. +// newGuestToken 为匿名评论者生成随机十六进制令牌。 func newGuestToken() string { b := make([]byte, 16) if _, err := rand.Read(b); err != nil { - // Extremely unlikely; fall back to a timestamp-based token. + // 极罕见;回退到基于时间戳的令牌。 return fmt.Sprintf("%x", time.Now().UnixNano()) } return hex.EncodeToString(b) } -// guestTokenFrom reads the anonymous commenter cookie, creating and setting a -// new one when absent. The token is returned for storage on the comment. +// guestTokenFrom 读取匿名评论者 Cookie,缺失时创建并设置新值。 +// 令牌返回给调用方以存储到评论上。 func guestTokenFrom(c *gin.Context) string { token, _ := c.Cookie(guestCookieName) if token == "" { token = newGuestToken() } - // (Re)set the cookie so returning visitors keep their identity. HttpOnly - // prevents JS access; SameSite=Lax plus Secure-over-HTTPS mirror the - // session cookie hardening. + // (重新)设置 Cookie,使回访访客保持身份一致。HttpOnly 阻止 JS 访问; + // SameSite=Lax 加上 HTTPS 下的 Secure 与会话 Cookie 加固措施一致。 c.SetSameSite(http.SameSiteLaxMode) c.SetCookie(guestCookieName, token, guestCookieMaxAge, "/", "", middleware.IsHTTPSRequest(c), true) return token } -// emailHash returns the md5 of a lowercased, trimmed email, per the Gravatar -// spec. +// emailHash 按照 Gravatar 规范返回小写并去除空白后的邮箱的 md5 值。 func emailHash(email string) string { h := md5.Sum([]byte(strings.ToLower(strings.TrimSpace(email)))) return hex.EncodeToString(h[:]) } -// PostComment handles submission of a new comment (or reply) on an article. +// PostComment 处理在文章上提交新评论(或回复)。 func PostComment(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { tr := getTr(c) @@ -129,7 +124,7 @@ func PostComment(db *gorm.DB) gin.HandlerFunc { ParentID: strings.TrimSpace(c.PostForm("parent_id")), } - // --- Validation --- + // --- 校验 --- if form.Name == "" || len(form.Name) > 64 { renderArticleDetail(c, db, &article, form, tr["comments_required_name"], "") return @@ -158,7 +153,7 @@ func PostComment(db *gorm.DB) gin.HandlerFunc { return } - // --- Parent validation --- + // --- 父评论校验 --- var parentID *uint if form.ParentID != "" { pid, err := strconv.ParseUint(form.ParentID, 10, 64) @@ -175,7 +170,7 @@ func PostComment(db *gorm.DB) gin.HandlerFunc { parentID = &id } - // --- Build comment --- + // --- 构建评论 --- comment := models.Comment{ ArticleID: article.ID, ParentID: parentID, @@ -218,7 +213,7 @@ func PostComment(db *gorm.DB) gin.HandlerFunc { } } -// truncate clips s to at most n runes. +// truncate 将 s 裁剪为最多 n 个 rune。 func truncate(s string, n int) string { if n <= 0 { return "" @@ -230,15 +225,14 @@ func truncate(s string, n int) string { return string(r[:n]) } -// commentViewer describes the identity of the current request for the purposes -// of comment visibility. +// commentViewer 描述当前请求的身份,用于决定评论可见性。 type commentViewer struct { userID *uint isAdmin bool guestToken string } -// viewerFromContext builds a commentViewer from the request/session. +// viewerFromContext 基于请求/会话构建 commentViewer。 func viewerFromContext(c *gin.Context) commentViewer { v := commentViewer{} if uid := userIDFromSession(c); uid != 0 { @@ -253,14 +247,14 @@ func viewerFromContext(c *gin.Context) commentViewer { return v } -// canSee reports whether the viewer is allowed to see one comment. +// canSee 报告查看者是否被允许查看某条评论。 func (v commentViewer) canSee(c *models.Comment) bool { switch c.Status { case models.CommentApproved: if !c.IsPrivate { return true } - // Private: admin or author only. + // 私密:仅管理员或作者可见。 return v.isAdmin || v.owns(c) case models.CommentPending: return v.isAdmin || v.owns(c) @@ -270,7 +264,7 @@ func (v commentViewer) canSee(c *models.Comment) bool { return false } -// owns reports whether the viewer is the author of the comment. +// owns 报告查看者是否为该评论的作者。 func (v commentViewer) owns(c *models.Comment) bool { if v.userID != nil && c.UserID != nil && *v.userID == *c.UserID { return true @@ -281,11 +275,9 @@ func (v commentViewer) owns(c *models.Comment) bool { return false } -// CommentNode is a comment plus its rendered children, used by the template's -// recursive comment_node block. Tr/UseGravatar/AvatarColor are propagated to -// every node so the recursive template can render badges and avatars without -// reaching back to the page-level data (inside a {{template}} invocation, $ -// binds to the node, not the page data). +// CommentNode 是一条评论及其已渲染的子评论,供模板的递归 comment_node 块使用。 +// Tr/UseGravatar/AvatarColor 会传播到每个节点,使递归模板能够渲染徽章和头像 +// 而无需回溯页面级数据(在 {{template}} 调用内部,$ 绑定到节点而非页面数据)。 type CommentNode struct { Comment models.Comment Children []CommentNode @@ -296,14 +288,13 @@ type CommentNode struct { AvatarColor string } -// avatarPalette is the set of background colors used for text-initial avatars -// when Gravatar is disabled. +// avatarPalette 是禁用 Gravatar 时用于文本首字母头像的背景色集合。 var avatarPalette = []string{ "#3b82f6", "#ef4444", "#10b981", "#f59e0b", "#8b5cf6", "#ec4899", "#14b8a6", "#6366f1", } -// avatarColorFor returns a deterministic palette color for a comment ID. +// avatarColorFor 为评论 ID 返回确定性的调色板颜色。 func avatarColorFor(id uint) string { if len(avatarPalette) == 0 { return "#3b82f6" @@ -311,9 +302,8 @@ func avatarColorFor(id uint) string { return avatarPalette[int(id)%len(avatarPalette)] } -// buildCommentTree filters comments by visibility and assembles them into a -// nested tree ordered by creation time. tr and useGravatar are propagated to -// every node for template rendering. +// buildCommentTree 按可见性过滤评论,并按创建时间组装为嵌套树。 +// tr 与 useGravatar 会传播到每个节点以供模板渲染。 func buildCommentTree(comments []models.Comment, viewer commentViewer, tr map[string]string, useGravatar bool) []CommentNode { visible := make([]models.Comment, 0, len(comments)) for i := range comments { @@ -352,8 +342,7 @@ func buildCommentNode(c models.Comment, byParent map[uint][]models.Comment, dept return node } -// relativeTime returns a coarse human-readable age for a comment timestamp, -// falling back to an absolute date for anything older than a day. +// relativeTime 为评论时间戳返回粗略的可读年龄,超过一天后回退到绝对日期。 func relativeTime(t time.Time) string { d := time.Since(t) switch { diff --git a/handlers/helpers.go b/handlers/helpers.go index 21671e7..1cc80f6 100644 --- a/handlers/helpers.go +++ b/handlers/helpers.go @@ -7,19 +7,16 @@ import ( "github.com/gin-gonic/gin" ) -// minPasswordLength is the minimum accepted password length, shared by -// registration, profile password change and admin user management -// (SECURITY_TODO #23). It mirrors the registration policy. +// minPasswordLength 是接受的最小密码长度,由注册、个人资料密码修改和 +// 管理员用户管理共用(SECURITY_TODO #23)。与注册策略保持一致。 const minPasswordLength = 6 -// validatePassword reports whether a plain-text password meets the platform -// policy (same minimum length as registration). +// validatePassword 报告明文密码是否符合平台策略(与注册相同的最小长度)。 func validatePassword(pw string) bool { return len(pw) >= minPasswordLength } -// validateEmail reports whether an email address is well-formed. An empty -// value is always valid (the field is optional in most forms). +// validateEmail 报告邮箱地址是否格式正确。空值始终合法(该字段在多数表单中为可选项)。 func validateEmail(email string) bool { email = strings.TrimSpace(email) if email == "" { @@ -29,9 +26,8 @@ func validateEmail(email string) bool { return err == nil } -// DefaultData builds a base gin.H map populated with values set by the -// SetUserContext middleware (translations, language, auth state). Handlers -// add page-specific fields on top and pass it to c.HTML(). +// DefaultData 构建基础 gin.H 映射,填充由 SetUserContext 中间件设置的值 +// (翻译、语言、认证状态)。处理器在其上添加页面特定字段并传给 c.HTML()。 func DefaultData(c *gin.Context) gin.H { tr, _ := c.Get("tr") isLoggedIn, _ := c.Get("is_logged_in") @@ -78,8 +74,8 @@ func DefaultData(c *gin.Context) gin.H { } } -// getTr is a convenience helper that returns the translation map from the -// Gin context, falling back to an empty map if not set. +// getTr 是一个便捷辅助函数,从 Gin 上下文返回翻译映射, +// 未设置时回退到空映射。 func getTr(c *gin.Context) map[string]string { tr, ok := c.Get("tr") if !ok { @@ -92,10 +88,9 @@ func getTr(c *gin.Context) map[string]string { return m } -// GetClientIP returns the real client IP. It relies on gin's proxy-aware -// ClientIP(), which honors the trusted_proxies config: only IPs in that list -// are allowed to influence X-Forwarded-For, so direct clients cannot spoof -// the value. +// GetClientIP 返回真实客户端 IP。它依赖 gin 的代理感知 ClientIP(), +// 遵循 trusted_proxies 配置:只有该列表中的 IP 才能影响 +// X-Forwarded-For,因此直接客户端无法伪造该值。 func GetClientIP(c *gin.Context) string { return c.ClientIP() } diff --git a/handlers/home.go b/handlers/home.go index e61e5f3..601acc1 100644 --- a/handlers/home.go +++ b/handlers/home.go @@ -13,30 +13,30 @@ import ( "gorm.io/gorm" ) -// publishedArticleOrder is the ordering used to list published articles: -// pinned first, then by most recent publish/creation time. +// publishedArticleOrder 是列出已发布文章时使用的排序: +// 置顶优先,其次按发布/创建时间从新到旧。 const publishedArticleOrder = "articles.is_top DESC, articles.published_at DESC, articles.created_at DESC" -// HomePage renders the public home page with the latest published articles. +// HomePage 渲染公开首页,展示最新发布的文章。 func HomePage(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { tr := getTr(c) data := DefaultData(c) data["Title"] = tr["page_home"] - // Get tag filter if present + // 获取标签筛选(如存在) tagSlug := c.Query("tag") - // Build query + // 构建查询 query := db.Where("status = ?", models.ArticlePublished) if tagSlug != "" { - // Join with article_tags to filter by tag + // 联表 article_tags 以按标签筛选 query = query.Joins("JOIN article_tags ON article_tags.article_id = articles.id"). Joins("JOIN tags ON tags.id = article_tags.tag_id"). Where("tags.slug = ?", tagSlug) - // Get tag info for display + // 获取标签信息用于展示 var tag models.Tag if err := db.Where("slug = ?", tagSlug).First(&tag).Error; err == nil { data["FilterTag"] = tag @@ -49,11 +49,11 @@ func HomePage(db *gorm.DB) gin.HandlerFunc { Limit(10). Find(&articles) - // Load all tags for sidebar + // 加载所有标签用于侧边栏 var tags []models.Tag db.Where("count > 0").Order("count DESC, name_zh ASC").Find(&tags) - // Get comment counts for all articles + // 获取所有文章的评论数量 articleIDs := make([]uint, len(articles)) for i, article := range articles { articleIDs[i] = article.ID @@ -73,13 +73,13 @@ func HomePage(db *gorm.DB) gin.HandlerFunc { Scan(&commentCounts) } - // Create a map for quick lookup + // 创建用于快速查找的映射 commentCountMap := make(map[uint]int64) for _, cc := range commentCounts { commentCountMap[cc.ArticleID] = cc.Count } - // Add data to template + // 向模板添加数据 data["Articles"] = articles data["Tags"] = tags data["CommentCounts"] = commentCountMap @@ -88,7 +88,7 @@ func HomePage(db *gorm.DB) gin.HandlerFunc { } } -// HomeArticlesAPI returns articles in JSON format for infinite scroll. +// HomeArticlesAPI 以 JSON 格式返回文章,用于无限滚动加载。 func HomeArticlesAPI(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { page := 1 @@ -102,15 +102,15 @@ func HomeArticlesAPI(db *gorm.DB) gin.HandlerFunc { pageSize := 10 offset := (page - 1) * pageSize - // Get tag filter if present + // 获取标签筛选(如存在) tagSlug := c.Query("tag") - // Build query + // 构建查询 query := db.Where("status = ?", models.ArticlePublished) countQuery := db.Model(&models.Article{}).Where("status = ?", models.ArticlePublished) if tagSlug != "" { - // Join with article_tags to filter by tag + // 联表 article_tags 以按标签筛选 query = query.Joins("JOIN article_tags ON article_tags.article_id = articles.id"). Joins("JOIN tags ON tags.id = article_tags.tag_id"). Where("tags.slug = ?", tagSlug) @@ -130,7 +130,7 @@ func HomeArticlesAPI(db *gorm.DB) gin.HandlerFunc { var total int64 countQuery.Count(&total) - // Get comment counts for these articles + // 获取这些文章的评论数量 articleIDs := make([]uint, len(articles)) for i, article := range articles { articleIDs[i] = article.ID @@ -150,13 +150,13 @@ func HomeArticlesAPI(db *gorm.DB) gin.HandlerFunc { Scan(&commentCounts) } - // Create a map for quick lookup + // 创建用于快速查找的映射 commentCountMap := make(map[uint]int64) for _, cc := range commentCounts { commentCountMap[cc.ArticleID] = cc.Count } - // Build response with comment counts + // 构建带评论数量的响应 type ArticleResponse struct { models.Article CommentCount int64 `json:"comment_count"` @@ -177,7 +177,7 @@ func HomeArticlesAPI(db *gorm.DB) gin.HandlerFunc { } } -// ArticleDetail renders a single published article by its slug. +// ArticleDetail 按 slug 渲染单篇已发布文章。 func ArticleDetail(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { tr := getTr(c) @@ -194,14 +194,13 @@ func ArticleDetail(db *gorm.DB) gin.HandlerFunc { return } - // Increment view count; ignore errors so a view never breaks the page. + // 增加浏览量;忽略错误,确保一次浏览不会破坏页面。 db.Model(&models.Article{}).Where("id = ?", article.ID). UpdateColumn("view_count", gorm.Expr("view_count + 1")) - // Record unique article view asynchronously (doesn't block page - // response). All request-derived values are extracted synchronously: - // the gin context is pool-reused and must never be touched from - // another goroutine after the handler returns. + // 异步记录唯一文章浏览(不阻塞页面响应)。所有从请求派生的值 + // 都在当前 goroutine 中同步提取:gin 上下文会复用, + // 处理器返回后严禁在其他 goroutine 中访问。 uid := userIDFromSession(c) var userID *uint if uid != 0 { @@ -211,16 +210,16 @@ func ArticleDetail(db *gorm.DB) gin.HandlerFunc { ua := c.Request.UserAgent() go recordArticleView(db, article.ID, userID, ip, ua) - // One-time flash notice (set by PostComment on success/pending). Reading - // consumes the flash, so refreshing the page no longer re-shows it. + // 一次性 flash 通知(由 PostComment 在成功/待审时设置)。读取即消耗 + // flash,因此刷新页面不会再显示。 notice := readCommentFlash(c) renderArticleDetail(c, db, &article, commentForm{}, "", notice) } } -// renderArticleDetail renders the article page, including the comment section. -// formErr refills the form with an error banner; notice is a one-time -// success/pending banner (already consumed from the session by the caller). +// renderArticleDetail 渲染文章页面,包括评论区域。 +// formErr 带错误横幅重新填充表单;notice 是一次性成功/待审横幅 +// (调用方已从会话中读取消耗)。 func renderArticleDetail(c *gin.Context, db *gorm.DB, article *models.Article, form commentForm, formErr, notice string) { tr := getTr(c) @@ -252,8 +251,8 @@ func renderArticleDetail(c *gin.Context, db *gorm.DB, article *models.Article, f c.HTML(http.StatusOK, "article", data) } -// formatPublishTime returns the publication time as a readable string, falling -// back to the creation time when the publish timestamp is unset. +// formatPublishTime 返回可读的发布时间字符串,当发布时间为空时 +// 回退到创建时间。 func formatPublishTime(publishedAt *time.Time) string { if publishedAt != nil { return publishedAt.Format("2006-01-02 15:04") @@ -261,24 +260,23 @@ func formatPublishTime(publishedAt *time.Time) string { return "" } -// formatUpdateTime returns the last update time as a readable string. +// formatUpdateTime 返回可读的最后更新时间字符串。 func formatUpdateTime(updatedAt time.Time) string { return updatedAt.Format("2006-01-02 15:04") } -// commentFlashKey is the session key for the one-time comment notice. +// commentFlashKey 是一次性评论通知的会话键。 const commentFlashKey = "comment_flash" -// setCommentFlash stores a one-time comment notice in the session so the -// following GET /article/:slug (after the POST/redirect) can show it once and -// never again on refresh. +// setCommentFlash 在会话中保存一次性评论通知,使 POST/重定向后的 +// GET /article/:slug 能显示一次,刷新后不再显示。 func setCommentFlash(c *gin.Context, value string) { session := sessions.Default(c) session.Set(commentFlashKey, value) session.Save() } -// readCommentFlash returns and clears the one-time comment notice, if any. +// readCommentFlash 返回并清除一次性评论通知(如有)。 func readCommentFlash(c *gin.Context) string { session := sessions.Default(c) v, ok := session.Get(commentFlashKey).(string) @@ -290,15 +288,13 @@ func readCommentFlash(c *gin.Context) string { return "" } -// recordArticleView records a unique article view in the database. -// This function is designed to be called asynchronously (via goroutine) to -// avoid blocking the page response. It checks for existing records to ensure -// each user/IP combination only records one view per article. All request -// derived values (userID, ip, userAgent) must be extracted by the caller -// before the goroutine is spawned - this function never touches the gin -// context. +// recordArticleView 在数据库中记录一条唯一的文章浏览。 +// 该函数设计为异步调用(通过 goroutine),避免阻塞页面响应。 +// 它会检查已有记录,确保每个用户/IP 组合对每篇文章只记录一次浏览。 +// 所有从请求派生的值(userID、ip、userAgent)必须在 goroutine 启动前 +// 由调用方提取——此函数绝不触碰 gin 上下文。 func recordArticleView(db *gorm.DB, articleID uint, userID *uint, ip, userAgent string) { - // Check if this view already exists (deduplication) + // 检查该浏览是否已存在(去重) var count int64 query := db.Model(&models.ArticleView{}). Where("article_id = ? AND ip = ?", articleID, ip) @@ -310,16 +306,16 @@ func recordArticleView(db *gorm.DB, articleID uint, userID *uint, ip, userAgent } if err := query.Count(&count).Error; err != nil { - // Silently fail - don't break the user experience + // 静默失败——不影响用户体验 return } if count > 0 { - // Already recorded + // 已记录 return } - // Create new view record using INSERT IGNORE pattern + // 使用类似 INSERT IGNORE 的模式创建新浏览记录 view := models.ArticleView{ ArticleID: articleID, UserID: userID, @@ -328,12 +324,12 @@ func recordArticleView(db *gorm.DB, articleID uint, userID *uint, ip, userAgent IsBot: models.IsBot(userAgent), } - // Create the view record (BeforeCreate hook in model handles deduplication) + // 创建浏览记录(模型中的 BeforeCreate 钩子负责去重) db.Create(&view) - // Ignore errors - this is a best-effort tracking system + // 忽略错误——这是一个尽力而为的统计系统 } -// SearchPage handles article search by keyword. +// SearchPage 处理按关键字搜索文章。 func SearchPage(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { tr := getTr(c) @@ -351,21 +347,21 @@ func SearchPage(db *gorm.DB) gin.HandlerFunc { return } - // Search in title, summary, and content + // 在标题、摘要和正文中搜索 searchPattern := "%" + keyword + "%" var articles []models.Article db.Where("status = ?", models.ArticlePublished). Where("title LIKE ? OR summary LIKE ? OR content LIKE ?", searchPattern, searchPattern, searchPattern). Preload("Tags"). Order(publishedArticleOrder). - Limit(50). // Limit search results + Limit(50). // 限制搜索结果数量 Find(&articles) - // Load all tags for sidebar + // 加载所有标签用于侧边栏 var tags []models.Tag db.Where("count > 0").Order("count DESC, name_zh ASC").Find(&tags) - // Get comment counts + // 获取评论数量 articleIDs := make([]uint, len(articles)) for i, article := range articles { articleIDs[i] = article.ID diff --git a/handlers/login_ratelimit.go b/handlers/login_ratelimit.go index 909466d..12ed376 100644 --- a/handlers/login_ratelimit.go +++ b/handlers/login_ratelimit.go @@ -7,19 +7,18 @@ import ( "golang.org/x/crypto/bcrypt" ) -// Login rate limiting (SECURITY_TODO #10): per IP+username failure counting -// with a lockout window, to blunt credential-guessing attacks. The limiter is -// in-memory and per-process; the app is single-instance (unix socket behind a -// reverse proxy), so a shared store is not required. +// 登录速率限制(SECURITY_TODO #10):按 IP+用户名计数失败次数, +// 并设置锁定期窗口,以削弱凭据猜测攻击。该限流器为进程内存实现; +// 应用是单实例部署(反向代理后的 unix socket),因此无需共享存储。 const ( maxLoginFailures = 5 loginLockDuration = 15 * time.Minute maxTrackedKeys = 4096 - // dummyHashCost mirrors the production bcrypt cost (models.bcryptCost). + // dummyHashCost 与生产环境的 bcrypt 成本一致(models.bcryptCost)。 dummyHashCost = 12 ) -// loginRateLimiter tracks consecutive login failures per key ("IP|username"). +// loginRateLimiter 按键("IP|username")跟踪连续的登录失败次数。 type loginRateLimiter struct { mu sync.Mutex entries map[string]*loginRateEntry @@ -31,16 +30,15 @@ type loginRateEntry struct { lastSeen time.Time } -// NewLoginLimiter creates an empty rate limiter for the login endpoints. +// NewLoginLimiter 为登录端点创建空的速率限制器。 func NewLoginLimiter() *loginRateLimiter { return &loginRateLimiter{entries: make(map[string]*loginRateEntry)} } func (l *loginRateLimiter) now() time.Time { return time.Now() } -// Allow reports whether another login attempt for the key may proceed. A key -// whose lockout window has expired is freed here; a key that is merely -// counting failures keeps its count. +// Allow 报告该键是否允许再次尝试登录。锁定期窗口已过期的键会在此释放; +// 仅计数失败次数的键保留其计数。 func (l *loginRateLimiter) Allow(key string) bool { l.mu.Lock() defer l.mu.Unlock() @@ -52,13 +50,13 @@ func (l *loginRateLimiter) Allow(key string) bool { if now.Before(e.lockedUntil) { return false } - // Lockout window expired: free the key and start fresh. + // 锁定期窗口已过期:释放该键并重新开始。 delete(l.entries, key) return true } -// Fail records one failed attempt for the key and returns the number of -// remaining attempts before the lockout kicks in (0 = newly locked). +// Fail 记录该键的一次失败尝试,并返回锁定生效前剩余的可尝试次数 +// (0 = 刚刚被锁定)。 func (l *loginRateLimiter) Fail(key string) (remaining int) { l.mu.Lock() defer l.mu.Unlock() @@ -68,7 +66,7 @@ func (l *loginRateLimiter) Fail(key string) (remaining int) { e = &loginRateEntry{} l.entries[key] = e } else if !e.lockedUntil.IsZero() && now.After(e.lockedUntil) { - // Lock window expired; start a fresh counting period. + // 锁定窗口过期;开始新的计数周期。 e.failures = 0 e.lockedUntil = time.Time{} } @@ -83,28 +81,26 @@ func (l *loginRateLimiter) Fail(key string) (remaining int) { return maxLoginFailures - e.failures } -// Reset clears the failure counter after a successful login. +// Reset 在登录成功后清除失败计数。 func (l *loginRateLimiter) Reset(key string) { l.mu.Lock() defer l.mu.Unlock() delete(l.entries, key) } -// sweep bounds the map size so an attacker churning many keys cannot grow the -// limiter unbounded. Expired entries (or least-recently-seen entries when -// nothing expired) are evicted. +// sweep 限制映射大小,防止攻击者通过制造大量键使限流器无限增长。 +// 过期的条目(若无过期条目,则移除最少访问的条目)会被逐出。 func (l *loginRateLimiter) sweep(now time.Time) { if len(l.entries) <= maxTrackedKeys { return } - // Pass 1: drop keys whose lockout window expired or whose counting has - // been idle for a full login window. + // 第 1 轮:移除锁定期已过或计数已空闲超过一个完整登录窗口的键。 for k, e := range l.entries { if now.Sub(e.lastSeen) > loginLockDuration { delete(l.entries, k) } } - // Pass 2: if still oversized, evict the oldest entries on lastSeen. + // 第 2 轮:若仍然过大,按 lastSeen 逐出最旧的条目。 if len(l.entries) <= maxTrackedKeys { return } @@ -129,9 +125,8 @@ func (l *loginRateLimiter) sweep(now time.Time) { } } -// dummyHash is a pre-computed bcrypt hash compared against when a user does -// not exist, so the login time does not reveal whether the username is valid -// (SECURITY_TODO #25). Cost matches production (12, SECURITY_TODO #17) and is -// generated once at package init. +// dummyHash 是一个预计算的 bcrypt 哈希,在用户不存在时与之比对, +// 使登录耗时不会暴露用户名是否有效(SECURITY_TODO #25)。 +// 成本与生产环境一致(12,SECURITY_TODO #17),在包初始化时生成一次。 var dummyHash, _ = bcrypt.GenerateFromPassword( []byte("dummy-password-for-constant-time-login"), dummyHashCost) diff --git a/handlers/my_articles.go b/handlers/my_articles.go index c017712..d35c5ed 100644 --- a/handlers/my_articles.go +++ b/handlers/my_articles.go @@ -12,7 +12,7 @@ import ( "go_blog/models" ) -// MyArticlesPage renders the logged-in user's article management page. +// MyArticlesPage 渲染已登录用户的文章管理页面。 func MyArticlesPage(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { tr := getTr(c) @@ -29,7 +29,7 @@ func MyArticlesPage(db *gorm.DB) gin.HandlerFunc { } } -// MyArticleCreatePage renders the article creation form for regular users. +// MyArticleCreatePage 为普通用户渲染文章创建表单。 func MyArticleCreatePage(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { tr := getTr(c) @@ -46,12 +46,12 @@ func MyArticleCreatePage(db *gorm.DB) gin.HandlerFunc { } } -// MyArticleCreate handles the POST request to create a new article for regular users. +// MyArticleCreate 处理普通用户创建新文章的 POST 请求。 func MyArticleCreate(db *gorm.DB) gin.HandlerFunc { - return ArticleCreate(db) // Reuse the same logic + return ArticleCreate(db) // 复用相同的逻辑 } -// MyArticleEditPage renders the article edit form for the logged-in user's own articles. +// MyArticleEditPage 为已登录用户自己的文章渲染编辑表单。 func MyArticleEditPage(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { tr := getTr(c) @@ -81,7 +81,7 @@ func MyArticleEditPage(db *gorm.DB) gin.HandlerFunc { } } -// MyArticleUpdate handles the POST request to update the user's own article. +// MyArticleUpdate 处理更新用户自己文章的 POST 请求。 func MyArticleUpdate(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { tr := getTr(c) @@ -118,13 +118,13 @@ func MyArticleUpdate(db *gorm.DB) gin.HandlerFunc { newStatus := statusFromForm(f.StatusStr) - // Handle published_at: use form value if provided, otherwise auto-stamp on first publish. + // 处理 published_at:若提供了表单值则使用,否则在首次发布时自动盖章。 var publishedAt *time.Time if f.PublishedAt != "" { - // User provided a custom published time + // 用户提供了自定义发布时间 publishedAt = parsePublishedAt(f.PublishedAt) } else { - // Stamp the publish time the first time an article is published. + // 文章首次发布时盖上发布时间。 wasPublished := article.Status == models.ArticlePublished publishedAt = article.PublishedAt if newStatus == models.ArticlePublished && !wasPublished && publishedAt == nil { @@ -153,7 +153,7 @@ func MyArticleUpdate(db *gorm.DB) gin.HandlerFunc { } } -// MyArticleDelete soft-deletes the user's own article. +// MyArticleDelete 软删除用户自己的文章。 func MyArticleDelete(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { id := c.Param("id") @@ -165,7 +165,7 @@ func MyArticleDelete(db *gorm.DB) gin.HandlerFunc { } } -// renderMyArticleForm renders the article form for regular users. +// renderMyArticleForm 为普通用户渲染文章表单。 func renderMyArticleForm(c *gin.Context, db *gorm.DB, f articleForm, errMsg string) { data := DefaultData(c) data["Title"] = f.TitleText diff --git a/handlers/p2_validation_test.go b/handlers/p2_validation_test.go index 5925730..c44cc9d 100644 --- a/handlers/p2_validation_test.go +++ b/handlers/p2_validation_test.go @@ -11,8 +11,7 @@ import ( "go_blog/models" ) -// postForm is a tiny helper for form-encoded requests that always carries a -// CSRF token. +// postForm 是表单编码请求的小助手,始终携带 CSRF 令牌。 func postForm(e *securityTestEnv, method, path, cookie, csrfToken string, fields url.Values) *httptest.ResponseRecorder { if fields == nil { fields = url.Values{} @@ -23,8 +22,8 @@ func postForm(e *securityTestEnv, method, path, cookie, csrfToken string, fields return e.do(method, path, cookie, strings.NewReader(fields.Encode()), "application/x-www-form-urlencoded") } -// TestStorageDirTraversalRejected covers SECURITY_TODO #22: an admin must not -// be able to set storage_dir to a value that escapes the storage root. +// TestStorageDirTraversalRejected 覆盖 SECURITY_TODO #22:管理员不得将 +// storage_dir 设置为逃逸出存储根目录的值。 func TestStorageDirTraversalRejected(t *testing.T) { e := newSecurityTestEnv(t) admin := e.login(t, "admin") @@ -49,7 +48,7 @@ func TestStorageDirTraversalRejected(t *testing.T) { if loc := w.Header().Get("Location"); !strings.Contains(loc, "illegal_dir") { t.Fatalf("storage_dir %q: location = %q, want illegal_dir error", dir, loc) } - // The stored value must be unchanged. + // 存储的值必须保持不变。 var u models.UploadConfig if err := e.db.First(&u, 1).Error; err != nil { t.Fatalf("load upload config: %v", err) @@ -59,7 +58,7 @@ func TestStorageDirTraversalRejected(t *testing.T) { } } - // A safe single-segment value is accepted. + // 安全的单段值可被接受。 fields := url.Values{} fields.Set("action", "save_config") fields.Set("storage_dir", "my_attach-2") @@ -76,14 +75,14 @@ func TestStorageDirTraversalRejected(t *testing.T) { } } -// TestProfilePasswordMinLength covers SECURITY_TODO #23 on the profile -// password-change path. +// TestProfilePasswordMinLength 覆盖个人资料密码修改路径上的 +// SECURITY_TODO #23。 func TestProfilePasswordMinLength(t *testing.T) { e := newSecurityTestEnv(t) alice := e.login(t, "alice") token := e.csrfTokenFor(t, alice) - // A 1-char password must be rejected and the old hash preserved. + // 1 个字符的密码必须被拒绝,旧哈希保持不变。 fields := url.Values{} fields.Set("current_password", "pw-alice") fields.Set("new_password", "a") @@ -102,7 +101,7 @@ func TestProfilePasswordMinLength(t *testing.T) { t.Fatal("old password no longer verifies after rejected change") } - // A 6-char password is accepted. + // 6 个字符的密码可被接受。 fields = url.Values{} fields.Set("current_password", "pw-alice") fields.Set("new_password", "newpass6") @@ -118,7 +117,7 @@ func TestProfilePasswordMinLength(t *testing.T) { } } -// TestProfileEmailValidation covers SECURITY_TODO #24 on the profile path. +// TestProfileEmailValidation 覆盖个人资料路径上的 SECURITY_TODO #24。 func TestProfileEmailValidation(t *testing.T) { e := newSecurityTestEnv(t) alice := e.login(t, "alice") @@ -155,14 +154,14 @@ func TestProfileEmailValidation(t *testing.T) { } } -// TestAdminUserPasswordAndEmailEnforcement covers SECURITY_TODO #23/#24 on -// the admin user-create/update paths. +// TestAdminUserPasswordAndEmailEnforcement 覆盖后台用户创建/更新路径上的 +// SECURITY_TODO #23/#24。 func TestAdminUserPasswordAndEmailEnforcement(t *testing.T) { e := newSecurityTestEnv(t) admin := e.login(t, "admin") token := e.csrfTokenFor(t, admin) - // Create: short password rejected (no row created). + // 创建:短密码被拒绝(不创建任何行)。 fields := url.Values{} fields.Set("username", "charlie") fields.Set("password", "ab") @@ -175,7 +174,7 @@ func TestAdminUserPasswordAndEmailEnforcement(t *testing.T) { t.Fatal("short-password error message not rendered") } - // Create: invalid email rejected. + // 创建:非法邮箱被拒绝。 fields = url.Values{} fields.Set("username", "charlie") fields.Set("password", "longenough") @@ -192,7 +191,7 @@ func TestAdminUserPasswordAndEmailEnforcement(t *testing.T) { t.Fatal("charlie was created despite invalid input") } - // Create: valid row succeeds. + // 创建:合法数据成功。 fields = url.Values{} fields.Set("username", "charlie") fields.Set("password", "longenough") @@ -207,7 +206,7 @@ func TestAdminUserPasswordAndEmailEnforcement(t *testing.T) { t.Fatal("charlie was not created") } - // Update (password reset path): short password rejected, hash unchanged. + // 更新(密码重置路径):短密码被拒绝,哈希保持不变。 aliceID := userIDByUsername(t, e.db, "alice") fields = url.Values{} fields.Set("password", "x") @@ -224,7 +223,7 @@ func TestAdminUserPasswordAndEmailEnforcement(t *testing.T) { t.Fatal("alice password changed by a rejected reset") } - // Update: invalid email rejected, old value preserved. + // 更新:非法邮箱被拒绝,旧值保留。 fields = url.Values{} fields.Set("email", "bad") fields.Set("role", models.RoleAuthor) @@ -238,14 +237,14 @@ func TestAdminUserPasswordAndEmailEnforcement(t *testing.T) { } } -// TestRegisterRejectsInvalidEmail covers SECURITY_TODO #24 on registration. +// TestRegisterRejectsInvalidEmail 覆盖注册上的 SECURITY_TODO #24。 func TestRegisterRejectsInvalidEmail(t *testing.T) { e := newSecurityTestEnv(t) if err := e.db.Model(&models.SiteSetting{}).Where("id = ?", 1).Update("allow_registration", true).Error; err != nil { t.Fatalf("enable registration: %v", err) } - // Fetch the registration form for an anonymous CSRF token + session. + // 获取注册表单以获得匿名 CSRF 令牌 + 会话。 req := httptest.NewRequest(http.MethodGet, "/register", nil) w := httptest.NewRecorder() e.router.ServeHTTP(w, req) @@ -287,8 +286,8 @@ func TestRegisterRejectsInvalidEmail(t *testing.T) { } } -// limiterEntryKey returns the rate-limiter key for a username by scanning the -// tracked entries (the prepended client IP depends on the test transport). +// limiterEntryKey 通过扫描已跟踪条目返回某用户名的限流器键 +// (前缀的客户端 IP 取决于测试传输方式)。 func limiterEntryKey(e *securityTestEnv, username string) string { for k := range e.limiter.entries { if strings.HasSuffix(k, "\x00"+username) { @@ -298,13 +297,13 @@ func limiterEntryKey(e *securityTestEnv, username string) string { return "" } -// TestLoginRateLimited covers SECURITY_TODO #10: repeated failures lock the -// IP+username key, and a successful login resets it. +// TestLoginRateLimited 覆盖 SECURITY_TODO #10:重复失败会锁定 +// IP+用户名键,成功登录后重置。 func TestLoginRateLimited(t *testing.T) { e := newSecurityTestEnv(t) loginAttempt := func(form url.Values) (*httptest.ResponseRecorder, string) { - // Fresh anonymous session (and CSRF token) for each attempt. + // 每次尝试使用全新的匿名会话(和 CSRF 令牌)。 req := httptest.NewRequest(http.MethodGet, "/login", nil) w := httptest.NewRecorder() e.router.ServeHTTP(w, req) @@ -329,7 +328,7 @@ func TestLoginRateLimited(t *testing.T) { } } - // The next attempt (even with the correct password) is locked. + // 下一次尝试(即使密码正确)也会被锁定。 good := url.Values{"username": {"alice"}, "password": {"pw-alice"}} w, _ := loginAttempt(good) if w.Code != http.StatusFound { @@ -339,14 +338,14 @@ func TestLoginRateLimited(t *testing.T) { t.Fatalf("locked attempt: location = %q, want /login?error=locked", loc) } - // A different key (username) is unaffected. + // 不同的键(用户名)不受影响。 w, _ = loginAttempt(url.Values{"username": {"bob"}, "password": {"pw-bob"}}) if w.Code != http.StatusFound || w.Header().Get("Location") != "/" { t.Fatalf("different user login during lock: status = %d, location = %q", w.Code, w.Header().Get("Location")) } - // After reset the locked key works again. + // 重置后,被锁定的键再次可用。 aliceKey := limiterEntryKey(e, "alice") if aliceKey == "" { t.Fatal("alice rate-limit entry not found") @@ -359,10 +358,9 @@ func TestLoginRateLimited(t *testing.T) { } } -// TestLoginTimingDoesNotRevealUser asserts the structural property of -// SECURITY_TODO #25: an unknown username still incurs a bcrypt comparison -// (dummy hash) and one failure is recorded, so the two branches are -// indistinguishable in cost by design. +// TestLoginTimingDoesNotRevealUser 断言 SECURITY_TODO #25 的结构性保证: +// 未知用户名仍执行一次 bcrypt 比较(虚拟哈希)并记录一次失败, +// 因此两个分支在设计上耗时不可区分。 func TestLoginTimingDoesNotRevealUser(t *testing.T) { e := newSecurityTestEnv(t) @@ -382,8 +380,8 @@ func TestLoginTimingDoesNotRevealUser(t *testing.T) { t.Fatalf("unknown user: status = %d, location = %q", w2.Code, w2.Header().Get("Location")) } - // The unknown user's key must be fail-counted (if the limiter is - // shared), proving the branch went through Fail + dummy bcrypt path. + // 未知用户的键必须被计入失败次数(若限流器共享), + // 证明该分支走过了 Fail + 虚拟 bcrypt 路径。 if key := limiterEntryKey(e, "does-not-exist-31415"); key == "" { t.Fatal("unknown-user branch did not record a failure") } else if e.limiter.entries[key].failures != 1 { diff --git a/handlers/p3_upload_test.go b/handlers/p3_upload_test.go index a6f9e7c..af89838 100644 --- a/handlers/p3_upload_test.go +++ b/handlers/p3_upload_test.go @@ -11,9 +11,9 @@ import ( "go_blog/models" ) -// TestUploadAttachmentRejectsMismatchedContent covers SECURITY_TODO #14: the -// extension whitelist is header-level only; bytes must match the configured -// MIME type (a .txt file carrying PNG bytes is a disguised payload). +// TestUploadAttachmentRejectsMismatchedContent 覆盖 SECURITY_TODO #14: +// 扩展名白名单仅是头部级别的;字节必须与配置的 MIME 类型匹配 +//(携带 PNG 字节的 .txt 文件是伪装载荷)。 func TestUploadAttachmentRejectsMismatchedContent(t *testing.T) { e := newSecurityTestEnv(t) alice := e.login(t, "alice") @@ -23,7 +23,7 @@ func TestUploadAttachmentRejectsMismatchedContent(t *testing.T) { e.db.Where("slug = ?", "alice-post").First(&aliceArt) artID := strconv.FormatUint(uint64(aliceArt.ID), 10) - // .txt claim, PNG bytes -> reject 400. + // 声称 .txt、实际 PNG 字节 -> 拒绝 400。 var buf strings.Builder mw := multipart.NewWriter(&buf) mw.WriteField("article_id", artID) @@ -36,7 +36,7 @@ func TestUploadAttachmentRejectsMismatchedContent(t *testing.T) { t.Fatalf("mismatched content: status = %d, want 400 (body %s)", w.Code, w.Body.String()) } - // Genuine text passes. + // 真正的文本可通过。 buf.Reset() mw = multipart.NewWriter(&buf) mw.WriteField("article_id", artID) @@ -50,13 +50,12 @@ func TestUploadAttachmentRejectsMismatchedContent(t *testing.T) { } } -// TestRSSUsesConfiguredSiteURL covers SECURITY_TODO #16: the feed links use -// the canonical site URL when configured and fall back (with a log warning) -// to the request Host otherwise. +// TestRSSUsesConfiguredSiteURL 覆盖 SECURITY_TODO #16:配置后 feed 链接使用 +// 规范化的站点 URL,否则回退到请求的 Host(并输出日志警告)。 func TestRSSUsesConfiguredSiteURL(t *testing.T) { e := newSecurityTestEnv(t) - // Unset: falls back to the request Host. + // 未设置:回退到请求的 Host。 req := httptest.NewRequest(http.MethodGet, "/rss", nil) req.Host = "evil.example.com" w := httptest.NewRecorder() @@ -68,7 +67,7 @@ func TestRSSUsesConfiguredSiteURL(t *testing.T) { t.Fatal("fallback did not use the request Host") } - // Configured: fixed URL wins, Host header is ignored. + // 已配置:固定 URL 生效,Host 头被忽略。 var s models.SiteSetting if err := e.db.First(&s, 1).Error; err != nil { t.Fatalf("load site setting: %v", err) @@ -91,9 +90,9 @@ func TestRSSUsesConfiguredSiteURL(t *testing.T) { } } -// TestAdminCommentListFollowsGravatarSwitch covers SECURITY_TODO #15: the -// admin moderation list emits no Gravatar URLs when the platform switch is -// off and uses them when an admin re-enables it. +// TestAdminCommentListFollowsGravatarSwitch 覆盖 SECURITY_TODO #15: +// 平台开关关闭时后台审核列表不输出 Gravatar URL, +// 管理员重新启用后再使用。 func TestAdminCommentListFollowsGravatarSwitch(t *testing.T) { e := newSecurityTestEnv(t) admin := e.login(t, "admin") @@ -105,7 +104,7 @@ func TestAdminCommentListFollowsGravatarSwitch(t *testing.T) { Content: "hello", Status: models.CommentApproved, IPAddress: "127.0.0.1", }) - // Switch off (new default): no gravatar.com list entries. + // 关闭(新默认):列表中没有 gravatar.com 条目。 w := e.do(http.MethodGet, "/admin/comments?status=all", admin, nil, "") if w.Code != http.StatusOK { t.Fatalf("GET /admin/comments: status = %d", w.Code) @@ -114,7 +113,7 @@ func TestAdminCommentListFollowsGravatarSwitch(t *testing.T) { t.Fatal("admin comment list emitted Gravatar URLs while disabled") } - // Switch on: Gravatar URLs appear (following the platform policy). + // 开启:Gravatar URL 出现(遵循平台策略)。 e.db.Model(&models.CommentConfig{}).Where("id = ?", 1).Update("use_gravatar", true) models.LoadConfigCache(e.db) w = e.do(http.MethodGet, "/admin/comments?status=all", admin, nil, "") @@ -125,7 +124,7 @@ func TestAdminCommentListFollowsGravatarSwitch(t *testing.T) { t.Fatal("admin comment list missing Gravatar URLs while enabled") } } -// TestContentMatchesTypeTable drives the pure matcher (SECURITY_TODO #14). +// TestContentMatchesTypeTable 驱动纯匹配函数(SECURITY_TODO #14)。 func TestContentMatchesTypeTable(t *testing.T) { txt := &models.UploadFileType{MimeType: "text/plain"} pngType := &models.UploadFileType{MimeType: "image/png"} diff --git a/handlers/profile.go b/handlers/profile.go index 5852ae5..e24da58 100644 --- a/handlers/profile.go +++ b/handlers/profile.go @@ -19,16 +19,15 @@ import ( xdraw "golang.org/x/image/draw" _ "golang.org/x/image/webp" - // Register the decoders processAvatar relies on. JPEG is registered by - // the image/jpeg import above; png/gif must be blank-imported or - // image.Decode would reject them. + // 注册 processAvatar 依赖的解码器。JPEG 由上面的 image/jpeg 导入注册; + // png/gif 必须空导入,否则 image.Decode 会拒绝它们。 _ "image/gif" _ "image/png" "go_blog/models" ) -// ProfilePage renders the profile edit page. +// ProfilePage 渲染个人资料编辑页面。 func ProfilePage(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { tr := getTr(c) @@ -45,7 +44,7 @@ func ProfilePage(db *gorm.DB) gin.HandlerFunc { data["Title"] = tr["profile_title"] data["Profile"] = user - // Flash messages (success / error). + // Flash 消息(成功 / 错误)。 if msg := c.Query("saved"); msg == "1" { data["Success"] = tr["profile_saved"] } @@ -69,7 +68,7 @@ func ProfilePage(db *gorm.DB) gin.HandlerFunc { } } -// UpdateProfile processes the profile edit form (multipart). +// UpdateProfile 处理个人资料编辑表单(multipart)。 func UpdateProfile(db *gorm.DB, storagePath string) gin.HandlerFunc { return func(c *gin.Context) { session := sessions.Default(c) @@ -81,15 +80,15 @@ func UpdateProfile(db *gorm.DB, storagePath string) gin.HandlerFunc { return } - // --- Text fields --- - // Allow empty display_name (user can clear it to fall back to username) + // --- 文本字段 --- + // 允许 display_name 为空(用户可清空以回退到用户名) user.DisplayName = strings.TrimSpace(c.PostForm("display_name")) if v := c.PostForm("gender"); v != "" { user.Gender = v } - // SECURITY (#24): validate the email format before persisting - // (dirty values would pollute Gravatar lookups). Empty is allowed. + // SECURITY (#24):持久化前校验邮箱格式 + //(脏值会污染 Gravatar 查询)。允许为空。 if v := c.PostForm("email"); v != "" { if !validateEmail(v) { session.Save() @@ -104,12 +103,12 @@ func UpdateProfile(db *gorm.DB, storagePath string) gin.HandlerFunc { } } - // --- Avatar upload --- + // --- 头像上传 --- file, header, err := c.Request.FormFile("avatar") if err == nil { defer file.Close() - // Validate against the platform upload policy (switch + type + size). + // 依据平台上传策略校验(总开关 + 类型 + 大小)。 check := ValidateUpload(header) if !check.OK || check.Type.Category != models.CategoryImage { session.Save() @@ -123,19 +122,19 @@ func UpdateProfile(db *gorm.DB, storagePath string) gin.HandlerFunc { return } - // Determine file extension (validated to be in the whitelist). + // 确定文件扩展名(已校验在白名单内)。 ext := strings.ToLower(filepath.Ext(header.Filename)) - // SECURITY (#21): decode and re-encode the avatar as a normalized - // JPEG instead of storing the original bytes — undecodable payloads - // (e.g. HTML disguised behind an image extension) are rejected. + // SECURITY (#21):解码并将头像重新编码为规范化 JPEG, + // 而不是存储原始字节——无法解码的载荷(如伪装在图片扩展名下的 + // HTML)会被拒绝。 imgBytes, err := io.ReadAll(file) if err != nil { c.Redirect(http.StatusFound, "/profile?error=upload") return } - // SECURITY (#14): magic-byte consistency check before decoding — - // the extension policy is header-level only. + // SECURITY (#14):解码前进行魔数字节一致性校验—— + // 扩展名策略仅是头部级别的。 if !contentMatchesType(check.Type, imgBytes) { session.Save() c.Redirect(http.StatusFound, "/profile?error=upload") @@ -148,20 +147,20 @@ func UpdateProfile(db *gorm.DB, storagePath string) gin.HandlerFunc { return } - // Save under storagePath/avatars/. + // 保存到 storagePath/avatars/ 下。 avatarDir := filepath.Join(storagePath, "avatars") if err := os.MkdirAll(avatarDir, 0755); err != nil { c.Redirect(http.StatusFound, "/profile") return } - // Remove old avatar file if it exists (different extension or same). + // 若存在旧头像文件则删除(无论扩展名是否相同)。 if user.Avatar != "" { oldPath := filepath.Join(avatarDir, user.Avatar) - os.Remove(oldPath) // ignore error — file may not exist + os.Remove(oldPath) // 忽略错误——文件可能不存在 } - // Use user ID as filename base. + // 使用用户 ID 作为文件名基础。 savedName := fmt.Sprintf("%d%s", user.ID, finalExt) savedPath := filepath.Join(avatarDir, savedName) @@ -181,7 +180,7 @@ func UpdateProfile(db *gorm.DB, storagePath string) gin.HandlerFunc { session.Set("avatar", savedName) } - // --- Password change --- + // --- 密码修改 --- currentPass := c.PostForm("current_password") newPass := c.PostForm("new_password") if currentPass != "" && newPass != "" { @@ -190,8 +189,8 @@ func UpdateProfile(db *gorm.DB, storagePath string) gin.HandlerFunc { c.Redirect(http.StatusFound, "/profile?error=pw") return } - // SECURITY (#23): enforce the same minimum length as registration; - // resetting to a 1-char password would be trivially guessable. + // SECURITY (#23):执行与注册相同的最小长度; + // 重置为 1 个字符的密码将极易被猜出。 if !validatePassword(newPass) { session.Save() c.Redirect(http.StatusFound, "/profile?error=pw_short") @@ -204,14 +203,14 @@ func UpdateProfile(db *gorm.DB, storagePath string) gin.HandlerFunc { } } - // Save user record. + // 保存用户记录。 if err := db.Save(&user).Error; err != nil { session.Save() c.Redirect(http.StatusFound, "/profile") return } - // Update display name in session. + // 更新会话中的显示名。 session.Set("display_name", user.DisplayName) session.Save() @@ -219,7 +218,7 @@ func UpdateProfile(db *gorm.DB, storagePath string) gin.HandlerFunc { } } -// UploadAvatar handles AJAX avatar upload with cropping. +// UploadAvatar 处理带裁剪的 AJAX 头像上传。 func UploadAvatar(db *gorm.DB, storagePath string) gin.HandlerFunc { return func(c *gin.Context) { session := sessions.Default(c) @@ -238,7 +237,7 @@ func UploadAvatar(db *gorm.DB, storagePath string) gin.HandlerFunc { } defer file.Close() - // Validate against the platform upload policy (switch + type + size). + // 依据平台上传策略校验(总开关 + 类型 + 大小)。 check := ValidateUpload(header) if !check.OK { if !models.GetUploadConfig().Enabled { @@ -253,50 +252,50 @@ func UploadAvatar(db *gorm.DB, storagePath string) gin.HandlerFunc { return } - // SECURITY (#21): avatars must be an image-category type from the - // whitelist — the extension whitelist alone is admin-configurable and - // could otherwise admit active content into /uploads/avatars/. + // SECURITY (#21):头像必须是白名单中的图片类型—— + // 仅靠扩展名白名单(管理员可配置)可能让活动内容进入 + // /uploads/avatars/。 if check.Type.Category != models.CategoryImage { c.JSON(http.StatusBadRequest, gin.H{"error": "file type not allowed"}) return } - // Determine file extension (validated to be in the whitelist). + // 确定文件扩展名(已校验在白名单内)。 ext := strings.ToLower(filepath.Ext(header.Filename)) - // Read file bytes for image processing. + // 读取文件字节以进行图像处理。 imgBytes, err := io.ReadAll(file) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to read file"}) return } - // SECURITY (#14): magic-byte consistency before decoding. + // SECURITY (#14):解码前进行魔数字节一致性校验。 if !contentMatchesType(check.Type, imgBytes) { c.JSON(http.StatusBadRequest, gin.H{"error": "file content does not match its declared type"}) return } - // Decode, resize, and re-encode the image. + // 解码、缩放并重新编码图像。 processedBytes, finalExt, err := processAvatar(imgBytes, ext) if err != nil { - // SECURITY (#21): reject undecodable payloads outright — storing - // the raw bytes would let non-image content land in avatars/. + // SECURITY (#21):直接拒绝无法解码的载荷——存储原始字节 + // 会让非图像内容进入 avatars/ 目录。 c.JSON(http.StatusBadRequest, gin.H{"error": "invalid image file"}) return } - // Ensure avatar directory exists. + // 确保头像目录存在。 avatarDir := filepath.Join(storagePath, "avatars") os.MkdirAll(avatarDir, 0755) - // Remove old avatar file. + // 删除旧头像文件。 if user.Avatar != "" { oldPath := filepath.Join(avatarDir, user.Avatar) os.Remove(oldPath) } - // Save processed avatar. + // 保存处理后的头像。 savedName := fmt.Sprintf("%d%s", user.ID, finalExt) savedPath := filepath.Join(avatarDir, savedName) if err := os.WriteFile(savedPath, processedBytes, 0644); err != nil { @@ -304,11 +303,11 @@ func UploadAvatar(db *gorm.DB, storagePath string) gin.HandlerFunc { return } - // Update user record. + // 更新用户记录。 user.Avatar = savedName db.Save(&user) - // Update session. + // 更新会话。 session.Set("avatar", savedName) session.Save() @@ -316,7 +315,7 @@ func UploadAvatar(db *gorm.DB, storagePath string) gin.HandlerFunc { } } -// processAvatar decodes, resizes to 256x256, and re-encodes an avatar image as JPEG. +// processAvatar 解码头像图像,缩放到 256x256,并重新编码为 JPEG。 func processAvatar(imgBytes []byte, ext string) ([]byte, string, error) { src, _, err := image.Decode(bytes.NewReader(imgBytes)) if err != nil { diff --git a/handlers/rss.go b/handlers/rss.go index d241fa5..e3638ae 100644 --- a/handlers/rss.go +++ b/handlers/rss.go @@ -14,16 +14,16 @@ import ( "gorm.io/gorm" ) -// RSS 2.0 XML structure definitions. +// RSS 2.0 XML 结构定义。 -// RSS is the root element of an RSS 2.0 feed. +// RSS 是 RSS 2.0 feed 的根元素。 type RSS struct { XMLName xml.Name `xml:"rss"` Version string `xml:"version,attr"` Channel *Channel `xml:"channel"` } -// Channel represents the RSS channel containing feed metadata and items. +// Channel 表示包含 feed 元数据和条目(items)的 RSS channel。 type Channel struct { Title string `xml:"title"` Link string `xml:"link"` @@ -33,7 +33,7 @@ type Channel struct { Items []Item `xml:"item"` } -// Item represents a single article in the RSS feed. +// Item 表示 RSS feed 中的单篇文章。 type Item struct { Title string `xml:"title"` Link string `xml:"link"` @@ -43,24 +43,23 @@ type Item struct { GUID string `xml:"guid"` } -// RSSFeed generates an RSS 2.0 feed of the latest published articles. +// RSSFeed 生成最新已发布文章的 RSS 2.0 feed。 func RSSFeed(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { - // Determine the current language for site metadata. + // 确定用于站点元数据的当前语言。 lang, exists := c.Get("lang") if !exists { lang = "en" } langStr := lang.(string) - // Get site settings for feed metadata. + // 获取站点设置用于 feed 元数据。 siteSetting := &models.SiteSetting{} db.First(siteSetting) - // SECURITY_TODO #16: the canonical site URL from settings is used - // when configured — the request Host is attacker-controllable and - // would otherwise poison every link in the feed. Fall back with a - // warning for legacy deployments. + // SECURITY_TODO #16:配置后使用设置中的规范化站点 URL—— + // 请求的 Host 可被攻击者控制,否则会污染 feed 中的每个链接。 + // 对于旧部署则回退并给出警告。 var baseURL string if u := strings.TrimSpace(siteSetting.SiteURL); u != "" { baseURL = strings.TrimRight(u, "/") @@ -74,7 +73,7 @@ func RSSFeed(db *gorm.DB) gin.HandlerFunc { baseURL = fmt.Sprintf("%s://%s", scheme, c.Request.Host) } - // Get the latest 20 published articles. + // 获取最新 20 篇已发布文章。 var articles []models.Article db.Where("status = ?", models.ArticlePublished). Preload("Author"). @@ -82,7 +81,7 @@ func RSSFeed(db *gorm.DB) gin.HandlerFunc { Limit(20). Find(&articles) - // Build channel metadata. + // 构建 channel 元数据。 channel := &Channel{ Title: siteSetting.LogoText(langStr), Link: baseURL, @@ -91,12 +90,12 @@ func RSSFeed(db *gorm.DB) gin.HandlerFunc { Items: make([]Item, 0, len(articles)), } - // Set lastBuildDate to the most recent article's publish date. + // 将 lastBuildDate 设置为最新文章的发布日期。 if len(articles) > 0 && articles[0].PublishedAt != nil { channel.LastBuildDate = formatRSSTime(*articles[0].PublishedAt) } - // Convert articles to RSS items. + // 将文章转换为 RSS 条目。 for _, article := range articles { item := Item{ Title: article.Title, @@ -106,7 +105,7 @@ func RSSFeed(db *gorm.DB) gin.HandlerFunc { GUID: fmt.Sprintf("%s/article/%s", baseURL, article.Slug), } - // Add author information. + // 添加作者信息。 if article.Author.DisplayName != "" { item.Author = article.Author.DisplayName } else { @@ -116,19 +115,19 @@ func RSSFeed(db *gorm.DB) gin.HandlerFunc { channel.Items = append(channel.Items, item) } - // Build the RSS feed. + // 构建 RSS feed。 feed := &RSS{ Version: "2.0", Channel: channel, } - // Set the correct content type and return XML. + // 设置正确的内容类型并返回 XML。 c.Header("Content-Type", "application/rss+xml; charset=utf-8") c.XML(http.StatusOK, feed) } } -// getRSSLanguage converts the internal language code to RSS language format. +// getRSSLanguage 将内部语言代码转换为 RSS 语言格式。 func getRSSLanguage(lang string) string { switch lang { case "zh": @@ -140,12 +139,12 @@ func getRSSLanguage(lang string) string { } } -// formatRSSTime formats a time.Time to RFC1123Z format required by RSS 2.0. +// formatRSSTime 将 time.Time 格式化为 RSS 2.0 要求的 RFC1123Z 格式。 func formatRSSTime(t time.Time) string { return t.Format(time.RFC1123Z) } -// getArticlePubDate returns the article's publish date, falling back to created date. +// getArticlePubDate 返回文章的发布日期,无则回退到创建日期。 func getArticlePubDate(article *models.Article) time.Time { if article.PublishedAt != nil { return *article.PublishedAt @@ -153,14 +152,14 @@ func getArticlePubDate(article *models.Article) time.Time { return article.CreatedAt } -// getArticleDescription returns the article description for RSS. -// Prefers the summary field; falls back to truncated content. +// getArticleDescription 返回文章用于 RSS 的描述。 +// 优先使用摘要字段;没有则回退到截断的正文。 func getArticleDescription(article *models.Article) string { if article.Summary != "" { return html.EscapeString(article.Summary) } - // Strip HTML tags and truncate content to 200 characters. + // 去除 HTML 标签并将正文截断到 200 个字符。 content := stripHTMLTags(article.Content) if len(content) > 200 { content = content[:200] + "..." @@ -168,9 +167,9 @@ func getArticleDescription(article *models.Article) string { return html.EscapeString(content) } -// stripHTMLTags removes HTML tags from a string (basic implementation). +// stripHTMLTags 从字符串中去除 HTML 标签(基础实现)。 func stripHTMLTags(s string) string { - // Remove HTML tags by finding < and > pairs. + // 通过查找 < 与 > 的配对去除 HTML 标签。 var result strings.Builder inTag := false for _, r := range s { @@ -186,7 +185,7 @@ func stripHTMLTags(s string) string { result.WriteRune(r) } } - // Clean up multiple spaces and trim. + // 清理多余空格并去除首尾空白。 cleaned := strings.Join(strings.Fields(result.String()), " ") return cleaned } diff --git a/handlers/security_test.go b/handlers/security_test.go index d67d9ea..b37ce04 100644 --- a/handlers/security_test.go +++ b/handlers/security_test.go @@ -23,8 +23,8 @@ import ( "go_blog/models" ) -// securityTestEnv wires a router that mirrors the production middleware chain -// (sessions -> CSRF -> user context) plus the routes under test. +// securityTestEnv 搭建与生产中间件链一致的路由器 +// (sessions -> CSRF -> 用户上下文),外加待测路由。 type securityTestEnv struct { router *gin.Engine db *gorm.DB @@ -50,19 +50,19 @@ func newSecurityTestEnv(t *testing.T) *securityTestEnv { storageDir := t.TempDir() - // Seed the upload policy so ValidateUpload accepts .txt files. + // 初始化上传策略,使 ValidateUpload 接受 .txt 文件。 db.Create(&models.SiteSetting{ID: 1}) db.Create(&models.CommentConfig{ID: 1, Enabled: true, AllowGuest: true}) db.Create(&models.UploadConfig{ID: 1, Enabled: true, DefaultMaxSize: 1024 * 1024, StorageDir: "attachments"}) db.Create(&models.UploadFileType{Extension: ".txt", MimeType: "text/plain", Category: models.CategoryDocument, Enabled: true}) models.LoadConfigCache(db) - // Seed users. + // 初始化用户。 mustUser(t, db, "admin", models.RoleAdmin) alice := mustUser(t, db, "alice", models.RoleAuthor) bob := mustUser(t, db, "bob", models.RoleAuthor) - // Seed one article per author. + // 每位作者初始化一篇文章。 aliceArt := models.Article{AuthorID: alice.ID, Title: "alice post", Slug: "alice-post", Content: "x", Status: models.ArticlePublished} bobArt := models.Article{AuthorID: bob.ID, Title: "bob post", Slug: "bob-post", Content: "x", Status: models.ArticlePublished} db.Create(&aliceArt) @@ -98,20 +98,20 @@ func newSecurityTestEnv(t *testing.T) *securityTestEnv { }) } - // Profile routes (avatar upload XSS-chain regression coverage, #21). + // 个人资料路由(头像上传 XSS 链回归覆盖,#21)。 profile := r.Group("/profile", middleware.AuthRequired(db)) { profile.POST("", UpdateProfile(db, storageDir)) profile.POST("/avatar", UploadAvatar(db, storageDir)) } - // Upload settings routes (dangerous-extension blacklist coverage, #21). + // 上传设置路由(危险扩展名黑名单覆盖,#21)。 adminSettings := r.Group("/admin/settings", middleware.AuthRequired(db), middleware.AdminRequired(db)) { adminSettings.POST("/upload", UploadSettingsSave(db)) } - // Admin user-management routes (SQL-injection regression coverage, #19). + // 后台用户管理路由(SQL 注入回归覆盖,#19)。 admin := r.Group("/admin", middleware.AuthRequired(db), middleware.AdminRequired(db)) { admin.POST("/users/new", UserCreate(db)) @@ -136,12 +136,12 @@ func mustUser(t *testing.T, db *gorm.DB, username, role string) models.User { return u } -// login performs the full login flow (GET the form for a CSRF token, then POST -// credentials) and returns the authenticated session cookie. +// login 执行完整登录流程(GET 表单获取 CSRF 令牌,再 POST 凭据), +// 返回认证后的会话 Cookie。 func (e *securityTestEnv) login(t *testing.T, username string) string { t.Helper() - // Anonymous GET to obtain CSRF token + session cookie. + // 匿名 GET 获取 CSRF 令牌 + 会话 Cookie。 req := httptest.NewRequest(http.MethodGet, "/login", nil) w := httptest.NewRecorder() e.router.ServeHTTP(w, req) @@ -154,7 +154,7 @@ func (e *securityTestEnv) login(t *testing.T, username string) string { } cookie := e.sessionCookie(w) - // POST credentials with the token. + // 携带令牌 POST 凭据。 form := url.Values{} form.Set("username", username) form.Set("password", "pw-"+username) @@ -176,10 +176,9 @@ func (e *securityTestEnv) login(t *testing.T, username string) string { return authCookie } -// sessionCookie extracts the blog_session cookie from a recorder. When -// several Set-Cookie headers are present (e.g. middleware and handler both -// save the session), the LAST one is the effective value - browsers apply -// them in order. +// sessionCookie 从记录器中提取 blog_session Cookie。当存在多个 Set-Cookie +// 头时(例如中间件和处理器都保存了会话),最后一个才是生效值—— +// 浏览器按顺序应用它们。 func (e *securityTestEnv) sessionCookie(w *httptest.ResponseRecorder) string { cookie := "" for _, c := range w.Result().Cookies() { @@ -219,7 +218,7 @@ func (e *securityTestEnv) upload(t *testing.T, cookie, csrfToken, articleID stri return e.do(http.MethodPost, "/my/articles/attachments", cookie, strings.NewReader(buf.String()), mw.FormDataContentType()) } -// csrfTokenFor fetches a fresh CSRF token for an authenticated session. +// csrfTokenFor 为已认证会话获取一个全新的 CSRF 令牌。 func (e *securityTestEnv) csrfTokenFor(t *testing.T, cookie string) string { t.Helper() w := e.do(http.MethodGet, "/login", cookie, nil, "") @@ -236,7 +235,7 @@ func (e *securityTestEnv) csrfTokenFor(t *testing.T, cookie string) string { func TestLoginRotatesSession(t *testing.T) { e := newSecurityTestEnv(t) - // Obtain an anonymous session (pre-login cookie). + // 获取匿名会话(登录前的 Cookie)。 req := httptest.NewRequest(http.MethodGet, "/login", nil) w := httptest.NewRecorder() e.router.ServeHTTP(w, req) @@ -250,13 +249,13 @@ func TestLoginRotatesSession(t *testing.T) { t.Fatal("session cookie was not rotated on login (fixation risk)") } - // The authenticated session works. + // 认证会话可正常工作。 w = e.do(http.MethodGet, "/my/whoami", authCookie, nil, "") if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "uid=") { t.Fatalf("authenticated request failed: status=%d body=%s", w.Code, w.Body.String()) } - // The old (fixated) session must NOT carry the login. + // 旧的(被固定的)会话不得携带登录状态。 w = e.do(http.MethodGet, "/my/whoami", preLoginCookie, nil, "") if w.Code != http.StatusFound { t.Fatalf("pre-login session still authenticated after login: status=%d", w.Code) @@ -271,13 +270,13 @@ func TestAttachmentListRequiresOwnership(t *testing.T) { alice := e.login(t, "alice") - // Own article: allowed. + // 自己的文章:允许。 w := e.do(http.MethodGet, fmt.Sprintf("/my/articles/%d/attachments", aliceArt.ID), alice, nil, "") if w.Code != http.StatusOK { t.Fatalf("list own attachments: status = %d, want 200", w.Code) } - // Someone else's article: forbidden. + // 他人的文章:禁止。 w = e.do(http.MethodGet, fmt.Sprintf("/my/articles/%d/attachments", bobArt.ID), alice, nil, "") if w.Code != http.StatusForbidden { t.Fatalf("list other user's attachments: status = %d, want 403", w.Code) @@ -292,13 +291,13 @@ func TestAttachmentUploadRequiresOwnership(t *testing.T) { alice := e.login(t, "alice") token := e.csrfTokenFor(t, alice) - // Upload pending (article_id=0 + session token): allowed. + // 上传待绑定附件(article_id=0 + 会话令牌):允许。 w := e.upload(t, alice, token, "") if w.Code != http.StatusOK { t.Fatalf("pending upload: status = %d, body %s", w.Code, w.Body.String()) } - // Upload to someone else's article: forbidden. + // 上传到他人文章:禁止。 w = e.upload(t, alice, token, fmt.Sprint(bobArt.ID)) if w.Code != http.StatusForbidden { t.Fatalf("upload to other user's article: status = %d, want 403", w.Code) @@ -314,13 +313,13 @@ func TestAttachmentDeleteRequiresOwnership(t *testing.T) { alice := e.login(t, "alice") token := e.csrfTokenFor(t, alice) - // Alice uploads an attachment to her own article. + // Alice 上传附件到自己的文章。 w := e.upload(t, alice, token, fmt.Sprint(aliceArt.ID)) if w.Code != http.StatusOK { t.Fatalf("upload: status = %d, body %s", w.Code, w.Body.String()) } - // Bob uploads an attachment to his own article. + // Bob 上传附件到自己的文章。 bob := e.login(t, "bob") bobToken := e.csrfTokenFor(t, bob) w = e.upload(t, bob, bobToken, fmt.Sprint(bobArt.ID)) @@ -333,7 +332,7 @@ func TestAttachmentDeleteRequiresOwnership(t *testing.T) { t.Fatalf("bob attachment not found: %v", err) } - // Alice cannot delete Bob's attachment. + // Alice 不能删除 Bob 的附件。 form := url.Values{} form.Set("_csrf", token) w = e.do(http.MethodPost, fmt.Sprintf("/my/articles/attachments/%d/delete", bobAtt.ID), alice, @@ -342,7 +341,7 @@ func TestAttachmentDeleteRequiresOwnership(t *testing.T) { t.Fatalf("delete other user's attachment: status = %d, want 403", w.Code) } - // Bob can delete his own. + // Bob 可以删除自己的附件。 form.Set("_csrf", bobToken) w = e.do(http.MethodPost, fmt.Sprintf("/my/articles/attachments/%d/delete", bobAtt.ID), bob, strings.NewReader(form.Encode()), "application/x-www-form-urlencoded") @@ -350,7 +349,7 @@ func TestAttachmentDeleteRequiresOwnership(t *testing.T) { t.Fatalf("delete own attachment: status = %d, want 200 (body %s)", w.Code, w.Body.String()) } - // Bob's attachment record should be gone. + // Bob 的附件记录应该已删除。 var count int64 e.db.Model(&models.Attachment{}).Where("id = ?", bobAtt.ID).Count(&count) if count != 0 { @@ -362,7 +361,7 @@ func TestAttachmentCSRFEnforced(t *testing.T) { e := newSecurityTestEnv(t) alice := e.login(t, "alice") - // POST without a CSRF token must be rejected before reaching the handler. + // 未携带 CSRF 令牌的 POST 必须在到达处理器前被拒绝。 var buf strings.Builder mw := multipart.NewWriter(&buf) fw, _ := mw.CreateFormFile("file", "hello.txt") @@ -382,7 +381,7 @@ func TestAttachmentAdminOverride(t *testing.T) { admin := e.login(t, "admin") token := e.csrfTokenFor(t, admin) - // Admin may list and upload to any article. + // 管理员可以列出和上传到任意文章。 w := e.do(http.MethodGet, fmt.Sprintf("/my/articles/%d/attachments", bobArt.ID), admin, nil, "") if w.Code != http.StatusOK { t.Fatalf("admin list: status = %d, want 200", w.Code) @@ -392,7 +391,7 @@ func TestAttachmentAdminOverride(t *testing.T) { t.Fatalf("admin upload: status = %d, want 200 (body %s)", w.Code, w.Body.String()) } - // Clean up files created during the test (best effort). + // 清理测试期间创建的文件(尽力而为)。 entries, _ := os.ReadDir(filepath.Join(e.storageDir, "attachments")) for _, ent := range entries { os.Remove(filepath.Join(e.storageDir, "attachments", ent.Name())) @@ -409,9 +408,8 @@ func TestAdminUserRoutesRejectNonNumericIDs(t *testing.T) { t.Fatalf("alice not found: %v", err) } - // Malicious :id values. Before the #19 fix, GORM interpolated a - // non-numeric single string cond into the WHERE clause raw - // (e.g. WHERE 1 OR 1=1). + // 恶意的 :id 值。在 #19 修复前,GORM 会把非数值的单一字符串条件 + // 原样插值进 WHERE 子句(例如 WHERE 1 OR 1=1)。 ids := []string{ "1 OR 1=1", "1;--", @@ -421,7 +419,7 @@ func TestAdminUserRoutesRejectNonNumericIDs(t *testing.T) { } for _, id := range ids { - // GET edit page must redirect instead of rendering a matched row. + // GET 编辑页必须重定向而非渲染匹配到的行。 w := e.do(http.MethodGet, "/admin/users/"+url.PathEscape(id)+"/edit", admin, nil, "") if w.Code != http.StatusFound { t.Fatalf("GET edit with id %q: status = %d, want 302", id, w.Code) @@ -430,7 +428,7 @@ func TestAdminUserRoutesRejectNonNumericIDs(t *testing.T) { t.Fatalf("GET edit with id %q: location = %q, want /admin/users", id, loc) } - // POST update must not modify anything (attempt role escalation). + // POST 更新不得修改任何内容(尝试提权)。 form := url.Values{} form.Set("_csrf", token) form.Set("role", models.RoleAdmin) @@ -442,7 +440,7 @@ func TestAdminUserRoutesRejectNonNumericIDs(t *testing.T) { t.Fatalf("POST edit with id %q: status = %d, location = %q", id, w.Code, w.Header().Get("Location")) } - // POST delete must not delete anything. + // POST 删除不得删除任何内容。 form = url.Values{} form.Set("_csrf", token) w = e.do(http.MethodPost, "/admin/users/"+url.PathEscape(id)+"/delete", admin, @@ -452,7 +450,7 @@ func TestAdminUserRoutesRejectNonNumericIDs(t *testing.T) { } } - // No user was modified or removed by any of the payloads. + // 任何载荷都不应修改或删除用户。 var count int64 e.db.Model(&models.User{}).Count(&count) if count != 3 { @@ -466,7 +464,7 @@ func TestAdminUserRoutesRejectNonNumericIDs(t *testing.T) { t.Fatalf("alice modified via id injection: role=%q display=%q", check.Role, check.DisplayName) } - // Sanity: a valid numeric id still works. + // 健全性检查:合法的数值 id 仍然有效。 w := e.do(http.MethodGet, fmt.Sprintf("/admin/users/%d/edit", alice.ID), admin, nil, "") if w.Code != http.StatusOK { t.Fatalf("GET edit with valid id: status = %d, want 200", w.Code) diff --git a/handlers/session_upload_security_test.go b/handlers/session_upload_security_test.go index fd1ca8e..6c8b2be 100644 --- a/handlers/session_upload_security_test.go +++ b/handlers/session_upload_security_test.go @@ -18,8 +18,8 @@ import ( "go_blog/models" ) -// seedUploadType inserts an upload file-type row directly in the DB and -// reloads the config cache, simulating policy rows created before a fix. +// seedUploadType 直接在数据库中插入上传文件类型行并重载配置缓存, +// 模拟修复之前创建的策略行。 func seedUploadType(t *testing.T, e *securityTestEnv, ext, category string) { t.Helper() if err := e.db.Create(&models.UploadFileType{ @@ -30,7 +30,7 @@ func seedUploadType(t *testing.T, e *securityTestEnv, ext, category string) { models.LoadConfigCache(e.db) } -// pngBytes renders a small valid PNG. +// pngBytes 生成一张小的合法 PNG。 func pngBytes(t *testing.T) []byte { t.Helper() img := image.NewRGBA(image.Rect(0, 0, 8, 8)) @@ -46,7 +46,7 @@ func pngBytes(t *testing.T) []byte { return buf.Bytes() } -// multipartUpload posts a multipart form carrying one file field. +// multipartUpload 提交携带一个文件字段的 multipart 表单。 func (e *securityTestEnv) multipartUpload(t *testing.T, path, cookie, csrfToken, fieldName, filename string, content []byte, fields map[string]string) *httptest.ResponseRecorder { t.Helper() var buf bytes.Buffer @@ -70,13 +70,13 @@ func reloadAlice(t *testing.T, e *securityTestEnv) models.User { return alice } -// --- #20: stale sessions of disabled / locked / deleted users --- +// --- #20:已禁用 / 已锁定 / 已删除用户的过期会话 --- func TestDisabledUserSessionInvalidated(t *testing.T) { e := newSecurityTestEnv(t) aliceCookie := e.login(t, "alice") - // Sanity: the session works while the account is normal. + // 健全性检查:账户正常时会话有效。 if w := e.do(http.MethodGet, "/my/whoami", aliceCookie, nil, ""); w.Code != http.StatusOK { t.Fatalf("pre-disable /my/whoami: status=%d", w.Code) } @@ -94,7 +94,7 @@ func TestDisabledUserSessionInvalidated(t *testing.T) { t.Fatalf("%s user with stale cookie: status=%d location=%q, want 302 /login", tc.name, w.Code, w.Header().Get("Location")) } - // Restore so the next case starts from a normal account again. + // 恢复状态,使下一个用例重新从正常账户开始。 e.db.Model(&models.User{}).Where("username = ?", "alice").Update("status", models.StatusNormal) } } @@ -116,7 +116,7 @@ func TestDisabledUserCommentsRequireApproval(t *testing.T) { aliceCookie := e.login(t, "alice") token := e.csrfTokenFor(t, aliceCookie) - // Guests must pass moderation for this scenario. + // 该场景下访客必须通过审核。 e.db.Model(&models.CommentConfig{}).Where("id = ?", 1).Update("guest_require_approval", true) models.LoadConfigCache(e.db) @@ -139,21 +139,20 @@ func TestDisabledUserCommentsRequireApproval(t *testing.T) { return cm } - // Control: while alice is a normal user her comment is auto-approved. + // 对照组:alice 为正常用户时,其评论自动通过。 if cm := postComment(); cm.Status != models.CommentApproved { t.Fatalf("normal user comment status=%d, want approved", cm.Status) } - // After being locked, her stale session no longer grants auto-approval: - // SetUserContext reports her as logged out, so the comment follows the - // guest moderation policy. + // 被锁定后,她的过期会话不再赋予自动通过权限: + // SetUserContext 将她视为未登录,因此评论遵循访客审核策略。 e.db.Model(&models.User{}).Where("username = ?", "alice").Update("status", models.StatusLocked) if cm := postComment(); cm.Status != models.CommentPending { t.Fatalf("locked user comment status=%d, want pending", cm.Status) } } -// --- #21: avatar upload XSS chain --- +// --- #21:头像上传 XSS 链 --- func TestAddUploadFileTypeRejectsDangerousExtensions(t *testing.T) { e := newSecurityTestEnv(t) @@ -183,7 +182,7 @@ func TestAddUploadFileTypeRejectsDangerousExtensions(t *testing.T) { } } - // Control: a benign extension is still accepted. + // 对照组:良性的扩展名仍然被接受。 form := url.Values{} form.Set("_csrf", token) form.Set("action", "add_type") @@ -208,30 +207,29 @@ func TestUploadAvatarRejectsNonImage(t *testing.T) { htmlPayload := []byte("") - // Simulate a pre-existing dangerous type configured before the blacklist - // (defense in depth): the category check must reject it. + // 模拟黑名单之前已配置的危险类型(纵深防御):类别检查必须拒绝它。 seedUploadType(t, e, ".html", models.CategoryOther) w := e.multipartUpload(t, "/profile/avatar", aliceCookie, token, "avatar", "evil.html", htmlPayload, nil) if w.Code != http.StatusBadRequest { t.Fatalf("upload .html (other category): status=%d body=%s, want 400", w.Code, w.Body.String()) } - // Even a legacy .html row miscategorized as "image" is stopped by the - // decode step — raw bytes are never stored anymore. + // 即使是误分类为 "image" 的旧版 .html 行,也会被解码步骤拦下—— + // 不会再存储原始字节。 seedUploadType(t, e, ".htm", models.CategoryImage) w = e.multipartUpload(t, "/profile/avatar", aliceCookie, token, "avatar", "evil.htm", htmlPayload, nil) if w.Code != http.StatusBadRequest { t.Fatalf("upload .htm (image category): status=%d body=%s, want 400", w.Code, w.Body.String()) } - // HTML disguised behind a whitelisted image extension is likewise rejected. + // 伪装在白名单图片扩展名之后的 HTML 同样被拒绝。 seedUploadType(t, e, ".jpg", models.CategoryImage) w = e.multipartUpload(t, "/profile/avatar", aliceCookie, token, "avatar", "x.jpg", htmlPayload, nil) if w.Code != http.StatusBadRequest { t.Fatalf("upload html as .jpg: status=%d body=%s, want 400", w.Code, w.Body.String()) } - // Nothing was stored and the avatar is unchanged. + // 未存储任何内容,头像保持不变。 if alice := reloadAlice(t, e); alice.Avatar != "" { t.Fatalf("avatar unexpectedly set to %q", alice.Avatar) } @@ -243,7 +241,7 @@ func TestUploadAvatarRejectsNonImage(t *testing.T) { t.Fatal("avatar directory should not contain any file after rejected uploads") } - // A real image is accepted, processed to a normalized JPEG. + // 真实图片被接受,并处理为规范化 JPEG。 seedUploadType(t, e, ".png", models.CategoryImage) w = e.multipartUpload(t, "/profile/avatar", aliceCookie, token, "avatar", "me.png", pngBytes(t), nil) if w.Code != http.StatusOK { @@ -264,10 +262,10 @@ func TestUpdateProfileAvatarRejectsNonImage(t *testing.T) { token := e.csrfTokenFor(t, aliceCookie) seedUploadType(t, e, ".png", models.CategoryImage) - seedUploadType(t, e, ".html", models.CategoryImage) // legacy miscategorized row + seedUploadType(t, e, ".html", models.CategoryImage) // 旧版误分类的行 - // HTML behind a whitelisted extension must be rejected with the upload - // error redirect, and nothing may be written to avatars/. + // 白名单扩展名背后的 HTML 必须以上传错误重定向被拒绝, + // 且不得向 avatars/ 写入任何内容。 htmlPayload := []byte("") w := e.multipartUpload(t, "/profile", aliceCookie, token, "avatar", "evil.html", htmlPayload, map[string]string{"display_name": "alice"}) @@ -279,7 +277,7 @@ func TestUpdateProfileAvatarRejectsNonImage(t *testing.T) { t.Fatalf("avatar unexpectedly set to %q", alice.Avatar) } - // A real image goes through processing and is saved as JPEG. + // 真实图片经过处理并以 JPEG 保存。 w = e.multipartUpload(t, "/profile", aliceCookie, token, "avatar", "me.png", pngBytes(t), map[string]string{"display_name": "alice"}) if w.Code != http.StatusFound || !strings.Contains(w.Header().Get("Location"), "saved=1") { diff --git a/handlers/settings.go b/handlers/settings.go index a61dde8..7c188e9 100644 --- a/handlers/settings.go +++ b/handlers/settings.go @@ -16,9 +16,8 @@ import ( "go_blog/models" ) -// mbToBytes converts a megabyte count (string) to bytes. Returns 0 on parse -// failure. Values <= 0 are treated as 0 (meaning "use default" for per-type -// limits). +// mbToBytes 将兆字节数(字符串)转换为字节。解析失败时返回 0。 +// <= 0 的值视为 0(对按类型限制来说表示"使用默认值")。 func mbToBytes(s string) int64 { s = strings.TrimSpace(s) if s == "" { @@ -31,13 +30,13 @@ func mbToBytes(s string) int64 { return int64(n * 1024 * 1024) } -// bytesToMB renders a byte count as megabytes (one decimal) for form display. +// bytesToMB 将字节数渲染为兆字节(一位小数)用于表单展示。 func bytesToMB(b int64) string { return fmt.Sprintf("%.1f", float64(b)/float64(1024*1024)) } -// userIDFromSession extracts the logged-in user's ID, or 0 if absent. It -// defends against int/uint/int64/float64 storage in the session. +// userIDFromSession 提取已登录用户的 ID,不存在时为 0。 +// 它兼容会话中 int/uint/int64/float64 的存储类型。 func userIDFromSession(c *gin.Context) uint { session := sessions.Default(c) userID := session.Get("user_id") @@ -57,9 +56,9 @@ func userIDFromSession(c *gin.Context) uint { return 0 } -// ---------------- Site settings ---------------- +// ---------------- 站点设置 ---------------- -// SiteSettingsPage renders the site display settings form. +// SiteSettingsPage 渲染站点显示设置表单。 func SiteSettingsPage(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { tr := getTr(c) @@ -71,8 +70,8 @@ func SiteSettingsPage(db *gorm.DB) gin.HandlerFunc { data := DefaultData(c) data["Title"] = tr["settings_site_title"] data["Site"] = s - // Pre-compute derived values so the template never invokes methods on - // an interface{}-wrapped struct (which Go templates cannot resolve). + // 预计算派生值,使模板绝不调用包装为 interface{} 的结构体上的方法 + //(Go 模板无法解析此类调用)。 data["SiteLogoIsURL"] = s.LogoIsURL() data["SiteFaviconIsURL"] = s.FaviconIsURL() if msg := c.Query("saved"); msg == "1" { @@ -82,7 +81,7 @@ func SiteSettingsPage(db *gorm.DB) gin.HandlerFunc { } } -// SiteSettingsSave handles logo upload and text fields for site settings. +// SiteSettingsSave 处理站点设置的徽标上传与文本字段。 func SiteSettingsSave(db *gorm.DB, storagePath string) gin.HandlerFunc { return func(c *gin.Context) { var s models.SiteSetting @@ -100,14 +99,14 @@ func SiteSettingsSave(db *gorm.DB, storagePath string) gin.HandlerFunc { s.HomeSubtitleEn = strings.TrimSpace(c.PostForm("home_subtitle_en")) s.FooterTextZh = strings.TrimSpace(c.PostForm("footer_text_zh")) s.FooterTextEn = strings.TrimSpace(c.PostForm("footer_text_en")) - // SECURITY_TODO #16: canonical feed/site URL; RSS uses this instead of - // the request Host to avoid Host-header pollution. + // SECURITY_TODO #16:规范化的 feed/站点 URL;RSS 使用它而非请求的 + // Host,以避免 Host 头污染。 s.SiteURL = strings.TrimSpace(c.PostForm("site_url")) s.AllowRegistration = c.PostForm("allow_registration") == "1" s.UpdatedBy = userIDFromSession(c) - // Favicon upload (optional). A favicon_url form field takes precedence over an - // uploaded file, so admins can set either a local file or an external link. + // Favicon 上传(可选)。favicon_url 表单字段优先于上传文件, + // 因此管理员可以设置本地文件或外链。 if faviconURL := strings.TrimSpace(c.PostForm("favicon_url")); faviconURL != "" { s.Favicon = faviconURL } else if file, header, err := c.Request.FormFile("favicon"); err == nil { @@ -119,7 +118,7 @@ func SiteSettingsSave(db *gorm.DB, storagePath string) gin.HandlerFunc { } logoDir := filepath.Join(storagePath, "logos") os.MkdirAll(logoDir, 0755) - // Remove the previous local favicon (skip external URLs). + // 删除之前的本地 favicon(跳过外部 URL)。 if s.Favicon != "" && !s.FaviconIsURL() { os.Remove(filepath.Join(logoDir, s.Favicon)) } @@ -138,7 +137,7 @@ func SiteSettingsSave(db *gorm.DB, storagePath string) gin.HandlerFunc { s.Favicon = savedName } - // Remove favicon entirely if requested. + // 若请求删除,则完全移除 favicon。 if c.PostForm("favicon_clear") == "1" { if s.Favicon != "" && !s.FaviconIsURL() { os.Remove(filepath.Join(storagePath, "logos", s.Favicon)) @@ -146,8 +145,8 @@ func SiteSettingsSave(db *gorm.DB, storagePath string) gin.HandlerFunc { s.Favicon = "" } - // Logo upload (optional). A logo_url form field takes precedence over an - // uploaded file, so admins can set either a local file or an external link. + // 徽标上传(可选)。logo_url 表单字段优先于上传文件, + // 因此管理员可以设置本地文件或外链。 if logoURL := strings.TrimSpace(c.PostForm("logo_url")); logoURL != "" { s.Logo = logoURL } else if file, header, err := c.Request.FormFile("logo"); err == nil { @@ -159,7 +158,7 @@ func SiteSettingsSave(db *gorm.DB, storagePath string) gin.HandlerFunc { } logoDir := filepath.Join(storagePath, "logos") os.MkdirAll(logoDir, 0755) - // Remove the previous local logo (skip external URLs). + // 删除之前的本地徽标(跳过外部 URL)。 if s.Logo != "" && !s.LogoIsURL() { os.Remove(filepath.Join(logoDir, s.Logo)) } @@ -178,7 +177,7 @@ func SiteSettingsSave(db *gorm.DB, storagePath string) gin.HandlerFunc { s.Logo = savedName } - // Remove logo entirely if requested. + // 若请求删除,则完全移除徽标。 if c.PostForm("logo_clear") == "1" { if s.Logo != "" && !s.LogoIsURL() { os.Remove(filepath.Join(storagePath, "logos", s.Logo)) @@ -195,16 +194,16 @@ func SiteSettingsSave(db *gorm.DB, storagePath string) gin.HandlerFunc { } } -// ---------------- Upload settings ---------------- +// ---------------- 上传设置 ---------------- -// fileTypeView augments an UploadFileType with a pre-rendered max-size MB -// string for the template (avoids needing a template FuncMap for division). +// fileTypeView 为 UploadFileType 附加预渲染的最大大小 MB 字符串供模板使用 +//(避免为除法引入模板 FuncMap)。 type fileTypeView struct { models.UploadFileType MaxSizeMB string } -// UploadSettingsPage renders the upload policy + file-type management page. +// UploadSettingsPage 渲染上传策略 + 文件类型管理页面。 func UploadSettingsPage(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { tr := getTr(c) @@ -238,15 +237,15 @@ func UploadSettingsPage(db *gorm.DB) gin.HandlerFunc { } } -// UploadSettingsSave dispatches upload-config and file-type actions. +// UploadSettingsSave 分发上传配置与文件类型操作。 func UploadSettingsSave(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { redirect := "/admin/settings/upload?saved=1" switch c.PostForm("action") { case "save_config": if !saveUploadConfig(db, c) { - // SECURITY (#22): an illegal storage_dir was rejected; - // report and keep the previous value. + // SECURITY (#22):非法的 storage_dir 已被拒绝; + // 报告错误并保留原值。 redirect = "/admin/settings/upload?error=illegal_dir" } case "add_type": @@ -265,9 +264,9 @@ func UploadSettingsSave(db *gorm.DB) gin.HandlerFunc { } } -// safeStorageDirName reports whether s is a single safe path segment: no -// separators, no traversal, no absolute paths. storage_dir must stay inside -// the storage root (SECURITY_TODO #22). +// safeStorageDirName 报告 s 是否为单一安全路径段:无分隔符、 +// 无路径穿越、非绝对路径。storage_dir 必须保持在存储根目录内 +//(SECURITY_TODO #22)。 func safeStorageDirName(s string) bool { if s == "" { return false @@ -282,10 +281,9 @@ func safeStorageDirName(s string) bool { return true } -// saveUploadConfig persists the upload policy. It returns false and leaves -// the stored value untouched when the submitted storage_dir is unsafe -// (SECURITY_TODO #22), so a misconfigured admin cannot redirect attachment -// writes outside the storage root. +// saveUploadConfig 持久化上传策略。当提交的 storage_dir 不安全时 +//(SECURITY_TODO #22),返回 false 且不修改存储的值, +// 以免配置错误的管理员将附件写入存储根目录之外。 func saveUploadConfig(db *gorm.DB, c *gin.Context) bool { var u models.UploadConfig if err := db.First(&u, 1).Error; err != nil { @@ -307,17 +305,15 @@ func saveUploadConfig(db *gorm.DB, c *gin.Context) bool { return true } -// dangerousUploadExtensions are never accepted as upload file types: files of -// these extensions would be served same-origin from /uploads and can execute -// active content (HTML/SVG/JS) in the site's origin, giving any logged-in -// uploader a stored-XSS primitive (SECURITY_TODO #21). +// dangerousUploadExtensions 永不接受为上传文件类型:这些扩展名的文件将从 +// /uploads 同源提供,可在站点源上执行活动内容(HTML/SVG/JS), +// 使任何已登录的上传者获得存储型 XSS 能力(SECURITY_TODO #21)。 var dangerousUploadExtensions = map[string]bool{ ".html": true, ".htm": true, ".xhtml": true, ".xht": true, ".svg": true, ".xml": true, ".js": true, ".mjs": true, } -// addUploadFileType creates a new permitted file type. It reports whether the -// extension was rejected as dangerous. +// addUploadFileType 创建新的允许文件类型。报告扩展名是否因危险而被拒绝。 func addUploadFileType(db *gorm.DB, c *gin.Context) bool { ext := strings.ToLower(strings.TrimSpace(c.PostForm("extension"))) if ext == "" { @@ -340,7 +336,7 @@ func addUploadFileType(db *gorm.DB, c *gin.Context) bool { if t.Category == "" { t.Category = models.CategoryOther } - // Ignore duplicate-extension errors silently. + // 静默忽略扩展名重复的错误。 db.Where("extension = ?", t.Extension).FirstOrCreate(&t) return false } @@ -370,9 +366,9 @@ func deleteUploadFileType(db *gorm.DB, c *gin.Context) { db.Delete(&models.UploadFileType{}, id) } -// ---------------- Download settings ---------------- +// ---------------- 下载设置 ---------------- -// DownloadSettingsPage renders the download base-URL management page. +// DownloadSettingsPage 渲染下载基础 URL 管理页面。 func DownloadSettingsPage(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { tr := getTr(c) @@ -389,7 +385,7 @@ func DownloadSettingsPage(db *gorm.DB) gin.HandlerFunc { } } -// DownloadSettingsSave dispatches download base-URL actions. +// DownloadSettingsSave 分发下载基础 URL 操作。 func DownloadSettingsSave(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { switch c.PostForm("action") { @@ -435,7 +431,7 @@ func toggleDownloadBaseURL(db *gorm.DB, c *gin.Context) { func defaultDownloadBaseURL(db *gorm.DB, c *gin.Context) { id, _ := strconv.Atoi(c.PostForm("id")) - // Only one default at a time. + // 同一时间只能有一个默认项。 db.Model(&models.DownloadBaseURL{}).Where("1=1").Update("is_default", false) db.Model(&models.DownloadBaseURL{}).Where("id = ?", id).Update("is_default", true) } @@ -445,9 +441,9 @@ func deleteDownloadBaseURL(db *gorm.DB, c *gin.Context) { db.Delete(&models.DownloadBaseURL{}, id) } -// ---------------- Comment settings ---------------- +// ---------------- 评论设置 ---------------- -// CommentSettingsPage renders the comment policy form. +// CommentSettingsPage 渲染评论策略表单。 func CommentSettingsPage(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { tr := getTr(c) @@ -466,8 +462,8 @@ func CommentSettingsPage(db *gorm.DB) gin.HandlerFunc { } } -// CommentSettingsSave persists the comment policy toggles and refreshes the -// in-memory cache so subsequent requests see the change. +// CommentSettingsSave 持久化评论策略开关并刷新内存缓存, +// 使后续请求能看到变更。 func CommentSettingsSave(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { var cc models.CommentConfig @@ -485,9 +481,9 @@ func CommentSettingsSave(db *gorm.DB) gin.HandlerFunc { } } -// ---------------- Navigation Links settings ---------------- +// ---------------- 导航链接设置 ---------------- -// NavLinksSettingsPage renders the navigation links management page. +// NavLinksSettingsPage 渲染导航链接管理页面。 func NavLinksSettingsPage(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { tr := getTr(c) @@ -504,7 +500,7 @@ func NavLinksSettingsPage(db *gorm.DB) gin.HandlerFunc { } } -// NavLinksSettingsSave dispatches navigation link actions. +// NavLinksSettingsSave 分发导航链接操作。 func NavLinksSettingsSave(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { switch c.PostForm("action") { diff --git a/handlers/upload_validator.go b/handlers/upload_validator.go index 9e36f9a..f185c5b 100644 --- a/handlers/upload_validator.go +++ b/handlers/upload_validator.go @@ -12,26 +12,26 @@ import ( "go_blog/models" ) -// ErrUploadsDisabled is returned when the global upload switch is off. +// ErrUploadsDisabled 在全局上传开关关闭时返回。 var ErrUploadsDisabled = errors.New("uploads are disabled") -// FileValidationError describes why an uploaded file was rejected. +// FileValidationError 描述上传文件被拒绝的原因。 type FileValidationError struct { Reason string } func (e *FileValidationError) Error() string { return e.Reason } -// FileCheck is the outcome of validating an uploaded file header. +// FileCheck 是验证上传文件头的结果。 type FileCheck struct { OK bool - Type *models.UploadFileType // matched type, nil if not found - MaxSize int64 // effective byte limit applied + Type *models.UploadFileType // 匹配的类型,未匹配时为 nil + MaxSize int64 // 生效的字节限制 } -// ValidateUpload checks a file header against the cached platform upload -// policy: master switch, extension whitelist, and per-type size limit. The -// reported MaxSize is the effective limit (per-type override, else default). +// ValidateUpload 依据缓存的平台上传策略校验文件头: +// 总开关、扩展名白名单以及按类型的单文件大小限制。 +// 报告的 MaxSize 是生效限制(按类型覆盖,否则使用默认值)。 func ValidateUpload(header *multipart.FileHeader) FileCheck { cfg := models.GetUploadConfig() @@ -55,11 +55,11 @@ func ValidateUpload(header *multipart.FileHeader) FileCheck { } } - // Extension not in the whitelist. + // 扩展名不在白名单内。 return FileCheck{OK: false, MaxSize: def} } -// formatSize renders a byte count as a human-readable string. +// formatSize 将字节数渲染为人类可读的字符串。 func formatSize(b int64) string { const unit = 1024 if b < unit { @@ -73,21 +73,20 @@ func formatSize(b int64) string { return fmt.Sprintf("%.1f %ciB", float64(b)/float64(div), "KMGTPE"[exp]) } -// contentMatchesType checks the uploaded bytes' magic bytes against the MIME -// type configured for the matched extension (SECURITY_TODO #14). It is -// deliberately lenient: empty/unknown MIME policies and unrecognizable -// content pass (the extension whitelist remains the primary gate); a file -// that claims .txt while carrying PNG bytes is rejected. +// contentMatchesType 将上传字节的魔数与匹配扩展名配置的 MIME 类型比对 +// (SECURITY_TODO #14)。它有意保持宽松:空/未知的 MIME 策略和无法识别的 +// 内容均可通过(扩展名白名单仍是主要关卡);声称是 .txt 却携带 PNG 字节 +// 的文件会被拒绝。 func contentMatchesType(t *models.UploadFileType, content []byte) bool { if t == nil || len(content) == 0 { return true } expected := strings.ToLower(strings.TrimSpace(t.MimeType)) if expected == "" || expected == "application/octet-stream" { - // No concrete policy, or the admin explicitly allows any binary. + // 无具体策略,或管理员明确允许任意二进制内容。 return true } - // Normalize away a charset parameter the admin may have copied. + // 去除管理员可能复制过来的 charset 参数。 if i := strings.Index(expected, ";"); i >= 0 { expected = strings.TrimSpace(expected[:i]) } @@ -96,8 +95,7 @@ func contentMatchesType(t *models.UploadFileType, content []byte) bool { } det := mimetype.Detect(content) if det == nil || det.String() == "" { - // Content undetectable (e.g. exotic Unicode text); header policy - // alone remains the gate. + // 内容无法识别(如特殊的 Unicode 文本);仅由头部策略把关。 return true } return det.Is(expected) diff --git a/i18n/i18n.go b/i18n/i18n.go index 762e677..0d37979 100644 --- a/i18n/i18n.go +++ b/i18n/i18n.go @@ -4,7 +4,7 @@ import ( "strings" ) -// Lang represents a supported language code. +// Lang 表示一种受支持的语言代码。 type Lang string const ( @@ -12,17 +12,17 @@ const ( ZH Lang = "zh" ) -// translations holds all UI strings keyed by language then translation key. +// translations 保存全部 UI 字符串,按语言、再按翻译键索引。 var translations = map[Lang]map[string]string{ EN: { - // Nav + // 导航 "site_title": "Go Blog", "home": "Home", "login": "Login", "dashboard": "Dashboard", "logout": "Logout", - // Home page + // 首页 "home_welcome": "Welcome to Go Blog", "home_subtitle": "A simple, fast blog engine built with Go, Gin, and Tailwind CSS.", "home_sign_in": "Sign In", @@ -37,7 +37,7 @@ var translations = map[Lang]map[string]string{ "home_post3_dsc": "This blog engine uses Go, Gin web framework, GORM, and Tailwind CSS for a modern experience.", "home_no_posts": "No published posts yet.", - // Login page + // 登录页 "login_title": "Sign In", "login_username": "Username", "login_password": "Password", @@ -50,7 +50,7 @@ var translations = map[Lang]map[string]string{ "login_no_account": "Don't have an account?", "login_register_link": "Register", - // Register page + // 注册页 "page_register": "Register", "register_title": "Create Account", "register_username": "Username", @@ -77,13 +77,13 @@ var translations = map[Lang]map[string]string{ "register_email_invalid": "Please enter a valid email address.", "register_error": "Registration failed. Please try again.", - // Settings + // 设置 "settings_allow_registration": "Allow user registration", "settings_allow_registration_hint": "When enabled, visitors can create their own accounts from the login page", "settings_site_url": "Canonical site URL (used in RSS feeds)", "settings_site_url_hint": "Used as the base URL in RSS links. Leave blank to fall back to the request Host (legacy).", - // Dashboard + // 后台 "dash_title": "Dashboard", "dash_welcome": "Welcome back,", "dash_posts": "Posts", @@ -95,17 +95,17 @@ var translations = map[Lang]map[string]string{ "dash_new_article": "New Article", "dash_page_title": "Dashboard", - // Footer + // 页脚 "footer_text": "© 2026 Go Blog. Powered by Go & Gin.", - // Page titles + // 页面标题 "page_home": "Home", "page_login": "Login", - // Language switcher + // 语言切换器 "lang_switch": "中文", - // Profile dropdown & page + // 个人中心下拉菜单 & 页面 "profile": "Profile", "admin_panel": "Admin Panel", "my_articles": "My Articles", @@ -131,7 +131,7 @@ var translations = map[Lang]map[string]string{ "gender_female": "Female", "gender_other": "Other", - // Avatar cropping + // 头像裁剪 "crop_avatar_title": "Crop Avatar", "crop_cancel": "Cancel", "crop_confirm": "Confirm", @@ -139,7 +139,7 @@ var translations = map[Lang]map[string]string{ "crop_success": "Avatar updated.", "crop_error": "Failed to upload avatar. Please try again.", - // Article creation + // 文章创建 "article_create_title": "Create Article", "article_field_title": "Title", "article_title": "Title", @@ -185,7 +185,7 @@ var translations = map[Lang]map[string]string{ "article_att_error": "Upload failed. Please try again.", "article_att_delete_confirm": "Delete this attachment?", - // Article management + // 文章管理 "article_list_title": "Articles", "my_articles_title": "My Articles", "article_edit_title": "Edit Article", @@ -202,21 +202,21 @@ var translations = map[Lang]map[string]string{ "article_col_actions": "Actions", "article_last_updated": "Last Updated", - // Tags + // 标签 "tags_title": "Tags", "article_tags": "Tags", "article_tags_hint": "Comma-separated tag names, e.g., golang, web, tutorial", "tag_filter": "Filter by tag", "tag_all": "All", - // Search + // 搜索 "search_placeholder": "Search articles...", "search_title": "Search Results", "search_results_for": "Search results for", "search_no_results": "No articles found matching your search.", "search_keyword": "Keyword", - // Settings (platform configuration) + // 平台配置设置 "settings_nav": "Platform Settings", "settings_saved": "Settings saved.", "settings_site_title": "Site Settings", @@ -290,7 +290,7 @@ var translations = map[Lang]map[string]string{ "cat_video": "Video", "cat_other": "Other", - // Comments + // 评论 "comments_title": "Comments", "comments_count": "%d Comments", "comments_empty": "No comments yet. Be the first to comment.", @@ -330,7 +330,7 @@ var translations = map[Lang]map[string]string{ "comments_markdown_link": "[text](url)", "comments_markdown_quote": "> quote", - // Admin: comments + // 后台:评论 "admin_comments": "Comments", "comment_manage": "Manage Comments", "admin_comments_title": "Comments", @@ -353,7 +353,7 @@ var translations = map[Lang]map[string]string{ "comment_rejected": "Comment rejected.", "comment_deleted": "Comment deleted.", - // Admin: comment settings + // 后台:评论设置 "comment_settings_title": "Comment Settings", "comment_settings_desc": "Control whether comments are enabled and how they are moderated.", "comment_settings_enabled": "Enable comments", @@ -362,7 +362,7 @@ var translations = map[Lang]map[string]string{ "comment_settings_use_gravatar": "Use Gravatar avatars", "comment_settings_use_gravatar_hint": "When off, avatars show the author's initial on a colored background.", - // Admin: user management + // 后台:用户管理 "admin_users": "Users", "admin_users_title": "Users", "user_list_title": "User Management", @@ -406,7 +406,7 @@ var translations = map[Lang]map[string]string{ "user_cannot_remove_last_admin": "Cannot remove the last administrator.", "dash_user_mgmt": "Manage Users", - // Analytics + // 阅读统计 "analytics_views_title": "Reading Analytics", "analytics_views_desc": "Track article views, identify unique visitors and detect bots.", "analytics_stats_total": "Total Views", @@ -874,14 +874,14 @@ var translations = map[Lang]map[string]string{ }, } -// T returns a copy of the translation map for the given language. -// Falls back to English if the language is not supported. +// T 返回指定语言的翻译映射副本。 +// 若语言不受支持则回退到英语。 func T(l Lang) map[string]string { t, ok := translations[l] if !ok { t = translations[EN] } - // Return a shallow copy so callers cannot mutate the original map values. + // 返回浅拷贝,使调用方无法修改原始映射的值。 out := make(map[string]string, len(t)) for k, v := range t { out[k] = v @@ -889,23 +889,23 @@ func T(l Lang) map[string]string { return out } -// DetectLang parses the Accept-Language header and returns the best matching -// supported language. Returns EN for unsupported languages. +// DetectLang 解析 Accept-Language 请求头,返回最匹配的受支持语言。 +// 对不受支持的语言返回 EN。 func DetectLang(acceptHeader string) Lang { if acceptHeader == "" { return EN } - // Accept-Language format: "zh-CN,zh;q=0.9,en;q=0.8,en-US;q=0.7" - // Split by comma, then extract primary language from each entry. + // Accept-Language 格式:"zh-CN,zh;q=0.9,en;q=0.8,en-US;q=0.7" + // 按逗号拆分,然后从每个条目中提取主要语言。 entries := strings.Split(acceptHeader, ",") for _, entry := range entries { - // Trim spaces and remove quality values. + // 去除空格并移除质量(q)值。 entry = strings.TrimSpace(entry) if idx := strings.Index(entry, ";"); idx != -1 { entry = entry[:idx] } - // Extract primary language (before any "-" subtag). + // 提取主要语言(任何 "-" 子标签之前的部分)。 primary := strings.ToLower(entry) if idx := strings.Index(primary, "-"); idx != -1 { primary = primary[:idx] diff --git a/main.go b/main.go index 1e33182..815eba8 100644 --- a/main.go +++ b/main.go @@ -23,77 +23,73 @@ import ( "go_blog/models" ) -// staticFiles embeds the static assets (Markdown CSS/JS) into the binary so a -// deployment only needs to replace the executable — no separate static -// directory has to be copied to the server. +// staticFiles 将静态资源(Markdown CSS/JS)嵌入二进制文件, +// 使部署只需替换可执行文件——无需向服务器复制独立的静态目录。 // //go:embed static var staticFiles embed.FS func main() { - // 0. Parse command-line flags. + // 0. 解析命令行参数。 configFlag := flag.String("config", "", "path to config file (default: OS-aware path)") flag.Parse() - // 1. Load configuration (auto-creates if missing). + // 1. 加载配置(不存在时自动创建)。 cfg := config.LoadConfig(*configFlag) - // 2. Initialize the database (auto-migrates, seeds admin). + // 2. 初始化数据库(自动迁移、初始化管理员)。 db := models.InitDB(cfg) - // 2b. Warm the platform configuration cache from the database. + // 2b. 从数据库预热平台配置缓存。 models.LoadConfigCache(db) - // 3. Create session store (cookie-based). + // 3. 创建会话存储(基于 Cookie)。 store := cookie.NewStore([]byte(cfg.Secret)) - // Login rate limiter (SECURITY_TODO #10): per IP+username failures, a - // libcurl/wordlist attacker cannot hammer the login endpoint. + // 登录速率限制器(SECURITY_TODO #10):按 IP+用户名计数失败次数, + // 使 libcurl/字典攻击者无法猛攻登录端点。 loginLimiter := handlers.NewLoginLimiter() store.Options(sessions.Options{ Path: "/", - MaxAge: 86400, // 24 hours - HttpOnly: true, // prevent XSS access - SameSite: http.SameSiteLaxMode, // CSRF defense-in-depth; token check is the primary control - // Secure is set per request (over HTTPS only) in the middleware below. + MaxAge: 86400, // 24 小时 + HttpOnly: true, // 防止 XSS 访问 + SameSite: http.SameSiteLaxMode, // CSRF 纵深防御;令牌校验是主控措施 + // Secure 在下方的中间件中按请求设置(仅 HTTPS 时)。 }) - // 4. Create Gin router. + // 4. 创建 Gin 路由器。 router := gin.Default() - // 4b. Trusted proxies: only IPs listed here may influence the client IP - // (X-Forwarded-For). Without this, gin trusts every proxy and a client - // can spoof the IP recorded for comments/article views. + // 4b. 可信代理:只有列表中的 IP 才能影响客户端 IP(X-Forwarded-For)。 + // 如果不设置,gin 会信任所有代理,客户端就能伪造评论/文章浏览 + // 中记录的 IP。 if err := router.SetTrustedProxies(cfg.Web.TrustedProxies); err != nil { log.Fatalf("Invalid trusted_proxies in config: %v", err) } - // 4c. Security response headers (registered first so they are present - // even on rejected responses). + // 4c. 安全响应头(最先注册,确保被拒绝的响应上也包含它们)。 router.Use(middleware.SecurityHeaders()) - // 5. Load HTML templates. + // 5. 加载 HTML 模板。 router.LoadHTMLGlob("templates/**/*.html") - // 6. Serve uploaded files (avatars etc.) from the storage path. Only the - // known upload subdirectories are exposed — never the storage root - // itself, which also holds the SQLite database file: mounting the whole - // root would let anyone download /uploads/blog.db (SECURITY_TODO #18). + // 6. 从存储路径提供上传文件(头像等)。只暴露已知的上传子目录—— + // 绝不暴露存储根目录本身,其中还包含 SQLite 数据库文件:挂载整个 + // 根目录会让任何人下载 /uploads/blog.db(SECURITY_TODO #18)。 registerUploadRoutes(router.Group("/uploads"), cfg.Path, models.GetUploadConfig().StorageDir) - // 6b. Serve bundled static assets (embedded into the binary). + // 6b. 提供捆绑的静态资源(内嵌于二进制中)。 staticFS, err := fs.Sub(staticFiles, "static") if err != nil { log.Fatalf("Failed to open embedded static assets: %v", err) } router.StaticFS("/static", http.FS(staticFS)) - // 6. Global session middleware. + // 6. 全局会话中间件。 router.Use(sessions.Sessions("blog_session", store)) - // 6a. Per-request session cookie hardening: Secure only over HTTPS, and - // SameSite=Lax. Applied per request because the app sits behind a TLS - // terminator (Caddy/Cloudflare) and cannot know at startup whether the - // client connection is encrypted. + // 6a. 按请求的会话 Cookie 加固:仅 HTTPS 时设置 Secure,以及 + // SameSite=Lax。按请求应用是因为应用位于 TLS 终结端 + //(Caddy/Cloudflare)之后,启动时无法得知客户端连接是否加密。 router.Use(func(c *gin.Context) { opts := sessions.Options{ Path: "/", @@ -107,13 +103,13 @@ func main() { sessions.Default(c).Options(opts) }) - // 6b. CSRF protection (must run after the session middleware). + // 6b. CSRF 防护(必须在会话中间件之后运行)。 router.Use(middleware.CSRFProtect()) - // 7. Global context middleware (sets IsLoggedIn, Username for templates). + // 7. 全局上下文中间件(为模板设置 IsLoggedIn、Username 等)。 router.Use(middleware.SetUserContext(db)) - // 8. Register routes. + // 8. 注册路由。 router.GET("/", handlers.HomePage(db)) router.GET("/search", handlers.SearchPage(db)) router.GET("/api/articles", handlers.HomeArticlesAPI(db)) @@ -127,7 +123,7 @@ func main() { router.GET("/article/:slug", handlers.ArticleDetail(db)) router.POST("/article/:slug/comments", handlers.PostComment(db)) - // Protected admin routes (admin role only). + // 受保护的后台路由(仅管理员角色)。 admin := router.Group("/admin") admin.Use(middleware.AuthRequired(db), middleware.AdminRequired(db)) { @@ -141,7 +137,7 @@ func main() { } - // Protected admin comment management routes (admin role only). + // 受保护的后台评论管理路由(仅管理员角色)。 comments := router.Group("/admin/comments") comments.Use(middleware.AuthRequired(db), middleware.AdminRequired(db)) { @@ -151,7 +147,7 @@ func main() { comments.POST("/:id/delete", handlers.CommentDelete(db)) } - // Protected admin user-management routes (admin role only). + // 受保护的后台用户管理路由(仅管理员角色)。 users := router.Group("/admin/users") users.Use(middleware.AuthRequired(db), middleware.AdminRequired(db)) { @@ -163,7 +159,7 @@ func main() { users.POST("/:id/delete", handlers.UserDelete(db)) } - // Protected article attachment routes (admin role only). + // 受保护的文章附件路由(仅管理员角色)。 attachments := router.Group("/admin/articles") attachments.Use(middleware.AuthRequired(db), middleware.AdminRequired(db)) { @@ -172,7 +168,7 @@ func main() { attachments.GET("/:id/attachments", handlers.ListAttachments(db)) } - // Protected admin settings routes (platform configuration). + // 受保护的后台设置路由(平台配置)。 settings := router.Group("/admin/settings") settings.Use(middleware.AuthRequired(db), middleware.AdminRequired(db)) { @@ -188,14 +184,14 @@ func main() { settings.POST("/comments", handlers.CommentSettingsSave(db)) } - // Protected admin analytics routes (reading statistics). + // 受保护的后台统计路由(读取统计信息)。 analytics := router.Group("/admin/analytics") analytics.Use(middleware.AuthRequired(db), middleware.AdminRequired(db)) { analytics.GET("/views", handlers.ViewAnalyticsPage(db)) } - // Protected profile routes. + // 受保护的个人资料路由。 profile := router.Group("/profile") profile.Use(middleware.AuthRequired(db)) { @@ -204,7 +200,7 @@ func main() { profile.POST("/avatar", handlers.UploadAvatar(db, cfg.Path)) } - // Protected user article management routes (for non-admin users). + // 受保护的用户文章管理路由(面向非管理员用户)。 myArticles := router.Group("/my") myArticles.Use(middleware.AuthRequired(db)) { @@ -216,7 +212,7 @@ func main() { myArticles.POST("/articles/:id/delete", handlers.MyArticleDelete(db)) } - // Protected article attachment routes for user articles. + // 用户文章的受保护附件路由。 myAttachments := router.Group("/my/articles") myAttachments.Use(middleware.AuthRequired(db)) { @@ -225,7 +221,7 @@ func main() { myAttachments.GET("/:id/attachments", handlers.ListAttachments(db)) } - // 9. Start the server. + // 9. 启动服务器。 webPort := cfg.Web.Port socketPath := cfg.Web.Socket usePort := webPort != "" && webPort != "0" @@ -247,7 +243,7 @@ func main() { if useSocket { go func() { - os.Remove(socketPath) // remove stale socket file if exists + os.Remove(socketPath) // 移除遗留的 socket 文件(若存在) listener, err := net.Listen("unix", socketPath) if err != nil { log.Fatalf("Failed to listen on unix socket %s: %v", socketPath, err) @@ -259,16 +255,15 @@ func main() { }() } - // Block forever. + // 永久阻塞。 select {} } -// registerUploadRoutes exposes the public upload subdirectories under the -// /uploads group: avatars, logos, and the configured attachment storage -// directory (plus the default "attachments" for backward compatibility). -// The storage root itself is never mounted — it also contains the SQLite -// database file, which must not be downloadable (SECURITY_TODO #18). -// Directory listing is disabled: only concrete files resolve. +// registerUploadRoutes 在 /uploads 组下暴露公开的上传子目录:avatars、 +// logos,以及配置的附件存储目录(外加向后兼容的默认 "attachments")。 +// 存储根目录绝不挂载——其中还包含 SQLite 数据库文件, +// 该文件不可被下载(SECURITY_TODO #18)。 +// 禁用目录列表:仅具体文件可解析。 func registerUploadRoutes(g *gin.RouterGroup, storagePath, storageDir string) { dirs := []string{"attachments", "avatars", "logos"} if dir := safeStorageDir(storageDir); dir != "attachments" && dir != "avatars" && dir != "logos" { @@ -281,11 +276,11 @@ func registerUploadRoutes(g *gin.RouterGroup, storagePath, storageDir string) { } } -// safeStorageDir clamps the configured attachment storage directory to a -// safe relative path: non-empty, not absolute, and free of ".." or "\". -// Anything unsafe falls back to the default "attachments" so a -// misconfigured storage_dir cannot escape the storage root (defense in -// depth for SECURITY_TODO #22). +// safeStorageDir 将配置的附件存储目录收窄为安全的相对路径: +// 非空、非绝对路径,且不含 ".." 或 "\"。 +// 任何不安全值回退到默认的 "attachments", +// 使配置错误的 storage_dir 无法逃逸出存储根目录 +// (针对 SECURITY_TODO #22 的纵深防御)。 func safeStorageDir(dir string) string { const fallback = "attachments" if dir == "" { @@ -299,11 +294,11 @@ func safeStorageDir(dir string) string { return cleaned } -// serveUploadDir serves concrete files from one upload subdirectory. -// Directory listings and traversal attempts are rejected with 404. +// serveUploadDir 从一个上传子目录提供具体文件。 +// 目录列表和路径穿越尝试以 404 拒绝。 func serveUploadDir(root string) gin.HandlerFunc { return func(c *gin.Context) { - rel := c.Param("file") // always begins with "/" + rel := c.Param("file") // 始终以 "/" 开头 if strings.Contains(rel, "..") || strings.ContainsRune(rel, '\\') { c.Status(http.StatusNotFound) return diff --git a/main_test.go b/main_test.go index 928b755..5edae70 100644 --- a/main_test.go +++ b/main_test.go @@ -10,9 +10,9 @@ import ( "github.com/gin-gonic/gin" ) -// newUploadsRouter builds a router with the production upload routes over a -// temp storage root that mirrors the real layout: the SQLite database file -// lives in the root itself, uploads live in subdirectories. +// newUploadsRouter 在模拟真实布局的临时存储根目录上构建 +// 使用生产上传路由的路由器:SQLite 数据库文件位于根目录中, +// 上传文件位于子目录中。 func newUploadsRouter(t *testing.T, storageDir string) (*gin.Engine, string) { t.Helper() gin.SetMode(gin.TestMode) @@ -51,12 +51,12 @@ func TestUploadsWhitelistHidesStorageRoot(t *testing.T) { seedUploadFile(t, root, sub, "file.txt") } - // The database file in the storage root must not be downloadable. + // 存储根目录中的数据库文件必须不可下载。 if w := doGet(t, r, "/uploads/blog.db"); w.Code != http.StatusNotFound { t.Fatalf("GET /uploads/blog.db = %d, want 404 (database leak)", w.Code) } - // No directory listing anywhere. + // 任何地方都不允许目录列表。 for _, p := range []string{ "/uploads", "/uploads/", "/uploads/attachments/", "/uploads/avatars/", "/uploads/logos/", @@ -66,7 +66,7 @@ func TestUploadsWhitelistHidesStorageRoot(t *testing.T) { } } - // Traversal attempts must not escape the subdirectory. + // 路径穿越尝试不得逃逸出子目录。 for _, p := range []string{ "/uploads/attachments/../blog.db", "/uploads/attachments/..%2f..%2fblog.db", @@ -77,7 +77,7 @@ func TestUploadsWhitelistHidesStorageRoot(t *testing.T) { } } - // Files in the whitelisted subdirectories are still served. + // 白名单子目录中的文件仍然可以访问。 for _, p := range []string{ "/uploads/attachments/file.txt", "/uploads/avatars/file.txt", @@ -96,7 +96,7 @@ func TestUploadsWhitelistCustomStorageDir(t *testing.T) { if w := doGet(t, r, "/uploads/files/a.bin"); w.Code != http.StatusOK { t.Fatalf("GET /uploads/files/a.bin = %d, want 200", w.Code) } - // The default dir stays mounted for backward compatibility. + // 默认目录保持挂载以向后兼容。 seedUploadFile(t, root, "attachments", "old.txt") if w := doGet(t, r, "/uploads/attachments/old.txt"); w.Code != http.StatusOK { t.Fatalf("GET /uploads/attachments/old.txt = %d, want 200", w.Code) @@ -120,7 +120,7 @@ func TestUploadsWhitelistUnsafeStorageDirFallsBack(t *testing.T) { } func TestUploadsWhitelistStorageDirDedup(t *testing.T) { - // A storage dir equal to a known dir must not panic on duplicate routes. + // 与已知目录相同的存储目录不能因重复路由而 panic。 r, root := newUploadsRouter(t, "avatars") seedUploadFile(t, root, "avatars", "me.jpg") if w := doGet(t, r, "/uploads/avatars/me.jpg"); w.Code != http.StatusOK { diff --git a/middleware/auth.go b/middleware/auth.go index d2fcc9c..3d259c2 100644 --- a/middleware/auth.go +++ b/middleware/auth.go @@ -11,9 +11,8 @@ import ( "go_blog/models" ) -// sessionUserID extracts the logged-in user's numeric ID from the session, -// defending against int/uint/int64/float64 storage. ok=false if absent or of -// an unexpected type. +// sessionUserID 从会话中提取已登录用户的数值 ID, +// 兼容 int/uint/int64/float64 的存储类型。若不存在或类型不符,ok=false。 func sessionUserID(session sessions.Session) (uint, bool) { userID := session.Get("user_id") if userID == nil { @@ -33,9 +32,9 @@ func sessionUserID(session sessions.Session) (uint, bool) { } } -// clearUserSession drops the authentication state from a session, keeping only -// the harmless UI preferences (language and CSRF token, mirroring the login -// handler's rotation) so forms already rendered in other tabs stay valid. +// clearUserSession 从会话中清除认证状态,仅保留无害的 UI 偏好 +// (语言与 CSRF 令牌,与登录处理器的轮换逻辑保持一致), +// 以确保其他标签页中已渲染的表单仍然有效。 func clearUserSession(session sessions.Session) { lang, _ := session.Get("lang").(string) csrfTok, _ := session.Get(CSRFSessionKey).(string) @@ -49,11 +48,9 @@ func clearUserSession(session sessions.Session) { session.Save() } -// AuthRequired is middleware that protects routes. If the user is not logged -// in, they are redirected to /login. The session user is also re-validated -// against the database on every request: an account that has since been -// disabled, locked or soft-deleted loses access immediately instead of when -// its cookie expires (SECURITY_TODO #20). +// AuthRequired 是保护路由的中间件。若用户未登录,则重定向到 /login。 +// 会话用户还会在每次请求时重新对数据库校验:已停用、已锁定或已软删除的 +// 账户会立即失去访问权限,而无需等到 Cookie 过期(SECURITY_TODO #20)。 func AuthRequired(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { session := sessions.Default(c) @@ -65,8 +62,7 @@ func AuthRequired(db *gorm.DB) gin.HandlerFunc { } var user models.User if err := db.First(&user, uid).Error; err != nil || user.Status != models.StatusNormal { - // Account no longer usable — kill the session so the stale cookie - // cannot be replayed. + // 账户已不可用——销毁会话,防止过期 Cookie 被重放。 clearUserSession(session) c.Redirect(http.StatusFound, "/login") c.Abort() @@ -76,9 +72,9 @@ func AuthRequired(db *gorm.DB) gin.HandlerFunc { } } -// AdminRequired is middleware that restricts a route to admin-role users. It -// must run after AuthRequired (which guarantees a live, normal-status session -// user). Non-admin users are redirected back to the admin dashboard. +// AdminRequired 是仅允许管理员角色用户访问路由的中间件。它必须在 +// AuthRequired 之后运行(后者保证会话用户存在且状态正常)。 +// 非管理员用户会被重定向回管理后台。 func AdminRequired(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { session := sessions.Default(c) @@ -98,14 +94,14 @@ func AdminRequired(db *gorm.DB) gin.HandlerFunc { } } -// SetUserContext is global middleware that reads the session and sets -// template-friendly context values for all pages (language, auth state, etc.). +// SetUserContext 是全局中间件,读取会话并为所有页面设置 +// 便于模板使用的上下文值(语言、认证状态等)。 func SetUserContext(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { session := sessions.Default(c) - // --- Language detection --- - // Priority: query param > session > Accept-Language header > default EN + // --- 语言检测 --- + // 优先级:查询参数 > 会话 > Accept-Language 请求头 > 默认 EN var lang i18n.Lang queryLang := c.Query("lang") @@ -115,39 +111,38 @@ func SetUserContext(db *gorm.DB) gin.HandlerFunc { case "en": lang = i18n.EN case "": - // Try session + // 尝试从会话中读取 if saved, ok := session.Get("lang").(string); ok { lang = i18n.Lang(saved) } if lang == "" { - // Try Accept-Language header + // 尝试从 Accept-Language 请求头检测 lang = i18n.DetectLang(c.GetHeader("Accept-Language")) } default: - // Unsupported language in query — fall back to English. + // 查询参数包含不支持的语言——回退到英语。 lang = i18n.EN } - // Persist language in session. + // 将会话中的语言持久化。 session.Set("lang", string(lang)) session.Save() - // Make translations available in the Gin context. + // 将翻译字典放入 Gin 上下文。 c.Set("tr", i18n.T(lang)) c.Set("lang", string(lang)) - // Set the opposite language code for the language switcher link. + // 为语言切换链接设置相反的语言代码。 switchLang := "zh" if lang == i18n.ZH { switchLang = "en" } c.Set("switch_lang", switchLang) - // --- Auth state --- - // The user is only considered logged in if the account still exists - // and is in normal status: a disabled/locked/soft-deleted account must - // not keep template-level privileges (e.g. comment auto-approval) - // after its session was invalidated (SECURITY_TODO #20). + // --- 认证状态 --- + // 仅当账户仍然存在且状态正常时,才认为用户已登录: + // 被停用/锁定/软删除的账户,在其会话失效后 + // 不得继续保留模板级权限(如评论自动通过)(SECURITY_TODO #20)。 isLoggedIn := false var username string var avatar string @@ -171,7 +166,7 @@ func SetUserContext(db *gorm.DB) gin.HandlerFunc { c.Set("display_name", displayName) c.Set("role", role) - // --- Site platform configuration (from DB cache) --- + // --- 站点平台配置(来自数据库缓存)--- site := models.GetSiteSetting() c.Set("site_setting", site) c.Set("site_logo", site.Logo) @@ -184,7 +179,7 @@ func SetUserContext(db *gorm.DB) gin.HandlerFunc { c.Set("site_home_subtitle", site.HomeSubtitle(string(lang))) c.Set("site_footer_text", site.FooterText(string(lang))) - // --- Navigation links (from DB cache) --- + // --- 导航链接(来自数据库缓存)--- navLinks := models.GetNavLinks() c.Set("nav_links", navLinks) diff --git a/middleware/clientip_test.go b/middleware/clientip_test.go index 1b2e83d..79b5f38 100644 --- a/middleware/clientip_test.go +++ b/middleware/clientip_test.go @@ -8,8 +8,8 @@ import ( "github.com/gin-gonic/gin" ) -// newClientIPRouter mirrors the production trusted-proxy configuration: -// only loopback is trusted (the Caddy/nginx host). +// newClientIPRouter 模拟生产环境的可信代理配置: +// 仅信任回环地址(即 Caddy/nginx 主机)。 func newClientIPRouter() *gin.Engine { gin.SetMode(gin.TestMode) r := gin.New() @@ -25,8 +25,8 @@ func newClientIPRouter() *gin.Engine { func TestClientIPSpoofingBlocked(t *testing.T) { r := newClientIPRouter() - // A direct (untrusted) client sending a forged X-Forwarded-For must not - // be able to change the recorded IP. + // 直接(不受信任)客户端伪造 X-Forwarded-For 时, + // 不得改变记录的 IP。 req := httptest.NewRequest(http.MethodGet, "/ip", nil) req.RemoteAddr = "203.0.113.5:12345" req.Header.Set("X-Forwarded-For", "6.6.6.6") @@ -36,8 +36,8 @@ func TestClientIPSpoofingBlocked(t *testing.T) { t.Errorf("direct client with forged XFF: got %q, want 203.0.113.5", got) } - // A trusted proxy (loopback) forwarding a real chain: the rightmost - // untrusted entry wins, earlier (client-supplied) entries are ignored. + // 可信代理(回环)转发真实链路:最右侧不受信任的条目生效, + // 更早的(客户端提供的)条目被忽略。 req = httptest.NewRequest(http.MethodGet, "/ip", nil) req.RemoteAddr = "127.0.0.1:54321" req.Header.Set("X-Forwarded-For", "6.6.6.6, 198.51.100.42") @@ -47,7 +47,7 @@ func TestClientIPSpoofingBlocked(t *testing.T) { t.Errorf("proxy-forwarded chain: got %q, want 198.51.100.42 (client-supplied entry must be ignored)", got) } - // A trusted proxy forwarding a single entry: that entry is the client. + // 可信代理转发单个条目:该条目即为客户端。 req = httptest.NewRequest(http.MethodGet, "/ip", nil) req.RemoteAddr = "127.0.0.1:54321" req.Header.Set("X-Forwarded-For", "198.51.100.42") diff --git a/middleware/csrf.go b/middleware/csrf.go index d404607..a277f65 100644 --- a/middleware/csrf.go +++ b/middleware/csrf.go @@ -10,30 +10,29 @@ import ( "github.com/gin-gonic/gin" ) -// CSRF protection uses the synchronizer-token pattern on top of the existing -// session store: -// - Safe methods (GET/HEAD/OPTIONS): a per-session token is created on first -// use and exposed to templates / JS so it can be embedded in forms. -// - Unsafe methods (POST/PUT/PATCH/DELETE): the request must carry the token -// either as the "_csrf" form field (regular forms, multipart uploads) or -// in the "X-CSRF-Token" header (AJAX). A mismatch aborts with 403. +// CSRF 防护在现有会话存储之上采用同步令牌(synchronizer-token)模式: +// - 安全方法(GET/HEAD/OPTIONS):首次使用时按会话生成令牌, +// 并通过模板 / JS 暴露,以便嵌入表单。 +// - 不安全方法(POST/PUT/PATCH/DELETE):请求必须携带令牌, +// 可以是 "_csrf" 表单字段(普通表单、multipart 上传), +// 也可以是 "X-CSRF-Token" 请求头(AJAX)。不匹配时以 403 终止请求。 // -// The token is bound to the session, so it works for anonymous visitors (e.g. -// the comment form) as well as for logged-in users. +// 令牌与会话绑定,因此既适用于匿名访客(如评论表单), +// 也适用于已登录用户。 const ( - // CSRFFieldName is the form field carrying the token. + // CSRFFieldName 是携带令牌的表单字段名。 CSRFFieldName = "_csrf" - // CSRFHeaderName is the HTTP header carrying the token (AJAX). + // CSRFHeaderName 是携带令牌的 HTTP 请求头名(AJAX)。 CSRFHeaderName = "X-CSRF-Token" - // CSRFSessionKey stores the token server-side. + // CSRFSessionKey 是服务端存储令牌的会话键。 CSRFSessionKey = "csrf_token" - // CSRFContextKey exposes the token to handlers/templates via c.Set. + // CSRFContextKey 通过 c.Set 将令牌暴露给处理器/模板。 CSRFContextKey = "csrf_token" ) -// newCSRFToken returns a 256-bit random hex token. A crypto/rand failure is -// unrecoverable; panic rather than degrade the defense. +// newCSRFToken 返回 256 位随机十六进制令牌。crypto/rand 失败不可恢复: +// 宁可 panic,也不削弱防御。 func newCSRFToken() string { b := make([]byte, 32) if _, err := rand.Read(b); err != nil { @@ -42,7 +41,7 @@ func newCSRFToken() string { return hex.EncodeToString(b) } -// csrfTokensEqual compares two tokens in constant time. +// csrfTokensEqual 以常量时间比较两个令牌。 func csrfTokensEqual(a, b string) bool { if len(a) != len(b) { return false @@ -54,8 +53,8 @@ func csrfTokensEqual(a, b string) bool { return v == 0 } -// CSRFProtect validates unsafe requests against the per-session CSRF token. -// It must be registered after the sessions middleware. +// CSRFProtect 校验不安全请求是否携带与会话匹配的 CSRF 令牌。 +// 必须在 sessions 中间件之后注册。 func CSRFProtect() gin.HandlerFunc { return func(c *gin.Context) { session := sessions.Default(c) @@ -63,8 +62,7 @@ func CSRFProtect() gin.HandlerFunc { switch c.Request.Method { case http.MethodGet, http.MethodHead, http.MethodOptions, http.MethodTrace: - // Safe method: make sure a token exists and hand it to the - // template layer. + // 安全方法:确保令牌存在,并交给模板层使用。 if token == "" { token = newCSRFToken() session.Set(CSRFSessionKey, token) @@ -75,7 +73,7 @@ func CSRFProtect() gin.HandlerFunc { return } - // Unsafe method: require a matching token. + // 不安全方法:要求携带匹配的令牌。 supplied := c.PostForm(CSRFFieldName) if supplied == "" { supplied = c.GetHeader(CSRFHeaderName) diff --git a/middleware/csrf_test.go b/middleware/csrf_test.go index 04ac19e..3a42f48 100644 --- a/middleware/csrf_test.go +++ b/middleware/csrf_test.go @@ -31,8 +31,8 @@ func newCSRFTestRouter() *gin.Engine { return r } -// tokenFromForm performs GET /form with the given session cookie and returns -// the issued CSRF token plus the (possibly new) session cookie. +// tokenFromForm 使用给定的会话 Cookie 执行 GET /form,并返回 +// 签发的 CSRF 令牌及(可能更新的)会话 Cookie。 func tokenFromForm(t *testing.T, r *gin.Engine, sessionCookie string) (token, cookie string) { t.Helper() req := httptest.NewRequest(http.MethodGet, "/form", nil) @@ -82,7 +82,7 @@ func TestCSRFTokenIssuedOnGET(t *testing.T) { t.Fatalf("expected session cookie to be set, got %q", cookie) } - // A second GET with the same session must return the same token. + // 相同会话的第二次 GET 必须返回相同的令牌。 token2, _ := tokenFromForm(t, r, cookie) if token2 != token { t.Fatalf("token changed between requests: %q vs %q", token, token2) @@ -111,7 +111,7 @@ func TestCSRFPostRejectedWithWrongToken(t *testing.T) { func TestCSRFPostRejectedWithoutSession(t *testing.T) { r := newCSRFTestRouter() - // No prior GET: no session, no token issued. + // 没有先前的 GET:无会话,也未签发令牌。 w := postAction(r, "", "some-token", false) if w.Code != http.StatusForbidden { t.Fatalf("POST without session: status = %d, want 403", w.Code) @@ -140,9 +140,8 @@ func TestCSRFPostAcceptedWithHeader(t *testing.T) { func TestCSRFSafeMethodsPassWithoutToken(t *testing.T) { r := newCSRFTestRouter() - // GET and HEAD are registered routes; OPTIONS is not (gin does not - // auto-register it), so it falls to noRoute - but in all cases the CSRF - // middleware itself must not reject with 403. + // GET 和 HEAD 已注册路由;OPTIONS 未注册(gin 不会自动注册), + // 因此会落入 noRoute——但在所有情况下,CSRF 中间件本身都不得以 403 拒绝。 for _, method := range []string{http.MethodGet, http.MethodHead, http.MethodOptions} { req := httptest.NewRequest(method, "/form", nil) w := httptest.NewRecorder() diff --git a/middleware/https.go b/middleware/https.go index f3deca5..e2f558f 100644 --- a/middleware/https.go +++ b/middleware/https.go @@ -6,12 +6,11 @@ import ( "github.com/gin-gonic/gin" ) -// IsHTTPSRequest reports whether the client reached us over TLS. Behind a -// reverse proxy (Caddy/nginx) the app itself usually terminates plain -// connections, so X-Forwarded-Proto is consulted as well. The header is only -// honored when a trusted proxy forwarded the request - untrusted clients -// spoofing it can at worst break their own session (the cookie turns Secure -// and is refused over plain HTTP). +// IsHTTPSRequest 报告客户端是否通过 TLS 访问到我们。当应用部署在反向代理 +// (Caddy/nginx)之后,应用本身通常使用明文连接,因此还需参考 +// X-Forwarded-Proto 请求头。仅当请求由可信代理转发时才采信该头部—— +// 不受信任的客户端伪造它,最坏情况下只会破坏自己的会话(Cookie 变为 +// Secure,在明文 HTTP 下会被拒收)。 func IsHTTPSRequest(c *gin.Context) bool { if c.Request.TLS != nil { return true diff --git a/middleware/security_headers.go b/middleware/security_headers.go index ac2f8ac..abdd30b 100644 --- a/middleware/security_headers.go +++ b/middleware/security_headers.go @@ -2,12 +2,11 @@ package middleware import "github.com/gin-gonic/gin" -// csp is the Content-Security-Policy for HTML responses. +// csp 是 HTML 响应的 Content-Security-Policy 策略。 // -// 'unsafe-inline' is required because templates embed inline