Focus on content

This commit is contained in:
2026-10-31 15:11:21 -04:00
parent 88ce1d1e03
commit 1c8daf01df
17 changed files with 3003 additions and 119 deletions
+287
View File
@@ -0,0 +1,287 @@
/** Recent public pushes performed by eric, across owners. Not a repository/authorship census.
* Standalone public-data client: no credentials, storage, avatars, HTML sinks or raw feed logging.
*/
export const ORIGIN = 'https://gitea.eom.dev';
export const PAGE_SIZE = 10;
export const MAX_PAGES = 15;
export const MAX_BYTES = 2 * 1024 * 1024;
export const MAX_CONTENT_BYTES = 64 * 1024;
export const REFRESH_MS = 15 * 60 * 1000;
const ENDPOINT = `${ORIGIN}/api/v1/users/eric/activities/feeds`;
const isRecord = value => value !== null && typeof value === 'object' && !Array.isArray(value);
const isID = value => Number.isSafeInteger(value) && value > 0;
const safeSegment = value => typeof value === 'string' && value.length <= 100 &&
/^[A-Za-z0-9_.-]+$/.test(value) && !/[\r\n]/.test(value) && value !== '.' && value !== '..';
const text = (value, max) => typeof value === 'string' ? value.trim().slice(0, max) : '';
const compareText = (a, b) => a < b ? -1 : a > b ? 1 : 0;
/** Check the raw string BEFORE URL parsing can erase traversal, whitespace or port 443. */
export function validateRepositoryURL(value, fullName) {
if (typeof fullName !== 'string' || fullName.length > 201) return null;
const parts = fullName.split('/');
if (parts.length !== 2 || !parts.every(safeSegment)) return null;
if (typeof value !== 'string' || /[\s\\%?#\x00-\x1f\x7f]/.test(value) ||
value !== `${ORIGIN}/${fullName}`) return null;
try {
const url = new URL(value);
return url.protocol === 'https:' && url.origin === ORIGIN && !url.username &&
!url.password && !url.port && !url.hash && !url.search ? url.href : null;
} catch { return null; }
}
/** Gitea activity.created is RFC3339. Reject impossible calendars and future timestamps. */
export function normalizeDate(value, { now = Date.now() } = {}) {
if (typeof value !== 'string' || value.length > 40 || !Number.isFinite(now)) return null;
const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d{1,9})?(Z|[+-]\d{2}:\d{2})$/.exec(value);
if (!match || /[\r\n]/.test(value)) return null;
const [, year, month, day, hour, minute, second, zone] = match;
if (+hour > 23 || +minute > 59 || +second > 59 ||
(zone !== 'Z' && (+zone.slice(1, 3) > 23 || +zone.slice(4) > 59))) return null;
const check = new Date(0);
check.setUTCFullYear(+year, +month - 1, +day);
if (check.getUTCFullYear() !== +year || check.getUTCMonth() !== +month - 1 || check.getUTCDate() !== +day) return null;
const timestamp = Date.parse(value);
return Number.isFinite(timestamp) && timestamp <= now + 60_000 ? new Date(timestamp).toISOString() : null;
}
function commitMetadata(content, url, description) {
let head;
// Byte-bound embedded JSON as well as the outer response. Never copy authors/emails/CompareURL.
if (typeof content === 'string' && content.length <= MAX_CONTENT_BYTES &&
new TextEncoder().encode(content).byteLength <= MAX_CONTENT_BYTES) {
try {
const parsed = JSON.parse(content);
if (isRecord(parsed) && isRecord(parsed.HeadCommit)) head = parsed.HeadCommit;
} catch { /* Malformed embedded JSON falls back to the public repository description. */ }
}
const summary = typeof head?.Message === 'string'
? text(head.Message.split(/[\r\n]/, 1)[0], 200) : '';
// Sha1 is an opaque Git identifier here, not a cryptographic security primitive.
const hash = typeof head?.Sha1 === 'string' && /^(?:[\da-fA-F]{40}|[\da-fA-F]{64})$/.test(head.Sha1) &&
!/[\r\n]/.test(head.Sha1) ? head.Sha1.toLowerCase() : null;
return { summary: summary || description.slice(0, 200), commitURL: hash ? `${url}/commit/${hash}` : null };
}
/** Strict public/actor gates; sort by push time, deduplicate repo.id, keep the newest three. */
export function normalizeActivities(events, { now = Date.now() } = {}) {
if (!Array.isArray(events) || events.length > PAGE_SIZE * MAX_PAGES) throw new Error('Invalid activity list');
const projects = [];
for (const event of events) {
if (!isRecord(event) || !isID(event.id) || event.op_type !== 'commit_repo' ||
event.is_private !== false || !isRecord(event.act_user) || event.act_user.login !== 'eric' ||
!isRecord(event.repo) || event.repo.private !== false || !isID(event.repo.id)) continue;
const repo = event.repo;
const url = validateRepositoryURL(repo.html_url, repo.full_name);
const created = normalizeDate(event.created, { now });
if (!url || !created || repo.name !== repo.full_name.split('/')[1]) continue;
const description = text(repo.description, 240);
projects.push({ id: repo.id, fullName: repo.full_name, url, created, description,
...commitMetadata(event.content, url, description), eventID: event.id });
}
projects.sort((a, b) => Date.parse(b.created) - Date.parse(a.created) || b.eventID - a.eventID ||
compareText(a.fullName, b.fullName) || compareText(a.summary, b.summary));
const seen = new Set();
return projects.filter(project => !seen.has(project.id) && seen.add(project.id)).slice(0, 3)
.map(({ eventID, ...project }) => project);
}
const abortError = () => new DOMException('Public activity request aborted', 'AbortError');
/** Fixed endpoint only. Timer covers headers AND streamed body, even with non-cooperative mocks. */
export async function fetchActivityPage(page, {
fetchImpl = globalThis.fetch, signal, timeoutMs = 10_000, maxBytes = MAX_BYTES
} = {}) {
if (!Number.isInteger(page) || page < 1 || page > MAX_PAGES) throw new Error('Invalid activity page');
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0 || timeoutMs > 10_000 ||
!Number.isInteger(maxBytes) || maxBytes < 1 || maxBytes > MAX_BYTES) throw new Error('Invalid request bounds');
const url = `${ENDPOINT}?only-performed-by=true&limit=${PAGE_SIZE}&page=${page}`;
const controller = new AbortController();
let reader;
let rejectAbort;
const aborted = new Promise((_, reject) => { rejectAbort = reject; });
const cancel = () => { controller.abort(); rejectAbort(abortError()); };
signal?.addEventListener('abort', cancel, { once: true });
const timer = setTimeout(cancel, timeoutMs);
const work = async () => {
if (signal?.aborted) throw abortError();
const response = await fetchImpl(url, { method: 'GET', signal: controller.signal,
credentials: 'omit', redirect: 'error', referrerPolicy: 'no-referrer', cache: 'no-store',
headers: { Accept: 'application/json' } });
if (controller.signal.aborted) throw abortError();
if (!response.ok || response.redirected || (response.url && response.url !== url)) throw new Error('Public activity request failed');
if ((response.headers.get('content-type') ?? '').split(';')[0].trim().toLowerCase() !== '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');
const totalHeader = response.headers.get('x-total-count');
const total = totalHeader === null ? null : Number(totalHeader);
if (totalHeader !== null && (!/^\d+$/.test(totalHeader) || !Number.isSafeInteger(total) || total < 0)) {
throw new Error('Invalid activity total');
}
if (!response.body?.getReader) throw new Error('Streaming response required');
reader = response.body.getReader();
const decoder = new TextDecoder('utf-8', { fatal: true });
let body = '';
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');
body += decoder.decode(value, { stream: true });
}
const events = JSON.parse(body + decoder.decode());
if (!Array.isArray(events) || events.length > PAGE_SIZE) throw new Error('Invalid activity page body');
return { events, total };
};
try { return await Promise.race([work(), aborted]); }
finally {
clearTimeout(timer);
signal?.removeEventListener('abort', cancel);
controller.abort();
if (reader) void Promise.resolve(reader.cancel()).catch(() => {});
}
}
/** A bounded window, not all history. Any required page failure rejects the entire refresh. */
export async function fetchRecentProjects(dependencies = {}) {
const now = typeof dependencies.now === 'function' ? dependencies.now() : Date.now();
const events = [];
let total = null;
for (let page = 1; page <= MAX_PAGES; page++) {
const result = await fetchActivityPage(page, dependencies);
if (page === 1) total = result.total; // Optional feed event count; never a project count.
events.push(...result.events);
const items = normalizeActivities(events, { now });
if (items.length >= 3 || result.events.length < PAGE_SIZE || page === MAX_PAGES) {
return { items, pages: page, scannedEvents: events.length, total };
}
}
}
function element(document, tag, className, content) {
const node = document.createElement(tag);
if (className) node.className = className;
if (content !== undefined) node.textContent = content;
return node;
}
/** Safe DOM only. Validate normalized inputs again at the public rendering boundary. */
export function createCard(document, project) {
const url = validateRepositoryURL(project?.url, project?.fullName);
const created = normalizeDate(project?.created);
if (!url || !created) throw new Error('Invalid project card');
const card = element(document, 'article', 'project-card');
card.append(element(document, 'h3', '', project.fullName));
const description = text(project.description, 240);
const summary = text(project.summary, 200);
if (description) card.append(element(document, 'p', 'project-description', description));
if (summary) card.append(element(document, 'p', 'project-summary', summary));
const time = element(document, 'time', 'project-meta', `Public push · ${new Date(created).toLocaleDateString('en-US', {
year: 'numeric', month: 'short', day: 'numeric', timeZone: 'UTC'
})}`);
time.dateTime = created;
card.append(time);
const links = element(document, 'div', 'project-links');
const explore = element(document, 'a', 'text-link', 'Explore repository →');
explore.href = url;
explore.rel = 'noopener noreferrer';
explore.referrerPolicy = 'no-referrer';
links.append(explore);
const commitURL = project.commitURL;
const hash = typeof commitURL === 'string' && commitURL.startsWith(`${url}/commit/`)
? commitURL.slice(`${url}/commit/`.length) : '';
if (/^(?:[\da-f]{40}|[\da-f]{64})$/.test(hash) && !/[\r\n]/.test(hash)) {
const commit = element(document, 'a', 'text-link', 'View this commit');
commit.href = `${url}/commit/${hash}`;
commit.rel = 'noopener noreferrer';
commit.referrerPolicy = 'no-referrer';
links.append(commit);
}
card.append(links);
return card;
}
/** DOM contract: #gitea-projects > [data-project-items], [data-project-status].
* The grid/fallback and permanent activity-heading link belong to the host markup.
*/
export function initGiteaProjects(document = globalThis.document, {
fetchImpl = globalThis.fetch, now = Date.now,
setIntervalImpl = globalThis.setInterval, clearIntervalImpl = globalThis.clearInterval,
...requestBounds
} = {}) {
const root = document?.querySelector('#gitea-projects');
const items = root?.querySelector('[data-project-items]');
const status = root?.querySelector('[data-project-status]');
if (!items || !status) return { ready: Promise.resolve(), refresh: async () => {}, stop() {} };
status.setAttribute('role', 'status');
let stopped = false;
let generation = 0;
let pending;
let interval;
let checkedAt;
let needsRefresh = true;
const previous = () => checkedAt === undefined ? 'showing selected projects.' : 'showing previously checked results.';
const invalidate = () => { generation++; pending?.abort(); pending = undefined; };
const refresh = async () => {
if (stopped || document.hidden) return;
invalidate();
const current = generation;
const controller = new AbortController();
pending = controller;
needsRefresh = true;
status.textContent = `Checking recent public pushes; ${previous()}`;
try {
const result = await fetchRecentProjects({ ...requestBounds, fetchImpl, now, signal: controller.signal });
if (stopped || document.hidden || current !== generation) return;
const cards = result.items.map(project => createCard(document, project));
if (!cards.length) cards.push(element(document, 'p', 'project-empty', 'No recent public pushes found.'));
items.replaceChildren(...cards);
checkedAt = now();
needsRefresh = false;
status.textContent = `Recent public pushes · Checked ${new Date(checkedAt).toISOString()} · ${result.scannedEvents} recent activity events checked (up to 150; not all history).`;
} catch {
if (!stopped && !document.hidden && current === generation) {
status.textContent = `Recent public pushes unavailable; ${previous()}`;
}
} finally {
if (current === generation) pending = undefined;
}
};
const pauseTimer = () => { if (interval !== undefined) clearIntervalImpl(interval); interval = undefined; };
const startTimer = () => {
if (!stopped && !document.hidden && interval === undefined) interval = setIntervalImpl(() => {
if (!pending) void refresh();
}, REFRESH_MS);
};
const visibility = () => {
if (stopped) return;
if (document.hidden) {
pauseTimer();
invalidate();
status.textContent = `Recent public pushes paused; ${previous()}`;
} else {
startTimer();
if (needsRefresh || checkedAt === undefined || now() - checkedAt >= REFRESH_MS) void refresh();
else status.textContent = `Recent public pushes · Previously checked ${new Date(checkedAt).toISOString()}.`;
}
};
document.addEventListener('visibilitychange', visibility);
startTimer();
if (document.hidden) status.textContent = 'Recent public pushes paused; showing selected projects.';
const ready = refresh();
return { ready, refresh, stop() {
if (stopped) return;
stopped = true;
pauseTimer();
invalidate();
document.removeEventListener('visibilitychange', visibility);
} };
}
if (typeof document !== 'undefined') {
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => initGiteaProjects(), { once: true });
else initGiteaProjects();
}
+184 -6
View File
@@ -217,6 +217,7 @@ main {
.hero-inner {
position: relative;
z-index: 1;
min-width: 0;
max-width: 780px;
}
@@ -353,7 +354,9 @@ main {
}
.intro-section,
.featured-section {
.featured-section,
.films-section,
.services-section {
max-width: 1600px;
margin-inline: auto;
}
@@ -414,6 +417,23 @@ main {
border-top: 3px solid var(--blue);
}
.path-game {
border-top: 3px solid var(--red);
}
.path-grid,
.gaming-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.gaming-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.gaming-grid .project-card:last-child {
grid-column: auto;
}
.path-play {
border-top: 3px solid var(--peach);
}
@@ -446,6 +466,156 @@ main {
padding-top: 2rem;
}
.film-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1.5rem;
}
.media-card {
padding: 0;
overflow: hidden;
}
.media-image {
position: relative;
display: block;
overflow: hidden;
background: var(--page-deep);
isolation: isolate;
}
.media-image img {
display: block;
width: 100%;
height: auto;
aspect-ratio: 16 / 9;
object-fit: cover;
}
.media-label {
position: absolute;
right: 1rem;
bottom: 1rem;
left: 1rem;
width: fit-content;
max-width: calc(100% - 2rem);
padding: 0.45rem 0.8rem;
color: #cdd6f4;
background: #181825;
border: 1px solid #45475a;
border-radius: 0.5rem;
font-size: 0.8rem;
font-weight: 750;
}
.media-copy {
display: flex;
flex: 1;
flex-direction: column;
padding: clamp(1.35rem, 2.5vw, 2rem);
}
.media-copy > .text-link {
margin-top: auto;
}
.music-section {
display: block;
background: var(--page-deep);
border-block: 1px solid var(--border);
}
.music-copy {
max-width: 650px;
margin-bottom: 2rem;
}
.music-copy > p:not(.eyebrow) {
font-size: 1.05rem;
}
.music-feature {
border: 1px solid var(--border);
border-radius: 1.1rem;
}
.latest-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.feed-status {
font-size: 0.85rem;
color: var(--muted);
}
.feed-empty {
grid-column: 1 / -1;
padding: 1.5rem;
border: 1px solid var(--border);
border-radius: 0.75rem;
}
.media-card .media-image {
aspect-ratio: 16 / 9;
}
.media-copy time {
margin-block: 0.25rem 1rem;
color: var(--muted);
font-size: 0.85rem;
}
.collection-controls {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.8rem 1.25rem;
margin-bottom: 1.25rem;
}
.collection-controls label {
font-weight: 700;
}
.collection-controls select {
max-width: 100%;
min-width: 0;
padding: 0.8rem;
color: var(--text);
background: var(--surface);
border: 1px solid var(--border);
border-radius: 0.5rem;
font: inherit;
}
.collection-controls select:focus-visible {
outline: 3px solid var(--peach);
outline-offset: 4px;
}
.workshop-recordings {
margin-top: 3rem;
}
.project-card h3 {
overflow-wrap: anywhere;
}
.project-meta {
color: var(--muted);
font-size: 0.85rem;
}
.project-summary {
padding-left: 0.8rem;
border-left: 2px solid var(--accent);
}
.project-empty {
grid-column: 1 / -1;
}
.service-mark img {
width: 2rem;
height: 2rem;
@@ -621,6 +791,7 @@ main {
}
.support-copy {
min-width: 0;
max-width: 620px;
}
@@ -749,13 +920,13 @@ main {
font-size: 0.76rem;
}
.home-menu .pure-menu-item:nth-child(3) {
.home-menu .nav-about {
display: none;
}
.hero {
min-height: auto;
grid-template-columns: 1fr;
grid-template-columns: minmax(0, 1fr);
padding: 4.5rem 1.35rem 3.5rem;
}
@@ -794,7 +965,9 @@ main {
}
.path-grid,
.project-grid {
.project-grid,
.film-grid,
.music-section {
grid-template-columns: 1fr;
}
@@ -835,7 +1008,7 @@ main {
}
.support-section {
grid-template-columns: 1fr;
grid-template-columns: minmax(0, 1fr);
gap: 1.5rem;
padding: 3.5rem 1.25rem;
}
@@ -855,7 +1028,12 @@ main {
}
@media (max-width: 380px) {
.home-menu .pure-menu-item:nth-child(2) {
.home-menu .pure-menu-link {
padding-inline: 0.3rem;
font-size: 0.72rem;
}
.home-menu .pure-menu-item:nth-child(3) {
display: none;
}
}
+420
View File
@@ -0,0 +1,420 @@
/** 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);
}