From 71f1913d0b4bbd90e322ee704f2db67c0b5edfb5 Mon Sep 17 00:00:00 2001 From: Jonathan Sykes Date: Sat, 3 Oct 2026 01:51:07 +0800 Subject: [PATCH] Optional profile password or key, with a credentials file to sign in other devices Profiles without protection behave as before. Protected profiles store only an argon2id hash; load, save, protection changes and the playlist inbox need the secret (X-Profile-Secret), wrong guesses are throttled per profile and IP. Settings: Protect (password or generated key), change/remove, export and import a credentials file; loading a protected profile prompts for the secret. --- frontend/app.js | 272 +++++++++++++++++++++++++++++++++++++++++------ server/db.js | 17 ++- server/server.js | 78 +++++++++++++- 3 files changed, 330 insertions(+), 37 deletions(-) diff --git a/frontend/app.js b/frontend/app.js index adbacf4..900fec8 100755 --- a/frontend/app.js +++ b/frontend/app.js @@ -721,6 +721,68 @@ function profilePayload() { }; } +// ---- Optional profile protection ---------------------------------------- +// The profile's password / key lives only in this device's localStorage (never +// in the synced data blob) and rides along as X-Profile-Secret. +const ProfileSecret = (() => { + const KEY = 'ytpProfileSecret'; + const read = () => { try { return JSON.parse(localStorage.getItem(KEY) || 'null'); } catch { return null; } }; + return { + getFor(name) { const j = read(); return j && name && j.name === String(name).toLowerCase() ? j.secret : null; }, + kindFor(name) { const j = read(); return j && name && j.name === String(name).toLowerCase() ? j.kind : null; }, + set(name, secret, kind) { try { localStorage.setItem(KEY, JSON.stringify({ name: String(name).toLowerCase(), secret, kind })); } catch { /* blocked */ } }, + clear() { try { localStorage.removeItem(KEY); } catch { /* blocked */ } }, + }; +})(); +function profileHeaders(name, extra = {}) { + const s = ProfileSecret.getFor(name); + return s ? { ...extra, 'X-Profile-Secret': s } : extra; +} + +// Ask for a protected profile's password or key. Resolves the text or null. +function askProfileSecret(name, kind, wrong) { + return new Promise((resolve) => { + const body = document.createElement('div'); + const what = kind === 'key' ? 'profile key' : 'password'; + body.innerHTML = ` +

+ ${escapeHtml(name)} is protected. ${wrong ? 'That was wrong. ' : ''} + Enter its ${what}${kind === 'key' ? ', or import its credentials file' : ''}. +

+ `; + const done = (v) => { closeModal(); resolve(v); }; + showModal('πŸ”’ Protected profile', body, [ + { label: 'Cancel', onClick: () => done(null) }, + { label: 'Import file', onClick: () => { closeModal(); resolve(null); importProfileCredentials(); } }, + { label: 'Open', primary: true, onClick: () => { const v = ($('profileSecretInput').value || '').trim(); if (v) done(v); } }, + ]); + setTimeout(() => $('profileSecretInput') && $('profileSecretInput').focus(), 50); + $('profileSecretInput').addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); const v = e.target.value.trim(); if (v) done(v); } }); + }); +} + +// Load a profile, asking for its password/key when it is protected. +// Resolves the server reply ({ ok, name, data, … }) or null when cancelled. +async function fetchProfile(name, { prompt = true, secret = null } = {}) { + let tryWith = secret || ProfileSecret.getFor(name); + let wrong = false; + for (let i = 0; i < 6; i++) { + const headers = tryWith ? { 'X-Profile-Secret': tryWith } : {}; + const res = await fetch(`/api/profile/load?name=${encodeURIComponent(name)}`, { headers }); + const j = await res.json().catch(() => null); + if (res.status === 401 && j && j.needSecret) { + if (!prompt) return j; + wrong = !!tryWith; + tryWith = await askProfileSecret(name, j.kind, wrong); + if (!tryWith) return null; + continue; + } + if (j && j.ok && j.protected && tryWith) ProfileSecret.set(j.name, tryWith, j.kind); + return j; + } + return null; +} + let profilePushTimer = null; function scheduleProfilePush() { if (!WEB || !data.profile || !data.profile.name) return; @@ -733,10 +795,14 @@ async function pushProfile() { try { const res = await fetch('/api/profile/save', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: profileHeaders(data.profile.name, { 'Content-Type': 'application/json' }), body: JSON.stringify({ name: data.profile.name, data: profilePayload() }), }); const j = await res.json().catch(() => null); + if (res.status === 401 && j && j.needSecret) { + if (!pushProfile._warned) { pushProfile._warned = true; toast('πŸ”’ This profile is protected β€” enter its password in Settings β†’ Profile to keep syncing', { duration: 6000 }); } + return; + } if (j && j.ok) { data.profile.syncedAt = j.updatedAt || 0; // Record syncedAt directly β€” going through persist() would re-schedule @@ -761,7 +827,7 @@ function applyProfileData(name, payload, updatedAt) { if (payload.lyricOffsets && typeof payload.lyricOffsets === 'object') data.lyricOffsets = payload.lyricOffsets; if (payload.stats && typeof payload.stats === 'object') data.stats = payload.stats; if (payload.settings && typeof payload.settings === 'object') data.settings = { ...DEFAULT_SETTINGS, ...payload.settings }; - data.profile = { name, syncedAt: updatedAt || 0 }; + data.profile = { name, syncedAt: updatedAt || 0, protected: !!payload.__protected, kind: payload.__kind || null }; API.saveData(data).catch(() => {}); } @@ -808,12 +874,21 @@ function playlistFingerprint() { async function pullProfileIfNewer() { if (!WEB || !data.profile || !data.profile.name) return; try { - const res = await fetch(`/api/profile/load?name=${encodeURIComponent(data.profile.name)}`); + const res = await fetch(`/api/profile/load?name=${encodeURIComponent(data.profile.name)}`, { headers: profileHeaders(data.profile.name) }); if (res.status === 404) return; // profile gone server-side; keep local data const j = await res.json().catch(() => null); + if (res.status === 401 && j && j.needSecret) { + // Protected since this device last synced (or the password changed): + // keep the local copy and say how to get back in. + data.profile.protected = true; + toast('πŸ”’ Your profile is now protected β€” enter its password in Settings β†’ Profile to sync', { duration: 6000 }); + return; + } if (!j || !j.ok) return; + data.profile.protected = !!j.protected; + data.profile.kind = j.kind || null; if ((j.updatedAt || 0) > (data.profile.syncedAt || 0)) { - applyProfileData(j.name, j.data, j.updatedAt); + applyProfileData(j.name, { ...j.data, __protected: j.protected, __kind: j.kind }, j.updatedAt); } else { scheduleProfilePush(); } @@ -872,6 +947,14 @@ function updateProfileStatus() { if (el) el.textContent = (linked && data.profile.name) || 'Not linked'; const unlink = document.getElementById('profileUnlinkBtn'); if (unlink) unlink.style.display = linked ? '' : 'none'; + const prot = linked && !!data.profile.protected; + const haveSecret = linked && !!ProfileSecret.getFor(data.profile.name); + if (el && prot) el.textContent += ' Β· πŸ”’ protected'; + const pb = document.getElementById('profileProtectBtn'); + if (pb) { pb.style.display = linked ? '' : 'none'; pb.textContent = prot ? 'πŸ”‘ Protection…' : 'πŸ”’ Protect…'; } + // The credentials file only exists for a protected profile whose secret this device knows. + const eb = document.getElementById('profileExportCredBtn'); + if (eb) eb.style.display = prot && haveSecret ? '' : 'none'; // Settings β†’ share-link row (only rendered on the settings view) const linkRow = document.getElementById('profileLinkRow'); @@ -1149,7 +1232,7 @@ async function drainPlaylistInbox(me) { let items = []; try { - const res = await fetch(`/api/playlist/inbox?name=${encodeURIComponent(me)}`); + const res = await fetch(`/api/playlist/inbox?name=${encodeURIComponent(me)}`, { headers: profileHeaders(me) }); const j = await res.json().catch(() => null); if (!j || !j.ok || !Array.isArray(j.items)) return; items = j.items; @@ -1163,7 +1246,7 @@ async function drainPlaylistInbox(me) { try { await fetch('/api/playlist/inbox/dismiss', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: profileHeaders(me, { 'Content-Type': 'application/json' }), body: JSON.stringify({ name: me, id: item.id }), }); } catch { /* leave it queued; a duplicate prompt beats losing the playlist */ } @@ -1208,14 +1291,13 @@ async function adoptProfileFromUrl() { } try { - const res = await fetch(`/api/profile/load?name=${encodeURIComponent(name)}`); - const j = await res.json().catch(() => null); - if (!j || !j.ok) { - toast('⚠ ' + ((j && j.error) || 'Profile not found')); + const j = await fetchProfile(name); + if (!j) return; // cancelled at the password prompt + if (!j.ok) { + toast('⚠ ' + (j.error || 'Profile not found')); return; } - applyProfileData(j.name, j.data, j.updatedAt); - toast(`Profile β€œ${j.name}” loaded βœ“ β€” this device now syncs to it`, { duration: 4000 }); + finishProfileLoad(j); } catch { toast('⚠ Network error β€” could not open that profile'); } @@ -1268,6 +1350,135 @@ async function requestCreateProfile(name) { } } +// ---- Protect / credentials file ---- +function randomProfileKey() { + const b = new Uint8Array(32); + crypto.getRandomValues(b); + return btoa(String.fromCharCode(...b)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +} + +async function setProfileSecretOnServer(secret, kind) { + const name = data.profile.name; + const res = await fetch('/api/profile/secret', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name, current: ProfileSecret.getFor(name), secret, kind }), + }); + const j = await res.json().catch(() => ({})); + if (!res.ok || !j.ok) throw new Error(j.error || 'could not change protection'); + if (secret) ProfileSecret.set(name, secret, kind); else ProfileSecret.clear(); + data.profile.protected = !!secret; + data.profile.kind = secret ? kind : null; + persist(); + updateProfileStatus(); +} + +function protectProfileFlow() { + if (!data.profile || !data.profile.name) return; + const prot = !!data.profile.protected; + if (prot && !ProfileSecret.getFor(data.profile.name)) { + toast('πŸ”’ Load this profile with its password or credentials file first'); + return; + } + const body = document.createElement('div'); + body.innerHTML = ` +

+ ${prot ? 'Change or remove the protection on' : 'Protect'} ${escapeHtml(data.profile.name)}. + Once protected, the name alone no longer opens it β€” a device also needs the + password, the profile key, or the credentials file. If you lose all of + them the profile can't be opened. +

+ + `; + const actions = [ + { label: 'Cancel', onClick: closeModal }, + { label: 'πŸ”‘ Generate key', onClick: async () => { + try { + await setProfileSecretOnServer(randomProfileKey(), 'key'); + closeModal(); + toast('Profile key set βœ“ β€” export the credentials file now so other devices can open it', { duration: 5000 }); + exportProfileCredentials(); + } catch (e) { toast('⚠ ' + e.message); } + } }, + { label: 'Set password', primary: true, onClick: async () => { + const a = $('profilePwInput').value, b = $('profilePwInput2').value; + if (a.length < 8) { toast('⚠ Use at least 8 characters'); return; } + if (a !== b) { toast('⚠ The two passwords differ'); return; } + try { + await setProfileSecretOnServer(a, 'password'); + closeModal(); + toast('Password set βœ“ β€” other devices will ask for it', { duration: 4000 }); + } catch (e) { toast('⚠ ' + e.message); } + } }, + ]; + if (prot) actions.splice(1, 0, { label: 'Remove', danger: true, onClick: async () => { + try { await setProfileSecretOnServer(null, null); closeModal(); toast('Protection removed β€” the name alone opens this profile again'); } + catch (e) { toast('⚠ ' + e.message); } + } }); + showModal(prot ? 'πŸ”‘ Profile protection' : 'πŸ”’ Protect profile', body, actions); + setTimeout(() => $('profilePwInput') && $('profilePwInput').focus(), 50); +} + +// A small JSON file that signs another device into this profile. +async function exportProfileCredentials() { + const name = data.profile && data.profile.name; + const secret = name && ProfileSecret.getFor(name); + if (!secret) { toast('⚠ Protect the profile first β€” the credentials file needs its password or key'); return; } + const cred = { type: 'ytplayer-profile-credentials', version: 1, origin: location.origin, name, + kind: ProfileSecret.kindFor(name) || 'password', secret, exportedAt: new Date().toISOString() }; + const fileName = `ytplayer-${name}.credentials.json`; + const blob = new Blob([JSON.stringify(cred, null, 2) + '\n'], { type: 'application/json' }); + const file = new File([blob], fileName, { type: 'application/json' }); + if (navigator.canShare && navigator.canShare({ files: [file] }) && /iPad|iPhone|iPod|Android/.test(navigator.userAgent)) { + try { await navigator.share({ files: [file], title: fileName }); return; } catch { /* fall back to a download */ } + } + const a = document.createElement('a'); + a.href = URL.createObjectURL(blob); + a.download = fileName; + document.body.appendChild(a); + a.click(); + setTimeout(() => { URL.revokeObjectURL(a.href); a.remove(); }, 1000); + toast('πŸ”‘ Credentials file saved β€” anyone with it can open your profile, keep it private', { duration: 5000 }); +} + +function importProfileCredentials() { + const input = $('profileCredFileInput') || Object.assign(document.createElement('input'), { type: 'file', accept: '.json,application/json' }); + input.value = ''; + input.onchange = async () => { + const f = input.files && input.files[0]; + if (!f) return; + let cred; + try { cred = JSON.parse(await f.text()); } catch { toast('⚠ That is not a credentials file'); return; } + if (!cred || cred.type !== 'ytplayer-profile-credentials' || !cred.name || !cred.secret) { toast('⚠ That is not a YT Player credentials file'); return; } + if (cred.origin && cred.origin !== location.origin) toast(`Note: this file was made on ${cred.origin}`, { duration: 4000 }); + try { + const j = await fetchProfile(String(cred.name).toLowerCase(), { prompt: false, secret: cred.secret }); + if (!j || !j.ok) { toast('⚠ ' + ((j && j.error) || 'Could not open that profile')); return; } + ProfileSecret.set(j.name, cred.secret, cred.kind || j.kind || 'password'); + finishProfileLoad(j); + } catch { toast('⚠ Network error β€” try again'); } + }; + input.click(); +} + +// Adopt a loaded profile on this device and re-apply everything it drives. +function finishProfileLoad(j) { + applyProfileData(j.name, { ...j.data, __protected: j.protected, __kind: j.kind }, j.updatedAt); + applyAppearance(); + updateLoopRepeatButtons(); + updateQueueBadge(); + els.volume.value = String(data.settings.volume ?? 1); + els.quality.value = data.settings.quality || 'auto'; + els.audioOnlyToggle.checked = !!data.settings.audioOnly; + renderSidebar(); + renderSmartSidebar(); + render(); + updateProfileStatus(); + data.playlists.forEach(preloadPlaylist); + preloadPinnedPlaylists(); + toast(`Profile β€œ${j.name}” loaded βœ“ β€” this device now syncs to it`, { duration: 4000 }); +} + function loadProfileFlow() { const body = document.createElement('div'); body.innerHTML = ` @@ -1283,28 +1494,14 @@ function loadProfileFlow() { const name = ($('profileNameInput').value || '').trim(); if (!name) return; try { - const res = await fetch(`/api/profile/load?name=${encodeURIComponent(name)}`); - const j = await res.json().catch(() => null); - if (!j || !j.ok) { - toast('⚠ ' + ((j && j.error) || 'Profile not found')); - return; // keep the modal open - } closeModal(); - applyProfileData(j.name, j.data, j.updatedAt); - // Re-apply everything the loaded data drives. - applyAppearance(); - updateLoopRepeatButtons(); - updateQueueBadge(); - els.volume.value = String(data.settings.volume ?? 1); - els.quality.value = data.settings.quality || 'auto'; - els.audioOnlyToggle.checked = !!data.settings.audioOnly; - renderSidebar(); - renderSmartSidebar(); - render(); - updateProfileStatus(); - data.playlists.forEach(preloadPlaylist); - preloadPinnedPlaylists(); - toast(`Profile β€œ${j.name}” loaded βœ“ β€” this device now syncs to it`, { duration: 4000 }); + const j = await fetchProfile(name); + if (!j) return; // cancelled at the password prompt + if (!j.ok) { + toast('⚠ ' + (j.error || 'Profile not found')); + return; + } + finishProfileLoad(j); } catch { toast('⚠ Network error β€” try again'); } @@ -8854,6 +9051,10 @@ async function renderSettings() { + + + + @@ -9072,12 +9273,17 @@ async function renderSettings() { $('profileLoadBtn').addEventListener('click', loadProfileFlow); $('profileCopyLinkBtn').addEventListener('click', copyProfileLink); $('profileShareBtn').addEventListener('click', shareProfileLink); + $('profileProtectBtn').addEventListener('click', protectProfileFlow); + $('profileExportCredBtn').addEventListener('click', exportProfileCredentials); + $('profileImportCredBtn').addEventListener('click', importProfileCredentials); $('profileUnlinkBtn').addEventListener('click', () => { data.profile = null; + ProfileSecret.clear(); persist(); updateProfileStatus(); toast('Profile unlinked β€” this device stops syncing (server copy is kept)'); }); + updateProfileStatus(); // ---- About: append server build tag + build time (WEB mode only) ---- if (WEB) { diff --git a/server/db.js b/server/db.js index 1bafff2..106e331 100644 --- a/server/db.js +++ b/server/db.js @@ -217,6 +217,11 @@ export async function initDb() { for (const col of ['owner TEXT', 'listed INTEGER NOT NULL DEFAULT 1']) { try { await db.execute(`ALTER TABLE uploads ADD COLUMN ${col}`); } catch { /* already there */ } } + // Optional profile protection: a password or generated key, stored only as a + // slow salted hash (Bun.password / argon2id). NULL = the name alone opens it. + for (const col of ['secret_hash TEXT', 'secret_kind TEXT']) { + try { await db.execute(`ALTER TABLE profiles ADD COLUMN ${col}`); } catch { /* already there */ } + } } // ---- Shared video notes (lyrics / chapters) --------------------------------- @@ -541,12 +546,20 @@ export async function createProfile(name, dataJson) { export async function getProfile(name) { const r = await db.execute({ - sql: 'SELECT data, updated_at FROM profiles WHERE name = ?', + sql: 'SELECT data, updated_at, secret_hash, secret_kind FROM profiles WHERE name = ?', args: [name], }); const row = r.rows[0]; if (!row) return null; - return { data: row.data, updatedAt: Number(row.updated_at) }; + return { data: row.data, updatedAt: Number(row.updated_at), secretHash: row.secret_hash || null, secretKind: row.secret_kind || null }; +} + +export async function setProfileSecret(name, hash, kind) { + const r = await db.execute({ + sql: 'UPDATE profiles SET secret_hash = ?, secret_kind = ? WHERE name = ?', + args: [hash || null, hash ? kind : null, name], + }); + return (r.rowsAffected || 0) > 0; } // Update an EXISTING profile's data blob. Returns false when it doesn't exist diff --git a/server/server.js b/server/server.js index ed6c9db..578c925 100644 --- a/server/server.js +++ b/server/server.js @@ -40,7 +40,7 @@ import { Readable } from 'node:stream'; import { tmpdir } from 'node:os'; import { createHash } from 'node:crypto'; import { brotliCompressSync, constants as zlibConstants } from 'node:zlib'; -import { initDb, upsertUser, recordVideoAccess, getUserData, createProfile, getProfile, saveProfile, createSharedPlaylist, getSharedPlaylist, queueInboxPlaylist, listInbox, deleteInboxItem, countInbox, +import { initDb, upsertUser, recordVideoAccess, getUserData, createProfile, getProfile, saveProfile, setProfileSecret, createSharedPlaylist, getSharedPlaylist, queueInboxPlaylist, listInbox, deleteInboxItem, countInbox, getMedia, upsertMedia, deleteMedia, listMedia, listMediaLru, touchMedia, mediaStats } from './db.js'; import { createMediaCache, HIGH, LOW, validateMedia, MediaSkip } from './media-cache.js'; import * as notesDb from './db.js'; @@ -1722,6 +1722,70 @@ function randomProfileName() { return `${a}-${n}-${1000 + Math.floor(Math.random() * 9000)}`; } +// ---- Optional protection (password or generated key) ---- +// A profile without a secret behaves as before. With one, load/save/changing it +// need the secret (X-Profile-Secret header or `secret` in the body). Wrong +// guesses are throttled per profile+IP, so a name alone can't be brute-forced. +const profileFails = new Map(); // `${name}|${ip}` β†’ { n, until } +const PROFILE_FAIL_LIMIT = 10, PROFILE_FAIL_WINDOW = 15 * 60_000; +const clientIp = (c) => (c.req.header('x-forwarded-for') || c.req.header('x-real-ip') || '').split(',')[0].trim() || 'local'; +async function profileGate(c, name, row, provided) { + if (!row || !row.secretHash) return null; + const key = `${name}|${clientIp(c)}`; + const f = profileFails.get(key); + if (f && f.n >= PROFILE_FAIL_LIMIT && Date.now() < f.until) { + return c.json({ ok: false, error: 'too many wrong attempts β€” try again in 15 minutes' }, 429); + } + const secret = String(provided || ''); + if (!secret) return c.json({ ok: false, needSecret: true, kind: row.secretKind, error: 'this profile is protected' }, 401); + let ok = false; + try { ok = await Bun.password.verify(secret, row.secretHash); } catch { ok = false; } + if (ok) { profileFails.delete(key); return null; } + const n = f && Date.now() < f.until ? f.n + 1 : 1; + profileFails.set(key, { n, until: Date.now() + PROFILE_FAIL_WINDOW }); + if (profileFails.size > 5000) profileFails.clear(); + return c.json({ ok: false, needSecret: true, wrong: true, kind: row.secretKind, + error: row.secretKind === 'key' ? 'wrong profile key' : 'wrong password' }, 401); +} + +// GET /api/profile/info?name= β€” exists / protected, never the data. +app.get('/api/profile/info', async (c) => { + const name = (c.req.query('name') || '').trim().toLowerCase(); + if (!name) return c.json({ ok: false, error: 'missing name' }, 400); + const row = await getProfile(name); + return c.json({ ok: true, exists: !!row, protected: !!(row && row.secretHash), kind: row ? row.secretKind : null }, + 200, { 'Cache-Control': 'no-store' }); +}); + +// POST /api/profile/secret +// Body: { name, current?, secret: | null, kind: 'password'|'key' } +// Adds, changes or (secret: null) removes protection. Changing or removing an +// existing one needs the current secret. +app.post('/api/profile/secret', async (c) => { + let body; + try { body = await c.req.json(); } catch { return c.json({ ok: false, error: 'invalid JSON' }, 400); } + const name = (body.name || '').trim().toLowerCase(); + const row = name ? await getProfile(name) : null; + if (!row) return c.json({ ok: false, error: 'profile not found' }, 404); + const denied = await profileGate(c, name, row, body.current || c.req.header('x-profile-secret')); + if (denied) return denied; + if (body.secret === null) { + await setProfileSecret(name, null, null); + return c.json({ ok: true, protected: false }); + } + const kind = body.kind === 'key' ? 'key' : 'password'; + const secret = String(body.secret || ''); + if (kind === 'password' && (secret.length < 8 || secret.length > 200)) { + return c.json({ ok: false, error: 'password must be 8–200 characters' }, 400); + } + if (kind === 'key' && !/^[A-Za-z0-9_-]{32,128}$/.test(secret)) { + return c.json({ ok: false, error: 'invalid profile key' }, 400); + } + const hash = await Bun.password.hash(secret); + await setProfileSecret(name, hash, kind); + return c.json({ ok: true, protected: true, kind }); +}); + // POST /api/profile/create // Body: { name?, data? } β€” empty/absent name asks the server to generate a // unique random one. Fails with 409 when the requested name is taken. @@ -1763,9 +1827,11 @@ app.get('/api/profile/load', async (c) => { try { const row = await getProfile(name); if (!row) return c.json({ ok: false, error: 'profile not found' }, 404); + const denied = await profileGate(c, name, row, c.req.header('x-profile-secret')); + if (denied) return denied; let data = {}; try { data = JSON.parse(row.data || '{}'); } catch { /* corrupt blob β€” hand back empty */ } - return c.json({ ok: true, name, data, updatedAt: row.updatedAt }); + return c.json({ ok: true, name, data, updatedAt: row.updatedAt, protected: !!row.secretHash, kind: row.secretKind }); } catch (err) { return c.json({ ok: false, error: err.message }, 500); } @@ -1783,6 +1849,10 @@ app.post('/api/profile/save', async (c) => { if (dataJson.length > PROFILE_MAX_BYTES) return c.json({ ok: false, error: 'profile data too large' }, 413); try { + const existing = await getProfile(name); + if (!existing) return c.json({ ok: false, error: 'profile not found' }, 404); + const denied = await profileGate(c, name, existing, body.secret || c.req.header('x-profile-secret')); + if (denied) return denied; if (!(await saveProfile(name, dataJson))) { return c.json({ ok: false, error: 'profile not found' }, 404); } @@ -1952,6 +2022,8 @@ app.get('/api/playlist/inbox', async (c) => { if (!name) return c.json({ ok: false, error: 'missing name' }, 400); if (!PROFILE_NAME_RE.test(name)) return c.json({ ok: true, items: [] }); try { + const denied = await profileGate(c, name.toLowerCase(), await getProfile(name.toLowerCase()), c.req.header('x-profile-secret')); + if (denied) return denied; return c.json({ ok: true, items: await listInbox(name) }); } catch (err) { return c.json({ ok: false, error: err.message }, 500); @@ -1966,6 +2038,8 @@ app.post('/api/playlist/inbox/dismiss', async (c) => { const id = String(body?.id || '').trim(); if (!name || !id) return c.json({ ok: false, error: 'missing name or id' }, 400); try { + const denied = await profileGate(c, name.toLowerCase(), await getProfile(name.toLowerCase()), body.secret || c.req.header('x-profile-secret')); + if (denied) return denied; await deleteInboxItem(name, id); return c.json({ ok: true }); } catch (err) {