/** 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); }