@c-time/frelio-cms 1.3.5 → 1.3.7

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 (35) hide show
  1. package/dist/assets/{index-Qi4Cp3k2.js → index-D36XnvJP.js} +1 -1
  2. package/dist/assets/{index-Dr4qYonI.js → index-DCWmZub0.js} +4 -4
  3. package/dist/index.html +1 -1
  4. package/functions/api/auth/callback.ts +138 -0
  5. package/functions/api/storage/_middleware.ts +76 -0
  6. package/functions/api/storage/files/[uuid].ts +35 -0
  7. package/functions/api/storage/list.ts +19 -0
  8. package/functions/api/storage/rebuild/[year].ts +27 -0
  9. package/functions/api/storage/upload-set.ts +18 -0
  10. package/functions/api/storage/upload.ts +18 -0
  11. package/functions/api/storage/years.ts +18 -0
  12. package/package.json +6 -4
  13. package/workers/file-upload/package-lock.json +1606 -0
  14. package/workers/file-upload/package.json +14 -0
  15. package/workers/file-upload/src/controller/DIContainer.ts +103 -0
  16. package/workers/file-upload/src/controller/FileController.ts +248 -0
  17. package/workers/file-upload/src/domain/AuthEntity.ts +17 -0
  18. package/workers/file-upload/src/domain/FileEntity.ts +113 -0
  19. package/workers/file-upload/src/index.ts +134 -0
  20. package/workers/file-upload/src/infra/LoggerSingleton.ts +162 -0
  21. package/workers/file-upload/src/presenter/HttpPresenterImpl.ts +86 -0
  22. package/workers/file-upload/src/repository/AuthRepositoryPort.ts +11 -0
  23. package/workers/file-upload/src/repository/GitHubAuthRepositoryImpl.ts +58 -0
  24. package/workers/file-upload/src/repository/R2RepositoryImpl.ts +145 -0
  25. package/workers/file-upload/src/repository/R2RepositoryPort.ts +86 -0
  26. package/workers/file-upload/src/usecase/DeleteFileUseCase.ts +97 -0
  27. package/workers/file-upload/src/usecase/GetFileUseCase.ts +50 -0
  28. package/workers/file-upload/src/usecase/ListFilesUseCase.ts +52 -0
  29. package/workers/file-upload/src/usecase/RebuildIndexUseCase.ts +182 -0
  30. package/workers/file-upload/src/usecase/UpdateFileMetadataUseCase.ts +43 -0
  31. package/workers/file-upload/src/usecase/UploadFileUseCase.ts +62 -0
  32. package/workers/file-upload/src/usecase/UploadImageSetUseCase.ts +95 -0
  33. package/workers/file-upload/src/usecase/ValidateAuthUseCase.ts +26 -0
  34. package/workers/file-upload/tsconfig.json +13 -0
  35. package/workers/file-upload/wrangler.toml.example +11 -0
@@ -0,0 +1,14 @@
1
+ {
2
+ "name": "@frelio/file-upload",
3
+ "version": "0.0.1",
4
+ "private": true,
5
+ "scripts": {
6
+ "dev": "wrangler dev",
7
+ "deploy": "wrangler deploy"
8
+ },
9
+ "devDependencies": {
10
+ "@cloudflare/workers-types": "^4.20240208.0",
11
+ "typescript": "^5.3.3",
12
+ "wrangler": "^3.28.0"
13
+ }
14
+ }
@@ -0,0 +1,103 @@
1
+ /**
2
+ * DIContainer - Cloudflare Workers 用の依存性注入コンテナ
3
+ *
4
+ * Workers 環境では Env オブジェクトを受け取り、
5
+ * R2 バケットなどの Cloudflare バインディングを Repository に注入する。
6
+ */
7
+
8
+ import { LoggerSingleton } from '../infra/LoggerSingleton'
9
+
10
+ type Factory<T> = () => T
11
+ type ServiceKey = string
12
+
13
+ export class DIContainer {
14
+ private singletons = new Map<ServiceKey, unknown>()
15
+ private factories = new Map<ServiceKey, Factory<unknown>>()
16
+
17
+ constructor() {
18
+ // Logger はデフォルトで登録
19
+ this.registerSingleton('Logger', LoggerSingleton.getInstance())
20
+ }
21
+
22
+ /**
23
+ * シングルトンとしてインスタンスを登録
24
+ */
25
+ registerSingleton<T>(key: ServiceKey, instance: T): void {
26
+ this.singletons.set(key, instance)
27
+ }
28
+
29
+ /**
30
+ * ファクトリ関数を登録(毎回新しいインスタンスを生成)
31
+ */
32
+ registerFactory<T>(key: ServiceKey, factory: Factory<T>): void {
33
+ this.factories.set(key, factory)
34
+ }
35
+
36
+ /**
37
+ * 登録済みのサービスを取得
38
+ */
39
+ get<T>(key: ServiceKey): T {
40
+ // シングルトンを優先
41
+ if (this.singletons.has(key)) {
42
+ return this.singletons.get(key) as T
43
+ }
44
+
45
+ // ファクトリがあれば新しいインスタンスを生成
46
+ if (this.factories.has(key)) {
47
+ const factory = this.factories.get(key) as Factory<T>
48
+ return factory()
49
+ }
50
+
51
+ throw new Error(`DIContainer: Service '${key}' is not registered`)
52
+ }
53
+
54
+ /**
55
+ * サービスが登録済みかどうか
56
+ */
57
+ has(key: ServiceKey): boolean {
58
+ return this.singletons.has(key) || this.factories.has(key)
59
+ }
60
+
61
+ /**
62
+ * Logger のショートカット
63
+ */
64
+ getLogger(): LoggerSingleton {
65
+ return this.get<LoggerSingleton>('Logger')
66
+ }
67
+ }
68
+
69
+ /**
70
+ * リクエストごとにコンテナを作成
71
+ *
72
+ * Workers ではリクエストごとに Env オブジェクトが渡されるため、
73
+ * グローバルシングルトンではなくリクエストスコープで管理する。
74
+ */
75
+ export function createContainer(env: Env): DIContainer {
76
+ const container = new DIContainer()
77
+ const logger = container.getLogger()
78
+
79
+ // Env オブジェクトを登録(R2 バケットなどへのアクセスに使用)
80
+ container.registerSingleton('Env', env)
81
+
82
+ // TODO: Phase 2 で Repository を登録
83
+ // container.registerSingleton('R2Repository', new R2RepositoryImpl(env.FILES_BUCKET, logger))
84
+
85
+ // TODO: Phase 2 で UseCase を登録
86
+ // container.registerFactory('UploadFileUseCase', () => new UploadFileUseCase(...))
87
+
88
+ logger.debug('DIContainer', 'Container created for request')
89
+
90
+ return container
91
+ }
92
+
93
+ /**
94
+ * Cloudflare Workers の Env 型定義
95
+ *
96
+ * wrangler.toml で定義されたバインディングに対応
97
+ */
98
+ interface Env {
99
+ FILES_BUCKET: R2Bucket
100
+ GITHUB_TOKEN?: string
101
+ CONTENT_REPO?: string
102
+ ALLOWED_ORIGINS?: string
103
+ }
@@ -0,0 +1,248 @@
1
+ /**
2
+ * FileController - ファイル操作のコントローラ
3
+ *
4
+ * HTTP リクエストを受け取り、適切な UseCase を実行し、
5
+ * Presenter でレスポンスを整形して返す。
6
+ */
7
+
8
+ import type { PresetName } from '../domain/FileEntity'
9
+ import { logger } from '../infra/LoggerSingleton'
10
+ import { HttpPresenterImpl, type CorsHeaders } from '../presenter/HttpPresenterImpl'
11
+ import type { R2RepositoryPort } from '../repository/R2RepositoryPort'
12
+ import { DeleteFileUseCase } from '../usecase/DeleteFileUseCase'
13
+ import { GetFileUseCase } from '../usecase/GetFileUseCase'
14
+ import { ListFilesUseCase } from '../usecase/ListFilesUseCase'
15
+ import { RebuildIndexUseCase } from '../usecase/RebuildIndexUseCase'
16
+ import { UpdateFileMetadataUseCase } from '../usecase/UpdateFileMetadataUseCase'
17
+ import { UploadFileUseCase } from '../usecase/UploadFileUseCase'
18
+ import { UploadImageSetUseCase } from '../usecase/UploadImageSetUseCase'
19
+
20
+ type ExecutionContext = {
21
+ waitUntil(promise: Promise<unknown>): void
22
+ }
23
+
24
+ export class FileController {
25
+ private presenter: HttpPresenterImpl
26
+ private uploadFileUseCase: UploadFileUseCase
27
+ private uploadImageSetUseCase: UploadImageSetUseCase
28
+ private deleteFileUseCase: DeleteFileUseCase
29
+ private listFilesUseCase: ListFilesUseCase
30
+ private getFileUseCase: GetFileUseCase
31
+ private updateFileMetadataUseCase: UpdateFileMetadataUseCase
32
+ private rebuildIndexUseCase: RebuildIndexUseCase
33
+
34
+ constructor(r2Repository: R2RepositoryPort, corsHeaders?: CorsHeaders) {
35
+ this.presenter = new HttpPresenterImpl(corsHeaders)
36
+ this.uploadFileUseCase = new UploadFileUseCase(r2Repository)
37
+ this.uploadImageSetUseCase = new UploadImageSetUseCase(r2Repository)
38
+ this.deleteFileUseCase = new DeleteFileUseCase(r2Repository)
39
+ this.listFilesUseCase = new ListFilesUseCase(r2Repository)
40
+ this.getFileUseCase = new GetFileUseCase(r2Repository)
41
+ this.updateFileMetadataUseCase = new UpdateFileMetadataUseCase(r2Repository)
42
+ this.rebuildIndexUseCase = new RebuildIndexUseCase(r2Repository)
43
+ }
44
+
45
+ /**
46
+ * CORS プリフライト
47
+ */
48
+ handleCors(): Response {
49
+ return this.presenter.cors()
50
+ }
51
+
52
+ /**
53
+ * POST /upload - 単一ファイルのアップロード
54
+ */
55
+ async handleUpload(request: Request): Promise<Response> {
56
+ const formData = await request.formData()
57
+ const file = formData.get('file') as File | null
58
+ const uploadedBy = formData.get('uploadedBy') as string | null
59
+
60
+ if (!file) {
61
+ return this.presenter.badRequest('No file provided')
62
+ }
63
+ if (!uploadedBy) {
64
+ return this.presenter.badRequest('uploadedBy is required')
65
+ }
66
+
67
+ try {
68
+ const result = await this.uploadFileUseCase.execute({ file, uploadedBy })
69
+ return this.presenter.created(result)
70
+ } catch (error) {
71
+ logger.error('FileController.handleUpload', { error })
72
+ return this.presenter.serverError((error as Error).message)
73
+ }
74
+ }
75
+
76
+ /**
77
+ * POST /upload-set - 画像セットのアップロード
78
+ */
79
+ async handleUploadSet(request: Request, ctx: ExecutionContext): Promise<Response> {
80
+ const formData = await request.formData()
81
+ const uploadedBy = formData.get('uploadedBy') as string | null
82
+ const originalName = formData.get('originalName') as string | null
83
+
84
+ if (!uploadedBy) {
85
+ return this.presenter.badRequest('uploadedBy is required')
86
+ }
87
+ if (!originalName) {
88
+ return this.presenter.badRequest('originalName is required')
89
+ }
90
+
91
+ // プリセットファイルを収集
92
+ const files = new Map<PresetName, File>()
93
+ const presetNames: PresetName[] = ['original', 'retina', 'large', 'medium', 'small', 'thumbnail']
94
+ for (const name of presetNames) {
95
+ const file = formData.get(name) as File | null
96
+ if (file) {
97
+ files.set(name, file)
98
+ }
99
+ }
100
+
101
+ if (files.size === 0) {
102
+ return this.presenter.badRequest('No image files provided')
103
+ }
104
+
105
+ const widthStr = formData.get('width') as string | null
106
+ const heightStr = formData.get('height') as string | null
107
+ const width = widthStr ? parseInt(widthStr) : undefined
108
+ const height = heightStr ? parseInt(heightStr) : undefined
109
+
110
+ try {
111
+ const result = await this.uploadImageSetUseCase.execute({ originalName, uploadedBy, files, width, height })
112
+
113
+ // プリウォーム(バックグラウンド)
114
+ const prewarmUrls = this.uploadImageSetUseCase.getPrewarmUrls(result.variants)
115
+ ctx.waitUntil(this.prewarmUrls(prewarmUrls))
116
+
117
+ return this.presenter.created(result)
118
+ } catch (error) {
119
+ logger.error('FileController.handleUploadSet', { error })
120
+ return this.presenter.serverError((error as Error).message)
121
+ }
122
+ }
123
+
124
+ /**
125
+ * GET /years - 利用可能な年一覧
126
+ */
127
+ async handleGetYears(): Promise<Response> {
128
+ try {
129
+ const result = await this.listFilesUseCase.getAvailableYears()
130
+ return this.presenter.ok(result)
131
+ } catch (error) {
132
+ logger.error('FileController.handleGetYears', { error })
133
+ return this.presenter.serverError((error as Error).message)
134
+ }
135
+ }
136
+
137
+ /**
138
+ * GET /list - ファイル一覧
139
+ */
140
+ async handleListFiles(url: URL): Promise<Response> {
141
+ const yearParam = url.searchParams.get('year')
142
+ const year = yearParam ? parseInt(yearParam) : undefined
143
+
144
+ try {
145
+ const result = await this.listFilesUseCase.execute({ year })
146
+ return this.presenter.ok(result)
147
+ } catch (error) {
148
+ logger.error('FileController.handleListFiles', { error })
149
+ return this.presenter.serverError((error as Error).message)
150
+ }
151
+ }
152
+
153
+ /**
154
+ * GET /files/:uuid - ファイル情報
155
+ */
156
+ async handleGetFile(uuid: string): Promise<Response> {
157
+ try {
158
+ const result = await this.getFileUseCase.execute({ uuid })
159
+ return this.presenter.ok(result)
160
+ } catch (error) {
161
+ if ((error as Error).message === 'File not found') {
162
+ return this.presenter.notFound('File not found')
163
+ }
164
+ logger.error('FileController.handleGetFile', { error })
165
+ return this.presenter.serverError((error as Error).message)
166
+ }
167
+ }
168
+
169
+ /**
170
+ * PATCH /files/:uuid - メタデータ更新
171
+ */
172
+ async handleUpdateFileMetadata(uuid: string, request: Request): Promise<Response> {
173
+ const body = (await request.json()) as { year?: number; alt?: string }
174
+ const { year, alt } = body
175
+
176
+ if (year === undefined) {
177
+ return this.presenter.badRequest('year is required')
178
+ }
179
+
180
+ try {
181
+ const result = await this.updateFileMetadataUseCase.execute({ uuid, year, alt })
182
+ return this.presenter.ok(result)
183
+ } catch (error) {
184
+ if ((error as Error).message === 'File not found') {
185
+ return this.presenter.notFound('File not found')
186
+ }
187
+ logger.error('FileController.handleUpdateFileMetadata', { error })
188
+ return this.presenter.serverError((error as Error).message)
189
+ }
190
+ }
191
+
192
+ /**
193
+ * DELETE /files/:uuid - ファイル削除
194
+ */
195
+ async handleDeleteFile(uuid: string, url: URL): Promise<Response> {
196
+ const yearParam = url.searchParams.get('year')
197
+ const year = yearParam ? parseInt(yearParam) : undefined
198
+
199
+ try {
200
+ const result = await this.deleteFileUseCase.execute({ uuid, year })
201
+ return this.presenter.ok(result)
202
+ } catch (error) {
203
+ if ((error as Error).message === 'File not found') {
204
+ return this.presenter.notFound('File not found')
205
+ }
206
+ logger.error('FileController.handleDeleteFile', { error })
207
+ return this.presenter.serverError((error as Error).message)
208
+ }
209
+ }
210
+
211
+ /**
212
+ * POST /rebuild/:year - インデックス再構築
213
+ */
214
+ async handleRebuildIndex(year: number): Promise<Response> {
215
+ try {
216
+ const result = await this.rebuildIndexUseCase.execute({ year })
217
+ return this.presenter.ok(result)
218
+ } catch (error) {
219
+ logger.error('FileController.handleRebuildIndex', { error })
220
+ return this.presenter.serverError((error as Error).message)
221
+ }
222
+ }
223
+
224
+ /**
225
+ * Not Found
226
+ */
227
+ handleNotFound(): Response {
228
+ return this.presenter.notFound()
229
+ }
230
+
231
+ /**
232
+ * プリウォーム: CDN キャッシュを温める
233
+ */
234
+ private async prewarmUrls(urls: string[]): Promise<void> {
235
+ const results = await Promise.allSettled(
236
+ urls.map((url) =>
237
+ fetch(url, {
238
+ method: 'HEAD',
239
+ cf: { cacheTtl: 86400 },
240
+ })
241
+ )
242
+ )
243
+ logger.debug(
244
+ 'FileController.prewarmUrls',
245
+ `Completed: ${results.filter((r) => r.status === 'fulfilled').length}/${urls.length}`
246
+ )
247
+ }
248
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * AuthEntity - 認証関連の型定義
3
+ */
4
+
5
+ export type GitHubUser = {
6
+ login: string
7
+ }
8
+
9
+ export class AuthError extends Error {
10
+ constructor(
11
+ message: string,
12
+ public statusCode: number,
13
+ ) {
14
+ super(message)
15
+ this.name = 'AuthError'
16
+ }
17
+ }
@@ -0,0 +1,113 @@
1
+ /**
2
+ * FileEntity - ファイルのエンティティ
3
+ *
4
+ * R2 に保存されるファイルのビジネスオブジェクト。
5
+ * 単一ファイルと画像セット(複数プリセット)の両方を表現する。
6
+ */
7
+
8
+ export type FileEntry = {
9
+ uuid: string
10
+ originalName: string
11
+ contentType: string
12
+ size: number
13
+ uploadedBy: string
14
+ uploadedAt: string
15
+ url: string
16
+ alt?: string
17
+ width?: number
18
+ height?: number
19
+ variants?: Record<string, string>
20
+ }
21
+
22
+ export type YearFiles = {
23
+ year: number
24
+ updatedAt: string
25
+ totalCount: number
26
+ totalSize: number
27
+ files: FileEntry[]
28
+ }
29
+
30
+ export type FileMetadata = {
31
+ uuid: string
32
+ originalName: string
33
+ mimeType: string
34
+ size: number
35
+ width?: number
36
+ height?: number
37
+ uploadedBy: string
38
+ createdAt: string
39
+ }
40
+
41
+ /**
42
+ * プリセット名の定数
43
+ */
44
+ export const PRESET_NAMES = ['original', 'retina', 'large', 'medium', 'small', 'thumbnail'] as const
45
+ export type PresetName = (typeof PRESET_NAMES)[number]
46
+
47
+ /**
48
+ * ファイルエントリを作成
49
+ */
50
+ export function createFileEntry(params: {
51
+ uuid: string
52
+ originalName: string
53
+ contentType: string
54
+ size: number
55
+ uploadedBy: string
56
+ uploadedAt: string
57
+ publicUrl: string
58
+ width?: number
59
+ height?: number
60
+ variants?: Record<string, string>
61
+ }): FileEntry {
62
+ return {
63
+ uuid: params.uuid,
64
+ originalName: params.originalName,
65
+ contentType: params.contentType,
66
+ size: params.size,
67
+ uploadedBy: params.uploadedBy,
68
+ uploadedAt: params.uploadedAt,
69
+ url: params.publicUrl,
70
+ ...(params.width != null && { width: params.width }),
71
+ ...(params.height != null && { height: params.height }),
72
+ ...(params.variants && { variants: params.variants }),
73
+ }
74
+ }
75
+
76
+ /**
77
+ * 画像セットからサムネイル URL を選択
78
+ */
79
+ export function selectThumbnailUrl(variants: Record<string, string>): string {
80
+ return (
81
+ variants.thumbnail ||
82
+ variants.small ||
83
+ variants.medium ||
84
+ variants.large ||
85
+ variants.original ||
86
+ ''
87
+ )
88
+ }
89
+
90
+ /**
91
+ * YearFiles の統計を再計算
92
+ */
93
+ export function recalculateYearFilesStats(yearFiles: YearFiles): YearFiles {
94
+ return {
95
+ ...yearFiles,
96
+ totalCount: yearFiles.files.length,
97
+ totalSize: yearFiles.files.reduce((sum, f) => sum + f.size, 0),
98
+ updatedAt: new Date().toISOString(),
99
+ }
100
+ }
101
+
102
+ /**
103
+ * 空の YearFiles を作成
104
+ */
105
+ export function createEmptyYearFiles(year: number): YearFiles {
106
+ return {
107
+ year,
108
+ updatedAt: new Date().toISOString(),
109
+ totalCount: 0,
110
+ totalSize: 0,
111
+ files: [],
112
+ }
113
+ }
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Cloudflare Workers エントリポイント
3
+ *
4
+ * クリーンアーキテクチャに従い、薄いルーターとして機能する。
5
+ * ビジネスロジックは UseCase 層に、HTTP 処理は Controller 層に委譲。
6
+ */
7
+
8
+ import { FileController } from './controller/FileController'
9
+ import { AuthError } from './domain/AuthEntity'
10
+ import { logger } from './infra/LoggerSingleton'
11
+ import { HttpPresenterImpl } from './presenter/HttpPresenterImpl'
12
+ import { GitHubAuthRepositoryImpl } from './repository/GitHubAuthRepositoryImpl'
13
+ import { R2RepositoryImpl } from './repository/R2RepositoryImpl'
14
+ import { ValidateAuthUseCase } from './usecase/ValidateAuthUseCase'
15
+
16
+ export interface Env {
17
+ R2: R2Bucket
18
+ R2_PUBLIC_URL: string
19
+ ALLOWED_ORIGINS?: string
20
+ }
21
+
22
+ type ExecutionContext = {
23
+ waitUntil(promise: Promise<unknown>): void
24
+ passThroughOnException(): void
25
+ }
26
+
27
+ export default {
28
+ async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
29
+ const url = new URL(request.url)
30
+
31
+ // リクエスト ID を設定(トレーシング用)
32
+ const requestId = crypto.randomUUID().slice(0, 8)
33
+ logger.setRequestId(requestId)
34
+
35
+ try {
36
+ // CORS: リクエストの Origin を許可リストと照合
37
+ const origin = request.headers.get('Origin') ?? ''
38
+ const allowedOrigins = env.ALLOWED_ORIGINS?.split(',').map((s) => s.trim()) ?? []
39
+ const matchedOrigin = allowedOrigins.includes(origin) ? origin : null
40
+ const corsHeaders = {
41
+ 'Access-Control-Allow-Origin': matchedOrigin ?? '',
42
+ 'Access-Control-Allow-Methods': 'GET, POST, PATCH, DELETE, OPTIONS',
43
+ 'Access-Control-Allow-Headers': 'Content-Type, Authorization',
44
+ Vary: 'Origin',
45
+ }
46
+
47
+ // DI: Repository と Controller を初期化
48
+ const r2Repository = new R2RepositoryImpl(env.R2, env.R2_PUBLIC_URL)
49
+ const controller = new FileController(r2Repository, corsHeaders)
50
+
51
+ // CORS プリフライト
52
+ if (request.method === 'OPTIONS') {
53
+ return controller.handleCors()
54
+ }
55
+
56
+ // 認証: Bearer トークンを検証
57
+ const authRepository = new GitHubAuthRepositoryImpl()
58
+ const validateAuth = new ValidateAuthUseCase(authRepository)
59
+ const presenter = new HttpPresenterImpl(corsHeaders)
60
+ try {
61
+ await validateAuth.execute(request.headers.get('Authorization'))
62
+ } catch (error) {
63
+ if (error instanceof AuthError) {
64
+ return presenter.unauthorized(error.message)
65
+ }
66
+ throw error
67
+ }
68
+
69
+ // ルーティング
70
+ // POST /upload
71
+ if (request.method === 'POST' && url.pathname === '/upload') {
72
+ return await controller.handleUpload(request)
73
+ }
74
+
75
+ // POST /upload-set
76
+ if (request.method === 'POST' && url.pathname === '/upload-set') {
77
+ return await controller.handleUploadSet(request, ctx)
78
+ }
79
+
80
+ // GET /years
81
+ if (request.method === 'GET' && url.pathname === '/years') {
82
+ return await controller.handleGetYears()
83
+ }
84
+
85
+ // GET /list
86
+ if (request.method === 'GET' && url.pathname === '/list') {
87
+ return await controller.handleListFiles(url)
88
+ }
89
+
90
+ // GET /files/:uuid
91
+ if (request.method === 'GET' && url.pathname.startsWith('/files/')) {
92
+ const uuid = url.pathname.replace('/files/', '')
93
+ return await controller.handleGetFile(uuid)
94
+ }
95
+
96
+ // PATCH /files/:uuid
97
+ if (request.method === 'PATCH' && url.pathname.startsWith('/files/')) {
98
+ const uuid = url.pathname.replace('/files/', '')
99
+ return await controller.handleUpdateFileMetadata(uuid, request)
100
+ }
101
+
102
+ // DELETE /files/:uuid
103
+ if (request.method === 'DELETE' && url.pathname.startsWith('/files/')) {
104
+ const uuid = url.pathname.replace('/files/', '')
105
+ return await controller.handleDeleteFile(uuid, url)
106
+ }
107
+
108
+ // POST /rebuild/:year
109
+ const rebuildMatch = url.pathname.match(/^\/rebuild\/(\d{4})$/)
110
+ if (request.method === 'POST' && rebuildMatch) {
111
+ const year = parseInt(rebuildMatch[1])
112
+ return await controller.handleRebuildIndex(year)
113
+ }
114
+
115
+ // Not Found
116
+ return controller.handleNotFound()
117
+ } catch (error) {
118
+ logger.error('Worker.fetch', 'Unhandled error', { error })
119
+ const origin = request.headers.get('Origin') ?? ''
120
+ const allowedOrigins = env.ALLOWED_ORIGINS?.split(',').map((s) => s.trim()) ?? []
121
+ const matchedOrigin = allowedOrigins.includes(origin) ? origin : ''
122
+ return new Response(JSON.stringify({ error: (error as Error).message }), {
123
+ status: 500,
124
+ headers: {
125
+ 'Access-Control-Allow-Origin': matchedOrigin,
126
+ 'Content-Type': 'application/json',
127
+ Vary: 'Origin',
128
+ },
129
+ })
130
+ } finally {
131
+ logger.clearRequestId()
132
+ }
133
+ },
134
+ }