docs: 全部 Go 代码注释汉化
- 48 个 Go 文件所有注释(行注释/块注释/行尾注释,含 _test.go)翻译为中文 - 保留技术标识符:SECURITY_TODO(n)、unsafe-inline、sqlite/mysql、路由参数等 - 代码、字符串字面量、日志消息保持英文原文,零逻辑改动 - go build/vet 通过,go test -count=1 ./... 全绿
This commit is contained in:
+30
-32
@@ -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())
|
||||
|
||||
@@ -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)
|
||||
|
||||
+3
-3
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+12
-14
@@ -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 != "" {
|
||||
|
||||
+33
-39
@@ -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 <select> (0/1/2/3); treat an absent field
|
||||
// as Normal, but keep an explicit 0 (Disabled) intact.
|
||||
// 状态始终由 <select>(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 <select> elements.
|
||||
// <select> 元素的角色/状态选项。
|
||||
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)
|
||||
|
||||
+70
-73
@@ -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-<id>" fallback.
|
||||
// slugAsciiRe 匹配仅由 URL 安全的 ASCII 字母、数字和连字符组成的 slug。
|
||||
// 包含其他字符的 slug(如 CJK,或类似土耳其无点 i 的 Unicode 小写
|
||||
// 癖好)在 URL 中不稳定,会被丢弃,转而使用 "post-<id>" 回退方案。
|
||||
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-<id>"). 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-<id>")。非 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-<id> after insert.
|
||||
// 标题没有可用字符(例如只有标点)。先使用临时的基于令牌的
|
||||
// slug;插入后再完善为 post-<id>。
|
||||
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-<id> form.
|
||||
// 将基于令牌的占位 slug 完善为可读的 post-<id> 形式。
|
||||
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")
|
||||
|
||||
+37
-45
@@ -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/<stored>" — used to build /uploads URLs.
|
||||
// attachmentRelPath 返回附件相对于存储根目录的路径,
|
||||
// 例如 "attachments/<stored>"——用于构建 /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)
|
||||
|
||||
+24
-28
@@ -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, "/")
|
||||
}
|
||||
}
|
||||
+39
-50
@@ -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
|
||||
// <http://…>, which are rare in comments and acceptable to lose).
|
||||
// htmlTagPattern 匹配所有 HTML/XML 标签,以便在存储前从评论 Markdown 中剥除。
|
||||
// Markdown 语法本身不含会冲突的尖括号形式(唯一类似结构是
|
||||
// <http://…> 这样的自动链接,在评论中很少见,可以接受丢失)。
|
||||
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 {
|
||||
|
||||
+11
-16
@@ -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()
|
||||
}
|
||||
+51
-55
@@ -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
|
||||
|
||||
+20
-25
@@ -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)
|
||||
+11
-11
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+15
-16
@@ -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"}
|
||||
|
||||
+43
-44
@@ -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 {
|
||||
|
||||
+26
-27
@@ -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
|
||||
}
|
||||
+38
-40
@@ -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)
|
||||
|
||||
@@ -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("<html><script>alert(document.cookie)</script></html>")
|
||||
|
||||
// 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("<html><script>alert(1)</script></html>")
|
||||
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") {
|
||||
|
||||
+49
-53
@@ -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") {
|
||||
|
||||
@@ -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)
|
||||
|
||||
+32
-32
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
+9
-9
@@ -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 {
|
||||
|
||||
+28
-33
@@ -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)
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
+19
-21
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
+5
-6
@@ -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
|
||||
|
||||
@@ -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 <script> and
|
||||
// <style> blocks (Go html/template is the XSS defense for those). All
|
||||
// third-party assets are vendored locally (SECURITY_TODO #9), so the policy
|
||||
// allows no other origins for scripts or styles.
|
||||
// 需要 'unsafe-inline' 是因为模板内嵌了 <script> 与 <style> 块
|
||||
// (这些块的 XSS 防御由 Go html/template 提供)。所有第三方资源均已
|
||||
// 本地化托管(SECURITY_TODO #9),因此策略不允许其他来源的脚本或样式。
|
||||
const csp = "default-src 'self'; " +
|
||||
"script-src 'self' 'unsafe-inline'; " +
|
||||
"style-src 'self' 'unsafe-inline'; " +
|
||||
@@ -18,11 +17,11 @@ const csp = "default-src 'self'; " +
|
||||
"base-uri 'self'; " +
|
||||
"form-action 'self'"
|
||||
|
||||
// SecurityHeaders sets hardening response headers on every response:
|
||||
// CSP, nosniff, clickjacking (X-Frame-Options + frame-ancestors),
|
||||
// referrer policy, and HSTS when the request arrived over HTTPS.
|
||||
// Register it before all other middleware so the headers are present even
|
||||
// on rejected (403/redirect) responses.
|
||||
// SecurityHeaders 为每个响应设置安全加固头:
|
||||
// CSP、nosniff、点击劫持防护(X-Frame-Options + frame-ancestors)、
|
||||
// Referrer-Policy,以及当请求通过 HTTPS 到达时的 HSTS。
|
||||
// 必须在其他中间件之前注册,以确保即使在被拒绝(403/重定向)的响应上
|
||||
// 也包含这些头。
|
||||
func SecurityHeaders() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.Header("Content-Security-Policy", csp)
|
||||
@@ -31,8 +30,8 @@ func SecurityHeaders() gin.HandlerFunc {
|
||||
c.Header("Referrer-Policy", "strict-origin-when-cross-origin")
|
||||
c.Header("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
|
||||
if IsHTTPSRequest(c) {
|
||||
// includeSubDomains is deliberately omitted: some subdomains of
|
||||
// the site may still be served over plain HTTP.
|
||||
// 故意省略 includeSubDomains:站点的某些子域
|
||||
// 可能仍通过明文 HTTP 提供访问。
|
||||
c.Header("Strict-Transport-Security", "max-age=31536000")
|
||||
}
|
||||
c.Next()
|
||||
|
||||
@@ -20,7 +20,7 @@ func newHeadersTestRouter() *gin.Engine {
|
||||
func TestSecurityHeadersPresent(t *testing.T) {
|
||||
r := newHeadersTestRouter()
|
||||
|
||||
// Plain HTTP request: hardening headers present, no HSTS.
|
||||
// 明文 HTTP 请求:加固头存在,无 HSTS。
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
@@ -53,7 +53,7 @@ func TestSecurityHeadersPresent(t *testing.T) {
|
||||
func TestSecurityHeadersHSTSOverHTTPS(t *testing.T) {
|
||||
r := newHeadersTestRouter()
|
||||
|
||||
// TLS request: HSTS present.
|
||||
// TLS 请求:HSTS 存在。
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.TLS = &tls.ConnectionState{}
|
||||
w := httptest.NewRecorder()
|
||||
@@ -62,7 +62,7 @@ func TestSecurityHeadersHSTSOverHTTPS(t *testing.T) {
|
||||
t.Errorf("HSTS over TLS = %q, want max-age=31536000", v)
|
||||
}
|
||||
|
||||
// Behind a trusted proxy (X-Forwarded-Proto: https): HSTS present.
|
||||
// 位于可信代理之后(X-Forwarded-Proto: https):HSTS 存在。
|
||||
req = httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.Header.Set("X-Forwarded-Proto", "https")
|
||||
w = httptest.NewRecorder()
|
||||
@@ -73,22 +73,22 @@ func TestSecurityHeadersHSTSOverHTTPS(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestIsHTTPSRequest(t *testing.T) {
|
||||
// TLS request.
|
||||
// TLS 请求。
|
||||
if !IsHTTPSRequest(&gin.Context{Request: mustTLSRequest()}) {
|
||||
t.Error("TLS request must be HTTPS")
|
||||
}
|
||||
// Plain request.
|
||||
// 明文请求。
|
||||
c := &gin.Context{}
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
if IsHTTPSRequest(c) {
|
||||
t.Error("plain request must not be HTTPS")
|
||||
}
|
||||
// X-Forwarded-Proto: https.
|
||||
// X-Forwarded-Proto: https。
|
||||
c.Request.Header.Set("X-Forwarded-Proto", "https")
|
||||
if !IsHTTPSRequest(c) {
|
||||
t.Error("X-Forwarded-Proto https must be treated as HTTPS")
|
||||
}
|
||||
// X-Forwarded-Proto: http must not trigger HTTPS behavior.
|
||||
// X-Forwarded-Proto: http 不得触发 HTTPS 行为。
|
||||
c.Request.Header.Set("X-Forwarded-Proto", "http")
|
||||
if IsHTTPSRequest(c) {
|
||||
t.Error("X-Forwarded-Proto http must not be treated as HTTPS")
|
||||
|
||||
+19
-19
@@ -6,34 +6,34 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Article status constants.
|
||||
// 文章状态常量。
|
||||
const (
|
||||
ArticleDraft = 0 // 草稿
|
||||
ArticlePublished = 1 // 已发布
|
||||
ArticleArchived = 2 // 已归档
|
||||
)
|
||||
|
||||
// Article represents a blog post.
|
||||
// Article 表示一篇博客文章。
|
||||
type Article struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"uniqueIndex:idx_slug_deleted_at" json:"deleted_at"`
|
||||
AuthorID uint `gorm:"not null;index" json:"author_id"`
|
||||
Title string `gorm:"not null;size:255" json:"title"`
|
||||
Summary string `gorm:"size:512" json:"summary"`
|
||||
Content string `gorm:"type:text;not null" json:"content"`
|
||||
Cover string `gorm:"size:512" json:"cover"`
|
||||
Status int `gorm:"default:0;index" json:"status"`
|
||||
IsTop bool `gorm:"default:false" json:"is_top"`
|
||||
ViewCount int `gorm:"default:0" json:"view_count"`
|
||||
Slug string `gorm:"uniqueIndex:idx_slug_deleted_at;size:255" json:"slug"`
|
||||
PublishedAt *time.Time `gorm:"index" json:"published_at"`
|
||||
Author User `gorm:"foreignKey:AuthorID" json:"-"`
|
||||
Tags []Tag `gorm:"many2many:article_tags;" json:"tags"`
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"uniqueIndex:idx_slug_deleted_at" json:"deleted_at"`
|
||||
AuthorID uint `gorm:"not null;index" json:"author_id"`
|
||||
Title string `gorm:"not null;size:255" json:"title"`
|
||||
Summary string `gorm:"size:512" json:"summary"`
|
||||
Content string `gorm:"type:text;not null" json:"content"`
|
||||
Cover string `gorm:"size:512" json:"cover"`
|
||||
Status int `gorm:"default:0;index" json:"status"`
|
||||
IsTop bool `gorm:"default:false" json:"is_top"`
|
||||
ViewCount int `gorm:"default:0" json:"view_count"`
|
||||
Slug string `gorm:"uniqueIndex:idx_slug_deleted_at;size:255" json:"slug"`
|
||||
PublishedAt *time.Time `gorm:"index" json:"published_at"`
|
||||
Author User `gorm:"foreignKey:AuthorID" json:"-"`
|
||||
Tags []Tag `gorm:"many2many:article_tags;" json:"tags"`
|
||||
}
|
||||
|
||||
// TableName overrides the default GORM table name.
|
||||
// TableName 覆盖 GORM 默认的表名。
|
||||
func (Article) TableName() string {
|
||||
return "articles"
|
||||
}
|
||||
@@ -4,14 +4,14 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// ArticleTag represents the many-to-many relationship between articles and tags.
|
||||
// ArticleTag 表示文章与标签之间的多对多关联。
|
||||
type ArticleTag struct {
|
||||
ArticleID uint `gorm:"primaryKey;index" json:"article_id"`
|
||||
TagID uint `gorm:"primaryKey;index" json:"tag_id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// TableName overrides the default GORM table name.
|
||||
// TableName 覆盖 GORM 默认的表名。
|
||||
func (ArticleTag) TableName() string {
|
||||
return "article_tags"
|
||||
}
|
||||
@@ -6,13 +6,13 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ArticleView represents a unique article view record.
|
||||
// Each record tracks one unique visit (by IP or user) to an article.
|
||||
// ArticleView 表示一条唯一的文章浏览记录。
|
||||
// 每条记录跟踪一次(按 IP 或用户)对文章的唯一访问。
|
||||
type ArticleView struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ArticleID uint `gorm:"not null;index:idx_article_views_article" json:"article_id"`
|
||||
UserID *uint `gorm:"index:idx_article_views_user" json:"user_id"` // NULL for anonymous users
|
||||
UserID *uint `gorm:"index:idx_article_views_user" json:"user_id"` // 匿名用户为 NULL
|
||||
IP string `gorm:"size:64;not null;index:idx_article_views_ip" json:"ip"`
|
||||
UserAgent string `gorm:"size:512" json:"user_agent"`
|
||||
IsBot bool `gorm:"default:false;index:idx_article_views_bot" json:"is_bot"`
|
||||
@@ -20,13 +20,13 @@ type ArticleView struct {
|
||||
User *User `gorm:"foreignKey:UserID" json:"-"`
|
||||
}
|
||||
|
||||
// TableName overrides the default GORM table name.
|
||||
// TableName 覆盖 GORM 默认的表名。
|
||||
func (ArticleView) TableName() string {
|
||||
return "article_views"
|
||||
}
|
||||
|
||||
// BeforeCreate hook to ensure we don't create duplicate records.
|
||||
// This is a safety check in addition to application-level deduplication.
|
||||
// BeforeCreate 钩子确保不会创建重复记录。
|
||||
// 这是在应用层去重之外的另一道安全校验。
|
||||
func (av *ArticleView) BeforeCreate(tx *gorm.DB) error {
|
||||
var count int64
|
||||
query := tx.Model(&ArticleView{}).
|
||||
@@ -40,7 +40,7 @@ func (av *ArticleView) BeforeCreate(tx *gorm.DB) error {
|
||||
|
||||
query.Count(&count)
|
||||
if count > 0 {
|
||||
// Record already exists, skip creation
|
||||
// 记录已存在,跳过创建
|
||||
return gorm.ErrDuplicatedKey
|
||||
}
|
||||
|
||||
|
||||
+16
-19
@@ -6,27 +6,25 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Attachment represents a file attached to an article.
|
||||
// Attachment 表示附加到文章的文件。
|
||||
//
|
||||
// Lifecycle (plan A — upload-then-bind):
|
||||
// - On the article-create page the article does not exist yet, so ArticleID
|
||||
// is 0 and the row is temporarily owned by SessionToken (a random value
|
||||
// generated for the page session).
|
||||
// - When the article is saved, ArticleCreate binds pending rows by
|
||||
// SessionToken, setting their ArticleID and clearing the token.
|
||||
// - On the edit page uploads carry the real ArticleID directly.
|
||||
// 生命周期(方案 A——先上传后绑定):
|
||||
// - 在文章创建页面上文章尚不存在,因此 ArticleID 为 0,
|
||||
// 该行暂时由 SessionToken(为页面会话生成的随机值)持有。
|
||||
// - 保存文章时,ArticleCreate 通过 SessionToken 绑定待处理行,
|
||||
// 设置它们的 ArticleID 并清除令牌。
|
||||
// - 在编辑页面上,上传直接携带真实的 ArticleID。
|
||||
//
|
||||
// Disk deduplication: StoredName is the SHA-256 of the file content. Before
|
||||
// writing, the handler checks whether a file with that name already exists on
|
||||
// disk; if so it is reused (no rewrite). Deletion uses reference counting —
|
||||
// the disk file is removed only when no Attachment rows reference it.
|
||||
// 磁盘去重:StoredName 是文件内容的 SHA-256。写入前,
|
||||
// 处理器会检查磁盘上是否已存在同名文件;若已存在则复用(不重写)。
|
||||
// 删除采用引用计数——仅当没有任何 Attachment 行引用时,才删除磁盘文件。
|
||||
type Attachment struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
ArticleID uint `gorm:"index" json:"article_id"` // 0 while pending on the create page
|
||||
SessionToken string `gorm:"size:64;index" json:"-"` // temporary ownership token for the create page
|
||||
ArticleID uint `gorm:"index" json:"article_id"` // 在创建页面上待绑定时为 0
|
||||
SessionToken string `gorm:"size:64;index" json:"-"` // 创建页面上的临时归属令牌
|
||||
UploaderID uint `gorm:"index" json:"uploader_id"`
|
||||
Filename string `gorm:"size:255" json:"filename"` // original filename
|
||||
StoredName string `gorm:"size:64;index" json:"stored_name"` // SHA-256 hex, the on-disk filename
|
||||
Filename string `gorm:"size:255" json:"filename"` // 原始文件名
|
||||
StoredName string `gorm:"size:64;index" json:"stored_name"` // SHA-256 十六进制字符串,磁盘文件名
|
||||
Ext string `gorm:"size:32" json:"ext"`
|
||||
MIME string `gorm:"size:128" json:"mime"`
|
||||
Size int64 `gorm:"default:0" json:"size"`
|
||||
@@ -36,13 +34,12 @@ type Attachment struct {
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at"`
|
||||
}
|
||||
|
||||
// TableName overrides the default GORM table name.
|
||||
// TableName 覆盖 GORM 默认的表名。
|
||||
func (Attachment) TableName() string {
|
||||
return "attachments"
|
||||
}
|
||||
|
||||
// AttachmentCategoryImage reports whether this attachment is an image (used to
|
||||
// decide markdown insertion form: ![]() vs []()).
|
||||
// IsImage 报告该附件是否为图片(用于决定 Markdown 插入形式:![]() 还是 []())。
|
||||
func (a *Attachment) IsImage() bool {
|
||||
return a.Category == CategoryImage
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// botPatterns contains common bot/crawler/spider User-Agent patterns.
|
||||
// botPatterns 包含常见的 bot/爬虫/蜘蛛 User-Agent 特征模式。
|
||||
var botPatterns = []string{
|
||||
"bot", "crawler", "spider", "scraper", "scraping",
|
||||
"googlebot", "bingbot", "baiduspider", "yandexbot",
|
||||
@@ -19,8 +19,8 @@ var botPatterns = []string{
|
||||
"headless", "phantom", "selenium", "puppeteer",
|
||||
}
|
||||
|
||||
// IsBot checks if the given User-Agent string matches known bot patterns.
|
||||
// It performs a case-insensitive substring match against common bot identifiers.
|
||||
// IsBot 检查给定的 User-Agent 字符串是否匹配已知的 bot 特征模式。
|
||||
// 它针对常见的 bot 标识进行不区分大小写的子串匹配。
|
||||
func IsBot(userAgent string) bool {
|
||||
if userAgent == "" {
|
||||
return false
|
||||
|
||||
+16
-18
@@ -10,16 +10,16 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Comment status constants.
|
||||
// 评论状态常量。
|
||||
const (
|
||||
CommentPending = 0 // 待审核
|
||||
CommentApproved = 1 // 已通过
|
||||
CommentRejected = 2 // 已拒绝(软拒绝;后台仍可查看,但前台不再显示)
|
||||
)
|
||||
|
||||
// Comment represents one reader-submitted comment on an article. Comments may
|
||||
// be nested via ParentID and authored by either a logged-in user (UserID) or
|
||||
// an anonymous visitor identified by a long-lived GuestToken cookie.
|
||||
// Comment 表示读者对文章提交的一条评论。评论可通过 ParentID 进行嵌套,
|
||||
// 作者可以是已登录用户(UserID),也可以通过长期有效的 GuestToken Cookie
|
||||
// 标识的匿名访客。
|
||||
type Comment struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
@@ -29,9 +29,9 @@ type Comment struct {
|
||||
ArticleID uint `gorm:"not null;index" json:"article_id"`
|
||||
ParentID *uint `gorm:"index" json:"parent_id,omitempty"`
|
||||
|
||||
// Authorship: logged-in users get UserID; anonymous visitors get a random
|
||||
// GuestToken stored in a cookie so they can see their own pending/private
|
||||
// comments on subsequent page loads.
|
||||
// 作者标识:已登录用户使用 UserID;匿名访客使用随机的
|
||||
// GuestToken(保存于 Cookie 中),以便后续页面加载时能查看
|
||||
// 自己待审核/私密的评论。
|
||||
UserID *uint `gorm:"index" json:"user_id,omitempty"`
|
||||
GuestToken string `gorm:"size:64;index" json:"-"`
|
||||
|
||||
@@ -51,20 +51,20 @@ type Comment struct {
|
||||
Article Article `gorm:"foreignKey:ArticleID" json:"-"`
|
||||
}
|
||||
|
||||
// TableName overrides the default GORM table name.
|
||||
// TableName 覆盖 GORM 默认的表名。
|
||||
func (Comment) TableName() string {
|
||||
return "comments"
|
||||
}
|
||||
|
||||
// HashEmail returns the md5 hash of a lowercase, trimmed email address. This
|
||||
// is the form expected by Gravatar.
|
||||
// HashEmail 返回小写并去除空格后的邮箱地址的 md5 哈希值。
|
||||
// 这是 Gravatar 所期望的格式。
|
||||
func HashEmail(email string) string {
|
||||
sum := md5.Sum([]byte(strings.ToLower(strings.TrimSpace(email))))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// GravatarURL returns a Gravatar avatar URL for this comment's email hash.
|
||||
// Falls back to the "identicon" default avatar when no Gravatar exists.
|
||||
// GravatarURL 根据此评论的邮箱哈希返回 Gravatar 头像 URL。
|
||||
// 当没有对应的 Gravatar 头像时,回退到 "identicon" 默认头像。
|
||||
func (c *Comment) GravatarURL(size int) string {
|
||||
if size <= 0 {
|
||||
size = 64
|
||||
@@ -76,9 +76,8 @@ func (c *Comment) GravatarURL(size int) string {
|
||||
return fmt.Sprintf("https://www.gravatar.com/avatar/%s?s=%d&d=identicon", hash, size)
|
||||
}
|
||||
|
||||
// MaskedEmail returns the email with the local part partially obscured, for
|
||||
// admin-side listings that should hint at identity without exposing the
|
||||
// full address.
|
||||
// MaskedEmail 返回把本地部分部分遮盖后的邮箱,供后台列表展示:
|
||||
// 既能提示身份,又不暴露完整地址。
|
||||
func (c *Comment) MaskedEmail() string {
|
||||
email := c.Email
|
||||
at := strings.LastIndex(email, "@")
|
||||
@@ -93,9 +92,8 @@ func (c *Comment) MaskedEmail() string {
|
||||
return string(local[0]) + "***" + string(local[len(local)-1]) + host
|
||||
}
|
||||
|
||||
// AuthorInitial returns an uppercase first character of AuthorName for use as
|
||||
// a text-based avatar placeholder when Gravatar is disabled. Returns "?" when
|
||||
// the name is empty.
|
||||
// AuthorInitial 返回 AuthorName 的首个大写字符,用作禁用 Gravatar 时
|
||||
// 的文本头像占位符。名称为空时返回 "?"。
|
||||
func (c *Comment) AuthorInitial() string {
|
||||
name := strings.TrimSpace(c.AuthorName)
|
||||
if name == "" {
|
||||
|
||||
+12
-12
@@ -2,26 +2,26 @@ package models
|
||||
|
||||
import "time"
|
||||
|
||||
// CommentConfig holds the singleton (id=1) global comment policy.
|
||||
// CommentConfig 保存单例(id=1)的全局评论策略。
|
||||
type CommentConfig struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
Enabled bool `gorm:"default:true" json:"enabled"` // master switch for the comment system
|
||||
AllowGuest bool `gorm:"default:true" json:"allow_guest"` // whether anonymous (non-logged-in) comments are allowed
|
||||
GuestRequireApproval bool `gorm:"default:false" json:"guest_require_approval"` // hold guest comments in the moderation queue
|
||||
// SECURITY_TODO #15: Gravatar reveals MD5(email) via reverse lookup;
|
||||
// off by default on new deployments (admins can re-enable explicitly).
|
||||
UseGravatar bool `gorm:"default:false" json:"use_gravatar"` // when false, avatars render as a text-initial placeholder
|
||||
UpdatedBy uint `gorm:"index" json:"updated_by"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Enabled bool `gorm:"default:true" json:"enabled"` // 评论系统的总开关
|
||||
AllowGuest bool `gorm:"default:true" json:"allow_guest"` // 是否允许匿名(非登录)评论
|
||||
GuestRequireApproval bool `gorm:"default:false" json:"guest_require_approval"` // 将访客评论置于审核队列
|
||||
// SECURITY_TODO #15:Gravatar 可通过反向查询暴露 MD5(邮箱);
|
||||
// 新部署默认关闭(管理员可显式重新启用)。
|
||||
UseGravatar bool `gorm:"default:false" json:"use_gravatar"` // 为 false 时,头像显示为文本首字母占位
|
||||
UpdatedBy uint `gorm:"index" json:"updated_by"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// TableName overrides the default GORM table name.
|
||||
// TableName 覆盖 GORM 默认的表名。
|
||||
func (CommentConfig) TableName() string {
|
||||
return "comment_configs"
|
||||
}
|
||||
|
||||
// defaultCommentConfig returns the in-memory fallback used before the DB row is
|
||||
// seeded, matching the seed defaults.
|
||||
// defaultCommentConfig 返回数据库行被初始化之前使用的内存回退值,
|
||||
// 与初始化种子默认值保持一致。
|
||||
func defaultCommentConfig() *CommentConfig {
|
||||
return &CommentConfig{
|
||||
ID: 1,
|
||||
|
||||
+17
-20
@@ -6,10 +6,9 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// configCache holds process-level caches of platform configuration so that
|
||||
// per-request rendering and upload validation do not hit the database. The
|
||||
// cache is populated once at startup and refreshed whenever an admin saves a
|
||||
// settings page (see RefreshConfigCache / the admin handlers).
|
||||
// configCache 保存平台配置的进程级缓存,使每次请求的渲染与上传校验
|
||||
// 都不必访问数据库。缓存启动时填充一次,每当管理员保存设置页面时刷新
|
||||
// (参见 RefreshConfigCache / 各管理处理器)。
|
||||
var configCache = struct {
|
||||
mu sync.RWMutex
|
||||
site *SiteSetting
|
||||
@@ -23,8 +22,8 @@ var configCache = struct {
|
||||
upload: &UploadConfig{Enabled: true, DefaultMaxSize: DefaultUploadMaxSize, StorageDir: "attachments"},
|
||||
}
|
||||
|
||||
// LoadConfigCache reads all platform configuration from the database into the
|
||||
// process cache. Called once at startup after InitDB.
|
||||
// LoadConfigCache 将所有平台配置从数据库读入进程缓存。
|
||||
// 在 InitDB 之后于启动时调用一次。
|
||||
func LoadConfigCache(db *gorm.DB) {
|
||||
configCache.mu.Lock()
|
||||
defer configCache.mu.Unlock()
|
||||
@@ -66,57 +65,55 @@ func LoadConfigCache(db *gorm.DB) {
|
||||
}
|
||||
}
|
||||
|
||||
// RefreshConfigCache reloads all cached platform configuration. Admin handlers
|
||||
// call this after writing changes so the next request sees them.
|
||||
// RefreshConfigCache 重新加载所有缓存中的平台配置。管理处理器在写入变更后
|
||||
// 调用此方法,以便下一次请求能看到更新。
|
||||
func RefreshConfigCache(db *gorm.DB) {
|
||||
LoadConfigCache(db)
|
||||
}
|
||||
|
||||
// GetSiteSetting returns a pointer to the cached site settings (read-only copy
|
||||
// semantics: callers must not mutate).
|
||||
// GetSiteSetting 返回缓存的站点设置指针(只读副本语义:调用方不得修改)。
|
||||
func GetSiteSetting() *SiteSetting {
|
||||
configCache.mu.RLock()
|
||||
defer configCache.mu.RUnlock()
|
||||
return configCache.site
|
||||
}
|
||||
|
||||
// GetUploadConfig returns the cached upload policy.
|
||||
// GetUploadConfig 返回缓存的上传策略。
|
||||
func GetUploadConfig() *UploadConfig {
|
||||
configCache.mu.RLock()
|
||||
defer configCache.mu.RUnlock()
|
||||
return configCache.upload
|
||||
}
|
||||
|
||||
// GetCommentConfig returns the cached comment policy.
|
||||
// GetCommentConfig 返回缓存的评论策略。
|
||||
func GetCommentConfig() *CommentConfig {
|
||||
configCache.mu.RLock()
|
||||
defer configCache.mu.RUnlock()
|
||||
return configCache.comment
|
||||
}
|
||||
|
||||
// GetUploadFileTypes returns the cached list of permitted file types.
|
||||
// GetUploadFileTypes 返回缓存的允许文件类型列表。
|
||||
func GetUploadFileTypes() []UploadFileType {
|
||||
configCache.mu.RLock()
|
||||
defer configCache.mu.RUnlock()
|
||||
return configCache.types
|
||||
}
|
||||
|
||||
// GetDownloadBaseURLs returns the cached list of download base URLs.
|
||||
// GetDownloadBaseURLs 返回缓存的下载基础 URL 列表。
|
||||
func GetDownloadBaseURLs() []DownloadBaseURL {
|
||||
configCache.mu.RLock()
|
||||
defer configCache.mu.RUnlock()
|
||||
return configCache.baseURLs
|
||||
}
|
||||
|
||||
// DefaultDownloadBaseURL returns the base URL used to build attachment download
|
||||
// links: the enabled row marked IsDefault, else the highest-priority enabled
|
||||
// row. Returns an empty string if none is configured.
|
||||
// DefaultDownloadBaseURL 返回用于构建附件下载链接的基础 URL:
|
||||
// 首选标记为 IsDefault 且启用的行,否则选择优先级最高的启用行。
|
||||
// 若未配置任何项则返回空字符串。
|
||||
func DefaultDownloadBaseURL() string {
|
||||
configCache.mu.RLock()
|
||||
defer configCache.mu.RUnlock()
|
||||
|
||||
// Rows are ordered is_default desc, priority asc, so the first enabled row
|
||||
// is the right pick.
|
||||
// 行按 is_default desc、priority asc 排序,因此第一个启用行即为正确选择。
|
||||
for _, b := range configCache.baseURLs {
|
||||
if b.Enabled {
|
||||
return b.BaseURL
|
||||
@@ -125,7 +122,7 @@ func DefaultDownloadBaseURL() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// GetNavLinks returns the cached list of enabled navigation links, sorted by sort order.
|
||||
// GetNavLinks 返回缓存的启用导航链接列表,按排序顺序排列。
|
||||
func GetNavLinks() []NavLink {
|
||||
configCache.mu.RLock()
|
||||
defer configCache.mu.RUnlock()
|
||||
|
||||
+13
-13
@@ -15,15 +15,15 @@ import (
|
||||
"github.com/glebarez/sqlite"
|
||||
)
|
||||
|
||||
// DB is the global database connection, initialized by InitDB.
|
||||
// DB 是全局数据库连接,由 InitDB 初始化。
|
||||
var DB *gorm.DB
|
||||
|
||||
// adminPasswordAlphabet avoids visually ambiguous characters (no l, I, O, 0,
|
||||
// 1) and is used to generate the first-run admin password.
|
||||
// adminPasswordAlphabet 避免了视觉上易混淆的字符(不含 l、I、O、0、1),
|
||||
// 用于生成首次运行的管理员密码。
|
||||
const adminPasswordAlphabet = "abcdefghjkmnpqrstuvwxyzABCDEFGHJKMNPQRSTUVWXYZ23456789" + "^$*+?%"
|
||||
|
||||
// randomAdminPassword returns a crypto-random 16-character first-run admin
|
||||
// password (SECURITY_TODO #12: no more hardcoded admin/admin).
|
||||
// randomAdminPassword 返回密码学随机的 16 位首次运行管理员密码
|
||||
// (SECURITY_TODO #12:不再硬编码 admin/admin)。
|
||||
func randomAdminPassword() string {
|
||||
b := make([]byte, 16)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
@@ -36,9 +36,9 @@ func randomAdminPassword() string {
|
||||
return string(out)
|
||||
}
|
||||
|
||||
// InitDB opens the database connection, runs migrations, and seeds the admin user.
|
||||
// InitDB 打开数据库连接、执行迁移并初始化管理员用户。
|
||||
func InitDB(cfg *config.Config) *gorm.DB {
|
||||
// Ensure the storage path exists.
|
||||
// 确保存储目录存在。
|
||||
if err := os.MkdirAll(cfg.Path, 0755); err != nil {
|
||||
log.Fatalf("Failed to create storage directory %s: %v", cfg.Path, err)
|
||||
}
|
||||
@@ -63,18 +63,18 @@ func InitDB(cfg *config.Config) *gorm.DB {
|
||||
log.Fatalf("Failed to connect to database: %v", err)
|
||||
}
|
||||
|
||||
// Auto-migrate tables (idempotent).
|
||||
// 自动迁移数据表(幂等操作)。
|
||||
if err := db.AutoMigrate(&User{}, &Article{}, &SiteSetting{}, &UploadConfig{}, &UploadFileType{}, &DownloadBaseURL{}, &Attachment{}, &Comment{}, &CommentConfig{}, &ArticleView{}, &NavLink{}, &Tag{}, &ArticleTag{}); err != nil {
|
||||
log.Fatalf("Failed to auto-migrate database: %v", err)
|
||||
}
|
||||
|
||||
// Seed site platform configuration on first run.
|
||||
// 首次运行时初始化站点平台配置。
|
||||
seedSiteSettings(db)
|
||||
seedUploadConfig(db)
|
||||
seedUploadFileTypes(db)
|
||||
seedCommentConfig(db)
|
||||
|
||||
// First-run seed: create admin user if no users exist.
|
||||
// 首次运行初始化:若不存在任何用户则创建管理员用户。
|
||||
var count int64
|
||||
db.Model(&User{}).Count(&count)
|
||||
if count == 0 {
|
||||
@@ -92,8 +92,8 @@ func InitDB(cfg *config.Config) *gorm.DB {
|
||||
if err := db.Create(admin).Error; err != nil {
|
||||
log.Fatalf("Failed to create admin user: %v", err)
|
||||
}
|
||||
// SECURITY_TODO #12: the first-run password is crypto-random and
|
||||
// printed exactly once — copy it now; it cannot be recovered later.
|
||||
// SECURITY_TODO #12:首次运行密码为密码学随机生成,且只打印一次——
|
||||
// 请立即抄写;之后将无法找回。
|
||||
log.Println("==============================================")
|
||||
log.Println(" First run: created default admin user.")
|
||||
log.Println(" Username: admin")
|
||||
@@ -102,7 +102,7 @@ func InitDB(cfg *config.Config) *gorm.DB {
|
||||
log.Println("==============================================")
|
||||
}
|
||||
|
||||
// Migration fix: always set admin role on the admin user.
|
||||
// 迁移修复:始终为 admin 用户设置管理员角色。
|
||||
result := db.Model(&User{}).Where("username = ?", "admin").Update("role", RoleAdmin)
|
||||
if result.RowsAffected > 0 {
|
||||
log.Printf("Migration: set admin role for existing admin user (rows affected: %d)", result.RowsAffected)
|
||||
|
||||
+4
-5
@@ -5,9 +5,8 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestRandomAdminPassword covers SECURITY_TODO #12: the first-run admin
|
||||
// password comes from the ambiguous-safe alphabet, has fixed length, and
|
||||
// differs between generations.
|
||||
// TestRandomAdminPassword 覆盖 SECURITY_TODO #12:首次运行的管理员密码
|
||||
// 来自无歧义安全的字符表、长度固定,且每次生成结果不同。
|
||||
func TestRandomAdminPassword(t *testing.T) {
|
||||
pw := randomAdminPassword()
|
||||
if len(pw) != 16 {
|
||||
@@ -23,8 +22,8 @@ func TestRandomAdminPassword(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestGravatarOffByDefault covers SECURITY_TODO #15: new deployments must not
|
||||
// leak MD5(email) to Gravatar unless an admin deliberately enables it.
|
||||
// TestGravatarOffByDefault 覆盖 SECURITY_TODO #15:新部署不得将 MD5(邮箱)
|
||||
// 泄露给 Gravatar,除非管理员明确启用。
|
||||
func TestGravatarOffByDefault(t *testing.T) {
|
||||
cc := defaultCommentConfig()
|
||||
if cc.UseGravatar {
|
||||
|
||||
+9
-10
@@ -2,27 +2,26 @@ package models
|
||||
|
||||
import "time"
|
||||
|
||||
// NavLink represents a custom navigation link in the header.
|
||||
// NavLink 表示页头中的自定义导航链接。
|
||||
type NavLink struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
TitleZh string `gorm:"size:100;not null" json:"title_zh"` // Link text (Chinese)
|
||||
TitleEn string `gorm:"size:100;not null" json:"title_en"` // Link text (English)
|
||||
URL string `gorm:"size:512;not null" json:"url"` // Target URL
|
||||
OpenNew bool `gorm:"default:false" json:"open_new"` // Open in new window
|
||||
Enabled bool `gorm:"default:true" json:"enabled"` // Show/hide link
|
||||
Sort int `gorm:"default:0;index" json:"sort"` // Display order (lower = first)
|
||||
TitleZh string `gorm:"size:100;not null" json:"title_zh"` // 链接文本(中文)
|
||||
TitleEn string `gorm:"size:100;not null" json:"title_en"` // 链接文本(英文)
|
||||
URL string `gorm:"size:512;not null" json:"url"` // 目标 URL
|
||||
OpenNew bool `gorm:"default:false" json:"open_new"` // 在新窗口中打开
|
||||
Enabled bool `gorm:"default:true" json:"enabled"` // 显示/隐藏链接
|
||||
Sort int `gorm:"default:0;index" json:"sort"` // 显示顺序(数值越小越靠前)
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
UpdatedBy uint `gorm:"index" json:"updated_by"`
|
||||
}
|
||||
|
||||
// TableName overrides the default GORM table name.
|
||||
// TableName 覆盖 GORM 默认的表名。
|
||||
func (NavLink) TableName() string {
|
||||
return "nav_links"
|
||||
}
|
||||
|
||||
// Title returns the link text for the given language code, falling back to
|
||||
// the other language when the requested one is empty.
|
||||
// Title 返回指定语言代码下的链接文本,当所请求语言为空时回退到另一语言。
|
||||
func (n *NavLink) Title(lang string) string {
|
||||
if lang == "zh" {
|
||||
if n.TitleZh != "" {
|
||||
|
||||
+12
-15
@@ -6,9 +6,8 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// seedSiteSettings inserts the singleton site_settings row (id=1) if absent.
|
||||
// Text fields are left empty so templates fall back to i18n defaults until an
|
||||
// admin configures them.
|
||||
// seedSiteSettings 在不存在时插入单例的 site_settings 行(id=1)。
|
||||
// 文本字段留空,以便模板回退到 i18n 默认值,直到管理员配置它们为止。
|
||||
func seedSiteSettings(db *gorm.DB) {
|
||||
var count int64
|
||||
db.Model(&SiteSetting{}).Count(&count)
|
||||
@@ -21,7 +20,7 @@ func seedSiteSettings(db *gorm.DB) {
|
||||
}
|
||||
}
|
||||
|
||||
// seedUploadConfig inserts the singleton upload_configs row (id=1) if absent.
|
||||
// seedUploadConfig 在不存在时插入单例的 upload_configs 行(id=1)。
|
||||
func seedUploadConfig(db *gorm.DB) {
|
||||
var count int64
|
||||
db.Model(&UploadConfig{}).Count(&count)
|
||||
@@ -39,7 +38,7 @@ func seedUploadConfig(db *gorm.DB) {
|
||||
}
|
||||
}
|
||||
|
||||
// seedCommentConfig inserts the singleton comment_configs row (id=1) if absent.
|
||||
// seedCommentConfig 在不存在时插入单例的 comment_configs 行(id=1)。
|
||||
func seedCommentConfig(db *gorm.DB) {
|
||||
var count int64
|
||||
db.Model(&CommentConfig{}).Count(&count)
|
||||
@@ -51,9 +50,8 @@ func seedCommentConfig(db *gorm.DB) {
|
||||
Enabled: true,
|
||||
AllowGuest: true,
|
||||
GuestRequireApproval: false,
|
||||
// SECURITY_TODO #15: Gravatar reveals MD5(email) via reverse lookup;
|
||||
// disabled by default on new deployments. Admins can re-enable from
|
||||
// the comment settings page.
|
||||
// SECURITY_TODO #15:Gravatar 可通过反向查询暴露 MD5(邮箱);
|
||||
// 新部署默认关闭,管理员可在评论设置页面重新启用。
|
||||
UseGravatar: false,
|
||||
}
|
||||
if err := db.Create(c).Error; err != nil {
|
||||
@@ -61,16 +59,15 @@ func seedCommentConfig(db *gorm.DB) {
|
||||
}
|
||||
}
|
||||
|
||||
// defaultUploadFileTypes is the set of commonly permitted attachment types
|
||||
// seeded on first run.
|
||||
// defaultUploadFileTypes 是首次运行初始化的常用允许附件类型集合。
|
||||
var defaultUploadFileTypes = []UploadFileType{
|
||||
// Images
|
||||
// 图片
|
||||
{Extension: ".jpg", MimeType: "image/jpeg", Category: CategoryImage, Enabled: true, Sort: 1},
|
||||
{Extension: ".jpeg", MimeType: "image/jpeg", Category: CategoryImage, Enabled: true, Sort: 2},
|
||||
{Extension: ".png", MimeType: "image/png", Category: CategoryImage, Enabled: true, Sort: 3},
|
||||
{Extension: ".gif", MimeType: "image/gif", Category: CategoryImage, Enabled: true, Sort: 4},
|
||||
{Extension: ".webp", MimeType: "image/webp", Category: CategoryImage, Enabled: true, Sort: 5},
|
||||
// Documents
|
||||
// 文档
|
||||
{Extension: ".pdf", MimeType: "application/pdf", Category: CategoryDocument, Enabled: true, Sort: 10},
|
||||
{Extension: ".doc", MimeType: "application/msword", Category: CategoryDocument, Enabled: true, Sort: 11},
|
||||
{Extension: ".docx", MimeType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", Category: CategoryDocument, Enabled: true, Sort: 12},
|
||||
@@ -79,16 +76,16 @@ var defaultUploadFileTypes = []UploadFileType{
|
||||
{Extension: ".ppt", MimeType: "application/vnd.ms-powerpoint", Category: CategoryDocument, Enabled: true, Sort: 15},
|
||||
{Extension: ".pptx", MimeType: "application/vnd.openxmlformats-officedocument.presentationml.presentation", Category: CategoryDocument, Enabled: true, Sort: 16},
|
||||
{Extension: ".txt", MimeType: "text/plain", Category: CategoryDocument, Enabled: true, Sort: 17},
|
||||
// Archives
|
||||
// 压缩包
|
||||
{Extension: ".zip", MimeType: "application/zip", Category: CategoryArchive, Enabled: true, Sort: 20},
|
||||
{Extension: ".rar", MimeType: "application/vnd.rar", Category: CategoryArchive, Enabled: true, Sort: 21},
|
||||
{Extension: ".7z", MimeType: "application/x-7z-compressed", Category: CategoryArchive, Enabled: true, Sort: 22},
|
||||
// Video
|
||||
// 视频
|
||||
{Extension: ".mp4", MimeType: "video/mp4", Category: CategoryVideo, Enabled: true, Sort: 30},
|
||||
{Extension: ".avi", MimeType: "video/x-msvideo", Category: CategoryVideo, Enabled: true, Sort: 31},
|
||||
}
|
||||
|
||||
// seedUploadFileTypes seeds the permitted file-type rows if the table is empty.
|
||||
// seedUploadFileTypes 在表为空时初始化允许的文件类型行。
|
||||
func seedUploadFileTypes(db *gorm.DB) {
|
||||
var count int64
|
||||
db.Model(&UploadFileType{}).Count(&count)
|
||||
|
||||
+34
-37
@@ -2,38 +2,37 @@ package models
|
||||
|
||||
import "time"
|
||||
|
||||
// SiteSetting holds the singleton (id=1) global display configuration:
|
||||
// logo, top-left title, header banner text, and footer text, each with
|
||||
// zh/en variants that fall back to i18n defaults when empty.
|
||||
// SiteSetting 保存单例(id=1)的全局展示配置:
|
||||
// 徽标、左上角标题、页头横幅文案和页脚文案,每项均有 zh/en 变体,
|
||||
// 为空时回退到 i18n 默认值。
|
||||
type SiteSetting struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
Logo string `gorm:"size:512" json:"logo"` // local filename (served under /uploads/logos) OR a full URL
|
||||
Favicon string `gorm:"size:512" json:"favicon"` // local filename (served under /uploads/logos) OR a full URL
|
||||
LogoTextZh string `gorm:"size:255" json:"logo_text_zh"` // top-left title (zh)
|
||||
LogoTextEn string `gorm:"size:255" json:"logo_text_en"` // top-left title (en)
|
||||
HeaderTextZh string `gorm:"size:512" json:"header_text_zh"` // header banner text (zh)
|
||||
HeaderTextEn string `gorm:"size:512" json:"header_text_en"` // header banner text (en)
|
||||
HomeWelcomeZh string `gorm:"size:255" json:"home_welcome_zh"` // home page welcome heading (zh)
|
||||
HomeWelcomeEn string `gorm:"size:255" json:"home_welcome_en"` // home page welcome heading (en)
|
||||
HomeSubtitleZh string `gorm:"size:512" json:"home_subtitle_zh"` // home page subtitle (zh)
|
||||
HomeSubtitleEn string `gorm:"size:512" json:"home_subtitle_en"` // home page subtitle (en)
|
||||
FooterTextZh string `gorm:"size:512" json:"footer_text_zh"` // footer text (zh)
|
||||
FooterTextEn string `gorm:"size:512" json:"footer_text_en"` // footer text (en)
|
||||
// SiteURL is the canonical site base URL used for RSS/feed links
|
||||
// (SECURITY_TODO #16); empty falls back to the request Host at runtime.
|
||||
SiteURL string `gorm:"size:512" json:"site_url"`
|
||||
AllowRegistration bool `gorm:"default:false" json:"allow_registration"` // whether users can self-register
|
||||
UpdatedBy uint `gorm:"index" json:"updated_by"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
Logo string `gorm:"size:512" json:"logo"` // 本地文件名(供 /uploads/logos 目录提供)或完整 URL
|
||||
Favicon string `gorm:"size:512" json:"favicon"` // 本地文件名(供 /uploads/logos 目录提供)或完整 URL
|
||||
LogoTextZh string `gorm:"size:255" json:"logo_text_zh"` // 左上角标题(中文)
|
||||
LogoTextEn string `gorm:"size:255" json:"logo_text_en"` // 左上角标题(英文)
|
||||
HeaderTextZh string `gorm:"size:512" json:"header_text_zh"` // 页头横幅文案(中文)
|
||||
HeaderTextEn string `gorm:"size:512" json:"header_text_en"` // 页头横幅文案(英文)
|
||||
HomeWelcomeZh string `gorm:"size:255" json:"home_welcome_zh"` // 首页欢迎标题(中文)
|
||||
HomeWelcomeEn string `gorm:"size:255" json:"home_welcome_en"` // 首页欢迎标题(英文)
|
||||
HomeSubtitleZh string `gorm:"size:512" json:"home_subtitle_zh"`// 首页副标题(中文)
|
||||
HomeSubtitleEn string `gorm:"size:512" json:"home_subtitle_en"`// 首页副标题(英文)
|
||||
FooterTextZh string `gorm:"size:512" json:"footer_text_zh"` // 页脚文案(中文)
|
||||
FooterTextEn string `gorm:"size:512" json:"footer_text_en"` // 页脚文案(英文)
|
||||
// SiteURL 是用于 RSS/订阅链接的规范化站点基础 URL(SECURITY_TODO #16);
|
||||
// 为空时在运行时回退到请求的 Host。
|
||||
SiteURL string `gorm:"size:512" json:"site_url"`
|
||||
AllowRegistration bool `gorm:"default:false" json:"allow_registration"` // 是否允许用户自助注册
|
||||
UpdatedBy uint `gorm:"index" json:"updated_by"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// TableName overrides the default GORM table name.
|
||||
// TableName 覆盖 GORM 默认的表名。
|
||||
func (SiteSetting) TableName() string {
|
||||
return "site_settings"
|
||||
}
|
||||
|
||||
// LogoIsURL reports whether the logo value is an external URL rather than a
|
||||
// local filename.
|
||||
// LogoIsURL 报告徽标值是否为外部 URL 而非本地文件名。
|
||||
func (s *SiteSetting) LogoIsURL() bool {
|
||||
if s == nil || s.Logo == "" {
|
||||
return false
|
||||
@@ -41,8 +40,7 @@ func (s *SiteSetting) LogoIsURL() bool {
|
||||
return len(s.Logo) >= 4 && (s.Logo[:4] == "http")
|
||||
}
|
||||
|
||||
// FaviconIsURL reports whether the favicon value is an external URL rather than a
|
||||
// local filename.
|
||||
// FaviconIsURL 报告 favicon 值是否为外部 URL 而非本地文件名。
|
||||
func (s *SiteSetting) FaviconIsURL() bool {
|
||||
if s == nil || s.Favicon == "" {
|
||||
return false
|
||||
@@ -50,8 +48,7 @@ func (s *SiteSetting) FaviconIsURL() bool {
|
||||
return len(s.Favicon) >= 4 && (s.Favicon[:4] == "http")
|
||||
}
|
||||
|
||||
// LogoText returns the title for the given language code, falling back to the
|
||||
// other language when the requested one is empty.
|
||||
// LogoText 返回指定语言代码下的标题,当所请求语言为空时回退到另一语言。
|
||||
func (s *SiteSetting) LogoText(lang string) string {
|
||||
if lang == "zh" {
|
||||
if s.LogoTextZh != "" {
|
||||
@@ -65,8 +62,8 @@ func (s *SiteSetting) LogoText(lang string) string {
|
||||
return s.LogoTextZh
|
||||
}
|
||||
|
||||
// HeaderText returns the header banner text for the given language code,
|
||||
// falling back to the other language when the requested one is empty.
|
||||
// HeaderText 返回指定语言代码下的页头横幅文案,当所请求语言为空时
|
||||
// 回退到另一语言。
|
||||
func (s *SiteSetting) HeaderText(lang string) string {
|
||||
if lang == "zh" {
|
||||
if s.HeaderTextZh != "" {
|
||||
@@ -80,8 +77,8 @@ func (s *SiteSetting) HeaderText(lang string) string {
|
||||
return s.HeaderTextZh
|
||||
}
|
||||
|
||||
// HomeWelcome returns the home page welcome heading for the given language,
|
||||
// falling back to the other language when the requested one is empty.
|
||||
// HomeWelcome 返回指定语言下的首页欢迎标题,当所请求语言为空时
|
||||
// 回退到另一语言。
|
||||
func (s *SiteSetting) HomeWelcome(lang string) string {
|
||||
if lang == "zh" {
|
||||
if s.HomeWelcomeZh != "" {
|
||||
@@ -95,8 +92,8 @@ func (s *SiteSetting) HomeWelcome(lang string) string {
|
||||
return s.HomeWelcomeZh
|
||||
}
|
||||
|
||||
// HomeSubtitle returns the home page subtitle for the given language,
|
||||
// falling back to the other language when the requested one is empty.
|
||||
// HomeSubtitle 返回指定语言下的首页副标题,当所请求语言为空时
|
||||
// 回退到另一语言。
|
||||
func (s *SiteSetting) HomeSubtitle(lang string) string {
|
||||
if lang == "zh" {
|
||||
if s.HomeSubtitleZh != "" {
|
||||
@@ -110,8 +107,8 @@ func (s *SiteSetting) HomeSubtitle(lang string) string {
|
||||
return s.HomeSubtitleZh
|
||||
}
|
||||
|
||||
// FooterText returns the footer text for the given language code, falling
|
||||
// back to the other language when the requested one is empty.
|
||||
// FooterText 返回指定语言代码下的页脚文案,当所请求语言为空时
|
||||
// 回退到另一语言。
|
||||
func (s *SiteSetting) FooterText(lang string) string {
|
||||
if lang == "zh" {
|
||||
if s.FooterTextZh != "" {
|
||||
|
||||
+14
-14
@@ -7,7 +7,7 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Tag represents a blog post tag with multi-language support.
|
||||
// Tag 表示支持多语言的博客文章标签。
|
||||
type Tag struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
NameZh string `gorm:"size:50;not null" json:"name_zh"`
|
||||
@@ -18,12 +18,12 @@ type Tag struct {
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// TableName overrides the default GORM table name.
|
||||
// TableName 覆盖 GORM 默认的表名。
|
||||
func (Tag) TableName() string {
|
||||
return "tags"
|
||||
}
|
||||
|
||||
// Name returns the tag name for the specified language.
|
||||
// Name 返回指定语言下的标签名称。
|
||||
func (t *Tag) Name(lang string) string {
|
||||
if lang == "zh" {
|
||||
return t.NameZh
|
||||
@@ -31,7 +31,7 @@ func (t *Tag) Name(lang string) string {
|
||||
return t.NameEn
|
||||
}
|
||||
|
||||
// generateTagSlug creates a URL-friendly slug from tag name.
|
||||
// generateTagSlug 根据标签名称生成对 URL 友好的 slug。
|
||||
func generateTagSlug(name string) string {
|
||||
slug := strings.ToLower(strings.TrimSpace(name))
|
||||
slug = strings.ReplaceAll(slug, " ", "-")
|
||||
@@ -39,13 +39,13 @@ func generateTagSlug(name string) string {
|
||||
return slug
|
||||
}
|
||||
|
||||
// FindOrCreateTag finds a tag by name or creates it if it doesn't exist.
|
||||
// If both nameZh and nameEn are provided, it uses them; otherwise uses the same name for both languages.
|
||||
// FindOrCreateTag 按名称查找标签,不存在则创建。
|
||||
// 若同时提供 nameZh 与 nameEn 则分别使用;否则两种语言使用相同的名称。
|
||||
func FindOrCreateTag(db *gorm.DB, nameZh, nameEn string) (*Tag, error) {
|
||||
nameZh = strings.TrimSpace(nameZh)
|
||||
nameEn = strings.TrimSpace(nameEn)
|
||||
|
||||
// If only one name is provided, use it for both languages
|
||||
// 若只提供其中一个名称,两种语言都使用它
|
||||
if nameZh == "" && nameEn != "" {
|
||||
nameZh = nameEn
|
||||
} else if nameEn == "" && nameZh != "" {
|
||||
@@ -68,7 +68,7 @@ func FindOrCreateTag(db *gorm.DB, nameZh, nameEn string) (*Tag, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Create new tag
|
||||
// 创建新标签
|
||||
tag = Tag{
|
||||
NameZh: nameZh,
|
||||
NameEn: nameEn,
|
||||
@@ -83,14 +83,14 @@ func FindOrCreateTag(db *gorm.DB, nameZh, nameEn string) (*Tag, error) {
|
||||
return &tag, nil
|
||||
}
|
||||
|
||||
// GetAllTags returns all tags ordered by count descending.
|
||||
// GetAllTags 按数量降序返回所有标签。
|
||||
func GetAllTags(db *gorm.DB) ([]Tag, error) {
|
||||
var tags []Tag
|
||||
err := db.Order("count DESC, name_zh ASC").Find(&tags).Error
|
||||
return tags, err
|
||||
}
|
||||
|
||||
// GetTagBySlug returns a tag by its slug.
|
||||
// GetTagBySlug 根据 slug 返回标签。
|
||||
func GetTagBySlug(db *gorm.DB, slug string) (*Tag, error) {
|
||||
var tag Tag
|
||||
err := db.Where("slug = ?", slug).First(&tag).Error
|
||||
@@ -100,22 +100,22 @@ func GetTagBySlug(db *gorm.DB, slug string) (*Tag, error) {
|
||||
return &tag, nil
|
||||
}
|
||||
|
||||
// UpdateTagCount recalculates the article count for a tag.
|
||||
// UpdateTagCount 重新计算某个标签的文章数量。
|
||||
func UpdateTagCount(db *gorm.DB, tagID uint) error {
|
||||
var count int64
|
||||
db.Table("article_tags").Where("tag_id = ?", tagID).Count(&count)
|
||||
return db.Model(&Tag{}).Where("id = ?", tagID).Update("count", count).Error
|
||||
}
|
||||
|
||||
// UpdateAllTagCounts recalculates article counts for all tags.
|
||||
// UpdateAllTagCounts 重新计算所有标签的文章数量。
|
||||
func UpdateAllTagCounts(db *gorm.DB) error {
|
||||
// Get all tags
|
||||
// 获取所有标签
|
||||
var tags []Tag
|
||||
if err := db.Find(&tags).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Update count for each tag
|
||||
// 为每个标签更新数量
|
||||
for _, tag := range tags {
|
||||
if err := UpdateTagCount(db, tag.ID); err != nil {
|
||||
return err
|
||||
|
||||
+25
-25
@@ -2,7 +2,7 @@ package models
|
||||
|
||||
import "time"
|
||||
|
||||
// UploadCategory groups file types for the admin UI.
|
||||
// UploadCategory 为管理界面按类别归类文件类型。
|
||||
const (
|
||||
CategoryImage = "image"
|
||||
CategoryDocument = "document"
|
||||
@@ -11,42 +11,42 @@ const (
|
||||
CategoryOther = "other"
|
||||
)
|
||||
|
||||
// DefaultUploadMaxSize is the default per-file size limit (10 MiB), in bytes.
|
||||
// DefaultUploadMaxSize 是默认的单文件大小上限(10 MiB),单位为字节。
|
||||
const DefaultUploadMaxSize int64 = 10 * 1024 * 1024
|
||||
|
||||
// UploadConfig holds the singleton (id=1) global attachment upload policy.
|
||||
// UploadConfig 保存单例(id=1)的全局附件上传策略。
|
||||
type UploadConfig struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
Enabled bool `gorm:"default:true" json:"enabled"` // master switch for attachment uploads
|
||||
DefaultMaxSize int64 `gorm:"default:10485760" json:"default_max_size"` // bytes; overridden per type by UploadFileType.MaxSize
|
||||
StorageDir string `gorm:"size:255;default:attachments" json:"storage_dir"` // sub-dir under cfg.Path
|
||||
UpdatedBy uint `gorm:"index" json:"updated_by"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
Enabled bool `gorm:"default:true" json:"enabled"` // 附件上传总开关
|
||||
DefaultMaxSize int64 `gorm:"default:10485760" json:"default_max_size"` // 字节;可被 UploadFileType.MaxSize 按类型覆盖
|
||||
StorageDir string `gorm:"size:255;default:attachments" json:"storage_dir"` // cfg.Path 下的子目录
|
||||
UpdatedBy uint `gorm:"index" json:"updated_by"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// TableName overrides the default GORM table name.
|
||||
// TableName 覆盖 GORM 默认的表名。
|
||||
func (UploadConfig) TableName() string {
|
||||
return "upload_configs"
|
||||
}
|
||||
|
||||
// UploadFileType describes one permitted attachment extension. Multiple rows.
|
||||
// UploadFileType 描述一种允许的附件扩展名。允许多行记录。
|
||||
type UploadFileType struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
Extension string `gorm:"size:32;uniqueIndex" json:"extension"` // with leading dot, e.g. ".pdf"
|
||||
MimeType string `gorm:"size:128" json:"mime_type"` // associated MIME for validation
|
||||
Category string `gorm:"size:32;index" json:"category"` // image/document/archive/video/other
|
||||
MaxSize int64 `gorm:"default:0" json:"max_size"` // bytes; 0 means use UploadConfig.DefaultMaxSize
|
||||
Extension string `gorm:"size:32;uniqueIndex" json:"extension"` // 带前导点,如 ".pdf"
|
||||
MimeType string `gorm:"size:128" json:"mime_type"` // 用于校验的关联 MIME
|
||||
Category string `gorm:"size:32;index" json:"category"` // image/document/archive/video/other
|
||||
MaxSize int64 `gorm:"default:0" json:"max_size"` // 字节;0 表示使用 UploadConfig.DefaultMaxSize
|
||||
Enabled bool `gorm:"default:true" json:"enabled"`
|
||||
Sort int `gorm:"default:0" json:"sort"`
|
||||
}
|
||||
|
||||
// TableName overrides the default GORM table name.
|
||||
// TableName 覆盖 GORM 默认的表名。
|
||||
func (UploadFileType) TableName() string {
|
||||
return "upload_file_types"
|
||||
}
|
||||
|
||||
// EffectiveMaxSize returns the per-file size limit for this type, falling back
|
||||
// to the provided default when MaxSize is 0.
|
||||
// EffectiveMaxSize 返回该类型下单个文件的大小上限,当 MaxSize 为 0 时
|
||||
// 回退到传入的默认值。
|
||||
func (t *UploadFileType) EffectiveMaxSize(def int64) int64 {
|
||||
if t.MaxSize > 0 {
|
||||
return t.MaxSize
|
||||
@@ -54,21 +54,21 @@ func (t *UploadFileType) EffectiveMaxSize(def int64) int64 {
|
||||
return def
|
||||
}
|
||||
|
||||
// DownloadBaseURL is one source base URL used to build attachment download
|
||||
// links. Multiple rows; the row marked IsDefault (or the highest-priority
|
||||
// enabled one) is used for generated links.
|
||||
// DownloadBaseURL 是一种用于构建附件下载链接的来源基础 URL。
|
||||
// 允许多行记录;标记为 IsDefault(或优先级最高且启用)的行
|
||||
// 用于生成链接。
|
||||
type DownloadBaseURL struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
Name string `gorm:"size:64" json:"name"` // label, e.g. "主站" / "CDN"
|
||||
BaseURL string `gorm:"size:512" json:"base_url"` // e.g. https://cdn.example.com/uploads
|
||||
Priority int `gorm:"default:0" json:"priority"` // lower = higher priority
|
||||
Name string `gorm:"size:64" json:"name"` // 标签,如 "主站" / "CDN"
|
||||
BaseURL string `gorm:"size:512" json:"base_url"` // 例如 https://cdn.example.com/uploads
|
||||
Priority int `gorm:"default:0" json:"priority"` // 数值越小优先级越高
|
||||
IsDefault bool `gorm:"default:false" json:"is_default"`
|
||||
Enabled bool `gorm:"default:true" json:"enabled"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// TableName overrides the default GORM table name.
|
||||
// TableName 覆盖 GORM 默认的表名。
|
||||
func (DownloadBaseURL) TableName() string {
|
||||
return "download_baseurls"
|
||||
}
|
||||
+8
-9
@@ -7,7 +7,7 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Account status constants.
|
||||
// 账号状态常量。
|
||||
const (
|
||||
StatusDisabled = 0 // 禁用
|
||||
StatusNormal = 1 // 正常
|
||||
@@ -15,13 +15,13 @@ const (
|
||||
StatusUnactivated = 3 // 未激活
|
||||
)
|
||||
|
||||
// Role constants.
|
||||
// 角色常量。
|
||||
const (
|
||||
RoleAdmin = "admin"
|
||||
RoleAuthor = "author"
|
||||
)
|
||||
|
||||
// User represents a blog user (author / admin).
|
||||
// User 表示博客用户(作者 / 管理员)。
|
||||
type User struct {
|
||||
gorm.Model
|
||||
Username string `gorm:"uniqueIndex;not null;size:255" json:"username"`
|
||||
@@ -36,13 +36,12 @@ type User struct {
|
||||
Articles []Article `gorm:"foreignKey:AuthorID" json:"-"`
|
||||
}
|
||||
|
||||
// bcryptCost is the work factor used when hashing new passwords
|
||||
// (SECURITY_TODO #17). Existing hashes keep their cost — CompareHashAndPassword
|
||||
// adapts per hash — and are naturally upgraded on the user's next password
|
||||
// change.
|
||||
// bcryptCost 是新密码哈希时使用的工作因子(SECURITY_TODO #17)。
|
||||
// 现有哈希保留其原有成本——CompareHashAndPassword 会按哈希自适应——
|
||||
// 并在用户下次修改密码时自然升级。
|
||||
const bcryptCost = 12
|
||||
|
||||
// SetPassword hashes the plain-text password with bcrypt and stores it.
|
||||
// SetPassword 使用 bcrypt 对明文密码进行哈希并存储。
|
||||
func (u *User) SetPassword(plain string) error {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(plain), bcryptCost)
|
||||
if err != nil {
|
||||
@@ -52,7 +51,7 @@ func (u *User) SetPassword(plain string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// CheckPassword compares a plain-text password against the stored bcrypt hash.
|
||||
// CheckPassword 将明文密码与存储的 bcrypt 哈希进行比对。
|
||||
func (u *User) CheckPassword(plain string) bool {
|
||||
err := bcrypt.CompareHashAndPassword([]byte(u.Password), []byte(plain))
|
||||
return err == nil
|
||||
|
||||
Reference in New Issue
Block a user