diff --git a/docker-compose.yml b/docker-compose.yml index ab8d5ab..ee7fb1c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -107,6 +107,10 @@ services: - VAPID_PRIVATE_KEY=${VAPID_PRIVATE_KEY} - VAPID_SUBJECT=${VAPID_SUBJECT:-mailto:admin@technicalkorea.net} - INTERNAL_SERVICE_KEY=${INTERNAL_SERVICE_KEY} + - NTFY_BASE_URL=${NTFY_BASE_URL:-http://ntfy:80} + - NTFY_PUBLISH_TOKEN=${NTFY_PUBLISH_TOKEN} + - NTFY_EXTERNAL_URL=${NTFY_EXTERNAL_URL:-https://ntfy.technicalkorea.net} + - TKFB_BASE_URL=${TKFB_BASE_URL:-https://tkfb.technicalkorea.net} volumes: - system1_uploads:/usr/src/app/uploads - system1_logs:/usr/src/app/logs diff --git a/gateway/html/shared/notification-bell.js b/gateway/html/shared/notification-bell.js index 2967653..c0e3ec8 100644 --- a/gateway/html/shared/notification-bell.js +++ b/gateway/html/shared/notification-bell.js @@ -44,6 +44,7 @@ var unreadCount = 0; var dropdownOpen = false; var pushSubscribed = false; + var ntfySubscribed = false; /* ========== UI: Bell injection ========== */ function injectBell() { @@ -67,7 +68,8 @@ '
' + '알림' + '
' + - '' + + '' + + '' + '' + '
' + '
' + @@ -97,6 +99,7 @@ document.getElementById('notif-bell-btn').addEventListener('click', toggleDropdown); document.getElementById('notif-read-all').addEventListener('click', markAllRead); document.getElementById('notif-push-toggle').addEventListener('click', handlePushToggle); + document.getElementById('notif-ntfy-toggle').addEventListener('click', handleNtfyToggle); // 외부 클릭 시 닫기 document.addEventListener('click', function (e) { @@ -161,6 +164,7 @@ window.addEventListener('scroll', onScrollWhileOpen, { once: true }); loadNotifications(); updatePushToggleUI(); + updateNtfyToggleUI(); } function closeDropdown() { @@ -356,17 +360,134 @@ function updatePushToggleUI() { var btn = document.getElementById('notif-push-toggle'); if (!btn) return; - if (pushSubscribed) { - btn.textContent = '🔕 Push 해제'; + if (ntfySubscribed) { + // ntfy 활성화 시 Web Push 비활성화 + btn.textContent = 'Push'; + btn.style.borderColor = '#E5E7EB'; + btn.style.color = '#D1D5DB'; + btn.disabled = true; + btn.title = 'ntfy 사용 중에는 Push를 사용할 수 없습니다'; + } else if (pushSubscribed) { + btn.textContent = 'Push 해제'; + btn.style.borderColor = '#EF4444'; + btn.style.color = '#EF4444'; + btn.disabled = false; + btn.title = 'Push 알림 해제'; + } else { + btn.textContent = 'Push'; + btn.style.borderColor = '#D1D5DB'; + btn.style.color = '#6B7280'; + btn.disabled = false; + btn.title = 'Push 알림 설정'; + } + } + + /* ========== ntfy ========== */ + function handleNtfyToggle(e) { + e && e.stopPropagation(); + if (ntfySubscribed) { + unsubscribeNtfy(); + } else { + subscribeNtfy(); + } + } + + function subscribeNtfy() { + _authFetch(PUSH_API_BASE + '/ntfy/subscribe', { method: 'POST' }) + .then(function (r) { return r.json(); }) + .then(function (data) { + if (!data.success) { + alert('ntfy 구독 등록 실패'); + return; + } + ntfySubscribed = true; + updateNtfyToggleUI(); + updatePushToggleUI(); + showNtfySetupModal(data.data); + }) + .catch(function (err) { + console.error('[notification-bell] ntfy subscribe error:', err); + }); + } + + function unsubscribeNtfy() { + _authFetch(PUSH_API_BASE + '/ntfy/unsubscribe', { method: 'DELETE' }) + .then(function (r) { return r.json(); }) + .then(function () { + ntfySubscribed = false; + updateNtfyToggleUI(); + updatePushToggleUI(); + checkPushStatus(); + }) + .catch(function (err) { + console.error('[notification-bell] ntfy unsubscribe error:', err); + }); + } + + function checkNtfyStatus() { + _authFetch(PUSH_API_BASE + '/ntfy/status') + .then(function (r) { return r.json(); }) + .then(function (data) { + if (data.success && data.data.subscribed) { + ntfySubscribed = true; + updateNtfyToggleUI(); + updatePushToggleUI(); + } + }) + .catch(function () {}); + } + + function updateNtfyToggleUI() { + var btn = document.getElementById('notif-ntfy-toggle'); + if (!btn) return; + if (ntfySubscribed) { + btn.textContent = 'ntfy 해제'; btn.style.borderColor = '#EF4444'; btn.style.color = '#EF4444'; } else { - btn.textContent = '🔔 Push'; + btn.textContent = 'ntfy'; btn.style.borderColor = '#D1D5DB'; btn.style.color = '#6B7280'; } } + function showNtfySetupModal(info) { + // 기존 모달 제거 + var existing = document.getElementById('ntfy-setup-modal'); + if (existing) existing.remove(); + + var overlay = document.createElement('div'); + overlay.id = 'ntfy-setup-modal'; + overlay.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,.5);z-index:10000;display:flex;align-items:center;justify-content:center;'; + + var modal = document.createElement('div'); + modal.style.cssText = 'background:#fff;border-radius:12px;padding:24px;max-width:380px;width:90%;box-shadow:0 20px 40px rgba(0,0,0,.2);'; + modal.innerHTML = + '

ntfy 앱 설정 안내

' + + '
' + + '

1. ntfy 앱 설치
' + + 'Android: Play Store / iOS: App Store

' + + '

2. 앱에서 서버 추가
' + + '' + _escHtml(info.serverUrl) + '

' + + '

3. 계정 로그인
' + + '아이디: ' + _escHtml(info.username) + '
' + + '비밀번호: ' + _escHtml(info.password) + '

' + + '

4. 토픽 구독
' + + '' + _escHtml(info.topic) + '

' + + '
' + + ''; + + overlay.appendChild(modal); + document.body.appendChild(overlay); + + document.getElementById('ntfy-modal-close').addEventListener('click', function () { + overlay.remove(); + }); + overlay.addEventListener('click', function (e) { + if (e.target === overlay) overlay.remove(); + }); + } + /* ========== Push SW message handler ========== */ function listenForPushMessages() { if (!('serviceWorker' in navigator)) return; @@ -424,6 +545,7 @@ injectBell(); startPolling(); + checkNtfyStatus(); checkPushStatus(); listenForPushMessages(); } diff --git a/system1-factory/api/controllers/pushSubscriptionController.js b/system1-factory/api/controllers/pushSubscriptionController.js index a165317..8bb3328 100644 --- a/system1-factory/api/controllers/pushSubscriptionController.js +++ b/system1-factory/api/controllers/pushSubscriptionController.js @@ -43,6 +43,64 @@ const pushSubscriptionController = { console.error('Push 구독 해제 오류:', error); res.status(500).json({ success: false, message: 'Push 구독 해제 중 오류가 발생했습니다.' }); } + }, + + // === ntfy === + + // ntfy 구독 등록 + async ntfySubscribe(req, res) { + try { + const userId = req.user?.id; + await pushSubscriptionModel.ntfySubscribe(userId); + + const topic = `tkfactory-user-${userId}`; + res.json({ + success: true, + message: 'ntfy 구독이 등록되었습니다.', + data: { + topic, + serverUrl: process.env.NTFY_EXTERNAL_URL || 'https://ntfy.technicalkorea.net', + username: 'subscriber', + password: 'tkfactory-sub-2026' + } + }); + } catch (error) { + console.error('ntfy 구독 오류:', error); + res.status(500).json({ success: false, message: 'ntfy 구독 중 오류가 발생했습니다.' }); + } + }, + + // ntfy 구독 해제 + async ntfyUnsubscribe(req, res) { + try { + const userId = req.user?.id; + await pushSubscriptionModel.ntfyUnsubscribe(userId); + res.json({ success: true, message: 'ntfy 구독이 해제되었습니다.' }); + } catch (error) { + console.error('ntfy 구독 해제 오류:', error); + res.status(500).json({ success: false, message: 'ntfy 구독 해제 중 오류가 발생했습니다.' }); + } + }, + + // ntfy 구독 상태 확인 + async ntfyStatus(req, res) { + try { + const userId = req.user?.id; + const subscribed = await pushSubscriptionModel.isNtfySubscribed(userId); + const topic = `tkfactory-user-${userId}`; + + res.json({ + success: true, + data: { + subscribed, + topic, + serverUrl: process.env.NTFY_EXTERNAL_URL || 'https://ntfy.technicalkorea.net' + } + }); + } catch (error) { + console.error('ntfy 상태 확인 오류:', error); + res.status(500).json({ success: false, message: 'ntfy 상태 확인 중 오류가 발생했습니다.' }); + } } }; diff --git a/system1-factory/api/db/migrations/20260317001000_create_ntfy_subscriptions.sql b/system1-factory/api/db/migrations/20260317001000_create_ntfy_subscriptions.sql new file mode 100644 index 0000000..58492d8 --- /dev/null +++ b/system1-factory/api/db/migrations/20260317001000_create_ntfy_subscriptions.sql @@ -0,0 +1,5 @@ +-- ntfy 구독 테이블 +CREATE TABLE IF NOT EXISTS ntfy_subscriptions ( + user_id INT PRIMARY KEY, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); diff --git a/system1-factory/api/models/notificationModel.js b/system1-factory/api/models/notificationModel.js index 2cdcf97..f8a4716 100644 --- a/system1-factory/api/models/notificationModel.js +++ b/system1-factory/api/models/notificationModel.js @@ -24,19 +24,44 @@ function getWebPush() { return vapidConfigured ? webpush : null; } -// Push 전송 헬퍼 — 알림 생성 후 호출 +// Push 전송 헬퍼 — 알림 생성 후 호출 (ntfy 우선, 나머지 Web Push) async function sendPushToUsers(userIds, payload) { - const wp = getWebPush(); - if (!wp) return; + const pushModel = require('./pushSubscriptionModel'); + const { sendNtfy } = require('../utils/ntfySender'); try { - const pushModel = require('./pushSubscriptionModel'); - const subscriptions = userIds && userIds.length > 0 - ? await pushModel.getByUserIds(userIds) - : await pushModel.getAll(); // broadcast + // 1) ntfy 구독자 분리 + const ntfyUserIds = userIds && userIds.length > 0 + ? await pushModel.getNtfyUserIds(userIds) + : await pushModel.getAllNtfyUserIds(); + + const ntfySet = new Set(ntfyUserIds); + + // 2) ntfy 병렬 발송 + if (ntfyUserIds.length > 0) { + await Promise.allSettled( + ntfyUserIds.map(uid => + sendNtfy(uid, { title: payload.title, body: payload.body, url: payload.url }) + ) + ); + } + + // 3) Web Push — ntfy 구독자 제외 + const wp = getWebPush(); + if (!wp) return; + + let subscriptions; + if (userIds && userIds.length > 0) { + const webPushUserIds = userIds.filter(id => !ntfySet.has(id)); + subscriptions = webPushUserIds.length > 0 + ? await pushModel.getByUserIds(webPushUserIds) + : []; + } else { + // broadcast: 전체 구독 가져온 뒤 ntfy 사용자 제외 + subscriptions = (await pushModel.getAll()).filter(s => !ntfySet.has(s.user_id)); + } const payloadStr = JSON.stringify(payload); - for (const sub of subscriptions) { try { await wp.sendNotification({ @@ -44,7 +69,6 @@ async function sendPushToUsers(userIds, payload) { keys: { p256dh: sub.p256dh, auth: sub.auth } }, payloadStr); } catch (err) { - // 만료 구독 (410 Gone, 404 Not Found) 자동 정리 if (err.statusCode === 410 || err.statusCode === 404) { await pushModel.deleteByEndpoint(sub.endpoint).catch(() => {}); } diff --git a/system1-factory/api/models/pushSubscriptionModel.js b/system1-factory/api/models/pushSubscriptionModel.js index 0cc7c1d..ce6a03d 100644 --- a/system1-factory/api/models/pushSubscriptionModel.js +++ b/system1-factory/api/models/pushSubscriptionModel.js @@ -46,6 +46,46 @@ const pushSubscriptionModel = { async deleteByEndpoint(endpoint) { const db = await getDb(); await db.query('DELETE FROM push_subscriptions WHERE endpoint = ?', [endpoint]); + }, + + // === ntfy 구독 관련 === + + async getNtfyUserIds(userIds) { + if (!userIds || userIds.length === 0) return []; + const db = await getDb(); + const [rows] = await db.query( + 'SELECT user_id FROM ntfy_subscriptions WHERE user_id IN (?)', + [userIds] + ); + return rows.map(r => r.user_id); + }, + + async getAllNtfyUserIds() { + const db = await getDb(); + const [rows] = await db.query('SELECT user_id FROM ntfy_subscriptions'); + return rows.map(r => r.user_id); + }, + + async ntfySubscribe(userId) { + const db = await getDb(); + await db.query( + 'INSERT IGNORE INTO ntfy_subscriptions (user_id) VALUES (?)', + [userId] + ); + }, + + async ntfyUnsubscribe(userId) { + const db = await getDb(); + await db.query('DELETE FROM ntfy_subscriptions WHERE user_id = ?', [userId]); + }, + + async isNtfySubscribed(userId) { + const db = await getDb(); + const [rows] = await db.query( + 'SELECT 1 FROM ntfy_subscriptions WHERE user_id = ? LIMIT 1', + [userId] + ); + return rows.length > 0; } }; diff --git a/system1-factory/api/routes/pushSubscriptionRoutes.js b/system1-factory/api/routes/pushSubscriptionRoutes.js index 33b5f2a..63da60c 100644 --- a/system1-factory/api/routes/pushSubscriptionRoutes.js +++ b/system1-factory/api/routes/pushSubscriptionRoutes.js @@ -11,4 +11,9 @@ router.get('/vapid-public-key', pushController.getVapidPublicKey); router.post('/subscribe', requireAuth, pushController.subscribe); router.delete('/unsubscribe', requireAuth, pushController.unsubscribe); +// ntfy 구독 관리 +router.post('/ntfy/subscribe', requireAuth, pushController.ntfySubscribe); +router.delete('/ntfy/unsubscribe', requireAuth, pushController.ntfyUnsubscribe); +router.get('/ntfy/status', requireAuth, pushController.ntfyStatus); + module.exports = router; diff --git a/system1-factory/api/utils/ntfySender.js b/system1-factory/api/utils/ntfySender.js new file mode 100644 index 0000000..954e5dc --- /dev/null +++ b/system1-factory/api/utils/ntfySender.js @@ -0,0 +1,31 @@ +// utils/ntfySender.js — ntfy HTTP POST 래퍼 +const NTFY_BASE_URL = process.env.NTFY_BASE_URL || 'http://ntfy:80'; +const NTFY_PUBLISH_TOKEN = process.env.NTFY_PUBLISH_TOKEN; +const TKFB_BASE_URL = process.env.TKFB_BASE_URL || 'https://tkfb.technicalkorea.net'; + +async function sendNtfy(userId, { title, body, url }) { + if (!NTFY_PUBLISH_TOKEN) return; + + const topic = `tkfactory-user-${userId}`; + const headers = { + 'Authorization': `Bearer ${NTFY_PUBLISH_TOKEN}`, + 'Title': title || '', + 'Tags': 'bell', + }; + if (url) { + headers['Click'] = url.startsWith('http') ? url : `${TKFB_BASE_URL}${url}`; + } + + try { + const resp = await fetch(`${NTFY_BASE_URL}/${topic}`, { + method: 'POST', + headers, + body: body || '', + }); + if (!resp.ok) console.warn(`[ntfy] ${userId} 발송 실패: ${resp.status}`); + } catch (e) { + console.error(`[ntfy] ${userId} 발송 오류:`, e.message); + } +} + +module.exports = { sendNtfy };