Files
geograsim/App/pages/foto-upload.php
T

407 lines
21 KiB
PHP

<?php
/**
* Foto-Backend für die Schulbuch-Foto-Slots
* ------------------------------------------
* URL: /foto-upload (offen, unverlinkt — für Thomas' Foto-Nachschub)
*
* Liest alle buchBild()-Slots direkt aus den Kapitel-Dateien (pages/buch-*.php),
* zeigt pro Slot die Foto-Beschreibung und ein Upload-Formular (mehrere Dateien
* + Notiz). Uploads landen UNVERARBEITET in uploads/buch-fotos/<sim>/ — dieser
* Ordner ist per .htaccess gesperrt (Originale enthalten EXIF/GPS!).
* Atlas holt sie vom Server, strippt EXIF, skaliert und baut sie in die Slots ein.
*/
require_once __DIR__ . '/../php/config/app.php';
$bp = BASE_PATH;
/* ---------- Zugang: eingeloggte:r Admin ODER PIN (Ausnahme, vorläufig) ---------- */
require_once __DIR__ . '/../php/lib/Session.php';
Session::start();
$__isAdmin = !empty($_SESSION['admin_id']);
/* ---------- PIN-Schranke (im Browser gespeichert, 1 Jahr) ---------- */
$PIN = '0815';
$pinFehler = false;
if (isset($_POST['pin'])) {
if ($_POST['pin'] === $PIN) {
setcookie('ggs_foto_pin', $PIN, time() + 60 * 60 * 24 * 365, '/');
header('Location: ' . strtok($_SERVER['REQUEST_URI'], '?'));
exit;
}
$pinFehler = true;
}
if (!$__isAdmin && ($_COOKIE['ggs_foto_pin'] ?? '') !== $PIN) {
?>
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>📷 Foto-Backend · PIN</title>
<style>
body { background: #edeae0; margin: 0; font-family: system-ui, -apple-system, "Segoe UI", sans-serif; color: #2f3e46;
min-height: 100vh; display: flex; align-items: center; justify-content: center; }
.box { background: #fdfbf4; border-radius: 14px; box-shadow: 0 4px 18px rgba(47,62,70,.15); padding: 30px 34px; text-align: center; max-width: 320px; }
.box h1 { font-size: 20px; margin: 0 0 6px; }
.box p { color: #6b6659; font-size: 14px; margin: 0 0 16px; }
input[type=password] { width: 130px; text-align: center; font-size: 26px; letter-spacing: 8px; border: 1.5px solid #c9c2ae;
border-radius: 10px; padding: 10px 6px; background: #fff; }
button { display: block; margin: 14px auto 0; background: #4f7f5e; color: #fff; border: 0; border-radius: 10px;
padding: 11px 30px; font-size: 15px; font-weight: 800; cursor: pointer; }
.err { color: #a33227; font-weight: 700; font-size: 14px; margin-top: 10px; }
</style>
</head>
<body>
<form class="box" method="post">
<h1>📷 Foto-Backend</h1>
<p>Bitte PIN eingeben — dein Browser merkt sie sich.</p>
<input type="password" name="pin" inputmode="numeric" pattern="[0-9]*" maxlength="8" autofocus autocomplete="current-password">
<button type="submit">Öffnen</button>
<?php if ($pinFehler): ?><div class="err">PIN stimmt nicht.</div><?php endif; ?>
</form>
</body>
</html>
<?php
exit;
}
$UPLOAD_BASE = __DIR__ . '/../uploads/buch-fotos';
$MAX_BYTES = 40 * 1024 * 1024; // 40 MB je Datei
$MAX_FILES = 12; // je Absenden
$ALLOWED_EXT = ['jpg','jpeg','png','heic','heif','webp','tif','tiff'];
/* ---------- Bild-Auslieferung (nur mit PIN; der Ordner selbst bleibt gesperrt) ---------- */
if (isset($_GET['img']) || isset($_GET['thumb'])) {
$req = $_GET['img'] ?? $_GET['thumb'];
$sim = preg_replace('/[^a-z0-9-]/', '', dirname($req));
$file = basename($req);
$pfad = "{$UPLOAD_BASE}/{$sim}/{$file}";
if ($sim === '' || $file === '' || strpos($file, '..') !== false || !is_file($pfad)) {
http_response_code(404); exit('nicht gefunden');
}
$ext = strtolower(pathinfo($file, PATHINFO_EXTENSION));
$mime = ['jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg', 'png' => 'image/png', 'webp' => 'image/webp',
'heic' => 'image/heic', 'heif' => 'image/heif', 'tif' => 'image/tiff', 'tiff' => 'image/tiff'][$ext] ?? 'application/octet-stream';
if (isset($_GET['thumb'])) {
$tdir = "{$UPLOAD_BASE}/.thumbs";
if (!is_dir($tdir)) mkdir($tdir, 0775, true);
$tpfad = "{$tdir}/{$sim}__{$file}.jpg";
if (!is_file($tpfad) || filemtime($tpfad) < filemtime($pfad)) {
$src = @imagecreatefromstring((string)@file_get_contents($pfad));
if ($src) {
// iPhone/Kamera-Fotos: EXIF-Orientierung berücksichtigen
if (in_array($ext, ['jpg', 'jpeg'], true) && function_exists('exif_read_data')) {
$exif = @exif_read_data($pfad);
$o = (int)($exif['Orientation'] ?? 1);
if ($o === 3) $src = imagerotate($src, 180, 0);
elseif ($o === 6) $src = imagerotate($src, -90, 0);
elseif ($o === 8) $src = imagerotate($src, 90, 0);
}
$w = imagesx($src); $h = imagesy($src);
$f = min(1, 340 / max($w, $h));
$tw = max(1, (int)round($w * $f)); $th = max(1, (int)round($h * $f));
$dst = imagecreatetruecolor($tw, $th);
imagecopyresampled($dst, $src, 0, 0, 0, 0, $tw, $th, $w, $h);
imagejpeg($dst, $tpfad, 82);
imagedestroy($src); imagedestroy($dst);
}
}
if (is_file($tpfad)) { $pfad = $tpfad; $mime = 'image/jpeg'; }
// sonst (z. B. HEIC ohne GD-Support): Original ausliefern — Safari/iPad zeigt es nativ
}
header('Content-Type: ' . $mime);
header('Content-Length: ' . (string)filesize($pfad));
header('Cache-Control: private, max-age=86400');
readfile($pfad);
exit;
}
/* ---------- Slots aus den Kapiteln parsen ---------- */
$ORDER = ['tourismustal','tourismusregion','kofferdetektiv','busfahrt','logistik',
'weltkueche','farmer','klima','fluss','energiemanager','heli',
'sonnensystem','eu-werkstatt'];
$KAPITEL = [];
foreach ($ORDER as $id) {
$file = __DIR__ . "/buch-{$id}.php";
if (!is_file($file)) continue;
$src = file_get_contents($file);
$titel = preg_match('/<h1>(.*?)<\/h1>/s', $src, $m) ? trim(strip_tags($m[1])) : $id;
preg_match_all("/buchBild\(\s*'((?:[^'\\\\]|\\\\.)*)'\s*,\s*'((?:[^'\\\\]|\\\\.)*)'/s", $src, $mm, PREG_SET_ORDER);
$slots = [];
foreach ($mm as $b) {
$slug = stripcslashes($b[1]);
$vorschlag = stripcslashes($b[2]);
// Leicht- und Normal-Version rufen denselben Slot auf → längste Beschreibung gewinnt
if (!isset($slots[$slug]) || mb_strlen($vorschlag) > mb_strlen($slots[$slug])) {
$slots[$slug] = $vorschlag;
}
}
if ($slots) $KAPITEL[$id] = ['titel' => $titel, 'slots' => $slots];
}
/* ---------- Helfer ---------- */
function slotBildPfad(string $sim, string $slug): ?string {
foreach (['jpg','jpeg','webp','png'] as $ext) {
$rel = "assets/img/buch/{$sim}/{$slug}.{$ext}";
if (is_file(__DIR__ . '/../' . $rel)) return $rel;
}
return null;
}
function pendingUploads(string $base, string $sim, string $slug): array {
$dir = "{$base}/{$sim}";
if (!is_dir($dir)) return [];
$out = [];
foreach (glob($dir . '/' . $slug . '__*') as $f) {
if (substr($f, -9) === '.note.txt') continue;
$out[] = ['name' => basename($f), 'kb' => (int)round(filesize($f) / 1024)];
}
return $out;
}
function pendingNotes(string $base, string $sim, string $slug): array {
$dir = "{$base}/{$sim}";
if (!is_dir($dir)) return [];
$out = [];
foreach (glob($dir . '/' . $slug . '__*.note.txt') as $f) {
$t = trim((string)file_get_contents($f));
if ($t !== '') $out[] = $t;
}
return $out;
}
function safeName(string $s): string {
$s = preg_replace('/[^A-Za-z0-9._-]+/', '-', $s);
return trim(mb_substr($s, 0, 80), '-.') ?: 'foto';
}
/* ---------- Löschen eines wartenden Uploads (PRG) ---------- */
$fehler = [];
$geloescht = 0;
if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['aktion'] ?? '') === 'loeschen') {
$sim = preg_replace('/[^a-z0-9-]/', '', $_POST['sim'] ?? '');
$file = basename($_POST['file'] ?? '');
$pfad = "{$UPLOAD_BASE}/{$sim}/{$file}";
// nur echte Upload-Dateien (Muster <slug>__…), keine Notizen, kein Pfad-Ausbruch
if ($sim !== '' && $file !== '' && strpos($file, '..') === false
&& substr($file, -9) !== '.note.txt' && is_file($pfad)) {
@unlink($pfad);
// zugehörigen Thumbnail-Cache mitlöschen
@unlink("{$UPLOAD_BASE}/.thumbs/{$sim}__{$file}.jpg");
$geloescht = 1;
} else {
$fehler[] = 'Foto zum Löschen nicht gefunden.';
}
if ($geloescht) {
$anker = $sim . '-' . preg_replace('/__.*/', '', $file);
header("Location: ?del=1&sim=" . urlencode($sim) . "#{$anker}");
exit;
}
}
/* ---------- Upload verarbeiten (PRG-Muster) ---------- */
if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['aktion'] ?? '') !== 'loeschen') {
$sim = $_POST['sim'] ?? '';
$slug = preg_replace('/[^a-z0-9-]/', '', $_POST['slug'] ?? '');
$note = trim($_POST['note'] ?? '');
$gueltig = ($sim === 'sonstiges' && $slug !== '')
|| (isset($KAPITEL[$sim]) && isset($KAPITEL[$sim]['slots'][$slug]));
if (!$gueltig) {
$fehler[] = 'Unbekannter Foto-Slot.';
} else {
$dir = "{$UPLOAD_BASE}/{$sim}";
if (!is_dir($dir)) mkdir($dir, 0775, true);
// Ordner absichern: Originale (mit GPS!) dürfen nie öffentlich abrufbar sein
if (!is_file($UPLOAD_BASE . '/.htaccess')) {
file_put_contents($UPLOAD_BASE . '/.htaccess', "Require all denied\n");
}
$ts = date('Ymd-His');
$gespeichert = 0;
if (!empty($_FILES['fotos']) && is_array($_FILES['fotos']['name'])) {
$n = min(count($_FILES['fotos']['name']), $MAX_FILES);
for ($i = 0; $i < $n; $i++) {
if ($_FILES['fotos']['error'][$i] === UPLOAD_ERR_NO_FILE) continue;
if ($_FILES['fotos']['error'][$i] !== UPLOAD_ERR_OK) { $fehler[] = 'Upload-Fehler bei Datei ' . ($i + 1) . ' (Code ' . $_FILES['fotos']['error'][$i] . ').'; continue; }
if ($_FILES['fotos']['size'][$i] > $MAX_BYTES) { $fehler[] = htmlspecialchars($_FILES['fotos']['name'][$i]) . ' ist größer als 40 MB.'; continue; }
$orig = $_FILES['fotos']['name'][$i];
$ext = strtolower(pathinfo($orig, PATHINFO_EXTENSION));
if (!in_array($ext, $ALLOWED_EXT, true)) { $fehler[] = htmlspecialchars($orig) . ': Dateityp .' . htmlspecialchars($ext) . ' nicht erlaubt.'; continue; }
$ziel = "{$dir}/{$slug}__{$ts}__" . ($i + 1) . '__' . safeName($orig);
if (move_uploaded_file($_FILES['fotos']['tmp_name'][$i], $ziel)) $gespeichert++;
else $fehler[] = htmlspecialchars($orig) . ' konnte nicht gespeichert werden.';
}
}
if ($gespeichert === 0 && !$fehler) $fehler[] = 'Keine Datei ausgewählt.';
if ($note !== '' && ($gespeichert > 0 || $sim === 'sonstiges')) {
file_put_contents("{$dir}/{$slug}__{$ts}.note.txt", $note . "\n");
}
if ($gespeichert > 0 && !$fehler) {
header("Location: ?ok={$gespeichert}&sim=" . urlencode($sim) . "&slug=" . urlencode($slug) . "#{$sim}-{$slug}");
exit;
}
}
}
$ok = isset($_GET['ok']) ? (int)$_GET['ok'] : 0;
$okAnker = $ok ? (($_GET['sim'] ?? '') . '-' . ($_GET['slug'] ?? '')) : '';
$del = isset($_GET['del']);
/* Übersichtszahlen */
$gesamt = 0; $mitFoto = 0; $wartend = 0;
foreach ($KAPITEL as $sim => $k) {
foreach ($k['slots'] as $slug => $v) {
$gesamt++;
if (slotBildPfad($sim, $slug)) $mitFoto++;
if (pendingUploads($UPLOAD_BASE, $sim, $slug)) $wartend++;
}
}
?>
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>📷 Foto-Backend · Schulbuch · GeoGraSim</title>
<style>
body { background: #edeae0; margin: 0; font-family: system-ui, -apple-system, "Segoe UI", sans-serif; color: #2f3e46; }
.w { max-width: 880px; margin: 0 auto; padding: 22px 14px 90px; }
h1 { font-size: 26px; margin: 0 0 6px; }
h2 { font-size: 20px; margin: 34px 0 4px; border-bottom: 2px solid #d9d3c0; padding-bottom: 6px; }
.sub { color: #6b6659; line-height: 1.55; margin: 0 0 8px; }
.stat { display: inline-block; background: #fdfbf4; border: 1.5px solid #c9c2ae; border-radius: 999px; padding: 4px 12px; font-size: 13px; font-weight: 700; margin: 2px 6px 2px 0; }
.slot { background: #fdfbf4; border-radius: 12px; box-shadow: 0 3px 12px rgba(47,62,70,.10); padding: 16px 18px; margin: 14px 0; }
.slot.hat { border-left: 5px solid #4f7f5e; }
.slot.wartet { border-left: 5px solid #e8892b; }
.slot.offen { border-left: 5px solid #c9c2ae; }
.kopf { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; }
.kopf code { font-size: 12px; color: #9a958a; }
.badge { font-size: 11px; font-weight: 800; border-radius: 999px; padding: 2px 10px; }
.badge.hat { background: #dcead1; color: #2f6b46; }
.badge.wartet { background: #fbe9c8; color: #a06a12; }
.badge.offen { background: #eeeadd; color: #6b6659; }
.beschr { line-height: 1.55; font-size: 14.5px; margin: 8px 0 10px; }
.thumb { max-width: 190px; border-radius: 8px; display: block; margin: 6px 0; }
a.lightbox { cursor: zoom-in; }
.pgrid { display: flex; flex-wrap: wrap; gap: 8px; margin: 8px 0; }
.pgrid .pt { position: relative; }
.pgrid .pt img { width: 110px; height: 110px; object-fit: cover; border-radius: 8px; display: block;
border: 2px solid #e8c9a0; background: #eee; }
.pgrid .pt .del { position: absolute; top: 3px; right: 3px; margin: 0; }
.pgrid .pt .del button { width: 26px; height: 26px; border-radius: 999px; border: 0; cursor: pointer;
background: rgba(163,50,39,.92); color: #fff; font-size: 15px; font-weight: 800; line-height: 1;
display: flex; align-items: center; justify-content: center; box-shadow: 0 1px 4px rgba(0,0,0,.35); }
.pgrid .pt .del button:hover { background: #a33227; }
.pnote { font-size: 13px; color: #6b6659; font-style: italic; margin: 2px 0 6px; }
#ov { position: fixed; inset: 0; background: rgba(20,26,30,.88); display: flex; align-items: center;
justify-content: center; z-index: 99; cursor: zoom-out; }
#ov[hidden] { display: none; }
#ov img { max-width: 94vw; max-height: 92vh; border-radius: 8px; box-shadow: 0 10px 40px rgba(0,0,0,.5); }
#ovx { position: fixed; top: 12px; right: 18px; color: #fff; font-size: 30px; font-weight: 800; cursor: pointer;
text-shadow: 0 1px 6px rgba(0,0,0,.7); }
form.up { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; background: #f4f1e6; border-radius: 9px; padding: 10px 12px; }
form.up input[type=file] { font-size: 13px; max-width: 100%; }
form.up input[type=text] { flex: 1 1 220px; min-width: 180px; border: 1.5px solid #c9c2ae; border-radius: 8px; padding: 8px 10px; font-size: 14px; background: #fff; }
form.up button { background: #4f7f5e; color: #fff; border: 0; border-radius: 8px; padding: 9px 18px; font-size: 14px; font-weight: 800; cursor: pointer; }
form.up button:hover { background: #3f6a4e; }
.okbox { background: #dcead1; border-left: 5px solid #4f7f5e; border-radius: 9px; padding: 11px 14px; font-weight: 700; margin: 12px 0; }
.errbox { background: #f3dcd6; border-left: 5px solid #a33227; border-radius: 9px; padding: 11px 14px; margin: 12px 0; }
.hinweis { background: #fbf6e3; border: 1.5px solid #e8c547; border-radius: 10px; padding: 10px 14px; font-size: 14px; line-height: 1.6; margin: 12px 0 4px; }
a { color: #4f7f5e; }
.toc { font-size: 13.5px; line-height: 2; background: #fdfbf4; border-radius: 12px; padding: 12px 16px; }
</style>
</head>
<body>
<div class="w">
<h1>📷 Foto-Backend zum Schulbuch</h1>
<p class="sub">Alle Foto-Slots aus den <a href="<?= $bp ?>/buch">13 Schulbuch-Kapiteln</a> mit ihren
Foto-Wünschen. Zu jedem Slot kannst du direkt Fotos hochladen (mehrere auf einmal, auch HEIC
vom iPhone/iPad) und eine Notiz dazuschreiben — Atlas holt sie ab, entfernt die GPS-Daten,
verkleinert sie und baut sie ein.</p>
<span class="stat">📷 <?= $gesamt ?> Slots</span>
<span class="stat">✅ <?= $mitFoto ?> eingebaut</span>
<span class="stat">📥 <?= $wartend ?> mit wartenden Uploads</span>
<div class="hinweis">🔒 Hochgeladene Original-Dateien sind <b>nicht öffentlich abrufbar</b>
(Ordner gesperrt) — deine GPS-Daten bleiben privat. Veröffentlicht wird nur die bereinigte
Version im Kapitel.</div>
<?php if ($ok): ?><div class="okbox">✅ <?= $ok ?> Foto<?= $ok > 1 ? 's' : '' ?> hochgeladen — danke! Atlas kümmert sich um Zuschnitt und Einbau.</div><?php endif; ?>
<?php if ($del): ?><div class="okbox">🗑️ Foto gelöscht.</div><?php endif; ?>
<?php foreach ($fehler as $f): ?><div class="errbox">⚠️ <?= $f ?></div><?php endforeach; ?>
<div class="toc"><b>Kapitel:</b>
<?php foreach ($KAPITEL as $sim => $k): ?>
<a href="#kap-<?= $sim ?>"><?= htmlspecialchars($k['titel']) ?></a> ·
<?php endforeach; ?>
<a href="#kap-sonstiges">Sonstiges</a>
</div>
<?php foreach ($KAPITEL as $sim => $k): ?>
<h2 id="kap-<?= $sim ?>"><?= htmlspecialchars($k['titel']) ?></h2>
<?php foreach ($k['slots'] as $slug => $vorschlag):
$bild = slotBildPfad($sim, $slug);
$pend = pendingUploads($UPLOAD_BASE, $sim, $slug);
$status = $bild ? 'hat' : ($pend ? 'wartet' : 'offen');
?>
<div class="slot <?= $status ?>" id="<?= $sim ?>-<?= $slug ?>">
<div class="kopf">
<b><?= $slug ?></b>
<span class="badge <?= $status ?>"><?= $bild ? '✅ Foto eingebaut' : ($pend ? '📥 Upload wartet auf Einbau' : '📷 Foto gesucht') ?></span>
</div>
<p class="beschr"><?= htmlspecialchars($vorschlag) ?></p>
<?php if ($bild): ?><a class="lightbox" href="<?= $bp ?>/<?= $bild ?>"><img class="thumb" src="<?= $bp ?>/<?= $bild ?>" alt="" loading="lazy"></a><?php endif; ?>
<?php if ($pend): ?>
<div class="pgrid">
<?php foreach ($pend as $p): ?>
<div class="pt">
<a class="lightbox" href="?img=<?= urlencode($sim . '/' . $p['name']) ?>" title="<?= htmlspecialchars($p['name']) ?> · <?= $p['kb'] ?> KB">
<img src="?thumb=<?= urlencode($sim . '/' . $p['name']) ?>" loading="lazy" alt="">
</a>
<form method="post" class="del" onsubmit="return confirm('Dieses Foto wirklich löschen?');">
<input type="hidden" name="aktion" value="loeschen">
<input type="hidden" name="sim" value="<?= htmlspecialchars($sim) ?>">
<input type="hidden" name="file" value="<?= htmlspecialchars($p['name']) ?>">
<button type="submit" title="Foto löschen" aria-label="Foto löschen">✕</button>
</form>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
<?php foreach (pendingNotes($UPLOAD_BASE, $sim, $slug) as $n): ?><div class="pnote">📝 <?= htmlspecialchars($n) ?></div><?php endforeach; ?>
<form class="up" method="post" enctype="multipart/form-data" action="?#<?= $sim ?>-<?= $slug ?>">
<input type="hidden" name="sim" value="<?= $sim ?>">
<input type="hidden" name="slug" value="<?= $slug ?>">
<input type="file" name="fotos[]" accept="image/*,.heic,.heif" multiple>
<input type="text" name="note" placeholder="Notiz (Ort, Jahr, was zu sehen ist … optional)" maxlength="500">
<button type="submit">⬆️ Hochladen</button>
</form>
</div>
<?php endforeach; ?>
<?php endforeach; ?>
<h2 id="kap-sonstiges">Sonstiges / ohne Zuordnung</h2>
<div class="slot offen" id="sonstiges-foto">
<p class="beschr">Fotos, die zu keinem Slot passen, aber gut sein könnten — mit kurzer
Beschreibung, wofür du sie siehst. Atlas ordnet sie zu oder legt neue Slots an.</p>
<form class="up" method="post" enctype="multipart/form-data" action="?#sonstiges-foto">
<input type="hidden" name="sim" value="sonstiges">
<input type="hidden" name="slug" value="foto">
<input type="file" name="fotos[]" accept="image/*,.heic,.heif" multiple>
<input type="text" name="note" placeholder="Beschreibung: Was ist das, wofür könnte es passen?" maxlength="500">
<button type="submit">⬆️ Hochladen</button>
</form>
</div>
</div>
<div id="ov" hidden><img id="ovimg" src="" alt=""><span id="ovx">✕</span></div>
<script>
(function () {
var ov = document.getElementById('ov'), ovimg = document.getElementById('ovimg');
function zu() { ov.hidden = true; ovimg.src = ''; document.body.style.overflow = ''; }
document.addEventListener('click', function (e) {
var a = e.target.closest('a.lightbox');
if (a) { e.preventDefault(); ovimg.src = a.href; ov.hidden = false; document.body.style.overflow = 'hidden'; return; }
if (!ov.hidden) zu();
});
document.addEventListener('keydown', function (e) { if (e.key === 'Escape' && !ov.hidden) zu(); });
})();
</script>
</body>
</html>