Author SHA1 Message Date
dsh 942e3174b9 feat: 头像上传纳入统一上传引擎——files 表登记 type=avatars
- 提取共享上传引擎 saveUploadedFile(handlers/attachment.go):
  内容 SHA-256 寻址写入 + 磁盘去重 + files 表登记,供附件/头像共用
- UploadAvatar 改用该引擎:处理后 JPEG 以哈希命名存储,登记
  models.FileTypeAvatar('avatars')记录;用户 Avatar 字段 = 哈希,
  公开链接 /uploads/avatars/<哈希>(沿用原有 avatars/ 目录与路由)
- 更换头像时:旧登记行软删除;仅当无其他 files 记录或用户引用时
  删除旧磁盘文件(与附件一致的引用计数语义)
- 既有头像安全测试断言从 <uid>.jpg 改为 64 位哈希;
  新增 TestAvatarUploadRegistersFileRow 覆盖登记/磁盘/替换清理全流程
2026-08-28 20:13:13 +08:00
kevin 39609ebf03 Merge pull request 'feat: 全站统一上传文件表 files——上传/下载接口迁移 + attachments 旧表下线' (#5) from dsh/go_blog:feat/unified-files-table into main
Reviewed-on: kevin/go_blog#5
2026-08-28 19:28:21 +08:00
dsh 970dbd4c5b refactor: 上传/下载接口全面迁移到统一 files 表,移除 attachments 模型
- 删除 models/attachment.go,Attachment 模型由 File(Type=attachments)替代
- handlers/attachment.go:上传/删除/列表/绑定全部改读写 files 表,
  查询按 type='attachments' 过滤(为 avatar/logo 等类型预留隔离)
- models/db.go:AutoMigrate 移除 Attachment;启动迁移仅当旧表仍存在时执行
  (升级路径保障,已删除则为无操作)
- 测试同步切换到 File 模型(security_test/bodylimit_test)
- scripts/drop_attachments_table.sql:旧表删除脚本(含前置校验说明)
2026-08-28 19:23:50 +08:00
dsh 09bf35d0dd feat: 新增全站统一上传文件表 files,迁移 attachments 数据(type=attachments)
- models/file.go:File 模型,字段与 Attachment 对齐并新增 Type 归属类型字段
- models/db.go:AutoMigrate 注册 File;启动时幂等迁移 attachments → files
  (按主键 id 对齐跳过已迁移行,含软删除行一并复制)
- scripts/add_files_table.sql:MariaDB 幂等迁移脚本(建表 + INSERT...SELECT)
- 线上库 blog_go:files 表已建立,15 条 attachments 记录已迁入,type='attachments'
2026-08-28 19:16:42 +08:00
kevin 99ea9427bd feat: 启动时自动补齐 config.yaml 缺失的配置项并回写文件 2026-08-28 17:08:09 +08:00
kevin 7280650069 feat: 数据库配置将 MySQL DSN 拆分为独立字段(db_name/username/password/host/port) 2026-08-28 17:02:28 +08:00
kevin 6220de9c84 fix: 编辑/新建文章页 Font Awesome 改用本地 vendored 资源
easyMDE 会注入失效的 maxcdn.bootstrapcdn.com 样式链接导致工具栏图标加载失败。
按 SECURITY_TODO #9 无外部 CDN 政策,本地化 Font Awesome 4.7.0(css+fonts),
并在两处 EasyMDE 配置中禁用 autoDownloadFontAwesome。
2026-08-28 16:26:06 +08:00
kevin 9bdb2d8556 Merge pull request 'fix: 完成 SECURITY_TODO #27–#32 剩余安全修复(限流/魔数校验/最后管理员事务化/置顶收紧/零碎加固)' (#4) from dsh/go_blog:fix/security-todo-27-32 into main
Reviewed-on: kevin/go_blog#4
2026-08-27 23:22:15 +08:00
23 changed files with 3313 additions and 90 deletions

No files matched your search

+12 -2
View File
@@ -56,11 +56,17 @@ go run .
| Linux | `/etc/blog_go/config.yaml` | `/srv/blog_go/` |
| Windows | `./win/etc/blog_go/config.yaml` | `./win/srv/blog_go/` |
已存在的配置文件若缺少配置项(例如升级前的旧文件没有 `database` 段),启动时会自动用默认值补齐并写回。
```yaml
# config.yaml
database:
type: sqlite # sqlite(默认)或 mysql
dsn: "" # MySQL 连接串sqlite 模式下忽略
db_name: "" # MySQL 数据库名sqlite 模式下忽略
username: "" # MySQL 用户名,sqlite 模式下忽略
password: "" # MySQL 密码,sqlite 模式下忽略
host: "" # MySQL IP 或主机名,sqlite 模式下忽略
port: "" # MySQL 端口,sqlite 模式下忽略
web:
port: "8080" # Web 服务端口,"" 或 "0" 可只启用 socket
socket: "" # unix socket 路径(Linux 部署推荐,见 install_linux.sh
@@ -78,7 +84,11 @@ secret: <自动生成> # Session 加密密钥;缺失时拒绝启动
```yaml
database:
type: mysql
dsn: user:password@tcp(127.0.0.1:3306)/blog_go?charset=utf8mb4&parseTime=True&loc=Local
db_name: blog_go
username: user
password: password
host: 127.0.0.1
port: "3306"
web:
port: "8080"
```
+113 -15
View File
@@ -3,10 +3,12 @@ package config
import (
"crypto/rand"
"encoding/hex"
"fmt"
"log"
"os"
"path/filepath"
"runtime"
"strings"
"gopkg.in/yaml.v3"
)
@@ -21,8 +23,20 @@ type Config struct {
// DatabaseConfig 保存数据库相关配置。
type DatabaseConfig struct {
Type string `yaml:"type"` // "sqlite"(默认)或 "mysql"
DSN string `yaml:"dsn"` // MySQL 连接字符串type 为 "mysql" 时必填)
Type string `yaml:"type"` // "sqlite"(默认)或 "mysql"
DBName string `yaml:"db_name"` // 数据库名type 为 "mysql" 时必填)
Username string `yaml:"username"` // 用户名(type 为 "mysql" 时必填)
Password string `yaml:"password"` // 密码(type 为 "mysql" 时必填)
Host string `yaml:"host"` // IP 或主机名(type 为 "mysql" 时必填)
Port string `yaml:"port"` // 端口(type 为 "mysql" 时必填)
}
// MySQLDSN 根据拆分字段构建 MySQL 连接字符串。
func (d *DatabaseConfig) MySQLDSN() string {
return fmt.Sprintf(
"%s:%s@tcp(%s:%s)/%s?charset=utf8mb4&parseTime=True&loc=Local",
d.Username, d.Password, d.Host, d.Port, d.DBName,
)
}
// WebConfig 保存 Web 服务器监听配置。
@@ -41,8 +55,20 @@ var defaultTrustedProxies = []string{"127.0.0.1", "::1"}
const defaultPort = "8080"
// mysqlExampleDSN 会写入新建配置文件,作为参考示例
const mysqlExampleDSN = "user:password@tcp(127.0.0.1:3306)/blog_go?charset=utf8mb4&parseTime=True&loc=Local"
// MySQL 示例值,用于写入新建配置文件或补齐缺失的数据库段
const (
mysqlExampleDBName = "blog_go"
mysqlExampleUser = "user"
mysqlExamplePassword = "password"
mysqlExampleHost = "127.0.0.1"
mysqlExamplePort = "3306"
)
// databaseKeys / webKeys 用于检查配置文件中缺失的子键。
var (
databaseKeys = []string{"type", "db_name", "username", "password", "host", "port"}
webKeys = []string{"port", "socket", "trusted_proxies"}
)
// getConfigPath 返回按操作系统区分的配置目录和配置文件路径。
func getConfigPath() (dir, file string) {
@@ -107,8 +133,12 @@ func LoadConfig(customPath string) *Config {
cfg := &Config{
Database: DatabaseConfig{
Type: "sqlite",
DSN: mysqlExampleDSN,
Type: "sqlite",
DBName: mysqlExampleDBName,
Username: mysqlExampleUser,
Password: mysqlExamplePassword,
Host: mysqlExampleHost,
Port: mysqlExamplePort,
},
Web: WebConfig{
Port: defaultPort,
@@ -118,14 +148,7 @@ func LoadConfig(customPath string) *Config {
Secret: generateSecret(),
}
data, err := yaml.Marshal(cfg)
if err != nil {
log.Fatalf("Failed to marshal default config: %v", err)
}
if err := os.WriteFile(configFile, data, 0640); err != nil {
log.Fatalf("Failed to write config file %s: %v", configFile, err)
}
writeConfigFile(configFile, cfg)
// SECURITY_TODO #11:配置文件保存会话密钥;仅允许所有者读取
// install_linux.sh 已应用 0640 权限)。
log.Printf("Default config created at %s", configFile)
@@ -141,9 +164,84 @@ func LoadConfig(customPath string) *Config {
cfg := &Config{}
if err := yaml.Unmarshal(data, cfg); err != nil {
log.Printf("Warning: malformed config file %s: %v, using defaults", configFile, err)
return applyDefaults(cfg, defaultPath, configFile)
}
return applyDefaults(cfg, defaultPath, configFile)
cfg = applyDefaults(cfg, defaultPath, configFile)
// 检查配置文件里缺失的键(如旧版本没有 database 段),缺失项自动补全并回写。
if missing := missingConfigKeys(data); len(missing) > 0 {
fillDatabaseExamples(&cfg.Database)
writeConfigFile(configFile, cfg)
log.Printf("Config file %s was missing: %s. Added defaults.", configFile, strings.Join(missing, ", "))
}
return cfg
}
// writeConfigFile 以 0640 权限写入配置文件;已有文件保留原权限。
func writeConfigFile(configFile string, cfg *Config) {
data, err := yaml.Marshal(cfg)
if err != nil {
log.Fatalf("Failed to marshal config: %v", err)
}
if err := os.WriteFile(configFile, data, 0640); err != nil {
log.Fatalf("Failed to write config file %s: %v", configFile, err)
}
}
// missingConfigKeys 返回配置文件中缺失的键(含顶层与 database/web 子键)。
// 解析失败时不返回任何缺失(调用方已按 malformed 路径处理)。
func missingConfigKeys(data []byte) []string {
raw := map[string]any{}
if err := yaml.Unmarshal(data, &raw); err != nil {
return nil
}
var missing []string
if db, ok := raw["database"].(map[string]any); ok {
for _, k := range databaseKeys {
if _, ok := db[k]; !ok {
missing = append(missing, "database."+k)
}
}
} else {
missing = append(missing, "database")
}
if web, ok := raw["web"].(map[string]any); ok {
for _, k := range webKeys {
if _, ok := web[k]; !ok {
missing = append(missing, "web."+k)
}
}
} else {
missing = append(missing, "web")
}
if _, ok := raw["path"]; !ok {
missing = append(missing, "path")
}
return missing
}
// fillDatabaseExamples 用示例值填充数据库段中的空字段,用于补齐缺失配置后的回写。
func fillDatabaseExamples(d *DatabaseConfig) {
if d.Type == "" {
d.Type = "sqlite"
}
if d.DBName == "" {
d.DBName = mysqlExampleDBName
}
if d.Username == "" {
d.Username = mysqlExampleUser
}
if d.Password == "" {
d.Password = mysqlExamplePassword
}
if d.Host == "" {
d.Host = mysqlExampleHost
}
if d.Port == "" {
d.Port = mysqlExamplePort
}
}
// applyDefaults 以合理的默认值填充零值字段。
+119
View File
@@ -1,9 +1,13 @@
package config
import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
"gopkg.in/yaml.v3"
)
// TestConfigFileCreatedNotWorldReadable 覆盖 SECURITY_TODO #11
@@ -20,3 +24,118 @@ func TestConfigFileCreatedNotWorldReadable(t *testing.T) {
t.Fatalf("config perms = %v, want 0640", perm)
}
}
func TestMySQLDSN(t *testing.T) {
d := &DatabaseConfig{
DBName: "blog_go",
Username: "user",
Password: "pass:word",
Host: "127.0.0.1",
Port: "3306",
}
want := "user:pass:word@tcp(127.0.0.1:3306)/blog_go?charset=utf8mb4&parseTime=True&loc=Local"
if got := d.MySQLDSN(); got != want {
t.Fatalf("MySQLDSN() = %q, want %q", got, want)
}
}
// TestLoadConfigFillsMissingKeys 覆盖启动时补齐缺失配置项并回写文件:
// 旧格式配置(无 database 段)应被补全,且不覆盖已存在的 path。
func TestLoadConfigFillsMissingKeys(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.yaml")
secret := strings.Repeat("a", 64)
old := "secret: " + secret + "\npath: ./data\n"
if err := os.WriteFile(path, []byte(old), 0640); err != nil {
t.Fatalf("write config: %v", err)
}
cfg := LoadConfig(path)
if cfg.Database.Type != "sqlite" || cfg.Database.DBName != mysqlExampleDBName ||
cfg.Database.Username != mysqlExampleUser || cfg.Database.Password != mysqlExamplePassword ||
cfg.Database.Host != mysqlExampleHost || cfg.Database.Port != mysqlExamplePort {
t.Fatalf("database defaults not filled: %+v", cfg.Database)
}
if cfg.Web.Port != defaultPort {
t.Fatalf("web port = %q, want %q", cfg.Web.Port, defaultPort)
}
if cfg.Path != "./data" {
t.Fatalf("path = %q, want ./data (must not be overwritten)", cfg.Path)
}
st, err := os.Stat(path)
if err != nil {
t.Fatalf("stat config: %v", err)
}
if perm := st.Mode().Perm(); perm != 0640 {
t.Fatalf("config perms = %v, want 0640", perm)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read config: %v", err)
}
back := &Config{}
if err := yaml.Unmarshal(data, back); err != nil {
t.Fatalf("reload config: %v", err)
}
if back.Database.DBName != mysqlExampleDBName {
t.Fatalf("rewritten file db_name = %q, want %q", back.Database.DBName, mysqlExampleDBName)
}
}
// TestLoadConfigKeepsCompleteFile 覆盖配置完整时不回写文件。
func TestLoadConfigKeepsCompleteFile(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.yaml")
content := "database:\n" +
" type: mysql\n" +
" db_name: myblog\n" +
" username: root\n" +
" password: p@ss\n" +
" host: db.local\n" +
" port: \"3307\"\n" +
"web:\n" +
" port: \"8080\"\n" +
" socket: \"\"\n" +
" trusted_proxies:\n" +
" - 127.0.0.1\n" +
" - ::1\n" +
"path: ./data\n" +
"secret: " + strings.Repeat("a", 64) + "\n"
if err := os.WriteFile(path, []byte(content), 0640); err != nil {
t.Fatalf("write config: %v", err)
}
cfg := LoadConfig(path)
if cfg.Database.Password != "p@ss" {
t.Fatalf("password = %q, want p@ss (must not be overwritten)", cfg.Database.Password)
}
after, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read config: %v", err)
}
if !bytes.Equal(after, []byte(content)) {
t.Fatalf("complete config file was rewritten:\n%s", after)
}
}
// TestLoadConfigMalformedNoRewrite 覆盖解析失败的配置文件不被回写。
func TestLoadConfigMalformedNoRewrite(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.yaml")
// web.port 为序列不能解码到 stringyaml.Unmarshal 报错。
content := "secret: " + strings.Repeat("a", 64) + "\nweb:\n port: [8080]\n"
if err := os.WriteFile(path, []byte(content), 0640); err != nil {
t.Fatalf("write config: %v", err)
}
LoadConfig(path)
after, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read config: %v", err)
}
if !bytes.Equal(after, []byte(content)) {
t.Fatalf("malformed config was rewritten:\n%s", after)
}
}
+42 -27
View File
@@ -76,6 +76,8 @@ func canManageArticle(c *gin.Context, db *gorm.DB, articleID uint) bool {
// UploadAttachment 处理来自文章创建/编辑表单的 AJAX 附件上传。
// 请求携带真实的 article_id(编辑页)或 session_token(创建页,待绑定)。
// 文件按 SHA-256 内容寻址,实现磁盘去重。
//
// 记录写入全站统一的 files 表(Type=attachments),与历史数据同属一个表。
func UploadAttachment(db *gorm.DB, storagePath string) gin.HandlerFunc {
return func(c *gin.Context) {
uploaderID, ok := sessionAuthorID(c)
@@ -137,22 +139,9 @@ func UploadAttachment(db *gorm.DB, storagePath string) gin.HandlerFunc {
sum := sha256.Sum256(content)
stored := hex.EncodeToString(sum[:])
// 磁盘去重:仅在文件不存在时才写入。
dir := attachmentsDir(storagePath)
if err := os.MkdirAll(dir, 0755); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create storage dir"})
return
}
dstPath := filepath.Join(dir, stored)
if _, err := os.Stat(dstPath); os.IsNotExist(err) {
if err := os.WriteFile(dstPath, content, 0644); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save file"})
return
}
}
ext := strings.ToLower(filepath.Ext(header.Filename))
att := models.Attachment{
att := models.File{
Type: models.FileTypeAttachment,
ArticleID: articleID,
SessionToken: token,
UploaderID: uploaderID,
@@ -163,8 +152,9 @@ func UploadAttachment(db *gorm.DB, storagePath string) gin.HandlerFunc {
Size: header.Size,
Category: check.Type.Category,
}
if err := db.Create(&att).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to record attachment"})
// 统一上传引擎:磁盘去重写入(SHA-256 内容寻址)+ files 表登记。
if _, err := saveUploadedFile(db, att, attachmentsDir(storagePath), content); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save attachment"})
return
}
@@ -179,16 +169,39 @@ func UploadAttachment(db *gorm.DB, storagePath string) gin.HandlerFunc {
}
}
// saveUploadedFile 是全站统一上传引擎(files 表 + 内容寻址磁盘存储):
// - 确保 dir 存在;
// - 以 f.StoredName(内容 SHA-256 十六进制)为磁盘文件名,文件已存在则
// 跳过写入(磁盘去重,内容寻址文件可被多行记录共享);
// - 在 files 表登记一条记录(Type/UploaderID/ArticleID 等由调用方给定)。
//
// 附件、头像等所有上传类型共用本引擎。
func saveUploadedFile(db *gorm.DB, f models.File, dir string, content []byte) (models.File, error) {
if err := os.MkdirAll(dir, 0755); err != nil {
return f, err
}
dstPath := filepath.Join(dir, f.StoredName)
if _, err := os.Stat(dstPath); os.IsNotExist(err) {
if err := os.WriteFile(dstPath, content, 0644); err != nil {
return f, err
}
}
if err := db.Create(&f).Error; err != nil {
return f, err
}
return f, nil
}
// ---------------- 删除 ----------------
// DeleteAttachment 软删除附件记录,仅当没有其余记录引用时才删除磁盘文件
// (引用计数,因为内容寻址的文件可能被共享)。只有管理员、上传者或
// 文件所在文章的作者可以删除。
// DeleteAttachment 软删除附件记录files 表,Type=attachments),仅当
// 没有其余记录引用时才删除磁盘文件(引用计数,因为内容寻址的文件可能
// 被共享)。只有管理员、上传者或文件所在文章的作者可以删除。
func DeleteAttachment(db *gorm.DB, storagePath string) gin.HandlerFunc {
return func(c *gin.Context) {
id := parseUintParam(c, "id")
var att models.Attachment
if err := db.First(&att, id).Error; err != nil {
var att models.File
if err := db.First(&att, "id = ? AND type = ?", id, models.FileTypeAttachment).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
@@ -214,7 +227,7 @@ func DeleteAttachment(db *gorm.DB, storagePath string) gin.HandlerFunc {
// 引用计数:还有任何其他(未删除)行指向此文件吗?
var count int64
db.Model(&models.Attachment{}).Where("stored_name = ?", stored).Count(&count)
db.Model(&models.File{}).Where("stored_name = ? AND type = ?", stored, models.FileTypeAttachment).Count(&count)
if count == 0 {
os.Remove(filepath.Join(attachmentsDir(storagePath), stored)) // 忽略错误
}
@@ -225,6 +238,7 @@ func DeleteAttachment(db *gorm.DB, storagePath string) gin.HandlerFunc {
// ---------------- 列表 ----------------
// ListAttachments 以 JSON 返回文章的附件(供编辑页加载时重新填充列表)。
// 只读取 files 表中 Type=attachments 的记录。
// 只有文章作者(或管理员)可以列出。
func ListAttachments(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
@@ -237,8 +251,9 @@ func ListAttachments(db *gorm.DB) gin.HandlerFunc {
c.JSON(http.StatusForbidden, gin.H{"error": "forbidden"})
return
}
var atts []models.Attachment
db.Where("article_id = ?", articleID).Order("created_at ASC").Find(&atts)
var atts []models.File
db.Where("article_id = ? AND type = ?", articleID, models.FileTypeAttachment).
Order("created_at ASC").Find(&atts)
out := make([]gin.H, 0, len(atts))
for _, a := range atts {
@@ -264,8 +279,8 @@ func BindPendingAttachments(db *gorm.DB, token string, articleID uint) error {
if token == "" {
return nil
}
return db.Model(&models.Attachment{}).
Where("session_token = ? AND article_id = 0", token).
return db.Model(&models.File{}).
Where("session_token = ? AND article_id = 0 AND type = ?", token, models.FileTypeAttachment).
Updates(map[string]interface{}{"article_id": articleID, "session_token": ""}).Error
}
+167
View File
@@ -0,0 +1,167 @@
package handlers
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"image"
"image/color"
"image/png"
"mime/multipart"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"go_blog/models"
)
// newTestPNG 生成一张指定大小的纯色 PNG。
func newTestPNG(t *testing.T, w, h int, c color.RGBA) []byte {
t.Helper()
img := image.NewRGBA(image.Rect(0, 0, w, h))
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
img.Set(x, y, c)
}
}
var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
t.Fatalf("encode png: %v", err)
}
return buf.Bytes()
}
// isHexString 报告字符串是否全部为十六进制字符。
func isHexString(s string) bool {
if s == "" {
return false
}
for _, r := range s {
if !strings.ContainsRune("0123456789abcdef", r) {
return false
}
}
return true
}
// avatarPOST 以 multipart 提交头像上传(表单字段名 avatar)。
func avatarPOST(e *securityTestEnv, cookie, csrf string, img []byte, filename string) *httptest.ResponseRecorder {
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
fw, _ := mw.CreateFormFile("avatar", filename)
_, _ = fw.Write(img)
_ = mw.Close()
req := httptest.NewRequest(http.MethodPost, "/api/profile/avatar", &buf)
req.Header.Set("Content-Type", mw.FormDataContentType())
req.Header.Set("X-CSRF-Token", csrf)
req.Header.Set("Cookie", cookie)
w := httptest.NewRecorder()
e.router.ServeHTTP(w, req)
return w
}
// TestAvatarUploadRegistersFileRow 验证头像上传走统一上传引擎:
// files 表登记 type=avatars、stored_name=处理内容 SHA-256 哈希、
// 用户 Avatar 字段与磁盘文件 avatars/<哈希>、公开 URL /uploads/avatars/<哈希>。
// 更换头像时:旧登记行软删除;旧磁盘文件无其他引用即清理。
func TestAvatarUploadRegistersFileRow(t *testing.T) {
e := newSecurityTestEnv(t)
// 允许 .png(平台策略允许的图片类型)。
_ = e.db.Create(&models.UploadFileType{Extension: ".png", MimeType: "image/png", Category: models.CategoryImage, Enabled: true})
models.LoadConfigCache(e.db)
alice := e.login(t, "alice")
token := e.csrfTokenFor(t, alice)
aliceID := userIDByUsername(t, e.db, "alice")
// --- 第一次上传 ---
img1 := newTestPNG(t, 16, 16, color.RGBA{R: 200, G: 30, B: 30, A: 255})
w := avatarPOST(e, alice, token, img1, "avatar.png")
if w.Code != http.StatusOK {
t.Fatalf("avatar upload: status = %d, body %s", w.Code, w.Body.String())
}
var resp struct {
Avatar string `json:"avatar"`
}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if len(resp.Avatar) != 64 || !isHexString(resp.Avatar) {
t.Fatalf("avatar stored name = %q, want 64-char sha256 hex", resp.Avatar)
}
name1 := resp.Avatar
// files 表记录:type=avatars + 哈希存储名 + 归属上传者。
var f models.File
if err := e.db.Where("type = ? AND stored_name = ?", models.FileTypeAvatar, name1).First(&f).Error; err != nil {
t.Fatalf("files row (type=avatars) not found: %v", err)
}
if f.UploaderID != aliceID {
t.Fatalf("uploader_id = %d, want %d", f.UploaderID, aliceID)
}
if f.Category != models.CategoryImage || f.Ext != ".jpg" || f.MIME != "image/jpeg" {
t.Fatalf("avatar row category/ext/mime = %q/%q/%q", f.Category, f.Ext, f.MIME)
}
// 用户记录头像 = 哈希。
var u models.User
if err := e.db.First(&u, aliceID).Error; err != nil {
t.Fatalf("load user: %v", err)
}
if u.Avatar != name1 {
t.Fatalf("user.Avatar = %q, want %q", u.Avatar, name1)
}
// 磁盘文件位于 avatars/<哈希>,内容为处理后的 256x256 JPEG,且与哈希一致。
diskPath := filepath.Join(e.storageDir, "avatars", name1)
raw, err := os.ReadFile(diskPath)
if err != nil {
t.Fatalf("avatar disk file missing: %v", err)
}
if len(raw) == 0 {
t.Fatal("avatar disk file is empty")
}
if sum := sha256.Sum256(raw); hex.EncodeToString(sum[:]) != name1 {
t.Fatal("disk file content does not match stored_name (sha256)")
}
// --- 更换头像 ---
img2 := newTestPNG(t, 16, 16, color.RGBA{R: 30, G: 30, B: 200, A: 255})
w = avatarPOST(e, alice, token, img2, "avatar2.png")
if w.Code != http.StatusOK {
t.Fatalf("second avatar upload: status = %d, body %s", w.Code, w.Body.String())
}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode second response: %v", err)
}
if resp.Avatar == "" || resp.Avatar == name1 {
t.Fatalf("second avatar name = %q, want a new hash", resp.Avatar)
}
name2 := resp.Avatar
// 旧的 avatars 登记行已软删除(活动查询不可见,Unscoped 可见且 DeletedAt 非空)。
var oldRow models.File
if err := e.db.Unscoped().Where("type = ? AND stored_name = ?", models.FileTypeAvatar, name1).First(&oldRow).Error; err != nil {
t.Fatalf("old avatar row not found (unscoped): %v", err)
}
if !oldRow.DeletedAt.Valid {
t.Fatal("old avatar row should be soft-deleted")
}
var active models.File
if err := e.db.Where("type = ? AND stored_name = ?", models.FileTypeAvatar, name2).First(&active).Error; err != nil {
t.Fatalf("new avatar row not found: %v", err)
}
// 旧磁盘文件无其他引用,应被清理。
if _, err := os.Stat(filepath.Join(e.storageDir, "avatars", name1)); !os.IsNotExist(err) {
t.Fatal("old avatar disk file should have been removed")
}
if _, err := os.Stat(filepath.Join(e.storageDir, "avatars", name2)); err != nil {
t.Fatalf("new avatar disk file missing: %v", err)
}
}
+1 -1
View File
@@ -91,7 +91,7 @@ func TestBodyLimitRejectsOversizedMultipart(t *testing.T) {
}
var count int64
e.db.Model(&models.Attachment{}).Where("filename = ?", "big.txt").Count(&count)
e.db.Model(&models.File{}).Where("filename = ?", "big.txt").Count(&count)
if count != 0 {
t.Fatalf("oversized multipart created %d attachment rows, want 0", count)
}
+38 -18
View File
@@ -2,6 +2,8 @@ package handlers
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"fmt"
"image"
"image/jpeg"
@@ -219,33 +221,51 @@ func UploadAvatar(db *gorm.DB, storagePath string) gin.HandlerFunc {
return
}
// 确保头像目录存在。
avatarDir := filepath.Join(storagePath, "avatars")
os.MkdirAll(avatarDir, 0755)
// 统一上传引擎:SHA-256 内容寻址写入 avatars/ 目录,并在 files 表
// 登记一条记录(Type=avatars)。公开链接 /uploads/avatars/<哈希>。
sum := sha256.Sum256(processedBytes)
storedName := hex.EncodeToString(sum[:])
// 删除旧头像文件。
if user.Avatar != "" {
oldPath := filepath.Join(avatarDir, user.Avatar)
os.Remove(oldPath)
oldAvatar := user.Avatar // 替换前的旧头像(旧命名 <uid>.jpg 或哈希)
f := models.File{
Type: models.FileTypeAvatar,
UploaderID: user.ID,
Filename: header.Filename,
StoredName: storedName,
Ext: finalExt,
MIME: "image/jpeg",
Size: int64(len(processedBytes)),
Category: models.CategoryImage,
}
// 保存处理后的头像。
savedName := fmt.Sprintf("%d%s", user.ID, finalExt)
savedPath := filepath.Join(avatarDir, savedName)
if err := os.WriteFile(savedPath, processedBytes, 0644); err != nil {
if _, err := saveUploadedFile(db, f, filepath.Join(storagePath, "avatars"), processedBytes); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save avatar"})
return
}
// 更新用户记录。
user.Avatar = savedName
// 更新用户记录与会话(Avatar 存哈希,公开 URL /uploads/avatars/<哈希>
user.Avatar = storedName
db.Save(&user)
// 更新会话。
session.Set("avatar", savedName)
session.Set("avatar", storedName)
session.Save()
c.JSON(http.StatusOK, gin.H{"avatar": savedName})
// 清理旧的头像记录与文件:
// - 软删除旧的 avatars 登记行(保留历史查询痕迹,仅从活动查询隐藏);
// - 仅当无其他 files 记录或其他用户引用时删除磁盘文件,
// 避免误删被共享的内容寻址文件(引用计数语义与附件一致)。
if oldAvatar != "" && oldAvatar != storedName {
db.Where("type = ? AND stored_name = ?", models.FileTypeAvatar, oldAvatar).
Delete(&models.File{})
var refs int64
db.Model(&models.File{}).Where("type = ? AND stored_name = ?", models.FileTypeAvatar, oldAvatar).Count(&refs)
var otherUsers int64
db.Model(&models.User{}).Where("avatar = ? AND id <> ?", oldAvatar, user.ID).Count(&otherUsers)
if refs == 0 && otherUsers == 0 {
os.Remove(filepath.Join(storagePath, "avatars", oldAvatar)) // 忽略错误
}
}
c.JSON(http.StatusOK, gin.H{"avatar": storedName})
}
}
+3 -3
View File
@@ -47,7 +47,7 @@ func newSecurityTestEnv(t *testing.T) *securityTestEnv {
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := db.AutoMigrate(&models.User{}, &models.Article{}, &models.Attachment{}, &models.SiteSetting{},
if err := db.AutoMigrate(&models.User{}, &models.Article{}, &models.File{}, &models.SiteSetting{},
&models.UploadConfig{}, &models.UploadFileType{}, &models.CommentConfig{}, &models.NavLink{},
&models.DownloadBaseURL{}, &models.Comment{}); err != nil {
t.Fatalf("migrate: %v", err)
@@ -439,7 +439,7 @@ func TestAttachmentDeleteRequiresOwnership(t *testing.T) {
t.Fatalf("upload (bob): status = %d, body %s", w.Code, w.Body.String())
}
var bobAtt models.Attachment
var bobAtt models.File
if err := e.db.Where("uploader_id = ?", userIDByUsername(t, e.db, "bob")).First(&bobAtt).Error; err != nil {
t.Fatalf("bob attachment not found: %v", err)
}
@@ -458,7 +458,7 @@ func TestAttachmentDeleteRequiresOwnership(t *testing.T) {
// Bob 的附件记录应该已删除。
var count int64
e.db.Model(&models.Attachment{}).Where("id = ?", bobAtt.ID).Count(&count)
e.db.Model(&models.File{}).Where("id = ?", bobAtt.ID).Count(&count)
if count != 0 {
t.Fatal("attachment was not deleted")
}
+4 -5
View File
@@ -2,7 +2,6 @@ package handlers
import (
"bytes"
"fmt"
"image"
"image/color"
"image/png"
@@ -239,8 +238,8 @@ func TestUploadAvatarRejectsNonImage(t *testing.T) {
t.Fatalf("upload valid png: status=%d body=%s", w.Code, w.Body.String())
}
alice := reloadAlice(t, e)
if want := fmt.Sprintf("%d.jpg", alice.ID); alice.Avatar != want {
t.Fatalf("avatar = %q, want %q", alice.Avatar, want)
if len(alice.Avatar) != 64 || !isHexString(alice.Avatar) {
t.Fatalf("avatar = %q, want 64-char sha256 hex (hash-based name)", alice.Avatar)
}
if _, err := os.Stat(filepath.Join(e.storageDir, "avatars", alice.Avatar)); err != nil {
t.Fatalf("processed avatar file missing: %v", err)
@@ -272,8 +271,8 @@ func TestUpdateProfileAvatarRejectsNonImage(t *testing.T) {
t.Fatalf("upload valid avatar: status=%d body=%s", w.Code, w.Body.String())
}
alice := reloadAlice(t, e)
if want := fmt.Sprintf("%d.jpg", alice.ID); alice.Avatar != want {
t.Fatalf("avatar = %q, want %q", alice.Avatar, want)
if len(alice.Avatar) != 64 || !isHexString(alice.Avatar) {
t.Fatalf("avatar = %q, want 64-char sha256 hex (hash-based name)", alice.Avatar)
}
if _, err := os.Stat(filepath.Join(e.storageDir, "avatars", alice.Avatar)); err != nil {
t.Fatalf("processed avatar file missing: %v", err)
+5 -1
View File
@@ -53,7 +53,11 @@ if [[ ! -f "${CONFIG_DIR}/config.yaml" ]]; then
cat > "${CONFIG_DIR}/config.yaml" <<EOF
database:
type: sqlite
dsn: ""
db_name: ""
username: ""
password: ""
host: ""
port: ""
web:
port: "8080"
socket: ${SOCKET_PATH}
+58 -5
View File
@@ -2,6 +2,7 @@ package models
import (
"crypto/rand"
"errors"
"log"
"os"
"path/filepath"
@@ -36,6 +37,26 @@ func randomAdminPassword() string {
return string(out)
}
// validateMySQLConfig 校验 MySQL 连接所需字段均非空。
func validateMySQLConfig(d *config.DatabaseConfig) error {
if d.DBName == "" {
return errors.New("'db_name' is required when type is 'mysql'")
}
if d.Username == "" {
return errors.New("'username' is required when type is 'mysql'")
}
if d.Password == "" {
return errors.New("'password' is required when type is 'mysql'")
}
if d.Host == "" {
return errors.New("'host' is required when type is 'mysql'")
}
if d.Port == "" {
return errors.New("'port' is required when type is 'mysql'")
}
return nil
}
// InitDB 打开数据库连接、执行迁移并初始化管理员用户。
func InitDB(cfg *config.Config) *gorm.DB {
// 确保存储目录存在。
@@ -47,10 +68,10 @@ func InitDB(cfg *config.Config) *gorm.DB {
switch cfg.Database.Type {
case "mysql":
if cfg.Database.DSN == "" {
log.Fatalf("Database DSN is required when type is 'mysql'. Please set it in your config file.")
if err := validateMySQLConfig(&cfg.Database); err != nil {
log.Fatalf("Invalid MySQL config: %v", err)
}
dialector = mysql.Open(cfg.Database.DSN)
dialector = mysql.Open(cfg.Database.MySQLDSN())
default:
dbPath := filepath.Join(cfg.Path, "blog.db")
dialector = sqlite.Open(dbPath)
@@ -63,11 +84,16 @@ func InitDB(cfg *config.Config) *gorm.DB {
log.Fatalf("Failed to connect to database: %v", err)
}
// 自动迁移数据表(幂等操作)。
if err := db.AutoMigrate(&User{}, &Article{}, &SiteSetting{}, &UploadConfig{}, &UploadFileType{}, &DownloadBaseURL{}, &Attachment{}, &Comment{}, &CommentConfig{}, &ArticleView{}, &NavLink{}, &Tag{}, &ArticleTag{}); err != nil {
// 自动迁移数据表(幂等操作)。attachments 旧表已由 files 替代,
// 不再参与迁移;历史数据在下方 migrateAttachmentsToFiles 中一次性搬运。
if err := db.AutoMigrate(&User{}, &Article{}, &SiteSetting{}, &UploadConfig{}, &UploadFileType{}, &DownloadBaseURL{}, &File{}, &Comment{}, &CommentConfig{}, &ArticleView{}, &NavLink{}, &Tag{}, &ArticleTag{}); err != nil {
log.Fatalf("Failed to auto-migrate database: %v", err)
}
// 数据迁移:attachments → 全站统一 files 表(type='attachments'),幂等。
// 仅当旧表仍存在时执行(已删除则为无操作),保证升级路径上的数据不丢。
migrateAttachmentsToFiles(db)
// 首次运行时初始化站点平台配置。
seedSiteSettings(db)
seedUploadConfig(db)
@@ -111,3 +137,30 @@ func InitDB(cfg *config.Config) *gorm.DB {
DB = db
return db
}
// migrateAttachmentsToFiles 将旧 attachments 表中的历史数据复制到统一的
// files 表,type 一律标记为 "attachments"。仅当 attachments 表仍存在时
// 执行——新安装从未创建过该表,而已经切换的部署会将其删除。
//
// 幂等策略:以主键 id 对齐——files 中已存在同 id 的行视为已迁移并跳过,
// 因此 InitDB 每次启动重复执行也不会产生重复数据(含软删除行一并复制)。
func migrateAttachmentsToFiles(db *gorm.DB) {
if !db.Migrator().HasTable("attachments") {
return
}
res := db.Exec(`
INSERT INTO files
(id, type, article_id, session_token, uploader_id, filename, stored_name, ext, mime, size, category, created_at, updated_at, deleted_at)
SELECT
a.id, 'attachments', a.article_id, a.session_token, a.uploader_id, a.filename,
a.stored_name, a.ext, a.mime, a.size, a.category, a.created_at, a.updated_at, a.deleted_at
FROM attachments a
WHERE NOT EXISTS (SELECT 1 FROM files f WHERE f.id = a.id)`)
if res.Error != nil {
log.Printf("Migration attachments -> files failed: %v", res.Error)
return
}
if res.RowsAffected > 0 {
log.Printf("Migration: copied %d attachment(s) into files table (type=attachments)", res.RowsAffected)
}
}
+23 -13
View File
@@ -6,7 +6,9 @@ import (
"gorm.io/gorm"
)
// Attachment 表示附加到文章的文件
// File 是全站统一的上传文件登记表,用于管理所有类型的上传文件
// (附件、头像、Logo 等)。字段与原 attachments 表对齐,额外通过
// Type 字段区分文件归属类型。
//
// 生命周期(方案 A——先上传后绑定):
// - 在文章创建页面上文章尚不存在,因此 ArticleID 为 0
@@ -17,29 +19,37 @@ import (
//
// 磁盘去重:StoredName 是文件内容的 SHA-256。写入前,
// 处理器会检查磁盘上是否已存在同名文件;若已存在则复用(不重写)。
// 删除采用引用计数——仅当没有任何 Attachment 行引用时,才删除磁盘文件。
type Attachment struct {
// 删除采用引用计数——仅当没有任何 File 行引用时,才删除磁盘文件。
type File struct {
ID uint `gorm:"primarykey" json:"id"`
ArticleID uint `gorm:"index" json:"article_id"` // 在创建页面上待绑定时为 0
SessionToken string `gorm:"size:64;index" json:"-"` // 创建页面上的临时归属令牌
Type string `gorm:"size:32;index;default:attachments" json:"type"` // 归属类型:attachment 等
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"` // 原始文件名
StoredName string `gorm:"size:64;index" json:"stored_name"` // SHA-256 十六进制字符串,磁盘文件名
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"`
Category string `gorm:"size:32" json:"category"` // image/document/archive/video/other
Category string `gorm:"size:32" json:"category"` // image/document/archive/video/other
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at"`
}
// TableName 覆盖 GORM 默认的表名。
func (Attachment) TableName() string {
return "attachments"
func (File) TableName() string {
return "files"
}
// IsImage 报告该附件是否为图片(用于决定 Markdown 插入形式:![]() 还是 []())。
func (a *Attachment) IsImage() bool {
return a.Category == CategoryImage
// FileTypeAttachment 是文件归属类型常量:文章附件(原 attachments 表历史数据)。
const FileTypeAttachment = "attachments"
// FileTypeAvatar 是文件归属类型常量:用户头像(存储于 avatars/ 目录,
// 公开下载链接为 /uploads/avatars/<stored_name>)。
const FileTypeAvatar = "avatars"
// IsImage 报告该文件是否为图片(用于决定 Markdown 插入形式:![]() 还是 []())。
func (f *File) IsImage() bool {
return f.Category == CategoryImage
}
+39
View File
@@ -0,0 +1,39 @@
-- Migration: 新增全站统一上传文件表 files,并把 attachments 数据迁入(type='attachments'
-- Date: 2026-08-28
-- Description: files 表用于管理全站所有上传文件(附件/头像/Logo 等),
-- Type 字段区分归属类型;attachments 历史数据逐行复制,type 填 'attachments'。
-- 幂等:表用 IF NOT EXISTS,数据按主键 id 对齐跳过已迁移行,可重复执行。
-- 1) 建表(与 GORM AutoMigrate 输出一致)
CREATE TABLE IF NOT EXISTS `files` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
`type` varchar(32) DEFAULT 'attachments',
`article_id` bigint(20) unsigned DEFAULT NULL,
`session_token` varchar(64) DEFAULT NULL,
`uploader_id` bigint(20) unsigned DEFAULT NULL,
`filename` varchar(255) DEFAULT NULL,
`stored_name` varchar(64) DEFAULT NULL,
`ext` varchar(32) DEFAULT NULL,
`mime` varchar(128) DEFAULT NULL,
`size` bigint(20) DEFAULT 0,
`category` varchar(32) DEFAULT NULL,
`created_at` datetime(3) DEFAULT NULL,
`updated_at` datetime(3) DEFAULT NULL,
`deleted_at` datetime(3) DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `idx_files_type` (`type`),
KEY `idx_files_article_id` (`article_id`),
KEY `idx_files_session_token` (`session_token`),
KEY `idx_files_uploader_id` (`uploader_id`),
KEY `idx_files_stored_name` (`stored_name`),
KEY `idx_files_deleted_at` (`deleted_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 2) 数据迁移:attachments 全部行(含软删除)复制进 filestype='attachments'
INSERT INTO `files`
(`id`,`type`,`article_id`,`session_token`,`uploader_id`,`filename`,`stored_name`,`ext`,`mime`,`size`,`category`,`created_at`,`updated_at`,`deleted_at`)
SELECT
a.`id`, 'attachments', a.`article_id`, a.`session_token`, a.`uploader_id`, a.`filename`,
a.`stored_name`, a.`ext`, a.`mime`, a.`size`, a.`category`, a.`created_at`, a.`updated_at`, a.`deleted_at`
FROM `attachments` a
WHERE NOT EXISTS (SELECT 1 FROM `files` f WHERE f.`id` = a.`id`);
+11
View File
@@ -0,0 +1,11 @@
-- Migration: 删除旧 attachments 表(数据已迁移至统一 files 表,type='attachments'
-- Date: 2026-08-28
-- 前置条件:已部署使用 files 表的新版 blog_go 并完成验证(启动迁移会把
-- 尚未搬运的 attachments 行复制进 files)。本脚本幂等,可安全重跑。
--
-- 执行前确认:
-- SELECT COUNT(*) AS remaining FROM attachments a
-- WHERE NOT EXISTS (SELECT 1 FROM files f WHERE f.id = a.id);
-- 结果应为 0——若有遗留行,先重启新版本服务让其自动搬运。
DROP TABLE IF EXISTS `attachments`;
File diff suppressed because one or more lines are too long.
Binary file not shown.
File diff suppressed because it is too large. Load diff

After

Width:  |  Height:  |  Size: 434 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
+1
View File
@@ -120,6 +120,7 @@
<script>
var easyMDE = new EasyMDE({
element: document.getElementById('articleContent'),
autoDownloadFontAwesome: false,
spellChecker: false,
autosave: { enabled: false },
placeholder: '{{index .Tr "article_content"}}',
+1
View File
@@ -20,6 +20,7 @@
<link rel="stylesheet" href="/static/vendor/cropper.min.css?v=1">
<script src="/static/vendor/cropper.min.js?v=1"></script>
<link rel="stylesheet" href="/static/vendor/easymde.min.css?v=1">
<link rel="stylesheet" href="/static/vendor/font-awesome/4.7.0/css/font-awesome.min.css?v=1">
<script src="/static/vendor/easymde.min.js?v=1"></script>
</head>
<body class="bg-gray-50 min-h-screen flex flex-col">
+1
View File
@@ -84,6 +84,7 @@ var myEasyMDE = null;
document.addEventListener('DOMContentLoaded', function() {
myEasyMDE = new EasyMDE({
element: document.getElementById('content'),
autoDownloadFontAwesome: false,
spellChecker: false,
status: false,
previewRender: function (plainText, preview) {