mega-framework 0.1.7 → 0.1.9
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/README.md +9 -0
- package/package.json +3 -3
- package/sample/crud/.env +9 -0
- package/sample/crud/.env.example +9 -0
- package/sample/crud/apps/main/controllers/upload-controller.js +28 -5
- package/sample/crud/apps/main/locales/server/en.json +12 -1
- package/sample/crud/apps/main/locales/server/ko.json +12 -1
- package/sample/crud/apps/main/routes/upload.js +20 -1
- package/sample/crud/apps/main/services/guide-service.js +4 -3
- package/sample/crud/apps/main/services/upload-demo-service.js +6 -5
- package/sample/crud/apps/main/views/upload/index.ejs +4 -1
- package/sample/crud/docs/guide/01-cli.md +587 -0
- package/sample/crud/docs/guide/02-router-controller.md +497 -0
- package/sample/crud/docs/guide/03-service-model-db.md +929 -0
- package/sample/crud/docs/guide/04-websocket-asp.md +632 -0
- package/sample/crud/docs/guide/05-scheduler-job-worker.md +400 -0
- package/sample/crud/docs/guide/06-config-auth-session-security.md +495 -0
- package/sample/crud/docs/guide/07-view-i18n-static-multipart.md +462 -0
- package/sample/crud/docs/guide/08-observability.md +373 -0
- package/sample/crud/mega.config.js +7 -0
- package/sample/crud/package.json +2 -2
- package/sample/crud/scripts/start-ws-hub.sh +18 -4
- package/sample/crud/var/uploads/(/355/212/271/352/270/260/354/236/220) 2026 /353/251/264/354/240/221/354/225/210/353/202/264-mqbnwq5v-d2125aa8.txt" +1 -0
- package/sample/crud/var/uploads/(/355/212/271/352/270/260/354/236/220) 2026 /353/251/264/354/240/221/354/225/210/353/202/264-mqbo0nbf-842b6135.txt" +1 -0
- package/sample/crud/var/uploads/00_b-mqbnh7lc-c9790ab8.png +0 -0
- package/sample/crud/var/uploads/2025-07-22 13 35 56-mqbngvvk-e545e90e.png +0 -0
- package/sample/crud/var/uploads/big-file-mqbo1h9e-2957eaf5.png +0 -0
- package/sample/crud/var/uploads//341/204/200/341/205/247/341/206/274/341/204/200/341/205/265/341/204/211/341/205/265/341/206/257/341/204/214/341/205/245/341/206/250/341/204/214/341/205/263/341/206/274/341/204/206/341/205/247/341/206/274/341/204/211/341/205/245-mqbo5yxh-5288d8ef.pdf +0 -0
- package/sample/simple/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
- package/src/adapters/adapter-options.js +14 -3
- package/src/adapters/file-adapter.js +9 -5
- package/src/adapters/file-session-adapter.js +4 -3
- package/src/adapters/maria-adapter.js +7 -4
- package/src/adapters/mega-cache-adapter.js +83 -6
- package/src/adapters/mega-db-adapter.js +4 -1
- package/src/adapters/mongo-adapter.js +21 -7
- package/src/adapters/postgres-adapter.js +8 -4
- package/src/adapters/redis-adapter.js +7 -3
- package/src/adapters/sqlite-adapter.js +6 -2
- package/src/cli/commands/console-cmd.js +3 -1
- package/src/cli/commands/scaffold.js +38 -2
- package/src/cli/generators/index.js +58 -1
- package/src/cli/index.js +88 -59
- package/src/cli/watch.js +188 -0
- package/src/core/ajv-mapper.js +3 -1
- package/src/core/ctx-builder.js +59 -1
- package/src/core/envelope.js +9 -2
- package/src/core/hub-link.js +24 -14
- package/src/core/index.js +1 -1
- package/src/core/mega-app.js +55 -45
- package/src/core/pipeline.js +8 -6
- package/src/core/scope-registry.js +1 -0
- package/src/core/security.js +3 -3
- package/src/core/session-store.js +14 -1
- package/src/core/ws-presence.js +17 -5
- package/src/core/ws-roster.js +49 -10
- package/src/core/ws-upgrade.js +105 -0
- package/src/lib/mega-circuit-breaker.js +5 -3
- package/src/lib/mega-health.js +10 -0
- package/src/lib/mega-job-queue.js +53 -13
- package/src/lib/mega-job.js +8 -1
- package/src/lib/mega-metrics.js +28 -1
- package/src/lib/mega-plugin.js +2 -2
- package/src/lib/mega-worker.js +28 -5
- package/src/lib/ws-hub.js +90 -9
- package/templates/adr/code.tpl +23 -0
- package/types/adapters/adapter-options.d.ts +2 -0
- package/types/adapters/file-adapter.d.ts +12 -1
- package/types/adapters/file-session-adapter.d.ts +4 -2
- package/types/adapters/maria-adapter.d.ts +5 -3
- package/types/adapters/mega-cache-adapter.d.ts +27 -1
- package/types/adapters/mega-db-adapter.d.ts +4 -1
- package/types/adapters/mongo-adapter.d.ts +13 -2
- package/types/adapters/postgres-adapter.d.ts +4 -2
- package/types/adapters/redis-adapter.d.ts +8 -0
- package/types/adapters/sqlite-adapter.d.ts +8 -2
- package/types/cli/generators/index.d.ts +11 -1
- package/types/cli/index.d.ts +12 -27
- package/types/cli/watch.d.ts +59 -0
- package/types/core/ctx-builder.d.ts +23 -0
- package/types/core/hub-link.d.ts +3 -1
- package/types/core/index.d.ts +1 -1
- package/types/core/mega-app.d.ts +1 -1
- package/types/core/pipeline.d.ts +2 -1
- package/types/core/security.d.ts +3 -3
- package/types/core/session-store.d.ts +7 -0
- package/types/core/ws-roster.d.ts +13 -1
- package/types/core/ws-upgrade.d.ts +29 -0
- package/types/lib/mega-circuit-breaker.d.ts +4 -2
- package/types/lib/mega-health.d.ts +7 -0
- package/types/lib/mega-job-queue.d.ts +16 -4
- package/types/lib/mega-job.d.ts +8 -1
- package/types/lib/mega-plugin.d.ts +1 -1
- package/types/lib/mega-worker.d.ts +3 -1
- package/types/lib/ws-hub.d.ts +27 -2
|
@@ -0,0 +1,462 @@
|
|
|
1
|
+
# View(EJS) + i18n + Static + Multipart
|
|
2
|
+
|
|
3
|
+
서버사이드 HTML 렌더(EJS), 다국어(i18next), 정적 자산 서빙(@fastify/static), 파일 업로드
|
|
4
|
+
(@fastify/multipart) 네 기능을 한 묶음으로 다룬다. 넷 다 **앱 config 옵트인** — 해당 키를 적은 앱에만
|
|
5
|
+
배선되고, 안 적으면 라우트·데코레이터가 아예 없다(의도치 않은 노출 0).
|
|
6
|
+
|
|
7
|
+
실 예시는 모두 `sample/crud` 앱(`sample/crud/apps/main/`) 코드다.
|
|
8
|
+
|
|
9
|
+
> 관련 코어: `src/core/template.js`, `src/core/i18n.js`, `src/core/static-assets.js`,
|
|
10
|
+
> `src/core/multipart.js`.
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
## 0. 한눈에 — 앱 config 옵트인
|
|
15
|
+
|
|
16
|
+
`apps/<app>/app.config.js` 의 키 하나로 켠다. 네 기능 모두 "키가 있으면 등록, 없으면 미등록"이다.
|
|
17
|
+
|
|
18
|
+
```js
|
|
19
|
+
// sample/crud/apps/main/app.config.js (발췌)
|
|
20
|
+
export default {
|
|
21
|
+
name: 'main',
|
|
22
|
+
|
|
23
|
+
// EJS 서버사이드 렌더 — views.dir 옵트인
|
|
24
|
+
views: {
|
|
25
|
+
dir: 'apps/main/views',
|
|
26
|
+
layoutDir: 'layouts',
|
|
27
|
+
partialsDir: 'partials',
|
|
28
|
+
},
|
|
29
|
+
|
|
30
|
+
// i18next 다국어 — 쿠키(mega.lang)로만 언어 결정
|
|
31
|
+
i18n: {
|
|
32
|
+
default: 'ko',
|
|
33
|
+
available: ['ko', 'en'],
|
|
34
|
+
fallback: 'en',
|
|
35
|
+
localesDir: 'apps/main/locales',
|
|
36
|
+
exposeTranslations: true,
|
|
37
|
+
},
|
|
38
|
+
|
|
39
|
+
// 정적 자산 — enabled:true 일 때만 등록
|
|
40
|
+
staticAssets: {
|
|
41
|
+
enabled: true,
|
|
42
|
+
dir: 'apps/main/public',
|
|
43
|
+
prefix: '/static',
|
|
44
|
+
},
|
|
45
|
+
|
|
46
|
+
// 파일 업로드 — 키가 있으면 multipart 파서 + 게이트 배선(없으면 multipart 요청 415)
|
|
47
|
+
upload: {
|
|
48
|
+
maxFileSize: 5 * 1024 * 1024,
|
|
49
|
+
maxFiles: 3,
|
|
50
|
+
allowedMimeTypes: ['image/*', 'application/pdf', 'text/plain'],
|
|
51
|
+
},
|
|
52
|
+
}
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
> 경로(`dir`/`localesDir`)는 상대경로면 **프로젝트 루트(`process.cwd()`)** 기준으로 해석된다. 런타임은
|
|
56
|
+
> 앱 디렉터리를 모르므로 config 로 명시받는다.
|
|
57
|
+
|
|
58
|
+
---
|
|
59
|
+
|
|
60
|
+
## 1. EJS + ejs-mate (template.js)
|
|
61
|
+
|
|
62
|
+
엔진은 **EJS + ejs-mate** 고정(ADR-011). EJS 가 표현식·이스케이프를, ejs-mate 가 레이아웃·파셜·블록을
|
|
63
|
+
담당한다.
|
|
64
|
+
|
|
65
|
+
### 1-1. 마스터 레이아웃 + `layout()`
|
|
66
|
+
|
|
67
|
+
페이지 뷰 맨 위에서 `<% layout('layouts/main') %>` 를 부르면 그 페이지 HTML 이 마스터 레이아웃의
|
|
68
|
+
`<%- body %>` 자리에 끼워진다.
|
|
69
|
+
|
|
70
|
+
```ejs
|
|
71
|
+
<%# sample/crud/apps/main/views/home.ejs %>
|
|
72
|
+
<% layout('layouts/main') %>
|
|
73
|
+
|
|
74
|
+
<section class="hero ...">
|
|
75
|
+
<h1><%= t('home_title') %></h1>
|
|
76
|
+
...
|
|
77
|
+
</section>
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
```ejs
|
|
81
|
+
<%# sample/crud/apps/main/views/layouts/main.ejs (발췌) %>
|
|
82
|
+
<!doctype html>
|
|
83
|
+
<html lang="<%= lang %>" data-bs-theme="light">
|
|
84
|
+
<head>
|
|
85
|
+
<title><%= typeof title !== 'undefined' ? title : 'sample-crud' %></title>
|
|
86
|
+
...
|
|
87
|
+
</head>
|
|
88
|
+
<body>
|
|
89
|
+
<nav class="navbar">...</nav>
|
|
90
|
+
<main class="container">
|
|
91
|
+
<%- body %> <%# 여기에 페이지 본문이 들어온다 %>
|
|
92
|
+
</main>
|
|
93
|
+
<footer>...</footer>
|
|
94
|
+
</body>
|
|
95
|
+
</html>
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
레이아웃 결정 우선순위([template.js:154](../../node_modules/mega-framework/src/core/template.js#L154)):
|
|
99
|
+
|
|
100
|
+
1. `reply.render(view, data, { layout })` 의 `opts.layout` — 문자열이면 그 레이아웃, `false` 면 레이아웃 강제 해제.
|
|
101
|
+
2. 앱 config 의 `views.defaultLayout`(있으면).
|
|
102
|
+
3. 둘 다 없으면 템플릿 안의 `<% layout(...) %>` 호출에 위임(위 home.ejs 방식).
|
|
103
|
+
|
|
104
|
+
### 1-2. 이스케이프 — `<%= %>` vs `<%- %>`
|
|
105
|
+
|
|
106
|
+
- `<%= value %>` — **HTML 이스케이프**(XSS 기본 방어). 사용자 입력은 항상 이쪽.
|
|
107
|
+
- `<%- value %>` — **raw 출력**(이스케이프 없음). 신뢰하는 데이터에만. 레이아웃의 `<%- body %>` 가 대표 예
|
|
108
|
+
(이미 렌더된 HTML 이라 raw 로 끼운다).
|
|
109
|
+
|
|
110
|
+
### 1-3. 파셜
|
|
111
|
+
|
|
112
|
+
ejs-mate 의 `partial()` 로 조각 뷰를 끼울 수 있다([template.js:21](../../node_modules/mega-framework/src/core/template.js#L21)). lookup 은 뷰
|
|
113
|
+
루트(`views.dir`) 기준이다.
|
|
114
|
+
|
|
115
|
+
```ejs
|
|
116
|
+
<%- partial('partials/navbar') %>
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
> sample/crud 는 navbar·footer 를 `layouts/main.ejs` 안에 **인라인**으로 두었다(별도 `partials/` 파일
|
|
120
|
+
> 없음). `partialsDir` 은 config 에 선언돼 있어 파셜을 쓰려면 `apps/main/views/partials/` 에 파일을 만들면
|
|
121
|
+
> 된다.
|
|
122
|
+
|
|
123
|
+
### 1-4. 렌더 호출 — `ctx.render` / `reply.render`
|
|
124
|
+
|
|
125
|
+
라우트 핸들러는 `ctx.render(view, data)` 로 렌더한다(내부적으로 `reply.render` 에 위임). 렌더 후
|
|
126
|
+
`Content-Type: text/html; charset=utf-8` 로 응답된다.
|
|
127
|
+
|
|
128
|
+
```js
|
|
129
|
+
// sample/crud/apps/main/controllers/upload-controller.js (발췌)
|
|
130
|
+
static async index(req, reply, ctx) {
|
|
131
|
+
const snap = await ctx.services.uploadDemo.snapshot()
|
|
132
|
+
return ctx.render('upload/index', {
|
|
133
|
+
title: ctx.t('upload_title'),
|
|
134
|
+
snap,
|
|
135
|
+
uploadDir: UPLOAD_DIR_SETTING,
|
|
136
|
+
currentUser: currentUser(req),
|
|
137
|
+
csrfToken: reply.generateCsrf(),
|
|
138
|
+
})
|
|
139
|
+
}
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
`view` 는 뷰 루트 기준 상대 이름(`'upload/index'` → `apps/main/views/upload/index.ejs`, `.ejs` 자동 부착).
|
|
143
|
+
|
|
144
|
+
### 1-5. 예약 키 (자동 주입) — ⚠️ 키마다 동작이 다르다
|
|
145
|
+
|
|
146
|
+
프레임워크가 렌더 시 채우는 키가 있다. **두 부류로 나뉜다**:
|
|
147
|
+
|
|
148
|
+
| 키 | 누가 이김 | 의미 |
|
|
149
|
+
|---|---|---|
|
|
150
|
+
| `t`, `lang` | **사용자 data 우선** | i18n 번역 함수·현재 언어. data 에 안 주면 요청값 자동 주입, 주면 그게 우선([template.js:276](../../node_modules/mega-framework/src/core/template.js#L276)) |
|
|
151
|
+
| `settings`, `cache`, `filename` | **프레임워크 우선(덮어씀)** | ejs-mate 내부용. 사용자 data 의 동명 키는 무시됨([template.js:171](../../node_modules/mega-framework/src/core/template.js#L171)) |
|
|
152
|
+
|
|
153
|
+
즉 `settings`/`cache`/`filename` 은 **사용자 데이터 키 이름으로 쓰면 안 된다**(조용히 덮어써짐). `t`/`lang`
|
|
154
|
+
은 의도적으로 override 를 허용한다(특수 상황에서 번역기·언어를 갈아끼우라고).
|
|
155
|
+
|
|
156
|
+
### 1-6. 경로 탐색(path traversal) 차단
|
|
157
|
+
|
|
158
|
+
동적(사용자 입력) view 이름이 임의 파일을 읽는 LFI 를 막는다. view/layout 이름을 뷰 루트 기준으로 resolve
|
|
159
|
+
한 뒤 그 경로가 **뷰 루트 내부**가 아니면 400(`template.invalid_view`)으로 거부한다
|
|
160
|
+
([template.js:115](../../node_modules/mega-framework/src/core/template.js#L115)).
|
|
161
|
+
|
|
162
|
+
---
|
|
163
|
+
|
|
164
|
+
## 2. i18n (i18next, ADR-037/038/039/164)
|
|
165
|
+
|
|
166
|
+
### 2-1. locale 파일 구조 — scope 하위 디렉터리
|
|
167
|
+
|
|
168
|
+
`<localesDir>/<scope>/<lng>.json` 구조다([i18n.js:155](../../node_modules/mega-framework/src/core/i18n.js#L155)). scope 는 **server / client**
|
|
169
|
+
2종 고정(ADR-039).
|
|
170
|
+
|
|
171
|
+
```
|
|
172
|
+
apps/main/locales/
|
|
173
|
+
├── server/
|
|
174
|
+
│ ├── ko.json ← 에러·검증 메시지 + 뷰에서 쓰는 t() 키
|
|
175
|
+
│ └── en.json
|
|
176
|
+
└── client/
|
|
177
|
+
├── ko.json ← SPA·브라우저로 내려보낼 UI 문자열
|
|
178
|
+
└── en.json
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
```jsonc
|
|
182
|
+
// apps/main/locales/server/ko.json (발췌)
|
|
183
|
+
{
|
|
184
|
+
"nav_home": "홈",
|
|
185
|
+
"upload_title": "파일 업로드",
|
|
186
|
+
"upload": { "too_large": "파일 크기가 허용 한도를 초과했습니다." }
|
|
187
|
+
}
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
- **server scope** = 기본 namespace. 뷰의 `<%= t('upload_title') %>` 와 `ctx.t()` 가 자동으로 이 scope 를 본다.
|
|
191
|
+
- **client scope** = `GET /i18n/translations` 로만 노출된다. 서버 전용 키가 SPA 번들로 새지 않게 분리(ADR-039).
|
|
192
|
+
|
|
193
|
+
### 2-2. 언어 결정 = 쿠키만 (ADR-038)
|
|
194
|
+
|
|
195
|
+
`Accept-Language` 헤더·query 는 **안 본다**. 언어는 오직 쿠키 `mega.lang` 으로 결정된다. 쿠키가 없거나
|
|
196
|
+
`available` 에 없는 값이면 `default` 로 폴백한다([i18n.js:370](../../node_modules/mega-framework/src/core/i18n.js#L370)).
|
|
197
|
+
|
|
198
|
+
요청마다 `req.lang`, `req.t`, `req.setLocale(lng)`, `req.translations(scope)` 가 부착된다
|
|
199
|
+
([i18n.js:487](../../node_modules/mega-framework/src/core/i18n.js#L487)). 핸들러에선 `ctx.lang` / `ctx.t` 로 쓴다.
|
|
200
|
+
|
|
201
|
+
### 2-3. 언어 토글 = JS 로 쿠키 굽고 reload
|
|
202
|
+
|
|
203
|
+
서버에 query/header 를 보내는 게 아니라, 클라가 쿠키를 직접 굽고 새로고침한다.
|
|
204
|
+
|
|
205
|
+
```ejs
|
|
206
|
+
<%# layouts/main.ejs — 언어 드롭다운 %>
|
|
207
|
+
<button class="dropdown-toggle"><%= lang === 'ko' ? '한국어' : 'English' %></button>
|
|
208
|
+
<ul class="dropdown-menu">
|
|
209
|
+
<li><a href="#" data-lang="ko">한국어</a></li>
|
|
210
|
+
<li><a href="#" data-lang="en">English</a></li>
|
|
211
|
+
</ul>
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
```js
|
|
215
|
+
// sample/crud/apps/main/public/js/app.js (발췌)
|
|
216
|
+
document.querySelectorAll('[data-lang]').forEach(function (el) {
|
|
217
|
+
el.addEventListener('click', function (e) {
|
|
218
|
+
e.preventDefault()
|
|
219
|
+
var lang = el.getAttribute('data-lang')
|
|
220
|
+
document.cookie = 'mega.lang=' + encodeURIComponent(lang) + '; path=/; max-age=31536000; samesite=lax'
|
|
221
|
+
location.reload()
|
|
222
|
+
})
|
|
223
|
+
})
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
> 서버 쪽 `req.setLocale(lng)` 도 있다(쿠키 발급 + 현재 요청 즉시 반영, `available` 외면 400). 명시적
|
|
227
|
+
> 언어 변경 API 가 필요할 때 쓴다([i18n.js:496](../../node_modules/mega-framework/src/core/i18n.js#L496)).
|
|
228
|
+
|
|
229
|
+
### 2-4. saveMissing — dev 자동 키 생성 (ADR-164)
|
|
230
|
+
|
|
231
|
+
`NODE_ENV==='development'` 에서만 동작한다. 코드가 locale 에 없는 키를 부르면:
|
|
232
|
+
|
|
233
|
+
1. 그 키를 **설정된 모든 available 언어** 파일(예 ko·en 둘 다)에 자동 기입한다 — 키가 전 언어에 있어야 다음
|
|
234
|
+
요청에서 재트리거되지 않는다(재오염 루프 차단, ADR-164).
|
|
235
|
+
2. 기입 값은 `defaultValue`(있으면), 없으면 **키 이름 그대로**(모든 언어 동일 → 개발자가 이후 번역).
|
|
236
|
+
3. 500ms 디바운스 → 기존 키 보존 deep-merge → tmp 작성 → rename(원자적 쓰기)
|
|
237
|
+
([i18n.js:290](../../node_modules/mega-framework/src/core/i18n.js#L290)).
|
|
238
|
+
|
|
239
|
+
```ejs
|
|
240
|
+
<%# defaultValue 를 두 번째 인자로 — i18n 미설정 앱에서도 깨지지 않는다 %>
|
|
241
|
+
<%= t('user.greeting', '안녕하세요') %>
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
> **영어값이 ko 에 박히는 사고 차단**: init 시 `reconcileLocaleKeys` 가 한 언어에만 있는 키를 나머지 언어에
|
|
245
|
+
> *fallback 언어 값 우선*으로 채운다([i18n.js:236](../../node_modules/mega-framework/src/core/i18n.js#L236)). i18next 가 fallback 으로 찾아
|
|
246
|
+
> `missingKeyHandler` 가 안 터지는 공백을 메운다.
|
|
247
|
+
|
|
248
|
+
> **production/test/CI 에선 off** — 추적된 locale 파일을 디스크에 기입하지 않는다(테스트 오염 방지). sample
|
|
249
|
+
> 의 `dev` 스크립트는 `NODE_ENV=development`, `start` 는 `production` 이라 정확히 맞물린다.
|
|
250
|
+
|
|
251
|
+
---
|
|
252
|
+
|
|
253
|
+
## 3. Static (ADR-071/139)
|
|
254
|
+
|
|
255
|
+
`@fastify/static@9` 로 `${prefix}/<파일>`(prefix 디폴트 `/static`) 경로에 디스크 파일을 스트리밍 서빙한다.
|
|
256
|
+
**`enabled:true` 일 때만** 등록된다([static-assets.js:61](../../node_modules/mega-framework/src/core/static-assets.js#L61)).
|
|
257
|
+
|
|
258
|
+
```js
|
|
259
|
+
staticAssets: {
|
|
260
|
+
enabled: true,
|
|
261
|
+
dir: 'apps/main/public', // apps/main/public/css/app.css → /static/css/app.css
|
|
262
|
+
prefix: '/static',
|
|
263
|
+
}
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
```ejs
|
|
267
|
+
<%# layouts/main.ejs — /static/... 로 참조 %>
|
|
268
|
+
<link rel="stylesheet" href="/static/vendor/bootstrap/bootstrap.min.css" />
|
|
269
|
+
<link rel="stylesheet" href="/static/css/app.css" />
|
|
270
|
+
<script src="/static/js/app.js"></script>
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
보안 기본값:
|
|
274
|
+
|
|
275
|
+
- **dotfiles 차단(디폴트)**: `.git`·`.env` 같은 점파일은 404 로 차단된다(`dotfiles:'ignore'`, 존재까지 은닉).
|
|
276
|
+
`staticAssets.dotfiles:true` 옵트인 시만 서빙([static-assets.js:69](../../node_modules/mega-framework/src/core/static-assets.js#L69)).
|
|
277
|
+
- **path traversal**: `@fastify/static@9.1.3+` 가 인코딩된 경로 구분자·디렉터리 listing 우회를 내부 차단.
|
|
278
|
+
- **dir 누락/미존재**: 프로덕션은 **부팅 throw**(잘못된 배포 즉시 중단), dev 는 **warn + skip**(잘못된 dir
|
|
279
|
+
하나로 앱 전체가 죽지 않게, ADR-071).
|
|
280
|
+
|
|
281
|
+
> **vendored Bootstrap 5**(ADR-151): `public/vendor/bootstrap/bootstrap.min.{css,js}` 를 git 에 커밋해 둔다 —
|
|
282
|
+
> CDN 의존 없이 오프라인에서도 자기완결로 뜬다(§5).
|
|
283
|
+
|
|
284
|
+
---
|
|
285
|
+
|
|
286
|
+
## 4. Multipart (@fastify/multipart)
|
|
287
|
+
|
|
288
|
+
`upload` 키가 있으면 `@fastify/multipart@10` 파서 + MIME 게이트 + 안전 저장이 배선된다. 없으면 multipart
|
|
289
|
+
요청은 Fastify 가 415(파서 없음)로 거부한다([multipart.js:142](../../node_modules/mega-framework/src/core/multipart.js#L142)).
|
|
290
|
+
|
|
291
|
+
```js
|
|
292
|
+
upload: {
|
|
293
|
+
maxFileSize: 5 * 1024 * 1024, // 디폴트 10MB
|
|
294
|
+
maxFiles: 3, // 개수 한도(초과 413)
|
|
295
|
+
allowedMimeTypes: ['image/*', 'application/pdf', 'text/plain'], // 빈 배열=전부 허용
|
|
296
|
+
}
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
한도·게이트([multipart.js:99](../../node_modules/mega-framework/src/core/multipart.js#L99), [multipart.js:148](../../node_modules/mega-framework/src/core/multipart.js#L148)):
|
|
300
|
+
|
|
301
|
+
- **크기 초과** → 플러그인이 413 throw(절단 후 silent 통과 없이 명시 실패).
|
|
302
|
+
- **개수 초과** → 413.
|
|
303
|
+
- **MIME 화이트리스트** → 비허용이면 415(`MegaUnsupportedMediaTypeError`). 정확 매치 + `image/*` 와일드카드.
|
|
304
|
+
|
|
305
|
+
### 4-1. 저장 — `req.saveUploads(destDir)`
|
|
306
|
+
|
|
307
|
+
핸들러에서 `req.saveUploads()` 를 부르면 파일명 살균(경로 탐색 차단) + 디렉터리 내부 재검증 + 스트리밍
|
|
308
|
+
저장 + 트레이싱 span/메트릭까지 한 번에 처리한다([multipart.js:225](../../node_modules/mega-framework/src/core/multipart.js#L225)).
|
|
309
|
+
|
|
310
|
+
```js
|
|
311
|
+
// sample/crud/apps/main/controllers/upload-controller.js (발췌)
|
|
312
|
+
static async upload(req, _reply, ctx) {
|
|
313
|
+
// MIME 비허용(415)·크기 초과(413)면 throw → 글로벌 핸들러가 에러 envelope 로 응답
|
|
314
|
+
const saved = await req.saveUploads(uploadDir())
|
|
315
|
+
const files = saved.map((f) => ({
|
|
316
|
+
filename: f.filename,
|
|
317
|
+
bytes: f.bytes,
|
|
318
|
+
mimetype: f.mimetype,
|
|
319
|
+
path: relative(process.cwd(), f.savedAs), // 서버 절대경로 비노출
|
|
320
|
+
}))
|
|
321
|
+
await ctx.services.uploadDemo.record(files)
|
|
322
|
+
return { files }
|
|
323
|
+
}
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
> 단일/스트림 처리는 `req.file()` / `req.files()` 도 그대로 쓸 수 있다(둘 다 MIME 게이트가 감싸져 있다).
|
|
327
|
+
> 진짜 콘텐츠 검증이 필요하면 `toBuffer()` 후 매직바이트를 직접 본다(게이트는 선언 MIME 기준 —
|
|
328
|
+
> 스푸핑은 문서화된 한계, [multipart.js:26](../../node_modules/mega-framework/src/core/multipart.js#L26)).
|
|
329
|
+
|
|
330
|
+
### 4-2. 저장 위치 — `DEMO_UPLOAD_DIR`
|
|
331
|
+
|
|
332
|
+
저장 디렉터리는 `.env` 의 `DEMO_UPLOAD_DIR` 로 받는다(미설정 시 `var/uploads`). 상대경로는 프로젝트 루트
|
|
333
|
+
기준([upload-controller.js:24](../../apps/main/controllers/upload-controller.js#L24)).
|
|
334
|
+
|
|
335
|
+
### 4-3. ⚠️ multipart 폼의 CSRF 토큰은 **헤더**로
|
|
336
|
+
|
|
337
|
+
`multipart/form-data` 는 CSRF 검증 대상(폼으로 분류, ADR-051)이다. 그런데 스트리밍 multipart body 는
|
|
338
|
+
preHandler 시점에 아직 파싱되지 않아 `_csrf` **바디 필드를 못 읽는다**. 그래서 토큰을 `csrf-token` **헤더**
|
|
339
|
+
로 보내야 한다. HTML 폼은 커스텀 헤더를 못 보내므로 `fetch` + `FormData` 로 제출한다.
|
|
340
|
+
|
|
341
|
+
```js
|
|
342
|
+
// sample/crud/apps/main/public/js/upload-demo.js (발췌)
|
|
343
|
+
var fd = new FormData()
|
|
344
|
+
for (var i = 0; i < fileInput.files.length; i++) fd.append('file', fileInput.files[i])
|
|
345
|
+
|
|
346
|
+
fetch('/demo/upload', {
|
|
347
|
+
method: 'POST',
|
|
348
|
+
headers: { 'csrf-token': csrf, accept: 'application/json' }, // ← 바디 필드 아님, 헤더
|
|
349
|
+
body: fd,
|
|
350
|
+
})
|
|
351
|
+
```
|
|
352
|
+
|
|
353
|
+
```ejs
|
|
354
|
+
<%# upload/index.ejs — 토큰을 data 속성으로 내려보냄 %>
|
|
355
|
+
<form id="up-form" data-csrf="<%= csrfToken %>">
|
|
356
|
+
<input type="file" name="file" multiple accept="image/*,application/pdf,text/plain" />
|
|
357
|
+
...
|
|
358
|
+
</form>
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
---
|
|
362
|
+
|
|
363
|
+
## 5. Bootstrap 5 디자인 (ADR-151)
|
|
364
|
+
|
|
365
|
+
- **vendored min 파일**: `public/vendor/bootstrap/bootstrap.min.css` + `bootstrap.bundle.min.js` 를 커밋한다.
|
|
366
|
+
CDN·빌드 단계 없이 자기완결로 뜬다(오프라인 OK).
|
|
367
|
+
- **CSS 변수 커스텀**: Sass 재컴파일 없이 Bootstrap 5.3 의 인스턴스 CSS 변수(`--bs-btn-*`, `--bs-link-*`)만
|
|
368
|
+
덮어써 브랜드 색을 입힌다([app.css](../../apps/main/public/css/app.css)).
|
|
369
|
+
|
|
370
|
+
```css
|
|
371
|
+
/* public/css/app.css (발췌) */
|
|
372
|
+
:root { --brand: #5b4bff; --brand-rgb: 91, 75, 255; }
|
|
373
|
+
.btn-primary {
|
|
374
|
+
--bs-btn-bg: var(--brand);
|
|
375
|
+
--bs-btn-hover-bg: var(--brand-dark);
|
|
376
|
+
}
|
|
377
|
+
```
|
|
378
|
+
|
|
379
|
+
### 다크모드 — `data-bs-theme` + localStorage
|
|
380
|
+
|
|
381
|
+
Bootstrap 5.3 의 `data-bs-theme="light|dark"` 속성으로 테마를 바꾼다. **FOUC(깜빡임) 방지**를 위해 저장된
|
|
382
|
+
테마를 페인트 *전*에 `<head>` 에서 동기 적용하고(`theme-init.js`), 토글 버튼은 `app.js` 에서 처리한다.
|
|
383
|
+
|
|
384
|
+
```js
|
|
385
|
+
// public/js/theme-init.js — <head> 에서 동기 로드, 페인트 전 적용
|
|
386
|
+
;(function () {
|
|
387
|
+
try {
|
|
388
|
+
var t = localStorage.getItem('mega.theme')
|
|
389
|
+
if (t) document.documentElement.setAttribute('data-bs-theme', t)
|
|
390
|
+
} catch (e) {
|
|
391
|
+
// localStorage 차단(사생활 모드)은 비치명적 — 기본 테마로 렌더
|
|
392
|
+
}
|
|
393
|
+
})()
|
|
394
|
+
```
|
|
395
|
+
|
|
396
|
+
```js
|
|
397
|
+
// public/js/app.js — 토글 버튼 → 속성 갱신 + localStorage 저장
|
|
398
|
+
function applyTheme(theme) {
|
|
399
|
+
document.documentElement.setAttribute('data-bs-theme', theme)
|
|
400
|
+
try { localStorage.setItem('mega.theme', theme) } catch (e) { /* 비치명적 */ }
|
|
401
|
+
}
|
|
402
|
+
```
|
|
403
|
+
|
|
404
|
+
---
|
|
405
|
+
|
|
406
|
+
## 6. CSP (ADR-153)
|
|
407
|
+
|
|
408
|
+
helmet 의 기본 CSP 는 `script-src 'self'` — **인라인 `<script>`·인라인 이벤트 핸들러를 차단한다**. 그래서
|
|
409
|
+
모든 클라이언트 동작을 외부 `.js` 파일로 뺀다(`theme-init.js`, `app.js`, `upload-demo.js`, …).
|
|
410
|
+
|
|
411
|
+
```ejs
|
|
412
|
+
<%# 인라인 스크립트 금지 → 외부 파일로 %>
|
|
413
|
+
<script src="/static/js/theme-init.js"></script> <%# O %>
|
|
414
|
+
<%# <script>initTheme()</script> ← CSP 차단, X %>
|
|
415
|
+
```
|
|
416
|
+
|
|
417
|
+
특수 지시문이 필요하면 앱 config 의 `helmet` 으로 override 한다. sample 은 WASM MegaSocket(/demo/ws)을 위해
|
|
418
|
+
`'wasm-unsafe-eval'` 만 추가하고 나머지(`useDefaults`)는 helmet 기본을 유지한다(ADR-153/158).
|
|
419
|
+
|
|
420
|
+
```js
|
|
421
|
+
// app.config.js (발췌)
|
|
422
|
+
helmet: {
|
|
423
|
+
contentSecurityPolicy: {
|
|
424
|
+
directives: { scriptSrc: ["'self'", "'wasm-unsafe-eval'"] },
|
|
425
|
+
},
|
|
426
|
+
}
|
|
427
|
+
```
|
|
428
|
+
|
|
429
|
+
---
|
|
430
|
+
|
|
431
|
+
## 7. 함정 (Gotchas)
|
|
432
|
+
|
|
433
|
+
1. **예약 키 충돌** — `settings`/`cache`/`filename` 은 프레임워크가 항상 덮어쓰므로 사용자 data 키 이름으로
|
|
434
|
+
쓰면 조용히 사라진다. `t`/`lang` 은 반대로 사용자 data 가 우선(의도적). 데이터 키는 다른 이름을 쓴다(§1-5).
|
|
435
|
+
2. **locale 은 scope 하위 디렉터리** — `locales/ko.json` 이 아니라 `locales/server/ko.json` ·
|
|
436
|
+
`locales/client/ko.json`. server 키만 `t()`/뷰에서 보이고, client 키는 `/i18n/translations` 로만 나간다(§2-1).
|
|
437
|
+
3. **언어 토글은 JS 쿠키 + reload** — 서버 query/header 로 바꾸려 하면 안 먹는다. 언어는 쿠키 `mega.lang`
|
|
438
|
+
으로만 결정된다(ADR-038, §2-2~2-3).
|
|
439
|
+
4. **saveMissing 은 dev 전용 + 디스크 기입** — `NODE_ENV==='development'` 에서만 켜지고, locale 파일을 실제로
|
|
440
|
+
수정해 git diff 노이즈를 낸다. production/test/CI 는 off(§2-4).
|
|
441
|
+
5. **multipart CSRF 는 헤더로** — 바디 `_csrf` 필드는 스트리밍 시점에 못 읽는다. `csrf-token` 헤더 + `fetch`/
|
|
442
|
+
`FormData` 로 보낸다(§4-3).
|
|
443
|
+
6. **CSP 가 인라인 script 차단** — `<script>...</script>` 인라인 코드·`onclick=` 핸들러는 동작 안 한다. 외부
|
|
444
|
+
`.js` 로 빼라(§6).
|
|
445
|
+
7. **ko/en parity 유지** — 한 언어에만 키가 있으면 다른 언어에서 영어값이 노출될 수 있다. dev 의
|
|
446
|
+
`reconcileLocaleKeys` 가 보정하지만, production 배포 전엔 두 언어 파일의 키 집합이 같은지 확인한다.
|
|
447
|
+
|
|
448
|
+
---
|
|
449
|
+
|
|
450
|
+
## 관련 ADR
|
|
451
|
+
|
|
452
|
+
- **011, 136** — EJS + ejs-mate 채택
|
|
453
|
+
- **037, 038, 039, 164** — i18next / 쿠키-only locale / server·client scope 분리 / saveMissing 전 언어 기입
|
|
454
|
+
- **071, 139** — 정적 자산 옵트인 / dotfiles·path traversal
|
|
455
|
+
- **133, 163** — multipart 업로드 / 저장 위치(var/uploads)
|
|
456
|
+
- **151** — Bootstrap 5 vendored + @fastify/formbody(폼 urlencoded 자동 파싱)
|
|
457
|
+
- **152** — .env 자동 로드
|
|
458
|
+
- **153, 158** — helmet CSP / WASM `wasm-unsafe-eval`
|
|
459
|
+
- **147** — 응답 envelope
|
|
460
|
+
- **140, 141** — Swagger·pino(관측)
|
|
461
|
+
</content>
|
|
462
|
+
</invoke>
|