✨ 瞬间 15 📚 归档 11 🏷️ 标签 26
📂 分类
🏷️ 标签 (26)

Trilium博客图片图床方案教程

2026.10.14

研究背景 Background

博客文章写在 Trilium Notes 中,通过同步脚本发布到 Gitee 仓库,再由阿里云 ESA 构建静态站点。文章中的图片是 Trilium 附件,URL 格式为 /api/attachments/{attachmentId}/download。需要让博客读者通过公网 CDN 访问这些图片,但只暴露博客用到的图片,其他附件不允许外网访问。服务器用 1Panel 面板管理,OpenResty 已安装(host 网络模式),防火墙只开放一个端口对外,Trilium 运行在 Docker 中不直接暴露公网。本文记录从技术方案筛选到最终实现的全过程,包括反复踩坑的排查与解决。

Blog articles are written in Trilium Notes, published to a Gitee repository via a sync script, and then built as a static site by Alibaba Cloud ESA. Images in articles are Trilium attachments with URLs like /api/attachments/{attachmentId}/download. The goal is to allow readers to access these images via a public CDN, but only expose images used in the blog—other attachments must not be publicly accessible. The server is managed with 1Panel, OpenResty is installed in host network mode, the firewall exposes only one port, and Trilium runs in Docker without direct public exposure. This article documents the entire process from technology selection to final implementation, including repeated pitfalls and their resolutions.

参考资料 References

技术总结 Technical Summary

最终方案为 CDN 回源 Trilium + OpenResty 白名单过滤:ESA 边缘节点缓存图片(30 天),回源到服务器 OpenResty(8369 SSL),OpenResty 用 Lua 检查附件 ID 白名单后代理到 Trilium ETAPI 端点获取图片。白名单存储在 Trilium 笔记中,同步脚本用 api 对象写入,OpenResty 用 Lua cosocket 拉取。整个过程中踩了 9 个坑,主要集中在 Trilium 沙箱限制、API 端点选择、端口混淆和路径匹配上。

环节 关键点
方案选型 4 方案对比,选 CDN 回源 + 白名单过滤(无额外存储成本,只暴露白名单图片)
白名单存储 Trilium code 笔记,同步脚本用 api 对象写入,OpenResty cosocket 拉取
OpenResty 8369 SSL,Lua access_by_lua_block 检查白名单,proxy_pass 到 ETAPI
ESA 配置 A 记录 + 代理加速,回源 HTTPS:8369,缓存 30 天
核心难点 Trilium 沙箱禁 fetch 内部 IP / 禁 require(‘http’);API 端点 401;路径 /download vs /content
最终链路 用户 → ESA(443) → OpenResty(8369) → Trilium(18080) → 图片
CORP 跨域 图片跨域加载受 Cross-Origin-Resource-Policy: same-origin 拦截,须改写响应头为 cross-origin

细节 Details

一、技术方案选型

对比了 4 种方案:

方案 优点 缺点 采用
Trilium 直接公网暴露 简单 所有附件可访问,安全风险大 ❌
图片导出到 Git 仓库 CDN 直接加速 二进制污染仓库,仓库膨胀快 ❌
图片上传到 OSS 加速好 额外存储成本,脚本复杂 ❌
CDN 回源 + 白名单过滤 无额外存储,只暴露白名单图片 需 Lua 逻辑,白名单需维护 ✅

二、Trilium ETAPI 准备

Trilium ETAPI 用 token 鉴权,无需 session 登录。在 Trilium → 设置 → ETAPI → 创建 token。

关键端点:

用途 方法 路径
读取笔记内容 GET /etapi/notes/{noteId}/content
读取附件内容 GET /etapi/attachments/{attachmentId}/content

鉴权方式:HTTP 头 Authorization: <token>,直接放 token,不加 Bearer 前缀。

⚠️ /api/attachments/{id}/download 需要 session cookie 认证,ETAPI token 无效返回 401。必须用 /etapi/attachments/{id}/content。

三、白名单存储方案

白名单需要一个 OpenResty 和 Trilium 同步脚本都能访问的地方。在 Trilium 中创建 code 类型笔记,每行存一个附件 ID。同步脚本用 api 对象写入:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 扫描博客文章中的附件 ID
function extractAttachmentIds(html) {
const ids = new Set();
const regex = /api\/attachments\/([A-Za-z0-9]+)/g;
let match;
while ((match = regex.exec(html)) !== null) {
ids.add(match[1]);
}
return [...ids];
}

// 用 api 对象写入白名单笔记
function updateWhitelist(attachmentIds) {
const content = attachmentIds.join('\n');
api.updateNoteContent('WHITE_LIST_NOTE_ID', content);
}

四、OpenResty 配置

4.1 nginx.conf 全局配置

在 http{} 块内添加共享字典:

1
lua_shared_dict attachment_whitelist 10m;
4.2 网站配置(1Panel 网站管理)

在 1Panel → 网站 → 创建网站(域名 img.yourdomain.com,端口 8369 SSL),配置文件中写入:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
server {
listen 8369 ssl;
listen [::]:8369 ssl;
server_name img.yourdomain.com;

ssl_certificate /www/sites/img.yourdomain.com/ssl/fullchain.pem;
ssl_certificate_key /www/sites/img.yourdomain.com/ssl/privkey.pem;
# ... 其他 SSL 配置由 1Panel 自动填充 ...

access_log /www/sites/img.yourdomain.com/log/access.log main;
error_log /www/sites/img.yourdomain.com/log/error.log;

location ~ ^/api/attachments/([A-Za-z0-9]+)/content$ {
set $aid $1;

access_by_lua_block {
local dict = ngx.shared.attachment_whitelist
local attachment_id = ngx.var.aid

if not attachment_id then
ngx.status = 403
ngx.say("Forbidden")
return ngx.exit(403)
end

-- 1. 先查内存缓存
if dict:get(attachment_id) then
return
end

-- 2. 缓存未命中,刷新白名单(60 秒内不重复刷新)
local last_refresh = dict:get("__last_refresh") or "0"
local now = ngx.time()
if (now - tonumber(last_refresh)) > 60 then
dict:set("__last_refresh", tostring(now))

local sock = ngx.socket.tcp()
sock:settimeout(2000)
local ok = sock:connect("127.0.0.1", 18080)
if ok then
local req = "GET /etapi/notes/WHITE_LIST_NOTE_ID/content HTTP/1.0\r\n" ..
"Host: 127.0.0.1\r\n" ..
"Authorization: YOUR_ETAPI_TOKEN\r\n\r\n"
sock:send(req)
local data = sock:receive("*a")
sock:close()

if data then
local body = data:match("\r\n\r\n(.*)$")
if body then
for id in body:gmatch("(%w+)") do
if #id >= 10 then
dict:set(id, 1)
end
end
end
end
end
end

-- 3. 刷新后再查一次
if not dict:get(attachment_id) then
ngx.status = 403
ngx.say("Forbidden")
return ngx.exit(403)
end
}

proxy_pass http://127.0.0.1:18080/etapi/attachments/$aid/content;
proxy_set_header Authorization "YOUR_ETAPI_TOKEN";
proxy_set_header Host 127.0.0.1;
proxy_hide_header Authorization;
proxy_hide_header Content-Disposition;
}

location / {
return 403;
}
}

配置要点:

配置项 值 说明
location 路径 /api/attachments/{id}/content 匹配博客中的图片 URL
proxy_pass /etapi/attachments/{id}/content 转发到 Trilium ETAPI 端点
cosocket 连接 127.0.0.1:18080 Trilium Docker 端口映射地址
白名单刷新间隔 60 秒 避免每个请求都回源拉白名单
proxy_hide_header Authorization + Content-Disposition 隐藏鉴权头和下载行为头

⚠️ OpenResty 容器必须用 host 网络模式,才能访问 127.0.0.1:18080。1Panel 安装的 OpenResty 默认就是 host 模式。

五、ESA 配置

5.1 DNS 记录
字段 值
记录类型 A
主机记录 img
代理状态 代理加速
记录值/源站 服务器公网 IP
回源 HOST 跟随请求 HOST
TTL 自动

高级配置中的 HTTP/HTTPS 端口保持默认(80/443),这是 ESA 对客户端监听的端口,不是回源端口。

5.2 回源规则
字段 值
匹配条件 主机名 等于 img.yourdomain.com
回源协议和端口 HTTPS,端口 8369
回源 301/302 跟随 开启
5.3 缓存规则
字段 值
匹配条件 主机名 等于 img.yourdomain.com
缓存资格 符合缓存条件
浏览器缓存过期时间 忽略源站策略,自定义 7 天
边缘缓存过期时间 忽略源站策略,自定义 30 天
查询字符串 全部忽略
响应过期缓存 开启

图片附件 ID 不变则内容不变,缓存 30 天安全。替换图片时同步脚本会生成新附件 ID,URL 自动变化,不会命中旧缓存。

六、踩坑记录

坑 1:Trilium 后端脚本沙箱限制

现象:同步脚本用 fetch() 向 OpenResty 推送白名单,报错 fetch failed。改用 require('http'),报错 Module 'http' is not in the allowed modules list。

根因:Trilium 后端脚本运行在沙箱中,fetch() 不允许访问 HTTP 内部 IP(禁止 SSRF),require('http') 等网络模块被禁,只有 api 对象可用。

解决:同步脚本用 api.updateNoteContent() 写白名单到 Trilium 笔记,OpenResty 用 Lua cosocket(ngx.socket.tcp,不受沙箱限制)从 Trilium ETAPI 拉取白名单。

坑 2:401 Unauthorized — API 端点 vs ETAPI 端点

现象:proxy_pass 到 /api/attachments/{id}/download,返回 401。

根因:/api/attachments/{id}/download 需要 session cookie 认证,ETAPI token 对此端点无效。

解决:改用 ETAPI 端点 /etapi/attachments/{id}/content。

1
2
❌ proxy_pass http://127.0.0.1:18080/api/attachments/$aid/download;
✅ proxy_pass http://127.0.0.1:18080/etapi/attachments/$aid/content;
坑 3:端口混淆 — SSH 隧道端口 vs 服务器实际端口

现象:配置中用了端口 37840,curl 无输出。

根因:37840 是本地 SSH 隧道映射端口(ssh -L 37840:localhost:18080),只在本地有效。服务器上 Trilium 实际端口是 18080(Docker 端口映射 127.0.0.1:18080->8080)。

解决:OpenResty 配置中 cosocket 和 proxy_pass 全部改用 127.0.0.1:18080。用 docker ps 确认端口映射。

坑 4:1Panel 自动 HTTP→HTTPS 跳转导致 302

现象:HTTP 访问返回 302 重定向,ESA 回源异常。

根因:1Panel 创建网站时自动添加 if ($scheme = http) { return 301 https://$host:8369$request_uri; }。

解决:在 1Panel 网站配置中删除这行跳转规则。ESA 回源直接用 HTTPS,不需要 HTTP 跳转。

坑 5:location 路径不匹配 — /download vs /content

现象:OpenResty 直接返回 403,白名单数据正常,错误日志无记录。

根因:location 匹配 /api/attachments/{id}/download,但请求 URL 是 /api/attachments/{id}/content,路径不匹配,落到 location / { return 403; }。

排查过程:

  1. 直接 curl OpenResty → 403(排除 ESA 问题)
  2. 查错误日志 → 无 403 记录(说明是 location 兜底返回的)
  3. 查白名单笔记 → 数据正常(排除白名单为空)
  4. 对比 location 路径和请求 URL → 发现不匹配
    解决:
1
2
❌ location ~ ^/api/attachments/([A-Za-z0-9]+)/download$ {
✅ location ~ ^/api/attachments/([A-Za-z0-9]+)/content$ {
坑 6:Content-Disposition 头导致浏览器下载图片

现象:直接在浏览器访问图片 URL 弹出下载框。

根因:Trilium ETAPI 返回 Content-Disposition: attachment 头。

解决:OpenResty 配置加 proxy_hide_header Content-Disposition;。

坑 7:ESA 回源 403 排查思路

现象:通过 ESA 访问返回 403,响应头有 x-swift-error: orig response 4XX error。

关键诊断:x-swift-error: orig response 4XX error 说明 ESA 成功连接到源站,是源站返回了 4XX,问题在 OpenResty 而非 ESA。

排查顺序:

  1. 看响应头 server 字段 → ESA 说明经过 ESA
  2. 看 x-swift-error → orig response 4XX error 说明源站返回错误
  3. 直接 curl 源站绕过 ESA → 确认源站本身的问题
  4. 查源站日志和配置 → 定位具体原因
坑 8:Trilium 代码笔记写入换行符丢失

现象:通过 MCP create_note 写入白名单笔记,换行符被破坏,多个附件 ID 连成一行。

根因:create_note MCP 工具处理文本时破坏换行符。

解决:改用 ETAPI PUT + text/plain Content-Type 直接写入。

坑 9:Cross-Origin-Resource-Policy 导致图片跨域加载失败

现象:博客页面刷新后所有图片加载失败,浏览器控制台报 Cross-Origin-Resource-Policy 错误;此前误判为 ESA 智能限流返回 HTML 挑战页(ORB 拦截),但关闭限流后依然报错。

根因:Trilium 内置 helmet 中间件返回 Cross-Origin-Resource-Policy: same-origin 响应头。博客站点与图片 CDN(img.cimugarage.cn)是不同源(origin),浏览器按 CORP 规则拦截跨域 <img> 加载。此前误以为是限流(ORB),实际是 CORP——两者现象相似但根因完全不同。

排查过程:

  1. 关闭 ESA 智能限流(http_auto_ratelimit)后仍报错 → 排除限流
  2. 用 curl -I 直连源站 dump 响应头 → 发现 cross-origin-resource-policy: same-origin
  3. 定位为 Trilium helmet 中间件默认策略
    解决:OpenResty 中改写响应头,二选一:
1
2
3
4
5
# Lua 方式
header_filter_by_lua_block { ngx.header["cross-origin-resource-policy"] = "cross-origin" }
# 纯 nginx 方式
proxy_hide_header Cross-Origin-Resource-Policy;
add_header Cross-Origin-Resource-Policy "cross-origin" always;

改完 nginx -t + reload,并到 ESA 刷新缓存(30 天缓存会持有旧响应头)。

七、最终验证

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 1. 直连 OpenResty
curl -I -k -H "Host: img.yourdomain.com" https://127.0.0.1:8369/api/attachments/ATTACHMENT_ID/content
# 预期: HTTP/2 200, content-type: image/jpg

# 2. ESA 全链路
curl -I https://img.yourdomain.com/api/attachments/ATTACHMENT_ID/content
# 预期: HTTP/2 200, x-site-cache-status: MISS

# 3. 缓存命中
curl -I https://img.yourdomain.com/api/attachments/ATTACHMENT_ID/content 2>&1 | grep x-site-cache-status
# 预期: HIT

# 4. 白名单拦截
curl -I https://img.yourdomain.com/api/attachments/INVALID_ID/content
# 预期: HTTP/2 403

八、架构图

1
用户 → ESA 边缘节点(443, 缓存30天) → OpenResty(8369 SSL, 白名单) → Trilium(18080, ETAPI) → 图片

白名单更新流程:

1
同步脚本执行 → 扫描文章中的附件 ID → api.updateNoteContent() 写入白名单笔记 → OpenResty 下次请求时 cosocket 拉取(60秒刷新)

九、日常维护

  • 新增文章:在 Trilium 写文章插入图片,执行同步脚本,脚本自动更新白名单
  • 替换图片:新图片有新附件 ID,URL 自动变化,旧缓存自然过期
  • 检查白名单:curl -s -H "Authorization: TOKEN" http://127.0.0.1:18080/etapi/notes/NOTE_ID/content
  • 强制刷新白名单:重启 OpenResty(docker restart 容器名),共享字典清空后自动拉取

十、关键参数速查

参数 值 说明
OpenResty 监听端口 8369 (SSL) 对外唯一端口
Trilium 内部端口 18080 Docker 端口映射
白名单刷新间隔 60 秒 Lua 共享字典 TTL
ESA 边缘缓存 30 天 图片不变则长缓存
ESA 浏览器缓存 7 天 减少回源
附件 ID 最小长度 10 字符 过滤非 ID 字符串
cosocket 超时 2000ms Trilium 响应超时
回源协议 HTTPS ESA → OpenResty
回源端口 8369 自定义回源端口

十一、跨域资源策略原理

11.1 四种机制对比
机制 作用对象 拦截场景 与图片加载的关系
Referer 请求头 防盗链、来源校验 服务器主动拒绝,返回 403
CORS 响应头 JS 跨域读写(fetch/XHR) 仅影响 JS 读取,不拦截 `` 显示
CORP 响应头 跨域资源嵌入(//``) 正是本次图片无法显示的原因
ORB 浏览器内部 响应 MIME 类型不匹配 误信号来源(限流返回的 HTML 挑战页)
11.2 关键区分

<img> 标签显示图片不需要 CORS(除非用 canvas 读像素),但受 CORP 约束;而 CORP same-origin 只允许同源页面嵌入该资源。跨域嵌入需源站返回 cross-origin-resource-policy: cross-origin(或移除该头)。

2026.10.18

图床稳定运行后,同一台服务器上又接入了两个动态接口(文章访问量统计、评论提交),过程中对部署方式与缓存策略各做了一次调整,均为实测结论,记录如下。

更新 1:共享字典与 init 从全局配置「下沉」到站点配置

原先 lua_shared_dict、init_worker_by_lua* 写在 nginx.conf 的全局 http 块(见上文 4.1)。随着动态接口变多,全局配置里堆了多个站点共用的声明,维护上「顾不上同时维护」,改为下沉到各自站点配置:

  • 三个共享字典(attachment_whitelist、blog_views、comment_rate)改在站点配置顶层(server {} 之前)声明——站点的顶层就是 http 上下文,声明合法;

  • 全局 nginx.conf 里的同名行同批删除。
    两个注意点:

  • 同名重复声明会让 **nginx -t** 直接失败([emerg] lua_shared_dict "xxx" is already defined)⇒「新增到站点」与「从全局删除」必须在同一批完成,中间不存在两边都在的状态;

  • init_worker_by_lua* 在同一个 http 块内全局唯一,只能有一处声明。

更新 2(新坑 10):文件级 bind mount 必须「原地写」,sed -i 会改了不生效

改全局 nginx.conf 时踩到的新坑,值得单列。

1Panel 的 OpenResty 容器对 nginx.conf 是文件级 bind mount(绑的是 inode),而 conf.d/、sites/ 等是目录级挂载(按路径解析)。

sed -i 的实现是「写临时文件 → rename 覆盖」,会把目标路径指向新的 inode;而容器内的挂载仍然盯着旧 inode,于是出现「宿主机文件看起来改好了、容器里读到的还是旧内容、nginx -t 报的还是旧错误」的假象。

实测证据:容器内 /usr/local/openresty/nginx/conf/nginx.conf 的 inode 与宿主机 nginx.conf.bak-del 一致,而宿主机 nginx.conf 早已换成另一个 inode——**生效的那份其实是 ****.bak-del**。

正确做法(保持同一 inode):

1
sed '...规则...' /path/nginx.conf > /tmp/nginx.conf.new && cat /tmp/nginx.conf.new > /path/nginx.conf

cat > 是 truncate 写入,不换 inode。另外 vim 默认的 backupcopy=auto 同样可能触发「换 inode」,改文件级挂载的目标时要留意。

目录级挂载(站点配置所在的 conf.d/)不受影响,sed -i 照常用——这也是站点配置改得成功、全局配置改不动的原因。

更新 3:ESA 缓存策略——图片照旧,动态接口必须绕过

图床的图片路径(/api/attachments/{id}/content)继续沿用上文 5.3 的 30 天长缓存,安全(附件 ID 变了 URL 就变)。但新接的动态接口踩了缓存的坑,结论如下:

  • 动态接口路径必须把边缘缓存设为「绕过缓存」,否则会出现「接口永远返回同一份数据、刷新不涨」。实测修复后响应头变为 x-site-cache-status: BYPASS,age 与 x-swift-cachetime 一并消失;
  • CDN 不一定会遵循源站的缓存头。实测源站已返回 Cache-Control: no-store,ESA 仍把它改写成 no-cache 并继续缓存 30 天 ⇒ 不能假设「源站声明不缓存就万事大吉」;
  • 缓存键默认忽略查询字符串。带随机参数再请求,仍返回同一份内容 ⇒ 不同参数会共用同一个缓存对象(多篇访问量互相串号)。凡需按参数区分的接口,必须绕过;
  • POST 缓存必须关闭(评论提交是 POST)。开启后提交响应可能被缓存并复用给他人;
  • 每次改完缓存策略,务必到 ESA 刷新缓存,否则旧对象继续被 serve,表现为「改了没生效」——这是最容易误判为故障的一步。

附:当前完整链路

1
2
3
用户 → ESA(443)
├─ 图片路径 → 边缘缓存 30 天 → 回源 OpenResty(8369) → 白名单校验 → Trilium(18080) ETAPI
└─ 动态接口路径 → 绕过缓存 → 回源 OpenResty(8369) → Lua 处理 → Trilium(18080) ETAPI

留言

0/500

留言经审核后才会公开显示:审核通过后刷新本页即可看到。