Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
38cd09f723 | ||
|
|
c3f8be58c9 | ||
|
|
c5218f35a1 | ||
|
|
9c0ba8cd6d | ||
|
|
bd3a875403 | ||
|
|
5f21c73044 | ||
|
|
83866f6de2 |
@@ -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/
|
||||||
|
|||||||
@@ -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)
|
||||||
|
- XSS:html/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 按迭代排入
|
||||||
+31
-13
@@ -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"
|
||||||
@@ -29,8 +29,17 @@ type DatabaseConfig struct {
|
|||||||
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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 {} \;
|
||||||
|
|||||||
@@ -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)")
|
||||||
@@ -37,21 +47,60 @@ func main() {
|
|||||||
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))
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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">×</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);
|
||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
function renderCommentBodies() {
|
|
||||||
document.querySelectorAll('.comment-body[data-md]').forEach(function (el) {
|
document.querySelectorAll('.comment-body[data-md]').forEach(function (el) {
|
||||||
if (el.dataset.rendered) return;
|
if (el.dataset.rendered) return;
|
||||||
var raw = el.getAttribute('data-md') || '';
|
var raw = el.getAttribute('data-md') || '';
|
||||||
el.innerHTML = DOMPurify.sanitize(marked.parse(raw));
|
if (window.BlogMD) {
|
||||||
|
BlogMD.renderInto(el, raw);
|
||||||
|
} else {
|
||||||
|
el.textContent = raw;
|
||||||
|
}
|
||||||
el.dataset.rendered = '1';
|
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"}}';
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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'
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"]
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user