diff --git a/README.md b/README.md index b875f51..4bc4e44 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,6 @@ To receive ProtonMail notifications via Signal: - Enter your ProtonMail email - Enter your ProtonMail password - Enter your 2FA code - - Wait (potentially a long time) for ProtonMail Bridge to sync emails 3. **Get IMAP credentials**: - Run: `info` @@ -113,6 +112,12 @@ Then build and run with docker-compose.dev.yml: docker compose --profile protonmail -f docker-compose.dev.yml up -d ``` +or just the proton-bridge: + +```bash +docker compose -f docker-compose.dev.yml up protonmail-bridge +``` + Or run services directly with Bun: ```bash diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index ea261f2..6136ab0 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -22,6 +22,8 @@ services: protonmail-bridge: image: shenxn/protonmail-bridge:build profiles: ['protonmail'] + ports: + - '127.0.0.1:143:143' volumes: - proton-bridge-data:/root - /tmp/bridge-updates:/root/.local/share/protonmail/bridge-v3/updates:ro diff --git a/package.json b/package.json index e1a0979..583b6a4 100644 --- a/package.json +++ b/package.json @@ -14,8 +14,6 @@ "url": "https://github.com/lone-cloud/sup" }, "scripts": { - "check": "tsc --noEmit && biome check .", - "fix": "biome check --write .", "android:build": "bun run scripts/test-android-build.ts", "android:release": "bun run scripts/release-android.ts", "android:deps": "bun run scripts/check-android-deps.ts", diff --git a/server/Dockerfile b/server/Dockerfile index 77c0396..c7af5df 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -35,7 +35,7 @@ RUN ARCH=$(uname -m) && \ fi COPY --from=builder /app/sup-server /usr/local/bin/sup-server -COPY --from=builder /app/templates /templates +COPY --from=builder /app/public /public ENV PATH="/usr/local/signal-cli/bin:${PATH}" ENV LD_LIBRARY_PATH="/usr/local/signal-cli/lib:${LD_LIBRARY_PATH}" diff --git a/server/bun.lock b/server/bun.lock index 59bf2b5..dde3194 100644 --- a/server/bun.lock +++ b/server/bun.lock @@ -6,6 +6,7 @@ "name": "sup-server", "dependencies": { "chalk": "^5.6.2", + "htmx.org": "^2.0.8", "imap": "^0.8.19", }, "devDependencies": { @@ -22,6 +23,8 @@ "core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="], + "htmx.org": ["htmx.org@2.0.8", "", {}, "sha512-fm297iru0iWsNJlBrjvtN7V9zjaxd+69Oqjh4F/Vq9Wwi2kFisLcrLCiv5oBX0KLfOX/zG8AUo9ROMU5XUB44Q=="], + "imap": ["imap@0.8.19", "", { "dependencies": { "readable-stream": "1.1.x", "utf7": ">=1.0.2" } }, "sha512-z5DxEA1uRnZG73UcPA4ES5NSCGnPuuouUx43OPX7KZx1yzq3N8/vx2mtXEShT5inxB3pRgnfG1hijfu7XN2YMw=="], "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], diff --git a/server/constants/config.ts b/server/constants/config.ts index d0758dc..55fea62 100644 --- a/server/constants/config.ts +++ b/server/constants/config.ts @@ -7,7 +7,6 @@ export const DEVICE_NAME = 'SUP'; export const SUP_ENDPOINT_PREFIX = `[${DEVICE_NAME}:`; export const LAUNCH_ENDPOINT_PREFIX = '[LAUNCH:'; -// ProtonMail Integration export const BRIDGE_IMAP_USERNAME = Bun.env.BRIDGE_IMAP_USERNAME; export const BRIDGE_IMAP_PASSWORD = Bun.env.BRIDGE_IMAP_PASSWORD; export const PROTON_BRIDGE_HOST = Bun.env.PROTON_BRIDGE_HOST || 'protonmail-bridge'; diff --git a/server/constants/paths.ts b/server/constants/paths.ts index e1744a1..d387596 100644 --- a/server/constants/paths.ts +++ b/server/constants/paths.ts @@ -7,5 +7,4 @@ export const SIGNAL_CLI_SOCKET = '/tmp/signal-cli.sock'; export const SIGNAL_CLI_DATA_DIR = `${HOME}/.local/share/signal-cli`; export const SIGNAL_CLI_DATA = `${HOME}/.local/share/signal-cli/data`; -export const SUP_DATA_DIR = `${HOME}/.local/share/sup`; export const SUP_DB = `${HOME}/.local/share/sup/store.db`; diff --git a/server/constants/server.ts b/server/constants/server.ts index 36af458..d0916cc 100644 --- a/server/constants/server.ts +++ b/server/constants/server.ts @@ -1,20 +1,15 @@ export const ROUTES = { - HEALTH: '/health', - LINK: '/link', - LINK_QR: '/link/qr', - LINK_STATUS: '/link/status', - LINK_UNLINK: '/link/unlink', FAVICON: '/favicon.png', + HTMX: '/htmx.js', + ADMIN: '/', + ADMIN_CSS: '/admin.css', + HEALTH_FRAGMENT: '/health/fragment', + SIGNAL_INFO_FRAGMENT: '/signal-info/fragment', + ENDPOINTS_FRAGMENT: '/endpoints/fragment', + QR_SECTION: '/link/qr-section', + QR_IMAGE: '/link/qr-image', + STATUS_CHECK: '/link/status-check', MATRIX_NOTIFY: '/_matrix/push/v1/notify', UP: '/up', UP_INSTANCE: '/up/:instance', - ENDPOINTS: '/endpoints', - NOTIFY_TOPIC: '/notify/:topic', - TOPICS: '/topics', -} as const; - -export const TEMPLATES = { - LINKED: 'templates/linked.html', - LINK: 'templates/link.html', - SETUP: 'templates/setup.html', } as const; diff --git a/server/index.ts b/server/index.ts index 0b3224e..615b601 100644 --- a/server/index.ts +++ b/server/index.ts @@ -1,39 +1,18 @@ -import { API_KEY, BRIDGE_IMAP_PASSWORD, BRIDGE_IMAP_USERNAME, PORT } from './constants/config'; -import { ROUTES } from './constants/server'; -import { checkSignalCli, initSignal, startDaemon } from './modules/signal'; -import { handleHealth } from './routes/health'; -import { handleLink, handleLinkQR, handleLinkStatus, handleUnlink } from './routes/link'; -import { handleNotify, handleTopics } from './routes/notify'; -import { - handleDiscovery, - handleEndpoints, - handleMatrixNotify, - handleRegister, - handleUnregister, -} from './routes/unifiedpush'; -import { withAuth, withFormAuth } from './utils/auth'; -import { logError, logInfo, logSuccess, logWarn } from './utils/log'; - -let daemon: ReturnType | null = null; +import { API_KEY, BRIDGE_IMAP_PASSWORD, BRIDGE_IMAP_USERNAME, PORT } from '@/constants/config'; +import { ROUTES } from '@/constants/server'; +import { cleanupDaemon, initSignal } from '@/modules/signal'; +import { adminRoutes } from '@/routes/admin/index'; +import { unifiedPushRoutes } from '@/routes/unifiedpush'; +import { logError, logInfo, logWarn } from '@/utils/log'; try { - daemon = await startDaemon(); - const isLinked = await checkSignalCli(); - const hasAccount = isLinked && (await initSignal({})); - - if (hasAccount) { - logSuccess('Signal account linked'); - } else { - logWarn('No Signal account linked'); - logInfo(`Visit http://localhost:${PORT}/link to link your device`); - } + await initSignal(); } catch (error) { - logError(` ${error instanceof Error ? error.message : String(error)}`); + logError(`${error instanceof Error ? error.message : String(error)}`); } if (!API_KEY) { logWarn('Server running without API_KEY'); - console.warn('Set API_KEY env var for production deployments.'); } if (BRIDGE_IMAP_USERNAME && BRIDGE_IMAP_PASSWORD) { @@ -51,60 +30,23 @@ const server = Bun.serve({ idleTimeout: 60, routes: { - [ROUTES.FAVICON]: Bun.file('assets/favicon.png'), + [ROUTES.FAVICON]: Bun.file('public/favicon.png'), + [ROUTES.HTMX]: Bun.file('node_modules/htmx.org/dist/htmx.min.js'), - [ROUTES.HEALTH]: handleHealth, + ...adminRoutes, - [ROUTES.LINK]: { - GET: handleLink, - }, - - [ROUTES.LINK_QR]: { - GET: () => - handleLinkQR(async () => { - daemon?.kill(); - daemon = await startDaemon(); - }), - }, - - [ROUTES.LINK_STATUS]: { - GET: handleLinkStatus, - }, - - [ROUTES.LINK_UNLINK]: { - POST: withFormAuth(() => - handleUnlink(async () => { - daemon?.kill(); - daemon = await startDaemon(); - }), - ), - }, - - [ROUTES.UP]: { - GET: handleDiscovery, - }, - - [ROUTES.ENDPOINTS]: { - GET: withAuth(handleEndpoints), - }, - - [ROUTES.TOPICS]: { - GET: withAuth(handleTopics), - }, - - [ROUTES.MATRIX_NOTIFY]: { - POST: handleMatrixNotify, - }, - - [ROUTES.UP_INSTANCE]: { - POST: withAuth((req) => handleRegister(req, new URL(req.url))), - DELETE: withAuth((req) => handleUnregister(new URL(req.url))), - }, - - [ROUTES.NOTIFY_TOPIC]: { - POST: withAuth((req) => handleNotify(req, new URL(req.url))), - }, + ...unifiedPushRoutes, }, }); logInfo(`\nSUP running on http://localhost:${server.port} 🚀`); + +process.on('SIGINT', () => { + cleanupDaemon(); + process.exit(0); +}); + +process.on('SIGTERM', () => { + cleanupDaemon(); + process.exit(0); +}); diff --git a/server/modules/protonmail.ts b/server/modules/protonmail.ts index 04bb9f1..6dd1586 100644 --- a/server/modules/protonmail.ts +++ b/server/modules/protonmail.ts @@ -7,10 +7,14 @@ import { PROTON_BRIDGE_HOST, PROTON_BRIDGE_PORT, SUP_TOPIC, -} from '../constants/config'; -import { logError, logInfo, logSuccess, logVerbose, logWarn } from '../utils/log'; -import { createGroup, sendGroupMessage } from './signal'; -import { getGroupId, register } from './store'; +} from '@/constants/config'; +import { createGroup, hasValidAccount, sendGroupMessage } from '@/modules/signal'; +import { getGroupId, register } from '@/modules/store'; +import { logError, logInfo, logSuccess, logVerbose, logWarn } from '@/utils/log'; + +let imapConnected = false; + +export const isImapConnected = () => imapConnected; export async function startProtonMonitor() { if (!BRIDGE_IMAP_USERNAME || !BRIDGE_IMAP_PASSWORD) { @@ -20,20 +24,31 @@ export async function startProtonMonitor() { return; } - logInfo(`🔗 Connecting to Proton Bridge at ${PROTON_BRIDGE_HOST}:${PROTON_BRIDGE_PORT}`); - logInfo(`📨 Monitoring mailbox: ${BRIDGE_IMAP_USERNAME}`); + const linked = await hasValidAccount(); + if (!linked) { + logWarn('Signal account not linked. ProtonMail notifications will be skipped.'); + logWarn('Link your Signal account at /link to enable email notifications.'); + } + + logInfo(`Connecting to Proton Bridge at ${PROTON_BRIDGE_HOST}:${PROTON_BRIDGE_PORT}`); + logInfo(`Monitoring mailbox: ${BRIDGE_IMAP_USERNAME}`); const imap = new Imap({ user: BRIDGE_IMAP_USERNAME, password: BRIDGE_IMAP_PASSWORD, host: PROTON_BRIDGE_HOST, port: PROTON_BRIDGE_PORT, - tls: true, + tls: false, tlsOptions: { rejectUnauthorized: false }, keepalive: true, }); async function sendNotification(title: string, message: string) { + if (!(await hasValidAccount())) { + logVerbose('Skipping notification (Signal not linked)'); + return; + } + try { const topicKey = `proton-${SUP_TOPIC}`; const groupId = getGroupId(topicKey) ?? (await createGroup(SUP_TOPIC)); @@ -42,12 +57,16 @@ export async function startProtonMonitor() { register(topicKey, groupId, SUP_TOPIC); } - const prefix = ENABLE_PROTON_ANDROID - ? `${LAUNCH_ENDPOINT_PREFIX}ch.protonmail.android]\n` - : ''; - await sendGroupMessage(groupId, `${prefix}**${title}**\n${message}`); + if (ENABLE_PROTON_ANDROID) { + await sendGroupMessage( + groupId, + `${LAUNCH_ENDPOINT_PREFIX}ch.protonmail.android]\n**${title}**\n${message}`, + ); + } else { + await sendGroupMessage(groupId, `${title}\n${message}`); + } - logSuccess(`Notification sent: ${title}`); + logVerbose(`Notification sent: ${title}`); } catch (error) { logError('Failed to send notification:', error); } @@ -60,10 +79,8 @@ export async function startProtonMonitor() { return; } - logVerbose(`Connected to inbox (${box.messages.total} messages)`); - imap.on('mail', async (numNewMsgs: number) => { - logVerbose(`📬 ${numNewMsgs} new message(s) received`); + logVerbose(`${numNewMsgs} new message(s) received`); const fetch = imap.seq.fetch(`${box.messages.total}:*`, { bodies: 'HEADER.FIELDS (FROM SUBJECT)', @@ -78,34 +95,39 @@ export async function startProtonMonitor() { }); stream.once('end', () => { const header = Imap.parseHeader(buffer); - const from = header.from?.[0] || 'Unknown sender'; + const rawFrom = header.from?.[0] || 'Unknown sender'; const subject = header.subject?.[0] || 'No subject'; - sendNotification(`New Mail from ${from}`, subject); + const nameMatch = rawFrom.match(/^"?([^"<]+)"?\s* { - logVerbose('📊 Mailbox updated'); - }); }); } - imap.once('ready', () => { - logVerbose('IMAP connection ready'); + imap.on('ready', () => { + imapConnected = true; + logSuccess('IMAP is ready'); openInbox(); }); - imap.once('error', (err: Error) => { - logError('IMAP error:', err); - logWarn('ProtonMail integration disabled due to connection error'); + imap.on('error', (err: Error) => { + imapConnected = false; + logError('IMAP error:', err.message); }); - imap.once('end', () => { - logVerbose('IMAP connection ended, reconnecting...'); - setTimeout(() => imap.connect(), 5000); + imap.on('close', (hadError: boolean) => { + imapConnected = false; + logError(`IMAP connection closed (hadError: ${hadError})`); + }); + + imap.on('end', () => { + imapConnected = false; + logError('IMAP connection ended by server'); }); imap.connect(); diff --git a/server/modules/signal.ts b/server/modules/signal.ts index 7b2d5b8..356817d 100644 --- a/server/modules/signal.ts +++ b/server/modules/signal.ts @@ -1,31 +1,47 @@ import { rm, unlink } from 'node:fs/promises'; -import { DEVICE_NAME, VERBOSE } from '../constants/config'; -import { SIGNAL_CLI, SIGNAL_CLI_DATA, SIGNAL_CLI_SOCKET } from '../constants/paths'; -import type { ListAccountsResult, StartLinkResult, UpdateGroupResult } from '../types'; -import { logError, logInfo, logSuccess, logVerbose, logWarn } from '../utils/log'; -import { call } from '../utils/rpc'; - -logVerbose(`Running signal-cli from ${SIGNAL_CLI}`); +import { DEVICE_NAME, PORT, VERBOSE } from '@/constants/config'; +import { SIGNAL_CLI, SIGNAL_CLI_DATA, SIGNAL_CLI_SOCKET } from '@/constants/paths'; +import type { ListAccountsResult, StartLinkResult, UpdateGroupResult } from '@/types'; +import { logError, logInfo, logSuccess, logVerbose, logWarn } from '@/utils/log'; +import { call } from '@/utils/rpc'; let account: string | null = null; let currentLinkUri: string | null = null; +let daemon: ReturnType | null = null; export const hasLinkUri = () => currentLinkUri !== null; -export async function initSignal({ accountOverride }: { accountOverride?: string }) { +export async function initSignal({ accountOverride }: { accountOverride?: string } = {}) { + await startDaemon(); + + const isLinked = await checkSignalCli(); + + if (!isLinked) { + logWarn('No Signal account linked'); + logInfo(`Visit http://localhost:${PORT} to link your device`); + return { linked: false, account: null }; + } + if (accountOverride) { account = accountOverride; - return true; + logVerbose(`Signal account set: ${account}`); + logSuccess('Signal account linked'); + return { linked: true, account }; } - const result = (await call('listAccounts', {}, account)) as ListAccountsResult; + const result = (await call('listAccounts', {}, null)) as ListAccountsResult; const [firstAccount] = result; if (firstAccount) { - account = firstAccount; - return true; + account = firstAccount.number; + logVerbose(`Signal account initialized: ${account}`); + logSuccess('Signal account linked'); + return { linked: true, account }; } - return false; + logVerbose('No Signal accounts found'); + logWarn('No Signal account linked'); + logInfo(`Visit http://localhost:${PORT} to link your device`); + return { linked: false, account: null }; } export async function generateLinkQR() { @@ -71,12 +87,13 @@ export async function unlinkDevice() { currentLinkUri = null; if (await Bun.file(SIGNAL_CLI_DATA).exists()) { - logWarn('Unlinking device and removing account data...'); + logWarn('Removing local account data...'); try { await rm(SIGNAL_CLI_DATA, { recursive: true, force: true }); + logSuccess('Account data removed'); } catch (error) { - logWarn('Failed to remove account data directory:', error); + logError('Failed to remove account data directory:', error); } } } @@ -98,12 +115,17 @@ export async function createGroup(name: string, members: string[] = []) { return result.groupId; } -export async function sendGroupMessage(groupId: string, message: string) { +export async function sendGroupMessage( + groupId: string, + message: string, + { notifySelf = true }: { notifySelf?: boolean } = {}, +) { await call( 'send', { groupId, message, + 'notify-self': notifySelf, }, account, ); @@ -134,8 +156,8 @@ export async function startDaemon() { try { await unlink(SIGNAL_CLI_SOCKET); logVerbose('Removed stale socket file'); - } catch (error: any) { - if (error.code !== 'ENOENT') { + } catch (error) { + if (error instanceof Error && 'code' in error && error.code !== 'ENOENT') { logError('Failed to remove stale socket file:', error); } } @@ -194,6 +216,7 @@ export async function startDaemon() { }); socket.end(); logSuccess('signal-cli daemon started'); + daemon = proc; return proc; } catch (error) { if (authError && !cleaned) { @@ -211,3 +234,13 @@ export async function startDaemon() { throw new Error('Failed to start signal-cli daemon'); } } + +export async function restartDaemon() { + if (daemon) { + daemon.kill(); + await Bun.sleep(500); + } + daemon = await startDaemon(); +} + +export const cleanupDaemon = () => daemon?.kill(); diff --git a/server/modules/store.ts b/server/modules/store.ts index f6a3e3c..515f6bd 100644 --- a/server/modules/store.ts +++ b/server/modules/store.ts @@ -1,5 +1,5 @@ import { Database } from 'bun:sqlite'; -import { SUP_DATA_DIR, SUP_DB } from '../constants/paths'; +import { SUP_DB } from '@/constants/paths'; interface EndpointMapping { endpoint: string; @@ -7,8 +7,6 @@ interface EndpointMapping { appName: string; } -await Bun.write(`${SUP_DATA_DIR}/.keep`, ''); - const db = new Database(SUP_DB); db.run(` @@ -41,9 +39,8 @@ export const getAppName = (endpoint: string) => { return row?.appName; }; -export const getAllMappings = () => { - return db.query('SELECT endpoint, groupId, appName FROM mappings').all() as EndpointMapping[]; -}; +export const getAllMappings = () => + db.query('SELECT endpoint, groupId, appName FROM mappings').all() as EndpointMapping[]; export const remove = (endpoint: string) => { db.run('DELETE FROM mappings WHERE endpoint = ?', [endpoint]); diff --git a/server/modules/unifiedpush.ts b/server/modules/unifiedpush.ts index 1b9a129..4159e47 100644 --- a/server/modules/unifiedpush.ts +++ b/server/modules/unifiedpush.ts @@ -1,4 +1,4 @@ -import { SUP_ENDPOINT_PREFIX } from '../constants/config'; +import { SUP_ENDPOINT_PREFIX } from '@/constants/config'; export interface UnifiedPushMessage { endpoint: string; diff --git a/server/package.json b/server/package.json index 8e5fa0d..db0d10d 100644 --- a/server/package.json +++ b/server/package.json @@ -8,6 +8,7 @@ "license": "AGPL-3.0-or-later", "dependencies": { "chalk": "^5.6.2", + "htmx.org": "^2.0.8", "imap": "^0.8.19" }, "devDependencies": { @@ -19,6 +20,6 @@ "start": "bun run index.ts", "build": "bun build --compile index.ts --outfile sup-server", "check": "tsc --noEmit && biome check .", - "fix": "biome check --write ." + "fix": "biome check --write --unsafe ." } } diff --git a/server/public/admin.css b/server/public/admin.css new file mode 100644 index 0000000..69d7d85 --- /dev/null +++ b/server/public/admin.css @@ -0,0 +1,120 @@ +/* CSS Reset */ +*, +*::before, +*::after { + box-sizing: border-box; +} + +* { + margin: 0; + padding: 0; +} + +body { + line-height: 1.5; + -webkit-font-smoothing: antialiased; +} + +img, +picture, +video, +canvas, +svg { + display: block; + max-width: 100%; +} + +input, +button, +textarea, +select { + font: inherit; +} + +p, +h1, +h2, +h3, +h4, +h5, +h6 { + overflow-wrap: break-word; +} + +/* Styles */ +body { + font-family: system-ui; + max-width: 800px; + margin: 20px auto; + padding: 20px; + background: #f5f5f5; +} + +.card { + background: white; + border-radius: 8px; + padding: 20px; + margin-bottom: 20px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); +} + +h2 { + margin-bottom: 20px; +} + +.status { + display: flex; + gap: 10px; + flex-wrap: wrap; +} + +.status-item { + padding: 10px 16px; + border-radius: 4px; + font-weight: 500; +} + +.status-ok { + background: #d4edda; + color: #155724; +} + +.status-error { + background: #f8d7da; + color: #721c24; +} + +.endpoint-list { + list-style: none; + padding: 0; + max-height: 300px; + overflow-y: auto; +} + +.endpoint-list li { + padding: 8px 12px; + background: #f8f9fa; + border-radius: 4px; + margin-bottom: 6px; + font-size: 0.9em; +} + +.link-button { + display: inline-block; + padding: 10px 20px; + background: #007bff; + color: white; + text-decoration: none; + border-radius: 4px; + margin-top: 10px; + border: none; + cursor: pointer; +} + +.link-button:hover { + background: #0056b3; +} + +.loading { + color: #666; +} diff --git a/server/public/admin.html b/server/public/admin.html new file mode 100644 index 0000000..7463dfa --- /dev/null +++ b/server/public/admin.html @@ -0,0 +1,66 @@ + + + + + SUP Admin + + + + + + +
+
+ Loading... +
+
+ +
+

Signal

+
+ Loading... +
+
+
+ +
+

Endpoints

+
+ Loading... +
+
+ + + + diff --git a/server/assets/favicon.png b/server/public/favicon.png similarity index 100% rename from server/assets/favicon.png rename to server/public/favicon.png diff --git a/server/routes/admin/fragments.ts b/server/routes/admin/fragments.ts new file mode 100644 index 0000000..9cb84d2 --- /dev/null +++ b/server/routes/admin/fragments.ts @@ -0,0 +1,146 @@ +import { DEVICE_NAME } from '@/constants/config'; +import { SIGNAL_CLI_DATA } from '@/constants/paths'; +import { isImapConnected } from '@/modules/protonmail'; +import { + checkSignalCli, + finishLink, + generateLinkQR, + hasLinkUri, + hasValidAccount, + initSignal, + restartDaemon, + unlinkDevice, +} from '@/modules/signal'; +import { getAllMappings } from '@/modules/store'; + +export const handleHealthFragment = async () => { + const signalOk = await checkSignalCli(); + const linked = signalOk && (await hasValidAccount()); + const imap = isImapConnected(); + + const html = ` +
+
+ Signal: ${signalOk ? 'Connected' : 'Disconnected'} +
+
+ Account: ${linked ? 'Linked' : 'Unlinked'} +
+
+ IMAP: ${imap ? 'Connected' : 'Disconnected'} +
+
+ `; + + return new Response(html, { + headers: { 'content-type': 'text/html' }, + }); +}; + +export const handleSignalInfoFragment = async () => { + const linked = await hasValidAccount(); + + const html = linked + ? `
+ Unlink and remove device +
+
    +
  1. Open Signal app → Settings → Linked Devices
  2. +
  3. Find "${DEVICE_NAME}" and tap it
  4. +
  5. Tap "Unlink Device"
  6. +
+
+
` + : ``; + + return new Response(html, { + headers: { 'content-type': 'text/html' }, + }); +}; + +export const handleEndpointsFragment = async () => { + const endpoints = getAllMappings(); + + if (endpoints.length === 0) { + return new Response('

No endpoints registered

', { + headers: { 'content-type': 'text/html' }, + }); + } + + const html = ` + + `; + + return new Response(html, { + headers: { 'content-type': 'text/html' }, + }); +}; + +export const handleQRSection = async () => { + const html = ` +

Scan this QR code with your Signal app:

+

Settings → Linked Devices → Link New Device

+
+ Generating QR code... +
+
+ + `; + + return new Response(html, { + headers: { 'content-type': 'text/html' }, + }); +}; + +export const handleQRImage = async () => { + const linked = await hasValidAccount(); + if (!linked && (await Bun.file(SIGNAL_CLI_DATA).exists())) { + await unlinkDevice(); + await restartDaemon(); + } + + const qrDataUrl = await generateLinkQR(); + const html = `QR Code`; + + return new Response(html, { + headers: { 'content-type': 'text/html' }, + }); +}; + +export const handleLinkStatusCheck = async () => { + let linked = await hasValidAccount(); + + if (!linked && hasLinkUri()) { + try { + await finishLink(); + const result = await initSignal(); + linked = result.linked; + } catch (error) { + console.error('Failed to finish link:', error); + } + } + + if (linked) { + return new Response('', { + headers: { + 'content-type': 'text/html', + 'HX-Refresh': 'true', + }, + }); + } + + return new Response('', { + headers: { 'content-type': 'text/html' }, + }); +}; diff --git a/server/routes/admin/index.ts b/server/routes/admin/index.ts new file mode 100644 index 0000000..b026c53 --- /dev/null +++ b/server/routes/admin/index.ts @@ -0,0 +1,42 @@ +import { ROUTES } from '@/constants/server'; +import { withAuth } from '@/utils/auth'; +import { + handleEndpointsFragment, + handleHealthFragment, + handleLinkStatusCheck, + handleQRImage, + handleQRSection, + handleSignalInfoFragment, +} from './fragments'; + +export const adminRoutes = { + [ROUTES.ADMIN]: { + GET: () => new Response(Bun.file('public/admin.html')), + }, + + [ROUTES.ADMIN_CSS]: Bun.file('public/admin.css'), + + [ROUTES.HEALTH_FRAGMENT]: { + GET: withAuth(handleHealthFragment), + }, + + [ROUTES.SIGNAL_INFO_FRAGMENT]: { + GET: withAuth(handleSignalInfoFragment), + }, + + [ROUTES.ENDPOINTS_FRAGMENT]: { + GET: withAuth(handleEndpointsFragment), + }, + + [ROUTES.QR_SECTION]: { + GET: withAuth(handleQRSection), + }, + + [ROUTES.QR_IMAGE]: { + GET: withAuth(handleQRImage), + }, + + [ROUTES.STATUS_CHECK]: { + GET: withAuth(handleLinkStatusCheck), + }, +}; diff --git a/server/routes/health.ts b/server/routes/health.ts deleted file mode 100644 index c3dace1..0000000 --- a/server/routes/health.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { checkSignalCli, hasValidAccount } from '../modules/signal'; - -export const handleHealth = async () => { - const signalOk = await checkSignalCli(); - const linked = signalOk && (await hasValidAccount()); - - return Response.json({ - status: 'ok', - signal: signalOk ? 'connected' : 'disconnected', - linked, - mode: 'daemon', - }); -}; diff --git a/server/routes/link.ts b/server/routes/link.ts deleted file mode 100644 index d59f510..0000000 --- a/server/routes/link.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { API_KEY } from '../constants/config'; -import { SIGNAL_CLI_DATA } from '../constants/paths'; -import { ROUTES, TEMPLATES } from '../constants/server'; -import { - finishLink, - generateLinkQR, - hasLinkUri, - hasValidAccount, - initSignal, - unlinkDevice, -} from '../modules/signal'; - -export const handleLink = async () => { - const linked = await hasValidAccount(); - const template = linked ? TEMPLATES.LINKED : TEMPLATES.LINK; - let html = await Bun.file(template).text(); - - if (linked && API_KEY) { - const passwordField = - ''; - html = html.replace('{{PASSWORD_FIELD}}', passwordField); - } else if (linked) { - html = html.replace('{{PASSWORD_FIELD}}', ''); - } - - return new Response(html, { - headers: { 'content-type': 'text/html' }, - }); -}; - -export const handleLinkQR = async (restartDaemon: () => Promise) => { - const linked = await hasValidAccount(); - if (!linked && (await Bun.file(SIGNAL_CLI_DATA).exists())) { - await unlinkDevice(); - await restartDaemon(); - } - - const qrDataUrl = await generateLinkQR(); - - return new Response(qrDataUrl, { - headers: { 'content-type': 'text/plain' }, - }); -}; - -export const handleLinkStatus = async () => { - let linked = await hasValidAccount(); - - if (!linked && hasLinkUri()) { - try { - await finishLink(); - await initSignal({}); - linked = true; - } catch {} - } - - return Response.json({ linked }); -}; - -export const handleUnlink = async (killAndRestart: () => Promise) => { - await unlinkDevice(); - await killAndRestart(); - - return new Response('', { - status: 303, - headers: { Location: ROUTES.LINK }, - }); -}; diff --git a/server/routes/notify.ts b/server/routes/notify.ts deleted file mode 100644 index d6e6201..0000000 --- a/server/routes/notify.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { createGroup, sendGroupMessage } from '../modules/signal'; -import { getAllMappings, getGroupId, register } from '../modules/store'; - -interface NotificationMessage { - topic: string; - title?: string; - message: string; -} - -const formatNotification = (notification: NotificationMessage) => { - const parts: string[] = []; - const title = notification.title || notification.topic; - - parts.push(`**${title}**`); - parts.push(notification.message); - - return parts.join('\n'); -}; - -export const handleNotify = async (req: Request, url: URL) => { - const topic = url.pathname.split('/')[2]; - if (!topic) { - return new Response('Topic required', { status: 400 }); - } - - const body = await req.text(); - if (!body) { - return new Response('Message required', { status: 400 }); - } - - const title = req.headers.get('x-title') || undefined; - - const notification: NotificationMessage = { - topic, - title, - message: body, - }; - - const topicKey = `notify-${topic}`; - const groupId = getGroupId(topicKey) ?? (await createGroup(topic)); - - if (!getGroupId(topicKey)) { - register(topicKey, groupId, topic); - } - - const signalMessage = formatNotification(notification); - await sendGroupMessage(groupId, signalMessage); - - return Response.json({ success: true, topic, groupId }); -}; - -export const handleTopics = () => { - const allMappings = getAllMappings(); - const topics = allMappings - .filter((m) => m.endpoint.startsWith('notify-')) - .map((m) => ({ - topic: m.appName, - groupId: m.groupId, - })); - - return Response.json({ topics }); -}; diff --git a/server/routes/unifiedpush.ts b/server/routes/unifiedpush.ts index eee16f3..de086be 100644 --- a/server/routes/unifiedpush.ts +++ b/server/routes/unifiedpush.ts @@ -1,9 +1,9 @@ -import { ROUTES } from '../constants/server'; -import { createGroup, sendGroupMessage } from '../modules/signal'; -import { getAllMappings, getGroupId, register, remove } from '../modules/store'; -import { formatAsSignalMessage, parseUnifiedPushRequest } from '../modules/unifiedpush'; +import { ROUTES } from '@/constants/server'; +import { createGroup, sendGroupMessage } from '@/modules/signal'; +import { getGroupId, register, remove } from '@/modules/store'; +import { formatAsSignalMessage, parseUnifiedPushRequest } from '@/modules/unifiedpush'; -export const handleMatrixNotify = async (req: Request) => { +const handleMatrixNotify = async (req: Request) => { const message = await parseUnifiedPushRequest(req); const groupId = getGroupId(message.endpoint); @@ -17,7 +17,8 @@ export const handleMatrixNotify = async (req: Request) => { return Response.json({ success: true }); }; -export const handleRegister = async (req: Request, url: URL) => { +const handleRegister = async (req: Request) => { + const url = new URL(req.url); const endpointId = url.pathname.split('/')[2] ?? ''; const { appName } = (await req.json()) as { appName: string; @@ -38,16 +39,30 @@ export const handleRegister = async (req: Request, url: URL) => { return Response.json({ endpoint, gateway: 'matrix' }); }; -export const handleUnregister = async (url: URL) => { +const handleUnregister = async (req: Request) => { + const url = new URL(req.url); const endpointId = url.pathname.split('/')[2] ?? ''; remove(endpointId); return new Response(null, { status: 204 }); }; -export const handleDiscovery = () => +const handleDiscovery = () => Response.json({ unifiedpush: { version: 1 }, gateway: 'matrix', }); -export const handleEndpoints = () => Response.json(getAllMappings()); +export const unifiedPushRoutes = { + [ROUTES.UP]: { + GET: handleDiscovery, + }, + + [ROUTES.MATRIX_NOTIFY]: { + POST: handleMatrixNotify, + }, + + [ROUTES.UP_INSTANCE]: { + POST: handleRegister, + DELETE: handleUnregister, + }, +}; diff --git a/server/templates/link.html b/server/templates/link.html deleted file mode 100644 index d2d12e3..0000000 --- a/server/templates/link.html +++ /dev/null @@ -1,38 +0,0 @@ - - - - - SUP - Link Signal - - - - -

Link Signal Account

-

Scan this QR code with your Signal app:

-

Settings → Linked Devices → Link New Device

-
Generating QR code...
- - - diff --git a/server/templates/linked.html b/server/templates/linked.html deleted file mode 100644 index 004a880..0000000 --- a/server/templates/linked.html +++ /dev/null @@ -1,29 +0,0 @@ - - - - - SUP - Linked - - - -

Signal Linked

-

Your Signal account is already linked to this server.

- -
- Unlink and re-link with different account -
-

Steps to unlink:

-
    -
  1. Open Signal app → Settings → Linked Devices
  2. -
  3. Find "SUP" and tap it
  4. -
  5. Tap "Unlink Device"
  6. -
  7. Click the button below to clear server data:
  8. -
-
- {{PASSWORD_FIELD}} - -
-
-
- - diff --git a/server/tsconfig.json b/server/tsconfig.json new file mode 100644 index 0000000..0c1da4a --- /dev/null +++ b/server/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@/*": ["./*"] + } + } +} diff --git a/server/types.d.ts b/server/types.d.ts index a677390..6bbabc6 100644 --- a/server/types.d.ts +++ b/server/types.d.ts @@ -27,4 +27,10 @@ export interface UpdateGroupResult { groupId: string; } -export type ListAccountsResult = string[]; +export interface AccountInfo { + number: string; + name?: string; + uuid?: string; +} + +export type ListAccountsResult = AccountInfo[]; diff --git a/server/utils/auth.ts b/server/utils/auth.ts index 0b8a391..f1e0bb7 100644 --- a/server/utils/auth.ts +++ b/server/utils/auth.ts @@ -1,7 +1,9 @@ -import { API_KEY } from '../constants/config'; +import { API_KEY } from '@/constants/config'; const checkAuth = (req: Request) => { - if (!API_KEY) return null; + if (!API_KEY) { + return new Response('Unauthorized', { status: 401 }); + } const proto = req.headers.get('x-forwarded-proto') || 'http'; const host = req.headers.get('host') || ''; @@ -18,20 +20,6 @@ const checkAuth = (req: Request) => { return null; }; -export const checkFormAuth = async (req: Request) => { - const bearerAuth = checkAuth(req); - if (!bearerAuth) return null; - - // Fall back to form password - const body = await req.text(); - const params = new URLSearchParams(body); - if (params.get('password') === API_KEY) { - return null; - } - - return new Response(null, { status: 403 }); -}; - export const withAuth = (handler: (req: Request, ...args: T) => Response | Promise) => (req: Request, ...args: T) => { @@ -39,11 +27,3 @@ export const withAuth = if (auth) return auth; return handler(req, ...args); }; - -export const withFormAuth = - (handler: (req: Request, ...args: T) => Response | Promise) => - async (req: Request, ...args: T) => { - const auth = await checkFormAuth(req); - if (auth) return auth; - return handler(req, ...args); - }; diff --git a/server/utils/log.ts b/server/utils/log.ts index f96854c..260edf1 100644 --- a/server/utils/log.ts +++ b/server/utils/log.ts @@ -1,5 +1,5 @@ import chalk from 'chalk'; -import { VERBOSE } from '../constants/config'; +import { VERBOSE } from '@/constants/config'; export const logVerbose = (...args: unknown[]) => VERBOSE && console.log(...args); diff --git a/server/utils/rpc.ts b/server/utils/rpc.ts index ae1e745..eb0753d 100644 --- a/server/utils/rpc.ts +++ b/server/utils/rpc.ts @@ -1,4 +1,4 @@ -import { SIGNAL_CLI_SOCKET } from '../constants/paths'; +import { SIGNAL_CLI_SOCKET } from '@/constants/paths'; const MESSAGE_DELIMITER = '\n'; let rpcId = 1;