commit basic
This commit is contained in:
0
api/.gitkeep
Executable file
0
api/.gitkeep
Executable file
53
config/config.php
Executable file
53
config/config.php
Executable file
@@ -0,0 +1,53 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
// Basic error reporting (keep strict in dev)
|
||||||
|
ini_set('display_errors', '1');
|
||||||
|
ini_set('display_startup_errors', '1');
|
||||||
|
error_reporting(E_ALL);
|
||||||
|
|
||||||
|
// Pick config set: first try root files, otherwise fall back to env dir (prod/staging/...)
|
||||||
|
$appEnvFromEnv = getenv('APP_ENV') ?: 'prod';
|
||||||
|
$envDir = rtrim(__DIR__, '/\\') . '/' . $appEnvFromEnv;
|
||||||
|
|
||||||
|
foreach (['domaindata.php','settings.php'] as $cfgFile) {
|
||||||
|
$rootPath = __DIR__ . '/' . $cfgFile;
|
||||||
|
$envPath = $envDir . '/' . $cfgFile;
|
||||||
|
|
||||||
|
if (file_exists($rootPath)) {
|
||||||
|
require_once $rootPath;
|
||||||
|
} elseif (file_exists($envPath)) {
|
||||||
|
require_once $envPath;
|
||||||
|
} else {
|
||||||
|
throw new \RuntimeException("Missing required config file: $cfgFile (looked for $rootPath or $envPath)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Environment: staging|prod|local (example)
|
||||||
|
if (!defined('APP_ENV')) {
|
||||||
|
define('APP_ENV', 'local');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Asset versioning
|
||||||
|
if (!defined('ASSET_VERSION')) {
|
||||||
|
define('ASSET_VERSION', 'dev-' . date('Ymd-His'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Primary domain + URL
|
||||||
|
if (!defined('APP_DOMAIN_PRIMARY')) {
|
||||||
|
define('APP_DOMAIN_PRIMARY', APP_DOMAIN_NAME);
|
||||||
|
}
|
||||||
|
if (!defined('APP_URL_PRIMARY')) {
|
||||||
|
define('APP_URL_PRIMARY', 'https://' . APP_DOMAIN_PRIMARY);
|
||||||
|
}
|
||||||
|
|
||||||
|
// API base (example)
|
||||||
|
if (!defined('APP_API_BASE')) {
|
||||||
|
define('APP_API_BASE', 'https://api.' . APP_DOMAIN_PRIMARY);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Feature toggles
|
||||||
|
if (!defined('APP_DB_ENABLED')) {
|
||||||
|
define('APP_DB_ENABLED', false); // set true to enable DB connection
|
||||||
|
}
|
||||||
41
config/fileload.php
Executable file
41
config/fileload.php
Executable file
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
// 1) Load config (constants, env, domains)
|
||||||
|
require_once __DIR__ . '/config.php';
|
||||||
|
|
||||||
|
// 2) Composer Autoloader (falls vorhanden)
|
||||||
|
$composerAutoload = __DIR__ . '/../vendor/autoload.php';
|
||||||
|
if (file_exists($composerAutoload)) {
|
||||||
|
require_once $composerAutoload;
|
||||||
|
} else {
|
||||||
|
// 2b) Fallback: minimaler Autoloader
|
||||||
|
spl_autoload_register(function (string $class): void {
|
||||||
|
$prefix = 'App\\';
|
||||||
|
if (!str_starts_with($class, $prefix)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$rel = substr($class, strlen($prefix));
|
||||||
|
$path = __DIR__ . '/../src/App/' . str_replace('\\', '/', $rel) . '.php';
|
||||||
|
|
||||||
|
if (file_exists($path)) {
|
||||||
|
require_once $path;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) Global helper functions (tpl(), t(), asset_*())
|
||||||
|
require_once __DIR__ . '/../src/helpers.php';
|
||||||
|
|
||||||
|
// 4) Initialize App (services)
|
||||||
|
$config = \App\Config::fromPhpConstants(__DIR__ . '/../config');
|
||||||
|
\App\App::init($config);
|
||||||
|
|
||||||
|
// 5) Start session + create client-id cookie
|
||||||
|
$app = \App\App::get();
|
||||||
|
$app->session()->start();
|
||||||
|
$clientId = $app->session()->ensureClientId();
|
||||||
|
|
||||||
|
// Optionally expose a single global for templates if desired
|
||||||
|
$GLOBALS['client_id'] = $clientId;
|
||||||
96
config/prod/db.php
Executable file
96
config/prod/db.php
Executable file
@@ -0,0 +1,96 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* config/db.php
|
||||||
|
*
|
||||||
|
* - Choose ONE driver below (others stay commented).
|
||||||
|
* - Each driver has its own config section.
|
||||||
|
* - The file returns ONE normalized array used by Database::createPdo().
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
// 1) Driver selection (choose one)
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
$driver = 'pgsql';
|
||||||
|
// $driver = 'mysql';
|
||||||
|
// $driver = 'sqlite';
|
||||||
|
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
// 2) Driver-specific configuration sections
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
|
||||||
|
// ---- PostgreSQL (PDO driver: pgsql) -------------------------
|
||||||
|
$pgsql = [
|
||||||
|
'driver' => 'pgsql',
|
||||||
|
'host' => 'localhost',
|
||||||
|
'port' => 5432,
|
||||||
|
'dbname' => 'mydb',
|
||||||
|
|
||||||
|
// optional: schema/search_path (commonly "public")
|
||||||
|
'schema' => 'public',
|
||||||
|
|
||||||
|
'user' => 'myuser',
|
||||||
|
'password' => 'secret',
|
||||||
|
|
||||||
|
'options' => [
|
||||||
|
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||||
|
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
// ---- MySQL / MariaDB (PDO driver: mysql) -------------------
|
||||||
|
$mysql = [
|
||||||
|
'driver' => 'mysql',
|
||||||
|
'host' => 'localhost',
|
||||||
|
'port' => 3306,
|
||||||
|
'dbname' => 'd0453540',
|
||||||
|
'charset' => 'utf8mb4',
|
||||||
|
|
||||||
|
// Alternative to host/port:
|
||||||
|
// 'unix_socket' => '/var/run/mysqld/mysqld.sock',
|
||||||
|
|
||||||
|
'user' => 'd0453540',
|
||||||
|
'password' => 'P6jGRrSaX8QSiBMEJBL7',
|
||||||
|
|
||||||
|
'options' => [
|
||||||
|
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||||
|
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||||
|
PDO::ATTR_EMULATE_PREPARES => false,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
// ---- SQLite (PDO driver: sqlite) ---------------------------
|
||||||
|
$sqlite = [
|
||||||
|
'driver' => 'sqlite',
|
||||||
|
|
||||||
|
// Use an absolute path in production, e.g. /var/app/data/app.sqlite
|
||||||
|
// For demo/dev you can use a relative path.
|
||||||
|
'path' => __DIR__ . '/../var/app.sqlite',
|
||||||
|
|
||||||
|
// SQLite ignores host/port/user/pass
|
||||||
|
'options' => [
|
||||||
|
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||||
|
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
// 3) Select and return config
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
switch ($driver) {
|
||||||
|
case 'pgsql':
|
||||||
|
return $pgsql;
|
||||||
|
|
||||||
|
case 'mysql':
|
||||||
|
return $mysql;
|
||||||
|
|
||||||
|
case 'sqlite':
|
||||||
|
return $sqlite;
|
||||||
|
|
||||||
|
default:
|
||||||
|
throw new RuntimeException('Unsupported DB driver in config/db.php: ' . $driver);
|
||||||
|
}
|
||||||
|
|
||||||
|
],
|
||||||
|
];
|
||||||
12
config/prod/domaindata.php
Executable file
12
config/prod/domaindata.php
Executable file
@@ -0,0 +1,12 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
// Example: a single "brand" domain name.
|
||||||
|
// In real deployments you might derive this from ENV or hostnames.
|
||||||
|
if (!defined('APP_DOMAIN_NAME')) {
|
||||||
|
define('APP_DOMAIN_NAME', 'example.test');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!defined('APP_PREFIX')) {
|
||||||
|
define('APP_PREFIX', 'miniapp');
|
||||||
|
}
|
||||||
8
config/prod/settings.php
Executable file
8
config/prod/settings.php
Executable file
@@ -0,0 +1,8 @@
|
|||||||
|
<?php
|
||||||
|
define('APP_ENV', 'local');
|
||||||
|
define('ASSET_VERSION', 'dev-' . date('Ymd-His'));
|
||||||
|
define('APP_DOMAIN_PRIMARY', APP_DOMAIN_NAME);
|
||||||
|
define('APP_URL_PRIMARY', 'https://' . APP_DOMAIN_PRIMARY);
|
||||||
|
define('APP_API_BASE', 'https://api.' . APP_DOMAIN_PRIMARY);
|
||||||
|
define('APP_DB_ENABLED', false); // set true to enable DB connection
|
||||||
|
|
||||||
93
config/staging/db.php
Executable file
93
config/staging/db.php
Executable file
@@ -0,0 +1,93 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* config/db.php
|
||||||
|
*
|
||||||
|
* - Choose ONE driver below (others stay commented).
|
||||||
|
* - Each driver has its own config section.
|
||||||
|
* - The file returns ONE normalized array used by Database::createPdo().
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
// 1) Driver selection (choose one)
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
$driver = 'pgsql';
|
||||||
|
// $driver = 'mysql';
|
||||||
|
// $driver = 'sqlite';
|
||||||
|
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
// 2) Driver-specific configuration sections
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
|
||||||
|
// ---- PostgreSQL (PDO driver: pgsql) -------------------------
|
||||||
|
$pgsql = [
|
||||||
|
'driver' => 'pgsql',
|
||||||
|
'host' => 'localhost',
|
||||||
|
'port' => 5432,
|
||||||
|
'dbname' => 'mydb',
|
||||||
|
|
||||||
|
// optional: schema/search_path (commonly "public")
|
||||||
|
'schema' => 'public',
|
||||||
|
|
||||||
|
'user' => 'myuser',
|
||||||
|
'password' => 'secret',
|
||||||
|
|
||||||
|
'options' => [
|
||||||
|
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||||
|
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
// ---- MySQL / MariaDB (PDO driver: mysql) -------------------
|
||||||
|
$mysql = [
|
||||||
|
'driver' => 'mysql',
|
||||||
|
'host' => 'localhost',
|
||||||
|
'port' => 3306,
|
||||||
|
'dbname' => 'd0453540',
|
||||||
|
'charset' => 'utf8mb4',
|
||||||
|
|
||||||
|
// Alternative to host/port:
|
||||||
|
// 'unix_socket' => '/var/run/mysqld/mysqld.sock',
|
||||||
|
|
||||||
|
'user' => 'd0453540',
|
||||||
|
'password' => 'P6jGRrSaX8QSiBMEJBL7',
|
||||||
|
|
||||||
|
'options' => [
|
||||||
|
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||||
|
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||||
|
PDO::ATTR_EMULATE_PREPARES => false,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
// ---- SQLite (PDO driver: sqlite) ---------------------------
|
||||||
|
$sqlite = [
|
||||||
|
'driver' => 'sqlite',
|
||||||
|
|
||||||
|
// Use an absolute path in production, e.g. /var/app/data/app.sqlite
|
||||||
|
// For demo/dev you can use a relative path.
|
||||||
|
'path' => __DIR__ . '/../var/app.sqlite',
|
||||||
|
|
||||||
|
// SQLite ignores host/port/user/pass
|
||||||
|
'options' => [
|
||||||
|
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||||
|
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
// 3) Select and return config
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
switch ($driver) {
|
||||||
|
case 'pgsql':
|
||||||
|
return $pgsql;
|
||||||
|
|
||||||
|
case 'mysql':
|
||||||
|
return $mysql;
|
||||||
|
|
||||||
|
case 'sqlite':
|
||||||
|
return $sqlite;
|
||||||
|
|
||||||
|
default:
|
||||||
|
throw new RuntimeException('Unsupported DB driver in config/db.php: ' . $driver);
|
||||||
|
}
|
||||||
12
config/staging/domaindata.php
Executable file
12
config/staging/domaindata.php
Executable file
@@ -0,0 +1,12 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
// Example: a single "brand" domain name.
|
||||||
|
// In real deployments you might derive this from ENV or hostnames.
|
||||||
|
if (!defined('APP_DOMAIN_NAME')) {
|
||||||
|
define('APP_DOMAIN_NAME', 'example.test');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!defined('APP_PREFIX')) {
|
||||||
|
define('APP_PREFIX', 'miniapp');
|
||||||
|
}
|
||||||
8
config/staging/settings.php
Executable file
8
config/staging/settings.php
Executable file
@@ -0,0 +1,8 @@
|
|||||||
|
<?php
|
||||||
|
define('APP_ENV', 'local');
|
||||||
|
define('ASSET_VERSION', 'dev-' . date('Ymd-His'));
|
||||||
|
define('APP_DOMAIN_PRIMARY', APP_DOMAIN_NAME);
|
||||||
|
define('APP_URL_PRIMARY', 'https://' . APP_DOMAIN_PRIMARY);
|
||||||
|
define('APP_API_BASE', 'https://api.' . APP_DOMAIN_PRIMARY);
|
||||||
|
define('APP_DB_ENABLED', false); // set true to enable DB connection
|
||||||
|
|
||||||
0
debug/.gitkeep
Executable file
0
debug/.gitkeep
Executable file
@@ -1 +0,0 @@
|
|||||||
adadssadasd
|
|
||||||
39
partials/landing/main/home.php
Executable file
39
partials/landing/main/home.php
Executable file
@@ -0,0 +1,39 @@
|
|||||||
|
<?php
|
||||||
|
$app = app();
|
||||||
|
|
||||||
|
// Example: register assets from inside a landing template
|
||||||
|
$app->assets()->addStyle('/assets/app.css', 'early');
|
||||||
|
$app->assets()->addScript('/assets/app.js', 'footer', true);
|
||||||
|
|
||||||
|
$flash = $app->flash()->get();
|
||||||
|
?>
|
||||||
|
<div class="card">
|
||||||
|
<div class="pill">env: <?= htmlspecialchars($app->config()->env, ENT_QUOTES) ?></div>
|
||||||
|
<h1 style="margin-top: .75rem;"><?= htmlspecialchars(t('common.title'), ENT_QUOTES) ?></h1>
|
||||||
|
|
||||||
|
<p class="muted"><?= htmlspecialchars(t('common.intro'), ENT_QUOTES) ?></p>
|
||||||
|
|
||||||
|
<?php if ($flash): ?>
|
||||||
|
<div style="margin: 1rem 0; padding: .75rem 1rem; border: 1px solid #ddd; border-radius: 12px;">
|
||||||
|
<strong><?= htmlspecialchars($flash['type'], ENT_QUOTES) ?>:</strong>
|
||||||
|
<?= htmlspecialchars($flash['message'], ENT_QUOTES) ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<div class="grid" style="margin-top: 1rem;">
|
||||||
|
<div>
|
||||||
|
<h3 style="margin: 0 0 .5rem 0;">Runtime</h3>
|
||||||
|
<div><strong>Current URL:</strong> <?= htmlspecialchars($app->request()->currentUrl(), ENT_QUOTES) ?></div>
|
||||||
|
<div><strong>Client-ID:</strong> <code><?= htmlspecialchars($GLOBALS['client_id'] ?? '', ENT_QUOTES) ?></code></div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 style="margin: 0 0 .5rem 0;">Actions</h3>
|
||||||
|
<form method="post" action="/action/flash">
|
||||||
|
<button type="submit" style="padding:.6rem 1rem; border-radius: 12px; border: 1px solid #ddd; background: white; cursor:pointer;">
|
||||||
|
Set flash message
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<p class="muted" style="margin-top:.5rem;">Flash uses SessionManager, no direct globals.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
3
partials/structure/layout_end.php
Executable file
3
partials/structure/layout_end.php
Executable file
@@ -0,0 +1,3 @@
|
|||||||
|
<?php asset_scripts('footer'); ?>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
22
partials/structure/layout_start.php
Executable file
22
partials/structure/layout_start.php
Executable file
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
/** @var \App\App $app */
|
||||||
|
$app = app();
|
||||||
|
?>
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title><?= htmlspecialchars(t('common.title'), ENT_QUOTES) ?></title>
|
||||||
|
<?php asset_styles(); ?>
|
||||||
|
<?php asset_scripts('header'); ?>
|
||||||
|
<style>
|
||||||
|
body { font-family: system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif; margin: 2rem; }
|
||||||
|
.card { border: 1px solid #ddd; border-radius: 12px; padding: 1.25rem; max-width: 820px; }
|
||||||
|
.muted { color: #555; }
|
||||||
|
.pill { display: inline-block; padding: .25rem .5rem; border-radius: 999px; border: 1px solid #ddd; font-size: .9rem; }
|
||||||
|
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
|
||||||
|
@media (max-width: 720px) { .grid { grid-template-columns: 1fr; } }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
0
public/.gitkeep
Executable file
0
public/.gitkeep
Executable file
32
public/.htaccess
Executable file
32
public/.htaccess
Executable file
@@ -0,0 +1,32 @@
|
|||||||
|
# -------------------------------------------------
|
||||||
|
# Apache Front Controller Setup (public/.htaccess)
|
||||||
|
# -------------------------------------------------
|
||||||
|
|
||||||
|
RewriteEngine On
|
||||||
|
|
||||||
|
# Sicherheit: keine Directory Listings
|
||||||
|
Options -Indexes
|
||||||
|
|
||||||
|
# -------------------------------------------------
|
||||||
|
# 1) Assets DIREKT ausliefern
|
||||||
|
# -------------------------------------------------
|
||||||
|
RewriteRule ^assets/ - [L]
|
||||||
|
|
||||||
|
# -------------------------------------------------
|
||||||
|
# 2) page/ von außen sperren (nur intern per require nutzbar)
|
||||||
|
# -------------------------------------------------
|
||||||
|
RewriteRule ^page/ - [F,L]
|
||||||
|
|
||||||
|
# -------------------------------------------------
|
||||||
|
# 3) Alles andere an den Front Controller
|
||||||
|
# -------------------------------------------------
|
||||||
|
RewriteRule ^ index.php [L]
|
||||||
|
|
||||||
|
# -------------------------------------------------
|
||||||
|
# 4) (Optional) Zusätzliche Sicherheits-Header
|
||||||
|
# -------------------------------------------------
|
||||||
|
<IfModule mod_headers.c>
|
||||||
|
Header set X-Frame-Options "SAMEORIGIN"
|
||||||
|
Header set X-Content-Type-Options "nosniff"
|
||||||
|
Header set Referrer-Policy "strict-origin-when-cross-origin"
|
||||||
|
</IfModule>
|
||||||
1
public/assets/app.css
Executable file
1
public/assets/app.css
Executable file
@@ -0,0 +1 @@
|
|||||||
|
/* minimal css placeholder */
|
||||||
1
public/assets/app.js
Executable file
1
public/assets/app.js
Executable file
@@ -0,0 +1 @@
|
|||||||
|
console.log('mini example loaded');
|
||||||
88
public/index.php
Executable file
88
public/index.php
Executable file
@@ -0,0 +1,88 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
// boot application (config, autoload, services)
|
||||||
|
require_once __DIR__ . '/../config/fileload.php';
|
||||||
|
|
||||||
|
// Staging-Access-Protection (Basic Auth)
|
||||||
|
$uriPath = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/';
|
||||||
|
$uriPath = preg_replace('~/{2,}~', '/', $uriPath);
|
||||||
|
$uriPath = trim($uriPath, '/');
|
||||||
|
$isRetoolPath = ($uriPath === 'retool' || str_starts_with($uriPath, 'retool/'));
|
||||||
|
if (defined('APP_ENV') && APP_ENV === 'staging' && !$isRetoolPath) {
|
||||||
|
$authUser = getenv('STAGING_AUTH_USER') ?: 'staging';
|
||||||
|
$authPass = getenv('STAGING_AUTH_PASS') ?: 'staging123';
|
||||||
|
$user = $_SERVER['PHP_AUTH_USER'] ?? null;
|
||||||
|
$pass = $_SERVER['PHP_AUTH_PW'] ?? null;
|
||||||
|
if ($user !== $authUser || $pass !== $authPass) {
|
||||||
|
header('WWW-Authenticate: Basic realm="Staging"');
|
||||||
|
header('HTTP/1.0 401 Unauthorized');
|
||||||
|
echo 'Unauthorized';
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sicherheitscheck
|
||||||
|
if (str_contains($uriPath, '..')) {
|
||||||
|
http_response_code(400);
|
||||||
|
exit('Bad request');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Root → page/index.php
|
||||||
|
if ($uriPath === '' || $uriPath === 'index' || $uriPath === 'index.php') {
|
||||||
|
$target = __DIR__ . '/page/index.php';
|
||||||
|
} else {
|
||||||
|
$base = __DIR__ . '/page/' . $uriPath;
|
||||||
|
// 1) Verzeichnis mit index.php
|
||||||
|
if (is_dir($base) && is_file($base . '/index.php')) {
|
||||||
|
$target = $base . '/index.php';
|
||||||
|
}
|
||||||
|
// 2) Datei
|
||||||
|
elseif (is_file($base . '.php')) {
|
||||||
|
|
||||||
|
$target = $base . '.php';
|
||||||
|
}
|
||||||
|
// 3) 404
|
||||||
|
elseif (is_file($base)) {
|
||||||
|
|
||||||
|
$target = $base;
|
||||||
|
}
|
||||||
|
// 3) 404
|
||||||
|
else {
|
||||||
|
http_response_code(404);
|
||||||
|
$target = __DIR__ . '/page/404.php';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// ------------------------------------
|
||||||
|
// Layout-Regel
|
||||||
|
// ------------------------------------
|
||||||
|
$skipLayout = false;
|
||||||
|
$targetReal = realpath($target);
|
||||||
|
|
||||||
|
// Beispiel: alles unter /page/raw/* ohne Layout
|
||||||
|
if ($targetReal && str_starts_with($targetReal, realpath(__DIR__ . '/page/retool'))) {
|
||||||
|
$skipLayout = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------
|
||||||
|
// Ausgabe
|
||||||
|
// ------------------------------------
|
||||||
|
// Erst Inhalt laden (ohne Ausgabe), damit Header/Redirects vor HTML funktionieren
|
||||||
|
ob_start();
|
||||||
|
require $target;
|
||||||
|
$content = ob_get_clean();
|
||||||
|
|
||||||
|
// Wenn bereits Header gesendet wurden (z. B. eigener Redirect/Content-Type), Layout überspringen
|
||||||
|
if (headers_sent()) {
|
||||||
|
$skipLayout = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$skipLayout) {
|
||||||
|
tpl('layout_start', 'structure');
|
||||||
|
}
|
||||||
|
|
||||||
|
echo $content;
|
||||||
|
|
||||||
|
if (!$skipLayout) {
|
||||||
|
tpl('layout_end', 'structure');
|
||||||
|
}
|
||||||
3
public/page/index.php
Executable file
3
public/page/index.php
Executable file
@@ -0,0 +1,3 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
echo "test";
|
||||||
0
src/.gitkeep
Executable file
0
src/.gitkeep
Executable file
398
src/App/AccountPages.php
Executable file
398
src/App/AccountPages.php
Executable file
@@ -0,0 +1,398 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App;
|
||||||
|
|
||||||
|
final class AccountPages
|
||||||
|
{
|
||||||
|
public static function register(App $app): array
|
||||||
|
{
|
||||||
|
$flash = $app->flash()->get();
|
||||||
|
$isLoggedIn = isset($_SESSION['user_id']);
|
||||||
|
$error = '';
|
||||||
|
$displayName = '';
|
||||||
|
$email = '';
|
||||||
|
|
||||||
|
if ($isLoggedIn) {
|
||||||
|
redirect('/dashboard');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
|
$displayName = trim((string)($_POST['display_name'] ?? ''));
|
||||||
|
$email = trim((string)($_POST['email'] ?? ''));
|
||||||
|
$password = (string)($_POST['password'] ?? '');
|
||||||
|
$password2 = (string)($_POST['password_confirm'] ?? '');
|
||||||
|
|
||||||
|
if ($password !== $password2) {
|
||||||
|
$error = 'Passwörter stimmen nicht überein.';
|
||||||
|
} elseif (strlen($password) < 8) {
|
||||||
|
$error = 'Passwort muss mindestens 8 Zeichen haben.';
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
$auth = new Auth($app);
|
||||||
|
$userId = $auth->register($displayName, $email, $password);
|
||||||
|
$code = $auth->createVerifyCode($userId, $email);
|
||||||
|
$mailer = new Mailer($app);
|
||||||
|
$mailer->sendTemplate('registration_confirm', $email, [
|
||||||
|
'code' => $code,
|
||||||
|
'display_name' => $displayName,
|
||||||
|
]);
|
||||||
|
$_SESSION['verify_email'] = $email;
|
||||||
|
$app->flash()->set('info', 'Bitte bestätige deine Registrierung mit dem Code aus der E-Mail.');
|
||||||
|
redirect('/verify');
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$error = $e->getMessage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return compact('flash', 'error', 'displayName', 'email');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function login(App $app): array
|
||||||
|
{
|
||||||
|
$flash = $app->flash()->get();
|
||||||
|
$isLoggedIn = isset($_SESSION['user_id']);
|
||||||
|
$error = '';
|
||||||
|
$emailPrefill = '';
|
||||||
|
|
||||||
|
if ($isLoggedIn) {
|
||||||
|
redirect('/dashboard');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
|
$email = trim((string)($_POST['email'] ?? ''));
|
||||||
|
$emailPrefill = $email;
|
||||||
|
$password = (string)($_POST['password'] ?? '');
|
||||||
|
try {
|
||||||
|
$auth = new Auth($app);
|
||||||
|
$res = $auth->login($email, $password);
|
||||||
|
if ($res['status'] === 'pending') {
|
||||||
|
$code = $auth->createVerifyCode($res['id'], $email);
|
||||||
|
$mailer = new Mailer($app);
|
||||||
|
$mailer->sendTemplate('registration_confirm', $email, [
|
||||||
|
'code' => $code,
|
||||||
|
'display_name' => $email,
|
||||||
|
]);
|
||||||
|
$_SESSION['verify_email'] = $email;
|
||||||
|
$app->flash()->set('info', 'Bitte bestätige deine Registrierung mit dem Code aus der E-Mail.');
|
||||||
|
redirect('/verify');
|
||||||
|
}
|
||||||
|
$_SESSION['user_id'] = $res['id'];
|
||||||
|
$app->flash()->set('success', 'Erfolgreich angemeldet.');
|
||||||
|
redirect('/dashboard');
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$error = $e->getMessage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return compact('flash', 'error', 'emailPrefill', 'isLoggedIn');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function verify(App $app): array
|
||||||
|
{
|
||||||
|
$pdo = $app->pdo();
|
||||||
|
$flash = $app->flash()->get();
|
||||||
|
$error = '';
|
||||||
|
$info = '';
|
||||||
|
$email = $_SESSION['verify_email'] ?? '';
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
|
$action = $_POST['action'] ?? 'verify';
|
||||||
|
$email = trim((string)($_POST['email'] ?? ''));
|
||||||
|
$code = strtoupper(trim((string)($_POST['code'] ?? '')));
|
||||||
|
$auth = new Auth($app);
|
||||||
|
$mailer = new Mailer($app);
|
||||||
|
|
||||||
|
if ($action === 'resend') {
|
||||||
|
try {
|
||||||
|
$stmt = $pdo?->prepare('SELECT id, display_name, status FROM users u JOIN user_profiles p ON p.user_id = u.id WHERE u.email = :email LIMIT 1');
|
||||||
|
$stmt?->execute(['email' => $email]);
|
||||||
|
$row = $stmt?->fetch(\PDO::FETCH_ASSOC);
|
||||||
|
if (!$row) {
|
||||||
|
throw new \RuntimeException('E-Mail nicht gefunden.');
|
||||||
|
}
|
||||||
|
$userId = (int)$row['id'];
|
||||||
|
$codeNew = $auth->createVerifyCode($userId, $email);
|
||||||
|
$mailer->sendTemplate('registration_resend_code', $email, [
|
||||||
|
'code' => $codeNew,
|
||||||
|
'display_name' => $row['display_name'] ?? '',
|
||||||
|
]);
|
||||||
|
$info = 'Neuer Code wurde versendet.';
|
||||||
|
$_SESSION['verify_email'] = $email;
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$error = $e->getMessage();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
$userId = $auth->verifyCode($email, $code);
|
||||||
|
$_SESSION['user_id'] = $userId;
|
||||||
|
unset($_SESSION['verify_email']);
|
||||||
|
$mailer->sendTemplate('registration_welcome', $email, ['display_name' => $email]);
|
||||||
|
$app->flash()->set('success', 'Registrierung bestätigt. Willkommen!');
|
||||||
|
redirect('/dashboard');
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$error = $e->getMessage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return compact('flash', 'error', 'info', 'email');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function dashboard(App $app): array
|
||||||
|
{
|
||||||
|
if (!isset($_SESSION['user_id'])) {
|
||||||
|
redirect('/login');
|
||||||
|
}
|
||||||
|
|
||||||
|
$pdo = $app->pdo();
|
||||||
|
$flash = $app->flash()->get();
|
||||||
|
$userId = (int)$_SESSION['user_id'];
|
||||||
|
$error = '';
|
||||||
|
$info = '';
|
||||||
|
$crypto = null;
|
||||||
|
try { $crypto = new Crypto($app->config()); } catch (\Throwable) {}
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
|
$action = $_POST['action'] ?? '';
|
||||||
|
try {
|
||||||
|
if ($action === 'profile') {
|
||||||
|
$languages = $_POST['languages'] ?? '';
|
||||||
|
if (is_array($languages)) {
|
||||||
|
$languages = implode(', ', array_map('trim', $languages));
|
||||||
|
}
|
||||||
|
$phoneEnc = $crypto ? $crypto->encrypt(trim((string)$_POST['contact_phone'])) : trim((string)$_POST['contact_phone']);
|
||||||
|
$stmt = $pdo?->prepare('UPDATE user_profiles SET display_name=:name, first_name=:fname, last_name=:lname, zip=:zip, city=:city, profession=:prof, languages=:langs, about=:about, contact_phone=:phone, updated_at=NOW() WHERE user_id=:id');
|
||||||
|
$stmt?->execute([
|
||||||
|
'name' => trim((string)$_POST['display_name']),
|
||||||
|
'fname' => trim((string)$_POST['first_name']),
|
||||||
|
'lname' => trim((string)$_POST['last_name']),
|
||||||
|
'zip' => trim((string)$_POST['zip']),
|
||||||
|
'city' => trim((string)$_POST['city']),
|
||||||
|
'prof' => trim((string)$_POST['profession']),
|
||||||
|
'langs' => trim((string)$languages),
|
||||||
|
'about' => trim((string)$_POST['about']),
|
||||||
|
'phone' => $phoneEnc,
|
||||||
|
'id' => $userId,
|
||||||
|
]);
|
||||||
|
$info = 'Profil gespeichert.';
|
||||||
|
} elseif ($action === 'child_add') {
|
||||||
|
$firstNameEnc = $crypto ? $crypto->encrypt(trim((string)$_POST['first_name'])) : trim((string)$_POST['first_name']);
|
||||||
|
$noteEnc = $crypto ? $crypto->encrypt(trim((string)$_POST['note'])) : trim((string)$_POST['note']);
|
||||||
|
$stmt = $pdo?->prepare('INSERT INTO children (user_id, gender, birthdate, age_years, encrypted_first_name, note, created_at, updated_at) VALUES (:uid, :gender, :birthdate, :age, :name, :note, NOW(), NOW())');
|
||||||
|
$stmt?->execute([
|
||||||
|
'uid' => $userId,
|
||||||
|
'gender' => $_POST['gender'] ?? 'unknown',
|
||||||
|
'birthdate' => $_POST['birthdate'] ?: null,
|
||||||
|
'age' => $_POST['age_years'] ?: null,
|
||||||
|
'name' => $firstNameEnc,
|
||||||
|
'note' => $noteEnc,
|
||||||
|
]);
|
||||||
|
$info = 'Kind hinzugefügt.';
|
||||||
|
} elseif ($action === 'event_add' || $action === 'event_update') {
|
||||||
|
$street = trim((string)($_POST['street'] ?? ''));
|
||||||
|
$zip = trim((string)($_POST['zip'] ?? ''));
|
||||||
|
$city = trim((string)($_POST['city'] ?? ''));
|
||||||
|
$region = trim((string)($_POST['region'] ?? ''));
|
||||||
|
$lat = isset($_POST['lat']) && $_POST['lat'] !== '' ? (float)$_POST['lat'] : null;
|
||||||
|
$lng = isset($_POST['lng']) && $_POST['lng'] !== '' ? (float)$_POST['lng'] : null;
|
||||||
|
$needsGeocode = ($lat === null || $lng === null || $region === '');
|
||||||
|
if ($needsGeocode) {
|
||||||
|
[$geoLat, $geoLng, $geoRegion] = self::geocodeAddress($street, $zip, $city, $region);
|
||||||
|
if ($lat === null) { $lat = $geoLat; }
|
||||||
|
if ($lng === null) { $lng = $geoLng; }
|
||||||
|
if ($region === '' && $geoRegion) { $region = $geoRegion; }
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'event_add') {
|
||||||
|
$stmt = $pdo?->prepare('INSERT INTO events (created_by, title, teaser_public, description, location_label, street, zip, city, region, lat, lng, starts_at, allow_kids, visibility, status, created_at, updated_at) VALUES (:uid, :title, :teaser, :descr, :loc, :street, :zip, :city, :region, :lat, :lng, :start, :allow, :vis, :status, NOW(), NOW())');
|
||||||
|
$stmt?->execute([
|
||||||
|
'uid' => $userId,
|
||||||
|
'title' => trim((string)$_POST['title']),
|
||||||
|
'teaser' => trim((string)$_POST['teaser']),
|
||||||
|
'descr' => trim((string)$_POST['description']),
|
||||||
|
'loc' => trim((string)$_POST['location_label']),
|
||||||
|
'street' => $street ?: null,
|
||||||
|
'zip' => $zip,
|
||||||
|
'city' => $city,
|
||||||
|
'region' => $region,
|
||||||
|
'lat' => $lat,
|
||||||
|
'lng' => $lng,
|
||||||
|
'start' => $_POST['starts_at'] ?? null,
|
||||||
|
'allow' => isset($_POST['allow_kids']) ? 0 : 1,
|
||||||
|
'vis' => $_POST['visibility'] ?? 'public',
|
||||||
|
'status' => 'published',
|
||||||
|
]);
|
||||||
|
$info = 'Event gespeichert.';
|
||||||
|
// Punkte für Event-Erstellung vergeben
|
||||||
|
try {
|
||||||
|
$cfgPath = dirname(__DIR__, 2) . '/config/community.php';
|
||||||
|
$communityCfg = file_exists($cfgPath) ? require $cfgPath : [];
|
||||||
|
$community = new Community($pdo, $communityCfg);
|
||||||
|
$community->addPoints($userId, 'event', 'create', ['event_id' => $pdo?->lastInsertId()]);
|
||||||
|
} catch (\Throwable) {
|
||||||
|
// still continue, points optional
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$eventId = (int)($_POST['event_id'] ?? 0);
|
||||||
|
$stmt = $pdo?->prepare('UPDATE events SET title=:title, teaser_public=:teaser, description=:descr, location_label=:loc, street=:street, zip=:zip, city=:city, region=:region, lat=:lat, lng=:lng, starts_at=:start, allow_kids=:allow, visibility=:vis, updated_at=NOW() WHERE id=:id AND created_by=:uid');
|
||||||
|
$stmt?->execute([
|
||||||
|
'id' => $eventId,
|
||||||
|
'uid' => $userId,
|
||||||
|
'title' => trim((string)$_POST['title']),
|
||||||
|
'teaser' => trim((string)$_POST['teaser']),
|
||||||
|
'descr' => trim((string)$_POST['description']),
|
||||||
|
'loc' => trim((string)$_POST['location_label']),
|
||||||
|
'street' => $street ?: null,
|
||||||
|
'zip' => $zip,
|
||||||
|
'city' => $city,
|
||||||
|
'region' => $region,
|
||||||
|
'lat' => $lat,
|
||||||
|
'lng' => $lng,
|
||||||
|
'start' => $_POST['starts_at'] ?? null,
|
||||||
|
'allow' => isset($_POST['allow_kids']) ? 0 : 1,
|
||||||
|
'vis' => $_POST['visibility'] ?? 'public',
|
||||||
|
]);
|
||||||
|
$info = 'Event aktualisiert.';
|
||||||
|
}
|
||||||
|
} elseif ($action === 'event_delete') {
|
||||||
|
$eventId = (int)($_POST['event_id'] ?? 0);
|
||||||
|
$stmt = $pdo?->prepare('SELECT id, created_by, status, (SELECT COUNT(*) FROM event_participants ep WHERE ep.event_id = events.id) AS participant_count FROM events WHERE id = :id LIMIT 1');
|
||||||
|
$stmt?->execute(['id' => $eventId]);
|
||||||
|
$ev = $stmt?->fetch(\PDO::FETCH_ASSOC);
|
||||||
|
if (!$ev || (int)$ev['created_by'] !== $userId) {
|
||||||
|
throw new \RuntimeException('Event nicht gefunden.');
|
||||||
|
}
|
||||||
|
if ((int)$ev['participant_count'] > 0) {
|
||||||
|
throw new \RuntimeException('Event hat Anmeldungen und kann nicht gelöscht werden.');
|
||||||
|
}
|
||||||
|
$pdo?->prepare('DELETE FROM events WHERE id = :id')->execute(['id' => $eventId]);
|
||||||
|
$info = 'Event gelöscht.';
|
||||||
|
} elseif ($action === 'event_cancel') {
|
||||||
|
$eventId = (int)($_POST['event_id'] ?? 0);
|
||||||
|
$stmt = $pdo?->prepare('SELECT id, created_by FROM events WHERE id = :id LIMIT 1');
|
||||||
|
$stmt?->execute(['id' => $eventId]);
|
||||||
|
$ev = $stmt?->fetch(\PDO::FETCH_ASSOC);
|
||||||
|
if (!$ev || (int)$ev['created_by'] !== $userId) {
|
||||||
|
throw new \RuntimeException('Event nicht gefunden.');
|
||||||
|
}
|
||||||
|
$pdo?->prepare('UPDATE events SET status = :st, updated_at = NOW() WHERE id = :id')->execute([
|
||||||
|
'st' => 'cancelled',
|
||||||
|
'id' => $eventId,
|
||||||
|
]);
|
||||||
|
$info = 'Event wurde abgesagt.';
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$error = $e->getMessage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Daten laden
|
||||||
|
$profile = [
|
||||||
|
'display_name' => '',
|
||||||
|
'first_name' => '',
|
||||||
|
'last_name' => '',
|
||||||
|
'zip' => '',
|
||||||
|
'city' => '',
|
||||||
|
'profession' => '',
|
||||||
|
'languages' => '',
|
||||||
|
'about' => '',
|
||||||
|
'email' => '',
|
||||||
|
'contact_phone' => '',
|
||||||
|
];
|
||||||
|
$stmt = $pdo?->prepare('SELECT u.email, u.status, p.display_name, p.first_name, p.last_name, p.zip, p.city, p.profession, p.languages, p.about, p.contact_phone FROM users u LEFT JOIN user_profiles p ON p.user_id = u.id WHERE u.id = :id LIMIT 1');
|
||||||
|
$stmt?->execute(['id' => $userId]);
|
||||||
|
$row = $stmt?->fetch(\PDO::FETCH_ASSOC);
|
||||||
|
if ($row) {
|
||||||
|
$profile = array_merge($profile, array_filter($row, fn($v) => $v !== null));
|
||||||
|
if ($crypto && !empty($profile['contact_phone'])) {
|
||||||
|
$profile['contact_phone'] = $crypto->decrypt((string)$profile['contact_phone']) ?: '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$children = [];
|
||||||
|
$stmt = $pdo?->prepare('SELECT id, encrypted_first_name AS first_name, note, gender, birthdate, age_years FROM children WHERE user_id = :id ORDER BY id DESC');
|
||||||
|
$stmt?->execute(['id' => $userId]);
|
||||||
|
$childrenRaw = $stmt?->fetchAll(\PDO::FETCH_ASSOC) ?: [];
|
||||||
|
foreach ($childrenRaw as $c) {
|
||||||
|
if ($crypto) {
|
||||||
|
$c['first_name'] = $crypto->decrypt((string)$c['first_name']) ?: '';
|
||||||
|
$c['note'] = $crypto->decrypt((string)($c['note'] ?? '')) ?: '';
|
||||||
|
}
|
||||||
|
$children[] = $c;
|
||||||
|
}
|
||||||
|
|
||||||
|
$eventsUpcoming = [];
|
||||||
|
$eventsPast = [];
|
||||||
|
$editEvent = null;
|
||||||
|
$stmt = $pdo?->prepare(
|
||||||
|
'SELECT e.id, e.title, e.teaser_public, e.description, e.location_label, e.street, e.zip, e.city, e.region, e.starts_at, e.allow_kids, e.visibility, e.status, e.lat, e.lng,
|
||||||
|
(SELECT COUNT(*) FROM event_participants ep WHERE ep.event_id = e.id) AS participant_count
|
||||||
|
FROM events e
|
||||||
|
WHERE e.created_by = :id AND e.starts_at >= NOW()
|
||||||
|
ORDER BY e.starts_at ASC'
|
||||||
|
);
|
||||||
|
$stmt?->execute(['id' => $userId]);
|
||||||
|
$eventsUpcoming = $stmt?->fetchAll(\PDO::FETCH_ASSOC) ?: [];
|
||||||
|
|
||||||
|
$stmt = $pdo?->prepare(
|
||||||
|
'SELECT e.id, e.title, e.teaser_public, e.starts_at, e.city, e.visibility, e.status,
|
||||||
|
(SELECT COUNT(*) FROM event_participants ep WHERE ep.event_id = e.id) AS participant_count
|
||||||
|
FROM events e
|
||||||
|
WHERE e.created_by = :id AND e.starts_at < NOW()
|
||||||
|
ORDER BY e.starts_at DESC'
|
||||||
|
);
|
||||||
|
$stmt?->execute(['id' => $userId]);
|
||||||
|
$eventsPast = $stmt?->fetchAll(\PDO::FETCH_ASSOC) ?: [];
|
||||||
|
|
||||||
|
if (isset($_GET['edit_event'])) {
|
||||||
|
$editId = (int)$_GET['edit_event'];
|
||||||
|
$stmt = $pdo?->prepare('SELECT * FROM events WHERE id = :id AND created_by = :uid AND starts_at >= NOW() LIMIT 1');
|
||||||
|
$stmt?->execute(['id' => $editId, 'uid' => $userId]);
|
||||||
|
$editEvent = $stmt?->fetch(\PDO::FETCH_ASSOC) ?: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return compact('flash','info','error','profile','children','eventsUpcoming','eventsPast','editEvent');
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function geocodeAddress(?string $street, ?string $zip, ?string $city, ?string $region): array
|
||||||
|
{
|
||||||
|
$parts = array_filter([
|
||||||
|
$street ?: null,
|
||||||
|
$zip ?: null,
|
||||||
|
$city ?: null,
|
||||||
|
$region ?: null,
|
||||||
|
]);
|
||||||
|
if (!$parts) {
|
||||||
|
return [null, null, null];
|
||||||
|
}
|
||||||
|
|
||||||
|
$query = implode(', ', $parts);
|
||||||
|
$url = 'https://nominatim.openstreetmap.org/search?' . http_build_query([
|
||||||
|
'format' => 'jsonv2',
|
||||||
|
'limit' => 1,
|
||||||
|
'q' => $query,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$ctx = stream_context_create([
|
||||||
|
'http' => [
|
||||||
|
'method' => 'GET',
|
||||||
|
'header' => "User-Agent: papa-kind-treff/1.0\r\nAccept-Language: de\r\n",
|
||||||
|
'timeout' => 6,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$resp = @file_get_contents($url, false, $ctx);
|
||||||
|
if ($resp === false) {
|
||||||
|
return [null, null, null];
|
||||||
|
}
|
||||||
|
$json = json_decode($resp, true);
|
||||||
|
if (!is_array($json) || empty($json[0]['lat']) || empty($json[0]['lon'])) {
|
||||||
|
return [null, null, null];
|
||||||
|
}
|
||||||
|
$addr = $json[0]['address'] ?? [];
|
||||||
|
$regionGuess = $addr['city_district'] ?? $addr['suburb'] ?? $addr['state'] ?? $addr['county'] ?? $addr['region'] ?? $addr['state_district'] ?? null;
|
||||||
|
return [round((float)$json[0]['lat'], 7), round((float)$json[0]['lon'], 7), $regionGuess];
|
||||||
|
}
|
||||||
|
}
|
||||||
50
src/App/App.php
Executable file
50
src/App/App.php
Executable file
@@ -0,0 +1,50 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App;
|
||||||
|
|
||||||
|
final class App
|
||||||
|
{
|
||||||
|
private static ?self $instance = null;
|
||||||
|
|
||||||
|
private Request $request;
|
||||||
|
private SessionManager $session;
|
||||||
|
private Assets $assets;
|
||||||
|
private I18n $i18n;
|
||||||
|
private Flash $flash;
|
||||||
|
private ?\PDO $pdo;
|
||||||
|
|
||||||
|
private function __construct(private Config $config)
|
||||||
|
{
|
||||||
|
$this->request = new Request();
|
||||||
|
$this->session = new SessionManager($config);
|
||||||
|
$this->assets = new Assets($config);
|
||||||
|
$this->i18n = new I18n($config, 'en');
|
||||||
|
$this->flash = new Flash($this->session);
|
||||||
|
$this->pdo = Database::createPdo($config);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function init(Config $config): self
|
||||||
|
{
|
||||||
|
if (self::$instance === null) {
|
||||||
|
self::$instance = new self($config);
|
||||||
|
}
|
||||||
|
return self::$instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function get(): self
|
||||||
|
{
|
||||||
|
if (self::$instance === null) {
|
||||||
|
throw new \RuntimeException('App not initialized. Call App::init() in bootstrap.');
|
||||||
|
}
|
||||||
|
return self::$instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function config(): Config { return $this->config; }
|
||||||
|
public function request(): Request { return $this->request; }
|
||||||
|
public function session(): SessionManager { return $this->session; }
|
||||||
|
public function assets(): Assets { return $this->assets; }
|
||||||
|
public function i18n(): I18n { return $this->i18n; }
|
||||||
|
public function flash(): Flash { return $this->flash; }
|
||||||
|
public function pdo(): ?\PDO { return $this->pdo; }
|
||||||
|
}
|
||||||
44
src/App/Assets.php
Executable file
44
src/App/Assets.php
Executable file
@@ -0,0 +1,44 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App;
|
||||||
|
|
||||||
|
final class Assets
|
||||||
|
{
|
||||||
|
private array $styles = [];
|
||||||
|
private array $scriptsHeader = [];
|
||||||
|
private array $scriptsFooter = [];
|
||||||
|
|
||||||
|
public function __construct(private Config $config) {}
|
||||||
|
|
||||||
|
public function addStyle(string $href, string $priority = 'normal', ?string $version = null): void
|
||||||
|
{
|
||||||
|
$version ??= $this->config->assetVersion;
|
||||||
|
$this->styles[] = [
|
||||||
|
'href' => $href,
|
||||||
|
'priority' => $priority,
|
||||||
|
'version' => $version,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function addScript(string $src, string $pos = 'footer', bool $defer = true, bool $async = false, ?string $version = null): void
|
||||||
|
{
|
||||||
|
$version ??= $this->config->assetVersion;
|
||||||
|
$row = [
|
||||||
|
'src' => $src,
|
||||||
|
'defer' => $defer,
|
||||||
|
'async' => $async,
|
||||||
|
'version' => $version,
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($pos === 'header') {
|
||||||
|
$this->scriptsHeader[] = $row;
|
||||||
|
} else {
|
||||||
|
$this->scriptsFooter[] = $row;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function styles(): array { return $this->styles; }
|
||||||
|
public function headerScripts(): array { return $this->scriptsHeader; }
|
||||||
|
public function footerScripts(): array { return $this->scriptsFooter; }
|
||||||
|
}
|
||||||
217
src/App/Auth.php
Executable file
217
src/App/Auth.php
Executable file
@@ -0,0 +1,217 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App;
|
||||||
|
|
||||||
|
final class Auth
|
||||||
|
{
|
||||||
|
public function __construct(private App $app) {}
|
||||||
|
|
||||||
|
private function pdo(): \PDO
|
||||||
|
{
|
||||||
|
$pdo = $this->app->pdo();
|
||||||
|
if (!$pdo) {
|
||||||
|
throw new \RuntimeException('Database connection not available.');
|
||||||
|
}
|
||||||
|
return $pdo;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function register(string $displayName, string $email, string $password): int
|
||||||
|
{
|
||||||
|
$pdo = $this->pdo();
|
||||||
|
$email = strtolower(trim($email));
|
||||||
|
$displayName = trim($displayName);
|
||||||
|
|
||||||
|
if ($displayName === '' || $email === '' || $password === '') {
|
||||||
|
throw new \InvalidArgumentException('Display-Name, E-Mail und Passwort sind erforderlich.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$pdo->beginTransaction();
|
||||||
|
try {
|
||||||
|
$stmt = $pdo->prepare('SELECT id FROM users WHERE email = :email LIMIT 1');
|
||||||
|
$stmt->execute(['email' => $email]);
|
||||||
|
if ($stmt->fetchColumn()) {
|
||||||
|
throw new \RuntimeException('E-Mail ist bereits registriert.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$hash = password_hash($password, PASSWORD_ARGON2ID);
|
||||||
|
$stmt = $pdo->prepare('INSERT INTO users (email, password_hash, status, created_at, updated_at) VALUES (:email, :pw, :status, NOW(), NOW())');
|
||||||
|
$stmt->execute([
|
||||||
|
'email' => $email,
|
||||||
|
'pw' => $hash,
|
||||||
|
'status' => 'pending',
|
||||||
|
]);
|
||||||
|
$userId = (int)$pdo->lastInsertId();
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare('INSERT INTO user_profiles (user_id, display_name, share_level, children_visibility, created_at, updated_at) VALUES (:uid, :name, :share, :childvis, NOW(), NOW())');
|
||||||
|
$stmt->execute([
|
||||||
|
'uid' => $userId,
|
||||||
|
'name' => $displayName,
|
||||||
|
'share' => 'basic',
|
||||||
|
'childvis' => 'hidden',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$pdo->commit();
|
||||||
|
return $userId;
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$pdo->rollBack();
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function createVerifyCode(int $userId, string $email): string
|
||||||
|
{
|
||||||
|
$pdo = $this->pdo();
|
||||||
|
$code = $this->generateCode(6);
|
||||||
|
$hash = hash('sha256', $code);
|
||||||
|
|
||||||
|
$pdo->prepare('DELETE FROM user_tokens WHERE user_id = :uid AND type = :t')->execute(['uid' => $userId, 't' => 'verify']);
|
||||||
|
$stmt = $pdo->prepare('INSERT INTO user_tokens (user_id, type, code, token_hash, expires_at, created_at) VALUES (:uid, :type, :code, :hash, DATE_ADD(NOW(), INTERVAL 48 HOUR), NOW())');
|
||||||
|
$stmt->execute([
|
||||||
|
'uid' => $userId,
|
||||||
|
'type' => 'verify',
|
||||||
|
'code' => $code,
|
||||||
|
'hash' => $hash,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $code;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function verifyCode(string $email, string $code): int
|
||||||
|
{
|
||||||
|
$pdo = $this->pdo();
|
||||||
|
$email = strtolower(trim($email));
|
||||||
|
$hash = hash('sha256', $code);
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare('SELECT u.id, u.status, t.id AS tid, t.token_hash FROM users u JOIN user_tokens t ON t.user_id = u.id AND t.type = :type WHERE u.email = :email AND (t.used_at IS NULL) AND t.expires_at > NOW() ORDER BY t.expires_at DESC LIMIT 1');
|
||||||
|
$stmt->execute(['type' => 'verify', 'email' => $email]);
|
||||||
|
$row = $stmt->fetch(\PDO::FETCH_ASSOC);
|
||||||
|
if (!$row || !hash_equals((string)$row['token_hash'], $hash)) {
|
||||||
|
throw new \RuntimeException('Code ist ungültig oder abgelaufen.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$userId = (int)$row['id'];
|
||||||
|
$tid = (int)$row['tid'];
|
||||||
|
|
||||||
|
$pdo->beginTransaction();
|
||||||
|
try {
|
||||||
|
$pdo->prepare('UPDATE user_tokens SET used_at = NOW() WHERE id = :id')->execute(['id' => $tid]);
|
||||||
|
$pdo->prepare('UPDATE users SET status = :st, email_verified_at = NOW() WHERE id = :id')->execute(['st' => 'active', 'id' => $userId]);
|
||||||
|
$pdo->commit();
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$pdo->rollBack();
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function createResetCode(string $email): array
|
||||||
|
{
|
||||||
|
$pdo = $this->pdo();
|
||||||
|
$email = strtolower(trim($email));
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare('SELECT u.id, p.display_name FROM users u LEFT JOIN user_profiles p ON p.user_id = u.id WHERE u.email = :email LIMIT 1');
|
||||||
|
$stmt->execute(['email' => $email]);
|
||||||
|
$row = $stmt->fetch(\PDO::FETCH_ASSOC);
|
||||||
|
if (!$row) {
|
||||||
|
throw new \RuntimeException('E-Mail ist nicht registriert.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$userId = (int)$row['id'];
|
||||||
|
$displayName = (string)($row['display_name'] ?? $email);
|
||||||
|
$code = $this->generateCode(6);
|
||||||
|
$hash = hash('sha256', $code);
|
||||||
|
|
||||||
|
$pdo->prepare('DELETE FROM user_tokens WHERE user_id = :uid AND type = :t')->execute(['uid' => $userId, 't' => 'reset']);
|
||||||
|
$stmt = $pdo->prepare('INSERT INTO user_tokens (user_id, type, code, token_hash, expires_at, created_at) VALUES (:uid, :type, :code, :hash, DATE_ADD(NOW(), INTERVAL 2 HOUR), NOW())');
|
||||||
|
$stmt->execute([
|
||||||
|
'uid' => $userId,
|
||||||
|
'type' => 'reset',
|
||||||
|
'code' => $code,
|
||||||
|
'hash' => $hash,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return ['user_id' => $userId, 'code' => $code, 'display_name' => $displayName];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function verifyResetCode(string $email, string $code): int
|
||||||
|
{
|
||||||
|
$pdo = $this->pdo();
|
||||||
|
$email = strtolower(trim($email));
|
||||||
|
$hash = hash('sha256', $code);
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare('SELECT u.id, t.id AS tid, t.token_hash FROM users u JOIN user_tokens t ON t.user_id = u.id AND t.type = :type WHERE u.email = :email AND (t.used_at IS NULL) AND t.expires_at > NOW() ORDER BY t.expires_at DESC LIMIT 1');
|
||||||
|
$stmt->execute(['type' => 'reset', 'email' => $email]);
|
||||||
|
$row = $stmt->fetch(\PDO::FETCH_ASSOC);
|
||||||
|
if (!$row || !hash_equals((string)$row['token_hash'], $hash)) {
|
||||||
|
throw new \RuntimeException('Code ist ungültig oder abgelaufen.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$userId = (int)$row['id'];
|
||||||
|
$tid = (int)$row['tid'];
|
||||||
|
|
||||||
|
$pdo->beginTransaction();
|
||||||
|
try {
|
||||||
|
$pdo->prepare('UPDATE user_tokens SET used_at = NOW() WHERE id = :id')->execute(['id' => $tid]);
|
||||||
|
$pdo->commit();
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$pdo->rollBack();
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function resetPassword(int $userId, string $password): void
|
||||||
|
{
|
||||||
|
$pdo = $this->pdo();
|
||||||
|
if ($password === '' || strlen($password) < 8) {
|
||||||
|
throw new \InvalidArgumentException('Passwort muss mindestens 8 Zeichen haben.');
|
||||||
|
}
|
||||||
|
$hash = password_hash($password, PASSWORD_ARGON2ID);
|
||||||
|
$stmt = $pdo->prepare('UPDATE users SET password_hash = :pw, status = :status, updated_at = NOW() WHERE id = :id');
|
||||||
|
$stmt->execute([
|
||||||
|
'pw' => $hash,
|
||||||
|
'status' => 'active',
|
||||||
|
'id' => $userId,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function generateCode(int $len = 6): string
|
||||||
|
{
|
||||||
|
$chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
||||||
|
$out = '';
|
||||||
|
for ($i = 0; $i < $len; $i++) {
|
||||||
|
$out .= $chars[random_int(0, strlen($chars) - 1)];
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function login(string $email, string $password): array
|
||||||
|
{
|
||||||
|
$pdo = $this->pdo();
|
||||||
|
$email = strtolower(trim($email));
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare('SELECT id, password_hash, status FROM users WHERE email = :email LIMIT 1');
|
||||||
|
$stmt->execute(['email' => $email]);
|
||||||
|
$row = $stmt->fetch(\PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
if (!$row) {
|
||||||
|
throw new \RuntimeException('E-Mail oder Passwort ist falsch.');
|
||||||
|
}
|
||||||
|
if (!password_verify($password, (string)$row['password_hash'])) {
|
||||||
|
throw new \RuntimeException('E-Mail oder Passwort ist falsch.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$userId = (int)$row['id'];
|
||||||
|
$status = (string)$row['status'];
|
||||||
|
|
||||||
|
if ($status === 'active') {
|
||||||
|
$upd = $pdo->prepare('UPDATE users SET last_login_at = NOW() WHERE id = :id');
|
||||||
|
$upd->execute(['id' => $userId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['id' => $userId, 'status' => $status];
|
||||||
|
}
|
||||||
|
}
|
||||||
196
src/App/Community.php
Executable file
196
src/App/Community.php
Executable file
@@ -0,0 +1,196 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App;
|
||||||
|
|
||||||
|
final class Community
|
||||||
|
{
|
||||||
|
public function __construct(private \PDO $pdo, private array $config)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function createThread(int $userId, string $title, string $body): void
|
||||||
|
{
|
||||||
|
$stmt = $this->pdo->prepare('INSERT INTO forum_threads (user_id, title, body) VALUES (:uid, :title, :body)');
|
||||||
|
$stmt->execute([
|
||||||
|
':uid' => $userId,
|
||||||
|
':title' => trim($title),
|
||||||
|
':body' => trim($body),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function createPost(int $userId, int $threadId, string $body): void
|
||||||
|
{
|
||||||
|
$stmt = $this->pdo->prepare('INSERT INTO forum_posts (thread_id, user_id, body) VALUES (:tid, :uid, :body)');
|
||||||
|
$stmt->execute([
|
||||||
|
':tid' => $threadId,
|
||||||
|
':uid' => $userId,
|
||||||
|
':body' => trim($body),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function searchThreads(string $query, int $limit = 50): array
|
||||||
|
{
|
||||||
|
$conditions = [];
|
||||||
|
$params = [];
|
||||||
|
$tokens = array_filter(preg_split('/\s+/', trim($query)) ?: [], fn($t) => $t !== '');
|
||||||
|
$i = 0;
|
||||||
|
foreach ($tokens as $tok) {
|
||||||
|
$ph1 = ':t' . $i . 'a';
|
||||||
|
$ph2 = ':t' . $i . 'b';
|
||||||
|
$conditions[] = "(ft.title LIKE $ph1 OR ft.body LIKE $ph2)";
|
||||||
|
$params[$ph1] = '%' . $tok . '%';
|
||||||
|
$params[$ph2] = '%' . $tok . '%';
|
||||||
|
$i++;
|
||||||
|
}
|
||||||
|
$where = $conditions ? ('AND ' . implode(' AND ', $conditions)) : '';
|
||||||
|
|
||||||
|
$sql = "SELECT ft.id, ft.title, ft.body, ft.created_at,
|
||||||
|
u.id as uid, u.created_at as user_created,
|
||||||
|
p.display_name,
|
||||||
|
(SELECT COUNT(*) FROM forum_posts fp WHERE fp.thread_id = ft.id) AS answers,
|
||||||
|
(SELECT COUNT(*) FROM forum_posts fp2 WHERE fp2.user_id = u.id) +
|
||||||
|
(SELECT COUNT(*) FROM forum_threads ft2 WHERE ft2.user_id = u.id) AS user_posts
|
||||||
|
FROM forum_threads ft
|
||||||
|
JOIN users u ON u.id = ft.user_id
|
||||||
|
LEFT JOIN user_profiles p ON p.user_id = u.id
|
||||||
|
WHERE 1=1 $where
|
||||||
|
ORDER BY ft.created_at DESC
|
||||||
|
LIMIT :lim";
|
||||||
|
$stmt = $this->pdo->prepare($sql);
|
||||||
|
foreach ($params as $k => $v) {
|
||||||
|
$stmt->bindValue($k, $v, \PDO::PARAM_STR);
|
||||||
|
}
|
||||||
|
$stmt->bindValue(':lim', $limit, \PDO::PARAM_INT);
|
||||||
|
$stmt->execute();
|
||||||
|
return $stmt->fetchAll(\PDO::FETCH_ASSOC) ?: [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function listThreads(int $limit = 50): array
|
||||||
|
{
|
||||||
|
return $this->searchThreads('', $limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getThread(int $id): ?array
|
||||||
|
{
|
||||||
|
$stmt = $this->pdo->prepare('SELECT ft.*, p.display_name FROM forum_threads ft LEFT JOIN user_profiles p ON p.user_id = ft.user_id WHERE ft.id = :id');
|
||||||
|
$stmt->execute([':id' => $id]);
|
||||||
|
$row = $stmt->fetch(\PDO::FETCH_ASSOC);
|
||||||
|
return $row ?: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function listPosts(int $threadId): array
|
||||||
|
{
|
||||||
|
$stmt = $this->pdo->prepare('SELECT fp.*, p.display_name FROM forum_posts fp LEFT JOIN user_profiles p ON p.user_id = fp.user_id WHERE fp.thread_id = :id ORDER BY fp.created_at ASC');
|
||||||
|
$stmt->execute([':id' => $threadId]);
|
||||||
|
return $stmt->fetchAll(\PDO::FETCH_ASSOC) ?: [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function computePoints(int $userId): float
|
||||||
|
{
|
||||||
|
// Primär: aggregierte Werte aus user_points_totals, Fallback: Summe aus user_points
|
||||||
|
$stmt = $this->pdo->prepare('SELECT total FROM user_points_totals WHERE user_id = :uid');
|
||||||
|
$stmt->execute([':uid' => $userId]);
|
||||||
|
$total = $stmt->fetchColumn();
|
||||||
|
if ($total !== false && $total !== null) {
|
||||||
|
return (float)$total;
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt = $this->pdo->prepare('SELECT COALESCE(SUM(amount),0) FROM user_points WHERE user_id = :uid');
|
||||||
|
$stmt->execute([':uid' => $userId]);
|
||||||
|
return (float)$stmt->fetchColumn();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vergibt Punkte persistent und berücksichtigt Caps/Bonis gemäß config actions.
|
||||||
|
*/
|
||||||
|
public function addPoints(int $userId, string $group, string $key, array $meta = []): float
|
||||||
|
{
|
||||||
|
$actions = $this->config['actions'][$group][$key] ?? null;
|
||||||
|
if (!$actions || empty($actions['points'])) {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
$basePoints = (float)$actions['points'];
|
||||||
|
|
||||||
|
// Boni (einfacher first-Check)
|
||||||
|
$bonusPoints = 0.0;
|
||||||
|
if (!empty($actions['bonuses'])) {
|
||||||
|
if (isset($actions['bonuses']['first'])) {
|
||||||
|
$bonusPoints += (float)$actions['bonuses']['first'];
|
||||||
|
}
|
||||||
|
if (isset($actions['bonuses']['first_helpful_5']) && isset($meta['helpful_count']) && (int)$meta['helpful_count'] >= 5) {
|
||||||
|
$bonusPoints += (float)$actions['bonuses']['first_helpful_5'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$amount = $basePoints + $bonusPoints;
|
||||||
|
if ($amount <= 0) {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$caps = $actions['caps'] ?? [];
|
||||||
|
$capDaily = $caps['daily'] ?? null;
|
||||||
|
$capTotal = $caps['total'] ?? null;
|
||||||
|
|
||||||
|
$todayStart = (new \DateTimeImmutable('today'))->format('Y-m-d 00:00:00');
|
||||||
|
$todayEnd = (new \DateTimeImmutable('today'))->format('Y-m-d 23:59:59');
|
||||||
|
|
||||||
|
$actionKey = $group . '.' . $key;
|
||||||
|
|
||||||
|
if ($capDaily !== null) {
|
||||||
|
$stmt = $this->pdo->prepare("SELECT COALESCE(SUM(amount),0) FROM user_points WHERE user_id = :uid AND action = :action AND created_at BETWEEN :s AND :e");
|
||||||
|
$stmt->execute([
|
||||||
|
':uid' => $userId,
|
||||||
|
':action' => $actionKey,
|
||||||
|
':s' => $todayStart,
|
||||||
|
':e' => $todayEnd,
|
||||||
|
]);
|
||||||
|
$usedToday = (float)$stmt->fetchColumn();
|
||||||
|
$remaining = max(0.0, (float)$capDaily - $usedToday);
|
||||||
|
if ($remaining <= 0) {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
$amount = min($amount, $remaining);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($capTotal !== null) {
|
||||||
|
$stmt = $this->pdo->prepare("SELECT COALESCE(SUM(amount),0) FROM user_points WHERE user_id = :uid AND action = :action");
|
||||||
|
$stmt->execute([':uid' => $userId, ':action' => $actionKey]);
|
||||||
|
$usedTotal = (float)$stmt->fetchColumn();
|
||||||
|
$remaining = max(0.0, (float)$capTotal - $usedTotal);
|
||||||
|
if ($remaining <= 0) {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
$amount = min($amount, $remaining);
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt = $this->pdo->prepare('INSERT INTO user_points (user_id, action, amount, meta) VALUES (:uid, :action, :amount, :meta)');
|
||||||
|
$stmt->execute([
|
||||||
|
':uid' => $userId,
|
||||||
|
':action' => $actionKey,
|
||||||
|
':amount' => $amount,
|
||||||
|
':meta' => $meta ? json_encode($meta) : null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$stmt = $this->pdo->prepare('INSERT INTO user_points_totals (user_id, total) VALUES (:uid, :amt) ON DUPLICATE KEY UPDATE total = total + VALUES(total)');
|
||||||
|
$stmt->execute([':uid' => $userId, ':amt' => $amount]);
|
||||||
|
|
||||||
|
return $amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function membershipLevel(float $points): array
|
||||||
|
{
|
||||||
|
$levels = $this->config['levels'] ?? [];
|
||||||
|
usort($levels, fn($a,$b) => ($b['min'] ?? 0) <=> ($a['min'] ?? 0));
|
||||||
|
foreach ($levels as $lvl) {
|
||||||
|
if ($points >= (float)($lvl['min'] ?? 0)) {
|
||||||
|
return [
|
||||||
|
'label' => $lvl['label'] ?? 'New Daddy',
|
||||||
|
'icon' => $lvl['icon'] ?? '',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$fallback = $levels ? $levels[count($levels)-1] : ['label' => 'New Daddy','icon' => ''];
|
||||||
|
return ['label' => $fallback['label'], 'icon' => $fallback['icon'] ?? ''];
|
||||||
|
}
|
||||||
|
}
|
||||||
62
src/App/Config.php
Executable file
62
src/App/Config.php
Executable file
@@ -0,0 +1,62 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App;
|
||||||
|
|
||||||
|
final class Config
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public readonly string $env,
|
||||||
|
public readonly string $prefix,
|
||||||
|
public readonly string $primaryDomain,
|
||||||
|
public readonly string $primaryUrl,
|
||||||
|
public readonly string $apiBase,
|
||||||
|
public readonly string $assetVersion,
|
||||||
|
public readonly bool $dbEnabled,
|
||||||
|
public readonly array $db,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public static function fromPhpConstants(string $configDir): self
|
||||||
|
{
|
||||||
|
// config.php defines these constants.
|
||||||
|
$env = defined('APP_ENV') ? (string) APP_ENV : 'prod';
|
||||||
|
$prefix = defined('APP_PREFIX') ? (string) APP_PREFIX : 'app';
|
||||||
|
$primaryDom = defined('APP_DOMAIN_PRIMARY') ? (string) APP_DOMAIN_PRIMARY : 'example.test';
|
||||||
|
$primaryUrl = defined('APP_URL_PRIMARY') ? (string) APP_URL_PRIMARY : 'https://example.test';
|
||||||
|
$apiBase = defined('APP_API_BASE') ? (string) APP_API_BASE : ($primaryUrl . '/api');
|
||||||
|
$assetVersion = defined('ASSET_VERSION') ? (string) ASSET_VERSION : '';
|
||||||
|
|
||||||
|
$dbEnabled = defined('APP_DB_ENABLED') ? (bool) APP_DB_ENABLED : false;
|
||||||
|
|
||||||
|
$dbFileRoot = rtrim($configDir, '/\\') . DIRECTORY_SEPARATOR . 'db.php';
|
||||||
|
$dbFileEnv = rtrim($configDir, '/\\') . DIRECTORY_SEPARATOR . $env . DIRECTORY_SEPARATOR . 'db.php';
|
||||||
|
$dbFile = file_exists($dbFileRoot) ? $dbFileRoot : (file_exists($dbFileEnv) ? $dbFileEnv : null);
|
||||||
|
$db = $dbFile ? (array) require $dbFile : [];
|
||||||
|
|
||||||
|
return new self(
|
||||||
|
env: $env,
|
||||||
|
prefix: $prefix,
|
||||||
|
primaryDomain: $primaryDom,
|
||||||
|
primaryUrl: rtrim($primaryUrl, '/'),
|
||||||
|
apiBase: rtrim($apiBase, '/'),
|
||||||
|
assetVersion: $assetVersion,
|
||||||
|
dbEnabled: $dbEnabled,
|
||||||
|
db: $db
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function cookiePrefix(): string
|
||||||
|
{
|
||||||
|
// Example: add suffix for staging
|
||||||
|
if ($this->env === 'staging') {
|
||||||
|
return $this->prefix . '_stg_';
|
||||||
|
}
|
||||||
|
return $this->prefix . '_';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function cookieDomain(): string
|
||||||
|
{
|
||||||
|
// Leading dot for subdomain-wide cookies
|
||||||
|
return '.' . ltrim($this->primaryDomain, '.');
|
||||||
|
}
|
||||||
|
}
|
||||||
68
src/App/Crypto.php
Executable file
68
src/App/Crypto.php
Executable file
@@ -0,0 +1,68 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App;
|
||||||
|
|
||||||
|
final class Crypto
|
||||||
|
{
|
||||||
|
private string $key;
|
||||||
|
|
||||||
|
public function __construct(Config $config)
|
||||||
|
{
|
||||||
|
if (!extension_loaded('sodium')) {
|
||||||
|
throw new \RuntimeException('libsodium extension not available');
|
||||||
|
}
|
||||||
|
|
||||||
|
$raw = getenv('DATA_KEY') ?: '';
|
||||||
|
$raw = trim($raw);
|
||||||
|
if ($raw === '') {
|
||||||
|
throw new \RuntimeException('DATA_KEY env not set');
|
||||||
|
}
|
||||||
|
|
||||||
|
// base64?
|
||||||
|
if (str_starts_with($raw, 'base64:')) {
|
||||||
|
$raw = substr($raw, 7);
|
||||||
|
}
|
||||||
|
$decoded = base64_decode($raw, true);
|
||||||
|
if ($decoded !== false && strlen($decoded) >= SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_KEYBYTES) {
|
||||||
|
$raw = $decoded;
|
||||||
|
} elseif (ctype_xdigit($raw) && strlen($raw) >= SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_KEYBYTES * 2) {
|
||||||
|
$raw = hex2bin($raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (strlen($raw) < SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_KEYBYTES) {
|
||||||
|
throw new \RuntimeException('DATA_KEY invalid length');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->key = substr($raw, 0, SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_KEYBYTES);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function encrypt(string $plaintext): string
|
||||||
|
{
|
||||||
|
if ($plaintext === '') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
$nonce = random_bytes(SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES);
|
||||||
|
$cipher = sodium_crypto_aead_xchacha20poly1305_ietf_encrypt($plaintext, '', $nonce, $this->key);
|
||||||
|
return base64_encode($nonce . $cipher);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function decrypt(?string $blob): string
|
||||||
|
{
|
||||||
|
if ($blob === null || $blob === '') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
$raw = base64_decode($blob, true);
|
||||||
|
if ($raw === false || strlen($raw) <= SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
$nonce = substr($raw, 0, SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES);
|
||||||
|
$cipher = substr($raw, SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES);
|
||||||
|
try {
|
||||||
|
$plain = sodium_crypto_aead_xchacha20poly1305_ietf_decrypt($cipher, '', $nonce, $this->key);
|
||||||
|
return $plain === false ? '' : $plain;
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
123
src/App/Database.php
Executable file
123
src/App/Database.php
Executable file
@@ -0,0 +1,123 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App;
|
||||||
|
|
||||||
|
final class Database
|
||||||
|
{
|
||||||
|
public static function createPdo(Config $config): ?\PDO
|
||||||
|
{
|
||||||
|
if (!$config->dbEnabled) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$db = $config->db;
|
||||||
|
$driver = (string)($db['driver'] ?? '');
|
||||||
|
|
||||||
|
if ($driver === '') {
|
||||||
|
throw new \RuntimeException('DB enabled but config/db.php missing "driver"');
|
||||||
|
}
|
||||||
|
|
||||||
|
$dsn = match ($driver) {
|
||||||
|
'mysql' => self::buildMysqlDsn($db),
|
||||||
|
'pgsql' => self::buildPgsqlDsn($db),
|
||||||
|
'sqlite' => self::buildSqliteDsn($db),
|
||||||
|
default => throw new \RuntimeException('Unsupported PDO driver: ' . $driver),
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
$pdo = new \PDO(
|
||||||
|
$dsn,
|
||||||
|
// sqlite braucht user/pass nicht, PDO ignoriert es aber; wir geben leer zurück
|
||||||
|
(string)($db['user'] ?? ''),
|
||||||
|
(string)($db['password'] ?? ''),
|
||||||
|
(array)($db['options'] ?? [])
|
||||||
|
);
|
||||||
|
|
||||||
|
// Optional: PostgreSQL schema/search_path setzen
|
||||||
|
if ($driver === 'pgsql' && !empty($db['schema'])) {
|
||||||
|
// Minimaler Schutz gegen Injection über schema
|
||||||
|
$schema = preg_replace('/[^a-zA-Z0-9_]/', '', (string)$db['schema']);
|
||||||
|
if ($schema !== '') {
|
||||||
|
$pdo->exec('SET search_path TO ' . $schema);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $pdo;
|
||||||
|
} catch (\PDOException $e) {
|
||||||
|
// In Prod würdest du loggen; hier minimal
|
||||||
|
http_response_code(500);
|
||||||
|
echo 'Database connection error.';
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function buildMysqlDsn(array $db): string
|
||||||
|
{
|
||||||
|
if (empty($db['dbname'])) {
|
||||||
|
throw new \RuntimeException('MySQL config missing "dbname"');
|
||||||
|
}
|
||||||
|
|
||||||
|
$charset = (string)($db['charset'] ?? 'utf8mb4');
|
||||||
|
|
||||||
|
// Unix socket takes precedence
|
||||||
|
if (!empty($db['unix_socket'])) {
|
||||||
|
return sprintf(
|
||||||
|
'mysql:unix_socket=%s;dbname=%s;charset=%s',
|
||||||
|
(string)$db['unix_socket'],
|
||||||
|
(string)$db['dbname'],
|
||||||
|
$charset
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$host = (string)($db['host'] ?? 'localhost');
|
||||||
|
$port = (int)($db['port'] ?? 3306);
|
||||||
|
|
||||||
|
return sprintf(
|
||||||
|
'mysql:host=%s;port=%d;dbname=%s;charset=%s',
|
||||||
|
$host,
|
||||||
|
$port,
|
||||||
|
(string)$db['dbname'],
|
||||||
|
$charset
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function buildPgsqlDsn(array $db): string
|
||||||
|
{
|
||||||
|
if (empty($db['dbname'])) {
|
||||||
|
throw new \RuntimeException('PostgreSQL config missing "dbname"');
|
||||||
|
}
|
||||||
|
|
||||||
|
$host = (string)($db['host'] ?? 'localhost');
|
||||||
|
$port = (int)($db['port'] ?? 5432);
|
||||||
|
|
||||||
|
// Hinweis: charset gehört bei pgsql nicht in den DSN
|
||||||
|
return sprintf(
|
||||||
|
'pgsql:host=%s;port=%d;dbname=%s',
|
||||||
|
$host,
|
||||||
|
$port,
|
||||||
|
(string)$db['dbname']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function buildSqliteDsn(array $db): string
|
||||||
|
{
|
||||||
|
// SQLite kann :memory: oder einen Pfad nutzen
|
||||||
|
$path = (string)($db['path'] ?? '');
|
||||||
|
|
||||||
|
if ($path === '') {
|
||||||
|
// Default: Memory-DB
|
||||||
|
$path = ':memory:';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wenn es ein Pfad ist, stelle sicher, dass das Verzeichnis existiert.
|
||||||
|
if ($path !== ':memory:') {
|
||||||
|
$dir = \dirname($path);
|
||||||
|
if ($dir && !is_dir($dir)) {
|
||||||
|
@mkdir($dir, 0775, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'sqlite:' . $path;
|
||||||
|
}
|
||||||
|
}
|
||||||
33
src/App/Flash.php
Executable file
33
src/App/Flash.php
Executable file
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App;
|
||||||
|
|
||||||
|
final class Flash
|
||||||
|
{
|
||||||
|
public function __construct(private SessionManager $session) {}
|
||||||
|
|
||||||
|
public function set(string $type, string $message): void
|
||||||
|
{
|
||||||
|
$this->session->start();
|
||||||
|
$_SESSION['flash'] = [
|
||||||
|
'type' => $type,
|
||||||
|
'message' => $message,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function get(): ?array
|
||||||
|
{
|
||||||
|
$this->session->start();
|
||||||
|
if (empty($_SESSION['flash']) || !is_array($_SESSION['flash'])) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$f = $_SESSION['flash'];
|
||||||
|
unset($_SESSION['flash']);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'type' => (string)($f['type'] ?? 'info'),
|
||||||
|
'message' => (string)($f['message'] ?? ''),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
59
src/App/I18n.php
Executable file
59
src/App/I18n.php
Executable file
@@ -0,0 +1,59 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App;
|
||||||
|
|
||||||
|
final class I18n
|
||||||
|
{
|
||||||
|
private array $current = [];
|
||||||
|
private array $fallback = [];
|
||||||
|
|
||||||
|
public function __construct(private Config $config, private string $lang = 'en')
|
||||||
|
{
|
||||||
|
// Minimal example translations (normally load JSON/PHP arrays from disk)
|
||||||
|
$this->fallback = [
|
||||||
|
'common' => [
|
||||||
|
'title' => 'Papa-Kind-Treff',
|
||||||
|
'intro' => 'Väter vernetzen sich für Treffen mit und ohne Kinder.',
|
||||||
|
],
|
||||||
|
'cta' => [
|
||||||
|
'primary' => 'Weiter',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->current = $this->fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function traverse(array $data, string $key): mixed
|
||||||
|
{
|
||||||
|
$node = $data;
|
||||||
|
foreach (explode('.', $key) as $seg) {
|
||||||
|
if (!is_array($node) || !array_key_exists($seg, $node)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$node = $node[$seg];
|
||||||
|
}
|
||||||
|
return $node;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function get(string $key, $default = '', array $vars = []): string
|
||||||
|
{
|
||||||
|
$val = $this->traverse($this->current, $key);
|
||||||
|
if ($val === null) {
|
||||||
|
$val = $this->traverse($this->fallback, $key);
|
||||||
|
}
|
||||||
|
if (!is_string($val)) {
|
||||||
|
$val = (string)($default ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Built-ins
|
||||||
|
$val = str_replace('{year}', date('Y'), $val);
|
||||||
|
$val = str_replace('{{primary_url}}', $this->config->primaryUrl, $val);
|
||||||
|
|
||||||
|
foreach ($vars as $k => $v) {
|
||||||
|
$val = str_replace('{' . $k . '}', (string)$v, $val);
|
||||||
|
$val = str_replace('{{' . $k . '}}', (string)$v, $val);
|
||||||
|
}
|
||||||
|
return $val;
|
||||||
|
}
|
||||||
|
}
|
||||||
352
src/App/Mailer.php
Executable file
352
src/App/Mailer.php
Executable file
@@ -0,0 +1,352 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App;
|
||||||
|
|
||||||
|
final class Mailer
|
||||||
|
{
|
||||||
|
private string $logFile;
|
||||||
|
private bool $logCleared = false;
|
||||||
|
|
||||||
|
public function __construct(private App $app)
|
||||||
|
{
|
||||||
|
$base = dirname(__DIR__, 2);
|
||||||
|
$this->logFile = $base . '/debug/mailer_debug.log';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function log(string $msg, array $ctx = []): void
|
||||||
|
{
|
||||||
|
if (!defined('APP_DEBUG') || APP_DEBUG !== true) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$line = '[' . date('Y-m-d H:i:s') . '] ' . $msg;
|
||||||
|
if ($ctx) {
|
||||||
|
$line .= ' ' . json_encode($ctx, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||||
|
}
|
||||||
|
$line .= "\n";
|
||||||
|
$dir = dirname($this->logFile);
|
||||||
|
if (!is_dir($dir)) {
|
||||||
|
@mkdir($dir, 0775, true);
|
||||||
|
}
|
||||||
|
// For clarity keep only the latest run in the log: truncate once per request
|
||||||
|
if ($this->logCleared === false) {
|
||||||
|
@file_put_contents($this->logFile, '');
|
||||||
|
$this->logCleared = true;
|
||||||
|
}
|
||||||
|
@file_put_contents($this->logFile, $line, FILE_APPEND);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function templates(): array
|
||||||
|
{
|
||||||
|
$env = $this->app->config()->env;
|
||||||
|
$root = __DIR__ . '/../../config/emailtemplates.php';
|
||||||
|
$envPath = __DIR__ . "/../../config/{$env}/emailtemplates.php";
|
||||||
|
$file = is_file($root) ? $root : $envPath;
|
||||||
|
$emailtemplates = [];
|
||||||
|
if (is_file($file)) {
|
||||||
|
/** @noinspection PhpIncludeInspection */
|
||||||
|
include $file; // populates $emailtemplates variable from included file
|
||||||
|
}
|
||||||
|
return is_array($emailtemplates ?? null) ? $emailtemplates : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function renderTemplate(string $key, array $vars): array
|
||||||
|
{
|
||||||
|
$templates = $this->templates();
|
||||||
|
$id = $templates[$key] ?? $key;
|
||||||
|
$this->log('template_resolved_id', ['key' => $key, 'id' => $id]);
|
||||||
|
|
||||||
|
$apiBase = getenv('EMAILTEMPLATE_API_BASE') ?: '';
|
||||||
|
$apiToken = getenv('EMAILTEMPLATE_API_TOKEN') ?: '';
|
||||||
|
|
||||||
|
if ($apiBase && $apiToken) {
|
||||||
|
$payload = [
|
||||||
|
'template' => $id,
|
||||||
|
'placeholders' => $vars,
|
||||||
|
];
|
||||||
|
$payload['token'] = $apiToken;
|
||||||
|
|
||||||
|
$payloadForLog = $payload;
|
||||||
|
$payloadForLog['token'] = '[hidden length ' . strlen((string)$apiToken) . ']';
|
||||||
|
$this->log('template_api_request_payload', [
|
||||||
|
'url' => $apiBase,
|
||||||
|
'payload' => $payloadForLog,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->log('template_api_request', ['template' => $id, 'placeholders' => array_keys($vars)]);
|
||||||
|
$ctx = stream_context_create([
|
||||||
|
'http' => [
|
||||||
|
'method' => 'POST',
|
||||||
|
'header' => "Content-Type: application/json\r\n",
|
||||||
|
'timeout' => 15,
|
||||||
|
'content' => json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
$resp = @file_get_contents($apiBase, false, $ctx);
|
||||||
|
if ($resp !== false) {
|
||||||
|
$status = null;
|
||||||
|
if (isset($http_response_header) && is_array($http_response_header)) {
|
||||||
|
foreach ($http_response_header as $hdr) {
|
||||||
|
if (preg_match('~^HTTP/\\S+\\s+(\\d+)~i', $hdr, $m)) {
|
||||||
|
$status = (int)$m[1];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$this->log('template_api_response_raw', [
|
||||||
|
'status' => $status,
|
||||||
|
'body' => $resp,
|
||||||
|
]);
|
||||||
|
$decoded = json_decode($resp, true);
|
||||||
|
if (is_array($decoded) && !empty($decoded['ok']) && !empty($decoded['html'])) {
|
||||||
|
$this->log('template_api_success', ['template' => $id, 'subject' => $decoded['subject'] ?? null, 'html_len' => strlen((string)$decoded['html'])]);
|
||||||
|
return [
|
||||||
|
'id' => $id,
|
||||||
|
'subject' => $decoded['subject'] ?? 'Papa-Kind-Treff',
|
||||||
|
'html' => $decoded['html'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
$this->log('template_api_response_invalid', ['template' => $id, 'response' => $decoded]);
|
||||||
|
} else {
|
||||||
|
$this->log('template_api_unreachable', ['template' => $id]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: einfacher Text
|
||||||
|
$subject = 'Papa-Kind-Treff';
|
||||||
|
$body = $id;
|
||||||
|
foreach ($vars as $k => $v) {
|
||||||
|
$body = str_replace(['{' . $k . '}', '{{' . $k . '}}'], (string)$v, $body);
|
||||||
|
}
|
||||||
|
$this->log('template_fallback_used', ['template' => $id]);
|
||||||
|
return [
|
||||||
|
'id' => $id,
|
||||||
|
'subject' => $subject,
|
||||||
|
'html' => nl2br(htmlspecialchars($body, ENT_QUOTES)),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function sendTemplate(string $templateKey, string $to, array $vars = []): void
|
||||||
|
{
|
||||||
|
if (!filter_var($to, FILTER_VALIDATE_EMAIL)) {
|
||||||
|
throw new \InvalidArgumentException('Invalid recipient email.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$tpl = $this->renderTemplate($templateKey, $vars);
|
||||||
|
$resolvedId = $tpl['id'] ?? $templateKey;
|
||||||
|
$subject = $tpl['subject'] ?? 'Papa-Kind-Treff';
|
||||||
|
$html = $tpl['html'] ?? '';
|
||||||
|
|
||||||
|
$this->log('mail_rendered_template', [
|
||||||
|
'template_key' => $templateKey,
|
||||||
|
'template_id' => $resolvedId,
|
||||||
|
'subject' => $subject,
|
||||||
|
'html_len' => strlen((string)$html),
|
||||||
|
'html_preview' => substr((string)$html, 0, 200),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$transport = getenv('MAIL_TRANSPORT') ?: 'mail';
|
||||||
|
$fromEmail = getenv('MAIL_FROM') ?: 'no-reply@' . $this->app->config()->primaryDomain;
|
||||||
|
$fromName = getenv('MAIL_FROM_NAME') ?: 'Papa-Kind-Treff';
|
||||||
|
|
||||||
|
$this->log('mail_send_start', [
|
||||||
|
'template_key' => $templateKey,
|
||||||
|
'template_id' => $resolvedId,
|
||||||
|
'to' => $to,
|
||||||
|
'transport' => $transport,
|
||||||
|
'subject' => $subject
|
||||||
|
]);
|
||||||
|
if ($transport === 'smtp') {
|
||||||
|
$this->sendSmtp($to, $subject, $html, $fromEmail, $fromName);
|
||||||
|
} else {
|
||||||
|
$this->sendMailFn($to, $subject, $html, $fromEmail, $fromName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function sendMailFn(string $to, string $subject, string $html, string $from, string $fromName): void
|
||||||
|
{
|
||||||
|
$headers = [];
|
||||||
|
if ($from) {
|
||||||
|
$headers[] = 'From: ' . sprintf('"%s" <%s>', addslashes($fromName), $from);
|
||||||
|
}
|
||||||
|
$headers[] = 'Content-Type: text/html; charset=utf-8';
|
||||||
|
$ok = @mail($to, $subject, $html, implode("\r\n", $headers));
|
||||||
|
$this->log('mail_mail_transport', ['to' => $to, 'ok' => $ok]);
|
||||||
|
if (!$ok) {
|
||||||
|
throw new \RuntimeException('mail() transport failed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function sendSmtp(string $to, string $subject, string $html, string $from, string $fromName): void
|
||||||
|
{
|
||||||
|
$host = getenv('SMTP_HOST') ?: '';
|
||||||
|
$port = (int)(getenv('SMTP_PORT') ?: 587);
|
||||||
|
$user = getenv('SMTP_USER') ?: '';
|
||||||
|
$pass = getenv('SMTP_PASS') ?: '';
|
||||||
|
$secure = strtolower(getenv('SMTP_SECURE') ?: 'tls'); // tls|ssl|none
|
||||||
|
|
||||||
|
if (!$host) {
|
||||||
|
$this->log('mail_smtp_missing_host_fallback_mail', []);
|
||||||
|
$this->sendMailFn($to, $subject, $html, $from, $fromName);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$proto = ($secure === 'ssl') ? 'ssl://' : '';
|
||||||
|
$fp = @stream_socket_client($proto . $host . ':' . $port, $errno, $errstr, 15, STREAM_CLIENT_CONNECT);
|
||||||
|
if (!$fp) {
|
||||||
|
$this->log('mail_smtp_connect_failed', ['host' => $host, 'port' => $port, 'error' => $errstr]);
|
||||||
|
$this->sendMailFn($to, $subject, $html, $from, $fromName);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
stream_set_timeout($fp, 15);
|
||||||
|
|
||||||
|
$transcript = [];
|
||||||
|
$readResponse = function (array $expectCodes = [], string $label = 'read') use ($fp, &$transcript): array {
|
||||||
|
$lines = [];
|
||||||
|
while (($line = fgets($fp, 515)) !== false) {
|
||||||
|
$line = rtrim($line, "\r\n");
|
||||||
|
$lines[] = $line;
|
||||||
|
$transcript[] = $label . ': ' . $line;
|
||||||
|
// SMTP multiline: code + '-' means more lines, code + ' ' means end
|
||||||
|
if (strlen($line) >= 4 && $line[3] === ' ') {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$code = 0;
|
||||||
|
if ($lines) {
|
||||||
|
$code = (int)substr($lines[0], 0, 3);
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
'ok' => !$expectCodes || in_array($code, $expectCodes, true),
|
||||||
|
'code' => $code,
|
||||||
|
'lines' => $lines,
|
||||||
|
];
|
||||||
|
};
|
||||||
|
$write = function (string $cmd, string $label = 'write', bool $mask = false) use ($fp, &$transcript): void {
|
||||||
|
$transcript[] = $label . ': ' . ($mask ? '[omitted]' : $cmd);
|
||||||
|
fwrite($fp, $cmd . "\r\n");
|
||||||
|
};
|
||||||
|
|
||||||
|
$resp = $readResponse([220], 'greeting');
|
||||||
|
if (!$resp['ok']) {
|
||||||
|
fclose($fp);
|
||||||
|
$this->log('mail_smtp_greeting_failed', ['host' => $host, 'port' => $port, 'resp' => $resp]);
|
||||||
|
$this->log('mail_smtp_transcript', ['host' => $host, 'port' => $port, 'secure' => $secure, 'steps' => $transcript]);
|
||||||
|
$this->sendMailFn($to, $subject, $html, $from, $fromName);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$write('EHLO ' . $this->app->config()->primaryDomain);
|
||||||
|
$resp = $readResponse([250], 'ehlo');
|
||||||
|
if (!$resp['ok']) {
|
||||||
|
fclose($fp);
|
||||||
|
$this->log('mail_smtp_ehlo_failed', ['host' => $host, 'port' => $port, 'resp' => $resp]);
|
||||||
|
$this->log('mail_smtp_transcript', ['host' => $host, 'port' => $port, 'secure' => $secure, 'steps' => $transcript]);
|
||||||
|
$this->sendMailFn($to, $subject, $html, $from, $fromName);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($secure === 'tls') {
|
||||||
|
$write('STARTTLS');
|
||||||
|
$resp = $readResponse([220], 'starttls');
|
||||||
|
if (!$resp['ok'] || !stream_socket_enable_crypto($fp, true, STREAM_CRYPTO_METHOD_TLS_CLIENT)) {
|
||||||
|
fclose($fp);
|
||||||
|
$this->log('mail_smtp_starttls_failed', ['host' => $host, 'port' => $port, 'resp' => $resp]);
|
||||||
|
$this->log('mail_smtp_transcript', ['host' => $host, 'port' => $port, 'secure' => $secure, 'steps' => $transcript]);
|
||||||
|
$this->sendMailFn($to, $subject, $html, $from, $fromName);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$write('EHLO ' . $this->app->config()->primaryDomain);
|
||||||
|
$resp = $readResponse([250], 'ehlo-tls');
|
||||||
|
if (!$resp['ok']) {
|
||||||
|
fclose($fp);
|
||||||
|
$this->log('mail_smtp_ehlo_tls_failed', ['host' => $host, 'port' => $port, 'resp' => $resp]);
|
||||||
|
$this->log('mail_smtp_transcript', ['host' => $host, 'port' => $port, 'secure' => $secure, 'steps' => $transcript]);
|
||||||
|
$this->sendMailFn($to, $subject, $html, $from, $fromName);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($user !== '') {
|
||||||
|
$write('AUTH LOGIN');
|
||||||
|
$resp = $readResponse([334], 'auth-login');
|
||||||
|
if (!$resp['ok']) {
|
||||||
|
fclose($fp);
|
||||||
|
$this->log('mail_smtp_auth_login_failed', ['host' => $host, 'port' => $port, 'resp' => $resp]);
|
||||||
|
$this->log('mail_smtp_transcript', ['host' => $host, 'port' => $port, 'secure' => $secure, 'steps' => $transcript]);
|
||||||
|
$this->sendMailFn($to, $subject, $html, $from, $fromName);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$write(base64_encode($user), 'auth-user', true);
|
||||||
|
$resp = $readResponse([334], 'auth-user');
|
||||||
|
if (!$resp['ok']) {
|
||||||
|
fclose($fp);
|
||||||
|
$this->log('mail_smtp_auth_user_failed', ['host' => $host, 'port' => $port, 'resp' => $resp]);
|
||||||
|
$this->log('mail_smtp_transcript', ['host' => $host, 'port' => $port, 'secure' => $secure, 'steps' => $transcript]);
|
||||||
|
$this->sendMailFn($to, $subject, $html, $from, $fromName);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$write(base64_encode($pass), 'auth-pass', true);
|
||||||
|
$resp = $readResponse([235], 'auth-pass');
|
||||||
|
if (!$resp['ok']) {
|
||||||
|
fclose($fp);
|
||||||
|
$this->log('mail_smtp_auth_pass_failed', ['host' => $host, 'port' => $port, 'resp' => $resp]);
|
||||||
|
$this->log('mail_smtp_transcript', ['host' => $host, 'port' => $port, 'secure' => $secure, 'steps' => $transcript]);
|
||||||
|
$this->sendMailFn($to, $subject, $html, $from, $fromName);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$write('MAIL FROM: <' . $from . '>');
|
||||||
|
$resp = $readResponse([250], 'mail-from');
|
||||||
|
if (!$resp['ok']) {
|
||||||
|
fclose($fp);
|
||||||
|
$this->log('mail_smtp_mailfrom_failed', ['host' => $host, 'port' => $port, 'resp' => $resp]);
|
||||||
|
$this->log('mail_smtp_transcript', ['host' => $host, 'port' => $port, 'secure' => $secure, 'steps' => $transcript]);
|
||||||
|
$this->sendMailFn($to, $subject, $html, $from, $fromName);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$write('RCPT TO: <' . $to . '>');
|
||||||
|
$resp = $readResponse([250, 251], 'rcpt-to');
|
||||||
|
if (!$resp['ok']) {
|
||||||
|
fclose($fp);
|
||||||
|
$this->log('mail_smtp_rcpt_failed', ['host' => $host, 'port' => $port, 'resp' => $resp]);
|
||||||
|
$this->log('mail_smtp_transcript', ['host' => $host, 'port' => $port, 'secure' => $secure, 'steps' => $transcript]);
|
||||||
|
$this->sendMailFn($to, $subject, $html, $from, $fromName);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$write('DATA');
|
||||||
|
$resp = $readResponse([354], 'data-start');
|
||||||
|
if (!$resp['ok']) {
|
||||||
|
fclose($fp);
|
||||||
|
$this->log('mail_smtp_data_start_failed', ['host' => $host, 'port' => $port, 'resp' => $resp]);
|
||||||
|
$this->log('mail_smtp_transcript', ['host' => $host, 'port' => $port, 'secure' => $secure, 'steps' => $transcript]);
|
||||||
|
$this->sendMailFn($to, $subject, $html, $from, $fromName);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$msg = "From: {$fromName} <{$from}>\r\n";
|
||||||
|
$msg .= "To: <{$to}>\r\n";
|
||||||
|
$msg .= "Subject: {$subject}\r\n";
|
||||||
|
$msg .= "MIME-Version: 1.0\r\n";
|
||||||
|
$msg .= "Content-Type: text/html; charset=utf-8\r\n\r\n";
|
||||||
|
$msg .= $html . "\r\n.\r\n";
|
||||||
|
$write($msg, 'data', false);
|
||||||
|
$resp = $readResponse([250], 'data-end');
|
||||||
|
|
||||||
|
$write('QUIT');
|
||||||
|
$readResponse([221], 'quit');
|
||||||
|
fclose($fp);
|
||||||
|
|
||||||
|
$this->log('mail_smtp_transcript', ['host' => $host, 'port' => $port, 'secure' => $secure, 'steps' => $transcript]);
|
||||||
|
|
||||||
|
if (!$resp['ok']) {
|
||||||
|
$this->log('mail_smtp_send_failed', ['host' => $host, 'port' => $port, 'resp' => $resp]);
|
||||||
|
$this->sendMailFn($to, $subject, $html, $from, $fromName);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$this->log('mail_smtp_sent', ['to' => $to, 'host' => $host, 'port' => $port, 'secure' => $secure]);
|
||||||
|
}
|
||||||
|
}
|
||||||
47
src/App/Request.php
Executable file
47
src/App/Request.php
Executable file
@@ -0,0 +1,47 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App;
|
||||||
|
|
||||||
|
final class Request
|
||||||
|
{
|
||||||
|
public function scheme(): string
|
||||||
|
{
|
||||||
|
// Proxy / LB
|
||||||
|
if (!empty($_SERVER['HTTP_X_FORWARDED_PROTO'])) {
|
||||||
|
$proto = strtolower((string)$_SERVER['HTTP_X_FORWARDED_PROTO']);
|
||||||
|
if ($proto === 'https' || $proto === 'http') {
|
||||||
|
return $proto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') {
|
||||||
|
return 'https';
|
||||||
|
}
|
||||||
|
return 'http';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function host(): string
|
||||||
|
{
|
||||||
|
return (string)($_SERVER['HTTP_HOST'] ?? 'localhost');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function baseUrl(): string
|
||||||
|
{
|
||||||
|
return $this->scheme() . '://' . $this->host();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function path(): string
|
||||||
|
{
|
||||||
|
return (string) strtok((string)($_SERVER['REQUEST_URI'] ?? '/'), '?');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function currentUrl(bool $withQuery = true): string
|
||||||
|
{
|
||||||
|
$base = $this->baseUrl();
|
||||||
|
$uri = (string)($_SERVER['REQUEST_URI'] ?? '/');
|
||||||
|
if ($withQuery) {
|
||||||
|
return $base . $uri;
|
||||||
|
}
|
||||||
|
return $base . (string) strtok($uri, '?');
|
||||||
|
}
|
||||||
|
}
|
||||||
241
src/App/Search.php
Executable file
241
src/App/Search.php
Executable file
@@ -0,0 +1,241 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App;
|
||||||
|
|
||||||
|
final class Search
|
||||||
|
{
|
||||||
|
public function __construct(private ?\PDO $pdo) {}
|
||||||
|
|
||||||
|
public function searchEvents(string $query, int $limit = 100, ?array $geo = null): array
|
||||||
|
{
|
||||||
|
if (!$this->pdo) return [];
|
||||||
|
|
||||||
|
$q = trim($query);
|
||||||
|
$hasGeo = isset($geo['lat'], $geo['lng']) && is_numeric($geo['lat']) && is_numeric($geo['lng']);
|
||||||
|
if ($q === '' && !$hasGeo) return [];
|
||||||
|
|
||||||
|
$tokens = array_filter(preg_split('/\s+/', $q) ?: [], fn($t) => $t !== '');
|
||||||
|
if (!$tokens) {
|
||||||
|
$tokens = [$q];
|
||||||
|
}
|
||||||
|
// Nur Tokens ab 3 Zeichen für fuzzy/LIKE berücksichtigen
|
||||||
|
$tokens = array_values(array_filter($tokens, fn($t) => mb_strlen($t) >= 3));
|
||||||
|
if (!$tokens && !$hasGeo) return [];
|
||||||
|
|
||||||
|
$conditions = [];
|
||||||
|
$bindTokens = [];
|
||||||
|
$i = 0;
|
||||||
|
foreach ($tokens as $tok) {
|
||||||
|
$tok = trim($tok);
|
||||||
|
if ($tok === '') continue;
|
||||||
|
// LIKE + phonetic (SOUNDEX) to allow partial and typo-tolerant matches
|
||||||
|
$conditions[] = "(title LIKE CONCAT('%', ?, '%') OR teaser_public LIKE CONCAT('%', ?, '%') OR description LIKE CONCAT('%', ?, '%') OR city LIKE CONCAT('%', ?, '%') OR region LIKE CONCAT('%', ?, '%') OR zip LIKE CONCAT('%', ?, '%') OR SOUNDEX(title)=SOUNDEX(?) OR SOUNDEX(teaser_public)=SOUNDEX(?) OR SOUNDEX(description)=SOUNDEX(?) OR SOUNDEX(city)=SOUNDEX(?) OR SOUNDEX(region)=SOUNDEX(?))";
|
||||||
|
// LIKE bindings
|
||||||
|
$bindTokens[] = $tok;
|
||||||
|
$bindTokens[] = $tok;
|
||||||
|
$bindTokens[] = $tok;
|
||||||
|
$bindTokens[] = $tok;
|
||||||
|
$bindTokens[] = $tok;
|
||||||
|
$bindTokens[] = $tok;
|
||||||
|
// SOUNDEX bindings
|
||||||
|
$bindTokens[] = $tok;
|
||||||
|
$bindTokens[] = $tok;
|
||||||
|
$bindTokens[] = $tok;
|
||||||
|
$bindTokens[] = $tok;
|
||||||
|
$bindTokens[] = $tok;
|
||||||
|
$i++;
|
||||||
|
}
|
||||||
|
|
||||||
|
$whereParts = [
|
||||||
|
"starts_at >= NOW()",
|
||||||
|
"status != 'cancelled'",
|
||||||
|
];
|
||||||
|
if ($conditions) {
|
||||||
|
// "OR" so that partial matches across tokens are allowed
|
||||||
|
$whereParts[] = '(' . implode(' OR ', $conditions) . ')';
|
||||||
|
}
|
||||||
|
|
||||||
|
$distanceFiltering = false;
|
||||||
|
$bind = [];
|
||||||
|
if ($hasGeo) {
|
||||||
|
$sql = "SELECT id, title, teaser_public, description, city, region, zip, starts_at, visibility, allow_kids, location_label, lat, lng,
|
||||||
|
(6371 * ACOS(LEAST(1,
|
||||||
|
COS(RADIANS(?)) * COS(RADIANS(lat)) * COS(RADIANS(lng) - RADIANS(?)) +
|
||||||
|
SIN(RADIANS(?)) * SIN(RADIANS(lat))
|
||||||
|
))) AS distance_km";
|
||||||
|
$lat = (float)$geo['lat'];
|
||||||
|
$lng = (float)$geo['lng'];
|
||||||
|
$radius = isset($geo['radius']) && is_numeric($geo['radius']) ? max(0.1, (float)$geo['radius']) : 5.0;
|
||||||
|
$distanceFiltering = true;
|
||||||
|
|
||||||
|
$latRange = $radius / 111.0;
|
||||||
|
$lngRange = $radius / (111.0 * max(0.1, cos($lat * M_PI / 180)));
|
||||||
|
$whereParts[] = "(lat IS NOT NULL AND lng IS NOT NULL)";
|
||||||
|
$whereParts[] = "(lat BETWEEN ? AND ?)";
|
||||||
|
$whereParts[] = "(lng BETWEEN ? AND ?)";
|
||||||
|
// Haversine params (order must match SQL): first three
|
||||||
|
$bind[] = $lat; // COS(RADIANS(?))
|
||||||
|
$bind[] = $lng; // COS(RADIANS(lng) - RADIANS(?))
|
||||||
|
$bind[] = $lat; // SIN(RADIANS(?))
|
||||||
|
// THEN token binds
|
||||||
|
$bind = array_merge($bind, $bindTokens);
|
||||||
|
// Bounding box
|
||||||
|
$bind[] = $lat - $latRange;
|
||||||
|
$bind[] = $lat + $latRange;
|
||||||
|
$bind[] = $lng - $lngRange;
|
||||||
|
$bind[] = $lng + $lngRange;
|
||||||
|
// Radius for HAVING
|
||||||
|
$bind[] = $radius;
|
||||||
|
} else {
|
||||||
|
$sql = "SELECT id, title, teaser_public, description, city, region, zip, starts_at, visibility, allow_kids, location_label, lat, lng, NULL AS distance_km";
|
||||||
|
$bind = $bindTokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
$where = $whereParts ? ('WHERE ' . implode(' AND ', $whereParts)) : '';
|
||||||
|
$sql .= " FROM events $where";
|
||||||
|
if ($distanceFiltering) {
|
||||||
|
$sql .= " HAVING distance_km <= ?";
|
||||||
|
$sql .= " ORDER BY distance_km ASC, starts_at ASC";
|
||||||
|
} else {
|
||||||
|
$sql .= " ORDER BY starts_at ASC";
|
||||||
|
}
|
||||||
|
$limit = (int)$limit;
|
||||||
|
$sql .= " LIMIT {$limit}";
|
||||||
|
|
||||||
|
$stmt = $this->pdo->prepare($sql);
|
||||||
|
try {
|
||||||
|
$stmt->execute($bind);
|
||||||
|
$rows = $stmt->fetchAll(\PDO::FETCH_ASSOC) ?: [];
|
||||||
|
if (!$hasGeo) {
|
||||||
|
foreach ($rows as &$r) {
|
||||||
|
unset($r['distance_km']);
|
||||||
|
}
|
||||||
|
unset($r);
|
||||||
|
}
|
||||||
|
// Fuzzy filter: allow slight typos (Levenshtein <= 1 or 2)
|
||||||
|
if ($tokens) {
|
||||||
|
$rows = array_values(array_filter($rows, function ($row) use ($tokens) {
|
||||||
|
$haystack = strtolower(
|
||||||
|
($row['title'] ?? '') . ' ' .
|
||||||
|
($row['teaser_public'] ?? '') . ' ' .
|
||||||
|
($row['description'] ?? '') . ' ' .
|
||||||
|
($row['city'] ?? '') . ' ' .
|
||||||
|
($row['region'] ?? '')
|
||||||
|
);
|
||||||
|
$words = preg_split('/[^a-z0-9äöüß]+/i', $haystack) ?: [];
|
||||||
|
foreach ($tokens as $tok) {
|
||||||
|
$t = strtolower($tok);
|
||||||
|
if ($t === '') continue;
|
||||||
|
if (str_contains($haystack, $t)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
foreach ($words as $w) {
|
||||||
|
if ($w === '') continue;
|
||||||
|
$dist = levenshtein($t, $w);
|
||||||
|
if ($dist <= 1 || ($dist <= 2 && max(strlen($t), strlen($w)) > 4)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
// Fallback: wenn keine Treffer, erneut ohne Token-Filter laden und nur fuzzy filtern
|
||||||
|
if (!$rows && $tokens) {
|
||||||
|
$wherePartsFallback = [
|
||||||
|
"starts_at >= NOW()",
|
||||||
|
"status != 'cancelled'",
|
||||||
|
];
|
||||||
|
$bindFb = [];
|
||||||
|
$sqlFb = '';
|
||||||
|
if ($hasGeo) {
|
||||||
|
$sqlFb = "SELECT id, title, teaser_public, description, city, region, zip, starts_at, visibility, allow_kids, location_label, lat, lng,
|
||||||
|
(6371 * ACOS(LEAST(1,
|
||||||
|
COS(RADIANS(?)) * COS(RADIANS(lat)) * COS(RADIANS(lng) - RADIANS(?)) +
|
||||||
|
SIN(RADIANS(?)) * SIN(RADIANS(lat))
|
||||||
|
))) AS distance_km";
|
||||||
|
$lat = (float)$geo['lat'];
|
||||||
|
$lng = (float)$geo['lng'];
|
||||||
|
$radius = isset($geo['radius']) && is_numeric($geo['radius']) ? max(0.1, (float)$geo['radius']) : 5.0;
|
||||||
|
$latRange = $radius / 111.0;
|
||||||
|
$lngRange = $radius / (111.0 * max(0.1, cos($lat * M_PI / 180)));
|
||||||
|
$wherePartsFallback[] = "(lat IS NOT NULL AND lng IS NOT NULL)";
|
||||||
|
$wherePartsFallback[] = "(lat BETWEEN ? AND ?)";
|
||||||
|
$wherePartsFallback[] = "(lng BETWEEN ? AND ?)";
|
||||||
|
$bindFb[] = $lat;
|
||||||
|
$bindFb[] = $lng;
|
||||||
|
$bindFb[] = $lat;
|
||||||
|
$bindFb[] = $lat - $latRange;
|
||||||
|
$bindFb[] = $lat + $latRange;
|
||||||
|
$bindFb[] = $lng - $lngRange;
|
||||||
|
$bindFb[] = $lng + $lngRange;
|
||||||
|
$bindFb[] = $radius;
|
||||||
|
$havingFb = true;
|
||||||
|
} else {
|
||||||
|
$sqlFb = "SELECT id, title, teaser_public, description, city, region, zip, starts_at, visibility, allow_kids, location_label, lat, lng, 1 AS distance_km";
|
||||||
|
$havingFb = false;
|
||||||
|
}
|
||||||
|
$whereFb = $wherePartsFallback ? ('WHERE ' . implode(' AND ', $wherePartsFallback)) : '';
|
||||||
|
$sqlFb .= " FROM events $whereFb";
|
||||||
|
if ($havingFb) {
|
||||||
|
$sqlFb .= " HAVING distance_km <= ?";
|
||||||
|
$sqlFb .= " ORDER BY distance_km ASC, starts_at ASC";
|
||||||
|
} else {
|
||||||
|
$sqlFb .= " ORDER BY starts_at ASC";
|
||||||
|
}
|
||||||
|
$sqlFb .= " LIMIT {$limit}";
|
||||||
|
$stmtFb = $this->pdo->prepare($sqlFb);
|
||||||
|
$stmtFb->execute($bindFb);
|
||||||
|
$rowsFb = $stmtFb->fetchAll(\PDO::FETCH_ASSOC) ?: [];
|
||||||
|
if ($rowsFb) {
|
||||||
|
$rows = array_values(array_filter($rowsFb, function ($row) use ($tokens) {
|
||||||
|
$haystack = strtolower(
|
||||||
|
($row['title'] ?? '') . ' ' .
|
||||||
|
($row['teaser_public'] ?? '') . ' ' .
|
||||||
|
($row['description'] ?? '') . ' ' .
|
||||||
|
($row['city'] ?? '') . ' ' .
|
||||||
|
($row['region'] ?? '')
|
||||||
|
);
|
||||||
|
$words = preg_split('/[^a-z0-9äöüß]+/i', $haystack) ?: [];
|
||||||
|
foreach ($tokens as $tok) {
|
||||||
|
$t = strtolower($tok);
|
||||||
|
if ($t === '') continue;
|
||||||
|
if (str_contains($haystack, $t)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
foreach ($words as $w) {
|
||||||
|
if ($w === '') continue;
|
||||||
|
$dist = levenshtein($t, $w);
|
||||||
|
if ($dist <= 1 || ($dist <= 2 && max(strlen($t), strlen($w)) > 4)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (defined('APP_ENV') && APP_ENV === 'staging') {
|
||||||
|
$logOk = [
|
||||||
|
'status' => 'ok',
|
||||||
|
'sql' => $sql,
|
||||||
|
'bind' => $bind,
|
||||||
|
'count' => count($rows),
|
||||||
|
'fallback' => ($rows ? 'primary' : 'fallback'),
|
||||||
|
];
|
||||||
|
@file_put_contents(__DIR__ . '/../../debug/search_debug.log', print_r($logOk, true));
|
||||||
|
}
|
||||||
|
return $rows;
|
||||||
|
} catch (\PDOException $e) {
|
||||||
|
// Log into /debug/search_debug.log and continue with empty results
|
||||||
|
$logErr = [
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
'sql' => $sql,
|
||||||
|
'bind' => $bind,
|
||||||
|
];
|
||||||
|
@file_put_contents(__DIR__ . '/../../debug/search_debug.log', print_r($logErr, true));
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
71
src/App/SessionManager.php
Executable file
71
src/App/SessionManager.php
Executable file
@@ -0,0 +1,71 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App;
|
||||||
|
|
||||||
|
final class SessionManager
|
||||||
|
{
|
||||||
|
private string $sessionCookieName;
|
||||||
|
private string $clientCookieName;
|
||||||
|
|
||||||
|
public function __construct(private Config $config)
|
||||||
|
{
|
||||||
|
$prefix = $config->cookiePrefix();
|
||||||
|
$this->sessionCookieName = $prefix . 'session';
|
||||||
|
$this->clientCookieName = $prefix . 'client';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function start(): void
|
||||||
|
{
|
||||||
|
if (PHP_SAPI === 'cli') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (session_status() !== PHP_SESSION_NONE) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
session_name($this->sessionCookieName);
|
||||||
|
|
||||||
|
$secure = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off')
|
||||||
|
|| (isset($_SERVER['HTTP_X_FORWARDED_PROTO']) && $_SERVER['HTTP_X_FORWARDED_PROTO'] === 'https');
|
||||||
|
|
||||||
|
session_set_cookie_params([
|
||||||
|
'lifetime' => 0,
|
||||||
|
'path' => '/',
|
||||||
|
'domain' => $this->config->cookieDomain(),
|
||||||
|
'secure' => $secure,
|
||||||
|
'httponly' => true,
|
||||||
|
'samesite' => 'Lax',
|
||||||
|
]);
|
||||||
|
|
||||||
|
session_start();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function ensureClientId(int $lifetimeSeconds = 31536000): string
|
||||||
|
{
|
||||||
|
if (PHP_SAPI === 'cli') {
|
||||||
|
return 'cli';
|
||||||
|
}
|
||||||
|
|
||||||
|
$id = $_COOKIE[$this->clientCookieName] ?? null;
|
||||||
|
if (!is_string($id) || !preg_match('/^[a-f0-9]{64}$/', $id)) {
|
||||||
|
$id = bin2hex(random_bytes(32));
|
||||||
|
|
||||||
|
$secure = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off')
|
||||||
|
|| (isset($_SERVER['HTTP_X_FORWARDED_PROTO']) && $_SERVER['HTTP_X_FORWARDED_PROTO'] === 'https');
|
||||||
|
|
||||||
|
setcookie($this->clientCookieName, $id, [
|
||||||
|
'expires' => time() + $lifetimeSeconds,
|
||||||
|
'path' => '/',
|
||||||
|
'domain' => $this->config->cookieDomain(),
|
||||||
|
'secure' => $secure,
|
||||||
|
'httponly' => false, // accessible to JS if needed
|
||||||
|
'samesite' => 'Lax',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$_COOKIE[$this->clientCookieName] = $id;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $id;
|
||||||
|
}
|
||||||
|
}
|
||||||
87
src/Repository/UserRepository.php
Executable file
87
src/Repository/UserRepository.php
Executable file
@@ -0,0 +1,87 @@
|
|||||||
|
|
||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Repository;
|
||||||
|
|
||||||
|
use PDO;
|
||||||
|
|
||||||
|
final class UserRepository
|
||||||
|
{
|
||||||
|
public function __construct(private PDO $pdo) {}
|
||||||
|
|
||||||
|
public function findByEmail(string $email): ?array
|
||||||
|
{
|
||||||
|
$stmt = $this->pdo->prepare(
|
||||||
|
'SELECT id, email, name, created_at
|
||||||
|
FROM users
|
||||||
|
WHERE email = :email
|
||||||
|
LIMIT 1'
|
||||||
|
);
|
||||||
|
$stmt->execute(['email' => $email]);
|
||||||
|
|
||||||
|
$row = $stmt->fetch();
|
||||||
|
return $row ?: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function create(string $email, string $name): int
|
||||||
|
{
|
||||||
|
// DB-agnostischer INSERT
|
||||||
|
$stmt = $this->pdo->prepare(
|
||||||
|
'INSERT INTO users (email, name, created_at)
|
||||||
|
VALUES (:email, :name, :created_at)'
|
||||||
|
);
|
||||||
|
$stmt->execute([
|
||||||
|
'email' => $email,
|
||||||
|
'name' => $name,
|
||||||
|
'created_at' => date('Y-m-d H:i:s'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// ID-Ermittlung: DB-spezifische Unterschiede gekapselt
|
||||||
|
return $this->lastInsertIdSafe();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function listLatest(int $limit = 10): array
|
||||||
|
{
|
||||||
|
// LIMIT ist bei mysql/pgsql/sqlite gleich
|
||||||
|
$stmt = $this->pdo->prepare(
|
||||||
|
'SELECT id, email, name, created_at
|
||||||
|
FROM users
|
||||||
|
ORDER BY id DESC
|
||||||
|
LIMIT :limit'
|
||||||
|
);
|
||||||
|
|
||||||
|
// SQLite/MySQL/PG verstehen ints hier, aber PDO braucht oft PARAM_INT
|
||||||
|
$stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
|
||||||
|
$stmt->execute();
|
||||||
|
|
||||||
|
return $stmt->fetchAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function driver(): string
|
||||||
|
{
|
||||||
|
return (string)$this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function lastInsertIdSafe(): int
|
||||||
|
{
|
||||||
|
$driver = $this->driver();
|
||||||
|
|
||||||
|
if ($driver === 'pgsql') {
|
||||||
|
// Option A: Sequenzname (wenn du ihn kennst)
|
||||||
|
// Standard wäre oft users_id_seq, kann aber anders heißen.
|
||||||
|
// Wenn du "GENERATED ... AS IDENTITY" nutzt, ist RETURNING meist die bessere Option.
|
||||||
|
$id = $this->pdo->lastInsertId();
|
||||||
|
if ($id !== '') {
|
||||||
|
return (int)$id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: versuche typische Sequenz
|
||||||
|
$id = $this->pdo->lastInsertId('users_id_seq');
|
||||||
|
return (int)$id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// mysql + sqlite
|
||||||
|
return (int)$this->pdo->lastInsertId();
|
||||||
|
}
|
||||||
|
}
|
||||||
103
src/helpers.php
Executable file
103
src/helpers.php
Executable file
@@ -0,0 +1,103 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\App;
|
||||||
|
|
||||||
|
function app(): App
|
||||||
|
{
|
||||||
|
return App::get();
|
||||||
|
}
|
||||||
|
|
||||||
|
function t(string $key, $default = '', array $vars = []): string
|
||||||
|
{
|
||||||
|
return app()->i18n()->get($key, $default, $vars);
|
||||||
|
}
|
||||||
|
|
||||||
|
function tpl(string $file, string $type = 'structure', string $site = 'main'): void
|
||||||
|
{
|
||||||
|
$base = __DIR__ . '/../partials/';
|
||||||
|
|
||||||
|
// very small validation
|
||||||
|
foreach ([$file, $type, $site] as $v) {
|
||||||
|
if (preg_match('/[^a-zA-Z0-9_\-]/', $v)) {
|
||||||
|
echo "<!-- tpl(): invalid parameter -->";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($type === 'landing') {
|
||||||
|
$path = $base . "landing/$site/$file.php";
|
||||||
|
} else {
|
||||||
|
$path = $base . "structure/$file.php";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file_exists($path)) {
|
||||||
|
include $path;
|
||||||
|
} else {
|
||||||
|
echo "<!-- tpl(): not found: $path -->";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function app_primary_domain(): string
|
||||||
|
{
|
||||||
|
if (defined('APP_DOMAIN_PRIMARY')) {
|
||||||
|
return APP_DOMAIN_PRIMARY;
|
||||||
|
}
|
||||||
|
if (defined('APP_DOMAIN_NAME')) {
|
||||||
|
return APP_DOMAIN_NAME;
|
||||||
|
}
|
||||||
|
return $_SERVER['HTTP_HOST'] ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function app_fakecheck_domain(): string
|
||||||
|
{
|
||||||
|
if (defined('APP_DOMAIN_FAKECHECK')) {
|
||||||
|
return APP_DOMAIN_FAKECHECK;
|
||||||
|
}
|
||||||
|
return app_primary_domain();
|
||||||
|
}
|
||||||
|
|
||||||
|
function asset_styles(): void
|
||||||
|
{
|
||||||
|
$styles = app()->assets()->styles();
|
||||||
|
|
||||||
|
// simple priority order
|
||||||
|
$order = ['early' => 0, 'normal' => 1, 'late' => 2];
|
||||||
|
usort($styles, fn($a,$b) => ($order[$a['priority']] ?? 1) <=> ($order[$b['priority']] ?? 1));
|
||||||
|
|
||||||
|
foreach ($styles as $s) {
|
||||||
|
$href = $s['href'];
|
||||||
|
$v = $s['version'];
|
||||||
|
if ($v !== null && $v !== '') {
|
||||||
|
$sep = (str_contains($href, '?') ? '&' : '?');
|
||||||
|
$href = $href . $sep . 'v=' . rawurlencode((string)$v);
|
||||||
|
}
|
||||||
|
echo '<link rel="stylesheet" href="' . htmlspecialchars($href, ENT_QUOTES) . '">' . "\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function asset_scripts(string $pos = 'footer'): void
|
||||||
|
{
|
||||||
|
$scripts = ($pos === 'header') ? app()->assets()->headerScripts() : app()->assets()->footerScripts();
|
||||||
|
|
||||||
|
foreach ($scripts as $s) {
|
||||||
|
$src = $s['src'];
|
||||||
|
$v = $s['version'];
|
||||||
|
if ($v !== null && $v !== '') {
|
||||||
|
$sep = (str_contains($src, '?') ? '&' : '?');
|
||||||
|
$src = $src . $sep . 'v=' . rawurlencode((string)$v);
|
||||||
|
}
|
||||||
|
|
||||||
|
$attrs = '';
|
||||||
|
if (!empty($s['defer'])) $attrs .= ' defer';
|
||||||
|
if (!empty($s['async'])) $attrs .= ' async';
|
||||||
|
|
||||||
|
echo '<script src="' . htmlspecialchars($src, ENT_QUOTES) . '"' . $attrs . '></script>' . "\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function redirect(string $path): void
|
||||||
|
{
|
||||||
|
header('Location: ' . $path, true, 303);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
0
tools/.gitkeep
Executable file
0
tools/.gitkeep
Executable file
Reference in New Issue
Block a user