@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.
- 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/lib/core/Application.js +43 -13
- package/lib/core/AutoLoader.js +1 -1
- package/lib/database/Model.js +35 -1
- package/lib/database/SqlQueryBuilder.js +8 -6
- package/lib/services/Storage.js +174 -11
- package/package.json +3 -2
package/lib/core/Application.js
CHANGED
|
@@ -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 [
|
|
373
|
-
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
|
+
}
|
|
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
|
-
//
|
|
417
|
-
const
|
|
418
|
-
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;
|
|
419
431
|
|
|
420
|
-
// 매칭
|
|
421
|
-
return
|
|
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
|
-
|
|
763
|
-
appName = this._resolveApp(host, rawReq.headers);
|
|
788
|
+
appName = resolvedAppName;
|
|
764
789
|
entry = dispatch.appMap.get(appName);
|
|
765
|
-
if (!entry)
|
|
766
|
-
|
|
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;
|
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
|
|
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",
|