Share target: shared YouTube links play, shared audio/video uploads to the user's library; page shortcuts
- manifest share_target (POST multipart: title/text/url + audio/video files) and eight shortcuts (Search, Queue, Saved, Downloads, History, Library, Notes, Settings). /?view=<page> now actually opens that page — the old shortcuts pointed at it but nothing read it. - sw.js receives the share: files are parked in the ytp-share-inbox cache (not a versioned shell cache, so deploys never evict it) and the app is opened; links/text go to /?shared=. A server POST /share-target fallback covers the first visit before a worker is in control (links only). - The app plays a YouTube link found anywhere in the shared text (watch, youtu.be, shorts, live, embed, music; keeps t=), otherwise searches the text. /?shared=<link> also works from an iOS Shortcut (iOS has no share target). - Shared files upload with progress to PUT /api/uploads/shared and join a "Shared uploads" playlist; network failures stay in the inbox for the next open, refusals are dropped with the reason. - The public route is bounded: audio/video only (ffprobe-validated, error text without server paths), 500 MB per file, 2 GB and 20 a day per device, 50 GB for all shared uploads, one at a time per device (all env-tunable). Shared uploads are unlisted: reachable by id, never in anyone else's search. uploads gains owner + listed columns (idempotent ALTER).
This commit is contained in:
@@ -112,7 +112,7 @@ export function registerUploadRoutes(app, deps) {
|
||||
|
||||
// Probe a file already on disk, extract cover/lyrics and register the row.
|
||||
// Shared by the multipart route (small files) and the streaming route.
|
||||
async function ingest({ id, ext, target, size, filename, fields = {}, artFile = null }) {
|
||||
async function ingest({ id, ext, target, size, filename, fields = {}, artFile = null, owner = null, listed = true }) {
|
||||
const probe = await probeFile(target);
|
||||
const info = describeProbe(probe, filename);
|
||||
if (!info.hasAudio && info.kind === 'audio') throw new Error('no audio or video streams found in this file');
|
||||
@@ -142,6 +142,7 @@ export function registerUploadRoutes(app, deps) {
|
||||
artist: String(fields.artist || info.artist || '').slice(0, 200),
|
||||
album: String(fields.album || info.album || '').slice(0, 200),
|
||||
duration: info.duration,
|
||||
owner, listed,
|
||||
};
|
||||
await db.createUpload(row);
|
||||
|
||||
@@ -225,6 +226,75 @@ export function registerUploadRoutes(app, deps) {
|
||||
}
|
||||
});
|
||||
|
||||
// Device-shared upload (PWA share target): ANY visitor may send a file, so it
|
||||
// is bounded — audio/video only (ffprobe-validated by ingest), a size cap per
|
||||
// file, a byte quota and a daily count per device, a ceiling for all shared
|
||||
// uploads together, one upload at a time per device — and it is unlisted:
|
||||
// reachable by its id (the sender's "Shared uploads" playlist), never in
|
||||
// anyone else's search results.
|
||||
// PUT /api/uploads/shared?name=song.m4a&title=&fp=<device fingerprint>
|
||||
const envNum = (k, d) => (process.env[k] ? Number(process.env[k]) : d);
|
||||
const SHARED = {
|
||||
fileBytes: envNum('SHARED_UPLOAD_MAX_BYTES', 500 * 1024 ** 2),
|
||||
deviceBytes: envNum('SHARED_UPLOAD_DEVICE_BYTES', 2 * 1024 ** 3),
|
||||
perDay: envNum('SHARED_UPLOAD_PER_DAY', 20),
|
||||
totalBytes: envNum('SHARED_UPLOAD_TOTAL_BYTES', 50 * 1024 ** 3),
|
||||
};
|
||||
const sharedActive = new Set();
|
||||
const mb = (n) => Math.round(n / 1048576) + ' MB';
|
||||
app.put('/api/uploads/shared', async (c) => {
|
||||
const fp = String(c.req.query('fp') || '');
|
||||
if (!/^[\w-]{8,128}$/.test(fp)) return c.json({ ok: false, error: 'missing device id' }, 400);
|
||||
const name = String(c.req.query('name') || '');
|
||||
const ext = extOf(name);
|
||||
if (!VIDEO_EXT.has(ext) && !AUDIO_EXT.has(ext)) return c.json({ ok: false, error: extError(ext) }, 415);
|
||||
const declared = Number(c.req.header('content-length')) || 0;
|
||||
if (declared > SHARED.fileBytes) return c.json({ ok: false, error: `shared files can be up to ${mb(SHARED.fileBytes)}` }, 413);
|
||||
if (sharedActive.has(fp)) return c.json({ ok: false, error: 'this device is already uploading — wait for it to finish' }, 429);
|
||||
const use = await db.sharedUploadUsage(fp);
|
||||
if (use.today >= SHARED.perDay) return c.json({ ok: false, error: `limit of ${SHARED.perDay} shared uploads a day reached` }, 429);
|
||||
if (use.bytes + declared > SHARED.deviceBytes) return c.json({ ok: false, error: `this device's ${mb(SHARED.deviceBytes)} of shared uploads is used up` }, 413);
|
||||
if (use.totalShared + declared > SHARED.totalBytes) return c.json({ ok: false, error: 'the server has no room for more shared uploads' }, 507);
|
||||
const reader = c.req.raw.body ? c.req.raw.body.getReader() : null;
|
||||
if (!reader) return c.json({ ok: false, error: 'empty body' }, 400);
|
||||
sharedActive.add(fp);
|
||||
const id = 'upl_' + randomBytes(6).toString('hex');
|
||||
const target = join(writeDir(), `${id}.${ext}`);
|
||||
try {
|
||||
let got = 0;
|
||||
const out = createWriteStream(target);
|
||||
try {
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
got += value.byteLength;
|
||||
if (got > SHARED.fileBytes || use.bytes + got > SHARED.deviceBytes) {
|
||||
throw Object.assign(new Error(`shared files can be up to ${mb(Math.min(SHARED.fileBytes, SHARED.deviceBytes - use.bytes))}`), { status: 413 });
|
||||
}
|
||||
if (!out.write(value)) await new Promise((r) => out.once('drain', r));
|
||||
}
|
||||
} finally {
|
||||
await new Promise((r) => out.end(r));
|
||||
}
|
||||
if (!got) throw Object.assign(new Error('empty file'), { status: 400 });
|
||||
if (declared > 0 && got !== declared) throw Object.assign(new Error(`upload cut short (${got} of ${declared} bytes)`), { status: 400 });
|
||||
let r;
|
||||
try {
|
||||
r = await ingest({ id, ext, target, size: got, filename: name,
|
||||
fields: { title: String(c.req.query('title') || '') }, owner: fp, listed: false });
|
||||
} catch (err) {
|
||||
// ffprobe's stderr names server paths — never hand that to a visitor.
|
||||
throw Object.assign(new Error('that file is not a playable audio or video file'), { status: 415 });
|
||||
}
|
||||
return c.json({ ok: true, ...r });
|
||||
} catch (err) {
|
||||
cleanup(id, target);
|
||||
return c.json({ ok: false, error: err.message }, err.status || 500);
|
||||
} finally {
|
||||
sharedActive.delete(fp);
|
||||
}
|
||||
});
|
||||
|
||||
// Attach / replace the cover of an existing upload (raw image body).
|
||||
app.put('/api/admin/uploads/:id/art', requireAdminOrToken, async (c) => {
|
||||
const id = c.req.param('id');
|
||||
|
||||
Reference in New Issue
Block a user