Merge branch 'main' of git.seemsleg.it:troy-grunt/businesscard into main
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,2 +1,4 @@
|
||||
www/secret.php
|
||||
www/_user.php
|
||||
www/_files/*
|
||||
!www/_files/.gitkeep
|
||||
|
||||
3
.vscode/settings.json
vendored
Normal file
3
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"todo-tree.tree.scanMode": "workspace only"
|
||||
}
|
||||
62
README.md
62
README.md
@@ -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/html/_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>. Unter dem Formular zeigt das Admin-UI eine Liste der bereits hochgeladenen Dateien inkl. einer Löschoption.
|
||||
* 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=<UUID></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/html/_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.
|
||||
|
||||
@@ -10,6 +10,7 @@ services:
|
||||
- ./www:/var/www/html
|
||||
depends_on:
|
||||
- db
|
||||
command: ["sh", "-c", "mkdir -p /var/www/html/_files && chown -R www-data:www-data /var/www/html/_files && apache2-foreground"]
|
||||
|
||||
db:
|
||||
image: mariadb:11
|
||||
|
||||
398
www/admin.php
398
www/admin.php
@@ -6,6 +6,11 @@ require '_user.php';
|
||||
session_start();
|
||||
$ip = $_SERVER['REMOTE_ADDR'];
|
||||
|
||||
const MAX_UPLOAD_FILE_SIZE_MB = 32;
|
||||
const MAX_UPLOAD_FILE_SIZE_BYTES = MAX_UPLOAD_FILE_SIZE_MB * 1024 * 1024;
|
||||
ini_set('upload_max_filesize', MAX_UPLOAD_FILE_SIZE_MB . 'M');
|
||||
ini_set('post_max_size', (MAX_UPLOAD_FILE_SIZE_MB + 1) . 'M');
|
||||
|
||||
/* ─────────────────────────────
|
||||
Security
|
||||
───────────────────────────── */
|
||||
@@ -81,6 +86,7 @@ button {
|
||||
<div class="err"><?= htmlspecialchars($error) ?></div>
|
||||
<?php endif; ?>
|
||||
</form>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
<?php
|
||||
@@ -221,7 +227,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,7 +270,146 @@ if ($action === 'identity_edit') {
|
||||
exit('Identität nicht gefunden');
|
||||
}
|
||||
|
||||
$uploadErrorLabel = function (int $error): string {
|
||||
return match ($error) {
|
||||
UPLOAD_ERR_INI_SIZE => 'Datei überschreitet upload_max_filesize',
|
||||
UPLOAD_ERR_FORM_SIZE => 'Datei überschreitet MAX_FILE_SIZE',
|
||||
UPLOAD_ERR_PARTIAL => 'Datei wurde nur teilweise hochgeladen',
|
||||
UPLOAD_ERR_NO_FILE => 'keine Datei ausgewählt',
|
||||
UPLOAD_ERR_NO_TMP_DIR => 'temporäres Verzeichnis fehlt',
|
||||
UPLOAD_ERR_CANT_WRITE => 'Datei konnte nicht geschrieben werden',
|
||||
UPLOAD_ERR_EXTENSION => 'Upload durch eine Erweiterung abgebrochen',
|
||||
default => 'unbekannter Upload-Fehler',
|
||||
};
|
||||
};
|
||||
|
||||
$fileUploadErrors = $_SESSION['fileUploadErrors'] ?? [];
|
||||
unset($_SESSION['fileUploadErrors']);
|
||||
$fileUploadSuccess = (int)($_SESSION['fileUploadSuccess'] ?? 0);
|
||||
unset($_SESSION['fileUploadSuccess']);
|
||||
$fileDeleteMessage = $_SESSION['fileDeleteMessage'] ?? '';
|
||||
unset($_SESSION['fileDeleteMessage']);
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$fileUploadErrors = [];
|
||||
$fileUploadSuccess = 0;
|
||||
$fileDeleteMessage = '';
|
||||
|
||||
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: %s.',
|
||||
$originalName,
|
||||
$uploadErrorLabel($error)
|
||||
);
|
||||
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';
|
||||
}
|
||||
do {
|
||||
$storedName = bin2hex(random_bytes(12)) . '_' . $safeName;
|
||||
$destination = $uploadDir . $storedName;
|
||||
} while (is_file($destination));
|
||||
|
||||
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.';
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($_POST['delete_file'])) {
|
||||
$fileId = (int)($_POST['file_id'] ?? 0);
|
||||
if ($fileId > 0) {
|
||||
$file = $sql->single(
|
||||
"SELECT stored_name, filename FROM files WHERE id = ? AND identity_id = ?",
|
||||
"ii",
|
||||
[$fileId, $id]
|
||||
);
|
||||
if ($file) {
|
||||
$diskPath = __DIR__ . '/_files/' . $file['stored_name'];
|
||||
if (is_file($diskPath)) {
|
||||
@unlink($diskPath);
|
||||
}
|
||||
$sql->set(
|
||||
"DELETE FROM files WHERE id = ?",
|
||||
"i",
|
||||
[$fileId]
|
||||
);
|
||||
$fileDeleteMessage = sprintf('Datei "%s" gelöscht.', $file['filename']);
|
||||
} else {
|
||||
$fileUploadErrors[] = 'Datei nicht gefunden oder gehört nicht zu dieser Identität.';
|
||||
}
|
||||
} else {
|
||||
$fileUploadErrors[] = 'Ungültige Datei.';
|
||||
}
|
||||
}
|
||||
|
||||
// Identität umbenennen
|
||||
if (isset($_POST['rename'])) {
|
||||
@@ -287,6 +462,10 @@ if ($action === 'identity_edit') {
|
||||
);
|
||||
}
|
||||
|
||||
$_SESSION['fileUploadErrors'] = $fileUploadErrors;
|
||||
$_SESSION['fileUploadSuccess'] = $fileUploadSuccess;
|
||||
$_SESSION['fileDeleteMessage'] = $fileDeleteMessage;
|
||||
|
||||
header("Location: admin.php?action=identity_edit&id=$id");
|
||||
exit;
|
||||
}
|
||||
@@ -296,6 +475,27 @@ 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 = [];
|
||||
}
|
||||
$duplicateFilenameCounts = [];
|
||||
foreach ($identityFiles as $file) {
|
||||
$filename = $file['filename'] ?? '';
|
||||
if ($filename === '') {
|
||||
continue;
|
||||
}
|
||||
$duplicateFilenameCounts[$filename] = ($duplicateFilenameCounts[$filename] ?? 0) + 1;
|
||||
}
|
||||
$duplicateFileNames = array_keys(array_filter(
|
||||
$duplicateFilenameCounts,
|
||||
static fn (int $count): bool => $count > 1
|
||||
));
|
||||
?>
|
||||
<!doctype html>
|
||||
<html>
|
||||
@@ -334,17 +534,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 +563,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 +575,143 @@ 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>
|
||||
<?php if ($fileDeleteMessage !== ''): ?>
|
||||
<p style="color:#22c55e; margin-top:.25rem;"><?= htmlspecialchars($fileDeleteMessage) ?></p>
|
||||
<?php endif; ?>
|
||||
<?php if (!empty($duplicateFileNames)): ?>
|
||||
<p style="color:#fbbf24; margin-top:.25rem;">
|
||||
Dateien mit identischem Dateinamen bleiben getrennt und können einzeln gelöscht.
|
||||
</p>
|
||||
<?php endif; ?>
|
||||
<ul>
|
||||
<?php foreach ($identityFiles as $file): ?>
|
||||
<li>
|
||||
<?= htmlspecialchars($file['filename']) ?> (ID <?= (int)$file['id'] ?>)
|
||||
<?php if (!empty($duplicateFileNames) && in_array($file['filename'], $duplicateFileNames, true)): ?>
|
||||
<span style="color:#fbbf24; margin-left:.5rem; font-size:.85rem;">mehrfach vorhanden</span>
|
||||
<?php endif; ?>
|
||||
<form method="post" style="display:inline; margin-left:.75rem;">
|
||||
<input type="hidden" name="file_id" value="<?= (int)$file['id'] ?>">
|
||||
<button name="delete_file" type="submit"
|
||||
onclick="return confirm('Datei wirklich löschen?')">Löschen</button>
|
||||
</form>
|
||||
</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>
|
||||
@@ -466,6 +801,26 @@ if ($action === 'uuid_edit') {
|
||||
"i",
|
||||
[$token['identity_id']]
|
||||
);
|
||||
$files = $sql->get(
|
||||
"SELECT id, filename FROM files
|
||||
WHERE identity_id = ?
|
||||
AND (token_id IS NULL OR token_id = ?)
|
||||
ORDER BY uploaded_at DESC",
|
||||
"ii",
|
||||
[$token['identity_id'], $token['id']]
|
||||
);
|
||||
if ($files === false) {
|
||||
$files = [];
|
||||
}
|
||||
$fileLinks = array_filter(array_map(static function ($file) use ($uuid) {
|
||||
if (empty($file['id'])) {
|
||||
return null;
|
||||
}
|
||||
return [
|
||||
'url' => '/download.php?id=' . (int)$file['id'] . '&uuid=' . urlencode($uuid),
|
||||
'filename' => (string)($file['filename'] ?: 'Datei'),
|
||||
];
|
||||
}, $files));
|
||||
?>
|
||||
<!doctype html>
|
||||
<html><head><meta charset="utf-8"><title>UUID bearbeiten</title><meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
|
||||
@@ -493,6 +848,20 @@ if ($action === 'uuid_edit') {
|
||||
<button>Speichern</button>
|
||||
</form>
|
||||
|
||||
<?php if (!empty($fileLinks)): ?>
|
||||
<h3>Dateien</h3>
|
||||
<ul>
|
||||
<?php foreach ($fileLinks as $link): ?>
|
||||
<li>
|
||||
<a href="<?= htmlspecialchars($link['url']) ?>"
|
||||
target="_blank" rel="noopener noreferrer">
|
||||
<?= htmlspecialchars($link['filename']) ?>
|
||||
</a>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
<?php endif; ?>
|
||||
|
||||
<p><a href="admin.php">← zurück</a></p>
|
||||
</body></html>
|
||||
<?php
|
||||
@@ -563,4 +932,9 @@ $identities = $sql->get("SELECT * FROM identities ORDER BY id DESC");
|
||||
</html>
|
||||
<?php
|
||||
//TODO einheitliches dunkles design
|
||||
|
||||
//TODO option schaffen eine bestehende datei zu überschreiben ??? bzw prüfen ob das notwendig ist
|
||||
|
||||
//TODO anzeige von einer gelöschten oder leeren datei ausblenden, bei vollzogenem löschvorgang
|
||||
|
||||
?>
|
||||
|
||||
40
www/card.php
40
www/card.php
@@ -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>
|
||||
|
||||
@@ -6,13 +6,19 @@ require '_func.php';
|
||||
* Eingaben prüfen
|
||||
*/
|
||||
$fileId = isset($_GET['id']) ? (int)$_GET['id'] : 0;
|
||||
$uuid = $_GET['uuid'] ?? null;
|
||||
$uuid = trim($_GET['uuid'] ?? '');
|
||||
|
||||
if ($fileId <= 0 || !$uuid) {
|
||||
http_response_code(400);
|
||||
exit('Ungültige Anfrage');
|
||||
}
|
||||
|
||||
if (!preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i', $uuid)) {
|
||||
http_response_code(400);
|
||||
exit('Ungültige Anfrage');
|
||||
//TODO fehlgeschlagener zugriff als fehlerhafte anmeldung ansehen und bei wiederholung ip sperren
|
||||
}
|
||||
|
||||
/**
|
||||
* Token validieren
|
||||
*/
|
||||
@@ -51,10 +57,10 @@ if (!$file) {
|
||||
/**
|
||||
* Dateipfad
|
||||
*/
|
||||
$basePath = '/var/www/files/';
|
||||
$path = realpath($basePath . $file['stored_name']);
|
||||
$basePath = realpath(__DIR__ . '/_files');
|
||||
$path = $basePath ? realpath($basePath . '/' . $file['stored_name']) : null;
|
||||
|
||||
if (!$path || !str_starts_with($path, $basePath) || !is_file($path)) {
|
||||
if (!$path || !$basePath || !str_starts_with($path, $basePath) || !is_file($path)) {
|
||||
http_response_code(404);
|
||||
exit('Datei fehlt');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user