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:
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