Ein neuer Button rendert die Zeitleiste der aktuell aktiven Ansicht (Weltraum oder Standort) Bild für Bild bei 24 fps und lädt sie als ZIP herunter. Jedes Frame wird einzeln an einen neuen PHP-Endpunkt hochgeladen und in einem Temp-Verzeichnis abgelegt statt im Browser gesammelt zu werden - das hält den Speicherbedarf sowohl client- als auch serverseitig unabhängig von der Frame-Anzahl vorhersagbar (wichtig bei mehreren tausend Frames). Das serverseitige Zippen kommt ohne ZipArchive-Modul aus und liefert die Datei in festen Blöcken statt sie komplett im Speicher zu puffern. Zusätzlich lässt sich der exportierte Zeitbereich per Von/Bis-Uhrzeit einschränken, statt immer die komplette Finsternis zu exportieren. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
240 lines
8.0 KiB
PHP
240 lines
8.0 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
session_start();
|
|
|
|
const SF_EXPORT_MAX_FRAME_BYTES = 8 * 1024 * 1024;
|
|
const SF_EXPORT_MAX_INDEX = 99999;
|
|
const SF_EXPORT_TTL_SECONDS = 3 * 3600;
|
|
|
|
function sfExportJson(array $payload, int $status = 200): never
|
|
{
|
|
http_response_code($status);
|
|
header('Content-Type: application/json; charset=UTF-8');
|
|
echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
|
exit;
|
|
}
|
|
|
|
function sfExportRequireLogin(): void
|
|
{
|
|
if (!isset($_SESSION['user_id'])) {
|
|
sfExportJson(['success' => false, 'message' => 'Bitte anmelden.'], 403);
|
|
}
|
|
}
|
|
|
|
function sfExportRequireToken(): void
|
|
{
|
|
$sessionToken = isset($_SESSION['sonnenfinsternis_export_token']) && is_string($_SESSION['sonnenfinsternis_export_token'])
|
|
? $_SESSION['sonnenfinsternis_export_token']
|
|
: '';
|
|
$submittedToken = isset($_REQUEST['token']) ? (string) $_REQUEST['token'] : '';
|
|
|
|
if ($sessionToken === '' || $submittedToken === '' || !hash_equals($sessionToken, $submittedToken)) {
|
|
sfExportJson(['success' => false, 'message' => 'Sicherheitsprüfung fehlgeschlagen. Bitte Seite neu laden.'], 403);
|
|
}
|
|
}
|
|
|
|
function sfExportDir(string $exportId): string
|
|
{
|
|
return sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'sfexport_' . $exportId;
|
|
}
|
|
|
|
function sfExportRequireOwnedExportId(): string
|
|
{
|
|
$exportId = isset($_REQUEST['exportId']) ? (string) $_REQUEST['exportId'] : '';
|
|
if (!preg_match('/^[a-f0-9]{32}$/', $exportId) || !isset($_SESSION['sonnenfinsternis_export_ids'][$exportId])) {
|
|
sfExportJson(['success' => false, 'message' => 'Unbekannte oder abgelaufene Export-ID.'], 403);
|
|
}
|
|
return $exportId;
|
|
}
|
|
|
|
function sfExportDeleteDir(string $dir): void
|
|
{
|
|
if (!is_dir($dir)) {
|
|
return;
|
|
}
|
|
foreach (glob($dir . DIRECTORY_SEPARATOR . '*') ?: [] as $file) {
|
|
@unlink($file);
|
|
}
|
|
@rmdir($dir);
|
|
}
|
|
|
|
function sfExportSweepStale(): void
|
|
{
|
|
$base = sys_get_temp_dir();
|
|
foreach (glob($base . DIRECTORY_SEPARATOR . 'sfexport_*', GLOB_ONLYDIR) ?: [] as $dir) {
|
|
$mtime = @filemtime($dir);
|
|
if ($mtime !== false && (time() - $mtime) > SF_EXPORT_TTL_SECONDS) {
|
|
sfExportDeleteDir($dir);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ─── Store-Format-ZIP-Writer (kein ZipArchive-Modul vorausgesetzt) ─────────
|
|
|
|
function sfZipLocalHeader(string $name, int $crc, int $size, int $dosTime, int $dosDate): string
|
|
{
|
|
return pack(
|
|
'VvvvvvVVVvv',
|
|
0x04034b50, 20, 0, 0, $dosTime, $dosDate, $crc, $size, $size, strlen($name), 0
|
|
) . $name;
|
|
}
|
|
|
|
function sfZipCentralHeader(string $name, int $crc, int $size, int $dosTime, int $dosDate, int $offset): string
|
|
{
|
|
return pack(
|
|
'VvvvvvvVVVvvvvvVV',
|
|
0x02014b50, 20, 20, 0, 0, $dosTime, $dosDate, $crc, $size, $size,
|
|
strlen($name), 0, 0, 0, 0, 0, $offset
|
|
) . $name;
|
|
}
|
|
|
|
function sfBuildZip(array $sourceFiles, string $zipPath): void
|
|
{
|
|
$fh = fopen($zipPath, 'wb');
|
|
if ($fh === false) {
|
|
throw new RuntimeException('ZIP-Datei konnte nicht angelegt werden.');
|
|
}
|
|
|
|
$dt = getdate();
|
|
$dosTime = ($dt['hours'] << 11) | ($dt['minutes'] << 5) | intdiv($dt['seconds'], 2);
|
|
$dosDate = (($dt['year'] - 1980) << 9) | ($dt['mon'] << 5) | $dt['mday'];
|
|
|
|
$centralBlob = '';
|
|
$offset = 0;
|
|
|
|
foreach ($sourceFiles as $name => $path) {
|
|
$data = file_get_contents($path);
|
|
if ($data === false) {
|
|
fclose($fh);
|
|
@unlink($zipPath);
|
|
throw new RuntimeException('Frame-Datei konnte nicht gelesen werden: ' . $path);
|
|
}
|
|
$crc = crc32($data);
|
|
$size = strlen($data);
|
|
|
|
fwrite($fh, sfZipLocalHeader($name, $crc, $size, $dosTime, $dosDate));
|
|
fwrite($fh, $data);
|
|
unset($data);
|
|
|
|
$centralBlob .= sfZipCentralHeader($name, $crc, $size, $dosTime, $dosDate, $offset);
|
|
$offset += 30 + strlen($name) + $size;
|
|
}
|
|
|
|
$centralStart = $offset;
|
|
fwrite($fh, $centralBlob);
|
|
fwrite($fh, pack(
|
|
'VvvvvVVv',
|
|
0x06054b50, 0, 0, count($sourceFiles), count($sourceFiles), strlen($centralBlob), $centralStart, 0
|
|
));
|
|
|
|
fclose($fh);
|
|
}
|
|
|
|
// ─── Routing ────────────────────────────────────────────────────────────────
|
|
|
|
sfExportRequireLogin();
|
|
sfExportRequireToken();
|
|
|
|
$action = isset($_REQUEST['action']) ? (string) $_REQUEST['action'] : '';
|
|
|
|
if ($action === 'start') {
|
|
sfExportSweepStale();
|
|
|
|
$exportId = bin2hex(random_bytes(16));
|
|
$dir = sfExportDir($exportId);
|
|
if (!mkdir($dir, 0700, true) && !is_dir($dir)) {
|
|
sfExportJson(['success' => false, 'message' => 'Temp-Verzeichnis konnte nicht angelegt werden.'], 500);
|
|
}
|
|
$_SESSION['sonnenfinsternis_export_ids'][$exportId] = time();
|
|
|
|
sfExportJson(['success' => true, 'exportId' => $exportId]);
|
|
}
|
|
|
|
if ($action === 'frame') {
|
|
$exportId = sfExportRequireOwnedExportId();
|
|
|
|
$index = isset($_REQUEST['index']) ? (string) $_REQUEST['index'] : '';
|
|
if (!ctype_digit($index) || (int) $index > SF_EXPORT_MAX_INDEX) {
|
|
sfExportJson(['success' => false, 'message' => 'Ungültiger Frame-Index.'], 400);
|
|
}
|
|
|
|
$raw = file_get_contents('php://input', false, null, 0, SF_EXPORT_MAX_FRAME_BYTES + 1);
|
|
if ($raw === false || $raw === '' || strlen($raw) > SF_EXPORT_MAX_FRAME_BYTES) {
|
|
sfExportJson(['success' => false, 'message' => 'Ungültige oder zu große Bilddaten.'], 400);
|
|
}
|
|
|
|
$filename = sfExportDir($exportId) . DIRECTORY_SEPARATOR . sprintf('frame_%05d.png', (int) $index);
|
|
if (file_put_contents($filename, $raw) === false) {
|
|
sfExportJson(['success' => false, 'message' => 'Frame konnte nicht gespeichert werden.'], 500);
|
|
}
|
|
|
|
sfExportJson(['success' => true]);
|
|
}
|
|
|
|
if ($action === 'cancel') {
|
|
$exportId = sfExportRequireOwnedExportId();
|
|
sfExportDeleteDir(sfExportDir($exportId));
|
|
unset($_SESSION['sonnenfinsternis_export_ids'][$exportId]);
|
|
|
|
sfExportJson(['success' => true]);
|
|
}
|
|
|
|
if ($action === 'finish') {
|
|
$exportId = sfExportRequireOwnedExportId();
|
|
$dir = sfExportDir($exportId);
|
|
|
|
$frameFiles = glob($dir . DIRECTORY_SEPARATOR . 'frame_*.png') ?: [];
|
|
sort($frameFiles);
|
|
if ($frameFiles === []) {
|
|
sfExportJson(['success' => false, 'message' => 'Keine Frames für diesen Export gefunden.'], 404);
|
|
}
|
|
|
|
$sourceFiles = [];
|
|
foreach ($frameFiles as $path) {
|
|
$sourceFiles[basename($path)] = $path;
|
|
}
|
|
|
|
// Das Zippen tausender Frames dauert laenger als PHPs Standard-Zeitlimit
|
|
// (30s) - hier ist es ein legitimer, einmaliger Vorgang fuer einen
|
|
// eingeloggten Nutzer, daher wird das Limit fuer diesen Request aufgehoben.
|
|
set_time_limit(0);
|
|
|
|
$zipPath = $dir . DIRECTORY_SEPARATOR . 'export.zip';
|
|
try {
|
|
sfBuildZip($sourceFiles, $zipPath);
|
|
} catch (Throwable $e) {
|
|
sfExportDeleteDir($dir);
|
|
unset($_SESSION['sonnenfinsternis_export_ids'][$exportId]);
|
|
sfExportJson(['success' => false, 'message' => 'ZIP konnte nicht erstellt werden: ' . $e->getMessage()], 500);
|
|
}
|
|
|
|
header('Content-Type: application/zip');
|
|
header('Content-Disposition: attachment; filename="sonnenfinsternis_export_' . substr($exportId, 0, 8) . '.zip"');
|
|
header('Content-Length: ' . (string) filesize($zipPath));
|
|
|
|
// Aktive Output-Buffer wuerden die komplette (potenziell mehrere GB grosse)
|
|
// ZIP-Datei im Speicher aufsammeln statt sie durchzureichen - das war die
|
|
// Ursache des Speicherfehlers. Deshalb Buffer abschalten und die Datei in
|
|
// festen Bloecken direkt ausgeben, statt readfile() das ini-abhaengige
|
|
// Verhalten zu ueberlassen.
|
|
while (ob_get_level() > 0) {
|
|
ob_end_clean();
|
|
}
|
|
|
|
$out = fopen($zipPath, 'rb');
|
|
if ($out !== false) {
|
|
while (!feof($out)) {
|
|
echo fread($out, 1024 * 1024);
|
|
flush();
|
|
}
|
|
fclose($out);
|
|
}
|
|
|
|
sfExportDeleteDir($dir);
|
|
unset($_SESSION['sonnenfinsternis_export_ids'][$exportId]);
|
|
exit;
|
|
}
|
|
|
|
sfExportJson(['success' => false, 'message' => 'Unbekannte Aktion.'], 400);
|