把 Markdown 变成安全网页:文章发布链路完整实现
把 Markdown 变成安全网页:文章发布链路完整实现
博客 MVP 的流程看起来很短:
上传 Markdown -> 保存数据库 -> 打开文章
真正实现时,中间至少要处理:
- 文件大小
- 扩展名
- slug
- 标题提取
- Markdown 渲染
- 代码高亮
- XSS 清理
- 发布时间
- 数据库唯一约束
漏掉其中任何一项,系统都可能以一种很有创造力的方式出错。
一、文章表怎么设计
CREATE TABLE posts (
id BIGSERIAL PRIMARY KEY,
slug VARCHAR(255) NOT NULL UNIQUE,
title VARCHAR(255) NOT NULL,
content_md TEXT NOT NULL DEFAULT '',
content_html TEXT NOT NULL DEFAULT '',
status VARCHAR(32) NOT NULL DEFAULT 'draft',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
published_at TIMESTAMPTZ
);
几个关键字段:
slug:URL 中的稳定标识,如/posts/go-http-servercontent_md:原始内容content_html:发布时生成的安全 HTMLstatus:草稿或发布published_at:真正的发布时间
slug 使用唯一约束。重复上传同名文件时,让数据库拒绝比静默覆盖更安全。
二、Handler 只处理 HTTP 细节
上传接口先限制请求体:
const maxUploadSize = 10 << 20
r.Body = http.MaxBytesReader(w, r.Body, maxUploadSize)
if err := r.ParseMultipartForm(maxUploadSize); err != nil {
renderError("文件太大")
return
}
然后检查扩展名:
ext := strings.ToLower(filepath.Ext(header.Filename))
if ext != ".md" && ext != ".markdown" {
renderError("只支持 Markdown 文件")
return
}
扩展名检查不能证明文件内容一定是 Markdown,但至少能阻止用户随手上传压缩包、视频或者某份神秘的年度总结。
三、从文件名生成 slug
func slugFromFilename(name string) string {
base := filepath.Base(name)
ext := filepath.Ext(base)
slug := strings.TrimSuffix(base, ext)
return strings.ToLower(strings.TrimSpace(slug))
}
这里还可以继续增强:
- 只允许字母、数字和连字符
- 连续空格转为
- - 拒绝空 slug
- 限制长度
当前上传者只有管理员,先使用简单规则。公开上传接口则必须做更严格校验。
四、标题从第一个一级标题提取
func extractTitle(markdown string) string {
for _, line := range strings.Split(markdown, "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "# ") {
return strings.TrimSpace(strings.TrimPrefix(line, "# "))
}
}
return ""
}
如果没有一级标题,使用 slug 作为兜底标题。
这不是完整 Markdown AST 解析,但对受控的文章上传流程足够直接。如果以后支持 YAML Front Matter,再统一改为结构化元数据。
五、渲染顺序不能错
最终管线:
Markdown
-> goldmark
-> chroma
-> bluemonday
-> 安全 HTML
goldmark
md := goldmark.New(
goldmark.WithExtensions(extension.GFM),
goldmark.WithParserOptions(parser.WithAutoHeadingID()),
goldmark.WithRendererOptions(
html.WithHardWraps(),
html.WithXHTML(),
),
)
GFM 提供表格、任务列表、删除线等常见语法。自动标题 ID 可以用于目录和锚点跳转。
chroma
highlighting.NewHighlighting(
highlighting.WithStyle("monokai"),
highlighting.WithFormatOptions(
chromahtml.WithClasses(false),
),
)
WithClasses(false) 会输出内联高亮样式。文章 HTML 更长,但不必额外维护一份 Chroma 主题 CSS。
bluemonday
policy := bluemonday.UGCPolicy().
AllowAttrs("class", "style").OnElements("span", "pre", "code").
AllowAttrs("id").OnElements("h1", "h2", "h3", "h4", "h5", "h6")
为什么放行 style?
因为 Chroma 的内联高亮依赖它。这里不是无条件允许所有元素的 style,而是只放行代码高亮涉及的元素。
六、为什么必须先渲染再过滤
假设输入:
<script>alert("hello")</script>
goldmark 可能把原始 HTML带入结果。如果只过滤 Markdown 源文本,无法准确判断渲染器最终输出了哪些标签。
正确顺序:
var buf strings.Builder
if err := renderer.Convert([]byte(contentMD), &buf); err != nil {
return nil, err
}
safeHTML := policy.Sanitize(buf.String())
然后才存入数据库。
七、发布时间不要忘记
我最初只设置了:
Status: "published"
却忘了设置 PublishedAt。
结果:
- 列表没有日期
ORDER BY published_at DESC对空值排序不符合预期- 新文章可能不在最上面
修复:
publishedAt := time.Now().UTC()
post := &blog.Post{
Status: "published",
PublishedAt: &publishedAt,
}
旧数据用迁移回填:
UPDATE posts
SET published_at = created_at
WHERE status = 'published'
AND published_at IS NULL;
状态字段和时间字段表达的是两件事,数据库不会因为看见 published 就热心地帮你补时间。
八、模板输出为什么使用 template.HTML
html/template 默认会转义 HTML:
<h1>标题</h1>
经过安全清理的正文需要标记为可信 HTML:
Content: template.HTML(post.ContentHTML)
这一步必须靠近可信边界。不要把用户原始输入直接转换成 template.HTML,否则前面的安全工作会当场失业。
九、Tailwind 本地编译
模板中的 class 由 Tailwind 扫描:
module.exports = {
content: ['./web/templates/**/*.html'],
}
编译:
npm run build:css
生产环境直接发布 output.css,不依赖 Tailwind CDN,也不需要安装 Node。
十、完整验证
准备一篇测试文章:
# Markdown 渲染测试
| 名称 | 值 |
|---|---|
| language | Go |
```go
fmt.Println("hello")
```
<script>alert("不应该执行")</script>
上传后检查:
- 标题是否正确。
- 表格是否渲染。
- Go 代码是否高亮。
- 页面源码里是否还有
<script>。 - 发布时间是否存在。
- 重复 slug 是否明确报错。
文章发布系统的难点从来不是“把 Markdown 变成 HTML”。真正的难点是建立一条可信的转换链,保证每篇文章都按同样规则进入数据库和浏览器。
评论
暂无评论,来说点什么吧。
请先登录后再发表评论
去登录