Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ae59f756ca | ||
|
|
a3122b04a4 | ||
|
|
942e3174b9 | ||
|
|
a6c1f92197 | ||
|
|
73412e51b4 | ||
|
|
30af358dd7 | ||
|
|
d78ed8e2fe | ||
|
|
f5439ae93a | ||
|
|
134943e4fb | ||
|
|
6216b9af13 | ||
|
|
39609ebf03 | ||
|
|
970dbd4c5b | ||
|
|
09bf35d0dd | ||
|
|
99ea9427bd | ||
|
|
7280650069 | ||
|
|
6220de9c84 | ||
|
|
9bdb2d8556 |
@@ -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
@@ -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 以合理的默认值填充零值字段。
|
||||
|
||||
@@ -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 为序列不能解码到 string,yaml.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)
|
||||
}
|
||||
}
|
||||
+4
-2
@@ -119,15 +119,17 @@ func applyFormToData(data gin.H, f articleForm) {
|
||||
data["SessionToken"] = f.SessionToken
|
||||
}
|
||||
|
||||
// renderArticleForm 使用给定的表单值和可选的错误消息渲染共享的文章表单模板。
|
||||
// renderArticleForm 使用给定的表单值和可选的错误消息渲染管理员工作区的
|
||||
// 文章表单(与作者工作区共用一份模板,FormIsMy=false 表示管理员变体)。
|
||||
func renderArticleForm(c *gin.Context, db *gorm.DB, f articleForm, errMsg string) {
|
||||
data := DefaultData(c)
|
||||
data["Title"] = f.TitleText
|
||||
data["FormIsMy"] = false
|
||||
if errMsg != "" {
|
||||
data["Error"] = errMsg
|
||||
}
|
||||
applyFormToData(data, f)
|
||||
c.HTML(http.StatusOK, "article_create", data)
|
||||
c.HTML(http.StatusOK, "article_form", data)
|
||||
}
|
||||
|
||||
// sessionAuthorID 从会话中提取已登录用户的 ID,兼容 int/uint/int64/float64
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"go_blog/models"
|
||||
)
|
||||
|
||||
// TestArticleDetailEditButton 验证文章页编辑按钮的可见性:
|
||||
// - 文章作者(普通用户)可见,链接指向 /my/articles/:id/edit
|
||||
// - 管理员对所有文章可见,链接指向 /admin/articles/:id/edit
|
||||
// - 其他登录用户与未登录访客不可见
|
||||
func TestArticleDetailEditButton(t *testing.T) {
|
||||
e := newSecurityTestEnv(t)
|
||||
|
||||
var aliceArt models.Article
|
||||
if err := e.db.Where("slug = ?", "alice-post").First(&aliceArt).Error; err != nil {
|
||||
t.Fatalf("alice article not found: %v", err)
|
||||
}
|
||||
id := strconv.FormatUint(uint64(aliceArt.ID), 10)
|
||||
myEdit := "/my/articles/" + id + "/edit"
|
||||
adminEdit := "/admin/articles/" + id + "/edit"
|
||||
|
||||
// 未登录访客:两种链接都不得出现。
|
||||
w := e.do(http.MethodGet, "/article/alice-post", "", nil, "")
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("anonymous GET article: status = %d", w.Code)
|
||||
}
|
||||
if strings.Contains(w.Body.String(), myEdit) || strings.Contains(w.Body.String(), adminEdit) {
|
||||
t.Fatal("anonymous viewer must not see any edit button")
|
||||
}
|
||||
|
||||
// 文章作者:看到 /my/articles/:id/edit。
|
||||
alice := e.login(t, "alice")
|
||||
w = e.do(http.MethodGet, "/article/alice-post", alice, nil, "")
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("author GET article: status = %d", w.Code)
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), myEdit) {
|
||||
t.Fatal("author should see their own edit button")
|
||||
}
|
||||
if strings.Contains(w.Body.String(), adminEdit) {
|
||||
t.Fatal("author must not see the admin edit button")
|
||||
}
|
||||
|
||||
// 其他作者:不可见。
|
||||
bob := e.login(t, "bob")
|
||||
w = e.do(http.MethodGet, "/article/alice-post", bob, nil, "")
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("other author GET article: status = %d", w.Code)
|
||||
}
|
||||
if strings.Contains(w.Body.String(), myEdit) || strings.Contains(w.Body.String(), adminEdit) {
|
||||
t.Fatal("other author must not see the edit button")
|
||||
}
|
||||
|
||||
// 管理员:看到 /admin/articles/:id/edit。
|
||||
admin := e.login(t, "admin")
|
||||
w = e.do(http.MethodGet, "/article/alice-post", admin, nil, "")
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("admin GET article: status = %d", w.Code)
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), adminEdit) {
|
||||
t.Fatal("admin should see the admin edit button")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestArticleFormTemplateVariants 验证管理员与作者工作区共用同一份
|
||||
// article_form 模板(templates/partials/article_form.html)时的两个变体:
|
||||
// - 管理员:含置顶勾选(is_top)/ /api/admin/articles 前缀,作者 API 不出现
|
||||
// - 作者:含状态下拉 / /api/my/articles 前缀,置顶与管理员 API 不出现
|
||||
func TestArticleFormTemplateVariants(t *testing.T) {
|
||||
e := newSecurityTestEnv(t)
|
||||
|
||||
// 管理员新建页(FormIsMy=false 变体)。
|
||||
admin := e.login(t, "admin")
|
||||
w := e.do(http.MethodGet, "/admin/articles/new", admin, nil, "")
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("admin GET /admin/articles/new: status = %d", w.Code)
|
||||
}
|
||||
body := w.Body.String()
|
||||
for _, want := range []string{`id="articleForm"`, `id="articleSessionToken"`, "isTopCheckbox", `"/api/admin/articles"`} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Fatalf("admin variant missing %q", want)
|
||||
}
|
||||
}
|
||||
if strings.Contains(body, "/api/my/articles") {
|
||||
t.Fatal("admin variant must not reference the author API")
|
||||
}
|
||||
|
||||
// 作者新建页(FormIsMy=true 变体)。
|
||||
alice := e.login(t, "alice")
|
||||
w = e.do(http.MethodGet, "/my/articles/new", alice, nil, "")
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("author GET /my/articles/new: status = %d", w.Code)
|
||||
}
|
||||
body = w.Body.String()
|
||||
for _, want := range []string{`id="articleForm"`, `id="articleSessionToken"`, `"/api/my/articles"`, `name="status"`} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Fatalf("author variant missing %q", want)
|
||||
}
|
||||
}
|
||||
for _, forbid := range []string{"isTopCheckbox", "/api/admin/articles"} {
|
||||
if strings.Contains(body, forbid) {
|
||||
t.Fatalf("author variant must not contain %q", forbid)
|
||||
}
|
||||
}
|
||||
}
|
||||
+42
-27
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -248,6 +248,23 @@ func renderArticleDetail(c *gin.Context, db *gorm.DB, article *models.Article, f
|
||||
data["CommentError"] = formErr
|
||||
data["CommentNotice"] = notice
|
||||
data["MaxCommentLength"] = MaxCommentLength
|
||||
|
||||
// 文章页编辑按钮:管理员可编辑全部文章;登录用户仅可编辑自己的文章
|
||||
// (普通作者跳转 /my/articles/:id/edit,编辑页/接口均有 author_id 所有权约束)。
|
||||
canEdit := false
|
||||
editURL := ""
|
||||
uid := userIDFromSession(c)
|
||||
role, _ := c.Get("role")
|
||||
if r, _ := role.(string); r == models.RoleAdmin {
|
||||
canEdit = true
|
||||
editURL = fmt.Sprintf("/admin/articles/%d/edit", article.ID)
|
||||
} else if uid != 0 && uid == article.AuthorID {
|
||||
canEdit = true
|
||||
editURL = fmt.Sprintf("/my/articles/%d/edit", article.ID)
|
||||
}
|
||||
data["CanEdit"] = canEdit
|
||||
data["EditURL"] = editURL
|
||||
|
||||
c.HTML(http.StatusOK, "article", data)
|
||||
}
|
||||
|
||||
|
||||
@@ -177,13 +177,15 @@ func MyArticleDelete(db *gorm.DB) gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// renderMyArticleForm 为普通用户渲染文章表单。
|
||||
// renderMyArticleForm 为普通用户渲染文章表单
|
||||
// (与管理员工作区共用一份模板,FormIsMy=true 表示作者变体)。
|
||||
func renderMyArticleForm(c *gin.Context, db *gorm.DB, f articleForm, errMsg string) {
|
||||
data := DefaultData(c)
|
||||
data["Title"] = f.TitleText
|
||||
data["FormIsMy"] = true
|
||||
if errMsg != "" {
|
||||
data["Error"] = errMsg
|
||||
}
|
||||
applyFormToData(data, f)
|
||||
c.HTML(http.StatusOK, "my_article_form", data)
|
||||
c.HTML(http.StatusOK, "article_form", data)
|
||||
}
|
||||
+38
-18
@@ -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})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -90,6 +90,7 @@ func newSecurityTestEnv(t *testing.T) *securityTestEnv {
|
||||
r.GET("/login", LoginPage())
|
||||
r.GET("/register", RegisterPage(db))
|
||||
r.GET("/rss", RSSFeed(db))
|
||||
r.GET("/article/:slug", ArticleDetail(db))
|
||||
|
||||
api := r.Group("/api")
|
||||
{
|
||||
@@ -105,6 +106,7 @@ func newSecurityTestEnv(t *testing.T) *securityTestEnv {
|
||||
uid, _ := sessionAuthorID(c)
|
||||
c.String(http.StatusOK, "uid=%d", uid)
|
||||
})
|
||||
protected.GET("/articles/new", MyArticleCreatePage(db))
|
||||
}
|
||||
|
||||
myAPI := r.Group("/api/my/articles", middleware.AuthRequired(db))
|
||||
@@ -138,6 +140,7 @@ func newSecurityTestEnv(t *testing.T) *securityTestEnv {
|
||||
{
|
||||
admin.GET("/users/:id/edit", UserEditPage(db))
|
||||
admin.GET("/comments", CommentListPage(db))
|
||||
admin.GET("/articles/new", ArticleCreatePage(db))
|
||||
}
|
||||
|
||||
usersAPI := r.Group("/api/admin/users", middleware.AuthRequired(db), middleware.AdminRequired(db))
|
||||
@@ -439,7 +442,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 +461,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")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -185,6 +185,8 @@ var translations = map[Lang]map[string]string{
|
||||
"article_att_uploading": "Uploading...",
|
||||
"article_att_error": "Upload failed. Please try again.",
|
||||
"article_att_delete_confirm": "Delete this attachment?",
|
||||
"article_image_upload": "Upload image",
|
||||
"article_image_not_image": "The file is not a valid image.",
|
||||
|
||||
// 文章管理
|
||||
"article_list_title": "Articles",
|
||||
@@ -626,6 +628,8 @@ var translations = map[Lang]map[string]string{
|
||||
"article_att_uploading": "上传中...",
|
||||
"article_att_error": "上传失败,请重试。",
|
||||
"article_att_delete_confirm": "删除该附件?",
|
||||
"article_image_upload": "上传图片",
|
||||
"article_image_not_image": "该文件不是有效的图片。",
|
||||
|
||||
// 文章管理
|
||||
"article_list_title": "文章管理",
|
||||
|
||||
+5
-1
@@ -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}
|
||||
|
||||
+83
-5
@@ -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,55 @@ func InitDB(cfg *config.Config) *gorm.DB {
|
||||
DB = db
|
||||
return db
|
||||
}
|
||||
|
||||
// migrateAttachmentsToFiles 将旧 attachments 表中的历史数据复制到统一的
|
||||
// files 表,type 一律标记为 "attachments"。仅当 attachments 表仍存在时
|
||||
// 执行——新安装从未创建过该表,而已经切换的部署会将其删除。
|
||||
//
|
||||
// 幂等与异常恢复策略:
|
||||
// 1. 按主键 id 对齐——files 中已存在同 id 的行视为已迁移并跳过;
|
||||
// 2. 若 attachments 表被重建(例如中途部署过旧版二进制,AutoMigrate 把表
|
||||
// 从 id=1 重新编号),旧 id 已被其他内容占用,改用 stored_name(内容
|
||||
// SHA-256)比对补齐,避免新上传被静默漏搬。
|
||||
// 含软删除行一并复制;重复执行不会产生重复数据。
|
||||
func migrateAttachmentsToFiles(db *gorm.DB) {
|
||||
if !db.Migrator().HasTable("attachments") {
|
||||
return
|
||||
}
|
||||
|
||||
// 1) id 对齐迁移(常规升级路径)。
|
||||
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)
|
||||
}
|
||||
|
||||
// 2) 内容补齐:id 已存在但指向不同内容(重建表后 id 撞号)的行,
|
||||
// 省略 id 让数据库重新分配,且跳过内容已在 files 中登记的行(去重)。
|
||||
res = db.Exec(`
|
||||
INSERT INTO files
|
||||
(type, article_id, session_token, uploader_id, filename, stored_name, ext, mime, size, category, created_at, updated_at, deleted_at)
|
||||
SELECT
|
||||
'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 EXISTS (SELECT 1 FROM files f WHERE f.id = a.id AND f.stored_name <> a.stored_name)
|
||||
AND NOT EXISTS (SELECT 1 FROM files f WHERE f.stored_name = a.stored_name)`)
|
||||
if res.Error != nil {
|
||||
log.Printf("Migration attachments -> files (content fallback) failed: %v", res.Error)
|
||||
return
|
||||
}
|
||||
if res.RowsAffected > 0 {
|
||||
log.Printf("Migration: recovered %d colliding attachment(s) into files table by content", res.RowsAffected)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 全部行(含软删除)复制进 files,type='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`);
|
||||
@@ -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`;
|
||||
Executable
+72
@@ -0,0 +1,72 @@
|
||||
#!/usr/bin/env bash
|
||||
# 存量头像迁移:把旧命名(<uid>.jpg,历史方案)的用户头像迁入统一 files 表
|
||||
# (type='avatars')并用 SHA-256 哈希命名(公开链接 /uploads/avatars/<哈希>)。
|
||||
#
|
||||
# 背景:头像上传自 2026-08 起走统一上传引擎(files 表 + 内容寻址);历史头像
|
||||
# 文件仍以 <uid>.jpg 命名、未被 files 表登记。本脚本按用户逐个迁移:
|
||||
# 1. 取头像文件内容 SHA-256 作为新存储名(内容字节不变,不重新编码);
|
||||
# 2. 复制为 avatars/<哈希> 并登记 files 行(type=avatars);
|
||||
# 3. 更新 users.avatar;
|
||||
# 4. 删除旧的 <uid>.jpg。
|
||||
# 幂等:已是 64 位十六进制哈希名的用户自动跳过;无遗留文件时无操作。
|
||||
#
|
||||
# 用法:
|
||||
# MYSQL_HOST=127.0.0.1 MYSQL_PORT=3306 MYSQL_USER=blog_go MYSQL_PASSWORD=xxx \
|
||||
# MYSQL_DB=blog_go STORAGE_DIR=/srv/blog_go [SVC_USER=blog_go] \
|
||||
# bash scripts/migrate_legacy_avatars.sh
|
||||
set -euo pipefail
|
||||
|
||||
: "${MYSQL_HOST:=127.0.0.1}"
|
||||
: "${MYSQL_PORT:=3306}"
|
||||
: "${MYSQL_USER:?请设置 MYSQL_USER}"
|
||||
: "${MYSQL_PASSWORD:?请设置 MYSQL_PASSWORD}"
|
||||
: "${MYSQL_DB:?请设置 MYSQL_DB}"
|
||||
: "${STORAGE_DIR:?请设置 STORAGE_DIR(存储根目录,头像位于其 avatars/ 子目录)}"
|
||||
: "${SVC_USER:=blog_go}"
|
||||
|
||||
AVATAR_DIR="$STORAGE_DIR/avatars"
|
||||
|
||||
mysqlq() { mysql -h"$MYSQL_HOST" -P"$MYSQL_PORT" -u"$MYSQL_USER" -p"$MYSQL_PASSWORD" "$MYSQL_DB" -N -B -e "$1"; }
|
||||
|
||||
# 前置检查:files 表与头像目录必须存在。
|
||||
if ! mysqlq "SHOW TABLES LIKE 'files'" | grep -q files; then
|
||||
echo "错误:数据库 $MYSQL_DB 中不存在 files 表(需先部署 files 表版本)" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ ! -d "$AVATAR_DIR" ]; then
|
||||
echo "错误:头像目录不存在:$AVATAR_DIR" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
migrated=0
|
||||
skipped=0
|
||||
while IFS=$'\t' read -r uid avatar; do
|
||||
[ -n "$avatar" ] || continue
|
||||
if [[ "$avatar" =~ ^[0-9a-f]{64}$ ]]; then
|
||||
echo "跳过 $uid:已是哈希命名($avatar)"
|
||||
skipped=$((skipped + 1))
|
||||
continue
|
||||
fi
|
||||
src="$AVATAR_DIR/$avatar"
|
||||
if [ ! -f "$src" ]; then
|
||||
echo "跳过 $uid:磁盘文件缺失 $src(不影响 URL,仅提示)"
|
||||
skipped=$((skipped + 1))
|
||||
continue
|
||||
fi
|
||||
hash=$(sha256sum "$src" | awk '{print $1}')
|
||||
dst="$AVATAR_DIR/$hash"
|
||||
if [ ! -f "$dst" ]; then
|
||||
cp "$src" "$dst"
|
||||
chown "$SVC_USER:$SVC_USER" "$dst"
|
||||
fi
|
||||
# 登记 files 行(幂等:同类型同哈希已存在则跳过)。
|
||||
mysqlq "INSERT INTO files (type, article_id, session_token, uploader_id, filename, stored_name, ext, mime, size, category, created_at, updated_at, deleted_at)
|
||||
SELECT 'avatars', 0, '', $uid, '$avatar', '$hash', '.jpg', 'image/jpeg', $(stat -c%s "$src"), 'image', NOW(3), NOW(3), NULL
|
||||
WHERE NOT EXISTS (SELECT 1 FROM files WHERE type='avatars' AND stored_name='$hash')"
|
||||
mysqlq "UPDATE users SET avatar='$hash' WHERE id=$uid AND avatar='$avatar'"
|
||||
rm -f "$src"
|
||||
echo "迁移 $uid:$avatar -> $hash"
|
||||
migrated=$((migrated + 1))
|
||||
done < <(mysqlq "SELECT id, avatar FROM users WHERE avatar <> ''")
|
||||
|
||||
echo "完成:迁移 $migrated 个,跳过 $skipped 个。"
|
||||
@@ -0,0 +1,134 @@
|
||||
// 文章附件区共享逻辑(管理员与普通用户的文章新建/编辑页共用):
|
||||
// 上传(multipart → 附件接口,写 files 表 type=attachments)、编辑页回填列表、
|
||||
// 插入正文(图片 ![]() / 其他 []())、图片一键设封面、删除(引用计数由服务端处理)。
|
||||
//
|
||||
// 由页面调用:initArticleAttachments(cfg)
|
||||
// cfg:
|
||||
// uploadURL 上传接口,如 "/api/my/articles/attachments"(DELETE 为 uploadURL + "/<id>")
|
||||
// listURL 列表接口模板,":id" 会被替换为文章 id,如 "/api/my/articles/:id/attachments"
|
||||
// editor EasyMDE 实例(用于在光标处插入 Markdown)
|
||||
// articleID 文章 id(编辑页);新建页为 0
|
||||
// sessionToken 新建页的临时归属令牌
|
||||
// texts 页面 i18n 文案:{pick, uploading, insert, setCover, coverSet, del, delConfirm, err}
|
||||
window.initArticleAttachments = function (cfg) {
|
||||
var uploadBtn = document.getElementById('attachmentUploadBtn');
|
||||
var fileInput = document.getElementById('attachmentInput');
|
||||
var msgEl = document.getElementById('attachmentMsg');
|
||||
var listEl = document.getElementById('attachmentList');
|
||||
var texts = cfg.texts || {};
|
||||
if (!uploadBtn || !listEl || !fileInput) { return; }
|
||||
|
||||
var meta = document.querySelector('meta[name="csrf-token"]');
|
||||
var csrfToken = meta ? meta.getAttribute('content') : '';
|
||||
|
||||
// Enable the uploader (uploads allowed only while logged in, which is true here).
|
||||
uploadBtn.disabled = false;
|
||||
fileInput.disabled = false;
|
||||
|
||||
function fmtSize(b) {
|
||||
if (b < 1024) { return b + ' B'; }
|
||||
var u = ['KiB', 'MiB', 'GiB'], i = -1;
|
||||
do { b /= 1024; i++; } while (b >= 1024 && i < u.length - 1);
|
||||
return b.toFixed(1) + ' ' + u[i];
|
||||
}
|
||||
|
||||
function insertMd(md) {
|
||||
var cm = cfg.editor && cfg.editor.codemirror;
|
||||
if (!cm) { return; }
|
||||
cm.replaceSelection(md + '\n');
|
||||
cm.focus();
|
||||
}
|
||||
|
||||
function addRow(att) {
|
||||
var tr = document.createElement('tr');
|
||||
tr.className = 'hover:bg-gray-50';
|
||||
tr.dataset.id = att.id;
|
||||
var nameTd = document.createElement('td');
|
||||
nameTd.className = 'px-3 py-2 text-sm text-gray-800';
|
||||
var link = document.createElement('a');
|
||||
link.href = att.url; link.target = '_blank'; link.textContent = att.filename;
|
||||
nameTd.appendChild(link);
|
||||
var sizeTd = document.createElement('td');
|
||||
sizeTd.className = 'px-3 py-2 text-sm text-gray-500';
|
||||
sizeTd.textContent = fmtSize(att.size);
|
||||
var actTd = document.createElement('td');
|
||||
actTd.className = 'px-3 py-2 text-sm text-right whitespace-nowrap';
|
||||
var insBtn = document.createElement('button');
|
||||
insBtn.type = 'button';
|
||||
insBtn.textContent = texts.insert || 'Insert';
|
||||
insBtn.className = 'text-blue-600 hover:text-blue-800 font-medium mr-3 cursor-pointer bg-transparent border-none';
|
||||
insBtn.onclick = function () {
|
||||
var md = att.is_image
|
||||
? ''
|
||||
: '[' + att.filename + '](' + att.url + ')';
|
||||
insertMd(md);
|
||||
};
|
||||
|
||||
// Images: extra button to copy the URL into the cover field.
|
||||
var coverBtn = null;
|
||||
if (att.is_image) {
|
||||
coverBtn = document.createElement('button');
|
||||
coverBtn.type = 'button';
|
||||
coverBtn.textContent = texts.setCover || 'Cover';
|
||||
coverBtn.className = 'text-green-600 hover:text-green-800 font-medium mr-3 cursor-pointer bg-transparent border-none';
|
||||
coverBtn.onclick = function () {
|
||||
var cover = document.querySelector('input[name="cover"]');
|
||||
if (cover) { cover.value = att.url; msgEl.textContent = texts.coverSet || ''; }
|
||||
};
|
||||
}
|
||||
var delBtn = document.createElement('button');
|
||||
delBtn.type = 'button';
|
||||
delBtn.textContent = texts.del || 'Delete';
|
||||
delBtn.className = 'text-red-600 hover:text-red-800 font-medium cursor-pointer bg-transparent border-none';
|
||||
delBtn.onclick = function () {
|
||||
if (!confirm(texts.delConfirm || 'Delete?')) { return; }
|
||||
fetch(cfg.uploadURL + '/' + att.id, {
|
||||
method: 'DELETE',
|
||||
headers: { 'X-CSRF-Token': csrfToken }
|
||||
})
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (r) {
|
||||
if (r.ok) { tr.remove(); }
|
||||
else { msgEl.textContent = r.error || 'error'; }
|
||||
});
|
||||
};
|
||||
actTd.appendChild(insBtn);
|
||||
if (coverBtn) { actTd.appendChild(coverBtn); }
|
||||
actTd.appendChild(delBtn);
|
||||
tr.appendChild(nameTd);
|
||||
tr.appendChild(sizeTd);
|
||||
tr.appendChild(actTd);
|
||||
listEl.appendChild(tr);
|
||||
}
|
||||
|
||||
uploadBtn.addEventListener('click', function () {
|
||||
if (!fileInput.files.length) { msgEl.textContent = texts.pick || 'Pick a file.'; return; }
|
||||
var fd = new FormData();
|
||||
fd.append('file', fileInput.files[0]);
|
||||
if (cfg.articleID) { fd.append('article_id', cfg.articleID); }
|
||||
else if (cfg.sessionToken) { fd.append('session_token', cfg.sessionToken); }
|
||||
msgEl.textContent = texts.uploading || 'Uploading...';
|
||||
fetch(cfg.uploadURL, {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRF-Token': csrfToken },
|
||||
body: fd
|
||||
})
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (r) {
|
||||
if (r.error) { msgEl.textContent = r.error; return; }
|
||||
msgEl.textContent = '';
|
||||
addRow(r);
|
||||
fileInput.value = '';
|
||||
})
|
||||
.catch(function () { msgEl.textContent = texts.err || 'Upload failed.'; });
|
||||
});
|
||||
|
||||
// Edit page: load existing attachments.
|
||||
if (cfg.articleID) {
|
||||
fetch(cfg.listURL.replace(':id', cfg.articleID))
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (r) {
|
||||
(r.attachments || []).forEach(addRow);
|
||||
});
|
||||
}
|
||||
};
|
||||
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,278 +0,0 @@
|
||||
{{define "article_create"}}
|
||||
{{template "header" .}}
|
||||
{{template "markdown_assets" .}}
|
||||
|
||||
<section class="max-w-3xl mx-auto px-4 py-10">
|
||||
<h2 class="text-2xl font-bold text-gray-900 mb-8">{{.FormTitleText}}</h2>
|
||||
|
||||
<div id="articleError" class="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg mb-6 text-sm {{if not .Error}}hidden{{end}}">
|
||||
{{.Error}}
|
||||
</div>
|
||||
|
||||
<form id="articleForm" action="{{.FormAction}}" method="post" class="space-y-6">
|
||||
<input type="hidden" name="_csrf" value="{{.CSRFToken}}">
|
||||
<!-- Hidden: attachment ownership (token on create, id on edit) -->
|
||||
<input type="hidden" name="session_token" value="{{.SessionToken}}">
|
||||
|
||||
<!-- Title -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "article_title"}}</label>
|
||||
<input type="text" name="title" value="{{.FormTitle}}"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors"
|
||||
placeholder="{{index .Tr "article_title"}}">
|
||||
</div>
|
||||
|
||||
<!-- Slug -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "article_slug"}}</label>
|
||||
<input type="text" name="slug" value="{{.FormSlug}}"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors"
|
||||
placeholder="{{index .Tr "article_slug_hint"}}">
|
||||
<p class="text-xs text-gray-400 mt-1">{{index .Tr "article_slug_hint"}}</p>
|
||||
</div>
|
||||
|
||||
<!-- Summary -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "article_summary"}}</label>
|
||||
<textarea name="summary" rows="3"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors resize-y"
|
||||
placeholder="{{index .Tr "article_summary"}}">{{.FormSummary}}</textarea>
|
||||
</div>
|
||||
|
||||
<!-- Content (EasyMDE Markdown Editor) -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "article_content"}}</label>
|
||||
<textarea id="articleContent" name="content">{{.FormContent}}</textarea>
|
||||
</div>
|
||||
|
||||
<!-- Cover -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "article_cover"}}</label>
|
||||
<input type="text" name="cover" value="{{.FormCover}}"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors"
|
||||
placeholder="https://...">
|
||||
</div>
|
||||
|
||||
<!-- Tags -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "article_tags"}}</label>
|
||||
<input type="text" name="tags" value="{{.FormTags}}"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors"
|
||||
placeholder="{{index .Tr "article_tags_hint"}}">
|
||||
<p class="text-xs text-gray-400 mt-1">{{index .Tr "article_tags_hint"}}</p>
|
||||
</div>
|
||||
|
||||
<!-- Attachments -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "article_attachments"}}</label>
|
||||
<div class="flex items-center gap-3 mb-3">
|
||||
<input type="file" id="attachmentInput" class="text-sm text-gray-600" disabled>
|
||||
<button type="button" id="attachmentUploadBtn"
|
||||
class="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg font-medium hover:bg-gray-300 transition-colors cursor-pointer disabled:opacity-50"
|
||||
disabled>
|
||||
{{index .Tr "article_upload"}}
|
||||
</button>
|
||||
<span id="attachmentMsg" class="text-xs text-gray-400"></span>
|
||||
</div>
|
||||
<table class="w-full text-left border border-gray-200 rounded-lg overflow-hidden">
|
||||
<thead class="bg-gray-50 border-b border-gray-200">
|
||||
<tr>
|
||||
<th class="px-3 py-2 text-xs font-semibold text-gray-600">{{index .Tr "article_att_name"}}</th>
|
||||
<th class="px-3 py-2 text-xs font-semibold text-gray-600">{{index .Tr "article_att_size"}}</th>
|
||||
<th class="px-3 py-2 text-xs font-semibold text-gray-600 text-right">{{index .Tr "settings_actions"}}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="attachmentList" class="divide-y divide-gray-100"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Published At -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "article_published_at"}}</label>
|
||||
<input type="datetime-local" name="published_at" value="{{.FormPublishedAt}}"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors">
|
||||
<p class="text-xs text-gray-400 mt-1">{{index .Tr "article_published_at_hint"}}</p>
|
||||
</div>
|
||||
|
||||
<!-- IsTop -->
|
||||
<div class="flex items-center gap-2">
|
||||
<input type="checkbox" name="is_top" value="1" id="isTopCheckbox" {{if .FormIsTop}}checked{{end}}
|
||||
class="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500">
|
||||
<label for="isTopCheckbox" class="text-sm font-medium text-gray-700">{{index .Tr "article_is_top"}}</label>
|
||||
</div>
|
||||
|
||||
<!-- Submit Buttons -->
|
||||
<div class="flex gap-3">
|
||||
<button type="submit" name="status" value="0"
|
||||
class="px-6 py-3 bg-gray-200 text-gray-700 rounded-lg font-medium hover:bg-gray-300 transition-colors cursor-pointer">
|
||||
{{index .Tr "article_save_draft"}}
|
||||
</button>
|
||||
<button type="submit" name="status" value="1"
|
||||
class="px-6 py-3 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 transition-colors cursor-pointer">
|
||||
{{index .Tr "article_publish"}}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{{template "footer" .}}
|
||||
|
||||
<script>
|
||||
var easyMDE = new EasyMDE({
|
||||
element: document.getElementById('articleContent'),
|
||||
spellChecker: false,
|
||||
autosave: { enabled: false },
|
||||
placeholder: '{{index .Tr "article_content"}}',
|
||||
previewRender: function (plainText, preview) {
|
||||
return '<div class="md-body">' + BlogMD.render(plainText) + '</div>';
|
||||
},
|
||||
toolbar: [
|
||||
'bold', 'italic', 'heading', '|',
|
||||
'quote', 'unordered-list', 'ordered-list', '|',
|
||||
'link', 'image', 'code', 'table', '|',
|
||||
'preview', 'side-by-side', 'fullscreen', '|',
|
||||
'guide'
|
||||
],
|
||||
status: false,
|
||||
minHeight: '300px'
|
||||
});
|
||||
|
||||
// ---- Attachments ----
|
||||
(function () {
|
||||
var articleID = {{ if .FormArticleID }}{{ .FormArticleID }}{{ else }}0{{ end }};
|
||||
var sessionToken = "{{ .SessionToken }}";
|
||||
var csrfTokenEl = document.querySelector('meta[name="csrf-token"]');
|
||||
var csrfToken = csrfTokenEl ? csrfTokenEl.getAttribute('content') : '';
|
||||
var uploadBtn = document.getElementById('attachmentUploadBtn');
|
||||
var fileInput = document.getElementById('attachmentInput');
|
||||
var msgEl = document.getElementById('attachmentMsg');
|
||||
var listEl = document.getElementById('attachmentList');
|
||||
|
||||
// Enable the uploader (uploads allowed only while logged in, which is true here).
|
||||
uploadBtn.disabled = false;
|
||||
fileInput.disabled = false;
|
||||
|
||||
function fmtSize(b) {
|
||||
if (b < 1024) return b + ' B';
|
||||
var u = ['KiB', 'MiB', 'GiB'], i = -1;
|
||||
do { b /= 1024; i++; } while (b >= 1024 && i < u.length - 1);
|
||||
return b.toFixed(1) + ' ' + u[i];
|
||||
}
|
||||
|
||||
function addRow(att) {
|
||||
var tr = document.createElement('tr');
|
||||
tr.className = 'hover:bg-gray-50';
|
||||
tr.dataset.id = att.id;
|
||||
var nameTd = document.createElement('td');
|
||||
nameTd.className = 'px-3 py-2 text-sm text-gray-800';
|
||||
var link = document.createElement('a');
|
||||
link.href = att.url; link.target = '_blank'; link.textContent = att.filename;
|
||||
nameTd.appendChild(link);
|
||||
var sizeTd = document.createElement('td');
|
||||
sizeTd.className = 'px-3 py-2 text-sm text-gray-500';
|
||||
sizeTd.textContent = fmtSize(att.size);
|
||||
var actTd = document.createElement('td');
|
||||
actTd.className = 'px-3 py-2 text-sm text-right whitespace-nowrap';
|
||||
var insBtn = document.createElement('button');
|
||||
insBtn.type = 'button';
|
||||
insBtn.textContent = "{{index .Tr "article_att_insert"}}";
|
||||
insBtn.className = 'text-blue-600 hover:text-blue-800 font-medium mr-3 cursor-pointer bg-transparent border-none';
|
||||
insBtn.onclick = function () {
|
||||
var md = att.is_image
|
||||
? ''
|
||||
: '[' + att.filename + '](' + att.url + ')';
|
||||
var cm = easyMDE.codemirror;
|
||||
cm.replaceSelection(md + '\n');
|
||||
cm.focus();
|
||||
};
|
||||
|
||||
// Images: extra button to copy the URL into the cover field.
|
||||
var coverBtn = null;
|
||||
if (att.is_image) {
|
||||
coverBtn = document.createElement('button');
|
||||
coverBtn.type = 'button';
|
||||
coverBtn.textContent = "{{index .Tr "article_att_set_cover"}}";
|
||||
coverBtn.className = 'text-green-600 hover:text-green-800 font-medium mr-3 cursor-pointer bg-transparent border-none';
|
||||
coverBtn.onclick = function () {
|
||||
var cover = document.querySelector('input[name="cover"]');
|
||||
if (cover) { cover.value = att.url; msgEl.textContent = "{{index .Tr "article_att_cover_set"}}"; }
|
||||
};
|
||||
}
|
||||
var delBtn = document.createElement('button');
|
||||
delBtn.type = 'button';
|
||||
delBtn.textContent = "{{index .Tr "settings_delete"}}";
|
||||
delBtn.className = 'text-red-600 hover:text-red-800 font-medium cursor-pointer bg-transparent border-none';
|
||||
delBtn.onclick = function () {
|
||||
if (!confirm("{{index .Tr "article_att_delete_confirm"}}")) return;
|
||||
fetch('/api/admin/articles/attachments/' + att.id, {
|
||||
method: 'DELETE',
|
||||
headers: { 'X-CSRF-Token': csrfToken }
|
||||
})
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (r) {
|
||||
if (r.ok) { tr.remove(); }
|
||||
else { msgEl.textContent = r.error || 'error'; }
|
||||
});
|
||||
};
|
||||
actTd.appendChild(insBtn);
|
||||
if (coverBtn) { actTd.appendChild(coverBtn); }
|
||||
actTd.appendChild(delBtn);
|
||||
tr.appendChild(nameTd);
|
||||
tr.appendChild(sizeTd);
|
||||
tr.appendChild(actTd);
|
||||
listEl.appendChild(tr);
|
||||
}
|
||||
|
||||
uploadBtn.addEventListener('click', function () {
|
||||
if (!fileInput.files.length) { msgEl.textContent = "{{index .Tr "article_att_pick"}}"; return; }
|
||||
var fd = new FormData();
|
||||
fd.append('file', fileInput.files[0]);
|
||||
if (articleID) { fd.append('article_id', articleID); }
|
||||
else { fd.append('session_token', sessionToken); }
|
||||
msgEl.textContent = "{{index .Tr "article_att_uploading"}}";
|
||||
fetch('/api/admin/articles/attachments', {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRF-Token': csrfToken },
|
||||
body: fd
|
||||
})
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (r) {
|
||||
if (r.error) { msgEl.textContent = r.error; return; }
|
||||
msgEl.textContent = '';
|
||||
addRow(r);
|
||||
fileInput.value = '';
|
||||
})
|
||||
.catch(function () { msgEl.textContent = "{{index .Tr "article_att_error"}}"; });
|
||||
});
|
||||
|
||||
// Edit page: load existing attachments.
|
||||
if (articleID) {
|
||||
fetch('/api/admin/articles/' + articleID + '/attachments')
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (r) {
|
||||
(r.attachments || []).forEach(addRow);
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
||||
// ---- Article form submit(JSON API;草稿/发布按钮由 e.submitter 分流) ----
|
||||
(function () {
|
||||
var form = document.getElementById('articleForm');
|
||||
if (!form) return;
|
||||
form.addEventListener('submit', function (e) {
|
||||
e.preventDefault();
|
||||
var btn = e.submitter || null;
|
||||
// EasyMDE 隐藏了 textarea:先同步编辑器内容再提交。
|
||||
var ta = document.getElementById('articleContent');
|
||||
if (ta && easyMDE) { ta.value = easyMDE.value(); }
|
||||
var method = articleID ? 'PUT' : 'POST';
|
||||
var url = articleID ? '/api/admin/articles/' + articleID : '/api/admin/articles';
|
||||
blogAPI(method, url, blogForm(form, btn)).then(function (r) {
|
||||
if (r.ok) { window.location.href = r.redirect || '/admin'; }
|
||||
else { blogShowError('articleError', r.error || 'Failed to save article.'); }
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
{{end}}
|
||||
@@ -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">
|
||||
@@ -202,6 +203,26 @@
|
||||
});
|
||||
};
|
||||
|
||||
// 上传本地图片(multipart)到文章附件接口(files 表,type=attachments)。
|
||||
// 新建页传 session_token,编辑页传 article_id;成功 resolve {…, url, is_image}。
|
||||
// 供文章新建/编辑页编辑器“上传图片”按钮使用。
|
||||
window.blogUploadImage = function (opts) {
|
||||
var meta = document.querySelector('meta[name="csrf-token"]');
|
||||
var csrf = meta ? meta.getAttribute('content') : '';
|
||||
var fd = new FormData();
|
||||
fd.append('file', opts.file);
|
||||
if (opts.articleID) { fd.append('article_id', opts.articleID); }
|
||||
if (!opts.articleID && opts.sessionToken) { fd.append('session_token', opts.sessionToken); }
|
||||
return fetch(opts.url, {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRF-Token': csrf, 'Accept': 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
body: fd
|
||||
}).then(function (r) {
|
||||
return r.json();
|
||||
});
|
||||
};
|
||||
|
||||
// 将表单序列化为 JSON 数据对象:
|
||||
// - 文本/select/textarea 从 FormData 取值(checkboxes 在下述循环覆盖)
|
||||
// - checkbox 一律转 bool(未选中也发送 false,匹配服务端 JSON 绑定)
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
<a href="/" class="inline-flex items-center gap-1 text-sm text-gray-500 hover:text-blue-600 transition-colors">
|
||||
← {{index .Tr "article_back_home"}}
|
||||
</a>
|
||||
{{if eq .Role "admin"}}
|
||||
<a href="/admin/articles/{{.Article.ID}}/edit"
|
||||
{{if .CanEdit}}
|
||||
<a href="{{.EditURL}}"
|
||||
class="inline-flex items-center gap-1 text-sm px-3 py-1.5 rounded-md border border-blue-200 bg-blue-50 text-blue-700 hover:bg-blue-100 hover:border-blue-300 transition-colors"
|
||||
title="{{index .Tr "article_edit"}}">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
{{define "article_attachments"}}
|
||||
<!-- Attachments:全站统一上传(files 表,type=attachments)。
|
||||
上传/删除/列表走当前页面所属的角色 API(/api/admin/... 或 /api/my/...),
|
||||
行为与文案由 static/js/article-attachments.js 的 initArticleAttachments 提供。 -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "article_attachments"}}</label>
|
||||
<div class="flex items-center gap-3 mb-3">
|
||||
<input type="file" id="attachmentInput" class="text-sm text-gray-600" disabled>
|
||||
<button type="button" id="attachmentUploadBtn"
|
||||
class="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg font-medium hover:bg-gray-300 transition-colors cursor-pointer disabled:opacity-50"
|
||||
disabled>
|
||||
{{index .Tr "article_upload"}}
|
||||
</button>
|
||||
<span id="attachmentMsg" class="text-xs text-gray-400"></span>
|
||||
</div>
|
||||
<table class="w-full text-left border border-gray-200 rounded-lg overflow-hidden">
|
||||
<thead class="bg-gray-50 border-b border-gray-200">
|
||||
<tr>
|
||||
<th class="px-3 py-2 text-xs font-semibold text-gray-600">{{index .Tr "article_att_name"}}</th>
|
||||
<th class="px-3 py-2 text-xs font-semibold text-gray-600">{{index .Tr "article_att_size"}}</th>
|
||||
<th class="px-3 py-2 text-xs font-semibold text-gray-600 text-right">{{index .Tr "settings_actions"}}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="attachmentList" class="divide-y divide-gray-100"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -0,0 +1,221 @@
|
||||
{{define "article_form"}}
|
||||
{{template "header" .}}
|
||||
{{template "markdown_assets" .}}
|
||||
|
||||
<section class="max-w-3xl mx-auto px-4 py-10">
|
||||
<h2 class="text-2xl font-bold text-gray-900 mb-8">{{.FormTitleText}}</h2>
|
||||
|
||||
<div id="articleError" class="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg mb-6 text-sm {{if not .Error}}hidden{{end}}">
|
||||
{{.Error}}
|
||||
</div>
|
||||
|
||||
<form id="articleForm" action="{{.FormAction}}" method="post" class="space-y-6">
|
||||
<input type="hidden" name="_csrf" value="{{.CSRFToken}}">
|
||||
<!-- Hidden: attachment ownership (token on create, id on edit) -->
|
||||
<input type="hidden" name="session_token" id="articleSessionToken" value="{{.SessionToken}}">
|
||||
|
||||
<!-- Title -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "article_title"}}</label>
|
||||
<input type="text" name="title" value="{{.FormTitle}}" required
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors"
|
||||
placeholder="{{index .Tr "article_title"}}">
|
||||
</div>
|
||||
|
||||
<!-- Slug -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "article_slug"}}</label>
|
||||
<input type="text" name="slug" value="{{.FormSlug}}"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors"
|
||||
placeholder="{{index .Tr "article_slug_hint"}}">
|
||||
<p class="text-xs text-gray-400 mt-1">{{index .Tr "article_slug_hint"}}</p>
|
||||
</div>
|
||||
|
||||
<!-- Summary -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "article_summary"}}</label>
|
||||
<textarea name="summary" rows="3"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors resize-y"
|
||||
placeholder="{{index .Tr "article_summary"}}">{{.FormSummary}}</textarea>
|
||||
</div>
|
||||
|
||||
<!-- Content (EasyMDE Markdown Editor) -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "article_content"}}</label>
|
||||
<textarea id="articleContent" name="content">{{.FormContent}}</textarea>
|
||||
</div>
|
||||
|
||||
<!-- Cover -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "article_cover"}}</label>
|
||||
<input type="text" name="cover" value="{{.FormCover}}"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors"
|
||||
placeholder="https://...">
|
||||
</div>
|
||||
|
||||
<!-- Tags(管理员工作区;后端两者均支持,作者页暂无入口) -->
|
||||
{{if not .FormIsMy}}
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "article_tags"}}</label>
|
||||
<input type="text" name="tags" value="{{.FormTags}}"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors"
|
||||
placeholder="{{index .Tr "article_tags_hint"}}">
|
||||
<p class="text-xs text-gray-400 mt-1">{{index .Tr "article_tags_hint"}}</p>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<!-- Attachments -->
|
||||
{{template "article_attachments" .}}
|
||||
|
||||
<!-- Published At -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "article_published_at"}}</label>
|
||||
<input type="datetime-local" name="published_at" value="{{.FormPublishedAt}}"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors">
|
||||
<p class="text-xs text-gray-400 mt-1">{{index .Tr "article_published_at_hint"}}</p>
|
||||
</div>
|
||||
|
||||
<!-- IsTop(管理员工作区:普通作者不可置顶全站文章,SECURITY_TODO #31) -->
|
||||
{{if not .FormIsMy}}
|
||||
<div class="flex items-center gap-2">
|
||||
<input type="checkbox" name="is_top" value="1" id="isTopCheckbox" {{if .FormIsTop}}checked{{end}}
|
||||
class="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500">
|
||||
<label for="isTopCheckbox" class="text-sm font-medium text-gray-700">{{index .Tr "article_is_top"}}</label>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if .FormIsMy}}
|
||||
<!-- My workspace:状态下拉 + 保存/取消 -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "article_field_status"}}</label>
|
||||
<select name="status"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors">
|
||||
<option value="0" {{if eq .FormStatus "0"}}selected{{end}}>{{index .Tr "article_draft"}}</option>
|
||||
<option value="1" {{if eq .FormStatus "1"}}selected{{end}}>{{index .Tr "article_published"}}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<button type="submit"
|
||||
class="px-6 py-3 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 transition-colors cursor-pointer">
|
||||
{{index .Tr "article_save"}}
|
||||
</button>
|
||||
<a href="/my/articles"
|
||||
class="px-6 py-3 bg-gray-200 text-gray-700 rounded-lg font-medium hover:bg-gray-300 transition-colors">
|
||||
{{index .Tr "article_cancel"}}
|
||||
</a>
|
||||
</div>
|
||||
{{else}}
|
||||
<!-- Admin workspace:草稿/发布双按钮 -->
|
||||
<div class="flex gap-3">
|
||||
<button type="submit" name="status" value="0"
|
||||
class="px-6 py-3 bg-gray-200 text-gray-700 rounded-lg font-medium hover:bg-gray-300 transition-colors cursor-pointer">
|
||||
{{index .Tr "article_save_draft"}}
|
||||
</button>
|
||||
<button type="submit" name="status" value="1"
|
||||
class="px-6 py-3 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 transition-colors cursor-pointer">
|
||||
{{index .Tr "article_publish"}}
|
||||
</button>
|
||||
</div>
|
||||
{{end}}
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{{template "footer" .}}
|
||||
|
||||
<script src="/static/js/article-attachments.js?v=1"></script>
|
||||
|
||||
<script>
|
||||
// 工作区上下文:管理员 /api/admin/articles,作者 /api/my/articles。
|
||||
// 新建页用 session_token,编辑页用 article_id。
|
||||
var API_BASE = "{{if .FormIsMy}}/api/my/articles{{else}}/api/admin/articles{{end}}";
|
||||
var sessEl = document.getElementById('articleSessionToken');
|
||||
var ATT = {
|
||||
articleID: {{ if .FormArticleID }}{{ .FormArticleID }}{{ else }}0{{ end }},
|
||||
sessionToken: sessEl ? sessEl.value : '',
|
||||
uploadURL: API_BASE + '/attachments',
|
||||
listURL: API_BASE + '/:id/attachments'
|
||||
};
|
||||
// 顶层作用域:供表单提交(PUT/POST 路由选择)与附件共享逻辑共同使用。
|
||||
var articleID = ATT.articleID;
|
||||
var redirectBase = "{{if .FormIsMy}}/my/articles{{else}}/admin{{end}}";
|
||||
|
||||
// 编辑器“上传图片”按钮:选择本地图片 → 上传(files 表,type=attachments)→ 插入正文光标处。
|
||||
function uploadImageAction(editor) {
|
||||
var input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = 'image/*';
|
||||
input.onchange = function () {
|
||||
var f = input.files && input.files[0];
|
||||
if (!f) return;
|
||||
blogUploadImage({ url: ATT.uploadURL, file: f, articleID: ATT.articleID, sessionToken: ATT.sessionToken })
|
||||
.then(function (r) {
|
||||
if (r.error) { blogShowError('articleError', r.error); return; }
|
||||
if (!r.is_image) { blogShowError('articleError', '{{index .Tr "article_image_not_image"}}'); return; }
|
||||
editor.codemirror.replaceSelection('\n');
|
||||
editor.codemirror.focus();
|
||||
});
|
||||
};
|
||||
input.click();
|
||||
}
|
||||
|
||||
var easyMDE = new EasyMDE({
|
||||
element: document.getElementById('articleContent'),
|
||||
autoDownloadFontAwesome: false,
|
||||
spellChecker: false,
|
||||
autosave: { enabled: false },
|
||||
placeholder: '{{index .Tr "article_content"}}',
|
||||
previewRender: function (plainText, preview) {
|
||||
return '<div class="md-body">' + BlogMD.render(plainText) + '</div>';
|
||||
},
|
||||
toolbar: [
|
||||
'bold', 'italic', 'heading', '|',
|
||||
'quote', 'unordered-list', 'ordered-list', '|',
|
||||
'link', { name: 'uploadImage', className: 'fa fa-image', title: '{{index .Tr "article_image_upload"}}', action: uploadImageAction }, 'code', 'table', '|',
|
||||
'preview', 'side-by-side', 'fullscreen', '|',
|
||||
'guide'
|
||||
],
|
||||
status: false,
|
||||
minHeight: '300px'
|
||||
});
|
||||
|
||||
// ---- Attachments(共享逻辑,见 static/js/article-attachments.js) ----
|
||||
initArticleAttachments({
|
||||
uploadURL: ATT.uploadURL,
|
||||
listURL: ATT.listURL,
|
||||
editor: easyMDE,
|
||||
articleID: ATT.articleID,
|
||||
sessionToken: ATT.sessionToken,
|
||||
texts: {
|
||||
pick: '{{index .Tr "article_att_pick"}}',
|
||||
uploading: '{{index .Tr "article_att_uploading"}}',
|
||||
insert: '{{index .Tr "article_att_insert"}}',
|
||||
setCover: '{{index .Tr "article_att_set_cover"}}',
|
||||
coverSet: '{{index .Tr "article_att_cover_set"}}',
|
||||
del: '{{index .Tr "settings_delete"}}',
|
||||
delConfirm: '{{index .Tr "article_att_delete_confirm"}}',
|
||||
err: '{{index .Tr "article_att_error"}}'
|
||||
}
|
||||
});
|
||||
|
||||
// ---- Article form submit(JSON API;admin 的草稿/发布按钮由 e.submitter 分流,
|
||||
// my 的 status 取自 select 字段) ----
|
||||
(function () {
|
||||
var form = document.getElementById('articleForm');
|
||||
if (!form) return;
|
||||
form.addEventListener('submit', function (e) {
|
||||
e.preventDefault();
|
||||
var btn = e.submitter || null;
|
||||
// EasyMDE 隐藏了 textarea:先同步编辑器内容再提交。
|
||||
var ta = document.getElementById('articleContent');
|
||||
if (ta && easyMDE) { ta.value = easyMDE.value(); }
|
||||
var method = articleID ? 'PUT' : 'POST';
|
||||
var url = articleID ? API_BASE + '/' + articleID : API_BASE;
|
||||
blogAPI(method, url, blogForm(form, btn)).then(function (r) {
|
||||
if (r.ok) { window.location.href = r.redirect || redirectBase; }
|
||||
else { blogShowError('articleError', r.error || 'Failed to save article.'); }
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
{{end}}
|
||||
@@ -1,118 +0,0 @@
|
||||
{{define "my_article_form"}}
|
||||
{{template "header" .}}
|
||||
{{template "markdown_assets" .}}
|
||||
<section class="max-w-4xl mx-auto px-4 py-12">
|
||||
<div class="mb-8">
|
||||
<h2 class="text-3xl font-bold text-gray-900">{{.FormTitleText}}</h2>
|
||||
</div>
|
||||
|
||||
<div id="myArticleError" class="mb-6 bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg {{if not .Error}}hidden{{end}}">
|
||||
{{.Error}}
|
||||
</div>
|
||||
|
||||
<form id="myArticleForm" action="{{.FormAction}}" method="post" class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-6">
|
||||
<input type="hidden" name="_csrf" value="{{.CSRFToken}}">
|
||||
{{if .SessionToken}}
|
||||
<input type="hidden" name="session_token" value="{{.SessionToken}}">
|
||||
{{end}}
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">{{index .Tr "article_field_title"}}</label>
|
||||
<input type="text" name="title" value="{{.FormTitle}}" required
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">{{index .Tr "article_field_slug"}}</label>
|
||||
<input type="text" name="slug" value="{{.FormSlug}}"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
<p class="mt-1 text-sm text-gray-500">{{index .Tr "article_slug_help"}}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">{{index .Tr "article_field_summary"}}</label>
|
||||
<textarea name="summary" rows="3"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">{{.FormSummary}}</textarea>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">{{index .Tr "article_field_content"}}</label>
|
||||
<textarea id="content" name="content" rows="20"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">{{.FormContent}}</textarea>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">{{index .Tr "article_field_cover"}}</label>
|
||||
<input type="text" name="cover" value="{{.FormCover}}"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
<p class="mt-1 text-sm text-gray-500">{{index .Tr "article_cover_help"}}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">{{index .Tr "article_published_at"}}</label>
|
||||
<input type="datetime-local" name="published_at" value="{{.FormPublishedAt}}"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
<p class="mt-1 text-sm text-gray-500">{{index .Tr "article_published_at_hint"}}</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">{{index .Tr "article_field_status"}}</label>
|
||||
<select name="status"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
<option value="0" {{if eq .FormStatus "0"}}selected{{end}}>{{index .Tr "article_draft"}}</option>
|
||||
<option value="1" {{if eq .FormStatus "1"}}selected{{end}}>{{index .Tr "article_published"}}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<button type="submit"
|
||||
class="bg-blue-600 text-white px-6 py-2 rounded-lg font-medium hover:bg-blue-700 transition-colors">
|
||||
{{index .Tr "article_save"}}
|
||||
</button>
|
||||
<a href="/my/articles"
|
||||
class="bg-gray-200 text-gray-700 px-6 py-2 rounded-lg font-medium hover:bg-gray-300 transition-colors">
|
||||
{{index .Tr "article_cancel"}}
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<script>
|
||||
var myEasyMDE = null;
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
myEasyMDE = new EasyMDE({
|
||||
element: document.getElementById('content'),
|
||||
spellChecker: false,
|
||||
status: false,
|
||||
previewRender: function (plainText, preview) {
|
||||
return '<div class="md-body">' + BlogMD.render(plainText) + '</div>';
|
||||
},
|
||||
toolbar: ["bold", "italic", "heading", "|", "quote", "unordered-list", "ordered-list", "|",
|
||||
"link", "image", "|", "preview", "side-by-side", "fullscreen", "|", "guide"]
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Form submit(JSON API) ----
|
||||
(function () {
|
||||
var form = document.getElementById('myArticleForm');
|
||||
if (!form) return;
|
||||
var articleId = {{ if .FormArticleID }}{{ .FormArticleID }}{{ else }}0{{ end }};
|
||||
form.addEventListener('submit', function (e) {
|
||||
e.preventDefault();
|
||||
var btn = e.submitter || null;
|
||||
var ta = document.getElementById('content');
|
||||
if (ta && myEasyMDE) { ta.value = myEasyMDE.value(); }
|
||||
var method = articleId ? 'PUT' : 'POST';
|
||||
var url = articleId ? '/api/my/articles/' + articleId : '/api/my/articles';
|
||||
blogAPI(method, url, blogForm(form, btn)).then(function (r) {
|
||||
if (r.ok) { window.location.href = r.redirect || '/my/articles'; }
|
||||
else { blogShowError('myArticleError', r.error || 'Failed to save article.'); }
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
{{template "footer" .}}
|
||||
{{end}}
|
||||
Reference in New Issue
Block a user