@fuzionx/framework 0.1.67 → 0.1.69

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.
@@ -9,7 +9,7 @@
9
9
  "preview": "vite preview"
10
10
  },
11
11
  "dependencies": {
12
- "@fuzionx/player": "^0.1.67",
12
+ "@fuzionx/player": "^0.1.69",
13
13
  "pinia": "^3.0.4",
14
14
  "vue": "^3.5.0",
15
15
  "vue-router": "^4.5.0"
@@ -11,6 +11,8 @@ export default defineConfig({
11
11
  },
12
12
  server: {
13
13
  port: 5173,
14
+ /** 리버스 프록시 경유 시 cross-origin 요청 허용 */
15
+ cors: true,
14
16
  proxy: {
15
17
  "/api": "http://127.0.0.1:49080",
16
18
  "/wasm": "http://127.0.0.1:49080",
@@ -291,6 +291,7 @@ export default class Application {
291
291
  this.storage = new Storage({
292
292
  driver: this.config.get('storage.driver', 'local'),
293
293
  basePath: path.resolve(this.baseDir, this.config.get('storage.path', './storage')),
294
+ s3: this.config.get('storage.s3') || null,
294
295
  fileHelper: this.file,
295
296
  });
296
297
 
@@ -367,10 +368,16 @@ export default class Application {
367
368
  this._booted = true;
368
369
 
369
370
  // hostname → appName 매핑 사전 계산 (매 요청 O(1) 조회)
371
+ // host:port 키와 hostname-only 키 모두 저장 (포트 제거 조회 호환)
370
372
  this._hostAppMap = new Map();
371
373
  const appsConfig = this.config.get('apps') || {};
372
- for (const [hostname, appName] of Object.entries(appsConfig)) {
373
- this._hostAppMap.set(hostname, appName);
374
+ for (const [hostKey, appName] of Object.entries(appsConfig)) {
375
+ this._hostAppMap.set(hostKey, appName);
376
+ /** 포트 제거한 hostname도 등록 (중복 시 마지막 우선) */
377
+ const hostnameOnly = hostKey.split(':')[0];
378
+ if (hostnameOnly !== hostKey) {
379
+ this._hostAppMap.set(hostnameOnly, appName);
380
+ }
374
381
  }
375
382
 
376
383
  await this.emit('booted');
@@ -402,23 +409,28 @@ export default class Application {
402
409
  /**
403
410
  * 호스트 → 앱 이름 결정
404
411
  * reverse proxy 경유 시 X-Forwarded-Host 우선 사용.
412
+ * 매핑되지 않은 도메인은 null 반환 (접근 차단).
405
413
  * @param {string} host - 요청 Host 헤더 (port 포함 가능)
406
414
  * @param {object} [headers] - 전체 요청 헤더 (proxy 지원)
407
- * @returns {string} 앱 이름
415
+ * @returns {string|null} 앱 이름 또는 null (미등록 도메인)
408
416
  */
409
417
  _resolveApp(host, headers) {
410
418
  // reverse proxy: X-Forwarded-Host > X-Original-Host > Host
411
419
  const forwardedHost = headers?.['x-forwarded-host'] || headers?.['X-Forwarded-Host']
412
420
  || headers?.['x-original-host'] || headers?.['X-Original-Host'];
413
421
  const rawHost = forwardedHost || host || '';
414
- const hostname = rawHost.split(':')[0]; // 포트 제거
415
422
 
416
- // 사전 계산된 Map O(1) 조회
417
- const mapped = this._hostAppMap?.get(hostname);
418
- if (mapped) return mapped;
423
+ // 1차: host:port 포함 전체 키로 조회
424
+ const fullMatch = this._hostAppMap?.get(rawHost);
425
+ if (fullMatch) return fullMatch;
426
+
427
+ // 2차: 포트 제거 hostname-only 조회
428
+ const hostname = rawHost.split(':')[0];
429
+ const hostMatch = this._hostAppMap?.get(hostname);
430
+ if (hostMatch) return hostMatch;
419
431
 
420
- // 매칭 없으면 기본
421
- return this._getDefaultAppName();
432
+ // 매칭 없음 null (미등록 도메인 접근 차단)
433
+ return null;
422
434
  }
423
435
 
424
436
  /**
@@ -753,17 +765,35 @@ export default class Application {
753
765
  return { status: 404, body: '{"error":"Not Found"}', headers: { 'Content-Type': 'application/json' } };
754
766
  }
755
767
 
768
+ // ── 도메인 검증 (모든 요청에 대해 수행) ──
769
+ const host = rawReq.headers?.host || '';
770
+ const resolvedAppName = this._resolveApp(host, rawReq.headers);
771
+
772
+ // 미등록 도메인 → 403 차단
773
+ if (!resolvedAppName) {
774
+ const reqHost = rawReq.headers?.host || 'unknown';
775
+ return {
776
+ status: 403,
777
+ body: JSON.stringify({ error: 'Forbidden', message: `도메인 '${reqHost}'은(는) 등록되지 않았습니다.` }),
778
+ headers: { 'Content-Type': 'application/json; charset=utf-8' },
779
+ };
780
+ }
781
+
756
782
  // ── 앱 결정 (단일 앱: O(0), 멀티앱: Host 기반) ──
757
783
  let appName, entry;
758
784
  if (dispatch.isSingleApp) {
759
785
  appName = dispatch.singleAppName;
760
786
  entry = dispatch.singleEntry;
761
787
  } else {
762
- const host = rawReq.headers?.host || '';
763
- appName = this._resolveApp(host, rawReq.headers);
788
+ appName = resolvedAppName;
764
789
  entry = dispatch.appMap.get(appName);
765
- if (!entry) entry = dispatch.appMap.get(this._getDefaultAppName());
766
- if (!entry) entry = dispatch.appMap.values().next().value;
790
+ if (!entry) {
791
+ return {
792
+ status: 403,
793
+ body: JSON.stringify({ error: 'Forbidden', message: `앱 '${appName}'을(를) 찾을 수 없습니다.` }),
794
+ headers: { 'Content-Type': 'application/json; charset=utf-8' },
795
+ };
796
+ }
767
797
  }
768
798
 
769
799
  const { middlewareFns, resolvedHandler } = entry;
@@ -87,7 +87,7 @@ export default class AutoLoader {
87
87
  const mod = await import(pathToFileURL(file).href);
88
88
  const ModelClass = mod.default;
89
89
  if (!ModelClass) continue;
90
- const name = extractName(file);
90
+ const name = extractName(file, 'Model');
91
91
  if (this.app.db) {
92
92
  this.app.db.register(name, ModelClass);
93
93
  }
@@ -104,7 +104,7 @@ export default class Model {
104
104
  }
105
105
 
106
106
  /**
107
- * JSON 직렬화 — hidden 필드 제외
107
+ * JSON 직렬화 — hidden 필드 제외 + 로드된 관계 + 동적 필드 포함
108
108
  * @returns {object}
109
109
  */
110
110
  toJSON() {
@@ -113,9 +113,43 @@ export default class Model {
113
113
  for (const key of hidden) {
114
114
  delete obj[key];
115
115
  }
116
+
117
+ // with()로 로드된 관계 데이터 포함
118
+ const relations = this.constructor.relations || {};
119
+ for (const relName of Object.keys(relations)) {
120
+ if (this[relName] !== undefined) {
121
+ const val = this[relName];
122
+ if (Array.isArray(val)) {
123
+ // hasMany: 배열 내 각 모델 인스턴스를 직렬화
124
+ obj[relName] = val.map(v => v?.toJSON ? v.toJSON() : v);
125
+ } else if (val?.toJSON) {
126
+ // hasOne/belongsTo: 단일 모델 인스턴스 직렬화
127
+ obj[relName] = val.toJSON();
128
+ } else {
129
+ obj[relName] = val;
130
+ }
131
+ }
132
+ }
133
+
134
+ // 동적으로 추가된 필드 포함 (e.g. sub_reseller_count, sub_member_count)
135
+ if (this._extras) {
136
+ Object.assign(obj, this._extras);
137
+ }
138
+
116
139
  return obj;
117
140
  }
118
141
 
142
+ /**
143
+ * 동적 필드 추가 — toJSON() 직렬화에 포함됨
144
+ *
145
+ * @param {string} key - 필드명
146
+ * @param {*} value - 값
147
+ */
148
+ setExtra(key, value) {
149
+ if (!this._extras) this._extras = {};
150
+ this._extras[key] = value;
151
+ }
152
+
119
153
  // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
120
154
  // 관계 (스텁 — 서브클래스에서 오버라이드)
121
155
  // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
@@ -350,21 +350,23 @@ export default class SqlQueryBuilder extends QueryBuilder {
350
350
  */
351
351
  async _loadRelations(results) {
352
352
  const modelRelations = this._model.relations || {};
353
-
353
+
354
354
  for (const relName of this._withs) {
355
355
  const rel = modelRelations[relName];
356
- if (!rel) continue; // 정의되지 않은 관계는 무시
356
+ if (!rel) {
357
+ continue;
358
+ }
357
359
 
358
360
  // 관계 대상 모델 클래스 resolve (문자열 → ModelRegistry에서 찾기)
359
361
  let RelatedModel = rel.model;
360
362
  if (typeof RelatedModel === 'string') {
361
363
  // SqlModel._modelRegistry를 통해 모델 클래스 resolve
362
364
  const registry = this._model._modelRegistry || this._model.constructor._modelRegistry;
363
- if (registry) {
364
- RelatedModel = registry.get(rel.model);
365
+ if (registry) {
366
+ RelatedModel = registry.get(rel.model);
365
367
  }
366
- if (!RelatedModel || typeof RelatedModel === 'string') {
367
- continue; // resolve 실패 시 스킵
368
+ if (!RelatedModel || typeof RelatedModel === 'string') {
369
+ continue;
368
370
  }
369
371
  }
370
372
 
@@ -1,29 +1,70 @@
1
1
  /**
2
- * Storage — 파일 저장 추상화 (Local / S3)
2
+ * Storage — 파일 저장 추상화 (Local / S3 / R2)
3
3
  *
4
4
  * put(path, tempPath) — 임시파일 → 최종 위치 이동.
5
5
  * Local: file.move(tempPath, fullPath)
6
- * S3: Node.js 스트리밍 업로드 (큐 Task 권장)
6
+ * S3/R2: Node.js 스트리밍 업로드
7
+ *
8
+ * S3 호환 스토리지 지원:
9
+ * - AWS S3
10
+ * - Cloudflare R2 (egress 무료, endpoint 지정 필요)
11
+ * - MinIO, DigitalOcean Spaces 등
7
12
  *
8
13
  * @see docs/framework/15-file-upload.md
9
14
  * @see docs/framework/class-design.mm.md (Storage)
10
15
  */
11
- import { promises as fs } from 'node:fs';
16
+ import { promises as fs, createReadStream } from 'node:fs';
12
17
  import path from 'node:path';
13
18
 
19
+ /**
20
+ * @aws-sdk/client-s3 — Optional Dependency
21
+ * S3 호환 스토리지(AWS S3, Cloudflare R2, MinIO 등) 사용 시에만 필요.
22
+ * 로컬 드라이버만 사용하면 설치할 필요 없음.
23
+ */
24
+ import {
25
+ S3Client,
26
+ PutObjectCommand,
27
+ GetObjectCommand,
28
+ DeleteObjectCommand,
29
+ HeadObjectCommand,
30
+ } from '@aws-sdk/client-s3';
31
+
14
32
  export default class Storage {
15
33
  /**
16
34
  * @param {object} opts
17
35
  * @param {string} [opts.driver='local'] - 'local' | 's3'
18
36
  * @param {string} [opts.basePath='./storage'] - 로컬 저장 경로
19
- * @param {object} [opts.s3] - S3 설정 { bucket, region, credentials }
37
+ * @param {object} [opts.s3] - S3/R2 설정
38
+ * @param {string} opts.s3.bucket - 버킷명
39
+ * @param {string} [opts.s3.region='auto'] - 리전 (R2는 'auto')
40
+ * @param {string} [opts.s3.endpoint] - 커스텀 엔드포인트 (R2: https://<ACCOUNT_ID>.r2.cloudflarestorage.com)
41
+ * @param {string} [opts.s3.publicUrl] - 퍼블릭 액세스 URL (R2 커스텀 도메인 등)
42
+ * @param {object} [opts.s3.credentials] - { accessKeyId, secretAccessKey }
20
43
  * @param {import('./FileHelper.js').default} [opts.fileHelper] - FileHelper 인스턴스
21
44
  */
22
45
  constructor(opts = {}) {
23
46
  this.driver = opts.driver || 'local';
24
47
  this.basePath = opts.basePath || './storage';
25
- this._s3 = opts.s3 || null;
26
48
  this._file = opts.fileHelper || null;
49
+
50
+ // YAML snake_case → JS camelCase 정규화
51
+ const s3Raw = opts.s3 || null;
52
+ if (s3Raw) {
53
+ const creds = s3Raw.credentials || null;
54
+ this._s3 = {
55
+ bucket: s3Raw.bucket,
56
+ region: s3Raw.region || 'auto',
57
+ endpoint: s3Raw.endpoint || null,
58
+ publicUrl: s3Raw.public_url || s3Raw.publicUrl || null,
59
+ forcePathStyle: s3Raw.force_path_style ?? s3Raw.forcePathStyle ?? false,
60
+ credentials: creds ? {
61
+ accessKeyId: creds.access_key_id || creds.accessKeyId,
62
+ secretAccessKey: creds.secret_access_key || creds.secretAccessKey,
63
+ } : null,
64
+ };
65
+ } else {
66
+ this._s3 = null;
67
+ }
27
68
  }
28
69
 
29
70
  /**
@@ -99,14 +140,136 @@ export default class Storage {
99
140
  */
100
141
  url(filePath) {
101
142
  if (this.driver === 's3' && this._s3) {
102
- return `https://${this._s3.bucket}.s3.${this._s3.region}.amazonaws.com/${filePath}`;
143
+ // 커스텀 퍼블릭 URL (R2 커스텀 도메인, CDN 등)
144
+ if (this._s3.publicUrl) {
145
+ return `${this._s3.publicUrl.replace(/\/$/, '')}/${filePath}`;
146
+ }
147
+ // AWS S3 기본 URL
148
+ return `https://${this._s3.bucket}.s3.${this._s3.region || 'us-east-1'}.amazonaws.com/${filePath}`;
103
149
  }
104
150
  return `/storage/${filePath}`;
105
151
  }
106
152
 
107
- // ── S3 스텁 (Phase 5+ 구현) ──
108
- async _s3Put(filePath, tempPath) { throw new Error('S3 driver not implemented'); }
109
- async _s3Get(filePath) { throw new Error('S3 driver not implemented'); }
110
- async _s3Delete(filePath) { throw new Error('S3 driver not implemented'); }
111
- async _s3Exists(filePath) { throw new Error('S3 driver not implemented'); }
153
+ // ── S3 호환 스토리지 구현 (@aws-sdk/client-s3) ──
154
+ // AWS S3, Cloudflare R2, MinIO, DigitalOcean Spaces 지원
155
+
156
+ /**
157
+ * S3Client 지연 초기화.
158
+ * 최초 호출 시 Client 생성, 이후 캐싱.
159
+ *
160
+ * 지원 스토리지:
161
+ * - AWS S3: region만 지정
162
+ * - Cloudflare R2: endpoint + region='auto'
163
+ * - MinIO/Spaces: endpoint + forcePathStyle=true
164
+ *
165
+ * @returns {S3Client}
166
+ * @private
167
+ */
168
+ _getClient() {
169
+ if (this._s3Client) return this._s3Client;
170
+ if (!this._s3) throw new Error('S3 설정이 없습니다. opts.s3 = { bucket, region, credentials }');
171
+
172
+ /** @type {import('@aws-sdk/client-s3').S3ClientConfig} 클라이언트 설정 */
173
+ const config = {
174
+ region: this._s3.region || 'auto', // R2는 'auto', AWS는 'ap-northeast-2' 등
175
+ credentials: this._s3.credentials || undefined, // IAM Role이면 생략
176
+ };
177
+
178
+ // 커스텀 엔드포인트 (R2, MinIO, DigitalOcean Spaces 등)
179
+ if (this._s3.endpoint) {
180
+ config.endpoint = this._s3.endpoint;
181
+ config.forcePathStyle = this._s3.forcePathStyle ?? false; // R2=false, MinIO=true
182
+ }
183
+
184
+ this._s3Client = new S3Client(config);
185
+ return this._s3Client;
186
+ }
187
+
188
+ /**
189
+ * 파일 업로드 (임시파일 → Object Storage).
190
+ *
191
+ * @param {string} filePath - Object Key (e.g. 'uploads/avatar.jpg')
192
+ * @param {string} tempPath - 로컬 임시 파일 경로
193
+ * @returns {Promise<string>} - 저장된 URL
194
+ * @private
195
+ */
196
+ async _s3Put(filePath, tempPath) {
197
+ const client = this._getClient();
198
+
199
+ const stream = createReadStream(tempPath); // 파일 스트림으로 업로드 (메모리 절약)
200
+ await client.send(new PutObjectCommand({
201
+ Bucket: this._s3.bucket,
202
+ Key: filePath,
203
+ Body: stream,
204
+ }));
205
+
206
+ // 업로드 완료 후 임시파일 삭제
207
+ await fs.unlink(tempPath).catch(() => {});
208
+
209
+ return this.url(filePath);
210
+ }
211
+
212
+ /**
213
+ * 파일 읽기 (Object Storage → Buffer).
214
+ *
215
+ * @param {string} filePath - Object Key
216
+ * @returns {Promise<Buffer>}
217
+ * @private
218
+ */
219
+ async _s3Get(filePath) {
220
+ const client = this._getClient();
221
+
222
+ const res = await client.send(new GetObjectCommand({
223
+ Bucket: this._s3.bucket,
224
+ Key: filePath,
225
+ }));
226
+
227
+ // Body는 ReadableStream — Buffer로 변환
228
+ const chunks = [];
229
+ for await (const chunk of res.Body) {
230
+ chunks.push(chunk);
231
+ }
232
+ return Buffer.concat(chunks);
233
+ }
234
+
235
+ /**
236
+ * 파일 삭제.
237
+ *
238
+ * @param {string} filePath - Object Key
239
+ * @returns {Promise<void>}
240
+ * @private
241
+ */
242
+ async _s3Delete(filePath) {
243
+ const client = this._getClient();
244
+
245
+ await client.send(new DeleteObjectCommand({
246
+ Bucket: this._s3.bucket,
247
+ Key: filePath,
248
+ }));
249
+ }
250
+
251
+ /**
252
+ * 파일 존재 여부 확인 (HeadObject).
253
+ *
254
+ * @param {string} filePath - Object Key
255
+ * @returns {Promise<boolean>}
256
+ * @private
257
+ */
258
+ async _s3Exists(filePath) {
259
+ const client = this._getClient();
260
+
261
+ try {
262
+ await client.send(new HeadObjectCommand({
263
+ Bucket: this._s3.bucket,
264
+ Key: filePath,
265
+ }));
266
+ return true;
267
+ } catch (err) {
268
+ if (err.name === 'NotFound' || err.$metadata?.httpStatusCode === 404) {
269
+ return false;
270
+ }
271
+ throw err; // 권한 오류 등은 그대로 throw
272
+ }
273
+ }
112
274
  }
275
+
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fuzionx/framework",
3
- "version": "0.1.67",
3
+ "version": "0.1.69",
4
4
  "type": "module",
5
5
  "description": "Full-stack MVC framework built on @fuzionx/core — Controller, Service, Model, Middleware, DI, EventBus",
6
6
  "main": "index.js",
@@ -34,7 +34,8 @@
34
34
  "url": "https://github.com/saytohenry/fuzionx"
35
35
  },
36
36
  "dependencies": {
37
- "@fuzionx/core": "^0.1.67",
37
+ "@aws-sdk/client-s3": "^3.1028.0",
38
+ "@fuzionx/core": "^0.1.69",
38
39
  "better-sqlite3": "^12.8.0",
39
40
  "knex": "^3.2.5",
40
41
  "mongoose": "^9.3.2",