import { execFileSync } from 'node:child_process'; const origin = new URL(process.env.GITEA_SERVER_URL || 'https://git.render.ink'); if (origin.protocol !== 'https:' || origin.username || origin.password) throw new Error('Gitea requires an HTTPS origin without credentials'); export const server = origin.origin; export const catalogRepository = process.env.CATALOG_REPOSITORY || 'trove/curios'; export function repositoryPath(repository) { if (!/^[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+$/.test(repository)) throw new Error('Invalid repository name'); return repository.split('/').map(encodeURIComponent).join('/'); } export async function api(route, { token = process.env.GITEA_TOKEN, method = 'GET', body, missing = false } = {}) { const auth = authorization(token); const response = await fetch(`${server}/api/v1${route}`, { method, redirect: 'error', signal: AbortSignal.timeout(60_000), headers: { Authorization: auth, ...(body && !(body instanceof FormData) ? { 'Content-Type': 'application/json' } : {}) }, body: body instanceof FormData ? body : body === undefined ? undefined : JSON.stringify(body), }); if (missing && response.status === 404) return null; if (!response.ok) { const error = new Error(`Gitea ${method} ${route.split('?')[0]} returned HTTP ${response.status}`); error.status = response.status; throw error; } if (response.status === 204) return null; return response.json(); } // Keep credentials on this Gitea origin; release storage may redirect to a CDN. export async function download(url, token = process.env.GITEA_TOKEN, limit = 512 * 1024 * 1024) { let current = new URL(url); for (let redirects = 0; redirects <= 5; redirects++) { if (current.protocol !== 'https:' || current.username || current.password) throw new Error('Invalid release download URL'); const response = await fetch(current, { redirect: 'manual', signal: AbortSignal.timeout(120_000), headers: current.origin === server ? { Authorization: authorization(token) } : {}, }); if ([301, 302, 303, 307, 308].includes(response.status)) { const location = response.headers.get('location'); await response.body?.cancel(); if (!location) throw new Error('Release redirect has no location'); current = new URL(location, current); continue; } if (!response.ok) throw new Error(`Release download returned HTTP ${response.status}`); const chunks = []; let size = 0; for await (const chunk of response.body) { size += chunk.length; if (size > limit) throw new Error('Release download exceeds declared size limit'); chunks.push(chunk); } return Buffer.concat(chunks); } throw new Error('Too many release download redirects'); } let localAuthorization; function authorization(token) { if (token) return `token ${token}`; if (process.env.GITEA_ACTIONS === 'true') throw new Error('Workflow token is missing'); if (!localAuthorization) { let output; try { output = execFileSync('git', ['-c', 'credential.interactive=false', 'credential', 'fill'], { input: `url=${server}\n\n`, encoding: 'utf8', timeout: 10_000, env: { ...process.env, GIT_TERMINAL_PROMPT: '0' }, stdio: ['pipe', 'pipe', 'ignore'], }); } catch { throw new Error('Set GITEA_TOKEN or configure Git HTTPS credentials'); } const fields = Object.fromEntries(output.trim().split('\n').map(line => { const separator = line.indexOf('='); return [line.slice(0, separator), line.slice(separator + 1)]; })); if (!fields.username || !fields.password) throw new Error('Set GITEA_TOKEN or configure Git HTTPS credentials'); localAuthorization = `Basic ${Buffer.from(`${fields.username}:${fields.password}`).toString('base64')}`; } return localAuthorization; } export async function notifyCatalog(repository, tag) { await api(`/repos/${repositoryPath(catalogRepository)}/actions/workflows/sync-release.yml/dispatches`, { token: process.env.CATALOG_TOKEN, method: 'POST', body: { ref: 'main', inputs: { repository, tag } }, }); console.log(`Requested catalog registration: ${repository}@${tag}`); }