7 Commits
Author SHA1 Message Date
kevin 38cd09f723 fix: 安全加固,修复 P0/P1 安全漏洞
P0(高危):
- 新增全局 CSRF 中间件(同步器令牌),覆盖全部 30 个表单与 AJAX 请求
- 修复附件上传/列表/删除越权(IDOR),增加 admin/上传者/文章作者所有权校验
- 登录/注册成功后会话轮换,修复会话固定
- 会话密钥改用 crypto/rand 生成,配置缺失 secret 时拒绝启动

P1(中危):
- session 与 comment_uid cookie 增加 Secure/SameSite 标志
- 新增安全响应头:CSP、X-Content-Type-Options、X-Frame-Options、HSTS 等
- 新增 web.trusted_proxies 配置,修复 X-Forwarded-For 伪造
- 修复浏览量记录 goroutine 访问已回收 gin.Context 的数据竞争

补充 17 个安全回归测试(middleware/handlers),go test -race 全绿
2026-08-19 12:33:09 +08:00
kevin c3f8be58c9 Merge pull request 'fix: 静态资源 URL 加版本号,避免浏览器缓存旧 404 导致持续白屏' (#3) from dsh/go_blog:fix/static-cache-busting into main
Reviewed-on: kevin/go_blog#3
2026-08-18 06:25:32 -04:00
dsh c5218f35a1 fix: cache-bust static asset URLs (?v=2)
Browsers cached the pre-fix 404 responses for /static/js/markdown.js and
/static/css/markdown.css (responses carry Cache-Control max-age=14400),
so users kept seeing broken pages after redeploy. A versioned query
string forces clients to refetch the assets; bump ?v= whenever the
static files change.
2026-08-18 06:25:01 -04:00
kevin 9c0ba8cd6d Merge pull request 'fix: static 资源嵌入二进制,修复部署后 Markdown 内容不显示' (#2) from dsh/go_blog:fix/embed-static-assets into main
Reviewed-on: kevin/go_blog#2
2026-08-18 06:18:47 -04:00
dsh bd3a875403 fix: embed static assets into the binary and never render blank content
The merged markdown-rendering change served /static from a disk directory,
but the live deployment only replaced the binary and templates, so
/static/js/markdown.js and /static/css/markdown.css 404'd and every
article/comment rendered empty (BlogMD was undefined and threw).

- main.go: serve /static from an embedded FS (go:embed) — deployments
  now only need to replace the executable; no static dir to copy.
- install_linux.sh: drop the static directory copy (no longer needed).
- article.html / comment_list.html: if BlogMD is unavailable, fall back
  to plain (HTML-escaped) text for the article body, comment bodies and
  the comment preview, so content is never blank.
- README: document the static/ directory.

Verified locally: /static/* serve 200 even with the disk directory
removed; normal render path (25 checks) and the fallback path both pass.
2026-08-18 06:18:08 -04:00
kevin 5f21c73044 Merge pull request '完善前端 Markdown 渲染:排版样式、代码高亮、标题锚点、图片灯箱、复制按钮' (#1) from dsh/go_blog:feat/markdown-rendering into main
Reviewed-on: kevin/go_blog#1
2026-08-18 06:12:12 -04:00
dsh 83866f6de2 feat: enhance frontend Markdown rendering
- Add shared BlogMD renderer (static/js/markdown.js): marked + DOMPurify
  + highlight.js pipeline with GFM support, heading id slugger with
  CJK-aware anchors, syntax highlighting, per-block copy button and
  language badge, lazy images with lightbox, external links opened
  safely in new tabs, tables wrapped for small screens.
- Add .md-body typography styles (static/css/markdown.css) so articles,
  comments and editor previews render with proper headings, tables,
  lists, blockquotes and code blocks (previously the prose classes had
  no effect because the Tailwind typography plugin is not loaded).
- Fix marked options that were set after parsing and removed from
  marked v4+ (mangle/headerIds no-ops).
- Pin CDN versions (marked 15.0.12, dompurify 3.4.13, highlight.js
  11.12.0) instead of floating 'latest' URLs.
- Wire EasyMDE preview/side-by-side to BlogMD in admin and user
  article editors; use BlogMD for comment bodies on the article page,
  admin comment list and comment preview.
- Serve /static in main.go and deploy it in install_linux.sh.
2026-08-18 06:05:53 -04:00
38 changed files with 2016 additions and 96 deletions
+4
View File
@@ -5,6 +5,7 @@
## 功能特性 ## 功能特性
- **文章管理** — 文章的创建、编辑、删除,支持标签分类、自定义发布时间和更新时间 - **文章管理** — 文章的创建、编辑、删除,支持标签分类、自定义发布时间和更新时间
- **Markdown 渲染** — 文章与评论支持 GFM Markdown(表格、任务列表、删除线),代码块语法高亮 + 一键复制,标题锚点链接,图片懒加载与灯箱预览
- **评论系统** — 文章评论功能,后台可审核(通过/拒绝/删除),支持评论设置 - **评论系统** — 文章评论功能,后台可审核(通过/拒绝/删除),支持评论设置
- **用户系统** — 用户注册、登录,角色分为管理员(admin)和作者(author - **用户系统** — 用户注册、登录,角色分为管理员(admin)和作者(author
- **角色权限** — 管理员可访问后台管理面板;作者可管理自己的文章 - **角色权限** — 管理员可访问后台管理面板;作者可管理自己的文章
@@ -119,6 +120,9 @@ go_blog/
│ └── upload_validator.go # 上传文件校验 │ └── upload_validator.go # 上传文件校验
├── i18n/ ├── i18n/
│ └── i18n.go # 中英文翻译映射 + Accept-Language 检测 │ └── i18n.go # 中英文翻译映射 + Accept-Language 检测
├── static/
│ ├── css/markdown.css # Markdown 排版样式(go:embed 编入二进制)
│ └── js/markdown.js # 前端 Markdown 渲染器(BlogMD
├── templates/ ├── templates/
│ ├── layouts/base.html # 公共布局(导航栏 + 头像下拉菜单 + 页脚) │ ├── layouts/base.html # 公共布局(导航栏 + 头像下拉菜单 + 页脚)
│ ├── pages/ │ ├── pages/
+154
View File
@@ -0,0 +1,154 @@
# 安全修复 TODO
基于 2026-08-19 的安全审计(源码 + haibara.ai 线上验证)整理。
按优先级排序,完成后勾选并标注日期。
---
## P0 — 立即修复
### [x] 1. 会话密钥弱回退(可伪造管理员会话)✅ 2026-08-19
- **位置**: `config/config.go``generateSecret` / `applyDefaults` 回退)
- **问题**: secret 缺失时回退为 SHA-256(主机名+PID),两者均可被外部推测/爆破,攻击者可离线伪造任意用户会话 cookie。
- **修复**:
- [x] `generateSecret()` 改用 `crypto/rand` 生成 32 字节随机数
- [x] 已有配置加载路径中 secret 为空时:拒绝启动(`log.Fatalf`),不再静默回退;配置文件读取失败也改为直接退出
- [x] 首次生成配置文件时写入强随机 secret(保持 `install_linux.sh` 的 openssl 路径不变)
- **验证**: ✅ 新密钥为 crypto/rand 输出;缺失 secret 时启动直接报错
### [x] 2. 全站无 CSRF 防护 ✅ 2026-08-19
- **位置**: 全部 POST 路由(登录/注册/文章/评论/管理后台/设置/附件)
- **问题**: 仅靠 cookie 认证,无 CSRF token;线上 cookie 无 SameSite 属性,浏览器默认 Lax 保护不完整(Chrome Lax+POST 豁免、Safari 差异)。
- **修复**:
- [x] 新增 `middleware/csrf.go`:同步器令牌模式(session 存储、常量时间比较),表单 `_csrf` 字段或 `X-CSRF-Token` 头二选一,不匹配返回 403
- [x] 覆盖全部 30 个 POST 表单(含游客评论表单);AJAX(附件上传/删除、头像上传)经 `<meta name="csrf-token">` 下发 token 并以请求头携带
- [x] `/article/:slug/comments` 游客 POST 一并覆盖(游客同样有 session)
- **验证**: ✅ `middleware/csrf_test.go` 7 用例 + 端到端 curl 冒烟(无 token/伪造 token 403,有效 token 302
### [x] 3. 附件接口越权(IDOR)✅ 2026-08-19
- **位置**: `handlers/attachment.go`DeleteAttachment / ListAttachments / UploadAttachment 的 article_id
- **问题**: `/my/articles/attachments/*` 仅要求登录,无所有权校验;任意登录用户可删除/列出全站任意附件、向他人文章挂附件。
- **修复**:
- [x] `DeleteAttachment`:admin / 上传者 / 所属文章作者三者之一,否则 403
- [x] `ListAttachments`:文章作者或 admin,否则 403
- [x] `UploadAttachment``article_id != 0` 时校验文章归属(admin 除外),否则 403
- [x] 单元测试(`handlers/security_test.go`:越权 403 / 本人 200 / admin 覆盖)
- **验证**: ✅ 普通用户 A 删除用户 B 的附件 -> 403(测试覆盖)
### [x] 4. 会话固定(Session Fixation)✅ 2026-08-19
- **位置**: `handlers/auth.go`Login / Register 自动登录)
- **问题**: 登录成功后未清空旧 session,直接写入 user_id,固定攻击可劫持登录后会话。
- **修复**:
- [x] 认证成功后先 `session.Clear()` 再写入 `user_id`/`username` 并 Save;保留 lang 与 csrf_token(避免多标签页已渲染表单失效)
- **验证**: ✅ 登录前后 cookie 值不同,旧 cookie 无法访问受保护路由(`TestLoginRotatesSession`
---
## P1 — 近期修复
### [x] 5. Cookie 缺 Secure / SameSite 标志 ✅ 2026-08-19
- **位置**: `main.go`session store)、`handlers/comment.go:82`comment_uid
- **修复**:
- [x] store 默认 `SameSite: Lax``Secure` 按请求动态设置(`middleware/https.go` 检测 TLS 或 X-Forwarded-Proto),通过中间件在每次请求时应用到 session cookie
- [x] `comment_uid` 游客 cookie 同步补齐 `SameSite=Lax` + HTTPS 下 `Secure`
- **验证**: ✅ 模拟 HTTPS 请求响应头 `Set-Cookie: ... HttpOnly; Secure; SameSite=Lax`;冒烟测试通过
### [x] 6. 缺失安全响应头 ✅ 2026-08-19
- **位置**: 新增 `middleware/security_headers.go`(全局第一个注册)
- **修复**:
- [x] `Content-Security-Policy`default-src 'self' + 现有 CDN 白名单 + frame-ancestors 'none' 等)
- [x] `X-Content-Type-Options: nosniff``X-Frame-Options: DENY``Referrer-Policy``Permissions-Policy`
- [x] `Strict-Transport-Security`(仅 HTTPS 请求下发,未加 includeSubDomains 以免影响 HTTP 子域)
- **说明**: CSP 含 `'unsafe-inline'`(模板内联 script/style 必需);待 P2-9 CDN 本地化后可进一步收紧
- **验证**: ✅ `middleware/security_headers_test.go`headers 存在性、HSTS 条件下发)+ 冒烟 curl 确认
### [x] 7. X-Forwarded-For 伪造(IP 审计/浏览量可刷)✅ 2026-08-19
- **位置**: `handlers/helpers.go`GetClientIP)、`config/config.go`WebConfig.TrustedProxies)、`main.go`
- **修复**:
- [x] 删除手动解析 XFF 首值逻辑,`GetClientIP` 改为 `c.ClientIP()`
- [x] `router.SetTrustedProxies(cfg.Web.TrustedProxies)`;新增 `web.trusted_proxies` 配置项(默认 `["127.0.0.1", "::1"]`unix socket 部署自动信任)
- [x] gin 内部 XFF 从右往左取第一个不可信 IP:直接客户端伪造的 XFF 被忽略
- **验证**: ✅ `middleware/clientip_test.go`(直接连接带假 XFF 取真实 IP / 代理链取最右不可信条目)
### [x] 8. goroutine 数据竞争(use-after-return)✅ 2026-08-19
- **位置**: `handlers/home.go`ArticleDetail → recordArticleView
- **修复**:
- [x] goroutine 启动前同步提取 userID / ip / UA 为局部变量,`recordArticleView` 不再触碰 gin.Context 与 session
- **验证**: ✅ `go test -race ./...` 全绿
---
## P2 — 计划修复
### [ ] 9. 第三方 CDN 无 SRI / Tailwind dev CDN
- **位置**: `templates/layouts/base.html:16-20``:118-122`
- **修复**:
- [ ] 将 marked / DOMPurify / highlight.js / cropperjs / easymde 下载到 `static/vendor/`,走 go:embed 本地分发(静态管线已具备)
- [ ] 替换 `cdn.tailwindcss.com` 为构建期生成的静态 CSS(或至少加 SRI)
- [ ] 本地化后配合 #6 收紧 CSP 为 `default-src 'self'`
- **验证**: 断网第三方域名后页面渲染功能完整;CSP 无违规报告
### [ ] 10. 登录无速率限制
- **位置**: `handlers/auth.go:33`
- **修复**:
- [ ] 按 IP + 用户名维度做失败计数(内存或 DB),如 5 次失败锁定 15 分钟
- [ ] 失败提示保持统一(现有 `?error=1` 已做用户名枚举防护,保持)
- **验证**: 连续错误登录后返回锁定提示
### [ ] 11. 配置文件权限过宽
- **位置**: `config/config.go:115`
- **修复**: `os.WriteFile(configFile, data, 0640)`secret 写入后可选 `os.Chmod`
### [ ] 12. 首启弱凭据 admin/admin
- **位置**: `models/db.go:58-81`
- **修复**:
- [ ] 方案 A:首启生成随机密码打印一次性提示
- [ ] 方案 B:admin 账户标记"必须改密",登录后强制跳转改密页
- **说明**: 线上已改密(已验证),此项为防御新部署
### [ ] 13. Unix socket 权限 666
- **位置**: `install_linux.sh:80`
- **修复**: `chmod 660` + `chown root:blog_go`(反向代理进程加入同组),避免本机任意用户绕过 Cloudflare 直连
---
## P3 — 低优先级 / 观察项
### [ ] 14. 上传不校验文件真实类型
- **位置**: `handlers/upload_validator.go:33-58`
- **修复**: 用 `github.com/gabriel-vasile/mimetype`(已在依赖树中)校验 magic bytes 与扩展名/MIME 一致;不一致则拒绝
- **说明**: 白名单无 .svg/.html,存储型 XSS 风险低,主要是恶意文件托管风险
### [ ] 15. Gravatar MD5 邮箱哈希可反查
- **位置**: `handlers/comment.go:88-91`
- **说明**: Gravatar 协议本身如此;若在意隐私可加后台开关(已有 UseGravatar 开关可关闭)
### [ ] 16. RSS 以 Host 头构造 baseURL
- **位置**: `handlers/rss.go:60-64`
- **修复**: 从站点设置中读取固定站点 URL,仅在与请求 Host 不符时告警
- **说明**: Cloudflare 会校验 Host,实际可利用性低
### [ ] 17. bcrypt cost 偏低
- **位置**: `models/user.go:41`DefaultCost=10
- **修复**: 提升到 12;已有哈希在用户下次改密时自然升级
---
## 不需要修复(已确认安全)
- SQL 注入:全参数化查询(GORM)
- XSShtml/template 自动转义 + 评论双防御(服务端 strip + DOMPurify
- 密码哈希:bcrypt
- 附件路径穿越:SHA-256 内容寻址文件名
- 线上默认凭据:已修改(已验证)
- 注册接口:已关闭(已验证)
---
## 建议执行顺序
1. **#1#4#5**(一次提交:会话安全三件套,改动小、风险低)
2. **#3**(附件越权,纯 handler 层校验)
3. **#2**(CSRF,涉及全站表单,改动面最大,单独一个 PR 充分回归)
4. **#7#8#6**IP/竞态/响应头)
5. P2/P3 按迭代排入
+33 -15
View File
@@ -1,8 +1,8 @@
package config package config
import ( import (
"crypto/sha256" "crypto/rand"
"fmt" "encoding/hex"
"log" "log"
"os" "os"
"path/filepath" "path/filepath"
@@ -27,10 +27,19 @@ type DatabaseConfig struct {
// WebConfig holds web-server listening configuration. // WebConfig holds web-server listening configuration.
type WebConfig struct { type WebConfig struct {
Port string `yaml:"port"` // TCP port, "" or "0" to disable Port string `yaml:"port"` // TCP port, "" or "0" to disable
Socket string `yaml:"socket"` // Unix socket path, "" to disable Socket string `yaml:"socket"` // Unix socket path, "" to disable
// TrustedProxies lists proxy IPs/CIDRs whose X-Forwarded-For /
// X-Forwarded-Proto headers are trusted (e.g. the Caddy/nginx box in
// front of the app). Defaults to loopback. If the app is exposed
// directly to clients, leave the default so client-supplied
// X-Forwarded-For cannot spoof the logged IP.
TrustedProxies []string `yaml:"trusted_proxies"`
} }
// defaultTrustedProxies is used when the config omits trusted_proxies.
var defaultTrustedProxies = []string{"127.0.0.1", "::1"}
const defaultPort = "8080" const defaultPort = "8080"
// mysqlExampleDSN is written into new config files as a reference. // mysqlExampleDSN is written into new config files as a reference.
@@ -62,12 +71,15 @@ func getDefaultStoragePath() string {
} }
} }
// generateSecret returns a random-ish hex string for the session secret. // generateSecret returns a cryptographically random hex string for the
// session secret. A failure of crypto/rand is unrecoverable, so the program
// terminates instead of falling back to a predictable value.
func generateSecret() string { func generateSecret() string {
hostname, _ := os.Hostname() b := make([]byte, 32)
input := fmt.Sprintf("%s-%d", hostname, os.Getpid()) if _, err := rand.Read(b); err != nil {
hash := sha256.Sum256([]byte(input)) log.Fatalf("Failed to generate session secret: %v", err)
return fmt.Sprintf("%x", hash) }
return hex.EncodeToString(b)
} }
// getDefaultSocketPath returns the OS-aware default unix socket path. // getDefaultSocketPath returns the OS-aware default unix socket path.
@@ -122,9 +134,7 @@ func LoadConfig(customPath string) *Config {
// Read existing config file. // Read existing config file.
data, err := os.ReadFile(configFile) data, err := os.ReadFile(configFile)
if err != nil { if err != nil {
log.Printf("Warning: could not read config file %s: %v, using defaults", configFile, err) log.Fatalf("Failed to read config file %s: %v", configFile, err)
cfg := &Config{}
return applyDefaults(cfg, defaultPath)
} }
cfg := &Config{} cfg := &Config{}
@@ -132,16 +142,19 @@ func LoadConfig(customPath string) *Config {
log.Printf("Warning: malformed config file %s: %v, using defaults", configFile, err) log.Printf("Warning: malformed config file %s: %v, using defaults", configFile, err)
} }
return applyDefaults(cfg, defaultPath) return applyDefaults(cfg, defaultPath, configFile)
} }
// applyDefaults fills zero-value fields with sensible defaults. // applyDefaults fills zero-value fields with sensible defaults.
func applyDefaults(cfg *Config, defaultPath string) *Config { func applyDefaults(cfg *Config, defaultPath, configFile string) *Config {
// If the entire web block is empty (old config without "web" key), // If the entire web block is empty (old config without "web" key),
// fill default port so the app still starts on 8080. // fill default port so the app still starts on 8080.
if cfg.Web.Port == "" && cfg.Web.Socket == "" { if cfg.Web.Port == "" && cfg.Web.Socket == "" {
cfg.Web.Port = defaultPort cfg.Web.Port = defaultPort
} }
if len(cfg.Web.TrustedProxies) == 0 {
cfg.Web.TrustedProxies = defaultTrustedProxies
}
if cfg.Database.Type == "" { if cfg.Database.Type == "" {
cfg.Database.Type = "sqlite" cfg.Database.Type = "sqlite"
} }
@@ -149,7 +162,12 @@ func applyDefaults(cfg *Config, defaultPath string) *Config {
cfg.Path = defaultPath cfg.Path = defaultPath
} }
if cfg.Secret == "" { if cfg.Secret == "" {
cfg.Secret = generateSecret() // The config file exists but has no secret. Refuse to start: a
// silently generated fallback would either be predictable (old
// hostname+pid scheme) or invalidate all sessions on every restart.
log.Fatalf("Config file %s is missing a session secret. "+
"Add a random value, e.g. `secret: %s`, and restart.",
configFile, generateSecret())
} }
return cfg return cfg
} }
+57 -2
View File
@@ -49,6 +49,32 @@ func attachmentURL(stored string) string {
// ---------------- Upload ---------------- // ---------------- Upload ----------------
// currentUserIsAdmin reports whether the authenticated user has the admin
// role, based on the context populated by the SetUserContext middleware.
func currentUserIsAdmin(c *gin.Context) bool {
role, _ := c.Get("role")
r, _ := role.(string)
return r == models.RoleAdmin
}
// canManageArticle reports whether the current user may attach files to (or
// manage attachments of) the given article: admins always, the article
// author otherwise.
func canManageArticle(c *gin.Context, db *gorm.DB, articleID uint) bool {
if currentUserIsAdmin(c) {
return true
}
uid, ok := sessionAuthorID(c)
if !ok || articleID == 0 {
return false
}
var article models.Article
if err := db.First(&article, "id = ?", articleID).Error; err != nil {
return false
}
return article.AuthorID == uid
}
// UploadAttachment handles AJAX attachment uploads from the article create/edit // UploadAttachment handles AJAX attachment uploads from the article create/edit
// form. The request carries either a real article_id (edit page) or a // form. The request carries either a real article_id (edit page) or a
// session_token (create page, pending binding). Files are content-addressed by // session_token (create page, pending binding). Files are content-addressed by
@@ -69,6 +95,12 @@ func UploadAttachment(db *gorm.DB, storagePath string) gin.HandlerFunc {
return return
} }
// Ownership check: a non-admin may only attach to their own articles.
if articleID != 0 && !canManageArticle(c, db, articleID) {
c.JSON(http.StatusForbidden, gin.H{"error": "forbidden"})
return
}
file, header, err := c.Request.FormFile("file") file, header, err := c.Request.FormFile("file")
if err != nil { if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "no file provided"}) c.JSON(http.StatusBadRequest, gin.H{"error": "no file provided"})
@@ -146,7 +178,8 @@ func UploadAttachment(db *gorm.DB, storagePath string) gin.HandlerFunc {
// DeleteAttachment soft-deletes an attachment record and removes the on-disk // DeleteAttachment soft-deletes an attachment record and removes the on-disk
// file only when no remaining records reference it (reference counting, since // file only when no remaining records reference it (reference counting, since
// content-addressed files may be shared). // content-addressed files may be shared). Only admins, the uploader, or the
// author of the article the file is attached to may delete it.
func DeleteAttachment(db *gorm.DB, storagePath string) gin.HandlerFunc { func DeleteAttachment(db *gorm.DB, storagePath string) gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
id := parseUintParam(c, "id") id := parseUintParam(c, "id")
@@ -155,6 +188,19 @@ func DeleteAttachment(db *gorm.DB, storagePath string) gin.HandlerFunc {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return return
} }
// Ownership check: admin, uploader, or the owning article's author.
if !currentUserIsAdmin(c) {
uid, ok := sessionAuthorID(c)
owned := ok && att.UploaderID == uid
if !owned && att.ArticleID != 0 {
owned = canManageArticle(c, db, att.ArticleID)
}
if !owned {
c.JSON(http.StatusForbidden, gin.H{"error": "forbidden"})
return
}
}
stored := att.StoredName stored := att.StoredName
if err := db.Delete(&att).Error; err != nil { if err := db.Delete(&att).Error; err != nil {
@@ -175,10 +221,19 @@ func DeleteAttachment(db *gorm.DB, storagePath string) gin.HandlerFunc {
// ---------------- List ---------------- // ---------------- List ----------------
// ListAttachments returns the attachments for an article as JSON (used by the // ListAttachments returns the attachments for an article as JSON (used by the
// edit page to repopulate the list on load). // edit page to repopulate the list on load). Only the article's author (or an
// admin) may list them.
func ListAttachments(db *gorm.DB) gin.HandlerFunc { func ListAttachments(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
articleID := parseUintParam(c, "id") articleID := parseUintParam(c, "id")
if articleID == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid article id"})
return
}
if !canManageArticle(c, db, articleID) {
c.JSON(http.StatusForbidden, gin.H{"error": "forbidden"})
return
}
var atts []models.Attachment var atts []models.Attachment
db.Where("article_id = ?", articleID).Order("created_at ASC").Find(&atts) db.Where("article_id = ?", articleID).Order("created_at ASC").Find(&atts)
+24 -2
View File
@@ -52,8 +52,20 @@ func Login(db *gorm.DB) gin.HandlerFunc {
return return
} }
// Create session. // Rotate the session on privilege change to prevent session
// fixation: drop all pre-authentication state, keep only the
// harmless UI preferences (language and CSRF token so forms
// already rendered in other tabs stay valid).
session := sessions.Default(c) session := sessions.Default(c)
lang, _ := session.Get("lang").(string)
csrfTok, _ := session.Get("csrf_token").(string)
session.Clear()
if lang != "" {
session.Set("lang", lang)
}
if csrfTok != "" {
session.Set("csrf_token", csrfTok)
}
session.Set("user_id", user.ID) session.Set("user_id", user.ID)
session.Set("username", user.Username) session.Set("username", user.Username)
if err := session.Save(); err != nil { if err := session.Save(); err != nil {
@@ -169,8 +181,18 @@ func Register(db *gorm.DB) gin.HandlerFunc {
return return
} }
// Auto-login after successful registration // Auto-login after successful registration (with session
// rotation, mirroring the login handler).
session := sessions.Default(c) session := sessions.Default(c)
lang, _ := session.Get("lang").(string)
csrfTok, _ := session.Get("csrf_token").(string)
session.Clear()
if lang != "" {
session.Set("lang", lang)
}
if csrfTok != "" {
session.Set("csrf_token", csrfTok)
}
session.Set("user_id", user.ID) session.Set("user_id", user.ID)
session.Set("username", user.Username) session.Set("username", user.Username)
if err := session.Save(); err != nil { if err := session.Save(); err != nil {
+5 -2
View File
@@ -16,6 +16,7 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"gorm.io/gorm" "gorm.io/gorm"
"go_blog/middleware"
"go_blog/models" "go_blog/models"
) )
@@ -78,8 +79,10 @@ func guestTokenFrom(c *gin.Context) string {
token = newGuestToken() token = newGuestToken()
} }
// (Re)set the cookie so returning visitors keep their identity. HttpOnly // (Re)set the cookie so returning visitors keep their identity. HttpOnly
// prevents JS access; SameSite=Lax is the gin default and is appropriate. // prevents JS access; SameSite=Lax plus Secure-over-HTTPS mirror the
c.SetCookie(guestCookieName, token, guestCookieMaxAge, "/", "", false, true) // session cookie hardening.
c.SetSameSite(http.SameSiteLaxMode)
c.SetCookie(guestCookieName, token, guestCookieMaxAge, "/", "", middleware.IsHTTPSRequest(c), true)
return token return token
} }
+6 -13
View File
@@ -1,8 +1,6 @@
package handlers package handlers
import ( import (
"strings"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
@@ -29,6 +27,7 @@ func DefaultData(c *gin.Context) gin.H {
siteHomeSubtitle, _ := c.Get("site_home_subtitle") siteHomeSubtitle, _ := c.Get("site_home_subtitle")
siteFooterText, _ := c.Get("site_footer_text") siteFooterText, _ := c.Get("site_footer_text")
navLinks, _ := c.Get("nav_links") navLinks, _ := c.Get("nav_links")
csrfToken, _ := c.Get("csrf_token")
return gin.H{ return gin.H{
"Tr": tr, "Tr": tr,
@@ -50,6 +49,7 @@ func DefaultData(c *gin.Context) gin.H {
"SiteHomeSubtitle": siteHomeSubtitle, "SiteHomeSubtitle": siteHomeSubtitle,
"SiteFooterText": siteFooterText, "SiteFooterText": siteFooterText,
"NavLinks": navLinks, "NavLinks": navLinks,
"CSRFToken": csrfToken,
} }
} }
@@ -67,17 +67,10 @@ func getTr(c *gin.Context) map[string]string {
return m return m
} }
// GetClientIP extracts the real client IP address, accounting for CDN/reverse proxy setups. // GetClientIP returns the real client IP. It relies on gin's proxy-aware
// It checks X-Forwarded-For and X-Real-IP headers before falling back to c.ClientIP(). // ClientIP(), which honors the trusted_proxies config: only IPs in that list
// are allowed to influence X-Forwarded-For, so direct clients cannot spoof
// the value.
func GetClientIP(c *gin.Context) string { func GetClientIP(c *gin.Context) string {
if xff := c.GetHeader("X-Forwarded-For"); xff != "" {
if i := strings.IndexByte(xff, ','); i != -1 {
return strings.TrimSpace(xff[:i])
}
return strings.TrimSpace(xff)
}
if xri := c.GetHeader("X-Real-IP"); xri != "" {
return strings.TrimSpace(xri)
}
return c.ClientIP() return c.ClientIP()
} }
+20 -32
View File
@@ -198,8 +198,18 @@ func ArticleDetail(db *gorm.DB) gin.HandlerFunc {
db.Model(&models.Article{}).Where("id = ?", article.ID). db.Model(&models.Article{}).Where("id = ?", article.ID).
UpdateColumn("view_count", gorm.Expr("view_count + 1")) UpdateColumn("view_count", gorm.Expr("view_count + 1"))
// Record unique article view asynchronously (doesn't block page response). // Record unique article view asynchronously (doesn't block page
go recordArticleView(db, article.ID, c) // response). All request-derived values are extracted synchronously:
// the gin context is pool-reused and must never be touched from
// another goroutine after the handler returns.
uid := userIDFromSession(c)
var userID *uint
if uid != 0 {
userID = &uid
}
ip := GetClientIP(c)
ua := c.Request.UserAgent()
go recordArticleView(db, article.ID, userID, ip, ua)
// One-time flash notice (set by PostComment on success/pending). Reading // One-time flash notice (set by PostComment on success/pending). Reading
// consumes the flash, so refreshing the page no longer re-shows it. // consumes the flash, so refreshing the page no longer re-shows it.
@@ -281,35 +291,13 @@ func readCommentFlash(c *gin.Context) string {
} }
// recordArticleView records a unique article view in the database. // recordArticleView records a unique article view in the database.
// This function is designed to be called asynchronously (via goroutine) to avoid // This function is designed to be called asynchronously (via goroutine) to
// blocking the page response. It checks for existing records to ensure each // avoid blocking the page response. It checks for existing records to ensure
// user/IP combination only records one view per article. // each user/IP combination only records one view per article. All request
func recordArticleView(db *gorm.DB, articleID uint, c *gin.Context) { // derived values (userID, ip, userAgent) must be extracted by the caller
session := sessions.Default(c) // before the goroutine is spawned - this function never touches the gin
// context.
// Extract user ID from session if logged in func recordArticleView(db *gorm.DB, articleID uint, userID *uint, ip, userAgent string) {
var userID *uint
if uid := session.Get("user_id"); uid != nil {
switch v := uid.(type) {
case uint:
userID = &v
case int:
u := uint(v)
userID = &u
case int64:
u := uint(v)
userID = &u
case float64:
u := uint(v)
userID = &u
}
}
// Get client IP and User-Agent
ip := GetClientIP(c)
userAgent := c.Request.UserAgent()
isBot := models.IsBot(userAgent)
// Check if this view already exists (deduplication) // Check if this view already exists (deduplication)
var count int64 var count int64
query := db.Model(&models.ArticleView{}). query := db.Model(&models.ArticleView{}).
@@ -337,7 +325,7 @@ func recordArticleView(db *gorm.DB, articleID uint, c *gin.Context) {
UserID: userID, UserID: userID,
IP: ip, IP: ip,
UserAgent: userAgent, UserAgent: userAgent,
IsBot: isBot, IsBot: models.IsBot(userAgent),
} }
// Create the view record (BeforeCreate hook in model handles deduplication) // Create the view record (BeforeCreate hook in model handles deduplication)
+380
View File
@@ -0,0 +1,380 @@
package handlers
import (
"fmt"
"io"
"mime/multipart"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"regexp"
"strings"
"testing"
"github.com/gin-contrib/sessions"
"github.com/gin-contrib/sessions/cookie"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
"go_blog/middleware"
"go_blog/models"
)
// securityTestEnv wires a router that mirrors the production middleware chain
// (sessions -> CSRF -> user context) plus the routes under test.
type securityTestEnv struct {
router *gin.Engine
db *gorm.DB
storageDir string
}
var csrfTokenRe = regexp.MustCompile(`name="_csrf" value="([^"]+)"`)
func newSecurityTestEnv(t *testing.T) *securityTestEnv {
t.Helper()
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "test.db")), &gorm.Config{})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := db.AutoMigrate(&models.User{}, &models.Article{}, &models.Attachment{}, &models.SiteSetting{},
&models.UploadConfig{}, &models.UploadFileType{}, &models.CommentConfig{}, &models.NavLink{},
&models.DownloadBaseURL{}); err != nil {
t.Fatalf("migrate: %v", err)
}
storageDir := t.TempDir()
// Seed the upload policy so ValidateUpload accepts .txt files.
db.Create(&models.SiteSetting{ID: 1})
db.Create(&models.CommentConfig{ID: 1, Enabled: true, AllowGuest: true})
db.Create(&models.UploadConfig{ID: 1, Enabled: true, DefaultMaxSize: 1024 * 1024, StorageDir: "attachments"})
db.Create(&models.UploadFileType{Extension: ".txt", MimeType: "text/plain", Category: models.CategoryDocument, Enabled: true})
models.LoadConfigCache(db)
// Seed users.
mustUser(t, db, "admin", models.RoleAdmin)
alice := mustUser(t, db, "alice", models.RoleAuthor)
bob := mustUser(t, db, "bob", models.RoleAuthor)
// Seed one article per author.
aliceArt := models.Article{AuthorID: alice.ID, Title: "alice post", Slug: "alice-post", Content: "x", Status: models.ArticlePublished}
bobArt := models.Article{AuthorID: bob.ID, Title: "bob post", Slug: "bob-post", Content: "x", Status: models.ArticlePublished}
db.Create(&aliceArt)
db.Create(&bobArt)
r := gin.New()
if err := r.SetTrustedProxies(nil); err != nil {
t.Fatalf("set trusted proxies: %v", err)
}
r.LoadHTMLGlob("../templates/**/*.html")
store := cookie.NewStore([]byte("test-secret"))
r.Use(sessions.Sessions("blog_session", store))
r.Use(middleware.CSRFProtect())
r.Use(middleware.SetUserContext(db))
r.GET("/login", LoginPage())
r.POST("/login", Login(db))
r.POST("/logout", Logout())
protected := r.Group("/my", middleware.AuthRequired())
{
protected.POST("/articles/attachments", UploadAttachment(db, storageDir))
protected.POST("/articles/attachments/:id/delete", DeleteAttachment(db, storageDir))
protected.GET("/articles/:id/attachments", ListAttachments(db))
protected.GET("/whoami", func(c *gin.Context) {
uid, _ := sessionAuthorID(c)
c.String(http.StatusOK, "uid=%d", uid)
})
}
return &securityTestEnv{router: r, db: db, storageDir: storageDir}
}
func mustUser(t *testing.T, db *gorm.DB, username, role string) models.User {
t.Helper()
u := models.User{Username: username, DisplayName: username, Role: role, Status: models.StatusNormal}
if err := u.SetPassword("pw-" + username); err != nil {
t.Fatalf("set password: %v", err)
}
if err := db.Create(&u).Error; err != nil {
t.Fatalf("create user %s: %v", username, err)
}
return u
}
// login performs the full login flow (GET the form for a CSRF token, then POST
// credentials) and returns the authenticated session cookie.
func (e *securityTestEnv) login(t *testing.T, username string) string {
t.Helper()
// Anonymous GET to obtain CSRF token + session cookie.
req := httptest.NewRequest(http.MethodGet, "/login", nil)
w := httptest.NewRecorder()
e.router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("GET /login: status = %d", w.Code)
}
m := csrfTokenRe.FindStringSubmatch(w.Body.String())
if m == nil {
t.Fatal("login page did not render a CSRF token")
}
cookie := e.sessionCookie(w)
// POST credentials with the token.
form := url.Values{}
form.Set("username", username)
form.Set("password", "pw-"+username)
form.Set("_csrf", m[1])
req = httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
if cookie != "" {
req.Header.Set("Cookie", cookie)
}
w = httptest.NewRecorder()
e.router.ServeHTTP(w, req)
if w.Code != http.StatusFound {
t.Fatalf("POST /login (%s): status = %d, body %s", username, w.Code, w.Body.String())
}
authCookie := e.sessionCookie(w)
if authCookie == "" {
t.Fatal("login did not set a session cookie")
}
return authCookie
}
// sessionCookie extracts the blog_session cookie from a recorder. When
// several Set-Cookie headers are present (e.g. middleware and handler both
// save the session), the LAST one is the effective value - browsers apply
// them in order.
func (e *securityTestEnv) sessionCookie(w *httptest.ResponseRecorder) string {
cookie := ""
for _, c := range w.Result().Cookies() {
if c.Name == "blog_session" {
cookie = c.Name + "=" + c.Value
}
}
return cookie
}
func (e *securityTestEnv) do(method, path, cookie string, body io.Reader, contentType string) *httptest.ResponseRecorder {
req := httptest.NewRequest(method, path, body)
if contentType != "" {
req.Header.Set("Content-Type", contentType)
}
if cookie != "" {
req.Header.Set("Cookie", cookie)
}
w := httptest.NewRecorder()
e.router.ServeHTTP(w, req)
return w
}
func (e *securityTestEnv) upload(t *testing.T, cookie, csrfToken, articleID string) *httptest.ResponseRecorder {
t.Helper()
var buf strings.Builder
mw := multipart.NewWriter(&buf)
if articleID != "" {
mw.WriteField("article_id", articleID)
} else {
mw.WriteField("session_token", "test-pending-token")
}
mw.WriteField("_csrf", csrfToken)
fw, _ := mw.CreateFormFile("file", "hello.txt")
fw.Write([]byte("hello world"))
mw.Close()
return e.do(http.MethodPost, "/my/articles/attachments", cookie, strings.NewReader(buf.String()), mw.FormDataContentType())
}
// csrfTokenFor fetches a fresh CSRF token for an authenticated session.
func (e *securityTestEnv) csrfTokenFor(t *testing.T, cookie string) string {
t.Helper()
w := e.do(http.MethodGet, "/login", cookie, nil, "")
if w.Code != http.StatusOK {
t.Fatalf("GET /login: status = %d", w.Code)
}
m := csrfTokenRe.FindStringSubmatch(w.Body.String())
if m == nil {
t.Fatal("login page did not render a CSRF token")
}
return m[1]
}
func TestLoginRotatesSession(t *testing.T) {
e := newSecurityTestEnv(t)
// Obtain an anonymous session (pre-login cookie).
req := httptest.NewRequest(http.MethodGet, "/login", nil)
w := httptest.NewRecorder()
e.router.ServeHTTP(w, req)
preLoginCookie := e.sessionCookie(w)
if preLoginCookie == "" {
t.Fatal("expected anonymous session cookie")
}
authCookie := e.login(t, "alice")
if authCookie == preLoginCookie {
t.Fatal("session cookie was not rotated on login (fixation risk)")
}
// The authenticated session works.
w = e.do(http.MethodGet, "/my/whoami", authCookie, nil, "")
if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "uid=") {
t.Fatalf("authenticated request failed: status=%d body=%s", w.Code, w.Body.String())
}
// The old (fixated) session must NOT carry the login.
w = e.do(http.MethodGet, "/my/whoami", preLoginCookie, nil, "")
if w.Code != http.StatusFound {
t.Fatalf("pre-login session still authenticated after login: status=%d", w.Code)
}
}
func TestAttachmentListRequiresOwnership(t *testing.T) {
e := newSecurityTestEnv(t)
var aliceArt, bobArt models.Article
e.db.Where("slug = ?", "alice-post").First(&aliceArt)
e.db.Where("slug = ?", "bob-post").First(&bobArt)
alice := e.login(t, "alice")
// Own article: allowed.
w := e.do(http.MethodGet, fmt.Sprintf("/my/articles/%d/attachments", aliceArt.ID), alice, nil, "")
if w.Code != http.StatusOK {
t.Fatalf("list own attachments: status = %d, want 200", w.Code)
}
// Someone else's article: forbidden.
w = e.do(http.MethodGet, fmt.Sprintf("/my/articles/%d/attachments", bobArt.ID), alice, nil, "")
if w.Code != http.StatusForbidden {
t.Fatalf("list other user's attachments: status = %d, want 403", w.Code)
}
}
func TestAttachmentUploadRequiresOwnership(t *testing.T) {
e := newSecurityTestEnv(t)
var bobArt models.Article
e.db.Where("slug = ?", "bob-post").First(&bobArt)
alice := e.login(t, "alice")
token := e.csrfTokenFor(t, alice)
// Upload pending (article_id=0 + session token): allowed.
w := e.upload(t, alice, token, "")
if w.Code != http.StatusOK {
t.Fatalf("pending upload: status = %d, body %s", w.Code, w.Body.String())
}
// Upload to someone else's article: forbidden.
w = e.upload(t, alice, token, fmt.Sprint(bobArt.ID))
if w.Code != http.StatusForbidden {
t.Fatalf("upload to other user's article: status = %d, want 403", w.Code)
}
}
func TestAttachmentDeleteRequiresOwnership(t *testing.T) {
e := newSecurityTestEnv(t)
var aliceArt, bobArt models.Article
e.db.Where("slug = ?", "alice-post").First(&aliceArt)
e.db.Where("slug = ?", "bob-post").First(&bobArt)
alice := e.login(t, "alice")
token := e.csrfTokenFor(t, alice)
// Alice uploads an attachment to her own article.
w := e.upload(t, alice, token, fmt.Sprint(aliceArt.ID))
if w.Code != http.StatusOK {
t.Fatalf("upload: status = %d, body %s", w.Code, w.Body.String())
}
// Bob uploads an attachment to his own article.
bob := e.login(t, "bob")
bobToken := e.csrfTokenFor(t, bob)
w = e.upload(t, bob, bobToken, fmt.Sprint(bobArt.ID))
if w.Code != http.StatusOK {
t.Fatalf("upload (bob): status = %d, body %s", w.Code, w.Body.String())
}
var bobAtt models.Attachment
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)
}
// Alice cannot delete Bob's attachment.
form := url.Values{}
form.Set("_csrf", token)
w = e.do(http.MethodPost, fmt.Sprintf("/my/articles/attachments/%d/delete", bobAtt.ID), alice,
strings.NewReader(form.Encode()), "application/x-www-form-urlencoded")
if w.Code != http.StatusForbidden {
t.Fatalf("delete other user's attachment: status = %d, want 403", w.Code)
}
// Bob can delete his own.
form.Set("_csrf", bobToken)
w = e.do(http.MethodPost, fmt.Sprintf("/my/articles/attachments/%d/delete", bobAtt.ID), bob,
strings.NewReader(form.Encode()), "application/x-www-form-urlencoded")
if w.Code != http.StatusOK {
t.Fatalf("delete own attachment: status = %d, want 200 (body %s)", w.Code, w.Body.String())
}
// Bob's attachment record should be gone.
var count int64
e.db.Model(&models.Attachment{}).Where("id = ?", bobAtt.ID).Count(&count)
if count != 0 {
t.Fatal("attachment was not deleted")
}
}
func TestAttachmentCSRFEnforced(t *testing.T) {
e := newSecurityTestEnv(t)
alice := e.login(t, "alice")
// POST without a CSRF token must be rejected before reaching the handler.
var buf strings.Builder
mw := multipart.NewWriter(&buf)
fw, _ := mw.CreateFormFile("file", "hello.txt")
fw.Write([]byte("hello"))
mw.Close()
w := e.do(http.MethodPost, "/my/articles/attachments", alice, strings.NewReader(buf.String()), mw.FormDataContentType())
if w.Code != http.StatusForbidden {
t.Fatalf("upload without CSRF token: status = %d, want 403", w.Code)
}
}
func TestAttachmentAdminOverride(t *testing.T) {
e := newSecurityTestEnv(t)
var bobArt models.Article
e.db.Where("slug = ?", "bob-post").First(&bobArt)
admin := e.login(t, "admin")
token := e.csrfTokenFor(t, admin)
// Admin may list and upload to any article.
w := e.do(http.MethodGet, fmt.Sprintf("/my/articles/%d/attachments", bobArt.ID), admin, nil, "")
if w.Code != http.StatusOK {
t.Fatalf("admin list: status = %d, want 200", w.Code)
}
w = e.upload(t, admin, token, fmt.Sprint(bobArt.ID))
if w.Code != http.StatusOK {
t.Fatalf("admin upload: status = %d, want 200 (body %s)", w.Code, w.Body.String())
}
// Clean up files created during the test (best effort).
entries, _ := os.ReadDir(filepath.Join(e.storageDir, "attachments"))
for _, ent := range entries {
os.Remove(filepath.Join(e.storageDir, "attachments", ent.Name()))
}
}
func userIDByUsername(t *testing.T, db *gorm.DB, username string) uint {
t.Helper()
var u models.User
if err := db.Where("username = ?", username).First(&u).Error; err != nil {
t.Fatalf("user %s not found: %v", username, err)
}
return u.ID
}
+1
View File
@@ -40,6 +40,7 @@ install -m 0755 -o root -g root "${SCRIPT_DIR}/${BINARY_NAME}" "${INSTALL_DIR}/$
rm -rf "${INSTALL_DIR}/templates" rm -rf "${INSTALL_DIR}/templates"
cp -a "${SCRIPT_DIR}/templates" "${INSTALL_DIR}/templates" cp -a "${SCRIPT_DIR}/templates" "${INSTALL_DIR}/templates"
chown -R root:root "${INSTALL_DIR}/templates" chown -R root:root "${INSTALL_DIR}/templates"
# static 资源已通过 go:embed 编入二进制,无需单独拷贝
chown "${SERVICE_USER}:${SERVICE_USER}" "${INSTALL_DIR}" chown "${SERVICE_USER}:${SERVICE_USER}" "${INSTALL_DIR}"
chmod 0755 "${INSTALL_DIR}" chmod 0755 "${INSTALL_DIR}"
find "${INSTALL_DIR}/templates" -type d -exec chmod 0755 {} \; find "${INSTALL_DIR}/templates" -type d -exec chmod 0755 {} \;
+52 -3
View File
@@ -1,10 +1,13 @@
package main package main
import ( import (
"embed"
"flag" "flag"
"fmt" "fmt"
"io/fs"
"log" "log"
"net" "net"
"net/http"
"os" "os"
"github.com/gin-contrib/sessions" "github.com/gin-contrib/sessions"
@@ -17,6 +20,13 @@ import (
"go_blog/models" "go_blog/models"
) )
// staticFiles embeds the static assets (Markdown CSS/JS) into the binary so a
// deployment only needs to replace the executable — no separate static
// directory has to be copied to the server.
//
//go:embed static
var staticFiles embed.FS
func main() { func main() {
// 0. Parse command-line flags. // 0. Parse command-line flags.
configFlag := flag.String("config", "", "path to config file (default: OS-aware path)") configFlag := flag.String("config", "", "path to config file (default: OS-aware path)")
@@ -35,23 +45,62 @@ func main() {
store := cookie.NewStore([]byte(cfg.Secret)) store := cookie.NewStore([]byte(cfg.Secret))
store.Options(sessions.Options{ store.Options(sessions.Options{
Path: "/", Path: "/",
MaxAge: 86400, // 24 hours MaxAge: 86400, // 24 hours
HttpOnly: true, // prevent XSS access HttpOnly: true, // prevent XSS access
Secure: false, // set true in production with HTTPS SameSite: http.SameSiteLaxMode, // CSRF defense-in-depth; token check is the primary control
// Secure is set per request (over HTTPS only) in the middleware below.
}) })
// 4. Create Gin router. // 4. Create Gin router.
router := gin.Default() router := gin.Default()
// 4b. Trusted proxies: only IPs listed here may influence the client IP
// (X-Forwarded-For). Without this, gin trusts every proxy and a client
// can spoof the IP recorded for comments/article views.
if err := router.SetTrustedProxies(cfg.Web.TrustedProxies); err != nil {
log.Fatalf("Invalid trusted_proxies in config: %v", err)
}
// 4c. Security response headers (registered first so they are present
// even on rejected responses).
router.Use(middleware.SecurityHeaders())
// 5. Load HTML templates. // 5. Load HTML templates.
router.LoadHTMLGlob("templates/**/*.html") router.LoadHTMLGlob("templates/**/*.html")
// 6. Serve uploaded files (avatars etc.) from the storage path. // 6. Serve uploaded files (avatars etc.) from the storage path.
router.Static("/uploads", cfg.Path) router.Static("/uploads", cfg.Path)
// 6b. Serve bundled static assets (embedded into the binary).
staticFS, err := fs.Sub(staticFiles, "static")
if err != nil {
log.Fatalf("Failed to open embedded static assets: %v", err)
}
router.StaticFS("/static", http.FS(staticFS))
// 6. Global session middleware. // 6. Global session middleware.
router.Use(sessions.Sessions("blog_session", store)) router.Use(sessions.Sessions("blog_session", store))
// 6a. Per-request session cookie hardening: Secure only over HTTPS, and
// SameSite=Lax. Applied per request because the app sits behind a TLS
// terminator (Caddy/Cloudflare) and cannot know at startup whether the
// client connection is encrypted.
router.Use(func(c *gin.Context) {
opts := sessions.Options{
Path: "/",
MaxAge: 86400,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
}
if middleware.IsHTTPSRequest(c) {
opts.Secure = true
}
sessions.Default(c).Options(opts)
})
// 6b. CSRF protection (must run after the session middleware).
router.Use(middleware.CSRFProtect())
// 7. Global context middleware (sets IsLoggedIn, Username for templates). // 7. Global context middleware (sets IsLoggedIn, Username for templates).
router.Use(middleware.SetUserContext(db)) router.Use(middleware.SetUserContext(db))
+59
View File
@@ -0,0 +1,59 @@
package middleware
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
)
// newClientIPRouter mirrors the production trusted-proxy configuration:
// only loopback is trusted (the Caddy/nginx host).
func newClientIPRouter() *gin.Engine {
gin.SetMode(gin.TestMode)
r := gin.New()
if err := r.SetTrustedProxies([]string{"127.0.0.1", "::1"}); err != nil {
panic(err)
}
r.GET("/ip", func(c *gin.Context) {
c.String(http.StatusOK, c.ClientIP())
})
return r
}
func TestClientIPSpoofingBlocked(t *testing.T) {
r := newClientIPRouter()
// A direct (untrusted) client sending a forged X-Forwarded-For must not
// be able to change the recorded IP.
req := httptest.NewRequest(http.MethodGet, "/ip", nil)
req.RemoteAddr = "203.0.113.5:12345"
req.Header.Set("X-Forwarded-For", "6.6.6.6")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if got := w.Body.String(); got != "203.0.113.5" {
t.Errorf("direct client with forged XFF: got %q, want 203.0.113.5", got)
}
// A trusted proxy (loopback) forwarding a real chain: the rightmost
// untrusted entry wins, earlier (client-supplied) entries are ignored.
req = httptest.NewRequest(http.MethodGet, "/ip", nil)
req.RemoteAddr = "127.0.0.1:54321"
req.Header.Set("X-Forwarded-For", "6.6.6.6, 198.51.100.42")
w = httptest.NewRecorder()
r.ServeHTTP(w, req)
if got := w.Body.String(); got != "198.51.100.42" {
t.Errorf("proxy-forwarded chain: got %q, want 198.51.100.42 (client-supplied entry must be ignored)", got)
}
// A trusted proxy forwarding a single entry: that entry is the client.
req = httptest.NewRequest(http.MethodGet, "/ip", nil)
req.RemoteAddr = "127.0.0.1:54321"
req.Header.Set("X-Forwarded-For", "198.51.100.42")
w = httptest.NewRecorder()
r.ServeHTTP(w, req)
if got := w.Body.String(); got != "198.51.100.42" {
t.Errorf("proxy-forwarded single entry: got %q, want 198.51.100.42", got)
}
}
+93
View File
@@ -0,0 +1,93 @@
package middleware
import (
"crypto/rand"
"encoding/hex"
"net/http"
"strconv"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
)
// CSRF protection uses the synchronizer-token pattern on top of the existing
// session store:
// - Safe methods (GET/HEAD/OPTIONS): a per-session token is created on first
// use and exposed to templates / JS so it can be embedded in forms.
// - Unsafe methods (POST/PUT/PATCH/DELETE): the request must carry the token
// either as the "_csrf" form field (regular forms, multipart uploads) or
// in the "X-CSRF-Token" header (AJAX). A mismatch aborts with 403.
//
// The token is bound to the session, so it works for anonymous visitors (e.g.
// the comment form) as well as for logged-in users.
const (
// CSRFFieldName is the form field carrying the token.
CSRFFieldName = "_csrf"
// CSRFHeaderName is the HTTP header carrying the token (AJAX).
CSRFHeaderName = "X-CSRF-Token"
// CSRFSessionKey stores the token server-side.
CSRFSessionKey = "csrf_token"
// CSRFContextKey exposes the token to handlers/templates via c.Set.
CSRFContextKey = "csrf_token"
)
// newCSRFToken returns a 256-bit random hex token. A crypto/rand failure is
// unrecoverable; panic rather than degrade the defense.
func newCSRFToken() string {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
panic("csrf: failed to read random bytes: " + err.Error())
}
return hex.EncodeToString(b)
}
// csrfTokensEqual compares two tokens in constant time.
func csrfTokensEqual(a, b string) bool {
if len(a) != len(b) {
return false
}
var v byte
for i := 0; i < len(a); i++ {
v |= a[i] ^ b[i]
}
return v == 0
}
// CSRFProtect validates unsafe requests against the per-session CSRF token.
// It must be registered after the sessions middleware.
func CSRFProtect() gin.HandlerFunc {
return func(c *gin.Context) {
session := sessions.Default(c)
token, _ := session.Get(CSRFSessionKey).(string)
switch c.Request.Method {
case http.MethodGet, http.MethodHead, http.MethodOptions, http.MethodTrace:
// Safe method: make sure a token exists and hand it to the
// template layer.
if token == "" {
token = newCSRFToken()
session.Set(CSRFSessionKey, token)
_ = session.Save()
}
c.Set(CSRFContextKey, token)
c.Next()
return
}
// Unsafe method: require a matching token.
supplied := c.PostForm(CSRFFieldName)
if supplied == "" {
supplied = c.GetHeader(CSRFHeaderName)
}
if token == "" || supplied == "" || !csrfTokensEqual(token, supplied) {
c.Header("Cache-Control", "no-store")
c.Header("Content-Type", "text/plain; charset=utf-8")
c.String(http.StatusForbidden, "403 Forbidden: CSRF token missing or invalid ("+strconv.Quote(c.Request.Method)+" "+c.Request.URL.Path+")")
c.Abort()
return
}
c.Set(CSRFContextKey, token)
c.Next()
}
}
+154
View File
@@ -0,0 +1,154 @@
package middleware
import (
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"github.com/gin-contrib/sessions"
"github.com/gin-contrib/sessions/cookie"
"github.com/gin-gonic/gin"
)
func newCSRFTestRouter() *gin.Engine {
gin.SetMode(gin.TestMode)
r := gin.New()
store := cookie.NewStore([]byte("test-secret"))
r.Use(sessions.Sessions("test_session", store))
r.Use(CSRFProtect())
r.GET("/form", func(c *gin.Context) {
c.String(http.StatusOK, "TOKEN="+c.GetString(CSRFContextKey))
})
r.HEAD("/form", func(c *gin.Context) {
c.String(http.StatusOK, "TOKEN="+c.GetString(CSRFContextKey))
})
r.POST("/action", func(c *gin.Context) {
c.String(http.StatusOK, "ok")
})
return r
}
// tokenFromForm performs GET /form with the given session cookie and returns
// the issued CSRF token plus the (possibly new) session cookie.
func tokenFromForm(t *testing.T, r *gin.Engine, sessionCookie string) (token, cookie string) {
t.Helper()
req := httptest.NewRequest(http.MethodGet, "/form", nil)
if sessionCookie != "" {
req.Header.Set("Cookie", sessionCookie)
}
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("GET /form: status = %d, want 200", w.Code)
}
body := w.Body.String()
const prefix = "TOKEN="
if !strings.HasPrefix(body, prefix) {
t.Fatalf("GET /form: unexpected body %q", body)
}
token = strings.TrimPrefix(body, prefix)
cookie = w.Header().Get("Set-Cookie")
return token, cookie
}
func postAction(r *gin.Engine, sessionCookie, token string, useHeader bool) *httptest.ResponseRecorder {
form := url.Values{}
if !useHeader {
form.Set(CSRFFieldName, token)
}
req := httptest.NewRequest(http.MethodPost, "/action", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
if sessionCookie != "" {
req.Header.Set("Cookie", sessionCookie)
}
if useHeader {
req.Header.Set(CSRFHeaderName, token)
}
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
return w
}
func TestCSRFTokenIssuedOnGET(t *testing.T) {
r := newCSRFTestRouter()
token, cookie := tokenFromForm(t, r, "")
if token == "" {
t.Fatal("expected a token to be issued on GET")
}
if !strings.Contains(cookie, "test_session=") {
t.Fatalf("expected session cookie to be set, got %q", cookie)
}
// A second GET with the same session must return the same token.
token2, _ := tokenFromForm(t, r, cookie)
if token2 != token {
t.Fatalf("token changed between requests: %q vs %q", token, token2)
}
}
func TestCSRFPostRejectedWithoutToken(t *testing.T) {
r := newCSRFTestRouter()
_, cookie := tokenFromForm(t, r, "")
w := postAction(r, cookie, "", false)
if w.Code != http.StatusForbidden {
t.Fatalf("POST without token: status = %d, want 403", w.Code)
}
}
func TestCSRFPostRejectedWithWrongToken(t *testing.T) {
r := newCSRFTestRouter()
_, cookie := tokenFromForm(t, r, "")
w := postAction(r, cookie, "bogus-token", false)
if w.Code != http.StatusForbidden {
t.Fatalf("POST with wrong token: status = %d, want 403", w.Code)
}
}
func TestCSRFPostRejectedWithoutSession(t *testing.T) {
r := newCSRFTestRouter()
// No prior GET: no session, no token issued.
w := postAction(r, "", "some-token", false)
if w.Code != http.StatusForbidden {
t.Fatalf("POST without session: status = %d, want 403", w.Code)
}
}
func TestCSRFPostAcceptedWithFormField(t *testing.T) {
r := newCSRFTestRouter()
token, cookie := tokenFromForm(t, r, "")
w := postAction(r, cookie, token, false)
if w.Code != http.StatusOK {
t.Fatalf("POST with valid token: status = %d, want 200 (body: %s)", w.Code, w.Body.String())
}
}
func TestCSRFPostAcceptedWithHeader(t *testing.T) {
r := newCSRFTestRouter()
token, cookie := tokenFromForm(t, r, "")
w := postAction(r, cookie, token, true)
if w.Code != http.StatusOK {
t.Fatalf("POST with token in header: status = %d, want 200 (body: %s)", w.Code, w.Body.String())
}
}
func TestCSRFSafeMethodsPassWithoutToken(t *testing.T) {
r := newCSRFTestRouter()
// GET and HEAD are registered routes; OPTIONS is not (gin does not
// auto-register it), so it falls to noRoute - but in all cases the CSRF
// middleware itself must not reject with 403.
for _, method := range []string{http.MethodGet, http.MethodHead, http.MethodOptions} {
req := httptest.NewRequest(method, "/form", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code == http.StatusForbidden {
t.Fatalf("%s /form: status = 403, CSRF middleware must not reject safe methods", method)
}
}
}
+23
View File
@@ -0,0 +1,23 @@
package middleware
import (
"strings"
"github.com/gin-gonic/gin"
)
// IsHTTPSRequest reports whether the client reached us over TLS. Behind a
// reverse proxy (Caddy/nginx) the app itself usually terminates plain
// connections, so X-Forwarded-Proto is consulted as well. The header is only
// honored when a trusted proxy forwarded the request - untrusted clients
// spoofing it can at worst break their own session (the cookie turns Secure
// and is refused over plain HTTP).
func IsHTTPSRequest(c *gin.Context) bool {
if c.Request.TLS != nil {
return true
}
if strings.EqualFold(c.GetHeader("X-Forwarded-Proto"), "https") {
return true
}
return false
}
+42
View File
@@ -0,0 +1,42 @@
package middleware
import "github.com/gin-gonic/gin"
// csp is the Content-Security-Policy for HTML responses.
//
// 'unsafe-inline' is required because templates embed inline <script> and
// <style> blocks (Go html/template is the XSS defense for those); the
// directive list restricts everything else (scripts can only load from the
// pinned CDN hosts, no third-party frames, no other origins for fetch).
// Tighten further once the third-party assets are vendored locally (see
// SECURITY_TODO P2-9).
const csp = "default-src 'self'; " +
"script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://cdnjs.cloudflare.com https://cdn.tailwindcss.com; " +
"style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://cdnjs.cloudflare.com; " +
"img-src 'self' data: https: http:; " +
"font-src 'self' data: https:; " +
"connect-src 'self'; " +
"frame-ancestors 'none'; " +
"base-uri 'self'; " +
"form-action 'self'"
// SecurityHeaders sets hardening response headers on every response:
// CSP, nosniff, clickjacking (X-Frame-Options + frame-ancestors),
// referrer policy, and HSTS when the request arrived over HTTPS.
// Register it before all other middleware so the headers are present even
// on rejected (403/redirect) responses.
func SecurityHeaders() gin.HandlerFunc {
return func(c *gin.Context) {
c.Header("Content-Security-Policy", csp)
c.Header("X-Content-Type-Options", "nosniff")
c.Header("X-Frame-Options", "DENY")
c.Header("Referrer-Policy", "strict-origin-when-cross-origin")
c.Header("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
if IsHTTPSRequest(c) {
// includeSubDomains is deliberately omitted: some subdomains of
// the site may still be served over plain HTTP.
c.Header("Strict-Transport-Security", "max-age=31536000")
}
c.Next()
}
}
+102
View File
@@ -0,0 +1,102 @@
package middleware
import (
"crypto/tls"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
)
func newHeadersTestRouter() *gin.Engine {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(SecurityHeaders())
r.GET("/", func(c *gin.Context) { c.String(http.StatusOK, "ok") })
return r
}
func TestSecurityHeadersPresent(t *testing.T) {
r := newHeadersTestRouter()
// Plain HTTP request: hardening headers present, no HSTS.
req := httptest.NewRequest(http.MethodGet, "/", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
for _, h := range []string{
"Content-Security-Policy",
"X-Content-Type-Options",
"X-Frame-Options",
"Referrer-Policy",
"Permissions-Policy",
} {
if v := w.Header().Get(h); v == "" {
t.Errorf("missing header %s", h)
}
}
if w.Header().Get("X-Content-Type-Options") != "nosniff" {
t.Errorf("X-Content-Type-Options = %q, want nosniff", w.Header().Get("X-Content-Type-Options"))
}
if w.Header().Get("X-Frame-Options") != "DENY" {
t.Errorf("X-Frame-Options = %q, want DENY", w.Header().Get("X-Frame-Options"))
}
if w.Header().Get("Content-Security-Policy") == "" {
t.Error("CSP header missing")
}
if w.Header().Get("Strict-Transport-Security") != "" {
t.Errorf("HSTS must be absent over plain HTTP, got %q", w.Header().Get("Strict-Transport-Security"))
}
}
func TestSecurityHeadersHSTSOverHTTPS(t *testing.T) {
r := newHeadersTestRouter()
// TLS request: HSTS present.
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.TLS = &tls.ConnectionState{}
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if v := w.Header().Get("Strict-Transport-Security"); v != "max-age=31536000" {
t.Errorf("HSTS over TLS = %q, want max-age=31536000", v)
}
// Behind a trusted proxy (X-Forwarded-Proto: https): HSTS present.
req = httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set("X-Forwarded-Proto", "https")
w = httptest.NewRecorder()
r.ServeHTTP(w, req)
if v := w.Header().Get("Strict-Transport-Security"); v != "max-age=31536000" {
t.Errorf("HSTS behind proxy = %q, want max-age=31536000", v)
}
}
func TestIsHTTPSRequest(t *testing.T) {
// TLS request.
if !IsHTTPSRequest(&gin.Context{Request: mustTLSRequest()}) {
t.Error("TLS request must be HTTPS")
}
// Plain request.
c := &gin.Context{}
c.Request = httptest.NewRequest(http.MethodGet, "/", nil)
if IsHTTPSRequest(c) {
t.Error("plain request must not be HTTPS")
}
// X-Forwarded-Proto: https.
c.Request.Header.Set("X-Forwarded-Proto", "https")
if !IsHTTPSRequest(c) {
t.Error("X-Forwarded-Proto https must be treated as HTTPS")
}
// X-Forwarded-Proto: http must not trigger HTTPS behavior.
c.Request.Header.Set("X-Forwarded-Proto", "http")
if IsHTTPSRequest(c) {
t.Error("X-Forwarded-Proto http must not be treated as HTTPS")
}
}
func mustTLSRequest() *http.Request {
r := httptest.NewRequest(http.MethodGet, "/", nil)
r.TLS = &tls.ConnectionState{}
return r
}
+377
View File
@@ -0,0 +1,377 @@
/*
* markdown.css — typography and components for rendered Markdown
* (article body, comments, editor previews).
*
* All rules are scoped under .md-body so they never leak into the rest
* of the page. Sizes are written in em so the content scales with the
* container's font size (articles are base size, comments are text-sm).
*/
.md-body {
font-size: 1em;
line-height: 1.75;
color: #1f2937; /* gray-800 */
word-wrap: break-word;
overflow-wrap: break-word;
}
/* ---------- paragraphs ---------- */
.md-body p {
margin: 0 0 1em;
}
.md-body > :first-child {
margin-top: 0;
}
.md-body > :last-child {
margin-bottom: 0;
}
/* ---------- headings ---------- */
.md-body h1,
.md-body h2,
.md-body h3,
.md-body h4,
.md-body h5,
.md-body h6 {
font-weight: 700;
color: #111827; /* gray-900 */
line-height: 1.3;
margin: 1.6em 0 0.6em;
scroll-margin-top: 1rem;
}
.md-body h1 {
font-size: 1.875em;
padding-bottom: 0.3em;
border-bottom: 1px solid #e5e7eb;
}
.md-body h2 {
font-size: 1.5em;
padding-bottom: 0.3em;
border-bottom: 1px solid #e5e7eb;
}
.md-body h3 { font-size: 1.25em; }
.md-body h4 { font-size: 1.1em; }
.md-body h5 { font-size: 1em; }
.md-body h6 {
font-size: 0.9em;
color: #4b5563; /* gray-600 */
text-transform: uppercase;
letter-spacing: 0.02em;
}
/* heading permalink (#) shown on hover */
.md-body .md-anchor {
display: inline-block;
margin-left: 0.4em;
font-size: 0.8em;
font-weight: 600;
color: #9ca3af; /* gray-400 */
text-decoration: none;
visibility: hidden;
opacity: 0;
transition: opacity 0.15s ease;
}
.md-body h1:hover .md-anchor,
.md-body h2:hover .md-anchor,
.md-body h3:hover .md-anchor,
.md-body h4:hover .md-anchor,
.md-body h5:hover .md-anchor,
.md-body h6:hover .md-anchor {
visibility: visible;
opacity: 1;
}
.md-body .md-anchor:hover {
color: #2563eb; /* blue-600 */
}
/* ---------- links ---------- */
.md-body a {
color: #2563eb; /* blue-600 */
text-decoration: none;
border-bottom: 1px solid rgba(37, 99, 235, 0.3);
transition: border-color 0.15s ease;
}
.md-body a:hover {
color: #1d4ed8; /* blue-700 */
border-bottom-color: currentColor;
}
/* ---------- emphasis ---------- */
.md-body strong { font-weight: 700; color: #111827; }
.md-body em { font-style: italic; }
.md-body del { color: #6b7280; }
.md-body mark {
background: #fef08a; /* yellow-200 */
color: #1f2937;
padding: 0 0.2em;
border-radius: 0.25em;
}
.md-body kbd {
display: inline-block;
padding: 0.15em 0.45em;
font-size: 0.85em;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
color: #374151;
background: #f9fafb;
border: 1px solid #d1d5db;
border-bottom-width: 2px;
border-radius: 0.35em;
}
/* ---------- inline code ---------- */
.md-body code {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
font-size: 0.875em;
background: #f3f4f6; /* gray-100 */
color: #111827;
padding: 0.2em 0.4em;
border-radius: 0.375em;
}
.md-body pre code {
background: transparent;
color: inherit;
padding: 0;
font-size: 0.9em;
border-radius: 0;
display: block;
overflow-x: auto;
}
/* ---------- code blocks ---------- */
.md-body pre {
position: relative;
margin: 1em 0;
padding: 1rem 1.1rem;
background: #f6f8fa; /* github light */
border: 1px solid #e5e7eb;
border-radius: 0.5rem;
overflow-x: auto;
line-height: 1.6;
tab-size: 4;
}
/* language badge (top-left) */
.md-body .md-lang {
position: absolute;
top: 0.5rem;
left: 0.9rem;
font-size: 0.65rem;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: #9ca3af;
user-select: none;
pointer-events: none;
}
/* copy button (top-right, appears on hover) */
.md-body .md-copy-btn {
position: absolute;
top: 0.5rem;
right: 0.5rem;
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.25rem 0.55rem;
font-size: 0.72rem;
font-weight: 500;
color: #6b7280;
background: #ffffff;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
cursor: pointer;
opacity: 0;
transition: opacity 0.15s ease, color 0.15s ease, border-color 0.15s ease;
z-index: 1;
}
.md-body pre:hover .md-copy-btn {
opacity: 1;
}
.md-body .md-copy-btn:hover {
color: #1f2937;
border-color: #9ca3af;
}
.md-body .md-copy-btn.md-copied {
color: #059669; /* green-600 */
border-color: #059669;
}
.md-body .md-copy-btn svg {
width: 0.85em;
height: 0.85em;
}
@media (hover: none) {
.md-body .md-copy-btn { opacity: 1; }
}
/* ---------- lists ---------- */
.md-body ul,
.md-body ol {
margin: 0 0 1em;
padding-left: 1.6em;
}
.md-body ul { list-style: disc; }
.md-body ol { list-style: decimal; }
.md-body li {
margin: 0.3em 0;
}
.md-body li > ul,
.md-body li > ol {
margin: 0.3em 0;
}
.md-body li > p {
margin: 0.3em 0;
}
/* GFM task lists */
.md-body li:has(> input[type="checkbox"]) {
list-style: none;
margin-left: -1.6em;
}
.md-body input[type="checkbox"] {
margin-right: 0.5em;
accent-color: #2563eb;
transform: translateY(0.1em);
}
/* ---------- blockquote ---------- */
.md-body blockquote {
margin: 1em 0;
padding: 0.6em 1em;
color: #4b5563; /* gray-600 */
background: #f9fafb; /* gray-50 */
border-left: 4px solid #d1d5db;
border-radius: 0 0.5rem 0.5rem 0;
}
.md-body blockquote > :last-child {
margin-bottom: 0;
}
.md-body blockquote > :first-child {
margin-top: 0;
}
/* ---------- tables ---------- */
.md-body .md-table-wrap {
margin: 1em 0;
overflow-x: auto;
border: 1px solid #e5e7eb;
border-radius: 0.5rem;
}
.md-body table {
width: 100%;
border-collapse: collapse;
font-size: 0.95em;
}
.md-body th,
.md-body td {
padding: 0.5em 0.9em;
border: 1px solid #e5e7eb;
text-align: left;
vertical-align: top;
}
.md-body th {
font-weight: 600;
background: #f9fafb;
color: #111827;
white-space: nowrap;
}
.md-body tbody tr:nth-child(even) {
background: #f9fafb;
}
.md-body tbody tr:hover {
background: #f3f4f6;
}
/* ---------- images ---------- */
.md-body img {
max-width: 100%;
height: auto;
margin: 0.5em 0;
border-radius: 0.5rem;
cursor: zoom-in;
}
/* ---------- horizontal rule ---------- */
.md-body hr {
margin: 2em 0;
border: 0;
border-top: 1px solid #e5e7eb;
}
/* ---------- details (rendered by some md flavors) ---------- */
.md-body details {
margin: 1em 0;
padding: 0.6em 1em;
background: #f9fafb;
border: 1px solid #e5e7eb;
border-radius: 0.5rem;
}
.md-body summary {
cursor: pointer;
font-weight: 600;
color: #111827;
}
/* ---------- lightbox ---------- */
#mdLightbox {
position: fixed;
inset: 0;
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
background: rgba(17, 24, 39, 0.9);
padding: 2rem;
opacity: 0;
visibility: hidden;
transition: opacity 0.2s ease, visibility 0.2s ease;
}
#mdLightbox.open {
opacity: 1;
visibility: visible;
}
#mdLightbox img {
max-width: 92vw;
max-height: 88vh;
border-radius: 0.5rem;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
}
#mdLightbox .md-lightbox-close {
position: absolute;
top: 1rem;
right: 1.25rem;
width: 2.5rem;
height: 2.5rem;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.5rem;
line-height: 1;
color: #e5e7eb;
background: rgba(255, 255, 255, 0.1);
border: none;
border-radius: 9999px;
cursor: pointer;
transition: background 0.15s ease;
}
#mdLightbox .md-lightbox-close:hover {
background: rgba(255, 255, 255, 0.25);
}
#mdLightbox .md-lightbox-caption {
position: absolute;
left: 0;
right: 0;
bottom: 1rem;
text-align: center;
color: #d1d5db;
font-size: 0.85rem;
padding: 0 2rem;
}
/* ---------- responsive ---------- */
@media (max-width: 640px) {
.md-body h1 { font-size: 1.6em; }
.md-body h2 { font-size: 1.35em; }
.md-body pre { padding: 0.85rem 0.9rem; }
}
+322
View File
@@ -0,0 +1,322 @@
/*
* markdown.js — enhanced Markdown rendering for Go Blog.
*
* Wraps marked + DOMPurify + highlight.js into one global `BlogMD` object:
*
* BlogMD.render(md) -> sanitized & enhanced HTML string
* BlogMD.renderInto(el, md) -> renders into an element in place
* BlogMD.init() -> (optional) binds delegated interactions
*
* Enhancements applied after parsing:
* - GFM (tables, task lists, strikethrough, autolinks)
* - heading permalink anchors (ids are generated by marked)
* - external links open in a new tab with rel="noopener noreferrer"
* - images are lazy-loaded and open in a lightbox on click
* - code blocks get syntax highlighting + a copy button + language badge
* - tables are wrapped for horizontal scrolling on small screens
*
* All output passes through DOMPurify, so raw HTML inside Markdown is
* sanitized before it touches the DOM.
*/
(function (global) {
'use strict';
function missing() {
return typeof marked === 'undefined' || typeof DOMPurify === 'undefined';
}
// ------------------------------------------------------------------
// heading id slugger (marked no longer generates ids by default)
// ------------------------------------------------------------------
// GitHub-style, but keeps CJK letters so Chinese headings get
// meaningful anchors. One instance per document render.
function makeSlugger() {
var counts = {};
return function (text) {
var slug = text
.replace(/<[^>]+>/g, '') // drop inline HTML (code, em, …)
.toLowerCase()
.trim()
.replace(/[^\p{L}\p{N}\u3000-\u303f]+/gu, '-') // runs -> single '-'
.replace(/-+/g, '-')
.replace(/^-|-$/g, '');
if (!slug) slug = 'section';
if (counts[slug] === undefined) counts[slug] = 0;
counts[slug] += 1;
return counts[slug] === 1 ? slug : slug + '-' + (counts[slug] - 1);
};
}
var slugger = makeSlugger();
// ------------------------------------------------------------------
// marked configuration (once)
// ------------------------------------------------------------------
if (!missing() && !window.__blogMdConfigured) {
window.__blogMdConfigured = true;
marked.use({
gfm: true, // tables, task lists, strikethrough, autolinks
breaks: false, // single newline does NOT become <br> (standard Markdown)
renderer: {
// re-enable heading ids (removed from marked core in v4)
heading: function (token) {
var text = this.parser.parseInline(token.tokens);
var id = slugger(text);
return '<h' + token.depth + ' id="' + id + '">' + text + '</h' + token.depth + '>';
}
}
});
}
var PURIFY_CONFIG = {
// `loading` is not in DOMPurify's default allow-list; everything
// else we emit (id, class, target, rel, checked, disabled, type)
// is allowed by default.
ADD_ATTR: ['loading']
};
// ------------------------------------------------------------------
// DOM post-processing
// ------------------------------------------------------------------
function processRoot(root) {
if (!root || !root.querySelectorAll) return;
// 1) heading permalinks (marked already assigns ids)
root.querySelectorAll('h1[id], h2[id], h3[id], h4[id], h5[id], h6[id]').forEach(function (h) {
if (h.querySelector('.md-anchor')) return;
var a = document.createElement('a');
a.className = 'md-anchor';
a.href = '#' + h.id;
a.setAttribute('aria-hidden', 'true');
a.setAttribute('title', h.textContent);
a.textContent = '#';
h.appendChild(a);
});
// 2) external links -> new tab, safe rel
root.querySelectorAll('a[href]').forEach(function (a) {
var href = a.getAttribute('href') || '';
if (/^https?:\/\//i.test(href) || href.indexOf('//') === 0) {
a.setAttribute('target', '_blank');
a.setAttribute('rel', 'noopener noreferrer');
}
});
// 3) images: lazy load + zoom on click (lightbox is delegated)
root.querySelectorAll('img').forEach(function (img) {
img.setAttribute('loading', 'lazy');
img.classList.add('md-img');
if (!img.getAttribute('alt')) img.setAttribute('alt', '');
});
// 4) tables: wrap for horizontal scroll
root.querySelectorAll('table').forEach(function (table) {
var parent = table.parentNode;
if (parent && parent.classList && parent.classList.contains('md-table-wrap')) return;
var wrap = document.createElement('div');
wrap.className = 'md-table-wrap';
table.parentNode.insertBefore(wrap, table);
wrap.appendChild(table);
});
// 5) code blocks: highlight + copy button + language badge
root.querySelectorAll('pre code').forEach(function (code) {
var pre = code.parentNode;
if (pre && pre.querySelector && pre.querySelector('.md-copy-btn')) return;
// syntax highlighting (only when a language is declared)
if (typeof hljs !== 'undefined') {
var langMatch = /(?:^|\s)language-([\w-]+)/.exec(code.className || '');
if (langMatch) {
try { hljs.highlightElement(code); } catch (e) { /* keep plain */ }
}
}
// language badge
var lang = /(?:^|\s)language-([\w-]+)/.exec(code.className || '');
if (lang) {
var badge = document.createElement('span');
badge.className = 'md-lang';
badge.textContent = lang[1].length > 14 ? lang[1].slice(0, 14) + '…' : lang[1];
pre.appendChild(badge);
}
// copy button (delegated click handler, see initInteractions)
var btn = document.createElement('button');
btn.type = 'button';
btn.className = 'md-copy-btn';
btn.title = 'Copy code';
btn.setAttribute('aria-label', 'Copy code');
btn.innerHTML =
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" ' +
'stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
'<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>' +
'<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>';
pre.appendChild(btn);
});
}
// ------------------------------------------------------------------
// clipboard helpers
// ------------------------------------------------------------------
function copyText(text, btn) {
function done() {
btn.classList.add('md-copied');
btn.textContent = '✓';
setTimeout(function () {
btn.classList.remove('md-copied');
btn.innerHTML =
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" ' +
'stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
'<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>' +
'<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>';
}, 1500);
}
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(done).catch(function () {
legacyCopy(text, done);
});
} else {
legacyCopy(text, done);
}
}
function legacyCopy(text, done) {
var ta = document.createElement('textarea');
ta.value = text;
ta.setAttribute('readonly', '');
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
try {
document.execCommand('copy');
done();
} catch (e) { /* clipboard unavailable */ }
document.body.removeChild(ta);
}
// ------------------------------------------------------------------
// lightbox
// ------------------------------------------------------------------
function ensureLightbox() {
var lb = document.getElementById('mdLightbox');
if (lb) return lb;
lb = document.createElement('div');
lb.id = 'mdLightbox';
lb.setAttribute('role', 'dialog');
lb.setAttribute('aria-modal', 'true');
lb.innerHTML =
'<button type="button" class="md-lightbox-close" aria-label="Close">&times;</button>' +
'<img alt="">' +
'<div class="md-lightbox-caption"></div>';
document.body.appendChild(lb);
lb.addEventListener('click', function (e) {
if (e.target === lb || e.target.classList.contains('md-lightbox-close')) {
closeLightbox();
}
});
return lb;
}
function openLightbox(img) {
var lb = ensureLightbox();
lb.querySelector('img').src = img.currentSrc || img.src;
var cap = lb.querySelector('.md-lightbox-caption');
var alt = (img.getAttribute('alt') || '').trim();
cap.textContent = alt;
cap.style.display = alt ? '' : 'none';
lb.classList.add('open');
document.body.style.overflow = 'hidden';
}
function closeLightbox() {
var lb = document.getElementById('mdLightbox');
if (!lb) return;
lb.classList.remove('open');
document.body.style.overflow = '';
}
// ------------------------------------------------------------------
// delegated interactions
// ------------------------------------------------------------------
// Copy buttons are rendered into HTML strings that get re-parsed via
// innerHTML, so listeners must live on the document, not the buttons.
var interactionsReady = false;
function initInteractions() {
if (interactionsReady) return;
interactionsReady = true;
// code copy (delegated)
document.addEventListener('click', function (e) {
var btn = e.target && e.target.closest ? e.target.closest('.md-copy-btn') : null;
if (!btn) return;
var pre = btn.closest('pre');
var code = pre && pre.querySelector('code');
if (!code) return;
copyText(code.textContent || '', btn);
});
// image lightbox (delegated, works for content rendered at any time)
document.addEventListener('click', function (e) {
var img = e.target;
if (!img || !img.closest) return;
var target = img.closest('.md-body img');
if (!target) return;
// images wrapped in a link keep normal navigation
if (target.closest('a')) return;
e.preventDefault();
openLightbox(target);
});
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') closeLightbox();
});
}
// ------------------------------------------------------------------
// public API
// ------------------------------------------------------------------
function render(md) {
if (missing()) {
// dependency unavailable: show the raw text, HTML-escaped
var esc = document.createElement('div');
esc.textContent = String(md == null ? '' : md);
return esc.innerHTML;
}
var html;
try {
slugger = makeSlugger(); // fresh id namespace per document
html = marked.parse(String(md == null ? '' : md));
} catch (e) {
html = String(md == null ? '' : md);
}
var clean = DOMPurify.sanitize(html, PURIFY_CONFIG);
var div = document.createElement('div');
div.innerHTML = clean;
processRoot(div);
return div.innerHTML;
}
function renderInto(el, md) {
if (!el) return el;
el.innerHTML = render(md);
return el;
}
global.BlogMD = {
render: render,
renderInto: renderInto,
init: initInteractions
};
initInteractions(); // safe to bind immediately (delegated, lazily built DOM)
})(window);
+16 -2
View File
@@ -1,5 +1,6 @@
{{define "article_create"}} {{define "article_create"}}
{{template "header" .}} {{template "header" .}}
{{template "markdown_assets" .}}
<section class="max-w-3xl mx-auto px-4 py-10"> <section class="max-w-3xl mx-auto px-4 py-10">
<h2 class="text-2xl font-bold text-gray-900 mb-8">{{.FormTitleText}}</h2> <h2 class="text-2xl font-bold text-gray-900 mb-8">{{.FormTitleText}}</h2>
@@ -11,6 +12,7 @@
{{end}} {{end}}
<form action="{{.FormAction}}" method="post" class="space-y-6"> <form action="{{.FormAction}}" method="post" class="space-y-6">
<input type="hidden" name="_csrf" value="{{.CSRFToken}}">
<!-- Hidden: attachment ownership (token on create, id on edit) --> <!-- Hidden: attachment ownership (token on create, id on edit) -->
<input type="hidden" name="session_token" value="{{.SessionToken}}"> <input type="hidden" name="session_token" value="{{.SessionToken}}">
@@ -123,6 +125,9 @@ var easyMDE = new EasyMDE({
spellChecker: false, spellChecker: false,
autosave: { enabled: false }, autosave: { enabled: false },
placeholder: '{{index .Tr "article_content"}}', placeholder: '{{index .Tr "article_content"}}',
previewRender: function (plainText, preview) {
return '<div class="md-body">' + BlogMD.render(plainText) + '</div>';
},
toolbar: [ toolbar: [
'bold', 'italic', 'heading', '|', 'bold', 'italic', 'heading', '|',
'quote', 'unordered-list', 'ordered-list', '|', 'quote', 'unordered-list', 'ordered-list', '|',
@@ -138,6 +143,8 @@ var easyMDE = new EasyMDE({
(function () { (function () {
var articleID = {{ if .FormArticleID }}{{ .FormArticleID }}{{ else }}0{{ end }}; var articleID = {{ if .FormArticleID }}{{ .FormArticleID }}{{ else }}0{{ end }};
var sessionToken = "{{ .SessionToken }}"; var sessionToken = "{{ .SessionToken }}";
var csrfTokenEl = document.querySelector('meta[name="csrf-token"]');
var csrfToken = csrfTokenEl ? csrfTokenEl.getAttribute('content') : '';
var uploadBtn = document.getElementById('attachmentUploadBtn'); var uploadBtn = document.getElementById('attachmentUploadBtn');
var fileInput = document.getElementById('attachmentInput'); var fileInput = document.getElementById('attachmentInput');
var msgEl = document.getElementById('attachmentMsg'); var msgEl = document.getElementById('attachmentMsg');
@@ -199,7 +206,10 @@ var easyMDE = new EasyMDE({
delBtn.className = 'text-red-600 hover:text-red-800 font-medium cursor-pointer bg-transparent border-none'; delBtn.className = 'text-red-600 hover:text-red-800 font-medium cursor-pointer bg-transparent border-none';
delBtn.onclick = function () { delBtn.onclick = function () {
if (!confirm("{{index .Tr "article_att_delete_confirm"}}")) return; if (!confirm("{{index .Tr "article_att_delete_confirm"}}")) return;
fetch('/admin/articles/attachments/' + att.id + '/delete', { method: 'POST' }) fetch('/admin/articles/attachments/' + att.id + '/delete', {
method: 'POST',
headers: { 'X-CSRF-Token': csrfToken }
})
.then(function (r) { return r.json(); }) .then(function (r) { return r.json(); })
.then(function (r) { .then(function (r) {
if (r.ok) { tr.remove(); } if (r.ok) { tr.remove(); }
@@ -222,7 +232,11 @@ var easyMDE = new EasyMDE({
if (articleID) { fd.append('article_id', articleID); } if (articleID) { fd.append('article_id', articleID); }
else { fd.append('session_token', sessionToken); } else { fd.append('session_token', sessionToken); }
msgEl.textContent = "{{index .Tr "article_att_uploading"}}"; msgEl.textContent = "{{index .Tr "article_att_uploading"}}";
fetch('/admin/articles/attachments', { method: 'POST', body: fd }) fetch('/admin/articles/attachments', {
method: 'POST',
headers: { 'X-CSRF-Token': csrfToken },
body: fd
})
.then(function (r) { return r.json(); }) .then(function (r) { return r.json(); })
.then(function (r) { .then(function (r) {
if (r.error) { msgEl.textContent = r.error; return; } if (r.error) { msgEl.textContent = r.error; return; }
+1
View File
@@ -42,6 +42,7 @@
class="text-blue-600 hover:text-blue-800 font-medium mr-3">{{index $.Tr "article_edit"}}</a> class="text-blue-600 hover:text-blue-800 font-medium mr-3">{{index $.Tr "article_edit"}}</a>
<form action="/admin/articles/{{.ID}}/delete" method="post" class="inline" <form action="/admin/articles/{{.ID}}/delete" method="post" class="inline"
onsubmit="return confirm('{{index $.Tr "article_delete_confirm"}}');"> onsubmit="return confirm('{{index $.Tr "article_delete_confirm"}}');">
<input type="hidden" name="_csrf" value="{{$.CSRFToken}}">
<button type="submit" <button type="submit"
class="text-red-600 hover:text-red-800 font-medium cursor-pointer bg-transparent border-none">{{index $.Tr "article_delete"}}</button> class="text-red-600 hover:text-red-800 font-medium cursor-pointer bg-transparent border-none">{{index $.Tr "article_delete"}}</button>
</form> </form>
+10 -5
View File
@@ -1,5 +1,6 @@
{{define "comment_list"}} {{define "comment_list"}}
{{template "header" .}} {{template "header" .}}
{{template "markdown_assets" .}}
<section class="max-w-6xl mx-auto px-4 py-12"> <section class="max-w-6xl mx-auto px-4 py-12">
<div class="flex items-center justify-between mb-6"> <div class="flex items-center justify-between mb-6">
<h2 class="text-3xl font-bold text-gray-900">{{index .Tr "admin_comments_title"}}</h2> <h2 class="text-3xl font-bold text-gray-900">{{index .Tr "admin_comments_title"}}</h2>
@@ -51,22 +52,25 @@
↗ {{.ArticleTitle}} ↗ {{.ArticleTitle}}
</a> </a>
{{end}} {{end}}
<div class="comment-body mt-2 text-sm text-gray-700 prose prose-sm max-w-none" data-md="{{.Content}}"></div> <div class="comment-body mt-2 text-sm text-gray-700 prose prose-sm max-w-none md-body" data-md="{{.Content}}"></div>
</div> </div>
</div> </div>
<div class="flex justify-end gap-3 mt-3 text-sm"> <div class="flex justify-end gap-3 mt-3 text-sm">
{{if eq .Status 0}} {{if eq .Status 0}}
<form action="/admin/comments/{{.ID}}/approve" method="post" class="inline"> <form action="/admin/comments/{{.ID}}/approve" method="post" class="inline">
<input type="hidden" name="_csrf" value="{{$.CSRFToken}}">
<button type="submit" class="text-green-600 hover:text-green-800 font-medium cursor-pointer bg-transparent border-none">{{index $.Tr "comment_approve"}}</button> <button type="submit" class="text-green-600 hover:text-green-800 font-medium cursor-pointer bg-transparent border-none">{{index $.Tr "comment_approve"}}</button>
</form> </form>
{{end}} {{end}}
{{if ne .Status 2}} {{if ne .Status 2}}
<form action="/admin/comments/{{.ID}}/reject" method="post" class="inline"> <form action="/admin/comments/{{.ID}}/reject" method="post" class="inline">
<input type="hidden" name="_csrf" value="{{$.CSRFToken}}">
<button type="submit" class="text-orange-600 hover:text-orange-800 font-medium cursor-pointer bg-transparent border-none">{{index $.Tr "comment_reject"}}</button> <button type="submit" class="text-orange-600 hover:text-orange-800 font-medium cursor-pointer bg-transparent border-none">{{index $.Tr "comment_reject"}}</button>
</form> </form>
{{end}} {{end}}
<form action="/admin/comments/{{.ID}}/delete" method="post" class="inline" <form action="/admin/comments/{{.ID}}/delete" method="post" class="inline"
onsubmit="return confirm('{{index $.Tr "comment_delete_confirm"}}');"> onsubmit="return confirm('{{index $.Tr "comment_delete_confirm"}}');">
<input type="hidden" name="_csrf" value="{{$.CSRFToken}}">
<button type="submit" class="text-red-600 hover:text-red-800 font-medium cursor-pointer bg-transparent border-none">{{index $.Tr "comment_delete"}}</button> <button type="submit" class="text-red-600 hover:text-red-800 font-medium cursor-pointer bg-transparent border-none">{{index $.Tr "comment_delete"}}</button>
</form> </form>
</div> </div>
@@ -79,14 +83,15 @@
</div> </div>
</section> </section>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/dompurify/dist/purify.min.js"></script>
<script> <script>
(function () { (function () {
document.querySelectorAll('.comment-body[data-md]').forEach(function (el) { document.querySelectorAll('.comment-body[data-md]').forEach(function (el) {
var raw = el.getAttribute('data-md') || ''; var raw = el.getAttribute('data-md') || '';
var html = marked.parse(raw); if (window.BlogMD) {
el.innerHTML = DOMPurify.sanitize(html); BlogMD.renderInto(el, raw);
} else {
el.textContent = raw;
}
}); });
})(); })();
</script> </script>
+1
View File
@@ -7,6 +7,7 @@
<p class="text-gray-500 mt-1">{{index .Tr "dash_welcome"}} <span class="font-medium text-gray-700">{{.Username}}</span>!</p> <p class="text-gray-500 mt-1">{{index .Tr "dash_welcome"}} <span class="font-medium text-gray-700">{{.Username}}</span>!</p>
</div> </div>
<form action="/logout" method="post" class="m-0"> <form action="/logout" method="post" class="m-0">
<input type="hidden" name="_csrf" value="{{.CSRFToken}}">
<button <button
type="submit" type="submit"
class="bg-gray-200 text-gray-700 px-4 py-2 rounded-lg font-medium hover:bg-gray-300 transition-colors cursor-pointer" class="bg-gray-200 text-gray-700 px-4 py-2 rounded-lg font-medium hover:bg-gray-300 transition-colors cursor-pointer"
+1
View File
@@ -16,6 +16,7 @@
{{end}} {{end}}
<form action="/admin/settings/comments" method="post" class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-4"> <form action="/admin/settings/comments" method="post" class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-4">
<input type="hidden" name="_csrf" value="{{.CSRFToken}}">
<label class="flex items-center gap-3 text-sm text-gray-700"> <label class="flex items-center gap-3 text-sm text-gray-700">
<input type="checkbox" name="enabled" value="1" {{if .CommentConfig.Enabled}}checked{{end}} <input type="checkbox" name="enabled" value="1" {{if .CommentConfig.Enabled}}checked{{end}}
class="w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"> class="w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500">
+4
View File
@@ -42,17 +42,20 @@
</td> </td>
<td class="px-4 py-3 text-sm text-right whitespace-nowrap"> <td class="px-4 py-3 text-sm text-right whitespace-nowrap">
<form action="/admin/settings/download" method="post" class="inline"> <form action="/admin/settings/download" method="post" class="inline">
<input type="hidden" name="_csrf" value="{{$.CSRFToken}}">
<input type="hidden" name="action" value="default"> <input type="hidden" name="action" value="default">
<input type="hidden" name="id" value="{{.ID}}"> <input type="hidden" name="id" value="{{.ID}}">
<button type="submit" class="text-blue-600 hover:text-blue-800 font-medium mr-3 cursor-pointer bg-transparent border-none">{{index $.Tr "settings_set_default"}}</button> <button type="submit" class="text-blue-600 hover:text-blue-800 font-medium mr-3 cursor-pointer bg-transparent border-none">{{index $.Tr "settings_set_default"}}</button>
</form> </form>
<form action="/admin/settings/download" method="post" class="inline"> <form action="/admin/settings/download" method="post" class="inline">
<input type="hidden" name="_csrf" value="{{$.CSRFToken}}">
<input type="hidden" name="action" value="toggle"> <input type="hidden" name="action" value="toggle">
<input type="hidden" name="id" value="{{.ID}}"> <input type="hidden" name="id" value="{{.ID}}">
<button type="submit" class="text-gray-600 hover:text-gray-800 font-medium mr-3 cursor-pointer bg-transparent border-none">{{index $.Tr "settings_toggle"}}</button> <button type="submit" class="text-gray-600 hover:text-gray-800 font-medium mr-3 cursor-pointer bg-transparent border-none">{{index $.Tr "settings_toggle"}}</button>
</form> </form>
<form action="/admin/settings/download" method="post" class="inline" <form action="/admin/settings/download" method="post" class="inline"
onsubmit="return confirm('{{index $.Tr "article_delete_confirm"}}');"> onsubmit="return confirm('{{index $.Tr "article_delete_confirm"}}');">
<input type="hidden" name="_csrf" value="{{$.CSRFToken}}">
<input type="hidden" name="action" value="delete"> <input type="hidden" name="action" value="delete">
<input type="hidden" name="id" value="{{.ID}}"> <input type="hidden" name="id" value="{{.ID}}">
<button type="submit" class="text-red-600 hover:text-red-800 font-medium cursor-pointer bg-transparent border-none">{{index $.Tr "settings_delete"}}</button> <button type="submit" class="text-red-600 hover:text-red-800 font-medium cursor-pointer bg-transparent border-none">{{index $.Tr "settings_delete"}}</button>
@@ -70,6 +73,7 @@
<!-- Add base URL --> <!-- Add base URL -->
<form action="/admin/settings/download" method="post" class="bg-white rounded-xl shadow-sm border border-gray-200 p-6"> <form action="/admin/settings/download" method="post" class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<input type="hidden" name="_csrf" value="{{.CSRFToken}}">
<input type="hidden" name="action" value="add"> <input type="hidden" name="action" value="add">
<h3 class="font-semibold text-gray-800 mb-4">{{index .Tr "settings_add_url"}}</h3> <h3 class="font-semibold text-gray-800 mb-4">{{index .Tr "settings_add_url"}}</h3>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-4"> <div class="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-4">
+4
View File
@@ -17,6 +17,7 @@
<!-- Add New Link Form --> <!-- Add New Link Form -->
<form action="/admin/settings/navlinks" method="post" class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 mb-6"> <form action="/admin/settings/navlinks" method="post" class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 mb-6">
<input type="hidden" name="_csrf" value="{{.CSRFToken}}">
<input type="hidden" name="action" value="add"> <input type="hidden" name="action" value="add">
<h3 class="text-lg font-semibold text-gray-800 mb-4">{{index .Tr "navlinks_add"}}</h3> <h3 class="text-lg font-semibold text-gray-800 mb-4">{{index .Tr "navlinks_add"}}</h3>
@@ -91,6 +92,7 @@
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<!-- Toggle Button --> <!-- Toggle Button -->
<form action="/admin/settings/navlinks" method="post" class="inline"> <form action="/admin/settings/navlinks" method="post" class="inline">
<input type="hidden" name="_csrf" value="{{$.CSRFToken}}">
<input type="hidden" name="action" value="toggle"> <input type="hidden" name="action" value="toggle">
<input type="hidden" name="id" value="{{.ID}}"> <input type="hidden" name="id" value="{{.ID}}">
<button type="submit" class="text-sm px-3 py-1.5 rounded-lg border {{if .Enabled}}bg-green-50 border-green-300 text-green-700 hover:bg-green-100{{else}}bg-gray-100 border-gray-300 text-gray-600 hover:bg-gray-200{{end}} transition-colors"> <button type="submit" class="text-sm px-3 py-1.5 rounded-lg border {{if .Enabled}}bg-green-50 border-green-300 text-green-700 hover:bg-green-100{{else}}bg-gray-100 border-gray-300 text-gray-600 hover:bg-gray-200{{end}} transition-colors">
@@ -103,6 +105,7 @@
</button> </button>
<!-- Delete Button --> <!-- Delete Button -->
<form action="/admin/settings/navlinks" method="post" class="inline" onsubmit="return confirm('{{index $.Tr "navlinks_confirm_delete"}}')"> <form action="/admin/settings/navlinks" method="post" class="inline" onsubmit="return confirm('{{index $.Tr "navlinks_confirm_delete"}}')">
<input type="hidden" name="_csrf" value="{{$.CSRFToken}}">
<input type="hidden" name="action" value="delete"> <input type="hidden" name="action" value="delete">
<input type="hidden" name="id" value="{{.ID}}"> <input type="hidden" name="id" value="{{.ID}}">
<button type="submit" class="text-sm px-3 py-1.5 bg-red-50 border border-red-300 text-red-700 rounded-lg hover:bg-red-100 transition-colors"> <button type="submit" class="text-sm px-3 py-1.5 bg-red-50 border border-red-300 text-red-700 rounded-lg hover:bg-red-100 transition-colors">
@@ -125,6 +128,7 @@
<div class="bg-white rounded-xl shadow-xl max-w-2xl w-full mx-4 p-6"> <div class="bg-white rounded-xl shadow-xl max-w-2xl w-full mx-4 p-6">
<h3 class="text-xl font-semibold text-gray-900 mb-4">{{index .Tr "navlinks_edit"}}</h3> <h3 class="text-xl font-semibold text-gray-900 mb-4">{{index .Tr "navlinks_edit"}}</h3>
<form action="/admin/settings/navlinks" method="post" id="editForm"> <form action="/admin/settings/navlinks" method="post" id="editForm">
<input type="hidden" name="_csrf" value="{{.CSRFToken}}">
<input type="hidden" name="action" value="edit"> <input type="hidden" name="action" value="edit">
<input type="hidden" name="id" id="edit_id"> <input type="hidden" name="id" id="edit_id">
+1
View File
@@ -16,6 +16,7 @@
{{end}} {{end}}
<form action="/admin/settings/site" method="post" enctype="multipart/form-data" class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-6"> <form action="/admin/settings/site" method="post" enctype="multipart/form-data" class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-6">
<input type="hidden" name="_csrf" value="{{.CSRFToken}}">
<!-- Logo --> <!-- Logo -->
<div> <div>
<label class="block text-sm font-semibold text-gray-700 mb-2">{{index .Tr "settings_logo"}}</label> <label class="block text-sm font-semibold text-gray-700 mb-2">{{index .Tr "settings_logo"}}</label>
+5
View File
@@ -17,6 +17,7 @@
<!-- Global policy --> <!-- Global policy -->
<form action="/admin/settings/upload" method="post" class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 mb-8"> <form action="/admin/settings/upload" method="post" class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 mb-8">
<input type="hidden" name="_csrf" value="{{.CSRFToken}}">
<input type="hidden" name="action" value="save_config"> <input type="hidden" name="action" value="save_config">
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-4"> <div class="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-4">
<div> <div>
@@ -66,6 +67,7 @@
<td class="px-4 py-3 text-sm text-gray-500">{{index $.Tr (printf "cat_%s" .Category)}}</td> <td class="px-4 py-3 text-sm text-gray-500">{{index $.Tr (printf "cat_%s" .Category)}}</td>
<td class="px-4 py-3 text-sm text-gray-500"> <td class="px-4 py-3 text-sm text-gray-500">
<form action="/admin/settings/upload" method="post" class="flex items-center gap-1"> <form action="/admin/settings/upload" method="post" class="flex items-center gap-1">
<input type="hidden" name="_csrf" value="{{$.CSRFToken}}">
<input type="hidden" name="action" value="size_type"> <input type="hidden" name="action" value="size_type">
<input type="hidden" name="id" value="{{.ID}}"> <input type="hidden" name="id" value="{{.ID}}">
<input type="number" name="max_size" min="0" step="0.1" value="{{.MaxSizeMB}}" <input type="number" name="max_size" min="0" step="0.1" value="{{.MaxSizeMB}}"
@@ -79,12 +81,14 @@
</td> </td>
<td class="px-4 py-3 text-sm text-right whitespace-nowrap"> <td class="px-4 py-3 text-sm text-right whitespace-nowrap">
<form action="/admin/settings/upload" method="post" class="inline"> <form action="/admin/settings/upload" method="post" class="inline">
<input type="hidden" name="_csrf" value="{{$.CSRFToken}}">
<input type="hidden" name="action" value="toggle_type"> <input type="hidden" name="action" value="toggle_type">
<input type="hidden" name="id" value="{{.ID}}"> <input type="hidden" name="id" value="{{.ID}}">
<button type="submit" class="text-gray-600 hover:text-gray-800 font-medium mr-3 cursor-pointer bg-transparent border-none">{{index $.Tr "settings_toggle"}}</button> <button type="submit" class="text-gray-600 hover:text-gray-800 font-medium mr-3 cursor-pointer bg-transparent border-none">{{index $.Tr "settings_toggle"}}</button>
</form> </form>
<form action="/admin/settings/upload" method="post" class="inline" <form action="/admin/settings/upload" method="post" class="inline"
onsubmit="return confirm('{{index $.Tr "article_delete_confirm"}}');"> onsubmit="return confirm('{{index $.Tr "article_delete_confirm"}}');">
<input type="hidden" name="_csrf" value="{{$.CSRFToken}}">
<input type="hidden" name="action" value="delete_type"> <input type="hidden" name="action" value="delete_type">
<input type="hidden" name="id" value="{{.ID}}"> <input type="hidden" name="id" value="{{.ID}}">
<button type="submit" class="text-red-600 hover:text-red-800 font-medium cursor-pointer bg-transparent border-none">{{index $.Tr "settings_delete"}}</button> <button type="submit" class="text-red-600 hover:text-red-800 font-medium cursor-pointer bg-transparent border-none">{{index $.Tr "settings_delete"}}</button>
@@ -98,6 +102,7 @@
<!-- Add file type --> <!-- Add file type -->
<form action="/admin/settings/upload" method="post" class="bg-white rounded-xl shadow-sm border border-gray-200 p-6"> <form action="/admin/settings/upload" method="post" class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<input type="hidden" name="_csrf" value="{{.CSRFToken}}">
<input type="hidden" name="action" value="add_type"> <input type="hidden" name="action" value="add_type">
<h3 class="font-semibold text-gray-800 mb-4">{{index .Tr "settings_add_type"}}</h3> <h3 class="font-semibold text-gray-800 mb-4">{{index .Tr "settings_add_type"}}</h3>
<div class="grid grid-cols-1 sm:grid-cols-4 gap-4 mb-4"> <div class="grid grid-cols-1 sm:grid-cols-4 gap-4 mb-4">
+1
View File
@@ -10,6 +10,7 @@
{{end}} {{end}}
<form action="{{.FormAction}}" method="post" class="space-y-6"> <form action="{{.FormAction}}" method="post" class="space-y-6">
<input type="hidden" name="_csrf" value="{{.CSRFToken}}">
<!-- Username (read-only on edit) --> <!-- Username (read-only on edit) -->
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "user_username"}}</label> <label class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "user_username"}}</label>
+1
View File
@@ -61,6 +61,7 @@
class="text-blue-600 hover:text-blue-800 font-medium mr-3">{{index $.Tr "article_edit"}}</a> class="text-blue-600 hover:text-blue-800 font-medium mr-3">{{index $.Tr "article_edit"}}</a>
<form action="/admin/users/{{.ID}}/delete" method="post" class="inline" <form action="/admin/users/{{.ID}}/delete" method="post" class="inline"
onsubmit="return confirm('{{index $.Tr "user_delete_confirm"}}');"> onsubmit="return confirm('{{index $.Tr "user_delete_confirm"}}');">
<input type="hidden" name="_csrf" value="{{$.CSRFToken}}">
<button type="submit" <button type="submit"
class="text-red-600 hover:text-red-800 font-medium cursor-pointer bg-transparent border-none">{{index $.Tr "settings_delete"}}</button> class="text-red-600 hover:text-red-800 font-medium cursor-pointer bg-transparent border-none">{{index $.Tr "settings_delete"}}</button>
</form> </form>
+17
View File
@@ -4,6 +4,7 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="csrf-token" content="{{.CSRFToken}}">
<title>{{.Title}} - {{index .Tr "site_title"}}</title> <title>{{.Title}} - {{index .Tr "site_title"}}</title>
{{if .SiteFavicon}} {{if .SiteFavicon}}
{{if .SiteFaviconIsURL}} {{if .SiteFaviconIsURL}}
@@ -80,6 +81,7 @@
{{end}} {{end}}
<div class="border-t border-gray-100 my-1"></div> <div class="border-t border-gray-100 my-1"></div>
<form action="/logout" method="post" class="m-0"> <form action="/logout" method="post" class="m-0">
<input type="hidden" name="_csrf" value="{{.CSRFToken}}">
<button type="submit" class="w-full text-left block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 transition-colors cursor-pointer bg-transparent border-none"> <button type="submit" class="w-full text-left block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 transition-colors cursor-pointer bg-transparent border-none">
{{index .Tr "logout"}} {{index .Tr "logout"}}
</button> </button>
@@ -108,6 +110,21 @@
{{end}} {{end}}
{{end}} {{end}}
{{define "markdown_assets"}}
{{/* Markdown rendering assets: marked (pinned UMD build), DOMPurify,
highlight.js and the shared renderer + styles. Include on any page that
renders Markdown (articles, comments, editor previews).
Local static files carry a ?v= cache-buster: bump it whenever
static/js/markdown.js or static/css/markdown.css changes, otherwise
browsers may keep serving stale cached copies. */}}
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.12.0/build/styles/github.min.css">
<link rel="stylesheet" href="/static/css/markdown.css?v=2">
<script src="https://cdn.jsdelivr.net/npm/marked@15.0.12/marked.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/dompurify@3.4.13/dist/purify.min.js"></script>
<script src="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.12.0/build/highlight.min.js"></script>
<script src="/static/js/markdown.js?v=2"></script>
{{end}}
{{define "footer"}} {{define "footer"}}
</main> </main>
+32 -19
View File
@@ -1,8 +1,6 @@
{{define "article"}} {{define "article"}}
{{template "header" .}} {{template "header" .}}
{{template "markdown_assets" .}}
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/dompurify/dist/purify.min.js"></script>
<section class="max-w-3xl mx-auto px-4 py-12"> <section class="max-w-3xl mx-auto px-4 py-12">
<div class="flex items-center justify-between mb-6"> <div class="flex items-center justify-between mb-6">
@@ -44,7 +42,7 @@
</div> </div>
{{end}} {{end}}
<div id="articleBody" class="prose max-w-none text-gray-800"></div> <div id="articleBody" class="prose max-w-none text-gray-800 md-body"></div>
</article> </article>
{{if .CommentError}} {{if .CommentError}}
@@ -77,6 +75,7 @@
<button type="button" id="cancelReply" class="text-blue-600 hover:text-blue-800 font-medium bg-transparent border-none cursor-pointer">{{index .Tr "comments_cancel_reply"}}</button> <button type="button" id="cancelReply" class="text-blue-600 hover:text-blue-800 font-medium bg-transparent border-none cursor-pointer">{{index .Tr "comments_cancel_reply"}}</button>
</div> </div>
<form id="commentForm" action="/article/{{.Article.Slug}}/comments" method="post"> <form id="commentForm" action="/article/{{.Article.Slug}}/comments" method="post">
<input type="hidden" name="_csrf" value="{{.CSRFToken}}">
<input type="hidden" name="parent_id" id="parent_id" value="{{.CommentForm.ParentID}}"> <input type="hidden" name="parent_id" id="parent_id" value="{{.CommentForm.ParentID}}">
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-4"> <div class="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-4">
<div> <div>
@@ -144,21 +143,31 @@
</section> </section>
<script> <script>
// Article body // Article body: render Markdown client-side through the shared BlogMD
var md = {{.Article.Content}}; // pipeline (marked + DOMPurify + highlight.js, see /static/js/markdown.js).
document.getElementById('articleBody').innerHTML = DOMPurify.sanitize(marked.parse(md)); // If the renderer failed to load, fall back to plain text so the article
// is never blank.
(function () {
var body = document.getElementById('articleBody');
var content = {{.Article.Content}};
if (window.BlogMD) {
BlogMD.renderInto(body, content);
} else {
body.textContent = content;
}
})();
marked.setOptions({ mangle: false, headerIds: false }); // Comments are rendered from the escaped data-md attribute.
document.querySelectorAll('.comment-body[data-md]').forEach(function (el) {
function renderCommentBodies() { if (el.dataset.rendered) return;
document.querySelectorAll('.comment-body[data-md]').forEach(function (el) { var raw = el.getAttribute('data-md') || '';
if (el.dataset.rendered) return; if (window.BlogMD) {
var raw = el.getAttribute('data-md') || ''; BlogMD.renderInto(el, raw);
el.innerHTML = DOMPurify.sanitize(marked.parse(raw)); } else {
el.dataset.rendered = '1'; el.textContent = raw;
}); }
} el.dataset.rendered = '1';
renderCommentBodies(); });
// ---- Comment form: preview / emoji / reply ---- // ---- Comment form: preview / emoji / reply ----
// Auto-dismiss the one-time success/pending notice after a few seconds. // Auto-dismiss the one-time success/pending notice after a few seconds.
@@ -198,7 +207,11 @@
var previewing = previewBox.classList.toggle('hidden') === false; var previewing = previewBox.classList.toggle('hidden') === false;
textarea.classList.toggle('hidden', previewing); textarea.classList.toggle('hidden', previewing);
if (previewing) { if (previewing) {
previewBox.innerHTML = DOMPurify.sanitize(marked.parse(textarea.value)); if (window.BlogMD) {
previewBox.innerHTML = BlogMD.render(textarea.value);
} else {
previewBox.textContent = textarea.value;
}
togglePreview.textContent = '{{index .Tr "comments_edit"}}'; togglePreview.textContent = '{{index .Tr "comments_edit"}}';
} else { } else {
togglePreview.textContent = '{{index .Tr "comments_preview"}}'; togglePreview.textContent = '{{index .Tr "comments_preview"}}';
+1 -1
View File
@@ -17,7 +17,7 @@
{{if .Comment.IsPrivate}}<span class="text-xs bg-purple-100 text-purple-700 px-2 py-0.5 rounded">{{index .Tr "comments_private_badge"}}</span>{{end}} {{if .Comment.IsPrivate}}<span class="text-xs bg-purple-100 text-purple-700 px-2 py-0.5 rounded">{{index .Tr "comments_private_badge"}}</span>{{end}}
{{if eq .Comment.Status 0}}<span class="text-xs bg-yellow-100 text-yellow-700 px-2 py-0.5 rounded">{{index .Tr "comments_pending"}}</span>{{end}} {{if eq .Comment.Status 0}}<span class="text-xs bg-yellow-100 text-yellow-700 px-2 py-0.5 rounded">{{index .Tr "comments_pending"}}</span>{{end}}
</div> </div>
<div class="comment-body mt-1 text-sm text-gray-700 prose prose-sm max-w-none" data-md="{{.Comment.Content}}"></div> <div class="comment-body mt-1 text-sm text-gray-700 prose prose-sm max-w-none md-body" data-md="{{.Comment.Content}}"></div>
<button type="button" class="reply-btn mt-2 text-xs text-blue-600 hover:text-blue-800 font-medium bg-transparent border-none cursor-pointer" <button type="button" class="reply-btn mt-2 text-xs text-blue-600 hover:text-blue-800 font-medium bg-transparent border-none cursor-pointer"
data-id="{{.Comment.ID}}" data-name="{{.Comment.AuthorName}}">{{index .Tr "comments_reply"}}</button> data-id="{{.Comment.ID}}" data-name="{{.Comment.AuthorName}}">{{index .Tr "comments_reply"}}</button>
</div> </div>
+1
View File
@@ -11,6 +11,7 @@
{{end}} {{end}}
<form action="/login" method="post" class="space-y-5"> <form action="/login" method="post" class="space-y-5">
<input type="hidden" name="_csrf" value="{{.CSRFToken}}">
<div> <div>
<label for="username" class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "login_username"}}</label> <label for="username" class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "login_username"}}</label>
<input <input
+5
View File
@@ -36,6 +36,7 @@
</div> </div>
<form action="/profile" method="post" enctype="multipart/form-data" class="space-y-8"> <form action="/profile" method="post" enctype="multipart/form-data" class="space-y-8">
<input type="hidden" name="_csrf" value="{{.CSRFToken}}">
<!-- Avatar Section --> <!-- Avatar Section -->
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6"> <div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<h3 class="text-lg font-semibold text-gray-800 mb-4">{{index .Tr "profile_avatar"}}</h3> <h3 class="text-lg font-semibold text-gray-800 mb-4">{{index .Tr "profile_avatar"}}</h3>
@@ -137,6 +138,9 @@
var i18nCropSuccess = "{{index .Tr "crop_success"}}"; var i18nCropSuccess = "{{index .Tr "crop_success"}}";
var i18nCropConfirm = "{{index .Tr "crop_confirm"}}"; var i18nCropConfirm = "{{index .Tr "crop_confirm"}}";
var csrfTokenEl = document.querySelector('meta[name="csrf-token"]');
var csrfToken = csrfTokenEl ? csrfTokenEl.getAttribute('content') : '';
fileInput.addEventListener('change', function() { fileInput.addEventListener('change', function() {
var file = this.files[0]; var file = this.files[0];
if (!file) return; if (!file) return;
@@ -193,6 +197,7 @@
fetch('/profile/avatar', { fetch('/profile/avatar', {
method: 'POST', method: 'POST',
headers: { 'X-CSRF-Token': csrfToken },
body: formData, body: formData,
credentials: 'same-origin' credentials: 'same-origin'
}) })
+1
View File
@@ -11,6 +11,7 @@
{{end}} {{end}}
<form action="/register" method="post" class="space-y-5"> <form action="/register" method="post" class="space-y-5">
<input type="hidden" name="_csrf" value="{{.CSRFToken}}">
<div> <div>
<label for="username" class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "register_username"}}</label> <label for="username" class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "register_username"}}</label>
<input <input
+5
View File
@@ -1,5 +1,6 @@
{{define "my_article_form"}} {{define "my_article_form"}}
{{template "header" .}} {{template "header" .}}
{{template "markdown_assets" .}}
<section class="max-w-4xl mx-auto px-4 py-12"> <section class="max-w-4xl mx-auto px-4 py-12">
<div class="mb-8"> <div class="mb-8">
<h2 class="text-3xl font-bold text-gray-900">{{.FormTitleText}}</h2> <h2 class="text-3xl font-bold text-gray-900">{{.FormTitleText}}</h2>
@@ -12,6 +13,7 @@
{{end}} {{end}}
<form action="{{.FormAction}}" method="post" class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-6"> <form 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}} {{if .SessionToken}}
<input type="hidden" name="session_token" value="{{.SessionToken}}"> <input type="hidden" name="session_token" value="{{.SessionToken}}">
{{end}} {{end}}
@@ -93,6 +95,9 @@ document.addEventListener('DOMContentLoaded', function() {
element: document.getElementById('content'), element: document.getElementById('content'),
spellChecker: false, spellChecker: false,
status: 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", "|", toolbar: ["bold", "italic", "heading", "|", "quote", "unordered-list", "ordered-list", "|",
"link", "image", "|", "preview", "side-by-side", "fullscreen", "|", "guide"] "link", "image", "|", "preview", "side-by-side", "fullscreen", "|", "guide"]
}); });
+1
View File
@@ -42,6 +42,7 @@
class="text-blue-600 hover:text-blue-800 font-medium mr-3">{{index $.Tr "article_edit"}}</a> class="text-blue-600 hover:text-blue-800 font-medium mr-3">{{index $.Tr "article_edit"}}</a>
<form action="/my/articles/{{.ID}}/delete" method="post" class="inline" <form action="/my/articles/{{.ID}}/delete" method="post" class="inline"
onsubmit="return confirm('{{index $.Tr "article_delete_confirm"}}');"> onsubmit="return confirm('{{index $.Tr "article_delete_confirm"}}');">
<input type="hidden" name="_csrf" value="{{$.CSRFToken}}">
<button type="submit" <button type="submit"
class="text-red-600 hover:text-red-800 font-medium cursor-pointer bg-transparent border-none">{{index $.Tr "article_delete"}}</button> class="text-red-600 hover:text-red-800 font-medium cursor-pointer bg-transparent border-none">{{index $.Tr "article_delete"}}</button>
</form> </form>