@fuzionx/framework 0.1.20 → 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/lib/core/Application.js +46 -42
- package/lib/core/AutoLoader.js +6 -1
- package/lib/core/Config.js +2 -1
- package/lib/core/Context.js +8 -3
- package/lib/database/ConnectionManager.js +17 -1
- package/lib/database/SqlModel.js +10 -5
- package/lib/database/SqlQueryBuilder.js +29 -17
- package/lib/helpers/MediaHelper.js +46 -0
- package/lib/http/Middleware.js +1 -1
- package/lib/middleware/index.js +1 -1
- package/package.json +2 -2
package/lib/core/Application.js
CHANGED
|
@@ -245,6 +245,18 @@ export default class Application {
|
|
|
245
245
|
async boot() {
|
|
246
246
|
if (this._booted) return;
|
|
247
247
|
|
|
248
|
+
// 0. Bridge 초기화 — boot 로직 전에 bridge 확보
|
|
249
|
+
// Helper(crypto/hash/media/file/i18n/logger/view)가 boot 중 bridge 필요
|
|
250
|
+
if (FuzionXApp && !this._coreApp) {
|
|
251
|
+
this._coreApp = new FuzionXApp({
|
|
252
|
+
config: this.configPath
|
|
253
|
+
? path.resolve(this.baseDir, this.configPath)
|
|
254
|
+
: undefined,
|
|
255
|
+
});
|
|
256
|
+
this._bridge = this._coreApp._bridge;
|
|
257
|
+
this._propagateBridge();
|
|
258
|
+
}
|
|
259
|
+
|
|
248
260
|
// 1. .env 로드 (17-config.md)
|
|
249
261
|
this.config.loadEnv(this.baseDir);
|
|
250
262
|
// .env 로드 후 캐시 클리어 (새로운 환경변수 반영)
|
|
@@ -307,6 +319,21 @@ export default class Application {
|
|
|
307
319
|
await this.emit('booted');
|
|
308
320
|
}
|
|
309
321
|
|
|
322
|
+
/**
|
|
323
|
+
* Bridge 참조를 모든 Helper 인스턴스에 전파
|
|
324
|
+
* @private
|
|
325
|
+
*/
|
|
326
|
+
_propagateBridge() {
|
|
327
|
+
if (!this._bridge) return;
|
|
328
|
+
if (this._view) this._view._bridge = this._bridge;
|
|
329
|
+
if (this.logger) this.logger._bridge = this._bridge;
|
|
330
|
+
if (this.crypto) this.crypto._bridge = this._bridge;
|
|
331
|
+
if (this.hash) this.hash._bridge = this._bridge;
|
|
332
|
+
if (this.media) this.media._bridge = this._bridge;
|
|
333
|
+
if (this.file) this.file._bridge = this._bridge;
|
|
334
|
+
if (this.i18n) this.i18n._bridge = this._bridge;
|
|
335
|
+
}
|
|
336
|
+
|
|
310
337
|
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
311
338
|
// 서버 시작 — Bridge 연결
|
|
312
339
|
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
@@ -323,34 +350,22 @@ export default class Application {
|
|
|
323
350
|
|
|
324
351
|
if (!this._booted) await this.boot();
|
|
325
352
|
|
|
326
|
-
// 포트 사용 여부 확인 —
|
|
327
|
-
await
|
|
353
|
+
// 포트 사용 여부 확인 — primary에서만 (워커는 SO_REUSEPORT로 공유)
|
|
354
|
+
const cluster = await import('node:cluster');
|
|
355
|
+
if (!cluster.default?.isWorker) {
|
|
356
|
+
await this._checkPort(port);
|
|
357
|
+
}
|
|
328
358
|
|
|
329
359
|
await this.emit('ready');
|
|
330
360
|
|
|
331
|
-
// Bridge 연동
|
|
332
|
-
if (
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
});
|
|
340
|
-
// FuzionXApp 생성 후 Bridge 전파 — boot() 시점에는 null이었음
|
|
341
|
-
this._bridge = this._coreApp._bridge;
|
|
342
|
-
if (this._bridge) {
|
|
343
|
-
if (this._view) this._view._bridge = this._bridge;
|
|
344
|
-
if (this.logger) this.logger._bridge = this._bridge;
|
|
345
|
-
if (this.crypto) this.crypto._bridge = this._bridge;
|
|
346
|
-
if (this.hash) this.hash._bridge = this._bridge;
|
|
347
|
-
if (this.media) this.media._bridge = this._bridge;
|
|
348
|
-
if (this.file) this.file._bridge = this._bridge;
|
|
349
|
-
if (this.i18n) this.i18n._bridge = this._bridge;
|
|
350
|
-
}
|
|
351
|
-
// WS proxy 연결 (WsHandler에서 this.app.ws 사용)
|
|
352
|
-
this.ws = this._coreApp.ws;
|
|
353
|
-
}
|
|
361
|
+
// Bridge 연동 — _coreApp은 boot()에서 이미 생성됨
|
|
362
|
+
if (this._coreApp) {
|
|
363
|
+
// boot()에서 port 없이 생성됐으므로 port 설정
|
|
364
|
+
this._coreApp._port = port;
|
|
365
|
+
|
|
366
|
+
// WS proxy 연결 (WsHandler에서 this.app.ws 사용)
|
|
367
|
+
if (!this.ws) this.ws = this._coreApp.ws;
|
|
368
|
+
|
|
354
369
|
this._registerBridgeRoutes(this._coreApp);
|
|
355
370
|
|
|
356
371
|
// WsHandler → Bridge WS 이벤트 연결
|
|
@@ -524,14 +539,15 @@ export default class Application {
|
|
|
524
539
|
const eventMap = HandlerClass.buildEventMap();
|
|
525
540
|
const wsNs = coreApp.ws(namespace);
|
|
526
541
|
|
|
542
|
+
// 싱글톤 인스턴스 — 연결/메시지/해제에서 동일 인스턴스 사용
|
|
543
|
+
const inst = new HandlerClass(this);
|
|
544
|
+
|
|
527
545
|
wsNs.on('connect', (socket) => {
|
|
528
|
-
const inst = new HandlerClass(this);
|
|
529
546
|
console.log(`[WS] connect: ${namespace} sid=${socket.sessionId}`);
|
|
530
547
|
inst.onConnect(socket);
|
|
531
548
|
});
|
|
532
549
|
|
|
533
550
|
wsNs.on('message', (socket, rawMessage) => {
|
|
534
|
-
const inst = new HandlerClass(this);
|
|
535
551
|
let parsed;
|
|
536
552
|
try { parsed = typeof rawMessage === 'string' ? JSON.parse(rawMessage) : rawMessage; }
|
|
537
553
|
catch { parsed = { type: 'message', data: rawMessage }; }
|
|
@@ -553,7 +569,6 @@ export default class Application {
|
|
|
553
569
|
});
|
|
554
570
|
|
|
555
571
|
wsNs.on('disconnect', (socket) => {
|
|
556
|
-
const inst = new HandlerClass(this);
|
|
557
572
|
console.log(`[WS] disconnect: ${namespace} sid=${socket.sessionId}`);
|
|
558
573
|
inst.onDisconnect(socket);
|
|
559
574
|
});
|
|
@@ -602,7 +617,7 @@ export default class Application {
|
|
|
602
617
|
}, this);
|
|
603
618
|
|
|
604
619
|
// Framework → async 체인 실행 후 직접 sendAsyncResponse
|
|
605
|
-
const promise = this._executeChain(middlewareFns, route, ctx
|
|
620
|
+
const promise = this._executeChain(middlewareFns, route, ctx);
|
|
606
621
|
|
|
607
622
|
promise.then(() => {
|
|
608
623
|
const response = ctx.toResponse();
|
|
@@ -643,7 +658,7 @@ export default class Application {
|
|
|
643
658
|
* 미들웨어 체인 + 핸들러 실행
|
|
644
659
|
* @private
|
|
645
660
|
*/
|
|
646
|
-
async _executeChain(middlewareFns, route, ctx
|
|
661
|
+
async _executeChain(middlewareFns, route, ctx) {
|
|
647
662
|
let idx = 0;
|
|
648
663
|
|
|
649
664
|
const next = async () => {
|
|
@@ -674,18 +689,7 @@ export default class Application {
|
|
|
674
689
|
}
|
|
675
690
|
if (!handled) this._errorHandler.handle(err, ctx);
|
|
676
691
|
}
|
|
677
|
-
|
|
678
|
-
// Context → Bridge res 변환 (이중 응답 방지 — Core res는 _sent 사용)
|
|
679
|
-
if (!res._sent) {
|
|
680
|
-
const response = ctx.toResponse();
|
|
681
|
-
res.status(response.status);
|
|
682
|
-
if (response.headers) {
|
|
683
|
-
for (const [k, v] of Object.entries(response.headers)) {
|
|
684
|
-
res.header(k, v);
|
|
685
|
-
}
|
|
686
|
-
}
|
|
687
|
-
res.send(response.body);
|
|
688
|
-
}
|
|
692
|
+
// 응답은 _createBridgeHandler의 .then()에서 sendAsyncResponse로 전송
|
|
689
693
|
}
|
|
690
694
|
|
|
691
695
|
/**
|
package/lib/core/AutoLoader.js
CHANGED
|
@@ -106,8 +106,13 @@ export default class AutoLoader {
|
|
|
106
106
|
*/
|
|
107
107
|
static registerController(ControllerClass) {
|
|
108
108
|
const proto = ControllerClass.prototype;
|
|
109
|
+
// Base 프로토타입 메서드 스킵 (Controller.register와 동일 로직)
|
|
110
|
+
const baseNames = new Set(Object.getOwnPropertyNames(
|
|
111
|
+
Object.getPrototypeOf(proto) // Base.prototype (Controller extends Base)
|
|
112
|
+
));
|
|
109
113
|
for (const method of Object.getOwnPropertyNames(proto)) {
|
|
110
114
|
if (method === 'constructor') continue;
|
|
115
|
+
if (baseNames.has(method)) continue;
|
|
111
116
|
if (typeof proto[method] !== 'function') continue;
|
|
112
117
|
// static property로 __handler__ 디스크립터 등록
|
|
113
118
|
ControllerClass[method] = {
|
|
@@ -140,7 +145,7 @@ export default class AutoLoader {
|
|
|
140
145
|
const mod = await import(file);
|
|
141
146
|
const MwClass = mod.default;
|
|
142
147
|
if (!MwClass) continue;
|
|
143
|
-
const mwName = MwClass.
|
|
148
|
+
const mwName = MwClass.alias || extractName(file, 'Middleware').toLowerCase();
|
|
144
149
|
this.app._middlewareRegistry.set(mwName, MwClass);
|
|
145
150
|
}
|
|
146
151
|
}
|
package/lib/core/Config.js
CHANGED
|
@@ -38,7 +38,8 @@ export default class Config {
|
|
|
38
38
|
|
|
39
39
|
try {
|
|
40
40
|
const content = readFileSync(configPath, 'utf-8');
|
|
41
|
-
const
|
|
41
|
+
const raw = Config.parseYaml(content);
|
|
42
|
+
const parsed = Config.resolveEnvVars(raw);
|
|
42
43
|
|
|
43
44
|
// _raw에 병합 (기존 opts.config 우선)
|
|
44
45
|
for (const [key, value] of Object.entries(parsed)) {
|
package/lib/core/Context.js
CHANGED
|
@@ -175,9 +175,14 @@ export default class Context {
|
|
|
175
175
|
return this;
|
|
176
176
|
}
|
|
177
177
|
|
|
178
|
-
send(
|
|
179
|
-
|
|
180
|
-
|
|
178
|
+
send(data) {
|
|
179
|
+
if (typeof data === 'object' && data !== null) {
|
|
180
|
+
return this.json(data);
|
|
181
|
+
}
|
|
182
|
+
this._body = String(data);
|
|
183
|
+
if (!this._headers['Content-Type']) {
|
|
184
|
+
this._headers['Content-Type'] = 'text/html; charset=utf-8';
|
|
185
|
+
}
|
|
181
186
|
this._sent = true;
|
|
182
187
|
return this;
|
|
183
188
|
}
|
|
@@ -139,7 +139,23 @@ export default class ConnectionManager {
|
|
|
139
139
|
case 'mongo': {
|
|
140
140
|
const mongoose = tryRequire('mongoose');
|
|
141
141
|
if (!mongoose) return this._createStub('mongo', config);
|
|
142
|
-
|
|
142
|
+
// Lazy connect — promise 캐싱으로 이중 연결 방지
|
|
143
|
+
const conn = { type: 'mongo', mongoose, config, _connected: false, _connectPromise: null };
|
|
144
|
+
conn.connect = () => {
|
|
145
|
+
if (conn._connected) return Promise.resolve();
|
|
146
|
+
if (conn._connectPromise) return conn._connectPromise;
|
|
147
|
+
const uri = config.uri || config.url
|
|
148
|
+
|| `mongodb://${config.host || '127.0.0.1'}:${config.port || 27017}/${config.database || ''}`;
|
|
149
|
+
conn._connectPromise = mongoose.connect(uri, config.options || {}).then(() => {
|
|
150
|
+
conn._connected = true;
|
|
151
|
+
});
|
|
152
|
+
return conn._connectPromise;
|
|
153
|
+
};
|
|
154
|
+
// 즉시 연결 시작 (await 없이 — 백그라운드)
|
|
155
|
+
conn.connect().catch(err => {
|
|
156
|
+
console.error(`[ConnectionManager] MongoDB 연결 실패: ${err.message}`);
|
|
157
|
+
});
|
|
158
|
+
return conn;
|
|
143
159
|
}
|
|
144
160
|
|
|
145
161
|
default:
|
package/lib/database/SqlModel.js
CHANGED
|
@@ -94,9 +94,10 @@ export default class SqlModel extends Model {
|
|
|
94
94
|
}
|
|
95
95
|
|
|
96
96
|
if (conn.type === 'sqlite' && conn.db) {
|
|
97
|
-
const columns = Object.keys(insertData);
|
|
97
|
+
const columns = Object.keys(insertData).map(c => SqlQueryBuilder._sanitizeName(c));
|
|
98
98
|
const placeholders = columns.map(() => '?').join(', ');
|
|
99
|
-
const
|
|
99
|
+
const safeTable = SqlQueryBuilder._sanitizeName(this.table);
|
|
100
|
+
const sql = `INSERT INTO ${safeTable} (${columns.join(', ')}) VALUES (${placeholders})`;
|
|
100
101
|
const result = conn.db.prepare(sql).run(...Object.values(insertData));
|
|
101
102
|
const id = result.lastInsertRowid;
|
|
102
103
|
return new this({ ...insertData, [this.primaryKey]: Number(id) });
|
|
@@ -207,9 +208,11 @@ export default class SqlModel extends Model {
|
|
|
207
208
|
const conn = this.constructor.getConnection();
|
|
208
209
|
|
|
209
210
|
if (conn.type === 'sqlite' && conn.db) {
|
|
210
|
-
const columns = Object.keys(data);
|
|
211
|
+
const columns = Object.keys(data).map(c => SqlQueryBuilder._sanitizeName(c));
|
|
212
|
+
const pk = SqlQueryBuilder._sanitizeName(this.constructor.primaryKey);
|
|
213
|
+
const safeTable = SqlQueryBuilder._sanitizeName(this.constructor.table);
|
|
211
214
|
const sets = columns.map(c => `${c} = ?`).join(', ');
|
|
212
|
-
const sql = `UPDATE ${
|
|
215
|
+
const sql = `UPDATE ${safeTable} SET ${sets} WHERE ${pk} = ?`;
|
|
213
216
|
conn.db.prepare(sql).run(...Object.values(data), id);
|
|
214
217
|
} else if (conn.type === 'knex' && conn.db) {
|
|
215
218
|
await conn.db(this.constructor.table).where(pk, id).update(data);
|
|
@@ -244,7 +247,9 @@ export default class SqlModel extends Model {
|
|
|
244
247
|
const conn = this.constructor.getConnection();
|
|
245
248
|
|
|
246
249
|
if (conn.type === 'sqlite' && conn.db) {
|
|
247
|
-
|
|
250
|
+
const safeTable = SqlQueryBuilder._sanitizeName(this.constructor.table);
|
|
251
|
+
const safePk = SqlQueryBuilder._sanitizeName(pk);
|
|
252
|
+
conn.db.prepare(`DELETE FROM ${safeTable} WHERE ${safePk} = ?`).run(id);
|
|
248
253
|
} else if (conn.type === 'knex' && conn.db) {
|
|
249
254
|
await conn.db(this.constructor.table).where(pk, id).delete();
|
|
250
255
|
}
|
|
@@ -7,12 +7,26 @@
|
|
|
7
7
|
* @see docs/framework/02-database-orm.md
|
|
8
8
|
*/
|
|
9
9
|
import QueryBuilder from './QueryBuilder.js';
|
|
10
|
+
import Pagination from './Pagination.js';
|
|
10
11
|
|
|
11
12
|
export default class SqlQueryBuilder extends QueryBuilder {
|
|
12
13
|
constructor(model) {
|
|
13
14
|
super(model);
|
|
14
15
|
}
|
|
15
16
|
|
|
17
|
+
/**
|
|
18
|
+
* SQL 식별자(컬럼명/테이블명) 유효성 검증 — SQL Injection 방지
|
|
19
|
+
* @param {string} name
|
|
20
|
+
* @returns {string}
|
|
21
|
+
* @private
|
|
22
|
+
*/
|
|
23
|
+
static _sanitizeName(name) {
|
|
24
|
+
if (typeof name !== 'string' || !/^[\w.]+$/.test(name)) {
|
|
25
|
+
throw new Error(`Invalid SQL identifier: '${name}'`);
|
|
26
|
+
}
|
|
27
|
+
return name;
|
|
28
|
+
}
|
|
29
|
+
|
|
16
30
|
/**
|
|
17
31
|
* 결과 조회 — SQL SELECT 실행
|
|
18
32
|
* @returns {Promise<Array<import('./Model.js').default>>}
|
|
@@ -117,7 +131,8 @@ export default class SqlQueryBuilder extends QueryBuilder {
|
|
|
117
131
|
|
|
118
132
|
if (conn.type === 'sqlite' && conn.db) {
|
|
119
133
|
const { whereClause, whereParams } = this._buildWhereSql();
|
|
120
|
-
const
|
|
134
|
+
const safeTable = SqlQueryBuilder._sanitizeName(this._model.table);
|
|
135
|
+
const sql = `DELETE FROM ${safeTable}${whereClause}`;
|
|
121
136
|
const result = conn.db.prepare(sql).run(...whereParams);
|
|
122
137
|
return result.changes;
|
|
123
138
|
}
|
|
@@ -141,13 +156,7 @@ export default class SqlQueryBuilder extends QueryBuilder {
|
|
|
141
156
|
this._limit = perPage;
|
|
142
157
|
this._offset = (page - 1) * perPage;
|
|
143
158
|
const data = await this.get();
|
|
144
|
-
return
|
|
145
|
-
data,
|
|
146
|
-
page,
|
|
147
|
-
perPage,
|
|
148
|
-
total,
|
|
149
|
-
lastPage: Math.ceil(total / perPage),
|
|
150
|
-
};
|
|
159
|
+
return new Pagination(data, total, page, perPage);
|
|
151
160
|
}
|
|
152
161
|
|
|
153
162
|
// ━━━━━━━━━━ SQLite 실행 ━━━━━━━━━━
|
|
@@ -161,13 +170,15 @@ export default class SqlQueryBuilder extends QueryBuilder {
|
|
|
161
170
|
|
|
162
171
|
const selectColumns = mode === 'count'
|
|
163
172
|
? 'COUNT(*) as cnt'
|
|
164
|
-
: (this._selects ? this._selects.join(', ') : '*');
|
|
173
|
+
: (this._selects ? this._selects.map(c => SqlQueryBuilder._sanitizeName(c)).join(', ') : '*');
|
|
174
|
+
|
|
175
|
+
const safeTable = SqlQueryBuilder._sanitizeName(this._model.table);
|
|
165
176
|
|
|
166
|
-
let sql = `SELECT ${selectColumns} FROM ${
|
|
177
|
+
let sql = `SELECT ${selectColumns} FROM ${safeTable}${whereClause}`;
|
|
167
178
|
|
|
168
179
|
// ORDER BY
|
|
169
180
|
if (this._orders.length > 0 && mode !== 'count') {
|
|
170
|
-
const orderParts = this._orders.map(o => `${o.column} ${o.direction.toUpperCase()}`);
|
|
181
|
+
const orderParts = this._orders.map(o => `${SqlQueryBuilder._sanitizeName(o.column)} ${o.direction.toUpperCase()}`);
|
|
171
182
|
sql += ` ORDER BY ${orderParts.join(', ')}`;
|
|
172
183
|
}
|
|
173
184
|
|
|
@@ -204,20 +215,21 @@ export default class SqlQueryBuilder extends QueryBuilder {
|
|
|
204
215
|
}
|
|
205
216
|
|
|
206
217
|
for (const w of this._wheres) {
|
|
218
|
+
const safeKey = SqlQueryBuilder._sanitizeName(w.key);
|
|
207
219
|
const prefix = parts.length > 0
|
|
208
220
|
? (w.type === 'or' ? ' OR ' : ' AND ')
|
|
209
221
|
: '';
|
|
210
222
|
|
|
211
223
|
if (w.op === 'IN') {
|
|
212
224
|
const placeholders = w.value.map(() => '?').join(', ');
|
|
213
|
-
parts.push(`${prefix}${
|
|
225
|
+
parts.push(`${prefix}${safeKey} IN (${placeholders})`);
|
|
214
226
|
params.push(...w.value);
|
|
215
227
|
} else if (w.op === 'IS NULL') {
|
|
216
|
-
parts.push(`${prefix}${
|
|
228
|
+
parts.push(`${prefix}${safeKey} IS NULL`);
|
|
217
229
|
} else if (w.op === 'IS NOT NULL') {
|
|
218
|
-
parts.push(`${prefix}${
|
|
230
|
+
parts.push(`${prefix}${safeKey} IS NOT NULL`);
|
|
219
231
|
} else {
|
|
220
|
-
parts.push(`${prefix}${
|
|
232
|
+
parts.push(`${prefix}${safeKey} ${w.op} ?`);
|
|
221
233
|
params.push(w.value);
|
|
222
234
|
}
|
|
223
235
|
}
|
|
@@ -233,8 +245,8 @@ export default class SqlQueryBuilder extends QueryBuilder {
|
|
|
233
245
|
_buildUpdateSql(data) {
|
|
234
246
|
const { whereClause, whereParams } = this._buildWhereSql();
|
|
235
247
|
const columns = Object.keys(data);
|
|
236
|
-
const sets = columns.map(c => `${c} = ?`).join(', ');
|
|
237
|
-
const sql = `UPDATE ${this._model.table} SET ${sets}${whereClause}`;
|
|
248
|
+
const sets = columns.map(c => `${SqlQueryBuilder._sanitizeName(c)} = ?`).join(', ');
|
|
249
|
+
const sql = `UPDATE ${SqlQueryBuilder._sanitizeName(this._model.table)} SET ${sets}${whereClause}`;
|
|
238
250
|
const params = [...Object.values(data), ...whereParams];
|
|
239
251
|
return { sql, params };
|
|
240
252
|
}
|
|
@@ -35,4 +35,50 @@ export default class MediaHelper {
|
|
|
35
35
|
if (this._bridge?.mediaVideoInfo) return this._bridge.mediaVideoInfo(filePath);
|
|
36
36
|
throw new Error('Video info requires ffprobe + Bridge.');
|
|
37
37
|
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* 이미지에 워터마크를 합성한다.
|
|
41
|
+
* @param {string} targetPath - 대상 이미지 경로
|
|
42
|
+
* @param {string} watermarkPath - 워터마크 이미지 경로
|
|
43
|
+
* @param {number} [opacity=50] - 불투명도 (0-100)
|
|
44
|
+
* @param {string} [format] - 출력 포맷 (기본: 원본 확장자)
|
|
45
|
+
* @param {number} [quality=90] - 출력 품질
|
|
46
|
+
*/
|
|
47
|
+
applyWatermark(targetPath, watermarkPath, opacity = 50, format, quality = 90) {
|
|
48
|
+
if (this._bridge?.mediaApplyWatermark) {
|
|
49
|
+
return this._bridge.mediaApplyWatermark(targetPath, watermarkPath, opacity, format, quality);
|
|
50
|
+
}
|
|
51
|
+
throw new Error('Watermark requires Bridge (Rust).');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* 비디오에서 N초 간격으로 다중 썸네일을 추출한다.
|
|
56
|
+
* @param {string} inputPath - 비디오 경로
|
|
57
|
+
* @param {string} outputDir - 출력 디렉토리
|
|
58
|
+
* @param {number} [interval=5] - 초 간격
|
|
59
|
+
* @param {number} [width=320] - 썸네일 가로 크기
|
|
60
|
+
* @param {string} [format='jpeg'] - 포맷
|
|
61
|
+
* @returns {string[]} 생성된 파일 경로 목록
|
|
62
|
+
*/
|
|
63
|
+
videoThumbnails(inputPath, outputDir, interval = 5, width = 320, format = 'jpeg') {
|
|
64
|
+
if (this._bridge?.mediaVideoThumbnails) {
|
|
65
|
+
return this._bridge.mediaVideoThumbnails(inputPath, outputDir, interval, width, format);
|
|
66
|
+
}
|
|
67
|
+
throw new Error('Video thumbnails requires ffmpeg + Bridge.');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* 썸네일 목록을 스프라이트 시트(그리드)로 합성한다.
|
|
72
|
+
* @param {string[]} thumbPaths - 썸네일 경로 목록
|
|
73
|
+
* @param {string} outputPath - 출력 스프라이트 시트 경로
|
|
74
|
+
* @param {number} [cols=10] - 그리드 열 수
|
|
75
|
+
* @param {number} [thumbWidth=160] - 각 썸네일 가로
|
|
76
|
+
* @param {number} [thumbHeight=0] - 각 썸네일 세로 (0=자동)
|
|
77
|
+
*/
|
|
78
|
+
videoPreviewSheet(thumbPaths, outputPath, cols = 10, thumbWidth = 160, thumbHeight = 0) {
|
|
79
|
+
if (this._bridge?.mediaVideoPreviewSheet) {
|
|
80
|
+
return this._bridge.mediaVideoPreviewSheet(thumbPaths, outputPath, cols, thumbWidth, thumbHeight);
|
|
81
|
+
}
|
|
82
|
+
throw new Error('Preview sheet requires Bridge (Rust).');
|
|
83
|
+
}
|
|
38
84
|
}
|
package/lib/http/Middleware.js
CHANGED
package/lib/middleware/index.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* @see docs/framework/12-middleware.md
|
|
5
5
|
* @see docs/framework/14-authentication.md
|
|
6
6
|
*/
|
|
7
|
+
import { createHmac, timingSafeEqual } from 'node:crypto';
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* JSON/Form body 파싱 미들웨어
|
|
@@ -200,7 +201,6 @@ function decodeJwtPayload(token, secret) {
|
|
|
200
201
|
const [headerB64, payloadB64, signatureB64] = parts;
|
|
201
202
|
|
|
202
203
|
// ── HMAC-SHA256 서명 검증 ──
|
|
203
|
-
const { createHmac, timingSafeEqual } = require('node:crypto');
|
|
204
204
|
const signingInput = `${headerB64}.${payloadB64}`;
|
|
205
205
|
const expectedSig = createHmac('sha256', secret)
|
|
206
206
|
.update(signingInput)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fuzionx/framework",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.22",
|
|
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,7 @@
|
|
|
34
34
|
"url": "https://github.com/saytohenry/fuzionx"
|
|
35
35
|
},
|
|
36
36
|
"dependencies": {
|
|
37
|
-
"@fuzionx/core": "^0.1.
|
|
37
|
+
"@fuzionx/core": "^0.1.22",
|
|
38
38
|
"better-sqlite3": "^12.8.0",
|
|
39
39
|
"knex": "^3.2.5",
|
|
40
40
|
"mongoose": "^9.3.2",
|