mega-framework 0.1.0

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.
Files changed (322) hide show
  1. package/.env +127 -0
  2. package/.env.example +186 -0
  3. package/.prettierrc.json +8 -0
  4. package/CHANGELOG.md +259 -0
  5. package/LICENSE +21 -0
  6. package/README.md +153 -0
  7. package/bin/mega-ws-hub.js +15 -0
  8. package/bin/mega.js +38 -0
  9. package/docker-compose.yml +201 -0
  10. package/eslint.config.js +57 -0
  11. package/infra/otel-collector-config.yaml +43 -0
  12. package/jsconfig.json +18 -0
  13. package/package.json +121 -0
  14. package/sample/crud/.env +18 -0
  15. package/sample/crud/.env.example +50 -0
  16. package/sample/crud/README.md +85 -0
  17. package/sample/crud/apps/main/app.config.js +114 -0
  18. package/sample/crud/apps/main/channels/chat-bus.js +115 -0
  19. package/sample/crud/apps/main/channels/chat-channel.js +145 -0
  20. package/sample/crud/apps/main/controllers/auth-controller.js +144 -0
  21. package/sample/crud/apps/main/controllers/cron-controller.js +34 -0
  22. package/sample/crud/apps/main/controllers/guide-controller.js +37 -0
  23. package/sample/crud/apps/main/controllers/jobs-controller.js +43 -0
  24. package/sample/crud/apps/main/controllers/logs-controller.js +35 -0
  25. package/sample/crud/apps/main/controllers/metrics-controller.js +22 -0
  26. package/sample/crud/apps/main/controllers/note-controller.js +116 -0
  27. package/sample/crud/apps/main/controllers/perf-controller.js +38 -0
  28. package/sample/crud/apps/main/controllers/redis-controller.js +36 -0
  29. package/sample/crud/apps/main/controllers/tracing-controller.js +43 -0
  30. package/sample/crud/apps/main/controllers/upload-controller.js +98 -0
  31. package/sample/crud/apps/main/controllers/user-controller.js +34 -0
  32. package/sample/crud/apps/main/controllers/web-controller.js +137 -0
  33. package/sample/crud/apps/main/controllers/worker-controller.js +57 -0
  34. package/sample/crud/apps/main/controllers/ws-controller.js +29 -0
  35. package/sample/crud/apps/main/jobs/email-job.js +72 -0
  36. package/sample/crud/apps/main/locales/client/en.json +3 -0
  37. package/sample/crud/apps/main/locales/client/ko.json +3 -0
  38. package/sample/crud/apps/main/locales/server/en.json +316 -0
  39. package/sample/crud/apps/main/locales/server/ko.json +316 -0
  40. package/sample/crud/apps/main/middleware/web-auth.js +40 -0
  41. package/sample/crud/apps/main/middleware/ws-auth.js +48 -0
  42. package/sample/crud/apps/main/migrations/20260606000001-create-users.js +27 -0
  43. package/sample/crud/apps/main/migrations/20260606000002-add-auth-to-users.js +30 -0
  44. package/sample/crud/apps/main/models/note.js +71 -0
  45. package/sample/crud/apps/main/models/user.js +86 -0
  46. package/sample/crud/apps/main/public/css/app.css +101 -0
  47. package/sample/crud/apps/main/public/css/guide.css +137 -0
  48. package/sample/crud/apps/main/public/js/app.js +54 -0
  49. package/sample/crud/apps/main/public/js/perf.js +129 -0
  50. package/sample/crud/apps/main/public/js/theme-init.js +12 -0
  51. package/sample/crud/apps/main/public/js/upload-demo.js +63 -0
  52. package/sample/crud/apps/main/public/js/worker-demo.js +92 -0
  53. package/sample/crud/apps/main/public/js/ws-chat.js +161 -0
  54. package/sample/crud/apps/main/public/vendor/bootstrap/bootstrap.bundle.min.js +7 -0
  55. package/sample/crud/apps/main/public/vendor/bootstrap/bootstrap.min.css +6 -0
  56. package/sample/crud/apps/main/public/vendor/highlight/github-dark.css +109 -0
  57. package/sample/crud/apps/main/public/vendor/highlight/github.css +118 -0
  58. package/sample/crud/apps/main/public/vendor/mega-client-wasm/README.md +19 -0
  59. package/sample/crud/apps/main/public/vendor/mega-client-wasm/mega_client_wasm.d.ts +196 -0
  60. package/sample/crud/apps/main/public/vendor/mega-client-wasm/mega_client_wasm.js +1187 -0
  61. package/sample/crud/apps/main/public/vendor/mega-client-wasm/mega_client_wasm_bg.wasm +0 -0
  62. package/sample/crud/apps/main/routes/auth.js +15 -0
  63. package/sample/crud/apps/main/routes/cron.js +14 -0
  64. package/sample/crud/apps/main/routes/guide.js +25 -0
  65. package/sample/crud/apps/main/routes/jobs.js +14 -0
  66. package/sample/crud/apps/main/routes/logs.js +28 -0
  67. package/sample/crud/apps/main/routes/metrics.js +13 -0
  68. package/sample/crud/apps/main/routes/notes.js +19 -0
  69. package/sample/crud/apps/main/routes/perf.js +47 -0
  70. package/sample/crud/apps/main/routes/redis.js +14 -0
  71. package/sample/crud/apps/main/routes/tracing.js +14 -0
  72. package/sample/crud/apps/main/routes/upload.js +16 -0
  73. package/sample/crud/apps/main/routes/users.js +54 -0
  74. package/sample/crud/apps/main/routes/web.js +23 -0
  75. package/sample/crud/apps/main/routes/worker.js +15 -0
  76. package/sample/crud/apps/main/routes/ws.js +30 -0
  77. package/sample/crud/apps/main/schedules/cron-counter-schedule.js +30 -0
  78. package/sample/crud/apps/main/services/auth-service.js +74 -0
  79. package/sample/crud/apps/main/services/cron-demo-service.js +66 -0
  80. package/sample/crud/apps/main/services/guide-service.js +145 -0
  81. package/sample/crud/apps/main/services/jobs-demo-service.js +83 -0
  82. package/sample/crud/apps/main/services/logs-demo-service.js +59 -0
  83. package/sample/crud/apps/main/services/metrics-demo-service.js +144 -0
  84. package/sample/crud/apps/main/services/note-service.js +75 -0
  85. package/sample/crud/apps/main/services/perf-service.js +302 -0
  86. package/sample/crud/apps/main/services/redis-demo-service.js +75 -0
  87. package/sample/crud/apps/main/services/tracing-demo-service.js +69 -0
  88. package/sample/crud/apps/main/services/upload-demo-service.js +48 -0
  89. package/sample/crud/apps/main/services/user-service.js +65 -0
  90. package/sample/crud/apps/main/views/auth/login.ejs +57 -0
  91. package/sample/crud/apps/main/views/auth/register.ejs +71 -0
  92. package/sample/crud/apps/main/views/cron/index.ejs +92 -0
  93. package/sample/crud/apps/main/views/guide/index.ejs +24 -0
  94. package/sample/crud/apps/main/views/guide/page.ejs +64 -0
  95. package/sample/crud/apps/main/views/home.ejs +82 -0
  96. package/sample/crud/apps/main/views/jobs/index.ejs +113 -0
  97. package/sample/crud/apps/main/views/layouts/main.ejs +112 -0
  98. package/sample/crud/apps/main/views/logs/index.ejs +80 -0
  99. package/sample/crud/apps/main/views/metrics/index.ejs +123 -0
  100. package/sample/crud/apps/main/views/notes/edit.ejs +45 -0
  101. package/sample/crud/apps/main/views/notes/list.ejs +74 -0
  102. package/sample/crud/apps/main/views/notes/new.ejs +45 -0
  103. package/sample/crud/apps/main/views/perf/index.ejs +90 -0
  104. package/sample/crud/apps/main/views/redis/index.ejs +65 -0
  105. package/sample/crud/apps/main/views/tracing/index.ejs +106 -0
  106. package/sample/crud/apps/main/views/upload/index.ejs +79 -0
  107. package/sample/crud/apps/main/views/users/edit.ejs +48 -0
  108. package/sample/crud/apps/main/views/users/list.ejs +81 -0
  109. package/sample/crud/apps/main/views/users/new.ejs +48 -0
  110. package/sample/crud/apps/main/views/worker/index.ejs +70 -0
  111. package/sample/crud/apps/main/views/ws/index.ejs +62 -0
  112. package/sample/crud/apps/main/workers/hash-worker.js +17 -0
  113. package/sample/crud/apps/main/workers/hash.task.js +22 -0
  114. package/sample/crud/ecosystem.config.cjs +9 -0
  115. package/sample/crud/mega.config.js +105 -0
  116. package/sample/crud/package-lock.json +5665 -0
  117. package/sample/crud/package.json +28 -0
  118. package/sample/crud/test/apps/main/auth-flow.integration.test.js +177 -0
  119. package/sample/crud/test/apps/main/auth-service.test.js +93 -0
  120. package/sample/crud/test/apps/main/chat-bus.test.js +101 -0
  121. package/sample/crud/test/apps/main/chat-channel.test.js +144 -0
  122. package/sample/crud/test/apps/main/cron-demo-service.test.js +93 -0
  123. package/sample/crud/test/apps/main/demo-flow.integration.test.js +386 -0
  124. package/sample/crud/test/apps/main/email-job.test.js +76 -0
  125. package/sample/crud/test/apps/main/guide-service.test.js +68 -0
  126. package/sample/crud/test/apps/main/hash-task.test.js +30 -0
  127. package/sample/crud/test/apps/main/jobs-demo-service.test.js +88 -0
  128. package/sample/crud/test/apps/main/logs-demo-service.test.js +85 -0
  129. package/sample/crud/test/apps/main/metrics-demo-service.test.js +90 -0
  130. package/sample/crud/test/apps/main/note-service.test.js +68 -0
  131. package/sample/crud/test/apps/main/perf-service.test.js +121 -0
  132. package/sample/crud/test/apps/main/perf.integration.test.js +202 -0
  133. package/sample/crud/test/apps/main/redis-demo-service.test.js +98 -0
  134. package/sample/crud/test/apps/main/tracing-demo-service.test.js +90 -0
  135. package/sample/crud/test/apps/main/upload-demo-service.test.js +61 -0
  136. package/sample/crud/test/apps/main/user-service.test.js +65 -0
  137. package/sample/crud/test/apps/main/ws-chat.integration.test.js +232 -0
  138. package/sample/crud/vitest.config.js +8 -0
  139. package/sample/crud/yarn.lock +2142 -0
  140. package/sample/simple/.env.example +15 -0
  141. package/sample/simple/README.md +52 -0
  142. package/sample/simple/apps/main/app.config.js +35 -0
  143. package/sample/simple/apps/main/controllers/pages-controller.js +22 -0
  144. package/sample/simple/apps/main/locales/client/en.json +3 -0
  145. package/sample/simple/apps/main/locales/client/ko.json +3 -0
  146. package/sample/simple/apps/main/locales/server/en.json +23 -0
  147. package/sample/simple/apps/main/locales/server/ko.json +23 -0
  148. package/sample/simple/apps/main/public/css/app.css +101 -0
  149. package/sample/simple/apps/main/public/hello.txt +1 -0
  150. package/sample/simple/apps/main/public/js/app.js +54 -0
  151. package/sample/simple/apps/main/public/js/theme-init.js +12 -0
  152. package/sample/simple/apps/main/public/vendor/bootstrap/bootstrap.bundle.min.js +7 -0
  153. package/sample/simple/apps/main/public/vendor/bootstrap/bootstrap.min.css +6 -0
  154. package/sample/simple/apps/main/routes/index.js +9 -0
  155. package/sample/simple/apps/main/routes/pages.js +12 -0
  156. package/sample/simple/apps/main/views/index.ejs +56 -0
  157. package/sample/simple/apps/main/views/layouts/main.ejs +74 -0
  158. package/sample/simple/ecosystem.config.cjs +10 -0
  159. package/sample/simple/mega.config.js +27 -0
  160. package/sample/simple/package-lock.json +1851 -0
  161. package/sample/simple/package.json +25 -0
  162. package/sample/simple/test/apps/main/index.test.js +13 -0
  163. package/sample/simple/vitest.config.js +8 -0
  164. package/src/adapters/adapter-manager.js +305 -0
  165. package/src/adapters/adapter-options.js +208 -0
  166. package/src/adapters/file-adapter.js +350 -0
  167. package/src/adapters/file-session-adapter.js +363 -0
  168. package/src/adapters/index.js +38 -0
  169. package/src/adapters/maria-adapter.js +425 -0
  170. package/src/adapters/mega-adapter.js +511 -0
  171. package/src/adapters/mega-bus-adapter.js +81 -0
  172. package/src/adapters/mega-cache-adapter.js +94 -0
  173. package/src/adapters/mega-db-adapter.js +72 -0
  174. package/src/adapters/mega-lock-adapter.js +118 -0
  175. package/src/adapters/mega-log-sink-adapter.js +46 -0
  176. package/src/adapters/mega-session-adapter.js +72 -0
  177. package/src/adapters/mongo-adapter.js +396 -0
  178. package/src/adapters/nats-adapter.js +370 -0
  179. package/src/adapters/postgres-adapter.js +341 -0
  180. package/src/adapters/redis-adapter.js +331 -0
  181. package/src/adapters/redis-session-adapter.js +261 -0
  182. package/src/adapters/redlock-adapter.js +385 -0
  183. package/src/adapters/registry.js +157 -0
  184. package/src/adapters/sqlite-adapter.js +309 -0
  185. package/src/auth/index.js +103 -0
  186. package/src/cli/commands/console-cmd.js +56 -0
  187. package/src/cli/commands/new.js +101 -0
  188. package/src/cli/commands/routes.js +107 -0
  189. package/src/cli/commands/scaffold.js +120 -0
  190. package/src/cli/commands/test-cmd.js +45 -0
  191. package/src/cli/generators/index.js +368 -0
  192. package/src/cli/index.js +472 -0
  193. package/src/cli/template-engine.js +72 -0
  194. package/src/cli/ws-hub.js +582 -0
  195. package/src/core/ajv-mapper.js +80 -0
  196. package/src/core/boot.js +323 -0
  197. package/src/core/cluster-metrics.js +278 -0
  198. package/src/core/config-loader.js +115 -0
  199. package/src/core/config-validator.js +322 -0
  200. package/src/core/ctx-builder.js +253 -0
  201. package/src/core/envelope.js +88 -0
  202. package/src/core/error-mapper.js +116 -0
  203. package/src/core/formbody.js +69 -0
  204. package/src/core/hub-link.js +552 -0
  205. package/src/core/i18n.js +525 -0
  206. package/src/core/index.js +63 -0
  207. package/src/core/mega-app.js +1138 -0
  208. package/src/core/mega-cluster.js +232 -0
  209. package/src/core/mega-server.js +176 -0
  210. package/src/core/mega-service.js +41 -0
  211. package/src/core/migration-runner.js +196 -0
  212. package/src/core/multipart.js +282 -0
  213. package/src/core/openapi.js +114 -0
  214. package/src/core/router.js +388 -0
  215. package/src/core/routes-loader.js +57 -0
  216. package/src/core/scope-registry.js +53 -0
  217. package/src/core/security.js +275 -0
  218. package/src/core/services-loader.js +98 -0
  219. package/src/core/session-cleanup-schedule.js +57 -0
  220. package/src/core/session-store.js +55 -0
  221. package/src/core/session.js +414 -0
  222. package/src/core/static-assets.js +126 -0
  223. package/src/core/template.js +294 -0
  224. package/src/core/workers-manager.js +193 -0
  225. package/src/core/ws-compression.js +112 -0
  226. package/src/core/ws-controller.js +109 -0
  227. package/src/core/ws-message.js +176 -0
  228. package/src/core/ws-upgrade.js +445 -0
  229. package/src/errors/config-error.js +16 -0
  230. package/src/errors/http-errors.js +130 -0
  231. package/src/errors/index.js +19 -0
  232. package/src/errors/mega-error.js +34 -0
  233. package/src/eslint-plugin/index.js +15 -0
  234. package/src/eslint-plugin/no-direct-model-import.js +113 -0
  235. package/src/index.js +131 -0
  236. package/src/lib/asp/config.js +83 -0
  237. package/src/lib/asp/crypto.js +145 -0
  238. package/src/lib/asp/errors.js +49 -0
  239. package/src/lib/asp/nonce-cache.js +94 -0
  240. package/src/lib/asp/plugin.js +263 -0
  241. package/src/lib/asp/ws-terminator.js +101 -0
  242. package/src/lib/env-mapper.js +222 -0
  243. package/src/lib/hub-protocol.js +322 -0
  244. package/src/lib/index.js +42 -0
  245. package/src/lib/logger/telegram-core.js +150 -0
  246. package/src/lib/logger/telegram-transport.js +126 -0
  247. package/src/lib/mega-brute-force.js +225 -0
  248. package/src/lib/mega-circuit-breaker.js +412 -0
  249. package/src/lib/mega-cron.js +169 -0
  250. package/src/lib/mega-hash.js +179 -0
  251. package/src/lib/mega-health.js +91 -0
  252. package/src/lib/mega-job-queue.js +600 -0
  253. package/src/lib/mega-job-worker.js +295 -0
  254. package/src/lib/mega-job.js +140 -0
  255. package/src/lib/mega-logger.js +128 -0
  256. package/src/lib/mega-metrics.js +661 -0
  257. package/src/lib/mega-plugin.js +650 -0
  258. package/src/lib/mega-retry.js +95 -0
  259. package/src/lib/mega-schedule.js +507 -0
  260. package/src/lib/mega-shutdown.js +176 -0
  261. package/src/lib/mega-tracing.js +715 -0
  262. package/src/lib/mega-worker.js +653 -0
  263. package/src/lib/worker-runner/process-entry.js +30 -0
  264. package/src/lib/worker-runner/task-dispatch.js +72 -0
  265. package/src/lib/worker-runner/thread-entry.js +26 -0
  266. package/src/models/index.js +7 -0
  267. package/src/models/mega-model.js +151 -0
  268. package/src/test/index.js +288 -0
  269. package/templates/adapter/code.tpl +40 -0
  270. package/templates/adapter/test.tpl +13 -0
  271. package/templates/app/app.config.tpl +10 -0
  272. package/templates/app/route.tpl +10 -0
  273. package/templates/app/test.tpl +13 -0
  274. package/templates/channel/code.tpl +38 -0
  275. package/templates/channel/test.tpl +19 -0
  276. package/templates/controller/code.tpl +16 -0
  277. package/templates/controller/route.tpl +9 -0
  278. package/templates/controller/test.tpl +14 -0
  279. package/templates/job/code.tpl +23 -0
  280. package/templates/job/test.tpl +17 -0
  281. package/templates/locale/code.tpl +3 -0
  282. package/templates/locale/test.tpl +13 -0
  283. package/templates/middleware/code.tpl +13 -0
  284. package/templates/middleware/test.tpl +11 -0
  285. package/templates/migration/code.tpl +20 -0
  286. package/templates/migration/test.tpl +14 -0
  287. package/templates/model/code.tpl +21 -0
  288. package/templates/model/test.tpl +29 -0
  289. package/templates/project/app.config.tpl +8 -0
  290. package/templates/project/app.config.views.tpl +37 -0
  291. package/templates/project/ecosystem.config.tpl +10 -0
  292. package/templates/project/env.tpl +12 -0
  293. package/templates/project/gitignore.tpl +8 -0
  294. package/templates/project/locales/client/en.json.tpl +3 -0
  295. package/templates/project/locales/client/ko.json.tpl +3 -0
  296. package/templates/project/locales/server/en.json.tpl +17 -0
  297. package/templates/project/locales/server/ko.json.tpl +17 -0
  298. package/templates/project/mega.config.tpl +11 -0
  299. package/templates/project/package.tpl +25 -0
  300. package/templates/project/public/css/app.css +101 -0
  301. package/templates/project/public/js/app.js +54 -0
  302. package/templates/project/public/js/theme-init.js +12 -0
  303. package/templates/project/public/vendor/bootstrap/bootstrap.bundle.min.js +7 -0
  304. package/templates/project/public/vendor/bootstrap/bootstrap.min.css +6 -0
  305. package/templates/project/readme.tpl +48 -0
  306. package/templates/project/route.test.tpl +13 -0
  307. package/templates/project/route.test.views.tpl +15 -0
  308. package/templates/project/route.tpl +10 -0
  309. package/templates/project/route.views.tpl +10 -0
  310. package/templates/project/views/index.ejs.tpl +58 -0
  311. package/templates/project/views/layout.ejs.tpl +73 -0
  312. package/templates/project/vitest.config.tpl +8 -0
  313. package/templates/route/code.tpl +11 -0
  314. package/templates/route/test.tpl +26 -0
  315. package/templates/schedule/code.tpl +19 -0
  316. package/templates/schedule/test.tpl +17 -0
  317. package/templates/service/code.tpl +18 -0
  318. package/templates/service/test.tpl +17 -0
  319. package/templates/worker/code.tpl +14 -0
  320. package/templates/worker/task.tpl +13 -0
  321. package/templates/worker/test.tpl +18 -0
  322. package/vitest.config.js +33 -0
@@ -0,0 +1,144 @@
1
+ // @ts-check
2
+ /**
3
+ * AuthController — 로그인·로그아웃·회원가입 HTTP 컨트롤러(ADR-074: 베이스 없음, 정적 메서드만, ADR-155).
4
+ *
5
+ * 인증 검증은 `ctx.services.auth`(AuthService)에 위임하고, 여기서는 **세션·brute-force·뷰** 를 다룬다:
6
+ * - brute-force: `ctx.bruteForce`(redis, ADR-049/130) — subject 는 `IP:email`(계정 잠금 DoS 회피, I-1).
7
+ * - 세션: 로그인 성공 시 `session.regenerate()`(세션 고정 공격 방지) 후 userId/userName 을 심는다.
8
+ * - 뷰: 실패는 폼을 에러와 함께 다시 렌더(JSON envelope 대신, web-controller 와 같은 패턴).
9
+ *
10
+ * CSRF(디폴트 ON)는 폼 `_csrf` 토큰을 요구하므로 폼 뷰에 `reply.generateCsrf()` 토큰을 심는다.
11
+ */
12
+ import { MegaValidationError, MegaConflictError } from 'mega-framework/errors'
13
+ import { currentUser } from '../middleware/web-auth.js'
14
+
15
+ /** 로그인 성공 후 도착지(관리 UI). */
16
+ const HOME_REDIRECT = '/admin/users'
17
+
18
+ /** 로그인 폼 알림(?notice=) → 로케일 키 화이트리스트(임의 t() 조회 방지). */
19
+ const NOTICE_KEYS = new Set(['logged_out', 'registered'])
20
+
21
+ /**
22
+ * 로그인 성공 시 세션을 새 sid 로 재발급(고정 공격 방지)하고 신원을 심는다.
23
+ * @param {any} req @param {{ id: any, name: string }} user @returns {Promise<void>}
24
+ */
25
+ async function establishSession(req, user) {
26
+ req.session.userId = user.id
27
+ req.session.userName = user.name
28
+ await req.session.regenerate()
29
+ }
30
+
31
+ export class AuthController {
32
+ /** GET /auth/login — 로그인 폼. 이미 로그인했으면 관리 UI 로. @param {any} req @param {any} reply @param {any} ctx */
33
+ static async loginForm(req, reply, ctx) {
34
+ if (currentUser(req)) return reply.redirect(HOME_REDIRECT)
35
+ const key = String(req.query?.notice ?? '')
36
+ const notice = NOTICE_KEYS.has(key) ? ctx.t(`auth_notice_${key}`) : null
37
+ return ctx.render('auth/login', {
38
+ title: ctx.t('login_title', '로그인'),
39
+ values: {},
40
+ notice,
41
+ currentUser: null,
42
+ csrfToken: reply.generateCsrf(),
43
+ })
44
+ }
45
+
46
+ /** POST /auth/login — 인증 → 세션 생성 → 관리 UI. 잠김/실패 시 폼 재렌더. @param {any} req @param {any} reply @param {any} ctx */
47
+ static async login(req, reply, ctx) {
48
+ const email = typeof req.body?.email === 'string' ? req.body.email.trim().toLowerCase() : ''
49
+ const password = typeof req.body?.password === 'string' ? req.body.password : ''
50
+ // subject = IP:email — email 단독 잠금은 피해자 계정을 공격자가 잠그는 DoS 가 가능(I-1).
51
+ const subject = `${req.ip}:${email}`
52
+ const bf = ctx.bruteForce
53
+
54
+ const status = await bf.check(subject)
55
+ if (status.isLocked) {
56
+ return AuthController.#renderLogin(reply, ctx, { email }, ctx.t('login_locked', '로그인 시도가 너무 많습니다. 잠시 후 다시 시도하세요.'), 423)
57
+ }
58
+
59
+ const user = await ctx.services.auth.authenticate({ email, password })
60
+ if (!user) {
61
+ const after = await bf.fail(subject)
62
+ const msg = after.isLocked ? ctx.t('login_locked', '로그인 시도가 너무 많습니다. 잠시 후 다시 시도하세요.') : ctx.t('login_failed', '이메일 또는 비밀번호가 올바르지 않습니다.')
63
+ return AuthController.#renderLogin(reply, ctx, { email }, msg, after.isLocked ? 423 : 401)
64
+ }
65
+
66
+ await bf.reset(subject)
67
+ await establishSession(req, user)
68
+ req.log?.debug?.({ userId: user.id }, 'auth.login ok')
69
+ return reply.redirect(HOME_REDIRECT)
70
+ }
71
+
72
+ /** POST /auth/logout — 세션 파기 → 로그인 페이지. @param {any} req @param {any} reply */
73
+ static async logout(req, reply) {
74
+ if (req.session?.userId != null) await req.session.destroy()
75
+ return reply.redirect('/auth/login?notice=logged_out')
76
+ }
77
+
78
+ /** GET /register — 회원가입 폼. 이미 로그인했으면 관리 UI 로. @param {any} req @param {any} reply @param {any} ctx */
79
+ static async registerForm(req, reply, ctx) {
80
+ if (currentUser(req)) return reply.redirect(HOME_REDIRECT)
81
+ return ctx.render('auth/register', {
82
+ title: ctx.t('register_title', '회원가입'),
83
+ values: {},
84
+ invalid: null,
85
+ currentUser: null,
86
+ csrfToken: reply.generateCsrf(),
87
+ })
88
+ }
89
+
90
+ /** POST /register — 계정 생성 → 자동 로그인 → 관리 UI. 검증/충돌 시 폼 재렌더. @param {any} req @param {any} reply @param {any} ctx */
91
+ static async register(req, reply, ctx) {
92
+ try {
93
+ const user = await ctx.services.auth.register(req.body ?? {})
94
+ await establishSession(req, user)
95
+ req.log?.debug?.({ userId: user.id }, 'auth.register ok — auto login')
96
+ return reply.redirect(`${HOME_REDIRECT}?notice=registered`)
97
+ } catch (err) {
98
+ if (err instanceof MegaValidationError || err instanceof MegaConflictError) {
99
+ reply.code(err instanceof MegaConflictError ? 409 : 400)
100
+ return ctx.render('auth/register', {
101
+ title: ctx.t('register_title', '회원가입'),
102
+ values: { name: req.body?.name ?? '', email: req.body?.email ?? '' },
103
+ invalid: AuthController.#invalidFields(err),
104
+ error: err.message,
105
+ currentUser: null,
106
+ csrfToken: reply.generateCsrf(),
107
+ })
108
+ }
109
+ throw err // 미지 에러는 프레임워크 글로벌 핸들러로.
110
+ }
111
+ }
112
+
113
+ /**
114
+ * 로그인 폼 재렌더 헬퍼(상태코드 + 에러 메시지 + 입력 보존 + 새 CSRF 토큰).
115
+ * @param {any} reply @param {any} ctx @param {{ email: string }} values @param {string} error @param {number} code
116
+ */
117
+ static #renderLogin(reply, ctx, values, error, code) {
118
+ reply.code(code)
119
+ return ctx.render('auth/login', {
120
+ title: ctx.t('login_title', '로그인'),
121
+ values,
122
+ error,
123
+ notice: null,
124
+ currentUser: null,
125
+ csrfToken: reply.generateCsrf(),
126
+ })
127
+ }
128
+
129
+ /**
130
+ * 회원가입 검증/충돌 에러 → 폼 is-invalid 표시용 필드 맵.
131
+ * @param {unknown} err @returns {{ name: boolean, email: boolean, password: boolean }}
132
+ */
133
+ static #invalidFields(err) {
134
+ if (err instanceof MegaValidationError) {
135
+ const d = /** @type {any} */ (err).details ?? {}
136
+ // auth-service 는 details.{name,email,password} = "값이 유효한가"(true=정상) 로 준다 → 없으면 invalid.
137
+ return { name: !d.name, email: !d.email, password: !d.password }
138
+ }
139
+ if (err instanceof MegaConflictError) {
140
+ return { name: false, email: true, password: false } // 이메일 중복.
141
+ }
142
+ return { name: false, email: false, password: false }
143
+ }
144
+ }
@@ -0,0 +1,34 @@
1
+ // @ts-check
2
+ /**
3
+ * CronController — /demo/cron 스케줄러 데모 UI(MPA) HTTP 컨트롤러(ADR-074: 베이스 없음, 정적 메서드, ADR-157).
4
+ *
5
+ * `ctx.services.cronDemo`(자동 DI)로 누적 실행 횟수·최근 실행 이력·다음 실행 시각을 EJS 로 렌더한다. 수동 실행은
6
+ * POST(PRG) — CSRF 폼 토큰을 심는다. 자동 실행(30초 주기)은 `mega scheduler` 프로세스의 CronCounterSchedule
7
+ * 이 같은 redis 에 쌓으므로, 수동/자동 실행이 한 이력에 함께 보인다.
8
+ */
9
+ import { currentUser } from '../middleware/web-auth.js'
10
+
11
+ /** 알림 쿼리(?notice=) → 로케일 키 화이트리스트(임의 t() 조회 방지). */
12
+ const NOTICE_KEYS = new Set(['triggered'])
13
+
14
+ export class CronController {
15
+ /** GET /demo/cron — 실행 횟수 + 이력 + 다음 실행 시각 렌더. @param {any} req @param {any} reply @param {any} ctx */
16
+ static async index(req, reply, ctx) {
17
+ const snap = await ctx.services.cronDemo.snapshot()
18
+ const key = String(req.query?.notice ?? '')
19
+ const notice = NOTICE_KEYS.has(key) ? ctx.t(`cron_notice_${key}`, { defaultValue: '스케줄 작업을 즉시 1회 실행했습니다.' }) : null
20
+ return ctx.render('cron/index', {
21
+ title: ctx.t('cron_title', { defaultValue: '스케줄러 데모 (MegaSchedule)' }),
22
+ snap,
23
+ notice,
24
+ currentUser: currentUser(req),
25
+ csrfToken: reply.generateCsrf(),
26
+ })
27
+ }
28
+
29
+ /** POST /demo/cron/run — 지금 즉시 1회 실행 → 리다이렉트(PRG). @param {any} _req @param {any} reply @param {any} ctx */
30
+ static async runNow(_req, reply, ctx) {
31
+ await ctx.services.cronDemo.tick('manual')
32
+ return reply.redirect('/demo/cron?notice=triggered')
33
+ }
34
+ }
@@ -0,0 +1,37 @@
1
+ // @ts-check
2
+ /**
3
+ * GuideController — /guide 가이드 뷰어 HTTP 컨트롤러(ADR-074: 베이스 없음, 정적 메서드만).
4
+ *
5
+ * `ctx.services.guide`(자동 DI)로 docs/guide 의 마크다운을 서버사이드 렌더한 HTML 을 EJS 뷰로 내보낸다.
6
+ * 브라우저 JS 를 추가하지 않으므로(CSP script-src 'self' 호환) 코드 하이라이트도 전부 서버에서 끝낸다.
7
+ * navbar 의 로그아웃 폼이 CSRF 토큰을 요구하므로 데모 다른 페이지와 동일하게 currentUser·csrfToken 을 넘긴다.
8
+ */
9
+ import { currentUser } from '../middleware/web-auth.js'
10
+
11
+ export class GuideController {
12
+ /** GET /guide — 가이드 목록(카드 그리드). @param {any} req @param {any} reply @param {any} ctx */
13
+ static async index(req, reply, ctx) {
14
+ const guides = await ctx.services.guide.listGuides()
15
+ return ctx.render('guide/index', {
16
+ title: ctx.t('guide_index_title', '가이드'),
17
+ guides,
18
+ currentUser: currentUser(req),
19
+ csrfToken: reply.generateCsrf(),
20
+ })
21
+ }
22
+
23
+ /** GET /guide/:slug — 단일 가이드(좌측 목차 + 우측 본문). @param {any} req @param {any} reply @param {any} ctx */
24
+ static async page(req, reply, ctx) {
25
+ const slug = String(req.params?.slug ?? '')
26
+ // renderGuide 가 화이트리스트 밖 slug 에 MegaNotFoundError 를 던진다(전역 에러 핸들러가 404 로 매핑).
27
+ const guide = await ctx.services.guide.renderGuide(slug)
28
+ const guides = await ctx.services.guide.listGuides()
29
+ return ctx.render('guide/page', {
30
+ title: guide.title,
31
+ guide,
32
+ guides,
33
+ currentUser: currentUser(req),
34
+ csrfToken: reply.generateCsrf(),
35
+ })
36
+ }
37
+ }
@@ -0,0 +1,43 @@
1
+ // @ts-check
2
+ /**
3
+ * JobsController — /demo/jobs 잡 큐 데모 UI(MPA) HTTP 컨트롤러(ADR-074: 정적 메서드, ADR-157).
4
+ *
5
+ * `ctx.services.jobsDemo`(자동 DI)로 EmailJob 을 enqueue 하고(NATS JetStream), 워커가 처리해 남긴 이벤트
6
+ * 타임라인과 DLQ 격리분을 EJS 로 렌더한다. enqueue 는 POST(PRG) — CSRF 폼 토큰을 심는다.
7
+ */
8
+ import { currentUser } from '../middleware/web-auth.js'
9
+
10
+ /** 발송 시뮬레이션 모드 화이트리스트(폼 입력 검증). */
11
+ const MODES = ['ok', 'flaky', 'fail']
12
+ /** 알림 쿼리(?notice=) → 로케일 키 화이트리스트. */
13
+ const NOTICE_KEYS = new Set(['enqueued'])
14
+
15
+ export class JobsController {
16
+ /** GET /demo/jobs — enqueue 폼 + 처리 이벤트 + DLQ 상태 렌더. @param {any} req @param {any} reply @param {any} ctx */
17
+ static async index(req, reply, ctx) {
18
+ const [events, dlq] = await Promise.all([ctx.services.jobsDemo.events(), ctx.services.jobsDemo.dlq()])
19
+ const key = String(req.query?.notice ?? '')
20
+ const notice = NOTICE_KEYS.has(key) ? ctx.t(`jobs_notice_${key}`, { defaultValue: '잡을 큐에 넣었습니다. 워커가 처리하면 아래 이벤트에 나타납니다.' }) : null
21
+ return ctx.render('jobs/index', {
22
+ title: ctx.t('jobs_title', { defaultValue: '잡 큐 데모 (MegaJob)' }),
23
+ events,
24
+ dlq,
25
+ modes: MODES,
26
+ notice,
27
+ currentUser: currentUser(req),
28
+ csrfToken: reply.generateCsrf(),
29
+ })
30
+ }
31
+
32
+ /** POST /demo/jobs/enqueue — EmailJob 1건 enqueue → 리다이렉트(PRG). @param {any} req @param {any} reply @param {any} ctx */
33
+ static async enqueue(req, reply, ctx) {
34
+ const body = req.body ?? {}
35
+ const mode = MODES.includes(body.mode) ? body.mode : 'ok'
36
+ const rawTo = typeof body.to === 'string' ? body.to.trim() : ''
37
+ const to = rawTo.length > 0 ? rawTo : 'demo@example.com'
38
+ // 잡 식별자 — 시도 카운터/이벤트 키에 쓰인다. 같은 페이로드 중복 enqueue 도 서로 구분되게 유니크하게 만든다.
39
+ const id = `email-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`
40
+ await ctx.services.jobsDemo.enqueue({ id, to, mode })
41
+ return reply.redirect('/demo/jobs?notice=enqueued')
42
+ }
43
+ }
@@ -0,0 +1,35 @@
1
+ // @ts-check
2
+ /**
3
+ * LogsController — /demo/logs 구조적 로깅 데모 UI(MPA) HTTP 컨트롤러(ADR-074: 정적 메서드, ADR-163).
4
+ *
5
+ * `ctx.services.logsDemo`(자동 DI)로 실제 pino 로그를 emit 하고, 안전 메타데이터 이력을 EJS 로 렌더한다.
6
+ * emit 은 POST(PRG) — CSRF 폼 토큰을 심는다. 실제 마스킹·trace_id 첨부·구조적 NDJSON 은 서버 콘솔에서 확인하며,
7
+ * 화면은 emit 사실과 trace 상관(trace_id)을 보여준다.
8
+ */
9
+ import { currentUser } from '../middleware/web-auth.js'
10
+
11
+ /** 알림 쿼리(?notice=) → 로케일 키 화이트리스트. */
12
+ const NOTICE_KEYS = new Set(['emitted'])
13
+
14
+ export class LogsController {
15
+ /** GET /demo/logs — 최근 emit 메타 + emit 폼 렌더. @param {any} req @param {any} reply @param {any} ctx */
16
+ static async index(req, reply, ctx) {
17
+ const snap = await ctx.services.logsDemo.snapshot()
18
+ const key = String(req.query?.notice ?? '')
19
+ const notice = NOTICE_KEYS.has(key) ? ctx.t(`logs_notice_${key}`, '로그를 emit 했습니다. 서버 콘솔에서 구조적 출력을 확인하세요.') : null
20
+ return ctx.render('logs/index', {
21
+ title: ctx.t('logs_title', '구조적 로깅'),
22
+ snap,
23
+ notice,
24
+ currentUser: currentUser(req),
25
+ csrfToken: reply.generateCsrf(),
26
+ })
27
+ }
28
+
29
+ /** POST /demo/logs/emit — 선택 레벨로 로그 1건 emit → 리다이렉트(PRG). @param {any} req @param {any} reply @param {any} ctx */
30
+ static async emit(req, reply, ctx) {
31
+ const body = req.body ?? {}
32
+ await ctx.services.logsDemo.emit(body.level, body.message)
33
+ return reply.redirect('/demo/logs?notice=emitted')
34
+ }
35
+ }
@@ -0,0 +1,22 @@
1
+ // @ts-check
2
+ /**
3
+ * MetricsController — /demo/metrics 관측(메트릭) 데모 UI(MPA) HTTP 컨트롤러(ADR-074: 정적 메서드, ADR-163).
4
+ *
5
+ * `ctx.services.metricsDemo`(자동 DI)가 `MegaMetrics.collect()`를 파싱한 요약을 EJS 카드로 렌더한다.
6
+ * raw `/metrics`(Prometheus scrape 포맷)는 loopback IP allowList(mega.config.js health, ADR-131)로 보호되며,
7
+ * 본 페이지는 그 데이터를 사람 친화 카드로 보여준 뒤 raw 링크를 함께 건다.
8
+ */
9
+ import { currentUser } from '../middleware/web-auth.js'
10
+
11
+ export class MetricsController {
12
+ /** GET /demo/metrics — HTTP/잡/WS/process 카운터 요약 렌더. @param {any} req @param {any} reply @param {any} ctx */
13
+ static async index(req, reply, ctx) {
14
+ const snap = await ctx.services.metricsDemo.snapshot()
15
+ return ctx.render('metrics/index', {
16
+ title: ctx.t('metrics_title', '메트릭'),
17
+ snap,
18
+ currentUser: currentUser(req),
19
+ csrfToken: reply.generateCsrf(),
20
+ })
21
+ }
22
+ }
@@ -0,0 +1,116 @@
1
+ // @ts-check
2
+ /**
3
+ * NoteController — mongo notes 데모 UI(MPA) HTTP 컨트롤러(ADR-074: 베이스 없음, 정적 메서드만, ADR-157).
4
+ *
5
+ * users 의 WebController 와 같은 패턴 — `ctx.services.note`(자동 DI)를 거쳐 EJS 뷰를 서버사이드 렌더하고,
6
+ * 생성/수정/삭제 후엔 PRG(Post/Redirect/Get)로 목록으로 리다이렉트한다. 검증 에러는 폼을 에러와 함께
7
+ * 다시 렌더한다. CSRF(디폴트 ON)는 폼 `_csrf` 토큰을 요구하므로 폼 뷰에 `reply.generateCsrf()` 를 심는다.
8
+ */
9
+ import { MegaNotFoundError, MegaValidationError } from 'mega-framework/errors'
10
+ import { currentUser } from '../middleware/web-auth.js'
11
+
12
+ /** 알림 쿼리(?notice=) → 로케일 키 화이트리스트(임의 t() 조회 방지). */
13
+ const NOTICE_KEYS = new Set(['created', 'updated', 'deleted'])
14
+ /** 알림 키별 한국어 fallback(locale 누락 시 표시할 값). @type {Record<string, string>} */
15
+ const NOTICE_DEFAULTS = {
16
+ created: '노트를 생성했습니다.',
17
+ updated: '노트를 수정했습니다.',
18
+ deleted: '노트를 삭제했습니다.',
19
+ }
20
+
21
+ export class NoteController {
22
+ /** GET /demo/notes — 목록(+ ?notice= 알림, 삭제 폼 CSRF). @param {any} req @param {any} reply @param {any} ctx */
23
+ static async list(req, reply, ctx) {
24
+ const notes = await ctx.services.note.list()
25
+ const key = String(req.query?.notice ?? '')
26
+ const notice = NOTICE_KEYS.has(key) ? ctx.t(`notes_notice_${key}`, { defaultValue: NOTICE_DEFAULTS[key] }) : null
27
+ return ctx.render('notes/list', {
28
+ title: ctx.t('notes_title', { defaultValue: '노트 (MongoDB)' }),
29
+ notes,
30
+ notice,
31
+ currentUser: currentUser(req),
32
+ csrfToken: reply.generateCsrf(),
33
+ })
34
+ }
35
+
36
+ /** GET /demo/notes/new — 신규 폼. @param {any} req @param {any} reply @param {any} ctx */
37
+ static async newForm(req, reply, ctx) {
38
+ return ctx.render('notes/new', {
39
+ title: ctx.t('notes_new_title', { defaultValue: '노트 추가' }),
40
+ values: {},
41
+ invalid: null,
42
+ currentUser: currentUser(req),
43
+ csrfToken: reply.generateCsrf(),
44
+ })
45
+ }
46
+
47
+ /** POST /demo/notes — 생성 → 목록 리다이렉트(PRG). 검증 시 폼 재렌더. @param {any} req @param {any} reply @param {any} ctx */
48
+ static async create(req, reply, ctx) {
49
+ try {
50
+ await ctx.services.note.create(req.body ?? {})
51
+ return reply.redirect('/demo/notes?notice=created')
52
+ } catch (err) {
53
+ if (err instanceof MegaValidationError) {
54
+ reply.code(400)
55
+ return ctx.render('notes/new', {
56
+ title: ctx.t('notes_new_title', { defaultValue: '노트 추가' }),
57
+ values: req.body ?? {},
58
+ invalid: { title: true },
59
+ error: err.message,
60
+ currentUser: currentUser(req),
61
+ csrfToken: reply.generateCsrf(),
62
+ })
63
+ }
64
+ throw err // 미지 에러는 프레임워크 글로벌 핸들러로.
65
+ }
66
+ }
67
+
68
+ /** GET /demo/notes/:id/edit — 수정 폼(사전 채움). 없으면 목록으로. @param {any} req @param {any} reply @param {any} ctx */
69
+ static async editForm(req, reply, ctx) {
70
+ try {
71
+ const note = await ctx.services.note.get(req.params.id)
72
+ return ctx.render('notes/edit', {
73
+ title: ctx.t('notes_edit_title', { defaultValue: '노트 수정' }),
74
+ values: note,
75
+ invalid: null,
76
+ currentUser: currentUser(req),
77
+ csrfToken: reply.generateCsrf(),
78
+ })
79
+ } catch (err) {
80
+ if (err instanceof MegaNotFoundError) return reply.redirect('/demo/notes')
81
+ throw err
82
+ }
83
+ }
84
+
85
+ /** POST /demo/notes/:id — 수정 → 목록 리다이렉트(PRG). 검증 시 폼 재렌더, 없으면 목록으로. @param {any} req @param {any} reply @param {any} ctx */
86
+ static async update(req, reply, ctx) {
87
+ try {
88
+ await ctx.services.note.update(req.params.id, req.body ?? {})
89
+ return reply.redirect('/demo/notes?notice=updated')
90
+ } catch (err) {
91
+ if (err instanceof MegaValidationError) {
92
+ reply.code(400)
93
+ return ctx.render('notes/edit', {
94
+ title: ctx.t('notes_edit_title', { defaultValue: '노트 수정' }),
95
+ values: { id: req.params.id, ...(req.body ?? {}) },
96
+ invalid: { title: true },
97
+ error: err.message,
98
+ currentUser: currentUser(req),
99
+ csrfToken: reply.generateCsrf(),
100
+ })
101
+ }
102
+ if (err instanceof MegaNotFoundError) return reply.redirect('/demo/notes')
103
+ throw err
104
+ }
105
+ }
106
+
107
+ /** POST /demo/notes/:id/delete — 삭제 → 목록 리다이렉트(PRG). 없으면 그대로 목록으로. @param {any} req @param {any} reply @param {any} ctx */
108
+ static async destroy(req, reply, ctx) {
109
+ try {
110
+ await ctx.services.note.remove(req.params.id)
111
+ } catch (err) {
112
+ if (!(err instanceof MegaNotFoundError)) throw err
113
+ }
114
+ return reply.redirect('/demo/notes?notice=deleted')
115
+ }
116
+ }
@@ -0,0 +1,38 @@
1
+ // @ts-check
2
+ /**
3
+ * PerfController — /perf 벤치마크 데모 UI + 실행 엔드포인트(ADR-074: 베이스 없음, 정적 메서드만, ADR-157).
4
+ *
5
+ * GET /perf 는 시나리오 선택·실행 폼이 든 EJS 페이지를 렌더한다. POST /perf/run 은 AJAX(JSON) 엔드포인트로
6
+ * `ctx.services.perf`(자동 DI)에 측정을 위임하고 도메인 결과만 반환한다 — envelope(`{ ok, data, meta }`)는
7
+ * 프레임워크가 감싼다(ADR-018/147). JSON 요청이라 CSRF 는 토큰 면제 + Origin 검증으로 통과한다(ADR-051).
8
+ */
9
+ import { currentUser } from '../middleware/web-auth.js'
10
+
11
+ /** UI 드롭다운에 보일 시나리오 목록(라벨 키는 i18n). 서비스 SCENARIO_LIMITS 와 동일 집합이어야 한다. */
12
+ const SCENARIOS = Object.freeze([
13
+ 'http.echo',
14
+ 'http.jsonSmall',
15
+ 'http.jsonLarge',
16
+ 'crypto.hash',
17
+ 'crypto.aspRoundtrip',
18
+ 'db.pg.insertSelect',
19
+ 'db.mongo.insertFind',
20
+ 'cache.redis.setGet',
21
+ 'session.createRead',
22
+ ])
23
+
24
+ export class PerfController {
25
+ /** GET /perf — 벤치마크 UI 렌더. @param {any} req @param {any} _reply @param {any} ctx */
26
+ static async index(req, _reply, ctx) {
27
+ return ctx.render('perf/index', {
28
+ title: ctx.t('perf_title', '성능 벤치마크'),
29
+ scenarios: SCENARIOS,
30
+ currentUser: currentUser(req),
31
+ })
32
+ }
33
+
34
+ /** POST /perf/run — 벤치마크 1회 실행 → 결과 반환(프레임워크가 envelope 로 감쌈). @param {any} req @param {any} _reply @param {any} ctx */
35
+ static async run(req, _reply, ctx) {
36
+ return ctx.services.perf.run(req.body)
37
+ }
38
+ }
@@ -0,0 +1,36 @@
1
+ // @ts-check
2
+ /**
3
+ * RedisController — /demo/redis 데모 UI(MPA) HTTP 컨트롤러(ADR-074: 베이스 없음, 정적 메서드만, ADR-157).
4
+ *
5
+ * `ctx.services.redisDemo`(자동 DI)를 거쳐 방문 카운터(INCR/EXPIRE)와 쿼리 결과 캐시(GET/SET/TTL/DEL) 상태를
6
+ * EJS 뷰로 렌더한다. 캐시 비우기는 POST(PRG) — CSRF(디폴트 ON) 토큰을 폼에 심는다(reply.generateCsrf()).
7
+ */
8
+ import { currentUser } from '../middleware/web-auth.js'
9
+
10
+ /** 알림 쿼리(?notice=) → 로케일 키 화이트리스트(임의 t() 조회 방지). */
11
+ const NOTICE_KEYS = new Set(['cache_cleared'])
12
+
13
+ export class RedisController {
14
+ /** GET /demo/redis — 방문 기록 + 캐시 상태 렌더. @param {any} req @param {any} reply @param {any} ctx */
15
+ static async index(req, reply, ctx) {
16
+ const visits = await ctx.services.redisDemo.recordVisit()
17
+ // 'cache' 는 EJS 예약 로컬(컴파일 캐시 플래그) — 프레임워크가 렌더 시 덮어쓰므로 다른 이름을 쓴다(template.js).
18
+ const cacheState = await ctx.services.redisDemo.getUserCountCached()
19
+ const key = String(req.query?.notice ?? '')
20
+ const notice = NOTICE_KEYS.has(key) ? ctx.t(`redis_notice_${key}`, { defaultValue: '캐시를 비웠습니다.' }) : null
21
+ return ctx.render('redis/index', {
22
+ title: ctx.t('redis_title', { defaultValue: 'Redis 데모' }),
23
+ visits,
24
+ cacheState,
25
+ notice,
26
+ currentUser: currentUser(req),
27
+ csrfToken: reply.generateCsrf(),
28
+ })
29
+ }
30
+
31
+ /** POST /demo/redis/clear — 캐시 비우기 → 리다이렉트(PRG). @param {any} _req @param {any} reply @param {any} ctx */
32
+ static async clearCache(_req, reply, ctx) {
33
+ await ctx.services.redisDemo.clearUserCountCache()
34
+ return reply.redirect('/demo/redis?notice=cache_cleared')
35
+ }
36
+ }
@@ -0,0 +1,43 @@
1
+ // @ts-check
2
+ /**
3
+ * TracingController — /demo/tracing 분산 추적 데모 UI(MPA) HTTP 컨트롤러(ADR-074: 정적 메서드, ADR-163).
4
+ *
5
+ * `ctx.services.tracingDemo`(자동 DI)로 현재 요청 trace_id 와 최근 trace 목록을 렌더하고, 각 trace 를
6
+ * Zipkin 딥링크로 잇는다. 응답 헤더 `x-trace-id` 에도 현재 trace_id 를 실어, 브라우저 개발자도구/curl 로
7
+ * 바로 추적 ID 를 확인할 수 있게 한다. "trace 생성" 버튼은 `ctx.tracer.span`(사용자 직접 span, ADR-126)으로
8
+ * 자식 span 을 하나 깔아 DB 핑을 수행한 뒤 PRG 로 돌아온다 — Zipkin 에 다층 span 트리가 보이게 한다.
9
+ */
10
+ import { currentUser } from '../middleware/web-auth.js'
11
+
12
+ /** 알림 쿼리(?notice=) → 로케일 키 화이트리스트. */
13
+ const NOTICE_KEYS = new Set(['generated'])
14
+
15
+ export class TracingController {
16
+ /** GET /demo/tracing — 현재 trace_id + 최근 trace 목록 렌더. @param {any} req @param {any} reply @param {any} ctx */
17
+ static async index(req, reply, ctx) {
18
+ await ctx.services.tracingDemo.record('GET /demo/tracing')
19
+ const snap = await ctx.services.tracingDemo.snapshot()
20
+ if (snap.current) reply.header('x-trace-id', snap.current.traceId)
21
+ const key = String(req.query?.notice ?? '')
22
+ const notice = NOTICE_KEYS.has(key) ? ctx.t(`tracing_notice_${key}`, 'trace 를 생성했습니다. 잠시 후 Zipkin 에서 확인할 수 있습니다.') : null
23
+ return ctx.render('tracing/index', {
24
+ title: ctx.t('tracing_title', '분산 추적'),
25
+ snap,
26
+ notice,
27
+ currentUser: currentUser(req),
28
+ csrfToken: reply.generateCsrf(),
29
+ })
30
+ }
31
+
32
+ /** POST /demo/tracing/generate — 사용자 span 으로 DB 핑 1회 → 최근 이력에 기록 → 리다이렉트(PRG). @param {any} _req @param {any} reply @param {any} ctx */
33
+ static async generate(_req, reply, ctx) {
34
+ // ctx.tracer.span — 활성 HTTP span 의 자식으로 'demo.tracing.work' span 을 깐다. 안에서 가벼운 DB 핑을
35
+ // 돌려(어댑터 자동 span 도 자식으로 중첩, ADR-114) Zipkin 에 span 트리가 만들어지게 한다.
36
+ await ctx.tracer.span('demo.tracing.work', async (/** @type {any} */ span) => {
37
+ span.setAttribute('demo.kind', 'manual-generate')
38
+ await ctx.db('db').query('SELECT 1')
39
+ })
40
+ await ctx.services.tracingDemo.record('POST /demo/tracing/generate')
41
+ return reply.redirect('/demo/tracing?notice=generated')
42
+ }
43
+ }
@@ -0,0 +1,98 @@
1
+ // @ts-check
2
+ /**
3
+ * UploadController — /demo/upload 파일 업로드 데모 UI(MPA) HTTP 컨트롤러(ADR-074: 정적 메서드, ADR-133/163).
4
+ *
5
+ * GET 은 업로드 폼 + 최근 업로드 목록을 EJS 로 렌더한다. POST 는 코어 `req.saveUploads()`(MIME 게이트 +
6
+ * 경로탐색 차단 + 스트리밍 저장 + 트레이싱·메트릭, ADR-133)로 저장 디렉터리에 저장하고 메타를 JSON envelope
7
+ * 으로 돌려준다. multipart 폼은 CSRF 토큰을 **헤더**(`csrf-token`)로 보내야 하므로(스트리밍 body 는 preHandler
8
+ * 시점에 미파싱) 업로드는 `upload-demo.js` 의 fetch + FormData 로 제출한다.
9
+ *
10
+ * 다운로드(GET /demo/upload/file/:name)는 **정적 서빙이 아니라 소스에서 파일을 읽어 전송**한다 — 인증
11
+ * (`webRequireAuth`, 라우트 가드)된 사용자만 받을 수 있다(가드를 떼면 공개로 전환 가능). 파일명은 basename +
12
+ * 디렉터리 내부 검증으로 경로 탐색을 막는다.
13
+ *
14
+ * 저장 디렉터리는 **설정(.env `DEMO_UPLOAD_DIR`)** 으로 받는다(미설정 시 `var/uploads`). 상대경로는 프로젝트
15
+ * 루트(`process.cwd()`, views.dir/staticAssets.dir 과 같은 규약 ADR-151) 기준으로 해석한다.
16
+ */
17
+ import { join, relative, isAbsolute, resolve, sep, basename, extname } from 'node:path'
18
+ import { createReadStream } from 'node:fs'
19
+ import { stat } from 'node:fs/promises'
20
+ import { MegaNotFoundError } from 'mega-framework/errors'
21
+ import { currentUser } from '../middleware/web-auth.js'
22
+
23
+ /** 저장 디렉터리 설정값 — .env `DEMO_UPLOAD_DIR`(상대/절대). 미설정 시 `var/uploads`(프로젝트 루트 상대). */
24
+ const UPLOAD_DIR_SETTING = (process.env.DEMO_UPLOAD_DIR ?? '').trim() || join('var', 'uploads')
25
+
26
+ /** 확장자 → MIME(다운로드 content-type). 미지원 확장자는 octet-stream(강제 다운로드). */
27
+ const EXT_MIME = {
28
+ '.png': 'image/png',
29
+ '.jpg': 'image/jpeg',
30
+ '.jpeg': 'image/jpeg',
31
+ '.gif': 'image/gif',
32
+ '.webp': 'image/webp',
33
+ '.svg': 'image/svg+xml',
34
+ '.pdf': 'application/pdf',
35
+ '.txt': 'text/plain; charset=utf-8',
36
+ }
37
+
38
+ /**
39
+ * 저장 절대 디렉터리. 설정이 절대경로면 그대로, 상대면 `process.cwd()` 기준.
40
+ * @returns {string}
41
+ */
42
+ function uploadDir() {
43
+ return isAbsolute(UPLOAD_DIR_SETTING) ? UPLOAD_DIR_SETTING : join(process.cwd(), UPLOAD_DIR_SETTING)
44
+ }
45
+
46
+ export class UploadController {
47
+ /** GET /demo/upload — 업로드 폼 + 최근 업로드 목록 렌더. @param {any} req @param {any} reply @param {any} ctx */
48
+ static async index(req, reply, ctx) {
49
+ const snap = await ctx.services.uploadDemo.snapshot()
50
+ return ctx.render('upload/index', {
51
+ title: ctx.t('upload_title', { defaultValue: '파일 업로드' }),
52
+ snap,
53
+ uploadDir: UPLOAD_DIR_SETTING,
54
+ currentUser: currentUser(req),
55
+ csrfToken: reply.generateCsrf(),
56
+ })
57
+ }
58
+
59
+ /** POST /demo/upload — multipart 저장 → 메타 JSON 반환(fetch 제출). @param {any} req @param {any} _reply @param {any} ctx */
60
+ static async upload(req, _reply, ctx) {
61
+ // saveUploads 가 MIME 비허용(415)·크기 초과(413)면 throw → 글로벌 핸들러가 에러 envelope 로 응답한다.
62
+ const saved = await req.saveUploads(uploadDir())
63
+ // 저장 경로는 프로젝트 루트 기준 상대경로로 환산(서버 절대경로 노출 회피, 데모에선 위치 확인이 목적).
64
+ const files = saved.map((/** @type {any} */ f) => ({
65
+ filename: f.filename,
66
+ bytes: f.bytes,
67
+ mimetype: f.mimetype,
68
+ path: relative(process.cwd(), f.savedAs),
69
+ }))
70
+ await ctx.services.uploadDemo.record(files)
71
+ return { files }
72
+ }
73
+
74
+ /** GET /demo/upload/file/:name — 저장된 파일을 소스에서 읽어 전송(인증 필요). @param {any} req @param {any} reply @param {any} ctx */
75
+ static async download(req, reply, ctx) {
76
+ // basename 으로 디렉터리 성분 제거(경로 탐색 1차 차단) — 'a/../b' / '../etc' 류를 단일 파일명으로.
77
+ const safe = basename(String(req.params?.name ?? ''))
78
+ const dir = resolve(uploadDir())
79
+ const abs = resolve(dir, safe)
80
+ // 2차 방어 — 최종 경로가 저장 디렉터리 내부(dir/<파일>)가 아니면 거부.
81
+ if (safe.length === 0 || !abs.startsWith(dir + sep)) {
82
+ throw new MegaNotFoundError('upload.not_found', `File '${safe}' not found.`)
83
+ }
84
+ // 존재·정규 파일 확인 — 없거나 디렉터리면 404.
85
+ try {
86
+ const st = await stat(abs)
87
+ if (!st.isFile()) throw new MegaNotFoundError('upload.not_found', `File '${safe}' not found.`)
88
+ } catch (err) {
89
+ if (err instanceof MegaNotFoundError) throw err
90
+ throw new MegaNotFoundError('upload.not_found', `File '${safe}' not found.`, { cause: err })
91
+ }
92
+ ctx.log?.debug?.({ file: safe }, 'upload-demo.download')
93
+ // 소스에서 스트리밍 전송(정적 서빙 아님). 스트림 payload 라 Fastify 가 envelope 직렬화를 건너뛴다.
94
+ reply.header('content-disposition', `attachment; filename="${encodeURIComponent(safe)}"`)
95
+ reply.type(EXT_MIME[extname(safe).toLowerCase()] ?? 'application/octet-stream')
96
+ return reply.send(createReadStream(abs))
97
+ }
98
+ }