import { html, render, useState, useEffect, useCallback, useRef, createContext, useContext, } from './vendor/htm-preact.js'; import { t, getLocale, setLocale, LOCALES } from './i18n.js'; // ── Constants ──────────────────────────────────────────────────────────────── const HUB = ''; const AUTH_KEY = 'mb_auth'; const THEME_KEY = 'mb_theme'; const IDB_NAME = 'meshbay'; const IDB_VERSION = 1; const IDB_STORE = 'group_indexes'; // ── IndexedDB cache ───────────────────────────────────────────────────────── function openDB() { return new Promise((resolve, reject) => { const req = indexedDB.open(IDB_NAME, IDB_VERSION); req.onupgradeneeded = () => { const db = req.result; if (!db.objectStoreNames.contains(IDB_STORE)) { db.createObjectStore(IDB_STORE, { keyPath: 'groupId' }); } }; req.onsuccess = () => resolve(req.result); req.onerror = () => reject(req.error); }); } async function cacheGroupIndex(groupId, groupName, entries) { try { const db = await openDB(); const tx = db.transaction(IDB_STORE, 'readwrite'); tx.objectStore(IDB_STORE).put({ groupId, groupName, entries, cachedAt: Date.now(), }); await new Promise((r, rej) => { tx.oncomplete = r; tx.onerror = rej; }); db.close(); } catch { /* best-effort */ } } async function getCachedGroupIndex(groupId) { try { const db = await openDB(); const tx = db.transaction(IDB_STORE, 'readonly'); const req = tx.objectStore(IDB_STORE).get(groupId); const result = await new Promise((r, rej) => { req.onsuccess = () => r(req.result); req.onerror = rej; }); db.close(); return result || null; } catch { return null; } } async function getAllCachedIndexes() { try { const db = await openDB(); const tx = db.transaction(IDB_STORE, 'readonly'); const req = tx.objectStore(IDB_STORE).getAll(); const result = await new Promise((r, rej) => { req.onsuccess = () => r(req.result); req.onerror = rej; }); db.close(); return result || []; } catch { return []; } } // ── Auth persistence ───────────────────────────────────────────────────────── // The key that opens a node's keypair bundle, derived once at sign-in. There is // no global identity to keep: identity keys belong to a node and are fetched from // it (transport.js), so nothing of that kind lives here. let _bundleKey = null; // A one-time pairing code the user just typed, consumed by the next connection // attempt. Deliberately not persisted: it is single-use and short-lived. let _pendingJoinCode = null; function _openKeyDB() { return new Promise((resolve, reject) => { const req = indexedDB.open('meshbay_keys', 1); req.onupgradeneeded = () => req.result.createObjectStore('k'); req.onsuccess = () => resolve(req.result); req.onerror = () => reject(req.error); }); } async function _storeBundleKey(key) { try { const db = await _openKeyDB(); const tx = db.transaction('k', 'readwrite'); tx.objectStore('k').put(key, 'bk'); await new Promise(r => { tx.oncomplete = r; }); db.close(); } catch {} } async function _loadBundleKey() { try { const db = await _openKeyDB(); const tx = db.transaction('k', 'readonly'); const g = tx.objectStore('k').get('bk'); const val = await new Promise(r => { g.onsuccess = () => r(g.result); }); db.close(); return val || null; } catch { return null; } } async function _clearKeyDB() { try { const db = await _openKeyDB(); const tx = db.transaction('k', 'readwrite'); tx.objectStore('k').clear(); await new Promise(r => { tx.oncomplete = r; }); db.close(); } catch {} } /** * Rough passphrase strength, in bits, and what it is up against. * * This number carries more weight here than in most applications. The encrypted * keypair bundle is protected by PBKDF2-SHA512 (600k) and sits on every node * whose group you join, so the people who host your groups can attack it offline * (finding C4). PBKDF2 is memory-light, which is exactly what GPUs are good at. * * The estimate is deliberately conservative — character classes and length, with * a penalty for repetition and for the handful of patterns everyone tries. It is * a guide, not a guarantee, and it says so in the UI. */ function passwordBits(pw) { if (!pw) return 0; let pool = 0; if (/[a-z]/.test(pw)) pool += 26; if (/[A-Z]/.test(pw)) pool += 26; if (/[0-9]/.test(pw)) pool += 10; if (/[^A-Za-z0-9]/.test(pw)) pool += 32; let bits = pw.length * Math.log2(pool || 1); const unique = new Set(pw).size; if (unique < pw.length / 2) bits *= 0.6; // "aaaaaaaa", "abcabcabc" if (/^[0-9]+$/.test(pw)) bits *= 0.5; // dates, PINs if (/(password|motdepasse|azerty|qwerty|123456|meshbay)/i.test(pw)) bits *= 0.3; return Math.round(bits); } const PASSWORD_MIN_BITS = 60; // refuse below this const PASSWORD_MIN_LEN = 12; /** Public X25519 key from our own secret — never read back from the hub. */ async function _pkXFromSk(skPkcs8B64) { const raw = Uint8Array.from(atob(skPkcs8B64), c => c.charCodeAt(0)); const sk = await crypto.subtle.importKey('pkcs8', raw, { name: 'X25519' }, true, ['deriveBits']); const jwk = await crypto.subtle.exportKey('jwk', sk); const b64 = jwk.x.replace(/-/g, '+').replace(/_/g, '/'); const pad = b64.length % 4; return pad ? b64 + '='.repeat(4 - pad) : b64; } function loadAuth() { try { return JSON.parse(localStorage.getItem(AUTH_KEY)); } catch { return null; } } function saveAuth(auth) { if (auth) { localStorage.setItem(AUTH_KEY, JSON.stringify(auth)); } else { localStorage.removeItem(AUTH_KEY); _bundleKey = null; _clearKeyDB(); } } // ── Theme ──────────────────────────────────────────────────────────────────── function getInitialTheme() { const stored = localStorage.getItem(THEME_KEY); if (stored === 'dark' || stored === 'light' || stored === 'system') return stored; return 'system'; } function resolveTheme(pref) { if (pref === 'system') { return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; } return pref; } // ── Hub API ────────────────────────────────────────────────────────────────── async function hubFetch(path, { method = 'GET', body, token } = {}) { const headers = {}; if (body) headers['Content-Type'] = 'application/json'; if (token) headers['Authorization'] = `Bearer ${token}`; const opts = { method, headers }; if (body) opts.body = JSON.stringify(body); const r = await fetch(HUB + path, opts); if (!r.ok) { const err = await r.json().catch(() => ({ detail: r.statusText })); const detail = Array.isArray(err.detail) ? err.detail.map(e => e.msg || JSON.stringify(e)).join(', ') : (err.detail || r.statusText); throw new Error(String(detail)); } return r.json(); } // ── Router ─────────────────────────────────────────────────────────────────── function useRoute() { const [hash, setHash] = useState(window.location.hash.slice(1) || '/'); useEffect(() => { const onHash = () => setHash(window.location.hash.slice(1) || '/'); window.addEventListener('hashchange', onHash); return () => window.removeEventListener('hashchange', onHash); }, []); return hash; } function navigate(path) { window.location.hash = path; } // ── Context ────────────────────────────────────────────────────────────────── const AuthContext = createContext(null); function useAuth() { return useContext(AuthContext); } // ── User Menu ──────────────────────────────────────────────────────────────── function UserMenu({ user, theme, onThemeChange, onLogout }) { const [open, setOpen] = useState(false); const [langOpen, setLangOpen] = useState(false); const ref = useRef(null); useEffect(() => { if (!open) return; const close = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); }; document.addEventListener('click', close); return () => document.removeEventListener('click', close); }, [open]); const resolved = resolveTheme(theme); return html`
${open && html`
${user.username[0].toUpperCase()}
${user.username}
${user.role || 'user'}
${langOpen && LOCALES.map(l => html` `)}
`}
`; } // ── Nav ────────────────────────────────────────────────────────────────────── function Nav({ user, theme, onThemeChange, onLogout, onMenuToggle, unreadCount }) { return html` `; } // ── Sidebar ────────────────────────────────────────────────────────────────── function Sidebar({ groups, route, menuOpen, role }) { const isStaff = role === 'moderator' || role === 'admin'; return html` `; } // ── Login Page ─────────────────────────────────────────────────────────────── function LoginPage() { const auth = useAuth(); const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); const [error, setError] = useState(''); const [loading, setLoading] = useState(false); const onSubmit = async (e) => { e.preventDefault(); if (!username || !password) return; setError(''); setLoading(true); try { await auth.login(username, password); navigate('/'); } catch (err) { setError(err.message); } finally { setLoading(false); } }; return html`

${t('login.title')}

setUsername(e.target.value)} autocomplete="username" required /> setPassword(e.target.value)} autocomplete="current-password" required /> ${error && html`
${error}
`}
`; } // ── Register Page ──────────────────────────────────────────────────────────── function RegisterPage() { const [username, setUsername] = useState(''); const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [confirm, setConfirm] = useState(''); const [error, setError] = useState(''); const [success, setSuccess] = useState(false); const [loading, setLoading] = useState(false); const onSubmit = async (e) => { e.preventDefault(); if (password !== confirm) { setError(t('register.err_mismatch')); return; } if (password.length < PASSWORD_MIN_LEN) { setError(t('register.err_min_len', { n: PASSWORD_MIN_LEN })); return; } // The floor can only live here: with the password split (T1) the hub never // sees the password, so it cannot enforce anything about it. if (passwordBits(password) < PASSWORD_MIN_BITS) { setError(t('register.err_too_weak')); return; } setError(''); setLoading(true); try { if (window.MeshBayKeys) { await window.MeshBayKeys.registerUser(username, email, password); } else { await hubFetch('/v1/users/register', { method: 'POST', body: { username, email, password, pk_user_ed25519: '', pk_user_x25519: '' }, }); } setSuccess(true); } catch (err) { setError(err.message); } finally { setLoading(false); } }; if (success) { return html`

${t('register.success_title')}

${t('register.success_msg')}

${t('register.go_login')}
`; } return html`

${t('register.title')}

setUsername(e.target.value)} autocomplete="username" required /> setEmail(e.target.value)} autocomplete="email" required /> setPassword(e.target.value)} autocomplete="new-password" required minlength="8" /> ${password && html`

${t('register.strength', { bits: passwordBits(password) })}

`} setConfirm(e.target.value)} autocomplete="new-password" required /> ${error && html`
${error}
`}
`; } // ── Home Page ──────────────────────────────────────────────────────────────── function NotificationFeed({ notifications, onMarkRead }) { if (!notifications.length) return null; return html`

${t('notif.title')}

${notifications.map(n => html`
{ if (!n.read) onMarkRead(n.id); if (n.link) navigate(n.link); }}> ${n.kind} ${n.title} ${new Date(n.created_at).toLocaleDateString()}
`)}
`; } function HomePage({ groups, notifications, onMarkRead }) { if (groups.length === 0) { return html`

${t('home.welcome')}

<${NotificationFeed} notifications=${notifications} onMarkRead=${onMarkRead} />

${t('home.no_groups')} ${' '}${t('home.browse_prefix')}${t('home.browse_link')}${t('home.browse_suffix')}

`; } return html`

${t('home.my_groups')}

<${NotificationFeed} notifications=${notifications} onMarkRead=${onMarkRead} />
${groups.map(g => html`

${g.name}

${g.visibility} ${' '} ${g.join_policy} ${g.is_admin && html`${' '}admin`}
`)}
`; } // ── Explore Page ───────────────────────────────────────────────────────────── function ExplorePage({ token, myGroupIds }) { const [groups, setGroups] = useState([]); const [loading, setLoading] = useState(true); const [search, setSearch] = useState(''); const [joining, setJoining] = useState(null); const doSearch = useCallback((q) => { setLoading(true); const url = q ? `/v1/groups?q=${encodeURIComponent(q)}` : '/v1/groups'; hubFetch(url, { token }) .then(data => setGroups(data.groups || [])) .catch(() => {}) .finally(() => setLoading(false)); }, [token]); useEffect(() => { doSearch(''); }, [token]); const onSearch = useCallback((e) => { const q = e.target.value; setSearch(q); doSearch(q); }, [doSearch]); const joinGroup = useCallback(async (gid) => { setJoining(gid); try { await hubFetch(`/v1/groups/${gid}/join`, { method: 'POST', token }); navigate(`/group/${gid}`); setTimeout(() => window.location.reload(), 100); } catch (err) { if (err.message.includes('Already a member')) { navigate(`/group/${gid}`); } else { alert(err.message); } } finally { setJoining(null); } }, [token]); const isMember = (gid) => myGroupIds && myGroupIds.includes(gid); return html`

${t('explore.title')}

${t('explore.create_group')}
${loading ? html`

${t('explore.loading')}

` : groups.length === 0 ? html`

${t('explore.empty')}

` : html`
${groups.map(g => html`

${g.name}

${g.description && html`

${g.description}

`}
${g.join_policy} ${g.source && g.source !== 'local' && html` ${' '}${g.source} `} ${' '} ${isMember(g.id) ? html`${t('explore.member')}` : g.join_policy === 'open' && html` ` }
`)}
` }
`; } // ── Create Group Page ──────────────────────────────────────────────────────── function CreateGroupPage({ token, onCreated }) { const [name, setName] = useState(''); const [description, setDescription] = useState(''); const [visibility, setVisibility] = useState('private'); const [joinPolicy, setJoinPolicy] = useState('invite'); const [error, setError] = useState(''); const [loading, setLoading] = useState(false); const onSubmit = async (e) => { e.preventDefault(); if (!name.trim()) return; setLoading(true); setError(''); try { const body = { name: name.trim(), visibility, join_policy: joinPolicy }; if (description.trim()) body.description = description.trim().slice(0, 512); const data = await hubFetch('/v1/groups', { method: 'POST', token, body, }); if (onCreated) onCreated(); navigate('/'); } catch (err) { setError(err.message); } finally { setLoading(false); } }; return html`

${t('create_group.title')}

${t('create_group.hint')}

${error && html`
${error}
`}
setName(e.target.value)} required autofocus />