diff options
Diffstat (limited to 'packages/meshbay-hub/src')
6 files changed, 339 insertions, 15 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/groups.py b/packages/meshbay-hub/src/meshbay_hub/api/groups.py index 83feeb4..74c6c9e 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/groups.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/groups.py @@ -276,6 +276,54 @@ async def create_group( return {"group_id": group.id, "name": group.name} +@router.delete("/{group_id}/members/{username}") +async def remove_group_member( + group_id: str, + username: str, + request: Request, + current_user: User = Depends(require_user_scope), + db: AsyncSession = Depends(get_db), +): + """ + Remove someone from a group. The group's owner only. + + This is half of removing a member, and the half the hub can do: without a + membership row they cannot reach the node through signaling, and their next + token will not name this group. What it does not do is make the node forget + them — the node's roster decides who it serves, and only a paired operator + can change that (`member_revoke` over MNP, or `meshbay-node member revoke`). + The browser does both; a caller using this endpoint alone should know it did + one. + """ + group = await db.get(Group, group_id) + if not group: + raise HTTPException(status_code=404, detail="Group not found") + if group.admin_id != current_user.id: + raise HTTPException(status_code=403, + detail="Only the group owner can remove members") + + target = (await db.execute( + select(User).where(User.username == username))).scalar_one_or_none() + if not target: + raise HTTPException(status_code=404, detail="User not found") + if target.id == group.admin_id: + raise HTTPException( + status_code=409, + detail="The owner cannot be removed from their own group. Hand the " + "group over or delete it.") + + membership = await db.get(GroupMember, (group_id, target.id)) + if not membership: + raise HTTPException(status_code=404, detail="Not a member of this group") + + await db.delete(membership) + db.add(IPLog(user_id=current_user.id, event="group_leave", + ip_address=client_ip(request), + detail=f"{username} removed from {group.name}")) + await db.commit() + return {"status": "removed", "group_id": group_id, "username": username} + + class GroupUpdateRequest(BaseModel): description: str | None = None diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js index c8fb694..18ce83a 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js @@ -941,6 +941,10 @@ function canPreview(e) { } const CHUNK_SIZE = 1024 * 1024; +// Seconds of already-watched video kept in the SourceBuffer, and the queue depth +// past which we start making room before being forced to. +const BUFFER_BEHIND_S = 60; +const QUEUE_HIGH_WATER = 12; const PIPELINE_WINDOW = 8; /** @@ -950,7 +954,8 @@ const PIPELINE_WINDOW = 8; * it and hand the browser a blob", or false for "the person dismissed the * dialog", which is not an error and must not start a transfer. */ -async function _openDownloadTarget(filename, size = 0, pickerOpts = {}) { +async function _openDownloadTarget(filename, size = 0, pickerOpts = {}, + swSize = size) { try { const target = await downloads.openTarget(filename); if (target) return target; @@ -958,12 +963,16 @@ async function _openDownloadTarget(filename, size = 0, pickerOpts = {}) { console.warn('[MeshBay] download folder unusable:', err.message); } - // No granted folder. Saving automatically means not putting a dialog in the - // way, so anything that fits in memory goes to the browser's own download - // folder — which is what "automatic" meant to whoever chose the setting. - // Past that a blob would take the tab down with it, and one dialog is the - // lesser evil; Settings is where to stop it happening again. - if (downloads.getMode() === 'auto' && size < downloads.BLOB_LIMIT) return null; + // No granted folder. A service worker can still hand the browser a stream to + // write, which is how this works at all in Firefox: the alternative there is + // to collect gigabytes in a tab. It goes to the browser's own download + // folder, without a dialog, which is what "save automatically" meant. + if (downloads.getMode() === 'auto') { + const streamed = await downloads.openStreamedDownload(filename, swSize); + if (streamed) return streamed; + // Nothing to stream to: small enough for memory, and no dialog. + if (size < downloads.BLOB_LIMIT) return null; + } if (!window.showSaveFilePicker) return null; try { @@ -1331,10 +1340,13 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth, const totalBytes = files.reduce((n, f) => n + (f.entry.size || 0), 0); const suggested = (dir.split('/').pop() || 'files') + '.zip'; + // totalBytes decides how this is delivered, but it is not the archive's + // size — headers and the central directory come on top — so it is not + // announced as a Content-Length that the download would then miss. const target = await _openDownloadTarget(suggested, totalBytes, { types: [{ description: 'ZIP archive', accept: { 'application/zip': ['.zip'] } }], - }); + }, 0); if (target === false) return; if (!target && !confirm(t('group.zip_no_stream', { size: formatSize(totalBytes), name: suggested, @@ -1970,6 +1982,39 @@ function MembersPanel({ groupId, group, token, transportRef, gekRef, } }, [pairCode, transportRef, userId]); + const [removing, setRemoving] = useState(''); + + /** + * Take someone out of this group: both halves, in the order that fails safe. + * + * The node first, because that is the half that stops the group key being + * wrapped for them; if the hub removal then fails, they are a member on paper + * with no key. The other order would leave them able to reach a node that + * still serves them. + */ + const removeMember = useCallback(async (member) => { + const transport = transportRef && transportRef.current; + setError(''); + setRemoving(member.user_id); + try { + if (transport && transport.connected && operatorPaired) { + const sk = transport.sessionKeys && transport.sessionKeys.skEdB64; + const signFn = (sk && window.MeshBayKeys) + ? (transcript) => window.MeshBayKeys.signBytes(sk, transcript) + : null; + await transport.revokeMember(member.user_id, signFn); + } + await hubFetch(`/v1/groups/${groupId}/members/${member.username}`, { + method: 'DELETE', token, + }); + loadMembers(); + } catch (err) { + setError(err.message); + } finally { + setRemoving(''); + } + }, [groupId, token, transportRef, operatorPaired]); + const loadMembers = useCallback(() => { setLoading(true); hubFetch(`/v1/groups/${groupId}/members`, { token }) @@ -2067,6 +2112,7 @@ function MembersPanel({ groupId, group, token, transportRef, gekRef, <tr> <th>${t('admin.col_username')}</th> <th>${t('members.group_role')}</th> + <th></th> </tr> </thead> <tbody> @@ -2079,10 +2125,24 @@ function MembersPanel({ groupId, group, token, transportRef, gekRef, : html`<span class="badge">${t('members.member')}</span>` } </td> + <td class="admin-actions"> + ${isAdmin && m.user_id !== adminId && html` + <button class="admin-btn danger" disabled=${removing === m.user_id} + onClick=${() => { + if (!confirm(t('members.remove_confirm', { user: m.username }))) return; + removeMember(m); + }}> + ${removing === m.user_id ? '...' : t('members.remove')} + </button> + `} + </td> </tr> `)} </tbody> </table> + ${isAdmin && members.length > 1 && html` + <p class="settings-hint">${t('members.remove_hint')}</p> + `} ${isNodeAdmin && !operatorPaired && html` <form class="invite-form" onSubmit=${doPair}> <h4>${t('members.pair_title')}</h4> @@ -2379,6 +2439,28 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose }) { const endedRef = useRef(false); const durationRef = useRef(0); + /** + * Drop what has already been watched. + * + * A SourceBuffer is not a file: browsers cap it at a few hundred megabytes + * and refuse the append that goes past. Keeping a minute behind the playhead + * is enough for a small seek backwards and bounded for a three-hour film. + */ + const evictBehind = useCallback(() => { + const sb = sbRef.current; + const v = videoRef.current; + if (!sb || !v || sb.updating || !sb.buffered.length) return false; + const keepFrom = Math.max(0, v.currentTime - BUFFER_BEHIND_S); + const start = sb.buffered.start(0); + if (keepFrom - start < 10) return false; + try { + sb.remove(start, keepFrom); + return true; + } catch { + return false; + } + }, []); + const flushQueue = useCallback(() => { const sb = sbRef.current; if (!sb || appendingRef.current || sb.updating) return; @@ -2389,14 +2471,25 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose }) { return; } appendingRef.current = true; - const chunk = queueRef.current.shift(); + const chunk = queueRef.current[0]; try { sb.appendBuffer(chunk); + queueRef.current.shift(); } catch (e) { appendingRef.current = false; + if (e.name === 'QuotaExceededError') { + // The segment stays at the head of the queue and is tried again once + // there is room. Dropping it — which is what this used to do — leaves a + // hole in the middle of the film and no error anywhere. + if (!evictBehind()) { + console.warn('[MSE] buffer full and nothing to evict yet'); + } + return; + } + queueRef.current.shift(); console.error('[MSE] appendBuffer error:', e); } - }, []); + }, [evictBehind]); useEffect(() => { let cancelled = false; @@ -2445,6 +2538,12 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose }) { sb.mode = 'sequence'; sb.addEventListener('updateend', () => { appendingRef.current = false; + // One segment consumed, so the node may send one more. The credit + // is granted here, after the append, because this is the point at + // which the memory is genuinely free again. + const transport = transportRef.current; + if (transport && transport.connected) transport.grantStreamCredit(1); + if (queueRef.current.length > QUEUE_HIGH_WATER) evictBehind(); flushQueue(); }); setPhase('streaming'); diff --git a/packages/meshbay-hub/src/meshbay_hub/static/downloads.js b/packages/meshbay-hub/src/meshbay_hub/static/downloads.js index a71f289..7f48fed 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/downloads.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/downloads.js @@ -171,8 +171,83 @@ export async function openTarget(filename) { } /** - * Below this, a download with no granted folder is collected in memory and - * handed to the browser, which saves it without asking. Above it that would - * mean holding gigabytes in a tab, so it is worth one Save As dialog instead. + * Below this, a download with no granted folder and no service worker is + * collected in memory and handed to the browser. Above it that would mean + * holding gigabytes in a tab, so it is worth one Save As dialog instead. */ export const BLOB_LIMIT = 512 * 1024 * 1024; + +// ── Streaming to disk without the File System Access API ──────────────────── + +const SW_PATH = '/sw.js'; +let _swReady = null; + +export const STREAMS_VIA_SW = typeof window !== 'undefined' + && 'serviceWorker' in navigator + && typeof TransformStream === 'function' + && window.isSecureContext; + +async function serviceWorker() { + if (!STREAMS_VIA_SW) return null; + if (!_swReady) { + _swReady = navigator.serviceWorker.register(SW_PATH, { scope: '/' }) + .then(() => navigator.serviceWorker.ready) + .then(reg => reg.active || navigator.serviceWorker.controller) + .catch(err => { + console.warn('[MeshBay] service worker unavailable:', err.message); + return null; + }); + } + return _swReady; +} + +/** + * A sink the browser writes to disk, for Firefox and anything else without the + * File System Access API. + * + * The page keeps the writable half of a stream and gives the readable half to + * the service worker, which answers a made-up URL with it. Navigating a hidden + * iframe there turns it into an ordinary download: written as it arrives, with + * the browser's own progress, and nothing held in the tab. Backpressure is + * real — `writer.write()` waits when the browser is behind. + * + * Returns {writable, name} shaped like the File System Access one, or null if + * this browser cannot do it either. + */ +export async function openStreamedDownload(filename, size = 0) { + const worker = await serviceWorker(); + if (!worker) return null; + + const id = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`; + const { readable, writable } = new TransformStream(); + + try { + worker.postMessage({ type: 'mbdl', id, filename, size, readable }, [readable]); + } catch (err) { + // Transferable streams are what makes the backpressure work; without them + // this would be a memory buffer wearing a stream's clothes. + console.warn('[MeshBay] streams cannot be transferred here:', err.message); + return null; + } + + const frame = document.createElement('iframe'); + frame.hidden = true; + frame.src = `/_mbdl/${id}`; + document.body.appendChild(frame); + + const writer = writable.getWriter(); + return { + name: filename, + writable: { + write: (bytes) => writer.write(bytes), + close: async () => { + await writer.close(); + setTimeout(() => frame.remove(), 2000); + }, + abort: async (reason) => { + try { await writer.abort(reason); } catch { /* already gone */ } + frame.remove(); + }, + }, + }; +} diff --git a/packages/meshbay-hub/src/meshbay_hub/static/i18n.js b/packages/meshbay-hub/src/meshbay_hub/static/i18n.js index a2d13c5..71f6cce 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/i18n.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/i18n.js @@ -284,6 +284,13 @@ const en = { + 'pinned for your browser, so pair it below before inviting anyone.', 'members.invite_ask_operator': 'Only the operator of the node hosting this group can ' + 'invite, from a browser paired with it.', + 'members.remove': 'Remove', + 'members.remove_confirm': 'Remove {user} from this group? Their account, their ' + + 'other groups and the files they uploaded are not affected.', + 'members.remove_hint': 'Removing someone takes away their membership and stops ' + + 'the node serving them the group key. It does not delete their account, and ' + + 'it does not take back the key they already hold — rotate it on the node ' + + '(meshbay-node gek-init) if that matters.', 'members.pair_title': 'Pair this browser with your node', 'members.pair_hint': 'Your node only accepts operator actions — invites, file ' + 'deletion — from a browser it has been paired with. Run ' diff --git a/packages/meshbay-hub/src/meshbay_hub/static/sw.js b/packages/meshbay-hub/src/meshbay_hub/static/sw.js new file mode 100644 index 0000000..d0a8805 --- /dev/null +++ b/packages/meshbay-hub/src/meshbay_hub/static/sw.js @@ -0,0 +1,60 @@ +/** + * Service worker: the only way to stream a download to disk in Firefox. + * + * Chrome and Edge have the File System Access API — the page opens a file and + * writes to it. Firefox and Safari do not, and the alternative there was to + * collect the whole download in memory and hand the browser a blob, which is + * not an option for a file measured in gigabytes. + * + * So the page makes up a URL, tells this worker what stream answers it, and + * navigates a hidden iframe there. The worker replies with the stream and a + * Content-Disposition header, and the browser does what it does with any + * download: writes it to disk as it arrives, showing its own progress, with + * nothing buffered in the tab. + * + * It caches nothing and intercepts nothing else. Every request that is not one + * of these downloads falls through untouched. + */ + +const PREFIX = '/_mbdl/'; +const pending = new Map(); + +self.addEventListener('install', () => self.skipWaiting()); +self.addEventListener('activate', (event) => event.waitUntil(self.clients.claim())); + +self.addEventListener('message', (event) => { + const data = event.data || {}; + if (data.type !== 'mbdl' || !data.id || !data.readable) return; + pending.set(data.id, { + readable: data.readable, + filename: data.filename || 'download', + size: Number(data.size) || 0, + }); + // A tab that is closed before it navigates would leave a stream here for the + // life of the worker. + setTimeout(() => pending.delete(data.id), 60000); +}); + +self.addEventListener('fetch', (event) => { + const url = new URL(event.request.url); + if (url.origin !== self.location.origin || !url.pathname.startsWith(PREFIX)) { + return; // not ours — the network handles it + } + + const entry = pending.get(url.pathname.slice(PREFIX.length)); + if (!entry) return; + pending.delete(url.pathname.slice(PREFIX.length)); + + const headers = { + 'Content-Type': 'application/octet-stream', + // filename* so a name with accents or spaces survives the trip. + 'Content-Disposition': + `attachment; filename*=UTF-8''${encodeURIComponent(entry.filename)}`, + 'Cache-Control': 'no-store', + }; + // Only when it is known. A zip is assembled as it goes and announcing a + // length we then miss would truncate the file. + if (entry.size > 0) headers['Content-Length'] = String(entry.size); + + event.respondWith(new Response(entry.readable, { headers })); +}); diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js index 24c08b9..844a201 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js @@ -42,6 +42,10 @@ const UPLOAD_CHUNK_SIZE = 48 * 1024; const UPLOAD_WINDOW = 32; const UPLOAD_BUFFER_HIGH = 1024 * 1024; +// Segments of 256 KB: 24 in flight is 6 MB, enough to keep playback fed over a +// slow link and small enough that nothing accumulates. +const STREAM_CREDITS = 24; + function _aborted() { const err = new Error('Cancelled'); err.name = 'AbortError'; @@ -522,8 +526,39 @@ class MeshBayTransport { return msg; } - requestStream(fileId) { - this._send({ type: 'stream_req', v: '0.1', file_id: fileId }); + /** + * Stop this node serving the group key to someone. Operator only. + * + * Only the node can do this: its roster decides who it serves. Removing them + * on the hub is the other half, and neither implies the other. + */ + async revokeMember(userId, signFn) { + const msg = await this._sendAndWait({ + type: 'member_revoke', v: '0.1', user_id: userId, + }); + if (msg.type === 'error') throw new Error(msg.detail); + if (msg.type === 'admin_challenge') { + return this._authorizeAdminOp(msg, 'member_revoke', userId, signFn); + } + return msg; + } + + /** + * Ask for a video stream, and say how much we can take. + * + * `credits` bounds what is in flight. Without it the node pushes the whole + * film as fast as ffmpeg produces it and the browser holds all of it while + * MediaSource consumes a segment at a time — which is fine for a clip and + * fatal for anything worth streaming. + */ + requestStream(fileId, credits = STREAM_CREDITS) { + this._send({ type: 'stream_req', v: '0.1', file_id: fileId, credits }); + } + + /** Room for `n` more segments. */ + grantStreamCredit(n = 1) { + if (!this._connected) return; + this._send({ type: 'stream_more', v: '0.1', n }); } /** |