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