New version

This commit is contained in:
2026-01-24 01:42:46 +01:00
parent 6063ae4193
commit f3f24cebba
68 changed files with 3136 additions and 407 deletions

View File

@@ -13,11 +13,11 @@ Die App ist so aufgebaut, dass UI und Datenzugriff getrennt sind. Je nach Reimpo
- Einstiegspunkt (z. B. `public/index.php`): Lädt eine Seite/Ansicht, die das Layout und die Matrix enthält.
- Matrix-Ansicht (z. B. `partials/landing/main/material-matrix.php`): Enthält das Markup + JS, das die Daten lädt und die Tabelle rendert.
- API-Endpoints (z. B. `public/api/*`): Stellen JSON bereit für
- API-Endpoints (z. B. `/api/*` über das Routing): Stellen JSON bereit für
- Drucker-Liste
- Material-Liste
- Drucker-spezifische Material-Kompatibilität
- DB-Zugriff (z. B. `tools/db.php`): Baut eine DB-Verbindung und wird von den API-Endpunkten genutzt.
- DB-Zugriff (z. B. `src/App/Database.php`): Baut eine DB-Verbindung und wird von den API-Endpunkten genutzt.
Aktuelle DB-Struktur (Schema-Orientierung)
Das Schema besteht aus drei Kern-Tabellen, die für die Materialmatrix benötigt werden:
@@ -47,6 +47,7 @@ Hinweise für Reimport/Neuaufbau
- `printer`: Drucker-Datensatz
- `materials`: Liste der Materialien mit optionalem `support_level` + Zusatzinfos.
- Die Datenbank kann migriert werden, solange Material-, Drucker- und Zuordnungsdaten semantisch erhalten bleiben.
- Für DB-Zugriff muss `APP_DB_ENABLED` aktiviert sein; die Zugangsdaten sollten per ENV-Variablen kommen.
Sicherheit
- Zugangsdaten sollten nicht im Repo liegen. Nutze ENV-Variablen oder separate Configs pro Environment.

View File

@@ -1 +0,0 @@

View File

@@ -1 +0,0 @@

View File

@@ -1 +0,0 @@
<?php // TODO

View File

@@ -1,6 +1,67 @@
<?php
$env = getenv('APP_ENV') ?: 'staging';
$env = strtolower($env);
$path = __DIR__ . 'db.php';
declare(strict_types=1);
return require $path;
// Basic error reporting (keep strict in dev)
ini_set('display_errors', '1');
ini_set('display_startup_errors', '1');
error_reporting(E_ALL);
// Determine APP_ENV consistently:
// 1) Prefer environment variable.
// 2) If not set, try root config files to define APP_ENV.
$appEnvFromEnv = getenv('APP_ENV');
if (!$appEnvFromEnv) {
$rootDomain = __DIR__ . '/domaindata.php';
$rootSettings = __DIR__ . '/settings.php';
if (file_exists($rootDomain)) {
require_once $rootDomain;
}
if (file_exists($rootSettings)) {
require_once $rootSettings;
}
}
$appEnv = $appEnvFromEnv ?: (defined('APP_ENV') ? (string) APP_ENV : 'prod');
$envDir = rtrim(__DIR__, '/\\') . '/' . $appEnv;
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
}

View File

@@ -1 +1,41 @@
<?php // TODO
<?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;

View File

@@ -1 +0,0 @@

View File

@@ -1 +1,94 @@
<?php // TODO
<?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);
}

View File

@@ -1 +1,12 @@
<?php // TODO
<?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', 'shape3d.it');
}
if (!defined('APP_PREFIX')) {
define('APP_PREFIX', 'miniapp');
}

View File

@@ -1 +0,0 @@
<?php // TODO

View File

@@ -1 +1,8 @@
<?php // TODO
<?php
define('APP_ENV', 'prod');
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', true); // set true to enable DB connection

View File

@@ -1,8 +1,93 @@
<?php
return [
'db_host' => 'localhost',
'db_name' => 'd0453540',
'db_user' => 'd0453540',
'db_pass' => 'P6jGRrSaX8QSiBMEJBL7',
'db_charset' => 'utf8mb4',
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 = 'pqsql';
$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);
}

View File

@@ -1 +1,12 @@
<?php // TODO
<?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', 'staging.shape3d.it');
}
if (!defined('APP_PREFIX')) {
define('APP_PREFIX', 'miniapp');
}

View File

@@ -1 +0,0 @@
<?php // TODO

View File

@@ -1 +1,8 @@
<?php // TODO
<?php
define('APP_ENV', 'staging');
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', true); // set true to enable DB connection

View File

@@ -1 +0,0 @@
Demo männ

View File

@@ -1 +0,0 @@

View File

@@ -1 +0,0 @@

View File

@@ -1,237 +1,69 @@
<div class="max-w-6xl mx-auto py-6 space-y-6 px-4">
<header class="flex items-center justify-between gap-4">
<?php
$app = app();
$app->assets()->addStyle('/assets/app.css', 'early');
$app->assets()->addScript('/assets/app.js', 'footer', true);
?>
<section class="mm-shell">
<header class="mm-header">
<div>
<h1 class="text-2xl font-bold tracking-tight text-slate-900">3D-Druck Materialmatrix</h1>
<p class="text-xs text-slate-500">Schnell prüfen, welche Filamente auf welchen Druckern laufen.</p>
<p class="mm-kicker">Materialmatrix</p>
<h1 class="mm-title"><?= htmlspecialchars(t('common.title'), ENT_QUOTES) ?></h1>
<p class="mm-subtitle">Schnell prüfen, welche Filamente auf welchen Druckern laufen.</p>
</div>
<span id="status" class="text-xs text-slate-500"></span>
<div class="mm-status" id="status">Bereit</div>
</header>
<div class="bg-white/80 backdrop-blur rounded-lg shadow flex gap-6 p-4 min-h-[420px]">
<!-- Sidebar -->
<aside class="w-72 space-y-5 border-r pr-4 border-slate-100">
<div>
<h2 class="text-sm font-semibold text-slate-700 mb-2">Drucker auswählen</h2>
<label class="block text-xs font-medium mb-1 text-slate-600">Einzelansicht</label>
<select id="printerSelect" class="w-full border rounded px-2 py-1 mb-2 text-sm">
<div class="mm-card">
<aside class="mm-sidebar">
<div class="mm-panel">
<h2>Drucker auswählen</h2>
<label for="printerSelect">Einzelansicht</label>
<select id="printerSelect">
<option value=""> wird geladen </option>
</select>
<p class="text-xs text-slate-500">
Zeigt die Kompatibilität nur für diesen Drucker.
</p>
<p>Zeigt die Kompatibilität nur für diesen Drucker.</p>
</div>
<div>
<label class="block text-xs font-medium mb-1 text-slate-600">Vergleich (mehrere)</label>
<select id="printerCompare" multiple class="w-full border rounded px-2 py-1 h-32 text-sm"></select>
<p class="text-xs text-slate-500">
Strg/ gedrückt halten, um mehrere zu wählen.
</p>
<div class="mm-panel">
<label for="printerCompare">Vergleich (mehrere)</label>
<select id="printerCompare" multiple></select>
<p>Strg/⌘ gedrückt halten, um mehrere zu wählen.</p>
</div>
<div class="text-xs text-slate-400">
Tipp: Im Vergleich werden die ausgewählten Drucker rechts als separate Spalten eingefärbt.
<div class="mm-tip">
Tipp: Im Vergleich werden die ausgewählten Drucker rechts als separate Spalten hervorgehoben.
</div>
</aside>
<!-- Main -->
<main class="flex-1 flex flex-col gap-3">
<div class="overflow-auto max-h-[70vh] rounded border bg-white" id="tableWrap">
<table class="min-w-full text-sm" id="matTable">
<thead class="bg-slate-50">
<main class="mm-main">
<div class="mm-table-wrap" id="tableWrap">
<table id="matTable" class="mm-table">
<thead>
<tr id="tableHead">
<th class="px-3 py-2 text-left">Material</th>
<th class="px-3 py-2 text-left">Eigenschaften</th>
<th class="px-3 py-2 text-left">Tg °C</th>
<th class="px-3 py-2 text-left">Düse</th>
<th class="px-3 py-2 text-left">Platte</th>
<th class="px-3 py-2 text-left">Zusatz</th>
<th class="px-3 py-2 text-left">Anwendung</th>
<th class="px-3 py-2 text-left">Kinder</th>
<th class="px-3 py-2 text-left">Emission</th>
<th>Material</th>
<th>Eigenschaften</th>
<th>Tg °C</th>
<th>Düse</th>
<th>Platte</th>
<th>Zusatz</th>
<th>Anwendung</th>
<th>Kinder</th>
<th>Emission</th>
</tr>
</thead>
<tbody id="matBody"></tbody>
</table>
</div>
<div id="errorBox" class="hidden rounded bg-rose-50 border border-rose-200 text-rose-700 text-sm px-3 py-2"></div>
<div id="errorBox" class="mm-error" hidden></div>
<!-- Hinweisblock unten -->
<section id="disclaimer" class="mt-2 rounded-lg border border-slate-200 bg-slate-50 p-4 text-xs text-slate-700 leading-snug">
<p>
<strong>Hinweis:</strong> Dieses Projekt wird privat betrieben und befindet sich im Aufbau.
<section class="mm-disclaimer">
<p><strong>Hinweis:</strong> Dieses Projekt wird privat betrieben und befindet sich im Aufbau.
Es sind noch nicht alle Drucker und Materialien eingetragen.
Alle Angaben erfolgen nach bestem Wissen, jedoch <u>ohne Gewähr auf Vollständigkeit oder Richtigkeit</u>.
</p>
</section>
</main>
</div>
</div>
<script>
const API_BASE = './api';
const printerSelect = document.getElementById('printerSelect');
const printerCompare = document.getElementById('printerCompare');
const matBody = document.getElementById('matBody');
const tableHead = document.getElementById('tableHead');
const statusEl = document.getElementById('status');
const errorBox = document.getElementById('errorBox');
function showError(msg) {
errorBox.textContent = msg;
errorBox.classList.remove('hidden');
}
function clearError() {
errorBox.classList.add('hidden');
}
async function fetchJSON(url) {
const res = await fetch(url);
if (!res.ok) throw new Error('HTTP ' + res.status + ' for ' + url);
return await res.json();
}
async function loadPrinters() {
try {
statusEl.textContent = 'Lade Drucker …';
const data = await fetchJSON(`${API_BASE}/printers.php`);
printerSelect.innerHTML = '';
printerCompare.innerHTML = '';
if (!data.length) {
printerSelect.innerHTML = '<option value="">(keine Drucker gefunden)</option>';
statusEl.textContent = 'Keine Drucker gefunden';
return;
}
data.forEach(p => {
printerSelect.appendChild(new Option(p.name, p.id));
printerCompare.appendChild(new Option(p.name, p.id));
});
// ersten Drucker anzeigen
loadSinglePrinter(data[0].id);
statusEl.textContent = 'Drucker geladen';
} catch (err) {
console.error(err);
showError('Konnte Drucker nicht laden. Prüfe public/api/printers.php.');
}
}
async function loadSinglePrinter(id) {
if (!id) return;
clearError();
statusEl.textContent = 'Lade Materialien …';
try {
const data = await fetchJSON(`${API_BASE}/printer-materials.php?id=${id}`);
renderTable([data]);
statusEl.textContent = 'Fertig';
} catch (err) {
console.error(err);
showError('Konnte Materialien für Drucker nicht laden.');
statusEl.textContent = 'Fehler';
}
}
async function loadMultiplePrinters(ids) {
if (!ids.length) return;
clearError();
statusEl.textContent = 'Lade Vergleich …';
try {
const datasets = await Promise.all(ids.map(id => fetchJSON(`${API_BASE}/printer-materials.php?id=${id}`)));
renderTable(datasets);
statusEl.textContent = 'Vergleich geladen';
} catch (err) {
console.error(err);
showError('Konnte einen der gewählten Drucker nicht laden.');
statusEl.textContent = 'Fehler';
}
}
function renderTable(datasets) {
// Kopf neu aufbauen
const baseHead = `
<th class="px-3 py-2 text-left">Material</th>
<th class="px-3 py-2 text-left">Eigenschaften</th>
<th class="px-3 py-2 text-left">Tg °C</th>
<th class="px-3 py-2 text-left">Düse</th>
<th class="px-3 py-2 text-left">Platte</th>
<th class="px-3 py-2 text-left">Zusatz</th>
<th class="px-3 py-2 text-left">Anwendung</th>
<th class="px-3 py-2 text-left">Kinder</th>
<th class="px-3 py-2 text-left">Emission</th>
`;
let printerCols = '';
datasets.forEach(ds => {
printerCols += `<th class="px-3 py-2 text-left bg-slate-100" data-printer="${ds.printer.id}">${ds.printer.name}</th>`;
});
tableHead.innerHTML = baseHead + printerCols;
const materials = datasets[0]?.materials ?? [];
matBody.innerHTML = '';
materials.forEach((m, idx) => {
const tr = document.createElement('tr');
tr.className = idx % 2 === 0 ? 'bg-white' : 'bg-slate-50/60';
const kid = m.kid_safety === 'safe' ? '🌿' : (m.kid_safety === 'limited' ? '🟡' : '🔴');
const em = m.emission === 'low' ? '✅' : (m.emission === 'medium' ? '⚠️' : '⛔');
let html = `
<td class="px-3 py-2 font-medium">${m.code}<div class="text-xs text-slate-500">${m.short_desc ?? ''}</div></td>
<td class="px-3 py-2">${m.properties ?? ''}</td>
<td class="px-3 py-2">${m.tg_celsius ?? ''}</td>
<td class="px-3 py-2">${m.nozzle_req ?? ''}</td>
<td class="px-3 py-2">${m.plate_req ?? ''}</td>
<td class="px-3 py-2">${m.extra_req ?? ''}</td>
<td class="px-3 py-2">${m.application ?? ''}</td>
<td class="px-3 py-2">${kid}</td>
<td class="px-3 py-2">${em}</td>
`;
// Drucker-Spalten
datasets.forEach(ds => {
const match = ds.materials.find(x => x.id === m.id || x.code === m.code);
if (!match || !match.support_level) {
html += `<td class="px-3 py-2" data-printer="${ds.printer.id}"><span class="px-2 py-1 rounded bg-slate-200 text-xs">unbekannt</span></td>`;
} else {
let badge = '';
if (match.support_level === 'full') {
badge = '<span class="px-2 py-1 rounded bg-green-100 text-green-800 text-xs">✓ voll</span>';
} else if (match.support_level === 'partial') {
badge = '<span class="px-2 py-1 rounded bg-amber-100 text-amber-800 text-xs">⚠ teilw.</span>';
} else if (match.support_level === 'with_addon') {
badge = '<span class="px-2 py-1 rounded bg-sky-100 text-sky-800 text-xs">⚙ Zusatz</span>';
} else {
badge = '<span class="px-2 py-1 rounded bg-rose-100 text-rose-800 text-xs">✗ nein</span>';
}
const note = match.partial_reason
? `<div class="text-xs text-slate-400">${match.partial_reason}</div>`
: (match.extra_info ? `<div class="text-xs text-slate-400">${match.extra_info}</div>` : '');
html += `<td class="px-3 py-2" data-printer="${ds.printer.id}">${badge}${note}</td>`;
}
});
tr.innerHTML = html;
matBody.appendChild(tr);
});
}
// Events
printerSelect.addEventListener('change', e => {
const id = e.target.value;
if (id) {
loadSinglePrinter(id);
// Vergleich leeren
printerCompare.selectedIndex = -1;
}
});
printerCompare.addEventListener('change', e => {
const ids = Array.from(e.target.selectedOptions).map(o => o.value);
if (ids.length) {
loadMultiplePrinters(ids);
}
});
// Start
loadPrinters();
</script>
</section>

View File

@@ -1,9 +1,3 @@
<?php
// Basic layout end.
?>
<footer class="mt-10 border-t border-slate-200 py-6 text-center text-xs text-slate-500">
3D-Druck Materialmatrix · intern
</footer>
<?php require __DIR__ . '/matomo.php'; ?>
<?php asset_scripts('footer'); ?>
</body>
</html>

View File

@@ -1,29 +1,14 @@
<?php
// Basic layout start.
?><!doctype html>
/** @var \App\App $app */
$app = app();
?>
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<title>3D-Druck Materialmatrix</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<script src="https://cdn.tailwindcss.com"></script>
<style>
body {
background: radial-gradient(circle at top, #e2e8f0 0%, #f8fafc 45%, #e2e8f0 90%);
}
thead th {
position: sticky;
top: 0;
backdrop-filter: blur(4px);
}
th[data-printer],
td[data-printer] {
background: rgba(148, 163, 184, 0.05);
}
#disclaimer {
box-shadow: inset 0 1px 0 rgba(148, 163, 184, 0.25);
}
</style>
<title><?= htmlspecialchars(t('common.title'), ENT_QUOTES) ?></title>
<?php asset_styles(); ?>
<?php asset_scripts('header'); ?>
</head>
<body class="bg-slate-100 min-h-screen">
<?php require __DIR__ . '/nav.php'; ?>
<body class="app-body">

View File

@@ -1,2 +0,0 @@
<?php
// Matomo tracking can be added here if needed.

View File

@@ -1,11 +0,0 @@
<?php
$activePage = $activePage ?? '';
?>
<header class="bg-white/80 backdrop-blur border-b border-slate-200">
<div class="max-w-6xl mx-auto px-4 py-3 flex items-center justify-between">
<div class="text-sm font-semibold text-slate-800">3D-Druck Materialmatrix</div>
<nav class="text-xs text-slate-500">
<span class="<?= $activePage === 'dashboard' ? 'text-slate-900 font-semibold' : '' ?>">Dashboard</span>
</nav>
</div>
</header>

View File

@@ -1 +1,32 @@
# TODO
# -------------------------------------------------
# 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>

View File

@@ -1,14 +0,0 @@
<?php
try {
require_once __DIR__ . '/../../tools/db.php';
$pdo = tools_build_pdo();
} catch (Throwable $e) {
http_response_code(500);
header('Content-Type: application/json; charset=utf-8');
$payload = ['error' => 'DB connection failed'];
if (getenv('APP_DEBUG') === '1') {
$payload['detail'] = $e->getMessage();
}
echo json_encode($payload, JSON_UNESCAPED_UNICODE);
exit;
}

View File

@@ -1,6 +0,0 @@
<?php
// public/api/materials.php
header('Content-Type: application/json; charset=utf-8');
require __DIR__ . '/_db.php';
$stmt = $pdo->query("SELECT * FROM materials WHERE is_active = 1 ORDER BY code");
echo json_encode($stmt->fetchAll(), JSON_UNESCAPED_UNICODE);

View File

@@ -1,35 +0,0 @@
<?php
// public/api/printer-materials.php?id={printer_id}
header('Content-Type: application/json; charset=utf-8');
require __DIR__ . '/_db.php';
$printer_id = isset($_GET['id']) ? (int)$_GET['id'] : 0;
if ($printer_id <= 0) {
http_response_code(400);
echo json_encode(['error' => 'printer id missing']);
exit;
}
$printerStmt = $pdo->prepare("SELECT * FROM printers WHERE id = ?");
$printerStmt->execute([$printer_id]);
$printer = $printerStmt->fetch();
if (!$printer) {
http_response_code(404);
echo json_encode(['error' => 'printer not found']);
exit;
}
$sql = "SELECT m.*, pms.support_level, pms.partial_reason, pms.extra_info
FROM materials m
LEFT JOIN printer_material_support pms
ON pms.material_id = m.id AND pms.printer_id = :pid
WHERE m.is_active = 1
ORDER BY m.code";
$stmt = $pdo->prepare($sql);
$stmt->execute([':pid' => $printer_id]);
$materials = $stmt->fetchAll();
echo json_encode([
'printer' => $printer,
'materials' => $materials
], JSON_UNESCAPED_UNICODE);

View File

@@ -1,7 +0,0 @@
<?php
// public/api/printers.php
header('Content-Type: application/json; charset=utf-8');
require __DIR__ . '/_db.php';
require_once __DIR__ . '/../../tools/printers.php';
$printers = tools_fetch_active_printers($pdo);
echo json_encode($printers, JSON_UNESCAPED_UNICODE);

View File

@@ -1 +0,0 @@

235
public/assets/app.css Normal file
View File

@@ -0,0 +1,235 @@
@import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500&display=swap');
:root {
--bg: #f5efe6;
--bg-accent: #eef1f8;
--ink: #1d1d1f;
--muted: #5b5b64;
--card: #ffffff;
--line: #e2e2e8;
--accent: #ffb454;
--accent-dark: #eb7b1c;
--ok: #1f8a4c;
--warn: #d97706;
--no: #b91c1c;
--shadow: 0 22px 60px rgba(20, 20, 45, 0.08);
}
* { box-sizing: border-box; }
.app-body {
margin: 0;
font-family: 'Space Grotesk', system-ui, sans-serif;
color: var(--ink);
background: radial-gradient(circle at 20% 10%, rgba(255, 210, 150, 0.35), transparent 55%),
radial-gradient(circle at 90% 5%, rgba(185, 221, 255, 0.35), transparent 60%),
var(--bg);
min-height: 100vh;
}
.mm-shell {
max-width: 1200px;
margin: 0 auto;
padding: 3rem 2rem 4rem;
animation: fadeUp 0.6s ease both;
}
.mm-header {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 2rem;
margin-bottom: 2rem;
}
.mm-kicker {
text-transform: uppercase;
letter-spacing: 0.2em;
font-size: 0.7rem;
color: var(--muted);
margin: 0 0 0.6rem 0;
}
.mm-title {
font-size: clamp(2rem, 2.6vw, 2.8rem);
margin: 0;
}
.mm-subtitle {
margin: 0.5rem 0 0;
color: var(--muted);
max-width: 36rem;
}
.mm-status {
font-family: 'IBM Plex Mono', ui-monospace, monospace;
font-size: 0.85rem;
padding: 0.4rem 0.8rem;
border-radius: 999px;
border: 1px solid var(--line);
background: var(--card);
}
.mm-card {
display: grid;
grid-template-columns: 280px 1fr;
gap: 1.5rem;
background: var(--card);
border: 1px solid var(--line);
border-radius: 24px;
padding: 1.75rem;
box-shadow: var(--shadow);
}
.mm-sidebar {
display: flex;
flex-direction: column;
gap: 1.5rem;
border-right: 1px dashed var(--line);
padding-right: 1.5rem;
}
.mm-panel h2 {
font-size: 1rem;
margin: 0 0 0.5rem 0;
}
.mm-panel label {
display: block;
font-weight: 600;
font-size: 0.85rem;
margin-bottom: 0.4rem;
color: var(--muted);
}
.mm-panel select {
width: 100%;
border: 1px solid var(--line);
border-radius: 12px;
padding: 0.55rem 0.75rem;
font-size: 0.95rem;
background: #fff;
}
.mm-panel p {
margin: 0.5rem 0 0;
color: var(--muted);
font-size: 0.85rem;
}
.mm-tip {
font-size: 0.8rem;
color: var(--muted);
padding: 0.75rem;
border-radius: 16px;
background: var(--bg-accent);
}
.mm-main {
display: flex;
flex-direction: column;
gap: 1rem;
}
.mm-table-wrap {
overflow: auto;
border-radius: 16px;
border: 1px solid var(--line);
background: #fff;
max-height: 70vh;
}
.mm-table {
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
}
.mm-table thead th {
position: sticky;
top: 0;
background: #f8f8fb;
padding: 0.75rem;
text-align: left;
font-weight: 600;
border-bottom: 1px solid var(--line);
}
.mm-table tbody td {
padding: 0.7rem 0.75rem;
border-bottom: 1px solid #f0f0f4;
vertical-align: top;
}
.mm-table tbody tr:nth-child(even) {
background: #fcfcff;
}
.mm-table tbody tr.is-alt {
background: #fcfcff;
}
.mm-table [data-printer] {
background: rgba(248, 247, 255, 0.8);
}
.mm-tag {
display: inline-flex;
align-items: center;
gap: 0.4rem;
border-radius: 999px;
padding: 0.25rem 0.6rem;
font-size: 0.75rem;
font-weight: 600;
}
.mm-tag.ok { background: rgba(31, 138, 76, 0.12); color: var(--ok); }
.mm-tag.warn { background: rgba(217, 119, 6, 0.12); color: var(--warn); }
.mm-tag.no { background: rgba(185, 28, 28, 0.12); color: var(--no); }
.mm-tag.addon { background: rgba(59, 130, 246, 0.12); color: #1d4ed8; }
.mm-sub {
font-size: 0.75rem;
color: var(--muted);
margin-top: 0.25rem;
}
.mm-error {
padding: 0.75rem 1rem;
border-radius: 12px;
border: 1px solid rgba(185, 28, 28, 0.25);
background: rgba(185, 28, 28, 0.08);
color: var(--no);
font-size: 0.9rem;
}
.mm-disclaimer {
background: #f9fafc;
border: 1px solid var(--line);
border-radius: 16px;
padding: 1rem 1.25rem;
font-size: 0.85rem;
color: var(--muted);
}
@keyframes fadeUp {
from { opacity: 0; transform: translateY(12px); }
to { opacity: 1; transform: translateY(0); }
}
@media (max-width: 980px) {
.mm-card {
grid-template-columns: 1fr;
}
.mm-sidebar {
border-right: none;
border-bottom: 1px dashed var(--line);
padding-right: 0;
padding-bottom: 1rem;
}
}
@media (max-width: 720px) {
.mm-shell { padding: 2rem 1.25rem 3rem; }
.mm-header { flex-direction: column; align-items: flex-start; }
}

200
public/assets/app.js Normal file
View File

@@ -0,0 +1,200 @@
(function () {
const API_BASE = '/api';
const printerSelect = document.getElementById('printerSelect');
const printerCompare = document.getElementById('printerCompare');
const matBody = document.getElementById('matBody');
const tableHead = document.getElementById('tableHead');
const statusEl = document.getElementById('status');
const errorBox = document.getElementById('errorBox');
if (!printerSelect || !printerCompare || !matBody || !tableHead) {
return;
}
function setStatus(text) {
if (statusEl) {
statusEl.textContent = text;
}
}
function showError(msg) {
if (!errorBox) return;
errorBox.hidden = false;
errorBox.textContent = msg;
}
function clearError() {
if (!errorBox) return;
errorBox.hidden = true;
errorBox.textContent = '';
}
function escapeHtml(value) {
return String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
async function fetchJSON(path) {
const res = await fetch(API_BASE + path, { headers: { 'Accept': 'application/json' } });
if (!res.ok) throw new Error('HTTP ' + res.status + ' for ' + path);
return await res.json();
}
async function loadPrinters() {
try {
setStatus('Lade Drucker ...');
const data = await fetchJSON('/printers');
printerSelect.innerHTML = '';
printerCompare.innerHTML = '';
if (!Array.isArray(data) || data.length === 0) {
printerSelect.innerHTML = '<option value="">(keine Drucker gefunden)</option>';
setStatus('Keine Drucker gefunden');
return;
}
data.forEach(p => {
printerSelect.appendChild(new Option(p.name, p.id));
printerCompare.appendChild(new Option(p.name, p.id));
});
loadSinglePrinter(data[0].id);
setStatus('Drucker geladen');
} catch (err) {
console.error(err);
showError('Konnte Drucker nicht laden.');
setStatus('Fehler');
}
}
async function loadSinglePrinter(id) {
if (!id) return;
clearError();
setStatus('Lade Materialien ...');
try {
const data = await fetchJSON('/printer-materials?id=' + encodeURIComponent(id));
renderTable([data]);
setStatus('Fertig');
} catch (err) {
console.error(err);
showError('Konnte Materialien für den Drucker nicht laden.');
setStatus('Fehler');
}
}
async function loadMultiplePrinters(ids) {
if (!ids.length) return;
clearError();
setStatus('Lade Vergleich ...');
try {
const datasets = await Promise.all(
ids.map(id => fetchJSON('/printer-materials?id=' + encodeURIComponent(id)))
);
renderTable(datasets);
setStatus('Vergleich geladen');
} catch (err) {
console.error(err);
showError('Konnte einen der gewählten Drucker nicht laden.');
setStatus('Fehler');
}
}
function renderTable(datasets) {
const baseHead = [
'Material',
'Eigenschaften',
'Tg °C',
'Düse',
'Platte',
'Zusatz',
'Anwendung',
'Kinder',
'Emission'
].map(label => `<th>${label}</th>`).join('');
let printerCols = '';
datasets.forEach(ds => {
if (ds && ds.printer) {
printerCols += `<th data-printer="${escapeHtml(ds.printer.id)}">${escapeHtml(ds.printer.name)}</th>`;
}
});
tableHead.innerHTML = baseHead + printerCols;
const materials = (datasets[0] && datasets[0].materials) ? datasets[0].materials : [];
matBody.innerHTML = '';
if (!materials.length) {
const empty = document.createElement('tr');
empty.innerHTML = '<td colspan="12">Keine Materialien gefunden.</td>';
matBody.appendChild(empty);
return;
}
materials.forEach((m, idx) => {
const tr = document.createElement('tr');
tr.className = idx % 2 === 0 ? '' : 'is-alt';
const kid = m.kid_safety === 'safe' ? 'grün' : (m.kid_safety === 'limited' ? 'gelb' : 'rot');
const em = m.emission === 'low' ? 'niedrig' : (m.emission === 'medium' ? 'mittel' : 'hoch');
let html = '';
html += `<td><strong>${escapeHtml(m.code)}</strong><div class="mm-sub">${escapeHtml(m.short_desc || '')}</div></td>`;
html += `<td>${escapeHtml(m.properties || '')}</td>`;
html += `<td>${escapeHtml(m.tg_celsius || '')}</td>`;
html += `<td>${escapeHtml(m.nozzle_req || '')}</td>`;
html += `<td>${escapeHtml(m.plate_req || '')}</td>`;
html += `<td>${escapeHtml(m.extra_req || '')}</td>`;
html += `<td>${escapeHtml(m.application || '')}</td>`;
html += `<td>${escapeHtml(kid)}</td>`;
html += `<td>${escapeHtml(em)}</td>`;
datasets.forEach(ds => {
const printerId = ds && ds.printer ? ds.printer.id : '';
const match = ds.materials.find(x => x.id === m.id || x.code === m.code);
if (!match || !match.support_level) {
html += `<td data-printer="${escapeHtml(printerId)}"><span class="mm-tag">unbekannt</span></td>`;
} else {
let badge = '';
if (match.support_level === 'full') {
badge = '<span class="mm-tag ok">voll</span>';
} else if (match.support_level === 'partial') {
badge = '<span class="mm-tag warn">teilw.</span>';
} else if (match.support_level === 'with_addon') {
badge = '<span class="mm-tag addon">Zusatz</span>';
} else {
badge = '<span class="mm-tag no">nein</span>';
}
const note = match.partial_reason
? `<div class="mm-sub">${escapeHtml(match.partial_reason)}</div>`
: (match.extra_info ? `<div class="mm-sub">${escapeHtml(match.extra_info)}</div>` : '');
html += `<td data-printer="${escapeHtml(printerId)}">${badge}${note}</td>`;
}
});
tr.innerHTML = html;
matBody.appendChild(tr);
});
}
printerSelect.addEventListener('change', e => {
const id = e.target.value;
if (id) {
loadSinglePrinter(id);
printerCompare.selectedIndex = -1;
}
});
printerCompare.addEventListener('change', e => {
const ids = Array.from(e.target.selectedOptions).map(o => o.value);
if (ids.length) {
loadMultiplePrinters(ids);
}
});
loadPrinters();
})();

View File

@@ -1 +0,0 @@

View File

@@ -1 +0,0 @@

View File

@@ -1 +0,0 @@

View File

@@ -1 +0,0 @@

View File

@@ -1,2 +1,88 @@
<?php
require __DIR__ . '/page/dashboard.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');
}

7
public/page/404.php Normal file
View File

@@ -0,0 +1,7 @@
<?php
http_response_code(404);
?>
<section class="mm-shell">
<h1 class="mm-title">Seite nicht gefunden</h1>
<p class="mm-subtitle">Die angeforderte Seite existiert nicht.</p>
</section>

View File

@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
$app = app();
$pdo = $app->pdo();
if (!$pdo) {
http_response_code(500);
echo json_encode(['error' => 'DB disabled or unavailable'], JSON_UNESCAPED_UNICODE);
exit;
}
$repo = new \App\Repository\MaterialMatrixRepository($pdo);
$materials = $repo->listActiveMaterials();
echo json_encode($materials, JSON_UNESCAPED_UNICODE);
exit;

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
$printerId = isset($_GET['id']) ? (int)$_GET['id'] : 0;
if ($printerId <= 0) {
http_response_code(400);
echo json_encode(['error' => 'printer id missing'], JSON_UNESCAPED_UNICODE);
exit;
}
$app = app();
$pdo = $app->pdo();
if (!$pdo) {
http_response_code(500);
echo json_encode(['error' => 'DB disabled or unavailable'], JSON_UNESCAPED_UNICODE);
exit;
}
$repo = new \App\Repository\MaterialMatrixRepository($pdo);
$printer = $repo->getPrinterById($printerId);
if (!$printer) {
http_response_code(404);
echo json_encode(['error' => 'printer not found'], JSON_UNESCAPED_UNICODE);
exit;
}
$materials = $repo->listMaterialsForPrinter($printerId);
echo json_encode([
'printer' => $printer,
'materials' => $materials,
], JSON_UNESCAPED_UNICODE);
exit;

View File

@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
$app = app();
$pdo = $app->pdo();
if (!$pdo) {
http_response_code(500);
echo json_encode(['error' => 'DB disabled or unavailable'], JSON_UNESCAPED_UNICODE);
exit;
}
$repo = new \App\Repository\MaterialMatrixRepository($pdo);
$printers = $repo->listActivePrinters();
echo json_encode($printers, JSON_UNESCAPED_UNICODE);
exit;

View File

@@ -1,5 +0,0 @@
<?php
$activePage = 'dashboard';
require __DIR__ . '/../../partials/structure/layout_start.php';
require __DIR__ . '/../../partials/landing/main/material-matrix.php';
require __DIR__ . '/../../partials/structure/layout_end.php';

2
public/page/index.php Normal file
View File

@@ -0,0 +1,2 @@
<?php
tpl('material-matrix', 'landing', 'main');

View File

@@ -1 +0,0 @@
dfdfassa

View File

@@ -1 +0,0 @@

View File

@@ -1 +1,398 @@
<?php // TODO
<?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];
}
}

View File

@@ -1 +1,50 @@
<?php // TODO
<?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, 'de');
$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; }
}

View File

@@ -1 +1,44 @@
<?php // TODO
<?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; }
}

View File

@@ -1 +1,217 @@
<?php // TODO
<?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];
}
}

View File

@@ -1 +1,196 @@
<?php // TODO
<?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'] ?? ''];
}
}

View File

@@ -1 +1,62 @@
<?php // TODO
<?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, '.');
}
}

View File

@@ -1 +1,68 @@
<?php // TODO
<?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 '';
}
}
}

View File

@@ -1 +1,123 @@
<?php // TODO
<?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;
}
}

View File

@@ -1 +1,33 @@
<?php // TODO
<?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'] ?? ''),
];
}
}

View File

@@ -1 +1,59 @@
<?php // TODO
<?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' => '3D-Druck Materialmatrix',
'intro' => 'Schnell prüfen, welche Filamente auf welchen Druckern laufen.',
],
'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;
}
}

View File

@@ -1 +1,352 @@
<?php // TODO
<?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'] ?? '3D-Druck Materialmatrix',
'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 = '3D-Druck Materialmatrix';
$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'] ?? '3D-Druck Materialmatrix';
$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') ?: '3D-Druck Materialmatrix';
$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]);
}
}

View File

@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use PDO;
final class MaterialMatrixRepository
{
public function __construct(private PDO $pdo) {}
public function listActivePrinters(): array
{
$stmt = $this->pdo->query('SELECT * FROM printers WHERE is_active = 1 ORDER BY name');
return $stmt->fetchAll();
}
public function listActiveMaterials(): array
{
$stmt = $this->pdo->query('SELECT * FROM materials WHERE is_active = 1 ORDER BY code');
return $stmt->fetchAll();
}
public function getPrinterById(int $printerId): ?array
{
$stmt = $this->pdo->prepare('SELECT * FROM printers WHERE id = :id');
$stmt->execute(['id' => $printerId]);
$row = $stmt->fetch();
return $row ?: null;
}
public function listMaterialsForPrinter(int $printerId): array
{
$sql = 'SELECT m.*, pms.support_level, pms.partial_reason, pms.extra_info
FROM materials m
LEFT JOIN printer_material_support pms
ON pms.material_id = m.id AND pms.printer_id = :pid
WHERE m.is_active = 1
ORDER BY m.code';
$stmt = $this->pdo->prepare($sql);
$stmt->execute(['pid' => $printerId]);
return $stmt->fetchAll();
}
}

View File

@@ -1 +1,47 @@
<?php // TODO
<?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, '?');
}
}

View File

@@ -1 +1,241 @@
<?php // TODO
<?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 [];
}
}
}

View File

@@ -1 +1,71 @@
<?php // TODO
<?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;
}
}

View File

@@ -1 +0,0 @@
# src\n\nTODO: Core backend/business logic.\n

View File

@@ -1,2 +0,0 @@
<?php
return require __DIR__ . '/../config/config.php';

View File

@@ -1 +1,103 @@
<?php // TODO
<?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;
}

View File

@@ -1 +0,0 @@

View File

@@ -1,21 +0,0 @@
<?php
function tools_build_pdo(): PDO
{
$cfg = require __DIR__ . '/../src/config.php';
if (!is_array($cfg)) {
throw new RuntimeException('DB config did not return an array.');
}
$required = ['db_host', 'db_name', 'db_user', 'db_pass', 'db_charset'];
foreach ($required as $key) {
if (!array_key_exists($key, $cfg)) {
throw new RuntimeException('Missing DB config key: ' . $key);
}
}
$dsn = "mysql:host={$cfg['db_host']};dbname={$cfg['db_name']};charset={$cfg['db_charset']}";
$options = [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
];
return new PDO($dsn, $cfg['db_user'], $cfg['db_pass'], $options);
}

View File

@@ -1,6 +0,0 @@
<?php
function tools_fetch_active_printers(PDO $pdo): array
{
$stmt = $pdo->query("SELECT * FROM printers WHERE is_active = 1 ORDER BY name");
return $stmt->fetchAll();
}