Let a shared playlist be sent to another user by name

This commit is contained in:
Jonathan Sykes
2026-09-08 15:52:46 +08:00
parent 9366b1749c
commit 3e1b56610f
4 changed files with 310 additions and 10 deletions

View File

@@ -32,7 +32,7 @@ import { readFileSync, readdirSync, existsSync, statSync, openSync, unlinkSync,
import { Readable } from 'node:stream';
import { tmpdir } from 'node:os';
import { createHash } from 'node:crypto';
import { initDb, upsertUser, recordVideoAccess, getUserData, createProfile, getProfile, saveProfile, createSharedPlaylist, getSharedPlaylist } from './db.js';
import { initDb, upsertUser, recordVideoAccess, getUserData, createProfile, getProfile, saveProfile, createSharedPlaylist, getSharedPlaylist, queueInboxPlaylist, listInbox, deleteInboxItem, countInbox } from './db.js';
// A media proxy must not die because one client's stream hit an edge case
// (see /api/play cancel()): log and keep serving instead of crash-looping.
@@ -1135,6 +1135,95 @@ app.post('/api/playlist/share', async (c) => {
}
});
// ---- Playlist inbox --------------------------------------------------------
// Send an already-shared playlist to another profile by name, and let that
// profile pick up what was sent. A delivery stores only the share code, so
// these endpoints never move video data around.
//
// Auth note: a profile name IS the credential in this app (see the profiles
// table), so `name` alone authorises reading and clearing an inbox. That is
// the same trust level as /api/profile/load, which already returns a whole
// profile for a bare name — these routes add no new exposure.
const INBOX_MAX_PENDING = 25;
// POST /api/playlist/send { to, from, code }
app.post('/api/playlist/send', async (c) => {
let body;
try { body = await c.req.json(); } catch { return c.json({ ok: false, error: 'invalid JSON' }, 400); }
const to = String(body?.to || '').trim();
const from = String(body?.from || '').trim();
const code = String(body?.code || '').trim().toLowerCase();
if (!to) return c.json({ ok: false, error: 'missing recipient' }, 400);
if (!PROFILE_NAME_RE.test(to)) {
return c.json({ ok: false, error: 'invalid username — 3-40 characters: letters, digits, - or _' }, 400);
}
if (from && !PROFILE_NAME_RE.test(from)) {
return c.json({ ok: false, error: 'invalid sender name' }, 400);
}
if (to.toLowerCase() === from.toLowerCase()) {
return c.json({ ok: false, error: 'that is your own username' }, 400);
}
if (!code) return c.json({ ok: false, error: 'missing code' }, 400);
try {
// The share code must exist — this is also where the title comes from, so
// a sender cannot attach arbitrary text to someone else's inbox.
const shared = await getSharedPlaylist(code);
if (!shared) return c.json({ ok: false, error: 'shared playlist not found' }, 404);
let pl = null;
try { pl = JSON.parse(shared.data || '{}'); } catch { /* corrupt blob */ }
if (!pl || !pl.name) return c.json({ ok: false, error: 'shared playlist not found' }, 404);
if (!(await getProfile(to))) {
return c.json({ ok: false, error: `no user named “${to}”` }, 404);
}
if (await countInbox(to) >= INBOX_MAX_PENDING) {
return c.json({ ok: false, error: `“${to}” has too many unopened playlists` }, 429);
}
await queueInboxPlaylist({
id: randomPlaylistCode() + randomPlaylistCode(),
toName: to,
fromName: from || null,
code,
title: String(pl.name).slice(0, 200),
});
return c.json({ ok: true });
} catch (err) {
return c.json({ ok: false, error: err.message }, 500);
}
});
// GET /api/playlist/inbox?name=<profile>
app.get('/api/playlist/inbox', async (c) => {
const name = (c.req.query('name') || '').trim();
if (!name) return c.json({ ok: false, error: 'missing name' }, 400);
if (!PROFILE_NAME_RE.test(name)) return c.json({ ok: true, items: [] });
try {
return c.json({ ok: true, items: await listInbox(name) });
} catch (err) {
return c.json({ ok: false, error: err.message }, 500);
}
});
// POST /api/playlist/inbox/dismiss { name, id }
app.post('/api/playlist/inbox/dismiss', async (c) => {
let body;
try { body = await c.req.json(); } catch { return c.json({ ok: false, error: 'invalid JSON' }, 400); }
const name = String(body?.name || '').trim();
const id = String(body?.id || '').trim();
if (!name || !id) return c.json({ ok: false, error: 'missing name or id' }, 400);
try {
await deleteInboxItem(name, id);
return c.json({ ok: true });
} catch (err) {
return c.json({ ok: false, error: err.message }, 500);
}
});
// GET /api/playlist/shared?code=<code>
app.get('/api/playlist/shared', async (c) => {
const code = (c.req.query('code') || '').trim().toLowerCase();