Atsulog#006

2026/11/08 15:03
2026/11/08 15:13
Contents
  1. 全体像
  2. アップロード
  3. 最適化
  4. 保存
  5. 配信
  6. 画像の管理

全体像#

記事に貼る画像は,エディタからアップロードしてサーバーのデータベースに保存し,/images/... という URL で配信しています.アップロードから表示までの流れは次のとおりです.

sequenceDiagram
    participant E as Editor
    participant S as Server
    participant DB as images.db
    participant B as Reader
    E->>S: Upload image
    Note over S: Check and optimize
    S->>DB: Save by hash
    S-->>E: /images/hash.webp
    Note over E: Insert into Markdown
    B->>S: GET /images/hash.webp
    S->>DB: Look up hash
    S-->>B: Image (cached 1 year)
  • アップロード:受け取った画像が本当に画像かを確かめる
  • 最適化:縮小して WebP に変換し,位置情報などのメタデータを消す
  • 保存:画像の中身から計算した ID を付けて,データベースに入れる
  • 配信:ID から画像を取り出して返す.中身が変わらないので長期間キャッシュさせる
  • 管理:どの記事にも使われていない画像を見つけて削除する

アップロード#

エディタでファイルを選ぶか,画像をドラッグ&ドロップすると,Server Action の uploadImageAction が呼ばれます.処理が終わると,返ってきた URL が ![](/images/fd99beea41701846.webp) の形でカーソルの位置に挿入されます.アップロードできるのは 10MB までです.

export async function uploadImageAction(formData: FormData) {
  await requireAuth(); // ログインしていなければ止める

  const file = formData.get("image") as File;
  if (!file) throw new Error("No file provided");

  // MIME タイプを整える (例: "IMAGE/PNG; q=1" → "image/png")
  const mimeType = normalizeMimeType(file.type);
  if (!mimeType.startsWith("image/")) throw new Error("Not an image");

  const bytes = new Uint8Array(await file.arrayBuffer());
  const optimized = await optimizeImage(bytes, mimeType);        // 最適化
  const { url } = saveImage(optimized.data, optimized.mimeType); // 保存
  return { url };
}

MIME タイプ とは,image/png や text/html のように,データの種類を表す文字列です.保存した MIME タイプは,配信するときの Content-Type ヘッダーにそのまま使います.画像以外のものが紛れ込むと,画像の URL から HTML などが返ってしまうので,image/ で始まらないものは受け付けません.

最適化#

スマホで撮った写真は,そのままだと数 MB あり,ブログに載せるには大きすぎます.そこで,保存する前に sharp で縮小と変換をしています.sharp とは,Node.js で画像の変換やリサイズを高速に行うライブラリです.

/** Twice the article column, for high-density screens. */
const MAX_EDGE = 1600;

export async function optimizeImage(data: Uint8Array, mimeType: string) {
  // GIF や SVG などは変換せずにそのまま返す
  if (!CONVERTIBLE.has(mimeType)) return { data, mimeType };

  const output = await sharp(data)
    .rotate()                   // EXIF の向きに合わせて回転する
    .resize({
      width: MAX_EDGE,
      height: MAX_EDGE,
      fit: "inside",            // 縦横比を保ち,長辺を 1600px 以内に
      withoutEnlargement: true, // 小さい画像は拡大しない
    })
    .webp(mimeType === "image/png" ? { lossless: true } : { quality: 80 })
    .toBuffer();

  return { data: new Uint8Array(output), mimeType: "image/webp" };
}
  • 長辺を 1600px までにする:記事の本文の幅の 2 倍で,高解像度の画面でもぼやけない大きさ.もともと小さい画像は拡大しない
  • WebP に変換する:WebP とは,Google が開発した画像形式で,同じ画質なら JPEG や PNG より小さくなりやすい
  • PNG は可逆圧縮にする:PNG はスクリーンショットであることが多いので,文字がにじまないよう画質を落とさない 可逆圧縮 にする.写真(JPEG など)は品質 80 の非可逆圧縮にする
  • 変換しない形式もある:変換するのは JPEG,PNG,WebP,AVIF,TIFF の 5 つだけ.アニメーションの GIF,SVG,ICO などは受け取ったまま保存する

スマホの写真には EXIF という撮影情報が埋め込まれています.EXIF には,撮影した向き,日時,機種のほか,位置情報(GPS)が入っていることもあります.sharp は出力するときにこれらのメタデータを付けないので,写真から撮影場所が漏れることはありません.ただし,向きの情報まで消えると写真が横倒しのまま表示されてしまうので,先に .rotate() で向きの情報どおりに画像を回転させています.

保存#

画像は,ファイルとしてではなく,SQLite のデータベース images.db に BLOB として保存しています.BLOB(Binary Large Object)とは,画像のようなバイナリデータをそのまま入れておくための型です.データベースに入れておけば,記事と同じ方法(SQL のダンプと Git)でバックアップでき,サーバーを移すときも画像のファイルを別に運ぶ必要がありません.

CREATE TABLE IF NOT EXISTS images (
  hash       TEXT PRIMARY KEY, -- 画像の中身から計算した ID
  mime_type  TEXT NOT NULL,    -- 配信時の Content-Type
  data       BLOB NOT NULL     -- 画像のバイナリ
);

画像の ID には,画像のバイナリの SHA-256 ハッシュ値 の先頭 16 文字を使っています.ハッシュ値は,同じデータからは必ず同じ値が得られ,違うデータから同じ値が出ることはほぼありません.16 文字(16 進数で 64 ビット分)に切り詰めていますが,個人のブログの画像の数なら重複の心配はまずありません.

export function saveImage(data: Uint8Array, mimeType: string) {
  const hash = hashImage(data); // SHA-256 の先頭 16 文字
  getImageDb()
    .prepare(
      // 同じ hash の行がすでにあれば何もしない
      `INSERT OR IGNORE INTO images (hash, mime_type, data)
       VALUES (?, ?, ?)`,
    )
    .run(hash, mimeType, data);

  // 例: /images/fd99beea41701846.webp
  return { hash, url: imageUrl(hash, mimeType) };
}

このように,データの中身から ID を決める方式を コンテンツアドレス と呼びます.コンテンツアドレスには,次の性質があります.

  • 同じ画像は 1 つだけ保存される:同じ画像を 2 回アップロードしても ID が同じなので,INSERT OR IGNORE で 2 回目は保存されず,1 回目と同じ URL が返る
  • URL の中身は変わらない:画像が 1 ピクセルでも違えば ID も URL も変わる.逆に言えば,ある URL が指す画像は永久に同じ

配信#

画像の URL は /images/<hash>.<拡張子> の形です.これを受け取るのが,Route Handler の app/images/[file]/route.ts です.Route Handler とは,ページではなく任意のレスポンスを返す処理を書くための仕組みです.フォルダ名の [file] は 動的セグメント で,/images/ に続く部分が何であっても,このファイルがリクエストを受け取ります.

export async function GET(_request, { params }) {
  const { file } = await params; // 例: "fd99beea41701846.webp"

  // The extension is only a hint; the stored MIME type decides.
  const hash = file.split(".")[0]; // 拡張子を除いた部分が ID
  // ID の形式が正しいときだけデータベースを引く
  const image = isImageHash(hash) ? getImage(hash) : null;
  if (!image) return new NextResponse("Image Not Found", { status: 404 });

  const headers = {
    "Content-Type": image.mimeType,
    "Cache-Control": "public, max-age=31536000, immutable",
    "X-Content-Type-Options": "nosniff",
  };
  if (image.mimeType === "image/svg+xml") {
    // SVG の中のスクリプトを動かさない
    headers["Content-Security-Policy"] =
      "default-src 'none'; style-src 'unsafe-inline'";
  }
  return new NextResponse(image.data, { headers });
}
  • 拡張子は飾り:URL の拡張子は見やすさのためだけに付けていて,実際の種類は保存した MIME タイプで決める.拡張子を .png に変えても同じ画像が返る
  • ID の形式を先に確かめる:16 文字の 16 進数でなければ,データベースを引かずに 404 を返す
  • 1 年間キャッシュさせる:コンテンツアドレスなので,同じ URL の画像が変わることはない.そこで max-age=31536000(1 年)と immutable を付け,その間はサーバーに問い合わせずにキャッシュを使ってよいとブラウザに伝えている
  • 種類を推測させない:X-Content-Type-Options: nosniff で,ブラウザが中身を見て種類を勝手に判断し,画像を HTML などとして扱ってしまうのを防ぐ
  • SVG のスクリプトを止める:SVG は XML で書かれた画像で,中に <script> を書ける.SVG の URL を直接開くとそのスクリプトがサイト上で動いてしまうので,CSP(Content Security Policy)でスクリプトの実行を禁止している.CSP とは,そのページで読み込みや実行を許すものをレスポンスヘッダーで指定する仕組み

画像の管理#

記事を書き直したり,画像を差し替えたりすると,どの記事にも使われていない画像がたまっていきます.そこで,管理画面の /edit/images で,画像の一覧とそれぞれを使っている記事を確認できるようにしています.

どの記事がどの画像を使っているかは,データベースに記録していません.画面を開くたびに,下書きを含むすべての記事の本文から /images/<hash> という文字列を正規表現で探して数えています.

/** "/images/<hash>" anywhere in a body; the extension does not matter. */
const IMAGE_REFERENCE = /\/images\/([0-9a-f]{16})\b/g;

export async function getImageUsers() {
  const users = new Map<string, ImageUser[]>(); // hash → 使っている記事
  for (const { slug, title, content } of await getArticleContents()) {
    // 本文に出てくる hash を集める (同じ記事の中の重複は 1 つにする)
    const hashes = new Set(
      Array.from(content.matchAll(IMAGE_REFERENCE), (m) => m[1]),
    );
    for (const hash of hashes) {
      users.set(hash, [...(users.get(hash) ?? []), { slug, title }]);
    }
  }
  return users;
}

使用状況を別の表に記録していないので,記事を書き換えたときに記録の更新を忘れて食い違う,ということが起きません.

一覧の画面では,次のことができます.

  • 未使用の画像を見つける:どの記事にも使われていない画像を,一覧の先頭に表示する
  • 未使用の画像を削除する:削除ボタンは未使用の画像にだけ表示される.さらに削除の Server Action の中でも使用状況を調べ直し,使われている画像の削除は断る
  • ほかの記事に貼る:Copy ボタンで,その画像の ![](url) をクリップボードにコピーすることができ,簡単に他の記事に掲載することができる.