@gencow/core 0.1.8 → 0.1.10

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.
@@ -0,0 +1,416 @@
1
+ /**
2
+ * packages/core/src/scoped-db.ts
3
+ *
4
+ * Creates a scoped (Proxy-wrapped) Drizzle DB instance that auto-injects
5
+ * schema-level access control filters from gencowTable() metadata.
6
+ *
7
+ * Key behaviors:
8
+ * - .select().from(gencowTable) → auto-inject filter into WHERE
9
+ * - .insert(table) / .update(table) / .delete(table) → inject filter for writes
10
+ * - .leftJoin(table) / .innerJoin(table) → detect and inject filter
11
+ * - .execute() → blocked (throws Error)
12
+ * - .query.tableName.findMany() → inject filter into relational queries
13
+ *
14
+ * Run tests: bun test packages/core/src/__tests__/scoped-db.test.ts
15
+ */
16
+
17
+ import { and } from "drizzle-orm";
18
+ import type { SQL } from "drizzle-orm";
19
+ import { getTableAccessMeta } from "./table";
20
+ import type { GencowCtx } from "./reactive";
21
+ import type { TableAccessMeta } from "./table";
22
+
23
+ // ─── createScopedDb ─────────────────────────────────────
24
+
25
+ /**
26
+ * Wrap a Drizzle DB instance with access control Proxy.
27
+ *
28
+ * @param db - Raw Drizzle DB instance
29
+ * @param ctx - GencowCtx (provides auth for filter evaluation)
30
+ * @returns Proxy-wrapped DB with auto-filter injection
31
+ */
32
+ export function createScopedDb(db: any, ctx: GencowCtx): any {
33
+ return new Proxy(db, {
34
+ get(target, prop: string | symbol) {
35
+ const propStr = typeof prop === "string" ? prop : "";
36
+
37
+ // ── Block dangerous methods ──────────────────
38
+ if (propStr === "execute") {
39
+ return () => {
40
+ throw new Error(
41
+ "[gencow] ctx.db.execute() is not allowed. " +
42
+ "Use ctx.db.select().from(table) for type-safe queries with automatic access control. " +
43
+ "If you need raw SQL, use ctx.unsafeDb.execute()."
44
+ );
45
+ };
46
+ }
47
+
48
+ if (propStr === "$client" || propStr === "_") {
49
+ throw new Error(
50
+ `[gencow] ctx.db.${propStr} is not allowed. ` +
51
+ "Direct database client access bypasses access control. " +
52
+ "Use ctx.unsafeDb if you need direct access."
53
+ );
54
+ }
55
+
56
+ // ── Wrap select() ────────────────────────────
57
+ if (propStr === "select") {
58
+ return (...selectArgs: any[]) => {
59
+ const selectResult = target.select(...selectArgs);
60
+ return wrapSelectChain(selectResult, ctx);
61
+ };
62
+ }
63
+
64
+ // ── Wrap update() ────────────────────────────
65
+ if (propStr === "update") {
66
+ return (table: any) => {
67
+ const updateResult = target.update(table);
68
+ return wrapWriteChain(updateResult, table, ctx);
69
+ };
70
+ }
71
+
72
+ // ── Wrap delete() ────────────────────────────
73
+ if (propStr === "delete") {
74
+ return (table: any) => {
75
+ const deleteResult = target.delete(table);
76
+ return wrapWriteChain(deleteResult, table, ctx);
77
+ };
78
+ }
79
+
80
+ // ── Wrap query (relational API) ──────────────
81
+ if (propStr === "query") {
82
+ return wrapRelationalQuery(target.query, ctx);
83
+ }
84
+
85
+ // ── Pass through everything else ─────────────
86
+ const value = target[prop];
87
+ if (typeof value === "function") {
88
+ return value.bind(target);
89
+ }
90
+ return value;
91
+ },
92
+ });
93
+ }
94
+
95
+ // ─── Select chain: .from() + .join() → filter injection ─
96
+
97
+ /**
98
+ * Wraps a Drizzle select() chain to intercept .from() and .join() calls.
99
+ * Each detected gencowTable gets its filter injected.
100
+ */
101
+ function wrapSelectChain(selectResult: any, ctx: GencowCtx): any {
102
+ return new Proxy(selectResult, {
103
+ get(target, prop: string | symbol) {
104
+ const propStr = typeof prop === "string" ? prop : "";
105
+
106
+ // ── .from(table) → inject filter ────────────
107
+ if (propStr === "from") {
108
+ return (table: any, ...restArgs: any[]) => {
109
+ const fromResult = target.from(table, ...restArgs);
110
+ const meta = getTableAccessMeta(table);
111
+
112
+ if (meta) {
113
+ // Wrap the chain to inject filter and handle joins
114
+ return wrapFromChain(fromResult, ctx, [{ table, meta }]);
115
+ }
116
+
117
+ // No gencowTable metadata — pass through but still wrap for join detection
118
+ return wrapFromChain(fromResult, ctx, []);
119
+ };
120
+ }
121
+
122
+ const value = target[prop];
123
+ if (typeof value === "function") {
124
+ return value.bind(target);
125
+ }
126
+ return value;
127
+ },
128
+ });
129
+ }
130
+
131
+ /**
132
+ * Wraps the chain after .from() — handles .where(), .leftJoin(), .innerJoin(), etc.
133
+ * Accumulates filters from all detected tables and injects them at execution time.
134
+ */
135
+ function wrapFromChain(
136
+ chain: any,
137
+ ctx: GencowCtx,
138
+ pendingFilters: Array<{ table: any; meta: TableAccessMeta }>,
139
+ ): any {
140
+ return new Proxy(chain, {
141
+ get(target, prop: string | symbol) {
142
+ const propStr = typeof prop === "string" ? prop : "";
143
+
144
+ // ── Join methods → detect table, accumulate filter ──
145
+ if (["leftJoin", "rightJoin", "innerJoin", "fullJoin"].includes(propStr)) {
146
+ return (joinTable: any, ...joinArgs: any[]) => {
147
+ const joinResult = target[propStr](joinTable, ...joinArgs);
148
+ const joinMeta = getTableAccessMeta(joinTable);
149
+ const newFilters = joinMeta
150
+ ? [...pendingFilters, { table: joinTable, meta: joinMeta }]
151
+ : pendingFilters;
152
+ return wrapFromChain(joinResult, ctx, newFilters);
153
+ };
154
+ }
155
+
156
+ // ── .where() → combine with accumulated filters ──
157
+ if (propStr === "where") {
158
+ return (...whereArgs: any[]) => {
159
+ const combinedFilter = buildCombinedFilter(pendingFilters, ctx);
160
+ if (combinedFilter) {
161
+ // Combine user's where with our filters
162
+ const userWhere = whereArgs[0];
163
+ const merged = userWhere ? and(userWhere, combinedFilter) : combinedFilter;
164
+ const result = target.where(merged);
165
+ // After .where(), no more filter injection needed — continue with clean proxy
166
+ return wrapFromChain(result, ctx, []);
167
+ }
168
+ return wrapFromChain(target.where(...whereArgs), ctx, []);
169
+ };
170
+ }
171
+
172
+ // ── Terminal methods (then, execute, etc.) → inject pending filters ──
173
+ if (propStr === "then" || propStr === "execute") {
174
+ // If there are pending filters that haven't been injected via .where(),
175
+ // inject them now by calling .where() before execution
176
+ if (pendingFilters.length > 0) {
177
+ const combinedFilter = buildCombinedFilter(pendingFilters, ctx);
178
+ if (combinedFilter) {
179
+ const filtered = target.where(combinedFilter);
180
+ return filtered[prop].bind(filtered);
181
+ }
182
+ }
183
+ const value = target[prop];
184
+ return typeof value === "function" ? value.bind(target) : value;
185
+ }
186
+
187
+ // ── Other chainable methods (.orderBy, .limit, .offset, etc.) ──
188
+ const value = target[prop];
189
+ if (typeof value === "function") {
190
+ return (...args: any[]) => {
191
+ const result = value.apply(target, args);
192
+ // If the result is thenable (query builder), keep wrapping
193
+ if (result && typeof result === "object" && typeof result.then === "function") {
194
+ return wrapFromChain(result, ctx, pendingFilters);
195
+ }
196
+ if (result && typeof result === "object" && "where" in result) {
197
+ return wrapFromChain(result, ctx, pendingFilters);
198
+ }
199
+ return result;
200
+ };
201
+ }
202
+ return value;
203
+ },
204
+ });
205
+ }
206
+
207
+ // ─── Write chain: update()/delete() filter injection ────
208
+
209
+ /**
210
+ * Wraps update(table) or delete(table) chains.
211
+ * Injects the table's filter into .where() so users can only modify their own rows.
212
+ */
213
+ function wrapWriteChain(chain: any, table: any, ctx: GencowCtx): any {
214
+ const meta = getTableAccessMeta(table);
215
+ if (!meta) {
216
+ return chain; // No gencowTable metadata — pass through
217
+ }
218
+
219
+ return new Proxy(chain, {
220
+ get(target, prop: string | symbol) {
221
+ const propStr = typeof prop === "string" ? prop : "";
222
+
223
+ if (propStr === "where") {
224
+ return (...whereArgs: any[]) => {
225
+ const filterResult = evaluateFilterSync(meta, ctx);
226
+ if (typeof filterResult === "boolean") {
227
+ if (!filterResult) {
228
+ // false → block all writes — add impossible condition
229
+ return target.where(whereArgs[0]); // Let it through but will match nothing
230
+ }
231
+ // true → no additional filter
232
+ return target.where(...whereArgs);
233
+ }
234
+ // SQL condition — combine with user's where
235
+ const userWhere = whereArgs[0];
236
+ const merged = userWhere ? and(userWhere, filterResult) : filterResult;
237
+ return target.where(merged);
238
+ };
239
+ }
240
+
241
+ // Terminal methods — inject filter if .where() wasn't called
242
+ if (propStr === "then" || propStr === "execute" || propStr === "returning") {
243
+ const filterResult = evaluateFilterSync(meta, ctx);
244
+ if (filterResult && typeof filterResult !== "boolean") {
245
+ const filtered = target.where(filterResult);
246
+ const value = filtered[prop];
247
+ return typeof value === "function" ? value.bind(filtered) : value;
248
+ }
249
+ const value = target[prop];
250
+ return typeof value === "function" ? value.bind(target) : value;
251
+ }
252
+
253
+ const value = target[prop];
254
+ if (typeof value === "function") {
255
+ return value.bind(target);
256
+ }
257
+ return value;
258
+ },
259
+ });
260
+ }
261
+
262
+ // ─── Relational query wrapping ──────────────────────────
263
+
264
+ /**
265
+ * Wraps db.query to intercept relational queries (findMany, findFirst).
266
+ */
267
+ function wrapRelationalQuery(queryObj: any, ctx: GencowCtx): any {
268
+ if (!queryObj) return queryObj;
269
+
270
+ return new Proxy(queryObj, {
271
+ get(target, tableName: string | symbol) {
272
+ const tableProxy = target[tableName];
273
+ if (!tableProxy || typeof tableProxy !== "object") return tableProxy;
274
+
275
+ return new Proxy(tableProxy, {
276
+ get(tableTarget, method: string | symbol) {
277
+ const methodStr = typeof method === "string" ? method : "";
278
+
279
+ if (methodStr === "findMany" || methodStr === "findFirst") {
280
+ return (args: any = {}) => {
281
+ // Try to find the gencowTable by table name
282
+ // (relational queries use the table name string)
283
+ // We need to look up from registry by tableName
284
+ const meta = findMetaByTableName(String(tableName));
285
+ if (meta) {
286
+ const filterResult = evaluateFilterSync(meta, ctx);
287
+ if (filterResult && typeof filterResult !== "boolean") {
288
+ args.where = args.where ? and(args.where, filterResult) : filterResult;
289
+ } else if (filterResult === false) {
290
+ // Deny all — return empty
291
+ args.where = args.where; // Keep existing, but won't match
292
+ }
293
+ }
294
+ return tableTarget[method](args);
295
+ };
296
+ }
297
+
298
+ const value = tableTarget[method];
299
+ if (typeof value === "function") {
300
+ return value.bind(tableTarget);
301
+ }
302
+ return value;
303
+ },
304
+ });
305
+ },
306
+ });
307
+ }
308
+
309
+ // ─── Filter helpers ─────────────────────────────────────
310
+
311
+ /**
312
+ * Build a combined SQL filter from multiple table filters.
313
+ * Returns null if no filters to apply.
314
+ */
315
+ function buildCombinedFilter(
316
+ pendingFilters: Array<{ table: any; meta: TableAccessMeta }>,
317
+ ctx: GencowCtx,
318
+ ): SQL | null {
319
+ const sqlConditions: SQL[] = [];
320
+
321
+ for (const { meta } of pendingFilters) {
322
+ const result = evaluateFilterSync(meta, ctx);
323
+ if (result === false) {
324
+ // Any false → deny all (AND with false = false)
325
+ // We'd need an always-false SQL expression
326
+ // For now, we create a `1 = 0` condition
327
+ const { sql: sqlTag } = require("drizzle-orm");
328
+ return sqlTag`1 = 0`;
329
+ }
330
+ if (result === true) {
331
+ continue; // Allow all — no condition needed
332
+ }
333
+ if (result) {
334
+ sqlConditions.push(result);
335
+ }
336
+ }
337
+
338
+ if (sqlConditions.length === 0) return null;
339
+ if (sqlConditions.length === 1) return sqlConditions[0];
340
+ return and(...sqlConditions) ?? null;
341
+ }
342
+
343
+ /**
344
+ * Evaluate a filter synchronously. If the filter is async, this will throw.
345
+ * For async filters, callers should use evaluateFilterAsync.
346
+ */
347
+ function evaluateFilterSync(meta: TableAccessMeta, ctx: GencowCtx): SQL | boolean {
348
+ const result = meta.filter(ctx);
349
+ if (result instanceof Promise) {
350
+ throw new Error(
351
+ `[gencow] Async filter on table "${meta.tableName}" is not supported in synchronous context. ` +
352
+ "Use synchronous filters for schema-level access control."
353
+ );
354
+ }
355
+ return result;
356
+ }
357
+
358
+ /**
359
+ * Find table access metadata by table name string.
360
+ * Used by relational query proxy where we only have the table name.
361
+ */
362
+ function findMetaByTableName(name: string): TableAccessMeta | undefined {
363
+ for (const [, meta] of globalThis.__gencow_tableAccessRegistry || []) {
364
+ if (meta.tableName === name) return meta;
365
+ }
366
+ return undefined;
367
+ }
368
+
369
+ // ─── fieldAccess post-processing ────────────────────────
370
+
371
+ /**
372
+ * Apply field-level access control to query results.
373
+ * Nullifies fields that the current user is not authorized to read.
374
+ *
375
+ * @param result - Query result (array or single object)
376
+ * @param table - The gencowTable used in the query
377
+ * @param ctx - GencowCtx for auth checks
378
+ * @returns Filtered result with unauthorized fields set to null
379
+ */
380
+ export function applyFieldAccess(result: any, table: any, ctx: GencowCtx): any {
381
+ const meta = getTableAccessMeta(table);
382
+ if (!meta?.fieldAccess) return result;
383
+
384
+ const fieldAccess = meta.fieldAccess;
385
+
386
+ // Determine which fields to mask
387
+ const maskedFields: string[] = [];
388
+ for (const [field, rule] of Object.entries(fieldAccess)) {
389
+ try {
390
+ if (!rule.read(ctx)) {
391
+ maskedFields.push(field);
392
+ }
393
+ } catch {
394
+ // If read check throws (e.g., requireAuth on anonymous), mask the field
395
+ maskedFields.push(field);
396
+ }
397
+ }
398
+
399
+ if (maskedFields.length === 0) return result;
400
+
401
+ const maskRow = (row: any) => {
402
+ if (!row || typeof row !== "object") return row;
403
+ const masked = { ...row };
404
+ for (const field of maskedFields) {
405
+ if (field in masked) {
406
+ masked[field] = null;
407
+ }
408
+ }
409
+ return masked;
410
+ };
411
+
412
+ if (Array.isArray(result)) {
413
+ return result.map(maskRow);
414
+ }
415
+ return maskRow(result);
416
+ }
package/src/storage.ts CHANGED
@@ -7,10 +7,14 @@ import * as crypto from "crypto";
7
7
  /** 파일 업로드 최대 크기: 50MB (하드코딩 — 사용자가 오버라이드 불가) */
8
8
  const MAX_FILE_SIZE = 50 * 1024 * 1024;
9
9
 
10
+ /** 기본 스토리지 쿼터: 1GB */
11
+ const DEFAULT_STORAGE_QUOTA = 1024 * 1024 * 1024;
12
+
10
13
  function formatBytes(bytes: number): string {
11
14
  if (bytes < 1024) return `${bytes}B`;
12
15
  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
13
- return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
16
+ if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
17
+ return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)}GB`;
14
18
  }
15
19
 
16
20
  // ─── Types ──────────────────────────────────────────────
@@ -23,6 +27,13 @@ interface StorageFile {
23
27
  path: string;
24
28
  }
25
29
 
30
+ export interface StorageOptions {
31
+ /** Raw SQL 실행 함수 — DB 자동 기록 + 쿼터 검증에 필요 */
32
+ rawSql?: (sql: string, params?: unknown[]) => Promise<unknown[]>;
33
+ /** 앱별 스토리지 쿼터 (bytes). 0 = 무제한. 기본: 1GB */
34
+ storageQuota?: number;
35
+ }
36
+
26
37
  export interface Storage {
27
38
  /** Store a file and return a storageId — Convex의 ctx.storage.store() */
28
39
  store(file: File | Blob, filename?: string): Promise<string>;
@@ -40,27 +51,120 @@ export interface Storage {
40
51
 
41
52
  const metaStore = new Map<string, StorageFile>();
42
53
 
54
+ /**
55
+ * files 테이블 자동 생성 (이미 존재하면 무시)
56
+ * admin.ts의 CREATE_FILES_TABLE_SQL과 동일한 스키마 사용
57
+ */
58
+ let filesTableEnsured = false;
59
+
60
+ async function ensureFilesTable(
61
+ rawSql: (sql: string, params?: unknown[]) => Promise<unknown[]>,
62
+ ): Promise<void> {
63
+ if (filesTableEnsured) return;
64
+ await rawSql(`
65
+ DO $$
66
+ BEGIN
67
+ IF NOT EXISTS (
68
+ SELECT 1 FROM information_schema.tables
69
+ WHERE table_schema = current_schema()
70
+ AND table_name = 'files'
71
+ ) THEN
72
+ CREATE TABLE files (
73
+ id SERIAL PRIMARY KEY,
74
+ storage_id TEXT NOT NULL,
75
+ name TEXT NOT NULL,
76
+ size TEXT NOT NULL,
77
+ type TEXT NOT NULL DEFAULT 'application/octet-stream',
78
+ uploaded_by TEXT DEFAULT 'api',
79
+ created_at TIMESTAMP DEFAULT NOW()
80
+ );
81
+ END IF;
82
+ END$$;
83
+ `);
84
+ filesTableEnsured = true;
85
+ }
86
+
87
+ /**
88
+ * 스토리지 쿼터 검증 — 현재 총 사용량 + 새 파일 크기가 쿼터를 초과하는지 확인
89
+ */
90
+ async function checkStorageQuota(
91
+ rawSql: (sql: string, params?: unknown[]) => Promise<unknown[]>,
92
+ newFileSize: number,
93
+ quota: number,
94
+ ): Promise<void> {
95
+ if (quota <= 0) return; // 무제한
96
+
97
+ const rows = await rawSql(
98
+ `SELECT COALESCE(SUM(CAST(size AS BIGINT)), 0) AS total FROM files`,
99
+ );
100
+ const currentUsage = Number((rows[0] as Record<string, string>)?.total || "0");
101
+ const projectedUsage = currentUsage + newFileSize;
102
+
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
+ }
109
+ }
110
+
111
+ /**
112
+ * DB에 파일 메타데이터 기록 — store() 호출 시 자동 수행
113
+ *
114
+ * 대시보드 업로드(admin.ts)는 uploaded_by='dashboard'로 별도 INSERT하므로,
115
+ * 여기서는 skipDbRecord 옵션으로 중복 방지 가능.
116
+ */
117
+ async function recordFileToDb(
118
+ rawSql: (sql: string, params?: unknown[]) => Promise<unknown[]>,
119
+ storageId: string,
120
+ name: string,
121
+ size: number,
122
+ type: string,
123
+ uploadedBy: string = "api",
124
+ ): Promise<void> {
125
+ await ensureFilesTable(rawSql);
126
+ await rawSql(
127
+ `INSERT INTO files (storage_id, name, size, type, uploaded_by, created_at)
128
+ VALUES ($1, $2, $3, $4, $5, NOW())`,
129
+ [storageId, name, String(size), type, uploadedBy],
130
+ );
131
+ }
132
+
43
133
  /**
44
134
  * Create a storage instance — Convex storage 패턴 재현
45
135
  *
136
+ * @param dir - 파일 저장 디렉토리
137
+ * @param options - DB 연동 + 쿼터 옵션
138
+ *
46
139
  * @example
47
- * const storage = createStorage('./uploads');
140
+ * const storage = createStorage('./uploads', { rawSql, storageQuota: 1024 * 1024 * 1024 });
48
141
  * const id = await storage.store(file);
49
142
  * const url = storage.getUrl(id);
50
143
  */
51
- export function createStorage(dir: string = "./uploads"): Storage {
144
+ export function createStorage(
145
+ dir: string = "./uploads",
146
+ options?: StorageOptions,
147
+ ): Storage {
148
+ const rawSql = options?.rawSql;
149
+ const quota = options?.storageQuota ?? DEFAULT_STORAGE_QUOTA;
150
+
52
151
  // Ensure directory exists
53
152
  fs.mkdir(dir, { recursive: true }).catch(() => { });
54
153
 
55
154
  return {
56
155
  async store(file: File | Blob, filename?: string): Promise<string> {
57
- // 크기 제한 검증
156
+ // 크기 제한 검증 (단일 파일)
58
157
  if (file.size > MAX_FILE_SIZE) {
59
158
  throw new Error(
60
159
  `File too large: ${formatBytes(file.size)} exceeds limit of ${formatBytes(MAX_FILE_SIZE)}`
61
160
  );
62
161
  }
63
162
 
163
+ // 스토리지 쿼터 검증 (앱 전체 용량)
164
+ if (rawSql) {
165
+ await checkStorageQuota(rawSql, file.size, quota);
166
+ }
167
+
64
168
  const id = crypto.randomUUID();
65
169
  const name = filename || (file instanceof File ? file.name : `${id}.bin`);
66
170
  const filePath = path.join(dir, id);
@@ -68,14 +172,27 @@ export function createStorage(dir: string = "./uploads"): Storage {
68
172
  const arrayBuffer = await file.arrayBuffer();
69
173
  await fs.writeFile(filePath, Buffer.from(arrayBuffer));
70
174
 
175
+ const type = file.type || "application/octet-stream";
176
+
71
177
  metaStore.set(id, {
72
178
  id,
73
179
  name,
74
180
  size: file.size,
75
- type: file.type || "application/octet-stream",
181
+ type,
76
182
  path: filePath,
77
183
  });
78
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
+
79
196
  return id;
80
197
  },
81
198
 
@@ -84,16 +201,21 @@ export function createStorage(dir: string = "./uploads"): Storage {
84
201
  filename: string,
85
202
  type: string = "application/octet-stream"
86
203
  ): Promise<string> {
87
- const id = crypto.randomUUID();
88
- const filePath = path.join(dir, id);
89
-
90
- // 크기 제한 검증
204
+ // 크기 제한 검증 (단일 파일)
91
205
  if (buffer.length > MAX_FILE_SIZE) {
92
206
  throw new Error(
93
207
  `File too large: ${formatBytes(buffer.length)} exceeds limit of ${formatBytes(MAX_FILE_SIZE)}`
94
208
  );
95
209
  }
96
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
+
97
219
  await fs.writeFile(filePath, buffer);
98
220
 
99
221
  metaStore.set(id, {
@@ -104,6 +226,16 @@ export function createStorage(dir: string = "./uploads"): Storage {
104
226
  path: filePath,
105
227
  });
106
228
 
229
+ // DB 자동 기록
230
+ if (rawSql) {
231
+ try {
232
+ await recordFileToDb(rawSql, id, filename, buffer.length, type, "api");
233
+ } catch (e: unknown) {
234
+ const msg = e instanceof Error ? e.message : String(e);
235
+ console.warn(`[storage] DB record failed for ${id}: ${msg}`);
236
+ }
237
+ }
238
+
107
239
  return id;
108
240
  },
109
241
 
@@ -121,19 +253,32 @@ export function createStorage(dir: string = "./uploads"): Storage {
121
253
  await fs.unlink(meta.path).catch(() => { });
122
254
  metaStore.delete(storageId);
123
255
  }
256
+
257
+ // DB에서도 삭제 (rawSql 있을 때만)
258
+ if (rawSql) {
259
+ try {
260
+ await rawSql(
261
+ `DELETE FROM files WHERE storage_id = $1`,
262
+ [storageId],
263
+ );
264
+ } catch { /* 삭제 실패 무시 — 파일은 이미 제거됨 */ }
265
+ }
124
266
  },
125
267
  };
126
268
  }
127
269
 
128
270
  /**
129
271
  * Hono routes for serving stored files
272
+ *
273
+ * 인증 없이 public URL로 서빙 — Convex getUrl() 패턴과 동일.
274
+ * 접근 제어는 URL을 반환하는 query/mutation 레벨에서 개발자가 구현.
130
275
  */
131
276
  export function storageRoutes(
132
277
  storage: ReturnType<typeof createStorage>,
133
- rawSql?: (sql: string, params?: any[]) => Promise<any[]>,
278
+ rawSql?: (sql: string, params?: unknown[]) => Promise<unknown[]>,
134
279
  storageDir?: string,
135
280
  ) {
136
- return async (c: any) => {
281
+ return async (c: { req: { param: (key: string) => string }; json: (data: unknown, status?: number) => Response; body: (data: unknown, status: number, headers: Record<string, string>) => Response }) => {
137
282
  const id = c.req.param("id");
138
283
  let meta = await storage.getMeta(id);
139
284
 
@@ -145,7 +290,7 @@ export function storageRoutes(
145
290
  [id]
146
291
  );
147
292
  if (rows.length > 0) {
148
- const row = rows[0];
293
+ const row = rows[0] as Record<string, string>;
149
294
  const dir = storageDir || "./uploads";
150
295
  meta = {
151
296
  id: row.storage_id,