@c-time/frelio-cms 1.3.6 → 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/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 -3
- 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,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,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
|
+
}
|