UPL

go-uploads

基于 Go + Gin 的高性能文件接收服务,支持单文件与大文件分片上传, 通过 HMAC 签名保证安全,为 Electron / Web / 微信小程序 / 移动 App 提供统一上传后端。

v1.0.0 体积 12 MB Go 1.21+ 系统 Windows / Linux / macOS

ARCHITECTURE

系统架构

架构图
任意客户端 ────▶ PHP 后端 (签发签名) ────▶ go-uploads (存储文件)
  │  ▲                │                        │
  │  │                │                        ├─ PUT  /upload
  │  │                │                        ├─ POST /upload/complete
  │  │                │                        └─ GET  /health
  │  │                │
  │  └── 返回签名配置 ─┘
  │
  └── PUT 预签名 URL + 文件流 ─────────────────▶ go-uploads

核心思路:客户端不持有密钥。

PHP 后端负责鉴权 + 签发带签名的临时上传 URL,客户端拿到 URL 后直接向 go-uploads 传输文件。分片上传完成后 PHP 通知 go-uploads 合并。

UPLOAD FLOW

上传流程

≤ 2MB

单文件上传

  1. 1.客户端 → PHP uploadConfig() 获取 { isChunk:false, putUrl, key }
  2. 2.客户端 → PUT {putUrl} 二进制流
≥ 2MB

分片上传

  1. 1.客户端 → PHP uploadConfig() 获取 { isChunk:true, chunkUrls[], uploadId, chunkSize, notifyUrl, key }
  2. 2.客户端 → PUT {chunkUrls[i]} 逐片上传
  3. 3.全部传完 → 客户端 POST {notifyUrl} 通知合并
  4. 4.PHP uploadNotify() → go-uploads POST /upload/complete 合并 + 清理

分片大小 2MB(2048KB),由 PHP 端 $CHUNK_SIZE 常量控制。

API

接口规范

PUT /upload?signature={hex}&options={url_encoded_json}

文件上传入口,支持单文件模式和分片模式。请求体为二进制文件流,Content-Type: application/octet-stream

options JSON 字段

Key string · 必要 — 存储路径 {Ymd}/{filename}.{ext},不含 uploads/ 前缀
ContentType string · 可选 — MIME 类型
PartNumber int · 可选 — 分片编号(从1起),>0 表示分片模式
UploadId string · 可选 — 分片会话ID,非空表示分片模式

成功 (200)

{
  "code": 200,
  "data": { "key": "20240101/example.jpg", "size": 1048576 }
}

错误响应

403 签名无效 / 缺少参数
{"code": 403, "message": "invalid signature"}
400 路径非法 / 格式不允许
{"code": 400, "message": "key escapes upload directory"}
413 文件过大 · 500 服务端错误
{"code": 413, "message": "File exceeds maximum size"}
POST /upload/complete?signature={hex}&options={url_encoded_json}

分片合并接口,由 PHP 的 uploadNotify() 代理调用,客户端不直接请求。options 字段:Key + UploadId

  1. 1.验证签名
  2. 2.读取 {upload.dir}/.chunks/{UploadId}/part_N
  3. 3.按编号升序合并
  4. 4.校验总大小 ≤ maxSize
  5. 5.删除临时目录 → 返回
// 成功
{"code":200,"data":{"key":"20240101/example.mp4","size":52428800}}
// 404 UploadId 不存在 / 无分片
{"code":404,"message":"UploadId not found"}
POST /upload/delete?signature={hex}&options={url_encoded_json}

批量删除文件,由 PHP 或可信后端调用。options 建议为 {"Action":"delete"},请求体为 { "keys": ["20240101/file1.jpg", ...] }

  • 每个 Key 经过 safePath 路径穿越校验
  • 仅删除文件,不删除目录
  • Key 不存在返回 not found,不影响其他删除
{
  "code": 200,
  "data": {
    "deleted": ["20240101/file1.jpg"],
    "failed": [{"key": "20240101/ghost.png", "reason": "not found"}]
  }
}
GET /health 无需签名
{"status":"ok","timestamp":"2026-08-04T12:00:00Z"}

SIGNATURE

签名机制

生成(PHP 端)

$optionsJson = json_encode($options);            // JSON 序列化
$signature   = hash_hmac('sha256', $optionsJson, $apikey);
$url         = $baseUrl . '/upload?signature=' . $signature
             . '&options=' . urlencode($optionsJson);

密钥:cH1rC************5xm6bp9k(PHP 与 config.yaml 必须一致)

验证(Go 端)

rawSignature := c.Query("signature")
rawOptions   := c.Query("options")   // 直接对原始 options 计算 HMAC
mac := hmac.New(sha256.New, []byte(config.ApiKey))
mac.Write([]byte(rawOptions))
expectedSig := hex.EncodeToString(mac.Sum(nil))
if !hmac.Equal([]byte(rawSignature), []byte(expectedSig)) {
    return 403   // 恒定时间比较,防时序攻击
}
  • 直接对 URL Query 原始 options 做 HMAC,避免 JSON 键顺序不一致
  • 客户端不持有 apikey,密钥仅存在于 PHP 与 go-uploads 两端

CONFIG

配置参考

config.yaml
apikey: "cH1rC************5xm6bp9k"

server:
  port: 9000
  readTimeout: 3600
  writeTimeout: 3600

upload:
  maxSize: 107374182400      # 100GB
  dir: "./uploads"
  allowedFormats: []         # 空 = 不限制
  chunkTtl: 86400            # 分片存活 24h

缺失字段使用默认值,无需完整写出。最小配置:apikey + upload.dir

apikey必填

HMAC-SHA256 签名密钥,与 PHP 端 $up_apikey 一致。

server.port默认 9000

HTTP 监听端口。

server.readTimeout / writeTimeout默认 3600s

HTTP 读写超时,大文件上传需较大值。

upload.maxSize默认 100GB

单文件(含合并后)最大字节数,通过 io.LimitReader 流式限流,不依赖 Content-Length。

upload.dir默认 ./uploads

文件存储根目录,Key 相对路径拼接至此。

upload.allowedFormats默认 [] 不限制

扩展名白名单,如 ["jpg","png","mp4","pdf"],不区分大小写。

upload.chunkTtl默认 86400s

未完成分片目录存活时间,后台 goroutine 自动清理,设 0 关闭。

CLIENTS

客户端接入

PHP 端是上传流程的核心调度层,负责用户鉴权、签发签名 URL、代理合并请求。完整参考实现见仓库根目录 php生成配置.php

uploadConfig() — 签发上传配置

POST /upload/uploadConfig
x-api-key: cH1rC************5xm6bp9k
{ "mime": "image/jpeg", "filename": "photo.jpg",
  "size": 5242880 }

// 响应(单文件)
{ "isChunk": false, "key": "20240101/photo.jpg",
  "putUrl": "http://112.132.215.28:9000/upload?...",
  "chunkUrls": [], "chunkSize": 0 }

// 响应(分片)
{ "isChunk": 1, "chunkUrls": [...], "chunkSize": 2097152,
  "key": "20240101/video.mp4", "uploadId": "abc123def456",
  "notifyUrl": "https://your-server.com/upload/uploadNotify" }

uploadNotify() — 通知合并

$options = ['Key' => $key, 'UploadId' => $uploadId];
$optionsJson = json_encode($options);
$signature = hash_hmac('sha256', $optionsJson, $this->up_apikey);
$url = $this->up_apiurl . '/upload/complete?signature='
     . $signature . '&options=' . urlencode($optionsJson);
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
鉴权x-api-key header,uploadNotify 与 login 排除
Key 生成date('Ymd').'/'.$name.'.'.$ext
UploadId — 混入 apikey 后 md5(json) 保证唯一
分片阈值$CHUNK_SIZE = 2MB
新平台接入 — 只需调 uploadConfig / 上传 / uploadNotify,PHP 对客户端透明

SECURITY

安全模型

攻击 向量 防御
非授权上传 / 删除 无签名请求 HMAC 签名验证 → 403
签名重放 复用旧签名 URL 签名含 Key + UploadId,无法跨文件复用
路径穿越写文件 Key=/etc/passwd 三层路径校验 → 400
路径穿越删除 Key=../ safePath 三层校验 → 400
磁盘写满 超大文件 io.LimitReader 限流 + maxSize → 413
上传恶意文件 .php、.exe allowedFormats 白名单 → 400
时序攻击猜解签名 逐字节比较响应时间 hmac.Equal 恒定时间比较

路径穿越三层防御

① filepath.IsAbs → 拒绝绝对路径
② strings.Contains(clean,"..")
③ HasPrefix(fullPath, absDir+sep)

大小限制

单文件: io.LimitReader(maxSize+1) 分片合并: 合并后校验总 size 超限删除 不依赖 Content-Length header

孤儿分片清理

后台 goroutine 每小时扫描 目录 mtime > chunkTtl(24h) 则清理 合并成功在 complete 中即时删除

运维建议

  • 生产加 nginx 反代,只暴露 /upload/health,屏蔽 /upload/complete(仅 PHP 内网调用)
  • 定期轮换 apikey,同步更新 PHP 与 config.yaml
  • 监控 upload.dir 磁盘使用量,配置 allowedFormats 白名单

ERROR CODES

错误码汇总

HTTP code 说明
200 200 成功
400 400 参数错误(路径非法、格式不允许)
403 403 签名无效或缺少签名参数
404 404 UploadId 不存在或无分片
413 413 文件超过 maxSize
500 500 服务端错误