# 实战：图片处理 API（Hono + sharp）

本教程把 [项目蓝图 4](/docs/projects) 展开为可执行步骤。`sharp` 是原生依赖，部署前必须在目标环境做 smoke test——这是本项目的首要验收点。

## 目标与最终形态

```text
image-api/
├── routes/transform.ts   # POST /transform：校验 → 转换 → 存储
├── services/image.ts     # sharp 管线，纯函数可测
├── storage.ts            # 对象存储抽象（本地磁盘 / S3 兼容）
├── main.ts               # Hono app 装配
└── deno.json
```

请求流：客户端 `multipart/form-data` 上传图片和参数 → 服务端预检 MIME（来自客户端，仅作第一道过滤）、字节数与尺寸参数 → sharp 解码验证真实内容并转换 → 写入对象存储 → 返回 key。

## 初始化

```bash
deno init --npm hono --template=deno image-api
cd image-api
deno add npm:sharp
```

## 里程碑一：受限的转换管线

### sharp 封装为纯函数

```ts title="services/image.ts"
import sharp from "sharp";

const MAX_PIXELS = 40_000_000; // 宽 × 高的输入像素上限

export interface TransformOptions {
  width?: number;
  format: "webp" | "jpeg" | "png";
  quality?: number;
}

export function transformImage(input: Uint8Array, opts: TransformOptions) {
  return sharp(input, {
    // 拒绝像素总量超限的输入；默认 failOn 为 "warning"，
    // 对不受信任的输入保持默认，让坏数据直接报错而不是静默截断
    limitInputPixels: MAX_PIXELS,
  })
    .rotate() // 按 EXIF 方向转正
    .resize({ width: opts.width, withoutEnlargement: true })
    .toFormat(opts.format, { quality: opts.quality ?? 80 })
    .toBuffer();
}
```

`limitInputPixels` 是 sharp 自带的资源防线（默认约 2.68 亿像素），按服务内存预算主动调低。像素解压炸弹在这里被挡掉，而不是在 OOM 时才被发现。

### 存储抽象

```ts title="storage.ts"
export interface Storage {
  put(key: string, data: Uint8Array, contentType: string): Promise<void>;
  publicUrl(key: string): string;
}

export class LocalStorage implements Storage {
  constructor(private dir: string, private baseUrl: string) {}
  async put(key: string, data: Uint8Array) {
    const path = `${this.dir}/${key}`;
    // key 可能含子目录（images/<uuid>.webp），先递归创建父目录
    await Deno.mkdir(path.slice(0, path.lastIndexOf("/")), { recursive: true });
    await Deno.writeFile(path, data);
  }
  publicUrl(key: string) {
    return `${this.baseUrl}/${key}`;
  }
}
```

开发和测试用 `LocalStorage` 写入 `./data`；生产换成 S3 兼容对象存储（`PutObject`，可通过 npm 兼容层使用 AWS SDK v3），接口不变。key 由服务端生成（如 `crypto.randomUUID()`），绝不使用客户端提供的文件名。

### 路由与校验

```ts title="routes/transform.ts"
import { Hono } from "hono";
import { transformImage } from "../services/image.ts";
import type { Storage } from "../storage.ts";

// 用 Set 做严格白名单；`type in {...}` 会放过 Object.prototype 上的继承键
const ALLOWED_TYPES = new Set(["image/jpeg", "image/png", "image/webp"]);
const MAX_BYTES = 10 * 1024 * 1024; // 10 MB 上传上限
const MAX_WIDTH = 4096;

export function transformRoute(storage: Storage) {
  const app = new Hono();

  app.post("/transform", async (c) => {
    const body = await c.req.parseBody();
    const file = body["file"];
    if (!(file instanceof File)) {
      return c.json({ error: "missing file" }, 400);
    }
    if (!ALLOWED_TYPES.has(file.type)) {
      return c.json({ error: "unsupported media type" }, 415);
    }
    if (file.size > MAX_BYTES) {
      return c.json({ error: "file too large" }, 413);
    }

    const widthParam = body["width"]?.toString();
    const width = widthParam ? Number(widthParam) : undefined;
    if (width !== undefined && (!Number.isInteger(width) || width < 1 || width > MAX_WIDTH)) {
      return c.json({ error: "invalid width" }, 400);
    }

    let output: Uint8Array;
    try {
      output = await transformImage(new Uint8Array(await file.arrayBuffer()), {
        width,
        format: "webp",
      });
    } catch {
      // sharp 对损坏或超限输入抛错；不回显内部错误细节
      return c.json({ error: "image processing failed" }, 422);
    }

    const key = `images/${crypto.randomUUID()}.webp`;
    await storage.put(key, output, "image/webp");
    return c.json({ key, url: storage.publicUrl(key) }, 201);
  });

  return app;
}
```

错误映射是验收的一部分：类型不符 415、超限 413、参数非法 400、处理失败 422，内部异常统一 500 且不泄露细节。注意 `File.type` 来自客户端，只是第一层过滤——真正的内容校验由 sharp 解码完成，解不开就是 422。

```ts title="main.ts"
import { Hono } from "hono";
import { serveStatic } from "hono/deno";
import { transformRoute } from "./routes/transform.ts";
import { LocalStorage } from "./storage.ts";

const app = new Hono();
app.get("/health", (c) => c.json({ ok: true }));
// 本地模式直接回源 /files/* 到 ./data；生产换 S3 后删除这段，URL 指向对象存储
app.use(
  "/files/*",
  serveStatic({ root: "./data", rewriteRequestPath: (p) => p.replace(/^\/files/, "") }),
);
app.route("/", transformRoute(new LocalStorage("./data", "/files")));

Deno.serve(app.fetch);
```

## 里程碑二：加固

- **请求体上限**：平台层（反向代理 / Deploy）和 `file.size` 检查双重限制；不要只信 `Content-Length`。
- **超时**：存储写入包 `AbortSignal.timeout()`，避免慢存储拖住 worker。
- **并发**：sharp 是 CPU 密集操作，评估单实例并发上限，必要时限流或排队。
- **输出尺寸**：`withoutEnlargement: true` 防止小图被放大，输出格式收敛到 webp 一种（或按 `Accept` 协商）。

## 测试策略

- **管线单测**：用 sharp 自己生成测试图（`sharp({ create: { width, height, channels: 3, background: "#333" } }).png().toBuffer()`），断言输出格式、尺寸；喂损坏字节断言抛错；构造超过 `limitInputPixels` 的输入断言被拒绝。
- **路由测试**：Hono 的 `app.request()` 直接驱动，不走网络：

```ts title="routes/transform_test.ts"
import { assertEquals } from "jsr:@std/assert";
import { transformRoute } from "./transform.ts";
import { LocalStorage } from "../storage.ts";

Deno.test("拒绝不支持的类型", async () => {
  const app = transformRoute(new LocalStorage(await Deno.makeTempDir(), ""));
  const form = new FormData();
  form.set("file", new File(["plain text"], "a.txt", { type: "text/plain" }));
  const res = await app.request("/transform", { method: "POST", body: form });
  assertEquals(res.status, 415);
});
```

- **smoke test（原生依赖）**：`sharp` 通过 Node-API 加载预编译二进制。在目标 Deno 版本、目标容器/Deploy 环境跑一个最小转换脚本，用**与生产相同的权限标志**确认能加载并能处理一张 1×1 图片。这一步不过，不上线：

```ts title="smoke.ts"
import sharp from "sharp";
const img = await sharp({
  create: { width: 1, height: 1, channels: 3, background: "#000" },
}).png().toBuffer();
const out = await sharp(img).resize(1, 1).webp().toBuffer();
console.log(`smoke ok, ${out.byteLength} bytes`);
```

## 部署

- **容器**：基于官方 `denoland/deno` 镜像，`deno task start` 启动；把 smoke test 放进 CI 的镜像构建后步骤。
- **权限**：sharp 是 Node-API 原生插件，硬性要求是本地 `node_modules`（`nodeModulesDir: "auto"` 或 `--node-modules-dir`）、批准其 install 生命周期脚本（`deno approve-scripts`），以及 `--allow-ffi`。在此之上按最小集合追加：`--allow-net`（存储端点）、`--allow-env`（白名单变量名）、本地存储模式加 `--allow-read=./data --allow-write=./data`。官方 sharp 示例还会用到部分 `--allow-sys`；以 smoke test 在目标环境下实测收窄，不要预先给 `-A`：

```bash
deno run --allow-ffi --allow-env=PORT --allow-read=./data --allow-write=./data main.ts
```
- **观测**：`/health` 区分存活与就绪；日志记录 key、输入字节数、耗时与错误分类，不记录图片内容。

## 验收清单

<Checklist id="image-api-acceptance" items={[
  "MIME 白名单、字节上限、像素上限、尺寸参数四层校验齐备",
  "错误映射为 400/413/415/422/500，不回显内部细节",
  "存储 key 由服务端生成，客户端文件名不参与路径",
  "sharp smoke test 在目标 Deno 版本与部署环境通过",
  "生产权限为最小集合（无 -A），smoke test 用同一权限运行",
  "deno fmt --check、deno lint、deno check、deno test 全部通过"
]} />

官方参考：[Hono on Deno](https://hono.dev/docs/getting-started/deno)、[Hono request API](https://hono.dev/docs/api/request)、[sharp constructor 选项](https://sharp.pixelplumbing.com/api-constructor)。
