@gencow/core 0.1.19 → 0.1.22
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 +30 -12
- package/dist/crud.js +233 -52
- package/dist/index.d.ts +18 -17
- package/dist/index.js +10 -10
- package/dist/reactive.d.ts +4 -4
- package/dist/rls-db.d.ts +3 -5
- package/dist/rls-db.js +3 -5
- package/dist/rls.d.ts +44 -1
- package/dist/rls.js +62 -2
- package/dist/server.d.ts +5 -4
- package/dist/server.js +4 -4
- package/dist/storage.d.ts +29 -2
- package/dist/storage.js +396 -8
- package/package.json +6 -2
- package/src/__tests__/crud-owner-rls.test.ts +380 -0
- package/src/__tests__/fixtures/basic/auth.ts +32 -0
- package/src/__tests__/fixtures/basic/drizzle.config.ts +15 -0
- package/src/__tests__/fixtures/basic/index.ts +6 -0
- package/src/__tests__/fixtures/basic/migrations/0000_faithful_silver_sable.sql +66 -0
- package/src/__tests__/fixtures/basic/migrations/meta/0000_snapshot.json +438 -0
- package/src/__tests__/fixtures/basic/migrations/meta/_journal.json +13 -0
- package/src/__tests__/fixtures/basic/schema.ts +35 -0
- package/src/__tests__/fixtures/basic/tasks.ts +15 -0
- package/src/__tests__/fixtures/common/auth-schema.ts +63 -0
- package/src/__tests__/helpers/pglite-migrations.ts +35 -0
- package/src/__tests__/helpers/pglite-rls-session.ts +54 -0
- package/src/__tests__/helpers/seed-like-fill.ts +196 -0
- package/src/__tests__/helpers/test-gencow-ctx-rls.ts +53 -0
- package/src/__tests__/image-optimization.test.ts +652 -0
- package/src/__tests__/rls-crud-basic.test.ts +431 -0
- package/src/__tests__/tsconfig.json +8 -0
- package/src/crud.ts +272 -49
- package/src/index.ts +18 -17
- package/src/reactive.ts +4 -4
- package/src/rls-db.ts +3 -5
- package/src/rls.ts +87 -3
- package/src/server.ts +5 -4
- package/src/storage.ts +473 -8
- package/dist/scoped-db.d.ts +0 -34
- package/dist/scoped-db.js +0 -364
- package/dist/table.d.ts +0 -67
- package/dist/table.js +0 -98
package/dist/rls.js
CHANGED
|
@@ -1,11 +1,71 @@
|
|
|
1
1
|
import { sql } from "drizzle-orm";
|
|
2
2
|
import { pgPolicy } from "drizzle-orm/pg-core";
|
|
3
|
+
const _ownerRlsRegistry = new WeakMap();
|
|
4
|
+
/**
|
|
5
|
+
* 테이블에 등록된 ownerRls 메타데이터를 반환한다.
|
|
6
|
+
* ownerRls()가 호출되지 않은 테이블은 undefined를 반환.
|
|
7
|
+
*/
|
|
8
|
+
export function getOwnerRlsMeta(table) {
|
|
9
|
+
return _ownerRlsRegistry.get(table);
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* 내부용: ownerRls()가 호출될 때 메타데이터를 레지스트리에 등록.
|
|
13
|
+
* pgTable의 extraConfig 콜백에서 호출되므로, 테이블 참조가 아닌
|
|
14
|
+
* 임시 프록시 객체가 전달될 수 있음 → registerOwnerRls()로 사후 등록.
|
|
15
|
+
*/
|
|
16
|
+
export function registerOwnerRls(table, meta) {
|
|
17
|
+
_ownerRlsRegistry.set(table, meta);
|
|
18
|
+
}
|
|
19
|
+
// ─── ownerRls — DB-level RLS 정책 선언 + 앱 레벨 메타데이터 등록 ──
|
|
20
|
+
/**
|
|
21
|
+
* 사용자 소유권 기반 RLS 정책을 선언한다.
|
|
22
|
+
*
|
|
23
|
+
* 2-Layer 방어:
|
|
24
|
+
* - Layer 1 (앱 레벨): crud()가 이 메타데이터를 감지하여
|
|
25
|
+
* 모든 CRUD 쿼리에 WHERE userId = auth.userId 자동 주입.
|
|
26
|
+
* PGlite + PostgreSQL 양쪽에서 동작.
|
|
27
|
+
* - Layer 2 (DB 레벨): PostgreSQL RLS 정책으로 심층 방어.
|
|
28
|
+
* createRlsDb()의 transaction() 내에서 set_config 주입.
|
|
29
|
+
*
|
|
30
|
+
* @example
|
|
31
|
+
* ```ts
|
|
32
|
+
* export const tasks = pgTable("tasks", {
|
|
33
|
+
* id: serial("id").primaryKey(),
|
|
34
|
+
* title: text("title").notNull(),
|
|
35
|
+
* userId: text("user_id").notNull(),
|
|
36
|
+
* }, (t) => ownerRls(t.userId));
|
|
37
|
+
*
|
|
38
|
+
* // read: "public" — 누구나 읽기 가능, CUD는 소유자만
|
|
39
|
+
* export const posts = pgTable("posts", {
|
|
40
|
+
* id: serial("id").primaryKey(),
|
|
41
|
+
* content: text("content").notNull(),
|
|
42
|
+
* userId: text("user_id").notNull(),
|
|
43
|
+
* }, (t) => ownerRls(t.userId, { read: "public" }));
|
|
44
|
+
* ```
|
|
45
|
+
*/
|
|
3
46
|
export function ownerRls(userIdColumn, options) {
|
|
4
|
-
|
|
5
|
-
|
|
47
|
+
// S3 방어: userIdColumn.name 미존재 시 명확한 에러
|
|
48
|
+
const colName = userIdColumn.name;
|
|
49
|
+
if (!colName) {
|
|
50
|
+
throw new Error("[ownerRls] userIdColumn must have a .name property. " +
|
|
51
|
+
"Ensure you pass a valid Drizzle column reference (e.g. t.userId).");
|
|
52
|
+
}
|
|
53
|
+
/** `missing_ok` avoids errors before first `set_config` and matches PG custom GUC behavior in PGlite. */
|
|
54
|
+
const isOwner = sql `${userIdColumn} = current_setting('app.current_user_id', true)`;
|
|
55
|
+
// ── 앱 레벨 메타데이터: crud()가 런타임에 읽음 ──
|
|
56
|
+
const meta = {
|
|
57
|
+
columnName: colName,
|
|
58
|
+
readPublic: options?.read === "public",
|
|
59
|
+
};
|
|
60
|
+
const policies = [
|
|
6
61
|
pgPolicy("rls-select", { for: "select", using: options?.read === "public" ? sql `true` : isOwner }),
|
|
7
62
|
pgPolicy("rls-insert", { for: "insert", withCheck: isOwner }),
|
|
8
63
|
pgPolicy("rls-update", { for: "update", using: isOwner, withCheck: isOwner }),
|
|
9
64
|
pgPolicy("rls-delete", { for: "delete", using: isOwner }),
|
|
10
65
|
];
|
|
66
|
+
// N2 정리: non-enumerable _ownerRlsMeta 마커 제거 (dead code).
|
|
67
|
+
// ownerRls()는 pgTable extraConfig 콜백에서 호출되며, 이 시점에서는
|
|
68
|
+
// 테이블 참조가 프록시이므로 WeakMap 등록 불가능.
|
|
69
|
+
// 실제 메타데이터 등록은 crud()의 detectOwnerMeta() fallback에서 수행.
|
|
70
|
+
return policies;
|
|
11
71
|
}
|
package/dist/server.d.ts
CHANGED
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
* executing server. Excluded from client-side core (`index.ts`) so they aren't
|
|
6
6
|
* bundled into user functions which run in Firecracker.
|
|
7
7
|
*/
|
|
8
|
-
export { createDb } from "./db";
|
|
9
|
-
export { createStorage, storageRoutes } from "./storage";
|
|
10
|
-
export {
|
|
11
|
-
export {
|
|
8
|
+
export { createDb } from "./db.js";
|
|
9
|
+
export { createStorage, storageRoutes } from "./storage.js";
|
|
10
|
+
export type { StorageImageTierConfig } from "./storage.js";
|
|
11
|
+
export { createScheduler, getSchedulerInfo } from "./scheduler.js";
|
|
12
|
+
export { authMiddleware, authRoutes, getUsers } from "./auth.js";
|
package/dist/server.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* executing server. Excluded from client-side core (`index.ts`) so they aren't
|
|
6
6
|
* bundled into user functions which run in Firecracker.
|
|
7
7
|
*/
|
|
8
|
-
export { createDb } from "./db";
|
|
9
|
-
export { createStorage, storageRoutes } from "./storage";
|
|
10
|
-
export { createScheduler, getSchedulerInfo } from "./scheduler";
|
|
11
|
-
export { authMiddleware, authRoutes, getUsers } from "./auth";
|
|
8
|
+
export { createDb } from "./db.js";
|
|
9
|
+
export { createStorage, storageRoutes } from "./storage.js";
|
|
10
|
+
export { createScheduler, getSchedulerInfo } from "./scheduler.js";
|
|
11
|
+
export { authMiddleware, authRoutes, getUsers } from "./auth.js";
|
package/dist/storage.d.ts
CHANGED
|
@@ -35,15 +35,42 @@ export interface Storage {
|
|
|
35
35
|
* const url = storage.getUrl(id);
|
|
36
36
|
*/
|
|
37
37
|
export declare function createStorage(dir?: string, options?: StorageOptions): Storage;
|
|
38
|
+
/** Tier별 이미지 최적화 설정 — Platform에서 주입 */
|
|
39
|
+
export interface StorageImageTierConfig {
|
|
40
|
+
/** Auto WebP 허용 여부 (기본 true) */
|
|
41
|
+
autoWebp?: boolean;
|
|
42
|
+
/** Auto WebP 시 최대 폭 — 초과 시 자동 축소 (Hobby=1920, Pro=3840, Scale=무제한) */
|
|
43
|
+
autoMaxWidth?: number;
|
|
44
|
+
/** 리사이즈 허용 여부 */
|
|
45
|
+
resize?: boolean;
|
|
46
|
+
/** 허용 포맷 목록 */
|
|
47
|
+
formats?: string[];
|
|
48
|
+
/** 품질 조절 허용 여부 */
|
|
49
|
+
qualityControl?: boolean;
|
|
50
|
+
/** 캐시 상한 (MB) */
|
|
51
|
+
cacheMaxMB?: number;
|
|
52
|
+
/** 월간 변환 횟수 상한 (-1 = 무제한) */
|
|
53
|
+
transformsPerMonth?: number;
|
|
54
|
+
/** Auto WebP 품질 오버라이드 (1-100, 기본 75) — 앱별 설정에서 주입 */
|
|
55
|
+
autoQuality?: number;
|
|
56
|
+
}
|
|
38
57
|
/**
|
|
39
|
-
* Hono routes for serving stored files
|
|
58
|
+
* Hono routes for serving stored files + Image Optimization
|
|
40
59
|
*
|
|
41
60
|
* 인증 없이 public URL로 서빙 — Convex getUrl() 패턴과 동일.
|
|
42
61
|
* 접근 제어는 URL을 반환하는 query/mutation 레벨에서 개발자가 구현.
|
|
62
|
+
*
|
|
63
|
+
* 이미지 최적화:
|
|
64
|
+
* - Auto WebP: Accept: image/webp 헤더 감지 → 자동 WebP 변환
|
|
65
|
+
* - URL 파라미터: ?w=300&h=200&f=webp&q=80&fit=cover
|
|
66
|
+
* - 디스크 캐시: uploads/.cache/{uuid}_{params}.{ext}
|
|
67
|
+
* - 원본 보존: 원본 파일은 절대 수정하지 않음
|
|
43
68
|
*/
|
|
44
|
-
export declare function storageRoutes(storage: ReturnType<typeof createStorage>, rawSql?: (sql: string, params?: unknown[]) => Promise<unknown[]>, storageDir?: string): (c: {
|
|
69
|
+
export declare function storageRoutes(storage: ReturnType<typeof createStorage>, rawSql?: (sql: string, params?: unknown[]) => Promise<unknown[]>, storageDir?: string, tierConfig?: StorageImageTierConfig): (c: {
|
|
45
70
|
req: {
|
|
46
71
|
param: (key: string) => string;
|
|
72
|
+
query: (key: string) => string | undefined;
|
|
73
|
+
header: (name: string) => string | undefined;
|
|
47
74
|
};
|
|
48
75
|
json: (data: unknown, status?: number) => Response;
|
|
49
76
|
body: (data: unknown, status: number, headers: Record<string, string>) => Response;
|
package/dist/storage.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import * as fs from "fs/promises";
|
|
2
|
+
import * as fsSync from "fs";
|
|
2
3
|
import * as path from "path";
|
|
3
4
|
import * as crypto from "crypto";
|
|
4
5
|
// ─── Constants ──────────────────────────────────────────
|
|
@@ -105,6 +106,8 @@ export function createStorage(dir = "./uploads", options) {
|
|
|
105
106
|
const arrayBuffer = await file.arrayBuffer();
|
|
106
107
|
await fs.writeFile(filePath, Buffer.from(arrayBuffer));
|
|
107
108
|
const type = file.type || "application/octet-stream";
|
|
109
|
+
// .meta JSON 기록 — Platform 레벨 직접 서빙용 (앱 DB 접근 불필요)
|
|
110
|
+
await fs.writeFile(`${filePath}.meta`, JSON.stringify({ name, type, size: file.size })).catch(() => { });
|
|
108
111
|
metaStore.set(id, {
|
|
109
112
|
id,
|
|
110
113
|
name,
|
|
@@ -137,6 +140,8 @@ export function createStorage(dir = "./uploads", options) {
|
|
|
137
140
|
const id = crypto.randomUUID();
|
|
138
141
|
const filePath = path.join(dir, id);
|
|
139
142
|
await fs.writeFile(filePath, buffer);
|
|
143
|
+
// .meta JSON 기록 — Platform 레벨 직접 서빙용
|
|
144
|
+
await fs.writeFile(`${filePath}.meta`, JSON.stringify({ name: filename, type, size: buffer.length })).catch(() => { });
|
|
140
145
|
metaStore.set(id, {
|
|
141
146
|
id,
|
|
142
147
|
name: filename,
|
|
@@ -166,8 +171,26 @@ export function createStorage(dir = "./uploads", options) {
|
|
|
166
171
|
const meta = metaStore.get(storageId);
|
|
167
172
|
if (meta) {
|
|
168
173
|
await fs.unlink(meta.path).catch(() => { });
|
|
174
|
+
// .meta JSON 동시 삭제
|
|
175
|
+
await fs.unlink(`${meta.path}.meta`).catch(() => { });
|
|
169
176
|
metaStore.delete(storageId);
|
|
170
177
|
}
|
|
178
|
+
else {
|
|
179
|
+
// metaStore에 없어도 디스크에서 직접 삭제 시도
|
|
180
|
+
const filePath = path.join(dir, storageId);
|
|
181
|
+
await fs.unlink(filePath).catch(() => { });
|
|
182
|
+
await fs.unlink(`${filePath}.meta`).catch(() => { });
|
|
183
|
+
}
|
|
184
|
+
// 이미지 캐시 삭제 — uploads/.cache/{storageId}_* glob
|
|
185
|
+
const cacheDir = path.join(dir, ".cache");
|
|
186
|
+
try {
|
|
187
|
+
const entries = await fs.readdir(cacheDir);
|
|
188
|
+
const prefix = `${storageId}_`;
|
|
189
|
+
await Promise.all(entries
|
|
190
|
+
.filter(e => e.startsWith(prefix))
|
|
191
|
+
.map(e => fs.unlink(path.join(cacheDir, e)).catch(() => { })));
|
|
192
|
+
}
|
|
193
|
+
catch { /* .cache 디렉토리 미존재 시 무시 */ }
|
|
171
194
|
// DB에서도 삭제 (rawSql 있을 때만)
|
|
172
195
|
if (rawSql) {
|
|
173
196
|
try {
|
|
@@ -178,14 +201,204 @@ export function createStorage(dir = "./uploads", options) {
|
|
|
178
201
|
},
|
|
179
202
|
};
|
|
180
203
|
}
|
|
204
|
+
// ─── Image Optimization Constants ───────────────────────
|
|
205
|
+
/** 변환 가능한 원본 MIME 타입 */
|
|
206
|
+
const TRANSFORMABLE_TYPES = new Set([
|
|
207
|
+
"image/png", "image/jpeg", "image/jpg", "image/webp",
|
|
208
|
+
]);
|
|
209
|
+
/** 허용 출력 포맷 */
|
|
210
|
+
const ALLOWED_FORMATS = new Set(["webp", "avif", "jpeg", "png"]);
|
|
211
|
+
/** 허용 맞춤 모드 */
|
|
212
|
+
const ALLOWED_FIT_MODES = new Set(["cover", "contain", "fill", "inside"]);
|
|
213
|
+
/** 최대 치수 (px) — DoS 방지 */
|
|
214
|
+
const MAX_DIMENSION = 4096;
|
|
215
|
+
/** 기본 품질 */
|
|
216
|
+
const DEFAULT_QUALITY = 80;
|
|
217
|
+
/** Auto WebP 전용 설정 — JPEG에서도 효과를 내려면 q75 + effort 6 필요
|
|
218
|
+
* (테스트: 2.5MB JPEG → q80 effort4=더커짐, q75 effort6=15.6% 감소) */
|
|
219
|
+
const AUTO_WEBP_QUALITY = 75;
|
|
220
|
+
const AUTO_WEBP_EFFORT = 6;
|
|
221
|
+
/** 앱당 최대 캐시 엔트리 수 — 랜덤 파라미터 DoS 방지 */
|
|
222
|
+
const MAX_CACHE_ENTRIES = 1000;
|
|
223
|
+
// ─── Image Optimization Helpers ─────────────────────────
|
|
224
|
+
/** 동시 변환 세마포어 — CPU 폭발 방지 (최대 3 동시) */
|
|
225
|
+
let activeTransforms = 0;
|
|
226
|
+
const MAX_CONCURRENT_TRANSFORMS = 3;
|
|
227
|
+
const transformQueue = [];
|
|
228
|
+
function acquireTransformSlot() {
|
|
229
|
+
if (activeTransforms < MAX_CONCURRENT_TRANSFORMS) {
|
|
230
|
+
activeTransforms++;
|
|
231
|
+
return Promise.resolve();
|
|
232
|
+
}
|
|
233
|
+
return new Promise((resolve) => {
|
|
234
|
+
transformQueue.push(() => {
|
|
235
|
+
activeTransforms++;
|
|
236
|
+
resolve();
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
function releaseTransformSlot() {
|
|
241
|
+
activeTransforms--;
|
|
242
|
+
const next = transformQueue.shift();
|
|
243
|
+
if (next)
|
|
244
|
+
next();
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* 쿼리 파라미터를 파싱 + 정규화 + 검증
|
|
248
|
+
* Hono의 c.req.query()로 추출한 개별 값을 받아 정규화.
|
|
249
|
+
* @returns null if no transform params, parsed params otherwise
|
|
250
|
+
*/
|
|
251
|
+
function parseTransformParams(wStr, hStr, fStr, qStr, fitStr) {
|
|
252
|
+
const params = {};
|
|
253
|
+
let hasParam = false;
|
|
254
|
+
if (wStr) {
|
|
255
|
+
const w = parseInt(wStr, 10);
|
|
256
|
+
if (isNaN(w) || w < 1 || w > MAX_DIMENSION)
|
|
257
|
+
return null;
|
|
258
|
+
params.w = w;
|
|
259
|
+
hasParam = true;
|
|
260
|
+
}
|
|
261
|
+
if (hStr) {
|
|
262
|
+
const h = parseInt(hStr, 10);
|
|
263
|
+
if (isNaN(h) || h < 1 || h > MAX_DIMENSION)
|
|
264
|
+
return null;
|
|
265
|
+
params.h = h;
|
|
266
|
+
hasParam = true;
|
|
267
|
+
}
|
|
268
|
+
if (fStr) {
|
|
269
|
+
const fmt = fStr.toLowerCase();
|
|
270
|
+
if (!ALLOWED_FORMATS.has(fmt))
|
|
271
|
+
return null;
|
|
272
|
+
params.f = fmt;
|
|
273
|
+
hasParam = true;
|
|
274
|
+
}
|
|
275
|
+
if (qStr) {
|
|
276
|
+
const q = parseInt(qStr, 10);
|
|
277
|
+
if (isNaN(q) || q < 1 || q > 100)
|
|
278
|
+
return null;
|
|
279
|
+
params.q = q;
|
|
280
|
+
hasParam = true;
|
|
281
|
+
}
|
|
282
|
+
if (fitStr) {
|
|
283
|
+
const fitMode = fitStr.toLowerCase();
|
|
284
|
+
if (!ALLOWED_FIT_MODES.has(fitMode))
|
|
285
|
+
return null;
|
|
286
|
+
params.fit = fitMode;
|
|
287
|
+
hasParam = true;
|
|
288
|
+
}
|
|
289
|
+
return hasParam ? params : null;
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* 변환 파라미터에서 캐시 키 생성
|
|
293
|
+
* 결정론적: 동일 파라미터 → 동일 키
|
|
294
|
+
*/
|
|
295
|
+
function buildCacheKey(storageId, params, isAutoWebp, autoMaxWidth, autoQuality) {
|
|
296
|
+
if (isAutoWebp) {
|
|
297
|
+
// autoMaxWidth/autoQuality가 있으면 캐시 키에 포함 (Tier/앱별 다른 크기/품질)
|
|
298
|
+
const mwSuffix = autoMaxWidth ? `_mw${autoMaxWidth}` : "";
|
|
299
|
+
const qSuffix = autoQuality && autoQuality !== AUTO_WEBP_QUALITY ? `_q${autoQuality}` : "";
|
|
300
|
+
return `${storageId}_auto_webp${mwSuffix}${qSuffix}.webp`;
|
|
301
|
+
}
|
|
302
|
+
const parts = [storageId];
|
|
303
|
+
if (params.w)
|
|
304
|
+
parts.push(`w${params.w}`);
|
|
305
|
+
if (params.h)
|
|
306
|
+
parts.push(`h${params.h}`);
|
|
307
|
+
const fmt = params.f || "webp";
|
|
308
|
+
parts.push(`f${fmt}`);
|
|
309
|
+
parts.push(`q${params.q || DEFAULT_QUALITY}`);
|
|
310
|
+
if (params.fit)
|
|
311
|
+
parts.push(params.fit);
|
|
312
|
+
return `${parts.join("_")}.${fmt === "jpeg" ? "jpg" : fmt}`;
|
|
313
|
+
}
|
|
181
314
|
/**
|
|
182
|
-
*
|
|
315
|
+
* 캐시 디렉토리의 엔트리 수 확인 — DoS 방지
|
|
316
|
+
*/
|
|
317
|
+
async function getCacheEntryCount(cacheDir) {
|
|
318
|
+
try {
|
|
319
|
+
const entries = await fs.readdir(cacheDir);
|
|
320
|
+
return entries.length;
|
|
321
|
+
}
|
|
322
|
+
catch {
|
|
323
|
+
return 0;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* Hono routes for serving stored files + Image Optimization
|
|
183
328
|
*
|
|
184
329
|
* 인증 없이 public URL로 서빙 — Convex getUrl() 패턴과 동일.
|
|
185
330
|
* 접근 제어는 URL을 반환하는 query/mutation 레벨에서 개발자가 구현.
|
|
331
|
+
*
|
|
332
|
+
* 이미지 최적화:
|
|
333
|
+
* - Auto WebP: Accept: image/webp 헤더 감지 → 자동 WebP 변환
|
|
334
|
+
* - URL 파라미터: ?w=300&h=200&f=webp&q=80&fit=cover
|
|
335
|
+
* - 디스크 캐시: uploads/.cache/{uuid}_{params}.{ext}
|
|
336
|
+
* - 원본 보존: 원본 파일은 절대 수정하지 않음
|
|
186
337
|
*/
|
|
187
|
-
export function storageRoutes(storage, rawSql, storageDir) {
|
|
338
|
+
export function storageRoutes(storage, rawSql, storageDir, tierConfig) {
|
|
339
|
+
const baseTierConfig = {
|
|
340
|
+
autoWebp: tierConfig?.autoWebp ?? true,
|
|
341
|
+
autoMaxWidth: tierConfig?.autoMaxWidth ?? 0, // 0 = 제한 없음
|
|
342
|
+
resize: tierConfig?.resize ?? true,
|
|
343
|
+
formats: tierConfig?.formats ?? ["webp", "avif", "jpeg", "png"],
|
|
344
|
+
qualityControl: tierConfig?.qualityControl ?? true,
|
|
345
|
+
cacheMaxMB: tierConfig?.cacheMaxMB ?? 1024, // 기본 1GB
|
|
346
|
+
transformsPerMonth: tierConfig?.transformsPerMonth ?? -1, // 무제한
|
|
347
|
+
autoQuality: tierConfig?.autoQuality ?? AUTO_WEBP_QUALITY,
|
|
348
|
+
};
|
|
349
|
+
/**
|
|
350
|
+
* 매 요청마다 uploads/.image-config.json 파일에서 앱별 이미지 설정을 읽어
|
|
351
|
+
* Tier config와 병합한 최종 config를 반환.
|
|
352
|
+
*
|
|
353
|
+
* 파일 기반 설계 이유:
|
|
354
|
+
* - env var 방식은 전파 파이프라인이 6단계(DB→provisioner→env→dist→handler)로 깨지기 쉬움
|
|
355
|
+
* - 파일은 대시보드에서 write → 다음 요청에서 즉시 read (2단계)
|
|
356
|
+
* - 어느 프로세스든(Platform/앱) 동일 파일을 읽으므로 일관성 보장
|
|
357
|
+
* - readFileSync ~0.01ms, 성능 영향 없음
|
|
358
|
+
*/
|
|
359
|
+
function getImageConfig(uploadsDir) {
|
|
360
|
+
const config = { ...baseTierConfig };
|
|
361
|
+
try {
|
|
362
|
+
const raw = fsSync.readFileSync(path.join(uploadsDir, ".image-config.json"), "utf-8");
|
|
363
|
+
const appConfig = JSON.parse(raw);
|
|
364
|
+
// Tier ceiling 적용: min(앱 설정, Tier 최대값)
|
|
365
|
+
if (appConfig.autoMaxWidth > 0) {
|
|
366
|
+
config.autoMaxWidth = config.autoMaxWidth > 0
|
|
367
|
+
? Math.min(appConfig.autoMaxWidth, config.autoMaxWidth)
|
|
368
|
+
: appConfig.autoMaxWidth;
|
|
369
|
+
}
|
|
370
|
+
if (appConfig.autoQuality > 0 && appConfig.autoQuality <= 100) {
|
|
371
|
+
config.autoQuality = appConfig.autoQuality;
|
|
372
|
+
}
|
|
373
|
+
if (appConfig.autoWebp === false) {
|
|
374
|
+
config.autoWebp = false;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
catch { /* 파일 없음 or 파싱 실패 → Tier 기본값 유지 */ }
|
|
378
|
+
return config;
|
|
379
|
+
}
|
|
380
|
+
// sharp 모듈 캐시 (동적 import 1회만)
|
|
381
|
+
let sharpModule = null;
|
|
382
|
+
let sharpLoadAttempted = false;
|
|
383
|
+
async function getSharp() {
|
|
384
|
+
if (sharpLoadAttempted)
|
|
385
|
+
return sharpModule;
|
|
386
|
+
sharpLoadAttempted = true;
|
|
387
|
+
try {
|
|
388
|
+
// @ts-ignore — sharp는 런타임 선택적 의존성 (설치 안 된 환경에서도 빌드 가능)
|
|
389
|
+
sharpModule = (await import("sharp")).default;
|
|
390
|
+
}
|
|
391
|
+
catch {
|
|
392
|
+
// sharp 미설치 시 null → 원본 서빙 fallback
|
|
393
|
+
console.warn("[storage] sharp not available — image optimization disabled");
|
|
394
|
+
sharpModule = null;
|
|
395
|
+
}
|
|
396
|
+
return sharpModule;
|
|
397
|
+
}
|
|
188
398
|
return async (c) => {
|
|
399
|
+
// 매 요청마다 uploads/.image-config.json에서 config 읽기 (파일 기반 hot-reload)
|
|
400
|
+
const dir = storageDir || "./uploads";
|
|
401
|
+
const config = getImageConfig(dir);
|
|
189
402
|
const id = c.req.param("id");
|
|
190
403
|
let meta = await storage.getMeta(id);
|
|
191
404
|
// Fallback: DB lookup when in-memory meta is missing (e.g. after server restart)
|
|
@@ -209,21 +422,196 @@ export function storageRoutes(storage, rawSql, storageDir) {
|
|
|
209
422
|
if (!meta) {
|
|
210
423
|
return c.json({ error: "Not found" }, 404);
|
|
211
424
|
}
|
|
212
|
-
//
|
|
213
|
-
|
|
425
|
+
// ── 이미지 최적화 분기 ─────────────────────────────
|
|
426
|
+
const isTransformable = TRANSFORMABLE_TYPES.has(meta.type);
|
|
427
|
+
// Hono native query 파서로 파라미터 추출
|
|
428
|
+
const transformParams = isTransformable
|
|
429
|
+
? parseTransformParams(c.req.query("w"), c.req.query("h"), c.req.query("f"), c.req.query("q"), c.req.query("fit"))
|
|
430
|
+
: null;
|
|
431
|
+
// Accept 헤더에서 WebP 지원 감지
|
|
432
|
+
const acceptHeader = c.req.header("accept") || "";
|
|
433
|
+
const clientAcceptsWebp = acceptHeader.includes("image/webp");
|
|
434
|
+
// Auto WebP: 파라미터 없지만 브라우저가 webp 지원 + 원본이 PNG/JPEG
|
|
435
|
+
const isAutoWebp = !transformParams
|
|
436
|
+
&& isTransformable
|
|
437
|
+
&& clientAcceptsWebp
|
|
438
|
+
&& config.autoWebp
|
|
439
|
+
&& (meta.type === "image/png" || meta.type === "image/jpeg" || meta.type === "image/jpg");
|
|
440
|
+
// 이미지 변환이 필요 없는 경우 → 원본 서빙 (기존 동작 100% 유지)
|
|
441
|
+
if (!transformParams && !isAutoWebp) {
|
|
442
|
+
return serveOriginal(c, meta);
|
|
443
|
+
}
|
|
444
|
+
// ── Tier 검증 (파라미터 변환 시) ──────────────────
|
|
445
|
+
if (transformParams) {
|
|
446
|
+
// 리사이즈 검증
|
|
447
|
+
if ((transformParams.w || transformParams.h) && !config.resize) {
|
|
448
|
+
return c.json({
|
|
449
|
+
error: "Image resize requires Pro plan",
|
|
450
|
+
code: "PLAN_LIMIT",
|
|
451
|
+
upgrade: "https://gencow.app/pricing",
|
|
452
|
+
}, 403);
|
|
453
|
+
}
|
|
454
|
+
// 포맷 검증
|
|
455
|
+
if (transformParams.f && !config.formats.includes(transformParams.f)) {
|
|
456
|
+
return c.json({
|
|
457
|
+
error: `Format "${transformParams.f}" requires a higher plan`,
|
|
458
|
+
code: "PLAN_LIMIT",
|
|
459
|
+
upgrade: "https://gencow.app/pricing",
|
|
460
|
+
allowed: { formats: config.formats },
|
|
461
|
+
}, 403);
|
|
462
|
+
}
|
|
463
|
+
// 품질 검증
|
|
464
|
+
if (transformParams.q && !config.qualityControl) {
|
|
465
|
+
return c.json({
|
|
466
|
+
error: "Quality control requires Pro plan",
|
|
467
|
+
code: "PLAN_LIMIT",
|
|
468
|
+
upgrade: "https://gencow.app/pricing",
|
|
469
|
+
}, 403);
|
|
470
|
+
}
|
|
471
|
+
// fit 검증 (resize와 함께만 동작)
|
|
472
|
+
if (transformParams.fit && !config.resize) {
|
|
473
|
+
return c.json({
|
|
474
|
+
error: "Fit mode requires Pro plan",
|
|
475
|
+
code: "PLAN_LIMIT",
|
|
476
|
+
upgrade: "https://gencow.app/pricing",
|
|
477
|
+
}, 403);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
// ── sharp 로드 확인 ─────────────────────────────
|
|
481
|
+
const sharp = await getSharp();
|
|
482
|
+
if (!sharp) {
|
|
483
|
+
// sharp 미설치 → 원본 서빙 (graceful degradation)
|
|
484
|
+
return serveOriginal(c, meta);
|
|
485
|
+
}
|
|
486
|
+
// ── 캐시 확인 ───────────────────────────────────
|
|
487
|
+
const cacheDir = path.join(dir, ".cache");
|
|
488
|
+
const cacheKey = buildCacheKey(id, transformParams || {}, isAutoWebp, config.autoMaxWidth || undefined, config.autoQuality);
|
|
489
|
+
const cachePath = path.join(cacheDir, cacheKey);
|
|
490
|
+
// 캐시 HIT
|
|
491
|
+
try {
|
|
492
|
+
await fs.access(cachePath);
|
|
493
|
+
// 캐시 파일이 존재 → 바로 서빙
|
|
494
|
+
return serveCachedFile(c, cachePath, transformParams, isAutoWebp, meta);
|
|
495
|
+
}
|
|
496
|
+
catch {
|
|
497
|
+
// 캐시 MISS → 변환 필요
|
|
498
|
+
}
|
|
499
|
+
// ── 캐시 엔트리 수 제한 확인 ─────────────────────
|
|
500
|
+
const cacheCount = await getCacheEntryCount(cacheDir);
|
|
501
|
+
if (cacheCount >= MAX_CACHE_ENTRIES) {
|
|
502
|
+
// 캐시 상한 초과 → 변환은 수행하되 캐시 저장 안 함 (or 원본 반환)
|
|
503
|
+
// 보안상 원본 서빙으로 fallback
|
|
504
|
+
return serveOriginal(c, meta);
|
|
505
|
+
}
|
|
506
|
+
// ── 이미지 변환 수행 ─────────────────────────────
|
|
507
|
+
try {
|
|
508
|
+
await acquireTransformSlot();
|
|
509
|
+
try {
|
|
510
|
+
// 캐시 디렉토리 생성
|
|
511
|
+
await fs.mkdir(cacheDir, { recursive: true });
|
|
512
|
+
// sharp 파이프라인 구성
|
|
513
|
+
let pipeline = sharp(meta.path);
|
|
514
|
+
// 리사이즈
|
|
515
|
+
if (transformParams?.w || transformParams?.h) {
|
|
516
|
+
const fitMode = (transformParams.fit || "cover");
|
|
517
|
+
pipeline = pipeline.resize({
|
|
518
|
+
width: transformParams.w || undefined,
|
|
519
|
+
height: transformParams.h || undefined,
|
|
520
|
+
fit: fitMode,
|
|
521
|
+
withoutEnlargement: true, // 원본보다 크게 확대하지 않음
|
|
522
|
+
});
|
|
523
|
+
}
|
|
524
|
+
else if (isAutoWebp && config.autoMaxWidth > 0) {
|
|
525
|
+
// Auto WebP 시 Tier별 최대폭 제한 (Hobby=1920, Pro=3840, Scale=0=무제한)
|
|
526
|
+
// 원본이 maxWidth 이하면 리사이즈하지 않음 (withoutEnlargement)
|
|
527
|
+
pipeline = pipeline.resize({
|
|
528
|
+
width: config.autoMaxWidth,
|
|
529
|
+
withoutEnlargement: true,
|
|
530
|
+
});
|
|
531
|
+
}
|
|
532
|
+
// 출력 포맷 + 품질
|
|
533
|
+
const outputFormat = transformParams?.f || (isAutoWebp ? "webp" : null);
|
|
534
|
+
const quality = transformParams?.q ?? DEFAULT_QUALITY;
|
|
535
|
+
if (outputFormat === "webp") {
|
|
536
|
+
pipeline = pipeline.webp(isAutoWebp
|
|
537
|
+
? { quality: config.autoQuality, effort: AUTO_WEBP_EFFORT, alphaQuality: 100 }
|
|
538
|
+
: { quality, alphaQuality: 100 });
|
|
539
|
+
}
|
|
540
|
+
else if (outputFormat === "avif") {
|
|
541
|
+
pipeline = pipeline.avif({ quality });
|
|
542
|
+
}
|
|
543
|
+
else if (outputFormat === "jpeg" || outputFormat === "jpg") {
|
|
544
|
+
pipeline = pipeline.jpeg({ quality, mozjpeg: true });
|
|
545
|
+
}
|
|
546
|
+
else if (outputFormat === "png") {
|
|
547
|
+
pipeline = pipeline.png({ compressionLevel: 9 });
|
|
548
|
+
}
|
|
549
|
+
// 변환 실행 → 캐시에 저장
|
|
550
|
+
await pipeline.toFile(cachePath);
|
|
551
|
+
// WebP/AVIF가 원본보다 큰 경우 → 캐시 삭제 + 원본 서빙
|
|
552
|
+
// (Static Deploy와 동일 전략 — apps.ts L840-847)
|
|
553
|
+
if (isAutoWebp) {
|
|
554
|
+
const cacheStats = await fs.stat(cachePath);
|
|
555
|
+
if (cacheStats.size >= meta.size) {
|
|
556
|
+
await fs.unlink(cachePath).catch(() => { });
|
|
557
|
+
return serveOriginal(c, meta);
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
return serveCachedFile(c, cachePath, transformParams, isAutoWebp, meta);
|
|
561
|
+
}
|
|
562
|
+
finally {
|
|
563
|
+
releaseTransformSlot();
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
catch (err) {
|
|
567
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
568
|
+
console.warn(`[storage] Image transform failed for ${id}: ${msg}`);
|
|
569
|
+
// 변환 실패 → 원본 서빙 (graceful degradation)
|
|
570
|
+
return serveOriginal(c, meta);
|
|
571
|
+
}
|
|
572
|
+
};
|
|
573
|
+
/** 원본 파일 서빙 (기존 동작) */
|
|
574
|
+
function serveOriginal(c, meta) {
|
|
214
575
|
const headers = {
|
|
215
576
|
"Content-Type": meta.type,
|
|
216
577
|
"Content-Disposition": `inline; filename="${encodeURIComponent(meta.name)}"; filename*=UTF-8''${encodeURIComponent(meta.name)}`,
|
|
217
578
|
"Cache-Control": "public, max-age=31536000, immutable",
|
|
218
579
|
};
|
|
219
|
-
// Bun은 Bun.file()로 직접 BunFile(Blob) 생성 → Response에 그대로 전달
|
|
220
580
|
if (typeof globalThis.Bun !== "undefined") {
|
|
221
581
|
const bunFile = Bun.file(meta.path);
|
|
222
582
|
return new Response(bunFile, { headers });
|
|
223
583
|
}
|
|
224
|
-
// Node.js 폴백
|
|
225
|
-
const file =
|
|
584
|
+
// Node.js 폴백 — 동기 readFile (async context에서 호출되므로 안전)
|
|
585
|
+
const file = require("fs").readFileSync(meta.path);
|
|
226
586
|
headers["Content-Length"] = String(file.byteLength);
|
|
227
587
|
return c.body(file, 200, headers);
|
|
228
|
-
}
|
|
588
|
+
}
|
|
589
|
+
/** 캐시된 변환 파일 서빙 */
|
|
590
|
+
function serveCachedFile(c, cachePath, params, isAutoWebp, originalMeta) {
|
|
591
|
+
const outputFormat = params?.f || (isAutoWebp ? "webp" : null);
|
|
592
|
+
const contentType = outputFormat
|
|
593
|
+
? `image/${outputFormat === "jpg" ? "jpeg" : outputFormat}`
|
|
594
|
+
: originalMeta.type;
|
|
595
|
+
// 변환된 파일명: original.png → original.webp (또는 original_300x200.webp)
|
|
596
|
+
const baseName = originalMeta.name.replace(/\.[^.]+$/, "");
|
|
597
|
+
const ext = outputFormat || originalMeta.name.split(".").pop() || "bin";
|
|
598
|
+
const suffix = params?.w || params?.h
|
|
599
|
+
? `_${params.w || "auto"}x${params.h || "auto"}`
|
|
600
|
+
: "";
|
|
601
|
+
const fileName = `${baseName}${suffix}.${ext}`;
|
|
602
|
+
const headers = {
|
|
603
|
+
"Content-Type": contentType,
|
|
604
|
+
"Content-Disposition": `inline; filename="${encodeURIComponent(fileName)}"; filename*=UTF-8''${encodeURIComponent(fileName)}`,
|
|
605
|
+
"Cache-Control": "public, max-age=31536000, immutable",
|
|
606
|
+
// Vary: Accept — Auto WebP 시 CDN/브라우저 캐시 분리
|
|
607
|
+
...(isAutoWebp ? { "Vary": "Accept" } : {}),
|
|
608
|
+
};
|
|
609
|
+
if (typeof globalThis.Bun !== "undefined") {
|
|
610
|
+
const bunFile = Bun.file(cachePath);
|
|
611
|
+
return new Response(bunFile, { headers });
|
|
612
|
+
}
|
|
613
|
+
const file = require("fs").readFileSync(cachePath);
|
|
614
|
+
headers["Content-Length"] = String(file.byteLength);
|
|
615
|
+
return c.body(file, 200, headers);
|
|
616
|
+
}
|
|
229
617
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gencow/core",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.22",
|
|
4
4
|
"description": "Gencow core library — defineQuery, defineMutation, reactive subscriptions",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
"src/"
|
|
23
23
|
],
|
|
24
24
|
"scripts": {
|
|
25
|
+
"db:generate:fixture-basic": "drizzle-kit generate --config ./src/__tests__/fixtures/basic/drizzle.config.ts",
|
|
25
26
|
"build": "tsc",
|
|
26
27
|
"typecheck": "tsc --noEmit",
|
|
27
28
|
"prepublishOnly": "npm run build",
|
|
@@ -37,6 +38,9 @@
|
|
|
37
38
|
"@types/bun": "^1.3.9",
|
|
38
39
|
"@types/node": "^25.3.0",
|
|
39
40
|
"@types/node-cron": "^3.0.11",
|
|
40
|
-
"
|
|
41
|
+
"drizzle-kit": "^0.31.10",
|
|
42
|
+
"drizzle-seed": "^0.3.1",
|
|
43
|
+
"typescript": "^5.9.3",
|
|
44
|
+
"uuid": "^13.0.0"
|
|
41
45
|
}
|
|
42
46
|
}
|