Initial Kaffeekasse SaaS restart

This commit is contained in:
2026-06-15 17:13:38 +02:00
commit b08eb93547
54 changed files with 4617 additions and 0 deletions
+108
View File
@@ -0,0 +1,108 @@
<?php
namespace App\Core;
use PDO;
final class Auth
{
public function __construct(
private readonly PDO $pdo,
private readonly Session $session
) {
}
public function attemptPlatformLogin(string $email, string $password): bool
{
$statement = $this->pdo->prepare(
'SELECT id, password_hash, platform_role FROM users WHERE email = :email LIMIT 1'
);
$statement->execute(['email' => mb_strtolower($email)]);
$user = $statement->fetch();
if (!$user || !password_verify($password, $user['password_hash'])) {
return false;
}
if (($user['platform_role'] ?? 'user') !== 'platform_admin') {
return false;
}
$this->session->regenerate();
$this->session->put('auth', [
'user_id' => (int) $user['id'],
'platform_role' => $user['platform_role'],
]);
return true;
}
public function attemptTenantLogin(string $tenantSlug, string $email, string $password): bool
{
$statement = $this->pdo->prepare(
'SELECT u.id,
u.password_hash,
m.tenant_id,
m.role,
t.slug AS tenant_slug
FROM users u
INNER JOIN tenant_memberships m ON m.user_id = u.id AND m.status = "active"
INNER JOIN tenants t ON t.id = m.tenant_id AND t.slug = :slug AND t.status = "active"
WHERE u.email = :email
LIMIT 1'
);
$statement->execute([
'slug' => $tenantSlug,
'email' => mb_strtolower($email),
]);
$membership = $statement->fetch();
if (!$membership || !password_verify($password, $membership['password_hash'])) {
return false;
}
$this->session->regenerate();
$this->session->put('auth', [
'user_id' => (int) $membership['id'],
'tenant_id' => (int) $membership['tenant_id'],
'tenant_slug' => $membership['tenant_slug'],
'tenant_role' => $membership['role'],
'platform_role' => 'user',
]);
return true;
}
public function user(): ?array
{
$auth = $this->session->get('auth');
return is_array($auth) ? $auth : null;
}
public function checkPlatformAdmin(): bool
{
return ($this->user()['platform_role'] ?? null) === 'platform_admin';
}
public function checkTenantAccess(string $tenantSlug): bool
{
$auth = $this->user();
return is_array($auth)
&& isset($auth['tenant_slug'])
&& hash_equals($auth['tenant_slug'], $tenantSlug);
}
public function requireRole(array $allowedRoles): bool
{
$role = $this->user()['tenant_role'] ?? null;
return is_string($role) && in_array($role, $allowedRoles, true);
}
public function logout(): void
{
$this->session->invalidate();
}
}
+24
View File
@@ -0,0 +1,24 @@
<?php
namespace App\Core;
final class Autoloader
{
public static function register(string $rootPath): void
{
spl_autoload_register(static function (string $class) use ($rootPath): void {
$prefix = 'App\\';
if (!str_starts_with($class, $prefix)) {
return;
}
$relativeClass = substr($class, strlen($prefix));
$file = $rootPath . '/app/' . str_replace('\\', '/', $relativeClass) . '.php';
if (is_file($file)) {
require_once $file;
}
});
}
}
+29
View File
@@ -0,0 +1,29 @@
<?php
namespace App\Core;
final class Csrf
{
public function __construct(private readonly Session $session)
{
}
public function token(): string
{
$token = $this->session->get('_csrf_token');
if (!is_string($token) || $token === '') {
$token = bin2hex(random_bytes(32));
$this->session->put('_csrf_token', $token);
}
return $token;
}
public function validate(?string $token): bool
{
$expected = $this->session->get('_csrf_token');
return is_string($expected) && is_string($token) && hash_equals($expected, $token);
}
}
+51
View File
@@ -0,0 +1,51 @@
<?php
namespace App\Core;
use PDO;
use PDOException;
final class Database
{
private ?PDO $pdo = null;
public function __construct(private readonly array $config)
{
}
public function pdo(): PDO
{
if ($this->pdo instanceof PDO) {
return $this->pdo;
}
$dsn = sprintf(
'mysql:host=%s;port=%d;dbname=%s;charset=%s',
$this->config['host'],
$this->config['port'],
$this->config['name'],
$this->config['charset']
);
try {
$this->pdo = new PDO($dsn, $this->config['user'], $this->config['pass'], [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]);
} catch (PDOException $exception) {
throw new PDOException(
'Database connection failed: ' . $exception->getMessage(),
(int) $exception->getCode(),
$exception
);
}
return $this->pdo;
}
public function isConfigured(): bool
{
return $this->config['name'] !== '' && $this->config['user'] !== '';
}
}
+34
View File
@@ -0,0 +1,34 @@
<?php
namespace App\Core;
final class Env
{
public static function load(string $file): void
{
if (!is_file($file)) {
return;
}
$lines = file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [];
foreach ($lines as $line) {
$trimmed = trim($line);
if ($trimmed === '' || str_starts_with($trimmed, '#')) {
continue;
}
[$name, $value] = array_pad(explode('=', $trimmed, 2), 2, '');
$name = trim($name);
$value = trim($value);
if ($value !== '' && ($value[0] === '"' || $value[0] === '\'')) {
$value = trim($value, "\"'");
}
$_ENV[$name] = $value;
$_SERVER[$name] = $value;
}
}
}
+18
View File
@@ -0,0 +1,18 @@
<?php
namespace App\Core;
final class Response
{
public static function redirect(string $url): never
{
header('Location: ' . $url);
exit;
}
public static function notFound(string $message = 'Seite nicht gefunden.'): void
{
http_response_code(404);
echo $message;
}
}
+62
View File
@@ -0,0 +1,62 @@
<?php
namespace App\Core;
final class Session
{
public function start(): void
{
if (session_status() === PHP_SESSION_NONE) {
session_set_cookie_params([
'httponly' => true,
'samesite' => 'Lax',
'secure' => (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off'),
'path' => '/',
]);
session_start();
}
}
public function get(string $key, mixed $default = null): mixed
{
return $_SESSION[$key] ?? $default;
}
public function put(string $key, mixed $value): void
{
$_SESSION[$key] = $value;
}
public function forget(string $key): void
{
unset($_SESSION[$key]);
}
public function flash(string $key, mixed $value): void
{
$_SESSION['_flash'][$key] = $value;
}
public function pullFlash(string $key, mixed $default = null): mixed
{
$value = $_SESSION['_flash'][$key] ?? $default;
unset($_SESSION['_flash'][$key]);
return $value;
}
public function regenerate(): void
{
session_regenerate_id(true);
}
public function invalidate(): void
{
$_SESSION = [];
if (session_status() === PHP_SESSION_ACTIVE) {
session_destroy();
}
}
}
+23
View File
@@ -0,0 +1,23 @@
<?php
namespace App\Core;
use PDO;
final class TenantContext
{
public function __construct(private readonly PDO $pdo)
{
}
public function bySlug(string $slug): ?array
{
$statement = $this->pdo->prepare(
'SELECT * FROM tenants WHERE slug = :slug LIMIT 1'
);
$statement->execute(['slug' => $slug]);
$tenant = $statement->fetch();
return $tenant ?: null;
}
}
+30
View File
@@ -0,0 +1,30 @@
<?php
namespace App\Core;
final class View
{
public function __construct(
private readonly string $rootPath,
private readonly array $shared = []
) {
}
public function render(string $template, array $data = []): void
{
$templateFile = $this->rootPath . '/resources/views/' . $template . '.php';
if (!is_file($templateFile)) {
http_response_code(500);
echo 'View not found: ' . htmlspecialchars($template, ENT_QUOTES, 'UTF-8');
return;
}
$shared = $this->shared;
extract($shared, EXTR_SKIP);
extract($data, EXTR_SKIP);
require $templateFile;
}
}