Compare commits

...

23 Commits

Author SHA1 Message Date
f3f24cebba New version 2026-01-24 01:42:46 +01:00
6063ae4193 asdasd 2026-01-24 01:05:43 +01:00
648d3aac5d asd 2026-01-24 00:31:46 +01:00
f15cf0b0ff asdasd 2026-01-24 00:25:50 +01:00
f0f9abdd66 xadsasd 2026-01-24 00:23:12 +01:00
ed5d77bf6c asd 2026-01-23 23:45:16 +01:00
0a784ef454 asdasd 2026-01-23 23:27:18 +01:00
a9445b305f testem 2026-01-23 23:15:06 +01:00
140f9b4288 asdsad 2026-01-23 01:20:24 +01:00
6e4002af70 adssad 2026-01-23 01:18:29 +01:00
ed3fdda98e Update .gitlab-ci.yml file 2026-01-23 01:15:16 +01:00
b6e49c8ad8 Update .gitlab-ci.yml file 2026-01-23 00:59:11 +01:00
e96cfd09ad Merge shape3d2/main into develop (take main versions) 2026-01-23 00:41:29 +01:00
4ae930033b Update .gitlab-ci.yml file 2026-01-23 00:18:34 +01:00
01e579aecf asdasd 2026-01-22 00:31:35 +01:00
be8f2b772c asdasd 2026-01-22 00:17:23 +01:00
5b24b9464c commit 2025-12-03 01:23:22 +01:00
b6009abede commit 2025-12-03 01:22:31 +01:00
367a3662a9 up 2025-12-03 01:13:34 +01:00
a0b0f21d38 sadasd 2025-12-02 00:37:28 +01:00
7cd512db2d sadsad 2025-12-02 00:36:27 +01:00
84c7e097af sss 2025-12-02 00:35:23 +01:00
5279b0d34d up 2025-11-17 23:50:23 +01:00
69 changed files with 3196 additions and 708 deletions

View File

@@ -17,7 +17,8 @@ default:
# -----------------------------------------
deploy:staging:
stage: deploy
tags:
- staging
variables:
TARGET_PATH: "${FTP_PATH_STAGING}"
CONFIG_ENV_DIR: "config/staging"

122
README.md
View File

@@ -1,93 +1,53 @@
# shape3d
Kurzbeschreibung
Diese App zeigt eine 3D-Druck-Materialmatrix: Welche Materialien laufen auf welchen Druckern, inkl. Eigenschaften, Anforderungen und Sicherheits-/Emissionsindikatoren. Die Oberfläche lädt Daten per API und rendert sie als Tabelle mit optionalem Drucker-Vergleich.
Was wird angezeigt
- Materialliste mit Metadaten (z. B. Eigenschaften, Tg, Düsen-/Plattenanforderung, Anwendung, Kinder-Sicherheit, Emission).
- Pro ausgewähltem Drucker eine Kompatibilitäts-Spalte (voll/teilweise/mit Zusatz/nicht unterstützt).
- Auswahl: Einzelansicht (ein Drucker) oder Vergleich (mehrere Drucker).
## Getting started
Datenquellen (flexibel gehalten)
Die App ist so aufgebaut, dass UI und Datenzugriff getrennt sind. Je nach Reimport/Neustrukturierung können Dateinamen und Pfade abweichen, solange die folgenden Rollen weiterhin existieren:
To make it easy for you to get started with GitLab, here's a list of recommended next steps.
- Einstiegspunkt (z. B. `public/index.php`): Lädt eine Seite/Ansicht, die das Layout und die Matrix enthält.
- Matrix-Ansicht (z. B. `partials/landing/main/material-matrix.php`): Enthält das Markup + JS, das die Daten lädt und die Tabelle rendert.
- API-Endpoints (z. B. `/api/*` über das Routing): Stellen JSON bereit für
- Drucker-Liste
- Material-Liste
- Drucker-spezifische Material-Kompatibilität
- DB-Zugriff (z. B. `src/App/Database.php`): Baut eine DB-Verbindung und wird von den API-Endpunkten genutzt.
Already a pro? Just edit this README.md and make it your own. Want to make it easy? [Use the template at the bottom](#editing-this-readme)!
Aktuelle DB-Struktur (Schema-Orientierung)
Das Schema besteht aus drei Kern-Tabellen, die für die Materialmatrix benötigt werden:
## Add your files
1) materials
- Stammdaten der Materialien (Code/Name/Short-Desc, Eigenschaften, Tg, Anforderungen, Anwendung).
- Flags für Kinder-Sicherheit und Emission.
- `is_active` steuert Sichtbarkeit.
- [ ] [Create](https://docs.gitlab.com/ee/user/project/repository/web_editor.html#create-a-file) or [upload](https://docs.gitlab.com/ee/user/project/repository/web_editor.html#upload-a-file) files
- [ ] [Add files using the command line](https://docs.gitlab.com/topics/git/add_files/#add-files-to-a-git-repository) or push an existing Git repository with the following command:
2) printers
- Stammdaten der Drucker (Name/Brand/Modell, Temperaturgrenzen, Bauvolumen, Notes).
- `is_active` steuert Sichtbarkeit.
```
cd existing_repo
git remote add origin https://gitlab.int.kusche.berlin/shape3d/shape3d.git
git branch -M main
git push -uf origin main
```
3) printer_material_support
- Zuordnung Drucker ↔ Material (M:N).
- `support_level` beschreibt die Kompatibilität.
- `partial_reason` / `extra_info` liefern Detailhinweise.
## Integrate with your tools
Datenfluss (funktional)
1) UI lädt die Druckerliste.
2) Für den gewählten Drucker (oder mehrere) werden Materialien inkl. Support-Level geladen.
3) Die Tabelle rendert Materialinfos und pro Drucker eine Support-Badge-Zelle.
- [ ] [Set up project integrations](https://gitlab.int.kusche.berlin/shape3d/shape3d/-/settings/integrations)
Hinweise für Reimport/Neuaufbau
- Die Datei-/Ordnernamen dürfen sich ändern, solange die Rollen (UI, API, DB-Zugriff) abgebildet sind.
- Die API muss weiterhin JSON liefern, das die Matrix-Ansicht erwarten kann:
- `printer`: Drucker-Datensatz
- `materials`: Liste der Materialien mit optionalem `support_level` + Zusatzinfos.
- Die Datenbank kann migriert werden, solange Material-, Drucker- und Zuordnungsdaten semantisch erhalten bleiben.
- Für DB-Zugriff muss `APP_DB_ENABLED` aktiviert sein; die Zugangsdaten sollten per ENV-Variablen kommen.
## Collaborate with your team
- [ ] [Invite team members and collaborators](https://docs.gitlab.com/ee/user/project/members/)
- [ ] [Create a new merge request](https://docs.gitlab.com/ee/user/project/merge_requests/creating_merge_requests.html)
- [ ] [Automatically close issues from merge requests](https://docs.gitlab.com/ee/user/project/issues/managing_issues.html#closing-issues-automatically)
- [ ] [Enable merge request approvals](https://docs.gitlab.com/ee/user/project/merge_requests/approvals/)
- [ ] [Set auto-merge](https://docs.gitlab.com/user/project/merge_requests/auto_merge/)
## Test and Deploy
Use the built-in continuous integration in GitLab.
- [ ] [Get started with GitLab CI/CD](https://docs.gitlab.com/ee/ci/quick_start/)
- [ ] [Analyze your code for known vulnerabilities with Static Application Security Testing (SAST)](https://docs.gitlab.com/ee/user/application_security/sast/)
- [ ] [Deploy to Kubernetes, Amazon EC2, or Amazon ECS using Auto Deploy](https://docs.gitlab.com/ee/topics/autodevops/requirements.html)
- [ ] [Use pull-based deployments for improved Kubernetes management](https://docs.gitlab.com/ee/user/clusters/agent/)
- [ ] [Set up protected environments](https://docs.gitlab.com/ee/ci/environments/protected_environments.html)
***
# Editing this README
When you're ready to make this README your own, just edit this file and use the handy template below (or feel free to structure it however you want - this is just a starting point!). Thanks to [makeareadme.com](https://www.makeareadme.com/) for this template.
## Suggestions for a good README
Every project is different, so consider which of these sections apply to yours. The sections used in the template are suggestions for most open source projects. Also keep in mind that while a README can be too long and detailed, too long is better than too short. If you think your README is too long, consider utilizing another form of documentation rather than cutting out information.
## Name
Choose a self-explaining name for your project.
## Description
Let people know what your project can do specifically. Provide context and add a link to any reference visitors might be unfamiliar with. A list of Features or a Background subsection can also be added here. If there are alternatives to your project, this is a good place to list differentiating factors.
## Badges
On some READMEs, you may see small images that convey metadata, such as whether or not all the tests are passing for the project. You can use Shields to add some to your README. Many services also have instructions for adding a badge.
## Visuals
Depending on what you are making, it can be a good idea to include screenshots or even a video (you'll frequently see GIFs rather than actual videos). Tools like ttygif can help, but check out Asciinema for a more sophisticated method.
## Installation
Within a particular ecosystem, there may be a common way of installing things, such as using Yarn, NuGet, or Homebrew. However, consider the possibility that whoever is reading your README is a novice and would like more guidance. Listing specific steps helps remove ambiguity and gets people to using your project as quickly as possible. If it only runs in a specific context like a particular programming language version or operating system or has dependencies that have to be installed manually, also add a Requirements subsection.
## Usage
Use examples liberally, and show the expected output if you can. It's helpful to have inline the smallest example of usage that you can demonstrate, while providing links to more sophisticated examples if they are too long to reasonably include in the README.
## Support
Tell people where they can go to for help. It can be any combination of an issue tracker, a chat room, an email address, etc.
## Roadmap
If you have ideas for releases in the future, it is a good idea to list them in the README.
## Contributing
State if you are open to contributions and what your requirements are for accepting them.
For people who want to make changes to your project, it's helpful to have some documentation on how to get started. Perhaps there is a script that they should run or some environment variables that they need to set. Make these steps explicit. These instructions could also be useful to your future self.
You can also document commands to lint the code or run tests. These steps help to ensure high code quality and reduce the likelihood that the changes inadvertently break something. Having instructions for running tests is especially helpful if it requires external setup, such as starting a Selenium server for testing in a browser.
## Authors and acknowledgment
Show your appreciation to those who have contributed to the project.
## License
For open source projects, say how it is licensed.
## Project status
If you have run out of energy or time for your project, put a note at the top of the README saying that development has slowed down or stopped completely. Someone may choose to fork your project or volunteer to step in as a maintainer or owner, allowing your project to keep going. You can also make an explicit request for maintainers.
Sicherheit
- Zugangsdaten sollten nicht im Repo liegen. Nutze ENV-Variablen oder separate Configs pro Environment.

View File

@@ -1 +0,0 @@

View File

@@ -1 +0,0 @@

67
config/config.php Normal file
View File

@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
// Basic error reporting (keep strict in dev)
ini_set('display_errors', '1');
ini_set('display_startup_errors', '1');
error_reporting(E_ALL);
// 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
}

41
config/fileload.php Normal file
View File

@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
// 1) Load config (constants, env, domains)
require_once __DIR__ . '/config.php';
// 2) Composer Autoloader (falls vorhanden)
$composerAutoload = __DIR__ . '/../vendor/autoload.php';
if (file_exists($composerAutoload)) {
require_once $composerAutoload;
} else {
// 2b) Fallback: minimaler Autoloader
spl_autoload_register(function (string $class): void {
$prefix = 'App\\';
if (!str_starts_with($class, $prefix)) {
return;
}
$rel = substr($class, strlen($prefix));
$path = __DIR__ . '/../src/App/' . str_replace('\\', '/', $rel) . '.php';
if (file_exists($path)) {
require_once $path;
}
});
}
// 3) Global helper functions (tpl(), t(), asset_*())
require_once __DIR__ . '/../src/helpers.php';
// 4) Initialize App (services)
$config = \App\Config::fromPhpConstants(__DIR__ . '/../config');
\App\App::init($config);
// 5) Start session + create client-id cookie
$app = \App\App::get();
$app->session()->start();
$clientId = $app->session()->ensureClientId();
// Optionally expose a single global for templates if desired
$GLOBALS['client_id'] = $clientId;

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 +0,0 @@
Laiv männ

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 +0,0 @@

View File

@@ -1 +1,93 @@
<?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 = '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 @@
Demo männ

View File

@@ -1 +0,0 @@

View File

@@ -1 +0,0 @@

View File

@@ -1 +0,0 @@

View File

@@ -0,0 +1,69 @@
<?php
$app = app();
$app->assets()->addStyle('/assets/app.css', 'early');
$app->assets()->addScript('/assets/app.js', 'footer', true);
?>
<section class="mm-shell">
<header class="mm-header">
<div>
<p class="mm-kicker">Materialmatrix</p>
<h1 class="mm-title"><?= htmlspecialchars(t('common.title'), ENT_QUOTES) ?></h1>
<p class="mm-subtitle">Schnell prüfen, welche Filamente auf welchen Druckern laufen.</p>
</div>
<div class="mm-status" id="status">Bereit</div>
</header>
<div class="mm-card">
<aside class="mm-sidebar">
<div class="mm-panel">
<h2>Drucker auswählen</h2>
<label for="printerSelect">Einzelansicht</label>
<select id="printerSelect">
<option value=""> wird geladen </option>
</select>
<p>Zeigt die Kompatibilität nur für diesen Drucker.</p>
</div>
<div class="mm-panel">
<label for="printerCompare">Vergleich (mehrere)</label>
<select id="printerCompare" multiple></select>
<p>Strg/⌘ gedrückt halten, um mehrere zu wählen.</p>
</div>
<div class="mm-tip">
Tipp: Im Vergleich werden die ausgewählten Drucker rechts als separate Spalten hervorgehoben.
</div>
</aside>
<main class="mm-main">
<div class="mm-table-wrap" id="tableWrap">
<table id="matTable" class="mm-table">
<thead>
<tr id="tableHead">
<th>Material</th>
<th>Eigenschaften</th>
<th>Tg °C</th>
<th>Düse</th>
<th>Platte</th>
<th>Zusatz</th>
<th>Anwendung</th>
<th>Kinder</th>
<th>Emission</th>
</tr>
</thead>
<tbody id="matBody"></tbody>
</table>
</div>
<div id="errorBox" class="mm-error" hidden></div>
<section class="mm-disclaimer">
<p><strong>Hinweis:</strong> Dieses Projekt wird privat betrieben und befindet sich im Aufbau.
Es sind noch nicht alle Drucker und Materialien eingetragen.
Alle Angaben erfolgen nach bestem Wissen, jedoch <u>ohne Gewähr auf Vollständigkeit oder Richtigkeit</u>.
</p>
</section>
</main>
</div>
</section>

View File

@@ -1 +0,0 @@

View File

@@ -1 +1,3 @@
<?php // TODO
<?php asset_scripts('footer'); ?>
</body>
</html>

View File

@@ -1 +1,14 @@
<?php // TODO
<?php
/** @var \App\App $app */
$app = app();
?>
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><?= htmlspecialchars(t('common.title'), ENT_QUOTES) ?></title>
<?php asset_styles(); ?>
<?php asset_scripts('header'); ?>
</head>
<body class="app-body">

View File

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

View File

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

0
public/.gitkeep Normal file
View File

32
public/.htaccess Normal file
View File

@@ -0,0 +1,32 @@
# -------------------------------------------------
# Apache Front Controller Setup (public/.htaccess)
# -------------------------------------------------
RewriteEngine On
# Sicherheit: keine Directory Listings
Options -Indexes
# -------------------------------------------------
# 1) Assets DIREKT ausliefern
# -------------------------------------------------
RewriteRule ^assets/ - [L]
# -------------------------------------------------
# 2) page/ von außen sperren (nur intern per require nutzbar)
# -------------------------------------------------
RewriteRule ^page/ - [F,L]
# -------------------------------------------------
# 3) Alles andere an den Front Controller
# -------------------------------------------------
RewriteRule ^ index.php [L]
# -------------------------------------------------
# 4) (Optional) Zusätzliche Sicherheits-Header
# -------------------------------------------------
<IfModule mod_headers.c>
Header set X-Frame-Options "SAMEORIGIN"
Header set X-Content-Type-Options "nosniff"
Header set Referrer-Policy "strict-origin-when-cross-origin"
</IfModule>

View File

@@ -1,16 +0,0 @@
<?php
// public/api/_db.php
$cfg = require __DIR__ . '/../../src/config.php';
$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,
];
try {
$pdo = new PDO($dsn, $cfg['db_user'], $cfg['db_pass'], $options);
} catch (PDOException $e) {
http_response_code(500);
header('Content-Type: application/json');
echo json_encode(['error' => 'DB connection failed']);
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,6 +0,0 @@
<?php
// public/api/printers.php
header('Content-Type: application/json; charset=utf-8');
require __DIR__ . '/_db.php';
$stmt = $pdo->query("SELECT * FROM printers WHERE is_active = 1 ORDER BY name");
echo json_encode($stmt->fetchAll(), 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,266 +0,0 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<title>3D-Druck Materialmatrix</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<script src="https://cdn.tailwindcss.com"></script>
<style>
body {
background: radial-gradient(circle at top, #e2e8f0 0%, #f8fafc 45%, #e2e8f0 90%);
}
thead th {
position: sticky;
top: 0;
backdrop-filter: blur(4px);
}
th[data-printer],
td[data-printer] {
background: rgba(148, 163, 184, 0.05);
}
#disclaimer {
box-shadow: inset 0 1px 0 rgba(148, 163, 184, 0.25);
}
</style>
</head>
<body class="bg-slate-100 min-h-screen">
<div class="max-w-6xl mx-auto py-6 space-y-6">
<header class="flex items-center justify-between gap-4">
<div>
<h1 class="text-2xl font-bold tracking-tight text-slate-900">3D-Druck Materialmatrix</h1>
<p class="text-xs text-slate-500">Schnell prüfen, welche Filamente auf welchen Druckern laufen.</p>
</div>
<span id="status" class="text-xs text-slate-500"></span>
</header>
<div class="bg-white/80 backdrop-blur rounded-lg shadow flex gap-6 p-4 min-h-[420px]">
<!-- Sidebar -->
<aside class="w-72 space-y-5 border-r pr-4 border-slate-100">
<div>
<h2 class="text-sm font-semibold text-slate-700 mb-2">Drucker auswählen</h2>
<label class="block text-xs font-medium mb-1 text-slate-600">Einzelansicht</label>
<select id="printerSelect" class="w-full border rounded px-2 py-1 mb-2 text-sm">
<option value=""> wird geladen </option>
</select>
<p class="text-xs text-slate-500">
Zeigt die Kompatibilität nur für diesen Drucker.
</p>
</div>
<div>
<label class="block text-xs font-medium mb-1 text-slate-600">Vergleich (mehrere)</label>
<select id="printerCompare" multiple class="w-full border rounded px-2 py-1 h-32 text-sm"></select>
<p class="text-xs text-slate-500">
Strg/⌘ gedrückt halten, um mehrere zu wählen.
</p>
</div>
<div class="text-xs text-slate-400">
Tipp: Im Vergleich werden die ausgewählten Drucker rechts als separate Spalten eingefärbt.
</div>
</aside>
<!-- Main -->
<main class="flex-1 flex flex-col gap-3">
<div class="overflow-auto max-h-[70vh] rounded border bg-white" id="tableWrap">
<table class="min-w-full text-sm" id="matTable">
<thead class="bg-slate-50">
<tr id="tableHead">
<th class="px-3 py-2 text-left">Material</th>
<th class="px-3 py-2 text-left">Eigenschaften</th>
<th class="px-3 py-2 text-left">Tg °C</th>
<th class="px-3 py-2 text-left">Düse</th>
<th class="px-3 py-2 text-left">Platte</th>
<th class="px-3 py-2 text-left">Zusatz</th>
<th class="px-3 py-2 text-left">Anwendung</th>
<th class="px-3 py-2 text-left">Kinder</th>
<th class="px-3 py-2 text-left">Emission</th>
</tr>
</thead>
<tbody id="matBody"></tbody>
</table>
</div>
<div id="errorBox" class="hidden rounded bg-rose-50 border border-rose-200 text-rose-700 text-sm px-3 py-2"></div>
<!-- Hinweisblock unten -->
<section id="disclaimer" class="mt-2 rounded-lg border border-slate-200 bg-slate-50 p-4 text-xs text-slate-700 leading-snug">
<p>
<strong>Hinweis:</strong> Dieses Projekt wird privat betrieben und befindet sich im Aufbau.
Es sind noch nicht alle Drucker und Materialien eingetragen.
Alle Angaben erfolgen nach bestem Wissen, jedoch <u>ohne Gewähr auf Vollständigkeit oder Richtigkeit</u>.
</p>
</section>
</main>
</div>
</div>
<script>
const API_BASE = './api';
const printerSelect = document.getElementById('printerSelect');
const printerCompare = document.getElementById('printerCompare');
const matBody = document.getElementById('matBody');
const tableHead = document.getElementById('tableHead');
const statusEl = document.getElementById('status');
const errorBox = document.getElementById('errorBox');
function showError(msg) {
errorBox.textContent = msg;
errorBox.classList.remove('hidden');
}
function clearError() {
errorBox.classList.add('hidden');
}
async function fetchJSON(url) {
const res = await fetch(url);
if (!res.ok) throw new Error('HTTP ' + res.status + ' for ' + url);
return await res.json();
}
async function loadPrinters() {
try {
statusEl.textContent = 'Lade Drucker …';
const data = await fetchJSON(`${API_BASE}/printers.php`);
printerSelect.innerHTML = '';
printerCompare.innerHTML = '';
if (!data.length) {
printerSelect.innerHTML = '<option value="">(keine Drucker gefunden)</option>';
statusEl.textContent = 'Keine Drucker gefunden';
return;
}
data.forEach(p => {
printerSelect.appendChild(new Option(p.name, p.id));
printerCompare.appendChild(new Option(p.name, p.id));
});
// ersten Drucker anzeigen
loadSinglePrinter(data[0].id);
statusEl.textContent = 'Drucker geladen';
} catch (err) {
console.error(err);
showError('Konnte Drucker nicht laden. Prüfe public/api/printers.php.');
}
}
async function loadSinglePrinter(id) {
if (!id) return;
clearError();
statusEl.textContent = 'Lade Materialien …';
try {
const data = await fetchJSON(`${API_BASE}/printer-materials.php?id=${id}`);
renderTable([data]);
statusEl.textContent = 'Fertig';
} catch (err) {
console.error(err);
showError('Konnte Materialien für Drucker nicht laden.');
statusEl.textContent = 'Fehler';
}
}
async function loadMultiplePrinters(ids) {
if (!ids.length) return;
clearError();
statusEl.textContent = 'Lade Vergleich …';
try {
const datasets = await Promise.all(ids.map(id => fetchJSON(`${API_BASE}/printer-materials.php?id=${id}`)));
renderTable(datasets);
statusEl.textContent = 'Vergleich geladen';
} catch (err) {
console.error(err);
showError('Konnte einen der gewählten Drucker nicht laden.');
statusEl.textContent = 'Fehler';
}
}
function renderTable(datasets) {
// Kopf neu aufbauen
const baseHead = `
<th class="px-3 py-2 text-left">Material</th>
<th class="px-3 py-2 text-left">Eigenschaften</th>
<th class="px-3 py-2 text-left">Tg °C</th>
<th class="px-3 py-2 text-left">Düse</th>
<th class="px-3 py-2 text-left">Platte</th>
<th class="px-3 py-2 text-left">Zusatz</th>
<th class="px-3 py-2 text-left">Anwendung</th>
<th class="px-3 py-2 text-left">Kinder</th>
<th class="px-3 py-2 text-left">Emission</th>
`;
let printerCols = '';
datasets.forEach(ds => {
printerCols += `<th class="px-3 py-2 text-left bg-slate-100" data-printer="${ds.printer.id}">${ds.printer.name}</th>`;
});
tableHead.innerHTML = baseHead + printerCols;
const materials = datasets[0]?.materials ?? [];
matBody.innerHTML = '';
materials.forEach((m, idx) => {
const tr = document.createElement('tr');
tr.className = idx % 2 === 0 ? 'bg-white' : 'bg-slate-50/60';
const kid = m.kid_safety === 'safe' ? '🌿' : (m.kid_safety === 'limited' ? '🟡' : '🔴');
const em = m.emission === 'low' ? '✅' : (m.emission === 'medium' ? '⚠️' : '⛔');
let html = `
<td class="px-3 py-2 font-medium">${m.code}<div class="text-xs text-slate-500">${m.short_desc ?? ''}</div></td>
<td class="px-3 py-2">${m.properties ?? ''}</td>
<td class="px-3 py-2">${m.tg_celsius ?? ''}</td>
<td class="px-3 py-2">${m.nozzle_req ?? ''}</td>
<td class="px-3 py-2">${m.plate_req ?? ''}</td>
<td class="px-3 py-2">${m.extra_req ?? ''}</td>
<td class="px-3 py-2">${m.application ?? ''}</td>
<td class="px-3 py-2">${kid}</td>
<td class="px-3 py-2">${em}</td>
`;
// Drucker-Spalten
datasets.forEach(ds => {
const match = ds.materials.find(x => x.id === m.id || x.code === m.code);
if (!match || !match.support_level) {
html += `<td class="px-3 py-2" data-printer="${ds.printer.id}"><span class="px-2 py-1 rounded bg-slate-200 text-xs">unbekannt</span></td>`;
} else {
let badge = '';
if (match.support_level === 'full') {
badge = '<span class="px-2 py-1 rounded bg-green-100 text-green-800 text-xs">✓ voll</span>';
} else if (match.support_level === 'partial') {
badge = '<span class="px-2 py-1 rounded bg-amber-100 text-amber-800 text-xs">⚠ teilw.</span>';
} else if (match.support_level === 'with_addon') {
badge = '<span class="px-2 py-1 rounded bg-sky-100 text-sky-800 text-xs">⚙ Zusatz</span>';
} else {
badge = '<span class="px-2 py-1 rounded bg-rose-100 text-rose-800 text-xs">✗ nein</span>';
}
const note = match.partial_reason
? `<div class="text-xs text-slate-400">${match.partial_reason}</div>`
: (match.extra_info ? `<div class="text-xs text-slate-400">${match.extra_info}</div>` : '');
html += `<td class="px-3 py-2" data-printer="${ds.printer.id}">${badge}${note}</td>`;
}
});
tr.innerHTML = html;
matBody.appendChild(tr);
});
}
// Events
printerSelect.addEventListener('change', e => {
const id = e.target.value;
if (id) {
loadSinglePrinter(id);
// Vergleich leeren
printerCompare.selectedIndex = -1;
}
});
printerCompare.addEventListener('change', e => {
const ids = Array.from(e.target.selectedOptions).map(o => o.value);
if (ids.length) {
loadMultiplePrinters(ids);
}
});
// Start
loadPrinters();
</script>
</body>
</html>

View File

@@ -1,270 +1,88 @@
<?php
header('Content-Type: text/html; charset=utf-8');
echo <<<'HTML'
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<title>3D-Druck Materialmatrix</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<script src="https://cdn.tailwindcss.com"></script>
<style>
body {
background: radial-gradient(circle at top, #e2e8f0 0%, #f8fafc 45%, #e2e8f0 90%);
}
thead th {
position: sticky;
top: 0;
backdrop-filter: blur(4px);
}
th[data-printer],
td[data-printer] {
background: rgba(148, 163, 184, 0.05);
}
#disclaimer {
box-shadow: inset 0 1px 0 rgba(148, 163, 184, 0.25);
}
</style>
</head>
<body class="bg-slate-100 min-h-screen">
<div class="max-w-6xl mx-auto py-6 space-y-6">
<header class="flex items-center justify-between gap-4">
<div>
<h1 class="text-2xl font-bold tracking-tight text-slate-900">3D-Druck Materialmatrix</h1>
<p class="text-xs text-slate-500">Schnell prüfen, welche Filamente auf welchen Druckern laufen.</p>
</div>
<span id="status" class="text-xs text-slate-500"></span>
</header>
declare(strict_types=1);
<div class="bg-white/80 backdrop-blur rounded-lg shadow flex gap-6 p-4 min-h-[420px]">
<!-- Sidebar -->
<aside class="w-72 space-y-5 border-r pr-4 border-slate-100">
<div>
<h2 class="text-sm font-semibold text-slate-700 mb-2">Drucker auswählen</h2>
<label class="block text-xs font-medium mb-1 text-slate-600">Einzelansicht</label>
<select id="printerSelect" class="w-full border rounded px-2 py-1 mb-2 text-sm">
<option value=""> wird geladen </option>
</select>
<p class="text-xs text-slate-500">
Zeigt die Kompatibilität nur für diesen Drucker.
</p>
</div>
// boot application (config, autoload, services)
require_once __DIR__ . '/../config/fileload.php';
<div>
<label class="block text-xs font-medium mb-1 text-slate-600">Vergleich (mehrere)</label>
<select id="printerCompare" multiple class="w-full border rounded px-2 py-1 h-32 text-sm"></select>
<p class="text-xs text-slate-500">
Strg/⌘ gedrückt halten, um mehrere zu wählen.
</p>
</div>
<div class="text-xs text-slate-400">
Tipp: Im Vergleich werden die ausgewählten Drucker rechts als separate Spalten eingefärbt.
</div>
</aside>
<!-- Main -->
<main class="flex-1 flex flex-col gap-3">
<div class="overflow-auto max-h-[70vh] rounded border bg-white" id="tableWrap">
<table class="min-w-full text-sm" id="matTable">
<thead class="bg-slate-50">
<tr id="tableHead">
<th class="px-3 py-2 text-left">Material</th>
<th class="px-3 py-2 text-left">Eigenschaften</th>
<th class="px-3 py-2 text-left">Tg °C</th>
<th class="px-3 py-2 text-left">Düse</th>
<th class="px-3 py-2 text-left">Platte</th>
<th class="px-3 py-2 text-left">Zusatz</th>
<th class="px-3 py-2 text-left">Anwendung</th>
<th class="px-3 py-2 text-left">Kinder</th>
<th class="px-3 py-2 text-left">Emission</th>
</tr>
</thead>
<tbody id="matBody"></tbody>
</table>
</div>
<div id="errorBox" class="hidden rounded bg-rose-50 border border-rose-200 text-rose-700 text-sm px-3 py-2"></div>
<!-- Hinweisblock unten -->
<section id="disclaimer" class="mt-2 rounded-lg border border-slate-200 bg-slate-50 p-4 text-xs text-slate-700 leading-snug">
<p>
<strong>Hinweis:</strong> Dieses Projekt wird privat betrieben und befindet sich im Aufbau.
Es sind noch nicht alle Drucker und Materialien eingetragen.
Alle Angaben erfolgen nach bestem Wissen, jedoch <u>ohne Gewähr auf Vollständigkeit oder Richtigkeit</u>.
</p>
</section>
</main>
</div>
</div>
<script>
const API_BASE = './api';
const printerSelect = document.getElementById('printerSelect');
const printerCompare = document.getElementById('printerCompare');
const matBody = document.getElementById('matBody');
const tableHead = document.getElementById('tableHead');
const statusEl = document.getElementById('status');
const errorBox = document.getElementById('errorBox');
function showError(msg) {
errorBox.textContent = msg;
errorBox.classList.remove('hidden');
// 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;
}
function clearError() {
errorBox.classList.add('hidden');
}
// 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')) {
async function fetchJSON(url) {
const res = await fetch(url);
if (!res.ok) throw new Error('HTTP ' + res.status + ' for ' + url);
return await res.json();
$target = $base . '.php';
}
// 3) 404
elseif (is_file($base)) {
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.');
}
$target = $base;
}
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';
}
// 3) 404
else {
http_response_code(404);
$target = __DIR__ . '/page/404.php';
}
}
// ------------------------------------
// Layout-Regel
// ------------------------------------
$skipLayout = false;
$targetReal = realpath($target);
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';
}
}
// Beispiel: alles unter /page/raw/* ohne Layout
if ($targetReal && str_starts_with($targetReal, realpath(__DIR__ . '/page/retool'))) {
$skipLayout = true;
}
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;
// ------------------------------------
// Ausgabe
// ------------------------------------
// Erst Inhalt laden (ohne Ausgabe), damit Header/Redirects vor HTML funktionieren
ob_start();
require $target;
$content = ob_get_clean();
const materials = datasets[0]?.materials ?? [];
matBody.innerHTML = '';
// Wenn bereits Header gesendet wurden (z. B. eigener Redirect/Content-Type), Layout überspringen
if (headers_sent()) {
$skipLayout = true;
}
materials.forEach((m, idx) => {
const tr = document.createElement('tr');
tr.className = idx % 2 === 0 ? 'bg-white' : 'bg-slate-50/60';
if (!$skipLayout) {
tpl('layout_start', 'structure');
}
const kid = m.kid_safety === 'safe' ? '🌿' : (m.kid_safety === 'limited' ? '🟡' : '🔴');
const em = m.emission === 'low' ? '✅' : (m.emission === 'medium' ? '⚠️' : '⛔');
echo $content;
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>
</body>
</html>
HTML;
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;

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 @@

398
src/App/AccountPages.php Normal file
View File

@@ -0,0 +1,398 @@
<?php
declare(strict_types=1);
namespace App;
final class AccountPages
{
public static function register(App $app): array
{
$flash = $app->flash()->get();
$isLoggedIn = isset($_SESSION['user_id']);
$error = '';
$displayName = '';
$email = '';
if ($isLoggedIn) {
redirect('/dashboard');
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$displayName = trim((string)($_POST['display_name'] ?? ''));
$email = trim((string)($_POST['email'] ?? ''));
$password = (string)($_POST['password'] ?? '');
$password2 = (string)($_POST['password_confirm'] ?? '');
if ($password !== $password2) {
$error = 'Passwörter stimmen nicht überein.';
} elseif (strlen($password) < 8) {
$error = 'Passwort muss mindestens 8 Zeichen haben.';
} else {
try {
$auth = new Auth($app);
$userId = $auth->register($displayName, $email, $password);
$code = $auth->createVerifyCode($userId, $email);
$mailer = new Mailer($app);
$mailer->sendTemplate('registration_confirm', $email, [
'code' => $code,
'display_name' => $displayName,
]);
$_SESSION['verify_email'] = $email;
$app->flash()->set('info', 'Bitte bestätige deine Registrierung mit dem Code aus der E-Mail.');
redirect('/verify');
} catch (\Throwable $e) {
$error = $e->getMessage();
}
}
}
return compact('flash', 'error', 'displayName', 'email');
}
public static function login(App $app): array
{
$flash = $app->flash()->get();
$isLoggedIn = isset($_SESSION['user_id']);
$error = '';
$emailPrefill = '';
if ($isLoggedIn) {
redirect('/dashboard');
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$email = trim((string)($_POST['email'] ?? ''));
$emailPrefill = $email;
$password = (string)($_POST['password'] ?? '');
try {
$auth = new Auth($app);
$res = $auth->login($email, $password);
if ($res['status'] === 'pending') {
$code = $auth->createVerifyCode($res['id'], $email);
$mailer = new Mailer($app);
$mailer->sendTemplate('registration_confirm', $email, [
'code' => $code,
'display_name' => $email,
]);
$_SESSION['verify_email'] = $email;
$app->flash()->set('info', 'Bitte bestätige deine Registrierung mit dem Code aus der E-Mail.');
redirect('/verify');
}
$_SESSION['user_id'] = $res['id'];
$app->flash()->set('success', 'Erfolgreich angemeldet.');
redirect('/dashboard');
} catch (\Throwable $e) {
$error = $e->getMessage();
}
}
return compact('flash', 'error', 'emailPrefill', 'isLoggedIn');
}
public static function verify(App $app): array
{
$pdo = $app->pdo();
$flash = $app->flash()->get();
$error = '';
$info = '';
$email = $_SESSION['verify_email'] ?? '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$action = $_POST['action'] ?? 'verify';
$email = trim((string)($_POST['email'] ?? ''));
$code = strtoupper(trim((string)($_POST['code'] ?? '')));
$auth = new Auth($app);
$mailer = new Mailer($app);
if ($action === 'resend') {
try {
$stmt = $pdo?->prepare('SELECT id, display_name, status FROM users u JOIN user_profiles p ON p.user_id = u.id WHERE u.email = :email LIMIT 1');
$stmt?->execute(['email' => $email]);
$row = $stmt?->fetch(\PDO::FETCH_ASSOC);
if (!$row) {
throw new \RuntimeException('E-Mail nicht gefunden.');
}
$userId = (int)$row['id'];
$codeNew = $auth->createVerifyCode($userId, $email);
$mailer->sendTemplate('registration_resend_code', $email, [
'code' => $codeNew,
'display_name' => $row['display_name'] ?? '',
]);
$info = 'Neuer Code wurde versendet.';
$_SESSION['verify_email'] = $email;
} catch (\Throwable $e) {
$error = $e->getMessage();
}
} else {
try {
$userId = $auth->verifyCode($email, $code);
$_SESSION['user_id'] = $userId;
unset($_SESSION['verify_email']);
$mailer->sendTemplate('registration_welcome', $email, ['display_name' => $email]);
$app->flash()->set('success', 'Registrierung bestätigt. Willkommen!');
redirect('/dashboard');
} catch (\Throwable $e) {
$error = $e->getMessage();
}
}
}
return compact('flash', 'error', 'info', 'email');
}
public static function dashboard(App $app): array
{
if (!isset($_SESSION['user_id'])) {
redirect('/login');
}
$pdo = $app->pdo();
$flash = $app->flash()->get();
$userId = (int)$_SESSION['user_id'];
$error = '';
$info = '';
$crypto = null;
try { $crypto = new Crypto($app->config()); } catch (\Throwable) {}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$action = $_POST['action'] ?? '';
try {
if ($action === 'profile') {
$languages = $_POST['languages'] ?? '';
if (is_array($languages)) {
$languages = implode(', ', array_map('trim', $languages));
}
$phoneEnc = $crypto ? $crypto->encrypt(trim((string)$_POST['contact_phone'])) : trim((string)$_POST['contact_phone']);
$stmt = $pdo?->prepare('UPDATE user_profiles SET display_name=:name, first_name=:fname, last_name=:lname, zip=:zip, city=:city, profession=:prof, languages=:langs, about=:about, contact_phone=:phone, updated_at=NOW() WHERE user_id=:id');
$stmt?->execute([
'name' => trim((string)$_POST['display_name']),
'fname' => trim((string)$_POST['first_name']),
'lname' => trim((string)$_POST['last_name']),
'zip' => trim((string)$_POST['zip']),
'city' => trim((string)$_POST['city']),
'prof' => trim((string)$_POST['profession']),
'langs' => trim((string)$languages),
'about' => trim((string)$_POST['about']),
'phone' => $phoneEnc,
'id' => $userId,
]);
$info = 'Profil gespeichert.';
} elseif ($action === 'child_add') {
$firstNameEnc = $crypto ? $crypto->encrypt(trim((string)$_POST['first_name'])) : trim((string)$_POST['first_name']);
$noteEnc = $crypto ? $crypto->encrypt(trim((string)$_POST['note'])) : trim((string)$_POST['note']);
$stmt = $pdo?->prepare('INSERT INTO children (user_id, gender, birthdate, age_years, encrypted_first_name, note, created_at, updated_at) VALUES (:uid, :gender, :birthdate, :age, :name, :note, NOW(), NOW())');
$stmt?->execute([
'uid' => $userId,
'gender' => $_POST['gender'] ?? 'unknown',
'birthdate' => $_POST['birthdate'] ?: null,
'age' => $_POST['age_years'] ?: null,
'name' => $firstNameEnc,
'note' => $noteEnc,
]);
$info = 'Kind hinzugefügt.';
} elseif ($action === 'event_add' || $action === 'event_update') {
$street = trim((string)($_POST['street'] ?? ''));
$zip = trim((string)($_POST['zip'] ?? ''));
$city = trim((string)($_POST['city'] ?? ''));
$region = trim((string)($_POST['region'] ?? ''));
$lat = isset($_POST['lat']) && $_POST['lat'] !== '' ? (float)$_POST['lat'] : null;
$lng = isset($_POST['lng']) && $_POST['lng'] !== '' ? (float)$_POST['lng'] : null;
$needsGeocode = ($lat === null || $lng === null || $region === '');
if ($needsGeocode) {
[$geoLat, $geoLng, $geoRegion] = self::geocodeAddress($street, $zip, $city, $region);
if ($lat === null) { $lat = $geoLat; }
if ($lng === null) { $lng = $geoLng; }
if ($region === '' && $geoRegion) { $region = $geoRegion; }
}
if ($action === 'event_add') {
$stmt = $pdo?->prepare('INSERT INTO events (created_by, title, teaser_public, description, location_label, street, zip, city, region, lat, lng, starts_at, allow_kids, visibility, status, created_at, updated_at) VALUES (:uid, :title, :teaser, :descr, :loc, :street, :zip, :city, :region, :lat, :lng, :start, :allow, :vis, :status, NOW(), NOW())');
$stmt?->execute([
'uid' => $userId,
'title' => trim((string)$_POST['title']),
'teaser' => trim((string)$_POST['teaser']),
'descr' => trim((string)$_POST['description']),
'loc' => trim((string)$_POST['location_label']),
'street' => $street ?: null,
'zip' => $zip,
'city' => $city,
'region' => $region,
'lat' => $lat,
'lng' => $lng,
'start' => $_POST['starts_at'] ?? null,
'allow' => isset($_POST['allow_kids']) ? 0 : 1,
'vis' => $_POST['visibility'] ?? 'public',
'status' => 'published',
]);
$info = 'Event gespeichert.';
// Punkte für Event-Erstellung vergeben
try {
$cfgPath = dirname(__DIR__, 2) . '/config/community.php';
$communityCfg = file_exists($cfgPath) ? require $cfgPath : [];
$community = new Community($pdo, $communityCfg);
$community->addPoints($userId, 'event', 'create', ['event_id' => $pdo?->lastInsertId()]);
} catch (\Throwable) {
// still continue, points optional
}
} else {
$eventId = (int)($_POST['event_id'] ?? 0);
$stmt = $pdo?->prepare('UPDATE events SET title=:title, teaser_public=:teaser, description=:descr, location_label=:loc, street=:street, zip=:zip, city=:city, region=:region, lat=:lat, lng=:lng, starts_at=:start, allow_kids=:allow, visibility=:vis, updated_at=NOW() WHERE id=:id AND created_by=:uid');
$stmt?->execute([
'id' => $eventId,
'uid' => $userId,
'title' => trim((string)$_POST['title']),
'teaser' => trim((string)$_POST['teaser']),
'descr' => trim((string)$_POST['description']),
'loc' => trim((string)$_POST['location_label']),
'street' => $street ?: null,
'zip' => $zip,
'city' => $city,
'region' => $region,
'lat' => $lat,
'lng' => $lng,
'start' => $_POST['starts_at'] ?? null,
'allow' => isset($_POST['allow_kids']) ? 0 : 1,
'vis' => $_POST['visibility'] ?? 'public',
]);
$info = 'Event aktualisiert.';
}
} elseif ($action === 'event_delete') {
$eventId = (int)($_POST['event_id'] ?? 0);
$stmt = $pdo?->prepare('SELECT id, created_by, status, (SELECT COUNT(*) FROM event_participants ep WHERE ep.event_id = events.id) AS participant_count FROM events WHERE id = :id LIMIT 1');
$stmt?->execute(['id' => $eventId]);
$ev = $stmt?->fetch(\PDO::FETCH_ASSOC);
if (!$ev || (int)$ev['created_by'] !== $userId) {
throw new \RuntimeException('Event nicht gefunden.');
}
if ((int)$ev['participant_count'] > 0) {
throw new \RuntimeException('Event hat Anmeldungen und kann nicht gelöscht werden.');
}
$pdo?->prepare('DELETE FROM events WHERE id = :id')->execute(['id' => $eventId]);
$info = 'Event gelöscht.';
} elseif ($action === 'event_cancel') {
$eventId = (int)($_POST['event_id'] ?? 0);
$stmt = $pdo?->prepare('SELECT id, created_by FROM events WHERE id = :id LIMIT 1');
$stmt?->execute(['id' => $eventId]);
$ev = $stmt?->fetch(\PDO::FETCH_ASSOC);
if (!$ev || (int)$ev['created_by'] !== $userId) {
throw new \RuntimeException('Event nicht gefunden.');
}
$pdo?->prepare('UPDATE events SET status = :st, updated_at = NOW() WHERE id = :id')->execute([
'st' => 'cancelled',
'id' => $eventId,
]);
$info = 'Event wurde abgesagt.';
}
} catch (\Throwable $e) {
$error = $e->getMessage();
}
}
// Daten laden
$profile = [
'display_name' => '',
'first_name' => '',
'last_name' => '',
'zip' => '',
'city' => '',
'profession' => '',
'languages' => '',
'about' => '',
'email' => '',
'contact_phone' => '',
];
$stmt = $pdo?->prepare('SELECT u.email, u.status, p.display_name, p.first_name, p.last_name, p.zip, p.city, p.profession, p.languages, p.about, p.contact_phone FROM users u LEFT JOIN user_profiles p ON p.user_id = u.id WHERE u.id = :id LIMIT 1');
$stmt?->execute(['id' => $userId]);
$row = $stmt?->fetch(\PDO::FETCH_ASSOC);
if ($row) {
$profile = array_merge($profile, array_filter($row, fn($v) => $v !== null));
if ($crypto && !empty($profile['contact_phone'])) {
$profile['contact_phone'] = $crypto->decrypt((string)$profile['contact_phone']) ?: '';
}
}
$children = [];
$stmt = $pdo?->prepare('SELECT id, encrypted_first_name AS first_name, note, gender, birthdate, age_years FROM children WHERE user_id = :id ORDER BY id DESC');
$stmt?->execute(['id' => $userId]);
$childrenRaw = $stmt?->fetchAll(\PDO::FETCH_ASSOC) ?: [];
foreach ($childrenRaw as $c) {
if ($crypto) {
$c['first_name'] = $crypto->decrypt((string)$c['first_name']) ?: '';
$c['note'] = $crypto->decrypt((string)($c['note'] ?? '')) ?: '';
}
$children[] = $c;
}
$eventsUpcoming = [];
$eventsPast = [];
$editEvent = null;
$stmt = $pdo?->prepare(
'SELECT e.id, e.title, e.teaser_public, e.description, e.location_label, e.street, e.zip, e.city, e.region, e.starts_at, e.allow_kids, e.visibility, e.status, e.lat, e.lng,
(SELECT COUNT(*) FROM event_participants ep WHERE ep.event_id = e.id) AS participant_count
FROM events e
WHERE e.created_by = :id AND e.starts_at >= NOW()
ORDER BY e.starts_at ASC'
);
$stmt?->execute(['id' => $userId]);
$eventsUpcoming = $stmt?->fetchAll(\PDO::FETCH_ASSOC) ?: [];
$stmt = $pdo?->prepare(
'SELECT e.id, e.title, e.teaser_public, e.starts_at, e.city, e.visibility, e.status,
(SELECT COUNT(*) FROM event_participants ep WHERE ep.event_id = e.id) AS participant_count
FROM events e
WHERE e.created_by = :id AND e.starts_at < NOW()
ORDER BY e.starts_at DESC'
);
$stmt?->execute(['id' => $userId]);
$eventsPast = $stmt?->fetchAll(\PDO::FETCH_ASSOC) ?: [];
if (isset($_GET['edit_event'])) {
$editId = (int)$_GET['edit_event'];
$stmt = $pdo?->prepare('SELECT * FROM events WHERE id = :id AND created_by = :uid AND starts_at >= NOW() LIMIT 1');
$stmt?->execute(['id' => $editId, 'uid' => $userId]);
$editEvent = $stmt?->fetch(\PDO::FETCH_ASSOC) ?: null;
}
return compact('flash','info','error','profile','children','eventsUpcoming','eventsPast','editEvent');
}
private static function geocodeAddress(?string $street, ?string $zip, ?string $city, ?string $region): array
{
$parts = array_filter([
$street ?: null,
$zip ?: null,
$city ?: null,
$region ?: null,
]);
if (!$parts) {
return [null, null, null];
}
$query = implode(', ', $parts);
$url = 'https://nominatim.openstreetmap.org/search?' . http_build_query([
'format' => 'jsonv2',
'limit' => 1,
'q' => $query,
]);
$ctx = stream_context_create([
'http' => [
'method' => 'GET',
'header' => "User-Agent: papa-kind-treff/1.0\r\nAccept-Language: de\r\n",
'timeout' => 6,
],
]);
$resp = @file_get_contents($url, false, $ctx);
if ($resp === false) {
return [null, null, null];
}
$json = json_decode($resp, true);
if (!is_array($json) || empty($json[0]['lat']) || empty($json[0]['lon'])) {
return [null, null, null];
}
$addr = $json[0]['address'] ?? [];
$regionGuess = $addr['city_district'] ?? $addr['suburb'] ?? $addr['state'] ?? $addr['county'] ?? $addr['region'] ?? $addr['state_district'] ?? null;
return [round((float)$json[0]['lat'], 7), round((float)$json[0]['lon'], 7), $regionGuess];
}
}

50
src/App/App.php Normal file
View File

@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace App;
final class App
{
private static ?self $instance = null;
private Request $request;
private SessionManager $session;
private Assets $assets;
private I18n $i18n;
private Flash $flash;
private ?\PDO $pdo;
private function __construct(private Config $config)
{
$this->request = new Request();
$this->session = new SessionManager($config);
$this->assets = new Assets($config);
$this->i18n = new I18n($config, '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; }
}

44
src/App/Assets.php Normal file
View File

@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace App;
final class Assets
{
private array $styles = [];
private array $scriptsHeader = [];
private array $scriptsFooter = [];
public function __construct(private Config $config) {}
public function addStyle(string $href, string $priority = 'normal', ?string $version = null): void
{
$version ??= $this->config->assetVersion;
$this->styles[] = [
'href' => $href,
'priority' => $priority,
'version' => $version,
];
}
public function addScript(string $src, string $pos = 'footer', bool $defer = true, bool $async = false, ?string $version = null): void
{
$version ??= $this->config->assetVersion;
$row = [
'src' => $src,
'defer' => $defer,
'async' => $async,
'version' => $version,
];
if ($pos === 'header') {
$this->scriptsHeader[] = $row;
} else {
$this->scriptsFooter[] = $row;
}
}
public function styles(): array { return $this->styles; }
public function headerScripts(): array { return $this->scriptsHeader; }
public function footerScripts(): array { return $this->scriptsFooter; }
}

217
src/App/Auth.php Normal file
View File

@@ -0,0 +1,217 @@
<?php
declare(strict_types=1);
namespace App;
final class Auth
{
public function __construct(private App $app) {}
private function pdo(): \PDO
{
$pdo = $this->app->pdo();
if (!$pdo) {
throw new \RuntimeException('Database connection not available.');
}
return $pdo;
}
public function register(string $displayName, string $email, string $password): int
{
$pdo = $this->pdo();
$email = strtolower(trim($email));
$displayName = trim($displayName);
if ($displayName === '' || $email === '' || $password === '') {
throw new \InvalidArgumentException('Display-Name, E-Mail und Passwort sind erforderlich.');
}
$pdo->beginTransaction();
try {
$stmt = $pdo->prepare('SELECT id FROM users WHERE email = :email LIMIT 1');
$stmt->execute(['email' => $email]);
if ($stmt->fetchColumn()) {
throw new \RuntimeException('E-Mail ist bereits registriert.');
}
$hash = password_hash($password, PASSWORD_ARGON2ID);
$stmt = $pdo->prepare('INSERT INTO users (email, password_hash, status, created_at, updated_at) VALUES (:email, :pw, :status, NOW(), NOW())');
$stmt->execute([
'email' => $email,
'pw' => $hash,
'status' => 'pending',
]);
$userId = (int)$pdo->lastInsertId();
$stmt = $pdo->prepare('INSERT INTO user_profiles (user_id, display_name, share_level, children_visibility, created_at, updated_at) VALUES (:uid, :name, :share, :childvis, NOW(), NOW())');
$stmt->execute([
'uid' => $userId,
'name' => $displayName,
'share' => 'basic',
'childvis' => 'hidden',
]);
$pdo->commit();
return $userId;
} catch (\Throwable $e) {
$pdo->rollBack();
throw $e;
}
}
public function createVerifyCode(int $userId, string $email): string
{
$pdo = $this->pdo();
$code = $this->generateCode(6);
$hash = hash('sha256', $code);
$pdo->prepare('DELETE FROM user_tokens WHERE user_id = :uid AND type = :t')->execute(['uid' => $userId, 't' => 'verify']);
$stmt = $pdo->prepare('INSERT INTO user_tokens (user_id, type, code, token_hash, expires_at, created_at) VALUES (:uid, :type, :code, :hash, DATE_ADD(NOW(), INTERVAL 48 HOUR), NOW())');
$stmt->execute([
'uid' => $userId,
'type' => 'verify',
'code' => $code,
'hash' => $hash,
]);
return $code;
}
public function verifyCode(string $email, string $code): int
{
$pdo = $this->pdo();
$email = strtolower(trim($email));
$hash = hash('sha256', $code);
$stmt = $pdo->prepare('SELECT u.id, u.status, t.id AS tid, t.token_hash FROM users u JOIN user_tokens t ON t.user_id = u.id AND t.type = :type WHERE u.email = :email AND (t.used_at IS NULL) AND t.expires_at > NOW() ORDER BY t.expires_at DESC LIMIT 1');
$stmt->execute(['type' => 'verify', 'email' => $email]);
$row = $stmt->fetch(\PDO::FETCH_ASSOC);
if (!$row || !hash_equals((string)$row['token_hash'], $hash)) {
throw new \RuntimeException('Code ist ungültig oder abgelaufen.');
}
$userId = (int)$row['id'];
$tid = (int)$row['tid'];
$pdo->beginTransaction();
try {
$pdo->prepare('UPDATE user_tokens SET used_at = NOW() WHERE id = :id')->execute(['id' => $tid]);
$pdo->prepare('UPDATE users SET status = :st, email_verified_at = NOW() WHERE id = :id')->execute(['st' => 'active', 'id' => $userId]);
$pdo->commit();
} catch (\Throwable $e) {
$pdo->rollBack();
throw $e;
}
return $userId;
}
public function createResetCode(string $email): array
{
$pdo = $this->pdo();
$email = strtolower(trim($email));
$stmt = $pdo->prepare('SELECT u.id, p.display_name FROM users u LEFT JOIN user_profiles p ON p.user_id = u.id WHERE u.email = :email LIMIT 1');
$stmt->execute(['email' => $email]);
$row = $stmt->fetch(\PDO::FETCH_ASSOC);
if (!$row) {
throw new \RuntimeException('E-Mail ist nicht registriert.');
}
$userId = (int)$row['id'];
$displayName = (string)($row['display_name'] ?? $email);
$code = $this->generateCode(6);
$hash = hash('sha256', $code);
$pdo->prepare('DELETE FROM user_tokens WHERE user_id = :uid AND type = :t')->execute(['uid' => $userId, 't' => 'reset']);
$stmt = $pdo->prepare('INSERT INTO user_tokens (user_id, type, code, token_hash, expires_at, created_at) VALUES (:uid, :type, :code, :hash, DATE_ADD(NOW(), INTERVAL 2 HOUR), NOW())');
$stmt->execute([
'uid' => $userId,
'type' => 'reset',
'code' => $code,
'hash' => $hash,
]);
return ['user_id' => $userId, 'code' => $code, 'display_name' => $displayName];
}
public function verifyResetCode(string $email, string $code): int
{
$pdo = $this->pdo();
$email = strtolower(trim($email));
$hash = hash('sha256', $code);
$stmt = $pdo->prepare('SELECT u.id, t.id AS tid, t.token_hash FROM users u JOIN user_tokens t ON t.user_id = u.id AND t.type = :type WHERE u.email = :email AND (t.used_at IS NULL) AND t.expires_at > NOW() ORDER BY t.expires_at DESC LIMIT 1');
$stmt->execute(['type' => 'reset', 'email' => $email]);
$row = $stmt->fetch(\PDO::FETCH_ASSOC);
if (!$row || !hash_equals((string)$row['token_hash'], $hash)) {
throw new \RuntimeException('Code ist ungültig oder abgelaufen.');
}
$userId = (int)$row['id'];
$tid = (int)$row['tid'];
$pdo->beginTransaction();
try {
$pdo->prepare('UPDATE user_tokens SET used_at = NOW() WHERE id = :id')->execute(['id' => $tid]);
$pdo->commit();
} catch (\Throwable $e) {
$pdo->rollBack();
throw $e;
}
return $userId;
}
public function resetPassword(int $userId, string $password): void
{
$pdo = $this->pdo();
if ($password === '' || strlen($password) < 8) {
throw new \InvalidArgumentException('Passwort muss mindestens 8 Zeichen haben.');
}
$hash = password_hash($password, PASSWORD_ARGON2ID);
$stmt = $pdo->prepare('UPDATE users SET password_hash = :pw, status = :status, updated_at = NOW() WHERE id = :id');
$stmt->execute([
'pw' => $hash,
'status' => 'active',
'id' => $userId,
]);
}
private function generateCode(int $len = 6): string
{
$chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
$out = '';
for ($i = 0; $i < $len; $i++) {
$out .= $chars[random_int(0, strlen($chars) - 1)];
}
return $out;
}
public function login(string $email, string $password): array
{
$pdo = $this->pdo();
$email = strtolower(trim($email));
$stmt = $pdo->prepare('SELECT id, password_hash, status FROM users WHERE email = :email LIMIT 1');
$stmt->execute(['email' => $email]);
$row = $stmt->fetch(\PDO::FETCH_ASSOC);
if (!$row) {
throw new \RuntimeException('E-Mail oder Passwort ist falsch.');
}
if (!password_verify($password, (string)$row['password_hash'])) {
throw new \RuntimeException('E-Mail oder Passwort ist falsch.');
}
$userId = (int)$row['id'];
$status = (string)$row['status'];
if ($status === 'active') {
$upd = $pdo->prepare('UPDATE users SET last_login_at = NOW() WHERE id = :id');
$upd->execute(['id' => $userId]);
}
return ['id' => $userId, 'status' => $status];
}
}

196
src/App/Community.php Normal file
View File

@@ -0,0 +1,196 @@
<?php
declare(strict_types=1);
namespace App;
final class Community
{
public function __construct(private \PDO $pdo, private array $config)
{
}
public function createThread(int $userId, string $title, string $body): void
{
$stmt = $this->pdo->prepare('INSERT INTO forum_threads (user_id, title, body) VALUES (:uid, :title, :body)');
$stmt->execute([
':uid' => $userId,
':title' => trim($title),
':body' => trim($body),
]);
}
public function createPost(int $userId, int $threadId, string $body): void
{
$stmt = $this->pdo->prepare('INSERT INTO forum_posts (thread_id, user_id, body) VALUES (:tid, :uid, :body)');
$stmt->execute([
':tid' => $threadId,
':uid' => $userId,
':body' => trim($body),
]);
}
public function searchThreads(string $query, int $limit = 50): array
{
$conditions = [];
$params = [];
$tokens = array_filter(preg_split('/\s+/', trim($query)) ?: [], fn($t) => $t !== '');
$i = 0;
foreach ($tokens as $tok) {
$ph1 = ':t' . $i . 'a';
$ph2 = ':t' . $i . 'b';
$conditions[] = "(ft.title LIKE $ph1 OR ft.body LIKE $ph2)";
$params[$ph1] = '%' . $tok . '%';
$params[$ph2] = '%' . $tok . '%';
$i++;
}
$where = $conditions ? ('AND ' . implode(' AND ', $conditions)) : '';
$sql = "SELECT ft.id, ft.title, ft.body, ft.created_at,
u.id as uid, u.created_at as user_created,
p.display_name,
(SELECT COUNT(*) FROM forum_posts fp WHERE fp.thread_id = ft.id) AS answers,
(SELECT COUNT(*) FROM forum_posts fp2 WHERE fp2.user_id = u.id) +
(SELECT COUNT(*) FROM forum_threads ft2 WHERE ft2.user_id = u.id) AS user_posts
FROM forum_threads ft
JOIN users u ON u.id = ft.user_id
LEFT JOIN user_profiles p ON p.user_id = u.id
WHERE 1=1 $where
ORDER BY ft.created_at DESC
LIMIT :lim";
$stmt = $this->pdo->prepare($sql);
foreach ($params as $k => $v) {
$stmt->bindValue($k, $v, \PDO::PARAM_STR);
}
$stmt->bindValue(':lim', $limit, \PDO::PARAM_INT);
$stmt->execute();
return $stmt->fetchAll(\PDO::FETCH_ASSOC) ?: [];
}
public function listThreads(int $limit = 50): array
{
return $this->searchThreads('', $limit);
}
public function getThread(int $id): ?array
{
$stmt = $this->pdo->prepare('SELECT ft.*, p.display_name FROM forum_threads ft LEFT JOIN user_profiles p ON p.user_id = ft.user_id WHERE ft.id = :id');
$stmt->execute([':id' => $id]);
$row = $stmt->fetch(\PDO::FETCH_ASSOC);
return $row ?: null;
}
public function listPosts(int $threadId): array
{
$stmt = $this->pdo->prepare('SELECT fp.*, p.display_name FROM forum_posts fp LEFT JOIN user_profiles p ON p.user_id = fp.user_id WHERE fp.thread_id = :id ORDER BY fp.created_at ASC');
$stmt->execute([':id' => $threadId]);
return $stmt->fetchAll(\PDO::FETCH_ASSOC) ?: [];
}
public function computePoints(int $userId): float
{
// Primär: aggregierte Werte aus user_points_totals, Fallback: Summe aus user_points
$stmt = $this->pdo->prepare('SELECT total FROM user_points_totals WHERE user_id = :uid');
$stmt->execute([':uid' => $userId]);
$total = $stmt->fetchColumn();
if ($total !== false && $total !== null) {
return (float)$total;
}
$stmt = $this->pdo->prepare('SELECT COALESCE(SUM(amount),0) FROM user_points WHERE user_id = :uid');
$stmt->execute([':uid' => $userId]);
return (float)$stmt->fetchColumn();
}
/**
* Vergibt Punkte persistent und berücksichtigt Caps/Bonis gemäß config actions.
*/
public function addPoints(int $userId, string $group, string $key, array $meta = []): float
{
$actions = $this->config['actions'][$group][$key] ?? null;
if (!$actions || empty($actions['points'])) {
return 0.0;
}
$basePoints = (float)$actions['points'];
// Boni (einfacher first-Check)
$bonusPoints = 0.0;
if (!empty($actions['bonuses'])) {
if (isset($actions['bonuses']['first'])) {
$bonusPoints += (float)$actions['bonuses']['first'];
}
if (isset($actions['bonuses']['first_helpful_5']) && isset($meta['helpful_count']) && (int)$meta['helpful_count'] >= 5) {
$bonusPoints += (float)$actions['bonuses']['first_helpful_5'];
}
}
$amount = $basePoints + $bonusPoints;
if ($amount <= 0) {
return 0.0;
}
$caps = $actions['caps'] ?? [];
$capDaily = $caps['daily'] ?? null;
$capTotal = $caps['total'] ?? null;
$todayStart = (new \DateTimeImmutable('today'))->format('Y-m-d 00:00:00');
$todayEnd = (new \DateTimeImmutable('today'))->format('Y-m-d 23:59:59');
$actionKey = $group . '.' . $key;
if ($capDaily !== null) {
$stmt = $this->pdo->prepare("SELECT COALESCE(SUM(amount),0) FROM user_points WHERE user_id = :uid AND action = :action AND created_at BETWEEN :s AND :e");
$stmt->execute([
':uid' => $userId,
':action' => $actionKey,
':s' => $todayStart,
':e' => $todayEnd,
]);
$usedToday = (float)$stmt->fetchColumn();
$remaining = max(0.0, (float)$capDaily - $usedToday);
if ($remaining <= 0) {
return 0.0;
}
$amount = min($amount, $remaining);
}
if ($capTotal !== null) {
$stmt = $this->pdo->prepare("SELECT COALESCE(SUM(amount),0) FROM user_points WHERE user_id = :uid AND action = :action");
$stmt->execute([':uid' => $userId, ':action' => $actionKey]);
$usedTotal = (float)$stmt->fetchColumn();
$remaining = max(0.0, (float)$capTotal - $usedTotal);
if ($remaining <= 0) {
return 0.0;
}
$amount = min($amount, $remaining);
}
$stmt = $this->pdo->prepare('INSERT INTO user_points (user_id, action, amount, meta) VALUES (:uid, :action, :amount, :meta)');
$stmt->execute([
':uid' => $userId,
':action' => $actionKey,
':amount' => $amount,
':meta' => $meta ? json_encode($meta) : null,
]);
$stmt = $this->pdo->prepare('INSERT INTO user_points_totals (user_id, total) VALUES (:uid, :amt) ON DUPLICATE KEY UPDATE total = total + VALUES(total)');
$stmt->execute([':uid' => $userId, ':amt' => $amount]);
return $amount;
}
public function membershipLevel(float $points): array
{
$levels = $this->config['levels'] ?? [];
usort($levels, fn($a,$b) => ($b['min'] ?? 0) <=> ($a['min'] ?? 0));
foreach ($levels as $lvl) {
if ($points >= (float)($lvl['min'] ?? 0)) {
return [
'label' => $lvl['label'] ?? 'New Daddy',
'icon' => $lvl['icon'] ?? '',
];
}
}
$fallback = $levels ? $levels[count($levels)-1] : ['label' => 'New Daddy','icon' => ''];
return ['label' => $fallback['label'], 'icon' => $fallback['icon'] ?? ''];
}
}

62
src/App/Config.php Normal file
View File

@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace App;
final class Config
{
public function __construct(
public readonly string $env,
public readonly string $prefix,
public readonly string $primaryDomain,
public readonly string $primaryUrl,
public readonly string $apiBase,
public readonly string $assetVersion,
public readonly bool $dbEnabled,
public readonly array $db,
) {}
public static function fromPhpConstants(string $configDir): self
{
// config.php defines these constants.
$env = defined('APP_ENV') ? (string) APP_ENV : 'prod';
$prefix = defined('APP_PREFIX') ? (string) APP_PREFIX : 'app';
$primaryDom = defined('APP_DOMAIN_PRIMARY') ? (string) APP_DOMAIN_PRIMARY : 'example.test';
$primaryUrl = defined('APP_URL_PRIMARY') ? (string) APP_URL_PRIMARY : 'https://example.test';
$apiBase = defined('APP_API_BASE') ? (string) APP_API_BASE : ($primaryUrl . '/api');
$assetVersion = defined('ASSET_VERSION') ? (string) ASSET_VERSION : '';
$dbEnabled = defined('APP_DB_ENABLED') ? (bool) APP_DB_ENABLED : false;
$dbFileRoot = rtrim($configDir, '/\\') . DIRECTORY_SEPARATOR . 'db.php';
$dbFileEnv = rtrim($configDir, '/\\') . DIRECTORY_SEPARATOR . $env . DIRECTORY_SEPARATOR . 'db.php';
$dbFile = file_exists($dbFileRoot) ? $dbFileRoot : (file_exists($dbFileEnv) ? $dbFileEnv : null);
$db = $dbFile ? (array) require $dbFile : [];
return new self(
env: $env,
prefix: $prefix,
primaryDomain: $primaryDom,
primaryUrl: rtrim($primaryUrl, '/'),
apiBase: rtrim($apiBase, '/'),
assetVersion: $assetVersion,
dbEnabled: $dbEnabled,
db: $db
);
}
public function cookiePrefix(): string
{
// Example: add suffix for staging
if ($this->env === 'staging') {
return $this->prefix . '_stg_';
}
return $this->prefix . '_';
}
public function cookieDomain(): string
{
// Leading dot for subdomain-wide cookies
return '.' . ltrim($this->primaryDomain, '.');
}
}

68
src/App/Crypto.php Normal file
View File

@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace App;
final class Crypto
{
private string $key;
public function __construct(Config $config)
{
if (!extension_loaded('sodium')) {
throw new \RuntimeException('libsodium extension not available');
}
$raw = getenv('DATA_KEY') ?: '';
$raw = trim($raw);
if ($raw === '') {
throw new \RuntimeException('DATA_KEY env not set');
}
// base64?
if (str_starts_with($raw, 'base64:')) {
$raw = substr($raw, 7);
}
$decoded = base64_decode($raw, true);
if ($decoded !== false && strlen($decoded) >= SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_KEYBYTES) {
$raw = $decoded;
} elseif (ctype_xdigit($raw) && strlen($raw) >= SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_KEYBYTES * 2) {
$raw = hex2bin($raw);
}
if (strlen($raw) < SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_KEYBYTES) {
throw new \RuntimeException('DATA_KEY invalid length');
}
$this->key = substr($raw, 0, SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_KEYBYTES);
}
public function encrypt(string $plaintext): string
{
if ($plaintext === '') {
return '';
}
$nonce = random_bytes(SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES);
$cipher = sodium_crypto_aead_xchacha20poly1305_ietf_encrypt($plaintext, '', $nonce, $this->key);
return base64_encode($nonce . $cipher);
}
public function decrypt(?string $blob): string
{
if ($blob === null || $blob === '') {
return '';
}
$raw = base64_decode($blob, true);
if ($raw === false || strlen($raw) <= SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES) {
return '';
}
$nonce = substr($raw, 0, SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES);
$cipher = substr($raw, SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES);
try {
$plain = sodium_crypto_aead_xchacha20poly1305_ietf_decrypt($cipher, '', $nonce, $this->key);
return $plain === false ? '' : $plain;
} catch (\Throwable) {
return '';
}
}
}

123
src/App/Database.php Normal file
View File

@@ -0,0 +1,123 @@
<?php
declare(strict_types=1);
namespace App;
final class Database
{
public static function createPdo(Config $config): ?\PDO
{
if (!$config->dbEnabled) {
return null;
}
$db = $config->db;
$driver = (string)($db['driver'] ?? '');
if ($driver === '') {
throw new \RuntimeException('DB enabled but config/db.php missing "driver"');
}
$dsn = match ($driver) {
'mysql' => self::buildMysqlDsn($db),
'pgsql' => self::buildPgsqlDsn($db),
'sqlite' => self::buildSqliteDsn($db),
default => throw new \RuntimeException('Unsupported PDO driver: ' . $driver),
};
try {
$pdo = new \PDO(
$dsn,
// sqlite braucht user/pass nicht, PDO ignoriert es aber; wir geben leer zurück
(string)($db['user'] ?? ''),
(string)($db['password'] ?? ''),
(array)($db['options'] ?? [])
);
// Optional: PostgreSQL schema/search_path setzen
if ($driver === 'pgsql' && !empty($db['schema'])) {
// Minimaler Schutz gegen Injection über schema
$schema = preg_replace('/[^a-zA-Z0-9_]/', '', (string)$db['schema']);
if ($schema !== '') {
$pdo->exec('SET search_path TO ' . $schema);
}
}
return $pdo;
} catch (\PDOException $e) {
// In Prod würdest du loggen; hier minimal
http_response_code(500);
echo 'Database connection error.';
exit;
}
}
private static function buildMysqlDsn(array $db): string
{
if (empty($db['dbname'])) {
throw new \RuntimeException('MySQL config missing "dbname"');
}
$charset = (string)($db['charset'] ?? 'utf8mb4');
// Unix socket takes precedence
if (!empty($db['unix_socket'])) {
return sprintf(
'mysql:unix_socket=%s;dbname=%s;charset=%s',
(string)$db['unix_socket'],
(string)$db['dbname'],
$charset
);
}
$host = (string)($db['host'] ?? 'localhost');
$port = (int)($db['port'] ?? 3306);
return sprintf(
'mysql:host=%s;port=%d;dbname=%s;charset=%s',
$host,
$port,
(string)$db['dbname'],
$charset
);
}
private static function buildPgsqlDsn(array $db): string
{
if (empty($db['dbname'])) {
throw new \RuntimeException('PostgreSQL config missing "dbname"');
}
$host = (string)($db['host'] ?? 'localhost');
$port = (int)($db['port'] ?? 5432);
// Hinweis: charset gehört bei pgsql nicht in den DSN
return sprintf(
'pgsql:host=%s;port=%d;dbname=%s',
$host,
$port,
(string)$db['dbname']
);
}
private static function buildSqliteDsn(array $db): string
{
// SQLite kann :memory: oder einen Pfad nutzen
$path = (string)($db['path'] ?? '');
if ($path === '') {
// Default: Memory-DB
$path = ':memory:';
}
// Wenn es ein Pfad ist, stelle sicher, dass das Verzeichnis existiert.
if ($path !== ':memory:') {
$dir = \dirname($path);
if ($dir && !is_dir($dir)) {
@mkdir($dir, 0775, true);
}
}
return 'sqlite:' . $path;
}
}

33
src/App/Flash.php Normal file
View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace App;
final class Flash
{
public function __construct(private SessionManager $session) {}
public function set(string $type, string $message): void
{
$this->session->start();
$_SESSION['flash'] = [
'type' => $type,
'message' => $message,
];
}
public function get(): ?array
{
$this->session->start();
if (empty($_SESSION['flash']) || !is_array($_SESSION['flash'])) {
return null;
}
$f = $_SESSION['flash'];
unset($_SESSION['flash']);
return [
'type' => (string)($f['type'] ?? 'info'),
'message' => (string)($f['message'] ?? ''),
];
}
}

59
src/App/I18n.php Normal file
View File

@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace App;
final class I18n
{
private array $current = [];
private array $fallback = [];
public function __construct(private Config $config, private string $lang = 'en')
{
// Minimal example translations (normally load JSON/PHP arrays from disk)
$this->fallback = [
'common' => [
'title' => '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;
}
}

352
src/App/Mailer.php Normal file
View File

@@ -0,0 +1,352 @@
<?php
declare(strict_types=1);
namespace App;
final class Mailer
{
private string $logFile;
private bool $logCleared = false;
public function __construct(private App $app)
{
$base = dirname(__DIR__, 2);
$this->logFile = $base . '/debug/mailer_debug.log';
}
private function log(string $msg, array $ctx = []): void
{
if (!defined('APP_DEBUG') || APP_DEBUG !== true) {
return;
}
$line = '[' . date('Y-m-d H:i:s') . '] ' . $msg;
if ($ctx) {
$line .= ' ' . json_encode($ctx, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
}
$line .= "\n";
$dir = dirname($this->logFile);
if (!is_dir($dir)) {
@mkdir($dir, 0775, true);
}
// For clarity keep only the latest run in the log: truncate once per request
if ($this->logCleared === false) {
@file_put_contents($this->logFile, '');
$this->logCleared = true;
}
@file_put_contents($this->logFile, $line, FILE_APPEND);
}
private function templates(): array
{
$env = $this->app->config()->env;
$root = __DIR__ . '/../../config/emailtemplates.php';
$envPath = __DIR__ . "/../../config/{$env}/emailtemplates.php";
$file = is_file($root) ? $root : $envPath;
$emailtemplates = [];
if (is_file($file)) {
/** @noinspection PhpIncludeInspection */
include $file; // populates $emailtemplates variable from included file
}
return is_array($emailtemplates ?? null) ? $emailtemplates : [];
}
private function renderTemplate(string $key, array $vars): array
{
$templates = $this->templates();
$id = $templates[$key] ?? $key;
$this->log('template_resolved_id', ['key' => $key, 'id' => $id]);
$apiBase = getenv('EMAILTEMPLATE_API_BASE') ?: '';
$apiToken = getenv('EMAILTEMPLATE_API_TOKEN') ?: '';
if ($apiBase && $apiToken) {
$payload = [
'template' => $id,
'placeholders' => $vars,
];
$payload['token'] = $apiToken;
$payloadForLog = $payload;
$payloadForLog['token'] = '[hidden length ' . strlen((string)$apiToken) . ']';
$this->log('template_api_request_payload', [
'url' => $apiBase,
'payload' => $payloadForLog,
]);
$this->log('template_api_request', ['template' => $id, 'placeholders' => array_keys($vars)]);
$ctx = stream_context_create([
'http' => [
'method' => 'POST',
'header' => "Content-Type: application/json\r\n",
'timeout' => 15,
'content' => json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
],
]);
$resp = @file_get_contents($apiBase, false, $ctx);
if ($resp !== false) {
$status = null;
if (isset($http_response_header) && is_array($http_response_header)) {
foreach ($http_response_header as $hdr) {
if (preg_match('~^HTTP/\\S+\\s+(\\d+)~i', $hdr, $m)) {
$status = (int)$m[1];
break;
}
}
}
$this->log('template_api_response_raw', [
'status' => $status,
'body' => $resp,
]);
$decoded = json_decode($resp, true);
if (is_array($decoded) && !empty($decoded['ok']) && !empty($decoded['html'])) {
$this->log('template_api_success', ['template' => $id, 'subject' => $decoded['subject'] ?? null, 'html_len' => strlen((string)$decoded['html'])]);
return [
'id' => $id,
'subject' => $decoded['subject'] ?? '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();
}
}

47
src/App/Request.php Normal file
View File

@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App;
final class Request
{
public function scheme(): string
{
// Proxy / LB
if (!empty($_SERVER['HTTP_X_FORWARDED_PROTO'])) {
$proto = strtolower((string)$_SERVER['HTTP_X_FORWARDED_PROTO']);
if ($proto === 'https' || $proto === 'http') {
return $proto;
}
}
if (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') {
return 'https';
}
return 'http';
}
public function host(): string
{
return (string)($_SERVER['HTTP_HOST'] ?? 'localhost');
}
public function baseUrl(): string
{
return $this->scheme() . '://' . $this->host();
}
public function path(): string
{
return (string) strtok((string)($_SERVER['REQUEST_URI'] ?? '/'), '?');
}
public function currentUrl(bool $withQuery = true): string
{
$base = $this->baseUrl();
$uri = (string)($_SERVER['REQUEST_URI'] ?? '/');
if ($withQuery) {
return $base . $uri;
}
return $base . (string) strtok($uri, '?');
}
}

241
src/App/Search.php Normal file
View File

@@ -0,0 +1,241 @@
<?php
declare(strict_types=1);
namespace App;
final class Search
{
public function __construct(private ?\PDO $pdo) {}
public function searchEvents(string $query, int $limit = 100, ?array $geo = null): array
{
if (!$this->pdo) return [];
$q = trim($query);
$hasGeo = isset($geo['lat'], $geo['lng']) && is_numeric($geo['lat']) && is_numeric($geo['lng']);
if ($q === '' && !$hasGeo) return [];
$tokens = array_filter(preg_split('/\s+/', $q) ?: [], fn($t) => $t !== '');
if (!$tokens) {
$tokens = [$q];
}
// Nur Tokens ab 3 Zeichen für fuzzy/LIKE berücksichtigen
$tokens = array_values(array_filter($tokens, fn($t) => mb_strlen($t) >= 3));
if (!$tokens && !$hasGeo) return [];
$conditions = [];
$bindTokens = [];
$i = 0;
foreach ($tokens as $tok) {
$tok = trim($tok);
if ($tok === '') continue;
// LIKE + phonetic (SOUNDEX) to allow partial and typo-tolerant matches
$conditions[] = "(title LIKE CONCAT('%', ?, '%') OR teaser_public LIKE CONCAT('%', ?, '%') OR description LIKE CONCAT('%', ?, '%') OR city LIKE CONCAT('%', ?, '%') OR region LIKE CONCAT('%', ?, '%') OR zip LIKE CONCAT('%', ?, '%') OR SOUNDEX(title)=SOUNDEX(?) OR SOUNDEX(teaser_public)=SOUNDEX(?) OR SOUNDEX(description)=SOUNDEX(?) OR SOUNDEX(city)=SOUNDEX(?) OR SOUNDEX(region)=SOUNDEX(?))";
// LIKE bindings
$bindTokens[] = $tok;
$bindTokens[] = $tok;
$bindTokens[] = $tok;
$bindTokens[] = $tok;
$bindTokens[] = $tok;
$bindTokens[] = $tok;
// SOUNDEX bindings
$bindTokens[] = $tok;
$bindTokens[] = $tok;
$bindTokens[] = $tok;
$bindTokens[] = $tok;
$bindTokens[] = $tok;
$i++;
}
$whereParts = [
"starts_at >= NOW()",
"status != 'cancelled'",
];
if ($conditions) {
// "OR" so that partial matches across tokens are allowed
$whereParts[] = '(' . implode(' OR ', $conditions) . ')';
}
$distanceFiltering = false;
$bind = [];
if ($hasGeo) {
$sql = "SELECT id, title, teaser_public, description, city, region, zip, starts_at, visibility, allow_kids, location_label, lat, lng,
(6371 * ACOS(LEAST(1,
COS(RADIANS(?)) * COS(RADIANS(lat)) * COS(RADIANS(lng) - RADIANS(?)) +
SIN(RADIANS(?)) * SIN(RADIANS(lat))
))) AS distance_km";
$lat = (float)$geo['lat'];
$lng = (float)$geo['lng'];
$radius = isset($geo['radius']) && is_numeric($geo['radius']) ? max(0.1, (float)$geo['radius']) : 5.0;
$distanceFiltering = true;
$latRange = $radius / 111.0;
$lngRange = $radius / (111.0 * max(0.1, cos($lat * M_PI / 180)));
$whereParts[] = "(lat IS NOT NULL AND lng IS NOT NULL)";
$whereParts[] = "(lat BETWEEN ? AND ?)";
$whereParts[] = "(lng BETWEEN ? AND ?)";
// Haversine params (order must match SQL): first three
$bind[] = $lat; // COS(RADIANS(?))
$bind[] = $lng; // COS(RADIANS(lng) - RADIANS(?))
$bind[] = $lat; // SIN(RADIANS(?))
// THEN token binds
$bind = array_merge($bind, $bindTokens);
// Bounding box
$bind[] = $lat - $latRange;
$bind[] = $lat + $latRange;
$bind[] = $lng - $lngRange;
$bind[] = $lng + $lngRange;
// Radius for HAVING
$bind[] = $radius;
} else {
$sql = "SELECT id, title, teaser_public, description, city, region, zip, starts_at, visibility, allow_kids, location_label, lat, lng, NULL AS distance_km";
$bind = $bindTokens;
}
$where = $whereParts ? ('WHERE ' . implode(' AND ', $whereParts)) : '';
$sql .= " FROM events $where";
if ($distanceFiltering) {
$sql .= " HAVING distance_km <= ?";
$sql .= " ORDER BY distance_km ASC, starts_at ASC";
} else {
$sql .= " ORDER BY starts_at ASC";
}
$limit = (int)$limit;
$sql .= " LIMIT {$limit}";
$stmt = $this->pdo->prepare($sql);
try {
$stmt->execute($bind);
$rows = $stmt->fetchAll(\PDO::FETCH_ASSOC) ?: [];
if (!$hasGeo) {
foreach ($rows as &$r) {
unset($r['distance_km']);
}
unset($r);
}
// Fuzzy filter: allow slight typos (Levenshtein <= 1 or 2)
if ($tokens) {
$rows = array_values(array_filter($rows, function ($row) use ($tokens) {
$haystack = strtolower(
($row['title'] ?? '') . ' ' .
($row['teaser_public'] ?? '') . ' ' .
($row['description'] ?? '') . ' ' .
($row['city'] ?? '') . ' ' .
($row['region'] ?? '')
);
$words = preg_split('/[^a-z0-9äöüß]+/i', $haystack) ?: [];
foreach ($tokens as $tok) {
$t = strtolower($tok);
if ($t === '') continue;
if (str_contains($haystack, $t)) {
return true;
}
foreach ($words as $w) {
if ($w === '') continue;
$dist = levenshtein($t, $w);
if ($dist <= 1 || ($dist <= 2 && max(strlen($t), strlen($w)) > 4)) {
return true;
}
}
}
return false;
}));
}
// Fallback: wenn keine Treffer, erneut ohne Token-Filter laden und nur fuzzy filtern
if (!$rows && $tokens) {
$wherePartsFallback = [
"starts_at >= NOW()",
"status != 'cancelled'",
];
$bindFb = [];
$sqlFb = '';
if ($hasGeo) {
$sqlFb = "SELECT id, title, teaser_public, description, city, region, zip, starts_at, visibility, allow_kids, location_label, lat, lng,
(6371 * ACOS(LEAST(1,
COS(RADIANS(?)) * COS(RADIANS(lat)) * COS(RADIANS(lng) - RADIANS(?)) +
SIN(RADIANS(?)) * SIN(RADIANS(lat))
))) AS distance_km";
$lat = (float)$geo['lat'];
$lng = (float)$geo['lng'];
$radius = isset($geo['radius']) && is_numeric($geo['radius']) ? max(0.1, (float)$geo['radius']) : 5.0;
$latRange = $radius / 111.0;
$lngRange = $radius / (111.0 * max(0.1, cos($lat * M_PI / 180)));
$wherePartsFallback[] = "(lat IS NOT NULL AND lng IS NOT NULL)";
$wherePartsFallback[] = "(lat BETWEEN ? AND ?)";
$wherePartsFallback[] = "(lng BETWEEN ? AND ?)";
$bindFb[] = $lat;
$bindFb[] = $lng;
$bindFb[] = $lat;
$bindFb[] = $lat - $latRange;
$bindFb[] = $lat + $latRange;
$bindFb[] = $lng - $lngRange;
$bindFb[] = $lng + $lngRange;
$bindFb[] = $radius;
$havingFb = true;
} else {
$sqlFb = "SELECT id, title, teaser_public, description, city, region, zip, starts_at, visibility, allow_kids, location_label, lat, lng, 1 AS distance_km";
$havingFb = false;
}
$whereFb = $wherePartsFallback ? ('WHERE ' . implode(' AND ', $wherePartsFallback)) : '';
$sqlFb .= " FROM events $whereFb";
if ($havingFb) {
$sqlFb .= " HAVING distance_km <= ?";
$sqlFb .= " ORDER BY distance_km ASC, starts_at ASC";
} else {
$sqlFb .= " ORDER BY starts_at ASC";
}
$sqlFb .= " LIMIT {$limit}";
$stmtFb = $this->pdo->prepare($sqlFb);
$stmtFb->execute($bindFb);
$rowsFb = $stmtFb->fetchAll(\PDO::FETCH_ASSOC) ?: [];
if ($rowsFb) {
$rows = array_values(array_filter($rowsFb, function ($row) use ($tokens) {
$haystack = strtolower(
($row['title'] ?? '') . ' ' .
($row['teaser_public'] ?? '') . ' ' .
($row['description'] ?? '') . ' ' .
($row['city'] ?? '') . ' ' .
($row['region'] ?? '')
);
$words = preg_split('/[^a-z0-9äöüß]+/i', $haystack) ?: [];
foreach ($tokens as $tok) {
$t = strtolower($tok);
if ($t === '') continue;
if (str_contains($haystack, $t)) {
return true;
}
foreach ($words as $w) {
if ($w === '') continue;
$dist = levenshtein($t, $w);
if ($dist <= 1 || ($dist <= 2 && max(strlen($t), strlen($w)) > 4)) {
return true;
}
}
}
return false;
}));
}
}
if (defined('APP_ENV') && APP_ENV === 'staging') {
$logOk = [
'status' => 'ok',
'sql' => $sql,
'bind' => $bind,
'count' => count($rows),
'fallback' => ($rows ? 'primary' : 'fallback'),
];
@file_put_contents(__DIR__ . '/../../debug/search_debug.log', print_r($logOk, true));
}
return $rows;
} catch (\PDOException $e) {
// Log into /debug/search_debug.log and continue with empty results
$logErr = [
'error' => $e->getMessage(),
'sql' => $sql,
'bind' => $bind,
];
@file_put_contents(__DIR__ . '/../../debug/search_debug.log', print_r($logErr, true));
return [];
}
}
}

View File

@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace App;
final class SessionManager
{
private string $sessionCookieName;
private string $clientCookieName;
public function __construct(private Config $config)
{
$prefix = $config->cookiePrefix();
$this->sessionCookieName = $prefix . 'session';
$this->clientCookieName = $prefix . 'client';
}
public function start(): void
{
if (PHP_SAPI === 'cli') {
return;
}
if (session_status() !== PHP_SESSION_NONE) {
return;
}
session_name($this->sessionCookieName);
$secure = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off')
|| (isset($_SERVER['HTTP_X_FORWARDED_PROTO']) && $_SERVER['HTTP_X_FORWARDED_PROTO'] === 'https');
session_set_cookie_params([
'lifetime' => 0,
'path' => '/',
'domain' => $this->config->cookieDomain(),
'secure' => $secure,
'httponly' => true,
'samesite' => 'Lax',
]);
session_start();
}
public function ensureClientId(int $lifetimeSeconds = 31536000): string
{
if (PHP_SAPI === 'cli') {
return 'cli';
}
$id = $_COOKIE[$this->clientCookieName] ?? null;
if (!is_string($id) || !preg_match('/^[a-f0-9]{64}$/', $id)) {
$id = bin2hex(random_bytes(32));
$secure = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off')
|| (isset($_SERVER['HTTP_X_FORWARDED_PROTO']) && $_SERVER['HTTP_X_FORWARDED_PROTO'] === 'https');
setcookie($this->clientCookieName, $id, [
'expires' => time() + $lifetimeSeconds,
'path' => '/',
'domain' => $this->config->cookieDomain(),
'secure' => $secure,
'httponly' => false, // accessible to JS if needed
'samesite' => 'Lax',
]);
$_COOKIE[$this->clientCookieName] = $id;
}
return $id;
}
}

View File

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

View File

@@ -1,10 +0,0 @@
<?php
// inc/config.php
// Diese Datei außerhalb des Webroots ablegen!
return [
'db_host' => 'localhost',
'db_name' => 'd0453540',
'db_user' => 'd0453540',
'db_pass' => 'P6jGRrSaX8QSiBMEJBL7',
'db_charset' => 'utf8mb4',
];

103
src/helpers.php Normal file
View File

@@ -0,0 +1,103 @@
<?php
declare(strict_types=1);
use App\App;
function app(): App
{
return App::get();
}
function t(string $key, $default = '', array $vars = []): string
{
return app()->i18n()->get($key, $default, $vars);
}
function tpl(string $file, string $type = 'structure', string $site = 'main'): void
{
$base = __DIR__ . '/../partials/';
// very small validation
foreach ([$file, $type, $site] as $v) {
if (preg_match('/[^a-zA-Z0-9_\-]/', $v)) {
echo "<!-- tpl(): invalid parameter -->";
return;
}
}
if ($type === 'landing') {
$path = $base . "landing/$site/$file.php";
} else {
$path = $base . "structure/$file.php";
}
if (file_exists($path)) {
include $path;
} else {
echo "<!-- tpl(): not found: $path -->";
}
}
function app_primary_domain(): string
{
if (defined('APP_DOMAIN_PRIMARY')) {
return APP_DOMAIN_PRIMARY;
}
if (defined('APP_DOMAIN_NAME')) {
return APP_DOMAIN_NAME;
}
return $_SERVER['HTTP_HOST'] ?? '';
}
function app_fakecheck_domain(): string
{
if (defined('APP_DOMAIN_FAKECHECK')) {
return APP_DOMAIN_FAKECHECK;
}
return app_primary_domain();
}
function asset_styles(): void
{
$styles = app()->assets()->styles();
// simple priority order
$order = ['early' => 0, 'normal' => 1, 'late' => 2];
usort($styles, fn($a,$b) => ($order[$a['priority']] ?? 1) <=> ($order[$b['priority']] ?? 1));
foreach ($styles as $s) {
$href = $s['href'];
$v = $s['version'];
if ($v !== null && $v !== '') {
$sep = (str_contains($href, '?') ? '&' : '?');
$href = $href . $sep . 'v=' . rawurlencode((string)$v);
}
echo '<link rel="stylesheet" href="' . htmlspecialchars($href, ENT_QUOTES) . '">' . "\n";
}
}
function asset_scripts(string $pos = 'footer'): void
{
$scripts = ($pos === 'header') ? app()->assets()->headerScripts() : app()->assets()->footerScripts();
foreach ($scripts as $s) {
$src = $s['src'];
$v = $s['version'];
if ($v !== null && $v !== '') {
$sep = (str_contains($src, '?') ? '&' : '?');
$src = $src . $sep . 'v=' . rawurlencode((string)$v);
}
$attrs = '';
if (!empty($s['defer'])) $attrs .= ' defer';
if (!empty($s['async'])) $attrs .= ' async';
echo '<script src="' . htmlspecialchars($src, ENT_QUOTES) . '"' . $attrs . '></script>' . "\n";
}
}
function redirect(string $path): void
{
header('Location: ' . $path, true, 303);
exit;
}

View File

@@ -1 +0,0 @@