aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/admin-page.js92
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/app.js41
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/auth-page.js37
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/connection-pool.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/create-group-page.js10
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/crypto.js95
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/downloads.js7
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/file-utils.js19
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/files-app.js20
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/group-page.js15
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/group-settings.js9
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/i18n.js4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/keyderive.js20
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/lazy.js40
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/de.js34
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/en.js34
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/es.js34
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js34
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/it.js34
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js34
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js34
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js34
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js34
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js34
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/node-page.js16
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/portable-name.js51
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/report.js70
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/style.css34
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transfers.js14
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport-admin.js16
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport-chat.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport-devices.js4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport-media.js15
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport-rewrap.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport.js79
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/video-app.js35
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/video-player.js8
37 files changed, 864 insertions, 232 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/admin-page.js b/packages/meshbay-hub/src/meshbay_hub/static/admin-page.js
index 8cbfb5a..9c32475 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/admin-page.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/admin-page.js
@@ -28,6 +28,8 @@ export function AdminPage({ token, role }) {
const [logEvent, setLogEvent] = useState('');
const [logOffset, setLogOffset] = useState(0);
const [blocklist, setBlocklist] = useState([]);
+ const [reports, setReports] = useState([]);
+ const [reportsDraft, setReportsDraft] = useState(null);
const [nodes, setNodes] = useState([]);
const [detailUser, setDetailUser] = useState(null);
const [error, setError] = useState('');
@@ -48,6 +50,7 @@ export function AdminPage({ token, role }) {
setMailDraft({ ...data.mail });
setLoginDraft({ ...data.login });
setSessionDraft({ ...data.session });
+ setReportsDraft({ ...data.reports });
} catch (e) { setError(e.message); }
try {
setMailStatus(await hubFetch('/v1/admin/mail', { token }));
@@ -68,6 +71,7 @@ export function AdminPage({ token, role }) {
setMailDraft({ ...data.mail });
setLoginDraft({ ...data.login });
setSessionDraft({ ...data.session });
+ setReportsDraft({ ...data.reports });
if (patch.mail) {
try {
setMailStatus(await hubFetch('/v1/admin/mail', { token }));
@@ -102,6 +106,23 @@ export function AdminPage({ token, role }) {
} catch (e) { setError(e.message); }
}, [token]);
+ const loadReports = useCallback(async () => {
+ try {
+ const data = await hubFetch('/v1/admin/reports', { token });
+ setReports(data.reports);
+ } catch (e) { setError(e.message); }
+ }, [token]);
+
+ // Block or dismiss one reported hash. Blocking names it on the list every
+ // node hosting a public group applies; dismissing closes it for good.
+ const decideReport = useCallback(async (hash, verdict) => {
+ if (verdict === 'block' && !await ask(t('admin.report_block_confirm'))) return;
+ try {
+ await hubFetch(`/v1/admin/reports/${hash}/${verdict}`, { method: 'POST', token });
+ loadReports();
+ } catch (e) { setError(e.message); }
+ }, [token]);
+
const loadBlocklist = useCallback(async () => {
try {
const data = await hubFetch('/v1/admin/blocklist', { token });
@@ -124,6 +145,7 @@ export function AdminPage({ token, role }) {
.then(d => setNodes(d.nodes || [])).catch(e => setError(e.message));
}
else if (tab === 'logs') { setLogOffset(0); loadLogs(logEvent, 0); }
+ else if (tab === 'reports') loadReports();
else if (tab === 'blocklist') loadBlocklist();
}, [tab]);
@@ -205,13 +227,16 @@ const LOGIN_FIELDS = ['max_failures', 'lockout_minutes'];
const SESSION_FIELDS = ['browser_idle_hours', 'refresh_idle_hours', 'max_hours'];
+const REPORT_FIELDS = ['min_account_age_hours', 'daily_per_account', 'review_threshold',
+ 'auto_block'];
+
// Only what changed, and only what is a number: an empty field is someone
// mid-edit, not a request to set zero.
const changedNumbers = (fields, draft, stored) => Object.fromEntries(fields
.filter(k => draft[k] !== '' && draft[k] !== null && Number(draft[k]) !== stored[k])
.map(k => [k, Number(draft[k])]));
-const TABS = ['general', 'stats', 'users', 'groups', 'nodes', 'logs', 'blocklist'];
+const TABS = ['general', 'stats', 'users', 'groups', 'nodes', 'logs', 'reports', 'blocklist'];
const canEditSettings = role === 'admin';
return html`
@@ -304,6 +329,37 @@ const TABS = ['general', 'stats', 'users', 'groups', 'nodes', 'logs', 'blocklist
`}
</div>
+ ${settings.reports && html`
+ <div class="settings-section">
+ <h3 class="settings-heading">${t('admin.reports_heading')}</h3>
+ <p class="settings-hint">${t('admin.reports_hint')}</p>
+
+ ${reportsDraft && REPORT_FIELDS.map(key => html`
+ <div class="settings-row" key=${key}>
+ <span class="settings-label">${t('admin.reports_' + key)}</span>
+ <input type="number" class="settings-number"
+ min=${(settings.reports_bounds?.[key] || [0])[0]}
+ max=${(settings.reports_bounds?.[key] || [0, 0])[1]}
+ value=${reportsDraft[key]}
+ disabled=${!canEditSettings || settingsSaving}
+ onInput=${e => setReportsDraft(d => ({ ...d, [key]: e.target.value }))} />
+ </div>
+ `)}
+
+ ${canEditSettings && reportsDraft && html`
+ <div class="settings-row">
+ <button class="btn" disabled=${settingsSaving}
+ onClick=${() => saveSettings({
+ reports: changedNumbers(REPORT_FIELDS, reportsDraft, settings.reports),
+ })}>${t('admin.reports_save')}</button>
+ <button class="btn btn-secondary" disabled=${settingsSaving}
+ onClick=${() => setReportsDraft({ ...settings.reports_defaults })}
+ >${t('admin.mail_reset_defaults')}</button>
+ </div>
+ `}
+ </div>
+ `}
+
<div class="settings-section">
<h3 class="settings-heading">${t('admin.session_heading')}</h3>
<p class="settings-hint">${t('admin.session_hint')}</p>
@@ -546,6 +602,40 @@ const TABS = ['general', 'stats', 'users', 'groups', 'nodes', 'logs', 'blocklist
`}
`}
+ ${tab === 'reports' && html`
+ <table class="admin-table">
+ <thead><tr>
+ <th>${t('admin.col_hash')}</th>
+ <th>${t('admin.col_reporters')}</th>
+ <th>${t('admin.col_reason')}</th>
+ <th>${t('admin.col_groups')}</th>
+ <th>${t('admin.col_date')}</th>
+ <th>${t('admin.col_actions')}</th>
+ </tr></thead>
+ <tbody>
+ ${reports.length === 0 && html`<tr><td colspan="6" class="admin-empty">${t('admin.no_reports')}</td></tr>`}
+ ${reports.map(r => html`
+ <tr key=${r.hash}>
+ <td style="font-family:monospace;font-size:0.8em">${r.hash.slice(0, 16)}...</td>
+ <td>${r.reporters}</td>
+ <td>
+ ${Object.entries(r.reasons).map(([k, n]) => `${t('report.reason_' + k)} (${n})`).join(', ')}
+ ${r.details.map(d => html`<div class="settings-hint">${d}</div>`)}
+ </td>
+ <td>${r.groups.map(g => g.name || g.id.slice(0, 8)).join(', ')}</td>
+ <td>${new Date(r.opened_at).toLocaleDateString()}</td>
+ <td>
+ ${role === 'admin' && html`
+ <button class="admin-btn" onClick=${() => decideReport(r.hash, 'block')}>${t('admin.btn_block')}</button>
+ <button class="admin-btn" onClick=${() => decideReport(r.hash, 'dismiss')}>${t('admin.btn_dismiss')}</button>
+ `}
+ </td>
+ </tr>
+ `)}
+ </tbody>
+ </table>
+ `}
+
${tab === 'blocklist' && html`
<${BlocklistForm} onAdd=${addToBlocklist} />
<table class="admin-table">
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js
index 6b20a63..875b1aa 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js
@@ -3,7 +3,7 @@
import { clearPending, loadPending } from './invite-link.js';
import {
html, render, useState, useEffect, useLayoutEffect, useCallback, useRef,
- createContext, useContext,
+ createContext,
} from './vendor/htm-preact.js';
import { t, getLocale, setLocale, initLocale, LOCALES } from './i18n.js';
import { ZipStream, entriesUnder } from './zipstream.js';
@@ -72,7 +72,6 @@ function useRoute() {
// ── Context ──────────────────────────────────────────────────────────────────
const AuthContext = createContext(null);
-function useAuth() { return useContext(AuthContext); }
// The M of the wordmark is a picture; the rest is text. Resolved from this
// module's own URL so the hub's fingerprinted path and the application's
@@ -304,6 +303,7 @@ function TransferRow({ it }) {
</button>
`}
</div>
+ ${it.note && html`<div class="transfer-meta transfer-note">${it.note}</div>`}
${it.status === 'preparing'
? html`
${/* Not a progress bar at 0%: nothing is wrong and nothing is
@@ -645,10 +645,6 @@ function LazyCreateGroupPage(props) {
return html`<${_CreateGroupPage} ...${props} />`;
}
-// ── Settings Page ───────────────────────────────────────────────────────────
-
-const THEME_OPTIONS = ['light', 'dark', 'system'];
-
// ── Profile Page ────────────────────────────────────────────────────────────
//
// ── Lazy-loaded Admin page (admin/moderator only) ─────────────────────────
@@ -924,8 +920,31 @@ function App() {
.catch(() => {});
}, [user, notifDisabled]);
+ // Whether this account has a node linked, which is what shows the sidebar's
+ // Node section. Asked again whenever a node may just have been linked (the
+ // Create Group wizard), not only when the session changes -- the section
+ // stayed hidden until a reload after the first group -- and retried rather
+ // than left false by one failed request after a session blip.
+ const nodeKeyAskedForRef = useRef(null);
+ const refreshNodeKey = useCallback(() => {
+ nodeKeyAskedForRef.current = user;
+ if (!user || !platform.capabilities.nodeAdmin) return;
+ // A late answer, or a retry, must not land on a session that has changed.
+ const current = () => nodeKeyAskedForRef.current === user;
+ const ask = (attempt) => hubFetch(`/v1/users/${user.username}/pubkeys`, { token: user.token })
+ .then(data => { if (current()) setHasNodeKey(Boolean(data.pk_node_ed25519)); })
+ .catch(() => {
+ if (attempt < 3 && current()) setTimeout(() => ask(attempt + 1), 2000 * attempt);
+ });
+ ask(1);
+ }, [user]);
+
useEffect(() => {
- if (!user) { setGroups([]); setNotifications([]); setUnreadCount(0); setHasNodeKey(false); return; }
+ if (!user) {
+ nodeKeyAskedForRef.current = null;
+ setGroups([]); setNotifications([]); setUnreadCount(0); setHasNodeKey(false);
+ return;
+ }
hubFetch('/v1/groups/mine', { token: user.token })
.then(data => setGroups(data.groups || []))
.catch(() => setGroups([]));
@@ -935,11 +954,7 @@ function App() {
if (prefs.notifications_disabled === 'true') setNotifDisabled(true);
})
.catch(() => {});
- if (platform.capabilities.nodeAdmin) {
- hubFetch(`/v1/users/${user.username}/pubkeys`, { token: user.token })
- .then(data => setHasNodeKey(Boolean(data.pk_node_ed25519)))
- .catch(() => {});
- }
+ refreshNodeKey();
fetchNotifications();
}, [user]);
@@ -1176,10 +1191,12 @@ function App() {
} else if (route === '/create-group') {
page = html`<${LazyCreateGroupPage} token=${user.token} username=${user.username}
allowPublicGroups=${allowPublicGroups}
+ onNodeLinked=${refreshNodeKey}
onCreated=${() => {
hubFetch('/v1/groups/mine', { token: user.token })
.then(data => setGroups(data.groups || []))
.catch(() => {});
+ refreshNodeKey();
}} />`;
} else if (route === '/node' && platform.capabilities.nodeAdmin && hasNodeKey) {
page = html`<${LazyNodePage} groups=${groups} token=${user.token} username=${user.username} />`;
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/auth-page.js b/packages/meshbay-hub/src/meshbay_hub/static/auth-page.js
index 2164eae..09c4acf 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/auth-page.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/auth-page.js
@@ -344,6 +344,23 @@ function WelcomePitch() {
`;
}
+// The sign-in page's dark gradient backdrop and frosted card, without the
+// pitch beside it — so Register (and its verify/recovery/done steps) sits on
+// the same background and reads in the same dark theme as Login. A lone
+// `.welcome-side` is centred by `.welcome`'s `justify-content`.
+function AuthShell({ children }) {
+ return html`
+ <div class="page-center">
+ <div class="welcome-backdrop" aria-hidden="true"></div>
+ <div class="welcome">
+ <div class="welcome-side">
+ ${children}
+ </div>
+ </div>
+ </div>
+ `;
+}
+
export function RegisterPage() {
const [username, setUsername] = useState('');
const [email, setEmail] = useState('');
@@ -358,7 +375,9 @@ export function RegisterPage() {
const [recoveryMnemonic, setRecoveryMnemonic] = useState('');
const [recoverySaved, setRecoverySaved] = useState(false);
const [recoveryCopied, setRecoveryCopied] = useState(false);
- const [emailRecovery, setEmailRecovery] = useState(true);
+ // Off by default: mailing the recovery key is opt-in — the key is shown on
+ // screen to save, and sending a copy is the user's own choice to make.
+ const [emailRecovery, setEmailRecovery] = useState(false);
const captcha = useCaptcha();
const onSubmit = async (e) => {
@@ -457,7 +476,7 @@ export function RegisterPage() {
if (phase === 'done') {
return html`
- <div class="page-center">
+ <${AuthShell}>
<div class="card login-card">
<h2>${t('register.verified_title')}</h2>
<p style="text-align:center; margin-bottom:16px; color:var(--text-secondary)">
@@ -467,7 +486,7 @@ export function RegisterPage() {
<p style="text-align:center; margin-bottom:16px">${t('invite.after_register')}</p>`}
<a href="#/login" style="display:block; text-align:center">${t('register.go_login')}</a>
</div>
- </div>
+ </${AuthShell}>
`;
}
@@ -480,7 +499,7 @@ export function RegisterPage() {
} catch { /* clipboard blocked — the text is on screen to copy by hand */ }
};
return html`
- <div class="page-center">
+ <${AuthShell}>
<div class="card login-card">
<h2>${t('register.recovery_title')}</h2>
<p style="margin-bottom:12px; color:var(--text-secondary)">
@@ -509,13 +528,13 @@ export function RegisterPage() {
${t('register.recovery_continue')}
</button>
</div>
- </div>
+ </${AuthShell}>
`;
}
if (phase === 'verify') {
return html`
- <div class="page-center">
+ <${AuthShell}>
<div class="card login-card">
<h2>${t('register.success_title')}</h2>
<p style="text-align:center; margin-bottom:16px; color:var(--text-secondary)">
@@ -538,12 +557,12 @@ export function RegisterPage() {
${t('register.resend_sent')}</span>`}
</div>
</div>
- </div>
+ </${AuthShell}>
`;
}
return html`
- <div class="page-center">
+ <${AuthShell}>
<div class="card login-card">
<h2>${t('register.title')}</h2>
<form onSubmit=${onSubmit}>
@@ -585,7 +604,7 @@ export function RegisterPage() {
${t('register.has_account')} <a href="#/login">${t('register.login_link')}</a>
</div>
</div>
- </div>
+ </${AuthShell}>
`;
}
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/connection-pool.js b/packages/meshbay-hub/src/meshbay_hub/static/connection-pool.js
index aac8225..15d352a 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/connection-pool.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/connection-pool.js
@@ -78,7 +78,7 @@ async function connectToGroup(hubBase, groupId, token, bundleKey, username, user
const ack = await Promise.race([
transport.connect(
n.node_id, live, groupId, null, null, bundleKey,
- username, userId, null),
+ username, userId, null, undefined, undefined, n.pk_node),
new Promise((_, reject) => {
const giveUp = () => reject(new Error('Connection timeout'));
stallTimer = setTimeout(giveUp, SEARCH_STALL_MS);
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/create-group-page.js b/packages/meshbay-hub/src/meshbay_hub/static/create-group-page.js
index 85fe53c..7556010 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/create-group-page.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/create-group-page.js
@@ -124,7 +124,7 @@ function CreateGroupFormSimple({ token, onCreated, allowPublicGroups = true }) {
`;
}
-function CreateGroupWizard({ token, username, onCreated, allowPublicGroups = true }) {
+function CreateGroupWizard({ token, username, onCreated, onNodeLinked, allowPublicGroups = true }) {
const [step, setStep] = useState(0);
const [nodeStatus, setNodeStatus] = useState(null);
const [nodeStarting, setNodeStarting] = useState(false);
@@ -151,7 +151,10 @@ function CreateGroupWizard({ token, username, onCreated, allowPublicGroups = tru
await hubFetch('/v1/users/me/node_key', {
method: 'PUT', token, body: { pk_node_ed25519: pk },
});
- }, [token]);
+ // The sidebar's Node section depends on this link; it only re-read it at
+ // sign-in, so the first node set up here stayed out of it until a reload.
+ if (onNodeLinked) onNodeLinked();
+ }, [token, onNodeLinked]);
const detectNode = useCallback(async () => {
setNodeStatus(null);
@@ -177,6 +180,8 @@ function CreateGroupWizard({ token, username, onCreated, allowPublicGroups = tru
setError('');
try {
const result = await platform.node.start({ hubUrl: HUB, username, token });
+ // node.start links the key from the main process, out of this page's sight.
+ if (onNodeLinked) onNodeLinked();
setNodeStatus({ detected: true, ...result });
setNodeStarting(false);
setStep(1);
@@ -337,6 +342,7 @@ function CreateGroupWizard({ token, username, onCreated, allowPublicGroups = tru
&& !provisionAttempted.current
&& (nodeStatus.status === 'waiting_for_account'
|| nodeStatus.status === 'waiting_for_node_key'
+ || nodeStatus.status === 'waiting_for_hub'
|| nodeStatus.status === 'starting')) {
provisionAttempted.current = true;
startNode();
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/crypto.js b/packages/meshbay-hub/src/meshbay_hub/static/crypto.js
index a3680ce..0ca8ee5 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/crypto.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/crypto.js
@@ -1,20 +1,12 @@
/**
- * MeshBay Browser Crypto — AES-256-GCM private group decryption.
- * Uses WebCrypto SubtleCrypto API (available in all modern browsers).
- *
- * Handles groups with cipher="aes-256-gcm" (browser-accessible groups).
- * ChaCha20-Poly1305 groups (cipher="chacha20-poly1305") require the
- * native client (node) for decryption — not supported in browser.
+ * MeshBay Browser Crypto — AES-256-GCM, through WebCrypto's SubtleCrypto API.
+ * The one content cipher, for every client (meshbay_common/webcrypto.py).
*
* Usage:
* const gek = await importGEK(gekB64);
* const plaintext = await decryptChunkBin(gek, fileHashHex, chunkIndex, nonce, ct);
*/
-const CIPHER_INFO_PREFIX = new TextEncoder().encode('file:');
-const CIPHER_INFO_SUFFIX_AES = new TextEncoder().encode(':aes');
-
-
// ── Key derivation ────────────────────────────────────────────────────────────
/**
@@ -284,38 +276,7 @@ async function verifyChatSignature(deviceRaw, groupId, epoch, nonce, ct, sig) {
}
-// ── GEK generation + ECIES wrapping ──────────────────────────────────────────
-
-function generateGEK() {
- return crypto.getRandomValues(new Uint8Array(32));
-}
-
-async function wrapGEK(gek, pkXRaw) {
- const skEph = await crypto.subtle.generateKey({ name: 'X25519' }, true, ['deriveBits']);
- const pkEphRaw = new Uint8Array(await crypto.subtle.exportKey('raw', skEph.publicKey));
-
- const pkRecip = await crypto.subtle.importKey('raw', pkXRaw, { name: 'X25519' }, false, []);
- const sharedBits = await crypto.subtle.deriveBits(
- { name: 'X25519', public: pkRecip }, skEph.privateKey, 256);
-
- const sharedKey = await crypto.subtle.importKey(
- 'raw', sharedBits, 'HKDF', false, ['deriveKey']);
- const wrapKey = await crypto.subtle.deriveKey(
- { name: 'HKDF', hash: 'SHA-256', salt: pkEphRaw,
- info: new TextEncoder().encode('meshbay:gek_wrap:v1:aes') },
- sharedKey,
- { name: 'AES-GCM', length: 256 }, false, ['encrypt']);
-
- const nonce = crypto.getRandomValues(new Uint8Array(12));
- const ct = await crypto.subtle.encrypt(
- { name: 'AES-GCM', iv: nonce, additionalData: pkXRaw }, wrapKey, gek);
-
- return {
- pk_eph_b64: btoa(String.fromCharCode(...pkEphRaw)),
- nonce_b64: btoa(String.fromCharCode(...nonce)),
- wrapped_b64: btoa(String.fromCharCode(...new Uint8Array(ct))),
- };
-}
+// ── GEK unwrapping (ECIES) ─────────────────────────────────────────────────────
async function unwrapGEK(bundle, skXPkcs8, pkXRaw) {
const pkEphRaw = b64decode(bundle.pk_eph_b64);
@@ -342,18 +303,45 @@ async function unwrapGEK(bundle, skXPkcs8, pkXRaw) {
return new Uint8Array(plain);
}
-// ── Chunk encryption (for upload) ────────────────────────────────────────────
+function b64encode(bytes) {
+ return btoa(String.fromCharCode(...bytes));
+}
-async function encryptChunk(gek, fileHashHex, chunkIndex, plaintext) {
- const chunkKey = await deriveChunkKey(gek, fileHashHex, chunkIndex);
- const nonce = crypto.getRandomValues(new Uint8Array(12));
- const ct = await crypto.subtle.encrypt(
- { name: 'AES-GCM', iv: nonce }, chunkKey, plaintext);
- return { nonce, ct: new Uint8Array(ct) };
+// ── Admin operation subjects ────────────────────────────────────────────────
+// Mirrors meshbay_common/adminop.py. The subject is what the signature covers of
+// a request, so an operation whose effect is several values names them all.
+// Canonical JSON — sorted keys, no whitespace — so both sides build the same
+// bytes, and `null`, `""` and a value stay distinct.
+
+function adminSubject(fields) {
+ const sorted = {};
+ for (const k of Object.keys(fields).sort()) sorted[k] = fields[k];
+ return JSON.stringify(sorted);
}
-function b64encode(bytes) {
- return btoa(String.fromCharCode(...bytes));
+// A secret named without being written: null (unchanged) and '' (clear) as
+// themselves, anything else as its SHA-256.
+async function secretDigest(value) {
+ if (!value) return value;
+ const d = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(value));
+ return 'sha256:' + Array.from(new Uint8Array(d))
+ .map((b) => b.toString(16).padStart(2, '0')).join('');
+}
+
+function rootAddSubject(path, name, kind, writable, removable) {
+ return adminSubject({ path, name, kind, writable, removable });
+}
+
+function groupAttachSubject(name, sharedDir, writable) {
+ return adminSubject({ name, shared_dir: sharedDir, writable });
+}
+
+function inviteCreateSubject(userId, username) {
+ return adminSubject({ user_id: userId, username });
+}
+
+async function tmdbConfigSubject(token, language) {
+ return adminSubject({ token: await secretDigest(token), language });
}
// ── Admin operation transcript ───────────────────────────────────────────────
@@ -583,8 +571,9 @@ async function verifyNodeSignature(nodePkB64, sigB64, transcript) {
window.MeshBayCrypto = {
importGEK, deriveChunkKey, decryptChunkBin,
openGroup, sealGroup,
- generateGEK, wrapGEK, unwrapGEK, encryptChunk, b64encode, b64decode,
- adminTranscript, handshakeTranscript, handshakeProof, webrtcBinding,
+ unwrapGEK, b64encode, b64decode,
+ adminTranscript, adminSubject, rootAddSubject, groupAttachSubject,
+ inviteCreateSubject, tmdbConfigSubject, handshakeTranscript, handshakeProof, webrtcBinding,
challengeTranscript, joinTranscript, verifyNodeSignature, constantTimeEqual,
deviceRequestTranscript, deviceAddTranscript, deviceHelloTranscript,
deviceCodeHash,
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/downloads.js b/packages/meshbay-hub/src/meshbay_hub/static/downloads.js
index 9c23d3c..50bca46 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/downloads.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/downloads.js
@@ -193,13 +193,6 @@ export async function openTarget(filename) {
};
}
-/**
- * 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';
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/file-utils.js b/packages/meshbay-hub/src/meshbay_hub/static/file-utils.js
index bdfba6e..255c162 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/file-utils.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/file-utils.js
@@ -3,6 +3,7 @@ import * as platform from './platform.js';
import { t } from './i18n.js';
import { ask } from './ask.js';
import { ZipStream, entriesUnder } from './zipstream.js';
+import { portableName, portablePath } from './portable-name.js';
const FILE_ICONS = {
video: '\u{1F3AC}', audio: '\u{1F3B5}', image: '\u{1F5BC}',
@@ -428,6 +429,9 @@ async function pipelinedDownload(transport, gekKey, fileId, totalChunks, onChunk
*/
async function downloadEntry(transfers, transport, gek, entry) {
const totalChunks = Math.ceil(entry.size / CHUNK_SIZE);
+ // Saved under a name every platform can write, and the row says so when that
+ // is not the node's (portable-name.js, docs/MESHBAY_DESIGN.md §10).
+ const saveName = portableName(entry.name);
const openRef = { url: null };
let target = null;
// The in-memory fallback's accumulator, held out here so a pause does not
@@ -435,14 +439,15 @@ async function downloadEntry(transfers, transport, gek, entry) {
const memoryChunks = new Array(totalChunks);
transfers.start({
- kind: 'download', name: entry.name, total: entry.size, transport,
+ kind: 'download', name: saveName, total: entry.size, transport,
+ note: saveName !== entry.name ? t('transfers.renamed', { name: entry.name }) : '',
// The row exists from the click. Opening a target is what takes the time —
// the streamed path waits for the worker (twice), a Save As dialog waits
// for a person — and doing it before the row meant three clicks produced no
// panel at all and then several rows at once.
prepare: async () => {
- target = await _openTargetInTurn(entry.name, entry.size);
+ target = await _openTargetInTurn(saveName, entry.size);
// Dismissed: nothing was started, so nothing is left on screen.
if (target === false) return false;
// `pausable` travels with the target, because only the target knows. The
@@ -489,7 +494,7 @@ async function downloadEntry(transfers, transport, gek, entry) {
transport, gek, entry.id, totalChunks, onChunk, null, signal,
lease && lease.tr, from, memoryChunks);
const blob = new Blob(chunks);
- _saveBlob(blob, entry.name);
+ _saveBlob(blob, saveName);
openRef.url = URL.createObjectURL(blob);
}
},
@@ -523,7 +528,10 @@ async function downloadDirectory(transfers, transport, gek, entries, dir, { setE
return;
}
const totalBytes = files.reduce((n, f) => n + (f.entry.size || 0), 0);
- const suggested = (dir.split('/').pop() || 'files') + '.zip';
+ const suggested = portableName(dir.split('/').pop() || 'files') + '.zip';
+ // Every name in the archive is made writable everywhere, or a Windows
+ // extraction refuses it; the row says how many changed.
+ const renamed = files.filter(f => portablePath(f.name) !== f.name).length;
// Checked here rather than by disabling the button: Files zips a whole
// multi-directory selection in one click (`for (const d of selectedDirs)`),
@@ -548,6 +556,7 @@ async function downloadDirectory(transfers, transport, gek, entries, dir, { setE
transfers.start({
kind: 'download', name: suggested, total: totalBytes, transport,
+ note: renamed ? t('transfers.renamed_n', { n: renamed }) : '',
// Same order as downloadEntry: the row first, then the target, then the
// slot. A folder of forty files is exactly where the wait is longest.
@@ -584,7 +593,7 @@ async function downloadDirectory(transfers, transport, gek, entries, dir, { setE
});
for (const { entry, name } of files) {
- await zip.begin(name, entry.size,
+ await zip.begin(portablePath(name), entry.size,
new Date((entry.added_at || 0) * 1000));
// A zero-byte file has no chunk to ask for; the header and an empty
// descriptor are the whole entry.
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/files-app.js b/packages/meshbay-hub/src/meshbay_hub/static/files-app.js
index ac978e2..cda34b5 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/files-app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/files-app.js
@@ -2,7 +2,7 @@ import {
html, useState, useEffect, useRef, useCallback,
} from './vendor/htm-preact.js';
import { t } from './i18n.js';
-import { ask } from './ask.js';
+import { ask, tell } from './ask.js';
import { Icon } from './icon.js';
import { entriesUnder } from './zipstream.js';
import { transfers } from './transfers.js';
@@ -12,6 +12,7 @@ import {
} from './file-utils.js';
import { useStickyBand } from './sticky.js';
import { Menu, useMenu } from './menu.js';
+import { askReport } from './report.js';
// ── Files ────────────────────────────────────────────────────────────────────
//
@@ -140,7 +141,7 @@ function FilesPanel({
groupId, transportRef, gekRef, status,
entries, nodeDirs, nodeRoots, setEntries, setNodeDirs, setNodeRoots, applyIndex,
isNodeAdmin, operatorPaired, userId, setError, onPreview,
- showGroup, readOnly, getTransport, onRefreshIndex, showRefresh,
+ showGroup, readOnly, getTransport, onRefreshIndex, showRefresh, onReport,
}) {
const [selected, setSelected] = useState(() => new Set());
const [sortKey, setSortKey] = useState('name');
@@ -668,6 +669,20 @@ function FilesPanel({
? [[], [key.slice(4)]]
: [entries.filter(x => x.id === key), []];
const items = actionsFor(files, dirs, selected.has(key)).filter(a => !a.disabled);
+ // One file at a time, and from the menu only: a report is about a file
+ // somebody looked at, not a batch action for a toolbar.
+ const one = files.length === 1 && dirs.length === 0 ? files[0] : null;
+ if (onReport && one) {
+ items.push({ key: 'report', icon: 'shield', label: t('report.action'),
+ onSelect: async () => {
+ const answer = await askReport(one.name);
+ if (!answer) return;
+ try {
+ await onReport(one.id, answer.reason, answer.detail);
+ await tell(t('report.sent'));
+ } catch (err) { setError(err.message); }
+ } });
+ }
if (items.length) openAt(e, items);
};
@@ -893,7 +908,6 @@ function FilesPanel({
// ── File Preview (text, images) ─────────────────────────────────────────
-const TEXT_EXTS = /\.(txt|md|json|csv|log|xml|yaml|yml|ini|conf|py|js|html|css|sh|c|h|java|rs|go|rb|toml)$/i;
const IMAGE_EXTS = /\.(jpg|jpeg|png|gif|webp|svg|bmp|ico)$/i;
function FilePreview({ entry, transportRef, gekRef, onClose, onDownload }) {
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js
index 2ee6e05..b6f3d37 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js
@@ -16,8 +16,9 @@ import { FilePreview } from './files-app.js';
import { lazy } from './lazy.js';
// Fetched the first time a video is played / the Settings tab is opened.
+const playerFrame = (content) => html`<div class="video-overlay video-player-overlay">${content}</div>`;
const VideoPlayer = lazy(() => import('./video-player.js'), 'VideoPlayer',
- html`<div class="video-overlay"><p class="page-message"><span class="spinner"></span></p></div>`);
+ playerFrame(html`<p class="page-message"><span class="spinner"></span></p>`), playerFrame);
const GroupSettingsPanel = lazy(() => import('./group-settings.js'), 'GroupSettingsPanel');
import { reportIndexPush } from './index-dock.js';
import { clearPending, nodePkFromLink, pendingFor } from './invite-link.js';
@@ -433,7 +434,7 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
try {
ack = await transport.connect(
n.node_id, live, groupId, null, sessionKeys, session.bundleKey,
- username, userId, joinCode, session.recoveryKey, joinNodePk);
+ username, userId, joinCode, session.recoveryKey, joinNodePk, n.pk_node);
break;
} catch (e) {
lastErr = e;
@@ -833,8 +834,18 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
}), [perAppDirectories, chatDirectory, chatLinkPreview, tmdbConfig,
musicbrainzConfig]);
+ // Reporting a file is offered in a public group only: that is the only place
+ // the hub's moderation reaches (docs/MESHBAY_DESIGN.md §7.5), and the hub
+ // refuses a report from anyone who is not a member of the group named here.
+ const isPublic = !!(group && group.visibility === 'public');
+ const reportContent = useCallback((contentHash, reason, detail) => hubFetch(
+ '/v1/reports', { method: 'POST', token,
+ body: { content_hash: contentHash, group_id: groupId, reason, detail } }),
+ [groupId, token]);
+
const commonProps = {
groupId, transportRef, gekRef, status, username, deviceReady,
+ onReport: isPublic ? reportContent : null,
entries, availableEntries, nodeDirs, nodeRoots,
setEntries, setNodeDirs, setNodeRoots, applyIndex,
isNodeAdmin, operatorPaired, attachRoot, attachDir, userId, setError, onPreview,
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js b/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js
index a510e63..fba8a0e 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js
@@ -417,7 +417,8 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
// Whether the hub mails the invitation. Checked, the hub is handed the code
// to write it into the mail — so it is the inviter's choice, remembered per
// account (docs/MESHBAY_DESIGN.md §3.4). Unchecked, the hub never sees it.
- const [inviteByEmail, setInviteByEmail] = useState(true);
+ // Off by default: mailing the code is opt-in, not something to do unasked.
+ const [inviteByEmail, setInviteByEmail] = useState(false);
const [error, setError] = useState('');
// Node loopback state (Electron-only)
@@ -811,7 +812,7 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
useEffect(() => {
hubFetch('/v1/users/me/preferences', { token })
- .then(prefs => setInviteByEmail(prefs[INVITE_EMAIL_PREF] !== 'false'))
+ .then(prefs => setInviteByEmail(prefs[INVITE_EMAIL_PREF] === 'true'))
.catch(() => {});
}, [token]);
@@ -1059,7 +1060,7 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
${inviting ? '...' : t('members.invite_btn')}
</button>
</div>
- <label style="display:flex; gap:8px; align-items:flex-start; margin:6px 0 0;
+ <label style="display:flex; gap:8px; align-items:center; margin:6px 0 0;
font-size:0.88em; color:var(--text-secondary)">
<input type="checkbox" checked=${inviteByEmail}
onChange=${e => toggleInviteByEmail(e.target.checked)} />
@@ -1105,7 +1106,7 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
${linking ? '...' : t('members.link_btn')}
</button>
</div>
- <label style="display:flex; gap:8px; align-items:flex-start; margin:6px 0 0;
+ <label style="display:flex; gap:8px; align-items:center; margin:6px 0 0;
font-size:0.88em; color:var(--text-secondary)">
<input type="checkbox" checked=${inviteByEmail}
onChange=${e => toggleInviteByEmail(e.target.checked)} />
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/i18n.js b/packages/meshbay-hub/src/meshbay_hub/static/i18n.js
index 56d35f7..bc78266 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/i18n.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/i18n.js
@@ -131,10 +131,6 @@ export function setLocale(code) {
return true;
}
-export function addLocale(code, strings) {
- _strings[code] = strings;
-}
-
function _pluralRules(locale) {
if (!_plurals[locale]) _plurals[locale] = new Intl.PluralRules(locale);
return _plurals[locale];
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js b/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js
index 879f56f..33b1cf2 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js
@@ -256,15 +256,9 @@ async function deriveRecoveryKey(R, username) {
// ── Bundle encryption ─────────────────────────────────────────────────────────
/**
- * Encrypt the keypair bundle with the password-derived AES key.
- * Bundle format: JSON { skEd: base64(pkcs8), skX: base64(pkcs8) }
+ * Encrypt the keypair bundle with a bundle key derived at sign-in. Always
+ * writes v2. Bundle format: JSON { skEd: base64(pkcs8), skX: base64(pkcs8) }
*/
-async function encryptBundle(skEdRaw, skXRaw, password, username) {
- const aesKey = await deriveEncryptionKey(password, username);
- return encryptBundleWithKey(skEdRaw, skXRaw, aesKey);
-}
-
-/** Same, when the key was already derived at sign-in. Always writes v2. */
async function encryptBundleWithKey(skEdRaw, skXRaw, aesKey) {
const nonce = crypto.getRandomValues(new Uint8Array(12));
const data = new TextEncoder().encode(JSON.stringify({
@@ -289,16 +283,6 @@ function bundleVersion(bundleB64) {
} catch { return 1; }
}
-/**
- * Decrypt a keypair bundle. Throws if password is wrong.
- */
-async function decryptBundle(bundleB64, password, username) {
- const key = bundleVersion(bundleB64) === 2
- ? await deriveEncryptionKey(password, username)
- : await deriveEncryptionKeyV1(password, username);
- return decryptBundleWithKey(bundleB64, key);
-}
-
// ── Registration ──────────────────────────────────────────────────────────────
/**
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/lazy.js b/packages/meshbay-hub/src/meshbay_hub/static/lazy.js
index 30a564c..fda73c0 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/lazy.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/lazy.js
@@ -9,17 +9,45 @@
//
// A failed fetch (a network drop on a phone) is not remembered: the next time
// the component is shown it asks again, rather than failing for the session.
+//
+// A failed fetch is also *said*, never left as the placeholder's spinner. The
+// commonest failure is not the network: the hub serves the module graph under
+// `/a/<hash>/` for the current hash only, so a tab opened before a hub deploy
+// asks for every module it has not loaded yet under a prefix that now answers
+// 404. Such a tab kept Files and Chat (already loaded) and spun for ever on
+// Search, Videos, Music, Photos and the player — with nothing in the console,
+// because the rejection was swallowed here. Asking again cannot help that tab;
+// reloading the page is the one thing that does, so that is what is offered.
+// Not done automatically: a reload that fails the same way would loop, and it
+// would throw away whatever the reader had in progress.
import { html, useState, useEffect } from './vendor/htm-preact.js';
+import { t } from './i18n.js';
const SPINNER = html`<p class="page-message"><span class="spinner"></span></p>`;
-function lazy(load, name, placeholder = SPINNER) {
+function LoadFailed({ onClose }) {
+ return html`
+ <div class="page-message lazy-failed" role="alert">
+ <p>${t('lazy.load_failed')}</p>
+ <button class="admin-btn" onClick=${() => location.reload()}>${t('lazy.reload')}</button>
+ ${onClose && html`
+ ${' '}<button class="admin-btn" onClick=${onClose}>${t('admin.btn_close')}</button>
+ `}
+ </div>
+ `;
+}
+
+// `frame` wraps the failure notice, so a component that is an overlay (the
+// video player) can put it where the overlay would have been rather than
+// somewhere at the bottom of the page, under nothing anyone is looking at.
+function lazy(load, name, placeholder = SPINNER, frame = (content) => content) {
let Loaded = null;
let pending = null;
function Lazy(props) {
const [, setReady] = useState(Loaded !== null);
+ const [failed, setFailed] = useState(false);
useEffect(() => {
if (Loaded) return undefined;
let alive = true;
@@ -28,11 +56,15 @@ function lazy(load, name, placeholder = SPINNER) {
(m) => { Loaded = m[name]; },
(e) => { pending = null; throw e; });
}
- pending.then(() => { if (alive) setReady(true); }, () => {});
+ pending.then(() => { if (alive) setReady(true); }, (e) => {
+ console.error(`[MeshBay] could not load ${name}:`, e);
+ if (alive) setFailed(true);
+ });
return () => { alive = false; };
}, []);
- if (!Loaded) return placeholder;
- return html`<${Loaded} ...${props} />`;
+ if (Loaded) return html`<${Loaded} ...${props} />`;
+ if (failed) return frame(html`<${LoadFailed} onClose=${props.onClose} />`);
+ return placeholder;
}
Lazy.displayName = `Lazy(${name})`;
return Lazy;
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
index 7bd5169..a83f44b 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
@@ -92,7 +92,7 @@ export default {
'login.invalid': "Benutzername oder Passphrase falsch.",
'login.locked': "Zu viele falsche Passphrasen für dieses Konto. Versuchen Sie es in {minutes} Min. erneut oder setzen Sie Ihre Passphrase zurück.",
// Sign-in page: what MeshBay is (browser only)
- 'welcome.title': 'Das Internet, wie es gedacht war.',
+ 'welcome.title': 'Das Netzwerk, das nur von Ihnen abhängt.',
'welcome.lead': 'Ihre Fotos, Musik, Videos und Unterhaltungen bleiben zu Hause, auf Ihrem eigenen Computer. Mit MeshBay genießen Sie sie von überall und teilen sie mit den Menschen Ihrer Wahl.',
'welcome.app_chat': 'Chat',
'welcome.app_photos': 'Fotos',
@@ -888,6 +888,8 @@ export default {
// Group misc
'group.retry': 'Erneut versuchen',
+ 'lazy.load_failed': 'Dieser Teil der Oberfläche konnte nicht geladen werden. Der Hub wurde möglicherweise aktualisiert, seit diese Seite geöffnet wurde.',
+ 'lazy.reload': 'Seite neu laden',
// Sidebar
'sidebar.node': 'Node',
@@ -910,7 +912,7 @@ export default {
'node.service_restarting': 'Wird neu gestartet…',
'node.service_mode_hint': 'Läuft als Hintergrunddienst — startet beim Booten, vor der Anmeldung.',
'node.startup_mode_label': 'Automatisch starten:',
- 'node.startup_mode_off': 'Aus (manuell starten)',
+ 'node.startup_mode_off': 'Nur solange MeshBay geöffnet ist',
'node.startup_mode_signin': 'Bei der Anmeldung',
'node.startup_mode_service': 'Als Hintergrunddienst (startet beim Booten)',
'node.startup_mode_updating': 'Modus wird gewechselt — achten Sie auf eine Administrator-Eingabeaufforderung…',
@@ -1192,4 +1194,32 @@ export default {
// In-page confirm/alert (ask.js)
'dialog.ok': 'OK',
'dialog.cancel': 'Abbrechen',
+
+ // Content reports (report.js, admin-page.js)
+ 'report.action': "Melden",
+ 'report.title': "Diese Datei melden",
+ 'report.hint': "Ein Administrator dieses Hubs wird sie prüfen.",
+ 'report.reason_illegal': "Illegaler Inhalt",
+ 'report.reason_spam': "Spam",
+ 'report.reason_copyright': "Urheberrechtsverletzung",
+ 'report.reason_other': "Sonstiges",
+ 'report.detail_placeholder': "Details (optional)",
+ 'report.send': "Meldung senden",
+ 'report.sent': "Danke. Ihre Meldung wurde erfasst.",
+ 'admin.tab_reports': "Meldungen",
+ 'admin.no_reports': "Nichts wartet auf eine Entscheidung",
+ 'admin.col_reporters': "Meldende",
+ 'admin.btn_dismiss': "Verwerfen",
+ 'admin.report_block_confirm': "Diese Datei sperren? Jeder Knoten mit einer öffentlichen Gruppe stellt sie dort nicht mehr bereit.",
+ 'admin.reports_heading': "Inhaltsmeldungen",
+ 'admin.reports_hint': "Wer eine Datei in einer öffentlichen Gruppe melden darf, wie oft, und was geschieht, wenn genug Mitglieder es getan haben.",
+ 'admin.reports_min_account_age_hours': "Mindestalter des Kontos (Stunden)",
+ 'admin.reports_daily_per_account': "Meldungen pro Konto und Tag",
+ 'admin.reports_review_threshold': "Mitglieder bis zur Prüfung",
+ 'admin.reports_auto_block': "Ohne Prüfung sperren (1 = ja, 0 = nein)",
+ 'admin.reports_save': "Meldeeinstellungen speichern",
+
+ // Names made writable everywhere when saved (portable-name.js)
+ 'transfers.renamed': "Umbenannt: „{name}“ ist nicht auf jedem System ein gültiger Name",
+ 'transfers.renamed_n': "Namen geändert, damit sie auf jedem System gültig sind: {n}",
};
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
index 0c9cb19..46c5094 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
@@ -95,7 +95,7 @@ export default {
'login.invalid': "Wrong username or passphrase.",
'login.locked': "Too many wrong passphrases for this account. Try again in {minutes} min, or reset your passphrase.",
// Sign-in page: what MeshBay is (browser only)
- 'welcome.title': 'The Internet as it was meant to be.',
+ 'welcome.title': 'The network that relies only on you.',
'welcome.lead': 'Your photos, music, videos and conversations stay at home, on your own computer. MeshBay lets you enjoy them from anywhere, and share them with the people you choose.',
'welcome.app_chat': 'Chat',
'welcome.app_photos': 'Photos',
@@ -984,6 +984,8 @@ export default {
'group.mute': 'Mute notifications',
'group.unmute': 'Unmute notifications',
'group.retry': 'Retry',
+ 'lazy.load_failed': 'This part of the interface could not be loaded. The hub may have been updated since this page was opened.',
+ 'lazy.reload': 'Reload the page',
'profile.title': 'Profile',
'usermenu.profile': 'Profile',
@@ -1006,7 +1008,7 @@ export default {
'node.service_restarting': 'Restarting…',
'node.service_mode_hint': 'Running as a background service — it starts at boot, before sign-in.',
'node.startup_mode_label': 'Start automatically:',
- 'node.startup_mode_off': 'Off (start manually)',
+ 'node.startup_mode_off': 'Only while MeshBay is open',
'node.startup_mode_signin': 'At sign-in',
'node.startup_mode_service': 'As a background service (starts at boot)',
'node.startup_mode_updating': 'Switching mode — check for an administrator prompt…',
@@ -1173,4 +1175,32 @@ export default {
// In-page confirm/alert (ask.js)
'dialog.ok': 'OK',
'dialog.cancel': 'Cancel',
+
+ // Content reports (report.js, admin-page.js)
+ 'report.action': "Report",
+ 'report.title': "Report this file",
+ 'report.hint': "An administrator of this hub will review it.",
+ 'report.reason_illegal': "Illegal content",
+ 'report.reason_spam': "Spam",
+ 'report.reason_copyright': "Copyright infringement",
+ 'report.reason_other': "Other",
+ 'report.detail_placeholder': "Details (optional)",
+ 'report.send': "Send report",
+ 'report.sent': "Thank you. Your report has been recorded.",
+ 'admin.tab_reports': "Reports",
+ 'admin.no_reports': "Nothing is waiting for a decision",
+ 'admin.col_reporters': "Reporters",
+ 'admin.btn_dismiss': "Dismiss",
+ 'admin.report_block_confirm': "Block this file? Every node hosting a public group will stop serving it there.",
+ 'admin.reports_heading': "Content reports",
+ 'admin.reports_hint': "Who may report a file in a public group, how often, and what happens once enough members have.",
+ 'admin.reports_min_account_age_hours': "Minimum account age (hours)",
+ 'admin.reports_daily_per_account': "Reports per account per day",
+ 'admin.reports_review_threshold': "Members before review",
+ 'admin.reports_auto_block': "Block without review (1 = yes, 0 = no)",
+ 'admin.reports_save': "Save report settings",
+
+ // Names made writable everywhere when saved (portable-name.js)
+ 'transfers.renamed': "Renamed: “{name}” is not a valid name on every system",
+ 'transfers.renamed_n': "Names changed to be valid on every system: {n}",
};
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
index 1399a83..0d0e865 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
@@ -91,7 +91,7 @@ export default {
'login.invalid': "Nombre de usuario o frase de contraseña incorrectos.",
'login.locked': "Demasiadas frases de contraseña incorrectas para esta cuenta. Vuelva a intentarlo en {minutes} min o restablezca su frase de contraseña.",
// Sign-in page: what MeshBay is (browser only)
- 'welcome.title': 'Internet como debió ser.',
+ 'welcome.title': 'La red que solo depende de ti.',
'welcome.lead': 'Sus fotos, su música, sus vídeos y sus conversaciones se quedan en casa, en su propio ordenador. MeshBay le permite disfrutarlos desde cualquier lugar y compartirlos con las personas que usted elija.',
'welcome.app_chat': 'Chat',
'welcome.app_photos': 'Fotos',
@@ -882,6 +882,8 @@ export default {
// Group
'group.retry': 'Reintentar',
+ 'lazy.load_failed': 'No se pudo cargar esta parte de la interfaz. Es posible que el hub se haya actualizado desde que se abrió esta página.',
+ 'lazy.reload': 'Recargar la página',
// Sidebar
'sidebar.node': 'Node',
@@ -904,7 +906,7 @@ export default {
'node.service_restarting': 'Reiniciando…',
'node.service_mode_hint': 'Se ejecuta como servicio en segundo plano — se inicia al arrancar, antes de iniciar sesión.',
'node.startup_mode_label': 'Iniciar automáticamente:',
- 'node.startup_mode_off': 'Desactivado (iniciar manualmente)',
+ 'node.startup_mode_off': 'Solo mientras MeshBay está abierto',
'node.startup_mode_signin': 'Al iniciar sesión',
'node.startup_mode_service': 'Como servicio en segundo plano (se inicia al arrancar)',
'node.startup_mode_updating': 'Cambiando de modo — compruebe si aparece un aviso de administrador…',
@@ -1186,4 +1188,32 @@ export default {
// In-page confirm/alert (ask.js)
'dialog.ok': 'Aceptar',
'dialog.cancel': 'Cancelar',
+
+ // Content reports (report.js, admin-page.js)
+ 'report.action': "Denunciar",
+ 'report.title': "Denunciar este archivo",
+ 'report.hint': "Un administrador de este hub lo revisará.",
+ 'report.reason_illegal': "Contenido ilegal",
+ 'report.reason_spam': "Spam",
+ 'report.reason_copyright': "Infracción de derechos de autor",
+ 'report.reason_other': "Otro",
+ 'report.detail_placeholder': "Detalles (opcional)",
+ 'report.send': "Enviar denuncia",
+ 'report.sent': "Gracias. Su denuncia ha quedado registrada.",
+ 'admin.tab_reports': "Denuncias",
+ 'admin.no_reports': "Nada espera una decisión",
+ 'admin.col_reporters': "Denunciantes",
+ 'admin.btn_dismiss': "Descartar",
+ 'admin.report_block_confirm': "¿Bloquear este archivo? Todos los nodos que alojan un grupo público dejarán de servirlo allí.",
+ 'admin.reports_heading': "Denuncias de contenido",
+ 'admin.reports_hint': "Quién puede denunciar un archivo en un grupo público, con qué frecuencia y qué ocurre cuando suficientes miembros lo han hecho.",
+ 'admin.reports_min_account_age_hours': "Antigüedad mínima de la cuenta (horas)",
+ 'admin.reports_daily_per_account': "Denuncias por cuenta y día",
+ 'admin.reports_review_threshold': "Miembros antes de la revisión",
+ 'admin.reports_auto_block': "Bloquear sin revisión (1 = sí, 0 = no)",
+ 'admin.reports_save': "Guardar ajustes de denuncias",
+
+ // Names made writable everywhere when saved (portable-name.js)
+ 'transfers.renamed': "Renombrado: «{name}» no es un nombre válido en todos los sistemas",
+ 'transfers.renamed_n': "Nombres cambiados para ser válidos en todos los sistemas: {n}",
};
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
index ae278d9..3c86cd7 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
@@ -91,7 +91,7 @@ export default {
'login.invalid': "Nom d'utilisateur ou phrase secrète incorrect.",
'login.locked': "Trop de phrases secrètes erronées pour ce compte. Réessayez dans {minutes} min, ou réinitialisez votre phrase secrète.",
// Sign-in page: what MeshBay is (browser only)
- 'welcome.title': 'L’Internet tel qu’il aurait dû être.',
+ 'welcome.title': 'Le réseau qui ne dépend que de vous.',
'welcome.lead': 'Vos photos, votre musique, vos vidéos et vos conversations restent chez vous, sur votre propre ordinateur. MeshBay vous permet d’en profiter de partout, et de les partager avec les personnes de votre choix.',
'welcome.app_chat': 'Messagerie',
'welcome.app_photos': 'Photos',
@@ -885,6 +885,8 @@ export default {
// Group
'group.retry': 'Réessayer',
+ 'lazy.load_failed': 'Cette partie de l\'interface n\'a pas pu être chargée. Le hub a peut-être été mis à jour depuis l\'ouverture de cette page.',
+ 'lazy.reload': 'Recharger la page',
// Sidebar
'sidebar.node': 'Node',
@@ -907,7 +909,7 @@ export default {
'node.service_restarting': 'Redémarrage…',
'node.service_mode_hint': 'Fonctionne comme service en arrière-plan — démarre au boot, avant l\'ouverture de session.',
'node.startup_mode_label': 'Démarrer automatiquement :',
- 'node.startup_mode_off': 'Désactivé (démarrage manuel)',
+ 'node.startup_mode_off': 'Uniquement quand MeshBay est ouvert',
'node.startup_mode_signin': 'À l\'ouverture de session',
'node.startup_mode_service': 'Comme service en arrière-plan (démarre au boot)',
'node.startup_mode_updating': 'Changement de mode — vérifiez une invite d\'administrateur…',
@@ -1201,4 +1203,32 @@ export default {
// In-page confirm/alert (ask.js)
'dialog.ok': 'OK',
'dialog.cancel': 'Annuler',
+
+ // Content reports (report.js, admin-page.js)
+ 'report.action': "Signaler",
+ 'report.title': "Signaler ce fichier",
+ 'report.hint': "Un administrateur de ce hub l’examinera.",
+ 'report.reason_illegal': "Contenu illégal",
+ 'report.reason_spam': "Spam",
+ 'report.reason_copyright': "Atteinte au droit d’auteur",
+ 'report.reason_other': "Autre",
+ 'report.detail_placeholder': "Précisions (facultatif)",
+ 'report.send': "Envoyer le signalement",
+ 'report.sent': "Merci. Votre signalement a été enregistré.",
+ 'admin.tab_reports': "Signalements",
+ 'admin.no_reports': "Rien n’attend de décision",
+ 'admin.col_reporters': "Signalements reçus",
+ 'admin.btn_dismiss': "Écarter",
+ 'admin.report_block_confirm': "Bloquer ce fichier ? Chaque nœud qui héberge un groupe public cessera de le servir.",
+ 'admin.reports_heading': "Signalement de contenu",
+ 'admin.reports_hint': "Qui peut signaler un fichier dans un groupe public, à quelle fréquence, et ce qui se passe quand assez de membres l’ont fait.",
+ 'admin.reports_min_account_age_hours': "Âge minimal du compte (heures)",
+ 'admin.reports_daily_per_account': "Signalements par compte et par jour",
+ 'admin.reports_review_threshold': "Membres avant examen",
+ 'admin.reports_auto_block': "Bloquer sans examen (1 = oui, 0 = non)",
+ 'admin.reports_save': "Enregistrer les réglages de signalement",
+
+ // Names made writable everywhere when saved (portable-name.js)
+ 'transfers.renamed': "Renommé : « {name} » n’est pas un nom valide sur tous les systèmes",
+ 'transfers.renamed_n': "Noms modifiés pour être valides sur tous les systèmes : {n}",
};
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
index f0fb0d1..3a74b4d 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
@@ -92,7 +92,7 @@ export default {
'login.invalid': "Nome utente o passphrase errati.",
'login.locked': "Troppe passphrase errate per questo account. Riprovi tra {minutes} min oppure reimposti la passphrase.",
// Sign-in page: what MeshBay is (browser only)
- 'welcome.title': 'Internet come doveva essere.',
+ 'welcome.title': 'La rete che dipende solo da te.',
'welcome.lead': 'Le sue foto, la sua musica, i suoi video e le sue conversazioni restano a casa, sul suo computer. MeshBay le permette di goderseli ovunque e di condividerli con le persone che sceglie.',
'welcome.app_chat': 'Chat',
'welcome.app_photos': 'Foto',
@@ -884,6 +884,8 @@ export default {
// Group misc
'group.retry': 'Riprova',
+ 'lazy.load_failed': 'Impossibile caricare questa parte dell\'interfaccia. L\'hub potrebbe essere stato aggiornato da quando questa pagina è stata aperta.',
+ 'lazy.reload': 'Ricarica la pagina',
// Sidebar
'sidebar.node': 'Node',
@@ -906,7 +908,7 @@ export default {
'node.service_restarting': 'Riavvio…',
'node.service_mode_hint': 'In esecuzione come servizio in background — si avvia all\'avvio del sistema, prima dell\'accesso.',
'node.startup_mode_label': 'Avvia automaticamente:',
- 'node.startup_mode_off': 'Disattivato (avvio manuale)',
+ 'node.startup_mode_off': 'Solo mentre MeshBay è aperto',
'node.startup_mode_signin': 'All\'accesso',
'node.startup_mode_service': 'Come servizio in background (si avvia all\'avvio del sistema)',
'node.startup_mode_updating': 'Cambio modalità — controlli se compare una richiesta di amministratore…',
@@ -1200,4 +1202,32 @@ export default {
// In-page confirm/alert (ask.js)
'dialog.ok': 'OK',
'dialog.cancel': 'Annulla',
+
+ // Content reports (report.js, admin-page.js)
+ 'report.action': "Segnala",
+ 'report.title': "Segnala questo file",
+ 'report.hint': "Un amministratore di questo hub lo esaminerà.",
+ 'report.reason_illegal': "Contenuto illegale",
+ 'report.reason_spam': "Spam",
+ 'report.reason_copyright': "Violazione del diritto d’autore",
+ 'report.reason_other': "Altro",
+ 'report.detail_placeholder': "Dettagli (facoltativo)",
+ 'report.send': "Invia segnalazione",
+ 'report.sent': "Grazie. La tua segnalazione è stata registrata.",
+ 'admin.tab_reports': "Segnalazioni",
+ 'admin.no_reports': "Nulla attende una decisione",
+ 'admin.col_reporters': "Segnalanti",
+ 'admin.btn_dismiss': "Archivia",
+ 'admin.report_block_confirm': "Bloccare questo file? Ogni nodo che ospita un gruppo pubblico smetterà di servirlo lì.",
+ 'admin.reports_heading': "Segnalazioni di contenuti",
+ 'admin.reports_hint': "Chi può segnalare un file in un gruppo pubblico, quanto spesso e cosa succede quando abbastanza membri lo hanno fatto.",
+ 'admin.reports_min_account_age_hours': "Età minima dell’account (ore)",
+ 'admin.reports_daily_per_account': "Segnalazioni per account al giorno",
+ 'admin.reports_review_threshold': "Membri prima dell’esame",
+ 'admin.reports_auto_block': "Blocca senza esame (1 = sì, 0 = no)",
+ 'admin.reports_save': "Salva impostazioni segnalazioni",
+
+ // Names made writable everywhere when saved (portable-name.js)
+ 'transfers.renamed': "Rinominato: «{name}» non è un nome valido su tutti i sistemi",
+ 'transfers.renamed_n': "Nomi modificati per essere validi su tutti i sistemi: {n}",
};
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
index 560332d..e0c38de 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
@@ -92,7 +92,7 @@ export default {
'login.invalid': "ユーザー名またはパスフレーズが正しくありません。",
'login.locked': "このアカウントでパスフレーズの誤りが多すぎます。{minutes} 分後にもう一度お試しいただくか、パスフレーズをリセットしてください。",
// Sign-in page: what MeshBay is (browser only)
- 'welcome.title': '本来あるべき姿のインターネット。',
+ 'welcome.title': 'あなただけが支えるネットワーク。',
'welcome.lead': '写真、音楽、動画、会話は、ご自宅の自分のパソコンに置いたまま。MeshBay なら、どこからでも楽しめて、選んだ相手と共有できます。',
'welcome.app_chat': 'チャット',
'welcome.app_photos': '写真',
@@ -872,6 +872,8 @@ export default {
// Group
'group.retry': '再試行',
+ 'lazy.load_failed': 'インターフェースのこの部分を読み込めませんでした。このページを開いた後に hub が更新された可能性があります。',
+ 'lazy.reload': 'ページを再読み込み',
// Sidebar
'sidebar.node': 'Node',
@@ -894,7 +896,7 @@ export default {
'node.service_restarting': '再起動中…',
'node.service_mode_hint': 'バックグラウンドサービスとして実行中 — サインインより前、起動時に開始します。',
'node.startup_mode_label': '自動的に開始:',
- 'node.startup_mode_off': 'オフ(手動で開始)',
+ 'node.startup_mode_off': 'MeshBay が開いている間のみ',
'node.startup_mode_signin': 'サインイン時',
'node.startup_mode_service': 'バックグラウンドサービスとして(起動時に開始)',
'node.startup_mode_updating': 'モードを切り替え中 — 管理者の確認ダイアログをご確認ください…',
@@ -1184,4 +1186,32 @@ export default {
// In-page confirm/alert (ask.js)
'dialog.ok': 'OK',
'dialog.cancel': 'キャンセル',
+
+ // Content reports (report.js, admin-page.js)
+ 'report.action': "報告",
+ 'report.title': "このファイルを報告",
+ 'report.hint': "このハブの管理者が確認します。",
+ 'report.reason_illegal': "違法なコンテンツ",
+ 'report.reason_spam': "スパム",
+ 'report.reason_copyright': "著作権侵害",
+ 'report.reason_other': "その他",
+ 'report.detail_placeholder': "詳細(任意)",
+ 'report.send': "報告を送信",
+ 'report.sent': "ありがとうございます。報告を受け付けました。",
+ 'admin.tab_reports': "報告",
+ 'admin.no_reports': "判断待ちの項目はありません",
+ 'admin.col_reporters': "報告者数",
+ 'admin.btn_dismiss': "却下",
+ 'admin.report_block_confirm': "このファイルをブロックしますか?公開グループをホストするすべてのノードが、そこでの提供を停止します。",
+ 'admin.reports_heading': "コンテンツの報告",
+ 'admin.reports_hint': "公開グループのファイルを誰が、どのくらいの頻度で報告できるか、そして十分な数のメンバーが報告したときに何が起こるか。",
+ 'admin.reports_min_account_age_hours': "アカウントの最低経過時間(時間)",
+ 'admin.reports_daily_per_account': "1アカウントあたり1日の報告数",
+ 'admin.reports_review_threshold': "確認までのメンバー数",
+ 'admin.reports_auto_block': "確認せずにブロック(1 = はい、0 = いいえ)",
+ 'admin.reports_save': "報告の設定を保存",
+
+ // Names made writable everywhere when saved (portable-name.js)
+ 'transfers.renamed': "名前を変更しました:「{name}」はすべてのシステムで有効な名前ではありません",
+ 'transfers.renamed_n': "すべてのシステムで有効になるよう変更した名前:{n}",
};
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
index adb94c6..83b9eed 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
@@ -92,7 +92,7 @@ export default {
'login.invalid': "Onjuiste gebruikersnaam of wachtwoordzin.",
'login.locked': "Te veel onjuiste wachtwoordzinnen voor dit account. Probeer het over {minutes} min opnieuw of stel uw wachtwoordzin opnieuw in.",
// Sign-in page: what MeshBay is (browser only)
- 'welcome.title': 'Het internet zoals het bedoeld was.',
+ 'welcome.title': 'Het netwerk dat alleen van jou afhangt.',
'welcome.lead': 'Uw foto’s, muziek, video’s en gesprekken blijven thuis, op uw eigen computer. Met MeshBay geniet u er overal van en deelt u ze met wie u maar wilt.',
'welcome.app_chat': 'Chat',
'welcome.app_photos': 'Foto’s',
@@ -886,6 +886,8 @@ export default {
// Group
'group.retry': 'Opnieuw proberen',
+ 'lazy.load_failed': 'Dit deel van de interface kon niet worden geladen. De hub is mogelijk bijgewerkt sinds deze pagina werd geopend.',
+ 'lazy.reload': 'Pagina opnieuw laden',
// Sidebar
'sidebar.node': 'Node',
@@ -908,7 +910,7 @@ export default {
'node.service_restarting': 'Herstarten…',
'node.service_mode_hint': 'Actief als achtergrondservice — start bij het opstarten, vóór het aanmelden.',
'node.startup_mode_label': 'Automatisch starten:',
- 'node.startup_mode_off': 'Uit (handmatig starten)',
+ 'node.startup_mode_off': 'Alleen zolang MeshBay open is',
'node.startup_mode_signin': 'Bij aanmelden',
'node.startup_mode_service': 'Als achtergrondservice (start bij het opstarten)',
'node.startup_mode_updating': 'Modus wijzigen — let op een beheerdersprompt…',
@@ -1202,4 +1204,32 @@ export default {
// In-page confirm/alert (ask.js)
'dialog.ok': 'OK',
'dialog.cancel': 'Annuleren',
+
+ // Content reports (report.js, admin-page.js)
+ 'report.action': "Melden",
+ 'report.title': "Dit bestand melden",
+ 'report.hint': "Een beheerder van deze hub bekijkt het.",
+ 'report.reason_illegal': "Illegale inhoud",
+ 'report.reason_spam': "Spam",
+ 'report.reason_copyright': "Inbreuk op auteursrecht",
+ 'report.reason_other': "Overig",
+ 'report.detail_placeholder': "Details (optioneel)",
+ 'report.send': "Melding versturen",
+ 'report.sent': "Dank u. Uw melding is vastgelegd.",
+ 'admin.tab_reports': "Meldingen",
+ 'admin.no_reports': "Niets wacht op een beslissing",
+ 'admin.col_reporters': "Melders",
+ 'admin.btn_dismiss': "Afwijzen",
+ 'admin.report_block_confirm': "Dit bestand blokkeren? Elke node met een openbare groep stopt met het daar aanbieden.",
+ 'admin.reports_heading': "Inhoudsmeldingen",
+ 'admin.reports_hint': "Wie een bestand in een openbare groep mag melden, hoe vaak, en wat er gebeurt als genoeg leden dat hebben gedaan.",
+ 'admin.reports_min_account_age_hours': "Minimale leeftijd van het account (uren)",
+ 'admin.reports_daily_per_account': "Meldingen per account per dag",
+ 'admin.reports_review_threshold': "Leden vóór beoordeling",
+ 'admin.reports_auto_block': "Blokkeren zonder beoordeling (1 = ja, 0 = nee)",
+ 'admin.reports_save': "Meldingsinstellingen opslaan",
+
+ // Names made writable everywhere when saved (portable-name.js)
+ 'transfers.renamed': "Hernoemd: ‘{name}’ is niet op elk systeem een geldige naam",
+ 'transfers.renamed_n': "Namen aangepast zodat ze op elk systeem geldig zijn: {n}",
};
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
index fb5a4ed..3edba2d 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
@@ -95,7 +95,7 @@ export default {
'login.invalid': "Nieprawidłowa nazwa użytkownika lub hasło-fraza.",
'login.locked': "Zbyt wiele błędnych haseł-fraz dla tego konta. Spróbuj ponownie za {minutes} min lub zresetuj hasło-frazę.",
// Sign-in page: what MeshBay is (browser only)
- 'welcome.title': 'Internet taki, jaki miał być.',
+ 'welcome.title': 'Sieć, która zależy tylko od Ciebie.',
'welcome.lead': 'Twoje zdjęcia, muzyka, filmy i rozmowy zostają w domu, na Twoim własnym komputerze. MeshBay pozwala korzystać z nich z dowolnego miejsca i dzielić się nimi z wybranymi osobami.',
'welcome.app_chat': 'Czat',
'welcome.app_photos': 'Zdjęcia',
@@ -904,6 +904,8 @@ export default {
// Group (supplementary)
'group.retry': 'Ponów',
+ 'lazy.load_failed': 'Nie udało się wczytać tej części interfejsu. Hub mógł zostać zaktualizowany od czasu otwarcia tej strony.',
+ 'lazy.reload': 'Wczytaj stronę ponownie',
// Sidebar (supplementary)
'sidebar.node': 'Node',
@@ -926,7 +928,7 @@ export default {
'node.service_restarting': 'Ponowne uruchamianie…',
'node.service_mode_hint': 'Działa jako usługa w tle — uruchamia się przy starcie systemu, przed zalogowaniem.',
'node.startup_mode_label': 'Uruchamiaj automatycznie:',
- 'node.startup_mode_off': 'Wyłączone (uruchamianie ręczne)',
+ 'node.startup_mode_off': 'Tylko gdy MeshBay jest otwarty',
'node.startup_mode_signin': 'Przy logowaniu',
'node.startup_mode_service': 'Jako usługa w tle (uruchamia się przy starcie systemu)',
'node.startup_mode_updating': 'Zmiana trybu — proszę sprawdzić, czy pojawiło się okno uprawnień administratora…',
@@ -1228,4 +1230,32 @@ export default {
// In-page confirm/alert (ask.js)
'dialog.ok': 'OK',
'dialog.cancel': 'Anuluj',
+
+ // Content reports (report.js, admin-page.js)
+ 'report.action': "Zgłoś",
+ 'report.title': "Zgłoś ten plik",
+ 'report.hint': "Administrator tego huba go sprawdzi.",
+ 'report.reason_illegal': "Treść nielegalna",
+ 'report.reason_spam': "Spam",
+ 'report.reason_copyright': "Naruszenie praw autorskich",
+ 'report.reason_other': "Inne",
+ 'report.detail_placeholder': "Szczegóły (opcjonalnie)",
+ 'report.send': "Wyślij zgłoszenie",
+ 'report.sent': "Dziękujemy. Zgłoszenie zostało zapisane.",
+ 'admin.tab_reports': "Zgłoszenia",
+ 'admin.no_reports': "Nic nie czeka na decyzję",
+ 'admin.col_reporters': "Zgłaszający",
+ 'admin.btn_dismiss': "Odrzuć",
+ 'admin.report_block_confirm': "Zablokować ten plik? Każdy węzeł hostujący grupę publiczną przestanie go tam udostępniać.",
+ 'admin.reports_heading': "Zgłoszenia treści",
+ 'admin.reports_hint': "Kto może zgłosić plik w grupie publicznej, jak często i co się dzieje, gdy zrobi to wystarczająco wielu członków.",
+ 'admin.reports_min_account_age_hours': "Minimalny wiek konta (godziny)",
+ 'admin.reports_daily_per_account': "Zgłoszenia na konto dziennie",
+ 'admin.reports_review_threshold': "Członkowie przed oceną",
+ 'admin.reports_auto_block': "Blokuj bez oceny (1 = tak, 0 = nie)",
+ 'admin.reports_save': "Zapisz ustawienia zgłoszeń",
+
+ // Names made writable everywhere when saved (portable-name.js)
+ 'transfers.renamed': "Zmieniono nazwę: „{name}” nie jest prawidłową nazwą w każdym systemie",
+ 'transfers.renamed_n': "Nazwy zmienione, by były prawidłowe w każdym systemie: {n}",
};
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
index 7bd11f6..3f44570 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
@@ -93,7 +93,7 @@ export default {
'login.invalid': "Nome de usuário ou frase secreta incorretos.",
'login.locked': "Muitas frases secretas incorretas para esta conta. Tente novamente em {minutes} min ou redefina sua frase secreta.",
// Sign-in page: what MeshBay is (browser only)
- 'welcome.title': 'A internet como deveria ser.',
+ 'welcome.title': 'A rede que só depende de você.',
'welcome.lead': 'Suas fotos, músicas, vídeos e conversas ficam em casa, no seu próprio computador. O MeshBay permite que você aproveite tudo de qualquer lugar e compartilhe com as pessoas que escolher.',
'welcome.app_chat': 'Chat',
'welcome.app_photos': 'Fotos',
@@ -883,6 +883,8 @@ export default {
// Group misc
'group.retry': 'Tentar novamente',
+ 'lazy.load_failed': 'Não foi possível carregar esta parte da interface. O hub pode ter sido atualizado desde que esta página foi aberta.',
+ 'lazy.reload': 'Recarregar a página',
// Sidebar
'sidebar.node': 'Node',
@@ -905,7 +907,7 @@ export default {
'node.service_restarting': 'Reiniciando…',
'node.service_mode_hint': 'Em execução como serviço em segundo plano — inicia na inicialização, antes do login.',
'node.startup_mode_label': 'Iniciar automaticamente:',
- 'node.startup_mode_off': 'Desativado (iniciar manualmente)',
+ 'node.startup_mode_off': 'Somente enquanto o MeshBay estiver aberto',
'node.startup_mode_signin': 'Ao entrar na sessão',
'node.startup_mode_service': 'Como serviço em segundo plano (inicia na inicialização)',
'node.startup_mode_updating': 'Alternando modo — verifique se aparece um aviso de administrador…',
@@ -1187,4 +1189,32 @@ export default {
// In-page confirm/alert (ask.js)
'dialog.ok': 'OK',
'dialog.cancel': 'Cancelar',
+
+ // Content reports (report.js, admin-page.js)
+ 'report.action': "Denunciar",
+ 'report.title': "Denunciar este arquivo",
+ 'report.hint': "Um administrador deste hub vai analisá-lo.",
+ 'report.reason_illegal': "Conteúdo ilegal",
+ 'report.reason_spam': "Spam",
+ 'report.reason_copyright': "Violação de direitos autorais",
+ 'report.reason_other': "Outro",
+ 'report.detail_placeholder': "Detalhes (opcional)",
+ 'report.send': "Enviar denúncia",
+ 'report.sent': "Obrigado. Sua denúncia foi registrada.",
+ 'admin.tab_reports': "Denúncias",
+ 'admin.no_reports': "Nada aguarda decisão",
+ 'admin.col_reporters': "Denunciantes",
+ 'admin.btn_dismiss': "Descartar",
+ 'admin.report_block_confirm': "Bloquear este arquivo? Todos os nós que hospedam um grupo público deixarão de servi-lo ali.",
+ 'admin.reports_heading': "Denúncias de conteúdo",
+ 'admin.reports_hint': "Quem pode denunciar um arquivo em um grupo público, com que frequência e o que acontece quando membros suficientes o fizeram.",
+ 'admin.reports_min_account_age_hours': "Idade mínima da conta (horas)",
+ 'admin.reports_daily_per_account': "Denúncias por conta por dia",
+ 'admin.reports_review_threshold': "Membros antes da análise",
+ 'admin.reports_auto_block': "Bloquear sem análise (1 = sim, 0 = não)",
+ 'admin.reports_save': "Salvar configurações de denúncias",
+
+ // Names made writable everywhere when saved (portable-name.js)
+ 'transfers.renamed': "Renomeado: “{name}” não é um nome válido em todos os sistemas",
+ 'transfers.renamed_n': "Nomes alterados para serem válidos em todos os sistemas: {n}",
};
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
index 5509332..1168460 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
@@ -92,7 +92,7 @@ export default {
'login.invalid': "用户名或密码短语错误。",
'login.locked': "此账户输错密码短语的次数过多。请在 {minutes} 分钟后重试,或重置密码短语。",
// Sign-in page: what MeshBay is (browser only)
- 'welcome.title': '互联网本该有的样子。',
+ 'welcome.title': '只依靠你自己的网络。',
'welcome.lead': '您的照片、音乐、视频和聊天都留在家里,保存在您自己的电脑上。MeshBay 让您随时随地享用它们,并与您选择的人分享。',
'welcome.app_chat': '聊天',
'welcome.app_photos': '照片',
@@ -860,6 +860,8 @@ export default {
// Group
'group.retry': '重试',
+ 'lazy.load_failed': '无法加载界面的这一部分。自打开此页面以来,hub 可能已更新。',
+ 'lazy.reload': '重新加载页面',
// Sidebar
'sidebar.node': 'Node',
@@ -882,7 +884,7 @@ export default {
'node.service_restarting': '正在重启…',
'node.service_mode_hint': '以后台服务方式运行 — 在开机时启动,早于登录。',
'node.startup_mode_label': '自动启动:',
- 'node.startup_mode_off': '关闭(手动启动)',
+ 'node.startup_mode_off': '仅在 MeshBay 打开时',
'node.startup_mode_signin': '登录时',
'node.startup_mode_service': '作为后台服务(开机时启动)',
'node.startup_mode_updating': '正在切换模式 — 请留意管理员权限提示…',
@@ -1173,4 +1175,32 @@ export default {
// In-page confirm/alert (ask.js)
'dialog.ok': '确定',
'dialog.cancel': '取消',
+
+ // Content reports (report.js, admin-page.js)
+ 'report.action': "举报",
+ 'report.title': "举报此文件",
+ 'report.hint': "本中心的管理员会进行审核。",
+ 'report.reason_illegal': "违法内容",
+ 'report.reason_spam': "垃圾信息",
+ 'report.reason_copyright': "侵犯版权",
+ 'report.reason_other': "其他",
+ 'report.detail_placeholder': "详细说明(可选)",
+ 'report.send': "提交举报",
+ 'report.sent': "谢谢,您的举报已记录。",
+ 'admin.tab_reports': "举报",
+ 'admin.no_reports': "暂无待处理事项",
+ 'admin.col_reporters': "举报人数",
+ 'admin.btn_dismiss': "驳回",
+ 'admin.report_block_confirm': "要屏蔽此文件吗?所有托管公开群组的节点都将停止在那里提供它。",
+ 'admin.reports_heading': "内容举报",
+ 'admin.reports_hint': "谁可以举报公开群组中的文件、举报频率,以及足够多的成员举报后会发生什么。",
+ 'admin.reports_min_account_age_hours': "账户最短注册时长(小时)",
+ 'admin.reports_daily_per_account': "每个账户每天的举报数",
+ 'admin.reports_review_threshold': "进入审核所需人数",
+ 'admin.reports_auto_block': "无需审核直接屏蔽(1 = 是,0 = 否)",
+ 'admin.reports_save': "保存举报设置",
+
+ // Names made writable everywhere when saved (portable-name.js)
+ 'transfers.renamed': "已重命名:“{name}”并非在所有系统上都是有效的名称",
+ 'transfers.renamed_n': "为在所有系统上有效而修改的名称:{n}",
};
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/node-page.js b/packages/meshbay-hub/src/meshbay_hub/static/node-page.js
index a59c100..7fd7ab1 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/node-page.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/node-page.js
@@ -13,6 +13,14 @@ import { HUB } from './hub-client.js';
// (platform.node.call), not over MNP/WebRTC. The MNP protocol types remain
// for potential future browser-side use.
+// true / false from a node that has read its roster, null from one that has
+// not yet (it publishes the roster only once it has signed in to the hub) --
+// which is not the same as having no operator.
+export function pairedFrom(result) {
+ const v = result && result.operator_paired;
+ return v === true || v === false ? v : null;
+}
+
function NodeServicePanel({ onChanged, token, username }) {
const [info, setInfo] = useState(null);
const [busy, setBusy] = useState('');
@@ -202,7 +210,7 @@ export function NodePage({ groups, token, username }) {
const [editIce, setEditIce] = useState(null);
const [savingIce, setSavingIce] = useState(false);
const [iceInput, setIceInput] = useState('');
- const [operatorPaired, setOperatorPaired] = useState(false);
+ const [operatorPaired, setOperatorPaired] = useState(null);
const [pairBusy, setPairBusy] = useState(false);
const [pairStatus, setPairStatus] = useState('');
const [nodeInfo, setNodeInfo] = useState(null);
@@ -231,7 +239,7 @@ export function NodePage({ groups, token, username }) {
}
const result = await nodeCall('GET', '/api/groups');
setNodeGroups(result.groups || []);
- setOperatorPaired(!!result.operator_paired);
+ setOperatorPaired(pairedFrom(result));
setNodeSettings(result.settings || null);
try { setNodeInfo(await nodeCall('GET', '/api/status')); } catch {}
setStatus('connected');
@@ -253,7 +261,7 @@ export function NodePage({ groups, token, username }) {
try {
const result = await nodeCall('GET', '/api/groups');
setNodeGroups(result.groups || []);
- setOperatorPaired(!!result.operator_paired);
+ setOperatorPaired(pairedFrom(result));
setNodeSettings(result.settings || null);
try { setNodeInfo(await nodeCall('GET', '/api/status')); } catch {}
} catch {}
@@ -711,7 +719,7 @@ export function NodePage({ groups, token, username }) {
</div>
<${NodeServicePanel} onChanged=${fetchStatus} token=${token} username=${username} />
${actionMsg && html`<div class="node-message">${actionMsg}</div>`}
- ${!operatorPaired && html`
+ ${operatorPaired === false && html`
<div class="node-pair-banner">
<p>${t('node.pair_needed')}</p>
<button class="btn btn-primary btn-small"
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/portable-name.js b/packages/meshbay-hub/src/meshbay_hub/static/portable-name.js
new file mode 100644
index 0000000..bb2438c
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/static/portable-name.js
@@ -0,0 +1,51 @@
+/**
+ * A name that can be written on every platform a member saves to
+ * (docs/MESHBAY_DESIGN.md §10).
+ *
+ * A node serves a file under the name its own disk gave it, and a name that is
+ * fine on ext4 can be impossible on Windows or on an exFAT drive: reserved
+ * characters, a trailing dot or space, `CON` or `aux.txt`. The node never
+ * rewrites a name — it is the string that opens the file — so the client does,
+ * at the moment it saves, and says so.
+ *
+ * The same rules as `meshbay_common.paths.sanitize_for_download`, and held to
+ * them by `test_portable_name_parity.py`: two copies of a rule that differ
+ * decide differently which files get renamed.
+ */
+
+const WINDOWS_RESERVED = new Set([
+ 'CON', 'PRN', 'AUX', 'NUL',
+ ...[1, 2, 3, 4, 5, 6, 7, 8, 9].map((i) => `COM${i}`),
+ ...[1, 2, 3, 4, 5, 6, 7, 8, 9].map((i) => `LPT${i}`),
+]);
+
+const RESERVED_CHARS = new Set('<>:"/\\|?*');
+
+const reserved = (c) => RESERVED_CHARS.has(c) || c.charCodeAt(0) < 32;
+
+function isPortable(name) {
+ if (!name || name === '.' || name === '..') return false;
+ for (const c of name) if (reserved(c)) return false;
+ if (name.endsWith(' ') || name.endsWith('.')) return false;
+ return !WINDOWS_RESERVED.has(name.split('.', 1)[0].toUpperCase());
+}
+
+/** `name` if it can be written everywhere, otherwise the nearest name that can. */
+export function portableName(name, replacement = '_') {
+ const text = String(name ?? '');
+ if (isPortable(text)) return text;
+ let out = Array.from(text).map((c) => (reserved(c) ? replacement : c)).join('');
+ out = out.replace(/[ .]+$/, '');
+ const dot = out.indexOf('.');
+ let stem = dot === -1 ? out : out.slice(0, dot);
+ const rest = dot === -1 ? '' : out.slice(dot);
+ if (WINDOWS_RESERVED.has(stem.toUpperCase())) stem += replacement;
+ out = stem + rest;
+ return out || 'unnamed';
+}
+
+/** Every segment of a relative path made portable, keeping `/` between them. */
+export function portablePath(path) {
+ return String(path ?? '').split('/')
+ .map((segment) => (segment ? portableName(segment) : segment)).join('/');
+}
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/report.js b/packages/meshbay-hub/src/meshbay_hub/static/report.js
new file mode 100644
index 0000000..f433780
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/static/report.js
@@ -0,0 +1,70 @@
+import { html, render, useEffect, useRef, useState } from './vendor/htm-preact.js';
+import { t } from './i18n.js';
+
+/**
+ * Ask why a file is being reported, drawn by the page like `ask.js`.
+ *
+ * Resolves `{ reason, detail }`, or null when cancelled. The reasons are the
+ * hub's own closed list (api/moderation.py `ReportRequest`), and the detail is
+ * bounded to what the hub keeps (256 characters).
+ */
+
+const REASONS = ['illegal', 'spam', 'copyright', 'other'];
+
+function ReportDialog({ name, onDone }) {
+ const [reason, setReason] = useState('illegal');
+ const [detail, setDetail] = useState('');
+ const firstRef = useRef(null);
+ useEffect(() => { if (firstRef.current) firstRef.current.focus(); }, []);
+
+ return html`
+ <div class="video-overlay" onClick=${(e) => {
+ if (e.target.classList.contains('video-overlay')) onDone(null);
+ }}>
+ <form class="music-detail playlist-modal" role="dialog" aria-modal="true"
+ onKeyDown=${(e) => { if (e.key === 'Escape') { e.preventDefault(); onDone(null); } }}
+ onSubmit=${(e) => {
+ e.preventDefault();
+ onDone({ reason, detail: detail.trim() || null });
+ }}>
+ <div class="playlist-modal-body">
+ <div class="ask-message"><strong>${t('report.title')}</strong></div>
+ <div class="ask-message file-name">${name}</div>
+ <p class="settings-hint">${t('report.hint')}</p>
+ <select ref=${firstRef} value=${reason}
+ onChange=${(e) => setReason(e.target.value)}>
+ ${REASONS.map((r) => html`
+ <option key=${r} value=${r}>${t('report.reason_' + r)}</option>`)}
+ </select>
+ <textarea maxlength="256" placeholder=${t('report.detail_placeholder')}
+ value=${detail} onInput=${(e) => setDetail(e.target.value)} />
+ <div class="playlist-modal-actions">
+ <button type="button" class="tb-btn" onClick=${() => onDone(null)}>
+ ${t('dialog.cancel')}</button>
+ <button type="submit" class="admin-btn">${t('report.send')}</button>
+ </div>
+ </div>
+ </form>
+ </div>
+ `;
+}
+
+export function askReport(name) {
+ return new Promise((resolve) => {
+ const host = document.createElement('div');
+ document.body.appendChild(host);
+ const previous = document.activeElement;
+ let settled = false;
+ const onDone = (value) => {
+ if (settled) return;
+ settled = true;
+ render(null, host);
+ host.remove();
+ if (previous && previous.isConnected && typeof previous.focus === 'function') {
+ previous.focus();
+ }
+ resolve(value);
+ };
+ render(html`<${ReportDialog} name=${String(name)} onDone=${onDone} />`, host);
+ });
+}
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/style.css b/packages/meshbay-hub/src/meshbay_hub/static/style.css
index 37775d8..fc91596 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/style.css
+++ b/packages/meshbay-hub/src/meshbay_hub/static/style.css
@@ -491,6 +491,14 @@ a:hover { text-decoration: underline; }
margin-bottom: 24px;
}
+/* lazy.js: a view whose code could not be fetched says so, in place of the
+ spinner it would otherwise have kept for good. Inside the player's overlay
+ the backdrop is near-black whatever the theme, so the text colour cannot
+ come from the theme there. */
+.lazy-failed { text-align: center; max-width: 520px; margin: 24px auto; }
+.lazy-failed p { margin-bottom: 12px; }
+.video-overlay .lazy-failed { color: #e2e8f0; }
+
/* ── Page center (login / register) ───────────────────────────────────────── */
.page-center {
@@ -682,7 +690,7 @@ a:hover { text-decoration: underline; }
overflow: hidden;
pointer-events: none;
background: linear-gradient(155deg,
- #86a3c4 0%, #6a819b 18%, #3d4d61 42%, #232b36 66%, #0f1113 100%);
+ #6d87a5 0%, #5d738c 18%, #3d4d61 42%, #232b36 66%, #0f1113 100%);
}
/* The night-blue pool. */
.welcome-backdrop::before {
@@ -1979,6 +1987,13 @@ button:disabled { opacity: 0.5; cursor: not-allowed; }
justify-content: center;
}
+/* The player opens on top of a show's detail modal, which stays open under it
+ so the next episode is one click away when the player closes. Both are
+ `.video-overlay`; equal values would leave the order to where each happens
+ to sit in the DOM, which is not a decision. Below the operator's match
+ search (210), which only ever opens from the modal, never over the player. */
+.video-overlay.video-player-overlay { z-index: 205; }
+
.video-top-bar {
position: absolute;
top: 0;
@@ -2573,6 +2588,7 @@ a.transfer-name {
margin-top: 3px;
}
.transfer-failed { color: var(--error); }
+.transfer-note { justify-content: flex-start; font-style: italic; }
/* ── File selection ──────────────────────────────────────────────────────── */
@@ -4135,6 +4151,13 @@ h2 .gn-owner, h3 .gn-owner { font-size: 0.55em; }
margin-bottom: 4px;
}
.video-episode-row:hover { border-color: var(--accent); color: var(--accent); }
+/* The episode last started from this modal, so the one to watch next is the
+ row under it. A border and a tint, not a colour change of the text alone,
+ so it does not read as the hover state left behind. */
+.video-episode-row.last-played {
+ border-color: var(--accent);
+ background: color-mix(in srgb, var(--accent) 10%, transparent);
+}
.video-episode-row .icon { width: 14px; height: 14px; flex-shrink: 0; }
.video-episode-thumb-slot { width: 64px; height: 40px; flex-shrink: 0; }
.video-episode-thumb {
@@ -5329,7 +5352,9 @@ h2 .gn-owner, h3 .gn-owner { font-size: 0.55em; }
.playlist-modal { max-width: 420px; }
.playlist-modal-body { padding: 16px; display: flex; flex-direction: column; gap: 12px; }
-.playlist-modal-body input {
+.playlist-modal-body input,
+.playlist-modal-body select,
+.playlist-modal-body textarea {
width: 100%;
padding: 9px 12px;
border: 1px solid var(--border);
@@ -5338,7 +5363,10 @@ h2 .gn-owner, h3 .gn-owner { font-size: 0.55em; }
color: var(--text);
font: inherit;
}
-.playlist-modal-body input:focus {
+.playlist-modal-body textarea { resize: vertical; min-height: 4.5em; }
+.playlist-modal-body input:focus,
+.playlist-modal-body select:focus,
+.playlist-modal-body textarea:focus {
outline: none;
border-color: var(--border-focus);
}
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transfers.js b/packages/meshbay-hub/src/meshbay_hub/static/transfers.js
index 9a1455a..0d012ed 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/transfers.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/transfers.js
@@ -29,13 +29,6 @@ function _live(status) {
|| status === 'paused';
}
-/** Raised by `run` when it stopped because the transfer was paused. */
-function _pausedError() {
- const err = new Error('Paused');
- err.name = 'PausedError';
- return err;
-}
-
function _abortError() {
const err = new Error('Cancelled');
err.name = 'AbortError';
@@ -80,6 +73,7 @@ export class TransferStore {
queuedByOwnLimit: Boolean(
it.lease && it.lease.cap && it.lease.used >= it.lease.cap),
error: it.error || '',
+ note: it.note || '',
speed: this._speed(it),
// The ETA is drawn only once the window holds a few seconds of real
// measurement -- see etaSeconds.
@@ -141,10 +135,14 @@ export class TransferStore {
* there is somewhere to write — see file-utils.js's downloadEntry.
*/
start({ kind, name, total = 0, transport = null, run, open = null,
- lease = null, prepare = null, makeLease = null, pausable = false }) {
+ lease = null, prepare = null, makeLease = null, pausable = false,
+ note = '' }) {
const item = {
id: _nextId++,
kind, name, total, transport, open, lease,
+ // One line said under the name for the life of the row — that a name was
+ // changed to be written here, for instance.
+ note,
done: 0,
// A transfer that has to wait for a slot starts as 'queued', not
// 'running'. Two different things are true of it — nothing is moving, and
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport-admin.js b/packages/meshbay-hub/src/meshbay_hub/static/transport-admin.js
index 03a0f33..c7c3f47 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/transport-admin.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/transport-admin.js
@@ -263,7 +263,10 @@ extendTransport(class {
});
if (msg.type === 'error') throw new Error(msg.detail);
if (msg.type === 'admin_challenge') {
- return this._authorizeAdminOp(msg, 'root_add', path, signFn);
+ // Everything the node will act on is in the subject, `writable` included.
+ const subject = window.MeshBayCrypto.rootAddSubject(
+ path, name || '', kind || 'generic', !!writable, !!removable);
+ return this._authorizeAdminOp(msg, 'root_add', subject, signFn);
}
return msg;
}
@@ -369,11 +372,13 @@ extendTransport(class {
async attachGroup(name, sharedDir, uploadDir, signFn) {
const msg = await this._sendAndWait({
type: 'group_attach', v: '0.1',
- name, shared_dir: sharedDir, upload_dir: uploadDir || '',
+ name, shared_dir: sharedDir, upload_dir: uploadDir || '', writable: true,
});
if (msg.type === 'error') throw new Error(msg.detail);
if (msg.type === 'admin_challenge') {
- return this._authorizeAdminOp(msg, 'group_attach', name, signFn);
+ // The directory being exposed is signed, not only the group's name.
+ const subject = window.MeshBayCrypto.groupAttachSubject(name, sharedDir, true);
+ return this._authorizeAdminOp(msg, 'group_attach', subject, signFn);
}
return msg;
}
@@ -416,7 +421,10 @@ extendTransport(class {
});
if (msg.type === 'error') throw new Error(msg.detail);
if (msg.type === 'admin_challenge') {
- return this._authorizeAdminOp(msg, 'invite_create', userId, signFn);
+ // The node keeps the first 64 code points of the name, as Python slices.
+ const name = Array.from(username || '').slice(0, 64).join('');
+ const subject = window.MeshBayCrypto.inviteCreateSubject(userId, name);
+ return this._authorizeAdminOp(msg, 'invite_create', subject, signFn);
}
return msg;
}
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport-chat.js b/packages/meshbay-hub/src/meshbay_hub/static/transport-chat.js
index d6d733f..270c76e 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/transport-chat.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/transport-chat.js
@@ -212,7 +212,6 @@ extendTransport(class {
if (msg.epoch) this.chatEpoch = msg.epoch;
this._chatKeys = null;
this._chatKeysInFlight = null;
- if (this._onChatEpoch) this._onChatEpoch(this.chatEpoch);
}
/**
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport-devices.js b/packages/meshbay-hub/src/meshbay_hub/static/transport-devices.js
index 4291cf8..199b6a2 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/transport-devices.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/transport-devices.js
@@ -268,6 +268,10 @@ extendTransport(class {
* The counterpart of storeKeypairBundle: turning the setting off has to remove
* what is already stored, not merely stop adding to it — otherwise the blob
* stays on every node the account has ever joined (C4).
+ *
+ * Nothing calls this yet, on purpose: it is reserved for `device_policy`
+ * (docs/MESHBAY_DESIGN.md §3.7, O3). Offered alone, it would strand the next
+ * browser that signs in to this node.
*/
async deleteKeypairBundle() {
const msg = await this._sendAndWait({
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport-media.js b/packages/meshbay-hub/src/meshbay_hub/static/transport-media.js
index f94eb40..cf53c08 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/transport-media.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/transport-media.js
@@ -106,18 +106,17 @@ extendTransport(class {
* `language`, to leave whatever is stored unchanged.
*/
async setTmdbConfig(token, language, signFn) {
+ const tok = token === undefined ? null : token;
+ const lang = language === undefined ? null : language;
const msg = await this._sendAndWait({
- type: 'tmdb_config', v: '0.7',
- token: token === undefined ? null : token,
- language: language === undefined ? null : language,
+ type: 'tmdb_config', v: '0.7', token: tok, language: lang,
});
if (msg.type === 'error') throw new Error(msg.detail);
if (msg.type === 'admin_challenge') {
- // Must match the node's subject byte-for-byte (apps/video_meta.py
- // _do_tmdb_config) — the token itself is never part of the subject
- // (it would end up in the audit log in plaintext), only whether one
- // was supplied. The language is not a secret, so it appears as-is.
- const subject = `custom_token=${token ? 'yes' : 'no'},language=${language || 'default'}`;
+ // Must match the node's subject byte for byte (apps/video_meta.py
+ // _do_tmdb_config). The token is named by its SHA-256, never written:
+ // the subject ends up in the audit log.
+ const subject = await window.MeshBayCrypto.tmdbConfigSubject(tok, lang);
return this._authorizeAdminOp(msg, 'tmdb_config', subject, signFn);
}
return msg;
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport-rewrap.js b/packages/meshbay-hub/src/meshbay_hub/static/transport-rewrap.js
index 6ae51d1..18ad9d4 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/transport-rewrap.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/transport-rewrap.js
@@ -110,7 +110,7 @@ async function rewrapAllNodes(o) {
try {
await _acWithTimeout(
tp.connect(n.node_id, o.token, g.id, null, null, oldKey,
- o.username, o.userId, null, recoveryKey),
+ o.username, o.userId, null, recoveryKey, undefined, n.pk_node),
30000, 'connect');
if (tp.newNodeBundle) {
// No identity existed on this node — connect just minted one under
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
index f3683eb..98d010e 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
@@ -305,8 +305,10 @@ window.addEventListener('hashchange', () => {
// The `v: '0.1'` on every other message in this file is the historical value
// and is read by nothing; it is left alone deliberately. The range is
// negotiated once, at the start, not restated per message.
-const MNP_V = '4.0';
-// Raised with it: 4.0 is a flag day. A member now presents a short-lived
+const MNP_V = '5.0';
+// Not raised with 5.0 (see meshbay_common/__init__.py): the break is confined to
+// four signed operations, which a peer on the other side of it refuses to sign.
+// Set at 4.0, a flag day. A member now presents a short-lived
// MNP-audience token in the handshake, not its hub session token — a node
// older than 4.0 expected the session token, and one newer refuses it, so the
// two cannot authenticate across the break. This is the C6 rule: no
@@ -498,11 +500,6 @@ class MeshBayTransport {
this._inFlightUploads = new Set();
// tr → Lease. A transfer's slot on the node, from the client's side.
this._leases = new Map();
- // Set from the handshake ack: a node that answers with `transfer_limits`
- // speaks transfer slots. Used instead of a timeout, because "no answer
- // yet" and "this node will never answer" are indistinguishable in time and
- // guessing wrong either stalls every download or defeats the cap.
- this._transferLimits = null;
// Set once close() runs — stops the automatic reconnect from firing on a
// connection the caller tore down on purpose (leaving the group, page
// unload), which would otherwise race back in right as everything else
@@ -571,18 +568,11 @@ class MeshBayTransport {
set onIndexDelta(fn) { this._onIndexDelta = fn; }
set onRootsChanged(fn) { this._onRootsChanged = fn; }
- /** The MNP version the connected node declared, or '' before a handshake. */
- get nodeVersion() { return this._nodeVersion || ''; }
-
- /** This member's own caps in this group, or null when the node said nothing. */
- get transferLimits() { return this._transferLimits; }
-
set onAppsEnabled(fn) { this._onAppsEnabled = fn; }
set onAppDirectories(fn) { this._onAppDirectories = fn; }
set onChatDirectory(fn) { this._onChatDirectory = fn; }
set onChatLinkPreview(fn) { this._onChatLinkPreview = fn; }
set onSearchListed(fn) { this._onSearchListed = fn; }
- set onChatEpoch(fn) { this._onChatEpoch = fn; }
set onTmdbConfig(fn) { this._onTmdbConfig = fn; }
set onTmdbEnabled(fn) { this._onTmdbEnabled = fn; }
set onMusicbrainzEnabled(fn) { this._onMusicbrainzEnabled = fn; }
@@ -663,21 +653,21 @@ class MeshBayTransport {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this._accessToken}`,
},
- body: JSON.stringify({}),
+ body: JSON.stringify({ node_pk: this._nodePkTarget || '' }),
});
if (!r.ok) throw new Error(`Could not obtain a node token: ${r.status}`);
return (await r.json()).mnp_token;
}
async connect(nodeId, jwtToken, groupId, gekRaw, sessionKeys, bundleKey, username,
- userId, joinCode, recoveryKey, joinNodePk) {
+ userId, joinCode, recoveryKey, joinNodePk, nodePk) {
// Remembered for _reconnectLoop, which calls connect() again with these
// same values (plus a freshly-fetched token and the identity connect()
// itself settles on below) after the WebRTC connection is declared
// "failed" — see the pc.onconnectionstatechange handler further down.
this._connectArgs = {
nodeId, groupId, gekRaw, bundleKey, username, userId, joinCode, recoveryKey,
- joinNodePk,
+ joinNodePk, nodePk,
};
this._lastToken = jwtToken;
// The constructor sets this once from whatever token the caller had at
@@ -695,6 +685,10 @@ class MeshBayTransport {
this._recoveryKey = recoveryKey || null;
this._username = username || null;
this._userId = userId || null;
+ // The key of the node we mean to reach, from the hub's node list. The MNP
+ // token is bound to it (E10) so it cannot be replayed to another node. It is
+ // the *expected* key; `this.nodePk` below is the one the node then proves.
+ this._nodePkTarget = nodePk || '';
// The group this connection is for. Kept on the instance because the
// handshake is not the only thing that needs it any more: device_hello and
// the chat envelope both bind to it, and both run outside connect()'s scope.
@@ -937,12 +931,9 @@ class MeshBayTransport {
if (reply.type === 'handshake_challenge') {
// The node's half of the range. Checked before anything else in this
// block, because everything below — the join, the proof, the sealed ack
- // — assumes both sides mean the same thing by each message.
+ // — assumes both sides mean the same thing by each message. Nothing else
+ // reads the node's version: a peer this admits speaks every message here.
_checkNodeVersion(reply);
- // Kept for diagnostics only. Nothing branches on it: the range check
- // above is what decides whether these two can talk at all, and a peer it
- // admits speaks every message in this file.
- this._nodeVersion = String(reply.v || '');
if (!window.MeshBayCrypto) {
throw new Error('Node requires GEK proof but no crypto available');
}
@@ -955,16 +946,14 @@ class MeshBayTransport {
// nonce_node ties a join to this connection, so one cannot be lifted onto
// another. node_pk is announced here because a first-time member has no
// GEK and so cannot complete the handshake that would prove it. From an
- // older node it is unverified until the ack below checks it.
+ // The signature checked next is what proves it here, before the ack.
this._nonceNode = window.MeshBayCrypto.b64decode(reply.nonce);
this.nodePk = reply.node_pk || null;
- // Since MNP 3.4 the node signs its challenge over this connection, so
- // node_pk is proved here and not only at the ack — which comes after any
- // join. A signature that does not verify is a peer lying about which node
- // it is, and is refused. An absent one is an older node: `nodePkProved`
- // stays false, and whatever needs the key proved before a code leaves
- // (an invitation link names its node) reads that — never a version.
- this.nodePkProved = await _challengeProvesNodeKey(
+ // The node signs its challenge over this connection, so node_pk is proved
+ // here and not only at the ack — which comes after any join. Every node
+ // this client can reach signs (the floor is 4.0, and signing is 3.4), so
+ // a missing signature is refused exactly like a wrong one.
+ await _challengeProvesNodeKey(
reply, groupId || '', this._nonceClient,
this._pc.localDescription.sdp, this._rawAnswerSdp);
@@ -1060,8 +1049,7 @@ class MeshBayTransport {
// (docs/MESHBAY_DESIGN.md §3.4). Otherwise nothing is sent at all — not
// even a join without the code, which this node would answer by asking
// for one.
- const linkRefusal = _linkJoinRefusal(joinNodePk, joinCode, this.nodePk,
- this.nodePkProved);
+ const linkRefusal = _linkJoinRefusal(joinNodePk, joinCode, this.nodePk);
if (linkRefusal) {
this._joinError = linkRefusal;
} else if (!gekRaw && this._sessionKeys && userId) {
@@ -1161,7 +1149,6 @@ class MeshBayTransport {
delete ack.nonce;
delete ack.ct;
Object.assign(ack, config);
- this._transferLimits = ack.transfer_limits || null;
// From the *sealed* part of the ack: a forged epoch would have this
// client sealing under a key the group has retired.
@@ -1321,7 +1308,7 @@ class MeshBayTransport {
ack = await this.connect(args.nodeId, token, args.groupId, args.gekRaw,
this._sessionKeys, args.bundleKey, args.username,
args.userId, args.joinCode, undefined,
- args.joinNodePk);
+ args.joinNodePk, args.nodePk);
} finally {
this._inReconnectAttempt = false;
}
@@ -2194,35 +2181,29 @@ class MeshBayTransport {
* Why a code from an invitation link must not go to this node, or null.
*
* `link_other_node` is the caller's cue to try the next node the hub listed,
- * as for `not_hosted`: the link names one node, and this is not it. An older
- * node that cannot prove its key early is refused rather than trusted — it
- * cannot have issued a link code anyway.
+ * as for `not_hosted`: the link names one node, and this is not it. `nodePk`
+ * has already been proved by the challenge signature, which is required.
*/
-function _linkJoinRefusal(joinNodePk, joinCode, nodePk, nodePkProved) {
+function _linkJoinRefusal(joinNodePk, joinCode, nodePk) {
if (!joinNodePk || !joinCode) return null;
if (nodePk !== joinNodePk) {
const err = new Error('This invitation was issued by another machine hosting this group.');
err.reason = 'link_other_node';
return err;
}
- if (!nodePkProved) {
- const err = new Error('This node is too old to accept invitation links.');
- err.reason = 'link_node_unproved';
- return err;
- }
return null;
}
/**
- * Whether `handshake_challenge` proves the key it announces (MNP 3.4).
+ * Check that `handshake_challenge` proves the key it announces; throw if not.
*
- * True when it carries a signature that verifies over this connection, false
- * when it carries none — an older node, which proves its key only at the ack.
- * A signature that does not verify is a peer lying about which node it is, and
- * throws: that is a refusal, not a node that merely cannot say.
+ * A node signs whenever it has a channel binding, and one without a binding
+ * could not complete the handshake anyway (its proof is refused), so a missing
+ * signature is refused like a wrong one: both are a peer that cannot show it is
+ * the node it names. There is no "older node" case — the floor is 4.0.
*/
async function _challengeProvesNodeKey(reply, groupId, nonceClient, offerSdp, answerSdp) {
- if (!reply.sig) return false;
+ if (!reply.sig) throw new Error('Node challenge is not signed — refusing connection');
const C = window.MeshBayCrypto;
let ok = false;
try {
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/video-app.js b/packages/meshbay-hub/src/meshbay_hub/static/video-app.js
index 124033e..9e2db1c 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/video-app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/video-app.js
@@ -619,6 +619,13 @@ function VideoDetailModal({
const [selectedSeason, setSelectedSeason] = useState(null);
useEffect(() => { setSelectedSeason(defaultSeason(show)); }, [show]);
+ // A show's modal stays open under the player (PosterGrid's onPlay), so
+ // closing the player lands back here. Marking the episode just started is
+ // what makes "the next one" the row under the mark, rather than a row the
+ // reader has to find again by its number.
+ const [lastPlayedId, setLastPlayedId] = useState(null);
+ useEffect(() => { setLastPlayedId(null); }, [show]);
+
const showMultiSeason = Boolean(show && show.seasons.length > 1);
const seasonMeta = useSeasonMeta(
transportRef, confident ? meta.tmdb_id : null, selectedSeason,
@@ -708,7 +715,10 @@ function VideoDetailModal({
</div>
`}
${s.episodes.map((ep) => html`
- <button class="video-episode-row" key=${ep.id} onClick=${() => onPlay(ep)}>
+ <button key=${ep.id}
+ class="video-episode-row ${ep.id === lastPlayedId ? 'last-played' : ''}"
+ aria-current=${ep.id === lastPlayedId ? 'true' : undefined}
+ onClick=${() => { setLastPlayedId(ep.id); onPlay(ep); }}>
<${LazyTile} cls="video-episode-thumb-slot">
<${MediaThumb} thumbHash=${ep.thumb_hash} alt=${ep.display_title || ep.name}
cls="video-episode-thumb" transportRef=${ep._tRef || transportRef} gekRef=${ep._gRef || gekRef}
@@ -857,13 +867,18 @@ function PosterGrid({
</${LazyTile}>
`; })}
</div>
+ ${/* A show's modal is left open under the player: watching episode after
+ episode used to mean reopening the show and picking the season again
+ every time. A film has nothing left to pick, so its modal still
+ closes. The player stacks above it by its own z-index (style.css,
+ .video-player-overlay), not by DOM order. */''}
${detail && html`
<${VideoDetailModal} title=${detail.title} meta=${detailMeta}
repEntry=${detail.repEntry} show=${detail.show}
transportRef=${detailTRef} gekRef=${detailGRef} isNodeAdmin=${isNodeAdmin}
tmdbEnabled=${tmdbEnabled}
onClose=${() => setDetail(null)}
- onPlay=${(entry) => { setDetail(null); onPreview(entry); }} />
+ onPlay=${(entry) => { if (!detail.show) setDetail(null); onPreview(entry); }} />
`}
`;
}
@@ -976,6 +991,22 @@ function VideoApp({
useEffect(() => { setMode(loadViewMode()); }, [groupId]);
useEffect(() => { setFilter(''); setTypeFilter('all'); }, [groupId]);
+ // When the operator changes the node's TMDB language, the node drops its
+ // metadata cache and refetches in the new language, and — until a language
+ // is set — answers nothing at all rather than querying in English (§9.7).
+ // Tell every mounted tile to redo its media_meta_req and drop the
+ // show-level meta already merged here, so the grid switches language (or
+ // fills in for the first time, right after the operator picks one) without
+ // a page reload. Skip the initial mount: the language is already right then,
+ // and bumping would restorm TMDB on every open of the Videos tab.
+ const tmdbLanguage = tmdbConfig ? (tmdbConfig.language || '') : '';
+ const firstLangRef = useRef(true);
+ useEffect(() => {
+ if (firstLangRef.current) { firstLangRef.current = false; return; }
+ setMetaByGroup({});
+ bumpMediaMetaGeneration();
+ }, [tmdbLanguage]);
+
const setModeAndSave = (m) => { setMode(m); saveViewMode(m); };
const videoEntries = availableEntries || entries;
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/video-player.js b/packages/meshbay-hub/src/meshbay_hub/static/video-player.js
index 6eaacc5..fa10d15 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/video-player.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/video-player.js
@@ -270,12 +270,6 @@ function reconnectPlan(playhead, range, ended, castActive) {
return { mode: 'seek', at: playhead };
}
-function _mseSupported(codec) {
- if (!window.MediaSource) return false;
- const mime = `video/mp4; codecs="${codec}"`;
- return MediaSource.isTypeSupported(mime);
-}
-
/** Seconds as h:mm:ss, or m:ss under an hour. */
function formatClock(seconds) {
const s = Math.max(0, Math.floor(seconds || 0));
@@ -1596,7 +1590,7 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) {
}, []);
return html`
- <div class="video-overlay" onClick=${(e) => {
+ <div class="video-overlay video-player-overlay" onClick=${(e) => {
if (e.target.classList.contains('video-overlay')) onClose();
}}>
<div class="video-top-bar">