feat: add passkey-style online profiles for cross-device sync and show build time in About
This commit is contained in:
46
server/db.js
46
server/db.js
@@ -53,9 +53,55 @@ export async function initDb() {
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_vh_fp_time
|
||||
ON video_history (fingerprint, accessed_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS profiles (
|
||||
name TEXT PRIMARY KEY,
|
||||
data TEXT NOT NULL DEFAULT '{}',
|
||||
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
||||
updated_at INTEGER NOT NULL DEFAULT (unixepoch())
|
||||
);
|
||||
`);
|
||||
}
|
||||
|
||||
// ---- Profiles (named cross-device sync; the name acts as the passkey) ------
|
||||
|
||||
// Insert a new profile. Returns false when the name is already taken.
|
||||
export async function createProfile(name, dataJson) {
|
||||
try {
|
||||
await db.execute({
|
||||
sql: `INSERT INTO profiles (name, data, created_at, updated_at)
|
||||
VALUES (?, ?, unixepoch(), unixepoch())`,
|
||||
args: [name, dataJson],
|
||||
});
|
||||
return true;
|
||||
} catch (err) {
|
||||
const msg = String(err && err.message || err);
|
||||
if (msg.includes('UNIQUE') || msg.includes('PRIMARY KEY')) return false;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getProfile(name) {
|
||||
const r = await db.execute({
|
||||
sql: 'SELECT data, updated_at FROM profiles WHERE name = ?',
|
||||
args: [name],
|
||||
});
|
||||
const row = r.rows[0];
|
||||
if (!row) return null;
|
||||
return { data: row.data, updatedAt: Number(row.updated_at) };
|
||||
}
|
||||
|
||||
// Update an EXISTING profile's data blob. Returns false when it doesn't exist
|
||||
// (saving must never implicitly create a profile — creation is a deliberate,
|
||||
// uniqueness-checked act).
|
||||
export async function saveProfile(name, dataJson) {
|
||||
const r = await db.execute({
|
||||
sql: 'UPDATE profiles SET data = ?, updated_at = unixepoch() WHERE name = ?',
|
||||
args: [dataJson, name],
|
||||
});
|
||||
return (r.rowsAffected || 0) > 0;
|
||||
}
|
||||
|
||||
// ---- Helpers ---------------------------------------------------------------
|
||||
|
||||
// Upsert the users row and optionally update playlists.
|
||||
|
||||
103
server/server.js
103
server/server.js
@@ -28,7 +28,7 @@ import { readFileSync, readdirSync, statSync, openSync, unlinkSync, createReadSt
|
||||
import { Readable } from 'node:stream';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { initDb, upsertUser, recordVideoAccess, getUserData } from './db.js';
|
||||
import { initDb, upsertUser, recordVideoAccess, getUserData, createProfile, getProfile, saveProfile } from './db.js';
|
||||
|
||||
const PORT = parseInt(process.env.PORT || '3000', 10);
|
||||
const APP_VERSION = process.env.APP_VERSION || '1.0.0';
|
||||
@@ -77,6 +77,14 @@ function computeBuildTag() {
|
||||
|
||||
const BUILD_TAG = process.env.BUILD_TAG || computeBuildTag();
|
||||
|
||||
// BUILD_TIME — human-readable "when was this image built". Written by the
|
||||
// Dockerfile at image build time (never at container start, so restarts
|
||||
// don't drift it). Kept OUTSIDE ./public so it can't perturb BUILD_TAG.
|
||||
const BUILD_TIME = process.env.BUILD_TIME || (() => {
|
||||
try { return readFileSync('./build-time.txt', 'utf8').trim(); }
|
||||
catch { return null; }
|
||||
})();
|
||||
|
||||
const SEARCH_LIMIT = 25;
|
||||
const CHANNEL_LIMIT = 60;
|
||||
|
||||
@@ -178,7 +186,7 @@ app.use('*', logger());
|
||||
// Clients poll this to detect when a new build is live and prompt a reload.
|
||||
app.get('/api/version', (c) =>
|
||||
c.json(
|
||||
{ version: APP_VERSION, buildTag: BUILD_TAG },
|
||||
{ version: APP_VERSION, buildTag: BUILD_TAG, buildTime: BUILD_TIME },
|
||||
200,
|
||||
{ 'Cache-Control': 'no-store, no-cache, must-revalidate' }
|
||||
)
|
||||
@@ -399,6 +407,97 @@ app.get('/api/download/:videoId', async (c) => {
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Online profiles — named cross-device sync. The NAME IS THE PASSKEY:
|
||||
// anyone who knows it can load and overwrite that profile, so clients are
|
||||
// encouraged to use the random generator. No other auth by design.
|
||||
// ============================================================================
|
||||
const PROFILE_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{2,39}$/;
|
||||
const PROFILE_MAX_BYTES = 2_000_000; // full data blob; typical payloads are ~KBs
|
||||
|
||||
const RAND_ADJ = ['amber', 'brave', 'calm', 'coral', 'crimson', 'dusty', 'gentle', 'golden',
|
||||
'hidden', 'ivory', 'jade', 'lunar', 'mellow', 'misty', 'noble', 'quiet',
|
||||
'rapid', 'silver', 'solar', 'stormy', 'swift', 'velvet', 'wild', 'zesty'];
|
||||
const RAND_NOUN = ['falcon', 'harbor', 'willow', 'ember', 'canyon', 'meadow', 'otter', 'pine',
|
||||
'raven', 'reef', 'sparrow', 'summit', 'thicket', 'tundra', 'brook', 'cedar',
|
||||
'dune', 'fjord', 'glade', 'heron', 'lagoon', 'maple', 'prairie', 'wren'];
|
||||
function randomProfileName() {
|
||||
const a = RAND_ADJ[Math.floor(Math.random() * RAND_ADJ.length)];
|
||||
const n = RAND_NOUN[Math.floor(Math.random() * RAND_NOUN.length)];
|
||||
return `${a}-${n}-${1000 + Math.floor(Math.random() * 9000)}`;
|
||||
}
|
||||
|
||||
// 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.
|
||||
app.post('/api/profile/create', async (c) => {
|
||||
let body;
|
||||
try { body = await c.req.json(); } catch { return c.json({ ok: false, error: 'invalid JSON' }, 400); }
|
||||
|
||||
let name = (body.name || '').trim().toLowerCase();
|
||||
const dataJson = JSON.stringify(body.data && typeof body.data === 'object' ? body.data : {});
|
||||
if (dataJson.length > PROFILE_MAX_BYTES) return c.json({ ok: false, error: 'profile data too large' }, 413);
|
||||
|
||||
try {
|
||||
if (name) {
|
||||
if (!PROFILE_NAME_RE.test(name)) {
|
||||
return c.json({ ok: false, error: 'invalid name — 3-40 characters: letters, digits, - or _' }, 400);
|
||||
}
|
||||
if (!(await createProfile(name, dataJson))) {
|
||||
return c.json({ ok: false, error: `“${name}” is already taken — pick another name` }, 409);
|
||||
}
|
||||
} else {
|
||||
let created = false;
|
||||
for (let tries = 0; tries < 20 && !created; tries++) {
|
||||
name = randomProfileName();
|
||||
created = await createProfile(name, dataJson);
|
||||
}
|
||||
if (!created) return c.json({ ok: false, error: 'could not generate a unique name — try again' }, 500);
|
||||
}
|
||||
const row = await getProfile(name);
|
||||
return c.json({ ok: true, name, updatedAt: row ? row.updatedAt : 0 });
|
||||
} catch (err) {
|
||||
return c.json({ ok: false, error: err.message }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/profile/load?name=<name>
|
||||
app.get('/api/profile/load', async (c) => {
|
||||
const name = (c.req.query('name') || '').trim().toLowerCase();
|
||||
if (!name) return c.json({ ok: false, error: 'missing name' }, 400);
|
||||
try {
|
||||
const row = await getProfile(name);
|
||||
if (!row) return c.json({ ok: false, error: 'profile not found' }, 404);
|
||||
let data = {};
|
||||
try { data = JSON.parse(row.data || '{}'); } catch { /* corrupt blob — hand back empty */ }
|
||||
return c.json({ ok: true, name, data, updatedAt: row.updatedAt });
|
||||
} catch (err) {
|
||||
return c.json({ ok: false, error: err.message }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/profile/save
|
||||
// Body: { name, data } — updates an EXISTING profile only (404 otherwise).
|
||||
app.post('/api/profile/save', 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();
|
||||
if (!name) return c.json({ ok: false, error: 'missing name' }, 400);
|
||||
const dataJson = JSON.stringify(body.data && typeof body.data === 'object' ? body.data : {});
|
||||
if (dataJson.length > PROFILE_MAX_BYTES) return c.json({ ok: false, error: 'profile data too large' }, 413);
|
||||
|
||||
try {
|
||||
if (!(await saveProfile(name, dataJson))) {
|
||||
return c.json({ ok: false, error: 'profile not found' }, 404);
|
||||
}
|
||||
const row = await getProfile(name);
|
||||
return c.json({ ok: true, updatedAt: row ? row.updatedAt : 0 });
|
||||
} catch (err) {
|
||||
return c.json({ ok: false, error: err.message }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/user/sync
|
||||
// Body: { fingerprint, playlists?, recentVideo?, appVersion? }
|
||||
app.post('/api/user/sync', async (c) => {
|
||||
|
||||
Reference in New Issue
Block a user