Let a shared playlist be sent to another user by name
This commit is contained in:
67
server/db.js
67
server/db.js
@@ -66,6 +66,21 @@ export async function initDb() {
|
||||
data TEXT NOT NULL DEFAULT '{}',
|
||||
created_at INTEGER NOT NULL DEFAULT (unixepoch())
|
||||
);
|
||||
|
||||
-- Playlists sent from one profile to another. Holds only a pointer to the
|
||||
-- shared_playlists row, never a copy of the videos, so a delivery costs a
|
||||
-- few bytes and the recipient decides whether to keep it.
|
||||
CREATE TABLE IF NOT EXISTS playlist_inbox (
|
||||
id TEXT PRIMARY KEY,
|
||||
to_name TEXT NOT NULL,
|
||||
from_name TEXT,
|
||||
code TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL DEFAULT (unixepoch())
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_inbox_to
|
||||
ON playlist_inbox (to_name, created_at DESC);
|
||||
`);
|
||||
}
|
||||
|
||||
@@ -136,6 +151,58 @@ export async function getSharedPlaylist(code) {
|
||||
return { data: row.data, createdAt: Number(row.created_at) };
|
||||
}
|
||||
|
||||
// ---- Playlist inbox (profile-to-profile sends) ------------------------------
|
||||
|
||||
// Queue a shared playlist for a recipient profile. Caller must have verified
|
||||
// the recipient exists; `code` must already be a row in shared_playlists.
|
||||
export async function queueInboxPlaylist({ id, toName, fromName, code, title }) {
|
||||
await db.execute({
|
||||
sql: `INSERT INTO playlist_inbox (id, to_name, from_name, code, title, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, unixepoch())`,
|
||||
args: [id, toName, fromName || null, code, title],
|
||||
});
|
||||
}
|
||||
|
||||
// Pending deliveries for a profile, oldest first so they are offered in the
|
||||
// order they were sent. Capped so a flooded inbox can't blow up the response.
|
||||
export async function listInbox(toName, limit = 20) {
|
||||
const r = await db.execute({
|
||||
sql: `SELECT id, from_name, code, title, created_at
|
||||
FROM playlist_inbox
|
||||
WHERE to_name = ?
|
||||
ORDER BY created_at ASC
|
||||
LIMIT ?`,
|
||||
args: [toName, limit],
|
||||
});
|
||||
return r.rows.map((row) => ({
|
||||
id: row.id,
|
||||
from: row.from_name || null,
|
||||
code: row.code,
|
||||
title: row.title,
|
||||
createdAt: Number(row.created_at),
|
||||
}));
|
||||
}
|
||||
|
||||
// Remove one delivery once the recipient has kept or dismissed it. Scoped by
|
||||
// to_name so knowing an id alone cannot clear someone else's inbox.
|
||||
export async function deleteInboxItem(toName, id) {
|
||||
const r = await db.execute({
|
||||
sql: 'DELETE FROM playlist_inbox WHERE to_name = ? AND id = ?',
|
||||
args: [toName, id],
|
||||
});
|
||||
return (r.rowsAffected || 0) > 0;
|
||||
}
|
||||
|
||||
// How many deliveries this recipient already has waiting — used to refuse a
|
||||
// send that would flood an inbox.
|
||||
export async function countInbox(toName) {
|
||||
const r = await db.execute({
|
||||
sql: 'SELECT COUNT(*) AS n FROM playlist_inbox WHERE to_name = ?',
|
||||
args: [toName],
|
||||
});
|
||||
return Number(r.rows[0] ? r.rows[0].n : 0);
|
||||
}
|
||||
|
||||
// ---- Helpers ---------------------------------------------------------------
|
||||
|
||||
// Upsert the users row and optionally update playlists.
|
||||
|
||||
@@ -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