@exabugs/dynamodb-client 1.5.2 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/dist/server/handler.cjs +10855 -6862
  3. package/dist/server/handler.cjs.map +4 -4
  4. package/dist/server/media/magicBytes.d.ts +25 -0
  5. package/dist/server/media/magicBytes.d.ts.map +1 -0
  6. package/dist/server/media/magicBytes.js +53 -0
  7. package/dist/server/media/magicBytes.js.map +1 -0
  8. package/dist/server/media/media-handler.d.ts +13 -0
  9. package/dist/server/media/media-handler.d.ts.map +1 -0
  10. package/dist/server/media/media-handler.js +177 -0
  11. package/dist/server/media/media-handler.js.map +1 -0
  12. package/dist/server/media/metadata.d.ts +29 -0
  13. package/dist/server/media/metadata.d.ts.map +1 -0
  14. package/dist/server/media/metadata.js +57 -0
  15. package/dist/server/media/metadata.js.map +1 -0
  16. package/dist/server/media/presign.d.ts +9 -0
  17. package/dist/server/media/presign.d.ts.map +1 -0
  18. package/dist/server/media/presign.js +90 -0
  19. package/dist/server/media/presign.js.map +1 -0
  20. package/dist/server/media/process-handler-dlq.d.ts +3 -0
  21. package/dist/server/media/process-handler-dlq.d.ts.map +1 -0
  22. package/dist/server/media/process-handler-dlq.js +94 -0
  23. package/dist/server/media/process-handler-dlq.js.map +1 -0
  24. package/dist/server/media/process-handler.d.ts +12 -0
  25. package/dist/server/media/process-handler.d.ts.map +1 -0
  26. package/dist/server/media/process-handler.js +153 -0
  27. package/dist/server/media/process-handler.js.map +1 -0
  28. package/dist/server/media/sign.d.ts +11 -0
  29. package/dist/server/media/sign.d.ts.map +1 -0
  30. package/dist/server/media/sign.js +49 -0
  31. package/dist/server/media/sign.js.map +1 -0
  32. package/dist/server/media/ssmParams.d.ts +29 -0
  33. package/dist/server/media/ssmParams.d.ts.map +1 -0
  34. package/dist/server/media/ssmParams.js +74 -0
  35. package/dist/server/media/ssmParams.js.map +1 -0
  36. package/dist/server/media/types.d.ts +109 -0
  37. package/dist/server/media/types.d.ts.map +1 -0
  38. package/dist/server/media/types.js +2 -0
  39. package/dist/server/media/types.js.map +1 -0
  40. package/dist/server/media/upload-handler.d.ts +50 -0
  41. package/dist/server/media/upload-handler.d.ts.map +1 -0
  42. package/dist/server/media/upload-handler.js +115 -0
  43. package/dist/server/media/upload-handler.js.map +1 -0
  44. package/dist/server/media-handler.cjs +57474 -0
  45. package/dist/server/media-handler.cjs.map +7 -0
  46. package/dist/server/media-process-handler-dlq.cjs +48138 -0
  47. package/dist/server/media-process-handler-dlq.cjs.map +7 -0
  48. package/dist/server/media-process-handler.cjs +60680 -0
  49. package/dist/server/media-process-handler.cjs.map +7 -0
  50. package/dist/sharp-layer.zip +0 -0
  51. package/package.json +38 -3
  52. package/terraform/main.tf +3 -2
  53. package/terraform/media/README.md +67 -0
  54. package/terraform/media/main.tf +791 -0
  55. package/terraform/media/outputs.tf +66 -0
  56. package/terraform/media/variables.tf +117 -0
  57. package/terraform/modules/parameter-store/main.tf +26 -1
  58. package/terraform/modules/parameter-store/variables.tf +7 -1
@@ -0,0 +1,791 @@
1
+ # メディア(ファイルアップロード/処理/配信)機能モジュール
2
+ #
3
+ # 詳細設計: ../../docs/media-design.md
4
+ # 決定事項・トレードオフ: ../../docs/adr/0002-media-design.md
5
+ #
6
+ # 本モジュールは upload-handler(presign/sign発行)のLambdaリソースを含まない。
7
+ # 「誰にpresign/署名URLを発行するか」の認可判断はアプリごとに、かつ同一アプリ内でも
8
+ # クライアント(モバイル/管理画面等)ごとに異なるため、Terraformでは画一的な
9
+ # リソースとして定義できない。呼び出し側は次のいずれかの方法で自前にデプロイすること:
10
+ # (a) 既存のHTTP APIレイヤー(認証・権限チェック済み)から
11
+ # `../../src/server/media/presign.ts`・`sign.ts` を直接呼び出す
12
+ # (b) 対応する既存HTTPレイヤーが無いクライアント(例: 別の認証方式を使う管理画面)向けには
13
+ # `../../src/server/media/upload-handler.ts` の `createUploadHandler()` を使い、
14
+ # 専用の小さなLambdaとして自前でバンドル・デプロイする
15
+
16
+ locals {
17
+ name_prefix = "${var.project_name}-${var.environment}-media"
18
+ }
19
+
20
+ # ============================================================
21
+ # S3バケット(raw/master/cache の3プレフィックス、完全非公開)
22
+ # ============================================================
23
+
24
+ resource "aws_s3_bucket" "media" {
25
+ bucket = local.name_prefix
26
+
27
+ tags = {
28
+ Name = local.name_prefix
29
+ }
30
+ }
31
+
32
+ resource "aws_s3_bucket_public_access_block" "media" {
33
+ bucket = aws_s3_bucket.media.id
34
+
35
+ block_public_acls = true
36
+ block_public_policy = true
37
+ ignore_public_acls = true
38
+ restrict_public_buckets = true
39
+ }
40
+
41
+ # cache/* のみ自動削除。raw/*・master/*は恒久保存
42
+ resource "aws_s3_bucket_lifecycle_configuration" "media" {
43
+ bucket = aws_s3_bucket.media.id
44
+
45
+ rule {
46
+ id = "expire-cache"
47
+ status = "Enabled"
48
+
49
+ filter {
50
+ prefix = "cache/"
51
+ }
52
+
53
+ expiration {
54
+ days = var.image_cache_ttl_days
55
+ }
56
+ }
57
+ }
58
+
59
+ # ブラウザ(管理画面等)からpresigned POSTで直接S3へアップロードする場合に必要。
60
+ # 無いとプリフライトリクエストが失敗しアップロードが全滅する
61
+ # (モバイルアプリ(React Native)はCORSの対象外のため影響しない)。
62
+ resource "aws_s3_bucket_cors_configuration" "media" {
63
+ count = length(var.allowed_upload_origins) > 0 ? 1 : 0
64
+
65
+ bucket = aws_s3_bucket.media.id
66
+
67
+ cors_rule {
68
+ allowed_methods = ["POST"]
69
+ allowed_origins = var.allowed_upload_origins
70
+ allowed_headers = ["*"]
71
+ max_age_seconds = 3000
72
+ }
73
+ }
74
+
75
+ # CloudFront OAC経由のアクセスのみ許可。Resourceはmaster/*に限定し、
76
+ # raw/*・cache/*をバケットポリシー経由で公開しない(cache/*はmedia-handlerの実行ロール経由のみ)
77
+ resource "aws_s3_bucket_policy" "media" {
78
+ bucket = aws_s3_bucket.media.id
79
+
80
+ policy = jsonencode({
81
+ Version = "2012-10-17"
82
+ Statement = [
83
+ {
84
+ Sid = "AllowCloudFrontOACReadMasterOnly"
85
+ Effect = "Allow"
86
+ Principal = { Service = "cloudfront.amazonaws.com" }
87
+ Action = "s3:GetObject"
88
+ Resource = "${aws_s3_bucket.media.arn}/master/*"
89
+ Condition = {
90
+ StringEquals = {
91
+ "AWS:SourceArn" = aws_cloudfront_distribution.media.arn
92
+ }
93
+ }
94
+ }
95
+ ]
96
+ })
97
+ }
98
+
99
+ # ============================================================
100
+ # DLQ(process-handlerのon-failure宛先)
101
+ # ============================================================
102
+
103
+ resource "aws_sqs_queue" "process_handler_dlq" {
104
+ name = "${local.name_prefix}-process-dlq"
105
+ message_retention_seconds = 1209600 # 14日(SQS上限)
106
+
107
+ tags = {
108
+ Name = "${local.name_prefix}-process-dlq"
109
+ }
110
+ }
111
+
112
+ # ============================================================
113
+ # sharp Lambda Layer(scripts/build-sharp-layer.shで事前ビルド)
114
+ # ============================================================
115
+
116
+ resource "aws_lambda_layer_version" "sharp" {
117
+ layer_name = "${local.name_prefix}-sharp"
118
+ filename = "${path.module}/../../dist/sharp-layer.zip"
119
+ source_code_hash = filebase64sha256("${path.module}/../../dist/sharp-layer.zip")
120
+ compatible_runtimes = ["nodejs22.x"]
121
+ # sharp-layer.zipはlinux/arm64向けにビルドされる(build-sharp-layer.sh参照)
122
+ compatible_architectures = ["arm64"]
123
+ }
124
+
125
+ # ============================================================
126
+ # process-handler Lambda(S3 ObjectCreatedトリガー)
127
+ # ============================================================
128
+
129
+ resource "aws_iam_role" "process_handler" {
130
+ name = "${local.name_prefix}-process-handler-role"
131
+
132
+ assume_role_policy = jsonencode({
133
+ Version = "2012-10-17"
134
+ Statement = [
135
+ {
136
+ Action = "sts:AssumeRole"
137
+ Effect = "Allow"
138
+ Principal = { Service = "lambda.amazonaws.com" }
139
+ }
140
+ ]
141
+ })
142
+
143
+ tags = {
144
+ Name = "${local.name_prefix}-process-handler-role"
145
+ }
146
+ }
147
+
148
+ resource "aws_iam_role_policy_attachment" "process_handler_basic" {
149
+ role = aws_iam_role.process_handler.name
150
+ policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
151
+ }
152
+
153
+ resource "aws_iam_role_policy" "process_handler_s3" {
154
+ name = "s3-access"
155
+ role = aws_iam_role.process_handler.id
156
+
157
+ policy = jsonencode({
158
+ Version = "2012-10-17"
159
+ Statement = [
160
+ {
161
+ Effect = "Allow"
162
+ Action = ["s3:GetObject"]
163
+ Resource = "${aws_s3_bucket.media.arn}/raw/*"
164
+ },
165
+ {
166
+ Effect = "Allow"
167
+ Action = ["s3:PutObject", "s3:GetObject"]
168
+ Resource = "${aws_s3_bucket.media.arn}/master/*"
169
+ }
170
+ ]
171
+ })
172
+ }
173
+
174
+ resource "aws_iam_role_policy" "process_handler_dynamodb" {
175
+ name = "dynamodb-access"
176
+ role = aws_iam_role.process_handler.id
177
+
178
+ policy = jsonencode({
179
+ Version = "2012-10-17"
180
+ Statement = [
181
+ {
182
+ Effect = "Allow"
183
+ # DeleteItem: updateMany(handleUpdateOneの内部実装)はTransactWriteの中で
184
+ # 更新前の古いShadow Recordを削除する処理を含むため、2回目以降の更新に必須
185
+ Action = ["dynamodb:GetItem", "dynamodb:PutItem", "dynamodb:UpdateItem", "dynamodb:DeleteItem", "dynamodb:BatchGetItem", "dynamodb:Query"]
186
+ Resource = [var.table_arn, "${var.table_arn}/index/*"]
187
+ }
188
+ ]
189
+ })
190
+ }
191
+
192
+ resource "aws_iam_role_policy" "process_handler_ssm" {
193
+ name = "ssm-access"
194
+ role = aws_iam_role.process_handler.id
195
+
196
+ policy = jsonencode({
197
+ Version = "2012-10-17"
198
+ Statement = [
199
+ {
200
+ Effect = "Allow"
201
+ Action = ["ssm:GetParameter"]
202
+ Resource = "arn:aws:ssm:${var.region}:*:parameter${var.image_policy_param}"
203
+ }
204
+ ]
205
+ })
206
+ }
207
+
208
+ # Lambda非同期呼び出し失敗時のon-failure宛先として送信する権限
209
+ resource "aws_iam_role_policy" "process_handler_dlq_send" {
210
+ name = "dlq-send"
211
+ role = aws_iam_role.process_handler.id
212
+
213
+ policy = jsonencode({
214
+ Version = "2012-10-17"
215
+ Statement = [
216
+ {
217
+ Effect = "Allow"
218
+ Action = ["sqs:SendMessage"]
219
+ Resource = aws_sqs_queue.process_handler_dlq.arn
220
+ }
221
+ ]
222
+ })
223
+ }
224
+
225
+ resource "aws_cloudwatch_log_group" "process_handler" {
226
+ name = "/aws/lambda/${local.name_prefix}-process-handler"
227
+ retention_in_days = var.log_retention_days
228
+
229
+ tags = {
230
+ Name = "${local.name_prefix}-process-handler-logs"
231
+ }
232
+ }
233
+
234
+ data "archive_file" "process_handler" {
235
+ type = "zip"
236
+ source_file = "${path.module}/../../dist/server/media-process-handler.cjs"
237
+ output_path = "${path.module}/../../dist/server/media-process-handler.zip"
238
+ }
239
+
240
+ resource "aws_lambda_function" "process_handler" {
241
+ function_name = "${local.name_prefix}-process-handler"
242
+ description = "メディア処理(EXIF除去・リサイズ・master配置)。S3 ObjectCreated(raw/*)トリガー"
243
+ role = aws_iam_role.process_handler.arn
244
+
245
+ filename = data.archive_file.process_handler.output_path
246
+ source_code_hash = data.archive_file.process_handler.output_base64sha256
247
+
248
+ runtime = "nodejs22.x"
249
+ architectures = ["arm64"] # sharp-layer.zip(linux/arm64)に合わせる
250
+ handler = "media-process-handler.handler"
251
+
252
+ timeout = var.process_handler_timeout
253
+ memory_size = var.process_handler_memory_size
254
+
255
+ layers = [aws_lambda_layer_version.sharp.arn]
256
+
257
+ environment {
258
+ variables = {
259
+ REGION = var.region
260
+ TABLE_NAME = var.table_name
261
+ IMAGE_POLICY_PARAM = var.image_policy_param
262
+ SHADOW_CREATED_AT_FIELD = var.shadow_created_at_field
263
+ SHADOW_UPDATED_AT_FIELD = var.shadow_updated_at_field
264
+ SHADOW_STRING_MAX_BYTES = var.shadow_string_max_bytes
265
+ SHADOW_NUMBER_PADDING = var.shadow_number_padding
266
+ }
267
+ }
268
+
269
+ depends_on = [
270
+ aws_cloudwatch_log_group.process_handler,
271
+ aws_iam_role_policy.process_handler_s3,
272
+ aws_iam_role_policy.process_handler_dynamodb,
273
+ aws_iam_role_policy.process_handler_ssm,
274
+ ]
275
+
276
+ tags = {
277
+ Name = "${local.name_prefix}-process-handler"
278
+ }
279
+ }
280
+
281
+ resource "aws_lambda_function_event_invoke_config" "process_handler" {
282
+ function_name = aws_lambda_function.process_handler.function_name
283
+ maximum_retry_attempts = 2
284
+ # 既定の6時間だと、リトライが尽きてDLQ経由でfailedが書き込まれるまで
285
+ # 呼び出し側のポーリングが長時間ブロックされ得る。短く設定し、クライアントの
286
+ # タイムアウト判断(詳細: docs/media-design.md「ポーリングが区別すべき状態」)
287
+ # と整合させる。
288
+ maximum_event_age_in_seconds = 300
289
+
290
+ destination_config {
291
+ on_failure {
292
+ destination = aws_sqs_queue.process_handler_dlq.arn
293
+ }
294
+ }
295
+ }
296
+
297
+ resource "aws_lambda_permission" "s3_invoke_process_handler" {
298
+ statement_id = "AllowS3Invoke"
299
+ action = "lambda:InvokeFunction"
300
+ function_name = aws_lambda_function.process_handler.function_name
301
+ principal = "s3.amazonaws.com"
302
+ source_arn = aws_s3_bucket.media.arn
303
+ }
304
+
305
+ resource "aws_s3_bucket_notification" "media" {
306
+ bucket = aws_s3_bucket.media.id
307
+
308
+ lambda_function {
309
+ lambda_function_arn = aws_lambda_function.process_handler.arn
310
+ events = ["s3:ObjectCreated:*"]
311
+ filter_prefix = "raw/"
312
+ }
313
+
314
+ depends_on = [aws_lambda_permission.s3_invoke_process_handler]
315
+ }
316
+
317
+ # ============================================================
318
+ # process-handler-dlq Lambda(SQS DLQトリガー)
319
+ # ============================================================
320
+
321
+ resource "aws_iam_role" "process_handler_dlq" {
322
+ name = "${local.name_prefix}-process-handler-dlq-role"
323
+
324
+ assume_role_policy = jsonencode({
325
+ Version = "2012-10-17"
326
+ Statement = [
327
+ {
328
+ Action = "sts:AssumeRole"
329
+ Effect = "Allow"
330
+ Principal = { Service = "lambda.amazonaws.com" }
331
+ }
332
+ ]
333
+ })
334
+
335
+ tags = {
336
+ Name = "${local.name_prefix}-process-handler-dlq-role"
337
+ }
338
+ }
339
+
340
+ resource "aws_iam_role_policy_attachment" "process_handler_dlq_basic" {
341
+ role = aws_iam_role.process_handler_dlq.name
342
+ policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
343
+ }
344
+
345
+ resource "aws_iam_role_policy" "process_handler_dlq_sqs" {
346
+ name = "sqs-access"
347
+ role = aws_iam_role.process_handler_dlq.id
348
+
349
+ policy = jsonencode({
350
+ Version = "2012-10-17"
351
+ Statement = [
352
+ {
353
+ Effect = "Allow"
354
+ Action = ["sqs:ReceiveMessage", "sqs:DeleteMessage", "sqs:GetQueueAttributes"]
355
+ Resource = aws_sqs_queue.process_handler_dlq.arn
356
+ }
357
+ ]
358
+ })
359
+ }
360
+
361
+ resource "aws_iam_role_policy" "process_handler_dlq_s3" {
362
+ name = "s3-access"
363
+ role = aws_iam_role.process_handler_dlq.id
364
+
365
+ # failedレコードにownerId等のアプリ固有メタデータを含めるため、raw/{fileId}を
366
+ # HeadObjectで再取得する(process-handler-dlq.ts)。この権限が無いとHeadObjectが
367
+ # 常にAccessDeniedになり、fail-soft設計により静かに失敗し続ける
368
+ # (メタデータ無しのfailedレコードが書き込まれ、権限スコープ付きポーリングで
369
+ # 読めないままクライアントが無限ポーリングする問題が再発する)。
370
+ policy = jsonencode({
371
+ Version = "2012-10-17"
372
+ Statement = [
373
+ {
374
+ Effect = "Allow"
375
+ Action = ["s3:GetObject"]
376
+ Resource = "${aws_s3_bucket.media.arn}/raw/*"
377
+ }
378
+ ]
379
+ })
380
+ }
381
+
382
+ resource "aws_iam_role_policy" "process_handler_dlq_dynamodb" {
383
+ name = "dynamodb-access"
384
+ role = aws_iam_role.process_handler_dlq.id
385
+
386
+ policy = jsonencode({
387
+ Version = "2012-10-17"
388
+ Statement = [
389
+ {
390
+ Effect = "Allow"
391
+ Action = ["dynamodb:GetItem", "dynamodb:PutItem", "dynamodb:UpdateItem", "dynamodb:DeleteItem", "dynamodb:BatchGetItem"]
392
+ Resource = [var.table_arn, "${var.table_arn}/index/*"]
393
+ }
394
+ ]
395
+ })
396
+ }
397
+
398
+ resource "aws_cloudwatch_log_group" "process_handler_dlq" {
399
+ name = "/aws/lambda/${local.name_prefix}-process-handler-dlq"
400
+ retention_in_days = var.log_retention_days
401
+
402
+ tags = {
403
+ Name = "${local.name_prefix}-process-handler-dlq-logs"
404
+ }
405
+ }
406
+
407
+ data "archive_file" "process_handler_dlq" {
408
+ type = "zip"
409
+ source_file = "${path.module}/../../dist/server/media-process-handler-dlq.cjs"
410
+ output_path = "${path.module}/../../dist/server/media-process-handler-dlq.zip"
411
+ }
412
+
413
+ resource "aws_lambda_function" "process_handler_dlq" {
414
+ function_name = "${local.name_prefix}-process-handler-dlq"
415
+ description = "process-handlerのリトライが尽きた場合にfailedレコードを書き込む(SQS DLQトリガー)"
416
+ role = aws_iam_role.process_handler_dlq.arn
417
+
418
+ filename = data.archive_file.process_handler_dlq.output_path
419
+ source_code_hash = data.archive_file.process_handler_dlq.output_base64sha256
420
+
421
+ runtime = "nodejs22.x"
422
+ architectures = ["arm64"]
423
+ handler = "media-process-handler-dlq.handler"
424
+
425
+ timeout = 30
426
+ memory_size = 256
427
+
428
+ environment {
429
+ variables = {
430
+ REGION = var.region
431
+ TABLE_NAME = var.table_name
432
+ SHADOW_CREATED_AT_FIELD = var.shadow_created_at_field
433
+ SHADOW_UPDATED_AT_FIELD = var.shadow_updated_at_field
434
+ SHADOW_STRING_MAX_BYTES = var.shadow_string_max_bytes
435
+ SHADOW_NUMBER_PADDING = var.shadow_number_padding
436
+ }
437
+ }
438
+
439
+ depends_on = [
440
+ aws_cloudwatch_log_group.process_handler_dlq,
441
+ aws_iam_role_policy.process_handler_dlq_sqs,
442
+ aws_iam_role_policy.process_handler_dlq_dynamodb,
443
+ aws_iam_role_policy.process_handler_dlq_s3,
444
+ ]
445
+
446
+ tags = {
447
+ Name = "${local.name_prefix}-process-handler-dlq"
448
+ }
449
+ }
450
+
451
+ resource "aws_lambda_event_source_mapping" "process_handler_dlq" {
452
+ event_source_arn = aws_sqs_queue.process_handler_dlq.arn
453
+ function_name = aws_lambda_function.process_handler_dlq.arn
454
+ batch_size = 1
455
+ }
456
+
457
+ # ============================================================
458
+ # media-handler Lambda(CloudFront /resize/* 専用オリジン)
459
+ # ============================================================
460
+
461
+ resource "aws_iam_role" "media_handler" {
462
+ name = "${local.name_prefix}-media-handler-role"
463
+
464
+ assume_role_policy = jsonencode({
465
+ Version = "2012-10-17"
466
+ Statement = [
467
+ {
468
+ Action = "sts:AssumeRole"
469
+ Effect = "Allow"
470
+ Principal = { Service = "lambda.amazonaws.com" }
471
+ }
472
+ ]
473
+ })
474
+
475
+ tags = {
476
+ Name = "${local.name_prefix}-media-handler-role"
477
+ }
478
+ }
479
+
480
+ resource "aws_iam_role_policy_attachment" "media_handler_basic" {
481
+ role = aws_iam_role.media_handler.name
482
+ policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
483
+ }
484
+
485
+ # 署名鍵へのSSM権限・DynamoDB権限は付与しない(最小権限。media-handlerに不要なため)
486
+ resource "aws_iam_role_policy" "media_handler_s3" {
487
+ name = "s3-access"
488
+ role = aws_iam_role.media_handler.id
489
+
490
+ policy = jsonencode({
491
+ Version = "2012-10-17"
492
+ Statement = [
493
+ {
494
+ Effect = "Allow"
495
+ Action = ["s3:GetObject"]
496
+ Resource = "${aws_s3_bucket.media.arn}/master/*"
497
+ },
498
+ {
499
+ Effect = "Allow"
500
+ Action = ["s3:GetObject", "s3:PutObject"]
501
+ Resource = "${aws_s3_bucket.media.arn}/cache/*"
502
+ },
503
+ {
504
+ # s3:ListBucket が無いロールでGetObjectが存在しないキーを指すと、S3は
505
+ # NoSuchKey(404相当)ではなく403 AccessDeniedを返す(存在確認防止のためのAWS仕様)。
506
+ # これを許可しないと tryGetFromS3() の NoSuchKey 判定に当たらず、
507
+ # cache未生成時の初回リクエストが常に500になる。
508
+ Effect = "Allow"
509
+ Action = ["s3:ListBucket"]
510
+ Resource = aws_s3_bucket.media.arn
511
+ Condition = {
512
+ StringLike = {
513
+ "s3:prefix" = ["master/*", "cache/*"]
514
+ }
515
+ }
516
+ }
517
+ ]
518
+ })
519
+ }
520
+
521
+ resource "aws_iam_role_policy" "media_handler_ssm" {
522
+ name = "ssm-access"
523
+ role = aws_iam_role.media_handler.id
524
+
525
+ policy = jsonencode({
526
+ Version = "2012-10-17"
527
+ Statement = [
528
+ {
529
+ Effect = "Allow"
530
+ Action = ["ssm:GetParameter"]
531
+ Resource = "arn:aws:ssm:${var.region}:*:parameter${var.image_policy_param}"
532
+ }
533
+ ]
534
+ })
535
+ }
536
+
537
+ resource "aws_cloudwatch_log_group" "media_handler" {
538
+ name = "/aws/lambda/${local.name_prefix}-handler"
539
+ retention_in_days = var.log_retention_days
540
+
541
+ tags = {
542
+ Name = "${local.name_prefix}-handler-logs"
543
+ }
544
+ }
545
+
546
+ data "archive_file" "media_handler" {
547
+ type = "zip"
548
+ source_file = "${path.module}/../../dist/server/media-handler.cjs"
549
+ output_path = "${path.module}/../../dist/server/media-handler.zip"
550
+ }
551
+
552
+ resource "aws_lambda_function" "media_handler" {
553
+ function_name = "${local.name_prefix}-handler"
554
+ description = "CloudFront /resize/* 専用オリジン。オンデマンドリサイズ"
555
+ role = aws_iam_role.media_handler.arn
556
+
557
+ filename = data.archive_file.media_handler.output_path
558
+ source_code_hash = data.archive_file.media_handler.output_base64sha256
559
+
560
+ runtime = "nodejs22.x"
561
+ architectures = ["arm64"] # sharp-layer.zip(linux/arm64)に合わせる
562
+ handler = "media-handler.handler"
563
+
564
+ timeout = 30
565
+ memory_size = var.media_handler_memory_size
566
+
567
+ layers = [aws_lambda_layer_version.sharp.arn]
568
+
569
+ environment {
570
+ variables = {
571
+ REGION = var.region
572
+ MEDIA_BUCKET = aws_s3_bucket.media.bucket
573
+ IMAGE_POLICY_PARAM = var.image_policy_param
574
+ }
575
+ }
576
+
577
+ depends_on = [
578
+ aws_cloudwatch_log_group.media_handler,
579
+ aws_iam_role_policy.media_handler_s3,
580
+ aws_iam_role_policy.media_handler_ssm,
581
+ ]
582
+
583
+ tags = {
584
+ Name = "${local.name_prefix}-handler"
585
+ }
586
+ }
587
+
588
+ # CloudFrontからのみ呼び出し可能にする(AWS_IAM認証、CloudFront OAC経由)
589
+ resource "aws_lambda_function_url" "media_handler" {
590
+ function_name = aws_lambda_function.media_handler.function_name
591
+ authorization_type = "AWS_IAM"
592
+ }
593
+
594
+ resource "aws_lambda_permission" "cloudfront_invoke_media_handler" {
595
+ statement_id = "AllowCloudFrontInvokeFunctionUrl"
596
+ action = "lambda:InvokeFunctionUrl"
597
+ function_name = aws_lambda_function.media_handler.function_name
598
+ principal = "cloudfront.amazonaws.com"
599
+ source_arn = aws_cloudfront_distribution.media.arn
600
+ function_url_auth_type = "AWS_IAM"
601
+ }
602
+
603
+ # AWSはFunction URL + OAC経由の呼び出しに対してDual Auth(lambda:InvokeFunctionUrl
604
+ # とlambda:InvokeFunctionの両方の許可)を正式に要求する(移行猶予期間は2026-11-01
605
+ # まで)。lambda:InvokeFunctionUrlだけの許可では、猶予期間終了後に/resize/*が
606
+ # 全件403になる(詳細: docs/adr/0002-media-design.md)。
607
+ # invoked_via_function_url=true により、この許可をFunction URL経由の呼び出しに
608
+ # 限定する(AWS推奨の条件キー)。
609
+ resource "aws_lambda_permission" "cloudfront_invoke_media_handler_function" {
610
+ statement_id = "AllowCloudFrontInvokeFunction"
611
+ action = "lambda:InvokeFunction"
612
+ function_name = aws_lambda_function.media_handler.function_name
613
+ principal = "cloudfront.amazonaws.com"
614
+ source_arn = aws_cloudfront_distribution.media.arn
615
+ invoked_via_function_url = true
616
+ }
617
+
618
+ # ============================================================
619
+ # CloudFront配信(署名付きURL必須、/master/*・/resize/*の2ビヘイビア)
620
+ # ============================================================
621
+
622
+ resource "aws_cloudfront_origin_access_control" "media_s3" {
623
+ name = "${local.name_prefix}-s3-oac"
624
+ origin_access_control_origin_type = "s3"
625
+ signing_behavior = "always"
626
+ signing_protocol = "sigv4"
627
+ }
628
+
629
+ resource "aws_cloudfront_origin_access_control" "media_handler" {
630
+ name = "${local.name_prefix}-lambda-oac"
631
+ origin_access_control_origin_type = "lambda"
632
+ signing_behavior = "always"
633
+ signing_protocol = "sigv4"
634
+ }
635
+
636
+ data "aws_ssm_parameter" "signing_public_key" {
637
+ name = var.signing_public_key_param
638
+ }
639
+
640
+ resource "aws_cloudfront_public_key" "media" {
641
+ name = "${local.name_prefix}-signing-key"
642
+ encoded_key = data.aws_ssm_parameter.signing_public_key.value
643
+ comment = "Media delivery signing key (2048bit RSA)"
644
+ }
645
+
646
+ resource "aws_cloudfront_key_group" "media" {
647
+ name = "${local.name_prefix}-key-group"
648
+ items = [aws_cloudfront_public_key.media.id]
649
+ }
650
+
651
+ # width クエリパラメータのみキャッシュキーに含め、署名パラメータは除外する
652
+ resource "aws_cloudfront_cache_policy" "resize" {
653
+ name = "${local.name_prefix}-resize-cache-policy"
654
+ comment = "width のみキャッシュキーに含める。署名付きURLのExpires/Signature/Key-Pair-Idは除外"
655
+
656
+ default_ttl = 86400
657
+ max_ttl = 31536000
658
+ min_ttl = 0
659
+
660
+ parameters_in_cache_key_and_forwarded_to_origin {
661
+ cookies_config {
662
+ cookie_behavior = "none"
663
+ }
664
+ headers_config {
665
+ header_behavior = "none"
666
+ }
667
+ query_strings_config {
668
+ query_string_behavior = "whitelist"
669
+ query_strings {
670
+ items = ["width"]
671
+ }
672
+ }
673
+ enable_accept_encoding_brotli = true
674
+ enable_accept_encoding_gzip = true
675
+ }
676
+ }
677
+
678
+ locals {
679
+ # AWSマネージドポリシー(キャッシュポリシー/オリジンリクエストポリシー)のID
680
+ # CachingOptimized: S3静的ファイル配信向けの標準キャッシュポリシー
681
+ managed_caching_optimized_policy_id = "658327ea-f89d-4fab-a63d-7e88639e58f6"
682
+ # AllViewerExceptHostHeader: Hostヘッダーのみ転送しない(Function URLがHostヘッダー転送時に403を返すため)
683
+ managed_all_viewer_except_host_header_policy_id = "b689b0a8-53d0-40ab-baf2-68738e2966ac"
684
+
685
+ media_handler_function_url_domain = replace(
686
+ replace(aws_lambda_function_url.media_handler.function_url, "https://", ""),
687
+ "/",
688
+ ""
689
+ )
690
+
691
+ has_custom_domain = var.domain_name != null && var.acm_certificate_arn != null
692
+ }
693
+
694
+ resource "aws_cloudfront_distribution" "media" {
695
+ enabled = true
696
+ comment = "${local.name_prefix} delivery"
697
+ price_class = "PriceClass_200"
698
+
699
+ aliases = local.has_custom_domain ? [var.domain_name] : []
700
+
701
+ origin {
702
+ domain_name = aws_s3_bucket.media.bucket_regional_domain_name
703
+ origin_id = "s3-media"
704
+ origin_access_control_id = aws_cloudfront_origin_access_control.media_s3.id
705
+ }
706
+
707
+ origin {
708
+ domain_name = local.media_handler_function_url_domain
709
+ origin_id = "lambda-media-handler"
710
+ origin_access_control_id = aws_cloudfront_origin_access_control.media_handler.id
711
+
712
+ custom_origin_config {
713
+ http_port = 80
714
+ https_port = 443
715
+ origin_protocol_policy = "https-only"
716
+ origin_ssl_protocols = ["TLSv1.2"]
717
+ }
718
+ }
719
+
720
+ # デフォルトビヘイビア(想定外のパス)。masterと同じ扱いにしておく
721
+ default_cache_behavior {
722
+ target_origin_id = "s3-media"
723
+ viewer_protocol_policy = "redirect-to-https"
724
+ allowed_methods = ["GET", "HEAD"]
725
+ cached_methods = ["GET", "HEAD"]
726
+ cache_policy_id = local.managed_caching_optimized_policy_id
727
+ trusted_key_groups = [aws_cloudfront_key_group.media.id]
728
+ }
729
+
730
+ ordered_cache_behavior {
731
+ path_pattern = "/master/*"
732
+ target_origin_id = "s3-media"
733
+ viewer_protocol_policy = "redirect-to-https"
734
+ allowed_methods = ["GET", "HEAD"]
735
+ cached_methods = ["GET", "HEAD"]
736
+ cache_policy_id = local.managed_caching_optimized_policy_id
737
+ trusted_key_groups = [aws_cloudfront_key_group.media.id]
738
+ }
739
+
740
+ ordered_cache_behavior {
741
+ path_pattern = "/resize/*"
742
+ target_origin_id = "lambda-media-handler"
743
+ viewer_protocol_policy = "redirect-to-https"
744
+ allowed_methods = ["GET", "HEAD"]
745
+ cached_methods = ["GET", "HEAD"]
746
+ cache_policy_id = aws_cloudfront_cache_policy.resize.id
747
+ origin_request_policy_id = local.managed_all_viewer_except_host_header_policy_id
748
+ trusted_key_groups = [aws_cloudfront_key_group.media.id]
749
+ }
750
+
751
+ # 404/403応答がネガティブキャッシュ(既定約10秒)で長引かないようにする
752
+ custom_error_response {
753
+ error_code = 403
754
+ error_caching_min_ttl = 0
755
+ }
756
+ custom_error_response {
757
+ error_code = 404
758
+ error_caching_min_ttl = 0
759
+ }
760
+
761
+ restrictions {
762
+ geo_restriction {
763
+ restriction_type = "none"
764
+ }
765
+ }
766
+
767
+ viewer_certificate {
768
+ cloudfront_default_certificate = local.has_custom_domain ? null : true
769
+ acm_certificate_arn = local.has_custom_domain ? var.acm_certificate_arn : null
770
+ ssl_support_method = local.has_custom_domain ? "sni-only" : null
771
+ minimum_protocol_version = local.has_custom_domain ? "TLSv1.2_2021" : null
772
+ }
773
+
774
+ tags = {
775
+ Name = local.name_prefix
776
+ }
777
+ }
778
+
779
+ resource "aws_route53_record" "media" {
780
+ count = local.has_custom_domain && var.route53_zone_id != null ? 1 : 0
781
+
782
+ zone_id = var.route53_zone_id
783
+ name = var.domain_name
784
+ type = "A"
785
+
786
+ alias {
787
+ name = aws_cloudfront_distribution.media.domain_name
788
+ zone_id = aws_cloudfront_distribution.media.hosted_zone_id
789
+ evaluate_target_health = false
790
+ }
791
+ }