mirror of
https://github.com/lone-cloud/prism
synced 2026-06-03 08:43:10 -07:00
milestone: proton-bridge notifications are working well, new admin view
This commit is contained in:
parent
08e5db0597
commit
44804ac425
30 changed files with 571 additions and 400 deletions
|
|
@ -70,7 +70,6 @@ To receive ProtonMail notifications via Signal:
|
||||||
- Enter your ProtonMail email
|
- Enter your ProtonMail email
|
||||||
- Enter your ProtonMail password
|
- Enter your ProtonMail password
|
||||||
- Enter your 2FA code
|
- Enter your 2FA code
|
||||||
- Wait (potentially a long time) for ProtonMail Bridge to sync emails
|
|
||||||
|
|
||||||
3. **Get IMAP credentials**:
|
3. **Get IMAP credentials**:
|
||||||
- Run: `info`
|
- 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
|
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:
|
Or run services directly with Bun:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,8 @@ services:
|
||||||
protonmail-bridge:
|
protonmail-bridge:
|
||||||
image: shenxn/protonmail-bridge:build
|
image: shenxn/protonmail-bridge:build
|
||||||
profiles: ['protonmail']
|
profiles: ['protonmail']
|
||||||
|
ports:
|
||||||
|
- '127.0.0.1:143:143'
|
||||||
volumes:
|
volumes:
|
||||||
- proton-bridge-data:/root
|
- proton-bridge-data:/root
|
||||||
- /tmp/bridge-updates:/root/.local/share/protonmail/bridge-v3/updates:ro
|
- /tmp/bridge-updates:/root/.local/share/protonmail/bridge-v3/updates:ro
|
||||||
|
|
|
||||||
|
|
@ -14,8 +14,6 @@
|
||||||
"url": "https://github.com/lone-cloud/sup"
|
"url": "https://github.com/lone-cloud/sup"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"check": "tsc --noEmit && biome check .",
|
|
||||||
"fix": "biome check --write .",
|
|
||||||
"android:build": "bun run scripts/test-android-build.ts",
|
"android:build": "bun run scripts/test-android-build.ts",
|
||||||
"android:release": "bun run scripts/release-android.ts",
|
"android:release": "bun run scripts/release-android.ts",
|
||||||
"android:deps": "bun run scripts/check-android-deps.ts",
|
"android:deps": "bun run scripts/check-android-deps.ts",
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@ RUN ARCH=$(uname -m) && \
|
||||||
fi
|
fi
|
||||||
|
|
||||||
COPY --from=builder /app/sup-server /usr/local/bin/sup-server
|
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 PATH="/usr/local/signal-cli/bin:${PATH}"
|
||||||
ENV LD_LIBRARY_PATH="/usr/local/signal-cli/lib:${LD_LIBRARY_PATH}"
|
ENV LD_LIBRARY_PATH="/usr/local/signal-cli/lib:${LD_LIBRARY_PATH}"
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@
|
||||||
"name": "sup-server",
|
"name": "sup-server",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"chalk": "^5.6.2",
|
"chalk": "^5.6.2",
|
||||||
|
"htmx.org": "^2.0.8",
|
||||||
"imap": "^0.8.19",
|
"imap": "^0.8.19",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
@ -22,6 +23,8 @@
|
||||||
|
|
||||||
"core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="],
|
"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=="],
|
"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=="],
|
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@ export const DEVICE_NAME = 'SUP';
|
||||||
export const SUP_ENDPOINT_PREFIX = `[${DEVICE_NAME}:`;
|
export const SUP_ENDPOINT_PREFIX = `[${DEVICE_NAME}:`;
|
||||||
export const LAUNCH_ENDPOINT_PREFIX = '[LAUNCH:';
|
export const LAUNCH_ENDPOINT_PREFIX = '[LAUNCH:';
|
||||||
|
|
||||||
// ProtonMail Integration
|
|
||||||
export const BRIDGE_IMAP_USERNAME = Bun.env.BRIDGE_IMAP_USERNAME;
|
export const BRIDGE_IMAP_USERNAME = Bun.env.BRIDGE_IMAP_USERNAME;
|
||||||
export const BRIDGE_IMAP_PASSWORD = Bun.env.BRIDGE_IMAP_PASSWORD;
|
export const BRIDGE_IMAP_PASSWORD = Bun.env.BRIDGE_IMAP_PASSWORD;
|
||||||
export const PROTON_BRIDGE_HOST = Bun.env.PROTON_BRIDGE_HOST || 'protonmail-bridge';
|
export const PROTON_BRIDGE_HOST = Bun.env.PROTON_BRIDGE_HOST || 'protonmail-bridge';
|
||||||
|
|
|
||||||
|
|
@ -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_DIR = `${HOME}/.local/share/signal-cli`;
|
||||||
export const SIGNAL_CLI_DATA = `${HOME}/.local/share/signal-cli/data`;
|
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`;
|
export const SUP_DB = `${HOME}/.local/share/sup/store.db`;
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,15 @@
|
||||||
export const ROUTES = {
|
export const ROUTES = {
|
||||||
HEALTH: '/health',
|
|
||||||
LINK: '/link',
|
|
||||||
LINK_QR: '/link/qr',
|
|
||||||
LINK_STATUS: '/link/status',
|
|
||||||
LINK_UNLINK: '/link/unlink',
|
|
||||||
FAVICON: '/favicon.png',
|
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',
|
MATRIX_NOTIFY: '/_matrix/push/v1/notify',
|
||||||
UP: '/up',
|
UP: '/up',
|
||||||
UP_INSTANCE: '/up/:instance',
|
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;
|
} as const;
|
||||||
|
|
|
||||||
102
server/index.ts
102
server/index.ts
|
|
@ -1,39 +1,18 @@
|
||||||
import { API_KEY, BRIDGE_IMAP_PASSWORD, BRIDGE_IMAP_USERNAME, PORT } from './constants/config';
|
import { API_KEY, BRIDGE_IMAP_PASSWORD, BRIDGE_IMAP_USERNAME, PORT } from '@/constants/config';
|
||||||
import { ROUTES } from './constants/server';
|
import { ROUTES } from '@/constants/server';
|
||||||
import { checkSignalCli, initSignal, startDaemon } from './modules/signal';
|
import { cleanupDaemon, initSignal } from '@/modules/signal';
|
||||||
import { handleHealth } from './routes/health';
|
import { adminRoutes } from '@/routes/admin/index';
|
||||||
import { handleLink, handleLinkQR, handleLinkStatus, handleUnlink } from './routes/link';
|
import { unifiedPushRoutes } from '@/routes/unifiedpush';
|
||||||
import { handleNotify, handleTopics } from './routes/notify';
|
import { logError, logInfo, logWarn } from '@/utils/log';
|
||||||
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<typeof Bun.spawn> | null = null;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
daemon = await startDaemon();
|
await initSignal();
|
||||||
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`);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logError(` ${error instanceof Error ? error.message : String(error)}`);
|
logError(`${error instanceof Error ? error.message : String(error)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!API_KEY) {
|
if (!API_KEY) {
|
||||||
logWarn('Server running without 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) {
|
if (BRIDGE_IMAP_USERNAME && BRIDGE_IMAP_PASSWORD) {
|
||||||
|
|
@ -51,60 +30,23 @@ const server = Bun.serve({
|
||||||
idleTimeout: 60,
|
idleTimeout: 60,
|
||||||
|
|
||||||
routes: {
|
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]: {
|
...unifiedPushRoutes,
|
||||||
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))),
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
logInfo(`\nSUP running on http://localhost:${server.port} 🚀`);
|
logInfo(`\nSUP running on http://localhost:${server.port} 🚀`);
|
||||||
|
|
||||||
|
process.on('SIGINT', () => {
|
||||||
|
cleanupDaemon();
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on('SIGTERM', () => {
|
||||||
|
cleanupDaemon();
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -7,10 +7,14 @@ import {
|
||||||
PROTON_BRIDGE_HOST,
|
PROTON_BRIDGE_HOST,
|
||||||
PROTON_BRIDGE_PORT,
|
PROTON_BRIDGE_PORT,
|
||||||
SUP_TOPIC,
|
SUP_TOPIC,
|
||||||
} from '../constants/config';
|
} from '@/constants/config';
|
||||||
import { logError, logInfo, logSuccess, logVerbose, logWarn } from '../utils/log';
|
import { createGroup, hasValidAccount, sendGroupMessage } from '@/modules/signal';
|
||||||
import { createGroup, sendGroupMessage } from './signal';
|
import { getGroupId, register } from '@/modules/store';
|
||||||
import { getGroupId, register } from './store';
|
import { logError, logInfo, logSuccess, logVerbose, logWarn } from '@/utils/log';
|
||||||
|
|
||||||
|
let imapConnected = false;
|
||||||
|
|
||||||
|
export const isImapConnected = () => imapConnected;
|
||||||
|
|
||||||
export async function startProtonMonitor() {
|
export async function startProtonMonitor() {
|
||||||
if (!BRIDGE_IMAP_USERNAME || !BRIDGE_IMAP_PASSWORD) {
|
if (!BRIDGE_IMAP_USERNAME || !BRIDGE_IMAP_PASSWORD) {
|
||||||
|
|
@ -20,20 +24,31 @@ export async function startProtonMonitor() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
logInfo(`🔗 Connecting to Proton Bridge at ${PROTON_BRIDGE_HOST}:${PROTON_BRIDGE_PORT}`);
|
const linked = await hasValidAccount();
|
||||||
logInfo(`📨 Monitoring mailbox: ${BRIDGE_IMAP_USERNAME}`);
|
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({
|
const imap = new Imap({
|
||||||
user: BRIDGE_IMAP_USERNAME,
|
user: BRIDGE_IMAP_USERNAME,
|
||||||
password: BRIDGE_IMAP_PASSWORD,
|
password: BRIDGE_IMAP_PASSWORD,
|
||||||
host: PROTON_BRIDGE_HOST,
|
host: PROTON_BRIDGE_HOST,
|
||||||
port: PROTON_BRIDGE_PORT,
|
port: PROTON_BRIDGE_PORT,
|
||||||
tls: true,
|
tls: false,
|
||||||
tlsOptions: { rejectUnauthorized: false },
|
tlsOptions: { rejectUnauthorized: false },
|
||||||
keepalive: true,
|
keepalive: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
async function sendNotification(title: string, message: string) {
|
async function sendNotification(title: string, message: string) {
|
||||||
|
if (!(await hasValidAccount())) {
|
||||||
|
logVerbose('Skipping notification (Signal not linked)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const topicKey = `proton-${SUP_TOPIC}`;
|
const topicKey = `proton-${SUP_TOPIC}`;
|
||||||
const groupId = getGroupId(topicKey) ?? (await createGroup(SUP_TOPIC));
|
const groupId = getGroupId(topicKey) ?? (await createGroup(SUP_TOPIC));
|
||||||
|
|
@ -42,12 +57,16 @@ export async function startProtonMonitor() {
|
||||||
register(topicKey, groupId, SUP_TOPIC);
|
register(topicKey, groupId, SUP_TOPIC);
|
||||||
}
|
}
|
||||||
|
|
||||||
const prefix = ENABLE_PROTON_ANDROID
|
if (ENABLE_PROTON_ANDROID) {
|
||||||
? `${LAUNCH_ENDPOINT_PREFIX}ch.protonmail.android]\n`
|
await sendGroupMessage(
|
||||||
: '';
|
groupId,
|
||||||
await sendGroupMessage(groupId, `${prefix}**${title}**\n${message}`);
|
`${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) {
|
} catch (error) {
|
||||||
logError('Failed to send notification:', error);
|
logError('Failed to send notification:', error);
|
||||||
}
|
}
|
||||||
|
|
@ -60,10 +79,8 @@ export async function startProtonMonitor() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
logVerbose(`Connected to inbox (${box.messages.total} messages)`);
|
|
||||||
|
|
||||||
imap.on('mail', async (numNewMsgs: number) => {
|
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}:*`, {
|
const fetch = imap.seq.fetch(`${box.messages.total}:*`, {
|
||||||
bodies: 'HEADER.FIELDS (FROM SUBJECT)',
|
bodies: 'HEADER.FIELDS (FROM SUBJECT)',
|
||||||
|
|
@ -78,34 +95,39 @@ export async function startProtonMonitor() {
|
||||||
});
|
});
|
||||||
stream.once('end', () => {
|
stream.once('end', () => {
|
||||||
const header = Imap.parseHeader(buffer);
|
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';
|
const subject = header.subject?.[0] || 'No subject';
|
||||||
|
|
||||||
sendNotification(`New Mail from ${from}`, subject);
|
const nameMatch = rawFrom.match(/^"?([^"<]+)"?\s*<?/);
|
||||||
|
const from = nameMatch ? nameMatch[1]?.trim() : rawFrom;
|
||||||
|
|
||||||
|
sendNotification('New Email Received', `From: ${from} - ${subject}`);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
imap.on('update', () => {
|
|
||||||
logVerbose('📊 Mailbox updated');
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
imap.once('ready', () => {
|
imap.on('ready', () => {
|
||||||
logVerbose('IMAP connection ready');
|
imapConnected = true;
|
||||||
|
logSuccess('IMAP is ready');
|
||||||
openInbox();
|
openInbox();
|
||||||
});
|
});
|
||||||
|
|
||||||
imap.once('error', (err: Error) => {
|
imap.on('error', (err: Error) => {
|
||||||
logError('IMAP error:', err);
|
imapConnected = false;
|
||||||
logWarn('ProtonMail integration disabled due to connection error');
|
logError('IMAP error:', err.message);
|
||||||
});
|
});
|
||||||
|
|
||||||
imap.once('end', () => {
|
imap.on('close', (hadError: boolean) => {
|
||||||
logVerbose('IMAP connection ended, reconnecting...');
|
imapConnected = false;
|
||||||
setTimeout(() => imap.connect(), 5000);
|
logError(`IMAP connection closed (hadError: ${hadError})`);
|
||||||
|
});
|
||||||
|
|
||||||
|
imap.on('end', () => {
|
||||||
|
imapConnected = false;
|
||||||
|
logError('IMAP connection ended by server');
|
||||||
});
|
});
|
||||||
|
|
||||||
imap.connect();
|
imap.connect();
|
||||||
|
|
|
||||||
|
|
@ -1,31 +1,47 @@
|
||||||
import { rm, unlink } from 'node:fs/promises';
|
import { rm, unlink } from 'node:fs/promises';
|
||||||
import { DEVICE_NAME, VERBOSE } from '../constants/config';
|
import { DEVICE_NAME, PORT, VERBOSE } from '@/constants/config';
|
||||||
import { SIGNAL_CLI, SIGNAL_CLI_DATA, SIGNAL_CLI_SOCKET } from '../constants/paths';
|
import { SIGNAL_CLI, SIGNAL_CLI_DATA, SIGNAL_CLI_SOCKET } from '@/constants/paths';
|
||||||
import type { ListAccountsResult, StartLinkResult, UpdateGroupResult } from '../types';
|
import type { ListAccountsResult, StartLinkResult, UpdateGroupResult } from '@/types';
|
||||||
import { logError, logInfo, logSuccess, logVerbose, logWarn } from '../utils/log';
|
import { logError, logInfo, logSuccess, logVerbose, logWarn } from '@/utils/log';
|
||||||
import { call } from '../utils/rpc';
|
import { call } from '@/utils/rpc';
|
||||||
|
|
||||||
logVerbose(`Running signal-cli from ${SIGNAL_CLI}`);
|
|
||||||
|
|
||||||
let account: string | null = null;
|
let account: string | null = null;
|
||||||
let currentLinkUri: string | null = null;
|
let currentLinkUri: string | null = null;
|
||||||
|
let daemon: ReturnType<typeof Bun.spawn> | null = null;
|
||||||
|
|
||||||
export const hasLinkUri = () => currentLinkUri !== 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) {
|
if (accountOverride) {
|
||||||
account = 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;
|
const [firstAccount] = result;
|
||||||
if (firstAccount) {
|
if (firstAccount) {
|
||||||
account = firstAccount;
|
account = firstAccount.number;
|
||||||
return true;
|
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() {
|
export async function generateLinkQR() {
|
||||||
|
|
@ -71,12 +87,13 @@ export async function unlinkDevice() {
|
||||||
currentLinkUri = null;
|
currentLinkUri = null;
|
||||||
|
|
||||||
if (await Bun.file(SIGNAL_CLI_DATA).exists()) {
|
if (await Bun.file(SIGNAL_CLI_DATA).exists()) {
|
||||||
logWarn('Unlinking device and removing account data...');
|
logWarn('Removing local account data...');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await rm(SIGNAL_CLI_DATA, { recursive: true, force: true });
|
await rm(SIGNAL_CLI_DATA, { recursive: true, force: true });
|
||||||
|
logSuccess('Account data removed');
|
||||||
} catch (error) {
|
} 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;
|
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(
|
await call(
|
||||||
'send',
|
'send',
|
||||||
{
|
{
|
||||||
groupId,
|
groupId,
|
||||||
message,
|
message,
|
||||||
|
'notify-self': notifySelf,
|
||||||
},
|
},
|
||||||
account,
|
account,
|
||||||
);
|
);
|
||||||
|
|
@ -134,8 +156,8 @@ export async function startDaemon() {
|
||||||
try {
|
try {
|
||||||
await unlink(SIGNAL_CLI_SOCKET);
|
await unlink(SIGNAL_CLI_SOCKET);
|
||||||
logVerbose('Removed stale socket file');
|
logVerbose('Removed stale socket file');
|
||||||
} catch (error: any) {
|
} catch (error) {
|
||||||
if (error.code !== 'ENOENT') {
|
if (error instanceof Error && 'code' in error && error.code !== 'ENOENT') {
|
||||||
logError('Failed to remove stale socket file:', error);
|
logError('Failed to remove stale socket file:', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -194,6 +216,7 @@ export async function startDaemon() {
|
||||||
});
|
});
|
||||||
socket.end();
|
socket.end();
|
||||||
logSuccess('signal-cli daemon started');
|
logSuccess('signal-cli daemon started');
|
||||||
|
daemon = proc;
|
||||||
return proc;
|
return proc;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (authError && !cleaned) {
|
if (authError && !cleaned) {
|
||||||
|
|
@ -211,3 +234,13 @@ export async function startDaemon() {
|
||||||
throw new Error('Failed to start signal-cli daemon');
|
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();
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { Database } from 'bun:sqlite';
|
import { Database } from 'bun:sqlite';
|
||||||
import { SUP_DATA_DIR, SUP_DB } from '../constants/paths';
|
import { SUP_DB } from '@/constants/paths';
|
||||||
|
|
||||||
interface EndpointMapping {
|
interface EndpointMapping {
|
||||||
endpoint: string;
|
endpoint: string;
|
||||||
|
|
@ -7,8 +7,6 @@ interface EndpointMapping {
|
||||||
appName: string;
|
appName: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
await Bun.write(`${SUP_DATA_DIR}/.keep`, '');
|
|
||||||
|
|
||||||
const db = new Database(SUP_DB);
|
const db = new Database(SUP_DB);
|
||||||
|
|
||||||
db.run(`
|
db.run(`
|
||||||
|
|
@ -41,9 +39,8 @@ export const getAppName = (endpoint: string) => {
|
||||||
return row?.appName;
|
return row?.appName;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getAllMappings = () => {
|
export const getAllMappings = () =>
|
||||||
return db.query('SELECT endpoint, groupId, appName FROM mappings').all() as EndpointMapping[];
|
db.query('SELECT endpoint, groupId, appName FROM mappings').all() as EndpointMapping[];
|
||||||
};
|
|
||||||
|
|
||||||
export const remove = (endpoint: string) => {
|
export const remove = (endpoint: string) => {
|
||||||
db.run('DELETE FROM mappings WHERE endpoint = ?', [endpoint]);
|
db.run('DELETE FROM mappings WHERE endpoint = ?', [endpoint]);
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { SUP_ENDPOINT_PREFIX } from '../constants/config';
|
import { SUP_ENDPOINT_PREFIX } from '@/constants/config';
|
||||||
|
|
||||||
export interface UnifiedPushMessage {
|
export interface UnifiedPushMessage {
|
||||||
endpoint: string;
|
endpoint: string;
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"chalk": "^5.6.2",
|
"chalk": "^5.6.2",
|
||||||
|
"htmx.org": "^2.0.8",
|
||||||
"imap": "^0.8.19"
|
"imap": "^0.8.19"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
@ -19,6 +20,6 @@
|
||||||
"start": "bun run index.ts",
|
"start": "bun run index.ts",
|
||||||
"build": "bun build --compile index.ts --outfile sup-server",
|
"build": "bun build --compile index.ts --outfile sup-server",
|
||||||
"check": "tsc --noEmit && biome check .",
|
"check": "tsc --noEmit && biome check .",
|
||||||
"fix": "biome check --write ."
|
"fix": "biome check --write --unsafe ."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
120
server/public/admin.css
Normal file
120
server/public/admin.css
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
66
server/public/admin.html
Normal file
66
server/public/admin.html
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>SUP Admin</title>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<link rel="icon" type="image/png" href="/favicon.png">
|
||||||
|
<link rel="stylesheet" href="/admin.css">
|
||||||
|
<script src="/htmx.js"></script>
|
||||||
|
</head>
|
||||||
|
<body hx-headers='{"Authorization": "Bearer __API_KEY__"}'>
|
||||||
|
<div class="card">
|
||||||
|
<div id="health-status"
|
||||||
|
hx-get="/health/fragment"
|
||||||
|
hx-trigger="load, every 10s"
|
||||||
|
class="loading">
|
||||||
|
Loading...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Signal</h2>
|
||||||
|
<div id="signal-info"
|
||||||
|
hx-get="/signal-info/fragment"
|
||||||
|
hx-trigger="load">
|
||||||
|
Loading...
|
||||||
|
</div>
|
||||||
|
<div id="qr-section"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Endpoints</h2>
|
||||||
|
<div id="endpoints-list"
|
||||||
|
hx-get="/endpoints/fragment"
|
||||||
|
hx-trigger="load"
|
||||||
|
class="loading">
|
||||||
|
Loading...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let apiKey = sessionStorage.getItem('sup_api_key');
|
||||||
|
if (!apiKey) {
|
||||||
|
apiKey = prompt('Enter API_KEY:');
|
||||||
|
if (!apiKey) {
|
||||||
|
document.body.innerHTML = '<div class="card"><h1>Access Denied</h1><p>API_KEY required</p></div>';
|
||||||
|
} else {
|
||||||
|
sessionStorage.setItem('sup_api_key', apiKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (apiKey) {
|
||||||
|
const authHeader = JSON.stringify({ 'Authorization': `Bearer ${apiKey}` });
|
||||||
|
document.body.setAttribute('hx-headers', authHeader);
|
||||||
|
|
||||||
|
document.body.addEventListener('htmx:responseError', (e) => {
|
||||||
|
if (e.detail.xhr.status === 401) {
|
||||||
|
sessionStorage.removeItem('sup_api_key');
|
||||||
|
alert('Invalid API_KEY. Please refresh and try again.');
|
||||||
|
location.reload();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
Before Width: | Height: | Size: 133 KiB After Width: | Height: | Size: 133 KiB |
146
server/routes/admin/fragments.ts
Normal file
146
server/routes/admin/fragments.ts
Normal file
|
|
@ -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 = `
|
||||||
|
<div class="status">
|
||||||
|
<div class="status-item ${signalOk ? 'status-ok' : 'status-error'}">
|
||||||
|
Signal: ${signalOk ? 'Connected' : 'Disconnected'}
|
||||||
|
</div>
|
||||||
|
<div class="status-item ${linked ? 'status-ok' : 'status-error'}">
|
||||||
|
Account: ${linked ? 'Linked' : 'Unlinked'}
|
||||||
|
</div>
|
||||||
|
<div class="status-item ${imap ? 'status-ok' : 'status-error'}">
|
||||||
|
IMAP: ${imap ? 'Connected' : 'Disconnected'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
return new Response(html, {
|
||||||
|
headers: { 'content-type': 'text/html' },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const handleSignalInfoFragment = async () => {
|
||||||
|
const linked = await hasValidAccount();
|
||||||
|
|
||||||
|
const html = linked
|
||||||
|
? `<details style="margin-top: 15px;">
|
||||||
|
<summary style="cursor: pointer; font-weight: bold;">Unlink and remove device</summary>
|
||||||
|
<div style="margin-top: 15px;">
|
||||||
|
<ol style="margin-left: 20px;">
|
||||||
|
<li>Open Signal app → <strong>Settings → Linked Devices</strong></li>
|
||||||
|
<li>Find <strong>"${DEVICE_NAME}"</strong> and tap it</li>
|
||||||
|
<li>Tap <strong>"Unlink Device"</strong></li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</details>`
|
||||||
|
: `<button onclick="document.getElementById('qr-section').style.display='block';
|
||||||
|
this.parentElement.style.display='none';
|
||||||
|
htmx.ajax('GET', '/link/qr-section', {target: '#qr-section', swap: 'innerHTML'})"
|
||||||
|
class="link-button" style="border:none;cursor:pointer;">
|
||||||
|
Link Signal Device
|
||||||
|
</button>`;
|
||||||
|
|
||||||
|
return new Response(html, {
|
||||||
|
headers: { 'content-type': 'text/html' },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const handleEndpointsFragment = async () => {
|
||||||
|
const endpoints = getAllMappings();
|
||||||
|
|
||||||
|
if (endpoints.length === 0) {
|
||||||
|
return new Response('<p>No endpoints registered</p>', {
|
||||||
|
headers: { 'content-type': 'text/html' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const html = `
|
||||||
|
<ul class="endpoint-list">
|
||||||
|
${endpoints.map((e) => `<li><strong>${e.appName}</strong><br><code>${e.endpoint}</code> → ${e.groupId}</li>`).join('')}
|
||||||
|
</ul>
|
||||||
|
`;
|
||||||
|
|
||||||
|
return new Response(html, {
|
||||||
|
headers: { 'content-type': 'text/html' },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const handleQRSection = async () => {
|
||||||
|
const html = `
|
||||||
|
<p>Scan this QR code with your Signal app:</p>
|
||||||
|
<p style="font-size: 1.05em;"><strong>Settings → Linked Devices → Link New Device</strong></p>
|
||||||
|
<div hx-get="/link/qr-image" hx-trigger="load, every 30s" style="margin-top:15px;">
|
||||||
|
Generating QR code...
|
||||||
|
</div>
|
||||||
|
<div hx-get="/link/status-check" hx-trigger="every 2s" hx-swap="none"></div>
|
||||||
|
<button onclick="document.getElementById('qr-section').style.display='none';
|
||||||
|
document.getElementById('signal-info').style.display='block'"
|
||||||
|
style="margin-top:15px;padding:8px 16px;background:#6c757d;color:white;border:none;border-radius:4px;cursor:pointer;">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
`;
|
||||||
|
|
||||||
|
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 = `<img src="${qrDataUrl}" style="max-width: 300px;" alt="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' },
|
||||||
|
});
|
||||||
|
};
|
||||||
42
server/routes/admin/index.ts
Normal file
42
server/routes/admin/index.ts
Normal file
|
|
@ -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),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -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',
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
@ -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 =
|
|
||||||
'<input type="password" name="password" placeholder="Enter API_KEY" required style="padding: 8px; margin-right: 10px; border: 1px solid #ccc; border-radius: 4px;" />';
|
|
||||||
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<void>) => {
|
|
||||||
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<void>) => {
|
|
||||||
await unlinkDevice();
|
|
||||||
await killAndRestart();
|
|
||||||
|
|
||||||
return new Response('', {
|
|
||||||
status: 303,
|
|
||||||
headers: { Location: ROUTES.LINK },
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
@ -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 });
|
|
||||||
};
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
import { ROUTES } from '../constants/server';
|
import { ROUTES } from '@/constants/server';
|
||||||
import { createGroup, sendGroupMessage } from '../modules/signal';
|
import { createGroup, sendGroupMessage } from '@/modules/signal';
|
||||||
import { getAllMappings, getGroupId, register, remove } from '../modules/store';
|
import { getGroupId, register, remove } from '@/modules/store';
|
||||||
import { formatAsSignalMessage, parseUnifiedPushRequest } from '../modules/unifiedpush';
|
import { formatAsSignalMessage, parseUnifiedPushRequest } from '@/modules/unifiedpush';
|
||||||
|
|
||||||
export const handleMatrixNotify = async (req: Request) => {
|
const handleMatrixNotify = async (req: Request) => {
|
||||||
const message = await parseUnifiedPushRequest(req);
|
const message = await parseUnifiedPushRequest(req);
|
||||||
const groupId = getGroupId(message.endpoint);
|
const groupId = getGroupId(message.endpoint);
|
||||||
|
|
||||||
|
|
@ -17,7 +17,8 @@ export const handleMatrixNotify = async (req: Request) => {
|
||||||
return Response.json({ success: true });
|
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 endpointId = url.pathname.split('/')[2] ?? '';
|
||||||
const { appName } = (await req.json()) as {
|
const { appName } = (await req.json()) as {
|
||||||
appName: string;
|
appName: string;
|
||||||
|
|
@ -38,16 +39,30 @@ export const handleRegister = async (req: Request, url: URL) => {
|
||||||
return Response.json({ endpoint, gateway: 'matrix' });
|
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] ?? '';
|
const endpointId = url.pathname.split('/')[2] ?? '';
|
||||||
remove(endpointId);
|
remove(endpointId);
|
||||||
return new Response(null, { status: 204 });
|
return new Response(null, { status: 204 });
|
||||||
};
|
};
|
||||||
|
|
||||||
export const handleDiscovery = () =>
|
const handleDiscovery = () =>
|
||||||
Response.json({
|
Response.json({
|
||||||
unifiedpush: { version: 1 },
|
unifiedpush: { version: 1 },
|
||||||
gateway: 'matrix',
|
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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,38 +0,0 @@
|
||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<title>SUP - Link Signal</title>
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<link rel="icon" type="image/png" href="/favicon.png">
|
|
||||||
</head>
|
|
||||||
<body style="font-family: system-ui; max-width: 600px; margin: 50px auto; padding: 20px; text-align: center;">
|
|
||||||
<h1>Link Signal Account</h1>
|
|
||||||
<p>Scan this QR code with your Signal app:</p>
|
|
||||||
<p style="font-size: 1.1em;"><strong>Settings <span style="font-size: 1.5em;">→</span> Linked Devices <span style="font-size: 1.5em;">→</span> Link New Device</strong></p>
|
|
||||||
<div id="qr">Generating QR code...</div>
|
|
||||||
<script>
|
|
||||||
let refreshInterval;
|
|
||||||
|
|
||||||
function loadQR() {
|
|
||||||
fetch('/link/qr').then(r => r.text()).then(qr => {
|
|
||||||
document.getElementById('qr').innerHTML = `<img src="${qr}" style="max-width: 300px;" />`;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
loadQR();
|
|
||||||
|
|
||||||
refreshInterval = setInterval(loadQR, 30000);
|
|
||||||
|
|
||||||
const check = setInterval(() => {
|
|
||||||
fetch('/link/status').then(r => r.json()).then(data => {
|
|
||||||
if (data.linked) {
|
|
||||||
clearInterval(check);
|
|
||||||
clearInterval(refreshInterval);
|
|
||||||
location.reload();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, 2000);
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<title>SUP - Linked</title>
|
|
||||||
<link rel="icon" type="image/png" href="/favicon.png">
|
|
||||||
</head>
|
|
||||||
<body style="font-family: system-ui; max-width: 600px; margin: 50px auto; padding: 20px;">
|
|
||||||
<h1>Signal Linked</h1>
|
|
||||||
<p>Your Signal account is already linked to this server.</p>
|
|
||||||
|
|
||||||
<details style="margin-top: 20px;">
|
|
||||||
<summary style="cursor: pointer; font-weight: bold;">Unlink and re-link with different account</summary>
|
|
||||||
<div style="margin-top: 15px; text-align: left;">
|
|
||||||
<p><strong>Steps to unlink:</strong></p>
|
|
||||||
<ol>
|
|
||||||
<li>Open Signal app → <strong>Settings → Linked Devices</strong></li>
|
|
||||||
<li>Find <strong>"SUP"</strong> and tap it</li>
|
|
||||||
<li>Tap <strong>"Unlink Device"</strong></li>
|
|
||||||
<li>Click the button below to clear server data:</li>
|
|
||||||
</ol>
|
|
||||||
<form method="POST" action="/link/unlink" style="margin-top: 15px;">
|
|
||||||
{{PASSWORD_FIELD}}
|
|
||||||
<button type="submit" style="padding: 10px 20px; cursor: pointer; background: #ff5555; color: white; border: none; border-radius: 4px; font-size: 14px;">Clear Server Data & Restart</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</details>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
9
server/tsconfig.json
Normal file
9
server/tsconfig.json
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
{
|
||||||
|
"extends": "../tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./*"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
8
server/types.d.ts
vendored
8
server/types.d.ts
vendored
|
|
@ -27,4 +27,10 @@ export interface UpdateGroupResult {
|
||||||
groupId: string;
|
groupId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ListAccountsResult = string[];
|
export interface AccountInfo {
|
||||||
|
number: string;
|
||||||
|
name?: string;
|
||||||
|
uuid?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ListAccountsResult = AccountInfo[];
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
import { API_KEY } from '../constants/config';
|
import { API_KEY } from '@/constants/config';
|
||||||
|
|
||||||
const checkAuth = (req: Request) => {
|
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 proto = req.headers.get('x-forwarded-proto') || 'http';
|
||||||
const host = req.headers.get('host') || '';
|
const host = req.headers.get('host') || '';
|
||||||
|
|
@ -18,20 +20,6 @@ const checkAuth = (req: Request) => {
|
||||||
return null;
|
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 =
|
export const withAuth =
|
||||||
<T extends unknown[]>(handler: (req: Request, ...args: T) => Response | Promise<Response>) =>
|
<T extends unknown[]>(handler: (req: Request, ...args: T) => Response | Promise<Response>) =>
|
||||||
(req: Request, ...args: T) => {
|
(req: Request, ...args: T) => {
|
||||||
|
|
@ -39,11 +27,3 @@ export const withAuth =
|
||||||
if (auth) return auth;
|
if (auth) return auth;
|
||||||
return handler(req, ...args);
|
return handler(req, ...args);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const withFormAuth =
|
|
||||||
<T extends unknown[]>(handler: (req: Request, ...args: T) => Response | Promise<Response>) =>
|
|
||||||
async (req: Request, ...args: T) => {
|
|
||||||
const auth = await checkFormAuth(req);
|
|
||||||
if (auth) return auth;
|
|
||||||
return handler(req, ...args);
|
|
||||||
};
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import chalk from 'chalk';
|
import chalk from 'chalk';
|
||||||
import { VERBOSE } from '../constants/config';
|
import { VERBOSE } from '@/constants/config';
|
||||||
|
|
||||||
export const logVerbose = (...args: unknown[]) => VERBOSE && console.log(...args);
|
export const logVerbose = (...args: unknown[]) => VERBOSE && console.log(...args);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { SIGNAL_CLI_SOCKET } from '../constants/paths';
|
import { SIGNAL_CLI_SOCKET } from '@/constants/paths';
|
||||||
|
|
||||||
const MESSAGE_DELIMITER = '\n';
|
const MESSAGE_DELIMITER = '\n';
|
||||||
let rpcId = 1;
|
let rpcId = 1;
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue