aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/src')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/groups.py48
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/app.js119
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/downloads.js81
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/i18n.js7
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/sw.js60
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport.js39
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 });
}
/**