Breaking wire-format change: dual-read (V1+V2) / single-write (V2) so existing user secrets, stored messages, and inter-tenant key keep working. Old clients and relays lose cross-version chat until both sides are upgraded. Relay - Switch clientEncrypt/masterEncrypt to AES-256-GCM with HKDF-derived per-ciphertext salts; keep CBC-V1 fallback on decrypt only. - On-bootstrap migration re-envelopes tenant secret_encrypted and the inter-tenant key to GCM-V2 (idempotent). - CORS: stop reflecting arbitrary Origin; allow own origin + optional RELAY_CORS_ORIGINS env allowlist; drop Access-Control-Allow-Credentials. - Generic error messages replace "no such user: <name>" enumeration. - Exception messages no longer echoed back; logged server-side. - Collab-lock max hold capped at 10min so a member can't grief-refresh forever. - Attachment filenames validated against hex.<ext> pattern. - Passwords min length 10; /api/account/password rate-limited. App / Electron client - Shared hyprcrdRelayEncrypt/Decrypt helpers (GCM-V2 write, V1 read). - Chat + collab-buckets plugins delegate to the shared helpers. - Poll + push + attachments migrated to the dual-format path. - /api/relay/settings GET returns has_key boolean; secret never echoed. - Setup endpoint now CSRF-checked. - SSRF: CURLOPT_PROTOCOLS restricted to HTTP(S) everywhere; bookmarks favicon fetch also blocks private/loopback/link-local. - Login / password-change rate limiter moved to DB-backed IP keying. - Passwords min length 10 throughout. - Zip import enforces per-file (25MB) and total (500MB) caps, extension allowlist, no-overwrite. - Verbose relay-push debug gated behind APP_DEBUG env. Version bumped to 2.0.0 (protocol-breaking). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
366 lines
16 KiB
JavaScript
366 lines
16 KiB
JavaScript
// ── Sync Orchestration ─────────────────────────────────────────────
|
|
//
|
|
// Owns the single source of truth for "time to sync". Every scheduled
|
|
// poller — core CalDAV + relay mail pull, the collab-buckets card sync,
|
|
// the chat plugin — hangs off the same interval via the `sync.tick` hook
|
|
// instead of running its own setInterval. That way:
|
|
//
|
|
// - The header Sync button triggers ALL syncs (emits `sync.tick`).
|
|
// - The Autosync toggle pauses ALL syncs (stops the single timer).
|
|
// - The configurable interval changes ALL cadences at once.
|
|
//
|
|
// This module also owns the autosync button UI, the sync-event toast
|
|
// feed, and the relay settings panel (which hosts the interval input).
|
|
//
|
|
// Hooks emitted:
|
|
// sync.tick — fire all pollers now
|
|
// sync.pulse — pulse the header dot (subset of tick)
|
|
// sync.interval.changed — restart timers with new cadence
|
|
// autosync.changed — user paused/resumed scheduled polling
|
|
//
|
|
// CalDAV-specific UI (calendar config rendering, CalDAV settings) lives
|
|
// in caldav.js. This file has no CalDAV icons/colors concerns.
|
|
|
|
import { $, api, getCsrf } from './util.js';
|
|
import { state } from './state.js';
|
|
|
|
const syncStatus = $('caldav-sync-status');
|
|
|
|
// ── Manual "Sync now" trigger ───────────────────────────────────
|
|
// Fired by the header Sync button, the CalDAV panel "sync now" button,
|
|
// and the `autoSync()` throttled helper. Always runs regardless of the
|
|
// autosync-paused setting — the user pressed a button, we honor that.
|
|
// Fans out to every plugin poller via `sync.tick`.
|
|
export async function triggerSync() {
|
|
pulseAutosyncDot();
|
|
const btn = $('cal-sync');
|
|
if (btn) btn.style.opacity = '0.5';
|
|
const headerIcon = $('header-sync-icon');
|
|
if (headerIcon) headerIcon.classList.add('card-spinner');
|
|
if (syncStatus) { syncStatus.textContent = 'syncing...'; syncStatus.style.color = 'var(--t-dim)'; }
|
|
|
|
const res = await fetch('api/caldav/sync', {
|
|
method: 'POST', headers: { 'X-CSRF-Token': getCsrf() },
|
|
}).then(r => r.json()).catch(() => ({ ok: false, error: 'network error' }));
|
|
|
|
if (btn) btn.style.opacity = '';
|
|
|
|
// Also poll relay for mail-style messages. Header spinner stays on
|
|
// until this completes too.
|
|
const relayRes = await fetch('api/relay/poll', {
|
|
method: 'POST', headers: { 'X-CSRF-Token': getCsrf() },
|
|
}).then(r => r.ok ? r.json() : null).catch(() => null);
|
|
drainSyncEvents(relayRes);
|
|
|
|
// Fan out to plugin pollers (collab-buckets, chat, …). They run
|
|
// concurrently with us — no await needed here.
|
|
window.hyprcrd?.hooks?.emit?.('sync.tick');
|
|
|
|
if (res.ok) {
|
|
const s = res.stats || {};
|
|
const rp = relayRes?.pulled || 0;
|
|
const total = { pushed: s.pushed || 0, pulled: (s.pulled || 0) + rp, deleted: s.deleted || 0 };
|
|
const allLog = res.log || [];
|
|
const hasErrors = allLog.some(l => l.status === 'error');
|
|
const msg = `pushed:${total.pushed} pulled:${total.pulled} del:${total.deleted}${rp ? ' relay:' + rp : ''}`;
|
|
if (syncStatus) { syncStatus.textContent = msg; syncStatus.style.color = hasErrors ? 'var(--t-danger)' : 'var(--t-accent)'; }
|
|
await window._reloadAll?.();
|
|
showLog(allLog);
|
|
showSyncNotification(total);
|
|
} else {
|
|
if (syncStatus) { syncStatus.textContent = res.error || 'sync failed'; syncStatus.style.color = 'var(--t-danger)'; }
|
|
}
|
|
if (headerIcon) headerIcon.classList.remove('card-spinner');
|
|
}
|
|
|
|
// CalDAV log display inside the Settings → CalDAV panel. Lives here
|
|
// (not in caldav.js) because triggerSync writes to it.
|
|
function showLog(log) {
|
|
const logEl = $('caldav-log');
|
|
if (!logEl || !log?.length) return;
|
|
logEl.classList.remove('hidden');
|
|
logEl.innerHTML = log.map(l =>
|
|
`<div class="${l.status === 'error' ? 'text-t-danger' : 'text-t-dim'}">${l.dir} ${l.action} ${l.status}${l.detail ? ' — ' + l.detail : ''}</div>`
|
|
).join('');
|
|
}
|
|
|
|
// ── Auto-sync throttle ──────────────────────────────────────────
|
|
// Used by the CalDAV module when the user saves a card with #termin or
|
|
// #geburtstag — we want to fire a sync immediately but not storm the
|
|
// server if they paste a big batch. 60s cooldown between auto-fires.
|
|
let lastAutoSync = 0;
|
|
export function autoSync() {
|
|
if (window._testRunning) return;
|
|
const now = Date.now();
|
|
if (now - lastAutoSync < 60000) return;
|
|
lastAutoSync = now;
|
|
triggerSync();
|
|
}
|
|
|
|
// ── Sync notifications ──────────────────────────────────────────
|
|
|
|
function showSyncNotification(stats) {
|
|
const s = stats || {};
|
|
const parts = [];
|
|
if (s.pulled) parts.push(`${s.pulled} received`);
|
|
if (s.pushed) parts.push(`${s.pushed} sent`);
|
|
if (s.deleted) parts.push(`${s.deleted} deleted`);
|
|
if (!parts.length) return;
|
|
|
|
const note = document.createElement('div');
|
|
note.style.cssText = 'position:fixed;bottom:60px;right:16px;z-index:200;background:var(--t-surface);border:1px solid var(--t-accent);border-radius:4px;padding:10px 14px;box-shadow:0 4px 20px rgba(0,0,0,0.4);max-width:300px;';
|
|
note.innerHTML = `<div style="font-family:var(--t-font-pixel);font-size:10px;color:var(--t-accent);display:flex;align-items:center;gap:6px;"><i class="ph ph-cloud-arrow-down"></i>CalDAV: ${parts.join(', ')}</div>`;
|
|
document.body.appendChild(note);
|
|
setTimeout(() => { note.style.transition = 'opacity 0.3s'; note.style.opacity = '0'; setTimeout(() => note.remove(), 350); }, 4000);
|
|
}
|
|
|
|
// One toast per incoming sync event: "alice synced: Card Title".
|
|
// Stacks downward so a burst of multiple doesn't overlay each other.
|
|
let syncEventToastOffset = 0;
|
|
function showSyncEventToast(ev) {
|
|
const body = String(ev?.body || '').trim();
|
|
if (!body) return;
|
|
const bottom = 110 + syncEventToastOffset * 48;
|
|
syncEventToastOffset++;
|
|
const note = document.createElement('div');
|
|
note.style.cssText = `position:fixed;bottom:${bottom}px;right:16px;z-index:200;background:var(--t-surface);border:1px solid var(--t-accent2);border-radius:4px;padding:9px 14px;box-shadow:0 4px 20px rgba(0,0,0,0.4);max-width:320px;`;
|
|
note.innerHTML = `<div style="font-family:var(--t-font-pixel);font-size:10px;color:var(--t-accent2);display:flex;align-items:center;gap:6px;"><i class="ph ph-check-circle"></i>${body.replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]))}</div>`;
|
|
document.body.appendChild(note);
|
|
setTimeout(() => {
|
|
note.style.transition = 'opacity 0.3s';
|
|
note.style.opacity = '0';
|
|
setTimeout(() => {
|
|
note.remove();
|
|
syncEventToastOffset = Math.max(0, syncEventToastOffset - 1);
|
|
}, 350);
|
|
}, 5000);
|
|
}
|
|
|
|
function drainSyncEvents(relayRes) {
|
|
if (relayRes?.sync_debug) {
|
|
console.debug('[sync-events]', relayRes.sync_debug);
|
|
}
|
|
const events = Array.isArray(relayRes?.sync_events) ? relayRes.sync_events : [];
|
|
for (const ev of events) showSyncEventToast(ev);
|
|
}
|
|
|
|
// ── Scheduled background pull ───────────────────────────────────
|
|
// Internal helper — the CalDAV + relay mail side of a tick. Plugin
|
|
// pollers run concurrently via the `sync.tick` hook.
|
|
async function backgroundPull() {
|
|
if (state.bgSyncRunning) return;
|
|
state.bgSyncRunning = true;
|
|
try {
|
|
const headers = { 'X-CSRF-Token': getCsrf() };
|
|
const [res, relayRes] = await Promise.all([
|
|
fetch('api/caldav/sync', { method: 'POST', headers }).then(r => r.ok ? r.json() : null).catch(() => null),
|
|
fetch('api/relay/poll', { method: 'POST', headers }).then(r => r.ok ? r.json() : null).catch(() => null),
|
|
]);
|
|
const s = res?.stats || {};
|
|
const rp = relayRes?.pulled || 0;
|
|
const total = { pushed: s.pushed || 0, pulled: (s.pulled || 0) + rp, deleted: s.deleted || 0 };
|
|
if (total.pulled > 0 || total.pushed > 0 || total.deleted > 0) {
|
|
await window._reloadAll?.();
|
|
showSyncNotification(total);
|
|
}
|
|
drainSyncEvents(relayRes);
|
|
} catch {} finally {
|
|
state.bgSyncRunning = false;
|
|
}
|
|
}
|
|
|
|
// Central tick: the one and only scheduled sync heartbeat. Fires the
|
|
// CalDAV+relay pull and emits `sync.tick` for every other poller.
|
|
function tick() {
|
|
pulseAutosyncDot();
|
|
backgroundPull();
|
|
window.hyprcrd?.hooks?.emit?.('sync.tick');
|
|
}
|
|
|
|
// ── Configurable sync interval ────────────────────────────────
|
|
// One setting drives every scheduled poller. Changed via the Relay
|
|
// settings panel; emits `sync.interval.changed` so the central timer
|
|
// restarts on the new cadence without a page reload.
|
|
const SYNC_INTERVAL_KEY = 'hyprcrd-sync-interval-sec';
|
|
const SYNC_INTERVAL_DEFAULT = 15;
|
|
const SYNC_INTERVAL_MIN = 5;
|
|
const SYNC_INTERVAL_MAX = 3600;
|
|
export function getSyncIntervalSec() {
|
|
const v = parseInt(localStorage.getItem(SYNC_INTERVAL_KEY));
|
|
return Number.isFinite(v) && v >= SYNC_INTERVAL_MIN ? v : SYNC_INTERVAL_DEFAULT;
|
|
}
|
|
export function setSyncIntervalSec(sec) {
|
|
const n = parseInt(sec);
|
|
const clamped = Math.max(SYNC_INTERVAL_MIN, Math.min(SYNC_INTERVAL_MAX, Number.isFinite(n) ? n : SYNC_INTERVAL_DEFAULT));
|
|
localStorage.setItem(SYNC_INTERVAL_KEY, String(clamped));
|
|
window.hyprcrd?.hooks?.emit?.('sync.interval.changed', clamped);
|
|
}
|
|
|
|
// ── Autosync on/off toggle ────────────────────────────────────
|
|
const AUTOSYNC_KEY = 'hyprcrd-autosync-enabled';
|
|
export function isAutoSyncEnabled() {
|
|
return localStorage.getItem(AUTOSYNC_KEY) !== '0';
|
|
}
|
|
export function setAutoSyncEnabled(on) {
|
|
localStorage.setItem(AUTOSYNC_KEY, on ? '1' : '0');
|
|
if (on) startBackgroundSync();
|
|
else stopBackgroundSync();
|
|
updateAutosyncButton();
|
|
window.hyprcrd?.hooks?.emit?.('autosync.changed', on);
|
|
}
|
|
|
|
// ── Central tick timer ────────────────────────────────────────
|
|
let tickInterval = null;
|
|
export function startBackgroundSync() {
|
|
if (tickInterval) return;
|
|
if (!isAutoSyncEnabled()) { updateAutosyncButton(); return; }
|
|
tick();
|
|
tickInterval = setInterval(tick, getSyncIntervalSec() * 1000);
|
|
updateAutosyncButton();
|
|
}
|
|
export function stopBackgroundSync() {
|
|
if (tickInterval) { clearInterval(tickInterval); tickInterval = null; }
|
|
updateAutosyncButton();
|
|
}
|
|
|
|
// Restart the central timer when the user changes the interval.
|
|
window.hyprcrd?.hooks?.on?.('sync.interval.changed', () => {
|
|
if (tickInterval) {
|
|
clearInterval(tickInterval);
|
|
tickInterval = setInterval(tick, getSyncIntervalSec() * 1000);
|
|
}
|
|
});
|
|
|
|
// ── Header autosync button ────────────────────────────────────
|
|
function updateAutosyncButton() {
|
|
const btn = $('header-autosync');
|
|
if (!btn) return;
|
|
const on = isAutoSyncEnabled();
|
|
btn.dataset.autosync = on ? 'on' : 'off';
|
|
btn.setAttribute('data-tip', on
|
|
? `auto-sync: on (every ${getSyncIntervalSec()}s) — click to pause`
|
|
: 'auto-sync: paused — click to resume');
|
|
}
|
|
|
|
// Pulse the dot once. Rip and re-add the class after a reflow so
|
|
// re-triggering mid-animation restarts cleanly.
|
|
export function pulseAutosyncDot() {
|
|
const dot = $('header-autosync-dot');
|
|
if (!dot) return;
|
|
dot.classList.remove('pulsing');
|
|
void dot.offsetWidth;
|
|
dot.classList.add('pulsing');
|
|
}
|
|
window.hyprcrd?.hooks?.on?.('sync.pulse', pulseAutosyncDot);
|
|
$('header-autosync')?.addEventListener('click', () => setAutoSyncEnabled(!isAutoSyncEnabled()));
|
|
window.hyprcrd?.hooks?.on?.('sync.interval.changed', updateAutosyncButton);
|
|
updateAutosyncButton();
|
|
|
|
// ── Sync-adjacent button wiring ───────────────────────────────
|
|
$('cal-sync')?.addEventListener('click', () => triggerSync());
|
|
$('header-sync')?.addEventListener('click', () => triggerSync());
|
|
$('caldav-sync-now')?.addEventListener('click', () => triggerSync());
|
|
|
|
// ── Relay settings panel ──────────────────────────────────────
|
|
// The relay panel lives in Settings → Relay. It holds the URL /
|
|
// username / shared-key inputs plus the shared sync-interval number
|
|
// input that drives every scheduled poller.
|
|
|
|
function setRelayStatus(text, kind = 'info') {
|
|
const el = $('relay-status');
|
|
if (!el) return;
|
|
if (!text) {
|
|
el.classList.add('hidden');
|
|
el.textContent = '';
|
|
el.style.borderColor = '';
|
|
el.style.color = '';
|
|
return;
|
|
}
|
|
el.classList.remove('hidden');
|
|
el.textContent = text;
|
|
if (kind === 'error') { el.style.borderColor = 'var(--t-danger)'; el.style.color = 'var(--t-danger)'; }
|
|
else if (kind === 'success') { el.style.borderColor = 'var(--t-accent)'; el.style.color = 'var(--t-accent)'; }
|
|
else { el.style.borderColor = 'var(--t-border)'; el.style.color = 'var(--t-muted)'; }
|
|
}
|
|
|
|
$('relay-save')?.addEventListener('click', async () => {
|
|
const body = {
|
|
url: $('relay-url')?.value || '',
|
|
username: $('relay-username')?.value || '',
|
|
key: $('relay-key')?.value || '',
|
|
};
|
|
setRelayStatus('saving...');
|
|
await api('api/relay/settings', { method: 'POST', body });
|
|
setRelayStatus('saved', 'success');
|
|
setTimeout(() => setRelayStatus(''), 1500);
|
|
});
|
|
|
|
$('relay-test')?.addEventListener('click', async () => {
|
|
setRelayStatus('testing...');
|
|
const res = await fetch('api/relay/poll', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': getCsrf() },
|
|
body: JSON.stringify({ strict: true }),
|
|
}).then(r => r.json()).catch(() => null);
|
|
if (res?.ok) {
|
|
setRelayStatus(`ok — ${res.pulled} pulled`, 'success');
|
|
if (res.pulled) await window._reloadAll?.();
|
|
drainSyncEvents(res);
|
|
} else {
|
|
const detail = res?.sent_username ? ` (sent username: ${res.sent_username})` : '';
|
|
setRelayStatus((res?.error || 'failed') + detail, 'error');
|
|
}
|
|
});
|
|
|
|
export async function loadRelaySettings() {
|
|
const s = await api('api/relay/settings');
|
|
if (!s) return;
|
|
const urlInput = $('relay-url');
|
|
const userInput = $('relay-username');
|
|
const keyInput = $('relay-key');
|
|
if (urlInput) urlInput.value = s.url || '';
|
|
if (userInput) userInput.value = s.username || '';
|
|
if (keyInput) {
|
|
// The server no longer echoes the stored secret. Leave the field
|
|
// blank; placeholder tells the user whether one is set. An empty
|
|
// save is treated as "field untouched" server-side.
|
|
keyInput.value = '';
|
|
keyInput.placeholder = s.has_key ? '•••••••• (saved — type to change)' : 'shared secret';
|
|
}
|
|
// Sync-interval slider lives in the Relay panel but drives every
|
|
// poller. Snap points span 5s..1h; live label updates on drag,
|
|
// persisted value commits on release.
|
|
const intervalInp = $('setting-sync-interval');
|
|
const intervalVal = $('setting-sync-interval-val');
|
|
if (intervalInp) {
|
|
const STEPS = [5, 10, 15, 20, 30, 45, 60, 90, 120, 180, 300, 600, 900, 1800, 3600];
|
|
const fmt = (s) => {
|
|
if (s < 60) return `${s}s`;
|
|
if (s === 3600) return '1h';
|
|
const m = Math.floor(s / 60), r = s % 60;
|
|
return r ? `${m}m ${r}s` : `${m}m`;
|
|
};
|
|
const idxFor = (sec) => {
|
|
let best = 0, bestDiff = Infinity;
|
|
for (let i = 0; i < STEPS.length; i++) {
|
|
const d = Math.abs(STEPS[i] - sec);
|
|
if (d < bestDiff) { bestDiff = d; best = i; }
|
|
}
|
|
return best;
|
|
};
|
|
intervalInp.max = String(STEPS.length - 1);
|
|
const sync = (idx) => {
|
|
const sec = STEPS[idx];
|
|
if (intervalVal) intervalVal.textContent = fmt(sec);
|
|
return sec;
|
|
};
|
|
intervalInp.value = String(idxFor(getSyncIntervalSec()));
|
|
sync(parseInt(intervalInp.value));
|
|
intervalInp.oninput = () => sync(parseInt(intervalInp.value));
|
|
intervalInp.onchange = () => {
|
|
const sec = sync(parseInt(intervalInp.value));
|
|
setSyncIntervalSec(sec);
|
|
};
|
|
}
|
|
}
|