@fuzionx/framework 0.1.30 → 0.1.31
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/cli/index.js +146 -11
- package/index.js +3 -0
- package/lib/core/Application.js +27 -5
- package/lib/core/Context.js +30 -1
- package/lib/helpers/I18nHelper.js +10 -6
- package/lib/middleware/apiAuth.js +79 -0
- package/lib/middleware/auth.js +42 -0
- package/lib/middleware/bodyParser.js +19 -0
- package/lib/middleware/cors.js +47 -0
- package/lib/middleware/csrf.js +32 -0
- package/lib/middleware/index.js +8 -277
- package/lib/middleware/session.js +27 -0
- package/lib/middleware/theme.js +20 -0
- package/lib/schedule/Job.js +4 -0
- package/lib/schedule/Queue.js +20 -8
- package/lib/schedule/Scheduler.js +84 -75
- package/lib/utilities/ArrUtil.js +112 -0
- package/lib/utilities/DateUtil.js +98 -0
- package/lib/utilities/FunctionUtil.js +119 -0
- package/lib/utilities/NumUtil.js +75 -0
- package/lib/utilities/ObjectUtil.js +170 -0
- package/lib/utilities/PaginationUtil.js +81 -0
- package/lib/utilities/StrUtil.js +105 -0
- package/lib/utilities/index.js +18 -0
- package/package.json +2 -2
package/cli/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* CLI — fx 명령어 핸들러
|
|
3
3
|
*
|
|
4
|
-
* fx make:app
|
|
4
|
+
* fx make:app --type=ssr|spa 앱 디렉토리 생성 (고정 이름)
|
|
5
5
|
* fx make:controller <Name> 컨트롤러 생성
|
|
6
6
|
* fx make:service <Name> 서비스 생성
|
|
7
7
|
* fx make:model <Name> 모델 생성
|
|
@@ -12,6 +12,8 @@
|
|
|
12
12
|
* fx make:event <Name> 이벤트 핸들러 생성
|
|
13
13
|
* fx make:worker <Name> Worker 생성
|
|
14
14
|
* fx make:test <Name> 테스트 생성
|
|
15
|
+
* fx dev:spa FuzionX + Vite 동시 실행
|
|
16
|
+
* fx build:spa Vite 프로덕션 빌드
|
|
15
17
|
*
|
|
16
18
|
* @see docs/framework/16-cli.md
|
|
17
19
|
*/
|
|
@@ -157,21 +159,41 @@ export async function run(args) {
|
|
|
157
159
|
}
|
|
158
160
|
|
|
159
161
|
if (command === 'make:app') {
|
|
160
|
-
const
|
|
161
|
-
|
|
162
|
-
const
|
|
162
|
+
const typeFlag = rest.find(a => a.startsWith('--type='));
|
|
163
|
+
const type = typeFlag?.split('=')[1] || rest[0];
|
|
164
|
+
const validTypes = ['ssr', 'spa'];
|
|
165
|
+
|
|
166
|
+
if (!type || !validTypes.includes(type)) {
|
|
167
|
+
console.error(`Usage: fx make:app --type=ssr|spa`);
|
|
168
|
+
console.error(` Available types: ${validTypes.join(', ')}`);
|
|
169
|
+
process.exit(1);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// 앱 이름은 타입에 따라 고정
|
|
173
|
+
const appName = type;
|
|
174
|
+
const appDir = path.join('.', 'app', appName);
|
|
175
|
+
|
|
176
|
+
// 기존 앱 확인
|
|
177
|
+
try {
|
|
178
|
+
await fs.access(appDir);
|
|
179
|
+
console.error(`❌ app/${appName}/ already exists.`);
|
|
180
|
+
process.exit(1);
|
|
181
|
+
} catch { /* 없으면 정상 */ }
|
|
182
|
+
|
|
163
183
|
// 빈 디렉토리 생성
|
|
164
184
|
for (const d of ['services', 'middleware', 'ws']) {
|
|
165
185
|
const fullDir = path.join(appDir, d);
|
|
166
186
|
await fs.mkdir(fullDir, { recursive: true });
|
|
167
187
|
await fs.writeFile(path.join(fullDir, '.gitkeep'), '');
|
|
168
188
|
}
|
|
189
|
+
|
|
169
190
|
// 기본 파일 복사 (HomeController, routes, views)
|
|
170
191
|
const appTplDir = path.join(TPL_DIR, 'make/app');
|
|
171
192
|
await copyDirRecursive(appTplDir, appDir);
|
|
172
|
-
|
|
193
|
+
|
|
194
|
+
console.log(`✅ Created app/${appName}/ (type: ${type})`);
|
|
173
195
|
console.log(`\n fuzionx.yaml의 apps에 추가하세요:`);
|
|
174
|
-
console.log(` "127.0.0.1:49080": ${
|
|
196
|
+
console.log(` "127.0.0.1:49080": ${appName}\n`);
|
|
175
197
|
return;
|
|
176
198
|
}
|
|
177
199
|
|
|
@@ -199,6 +221,45 @@ export async function run(args) {
|
|
|
199
221
|
return;
|
|
200
222
|
}
|
|
201
223
|
|
|
224
|
+
// ── fx dev:spa — FuzionX + Vite HMR 동시 실행 ──
|
|
225
|
+
if (command === 'dev:spa') {
|
|
226
|
+
const { execSync } = await import('node:child_process');
|
|
227
|
+
const spaDir = path.resolve('app/spa/views/default/spa');
|
|
228
|
+
try {
|
|
229
|
+
await fs.access(spaDir);
|
|
230
|
+
} catch {
|
|
231
|
+
console.error('❌ app/spa/views/default/spa/ 디렉토리가 없습니다.');
|
|
232
|
+
console.error(' fx make:app --type=spa 로 SPA 앱을 먼저 생성하세요.');
|
|
233
|
+
process.exit(1);
|
|
234
|
+
}
|
|
235
|
+
console.log(`🚀 Starting dev server + Vite HMR...`);
|
|
236
|
+
try {
|
|
237
|
+
execSync(
|
|
238
|
+
'npx concurrently "node --watch app.js" "cd app/spa/views/default/spa && npx vite"',
|
|
239
|
+
{ stdio: 'inherit', cwd: process.cwd() },
|
|
240
|
+
);
|
|
241
|
+
} catch {}
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// ── fx build:spa — Vite 프로덕션 빌드 ──
|
|
246
|
+
if (command === 'build:spa') {
|
|
247
|
+
const { execSync } = await import('node:child_process');
|
|
248
|
+
const spaDir = path.resolve('app/spa/views/default/spa');
|
|
249
|
+
try {
|
|
250
|
+
await fs.access(spaDir);
|
|
251
|
+
} catch {
|
|
252
|
+
console.error('❌ app/spa/views/default/spa/ 디렉토리가 없습니다.');
|
|
253
|
+
process.exit(1);
|
|
254
|
+
}
|
|
255
|
+
console.log(`📦 Building SPA for production...`);
|
|
256
|
+
try {
|
|
257
|
+
execSync('npx vite build', { stdio: 'inherit', cwd: spaDir });
|
|
258
|
+
console.log('\n✅ Build complete → public/dist/');
|
|
259
|
+
} catch { process.exit(1); }
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
|
|
202
263
|
// ── fx stop — 서버 종료 ──
|
|
203
264
|
if (command === 'stop') {
|
|
204
265
|
const pidFile = path.resolve('fuzionx.pid');
|
|
@@ -315,18 +376,90 @@ export async function run(args) {
|
|
|
315
376
|
const modelsDir = path.resolve('database/models');
|
|
316
377
|
const files = await fs.readdir(modelsDir);
|
|
317
378
|
const jsFiles = files.filter(f => f.endsWith('.js'));
|
|
379
|
+
const apply = rest.includes('--apply');
|
|
380
|
+
|
|
381
|
+
// fuzionx.yaml에서 DB 경로 읽기
|
|
382
|
+
let dbPath = './storage/database.sqlite';
|
|
383
|
+
try {
|
|
384
|
+
const yaml = await fs.readFile(path.resolve('fuzionx.yaml'), 'utf8');
|
|
385
|
+
const dbMatch = yaml.match(/database:\s*(.+\.sqlite)/);
|
|
386
|
+
if (dbMatch) dbPath = dbMatch[1].trim();
|
|
387
|
+
} catch {}
|
|
388
|
+
|
|
318
389
|
console.log('\n📊 Model Schema Status:\n');
|
|
390
|
+
const models = [];
|
|
391
|
+
|
|
319
392
|
for (const file of jsFiles) {
|
|
320
393
|
const mod = await import(path.join(modelsDir, file));
|
|
321
394
|
const Model = mod.default;
|
|
322
395
|
if (!Model) continue;
|
|
323
396
|
const name = path.basename(file, '.js');
|
|
324
397
|
const table = Model.table || name.toLowerCase() + 's';
|
|
325
|
-
const cols =
|
|
326
|
-
|
|
327
|
-
console.log(` ${name.padEnd(20)} table: ${table.padEnd(20)} columns: ${cols.length} ${status}`);
|
|
398
|
+
const cols = Model.columns || {};
|
|
399
|
+
models.push({ name, table, cols });
|
|
328
400
|
}
|
|
329
|
-
|
|
401
|
+
|
|
402
|
+
if (apply) {
|
|
403
|
+
// SQLite DB 열기
|
|
404
|
+
const resolvedDbPath = path.resolve(dbPath);
|
|
405
|
+
await fs.mkdir(path.dirname(resolvedDbPath), { recursive: true });
|
|
406
|
+
const { default: Database } = await import('better-sqlite3');
|
|
407
|
+
const db = new Database(resolvedDbPath);
|
|
408
|
+
|
|
409
|
+
for (const { name, table, cols } of models) {
|
|
410
|
+
const colDefs = [];
|
|
411
|
+
for (const [col, def] of Object.entries(cols)) {
|
|
412
|
+
if (def.type === 'increments') {
|
|
413
|
+
colDefs.push(`\`${col}\` INTEGER PRIMARY KEY AUTOINCREMENT`);
|
|
414
|
+
} else if (def.type === 'integer') {
|
|
415
|
+
colDefs.push(`\`${col}\` INTEGER NOT NULL DEFAULT 0`);
|
|
416
|
+
} else if (def.type === 'text') {
|
|
417
|
+
colDefs.push(`\`${col}\` TEXT NOT NULL DEFAULT ''`);
|
|
418
|
+
} else if (def.type === 'datetime') {
|
|
419
|
+
colDefs.push(`\`${col}\` DATETIME DEFAULT CURRENT_TIMESTAMP`);
|
|
420
|
+
} else if (def.type === 'boolean') {
|
|
421
|
+
colDefs.push(`\`${col}\` INTEGER NOT NULL DEFAULT 0`);
|
|
422
|
+
} else {
|
|
423
|
+
// string, etc.
|
|
424
|
+
const unique = def.unique ? ' UNIQUE' : '';
|
|
425
|
+
const dflt = def.default != null ? ` DEFAULT '${def.default}'` : " DEFAULT ''";
|
|
426
|
+
colDefs.push(`\`${col}\` TEXT NOT NULL${dflt}${unique}`);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
const sql = `CREATE TABLE IF NOT EXISTS \`${table}\` (\n ${colDefs.join(',\n ')}\n)`;
|
|
430
|
+
db.exec(sql);
|
|
431
|
+
|
|
432
|
+
// ── 기존 테이블에 누락된 컬럼 추가 (ALTER TABLE) ──
|
|
433
|
+
const existingCols = db.pragma(`table_info(${table})`).map(c => c.name);
|
|
434
|
+
let addedCols = 0;
|
|
435
|
+
for (const [col, def] of Object.entries(cols)) {
|
|
436
|
+
if (existingCols.includes(col)) continue;
|
|
437
|
+
|
|
438
|
+
let colSql;
|
|
439
|
+
if (def.type === 'integer') {
|
|
440
|
+
colSql = `INTEGER NOT NULL DEFAULT 0`;
|
|
441
|
+
} else if (def.type === 'text') {
|
|
442
|
+
colSql = `TEXT NOT NULL DEFAULT ''`;
|
|
443
|
+
} else if (def.type === 'datetime') {
|
|
444
|
+
colSql = `DATETIME DEFAULT NULL`;
|
|
445
|
+
} else if (def.type === 'boolean') {
|
|
446
|
+
colSql = `INTEGER NOT NULL DEFAULT 0`;
|
|
447
|
+
} else {
|
|
448
|
+
const dflt = def.default != null ? `'${def.default}'` : "''";
|
|
449
|
+
colSql = `TEXT DEFAULT ${dflt}`;
|
|
450
|
+
}
|
|
451
|
+
db.exec(`ALTER TABLE \`${table}\` ADD COLUMN \`${col}\` ${colSql}`);
|
|
452
|
+
addedCols++;
|
|
453
|
+
}
|
|
454
|
+
const status = addedCols > 0 ? `✅ synced (+${addedCols} columns)` : '✅ synced';
|
|
455
|
+
console.log(` ${name.padEnd(20)} table: ${table.padEnd(20)} columns: ${Object.keys(cols).length} ${status}`);
|
|
456
|
+
}
|
|
457
|
+
db.close();
|
|
458
|
+
console.log(`\n Database: ${path.resolve(dbPath)}`);
|
|
459
|
+
} else {
|
|
460
|
+
for (const { name, table, cols } of models) {
|
|
461
|
+
console.log(` ${name.padEnd(20)} table: ${table.padEnd(20)} columns: ${Object.keys(cols).length} ⏳ pending`);
|
|
462
|
+
}
|
|
330
463
|
console.log('\n Run with --apply to sync changes to database.');
|
|
331
464
|
}
|
|
332
465
|
console.log('');
|
|
@@ -336,7 +469,7 @@ export async function run(args) {
|
|
|
336
469
|
|
|
337
470
|
console.log(`
|
|
338
471
|
npx create-fuzionx <name> Create new project
|
|
339
|
-
fx make:app
|
|
472
|
+
fx make:app --type=ssr|spa Create app (fixed name: app/ssr or app/spa)
|
|
340
473
|
fx make:controller <Name> --app= Create controller (app-specific)
|
|
341
474
|
fx make:service <Name> --app= Create service (app-specific)
|
|
342
475
|
fx make:model <Name> Create model (database/models)
|
|
@@ -348,6 +481,8 @@ export async function run(args) {
|
|
|
348
481
|
fx make:worker <Name> Create worker (shared/workers)
|
|
349
482
|
fx make:test <Name> Create test
|
|
350
483
|
fx dev Start dev server (--watch)
|
|
484
|
+
fx dev:spa Start dev server + Vite HMR
|
|
485
|
+
fx build:spa Build SPA for production
|
|
351
486
|
fx stop Stop server (graceful)
|
|
352
487
|
fx restart Restart server (graceful)
|
|
353
488
|
fx test Run tests
|
package/index.js
CHANGED
|
@@ -60,5 +60,8 @@ export { default as FileHelper } from './lib/helpers/FileHelper.js';
|
|
|
60
60
|
export { default as View } from './lib/view/View.js';
|
|
61
61
|
export { default as OpenAPI } from './lib/view/OpenAPI.js';
|
|
62
62
|
|
|
63
|
+
// ── Utilities (Stateless) ──
|
|
64
|
+
export { PaginationUtil, StrUtil, NumUtil, DateUtil, ArrUtil, FunctionUtil, ObjectUtil } from './lib/utilities/index.js';
|
|
65
|
+
|
|
63
66
|
// ── Built-in Middleware ──
|
|
64
67
|
export { bodyParser, cors, auth, apiAuth, csrf, session, theme } from './lib/middleware/index.js';
|
package/lib/core/Application.js
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import path from 'node:path';
|
|
11
11
|
import { promises as fs } from 'node:fs';
|
|
12
|
+
import cluster from 'node:cluster';
|
|
12
13
|
import Config from './Config.js';
|
|
13
14
|
import Context from './Context.js';
|
|
14
15
|
import Router from '../http/Router.js';
|
|
@@ -429,13 +430,20 @@ export default class Application {
|
|
|
429
430
|
if (!this._booted) await this.boot();
|
|
430
431
|
|
|
431
432
|
// PID 파일 생성 (primary만)
|
|
432
|
-
|
|
433
|
-
if (!cluster.default?.isWorker) {
|
|
433
|
+
if (!cluster.isWorker) {
|
|
434
434
|
await this._checkPort(port);
|
|
435
435
|
const pidPath = path.join(this.baseDir, 'fuzionx.pid');
|
|
436
436
|
await fs.writeFile(pidPath, String(process.pid));
|
|
437
437
|
}
|
|
438
438
|
|
|
439
|
+
// process.title 설정 (ps에서 보이는 프로세스 이름)
|
|
440
|
+
const appName = this.config.get('app.name', 'fuzionx');
|
|
441
|
+
if (cluster.isWorker) {
|
|
442
|
+
process.title = `${appName}-worker-${cluster.worker.id}`;
|
|
443
|
+
} else {
|
|
444
|
+
process.title = `${appName}-primary`;
|
|
445
|
+
}
|
|
446
|
+
|
|
439
447
|
await this.emit('ready');
|
|
440
448
|
|
|
441
449
|
// Bridge 연동 — _coreApp은 boot()에서 이미 생성됨
|
|
@@ -461,6 +469,14 @@ export default class Application {
|
|
|
461
469
|
}
|
|
462
470
|
|
|
463
471
|
await this.emit('listening');
|
|
472
|
+
|
|
473
|
+
// Scheduler 시작 — primary 프로세스에서만 (워커 중복 실행 방지)
|
|
474
|
+
// fuzionx 상위 레이어가 cluster.fork()로 워커를 생성하므로
|
|
475
|
+
// cluster.isPrimary로 단일 프로세스 보장
|
|
476
|
+
if (this._scheduler && this._scheduler._jobs.length > 0 && !cluster.isWorker) {
|
|
477
|
+
this._scheduler.start();
|
|
478
|
+
}
|
|
479
|
+
|
|
464
480
|
return this;
|
|
465
481
|
}
|
|
466
482
|
|
|
@@ -622,9 +638,15 @@ export default class Application {
|
|
|
622
638
|
// 미들웨어 체인 사전 구성
|
|
623
639
|
const middlewareFns = [...this._globalMiddleware];
|
|
624
640
|
if (route.middleware?.length) {
|
|
625
|
-
for (const
|
|
626
|
-
|
|
627
|
-
|
|
641
|
+
for (const mw of route.middleware) {
|
|
642
|
+
// 함수면 직접 사용 (built-in: auth(), cors() 등)
|
|
643
|
+
// 문자열이면 resolveMiddleware로 클래스 기반 조회
|
|
644
|
+
if (typeof mw === 'function') {
|
|
645
|
+
middlewareFns.push(mw);
|
|
646
|
+
} else if (typeof mw === 'string') {
|
|
647
|
+
const fn = this.resolveMiddleware(mw, appName);
|
|
648
|
+
if (fn) middlewareFns.push(fn);
|
|
649
|
+
}
|
|
628
650
|
}
|
|
629
651
|
}
|
|
630
652
|
|
package/lib/core/Context.js
CHANGED
|
@@ -28,6 +28,7 @@ export default class Context {
|
|
|
28
28
|
this.body = rawReq.body || null;
|
|
29
29
|
this.ip = rawReq.remoteIp || '';
|
|
30
30
|
this.files = rawReq.files || null;
|
|
31
|
+
this.uploadError = rawReq.uploadError || null;
|
|
31
32
|
this.formFields = rawReq.formFields || null;
|
|
32
33
|
this.handlerId = rawReq.handlerId;
|
|
33
34
|
|
|
@@ -306,6 +307,22 @@ export default class Context {
|
|
|
306
307
|
|
|
307
308
|
render(view, data) {
|
|
308
309
|
// 글로벌 변수 주입 (03-views-templates.md)
|
|
310
|
+
const bridge = this.app?._bridge;
|
|
311
|
+
let aspVars = {};
|
|
312
|
+
if (bridge?.getAspConfig && bridge?.cryptoEncryptCustom) {
|
|
313
|
+
try {
|
|
314
|
+
const aspCfg = JSON.parse(bridge.getAspConfig());
|
|
315
|
+
const clientSecret = this.app?.config?.get('app.client_secret') || '';
|
|
316
|
+
if (clientSecret && aspCfg.masterSecret) {
|
|
317
|
+
aspVars = {
|
|
318
|
+
_fx_client_secret: clientSecret,
|
|
319
|
+
_fx_asp_secret: bridge.cryptoEncryptCustom(clientSecret, aspCfg.masterSecret),
|
|
320
|
+
_fx_asp_header: aspCfg.headerSignal || 'Ruxy-Enc-Mode',
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
} catch {}
|
|
324
|
+
}
|
|
325
|
+
|
|
309
326
|
const globals = {
|
|
310
327
|
session: this._rawSession,
|
|
311
328
|
auth: { user: this.user },
|
|
@@ -315,6 +332,7 @@ export default class Context {
|
|
|
315
332
|
flash: this.session?.getFlash() || {},
|
|
316
333
|
theme: this.theme || this.app?.config?.get('themes.default', 'default') || 'default',
|
|
317
334
|
locale: this.locale,
|
|
335
|
+
...aspVars,
|
|
318
336
|
...data,
|
|
319
337
|
};
|
|
320
338
|
|
|
@@ -336,11 +354,18 @@ export default class Context {
|
|
|
336
354
|
/** @private */
|
|
337
355
|
_createSession(rawReq, app) {
|
|
338
356
|
const data = rawReq.session || {};
|
|
357
|
+
// Flash 추출 + 세션에서 삭제 (one-time read)
|
|
339
358
|
const flash = { ...(data._flash || {}) };
|
|
359
|
+
const hadFlash = !!data._flash;
|
|
340
360
|
delete data._flash;
|
|
341
361
|
const sessionId = rawReq.sessionId || null;
|
|
342
362
|
const bridge = app?._bridge || null;
|
|
343
363
|
|
|
364
|
+
// Flash 소비 후 세션 저장소에 즉시 반영
|
|
365
|
+
if (hadFlash && sessionId && bridge?.sessionSet) {
|
|
366
|
+
try { bridge.sessionSet(sessionId, { ...data }); } catch {}
|
|
367
|
+
}
|
|
368
|
+
|
|
344
369
|
return {
|
|
345
370
|
/** 세션 값 조회 */
|
|
346
371
|
get: (key) => key ? (data[key] ?? null) : { ...data },
|
|
@@ -350,7 +375,11 @@ export default class Context {
|
|
|
350
375
|
data[key] = value;
|
|
351
376
|
// Core SessionProto 참조: bridge.sessionSet(id, { ...data })
|
|
352
377
|
if (sessionId && bridge?.sessionSet) {
|
|
353
|
-
try {
|
|
378
|
+
try {
|
|
379
|
+
bridge.sessionSet(sessionId, { ...data });
|
|
380
|
+
} catch (e) {
|
|
381
|
+
console.error('[Session] sessionSet failed:', e?.message || e);
|
|
382
|
+
}
|
|
354
383
|
}
|
|
355
384
|
},
|
|
356
385
|
|
|
@@ -60,7 +60,6 @@ export default class I18nHelper {
|
|
|
60
60
|
*/
|
|
61
61
|
translate(locale, key, vars = {}) {
|
|
62
62
|
// ── Bridge N-API 위임 ──
|
|
63
|
-
// Core i18n.js 참조: bridge.i18NTranslate(locale, key)
|
|
64
63
|
if (this._bridge && typeof this._bridge.i18NTranslate === 'function') {
|
|
65
64
|
try {
|
|
66
65
|
const result = this._bridge.i18NTranslate(locale, key);
|
|
@@ -72,12 +71,18 @@ export default class I18nHelper {
|
|
|
72
71
|
const messages = this._messages.get(locale)
|
|
73
72
|
|| this._messages.get(this.fallback)
|
|
74
73
|
|| this._messages.get(this.defaultLocale);
|
|
75
|
-
if (!messages) return vars.default || key;
|
|
76
74
|
|
|
77
|
-
const value = messages[key];
|
|
78
|
-
if (value
|
|
75
|
+
const value = messages?.[key];
|
|
76
|
+
if (value != null) return this._substitute(value, vars);
|
|
79
77
|
|
|
80
|
-
|
|
78
|
+
// ── auto_complete: 누락 키 자동 등록 ──
|
|
79
|
+
// default 값이 제공된 경우, Bridge에 누락 키를 보고하여
|
|
80
|
+
// 모든 locale 파일에 자동으로 추가한다.
|
|
81
|
+
if (vars.default) {
|
|
82
|
+
this.updateMissing(key, vars.default);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return vars.default || key;
|
|
81
86
|
}
|
|
82
87
|
|
|
83
88
|
/**
|
|
@@ -86,7 +91,6 @@ export default class I18nHelper {
|
|
|
86
91
|
* @returns {object}
|
|
87
92
|
*/
|
|
88
93
|
all(locale) {
|
|
89
|
-
// Bridge: i18NGetLocales() 는 locale 목록만 반환, 전체 데이터 API 없음
|
|
90
94
|
const messages = this._messages.get(locale) || this._messages.get(this.defaultLocale);
|
|
91
95
|
if (!messages) return {};
|
|
92
96
|
return { ...messages };
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* apiAuth — JWT Bearer 토큰 인증 미들웨어
|
|
3
|
+
*
|
|
4
|
+
* Authorization: Bearer <token> → 검증 → ctx.user
|
|
5
|
+
*
|
|
6
|
+
* @see docs/framework/14-authentication.md
|
|
7
|
+
*
|
|
8
|
+
* @param {object} [opts]
|
|
9
|
+
* @param {string} [opts.secret] - JWT 시크릿
|
|
10
|
+
* @param {string} [opts.model='User']
|
|
11
|
+
*/
|
|
12
|
+
import { createHmac, timingSafeEqual } from 'node:crypto';
|
|
13
|
+
|
|
14
|
+
export function apiAuth(opts = {}) {
|
|
15
|
+
const modelName = opts.model || 'User';
|
|
16
|
+
|
|
17
|
+
return async (ctx, next) => {
|
|
18
|
+
const authHeader = ctx.get('authorization') || '';
|
|
19
|
+
|
|
20
|
+
if (!authHeader.startsWith('Bearer ')) {
|
|
21
|
+
ctx.status(401).json({ error: { message: 'Token required', status: 401 } });
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const token = authHeader.slice(7);
|
|
26
|
+
|
|
27
|
+
try {
|
|
28
|
+
const secret = opts.secret || ctx.app?.config?.get('app.auth.secret', 'fuzionx');
|
|
29
|
+
const payload = decodeJwtPayload(token, secret);
|
|
30
|
+
|
|
31
|
+
if (!payload || !payload.sub) {
|
|
32
|
+
ctx.status(401).json({ error: { message: 'Invalid token', status: 401 } });
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (ctx.app?.db?.[modelName]) {
|
|
37
|
+
ctx.user = await ctx.app.db[modelName].find(payload.sub);
|
|
38
|
+
} else {
|
|
39
|
+
ctx.user = { id: payload.sub, ...payload };
|
|
40
|
+
}
|
|
41
|
+
} catch {
|
|
42
|
+
ctx.status(401).json({ error: { message: 'Invalid token', status: 401 } });
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
await next();
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* JWT 디코드 + HMAC-SHA256 서명 검증
|
|
52
|
+
* @private
|
|
53
|
+
*/
|
|
54
|
+
function decodeJwtPayload(token, secret) {
|
|
55
|
+
try {
|
|
56
|
+
const parts = token.split('.');
|
|
57
|
+
if (parts.length !== 3) return null;
|
|
58
|
+
|
|
59
|
+
const [headerB64, payloadB64, signatureB64] = parts;
|
|
60
|
+
|
|
61
|
+
const signingInput = `${headerB64}.${payloadB64}`;
|
|
62
|
+
const expectedSig = createHmac('sha256', secret)
|
|
63
|
+
.update(signingInput)
|
|
64
|
+
.digest();
|
|
65
|
+
|
|
66
|
+
const actualSig = Buffer.from(signatureB64, 'base64url');
|
|
67
|
+
if (expectedSig.length !== actualSig.length) return null;
|
|
68
|
+
if (!timingSafeEqual(expectedSig, actualSig)) return null;
|
|
69
|
+
|
|
70
|
+
const payload = JSON.parse(Buffer.from(payloadB64, 'base64url').toString());
|
|
71
|
+
|
|
72
|
+
if (payload.exp !== undefined && Date.now() / 1000 > payload.exp) return null;
|
|
73
|
+
if (payload.nbf !== undefined && Date.now() / 1000 < payload.nbf) return null;
|
|
74
|
+
|
|
75
|
+
return payload;
|
|
76
|
+
} catch {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* auth — 세션 인증 미들웨어
|
|
3
|
+
*
|
|
4
|
+
* ctx.session.userId → db.User.find() → ctx.user
|
|
5
|
+
*
|
|
6
|
+
* @see docs/framework/14-authentication.md
|
|
7
|
+
*
|
|
8
|
+
* @param {object} [opts]
|
|
9
|
+
* @param {string} [opts.sessionKey='userId']
|
|
10
|
+
* @param {string} [opts.model='User']
|
|
11
|
+
* @param {string} [opts.redirectTo='/login']
|
|
12
|
+
*/
|
|
13
|
+
export function auth(opts = {}) {
|
|
14
|
+
const sessionKey = opts.sessionKey || 'userId';
|
|
15
|
+
const modelName = opts.model || 'User';
|
|
16
|
+
const redirectTo = opts.redirectTo || null;
|
|
17
|
+
|
|
18
|
+
return async (ctx, next) => {
|
|
19
|
+
const userId = ctx._rawSession?.[sessionKey] || ctx.get('x-test-user-id');
|
|
20
|
+
|
|
21
|
+
if (!userId) {
|
|
22
|
+
if (redirectTo) {
|
|
23
|
+
ctx.redirect(redirectTo);
|
|
24
|
+
} else {
|
|
25
|
+
ctx.status(401).json({ error: { message: 'Unauthorized', status: 401 } });
|
|
26
|
+
}
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (ctx.app?.db?.[modelName]) {
|
|
31
|
+
try {
|
|
32
|
+
ctx.user = await ctx.app.db[modelName].find(userId);
|
|
33
|
+
} catch {
|
|
34
|
+
ctx.user = { id: userId };
|
|
35
|
+
}
|
|
36
|
+
} else {
|
|
37
|
+
ctx.user = { id: userId };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
await next();
|
|
41
|
+
};
|
|
42
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* bodyParser — JSON/Form body 파싱 미들웨어
|
|
3
|
+
*
|
|
4
|
+
* Bridge가 이미 파싱한 body를 보장.
|
|
5
|
+
* 추가 파싱이 필요한 경우 처리.
|
|
6
|
+
*
|
|
7
|
+
* @see docs/framework/12-middleware.md
|
|
8
|
+
*/
|
|
9
|
+
export function bodyParser() {
|
|
10
|
+
return async (ctx, next) => {
|
|
11
|
+
if (typeof ctx.body === 'string' && ctx.body) {
|
|
12
|
+
const ct = ctx.get('content-type') || '';
|
|
13
|
+
if (ct.includes('application/json')) {
|
|
14
|
+
try { ctx.body = JSON.parse(ctx.body); } catch {}
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
await next();
|
|
18
|
+
};
|
|
19
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cors — CORS 미들웨어
|
|
3
|
+
*
|
|
4
|
+
* @see docs/framework/12-middleware.md
|
|
5
|
+
*
|
|
6
|
+
* @param {object} [opts]
|
|
7
|
+
* @param {string|string[]} [opts.origin='*']
|
|
8
|
+
* @param {string} [opts.methods='GET,POST,PUT,PATCH,DELETE,OPTIONS']
|
|
9
|
+
* @param {string} [opts.headers='Content-Type,Authorization']
|
|
10
|
+
* @param {boolean} [opts.credentials=false]
|
|
11
|
+
* @param {number} [opts.maxAge=86400]
|
|
12
|
+
*/
|
|
13
|
+
export function cors(opts = {}) {
|
|
14
|
+
const origin = opts.origin || '*';
|
|
15
|
+
const methods = opts.methods || 'GET,POST,PUT,PATCH,DELETE,OPTIONS';
|
|
16
|
+
const headers = opts.headers || 'Content-Type,Authorization';
|
|
17
|
+
const credentials = opts.credentials || false;
|
|
18
|
+
const maxAge = opts.maxAge || 86400;
|
|
19
|
+
|
|
20
|
+
return async (ctx, next) => {
|
|
21
|
+
const reqOrigin = ctx.get('origin') || '*';
|
|
22
|
+
let allowOrigin;
|
|
23
|
+
|
|
24
|
+
if (credentials && origin === '*') {
|
|
25
|
+
allowOrigin = reqOrigin !== '*' ? reqOrigin : '';
|
|
26
|
+
} else {
|
|
27
|
+
allowOrigin = origin === '*' ? '*' : (
|
|
28
|
+
Array.isArray(origin)
|
|
29
|
+
? (origin.includes(reqOrigin) ? reqOrigin : origin[0])
|
|
30
|
+
: origin
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
ctx.header('Access-Control-Allow-Origin', allowOrigin);
|
|
35
|
+
ctx.header('Access-Control-Allow-Methods', methods);
|
|
36
|
+
ctx.header('Access-Control-Allow-Headers', headers);
|
|
37
|
+
if (credentials) ctx.header('Access-Control-Allow-Credentials', 'true');
|
|
38
|
+
|
|
39
|
+
if (ctx.method === 'OPTIONS') {
|
|
40
|
+
ctx.header('Access-Control-Max-Age', String(maxAge));
|
|
41
|
+
ctx.status(204).end();
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
await next();
|
|
46
|
+
};
|
|
47
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* csrf — CSRF 보호 미들웨어
|
|
3
|
+
*
|
|
4
|
+
* GET/HEAD/OPTIONS 는 통과, 나머지는 토큰 검증.
|
|
5
|
+
*
|
|
6
|
+
* @see docs/framework/12-middleware.md
|
|
7
|
+
*
|
|
8
|
+
* @param {object} [opts]
|
|
9
|
+
* @param {string} [opts.headerName='x-csrf-token']
|
|
10
|
+
* @param {string} [opts.sessionKey='_csrfToken']
|
|
11
|
+
*/
|
|
12
|
+
export function csrf(opts = {}) {
|
|
13
|
+
const headerName = opts.headerName || 'x-csrf-token';
|
|
14
|
+
const sessionKey = opts.sessionKey || '_csrfToken';
|
|
15
|
+
|
|
16
|
+
return async (ctx, next) => {
|
|
17
|
+
if (['GET', 'HEAD', 'OPTIONS'].includes(ctx.method)) {
|
|
18
|
+
await next();
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const token = ctx.get(headerName);
|
|
23
|
+
const expected = ctx._rawSession?.[sessionKey];
|
|
24
|
+
|
|
25
|
+
if (!token || !expected || token !== expected) {
|
|
26
|
+
ctx.status(403).json({ error: { message: 'CSRF token mismatch', status: 403 } });
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
await next();
|
|
31
|
+
};
|
|
32
|
+
}
|