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 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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
|
|
|||
|
|
@ -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=="],
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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`;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
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 { 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<typeof Bun.spawn> | 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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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*<?/);
|
||||
const from = nameMatch ? nameMatch[1]?.trim() : rawFrom;
|
||||
|
||||
sendNotification('New Email Received', `From: ${from} - ${subject}`);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
imap.on('update', () => {
|
||||
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();
|
||||
|
|
|
|||
|
|
@ -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<typeof Bun.spawn> | 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();
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { SUP_ENDPOINT_PREFIX } from '../constants/config';
|
||||
import { SUP_ENDPOINT_PREFIX } from '@/constants/config';
|
||||
|
||||
export interface UnifiedPushMessage {
|
||||
endpoint: string;
|
||||
|
|
|
|||
|
|
@ -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 ."
|
||||
}
|
||||
}
|
||||
|
|
|
|||
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 { 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,
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
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) => {
|
||||
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 =
|
||||
<T extends unknown[]>(handler: (req: Request, ...args: T) => Response | Promise<Response>) =>
|
||||
(req: Request, ...args: T) => {
|
||||
|
|
@ -39,11 +27,3 @@ export const withAuth =
|
|||
if (auth) return auth;
|
||||
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 { VERBOSE } from '../constants/config';
|
||||
import { VERBOSE } from '@/constants/config';
|
||||
|
||||
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';
|
||||
let rpcId = 1;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue