Signal chats in the app (desktop only) with a chat button on every screen
build-container / build (push) Successful in 6m44s
build-container / build (push) Successful in 6m44s
- signal.js: talks to signal-cli-rest-api (a linked device of your Signal account); keeps conversations in /data/signal; live updates; send/receive text, files, replies, reactions, groups, receipts - Signal tab and a floating chat panel on every screen, shown only on desktop-class windows; new Signal permission per person - compose: signal bridge container sharing the hub's network - tools/signal-test.js (fake bridge), README Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
5157e42bc4
commit
d0070aa0bb
10 files changed
+746
-11
No files matched your search
@@ -21,6 +21,7 @@ jobs:
|
|||||||
- run: node tools/users-test.js # accounts: private data, tab limits, invites, migration
|
- run: node tools/users-test.js # accounts: private data, tab limits, invites, migration
|
||||||
- run: node tools/plex-test.js # Plex sign-in stays signed in and repairs itself
|
- run: node tools/plex-test.js # Plex sign-in stays signed in and repairs itself
|
||||||
- run: node tools/player-test.js # music player: queue, shuffle/repeat, stall recovery
|
- run: node tools/player-test.js # music player: queue, shuffle/repeat, stall recovery
|
||||||
|
- run: node tools/signal-test.js # Signal: linking, messages, sending, live updates
|
||||||
- run: node tools/webmail-test.js # the /webmail/ pass-through to the bundled webmail (Cypht)
|
- run: node tools/webmail-test.js # the /webmail/ pass-through to the bundled webmail (Cypht)
|
||||||
|
|
||||||
- uses: docker/setup-buildx-action@v3
|
- uses: docker/setup-buildx-action@v3
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@ FROM node:22-alpine
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package.json package-lock.json ./
|
COPY package.json package-lock.json ./
|
||||||
RUN npm ci --omit=dev
|
RUN npm ci --omit=dev
|
||||||
COPY server.js auth.js cal.js ics.js caldav.js tasks.js settings.js search.js users.js admin.js vault.js ./
|
COPY server.js auth.js cal.js ics.js caldav.js tasks.js settings.js search.js users.js admin.js vault.js signal.js ./
|
||||||
COPY public ./public
|
COPY public ./public
|
||||||
ENV PORT=8080 DATA_DIR=/data
|
ENV PORT=8080 DATA_DIR=/data
|
||||||
RUN mkdir -p /data && chown node:node /data
|
RUN mkdir -p /data && chown node:node /data
|
||||||
|
|||||||
@@ -53,6 +53,15 @@ e.g. hub.necloud.us. Settings: `SEAFILE_URL`, `ONLYOFFICE_URL` env vars in docke
|
|||||||
script or logs. Anyone holding the link is signed in there with full access, so keep it out of the repo and `.env` backups. Or set `AUDIOBOOKS_SSO_SECRET`
|
script or logs. Anyone holding the link is signed in there with full access, so keep it out of the repo and `.env` backups. Or set `AUDIOBOOKS_SSO_SECRET`
|
||||||
(and optionally `AUDIOBOOKS_SSO_URL`, default `<AUDIOBOOKS_URL>/sso`) for short-lived signed links: `?token=<body>.<sig>`, where `body` is base64url JSON
|
(and optionally `AUDIOBOOKS_SSO_URL`, default `<AUDIOBOOKS_URL>/sso`) for short-lived signed links: `?token=<body>.<sig>`, where `body` is base64url JSON
|
||||||
`{u, id, exp, n}` and `sig` is the base64url HMAC-SHA256 of `body`. Without either, the plain site opens and the saved username can be copied.
|
`{u, id, exp, n}` and `sig` is the base64url HMAC-SHA256 of `body`. Without either, the plain site opens and the saved username can be copied.
|
||||||
|
- **Signal**: your Signal chats inside the app, **on desktop-class screens only** (a window at least 900 px wide with a mouse or trackpad; phones and touch tablets don't
|
||||||
|
see it). Besides the Signal tab there is a chat button on **every** screen that opens a panel over whatever you are doing (close it with the x; the
|
||||||
|
expand button opens the full page). It talks to your account through the community `signal-cli-rest-api` bridge, which is one more *linked device* of your account
|
||||||
|
(like Signal Desktop; not an official Signal product). The `signal` container in `docker-compose.yml` runs it; remove that service and the hub's `SIGNAL_API_URL` line to skip Signal.
|
||||||
|
First time: open the Signal tab, then on your phone Signal -> Settings -> Linked devices -> Link new device and scan the code. You can send and receive text, photos,
|
||||||
|
files, voice messages (played, not recorded), replies, reactions, groups and read receipts; there are no calls. Only messages from after linking appear (Signal does
|
||||||
|
not copy older ones to a new device) and they are kept in `/data/signal`. The link keys live in the `nehub-signal` volume: whoever gets into your server can read your chats,
|
||||||
|
so only give the **Signal** tab to people you would trust with your Signal (it is one shared account, admins always have it). Signal drops a linked device that is
|
||||||
|
not connected for about a month; the hub stays connected, so this only matters if the stack is switched off.
|
||||||
- **Podcast** and **Podcast Admin**: launchers that open `PODCAST_URL` (a home-network address) and `VERSE_URL` in their own tabs.
|
- **Podcast** and **Podcast Admin**: launchers that open `PODCAST_URL` (a home-network address) and `VERSE_URL` in their own tabs.
|
||||||
- **Office**: embeds onlyoffice.necloud.us in a frame, with an "open in new tab" fallback if it blocks framing.
|
- **Office**: embeds onlyoffice.necloud.us in a frame, with an "open in new tab" fallback if it blocks framing.
|
||||||
- **Mail**: Cypht comes with the project (a second container in `docker-compose.yml`), so the Mail tab can open a full webmail inside the app (the shortcut cards are the default page; the **Open webmail** button there opens it, and the envelope button in
|
- **Mail**: Cypht comes with the project (a second container in `docker-compose.yml`), so the Mail tab can open a full webmail inside the app (the shortcut cards are the default page; the **Open webmail** button there opens it, and the envelope button in
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ services:
|
|||||||
WEBMAIL_BACKEND_URL: http://127.0.0.1:80 # the bundled webmail (Cypht, below, shares this container's network); the hub shows it at /webmail/
|
WEBMAIL_BACKEND_URL: http://127.0.0.1:80 # the bundled webmail (Cypht, below, shares this container's network); the hub shows it at /webmail/
|
||||||
CYPHT_USER: ${CYPHT_USER:-hub} # the webmail's login; the Mail tab signs in with it for you
|
CYPHT_USER: ${CYPHT_USER:-hub} # the webmail's login; the Mail tab signs in with it for you
|
||||||
CYPHT_PASSWORD: ${CYPHT_PASSWORD:-}
|
CYPHT_PASSWORD: ${CYPHT_PASSWORD:-}
|
||||||
|
SIGNAL_API_URL: http://127.0.0.1:8085 # the bundled Signal bridge (below, shares this container's network); remove this line and the service to skip Signal
|
||||||
PODCAST_URL: ${PODCAST_URL:-https://podcast.necloud.us/}
|
PODCAST_URL: ${PODCAST_URL:-https://podcast.necloud.us/}
|
||||||
ONLYOFFICE_JWT_SECRET: ${ONLYOFFICE_JWT_SECRET:-} # same value as the OnlyOffice server's JWT secret
|
ONLYOFFICE_JWT_SECRET: ${ONLYOFFICE_JWT_SECRET:-} # same value as the OnlyOffice server's JWT secret
|
||||||
PUBLIC_URL: ${PUBLIC_URL:-} # e.g. https://hub.necloud.us (OnlyOffice posts saved edits here)
|
PUBLIC_URL: ${PUBLIC_URL:-} # e.g. https://hub.necloud.us (OnlyOffice posts saved edits here)
|
||||||
@@ -57,8 +58,24 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- cypht-data:/var/lib/hm3 # the webmail's database: your accounts and settings
|
- cypht-data:/var/lib/hm3 # the webmail's database: your accounts and settings
|
||||||
|
|
||||||
|
# Signal messages (the Signal tab). This links to your Signal account as one more linked device (like Signal Desktop) through the community
|
||||||
|
# signal-cli-rest-api bridge. Not an official Signal product; its link to your account (keys) is kept in the signal-data volume. See README -> Signal.
|
||||||
|
signal:
|
||||||
|
image: bbernhard/signal-cli-rest-api:0.101
|
||||||
|
restart: unless-stopped
|
||||||
|
network_mode: "service:hub" # reached by the hub at 127.0.0.1:8085 and by nothing else
|
||||||
|
depends_on:
|
||||||
|
- hub
|
||||||
|
environment:
|
||||||
|
MODE: json-rpc
|
||||||
|
PORT: "8085"
|
||||||
|
volumes:
|
||||||
|
- signal-data:/home/.local/share/signal-cli
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
hub-data:
|
hub-data:
|
||||||
name: nehub-data # fixed name so the data survives redeploys and renamed stacks
|
name: nehub-data # fixed name so the data survives redeploys and renamed stacks
|
||||||
cypht-data:
|
cypht-data:
|
||||||
name: nehub-cypht
|
name: nehub-cypht
|
||||||
|
signal-data:
|
||||||
|
name: nehub-signal
|
||||||
+207
-7
@@ -35,7 +35,9 @@ async function loadMe() {
|
|||||||
try { const r = await fetch('/api/me'); if (r.ok) { ME = await r.json(); try { localStorage.setItem('me_cache', JSON.stringify(ME)); } catch {} } }
|
try { const r = await fetch('/api/me'); if (r.ok) { ME = await r.json(); try { localStorage.setItem('me_cache', JSON.stringify(ME)); } catch {} } }
|
||||||
catch { try { ME = JSON.parse(localStorage.getItem('me_cache')); } catch {} } // offline: the last known access (the server still decides)
|
catch { try { ME = JSON.parse(localStorage.getItem('me_cache')); } catch {} } // offline: the last known access (the server still decides)
|
||||||
}
|
}
|
||||||
const allowed = id => !ME || ME.tabs.includes(id);
|
// Signal is for desktop-class screens only (a window at least 900 px wide with a mouse or trackpad), and needs the server to have it set up.
|
||||||
|
const sgMQ = matchMedia('(min-width: 900px) and (pointer: fine)');
|
||||||
|
const allowed = id => (!ME || ME.tabs.includes(id)) && (id !== 'signal' || (!!cfg.signal && sgMQ.matches)); // (Signal also needs the server to have it set up)
|
||||||
/** Pull saved settings from the server into this browser; push up anything only this browser has (first run / migration). */
|
/** Pull saved settings from the server into this browser; push up anything only this browser has (first run / migration). */
|
||||||
async function loadSettings() {
|
async function loadSettings() {
|
||||||
try {
|
try {
|
||||||
@@ -49,6 +51,9 @@ async function loadSettings() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ICONS = {
|
const ICONS = {
|
||||||
|
chat: '<path d="M4 5h16a1 1 0 011 1v10a1 1 0 01-1 1H10l-5 4v-4H4a1 1 0 01-1-1V6a1 1 0 011-1z"/>',
|
||||||
|
clip: '<path d="M20 11l-8 8a5 5 0 01-7-7l8-8a3.5 3.5 0 015 5l-8 8a2 2 0 01-3-3l7-7"/>',
|
||||||
|
send: '<path d="M4 12l16-8-6 16-3-7z"/>',
|
||||||
menu: '<path d="M4 7h16M4 12h16M4 17h16"/>',
|
menu: '<path d="M4 7h16M4 12h16M4 17h16"/>',
|
||||||
home: '<path d="M3 11l9-8 9 8M5 10v10h14V10"/>',
|
home: '<path d="M3 11l9-8 9 8M5 10v10h14V10"/>',
|
||||||
mail: '<rect x="3" y="5" width="18" height="14" rx="2"/><path d="M3 7l9 6 9-6"/>',
|
mail: '<rect x="3" y="5" width="18" height="14" rx="2"/><path d="M3 7l9 6 9-6"/>',
|
||||||
@@ -142,21 +147,24 @@ const fmtSize = b => b < 1024 ? b + ' B' : b < 1048576 ? (b / 1024).toFixed(0) +
|
|||||||
const fmtDate = s => new Date(s * 1000).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
|
const fmtDate = s => new Date(s * 1000).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
|
||||||
|
|
||||||
// ---- Navigation
|
// ---- Navigation
|
||||||
const TABS = [['home', 'Home'], ['files', 'Files'], ['music', 'Music'], ['mail', 'Mail'], ['tasks', 'Tasks'], ['calendar', 'Calendar'], ['search', 'Search'], ['office', 'Office'], ['audio', 'Audiobooks'], ['podcast', 'Podcast'], ['verse', 'Podcast Admin'], ['settings', 'Settings']];
|
const TABS = [['home', 'Home'], ['files', 'Files'], ['music', 'Music'], ['mail', 'Mail'], ['signal', 'Signal'], ['tasks', 'Tasks'], ['calendar', 'Calendar'], ['search', 'Search'], ['office', 'Office'], ['audio', 'Audiobooks'], ['podcast', 'Podcast'], ['verse', 'Podcast Admin'], ['settings', 'Settings']];
|
||||||
const TAB_ICON = { home: 'home', mail: 'mail', files: 'files', office: 'doc', audio: 'audio', verse: 'book', podcast: 'mic', music: 'music', settings: 'cog', calendar: 'cal', search: 'search', tasks: 'check' };
|
const TAB_ICON = { home: 'home', mail: 'mail', signal: 'chat', files: 'files', office: 'doc', audio: 'audio', verse: 'book', podcast: 'mic', music: 'music', settings: 'cog', calendar: 'cal', search: 'search', tasks: 'check' };
|
||||||
|
const sg = { st: null, convs: [], cur: null, unread: 0, es: null, timers: [], reply: null, tray: [], names: {} }; // Signal state
|
||||||
const PRIMARY = ['home', 'files', 'tasks', 'music', 'mail']; // phone tab bar; the rest sit behind "More"
|
const PRIMARY = ['home', 'files', 'tasks', 'music', 'mail']; // phone tab bar; the rest sit behind "More"
|
||||||
const phone = matchMedia('(max-width: 767px)');
|
const phone = matchMedia('(max-width: 767px)');
|
||||||
let activeTab = 'home';
|
let activeTab = 'home';
|
||||||
|
|
||||||
function renderNav(active = activeTab) {
|
function renderNav(active = activeTab) {
|
||||||
activeTab = active;
|
activeTab = active;
|
||||||
const tab = ([id, label]) => `<a href="#/${id}" class="${id === active ? 'on' : ''}" ${id === active ? 'aria-current="page"' : ''}>${icon(TAB_ICON[id])}<span>${label}</span></a>`;
|
if (typeof sgDockBadge === 'function') sgDockBadge();
|
||||||
|
const badge = n => n ? `<i class="nbadge">${n > 99 ? '99+' : n}</i>` : '';
|
||||||
|
const tab = ([id, label]) => `<a href="#/${id}" class="${id === active ? 'on' : ''}" ${id === active ? 'aria-current="page"' : ''}>${icon(TAB_ICON[id])}<span>${label}</span>${id === 'signal' ? badge(sg.unread) : ''}</a>`;
|
||||||
const brand = `<div class="brand"><img src="/icon.svg" alt=""><span>NE Hub</span></div>`;
|
const brand = `<div class="brand"><img src="/icon.svg" alt=""><span>NE Hub</span></div>`;
|
||||||
const mine = TABS.filter(t => allowed(t[0]));
|
const mine = TABS.filter(t => allowed(t[0]));
|
||||||
if (phone.matches) {
|
if (phone.matches) {
|
||||||
const rest = mine.filter(t => !PRIMARY.includes(t[0])), more = !PRIMARY.includes(active);
|
const rest = mine.filter(t => !PRIMARY.includes(t[0])), more = !PRIMARY.includes(active);
|
||||||
$('#nav').innerHTML = mine.filter(t => PRIMARY.includes(t[0])).map(tab).join('') +
|
$('#nav').innerHTML = mine.filter(t => PRIMARY.includes(t[0])).map(tab).join('') +
|
||||||
(rest.length ? `<a href="#" id="more" class="${more ? 'on' : ''}" role="button">${icon('more')}<span>More</span></a>` : '');
|
(rest.length ? `<a href="#" id="more" class="${more ? 'on' : ''}" role="button">${icon('more')}<span>More</span>${rest.some(t => t[0] === 'signal') ? badge(sg.unread) : ''}</a>` : '');
|
||||||
const mo = $('#more'); if (mo) mo.onclick = e => { e.preventDefault(); openMore(); };
|
const mo = $('#more'); if (mo) mo.onclick = e => { e.preventDefault(); openMore(); };
|
||||||
} else {
|
} else {
|
||||||
$('#nav').innerHTML = brand + mine.map(tab).join('') + `<div class="spacer"></div><button id="navcol" class="navcol" aria-label="Collapse the menu" title="Collapse or expand the menu">${icon('back')}<span>Collapse</span></button>`;
|
$('#nav').innerHTML = brand + mine.map(tab).join('') + `<div class="spacer"></div><button id="navcol" class="navcol" aria-label="Collapse the menu" title="Collapse or expand the menu">${icon('back')}<span>Collapse</span></button>`;
|
||||||
@@ -167,11 +175,11 @@ phone.addEventListener('change', () => renderNav());
|
|||||||
|
|
||||||
function openMore() {
|
function openMore() {
|
||||||
const el = openSheet('More', '', `<div class="list">${TABS.filter(t => !PRIMARY.includes(t[0]) && allowed(t[0])).map(([id, label]) => `
|
const el = openSheet('More', '', `<div class="list">${TABS.filter(t => !PRIMARY.includes(t[0]) && allowed(t[0])).map(([id, label]) => `
|
||||||
<a class="row" href="#/${id}">${icon(TAB_ICON[id])}<span class="grow"><b>${label}</b></span>${icon('chev', 'chev')}</a>`).join('')}</div>`);
|
<a class="row" href="#/${id}">${icon(TAB_ICON[id])}<span class="grow"><b>${label}</b></span>${id === 'signal' && sg.unread ? `<i class="nbadge" style="position:static">${sg.unread}</i>` : ''}${icon('chev', 'chev')}</a>`).join('')}</div>`);
|
||||||
el.querySelectorAll('a.row').forEach(a => a.addEventListener('click', closeSheet));
|
el.querySelectorAll('a.row').forEach(a => a.addEventListener('click', closeSheet));
|
||||||
}
|
}
|
||||||
|
|
||||||
const routes = { home: viewHome, mail: viewMail, files: viewFiles, office: viewOffice, audio: viewAudio, verse: viewVerse, podcast: viewPodcast, music: viewMusic, tasks: viewTasks, search: viewSearch, calendar: viewCalendar, settings: viewSettings, edit: viewEdit };
|
const routes = { home: viewHome, mail: viewMail, files: viewFiles, office: viewOffice, audio: viewAudio, verse: viewVerse, podcast: viewPodcast, music: viewMusic, tasks: viewTasks, search: viewSearch, calendar: viewCalendar, signal: viewSignal, settings: viewSettings, edit: viewEdit };
|
||||||
let editor;
|
let editor;
|
||||||
// Menu on a tablet or computer: collapse it to icons (wide screens), or hide it completely. Remembered on this device.
|
// Menu on a tablet or computer: collapse it to icons (wide screens), or hide it completely. Remembered on this device.
|
||||||
const navPref = () => { try { return JSON.parse(localStorage.getItem('nav_pref')) || {}; } catch { return {}; } };
|
const navPref = () => { try { return JSON.parse(localStorage.getItem('nav_pref')) || {}; } catch { return {}; } };
|
||||||
@@ -212,10 +220,12 @@ function route() {
|
|||||||
editor = null;
|
editor = null;
|
||||||
document.body.classList.toggle('editing', name === 'edit');
|
document.body.classList.toggle('editing', name === 'edit');
|
||||||
document.body.classList.remove('inedit', 'onaudio');
|
document.body.classList.remove('inedit', 'onaudio');
|
||||||
|
sg.timers.forEach(clearInterval); sg.timers = []; // (Signal screens start their own)
|
||||||
const aw = $('#audiowrap'); if (aw) aw.className = 'off'; // hide (not destroy) the audiobooks frame
|
const aw = $('#audiowrap'); if (aw) aw.className = 'off'; // hide (not destroy) the audiobooks frame
|
||||||
renderNav(name === 'edit' ? 'files' : name);
|
renderNav(name === 'edit' ? 'files' : name);
|
||||||
drawNav();
|
drawNav();
|
||||||
routes[name](rest.map(decodeURIComponent));
|
routes[name](rest.map(decodeURIComponent));
|
||||||
|
sgDockSync(name);
|
||||||
$('#view').scrollTop = 0;
|
$('#view').scrollTop = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -245,6 +255,7 @@ function viewHome() {
|
|||||||
${allowed('audio') ? tile('#/audio', '#8a5cf0', 'audio', 'Audiobooks', 'Listen') : ''}
|
${allowed('audio') ? tile('#/audio', '#8a5cf0', 'audio', 'Audiobooks', 'Listen') : ''}
|
||||||
${allowed('podcast') ? tile('#/podcast', '#c2417a', 'mic', 'Podcast', 'Podcasts') : ''}
|
${allowed('podcast') ? tile('#/podcast', '#c2417a', 'mic', 'Podcast', 'Podcasts') : ''}
|
||||||
${allowed('verse') && cfg.verse ? tile(cfg.verse, '#0f9d9a', 'book', 'Podcast Admin', 'Opens in a new tab', true) : ''}
|
${allowed('verse') && cfg.verse ? tile(cfg.verse, '#0f9d9a', 'book', 'Podcast Admin', 'Opens in a new tab', true) : ''}
|
||||||
|
${allowed('signal') ? tile('#/signal', '#3a76f0', 'chat', 'Signal', sg.unread ? sg.unread + ' unread' : 'Messages') : ''}
|
||||||
${allowed('mail') ? tile('#/mail', '#4f7cff', 'mail', 'Mail', mailOn().length + (mailOn().length === 1 ? ' app' : ' apps')) : ''}
|
${allowed('mail') ? tile('#/mail', '#4f7cff', 'mail', 'Mail', mailOn().length + (mailOn().length === 1 ? ' app' : ' apps')) : ''}
|
||||||
</div>
|
</div>
|
||||||
${allowed('mail') ? `<h2>Mail</h2><div class="grid">
|
${allowed('mail') ? `<h2>Mail</h2><div class="grid">
|
||||||
@@ -2032,6 +2043,194 @@ function dirSheet(repo, path, name, dir) {
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Signal (through signal-cli-rest-api on the server; this hub is one more "linked device" of your Signal account)
|
||||||
|
const sgq = q => (sg.root || document).querySelector(q);
|
||||||
|
const sgApi = (p, o) => jfetch('/api/signal' + p, o);
|
||||||
|
const sgPost = (p, body) => sgApi(p, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
||||||
|
const sgTime = ts => { const d = new Date(ts), n = new Date(); return d.toDateString() === n.toDateString() ? d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }) : (n - d < 6 * 864e5 ? d.toLocaleDateString([], { weekday: 'short' }) : d.toLocaleDateString([], { month: 'short', day: 'numeric' })); };
|
||||||
|
const sgInitial = n => (String(n).replace(/^\+/, '').trim()[0] || '?').toUpperCase();
|
||||||
|
const sgColor = id => `hsl(${[...String(id)].reduce((a, c) => (a * 31 + c.charCodeAt(0)) % 360, 7)} 45% 42%)`;
|
||||||
|
const sgOnView = () => location.hash.startsWith('#/signal');
|
||||||
|
const sgMounted = () => !!sg.root && document.contains(sg.root);
|
||||||
|
async function sgStart() { // keep the unread count and live updates running whatever screen is open
|
||||||
|
if (!allowed('signal') || sg.es) return;
|
||||||
|
try { const st = await sgApi('/status'); sg.st = st; sg.unread = st.unread || 0; renderNav(activeTab); } catch {}
|
||||||
|
sg.es = new EventSource('/api/signal/events');
|
||||||
|
sg.es.onmessage = e => { let ev; try { ev = JSON.parse(e.data); } catch { return; } sgEvent(ev); };
|
||||||
|
}
|
||||||
|
function sgStop() { if (sg.es) { sg.es.close(); sg.es = null; } }
|
||||||
|
let sgEvT = null;
|
||||||
|
function sgEvent(ev) {
|
||||||
|
if (typeof ev.unread === 'number' && ev.unread !== sg.unread) { sg.unread = ev.unread; renderNav(activeTab); }
|
||||||
|
if (ev.t === 'msg' && !ev.out && !(sgMounted() && sg.cur === ev.conv && document.visibilityState === 'visible')) toast('Signal · ' + (ev.name || '') + ': ' + (ev.preview || ''));
|
||||||
|
if (!sgMounted()) return;
|
||||||
|
clearTimeout(sgEvT); sgEvT = setTimeout(() => { if (ev.t === 'status') return sgMount(sg.root, sg.dock); sgRefresh(); }, 150);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The Signal tab (full page, desktop) ...
|
||||||
|
function viewSignal(args = []) { sg.cur = args[0] || null; sgDockHide(); return sgMount($('#view'), false); }
|
||||||
|
// ... and the same screens in a panel that floats over every other screen.
|
||||||
|
async function sgMount(root, dock) {
|
||||||
|
sg.root = root; sg.dock = dock; const cls = dock ? 'sgdockin' : 'wrap sgwrap', title = dock ? '' : '<h1>Signal</h1>';
|
||||||
|
root.innerHTML = `<div class="${cls}">${title}<p class="empty">Loading…</p></div>`;
|
||||||
|
let st; try { st = await sgApi('/status'); } catch (e) { root.innerHTML = `<div class="${cls}">${title}<p class="err">${esc(e.message)}</p></div>`; return; }
|
||||||
|
if (sg.root !== root) return; sg.st = st;
|
||||||
|
if (!st.reachable) { root.innerHTML = `<div class="${cls}">${title}<div class="card" style="max-width:none"><p style="margin-top:0">The Signal service is not answering. Check that its container (<b>signal</b>) is running, then reload.</p></div></div>`; return; }
|
||||||
|
if (!st.linked) return sgLinkScreen(root, dock);
|
||||||
|
root.innerHTML = `<div class="${cls}"><div class="sg ${sg.cur ? 'open' : ''}"><div class="sgl"><div class="sghead">${dock ? '<b class="sgdt">Chats</b>' : '<h1>Signal</h1>'}<button class="btn ghost" id="sgnew">${icon('edit')}New</button></div>
|
||||||
|
<input type="search" id="sgq" placeholder="Search chats" autocomplete="off"><div class="list sglist" id="sglist"></div><p class="sub" style="margin-top:8px">${esc(st.number)}${st.connected ? '' : ' · reconnecting…'}</p></div>
|
||||||
|
<div class="sgm" id="sgm"></div></div></div>`;
|
||||||
|
sgq('#sgnew').onclick = sgNewChat; sgq('#sgq').oninput = sgDrawList;
|
||||||
|
await sgRefresh(true);
|
||||||
|
}
|
||||||
|
async function sgRefresh(first) {
|
||||||
|
try {
|
||||||
|
const r = await sgApi('/convs'); sg.convs = r.convs; sg.unread = r.unread; renderNav(activeTab);
|
||||||
|
if (!sgq('#sglist')) return; sgDrawList();
|
||||||
|
if (sg.cur) await sgDrawThread(first); else sgq('#sgm').innerHTML = '<p class="empty">Choose a conversation, or start a new one.</p>';
|
||||||
|
} catch (e) { const l = sgq('#sglist'); if (l) l.innerHTML = `<p class="err" style="padding:12px">${esc(e.message)}</p>`; }
|
||||||
|
}
|
||||||
|
function sgOpen(id) { // in the floating panel a chat opens in place (the page behind does not change)
|
||||||
|
sg.cur = id; sgSaveDock(); const box = sgq('.sg'); if (box) box.classList.toggle('open', !!id);
|
||||||
|
sgq('#sgm').innerHTML = ''; sgDrawList(); if (id) sgDrawThread(true);
|
||||||
|
}
|
||||||
|
function sgDrawList() {
|
||||||
|
const q = (sgq('#sgq') || {}).value ? sgq('#sgq').value.toLowerCase() : '', box = sgq('#sglist'); if (!box) return;
|
||||||
|
const rows = sg.convs.filter(c => !q || (c.name || c.id).toLowerCase().includes(q) || (c.preview || '').toLowerCase().includes(q));
|
||||||
|
box.innerHTML = rows.map(c => `<a class="row sgrow ${c.id === sg.cur ? 'on' : ''}" data-id="${esc(c.id)}" href="${sg.dock ? '#' : '#/signal/' + encodeURIComponent(c.id)}"><span class="sgav" style="background:${sgColor(c.id)}">${c.type === 'group' ? icon('chat') : esc(sgInitial(c.name || c.id))}</span>
|
||||||
|
<span class="grow"><b>${esc(c.name || c.id)}</b><small>${esc(c.preview || '')}</small></span><span class="sgside"><small>${c.ts ? sgTime(c.ts) : ''}</small>${c.unread ? `<i class="nbadge" style="position:static">${c.unread}</i>` : ''}</span></a>`).join('') || `<p class="empty">${q ? 'Nothing matches' : 'No conversations yet. Tap New, or wait for a message.'}</p>`;
|
||||||
|
if (sg.dock) box.onclick = e => { const a = e.target.closest('[data-id]'); if (a) { e.preventDefault(); sgOpen(a.dataset.id); } };
|
||||||
|
}
|
||||||
|
|
||||||
|
const sgLink = t => t.replace(/(https?:\/\/[^\s<]+[^\s<.,;:!?)\]'"])/g, u => `<a href="${u}" target="_blank" rel="noopener">${u}</a>`);
|
||||||
|
const sgSize = n => n < 1024 ? n + ' B' : n < 1048576 ? (n / 1024).toFixed(0) + ' KB' : (n / 1048576).toFixed(1) + ' MB';
|
||||||
|
function sgBubble(m, c, prev) {
|
||||||
|
const nm = a => (sg.names[a] || (a === sg.st.number ? 'You' : a));
|
||||||
|
const att = (m.att || []).map(a => { const u = '/api/signal/attachment/' + encodeURIComponent(a.id);
|
||||||
|
return /^image\//.test(a.type || '') ? `<a href="${u}" target="_blank" rel="noopener"><img class="sgimg" loading="lazy" alt="${esc(a.name || 'photo')}" src="${u}"></a>`
|
||||||
|
: /^audio\//.test(a.type || '') ? `<audio controls preload="none" src="${u}"></audio>`
|
||||||
|
: /^video\//.test(a.type || '') ? `<video controls preload="metadata" playsinline src="${u}"></video>`
|
||||||
|
: `<a class="sgfile" href="${u}" target="_blank" rel="noopener">${icon('clip')}<span>${esc(a.name || 'file')}${a.size ? ' · ' + sgSize(a.size) : ''}</span></a>`; }).join('');
|
||||||
|
const reacts = {}; Object.values(m.react || {}).forEach(e => { reacts[e] = (reacts[e] || 0) + 1; });
|
||||||
|
const day = !prev || new Date(prev.ts).toDateString() !== new Date(m.ts).toDateString() ? `<div class="sgday">${new Date(m.ts).toLocaleDateString([], { weekday: 'long', month: 'long', day: 'numeric' })}</div>` : '';
|
||||||
|
const tick = m.out ? (m.status === 'read' ? '✓✓' : m.status === 'delivered' ? '✓✓' : '✓') : '';
|
||||||
|
return `${day}<div class="sgb ${m.out ? 'out' : 'in'}" data-ts="${m.ts}">${c.type === 'group' && !m.out ? `<small class="who" style="color:${sgColor(m.from)}">${esc(m.name || nm(m.from))}</small>` : ''}
|
||||||
|
${m.quote ? `<div class="sgq"><b>${esc(nm(m.quote.author))}</b><span>${esc(m.quote.text || '')}</span></div>` : ''}${att}
|
||||||
|
${m.deleted ? '<i class="sgdel">This message was deleted</i>' : m.text ? `<div class="sgt">${sgLink(esc(m.text)).replace(/\n/g, '<br>')}</div>` : ''}
|
||||||
|
<span class="sgmeta">${m.edited ? 'edited · ' : ''}${new Date(m.ts).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}${tick ? ` <span class="${m.status === 'read' ? 'rd' : ''}">${tick}</span>` : ''}</span>
|
||||||
|
${Object.keys(reacts).length ? `<span class="sgre">${Object.entries(reacts).map(([e, n]) => e + (n > 1 ? ' ' + n : '')).join(' ')}</span>` : ''}</div>`;
|
||||||
|
}
|
||||||
|
async function sgDrawThread(first) {
|
||||||
|
const box = sgq('#sgm'); if (!box) return;
|
||||||
|
const id = sg.cur; let r;
|
||||||
|
try { r = await sgApi('/conv/' + encodeURIComponent(id)); } catch (e) { box.innerHTML = `<p class="err">${esc(e.message)}</p>`; return; }
|
||||||
|
if (sg.cur !== id || !sgq('#sgm')) return; sg.names = r.names || {};
|
||||||
|
const c = r.conv, old = box.querySelector('.sgmsgs'), stick = !old || old.scrollHeight - old.scrollTop - old.clientHeight < 80;
|
||||||
|
if (!box.querySelector('.sgbar')) {
|
||||||
|
box.innerHTML = `<div class="sgbar">${sg.dock ? `<button type="button" class="ibtn sgback" id="sgbk" aria-label="Back to chats">${icon('back')}</button>` : `<a class="ibtn sgback" href="#/signal" aria-label="Back to chats">${icon('back')}</a>`}<span class="sgav" style="background:${sgColor(c.id)}">${c.type === 'group' ? icon('chat') : esc(sgInitial(c.name || c.id))}</span><span class="grow"><b id="sgtitle"></b><small id="sgsub"></small></span></div>
|
||||||
|
<div class="sgmsgs" id="sgmsgs"></div><div id="sgrep" class="sgrep" hidden></div><div id="sgtray" class="sgtray" hidden></div>
|
||||||
|
<form class="sgc" id="sgform"><button type="button" class="ibtn" id="sgat" aria-label="Attach a file">${icon('clip')}</button><input type="file" id="sgfile" multiple hidden>
|
||||||
|
<textarea id="sgtext" rows="1" placeholder="Message" enterkeyhint="send"></textarea><button class="ibtn sgsend" id="sgsend" aria-label="Send">${icon('send')}</button></form>`;
|
||||||
|
sgWireComposer(); const bk = sgq('#sgbk'); if (bk) bk.onclick = () => sgOpen(null);
|
||||||
|
}
|
||||||
|
sgq('#sgtitle').textContent = c.name || c.id; sgq('#sgsub').textContent = c.type === 'group' ? 'Group' : (c.name && c.name !== c.id ? c.id : '');
|
||||||
|
const list = r.msgs, el = sgq('#sgmsgs');
|
||||||
|
el.innerHTML = (r.more ? '<p class="sub" style="text-align:center">Older messages are not shown here</p>' : '') + list.map((m, i) => sgBubble(m, c, list[i - 1])).join('') || '<p class="empty">No messages yet. Say hello.</p>';
|
||||||
|
if (first || stick) el.scrollTop = el.scrollHeight;
|
||||||
|
el.onclick = e => { const b = e.target.closest('.sgb'); if (!b || e.target.closest('a, audio, video')) return; sgMessageMenu(list.find(m => String(m.ts) === b.dataset.ts), c); };
|
||||||
|
if (c.unread || (sg.convs.find(x => x.id === id) || {}).unread) sgPost('/read', { id }).then(() => { const x = sg.convs.find(q => q.id === id); if (x) x.unread = 0; sgDrawList(); }).catch(() => {});
|
||||||
|
}
|
||||||
|
function sgMessageMenu(m, c) {
|
||||||
|
if (!m || m.deleted) return;
|
||||||
|
const el = openSheet('', '', `<div class="sgemo">${['👍', '❤️', '😂', '😮', '😢', '🙏'].map(e => `<button class="ibtn" data-e="${e}" aria-label="React ${e}">${e}</button>`).join('')}</div>
|
||||||
|
<div class="list" style="background:var(--panel2);border:0;margin-top:8px"><button class="row" id="sgr">${icon('back')}<span class="grow"><b>Reply</b></span></button>${m.text ? `<button class="row" id="sgcp">${icon('copy')}<span class="grow"><b>Copy text</b></span></button>` : ''}</div>`);
|
||||||
|
el.querySelectorAll('[data-e]').forEach(b => b.onclick = async () => { closeSheet(); try { await sgPost('/react', { to: c.id, ts: m.ts, emoji: (m.react && m.react[sg.st.number]) === b.dataset.e ? '' : b.dataset.e }); } catch (e) { toast(e.message); } });
|
||||||
|
el.querySelector('#sgr').onclick = () => { closeSheet(); sg.reply = m; sgDrawReply(); sgq('#sgtext').focus(); };
|
||||||
|
const cp = el.querySelector('#sgcp'); if (cp) cp.onclick = async () => { closeSheet(); try { await navigator.clipboard.writeText(m.text); toast('Copied'); } catch { toast('Could not copy'); } };
|
||||||
|
}
|
||||||
|
function sgDrawReply() {
|
||||||
|
const b = sgq('#sgrep'); if (!b) return;
|
||||||
|
if (!sg.reply) { b.hidden = true; return; }
|
||||||
|
b.hidden = false; b.innerHTML = `<span class="grow"><b>${esc(sg.reply.out ? 'You' : (sg.reply.name || sg.reply.from))}</b><small>${esc(sg.reply.text || 'Attachment')}</small></span><button class="ibtn" id="sgrx" aria-label="Cancel reply">${icon('x')}</button>`;
|
||||||
|
sgq('#sgrx').onclick = () => { sg.reply = null; sgDrawReply(); };
|
||||||
|
}
|
||||||
|
function sgDrawTray() {
|
||||||
|
const t = sgq('#sgtray'); if (!t) return; t.hidden = !sg.tray.length;
|
||||||
|
t.innerHTML = sg.tray.map((f, i) => `<span class="sgchip">${/^image\//.test(f.type) ? `<img alt="" src="${URL.createObjectURL(f)}">` : icon('clip')}<small>${esc(f.name)}</small><button type="button" class="ibtn" data-x="${i}" aria-label="Remove">${icon('x')}</button></span>`).join('');
|
||||||
|
t.querySelectorAll('[data-x]').forEach(b => b.onclick = () => { sg.tray.splice(+b.dataset.x, 1); sgDrawTray(); });
|
||||||
|
}
|
||||||
|
const sgB64 = f => new Promise((ok, no) => { const r = new FileReader(); r.onload = () => ok(String(r.result).split(',')[1] || ''); r.onerror = () => no(new Error('Could not read ' + f.name)); r.readAsDataURL(f); });
|
||||||
|
function sgWireComposer() {
|
||||||
|
const ta = sgq('#sgtext'), grow = () => { ta.style.height = 'auto'; ta.style.height = Math.min(ta.scrollHeight, 140) + 'px'; };
|
||||||
|
ta.oninput = grow;
|
||||||
|
ta.onkeydown = e => { if (e.key === 'Enter' && !e.shiftKey && matchMedia('(pointer: fine)').matches) { e.preventDefault(); sgq('#sgform').requestSubmit(); } };
|
||||||
|
sgq('#sgat').onclick = () => sgq('#sgfile').click();
|
||||||
|
sgq('#sgfile').onchange = e => { sg.tray.push(...e.target.files); e.target.value = ''; sgDrawTray(); };
|
||||||
|
ta.addEventListener('paste', e => { const fs = [...(e.clipboardData && e.clipboardData.files || [])]; if (fs.length) { sg.tray.push(...fs); sgDrawTray(); } });
|
||||||
|
sgq('#sgform').onsubmit = async e => {
|
||||||
|
e.preventDefault(); const text = ta.value, files = [...sg.tray]; if (!text.trim() && !files.length) return;
|
||||||
|
const btn = sgq('#sgsend'); btn.disabled = true;
|
||||||
|
try {
|
||||||
|
const attachments = []; for (const f of files) attachments.push({ name: f.name, type: f.type || 'application/octet-stream', data: await sgB64(f) });
|
||||||
|
await sgPost('/send', { to: sg.cur, text, attachments, quote: sg.reply ? sg.reply.ts : undefined });
|
||||||
|
ta.value = ''; grow(); sg.tray = []; sg.reply = null; sgDrawTray(); sgDrawReply(); await sgRefresh(); const el = sgq('#sgmsgs'); if (el) el.scrollTop = el.scrollHeight;
|
||||||
|
} catch (err) { toast(err.message); }
|
||||||
|
btn.disabled = false; ta.focus();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
async function sgNewChat() {
|
||||||
|
let contacts = []; try { contacts = (await sgApi('/contacts')).contacts; } catch {}
|
||||||
|
const el = openSheet('New chat', '', `<form id="sgnf"><label for="sgnn">Phone number (with country code) or a contact</label><input type="text" id="sgnn" inputmode="tel" autocomplete="off" placeholder="+1 555 123 4567 or a name">
|
||||||
|
<div class="err" id="sgne"></div></form><div class="list" id="sgcl" style="background:var(--panel2);border:0;margin-top:10px;max-height:40dvh;overflow:auto"></div>`);
|
||||||
|
const input = el.querySelector('#sgnn'), draw = () => { const q = input.value.toLowerCase().trim(); el.querySelector('#sgcl').innerHTML = contacts.filter(c => !q || (c.name || '').toLowerCase().includes(q) || c.number.includes(q)).slice(0, 60).map(c => `<button class="row" data-n="${esc(c.number)}"><span class="sgav" style="background:${sgColor(c.number)}">${esc(sgInitial(c.name || c.number))}</span><span class="grow"><b>${esc(c.name || c.number)}</b><small>${esc(c.number)}</small></span></button>`).join('');
|
||||||
|
el.querySelectorAll('[data-n]').forEach(b => b.onclick = () => go(b.dataset.n)); };
|
||||||
|
const go = async n => { try { const r = await sgPost('/new', { number: n }); closeSheet(); if (sg.dock) { await sgRefresh(); sgOpen(r.conv.id); } else location.hash = '#/signal/' + encodeURIComponent(r.conv.id); } catch (e) { el.querySelector('#sgne').textContent = e.message; } };
|
||||||
|
input.oninput = draw; draw(); el.querySelector('#sgnf').onsubmit = e => { e.preventDefault(); go(input.value); }; setTimeout(() => input.focus(), 60);
|
||||||
|
}
|
||||||
|
function sgLinkScreen(root, dock) {
|
||||||
|
const v = root, cls = dock ? 'sgdockin' : 'wrap';
|
||||||
|
const draw = () => { const img = sgq('#sgqr'); if (img) img.src = '/api/signal/qr?t=' + Date.now(); };
|
||||||
|
v.innerHTML = `<div class="${cls}">${dock ? '' : '<h1>Signal</h1>'}<p class="sub">Link this app to your Signal account. It becomes one more linked device, like Signal Desktop.</p>
|
||||||
|
<div class="card" style="max-width:none"><ol class="sgsteps"><li>On your phone open <b>Signal</b> → <b>Settings</b> → <b>Linked devices</b> → <b>Link new device</b>.</li><li>Scan this code.</li></ol>
|
||||||
|
<div style="text-align:center;margin:14px 0"><img id="sgqr" alt="Signal link code" width="256" height="256" style="background:#fff;border-radius:12px;padding:8px;max-width:100%"></div>
|
||||||
|
<div class="bar" style="justify-content:center"><button class="btn ghost" id="sgrq">${icon('refresh')}New code</button></div><p class="sub" id="sgls" style="text-align:center">Waiting for you to scan…</p></div>
|
||||||
|
<p class="sub" style="margin-top:12px">Messages from before you link are not copied over, only new ones. Conversations are kept on your server.</p></div>`;
|
||||||
|
draw(); sgq('#sgrq').onclick = draw;
|
||||||
|
sg.timers.push(setInterval(draw, 55000));
|
||||||
|
sg.timers.push(setInterval(async () => { try { const st = await sgApi('/status'); if (st.linked && sgMounted()) { toast('Signal linked'); sgMount(sg.root, sg.dock); } } catch {} }, 3000));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- The floating chat: a button on every screen (desktop) that opens Signal over whatever you are doing.
|
||||||
|
const sgDockState = () => { try { return JSON.parse(localStorage.getItem('sg_dock')) || {}; } catch { return {}; } };
|
||||||
|
function sgSaveDock() { try { localStorage.setItem('sg_dock', JSON.stringify({ open: !!sg.dockOpen, cur: sg.cur })); } catch {} }
|
||||||
|
function sgDockInit() {
|
||||||
|
if ($('#sgfab') || !allowed('signal')) return;
|
||||||
|
const st = sgDockState(); sg.dockOpen = !!st.open; if (st.cur) sg.cur = st.cur;
|
||||||
|
const fab = document.createElement('button'); fab.id = 'sgfab'; fab.setAttribute('aria-label', 'Signal chat'); fab.title = 'Signal chat'; fab.hidden = true;
|
||||||
|
const dock = document.createElement('div'); dock.id = 'sgdock'; dock.hidden = true; dock.setAttribute('role', 'dialog'); dock.setAttribute('aria-label', 'Signal chat');
|
||||||
|
dock.innerHTML = `<div class="sgdtop"><b>Signal</b><span class="grow"></span><button class="ibtn" id="sgdfull" aria-label="Open the full Signal page" title="Open the full page">${icon('full')}</button><button class="ibtn" id="sgdx" aria-label="Close chat" title="Close">${icon('x')}</button></div><div class="sgdbody" id="sgdbody"></div>`;
|
||||||
|
document.body.append(dock, fab);
|
||||||
|
fab.onclick = () => (sg.dockOpen ? sgDockClose() : sgDockOpen());
|
||||||
|
dock.querySelector('#sgdx').onclick = sgDockClose;
|
||||||
|
dock.querySelector('#sgdfull').onclick = () => { const id = sg.cur; sgDockClose(); location.hash = '#/signal' + (id ? '/' + encodeURIComponent(id) : ''); };
|
||||||
|
const ro = new ResizeObserver(() => document.documentElement.style.setProperty('--ph', ($('#player') && !$('#player').hidden ? $('#player').offsetHeight : 0) + 'px')); ro.observe($('#player'));
|
||||||
|
}
|
||||||
|
function sgDockBadge() { const f = $('#sgfab'); if (f) f.innerHTML = icon('chat') + (sg.unread ? `<i class="nbadge">${sg.unread > 99 ? '99+' : sg.unread}</i>` : ''); }
|
||||||
|
function sgDockOpen() { const d = $('#sgdock'); if (!d) return; sg.dockOpen = true; d.hidden = false; sgSaveDock(); const body = $('#sgdbody'); if (sg.root !== body) sgMount(body, true); }
|
||||||
|
function sgDockClose() { const d = $('#sgdock'); if (d) d.hidden = true; sg.dockOpen = false; if (sg.dock) sg.root = null; const b = $('#sgdbody'); if (b) b.innerHTML = ''; sgSaveDock(); }
|
||||||
|
function sgDockHide() { const d = $('#sgdock'), f = $('#sgfab'); if (d) d.hidden = true; if (f) f.hidden = true; if (sg.dock) sg.root = null; const b = $('#sgdbody'); if (b) b.innerHTML = ''; }
|
||||||
|
function sgDockSync(name) { // after every screen change
|
||||||
|
if (!allowed('signal')) { sgDockHide(); return; }
|
||||||
|
sgDockInit(); const f = $('#sgfab'), d = $('#sgdock'); if (!f) return;
|
||||||
|
if (name === 'signal' || name === 'edit') { sgDockHide(); return; }
|
||||||
|
f.hidden = false; sgDockBadge();
|
||||||
|
if (sg.dockOpen) { d.hidden = false; if (sg.root !== $('#sgdbody')) sgMount($('#sgdbody'), true); } else d.hidden = true;
|
||||||
|
}
|
||||||
|
sgMQ.addEventListener('change', () => { // a window made narrower than a desktop (or back)
|
||||||
|
renderNav(activeTab);
|
||||||
|
if (!sgMQ.matches) { sgStop(); sgDockHide(); if (sgOnView()) location.hash = '#/home'; } else { sgStart(); sgDockSync(activeTab); }
|
||||||
|
});
|
||||||
|
|
||||||
// ---- Boot
|
// ---- Boot
|
||||||
(async () => {
|
(async () => {
|
||||||
try { cfg = { ...cfg, ...(await (await fetch('/api/config')).json()) }; } catch {}
|
try { cfg = { ...cfg, ...(await (await fetch('/api/config')).json()) }; } catch {}
|
||||||
@@ -2051,6 +2250,7 @@ function dirSheet(repo, path, name, dir) {
|
|||||||
setInterval(checkForUpdate, 10 * 60e3);
|
setInterval(checkForUpdate, 10 * 60e3);
|
||||||
await loadMe();
|
await loadMe();
|
||||||
await loadSettings();
|
await loadSettings();
|
||||||
|
sgStart();
|
||||||
addEventListener('hashchange', route);
|
addEventListener('hashchange', route);
|
||||||
restorePlayer();
|
restorePlayer();
|
||||||
route();
|
route();
|
||||||
|
|||||||
@@ -343,3 +343,83 @@ body:not(.editing) #view { padding-top: 8px; }
|
|||||||
body.nav-mini .navcol svg { transform: rotate(180deg); }
|
body.nav-mini .navcol svg { transform: rotate(180deg); }
|
||||||
body.nav-mini .navcol span { display: none; }
|
body.nav-mini .navcol span { display: none; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Unread badge on the menu */
|
||||||
|
#nav a { position: relative; }
|
||||||
|
.nbadge { background: var(--danger); color: #fff; font-size: 11px; font-style: normal; font-weight: 700; line-height: 18px; min-width: 18px; padding: 0 5px; border-radius: 9px; text-align: center; position: absolute; top: 4px; left: calc(50% + 6px); }
|
||||||
|
@media (min-width: 1024px) { body:not(.nav-mini) #nav .nbadge { position: static; margin-left: auto; } }
|
||||||
|
|
||||||
|
/* Signal */
|
||||||
|
.sgwrap { max-width: 1200px; }
|
||||||
|
.sg { display: block; }
|
||||||
|
.sg .sgm { display: none; }
|
||||||
|
.sg.open .sgl { display: none; }
|
||||||
|
.sg.open .sgm { display: flex; }
|
||||||
|
.sghead { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 8px; }
|
||||||
|
.sghead h1 { margin: 0; }
|
||||||
|
.sglist { margin-top: 10px; }
|
||||||
|
.sgrow { padding: 10px 14px; }
|
||||||
|
.sgrow.on { background: var(--panel2); }
|
||||||
|
.sgav { width: 42px; height: 42px; border-radius: 50%; color: #fff; display: grid; place-items: center; font-weight: 700; flex: none; }
|
||||||
|
.sgav svg { width: 22px; height: 22px; }
|
||||||
|
.sgside { display: flex; flex-direction: column; align-items: flex-end; gap: 4px; flex: none; }
|
||||||
|
.sgm { flex-direction: column; height: calc(100dvh - 210px); min-height: 360px; background: var(--panel); border: 1px solid var(--line); border-radius: 16px; overflow: hidden; }
|
||||||
|
.sgbar { display: flex; align-items: center; gap: 10px; padding: 8px 12px; border-bottom: 1px solid var(--line); }
|
||||||
|
.sgbar .grow { min-width: 0; } .sgbar b, .sgbar small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .sgbar small { color: var(--muted); }
|
||||||
|
.sgmsgs { flex: 1; overflow-y: auto; padding: 12px; display: flex; flex-direction: column; gap: 4px; -webkit-overflow-scrolling: touch; }
|
||||||
|
.sgday { align-self: center; color: var(--muted); font-size: 12px; margin: 10px 0 4px; }
|
||||||
|
.sgb { max-width: min(78%, 520px); padding: 8px 12px 6px; border-radius: 16px; background: var(--panel2); align-self: flex-start; word-break: break-word; position: relative; cursor: pointer; }
|
||||||
|
.sgb.out { align-self: flex-end; background: var(--accent); color: var(--accent-t); border-bottom-right-radius: 4px; }
|
||||||
|
.sgb.in { border-bottom-left-radius: 4px; }
|
||||||
|
.sgb .who { display: block; font-weight: 700; font-size: 12px; margin-bottom: 2px; }
|
||||||
|
.sgt a { color: inherit; text-decoration: underline; }
|
||||||
|
.sgmeta { display: block; text-align: right; font-size: 11px; opacity: .7; margin-top: 2px; }
|
||||||
|
.sgmeta .rd { color: #8be28b; font-weight: 700; }
|
||||||
|
.sgb.out .sgmeta .rd { color: #fff; }
|
||||||
|
.sgimg { display: block; max-width: 100%; max-height: 320px; border-radius: 10px; margin-bottom: 4px; }
|
||||||
|
.sgb audio, .sgb video { max-width: 100%; display: block; margin-bottom: 4px; }
|
||||||
|
.sgfile { display: flex; align-items: center; gap: 8px; text-decoration: underline; margin-bottom: 4px; }
|
||||||
|
.sgfile svg { width: 18px; height: 18px; }
|
||||||
|
.sgq { border-left: 3px solid currentColor; opacity: .85; padding: 2px 8px; margin-bottom: 6px; font-size: 13px; }
|
||||||
|
.sgq b, .sgq span { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.sgdel { opacity: .7; }
|
||||||
|
.sgre { position: absolute; right: 8px; bottom: -12px; background: var(--panel); color: var(--text); border: 1px solid var(--line); border-radius: 12px; padding: 0 6px; font-size: 13px; }
|
||||||
|
.sgb:has(.sgre) { margin-bottom: 12px; }
|
||||||
|
.sgrep, .sgtray { display: flex; align-items: center; gap: 8px; padding: 6px 12px; border-top: 1px solid var(--line); background: var(--panel2); }
|
||||||
|
.sgrep[hidden], .sgtray[hidden] { display: none; }
|
||||||
|
.sgrep .grow { min-width: 0; border-left: 3px solid var(--accent); padding-left: 8px; } .sgrep b, .sgrep small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.sgtray { overflow-x: auto; }
|
||||||
|
.sgchip { display: flex; align-items: center; gap: 6px; background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 2px 4px 2px 8px; flex: none; max-width: 200px; }
|
||||||
|
.sgchip img { width: 32px; height: 32px; object-fit: cover; border-radius: 6px; } .sgchip small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 110px; } .sgchip .ibtn { width: 32px; height: 32px; }
|
||||||
|
.sgc { display: flex; align-items: flex-end; gap: 4px; padding: 8px; border-top: 1px solid var(--line); }
|
||||||
|
.sgc textarea { flex: 1; min-height: 44px; max-height: 140px; resize: none; border-radius: 22px; border: 1px solid var(--line); background: var(--bg); padding: 11px 14px; font-size: 16px; }
|
||||||
|
.sgsend { background: var(--accent); color: var(--accent-t); }
|
||||||
|
.sgsend:disabled { opacity: .5; }
|
||||||
|
.sgemo { display: flex; justify-content: space-around; } .sgemo .ibtn { font-size: 26px; width: 52px; height: 52px; }
|
||||||
|
.sgsteps { margin: 0 0 6px; padding-left: 20px; }
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.sgwrap .sg { display: grid; grid-template-columns: 340px 1fr; gap: 20px; align-items: start; }
|
||||||
|
.sgwrap .sg .sgl, .sgwrap .sg.open .sgl, .sgwrap .sg .sgm, .sgwrap .sg.open .sgm { display: flex; }
|
||||||
|
.sgwrap .sg .sgl { flex-direction: column; }
|
||||||
|
.sgwrap .sgback { display: none; }
|
||||||
|
.sgwrap .sgm { height: calc(100dvh - 150px); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The floating chat (desktop): a button on every screen that opens Signal over it */
|
||||||
|
#sgfab { position: fixed; z-index: 14; right: 22px; bottom: calc(var(--ph, 0px) + 22px); width: 54px; height: 54px; border-radius: 50%; border: 0; background: var(--accent); color: var(--accent-t); display: grid; place-items: center; box-shadow: 0 6px 20px rgba(0,0,0,.35); cursor: pointer; }
|
||||||
|
#sgfab svg { width: 26px; height: 26px; }
|
||||||
|
#sgfab .nbadge { top: -2px; left: auto; right: -2px; position: absolute; }
|
||||||
|
#sgfab[hidden], #sgdock[hidden], body.editing #sgfab, body.editing #sgdock { display: none; }
|
||||||
|
#sgdock { position: fixed; z-index: 14; right: 22px; bottom: calc(var(--ph, 0px) + 88px); width: 400px; max-width: calc(100vw - 44px); height: min(640px, calc(100dvh - var(--ph, 0px) - 120px)); display: flex; flex-direction: column; background: var(--panel); border: 1px solid var(--line); border-radius: 16px; box-shadow: 0 12px 40px rgba(0,0,0,.4); overflow: hidden; }
|
||||||
|
.sgdtop { display: flex; align-items: center; gap: 2px; padding: 4px 6px 4px 14px; border-bottom: 1px solid var(--line); }
|
||||||
|
.sgdtop .ibtn { width: 38px; height: 38px; } .sgdtop svg { width: 20px; height: 20px; }
|
||||||
|
.sgdbody { flex: 1; min-height: 0; overflow-y: auto; display: flex; flex-direction: column; }
|
||||||
|
.sgdockin { padding: 10px; flex: 1; min-height: 0; display: flex; flex-direction: column; }
|
||||||
|
.sgdockin .sg { flex: 1; min-height: 0; display: flex; flex-direction: column; }
|
||||||
|
.sgdockin .sg .sgl { display: block; } .sgdockin .sg.open .sgl { display: none; }
|
||||||
|
.sgdockin .sg .sgm { display: none; } .sgdockin .sg.open .sgm { display: flex; flex: 1; height: auto; min-height: 0; border: 0; border-radius: 0; background: none; }
|
||||||
|
.sgdockin .sgbar { padding-left: 0; }
|
||||||
|
.sgdt { font-size: 15px; }
|
||||||
|
.sgdockin .sgm .sgmsgs { padding: 8px 4px; }
|
||||||
|
.sgdockin .sgb { max-width: 86%; }
|
||||||
|
@media (display-mode: standalone) { #sgfab { bottom: calc(var(--ph, 0px) + 22px + var(--sab, 0px)); } }
|
||||||
@@ -35,6 +35,7 @@ const settings = require('./settings');
|
|||||||
const search = require('./search');
|
const search = require('./search');
|
||||||
const users = require('./users');
|
const users = require('./users');
|
||||||
const admin = require('./admin');
|
const admin = require('./admin');
|
||||||
|
const signal = require('./signal'); // Signal messages, through signal-cli-rest-api (SIGNAL_API_URL)
|
||||||
const vault = require('./vault')('nehub-plex');
|
const vault = require('./vault')('nehub-plex');
|
||||||
const VERSE_URL = process.env.VERSE_URL || 'https://versebyversewithnate.us/admin';
|
const VERSE_URL = process.env.VERSE_URL || 'https://versebyversewithnate.us/admin';
|
||||||
const PODCAST_URL = process.env.PODCAST_URL || 'https://podcast.necloud.us/';
|
const PODCAST_URL = process.env.PODCAST_URL || 'https://podcast.necloud.us/';
|
||||||
@@ -408,6 +409,7 @@ function storageInfo() {
|
|||||||
startedAt: STARTED };
|
startedAt: STARTED };
|
||||||
}
|
}
|
||||||
const STARTED = Date.now();
|
const STARTED = Date.now();
|
||||||
|
signal.init({ dataDir: path.resolve(process.env.DATA_DIR || path.join(__dirname, 'data')), apiUrl: process.env.SIGNAL_API_URL || '' });
|
||||||
{ const i = storageInfo(); console.log(`[data] ${i.dir}: ${i.passkeys} passkeys, ${i.calendarLinks} calendar links, ${i.events} events; mounted: ${i.mounted === null ? 'unknown' : i.mounted ? (i.source.kind + ' ' + i.source.name) : 'NO (data is lost on redeploy)'}; writable: ${i.writable}; COOKIE_SECRET from env: ${i.secretFromEnv}`);
|
{ const i = storageInfo(); console.log(`[data] ${i.dir}: ${i.passkeys} passkeys, ${i.calendarLinks} calendar links, ${i.events} events; mounted: ${i.mounted === null ? 'unknown' : i.mounted ? (i.source.kind + ' ' + i.source.name) : 'NO (data is lost on redeploy)'}; writable: ${i.writable}; COOKIE_SECRET from env: ${i.secretFromEnv}`);
|
||||||
if (i.source && i.source.kind === 'anonymous') console.warn('[data] WARNING: /data is an anonymous Docker volume, not your host folder. It is deleted when the container is recreated. Check that your stack mounts the host folder at /data.');
|
if (i.source && i.source.kind === 'anonymous') console.warn('[data] WARNING: /data is an anonymous Docker volume, not your host folder. It is deleted when the container is recreated. Check that your stack mounts the host folder at /data.');
|
||||||
if (i.mounted === false) console.warn('[data] WARNING: the data folder is not a mounted volume. Passkeys, calendars and sessions will be lost whenever the container is recreated. Mount a volume or host folder at /data.'); }
|
if (i.mounted === false) console.warn('[data] WARNING: the data folder is not a mounted volume. Passkeys, calendars and sessions will be lost whenever the container is recreated. Mount a volume or host folder at /data.'); }
|
||||||
@@ -442,6 +444,7 @@ const ACCESS = [
|
|||||||
[/^\/api\/oo\/config$/, ['office', 'files']],
|
[/^\/api\/oo\/config$/, ['office', 'files']],
|
||||||
[/^\/(api\/plex\/|plex\/)/, ['music']],
|
[/^\/(api\/plex\/|plex\/)/, ['music']],
|
||||||
[/^\/api\/audiobooks\//, ['audio']],
|
[/^\/api\/audiobooks\//, ['audio']],
|
||||||
|
[/^\/api\/signal\//, ['signal']],
|
||||||
[/^\/(webmail(\/|$)|api\/webmail\/)/, ['mail']],
|
[/^\/(webmail(\/|$)|api\/webmail\/)/, ['mail']],
|
||||||
[/^\/api\/cal\//, ['calendar']],
|
[/^\/api\/cal\//, ['calendar']],
|
||||||
[/^\/api\/tasks/, ['tasks']],
|
[/^\/api\/tasks/, ['tasks']],
|
||||||
@@ -477,7 +480,7 @@ async function handleRequest(req, res) {
|
|||||||
const c = t => users.can(user, t);
|
const c = t => users.can(user, t);
|
||||||
res.writeHead(200, { 'content-type': 'application/json' });
|
res.writeHead(200, { 'content-type': 'application/json' });
|
||||||
return res.end(JSON.stringify({ seafile: c('files') || c('office') ? SEAFILE_URL : '', onlyoffice: c('office') ? ONLYOFFICE_URL : '', audiobooks: c('audio') ? AUDIOBOOKS_URL : '', verse: c('verse') ? VERSE_URL : '',
|
return res.end(JSON.stringify({ seafile: c('files') || c('office') ? SEAFILE_URL : '', onlyoffice: c('office') ? ONLYOFFICE_URL : '', audiobooks: c('audio') ? AUDIOBOOKS_URL : '', verse: c('verse') ? VERSE_URL : '',
|
||||||
podcast: c('podcast') ? PODCAST_URL : '', webmail: c('mail') ? (WEBMAIL_URL || (WEBMAIL_BACKEND_URL ? '/webmail/' : '')) : '', editor: c('office') || c('files'), jwt: !!JWT_SECRET, build: BUILD }));
|
podcast: c('podcast') ? PODCAST_URL : '', signal: c('signal') && signal.enabled() ? '1' : '', webmail: c('mail') ? (WEBMAIL_URL || (WEBMAIL_BACKEND_URL ? '/webmail/' : '')) : '', editor: c('office') || c('files'), jwt: !!JWT_SECRET, build: BUILD }));
|
||||||
}
|
}
|
||||||
if (req.method === 'POST' && req.url === '/api/oo/config') return ooConfig(req, res);
|
if (req.method === 'POST' && req.url === '/api/oo/config') return ooConfig(req, res);
|
||||||
if (pathname === '/api/ddg') return ddgAnswer(req, res);
|
if (pathname === '/api/ddg') return ddgAnswer(req, res);
|
||||||
@@ -491,6 +494,7 @@ async function handleRequest(req, res) {
|
|||||||
res.writeHead(302, { location: to || AUDIOBOOKS_URL, 'cache-control': 'no-store', 'referrer-policy': 'no-referrer' }); return res.end();
|
res.writeHead(302, { location: to || AUDIOBOOKS_URL, 'cache-control': 'no-store', 'referrer-policy': 'no-referrer' }); return res.end();
|
||||||
}
|
}
|
||||||
if (pathname === '/api/webmail/login') { res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' }); return res.end(JSON.stringify(WEBMAIL_BACKEND_URL && CYPHT_PASSWORD ? { user: CYPHT_USER, pass: CYPHT_PASSWORD } : {})); }
|
if (pathname === '/api/webmail/login') { res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' }); return res.end(JSON.stringify(WEBMAIL_BACKEND_URL && CYPHT_PASSWORD ? { user: CYPHT_USER, pass: CYPHT_PASSWORD } : {})); }
|
||||||
|
if (pathname.startsWith('/api/signal/')) return signal.enabled() ? signal.handle(req, res) : forbidden(res, 'Signal is not set up on this server');
|
||||||
if (WEBMAIL_BACKEND_URL && isWebmailPath(pathname)) return proxyWebmail(req, res);
|
if (WEBMAIL_BACKEND_URL && isWebmailPath(pathname)) return proxyWebmail(req, res);
|
||||||
if (req.url.startsWith('/api/plex/')) return plexApi(req, res);
|
if (req.url.startsWith('/api/plex/')) return plexApi(req, res);
|
||||||
if (req.url.startsWith('/plex/') && req.method === 'GET') return plexProxy(req, res);
|
if (req.url.startsWith('/plex/') && req.method === 'GET') return plexProxy(req, res);
|
||||||
|
|||||||
@@ -0,0 +1,282 @@
|
|||||||
|
// Signal messages inside the hub, through signal-cli-rest-api (an unofficial but widely used bridge that links to your Signal account as
|
||||||
|
// one more linked device, like Signal Desktop). The hub keeps the conversations it receives in /data/signal so they survive restarts.
|
||||||
|
// Everything here is behind the hub's passkey sign-in and the "Signal" tab permission.
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const { Readable } = require('stream');
|
||||||
|
|
||||||
|
const MAX_PER_CONV = 1500;
|
||||||
|
const state = { api: '', dir: '', account: '', db: { account: '', convs: {}, msgs: {} }, ws: null, wsUp: false, backoff: 1000, clients: new Set(), saveT: null,
|
||||||
|
contacts: { at: 0, map: new Map() }, groups: { at: 0, list: [] }, started: false };
|
||||||
|
|
||||||
|
const file = () => path.join(state.dir, 'store.json');
|
||||||
|
function load() { try { state.db = { account: '', convs: {}, msgs: {}, ...JSON.parse(fs.readFileSync(file(), 'utf8')) }; } catch { /* first run */ } }
|
||||||
|
function flushNow() {
|
||||||
|
if (!state.saveT) return;
|
||||||
|
clearTimeout(state.saveT); state.saveT = null;
|
||||||
|
try { const f = file(), tmp = f + '.tmp'; fs.writeFileSync(tmp, JSON.stringify(state.db), { mode: 0o600 }); fs.renameSync(tmp, f); } catch (e) { console.error('[signal] could not save:', e.message); }
|
||||||
|
}
|
||||||
|
function saveSoon() {
|
||||||
|
if (state.saveT) return;
|
||||||
|
state.saveT = setTimeout(() => {
|
||||||
|
state.saveT = null;
|
||||||
|
try { const f = file(), tmp = f + '.tmp'; fs.writeFileSync(tmp, JSON.stringify(state.db), { mode: 0o600 }); fs.renameSync(tmp, f); } catch (e) { console.error('[signal] could not save:', e.message); }
|
||||||
|
}, 400);
|
||||||
|
state.saveT.unref && state.saveT.unref();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- talking to signal-cli-rest-api
|
||||||
|
async function api(method, p, body, timeout = 30000) {
|
||||||
|
const r = await fetch(state.api + p, { method, signal: AbortSignal.timeout(timeout), headers: body ? { 'content-type': 'application/json' } : undefined, body: body ? JSON.stringify(body) : undefined });
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
async function apiJson(method, p, body) {
|
||||||
|
const r = await api(method, p, body); const t = await r.text(); let j = null; try { j = JSON.parse(t); } catch { /* not json */ }
|
||||||
|
if (!r.ok) throw Object.assign(new Error((j && j.error) || ('Signal service answered ' + r.status)), { status: r.status });
|
||||||
|
return j;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- who is who
|
||||||
|
async function loadContacts(force) {
|
||||||
|
if (!state.account) return;
|
||||||
|
if (!force && Date.now() - state.contacts.at < 10 * 60e3) return;
|
||||||
|
state.contacts.at = Date.now();
|
||||||
|
try {
|
||||||
|
const list = await apiJson('GET', '/v1/contacts/' + encodeURIComponent(state.account));
|
||||||
|
const m = new Map();
|
||||||
|
for (const c of list || []) { const n = c.name || c.nickname && c.nickname.name || [c.given_name, c.family_name].filter(Boolean).join(' ') || c.profile_name || (c.profile && c.profile.given_name) || ''; if (c.number) m.set(c.number, n); }
|
||||||
|
state.contacts.map = m;
|
||||||
|
for (const c of Object.values(state.db.convs)) if (c.type === 'direct' && m.get(c.id) && c.name !== m.get(c.id)) { c.name = m.get(c.id); }
|
||||||
|
saveSoon();
|
||||||
|
} catch (e) { state.contacts.at = Date.now() - 9 * 60e3; /* try again in a minute */ }
|
||||||
|
}
|
||||||
|
async function loadGroups(force) {
|
||||||
|
if (!state.account) return;
|
||||||
|
if (!force && Date.now() - state.groups.at < 5 * 60e3) return;
|
||||||
|
state.groups.at = Date.now();
|
||||||
|
try {
|
||||||
|
state.groups.list = (await apiJson('GET', '/v1/groups/' + encodeURIComponent(state.account))) || [];
|
||||||
|
for (const g of state.groups.list) { const c = state.db.convs['g:' + g.internal_id]; if (c && g.name && c.name !== g.name) c.name = g.name; }
|
||||||
|
saveSoon();
|
||||||
|
} catch (e) { state.groups.at = Date.now() - 4 * 60e3; }
|
||||||
|
}
|
||||||
|
const groupApiId = internal => { const g = state.groups.list.find(x => x.internal_id === internal); return g ? g.id : 'group.' + Buffer.from(internal).toString('base64'); };
|
||||||
|
const displayName = (id, hint) => state.contacts.map.get(id) || hint || id;
|
||||||
|
|
||||||
|
// ---- keeping the conversations
|
||||||
|
function emit(ev) {
|
||||||
|
const s = 'data: ' + JSON.stringify({ ...ev, unread: totalUnread() }) + '\n\n';
|
||||||
|
for (const res of state.clients) { try { res.write(s); } catch { state.clients.delete(res); } }
|
||||||
|
}
|
||||||
|
const totalUnread = () => Object.values(state.db.convs).reduce((a, c) => a + (c.unread || 0), 0);
|
||||||
|
function conv(id, type, nameHint) {
|
||||||
|
let c = state.db.convs[id];
|
||||||
|
if (!c) {
|
||||||
|
c = state.db.convs[id] = { id, type, name: type === 'group' ? (nameHint || 'Group') : displayName(id, nameHint), ts: 0, preview: '', unread: 0 };
|
||||||
|
state.db.msgs[id] = state.db.msgs[id] || [];
|
||||||
|
if (type === 'direct') loadContacts(true); else loadGroups(true);
|
||||||
|
} else if (type === 'direct' && nameHint && (c.name === id || !c.name)) c.name = displayName(id, nameHint);
|
||||||
|
else if (type === 'group' && nameHint && c.name === 'Group') c.name = nameHint;
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
const findMsg = (cid, ts) => (state.db.msgs[cid] || []).find(m => m.ts === ts);
|
||||||
|
const attOf = a => ({ id: a.id, type: a.contentType, name: a.filename, size: a.size, w: a.width, h: a.height, voice: !!a.isVoiceNote });
|
||||||
|
const previewOf = m => m.deleted ? 'Message deleted' : m.text || (m.att && m.att[0] ? (/^image\//.test(m.att[0].type) ? 'Photo' : /^audio\//.test(m.att[0].type) ? 'Voice message' : m.att[0].name || 'Attachment') : '');
|
||||||
|
|
||||||
|
function addMessage(cid, c, m) {
|
||||||
|
const list = state.db.msgs[cid] = state.db.msgs[cid] || [];
|
||||||
|
if (list.some(x => x.id === m.id)) return false;
|
||||||
|
list.push(m); list.sort((a, b) => a.ts - b.ts);
|
||||||
|
if (list.length > MAX_PER_CONV) list.splice(0, list.length - MAX_PER_CONV);
|
||||||
|
if (m.ts >= c.ts) { c.ts = m.ts; c.preview = (m.out ? 'You: ' : '') + previewOf(m); }
|
||||||
|
if (!m.out) c.unread = (c.unread || 0) + 1;
|
||||||
|
saveSoon();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ingestData(dm, from, fromName, ts, out, dest) {
|
||||||
|
const gid = dm.groupInfo && dm.groupInfo.groupId;
|
||||||
|
const cid = gid ? 'g:' + gid : (out ? dest : from);
|
||||||
|
if (!cid) return;
|
||||||
|
const c = conv(cid, gid ? 'group' : 'direct', gid ? dm.groupInfo.groupName : (out ? '' : fromName));
|
||||||
|
if (dm.reaction) {
|
||||||
|
const m = findMsg(cid, dm.reaction.targetSentTimestamp); if (!m) return;
|
||||||
|
m.react = m.react || {}; if (dm.reaction.isRemove) delete m.react[from]; else m.react[from] = dm.reaction.emoji;
|
||||||
|
saveSoon(); return emit({ t: 'update', conv: cid });
|
||||||
|
}
|
||||||
|
if (dm.remoteDelete) {
|
||||||
|
const m = findMsg(cid, dm.remoteDelete.timestamp); if (!m) return;
|
||||||
|
m.deleted = true; m.text = ''; m.att = []; saveSoon(); return emit({ t: 'update', conv: cid });
|
||||||
|
}
|
||||||
|
const text = dm.message || '', att = (dm.attachments || []).map(attOf);
|
||||||
|
if (!text && !att.length && !dm.sticker) return;
|
||||||
|
const m = { id: ts + ':' + from, ts, out: !!out, from, name: out ? '' : fromName || displayName(from, ''), text, att };
|
||||||
|
if (dm.sticker) m.text = m.text || '(sticker)';
|
||||||
|
if (dm.quote) m.quote = { author: dm.quote.authorNumber || dm.quote.author, text: dm.quote.text || '', ts: dm.quote.id };
|
||||||
|
if (addMessage(cid, c, m)) emit({ t: 'msg', conv: cid, out: !!out, name: c.name, preview: previewOf(m) });
|
||||||
|
}
|
||||||
|
|
||||||
|
function ingestEdit(ed, from, ts, out, dest) {
|
||||||
|
const dm = ed.dataMessage || {}, gid = dm.groupInfo && dm.groupInfo.groupId, cid = gid ? 'g:' + gid : (out ? dest : from);
|
||||||
|
const m = cid && findMsg(cid, ed.targetSentTimestamp); if (!m) return;
|
||||||
|
m.text = dm.message || m.text; m.edited = true;
|
||||||
|
const c = state.db.convs[cid]; if (c && m.ts >= c.ts) c.preview = (m.out ? 'You: ' : '') + previewOf(m);
|
||||||
|
saveSoon(); emit({ t: 'update', conv: cid });
|
||||||
|
}
|
||||||
|
|
||||||
|
function ingestReceipt(r, who) {
|
||||||
|
const status = r.isRead || r.isViewed ? 'read' : r.isDelivery ? 'delivered' : '';
|
||||||
|
if (!status) return;
|
||||||
|
let changed = null;
|
||||||
|
for (const [cid, list] of Object.entries(state.db.msgs)) {
|
||||||
|
if (cid !== who && !cid.startsWith('g:')) continue;
|
||||||
|
for (const m of list) if (m.out && (r.timestamps || []).includes(m.ts) && (m.status !== 'read')) { m.status = status; changed = cid; }
|
||||||
|
}
|
||||||
|
if (changed) { saveSoon(); emit({ t: 'update', conv: changed }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function ingest(env) {
|
||||||
|
if (!env) return;
|
||||||
|
const ts = env.timestamp || Date.now(), who = env.sourceNumber || env.source || env.sourceUuid || '', name = env.sourceName || '';
|
||||||
|
if (env.dataMessage) ingestData(env.dataMessage, who, name, env.dataMessage.timestamp || ts, false);
|
||||||
|
else if (env.editMessage) ingestEdit(env.editMessage, who, ts, false);
|
||||||
|
else if (env.syncMessage && env.syncMessage.sentMessage) {
|
||||||
|
const sm = env.syncMessage.sentMessage;
|
||||||
|
if (sm.editMessage) ingestEdit(sm.editMessage, state.account, ts, true, sm.destinationNumber || sm.destination);
|
||||||
|
else ingestData(sm, state.account, '', sm.timestamp || ts, true, sm.destinationNumber || sm.destination || sm.destinationUuid);
|
||||||
|
} else if (env.receiptMessage) ingestReceipt(env.receiptMessage, who);
|
||||||
|
else if (env.typingMessage) emit({ t: 'typing', conv: env.typingMessage.groupId ? 'g:' + env.typingMessage.groupId : who, stop: env.typingMessage.action === 'STOPPED' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRaw(text) {
|
||||||
|
let m; try { m = JSON.parse(text); } catch { return; }
|
||||||
|
const env = m.envelope || (m.params && m.params.envelope) || (m.result && m.result.envelope);
|
||||||
|
try { ingest(env); } catch (e) { console.error('[signal] could not read a message:', e.message); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- the link to signal-cli
|
||||||
|
function connect() {
|
||||||
|
if (!state.account || state.ws || typeof WebSocket === 'undefined') return;
|
||||||
|
const url = state.api.replace(/^http/, 'ws') + '/v1/receive/' + encodeURIComponent(state.account);
|
||||||
|
let ws;
|
||||||
|
try { ws = new WebSocket(url); } catch (e) { return setTimeout(connect, state.backoff = Math.min(state.backoff * 2, 30000)); }
|
||||||
|
state.ws = ws;
|
||||||
|
ws.onopen = () => { state.wsUp = true; state.backoff = 1000; emit({ t: 'status' }); };
|
||||||
|
ws.onmessage = e => handleRaw(typeof e.data === 'string' ? e.data : String(e.data));
|
||||||
|
const gone = () => { if (state.ws !== ws) return; state.ws = null; state.wsUp = false; emit({ t: 'status' }); const t = setTimeout(connect, state.backoff = Math.min(state.backoff * 2, 30000)); t.unref && t.unref(); };
|
||||||
|
ws.onclose = gone; ws.onerror = gone;
|
||||||
|
}
|
||||||
|
async function refreshAccount() {
|
||||||
|
try {
|
||||||
|
const list = await apiJson('GET', '/v1/accounts');
|
||||||
|
const want = process.env.SIGNAL_NUMBER || '';
|
||||||
|
const acct = (list || []).find(n => !want || n === want) || '';
|
||||||
|
if (acct !== state.account) {
|
||||||
|
state.account = acct; if (acct && state.db.account !== acct) { state.db = { account: acct, convs: {}, msgs: {} }; saveSoon(); } // a different number: start clean
|
||||||
|
if (!acct && state.ws) { try { state.ws.close(); } catch { /* already closed */ } }
|
||||||
|
emit({ t: 'status' });
|
||||||
|
}
|
||||||
|
if (state.account) { connect(); loadContacts(false); loadGroups(false); }
|
||||||
|
state.reachable = true;
|
||||||
|
} catch (e) { state.reachable = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function init({ dataDir, apiUrl }) {
|
||||||
|
state.api = (apiUrl || '').replace(/\/$/, ''); state.dir = path.join(dataDir, 'signal');
|
||||||
|
if (!state.api || state.started) return;
|
||||||
|
state.started = true;
|
||||||
|
fs.mkdirSync(path.join(state.dir, 'out'), { recursive: true, mode: 0o700 });
|
||||||
|
load(); state.account = state.db.account || '';
|
||||||
|
for (const sig of ['SIGTERM', 'SIGINT']) process.once(sig, () => { flushNow(); process.exit(0); }); // do not lose the last moments of a chat when the container stops
|
||||||
|
refreshAccount();
|
||||||
|
const t = setInterval(refreshAccount, +process.env.SIGNAL_POLL_MS || 10000); t.unref && t.unref();
|
||||||
|
}
|
||||||
|
const enabled = () => !!state.api;
|
||||||
|
|
||||||
|
// ---- what the app asks for
|
||||||
|
const json = (res, code, o) => { res.writeHead(code, { 'content-type': 'application/json', 'cache-control': 'no-store' }); res.end(JSON.stringify(o)); };
|
||||||
|
const readBody = (req, max = 40e6) => new Promise((ok, no) => { const c = []; let n = 0; req.on('data', d => { n += d.length; if (n > max) { no(new Error('That is too large to send')); req.destroy(); } else c.push(d); }); req.on('end', () => ok(Buffer.concat(c).toString())); req.on('error', no); });
|
||||||
|
const safeId = s => /^[A-Za-z0-9+/=_.:-]{1,300}$/.test(s);
|
||||||
|
|
||||||
|
async function send(cid, text, atts, quoteTs) {
|
||||||
|
const c = state.db.convs[cid]; if (!c) throw new Error('Unknown conversation');
|
||||||
|
if (!state.account) throw new Error('Signal is not linked yet');
|
||||||
|
const recipients = c.type === 'group' ? [groupApiId(cid.slice(2))] : [cid];
|
||||||
|
const body = { message: text || '', number: state.account, recipients, base64_attachments: atts.map(a => `data:${a.type || 'application/octet-stream'};filename=${encodeURIComponent(a.name || 'file')};base64,${a.data}`) };
|
||||||
|
const q = quoteTs && findMsg(cid, quoteTs);
|
||||||
|
if (q) { body.quote_timestamp = q.ts; body.quote_author = q.out ? state.account : q.from; body.quote_message = q.text || ''; }
|
||||||
|
const r = await apiJson('POST', '/v2/send', body);
|
||||||
|
const ts = parseInt(r && r.timestamp, 10) || Date.now();
|
||||||
|
const saved = atts.map(a => { const id = 'local-' + crypto.randomBytes(8).toString('hex'); try { fs.writeFileSync(path.join(state.dir, 'out', id), Buffer.from(a.data, 'base64'), { mode: 0o600 }); } catch { /* attachment kept only in Signal */ } return { id, type: a.type, name: a.name, size: Buffer.from(a.data, 'base64').length, local: true }; });
|
||||||
|
const m = { id: ts + ':' + state.account, ts, out: true, from: state.account, name: '', text: text || '', att: saved, status: 'sent' };
|
||||||
|
if (q) m.quote = { author: q.out ? state.account : q.from, text: q.text || '', ts: q.ts };
|
||||||
|
addMessage(cid, c, m); emit({ t: 'msg', conv: cid, out: true, name: c.name, preview: previewOf(m) });
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handle(req, res) {
|
||||||
|
const u = new URL(req.url, 'http://x'), p = u.pathname.replace(/^\/api\/signal/, '') || '/';
|
||||||
|
try {
|
||||||
|
if (p === '/status') return json(res, 200, { linked: !!state.account, number: state.account, connected: state.wsUp, reachable: state.reachable !== false, unread: totalUnread() });
|
||||||
|
if (p === '/qr') { // a new link code each time; scanned in Signal -> Settings -> Linked devices
|
||||||
|
const r = await api('GET', '/v1/qrcodelink?device_name=' + encodeURIComponent('NE Hub'), null, 60000);
|
||||||
|
if (!r.ok) return json(res, 502, { error: 'Could not get a link code from the Signal service' });
|
||||||
|
res.writeHead(200, { 'content-type': r.headers.get('content-type') || 'image/png', 'cache-control': 'no-store' }); return res.end(Buffer.from(await r.arrayBuffer()));
|
||||||
|
}
|
||||||
|
if (p === '/events') {
|
||||||
|
res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-store', connection: 'keep-alive', 'x-accel-buffering': 'no' });
|
||||||
|
res.write(': hello\n\n'); state.clients.add(res);
|
||||||
|
const ka = setInterval(() => { try { res.write(': \n\n'); } catch { /* closed */ } }, 25000);
|
||||||
|
res.on('close', () => { clearInterval(ka); state.clients.delete(res); }); return;
|
||||||
|
}
|
||||||
|
if (!state.account) return json(res, 409, { error: 'Signal is not linked yet' });
|
||||||
|
if (p === '/convs') return json(res, 200, { convs: Object.values(state.db.convs).sort((a, b) => b.ts - a.ts), unread: totalUnread() });
|
||||||
|
if (p === '/contacts') { await loadContacts(false); return json(res, 200, { contacts: [...state.contacts.map].map(([number, name]) => ({ number, name })).sort((a, b) => (a.name || a.number).localeCompare(b.name || b.number)) }); }
|
||||||
|
let m;
|
||||||
|
if ((m = /^\/conv\/(.+)$/.exec(p)) && req.method === 'GET') {
|
||||||
|
const id = decodeURIComponent(m[1]), c = state.db.convs[id]; if (!c) return json(res, 404, { error: 'Unknown conversation' });
|
||||||
|
const before = +u.searchParams.get('before') || Infinity, limit = Math.min(+u.searchParams.get('limit') || 100, 300);
|
||||||
|
const all = (state.db.msgs[id] || []).filter(x => x.ts < before), msgs = all.slice(-limit);
|
||||||
|
return json(res, 200, { conv: c, msgs, more: all.length > msgs.length, names: Object.fromEntries([...state.contacts.map]) });
|
||||||
|
}
|
||||||
|
if (p === '/new' && req.method === 'POST') {
|
||||||
|
const b = JSON.parse(await readBody(req, 10000)), n = String(b.number || '').replace(/[\s()-]/g, '');
|
||||||
|
if (!/^\+\d{6,15}$/.test(n)) return json(res, 400, { error: 'Enter a phone number with its country code, like +15551234567' });
|
||||||
|
const c = conv(n, 'direct', ''); saveSoon(); return json(res, 200, { conv: c });
|
||||||
|
}
|
||||||
|
if (p === '/read' && req.method === 'POST') {
|
||||||
|
const b = JSON.parse(await readBody(req, 10000)), c = state.db.convs[b.id]; if (!c) return json(res, 404, { error: 'Unknown conversation' });
|
||||||
|
c.unread = 0; saveSoon(); emit({ t: 'read', conv: c.id });
|
||||||
|
if (c.type === 'direct') for (const msg of (state.db.msgs[c.id] || [])) if (!msg.out && !msg.rs) { msg.rs = true; api('POST', '/v1/receipts/' + encodeURIComponent(state.account), { receipt_type: 'read', recipient: c.id, timestamp: msg.ts }).catch(() => {}); }
|
||||||
|
return json(res, 200, { ok: true });
|
||||||
|
}
|
||||||
|
if (p === '/send' && req.method === 'POST') {
|
||||||
|
const b = JSON.parse(await readBody(req));
|
||||||
|
if (!String(b.text || '').trim() && !(b.attachments || []).length) return json(res, 400, { error: 'Write a message or add a file' });
|
||||||
|
const msg = await send(b.to, String(b.text || ''), (b.attachments || []).slice(0, 10), b.quote); return json(res, 200, { msg });
|
||||||
|
}
|
||||||
|
if (p === '/react' && req.method === 'POST') {
|
||||||
|
const b = JSON.parse(await readBody(req, 10000)), c = state.db.convs[b.to], tm = c && findMsg(b.to, b.ts); if (!tm) return json(res, 404, { error: 'Unknown message' });
|
||||||
|
const author = tm.out ? state.account : tm.from, mine = state.account;
|
||||||
|
await apiJson(b.emoji ? 'POST' : 'DELETE', '/v1/reactions/' + encodeURIComponent(state.account), { reaction: b.emoji || (tm.react && tm.react[mine]) || '👍', recipient: c.type === 'group' ? groupApiId(b.to.slice(2)) : b.to, target_author: author, timestamp: tm.ts });
|
||||||
|
tm.react = tm.react || {}; if (b.emoji) tm.react[mine] = b.emoji; else delete tm.react[mine]; saveSoon(); emit({ t: 'update', conv: b.to }); return json(res, 200, { ok: true });
|
||||||
|
}
|
||||||
|
if ((m = /^\/attachment\/(.+)$/.exec(p))) {
|
||||||
|
const id = decodeURIComponent(m[1]); if (!safeId(id) || id.includes('..')) return json(res, 400, { error: 'Bad attachment' });
|
||||||
|
let meta = null; for (const list of Object.values(state.db.msgs)) { for (const x of list) { const a = (x.att || []).find(q => q.id === id); if (a) { meta = a; break; } } if (meta) break; }
|
||||||
|
if (!meta) return json(res, 404, { error: 'Unknown attachment' });
|
||||||
|
const head = { 'content-type': meta.type || 'application/octet-stream', 'cache-control': 'private, max-age=86400', 'content-disposition': (/^(image|audio|video)\//.test(meta.type || '') ? 'inline' : 'attachment') + `; filename*=UTF-8''${encodeURIComponent(meta.name || 'file')}`, 'x-content-type-options': 'nosniff' };
|
||||||
|
if (meta.local) { const f = path.join(state.dir, 'out', id); if (!fs.existsSync(f)) return json(res, 404, { error: 'Gone' }); res.writeHead(200, head); return fs.createReadStream(f).pipe(res); }
|
||||||
|
const r = await api('GET', '/v1/attachments/' + encodeURIComponent(id), null, 120000);
|
||||||
|
if (!r.ok) return json(res, 404, { error: 'Gone' });
|
||||||
|
res.writeHead(200, head); return Readable.fromWeb(r.body).on('error', () => res.destroy()).pipe(res);
|
||||||
|
}
|
||||||
|
return json(res, 404, { error: 'Not found' });
|
||||||
|
} catch (e) {
|
||||||
|
if (res.headersSent) return res.destroy();
|
||||||
|
json(res, e.status && e.status < 500 ? e.status : 502, { error: e.message || 'Signal service error' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { init, handle, enabled, _state: state };
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// Signal in the hub: linking, receiving (messages, groups, sync, reactions, receipts, edits, deletes), sending, unread, attachments, live events, access.
|
||||||
|
// Uses a fake signal-cli-rest-api (HTTP plus the websocket that json-rpc mode uses). Run: node tools/signal-test.js
|
||||||
|
const { spawn } = require('child_process');
|
||||||
|
const http = require('http');
|
||||||
|
const fs = require('fs'), os = require('os'), path = require('path'), crypto = require('crypto');
|
||||||
|
const root = path.join(__dirname, '..');
|
||||||
|
let failed = 0;
|
||||||
|
const ok = (n, c, x) => { console.log((c ? 'PASS ' : 'FAIL ') + n + (c ? '' : ' -> ' + JSON.stringify(x))); if (!c) failed++; };
|
||||||
|
const wait = ms => new Promise(r => setTimeout(r, ms));
|
||||||
|
const until = async (f, ms = 8000) => { const t = Date.now(); while (Date.now() - t < ms) { const v = await f(); if (v) return v; await wait(100); } return false; };
|
||||||
|
const SECRET = 'signal-test-secret-'.repeat(2), ME = '+15550001';
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
// ---- fake signal-cli-rest-api
|
||||||
|
const fake = { accounts: [], sends: [], receipts: [], reactions: [], sockets: new Set(), contacts: [{ number: '+15551234567', name: 'Sam Rivera' }], groups: [{ id: 'group.' + Buffer.from('GID123==').toString('base64'), internal_id: 'GID123==', name: 'Family' }] };
|
||||||
|
const frame = text => { const p = Buffer.from(text), n = p.length; const h = n < 126 ? Buffer.from([0x81, n]) : Buffer.from([0x81, 126, n >> 8, n & 255]); return Buffer.concat([h, p]); };
|
||||||
|
const push = o => { for (const s of fake.sockets) s.write(frame(JSON.stringify(o))); };
|
||||||
|
const png = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==', 'base64');
|
||||||
|
const srv = http.createServer((rq, rs) => {
|
||||||
|
const body = []; rq.on('data', d => body.push(d)); rq.on('end', () => {
|
||||||
|
const b = Buffer.concat(body).toString(), u = new URL(rq.url, 'http://x'), j = (c, o) => { rs.writeHead(c, { 'content-type': 'application/json' }); rs.end(JSON.stringify(o)); };
|
||||||
|
if (u.pathname === '/v1/accounts') return j(200, fake.accounts);
|
||||||
|
if (u.pathname === '/v1/qrcodelink') { rs.writeHead(200, { 'content-type': 'image/png' }); return rs.end(png); }
|
||||||
|
if (u.pathname.startsWith('/v1/contacts/')) return j(200, fake.contacts);
|
||||||
|
if (u.pathname.startsWith('/v1/groups/')) return j(200, fake.groups);
|
||||||
|
if (u.pathname === '/v2/send') { fake.sends.push(JSON.parse(b)); return j(201, { timestamp: String(1700000000000 + fake.sends.length * 1000) }); }
|
||||||
|
if (u.pathname.startsWith('/v1/receipts/')) { fake.receipts.push(JSON.parse(b)); return j(204, {}); }
|
||||||
|
if (u.pathname.startsWith('/v1/reactions/')) { fake.reactions.push({ method: rq.method, ...JSON.parse(b) }); return j(200, {}); }
|
||||||
|
if (u.pathname === '/v1/attachments/att-1') { rs.writeHead(200, { 'content-type': 'image/png' }); return rs.end(png); }
|
||||||
|
j(404, { error: 'not found ' + u.pathname });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
srv.on('upgrade', (rq, socket) => {
|
||||||
|
const accept = crypto.createHash('sha1').update(rq.headers['sec-websocket-key'] + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11').digest('base64');
|
||||||
|
socket.write('HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: ' + accept + '\r\n\r\n');
|
||||||
|
fake.sockets.add(socket); socket.on('close', () => fake.sockets.delete(socket)); socket.on('error', () => {}); socket.on('data', () => {});
|
||||||
|
});
|
||||||
|
await new Promise(r => srv.listen(0, '127.0.0.1', r));
|
||||||
|
|
||||||
|
// ---- the hub
|
||||||
|
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'signal-test-')), port = 20000 + Math.floor(Math.random() * 20000);
|
||||||
|
fs.writeFileSync(path.join(dataDir, 'users.json'), JSON.stringify({ users: [
|
||||||
|
{ id: 'uadmin1', name: 'Admin', role: 'admin', tabs: [], handle: 'h', epoch: 1, disabled: false, created: 1 },
|
||||||
|
{ id: 'ufriend1', name: 'Friend', role: 'user', tabs: ['mail'], handle: 'h2', epoch: 1, disabled: false, created: 2 }] }));
|
||||||
|
const start = () => { const c = spawn(process.execPath, ['server.js'], { cwd: root, env: { ...process.env, PORT: String(port), DATA_DIR: dataDir, COOKIE_SECRET: SECRET, SIGNAL_API_URL: 'http://127.0.0.1:' + srv.address().port, SIGNAL_POLL_MS: '300', SEAFILE_URL: 'http://127.0.0.1:9' } }); c.stdout.on('data', () => {}); c.stderr.on('data', d => process.env.SIG_DEBUG && process.stderr.write(d)); return c; };
|
||||||
|
let child = start(); await wait(1500);
|
||||||
|
const base = 'http://127.0.0.1:' + port, exp = Date.now() + 864e5;
|
||||||
|
const ck = (uid) => `hub_session=${uid}.1.${exp}.${crypto.createHmac('sha256', SECRET).update(`s${uid}.1.${exp}`).digest('base64url')}`;
|
||||||
|
const call = (p, o = {}, uid = 'uadmin1') => fetch(base + p, { redirect: 'manual', ...o, headers: { cookie: ck(uid), ...(o.json ? { 'content-type': 'application/json' } : {}), ...(o.headers || {}) }, body: o.json ? JSON.stringify(o.json) : o.body });
|
||||||
|
const getJ = async (p, uid) => (await call(p, {}, uid)).json();
|
||||||
|
const post = (p, json) => call(p, { method: 'POST', json });
|
||||||
|
|
||||||
|
// ---- not linked yet
|
||||||
|
let r = await call('/api/config'); ok('the Signal tab is offered when the service is set up', (await r.json()).signal === '1');
|
||||||
|
r = await call('/api/config', {}, 'ufriend1'); ok('but not to someone without the Signal tab', !(await r.json()).signal);
|
||||||
|
r = await call('/api/signal/status', {}, 'ufriend1'); ok('and they cannot use it', r.status === 403, r.status);
|
||||||
|
let st = await getJ('/api/signal/status'); ok('before linking, it says so', st.linked === false, st);
|
||||||
|
r = await call('/api/signal/qr'); ok('a link code (QR picture) comes from the Signal service', r.status === 200 && r.headers.get('content-type') === 'image/png' && (await r.arrayBuffer()).byteLength === png.length);
|
||||||
|
r = await call('/api/signal/convs'); ok('no conversations before linking', r.status === 409, r.status);
|
||||||
|
|
||||||
|
// ---- linked
|
||||||
|
fake.accounts = [ME];
|
||||||
|
st = await until(async () => { const s = await getJ('/api/signal/status'); return s.linked && s.connected ? s : false; });
|
||||||
|
ok('after linking in the phone app, it notices and connects', !!st && st.number === ME, st);
|
||||||
|
|
||||||
|
// live events
|
||||||
|
const events = []; const ac = new AbortController();
|
||||||
|
fetch(base + '/api/signal/events', { headers: { cookie: ck('uadmin1') }, signal: ac.signal }).then(async res => { const rd = res.body.getReader(); const dec = new TextDecoder(); for (;;) { const { value, done } = await rd.read(); if (done) break; for (const l of dec.decode(value).split('\n')) if (l.startsWith('data: ')) events.push(JSON.parse(l.slice(6))); } }).catch(() => {});
|
||||||
|
await wait(300);
|
||||||
|
|
||||||
|
const T0 = 1700000100000; // (later than the times the fake gives to what we send)
|
||||||
|
push({ account: ME, envelope: { sourceNumber: '+15551234567', sourceName: 'Sam', timestamp: T0, dataMessage: { timestamp: T0, message: 'Hi there' } } });
|
||||||
|
push({ account: ME, envelope: { sourceNumber: '+15557654321', sourceName: 'Kim', timestamp: T0 + 1000, dataMessage: { timestamp: T0 + 1000, message: 'Dinner?', groupInfo: { groupId: 'GID123==', groupName: 'Family', type: 'DELIVER' } } } });
|
||||||
|
push({ account: ME, envelope: { sourceNumber: ME, timestamp: T0 + 2000, syncMessage: { sentMessage: { timestamp: T0 + 2000, destinationNumber: '+15551234567', message: 'Hello Sam (from my phone)' } } } });
|
||||||
|
push({ account: ME, envelope: { sourceNumber: '+15551234567', sourceName: 'Sam', timestamp: T0 + 3000, dataMessage: { timestamp: T0 + 3000, message: 'Look', attachments: [{ id: 'att-1', contentType: 'image/png', filename: 'cat.png', size: png.length }] } } });
|
||||||
|
let convs = await until(async () => { const c = (await getJ('/api/signal/convs')).convs; return c.length >= 2 && c.find(x => x.id === '+15551234567').preview === 'Look' ? c : false; });
|
||||||
|
ok('incoming direct and group messages make conversations, newest first', !!convs && convs[0].id === '+15551234567' && convs.some(c => c.id === 'g:GID123==' && c.type === 'group'), convs);
|
||||||
|
ok('the contact\'s saved name is used', !!convs && convs.find(c => c.id === '+15551234567').name === 'Sam Rivera', convs && convs.map(c => c.name));
|
||||||
|
ok('unread counts', !!convs && convs.find(c => c.id === '+15551234567').unread === 2 && convs.find(c => c.id === 'g:GID123==').unread === 1, convs);
|
||||||
|
ok('the preview shows the latest message', !!convs && convs.find(c => c.id === '+15551234567').preview === 'Look');
|
||||||
|
ok('the group keeps its name', !!convs && convs.find(c => c.id === 'g:GID123==').name === 'Family');
|
||||||
|
ok('live events reach the app, with the unread total', events.some(e => e.t === 'msg' && e.conv === '+15551234567') && events.some(e => e.unread >= 3), events.slice(-3));
|
||||||
|
let th = await getJ('/api/signal/conv/' + encodeURIComponent('+15551234567'));
|
||||||
|
ok('a message written on your phone shows as yours, in order', th.msgs.map(m => (m.out ? 'me:' : 'in:') + (m.text || '')).join('|') === 'in:Hi there|me:Hello Sam (from my phone)|in:Look', th.msgs.map(m => m.text));
|
||||||
|
ok('an attachment is listed', th.msgs[2].att.length === 1 && th.msgs[2].att[0].name === 'cat.png');
|
||||||
|
r = await call('/api/signal/attachment/att-1'); ok('and it can be fetched through the hub', r.status === 200 && r.headers.get('content-type') === 'image/png' && (await r.arrayBuffer()).byteLength === png.length, r.status);
|
||||||
|
r = await call('/api/signal/attachment/..%2F..%2Fetc%2Fpasswd'); ok('attachment names cannot wander off', r.status === 400 || r.status === 404, r.status);
|
||||||
|
|
||||||
|
// reactions, receipts, edits, deletes
|
||||||
|
push({ account: ME, envelope: { sourceNumber: '+15551234567', timestamp: T0 + 4000, dataMessage: { timestamp: T0 + 4000, reaction: { emoji: '👍', targetAuthorNumber: ME, targetSentTimestamp: T0 + 2000 } } } });
|
||||||
|
push({ account: ME, envelope: { sourceNumber: '+15551234567', timestamp: T0 + 5000, editMessage: { targetSentTimestamp: T0, dataMessage: { message: 'Hi there (edited)' } } } });
|
||||||
|
push({ account: ME, envelope: { sourceNumber: '+15551234567', timestamp: T0 + 6000, dataMessage: { timestamp: T0 + 6000, remoteDelete: { timestamp: T0 + 3000 } } } });
|
||||||
|
th = await until(async () => { const t = await getJ('/api/signal/conv/' + encodeURIComponent('+15551234567')); return t.msgs[1].react && t.msgs[0].edited && t.msgs[2].deleted ? t : false; });
|
||||||
|
ok('a reaction, an edit and a delete are applied', !!th && th.msgs[1].react['+15551234567'] === '👍' && th.msgs[0].text === 'Hi there (edited)' && th.msgs[2].text === '' && th.msgs[2].att.length === 0, th);
|
||||||
|
|
||||||
|
// sending
|
||||||
|
r = await post('/api/signal/send', { to: '+15551234567', text: 'On my way' }); let sent = (await r.json()).msg;
|
||||||
|
ok('sending to a person', r.status === 200 && fake.sends.length === 1 && fake.sends[0].number === ME && fake.sends[0].recipients[0] === '+15551234567' && fake.sends[0].message === 'On my way', fake.sends);
|
||||||
|
ok('your sent message appears at once', sent && sent.out && sent.status === 'sent');
|
||||||
|
push({ account: ME, envelope: { sourceNumber: '+15551234567', timestamp: T0 + 7000, receiptMessage: { isDelivery: true, timestamps: [sent.ts] } } });
|
||||||
|
push({ account: ME, envelope: { sourceNumber: '+15551234567', timestamp: T0 + 8000, receiptMessage: { isRead: true, timestamps: [sent.ts] } } });
|
||||||
|
th = await until(async () => { const t = await getJ('/api/signal/conv/' + encodeURIComponent('+15551234567')); const m = t.msgs.find(x => x.ts === sent.ts); return m && m.status === 'read' ? t : false; });
|
||||||
|
ok('delivered and read receipts update the message', !!th);
|
||||||
|
r = await post('/api/signal/send', { to: 'g:GID123==', text: 'Count me in' });
|
||||||
|
ok('sending to a group uses the group id Signal expects', r.status === 200 && fake.sends[1].recipients[0] === fake.groups[0].id, fake.sends[1]);
|
||||||
|
r = await post('/api/signal/send', { to: '+15551234567', text: 'photo', attachments: [{ name: 'a b.png', type: 'image/png', data: png.toString('base64') }], quote: T0 + 3000 });
|
||||||
|
ok('a picture goes out as an attachment, with the quote', fake.sends[2].base64_attachments[0].startsWith('data:image/png;filename=a%20b.png;base64,') && fake.sends[2].quote_timestamp === T0 + 3000, fake.sends[2]);
|
||||||
|
const msgs3 = (await getJ('/api/signal/conv/' + encodeURIComponent('+15551234567'))).msgs; const mine = msgs3.find(m => m.text === 'photo');
|
||||||
|
r = await call('/api/signal/attachment/' + mine.att[0].id); ok('your own sent picture can be shown again', r.status === 200 && (await r.arrayBuffer()).byteLength === png.length, r.status);
|
||||||
|
r = await post('/api/signal/send', { to: '+15551234567', text: ' ' }); ok('an empty message is refused', r.status === 400, r.status);
|
||||||
|
r = await post('/api/signal/send', { to: 'nobody', text: 'x' }); ok('so is an unknown conversation', r.status >= 400, r.status);
|
||||||
|
|
||||||
|
// reading and reacting
|
||||||
|
r = await post('/api/signal/read', { id: '+15551234567' }); await wait(300);
|
||||||
|
ok('opening a chat marks it read and tells the sender', (await getJ('/api/signal/convs')).convs.find(c => c.id === '+15551234567').unread === 0 && fake.receipts.length >= 1 && fake.receipts[0].receipt_type === 'read' && fake.receipts[0].recipient === '+15551234567', fake.receipts);
|
||||||
|
r = await post('/api/signal/react', { to: '+15551234567', ts: T0, emoji: '❤️' });
|
||||||
|
ok('reacting to a message', r.status === 200 && fake.reactions[0].reaction === '❤️' && fake.reactions[0].recipient === '+15551234567', fake.reactions);
|
||||||
|
r = await post('/api/signal/new', { number: '+1 (555) 000-9999' }); const nc = (await r.json()).conv;
|
||||||
|
ok('starting a chat with a new number', r.status === 200 && nc.id === '+15550009999', nc);
|
||||||
|
r = await post('/api/signal/new', { number: 'abc' }); ok('a bad number is refused', r.status === 400);
|
||||||
|
|
||||||
|
// restart: history stays; the socket coming back
|
||||||
|
ac.abort(); child.kill(); await wait(800); const before = JSON.parse(fs.readFileSync(path.join(dataDir, 'signal', 'store.json'), 'utf8'));
|
||||||
|
ok('conversations are saved on disk', Object.keys(before.convs).length >= 3, Object.keys(before.convs));
|
||||||
|
child = start(); await wait(1500);
|
||||||
|
convs = (await getJ('/api/signal/convs')).convs;
|
||||||
|
ok('after a restart the history is still there', Array.isArray(convs) && convs.some(c => c.id === '+15551234567'), convs);
|
||||||
|
await until(async () => (await getJ('/api/signal/status')).connected);
|
||||||
|
for (const s of fake.sockets) s.destroy(); // the service drops the connection
|
||||||
|
const back = await until(async () => { const s = await getJ('/api/signal/status'); return s.connected && fake.sockets.size > 0; }, 12000);
|
||||||
|
ok('it reconnects by itself if the Signal service drops the link', !!back);
|
||||||
|
push({ account: ME, envelope: { sourceNumber: '+15551234567', timestamp: T0 + 9000, dataMessage: { timestamp: T0 + 9000, message: 'Still there?' } } });
|
||||||
|
ok('and messages keep arriving', !!(await until(async () => (await getJ('/api/signal/conv/' + encodeURIComponent('+15551234567'))).msgs.some(m => m.text === 'Still there?'))));
|
||||||
|
|
||||||
|
// the service going away
|
||||||
|
srv.close(); for (const s of fake.sockets) s.destroy(); await wait(1500);
|
||||||
|
st = await getJ('/api/signal/status'); ok('if the Signal service is down, the app is told', st.connected === false, st);
|
||||||
|
r = await call('/api/health').catch(() => null); const alive = await fetch(base + '/api/config', { headers: { cookie: ck('uadmin1') } }); ok('and the hub keeps running', alive.status === 200);
|
||||||
|
child.kill(); fs.rmSync(dataDir, { recursive: true, force: true });
|
||||||
|
console.log(failed ? '\n' + failed + ' check(s) failed' : '\nAll Signal checks passed'); process.exit(failed ? 1 : 0);
|
||||||
|
})();
|
||||||
@@ -11,9 +11,9 @@ const USERS_DIR = path.join(DATA_DIR, 'users');
|
|||||||
fs.mkdirSync(DATA_DIR, { recursive: true });
|
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||||
|
|
||||||
// Tabs an admin can switch on or off for a person. Home and Settings are always available to everyone (Settings only shows their own).
|
// Tabs an admin can switch on or off for a person. Home and Settings are always available to everyone (Settings only shows their own).
|
||||||
const TABS = ['files', 'office', 'music', 'audio', 'podcast', 'verse', 'mail', 'calendar', 'tasks', 'search'];
|
const TABS = ['files', 'office', 'music', 'audio', 'podcast', 'verse', 'mail', 'signal', 'calendar', 'tasks', 'search'];
|
||||||
const ALWAYS = ['home', 'settings'];
|
const ALWAYS = ['home', 'settings'];
|
||||||
const TAB_LABELS = { files: 'Files', office: 'Office', music: 'Music', audio: 'Audiobooks', podcast: 'Podcast', verse: 'Podcast Admin', mail: 'Mail', calendar: 'Calendar', tasks: 'Tasks', search: 'Search' };
|
const TAB_LABELS = { files: 'Files', office: 'Office', music: 'Music', audio: 'Audiobooks', podcast: 'Podcast', verse: 'Podcast Admin', mail: 'Mail', signal: 'Signal', calendar: 'Calendar', tasks: 'Tasks', search: 'Search' };
|
||||||
|
|
||||||
let cache = null;
|
let cache = null;
|
||||||
const load = () => { if (cache) return cache; try { cache = JSON.parse(fs.readFileSync(USERS_FILE, 'utf8')); } catch { cache = { users: [] }; } if (!Array.isArray(cache.users)) cache.users = []; return cache; };
|
const load = () => { if (cache) return cache; try { cache = JSON.parse(fs.readFileSync(USERS_FILE, 'utf8')); } catch { cache = { users: [] }; } if (!Array.isArray(cache.users)) cache.users = []; return cache; };
|
||||||
|
|||||||
Reference in new issue
Block a user