Initiales CMS: Deutschsprachiges Blog-System mit Admin-Bereich
Vollständiges, schlankes PHP/SQLite-CMS für IT-, KI- und Gaming-Inhalte: - Core: DB-Singleton, Auth mit Passwort-Hashing, Session-Cookies, CSRF-Schutz, Login-Rate-Limit, Bild-Upload mit serverseitiger Validierung - Admin: Dashboard, Artikel/Seiten-Verwaltung mit Quill WYSIWYG-Editor, Kategorien, Navigation (Drag & Drop), Medienbibliothek, Profil - Frontend: Responsive Dark-Theme, Artikel-Grid, Kategorie-Filter, Archiv, Paginierung, SEO-Meta-Tags - Sicherheit: Prepared Statements, HTML-Sanitizer, .htaccess-Schutz für sensible Verzeichnisse, PHP-Ausführungsschutz im Upload-Ordner - Installation: install.php erstellt DB-Schema und Admin-Account https://claude.ai/code/session_01Xsg4j2t4S9goMuWVpF3ezG
This commit is contained in:
1
core/.htaccess
Normal file
1
core/.htaccess
Normal file
@@ -0,0 +1 @@
|
||||
Deny from all
|
||||
132
core/auth.php
Normal file
132
core/auth.php
Normal file
@@ -0,0 +1,132 @@
|
||||
<?php
|
||||
/**
|
||||
* Authentifizierung, Session-Management, CSRF-Schutz, Rate-Limiting
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../config/config.php';
|
||||
require_once __DIR__ . '/db.php';
|
||||
require_once __DIR__ . '/helpers.php';
|
||||
|
||||
function auth_start_session(): void
|
||||
{
|
||||
if (session_status() === PHP_SESSION_ACTIVE) {
|
||||
return;
|
||||
}
|
||||
|
||||
$secure = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off');
|
||||
|
||||
session_set_cookie_params([
|
||||
'lifetime' => SESSION_LIFETIME,
|
||||
'path' => '/',
|
||||
'secure' => $secure,
|
||||
'httponly' => true,
|
||||
'samesite' => 'Strict',
|
||||
]);
|
||||
|
||||
session_start();
|
||||
|
||||
// Session-ID alle 15 Minuten erneuern
|
||||
if (!isset($_SESSION['_last_regeneration'])) {
|
||||
$_SESSION['_last_regeneration'] = time();
|
||||
} elseif (time() - $_SESSION['_last_regeneration'] > 900) {
|
||||
session_regenerate_id(true);
|
||||
$_SESSION['_last_regeneration'] = time();
|
||||
}
|
||||
}
|
||||
|
||||
function auth_is_logged_in(): bool
|
||||
{
|
||||
return !empty($_SESSION['user_id']);
|
||||
}
|
||||
|
||||
function auth_require_login(): void
|
||||
{
|
||||
if (!auth_is_logged_in()) {
|
||||
redirect('/admin/login.php');
|
||||
}
|
||||
}
|
||||
|
||||
function auth_login(string $username, string $password): bool
|
||||
{
|
||||
$stmt = db()->prepare('SELECT id, password_hash, display_name FROM users WHERE username = ?');
|
||||
$stmt->execute([$username]);
|
||||
$user = $stmt->fetch();
|
||||
|
||||
if (!$user || !password_verify($password, $user['password_hash'])) {
|
||||
auth_record_attempt();
|
||||
return false;
|
||||
}
|
||||
|
||||
// Session erneuern bei Login
|
||||
session_regenerate_id(true);
|
||||
$_SESSION['user_id'] = $user['id'];
|
||||
$_SESSION['display_name'] = $user['display_name'];
|
||||
$_SESSION['_last_regeneration'] = time();
|
||||
|
||||
// Fehlversuche löschen
|
||||
$ip = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
|
||||
$stmt = db()->prepare('DELETE FROM login_attempts WHERE ip_address = ?');
|
||||
$stmt->execute([$ip]);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function auth_logout(): void
|
||||
{
|
||||
$_SESSION = [];
|
||||
if (ini_get('session.use_cookies')) {
|
||||
$p = session_get_cookie_params();
|
||||
setcookie(session_name(), '', time() - 42000, $p['path'], $p['domain'], $p['secure'], $p['httponly']);
|
||||
}
|
||||
session_destroy();
|
||||
}
|
||||
|
||||
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="' . e(csrf_token()) . '">';
|
||||
}
|
||||
|
||||
function csrf_verify(): void
|
||||
{
|
||||
$token = $_POST['csrf_token'] ?? '';
|
||||
if (!hash_equals(csrf_token(), $token)) {
|
||||
http_response_code(403);
|
||||
die('Ungültiges CSRF-Token.');
|
||||
}
|
||||
}
|
||||
|
||||
function auth_check_rate_limit(): bool
|
||||
{
|
||||
$ip = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
|
||||
$stmt = db()->prepare(
|
||||
'SELECT COUNT(*) FROM login_attempts WHERE ip_address = ? AND attempted_at > datetime("now", ?)'
|
||||
);
|
||||
$stmt->execute([$ip, '-' . LOGIN_LOCKOUT_MINUTES . ' minutes']);
|
||||
$count = (int) $stmt->fetchColumn();
|
||||
return $count < LOGIN_MAX_ATTEMPTS;
|
||||
}
|
||||
|
||||
function auth_record_attempt(): void
|
||||
{
|
||||
$ip = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
|
||||
$stmt = db()->prepare('INSERT INTO login_attempts (ip_address) VALUES (?)');
|
||||
$stmt->execute([$ip]);
|
||||
}
|
||||
|
||||
function auth_user_id(): ?int
|
||||
{
|
||||
return $_SESSION['user_id'] ?? null;
|
||||
}
|
||||
|
||||
function auth_display_name(): string
|
||||
{
|
||||
return $_SESSION['display_name'] ?? 'Admin';
|
||||
}
|
||||
21
core/db.php
Normal file
21
core/db.php
Normal file
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
/**
|
||||
* Datenbank-Verbindung (SQLite PDO Singleton)
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../config/config.php';
|
||||
|
||||
function db(): PDO
|
||||
{
|
||||
static $pdo = null;
|
||||
if ($pdo === null) {
|
||||
$pdo = new PDO('sqlite:' . DB_PATH, null, null, [
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||
PDO::ATTR_EMULATE_PREPARES => false,
|
||||
]);
|
||||
$pdo->exec('PRAGMA journal_mode=WAL');
|
||||
$pdo->exec('PRAGMA foreign_keys=ON');
|
||||
}
|
||||
return $pdo;
|
||||
}
|
||||
107
core/helpers.php
Normal file
107
core/helpers.php
Normal file
@@ -0,0 +1,107 @@
|
||||
<?php
|
||||
/**
|
||||
* Hilfsfunktionen
|
||||
*/
|
||||
|
||||
function e(string $str): string
|
||||
{
|
||||
return htmlspecialchars($str, ENT_QUOTES, 'UTF-8');
|
||||
}
|
||||
|
||||
function slugify(string $str): string
|
||||
{
|
||||
$replacements = [
|
||||
'ä' => 'ae', 'ö' => 'oe', 'ü' => 'ue', 'ß' => 'ss',
|
||||
'Ä' => 'ae', 'Ö' => 'oe', 'Ü' => 'ue',
|
||||
];
|
||||
$str = str_replace(array_keys($replacements), array_values($replacements), $str);
|
||||
$str = mb_strtolower($str, 'UTF-8');
|
||||
$str = preg_replace('/[^a-z0-9\s-]/', '', $str);
|
||||
$str = preg_replace('/[\s-]+/', '-', $str);
|
||||
return trim($str, '-');
|
||||
}
|
||||
|
||||
function redirect(string $url): never
|
||||
{
|
||||
header('Location: ' . $url);
|
||||
exit;
|
||||
}
|
||||
|
||||
function flash(string $type, string $message): void
|
||||
{
|
||||
$_SESSION['flash'][] = ['type' => $type, 'message' => $message];
|
||||
}
|
||||
|
||||
function flash_display(): string
|
||||
{
|
||||
if (empty($_SESSION['flash'])) {
|
||||
return '';
|
||||
}
|
||||
$html = '';
|
||||
foreach ($_SESSION['flash'] as $msg) {
|
||||
$cls = e($msg['type']);
|
||||
$text = e($msg['message']);
|
||||
$html .= "<div class=\"flash flash-{$cls}\">{$text}</div>";
|
||||
}
|
||||
$_SESSION['flash'] = [];
|
||||
return $html;
|
||||
}
|
||||
|
||||
function paginate(int $total, int $page, int $perPage): array
|
||||
{
|
||||
$totalPages = max(1, (int) ceil($total / $perPage));
|
||||
$page = max(1, min($page, $totalPages));
|
||||
$offset = ($page - 1) * $perPage;
|
||||
return [
|
||||
'offset' => $offset,
|
||||
'limit' => $perPage,
|
||||
'total_pages' => $totalPages,
|
||||
'current_page' => $page,
|
||||
'total' => $total,
|
||||
];
|
||||
}
|
||||
|
||||
function format_date(string $datetime): string
|
||||
{
|
||||
$months = [
|
||||
1 => 'Januar', 2 => 'Februar', 3 => 'März', 4 => 'April',
|
||||
5 => 'Mai', 6 => 'Juni', 7 => 'Juli', 8 => 'August',
|
||||
9 => 'September', 10 => 'Oktober', 11 => 'November', 12 => 'Dezember',
|
||||
];
|
||||
$ts = strtotime($datetime);
|
||||
if ($ts === false) {
|
||||
return $datetime;
|
||||
}
|
||||
$day = (int) date('j', $ts);
|
||||
$month = $months[(int) date('n', $ts)];
|
||||
$year = date('Y', $ts);
|
||||
return "{$day}. {$month} {$year}";
|
||||
}
|
||||
|
||||
function excerpt(string $html, int $length = 200): string
|
||||
{
|
||||
$text = strip_tags($html);
|
||||
if (mb_strlen($text) <= $length) {
|
||||
return $text;
|
||||
}
|
||||
$truncated = mb_substr($text, 0, $length);
|
||||
$lastSpace = mb_strrpos($truncated, ' ');
|
||||
if ($lastSpace !== false) {
|
||||
$truncated = mb_substr($truncated, 0, $lastSpace);
|
||||
}
|
||||
return $truncated . '…';
|
||||
}
|
||||
|
||||
function sanitize_html(string $html): string
|
||||
{
|
||||
$allowed = '<p><br><strong><b><em><i><u><ul><ol><li><h2><h3><h4><a><img><blockquote><pre><code><hr><table><thead><tbody><tr><th><td>';
|
||||
$html = strip_tags($html, $allowed);
|
||||
|
||||
// Event-Handler und javascript:-URLs entfernen
|
||||
$html = preg_replace('/\bon\w+\s*=\s*["\'][^"\']*["\']/i', '', $html);
|
||||
$html = preg_replace('/\bon\w+\s*=\s*\S+/i', '', $html);
|
||||
$html = preg_replace('/href\s*=\s*["\']?\s*javascript\s*:[^"\'>\s]*/i', 'href="#"', $html);
|
||||
$html = preg_replace('/src\s*=\s*["\']?\s*javascript\s*:[^"\'>\s]*/i', 'src=""', $html);
|
||||
|
||||
return $html;
|
||||
}
|
||||
55
core/upload.php
Normal file
55
core/upload.php
Normal file
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
/**
|
||||
* Bild-Upload mit Validierung
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../config/config.php';
|
||||
|
||||
function handle_upload(array $file): string|false
|
||||
{
|
||||
if ($file['error'] !== UPLOAD_ERR_OK) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($file['size'] > UPLOAD_MAX_SIZE) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// MIME-Typ serverseitig prüfen
|
||||
$finfo = new finfo(FILEINFO_MIME_TYPE);
|
||||
$mime = $finfo->file($file['tmp_name']);
|
||||
if (!in_array($mime, ALLOWED_MIME_TYPES, true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Prüfen ob es ein echtes Bild ist
|
||||
$imageInfo = getimagesize($file['tmp_name']);
|
||||
if ($imageInfo === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Dateiendung aus MIME ableiten
|
||||
$extensions = [
|
||||
'image/jpeg' => '.jpg',
|
||||
'image/png' => '.png',
|
||||
'image/gif' => '.gif',
|
||||
'image/webp' => '.webp',
|
||||
];
|
||||
$ext = $extensions[$mime] ?? '.jpg';
|
||||
|
||||
// Eindeutigen Dateinamen generieren
|
||||
$subdir = date('Y/m');
|
||||
$dir = rtrim(UPLOAD_DIR, '/') . '/' . $subdir;
|
||||
if (!is_dir($dir)) {
|
||||
mkdir($dir, 0755, true);
|
||||
}
|
||||
|
||||
$filename = bin2hex(random_bytes(16)) . $ext;
|
||||
$filepath = $dir . '/' . $filename;
|
||||
|
||||
if (!move_uploaded_file($file['tmp_name'], $filepath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return UPLOAD_URL . $subdir . '/' . $filename;
|
||||
}
|
||||
Reference in New Issue
Block a user