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