From 08bae7479ccac1103c35a3951666a07dbad8f020 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20M=C3=BCller?= Date: Thu, 16 Jul 2026 08:43:30 +0200 Subject: [PATCH] =?UTF-8?q?Einzelbild-Export=20(24=20fps)=20f=C3=BCr=20Son?= =?UTF-8?q?nenfinsternis-Simulation=20hinzuf=C3=BCgen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- public/ajax/sonnenfinsternis_export.php | 239 +++++++++++++++++++++ public/css/style.css | 3 + public/sonnenfinsternis.php | 266 +++++++++++++++++++++++- 3 files changed, 497 insertions(+), 11 deletions(-) create mode 100644 public/ajax/sonnenfinsternis_export.php diff --git a/public/ajax/sonnenfinsternis_export.php b/public/ajax/sonnenfinsternis_export.php new file mode 100644 index 0000000..6c81fae --- /dev/null +++ b/public/ajax/sonnenfinsternis_export.php @@ -0,0 +1,239 @@ + 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); diff --git a/public/css/style.css b/public/css/style.css index ce79b0a..c044885 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -3880,12 +3880,14 @@ body.solarsystem-page:not(.jupitersystem-page) .solarsystem-stage-card { } .solarsystem-input-row input[type="date"], +.solarsystem-input-row input[type="time"], .solarsystem-input-row select, .solarsystem-sidebar input[type="range"] { width: 100%; } .solarsystem-input-row input[type="date"], +.solarsystem-input-row input[type="time"], .solarsystem-input-row select, .solarsystem-multiselect { min-height: 2.6rem; @@ -3899,6 +3901,7 @@ body.solarsystem-page:not(.jupitersystem-page) .solarsystem-stage-card { } .solarsystem-input-row input[type="date"], +.solarsystem-input-row input[type="time"], .solarsystem-input-row select { padding: 0.55rem 0.8rem; } diff --git a/public/sonnenfinsternis.php b/public/sonnenfinsternis.php index 4af6571..dd2cab9 100644 --- a/public/sonnenfinsternis.php +++ b/public/sonnenfinsternis.php @@ -18,6 +18,11 @@ session_start(); $loggedIn = isset($_SESSION['user_id']); $pageTitle = 'Sonnenfinsternis – AstroTools'; $bodyClass = 'solarsystem-page'; + +if (!isset($_SESSION['sonnenfinsternis_export_token']) || !is_string($_SESSION['sonnenfinsternis_export_token'])) { + $_SESSION['sonnenfinsternis_export_token'] = bin2hex(random_bytes(16)); +} +$sonnenfinsternisExportToken = $_SESSION['sonnenfinsternis_export_token']; ?> Kontaktzeiten (UTC)
+ +
+
Export
+ +
+ +
+ +
+ +
+ +

+
@@ -122,6 +141,8 @@ $userAlt = isset($currentLocation['elevation']) ? (float)$currentLocation['eleva