Files
www/htdocs/static/peertube-feeds.mjs
T
2026-10-31 15:11:21 -04:00

421 lines
23 KiB
JavaScript
Executable File

/** Public PeerTube publications. No cookies, embeds, HTML sinks or persistent storage. */
export const ORIGIN = 'https://peertube.eom.dev';
export const ORIGINALS_UUID = 'bde0cf4d-318f-4865-b205-b11104a7c46b';
export const MAX_BYTES = 2 * 1024 * 1024;
export const REFRESH_MS = 15 * 60 * 1000;
const UUID = '[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}';
const uuidPattern = new RegExp(`^${UUID}$`);
const watchPattern = new RegExp(`^/(?:w/[A-Za-z0-9]+|videos/watch/${UUID})$`);
const CHANNEL_NAME = '[A-Za-z][A-Za-z0-9_.-]{0,99}';
const isChannelName = value => typeof value === 'string' && new RegExp(`^${CHANNEL_NAME}$`).test(value) && !/[\r\n]/.test(value);
/** Reject ambiguous input BEFORE URL normalization can erase traversal or an explicit port. */
export function validateOriginURL(value) {
if (typeof value !== 'string' || value.length > 2048 || /[\s\\%<>"'\x00-\x1f\x7f]/.test(value)) return null;
if (!value.startsWith('/') && !value.startsWith(`${ORIGIN}/`)) return null;
if (value.startsWith('//') || /(?:^|\/)\.{1,2}(?:\/|[?#]|$)/.test(value)) return null;
try {
const url = new URL(value, ORIGIN);
if (url.origin !== ORIGIN || url.username || url.password || url.port || url.hash) return null;
return url;
} catch { return null; }
}
export function validateWatchURL(value) {
const url = validateOriginURL(value);
return url && !url.search && watchPattern.test(url.pathname) ? url.href : null;
}
export function validateThumbnailURL(value) {
const url = validateOriginURL(value);
return url && !url.search && /^\/(?:lazy-static|static)\/thumbnails\/(?:[A-Za-z0-9_-]+\/)*[A-Za-z0-9_-]+\.(?:jpg|jpeg|png|webp|avif)$/i.test(url.pathname) ? url.href : null;
}
function text(value, fallback = '') {
return typeof value === 'string' ? value.trim().slice(0, 500) : fallback;
}
export function normalizeDate(value, { now = Date.now(), maxFutureMs = Infinity } = {}) {
if (typeof value !== 'string' || value.length > 100 || !value.trim()) return null;
const timestamp = Date.parse(value);
if (!Number.isFinite(timestamp) || timestamp > now + maxFutureMs) return null;
// Date.parse normalizes impossible ISO/RFC calendar dates (e.g. February 30).
const calendar = /^(\d{4})-(\d{2})-(\d{2})/.exec(value);
const rfc = /^(?:[A-Za-z]{3},\s*)?(\d{1,2})\s+(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s+(\d{4})\s/i.exec(value);
if (!calendar && !rfc) return null;
const year = +(calendar ? calendar[1] : rfc[3]);
const month = calendar ? +calendar[2] - 1 : ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec'].indexOf(rfc[2].toLowerCase());
const day = +(calendar ? calendar[3] : rfc[1]);
const check = new Date(0);
check.setUTCFullYear(year, month, day);
if (check.getUTCFullYear() !== year || check.getUTCMonth() !== month || check.getUTCDate() !== day) return null;
return new Date(timestamp).toISOString();
}
const compareText = (a, b) => a < b ? -1 : a > b ? 1 : 0;
/** URL identity deliberately does not guess that a short ID equals a UUID. */
export function sortAndDedupe(items, limit = 3, dateOptions) {
if (!Array.isArray(items) || items.length > 1000) throw new Error('Too many publications');
if (!Number.isInteger(limit) || limit < 0 || limit > 1000) throw new Error('Invalid publication limit');
const normalized = items.flatMap(item => {
if (!item || typeof item !== 'object') return [];
const url = validateWatchURL(item.url);
const publishedAt = normalizeDate(item.publishedAt, dateOptions);
if (!url || !publishedAt) return [];
return [{ url, publishedAt, title: text(item.title, 'Untitled video') || 'Untitled video',
source: text(item.source, 'PeerTube'), thumbnail: validateThumbnailURL(item.thumbnail) }];
});
normalized.sort((a, b) => Date.parse(b.publishedAt) - Date.parse(a.publishedAt) || compareText(a.url, b.url) || compareText(a.title, b.title) || compareText(a.source, b.source));
const seen = new Set();
return normalized.filter(item => !seen.has(item.url) && seen.add(item.url)).slice(0, limit);
}
export function normalizeJSONVideos(entries, source = 'PeerTube', limit = 3, dateOptions) {
if (!Array.isArray(entries) || entries.length > 1000) throw new Error('Invalid video list');
return sortAndDedupe(entries.flatMap(entry => {
const video = entry?.video ?? entry;
if (!video || typeof video !== 'object' || (video.privacy !== undefined && video.privacy?.id !== 1)) return [];
// Validate the supplied URL first: UUID metadata must never rescue an unsafe origin/path.
const originalURL = validateWatchURL(video.url);
if (!originalURL) return [];
const url = typeof video.uuid === 'string' && uuidPattern.test(video.uuid)
? `${ORIGIN}/videos/watch/${video.uuid.toLowerCase()}` : originalURL;
return [{ url, title: video.name ?? video.title, publishedAt: video.publishedAt,
thumbnail: video.thumbnailPath, source }];
}), limit, dateOptions);
}
function abortError(message = 'Request aborted') { return new DOMException(message, 'AbortError'); }
/** The timer covers both headers and streamed body, including non-cooperative injected fetches. */
export async function fetchText(url, { fetchImpl = globalThis.fetch, signal, timeoutMs = 10_000, maxBytes = MAX_BYTES, shouldFetch = () => true } = {}) {
const safe = validateOriginURL(url);
if (!safe || !safe.pathname.startsWith('/api/v1/')) throw new Error('Invalid request');
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0 || timeoutMs > 10_000 || !Number.isInteger(maxBytes) || maxBytes <= 0 || maxBytes > MAX_BYTES) throw new Error('Invalid request bounds');
const controller = new AbortController();
let reader;
let timer;
let rejectAbort;
const aborted = new Promise((_, reject) => { rejectAbort = reject; });
const cancel = () => { controller.abort(); rejectAbort(abortError()); };
signal?.addEventListener('abort', cancel, { once: true });
timer = setTimeout(cancel, timeoutMs);
const work = async () => {
if (signal?.aborted || !shouldFetch()) throw abortError();
const response = await fetchImpl(safe.href, { signal: controller.signal, credentials: 'omit', redirect: 'error', referrerPolicy: 'no-referrer',
headers: { Accept: 'application/json' } });
if (controller.signal.aborted) throw abortError();
if (!response.ok || response.redirected || (response.url && response.url !== safe.href)) throw new Error('PeerTube request failed');
const mime = (response.headers.get('content-type') ?? '').split(';')[0].trim().toLowerCase();
if (mime !== 'application/json') throw new Error('Unexpected content type');
const length = response.headers.get('content-length');
if (length !== null && (!/^\d+$/.test(length) || Number(length) > maxBytes)) throw new Error('Response too large');
// Refuse unstreamable bodies rather than allocate an unbounded response.text().
if (!response.body?.getReader) throw new Error('Streaming response required');
reader = response.body.getReader();
const decoder = new TextDecoder('utf-8', { fatal: true });
let result = '';
let bytes = 0;
while (true) {
const { done, value } = await reader.read();
if (controller.signal.aborted) throw abortError();
if (done) break;
bytes += value.byteLength;
if (bytes > maxBytes) throw new Error('Response too large');
result += decoder.decode(value, { stream: true });
}
return result + decoder.decode();
};
try { return await Promise.race([work(), aborted]); }
finally {
clearTimeout(timer);
signal?.removeEventListener('abort', cancel);
controller.abort();
if (reader) void reader.cancel().catch(() => {});
}
}
export async function fetchJSON(url, dependencies) {
return JSON.parse(await fetchText(url, dependencies));
}
export function channelVideosURL(name, { count = 100, start = 0 } = {}) {
if (!isChannelName(name)) throw new Error('Invalid channel name');
if (!Number.isInteger(count) || count < 1 || count > 100 || !Number.isSafeInteger(start) || start < 0 || start > 1000) throw new Error('Invalid channel pagination');
return `${ORIGIN}/api/v1/video-channels/${name}/videos?count=${count}&start=${start}&sort=-publishedAt`;
}
function validateAPIPage(result, count, maxTotal = Number.MAX_SAFE_INTEGER) {
if (!result || !Number.isSafeInteger(result.total) || result.total < 0 || result.total > maxTotal ||
!Array.isArray(result.data) || result.data.length > count || result.data.length > result.total ||
result.data.some(entry => !entry || typeof entry !== 'object' || Array.isArray(entry))) throw new Error('Invalid API page');
}
export async function fetchChannel(name, source = 'PeerTube', dependencies = {}) {
// The server sorts before limiting: even a large channel needs just one latest page.
const result = await fetchJSON(channelVideosURL(name, { count: 3 }), dependencies);
validateAPIPage(result, 3);
if (result.data.length !== Math.min(result.total, 3)) throw new Error('Incomplete API page');
return normalizeJSONVideos(result.data, source, 3, dependencies.dateOptions);
}
/** Every page is checked. A changing/inconsistent total is a failure, not a partial library. */
export async function fetchPaginated(path, dependencies = {}) {
const channelPath = typeof path === 'string' && /^\/api\/v1\/video-channels\/([^/]+)\/videos$/.exec(path);
if (!/^\/api\/v1\/accounts\/eric\/(?:video-playlists|video-channels)$/.test(path) &&
!(channelPath && isChannelName(channelPath[1])) &&
!new RegExp(`^/api/v1/video-playlists/${UUID}/videos$`).test(path)) throw new Error('Invalid API endpoint');
const data = [];
let total;
for (let page = 0; page < 10; page++) {
const url = path.startsWith('/api/v1/video-channels/')
? channelVideosURL(path.split('/')[4], { start: data.length })
: `${ORIGIN}${path}?count=100&start=${data.length}`;
const result = await fetchJSON(url, dependencies);
validateAPIPage(result, 100, 1000);
if (total !== undefined && total !== result.total) throw new Error('API changed during pagination');
total = result.total;
if (data.length + result.data.length > total) throw new Error('Inconsistent API total');
data.push(...result.data);
if (data.length === total) return data;
if (!result.data.length) throw new Error('Incomplete API page');
}
throw new Error('API pagination limit exceeded');
}
export function normalizeCollections(channels, playlists) {
if (!Array.isArray(channels) || !Array.isArray(playlists) || channels.length > 1000 || playlists.length > 1000) throw new Error('Invalid collections');
const unique = values => {
const seen = new Set();
return values.filter(value => !seen.has(value.key) && seen.add(value.key))
.sort((a, b) => a.title.localeCompare(b.title) || a.key.localeCompare(b.key));
};
return {
channels: unique(channels.flatMap(channel => /^[1-9]\d{0,9}$/.test(String(channel?.id)) && isChannelName(channel?.name)
? [{ key: `channel:${channel.id}`, type: 'channel', id: channel.name, title: text(channel.displayName ?? channel.name), link: `${ORIGIN}/video-channels/${channel.name}/videos` }] : [])),
playlists: unique(playlists.flatMap(playlist => playlist?.privacy?.id === 1 && uuidPattern.test(playlist?.uuid) && typeof playlist.displayName === 'string'
? [{ key: `playlist:${playlist.uuid.toLowerCase()}`, type: 'playlist', id: playlist.uuid.toLowerCase(), title: text(playlist.displayName), link: `${ORIGIN}/video-playlists/${playlist.uuid.toLowerCase()}` }] : []))
};
}
export async function discoverCollections(dependencies = {}) {
const [channels, playlists] = await Promise.all([
fetchPaginated('/api/v1/accounts/eric/video-channels', dependencies),
fetchPaginated('/api/v1/accounts/eric/video-playlists', dependencies)
]);
return normalizeCollections(channels, playlists);
}
export async function fetchCollection(collection, dependencies = {}) {
if (collection?.type === 'channel') return fetchChannel(collection.id, collection.title, dependencies);
if (collection?.type !== 'playlist' || !uuidPattern.test(collection.id)) throw new Error('Invalid collection');
return normalizeJSONVideos(await fetchPaginated(`/api/v1/video-playlists/${collection.id}/videos`, dependencies), collection.title);
}
export async function loadRegion(names, source, dependencies = {}) {
if (!Array.isArray(names) || !names.length || names.length > 20 || names.some(name => !isChannelName(name))) throw new Error('Invalid feed channels');
const results = await Promise.allSettled(names.map(name => fetchChannel(name, source, dependencies)));
const successes = results.filter(result => result.status === 'fulfilled');
return { ok: successes.length > 0, unavailable: results.length - successes.length,
items: sortAndDedupe(successes.flatMap(result => result.value), 3) };
}
function element(document, tag, className, content) {
const node = document.createElement(tag);
if (className) node.className = className;
if (content !== undefined) node.textContent = content;
return node;
}
export function createCard(document, item) {
const url = validateWatchURL(item.url);
const publishedAt = normalizeDate(item.publishedAt);
if (!url || !publishedAt) throw new Error('Invalid card');
const title = text(item.title, 'Untitled video');
const card = element(document, 'article', 'project-card media-card');
const media = element(document, 'a', 'media-image');
media.href = url;
media.setAttribute('aria-label', `Watch ${title}`);
const thumbnail = validateThumbnailURL(item.thumbnail);
if (thumbnail) {
const image = element(document, 'img');
image.src = thumbnail;
image.alt = '';
image.loading = 'lazy';
image.width = 560;
image.height = 315;
image.referrerPolicy = 'no-referrer';
image.addEventListener('error', () => { image.hidden = true; }, { once: true });
media.append(image);
}
media.append(element(document, 'span', 'media-label', 'Watch ↗'));
const copy = element(document, 'div', 'media-copy');
const date = element(document, 'time', '', new Date(publishedAt).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }));
date.dateTime = publishedAt;
const link = element(document, 'a', 'text-link', 'Watch on PeerTube →');
link.href = url;
copy.append(element(document, 'p', 'card-kicker', text(item.source, 'PeerTube')), element(document, 'h3', '', title), date, link);
card.append(media, copy);
return card;
}
export function renderItems(document, container, items) {
const cards = items.map(item => createCard(document, item));
container.replaceChildren(...(cards.length ? cards : [element(document, 'p', 'feed-empty', 'No public uploads in this collection yet.')]));
}
function checkedStatus(now) { return `Latest uploads · checked ${new Date(now()).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })}`; }
/** Selection content has its own abort controller AND generation guard; select stays enabled. */
export function createLibraryController(document, root, dependencies = {}) {
dependencies = { ...dependencies, shouldFetch: () => !document.hidden };
const select = root.querySelector('#peertube-collection');
const items = root.querySelector('[data-library-items]');
const status = root.querySelector('[data-library-status]');
const link = root.querySelector('[data-library-link]');
const now = dependencies.now ?? Date.now;
let collections = new Map();
let generation = 0;
let contentController;
let discoveryController;
let discoveryBusy = false;
let stopped = false;
let contentBusy = false;
const selectCollection = async key => {
if (stopped || document.hidden) return;
const collection = collections.get(key);
if (!collection) return;
contentController?.abort();
contentController = new AbortController();
const version = ++generation;
contentBusy = true;
// Do not show cards from a previous collection under a new collection label.
items.replaceChildren();
link.href = collection.link;
link.hidden = false;
link.textContent = collection.type === 'playlist' ? 'Browse playlist on PeerTube →' : 'Browse channel on PeerTube →';
status.textContent = `Loading ${collection.title}…`;
try {
const publications = await (dependencies.fetchCollection ?? fetchCollection)(collection, { ...dependencies, signal: contentController.signal });
if (stopped || document.hidden || version !== generation) return;
renderItems(document, items, publications);
status.textContent = publications.length ? checkedStatus(now) : 'No public uploads in this collection yet.';
} catch {
if (!stopped && version === generation) status.textContent = 'Live publications unavailable. Browse this collection on PeerTube.';
} finally { if (version === generation) contentBusy = false; }
};
const discover = async () => {
if (stopped || document.hidden || discoveryBusy) return;
discoveryBusy = true;
discoveryController = new AbortController();
select.disabled = true;
status.textContent = 'Loading channels and playlists…';
try {
const result = await (dependencies.discoverCollections ?? discoverCollections)({ ...dependencies, signal: discoveryController.signal });
if (stopped || document.hidden) return;
const previous = select.value;
collections = new Map([...result.channels, ...result.playlists].map(collection => [collection.key, collection]));
const groups = [];
for (const [label, values] of [['Channels', result.channels], ['Playlists', result.playlists]]) {
if (!values.length) continue;
const group = element(document, 'optgroup');
group.label = label;
for (const collection of values) {
const option = element(document, 'option', '', collection.title);
option.value = collection.key;
group.append(option);
}
groups.push(group);
}
select.replaceChildren(...groups);
if (!collections.size) {
contentController?.abort();
generation++;
items.replaceChildren(element(document, 'p', 'feed-empty', 'No public channels or playlists found.'));
status.textContent = 'No public channels or playlists found.';
link.href = `${ORIGIN}/accounts/eric/videos`;
link.textContent = 'Browse all publications';
link.hidden = false;
return;
}
select.value = collections.has(previous) ? previous : collections.has(`playlist:${ORIGINALS_UUID}`) ? `playlist:${ORIGINALS_UUID}` : result.playlists[0]?.key ?? result.channels[0].key;
select.disabled = false;
await selectCollection(select.value);
} catch {
if (!stopped) {
select.disabled = collections.size === 0;
status.textContent = 'Live library discovery unavailable. Browse publications on PeerTube.';
link.hidden = false;
}
} finally { discoveryBusy = false; }
};
const onChange = () => { void selectCollection(select.value); };
select.addEventListener('change', onChange);
return { discover, selectCollection, refresh: async () => { if (!discoveryBusy && !contentBusy) await discover(); },
pause() { generation++; contentBusy = false; contentController?.abort(); discoveryController?.abort(); },
stop() { stopped = true; generation++; contentController?.abort(); discoveryController?.abort(); select.removeEventListener('change', onChange); } };
}
/** Returns {ready, refresh, stop, library}; all timers/listeners are removable for tests. */
export function initPeerTubeFeeds(document, dependencies = {}) {
dependencies = { ...dependencies, shouldFetch: () => !document.hidden };
const now = dependencies.now ?? Date.now;
const regions = Array.from(document.querySelectorAll('[data-peertube-feed]')).map(root => ({ root, busy: false, controller: null, hasLive: false }));
const libraryRoot = document.querySelector('#peertube-library');
const library = libraryRoot ? createLibraryController(document, libraryRoot, dependencies) : null;
let stopped = false;
let lastRefresh = -Infinity;
const refreshRegion = async region => {
if (region.busy || stopped || document.hidden) return;
const status = region.root.querySelector('[data-feed-status]');
const items = region.root.querySelector('[data-feed-items]');
if (!status || !items) return;
region.busy = true;
const controller = region.controller = new AbortController();
status.textContent = 'Checking latest uploads…';
try {
const names = (region.root.getAttribute('data-channels') ?? '').split(',').map(name => name.trim());
const result = await (dependencies.loadRegion ?? loadRegion)(names, region.root.getAttribute('data-title') ?? 'PeerTube', { ...dependencies, signal: controller.signal });
if (stopped || document.hidden || controller.signal.aborted) return;
if (!result.ok) { status.textContent = region.hasLive ? 'Live feeds unavailable. Previously checked uploads are shown below.' : 'Live feeds unavailable. Selected recordings are shown below.'; return; }
renderItems(document, items, result.items);
region.hasLive = true;
status.textContent = result.unavailable ? 'Some channels unavailable · showing available uploads.' : result.items.length ? checkedStatus(now) : 'No public uploads in these channels yet.';
} catch { if (!stopped && !document.hidden && !controller.signal.aborted) status.textContent = region.hasLive ? 'Live feeds unavailable. Previously checked uploads are shown below.' : 'Live feeds unavailable. Selected recordings are shown below.'; }
finally { if (region.controller === controller) region.busy = false; }
};
const refresh = async ({ force = false } = {}) => {
if (stopped || document.hidden || (!force && now() - lastRefresh < REFRESH_MS)) return;
lastRefresh = now();
await Promise.all([...regions.map(refreshRegion), library?.refresh()]);
};
const onVisibility = () => {
if (!document.hidden) void refresh();
else {
for (const region of regions) {
if (region.busy) lastRefresh = -Infinity;
region.controller?.abort();
region.busy = false;
}
library?.pause();
}
};
document.addEventListener('visibilitychange', onVisibility);
const interval = (dependencies.setIntervalImpl ?? setInterval)(() => { void refresh(); }, REFRESH_MS);
const ready = refresh();
return { ready, refresh, library, stop() {
stopped = true;
(dependencies.clearIntervalImpl ?? clearInterval)(interval);
document.removeEventListener('visibilitychange', onVisibility);
for (const region of regions) region.controller?.abort();
library?.stop();
} };
}
export const boot = initPeerTubeFeeds;
if (typeof globalThis.document !== 'undefined') {
if (globalThis.document.readyState === 'loading') globalThis.document.addEventListener('DOMContentLoaded', () => boot(globalThis.document), { once: true });
else boot(globalThis.document);
}