# 上传 (/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)。
