Initial commit: OMSORG website + Mitarbeiter-App
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1 @@
|
||||
Require all denied
|
||||
@@ -0,0 +1,111 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/db.php';
|
||||
|
||||
$secure = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off')
|
||||
|| (($_SERVER['SERVER_PORT'] ?? null) == 443);
|
||||
session_set_cookie_params([
|
||||
'lifetime' => 0,
|
||||
'path' => '/',
|
||||
'httponly' => true,
|
||||
'secure' => $secure,
|
||||
'samesite' => 'Lax',
|
||||
]);
|
||||
session_name('OMSORG_FELIX_APP');
|
||||
session_start();
|
||||
|
||||
function is_logged_in(): bool {
|
||||
if (empty($_SESSION['felix_logged_in']) || empty($_SESSION['felix_username'])) {
|
||||
return false;
|
||||
}
|
||||
$stmt = db()->prepare('SELECT active FROM users WHERE username = ?');
|
||||
$stmt->execute([$_SESSION['felix_username']]);
|
||||
$row = $stmt->fetch();
|
||||
return $row && (int)$row['active'] === 1;
|
||||
}
|
||||
|
||||
function require_login(): void {
|
||||
if (!is_logged_in()) {
|
||||
header('Location: ../index.php');
|
||||
exit;
|
||||
}
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && !empty($_POST)) {
|
||||
verify_csrf();
|
||||
}
|
||||
}
|
||||
|
||||
function csrf_token(): string {
|
||||
if (empty($_SESSION['csrf_token'])) {
|
||||
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
||||
}
|
||||
return $_SESSION['csrf_token'];
|
||||
}
|
||||
|
||||
function csrf_field(): string {
|
||||
return '<input type="hidden" name="csrf_token" value="' . csrf_token() . '">';
|
||||
}
|
||||
|
||||
function verify_csrf(): void {
|
||||
$token = $_POST['csrf_token'] ?? '';
|
||||
if (!hash_equals(csrf_token(), $token)) {
|
||||
http_response_code(403);
|
||||
exit('Ungültige Anfrage.');
|
||||
}
|
||||
}
|
||||
|
||||
function require_admin(): void {
|
||||
require_login();
|
||||
if (!is_admin()) {
|
||||
header('Location: ../pages/dashboard.php');
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
function is_admin(): bool {
|
||||
return isset($_SESSION['felix_role']) && $_SESSION['felix_role'] === 'admin';
|
||||
}
|
||||
|
||||
function current_user(): array|false {
|
||||
static $cache = null;
|
||||
static $cachedFor = null;
|
||||
$username = $_SESSION['felix_username'] ?? '';
|
||||
if ($username === '') return false;
|
||||
if ($cache !== null && $cachedFor === $username) return $cache;
|
||||
$stmt = db()->prepare('SELECT * FROM users WHERE username = ?');
|
||||
$stmt->execute([$username]);
|
||||
$cache = $stmt->fetch();
|
||||
$cachedFor = $username;
|
||||
return $cache;
|
||||
}
|
||||
|
||||
function current_username(): string {
|
||||
return $_SESSION['felix_username'] ?? '';
|
||||
}
|
||||
|
||||
function current_name(): string {
|
||||
return $_SESSION['felix_name'] ?? '';
|
||||
}
|
||||
|
||||
function e(string $s): string {
|
||||
return htmlspecialchars($s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
||||
}
|
||||
|
||||
// --- Login rate limiting (IP-based, persisted in DB) ---
|
||||
|
||||
function login_recent_fails(string $ip, int $windowSec = 600): int {
|
||||
$cutoff = date('c', time() - $windowSec);
|
||||
$stmt = db()->prepare(
|
||||
'SELECT COUNT(*) FROM login_attempts WHERE ip = ? AND attempted_at >= ?'
|
||||
);
|
||||
$stmt->execute([$ip, $cutoff]);
|
||||
return (int) $stmt->fetchColumn();
|
||||
}
|
||||
|
||||
function login_record_fail(string $ip, string $username): void {
|
||||
db()->prepare(
|
||||
'INSERT INTO login_attempts (ip, username, attempted_at) VALUES (?, ?, ?)'
|
||||
)->execute([$ip, $username, date('c')]);
|
||||
}
|
||||
|
||||
function login_clear_fails(string $ip): void {
|
||||
db()->prepare('DELETE FROM login_attempts WHERE ip = ?')->execute([$ip]);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
// Vorlage: nach config.secret.php kopieren und mit echten Werten füllen.
|
||||
// config.secret.php niemals ins Repo/Backup geben.
|
||||
return [
|
||||
'db_user' => 'DEIN_DB_BENUTZER',
|
||||
'db_password' => 'DEIN_DB_PASSWORT',
|
||||
'smtp_password' => 'DEIN_SMTP_PASSWORT',
|
||||
];
|
||||
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
function db(): PDO {
|
||||
static $pdo = null;
|
||||
if ($pdo === null) {
|
||||
$c = require __DIR__ . '/config.php';
|
||||
$pdo = new PDO(
|
||||
$c['db_dsn'],
|
||||
$c['db_user'],
|
||||
$c['db_password'],
|
||||
[
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||
]
|
||||
);
|
||||
if ($c['db_driver'] === 'sqlite') {
|
||||
$pdo->exec('PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;');
|
||||
}
|
||||
_run_migrations($pdo);
|
||||
}
|
||||
return $pdo;
|
||||
}
|
||||
|
||||
function _table_exists(PDO $pdo, string $table, string $driver): bool {
|
||||
if ($driver === 'sqlite') {
|
||||
return (bool) $pdo->query(
|
||||
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=" . $pdo->quote($table)
|
||||
)->fetchColumn();
|
||||
}
|
||||
return (bool) $pdo->query(
|
||||
"SELECT COUNT(*) FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name=" . $pdo->quote($table)
|
||||
)->fetchColumn();
|
||||
}
|
||||
|
||||
function _run_migrations(PDO $pdo): void {
|
||||
$c = require __DIR__ . '/config.php';
|
||||
$driver = $c['db_driver'];
|
||||
|
||||
$migrationTableExisted = _table_exists($pdo, 'schema_migrations', $driver);
|
||||
|
||||
$pdo->exec("
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version VARCHAR(255) NOT NULL PRIMARY KEY,
|
||||
applied_at VARCHAR(32) NOT NULL
|
||||
)
|
||||
");
|
||||
|
||||
$files = glob(__DIR__ . '/../migrations/*.php');
|
||||
if (!$files) return;
|
||||
sort($files);
|
||||
|
||||
// Existing DB without migration table: stamp all known migrations as applied.
|
||||
if (!$migrationTableExisted && _table_exists($pdo, 'users', $driver)) {
|
||||
$insertIgnore = $driver === 'sqlite' ? 'INSERT OR IGNORE' : 'INSERT IGNORE';
|
||||
$stmt = $pdo->prepare(
|
||||
"$insertIgnore INTO schema_migrations (version, applied_at) VALUES (?, ?)"
|
||||
);
|
||||
foreach ($files as $file) {
|
||||
$stmt->execute([basename($file, '.php'), date('c')]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
$applied = $pdo->query('SELECT version FROM schema_migrations')
|
||||
->fetchAll(PDO::FETCH_COLUMN);
|
||||
$applied = array_flip($applied);
|
||||
|
||||
$useTransactions = ($driver === 'sqlite');
|
||||
|
||||
foreach ($files as $file) {
|
||||
$version = basename($file, '.php');
|
||||
if (isset($applied[$version])) continue;
|
||||
|
||||
$migration = require $file;
|
||||
if ($useTransactions) $pdo->beginTransaction();
|
||||
try {
|
||||
foreach ((array) $migration['up'] as $sql) {
|
||||
$pdo->exec($sql);
|
||||
}
|
||||
$pdo->prepare(
|
||||
'INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)'
|
||||
)->execute([$version, date('c')]);
|
||||
if ($useTransactions) $pdo->commit();
|
||||
} catch (Throwable $e) {
|
||||
if ($useTransactions && $pdo->inTransaction()) $pdo->rollBack();
|
||||
throw new RuntimeException(
|
||||
"Migration {$version} failed: " . $e->getMessage(), 0, $e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
|
||||
function layout_start(string $title, string $current_page, string $extra_head = ''): void {
|
||||
$name = current_name();
|
||||
$admin = is_admin();
|
||||
$user = current_user();
|
||||
$nav = [
|
||||
'dashboard' => ['href' => 'dashboard.php', 'icon' => '⊞', 'label' => 'Dashboard'],
|
||||
'stundennachweis' => ['href' => 'stundennachweis.php', 'icon' => '🕐', 'label' => 'Stundennachweis'],
|
||||
'urlaubsantrag' => ['href' => 'urlaubsantrag.php', 'icon' => '🌴', 'label' => 'Urlaubsantrag'],
|
||||
'dienstplan' => ['href' => 'dienstplan.php', 'icon' => '📋', 'label' => 'Dienstplan'],
|
||||
'downloads' => ['href' => 'downloads.php', 'icon' => '📥', 'label' => 'Downloads'],
|
||||
'einsatzanweisung' => ['href' => 'einsatzanweisung.php', 'icon' => '📄', 'label' => 'Einsatzanweisung'],
|
||||
'benefitsantrag' => ['href' => 'benefitsantrag.php', 'icon' => '🎁', 'label' => 'Benefitsantrag'],
|
||||
'abwesenheitsantrag' => ['href' => 'abwesenheitsantrag.php', 'icon' => '📅', 'label' => 'Abwesenheitsantrag'],
|
||||
'fortbildungsantrag' => ['href' => 'fortbildungsantrag.php', 'icon' => '🎓', 'label' => 'Fortbildungsantrag'],
|
||||
'dokumentenarchiv' => ['href' => 'dokumentenarchiv.php', 'icon' => '🗂', 'label' => 'Dokumentenarchiv'],
|
||||
'einsatzbewertung' => ['href' => 'einsatzbewertung.php', 'icon' => '⭐', 'label' => 'Einsatzbewertung'],
|
||||
'werben' => ['href' => 'werben.php', 'icon' => '🤝', 'label' => 'Mitarbeiter werben'],
|
||||
'settings' => ['href' => 'settings.php', 'icon' => '⚙', 'label' => 'Einstellungen'],
|
||||
];
|
||||
if ($admin) {
|
||||
$nav['admin'] = ['href' => 'admin.php', 'icon' => '🛡', 'label' => 'Verwaltung'];
|
||||
}
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title><?= e($title) ?></title>
|
||||
<link rel="stylesheet" href="../app.css">
|
||||
<link rel="manifest" href="../manifest.webmanifest">
|
||||
<meta name="theme-color" content="#1a3a5c">
|
||||
<meta name="csrf-token" content="<?= csrf_token() ?>">
|
||||
<?= $extra_head ?>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="dashboard-layout">
|
||||
|
||||
<aside class="sidebar glass">
|
||||
<div class="sidebar-brand">
|
||||
<img src="../assets/omsorg-wordmark-new.png" alt="OMSORG">
|
||||
<span class="sidebar-app-name">Mitarbeiter-App</span>
|
||||
<button class="nav-toggle" type="button" aria-label="Menü öffnen" aria-expanded="false">☰</button>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-user">
|
||||
<div class="sidebar-avatar">
|
||||
<?php if (!empty($user['avatar'])): ?>
|
||||
<img src="../assets/avatars/<?= e($user['avatar']) ?>" alt="<?= e($name) ?>">
|
||||
<?php else: ?>
|
||||
<?= e(mb_substr($name, 0, 1)) ?>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<div>
|
||||
<b><?= e($name) ?></b>
|
||||
<small><?= $admin ? 'Admin' : 'Mitarbeiter' ?></small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="sidebar-nav">
|
||||
<p class="sidebar-nav-label">Navigation</p>
|
||||
<?php foreach ($nav as $key => $item): ?>
|
||||
<a href="<?= e($item['href']) ?>"<?= $current_page === $key ? ' class="active"' : '' ?>>
|
||||
<span class="nav-icon"><?= $item['icon'] ?></span><span class="nav-label"> <?= e($item['label']) ?></span>
|
||||
</a>
|
||||
<?php endforeach; ?>
|
||||
</nav>
|
||||
|
||||
<div class="sidebar-footer">
|
||||
<a href="../actions/logout.php" class="sidebar-logout">Abmelden</a>
|
||||
</div>
|
||||
</aside>
|
||||
<div class="nav-backdrop" onclick="closeNav()"></div>
|
||||
|
||||
<main class="main-content">
|
||||
<div class="main-inner">
|
||||
<?php
|
||||
}
|
||||
|
||||
function layout_end(): void {
|
||||
?>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
if ('serviceWorker' in navigator) navigator.serviceWorker.register('../service-worker.js');
|
||||
function closeNav(){
|
||||
document.body.classList.remove('nav-open');
|
||||
const btn=document.querySelector('.nav-toggle');
|
||||
if(btn){btn.textContent='☰';btn.setAttribute('aria-expanded','false');}
|
||||
}
|
||||
const navToggle=document.querySelector('.nav-toggle');
|
||||
if(navToggle){
|
||||
navToggle.addEventListener('click',function(){
|
||||
const open=document.body.classList.toggle('nav-open');
|
||||
this.textContent=open?'✕':'☰';
|
||||
this.setAttribute('aria-expanded',open);
|
||||
});
|
||||
}
|
||||
document.addEventListener('keydown',e=>{if(e.key==='Escape')closeNav();});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
<?php
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
<?php
|
||||
/**
|
||||
* Sends an email with an optional file attachment via SMTP.
|
||||
*
|
||||
* @param array $cfg Config array with smtp_host, smtp_port, smtp_user, smtp_password, mail_from
|
||||
* @param string $to Recipient address
|
||||
* @param string $subject Subject (plain UTF-8, will be encoded)
|
||||
* @param string $body Plain-text body
|
||||
* @param array $attach Optional: ['path' => '/abs/path', 'name' => 'display.pdf', 'mime' => 'application/pdf']
|
||||
* @return array ['ok' => bool, 'log' => string]
|
||||
*/
|
||||
function smtp_send(array $cfg, string $to, string $subject, string $body, array $attach = []): array
|
||||
{
|
||||
$log = '';
|
||||
|
||||
$read = function ($conn) use (&$log) {
|
||||
$line = '';
|
||||
while ($chunk = fgets($conn, 512)) {
|
||||
$log .= '<< ' . $chunk;
|
||||
$line = $chunk;
|
||||
if ($chunk[3] === ' ') break; // last line of multi-line response
|
||||
}
|
||||
return $line;
|
||||
};
|
||||
|
||||
$write = function ($conn, string $cmd) use (&$log) {
|
||||
$log .= '>> ' . $cmd;
|
||||
fwrite($conn, $cmd);
|
||||
};
|
||||
|
||||
$code = fn(string $line) => (int)substr($line, 0, 3);
|
||||
|
||||
try {
|
||||
$host = trim($cfg['smtp_host']);
|
||||
$port = (int)$cfg['smtp_port'];
|
||||
$ssl = ($port === 465);
|
||||
|
||||
$conn = @fsockopen(($ssl ? 'ssl://' : '') . $host, $port, $errno, $errstr, 10);
|
||||
if (!$conn) {
|
||||
return ['ok' => false, 'log' => "Connect failed: $errstr ($errno)"];
|
||||
}
|
||||
stream_set_timeout($conn, 10);
|
||||
|
||||
$line = $read($conn);
|
||||
if ($code($line) !== 220) throw new RuntimeException("Unexpected greeting: $line");
|
||||
|
||||
$write($conn, "EHLO omsorg-connect\r\n");
|
||||
$capabilities = '';
|
||||
while ($chunk = fgets($conn, 512)) {
|
||||
$log .= '<< ' . $chunk;
|
||||
$capabilities .= $chunk;
|
||||
if ($chunk[3] === ' ') break;
|
||||
}
|
||||
if ($code($capabilities) !== 250) throw new RuntimeException("EHLO failed");
|
||||
|
||||
if (!$ssl && str_contains($capabilities, 'STARTTLS')) {
|
||||
$write($conn, "STARTTLS\r\n");
|
||||
$line = $read($conn);
|
||||
if ($code($line) !== 220) throw new RuntimeException("STARTTLS failed: $line");
|
||||
if (!stream_socket_enable_crypto($conn, true, STREAM_CRYPTO_METHOD_TLS_CLIENT)) {
|
||||
throw new RuntimeException("TLS handshake failed");
|
||||
}
|
||||
$write($conn, "EHLO omsorg-connect\r\n");
|
||||
while ($chunk = fgets($conn, 512)) {
|
||||
$log .= '<< ' . $chunk;
|
||||
if ($chunk[3] === ' ') break;
|
||||
}
|
||||
}
|
||||
|
||||
$write($conn, "AUTH LOGIN\r\n");
|
||||
$line = $read($conn);
|
||||
if ($code($line) !== 334) throw new RuntimeException("AUTH LOGIN failed: $line");
|
||||
|
||||
$write($conn, base64_encode($cfg['smtp_user']) . "\r\n");
|
||||
$line = $read($conn);
|
||||
if ($code($line) !== 334) throw new RuntimeException("Username rejected: $line");
|
||||
|
||||
$write($conn, base64_encode($cfg['smtp_password']) . "\r\n");
|
||||
$line = $read($conn);
|
||||
if ($code($line) !== 235) throw new RuntimeException("Password rejected: $line");
|
||||
|
||||
$write($conn, "MAIL FROM:<{$cfg['mail_from']}>\r\n");
|
||||
$line = $read($conn);
|
||||
if ($code($line) !== 250) throw new RuntimeException("MAIL FROM failed: $line");
|
||||
|
||||
$write($conn, "RCPT TO:<$to>\r\n");
|
||||
$line = $read($conn);
|
||||
if ($code($line) !== 250) throw new RuntimeException("RCPT TO failed: $line");
|
||||
|
||||
$write($conn, "DATA\r\n");
|
||||
$line = $read($conn);
|
||||
if ($code($line) !== 354) throw new RuntimeException("DATA failed: $line");
|
||||
|
||||
$enc_subject = '=?UTF-8?B?' . base64_encode($subject) . '?=';
|
||||
$boundary = 'omsorg_' . md5(uniqid((string)mt_rand(), true));
|
||||
|
||||
$headers = "From: OMSORG Connect <{$cfg['mail_from']}>\r\n";
|
||||
$headers .= "To: $to\r\n";
|
||||
$headers .= "Subject: $enc_subject\r\n";
|
||||
$headers .= "MIME-Version: 1.0\r\n";
|
||||
|
||||
if ($attach) {
|
||||
$headers .= "Content-Type: multipart/mixed; boundary=\"$boundary\"\r\n";
|
||||
$msg = $headers . "\r\n";
|
||||
$msg .= "--$boundary\r\n";
|
||||
$msg .= "Content-Type: text/plain; charset=UTF-8\r\nContent-Transfer-Encoding: 8bit\r\n\r\n";
|
||||
$msg .= $body . "\r\n\r\n";
|
||||
$safe_name = preg_replace('/[^A-Za-z0-9._-]/', '_', $attach['name']);
|
||||
$file_data = chunk_split(base64_encode(file_get_contents($attach['path'])));
|
||||
$msg .= "--$boundary\r\n";
|
||||
$msg .= "Content-Type: {$attach['mime']}; name=\"$safe_name\"\r\n";
|
||||
$msg .= "Content-Transfer-Encoding: base64\r\n";
|
||||
$msg .= "Content-Disposition: attachment; filename=\"$safe_name\"\r\n\r\n";
|
||||
$msg .= $file_data . "\r\n";
|
||||
$msg .= "--$boundary--";
|
||||
} else {
|
||||
$headers .= "Content-Type: text/plain; charset=UTF-8\r\n";
|
||||
$msg = $headers . "\r\n" . $body;
|
||||
}
|
||||
|
||||
// Dot-stuffing: lines starting with '.' must be doubled
|
||||
$msg = str_replace("\n.", "\n..", $msg);
|
||||
$write($conn, $msg . "\r\n.\r\n");
|
||||
$line = $read($conn);
|
||||
if ($code($line) !== 250) throw new RuntimeException("Message rejected: $line");
|
||||
|
||||
$write($conn, "QUIT\r\n");
|
||||
$read($conn);
|
||||
fclose($conn);
|
||||
|
||||
return ['ok' => true, 'log' => $log];
|
||||
|
||||
} catch (RuntimeException $e) {
|
||||
if (isset($conn) && is_resource($conn)) fclose($conn);
|
||||
return ['ok' => false, 'log' => $log . "\nError: " . $e->getMessage()];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/auth.php';
|
||||
|
||||
// Gemeinsame Endung→MIME-Whitelists.
|
||||
const UPLOAD_TYPES_DOCS = [
|
||||
'pdf' => ['application/pdf'],
|
||||
'doc' => ['application/msword'],
|
||||
'docx' => ['application/vnd.openxmlformats-officedocument.wordprocessingml.document'],
|
||||
'jpg' => ['image/jpeg'],
|
||||
'jpeg' => ['image/jpeg'],
|
||||
'png' => ['image/png'],
|
||||
];
|
||||
|
||||
const UPLOAD_TYPES_PDF_IMG = [
|
||||
'pdf' => ['application/pdf'],
|
||||
'jpg' => ['image/jpeg'],
|
||||
'jpeg' => ['image/jpeg'],
|
||||
'png' => ['image/png'],
|
||||
];
|
||||
|
||||
const UPLOAD_TYPES_IMAGES = [
|
||||
'jpg' => ['image/jpeg'],
|
||||
'jpeg' => ['image/jpeg'],
|
||||
'png' => ['image/png'],
|
||||
'gif' => ['image/gif'],
|
||||
'webp' => ['image/webp'],
|
||||
];
|
||||
|
||||
const UPLOAD_TYPES_PDF = [
|
||||
'pdf' => ['application/pdf'],
|
||||
];
|
||||
|
||||
const UPLOAD_TYPES_OFFICE = [
|
||||
'pdf' => ['application/pdf'],
|
||||
'doc' => ['application/msword'],
|
||||
'docx' => ['application/vnd.openxmlformats-officedocument.wordprocessingml.document'],
|
||||
'xls' => ['application/vnd.ms-excel'],
|
||||
'xlsx' => ['application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'],
|
||||
'ppt' => ['application/vnd.ms-powerpoint'],
|
||||
'pptx' => ['application/vnd.openxmlformats-officedocument.presentationml.presentation'],
|
||||
'txt' => ['text/plain'],
|
||||
'zip' => ['application/zip', 'application/x-zip-compressed'],
|
||||
];
|
||||
|
||||
/**
|
||||
* Validiert und speichert eine hochgeladene Datei.
|
||||
*
|
||||
* @param array $file Eintrag aus $_FILES, z.B. $_FILES['datei']
|
||||
* @param array $opts allowed (ext=>mimes, Pflicht), dir (Pflicht), prefix,
|
||||
* max_bytes (Default 12 MB), required (Default true),
|
||||
* naming ('dated' [Default] = Datum_Username_Prefix_Rand,
|
||||
* 'plain' = Username_Rand, 'hash' = nur Rand),
|
||||
* username (Default current_username(); für 'dated'/'plain'),
|
||||
* messages (Überschreibt Standardtexte: missing/upload/ext/mime/size/move)
|
||||
* @return array ['ok'=>bool, 'error'=>?string, 'filename'=>?string,
|
||||
* 'original_name'=>?string, 'mime'=>?string]
|
||||
*/
|
||||
function handle_upload(array $file, array $opts): array {
|
||||
$allowed = $opts['allowed'];
|
||||
$dir = rtrim($opts['dir'], '/') . '/';
|
||||
$maxBytes = $opts['max_bytes'] ?? 12 * 1024 * 1024;
|
||||
$required = $opts['required'] ?? true;
|
||||
$naming = $opts['naming'] ?? 'dated';
|
||||
$prefix = $opts['prefix'] ?? 'datei';
|
||||
$username = $opts['username'] ?? current_username();
|
||||
|
||||
$msg = array_merge([
|
||||
'missing' => 'Bitte eine Datei auswählen.',
|
||||
'upload' => 'Fehler beim Hochladen der Datei.',
|
||||
'ext' => 'Dateityp nicht erlaubt.',
|
||||
'mime' => 'Dateityp nicht erlaubt.',
|
||||
'size' => 'Datei ist zu groß (max. 12 MB).',
|
||||
'move' => 'Fehler beim Hochladen der Datei.',
|
||||
], $opts['messages'] ?? []);
|
||||
|
||||
$err = $file['error'] ?? UPLOAD_ERR_NO_FILE;
|
||||
|
||||
if ($err === UPLOAD_ERR_NO_FILE || !isset($file['tmp_name'])) {
|
||||
if ($required) return ['ok' => false, 'error' => $msg['missing']];
|
||||
return ['ok' => true, 'error' => null, 'filename' => null, 'original_name' => null, 'mime' => null];
|
||||
}
|
||||
if ($err !== UPLOAD_ERR_OK) {
|
||||
return ['ok' => false, 'error' => $msg['upload']];
|
||||
}
|
||||
|
||||
$originalName = $file['name'];
|
||||
$ext = strtolower(pathinfo($originalName, PATHINFO_EXTENSION));
|
||||
if (!isset($allowed[$ext])) {
|
||||
return ['ok' => false, 'error' => $msg['ext']];
|
||||
}
|
||||
|
||||
$finfo = new finfo(FILEINFO_MIME_TYPE);
|
||||
$mime = $finfo->file($file['tmp_name']);
|
||||
if (!in_array($mime, $allowed[$ext], true)) {
|
||||
return ['ok' => false, 'error' => $msg['mime']];
|
||||
}
|
||||
|
||||
if ($file['size'] > $maxBytes) {
|
||||
return ['ok' => false, 'error' => $msg['size']];
|
||||
}
|
||||
|
||||
$safeName = match ($naming) {
|
||||
'hash' => bin2hex(random_bytes(16)) . '.' . $ext,
|
||||
'plain' => $username . '_' . bin2hex(random_bytes(8)) . '.' . $ext,
|
||||
default => date('Y-m-d') . '_' . $username . '_' . $prefix . '_' . bin2hex(random_bytes(8)) . '.' . $ext,
|
||||
};
|
||||
|
||||
if (!is_dir($dir)) {
|
||||
mkdir($dir, 0755, true);
|
||||
}
|
||||
if (!move_uploaded_file($file['tmp_name'], $dir . $safeName)) {
|
||||
return ['ok' => false, 'error' => $msg['move']];
|
||||
}
|
||||
|
||||
return [
|
||||
'ok' => true,
|
||||
'error' => null,
|
||||
'filename' => $safeName,
|
||||
'original_name' => $originalName,
|
||||
'mime' => $mime,
|
||||
];
|
||||
}
|
||||
|
||||
function redirect_error(string $back, string $msg): never {
|
||||
header('Location: ' . $back . '?error=' . urlencode($msg));
|
||||
exit;
|
||||
}
|
||||
|
||||
function redirect_ok(string $back, string $param = 'success', string $val = '1'): never {
|
||||
header('Location: ' . $back . '?' . $param . '=' . $val);
|
||||
exit;
|
||||
}
|
||||
Reference in New Issue
Block a user