@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.
- package/dist/assets/{index-Qi4Cp3k2.js → index-D36XnvJP.js} +1 -1
- package/dist/assets/{index-Dr4qYonI.js → index-DCWmZub0.js} +4 -4
- package/dist/index.html +1 -1
- package/functions/api/auth/callback.ts +138 -0
- package/functions/api/storage/_middleware.ts +76 -0
- package/functions/api/storage/files/[uuid].ts +35 -0
- package/functions/api/storage/list.ts +19 -0
- package/functions/api/storage/rebuild/[year].ts +27 -0
- package/functions/api/storage/upload-set.ts +18 -0
- package/functions/api/storage/upload.ts +18 -0
- package/functions/api/storage/years.ts +18 -0
- package/package.json +6 -4
- package/workers/file-upload/package-lock.json +1606 -0
- package/workers/file-upload/package.json +14 -0
- package/workers/file-upload/src/controller/DIContainer.ts +103 -0
- package/workers/file-upload/src/controller/FileController.ts +248 -0
- package/workers/file-upload/src/domain/AuthEntity.ts +17 -0
- package/workers/file-upload/src/domain/FileEntity.ts +113 -0
- package/workers/file-upload/src/index.ts +134 -0
- package/workers/file-upload/src/infra/LoggerSingleton.ts +162 -0
- package/workers/file-upload/src/presenter/HttpPresenterImpl.ts +86 -0
- package/workers/file-upload/src/repository/AuthRepositoryPort.ts +11 -0
- package/workers/file-upload/src/repository/GitHubAuthRepositoryImpl.ts +58 -0
- package/workers/file-upload/src/repository/R2RepositoryImpl.ts +145 -0
- package/workers/file-upload/src/repository/R2RepositoryPort.ts +86 -0
- package/workers/file-upload/src/usecase/DeleteFileUseCase.ts +97 -0
- package/workers/file-upload/src/usecase/GetFileUseCase.ts +50 -0
- package/workers/file-upload/src/usecase/ListFilesUseCase.ts +52 -0
- package/workers/file-upload/src/usecase/RebuildIndexUseCase.ts +182 -0
- package/workers/file-upload/src/usecase/UpdateFileMetadataUseCase.ts +43 -0
- package/workers/file-upload/src/usecase/UploadFileUseCase.ts +62 -0
- package/workers/file-upload/src/usecase/UploadImageSetUseCase.ts +95 -0
- package/workers/file-upload/src/usecase/ValidateAuthUseCase.ts +26 -0
- package/workers/file-upload/tsconfig.json +13 -0
- 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
|
+
}
|