@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,52 @@
1
+ /**
2
+ * ListFilesUseCase - ファイル一覧の取得
3
+ */
4
+
5
+ import type { FileEntry } from '../domain/FileEntity'
6
+ import { logger } from '../infra/LoggerSingleton'
7
+ import type { R2RepositoryPort } from '../repository/R2RepositoryPort'
8
+
9
+ export type ListFilesParams = {
10
+ year?: number
11
+ }
12
+
13
+ export type ListFilesResult = {
14
+ year: number
15
+ availableYears: number[]
16
+ files: FileEntry[]
17
+ totalSize: number
18
+ totalCount: number
19
+ baseUrl: string
20
+ }
21
+
22
+ export type GetAvailableYearsResult = {
23
+ years: number[]
24
+ currentYear: number
25
+ }
26
+
27
+ export class ListFilesUseCase {
28
+ constructor(private r2Repository: R2RepositoryPort) {}
29
+
30
+ async execute(params: ListFilesParams): Promise<ListFilesResult> {
31
+ const year = params.year ?? this.r2Repository.getCurrentYear()
32
+ logger.debug('ListFilesUseCase.execute', { year })
33
+
34
+ const yearFiles = await this.r2Repository.readYearFiles(year)
35
+ const availableYears = await this.r2Repository.getAvailableYears()
36
+
37
+ return {
38
+ year,
39
+ availableYears,
40
+ files: yearFiles.files,
41
+ totalSize: yearFiles.totalSize,
42
+ totalCount: yearFiles.totalCount,
43
+ baseUrl: this.r2Repository.getBaseUrl(),
44
+ }
45
+ }
46
+
47
+ async getAvailableYears(): Promise<GetAvailableYearsResult> {
48
+ const years = await this.r2Repository.getAvailableYears()
49
+ const currentYear = this.r2Repository.getCurrentYear()
50
+ return { years, currentYear }
51
+ }
52
+ }
@@ -0,0 +1,182 @@
1
+ /**
2
+ * RebuildIndexUseCase - files.json の再構築(既存ファイルの移行も行う)
3
+ */
4
+
5
+ import type { FileEntry, YearFiles } from '../domain/FileEntity'
6
+ import { logger } from '../infra/LoggerSingleton'
7
+ import type { R2ObjectInfo, R2RepositoryPort } from '../repository/R2RepositoryPort'
8
+
9
+ export type RebuildIndexParams = {
10
+ year: number
11
+ }
12
+
13
+ export type RebuildIndexResult = {
14
+ success: boolean
15
+ year: number
16
+ totalCount: number
17
+ movedFiles: number
18
+ movedFilesList: string[]
19
+ }
20
+
21
+ export class RebuildIndexUseCase {
22
+ constructor(private r2Repository: R2RepositoryPort) {}
23
+
24
+ async execute(params: RebuildIndexParams): Promise<RebuildIndexResult> {
25
+ const { year } = params
26
+ logger.info('RebuildIndexUseCase.execute', { year })
27
+
28
+ const files: FileEntry[] = []
29
+ const movedFiles: string[] = []
30
+
31
+ // 1. 指定年のファイルを収集
32
+ const yearObjects = await this.r2Repository.list(`${year}/`)
33
+ const grouped = this.groupObjectsByUuid(yearObjects, year)
34
+
35
+ // 2. 旧形式ファイルを検索し、指定年に移行
36
+ const allObjects = await this.r2Repository.listAll()
37
+ const oldFormatGrouped = this.findOldFormatFiles(allObjects, year)
38
+
39
+ // 3. 旧形式ファイルを新形式に移行
40
+ for (const [uuid, objects] of oldFormatGrouped) {
41
+ for (const obj of objects) {
42
+ const oldKey = obj.key
43
+ const correctNewKey = `${year}/${obj.key}`
44
+
45
+ // コピーして削除
46
+ const content = await this.r2Repository.get(oldKey)
47
+ if (content) {
48
+ await this.r2Repository.upload({
49
+ key: correctNewKey,
50
+ content: content.body,
51
+ contentType: content.info.httpMetadata?.contentType || 'application/octet-stream',
52
+ customMetadata: content.info.customMetadata,
53
+ })
54
+ await this.r2Repository.delete(oldKey)
55
+ movedFiles.push(`${oldKey} -> ${correctNewKey}`)
56
+ }
57
+ }
58
+
59
+ // 移行したファイルを grouped に追加
60
+ const movedObjects = await this.r2Repository.list(`${year}/${uuid}`)
61
+ if (movedObjects.length > 0) {
62
+ grouped.set(uuid, movedObjects)
63
+ }
64
+ }
65
+
66
+ // 4. ファイルエントリを構築
67
+ for (const [uuid, objects] of grouped) {
68
+ const entry = this.buildFileEntry(uuid, objects, year)
69
+ if (entry) {
70
+ files.push(entry)
71
+ }
72
+ }
73
+
74
+ // 5. files.json を書き込み
75
+ const yearFiles: YearFiles = {
76
+ year,
77
+ updatedAt: new Date().toISOString(),
78
+ totalCount: files.length,
79
+ totalSize: files.reduce((sum, f) => sum + f.size, 0),
80
+ files,
81
+ }
82
+ await this.r2Repository.writeYearFiles(year, yearFiles)
83
+
84
+ logger.info('RebuildIndexUseCase.execute completed', {
85
+ year,
86
+ totalCount: files.length,
87
+ movedFilesCount: movedFiles.length,
88
+ })
89
+
90
+ return {
91
+ success: true,
92
+ year,
93
+ totalCount: files.length,
94
+ movedFiles: movedFiles.length,
95
+ movedFilesList: movedFiles,
96
+ }
97
+ }
98
+
99
+ private groupObjectsByUuid(objects: R2ObjectInfo[], year: number): Map<string, R2ObjectInfo[]> {
100
+ const grouped = new Map<string, R2ObjectInfo[]>()
101
+
102
+ for (const obj of objects) {
103
+ // files.json は除外
104
+ if (obj.key === `${year}/files.json`) continue
105
+
106
+ const pathAfterYear = obj.key.slice(`${year}/`.length)
107
+ const uuid = pathAfterYear.includes('/') ? pathAfterYear.split('/')[0] : pathAfterYear.split('.')[0]
108
+
109
+ if (!grouped.has(uuid)) grouped.set(uuid, [])
110
+ grouped.get(uuid)!.push(obj)
111
+ }
112
+
113
+ return grouped
114
+ }
115
+
116
+ private findOldFormatFiles(objects: R2ObjectInfo[], year: number): Map<string, R2ObjectInfo[]> {
117
+ const grouped = new Map<string, R2ObjectInfo[]>()
118
+
119
+ for (const obj of objects) {
120
+ // 既に年フォルダに入っているものは除外
121
+ if (/^\d{4}\//.test(obj.key)) continue
122
+
123
+ const uuid = obj.key.includes('/') ? obj.key.split('/')[0] : obj.key.split('.')[0]
124
+ const fileYear = obj.uploaded.getFullYear()
125
+
126
+ // 指定年のファイルのみ処理
127
+ if (fileYear === year) {
128
+ if (!grouped.has(uuid)) grouped.set(uuid, [])
129
+ grouped.get(uuid)!.push(obj)
130
+ }
131
+ }
132
+
133
+ return grouped
134
+ }
135
+
136
+ private buildFileEntry(uuid: string, objects: R2ObjectInfo[], year: number): FileEntry | null {
137
+ const isImageSet = objects.some((o) => {
138
+ const pathAfterYear = o.key.slice(`${year}/`.length)
139
+ return pathAfterYear.includes('/')
140
+ })
141
+
142
+ if (isImageSet) {
143
+ const variants: Record<string, string> = {}
144
+ let totalSize = 0
145
+ let uploadedAt = ''
146
+ let originalName = ''
147
+ let uploadedBy = 'unknown'
148
+
149
+ for (const obj of objects) {
150
+ const pathAfterYear = obj.key.slice(`${year}/`.length)
151
+ const preset = pathAfterYear.split('/')[1]?.replace('.webp', '')
152
+ if (preset) variants[preset] = obj.key
153
+ totalSize += obj.size
154
+ uploadedAt = obj.uploaded.toISOString()
155
+ originalName = obj.customMetadata?.originalName || uuid
156
+ uploadedBy = obj.customMetadata?.uploadedBy || 'unknown'
157
+ }
158
+
159
+ return {
160
+ uuid,
161
+ originalName,
162
+ contentType: 'image/webp',
163
+ size: totalSize,
164
+ uploadedBy,
165
+ uploadedAt,
166
+ url: variants.thumbnail || variants.small || variants.medium || variants.large || variants.original || '',
167
+ variants,
168
+ }
169
+ } else {
170
+ const obj = objects[0]
171
+ return {
172
+ uuid,
173
+ originalName: obj.customMetadata?.originalName || obj.key,
174
+ contentType: obj.httpMetadata?.contentType || 'application/octet-stream',
175
+ size: obj.size,
176
+ uploadedBy: obj.customMetadata?.uploadedBy || 'unknown',
177
+ uploadedAt: obj.uploaded.toISOString(),
178
+ url: obj.key,
179
+ }
180
+ }
181
+ }
182
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * UpdateFileMetadataUseCase - ファイルメタデータの更新(alt等)
3
+ */
4
+
5
+ import type { FileEntry } from '../domain/FileEntity'
6
+ import { logger } from '../infra/LoggerSingleton'
7
+ import type { R2RepositoryPort } from '../repository/R2RepositoryPort'
8
+
9
+ export type UpdateFileMetadataParams = {
10
+ uuid: string
11
+ year: number
12
+ alt?: string
13
+ }
14
+
15
+ export type UpdateFileMetadataResult = FileEntry
16
+
17
+ export class UpdateFileMetadataUseCase {
18
+ constructor(private r2Repository: R2RepositoryPort) {}
19
+
20
+ async execute(params: UpdateFileMetadataParams): Promise<UpdateFileMetadataResult> {
21
+ logger.info('UpdateFileMetadataUseCase.execute', params)
22
+
23
+ const yearFiles = await this.r2Repository.readYearFiles(params.year)
24
+ const fileIndex = yearFiles.files.findIndex((f) => f.uuid === params.uuid)
25
+
26
+ if (fileIndex === -1) {
27
+ logger.warn('UpdateFileMetadataUseCase.execute', 'File not found', { uuid: params.uuid })
28
+ throw new Error('File not found')
29
+ }
30
+
31
+ // alt を更新(空文字は undefined にする)
32
+ if (params.alt !== undefined) {
33
+ yearFiles.files[fileIndex].alt = params.alt || undefined
34
+ }
35
+
36
+ yearFiles.updatedAt = new Date().toISOString()
37
+ await this.r2Repository.writeYearFiles(params.year, yearFiles)
38
+
39
+ logger.info('UpdateFileMetadataUseCase.execute completed', { uuid: params.uuid })
40
+
41
+ return yearFiles.files[fileIndex]
42
+ }
43
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * UploadFileUseCase - 単一ファイルのアップロード
3
+ */
4
+
5
+ import { createFileEntry, recalculateYearFilesStats, type FileEntry } from '../domain/FileEntity'
6
+ import { logger } from '../infra/LoggerSingleton'
7
+ import type { R2RepositoryPort } from '../repository/R2RepositoryPort'
8
+
9
+ export type UploadFileParams = {
10
+ file: File
11
+ uploadedBy: string
12
+ }
13
+
14
+ export type UploadFileResult = FileEntry
15
+
16
+ export class UploadFileUseCase {
17
+ constructor(private r2Repository: R2RepositoryPort) {}
18
+
19
+ async execute(params: UploadFileParams): Promise<UploadFileResult> {
20
+ logger.info('UploadFileUseCase.execute', {
21
+ fileName: params.file.name,
22
+ uploadedBy: params.uploadedBy,
23
+ })
24
+
25
+ const uuid = crypto.randomUUID()
26
+ const year = this.r2Repository.getCurrentYear()
27
+ const extension = params.file.name.split('.').pop() || ''
28
+ const key = extension ? `${year}/${uuid}.${extension}` : `${year}/${uuid}`
29
+ const uploadedAt = new Date().toISOString()
30
+
31
+ // R2 にアップロード
32
+ await this.r2Repository.upload({
33
+ key,
34
+ content: params.file.stream(),
35
+ contentType: params.file.type,
36
+ customMetadata: {
37
+ originalName: params.file.name,
38
+ uploadedBy: params.uploadedBy,
39
+ },
40
+ })
41
+
42
+ // files.json を更新(url にはキーのみ保存)
43
+ const yearFiles = await this.r2Repository.readYearFiles(year)
44
+ const newEntry = createFileEntry({
45
+ uuid,
46
+ originalName: params.file.name,
47
+ contentType: params.file.type,
48
+ size: params.file.size,
49
+ uploadedBy: params.uploadedBy,
50
+ uploadedAt,
51
+ publicUrl: key,
52
+ })
53
+
54
+ yearFiles.files.push(newEntry)
55
+ const updatedYearFiles = recalculateYearFilesStats(yearFiles)
56
+ await this.r2Repository.writeYearFiles(year, updatedYearFiles)
57
+
58
+ logger.info('UploadFileUseCase.execute completed', { uuid })
59
+
60
+ return newEntry
61
+ }
62
+ }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * UploadImageSetUseCase - 画像セット(複数プリセット)のアップロード
3
+ */
4
+
5
+ import {
6
+ createFileEntry,
7
+ PRESET_NAMES,
8
+ recalculateYearFilesStats,
9
+ selectThumbnailUrl,
10
+ type FileEntry,
11
+ type PresetName,
12
+ } from '../domain/FileEntity'
13
+ import { logger } from '../infra/LoggerSingleton'
14
+ import type { R2RepositoryPort } from '../repository/R2RepositoryPort'
15
+
16
+ export type UploadImageSetParams = {
17
+ originalName: string
18
+ uploadedBy: string
19
+ files: Map<PresetName, File>
20
+ width?: number
21
+ height?: number
22
+ }
23
+
24
+ export type UploadImageSetResult = FileEntry & {
25
+ variants: Record<string, string>
26
+ }
27
+
28
+ export class UploadImageSetUseCase {
29
+ constructor(private r2Repository: R2RepositoryPort) {}
30
+
31
+ async execute(params: UploadImageSetParams): Promise<UploadImageSetResult> {
32
+ logger.info('UploadImageSetUseCase.execute', {
33
+ originalName: params.originalName,
34
+ uploadedBy: params.uploadedBy,
35
+ presetCount: params.files.size,
36
+ })
37
+
38
+ const uuid = crypto.randomUUID()
39
+ const year = this.r2Repository.getCurrentYear()
40
+ const uploadedAt = new Date().toISOString()
41
+ const variants: Record<string, string> = {}
42
+ let totalSize = 0
43
+
44
+ // 各プリセットをアップロード
45
+ for (const name of PRESET_NAMES) {
46
+ const file = params.files.get(name)
47
+ if (file) {
48
+ const key = `${year}/${uuid}/${name}.webp`
49
+ await this.r2Repository.upload({
50
+ key,
51
+ content: file.stream(),
52
+ contentType: 'image/webp',
53
+ customMetadata: {
54
+ originalName: params.originalName,
55
+ uploadedBy: params.uploadedBy,
56
+ },
57
+ })
58
+ variants[name] = key
59
+ totalSize += file.size
60
+ }
61
+ }
62
+
63
+ const thumbnailUrl = selectThumbnailUrl(variants)
64
+
65
+ // files.json を更新
66
+ const yearFiles = await this.r2Repository.readYearFiles(year)
67
+ const newEntry = createFileEntry({
68
+ uuid,
69
+ originalName: params.originalName,
70
+ contentType: 'image/webp',
71
+ size: totalSize,
72
+ uploadedBy: params.uploadedBy,
73
+ uploadedAt,
74
+ publicUrl: thumbnailUrl,
75
+ width: params.width,
76
+ height: params.height,
77
+ variants,
78
+ })
79
+
80
+ yearFiles.files.push(newEntry)
81
+ const updatedYearFiles = recalculateYearFilesStats(yearFiles)
82
+ await this.r2Repository.writeYearFiles(year, updatedYearFiles)
83
+
84
+ logger.info('UploadImageSetUseCase.execute completed', { uuid, variantCount: Object.keys(variants).length })
85
+
86
+ return newEntry as UploadImageSetResult
87
+ }
88
+
89
+ /**
90
+ * プリウォーム用の URL 一覧を取得
91
+ */
92
+ getPrewarmUrls(variants: Record<string, string>): string[] {
93
+ return Object.values(variants)
94
+ }
95
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * ValidateAuthUseCase - Authorization ヘッダーからトークンを検証する
3
+ */
4
+
5
+ import { AuthError } from '../domain/AuthEntity'
6
+ import type { GitHubUser } from '../domain/AuthEntity'
7
+ import type { AuthRepositoryPort } from '../repository/AuthRepositoryPort'
8
+
9
+ export class ValidateAuthUseCase {
10
+ constructor(private authRepository: AuthRepositoryPort) {}
11
+
12
+ async execute(authHeader: string | null): Promise<GitHubUser> {
13
+ if (!authHeader || !authHeader.startsWith('Bearer ')) {
14
+ throw new AuthError('Missing or invalid Authorization header', 401)
15
+ }
16
+
17
+ const token = authHeader.slice(7)
18
+ const user = await this.authRepository.validateToken(token)
19
+
20
+ if (!user) {
21
+ throw new AuthError('Invalid token', 401)
22
+ }
23
+
24
+ return user
25
+ }
26
+ }
@@ -0,0 +1,13 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "Bundler",
6
+ "lib": ["ES2022"],
7
+ "types": ["@cloudflare/workers-types"],
8
+ "strict": true,
9
+ "noEmit": true,
10
+ "skipLibCheck": true
11
+ },
12
+ "include": ["src/**/*"]
13
+ }
@@ -0,0 +1,11 @@
1
+ name = "frelio-file-upload"
2
+ main = "src/index.ts"
3
+ compatibility_date = "2024-02-08"
4
+
5
+ [[r2_buckets]]
6
+ binding = "R2"
7
+ bucket_name = "<your-r2-bucket-name>"
8
+
9
+ [vars]
10
+ R2_PUBLIC_URL = "<your-r2-public-url>"
11
+ ALLOWED_ORIGINS = "<comma-separated-allowed-origins>"