🗃️

Next.js App RouterでS3ファイルをアップロード・取得・削除する

2024/11/19に公開

前提

この記事では、以下の技術を使ったS3ファイルの操作方法を記述していきます。

  • Next.js App Router (v14.2.10)
  • Amazon S3
  • AWS SDK for JavaScript v3
  • AWS IAMユーザー(SDK認証用)

なお、この記事ではローカルで挙動確認することを目的としているため、簡易的にIAMユーザーを用いています。AWS上で恒久的なシステムとして公開する場合は、セキュリティ強化のため各サービスのIAMロールやCognitoの利用をご検討ください。

事前準備

事前準備として、S3バケットを作成し、IAMユーザにS3アクションを許可するように設定します。
手順の内容はすべてAWSコンソール上で操作します。

S3

まずはS3バケットを作成します。

バケット作成

  1. バケットを作成を押下
  2. バケット名に任意の値を入力
  3. 他はデフォルトのまま、ページ最下部のバケットを作成を押下

IAM

続いて、IAMユーザーにS3操作を実行できるポリシーを関連付けます。

ポリシー作成

  1. 左メニューでポリシーを押下
  2. ポリシーの作成を押下
  3. 以下のとおりに設定
    • サービス:S3
    • アクション許可:GetObject, PutObject, DeleteObject
    • リソース > object
      • ARNを設定を押下
      • Resource bucket name:作成したS3のバケット名
      • Resource object name:*
      • ARNを追加を押下
  4. 次へを押下
  5. ポリシー名に任意の値を入力
  6. ポリシーの作成を押下

ここではResource object name*と設定しているため、作成したS3バケットに存在するすべてのオブジェクトに対して許可を与えている設定となります。
対象のオブジェクトを制限したい場合は、制限に応じた内容でポリシーを編集します。

ユーザー作成

  1. 左メニューでユーザーを押下
  2. ユーザーの作成を押下
  3. ユーザー名に任意の値を入力
  4. 次へを押下
  5. 以下のとおりに設定
    • 許可のオプション:ポリシーを直接アタッチする
    • 許可ポリシー:作成したポリシー名
  6. 次へを押下
  7. ユーザーの作成を押下

アクセスキー発行

IAMユーザのアクセスキーが未発行の場合のみ対応します。

  1. 左メニューでユーザーを押下
  2. 対象のユーザー名を押下
  3. セキュリティ認証情報タブ内のアクセスキーを作成を押下
  4. 以下のとおりに設定
    • ユースケース:ローカルコード
    • 上記のレコメンデーションを理解し、アクセスキーを作成します。:ON
  5. 次へを押下
  6. アクセスキーを作成を押下
  7. 画面上にアクセスキーシークレットアクセスキーが表示されるので、コピーして控えておく

Next.js

プロジェクトの作成については省略します。公式ドキュメントをご参照ください。

環境変数設定

機密情報秘匿のため、AWS関連の値を環境変数として設定します。
リポジトリ直下の.envに、以下の記述を追加します。

AWS_REGION={AWSリージョン}
S3_BUCKET_NAME={S3バケット名}
IAM_ACCESS_KEY={IAMユーザのアクセスキー}
IAM_SECRET_ACCESS_KEY={IAMユーザのシークレットアクセスキー}

ここではローカルで動かすための設定を記述していますが、デプロイ時には各サービスの環境変数に同様の設定が必要となります。

SDKライブラリ追加

以下のコマンドを実行して、AWS SDKライブラリを追加します。

npm install @aws-sdk/client-s3

または

yarn add @aws-sdk/client-s3

これで、IAMユーザーによる認証でS3操作を実行する準備が整いました。
以降は、Next.js App RouterにてS3操作を実装していきます。

ファイルをアップロードする

サーバアクション

action.ts
"use server";
import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3";

const s3Client = new S3Client({
  region: process.env.AWS_REGION,
  credentials: {
    accessKeyId: process.env.IAM_ACCESS_KEY!,
    secretAccessKey: process.env.IAM_SECRET_ACCESS_KEY!,
  },
});

export async function uploadFile(base64: string, fileName: string) {
  try {
    const command = new PutObjectCommand({
      Bucket: process.env.S3_BUCKET_NAME!,
      Key: fileName,
      Body: Buffer.from(base64.split(",")[1], "base64")
    });
    await s3Client.send(command).then(() => {
        console.log("File uploaded successfully");
    }).catch((error) => {
      console.error("Error uploading file:", error);
      throw error;
    });
    return;
  } catch (error) {
    console.error("Error uploading file:", error);
    throw error;
  }
}

まず最初にAWSリージョンとIAMユーザの認証情報を指定し、S3Clientを生成しています。

サーバアクションはシリアライズ可能な値のみを送受できるため、アップロードするファイルはbase64の文字列として受け取ります。S3へのコマンド送信のため、さらにBufferに変換しています。
ファイルはS3バケット直下に、引数として渡されたファイル名でアップロードされます。

コンポーネント

UploadInput.tsx
"use client";
import { uploadFile } from "./action";

function convertFileToBase64(file: File): Promise<string> {
  return new Promise((resolve, reject) => {
    const reader = new FileReader();
    reader.readAsDataURL(file);
    reader.onload = () => resolve(reader.result as string);
    reader.onerror = (error) => reject(error);
  });
}

async function handleUpload(event: React.ChangeEvent<HTMLInputElement>) {
  try {
    const file = event.target.files?.[0];
    if (file) {
      const base64 = await convertFileToBase64(file);
      await uploadFile(base64, file.name);
    }
  } catch (error) {
    console.error("Error uploading file:", error);
  }
}

export default function UploadInput() {
  return <input type="file" onChange={handleUpload} />;
}

フィールドにてファイルを選択した時点で、アップロード処理が動きます。
Fileをbase64形式のstringに変換したうえで、サーバアクションに送信します。
実行後にサーバでFile uploaded successfullyのログが出たら、アップロード成功です。

ファイルを取得する

サーバアクション

action.ts
"use server";
import { GetObjectCommand, S3Client } from "@aws-sdk/client-s3";
import { Readable } from "stream";

const s3Client = new S3Client({
  region: process.env.AWS_REGION,
  credentials: {
    accessKeyId: process.env.IAM_ACCESS_KEY!,
    secretAccessKey: process.env.IAM_SECRET_ACCESS_KEY!,
  },
});

export async function getFile(): Promise<Buffer> {
  try {
    const command = new GetObjectCommand({
      Bucket: process.env.S3_BUCKET_NAME,
      Key: "test.txt",
    });
    const response = await s3Client.send(command);

    const stream = response.Body as Readable;
    const chunks: Buffer[] = [];

    return new Promise((resolve, reject) => {
      stream.on("data", (chunk) => {
        chunks.push(chunk);
      });
      stream.on("end", () => {
        const buffer = Buffer.concat(chunks);
        resolve(buffer);
      });
      stream.on("error", (error) => {
        reject(`An error occurred: ${error.message}`);
      });
    });
  } catch (error) {
    console.error("Error getting file:", error);
    throw error;
  }
}

アップロードと同じようにKeyへのパスを指定してS3コマンドを実行しますが、今回はtest.txtで固定としています。
S3からのレスポンスをReadableに型変換し、すべてのチャンクを結合してからBufferとしてクライアントに返却します。

コンポーネント

GetButton.tsx
"use client";
import { getFile } from "./action";

async function handleGetButton() {
  const buffer = Buffer.from(await getFile());
  const fileContent = buffer.toString("utf-8");
  const blob = new Blob([fileContent], {
    type: "text/plain",
  });

  const url = window.URL.createObjectURL(blob);
  const a = document.createElement("a");
  a.href = url;
  a.download = "test.txt";
  document.body.appendChild(a);
  a.click();
  document.body.removeChild(a);
  window.URL.revokeObjectURL(url);
}

export default function GetFileButton() {
  return <button onClick={handleGetButton}>ファイル取得</button>;
}

ボタンが押下されると、イベントハンドラからサーバアクションを実行します。
今回はUTF-8への文字コード変換後、Blobを作成して自動でダウンロードさせています。

テキストファイル以外を取得する場合

例としてtest.pngの変換処理のうち、test.txtと変更がある箇所のみ記載します。

サーバアクションでは、S3バケットのファイルパス(Key)を変更します。

action.ts
    const command = new GetObjectCommand({
      Bucket: process.env.S3_BUCKET_NAME,
      Key: "test.png",
    });

コンポーネントでは、MIMEタイプとダウンロードファイル名を変更します。
また、文字コード変換処理は削除します。

GetButton.tsx
  const buffer = Buffer.from(await getFile());
  const blob = new Blob([new Uint8Array(buffer)], {
    type: "image/png",
  });
  // ...中略...
  a.download = "test.png";

ファイルを削除する

サーバアクション

action.ts
"use server";
import { DeleteObjectCommand, S3Client } from "@aws-sdk/client-s3";

const s3Client = new S3Client({
  region: process.env.AWS_REGION!,
  credentials: {
    accessKeyId: process.env.IAM_ACCESS_KEY!,
    secretAccessKey: process.env.IAM_SECRET_ACCESS_KEY!,
  },
});

export async function deleteFile() {
  try {
    const command = new DeleteObjectCommand({
      Bucket: process.env.S3_BUCKET_NAME!,
      Key: "test.txt",
    });
    const response = await s3Client.send(command);
    console.log("File deleted successfully");
    return response;
  } catch (error) {
    console.error("Error deleting file:", error);
    throw error;
  }
}

こちらもKeytest.txtで固定としています。

コンポーネント

DeleteButton.tsx
"use client";
import { deleteFile } from "./action";

async function handleDelete () {
  await deleteFile();
};

export default function DeleteButton() {
  return <button onClick={handleDelete}>ファイル削除</button>;
}

ボタンが押下されると、イベントハンドラからサーバアクションを実行します。
実行後にサーバでFile deleted successfullyのログが出たら、削除成功です。

NCDCエンジニアブログ

Discussion