admin update, und upload

This commit is contained in:
Troy grunt
2026-02-13 21:59:45 +01:00
parent b9d662b8b9
commit ae21c3c5d8
6 changed files with 358 additions and 18 deletions

2
.gitignore vendored
View File

@@ -1,2 +1,4 @@
www/secret.php
www/_user.php
www/files/*
!www/files/.gitkeep

3
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"todo-tree.tree.scanMode": "workspace only"
}

View File

@@ -1,4 +1,60 @@
# businesscard
# Digitale Visitenkarten (Businesscard)
## TODO
- admin ident werte typen
Dieses Projekt stellt eine einfache Verwaltungsoberfläche für digitale Visitenkarten bereit. Jede Karte wird über eine UUID adressiert, die eindeutig einem Datensatz in der Datenbank zugeordnet ist. Besucher:innen rufen die Karte über card.php?uuid=<UUID> auf, während Administrator:innen über das Passwort-geschützte Dashboard alle Identitäten, Feldwerte, Dateien und UUID-Token pflegen.
## Architektur
* **PHP/Apache**-Weboberfläche in <code>www/</code> mit Frontend (<code>card.php</code>, <code>download.php</code>) und Backend (<code>admin.php</code> plus <code>_sql.php</code>, <code>_func.php</code>, <code>_user.php</code>).
* **MariaDB** (Docker) hält Tabellen für Identitäten, Felder, UUIDs, Berechtigungen, Dateien und Loginversuche (<code>db/init/001_schema.sql</code>).
* **Docker Compose** startet Webserver und Datenbank; <code>www/</code> wird in den Apache-Container gemountet, <code>www/files/</code> spiegelt das Upload-Verzeichnis <code>/var/www/files/</code>.
* **Uploadverzeichnis** <code>www/files/</code> liegt außerhalb des Document Roots und ist als <code>.gitkeep</code> im Repository abgelegt, damit Hochladungen später von <code>download.php</code> referenziert werden.
## Installation & erster Start
1. Repository clonen.
2. <code>www/secret.php</code> vom Beispiel kopieren (<code>secret.php.example</code>) und Datenbankzugang konfigurieren, damit <code>_sql.php</code> sich verbinden kann.
3. <code>mkdir -p www/files</code> (oder <code>md www/files</code> unter Windows) ausführen und darauf achten, dass die <code>.gitkeep</code>-Datei vorhanden ist, damit der Ordner beim Containerstart genutzt wird.
4. <code>docker-compose up --build</code> im Projektverzeichnis ausführen, um Apache und MariaDB zu starten.
5. <code>http://localhost/admin.php</code> öffnen; die Standardanmeldedaten stehen in <code>www/_user.php</code> (<code>admin</code> / <code>password</code>) und sollten sofort geändert werden.
## Admin-Oberfläche & Workflows
* Login schützt das Dashboard; nach drei gescheiterten Versuchen wird eine IP für eine Stunde gesperrt (<code>admin_login_attempts</code>).
* Das Dashboard zeigt alle Identitäten sowie UUID-Token. Neue Identitäten erstellt man über <code>?action=identity_create</code>.
* Jedes Identity kann beliebige Felder (<code>identity_fields</code>) enthalten. Der Typ kann <code>single</code>, <code>multi</code>, <code>file</code> oder <code>url</code> sein.
* Dateien werden pro Identität über das neue Upload-Formular hinzugefügt und landen in <code>www/files/</code>. Der Upload speichert Dateiname, generierten Zwischennamen und MIME-Type in <code>files</code>.
* Wenn ein Feld auf Typ <code>file</code> gesetzt ist, zeigt die Auswahl nur Uploads der aktuellen Identität (per <code>files.identity_id</code>). Die ausgewählte Datei-ID wird im Feldwert gespeichert, damit später nur diese Datei angezeigt oder heruntergeladen wird.
* UUID-Token (<code>access_tokens</code>) verbinden eine Identität mit einer eindeutigen Zeichenfolge, können optional Auslaufdaten und Notizen erhalten und dürfen **nur einmal** existieren. Tokenrechte (<code>token_permissions</code>) bestimmen, welche Feldschlüssel Besucher:innen sehen.
## Besucheransicht (Visitenkarte)
* <code>/card.php?uuid=&lt;UUID&gt;</code> lädt nur die Felder, die das Token freigibt, sowie Dateien mit demselben Token oder ohne Tokenbindung.
* Dateifelder erscheinen unterhalb der Felder als Downloadlinks, wenn der Token für die Datei berechtigt ist.
* Das Frontend zeigt Meta-Informationen (Ablaufdatum, Notiz) sowie eine gekürzte UUID. Bestehende Admin-Sessions leiten zurück zum Dashboard, damit Redakteur:innen schnell bearbeiten können.
## Datenbankstruktur im Überblick
| Tabelle | Zweck |
| --- | --- |
| <code>identities</code> | Identitätsdatensätze mit Name und Timestamp. |
| <code>identity_fields</code> | Key-Value-Felder (Text, mehrzeilig, Datei, URL) pro Identität. |
| <code>access_tokens</code> | UUID-Token pro Identität mit Ablauf, Notiz und eindeutiger Zeichenfolge. |
| <code>token_permissions</code> | Erlaubte Feldschlüssel pro Token. |
| <code>files</code> | Hochgeladene Dateien mit <code>identity_id</code>, <code>filename</code>, <code>stored_name</code>, <code>mime_type</code> und optional <code>token_id</code>. |
| <code>admin_login_attempts</code> | Brute-Force-Schutz mit Zählern und Sperrzeit pro IP. |
## Sicherheitshinweise
* **UUIDs sind einmalige Zugriffstoken** und sollten nicht doppelt vergeben oder offen weitergegeben werden, wenn geschützte Daten hinterlegt sind.
* **Admin-Zugang**: Bitte <code>www/_user.php</code> durch eigene Credentials ersetzen oder ein robusteres Auth-Verfahren implementieren.
* **Loginversuche** werden nach drei Fehlversuchen eine Stunde gesperrt (<code>registerFailedLogin</code>, <code>isIpLocked</code>, <code>clearLoginAttempts</code>).
* **Datei-Uploads** liegen im Verzeichnis <code>www/files/</code> (Container: <code>/var/www/files/</code>) und dürfen ausschließlich über <code>download.php</code> mit gültigem Token ausgeliefert werden. Der Ordner sollte nicht direkt vom Webserver referenziert werden.
## Weiterentwicklung & Pflege
1. **Stil & Branding**: Die Inline-CSS im <code>card.php</code>-Head kann durch ein eigenes Template ersetzt oder ausgelagert werden.
2. **Dateien & Automatisierung**: UUIDs und zugehörige Dateien lassen sich per API oder QR-Code verteilen. Die Download-Logik kann um Freigabezeitfenster erweitert werden.
3. **Tests & Validierung**: Neue Felder brauchen eventuell Frontend-Validierung; Dateiuploads sollten auf MIME-Type, Größe und erlaubte Endungen geprüft werden.
4. **Deployment**: Docker Compose genügt lokal. In Produktion empfiehlt sich ein Reverse Proxy, HTTPS und regelmäßige Backups des MariaDB-Volumes (<code>db_data</code>). Auch <code>www/files/</code> sollte gesichert werden, da dort alle Uploads gespeichert werden.
> Hinweis: UUIDs repräsentieren digitale Visitenkarten und sind **einmalig**. Jeder Besuch über eine UUID dokumentiert genau eine Karte, deren Sichtbarkeit über Tokenrechte und Datei-Zuordnungen kontrolliert wird.

View File

@@ -81,6 +81,7 @@ button {
<div class="err"><?= htmlspecialchars($error) ?></div>
<?php endif; ?>
</form>
</body>
</html>
<?php
@@ -221,7 +222,37 @@ exit;
EDIT IDENTITY
───────────────────────────── */
if ($action === 'identity_edit') {
//TODO je nach typ des feldes soll auch das datenfeld getauscht werden, einzeilit input mehrzeilig textarea usw
function renderValueFieldElement(string $type, string $value, string $name = 'value', string $id = null, array $extraAttrs = [], array $filesForIdentity = []): string
{
$safeValue = htmlspecialchars($value);
$idAttr = $id ? ' id="' . htmlspecialchars($id) . '"' : '';
$extraAttrString = '';
foreach ($extraAttrs as $attr => $attrVal) {
$extraAttrString .= ' ' . htmlspecialchars($attr) . '="' . htmlspecialchars($attrVal) . '"';
}
if ($type === 'file') {
$options = '<option value="">-- Datei wählen --</option>';
foreach ($filesForIdentity as $file) {
$fileId = (string)(int)$file['id'];
$selected = $fileId === $value ? ' selected' : '';
$options .= sprintf(
'<option value="%s"%s>%s</option>',
htmlspecialchars($fileId),
$selected,
htmlspecialchars($file['filename'])
);
}
return "<select name=\"" . htmlspecialchars($name) . "\" style=\"width:100%\"{$idAttr}{$extraAttrString}>{$options}</select>";
}
if ($type === 'multi') {
return "<textarea name=\"" . htmlspecialchars($name) . "\" rows=\"3\" style=\"width:100%\"{$idAttr}{$extraAttrString}>{$safeValue}</textarea>";
}
$inputType = $type === 'url' ? 'url' : 'text';
return "<input type=\"{$inputType}\" name=\"" . htmlspecialchars($name) . "\" value=\"{$safeValue}\" style=\"width:100%\"{$idAttr}{$extraAttrString}>";
}
$id = (int)($_GET['id'] ?? 0);
$identity = $sql->single(
@@ -234,8 +265,94 @@ if ($action === 'identity_edit') {
exit('Identität nicht gefunden');
}
$fileUploadErrors = [];
$fileUploadSuccess = 0;
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (isset($_POST['upload_files'])) {
$filesInput = $_FILES['files'] ?? null;
$hasSelection = false;
if ($filesInput) {
if (is_array($filesInput['name'])) {
foreach ($filesInput['name'] as $fileName) {
if (trim((string)$fileName) !== '') {
$hasSelection = true;
break;
}
}
} else {
$hasSelection = trim((string)$filesInput['name']) !== '';
}
}
if ($filesInput && $hasSelection) {
$uploadDir = __DIR__ . '/../files/';
if (!is_dir($uploadDir) && !mkdir($uploadDir, 0755, true) && !is_dir($uploadDir)) {
$fileUploadErrors[] = 'Upload-Verzeichnis kann nicht erstellt werden.';
} else {
$total = is_array($filesInput['name']) ? count($filesInput['name']) : 1;
$uploaded = 0;
$finfo = finfo_open(FILEINFO_MIME_TYPE);
for ($i = 0; $i < $total; $i++) {
$originalName = is_array($filesInput['name']) ? $filesInput['name'][$i] : $filesInput['name'];
$error = is_array($filesInput['error']) ? $filesInput['error'][$i] : $filesInput['error'];
$tmpName = is_array($filesInput['tmp_name']) ? $filesInput['tmp_name'][$i] : $filesInput['tmp_name'];
if ($error === UPLOAD_ERR_NO_FILE) {
continue;
}
$originalName = trim((string)$originalName);
if ($originalName === '') {
continue;
}
if ($error !== UPLOAD_ERR_OK) {
$fileUploadErrors[] = sprintf('Fehler beim Hochladen von %s.', $originalName);
continue;
}
if (!is_uploaded_file($tmpName)) {
$fileUploadErrors[] = sprintf('Datei %s konnte nicht verarbeitet werden.', $originalName);
continue;
}
$safeName = preg_replace('/[^A-Za-z0-9._-]/', '_', basename($originalName));
if ($safeName === '') {
$safeName = 'file';
}
$storedName = bin2hex(random_bytes(12)) . '_' . $safeName;
$destination = $uploadDir . $storedName;
if (!move_uploaded_file($tmpName, $destination)) {
$fileUploadErrors[] = sprintf('Speichern von %s fehlgeschlagen.', $originalName);
continue;
}
$mimeType = $finfo ? finfo_file($finfo, $destination) : 'application/octet-stream';
$sql->set(
"INSERT INTO files (identity_id, filename, stored_name, mime_type)
VALUES (?, ?, ?, ?)",
"isss",
[$id, $originalName, $storedName, $mimeType]
);
$uploaded++;
}
if ($finfo) {
finfo_close($finfo);
}
if ($uploaded > 0) {
$fileUploadSuccess = $uploaded;
}
}
} else {
$fileUploadErrors[] = 'Bitte wählen Sie mindestens eine Datei aus.';
}
}
// Identität umbenennen
if (isset($_POST['rename'])) {
$sql->set(
@@ -296,6 +413,15 @@ if ($action === 'identity_edit') {
"i",
[$id]
);
$identityFiles = $sql->get(
"SELECT id, filename FROM files WHERE identity_id = ? ORDER BY uploaded_at DESC",
"i",
[$id]
);
if ($identityFiles === false) {
$identityFiles = [];
}
?>
<!doctype html>
<html>
@@ -334,17 +460,13 @@ if ($action === 'identity_edit') {
<input name="key"
value="<?= htmlspecialchars($f['field_key']) ?>">
</td>
<td>
<?php if ($f['typ'] === 'multi'): ?>
<textarea name="value" rows="3" style="width:100%"><?= htmlspecialchars($f['field_value']) ?></textarea>
<?php else: ?>
<input name="value"
value="<?= htmlspecialchars($f['field_value']) ?>"
style="width:100%">
<?php endif; ?>
<td class="value-cell">
<div data-value-wrapper class="value-field-wrapper">
<?= renderValueFieldElement($f['typ'], $f['field_value'], 'value', null, [], $identityFiles) ?>
</div>
</td>
<td>
<select name="typ">
<select name="typ" data-typ-switch>
<option value="single" <?= $f['typ']==='single'?'selected':'' ?>>einzeilig</option>
<option value="multi" <?= $f['typ']==='multi'?'selected':'' ?>>mehrzeilig</option>
<option value="file" <?= $f['typ']==='file'?'selected':'' ?>>Datei</option>
@@ -367,8 +489,10 @@ if ($action === 'identity_edit') {
<h3>Neues Feld</h3>
<form method="post">
<input name="key" placeholder="Feldname" required>
<input name="value" placeholder="Wert">
<select name="typ">
<div data-value-wrapper class="value-field-wrapper" data-placeholder="Wert">
<?= renderValueFieldElement('single', '', 'value', 'new-field-value', ['placeholder' => 'Wert'], $identityFiles) ?>
</div>
<select name="typ" data-typ-switch>
<option value="single">einzeilig</option>
<option value="multi">mehrzeilig</option>
<option value="file">Datei</option>
@@ -377,6 +501,125 @@ if ($action === 'identity_edit') {
<button name="add_field"> Feld hinzufügen</button>
</form>
<h3>Dateien hochladen</h3>
<form method="post" enctype="multipart/form-data">
<input type="file" name="files[]" multiple>
<button name="upload_files">Hochladen</button>
</form>
<?php if ($fileUploadSuccess > 0): ?>
<p style="color:#22c55e; margin-top:.5rem;">
<?= $fileUploadSuccess ?> Datei<?= $fileUploadSuccess === 1 ? '' : 'en' ?> hochgeladen.
</p>
<?php endif; ?>
<?php if (!empty($fileUploadErrors)): ?>
<ul style="color:#f87171; margin-top:.5rem;">
<?php foreach ($fileUploadErrors as $error): ?>
<li><?= htmlspecialchars($error) ?></li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
<?php if (!empty($identityFiles)): ?>
<h3>Vorhandene Dateien</h3>
<ul>
<?php foreach ($identityFiles as $file): ?>
<li><?= htmlspecialchars($file['filename']) ?> (ID <?= (int)$file['id'] ?>)</li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
<script>
document.addEventListener('DOMContentLoaded', function () {
const identityFiles = <?= json_encode(array_map(function ($file) {
return [
'id' => (int)$file['id'],
'filename' => $file['filename'],
];
}, $identityFiles), JSON_UNESCAPED_UNICODE | JSON_HEX_TAG) ?: '[]' ?>;
function createValueFieldElement(type, opts) {
opts = opts || {};
var element;
if (type === 'multi') {
element = document.createElement('textarea');
element.rows = 3;
} else if (type === 'file') {
element = document.createElement('select');
} else {
element = document.createElement('input');
element.type = type === 'url' ? 'url' : 'text';
}
element.name = opts.name || 'value';
element.style.width = '100%';
if (opts.id) {
element.id = opts.id;
}
if (type === 'file') {
var placeholder = opts.placeholder || '-- Datei wählen --';
var defaultOption = document.createElement('option');
defaultOption.value = '';
defaultOption.textContent = placeholder;
element.appendChild(defaultOption);
identityFiles.forEach(function (file) {
var option = document.createElement('option');
option.value = file.id;
option.textContent = file.filename;
if (opts.value && String(opts.value) === String(file.id)) {
option.selected = true;
}
element.appendChild(option);
});
return element;
}
if (opts.placeholder) {
element.setAttribute('placeholder', opts.placeholder);
}
element.value = opts.value || '';
return element;
}
function updateValueField(select) {
var form = select.closest('form');
if (!form) {
return;
}
var wrapper = form.querySelector('[data-value-wrapper]');
if (!wrapper) {
return;
}
var existing = wrapper.querySelector('[name="value"]');
var opts = {
name: existing ? existing.name : 'value',
id: existing ? existing.id : '',
placeholder: existing ? existing.getAttribute('placeholder') : wrapper.getAttribute('data-placeholder') || '',
value: existing ? existing.value : ''
};
if (select.value === 'file') {
delete opts.value;
}
wrapper.textContent = '';
wrapper.appendChild(createValueFieldElement(select.value, opts));
}
var switchers = document.querySelectorAll('select[data-typ-switch]');
for (var i = 0; i < switchers.length; i++) {
switchers[i].addEventListener('change', function () {
updateValueField(this);
});
}
});
</script>
<p><a href="admin.php">← zurück</a></p>
</body>
@@ -409,7 +652,6 @@ if ($action === 'uuid_create') {
EDIT UUID
───────────────────────────── */
if ($action === 'uuid_edit') {
//TODO es fehlt die auswahl welche felder sichtbar sein sollen.
$uuid = $_GET['uuid'] ?? '';
$token = $sql->single(

View File

@@ -170,6 +170,30 @@ function label(string $key): string {
text-align: center;
font-size: .75rem;
color: var(--muted);
display: flex;
flex-direction: column;
gap: .25rem;
align-items: center;
}
.footer-meta {
display: flex;
gap: .65rem;
font-size: .7rem;
flex-wrap: wrap;
justify-content: center;
color: var(--muted);
}
.footer-meta span {
padding: .15rem .4rem;
border-radius: 999px;
background: rgba(148, 163, 184, .18);
}
.footer-note {
font-size: .65rem;
color: rgba(229, 231, 235, .7);
}
</style>
</head>
@@ -203,10 +227,22 @@ function label(string $key): string {
<?php endif; ?>
<footer>
Zugriff über sicheren Link
<?php
//TODO ordentlichen footer
$expiresAt = $token['expires_at'] ?? null;
$note = trim($token['notes'] ?? '');
$expiresLabel = $expiresAt ? date('d.m.Y H:i', strtotime($expiresAt)) : null;
?>
<div class="footer-meta">
<span>
<?= $expiresLabel ? 'gültig bis ' . htmlspecialchars($expiresLabel) : 'unbegrenzt gültig' ?>
</span>
<?php if ($note !== ''): ?>
<span><?= htmlspecialchars($note) ?></span>
<?php endif; ?>
</div>
<div class="footer-note">
<?= 'UUID • ' . htmlspecialchars(substr($token['uuid'], 0, 8)) . '…' ?>
</div>
</footer>
</div>
</body>

1
www/files/.gitkeep Normal file
View File

@@ -0,0 +1 @@
This directory stores uploaded files outside the document root.