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:
135
frontend/app.js
135
frontend/app.js
@@ -11287,6 +11287,139 @@ async function registerServiceWorker() {
|
||||
// /?q=<query> — a shareable search link (same family as Share's /?v=&t=).
|
||||
// Runs after wireUI() so the search form's submit handler exists; the param
|
||||
// is stripped immediately so a reload doesn't re-run the search.
|
||||
// ============================================================================
|
||||
// Share target — links and audio/video files shared to the installed app
|
||||
// (manifest share_target → sw.js receiveShare → here). Android / ChromeOS /
|
||||
// desktop Chrome; iOS has no share target, but an iOS Shortcut can open
|
||||
// /?shared=<link> and land in the same place.
|
||||
// ============================================================================
|
||||
// YouTube video id (+ start time) anywhere in a shared text.
|
||||
function youtubeFromText(text) {
|
||||
const t = String(text || '');
|
||||
const m = /(?:youtu\.be\/|youtube(?:-nocookie)?\.com\/(?:watch\?(?:[^\s#]*&)?v=|shorts\/|live\/|embed\/|v\/))([\w-]{11})/i.exec(t);
|
||||
if (!m) return null;
|
||||
const tm = /[?&#](?:t|start)=(\d+)(?:s)?/.exec(t.slice(m.index));
|
||||
return { id: m[1], t: tm ? Number(tm[1]) : 0 };
|
||||
}
|
||||
|
||||
const SHARE_INBOX = 'ytp-share-inbox';
|
||||
const SHARED_PLAYLIST = 'Shared uploads';
|
||||
|
||||
function sharedUploadsPlaylist() {
|
||||
let pl = data.playlists.find((p) => p.sharedUploads);
|
||||
if (!pl) {
|
||||
pl = { id: uid(), name: SHARED_PLAYLIST, videos: [], sharedUploads: true };
|
||||
data.playlists.push(pl);
|
||||
}
|
||||
return pl;
|
||||
}
|
||||
|
||||
// PUT the file with upload progress (fetch has none) → { ok, upload | error }.
|
||||
function uploadSharedFile(blob, name, onProgress) {
|
||||
return new Promise((resolve) => {
|
||||
const fp = window.getFingerprint ? window.getFingerprint() : '';
|
||||
const qs = new URLSearchParams({ name, title: name.replace(/\.[^.]+$/, ''), fp });
|
||||
const x = new XMLHttpRequest();
|
||||
x.open('PUT', '/api/uploads/shared?' + qs.toString());
|
||||
x.upload.onprogress = (e) => { if (e.lengthComputable && onProgress) onProgress(e.loaded, e.total); };
|
||||
x.onload = () => {
|
||||
let j = {};
|
||||
try { j = JSON.parse(x.responseText); } catch { /* not JSON */ }
|
||||
resolve(x.status >= 200 && x.status < 300 && j.ok ? { ok: true, upload: j.upload } : { ok: false, status: x.status, error: j.error || 'upload failed (' + x.status + ')' });
|
||||
};
|
||||
x.onerror = () => resolve({ ok: false, status: 0, error: 'connection lost' });
|
||||
x.send(blob);
|
||||
});
|
||||
}
|
||||
|
||||
// Upload every file the share target parked; each finished one joins the
|
||||
// "Shared uploads" playlist. Network failures stay in the inbox for next time.
|
||||
let _sharing = false;
|
||||
async function processSharedFiles() {
|
||||
if (_sharing || !('caches' in window)) return;
|
||||
_sharing = true;
|
||||
try {
|
||||
const inbox = await caches.open(SHARE_INBOX);
|
||||
const keys = await inbox.keys();
|
||||
let done = 0;
|
||||
for (const req of keys) {
|
||||
const res = await inbox.match(req);
|
||||
if (!res) continue;
|
||||
const name = decodeURIComponent(res.headers.get('X-Name') || 'shared');
|
||||
const blob = await res.blob();
|
||||
const t = toast(`Uploading “${name}”…`, { duration: 600000 });
|
||||
const r = await uploadSharedFile(blob, name, (got, total) => {
|
||||
if (t) t.textContent = `Uploading “${name}”… ${Math.round((got / total) * 100)}%`;
|
||||
});
|
||||
if (t) t.remove();
|
||||
if (r.ok && r.upload) {
|
||||
await inbox.delete(req);
|
||||
const u = r.upload;
|
||||
const v = { id: u.id, title: u.title || name, channel: u.artist || 'Shared upload', duration: u.duration || 0 };
|
||||
const pl = sharedUploadsPlaylist();
|
||||
if (!pl.videos.some((x) => x.id === v.id)) pl.videos.unshift(slim(v));
|
||||
persist();
|
||||
done++;
|
||||
toast(`Added “${v.title}” to ${SHARED_PLAYLIST} ✓`);
|
||||
} else if (r.status === 0) {
|
||||
toast('⚠ Upload paused — it will continue when you open the app online');
|
||||
break;
|
||||
} else {
|
||||
await inbox.delete(req); // refused (type, size, quota) — retrying will not help
|
||||
toast('⚠ ' + r.error, { duration: 6000 });
|
||||
}
|
||||
}
|
||||
if (done) {
|
||||
const pl = sharedUploadsPlaylist();
|
||||
view = { type: 'playlist', id: pl.id };
|
||||
render();
|
||||
}
|
||||
} catch { /* inbox unavailable */ } finally { _sharing = false; }
|
||||
}
|
||||
|
||||
function bootShareFromUrl() {
|
||||
let q;
|
||||
try { q = new URLSearchParams(location.search); } catch { return; }
|
||||
const shared = q.get('shared');
|
||||
const files = q.get('share') === 'files';
|
||||
if (shared === null && !files && !q.has('sharefail')) return;
|
||||
try {
|
||||
const u = new URL(location.href);
|
||||
['shared', 'share', 'sharefail'].forEach((k) => u.searchParams.delete(k));
|
||||
history.replaceState(null, '', u.pathname + u.search + u.hash);
|
||||
} catch { /* keep */ }
|
||||
if (q.has('sharefail')) toast('⚠ Open the app once, then share the file again');
|
||||
if (shared) {
|
||||
const yt = youtubeFromText(shared);
|
||||
if (yt) {
|
||||
playVideoAt(data.history.find((x) => x.id === yt.id) || { id: yt.id, title: '' }, yt.t);
|
||||
} else {
|
||||
// Not a video link: search for whatever was shared.
|
||||
const text = shared.replace(/https?:\/\/\S+/g, '').trim() || shared.trim();
|
||||
els.searchInput.value = text.slice(0, 200);
|
||||
els.searchForm.requestSubmit();
|
||||
}
|
||||
}
|
||||
if (files) processSharedFiles();
|
||||
else setTimeout(processSharedFiles, 5000); // anything left from an earlier share
|
||||
}
|
||||
|
||||
// /?view=<page> — manifest shortcuts and links into a page of the app.
|
||||
const URL_VIEWS = new Set(['search', 'queue', 'history', 'saved', 'downloads', 'library', 'notes', 'settings', 'stats']);
|
||||
function bootViewFromUrl() {
|
||||
let v = '';
|
||||
try { v = new URLSearchParams(location.search).get('view') || ''; } catch { return; }
|
||||
if (!v) return;
|
||||
try {
|
||||
const u = new URL(location.href);
|
||||
u.searchParams.delete('view');
|
||||
history.replaceState(null, '', u.pathname + u.search + u.hash);
|
||||
} catch { /* keep */ }
|
||||
if (!URL_VIEWS.has(v)) return;
|
||||
view = { type: v };
|
||||
render();
|
||||
}
|
||||
|
||||
function bootSearchFromUrl() {
|
||||
let query = '';
|
||||
try {
|
||||
@@ -11364,7 +11497,9 @@ async function boot() {
|
||||
Presenter.boot();
|
||||
SectionRail.boot();
|
||||
Share.bootFromUrl();
|
||||
bootViewFromUrl();
|
||||
bootSearchFromUrl();
|
||||
if (WEB) bootShareFromUrl();
|
||||
Party.boot();
|
||||
Remote.boot();
|
||||
Transition.renderButton();
|
||||
|
||||
@@ -8,7 +8,10 @@
|
||||
"scope": "/",
|
||||
"theme_color": "#1a1311",
|
||||
"background_color": "#1a1311",
|
||||
"categories": ["music", "entertainment"],
|
||||
"categories": [
|
||||
"music",
|
||||
"entertainment"
|
||||
],
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icons/icon-192.png",
|
||||
@@ -29,13 +32,135 @@
|
||||
"name": "Search",
|
||||
"short_name": "Search",
|
||||
"url": "/?view=search",
|
||||
"description": "Search YouTube"
|
||||
"description": "Search YouTube",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icons/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Queue",
|
||||
"short_name": "Queue",
|
||||
"url": "/?view=queue",
|
||||
"description": "What plays next",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icons/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Saved",
|
||||
"short_name": "Saved",
|
||||
"url": "/?view=saved",
|
||||
"description": "Videos saved for offline",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icons/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Downloads",
|
||||
"short_name": "Downloads",
|
||||
"url": "/?view=downloads",
|
||||
"description": "Saves in progress and paused",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icons/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "History",
|
||||
"short_name": "History",
|
||||
"url": "/?view=history",
|
||||
"description": "Recently watched videos"
|
||||
"description": "Recently watched videos",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icons/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Library",
|
||||
"short_name": "Library",
|
||||
"url": "/?view=library",
|
||||
"description": "Your playlists",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icons/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Lyrics & notes",
|
||||
"short_name": "Notes",
|
||||
"url": "/?view=notes",
|
||||
"description": "Lyrics, chapters and notes",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icons/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Settings",
|
||||
"short_name": "Settings",
|
||||
"url": "/?view=settings",
|
||||
"description": "App settings",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icons/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"share_target": {
|
||||
"action": "/share-target",
|
||||
"method": "POST",
|
||||
"enctype": "multipart/form-data",
|
||||
"params": {
|
||||
"title": "title",
|
||||
"text": "text",
|
||||
"url": "url",
|
||||
"files": [
|
||||
{
|
||||
"name": "media",
|
||||
"accept": [
|
||||
"audio/*",
|
||||
"video/*",
|
||||
".mp3",
|
||||
".m4a",
|
||||
".aac",
|
||||
".wav",
|
||||
".flac",
|
||||
".ogg",
|
||||
".opus",
|
||||
".mp4",
|
||||
".mov",
|
||||
".webm",
|
||||
".mkv"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,10 +160,42 @@ self.addEventListener('activate', (e) => {
|
||||
});
|
||||
|
||||
// ---- Fetch: routing logic ----
|
||||
// ---- Share target (manifest share_target) ----
|
||||
// The OS share sheet POSTs the shared link/text/files here. Files are parked in
|
||||
// a cache the app reads on open (name does not start with "ytplayer-", so no
|
||||
// deploy ever evicts it), then the page is opened to process them.
|
||||
const SHARE_INBOX = 'ytp-share-inbox';
|
||||
async function receiveShare(request) {
|
||||
let form;
|
||||
try { form = await request.formData(); } catch { return Response.redirect('/?shared=', 303); }
|
||||
const files = form.getAll('media').filter((f) => f && typeof f !== 'string' && f.size);
|
||||
if (files.length) {
|
||||
const inbox = await caches.open(SHARE_INBOX);
|
||||
let n = 0;
|
||||
for (const f of files) {
|
||||
const key = `/__share/${Date.now()}-${n++}`;
|
||||
await inbox.put(key, new Response(f, { headers: {
|
||||
'Content-Type': f.type || 'application/octet-stream',
|
||||
'X-Name': encodeURIComponent(f.name || 'shared'),
|
||||
'Content-Length': String(f.size),
|
||||
} }));
|
||||
}
|
||||
return Response.redirect('/?share=files', 303);
|
||||
}
|
||||
const pick = (k) => String(form.get(k) || '').trim();
|
||||
const text = [pick('url'), pick('text'), pick('title')].filter(Boolean).join(' ');
|
||||
return Response.redirect('/?shared=' + encodeURIComponent(text.slice(0, 2000)), 303);
|
||||
}
|
||||
|
||||
self.addEventListener('fetch', (e) => {
|
||||
const { request } = e;
|
||||
const url = new URL(request.url);
|
||||
|
||||
if (request.method === 'POST' && url.pathname === '/share-target') {
|
||||
e.respondWith(receiveShare(request));
|
||||
return;
|
||||
}
|
||||
|
||||
// Only intercept GET/HEAD — let POST (sync endpoint) go through unmodified
|
||||
if (request.method !== 'GET' && request.method !== 'HEAD') return;
|
||||
|
||||
|
||||
33
server/db.js
33
server/db.js
@@ -212,6 +212,11 @@ export async function initDb() {
|
||||
last_used_at INTEGER
|
||||
);
|
||||
`);
|
||||
// Uploads shared from devices (PWA share target): who sent it, and whether it
|
||||
// is listed in everyone's search (admin uploads) or reachable only by link.
|
||||
for (const col of ['owner TEXT', 'listed INTEGER NOT NULL DEFAULT 1']) {
|
||||
try { await db.execute(`ALTER TABLE uploads ADD COLUMN ${col}`); } catch { /* already there */ }
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Shared video notes (lyrics / chapters) ---------------------------------
|
||||
@@ -725,16 +730,30 @@ const uploadRow = (r) => ({
|
||||
id: r.id, kind: r.kind, title: r.title, artist: r.artist || '', album: r.album || '',
|
||||
duration: Number(r.duration) || 0, ext: r.ext, mime: r.mime, size: Number(r.size) || 0,
|
||||
art: r.art || null, createdAt: Number(r.created_at), plays: Number(r.plays) || 0,
|
||||
owner: r.owner || null, listed: r.listed === undefined || r.listed === null ? true : Number(r.listed) === 1,
|
||||
});
|
||||
|
||||
export async function createUpload(u) {
|
||||
await db.execute({
|
||||
sql: `INSERT INTO uploads (id, kind, title, artist, album, duration, ext, mime, size, art, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, unixepoch())`,
|
||||
args: [u.id, u.kind, u.title, u.artist || null, u.album || null, u.duration || 0, u.ext, u.mime, u.size || 0, u.art || null],
|
||||
sql: `INSERT INTO uploads (id, kind, title, artist, album, duration, ext, mime, size, art, owner, listed, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, unixepoch())`,
|
||||
args: [u.id, u.kind, u.title, u.artist || null, u.album || null, u.duration || 0, u.ext, u.mime, u.size || 0, u.art || null,
|
||||
u.owner || null, u.listed === false ? 0 : 1],
|
||||
});
|
||||
}
|
||||
|
||||
// Quota bookkeeping for device-shared uploads.
|
||||
export async function sharedUploadUsage(owner) {
|
||||
const r = await db.execute({
|
||||
sql: `SELECT ifnull(sum(size), 0) AS bytes,
|
||||
ifnull(sum(CASE WHEN created_at > unixepoch() - 86400 THEN 1 ELSE 0 END), 0) AS today
|
||||
FROM uploads WHERE owner = ?`,
|
||||
args: [owner],
|
||||
});
|
||||
const t = await db.execute('SELECT ifnull(sum(size), 0) AS bytes FROM uploads WHERE owner IS NOT NULL');
|
||||
return { bytes: Number(r.rows[0].bytes) || 0, today: Number(r.rows[0].today) || 0, totalShared: Number(t.rows[0].bytes) || 0 };
|
||||
}
|
||||
|
||||
export async function setUploadArt(id, art) {
|
||||
await db.execute({ sql: 'UPDATE uploads SET art = ? WHERE id = ?', args: [art, id] });
|
||||
}
|
||||
@@ -745,16 +764,18 @@ export async function getUpload(id) {
|
||||
}
|
||||
|
||||
// Newest first; `q` matches title/artist/album (case-insensitive).
|
||||
export async function listUploads({ q = '', limit = 100 } = {}) {
|
||||
// `listedOnly`: public search shows admin uploads, never device-shared ones.
|
||||
export async function listUploads({ q = '', limit = 100, listedOnly = false } = {}) {
|
||||
const like = `%${String(q).toLowerCase()}%`;
|
||||
const vis = listedOnly ? 'ifnull(listed, 1) = 1 AND ' : '';
|
||||
const r = q
|
||||
? await db.execute({
|
||||
sql: `SELECT * FROM uploads
|
||||
WHERE lower(title) LIKE ? OR lower(ifnull(artist, '')) LIKE ? OR lower(ifnull(album, '')) LIKE ?
|
||||
WHERE ${vis}(lower(title) LIKE ? OR lower(ifnull(artist, '')) LIKE ? OR lower(ifnull(album, '')) LIKE ?)
|
||||
ORDER BY created_at DESC LIMIT ?`,
|
||||
args: [like, like, like, limit],
|
||||
})
|
||||
: await db.execute({ sql: 'SELECT * FROM uploads ORDER BY created_at DESC LIMIT ?', args: [limit] });
|
||||
: await db.execute({ sql: `SELECT * FROM uploads ${listedOnly ? 'WHERE ifnull(listed, 1) = 1 ' : ''}ORDER BY created_at DESC LIMIT ?`, args: [limit] });
|
||||
return r.rows.map(uploadRow);
|
||||
}
|
||||
|
||||
|
||||
@@ -468,7 +468,7 @@ function fetchYoutube(q) {
|
||||
|
||||
const searchLibrary = async (q) => {
|
||||
// This server's own library first — it still answers when YouTube is unreachable.
|
||||
try { return (await notesDb.listUploads({ q, limit: 20 })).map(uploads.card); } catch { return []; }
|
||||
try { return (await notesDb.listUploads({ q, limit: 20, listedOnly: true })).map(uploads.card); } catch { return []; }
|
||||
};
|
||||
|
||||
// GET /api/search/local?q=<query> — videos this server already knows (from every
|
||||
@@ -1553,6 +1553,16 @@ function cachedDownloadResponse(c, videoId, fp, row) {
|
||||
});
|
||||
}
|
||||
|
||||
// POST /share-target — the manifest share target when no service worker is in
|
||||
// control yet (first visit): links and text still work; files need the worker.
|
||||
app.post('/share-target', async (c) => {
|
||||
let body = {};
|
||||
try { body = await c.req.parseBody(); } catch { /* not a form */ }
|
||||
const pick = (k) => (typeof body[k] === 'string' ? body[k].trim() : '');
|
||||
const text = [pick('url'), pick('text'), pick('title')].filter(Boolean).join(' ').slice(0, 2000);
|
||||
return c.redirect('/?shared=' + encodeURIComponent(text) + (body.media ? '&sharefail=1' : ''), 303);
|
||||
});
|
||||
|
||||
// GET /api/download/:videoId/prepare[?hevc=1] — never blocks. Starts (or
|
||||
// joins) the server-side fetch and reports where it is:
|
||||
// ready → { gen, size, sha256 }: fetch /api/download/:id in ranges
|
||||
|
||||
@@ -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