CDK(Cloud Development Kit)でファイルアップローダーを作ってみた。アップロードされた画像・動画・音声・PDF・テキスト(html)は、別タブで表示可能(ブラウザでそのまま表示できる)
ブラウザ ⇔ index.html@S3 ⇔ API Gateway ⇔ lambda(POST,GET) ⇔ アップロードされたファイル@S3
アップロードされた画像・動画・音声・PDF・テキスト(html)は、別タブで表示可能(ブラウザでそのまま表示できる)
エクセルファイルなど、ブラウザで表示できない場合はダウンロードされます。
|
1 2 3 4 5 6 7 8 9 |
mkdir mini-cdk-uploader && cd mini-cdk-uploader cdk init app --language typescript npm i aws-cdk-lib constructs mkdir -p src/app touch src/app/handler.py mkdir -p frontend touch frontend/index.html |
インフラ生成、
mini-cdk-uploader-stack.ts
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 |
// AWS CDKライブラリから必要なクラスをインポート import { Stack, StackProps, Duration, CfnOutput } from "aws-cdk-lib"; // Construct(CDKの基本構成要素)をインポート import { Construct } from "constructs"; // S3関連のCDKモジュールをインポート import * as s3 from "aws-cdk-lib/aws-s3"; // S3へ静的ファイルをデプロイするためのモジュールをインポート import * as s3deploy from "aws-cdk-lib/aws-s3-deployment"; // Lambda関数を作成するためのモジュールをインポート import * as lambda from "aws-cdk-lib/aws-lambda"; // API Gatewayを作成するためのモジュールをインポート import * as apigw from "aws-cdk-lib/aws-apigateway"; // IAM(権限設定)を扱うモジュールをインポート import * as iam from "aws-cdk-lib/aws-iam"; // パス操作用のNode.js標準モジュール import * as path from "path"; // mini-cdk-uploaderのスタック定義クラス export class MiniCdkUploaderStack extends Stack { constructor(scope: Construct, id: string, props?: StackProps) { super(scope, id, props); // ===================================== // 1. アップロード先のS3バケット(非公開) // ===================================== const uploadBucket = new s3.Bucket(this, "UploadBucket", { blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL, // 外部公開をブロック encryption: s3.BucketEncryption.S3_MANAGED, // S3管理の暗号化を有効化 enforceSSL: true, // HTTPS接続を強制 // ブラウザから直接PUTできるようにCORSを設定 cors: [ { allowedMethods: [ s3.HttpMethods.GET, // GET許可(ダウンロード) s3.HttpMethods.PUT, // PUT許可(アップロード) s3.HttpMethods.HEAD, // HEAD許可(プリフライト対策) ], allowedOrigins: ["*"], // どのオリジンからも許可(デモ用) allowedHeaders: ["*"], // 任意のヘッダーを許可 exposedHeaders: ["ETag"], // レスポンスで公開するヘッダー maxAge: 3600, // プリフライトキャッシュ時間(秒) }, ], }); // ===================================== // 2. フロントエンド用のS3バケット(静的サイトホスティング) // ===================================== const siteBucket = new s3.Bucket(this, "SiteBucket", { websiteIndexDocument: "index.html", // サイトのルートドキュメント publicReadAccess: true, // 誰でも閲覧可(デモ用途) // パブリックアクセスを許可するための設定(OAIなし構成) blockPublicAccess: new s3.BlockPublicAccess({ blockPublicAcls: false, blockPublicPolicy: false, ignorePublicAcls: false, restrictPublicBuckets: false, }), }); // ===================================== // 3. Lambda関数(API用バックエンド) // ===================================== const handler = new lambda.Function(this, "ApiHandler", { runtime: lambda.Runtime.PYTHON_3_12, // 実行環境(Python 3.12) handler: "handler.handler", // エントリーポイント code: lambda.Code.fromAsset(path.join(__dirname, "../src/app")), // ソースコードのパス timeout: Duration.seconds(10), // タイムアウト設定 environment: { UPLOAD_BUCKET: uploadBucket.bucketName, // 環境変数(バケット名) URL_EXPIRES: "3600", // 署名付きURLの有効期限(秒) }, }); // LambdaにS3アクセス権限を付与(PUT/GET/LISTなど) uploadBucket.grantPut(handler); uploadBucket.grantRead(handler); uploadBucket.grantReadWrite(handler); // list含む広めの権限 // ===================================== // 4. API Gateway(LambdaをHTTPで呼び出す) // ===================================== const api = new apigw.RestApi(this, "UploaderApi", { restApiName: "mini-cdk-uploader-api", // APIの表示名 defaultCorsPreflightOptions: { allowOrigins: apigw.Cors.ALL_ORIGINS, // すべてのオリジンからのCORS許可(デモ用) allowHeaders: apigw.Cors.DEFAULT_HEADERS, // 標準ヘッダー許可 allowMethods: ["GET", "POST", "OPTIONS"], // 利用可能なHTTPメソッド }, deployOptions: { stageName: "prod", // ステージ名 }, }); // /presign エンドポイント:ファイルアップロード用プリサインURLを返す const presign = api.root.addResource("presign"); presign.addMethod("POST", new apigw.LambdaIntegration(handler)); // /list エンドポイント:アップロード済みファイル一覧を返す const list = api.root.addResource("list"); list.addMethod("GET", new apigw.LambdaIntegration(handler)); // ===================================== // 5. フロントエンド(index.htmlなど)をS3へデプロイ // ===================================== new s3deploy.BucketDeployment(this, "DeployFrontend", { destinationBucket: siteBucket, // デプロイ先 sources: [s3deploy.Source.asset(path.join(__dirname, "../frontend"))], // ローカルのfrontendディレクトリ }); // ===================================== // 6. スタック出力(デプロイ後に確認しやすく) // ===================================== new CfnOutput(this, "WebsiteURL", { value: siteBucket.bucketWebsiteUrl }); // フロントエンドURL new CfnOutput(this, "ApiBaseURL", { value: api.url }); // APIのエンドポイントURL new CfnOutput(this, "UploadBucketName", { value: uploadBucket.bucketName }); // バケット名 } } |
S3へのアップロード & アップロード済みファイル一覧取得
src/app/handler.py
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 |
# -*- coding: utf-8 -*- # 文字コードをUTF-8に指定 import json # JSON形式の変換に使用 import os # 環境変数の取得などに使用 import boto3 # AWS SDK for Python(S3などにアクセス) from botocore.exceptions import ClientError # AWSクライアントエラーを扱うための例外 from botocore.config import Config # boto3の設定用 # ★ 仮想ホスト形式を強制(リダイレクトを防ぐ) s3 = boto3.client('s3', config=Config(s3={'addressing_style': 'virtual'})) # S3クライアントを作成し、仮想ホスト形式を指定 BUCKET = os.environ['UPLOAD_BUCKET'] # 環境変数からS3バケット名を取得 EXPIRES = int(os.environ.get('URL_EXPIRES', '3600')) # 署名付きURLの有効期限(デフォルト3600秒) # CORS(クロスオリジン)対応ヘッダーを定義 CORS_HEADERS = { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': 'Content-Type', 'Access-Control-Allow-Methods': 'GET,POST,OPTIONS', } # 共通レスポンス関数(ステータスコード・ボディ・CORSヘッダーをまとめて返す) def _response(status: int, body: dict): return { 'statusCode': status, 'headers': CORS_HEADERS, 'body': json.dumps(body, ensure_ascii=False), # 日本語も含めてJSON文字列化 } # メインのLambdaハンドラー関数 def handler(event, context): method = event.get('httpMethod') # 呼び出されたHTTPメソッドを取得 path = event.get('path', '') # リクエストパスを取得 # プリフライトリクエスト(OPTIONS)への対応 if method == 'OPTIONS': return _response(200, {'ok': True}) try: # ------------------------ # POST /presign : アップロード用プリサインURLを生成 # ------------------------ if method == 'POST' and path.endswith('/presign'): body = json.loads(event.get('body') or '{}') # リクエストボディをJSONとして読み取る filename = body.get('filename') # アップロードするファイル名 content_type = body.get('contentType', 'application/octet-stream') # Content-Typeを取得(省略時はバイナリ) if not filename: return _response(400, {'message': 'filename は必須です'}) # ファイル名が無い場合はエラー # S3へのPUT用署名付きURLを生成 put_url = s3.generate_presigned_url( 'put_object', Params={'Bucket': BUCKET, 'Key': filename, 'ContentType': content_type}, ExpiresIn=EXPIRES, ) # URLを返す return _response(200, {'url': put_url, 'key': filename}) # ------------------------ # GET /list : バケット内ファイル一覧+表示&ダウンロードURL生成 # ------------------------ if method == 'GET' and path.endswith('/list'): resp = s3.list_objects_v2(Bucket=BUCKET) # バケット内オブジェクト一覧を取得 items = [] # ファイル情報を格納するリスト for obj in (resp.get('Contents') or []): key = obj['Key'] # ファイル名(キー)を取得 # Content-Type 取得(プレビューに利用) ct = 'application/octet-stream' try: head = s3.head_object(Bucket=BUCKET, Key=key) # ファイルのメタ情報を取得 ct = head.get('ContentType') or ct # Content-Typeが存在すれば上書き except Exception: pass # 失敗しても無視(既定値を使用) # ① 表示用URL(ブラウザで開く用) view_url = s3.generate_presigned_url( 'get_object', Params={ 'Bucket': BUCKET, 'Key': key, 'ResponseContentDisposition': 'inline', # ブラウザ内で開く設定 'ResponseContentType': ct, # Content-Typeを明示 }, ExpiresIn=EXPIRES, ) # ② ダウンロード用URL(強制保存) filename = os.path.basename(key) # ファイル名部分だけ抽出 download_url = s3.generate_presigned_url( 'get_object', Params={ 'Bucket': BUCKET, 'Key': key, 'ResponseContentDisposition': f'attachment; filename="{filename}"', # 保存ダイアログを出す 'ResponseContentType': ct, }, ExpiresIn=EXPIRES, ) # ファイル情報をリストに追加 items.append({ 'key': key, # S3キー 'size': obj.get('Size', 0), # ファイルサイズ 'lastModified': obj.get('LastModified').isoformat() if obj.get('LastModified') else None, # 更新日時 'viewUrl': view_url, # 表示用URL 'downloadUrl': download_url, # ダウンロード用URL 'contentType': ct, # MIMEタイプ }) # 一覧を返す return _response(200, {'items': items}) # 上記以外のパスは404エラー return _response(404, {'message': 'Not Found'}) # ------------------------ # 例外処理 # ------------------------ except ClientError as e: # AWS SDKのエラー(S3アクセス失敗など) return _response(500, {'message': 'S3エラー', 'detail': str(e)}) except Exception as e: # その他のサーバ側エラー return _response(500, {'message': 'サーバエラー', 'detail': str(e)}) |
frontend/index.html
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 |
<!doctype html> <html lang="ja"> <head> <meta charset="utf-8" /> <!-- ページの文字コードをUTF-8に設定 --> <meta name="viewport" content="width=device-width, initial-scale=1" /> <!-- モバイル端末対応 --> <title>mini-cdk-uploader</title> <!-- ページタイトル --> <style> /* 全体レイアウトと見た目設定 */ body { font-family: system-ui, sans-serif; max-width: 720px; margin: 2rem auto; padding: 0 1rem; } header { margin-bottom: 1rem; } .box { border: 1px solid #ddd; border-radius: 8px; padding: 1rem; margin-bottom: 1rem; } .row { display: flex; gap: .5rem; align-items: center; } .list { margin-top: .5rem; } .item { display: flex; justify-content: space-between; align-items: center; padding: .5rem 0; border-bottom: 1px solid #eee; } .muted { color: #666; font-size: .9em; } code { background: #f6f8fa; padding: .2rem .4rem; border-radius: 4px; } button { cursor: pointer; } </style> </head> <body> <!-- ヘッダー部 --> <header> <h1>mini-cdk-uploader</h1> <p>CDKで最小構成の「アップロード+一覧+ダウンロード」Webアプリ。</p> <p class="muted">※ デプロイ後に <code>API_BASE_URL</code> を下のスクリプト内で置換してください(CDK出力参照)。</p> </header> <!-- アップロードセクション --> <section class="box"> <h2>1) アップロード</h2> <div class="row"> <input type="file" id="file" /> <!-- ファイル選択ボタン --> <button id="btn-upload">アップロード</button> <!-- アップロード開始ボタン --> </div> <p id="status" class="muted"></p> <!-- ステータスメッセージ表示 --> </section> <!-- 一覧&ダウンロードセクション --> <section class="box"> <h2>2) 一覧&ダウンロード</h2> <button id="btn-reload">再読込</button> <!-- 一覧再取得ボタン --> <div id="list" class="list"></div> <!-- ファイル一覧表示領域 --> </section> <script> // ▼▼▼ デプロイ後のAPI GatewayのURLをここに指定 const API_BASE_URL = 'https://xxxxxx.execute-api.ap-northeast-1.amazonaws.com/prod/'; // DOM取得のショートカット関数 const $ = (sel) => document.querySelector(sel); // presign URLを取得し、S3にアップロードする関数 async function presignAndUpload(file) { // Lambda(API Gateway経由)にリクエストを送りpresigned URLを取得 const res = await fetch(`${API_BASE_URL}presign`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ filename: file.name, contentType: file.type || 'application/octet-stream' }) }); if (!res.ok) throw new Error('presign失敗'); // 失敗時 const data = await res.json(); // 取得したURLにPUTでファイルアップロード const put = await fetch(data.url, { method: 'PUT', mode: 'cors', // CORSを明示的に許可 credentials: 'omit', // cookieなどの資格情報を送信しない headers: { 'Content-Type': file.type || 'application/octet-stream' }, body: file, // アップロードするファイル }); if (!put.ok) throw new Error('アップロード失敗'); // アップロード失敗時 } // バケット内のファイル一覧を取得して表示する関数 async function loadList() { const list = $('#list'); list.textContent = '読込中...'; const res = await fetch(`${API_BASE_URL}list`); // Lambda経由で一覧取得 if (!res.ok) { list.textContent = '一覧取得に失敗しました'; return; } const data = await res.json(); const items = data.items || []; if (!items.length) { list.textContent = 'ファイルはまだありません'; return; } const frag = document.createDocumentFragment(); // DOMフラグメント作成(高速に構築) // 各ファイルを行として描画 items.forEach(it => { const row = document.createElement('div'); row.className = 'item'; const left = document.createElement('div'); // ファイル名、サイズ、更新日、Content-Type表示 left.innerHTML = `<strong>${it.key}</strong><br><span class="muted">${(it.size||0)} bytes / ${it.lastModified||''} / ${it.contentType||''}</span>`; const right = document.createElement('div'); // 表示リンク(別タブで開く) const view = document.createElement('a'); view.href = it.viewUrl; view.textContent = '表示'; view.target = '_blank'; // 新しいタブで開く view.rel = 'noopener'; // セキュリティ対策 // 区切り文字 const sep = document.createTextNode(' / '); // ダウンロードリンク(attachment設定済み) const dl = document.createElement('a'); dl.href = it.downloadUrl; dl.textContent = 'ダウンロード'; // 要素を組み立て right.appendChild(view); right.appendChild(sep); right.appendChild(dl); row.appendChild(left); row.appendChild(right); frag.appendChild(row); }); // 一覧を更新 list.innerHTML = ''; list.appendChild(frag); } // アップロードボタン押下時の処理 $('#btn-upload').addEventListener('click', async () => { const file = $('#file').files[0]; // ファイルを取得 if (!file) { $('#status').textContent = 'ファイルを選択してください'; return; } $('#status').textContent = 'アップロード中...'; try { await presignAndUpload(file); // アップロード処理実行 $('#status').textContent = 'アップロード完了!'; await loadList(); // アップロード後に一覧更新 } catch (e) { console.error(e); $('#status').textContent = 'エラー: ' + e.message; // エラー表示 } }); // 再読込ボタン押下時の処理 $('#btn-reload').addEventListener('click', loadList); // ページ読み込み時に自動で一覧を取得(API未設定時は警告) (async()=>{try{await loadList();}catch(e){console.warn('API未設定');}})(); </script> </body> </html> |