Let a shared playlist be sent to another user by name
This commit is contained in:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user