@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,162 @@
1
+ /**
2
+ * LoggerSingleton - Cloudflare Workers 用のシングルトンログ
3
+ *
4
+ * console.log を直接使用せず、このクラスを通してログを出力する。
5
+ * クリーンアーキテクチャのルールに従い、全レイヤーから使用可能。
6
+ *
7
+ * Workers 環境では import.meta.env が使えないため、
8
+ * 環境変数の判定方法が異なる。
9
+ */
10
+
11
+ export type LogLevel = 'debug' | 'info' | 'warn' | 'error'
12
+
13
+ export type LogEntry = {
14
+ level: LogLevel
15
+ timestamp: string
16
+ context: string
17
+ message?: string
18
+ data?: unknown
19
+ requestId?: string
20
+ }
21
+
22
+ export type LogHandler = (entry: LogEntry) => void
23
+
24
+ const LOG_LEVEL_PRIORITY: Record<LogLevel, number> = {
25
+ debug: 0,
26
+ info: 1,
27
+ warn: 2,
28
+ error: 3,
29
+ }
30
+
31
+ export class LoggerSingleton {
32
+ private static instance: LoggerSingleton | null = null
33
+ private minLevel: LogLevel = 'info'
34
+ private handlers: LogHandler[] = []
35
+ private requestId: string | null = null
36
+
37
+ private constructor() {
38
+ // Workers ではデフォルトで info レベル
39
+ }
40
+
41
+ static getInstance(): LoggerSingleton {
42
+ if (!LoggerSingleton.instance) {
43
+ LoggerSingleton.instance = new LoggerSingleton()
44
+ }
45
+ return LoggerSingleton.instance
46
+ }
47
+
48
+ /**
49
+ * リクエストごとに requestId を設定(トレーシング用)
50
+ */
51
+ setRequestId(requestId: string): void {
52
+ this.requestId = requestId
53
+ }
54
+
55
+ clearRequestId(): void {
56
+ this.requestId = null
57
+ }
58
+
59
+ setLevel(level: LogLevel): void {
60
+ this.minLevel = level
61
+ }
62
+
63
+ getLevel(): LogLevel {
64
+ return this.minLevel
65
+ }
66
+
67
+ addHandler(handler: LogHandler): () => void {
68
+ this.handlers.push(handler)
69
+ return () => {
70
+ const index = this.handlers.indexOf(handler)
71
+ if (index > -1) {
72
+ this.handlers.splice(index, 1)
73
+ }
74
+ }
75
+ }
76
+
77
+ debug(context: string, messageOrData?: string | unknown, data?: unknown): void {
78
+ this.log('debug', context, messageOrData, data)
79
+ }
80
+
81
+ info(context: string, messageOrData?: string | unknown, data?: unknown): void {
82
+ this.log('info', context, messageOrData, data)
83
+ }
84
+
85
+ warn(context: string, messageOrData?: string | unknown, data?: unknown): void {
86
+ this.log('warn', context, messageOrData, data)
87
+ }
88
+
89
+ error(context: string, messageOrData?: string | unknown, data?: unknown): void {
90
+ this.log('error', context, messageOrData, data)
91
+ }
92
+
93
+ private log(
94
+ level: LogLevel,
95
+ context: string,
96
+ messageOrData?: string | unknown,
97
+ data?: unknown
98
+ ): void {
99
+ if (!this.shouldLog(level)) return
100
+
101
+ const entry: LogEntry = {
102
+ level,
103
+ timestamp: new Date().toISOString(),
104
+ context,
105
+ message: typeof messageOrData === 'string' ? messageOrData : undefined,
106
+ data: typeof messageOrData === 'string' ? data : messageOrData,
107
+ requestId: this.requestId ?? undefined,
108
+ }
109
+
110
+ // Workers 環境では JSON 形式でログ出力(Cloudflare のログ収集と互換性あり)
111
+ this.logToConsole(entry)
112
+
113
+ // カスタムハンドラに通知
114
+ this.handlers.forEach((handler) => {
115
+ try {
116
+ handler(entry)
117
+ } catch {
118
+ // ハンドラのエラーは無視
119
+ }
120
+ })
121
+ }
122
+
123
+ private shouldLog(level: LogLevel): boolean {
124
+ return LOG_LEVEL_PRIORITY[level] >= LOG_LEVEL_PRIORITY[this.minLevel]
125
+ }
126
+
127
+ private logToConsole(entry: LogEntry): void {
128
+ // Cloudflare Workers のログは JSON 形式が推奨
129
+ const logObject = {
130
+ level: entry.level,
131
+ ts: entry.timestamp,
132
+ ctx: entry.context,
133
+ ...(entry.message && { msg: entry.message }),
134
+ ...(entry.data && { data: entry.data }),
135
+ ...(entry.requestId && { reqId: entry.requestId }),
136
+ }
137
+
138
+ switch (entry.level) {
139
+ case 'debug':
140
+ // eslint-disable-next-line no-console
141
+ console.debug(JSON.stringify(logObject))
142
+ break
143
+ case 'info':
144
+ // eslint-disable-next-line no-console
145
+ console.info(JSON.stringify(logObject))
146
+ break
147
+ case 'warn':
148
+ // eslint-disable-next-line no-console
149
+ console.warn(JSON.stringify(logObject))
150
+ break
151
+ case 'error':
152
+ // eslint-disable-next-line no-console
153
+ console.error(JSON.stringify(logObject))
154
+ break
155
+ }
156
+ }
157
+ }
158
+
159
+ /**
160
+ * グローバルログ関数(シングルトンのショートカット)
161
+ */
162
+ export const logger = LoggerSingleton.getInstance()
@@ -0,0 +1,86 @@
1
+ /**
2
+ * HttpPresenterImpl - HTTP レスポンスの整形
3
+ *
4
+ * UseCase の結果を HTTP Response に変換する。
5
+ */
6
+
7
+ export type CorsHeaders = Record<string, string>
8
+
9
+ export class HttpPresenterImpl {
10
+ private corsHeaders: CorsHeaders
11
+
12
+ constructor(corsHeaders?: CorsHeaders) {
13
+ this.corsHeaders = corsHeaders ?? {
14
+ 'Access-Control-Allow-Origin': '*',
15
+ 'Access-Control-Allow-Methods': 'GET, POST, PATCH, DELETE, OPTIONS',
16
+ 'Access-Control-Allow-Headers': 'Content-Type, Authorization',
17
+ }
18
+ }
19
+
20
+ /**
21
+ * CORS プリフライトレスポンス
22
+ */
23
+ cors(): Response {
24
+ return new Response(null, { headers: this.corsHeaders })
25
+ }
26
+
27
+ /**
28
+ * 成功レスポンス(200)
29
+ */
30
+ ok<T>(data: T): Response {
31
+ return new Response(JSON.stringify(data), {
32
+ status: 200,
33
+ headers: { ...this.corsHeaders, 'Content-Type': 'application/json' },
34
+ })
35
+ }
36
+
37
+ /**
38
+ * 作成成功レスポンス(201)
39
+ */
40
+ created<T>(data: T): Response {
41
+ return new Response(JSON.stringify(data), {
42
+ status: 201,
43
+ headers: { ...this.corsHeaders, 'Content-Type': 'application/json' },
44
+ })
45
+ }
46
+
47
+ /**
48
+ * バリデーションエラー(400)
49
+ */
50
+ badRequest(message: string): Response {
51
+ return new Response(JSON.stringify({ error: message }), {
52
+ status: 400,
53
+ headers: { ...this.corsHeaders, 'Content-Type': 'application/json' },
54
+ })
55
+ }
56
+
57
+ /**
58
+ * 認証エラー(401)
59
+ */
60
+ unauthorized(message = 'Unauthorized'): Response {
61
+ return new Response(JSON.stringify({ error: message }), {
62
+ status: 401,
63
+ headers: { ...this.corsHeaders, 'Content-Type': 'application/json' },
64
+ })
65
+ }
66
+
67
+ /**
68
+ * 見つからないエラー(404)
69
+ */
70
+ notFound(message = 'Not found'): Response {
71
+ return new Response(JSON.stringify({ error: message }), {
72
+ status: 404,
73
+ headers: { ...this.corsHeaders, 'Content-Type': 'application/json' },
74
+ })
75
+ }
76
+
77
+ /**
78
+ * サーバーエラー(500)
79
+ */
80
+ serverError(message: string): Response {
81
+ return new Response(JSON.stringify({ error: message }), {
82
+ status: 500,
83
+ headers: { ...this.corsHeaders, 'Content-Type': 'application/json' },
84
+ })
85
+ }
86
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * AuthRepositoryPort - トークン検証のインターフェース
3
+ *
4
+ * GitHub API への依存を封印し、UseCase 層から分離する。
5
+ */
6
+
7
+ import type { GitHubUser } from '../domain/AuthEntity'
8
+
9
+ export type AuthRepositoryPort = {
10
+ validateToken(token: string): Promise<GitHubUser | null>
11
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * GitHubAuthRepositoryImpl - GitHub API を使ったトークン検証
3
+ *
4
+ * Bearer トークンで GitHub /user API を呼び出し、ユーザー情報を取得する。
5
+ * モジュールレベルの Map でトークンをキャッシュ(TTL 5分)。
6
+ */
7
+
8
+ import type { GitHubUser } from '../domain/AuthEntity'
9
+ import type { AuthRepositoryPort } from './AuthRepositoryPort'
10
+ import { logger } from '../infra/LoggerSingleton'
11
+
12
+ type CacheEntry = {
13
+ user: GitHubUser
14
+ expiresAt: number
15
+ }
16
+
17
+ const tokenCache = new Map<string, CacheEntry>()
18
+ const CACHE_TTL_MS = 5 * 60 * 1000
19
+
20
+ export class GitHubAuthRepositoryImpl implements AuthRepositoryPort {
21
+ async validateToken(token: string): Promise<GitHubUser | null> {
22
+ const cached = tokenCache.get(token)
23
+ if (cached && cached.expiresAt > Date.now()) {
24
+ return cached.user
25
+ }
26
+
27
+ try {
28
+ const res = await fetch('https://api.github.com/user', {
29
+ headers: {
30
+ Authorization: `Bearer ${token}`,
31
+ Accept: 'application/vnd.github.v3+json',
32
+ 'User-Agent': 'frelio-file-upload',
33
+ },
34
+ })
35
+
36
+ if (!res.ok) {
37
+ logger.warn('GitHubAuthRepositoryImpl.validateToken', {
38
+ status: res.status,
39
+ })
40
+ tokenCache.delete(token)
41
+ return null
42
+ }
43
+
44
+ const data = (await res.json()) as { login: string }
45
+ const user: GitHubUser = { login: data.login }
46
+
47
+ tokenCache.set(token, {
48
+ user,
49
+ expiresAt: Date.now() + CACHE_TTL_MS,
50
+ })
51
+
52
+ return user
53
+ } catch (error) {
54
+ logger.error('GitHubAuthRepositoryImpl.validateToken', { error })
55
+ return null
56
+ }
57
+ }
58
+ }
@@ -0,0 +1,145 @@
1
+ /**
2
+ * R2RepositoryImpl - R2 ストレージ操作の実装
3
+ *
4
+ * Cloudflare R2 バケットへのアクセスを封印する。
5
+ */
6
+
7
+ import { createEmptyYearFiles, type YearFiles } from '../domain/FileEntity'
8
+ import { logger } from '../infra/LoggerSingleton'
9
+ import type { R2ObjectInfo, R2RepositoryPort, UploadFileParams } from './R2RepositoryPort'
10
+
11
+ export class R2RepositoryImpl implements R2RepositoryPort {
12
+ constructor(
13
+ private bucket: R2Bucket,
14
+ private publicUrl: string
15
+ ) {}
16
+
17
+ async upload(params: UploadFileParams): Promise<void> {
18
+ logger.debug('R2RepositoryImpl.upload', { key: params.key })
19
+ await this.bucket.put(params.key, params.content, {
20
+ httpMetadata: {
21
+ contentType: params.contentType,
22
+ },
23
+ customMetadata: params.customMetadata,
24
+ })
25
+ }
26
+
27
+ async delete(key: string): Promise<void> {
28
+ logger.debug('R2RepositoryImpl.delete', { key })
29
+ await this.bucket.delete(key)
30
+ }
31
+
32
+ async deleteMany(keys: string[]): Promise<void> {
33
+ logger.debug('R2RepositoryImpl.deleteMany', { count: keys.length })
34
+ await Promise.all(keys.map((key) => this.bucket.delete(key)))
35
+ }
36
+
37
+ async head(key: string): Promise<R2ObjectInfo | null> {
38
+ const obj = await this.bucket.head(key)
39
+ if (!obj) return null
40
+ return this.toObjectInfo(obj)
41
+ }
42
+
43
+ async get(key: string): Promise<{ body: ReadableStream; info: R2ObjectInfo } | null> {
44
+ const obj = await this.bucket.get(key)
45
+ if (!obj) return null
46
+ return {
47
+ body: obj.body,
48
+ info: this.toObjectInfo(obj),
49
+ }
50
+ }
51
+
52
+ async list(prefix: string): Promise<R2ObjectInfo[]> {
53
+ const result: R2ObjectInfo[] = []
54
+ let cursor: string | undefined
55
+
56
+ do {
57
+ const listed = await this.bucket.list({
58
+ prefix,
59
+ cursor,
60
+ include: ['httpMetadata', 'customMetadata'],
61
+ } as R2ListOptions)
62
+
63
+ for (const obj of listed.objects) {
64
+ result.push(this.toObjectInfo(obj))
65
+ }
66
+
67
+ cursor = listed.truncated ? listed.cursor : undefined
68
+ } while (cursor)
69
+
70
+ return result
71
+ }
72
+
73
+ async listAll(): Promise<R2ObjectInfo[]> {
74
+ const result: R2ObjectInfo[] = []
75
+ let cursor: string | undefined
76
+
77
+ do {
78
+ const listed = await this.bucket.list({
79
+ cursor,
80
+ include: ['httpMetadata', 'customMetadata'],
81
+ } as R2ListOptions)
82
+
83
+ for (const obj of listed.objects) {
84
+ result.push(this.toObjectInfo(obj))
85
+ }
86
+
87
+ cursor = listed.truncated ? listed.cursor : undefined
88
+ } while (cursor)
89
+
90
+ return result
91
+ }
92
+
93
+ async readYearFiles(year: number): Promise<YearFiles> {
94
+ const key = `${year}/files.json`
95
+ const obj = await this.bucket.get(key)
96
+ if (!obj) {
97
+ return createEmptyYearFiles(year)
98
+ }
99
+ return JSON.parse(await obj.text())
100
+ }
101
+
102
+ async writeYearFiles(year: number, data: YearFiles): Promise<void> {
103
+ const key = `${year}/files.json`
104
+ logger.debug('R2RepositoryImpl.writeYearFiles', { year, fileCount: data.files.length })
105
+ await this.bucket.put(key, JSON.stringify(data, null, 2), {
106
+ httpMetadata: { contentType: 'application/json' },
107
+ })
108
+ }
109
+
110
+ async getAvailableYears(): Promise<number[]> {
111
+ const years = new Set<number>()
112
+ const objects = await this.listAll()
113
+
114
+ for (const obj of objects) {
115
+ const match = obj.key.match(/^(\d{4})\//)
116
+ if (match) {
117
+ years.add(parseInt(match[1]))
118
+ }
119
+ }
120
+
121
+ return Array.from(years).sort((a, b) => b - a) // 降順
122
+ }
123
+
124
+ getCurrentYear(): number {
125
+ return new Date().getFullYear()
126
+ }
127
+
128
+ getBaseUrl(): string {
129
+ return this.publicUrl
130
+ }
131
+
132
+ private toObjectInfo(obj: R2Object | R2ObjectBody): R2ObjectInfo {
133
+ return {
134
+ key: obj.key,
135
+ size: obj.size,
136
+ uploaded: obj.uploaded,
137
+ httpMetadata: obj.httpMetadata
138
+ ? {
139
+ contentType: obj.httpMetadata.contentType,
140
+ }
141
+ : undefined,
142
+ customMetadata: obj.customMetadata,
143
+ }
144
+ }
145
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * R2RepositoryPort - R2 ストレージ操作のインターフェース
3
+ *
4
+ * R2 への依存を封印し、UseCase 層から分離する。
5
+ */
6
+
7
+ import type { FileEntry, YearFiles } from '../domain/FileEntity'
8
+
9
+ export type UploadFileParams = {
10
+ key: string
11
+ content: ReadableStream
12
+ contentType: string
13
+ customMetadata?: Record<string, string>
14
+ }
15
+
16
+ export type R2ObjectInfo = {
17
+ key: string
18
+ size: number
19
+ uploaded: Date
20
+ httpMetadata?: {
21
+ contentType?: string
22
+ }
23
+ customMetadata?: Record<string, string>
24
+ }
25
+
26
+ export interface R2RepositoryPort {
27
+ /**
28
+ * ファイルをアップロード
29
+ */
30
+ upload(params: UploadFileParams): Promise<void>
31
+
32
+ /**
33
+ * ファイルを削除
34
+ */
35
+ delete(key: string): Promise<void>
36
+
37
+ /**
38
+ * 複数ファイルを削除
39
+ */
40
+ deleteMany(keys: string[]): Promise<void>
41
+
42
+ /**
43
+ * ファイルのメタデータを取得
44
+ */
45
+ head(key: string): Promise<R2ObjectInfo | null>
46
+
47
+ /**
48
+ * ファイルの内容を取得
49
+ */
50
+ get(key: string): Promise<{ body: ReadableStream; info: R2ObjectInfo } | null>
51
+
52
+ /**
53
+ * プレフィックスでファイル一覧を取得
54
+ */
55
+ list(prefix: string): Promise<R2ObjectInfo[]>
56
+
57
+ /**
58
+ * 全ファイル一覧を取得(ページネーション対応)
59
+ */
60
+ listAll(): Promise<R2ObjectInfo[]>
61
+
62
+ /**
63
+ * 年別インデックスファイル(files.json)を読み込み
64
+ */
65
+ readYearFiles(year: number): Promise<YearFiles>
66
+
67
+ /**
68
+ * 年別インデックスファイル(files.json)を書き込み
69
+ */
70
+ writeYearFiles(year: number, data: YearFiles): Promise<void>
71
+
72
+ /**
73
+ * 利用可能な年一覧を取得
74
+ */
75
+ getAvailableYears(): Promise<number[]>
76
+
77
+ /**
78
+ * 現在の年を取得
79
+ */
80
+ getCurrentYear(): number
81
+
82
+ /**
83
+ * ストレージのベース URL を取得
84
+ */
85
+ getBaseUrl(): string
86
+ }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * DeleteFileUseCase - ファイルの削除
3
+ */
4
+
5
+ import { recalculateYearFilesStats } from '../domain/FileEntity'
6
+ import { logger } from '../infra/LoggerSingleton'
7
+ import type { R2RepositoryPort } from '../repository/R2RepositoryPort'
8
+
9
+ export type DeleteFileParams = {
10
+ uuid: string
11
+ year?: number
12
+ }
13
+
14
+ export type DeleteFileResult = {
15
+ success: boolean
16
+ uuid: string
17
+ }
18
+
19
+ export class DeleteFileUseCase {
20
+ constructor(private r2Repository: R2RepositoryPort) {}
21
+
22
+ async execute(params: DeleteFileParams): Promise<DeleteFileResult> {
23
+ logger.info('DeleteFileUseCase.execute', params)
24
+
25
+ // 年が指定されている場合: 新形式
26
+ if (params.year) {
27
+ const deleted = await this.deleteFromYear(params.uuid, params.year)
28
+ if (deleted) {
29
+ return { success: true, uuid: params.uuid }
30
+ }
31
+ }
32
+
33
+ // 年が指定されていない場合: 旧形式(後方互換)
34
+ const deleted = await this.deleteOldFormat(params.uuid)
35
+ if (deleted) {
36
+ return { success: true, uuid: params.uuid }
37
+ }
38
+
39
+ logger.warn('DeleteFileUseCase.execute', 'File not found', { uuid: params.uuid })
40
+ throw new Error('File not found')
41
+ }
42
+
43
+ private async deleteFromYear(uuid: string, year: number): Promise<boolean> {
44
+ // 画像セット形式: {year}/{uuid}/
45
+ const setObjects = await this.r2Repository.list(`${year}/${uuid}/`)
46
+ if (setObjects.length > 0) {
47
+ await this.r2Repository.deleteMany(setObjects.map((o) => o.key))
48
+ await this.removeFromYearFiles(uuid, year)
49
+ logger.debug('DeleteFileUseCase', 'Deleted image set', { uuid, year })
50
+ return true
51
+ }
52
+
53
+ // 単一ファイル形式: {year}/{uuid}.ext
54
+ const singleObjects = await this.r2Repository.list(`${year}/${uuid}`)
55
+ for (const obj of singleObjects) {
56
+ if (obj.key.startsWith(`${year}/${uuid}.`) || obj.key === `${year}/${uuid}`) {
57
+ await this.r2Repository.delete(obj.key)
58
+ await this.removeFromYearFiles(uuid, year)
59
+ logger.debug('DeleteFileUseCase', 'Deleted single file', { uuid, year })
60
+ return true
61
+ }
62
+ }
63
+
64
+ return false
65
+ }
66
+
67
+ private async deleteOldFormat(uuid: string): Promise<boolean> {
68
+ // 旧形式の画像セット: {uuid}/
69
+ const setObjects = await this.r2Repository.list(`${uuid}/`)
70
+ if (setObjects.length > 0) {
71
+ await this.r2Repository.deleteMany(setObjects.map((o) => o.key))
72
+ logger.debug('DeleteFileUseCase', 'Deleted old format image set', { uuid })
73
+ return true
74
+ }
75
+
76
+ // 旧形式の単一ファイル: {uuid}.ext
77
+ const extensions = ['', '.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg', '.pdf']
78
+ for (const ext of extensions) {
79
+ const key = uuid + ext
80
+ const obj = await this.r2Repository.head(key)
81
+ if (obj) {
82
+ await this.r2Repository.delete(key)
83
+ logger.debug('DeleteFileUseCase', 'Deleted old format single file', { uuid, key })
84
+ return true
85
+ }
86
+ }
87
+
88
+ return false
89
+ }
90
+
91
+ private async removeFromYearFiles(uuid: string, year: number): Promise<void> {
92
+ const yearFiles = await this.r2Repository.readYearFiles(year)
93
+ yearFiles.files = yearFiles.files.filter((f) => f.uuid !== uuid)
94
+ const updatedYearFiles = recalculateYearFilesStats(yearFiles)
95
+ await this.r2Repository.writeYearFiles(year, updatedYearFiles)
96
+ }
97
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * GetFileUseCase - ファイル情報の取得
3
+ */
4
+
5
+ import { logger } from '../infra/LoggerSingleton'
6
+ import type { R2RepositoryPort } from '../repository/R2RepositoryPort'
7
+
8
+ export type GetFileParams = {
9
+ uuid: string
10
+ }
11
+
12
+ export type GetFileResult = {
13
+ uuid: string
14
+ key: string
15
+ url: string
16
+ size: number
17
+ mimeType?: string
18
+ originalName?: string
19
+ uploadedBy?: string
20
+ }
21
+
22
+ export class GetFileUseCase {
23
+ constructor(private r2Repository: R2RepositoryPort) {}
24
+
25
+ async execute(params: GetFileParams): Promise<GetFileResult> {
26
+ logger.debug('GetFileUseCase.execute', { uuid: params.uuid })
27
+
28
+ // 拡張子なしと一般的な拡張子で試行
29
+ const extensions = ['', '.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg', '.pdf']
30
+
31
+ for (const ext of extensions) {
32
+ const key = params.uuid + ext
33
+ const obj = await this.r2Repository.head(key)
34
+ if (obj) {
35
+ return {
36
+ uuid: params.uuid,
37
+ key,
38
+ url: key,
39
+ size: obj.size,
40
+ mimeType: obj.httpMetadata?.contentType,
41
+ originalName: obj.customMetadata?.originalName,
42
+ uploadedBy: obj.customMetadata?.uploadedBy,
43
+ }
44
+ }
45
+ }
46
+
47
+ logger.warn('GetFileUseCase.execute', 'File not found', { uuid: params.uuid })
48
+ throw new Error('File not found')
49
+ }
50
+ }