# 介绍 (/zh/docs)
ImgYoung 是一个多渠道主备容灾的自部署图床,图片同时存进多个存储渠道,任何一个出问题都不影响上传和访问。
## ImgYoung 是什么 [#imgyoung-是什么]
ImgYoung 是一个自部署图床:一个 Go 二进制(或一个 Docker 镜像)就能运行,自带 Web 管理后台,MIT 许可。
存储层基于 [rclone](https://rclone.org/),图片可以存进 rclone 支持的任何后端:S3 / R2 / MinIO 等对象存储、WebDAV、各类网盘,以及本机磁盘。
每一份这样的存储配置在 ImgYoung 里叫一个**渠道**。
## 解决什么问题 [#解决什么问题]
自建图床最怕的不是功能少,而是图没了:免费图床停运、对象存储账号被封、网盘限流、服务器硬盘损坏——博客和笔记里的图片链接成批失效。
ImgYoung 的设计目标是让图片链接长期可用:
- **一张图存多份**。上传写进主渠道即返回,其余渠道在后台异步补齐副本;进程重启不丢补齐任务。
- **访问时自动换渠道**。默认由 ImgYoung 代理输出图片,某个渠道取不到就立即改从下一个有副本的渠道读,链接不变。
- **账本也有备份**。记录「哪张图在哪个渠道」的元数据库定时快照,写到每个健康渠道,本机损坏不会连带丢掉这份映射。
## 核心能力 [#核心能力]
| 能力 | 说明 |
|---|---|
| 多渠道主备容灾 | 健康渠道中优先级最高的是主渠道;周期健康探测发现故障即自动切换,副本异步补齐,水位在后台可见。见 [存储渠道](./channels/index.mdx) |
| 渠道存储加密 | 基于 rclone crypt,内容与文件名都加密,渠道服务商只看到密文;持有主密钥即可用标准 rclone 工具自行解密。见 [渠道存储加密](./channels/encryption.mdx) |
| 元数据库自动备份 | 出厂每 24 小时快照一次,写到每个健康渠道,各渠道各自保留最近 7 份;后台可立即备份、下载与恢复。见 [备份与恢复](./operations/backup.mdx) |
| TTL 生命周期与冷图裁决 | 有效期按「单次上传 > 用户默认 > 全局默认」三层决定,出厂开启访问即续期;超过 90 天(出厂值)没有公开访问的图进入「待清理」列表,由管理员决定删除或保留,不会自动删。见 [生命周期](./guides/lifecycle.mdx) |
| 后台查重 | 字节完全相同的图自动合并为一份物理文件;感知哈希相近的进入「疑似重复」列表人工裁决。查重在后台进行,不拖慢上传。见 [管理后台](./guides/admin.mdx) |
| 按需处理 | 链接加 `?w=400` 按预设宽度档缩放,浏览器支持时自动输出 WebP;处理结果缓存在本地磁盘。见 [访问与链接](./guides/access.mdx) |
| 多用户与配额 | 管理员建号,每个用户有自己的图片、上传 Token 与存储配额,普通用户之间互相不可见。见 [管理后台](./guides/admin.mdx) |
| 多种上传入口 | 后台拖拽或粘贴、HTTP 上传接口、URL 转存、S3 协议(PicList 内置的 AWS S3 图床直接可用,PicGo 装上 S3 插件后同样可用)、PicGo / PicList 自定义图床。见 [上传](./guides/upload.mdx) 与 [客户端接入](./guides/clients.mdx) |
此外还有防盗链(Referer 白名单)、代理 / 302 两种访问模式、Cloudflare CDN 缓存清理、审计日志、告警 Webhook 与 Prometheus 指标。
## 适合与不适合 [#适合与不适合]
适合:
- 个人或小团队自用:博客、笔记、文档站的图片。
- 手里有多个存储(对象存储的免费额度、网盘、VPS 磁盘),想让它们互为备份。
- 不完全信任存储服务商,希望渠道里只有密文。
不适合:
- **需要水平扩展或高可用集群**。ImgYoung 是单实例设计(SQLite + 进程内任务队列),不支持多个进程共用同一份数据。
- **对外开放的公共图床**。没有公开注册、付费与计费,账号只能由管理员创建。
- **私密图片**。图片链接是公开的,知道链接即可访问;没有签名 URL 或访问鉴权,防盗链只是轻量防护。见 [没有私有图片](./guides/access.mdx#no-private-images)。
- **相册、标签等图片组织**。后台只提供列表、搜索与按用户 / Token / 时间筛选。
- **常见四种以外的格式**。只接受 JPEG、PNG、GIF、WebP(按文件内容识别,不看扩展名),不接受 SVG、HEIC、AVIF 等。
## 文档怎么读 [#文档怎么读]
第一次接触,从快速开始用 Docker 跑起来;要长期运行,再看安装方式与存储渠道。部署到公网前,读一遍 [反向代理](./operations/reverse-proxy.mdx) 与 [备份与恢复](./operations/backup.mdx)。
十分钟内从零到贴出第一张图的 Markdown 链接。
一行脚本、Docker、Docker Compose、发布包与源码构建五种方式的对比。
添加对象存储与网盘渠道,配置优先级、加密与直链。
在自己的 Go 项目里复用多渠道存储引擎与容灾原语。
全部 HTTP 接口,由 OpenAPI 文档生成。
# 快速开始 (/zh/docs/quick-start)
用 Docker 跑起 ImgYoung,登录后台,上传第一张图并拿到 Markdown 链接。
需要一台装好 Docker 的机器(Linux、macOS、Windows 均可),并能用浏览器访问它的 8721 端口。不用 Docker 的安装方式见 [安装](./install/index.mdx)。
### 启动服务 [#启动服务]
```bash title="终端"
docker run -d --name imgyoung --restart unless-stopped \
-p 8721:8721 \
-v imgyoung-data:/data \
ghcr.io/x-haose/imgyoung:latest
```
- `-v imgyoung-data:/data`:数据库、内置 local 渠道里的图片与主密钥 `master.key` 都在这个卷里,删掉卷就是删掉全部数据。
- `--restart unless-stopped`:后台从快照恢复元数据库时,服务会主动退出、由 Docker 重新拉起后换入快照;没有重启策略,恢复后容器会停在已退出状态。
确认启动成功:
```bash title="终端"
docker logs imgyoung
```
看到 `msg="ImgYoung 已启动"` 一行即可(日志文案为中文)。首次启动还会有两条 `level=WARN`:`已生成主密钥文件,请立即备份——丢失将导致渠道配置与加密渠道数据永久不可恢复` 与 `已初始化默认管理员密码,请登录后立即在后台修改`,都是预期行为。
### 登录并修改密码 [#登录并修改密码]
浏览器打开 `http://<服务器地址>:8721/`(本机就是 `http://localhost:8721/`),用出厂账号登录:
| 用户名 | 密码 |
|---|---|
| `admin` | `imgyoung123` |
出厂密码是公开的。首次登录后会直接进入「设置管理员密码」页;改掉之前,后台其他页面都打不开,服务端也会拒绝除退出、查看本人信息与改密之外的全部后台接口(返回 403)。新密码至少 8 位,点「保存并重新登录」后用新密码再登录一次。
### 上传第一张图 [#上传第一张图]
两种方式任选其一:在后台直接上传,或者签发一个上传 Token 用 curl 上传。
1. 侧栏进入「图片」,点「上传图片」。
2. 把图片拖进对话框,或按 Ctrl / ⌘ + V 粘贴剪贴板里的截图。支持 JPEG、PNG、GIF、WebP,单图出厂上限 50 MB。
3. 上传完成后关闭对话框,点图片卡片打开「图片详情」,在「分享链接」的「复制为」里点 **Markdown**。
1. 侧栏进入「上传 Token」,点「签发 Token」,用途名称填 `curl`,有效期保持「永久」,点「签发」。
2. 复制弹窗里以 `imgy_` 开头的 Token 明文。明文只展示这一次,勾选「我已妥善保存这段明文」后才能关闭弹窗。
3. 上传:
```bash title="终端"
curl -H "Authorization: Bearer imgy_你的Token" \
-F "file=@screenshot.png" \
http://localhost:8721/api/upload
```
```json title="响应"
{"id":"R0RjUyP1UW","url":"http://localhost:8721/i/R0RjUyP1UW"}
```
`url` 就是图片地址。装了 jq 可以直接得到 Markdown:
```bash title="终端"
curl -s -H "Authorization: Bearer imgy_你的Token" -F "file=@screenshot.png" \
http://localhost:8721/api/upload | jq -r '")"'
```
拿到的 Markdown 形如:
```md

```
贴进博客或笔记即可显示。
没有配置对外站点地址(`base_url`)时,链接按本次请求的地址生成:用 `localhost` 访问后台,复制出的就是 `localhost` 链接,别的机器打不开。
要发给别人看,请用对外的域名或 IP 访问后台,或在「设置 → 运行配置」里填写 `base_url`。详见 [配置](./configuration.mdx)。
## 下一步 [#下一步]
全新实例只有一个内置的 `local` 渠道,图片存在数据卷里的 `blobs/` 目录;元数据库快照写到每个健康渠道,此刻也只有这一处。这块盘坏了,图片和快照一起丢失,后台「总览」也会提示「数据只存在这台主机上」。
尽快到侧栏「存储渠道」点「新建渠道」,加一个异地的对象存储或网盘渠道:已有图片会自动补齐副本过去,快照在下一轮定时备份或下次启动时写一份过去,想马上有一份就到「设置 → 备份与导出」点「立即备份」。新渠道健康后,总览上的提示随之消失。
- [存储渠道](./channels/index.mdx):添加异地渠道,设置优先级与加密。
- [备份与恢复](./operations/backup.mdx):单独备份主密钥 `master.key`,它不在快照里;丢失后服务拒绝启动,渠道凭证与加密数据也无法恢复。
- [反向代理](./operations/reverse-proxy.mdx):用域名与 HTTPS 对外提供服务。
- [客户端接入](./guides/clients.mdx):配置 PicGo、PicList、Typora 等客户端。
- [Docker 镜像](./install/docker.mdx):镜像标签、数据卷与环境变量的完整说明。
# 安装方式 (/zh/docs/install)
五种安装方式的对比:一行脚本、Docker 镜像、Docker Compose、发布包与源码构建。
ImgYoung 是一个纯 Go 的单二进制,管理后台的前端已经编进去,运行时不需要数据库服务、缓存或其他进程。五种安装方式装出来的是同一个程序,区别只在怎么拿到它、由谁托管它。
## 对比 [#对比]
| 方式 | 适合谁 | 需要 root | 如何升级 | 数据在哪 |
|---|---|---|---|---|
| [一行安装脚本](./script.mdx) | Linux / macOS 主机直接运行;Linux 上想交给 systemd 托管 | 装到 `/usr/local/bin` 或加 `--service` 时需要;装到 `~/.local/bin` 不需要 | 重新执行同一条命令 | 加 `--service`:`/var/lib/imgyoung`;否则为启动时当前目录下的 `data/` |
| [Docker 镜像](./docker.mdx) | 已经在用 Docker 的主机 | 取决于主机上 Docker 的权限设置 | 拉取新镜像后重建容器 | 挂到容器 `/data` 的卷 |
| [Docker Compose](./compose.mdx) | 想把部署写成文件,或与反向代理一起编排 | 同上 | `docker compose pull` 后 `docker compose up -d` | 卷 `imgyoung-data`(Compose 会加项目名前缀)|
| [发布包](./binary.mdx) | Windows;不便执行安装脚本;想手动掌控每一步 | 不需要 | 下载新包,替换二进制后重启 | 启动时当前目录下的 `data/`,可用 `IMGYOUNG_DATA_DIR` 指定 |
| [源码构建](./source.mdx) | 要改代码或自己构建的开发者 | 不需要 | 拉取新代码后重新构建 | 同发布包 |
不确定选哪个:有 Docker 就用 [Docker Compose](./compose.mdx);没有 Docker 的 Linux 主机用 [一行安装脚本](./script.mdx) 加 `--service`。
## 各方式共同的部分 [#各方式共同的部分]
- **监听**:默认 `:8721`(所有网卡的 8721 端口),可用环境变量 `IMGYOUNG_LISTEN` 改。
- **首次登录**:浏览器打开 `http://<主机>:8721/`,用 `admin` / `imgyoung123` 登录,后台会要求立即改密。
- **数据目录**:元数据库 `imgyoung.db`、主密钥 `master.key`、内置 local 渠道的 `blobs/` 与衍生图缓存 `variants/` 都在里面。备份它,尤其是单独备份 `master.key`。
- **配置**:启动所需的少量引导配置走 `IMGYOUNG_*` 环境变量,其余运营配置都在后台改、保存即生效,没有配置文件。见 [配置](../configuration.mdx)。
- **进程管理器**:后台「设置 → 备份与导出」里的「恢复」会让服务主动正常退出,由进程管理器重新拉起时换入快照。长期运行务必交给 systemd 或 Docker 这类会在进程退出后自动重启的托管方式(Docker 用 `--restart unless-stopped`,systemd 用 `Restart=always`),仓库 `deploy/` 下的示例已经这样配置。
`go install` 只拿仓库源码现场编译,而管理后台的前端是构建产物、不在仓库里,这样装出来的二进制只有接口、没有后台页面。本机安装请用一行脚本或发布包,无需 Go 工具链。
Go 开发者要复用的通常是存储引擎,它是可以 `go get` 的公开包,见 [Go 库](../library/index.mdx)。
# 一行安装脚本 (/zh/docs/install/script)
在 Linux 与 macOS 上下载发布版、校验 sha256 并装入 PATH;Linux 上可同时注册 systemd 服务。重复执行即升级。
## 用法 [#用法]
```bash title="终端"
curl -fsSL https://raw.githubusercontent.com/x-haose/imgyoung/main/install.sh | sh
```
选项经 `sh -s --` 传给脚本:
```bash title="终端"
# 装成 systemd 服务并启动(仅 Linux,需要 root)
curl -fsSL https://raw.githubusercontent.com/x-haose/imgyoung/main/install.sh | sudo sh -s -- --service
# 安装指定版本
curl -fsSL https://raw.githubusercontent.com/x-haose/imgyoung/main/install.sh | sh -s -- --version 1.0.0
```
想先读一遍脚本再执行:
```bash title="终端"
curl -fsSLo install.sh https://raw.githubusercontent.com/x-haose/imgyoung/main/install.sh
less install.sh
sudo sh install.sh --service
```
脚本需要 `curl`、`tar`、`install`,以及 `sha256sum` 或 `shasum` 之一;`--service` 还需要 `systemctl`。
## 选项与环境变量 [#选项与环境变量]
| 选项 | 默认 | 说明 |
|---|---|---|
| `--version X.Y.Z` | 最新正式版 | 安装指定版本,前缀 `v` 可带可不带 |
| `--dir DIR` | root 为 `/usr/local/bin`,其他用户为 `~/.local/bin` | 二进制安装目录,不存在时创建,必须可写;与 `--service` 同用时须为绝对路径,且不在 `/home`、`/root`、`/run/user` 下 |
| `--service` | 不安装 | 仅 Linux,需要 root:同时安装并启动 systemd 服务 `imgyoung`,数据在 `/var/lib/imgyoung` |
| 环境变量 | 默认 | 说明 |
|---|---|---|
| `IMGYOUNG_RELEASES` | `https://github.com/x-haose/imgyoung/releases` | 发布页地址。用镜像站或内网分发时设置,目录结构须与 GitHub Releases 一致,见下文「镜像站与内网分发」 |
传入未知选项或格式不对的版本号,脚本当场报错退出,不做任何改动。
## 脚本做了什么 [#脚本做了什么]
1. **检查前提**。确认 `curl`、`tar`、`install` 存在;带 `--service` 时再确认系统是 Linux、以 root 运行、有 `systemctl`,且指定的 `--dir` 是家目录之外的绝对路径。不满足就在下载之前退出,不动系统。
2. **识别平台**。`uname -s` 为 Linux 或 Darwin,`uname -m` 为 x86_64 / amd64 或 aarch64 / arm64;其他系统或架构直接报错(Windows 请用 [发布包](./binary.mdx))。
3. **确定版本**。没给 `--version` 时请求 `<发布页>/latest`,跟随跳转,取最终地址的最后一段作为版本标签;GitHub 的 latest 不含预发布版。
4. **下载并校验**。在临时目录下载 `imgyoung_<版本号>_<系统>_<架构>.tar.gz` 与同版本的 `checksums.txt`,核对 sha256;不一致即中止,不安装任何东西。临时目录在脚本退出时删除。
5. **安装二进制**。以 0755 权限装到目标目录的 `imgyoung`,并打印它的版本信息。
6. **安装服务**(仅 `--service`)。把发布包里的 `deploy/imgyoung.service` 写到 `/etc/systemd/system/imgyoung.service`,其中 `ExecStart` 换成实际安装路径,与原有文件内容不同时给出提示;执行 `systemctl daemon-reload` 并 enable。服务已在运行就重启以换上新版本,否则启动。
7. **提示下一步**。目标目录不在 `PATH` 中时提醒;打印登录地址与出厂账号。
没加 `--service` 时,脚本只安装二进制,不会启动它。直接运行 `imgyoung` 即可,数据写在当前目录的 `data/`(可用 `IMGYOUNG_DATA_DIR` 指定)。长期运行请交给进程管理器,原因见 [安装方式](./index.mdx)。
## systemd 服务 [#systemd-服务]
`--service` 安装的就是仓库里的这份示例,只改了 `ExecStart`:
```ini title="deploy/imgyoung.service"
# ImgYoung 的 systemd 服务示例;install.sh --service 安装的就是它。
# 手动安装:二进制放到 /usr/local/bin/imgyoung,本文件复制到 /etc/systemd/system/imgyoung.service,
# 然后 systemctl daemon-reload && systemctl enable --now imgyoung。
# 数据在 /var/lib/imgyoung(数据库、本地存储渠道、master.key),备份它。
[Unit]
Description=ImgYoung 自部署图床
Documentation=https://github.com/x-haose/imgyoung
After=network-online.target
Wants=network-online.target
[Service]
ExecStart=/usr/local/bin/imgyoung
Environment=IMGYOUNG_DATA_DIR=%S/imgyoung
# 渠道参数直接交给 rclone,不用 rclone 的配置文件;置空让 rclone 启动时不去寻找家目录下的配置
Environment=RCLONE_CONFIG=
# 只经本机反向代理对外时,改成只监听回环:
# Environment=IMGYOUNG_LISTEN=127.0.0.1:8721
# 以临时系统用户运行,数据目录由 systemd 创建并归属该用户
DynamicUser=yes
StateDirectory=imgyoung
# 后台「从快照恢复」会让服务优雅退出,靠这里重新拉起后换入快照;正常退出也要拉起
Restart=always
RestartSec=3s
# 优雅停机先停收新请求、等在途请求至多 6 秒,再停后台任务至多 2 秒;多出的是关库与退出的余量
TimeoutStopSec=15s
# 数据目录里有 master.key,新建文件只给运行用户自己读写
UMask=0077
# 进程只能写自己的数据目录;本地存储渠道指向别处时,用 ReadWritePaths= 放开那个目录
NoNewPrivileges=yes
CapabilityBoundingSet=
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
PrivateDevices=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
ProtectKernelLogs=yes
ProtectClock=yes
ProtectHostname=yes
RestrictNamespaces=yes
RestrictRealtime=yes
RestrictSUIDSGID=yes
LockPersonality=yes
SystemCallArchitectures=native
[Install]
WantedBy=multi-user.target
```
几个关键设置:
- **数据目录**。`IMGYOUNG_DATA_DIR=%S/imgyoung` 即 `/var/lib/imgyoung`,由 `StateDirectory=imgyoung` 让 systemd 创建并交给运行用户。启用 `DynamicUser` 时实际目录是 `/var/lib/private/imgyoung`,`/var/lib/imgyoung` 是指向它的符号链接。里面是 `imgyoung.db`、`master.key`、local 渠道的 `blobs/` 与衍生图缓存 `variants/`。
- **DynamicUser=yes**。每次启动由 systemd 分配一个临时系统用户来运行,不需要预先建用户。配合 `ProtectSystem=strict`、`ProtectHome=yes` 等沙箱设置,进程只能写自己的数据目录:local 渠道的路径改到别处时,要用 `ReadWritePaths=` 放开那个目录;二进制也不能装在 `/home` 或 `/root` 下,否则服务启动不了,脚本遇到这种 `--dir` 会直接拒绝。
- **Restart=always**。后台「恢复」快照会让服务正常退出,systemd 3 秒后重新拉起,启动时在开库前换入快照。`Restart=on-failure` 不会拉起正常退出的进程,恢复要等到有人手动启动服务才完成。
- **TimeoutStopSec=15s**。停止时先停收新请求,在途请求最多等 6 秒(超时强制断开);再取消后台循环与在途补齐任务,最多等 2 秒;然后关库退出。合计约 8 秒,15 秒留出余量。被取消的补齐任务在下次启动时由对账重新入队。
- **UMask=0077**。新建的文件(包括 `master.key`)只有运行用户可读写。
- **RCLONE_CONFIG=**(置空)。渠道参数由 ImgYoung 直接交给 rclone,不读 rclone 的配置文件。
常用命令:
```bash title="终端"
systemctl status imgyoung # 运行状态
journalctl -u imgyoung -f # 跟踪日志
sudo systemctl restart imgyoung # 改了环境变量后重启生效
```
### 修改服务配置 [#修改服务配置]
带 `--service` 重跑脚本时,`/etc/systemd/system/imgyoung.service` 会被发布包里的版本整个覆盖,内容有变化时脚本会提示。本地的改动不要写进这个文件,写进 drop-in:
```bash title="终端"
sudo systemctl edit imgyoung
```
在打开的编辑器里写入要覆盖的设置,例如只经本机反向代理对外时改成只监听回环:
```ini title="/etc/systemd/system/imgyoung.service.d/override.conf"
[Service]
Environment=IMGYOUNG_LISTEN=127.0.0.1:8721
```
保存后执行 `sudo systemctl restart imgyoung`。可用的环境变量见 [配置](../configuration.mdx)。
## 升级 [#升级]
重新执行安装时的同一条命令:
- 加过 `--service`:替换二进制、重写服务文件,并重启服务换上新版本。升级时必须同样带上 `--service`,否则只换了磁盘上的文件,运行中的仍是旧版本。
- 没加 `--service`:只替换二进制,自己重启进程。
要停在某个版本,用 `--version`。数据库结构在新版本启动时自动迁移。
`--version` 也能装回旧版本,但不能借此降级:被新版本迁移过的数据库,旧版本会拒绝启动,并在错误里写明库里与二进制各自的最新迁移版本。回滚只能用旧版本加升级前的快照,步骤与升级前的准备见 [升级](../operations/upgrade.mdx)。
## 卸载 [#卸载]
装了服务时:
```bash title="终端"
sudo systemctl disable --now imgyoung
sudo rm /etc/systemd/system/imgyoung.service
sudo rm -rf /etc/systemd/system/imgyoung.service.d # 用过 systemctl edit 才有
sudo systemctl daemon-reload
sudo rm /usr/local/bin/imgyoung
```
数据目录不会随之删除。确认已经不需要其中的图片、数据库与 `master.key` 之后再删:
```bash title="终端"
sudo rm -rf /var/lib/private/imgyoung /var/lib/imgyoung
```
只装了二进制时,删除二进制(默认 `~/.local/bin/imgyoung` 或 `/usr/local/bin/imgyoung`),再按需删除运行时用过的数据目录。
## 镜像站与内网分发 [#镜像站与内网分发]
访问 GitHub 不便时,把发布物放到镜像站或内网服务器,用 `IMGYOUNG_RELEASES` 指过去。环境变量要给执行脚本的 `sh`,不是给 `curl`:
```bash title="终端"
curl -fsSL https://mirror.example.com/imgyoung/install.sh \
| sudo IMGYOUNG_RELEASES=https://mirror.example.com/imgyoung/releases sh -s -- --service
```
`install.sh` 本身也要能从那里取到。镜像站的目录须与 GitHub Releases 一致:
```text
/latest 跳转到 .../tag/vX.Y.Z
/download/vX.Y.Z/imgyoung_X.Y.Z_linux_amd64.tar.gz
/download/vX.Y.Z/checksums.txt
```
`/latest` 只在不指定 `--version` 时用到:脚本跟随跳转,把最终地址的最后一段当作版本号,它必须以 `v` 加数字开头。镜像站做不到这种跳转时,安装与升级都显式带上 `--version`。
# Docker 镜像 (/zh/docs/install/docker)
用 GHCR 上的多架构镜像运行 ImgYoung:启动命令、镜像标签、数据卷、环境变量与反向代理下的设置。
## 启动 [#启动]
```bash title="终端"
docker run -d --name imgyoung --restart unless-stopped \
-p 8721:8721 \
-v imgyoung-data:/data \
ghcr.io/x-haose/imgyoung:latest
```
浏览器打开 `http://<主机>:8721/`,用 `admin` / `imgyoung123` 登录,首次登录会要求立即改密。
| 参数 | 作用 |
|---|---|
| `-d --name imgyoung` | 后台运行,容器名 `imgyoung`,后续命令都用这个名字 |
| `--restart unless-stopped` | 进程退出后自动拉起,原因见下文「重启策略」 |
| `-p 8721:8721` | 发布服务端口。只经本机反向代理对外时改成 `-p 127.0.0.1:8721:8721`,不把 8721 直接暴露出去 |
| `-v imgyoung-data:/data` | 具名卷,保存全部状态 |
镜像提供 `linux/amd64` 与 `linux/arm64` 两种架构,Docker 按主机自动选择。查看镜像里的版本:
```bash title="终端"
docker run --rm ghcr.io/x-haose/imgyoung:latest -version
```
## 镜像标签 [#镜像标签]
镜像在 `ghcr.io/x-haose/imgyoung`,标签不带 `v` 前缀:
| 标签 | 指向 | 适合 |
|---|---|---|
| `1.2.3` | 一个具体版本,发布后不再变化 | 生产环境,何时升级完全由你决定 |
| `1.2` | 1.2 系列的最新正式版,发布补丁版本时随之更新 | 想自动拿到修复、又不想跨次版本 |
| `latest` | 最新正式版 | 试用与个人实例 |
预发布版本只打具体版本标签,不会移动 `1.2` 这类主次版本标签和 `latest`,钉这两类标签的实例不会拉到预发布版。
## 数据卷 [#数据卷]
容器内的数据目录是 `/data`(镜像已设 `IMGYOUNG_DATA_DIR=/data`),里面有:
| 路径 | 内容 |
|---|---|
| `imgyoung.db` | 元数据库(SQLite,WAL 模式,运行时旁边还有 `imgyoung.db-wal` 与 `imgyoung.db-shm`):用户、图片记录、渠道配置(凭证经主密钥加密)、运营配置与任务队列 |
| `master.key` | 主密钥。全新实例首次启动、又没有设置 `IMGYOUNG_MASTER_KEY` 时生成,权限 0600。设置了 `IMGYOUNG_MASTER_KEY` 时既不生成也不读取这个文件 |
| `blobs/` | 内置 local 渠道的对象。local 渠道健康时,元数据库快照也会写一份到其中的 `_imgyoung/backup/` |
| `variants/` | 按需处理产生的衍生图缓存,出厂上限 10240 MB,超出按最近访问淘汰 |
从快照恢复时,数据目录里还会出现暂存文件 `imgyoung.db.restore`,以及换入后留存的原库 `imgyoung.db.pre-restore-<时刻>`。
渠道凭证和加密渠道里的数据都靠主密钥解开。它不包含在元数据库快照与数据导出里,丢失后这些数据无法恢复。数据库里记着主密钥的指纹,主密钥缺失或换错时服务拒绝启动并说明原因,不会换一把新密钥继续运行。启动后立即复制一份到别处保存:
```bash
docker cp imgyoung:/data/master.key ./imgyoung-master.key
```
### 改用宿主机目录 [#改用宿主机目录]
想把数据放在宿主机的某个目录,先把目录属主改成镜像里的运行用户(uid 65532):
```bash title="终端"
sudo mkdir -p /srv/imgyoung
sudo chown 65532:65532 /srv/imgyoung
docker run -d --name imgyoung --restart unless-stopped \
-p 8721:8721 \
-v /srv/imgyoung:/data \
ghcr.io/x-haose/imgyoung:latest
```
具名卷第一次挂载时会沿用镜像里 `/data` 的属主,宿主机目录不会;属主不对时进程无法在里面创建数据库,启动即失败。
## 环境变量 [#环境变量]
都是引导配置,用 `-e` 传入,修改后要重建容器才生效。完整说明见 [配置](../configuration.mdx)。
没有设置的变量取默认值;设为空串(或只有空白)是显式置空,不会回落到默认值,含义见「设为空串时」一列:
| 变量 | 默认 | 设为空串时 | 说明 |
|---|---|---|---|
| `IMGYOUNG_DATA_DIR` | `/data`(镜像已设)| 启动失败,错误里指出变量名 | 保持不变 |
| `IMGYOUNG_LISTEN` | `:8721` | 启动失败,错误里指出变量名 | 容器内的监听地址,一般不用改 |
| `IMGYOUNG_MASTER_KEY` | 使用 `/data/master.key` | 与不设置相同 | 64 位十六进制主密钥,可用 `openssl rand -hex 32` 生成。已有数据的实例改用它时,必须填现有 `master.key` 里的同一个值,填错会拒绝启动 |
| `IMGYOUNG_TRUSTED_PROXIES` | `127.0.0.0/8,::1/128` | 一概不信转发头 | 信任其转发头的反向代理网段,见下文 |
| `IMGYOUNG_METRICS_ALLOW` | 回环与私网网段 | `/metrics` 对谁都不开放 | 允许抓取 `/metrics` 的网段 |
`compose.yaml` 里写 `IMGYOUNG_TRUSTED_PROXIES: ${TRUSTED_PROXIES}` 这类引用时,宿主机没设这个变量,Compose 只打印一条警告就代入空串,效果等于上表的「设为空串」:`IMGYOUNG_LISTEN`、`IMGYOUNG_DATA_DIR` 让启动失败,`IMGYOUNG_TRUSTED_PROXIES` 变成一概不信转发头。`IMGYOUNG_MASTER_KEY` 变成空串时改读 `/data/master.key`;只靠这个变量提供主密钥、数据目录里没有 `master.key` 的实例会拒绝启动并说明找不到主密钥,不会生成新密钥。
变量必须由宿主机提供时写成 `${TRUSTED_PROXIES:?需要设置 TRUSTED_PROXIES}`,缺失或为空即拒绝启动;想用默认值,就不要写这一项。
## 以 nonroot 运行 [#以-nonroot-运行]
镜像基于 `gcr.io/distroless/static-debian12:nonroot`,以 nonroot 用户(uid 65532)运行,里面没有 shell 和包管理器。所以 `docker exec -it imgyoung sh` 用不了;需要直接操作数据(比如 [忘记密码](../operations/password-reset.mdx) 时改数据库)的话,停掉容器,把同一个卷挂到另一个带工具的容器里操作。
## 前面有反向代理 [#前面有反向代理]
ImgYoung 只在连接对端属于 `IMGYOUNG_TRUSTED_PROXIES` 网段时,才采信 `X-Forwarded-For`、`X-Forwarded-Proto` 与 `X-Forwarded-Host`。默认只信回环地址,而在容器里看到的对端几乎不会是回环:
- 反向代理在另一个容器里(同一 Docker 网络):对端是代理容器的地址。
- 反向代理装在宿主机上、经 `-p` 发布的端口转发进来:对端一般是 Docker 网桥的网关地址(如 `172.17.0.1`),不是 `127.0.0.1`。
这两种情况都要把代理所在网段设为可信:
```bash title="终端"
docker run -d --name imgyoung --restart unless-stopped \
-p 127.0.0.1:8721:8721 \
-v imgyoung-data:/data \
-e IMGYOUNG_TRUSTED_PROXIES=172.16.0.0/12 \
ghcr.io/x-haose/imgyoung:latest
```
`172.16.0.0/12` 覆盖 Docker 默认分配的大部分网段;实际网段用 `docker network inspect <网络名>` 查看。不设的话:
- 登录限速按代理的地址计算,所有访客共用同一份额度(突发 5 次,之后每 12 秒恢复 1 次):有人连续输错密码,其他人也会暂时无法登录。
- 没有配置 `base_url` 时,上传返回的链接按 `http://` 生成,即使对外是 HTTPS;会话 cookie 也不带 `Secure`。
- `/metrics` 的网段判定看到的也是代理地址(私网),经代理访问的任何人都能抓到指标,除非代理侧屏蔽了 `/metrics`(仓库 `deploy/` 里的 Caddy 与 Nginx 示例已经屏蔽)。
代理本身的配置见 [反向代理](../operations/reverse-proxy.mdx);与 Caddy 一起编排见 [Docker Compose](./compose.mdx)。
## 重启策略 [#重启策略]
后台「设置 → 备份与导出」里的「恢复」会先把快照暂存到数据目录,再让服务正常退出(退出码 0),等进程重新启动时在开库前换入快照。因此:
- 重启策略要用 `unless-stopped` 或 `always`。`on-failure` 不会拉起正常退出的容器,默认的 `no` 更不会。
- 没有合适的重启策略时,恢复后容器停在已退出状态,手动 `docker start imgyoung` 时才换入快照。
重启策略同时保证主机重启、Docker 服务重启之后 ImgYoung 自动恢复运行。
## 升级 [#升级]
拉取新镜像,用同样的参数重建容器;数据都在卷里,不受影响,数据库结构在新版本启动时自动迁移:
```bash title="终端"
docker pull ghcr.io/x-haose/imgyoung:latest
docker stop imgyoung && docker rm imgyoung
docker run -d --name imgyoung --restart unless-stopped \
-p 8721:8721 \
-v imgyoung-data:/data \
ghcr.io/x-haose/imgyoung:latest
```
`docker stop` 默认等 10 秒后强制结束,对 ImgYoung 足够:收到停止信号后,它先停收新请求、等在途请求至多 6 秒,再停后台任务至多 2 秒,然后关库退出。
不支持降级:把镜像换回更旧的标签,旧版本遇到被新版本迁移过的数据库会拒绝启动。回滚要用旧镜像加升级前的快照。
升级前建议在后台「设置 → 备份与导出」点「立即备份」。回滚步骤与更多注意事项见 [升级](../operations/upgrade.mdx)。
# Docker Compose (/zh/docs/install/compose)
用仓库里的 compose.yaml 运行 ImgYoung,以及与 Caddy 组合、自动申请证书对外提供 HTTPS 的写法。
## 示例文件 [#示例文件]
仓库 `deploy/compose.yaml`(发布包里也有一份):
```yaml title="deploy/compose.yaml"
# ImgYoung 的 Docker Compose 示例。
# 用法:把本文件放进一个空目录,执行 docker compose up -d,浏览器打开 http://<主机>:8721/,
# 用 admin / imgyoung123 登录(首次登录强制改密)。
services:
imgyoung:
image: ghcr.io/x-haose/imgyoung:latest # 生产环境建议钉大小版本(如 1.0)或具体版本
# 后台「从快照恢复」会让服务优雅退出,靠重启策略重新拉起后换入快照;正常退出也要拉起
restart: unless-stopped
# 优雅停机先停收新请求、等在途请求至多 6 秒,再停后台任务至多 2 秒;多出的是关库与退出的余量
stop_grace_period: 15s
ports:
- "8721:8721"
volumes:
# 数据库、本地存储渠道与自动生成的 master.key 都在这里;丢了 master.key,渠道凭证与加密数据不可恢复
- imgyoung-data:/data
# environment:
# # 前面有反向代理时,不论代理在宿主机还是另一个容器,ImgYoung 看到的对端都是 Docker 网络里的地址,
# # 填该网络的网段(docker network inspect 查看);否则登录限速与 /metrics 放行会把所有请求都当成来自代理本身
# IMGYOUNG_TRUSTED_PROXIES: 172.16.0.0/12
volumes:
imgyoung-data:
```
放进一个空目录后启动:
```bash title="终端"
mkdir imgyoung && cd imgyoung
curl -fsSLO https://raw.githubusercontent.com/x-haose/imgyoung/main/deploy/compose.yaml
docker compose up -d
```
浏览器打开 `http://<主机>:8721/`,用 `admin` / `imgyoung123` 登录,首次登录会要求立即改密。
镜像标签、数据卷内容、环境变量与重启策略的含义见 [Docker 镜像](./docker.mdx),这里不重复。在 `environment:` 里用 `${VAR}` 引用宿主机变量之前,先读那一页「环境变量」一节的提醒:宿主机没设的变量会被代入空串。
示例里的 `stop_grace_period: 15s` 是停止时等待进程退出的上限。ImgYoung 收到停止信号后先停收新请求、等在途请求至多 6 秒,再停后台任务至多 2 秒,然后关库退出;15 秒在这约 8 秒之上留出余量。
## 日常操作 [#日常操作]
在 `compose.yaml` 所在目录执行:
| 操作 | 命令 |
|---|---|
| 启动或应用 `compose.yaml` 的改动 | `docker compose up -d` |
| 查看状态 | `docker compose ps` |
| 跟踪日志 | `docker compose logs -f imgyoung` |
| 停止并删除容器(数据卷保留)| `docker compose down` |
| 升级 | `docker compose pull && docker compose up -d` |
`docker compose down -v` 会连同数据卷一起删除,数据库、图片与 `master.key` 全部丢失。
数据卷的实际名字带项目名前缀,项目名默认是目录名:在 `imgyoung` 目录下就是 `imgyoung_imgyoung-data`,可用 `docker volume ls` 确认。
要固定版本,把 `image:` 改成 `ghcr.io/x-haose/imgyoung:1.0` 这样的主次版本标签或具体版本,再执行 `docker compose up -d`。改回更旧的标签不能降级:旧版本遇到被新版本迁移过的数据库会拒绝启动。升级前建议先在后台「设置 → 备份与导出」点「立即备份」,回滚步骤与更多注意事项见 [升级](../operations/upgrade.mdx)。
## 与 Caddy 组合对外提供 HTTPS [#与-caddy-组合对外提供-https]
Caddy 会自动申请并续期证书。把下面两个文件放在同一目录,域名换成你的,并让它的 DNS 指向这台主机、放行 80 与 443 端口:
```yaml title="compose.yaml"
services:
imgyoung:
image: ghcr.io/x-haose/imgyoung:latest
restart: unless-stopped
stop_grace_period: 15s
# 不发布端口:只经 Caddy 对外
volumes:
- imgyoung-data:/data
environment:
# Caddy 在同一 Docker 网络的另一个容器里,填该网络的网段
IMGYOUNG_TRUSTED_PROXIES: 172.16.0.0/12
caddy:
image: caddy:2
restart: unless-stopped
depends_on:
- imgyoung
ports:
- "80:80"
- "443:443"
- "443:443/udp"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy-data:/data
- caddy-config:/config
volumes:
imgyoung-data:
caddy-data:
caddy-config:
```
```text title="Caddyfile"
img.example.com {
# 指标只给内网抓取,公网入口一律不转发
respond /metrics 404
reverse_proxy imgyoung:8721
}
```
这份 Caddyfile 与仓库 `deploy/Caddyfile` 在配置上的区别只有转发目标:Caddy 在容器里,要用服务名 `imgyoung:8721` 而不是 `127.0.0.1:8721`。
几点说明:
- **为什么要设 `IMGYOUNG_TRUSTED_PROXIES`**。ImgYoung 默认只信回环地址发来的转发头,而 Caddy 容器连过来的地址是 Docker 网络里的私网地址。不设的话,登录限速会把所有访客当成同一个来源,返回的图片链接是 `http://` 而不是 `https://`,会话 cookie 也不带 `Secure`。
- **核对网段**。Compose 给项目建的网络通常落在 `172.16.0.0/12` 内,但 Docker 的默认地址池用完后也会分配 `192.168.0.0/16` 里的网段。用 `docker network inspect imgyoung_default`(项目名为目录名时)查看 `Subnet`,不在 `172.16.0.0/12` 内就改成实际网段。
- **证书与 Caddy 的数据**。证书存在 `caddy-data` 卷里,删掉它会导致重新申请证书。
- **上传大小**。Caddy 默认不限制请求体大小,单图上限只由后台的「单图上限」(出厂 50 MB)决定。
- **链接地址**。设好可信代理后,未配置 `base_url` 时链接按 Caddy 转发的域名与协议生成,即 `https://img.example.com/i/...`。只有对外域名与 Caddy 收到的域名不一致(比如前面还有 CDN)时才需要配置 `base_url`,见 [配置](../configuration.mdx)。
- **指标**。公网入口屏蔽了 `/metrics`;同一网络里的 Prometheus 可以直接抓 `imgyoung:8721/metrics`,私网地址默认放行。见 [监控](../operations/monitoring.mdx)。
用 Nginx 或其他代理的写法见 [反向代理](../operations/reverse-proxy.mdx)。
# 发布包 (/zh/docs/install/binary)
下载对应平台的发布包,校验 sha256 后直接运行;适合 Windows 与不便执行安装脚本的环境。
## 选择发布包 [#选择发布包]
到 [发布页](https://github.com/x-haose/imgyoung/releases) 下载对应平台的压缩包。文件名里的版本号不带 `v`(如 `1.0.0`),下载路径里的标签带 `v`(如 `v1.0.0`):
| 系统 | amd64 | arm64 |
|---|---|---|
| Linux | `imgyoung_<版本号>_linux_amd64.tar.gz` | `imgyoung_<版本号>_linux_arm64.tar.gz` |
| macOS | `imgyoung_<版本号>_darwin_amd64.tar.gz`(Intel)| `imgyoung_<版本号>_darwin_arm64.tar.gz`(Apple 芯片)|
| Windows | `imgyoung_<版本号>_windows_amd64.zip` | `imgyoung_<版本号>_windows_arm64.zip` |
同目录的 `checksums.txt` 列出每个包的 sha256。包里的内容:
`deploy/` 是 Docker Compose、反向代理与 systemd 的示例配置,`web/licenses/` 是管理后台所用第三方代码的许可证。二进制不依赖其他文件,可以单独拷走。
## 下载与校验 [#下载与校验]
以 1.0.0 为例,换成你要装的版本与架构:
```bash title="终端"
VERSION=1.0.0
FILE=imgyoung_${VERSION}_linux_amd64.tar.gz
BASE=https://github.com/x-haose/imgyoung/releases/download/v${VERSION}
curl -fLO "$BASE/$FILE"
curl -fLO "$BASE/checksums.txt"
grep " $FILE\$" checksums.txt | sha256sum -c -
tar -xzf "$FILE"
```
```bash title="终端"
VERSION=1.0.0
FILE=imgyoung_${VERSION}_darwin_arm64.tar.gz
BASE=https://github.com/x-haose/imgyoung/releases/download/v${VERSION}
curl -fLO "$BASE/$FILE"
curl -fLO "$BASE/checksums.txt"
grep " $FILE\$" checksums.txt | shasum -a 256 -c -
tar -xzf "$FILE"
```
二进制没有开发者签名,也没有经过 Apple 公证。用 `curl` 下载的文件不带隔离属性,可以直接运行;用浏览器下载的包解压后,先去掉隔离属性,否则系统会拦下:
```bash title="终端"
xattr -d com.apple.quarantine imgyoung
```
```powershell title="PowerShell"
$Version = "1.0.0"
$File = "imgyoung_${Version}_windows_amd64.zip"
$Base = "https://github.com/x-haose/imgyoung/releases/download/v$Version"
Invoke-WebRequest "$Base/$File" -OutFile $File -UseBasicParsing
Invoke-WebRequest "$Base/checksums.txt" -OutFile checksums.txt -UseBasicParsing
$Expected = foreach ($Line in Get-Content checksums.txt) {
$Hash, $Name = $Line -split '\s+'
if ($Name -eq $File) { $Hash }
}
$Actual = (Get-FileHash $File -Algorithm SHA256).Hash
if (-not $Expected -or $Actual -ne $Expected) { throw "sha256 不符:期望 $Expected,实际 $Actual" }
Expand-Archive $File -DestinationPath imgyoung
```
校验通过时,Linux 与 macOS 输出 `<文件名>: OK`,PowerShell 不报错;不一致就不要使用这个包,重新下载。
## 运行 [#运行]
```bash title="终端"
./imgyoung
```
```powershell title="PowerShell"
cd imgyoung
.\imgyoung.exe
```
浏览器打开 `http://<主机>:8721/`,用 `admin` / `imgyoung123` 登录,首次登录会要求立即改密。`Ctrl+C` 优雅停机。
- **数据目录**默认是**启动时当前目录**下的 `data/`,不是二进制所在的目录;换个目录启动就会得到一个全新的空实例。用 `IMGYOUNG_DATA_DIR` 固定位置:
```bash
IMGYOUNG_DATA_DIR=/srv/imgyoung ./imgyoung
```
```powershell
$env:IMGYOUNG_DATA_DIR = "D:\imgyoung-data"; .\imgyoung.exe
```
- **监听地址**默认 `:8721`,即所有网卡;只给本机反向代理用时设 `IMGYOUNG_LISTEN=127.0.0.1:8721`。其他环境变量见 [配置](../configuration.mdx)。
- **查看版本**:`./imgyoung -version`。这是唯一的命令行参数,其余配置都走环境变量与后台。
Windows 上首次运行时,防火墙可能询问是否允许 `imgyoung.exe` 访问网络;只在本机使用可以不放行。
## 交给进程管理器托管 [#交给进程管理器托管]
长期运行不要只开在一个终端里。进程管理器负责开机启动、崩溃后拉起;另外,后台「设置 → 备份与导出」里的「恢复」会让服务主动正常退出,靠进程管理器重新拉起时换入快照,没有它恢复就要手动启动才能完成。进程管理器要配置成**进程退出后总是重启**,包括正常退出。
- **Linux**:用包里的 systemd 示例,效果与 [一行安装脚本](./script.mdx) 的 `--service` 相同,数据在 `/var/lib/imgyoung`,各项设置的含义也见那一页:
```bash title="终端"
sudo install -m 0755 imgyoung /usr/local/bin/imgyoung
sudo install -m 0644 deploy/imgyoung.service /etc/systemd/system/imgyoung.service
sudo systemctl daemon-reload
sudo systemctl enable --now imgyoung
```
- **macOS**:可以写一个 launchd 服务,或者改用 [Docker](./docker.mdx)。
- **Windows**:用 WinSW、NSSM 等工具把 `imgyoung.exe` 注册为 Windows 服务,设置好工作目录或 `IMGYOUNG_DATA_DIR`,以及退出后重启。
## 升级 [#升级]
下载新版本的包并校验,停止服务,用新的二进制替换旧的,再启动。数据目录不用动,数据库结构在新版本启动时自动迁移。换回旧的二进制不能降级:旧版本遇到被新版本迁移过的数据库会拒绝启动,回滚要用旧版本加升级前的快照。升级前建议先在后台「设置 → 备份与导出」点「立即备份」,回滚步骤与更多注意事项见 [升级](../operations/upgrade.mdx)。
# 源码构建 (/zh/docs/install/source)
从源码构建带管理后台的单二进制,以及构建参数背后的原因。
## 前置工具 [#前置工具]
| 工具 | 版本 | 用途 |
|---|---|---|
| Go | `go.mod` 里 `go` 指令声明的版本或更新 | 编译服务端 |
| Node.js | 22 | 构建管理后台前端 |
| pnpm | `web/package.json` 的 `packageManager` 字段;执行 `corepack enable` 后自动使用该版本 | 前端依赖与构建 |
| [Task](https://taskfile.dev) | 3 | 构建入口,`task --list` 列出全部任务 |
| Git | 任意 | 获取源码;构建时 Go 工具链从 Git 信息推导版本号 |
只构建、不改代码的话,这些就够了。参与开发所需的 lint 等工具见仓库的 [CONTRIBUTING.md](https://github.com/x-haose/imgyoung/blob/main/CONTRIBUTING.md)。
## 构建与运行 [#构建与运行]
```bash title="终端"
git clone https://github.com/x-haose/imgyoung.git
cd imgyoung
task build
./imgyoung
```
`task build` 先构建前端(在 `web/` 里执行 `pnpm install --frozen-lockfile` 与 `pnpm build`,产物落到 `web/dist`),再编译出仓库根目录下的 `imgyoung`。`task run` 等于构建后直接运行。
浏览器打开 `http://localhost:8721/`,用 `admin` / `imgyoung123` 登录。数据写在当前目录的 `data/`,与 [发布包](./binary.mdx) 相同,托管与升级方式也一样。
`./imgyoung -version` 显示的版本号由 Go 工具链从 Git 推导:检出在某个标签上时就是该标签,否则是伪版本;工作区有未提交的改动时带 dirty 标记。构建时刻由发布流水线与 `Dockerfile` 写入,`task build` 构建的二进制里为空。
## 构建参数 [#构建参数]
Taskfile 在顶层设置了两个环境变量,`task build`、`task run` 等构建任务都在这组条件下进行(只有 `task test` 的竞争检测在 Linux / Windows 上会打开 cgo):
```yaml title="Taskfile.yml(节选)"
env:
CGO_ENABLED: '0'
GOFLAGS: -tags=nodynamic
```
不用 Task 时,等价的手动步骤是:
```bash title="终端"
(cd web && pnpm install --frozen-lockfile && pnpm build)
CGO_ENABLED=0 go build -trimpath -tags nodynamic -o imgyoung ./cmd/imgyoung
```
- **`CGO_ENABLED=0`**:全程纯 Go。SQLite 驱动、图像编解码都是纯 Go 或 WASM 实现,不需要 C 工具链;Linux 产物不依赖 libc,可以直接交叉编译(如加上 `GOOS=linux GOARCH=arm64`),也能放进 distroless/static 这类没有 libc 的最小镜像。
- **`-tags nodynamic`**:WebP 编解码库默认会在运行时尝试加载系统里的 libwebp 共享库,这会让 Linux 二进制依赖系统的动态链接器,在 distroless/static 等最小镜像里根本启动不了。加上这个标签后只用内置的 WASM 实现,产物是真正的静态二进制,行为也不随主机是否装了 libwebp 而变化。
- **`-trimpath`**:去掉二进制里的本机源码路径。
发布流水线(`.goreleaser.yaml`)与镜像构建(`Dockerfile`)用的是同一组参数。
## 先构建前端 [#先构建前端]
管理后台的前端产物通过 `go:embed` 编进二进制,但产物不入库,仓库里的 `web/dist` 只有一个占位文件。跳过前端直接 `go build`,编译照样成功、接口也都正常,但没有后台页面:访问 `/` 返回 404,启动日志里会有一条告警:
```text
level=WARN msg="前端产物缺少 index.html,后台页面不可用(API 不受影响)"
```
看到它就先执行 `task web`(或上面的 `pnpm` 两步)再重新编译。这也是 ImgYoung 不提供 `go install` 安装方式的原因。
## 用 Docker 构建镜像 [#用-docker-构建镜像]
只有 Docker、没有 Go 与 Node.js 时,可以用仓库根目录的多阶段 `Dockerfile` 构建,前端与 Go 都在构建容器里编译:
```bash title="终端"
task docker
```
它等价于下面的命令,得到本地镜像 `imgyoung:dev`:
```bash title="终端"
docker build \
--build-arg VERSION=$(git describe --tags --always --dirty) \
--build-arg COMMIT=$(git rev-parse HEAD) \
-t imgyoung:dev .
```
构建上下文不含 `.git`,版本号与提交只能经 `--build-arg` 传入,不传时版本显示为 `devel`。镜像的用法与发布镜像相同,见 [Docker 镜像](./docker.mdx)。
# 上传 (/zh/docs/guides/upload)
用 HTTP 接口、URL 转存或管理后台上传图片,以及格式与大小限制、EXIF 处理、幂等键、查重关系与写入语义。
## 上传入口 [#上传入口]
| 入口 | 认证 | 适合 |
|---|---|---|
| `POST /api/upload`(multipart 表单)| 上传 Token | 脚本、PicGo / PicList 自定义图床 |
| `POST /api/upload/url`(JSON)| 上传 Token | 把别处的图片地址交给服务端抓取入库 |
| 管理后台「图片」页的「上传图片」 | 登录会话 | 拖拽、点选、粘贴剪贴板、链接转存 |
| S3 协议 `/s3/…` | 由上传 Token 派生的 S3 凭证 | 带 S3 上传能力的客户端,见 [上传客户端](./clients.mdx) |
四个入口走同一条上传管道:格式校验、像素预算、配额预检、EXIF 处理、写入渠道、入库。
差别只在字节从哪来,以及能带哪些选项:S3 入口不能逐次指定有效期,不做 EXIF 处理,也不支持幂等键。
上传 Token 在后台「上传 Token」页签发,见 [管理后台](./admin.mdx#upload-tokens)。
## 用接口上传 [#用接口上传]
```bash title="终端"
curl -H "Authorization: Bearer imgy_xxxxxxxx" \
-F "file=@photo.jpg" \
https://img.example.com/api/upload
```
带上全部可选项:
```bash title="终端"
curl -H "Authorization: Bearer imgy_xxxxxxxx" \
-H "Idempotency-Key: 7d0c6a2e-photo-0001" \
-F "file=@photo.jpg" \
-F "ttl=604800" \
-F "exif_policy=strip" \
https://img.example.com/api/upload
```
| 表单字段 | 必填 | 说明 |
|---|---|---|
| `file` | 是 | 图片文件。文件名只作元数据(后台展示与搜索),去掉控制字符后截断到 255 字节。只取第一个带文件名的 `file` 段 |
| `ttl` | 否 | 有效期,整数秒(0 到 3153600000)或 `never`。`0` 与 `never` 都表示永久;省略时按「用户默认 > 全局默认」,见 [生命周期与查重](./lifecycle.mdx) |
| `exif_policy` | 否 | `strip` 或 `passthrough`;省略时用全局默认(出厂 `passthrough`),见下文 [EXIF 处理](#exif-处理) |
`ttl` 与 `exif_policy` 只从 multipart 表单字段读取,写在 URL 查询参数里不生效;每个字段最多 1 KiB,超出返回 400。表单里的其他字段被忽略。
请求头 `Idempotency-Key` 可选,见 [幂等键](#幂等键)。
成功返回 200:
```json
{
"id": "R0RjUyP1UW",
"url": "https://img.example.com/i/R0RjUyP1UW"
}
```
- `id` 是 10 位短 ID,字符取自 `0-9A-Za-z`。
- `url` 恒为绝对地址:设置了「对外站点地址」(`base_url`)就用它;没设置时按本次请求推导,协议看连接是否 TLS,域名取 `Host` 头。
经信任的反向代理转发时改看 `X-Forwarded-Proto` 与 `X-Forwarded-Host`(信任范围由环境变量 `IMGYOUNG_TRUSTED_PROXIES` 决定)。
反代或 CDN 对外的域名与请求里的域名不一致时,必须配置 `base_url`,见 [配置](../configuration.mdx) 与 [反向代理](../operations/reverse-proxy.mdx)。
失败时返回 `{"error": "…"}`,状态码见 [状态码速查](#状态码速查)。完整接口定义见 [接口参考:数据面](../api/data-plane.mdx)。
## 幂等键 [#幂等键]
网络不稳时客户端可能重发同一次上传。请求带 `Idempotency-Key` 头,就能避免重发产生第二张图:
- 键按用户隔离,不同用户用同一个键互不影响,也就无法借幂等键探测或取到别人的图。
- 保留期内,同一用户带同一个键的请求直接返回第一次成功上传的 `id` 与 `url`,不再写入渠道。保留期由 `idempotency_key_ttl_hours` 决定,出厂 24 小时,从第一次成功时起算。
- 命中后不比较本次请求的内容:同一个键换了文件,返回的仍是第一次的那张图。每张不同的图要用不同的键。
- 只有成功的上传会记下键;失败的请求可以带同一个键放心重试。
- 并发发出的同键请求只有一个真正入库,其余返回同一结果。
- 超过保留期的键由清理扫描回收;图片被删除时,指向它的键一并删除。此后再用这个键会被当作新上传。
`/api/upload`、`/api/upload/url` 都支持幂等键;S3 入口没有。
## URL 转存 [#url-转存]
给一个图片地址,服务端替你下载后入库,常用来把散在其他图床的图收回来。抓回的字节与直接上传走同一条上传管道,格式白名单、像素预算、EXIF 处理与配额照常生效;服务端不会把远端文件直接拷进渠道。
```bash title="终端"
curl -H "Authorization: Bearer imgy_xxxxxxxx" \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com/photo.png", "ttl": "never"}' \
https://img.example.com/api/upload/url
```
请求体是 JSON 对象,只允许以下字段(出现其他字段返回 400),大小不超过 8 KiB:
| 字段 | 必填 | 说明 |
|---|---|---|
| `url` | 是 | 图片地址,仅 `http` / `https` |
| `file_name` | 否 | 文件名;省略时取地址路径的最后一段,取不到时为 `fetched` |
| `exif_policy` | 否 | 同表单上传 |
| `ttl` | 否 | 同表单上传;数字 `3600` 与字符串 `"3600"`、`"never"` 都接受 |
返回体与 `Idempotency-Key` 的用法和表单上传相同。抓回的字节按内容判定格式,远端声明的 `Content-Type` 不参与判断。
### 抓取限制 [#抓取限制]
这个入口相当于让服务端替请求方发起外部请求,所以抓取有一组固定约束,防止被借来探测你的内网:
| 约束 | 规则 | 不满足时 |
|---|---|---|
| 协议 | 只允许 `http` / `https`,重定向的每一跳同样检查 | 400 |
| 目标地址 | 只允许公网地址。判定发生在每次建立连接前,针对实际解析出的 IP,所以 DNS 重绑定与重定向到内网都会被拦下 | 400 |
| 重定向 | 最多 5 次 | 502 |
| 大小 | 与单图上限相同(`max_upload_mb`)。远端声明的 `Content-Length` 超限直接拒绝;未声明时读到超限即停止 | 413 |
| 时长 | 整次抓取最长 30 秒;其中建立 TCP 连接、TLS 握手、等待响应头各不超过 10 秒 | 502 |
| 远端状态 | 只接受 2xx | 502 |
被拒绝的地址段:
- IPv4:`0.0.0.0/8`、`10.0.0.0/8`、`100.64.0.0/10`、`127.0.0.0/8`、`169.254.0.0/16`(含云厂商元数据地址 `169.254.169.254`)、`172.16.0.0/12`、`192.0.0.0/24`、`192.168.0.0/16`、`198.18.0.0/15`、`224.0.0.0/4`、`240.0.0.0/4`。
- IPv6:`::/128`、`::1/128`、`::/96`、`64:ff9b::/96`、`64:ff9b:1::/48`、`2002::/16`、`fc00::/7`、`fe80::/10`、`ff00::/8`。IPv4 映射地址(如 `::ffff:127.0.0.1`)按其中的 IPv4 判定。
另外几点:
- 抓取直连目标,不使用 `HTTP_PROXY` 之类的代理环境变量。
- 请求不带 `Referer`,`User-Agent` 是 Go 标准库的默认值。开了防盗链或屏蔽爬虫的源站可能拒绝,表现为 502。
- 抓取期间占用一个上传并发名额(见 [格式与上限](#格式与上限))。
## 在管理后台上传 [#在管理后台上传]
在「图片」页点「上传图片」,弹窗里有两个标签:
- 「本地文件」:点击选择或把图片拖进虚线框,可多选;弹窗打开期间按 Ctrl / ⌘ + V 可直接粘贴剪贴板里的图片。文件每次最多两个同时上传,其余排队。
- 「链接转存」:填图片地址后点「转存」,等同于 [URL 转存](#url-转存)。
弹窗下方的「有效期」(默认 / 永久 / 自定义小时数)与「EXIF」(默认 / 剥离 / 保留)对之后加入的文件生效,「默认」即不传该字段。
关闭弹窗不会中断进行中的上传;每张上传成功后列表里显示链接,可一键复制。
后台上传归属当前登录账号,不需要 Token,图片详情里的「来源」显示为「后台上传 / 来源已删除」。它与 Token 上传共用频控与并发闸。
## 格式与上限 [#格式与上限]
只接受 JPEG、PNG、GIF、WebP。格式按文件内容(magic bytes)判定,并与解码器识别出的格式交叉核对,扩展名与客户端声明的类型一概不看。
- 不接受 SVG(可内嵌脚本)。
- 动画 GIF 可以上传,原样保存,访问时始终输出原图,动画保留。
- 动画 WebP 不支持,上传返回 400。
| 限制 | 配置键 | 出厂值 | 超出时 |
|---|---|---|---|
| 单图大小 | `max_upload_mb` | 50 MB | 413。只算文件本身,大小恰好等于上限的文件可以上传;整个请求体另以「单图上限 + 64 KiB」为界,超出同样 413 |
| 像素预算 | `max_pixels_mp` | 50 百万像素(宽 × 高)| 422。只读图片头判断,超限不进入完整解码 |
| 上传并发 | `max_concurrent_uploads` | 8 | 429,响应头 `Retry-After: 1` |
| 上传频率 | `upload_rate_per_min` | 0(不启用)| 429,响应头 `Retry-After: 60` |
| 存储配额 | 每个用户单独设置 | 不限 | 403 |
- **并发闸**全局只有一份,数据面上传、URL 转存、后台上传与全部 S3 请求(包括 GET / HEAD / DELETE)共用。它限制的是同时在处理的请求数;上传内容不论大小都只在内存里处理、不写临时文件,所以这个数决定了上传的峰值内存。满了立即返回 429,不排队。
- **像素预算**挡的是解码后的内存:它由分辨率决定,与文件大小无关,一张 2000 万像素的图解码成 RGBA 约 80 MB。所以先只读图片头判断,超限的图不进入解码。
- **频控**按用户计,同一用户经 Token、后台与 S3 PutObject 的上传共享额度。算法是令牌桶:可以一次性连续上传 N 张(N 为每分钟额度),之后每 60/N 秒恢复一张。额度保存在进程内存里,重启即重置。
- **配额**按引用计全额:每条图片记录都按其物理文件的完整大小计入归属用户,后台查重合并不会减少已用量。预检用上传时的原始字节数。
以上配置都在「设置 → 运行配置」修改,保存即生效,见 [配置](../configuration.mdx)。用户配额在「用户」页设置。
### 状态码速查 [#状态码速查]
| 状态码 | 含义 |
|---|---|
| 200 | 成功(幂等键命中也是 200)|
| 400 | 缺少 `file` 字段或请求体无法读取;图片内容无法解析(含动画 WebP);`ttl` 或 `exif_policy` 取值非法或超过 1 KiB;URL 转存的请求体非法、协议不是 http/https 或目标不是公网地址 |
| 401 | Token 缺失、无效、已吊销、已过期,或所属用户被禁用 |
| 403 | Token 没有上传权限,或存储配额已满 |
| 413 | 文件超过单图大小上限,或请求体超过「单图上限 + 64 KiB」(URL 转存时为远端内容超限)|
| 415 | 不是支持的图片格式 |
| 422 | 分辨率超过像素预算 |
| 429 | 上传并发已满,或该用户上传过于频繁;按 `Retry-After` 稍后重试 |
| 502 | URL 转存:远端返回非 2xx、重定向过多、超时或连接失败 |
| 503 | 没有健康渠道可写,或同步写入的份数达不到最少同步副本数 |
## EXIF 处理 [#exif-处理]
| 档位 | 行为 |
|---|---|
| `passthrough` | 原样保存,不动任何元数据。全局出厂默认 |
| `strip` | 剥离元数据后保存,范围见下表 |
出厂默认是 `passthrough`:存储商能看到原图的问题由[渠道存储加密](../channels/encryption.mdx)解决;要不要对拿到链接的访客抹掉元数据,是上传者自己的选择,所以默认不改动原图。
本次上传的档位按「表单字段(或 JSON 字段)> 全局默认 `exif_policy`」决定。S3 上传不做 EXIF 处理,一律按原字节保存:S3 客户端按大小与 ETag 核对上传结果,保存的字节必须与上传的一致。
`spoof`(伪造元数据)尚未开放,传入返回 400,也不能设为全局默认。
`strip` 是字节级操作,不重新编码、不改动任何像素:
| 格式 | 剥离 | 保留 |
|---|---|---|
| JPEG | 除 APP0(JFIF)、APP2(ICC 色彩配置)、APP14(Adobe)之外的全部 APPn 段,包括 EXIF(GPS、机型、拍摄时间等)、XMP、IPTC 与相机私有段;COM 注释段;EOI 之后的附加数据(如 Motion Photo 里的视频)| 熵编码数据原样保留。原图带非默认的方向标记(Orientation)时,补回一个只含方向的最小 EXIF 段,照片仍按正确方向显示。渐进式 JPEG 各次扫描之间的段不检查 |
| PNG | `eXIf`、`tEXt`、`zTXt`、`iTXt` 块;IEND 之后的附加数据 | `gAMA`、`iCCP` 等影响颜色渲染的块 |
| WebP | `EXIF` 与 `XMP` chunk,并同步清除 VP8X 头里对应的标志位、修正 RIFF 长度;RIFF 声明长度之外的附加数据 | 像素 chunk(含动画帧)|
| GIF | 不处理 | GIF 没有承载 EXIF / XMP 的标准容器 |
- 图片的 SHA-256(后台显示的哈希、查重的依据)按剥离后实际保存的字节计算。
- `passthrough` 保存的原图里的 GPS 等信息,任何拿到链接的人都能取到原始字节读出来。
[按需处理](./access.mdx#on-demand-processing) 输出的衍生图是重新编码的,不带元数据,但原图本身仍可访问。
### 为什么在上传时处理 [#why-on-upload]
EXIF 档位在上传那一刻生效,保存的就是处理后的字节;访问时不再按当前设置重新处理原图。
原因是访问层的缓存建立在「同一个 `/i/` 链接的字节永不改变」之上:响应带 `Cache-Control: immutable`,浏览器与 CDN 可以放心缓存一年。
如果改成访问时处理,全局档位一改,同一个链接输出的字节就变了:CDN 上的缓存要整体清除,而访客浏览器里已经缓存的旧字节无法让它失效。
访问时处理的主要好处,是原图可以原样留在渠道、只在出口净化;存储商侧的隐私已由渠道存储加密解决,上传时处理加上默认 `passthrough` 已经覆盖了这层需要,不值得为它放弃不可变缓存。
## 同一张图多次上传 [#同一张图多次上传]
上传路径上不做查重:同一个文件上传两次,得到两个短 ID,渠道里也会先写入两份。
这是有意的:上传路径上没有「秒传」之类的分支,每次上传都完整走一遍校验、写渠道与入库,出问题的面最小。查重只用上传时顺手算好的哈希,
在后台是纯数据库操作,不必重新解码图片。
后台查重循环(出厂每 300 秒一轮,`dedup_scan_seconds`)会把实际保存字节完全相同的物理文件自动合并为一份,多余的那份按删除宽限期清理。合并后:
- 每次上传仍有自己的短 ID、有效期、访问计数与归属用户,所有链接继续可用。
- 配额按引用计全额,合并不减少已用量。
用不同 EXIF 档位上传的同一张照片,保存的字节不同,不会被自动合并;它们的感知哈希接近,会进入「疑似重复」等管理员裁决。
细节见 [生命周期与查重](./lifecycle.mdx#deduplication)。客户端重试造成的重复,用 [幂等键](#幂等键) 从源头避免。
## 写入语义 [#写入语义]
一次上传在返回之前,按优先级从高到低依次写入健康渠道,直到写成功的份数达到 `min_success`(「最少同步副本」,出厂 1):
1. 写入失败或超时(每次写入最长 10 分钟)就顺延到下一个健康渠道;第一个写成功的渠道就是这张图的主渠道。
2. 份数凑够后写入元数据库并返回。其余渠道记为待补齐,由后台补齐任务异步复制,不拖慢本次上传。
3. 健康渠道不足、或全部尝试后份数仍不够,返回 503。已经写进渠道的对象成为孤儿,由对账任务在 48 小时宽限期后回收。
`min_success` 大于 1 时,一次上传要等多个渠道都写完才返回,换来返回时就已有多份副本;健康渠道数少于该值时上传一律 503。
渠道的优先级、健康探测与补齐见 [存储渠道](../channels/index.mdx)。
# 上传客户端 (/zh/docs/guides/clients)
把 PicGo、PicList、Typora、aws CLI、rclone 等客户端接到 ImgYoung:自定义 Web 图床与 S3 协议两种方式。
## 两种接入方式 [#两种接入方式]
| | 自定义 Web 图床 | S3 协议 |
|---|---|---|
| 客户端要求 | 能发 multipart POST,并从 JSON 响应里取出链接 | 能向 S3 兼容存储上传(SigV4 签名、路径风格寻址)|
| 典型客户端 | PicGo + web-uploader 插件、PicList「高级自定义」 | PicList 内置 AWS S3、PicGo + s3 插件、aws CLI、rclone、各语言 S3 SDK |
| 凭证 | 上传 Token 明文 | 由上传 Token 派生的 access key 与 secret |
| 返回的链接 | 服务端返回 `/i/<短ID>` | 客户端按对象键自己拼 `/o/<用户 id>/<对象键>` |
| 有效期 | 可以逐次指定 | 只用默认值 |
| EXIF 处理 | 可以逐次指定档位,省略时用全局默认 | 不处理,按原字节保存 |
| 幂等键 | 支持 | 不支持 |
| 同名文件再传 | 每次都是一张新图 | 同一对象键按 S3 语义覆盖 |
两种方式最后都进同一条上传管道,格式、大小、配额、频控规则相同,见 [上传](./upload.mdx)。
凭证都来自后台「上传 Token」页:建议每个客户端单独签发一枚具名 Token,某个客户端泄露时只吊销它。
## 自定义 Web 图床 [#自定义-web-图床]
客户端把图片以 multipart 表单 POST 到 `/api/upload`,文件字段名 `file`,带 `Authorization: Bearer ` 请求头,从响应 JSON 的 `url` 字段取链接。
`url` 是绝对地址;经反向代理或 CDN 后对外域名与请求域名不一致时,先在「设置 → 运行配置」填好「对外站点地址」,否则返回的链接会是反代内侧的地址。
### PicGo:web-uploader 插件 [#picgoweb-uploader-插件]
PicGo 本身没有通用的 HTTP 上传方式,需要在插件设置里搜索安装 `web-uploader`(npm 包 `picgo-plugin-web-uploader`),图床列表里会多出「自定义Web图床」:
| 插件参数 | 配置键 | 填写 |
|---|---|---|
| API地址 | `url` | `https://img.example.com/api/upload` |
| POST参数名 | `paramName` | `file` |
| JSON路径 | `jsonPath` | `url` |
| 自定义请求头 | `customHeader` | `{"Authorization": "Bearer imgy_xxxxxxxx"}` |
| 自定义Body | `customBody` | 可选,作为额外的表单字段一起提交,如 `{"ttl": "never", "exif_policy": "strip"}` |
`customBody` 里的值请写成字符串。
### PicList:高级自定义 [#piclist高级自定义]
PicList 内置的「高级自定义」图床填写同样的内容:接口地址 `https://img.example.com/api/upload`,请求方式 POST,文件字段名 `file`,请求头 `Authorization: Bearer imgy_xxxxxxxx`,返回值中链接的 JSON 路径 `url`。
需要指定有效期或 EXIF 档位时,把 `ttl`、`exif_policy` 作为附加的表单字段。
## S3 协议 [#s3-协议]
ImgYoung 在 `/s3` 下实现了 S3 协议的一个子集,凭证就是上传 Token:吊销 Token,这条路同时失效。
协议入口收在 `/s3` 前缀下,是因为 S3 的桶路径本是站点根下的单段路径,直接挂在根上会占用管理后台页面的顶层路由。
### 取得 S3 配置 [#取得-s3-配置]
在后台「上传 Token」页,点某枚有效 Token 行末的「更多操作」→「S3 客户端配置」,弹窗给出全部参数,逐项复制即可:
| 弹窗字段 | 示例值 | 说明 |
|---|---|---|
| 应用密钥 ID | `imgy_AbCdEfG` | 即 Token 的识别前缀(12 个字符)|
| 应用密钥 | 64 位十六进制 | 由主密钥按该 Token 派生,不落库,可随时回来重看 |
| 桶名 | `images` | 没有桶的概念,填任何桶名都接受,桶名也不进对象键 |
| 区域 | `us-east-1` | 不区分区域,填任何值都接受 |
| 自定义节点 | `https://img.example.com/s3` | 结尾的 `/s3` 是协议入口前缀,不能省 |
| 自定义域名 | `https://img.example.com/o/<用户 id>` | 拼图片链接用,含你的用户 id |
节点与域名按「对外站点地址」生成,未设置时按打开后台所用的地址推导。
- **必须用路径风格寻址**(path-style,请求形如 `/s3/<桶>/<对象键>`)。自部署没有桶子域名,客户端有「路径风格」「ForcePathStyle」之类的开关时请打开。
- 只能查看自己的 Token 的 S3 配置;管理员也看不到别人的。
- 轮转 Token 后 access key 与 secret 都会变,客户端要换成新值。更换主密钥会让全部 S3 secret 失效。
- Token 吊销、过期或所属用户被禁用时,签名校验一律失败。
### 支持的操作 [#支持的操作]
| 操作 | 请求 | 行为 |
|---|---|---|
| PutObject | `PUT /s3/<桶>/<对象键>` | 走上传管道。响应头 `ETag` 为上传正文的 SHA-256,`x-amz-meta-image-id` 为这张图的短 ID |
| HeadBucket | `HEAD /s3/<桶>` | 凭证有效即返回 200 |
| HeadObject | `HEAD /s3/<桶>/<对象键>` | 返回对象的元数据头 |
| GetObject | `GET /s3/<桶>/<对象键>` | 返回保存的原图,不做按需处理,不计访问,不受防盗链约束;缓存头与 `/o/` 别名相同 |
| DeleteObject | `DELETE /s3/<桶>/<对象键>` | 删除该对象键当前指向的图;对象不存在也返回 204 |
不支持:列举桶与对象(`ListBuckets`、`ListObjects`)、分片上传、`CopyObject`、建桶、ACL、预签名 URL(查询串签名)与 SigV2 签名。这些请求得到 4xx 错误。
客户端随请求带的 ACL 头不报错,也不起作用。
签名与正文:
- 只认 `Authorization` 头里的 `AWS4-HMAC-SHA256` 签名;请求时间与服务器相差超过 15 分钟即拒绝。
- 支持 `UNSIGNED-PAYLOAD`,也支持 aws CLI v2 等默认使用的 `aws-chunked` 分块正文。声明了正文哈希时,服务端会核对它与实际正文一致。
上传时的取值:
- 对象键是桶名之后的整段路径(URL 解码后),最长 1024 字节,不能含控制字符、不能以 `/` 开头;在同一用户内唯一。
- 文件名取对象键最后一段;有效期按「用户默认 > 全局默认」。
- 不做 EXIF 处理,全局 EXIF 默认档对 S3 上传不生效:保存的字节与上传的完全一致,PUT 响应的 ETag 与之后 GetObject 的 ETag 相同。
错误以 S3 风格的 XML 返回,客户端按 `Code` 区分:
| 情形 | 状态码 | Code |
|---|---|---|
| 签名不符、access key 不存在、Token 已失效 | 403 | `SignatureDoesNotMatch` |
| 请求时间偏差超过 15 分钟 | 403 | `RequestTimeTooSkewed` |
| 缺少或无法解析 `Authorization` 头 | 400 | `AuthorizationHeaderMalformed` |
| 不是 `AWS4-HMAC-SHA256` 签名 | 400 | `InvalidRequest` |
| 超过单图大小上限 | 400 | `EntityTooLarge` |
| 不是支持的图片,或超过像素预算 | 400 | `InvalidRequest` |
| 存储配额已满 | 403 | `QuotaExceeded` |
| 对象键过长 / 含非法字符 / 缺失 | 400 | `KeyTooLongError` / `InvalidArgument` / `InvalidRequest` |
| 对象不存在,或指向的图已到期 | 404 | `NoSuchKey` |
| 上传时没有健康渠道可写,或同步写入的份数达不到最少同步副本数 | 503 | `ServiceUnavailable` |
| 对象暂时读不出来(GET / HEAD)| 503 | `ServiceUnavailable` |
| 其余服务端错误 | 500 | `InternalError` |
上传并发已满或频率超限时返回 429,响应体是 JSON 而不是 XML。
### 对象键别名 [#object-key-aliases]
经 S3 上传的图片除了短 ID,还有一个按对象键的访问地址:
```text
https://img.example.com/o/<用户 id>/<对象键>
```
- 用户 id 是不可变的账号标识,不是登录名:内置管理员为 `default`,其他用户是建号时生成的 10 位随机串,可在「用户」页的 ID 列或 S3 配置弹窗的「自定义域名」里看到。对象键只在用户内唯一,所以链接里必须带它;用不可变的 ID 而不是登录名,是因为登录名可以修改,改名后旧名再被别人占用,之前分享出去的链接就会指向别人的图。
- `/o/` 与 `/i/<短ID>` 是同一张图的两个入口,按需处理、格式协商、防盗链、访问计数完全一致;只有缓存方式不同,见下文与 [访问图片](./access.mdx#caching)。
- 短 ID 在 PutObject 的响应头 `x-amz-meta-image-id` 里,也能在后台「图片」页查到。
### 同键覆盖 [#同键覆盖]
对同一对象键再次 PutObject,按 S3 语义覆盖:
- 这个对象键改为指向新上传的图,`/o/` 链接随之显示新图。
- 旧图不会被删除:它的短 ID 可能已经分享出去,链接继续可用,也继续占用存储与配额;不需要时到后台「图片」页手动删除。
- DeleteObject 只删除对象键当前指向的那张图。
对象键可以被覆盖,所以 `/o/` 链接(以及 S3 GetObject)的响应是 `Cache-Control: public, no-cache` 加强 `ETag`:浏览器与 CDN 可以缓存,但每次使用前都要带 `ETag` 向本服务确认,内容没变时得到 304。
同键覆盖后,下一次确认就会拿到新图。短链 `/i/<短ID>` 始终指向同一张图,仍按一年不可变缓存。
## 客户端配置示例 [#客户端配置示例]
以下示例里的 `imgy_AbCdEfG`、``、`<用户 id>` 换成「S3 客户端配置」弹窗里的实际值。
### PicList:内置 AWS S3 [#piclist内置-aws-s3]
PicList 自带「AWS S3」图床,按配置键对应填写(界面上的字段名随版本可能不同):
| 配置键 | 填写 |
|---|---|
| `accessKeyID` | 弹窗里的「应用密钥 ID」 |
| `secretAccessKey` | 弹窗里的「应用密钥」 |
| `bucketName` | `images` |
| `uploadPath` | 对象键模板,例如 `{year}/{month}/{md5}.{extName}` |
| `region` | `us-east-1` |
| `endpoint` | `https://img.example.com/s3` |
| `pathStyleAccess` | 开启 |
| `urlPrefix` | `https://img.example.com/o/<用户 id>` |
| `disableBucketPrefixToURL` | 开启。否则路径风格下 PicList 会在链接里插入桶名,得到打不开的地址 |
### PicGo:s3 插件 [#picgos3-插件]
PicGo 没有内置 S3 上传,需要在插件设置里安装 `s3`(npm 包 `picgo-plugin-s3`),图床列表里会多出「Amazon S3」。
这个插件生成链接推荐用「自定义输出 URL 模板」(`outputURLPattern`),其中 `{path}` 是上传后的完整对象键:
| 配置键 | 填写 |
|---|---|
| `accessKeyID` / `secretAccessKey` | 弹窗里的「应用密钥 ID」与「应用密钥」 |
| `bucketName` | `images` |
| `uploadPath` | 例如 `{year}/{month}/{md5}.{extName}` |
| `region` | `us-east-1` |
| `endpoint` | `https://img.example.com/s3` |
| `pathStyleAccess` | 开启(界面上叫 ForcePathStyle)|
| `outputURLPattern` | `https://img.example.com/o/<用户 id>/{path}` |
用 `{md5}` 之类的内容哈希做对象键,同一张图重复上传得到同一个键,不同的图不会互相覆盖。
### Typora [#typora]
Typora 不直接上传到 S3 或自定义接口,而是交给 PicGo:在 Typora 的偏好设置 → 图像里,把上传服务选为 `PicGo (app)` 或 `PicGo-Core (command line)`。
- 选 `PicGo (app)`:Typora 调用正在运行的 PicGo 应用,按上面任一种方式在 PicGo 里配好图床即可。
- 选 `PicGo-Core (command line)`:PicGo-Core 读取 `~/.picgo/config.json`。先用 `picgo install s3`(或 `picgo install web-uploader`)装好插件,再写配置:
```json title="~/.picgo/config.json"
{
"picBed": {
"uploader": "aws-s3",
"current": "aws-s3",
"aws-s3": {
"accessKeyID": "imgy_AbCdEfG",
"secretAccessKey": "",
"bucketName": "images",
"uploadPath": "{year}/{month}/{md5}.{extName}",
"region": "us-east-1",
"endpoint": "https://img.example.com/s3",
"pathStyleAccess": true,
"outputURLPattern": "https://img.example.com/o/<用户 id>/{path}"
}
},
"picgoPlugins": {
"picgo-plugin-s3": true
}
}
```
```json title="~/.picgo/config.json"
{
"picBed": {
"uploader": "web-uploader",
"current": "web-uploader",
"web-uploader": {
"url": "https://img.example.com/api/upload",
"paramName": "file",
"jsonPath": "url",
"customHeader": "{\"Authorization\": \"Bearer imgy_xxxxxxxx\"}"
}
},
"picgoPlugins": {
"picgo-plugin-web-uploader": true
}
}
```
### aws CLI [#aws-cli]
```ini title="~/.aws/credentials"
[imgyoung]
aws_access_key_id = imgy_AbCdEfG
aws_secret_access_key =
```
```ini title="~/.aws/config"
[profile imgyoung]
region = us-east-1
s3 =
addressing_style = path
multipart_threshold = 64MB
```
```bash title="终端"
# 上传
aws --profile imgyoung --endpoint-url https://img.example.com/s3 \
s3 cp ./photo.png s3://images/blog/2026/photo.png
# 取回元数据与删除
aws --profile imgyoung --endpoint-url https://img.example.com/s3 \
s3api head-object --bucket images --key blog/2026/photo.png
aws --profile imgyoung --endpoint-url https://img.example.com/s3 \
s3 rm s3://images/blog/2026/photo.png
```
- `aws s3 cp` 对超过 `multipart_threshold`(默认 8 MB)的文件改用分片上传,而分片上传不受支持。把它调到大于「单图上限」的值,或改用 `aws s3api put-object` 单次上传。
- `aws s3 ls`、`aws s3 sync` 依赖列举对象,不可用。
上传后的链接是 `https://img.example.com/o/<用户 id>/blog/2026/photo.png`。
### rclone [#rclone]
```bash title="终端"
rclone config create imgyoung s3 \
provider=Other \
access_key_id=imgy_AbCdEfG \
secret_access_key= \
endpoint=https://img.example.com/s3 \
region=us-east-1 \
force_path_style=true \
no_check_bucket=true
rclone copyto ./photo.png imgyoung:images/blog/2026/photo.png
```
- `no_check_bucket=true` 让 rclone 跳过上传前的建桶检查;服务端不支持建桶。
- 只用 `copyto`、`deletefile` 这类按单个对象操作的命令;`ls`、`sync`、`copy` 目录等依赖列举对象,不可用。
### 其他 S3 SDK [#其他-s3-sdk]
任何语言的 S3 SDK 都按同样的要点配置:端点设为 `https://img.example.com/s3`,开启路径风格寻址,区域随意填(如 `us-east-1`),只用 PutObject / HeadObject / GetObject / DeleteObject,不要让 SDK 自动切换到分片上传。
# 访问图片 (/zh/docs/guides/access)
图片链接的两种形式、缓存与条件请求、按需缩放与 WebP 协商、代理与 302 两种访问模式、防盗链,以及访问计数的口径。
## 链接形式 [#链接形式]
| 链接 | 谁有 | 说明 |
|---|---|---|
| `/i/<短ID>` | 每张图 | 上传响应里的 `url`;短 ID 为 10 位,字符取自 `0-9A-Za-z` |
| `/o/<用户 id>/<对象键>` | 经 S3 协议上传的图 | 按对象键的别名,对象键可含 `/`,见 [上传客户端](./clients.mdx#object-key-aliases) |
两种链接都公开访问、不需要登录,走同一条输出管线:防盗链校验、访问计数、按需处理、渠道容灾读取完全一致。
- 图片不存在、已删除或已到期时返回 404(纯文本)。删除后链接立即失效;设了有效期的图一到期就返回 404,不等清理扫描把它回收,见 [生命周期与查重](./lifecycle.mdx)。
- 健康渠道里都没有这张图的可用副本,或全部候选渠道读取失败,返回 503(纯文本)。
- 查询图片元数据失败(数据库故障)时返回 500(纯文本),可以稍后重试。
- 支持 GET 与 HEAD。HEAD 返回与 GET 相同的状态码与响应头、不带正文,也不计访问;不需要按需处理时不读取渠道里的对象,需要处理的 HEAD 请求照常生成衍生图。
完整接口定义见 [接口参考:图片访问](../api/image-access.mdx)。
## 缓存与条件请求 [#caching]
短链背后的图片固定不变,可以长期缓存;`/o/` 别名可能被同键覆盖、改指新图,所以要求缓存方每次使用前确认:
| 响应头 | 短链 `/i/` | 别名 `/o/` |
|---|---|---|
| `Cache-Control` | `public, max-age=31536000, immutable` | `public, no-cache` |
| `ETag` | 原图为 `""`,衍生图为 `"-<宽度>-<格式>"` | 同左 |
| `Vary` | 输出格式由 `Accept` 协商决定时为 `Accept`,见 [WebP 协商](#webp-协商) | 同左 |
| `Last-Modified` | 原图为上传时刻;衍生图无 | 同左 |
| `Content-Type` | 原图为上传时按内容判定的类型;衍生图为目标格式的类型 | 同左 |
| `X-Content-Type-Options` | `nosniff` | `nosniff` |
请求带 `If-None-Match` 且与当前 `ETag` 完全一致时返回 304,304 同样带 `Cache-Control`、`ETag` 与需要时的 `Vary`。只按 `If-None-Match` 协商,不处理 `If-Modified-Since`。
`public, no-cache` 允许浏览器与 CDN 存下响应,但每次使用前都要带 `ETag` 向本服务确认,内容没变时只回 304;同键覆盖后,下一次确认就拿到新图。
两种响应不缓存,带 `Cache-Control: no-store`:按需处理失败后的降级输出,以及 302 直链跳转,分别见下文。
短链背后的内容只有一种情况会变:管理员合并疑似重复图,被合并一方的链接改为输出保留方的内容,而浏览器与 CDN 已缓存的旧内容在缓存过期前不会更新。见 [生命周期与查重](./lifecycle.mdx#deduplication)。
## 按需缩放与格式 [#on-demand-processing]
在链接后加查询参数,即可拿到缩放或转换格式后的版本:
```text
https://img.example.com/i/R0RjUyP1UW?w=400
https://img.example.com/i/R0RjUyP1UW?w=800&format=webp
https://img.example.com/i/R0RjUyP1UW?format=jpeg
```
| 参数 | 取值 | 说明 |
|---|---|---|
| `w` | 「宽度档」`process_widths` 中的值,出厂 `100,200,400,800,1600` | 按宽度等比缩小;不在档位里返回 400。目标宽度不小于原图宽度时不放大,只按目标格式重新编码 |
| `format` | 「目标格式」`process_formats` 中的值(出厂 `webp`、`jpeg`、`png`),或 `auto` | 显式指定输出格式;不在列表里返回 400 |
宽度与格式都是白名单:放开任意取值会被人用来打满 CPU 与磁盘缓存,所以只开放预设的几档;档位固定,同一组参数的结果也能被反复命中缓存。参数在「设置 → 运行配置」的「按需处理」分组里修改,即时生效。
### WebP 协商 [#webp-协商]
不带 `format`,或 `format=auto` 时,按请求的 `Accept` 头决定输出格式:
- `Accept` 里有 `image/webp`,且 `webp` 在「目标格式」里,输出 WebP。
- 否则保持原格式。协商只会升级到 WebP,不会把 WebP 原图转成别的格式给旧客户端。
- 走协商的响应都带 `Vary: Accept`,包括协商后无需处理、直接输出的原图与 304 响应;显式指定了 `format` 的响应不带。GIF 不参与协商,也不带。
现代浏览器的 `Accept` 都含 `image/webp`,所以浏览器直接打开 JPEG / PNG 的 `/i/` 链接,默认拿到的是 WebP。
要拿原始字节,显式写原图自己的格式,例如 JPEG 原图加 `?format=jpeg`:目标格式与原格式相同且不缩放时,直接输出原图,不重新编码。
### 处理规则 [#处理规则]
- 既不缩放、格式又与原图相同时,不做处理,直接输出原图。
- GIF 一律输出原图,`w` 与 `format` 都被忽略(也不会因为参数非法返回 400),动画得以保留。
- JPEG 原图带 EXIF 方向标记时,先按标记转正再缩放,宽度作用在视觉上的宽度。
- 编码档位:WebP 质量 `process_quality_webp`(出厂 80)、速度权衡 `process_webp_method`(出厂 4);JPEG 质量 `process_quality_jpeg`(出厂 85);PNG 无损、最高压缩。输出不带任何元数据。
- 处理结果缓存在数据目录的 `variants/` 下,总量上限 `variants_cache_mb`(出厂 10240 MB),超出按最近访问时间淘汰。编码档位一改,之后的请求生成新的缓存项,旧的随淘汰清掉。
- 同一张图同一组参数的并发首次请求只处理一次,结果共享。
- 同时处理的数量上限 `process_workers`(出厂 4);等待处理名额与读取原图共用一个超时 `process_timeout_ms`(出厂 5000 毫秒)。
- 原图分辨率超过像素预算,或字节数超过当前的单图上限时,不处理。
处理失败(名额繁忙超时、解码失败等)时降级输出原图,响应为 `Cache-Control: no-store` 且不带 `ETag`,避免把全尺寸原图当成这个带参数链接的内容缓存下来。
## 访问模式:代理与 302 [#访问模式代理与-302]
「设置 → 运行配置」里的「访问模式」`access_mode` 有两档:
- **proxy(出厂)**:ImgYoung 从持有就绪副本的健康渠道里按优先级读取并转发给访客,读失败立即换下一个渠道。
- **redirect**:对能直链的渠道返回 302,访客直接从渠道的公开地址下载,不经本机转发。
出厂默认代理,是因为图片字节经过本机,下面几件事才能同时成立:防盗链每次都生效,按需处理与 WebP 协商可用,渠道读失败即时切换,
访问计数准确,渠道的直链不对外暴露。流量压力交给前置的 CDN,见 [CDN](../operations/cdn.mdx)。redirect 面向没有 CDN、带宽有限的小型 VPS,代价见下表。
redirect 模式下,按优先级在持有就绪副本的健康渠道中找第一个同时满足以下条件的渠道:
- 渠道设置里开了「允许 302 直链」;
- 不是加密渠道(密文不能直接给访客);
- 配置了 `public_base_url`。
302 的目标是 `/<渠道内对象键>`,渠道内对象键形如 `<短ID>_`,不带扩展名。`public_base_url` 要指向渠道 `path` 所对应的那一层目录。
找不到这样的渠道时,照常走代理。渠道的直链与加密设置见 [存储渠道](../channels/index.mdx)。
即使是 redirect 模式,以下请求仍由本机代理输出:
- 需要按需处理的请求,包括带 `w`、显式指定了与原图不同的 `format`,以及协商到 WebP 的请求。由于浏览器都声明支持 WebP,浏览器访问 JPEG / PNG 时默认会走代理转码;想让浏览器访问也走 302,可以把 `webp` 从「目标格式」里去掉。
- 处理失败后的降级输出。
- 命中 `If-None-Match` 的请求直接由本机回 304,不跳转。
| | proxy | redirect |
|---|---|---|
| 图片流量 | 经过本机 | 由渠道直接给访客 |
| 容灾 | 每次请求都可以换渠道读 | 302 指向的渠道出错时访客直接看到错误;本机要等健康探测把该渠道判为异常后才换目标 |
| 按需处理 | 可用 | 需要处理的请求仍走代理 |
| 防盗链 | 每次访问都校验 | 只校验 302 这一步,渠道公开地址本身不受约束 |
| 加密渠道 | 可用 | 不能直链,回落代理 |
302 响应带 `Cache-Control: no-store`,浏览器与 CDN 都不缓存这次跳转:跳转目标会随渠道健康、优先级与直链设置变化,图片删除或到期也要立即生效。跳转本身不带图片字节,每次回到本机的代价只是几次数据库查询。访客每次访问都会先到本机,访问计数照常。
渠道里的对象键不带扩展名,写入渠道时会按内容把 `Content-Type` 一并交给存储后端;对象存储类渠道据此保存类型,浏览器直接打开 302 链接也能正常显示图片。
## 防盗链 [#hotlink-protection]
防盗链按请求的 `Referer` 头决定是否放行,规则在「设置 → 防盗链」管理(仅管理员)。
### 规则写法 [#规则写法]
| 写法 | 匹配 |
|---|---|
| `example.com` | 只匹配 `example.com` 本身 |
| `*.example.com` | 匹配 `example.com` 的任意子域(如 `blog.example.com`、`a.b.example.com`),不匹配 `example.com` 本身 |
- 规则只写主机名,不带协议、端口与路径;不区分大小写,保存时转为小写。
- 允许单段主机名(如 `localhost`)与 IPv4 地址形式;长度不超过 253 个字符。
- 匹配只看 `Referer` 里的主机名,端口不参与。
- 重复添加返回 409。添加与删除都即时生效、不需要重启,并记入审计日志。
### 判定规则 [#判定规则]
- 规则列表为空时,防盗链不启用,任何来源都放行。
- 有规则时:
- 带 `Referer`:主机名命中任一规则才放行;`Referer` 无法解析视为未命中。
- 不带 `Referer`(地址栏直接打开、下载工具、设置了 `Referrer-Policy: no-referrer` 的页面):由「空 Referer 放行」开关决定,即配置 `allow_empty_referer`,出厂开启。
- 被拒绝的请求返回 403(纯文本),不计访问。
作用范围只有 `/i/` 与 `/o/` 两个公开入口。管理后台里的缩略图与预览、S3 的 GetObject 不受影响。
规则只保存在元数据库里,通过后台或 [接口](../api/admin-referer.mdx) 增删,没有对应的环境变量或配置文件。
别忘了把你自己用图的站点(博客、文档站)加进去,包括需要的子域。
`Referer` 可以被伪造或省略;开启「空 Referer 放行」时,不带 `Referer` 的请求都能拿到图。
图片经过 CDN 时,CDN 缓存命中的请求不会回到本机,本机的规则管不到它们,需要在 CDN 侧另配规则;redirect 模式下渠道的公开地址也不在保护范围内。
## 没有私有图片 [#no-private-images]
所有图片链接都是公开的:知道链接就能访问,不需要登录,也没有签名链接或其他访问鉴权。
短 ID 只是一个随机标识,不是密钥:私密性从不依赖它的长度,链接被猜中或枚举到也无妨,因为图片本来就是公开的。
真正的私有图需要短时效的签名链接,而且每次访问都必须由本机校验:不能按一年缓存、不能 302 交给渠道直链。
这与当前围绕「公开、内容不可变、可长期缓存」设计的访问路径相冲突,所以 ImgYoung 不提供私有图片。
不能公开的图片不要传进 ImgYoung;只想限制哪些站点能嵌入,用[防盗链](#hotlink-protection)。
## 后台浏览不计访问 [#console-browsing]
管理后台展示缩略图与预览走的是 `/api/admin/images/<短ID>/thumb`:
- 需要登录会话,不受防盗链约束;
- 与 `/i/` 同一条处理管线(同样支持 `w`、`format`);
- 不计访问次数、不更新最近访问时间、不顺延有效期,也不会把图从待清理列表里摘掉。
管理员翻看图片因此不会干扰访问统计与冷图判定。从后台复制出去的分享链接仍是 `/i/` 地址。
## 访问计数与热门图 [#访问计数与热门图]
计入访问的,是到达图片的 `/i/` 与 `/o/` GET 请求(已通过防盗链、处理参数合法),包括 304 与 302 响应。不计入的有:HEAD 请求、`w` 或 `format` 不合法而返回 400 的请求、后台缩略图与预览、S3 GetObject、被防盗链拒绝的请求。
计数先记在内存里,每隔 `activity_flush_seconds`(出厂 60 秒)批量写入数据库。同一张图在一个周期内无论被访问多少次只加 1,所以「访问次数」反映的是有访问的周期数,不是精确的 PV。不逐请求写库,是因为 SQLite 同一时刻只有一个写者,逐次更新会与上传、清理争抢写锁;冷图阈值以天计,计数用于趋势与排行,这个精度足够。
每次写入时同时:
- 更新「最近访问」时间;
- 摘掉待清理标记;
- 开启「访问即续期」时,把带有效期、尚未到期的图的到期时刻顺延,见 [生命周期与查重](./lifecycle.mdx)。
在后台可以看到:
- 图片详情里的「访问次数」与「最近访问」;
- 管理员总览的「访问次数」合计与「估算流量」,以及按访问次数排序的前 10 张「热门图片」。
估算流量按「访问次数 × 原图大小」粗算:304、302 与衍生图响应都按整张原图计(偏高),同一周期内的重复访问只计一次(偏低)。
需要精确的请求量与流量,用反向代理或 CDN 的日志与 [监控指标](../operations/monitoring.mdx)。
# 生命周期与查重 (/zh/docs/guides/lifecycle)
有效期的三层取值与访问续期、到期清理与三段式删除、冷图待清理的打标与裁决,以及精确重复自动合并与相似重复人工裁决。
## 有效期(TTL) [#有效期ttl]
每张图可以有一个有效期,到期后自动删除。有效期按三层取值,从上往下第一个有定义的生效:
| 层级 | 在哪设置 | 取值 |
|---|---|---|
| 单次上传 | 表单字段 `ttl`、URL 转存的 JSON 字段 `ttl`、后台上传弹窗的「有效期」 | 秒数;`0` 或 `never` 表示永久 |
| 用户默认 | 「用户」页 → 编辑 →「默认有效期(小时)」 | 留空表示随全局默认;`0` 表示永久;正数为小时数 |
| 全局默认 | 「设置 → 运行配置」的「默认有效期」`default_ttl_hours` | `0` 表示永久(出厂值);正数为小时数 |
- 单次上传显式写 `0` 或 `never`,会覆盖用户默认与全局默认,这张图就是永久的。
- S3 上传不能逐次指定,只按用户默认与全局默认取值。
- 到期时刻在上传时算定并保存。之后再改用户默认或全局默认,不影响已有的图。
- 要表达「临时」,给图设一个短有效期即可;没有单独的临时目录或临时上传类型。
- 单次上传、用户默认与全局默认的上限都是 876000 小时(100 年),超出即拒绝。其余换算成时长的运营配置(扫描间隔、删除宽限、保留期、超时等)按各自单位折算,同样以 100 年为上限。
已有图片的有效期在图片详情里修改(图片的归属用户与管理员都可以):
- 「修改有效期」→ 填天数后点「延长」:到期时刻改为从现在起算的 N 天后,这个时长也成为该图新的有效期(访问续期按它顺延)。
- 「设为永久」:去掉到期时刻。
## 访问即续期 [#访问即续期]
「访问即续期」`ttl_renew_on_access` 出厂开启。开启时,带有效期的图每被公开访问一次,到期时刻就顺延为「这次访问被记录的时刻 + 它的有效期」。
效果是有效期变成「连续这么久没人访问才过期」:有人在用的图一直保留,没人看的图到期清理。关闭后,到期时刻固定不变。
- 访问在内存里累计,每 `activity_flush_seconds`(出厂 60 秒)批量写入一次,续期在写入时发生。写入那一刻已经到期的图不再续期:到期是终态。
- 只有 `/i/` 与 `/o/` 的公开访问算数;后台缩略图与预览、S3 GetObject 不算,见 [后台浏览不计访问](./access.mdx#console-browsing)。
- 永久的图不受影响。
## 到期清理 [#到期清理]
清理扫描在进程启动时先跑一轮,之后每 `cleanup_sweep_seconds`(出厂 60 秒)一轮,每轮依次:
1. 删除已到期的图(每轮最多 500 张,积压的下一轮继续);
2. 物理删除已过宽限期的文件(见下节);
3. 给冷图打待清理标记(见 [冷图待清理](#冷图待清理));
4. 回收超过保留期的上传幂等键;
5. 设置了保留天数时,回收过期的审计日志(`audit_retention_days`,出厂 0 表示永久保留)。
图一到期,对外链接就返回 404,也不会再被访问续期;记录与文件由之后的清理扫描回收。在那之前,后台「图片」页里它显示为「已过期」,仍可手动删除。进程停机期间到期的图,在重启后的扫描里补删。
## 删除流程 [#删除流程]
所有删除都走同一条流程:到期、后台删除(单张或批量,一次最多 100 张)、待清理裁决的删除、S3 DeleteObject。
### 删除引用 [#删除引用]
在一个事务里删除这条图片记录(连同指向它的幂等键),并把它所指物理文件的引用计数减 1。链接立即返回 404。
配置了 Cloudflare 缓存清理时,同时为 `/i/<短ID>` 及这张图生成过的衍生图参数组合排队清理 CDN 缓存;`/o/` 别名链接不在清理范围内。见 [CDN](../operations/cdn.mdx)。
### 标记待删 [#标记待删]
引用计数降到 0 时,物理文件被标记为待删除,删除时刻 = 现在 + 删除宽限 `delete_grace_minutes`(出厂 0,即下一轮扫描就删)。
### 物理删除 [#物理删除]
删除时刻过后的那一轮扫描里:先删掉它的衍生图缓存,再在事务内复查它确实无人引用后删除数据库记录,然后为每个持有副本的渠道排队删除对象。
渠道删除异步执行、失败重试;最终没删掉的对象成为孤儿,由对账任务回收。
**引用计数**:一个物理文件可以被多条图片记录共享(查重合并的结果)。删除其中一条不影响其他记录,只有最后一条引用删除后物理文件才会进入删除流程。图片详情里的「引用数」显示当前值。
后台的删除确认框会按当前宽限期提示物理文件大约多久后清理。删除用户之前必须先删掉该用户名下的全部图片。
## 冷图待清理 [#冷图待清理]
长时间没人访问的图会被打上「待清理」标记,进入后台「待清理」页等管理员裁决。**不会自动删除。**
删除不可撤销,所以机器只负责发现,删不删由人决定;相似重复的合并也是同一原则。
- 阈值是「冷图清理阈值」`cold_cleanup_days`,出厂 90 天;设为 0 不再打标。
- 打标:每轮清理扫描时,最近一次公开访问(从未访问过则取上传时间)早于阈值、且未被豁免的图,打上标记。
- 摘标:
- 这张图被公开访问(在下一次访问计数写入时摘掉);
- 管理员裁决「保留」;
- 管理员取消该图的清理豁免(下一轮扫描重新判定);
- 这张图被删除。
- 调大阈值或把阈值设为 0,不会摘掉已有的标记。
- 冷图判定与有效期互不相关:带有效期的图同样可能被标记,被豁免的图照样会到期。
### 裁决 [#裁决]
「待清理」页(仅管理员)按最近访问时间从早到晚列出,可单张或批量操作:
- 「保留」:给图设上清理豁免,之后不再参与冷图打标,有效期与访问续期不受影响。可在图片详情里取消豁免。
- 「删除」:走上面的删除流程。
清理豁免只有管理员能设置,与冷图裁决同属管理员的权限,归属用户无法自己给图打上豁免、绕开回收:图片详情里的「清理豁免」开关可以在图变冷之前预先打开;普通用户看得到状态但不能修改。
「图片」页的筛选「仅看已豁免」列出全部已豁免的图。
## 查重 [#deduplication]
查重不在上传路径上进行,上传不会因此变慢。后台查重循环在进程启动时先跑一轮,之后每 `dedup_scan_seconds`(出厂 300 秒)一轮,每轮先合并精确重复,再扫描相似重复。
### 精确重复:自动合并 [#精确重复自动合并]
实际保存的字节完全相同(SHA-256 相同)的物理文件,合并没有任何风险,自动进行:
- 保留其中最早的一份,其余文件的图片记录改为指向它,引用计数并入;
- 多出来的文件标记待删,按删除宽限期物理删除;
- 跨用户同样合并。每条图片记录仍保留自己的短 ID、归属用户、有效期与访问计数,所有链接继续可用;
- 配额按引用计全额,合并不减少任何用户的已用量。
### 相似重复:人工裁决 [#相似重复人工裁决]
每个物理文件上传时都算了 64 位感知哈希(pHash)。两个文件的哈希差异位数(汉明距离)不超过「相似判定阈值」`dedup_phash_max_distance`(出厂 10)时,作为一对候选进入「疑似重复」页。
- 阈值越大越宽松,误报越多;设为 0 不做相似扫描。后台提示距离 0–2 基本是同一张图。
- 距离为 0 也只进候选、不自动合并:感知哈希相同不代表字节相同。
- 扫描是增量的:每轮只拿新文件与已有文件比较,在内存里逐一计算距离,自部署的规模不需要索引结构;进程重启后会全量重扫一次,已忽略的候选不会再出现。
### 裁决与选择保留哪一张 [#裁决与选择保留哪一张]
「疑似重复」页(仅管理员)把每对候选并排显示为左图、右图,附格式、尺寸、大小、引用数与距离,按距离从小到大排列:
- 「保留这张」→ 确认「合并」:另一张的全部引用改为指向保留的这张,被合并的文件按宽限期清理,涉及它的其他候选一并移除。**不可撤销。**
- 「忽略」:这一对以后不再提示,可批量忽略。
合并后,被合并一方的所有链接(包括其他用户的引用)开始输出保留方的内容:尺寸、格式、画面可能都不同。所以通常保留分辨率更高、格式更合适的一张;两张实为不同图片时选「忽略」。
已经缓存了旧内容的浏览器与 CDN 在缓存过期前仍显示旧图。
合并与忽略都记入审计日志(动作「合并重复图」「忽略重复图」)。
## 相关配置 [#相关配置]
| 配置键 | 后台名称 | 出厂值 |
|---|---|---|
| `default_ttl_hours` | 默认有效期 | 0(永久)|
| `ttl_renew_on_access` | 访问即续期 | 开 |
| `cold_cleanup_days` | 冷图清理阈值 | 90 天 |
| `delete_grace_minutes` | 删除宽限 | 0 分钟 |
| `cleanup_sweep_seconds` | 清理扫描间隔 | 60 秒 |
| `activity_flush_seconds` | 活跃度落库间隔 | 60 秒 |
| `idempotency_key_ttl_hours` | 幂等键保留期 | 24 小时 |
| `dedup_scan_seconds` | 查重扫描间隔 | 300 秒 |
| `dedup_phash_max_distance` | 相似判定阈值 | 10 |
| `audit_retention_days` | 审计日志保留天数 | 0(永久)|
都在「设置 → 运行配置」修改,保存即生效,见 [配置](../configuration.mdx)。
# 管理后台 (/zh/docs/guides/admin)
管理后台各页面的用途、管理员与普通用户的权限差别、配额与上传频控、上传 Token 的完整生命周期,以及登录、会话与界面偏好。
## 登录 [#登录]
管理后台就在站点根路径,例如 `https://img.example.com/`。
首次启动时内置管理员的登录名是 `admin`,密码是出厂默认值 `imgyoung123`。用它登录后,后台会直接进入「设置管理员密码」页。
这项限制在服务端执行:仍用出厂密码的会话只能改密、退出和查看当前账号,其余后台接口(包括后台上传、签发 Token、管理渠道)一律返回 403 `须先修改出厂默认密码`,不论请求来自后台页面还是脚本。
默认密码是公开的,实例暴露到公网之前务必先完成这一步。忘记密码见 [重置密码](../operations/password-reset.mdx)。
没有用来设置初始管理员密码的环境变量,也没有由环境变量提供的引导 Token:这类一次性种子设过一次就不再起作用,却会一直留在部署文件里,让人分不清值从哪来。没有界面的部署,首次登录改密后在后台签发 Token 即可。
## 页面导览 [#页面导览]
左侧栏分三组,普通用户只看得到其中一部分:
| 分组 | 页面 | 谁能看到 |
|---|---|---|
| 内容 | 总览、图片、上传 Token | 全部用户 |
| 站点 | 存储渠道、用户、疑似重复、待清理、审计日志 | 管理员 |
| 其他 | 使用指南 | 全部用户 |
| 其他 | 设置 | 管理员 |
顶栏有页面搜索(⌘K / Ctrl+K 打开命令面板)、语言切换与主题切换。侧栏底部的账号菜单里有「修改密码」与「退出登录」。
### 总览 [#总览]
管理员看到全站概况:
- 指标卡:图片、存储用量(原图字节合计,副本不重复计)、用户、访问次数(附估算流量)、待清理、疑似重复。
- 「存储渠道」:每个渠道的健康状态与已存字节。
- 「告警」:当前未恢复的告警,条件不再成立时自动恢复;「历史记录」跳到设置里的告警历史。告警类型有渠道不健康、副本补齐失败、渠道用量超阈值、渠道排空失败、备份失败。
- 「热门图片」:按访问次数排序的前 10 张。
- 本机磁盘以外没有健康的渠道时,顶部显示「数据只存在这台主机上」:图片副本和元数据库快照都只在这台主机上,主机损坏即全部丢失。判定条件是找不到一个健康、不在清理中、且驱动不是 local 的渠道,一个健康渠道都没有时同样显示。添加一个对象存储或网盘渠道即可解除;已经有了,就到「存储渠道」页查看它为什么不健康。
普通用户看到自己的图片数、已用空间与配额。站里还没有图片时,总览显示三步快速开始。
访问次数与估算流量的口径见 [访问图片](./access.mdx)。
### 图片 [#图片]
图片网格,管理员看全站(「全站图片」),普通用户只看自己的(「我的图片」)。
- 搜索:按短 ID 或文件名。
- 「筛选」:「上传来源 Token」、「上传时间」、「仅看已豁免」;管理员另有「归属用户」。
- 「选择」后可多选「批量删除」,一次最多 100 张。
- 「上传图片」:后台直接上传,见 [上传](./upload.mdx#在管理后台上传)。
点开一张图是详情抽屉:
- 「分享链接」:完整链接,「复制为」URL、Markdown、HTML、BBCode;下方列出可用的缩放宽度,点一下复制带 `?w=` 的链接。设置了对外站点地址时链接用它的域名。
- 元数据:格式、尺寸、大小、归属、来源 Token、引用数、上传时间、最近访问、访问次数、到期(含「修改有效期」)、清理豁免、SHA-256。
- 「副本分布」:这张图在每个渠道上的副本状态(待补齐 / 上传中 / 就绪 / 失败)、尝试次数与最近错误。
- 「删除」;管理员另有「审计记录」,跳到以这张图为目标的审计日志。
有效期与清理豁免的含义见 [生命周期与查重](./lifecycle.mdx)。
### 上传 Token [#上传-token]
签发与管理数据接口的凭证,见下文 [上传 Token](#upload-tokens)。
### 存储渠道 [#存储渠道]
新建、编辑、探测与删除渠道,设置优先级、加密与 302 直链,查看各渠道的副本水位。表单字段与各类渠道的配置细节见 [存储渠道](../channels/index.mdx),这里只列使用后台时要知道的几点:
- 网盘模板在授权控件上方有「应用 Client ID」「应用 Client Secret」两个字段,随渠道配置加密保存。一键授权必须先填 Client ID;粘贴令牌时,如果授权用的是自建应用也要填,因为令牌续期要用签发它的同一个应用。
- 编辑渠道时,凭证类参数显示为掩码:除密码、密钥、令牌外,访问密钥 ID、WebDAV 用户名、Client ID 等也是。不改就保留原值。
- 渠道装载失败(配置解不开、驱动构造失败等)时,列表里显示为「异常」,原因以 `装载失败:` 开头。健康检查每轮自动重试装载,「重新探测」也会先重试装载;装上且探测通过后才恢复健康、重新参与读写。
- 设置里「新渠道默认加密」开启时,新建渠道的「服务端加密」保持「继承全局默认」就会加密,此时「允许 302 直链」不可用;要用直链,把服务端加密设为「强制关闭」。
- 「测试连接」时如果服务商刷新了授权令牌,测试结论下方提示「服务商在测试时刷新了授权令牌,表单已换用新令牌,保存时提交的就是它;原令牌可能已失效。」,授权字段显示「已授权」,接着保存即可。编辑时沿用的已存令牌被刷新,会直接写回该渠道。
- 渠道行菜单的「导出 rclone 配置」(清理中的渠道没有这一项)下载该渠道的 rclone 配置文件 `imgyoung-channel-<渠道 ID>.rclone.conf`,标准 rclone 凭它即可脱离 ImgYoung 读写这个渠道上的数据;加密渠道另有一个加密层远端,经它读出的就是明文。
文件里含渠道凭证与授权令牌(加密渠道还有解密密码),要和主密钥一样离线妥善保存;每次下载都记入审计日志。文件内容与用法见 [渠道存储加密](../channels/encryption.mdx) 与 [存储渠道](../channels/index.mdx)。
删除渠道时,弹窗里的「同时删除本服务写在该渠道上的数据」默认勾选,确认按钮是「删除并清理」:图片副本与元数据库快照在后台逐个删除(深探测留下的残留文件一并删除),期间渠道显示为「清理中」,删完自动移除;不是本服务写的文件不动。
取消勾选,按钮变为「仅移除」:渠道立即移除,远端的图片副本与元数据库快照原样保留,需要时自行到存储商处理。
「删除并清理」删对象失败时:
- 第一次失败就把原因写进渠道的探测结果,在渠道列表里可见,同时开启「渠道排空失败」告警。单次清理任务超时、服务停机打断的不算失败。
- 之后按退避间隔一直自动重试,不会停止;渠道临时不可用,恢复后会自己清完。
- 凭证已失效或存储桶已不存在、再重试也删不掉时,对该渠道点「直接移除」:立即移除渠道,尚未删除的远端对象原样保留。确定删不掉的渠道,删除时也可以一开始就选「仅移除」。
### 用户 [#用户]
列出全部账号及其图片数、用量 / 配额、默认有效期。可以「建号」、「编辑」、「重置密码」、「删除」,见下文 [角色与权限](#角色与权限) 与 [配额与上传频控](#配额与上传频控)。
### 疑似重复与待清理 [#疑似重复与待清理]
两个裁决队列:「疑似重复」并排比较感知哈希相近的图片对,决定合并或忽略;「待清理」列出长期无人访问的冷图,决定保留或删除。见 [生命周期与查重](./lifecycle.mdx)。
### 审计日志 [#审计日志]
管理操作的只追加记录,可按「动作」、「操作者」(用户 ID)、「目标」(图片短 ID、渠道 ID、Token ID 等,精确匹配)与「时间范围」筛选。记录的动作:
| 对象 | 动作 |
|---|---|
| 渠道 | 新建渠道、编辑渠道、删除渠道、导出渠道 rclone 配置 |
| Token | 签发 Token、吊销 Token、轮换 Token、删除 Token |
| 用户 | 新建用户、更新用户(含改密与重置密码)、删除用户 |
| 图片 | 修改图片(有效期、清理豁免)、删除图片(含 S3 DeleteObject)|
| 裁决 | 合并重复图、忽略重复图 |
| 设置 | 修改配置、添加防盗链白名单、移除防盗链白名单 |
| 数据 | 导出数据、立即备份、恢复备份 |
上传本身与到期自动删除不记审计。保留期由「审计日志保留天数」`audit_retention_days` 决定,出厂 0 表示永久保留。
### 使用指南 [#使用指南]
后台内置的速查页,curl、PicGo、S3 客户端的配置示例里直接填好了当前站点的地址,所有用户可见。
### 设置 [#设置]
仅管理员可见,分四个标签:
- 「运行配置」:全部运营配置,按访问与链接、上传限制、生命周期、按需处理、查重、可靠性与渠道、CDN 缓存清理、告警与审计分组;改完点「保存修改」即时生效,不需要重启。
页面下方的「引导配置」卡片只读展示环境变量(监听地址、数据目录、信任的反代网段、`/metrics` 放行网段)、服务端版本与内置的登录限速参数。见 [配置](../configuration.mdx)。
网段类变量未设置时显示出厂默认网段;显式设为空串时,「信任的反代网段」显示「空:不采信任何转发头」,「/metrics 放行网段」显示「空:不对任何地址开放」。
- 「防盗链」:Referer 白名单与「空 Referer 放行」开关,见 [访问图片](./access.mdx#hotlink-protection)。
- 「备份与导出」:元数据库快照按「备份间隔」自动写到每个健康渠道,各渠道各自保留最近几份。快照表列出快照、大小、时刻与「所在渠道」(持有它的渠道,按优先级排列),可「立即备份」、「下载」、「恢复」;另有「导出 JSON」导出全量元数据。
「立即备份」时有渠道没写成会报错,错误形如 `备份失败:快照 imgyoung-….db 有 k/n 个渠道未写成: …`,写成的渠道仍保留这份快照;没有健康渠道时报 `没有健康渠道可存放快照`。「下载」与「恢复」按优先级从持有该快照的健康渠道读取,读不到就换下一个。见 [备份与恢复](../operations/backup.mdx)。
- 「告警历史」:全部告警的开启与恢复记录。告警 Webhook 见 [监控](../operations/monitoring.mdx)。
## 角色与权限 [#角色与权限]
账号只能由管理员在「用户」页创建,不开放注册。角色有两种:
| 能力 | 管理员 | 普通用户 |
|---|---|---|
| 上传(Token、后台、S3)| 可以 | 可以 |
| 查看、删除图片 | 全站 | 只限自己的 |
| 修改图片有效期 | 全站 | 只限自己的 |
| 设置清理豁免 | 可以 | 不可以 |
| 签发、轮转自己的 Token,查看自己 Token 的 S3 配置 | 可以 | 可以 |
| 查看、吊销、删除别人的 Token | 可以 | 不可以 |
| 存储渠道、用户、疑似重复、待清理、审计日志、设置 | 可以 | 不可以 |
普通用户之间互相看不到对方的图片与 Token;访问别人的图片详情得到 404 `图片不存在`。图片链接本身是公开的,知道链接的人都能访问。
可以有多个管理员。内置管理员(用户 ID 为 `default`)受保护:不能被禁用、降级或删除,可以改名。任何管理员都不能删除自己。
在「用户」页:
- 「建号」:登录名、初始密码(至少 8 位,建好即可登录)、角色、配额、默认有效期。
- 「编辑」:改登录名、角色、配额、默认有效期,或勾选「禁用」。禁用立即结束该用户的全部会话,其 Token 也无法再上传。
- 「重置密码」:设新密码,该用户的全部会话立即失效。
- 「删除」:名下还有图片时拒绝,需先删掉图片;删除时其 Token 与会话一并删除,审计记录保留。
## 配额与上传频控 [#配额与上传频控]
- **配额**:在建号或编辑时为每个用户设置,单位 MB 或 GB,留空或填 0 表示不限。按引用计全额:每张图都按其物理文件的完整大小计入,后台查重合并不减少已用量。口径只有这一种,也无法靠上传与别人相同的图来少计用量。上传后会超出配额时返回 403。
用户可在总览看到已用空间与配额,管理员在「用户」页的「用量 / 配额」列查看。
- **上传频率上限**:「设置 → 运行配置」的 `upload_rate_per_min`,对每个用户统一生效,出厂 0 表示不启用;同一用户经 Token、后台、S3 的上传共享额度。
- **上传并发上限**:`max_concurrent_uploads`,全站共享,出厂 8。
超限时的行为与状态码见 [上传](./upload.mdx#格式与上限)。
## 上传 Token [#upload-tokens]
上传 Token 是数据接口(`/api/upload`、`/api/upload/url`)与 S3 协议的凭证,只能用于上传,不能登录后台、也不能调用后台接口。
凭证只有两种:后台用密码加会话,数据接口与 S3 用上传 Token(S3 的签名用的也是同一批 Token)。Token 不能调用后台接口,是为了把它泄露时的影响限制在「以这个用户的身份上传」,后台接口另有 CSRF 防护。Token 的权限也只有上传一种:每个客户端签发一枚、可以单独吊销,已经足以止损。
### 签发 [#签发]
在「上传 Token」页点「签发 Token」:
- 「用途名称」:如 `picgo`、`blog-script`,最长 64 字节。同一用户的有效 Token 不能重名;同名的旧 Token 已过期时,签发新 Token 会自动吊销那条过期记录。
- 「有效期」:永久、7 天、30 天、90 天或 365 天。到期后与吊销同样失效。
签发成功后弹窗显示 Token 明文(形如 `imgy_` 加 43 个字符),**只显示这一次**,关掉就再也看不到。数据库只存它的 SHA-256 与前 12 个字符的识别前缀;Token 是高熵随机串,普通哈希即可,不需要密码那样的慢哈希。
用法是请求头 `Authorization: Bearer `。
### 列表与状态 [#列表与状态]
列表显示名称、识别前缀、状态(有效 / 已过期 / 已吊销)、到期、最近使用与签发时间。「最近使用」大约每分钟批量更新一次。
每张图记着经由哪个 Token 上传,「图片」页可以按「上传来源 Token」筛选。
### 行操作 [#行操作]
点行末的「更多操作」:
| 操作 | 适用 | 作用 |
|---|---|---|
| 「S3 客户端配置」 | 自己的有效 Token | 查看这枚 Token 对应的 S3 access key、secret 与端点,可以反复查看,见 [上传客户端](./clients.mdx) |
| 「轮转」 | 自己的有效 Token | 原子地签发一枚同名新 Token 并吊销旧的。新明文同样只显示一次;旧 Token 与它的 S3 凭证立即失效。原来是永久的仍为永久,原来有有效期的从现在起按同样时长重新计算 |
| 「吊销」 | 有效 Token | 立即失效,使用它的客户端无法再上传,S3 凭证同步失效。记录保留 |
| 「删除记录」 | 已吊销或已过期的 Token | 从列表移除,审计记录保留。有效的 Token 不能删除,需先吊销 |
### 管理员查看别人的 Token [#管理员查看别人的-token]
管理员可以用「按用户查看」切换到任意用户的 Token 列表,对其中的 Token「吊销」或「删除记录」。
「轮转」与「S3 客户端配置」只对本人开放:它们会交出可用的凭证,对别人的 Token 执行等于接管对方的上传权限。
## 会话与安全 [#会话与安全]
| 机制 | 做法 |
|---|---|
| 密码存储 | argon2id(64 MiB 内存、3 轮、单线程),每个密码独立随机盐。密码至少 8 位 |
| 登录限速 | 按客户端 IP 与登录名分别计:各允许连续 5 次尝试,之后每 12 秒恢复 1 次,超出返回 429。登录成功清零。经信任的反向代理时,客户端 IP 取自 `X-Forwarded-For` |
| 防用户名枚举 | 用户名不存在与密码错误返回同样的错误,校验耗时相近 |
| 会话 | 登录后下发 256 位随机会话 ID,放在 `HttpOnly`、`SameSite=Lax` 的 cookie `imgyoung_session` 里,客户端到站点是 HTTPS 时加 `Secure`。服务端只存哈希,数据库泄露也不能据此冒用会话。有效期固定 30 天,不随使用延长 |
| CSRF | 会话绑定一个 CSRF 令牌,所有改动状态的请求必须在 `X-CSRF-Token` 头里带上它,后台页面自动处理 |
| 出厂密码 | 内置管理员仍用出厂默认密码时,服务端只放行改密、退出与查看当前账号,其余后台接口返回 403 |
| 改密 | 「修改密码」需要旧密码;改完该用户全部设备下线,需要重新登录 |
| 禁用与重置 | 禁用或重置密码立即结束该用户的全部会话;被禁用的用户无法登录,其 Token 全部失效 |
| 页面防护 | 全站响应带 `X-Frame-Options: DENY` 与 `X-Content-Type-Options: nosniff`;后台页面另带内容安全策略(CSP):脚本与接口请求只允许来自本站,不允许被其他页面嵌入 |
HTTPS 判定与客户端 IP 都依赖反向代理的转发头,只有来自 `IMGYOUNG_TRUSTED_PROXIES` 网段的转发头才被采信。见 [反向代理](../operations/reverse-proxy.mdx)。
## 界面语言与主题 [#界面语言与主题]
- 语言:顶栏的语言按钮在简体中文与 English 之间切换。首次访问时按浏览器语言选择(中文环境用简体中文,其余用英文),之后记住你的选择一年。
- 主题:顶栏的主题按钮可选浅色、深色、跟随系统,默认深色,选择同样会记住。
两项偏好都只保存在当前浏览器里,不随账号同步。
# 渠道与容灾 (/zh/docs/channels)
渠道是什么、上传写到哪里、副本如何补齐、访问如何切换,以及健康探测、编辑、删除与用量告警的规则。
## 渠道是什么 [#渠道是什么]
一个渠道就是一个 rclone 后端实例:一种后端(`s3`、`webdav`、`local`……)加一组后端参数,再加上 ImgYoung 自己的几项属性:
| 属性 | 含义 |
|---|---|
| 渠道名 | 唯一,最长 64 字节,用于界面、日志、指标与告警 |
| 优先级 | 整数,越大越优先;后台表单接受 0–1000,新建默认 100 |
| 服务端加密 | 是否经 rclone crypt 加密存储,创建时固化,见[渠道存储加密](./encryption.mdx) |
| 允许 302 直链 | 访问时是否可以 302 跳转到渠道的公开地址,见[对象存储与 WebDAV](./object-storage.mdx) |
后端参数以映射直接交给 rclone,不经过 `rclone.conf`;参数整体用主密钥加密(AES-256-GCM)后存入元数据库。渠道在管理后台的「存储渠道」页创建与管理,只有管理员可见;对应接口见[渠道管理接口](../api/admin-channels.mdx)。接口返回的渠道对象(列表、创建、编辑、重新探测的响应,以及元数据导出里的 `channels`)带 `created_at`:渠道的创建时刻(UTC),参与加密渠道的密钥派生,建后不变。
**每个渠道最终都持有全部图片的副本**:多个渠道之间是全量镜像,不是分片。规划容量时按每个渠道都要放下全站图片来算。
每日对账只回收本服务写出的孤儿对象,必须同时满足:键名是本服务的对象键格式 `<短 ID>_`;元数据库里没有该渠道上任何状态的副本记录;修改时间不早于渠道的绑定时刻;已超过 48 小时。深探测残留 `.imgyoung-probe-*` 过了 48 小时也会回收。别的程序写进同一路径的文件只计数(对账日志字段 `foreign`),不会删除。两个 ImgYoung 实例不能共用同一个渠道路径:对方写出的对象在本实例看来就是孤儿,会被删除。
绑定时刻是渠道记录开始对应当前存储位置的时刻:新建渠道时等于创建时刻,[编辑](#编辑渠道)改动配置时重置;从快照恢复(后台恢复,或把快照放成 `<数据目录>/imgyoung.db.restore` 再启动)时全部渠道一起重置。完整规则见[元数据库备份](../operations/backup.mdx)的「对账 GC」。
## 内置 local 渠道 [#内置-local-渠道]
全新的元数据库(渠道表为空)首次启动时,会自动建一个名为 `local` 的渠道:后端 `local`,优先级 100,存储位置是 `<数据目录>/blobs`。这样不做任何配置也能上传。
- 只在渠道表为空时播种。删除或改名之后,重启不会让它重新出现。
- 它和元数据库、主密钥在同一台机器上,主机损坏即全部丢失。只有它一个渠道时,「存储渠道」页会提示「只有本机磁盘一个渠道」。接入一个异地渠道后,存量图片会自动补齐过去;元数据库快照每轮写到每个健康渠道,下一轮备份起新渠道上也有快照。
## 优先级:主渠道与备份渠道 [#优先级主渠道与备份渠道]
ImgYoung 不设单独的「主渠道」开关,主备完全由优先级表达:
- **主渠道**:健康、且不在清理中的渠道里优先级最高的一个。列表里标为「主写」。
- **备份渠道**:其余渠道。正常情况下上传不直接写它们,由异步补齐把副本复制过去;主渠道写入失败顺延、或 `min_success` 大于 1 时才会同步写入。
- 优先级相同时,先创建的渠道排在前面。
主渠道变为异常时,下一个健康渠道自动接替,无需人工切换;它恢复健康后重新成为主渠道。
## 上传写入 [#上传写入]
每次上传都按优先级从高到低遍历当前健康的渠道,同步写入,直到成功份数达到 `min_success`:
1. 写入某个渠道失败(含单次 10 分钟的写入超时),顺延到下一个健康渠道。
2. 凑够份数即返回成功。第一个写成功的渠道就是这张图的首个副本所在。
3. 其余渠道(包括暂时异常的渠道)都记一条「待补齐」副本,交给异步补齐。
`min_success` 在「设置 → 运行配置 → 访问与链接 → 最少同步副本」,默认 1,最小 1。健康渠道不足或全部写失败时,上传返回 503 `存储渠道暂不可用,请稍后重试`(S3 协议上传返回 503 `ServiceUnavailable`),不落元数据;已写成功的那几份对象成为孤儿,由每日对账回收。
`min_success` 大于当前健康渠道数时,所有上传都会失败。设为 2 的意思是:至少两个渠道同时写成功才算上传成功,换来的是写入时延与对多渠道可用性的依赖。
## 异步补齐 [#异步补齐]
副本状态存在元数据库的副本表里,这张业务表是唯一依据;任务队列(River,同一个 SQLite 库)只负责触发,同一张图在同一渠道上同时最多只有一个在途任务。
| 副本状态 | 含义 |
|---|---|
| `pending` | 待补齐 |
| `uploading` | 补齐进行中 |
| `done` | 就绪,可用于访问 |
| `failed` | 重试用尽仍失败 |
- **复制过程**:从优先级最高、健康且已有就绪副本的其他渠道读出,流式写入目标渠道。任务队列 4 个并发(与删除、排空任务共用),单个补齐任务超时 15 分钟。
- **失败重试**:失败后回到 `pending`,由 River 按指数退避重试:第 n 次失败后约等 n⁴ 秒(1 秒、16 秒、81 秒……),最多 25 次,前后约 20 天;最后一次仍失败记为 `failed`。
- **周期对账**:启动时与之后每 10 分钟各做一次:为每个在役渠道补种缺失的副本行;把健康渠道上的 `failed` 副本复位为 `pending`;把全部未完成副本重新入队(已在队列中的自动去重)。后台「设置 → 运行配置 → 引导配置」里显示的「副本对账间隔」就是它。
- **进程重启不丢**:任务和副本状态都在 SQLite 里。正常停机时在途的补齐任务被取消,副本状态留在业务表里,下次启动由对账重新入队;进程被强杀时正在执行的任务,约 16 分钟后由 River 重新排程。
- **新渠道回填存量**:创建渠道时,在同一个事务里为全部存量图片登记待补齐副本,随后立即开始补齐。与创建同时进行的上传如果漏登记了新渠道,周期对账会补上。
- **每日对账 GC**:每 24 小时对健康渠道做一次双向核对。本服务写出、却没有任何副本记录的对象超过 48 小时后删除(孤儿,判定条件见本页开头);元数据库记为就绪、渠道上却不存在的副本改回 `pending` 重新补齐(幻影)。
## 访问容灾 [#访问容灾]
访问一张图时,候选渠道是:**健康、不在清理中、且这张图的副本已就绪**的渠道,按优先级排序。
- **代理模式**(`access_mode = proxy`,默认):按顺序读取,某个渠道读失败就切到下一个,对访问者透明。没有任何候选或全部读失败时返回 503 `渠道暂不可用`。
- **302 模式**(`access_mode = redirect`):候选中第一个「允许 302 直链、未加密、配置了公开访问地址」的渠道直接 302 跳转;没有这样的候选就回到代理。带处理参数的请求(如 `?w=`)始终走代理。
副本还没补齐到某个渠道时,该渠道不会成为这张图的候选。访问模式与缓存细节见[访问与链接](../guides/access.mdx)。
## 健康探测 [#健康探测]
健康状态决定一个渠道能否承接上传与访问。探测分两档:
| | 轻探测 | 深探测 |
|---|---|---|
| 做什么 | 查询一个约定不存在的对象 `.imgyoung-health-probe`(从不写入);查到或得到「不存在」都算可达 | 写入一个随机键的小对象 `.imgyoung-probe-<随机十六进制>`,读回核对内容,再删除 |
| 成本 | 只读,零写删 | 一次写、读、删 |
| 超时 | 10 秒 | 写与读共 20 秒,删除另有 10 秒 |
| 何时运行 | 周期循环,每 `health_check_seconds`(默认 60 秒)| 创建渠道后、编辑保存后、点「重新探测」、点「测试连接」;周期循环里异常渠道的轻探测通过时 |
周期循环的规则是**轻探测只降不升**:
- 健康渠道每轮只做轻探测,失败即标为异常。
- 异常渠道先做轻探测;通过后再做一次深探测,写、读、删全部通过才恢复健康。
- 只读成功不能推翻深探测的失败结论。例如只读凭证能通过轻探测,却写不进去,这类渠道不会被轻探测「救活」。
周期探测只用只读的轻探测:对按量计费的存储,每分钟写删一次探测对象是无谓的费用;写路径的问题由事件触发的深探测与恢复前的确认把关。
各渠道并行探测、各自计时,一个慢渠道不会拖累其他渠道。清理中的渠道不参与探测。停机时正在进行的一轮作废,不写入结果。
探测结果写回元数据库,在「存储渠道」页的「状态」列可见:「健康」「异常」或「清理中」;鼠标悬停在「异常」上显示失败原因(已翻译成排障提示,不含凭证);下方小字是最近一次探测的时间,从未探测过显示「未探测」。常见失败原因与排查见[对象存储与 WebDAV](./object-storage.mdx)的「常见报错与排查」。
某个渠道的配置无法解密、或后端初始化失败时,该渠道会被跳过(日志 `渠道装载失败,本渠道跳过,健康检查时重试`),其余渠道照常服务。它在列表里显示为「异常」,失败原因以 `装载失败:` 开头。健康检查每一轮都会先重试装载,点「重新探测」也会先尝试装载;装上之后还要通过一次深探测才恢复健康、参与选路。启动时全部渠道都无法装载,则启动失败。
## 副本水位 [#副本水位]
「副本」列显示该渠道上的副本统计:「就绪」「待补」(含补齐进行中)「失败」。待补长期不降、或出现失败,说明这个渠道写入有问题:先看状态列的失败原因,点「重新探测」确认。渠道恢复健康后,失败副本会在下一次周期对账时重新补齐。
## 测试连接 [#测试连接]
创建与编辑表单底部的「测试连接」用表单当前内容做一次深探测,**不保存、不建渠道**:
- 通过显示「连接正常(写读删探测通过)」;失败显示「连接失败:」加原因,原因同样经过翻译,并抹掉表单里填的凭证。
- 不会创建存储桶,也不套加密层:验证的是后端连通性与写、读、删权限。
- 编辑时,保持掩码的凭证会先还原成已保存的原值再探测;沿用的已存令牌如果在测试中被刷新,新令牌直接写回该渠道。
- 本次表单里提交的令牌如果在测试中被服务商刷新(失败时也可能发生),新令牌交还给表单:结论下方提示「服务商在测试时刷新了授权令牌,表单已换用新令牌,保存时提交的就是它;原令牌可能已失效。」,授权栏显示「已授权」,之后保存提交的就是新令牌。详见[网盘与 OAuth](./cloud-drives.mdx)。
## 创建渠道 [#创建渠道]
「存储渠道」页点「新建渠道」,先选存储类型,再填表单:
选类型:「对象存储」「网盘」下是内置模板,「高级」下是「自定义」(直接填 rclone 后端名与参数)。各模板的字段见[对象存储与 WebDAV](./object-storage.mdx)与[网盘与 OAuth](./cloud-drives.mdx)。
填写渠道名、模板字段、优先级,选择「服务端加密」;对象存储可开「允许 302 直链」与「自动创建存储桶」。
点「测试连接」,通过后点「创建」。
创建时先用填写的参数试构造一次后端(需授权的模板还会先补齐授权带出的参数,如 OneDrive 的驱动器),参数非法直接拒绝;入库后立即做一次深探测。深探测失败不会撤销创建:渠道保留,状态为「异常」,并提示「渠道已创建,但探测失败:」加原因,改好配置后点「重新探测」即可。
## 编辑渠道 [#编辑渠道]
行菜单「编辑」打开与创建相同的表单,可以改:渠道名、模板字段(或自定义参数)、优先级、「允许 302 直链」。
- **不能改**:「服务端加密」(表单只显示「已加密」或「未加密」,接口拒绝变更并返回 409);存储类型(编辑沿用渠道创建时的模板;自定义渠道与内置 local 渠道以自定义表单打开);「自动创建存储桶」只在创建时出现。
- **凭证掩码回读**:编辑表单回读配置时,凭证不下发,以固定的掩码值 `__imgyoung_secret_keep__` 代替。掩码范围以 rclone 后端的选项元数据为准:rclone 标记为密码或敏感信息的参数都掩码,除密码、密钥与授权令牌外,还包括访问密钥 ID(Access Key、SecretId、Key ID)、WebDAV 用户名、应用 Client ID、OneDrive 的 `drive_id` 等;模板表单与自定义参数同一口径,这些栏以掩码输入框显示。保持掩码原样提交即沿用已保存的原值,输入新值即替换;掩码值填在不属于凭证的参数上会被拒绝(400)。
- 配置整体替换,不做增量合并。保存后只重建这一个渠道的驱动实例,其他渠道正在进行的读写不受影响;随后立即深探测,失败时提示「渠道已保存,但探测失败:」加原因。
- **绑定时刻重置**:后端名或任何配置参数(包括公开访问地址)有改动,即把渠道的绑定时刻重置为保存时刻。重新输入同一个密码也算改动,因为 rclone 编码密码时带随机 IV。只改渠道名、优先级或「允许 302 直链」不重置,令牌刷新写回也不重置。重置之后,新位置上早于这一刻的本服务格式对象只计数、不回收,见[元数据库备份](../operations/backup.mdx)的「对账 GC」。
编辑不会触发回填。改了桶、前缀或服务端点后,这个渠道上原有的副本仍记为就绪,读取会失败并切到其他渠道,直到每日对账发现对象缺失、重新补齐。要换存储位置,应新建一个渠道,等「副本」列的「待补」归零后再删除旧渠道。
## 删除渠道 [#删除渠道]
行菜单「删除」打开确认框,勾选项「同时删除本服务写在该渠道上的数据」决定走哪条路径:
| | 删除并清理(默认勾选)| 仅移除(取消勾选)|
|---|---|---|
| 立即效果 | 渠道转为「清理中」,立刻退出上传选路、补齐与探测 | 渠道与其副本记录立即删除 |
| 远端对象 | 本服务写的对象(图片副本、元数据库快照等)由后台排空任务逐个删除,删完自动移除渠道;别的程序写的文件不动 | 图片副本与元数据库快照原样保留,需要时自行到存储商处理 |
| 适用 | 正常下线一个渠道 | 凭证已失效、桶已不存在,或清理卡住 |
两条路径都有**唯一副本保护**:如果某张图在其他渠道上没有就绪副本(清理中的渠道不算),删除会被拒绝,返回 409 `该渠道持有部分图片的唯一副本,删除会丢数据;请先迁移或删除相关图片`。检查与删除在同一事务中完成,两个渠道先后删除也不会把同一张图的副本删光。在其他渠道上仍是「待补」的副本不算数,等它们补齐后再删。
清理的细节:
- 排空任务先按副本记录逐批删除图片对象并删掉对应记录,进度保存在元数据库里,中断后从剩余部分继续。
- 图片副本删完后,再删 `_imgyoung/` 下的全部对象(含 `_imgyoung/backup/` 下的元数据库快照)与根目录下的深探测残留 `.imgyoung-probe-*`,然后才删渠道记录。别的程序写的文件不动。完成时的日志 `渠道已清理完毕并删除` 带字段 `objects`(图片副本数)与 `system_objects`(系统对象数)。
- 单次排空最长 1 小时,超时后由下一次排空从剩余部分继续。
- 删除对象一失败,原因就写进状态列(以 `清理渠道对象失败,会自动重试` 开头),同时开启「渠道排空失败」告警。排空按退避持续自动重试,不会停止,渠道恢复可用后会自行清完。排空自身超时或停机取消不算失败。
- 凭证已失效或存储桶已不存在时,用行菜单里唯一的「直接移除」:停止清理并立即移除渠道,尚未删除的远端对象原样保留。
## 导出 rclone 配置 [#导出-rclone-配置]
行菜单「导出 rclone 配置」(清理中的渠道没有这一项)下载渠道的 rclone 配置文件 `imgyoung-channel-<渠道 ID>.rclone.conf`。标准 rclone 凭它就能脱离 ImgYoung 读写这个渠道上的数据:
- 远端 `imgyoung-<渠道 ID>`:渠道的后端,参数与 ImgYoung 交给后端的完全一致,含访问凭证与授权令牌。
- 远端 `imgyoung-<渠道 ID>-crypt`:只有加密渠道才有,是指向渠道存储路径的加密层,经它读写的就是明文。
- 文件开头的注释写明各远端的含义与用法示例。
文件里有渠道凭证(加密渠道还有解密密码),弹窗提示「和主密钥一样离线妥善保存:拿到它的人可以直接读写这个渠道上的全部数据。」。每次下载都记一条审计日志「导出渠道 rclone 配置」。文件的用法与注意事项见[渠道存储加密](./encryption.mdx)的「自救」。
## 用量监控与告警 [#用量监控与告警]
渠道用量按「该渠道上已就绪副本的图片大小之和」统计,是逻辑用量:不含元数据库快照与孤儿对象,也不等于存储商账单。
- 每 `usage_report_seconds`(默认 300 秒)刷新一次,写入指标 `imgyoung_channel_storage_bytes{channel="…"}`;「总览」页的「存储渠道」卡片展示各渠道的已存字节与占比。
- 告警阈值是「设置 → 运行配置 → 可靠性与渠道 → 渠道用量告警阈值」(`channel_quota_gb`,单位 GB,按 2³⁰ 字节计);0 表示只上报、不告警。超过阈值开启「渠道用量超阈值」告警。
与渠道相关的告警共四类,和健康探测同一节奏评估,记录在「设置 → 告警历史」,配置了告警 Webhook 时同步外发:
| 告警 | 开启条件 | 恢复条件 |
|---|---|---|
| 渠道不健康 | 在役渠道健康标记为异常 | 探测恢复健康 |
| 副本补齐失败 | 渠道上存在 `failed` 副本 | 渠道上不再有 `failed` 副本(渠道健康时,周期对账会把它们复位重试)|
| 渠道用量超阈值 | 用量大于阈值 | 用量回落或阈值调高 |
| 渠道排空失败 | 清理中渠道删除对象失败 | 渠道被移除(清完自动移除或直接移除)|
其他相关指标:`imgyoung_channels_healthy_total`(健康渠道数)、`imgyoung_channels_total`(已装载且不在清理中的渠道数,含异常渠道)、`imgyoung_channel_puts_total{channel, result}`(向渠道写对象的次数,`result` 为 `ok` 或 `error`,含同步写入与异步补齐)。指标与告警的接入见[监控与告警](../operations/monitoring.mdx)。
## 接下来 [#接下来]
# 对象存储与 WebDAV (/zh/docs/channels/object-storage)
内置对象存储模板逐个要填什么、为什么要预先建桶、公开访问地址与 302 直链、自定义模式与通用 WebDAV,以及探测失败时的报错与排查。
## 选择存储类型 [#选择存储类型]
「存储渠道」页点「新建渠道」后先选类型:
- **对象存储**:Amazon S3、Cloudflare R2、腾讯云 COS、阿里云 OSS、Backblaze B2、MinIO / 自建、S3 兼容(通用)。底层都是 rclone 的 `s3` 后端,差别只在预设的 `provider`、端点与区域。
- **网盘**:WebDAV(本页后半部分),以及需要授权的网盘(见[网盘与 OAuth](./cloud-drives.mdx))。
- **高级**:「自定义」,直接填 rclone 后端名与参数。
模板只暴露必要字段,其余参数由模板预设。保存时按模板合并出完整的后端参数;编辑时再从已存参数反解回表单,拼进端点的字段(账户 ID、区域)也会还原。
## 对象存储模板 [#对象存储模板]
| 模板 | 需要填写 | 模板自动设置 |
|---|---|---|
| Amazon S3 | Access Key、Secret Key、区域(下拉)、存储桶 | `provider=AWS`;端点由区域推导 |
| Cloudflare R2 | Access Key、Secret Key、账户 ID、存储桶 | `provider=Cloudflare`、`region=auto`、`endpoint=https://<账户 ID>.r2.cloudflarestorage.com` |
| 腾讯云 COS | SecretId、SecretKey、区域(下拉)、存储桶 | `provider=TencentCOS`、`endpoint=cos.<区域>.myqcloud.com` |
| 阿里云 OSS | Access Key、Secret Key、区域(下拉)、存储桶 | `provider=Alibaba`、`endpoint=<区域>.aliyuncs.com` |
| Backblaze B2 | Key ID、Application Key、区域(手填)、存储桶 | `provider=Other`、`endpoint=https://s3.<区域>.backblazeb2.com` |
| MinIO / 自建 | Access Key、Secret Key、服务端点、存储桶 | `provider=Minio` |
| S3 兼容(通用)| Access Key、Secret Key、S3 API 端点、存储桶 | `provider=Other` |
每个对象存储模板还有一个可选的「公开访问地址」,见下文。字段说明:
- **存储桶**:桶名,可以接 `/前缀` 把渠道限定在桶内的一个目录,如 `my-bucket` 或 `my-bucket/imgyoung`。这个值就是渠道的存储路径。腾讯云 COS 的桶名要带 APPID 后缀,如 `mybucket-1250000000`。
- **区域**:AWS、COS、OSS 的下拉只列常用区域;阿里云的取值带 `oss-` 前缀(如 `oss-cn-hangzhou`)。所需区域不在列表里时,用「S3 兼容(通用)」手填端点。B2 的区域手填(如 `us-west-004`),要与 B2 后台显示的端点一致。
- **账户 ID**:Cloudflare 账户 ID,用来拼 R2 端点。
- **服务端点 / S3 API 端点**:完整 URL,如 `https://minio.example.com`。
## 预先建好存储桶 [#预先建好存储桶]
ImgYoung 给所有 `s3` 渠道默认加上 `no_check_bucket=true`:rclone 上传前不再检查或创建存储桶。
原因:rclone 默认会在上传前尝试创建桶。R2、MinIO 等常用的作用域凭证只对单个桶有读写权限、没有建桶权限,桶已存在时这次尝试会返回 403,导致写入失败;而周期轻探测是只读的,探不出问题,表现为「状态健康,上传却失败」。对接的桶都是预先建好的,所以默认跳过这一步。
因此:
- **先在存储商控制台建好桶**,再创建渠道。
- 凭证确有建桶权限、又想让 ImgYoung 建桶时,创建表单里打开「自动创建存储桶」:创建时尝试建一次桶(桶已存在不报错),失败则整个创建中止,返回 `渠道参数非法: 自动创建存储桶失败(请确认密钥具备建桶权限,或先在控制台建好桶并关闭此项): ` 加原因。这个开关只在创建时出现,「测试连接」不会建桶。
- 自定义模式里显式写 `no_check_bucket=false`,rclone 会恢复上传前检查并按需建桶的行为。
## 公开访问地址与 302 直链 [#公开访问地址与-302-直链]
「公开访问地址」(`public_base_url`)是可选项:存储桶(或桶前面的 CDN)对外提供公开读取的 URL 前缀,如 `https://cdn.example.com`。配置后,这个渠道才能 302 直链。
直链地址是 `<公开访问地址>/<对象键>`,对象键相对渠道的存储路径。存储桶一栏填了前缀时,公开访问地址也要对应到这个前缀:例如存储桶填 `my-bucket/imgyoung`,而 CDN 指向整个桶,公开访问地址就应写 `https://cdn.example.com/imgyoung`。末尾的 `/` 会被去掉。
一张图会被 302 跳转,需要同时满足:
1. 「设置 → 运行配置 → 访问与链接 → 访问模式」为 `redirect · 302 直链`;
2. 渠道打开了「允许 302 直链」,且未加密;
3. 渠道配置了公开访问地址;
4. 渠道健康,这张图在它上面的副本已就绪,并且是按优先级排在最前的满足上述条件的渠道。
任一条件不满足就走代理。打开「允许 302 直链」却没填公开访问地址时,表单提示「未配置 public_base_url,302 直链不会生效,访问将回落代理」。
302 响应带 `Cache-Control: no-store`,浏览器与 CDN 都不缓存这次跳转,渠道切换、下线或图片到期后立即生效。对象写入渠道时按内容识别图片类型并设好 `Content-Type`,对象键虽然不带扩展名,浏览器直接打开直链也能正常显示。
存储桶的公开读权限要在存储商处自行开启,ImgYoung 不做检查。公开可读意味着拿到直链的人可以绕过 ImgYoung 直接访问对象,防盗链只对经过 ImgYoung 的请求生效。访问模式的完整说明见[访问与链接](../guides/access.mdx)。
## 自定义模式 [#自定义模式]
「高级 → 自定义」直接填写 rclone 后端名与参数,适合模板没有覆盖的后端(rclone 的任意后端都可用,如 `sftp`、`ftp`、`azureblob`、另一块磁盘上的 `local`)。
- **rclone 驱动**:后端名,即 `rclone.conf` 里 `type` 的值,如 `s3`、`sftp`、`local`。
- **后端参数**:键值对,键是该后端的选项名,与 `rclone.conf` 里的写法一致,只允许字母、数字与下划线。从 `rclone config show` 的输出里照抄即可(`type` 一行填到「rclone 驱动」)。
- 两个约定键不交给 rclone:`path` 是后端内的存储路径(桶名及前缀,或目录);`public_base_url` 是公开访问地址。`local` 后端不填 `path` 时使用 `<数据目录>/blobs`。
密码类参数(rclone 标记为密码的选项,如 `sftp`、`ftp`、`webdav` 的 `pass`)在 rclone 里要以编码后的形式保存。ImgYoung 自动处理:填明文会被编码;已经是编码结果的值(例如从 `rclone.conf` 复制来的)原样保留。极少数明文密码恰好形似编码结果,会被原样放过,表现为认证失败;遇到这种情况先执行 `rclone obscure <密码>`,把输出填进去。
```text title="示例:SFTP 渠道"
rclone 驱动 sftp
后端参数 host = files.example.com
user = imgyoung
pass = <密码>
path = /srv/imgyoung
```
## WebDAV [#webdav]
通用 WebDAV 模板在选类型的「网盘」分组里,覆盖坚果云、Nextcloud / ownCloud、群晖、Alist 等。这些服务底层都是 rclone 的 `webdav` 后端,只是服务地址与「服务类型」不同,所以只有一个模板。
| 字段 | 说明 |
|---|---|
| 服务地址 | WebDAV 根地址。坚果云 `https://dav.jianguoyun.com/dav/`;Nextcloud / ownCloud `https://域名/remote.php/dav/files/用户名/`;Alist `https://域名/dav/`;群晖 `https://域名:5006/` |
| 服务类型 | 选错会导致列目录或分片上传行为异常;拿不准选「其他(坚果云 / Alist / 群晖等)」 |
| 用户名 | WebDAV 账号 |
| 密码 | 坚果云必须用「应用密码」,不能用登录密码,在坚果云的安全选项里生成 |
| 存储目录 | 服务内用于存放图片的目录,默认提示 `imgyoung`;留空存放在根目录 |
「服务类型」的选项与 rclone `vendor` 参数的对应:
| 选项 | `vendor` |
|---|---|
| Nextcloud | `nextcloud` |
| ownCloud 10 | `owncloud` |
| ownCloud Infinite Scale | `infinitescale` |
| SharePoint Online | `sharepoint` |
| SharePoint(NTLM 自建)| `sharepoint-ntlm` |
| Fastmail Files | `fastmail` |
| rclone serve webdav | `rclone` |
| 其他(坚果云 / Alist / 群晖等)| `other` |
WebDAV 渠道没有公开访问地址,表单里也不显示「允许 302 直链」与「自动创建存储桶」,访问始终走代理。阿里云盘、百度网盘等国内网盘也通过 WebDAV 接入,做法见[网盘与 OAuth](./cloud-drives.mdx)。
## 常见报错与排查 [#常见报错与排查]
「测试连接」的失败原因,以及渠道状态列「异常」的悬停原因,都按底层错误的特征翻译成下表的提示(不区分大小写,自上而下匹配第一条);无法归类的错误显示原文的前 200 个字符,「测试连接」还会抹掉其中的凭证(rclone 标记为密码或敏感信息的参数值)。
| 提示 | 底层错误特征 | 排查 |
|---|---|---|
| `认证失败(403):检查 Access Key / Secret Key 及其权限` | `InvalidAccessKeyId`、`SignatureDoesNotMatch`、`StatusCode: 403`(只出现在 S3 类后端)| 核对密钥是否抄错、是否已吊销,以及是否对这个桶有读、写、删权限。只读密钥会在深探测的写入环节失败 |
| `应用凭证无效:检查 Client ID / Client Secret 是否与签发令牌的应用一致` | `invalid_client`、`unauthorized_client` | 网盘渠道的「应用 Client ID」「应用 Client Secret」填错,或与签发令牌的应用不是同一个 |
| `授权已失效:刷新令牌被拒绝,请重新授权` | `invalid_grant` | 令牌被吊销或已过期太久,在编辑表单里点「重新授权」 |
| `认证失败(401):检查用户名、密码或授权令牌是否有效` | `401 Unauthorized`、`unauthenticated`、`InvalidAuthenticationToken` | WebDAV 的用户名或密码(坚果云要用应用密码),或网盘的授权令牌 |
| `无访问权限(403):检查账号对存储位置的权限` | `AccessDenied`、`access denied`、`403 Forbidden` | 非 S3 后端的账号对存储目录没有读、写或删权限 |
| `存储桶不存在或无权访问:检查桶名与凭证权限` | `NoSuchBucket`、`no such bucket`、`BucketRegionError` | 核对桶名(COS 带 APPID 后缀);确认桶已建好 |
| `区域不匹配:检查所选区域与存储桶实际所在区域` | `PermanentRedirect`、`AuthorizationHeaderMalformed` | 区域或端点与桶的实际区域不一致 |
| `无法解析服务端点域名:检查 endpoint 配置` | `no such host`、`server misbehaving` | 端点拼写错误,或服务器 DNS 不通 |
| `无法连接服务端点:检查 endpoint 地址与端口` | `connection refused`、`connection reset` | 端口、协议或防火墙问题 |
| `TLS 证书校验失败:检查端点证书或改用正确协议` | `certificate`、`x509`、`tls handshake` | 自签证书,或把 `http` 服务写成了 `https` |
| `连接超时:检查端点地址与网络连通性` | 超时(含探测自身的 10 / 20 秒超时)| 网络不通或服务过慢 |
| `存储路径无写入权限:检查目录权限` | `permission denied`、`read-only file system` | `local` 渠道的目录权限;容器内以 uid 65532 运行时,挂载目录要对它可写 |
保存表单时的常见拒绝(提交即返回,不会建出渠道):
| 提示 | 原因 |
|---|---|
| `渠道参数非法: 字段 必填` | 模板必填字段为空,字段以 key 指称,如 `渠道参数非法: 字段 bucket 必填` |
| `渠道名已存在` | 渠道名重复(409)|
| `渠道参数非法: 初始化 rclone 后端 "…": …` | 用填写的参数试构造后端失败,原因见冒号后的 rclone 原文 |
| `渠道参数非法: 未知的 rclone 后端 "…"` | 自定义模式的驱动名拼错 |
| `渠道参数非法: 配置键 "…" 非法(仅允许字母数字下划线)` | 自定义参数的键含其他字符(如 `-`)|
| `渠道参数非法: 自动创建存储桶失败(…)` | 打开了「自动创建存储桶」,但凭证没有建桶权限 |
| `渠道参数非法: 后端 "onedrive" 授权后的配置失败: …` | 新授权的 OneDrive 渠道选定驱动器时向服务商查询失败 |
| `渠道参数非法: 字段 "…" 不是凭证,不能沿用已存值` | 掩码值 `__imgyoung_secret_keep__` 填在了不属于凭证的参数上 |
| `渠道参数非法: 字段 "…" 无已存原值可保留,请重新填写` | 提交了掩码值,但该参数在已存配置里没有值 |
渠道已建好但状态为「异常」时,改好配置后在行菜单点「重新探测」,它会立即做一次深探测并刷新状态。探测与状态的规则见[渠道与容灾](./index.mdx)。
# 网盘与 OAuth (/zh/docs/channels/cloud-drives)
需要授权的网盘有哪些、两条授权路径怎么走、令牌如何续期与落库,国内网盘如何经自部署的聚合服务以 WebDAV 接入,以及网盘渠道的固有限制。
## 支持的网盘 [#支持的网盘]
「新建渠道」选类型时,「网盘」分组里除了通用 [WebDAV](./object-storage.mdx) 之外,还有 6 个需要 OAuth 授权的模板:
| 模板 | rclone 后端 | 专属字段 |
|---|---|---|
| OneDrive | `onedrive` | 区域 |
| Google Drive | `drive` | — |
| Dropbox | `dropbox` | — |
| pCloud | `pcloud` | 数据区域 |
| Box | `box` | — |
| Yandex Disk | `yandex` | — |
专属字段之后,6 个模板都依次是「应用 Client ID」「应用 Client Secret」「授权」「存储目录」:
- **区域**(OneDrive):「国际版」「美国政府云」「德国云」「中国世纪互联」,决定授权与接口端点,要与账号所属区域一致。
- **数据区域**(pCloud):「美国」或「欧洲」,要与注册账号时所选区域一致,否则换取令牌失败。
- **应用 Client ID / 应用 Client Secret**:在服务商开放平台自建的 OAuth 应用的标识与密钥。一键授权必须先填 Client ID;粘贴令牌时,令牌若是用自建应用取得的,也要填这两栏;用 rclone 内置应用取得的令牌两栏留空。令牌续期用的就是这两项,必须与签发令牌的应用一致。两者随渠道配置加密保存,编辑时显示为掩码。
- **授权**:令牌,由下面两条授权路径之一填入,未授权不能保存。
- **存储目录**:网盘里存放图片的目录,默认提示 `imgyoung`;留空存放在根目录。
授权所需的端点、权限范围与 rclone 内置的应用标识都取自 rclone 后端自身的注册信息,ImgYoung 不为各家单独编码。
**OneDrive 的驱动器**:拿到新的授权后,保存或测试连接时,ImgYoung 按 rclone 自身的配置流程走完授权之后的步骤,每一问都取默认答案:选定账号的默认驱动器,写下 `drive_id` 与 `drive_type`,与 `rclone config` 逐题取默认值时的选择一致。向服务商查询失败时,保存返回 400,测试连接显示原因。编辑时沿用已存授权,不会重新选择。要用 SharePoint 文档库或其他驱动器,见[OneDrive 的其他驱动器](#onedrive-的其他驱动器)。
**pCloud 的令牌**:pCloud 的令牌永不过期(到期时刻为零值),不带刷新令牌,照常可以保存。
## 授权方式一:粘贴令牌 [#授权方式一粘贴令牌]
在任意一台有浏览器的电脑上用 rclone 取得令牌,再粘贴到表单。ImgYoung 所在的服务器不需要公网域名,这是没有域名的部署唯一可行的方式。
在有浏览器的电脑上安装 rclone,执行表单里提示的命令(以 Google Drive 为例):
```bash title="终端"
rclone authorize "drive"
```
浏览器会打开服务商的登录与授权页,完成授权。
终端输出形如:
```text
Paste the following into your remote machine --->
{"access_token":"…","token_type":"Bearer","refresh_token":"…","expiry":"…"}
<---End paste
```
把两行标记之间的内容整段复制。
在表单的「授权」一栏切到「粘贴令牌」,粘贴进去,填好其余字段,点「测试连接」后保存。
- 令牌 JSON 与 rclone 编码后的配置串(`rclone authorize` 带配置串参数时的输出)都能识别,ImgYoung 从中取出令牌。
- 会过期的令牌必须带刷新令牌,否则访问令牌过期后无法续期,保存时返回 `渠道参数非法: 缺少刷新令牌,访问令牌过期后无法续期(字段 token)`;永不过期的令牌(如 pCloud)不需要。内容无法解析时返回 `渠道参数非法: 令牌格式非法(字段 token)`。
- `rclone authorize "<后端名>"` 取得的令牌由 rclone 内置应用签发,「应用 Client ID」「应用 Client Secret」留空,rclone 续期时同样用内置应用。
- 想用自建应用,执行 `rclone authorize "<后端名>" `(自建应用要登记 rclone 的本机回调地址 `http://127.0.0.1:53682/`),再把同一个应用的 Client ID 与 Client Secret 填进表单。
## 授权方式二:后台一键授权 [#授权方式二后台一键授权]
在后台里直接走完 OAuth 授权码流程。它要求自建应用,因为 OAuth 的回调地址必须事先在服务商处登记,而 rclone 内置应用登记的只有本机回环地址,浏览器回调打不到远程服务器。
在「设置 → 运行配置 → 访问与链接 → 对外站点地址」填好 ImgYoung 的对外地址,如 `https://img.example.com`。未填写时,发起授权返回 `一键授权需先在设置中填写对外站点地址;或改用粘贴令牌方式`。
到服务商的开放平台创建应用,把回调地址登记为对外站点地址加固定路径:
```text
https://img.example.com/api/admin/channel-oauth/callback
```
两边必须逐字符一致(协议、域名、端口、路径都算)。
在表单里「授权」一栏上方的「应用 Client ID」「应用 Client Secret」填好自建应用的凭证,再在「授权」一栏的「一键授权」里点「前往授权 …」。没填 Client ID 时这个按钮不可用。浏览器会新开一页进入服务商授权页。
授权完成后回调页显示「授权成功,请回到后台继续。」,表单的「授权」一栏变为「已授权」,保存即可。
- 回调接口要求管理员登录态,授权要在**已登录后台的同一个浏览器**里完成。
- 一次授权会话 10 分钟内有效,表单最多等待约 11 分钟,超时提示「等待授权超时,请重新发起」。
- 令牌不写进回调页面,由表单凭一次性标识领取,只能领取一次。
- 换取令牌失败时表单显示 `换取令牌失败,请核对应用凭证与回调地址`;在服务商页面拒绝或取消时显示 `授权被拒绝或取消:` 加服务商返回的原因。
- 编辑已有渠道时重新授权,应用凭证两栏保持掩码即沿用已存的值。
- Client ID 与 Client Secret 随渠道保存,令牌到期后 rclone 用同一个应用续期。
## 令牌续期与落库 [#令牌续期与落库]
- 后端参数以映射直接交给 rclone,映射上挂了写回回调。访问令牌临近过期时,rclone 自己刷新,并通过回调写回新令牌;ImgYoung 立即用主密钥加密并存入渠道配置。rclone 在运行中写回的其他参数也走同一条路径。
- 落库失败只记日志(`后端写回参数无法落库`),内存里继续使用新令牌;进程重启后会退回元数据库里的旧令牌。
- 保存渠道时临时构造的后端如果刷新了令牌,新令牌随配置一起落库。
- 测试连接时令牌被刷新,分两种情况:
- 沿用的已存令牌(编辑时「授权」一栏保持「已授权 · 沿用已存令牌」)被刷新,新令牌直接写回该渠道。
- 本次表单里提交的令牌被刷新(测试失败时也可能发生),新令牌交还给表单:测试结论下方提示「服务商在测试时刷新了授权令牌,表单已换用新令牌,保存时提交的就是它;原令牌可能已失效。」,「授权」一栏显示「已授权」,之后保存提交的就是新令牌。自定义模式下新令牌写进后端参数的 `token` 一行。
- **只有一个刷新者**:ImgYoung 不另设刷新循环。有些服务商每次刷新都会作废旧的刷新令牌,两个刷新者会互相作废对方的令牌。周期轻探测(默认每 60 秒)发出的只读请求足以让 rclone 按时刷新。
- 编辑网盘渠道时,「授权」一栏显示「已授权 · 沿用已存令牌」,保持不动即沿用;点「重新授权」可以换一个令牌。
- [导出的 rclone 配置文件](./encryption.mdx)里也带着令牌。ImgYoung 仍在使用该渠道时不要拿这个文件去用:Box 这类服务商的刷新令牌只能用一次,两边会互相作废对方的令牌。ImgYoung 刷新令牌之后,文件里的令牌就过时了,需要重新导出。
创建或编辑渠道时,如果保存最终失败(参数非法、试构造失败、OneDrive 选定驱动器时查询失败、渠道名重复等),保存过程中服务商刷新出的新令牌既不落库,也不交还表单。对 Box 这类刷新令牌只能用一次的服务商,原令牌随之作废,只能重新授权。先点「测试连接」再保存能降低这种风险:测试时刷新出的令牌会交还表单,保存时用的已是新令牌。
## OneDrive 的其他驱动器 [#onedrive-的其他驱动器]
模板只会选定账号的默认驱动器。要用 SharePoint 文档库或其他驱动器,在「新建渠道 → 高级 → 自定义」里手填 `drive_id` 与 `drive_type`。
先在有浏览器的电脑上用 `rclone config` 交互地新建一个 OneDrive remote,在选择驱动器的步骤里选中目标驱动器,再查看它写下的参数:
```bash title="终端"
rclone config
rclone config show onedrive-tmp
```
把输出里的值填进自定义表单:
| 键 | 值 |
|---|---|
| rclone 驱动 | `onedrive` |
| `token` | `rclone config show` 输出的 `token` |
| `drive_id` | 同上 |
| `drive_type` | 同上(`personal`、`business` 或 `documentLibrary`)|
| `region` | 非国际版账号才需要:`us`、`de` 或 `cn` |
| `client_id`、`client_secret` | 只在 rclone 里用了自建应用时需要 |
| `path` | 存放图片的目录,如 `imgyoung` |
自定义模式里令牌原样保存,需要重新授权时替换 `token` 的值。保存后删除本机的临时 remote,避免两处同时刷新同一个令牌:
```bash title="终端"
rclone config delete onedrive-tmp
```
## 国内网盘:经自部署的聚合服务以 WebDAV 接入 [#国内网盘经自部署的聚合服务以-webdav-接入]
rclone 没有阿里云盘、百度网盘、115、夸克等国内网盘的后端。能对接它们的开源聚合服务(如 Alist 一类)采用 AGPL-3.0 许可证:把它们的代码移植进 ImgYoung,会让本项目整体被迫转为 AGPL,还要长期自行跟进这些网盘的接口与风控变化。
因此 ImgYoung 不移植,而是由你自行部署这类聚合服务、让它对外提供 WebDAV,ImgYoung 用通用 WebDAV 模板接入。两者是独立进程、只通过网络协议交互,许可证不会传染,网盘接口的维护也留在上游。
按聚合服务自己的文档部署它,挂载要用的网盘,并开启 WebDAV。
为 ImgYoung 建一个专用账号,给它读、写、删除权限。深探测、每日对账与渠道清理都要删除对象,缺删除权限的渠道无法通过深探测。
在 ImgYoung 新建 WebDAV 渠道:「服务地址」填聚合服务的 WebDAV 地址(Alist 为 `https://域名/dav/`),「服务类型」选「其他(坚果云 / Alist / 群晖等)」,填账号密码;「存储目录」填该网盘在聚合服务里的挂载路径下的一个专用目录。
点「测试连接」确认写、读、删都通过,再设一个低于对象存储渠道的优先级,保存。
## 网盘渠道的固有限制 [#网盘渠道的固有限制]
- **恒走代理**:网盘普遍不提供稳定的公开直链,网盘模板没有「公开访问地址」,表单也不显示「允许 302 直链」与「自动创建存储桶」。即使访问模式是 `redirect`,网盘上的图片也经 ImgYoung 代理输出。
- **频率与流量限制**:网盘对请求频率与流量的限制远严于对象存储,图片访问量大时容易被限流或触发风控。周期轻探测会对每个渠道每轮发一次只读请求。
- **全量镜像**:与其他渠道一样,网盘渠道最终要存下全站全部图片。
因此网盘更适合作**低优先级的备份渠道**:把它的优先级设得低于对象存储渠道,平时只承接异步补齐;主渠道异常时,访问才会切到它。这一取舍完全通过优先级表达,见[渠道与容灾](./index.mdx)。
# 渠道存储加密 (/zh/docs/channels/encryption)
用 rclone crypt 让存储商只看到密文:防什么、怎么开、为什么建后不可变、密钥从哪里来,以及不依赖 ImgYoung 用标准 rclone 解密渠道数据。
## 防什么,不防什么 [#防什么不防什么]
渠道存储加密针对的是**存储商**:免费或第三方渠道可能扫描、审查存储内容,甚至因误判封号。开启后,渠道上的对象内容与对象名都是密文。
它**不防服务端自身**。ImgYoung 持有主密钥,格式校验、元数据剥离、哈希、查重、按需处理都在加密之前对明文进行,这些功能不受影响;拿到服务器与主密钥的人能读出全部数据。
开启后存储商仍能看到:对象数量、对象大小(由密文大小可推算出明文大小,误差在 16 字节以内)、修改时间与访问规律。
## 实现方式 [#实现方式]
加密渠道在后端之上套一层 rclone 的 `crypt` 后端:
- **内容**:按 64 KiB 分块,用 XSalsa20-Poly1305 加密并校验完整性。
- **对象名**:`standard` 模式,每一级路径用 AES-256(EME 模式)加密后转成小写 base32;目录名同样加密。同一个名字总是加密成同一个密文,所以能按名查找。
- **密钥材料**:rclone 用 scrypt 从「密码」与「盐」派生出实际密钥。ImgYoung 为每个渠道派生一对独立的密码与盐,见[密钥从哪里来](#密钥从哪里来)。
上传、访问代理、补齐、探测都经过这一层自动加解密。渠道上的所有对象都会加密,包括图片、深探测的临时对象,以及每轮备份写到该渠道上的元数据库快照(`_imgyoung/backup/`)。
## 开启方式 [#开启方式]
两级设置,渠道级优先:
| 位置 | 取值 | 默认 |
|---|---|---|
| 「设置 → 运行配置 → 访问与链接 → 新渠道默认加密」(`encrypt_new_channels`)| 开 / 关 | 关 |
| 新建渠道表单的「服务端加密」 | 「继承全局默认」「强制开启」「强制关闭」 | 继承全局默认 |
「继承全局默认」在创建那一刻就按当时的全局设置定下来;之后再改全局设置,不影响已有渠道。通过接口创建时,省略 `encrypted` 字段即为继承。
渠道列表里加密渠道带「加密」标记;编辑表单只显示「已加密」或「未加密」。
## 创建后不可变 [#创建后不可变]
加密属性在创建时固化,之后不能修改。接口拒绝变更并返回 409 `加密属性创建后不可变(变更会造成密文/明文混存且不可恢复);如需调整请新建渠道并迁移数据`。
原因:翻转开关后,同一个渠道里会同时存在按旧设置写入的对象与按新设置写入的对象,旧对象无法按新设置读回。
需要改变加密属性时,用补齐完成迁移:
按需要的加密设置新建一个渠道。创建后它会自动为全部存量图片补齐副本。
等新渠道「副本」列的「待补」归零、没有「失败」。
删除旧渠道。存量图片在新渠道上都已有就绪副本,唯一副本保护不会拦截。
## 加密渠道不能 302 直链 [#加密渠道不能-302-直链]
密文无法直接交给浏览器,所以加密与「允许 302 直链」互斥:
- 新建表单里选了「强制开启」时,「允许 302 直链」不可开;已开直链时,「强制开启」不可选。
- 「继承全局默认」而全局默认为开时,同样不能开直链。
- 接口同时收到两者时拒绝,返回 400 `渠道参数非法: 加密渠道不允许直链(密文无法直出)`。
- `access_mode = redirect` 时,加密渠道上的图片始终走代理。
## 密钥从哪里来 [#密钥从哪里来]
每个加密渠道的密码与盐都由**主密钥**派生,不另外保存:
| 项 | 取值 |
|---|---|
| 算法 | HKDF-SHA256,盐为空(等同 32 个零字节),输出 32 字节 |
| 输入密钥 | 主密钥的 32 字节 |
| 密码的 info | `channel:<渠道 ID>:<创建时刻>:crypt:password` |
| 盐的 info | `channel:<渠道 ID>:<创建时刻>:crypt:salt` |
| 交给 rclone 的值 | 32 字节结果的小写十六进制串(64 个字符),分别作为 crypt 的 `password` 与 `password2` |
- **渠道 ID** 与 **创建时刻** 取自元数据库 `channels` 表的 `id` 与 `created_at` 列。创建时刻是 SQLite 写入的 UTC 文本,形如 `2026-10-15 08:00:00`,派生时逐字使用。
- 带上创建时刻,是因为 SQLite 会复用已删除渠道的 ID;只绑定 ID 时,新渠道会派生出与已删渠道相同的密钥。
- 不同渠道的密钥互相独立,一个渠道的密钥泄露不波及其他渠道。
**主密钥**来自环境变量 `IMGYOUNG_MASTER_KEY`(64 位十六进制),未设置或为空时使用 `<数据目录>/master.key`。它同时用于加密渠道配置与派生渠道密钥。主密钥不包含在数据导出与元数据库快照里。
首次以某把主密钥启动时,元数据库会记下由它派生的指纹(不能反推出主密钥),此后每次启动都核对:
- 只有全新的元数据库在找不到主密钥时,才生成 `master.key`(权限 0600)。
- 库里已有指纹时,主密钥缺失(环境变量未设置且没有 `master.key`)或与指纹不符,一律拒绝启动并说明原因,不会生成新密钥。
- 恢复别的实例的快照时,要同时换上那个实例的主密钥。
没有主密钥,就派生不出任何加密渠道的密码,渠道上的密文无法解开;已保存的渠道配置也无法解密,ImgYoung 会拒绝启动。把主密钥复制到与服务器分离的安全位置。主密钥的配置见[配置参考](../configuration.mdx),备份策略见[元数据库备份](../operations/backup.mdx)。
## 自救:用标准 rclone 读出渠道数据 [#自救用标准-rclone-读出渠道数据]
crypt 是 rclone 的公开格式。即使 ImgYoung 不再运行,也可以用标准 rclone 读出加密渠道上的全部对象。最省事的做法是事先导出每个渠道的 rclone 配置文件;没有导出文件时,也可以从元数据库与主密钥手工推导。
元数据库快照每轮写到每个健康渠道,加密渠道上的那一份同样是密文。只要有一个未加密的渠道持有快照,或者已经离线保存了加密渠道的导出文件,主机全损后就能取回快照。如果全部渠道都加密、又没有导出文件,手工推导需要渠道的 ID 与创建时刻,而这两项正记录在快照里。这种部署请为每个加密渠道导出配置文件,与主密钥一起离线保存。
### 用导出的配置文件 [#用导出的配置文件]
在「存储渠道」页的行菜单点「导出 rclone 配置」,再点「下载配置文件」,得到 `imgyoung-channel-<渠道 ID>.rclone.conf`。每个渠道一个文件,渠道配置改动后重新导出。
把它和主密钥一起离线保存。文件本身就足以读写这个渠道上的全部数据(加密渠道的解密密码已在其中);主密钥用于恢复 ImgYoung 自身。
在任意装有 rclone 的机器上,用文件头注释里的命令读出数据。加密渠道读 `imgyoung-<渠道 ID>-crypt:`,得到的就是明文:
```bash title="终端"
rclone --config imgyoung-channel-3.rclone.conf ls imgyoung-3-crypt:
rclone --config imgyoung-channel-3.rclone.conf copy imgyoung-3-crypt: ./imgyoung-restore
```
未加密的渠道直接读后端远端加存储路径,如 `imgyoung-3:my-bucket/imgyoung`,文件头注释里写着这个路径。
一个加密 R2 渠道导出的文件形如:
```ini title="imgyoung-channel-3.rclone.conf"
# ImgYoung 渠道 "r2-backup"(ID 3,创建于 2026-10-15 08:00:00 UTC)的 rclone 配置。
# 文件含渠道凭证与解密密码,与主密钥一样离线妥善保存。
#
# [imgyoung-3] 渠道后端(s3),参数与 ImgYoung 所用一致
# [imgyoung-3-crypt] 渠道的加密层,指向渠道存储路径 imgyoung-3:my-bucket/imgyoung,经它读写的是明文
#
# 用法:
# rclone --config imgyoung-channel-3.rclone.conf ls imgyoung-3-crypt:
# rclone --config imgyoung-channel-3.rclone.conf copy imgyoung-3-crypt: ./imgyoung-restore
[imgyoung-3]
type = s3
access_key_id =
endpoint = https://<账户 ID>.r2.cloudflarestorage.com
no_check_bucket = true
provider = Cloudflare
region = auto
secret_access_key =
[imgyoung-3-crypt]
type = crypt
filename_encryption = standard
password =
password2 =
remote = imgyoung-3:my-bucket/imgyoung
```
- 后端远端的参数与 ImgYoung 交给后端的完全一致,包括凭证与授权令牌;密码类参数是 rclone 编码后的形式,rclone 直接可用。s3 渠道带 `no_check_bucket = true`。
- 文件头注释固定为中文。
- 持有授权令牌的网盘渠道,注释里多两行提醒:rclone 用这个文件时会刷新令牌并写回文件;Box 等刷新令牌只能用一次的服务商,文件与仍在使用该渠道的 ImgYoung 会互相作废对方的令牌,不要同时使用。另外,ImgYoung 刷新令牌之后,文件里的令牌就过时了;对 Box 这类服务商,文件里的刷新令牌随即作废,要重新导出。
- `local` 渠道的路径是服务器上的路径(内置 local 渠道是 `<数据目录>/blobs`,数据目录配成相对路径时导出的也是相对路径),换一台机器使用要改成实际位置。
- 参数值写不进 rclone 配置文件(如含换行)时,导出返回 422,错误只指出远端名与参数键。每次下载都记一条审计日志「导出渠道 rclone 配置」。
### 没有导出文件时:手工推导 [#没有导出文件时手工推导]
需要准备:
- 主密钥(64 位十六进制);
- 渠道的 ID 与创建时刻;
- 渠道后端的凭证与渠道的存储路径(桶名及前缀、网盘目录等)。
#### 取得渠道 ID 与创建时刻 [#取得渠道-id-与创建时刻]
元数据导出的 JSON 与渠道列表接口里,每个渠道都有 `id` 与 `created_at`。只剩元数据库文件时,用 `sqlite3` 查询;元数据库是 `<数据目录>/imgyoung.db`,快照文件也是同样格式的 SQLite 库:
```bash title="终端"
sqlite3 imgyoung.db "SELECT id, name, created_at FROM channels WHERE encrypted = 1;"
```
```text title="输出示例"
3|r2-backup|2026-10-15 08:00:00
```
#### 计算密码与盐 [#计算密码与盐]
下面的脚本只用 Python 标准库,按 HKDF-SHA256 计算出密码与盐:
```python title="derive.py"
import hashlib, hmac, sys
master = bytes.fromhex(sys.argv[1].strip())
channel_id, created_at = sys.argv[2], sys.argv[3]
prk = hmac.new(b"\x00" * 32, master, hashlib.sha256).digest()
for usage in ("password", "salt"):
info = f"channel:{channel_id}:{created_at}:crypt:{usage}".encode()
print(usage, hmac.new(prk, info + b"\x01", hashlib.sha256).hexdigest())
```
```bash title="终端"
python3 derive.py "$(cat master.key)" 3 "2026-10-15 08:00:00"
```
输出两行:`password <64 位十六进制>` 与 `salt <64 位十六进制>`。
#### 配置 rclone [#配置-rclone]
先建一个指向渠道后端的普通 remote,参数与渠道的后端参数一致(以 Cloudflare R2 为例):
```bash title="终端"
rclone config create imgyoung-r2 s3 provider=Cloudflare \
access_key_id= secret_access_key= \
endpoint=https://<账户 ID>.r2.cloudflarestorage.com region=auto
```
再在它之上建 crypt remote。`remote` 指向渠道的存储路径(模板里「存储桶」一栏的值,含前缀);`--obscure` 不能省,它让 rclone 把两个十六进制串当作明文密码编码:
```bash title="终端"
rclone config create imgyoung-crypt crypt \
remote=imgyoung-r2:<存储桶>/<前缀> \
password=<上一步的 password> password2=<上一步的 salt> \
filename_encryption=standard --obscure
```
#### 读出数据 [#读出数据]
```bash title="终端"
rclone ls imgyoung-crypt:
rclone copy imgyoung-crypt: ./imgyoung-restore
```
### 读出的内容 [#读出的内容]
图片对象名形如 `<短 ID>_`,不带扩展名;短 ID 就是图片链接里的 ID。元数据库快照在 `_imgyoung/backup/` 下。
# 配置 (/zh/docs/configuration)
引导配置(环境变量)与运营配置(后台设置)的全部配置项、默认值、取值范围与生效方式,以及主密钥的保管。
## 两类配置 [#两类配置]
ImgYoung 的配置只分两类,没有配置文件,命令行参数只有 `-version`(打印版本、提交与构建时刻后退出)。
| | 引导配置 | 运营配置 |
|---|---|---|
| 内容 | 进程启动前就必须知道的:监听地址、数据目录、主密钥、信任的反代、指标放行网段 | 其余全部运营参数 |
| 来源 | 只认环境变量 `IMGYOUNG_*` | 元数据库里的 `settings` 表,不接受环境变量 |
| 修改 | 改环境变量后重启进程 | 后台「设置」修改,保存即生效,无需重启 |
| 查看 | 后台「设置 → 运行配置」底部的「引导配置」卡片(只读)| 后台「设置」 |
这样划分是为了让「这个值从哪来」永远只有一个答案:环境变量只负责把进程启动起来,其中有的还决定信任边界;运营配置只有后台一个写入方,不接受环境变量播种,也没有配置文件。
运营配置随元数据库持久化,也随元数据库快照一起备份与恢复。新实例在后台配置一次即可;升级新增的配置项由数据库迁移以出厂默认值补齐。
## 引导配置 [#引导配置]
| 环境变量 | 默认 | 格式 | 作用 |
|---|---|---|---|
| `IMGYOUNG_LISTEN` | `:8721` | `host:port`,如 `127.0.0.1:8721`、`[::1]:8721`;端口为 `0` 时由系统分配 | HTTP 监听地址。ImgYoung 只提供明文 HTTP,HTTPS 由前置反向代理负责 |
| `IMGYOUNG_DATA_DIR` | `data`(相对进程工作目录)| 目录路径;不存在时以 `0750` 权限创建 | 数据目录:元数据库 `imgyoung.db`、主密钥文件 `master.key`、内置 local 渠道的 `blobs/`、衍生图缓存 `variants/` |
| `IMGYOUNG_MASTER_KEY` | 空(使用数据目录下的 `master.key`)| 64 个十六进制字符(32 字节)| 主密钥。每次启动与库里记下的主密钥指纹核对,缺失或不符即拒绝启动,见下文[主密钥](#主密钥) |
| `IMGYOUNG_TRUSTED_PROXIES` | `127.0.0.0/8,::1/128` | 逗号分隔的 CIDR 或单个 IP;单个 IP 视为只含该地址的网段 | 连接对端落在这些网段时才采信 `X-Forwarded-For` / `X-Forwarded-Proto` / `X-Forwarded-Host`,见[反向代理与 HTTPS](./operations/reverse-proxy.mdx) |
| `IMGYOUNG_METRICS_ALLOW` | `127.0.0.0/8,::1/128,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,fc00::/7` | 同上 | 客户端地址落在这些网段时才放行 `/metrics`,其余一律 403,见[监控与告警](./operations/monitoring.mdx) |
- 变量**未设置**时取默认值;**设为空串**(或只有空白)就是显式置空。各变量的值都会去掉首尾空白。空值的含义:
- `IMGYOUNG_TRUSTED_PROXIES` 为空:一概不信转发头;
- `IMGYOUNG_METRICS_ALLOW` 为空:`/metrics` 对谁都不开放,一律 403;
- `IMGYOUNG_MASTER_KEY` 为空:与未设置相同,使用数据目录下的 `master.key`;
- `IMGYOUNG_LISTEN`、`IMGYOUNG_DATA_DIR` 不接受空值,设为空串即启动失败,错误信息指出变量名。Compose 文件里写 `${VAR}`
而宿主机没有设置该变量时,容器拿到的就是空串;要用默认值,就不要设置这个变量。
后台「引导配置」卡片对空的网段清单显示「空:不采信任何转发头」「空:不对任何地址开放」。
- 两个网段清单在启动时解析,任一项既不是 CIDR 也不是 IP 时启动失败,错误信息给出变量名与出错的条目。监听地址在绑定端口时校验,主密钥在解析时校验,非法同样启动失败。
- Docker 镜像已设置 `IMGYOUNG_DATA_DIR=/data`;`deploy/imgyoung.service` 把它设为 `/var/lib/imgyoung`。
各部署方式设置环境变量的位置:
```bash title="终端"
IMGYOUNG_LISTEN=127.0.0.1:8721 IMGYOUNG_DATA_DIR=/srv/imgyoung ./imgyoung
```
```bash title="终端"
docker run -d --name imgyoung --restart unless-stopped \
-p 127.0.0.1:8721:8721 -v imgyoung-data:/data \
-e IMGYOUNG_TRUSTED_PROXIES=172.16.0.0/12 \
ghcr.io/x-haose/imgyoung:latest
```
容器内应保持默认的 `:8721` 监听,只在宿主机一侧用 `-p 127.0.0.1:8721:8721` 收紧。
```yaml title="compose.yaml"
services:
imgyoung:
environment:
IMGYOUNG_TRUSTED_PROXIES: 172.16.0.0/12
```
改完执行 `docker compose up -d` 重建容器。
用 drop-in 覆盖,不要直接改 `/etc/systemd/system/imgyoung.service`:安装脚本带 `--service` 重新执行时会覆盖该文件。
```bash title="终端"
sudo systemctl edit imgyoung
```
```ini title="编辑器中填入"
[Service]
Environment=IMGYOUNG_LISTEN=127.0.0.1:8721
```
```bash title="终端"
sudo systemctl restart imgyoung
```
## 运营配置 [#运营配置]
运营配置在后台「设置」页修改,只有管理员可见。除「空 Referer 放行」在「防盗链」页签外,其余都在「运行配置」页签,按下列分组排列。
- 保存时只提交改动过的项;整组改动一起校验,任一项非法则全部不生效并提示原因。
- 生效即时:各后台循环的间隔改动后立刻按新值重新计时,不必等旧周期走完。
- 每次实际生效的修改记一条审计日志(动作 `config.update`,含新旧值),Cloudflare API Token 只记「已配置 / 空」。
- 同样的操作可经 `GET /api/admin/config/full` 与 `PUT /api/admin/config` 完成,请求体为「配置键 → 新值」的 JSON 对象,见[接口参考](./api/admin-overview.mdx)。
- 时长类配置项统一以 100 年(876000 小时)为上限,按各自单位折算:天 36500、小时 876000、分钟 52,560,000、秒 3,153,600,000、
毫秒 3,153,600,000,000,超出即拒绝保存。下表「取值」一栏对这些项只写下限。
下表的「默认」即出厂值,「取值」即服务端校验的范围。
### 访问与链接 [#访问与链接]
| 配置键 | 界面名称 | 默认 | 取值 | 说明 |
|---|---|---|---|---|
| `access_mode` | 访问模式 | `proxy` | `proxy` / `redirect` | `proxy`:图片由本服务从渠道读出后输出,渠道读取失败即时换下一个渠道。`redirect`:原图请求 302 到允许直链的渠道,重定向带 `Cache-Control: no-store`,见 [CDN](./operations/cdn.mdx) |
| `base_url` | 对外站点地址 | 空 | 带协议的站点根地址,如 `https://img.example.com`;保存时去掉末尾的 `/` | 空则按请求推导。经 CDN 或反代后对外域名与请求域名不一致、启用 CDN 缓存清除、网盘渠道一键授权时必须设置,见[反向代理与 HTTPS](./operations/reverse-proxy.mdx) |
| `min_success` | 最少同步副本 | `1` | ≥ 1 | 上传返回成功前必须同步写入的渠道数;健康渠道写不够这么多份时上传失败(503)|
| `encrypt_new_channels` | 新渠道默认加密 | 关 | 开 / 关 | 新建渠道未显式选择时是否加密;加密属性在渠道创建时确定,之后不可改 |
### 上传限制 [#上传限制]
| 配置键 | 界面名称 | 默认 | 取值 | 说明 |
|---|---|---|---|---|
| `max_upload_mb` | 单图上限 | `50` | ≥ 1,MB(1 MB = 1024 × 1024 字节)| 只约束图片本身,恰好等于上限的文件可以上传,超出返回 413;multipart 请求体整体另以「单图上限 + 64 KiB」为界。同时是 URL 转存与 S3 上传的大小上限,也是按需处理读取源图的上限:调小后,超过新上限的旧图做缩放或转格式时会回退原图 |
| `max_pixels_mp` | 像素预算 | `50` | ≥ 1,百万像素 | 宽 × 高超过 `max_pixels_mp × 1,000,000` 的图片拒收(422)|
| `max_concurrent_uploads` | 上传并发上限 | `8` | ≥ 1 | 全局共享:数据面上传、URL 转存、后台上传与 S3 协议面的全部请求共用一个上限;满载立即返回 429(`Retry-After: 1`)|
| `upload_rate_per_min` | 上传频率上限 | `0` | ≥ 0,次 / 分钟 | 按用户计,同一用户经 Token 与后台上传共享额度,突发额度等于每分钟额度;超出返回 429(`Retry-After: 60`)。`0` 不限制 |
### 生命周期 [#生命周期]
| 配置键 | 界面名称 | 默认 | 取值 | 说明 |
|---|---|---|---|---|
| `default_ttl_hours` | 默认有效期 | `0` | ≥ 0,小时 | 上传未指定 `ttl`、用户也没有自己的默认有效期时使用。`0` 永不过期 |
| `ttl_renew_on_access` | 访问即续期 | 开 | 开 / 关 | 带有效期、尚未到期的图片被公开访问(GET)时,到期时刻顺延为访问落库时刻加其有效时长。已过到期时刻的图片立即不可访问,也不再续期 |
| `cold_cleanup_days` | 冷图清理阈值 | `90` | ≥ 0,天 | 超过该天数未被公开访问的图片进入「待清理」等待人工裁决,不会自动删除。`0` 不标记 |
| `delete_grace_minutes` | 删除宽限 | `0` | ≥ 0,分钟 | 物理图引用归零到删除渠道对象之间的等待。删除时刻在引用归零那一刻按当时的取值排定,之后修改不影响已排定的。`0` 表示下一轮清理扫描即删除 |
| `cleanup_sweep_seconds` | 清理扫描间隔 | `60` | ≥ 1,秒 | 到期回收、物理删除、冷图标记、幂等键与审计日志回收的执行节奏 |
| `activity_flush_seconds` | 活跃度落库间隔 | `60` | ≥ 1,秒 | 访问次数、最近访问时刻与衍生图缓存访问时间批量写库的间隔 |
| `idempotency_key_ttl_hours` | 幂等键保留期 | `24` | ≥ 1,小时 | 带 `Idempotency-Key` 的上传重试在此期限内返回首次结果,超期的键被回收 |
### 按需处理 [#按需处理]
| 配置键 | 界面名称 | 默认 | 取值 | 说明 |
|---|---|---|---|---|
| `process_widths` | 宽度档 | `[100,200,400,800,1600]` | 正整数数组,像素;可为空 | `?w=` 只接受这些值,其余返回 400;为空时不允许缩放 |
| `process_formats` | 目标格式 | `["webp","jpeg","png"]` | `webp`、`jpeg`、`png` 的非空子集 | `?format=` 只接受这些值。包含 `webp` 时,支持 WebP 的浏览器访问 JPEG / PNG 会自动得到 WebP |
| `exif_policy` | EXIF 默认档 | `passthrough` | `passthrough`(保留)/ `strip`(剥离元数据)| 上传时的默认 EXIF 处理;单次上传可用表单字段 `exif_policy` 覆盖;S3 上传不适用,一律按原字节保存 |
| `process_quality_webp` | WebP 质量档 | `80` | 0–100 | |
| `process_quality_jpeg` | JPEG 质量档 | `85` | 1–100 | |
| `process_webp_method` | WebP 速度权衡 | `4` | 0–6 | 越大编码越慢、体积越小 |
| `process_workers` | 处理并发上限 | `4` | ≥ 1 | 同时进行的处理数;排队等待也计入处理超时 |
| `process_timeout_ms` | 处理超时 | `5000` | ≥ 1,毫秒 | 含排队时间;超时回退原图,该响应带 `Cache-Control: no-store` |
| `variants_cache_mb` | 衍生图缓存上限 | `10240` | ≥ 1,MB | 衍生图缓存在 `<数据目录>/variants/`,超出按最近访问时间淘汰 |
### 查重 [#查重]
| 配置键 | 界面名称 | 默认 | 取值 | 说明 |
|---|---|---|---|---|
| `dedup_scan_seconds` | 查重扫描间隔 | `300` | ≥ 1,秒 | 每轮先自动合并内容完全相同的图片,再做相似扫描 |
| `dedup_phash_max_distance` | 相似判定阈值 | `10` | ≥ 0,汉明距离 | 感知哈希距离不超过该值的图片进入「疑似重复」等待人工裁决。`0` 不做相似扫描,完全相同的图片仍自动合并 |
### 可靠性与渠道 [#可靠性与渠道]
| 配置键 | 界面名称 | 默认 | 取值 | 说明 |
|---|---|---|---|---|
| `health_check_seconds` | 健康检查间隔 | `60` | ≥ 1,秒 | 渠道健康探测与告警评估的节奏 |
| `backup_interval_hours` | 备份间隔 | `24` | ≥ 1,小时 | 元数据库自动快照的间隔。启动时任一健康渠道上缺少这个间隔内的快照,就先备份一次。见[备份与恢复](./operations/backup.mdx) |
| `backup_keep` | 备份保留份数 | `7` | ≥ 1 | 快照写到每个健康且不在清理中的渠道;每个渠道写入后各自只保留最新的这么多份 |
| `usage_report_seconds` | 用量上报间隔 | `300` | ≥ 1,秒 | 只决定指标 `imgyoung_channel_storage_bytes` 的刷新节奏;用量告警按健康检查间隔评估 |
| `channel_quota_gb` | 渠道用量告警阈值 | `0` | ≥ 0,GB(1 GB = 2³⁰ 字节)| 任一渠道已存数据(按已就绪副本的图片大小求和)超过阈值即开启告警,不限制写入。`0` 不告警 |
### CDN 缓存清理 [#cdn-缓存清理]
| 配置键 | 界面名称 | 默认 | 取值 | 说明 |
|---|---|---|---|---|
| `cdn_cloudflare_zone_id` | Cloudflare Zone ID | 空 | 文本 | |
| `cdn_cloudflare_api_token` | Cloudflare API Token | 空 | 文本 | 需要 Cache Purge 权限。以主密钥加密后入库,任何接口、页面与日志都不回显。表单留空表示不修改,用「清除 Token」清空 |
Zone ID、API Token 与 `base_url` 三项齐备才启用缓存清除,分组标题旁的徽标显示「purge 已启用 / purge 未启用」,启动日志也会打印一行启用状态。详见 [CDN](./operations/cdn.mdx)。
### 告警与审计 [#告警与审计]
| 配置键 | 界面名称 | 默认 | 取值 | 说明 |
|---|---|---|---|---|
| `alert_webhook_url` | 告警 Webhook 地址 | 空 | 空,或带主机的 `http` / `https` 地址 | 告警开启与恢复时 POST 一条 JSON,负载格式见[监控与告警](./operations/monitoring.mdx)。空则只记录不外发 |
| `audit_retention_days` | 审计日志保留天数 | `0` | ≥ 0,天 | 清理扫描时删除早于该天数的审计记录。`0` 永久保留 |
### 防盗链页签 [#防盗链页签]
| 配置键 | 界面名称 | 默认 | 取值 | 说明 |
|---|---|---|---|---|
| `allow_empty_referer` | 空 Referer 放行 | 开 | 开 / 关 | Referer 白名单非空时,不带 Referer 的请求(地址栏直接打开、下载工具)是否放行。白名单为空时防盗链整体不启用,本项不起作用 |
白名单规则本身在同一页签增删,不属于 `settings` 表。
## 主密钥 [#主密钥]
主密钥是 32 字节的随机数,用于:
- 加密渠道配置(含各渠道的凭证)与 Cloudflare API Token 后入库;
- 为每个加密渠道派生 crypt 密码与盐;
- 为每枚上传 Token 派生 S3 协议面的 secret(不落库,按需重算)。
上传 Token 与登录密码只存哈希,不依赖主密钥。
### 生成与核对 [#生成与核对]
启动时按以下顺序取得主密钥:`IMGYOUNG_MASTER_KEY` 非空时用它,不读也不生成 `master.key`;否则读数据目录下的 `master.key`。
取得之后与库里记下的**主密钥指纹**核对。指纹存在单行表 `master_key_fingerprint` 里,由主密钥经 HKDF-SHA256 派生,不能反推出主密钥。
- **库里还没有指纹**(全新的库,或从未记录过指纹的库):主密钥两处都没有时,在数据目录生成 `master.key`
(64 个十六进制字符加换行,权限 `0600`,先写临时文件再改名)并打印一条警告日志提醒备份;随后把这把主密钥的指纹写进库里。
- **库里已有指纹**:主密钥必须存在且与指纹相符。`IMGYOUNG_MASTER_KEY` 未设置或为空、数据目录下也没有 `master.key`,
或者主密钥与指纹不符,一律拒绝启动并说明原因,不会生成新密钥。只有全新的库才会生成 `master.key`。
- `master.key` 存在但内容不是合法的 64 位十六进制时启动失败,不会重新生成:重新生成会让所有已有密文作废。
想自己生成一把主密钥:
```bash title="终端"
openssl rand -hex 32
```
从文件方式改用环境变量时,把 `master.key` 的内容原样填进变量;填错时指纹核对不通过,进程拒绝启动。
### 保管 [#保管]
主密钥**不在**元数据库快照里,也不在元数据导出里,必须单独备份到与服务器、快照都不同的地方。首次启动后立即复制一份:
```bash title="终端"
cp data/master.key /安全位置/imgyoung-master.key
```
```bash title="终端"
docker cp imgyoung:/data/master.key ./imgyoung-master.key
```
```bash title="终端"
docker compose cp imgyoung:/data/master.key ./imgyoung-master.key
```
```bash title="终端"
sudo cp /var/lib/imgyoung/master.key ./imgyoung-master.key
```
### 丢失或换错的后果 [#丢失或换错的后果]
主密钥不支持更换。库里记下指纹之后,换错与缺失都会在启动时被拦下:
- 用了另一把主密钥(例如 `IMGYOUNG_MASTER_KEY` 填错):拒绝启动,错误信息提示换回原来的主密钥;换回后照常启动。
- 主密钥缺失(例如数据目录里的 `master.key` 被删,或 Compose 里的 `${VAR}` 在宿主机上没有设置):拒绝启动,不会生成新密钥;
放回原来的主密钥后照常启动。
主密钥真正丢失时无法挽回,这个库无法再启动,用它加密的数据都救不回来:
- 渠道配置(含各渠道的凭证)无法解密;
- 加密渠道上的数据(含存放在加密渠道上的元数据库快照)永久无法解密;
- Cloudflare API Token 无法解密;
- 全部上传 Token 的 S3 secret 无法再算出。
元数据库快照、数据导出和渠道里的图片都救不回丢失的主密钥。灾难恢复时,新机器必须在首次启动前放入同一个主密钥;
恢复别的实例的快照后,要换上那个实例的主密钥,否则指纹不符、拒绝启动。见[备份与恢复](./operations/backup.mdx)。
# 反向代理与 HTTPS (/zh/docs/operations/reverse-proxy)
用 Caddy 或 Nginx 前置 ImgYoung,正确配置 IMGYOUNG_TRUSTED_PROXIES、base_url 与上传体积上限。
## 为什么要前置反向代理 [#为什么要前置反向代理]
ImgYoung 只监听明文 HTTP,不终结 TLS。对公网提供服务时应在前面放一个反向代理:
- 由反代申请证书、提供 HTTPS;
- 会话 cookie 是否带 `Secure`、未配置 `base_url` 时返回链接的协议,都依据反代传来的 `X-Forwarded-Proto`;
- 在入口层拦掉 `/metrics`,指标只留给内网抓取;
- ImgYoung 本身改为只监听回环地址,不直接暴露。
## Caddy [#caddy]
Caddy 自动申请并续期证书,默认就会设置 `X-Forwarded-For` / `X-Forwarded-Proto` / `X-Forwarded-Host`,并原样转发 `Host`。
```text title="deploy/Caddyfile"
# Caddy 反向代理示例:Caddy 自动申请并续期 HTTPS 证书。把域名换成你的,DNS 指向本机。
# Caddy 默认就会设置 X-Forwarded-For / -Proto / -Host,并忽略客户端自带的这些头。
# ImgYoung 直接运行在本机(非容器)时,Caddy 的连接来自回环地址,默认受信任,无需额外配置。
# ImgYoung 在容器里时,不论 Caddy 在宿主机还是另一个容器,ImgYoung 看到的对端都是 Docker 网络里的地址,
# 要给 ImgYoung 设 IMGYOUNG_TRUSTED_PROXIES 为该网络的网段(docker network inspect 查看);
# Caddy 也在容器里时,把 reverse_proxy 改成 imgyoung:8721。
img.example.com {
# 指标只给内网抓取,公网入口一律不转发
respond /metrics 404
reverse_proxy 127.0.0.1:8721
}
```
## Nginx [#nginx]
```nginx title="deploy/nginx.conf"
# Nginx 反向代理示例:放进 http {} 块。域名与证书路径换成你的。
# ImgYoung 直接运行在本机(非容器)时,Nginx 的连接来自回环地址,默认受信任,无需额外配置。
# 其余情况要给 ImgYoung 设 IMGYOUNG_TRUSTED_PROXIES 为它看到的对端所在网段:ImgYoung 在容器里时,
# 不论 Nginx 在宿主机还是另一个容器,对端都是 Docker 网络里的地址(docker network inspect 查看);
# Nginx 在别的主机上时,是 Nginx 的地址。
server {
listen 80;
server_name img.example.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
http2 on; # nginx 1.25.1 起的写法;更早的版本改成 listen 443 ssl http2;
server_name img.example.com;
ssl_certificate /etc/nginx/certs/img.example.com/fullchain.pem;
ssl_certificate_key /etc/nginx/certs/img.example.com/privkey.pem;
# 不小于后台「单图上限」(出厂 50MB),多出的部分留给 multipart 表单开销;Nginx 默认只有 1MB
client_max_body_size 60m;
# 指标只给内网抓取,公网入口一律不转发
location = /metrics {
return 404;
}
location / {
proxy_pass http://127.0.0.1:8721;
proxy_set_header Host $host;
# ImgYoung 从 X-Forwarded-For 自右向左取第一个非信任代理的地址作客户端地址,
# 登录限速与 /metrics 放行都按它判定;少了这一行,所有请求都会被当成来自 Nginx 本身
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# 决定会话 cookie 是否带 Secure,以及未配置 base_url 时返回链接的协议与域名
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
}
}
```
示例里的几行 `proxy_set_header` 都不能省:
- `Host $host`:S3 协议面的签名校验包含 `Host` 头,反代改写它会导致 S3 客户端签名不匹配;
- `X-Forwarded-For`:缺了它,所有请求都会被当成来自 Nginx 本机,见下一节;
- `X-Forwarded-Proto` 与 `X-Forwarded-Host`:ImgYoung 取它们的第一个值,所以要用覆盖写法(`proxy_set_header`),不能把自己的值追加在客户端传来的值后面。
## 信任的反代:IMGYOUNG_TRUSTED_PROXIES [#信任的反代imgyoung_trusted_proxies]
ImgYoung 从请求推导三件事:**客户端地址**(登录限速的键、`/metrics` 的放行判定)、**第一跳是否 HTTPS**(会话 cookie 的 `Secure`)、**对外域名**(未配置 `base_url` 时拼返回链接)。只有 TCP 连接的对端地址落在 `IMGYOUNG_TRUSTED_PROXIES` 网段内时,才会采信 `X-Forwarded-*` 头;变量未设置时取默认值 `127.0.0.0/8,::1/128`,只信任同机的反代;设为空串(或只有空白)表示一个代理都不信任,转发头一律忽略,后台「引导配置」卡片上显示为「空:不采信任何转发头」。
对端受信任时:
1. 依次收集全部 `X-Forwarded-For` 头里逗号分隔的地址,无法解析的项跳过。
2. **自右向左**逐个检查,跳过落在信任网段内的地址,遇到的第一个不在信任网段内的地址就是客户端地址。
3. 整条链都是信任网段内的地址(内网客户端)时,取最左边那个;头缺失或没有可用地址时,取连接对端。
4. `X-Forwarded-Proto` 取第一个值,等于 `https`(不区分大小写)即视为 HTTPS 访问。
5. `X-Forwarded-Host` 取第一个值,用于推导对外域名。
对端不受信任时,`X-Forwarded-*` 一律忽略:客户端地址就是连接对端,HTTPS 判定看连接本身(ImgYoung 不终结 TLS,所以恒为否)。
**为什么伪造无效。**每一级代理都把它看到的对端追加到 `X-Forwarded-For` 末尾,客户端能控制的只有最左边的部分。例如客户端 `203.0.113.7` 自带 `X-Forwarded-For: 1.2.3.4`,经同机 Nginx 后变成 `1.2.3.4, 203.0.113.7`;自右向左第一个非信任地址是 `203.0.113.7`,伪造的 `1.2.3.4` 不起作用。直接连 ImgYoung 的请求,对端不在信任网段内,带什么转发头都不会被采信。
按部署拓扑取值:
| 拓扑 | ImgYoung 看到的连接对端 | `IMGYOUNG_TRUSTED_PROXIES` |
|---|---|---|
| 反代与 ImgYoung(二进制或 systemd)在同一台主机 | `127.0.0.1` / `::1` | 默认值即可 |
| ImgYoung 跑在 Docker 里,反代在宿主机上 | Docker 网桥的网关地址,不是回环 | 网关所在网段,如 `172.16.0.0/12`;用 `docker network inspect` 查看实际网关 |
| 反代与 ImgYoung 是同一 Compose 项目里的两个容器 | 反代容器在项目网络里的地址 | 项目网络的网段 |
| 反代在另一台主机 | 反代主机的内网地址 | 该地址或其网段 |
| 反代前面还有 CDN | 反代地址;`X-Forwarded-For` 里最右边一跳是 CDN 节点 | 反代网段加上 CDN 的回源网段,客户端地址才会解析到真实访客 |
连接对端不在信任网段内时,所有经反代的请求都被当成来自反代自身:全站访客共用一个登录限速额度;如果反代地址恰好在 `IMGYOUNG_METRICS_ALLOW` 内(Docker 网段默认就在),而反代又转发了 `/metrics`,任何人都能读到指标。反代没有设置 `X-Forwarded-For` 时也是同样的后果。
## 只监听回环 [#只监听回环]
有了反代,就不要让 ImgYoung 的端口直接对外:
设置 `IMGYOUNG_LISTEN=127.0.0.1:8721`。systemd 部署用 `sudo systemctl edit imgyoung` 加一行 `Environment=IMGYOUNG_LISTEN=127.0.0.1:8721` 后重启,见[配置](../configuration.mdx)。
容器内保持默认监听,只把端口发布到宿主机回环:
```bash title="终端"
docker run -d --name imgyoung --restart unless-stopped \
-p 127.0.0.1:8721:8721 -v imgyoung-data:/data \
-e IMGYOUNG_TRUSTED_PROXIES=172.16.0.0/12 \
ghcr.io/x-haose/imgyoung:latest
```
写成 `-p 8721:8721` 会发布到所有网卡,而且 Docker 自己管理的端口转发规则不受 ufw 等主机防火墙约束。
```yaml title="compose.yaml"
services:
imgyoung:
ports:
- "127.0.0.1:8721:8721"
environment:
IMGYOUNG_TRUSTED_PROXIES: 172.16.0.0/12
```
反代也在同一 Compose 项目里时,可以去掉 `ports`,让反代直接访问 `imgyoung:8721`。
## 什么时候必须设置 base_url [#什么时候必须设置-base_url]
`base_url`(后台「设置 → 运行配置 → 访问与链接 → 对外站点地址」)为空时,ImgYoung 按请求推导对外地址:协议取 HTTPS 判定,域名在对端受信任时取 `X-Forwarded-Host`,否则取 `Host`。以下情况必须显式设置:
- **对外域名与 ImgYoung 收到的域名不一致**:CDN 用另一个域名回源、反代改写了 `Host` 又没传 `X-Forwarded-Host`、反代不在信任网段内等。此时上传接口返回的 `url`、后台复制的分享链接、「S3 客户端配置」弹窗里的端点与链接前缀都会是错的。
- **启用 CDN 缓存清除**:清除任务在后台异步执行,没有请求可推导,只认 `base_url`,见 [CDN](./cdn.mdx)。
- **网盘渠道一键授权**:授权回调地址必须与开放平台登记的地址逐字一致,只用 `base_url` 拼出,见[网盘渠道](../channels/cloud-drives.mdx)。
## 上传体积上限要与反代一致 [#上传体积上限要与反代一致]
ImgYoung 的单图上限(`max_upload_mb`,出厂 50 MB)只约束文件本身,恰好等于上限的文件可以上传;整个请求体的上限是单图上限再加 64 KiB,留给 multipart 边界与 `exif_policy`、`ttl` 等短字段。反代的请求体上限不应小于这个值:
- Nginx 默认只允许 1 MB,示例里设为 `client_max_body_size 60m;`;
- Caddy 示例没有限制请求体;如果你用 `request_body` 设置了 `max_size`,不要小于单图上限加 64 KiB。
在后台调大单图上限后,记得同步调大反代的限制,否则大图会在反代处直接被 413 拒掉,请求到不了 ImgYoung。
# CDN (/zh/docs/operations/cdn)
在 ImgYoung 前面加 CDN:缓存头、WebP 协商与 Vary、按需处理的缓存键、Cloudflare 缓存清除,以及 302 模式和防盗链在 CDN 后的表现。
## 推荐模式:代理 + CDN [#推荐模式代理--cdn]
CDN 回源到 ImgYoung(经[反向代理](./reverse-proxy.mdx)),访问模式保持 `proxy`:
- 图片字节由 ImgYoung 从渠道读出,渠道故障在回源时即时切换,CDN 只看到一个稳定的源站;
- 按需处理(`?w=`、`?format=`)与 WebP 协商照常工作,结果由 CDN 缓存;
- 对外域名是 CDN 的域名,所以要在后台设置 `base_url`(「设置 → 运行配置 → 访问与链接 → 对外站点地址」),并把 CDN 的回源网段加进 `IMGYOUNG_TRUSTED_PROXIES`,登录限速才能按真实访客计,见[反向代理与 HTTPS](./reverse-proxy.mdx)。
只缓存图片入口 `/i/*` 与 `/o/*`;`/api/*`、`/s3/*` 应设为不缓存。后台页面的 `index.html` 带 `no-cache`,`/assets/` 下的文件名带内容哈希并带一年强缓存,随 CDN 默认规则即可。
## 缓存头 [#缓存头]
图片入口的响应头:
| 响应 | `Cache-Control` | `ETag` | `Vary` |
|---|---|---|---|
| `/i/<短ID>` 的原图 | `public, max-age=31536000, immutable` | `""` | 未指定 `format`(或 `format=auto`)访问 JPEG / PNG / WebP 时为 `Accept` |
| `/i/<短ID>` 的衍生图(缩放或转格式)| 同上 | `"-<宽度>-<格式>"`,不缩放时宽度为 `0` | 目标格式由协商得出时为 `Accept` |
| `/o/` 别名的原图与衍生图 | `public, no-cache` | 同上 | 同上 |
| 按需处理失败、回退的原图 | `no-store` | 无 | 无 |
| `If-None-Match` 命中 | 304,带对应的 `Cache-Control`、`ETag` 与 `Vary` | | |
| 302(`redirect` 模式)| `no-store` | 无 | 无 |
| 参数非法(400)、防盗链拒绝(403)、不存在或已到期(404)、查询元数据失败(500,可重试)| 无 | 无 | 无 |
原图响应另带 `Last-Modified`(上传时刻)。`/i/<短ID>` 绑定的内容永不改变,所以按一年强缓存。
`/o/<用户 ID>/<对象键>` 是 S3 客户端自取的别名,同一对象键重新上传时别名会改指新图,所以它的响应允许缓存,但每次使用前都要带 ETag 回源确认:内容没变得到 304,改指后得到新图,删除后得到 404。经 CDN 对外时,`/i/` 链接的缓存效果更好。
## WebP 协商与 Vary [#webp-协商与-vary]
不带 `format` 参数(或 `format=auto`)访问 JPEG / PNG 图片时,如果请求头 `Accept` 含 `image/webp`、且「目标格式」里有 `webp`,返回的是 WebP 衍生图,否则返回原格式。也就是说,**同一个 URL 会按 `Accept` 返回不同格式**,两种响应(包括 304)都带 `Vary: Accept`。
CDN 必须按 `Accept` 区分缓存(遵守 `Vary: Accept`,或把 `Accept` 纳入缓存键),否则先被缓存的 WebP 会发给不支持 WebP 的客户端。Cloudflare 可以在 Cache Rules 中配置对 `Vary` 的处理,其他 CDN 以各自文档为准。CDN 做不到时,在后台「按需处理 → 目标格式」里去掉 `webp`:不再发生协商,代价是 `?format=webp` 也随之不可用。
## 按需处理参数与缓存键 [#按需处理参数与缓存键]
- `w` 必须是「宽度档」里的值,`format` 必须是「目标格式」里的值或 `auto`,否则返回 400。白名单限定了一张图最多有多少种衍生图,任意参数打不出无限多的缓存条目。
- 其他查询参数被 ImgYoung 忽略,却会进入 CDN 默认的缓存键:给 URL 随便加个参数就能绕过缓存直接回源。建议把缓存键的查询串限定为 `w` 和 `format`。
- 参数顺序固定写成 `?w=…&format=…`。缓存清除只按这个顺序清理(见下节),顺序不同的 URL 在 CDN 看来是另一条缓存。
## Cloudflare 缓存清除 [#cloudflare-缓存清除]
图片被删除后,CDN 边缘还会按一年强缓存继续提供它的 `/i/` 链接。配置 Cloudflare 缓存清除后,ImgYoung 在删除时主动清理边缘缓存。
在后台「设置 → 运行配置」中配置三项:
| 配置 | 位置 | 说明 |
|---|---|---|
| Cloudflare Zone ID | 「CDN 缓存清理」分组 | 站点所在 Zone 的 ID |
| Cloudflare API Token | 「CDN 缓存清理」分组 | 需要该 Zone 的 Cache Purge 权限;以主密钥加密入库,保存后不再回显 |
| 对外站点地址(`base_url`)| 「访问与链接」分组 | 清除用它拼出公开 URL,必须与 CDN 上的域名一致 |
三项齐备时分组标题旁显示「purge 已启用」,缺任一项都不清除;进程启动时也会在日志里打印当时的启用状态。配置保存即生效,不需要重启。
**触发时机**:一条图片引用被删除的那一刻,不论来源——后台删除(单张或批量)、S3 协议面的 `DeleteObject`、有效期到期回收。清除不等删除宽限,也不等渠道对象真正删掉。查重合并改指向的图片不会触发清除。
**清除的 URL**:`/i/<短ID>`,再加上这张图实际生成过的每个衍生图对应的 `?w=<宽度>`、`?w=<宽度>&format=<格式>`(缩放过的)或 `?format=<格式>`(只转格式的)。
**执行方式**:作为后台任务异步执行,调用 Cloudflare 按 URL 清除缓存的接口 `POST /zones//purge_cache`,每次最多 100 个 URL;失败由任务队列退避重试,不影响删除本身。
**不在清除范围内的**:`format=auto` 形式、带其他查询参数或参数顺序不同的 URL,只能等 CDN 自身的缓存过期。`/o/` 别名链接不需要清除:它们每次都回源确认。
## 302 省流量模式与 CDN [#302-省流量模式与-cdn]
访问模式为 `redirect` 时,原图请求被 302 到某个允许直链、未加密且填写了公开地址的渠道,图片字节由渠道(或渠道自己的域名、CDN)直接提供,不经过你在 ImgYoung 前面的 CDN。没有这样的渠道时回退为代理。
- 302 响应一律带 `Cache-Control: no-store`,CDN 与浏览器都不缓存重定向本身:每次访问都回到 ImgYoung,渠道切换、删除与到期即时生效。
- 需要处理的请求(带 `w` / `format` 参数,或发生了 WebP 协商)仍由 ImgYoung 处理后直接输出,不会 302。要让浏览器的原图访问也走 302,需要去掉目标格式里的 `webp`。
已经在 ImgYoung 前面放了 CDN 时,用 `proxy` 模式即可:流量已由 CDN 承担。`redirect` 适合没有 CDN、上行带宽小的服务器,或渠道自带公开 CDN 域名(如对象存储绑定的自定义域名)的情况。
## 防盗链在 CDN 后的局限 [#防盗链在-cdn-后的局限]
ImgYoung 的 Referer 白名单只对回源请求生效:
- CDN 命中缓存的请求不回源,也就不受白名单约束;缓存键默认不含 Referer,一张图被白名单内的站点访问并缓存后,任何来源都能从 CDN 拿到;
- ImgYoung 拒绝时返回的 403 不带 `Cache-Control`,如果 CDN 缓存了这个 403,白名单内的访问也会被拒。
经 CDN 对外时,应在 CDN 上做防盗链(按 Referer 拦截的规则),ImgYoung 的白名单留空或只作补充。`redirect` 模式下渠道直链直接暴露,防盗链同样无效。
# 备份与恢复 (/zh/docs/operations/backup)
元数据库自动快照、从快照恢复、主机全损后的灾难恢复、元数据导出与对账 GC。
## 需要备份什么 [#需要备份什么]
渠道里的对象用内容哈希命名,图片与短链、用户、Token 的对应关系全在元数据库里。元数据库丢了,渠道里的图片就无法再对应回来;主密钥丢了,实例拒绝启动,渠道凭证与加密渠道的数据也都无法解密。
| 内容 | 位置 | 如何备份 |
|---|---|---|
| 主密钥 | `<数据目录>/master.key`,或环境变量 `IMGYOUNG_MASTER_KEY` | 首次启动后复制一份离线保存,与快照分开存放,见[配置](../configuration.mdx#主密钥) |
| 元数据库 | `<数据目录>/imgyoung.db` | 自动快照(写到每个健康渠道),另外定期下载一份存到别处 |
| 图片 | 各渠道 | 配置多个渠道,由副本补齐保证每张图不止一份;内置 local 渠道的数据在 `<数据目录>/blobs/` |
| 加密渠道的 rclone 配置 | 后台「存储渠道」页的「导出 rclone 配置」 | 每个加密渠道导出一份,和主密钥一样离线保存,见[渠道存储加密](../channels/encryption.mdx) |
| 部署配置 | 环境变量、systemd drop-in、`compose.yaml`、反代配置 | 纳入你自己的配置管理 |
`<数据目录>/variants/` 是衍生图缓存,丢失后按需重新生成,不必备份。服务运行中不要直接复制 `imgyoung.db`:数据库处于 WAL 模式,最近的写入可能还在 `imgyoung.db-wal` 里。要拿一份一致的副本,用后台下载快照,或先停服再复制。
## 元数据库自动快照 [#元数据库自动快照]
- **生成**:每轮用 SQLite 的 `VACUUM INTO` 生成一份一致的快照,先写到 `<数据目录>/backup-snapshot.tmp`(需要约一个数据库大小的空闲空间),写完各渠道后删除。
- **存放位置**:同一份快照写到**每个**健康且不在清理中的渠道,对象键为 `_imgyoung/backup/imgyoung-.db`(如 `imgyoung-20261015-083000.123.db`),位于渠道路径之下;内置 local 渠道上即 `<数据目录>/blobs/_imgyoung/backup/`。
- **加密**:写到加密渠道上的快照随渠道一起加密,渠道商看不到内容。
- **节奏**:每隔「备份间隔」(`backup_interval_hours`,出厂 24 小时)一次,间隔从进程启动起算,修改间隔后从修改时刻重新计时。定时备份与手动「立即备份」依次执行,同时发生时排队。
- **启动时**:任一健康渠道上没有备份间隔内的快照,就立即备份一次,否则跳过。新接入渠道后的下一次启动会补上一份;频繁重启也不会用同一天的快照挤掉轮换下来的历史。
- **保留**:每个渠道写入成功后,各自只保留最新的「备份保留份数」(`backup_keep`,出厂 7)份,更旧的删除。
- **失败**:单个渠道写失败不影响其余渠道继续写,但只要有渠道没写成,这一轮就算失败,开启 `backup_failed` 告警;写成的渠道保留这份快照。没有健康渠道、生成快照失败同样算失败。下一次成功后告警自动恢复,见[监控与告警](./monitoring.mdx)。
- **删除渠道时**:「删除并清理」会连同渠道上的快照一起删除;「仅移除」不动远端对象,快照留在原处。
本机磁盘以外没有健康的渠道时(只有内置 local 渠道,或其他渠道都不健康),图片副本和快照都只在这台主机上,主机损坏即全部丢失,总览页会提示「数据只存在这台主机上」。接入一个对象存储或网盘渠道,快照就会同时写到它上面,不需要调整优先级。
写到加密渠道上的快照是密文,解密用的 crypt 密码与盐由主密钥与该渠道在元数据库里的编号、创建时刻派生。元数据库整体丢失后,只要还有未加密的渠道持有快照,直接从它取回即可;快照只存在加密渠道上时,要靠事先用「导出 rclone 配置」下载并离线保存的配置文件读出,做法见[渠道存储加密](../channels/encryption.mdx)。
## 在后台查看与管理快照 [#在后台查看与管理快照]
「设置 → 备份与导出 → 元数据库快照」列出各健康渠道上快照的并集,最新在前;「所在渠道」列出持有每份快照的渠道,按优先级排列。
- 「立即备份」:马上做一次快照,写到每个健康渠道,同样会清理各渠道超出保留份数的旧快照。有渠道没写成时返回错误并列出失败的渠道与原因,形如 `备份失败:快照 imgyoung-20261016-083000.123.db 有 1/3 个渠道未写成: 渠道 "r2-main": 上传快照: …`,写成的渠道保留这份快照;没有健康渠道时返回 `没有健康渠道可存放快照`。
- 「下载」:按优先级依次从持有该快照的健康渠道读取,读不到换下一个。下载的文件就是一个完整的 SQLite 数据库。
- 「恢复」:同样按优先级读取,见下一节。
不健康或清理中的渠道上的快照不在列表里,也不参与下载与恢复。任一健康渠道列举失败时列表整体报错(500)。没有健康渠道时各项操作都返回 503:列表为 `没有健康渠道,无法列举快照`,下载与恢复为 `没有健康渠道,无法读取快照`,立即备份为 `没有健康渠道可存放快照`。健康渠道上都没有这份快照时,下载与恢复返回 404 `快照不存在`;有渠道读取失败、又没有一个读得出时返回 500。
## 从快照恢复 [#从快照恢复]
运行中的 SQLite 文件无法安全替换,所以恢复分两步:先暂存,再在下一次启动、打开数据库之前换入。
**暂存并停机**:在快照行点「恢复」,确认「恢复并重启」。服务把快照下载到 `<数据目录>/imgyoung.db.restore`(先写临时文件,完整后再改名),记一条 `backup.restore` 审计日志,返回后开始优雅停机:先停止接收新请求,在途请求最多等 6 秒,再停后台循环与补齐任务,最多等 2 秒。
**由进程管理器拉起**:进程正常退出,需要进程管理器把它重新拉起。Docker 用 `--restart unless-stopped`、Compose 用 `restart: unless-stopped`、systemd 用 `Restart=always`(`deploy/` 里的示例都已这样配置)。直接在终端运行的二进制会就此退出,暂存文件留到你下次手动启动时换入。
**换入**:启动时、打开数据库之前,先在数据目录写下换入标记 `imgyoung.db.restored`,再把现有的 `imgyoung.db` 连同 `-wal`、`-shm` 改名为 `imgyoung.db.pre-restore-` 留存,暂存文件改名为 `imgyoung.db`。随后照常执行数据库迁移,较早版本的快照会被迁移到当前版本。
**重置绑定时刻**:迁移之后、加载主密钥之前,见到换入标记就把全部渠道的绑定时刻(见[对账 GC](#对账-gc))重置为当前时刻,随后删除标记,日志 `换入快照后已把渠道绑定时刻重置为当前时刻,快照之后写入渠道的对象不会被对账回收`,字段 `channels` 为重置的渠道数。这一步失败时标记保留,下次启动会再做。
**重新登录**:后台页面在服务恢复后自动刷新。会话与密码都回到快照时刻的状态,要用那时的密码登录。
留存的 `imgyoung.db.pre-restore-*` 不会自动删除,确认恢复无误后手动清理。
恢复把元数据整体退回快照时刻,在此之后发生的变化与渠道上的实际对象不再一致:
- **快照之后上传的图片**:元数据里没有它们。它们在渠道上的对象早于重置后的绑定时刻,对账 GC 只计数(`foreign`)、不回收;对象留在渠道上,但无法再通过 ImgYoung 访问;
- **快照之后删除的图片**:元数据里还在,但渠道对象已删,访问失败,并以「副本补齐失败」告警出现,在后台删掉即可;
- **快照之后的其他改动**:新建的渠道、用户、Token,修改过的运营配置与防盗链规则,全部退回快照时刻。
## 主机全损后的灾难恢复 [#主机全损后的灾难恢复]
前提:你有快照所属实例的 master.key 备份,并且能拿到一份快照文件,来源可以是:
- 自己下载保存的快照;
- 任一**未加密**渠道上的快照:用渠道服务商的控制台或 rclone 下载渠道路径下 `_imgyoung/backup/` 里最新的 `imgyoung-*.db`;
- 加密渠道上的快照:用事先导出的 rclone 配置文件经 crypt 远端读出,见[渠道存储加密](../channels/encryption.mdx)。
**在新机器上安装同一或更新版本**,见[安装](../install/index.mdx)。已经启动过也没关系:换入快照时,全新实例的数据库会被改名留存;只是它首次启动时生成的 `master.key` 必须在下一步换成原来那把。元数据库里记着主密钥的指纹,换入快照后,主密钥缺失或与快照不符时进程拒绝启动,不会生成新密钥。
**停服,放入主密钥与快照**:在数据目录里放入原来的 `master.key`,把快照文件放为 `imgyoung.db.restore`。
快照必须放成 `imgyoung.db.restore`,不要直接覆盖 `imgyoung.db`:只有经暂存文件换入,启动时才会写下换入标记、重置渠道绑定时刻。直接覆盖的话,快照之后写进渠道的对象在对账 GC 看来就是本库的孤儿,过了宽限期会被删除。
```bash title="终端"
mkdir -p data
cp imgyoung-master.key data/master.key && chmod 600 data/master.key
cp imgyoung-20261015-083000.123.db data/imgyoung.db.restore
```
镜像以 uid 65532 运行且没有 shell,借一个临时容器写入数据卷并修正属主:
```bash title="终端"
# 容器已在运行时先 docker stop imgyoung;还没有容器时按原来的参数先建出来(不启动)
docker create --name imgyoung --restart unless-stopped -p 8721:8721 \
-v imgyoung-data:/data ghcr.io/x-haose/imgyoung:latest
docker run --rm --volumes-from imgyoung -v "$PWD":/in:ro alpine:3.24 sh -c '
cp /in/imgyoung-master.key /data/master.key &&
cp /in/imgyoung-20261015-083000.123.db /data/imgyoung.db.restore &&
chmod 600 /data/master.key &&
chown 65532:65532 /data /data/master.key /data/imgyoung.db.restore'
docker start imgyoung
```
```bash title="终端"
docker compose stop # 还没有启动过时改用 docker compose create
docker run --rm --volumes-from "$(docker compose ps -a -q imgyoung)" -v "$PWD":/in:ro alpine:3.24 sh -c '
cp /in/imgyoung-master.key /data/master.key &&
cp /in/imgyoung-20261015-083000.123.db /data/imgyoung.db.restore &&
chmod 600 /data/master.key &&
chown 65532:65532 /data /data/master.key /data/imgyoung.db.restore'
docker compose up -d
```
`install.sh --service` 安装后服务已经启动过一次,`/var/lib/imgyoung` 已由 systemd 建好并归属运行用户。放入文件后把属主改成与现有数据库一致:
```bash title="终端"
sudo systemctl stop imgyoung
sudo cp imgyoung-master.key /var/lib/imgyoung/master.key
sudo cp imgyoung-20261015-083000.123.db /var/lib/imgyoung/imgyoung.db.restore
sudo chown --reference=/var/lib/imgyoung/imgyoung.db \
/var/lib/imgyoung/master.key /var/lib/imgyoung/imgyoung.db.restore
sudo chmod 600 /var/lib/imgyoung/master.key
sudo systemctl start imgyoung
```
主密钥用环境变量 `IMGYOUNG_MASTER_KEY` 提供的,改设环境变量即可,不必放文件。
**启动并检查**:二进制方式此时启动 `imgyoung`。日志里依次出现 `已用暂存快照换入元数据库,原库留存` 与 `换入快照后已把渠道绑定时刻重置为当前时刻,快照之后写入渠道的对象不会被对账回收`,即换入完成。用快照时刻的账号密码登录,在「存储渠道」页确认各渠道恢复健康。
如果启动失败并提示主密钥与数据库不符,或找不到主密钥,说明放进去的不是快照所属实例的主密钥,或者没有放。此时快照已经换入,换上正确的主密钥再启动即可,不必重新放快照。
恢复后,原来只存在 local 渠道上的图片已随主机丢失;其他渠道上有副本的图片照常可访问,local 渠道上缺失的副本会被对账 GC 发现并从其他渠道补回。
快照在未加密渠道上时,也可以不手动取文件:先启动一个全新实例,在后台按原参数接入存放快照的渠道,再从「元数据库快照」里恢复;列表包含所有健康渠道上的快照,不需要调整优先级。这个全新实例必须从首次启动起就用原来的主密钥(首次启动前放入 `master.key` 或设置 `IMGYOUNG_MASTER_KEY`),否则恢复后重启时主密钥与快照不符,进程拒绝启动。已经用自动生成的主密钥启动过的,先停服,换上原来的 `master.key`,删掉数据目录里全新的 `imgyoung.db`(连同 `-wal`、`-shm`)后再启动,然后再接入渠道。新接入的渠道以接入时刻为绑定时刻,桶里原有的对象都早于它,对账 GC 只计数、不回收,等待期间不会被删。
接入后、恢复前,不要点「立即备份」,也不要重启全新实例:全新实例的空数据库快照会写进每个健康渠道,包括刚接入的旧渠道,并按保留份数挤掉那里最旧的一份原有快照。点「恢复」引起的那次重启没有这个问题:启动时先换入快照,之后备份的是恢复后的数据库。
## 元数据导出 [#元数据导出]
「设置 → 备份与导出 → 数据导出 → 导出 JSON」下载 `imgyoung-export.json`(每次导出记一条 `data.export` 审计日志),内容:
| 字段 | 内容 |
|---|---|
| `users` | 用户:ID、登录名、角色、配额、默认有效期、是否禁用、用量与图片数;不含密码哈希 |
| `channels` | 渠道:ID、名称、驱动、优先级、健康状态、是否加密、是否允许直链、创建时刻、副本水位;不含任何配置参数与凭证 |
| `tokens` | Token:名称、归属用户、明文前缀、授权范围、吊销与签发时刻;不含哈希 |
| `images` | 每条图片引用:短 ID、物理图、归属用户与 Token、原始文件名、对象键别名、有效期、访问统计、清理标记 |
| `blobs` | 每个物理图:SHA-256、感知哈希、渠道内对象键、MIME、大小、宽高、引用计数、删除状态 |
| `blob_channels` | 每份副本在哪个渠道、状态与最近错误 |
导出可读、自包含,即使不再运行 ImgYoung,也能凭它和渠道里的对象把图片对应回来。它不是恢复用的格式:恢复用快照。运营配置、防盗链规则、审计日志与告警不在导出内。
## 对账 GC [#对账-gc]
补齐子系统每 24 小时对每个健康渠道做一次双向对账(第一次在进程启动 24 小时后),渠道内 `_imgyoung/` 前缀下的系统对象(含快照)不参与。
孤儿判定以渠道的**绑定时刻**为界:渠道记录开始对应当前存储位置的时刻,ImgYoung 只会在这之后往该位置写对象。
| 情形 | 绑定时刻 |
|---|---|
| 新建渠道 | 创建时刻 |
| 后台编辑渠道,后端名或任何配置参数有改动 | 重置为编辑时刻;重新输入同一个密码也算改动(rclone 编码密码时带随机 IV,编码结果每次不同)|
| 只改名称、优先级或直链开关;服务商刷新令牌后的写回 | 不变 |
| 从快照恢复(经 `imgyoung.db.restore` 换入)| 全部渠道重置为换入后启动的时刻 |
- **孤儿**:同时满足以下条件的对象被删除,免得按量计费的渠道为无主对象付费:
- 键名是本服务的对象键格式 `<短 ID>_`;
- 元数据库里没有它在该渠道上的任何副本记录(不论状态);
- 修改时间不早于该渠道的绑定时刻;
- 修改时间已超过 48 小时宽限期,宽限期覆盖「已写入渠道、尚未落库」的正常中间状态。
- **深探测残留**:深度健康探测写入后即删的临时对象 `.imgyoung-probe-*`,删除失败时才会留下。过了 48 小时宽限期即删除,不看写入时刻(测试连接时留下的残留会早于渠道记录),也不计入对象数与用量。
- **外来对象**:元数据库里没有记录、但键名不是本服务格式,或修改时间早于绑定时刻的对象,比如别的程序写进同一路径的文件、灾备时新实例接入的旧桶里原有的对象、从快照恢复后快照之后写入的对象。它们只计数,不回收。
- **幻影**:元数据库记为已就绪、渠道上实际缺失的副本。状态退回待补齐,重新入队从其他渠道复制。
每个渠道对账完成后记一条 `渠道对账完成` 日志,字段 `objects`(对象数)、`bytes`(字节数)、`foreign`(外来对象数)、`orphans_deleted`(回收的孤儿数,含深探测残留)、`phantoms_requeued`(重新入队的幻影数)。
两个 ImgYoung 实例接入同一个存储路径时,对方在本实例绑定之后写出的对象,在本库看来就是孤儿,过了宽限期会被删掉。每个实例要用各自的桶或路径。
此外每 10 分钟有一次副本对账(不列举渠道对象):把恢复健康的渠道上失败的副本重新置为待补齐,把未完成的副本重新入队。
# 升级 (/zh/docs/operations/upgrade)
各安装方式的升级命令、启动时自动执行的数据库迁移,以及回滚时的注意事项。
## 升级前 [#升级前]
1. 读一遍目标版本的 [Release 说明](https://github.com/x-haose/imgyoung/releases)。
2. 留一份回滚用的元数据库副本:在后台「设置 → 备份与导出 → 元数据库快照」点「立即备份」,再「下载」这份快照,保存在服务器以外。也可以停服后复制 `<数据目录>/imgyoung.db`(停服后才是一致的)。
3. 确认 master.key 已有备份,见[配置](../configuration.mdx#主密钥)。
当前版本可用 `imgyoung -version` 查看,或看后台「设置 → 运行配置 → 引导配置」里的「服务端版本」。
## 升级命令 [#升级命令]
重新执行安装脚本即升级到最新正式版;指定版本加 `--version X.Y.Z`。
```bash title="终端"
# 用 systemd 服务运行的(当初加了 --service):替换二进制、重写服务单元并重启服务
curl -fsSL https://raw.githubusercontent.com/x-haose/imgyoung/main/install.sh | sudo sh -s -- --service
# 自己管理进程的:只替换二进制,之后自行重启 imgyoung
curl -fsSL https://raw.githubusercontent.com/x-haose/imgyoung/main/install.sh | sh
```
`--service` 会用发布包里的 `deploy/imgyoung.service` 覆盖 `/etc/systemd/system/imgyoung.service`;自定义的环境变量请放在 `systemctl edit` 创建的 drop-in 里,不会被覆盖。
拉取新镜像,删掉旧容器,用**同一个数据卷**和原来的参数重新创建。删除容器不会删除命名数据卷。原来用 `-e IMGYOUNG_MASTER_KEY=…` 传主密钥的,重建时不要漏掉它:数据库记着主密钥指纹,找不到主密钥时进程拒绝启动。
```bash title="终端"
docker pull ghcr.io/x-haose/imgyoung:latest
docker stop imgyoung && docker rm imgyoung
docker run -d --name imgyoung --restart unless-stopped \
-p 8721:8721 -v imgyoung-data:/data \
ghcr.io/x-haose/imgyoung:latest
```
镜像标签:`latest` 跟随最新正式版;`1.0` 这样的「主.次」标签跟随该系列的最新补丁版;`1.0.3` 这样的完整版本号固定不变。预发布版只有完整版本号标签。
```bash title="终端"
docker compose pull
docker compose up -d
```
`compose.yaml` 里钉的是「主.次」或完整版本号时,跨系列升级要先改 `image:` 的标签。
从[发布页](https://github.com/x-haose/imgyoung/releases)下载对应平台的新压缩包,用同目录的 `checksums.txt` 校验后解压,替换旧的 `imgyoung` 二进制,然后重启进程。数据目录不动。
```bash title="终端"
sha256sum -c checksums.txt --ignore-missing
tar -xzf imgyoung_<版本号>_linux_amd64.tar.gz
```
```bash title="终端"
git fetch --tags
git checkout vX.Y.Z
task build
```
用新生成的 `./imgyoung` 替换正在运行的二进制并重启。
停止旧进程时,ImgYoung 先停止接收新请求,在途请求最多等 6 秒(超时强制断开),再停后台循环与在途的补齐任务,最多等 2 秒,然后关库退出。被打断的补齐任务下次启动由对账重新入队。Docker 默认的 10 秒停止时限,以及 `deploy/` 示例里的 `stop_grace_period: 15s`(Compose)与 `TimeoutStopSec=15s`(systemd)都够用。
## 数据库迁移 [#数据库迁移]
数据库迁移打包在二进制里,每次启动、打开数据库之后自动执行:
- 先校验迁移文件与校验和清单一致,再按版本顺序应用所有尚未应用的迁移;跨多个版本升级时,中间版本的迁移会依次补齐。已应用的版本记录在数据库的 `schema_revisions` 表里。
- 补齐子系统的任务队列表也在启动时迁移到当前版本。
- 新增的运营配置项由迁移按出厂默认值补齐,已有配置保持不变。
- 库里记有本二进制不认识的迁移版本(被更新的版本迁移过)时拒绝启动,见下文[回滚](#回滚)。
- 迁移失败时进程以错误退出,日志给出失败的迁移文件名。此时用升级前的快照回滚(见下文)。
不需要也没有单独的迁移命令。
## 回滚 [#回滚]
不支持降级:迁移只有升级方向,部分迁移会删除列或重建表。旧版本的二进制遇到被新版本迁移过的库会拒绝启动,错误信息写明库里的最新迁移版本与本二进制认识到的最新版本。
所以回滚只有一种做法:**旧版本的二进制 + 升级前的快照**。没有升级前的快照,就只能继续用新版本。
停止服务。
换回旧版本的二进制或镜像标签。
把升级前的快照(或停服时复制的数据库)放为 `<数据目录>/imgyoung.db.restore`,不要直接覆盖 `imgyoung.db`:启动时它被换入,新版本迁移过的库改名为 `imgyoung.db.pre-restore-` 留存,并且渠道的绑定时刻随之重置。Docker 部署的文件属主须为 `65532:65532`,写入方法参考[备份与恢复](./backup.mdx)里借临时容器的做法。
启动服务。
升级之后产生的元数据随之丢失,影响与从快照恢复相同:这期间上传的图片在元数据里没有记录,它们在渠道上的对象只计数、不回收,但无法再通过 ImgYoung 访问,详见[备份与恢复](./backup.mdx)。
# 忘记密码 (/zh/docs/operations/password-reset)
内置管理员忘记密码时,清空密码哈希并重启以恢复出厂密码;管理员为其他用户重置密码。
## 内置管理员忘记密码 [#内置管理员忘记密码]
密码在数据库里是 argon2id 哈希,无法手写。但内置管理员的出厂密码是在启动时播种的:**内置管理员尚未设置密码时**,启动会把它设为出厂密码 `imgyoung123` 并标记为必须改密。所以清空它的 `password_hash` 再重启,就回到首次登录的状态。
没有专门的重置命令:一条 SQL 就能完成。这条路径需要服务器的 shell 或数据库的访问权限,也就是对机器的控制权,不构成远程攻击面。
内置管理员在 `users` 表里的 ID 固定为 `default`。它的登录名可以在后台改,所以下面按 ID 定位,而不是按登录名。
重启后到你改掉密码之前,任何人都能用公开的出厂密码登录并抢先改密。重启后立即登录改密。
需要本机装有 `sqlite3`。先停止 `imgyoung` 进程,对数据目录(默认是启动目录下的 `data/`,或 `IMGYOUNG_DATA_DIR` 指定的目录)里的数据库执行:
```bash title="终端"
sqlite3 data/imgyoung.db "UPDATE users SET password_hash = '' WHERE id = 'default';"
```
然后重新启动 `imgyoung`。
镜像里没有 shell 和 `sqlite3`,借一个临时 Alpine 容器挂上同一个数据卷执行,结束后把数据库文件属主改回镜像的运行用户:
```bash title="终端"
docker stop imgyoung
docker run --rm -i --volumes-from imgyoung alpine:3.24 sh -c \
'apk add -q sqlite && sqlite3 /data/imgyoung.db && chown 65532:65532 /data/imgyoung.db*' <<'SQL'
UPDATE users SET password_hash = '' WHERE id = 'default';
SQL
docker start imgyoung
```
`imgyoung` 换成你的容器名。
在 `compose.yaml` 所在目录执行:
```bash title="终端"
docker compose stop imgyoung
docker run --rm -i --volumes-from "$(docker compose ps -a -q imgyoung)" alpine:3.24 sh -c \
'apk add -q sqlite && sqlite3 /data/imgyoung.db && chown 65532:65532 /data/imgyoung.db*' <<'SQL'
UPDATE users SET password_hash = '' WHERE id = 'default';
SQL
docker compose start imgyoung
```
数据目录是 `/var/lib/imgyoung`,需要本机装有 `sqlite3`:
```bash title="终端"
sudo systemctl stop imgyoung
sudo sqlite3 /var/lib/imgyoung/imgyoung.db "UPDATE users SET password_hash = '' WHERE id = 'default';"
sudo systemctl start imgyoung
```
启动日志里出现 `已初始化默认管理员密码,请登录后立即在后台修改` 即表示播种完成。用内置管理员的登录名(默认 `admin`)和 `imgyoung123` 登录,页面会直接进入「设置管理员密码」,改完后用新密码重新登录。
强制改密由服务端执行:在改掉出厂密码之前,这个会话只能退出、查看自己的账号(`GET /api/admin/me`)和改密,其余后台接口一律返回 403 `须先修改出厂默认密码`,用脚本直接调接口也一样。
这个办法只对内置管理员有效。其他用户的 `password_hash` 被清空后只会变成无法登录,不会恢复任何密码。
## 为其他用户重置密码 [#为其他用户重置密码]
管理员可以在后台为任何用户(包括另一个管理员)重置密码:「用户」页,在该用户行末尾点开操作菜单(「更多操作」),选「重置密码」,输入新密码(至少 8 位)。
- 重置立即生效,该用户的全部会话随即失效,需要用新密码重新登录;
- 重置的密码不要求用户登录后再改;
- 每次重置记一条审计日志(动作 `user.update`,详情 `password.reset`)。
用户自己记得旧密码时,可以在侧栏的账号菜单里选「修改密码」自行修改。对应接口见[接口参考](../api/admin-users.mdx)。
# 监控与告警 (/zh/docs/operations/monitoring)
/healthz 与 /metrics、Prometheus 抓取、告警条件与 Webhook 负载格式、日志。
## /healthz [#healthz]
`GET /healthz` 返回 200 与纯文本 `ok`,不需要认证。它只表示进程在运行、HTTP 服务能响应,**不检查**数据库、渠道或任务队列。适合做进程存活检查与外部可用性探测;渠道是否健康看指标与告警。
`/healthz` 与 `/metrics` 自身的请求不计入 HTTP 指标。
## /metrics [#metrics]
`GET /metrics` 以 Prometheus 文本格式输出指标,与业务共用同一个端口。
**访问控制**:只有客户端地址落在 `IMGYOUNG_METRICS_ALLOW` 内的请求才放行(变量未设置时为回环与私网段),其余返回 403;把变量设为空串(或只有空白)则对谁都不开放。客户端地址按[反向代理与 HTTPS](./reverse-proxy.mdx)里的规则解析。指标里有路由模板、渠道名与用量,不应对公网开放:
- 公网入口(反代)一律不转发 `/metrics`,`deploy/` 里的 Caddy 与 Nginx 示例都对它返回 404;
- 让 Prometheus 从内网直接访问 ImgYoung 的监听地址;
- `IMGYOUNG_TRUSTED_PROXIES` 要配对。配错时经反代的请求都显得来自反代自身,反代地址又在放行网段内的话,转发了 `/metrics` 的反代会把指标暴露给所有人。
### 指标清单 [#指标清单]
全部业务指标以 `imgyoung_` 为前缀:
| 指标 | 类型 | 标签 | 含义 |
|---|---|---|---|
| `imgyoung_http_requests_total` | counter | `method`、`route`、`status` | HTTP 请求数。`route` 是路由模板(如 `/i/{id}`),不是原始路径 |
| `imgyoung_http_request_duration_seconds` | histogram | `method`、`route` | HTTP 请求处理时延(秒)|
| `imgyoung_upload_duration_seconds` | histogram | `outcome` | 上传管道耗时(秒)。`ok` 成功;`rejected` 输入或配额不合规;`failed` 基础设施失败 |
| `imgyoung_channel_puts_total` | counter | `channel`、`result` | 向渠道写对象的次数,含上传时的同步写入与异步补齐;`result` 为 `ok` / `error` |
| `imgyoung_channel_storage_bytes` | gauge | `channel` | 渠道逻辑用量(字节),按已就绪副本的图片大小求和;每隔「用量上报间隔」刷新 |
| `imgyoung_channels_total` | gauge | | 已装载的渠道数,含不健康的,不含清理中和装载失败的 |
| `imgyoung_channels_healthy_total` | gauge | | 当前健康的渠道数 |
| `imgyoung_variant_lookups_total` | counter | `result` | 衍生图缓存查找次数;`result` 为 `hit` / `miss` |
| `imgyoung_queue_jobs_waiting` | gauge | | 任务队列里等待执行的任务数(可认领、已排程与待重试);抓取时查询,查询失败为 `NaN` |
| `imgyoung_queue_jobs_running` | gauge | | 任务队列里正在执行的任务数;同上 |
此外还有 Prometheus Go 客户端自带的 `go_*`(运行时)与 `process_*`(进程)指标。
### Prometheus 抓取配置 [#prometheus-抓取配置]
```yaml title="prometheus.yml"
scrape_configs:
- job_name: imgyoung
static_configs:
- targets: ["10.0.0.5:8721"] # ImgYoung 的内网地址与端口
```
Prometheus 不在默认放行的私网段内时,把它的地址加进 `IMGYOUNG_METRICS_ALLOW`(连同仍需要的默认网段一起写,设置后会整体替换默认值)。
几条常用的告警表达式:
```text title="PromQL"
# 有渠道不健康
imgyoung_channels_healthy_total < imgyoung_channels_total
# 渠道写入持续出错
sum by (channel) (rate(imgyoung_channel_puts_total{result="error"}[10m])) > 0
# 上传因基础设施失败
sum(rate(imgyoung_upload_duration_seconds_count{outcome="failed"}[10m])) > 0
# 任务队列积压
imgyoung_queue_jobs_waiting > 1000
```
## 告警 [#告警]
告警由评估循环产生:启动时评估一次,之后每隔「健康检查间隔」(`health_check_seconds`,出厂 60 秒)评估一次。每轮按现状判断哪些条件成立,与未恢复的告警对账——新成立的开启,不再成立的自动恢复。同一类型、同一对象至多有一条未恢复的告警;条件持续成立期间,详情会随每轮评估更新。
| 类型 | 界面名称 | 对象 | 成立条件 |
|---|---|---|---|
| `channel_unhealthy` | 渠道不健康 | 渠道名 | 在役渠道最近一次健康探测失败,或渠道装载失败;详情为失败原因 |
| `replication_failed` | 副本补齐失败 | 渠道名 | 该渠道上有补齐失败的副本;详情含份数。渠道恢复健康后失败的副本会被重试 |
| `channel_quota_exceeded` | 渠道用量超阈值 | 渠道名 | 设置了「渠道用量告警阈值」且该渠道用量超过它 |
| `drain_failed` | 渠道排空失败 | 渠道名 | 「删除并清理」渠道时,删除渠道上的图片副本或本服务写的系统对象(含元数据库快照)第一次失败即开启,详情为失败原因(任务自身超时或停机中断不算)。排空会按退避一直自动重试,渠道清空后自动移除,告警随之恢复;凭证已失效或存储桶已不存在时,在「存储渠道」页对该渠道「直接移除」,远端对象保留 |
| `backup_failed` | 备份失败 | 空 | 最近一次元数据库备份失败:没有健康渠道、生成快照失败,或有渠道没写成。详情为失败原因,部分渠道没写成时形如 `快照 imgyoung-20261016-083000.123.db 有 1/3 个渠道未写成: 渠道 "r2-main": 上传快照: …`,写成的渠道保留这份快照 |
`backup_failed` 依据的是本进程内最近一次备份的结果,进程重启后在下一次备份失败之前不会再出现。
**在哪里看**:后台「总览」页的「告警」卡片列出当前未恢复的告警,点「历史记录」进入「设置 → 告警历史」,那里分页列出包括已恢复在内的全部告警。告警的开启与恢复也会写入日志(`告警开启`、`告警恢复`)。
### Webhook [#webhook]
在「设置 → 运行配置 → 告警与审计 → 告警 Webhook 地址」填入一个 `http` / `https` 地址后,每条告警在**开启**与**恢复**时各 POST 一次 JSON(`Content-Type: application/json`):
```json title="开启"
{
"kind": "channel_unhealthy",
"target": "r2-main",
"message": "连接超时",
"status": "open",
"opened_at": "2026-10-15 08:30:00"
}
```
```json title="恢复"
{
"kind": "channel_unhealthy",
"target": "r2-main",
"message": "连接超时",
"status": "resolved",
"opened_at": "2026-10-15 08:30:00",
"resolved_at": "2026-10-15 09:05:00"
}
```
| 字段 | 说明 |
|---|---|
| `kind` | 告警类型,取值见上表 |
| `target` | 告警对象(渠道名);`backup_failed` 为空串 |
| `message` | 最近一次评估得到的描述(中文)|
| `status` | `open`(开启)或 `resolved`(恢复)|
| `opened_at` | 开启时刻,UTC,格式 `YYYY-MM-DD HH:MM:SS` |
| `resolved_at` | 恢复时刻,同上格式;只在 `resolved` 时出现 |
投递规则:
- 接收方返回 2xx 即视为成功;连接失败、超时(10 秒)或非 2xx 都会在下一轮评估时重试,直到成功;
- 同一条告警总是先投递开启、再投递恢复;
- 请求不带签名或认证头,需要校验来源时把密钥放进 URL(如查询参数),并使用 `https`;
- 告警开启时没有配置 Webhook 的,该次转折直接记为已投递,之后再配置也不会补发;
- 负载格式是固定的。要推送到即时通讯工具,需要一个转换格式的中转服务。
## 日志 [#日志]
- 日志写到标准错误,格式是 Go `log/slog` 的文本格式:每行以 `time=`、`level=`、`msg=` 开头,后接 `key=value` 形式的字段,消息为中文。级别固定为 INFO 及以上,没有调整级别的配置项。
- 查看:systemd 用 `journalctl -u imgyoung`,Docker 用 `docker logs imgyoung`,Compose 用 `docker compose logs imgyoung`。
- 凭证不进日志:渠道配置参数不出现在任何日志与错误信息里,Cloudflare API Token 只在内存中以明文存在,配置修改的审计记录也只写 `(已配置)` 或 `(空)`;上传 Token 与会话只存哈希。唯一会打印的密码是首次启动(或[重置密码](./password-reset.mdx)后)播种的出厂密码,它本就是公开值。
排障时值得留意的几行:
| 日志 | 含义 |
|---|---|
| `ImgYoung 已启动` | 启动完成,附版本、提交、实际监听地址与数据目录 |
| `已生成主密钥文件,请立即备份……` | 数据目录里是全新的数据库、又没有主密钥,本次启动新生成了 `master.key`。不是首次部署时出现它,多半是数据目录指错了或数据卷没挂上 |
| `启动失败`,`err` 含 `主密钥与数据库不符` 或 `找不到主密钥` | 数据库记有主密钥指纹,而当前主密钥与它不符,或 `IMGYOUNG_MASTER_KEY` 未设置且 `master.key` 不存在。换回原来的主密钥再启动 |
| `CDN purge 已启用(Cloudflare)` / `CDN purge 未启用……` | 启动时 CDN 缓存清除的配置状态 |
| `渠道装载失败,本渠道跳过,健康检查时重试` | 渠道配置解不开或驱动构造失败;渠道列表显示为不健康,原因以 `装载失败:` 开头,每轮健康检查重试装载 |
| `告警开启` / `告警恢复` | 告警状态变化 |
| `元数据库快照已备份` | 一份快照写进了一个渠道,每个渠道一行,字段 `channel`、`key`、`bytes` |
| `启动备份未完成` / `定时备份未完成` | 这一轮备份失败,`err` 与 `backup_failed` 告警的详情相同 |
| `渠道对账完成` | 每日对账 GC 的结果,字段 `objects`、`bytes`、`foreign`(库里没有记录,键名不是本服务格式或早于渠道绑定时刻的对象,只计数不回收)、`orphans_deleted`、`phantoms_requeued`,见[备份与恢复](./backup.mdx) |
| `渠道已清理完毕并删除` | 「删除并清理」的渠道清完并删除了记录,字段 `objects`(删除的图片副本数)、`system_objects`(删除的系统对象数,含元数据库快照与深探测残留)|
| `收到停机请求,优雅停机后由进程管理器拉起` | 后台触发了从快照恢复 |
| `已用暂存快照换入元数据库,原库留存` | 快照已换入,附原库留存路径 |
| `换入快照后已把渠道绑定时刻重置为当前时刻,快照之后写入渠道的对象不会被对账回收` | 换入快照后的收尾,字段 `channels` 为重置的渠道数,见[备份与恢复](./backup.mdx) |
# 作为 Go 库使用 (/zh/docs/library)
ImgYoung 的存储层是三个可 go get 的公开包:驱动接口、rclone 实现与多存储容灾原语。
ImgYoung 的多存储读写、渠道存储加密与容灾能力以公开 Go 包的形式提供,应用自己也是通过这几个包访问存储的。
如果你在自己的 Go 项目里需要「同一份数据写进多个存储、读取时自动切换、探测存储是否可用」,可以直接复用它们。
## 三个包 [#三个包]
| 包 | 内容 | 依赖 |
|---|---|---|
| `store/driver` | `Driver` 接口(`Put` / `Open` / `Stat` / `Delete` / `List`)、`ObjectInfo`、哨兵错误 `ErrNotFound` | 仅标准库 |
| `store/rclone` | 用 rclone 的任一后端实现 `Driver`;可叠加 crypt 加密层;OAuth 令牌写回;密码类参数编码;导出与驱动等价的 rclone 配置文件 | `store/driver` 与 rclone |
| `store/failover` | 在一组 `Driver` 之上的无状态容灾原语:按序写够 N 份、按序读第一个可读的、轻探测与深探测 | 仅 `store/driver` |
```text
store/failover ──→ store/driver ←── store/rclone ──→ github.com/rclone/rclone
```
- `store/driver` 不导入 rclone,也不导入任何具体后端。你可以自己实现 `Driver`,完全不用 rclone。
- `store/rclone` 是可选实现:只有导入它才会把 rclone 编译进来。它注册了 rclone 的全部后端,所以 `Backend` 填任何 rclone 支持的后端名都能用。
- `store/failover` 只操作 `driver.Driver` 接口,对 rclone 驱动和你自己的驱动一视同仁。
三个包都不依赖应用的 `internal/` 代码,这条边界由 lint 规则(depguard)强制。
## 安装 [#安装]
```bash title="终端"
# 只用接口与容灾原语(搭配自己实现的驱动)
go get github.com/x-haose/imgyoung/store/failover@latest
# 同时使用 rclone 实现
go get github.com/x-haose/imgyoung/store/rclone@latest
```
本模块 `go.mod` 声明的 Go 版本是 1.26.9,你的工具链需要不低于它。
## 同一个 module,只编译用到的包 [#同一个-module只编译用到的包]
`store/` 与应用位于同一个 module `github.com/x-haose/imgyoung`,没有拆成独立 module:拆开之后,仓库内的开发要靠 `replace` 指令把两个 module 接起来。
Go 只编译被 import 的包:只导入 `store/driver` 与 `store/failover` 时,rclone 和应用代码都不会进入你的二进制。
两种导入方式的体积差别很大。在 darwin/arm64 上构建只打印一行的最小程序(未 strip):
| 导入的包 | 二进制大小 |
|---|---|
| `store/failover`(连带 `store/driver`)| 约 2.7 MB |
| `store/rclone` | 约 100 MB |
后者大是因为 `store/rclone` 导入了 rclone 的 `backend/all`,一次注册全部后端。
导入任一 `store` 包后,本模块就进入了你的依赖图,它的 `go.mod` 里列出的模块版本会参与 Go 的最小版本选择(MVS)。
如果你的项目也依赖其中某个模块(例如 AWS SDK、`golang.org/x/...`),它的版本可能被抬到不低于本模块要求的版本。
这只影响版本选择;编译什么、二进制多大,仍只取决于你实际 import 的包。
## 兼容承诺 [#兼容承诺]
三个包的导出标识符在 v1 主版本内保持向后兼容:类型、函数、方法、字段与哨兵错误不会删除或改变签名。
- 错误消息文本是中文描述,不要依赖它的内容。判断错误类别一律用 `errors.Is` 匹配导出的哨兵错误
(`driver.ErrNotFound`、`failover.ErrUnavailable` 等)。
- `store/rclone` 的后端参数名与行为来自 rclone 本身,以本模块 `go.mod` 中固定的 rclone 版本为准。
- `internal/` 下的包按 Go 的规则无法从模块外导入,不在承诺范围内。
## 库不包含什么 [#库不包含什么]
`store` 只负责「对一组排好序的驱动做一次操作」,不保存任何状态,也不在后台运行任何东西。下面这些在应用里实现,
叠加在同一组原语之上;你的项目需要的话,要自己搭:
| 能力 | 说明 | 应用中的实现位置 |
|---|---|---|
| 副本状态持久化 | 哪个对象在哪几个驱动上有副本。`failover.Write` 只返回本次尝试的结果,由你记录 | `blob_channels` 表 |
| 异步补齐 | 先写够 N 份就返回,其余驱动在后台补上。做法见 [多存储容灾 · 自己做补齐](./failover.mdx#replication) | `internal/core/replication` |
| 对账 | 定期 `List` 存储内容,与你的记录双向比对,清理孤儿对象、重传缺失副本 | `internal/core/replication` |
| 调度 | 健康探测的周期、失败重试与退避、任务队列 | `cmd/imgyoung`、River 任务队列 |
| 驱动实例管理 | 配置变更时重建驱动并 `Close` 旧实例,健康状态的保存与切换策略 | `internal/core/channel` |
| 网盘授权流程 | 库只负责令牌刷新后的写回;首次取得令牌请用 `rclone authorize` | `internal/core/channel` |
应用如何组合这些部分,见 [架构](../architecture.mdx)。
## 下一步 [#下一步]
Driver 契约、rclone.Open 的参数、密码编码、OAuth 令牌写回、crypt 加密层,以及如何自带驱动。
Write / Open / Probe / DeepProbe 的语义、完整的主备示例,以及在原语之上自己做补齐。
# 驱动接口与 rclone 实现 (/zh/docs/library/driver)
Driver 接口的契约、rclone.Open 的参数、密码类参数编码、OAuth 令牌写回、crypt 加密层,以及如何自带驱动。
## Driver 接口 [#driver-接口]
`store/driver` 只定义一件事:把字节存到某个后端、取回、查询、删除、列举。
```go
type Driver interface {
Put(ctx context.Context, key string, r io.Reader, size int64) error
Open(ctx context.Context, key string) (io.ReadCloser, error)
Stat(ctx context.Context, key string) (*ObjectInfo, error)
Delete(ctx context.Context, key string) error
List(ctx context.Context, dir string) ([]ObjectInfo, error)
}
```
| 方法 | 语义 | 对象不存在时 |
|---|---|---|
| `Put` | 把 `r` 中的 `size` 个字节流式写入 `key`。`size` 必须事先已知;`r` 由调用方负责关闭 | — |
| `Open` | 返回对象的读取流,调用方负责 `Close` | 返回包裹 `ErrNotFound` 的错误 |
| `Stat` | 返回对象信息 | 返回包裹 `ErrNotFound` 的错误 |
| `Delete` | 删除对象 | 返回包裹 `ErrNotFound` 的错误,由调用方决定是否当作成功 |
| `List` | 递归列举 `dir` 下的全部对象,`dir` 为空表示整个存储;返回的键是相对存储根的完整路径 | 目录不存在时返回空结果,不报错 |
对全部实现都成立的约定:
- **并发安全**:同一个驱动实例可以被多个 goroutine 同时使用。
- **尊重 `ctx`**:每个方法都受 `ctx` 的取消与超时约束。
- **用 `errors.Is` 判断缺失**:实现会给 `ErrNotFound` 包上对象键等上下文,不要用 `==` 比较。
- **键用 `/` 分隔**,形如 `photos/2026/a.png`,相对于驱动的根路径。
`ObjectInfo` 是 `Stat` 与 `List` 的返回结构:
## rclone 实现 [#rclone-实现]
`store/rclone` 用 rclone 的后端实现 `Driver`。它直接在进程内构造 rclone 的 `fs.Fs`:参数以映射传给后端,
不读写 rclone 配置文件,不拼连接字符串,也不启动 rclone 子进程。
### 打开驱动 [#打开驱动]
```go
d, err := rclone.Open(ctx, rclone.Options{
Backend: "s3",
Params: map[string]string{
"provider": "Cloudflare",
"access_key_id": "",
"secret_access_key": "",
"endpoint": "https://.r2.cloudflarestorage.com",
},
Path: "my-bucket/images",
})
if err != nil {
return err
}
defer func() { _ = d.Close(ctx) }()
```
`rclone.Options` 的字段:
`Open` 返回的错误不携带参数映射,参数里的凭证不会经错误消息泄露到日志。
### 写入时的 Content-Type [#写入时的-content-type]
`Put` 流式写入,驱动本身不缓存整个对象。它先读出数据开头的最多 512 字节,用 `net/http.DetectContentType`(WHATWG 内容嗅探算法)
判定 Content-Type,随对象一并交给后端,再接着写完其余字节。对象键不必带扩展名:S3 等对象存储里的对象带上真实类型(如 `image/png`),
而不是 `application/octet-stream`,浏览器直接打开存储的公开链接时能正常显示,不会变成下载。
- 加密驱动不把类型交给存储:rclone 的 crypt 层有意不暴露明文的类型,存储里的对象是密文。
- 不记录 Content-Type 的后端(如 `local`)忽略这一信息。
### s3 默认跳过桶检查 [#s3-默认跳过桶检查]
`Backend` 为 `s3` 且 `Params` 里没有 `no_check_bucket` 时,驱动自动补上 `no_check_bucket=true`;显式给出的值不会被覆盖。
原因:R2、MinIO 等 S3 兼容存储常用只授权到桶内的凭证,没有建桶权限。rclone 默认在上传前尝试建桶,
桶已存在而凭证无权建桶时返回 403,写入失败;而只读的健康探测(见 [多存储容灾](./failover.mdx))照常通过,
表现为「探测健康却写不进」。跳过桶检查的代价是**桶必须事先存在**。
### EnsureBucket [#ensurebucket]
需要由程序建桶时,用 `(*rclone.Driver).EnsureBucket`:它对根路径执行一次建目录,对 s3 即创建 `Path` 所在的桶,桶已存在不报错。
```go
b, err := rclone.Open(ctx, rclone.Options{
Backend: "s3",
Params: map[string]string{
"provider": "Cloudflare",
"access_key_id": "",
"secret_access_key": "",
"endpoint": "https://.r2.cloudflarestorage.com",
"no_check_bucket": "false", // 必须显式关闭,否则建桶被跳过,EnsureBucket 什么也不做
},
Path: "my-bucket",
})
if err != nil {
return err
}
defer func() { _ = b.Close(ctx) }()
if err := b.EnsureBucket(ctx); err != nil {
return err // 多为凭证没有建桶权限
}
```
`EnsureBucket` 不在 `Driver` 接口里,因为它只对对象存储有意义。应用的做法是:建桶时临时打开一个 `no_check_bucket=false`
的驱动调用它,日常读写仍用默认配置的驱动。
### 密码类参数的编码 [#密码类参数的编码]
rclone 要求一部分参数以 `rclone obscure` 的编码形式提供,例如 webdav、sftp、ftp 的 `pass`。哪些参数属于这一类,
由后端自己的选项元数据标明。驱动把 `Params` 原样交给后端,所以这些值必须**事先编码**:漏编码的明文会被后端当作编码值解出乱码,
表现为凭证正确却认证失败。不是所有密钥都属于这一类,例如 s3 的 `secret_access_key` 就以原文传入。
包里提供两个函数完成编码。两者都按后端元数据挑出密码类参数、原地修改传入的映射、跳过空值,后端名不认识时返回错误:
| | `ObscureSecrets` | `ObscurePlainSecrets` |
|---|---|---|
| 编码哪些值 | 全部密码类参数 | 密码类参数中尚未编码的值 |
| 适用场景 | 值确定是明文,例如来自表单输入 | 来源不确定,可能是明文,也可能是从 rclone.conf 复制来的编码值 |
| 误判后果 | 已编码的值被再编码一次,认证失败 | 恰好形似编码产物的明文被原样放过,认证失败 |
`ObscurePlainSecrets` 判断「已编码」的规则与 rclone 相同:能被 rclone 解码的值即视为已编码,与不带 `--obscure` / `--no-obscure`
执行 `rclone config create` 时的行为一致。遇到它误判的明文,先用 `rclone obscure` 编码再传入即可。
应用在后台模板表单里用 `ObscureSecrets`(值确定是明文),在自定义参数里用 `ObscurePlainSecrets`(两种形式都接受)。
```go
params := map[string]string{
"url": "https://dav.example.com/remote.php/dav/files/me",
"vendor": "nextcloud",
"user": "me",
"pass": "明文密码",
}
if err := rclone.ObscureSecrets("webdav", params); err != nil {
return err
}
d, err := rclone.Open(ctx, rclone.Options{Backend: "webdav", Params: params, Path: "images"})
```
另一个辅助函数 `SensitiveParams(backend)` 返回后端声明为密码或敏感信息的参数名(选项元数据里的 `IsPassword` 或 `Sensitive`),
按名排序、无重复,口径与 `rclone config redacted` 相同:除密码、密钥与令牌外,访问密钥 ID、账号名这类身份信息也在其中。
回显配置时掩码哪些值、从错误信息里抹除哪些值,可以据此决定;应用的编辑回读与错误抹除用的就是它。
### OAuth 令牌写回与 OnChange [#oauth-令牌写回与-onchange]
Google Drive、OneDrive、Dropbox 等网盘后端用 OAuth 授权。库不包含授权流程:在本机执行 `rclone authorize "drive"`
(后端名按实际替换)取得 JSON 令牌,放进 `Params["token"]`。授权用的是自建应用时,把 `client_id` 与 `client_secret` 一并放进
`Params`:令牌续期必须用签发它的同一个应用。
`Open` 只构造后端,不执行 `rclone config` 在授权之后的配置步骤。有的后端还要这些步骤写下的参数,例如 OneDrive 需要 `drive_id`
与 `drive_type`:先在本机用 `rclone config` 配好,再从 rclone.conf 把它们抄进 `Params`。
令牌临近过期时,rclone 自己刷新,并把新值写回参数。驱动收到写回后先更新内存中的参数,再调用 `OnChange(key, value)`。
- `OnChange` 为 nil 时,新值只活在内存里,进程重启后又从旧令牌开始。对刷新时会轮换刷新令牌的服务商,旧令牌此时已经作废,
重启后授权随之失效。用网盘后端时应当提供 `OnChange` 并把值持久化。
- 回调可能在任意 goroutine 中调用,也可能在 `Open` 返回之前就被调用(构造期写回);它同步执行在 rclone 的调用路径上,应尽快返回,并自行保证并发安全。
- 回调没有返回值,持久化失败只能由你自己记录。
- 不要再另起一个刷新令牌的循环。两个刷新者会让对方的刷新令牌失效。定期调用一次只读操作(例如 [`failover.Probe`](./failover.mdx))就足以让 rclone 按时刷新。
```go
d, err := rclone.Open(ctx, rclone.Options{
Backend: "drive",
Params: map[string]string{"token": tokenJSON},
Path: "imgyoung",
OnChange: func(key, value string) {
if err := saveParam(key, value); err != nil { // saveParam:你的持久化函数
log.Printf("持久化参数 %s 失败: %v", key, err)
}
},
})
```
### crypt 加密层 [#crypt-加密层]
`Crypt` 不为 nil 时,驱动在后端之上叠加 rclone 的 crypt 层:对象内容流式加密,文件名按 rclone 的 `standard` 方式加密。
经驱动的读写自动加解密,`List` 返回的是解密后的键;存储服务商只能看到密文与加密后的文件名。
实现上,`Open` 先构造内层后端,以随机名(形如 `imgyoung-0123abcd`)登记进 rclone 的进程级 fs 缓存,再构造以该名为 `remote` 的 crypt 层。
Password 与 Salt 是解密的唯一依据,丢失后存储里的对象无法还原。
crypt 是 rclone 的公开格式,不依赖本库也能用标准 rclone 解密:用下一节的 `ConfigFile` 生成配置文件即可。
应用的渠道存储加密也基于这一层,密码由主密钥派生,见 [渠道存储加密](../channels/encryption.mdx)。
### 导出为 rclone 配置文件 [#config-file]
```go
func ConfigFile(opts Options, remote, cryptRemote string) (string, error)
```
`ConfigFile` 生成一份 rclone 配置文件(rclone.conf)的文本,与 `Open(opts)` 构造的驱动等价,标准 rclone 凭它就能读写同一份数据:
- 后端段名为 `remote`,参数就是驱动实际交给后端的那一份,包括本包补上的默认值(如 s3 的 `no_check_bucket = true`);
空值省略,因为驱动与 rclone 都把空值当作未设置。密码类参数按 `Options` 的约定本就是 rclone 编码形式,原样写出。
- `opts.Crypt` 不为 nil 时,另写一段名为 `cryptRemote` 的 crypt 远端:`remote` 指向 `:`,
`password` 与 `password2` 是编码后的 `Password` 与 `Salt`,`filename_encryption = standard`,与驱动的 crypt 层一致。
经它读写的是明文。
- 不加密时,后端段本身不含路径,使用时写成 `:`。`OnChange` 不参与。
- 等价性由实现保证:`ConfigFile` 与 `Open` 共用同一套默认值补齐与 crypt 参数生成,不会各算各的。
```go
text, err := rclone.ConfigFile(rclone.Options{
Backend: "s3",
Params: map[string]string{
"provider": "Cloudflare",
"access_key_id": "",
"secret_access_key": "",
"endpoint": "https://.r2.cloudflarestorage.com",
},
Path: "my-bucket/images",
Crypt: &rclone.CryptOptions{Password: "", Salt: ""},
}, "mystore", "mystore-crypt")
```
得到的文本形如(编码带随机 IV,每次生成的 `password` 与 `password2` 都不同,都能解出同一个值):
```ini title="rclone.conf"
[mystore]
type = s3
access_key_id =
endpoint = https://.r2.cloudflarestorage.com
no_check_bucket = true
provider = Cloudflare
secret_access_key =
[mystore-crypt]
type = crypt
filename_encryption = standard
password = <编码后的 Password>
password2 = <编码后的 Salt>
remote = mystore:my-bucket/images
```
```bash title="终端"
rclone --config rclone.conf ls mystore-crypt:
```
文本用 rclone 自己读写配置文件的同一个 INI 库序列化,写完再按 rclone 的读法回读核对;有参数无法原样写进文件时,
返回包裹 `ErrUnrepresentable` 的错误,不会产出与驱动不等价的文件。错误文本只指出段名与参数名,不带参数值:
| 情形 | 错误文本 |
|---|---|
| 某个参数值含换行(`\n` 或 `\r`)| `参数值无法写进 rclone 配置文件: 远端 "<段名>" 的参数 "<参数名>" 含换行` |
| 生成的文本按 rclone 的读法无法解析 | `参数值无法写进 rclone 配置文件: 生成的文本按 rclone 的读法无法解析` |
| 某个参数读回的值与原值不同(如首尾空白、`%(name)s` 形式的引用)| `参数值无法写进 rclone 配置文件: 远端 "<段名>" 的参数 "<参数名>"` |
其他错误:后端名不认识、段名不合 rclone 的命名规则(只允许字母、数字、`_`、`-`、`.`、`+`、`@` 与空格)、密码编码失败。
配置文件含后端凭证与授权令牌;rclone 的编码可以还原,不是加密。加密渠道的文件还含解密密码。按主密钥的标准离线保存。
### Close [#close]
驱动用完必须调用 `Close(ctx)`:后端提供了关闭钩子(rclone 的 `Shutdowner`)时,它关闭后端的后台任务与连接池;
加密驱动还会把内层后端从 rclone 的 fs 缓存中移除,移除时由缓存负责关闭内层。`Close` 之后不能再使用该驱动。
`Close` 是 `*rclone.Driver` 的方法,不在 `Driver` 接口里。以 `driver.Driver` 接口传递驱动时,要另外保留具体类型的指针用于关闭。
### 完整示例 [#完整示例]
下面是仓库里的 `store/rclone/example_test.go`,随 `go test ./store/rclone` 执行,输出与各自的 `Output` 注释一致:
`ExampleSensitiveParams` 查询 webdav 后端的敏感参数;`Example` 用 rclone 的 `memory` 后端走一遍写入、查询、读回、删除。
```go title="store/rclone/example_test.go"
package rclone_test
import (
"context"
"errors"
"fmt"
"io"
"strings"
"github.com/x-haose/imgyoung/store/driver"
"github.com/x-haose/imgyoung/store/rclone"
)
// ExampleSensitiveParams 查询 webdav 后端的敏感参数:密码(IsPassword)与账号名、令牌(Sensitive)都在其中。
func ExampleSensitiveParams() {
names, err := rclone.SensitiveParams("webdav")
if err != nil {
fmt.Println("sensitive params:", err)
return
}
fmt.Println(strings.Join(names, " "))
// Output:
// bearer_token pass user
}
// Example 用 rclone 的内存后端走一遍驱动的基本操作:写入、查询、读回、删除。
func Example() {
ctx := context.Background()
d, err := rclone.Open(ctx, rclone.Options{Backend: "memory", Path: "bucket"})
if err != nil {
fmt.Println("open:", err)
return
}
defer func() { _ = d.Close(ctx) }()
if err := d.Put(ctx, "hello.txt", strings.NewReader("hello"), 5); err != nil {
fmt.Println("put:", err)
return
}
info, err := d.Stat(ctx, "hello.txt")
if err != nil {
fmt.Println("stat:", err)
return
}
fmt.Println(info.Key, info.Size)
rc, err := d.Open(ctx, "hello.txt")
if err != nil {
fmt.Println("open object:", err)
return
}
data, err := io.ReadAll(rc)
_ = rc.Close()
if err != nil {
fmt.Println("read:", err)
return
}
fmt.Println(string(data))
if err := d.Delete(ctx, "hello.txt"); err != nil {
fmt.Println("delete:", err)
return
}
_, err = d.Stat(ctx, "hello.txt")
fmt.Println(errors.Is(err, driver.ErrNotFound))
// Output:
// hello.txt 5
// hello
// true
}
```
`memory` 与 `local` 后端很适合写集成测试:前者数据只在进程内存里,后者配合 `t.TempDir()` 使用,都不需要任何云账号。
## 自带驱动 [#自带驱动]
不用 rclone 时,自己实现 `driver.Driver` 即可与 `store/failover` 配合。要点:
1. **对象缺失返回 `ErrNotFound`**:`Open`、`Stat`、`Delete` 找不到对象时,返回能被 `errors.Is(err, driver.ErrNotFound)` 命中的错误。
`failover.Open` 靠它区分「对象不存在」与「存储故障」,`failover.Probe` 把 `Stat` 返回的 `ErrNotFound` 视为存储可达。
2. **`Put` 读满 `size` 个字节**,读不够时返回错误;不要关闭 `r`。失败后调用方可能以同一个键重试,再次 `Put` 应整体覆盖之前留下的内容。
3. **`List` 递归列举**:`dir` 为空表示全部对象;返回相对根的完整键;目录不存在时返回空结果与 nil。
4. **`ModTime` 填真实的修改时间**:调用方会据此判断对象新旧,例如应用的对账只回收修改时间早于宽限期的未知对象。
5. **并发安全、尊重 `ctx`**。用编译期断言确认实现完整:`var _ driver.Driver = (*MemDriver)(nil)`。
下面是一个把对象存在进程内存里的实现,可直接用于测试:
```go
package memstore
import (
"bytes"
"context"
"fmt"
"io"
"strings"
"sync"
"time"
"github.com/x-haose/imgyoung/store/driver"
)
// MemDriver 把对象存在进程内存里的 Driver 实现。
type MemDriver struct {
mu sync.RWMutex
objects map[string]memObject
}
type memObject struct {
data []byte
modTime time.Time
}
var _ driver.Driver = (*MemDriver)(nil)
func New() *MemDriver {
return &MemDriver{objects: map[string]memObject{}}
}
func (m *MemDriver) Put(ctx context.Context, key string, r io.Reader, size int64) error {
data, err := io.ReadAll(io.LimitReader(r, size))
if err != nil {
return fmt.Errorf("读取 %q: %w", key, err)
}
if int64(len(data)) != size {
return fmt.Errorf("写入 %q: 期望 %d 字节,实际 %d: %w", key, size, len(data), io.ErrUnexpectedEOF)
}
if err := ctx.Err(); err != nil {
return err
}
m.mu.Lock()
defer m.mu.Unlock()
m.objects[key] = memObject{data: data, modTime: time.Now()}
return nil
}
func (m *MemDriver) Open(ctx context.Context, key string) (io.ReadCloser, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
m.mu.RLock()
defer m.mu.RUnlock()
o, ok := m.objects[key]
if !ok {
return nil, fmt.Errorf("对象 %q: %w", key, driver.ErrNotFound)
}
return io.NopCloser(bytes.NewReader(o.data)), nil
}
func (m *MemDriver) Stat(ctx context.Context, key string) (*driver.ObjectInfo, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
m.mu.RLock()
defer m.mu.RUnlock()
o, ok := m.objects[key]
if !ok {
return nil, fmt.Errorf("对象 %q: %w", key, driver.ErrNotFound)
}
return &driver.ObjectInfo{Key: key, Size: int64(len(o.data)), ModTime: o.modTime}, nil
}
func (m *MemDriver) Delete(ctx context.Context, key string) error {
if err := ctx.Err(); err != nil {
return err
}
m.mu.Lock()
defer m.mu.Unlock()
if _, ok := m.objects[key]; !ok {
return fmt.Errorf("对象 %q: %w", key, driver.ErrNotFound)
}
delete(m.objects, key)
return nil
}
func (m *MemDriver) List(ctx context.Context, dir string) ([]driver.ObjectInfo, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
prefix := ""
if dir != "" {
prefix = strings.TrimSuffix(dir, "/") + "/"
}
m.mu.RLock()
defer m.mu.RUnlock()
var out []driver.ObjectInfo
for k, o := range m.objects {
if strings.HasPrefix(k, prefix) {
out = append(out, driver.ObjectInfo{Key: k, Size: int64(len(o.data)), ModTime: o.modTime})
}
}
return out, nil
}
```
# 多存储容灾 (/zh/docs/library/failover)
store/failover 的写入、读取与健康探测原语,完整的主备示例,以及如何在原语之上自己做副本补齐。
`store/failover` 在一组驱动之上提供四个无状态函数。**驱动切片的顺序就是优先级**:哪些驱动参与、谁健康、
对象在哪几个驱动上有副本,都由调用方决定后按优先级排好传进来,包本身不保存任何状态。
```go
func Write(ctx context.Context, ds []driver.Driver, key string, r io.ReaderAt, size int64,
opts WriteOptions) ([]Attempt, error)
func Open(ctx context.Context, ds []driver.Driver, key string) (io.ReadCloser, []Attempt, error)
func Probe(ctx context.Context, d driver.Driver) error
func DeepProbe(ctx context.Context, d driver.Driver) error
```
`Write` 与 `Open` 都返回 `[]Attempt`,按尝试顺序记录实际访问过的每个驱动:
## Write:按序写够 N 份 [#write按序写够-n-份]
```go
attempts, err := failover.Write(ctx, ds, key, bytes.NewReader(data), int64(len(data)),
failover.WriteOptions{Need: 2, AttemptTimeout: time.Minute})
```
= 1;凑够即停,其余驱动不再写' },
AttemptTimeout: { type: 'time.Duration', description: '单个驱动一次写入的超时;0 表示只受 ctx 约束', default: '0' },
}}
/>
- **按切片顺序逐个写,不并发**。某个驱动写失败就顺延下一个;成功份数达到 `Need` 立即返回,排在后面的驱动不会被访问。
- **每次尝试都从数据开头重新读**。数据源是 `io.ReaderAt` 加 `size`,每次尝试单独读取 `[0, size)`,上一个驱动写到一半失败不影响下一个。
`*bytes.Reader`、`*os.File` 都满足 `io.ReaderAt`。
- **`ctx` 取消后立即停止**:每次尝试前检查 `ctx`,已取消就不再尝试后续驱动,直接返回 `ctx.Err()`,而不是 `ErrInsufficient`——
取消造成的失败不能算作驱动的结论。
- **返回值里 `Err == nil` 的尝试,就是持有副本的驱动**。成功时 `attempts` 里也可能包含失败的尝试。
- **失败的写入不会被清理**:失败的驱动上可能残留半截对象,需要时自己删除,或交给对账处理。
- `Need` 大于驱动数时不会提前拒绝,而是试完全部驱动后返回 `ErrInsufficient`。
| 错误 | 含义 |
|---|---|
| `ErrInvalidNeed` | `opts.Need` 小于 1;最先检查 |
| `ErrNoDrivers` | 驱动切片为空 |
| `ErrInsufficient` | 全部驱动都试过,成功份数仍不足;同一个错误还包裹了每次失败的原因 |
| `ctx` 的错误 | 尝试中途 `ctx` 被取消或到期 |
## Open:按序读第一个可读的 [#open按序读第一个可读的]
```go
rc, attempts, err := failover.Open(ctx, ds, key)
if err != nil {
return err
}
defer func() { _ = rc.Close() }()
```
- 按切片顺序打开对象,**第一个打开成功的立即返回**,后续驱动不再访问。读取流由调用方 `Close`。
- 成功时 `attempts` 的最后一项就是提供读取流的驱动,前面的都是失败的尝试,可用于日志或指标。
- **切换只发生在打开阶段**。读取流打开之后中途出错,不会自动换到下一个驱动。
- 没有单次超时,只受 `ctx` 约束;每次尝试前检查 `ctx`,已取消就直接返回 `ctx.Err()`。
对 rclone 驱动,`ctx` 通常同时约束返回的读取流(HTTP 类后端的响应体随 `ctx` 取消而中断),读完之前不要取消它。
全部驱动都失败时,错误分两种:
| 情况 | 返回的错误 | 含义 |
|---|---|---|
| 每个驱动都报告对象不存在 | 包裹 `driver.ErrNotFound` | 对象确实缺失 |
| 至少一个驱动的失败不是「不存在」 | 包裹 `ErrUnavailable`;各驱动的失败原因只写进错误文本,不参与包裹 | 有驱动故障,对象可能还在 |
另外,驱动切片为空时返回 `ErrNoDrivers`。
两类错误互斥:`errors.Is(err, driver.ErrNotFound)` 为 true 只说明每个驱动都确认对象不存在,有驱动故障时它一定为 false。
`ErrUnavailable` 的错误不包裹各驱动的原始错误,要逐个判断某个驱动为什么失败,看返回的尝试记录:
```go
rc, attempts, err := failover.Open(ctx, ds, key)
switch {
case errors.Is(err, driver.ErrNotFound):
// 所有驱动都确认对象不存在:例如 HTTP 404
case errors.Is(err, failover.ErrUnavailable):
// 有驱动故障,对象可能还在:按「暂时不可用」处理,例如 HTTP 503
for _, a := range attempts {
log.Printf("驱动 %d: %v", a.Index, a.Err)
}
case err != nil:
// ErrNoDrivers,或 ctx 被取消
default:
defer func() { _ = rc.Close() }()
// 读取 rc
}
```
## Probe 与 DeepProbe:健康探测 [#probe-与-deepprobe健康探测]
| | `Probe`(轻探测)| `DeepProbe`(深探测)|
|---|---|---|
| 做什么 | 对约定键 `ProbeKey` 执行一次 `Stat` | 写入一个随机键的小对象 → 读回逐字节核对 → 删除 |
| 判为健康 | `Stat` 成功,或返回 `driver.ErrNotFound` | 写、读、删全部通过 |
| 成本 | 一次只读请求,不写不删 | 每次都有写入与删除请求 |
| 能发现 | 存储不可达、凭证无效 | 另外能发现「可读不可写」:只读凭证、桶满、配额耗尽 |
| 适合 | 周期执行 | 事件触发:新建、改配置、手动复查、恢复健康前确认 |
`Probe` 的超时完全由 `ctx` 决定,调用时应带超时。`ProbeKey` 的值是 `.imgyoung-health-probe`,约定从不写入,不要用这个键存数据。
`DeepProbe` 的细节:
- 临时对象键为 `.imgyoung-probe-` 加 16 位十六进制随机串,内容是固定的 19 字节;随机键让并发的深探测互不干扰。
- 写入与读回受 `ctx` 约束;删除使用脱离 `ctx` 取消的独立 10 秒超时,所以读回失败或 `ctx` 到期后仍会尽力删除。
- 写入、读回、内容核对任一环节失败都返回对应错误;读回已经失败时,删除的结果不再影响返回值;
读回成功而删除失败(且不是 `driver.ErrNotFound`)时返回删除错误。
- 删除失败会留下临时对象。`failover.IsDeepProbeKey(key)` 判断一个键是不是深探测临时对象,清理逻辑可据此认领回收。
只读探测成功不代表能写。一种稳妥的组合是应用采用的「只降不升」:周期轻探测只能把健康的存储判为不健康;
不健康的存储即使轻探测通过,也要再经深探测确认写读删全通,才恢复为健康。这样只读凭证这类「探测健康却写不进」的情况不会被下一轮轻探测误判为恢复。
## 完整示例 [#完整示例]
下面是仓库里的 `store/failover/example_test.go`:两个本地目录充当主、备存储。主存储的根路径落在一个普通文件之下,
任何写入都会失败,以此模拟主存储故障;写入顺延到备存储,读取同样切到备存储。它随 `go test ./store/failover` 执行,
输出与末尾的 `Output` 注释一致:
```go title="store/failover/example_test.go"
package failover_test
import (
"bytes"
"context"
"fmt"
"io"
"os"
"path/filepath"
"github.com/x-haose/imgyoung/store/driver"
"github.com/x-haose/imgyoung/store/failover"
"github.com/x-haose/imgyoung/store/rclone"
)
// Example 两个本地目录充当主、备存储,主存储故障时写入顺延到备存储,读取同样切到备存储。
func Example() {
ctx := context.Background()
dir, err := os.MkdirTemp("", "failover-example")
if err != nil {
fmt.Println("mkdir:", err)
return
}
defer func() { _ = os.RemoveAll(dir) }()
// 主存储的根落在一个普通文件之下,任何写入都会失败,以此模拟主存储故障。
blocker := filepath.Join(dir, "blocker")
if err := os.WriteFile(blocker, nil, 0o600); err != nil {
fmt.Println("write blocker:", err)
return
}
primary, err := rclone.Open(ctx, rclone.Options{Backend: "local", Path: filepath.Join(blocker, "data")})
if err != nil {
fmt.Println("open primary:", err)
return
}
defer func() { _ = primary.Close(ctx) }()
backup, err := rclone.Open(ctx, rclone.Options{Backend: "local", Path: filepath.Join(dir, "backup")})
if err != nil {
fmt.Println("open backup:", err)
return
}
defer func() { _ = backup.Close(ctx) }()
ds := []driver.Driver{primary, backup} // 顺序即优先级
data := []byte("hello")
written, err := failover.Write(ctx, ds, "a.txt", bytes.NewReader(data), int64(len(data)),
failover.WriteOptions{Need: 1})
if err != nil {
fmt.Println("write:", err)
return
}
for _, a := range written {
fmt.Println("write to", a.Index, "ok:", a.Err == nil)
}
rc, read, err := failover.Open(ctx, ds, "a.txt")
if err != nil {
fmt.Println("read:", err)
return
}
got, err := io.ReadAll(rc)
_ = rc.Close()
if err != nil {
fmt.Println("read all:", err)
return
}
fmt.Println(string(got), "from", read[len(read)-1].Index)
// Output:
// write to 0 ok: false
// write to 1 ok: true
// hello from 1
}
```
## 自己做补齐 [#replication]
常见的用法是「主存储写成功就返回,其余存储在后台补上」:`Write` 用 `Need: 1`,把没写到的驱动记下来,稍后再复制。
```go
// writePrimary 写够 1 份就返回,同时给出持有副本与待补齐的驱动下标。
func writePrimary(ctx context.Context, ds []driver.Driver, key string, data []byte) (holders, pending []int, err error) {
attempts, err := failover.Write(ctx, ds, key, bytes.NewReader(data), int64(len(data)),
failover.WriteOptions{Need: 1, AttemptTimeout: time.Minute})
if err != nil {
return nil, nil, err
}
done := map[int]bool{}
for _, a := range attempts {
if a.Err == nil {
done[a.Index] = true
holders = append(holders, a.Index)
}
}
for i := range ds {
if !done[i] {
pending = append(pending, i)
}
}
return holders, pending, nil
}
```
补齐一份副本就是「从持有副本的驱动里读出来,写进目标驱动」,读取这一步可以直接用 `Open` 的容灾:
```go
// Replicate 把 key 从 holders(持有副本的驱动,按优先级排好)复制到 target。
// size 是对象字节数,通常取自你自己记录的元数据;没有记录时可以先对源驱动 Stat。
func Replicate(ctx context.Context, holders []driver.Driver, target driver.Driver, key string, size int64) error {
rc, attempts, err := failover.Open(ctx, holders, key)
if err != nil {
return fmt.Errorf("没有可读的源副本: %w", err)
}
for _, a := range attempts[:len(attempts)-1] {
log.Printf("源驱动 %d 读取失败,已切换: %v", a.Index, a.Err)
}
err = target.Put(ctx, key, rc, size)
if cerr := rc.Close(); err == nil {
err = cerr
}
return err
}
```
这两段只是执行动作。要成为可靠的补齐机制,还需要你自己补上:
- **持久化副本状态**:`holders` 与 `pending` 要落进你的存储,进程重启后能从中恢复未完成的补齐。
- **重试与退避**:`Replicate` 失败时稍后重试,多次失败后标记为失败并告警,存储恢复后再重新尝试。
- **对账**:定期对每个存储 `List`,与记录双向比对:存储里有、记录里没有的是孤儿对象(写入成功但记录落库失败时产生),
过了宽限期再删除;记录说有、存储里没有的副本要重新补齐。只回收能确认是自己写出的对象(例如按你自己的键名格式判断),
别的程序写进同一存储的文件不要动;深探测残留用 `IsDeepProbeKey` 认领,过宽限期即可删除。
- **并发控制**:补齐任务与业务读写共用存储的带宽与请求配额,需要限制同时运行的数量。
应用用业务表 `blob_channels` 记录副本状态,用 River 任务队列执行补齐,做法见 [架构](../architecture.mdx)。
## 应用如何使用这些原语 [#应用如何使用这些原语]
应用的渠道就是一组 rclone 驱动,上传、访问与健康探测都直接调用本包:
| 场景 | 调用 | 参数与处理 |
|---|---|---|
| 上传写入 | `Write` | 驱动为健康渠道,按优先级排序;`Need` 取运营配置 `min_success`(小于 1 时按 1);`AttemptTimeout` 为 10 分钟。成功的渠道记为副本就绪,其余渠道记为待补齐并入队补齐任务;份数不足返回 503 |
| 访问读取 | `Open` | 驱动为健康、不在清理中、且该图副本已就绪的渠道,按优先级排序;失败的尝试逐条记日志;全部失败返回 503 |
| 周期健康探测 | `Probe` / `DeepProbe` | 每隔 `health_check_seconds` 秒先重试装载此前装载失败的渠道,再对各渠道并行轻探测,单个渠道 10 秒超时;不健康的渠道轻探测通过后,再以 20 秒超时深探测确认才恢复 |
| 事件深探测 | `DeepProbe` | 创建渠道后、编辑渠道后、手动重检、测试连接,20 秒超时 |
补齐任务从候选源渠道逐个尝试读出、写入目标渠道,与上面的 `Replicate` 思路相同。每日的对账会跳过 `ProbeKey` 与系统目录 `_imgyoung/`,
只回收键名是应用对象键格式的孤儿(口径见 [架构](../architecture.mdx));深探测残留由 `IsDeepProbeKey` 认领,过 48 小时宽限期即删,
不看写入时刻。运营配置项见 [配置](../configuration.mdx)。
# 接口参考 (/zh/docs/api)
ImgYoung HTTP 接口的认证方式、错误格式与通用约定;各分组的接口详情由 OpenAPI 文档生成。
## 接口文档从哪里来 [#接口文档从哪里来]
仓库里的 `api/openapi.yaml`(OpenAPI 3.1)是 ImgYoung 对外 HTTP 接口的唯一依据。本节各分组页由它生成;
端到端测试用它核对每条 `/api/`、`/healthz` 与 `/i/` 响应的状态码与响应体;管理后台的前端类型也由它生成。
这份文档编译进了二进制,运行中的实例在 `GET /api/openapi.yaml` 提供与自身版本一致的副本,可以直接导入 Swagger UI、
Scalar 或各类代码生成器:
```bash title="终端"
curl -o openapi.yaml https://img.example.com/api/openapi.yaml
```
S3 协议面(`/s3/…`)按 AWS S3 规范实现,使用 XML 错误体,不在本参考内;用法见 [上传客户端](../guides/clients.mdx)。
## 认证 [#认证]
认证面只有两个,凭证互不通用:
| | 数据面 | 管理后台 |
|---|---|---|
| 路径 | `POST /api/upload`、`POST /api/upload/url` | `/api/admin/…` |
| 凭证 | 上传 Token,放在 `Authorization: Bearer ` 头 | 会话 cookie `imgyoung_session`;状态变更请求另带 `X-CSRF-Token` 头 |
| 获取方式 | 在管理后台「上传 Token」页签发,明文只显示一次 | `POST /api/admin/auth/login` |
| 认证失败 | 401:Token 无效、已过期或已吊销 | 401:未登录或会话已过期 |
| 权限不足 | 403:Token 没有上传权限 | 403:CSRF 令牌缺失或不匹配;仍为出厂默认密码;需要管理员权限 |
上传 Token 形如 `imgy_…`,只授权数据面,管理后台的接口一律不接受它。图片访问入口(`/i/…`、`/o/…`)与 `/healthz`、
`/api/openapi.yaml` 不需要认证。
### 会话与 CSRF 令牌 [#会话与-csrf-令牌]
**登录**:`POST /api/admin/auth/login`,请求体 `{"name": "<登录名>", "password": "<密码>"}`。
成功后响应设置会话 cookie,响应体为 `{"user": {…}, "csrf_token": "…"}`。
**调用接口**:每个请求都带上 cookie。`GET`、`HEAD`、`OPTIONS` 只需 cookie;其余方法还要以 `X-CSRF-Token` 头回传
登录时拿到的 `csrf_token`,缺失或不匹配返回 403。
**恢复登录态**:页面刷新等丢失了 `csrf_token` 时,`GET /api/admin/me` 会返回当前用户与同一个 `csrf_token`。
```bash title="终端"
# 登录,cookie 存进 jar.txt;记下响应体里的 csrf_token
curl -c jar.txt -H 'Content-Type: application/json' \
-d '{"name":"admin","password":"<密码>"}' \
https://img.example.com/api/admin/auth/login
# 读接口只需 cookie
curl -b jar.txt https://img.example.com/api/admin/me
# 状态变更接口还要带 CSRF 令牌
curl -b jar.txt -X POST -H 'X-CSRF-Token: ' \
https://img.example.com/api/admin/auth/logout
```
会话的细节:
- cookie 带 `HttpOnly` 与 `SameSite=Lax`;请求经 HTTPS 到达时(直接 TLS,或受信任的反向代理以 `X-Forwarded-Proto` 告知)另带 `Secure`。
- 会话有效期 30 天,从登录时刻算起,不随使用顺延;到期后须重新登录。
- 修改密码后,该用户的全部会话失效。
- 登录尝试过于频繁时返回 429。
- 账号仍是出厂默认密码时,会话只能用三个接口:退出(`POST /api/admin/auth/logout`)、查看自己(`GET /api/admin/me`,
其中 `must_change_password` 为 true)与改密(`PUT /api/admin/me/password`)。其余后台接口(包括后台上传、签发 Token)
一律返回 403 `须先修改出厂默认密码`,改密并重新登录后放行。
渠道、用户、防盗链、审计、告警、备份、导出、裁决,以及运营配置的全量读取与修改,仅管理员可用,普通用户调用返回 403。
## 错误格式 [#错误格式]
除下面的例外,错误响应一律是 JSON 信封:
```json
{ "error": "存储渠道暂不可用,请稍后重试" }
```
`error` 是面向用户的描述(中文),不含内部细节与凭证。程序据 HTTP 状态码判断错误类别,不要解析这段文字。
**例外是图片访问入口**(`/i/{id}`、`/o/{user_id}/{object_key}` 与后台预览图 `/api/admin/images/{id}/thumb`):
除了处理参数非法的 400 仍是 JSON 信封,其余错误响应都是纯文本:
| 状态码 | 含义 |
|---|---|
| 403 | 防盗链拒绝(仅 `/i/` 与 `/o/`)|
| 404 | 图片不存在、已到期或已删除 |
| 500 | 查询图片元数据失败(数据库故障),可稍后重试 |
| 503 | 当前没有健康渠道能读出这张图 |
后台预览图在会话认证之后才进入这条管线,未登录等认证错误仍是 JSON 信封。访问模式为 `redirect` 时,图片访问入口的 302 重定向
带 `Cache-Control: no-store`:重定向目标随渠道健康与配置变化,不进 CDN 与浏览器缓存。
常见状态码:
| 状态码 | 含义 |
|---|---|
| 400 | 请求体或参数非法 |
| 401 | 未认证:Token 无效,或未登录、会话已过期 |
| 403 | 无权限:CSRF 校验失败、仍为出厂默认密码、需要管理员、Token 无上传权限;上传时也表示存储配额已满 |
| 404 | 目标不存在,或对当前用户不可见 |
| 409 | 与现有状态冲突,如重名,或渠道持有部分图片的唯一副本 |
| 413 | 超过单图大小上限(运营配置 `max_upload_mb`)|
| 415 | 不是支持的图片格式(按文件内容判定)|
| 422 | 分辨率超出像素预算(运营配置 `max_pixels_mp`)|
| 429 | 上传并发已满、该用户上传过于频繁,或登录尝试过于频繁;有 `Retry-After` 头时按它等待 |
| 500 | 服务端内部错误 |
| 503 | 上传时没有健康渠道可写或同步副本数不足;访问图片时暂无渠道能读出 |
每个接口实际可能返回的状态码,以各分组页为准。
## 通用约定 [#通用约定]
- **时刻**:除特别说明外,均为 UTC 文本 `YYYY-MM-DD HH:MM:SS`(如 `2026-10-15 08:30:00`),不带时区后缀。
例外是构建信息的 `built_at` 与备份快照的 `taken_at`,为 RFC 3339(`taken_at` 精确到毫秒)。
- **列表**:列表接口返回 `{"items": […], "total": N}`,`total` 是过滤后的总条数。
- **分页**:图片、上传 Token、疑似重复、待清理、审计日志、告警列表支持 `page`(从 1 起,默认 1)与 `page_size`(默认 20,
最大 100,超出按 100 处理)。
- **时间范围**:图片列表与审计日志支持 `since`(含)与 `until`(不含),格式同上。
- **ID**:图片为 10 位短 ID 字符串;用户 ID 为字符串(内置管理员为 `default`);Token、渠道、疑似重复候选等记录为整数;备份快照以文件名标识。
- **幂等上传**:上传请求可带 `Idempotency-Key` 头,保留期(运营配置 `idempotency_key_ttl_hours`,默认 24 小时)内,
同一用户同一键的重试返回同一张图。键按用户隔离。
- **请求体**:JSON 接口使用 `application/json`;文件上传使用 `multipart/form-data`,文件字段名为 `file`,
可选的 `exif_policy` 与 `ttl` 也只认表单字段(不读 URL 查询参数),每个最多 1 KiB。
- **服务地址**:接口与站点同源,路径前缀即上文所列,没有版本号段。
## 分组 [#分组]
上传 Token 认证的上传与 URL 转存
公开的图片输出入口,受防盗链约束
登录、登出、当前用户与改密
仪表盘与运行配置
会话认证的后台上传,与数据面同一管道
图片列表、详情、修改与删除
具名 Token 的签发、轮转、吊销与 S3 凭证
渠道模板、创建、编辑、探测、删除与网盘授权
多用户建号、配额与密码重置(仅管理员)
Referer 白名单(仅管理员)
相似重复与冷图待清理的裁决(仅管理员)
审计日志、告警、备份快照与元数据导出(仅管理员)
健康检查与接口文档
# 数据面 (/zh/docs/api/data-plane)
上传 Token 认证的上传与 URL 转存
OpenAPI 3.1 document / OpenAPI 文档: https://github.com/x-haose/imgyoung/blob/main/api/openapi.yaml
# 图片访问 (/zh/docs/api/image-access)
公开的图片输出入口,受防盗链约束
OpenAPI 3.1 document / OpenAPI 文档: https://github.com/x-haose/imgyoung/blob/main/api/openapi.yaml
# 系统 (/zh/docs/api/system)
健康检查与接口文档
OpenAPI 3.1 document / OpenAPI 文档: https://github.com/x-haose/imgyoung/blob/main/api/openapi.yaml
# 后台·会话 (/zh/docs/api/admin-session)
登录、登出、当前用户与改密
OpenAPI 3.1 document / OpenAPI 文档: https://github.com/x-haose/imgyoung/blob/main/api/openapi.yaml
# 后台·总览与配置 (/zh/docs/api/admin-overview)
仪表盘与运行配置
OpenAPI 3.1 document / OpenAPI 文档: https://github.com/x-haose/imgyoung/blob/main/api/openapi.yaml
# 后台·上传 (/zh/docs/api/admin-upload)
会话认证的后台上传,与数据面同一管道
OpenAPI 3.1 document / OpenAPI 文档: https://github.com/x-haose/imgyoung/blob/main/api/openapi.yaml
# 后台·图片 (/zh/docs/api/admin-images)
图片列表、详情、修改与删除
OpenAPI 3.1 document / OpenAPI 文档: https://github.com/x-haose/imgyoung/blob/main/api/openapi.yaml
# 后台·上传 Token (/zh/docs/api/admin-tokens)
具名 Token 的签发、轮转、吊销与 S3 凭证
OpenAPI 3.1 document / OpenAPI 文档: https://github.com/x-haose/imgyoung/blob/main/api/openapi.yaml
# 后台·存储渠道 (/zh/docs/api/admin-channels)
渠道模板、创建、编辑、探测、删除与网盘授权
OpenAPI 3.1 document / OpenAPI 文档: https://github.com/x-haose/imgyoung/blob/main/api/openapi.yaml
# 后台·用户 (/zh/docs/api/admin-users)
多用户建号、配额与密码重置(仅管理员)
OpenAPI 3.1 document / OpenAPI 文档: https://github.com/x-haose/imgyoung/blob/main/api/openapi.yaml
# 后台·防盗链 (/zh/docs/api/admin-referer)
Referer 白名单(仅管理员)
OpenAPI 3.1 document / OpenAPI 文档: https://github.com/x-haose/imgyoung/blob/main/api/openapi.yaml
# 后台·裁决 (/zh/docs/api/admin-review)
相似重复与冷图待清理的裁决(仅管理员)
OpenAPI 3.1 document / OpenAPI 文档: https://github.com/x-haose/imgyoung/blob/main/api/openapi.yaml
# 后台·运维 (/zh/docs/api/admin-operations)
审计日志、告警、备份快照与元数据导出(仅管理员)
OpenAPI 3.1 document / OpenAPI 文档: https://github.com/x-haose/imgyoung/blob/main/api/openapi.yaml
# 架构 (/zh/docs/architecture)
单实例设计、分层与依赖方向、数据模型、上传与访问的数据流、outbox 围栏、周期任务,以及为什么在进程内集成 rclone。
## 单实例设计 [#单实例设计]
ImgYoung 明确按单实例设计:一个进程、一个数据目录。
- **元数据库是一个 SQLite 文件**(数据目录下的 `imgyoung.db`,WAL 模式)。写操作走单连接排队,读操作走连接池;驱动是纯 Go 的 `modernc.org/sqlite`。
- **任务队列在同一个库里**:异步任务由 River 执行,它的任务表与业务表在同一个 SQLite 文件中,不需要额外的队列服务。
- **其余状态都在本机**:上传并发闸、按用户的上传频控、登录限速、访问活跃度的聚合都在进程内存里,衍生图缓存在本地磁盘上。不使用 Redis。
- **单个静态二进制**:全程 `CGO_ENABLED=0`,管理后台的前端产物与 OpenAPI 文档都编译进二进制。
因此 ImgYoung 不支持多个进程共享同一个数据目录,也不支持水平扩展。需要高可用时,升级路径是把数据库换成 PostgreSQL
(River 本身支持 PostgreSQL,任务队列随之迁过去),进程内的共享状态届时再外置;当前代码只有 SQLite 实现。
## 分层与依赖方向 [#layers]
```text
http ──→ core ──→ infra
│ └────→ store
└─────────────→ infra
```
| 层 | 目录 | 职责 |
|---|---|---|
| store | `store/` | 公开的存储引擎:`driver` 接口、`rclone` 实现、`failover` 容灾原语。见 [作为 Go 库使用](./library/index.mdx) |
| infra | `internal/infra/` | 基础设施与出站适配:`config`(引导配置与运营配置)、`db`(schema、查询、迁移、sqlc 生成代码)、`secret`(主密钥与加密)、`id`(短 ID)、`imaging`(图像处理)、`cdn`、`urlfetch`(URL 转存的抓取)、`observability`(指标)、`version` |
| core | `internal/core/` | 业务核心:`channel`(渠道与健康探测)、`replication`(补齐、对账、排空、CDN purge)、`upload`、`variants`(按需处理)、`dedup`、`lifecycle`、`reliability`(备份恢复)、`auth`、`audit`、`referer`、`usage`、`alert` |
| http | `internal/http/` | HTTP 入站:`publicapi`(上传与图片访问)、`s3api`(S3 协议面)、`adminapi`(管理后台)、`middleware`、`httpx`、`router` |
| 装配根 | `cmd/imgyoung/` | `build.go` 按「基础设施 → 业务服务 → HTTP」顺序手工构造对象图,`serve.go` 用 oklog/run 编排启停,`loops.go` 放周期循环 |
`store` 与 `infra` 是两个并列的叶子:`store` 不依赖任何 `internal/` 包,`infra` 不依赖 core、store 与 http;`core` 同时依赖二者。
依赖方向由 golangci-lint 的 depguard 规则强制,CI 不通过即不能合并:
| 规则 | 禁止导入 | 原因 |
|---|---|---|
| `store/**` | `internal/` 下的任何包 | store 是公开的可复用存储引擎,不能依赖应用代码 |
| `internal/infra/**` | `internal/core`、`store`、`internal/http` | infra 是基础设施叶子 |
| `internal/core/**` | `internal/http` | 业务核心不知道传输层,可以脱离 HTTP 测试 |
| `internal/http/**` | `store` | 选路与容灾属于 core,传输层只拿 core 给出的结果 |
另外两条约定:
- **接口由使用方定义**:各包在自己内部声明只含所需方法的小接口(如 `upload.ChannelSelector`、`publicapi.BlobChannels`),由具体类型隐式满足。
`store/driver.Driver` 是唯一的一等抽象。
- **身份显式传参**:中间件解析出认证主体放进请求上下文,handler 取出后作为参数传给 core;core 不读 context 里的身份,按用户隔离在 core 中强制执行。
HTTP handler 与 River worker 都只是把外部触发翻译成服务调用的薄适配;周期任务是各服务自带的 `Run(ctx, wait)` 循环,由装配根统一编排,没有单独的调度层。
## 数据模型 [#data-model]
数据模型的核心是**物理图与上传引用两层分离**:
```text
images(上传引用)──→ blobs(物理图)──→ blob_channels(各渠道的副本)──→ channels
N : 1 1 : N N : 1
```
- **`blobs`** 是一份实际存储的字节。每次上传都新建一个 blob;后台查重把字节完全相同的 blob 合并后,一个 blob 可以被多条引用指向。
它记录净化后字节的 `sha256`、感知哈希 `phash`、渠道内对象键 `storage_key`(形如 `{short_id}_{sha256 前 8 位}`)、MIME、大小、尺寸,
以及引用计数 `ref_count` 与待删标记。
- **`images`** 是一次上传产生的引用,对外的短 ID(10 位)就是它的主键。归属用户、来源 Token、S3 对象键别名、到期时刻与访问活跃度、
冷图标签与清理豁免都挂在引用上,不挂在 blob 上。
- **`blob_channels`** 记录某个 blob 在某个渠道上的副本,兼作补齐任务的状态:`pending → uploading → done / failed`,另有尝试次数与最近错误。
两层分离让几件事各得其所:查重合并只是把引用改指到保留的 blob;TTL、冷图与配额按引用计算;副本、补齐与物理删除按 blob 进行。
**引用计数**:`ref_count` 的增减都是原子的 `UPDATE … SET ref_count = ref_count ± 1`。删除引用与递减计数在同一个事务里;
计数归零的 blob 被标记待删,并按运营配置 `delete_grace_minutes` 记下物理删除时刻。到点后清理任务先清掉它的衍生图缓存,
再在一个事务里删除业务行并复查删除条件(期间又被引用就放弃本次删除),最后为每个持有副本的渠道入队对象删除任务。
其余的表:
| 表 | 内容 |
|---|---|
| `channels` | 渠道:rclone 后端名、用主密钥 AES-GCM 加密的参数 JSON、优先级、健康标记与最近探测结果、是否加密、是否允许直链、清理中标记;创建时刻 `created_at`(与渠道 ID 一起参与加密渠道的密钥派生,建后不变)与绑定时刻 `bound_at`(见[对账 GC](#reconcile-gc))|
| `variants` | 衍生图缓存的索引:一个 blob 一种处理参数一行,字节存在本地磁盘,按最近访问时间做 LRU 淘汰 |
| `settings` | 运营配置,单行宽表;每列的 DEFAULT 就是出厂值。见 [配置](./configuration.mdx) |
| `users`、`tokens`、`sessions` | 用户与配额、上传 Token(只存哈希)、后台会话(只存 session id 的哈希与绑定的 CSRF 令牌)|
| `idempotency_keys` | 上传幂等键,按用户隔离 |
| `dedup_candidates` | 相似重复的候选对,人工裁决的工作队列 |
| `referer_whitelist`、`audit_log`、`alerts` | 防盗链白名单、管理操作审计、告警记录 |
| `master_key_fingerprint` | 单行,由主密钥派生的指纹(不能反推主密钥);每次启动据此核对主密钥,缺失或不符即拒绝启动,见[主密钥](./configuration.mdx#主密钥) |
表结构以 `internal/infra/db/schema/` 下每表一个文件的声明式 SQL 为准,每张表与每一列都有注释。版本化迁移由 Atlas 据此生成,
随二进制内嵌,进程启动时按顺序自动应用,已应用的版本记在 `schema_revisions` 表里,跨多个版本升级也会补齐中间的迁移。
迁移不支持降级:库里记有本二进制不认识的迁移版本(即库已被更新版本的二进制迁移过)时,进程拒绝启动,错误信息写明库里与本二进制各自的最新迁移版本。
回滚只能用「旧二进制 + 升级前的快照」,见 [升级](./operations/upgrade.mdx)。
## 上传数据流 [#上传数据流]
数据面上传、后台上传、URL 转存与 S3 PutObject 只在「字节从哪来」上不同,拿到字节之后走同一条管道。上传路径不查重:每次上传都新建 blob,
查重在后台进行,上传路径因此是一条没有分支的直线。
**入口与防护**:认证(上传 Token、会话或 S3 签名),并通过两道闸:按用户的上传频控(`upload_rate_per_min`)与全局上传并发闸
(`max_concurrent_uploads`,满载直接返回 429)。随后把图片字节完整读进内存,任何大小都不写临时文件。
单图上限 `max_upload_mb` 只约束图片本身,恰好等于上限可以上传,超出返回 413。multipart 上传的请求体整体另以「单图上限 + 64 KiB」为界,
只读 `file`、`exif_policy`、`ttl` 三个段:`exif_policy` 与 `ttl` 只认表单字段(不读 URL 查询参数),每个最多 1 KiB,超出返回 400。
**幂等**:带 `Idempotency-Key` 且同一用户已用过这个键时,直接返回已有的图。
**格式与像素预算**:按文件头的 magic bytes 判定真实格式,只接受 JPEG、PNG、GIF、WebP;只读图片头取得尺寸,并与嗅探出的格式交叉核对;
宽 × 高超过 `max_pixels_mp` 直接拒绝,不进入完整解码。解码后的内存由分辨率决定,与文件字节数无关,所以这道闸必须在解码之前。
**配额预检**:按引用计全额,超出用户配额返回 403,不写渠道。
**净化**:EXIF 档位为 `strip` 时剥离元数据(JPEG、PNG、WebP;GIF 没有标准元数据段,原样通过)。
JPEG 剥离后补回一个只含方向(Orientation)的最小 Exif 段,靠方向标记摆正的照片不会转倒;GPS、机型、时间等字段照删,方向为默认值时不补。
**哈希**:完整解码一次计算感知哈希 pHash,再对将要存储的字节计算 SHA-256。净化在哈希之前,所以 `sha256` 永远对应渠道里实际存的字节。
**写渠道**:对象键为 `{short_id}_{sha256 前 8 位}`,嵌入短 ID 是元数据库全部丢失时从存储端反查的最后手段。
用 `failover.Write` 按优先级向健康渠道写入,凑够 `min_success` 份即止,单次写入超时 10 分钟,失败顺延下一个渠道。
**落库**:一个事务内写入 blob、引用、每个在役渠道的副本记录(写成功的为 `done`,其余为 `pending`)与幂等键。
**入队补齐**:为 `pending` 的渠道入队补齐任务后返回。入队失败只记日志,由对账补上。
整个上传在内存中完成,不写临时文件;峰值内存约为「并发数 × max(单图上限,像素预算对应的解码内存)」,调低这几项即可适配小内存机器。
渠道写入成功而落库失败时会留下孤儿对象,由对账 GC 回收,不回滚渠道写入。
## 访问数据流 [#访问数据流]
`GET` / `HEAD /i/{id}`(以及 S3 对象键别名 `/o/{user_id}/{key}`;后台预览图 `/api/admin/images/{id}/thumb` 走同一条管线,但不计访问):
1. 防盗链中间件按 Referer 白名单放行或返回 403(后台预览图不经过这一步)。
2. 查引用,查不到返回 404。到期时刻已过的引用立即按不存在处理,不必等清理任务回收,也不会被访问续期。
3. 校验处理参数:`w` 不在宽度档位、显式 `format` 不在允许格式时返回 400(GIF 原图不处理,忽略这两个参数)。
4. 计访问:只有对外入口的 GET 才在内存中累计活跃度,按 `activity_flush_seconds` 批量落库;HEAD、参数非法的请求与后台预览图都不计访问。
5. 需要缩放或转格式时走按需处理:带 `w`、显式 `format`,或未指定格式而浏览器接受 WebP、档位允许且原图不是 WebP。GIF 一律输出原图。
命中衍生图缓存直接返回;未命中则在 `process_workers` 并发上限内处理并写入缓存,相同参数的并发请求合并为一次处理;
处理繁忙或失败时降级输出原图,并带 `Cache-Control: no-store`。HEAD 请求同样取或生成衍生图,只是不返回正文。
6. 否则输出原图:候选渠道为「健康、不在清理中、该图副本为 `done`」的渠道,按优先级排序。访问模式为 `redirect` 且候选中有允许直链、
未加密并配置了公开地址的渠道时,302 到该渠道,重定向带 `Cache-Control: no-store`(目标随渠道健康与配置变化,不能被缓存);
HEAD 请求只写响应头,不读渠道对象;其余用 `failover.Open` 代理读取,第一个渠道读不到立即切下一个。
缓存语义按入口区分:
| 入口 | Cache-Control | 原因 |
|---|---|---|
| `/i/{id}` | `public, max-age=31536000, immutable` | 短 ID 指向的内容不可变,URL 即版本 |
| `/o/{user_id}/{key}` | `public, no-cache` | 同键重传会让别名改指新图;允许缓存,但每次使用前带 ETag 向源站确认,未变即 304 |
两个入口的原图与衍生图都带强 ETag(原图为内容哈希,衍生图为哈希加处理参数),请求的 `If-None-Match` 与之相同时返回 304;
304 带上 200 响应会有的 `ETag`、`Cache-Control` 与 `Vary`,缓存据此续用已存的副本。格式经 `Accept` 协商的响应带 `Vary: Accept`;
降级输出的原图为 `no-store`,不走 304。数据库查询失败时返回纯文本 500,可以重试。
## outbox 围栏:业务表是唯一真相 [#outbox]
异步补齐由 River 执行,但 River 的任务只是**幂等的触发器**,副本状态的唯一真相是业务表 `blob_channels`:
- 补齐任务的含义是「确保 blob X 在渠道 Y 上有副本」。worker 先读业务表:已是 `done` 直接返回;开始复制前置为 `uploading`,
失败退回 `pending` 交给 River 重试,最后一次尝试仍失败置为 `failed`,成功置为 `done`。
- 入队按(blob,渠道)去重,只对尚未结束的任务去重,同一副本不会在队列里出现两份。
- **对账**在启动时与之后每 10 分钟执行一次:为清理中的渠道补入排空任务;为每个渠道补种缺失的副本行;把健康渠道上 `failed` 的副本复位为 `pending`;
再按主键游标分页扫描全部 `pending` / `uploading` 副本,重新入队;扫描期间有副本变成 `done` 也不会让后面的页错位漏行。
所以即使 River 的任务表丢失或损坏,队列也能从业务表完整重建;进程被强制杀死时正在执行的任务,超过 16 分钟仍停在运行态会被 River 重新排程。
新建渠道时为存量 blob 播种 `pending` 副本行,同样由这套机制自然收敛,没有额外的回填逻辑。
其他异步任务遵循同一原则:删除渠道上的对象(`delete_object`)、排空清理中的渠道(`drain_channel`:先按剩余的副本行逐个删除副本对象,进度就是剩余的副本行;再删除该渠道上 `_imgyoung/` 下的系统对象(含元数据库快照)与深探测残留,别的程序写的对象不动;最后删除渠道记录)、
清理 CDN 缓存(`cdn_purge`)。它们都是幂等的,失败由 River 退避重试:对象删除最终失败留下的孤儿由对账 GC 回收;
排空任务删对象第一次失败就把原因写进渠道的探测结果并触发 `drain_failed` 告警,之后一直退避重试,尝试次数用尽后由对账重新入队,
不会停止(任务自身超时或停机取消不算失败),出口是在后台对该渠道选择「直接移除」(远端对象保留);CDN 清理最终失败时,边缘缓存在 CDN 自身的缓存期满后过期。
## 周期任务 [#周期任务]
周期任务都是服务自带的循环,由装配根统一启停。间隔取自运营配置的,在后台修改后立即按新值重新计时,不必等旧的周期走完。
| 任务 | 间隔 | 内容 |
|---|---|---|
| 健康探测 | `health_check_seconds`(默认 60 秒)| 先重试装载此前装载失败的渠道,再对各渠道并行轻探测;不健康的渠道轻探测通过后,再经深探测确认才恢复。见 [多存储容灾](./library/failover.mdx) |
| 告警评估 | 同健康探测 | 渠道不健康、补齐失败、渠道用量超阈值、排空失败、备份失败;可推送到 Webhook |
| 生命周期清理 | `cleanup_sweep_seconds`(默认 60 秒)| 回收到期引用 → 物理删除过了宽限期的 blob → 冷图打标 → 回收过期幂等键 → 回收超期审计日志 |
| 活跃度落库 | `activity_flush_seconds`(默认 60 秒)| 把公开访问时刻与计数批量写库;衍生图的最近访问时间按同一间隔落库 |
| 查重 | `dedup_scan_seconds`(默认 300 秒)| 精确重复(SHA-256 相同)自动合并,再做 pHash 相似扫描,结果进人工裁决队列 |
| 用量上报 | `usage_report_seconds`(默认 300 秒)| 统计各渠道存储用量并更新指标 |
| 元数据库备份 | `backup_interval_hours`(默认 24 小时)| 用 `VACUUM INTO` 做一份一致性快照,写到每个健康且不在清理中的渠道的 `_imgyoung/backup/`,各渠道各自保留 `backup_keep` 份;任一渠道没写成即本轮失败(`backup_failed` 告警),写成的渠道保留这份快照。启动时任一健康渠道上缺少备份间隔内的快照,就先备份一次。见 [备份与恢复](./operations/backup.mdx) |
| 认证维护 | 1 分钟 / 1 小时(固定)| 批量回写 Token 最近使用时刻 / 回收过期会话 |
| 补齐对账 | 10 分钟(固定)| 见上一节 |
| 对账 GC | 24 小时(固定)| 列举每个健康渠道,与库里的副本记录双向比对:回收孤儿对象,把幻影副本退回 `pending` 重新补齐。口径见下 |
### 对账 GC 的口径 [#reconcile-gc]
对账 GC 处理的对象(系统目录 `_imgyoung/` 与轻探测键不参与比对):
- **孤儿**:只回收同时满足以下条件的对象——键名是本服务的对象键格式 `{short_id}_{sha256 前 8 位}`;库里没有该渠道任何状态的副本记录;
修改时间不早于该渠道的绑定时刻;已过 48 小时宽限期(覆盖「已写入渠道、尚未落库」的在途上传)。其余库里没有记录的对象,
例如别的程序写进来的文件、灾备时新实例接入旧桶而元数据还没恢复的对象、从快照恢复之前写进渠道而恢复后的库里没有记录的对象,只计数不回收,对账日志里记为 `foreign`。
- **深探测残留**:深探测删除失败留下的 `.imgyoung-probe-` 临时对象不看写入时刻,过 48 小时宽限期即删,也不计入对账统计的对象数与字节数。
- **幻影**:库里记为 `done`、渠道上实际缺失的副本,退回 `pending` 并重新入队补齐。
**绑定时刻**(`channels.bound_at`)表示这条渠道记录从什么时候起对应当前的存储位置,本服务只会在它之后往这个位置写对象:
- 新建渠道时等于创建时刻。新实例接入旧桶时,桶里原有的对象都早于它,只计数不回收。
- 在后台编辑渠道时,后端名或任何配置参数有改动,即重置为编辑时刻;重新输入同一个密码也算改动(rclone 编码密码时带随机 IV)。
只改名称、优先级或直链开关不重置,令牌刷新写回也不重置。
- 从快照恢复后,全部渠道重置为恢复后启动的时刻:换入快照之前,在数据目录写下标记文件 `imgyoung.db.restored`;
迁移之后、加载主密钥之前见到标记即重置,成功后删除标记,日志为
`换入快照后已把渠道绑定时刻重置为当前时刻,快照之后写入渠道的对象不会被对账回收`。这样,快照之后上传、在恢复后的库里没有记录的对象不会被当作孤儿删除。
标记在换入暂存文件 `<数据目录>/imgyoung.db.restore` 时写下,后台恢复就是先暂存这个文件;手动恢复也要把快照放成这个文件再启动,
不要直接覆盖 `imgyoung.db`,否则不会写标记、不会重置绑定时刻。
`created_at` 不随绑定时刻变化:它与渠道 ID 一起参与加密渠道的密钥派生。
对账只认本库的副本记录:另一个实例写进同一路径的对象,在本实例看来格式正确、却没有记录,过了宽限期就会被当作孤儿删除。
### 停机 [#停机]
停机(收到 SIGINT / SIGTERM,或在后台恢复备份快照后请求重启)按顺序进行:先停收新请求,在途请求最多等 6 秒,超时强制断开;
再取消后台循环与在途的补齐任务,补齐子系统最多等 2 秒,各循环退出前把内存中待落库的数据写完;最后关闭渠道驱动与数据库。
两段合计 8 秒,在 Docker 默认 10 秒的强制终止时限之内,`deploy/` 下的示例配置还另留了余量(compose 的 `stop_grace_period: 15s`、
systemd 的 `TimeoutStopSec=15s`)。被取消的补齐任务状态留在业务表里,下次启动由对账重新入队。
## 技术选型 [#tech-stack]
选型围绕同一个前提:单实例、单个静态二进制,除了存储渠道,部署时不需要任何外部服务。依赖的具体版本以仓库的 `go.mod` 为准。
### 语言与构建 [#语言与构建]
- **Go**:静态编译成单个二进制,Docker 镜像小;多渠道写入、异步补齐、健康探测、任务队列这类 I/O 编排用 goroutine 写起来直接;
对象存储、HTTP、指标监控的生态成熟,语法简单、编译快,参与贡献的门槛低。决定性的一条是 rclone 本身用 Go 编写,
只有 Go 能在进程内直接调用它的后端,见[为什么在进程内集成 rclone](#rclone)。
- **全程 `CGO_ENABLED=0`**:不依赖 C 工具链与系统库,才能交叉编译出 Linux、macOS、Windows 各两种架构的发布包,
并放进只有一个二进制的 distroless 镜像。下面每一项依赖都是纯 Go 或不需要 cgo。构建一律带 `-tags nodynamic`:
WebP 库默认会在运行期尝试加载系统的 libwebp,关掉这条路径后,Linux 二进制才不依赖动态链接器。
### 依赖 [#依赖]
| 领域 | 选择 | 为什么 |
|---|---|---|
| HTTP 路由 | chi | handler 就是标准库的 `http.Handler`,没有私有概念;上传(流式读请求体)与图片代理(流式写响应)都直接面对 `io.Reader` / `io.Writer`,薄路由最合适 |
| SQLite 驱动 | `modernc.org/sqlite` | 纯 Go 实现,不需要 cgo |
| 数据库访问 | sqlc | 查询写成 SQL,生成类型安全的 Go 代码。不用 ORM:图床的查询都是简单的精确查询,ORM 的抽象没有收益 |
| 迁移 | Atlas | 声明式 schema 是唯一依据,开发期由 Atlas 计算差异生成版本化迁移;运行期用它的纯 Go 执行器在启动时应用,部署方不需要安装迁移工具 |
| 任务队列 | River | 持久化任务队列,重试、退避与并发控制现成;用 SQLite 驱动,任务表与业务表在同一个库里,不多一个服务。任务只是幂等触发器,状态以业务表为准,见[outbox 围栏](#outbox) |
| 周期任务 | 各服务自带的 `Run(ctx, wait)` 循环 | 周期任务只需要「每隔多久执行一次」,间隔取自运营配置、修改后立即重新计时;不引入 cron 库 |
| 依赖装配 | 在 `cmd/imgyoung/build.go` 里手工装配 | 组件集合固定、依赖关系静态,按顺序调用构造函数即可,能搜索、能跳转,没有反射与运行时容器。不用 DI 框架 |
| 生命周期编排 | oklog/run | HTTP、River 与各后台循环各是一个 actor,任何一个退出即触发整组按序停机;比手写 `WaitGroup` 加通道更不容易写错 |
| 架构守卫 | depguard(golangci-lint 内置)| 层间依赖方向写成 lint 规则由 CI 强制,边界不靠自觉,见[分层与依赖方向](#layers) |
| 感知哈希 | goimagehash | 纯 Go,输入是解码后的 `image.Image`,直接接在上传的解码步骤后面 |
| 图像编解码 | 标准库、`golang.org/x/image`、gen2brain/webp | 都不需要 cgo;WebP 编码由 gen2brain/webp 在纯 Go 的 WebAssembly 运行时里执行 libwebp |
| 加密 | 标准库 AES-256-GCM 与 HKDF;`golang.org/x/crypto` 的 argon2id | 分别用于加密渠道配置与凭证、派生渠道密钥与 S3 secret、哈希登录密码。渠道数据本身的加密交给 rclone crypt |
| 限流 | `golang.org/x/time/rate` | 上传频控与登录限速都是进程内的令牌桶 |
| 日志与指标 | `log/slog`、Prometheus 的 client_golang | 各自领域的事实标准 |
| 短 ID | jaevor/go-nanoid | 对外短 ID 用成熟实现;内部主键用 SQLite 自增 rowid,不对外暴露 |
项目以 MIT 许可发布:与 rclone 一致,贡献门槛最低;自部署工具不存在被云厂商拿去托管转售的现实顾虑。
### 不引入的东西 [#不引入的东西]
- **Redis**:单实例下没有它的位置。幂等键、引用计数这类需要原子性的地方交给 SQLite 的约束与原子更新,限流是进程内令牌桶,
衍生图缓存在本地磁盘。单实例下进程内的实现严格优于 Redis:没有网络往返与序列化,也不多运维一个服务。走向多实例时才需要重新考虑。
- **配置文件与配置库**:环境变量只负责把进程启动起来,其余运营配置以数据库里的 `settings` 表为唯一依据、在后台修改,
「这个值从哪来」永远只有一个答案,见[配置](./configuration.mdx)。
- **ORM、DI 框架、cron 库**:理由见上表。
### SQLite 与版本化迁移 [#sqlite-与版本化迁移]
SQLite 是单个文件、没有外部依赖,一致性快照也就是一个文件,最贴合自部署,也与单实例设计相配。
版本化迁移从第一天起就有:长期运行的自部署实例会跨很多个版本升级,没有版本化迁移,表结构很快失控。迁移只有升级方向,见[数据模型](#data-model)。
### 两条升级缝 [#两条升级缝]
只有两处预期会被替换,其余选型都是定死的:Go 生态的薄库都贴着标准库的形状,真要替换也是机械劳动,为此预留灵活性没有收益。
- **图像处理引擎**:按需处理的唯一入口是 `internal/infra/imaging` 的 `Processor.Process(src, params)`。当前是纯 Go 实现:标准库解码
JPEG / PNG / GIF,WebP 编码用 gen2brain/webp,只输出 WebP、JPEG、PNG,GIF 只收发原图不处理。AVIF、HEIF 不在其列:使用率低,
编码器(如 libaom)又重。需要更多格式或更快的处理时(例如基于 libvips 的实现),替换的是这个处理器。
- **数据库**:当前是 SQLite。表结构以声明式 SQL 为准,迁移由 Atlas 生成,查询代码由 sqlc 生成;换 PostgreSQL 时,
按目标方言重新生成查询代码与迁移,并换上 River 的 PostgreSQL 驱动。
两处目前都只有一个实现,没有为它们预先定义接口类型。
## 为什么在进程内集成 rclone [#rclone]
存储层的原则是:rclone 只负责「把字节存到某个后端、取回、查询、列举、删除」,主备切换、补齐、引用计数、查重、TTL、健康判断全部由 ImgYoung 自己实现,
两者之间隔着 `store/driver.Driver` 接口。
集成方式是直接 import rclone 的 `fs` 与后端包,在进程内以参数映射构造 `fs.Fs`,而不是调用 rclone 命令行、librclone 的 RPC 接口或 rclone 配置文件:
- **流式、不落盘**:上传用 `fs.Fs.Put` 直接写内存里的字节,读取直接把对象流转给 HTTP 响应。
- **配置在自己的库里**:渠道参数用主密钥加密后存在 `channels` 表,运行时解密成映射交给后端;参数含凭证,不写日志,错误链也不携带参数。
- **令牌写回只有一个刷新者**:rclone 自己刷新 OAuth 令牌,经参数写回钩子交给应用,立即加密落库;应用不另起刷新循环。
- **一个句柄完成全部操作**:读、写、探测、列举、删除走同一个 `fs.Fs`,错误映射只维护一套(对象缺失统一收敛为 `driver.ErrNotFound`)。
- **渠道加密零额外代码**:加密渠道在后端之上叠加 rclone 的 crypt 层,上传与访问路径无需改动;crypt 是公开格式,持密码可以用标准 rclone 独立解密。见 [渠道存储加密](./channels/encryption.mdx)。
- **配置可以原样导出**:后台能为每个渠道导出一份 rclone 配置文件,参数与驱动实际交给后端的完全一致,加密渠道另带 crypt 远端;
它由 `store/rclone` 的 `ConfigFile` 生成,与构造驱动共用同一套参数整理,标准 rclone 凭它即可脱离 ImgYoung 读写渠道数据。见 [驱动接口与 rclone 实现](./library/driver.mdx#config-file)。
- **测试不需要云账号**:rclone 的 `local` 与 `memory` 后端让整条存储链路可以在本机做集成测试。
注册全部 rclone 后端(`backend/all`)意味着任何 rclone 支持的存储都能直接作为渠道使用,rclone 升级即带来新后端;代价是二进制体积达到百 MB 量级。
这里用的是 rclone 的内部 API,它不承诺稳定,所以 `go.mod` 固定了 rclone 版本,升级由人工进行并跑完整测试。
`Driver` 接口不假设 rclone 存在,这也是 rclone 内部 API 变动时的退路:换一个 `Driver` 实现即可,业务代码不动。
接口与 rclone 实现拆成两个包,也让应用之外的 Go 项目可以单独复用这套存储引擎,见 [作为 Go 库使用](./library/index.mdx)。