Foto-Backend /foto-upload: alle Schulbuch-Foto-Slots mit Upload-Formular
- Parst buchBild()-Slots live aus allen buch-*.php (92 Slots, bleibt automatisch synchron) - Pro Slot: Beschreibung, Status (eingebaut/wartend/gesucht), Thumbnail, Multi-Upload (JPG/PNG/HEIC/WebP, max 40 MB) + Notizfeld; Sonstiges-Slot für freie Fotos - Uploads nach uploads/buch-fotos/<sim>/, per .htaccess gesperrt (Originale mit GPS nie öffentlich); PRG-Redirect, Dateityp-/Größen-Validierung - App/uploads/ in .gitignore Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -23,3 +23,4 @@ secret_keys.txt
|
|||||||
**/.humaninput/
|
**/.humaninput/
|
||||||
**/.env
|
**/.env
|
||||||
!**/.env.example
|
!**/.env.example
|
||||||
|
App/uploads/
|
||||||
|
|||||||
@@ -0,0 +1,227 @@
|
|||||||
|
<?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;
|
||||||
|
|
||||||
|
$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'];
|
||||||
|
|
||||||
|
/* ---------- 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 safeName(string $s): string {
|
||||||
|
$s = preg_replace('/[^A-Za-z0-9._-]+/', '-', $s);
|
||||||
|
return trim(mb_substr($s, 0, 80), '-.') ?: 'foto';
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Upload verarbeiten (PRG-Muster) ---------- */
|
||||||
|
$fehler = [];
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
|
$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'] ?? '')) : '';
|
||||||
|
|
||||||
|
/* Ü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; }
|
||||||
|
.pend { font-size: 12.5px; color: #a06a12; margin: 4px 0; }
|
||||||
|
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 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): ?><img class="thumb" src="<?= $bp ?>/<?= $bild ?>" alt="" loading="lazy"><?php endif; ?>
|
||||||
|
<?php foreach ($pend as $p): ?><div class="pend">📥 <?= htmlspecialchars($p['name']) ?> (<?= $p['kb'] ?> KB)</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>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user