Files
racket-chatgpt-bootstrap/package.php
T
www-data 475765e31f Move rendering into private templates
Add an explicit template renderer with HTML views and partials for the app, bootstrap, package, and catalog pages. Move shared reporting setup into config/reporting.php and relocate stylesheet assets under css/.
2026-05-26 12:50:26 +02:00

441 lines
12 KiB
PHP

<?php
/*
* package.php
*
* Vereist:
*
* gitfetcher.php
* b64parts.php
*
* Routes:
*
* /package?name=<package>&next=...
* HTML-pagina met base64 part-links voor data/<package>.zip.
*
* /package-part?name=<package>&part=000001&next=...
* text/plain met base64-inhoud van één part.
*
* Regels:
*
* - HTML voor index/pagina's.
* - text/plain voor payload.
* - Payload is altijd base64.
* - Eén next-id per gegenereerde HTML-pagina.
*/
require_once __DIR__ . '/config/reporting.php';
require_once __DIR__ . '/private/nexttoken.php';
$TOKENS = new NextTokenStore(__DIR__ . '/data/racket-sandbox.sqlite');
@set_time_limit(300);
ignore_user_abort(false);
require_once __DIR__ . '/private/gitfetcher.php';
require_once __DIR__ . '/private/b64parts.php';
require_once __DIR__ . '/private/base64config.php';
require_once __DIR__ . '/private/lib/catalog-http.php';
require_once __DIR__ . '/private/lib/racket-data.php';
require_once __DIR__ . '/private/Template.php';
define('DATA_DIR', __DIR__ . '/data');
define('CATALOG_PACKAGE_BASE', 'https://pkgs.racket-lang.org/pkg/');
define('CATALOG_CACHE_TTL', 3600);
$chunkConfig = load_base64_chunk_config();
$packageZipMaxBase64Kb = (int)($chunkConfig['package_zip_max_base64_kb'] ?? 2048);
if ($packageZipMaxBase64Kb < 1) {
$packageZipMaxBase64Kb = 1;
}
define('PACKAGE_ZIP_MAX_BASE64_KB', $packageZipMaxBase64Kb);
define('PACKAGE_ZIP_MAX_BASE64_BYTES', PACKAGE_ZIP_MAX_BASE64_KB * 1024);
$NEXT_ID = $TOKENS->create();
function path_only()
{
$p = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH);
$p = '/' . trim($p ?: '/', '/');
return $p === '/' ? '/' : $p;
}
function h($s)
{
return htmlspecialchars((string)$s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
function current_scheme()
{
if (!empty($_SERVER['HTTP_X_FORWARDED_PROTO'])) {
return strtolower(trim(explode(',', $_SERVER['HTTP_X_FORWARDED_PROTO'])[0]));
}
return (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
}
function current_host()
{
return $_SERVER['HTTP_HOST'] ?? 'localhost';
}
function make_url($path, $query = array())
{
global $NEXT_ID;
$query['next'] = $NEXT_ID;
return current_scheme() . '://' . current_host() . $path . '?' . http_build_query($query);
}
function html_response($html, $status = 200)
{
http_response_code($status);
header('Content-Type: text/html; charset=utf-8');
header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
header('Pragma: no-cache');
echo $html;
exit;
}
function text_response($text, $status = 200)
{
http_response_code($status);
header('Content-Type: text/plain; charset=us-ascii');
header('Content-Disposition: inline');
header('X-Content-Type-Options: nosniff');
header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
header('Pragma: no-cache');
echo $text;
if ($text === '' || substr($text, -1) !== "\n") {
echo "\n";
}
exit;
}
function fail_html($message, $status = 500)
{
html_response(
RacketSandboxTemplate::renderFile('protocol-error.html', array(
'title' => 'Package error',
'message' => $message,
)),
$status
);
}
function fail_text($message, $status = 500)
{
text_response("error: " . $message . "\n", $status);
}
function package_name_ok($name)
{
return rktd_package_name_ok($name);
}
function ensure_data_dir()
{
try {
catalog_http_ensure_dir(DATA_DIR);
} catch (Throwable $e) {
fail_html($e->getMessage());
}
}
function catalog_cache_file($package)
{
ensure_data_dir();
return DATA_DIR . '/catalog-' . sha1($package) . '.rktd';
}
function catalog_meta_file($package)
{
ensure_data_dir();
return DATA_DIR . '/catalog-' . sha1($package) . '.meta.json';
}
function get_catalog_package_text($package)
{
$cacheFile = catalog_cache_file($package);
$url = CATALOG_PACKAGE_BASE . rawurlencode($package);
try {
return catalog_http_fetch_cached(
$url,
$cacheFile,
catalog_meta_file($package),
CATALOG_CACHE_TTL,
'rktsndbx-package-entry/1.0',
180
);
} catch (Throwable $e) {
fail_html($e->getMessage());
}
}
function extract_catalog_source($catalogText)
{
return rktd_extract_catalog_source($catalogText);
}
function normalize_source_to_repo_url($source)
{
$source = trim((string)$source);
if ($source === '') {
fail_html('Lege package source.');
}
/*
* github://github.com/owner/repo[/branch[/subdir]]
* Voor nu gebruiken we owner/repo. Subdirs lossen we later op.
*/
$p = parse_url($source);
if ($p === false || empty($p['scheme'])) {
fail_html('Kan package source niet parsen: ' . $source);
}
$scheme = strtolower($p['scheme']);
if ($scheme === 'github') {
$host = !empty($p['host']) ? strtolower($p['host']) : 'github.com';
$path = trim($p['path'] ?? '', '/');
$bits = explode('/', $path);
if ($host !== 'github.com' || count($bits) < 2) {
fail_html('Ongeldige github package source: ' . $source);
}
return 'https://github.com/' . $bits[0] . '/' . $bits[1];
}
/*
* git+https://... normaliseren. gitfetcher.php kan hier ook deels mee
* omgaan, maar dit houdt metadata netter.
*/
if (strpos($source, 'git+https://') === 0) {
return 'https://' . substr($source, strlen('git+https://'));
}
if (strpos($source, 'git+http://') === 0) {
return 'http://' . substr($source, strlen('git+http://'));
}
/*
* Verwijder query/fragment voor de repository-fetch.
* ?path=... en #branch/subdir pakken we later apart aan.
*/
$source = preg_replace('/[?#].*$/', '', $source);
return $source;
}
function ensure_package_zip_and_parts($package)
{
$catalogText = get_catalog_package_text($package);
$source = extract_catalog_source($catalogText);
if ($source === null || $source === '') {
fail_html('Geen source gevonden in Racket package catalogus voor package: ' . $package);
}
$repoUrl = normalize_source_to_repo_url($source);
$fetcher = new GitFetcher(array(
'data_dir' => DATA_DIR,
));
try {
$zipInfo = $fetcher->ensurePackageZip($package, $repoUrl);
} catch (Throwable $e) {
fail_html(
"Kon package zip niet ophalen.\n\n" .
"Package: " . $package . "\n" .
"Catalog source: " . $source . "\n" .
"Repo URL: " . $repoUrl . "\n\n" .
$e->getMessage()
);
}
$zipFile = DATA_DIR . '/' . $package . '.zip';
if (!is_file($zipFile) || !is_readable($zipFile)) {
fail_html('Zipbestand ontbreekt na fetch: ' . $zipFile);
}
$parts = new Base64Parts(DATA_DIR, PACKAGE_ZIP_MAX_BASE64_BYTES);
try {
$manifest = ensure_parts_for_zip($parts, $package, $zipFile);
} catch (Throwable $e) {
fail_html('Kon base64-parts niet maken: ' . $e->getMessage());
}
return array(
'package' => $package,
'source' => $source,
'repo_url' => $repoUrl,
'zip_info' => $zipInfo,
'manifest' => $manifest,
);
}
function ensure_parts_for_zip($parts, $package, $zipFile)
{
$zipSha = hash_file('sha256', $zipFile);
$zipSize = filesize($zipFile);
/*
* Hergebruik bestaande parts als ze nog exact bij de zip horen.
*/
try {
$manifest = $parts->loadManifest($package);
if (isset($manifest['source_sha256']) &&
$manifest['source_sha256'] === $zipSha &&
isset($manifest['source_bytes']) &&
(int)$manifest['source_bytes'] === (int)$zipSize &&
isset($manifest['max_base64_bytes']) &&
(int)$manifest['max_base64_bytes'] === PACKAGE_ZIP_MAX_BASE64_BYTES &&
isset($manifest['parts']) &&
is_array($manifest['parts']) &&
count($manifest['parts']) > 0) {
$ok = true;
foreach ($manifest['parts'] as $part) {
if (empty($part['file']) || !is_file($part['file']) || !is_readable($part['file'])) {
$ok = false;
break;
}
}
if ($ok) {
$manifest['parts_status'] = 'cached';
return $manifest;
}
}
} catch (Throwable $e) {
/*
* Geen manifest of ongeldig manifest: gewoon opnieuw maken.
*/
}
$manifest = $parts->splitFile($zipFile, $package, true);
$manifest['parts_status'] = 'created';
return $manifest;
}
function serve_package_page()
{
global $NEXT_ID;
$package = $_GET['name'] ?? '';
if (!package_name_ok($package)) {
fail_html('Ongeldige of ontbrekende package naam.', 400);
}
$info = ensure_package_zip_and_parts($package);
$manifest = $info['manifest'];
$zipInfo = $info['zip_info'];
$rows = '';
foreach ($manifest['parts'] as $part) {
$n = $part['number'];
$url = make_url('/package-part', array(
'name' => $package,
'part' => $n,
));
$rows .= RacketSandboxTemplate::renderFile('partials/package-part-row.html', array(
'number' => $n,
'base64_bytes' => (string)$part['base64_bytes'],
'url' => $url,
)) . "\n";
}
$sourceRows = '';
$sourceValues = array(
'catalog source' => $info['source'],
'repo url' => $info['repo_url'],
'fetch status' => $zipInfo['status'] ?? '',
'default branch' => $zipInfo['default_branch'] ?? '',
'head sha' => $zipInfo['head_sha'] ?? '',
'zip file' => $zipInfo['zip_file'] ?? '',
'zip bytes' => (string)($zipInfo['zip_bytes'] ?? ''),
'zip sha256' => $zipInfo['zip_sha256'] ?? '',
'parts status' => $manifest['parts_status'] ?? '',
'max base64 part size' => '<code>' . h((string)PACKAGE_ZIP_MAX_BASE64_KB) . '</code> KiB (<code>' . h((string)PACKAGE_ZIP_MAX_BASE64_BYTES) . '</code> bytes)',
'binary chunk size' => '<code>' . h((string)($manifest['binary_chunk_bytes'] ?? '')) . '</code> bytes',
'part count' => (string)$manifest['part_count'],
'next id' => $NEXT_ID,
);
foreach ($sourceValues as $label => $value) {
$valueHtml = strpos((string)$value, '<code>') !== false
? (string)$value
: '<code>' . h((string)$value) . '</code>';
$sourceRows .= RacketSandboxTemplate::renderFile('partials/package-source-row.html', array(
'label' => $label,
'value_html' => $valueHtml,
)) . "\n";
}
html_response(RacketSandboxTemplate::renderFile('package.html', array(
'package' => $package,
'source_rows_html' => $sourceRows,
'part_rows_html' => $rows,
)));
}
function serve_package_part()
{
$package = $_GET['name'] ?? '';
$part = $_GET['part'] ?? '';
if (!package_name_ok($package)) {
fail_text('Ongeldige of ontbrekende package naam.', 400);
}
if (!is_string($part) || !preg_match('/^[0-9]{6}$/', $part)) {
fail_text('Ongeldig of ontbrekend partnummer.', 400);
}
$parts = new Base64Parts(DATA_DIR, PACKAGE_ZIP_MAX_BASE64_BYTES);
try {
$txt = $parts->readPart($package, $part);
} catch (Throwable $e) {
fail_text($e->getMessage(), 404);
}
text_response($txt, 200);
}
$path = path_only();
if ($path === '/package') {
$TOKENS->check_valid_next('html');
serve_package_page();
}
if ($path === '/package-part') {
$TOKENS->check_valid_next('text');
serve_package_part();
}
fail_html('Onbekende route: ' . $path, 404);