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. - 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. - 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 - Drucker-Liste
- Material-Liste - Material-Liste
- Drucker-spezifische Material-Kompatibilität - 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) Aktuelle DB-Struktur (Schema-Orientierung)
Das Schema besteht aus drei Kern-Tabellen, die für die Materialmatrix benötigt werden: 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 - `printer`: Drucker-Datensatz
- `materials`: Liste der Materialien mit optionalem `support_level` + Zusatzinfos. - `materials`: Liste der Materialien mit optionalem `support_level` + Zusatzinfos.
- Die Datenbank kann migriert werden, solange Material-, Drucker- und Zuordnungsdaten semantisch erhalten bleiben. - 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 Sicherheit
- Zugangsdaten sollten nicht im Repo liegen. Nutze ENV-Variablen oder separate Configs pro Environment. - 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 <?php
$env = getenv('APP_ENV') ?: 'staging'; declare(strict_types=1);
$env = strtolower($env);
$path = __DIR__ . 'db.php';
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 <?php
return [ declare(strict_types=1);
'db_host' => 'localhost',
'db_name' => 'd0453540', /**
'db_user' => 'd0453540', * config/db.php
'db_pass' => 'P6jGRrSaX8QSiBMEJBL7', *
'db_charset' => 'utf8mb4', * - 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"> <?php
<header class="flex items-center justify-between gap-4"> $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> <div>
<h1 class="text-2xl font-bold tracking-tight text-slate-900">3D-Druck Materialmatrix</h1> <p class="mm-kicker">Materialmatrix</p>
<p class="text-xs text-slate-500">Schnell prüfen, welche Filamente auf welchen Druckern laufen.</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> </div>
<span id="status" class="text-xs text-slate-500"></span> <div class="mm-status" id="status">Bereit</div>
</header> </header>
<div class="bg-white/80 backdrop-blur rounded-lg shadow flex gap-6 p-4 min-h-[420px]"> <div class="mm-card">
<!-- Sidebar --> <aside class="mm-sidebar">
<aside class="w-72 space-y-5 border-r pr-4 border-slate-100"> <div class="mm-panel">
<div> <h2>Drucker auswählen</h2>
<h2 class="text-sm font-semibold text-slate-700 mb-2">Drucker auswählen</h2> <label for="printerSelect">Einzelansicht</label>
<label class="block text-xs font-medium mb-1 text-slate-600">Einzelansicht</label> <select id="printerSelect">
<select id="printerSelect" class="w-full border rounded px-2 py-1 mb-2 text-sm">
<option value=""> wird geladen </option> <option value=""> wird geladen </option>
</select> </select>
<p class="text-xs text-slate-500"> <p>Zeigt die Kompatibilität nur für diesen Drucker.</p>
Zeigt die Kompatibilität nur für diesen Drucker.
</p>
</div> </div>
<div> <div class="mm-panel">
<label class="block text-xs font-medium mb-1 text-slate-600">Vergleich (mehrere)</label> <label for="printerCompare">Vergleich (mehrere)</label>
<select id="printerCompare" multiple class="w-full border rounded px-2 py-1 h-32 text-sm"></select> <select id="printerCompare" multiple></select>
<p class="text-xs text-slate-500"> <p>Strg/⌘ gedrückt halten, um mehrere zu wählen.</p>
Strg/ gedrückt halten, um mehrere zu wählen.
</p>
</div> </div>
<div class="text-xs text-slate-400"> <div class="mm-tip">
Tipp: Im Vergleich werden die ausgewählten Drucker rechts als separate Spalten eingefärbt. Tipp: Im Vergleich werden die ausgewählten Drucker rechts als separate Spalten hervorgehoben.
</div> </div>
</aside> </aside>
<!-- Main --> <main class="mm-main">
<main class="flex-1 flex flex-col gap-3"> <div class="mm-table-wrap" id="tableWrap">
<div class="overflow-auto max-h-[70vh] rounded border bg-white" id="tableWrap"> <table id="matTable" class="mm-table">
<table class="min-w-full text-sm" id="matTable"> <thead>
<thead class="bg-slate-50">
<tr id="tableHead"> <tr id="tableHead">
<th class="px-3 py-2 text-left">Material</th> <th>Material</th>
<th class="px-3 py-2 text-left">Eigenschaften</th> <th>Eigenschaften</th>
<th class="px-3 py-2 text-left">Tg °C</th> <th>Tg °C</th>
<th class="px-3 py-2 text-left">Düse</th> <th>Düse</th>
<th class="px-3 py-2 text-left">Platte</th> <th>Platte</th>
<th class="px-3 py-2 text-left">Zusatz</th> <th>Zusatz</th>
<th class="px-3 py-2 text-left">Anwendung</th> <th>Anwendung</th>
<th class="px-3 py-2 text-left">Kinder</th> <th>Kinder</th>
<th class="px-3 py-2 text-left">Emission</th> <th>Emission</th>
</tr> </tr>
</thead> </thead>
<tbody id="matBody"></tbody> <tbody id="matBody"></tbody>
</table> </table>
</div> </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 class="mm-disclaimer">
<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.
<p>
<strong>Hinweis:</strong> Dieses Projekt wird privat betrieben und befindet sich im Aufbau.
Es sind noch nicht alle Drucker und Materialien eingetragen. 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>. Alle Angaben erfolgen nach bestem Wissen, jedoch <u>ohne Gewähr auf Vollständigkeit oder Richtigkeit</u>.
</p> </p>
</section> </section>
</main> </main>
</div> </div>
</div> </section>
<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>

View File

@@ -1,9 +1,3 @@
<?php <?php asset_scripts('footer'); ?>
// 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'; ?>
</body> </body>
</html> </html>

View File

@@ -1,29 +1,14 @@
<?php <?php
// Basic layout start. /** @var \App\App $app */
?><!doctype html> $app = app();
?>
<!doctype html>
<html lang="de"> <html lang="de">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<title>3D-Druck Materialmatrix</title>
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<script src="https://cdn.tailwindcss.com"></script> <title><?= htmlspecialchars(t('common.title'), ENT_QUOTES) ?></title>
<style> <?php asset_styles(); ?>
body { <?php asset_scripts('header'); ?>
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>
</head> </head>
<body class="bg-slate-100 min-h-screen"> <body class="app-body">
<?php require __DIR__ . '/nav.php'; ?>

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 <?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();
}