1️⃣

ユーザーにS3(互換)ストレージへの「1回きりのアップロード」を許可する

に公開

Amazon S3 や互換ストレージにユーザーから直接ファイルをアップロードさせるときは、事前署名 URL (Pre-signed URL) を使うのが一般的です。しかし、事前署名 URL は発行したら基本的に無効化することができないため、単純にこれを用いると期限内であれば何度でもファイルを上書きできてしまいます。ファイルの上書きを防ぐには、アップロード完了後に明示的に再アップロードを無効化する仕組みが必要になります。

そこで今回は、マルチパートアップロードを使って「1回だけ」のアップロードを実現し、完了後は再アップロードをできなくする方法を紹介します。

よくあるやりかた

ユーザーに S3(またはその互換ストレージ)へアップロードさせるよくあるやり方は、事前署名 URL を使うことです。これはバケットに直接アクセスできる署名付きのURLで、有効期限を設定できます。

import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";

const UPLOAD_EXPIRES_IN = 3600; // 1 時間有効

const s3 = new S3Client({ region: "ap-northeast-1" });

async function generatePresignedUrl(
  bucket: string,
  key: string
): Promise<string> {
  const command = new PutObjectCommand({ Bucket: bucket, Key: key });
  const url = await getSignedUrl(s3, command, { expiresIn: UPLOAD_EXPIRES_IN });
  return url;
}

ですが、この方法では先述の通り有効期限内なら何回でも上書きすることができます。

マルチパートアップロードで「1回だけ」を実現する

この問題を解決するのに有効なのが、マルチパートアップロードです。マルチパートアップロードでは、ファイルを複数のパートに分割してアップロードして、すべてのパートがアップロードされたら完了処理を行います。完了処理が終わると同じ UploadId は再利用できなくなるので、それ以降の再アップロードを防げます。

パートが1つだけでもマルチパートアップロードは使えるため、小さいファイルでもこの方法を使うことができます。

具体的な実装方法

実装は以下の流れで行います。

  1. (サーバー)マルチパートアップロードを開始して UploadId を取得する
  2. (サーバー)必要なパートの署名付き URL をクライアントにまとめて渡す
  3. (クライアント)各パートをアップロードする
  4. (クライアント)アップロード完了後、各パートの ETag をサーバーに送る
  5. (サーバー)マルチパートアップロードを完了させる

マルチパートアップロードの開始と署名URLの生成(1-2)

import {
  CreateMultipartUploadCommand,
  S3Client,
  UploadPartCommand,
} from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";

const s3 = new S3Client({ region: "ap-northeast-1" });

const UPLOAD_EXPIRES_IN = 3600; // 1 時間有効
const MAX_PART_SIZE = 5 * 1024 * 1024; // 5 MiB

async function prepareMultipartUpload(
  bucket: string,
  key: string,
  contentType: string,
  fileSize: number
): Promise<{
  uploadId: string;
  signedParts: {
    url: string;
    partNumber: number;
    range: [number, number];
  }[];
}> {
  const createCommand = new CreateMultipartUploadCommand({
    Bucket: bucket,
    Key: key,
    // オブジェクトの追加のメタデータがある場合、ここに追加する(Content-Disposition や Cache-Control など)
    ContentType: contentType,
  });
  const createResponse = await s3.send(createCommand);
  // TODO: 必要に応じてエラーハンドリングやリトライをする
  const uploadId = createResponse.UploadId!;

  const partCount = Math.ceil(fileSize / MAX_PART_SIZE);
  const signedParts = await Array.fromAsync(
    { length: partCount },
    async (_, index) => {
      const begin = index * MAX_PART_SIZE;
      const end = Math.min((index + 1) * MAX_PART_SIZE, fileSize);
      const command = new UploadPartCommand({
        Bucket: bucket,
        Key: key,
        UploadId: uploadId,
        PartNumber: index + 1,
        ContentLength: end - begin, // クライアントによるサイズ変更を防止する
      });
      return {
        url: await getSignedUrl(s3, command, {
          expiresIn: UPLOAD_EXPIRES_IN,
        }),
        partNumber: index + 1,
        // クライアントは blob.slice(range[0], range[1]) で切り出す
        range: [begin, end] satisfies [number, number],
      };
    }
  );

  return { uploadId, signedParts };
}

クライアント側でのアップロード(3-4)

クライアント(例: ブラウザ)は、提供された URL を使ってパートをアップロードします。

async function uploadFile(
  file: Blob,
  signedUrls: readonly {
    url: string;
    partNumber: number;
    range: readonly [number, number];
  }[]
): Promise<{ ETag: string; PartNumber: number }[]> {
  // TODO: 必要に応じて並列数を制限する
  const uploadedParts = await Array.fromAsync(
    signedUrls,
    async ({ url, partNumber, range }) => {
      const part = file.slice(range[0], range[1]);
      const response = await fetch(url, {
        method: "PUT",
        body: part,
        headers: {
          "Content-Length": String(part.size),
          "Content-Type": "application/octet-stream",
        },
      });
      // TODO: 必要に応じてエラーハンドリングやリトライをする
      if (!response.ok) {
        throw new Error(
          `Failed to upload part ${partNumber}: ${response.status} ${response.statusText}`
        );
      }
      return { ETag: response.headers.get("ETag")!, PartNumber: partNumber };
    }
  );

  return uploadedParts;
}

完了処理(5)

ユーザーがアップロードした後にサーバー側で完了処理を行います。

import { CompleteMultipartUploadCommand } from "@aws-sdk/client-s3";

async function completeMultipartUpload(
  bucket: string,
  key: string,
  uploadId: string,
  parts: readonly { ETag: string; PartNumber: number }[]
): Promise<void> {
  const command = new CompleteMultipartUploadCommand({
    Bucket: bucket,
    Key: key,
    UploadId: uploadId,
    MultipartUpload: { Parts: [...parts] },
  });

  await s3.send(command);
}

この方法なら、完了処理が終わると同じ UploadId は使えなくなるので、ユーザーのアップロードを確実に「1回だけ」に制限できます。

今回のサンプルコードは、以下のようなコードで動作確認しています(Node.js 22)
import fsp from "node:fs/promises";
import { uploadFile } from "./client.ts";
import { completeMultipartUpload, prepareMultipartUpload } from "./server.ts";

const file = new Blob([await fsp.readFile(FILENAME)]);

const { signedParts, uploadId } = await prepareMultipartUpload(
  BUCKET_NAME,
  FILENAME,
  CONTENT_TYPE,
  file.size
);

const uploadedParts = await uploadFile(file, signedParts);

await completeMultipartUpload(BUCKET_NAME, FILENAME, uploadId, uploadedParts);

// 完了後に再度アップロードしようとすると 404 エラーになる
await uploadFile(file, signedParts.slice(0, 1));

マルチパートアップロードの注意点

不完全なマルチパートアップロード(開始されても完了処理されていないもの)はストレージ使用量として課金され続けます。バケットのライフサイクル設定で一定時間後に自動的に削除されるよう設定しておきましょう。

また、サービスプロバイダによって仔細は異なりますが、パートのサイズや個数に制限がある場合があるため、実装時は公式のドキュメントを参照してください。例えば Amazon S3 の場合、各パートのサイズは(最後以外)5 MiB 以上である必要があります。

Discussion