@gencow/core 0.1.24 → 0.1.25
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/crud.d.ts +2 -2
- package/dist/crud.js +225 -208
- package/dist/index.d.ts +5 -5
- package/dist/index.js +2 -2
- package/dist/reactive.js +10 -3
- package/dist/retry.js +1 -1
- package/dist/rls-db.d.ts +2 -2
- package/dist/rls-db.js +1 -5
- package/dist/scheduler.d.ts +2 -0
- package/dist/scheduler.js +16 -6
- package/dist/server.d.ts +0 -1
- package/dist/server.js +0 -1
- package/dist/storage.js +29 -22
- package/dist/v.d.ts +2 -2
- package/dist/workflow.js +4 -11
- package/dist/workflows-api.js +5 -12
- package/package.json +46 -42
- package/src/__tests__/auth.test.ts +90 -86
- package/src/__tests__/crons.test.ts +69 -67
- package/src/__tests__/crud-codegen-integration.test.ts +164 -170
- package/src/__tests__/crud-owner-rls.test.ts +308 -301
- package/src/__tests__/crud.test.ts +694 -711
- package/src/__tests__/dist-exports.test.ts +120 -120
- package/src/__tests__/fixtures/basic/auth.ts +16 -16
- package/src/__tests__/fixtures/basic/drizzle.config.ts +1 -4
- package/src/__tests__/fixtures/basic/index.ts +1 -1
- package/src/__tests__/fixtures/basic/schema.ts +1 -1
- package/src/__tests__/fixtures/basic/tasks.ts +4 -4
- package/src/__tests__/fixtures/common/auth-schema.ts +38 -34
- package/src/__tests__/helpers/basic-rls-fixture.ts +80 -78
- package/src/__tests__/helpers/pglite-migrations.ts +2 -5
- package/src/__tests__/helpers/pglite-rls-session.ts +13 -16
- package/src/__tests__/helpers/seed-like-fill.ts +50 -44
- package/src/__tests__/helpers/test-gencow-ctx-rls.ts +4 -7
- package/src/__tests__/httpaction.test.ts +91 -91
- package/src/__tests__/image-optimization.test.ts +570 -574
- package/src/__tests__/load.test.ts +321 -308
- package/src/__tests__/network-sim.test.ts +238 -215
- package/src/__tests__/reactive.test.ts +380 -358
- package/src/__tests__/retry.test.ts +99 -84
- package/src/__tests__/rls-crud-basic.test.ts +172 -245
- package/src/__tests__/rls-crud-no-owner-rls-pglite.test.ts +81 -81
- package/src/__tests__/rls-custom-mutation-handlers.test.ts +47 -94
- package/src/__tests__/rls-custom-query-handlers.test.ts +92 -92
- package/src/__tests__/rls-db-leased-connection.test.ts +2 -6
- package/src/__tests__/rls-session-and-policies.test.ts +181 -199
- package/src/__tests__/scheduler-durable-v2.test.ts +199 -181
- package/src/__tests__/scheduler-durable.test.ts +117 -117
- package/src/__tests__/scheduler-exec.test.ts +258 -246
- package/src/__tests__/scheduler.test.ts +129 -111
- package/src/__tests__/storage.test.ts +282 -269
- package/src/__tests__/tsconfig.json +6 -6
- package/src/__tests__/validator.test.ts +236 -232
- package/src/__tests__/workflow.test.ts +309 -286
- package/src/__tests__/ws-integration.test.ts +223 -218
- package/src/__tests__/ws-scale.test.ts +168 -159
- package/src/auth-config.ts +18 -18
- package/src/auth.ts +106 -106
- package/src/crons.ts +77 -77
- package/src/crud.ts +523 -479
- package/src/index.ts +69 -5
- package/src/reactive.ts +357 -331
- package/src/retry.ts +51 -54
- package/src/rls-db.ts +195 -205
- package/src/rls.ts +33 -36
- package/src/scheduler.ts +237 -211
- package/src/server.ts +0 -1
- package/src/storage.ts +632 -593
- package/src/v.ts +119 -114
- package/src/workflow-types.ts +67 -70
- package/src/workflow.ts +99 -116
- package/src/workflows-api.ts +231 -241
- package/src/db.ts +0 -18
package/src/storage.ts
CHANGED
|
@@ -12,40 +12,40 @@ const MAX_FILE_SIZE = 50 * 1024 * 1024;
|
|
|
12
12
|
const DEFAULT_STORAGE_QUOTA = 1024 * 1024 * 1024;
|
|
13
13
|
|
|
14
14
|
function formatBytes(bytes: number): string {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
15
|
+
if (bytes < 1024) return `${bytes}B`;
|
|
16
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
|
|
17
|
+
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
|
18
|
+
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)}GB`;
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
// ─── Types ──────────────────────────────────────────────
|
|
22
22
|
|
|
23
23
|
interface StorageFile {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
24
|
+
id: string;
|
|
25
|
+
name: string;
|
|
26
|
+
size: number;
|
|
27
|
+
type: string;
|
|
28
|
+
path: string;
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
export interface StorageOptions {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
32
|
+
/** Raw SQL 실행 함수 — DB 자동 기록 + 쿼터 검증에 필요 */
|
|
33
|
+
rawSql?: (sql: string, params?: unknown[]) => Promise<unknown[]>;
|
|
34
|
+
/** 앱별 스토리지 쿼터 (bytes). 0 = 무제한. 기본: 1GB */
|
|
35
|
+
storageQuota?: number;
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
export interface Storage {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
39
|
+
/** Store a file and return a storageId — Convex의 ctx.storage.store() */
|
|
40
|
+
store(file: File | Blob, filename?: string): Promise<string>;
|
|
41
|
+
/** Store from raw buffer */
|
|
42
|
+
storeBuffer(buffer: Buffer, filename: string, type?: string): Promise<string>;
|
|
43
|
+
/** Get a serving URL for the file — Convex의 ctx.storage.getUrl() */
|
|
44
|
+
getUrl(storageId: string): string;
|
|
45
|
+
/** Get file metadata */
|
|
46
|
+
getMeta(storageId: string): Promise<StorageFile | null>;
|
|
47
|
+
/** Delete a stored file — Convex의 ctx.storage.delete() */
|
|
48
|
+
delete(storageId: string): Promise<void>;
|
|
49
49
|
}
|
|
50
50
|
|
|
51
51
|
// ─── Implementation ─────────────────────────────────────
|
|
@@ -60,10 +60,10 @@ const metaStore = new Map<string, StorageFile>();
|
|
|
60
60
|
let filesTableEnsured = false;
|
|
61
61
|
|
|
62
62
|
async function ensureFilesTable(
|
|
63
|
-
|
|
63
|
+
rawSql: (sql: string, params?: unknown[]) => Promise<unknown[]>,
|
|
64
64
|
): Promise<void> {
|
|
65
|
-
|
|
66
|
-
|
|
65
|
+
if (filesTableEnsured) return;
|
|
66
|
+
await rawSql(`
|
|
67
67
|
DO $$
|
|
68
68
|
BEGIN
|
|
69
69
|
IF NOT EXISTS (
|
|
@@ -83,31 +83,29 @@ async function ensureFilesTable(
|
|
|
83
83
|
END IF;
|
|
84
84
|
END$$;
|
|
85
85
|
`);
|
|
86
|
-
|
|
86
|
+
filesTableEnsured = true;
|
|
87
87
|
}
|
|
88
88
|
|
|
89
89
|
/**
|
|
90
90
|
* 스토리지 쿼터 검증 — 현재 총 사용량 + 새 파일 크기가 쿼터를 초과하는지 확인
|
|
91
91
|
*/
|
|
92
92
|
async function checkStorageQuota(
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
93
|
+
rawSql: (sql: string, params?: unknown[]) => Promise<unknown[]>,
|
|
94
|
+
newFileSize: number,
|
|
95
|
+
quota: number,
|
|
96
96
|
): Promise<void> {
|
|
97
|
-
|
|
97
|
+
if (quota <= 0) return; // 무제한
|
|
98
98
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
const currentUsage = Number((rows[0] as Record<string, string>)?.total || "0");
|
|
103
|
-
const projectedUsage = currentUsage + newFileSize;
|
|
99
|
+
const rows = await rawSql(`SELECT COALESCE(SUM(CAST(size AS BIGINT)), 0) AS total FROM _system_files`);
|
|
100
|
+
const currentUsage = Number((rows[0] as Record<string, string>)?.total || "0");
|
|
101
|
+
const projectedUsage = currentUsage + newFileSize;
|
|
104
102
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
103
|
+
if (projectedUsage > quota) {
|
|
104
|
+
throw new Error(
|
|
105
|
+
`Storage quota exceeded: ${formatBytes(currentUsage)} used + ${formatBytes(newFileSize)} new = ${formatBytes(projectedUsage)}, ` +
|
|
106
|
+
`quota is ${formatBytes(quota)}. Delete files or increase quota.`,
|
|
107
|
+
);
|
|
108
|
+
}
|
|
111
109
|
}
|
|
112
110
|
|
|
113
111
|
/**
|
|
@@ -117,19 +115,19 @@ async function checkStorageQuota(
|
|
|
117
115
|
* 여기서는 skipDbRecord 옵션으로 중복 방지 가능.
|
|
118
116
|
*/
|
|
119
117
|
async function recordFileToDb(
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
118
|
+
rawSql: (sql: string, params?: unknown[]) => Promise<unknown[]>,
|
|
119
|
+
storageId: string,
|
|
120
|
+
name: string,
|
|
121
|
+
size: number,
|
|
122
|
+
type: string,
|
|
123
|
+
uploadedBy: string = "api",
|
|
126
124
|
): Promise<void> {
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
125
|
+
await ensureFilesTable(rawSql);
|
|
126
|
+
await rawSql(
|
|
127
|
+
`INSERT INTO _system_files (storage_id, name, size, type, uploaded_by, created_at)
|
|
130
128
|
VALUES ($1, $2, $3, $4, $5, NOW())`,
|
|
131
|
-
|
|
132
|
-
|
|
129
|
+
[storageId, name, String(size), type, uploadedBy],
|
|
130
|
+
);
|
|
133
131
|
}
|
|
134
132
|
|
|
135
133
|
/**
|
|
@@ -143,194 +141,192 @@ async function recordFileToDb(
|
|
|
143
141
|
* const id = await storage.store(file);
|
|
144
142
|
* const url = storage.getUrl(id);
|
|
145
143
|
*/
|
|
146
|
-
export function createStorage(
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
144
|
+
export function createStorage(dir: string = "./uploads", options?: StorageOptions): Storage {
|
|
145
|
+
const rawSql = options?.rawSql;
|
|
146
|
+
const quota = options?.storageQuota ?? DEFAULT_STORAGE_QUOTA;
|
|
147
|
+
|
|
148
|
+
// Ensure directory exists
|
|
149
|
+
fs.mkdir(dir, { recursive: true }).catch(() => {});
|
|
150
|
+
|
|
151
|
+
return {
|
|
152
|
+
async store(file: File | Blob, filename?: string): Promise<string> {
|
|
153
|
+
// 크기 제한 검증 (단일 파일)
|
|
154
|
+
if (file.size > MAX_FILE_SIZE) {
|
|
155
|
+
throw new Error(
|
|
156
|
+
`File too large: ${formatBytes(file.size)} exceeds limit of ${formatBytes(MAX_FILE_SIZE)}`,
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// 스토리지 쿼터 검증 (앱 전체 용량)
|
|
161
|
+
if (rawSql) {
|
|
162
|
+
await checkStorageQuota(rawSql, file.size, quota);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const id = crypto.randomUUID();
|
|
166
|
+
const name = filename || (file instanceof File ? file.name : `${id}.bin`);
|
|
167
|
+
const filePath = path.join(dir, id);
|
|
168
|
+
|
|
169
|
+
const arrayBuffer = await file.arrayBuffer();
|
|
170
|
+
await fs.writeFile(filePath, Buffer.from(arrayBuffer));
|
|
171
|
+
|
|
172
|
+
const type = file.type || "application/octet-stream";
|
|
173
|
+
|
|
174
|
+
// .meta JSON 기록 — Platform 레벨 직접 서빙용 (앱 DB 접근 불필요)
|
|
175
|
+
await fs.writeFile(`${filePath}.meta`, JSON.stringify({ name, type, size: file.size })).catch(() => {});
|
|
176
|
+
|
|
177
|
+
metaStore.set(id, {
|
|
178
|
+
id,
|
|
179
|
+
name,
|
|
180
|
+
size: file.size,
|
|
181
|
+
type,
|
|
182
|
+
path: filePath,
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
// DB 자동 기록 (rawSql 있을 때만)
|
|
186
|
+
if (rawSql) {
|
|
187
|
+
try {
|
|
188
|
+
await recordFileToDb(rawSql, id, name, file.size, type, "api");
|
|
189
|
+
} catch (e: unknown) {
|
|
190
|
+
// DB 기록 실패해도 파일 저장은 성공 — 로그만 남김
|
|
191
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
192
|
+
console.warn(`[storage] DB record failed for ${id}: ${msg}`);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return id;
|
|
197
|
+
},
|
|
198
|
+
|
|
199
|
+
async storeBuffer(
|
|
200
|
+
buffer: Buffer,
|
|
201
|
+
filename: string,
|
|
202
|
+
type: string = "application/octet-stream",
|
|
203
|
+
): Promise<string> {
|
|
204
|
+
// 크기 제한 검증 (단일 파일)
|
|
205
|
+
if (buffer.length > MAX_FILE_SIZE) {
|
|
206
|
+
throw new Error(
|
|
207
|
+
`File too large: ${formatBytes(buffer.length)} exceeds limit of ${formatBytes(MAX_FILE_SIZE)}`,
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// 스토리지 쿼터 검증 (앱 전체 용량)
|
|
212
|
+
if (rawSql) {
|
|
213
|
+
await checkStorageQuota(rawSql, buffer.length, quota);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const id = crypto.randomUUID();
|
|
217
|
+
const filePath = path.join(dir, id);
|
|
218
|
+
|
|
219
|
+
await fs.writeFile(filePath, buffer);
|
|
220
|
+
|
|
221
|
+
// .meta JSON 기록 — Platform 레벨 직접 서빙용
|
|
222
|
+
await fs
|
|
223
|
+
.writeFile(`${filePath}.meta`, JSON.stringify({ name: filename, type, size: buffer.length }))
|
|
224
|
+
.catch(() => {});
|
|
225
|
+
|
|
226
|
+
metaStore.set(id, {
|
|
227
|
+
id,
|
|
228
|
+
name: filename,
|
|
229
|
+
size: buffer.length,
|
|
230
|
+
type,
|
|
231
|
+
path: filePath,
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
// DB 자동 기록
|
|
235
|
+
if (rawSql) {
|
|
236
|
+
try {
|
|
237
|
+
await recordFileToDb(rawSql, id, filename, buffer.length, type, "api");
|
|
238
|
+
} catch (e: unknown) {
|
|
239
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
240
|
+
console.warn(`[storage] DB record failed for ${id}: ${msg}`);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return id;
|
|
245
|
+
},
|
|
246
|
+
|
|
247
|
+
getUrl(storageId: string): string {
|
|
248
|
+
return `/api/storage/${storageId}`;
|
|
249
|
+
},
|
|
250
|
+
|
|
251
|
+
async getMeta(storageId: string): Promise<StorageFile | null> {
|
|
252
|
+
return metaStore.get(storageId) || null;
|
|
253
|
+
},
|
|
254
|
+
|
|
255
|
+
async delete(storageId: string): Promise<void> {
|
|
256
|
+
const meta = metaStore.get(storageId);
|
|
257
|
+
if (meta) {
|
|
258
|
+
await fs.unlink(meta.path).catch(() => {});
|
|
259
|
+
// .meta JSON 동시 삭제
|
|
260
|
+
await fs.unlink(`${meta.path}.meta`).catch(() => {});
|
|
261
|
+
metaStore.delete(storageId);
|
|
262
|
+
} else {
|
|
263
|
+
// metaStore에 없어도 디스크에서 직접 삭제 시도
|
|
264
|
+
const filePath = path.join(dir, storageId);
|
|
265
|
+
await fs.unlink(filePath).catch(() => {});
|
|
266
|
+
await fs.unlink(`${filePath}.meta`).catch(() => {});
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// 이미지 캐시 삭제 — uploads/.cache/{storageId}_* glob
|
|
270
|
+
const cacheDir = path.join(dir, ".cache");
|
|
271
|
+
try {
|
|
272
|
+
const entries = await fs.readdir(cacheDir);
|
|
273
|
+
const prefix = `${storageId}_`;
|
|
274
|
+
await Promise.all(
|
|
275
|
+
entries
|
|
276
|
+
.filter((e) => e.startsWith(prefix))
|
|
277
|
+
.map((e) => fs.unlink(path.join(cacheDir, e)).catch(() => {})),
|
|
278
|
+
);
|
|
279
|
+
} catch {
|
|
280
|
+
/* .cache 디렉토리 미존재 시 무시 */
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// DB에서도 삭제 (rawSql 있을 때만)
|
|
284
|
+
if (rawSql) {
|
|
285
|
+
try {
|
|
286
|
+
await rawSql(`DELETE FROM _system_files WHERE storage_id = $1`, [storageId]);
|
|
287
|
+
} catch {
|
|
288
|
+
/* 삭제 실패 무시 — 파일은 이미 제거됨 */
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
},
|
|
292
|
+
};
|
|
295
293
|
}
|
|
296
294
|
|
|
297
295
|
// ─── Image Optimization Types ───────────────────────────
|
|
298
296
|
|
|
299
297
|
/** 이미지 변환 쿼리 파라미터 */
|
|
300
298
|
interface ImageTransformParams {
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
299
|
+
w?: number; // 너비 (1-4096)
|
|
300
|
+
h?: number; // 높이 (1-4096)
|
|
301
|
+
f?: string; // 출력 포맷 (webp, avif, jpeg, png)
|
|
302
|
+
q?: number; // 품질 (1-100, 기본 80)
|
|
303
|
+
fit?: string; // 맞춤 모드 (cover, contain, fill, inside)
|
|
306
304
|
}
|
|
307
305
|
|
|
308
306
|
/** Tier별 이미지 최적화 설정 — Platform에서 주입 */
|
|
309
307
|
export interface StorageImageTierConfig {
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
308
|
+
/** Auto WebP 허용 여부 (기본 true) */
|
|
309
|
+
autoWebp?: boolean;
|
|
310
|
+
/** Auto WebP 시 최대 폭 — 초과 시 자동 축소 (Hobby=1920, Pro=3840, Scale=무제한) */
|
|
311
|
+
autoMaxWidth?: number;
|
|
312
|
+
/** 리사이즈 허용 여부 */
|
|
313
|
+
resize?: boolean;
|
|
314
|
+
/** 허용 포맷 목록 */
|
|
315
|
+
formats?: string[];
|
|
316
|
+
/** 품질 조절 허용 여부 */
|
|
317
|
+
qualityControl?: boolean;
|
|
318
|
+
/** 캐시 상한 (MB) */
|
|
319
|
+
cacheMaxMB?: number;
|
|
320
|
+
/** 월간 변환 횟수 상한 (-1 = 무제한) */
|
|
321
|
+
transformsPerMonth?: number;
|
|
322
|
+
/** Auto WebP 품질 오버라이드 (1-100, 기본 75) — 앱별 설정에서 주입 */
|
|
323
|
+
autoQuality?: number;
|
|
326
324
|
}
|
|
327
325
|
|
|
328
326
|
// ─── Image Optimization Constants ───────────────────────
|
|
329
327
|
|
|
330
328
|
/** 변환 가능한 원본 MIME 타입 */
|
|
331
|
-
const TRANSFORMABLE_TYPES = new Set([
|
|
332
|
-
"image/png", "image/jpeg", "image/jpg", "image/webp",
|
|
333
|
-
]);
|
|
329
|
+
const TRANSFORMABLE_TYPES = new Set(["image/png", "image/jpeg", "image/jpg", "image/webp"]);
|
|
334
330
|
|
|
335
331
|
/** 허용 출력 포맷 */
|
|
336
332
|
const ALLOWED_FORMATS = new Set(["webp", "avif", "jpeg", "png"]);
|
|
@@ -359,22 +355,22 @@ const MAX_CONCURRENT_TRANSFORMS = 3;
|
|
|
359
355
|
const transformQueue: Array<() => void> = [];
|
|
360
356
|
|
|
361
357
|
function acquireTransformSlot(): Promise<void> {
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
});
|
|
358
|
+
if (activeTransforms < MAX_CONCURRENT_TRANSFORMS) {
|
|
359
|
+
activeTransforms++;
|
|
360
|
+
return Promise.resolve();
|
|
361
|
+
}
|
|
362
|
+
return new Promise<void>((resolve) => {
|
|
363
|
+
transformQueue.push(() => {
|
|
364
|
+
activeTransforms++;
|
|
365
|
+
resolve();
|
|
371
366
|
});
|
|
367
|
+
});
|
|
372
368
|
}
|
|
373
369
|
|
|
374
370
|
function releaseTransformSlot(): void {
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
371
|
+
activeTransforms--;
|
|
372
|
+
const next = transformQueue.shift();
|
|
373
|
+
if (next) next();
|
|
378
374
|
}
|
|
379
375
|
|
|
380
376
|
/**
|
|
@@ -383,80 +379,90 @@ function releaseTransformSlot(): void {
|
|
|
383
379
|
* @returns null if no transform params, parsed params otherwise
|
|
384
380
|
*/
|
|
385
381
|
function parseTransformParams(
|
|
386
|
-
|
|
382
|
+
wStr?: string,
|
|
383
|
+
hStr?: string,
|
|
384
|
+
fStr?: string,
|
|
385
|
+
qStr?: string,
|
|
386
|
+
fitStr?: string,
|
|
387
387
|
): ImageTransformParams | null {
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
388
|
+
const params: ImageTransformParams = {};
|
|
389
|
+
let hasParam = false;
|
|
390
|
+
|
|
391
|
+
if (wStr) {
|
|
392
|
+
const w = parseInt(wStr, 10);
|
|
393
|
+
if (isNaN(w) || w < 1 || w > MAX_DIMENSION) return null;
|
|
394
|
+
params.w = w;
|
|
395
|
+
hasParam = true;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
if (hStr) {
|
|
399
|
+
const h = parseInt(hStr, 10);
|
|
400
|
+
if (isNaN(h) || h < 1 || h > MAX_DIMENSION) return null;
|
|
401
|
+
params.h = h;
|
|
402
|
+
hasParam = true;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
if (fStr) {
|
|
406
|
+
const fmt = fStr.toLowerCase();
|
|
407
|
+
if (!ALLOWED_FORMATS.has(fmt)) return null;
|
|
408
|
+
params.f = fmt;
|
|
409
|
+
hasParam = true;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
if (qStr) {
|
|
413
|
+
const q = parseInt(qStr, 10);
|
|
414
|
+
if (isNaN(q) || q < 1 || q > 100) return null;
|
|
415
|
+
params.q = q;
|
|
416
|
+
hasParam = true;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
if (fitStr) {
|
|
420
|
+
const fitMode = fitStr.toLowerCase();
|
|
421
|
+
if (!ALLOWED_FIT_MODES.has(fitMode)) return null;
|
|
422
|
+
params.fit = fitMode;
|
|
423
|
+
hasParam = true;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
return hasParam ? params : null;
|
|
427
427
|
}
|
|
428
428
|
|
|
429
429
|
/**
|
|
430
430
|
* 변환 파라미터에서 캐시 키 생성
|
|
431
431
|
* 결정론적: 동일 파라미터 → 동일 키
|
|
432
432
|
*/
|
|
433
|
-
function buildCacheKey(
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
const
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
433
|
+
function buildCacheKey(
|
|
434
|
+
storageId: string,
|
|
435
|
+
params: ImageTransformParams,
|
|
436
|
+
isAutoWebp: boolean,
|
|
437
|
+
autoMaxWidth?: number,
|
|
438
|
+
autoQuality?: number,
|
|
439
|
+
): string {
|
|
440
|
+
if (isAutoWebp) {
|
|
441
|
+
// autoMaxWidth/autoQuality가 있으면 캐시 키에 포함 (Tier/앱별 다른 크기/품질)
|
|
442
|
+
const mwSuffix = autoMaxWidth ? `_mw${autoMaxWidth}` : "";
|
|
443
|
+
const qSuffix = autoQuality && autoQuality !== AUTO_WEBP_QUALITY ? `_q${autoQuality}` : "";
|
|
444
|
+
return `${storageId}_auto_webp${mwSuffix}${qSuffix}.webp`;
|
|
445
|
+
}
|
|
446
|
+
const parts: string[] = [storageId];
|
|
447
|
+
if (params.w) parts.push(`w${params.w}`);
|
|
448
|
+
if (params.h) parts.push(`h${params.h}`);
|
|
449
|
+
const fmt = params.f || "webp";
|
|
450
|
+
parts.push(`f${fmt}`);
|
|
451
|
+
parts.push(`q${params.q || DEFAULT_QUALITY}`);
|
|
452
|
+
if (params.fit) parts.push(params.fit);
|
|
453
|
+
return `${parts.join("_")}.${fmt === "jpeg" ? "jpg" : fmt}`;
|
|
448
454
|
}
|
|
449
455
|
|
|
450
456
|
/**
|
|
451
457
|
* 캐시 디렉토리의 엔트리 수 확인 — DoS 방지
|
|
452
458
|
*/
|
|
453
459
|
async function getCacheEntryCount(cacheDir: string): Promise<number> {
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
+
try {
|
|
461
|
+
const entries = await fs.readdir(cacheDir);
|
|
462
|
+
return entries.length;
|
|
463
|
+
} catch {
|
|
464
|
+
return 0;
|
|
465
|
+
}
|
|
460
466
|
}
|
|
461
467
|
|
|
462
468
|
/**
|
|
@@ -472,324 +478,357 @@ async function getCacheEntryCount(cacheDir: string): Promise<number> {
|
|
|
472
478
|
* - 원본 보존: 원본 파일은 절대 수정하지 않음
|
|
473
479
|
*/
|
|
474
480
|
export function storageRoutes(
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
481
|
+
storage: ReturnType<typeof createStorage>,
|
|
482
|
+
rawSql?: (sql: string, params?: unknown[]) => Promise<unknown[]>,
|
|
483
|
+
storageDir?: string,
|
|
484
|
+
tierConfig?: StorageImageTierConfig,
|
|
479
485
|
) {
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
486
|
+
const baseTierConfig: Required<StorageImageTierConfig> = {
|
|
487
|
+
autoWebp: tierConfig?.autoWebp ?? true,
|
|
488
|
+
autoMaxWidth: tierConfig?.autoMaxWidth ?? 0, // 0 = 제한 없음
|
|
489
|
+
resize: tierConfig?.resize ?? true,
|
|
490
|
+
formats: tierConfig?.formats ?? ["webp", "avif", "jpeg", "png"],
|
|
491
|
+
qualityControl: tierConfig?.qualityControl ?? true,
|
|
492
|
+
cacheMaxMB: tierConfig?.cacheMaxMB ?? 1024, // 기본 1GB
|
|
493
|
+
transformsPerMonth: tierConfig?.transformsPerMonth ?? -1, // 무제한
|
|
494
|
+
autoQuality: tierConfig?.autoQuality ?? AUTO_WEBP_QUALITY,
|
|
495
|
+
};
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* 매 요청마다 uploads/.image-config.json 파일에서 앱별 이미지 설정을 읽어
|
|
499
|
+
* Tier config와 병합한 최종 config를 반환.
|
|
500
|
+
*
|
|
501
|
+
* 파일 기반 설계 이유:
|
|
502
|
+
* - env var 방식은 전파 파이프라인이 6단계(DB→provisioner→env→dist→handler)로 깨지기 쉬움
|
|
503
|
+
* - 파일은 대시보드에서 write → 다음 요청에서 즉시 read (2단계)
|
|
504
|
+
* - 어느 프로세스든(Platform/앱) 동일 파일을 읽으므로 일관성 보장
|
|
505
|
+
* - readFileSync ~0.01ms, 성능 영향 없음
|
|
506
|
+
*/
|
|
507
|
+
function getImageConfig(uploadsDir: string): Required<StorageImageTierConfig> {
|
|
508
|
+
const config = { ...baseTierConfig };
|
|
509
|
+
try {
|
|
510
|
+
const raw = fsSync.readFileSync(path.join(uploadsDir, ".image-config.json"), "utf-8");
|
|
511
|
+
const appConfig = JSON.parse(raw);
|
|
512
|
+
// Tier ceiling 적용: min(앱 설정, Tier 최대값)
|
|
513
|
+
if (appConfig.autoMaxWidth > 0) {
|
|
514
|
+
config.autoMaxWidth =
|
|
515
|
+
config.autoMaxWidth > 0
|
|
516
|
+
? Math.min(appConfig.autoMaxWidth, config.autoMaxWidth)
|
|
517
|
+
: appConfig.autoMaxWidth;
|
|
518
|
+
}
|
|
519
|
+
if (appConfig.autoQuality > 0 && appConfig.autoQuality <= 100) {
|
|
520
|
+
config.autoQuality = appConfig.autoQuality;
|
|
521
|
+
}
|
|
522
|
+
if (appConfig.autoWebp === false) {
|
|
523
|
+
config.autoWebp = false;
|
|
524
|
+
}
|
|
525
|
+
} catch {
|
|
526
|
+
/* 파일 없음 or 파싱 실패 → Tier 기본값 유지 */
|
|
520
527
|
}
|
|
528
|
+
return config;
|
|
529
|
+
}
|
|
521
530
|
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
531
|
+
// sharp 모듈 캐시 (동적 import 1회만)
|
|
532
|
+
let sharpModule: any = null;
|
|
533
|
+
let sharpLoadAttempted = false;
|
|
525
534
|
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
535
|
+
async function getSharp(): Promise<any> {
|
|
536
|
+
if (sharpLoadAttempted) return sharpModule;
|
|
537
|
+
sharpLoadAttempted = true;
|
|
538
|
+
try {
|
|
539
|
+
// @ts-ignore — sharp는 런타임 선택적 의존성 (설치 안 된 환경에서도 빌드 가능)
|
|
540
|
+
sharpModule = (await import("sharp")).default;
|
|
541
|
+
} catch {
|
|
542
|
+
// sharp 미설치 시 null → 원본 서빙 fallback
|
|
543
|
+
console.warn("[storage] sharp not available — image optimization disabled");
|
|
544
|
+
sharpModule = null;
|
|
545
|
+
}
|
|
546
|
+
return sharpModule;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
return async (c: {
|
|
550
|
+
req: {
|
|
551
|
+
param: (key: string) => string;
|
|
552
|
+
query: (key: string) => string | undefined;
|
|
553
|
+
header: (name: string) => string | undefined;
|
|
554
|
+
};
|
|
555
|
+
json: (data: unknown, status?: number) => Response;
|
|
556
|
+
body: (data: unknown, status: number, headers: Record<string, string>) => Response;
|
|
557
|
+
}) => {
|
|
558
|
+
// 매 요청마다 uploads/.image-config.json에서 config 읽기 (파일 기반 hot-reload)
|
|
559
|
+
const dir = storageDir || "./uploads";
|
|
560
|
+
const config = getImageConfig(dir);
|
|
561
|
+
|
|
562
|
+
const id = c.req.param("id");
|
|
563
|
+
let meta = await storage.getMeta(id);
|
|
564
|
+
|
|
565
|
+
// Fallback: DB lookup when in-memory meta is missing (e.g. after server restart)
|
|
566
|
+
if (!meta && rawSql) {
|
|
567
|
+
try {
|
|
568
|
+
const rows = await rawSql(
|
|
569
|
+
`SELECT storage_id, name, size, type FROM _system_files WHERE storage_id = $1 LIMIT 1`,
|
|
570
|
+
[id],
|
|
571
|
+
);
|
|
572
|
+
if (rows.length > 0) {
|
|
573
|
+
const row = rows[0] as Record<string, string>;
|
|
574
|
+
const dir = storageDir || "./uploads";
|
|
575
|
+
meta = {
|
|
576
|
+
id: row.storage_id,
|
|
577
|
+
name: row.name,
|
|
578
|
+
size: Number(row.size),
|
|
579
|
+
type: row.type || "application/octet-stream",
|
|
580
|
+
path: path.join(dir, row.storage_id),
|
|
581
|
+
};
|
|
536
582
|
}
|
|
537
|
-
|
|
583
|
+
} catch {
|
|
584
|
+
/* fallthrough to 404 */
|
|
585
|
+
}
|
|
538
586
|
}
|
|
539
587
|
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
const config = getImageConfig(dir);
|
|
544
|
-
|
|
545
|
-
const id = c.req.param("id");
|
|
546
|
-
let meta = await storage.getMeta(id);
|
|
588
|
+
if (!meta) {
|
|
589
|
+
return c.json({ error: "Not found" }, 404);
|
|
590
|
+
}
|
|
547
591
|
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
592
|
+
// ── 이미지 최적화 분기 ─────────────────────────────
|
|
593
|
+
const isTransformable = TRANSFORMABLE_TYPES.has(meta.type);
|
|
594
|
+
|
|
595
|
+
// Hono native query 파서로 파라미터 추출
|
|
596
|
+
const transformParams = isTransformable
|
|
597
|
+
? parseTransformParams(
|
|
598
|
+
c.req.query("w"),
|
|
599
|
+
c.req.query("h"),
|
|
600
|
+
c.req.query("f"),
|
|
601
|
+
c.req.query("q"),
|
|
602
|
+
c.req.query("fit"),
|
|
603
|
+
)
|
|
604
|
+
: null;
|
|
605
|
+
|
|
606
|
+
// Accept 헤더에서 WebP 지원 감지
|
|
607
|
+
const acceptHeader = c.req.header("accept") || "";
|
|
608
|
+
const clientAcceptsWebp = acceptHeader.includes("image/webp");
|
|
609
|
+
|
|
610
|
+
// Auto WebP: 파라미터 없지만 브라우저가 webp 지원 + 원본이 PNG/JPEG
|
|
611
|
+
const isAutoWebp =
|
|
612
|
+
!transformParams &&
|
|
613
|
+
isTransformable &&
|
|
614
|
+
clientAcceptsWebp &&
|
|
615
|
+
config.autoWebp &&
|
|
616
|
+
(meta.type === "image/png" || meta.type === "image/jpeg" || meta.type === "image/jpg");
|
|
617
|
+
|
|
618
|
+
// 이미지 변환이 필요 없는 경우 → 원본 서빙 (기존 동작 100% 유지)
|
|
619
|
+
if (!transformParams && !isAutoWebp) {
|
|
620
|
+
return serveOriginal(c, meta);
|
|
621
|
+
}
|
|
568
622
|
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
623
|
+
// ── Tier 검증 (파라미터 변환 시) ──────────────────
|
|
624
|
+
if (transformParams) {
|
|
625
|
+
// 리사이즈 검증
|
|
626
|
+
if ((transformParams.w || transformParams.h) && !config.resize) {
|
|
627
|
+
return c.json(
|
|
628
|
+
{
|
|
629
|
+
error: "Image resize requires Pro plan",
|
|
630
|
+
code: "PLAN_LIMIT",
|
|
631
|
+
upgrade: "https://gencow.app/pricing",
|
|
632
|
+
},
|
|
633
|
+
403,
|
|
634
|
+
);
|
|
635
|
+
}
|
|
636
|
+
// 포맷 검증
|
|
637
|
+
if (transformParams.f && !config.formats.includes(transformParams.f)) {
|
|
638
|
+
return c.json(
|
|
639
|
+
{
|
|
640
|
+
error: `Format "${transformParams.f}" requires a higher plan`,
|
|
641
|
+
code: "PLAN_LIMIT",
|
|
642
|
+
upgrade: "https://gencow.app/pricing",
|
|
643
|
+
allowed: { formats: config.formats },
|
|
644
|
+
},
|
|
645
|
+
403,
|
|
646
|
+
);
|
|
647
|
+
}
|
|
648
|
+
// 품질 검증
|
|
649
|
+
if (transformParams.q && !config.qualityControl) {
|
|
650
|
+
return c.json(
|
|
651
|
+
{
|
|
652
|
+
error: "Quality control requires Pro plan",
|
|
653
|
+
code: "PLAN_LIMIT",
|
|
654
|
+
upgrade: "https://gencow.app/pricing",
|
|
655
|
+
},
|
|
656
|
+
403,
|
|
657
|
+
);
|
|
658
|
+
}
|
|
659
|
+
// fit 검증 (resize와 함께만 동작)
|
|
660
|
+
if (transformParams.fit && !config.resize) {
|
|
661
|
+
return c.json(
|
|
662
|
+
{
|
|
663
|
+
error: "Fit mode requires Pro plan",
|
|
664
|
+
code: "PLAN_LIMIT",
|
|
665
|
+
upgrade: "https://gencow.app/pricing",
|
|
666
|
+
},
|
|
667
|
+
403,
|
|
668
|
+
);
|
|
669
|
+
}
|
|
670
|
+
}
|
|
572
671
|
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
c.req.query("w"), c.req.query("h"),
|
|
580
|
-
c.req.query("f"), c.req.query("q"),
|
|
581
|
-
c.req.query("fit"),
|
|
582
|
-
)
|
|
583
|
-
: null;
|
|
584
|
-
|
|
585
|
-
// Accept 헤더에서 WebP 지원 감지
|
|
586
|
-
const acceptHeader = c.req.header("accept") || "";
|
|
587
|
-
const clientAcceptsWebp = acceptHeader.includes("image/webp");
|
|
588
|
-
|
|
589
|
-
// Auto WebP: 파라미터 없지만 브라우저가 webp 지원 + 원본이 PNG/JPEG
|
|
590
|
-
const isAutoWebp = !transformParams
|
|
591
|
-
&& isTransformable
|
|
592
|
-
&& clientAcceptsWebp
|
|
593
|
-
&& config.autoWebp
|
|
594
|
-
&& (meta.type === "image/png" || meta.type === "image/jpeg" || meta.type === "image/jpg");
|
|
595
|
-
|
|
596
|
-
// 이미지 변환이 필요 없는 경우 → 원본 서빙 (기존 동작 100% 유지)
|
|
597
|
-
if (!transformParams && !isAutoWebp) {
|
|
598
|
-
return serveOriginal(c, meta);
|
|
599
|
-
}
|
|
672
|
+
// ── sharp 로드 확인 ─────────────────────────────
|
|
673
|
+
const sharp = await getSharp();
|
|
674
|
+
if (!sharp) {
|
|
675
|
+
// sharp 미설치 → 원본 서빙 (graceful degradation)
|
|
676
|
+
return serveOriginal(c, meta);
|
|
677
|
+
}
|
|
600
678
|
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
// 포맷 검증
|
|
612
|
-
if (transformParams.f && !config.formats.includes(transformParams.f)) {
|
|
613
|
-
return c.json({
|
|
614
|
-
error: `Format "${transformParams.f}" requires a higher plan`,
|
|
615
|
-
code: "PLAN_LIMIT",
|
|
616
|
-
upgrade: "https://gencow.app/pricing",
|
|
617
|
-
allowed: { formats: config.formats },
|
|
618
|
-
}, 403);
|
|
619
|
-
}
|
|
620
|
-
// 품질 검증
|
|
621
|
-
if (transformParams.q && !config.qualityControl) {
|
|
622
|
-
return c.json({
|
|
623
|
-
error: "Quality control requires Pro plan",
|
|
624
|
-
code: "PLAN_LIMIT",
|
|
625
|
-
upgrade: "https://gencow.app/pricing",
|
|
626
|
-
}, 403);
|
|
627
|
-
}
|
|
628
|
-
// fit 검증 (resize와 함께만 동작)
|
|
629
|
-
if (transformParams.fit && !config.resize) {
|
|
630
|
-
return c.json({
|
|
631
|
-
error: "Fit mode requires Pro plan",
|
|
632
|
-
code: "PLAN_LIMIT",
|
|
633
|
-
upgrade: "https://gencow.app/pricing",
|
|
634
|
-
}, 403);
|
|
635
|
-
}
|
|
636
|
-
}
|
|
679
|
+
// ── 캐시 확인 ───────────────────────────────────
|
|
680
|
+
const cacheDir = path.join(dir, ".cache");
|
|
681
|
+
const cacheKey = buildCacheKey(
|
|
682
|
+
id,
|
|
683
|
+
transformParams || {},
|
|
684
|
+
isAutoWebp,
|
|
685
|
+
config.autoMaxWidth || undefined,
|
|
686
|
+
config.autoQuality,
|
|
687
|
+
);
|
|
688
|
+
const cachePath = path.join(cacheDir, cacheKey);
|
|
637
689
|
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
690
|
+
// 캐시 HIT
|
|
691
|
+
try {
|
|
692
|
+
await fs.access(cachePath);
|
|
693
|
+
// 캐시 파일이 존재 → 바로 서빙
|
|
694
|
+
return serveCachedFile(c, cachePath, transformParams, isAutoWebp, meta);
|
|
695
|
+
} catch {
|
|
696
|
+
// 캐시 MISS → 변환 필요
|
|
697
|
+
}
|
|
644
698
|
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
699
|
+
// ── 캐시 엔트리 수 제한 확인 ─────────────────────
|
|
700
|
+
const cacheCount = await getCacheEntryCount(cacheDir);
|
|
701
|
+
if (cacheCount >= MAX_CACHE_ENTRIES) {
|
|
702
|
+
// 캐시 상한 초과 → 변환은 수행하되 캐시 저장 안 함 (or 원본 반환)
|
|
703
|
+
// 보안상 원본 서빙으로 fallback
|
|
704
|
+
return serveOriginal(c, meta);
|
|
705
|
+
}
|
|
649
706
|
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
707
|
+
// ── 이미지 변환 수행 ─────────────────────────────
|
|
708
|
+
try {
|
|
709
|
+
await acquireTransformSlot();
|
|
710
|
+
|
|
711
|
+
try {
|
|
712
|
+
// 캐시 디렉토리 생성
|
|
713
|
+
await fs.mkdir(cacheDir, { recursive: true });
|
|
714
|
+
|
|
715
|
+
// sharp 파이프라인 구성
|
|
716
|
+
let pipeline = sharp(meta.path);
|
|
717
|
+
|
|
718
|
+
// 리사이즈
|
|
719
|
+
if (transformParams?.w || transformParams?.h) {
|
|
720
|
+
const fitMode = (transformParams.fit || "cover") as "cover" | "contain" | "fill" | "inside";
|
|
721
|
+
pipeline = pipeline.resize({
|
|
722
|
+
width: transformParams.w || undefined,
|
|
723
|
+
height: transformParams.h || undefined,
|
|
724
|
+
fit: fitMode,
|
|
725
|
+
withoutEnlargement: true, // 원본보다 크게 확대하지 않음
|
|
726
|
+
});
|
|
727
|
+
} else if (isAutoWebp && config.autoMaxWidth > 0) {
|
|
728
|
+
// Auto WebP 시 Tier별 최대폭 제한 (Hobby=1920, Pro=3840, Scale=0=무제한)
|
|
729
|
+
// 원본이 maxWidth 이하면 리사이즈하지 않음 (withoutEnlargement)
|
|
730
|
+
pipeline = pipeline.resize({
|
|
731
|
+
width: config.autoMaxWidth,
|
|
732
|
+
withoutEnlargement: true,
|
|
733
|
+
});
|
|
657
734
|
}
|
|
658
735
|
|
|
659
|
-
//
|
|
660
|
-
const
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
736
|
+
// 출력 포맷 + 품질
|
|
737
|
+
const outputFormat = transformParams?.f || (isAutoWebp ? "webp" : null);
|
|
738
|
+
const quality = transformParams?.q ?? DEFAULT_QUALITY;
|
|
739
|
+
|
|
740
|
+
if (outputFormat === "webp") {
|
|
741
|
+
pipeline = pipeline.webp(
|
|
742
|
+
isAutoWebp
|
|
743
|
+
? { quality: config.autoQuality, effort: AUTO_WEBP_EFFORT, alphaQuality: 100 }
|
|
744
|
+
: { quality, alphaQuality: 100 },
|
|
745
|
+
);
|
|
746
|
+
} else if (outputFormat === "avif") {
|
|
747
|
+
pipeline = pipeline.avif({ quality });
|
|
748
|
+
} else if (outputFormat === "jpeg" || outputFormat === "jpg") {
|
|
749
|
+
pipeline = pipeline.jpeg({ quality, mozjpeg: true });
|
|
750
|
+
} else if (outputFormat === "png") {
|
|
751
|
+
pipeline = pipeline.png({ compressionLevel: 9 });
|
|
665
752
|
}
|
|
666
753
|
|
|
667
|
-
//
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
let pipeline = sharp(meta.path);
|
|
677
|
-
|
|
678
|
-
// 리사이즈
|
|
679
|
-
if (transformParams?.w || transformParams?.h) {
|
|
680
|
-
const fitMode = (transformParams.fit || "cover") as "cover" | "contain" | "fill" | "inside";
|
|
681
|
-
pipeline = pipeline.resize({
|
|
682
|
-
width: transformParams.w || undefined,
|
|
683
|
-
height: transformParams.h || undefined,
|
|
684
|
-
fit: fitMode,
|
|
685
|
-
withoutEnlargement: true, // 원본보다 크게 확대하지 않음
|
|
686
|
-
});
|
|
687
|
-
} else if (isAutoWebp && config.autoMaxWidth > 0) {
|
|
688
|
-
// Auto WebP 시 Tier별 최대폭 제한 (Hobby=1920, Pro=3840, Scale=0=무제한)
|
|
689
|
-
// 원본이 maxWidth 이하면 리사이즈하지 않음 (withoutEnlargement)
|
|
690
|
-
pipeline = pipeline.resize({
|
|
691
|
-
width: config.autoMaxWidth,
|
|
692
|
-
withoutEnlargement: true,
|
|
693
|
-
});
|
|
694
|
-
}
|
|
695
|
-
|
|
696
|
-
// 출력 포맷 + 품질
|
|
697
|
-
const outputFormat = transformParams?.f || (isAutoWebp ? "webp" : null);
|
|
698
|
-
const quality = transformParams?.q ?? DEFAULT_QUALITY;
|
|
699
|
-
|
|
700
|
-
if (outputFormat === "webp") {
|
|
701
|
-
pipeline = pipeline.webp(isAutoWebp
|
|
702
|
-
? { quality: config.autoQuality, effort: AUTO_WEBP_EFFORT, alphaQuality: 100 }
|
|
703
|
-
: { quality, alphaQuality: 100 });
|
|
704
|
-
} else if (outputFormat === "avif") {
|
|
705
|
-
pipeline = pipeline.avif({ quality });
|
|
706
|
-
} else if (outputFormat === "jpeg" || outputFormat === "jpg") {
|
|
707
|
-
pipeline = pipeline.jpeg({ quality, mozjpeg: true });
|
|
708
|
-
} else if (outputFormat === "png") {
|
|
709
|
-
pipeline = pipeline.png({ compressionLevel: 9 });
|
|
710
|
-
}
|
|
711
|
-
|
|
712
|
-
// 변환 실행 → 캐시에 저장
|
|
713
|
-
await pipeline.toFile(cachePath);
|
|
714
|
-
|
|
715
|
-
// WebP/AVIF가 원본보다 큰 경우 → 캐시 삭제 + 원본 서빙
|
|
716
|
-
// (Static Deploy와 동일 전략 — apps.ts L840-847)
|
|
717
|
-
if (isAutoWebp) {
|
|
718
|
-
const cacheStats = await fs.stat(cachePath);
|
|
719
|
-
if (cacheStats.size >= meta.size) {
|
|
720
|
-
await fs.unlink(cachePath).catch(() => { });
|
|
721
|
-
return serveOriginal(c, meta);
|
|
722
|
-
}
|
|
723
|
-
}
|
|
724
|
-
|
|
725
|
-
return serveCachedFile(c, cachePath, transformParams, isAutoWebp, meta);
|
|
726
|
-
} finally {
|
|
727
|
-
releaseTransformSlot();
|
|
728
|
-
}
|
|
729
|
-
} catch (err: unknown) {
|
|
730
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
731
|
-
console.warn(`[storage] Image transform failed for ${id}: ${msg}`);
|
|
732
|
-
// 변환 실패 → 원본 서빙 (graceful degradation)
|
|
754
|
+
// 변환 실행 → 캐시에 저장
|
|
755
|
+
await pipeline.toFile(cachePath);
|
|
756
|
+
|
|
757
|
+
// WebP/AVIF가 원본보다 큰 경우 → 캐시 삭제 + 원본 서빙
|
|
758
|
+
// (Static Deploy와 동일 전략 — apps.ts L840-847)
|
|
759
|
+
if (isAutoWebp) {
|
|
760
|
+
const cacheStats = await fs.stat(cachePath);
|
|
761
|
+
if (cacheStats.size >= meta.size) {
|
|
762
|
+
await fs.unlink(cachePath).catch(() => {});
|
|
733
763
|
return serveOriginal(c, meta);
|
|
764
|
+
}
|
|
734
765
|
}
|
|
735
|
-
};
|
|
736
766
|
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
767
|
+
return serveCachedFile(c, cachePath, transformParams, isAutoWebp, meta);
|
|
768
|
+
} finally {
|
|
769
|
+
releaseTransformSlot();
|
|
770
|
+
}
|
|
771
|
+
} catch (err: unknown) {
|
|
772
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
773
|
+
console.warn(`[storage] Image transform failed for ${id}: ${msg}`);
|
|
774
|
+
// 변환 실패 → 원본 서빙 (graceful degradation)
|
|
775
|
+
return serveOriginal(c, meta);
|
|
776
|
+
}
|
|
777
|
+
};
|
|
778
|
+
|
|
779
|
+
/** 원본 파일 서빙 (기존 동작) */
|
|
780
|
+
function serveOriginal(c: any, meta: StorageFile): Response {
|
|
781
|
+
const headers: Record<string, string> = {
|
|
782
|
+
"Content-Type": meta.type,
|
|
783
|
+
"Content-Disposition": `inline; filename="${encodeURIComponent(meta.name)}"; filename*=UTF-8''${encodeURIComponent(meta.name)}`,
|
|
784
|
+
"Cache-Control": "public, max-age=31536000, immutable",
|
|
785
|
+
};
|
|
749
786
|
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
return c.body(file, 200, headers);
|
|
787
|
+
if (typeof globalThis.Bun !== "undefined") {
|
|
788
|
+
const bunFile = Bun.file(meta.path);
|
|
789
|
+
return new Response(bunFile, { headers });
|
|
754
790
|
}
|
|
755
791
|
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
}
|
|
792
|
+
// Node.js 폴백 — 동기 readFile (async context에서 호출되므로 안전)
|
|
793
|
+
const file = require("fs").readFileSync(meta.path);
|
|
794
|
+
headers["Content-Length"] = String(file.byteLength);
|
|
795
|
+
return c.body(file, 200, headers);
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
/** 캐시된 변환 파일 서빙 */
|
|
799
|
+
function serveCachedFile(
|
|
800
|
+
c: any,
|
|
801
|
+
cachePath: string,
|
|
802
|
+
params: ImageTransformParams | null,
|
|
803
|
+
isAutoWebp: boolean,
|
|
804
|
+
originalMeta: StorageFile,
|
|
805
|
+
): Response {
|
|
806
|
+
const outputFormat = params?.f || (isAutoWebp ? "webp" : null);
|
|
807
|
+
const contentType = outputFormat
|
|
808
|
+
? `image/${outputFormat === "jpg" ? "jpeg" : outputFormat}`
|
|
809
|
+
: originalMeta.type;
|
|
810
|
+
|
|
811
|
+
// 변환된 파일명: original.png → original.webp (또는 original_300x200.webp)
|
|
812
|
+
const baseName = originalMeta.name.replace(/\.[^.]+$/, "");
|
|
813
|
+
const ext = outputFormat || originalMeta.name.split(".").pop() || "bin";
|
|
814
|
+
const suffix = params?.w || params?.h ? `_${params.w || "auto"}x${params.h || "auto"}` : "";
|
|
815
|
+
const fileName = `${baseName}${suffix}.${ext}`;
|
|
816
|
+
|
|
817
|
+
const headers: Record<string, string> = {
|
|
818
|
+
"Content-Type": contentType,
|
|
819
|
+
"Content-Disposition": `inline; filename="${encodeURIComponent(fileName)}"; filename*=UTF-8''${encodeURIComponent(fileName)}`,
|
|
820
|
+
"Cache-Control": "public, max-age=31536000, immutable",
|
|
821
|
+
// Vary: Accept — Auto WebP 시 CDN/브라우저 캐시 분리
|
|
822
|
+
...(isAutoWebp ? { Vary: "Accept" } : {}),
|
|
823
|
+
};
|
|
789
824
|
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
825
|
+
if (typeof globalThis.Bun !== "undefined") {
|
|
826
|
+
const bunFile = Bun.file(cachePath);
|
|
827
|
+
return new Response(bunFile, { headers });
|
|
793
828
|
}
|
|
794
|
-
}
|
|
795
829
|
|
|
830
|
+
const file = require("fs").readFileSync(cachePath);
|
|
831
|
+
headers["Content-Length"] = String(file.byteLength);
|
|
832
|
+
return c.body(file, 200, headers);
|
|
833
|
+
}
|
|
834
|
+
}
|