Initial Kaffeekasse SaaS restart
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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'] !== '';
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user