Add MySQL schema dump for phpMyAdmin

This commit is contained in:
Thomas
2025-10-28 14:08:03 +01:00
parent f956a735ca
commit 8e608d03ec
49 changed files with 1929 additions and 1 deletions

View File

@@ -0,0 +1,36 @@
<?php
namespace App\Controllers;
use App\Core\Controller;
use App\Core\Response;
use App\Services\AuthService;
class AuthController extends Controller
{
public function showLogin(): Response
{
return $this->view('auth/login');
}
public function login(): Response
{
$email = $_POST['email'] ?? '';
$password = $_POST['password'] ?? '';
$auth = AuthService::getInstance();
if ($auth->attempt($email, $password)) {
return $this->redirect('/');
}
return $this->view('auth/login', [
'error' => 'Invalid credentials or inactive account.',
]);
}
public function logout(): Response
{
AuthService::getInstance()->logout();
return $this->redirect('/login');
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Controllers;
use App\Core\Controller;
use App\Core\Response;
use App\Services\AuthService;
class DashboardController extends Controller
{
public function index(): Response
{
$user = AuthService::getInstance()->user();
return $this->view('dashboard/index', [
'user' => $user,
]);
}
}

93
app/Core/Config.php Normal file
View File

@@ -0,0 +1,93 @@
<?php
namespace App\Core;
class Config
{
private static ?self $instance = null;
/** @var array<string, mixed> */
private array $values = [];
private function __construct()
{
$this->loadEnv();
$this->loadConfigFiles();
}
public static function getInstance(): self
{
if (self::$instance === null) {
self::$instance = new self();
}
return self::$instance;
}
public function get(string $key, $default = null)
{
$segments = explode('.', $key);
$value = $this->values;
foreach ($segments as $segment) {
if (!is_array($value) || !array_key_exists($segment, $value)) {
return $default;
}
$value = $value[$segment];
}
return $value;
}
/**
* @param array<string, mixed> $config
*/
public function set(array $config): void
{
$this->values = array_replace_recursive($this->values, $config);
}
private function loadEnv(): void
{
$envPath = base_path('.env');
if (!file_exists($envPath)) {
return;
}
$lines = file($envPath, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
if ($lines === false) {
return;
}
foreach ($lines as $line) {
if (str_starts_with(trim($line), '#')) {
continue;
}
[$name, $value] = array_map('trim', explode('=', $line, 2) + [1 => '']);
$value = trim($value, "\"' ");
$_ENV[$name] = $value;
putenv("{$name}={$value}");
}
}
private function loadConfigFiles(): void
{
$configDir = base_path('config');
if (!is_dir($configDir)) {
return;
}
$files = glob($configDir . '/*.php');
if ($files === false) {
return;
}
foreach ($files as $file) {
$key = basename($file, '.php');
/** @var array<string, mixed> $data */
$data = require $file;
$this->values[$key] = $data;
}
}
}

17
app/Core/Controller.php Normal file
View File

@@ -0,0 +1,17 @@
<?php
namespace App\Core;
class Controller
{
protected function view(string $template, array $data = []): Response
{
$content = View::render($template, $data);
return new Response($content);
}
protected function redirect(string $url): Response
{
return new Response('', 302, ['Location' => $url]);
}
}

44
app/Core/Database.php Normal file
View File

@@ -0,0 +1,44 @@
<?php
namespace App\Core;
use PDO;
use PDOException;
class Database
{
private static ?PDO $connection = null;
public static function connection(): PDO
{
if (self::$connection === null) {
$config = config('database');
$dsn = sprintf(
'mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4',
$config['host'],
$config['port'],
$config['database']
);
$options = [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
];
try {
self::$connection = new PDO(
$dsn,
$config['username'],
$config['password'],
$options
);
} catch (PDOException $exception) {
throw new \RuntimeException('Database connection failed: ' . $exception->getMessage());
}
}
return self::$connection;
}
}

39
app/Core/Response.php Normal file
View File

@@ -0,0 +1,39 @@
<?php
namespace App\Core;
class Response
{
private string $body;
private int $status;
/** @var array<string, string> */
private array $headers;
/**
* @param array<string, string> $headers
*/
public function __construct(string $body = '', int $status = 200, array $headers = [])
{
$this->body = $body;
$this->status = $status;
$this->headers = $headers;
}
public function getBody(): string
{
return $this->body;
}
public function getStatus(): int
{
return $this->status;
}
/**
* @return array<string, string>
*/
public function getHeaders(): array
{
return $this->headers;
}
}

97
app/Core/Router.php Normal file
View File

@@ -0,0 +1,97 @@
<?php
namespace App\Core;
class Router
{
/** @var array<string, array<int, array{path:string,action:callable,middleware:array}>> */
private array $routes = [];
/** @var array<int, array<int, class-string>> */
private array $middlewareStack = [];
public function get(string $path, callable $action, array $middleware = []): void
{
$this->addRoute('GET', $path, $action, $middleware);
}
public function post(string $path, callable $action, array $middleware = []): void
{
$this->addRoute('POST', $path, $action, $middleware);
}
public function put(string $path, callable $action, array $middleware = []): void
{
$this->addRoute('PUT', $path, $action, $middleware);
}
public function delete(string $path, callable $action, array $middleware = []): void
{
$this->addRoute('DELETE', $path, $action, $middleware);
}
public function group(array $attributes, \Closure $callback): void
{
$groupMiddleware = $attributes['middleware'] ?? [];
$this->middlewareStack[] = $groupMiddleware;
$callback($this);
array_pop($this->middlewareStack);
}
/**
* @param array<int, class-string> $middleware
*/
private function addRoute(string $method, string $path, callable $action, array $middleware = []): void
{
$stackedMiddleware = [];
foreach ($this->middlewareStack as $group) {
$stackedMiddleware = array_merge($stackedMiddleware, $group);
}
$combinedMiddleware = array_merge($stackedMiddleware, $middleware);
$this->routes[$method][] = [
'path' => $path,
'action' => $action,
'middleware' => $combinedMiddleware,
];
}
public function dispatch(string $method, string $uri): Response
{
$uri = parse_url($uri, PHP_URL_PATH) ?? '/';
$routes = $this->routes[$method] ?? [];
foreach ($routes as $route) {
$pattern = '#^' . preg_replace('#\{(.*?)\}#', '(?P<$1>[^/]+)', $route['path']) . '$#';
if (preg_match($pattern, $uri, $matches)) {
$params = array_filter($matches, 'is_string', ARRAY_FILTER_USE_KEY);
$handler = $this->applyMiddleware($route['action'], $route['middleware']);
$result = $handler($params);
if ($result instanceof Response) {
return $result;
}
return new Response((string) $result);
}
}
return new Response('Not Found', 404);
}
/**
* @param callable $action
* @param array<int, class-string> $middleware
*/
private function applyMiddleware(callable $action, array $middleware): callable
{
return array_reduce(
array_reverse($middleware),
function ($next, $middlewareClass) {
$middlewareInstance = new $middlewareClass();
return fn(array $params) => $middlewareInstance->handle($params, $next);
},
$action
);
}
}

19
app/Core/View.php Normal file
View File

@@ -0,0 +1,19 @@
<?php
namespace App\Core;
class View
{
public static function render(string $template, array $data = []): string
{
$path = base_path('resources/views/' . $template . '.php');
if (!file_exists($path)) {
throw new \RuntimeException('View not found: ' . $template);
}
extract($data, EXTR_SKIP);
ob_start();
include $path;
return (string) ob_get_clean();
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Http\Middleware;
use App\Core\Response;
use App\Services\AuthService;
class AuthMiddleware
{
public function handle(array $params, callable $next): Response
{
$auth = AuthService::getInstance();
if (!$auth->check()) {
return new Response('', 302, ['Location' => '/login']);
}
return $next($params);
}
}

38
app/Models/Model.php Normal file
View File

@@ -0,0 +1,38 @@
<?php
namespace App\Models;
use App\Core\Database;
use PDO;
abstract class Model
{
protected static string $table;
protected static string $primaryKey = 'id';
/**
* @return array<int, static>
*/
public static function all(): array
{
$stmt = Database::connection()->query('SELECT * FROM ' . static::$table);
$rows = $stmt->fetchAll();
return array_map(fn($row) => static::fromArray($row), $rows);
}
public static function find(int $id): ?static
{
$stmt = Database::connection()->prepare('SELECT * FROM ' . static::$table . ' WHERE ' . static::$primaryKey . ' = :id LIMIT 1');
$stmt->execute(['id' => $id]);
$row = $stmt->fetch();
if (!$row) {
return null;
}
return static::fromArray($row);
}
/**
* @param array<string, mixed> $attributes
*/
abstract protected static function fromArray(array $attributes): static;
}

54
app/Models/User.php Normal file
View File

@@ -0,0 +1,54 @@
<?php
namespace App\Models;
class User extends Model
{
protected static string $table = 'users';
public int $id;
public string $role;
public string $name;
public string $email;
public string $pass_hash;
public ?string $phone;
public ?string $address;
public ?int $customer_id;
public bool $is_active;
public ?string $twofa_secret;
public string $created_at;
public string $updated_at;
/**
* @param array<string, mixed> $attributes
*/
protected static function fromArray(array $attributes): static
{
$user = new static();
$user->id = (int) $attributes['id'];
$user->role = (string) $attributes['role'];
$user->name = (string) $attributes['name'];
$user->email = (string) $attributes['email'];
$user->pass_hash = (string) $attributes['pass_hash'];
$user->phone = $attributes['phone'] ?? null;
$user->address = $attributes['address'] ?? null;
$user->customer_id = isset($attributes['customer_id']) ? (int) $attributes['customer_id'] : null;
$user->is_active = (bool) $attributes['is_active'];
$user->twofa_secret = $attributes['twofa_secret'] ?? null;
$user->created_at = (string) $attributes['created_at'];
$user->updated_at = (string) $attributes['updated_at'];
return $user;
}
public static function findByEmail(string $email): ?self
{
$stmt = \App\Core\Database::connection()->prepare('SELECT * FROM users WHERE email = :email LIMIT 1');
$stmt->execute(['email' => $email]);
$row = $stmt->fetch();
if (!$row) {
return null;
}
return self::fromArray($row);
}
}

View File

@@ -0,0 +1,69 @@
<?php
namespace App\Services;
use App\Models\User;
use App\Support\PasswordHasher;
class AuthService
{
private static ?self $instance = null;
private ?User $user = null;
private function __construct()
{
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
if (isset($_SESSION['user_id'])) {
$this->user = User::find((int) $_SESSION['user_id']);
}
}
public static function getInstance(): self
{
if (self::$instance === null) {
self::$instance = new self();
}
return self::$instance;
}
public function attempt(string $email, string $password): bool
{
$user = User::findByEmail($email);
if (!$user || !$user->is_active) {
return false;
}
if (!PasswordHasher::verify($password, $user->pass_hash)) {
return false;
}
$_SESSION['user_id'] = $user->id;
$this->user = $user;
return true;
}
public function logout(): void
{
$_SESSION = [];
if (ini_get('session.use_cookies')) {
$params = session_get_cookie_params();
setcookie(session_name(), '', time() - 42000, $params['path'], $params['domain'], $params['secure'], $params['httponly']);
}
session_destroy();
$this->user = null;
}
public function check(): bool
{
return $this->user !== null;
}
public function user(): ?User
{
return $this->user;
}
}

View File

@@ -0,0 +1,125 @@
<?php
namespace App\Services;
use PHPMailer\PHPMailer\Exception as MailException;
use PHPMailer\PHPMailer\PHPMailer;
class MailService
{
/**
* @param string|array<int|string, string|array{address:string,name?:string}> $to
* @param array<int|string, string|array{address:string,name?:string}> $cc
* @param array<int|string, string|array{address:string,name?:string}> $bcc
*/
public function send($to, string $subject, string $htmlBody, ?string $textBody = null, array $cc = [], array $bcc = []): void
{
$config = config('mail');
$mailer = new PHPMailer(true);
$mailer->CharSet = 'UTF-8';
$mailer->isHTML(true);
$driver = strtolower((string)($config['driver'] ?? 'smtp'));
if ($driver === 'sendmail') {
$mailer->isSendmail();
if (!empty($config['sendmail_path'])) {
$mailer->Sendmail = $config['sendmail_path'];
}
} elseif ($driver === 'mail') {
$mailer->isMail();
} else {
$mailer->isSMTP();
$mailer->Host = (string)($config['host'] ?? 'localhost');
$mailer->Port = (int)($config['port'] ?? 25);
$mailer->Timeout = (int)($config['timeout'] ?? 30);
$username = $config['username'] ?? null;
$password = $config['password'] ?? null;
if ($username) {
$mailer->SMTPAuth = true;
$mailer->Username = $username;
$mailer->Password = (string)$password;
} else {
$mailer->SMTPAuth = false;
}
$encryption = strtolower((string)($config['encryption'] ?? ''));
if ($encryption === 'ssl') {
$mailer->SMTPSecure = PHPMailer::ENCRYPTION_SMTPS;
} elseif ($encryption === 'tls') {
$mailer->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS;
}
}
$fromAddress = $config['from']['address'] ?? null;
$fromName = $config['from']['name'] ?? '';
if ($fromAddress) {
$mailer->setFrom($fromAddress, $fromName ?: $fromAddress);
}
foreach ($this->normalizeRecipients($to) as $recipient) {
$mailer->addAddress($recipient['address'], $recipient['name']);
}
foreach ($this->normalizeRecipients($cc) as $recipient) {
$mailer->addCC($recipient['address'], $recipient['name']);
}
foreach ($this->normalizeRecipients($bcc) as $recipient) {
$mailer->addBCC($recipient['address'], $recipient['name']);
}
$mailer->Subject = $subject;
$mailer->Body = $htmlBody;
$mailer->AltBody = $textBody ?? strip_tags($htmlBody);
try {
$mailer->send();
} catch (MailException $exception) {
throw new \RuntimeException('Unable to send mail: ' . $exception->getMessage(), previous: $exception);
}
}
/**
* @param string|array<int|string, string|array{address:string,name?:string}> $addresses
* @return list<array{address:string,name:string}>
*/
private function normalizeRecipients($addresses): array
{
if ($addresses === null) {
return [];
}
if (!is_array($addresses)) {
return [['address' => (string)$addresses, 'name' => '']];
}
$normalized = [];
foreach ($addresses as $key => $value) {
if (is_array($value)) {
$address = $value['address'] ?? null;
if (!$address) {
continue;
}
$normalized[] = [
'address' => (string)$address,
'name' => (string)($value['name'] ?? ''),
];
} elseif (is_string($key) && is_string($value)) {
$normalized[] = [
'address' => $key,
'name' => $value,
];
} else {
$normalized[] = [
'address' => (string)$value,
'name' => '',
];
}
}
return $normalized;
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace App\Support;
class PasswordHasher
{
public static function make(string $password): string
{
return password_hash($password, PASSWORD_ARGON2ID);
}
public static function verify(string $password, string $hash): bool
{
return password_verify($password, $hash);
}
}