Initial commit: OMSORG website + Mitarbeiter-App

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Felix Kemmler
2026-06-13 20:03:22 +02:00
co-authored by Claude Sonnet 4.6
commit 8beb0fcf52
103 changed files with 7384 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
Require all denied
+111
View File
@@ -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',
];
+90
View File
@@ -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
);
}
}
}
+110
View File
@@ -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
}
+137
View File
@@ -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()];
}
}
+132
View File
@@ -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;
}