背景
S3のプライベートバケットにあるファイル(主にPDFや画像)を手軽に確認できる機能が必要になった。最初はS3バケットのファイルを一覧表示するだけのシンプルな内容だったけど、ファイル名だけじゃ内容が分からないから「中身をサクッと確認したい」という声が上がった。そこで、ファイル名横に「表示」ボタンを付けて、クリックするとポップアップでファイル内容をプレビューできるビューワー機能を実装した。S3の署名付きURL(Presigned URL)を使って、セキュリティを保ちつつ簡単に実装できたので、その過程を忘備録としてまとめる。
やりたかったこと
- 目的: S3バケットのファイル一覧を表示し、ボタンをクリックでポップアップ表示するビューワーを作る。
-
要件:
- バケットはプライベート設定で、外部公開しない。
- ファイル(PDFや画像)を安全かつ一時的にプレビュー。
- シンプルなUIで、チームの誰でも簡単に使える。
- なぜ署名付きURL?: バケットを公開せずに、特定ファイルに一時的なアクセス権を付与できる。ポップアップで表示するのにピッタリだった。
実装の流れ
バックエンドはPython(FastAPI+boto3)、フロントエンドはTypeScript+Reactで実装。署名付きURLを使って、ファイルのプレビューURLを動的に生成し、ポップアップで表示する仕組みにした。
1. バックエンド: 署名付きURLの生成
FastAPIでAPIを作り、S3のファイル一覧取得と署名付きURL生成。
from fastapi import FastAPI, HTTPException
import boto3
from botocore.exceptions import ClientError
from typing import List, Dict
app = FastAPI()
# クレデンシャルを明示的に指定
session = boto3.Session(profile_name='my-profile') # AWS CLIで設定したプロファイル
s3 = session.client('s3')
# ファイル一覧を取得
@app.get("/files", response_model=List[Dict[str, str]])
async def list_files():
try:
response = s3.list_objects_v2(Bucket='my-project-bucket', Prefix='reports/')
files = [
{'key': obj['Key'], 'last_modified': obj['LastModified'].isoformat()}
for obj in response.get('Contents', [])
]
return files
except ClientError as e:
raise HTTPException(status_code=500, detail=str(e))
# 署名付きURLを生成
@app.get("/presigned-url/{key:path}")
async def get_presigned_url(key: str):
try:
url = s3.generate_presigned_url(
ClientMethod='get_object',
Params={'Bucket': 'my-project-bucket', 'Key': key},
ExpiresIn=3600 # 1時間有効
)
return {'url': url}
except ClientError as e:
raise HTTPException(status_code=500, detail=str(e))
generate_presigned_urlのパラメータの詳細
-
ClientMethod(必須):- S3の操作を指定。主な値:
-
'get_object': ファイルのダウンロード。 -
'put_object': ファイルのアップロード。 -
'delete_object': ファイルの削除。
-
- 他にも
head_object(メタデータ取得)など、S3のAPIに対応したメソッドを指定可能。
- S3の操作を指定。主な値:
-
Params(必須):- S3操作に必要なパラメータを辞書形式で指定。
- 必須キー:
-
Bucket: 対象のS3バケット名。 -
Key: 対象のオブジェクトキー(ファイルのパス)。
-
- 任意キー(例):
-
ContentType: アップロード時のファイル形式(例:'image/jpeg')。 -
Metadata: アップロード時に付与するメタデータ。 -
ResponseContentDisposition: ダウンロード時のファイル名指定(例:'attachment; filename="download.pdf"')。
-
-
ExpiresIn(必須):- URLの有効期限(秒単位)。最大は7日(604800秒)だが、セキュリティのため短めに設定推奨(例:数分~数時間)。
-
HttpMethod(任意):- HTTPメソッドを明示的に指定(例:
'GET'、'PUT')。通常はClientMethodから自動推論されるので省略可。
- HTTPメソッドを明示的に指定(例:
2. フロントエンド: ファイル一覧とポップアップビューワー(TypeScript+React)
TypeScript+Reactで、ファイル一覧を表示し、「表示」ボタンでモーダルを表示。PDFは<iframe>、画像は<img>でプレビュー。型安全なコードで、バグを減らせた。
import React, { useState, useEffect } from 'react';
import axios from 'axios';
import Modal from 'react-modal';
Modal.setAppElement('#root');
interface File {
key: string;
last_modified: string;
}
const FileList: React.FC = () => {
const [files, setFiles] = useState<File[]>([]);
const [modalIsOpen, setModalIsOpen] = useState(false);
const [fileUrl, setFileUrl] = useState('');
const [fileType, setFileType] = useState<'pdf' | 'image' | ''>('');
// ファイル一覧を取得
useEffect(() => {
const fetchFiles = async () => {
try {
const response = await axios.get<File[]>('/files');
setFiles(response.data);
} catch (error) {
console.error('ファイル一覧取得エラー:', error);
}
};
fetchFiles();
}, []);
// 署名付きURLを取得してモーダル表示
const openFile = async (key: string) => {
try {
const response = await axios.get<{ url: string }>(`/presigned-url/${key}`);
setFileUrl(response.data.url);
setFileType(key.endsWith('.pdf') ? 'pdf' : 'image');
setModalIsOpen(true);
} catch (error) {
console.error('署名付きURL取得エラー:', error);
}
};
return (
<div>
<h2>S3ファイル一覧</h2>
<ul>
{files.map(file => (
<li key={file.key}>
{file.key} ({file.last_modified})
<button onClick={() => openFile(file.key)}>表示</button>
</li>
))}
</ul>
<Modal
isOpen={modalIsOpen}
onRequestClose={() => setModalIsOpen(false)}
style={{ content: { width: '80%', height: '80%', margin: 'auto' } }}
>
{fileType === 'pdf' ? (
<iframe src={fileUrl} width="100%" height="100%" title="File Preview" />
) : (
<img src={fileUrl} alt="File Preview" style={{ maxWidth: '100%' }} />
)}
<button onClick={() => setModalIsOpen(false)}>閉じる</button>
</Modal>
</div>
);
};
export default FileList;
使ったライブラリ
-
axios: APIリクエスト。TypeScriptの型推論と相性が良い。 -
react-modal: ポップアップ表示。シンプルでカスタマイズしやすい。 -
@types/react-modal: TypeScript用の型定義で、型エラーを防げた。
ハマったポイントと対策
-
クレデンシャルの設定
最初、boto3がデフォルトのクレデンシャルを使ってくれなくて「NoCredentialsError」でハマった。ローカル開発環境ではAWS CLIのプロファイルを明示的に指定する必要があった。boto3.Session(profile_name='my-profile')でプロファイルを指定したら解決。AWS CLIでaws configure --profile my-profileしてアクセスキーとシークレットキーを設定しておくのが大事。デプロイ環境ではIAMロールを使ったから、この問題はローカルだけで済んだ。 -
IAM権限
s3:ListBucketとs3:GetObjectの権限がIAMロールに付いてなくて「Access Denied」エラー。IAMポリシーを確認して以下を追加したら解決:{ "Effect": "Allow", "Action": ["s3:ListBucket", "s3:GetObject"], "Resource": [ "arn:aws:s3:::my-project-bucket", "arn:aws:s3:::my-project-bucket/*" ] } -
パスエンコーディング
ファイルキーにスラッシュ(/)が含まれる場合、FastAPIの{key:path}で上手く処理できたけど、最初はURLエンコーディングのミスで404エラー。FastAPIのpathパラメータが助けてくれた。 -
ファイルタイプの型
TypeScriptでfileTypeを'pdf' | 'image' | ''で型付けしたが、初期値の空文字列が扱いづらかった。nullのほうが良かったかも。 -
署名付きURLの有効期限
1時間(3600秒)に設定したが、プレビューなら5~10分でも十分かも。チームの使い方を見て調整予定。
まとめ
S3の署名付きURLを使って、プライベートバケットのファイルをポップアップでプレビューするビューワーをPython+FastAPIとTypeScript+Reactで作った。ファイル一覧からワンクリックで中身が見られるのは、ユーザーの作業効率をめっちゃ上げてくれた。クレデンシャルの設定やIAM権限でハマったけど、boto3.Session(profile_name='my-profile')でローカルの認証問題を解決できたのは大きな学び。