Focus on content
This commit is contained in:
Executable
+287
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user