@fuzionx/framework 0.1.45 → 0.1.47
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 +29 -2
- package/cli/index.js +37 -8
- package/cli/templates/make/app-spa/controllers/AuthController.js +114 -0
- package/cli/templates/make/app-spa/controllers/HomeController.js +66 -0
- package/cli/templates/make/app-spa/controllers/PostController.js +191 -0
- package/cli/templates/make/app-spa/controllers/UserController.js +43 -0
- package/cli/templates/make/app-spa/public/css/style.css +1011 -0
- package/cli/templates/make/app-spa/routes/api.js +31 -0
- package/cli/templates/make/app-spa/routes/web.js +19 -0
- package/cli/templates/make/app-spa/services/AuthService.js +48 -0
- package/cli/templates/make/app-spa/services/PostService.js +372 -0
- package/cli/templates/make/app-spa/services/UserService.js +48 -0
- package/cli/templates/make/app-spa/views/default/errors/404.html +11 -0
- package/cli/templates/make/app-spa/views/default/errors/500.html +11 -0
- package/cli/templates/make/app-spa/views/default/layouts/main.html +34 -0
- package/cli/templates/make/app-spa/views/default/pages/home.html +22 -0
- package/cli/templates/make/app-spa/views/default/spa/index.html +13 -0
- package/cli/templates/make/app-spa/views/default/spa/package.json +20 -0
- package/cli/templates/make/app-spa/views/default/spa/src/App.vue +41 -0
- package/cli/templates/make/app-spa/views/default/spa/src/assets/landing.css +220 -0
- package/cli/templates/make/app-spa/views/default/spa/src/assets/style.css +1156 -0
- package/cli/templates/make/app-spa/views/default/spa/src/components/AlertDialog.vue +179 -0
- package/cli/templates/make/app-spa/views/default/spa/src/components/CodeBlock.vue +33 -0
- package/cli/templates/make/app-spa/views/default/spa/src/components/EditorToolbar.vue +54 -0
- package/cli/templates/make/app-spa/views/default/spa/src/components/FileUpload.vue +161 -0
- package/cli/templates/make/app-spa/views/default/spa/src/components/FlashMessage.vue +39 -0
- package/cli/templates/make/app-spa/views/default/spa/src/components/LanguageSwitcher.vue +108 -0
- package/cli/templates/make/app-spa/views/default/spa/src/components/Lightbox.vue +62 -0
- package/cli/templates/make/app-spa/views/default/spa/src/components/Navbar.vue +68 -0
- package/cli/templates/make/app-spa/views/default/spa/src/components/Pagination.vue +166 -0
- package/cli/templates/make/app-spa/views/default/spa/src/components/ToastContainer.vue +135 -0
- package/cli/templates/make/app-spa/views/default/spa/src/composables/useApi.js +129 -0
- package/cli/templates/make/app-spa/views/default/spa/src/composables/useClipboard.js +44 -0
- package/cli/templates/make/app-spa/views/default/spa/src/composables/useDate.js +73 -0
- package/cli/templates/make/app-spa/views/default/spa/src/composables/useDebounce.js +59 -0
- package/cli/templates/make/app-spa/views/default/spa/src/composables/useFlash.js +46 -0
- package/cli/templates/make/app-spa/views/default/spa/src/composables/useHeartbeat.js +45 -0
- package/cli/templates/make/app-spa/views/default/spa/src/composables/useLocalStorage.js +43 -0
- package/cli/templates/make/app-spa/views/default/spa/src/composables/useLocale.js +79 -0
- package/cli/templates/make/app-spa/views/default/spa/src/composables/useWebSocket.js +93 -0
- package/cli/templates/make/app-spa/views/default/spa/src/main.js +106 -0
- package/cli/templates/make/app-spa/views/default/spa/src/plugins/alert.js +96 -0
- package/cli/templates/make/app-spa/views/default/spa/src/plugins/toast.js +79 -0
- package/cli/templates/make/app-spa/views/default/spa/src/router/index.js +29 -0
- package/cli/templates/make/app-spa/views/default/spa/src/stores/auth.js +58 -0
- package/cli/templates/make/app-spa/views/default/spa/src/views/BoardDetail.vue +169 -0
- package/cli/templates/make/app-spa/views/default/spa/src/views/BoardForm.vue +192 -0
- package/cli/templates/make/app-spa/views/default/spa/src/views/BoardList.vue +129 -0
- package/cli/templates/make/app-spa/views/default/spa/src/views/ChatView.vue +317 -0
- package/cli/templates/make/app-spa/views/default/spa/src/views/FeaturesView.vue +242 -0
- package/cli/templates/make/app-spa/views/default/spa/src/views/HomeView.vue +215 -0
- package/cli/templates/make/app-spa/views/default/spa/src/views/Login.vue +82 -0
- package/cli/templates/make/app-spa/views/default/spa/src/views/Profile.vue +85 -0
- package/cli/templates/make/app-spa/views/default/spa/src/views/Register.vue +84 -0
- package/cli/templates/make/app-spa/views/default/spa/vite.config.js +28 -0
- package/cli/templates/make/app-spa/views/default/spa/yarn.lock +633 -0
- package/cli/templates/make/app-spa/ws/ChatHandler.js +138 -0
- package/cli/templates/make/app-ssr/controllers/AuthController.js +119 -0
- package/cli/templates/make/app-ssr/controllers/ChatController.js +15 -0
- package/cli/templates/make/app-ssr/controllers/FeaturesController.js +15 -0
- package/cli/templates/make/app-ssr/controllers/HomeController.js +21 -0
- package/cli/templates/make/app-ssr/controllers/PostController.js +214 -0
- package/cli/templates/make/app-ssr/controllers/UserController.js +48 -0
- package/cli/templates/make/app-ssr/public/css/fx-ui.css +43 -0
- package/cli/templates/make/app-ssr/public/css/landing.css +220 -0
- package/cli/templates/make/app-ssr/public/css/style.css +1011 -0
- package/cli/templates/make/app-ssr/public/js/fx-client.js +107 -0
- package/cli/templates/make/app-ssr/public/js/fx-ui.js +124 -0
- package/cli/templates/make/app-ssr/routes/web.js +46 -0
- package/cli/templates/make/app-ssr/services/AuthService.js +48 -0
- package/cli/templates/make/app-ssr/services/PostService.js +372 -0
- package/cli/templates/make/app-ssr/services/UserService.js +48 -0
- package/cli/templates/make/app-ssr/views/default/errors/404.html +11 -0
- package/cli/templates/make/app-ssr/views/default/errors/500.html +48 -0
- package/cli/templates/make/app-ssr/views/default/layouts/main.html +96 -0
- package/cli/templates/make/app-ssr/views/default/pages/board/form.html +240 -0
- package/cli/templates/make/app-ssr/views/default/pages/board/index.html +73 -0
- package/cli/templates/make/app-ssr/views/default/pages/board/show.html +148 -0
- package/cli/templates/make/app-ssr/views/default/pages/chat.html +288 -0
- package/cli/templates/make/app-ssr/views/default/pages/features.html +373 -0
- package/cli/templates/make/app-ssr/views/default/pages/home.html +258 -0
- package/cli/templates/make/app-ssr/views/default/pages/login.html +27 -0
- package/cli/templates/make/app-ssr/views/default/pages/profile.html +36 -0
- package/cli/templates/make/app-ssr/views/default/pages/register.html +35 -0
- package/cli/templates/make/app-ssr/views/default/partials/pagination.html +75 -0
- package/cli/templates/make/app-ssr/ws/ChatHandler.js +138 -0
- package/lib/core/Application.js +425 -138
- package/lib/core/Context.js +540 -236
- package/lib/middleware/auth.js +1 -1
- package/lib/middleware/csrf.js +1 -1
- package/lib/middleware/session.js +5 -4
- package/package.json +2 -2
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FuzionX SSR Client — WASM ASP HTTP + WebSocket 통합 클라이언트
|
|
3
|
+
*
|
|
4
|
+
* create-fuzionx tester 레이아웃 패턴 기반.
|
|
5
|
+
* WASM 로드 → FuzionXClient, FuzionXSocket, aspFetch 글로벌 노출.
|
|
6
|
+
*
|
|
7
|
+
* 사용법 (인증 유저):
|
|
8
|
+
* FxClient.init(clientSecret, encSecret, headerSignal)
|
|
9
|
+
*
|
|
10
|
+
* 사용법 (공개 페이지):
|
|
11
|
+
* FxClient.initPublic(masterSecret, headerSignal)
|
|
12
|
+
*/
|
|
13
|
+
(function () {
|
|
14
|
+
'use strict';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* 인증 유저용 — 암호화된 masterSecret 복호화 후 ASP 클라이언트 생성
|
|
18
|
+
*/
|
|
19
|
+
function init(clientSecret, encSecret, headerSignal) {
|
|
20
|
+
if (!clientSecret || !encSecret) return;
|
|
21
|
+
|
|
22
|
+
window.aspEnabled = true;
|
|
23
|
+
window._aspReady = (async () => {
|
|
24
|
+
try {
|
|
25
|
+
const _v = Date.now();
|
|
26
|
+
const mod = await import(`/wasm/fuzionx_client_wasm.js?v=${_v}`);
|
|
27
|
+
await mod.default({ module_or_path: `/wasm/fuzionx_client_wasm_bg.wasm?v=${_v}` });
|
|
28
|
+
const { FuzionXClient, FuzionXSocket } = mod;
|
|
29
|
+
window.FuzionXSocket = FuzionXSocket;
|
|
30
|
+
|
|
31
|
+
// 임시 클라이언트로 masterSecret 복호화
|
|
32
|
+
const tmp = new FuzionXClient('_init_');
|
|
33
|
+
const masterSecret = tmp.decrypt_custom(clientSecret, encSecret);
|
|
34
|
+
window._aspMasterSecret = masterSecret;
|
|
35
|
+
|
|
36
|
+
// ASP 활성 클라이언트 생성
|
|
37
|
+
window._aspClient = FuzionXClient.new_with_options(
|
|
38
|
+
masterSecret,
|
|
39
|
+
headerSignal || 'Ruxy-Enc-Mode',
|
|
40
|
+
);
|
|
41
|
+
console.log('[FxClient] WASM ASP client ready');
|
|
42
|
+
} catch (e) {
|
|
43
|
+
console.warn('[FxClient] WASM init failed:', e);
|
|
44
|
+
}
|
|
45
|
+
})();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* 공개 페이지용 — masterSecret 직접 사용
|
|
50
|
+
*/
|
|
51
|
+
function initPublic(masterSecret, headerSignal) {
|
|
52
|
+
window.aspEnabled = !!masterSecret;
|
|
53
|
+
if (masterSecret) window._aspMasterSecret = masterSecret;
|
|
54
|
+
|
|
55
|
+
window._aspReady = (async () => {
|
|
56
|
+
try {
|
|
57
|
+
const _v = Date.now();
|
|
58
|
+
const mod = await import(`/wasm/fuzionx_client_wasm.js?v=${_v}`);
|
|
59
|
+
await mod.default({ module_or_path: `/wasm/fuzionx_client_wasm_bg.wasm?v=${_v}` });
|
|
60
|
+
const { FuzionXClient, FuzionXSocket } = mod;
|
|
61
|
+
window.FuzionXSocket = FuzionXSocket;
|
|
62
|
+
|
|
63
|
+
if (masterSecret) {
|
|
64
|
+
window._aspClient = FuzionXClient.new_with_options(
|
|
65
|
+
masterSecret,
|
|
66
|
+
headerSignal || 'Ruxy-Enc-Mode',
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
console.log('[FxClient] WASM client ready (public)');
|
|
70
|
+
} catch (e) {
|
|
71
|
+
console.warn('[FxClient] WASM init failed:', e);
|
|
72
|
+
}
|
|
73
|
+
})();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* ASP 암호화 fetch — create-fuzionx tester aspFetch 동일 패턴
|
|
78
|
+
*/
|
|
79
|
+
window.aspFetch = async function (url, opts = {}) {
|
|
80
|
+
if (window._aspReady) await window._aspReady;
|
|
81
|
+
const method = (opts.method || 'GET').toUpperCase();
|
|
82
|
+
if (!url.startsWith('/api') || !window._aspClient) return fetch(url, opts);
|
|
83
|
+
|
|
84
|
+
if (opts.body instanceof FormData) {
|
|
85
|
+
return window._aspClient.upload(url, opts.body);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
let bodyObj = undefined;
|
|
89
|
+
if (opts.body) {
|
|
90
|
+
bodyObj = typeof opts.body === 'string' ? JSON.parse(opts.body) : opts.body;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
switch (method) {
|
|
94
|
+
case 'GET': return window._aspClient.get(url, window.__FX__ENC_ENABLED);
|
|
95
|
+
case 'POST': return window._aspClient.post(url, bodyObj, window.__FX__ENC_ENABLED);
|
|
96
|
+
case 'PUT': return window._aspClient.put(url, bodyObj, window.__FX__ENC_ENABLED);
|
|
97
|
+
case 'PATCH': return window._aspClient.patch(url, bodyObj, window.__FX__ENC_ENABLED);
|
|
98
|
+
case 'DELETE': return window._aspClient.delete(url, window.__FX__ENC_ENABLED);
|
|
99
|
+
default: return window._aspClient.get(url, window.__FX__ENC_ENABLED);
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
// 글로벌 노출
|
|
104
|
+
window.FxClient = { init, initPublic };
|
|
105
|
+
window.aspEnabled = window.__FX__ENC_ENABLED;
|
|
106
|
+
window._aspReady = Promise.resolve();
|
|
107
|
+
})();
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FuzionX SSR Toast & Alert Utilities
|
|
3
|
+
*
|
|
4
|
+
* SSR 템플릿에서 사용하는 바닐라 JS toast / alert.
|
|
5
|
+
* main.html 레이아웃에 include하면 전역 사용 가능.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* fxToast.success('저장되었습니다!');
|
|
9
|
+
* fxToast.error('오류가 발생했습니다.');
|
|
10
|
+
* fxAlert.confirm('삭제', '정말 삭제하시겠습니까?').then(ok => { ... });
|
|
11
|
+
*/
|
|
12
|
+
(function() {
|
|
13
|
+
'use strict';
|
|
14
|
+
|
|
15
|
+
// ── Toast ──────────────────────
|
|
16
|
+
var container = null;
|
|
17
|
+
var _id = 0;
|
|
18
|
+
var icons = { success: '✓', error: '✕', warning: '⚠', info: 'ℹ' };
|
|
19
|
+
|
|
20
|
+
function getContainer() {
|
|
21
|
+
if (!container) {
|
|
22
|
+
container = document.createElement('div');
|
|
23
|
+
container.className = 'fx-toast-container';
|
|
24
|
+
document.body.appendChild(container);
|
|
25
|
+
}
|
|
26
|
+
return container;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function showToast(opts) {
|
|
30
|
+
var config = typeof opts === 'string' ? { message: opts } : opts;
|
|
31
|
+
var type = config.type || 'info';
|
|
32
|
+
var duration = config.duration !== undefined ? config.duration : 3000;
|
|
33
|
+
var id = ++_id;
|
|
34
|
+
|
|
35
|
+
var el = document.createElement('div');
|
|
36
|
+
el.className = 'fx-toast fx-toast-' + type;
|
|
37
|
+
el.setAttribute('data-toast-id', id);
|
|
38
|
+
el.innerHTML =
|
|
39
|
+
'<span class="fx-toast-icon">' + (icons[type] || icons.info) + '</span>' +
|
|
40
|
+
'<span class="fx-toast-msg">' + (config.message || '') + '</span>' +
|
|
41
|
+
'<button class="fx-toast-close">×</button>';
|
|
42
|
+
el.style.animation = 'toastIn 0.3s ease-out';
|
|
43
|
+
|
|
44
|
+
el.querySelector('.fx-toast-close').onclick = function(e) {
|
|
45
|
+
e.stopPropagation();
|
|
46
|
+
dismissToast(el);
|
|
47
|
+
};
|
|
48
|
+
el.onclick = function() { dismissToast(el); };
|
|
49
|
+
|
|
50
|
+
getContainer().appendChild(el);
|
|
51
|
+
|
|
52
|
+
if (duration > 0) {
|
|
53
|
+
setTimeout(function() { dismissToast(el); }, duration);
|
|
54
|
+
}
|
|
55
|
+
return id;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function dismissToast(el) {
|
|
59
|
+
if (!el || !el.parentNode) return;
|
|
60
|
+
el.style.animation = 'toastOut 0.25s ease-in forwards';
|
|
61
|
+
setTimeout(function() { if (el.parentNode) el.parentNode.removeChild(el); }, 250);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
window.fxToast = {
|
|
65
|
+
show: showToast,
|
|
66
|
+
success: function(msg, dur) { return showToast({ message: msg, type: 'success', duration: dur }); },
|
|
67
|
+
error: function(msg, dur) { return showToast({ message: msg, type: 'error', duration: dur }); },
|
|
68
|
+
info: function(msg, dur) { return showToast({ message: msg, type: 'info', duration: dur }); },
|
|
69
|
+
warning: function(msg, dur) { return showToast({ message: msg, type: 'warning', duration: dur }); },
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
// ── Alert ──────────────────────
|
|
73
|
+
function showAlert(title, message, opts) {
|
|
74
|
+
opts = opts || {};
|
|
75
|
+
return new Promise(function(resolve) {
|
|
76
|
+
var showCancel = opts.showCancel || false;
|
|
77
|
+
var type = opts.type || 'info';
|
|
78
|
+
var okText = opts.ok || '확인';
|
|
79
|
+
var cancelText = opts.cancel || '취소';
|
|
80
|
+
|
|
81
|
+
var backdrop = document.createElement('div');
|
|
82
|
+
backdrop.className = 'fx-alert-backdrop';
|
|
83
|
+
backdrop.style.animation = 'alertIn 0.25s ease-out';
|
|
84
|
+
|
|
85
|
+
var iconHtml = icons[type] || icons.info;
|
|
86
|
+
|
|
87
|
+
backdrop.innerHTML =
|
|
88
|
+
'<div class="fx-alert-dialog fx-alert-' + type + '">' +
|
|
89
|
+
(title ? '<div class="fx-alert-header"><span class="fx-alert-icon">' + iconHtml + '</span><h3 class="fx-alert-title">' + title + '</h3></div>' : '') +
|
|
90
|
+
(message ? '<p class="fx-alert-message">' + message + '</p>' : '') +
|
|
91
|
+
'<div class="fx-alert-actions">' +
|
|
92
|
+
(showCancel ? '<button class="fx-alert-btn fx-alert-cancel">' + cancelText + '</button>' : '') +
|
|
93
|
+
'<button class="fx-alert-btn fx-alert-ok">' + okText + '</button>' +
|
|
94
|
+
'</div>' +
|
|
95
|
+
'</div>';
|
|
96
|
+
|
|
97
|
+
function close(result) {
|
|
98
|
+
backdrop.style.animation = 'alertOut 0.2s ease-in forwards';
|
|
99
|
+
setTimeout(function() { if (backdrop.parentNode) backdrop.parentNode.removeChild(backdrop); }, 200);
|
|
100
|
+
resolve(result);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
backdrop.querySelector('.fx-alert-ok').onclick = function() { close(true); };
|
|
104
|
+
if (showCancel) {
|
|
105
|
+
backdrop.querySelector('.fx-alert-cancel').onclick = function() { close(false); };
|
|
106
|
+
}
|
|
107
|
+
backdrop.onclick = function(e) {
|
|
108
|
+
if (e.target === backdrop && showCancel) close(false);
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
document.body.appendChild(backdrop);
|
|
112
|
+
backdrop.querySelector('.fx-alert-ok').focus();
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
window.fxAlert = {
|
|
117
|
+
show: function(title, message, opts) {
|
|
118
|
+
return showAlert(title, message, Object.assign({ showCancel: false }, opts));
|
|
119
|
+
},
|
|
120
|
+
confirm: function(title, message, opts) {
|
|
121
|
+
return showAlert(title, message, Object.assign({ showCancel: true, type: 'confirm' }, opts));
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
})();
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { auth } from '@fuzionx/framework';
|
|
2
|
+
import HomeController from '../controllers/HomeController.js';
|
|
3
|
+
import FeaturesController from '../controllers/FeaturesController.js';
|
|
4
|
+
import ChatController from '../controllers/ChatController.js';
|
|
5
|
+
import AuthController from '../controllers/AuthController.js';
|
|
6
|
+
import UserController from '../controllers/UserController.js';
|
|
7
|
+
import PostController from '../controllers/PostController.js';
|
|
8
|
+
|
|
9
|
+
export default (r) => {
|
|
10
|
+
// ── 공개 페이지 (인증 불필요) ──
|
|
11
|
+
r.get('/', HomeController.index);
|
|
12
|
+
r.get('/features', FeaturesController.index);
|
|
13
|
+
|
|
14
|
+
// ── 인증 ──
|
|
15
|
+
r.get('/login', AuthController.loginPage);
|
|
16
|
+
r.post('/login', AuthController.login);
|
|
17
|
+
r.get('/register', AuthController.registerPage);
|
|
18
|
+
r.post('/register', AuthController.register);
|
|
19
|
+
r.post('/logout', AuthController.logout);
|
|
20
|
+
|
|
21
|
+
// ── 하트비트 (세션 연장) ──
|
|
22
|
+
r.get('/api/heartbeat', AuthController.heartbeat);
|
|
23
|
+
|
|
24
|
+
// ── 인증 필요 (built-in auth 미들웨어) ──
|
|
25
|
+
r.group('', { middleware: [auth({ redirectTo: '/login' })] }, (r) => {
|
|
26
|
+
// 채팅
|
|
27
|
+
r.get('/chat', ChatController.index);
|
|
28
|
+
|
|
29
|
+
// 프로필
|
|
30
|
+
r.get('/profile', UserController.profile);
|
|
31
|
+
r.post('/profile', UserController.updateProfile);
|
|
32
|
+
|
|
33
|
+
// 게시판
|
|
34
|
+
r.get('/board', PostController.index);
|
|
35
|
+
r.get('/board/new', PostController.create);
|
|
36
|
+
r.post('/board', PostController.store);
|
|
37
|
+
r.get('/board/:id', PostController.show);
|
|
38
|
+
r.get('/board/:id/edit', PostController.edit);
|
|
39
|
+
r.post('/board/:id', PostController.update);
|
|
40
|
+
r.post('/board/:id/delete', PostController.destroy);
|
|
41
|
+
r.post('/board/attachment/:id/delete', PostController.deleteAttachment);
|
|
42
|
+
|
|
43
|
+
// 게시글 상태 API (REST 폴링용)
|
|
44
|
+
r.get('/api/board/status', PostController.status);
|
|
45
|
+
});
|
|
46
|
+
};
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { Service } from '@fuzionx/framework';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* AuthService — SSR 인증 서비스
|
|
5
|
+
*
|
|
6
|
+
* 회원가입/로그인 비즈니스 로직 처리.
|
|
7
|
+
* bcrypt 해시를 사용한 비밀번호 암호화 및 검증.
|
|
8
|
+
*
|
|
9
|
+
* @extends Service
|
|
10
|
+
*/
|
|
11
|
+
export default class AuthService extends Service {
|
|
12
|
+
/**
|
|
13
|
+
* 회원가입 — 신규 사용자 생성
|
|
14
|
+
*
|
|
15
|
+
* 이메일 중복 검사 후 bcrypt 해시 비밀번호로 사용자 생성.
|
|
16
|
+
*
|
|
17
|
+
* @param {Object} data - 가입 정보
|
|
18
|
+
* @param {string} data.name - 사용자 이름
|
|
19
|
+
* @param {string} data.email - 이메일 주소
|
|
20
|
+
* @param {string} data.password - 비밀번호 (평문 → bcrypt 해시 변환)
|
|
21
|
+
* @returns {Promise<import('../../database/models/User.js').default>} 생성된 사용자
|
|
22
|
+
* @throws {AppError} 409 — 이메일 중복 시
|
|
23
|
+
*/
|
|
24
|
+
async register({ name, email, password }) {
|
|
25
|
+
const existing = await this.db.User.where('email', email).first();
|
|
26
|
+
if (existing) throw this.error('auth.email_exists', 409);
|
|
27
|
+
|
|
28
|
+
const hash = this.app.hash.bcrypt(password);
|
|
29
|
+
return this.db.User.create({ name, email, password: hash, role: 'user' });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* 로그인 — 이메일/비밀번호 검증
|
|
34
|
+
*
|
|
35
|
+
* 이메일로 사용자 조회 후 bcrypt 비밀번호 대조.
|
|
36
|
+
*
|
|
37
|
+
* @param {Object} credentials - 로그인 정보
|
|
38
|
+
* @param {string} credentials.email - 이메일 주소
|
|
39
|
+
* @param {string} credentials.password - 비밀번호 (평문)
|
|
40
|
+
* @returns {Promise<import('../../database/models/User.js').default|null>} 인증된 사용자 또는 null
|
|
41
|
+
*/
|
|
42
|
+
async login({ email, password }) {
|
|
43
|
+
const user = await this.db.User.where('email', email).first();
|
|
44
|
+
if (!user) return null;
|
|
45
|
+
if (!this.app.hash.bcryptVerify(password, user.password)) return null;
|
|
46
|
+
return user;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
import { Service } from '@fuzionx/framework';
|
|
2
|
+
import { promises as fs } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* PostService — SSR 게시글 + 첨부파일 + 썸네일 서비스
|
|
7
|
+
*
|
|
8
|
+
* ## 주요 기능
|
|
9
|
+
* - 게시글 CRUD (list, find, create, update, remove)
|
|
10
|
+
* - 첨부파일 저장/삭제 (Storage API)
|
|
11
|
+
* - 썸네일 자동 생성 (이미지: resize, 비디오: frame 추출)
|
|
12
|
+
*
|
|
13
|
+
* ## 썸네일 생성 전략
|
|
14
|
+
* - **이미지 파일**: `app.media.resize()` → 400x300 webp 썸네일
|
|
15
|
+
* - **비디오 파일**: `app.media.videoThumbnail()` → 3초 지점 프레임 추출 → jpeg
|
|
16
|
+
* - 썸네일은 `storage/uploads/thumbs/{attachmentId}.webp` 에 저장
|
|
17
|
+
* - Thumbnail 모델에 메타데이터(경로, 크기, 포맷, source_type) 기록
|
|
18
|
+
*
|
|
19
|
+
* ## 사용 예시
|
|
20
|
+
* ```js
|
|
21
|
+
* // 컨트롤러에서
|
|
22
|
+
* const post = await this.service('PostService').create(
|
|
23
|
+
* { title: '제목', content: '내용', user_id: ctx.user.id },
|
|
24
|
+
* ctx.files, // Bridge 업로드 파일 배열
|
|
25
|
+
* );
|
|
26
|
+
*
|
|
27
|
+
* // 첨부파일 + 썸네일 조회
|
|
28
|
+
* const files = await this.service('PostService').getAttachmentsWithThumbs(post.id);
|
|
29
|
+
* // → [{ ...attachment, thumbUrl, isImage, isVideo }]
|
|
30
|
+
* ```
|
|
31
|
+
*
|
|
32
|
+
* @extends Service
|
|
33
|
+
*/
|
|
34
|
+
export default class PostService extends Service {
|
|
35
|
+
|
|
36
|
+
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
37
|
+
// 게시글 CRUD
|
|
38
|
+
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* 게시글 목록 조회 (페이지네이션)
|
|
42
|
+
*
|
|
43
|
+
* @param {number} [page=1] - 현재 페이지 번호
|
|
44
|
+
* @param {number} [perPage=10] - 페이지당 게시글 수
|
|
45
|
+
* @returns {Promise<{ data: Post[], page, lastPage, hasMore, total }>}
|
|
46
|
+
*/
|
|
47
|
+
async list(page = 1, perPage = 10) {
|
|
48
|
+
return this.db.Post.query()
|
|
49
|
+
.orderBy('created_at', 'desc')
|
|
50
|
+
.paginate(page, perPage);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* 게시글 단건 조회
|
|
55
|
+
*
|
|
56
|
+
* @param {number} id - 게시글 ID
|
|
57
|
+
* @returns {Promise<Post|null>}
|
|
58
|
+
*/
|
|
59
|
+
async find(id) {
|
|
60
|
+
return this.db.Post.find(id);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* 게시글 생성 + 첨부파일 + 썸네일
|
|
65
|
+
*
|
|
66
|
+
* @param {Object} data - { title, content, user_id }
|
|
67
|
+
* @param {Array} [files=[]] - Bridge 업로드 파일 배열 [{fieldName, originalName, mimeType, size, tempPath}]
|
|
68
|
+
* @returns {Promise<Post>}
|
|
69
|
+
*
|
|
70
|
+
* @example
|
|
71
|
+
* const post = await postService.create(
|
|
72
|
+
* { title: 'Hello', content: 'World', user_id: 1 },
|
|
73
|
+
* ctx.files,
|
|
74
|
+
* );
|
|
75
|
+
*/
|
|
76
|
+
async create(data, files = []) {
|
|
77
|
+
// 비디오 포함 여부에 따라 초기 status 결정
|
|
78
|
+
const hasVideo = files?.some(f => f.mimeType?.startsWith('video/'));
|
|
79
|
+
data.status = hasVideo ? 'processing' : 'published';
|
|
80
|
+
const post = await this.db.Post.create(data);
|
|
81
|
+
if (files?.length) {
|
|
82
|
+
await this._processFiles(post.id, files);
|
|
83
|
+
}
|
|
84
|
+
return post;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* 게시글 수정 + 새 첨부파일 추가
|
|
89
|
+
*
|
|
90
|
+
* @param {number} id - 게시글 ID
|
|
91
|
+
* @param {Object} data - { title, content }
|
|
92
|
+
* @param {Array} [files=[]] - 새로 추가할 파일
|
|
93
|
+
* @returns {Promise<Post>}
|
|
94
|
+
*/
|
|
95
|
+
async update(id, data, files = []) {
|
|
96
|
+
const post = await this.db.Post.find(id);
|
|
97
|
+
if (!post) throw this.error('Post not found', 404);
|
|
98
|
+
await post.update(data);
|
|
99
|
+
if (files?.length) {
|
|
100
|
+
const hasVideo = files.some(f => f.mimeType?.startsWith('video/'));
|
|
101
|
+
if (hasVideo) await post.update({ status: 'processing' });
|
|
102
|
+
await this._processFiles(post.id, files);
|
|
103
|
+
}
|
|
104
|
+
return post;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* 게시글 삭제 + 모든 첨부파일/썸네일 삭제
|
|
109
|
+
*
|
|
110
|
+
* 삭제 순서: 썸네일 → 첨부파일(Storage + DB) → 게시글
|
|
111
|
+
*
|
|
112
|
+
* @param {number} id - 게시글 ID
|
|
113
|
+
* @returns {Promise<void>}
|
|
114
|
+
*/
|
|
115
|
+
async remove(id) {
|
|
116
|
+
const post = await this.db.Post.find(id);
|
|
117
|
+
if (!post) throw this.error('Post not found', 404);
|
|
118
|
+
await this._deleteAllFiles(id);
|
|
119
|
+
await post.delete();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
123
|
+
// 첨부파일 + 썸네일 조회
|
|
124
|
+
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* 게시글의 첨부파일 목록 + 썸네일 URL 포함
|
|
128
|
+
*
|
|
129
|
+
* 반환 객체에 추가되는 필드:
|
|
130
|
+
* - `url` : 원본 파일 공개 URL
|
|
131
|
+
* - `thumbUrl` : 썸네일 URL (없으면 null)
|
|
132
|
+
* - `isImage` : MIME이 image/* 인지 여부
|
|
133
|
+
* - `isVideo` : MIME이 video/* 인지 여부
|
|
134
|
+
*
|
|
135
|
+
* @param {number} postId - 게시글 ID
|
|
136
|
+
* @returns {Promise<Array<Object>>}
|
|
137
|
+
*
|
|
138
|
+
* @example
|
|
139
|
+
* const files = await postService.getAttachmentsWithThumbs(postId);
|
|
140
|
+
* // → [{ id, original_name, url, thumbUrl, isImage, isVideo, size, mime_type }]
|
|
141
|
+
*/
|
|
142
|
+
async getAttachmentsWithThumbs(postId) {
|
|
143
|
+
const attachments = await this.db.Attachment.where('post_id', postId).get();
|
|
144
|
+
if (!attachments.length) return [];
|
|
145
|
+
|
|
146
|
+
// 모든 썸네일을 한 번에 조회 (N+1 방지)
|
|
147
|
+
const attIds = attachments.map(a => a.id);
|
|
148
|
+
const thumbnails = await this.db.Thumbnail.query()
|
|
149
|
+
.whereIn('attachment_id', attIds)
|
|
150
|
+
.get();
|
|
151
|
+
|
|
152
|
+
// attachment_id → thumbnail 매핑
|
|
153
|
+
const thumbMap = new Map();
|
|
154
|
+
for (const t of thumbnails) {
|
|
155
|
+
thumbMap.set(t.attachment_id, t);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return attachments.map(att => {
|
|
159
|
+
const thumb = thumbMap.get(att.id);
|
|
160
|
+
return {
|
|
161
|
+
...att,
|
|
162
|
+
url: this.app.storage.url(att.file_path),
|
|
163
|
+
thumbUrl: thumb ? this.app.storage.url(thumb.file_path) : null,
|
|
164
|
+
isImage: att.mime_type?.startsWith('image/'),
|
|
165
|
+
isVideo: att.mime_type?.startsWith('video/'),
|
|
166
|
+
};
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* 게시글 목록용 — 각 게시글의 대표 썸네일 1개 조회
|
|
172
|
+
*
|
|
173
|
+
* 첫 번째 첨부파일의 썸네일을 대표 이미지로 사용.
|
|
174
|
+
* 게시글 목록 페이지에서 미리보기 이미지 제공용.
|
|
175
|
+
*
|
|
176
|
+
* @param {number[]} postIds - 게시글 ID 배열
|
|
177
|
+
* @returns {Promise<Map<number, string|null>>} postId → thumbUrl
|
|
178
|
+
*
|
|
179
|
+
* @example
|
|
180
|
+
* const thumbMap = await postService.getPostThumbnails([1, 2, 3]);
|
|
181
|
+
* // → Map { 1 => '/storage/uploads/thumbs/5.webp', 2 => null, 3 => '...' }
|
|
182
|
+
*/
|
|
183
|
+
async getPostThumbnails(postIds) {
|
|
184
|
+
if (!postIds.length) return new Map();
|
|
185
|
+
|
|
186
|
+
// 각 게시글의 첫 첨부파일 조회
|
|
187
|
+
const attachments = await this.db.Attachment.query()
|
|
188
|
+
.whereIn('post_id', postIds)
|
|
189
|
+
.orderBy('id', 'asc')
|
|
190
|
+
.get();
|
|
191
|
+
|
|
192
|
+
// postId → 첫 attachment 매핑
|
|
193
|
+
const firstAttMap = new Map();
|
|
194
|
+
for (const att of attachments) {
|
|
195
|
+
if (!firstAttMap.has(att.post_id)) {
|
|
196
|
+
firstAttMap.set(att.post_id, att);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// 첫 첨부파일들의 썸네일 조회
|
|
201
|
+
const attIds = [...firstAttMap.values()].map(a => a.id);
|
|
202
|
+
if (!attIds.length) return new Map();
|
|
203
|
+
|
|
204
|
+
const thumbnails = await this.db.Thumbnail.query()
|
|
205
|
+
.whereIn('attachment_id', attIds)
|
|
206
|
+
.get();
|
|
207
|
+
|
|
208
|
+
const thumbByAtt = new Map();
|
|
209
|
+
for (const t of thumbnails) {
|
|
210
|
+
thumbByAtt.set(t.attachment_id, t);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// postId → thumbUrl 매핑
|
|
214
|
+
const result = new Map();
|
|
215
|
+
for (const pid of postIds) {
|
|
216
|
+
const att = firstAttMap.get(pid);
|
|
217
|
+
if (att) {
|
|
218
|
+
const thumb = thumbByAtt.get(att.id);
|
|
219
|
+
result.set(pid, thumb ? this.app.storage.url(thumb.file_path) : null);
|
|
220
|
+
} else {
|
|
221
|
+
result.set(pid, null);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return result;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* 특정 첨부파일 삭제 (썸네일 포함)
|
|
229
|
+
*
|
|
230
|
+
* @param {number} attachmentId - 첨부파일 ID
|
|
231
|
+
* @returns {Promise<void>}
|
|
232
|
+
*/
|
|
233
|
+
async removeAttachment(attachmentId) {
|
|
234
|
+
const att = await this.db.Attachment.find(attachmentId);
|
|
235
|
+
if (!att) return;
|
|
236
|
+
|
|
237
|
+
// 썸네일 삭제
|
|
238
|
+
await this._deleteThumbnail(attachmentId);
|
|
239
|
+
|
|
240
|
+
// 원본 파일 삭제
|
|
241
|
+
try { await this.app.storage.delete(att.file_path); } catch {}
|
|
242
|
+
await att.delete();
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
246
|
+
// Private — 파일 처리 + 썸네일 생성
|
|
247
|
+
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* 파일 저장 + 썸네일 생성 (이미지/비디오 자동 감지)
|
|
251
|
+
*
|
|
252
|
+
* 처리 흐름:
|
|
253
|
+
* 1. 임시파일 → Storage 영구 저장 (`uploads/posts/{postId}/xxx.ext`)
|
|
254
|
+
* 2. Attachment DB 레코드 생성
|
|
255
|
+
* 3. 이미지인 경우 → `app.media.resize()` 로 썸네일 생성
|
|
256
|
+
* 4. 비디오인 경우 → `app.media.videoThumbnail()` 로 프레임 추출
|
|
257
|
+
* 5. Thumbnail DB 레코드 생성
|
|
258
|
+
*
|
|
259
|
+
* @private
|
|
260
|
+
* @param {number} postId - 게시글 ID
|
|
261
|
+
* @param {Array} files - [{fieldName, originalName, mimeType, size, tempPath}]
|
|
262
|
+
*/
|
|
263
|
+
async _processFiles(postId, files) {
|
|
264
|
+
for (const f of files) {
|
|
265
|
+
// 1. 원본 파일 저장
|
|
266
|
+
const ext = f.originalName.split('.').pop() || 'bin';
|
|
267
|
+
const uid = `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
|
268
|
+
const storagePath = `uploads/posts/${postId}/${uid}.${ext}`;
|
|
269
|
+
await this.app.storage.put(storagePath, f.tempPath);
|
|
270
|
+
|
|
271
|
+
// 2. Attachment 레코드
|
|
272
|
+
const att = await this.db.Attachment.create({
|
|
273
|
+
post_id: postId,
|
|
274
|
+
original_name: f.originalName,
|
|
275
|
+
file_path: storagePath,
|
|
276
|
+
mime_type: f.mimeType,
|
|
277
|
+
size: f.size,
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
// 3. 썸네일 생성 (이미지 or 비디오)
|
|
281
|
+
const isImage = f.mimeType?.startsWith('image/');
|
|
282
|
+
const isVideo = f.mimeType?.startsWith('video/');
|
|
283
|
+
|
|
284
|
+
if (isImage) {
|
|
285
|
+
await this._generateImageThumbnail(att, storagePath);
|
|
286
|
+
} else if (isVideo) {
|
|
287
|
+
// 비디오 썸네일은 백그라운드 Task로 위임
|
|
288
|
+
this.app.dispatch('ProcessVideoThumbnailTask', {
|
|
289
|
+
postId,
|
|
290
|
+
attachmentId: att.id,
|
|
291
|
+
storagePath,
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* 이미지 썸네일 생성
|
|
299
|
+
*
|
|
300
|
+
* `app.media.resize()` 호출:
|
|
301
|
+
* - 입력: Storage 원본 이미지
|
|
302
|
+
* - 출력: 400px 가로 webp (비율 유지, 높이 자동)
|
|
303
|
+
* - 품질: 80
|
|
304
|
+
*
|
|
305
|
+
* @private
|
|
306
|
+
* @param {Object} att - Attachment 레코드
|
|
307
|
+
* @param {string} storagePath - 원본 Storage 경로
|
|
308
|
+
*/
|
|
309
|
+
async _generateImageThumbnail(att, storagePath) {
|
|
310
|
+
try {
|
|
311
|
+
const fullPath = path.join(this.app.storage.basePath, storagePath);
|
|
312
|
+
const thumbName = `${att.id}.webp`;
|
|
313
|
+
const thumbStoragePath = `uploads/thumbs/${thumbName}`;
|
|
314
|
+
const thumbFullPath = path.join(this.app.storage.basePath, thumbStoragePath);
|
|
315
|
+
|
|
316
|
+
// 썸네일 디렉토리 생성
|
|
317
|
+
await fs.mkdir(path.dirname(thumbFullPath), { recursive: true });
|
|
318
|
+
|
|
319
|
+
// Bridge 이미지 리사이즈 (Rust native — width:400, height:0 = 비율 유지)
|
|
320
|
+
this.app.media.resize(fullPath, thumbFullPath, 400, 0, 'webp', 80);
|
|
321
|
+
|
|
322
|
+
// 썸네일 파일 정보
|
|
323
|
+
const stat = await fs.stat(thumbFullPath);
|
|
324
|
+
let info = { width: 400, height: 0 };
|
|
325
|
+
try { info = this.app.media.imageInfo(thumbFullPath); } catch {}
|
|
326
|
+
|
|
327
|
+
// Thumbnail DB 레코드
|
|
328
|
+
await this.db.Thumbnail.create({
|
|
329
|
+
attachment_id: att.id,
|
|
330
|
+
file_path: thumbStoragePath,
|
|
331
|
+
width: info.width || 400,
|
|
332
|
+
height: info.height || 0,
|
|
333
|
+
format: 'webp',
|
|
334
|
+
size: stat.size,
|
|
335
|
+
source_type: 'image',
|
|
336
|
+
});
|
|
337
|
+
} catch (err) {
|
|
338
|
+
console.warn(`[PostService] 이미지 썸네일 생성 실패 (att:${att.id}):`, err.message);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* 특정 첨부파일의 썸네일 삭제 (Storage + DB)
|
|
344
|
+
*
|
|
345
|
+
* @private
|
|
346
|
+
* @param {number} attachmentId
|
|
347
|
+
*/
|
|
348
|
+
async _deleteThumbnail(attachmentId) {
|
|
349
|
+
const thumbs = await this.db.Thumbnail.where('attachment_id', attachmentId).get();
|
|
350
|
+
for (const t of thumbs) {
|
|
351
|
+
try { await this.app.storage.delete(t.file_path); } catch {}
|
|
352
|
+
await t.delete();
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* 게시글의 모든 첨부파일 + 썸네일 삭제
|
|
358
|
+
*
|
|
359
|
+
* @private
|
|
360
|
+
* @param {number} postId
|
|
361
|
+
*/
|
|
362
|
+
async _deleteAllFiles(postId) {
|
|
363
|
+
const attachments = await this.db.Attachment.where('post_id', postId).get();
|
|
364
|
+
for (const att of attachments) {
|
|
365
|
+
// 썸네일 먼저 삭제
|
|
366
|
+
await this._deleteThumbnail(att.id);
|
|
367
|
+
// 원본 파일 삭제
|
|
368
|
+
try { await this.app.storage.delete(att.file_path); } catch {}
|
|
369
|
+
await att.delete();
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}
|