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.
This commit is contained in:
272
frontend/app.js
272
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 = `
|
||||
<p style="margin:0 0 12px;color:var(--text-2);font-size:13px;line-height:1.5">
|
||||
<strong>${escapeHtml(name)}</strong> is protected. ${wrong ? '<strong style="color:#ff6b5f">That was wrong.</strong> ' : ''}
|
||||
Enter its ${what}${kind === 'key' ? ', or import its credentials file' : ''}.
|
||||
</p>
|
||||
<input id="profileSecretInput" type="password" autocomplete="current-password" placeholder="${what}" />`;
|
||||
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 = `
|
||||
<p style="margin:0 0 12px;color:var(--text-2);font-size:13px;line-height:1.5">
|
||||
${prot ? 'Change or remove the protection on' : 'Protect'} <strong>${escapeHtml(data.profile.name)}</strong>.
|
||||
Once protected, the name alone no longer opens it — a device also needs the
|
||||
password, the profile key, or the credentials file. <strong>If you lose all of
|
||||
them the profile can't be opened.</strong>
|
||||
</p>
|
||||
<input id="profilePwInput" type="password" autocomplete="new-password" placeholder="New password (8+ characters)" style="margin-bottom:8px" />
|
||||
<input id="profilePwInput2" type="password" autocomplete="new-password" placeholder="Repeat the password" />`;
|
||||
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() {
|
||||
<button id="profileCopyLinkBtn" class="btn"${profileShareUrl() ? '' : ' style="display:none"'}>⧉ Copy link</button>
|
||||
<button id="profileShareBtn" class="btn"${profileShareUrl() ? '' : ' style="display:none"'}>↗ Share</button>
|
||||
<button id="profileUnlinkBtn" class="btn danger"${data.profile && data.profile.name ? '' : ' style="display:none"'}>Unlink</button>
|
||||
<button id="profileProtectBtn" class="btn"${data.profile && data.profile.name ? '' : ' style="display:none"'}>🔒 Protect…</button>
|
||||
<button id="profileExportCredBtn" class="btn" style="display:none">⤓ Credentials file</button>
|
||||
<button id="profileImportCredBtn" class="btn">⤒ Import credentials</button>
|
||||
<input id="profileCredFileInput" type="file" accept=".json,application/json" hidden />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
17
server/db.js
17
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
|
||||
|
||||
@@ -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: <new password/key> | 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) {
|
||||
|
||||
Reference in New Issue
Block a user