@fuzionx/framework 0.1.66 → 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.
- package/cli/templates/make/app-spa/views/default/spa/package.json +1 -1
- package/cli/templates/make/app-spa/views/default/spa/vite.config.js +2 -0
- package/index.js +1 -1
- package/lib/core/Application.js +44 -13
- package/lib/core/AutoLoader.js +1 -1
- package/lib/database/Model.js +35 -1
- package/lib/database/SqlQueryBuilder.js +8 -6
- package/lib/helpers/I18nHelper.js +110 -6
- package/lib/middleware/index.js +4 -3
- package/lib/services/Storage.js +174 -11
- package/package.json +3 -2
package/index.js
CHANGED
|
@@ -65,4 +65,4 @@ export { default as OpenAPI } from './lib/view/OpenAPI.js';
|
|
|
65
65
|
export { PaginationUtil, StrUtil, NumUtil, DateUtil, ArrUtil, FunctionUtil, ObjectUtil } from './lib/utilities/index.js';
|
|
66
66
|
|
|
67
67
|
// ── Built-in Middleware ──
|
|
68
|
-
export { bodyParser, cors, auth, apiAuth, csrf, session,
|
|
68
|
+
export { bodyParser, cors, auth, apiAuth, csrf, session, loadUser } from './lib/middleware/index.js';
|
package/lib/core/Application.js
CHANGED
|
@@ -103,6 +103,7 @@ export default class Application {
|
|
|
103
103
|
fallback: this.config.get('app.i18n.fallback', 'en'),
|
|
104
104
|
dir: this.config.get('app.i18n.dir', './locales'),
|
|
105
105
|
bridge: this._bridge,
|
|
106
|
+
autoComplete: this.config.get('app.i18n.auto_complete', false),
|
|
106
107
|
});
|
|
107
108
|
this.storage = null;
|
|
108
109
|
this._scheduler = null;
|
|
@@ -290,6 +291,7 @@ export default class Application {
|
|
|
290
291
|
this.storage = new Storage({
|
|
291
292
|
driver: this.config.get('storage.driver', 'local'),
|
|
292
293
|
basePath: path.resolve(this.baseDir, this.config.get('storage.path', './storage')),
|
|
294
|
+
s3: this.config.get('storage.s3') || null,
|
|
293
295
|
fileHelper: this.file,
|
|
294
296
|
});
|
|
295
297
|
|
|
@@ -366,10 +368,16 @@ export default class Application {
|
|
|
366
368
|
this._booted = true;
|
|
367
369
|
|
|
368
370
|
// hostname → appName 매핑 사전 계산 (매 요청 O(1) 조회)
|
|
371
|
+
// host:port 키와 hostname-only 키 모두 저장 (포트 제거 조회 호환)
|
|
369
372
|
this._hostAppMap = new Map();
|
|
370
373
|
const appsConfig = this.config.get('apps') || {};
|
|
371
|
-
for (const [
|
|
372
|
-
this._hostAppMap.set(
|
|
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
|
+
}
|
|
373
381
|
}
|
|
374
382
|
|
|
375
383
|
await this.emit('booted');
|
|
@@ -401,23 +409,28 @@ export default class Application {
|
|
|
401
409
|
/**
|
|
402
410
|
* 호스트 → 앱 이름 결정
|
|
403
411
|
* reverse proxy 경유 시 X-Forwarded-Host 우선 사용.
|
|
412
|
+
* 매핑되지 않은 도메인은 null 반환 (접근 차단).
|
|
404
413
|
* @param {string} host - 요청 Host 헤더 (port 포함 가능)
|
|
405
414
|
* @param {object} [headers] - 전체 요청 헤더 (proxy 지원)
|
|
406
|
-
* @returns {string} 앱 이름
|
|
415
|
+
* @returns {string|null} 앱 이름 또는 null (미등록 도메인)
|
|
407
416
|
*/
|
|
408
417
|
_resolveApp(host, headers) {
|
|
409
418
|
// reverse proxy: X-Forwarded-Host > X-Original-Host > Host
|
|
410
419
|
const forwardedHost = headers?.['x-forwarded-host'] || headers?.['X-Forwarded-Host']
|
|
411
420
|
|| headers?.['x-original-host'] || headers?.['X-Original-Host'];
|
|
412
421
|
const rawHost = forwardedHost || host || '';
|
|
413
|
-
const hostname = rawHost.split(':')[0]; // 포트 제거
|
|
414
422
|
|
|
415
|
-
//
|
|
416
|
-
const
|
|
417
|
-
if (
|
|
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;
|
|
418
431
|
|
|
419
|
-
// 매칭
|
|
420
|
-
return
|
|
432
|
+
// 매칭 없음 → null (미등록 도메인 접근 차단)
|
|
433
|
+
return null;
|
|
421
434
|
}
|
|
422
435
|
|
|
423
436
|
/**
|
|
@@ -752,17 +765,35 @@ export default class Application {
|
|
|
752
765
|
return { status: 404, body: '{"error":"Not Found"}', headers: { 'Content-Type': 'application/json' } };
|
|
753
766
|
}
|
|
754
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
|
+
|
|
755
782
|
// ── 앱 결정 (단일 앱: O(0), 멀티앱: Host 기반) ──
|
|
756
783
|
let appName, entry;
|
|
757
784
|
if (dispatch.isSingleApp) {
|
|
758
785
|
appName = dispatch.singleAppName;
|
|
759
786
|
entry = dispatch.singleEntry;
|
|
760
787
|
} else {
|
|
761
|
-
|
|
762
|
-
appName = this._resolveApp(host, rawReq.headers);
|
|
788
|
+
appName = resolvedAppName;
|
|
763
789
|
entry = dispatch.appMap.get(appName);
|
|
764
|
-
if (!entry)
|
|
765
|
-
|
|
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
|
+
}
|
|
766
797
|
}
|
|
767
798
|
|
|
768
799
|
const { middlewareFns, resolvedHandler } = entry;
|
package/lib/core/AutoLoader.js
CHANGED
|
@@ -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
|
}
|
package/lib/database/Model.js
CHANGED
|
@@ -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)
|
|
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
|
-
|
|
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;
|
|
368
|
+
if (!RelatedModel || typeof RelatedModel === 'string') {
|
|
369
|
+
continue;
|
|
368
370
|
}
|
|
369
371
|
}
|
|
370
372
|
|
|
@@ -4,12 +4,18 @@
|
|
|
4
4
|
* Bridge의 i18NTranslate(locale, key) 직접 호출.
|
|
5
5
|
* Bridge 없으면 JS 파일 기반 폴백.
|
|
6
6
|
*
|
|
7
|
+
* auto_complete 활성 시 fs.watch로 locale 파일 변경을 감지하여
|
|
8
|
+
* 디바운스 후 자동 동기화한다. (매 요청마다 파일 I/O 없음)
|
|
9
|
+
*
|
|
7
10
|
* @see docs/framework/18-i18n.md
|
|
8
11
|
* @see packages/fuzionx/lib/i18n.js (Core 래퍼 참조)
|
|
9
12
|
*/
|
|
10
|
-
import { promises as fs } from 'node:fs';
|
|
13
|
+
import { promises as fs, readFileSync, watch as fsWatch, existsSync } from 'node:fs';
|
|
11
14
|
import path from 'node:path';
|
|
12
15
|
|
|
16
|
+
/** 디바운스 기본 대기 시간 (ms) */
|
|
17
|
+
const RELOAD_DEBOUNCE_MS = 500;
|
|
18
|
+
|
|
13
19
|
export default class I18nHelper {
|
|
14
20
|
/**
|
|
15
21
|
* @param {object} [opts]
|
|
@@ -17,18 +23,24 @@ export default class I18nHelper {
|
|
|
17
23
|
* @param {string} [opts.fallback='en']
|
|
18
24
|
* @param {string} [opts.dir='./locales']
|
|
19
25
|
* @param {object} [opts.bridge] - Bridge N-API 인스턴스
|
|
26
|
+
* @param {boolean} [opts.autoComplete=false] - auto_complete 활성 시 파일 감시
|
|
20
27
|
*/
|
|
21
28
|
constructor(opts = {}) {
|
|
22
29
|
this.defaultLocale = opts.defaultLocale || 'ko';
|
|
23
30
|
this.fallback = opts.fallback || 'en';
|
|
24
31
|
this.dir = opts.dir || './locales';
|
|
25
32
|
this._bridge = opts.bridge || null;
|
|
26
|
-
this.
|
|
33
|
+
this._autoComplete = !!opts.autoComplete;
|
|
34
|
+
this._messages = new Map(); // locale → { flat key: value }
|
|
27
35
|
this._loaded = false;
|
|
36
|
+
this._watcher = null; // fs.watch 인스턴스
|
|
37
|
+
this._reloadTimer = null; // 디바운스 타이머
|
|
38
|
+
this._dirtyLocales = new Set(); // 변경된 locale 큐
|
|
28
39
|
}
|
|
29
40
|
|
|
30
41
|
/**
|
|
31
|
-
* 번역 파일 로드 (JS 폴백용)
|
|
42
|
+
* 번역 파일 로드 (JS 폴백용).
|
|
43
|
+
* auto_complete가 활성이면 locale 디렉토리 감시 시작.
|
|
32
44
|
*/
|
|
33
45
|
async load() {
|
|
34
46
|
if (this._loaded) return;
|
|
@@ -45,6 +57,11 @@ export default class I18nHelper {
|
|
|
45
57
|
}
|
|
46
58
|
} catch {} // locales/ 없으면 무시
|
|
47
59
|
this._loaded = true;
|
|
60
|
+
|
|
61
|
+
// auto_complete 활성 시 파일 감시 시작
|
|
62
|
+
if (this._autoComplete) {
|
|
63
|
+
this._startWatcher();
|
|
64
|
+
}
|
|
48
65
|
}
|
|
49
66
|
|
|
50
67
|
/**
|
|
@@ -86,12 +103,30 @@ export default class I18nHelper {
|
|
|
86
103
|
}
|
|
87
104
|
|
|
88
105
|
/**
|
|
89
|
-
* 전체 locale 데이터 반환 (뷰 주입용)
|
|
106
|
+
* 전체 locale 데이터 반환 (뷰 주입용).
|
|
107
|
+
*
|
|
108
|
+
* Bridge가 있으면 i18NGetTranslations N-API로 최신 데이터를 직접 반환.
|
|
109
|
+
* Bridge 없으면 JS _messages 폴백.
|
|
110
|
+
*
|
|
90
111
|
* @param {string} locale
|
|
91
|
-
* @returns {object}
|
|
112
|
+
* @returns {object} flat dot-notation 번역 데이터
|
|
92
113
|
*/
|
|
93
114
|
all(locale) {
|
|
94
|
-
const
|
|
115
|
+
const target = locale || this.defaultLocale;
|
|
116
|
+
|
|
117
|
+
/** Bridge 우선 — 메모리에서 직접 최신 데이터 반환 */
|
|
118
|
+
if (this._bridge && typeof this._bridge.i18NGetTranslations === 'function') {
|
|
119
|
+
try {
|
|
120
|
+
const json = this._bridge.i18NGetTranslations(target);
|
|
121
|
+
if (json) {
|
|
122
|
+
const data = JSON.parse(json);
|
|
123
|
+
return this._flatten(data); // nested → flat dot-notation
|
|
124
|
+
}
|
|
125
|
+
} catch {} // Bridge 실패 시 JS 폴백
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** JS 폴백 — _messages 캐시 사용 */
|
|
129
|
+
const messages = this._messages.get(target) || this._messages.get(this.defaultLocale);
|
|
95
130
|
if (!messages) return {};
|
|
96
131
|
return { ...messages };
|
|
97
132
|
}
|
|
@@ -121,6 +156,75 @@ export default class I18nHelper {
|
|
|
121
156
|
}
|
|
122
157
|
}
|
|
123
158
|
|
|
159
|
+
// ──────────────────────────────────────────
|
|
160
|
+
// 파일 감시 & 디바운스 동기화
|
|
161
|
+
// ──────────────────────────────────────────
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* locale 디렉토리를 fs.watch로 감시 시작.
|
|
165
|
+
* JSON 파일 변경 감지 시 해당 locale을 dirty 큐에 넣고
|
|
166
|
+
* 디바운스 타이머 후 일괄 동기화.
|
|
167
|
+
* @private
|
|
168
|
+
*/
|
|
169
|
+
_startWatcher() {
|
|
170
|
+
if (!existsSync(this.dir)) return;
|
|
171
|
+
try {
|
|
172
|
+
this._watcher = fsWatch(this.dir, (eventType, filename) => {
|
|
173
|
+
// .json 파일 변경만 감시
|
|
174
|
+
if (!filename || !filename.endsWith('.json')) return;
|
|
175
|
+
const locale = path.basename(filename, '.json');
|
|
176
|
+
this._dirtyLocales.add(locale); // 큐에 추가
|
|
177
|
+
|
|
178
|
+
// 디바운스: 기존 타이머 취소 → 새로 시작
|
|
179
|
+
clearTimeout(this._reloadTimer);
|
|
180
|
+
this._reloadTimer = setTimeout(() => {
|
|
181
|
+
this._flushDirty();
|
|
182
|
+
}, RELOAD_DEBOUNCE_MS);
|
|
183
|
+
});
|
|
184
|
+
} catch {} // 감시 실패 시 무시 (기존 동작 유지)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* dirty 큐에 쌓인 locale 파일들을 일괄 동기 재로드.
|
|
189
|
+
* @private
|
|
190
|
+
*/
|
|
191
|
+
_flushDirty() {
|
|
192
|
+
if (this._dirtyLocales.size === 0) return;
|
|
193
|
+
for (const locale of this._dirtyLocales) {
|
|
194
|
+
this._syncReload(locale);
|
|
195
|
+
}
|
|
196
|
+
this._dirtyLocales.clear();
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* 단일 locale 파일을 동기적으로 재로드.
|
|
201
|
+
* @param {string} locale - 로드할 locale (예: 'ko', 'en')
|
|
202
|
+
* @private
|
|
203
|
+
*/
|
|
204
|
+
_syncReload(locale) {
|
|
205
|
+
try {
|
|
206
|
+
const jsonPath = path.join(this.dir, `${locale}.json`);
|
|
207
|
+
const content = readFileSync(jsonPath, 'utf-8');
|
|
208
|
+
const data = JSON.parse(content);
|
|
209
|
+
this._messages.set(locale, this._flatten(data));
|
|
210
|
+
} catch {} // 파일 없으면 무시
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* 감시자 정리 (graceful shutdown 용)
|
|
215
|
+
*/
|
|
216
|
+
destroy() {
|
|
217
|
+
if (this._watcher) {
|
|
218
|
+
this._watcher.close();
|
|
219
|
+
this._watcher = null;
|
|
220
|
+
}
|
|
221
|
+
clearTimeout(this._reloadTimer);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// ──────────────────────────────────────────
|
|
225
|
+
// 파서 유틸리티
|
|
226
|
+
// ──────────────────────────────────────────
|
|
227
|
+
|
|
124
228
|
/** {field} → value 치환 */
|
|
125
229
|
_substitute(template, vars) {
|
|
126
230
|
if (!vars || typeof template !== 'string') return template;
|
package/lib/middleware/index.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* 내장 미들웨어 — 배럴 re-export
|
|
2
|
+
* 내장 미들웨어 — 배럴 re-export (공용)
|
|
3
|
+
*
|
|
4
|
+
* 앱 전용 미들웨어(theme, roleGuard 등)는
|
|
5
|
+
* 각 앱의 middleware/ 폴더에서 직접 구현합니다.
|
|
3
6
|
*
|
|
4
7
|
* @see docs/framework/12-middleware.md
|
|
5
8
|
* @see docs/framework/14-authentication.md
|
|
@@ -10,6 +13,4 @@ export { auth } from './auth.js';
|
|
|
10
13
|
export { apiAuth } from './apiAuth.js';
|
|
11
14
|
export { csrf } from './csrf.js';
|
|
12
15
|
export { session } from './session.js';
|
|
13
|
-
export { theme } from './theme.js';
|
|
14
16
|
export { loadUser } from './loadUser.js';
|
|
15
|
-
export { roleGuard } from './roleGuard.js';
|
package/lib/services/Storage.js
CHANGED
|
@@ -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 스트리밍 업로드
|
|
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 설정
|
|
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
|
-
|
|
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
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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.
|
|
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
|
-
"@
|
|
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",
|