Compare commits

..

10 Commits

Author SHA1 Message Date
e83925ba64 Nexus upgrade design and refresh 2026-04-11 01:23:28 +02:00
9d5bb2d3cf asdasd 2026-03-19 02:01:57 +01:00
f55667ba83 asd 2026-03-19 02:01:54 +01:00
291ce9f0c7 nexus... 2026-03-19 00:13:30 +01:00
8de05d5552 pi hole setup 2026-03-09 02:24:29 +01:00
c144d9bcd2 pi hole settings 2026-03-09 02:08:40 +01:00
b999c2cf15 modulcreation 2026-03-09 02:01:31 +01:00
5687fbb21b pi hole update 2026-03-09 02:00:04 +01:00
4adb50dc57 Update pihole module 2026-03-09 01:44:57 +01:00
c77b4b3ea7 Modul pihole 2026-03-09 01:32:39 +01:00
68 changed files with 16350 additions and 60 deletions

View File

@@ -0,0 +1,23 @@
Neues Projekt auf Basis dieser Vorlage erstellen.
Dabei sind die Dateien `GENERAL.md` und `BASE_FILES.md` zwingend vollstaendig zu beachten.
Ohne Beachtung dieser beiden Dateien darf keine Projekterstellung erfolgen.
Vor der Erstellung muessen die beiden Domains gesetzt werden:
- Live-Domain: <LIVE_DOMAIN>
- Staging-Domain: <STAGING_DOMAIN>
Wichtige Pflichtregel:
- Wenn eine der beiden Domains fehlt, muss die Erstellung blockiert werden.
- Wenn einer der Platzhalter `<LIVE_DOMAIN>` oder `<STAGING_DOMAIN>` noch unveraendert vorhanden ist, muss die Erstellung ebenfalls blockiert werden.
- Eine Ausfuehrung ist nur erlaubt, wenn beide Platzhalter durch echte Domainnamen ersetzt wurden.
Weitere Pflichtregeln:
- Alle bestehenden Dateien und Ordner sind vor der Neuerstellung zu loeschen.
- Die Datei `.gitlab-ci.yml` ist die einzige Ausnahme und muss erhalten bleiben.
- Falls `.gitlab-ci.yml` Domainreferenzen, URLs oder projektspezifische Angaben enthaelt, muessen diese auf das neue Projekt angepasst werden.
Danach ist das neue Projekt exakt nach den Vorgaben aus `GENERAL.md` und `BASE_FILES.md` zu erzeugen.

445
BASE_FILES.md Normal file
View File

@@ -0,0 +1,445 @@
# Basisdateien fuer neue Projekte
Dieses Dokument enthaelt die Basisdateien, die bei einem neuen Projekt direkt angelegt werden sollen. Der Schwerpunkt liegt auf den Config-Dateien, weil diese fuer den ersten lauffaehigen Stand zwingend benoetigt werden.
Dieses Dokument ist dafuer gedacht, zusammen mit `GENERAL.md` in ein neues GitLab-Projekt kopiert zu werden. Danach kann die Projektstruktur inklusive Basisdateien direkt erstellt werden.
## Wichtige Regel vor der Erstellung
Ein neues Projekt darf nur erstellt werden, wenn beide Domains bekannt sind:
- Live-Domain
- Staging-Domain
Wenn eine oder beide Angaben fehlen, muss die Erstellung gestoppt werden. Vor dem Anlegen der Dateien ist dann explizit nach beiden Domains zu fragen.
Ohne beide Domains duerfen insbesondere diese Dateien nicht final erzeugt werden:
- `config/prod/domaindata.php`
- `config/prod/settings.php`
- `config/staging/domaindata.php`
- `config/staging/settings.php`
Vor der Neuerstellung ist das Repository ausserdem auf den neuen Projektstand zurueckzusetzen:
- alle bestehenden Dateien und Ordner loeschen
- `.gitlab-ci.yml` ausdruecklich behalten
- `.gitlab-ci.yml` anschliessend auf alte Domainreferenzen, Umgebungs-URLs und projektspezifische Angaben pruefen
- gefundene Domainreferenzen in `.gitlab-ci.yml` auf die neue Live- und Staging-Domain anpassen
## Platzhalter fuer neue Projekte
In den folgenden Vorlagen werden diese Platzhalter verwendet:
- `<LIVE_DOMAIN>` fuer die Produktiv-Domain
- `<STAGING_DOMAIN>` fuer die Staging-Domain
- `<APP_PREFIX>` fuer den Cookie- und App-Prefix
- `<APP_NAME>` fuer einen allgemeinen Projektnamen oder OIDC-Client-Namen
## Datei: `config/fileload.php`
```php
<?php
declare(strict_types=1);
spl_autoload_register(function ($class) {
if (str_starts_with($class, 'App\\Repository\\')) {
$prefix = 'App\\Repository\\';
$baseDir = __DIR__ . '/../src/Repository/';
} elseif (str_starts_with($class, 'App\\')) {
$prefix = 'App\\';
$baseDir = __DIR__ . '/../src/App/';
} else {
return;
}
$len = strlen($prefix);
$relativeClass = substr($class, $len);
$file = $baseDir . str_replace('\\', '/', $relativeClass) . '.php';
if (file_exists($file)) {
require $file;
}
});
require_once __DIR__ . '/../src/App/functions.php';
$domainFile = __DIR__ . '/domaindata.php';
$settingsFile = __DIR__ . '/settings.php';
$configFile = __DIR__ . '/db.php';
$baseConfigFile = __DIR__ . '/base_db.php';
$fallbackBaseConfigStaging = __DIR__ . '/staging/db_settings_basic.php';
$fallbackBaseConfigProd = __DIR__ . '/prod/db_settings_basic.php';
if (file_exists($domainFile)) {
require_once $domainFile;
}
if (file_exists($settingsFile)) {
require_once $settingsFile;
}
$dbConfig = [];
if (file_exists($configFile)) {
$dbConfig = require $configFile;
}
$baseDbConfig = [];
if (file_exists($baseConfigFile)) {
$baseDbConfig = require $baseConfigFile;
}
if (empty($baseDbConfig) && file_exists($fallbackBaseConfigStaging)) {
$baseDbConfig = require $fallbackBaseConfigStaging;
}
if (empty($baseDbConfig) && file_exists($fallbackBaseConfigProd)) {
$baseDbConfig = require $fallbackBaseConfigProd;
}
global $appConfig;
$dbEnabled = defined('APP_DB_ENABLED') ? APP_DB_ENABLED : true;
$baseDbEnabled = defined('APP_BASE_DB_ENABLED') ? APP_BASE_DB_ENABLED : false;
$appConfig = new \App\Config($dbConfig, $dbEnabled, $baseDbConfig, $baseDbEnabled);
\App\App::init($appConfig);
```
## Datei: `config/config.php`
```php
<?php
declare(strict_types=1);
ini_set('display_errors', '1');
ini_set('display_startup_errors', '1');
error_reporting(E_ALL);
foreach (['domaindata.php', 'settings.php'] as $cfgFile) {
$rootPath = __DIR__ . '/' . $cfgFile;
if (file_exists($rootPath)) {
require_once $rootPath;
} else {
throw new \RuntimeException("Missing required config file: $cfgFile (expected $rootPath)");
}
}
if (!defined('ASSET_VERSION')) {
define('ASSET_VERSION', 'dev-' . date('Ymd-His'));
}
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);
}
if (!defined('APP_API_BASE')) {
define('APP_API_BASE', 'https://api.' . APP_DOMAIN_PRIMARY);
}
if (!defined('APP_DB_ENABLED')) {
define('APP_DB_ENABLED', false);
}
```
## Datei: `config/base_db.php`
```php
<?php
declare(strict_types=1);
$path = __DIR__ . '/db_settings_basic.php';
if (!file_exists($path)) {
throw new RuntimeException('Missing base DB config: expected config/db_settings_basic.php');
}
return require $path;
```
## Datei: `config/staging/domaindata.php`
```php
<?php
declare(strict_types=1);
if (!defined('APP_DOMAIN_NAME')) {
define('APP_DOMAIN_NAME', '<STAGING_DOMAIN>');
}
if (!defined('APP_PREFIX')) {
define('APP_PREFIX', '<APP_PREFIX>');
}
```
## Datei: `config/prod/domaindata.php`
```php
<?php
declare(strict_types=1);
if (!defined('APP_DOMAIN_NAME')) {
define('APP_DOMAIN_NAME', '<LIVE_DOMAIN>');
}
if (!defined('APP_PREFIX')) {
define('APP_PREFIX', '<APP_PREFIX>');
}
```
## Datei: `config/staging/settings.php`
```php
<?php
define('ASSET_VERSION', 'dev-' . date('Ymd-His'));
define('APP_DOMAIN_PRIMARY', APP_DOMAIN_NAME);
define('APP_URL_PRIMARY', 'https://' . APP_DOMAIN_PRIMARY);
define('APP_API_BASE', 'https://api.' . APP_DOMAIN_PRIMARY);
define('APP_DB_ENABLED', false);
define('APP_DB_DEBUG', true);
define('APP_DB_AUTO_INIT', true);
define('APP_BASE_DB_ENABLED', true);
define('APP_BASIC_AUTH', true);
define('APP_AUTH_ENABLED', false);
define('APP_DEBUG_TOOL', true);
define('APP_AUTH_DEBUG', true);
/*
Optional fuer Projekte mit OIDC:
define('APP_OIDC_ISSUER', 'https://auth.example.tld/realms/<APP_NAME>');
define('APP_OIDC_AUTH_ENDPOINT', 'https://auth.example.tld/realms/<APP_NAME>/protocol/openid-connect/auth');
define('APP_OIDC_TOKEN_ENDPOINT', 'https://auth.example.tld/realms/<APP_NAME>/protocol/openid-connect/token');
define('APP_OIDC_USERINFO_ENDPOINT', 'https://auth.example.tld/realms/<APP_NAME>/protocol/openid-connect/userinfo');
define('APP_OIDC_LOGOUT_ENDPOINT', 'https://auth.example.tld/realms/<APP_NAME>/protocol/openid-connect/logout');
define('APP_OIDC_CLIENT_ID', '<APP_NAME>');
define('APP_OIDC_CLIENT_SECRET', 'CHANGE_ME');
define('APP_OIDC_REDIRECT_URI', 'https://<STAGING_DOMAIN>/auth/callback');
define('APP_OIDC_POST_LOGOUT_REDIRECT_URI', 'https://<STAGING_DOMAIN>/');
define('APP_OIDC_GROUP_CLAIM', 'groups');
define('APP_OIDC_ADMIN_GROUP', 'appadmin');
define('APP_OIDC_USER_GROUP', 'user');
*/
```
## Datei: `config/prod/settings.php`
```php
<?php
define('ASSET_VERSION', 'dev-' . date('Ymd-His'));
define('APP_DOMAIN_PRIMARY', APP_DOMAIN_NAME);
define('APP_URL_PRIMARY', 'https://' . APP_DOMAIN_PRIMARY);
define('APP_API_BASE', 'https://api.' . APP_DOMAIN_PRIMARY);
define('APP_DB_ENABLED', false);
define('APP_DB_DEBUG', false);
define('APP_DB_AUTO_INIT', true);
define('APP_BASE_DB_ENABLED', true);
define('APP_BASIC_AUTH', false);
define('APP_AUTH_ENABLED', false);
define('APP_DEBUG_TOOL', false);
define('APP_AUTH_DEBUG', false);
/*
Optional fuer Projekte mit OIDC:
define('APP_OIDC_ISSUER', 'https://auth.example.tld/realms/<APP_NAME>');
define('APP_OIDC_AUTH_ENDPOINT', 'https://auth.example.tld/realms/<APP_NAME>/protocol/openid-connect/auth');
define('APP_OIDC_TOKEN_ENDPOINT', 'https://auth.example.tld/realms/<APP_NAME>/protocol/openid-connect/token');
define('APP_OIDC_USERINFO_ENDPOINT', 'https://auth.example.tld/realms/<APP_NAME>/protocol/openid-connect/userinfo');
define('APP_OIDC_LOGOUT_ENDPOINT', 'https://auth.example.tld/realms/<APP_NAME>/protocol/openid-connect/logout');
define('APP_OIDC_CLIENT_ID', '<APP_NAME>');
define('APP_OIDC_CLIENT_SECRET', 'CHANGE_ME');
define('APP_OIDC_REDIRECT_URI', 'https://<LIVE_DOMAIN>/auth/callback');
define('APP_OIDC_POST_LOGOUT_REDIRECT_URI', 'https://<LIVE_DOMAIN>/');
define('APP_OIDC_GROUP_CLAIM', 'groups');
define('APP_OIDC_ADMIN_GROUP', 'appadmin');
define('APP_OIDC_USER_GROUP', 'user');
*/
```
## Datei: `config/staging/db_settings_basic.php`
```php
<?php
declare(strict_types=1);
return [
'dsn' => 'sqlite:' . __DIR__ . '/../../data/app_staging.sqlite',
'user' => null,
'password' => null,
'options' => [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
],
];
```
## Datei: `config/prod/db_settings_basic.php`
```php
<?php
declare(strict_types=1);
return [
'dsn' => 'sqlite:' . __DIR__ . '/../../data/app_prod.sqlite',
'user' => null,
'password' => null,
'options' => [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
],
];
```
## Datei: `public/index.php`
Minimaler Einstiegspunkt fuer den Webzugriff:
```php
<?php
declare(strict_types=1);
require_once __DIR__ . '/../config/fileload.php';
$uriPath = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/';
$uriPath = preg_replace('~/{2,}~', '/', $uriPath);
$uriPath = trim($uriPath, '/');
if (str_contains($uriPath, '..')) {
http_response_code(400);
exit('Bad request');
}
$pagesBase = realpath(__DIR__ . '/../partials/landingpages') ?: (__DIR__ . '/../partials/landingpages');
$page404 = $pagesBase . '/404.php';
if (preg_match('~^module/([a-zA-Z0-9_-]+)(?:/(.+))?$~', $uriPath, $m)) {
$module = $m[1];
$page = isset($m[2]) && $m[2] !== '' ? trim($m[2], '/') : 'index';
$modulePage = app()->modules()->resolvePage($module, $page);
$target = $modulePage ?: $page404;
if (!$modulePage) {
http_response_code(404);
}
} elseif ($uriPath === '' || $uriPath === 'index' || $uriPath === 'index.php') {
$target = $pagesBase . '/index.php';
} else {
$base = $pagesBase . '/' . $uriPath;
if (is_dir($base) && is_file($base . '/index.php')) {
$target = $base . '/index.php';
} elseif (is_file($base . '.php')) {
$target = $base . '.php';
} else {
http_response_code(404);
$target = $page404;
}
}
ob_start();
require $target;
$content = ob_get_clean();
tpl('layout_start', 'structure');
echo $content;
tpl('layout_end', 'structure');
```
## Datei: `partials/landingpages/index.php`
```php
<div class="card">
<h1>Projektstart</h1>
<p>Die Grundstruktur wurde erfolgreich erstellt.</p>
</div>
```
## Datei: `partials/landingpages/404.php`
```php
<div class="card">
<h1>404</h1>
<p>Die angeforderte Seite wurde nicht gefunden.</p>
</div>
```
## Datei: `partials/structure/layout_start.php`
```php
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Projektbasis</title>
<link rel="stylesheet" href="/assets/css/app.css?v=<?= urlencode(app()->config()->assetVersion) ?>">
</head>
<body>
<main class="main-content">
```
## Datei: `partials/structure/layout_end.php`
```php
</main>
<script src="/assets/js/app.js?v=<?= urlencode(app()->config()->assetVersion) ?>" defer></script>
</body>
</html>
```
## Datei: `public/assets/css/app.css`
```css
body {
margin: 0;
font-family: sans-serif;
background: #f5f5f5;
color: #111;
}
.main-content {
max-width: 960px;
margin: 0 auto;
padding: 32px 16px;
}
.card {
background: #fff;
border: 1px solid #ddd;
border-radius: 12px;
padding: 24px;
}
```
## Datei: `public/assets/js/app.js`
```js
document.documentElement.classList.add('js');
```
## Datei: `public/.htaccess`
```apache
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^ index.php [QSA,L]
```
## Empfohlene Startreihenfolge in einem neuen Projekt
1. Neues GitLab-Projekt anlegen.
2. Repository lokal mit VS Code verknuepfen.
3. Alle bestehenden Dateien und Ordner entfernen, `.gitlab-ci.yml` jedoch behalten.
4. `GENERAL.md` und `BASE_FILES.md` in das neue Repository kopieren.
5. `.gitlab-ci.yml` auf alte Domain- und Projektreferenzen pruefen und anpassen.
6. Pflichtstruktur aus `GENERAL.md` anlegen.
7. Basisdateien aus `BASE_FILES.md` anlegen.
8. Live- und Staging-Domain einsetzen.
9. Danach erst die eigentliche Projekterstellung oder Modulentwicklung starten.
## Kurzregel
Ohne `GENERAL.md`, ohne `BASE_FILES.md` oder ohne beide Domains ist die saubere Erstellung eines neuen Projekts auf dieser Basis nicht vollstaendig und soll nicht ausgefuehrt werden.

375
GENERAL.md Normal file
View File

@@ -0,0 +1,375 @@
# Projektbasis fuer neue Projekte
Dieses Dokument kombiniert die Erklaerung des allgemeinen Projektaufbaus mit einer konkreten Startstruktur fuer neue Projekte. Ziel ist, dass neue Projekte dieselbe technische Basis verwenden und dieselben Regeln fuer Routing, Verzeichnisse, Module, Assets und Konfiguration einhalten.
## Grundprinzip
Das Projekt basiert auf einer schlanken PHP-Struktur mit:
- einem zentralen Web-Einstiegspunkt in `public/index.php`
- einem zentralen Bootstrap in `config/fileload.php`
- globalen Seiten unter `partials/landingpages/`
- globalem Layout unter `partials/structure/`
- optionalen Fachmodulen unter `modules/`
- globalen Assets unter `public/assets/`
- einer Deployment-gesteuerten Config-Aufteilung mit `config/staging/` und `config/prod/`
## Verbindliche Startstruktur fuer neue Projekte
Die folgende Struktur soll als Basis fuer neue Projekte verwendet werden. Sie beruecksichtigt ausdruecklich auch die Basisverzeichnisse aus `.gitlab-ci.yml`.
```text
/
|-- .gitlab-ci.yml
|-- GENERAL.md
|-- api/
| `-- .gitkeep
|-- config/
| |-- .gitkeep
| |-- base_db.php
| |-- config.php
| |-- fileload.php
| |-- prod/
| | |-- .gitkeep
| | |-- db_settings_basic.php
| | |-- domaindata.php
| | `-- settings.php
| `-- staging/
| |-- .gitkeep
| |-- db_settings_basic.php
| |-- domaindata.php
| `-- settings.php
|-- data/
| `-- .gitkeep
|-- debug/
| `-- .gitkeep
|-- modules/
| `-- .gitkeep
|-- partials/
| |-- landingpages/
| | |-- .gitkeep
| | `-- index.php
| `-- structure/
| |-- .gitkeep
| |-- layout_end.php
| `-- layout_start.php
|-- public/
| |-- .gitkeep
| |-- .htaccess
| |-- index.php
| `-- assets/
| |-- css/
| | |-- .gitkeep
| | `-- app.css
| |-- images/
| | `-- .gitkeep
| `-- js/
| |-- .gitkeep
| `-- app.js
|-- src/
| |-- .gitkeep
| |-- App/
| | |-- .gitkeep
| | |-- App.php
| | |-- Assets.php
| | |-- Config.php
| | |-- Database.php
| | |-- ModuleManager.php
| | `-- functions.php
| `-- Repository/
| `-- .gitkeep
`-- tools/
`-- .gitkeep
```
## Pflichtordner
Diese Ordner muessen als Basis immer vorhanden sein, weil sie entweder vom Projektaufbau oder vom Deployment vorausgesetzt werden:
- `api/`
- `config/`
- `data/`
- `debug/`
- `modules/`
- `partials/`
- `public/`
- `src/`
- `tools/`
Diese Liste entspricht der Deploy-Basis aus `.gitlab-ci.yml` plus den notwendigen Unterordnern fuer die Anwendung.
## Regel fuer leere Ordner
Wenn ein notwendiger Ordner anfangs noch keine echten Dateien enthaelt, muss er eine leere Datei mit dem Namen `.gitkeep` enthalten. Das gilt insbesondere fuer:
- `api/`
- `data/`
- `debug/`
- `modules/`
- `tools/`
- `public/assets/images/`
- `src/Repository/`
- weitere leere Unterordner, die im Template bereits vorgesehen sind
Damit bleiben die Verzeichnisse versionierbar und werden beim Kopieren und Deployment nicht versehentlich ausgelassen.
## Rollen der wichtigsten Verzeichnisse
### `public/`
`public/` ist der Webroot. Hier liegen:
- `index.php` als zentraler Router
- globale Assets unter `public/assets/`
- Webserver-Dateien wie `.htaccess`
### `src/`
`src/` enthaelt den PHP-Kern der Anwendung.
- `src/App/` fuer App-Klassen, Bootstrap-nahe Logik, Config, Assets, Datenbank, Request, Session und Modulverwaltung
- `src/Repository/` fuer Datenzugriffslogik
### `partials/`
`partials/` enthaelt globale Templates.
- `partials/landingpages/` fuer globale, direkt routbare Seiten
- `partials/structure/` fuer das globale Layout
### `modules/`
`modules/` enthaelt fachlich getrennte Erweiterungen. Jedes Modul bleibt in seinem eigenen Ordner und kapselt Seiten, Assets, Partials und optionale Bootstrap-Logik.
### `api/`
`api/` ist fuer API-Funktionen vorgesehen, falls das Projekt eigene API-Endpunkte anbieten soll. Wenn ein Projekt keine API bereitstellt, kann der Ordner leer bleiben, soll aber als Teil der Basisstruktur dennoch vorhanden sein.
### `config/`
`config/` enthaelt den Bootstrap der Konfiguration sowie die umgebungsspezifischen Quellverzeichnisse `staging` und `prod`.
### `tools/`
`tools/` ist fuer CLI-Skripte, Worker und Hilfsprogramme vorgesehen.
## Routing- und Renderstruktur
### `public/index.php`
`public/index.php` ist der einzige Web-Einstiegspunkt und uebernimmt:
- Laden von `config/fileload.php`
- Normalisierung des Request-Pfads
- Pruefung optionaler Authentifizierung oder Schutzregeln
- Routing auf globale Seiten unter `partials/landingpages/`
- Routing auf Modulseiten unter `modules/<modul>/pages/`
- 404-Behandlung
- Rendern des Inhalts innerhalb des globalen Layouts
### Globale Seiten
Globale Seiten liegen unter `partials/landingpages/`. Das Routing ist dateibasiert, also ohne externen Framework-Router.
Beispiele:
- `/` -> `partials/landingpages/index.php`
- `/users` -> `partials/landingpages/users/index.php`
### Layout
Die globale HTML-Struktur liegt unter `partials/structure/`.
- `layout_start.php` oeffnet das Seitenlayout
- `layout_end.php` schliesst es
### Module
Modulrouten folgen dem Schema:
```text
/module/<modulname>
/module/<modulname>/<seite>
```
Diese Routen verweisen auf Dateien unter:
```text
modules/<modulname>/pages/
```
## Modulstruktur fuer neue Projekte
Wenn ein neues Projekt Module verwendet, sollte jedes Modul nach demselben Muster aufgebaut sein:
```text
modules/<modulname>/
|-- assets/
| `-- .gitkeep
|-- pages/
| |-- .gitkeep
| `-- index.php
|-- partials/
| `-- .gitkeep
|-- bootstrap.php
`-- module.json
```
Regeln:
- modulspezifisches CSS und JavaScript bleibt unter `modules/<modulname>/assets/`
- globale Assets gehoeren nicht in Modulordner
- Modulcode gehoert nicht nach `public/assets/`
- Seiten eines Moduls liegen unter `pages/`
- wiederverwendbare Modul-Templates liegen unter `partials/`
## Asset-Struktur
### Globale Assets
Projektweite Dateien liegen unter:
- `public/assets/css/`
- `public/assets/js/`
- `public/assets/images/`
### Modul-Assets
Modulbezogene Dateien liegen unter:
- `modules/<modulname>/assets/`
Neue Projekte sollen diese Trennung konsequent beibehalten.
## Config-Aufteilung mit `staging` und `prod`
Dieser Teil ist fuer neue Projekte verpflichtend.
### Quellverzeichnisse im Repository
Im Repository liegen die umgebungsspezifischen Config-Quellen unter:
```text
config/staging/
config/prod/
```
Darin liegen typischerweise:
- `domaindata.php`
- `settings.php`
- `db_settings_basic.php`
### Laufzeitlogik
Die Anwendung selbst laedt zur Laufzeit nicht direkt aus `config/staging/` oder `config/prod/`, sondern aus dem Root von `config/`, also z. B.:
- `config/settings.php`
- `config/domaindata.php`
- `config/db_settings_basic.php`
`config/fileload.php` erwartet genau diese Root-Dateien.
### Deployment-Regel
Beim Deployment wird die passende Umgebung in das aktive `config/`-Root kopiert:
1. allgemeine Root-Dateien aus `config/` werden bereitgestellt
2. je nach Branch wird die Umgebungsvariante darueberkopiert
3. `develop` verwendet `config/staging/`
4. `main` verwendet `config/prod/`
5. im Zielsystem gelten die kopierten Dateien danach so, als waeren sie direkt normale Root-Configs
Neue Projekte muessen exakt dieses Prinzip uebernehmen.
## Domain-Pflicht fuer neue Projekterstellungen
Neue Projekte duerfen nur erstellt werden, wenn beide Domains vorliegen:
- Live-Domain
- Staging-Domain
Diese Domains muessen in den umgebungsspezifischen Config-Dateien verwendet und automatisch eingetragen werden, insbesondere in:
- `config/prod/domaindata.php`
- `config/prod/settings.php`
- `config/staging/domaindata.php`
- `config/staging/settings.php`
- `.gitlab-ci.yml`, falls dort Domainreferenzen oder Umgebungs-URLs hinterlegt sind
## Verbindliche Erstellungsregel
Wenn bei der Aufgabenstellung fuer ein neues Projekt keine Domains angegeben werden, muss vor der Projekterstellung explizit nach beiden Werten gefragt werden.
Die Erstellung ist in diesem Fall zu blockieren.
Sie darf erst fortgesetzt werden, wenn beide Angaben vorhanden sind:
1. Live-Domain
2. Staging-Domain
Fehlt mindestens eine der beiden Angaben, muss die Erstellung unterbunden werden mit einem klaren Hinweis, dass die Domains fehlen.
Zusaetzlich gilt:
- Platzhalterwerte fuer Domains duerfen nicht unveraendert uebernommen werden
- insbesondere Werte wie `<LIVE_DOMAIN>` und `<STAGING_DOMAIN>` muessen vor der Erstellung durch echte Domainnamen ersetzt werden
- solange einer dieser Platzhalter noch vorhanden ist, muss die Erstellung blockiert werden
- eine Ausfuehrung darf erst erfolgen, wenn beide echten Domainwerte gesetzt wurden
## Reset-Regel fuer neue Projekterstellungen
Vor der eigentlichen Erstellung eines neuen Projekts muessen alle bestehenden Dateien und Ordner im Repository entfernt werden, mit genau einer Ausnahme:
- `.gitlab-ci.yml`
Die bestehende `.gitlab-ci.yml` bleibt erhalten, weil sie die Deploy-Basis vorgibt. Sie muss jedoch im Zuge der neuen Projekterstellung geprueft und angepasst werden, wenn darin Domainreferenzen, Umgebungs-URLs oder projektspezifische Zielpfade enthalten sind.
Fuer neue Projekterstellungen gilt deshalb verbindlich:
1. vorhandene Projektdateien und Projektordner loeschen
2. `.gitlab-ci.yml` behalten
3. `.gitlab-ci.yml` auf alte Domain- oder Projektreferenzen pruefen
4. vorhandene Domainreferenzen in `.gitlab-ci.yml` auf die neue Live- und Staging-Domain anpassen
5. danach erst die neue Basisstruktur und die neuen Basisdateien erstellen
## Mindestinhalt der Config-Dateien
Die folgenden Dateien koennen als Basis aus diesem Projekttyp uebernommen werden und muessen pro neuem Projekt angepasst werden:
- `config/fileload.php`
- `config/config.php`
- `config/base_db.php`
- `config/prod/domaindata.php`
- `config/prod/settings.php`
- `config/staging/domaindata.php`
- `config/staging/settings.php`
Dabei gilt:
- die allgemeine Config-Logik kann uebernommen werden
- domainspezifische Werte muessen pro Projekt ersetzt werden
- staging und prod muessen immer unterschiedliche Zielwerte bekommen, sofern nicht ausdruecklich anders gefordert
## Basisdateien fuer neue Projekte
Neben der Ordnerstruktur werden fuer neue Projekte auch Basisdateien benoetigt, insbesondere im Config-Bereich. Die konkreten Inhalte sind in einer separaten Datei beschrieben:
- `BASE_FILES.md`
Diese Datei ist dafuer gedacht, nach dem Anlegen eines neuen GitLab-Projekts zusammen mit `GENERAL.md` in das neue Repository kopiert zu werden, damit die Erstellung der Grundstruktur und der Konfigurationsdateien direkt auf einer klaren Vorlage basiert.
## Kurzfassung fuer neue Projekte
Neue Projekte auf dieser Basis verwenden:
- einen zentralen Router in `public/index.php`
- einen zentralen Bootstrap in `config/fileload.php`
- globale Seiten in `partials/landingpages/`
- globale Layout-Dateien in `partials/structure/`
- optionale Module in `modules/`
- globale Assets in `public/assets/`
- modulspezifische Assets in `modules/<modulname>/assets/`
- eine Deploy-gesteuerte Config mit `config/staging/` und `config/prod/`
Leere Pflichtordner erhalten immer `.gitkeep`. Eine neue Projekterstellung ist nur zulaessig, wenn sowohl Live- als auch Staging-Domain vorab angegeben wurden.

View File

@@ -0,0 +1,5 @@
Bitte PROJECT_CONTEXT.md lesen und strikt einhalten.
Wichtig: Modul-spezifischer Code/Assets ausschließlich unter /modules/<modul>/,
keine Änderungen an /public/assets/* für modul-spezifische Features.
Staging/Live: /config/<env> im Repo wird nach /app/<env>/config kopiert.
Modul-Assets müssen über /module/<modul>/asset geladen werden.

View File

@@ -0,0 +1,6 @@
<?php
declare(strict_types=1);
require_once dirname(__DIR__) . '/bootstrap.php';
(new Modules\MiningChecker\Api\Router(dirname(__DIR__)))->handle($_GET['path'] ?? '');

View File

@@ -0,0 +1,961 @@
#mining-checker-app {
--mc-bg: #09111f;
--mc-surface: rgba(8, 15, 29, 0.74);
--mc-surface-strong: rgba(15, 23, 42, 0.92);
--mc-line: rgba(148, 163, 184, 0.18);
--mc-line-strong: rgba(255, 255, 255, 0.12);
--mc-text: #e5eef8;
--mc-text-muted: #a7b5c8;
--mc-accent: #3dd9c4;
--mc-accent-strong: #7dd3fc;
--mc-danger: #fb7185;
--mc-success: #34d399;
--mc-warning: #fbbf24;
min-height: 70vh;
background:
radial-gradient(circle at top left, rgba(13, 148, 136, 0.16), transparent 26%),
radial-gradient(circle at top right, rgba(59, 130, 246, 0.18), transparent 22%),
linear-gradient(180deg, #04111d 0%, #0f172a 42%, #111827 100%);
color: var(--mc-text);
font-family: "Space Grotesk", "Segoe UI", sans-serif;
max-width: 100%;
overflow-x: clip;
}
:root[data-theme="day"] #mining-checker-app {
--mc-bg: #f7fbfb;
--mc-surface: rgba(255, 255, 255, 0.84);
--mc-surface-strong: rgba(255, 255, 255, 0.96);
--mc-line: rgba(16, 33, 43, 0.13);
--mc-line-strong: color-mix(in srgb, var(--brand-accent, #ed1671) 32%, transparent);
--mc-text: #10212b;
--mc-text-muted: #66737b;
--mc-accent: var(--brand-accent, #ed1671);
--mc-accent-strong: var(--brand-accent-2, #06a9c8);
background:
radial-gradient(circle at top left, color-mix(in srgb, var(--brand-accent-2, #06a9c8) 16%, transparent), transparent 26%),
radial-gradient(circle at top right, color-mix(in srgb, var(--brand-accent, #ed1671) 16%, transparent), transparent 22%),
linear-gradient(180deg, #f7fbfb 0%, #eef7f5 48%, #fff8e9 100%);
}
:root[data-theme="night"] #mining-checker-app {
--mc-accent: var(--brand-accent, #ed1671);
--mc-accent-strong: var(--brand-accent-2, #06a9c8);
background:
radial-gradient(circle at top left, color-mix(in srgb, var(--brand-accent-2, #06a9c8) 18%, transparent), transparent 26%),
radial-gradient(circle at top right, color-mix(in srgb, var(--brand-accent, #ed1671) 20%, transparent), transparent 22%),
linear-gradient(180deg, #050b12 0%, #0c1721 48%, #111827 100%);
}
:root[data-theme="day"] #mining-checker-app .mc-stat-value {
color: var(--mc-text);
}
:root[data-theme="day"] #mining-checker-app .mc-badge,
:root[data-theme="day"] #mining-checker-app .mc-badge--info,
:root[data-theme="day"] #mining-checker-app .mc-button--ghost {
color: var(--mc-text);
}
:root[data-theme="day"] #mining-checker-app .mc-button--tab {
background: rgba(255, 255, 255, 0.72);
color: var(--mc-text);
}
:root[data-theme="day"] #mining-checker-app .mc-button--tab-active,
:root[data-theme="day"] #mining-checker-app .mc-button--secondary {
background: var(--mc-text);
color: #ffffff;
}
:root[data-theme="day"] #mining-checker-app .mc-input,
:root[data-theme="day"] #mining-checker-app .mc-select,
:root[data-theme="day"] #mining-checker-app .mc-textarea,
:root[data-theme="day"] #mining-checker-app .mc-file,
:root[data-theme="day"] #mining-checker-app .mc-display-field {
background: rgba(255, 255, 255, 0.72);
}
:root[data-theme="day"] #mining-checker-app .mc-debug-entry,
:root[data-theme="day"] #mining-checker-app .mc-debug-text-console {
background: rgba(255, 255, 255, 0.7);
}
:root[data-theme="day"] #mining-checker-app .mc-suggestion strong {
color: var(--mc-text);
}
#mining-checker-app,
#mining-checker-app * {
box-sizing: border-box;
}
#mining-checker-app .mc-grid-bg {
background-image:
linear-gradient(rgba(255, 255, 255, 0.04) 1px, transparent 1px),
linear-gradient(90deg, rgba(255, 255, 255, 0.04) 1px, transparent 1px);
background-size: 24px 24px;
max-width: 100%;
overflow-x: clip;
}
#mining-checker-app .mc-grid-bg[data-module-theme="custom"] {
--mc-bg: #09111f;
--mc-surface: rgba(8, 15, 29, 0.76);
--mc-surface-strong: rgba(15, 23, 42, 0.94);
--mc-line: rgba(148, 163, 184, 0.18);
--mc-line-strong: rgba(255, 255, 255, 0.12);
--mc-text: #e5eef8;
--mc-text-muted: #a7b5c8;
--mc-accent: #3dd9c4;
--mc-accent-strong: #7dd3fc;
color: var(--mc-text);
background:
linear-gradient(rgba(255, 255, 255, 0.04) 1px, transparent 1px),
linear-gradient(90deg, rgba(255, 255, 255, 0.04) 1px, transparent 1px),
radial-gradient(circle at top left, rgba(13, 148, 136, 0.16), transparent 26%),
radial-gradient(circle at top right, rgba(59, 130, 246, 0.18), transparent 22%),
linear-gradient(180deg, #04111d 0%, #0f172a 42%, #111827 100%);
background-size: 24px 24px, 24px 24px, auto, auto, auto;
}
#mining-checker-app .mc-grid-bg[data-module-theme="custom"][data-module-accent="logo"] {
--mc-accent: #ed1671;
--mc-accent-strong: #06a9c8;
}
#mining-checker-app .mc-grid-bg[data-module-theme="custom"][data-module-accent="pink"] {
--mc-accent: #ed1671;
--mc-accent-strong: #f6aa21;
}
#mining-checker-app .mc-grid-bg[data-module-theme="custom"][data-module-accent="cyan"] {
--mc-accent: #06a9c8;
--mc-accent-strong: #8bc53f;
}
#mining-checker-app .mc-grid-bg[data-module-theme="custom"][data-module-accent="orange"] {
--mc-accent: #f6aa21;
--mc-accent-strong: #ed1671;
}
#mining-checker-app .mc-grid-bg[data-module-theme="custom"][data-module-accent="green"] {
--mc-accent: #8bc53f;
--mc-accent-strong: #06a9c8;
}
#mining-checker-app .mc-shell {
width: min(1360px, calc(100% - 24px));
max-width: 100%;
margin: 0 auto;
padding: 24px 0 40px;
}
#mining-checker-app .mc-stack {
display: grid;
gap: 24px;
}
#mining-checker-app .mc-hero,
#mining-checker-app .mc-panel,
#mining-checker-app .mc-stat-card,
#mining-checker-app .mc-dashboard-card,
#mining-checker-app .mc-target-card,
#mining-checker-app .mc-alert,
#mining-checker-app .mc-empty,
#mining-checker-app .mc-table-shell,
#mining-checker-app .mc-display-field {
border: 1px solid var(--mc-line);
border-radius: 28px;
background: var(--mc-surface);
box-shadow: 0 28px 60px rgba(0, 0, 0, 0.24);
backdrop-filter: blur(14px);
}
#mining-checker-app .mc-hero,
#mining-checker-app .mc-panel,
#mining-checker-app .mc-dashboard-card,
#mining-checker-app .mc-alert,
#mining-checker-app .mc-empty,
#mining-checker-app .mc-table-shell {
padding: 24px;
}
#mining-checker-app .mc-hero-top,
#mining-checker-app .mc-inline-row,
#mining-checker-app .mc-flex-split,
#mining-checker-app .mc-section-head {
display: flex;
gap: 16px;
}
#mining-checker-app .mc-hero-top,
#mining-checker-app .mc-flex-split,
#mining-checker-app .mc-section-head {
justify-content: space-between;
}
#mining-checker-app .mc-hero-copy,
#mining-checker-app .mc-hero-controls,
#mining-checker-app .mc-panel-body,
#mining-checker-app .mc-form,
#mining-checker-app .mc-field,
#mining-checker-app .mc-filter-grid,
#mining-checker-app .mc-chart,
#mining-checker-app .mc-target-grid {
display: grid;
gap: 14px;
}
#mining-checker-app .mc-filter-grid {
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
}
#mining-checker-app .mc-hero-copy {
max-width: 860px;
}
#mining-checker-app .mc-title {
margin: 0;
font-size: clamp(2rem, 4vw, 3.4rem);
line-height: 1.05;
letter-spacing: -0.04em;
}
#mining-checker-app .mc-text,
#mining-checker-app p,
#mining-checker-app td,
#mining-checker-app th,
#mining-checker-app label,
#mining-checker-app summary,
#mining-checker-app pre {
color: var(--mc-text-muted);
}
#mining-checker-app h1,
#mining-checker-app h2,
#mining-checker-app h3 {
margin: 0;
color: var(--mc-text);
}
#mining-checker-app .mc-stats-grid,
#mining-checker-app .mc-target-grid,
#mining-checker-app .mc-overview-grid,
#mining-checker-app .mc-two-col,
#mining-checker-app .mc-main-grid {
display: grid;
gap: 16px;
}
#mining-checker-app .mc-stats-grid {
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
}
#mining-checker-app .mc-overview-grid {
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
}
#mining-checker-app .mc-target-grid {
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
}
#mining-checker-app .mc-two-col {
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
}
#mining-checker-app .mc-main-grid {
grid-template-columns: minmax(300px, 380px) minmax(0, 1fr);
}
#mining-checker-app .mc-stat-card,
#mining-checker-app .mc-target-card {
padding: 20px;
}
#mining-checker-app .mc-kicker {
font-size: 0.76rem;
text-transform: uppercase;
letter-spacing: 0.18em;
color: var(--mc-accent-strong);
}
#mining-checker-app .mc-stat-value {
font-size: 2rem;
font-weight: 700;
line-height: 1.1;
color: #fff;
}
#mining-checker-app .mc-badge {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-radius: 999px;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.18em;
border: 1px solid var(--mc-line-strong);
background: color-mix(in srgb, var(--mc-accent) 16%, transparent);
color: var(--mc-accent-strong);
}
#mining-checker-app .mc-badge--warn {
background: rgba(251, 191, 36, 0.14);
color: #fde68a;
}
#mining-checker-app .mc-badge--info {
background: color-mix(in srgb, var(--mc-accent) 16%, transparent);
color: var(--mc-accent-strong);
}
#mining-checker-app .mc-badge--danger {
background: rgba(251, 113, 133, 0.14);
color: #fecdd3;
}
#mining-checker-app .mc-badge--success {
background: rgba(52, 211, 153, 0.14);
color: #a7f3d0;
}
#mining-checker-app .mc-tabs {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 18px;
}
#mining-checker-app .mc-button,
#mining-checker-app button,
#mining-checker-app input,
#mining-checker-app select,
#mining-checker-app textarea {
font: inherit;
}
#mining-checker-app .mc-button {
border: 1px solid transparent;
border-radius: 16px;
padding: 12px 16px;
cursor: pointer;
text-decoration: none;
transition: 160ms ease;
}
#mining-checker-app .mc-button:hover {
transform: translateY(-1px);
}
#mining-checker-app .mc-button:disabled {
opacity: 0.6;
cursor: default;
transform: none;
}
#mining-checker-app .mc-button--primary {
background: linear-gradient(135deg, var(--mc-accent), var(--mc-accent-strong));
color: #05121f;
font-weight: 700;
}
#mining-checker-app .mc-button--secondary {
background: rgba(255, 255, 255, 0.92);
color: #09111f;
font-weight: 700;
}
#mining-checker-app .mc-button--danger {
background: linear-gradient(135deg, rgba(251, 113, 133, 0.92), rgba(239, 68, 68, 0.92));
color: #fff7f7;
font-weight: 700;
}
#mining-checker-app .mc-button--ghost {
background: color-mix(in srgb, var(--mc-accent) 14%, transparent);
border-color: color-mix(in srgb, var(--mc-accent) 34%, transparent);
color: var(--mc-accent-strong);
}
#mining-checker-app .mc-home-link {
justify-self: end;
}
#mining-checker-app .mc-debug-tools {
display: flex;
flex-wrap: wrap;
gap: 12px;
justify-content: flex-start;
}
#mining-checker-app .mc-debug-console {
border-color: rgba(125, 211, 252, 0.28);
}
#mining-checker-app .mc-debug-view-switch {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 14px;
}
#mining-checker-app .mc-debug-log {
display: grid;
gap: 12px;
max-height: 520px;
overflow: auto;
}
#mining-checker-app .mc-debug-text-console {
max-height: 520px;
overflow: auto;
white-space: pre-wrap;
word-break: break-word;
}
#mining-checker-app .mc-debug-entry {
border: 1px solid var(--mc-line);
border-radius: 18px;
padding: 14px 16px;
background: rgba(2, 6, 23, 0.7);
}
#mining-checker-app .mc-button--tab {
background: rgba(255, 255, 255, 0.06);
color: var(--mc-text);
}
#mining-checker-app .mc-button--tab-active {
background: rgba(255, 255, 255, 0.94);
color: #09111f;
font-weight: 700;
}
#mining-checker-app .mc-field {
gap: 8px;
}
#mining-checker-app .mc-field-label {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.18em;
color: var(--mc-text-muted);
}
#mining-checker-app .mc-input,
#mining-checker-app .mc-select,
#mining-checker-app .mc-textarea,
#mining-checker-app .mc-file {
width: 100%;
border: 1px solid var(--mc-line);
border-radius: 16px;
padding: 13px 15px;
color: var(--mc-text);
background: rgba(255, 255, 255, 0.05);
outline: none;
}
#mining-checker-app .mc-select option {
color: #09111f;
background: #f8fafc;
}
#mining-checker-app .mc-textarea {
min-height: 120px;
resize: vertical;
}
#mining-checker-app .mc-input::placeholder,
#mining-checker-app .mc-textarea::placeholder {
color: #6d7c90;
}
#mining-checker-app .mc-input:focus,
#mining-checker-app .mc-select:focus,
#mining-checker-app .mc-textarea:focus,
#mining-checker-app .mc-file:focus {
border-color: rgba(125, 211, 252, 0.5);
box-shadow: 0 0 0 3px rgba(125, 211, 252, 0.12);
}
#mining-checker-app .mc-checkbox {
display: flex;
align-items: center;
gap: 12px;
border: 1px solid var(--mc-line);
border-radius: 16px;
padding: 14px 16px;
background: rgba(255, 255, 255, 0.05);
}
#mining-checker-app .mc-token-list,
#mining-checker-app .mc-suggestion-list {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
#mining-checker-app .mc-token-list--inline {
flex: 1 1 auto;
}
#mining-checker-app .mc-currency-selection-row {
display: flex;
flex-wrap: wrap;
gap: 16px;
align-items: flex-start;
}
#mining-checker-app .mc-inline-fields {
display: flex;
flex-wrap: wrap;
gap: 16px;
align-items: end;
}
#mining-checker-app .mc-inline-fields > .mc-field {
flex: 1 1 280px;
}
#mining-checker-app .mc-currency-search {
flex: 0 1 360px;
min-width: 260px;
}
#mining-checker-app .mc-token,
#mining-checker-app .mc-suggestion {
display: inline-flex;
align-items: center;
gap: 10px;
border: 1px solid var(--mc-line);
border-radius: 999px;
padding: 10px 14px;
background: rgba(255, 255, 255, 0.06);
color: var(--mc-text);
}
#mining-checker-app .mc-token {
cursor: pointer;
}
#mining-checker-app .mc-token:hover,
#mining-checker-app .mc-suggestion:hover {
border-color: rgba(125, 211, 252, 0.4);
background: rgba(61, 217, 196, 0.12);
}
#mining-checker-app .mc-token-close {
color: var(--mc-accent-strong);
font-weight: 700;
text-transform: uppercase;
}
#mining-checker-app .mc-suggestion {
cursor: pointer;
}
@media (max-width: 860px) {
#mining-checker-app .mc-currency-selection-row {
flex-direction: column;
}
#mining-checker-app .mc-currency-search {
flex: 1 1 auto;
width: 100%;
min-width: 0;
}
}
#mining-checker-app .mc-suggestion strong {
color: #ffffff;
}
#mining-checker-app .mc-alert--error {
border-color: rgba(251, 113, 133, 0.28);
background: rgba(127, 29, 29, 0.35);
color: #ffe4e6;
}
#mining-checker-app .mc-alert--warning {
border-color: rgba(245, 158, 11, 0.28);
background: rgba(120, 53, 15, 0.34);
color: #fef3c7;
}
#mining-checker-app .mc-alert--success {
border-color: rgba(52, 211, 153, 0.28);
background: rgba(6, 78, 59, 0.34);
color: #d1fae5;
}
#mining-checker-app .mc-table-shell {
overflow: auto;
padding: 0;
}
#mining-checker-app .mc-table {
width: 100%;
border-collapse: collapse;
}
#mining-checker-app .mc-table th,
#mining-checker-app .mc-table td {
padding: 14px 16px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
text-align: left;
vertical-align: top;
}
#mining-checker-app .mc-table thead {
background: rgba(255, 255, 255, 0.04);
}
#mining-checker-app .mc-empty {
border-style: dashed;
}
#mining-checker-app details {
border: 1px solid var(--mc-line);
border-radius: 18px;
padding: 16px;
background: rgba(255, 255, 255, 0.04);
}
#mining-checker-app pre {
white-space: pre-wrap;
margin: 12px 0 0;
line-height: 1.65;
}
#mining-checker-app .mc-mini-grid {
display: grid;
gap: 10px;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
}
#mining-checker-app .mc-mini-card,
#mining-checker-app .mc-display-field {
padding: 12px 14px;
background: rgba(255, 255, 255, 0.04);
}
#mining-checker-app .mc-code-block {
margin: 0;
padding: 12px 14px;
border: 1px solid var(--mc-line);
border-radius: 16px;
background: rgba(255, 255, 255, 0.03);
white-space: pre-wrap;
word-break: break-word;
font-family: "JetBrains Mono", "SFMono-Regular", monospace;
font-size: 0.92rem;
line-height: 1.6;
}
#mining-checker-app .mc-chart svg {
width: 100%;
height: 220px;
}
#mining-checker-app .mc-modal-backdrop {
position: fixed;
inset: 0;
z-index: 80;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
background: rgba(2, 6, 23, 0.72);
}
#mining-checker-app .mc-modal {
width: min(720px, 100%);
max-height: min(80vh, 900px);
overflow: auto;
padding: 24px;
border: 1px solid var(--mc-line);
border-radius: 28px;
background: rgba(9, 17, 31, 0.96);
box-shadow: 0 28px 60px rgba(0, 0, 0, 0.34);
backdrop-filter: blur(14px);
}
#mining-checker-app .mc-chart path,
#mining-checker-app .mc-chart polyline,
#mining-checker-app .mc-chart line,
#mining-checker-app .mc-chart rect {
vector-effect: non-scaling-stroke;
}
@media (max-width: 960px) {
#mining-checker-app .mc-hero-top,
#mining-checker-app .mc-inline-row,
#mining-checker-app .mc-flex-split,
#mining-checker-app .mc-section-head {
flex-direction: column;
align-items: stretch;
}
#mining-checker-app .mc-main-grid {
grid-template-columns: 1fr;
}
#mining-checker-app .mc-shell {
width: min(100% - 10px, 1360px);
padding: 8px 0 20px;
}
#mining-checker-app .mc-stack {
gap: 14px;
}
#mining-checker-app .mc-hero,
#mining-checker-app .mc-panel,
#mining-checker-app .mc-dashboard-card,
#mining-checker-app .mc-alert,
#mining-checker-app .mc-empty,
#mining-checker-app .mc-table-shell {
padding: 14px;
border-radius: 20px;
}
#mining-checker-app .mc-title {
font-size: clamp(1.45rem, 8vw, 2.15rem);
}
#mining-checker-app .mc-hero-copy {
gap: 8px;
}
#mining-checker-app .mc-hero-copy p {
margin: 0;
}
#mining-checker-app .mc-hero-controls {
grid-template-columns: 1fr;
}
#mining-checker-app .mc-home-link {
justify-self: stretch;
justify-content: center;
}
#mining-checker-app .mc-tabs {
flex-wrap: nowrap;
gap: 8px;
margin: 12px -4px 0;
padding: 0 4px 4px;
overflow-x: auto;
scrollbar-width: thin;
}
#mining-checker-app .mc-button {
padding: 10px 12px;
border-radius: 14px;
}
#mining-checker-app .mc-button--tab {
flex: 0 0 auto;
white-space: nowrap;
}
#mining-checker-app .mc-table th,
#mining-checker-app .mc-table td {
padding: 10px 12px;
}
#mining-checker-app .mc-modal {
max-height: min(92vh, 900px);
padding: 16px;
border-radius: 20px;
}
}
@media (max-width: 600px) {
#mining-checker-app,
#mining-checker-app * {
max-width: 100%;
}
#mining-checker-app {
min-height: 100vh;
overflow-x: hidden;
}
#mining-checker-app .mc-grid-bg {
overflow-x: hidden;
background-size: 18px 18px;
}
#mining-checker-app .mc-shell {
width: 100%;
padding: 0 0 16px;
}
#mining-checker-app .mc-stack {
gap: 12px;
}
#mining-checker-app .mc-hero,
#mining-checker-app .mc-panel,
#mining-checker-app .mc-dashboard-card,
#mining-checker-app .mc-alert,
#mining-checker-app .mc-empty {
width: 100%;
border-radius: 18px;
padding: 14px;
box-shadow: 0 14px 32px rgba(0, 0, 0, 0.16);
}
#mining-checker-app .mc-hero {
border-radius: 0 0 18px 18px;
}
#mining-checker-app .mc-panel,
#mining-checker-app .mc-dashboard-card,
#mining-checker-app .mc-alert,
#mining-checker-app .mc-empty,
#mining-checker-app .mc-table-shell {
border-left: 0;
border-right: 0;
}
#mining-checker-app .mc-title,
#mining-checker-app .mc-hero-copy p {
display: none;
}
#mining-checker-app .mc-hero-top {
gap: 10px;
}
#mining-checker-app .mc-kicker {
font-size: 0.68rem;
letter-spacing: 0.14em;
}
#mining-checker-app .mc-tabs {
margin: 10px -6px 0;
padding: 0 6px 6px;
gap: 6px;
}
#mining-checker-app .mc-button {
min-height: 38px;
padding: 8px 10px;
border-radius: 12px;
font-size: 0.92rem;
}
#mining-checker-app .mc-button:hover {
transform: none;
}
#mining-checker-app .mc-stats-grid,
#mining-checker-app .mc-target-grid,
#mining-checker-app .mc-overview-grid,
#mining-checker-app .mc-two-col,
#mining-checker-app .mc-main-grid,
#mining-checker-app .mc-filter-grid,
#mining-checker-app .mc-mini-grid {
grid-template-columns: minmax(0, 1fr);
gap: 10px;
}
#mining-checker-app .mc-stat-card,
#mining-checker-app .mc-target-card,
#mining-checker-app .mc-mini-card,
#mining-checker-app .mc-display-field {
padding: 12px;
border-radius: 16px;
}
#mining-checker-app .mc-stat-value {
font-size: clamp(1.45rem, 8vw, 1.85rem);
overflow-wrap: anywhere;
}
#mining-checker-app h2 {
font-size: 1.25rem;
}
#mining-checker-app h3 {
font-size: 1.05rem;
}
#mining-checker-app .mc-text,
#mining-checker-app p,
#mining-checker-app td,
#mining-checker-app th,
#mining-checker-app label,
#mining-checker-app summary,
#mining-checker-app pre {
font-size: 0.92rem;
}
#mining-checker-app .mc-input,
#mining-checker-app .mc-select,
#mining-checker-app .mc-textarea,
#mining-checker-app .mc-file {
min-width: 0;
padding: 10px 12px;
border-radius: 12px;
font-size: 16px;
}
#mining-checker-app .mc-inline-fields,
#mining-checker-app .mc-currency-selection-row,
#mining-checker-app .mc-debug-tools,
#mining-checker-app .mc-debug-view-switch {
gap: 8px;
}
#mining-checker-app .mc-inline-fields > .mc-field {
flex-basis: 100%;
min-width: 0;
}
#mining-checker-app .mc-token,
#mining-checker-app .mc-suggestion,
#mining-checker-app .mc-badge {
padding: 7px 10px;
font-size: 0.78rem;
}
#mining-checker-app .mc-table-shell {
width: 100%;
max-width: 100%;
border-radius: 16px;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
#mining-checker-app .mc-table {
max-width: none;
min-width: 640px;
}
#mining-checker-app .mc-table th,
#mining-checker-app .mc-table td {
padding: 9px 10px;
font-size: 0.86rem;
}
#mining-checker-app .mc-chart svg {
height: 180px;
}
#mining-checker-app .mc-modal-backdrop {
align-items: stretch;
padding: 8px;
}
#mining-checker-app .mc-modal {
width: 100%;
max-height: calc(100vh - 16px);
padding: 14px;
border-radius: 18px;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
spl_autoload_register(static function (string $class): void {
$prefix = 'Modules\\MiningChecker\\';
if (!str_starts_with($class, $prefix)) {
return;
}
$relativeClass = substr($class, strlen($prefix));
$file = __DIR__ . '/src/' . str_replace('\\', '/', $relativeClass) . '.php';
if (is_file($file)) {
require_once $file;
}
});

View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
return [
'MINING_CHECKER_DEFAULT_PROJECT_KEY' => 'doge-main',
'MINING_CHECKER_OCR_PROVIDERS' => 'ocrspace,tesseract',
'MINING_CHECKER_OCR_SPACE_URL' => 'https://api.ocr.space/parse/image',
'MINING_CHECKER_OCR_SPACE_API_KEY' => 'K83150278888957',
'MINING_CHECKER_OCR_SPACE_LANGUAGE' => 'eng',
'MINING_CHECKER_OCR_SPACE_ENGINE' => '2',
'MINING_CHECKER_OCR_SPACE_SCALE' => 'true',
'MINING_CHECKER_OCR_SPACE_DETECT_ORIENTATION' => 'true',
'MINING_CHECKER_OCR_SPACE_IS_TABLE' => 'false',
'MINING_CHECKER_OCR_SPACE_TIMEOUT' => '25',
'MINING_CHECKER_TESSERACT_BIN' => '/usr/bin/tesseract',
'MINING_CHECKER_TESSERACT_LANG' => 'eng',
'MINING_CHECKER_FX_PROVIDER' => 'currencyapi',
'MINING_CHECKER_FX_URL' => 'https://currencyapi.net',
'MINING_CHECKER_FX_CURRENCIES_URL' => 'https://currencyapi.net',
'MINING_CHECKER_FX_API_KEY' => 'eb18ce459ffb0461c59229b478f2e00388d1',
'MINING_CHECKER_FX_TIMEOUT' => '10',
'MINING_CHECKER_FX_CACHE_TTL' => '21600',
'MINING_CHECKER_FX_AUTO_FETCH_ON_MISS' => 'false',
];

View File

@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
return [
'default_project_key' => getenv('MINING_CHECKER_DEFAULT_PROJECT_KEY') ?: 'doge-main',
'use_project_database' => true,
'table_prefix' => 'miningcheck_',
'uploads_dir' => dirname(__DIR__, 3) . '/data/mining-checker/uploads',
'uploads_public_prefix' => '/data/mining-checker/uploads',
'ocr' => [
'providers' => array_values(array_filter(array_map(
static fn (string $provider): string => trim(strtolower($provider)),
explode(',', getenv('MINING_CHECKER_OCR_PROVIDERS') ?: 'ocrspace,tesseract')
))),
'ocrspace' => [
'url' => getenv('MINING_CHECKER_OCR_SPACE_URL') ?: 'https://api.ocr.space/parse/image',
'api_key' => getenv('MINING_CHECKER_OCR_SPACE_API_KEY') ?: 'K83150278888957',
'language' => getenv('MINING_CHECKER_OCR_SPACE_LANGUAGE') ?: 'eng',
'engine' => (int) (getenv('MINING_CHECKER_OCR_SPACE_ENGINE') ?: 2),
'scale' => getenv('MINING_CHECKER_OCR_SPACE_SCALE') ?: 'true',
'detect_orientation' => getenv('MINING_CHECKER_OCR_SPACE_DETECT_ORIENTATION') ?: 'true',
'is_table' => getenv('MINING_CHECKER_OCR_SPACE_IS_TABLE') ?: 'false',
'timeout' => (int) (getenv('MINING_CHECKER_OCR_SPACE_TIMEOUT') ?: 25),
],
'tesseract' => [
'binary' => getenv('MINING_CHECKER_TESSERACT_BIN') ?: 'tesseract',
'language' => getenv('MINING_CHECKER_TESSERACT_LANG') ?: 'eng',
],
],
'fx' => [
'provider' => getenv('MINING_CHECKER_FX_PROVIDER') ?: 'currencyapi',
'url' => getenv('MINING_CHECKER_FX_URL') ?: 'https://currencyapi.net',
'currencies_url' => getenv('MINING_CHECKER_FX_CURRENCIES_URL') ?: 'https://currencyapi.net',
'api_key' => getenv('MINING_CHECKER_FX_API_KEY') ?: 'eb18ce459ffb0461c59229b478f2e00388d1',
'timeout' => (int) (getenv('MINING_CHECKER_FX_TIMEOUT') ?: 10),
'cache_ttl' => (int) (getenv('MINING_CHECKER_FX_CACHE_TTL') ?: 21600),
'auto_fetch_on_miss' => filter_var(
getenv('MINING_CHECKER_FX_AUTO_FETCH_ON_MISS') ?: 'false',
FILTER_VALIDATE_BOOL
),
],
'debug' => [
'enabled' => filter_var(getenv('MINING_CHECKER_DEBUG') ?: 'false', FILTER_VALIDATE_BOOL),
'dir' => getenv('MINING_CHECKER_DEBUG_DIR') ?: dirname(__DIR__, 3) . '/data/mining-checker/debug',
],
];

View File

@@ -0,0 +1,125 @@
# Mining-Checker Modul
## Zweck
Das Modul erfasst DOGE-Mining-Messpunkte, analysiert OCR-Vorschlaege aus Screenshots, speichert Messreihen projektbezogen und berechnet Performance-, Kurs- und Zielmetriken.
## Ordnerstruktur
```text
modules/mining-checker/
|-- api/
|-- assets/
| |-- css/
| `-- js/
|-- config/
|-- docs/
|-- pages/
|-- partials/
|-- sql/
| `-- migrations/
|-- src/
| |-- Api/
| |-- Domain/
| |-- Infrastructure/
| `-- Support/
|-- storage/uploads/
|-- bootstrap.php
`-- module.json
```
## API-Endpunkte
- `GET /api/mining-checker/v1/health`
- `GET /api/mining-checker/v1/projects/{projectKey}/bootstrap`
- `GET /api/mining-checker/v1/projects/{projectKey}/measurements`
- `POST /api/mining-checker/v1/projects/{projectKey}/measurements`
- `POST /api/mining-checker/v1/projects/{projectKey}/ocr-preview`
- `GET /api/mining-checker/v1/projects/{projectKey}/settings`
- `PUT /api/mining-checker/v1/projects/{projectKey}/settings`
- `GET /api/mining-checker/v1/projects/{projectKey}/targets`
- `POST /api/mining-checker/v1/projects/{projectKey}/targets`
- `PATCH /api/mining-checker/v1/projects/{projectKey}/targets/{targetId}`
- `GET /api/mining-checker/v1/projects/{projectKey}/dashboards`
- `POST /api/mining-checker/v1/projects/{projectKey}/dashboards`
- `GET /api/mining-checker/v1/projects/{projectKey}/dashboard-data`
- `POST /api/mining-checker/v1/projects/{projectKey}/seed-import`
- `GET /api/mining-checker/v1/projects/{projectKey}/schema-status`
- `POST /api/mining-checker/v1/projects/{projectKey}/initialize`
- `POST /api/mining-checker/v1/projects/{projectKey}/upgrade`
- `GET /api/mining-checker/v1/projects/{projectKey}/connection-test`
- `POST /api/mining-checker/v1/projects/{projectKey}/fx-refresh`
- `POST /api/mining-checker/v1/projects/{projectKey}/currencies-refresh`
- `GET /api/mining-checker/v1/projects/{projectKey}/fx-history`
## Integration
1. SQL aus dem passenden Dialekt-Schema ausfuehren:
- MySQL/MariaDB: `sql/schema.mysql.sql`
- PostgreSQL: `sql/schema.pgsql.sql`
- `sql/schema.sql` bleibt der Rueckfall fuer bestehende Setups
2. Das Modul nutzt bewusst dieselbe Projekt-Datenbank wie die Anwendung und legt seine Tabellen mit dem Praefix `miningcheck_` an.
3. Modulroute ueber `/module/mining-checker` aufrufen.
4. REST-API wird ueber `/api/mining-checker/...` vom Hauptprojekt geroutet.
Hinweis:
Wenn beim ersten API-Zugriff noch keine `miningcheck_*` Tabellen vorhanden sind, importiert das Modul automatisch das zum aktiven PDO-Treiber passende Schema.
Seed-Daten werden dabei nicht automatisch eingespielt.
Fuer eine manuelle Initialisierung, ein inkrementelles Upgrade oder einen Reset gibt es zusaetzlich `schema-status`, `upgrade` und `initialize`. Mit `{ "drop_existing": true }` werden vorhandene `miningcheck_*` Tabellen inklusive Daten geloescht und das Schema neu angelegt.
## OCR-Hinweis
Das Modul unterstuetzt einen OCR-Provider-Stack. Standardmaessig wird zuerst `ocr.space` verwendet und danach optional auf lokales `tesseract` zurueckgefallen.
Empfohlene Umgebungsvariablen:
- `MINING_CHECKER_OCR_PROVIDERS=ocrspace,tesseract`
- `MINING_CHECKER_OCR_SPACE_URL=https://api.ocr.space/parse/image`
- `MINING_CHECKER_OCR_SPACE_API_KEY=...`
- `MINING_CHECKER_OCR_SPACE_LANGUAGE=eng`
- `MINING_CHECKER_OCR_SPACE_ENGINE=2`
- `MINING_CHECKER_OCR_SPACE_SCALE=true`
- `MINING_CHECKER_OCR_SPACE_DETECT_ORIENTATION=true`
- `MINING_CHECKER_OCR_SPACE_IS_TABLE=false`
- `MINING_CHECKER_OCR_SPACE_TIMEOUT=25`
- `MINING_CHECKER_TESSERACT_BIN=/usr/bin/tesseract`
- `MINING_CHECKER_TESSERACT_LANG=eng`
Laut OCR.space-Doku wird `POST https://api.ocr.space/parse/image` mit `file`, Header-`apikey`, optional `language`, `scale`, `detectOrientation`, `isTable` und `OCREngine` verwendet. Der Modulparser wertet die OCR.space-Felder `ParsedResults`, `ParsedText`, `IsErroredOnProcessing`, `ErrorMessage` und `OCRExitCode` aus. Quellen: https://ocr.space/ocrapi
## Wechselkurse
Der Endpunkt `POST /api/mining-checker/v1/projects/{projectKey}/fx-refresh` holt aktuelle Fiat-Wechselkurse von `currencyapi.net` und speichert sie in `miningcheck_fx_rates`.
Empfohlene Umgebungsvariablen:
- `MINING_CHECKER_FX_PROVIDER=currencyapi`
- `MINING_CHECKER_FX_URL=https://currencyapi.net`
- `MINING_CHECKER_FX_CURRENCIES_URL=https://currencyapi.net`
- `MINING_CHECKER_FX_API_KEY=...`
- `MINING_CHECKER_FX_TIMEOUT=10`
- `MINING_CHECKER_FX_CACHE_TTL=21600`
- `MINING_CHECKER_FX_AUTO_FETCH_ON_MISS=false`
Optionaler JSON-Body:
- `base`: Standard `EUR`
- `symbols`: wird aktuell ignoriert; der Mining-Checker speichert immer den kompletten Waehrungssatz des Fetches
Beispiel:
```json
{
"base": "EUR"
}
```
`currencyapi.net` wird ueber `GET /api/v2/rates?base=...&output=json&key=...` abgefragt. Aus dem Response werden `base`, `rates` und `updated` uebernommen; `valid` muss `true` sein. Die API liefert mehr Waehrungen als benoetigt, der Mining-Checker filtert lokal auf die angeforderten Zielwaehrungen und speichert die Kurse danach normalisiert in `miningcheck_fx_fetches` und `miningcheck_fx_rates`.
Pro Abruf entsteht genau ein Datensatz in `miningcheck_fx_fetches` mit Basiswaehrung, Provider und Stichtag. Alle Einzelkurse dieses Abrufs liegen darunter in `miningcheck_fx_rates` und teilen sich dieselbe `fetch_id`. Dadurch lassen sich Kurse innerhalb desselben Abrufs konsistent gegeneinander umrechnen.
Wenn fuer eine benoetigte Umrechnung noch kein passender FIAT-Fetch gespeichert ist, faellt der Mining-Checker auf vorhandene Kurs-Snapshots aus den Mining-Messpunkten (`measurement_rates`) zurueck.
Mit `POST /api/mining-checker/v1/projects/{projectKey}/currencies-refresh` kann die Waehrungstabelle einmalig oder bei Bedarf aus `GET /api/v2/currencies?output=json&key=...` synchronisiert werden. Dabei werden Code, Name, Symbol und Sortierung in `miningcheck_currencies` gespeichert.
Die im Tab `Waehrungen` ausgewaehlten Favoriten werden in `miningcheck_settings.preferred_currencies` gespeichert. Dadurch ist die Auswahl geraeteuebergreifend verfuegbar. Fuer bestehende Installationen ist dafuer einmal ein Schema-Upgrade noetig.

View File

@@ -0,0 +1,10 @@
{
"name": "Mining-Checker",
"description": "Erfassung, OCR-Auswertung und Analyse von DOGE-Mining-Messwerten als eingebettetes Modul.",
"enabled_by_default": true,
"auth": {
"required": true,
"users": [],
"groups": []
}
}

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
require_once dirname(__DIR__) . '/bootstrap.php';
$moduleConfig = require dirname(__DIR__) . '/config/module.php';
$defaultProjectKey = (string) ($moduleConfig['default_project_key'] ?? 'doge-main');
$fxConfig = (array) ($moduleConfig['fx'] ?? []);
$fxProvider = (string) ($fxConfig['provider'] ?? 'currencyapi');
$fxBaseUrl = rtrim((string) ($fxConfig['url'] ?? 'https://currencyapi.net'), '/');
$fxCurrenciesUrl = rtrim((string) ($fxConfig['currencies_url'] ?? $fxBaseUrl), '/');
$fxApiKey = (string) ($fxConfig['api_key'] ?? '');
$fxApiKeyMasked = $fxApiKey === ''
? ''
: (strlen($fxApiKey) <= 10 ? $fxApiKey : substr($fxApiKey, 0, 6) . '...' . substr($fxApiKey, -4));
$moduleCss = file_get_contents(dirname(__DIR__) . '/assets/css/app.css') ?: '';
$moduleJs = file_get_contents(dirname(__DIR__) . '/assets/js/app.js') ?: '';
$moduleJs = str_replace('</script>', '<\/script>', $moduleJs);
?>
<div class="module-host-card mining-checker-host">
<div id="mining-checker-app"
data-default-project-key="<?= e($defaultProjectKey) ?>"
data-api-base="/api/mining-checker/v1"
data-fx-provider="<?= e($fxProvider) ?>"
data-fx-url="<?= e($fxBaseUrl) ?>"
data-fx-currencies-url="<?= e($fxCurrenciesUrl) ?>"
data-fx-api-key-mask="<?= e($fxApiKeyMasked) ?>"></div>
</div>
<style><?= $moduleCss ?></style>
<script crossorigin src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
<script><?= $moduleJs ?></script>

View File

View File

@@ -0,0 +1,123 @@
CREATE TABLE IF NOT EXISTS miningcheck_projects (
project_key VARCHAR(64) NOT NULL PRIMARY KEY,
project_name VARCHAR(160) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS miningcheck_currencies (
code VARCHAR(10) NOT NULL PRIMARY KEY,
name VARCHAR(64) NOT NULL,
symbol VARCHAR(8) NULL,
is_active TINYINT(1) NOT NULL DEFAULT 1,
is_crypto TINYINT(1) NOT NULL DEFAULT 0,
sort_order INT NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS miningcheck_currency_aliases (
alias_code VARCHAR(10) NOT NULL PRIMARY KEY,
currency_code VARCHAR(10) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_mining_currency_aliases_currency FOREIGN KEY (currency_code) REFERENCES miningcheck_currencies(code) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS miningcheck_settings (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
project_key VARCHAR(64) NOT NULL,
baseline_measured_at DATETIME NOT NULL,
baseline_coins_total DECIMAL(20,6) NOT NULL,
daily_cost_amount DECIMAL(20,10) NOT NULL,
daily_cost_currency VARCHAR(10) NOT NULL,
report_currency VARCHAR(10) NULL,
crypto_currency VARCHAR(10) NULL,
display_timezone VARCHAR(64) NULL,
fx_max_age_hours DECIMAL(10,2) NULL,
module_theme_mode VARCHAR(16) NULL,
module_theme_accent VARCHAR(16) NULL,
preferred_currencies JSON NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_mining_settings_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
CONSTRAINT fk_mining_settings_daily_cost_currency_currency FOREIGN KEY (daily_cost_currency) REFERENCES miningcheck_currencies(code),
CONSTRAINT fk_mining_settings_report_currency_currency FOREIGN KEY (report_currency) REFERENCES miningcheck_currencies(code),
CONSTRAINT fk_mining_settings_crypto_currency_currency FOREIGN KEY (crypto_currency) REFERENCES miningcheck_currencies(code),
CONSTRAINT uq_mining_settings_project UNIQUE (project_key)
);
CREATE TABLE IF NOT EXISTS miningcheck_cost_plans (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
project_key VARCHAR(64) NOT NULL,
label VARCHAR(120) NOT NULL,
starts_at DATETIME NOT NULL,
runtime_months INT NOT NULL,
auto_renew TINYINT(1) NOT NULL DEFAULT 0,
base_price_amount DECIMAL(20,8) NULL,
payment_type VARCHAR(10) NOT NULL DEFAULT 'fiat',
total_cost_amount DECIMAL(20,8) NOT NULL,
currency VARCHAR(10) NOT NULL,
note TEXT NULL,
is_active TINYINT(1) NOT NULL DEFAULT 1,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_mining_cost_plans_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
CONSTRAINT fk_mining_cost_plans_currency_currency FOREIGN KEY (currency) REFERENCES miningcheck_currencies(code)
);
CREATE INDEX idx_miningcheck_cost_plans_project_start
ON miningcheck_cost_plans(project_key, starts_at);
CREATE TABLE IF NOT EXISTS miningcheck_measurements (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
project_key VARCHAR(64) NOT NULL,
measured_at DATETIME NOT NULL,
coins_total DECIMAL(20,6) NOT NULL,
price_per_coin DECIMAL(20,8) NULL,
price_currency VARCHAR(10) NULL,
note TEXT NULL,
source ENUM('manual', 'image_ocr', 'seed_import') NOT NULL,
image_path VARCHAR(255) NULL,
ocr_raw_text MEDIUMTEXT NULL,
ocr_confidence DECIMAL(6,4) NULL,
ocr_flags JSON NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_mining_measurements_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
CONSTRAINT fk_mining_measurements_price_currency_currency FOREIGN KEY (price_currency) REFERENCES miningcheck_currencies(code),
CONSTRAINT uq_mining_measurements_unique UNIQUE (project_key, measured_at, coins_total)
);
CREATE INDEX idx_miningcheck_measurements_project_measured_at
ON miningcheck_measurements(project_key, measured_at);
CREATE TABLE IF NOT EXISTS miningcheck_targets (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
project_key VARCHAR(64) NOT NULL,
label VARCHAR(120) NOT NULL,
target_amount_fiat DECIMAL(20,2) NOT NULL,
currency VARCHAR(10) NOT NULL,
miner_offer_id BIGINT UNSIGNED NULL,
is_active TINYINT(1) NOT NULL DEFAULT 1,
sort_order INT NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_mining_targets_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
CONSTRAINT fk_mining_targets_currency_currency FOREIGN KEY (currency) REFERENCES miningcheck_currencies(code),
CONSTRAINT fk_mining_targets_offer FOREIGN KEY (miner_offer_id) REFERENCES miningcheck_miner_offers(id) ON DELETE SET NULL,
CONSTRAINT uq_mining_targets_project_label UNIQUE (project_key, label)
);
CREATE TABLE IF NOT EXISTS miningcheck_dashboard_definitions (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
project_key VARCHAR(64) NOT NULL,
name VARCHAR(160) NOT NULL,
chart_type ENUM('line', 'bar', 'area', 'table') NOT NULL,
x_field VARCHAR(64) NOT NULL,
y_field VARCHAR(64) NOT NULL,
aggregation VARCHAR(32) NOT NULL DEFAULT 'none',
filters_json JSON NULL,
is_active TINYINT(1) NOT NULL DEFAULT 1,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_mining_dashboards_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
CONSTRAINT uq_mining_dashboards_project_name UNIQUE (project_key, name)
);

View File

@@ -0,0 +1,91 @@
INSERT INTO miningcheck_projects (project_key, project_name)
VALUES ('doge-main', 'DOGE Mining Main')
ON DUPLICATE KEY UPDATE project_name = VALUES(project_name);
INSERT INTO miningcheck_currencies (code, name, symbol, is_active, sort_order)
VALUES
('EUR', 'Euro', 'EUR', 1, 10),
('USD', 'US-Dollar', 'USD', 1, 20),
('DOGE', 'Dogecoin', 'DOGE', 1, 100),
('BTC', 'Bitcoin', 'BTC', 1, 110),
('ETH', 'Ethereum', 'ETH', 1, 120),
('LTC', 'Litecoin', 'LTC', 1, 130),
('USDT', 'Tether', 'USDT', 1, 140),
('USDC', 'USD Coin', 'USDC', 1, 150)
ON DUPLICATE KEY UPDATE
name = VALUES(name),
symbol = VALUES(symbol),
is_active = VALUES(is_active),
sort_order = VALUES(sort_order);
INSERT INTO miningcheck_settings (
project_key,
baseline_measured_at,
baseline_coins_total,
daily_cost_amount,
daily_cost_currency,
preferred_currencies
)
VALUES (
'doge-main',
'2026-03-16 01:32:00',
27.617864,
0.3123287671,
'EUR',
'["DOGE","USD","EUR"]'
)
ON DUPLICATE KEY UPDATE
baseline_measured_at = VALUES(baseline_measured_at),
baseline_coins_total = VALUES(baseline_coins_total),
daily_cost_amount = VALUES(daily_cost_amount),
daily_cost_currency = VALUES(daily_cost_currency),
preferred_currencies = VALUES(preferred_currencies);
INSERT INTO miningcheck_targets (project_key, label, target_amount_fiat, currency, is_active, sort_order)
VALUES
('doge-main', 'Ziel A', 10.82, 'EUR', 1, 10),
('doge-main', 'Ziel B', 19.50, 'EUR', 1, 20)
ON DUPLICATE KEY UPDATE
target_amount_fiat = VALUES(target_amount_fiat),
currency = VALUES(currency),
is_active = VALUES(is_active),
sort_order = VALUES(sort_order);
INSERT INTO miningcheck_dashboard_definitions (
project_key, name, chart_type, x_field, y_field, aggregation, filters_json, is_active
)
VALUES
('doge-main', 'Mining-Verlauf', 'line', 'measured_at', 'coins_total', 'none', NULL, 1),
('doge-main', 'Performance-Verlauf', 'area', 'measured_date', 'doge_per_day_interval', 'avg', NULL, 1),
('doge-main', 'Kurs-Verlauf', 'line', 'measured_at', 'price_per_coin', 'none', '{"currency":"EUR"}', 1)
ON DUPLICATE KEY UPDATE
chart_type = VALUES(chart_type),
x_field = VALUES(x_field),
y_field = VALUES(y_field),
aggregation = VALUES(aggregation),
filters_json = VALUES(filters_json),
is_active = VALUES(is_active);
INSERT INTO miningcheck_measurements (
project_key, measured_at, coins_total, price_per_coin, price_currency, note, source, image_path, ocr_raw_text, ocr_confidence, ocr_flags
)
VALUES
('doge-main', '2026-03-16 01:32:00', 27.617864, NULL, NULL, 'Basiswert', 'seed_import', NULL, NULL, NULL, NULL),
('doge-main', '2026-03-17 02:41:00', 33.751904, NULL, NULL, 'Kurs wurde spaeter separat genannt, aber nicht sicher exakt diesem Messpunkt zuordenbar', 'seed_import', NULL, NULL, NULL, NULL),
('doge-main', '2026-03-17 07:15:00', 34.825695, 0.10037, 'EUR', NULL, 'seed_import', NULL, NULL, NULL, NULL),
('doge-main', '2026-03-17 13:21:00', 36.328140, 0.10002, 'EUR', NULL, 'seed_import', NULL, NULL, NULL, NULL),
('doge-main', '2026-03-17 18:53:00', 37.682757, 0.10062, 'EUR', NULL, 'seed_import', NULL, NULL, NULL, NULL),
('doge-main', '2026-03-18 00:08:00', 38.934351, 0.10097, 'EUR', NULL, 'seed_import', NULL, NULL, NULL, NULL),
('doge-main', '2026-03-18 07:40:00', 40.782006, 0.10040, 'EUR', NULL, 'seed_import', NULL, NULL, NULL, NULL),
('doge-main', '2026-03-18 13:32:00', 42.223449, 0.09607, 'EUR', 'Originaleingabe im Chat: 18.6.2026', 'seed_import', NULL, NULL, NULL, NULL),
('doge-main', '2026-03-18 21:15:00', 44.191018, 0.09446, 'EUR', NULL, 'seed_import', NULL, NULL, NULL, NULL),
('doge-main', '2026-03-19 00:09:00', 44.908500, 0.09507, 'EUR', NULL, 'seed_import', NULL, NULL, NULL, NULL),
('doge-main', '2026-03-19 02:33:00', 45.546924, 0.09499, 'USD', 'aus Screenshot extrahiert', 'seed_import', NULL, NULL, NULL, JSON_ARRAY('source:screenshot')),
('doge-main', '2026-03-19 07:01:00', 46.694127, 0.09460, 'USD', 'aus Screenshot extrahiert', 'seed_import', NULL, NULL, NULL, JSON_ARRAY('source:screenshot')),
('doge-main', '2026-03-19 12:24:00', 48.056494, 0.09419, 'USD', 'aus Screenshot extrahiert', 'seed_import', NULL, NULL, NULL, JSON_ARRAY('source:screenshot')),
('doge-main', '2026-03-19 21:39:00', 50.427943, 0.09361, 'USD', 'aus Screenshot extrahiert', 'seed_import', NULL, NULL, NULL, JSON_ARRAY('source:screenshot'))
ON DUPLICATE KEY UPDATE
price_per_coin = VALUES(price_per_coin),
price_currency = VALUES(price_currency),
note = VALUES(note),
source = VALUES(source);

View File

@@ -0,0 +1,34 @@
BEGIN;
ALTER TABLE miningcheck_settings
ADD COLUMN IF NOT EXISTS display_timezone VARCHAR(64);
UPDATE miningcheck_settings
SET display_timezone = 'Europe/Berlin'
WHERE display_timezone IS NULL OR BTRIM(display_timezone) = '';
UPDATE miningcheck_settings
SET baseline_measured_at = ((baseline_measured_at AT TIME ZONE 'Europe/Berlin') AT TIME ZONE 'UTC')
WHERE baseline_measured_at IS NOT NULL;
UPDATE miningcheck_cost_plans
SET starts_at = ((starts_at AT TIME ZONE 'Europe/Berlin') AT TIME ZONE 'UTC')
WHERE starts_at IS NOT NULL;
UPDATE miningcheck_measurements
SET measured_at = ((measured_at AT TIME ZONE 'Europe/Berlin') AT TIME ZONE 'UTC')
WHERE measured_at IS NOT NULL;
UPDATE miningcheck_payouts
SET payout_at = ((payout_at AT TIME ZONE 'Europe/Berlin') AT TIME ZONE 'UTC')
WHERE payout_at IS NOT NULL;
UPDATE miningcheck_purchased_miners
SET purchased_at = ((purchased_at AT TIME ZONE 'Europe/Berlin') AT TIME ZONE 'UTC')
WHERE purchased_at IS NOT NULL;
UPDATE miningcheck_fx_fetches
SET fetched_at = ((fetched_at AT TIME ZONE 'Europe/Berlin') AT TIME ZONE 'UTC')
WHERE fetched_at IS NOT NULL;
COMMIT;

View File

@@ -0,0 +1,72 @@
BEGIN;
CREATE TABLE IF NOT EXISTS miningcheck_cost_plans_legacy AS
SELECT *
FROM miningcheck_cost_plans
WHERE 1 = 0;
INSERT INTO miningcheck_cost_plans_legacy
SELECT cp.*
FROM miningcheck_cost_plans cp
LEFT JOIN miningcheck_cost_plans_legacy legacy
ON legacy.id = cp.id
WHERE legacy.id IS NULL;
INSERT INTO miningcheck_purchased_miners (
project_key,
miner_offer_id,
purchased_at,
label,
runtime_months,
mining_speed_value,
mining_speed_unit,
bonus_speed_value,
bonus_speed_unit,
total_cost_amount,
currency,
usd_reference_amount,
reference_price_amount,
reference_price_currency,
auto_renew,
note,
is_active
)
SELECT
cp.project_key,
NULL AS miner_offer_id,
cp.starts_at AS purchased_at,
cp.label,
cp.runtime_months,
cp.mining_speed_value,
cp.mining_speed_unit,
cp.bonus_speed_value,
cp.bonus_speed_unit,
cp.total_cost_amount,
cp.currency,
CASE
WHEN COALESCE(s.report_currency, 'EUR') = 'USD' THEN cp.base_price_amount
ELSE NULL
END AS usd_reference_amount,
cp.base_price_amount AS reference_price_amount,
COALESCE(s.report_currency, 'EUR') AS reference_price_currency,
cp.auto_renew,
CASE
WHEN cp.note IS NULL OR BTRIM(cp.note) = '' THEN 'Migriert aus miningcheck_cost_plans'
ELSE cp.note || ' | Migriert aus miningcheck_cost_plans'
END AS note,
cp.is_active
FROM miningcheck_cost_plans cp
LEFT JOIN miningcheck_settings s
ON s.project_key = cp.project_key
LEFT JOIN miningcheck_purchased_miners pm
ON pm.project_key = cp.project_key
AND pm.miner_offer_id IS NULL
AND pm.purchased_at = cp.starts_at
AND pm.label = cp.label
AND pm.total_cost_amount = cp.total_cost_amount
AND pm.currency = cp.currency
WHERE pm.id IS NULL;
DELETE FROM miningcheck_cost_plans;
COMMIT;

View File

@@ -0,0 +1,15 @@
BEGIN;
ALTER TABLE miningcheck_settings
ADD COLUMN IF NOT EXISTS module_theme_mode VARCHAR(16),
ADD COLUMN IF NOT EXISTS module_theme_accent VARCHAR(16);
UPDATE miningcheck_settings
SET module_theme_mode = 'inherit'
WHERE module_theme_mode IS NULL OR BTRIM(module_theme_mode) = '';
UPDATE miningcheck_settings
SET module_theme_accent = 'teal'
WHERE module_theme_accent IS NULL OR BTRIM(module_theme_accent) = '';
COMMIT;

View File

@@ -0,0 +1,182 @@
-- Bestehende benutzerspezifische Mining-Daten werden diesem Keycloak-Sub zugeordnet:
-- adea1766-5d1c-4c2e-98bd-5239861f745f
-- Die Keycloak-Sub ist stabiler als preferred_username und wird fuer alle benutzerspezifischen Mining-Daten genutzt.
BEGIN;
ALTER TABLE miningcheck_settings
ADD COLUMN IF NOT EXISTS owner_sub VARCHAR(128);
ALTER TABLE miningcheck_cost_plans
ADD COLUMN IF NOT EXISTS owner_sub VARCHAR(128);
ALTER TABLE miningcheck_measurements
ADD COLUMN IF NOT EXISTS owner_sub VARCHAR(128);
ALTER TABLE miningcheck_measurement_rates
ADD COLUMN IF NOT EXISTS owner_sub VARCHAR(128);
ALTER TABLE miningcheck_payouts
ADD COLUMN IF NOT EXISTS owner_sub VARCHAR(128);
ALTER TABLE miningcheck_targets
ADD COLUMN IF NOT EXISTS owner_sub VARCHAR(128);
ALTER TABLE miningcheck_dashboard_definitions
ADD COLUMN IF NOT EXISTS owner_sub VARCHAR(128);
ALTER TABLE miningcheck_purchased_miners
ADD COLUMN IF NOT EXISTS owner_sub VARCHAR(128);
UPDATE miningcheck_settings
SET owner_sub = 'adea1766-5d1c-4c2e-98bd-5239861f745f'
WHERE owner_sub IS NULL OR BTRIM(owner_sub) = '';
UPDATE miningcheck_cost_plans
SET owner_sub = 'adea1766-5d1c-4c2e-98bd-5239861f745f'
WHERE owner_sub IS NULL OR BTRIM(owner_sub) = '';
UPDATE miningcheck_measurements
SET owner_sub = 'adea1766-5d1c-4c2e-98bd-5239861f745f'
WHERE owner_sub IS NULL OR BTRIM(owner_sub) = '';
UPDATE miningcheck_measurement_rates mr
SET owner_sub = m.owner_sub
FROM miningcheck_measurements m
WHERE mr.measurement_id = m.id
AND (mr.owner_sub IS NULL OR BTRIM(mr.owner_sub) = '');
UPDATE miningcheck_measurement_rates
SET owner_sub = 'adea1766-5d1c-4c2e-98bd-5239861f745f'
WHERE owner_sub IS NULL OR BTRIM(owner_sub) = '';
UPDATE miningcheck_payouts
SET owner_sub = 'adea1766-5d1c-4c2e-98bd-5239861f745f'
WHERE owner_sub IS NULL OR BTRIM(owner_sub) = '';
UPDATE miningcheck_targets
SET owner_sub = 'adea1766-5d1c-4c2e-98bd-5239861f745f'
WHERE owner_sub IS NULL OR BTRIM(owner_sub) = '';
UPDATE miningcheck_dashboard_definitions
SET owner_sub = 'adea1766-5d1c-4c2e-98bd-5239861f745f'
WHERE owner_sub IS NULL OR BTRIM(owner_sub) = '';
UPDATE miningcheck_purchased_miners
SET owner_sub = 'adea1766-5d1c-4c2e-98bd-5239861f745f'
WHERE owner_sub IS NULL OR BTRIM(owner_sub) = '';
ALTER TABLE miningcheck_settings
ALTER COLUMN owner_sub SET NOT NULL;
ALTER TABLE miningcheck_cost_plans
ALTER COLUMN owner_sub SET NOT NULL;
ALTER TABLE miningcheck_measurements
ALTER COLUMN owner_sub SET NOT NULL;
ALTER TABLE miningcheck_measurement_rates
ALTER COLUMN owner_sub SET NOT NULL;
ALTER TABLE miningcheck_payouts
ALTER COLUMN owner_sub SET NOT NULL;
ALTER TABLE miningcheck_targets
ALTER COLUMN owner_sub SET NOT NULL;
ALTER TABLE miningcheck_dashboard_definitions
ALTER COLUMN owner_sub SET NOT NULL;
ALTER TABLE miningcheck_purchased_miners
ALTER COLUMN owner_sub SET NOT NULL;
ALTER TABLE miningcheck_settings
DROP CONSTRAINT IF EXISTS miningcheck_settings_project_key_key;
ALTER TABLE miningcheck_measurements
DROP CONSTRAINT IF EXISTS uq_mining_measurements_unique;
ALTER TABLE miningcheck_targets
DROP CONSTRAINT IF EXISTS uq_mining_targets_project_label;
ALTER TABLE miningcheck_dashboard_definitions
DROP CONSTRAINT IF EXISTS uq_mining_dashboards_project_name;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM information_schema.table_constraints
WHERE table_schema = current_schema()
AND table_name = 'miningcheck_settings'
AND constraint_name = 'uq_mining_settings_project_owner'
) THEN
ALTER TABLE miningcheck_settings
ADD CONSTRAINT uq_mining_settings_project_owner UNIQUE (project_key, owner_sub);
END IF;
END $$;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM information_schema.table_constraints
WHERE table_schema = current_schema()
AND table_name = 'miningcheck_measurements'
AND constraint_name = 'uq_mining_measurements_unique'
) THEN
ALTER TABLE miningcheck_measurements
ADD CONSTRAINT uq_mining_measurements_unique UNIQUE (project_key, owner_sub, measured_at, coins_total);
END IF;
END $$;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM information_schema.table_constraints
WHERE table_schema = current_schema()
AND table_name = 'miningcheck_targets'
AND constraint_name = 'uq_mining_targets_project_label'
) THEN
ALTER TABLE miningcheck_targets
ADD CONSTRAINT uq_mining_targets_project_label UNIQUE (project_key, owner_sub, label);
END IF;
END $$;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM information_schema.table_constraints
WHERE table_schema = current_schema()
AND table_name = 'miningcheck_dashboard_definitions'
AND constraint_name = 'uq_mining_dashboards_project_name'
) THEN
ALTER TABLE miningcheck_dashboard_definitions
ADD CONSTRAINT uq_mining_dashboards_project_name UNIQUE (project_key, owner_sub, name);
END IF;
END $$;
CREATE INDEX IF NOT EXISTS idx_miningcheck_cost_plans_project_owner_start
ON miningcheck_cost_plans(project_key, owner_sub, starts_at);
CREATE INDEX IF NOT EXISTS idx_miningcheck_measurements_project_owner_measured_at
ON miningcheck_measurements(project_key, owner_sub, measured_at);
CREATE INDEX IF NOT EXISTS idx_miningcheck_measurement_rates_project_owner_measurement
ON miningcheck_measurement_rates(project_key, owner_sub, measurement_id);
CREATE INDEX IF NOT EXISTS idx_miningcheck_payouts_project_owner_payout_at
ON miningcheck_payouts(project_key, owner_sub, payout_at);
CREATE INDEX IF NOT EXISTS idx_miningcheck_targets_project_owner
ON miningcheck_targets(project_key, owner_sub, sort_order, id);
CREATE INDEX IF NOT EXISTS idx_miningcheck_dashboards_project_owner
ON miningcheck_dashboard_definitions(project_key, owner_sub, id);
CREATE INDEX IF NOT EXISTS idx_miningcheck_purchased_miners_project_owner_purchased_at
ON miningcheck_purchased_miners(project_key, owner_sub, purchased_at);
COMMIT;

View File

@@ -0,0 +1,226 @@
CREATE TABLE IF NOT EXISTS miningcheck_projects (
project_key VARCHAR(64) NOT NULL PRIMARY KEY,
project_name VARCHAR(160) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS miningcheck_currencies (
code VARCHAR(10) NOT NULL PRIMARY KEY,
name VARCHAR(64) NOT NULL,
symbol VARCHAR(8) NULL,
is_active TINYINT(1) NOT NULL DEFAULT 1,
is_crypto TINYINT(1) NOT NULL DEFAULT 0,
sort_order INT NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS miningcheck_currency_aliases (
alias_code VARCHAR(10) NOT NULL PRIMARY KEY,
currency_code VARCHAR(10) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_mining_currency_aliases_currency FOREIGN KEY (currency_code) REFERENCES miningcheck_currencies(code) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS miningcheck_settings (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
project_key VARCHAR(64) NOT NULL,
baseline_measured_at DATETIME NOT NULL,
baseline_coins_total DECIMAL(20,6) NOT NULL,
daily_cost_amount DECIMAL(20,10) NOT NULL,
daily_cost_currency VARCHAR(10) NOT NULL,
report_currency VARCHAR(10) NULL,
crypto_currency VARCHAR(10) NULL,
display_timezone VARCHAR(64) NULL,
fx_max_age_hours DECIMAL(10,2) NULL,
module_theme_mode VARCHAR(16) NULL,
module_theme_accent VARCHAR(16) NULL,
preferred_currencies JSON NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_mining_settings_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
CONSTRAINT fk_mining_settings_daily_cost_currency_currency FOREIGN KEY (daily_cost_currency) REFERENCES miningcheck_currencies(code),
CONSTRAINT fk_mining_settings_report_currency_currency FOREIGN KEY (report_currency) REFERENCES miningcheck_currencies(code),
CONSTRAINT fk_mining_settings_crypto_currency_currency FOREIGN KEY (crypto_currency) REFERENCES miningcheck_currencies(code),
CONSTRAINT uq_mining_settings_project UNIQUE (project_key)
);
CREATE TABLE IF NOT EXISTS miningcheck_cost_plans (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
project_key VARCHAR(64) NOT NULL,
label VARCHAR(120) NOT NULL,
starts_at DATETIME NOT NULL,
runtime_months INT NOT NULL,
mining_speed_value DECIMAL(20,4) NULL,
mining_speed_unit VARCHAR(8) NULL,
bonus_speed_value DECIMAL(20,4) NULL,
bonus_speed_unit VARCHAR(8) NULL,
auto_renew TINYINT(1) NOT NULL DEFAULT 0,
base_price_amount DECIMAL(20,8) NULL,
payment_type VARCHAR(10) NOT NULL DEFAULT 'fiat',
total_cost_amount DECIMAL(20,8) NOT NULL,
currency VARCHAR(10) NOT NULL,
note TEXT NULL,
is_active TINYINT(1) NOT NULL DEFAULT 1,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_mining_cost_plans_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
CONSTRAINT fk_mining_cost_plans_currency_currency FOREIGN KEY (currency) REFERENCES miningcheck_currencies(code)
);
CREATE INDEX idx_miningcheck_cost_plans_project_start
ON miningcheck_cost_plans(project_key, starts_at);
CREATE TABLE IF NOT EXISTS miningcheck_measurements (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
project_key VARCHAR(64) NOT NULL,
measured_at DATETIME NOT NULL,
coins_total DECIMAL(20,6) NOT NULL,
price_per_coin DECIMAL(20,8) NULL,
price_currency VARCHAR(10) NULL,
note TEXT NULL,
source ENUM('manual', 'image_ocr', 'seed_import') NOT NULL,
image_path VARCHAR(255) NULL,
ocr_raw_text MEDIUMTEXT NULL,
ocr_confidence DECIMAL(6,4) NULL,
ocr_flags JSON NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_mining_measurements_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
CONSTRAINT fk_mining_measurements_price_currency_currency FOREIGN KEY (price_currency) REFERENCES miningcheck_currencies(code),
CONSTRAINT uq_mining_measurements_unique UNIQUE (project_key, measured_at, coins_total)
);
CREATE INDEX idx_miningcheck_measurements_project_measured_at
ON miningcheck_measurements(project_key, measured_at);
CREATE TABLE IF NOT EXISTS miningcheck_measurement_rates (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
measurement_id BIGINT UNSIGNED NOT NULL,
project_key VARCHAR(64) NOT NULL,
base_currency VARCHAR(10) NOT NULL,
quote_currency VARCHAR(10) NOT NULL,
rate DECIMAL(20,10) NOT NULL,
provider VARCHAR(32) NOT NULL DEFAULT 'derived',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_mining_measurement_rates_measurement FOREIGN KEY (measurement_id) REFERENCES miningcheck_measurements(id) ON DELETE CASCADE,
CONSTRAINT fk_mining_measurement_rates_base_currency_currency FOREIGN KEY (base_currency) REFERENCES miningcheck_currencies(code),
CONSTRAINT fk_mining_measurement_rates_quote_currency_currency FOREIGN KEY (quote_currency) REFERENCES miningcheck_currencies(code),
CONSTRAINT uq_mining_measurement_rate_pair UNIQUE (measurement_id, base_currency, quote_currency),
KEY idx_miningcheck_measurement_rates_project_measurement (project_key, measurement_id)
);
CREATE TABLE IF NOT EXISTS miningcheck_payouts (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
project_key VARCHAR(64) NOT NULL,
payout_at TIMESTAMP NOT NULL,
coins_amount DECIMAL(20,6) NOT NULL,
payout_currency VARCHAR(10) NOT NULL DEFAULT 'DOGE',
note TEXT,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_mining_payouts_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
CONSTRAINT fk_mining_payouts_payout_currency_currency FOREIGN KEY (payout_currency) REFERENCES miningcheck_currencies(code),
KEY idx_miningcheck_payouts_project_payout_at (project_key, payout_at)
);
CREATE TABLE IF NOT EXISTS miningcheck_targets (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
project_key VARCHAR(64) NOT NULL,
label VARCHAR(120) NOT NULL,
target_amount_fiat DECIMAL(20,2) NOT NULL,
currency VARCHAR(10) NOT NULL,
miner_offer_id BIGINT UNSIGNED NULL,
is_active TINYINT(1) NOT NULL DEFAULT 1,
sort_order INT NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_mining_targets_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
CONSTRAINT fk_mining_targets_currency_currency FOREIGN KEY (currency) REFERENCES miningcheck_currencies(code),
CONSTRAINT fk_mining_targets_offer FOREIGN KEY (miner_offer_id) REFERENCES miningcheck_miner_offers(id) ON DELETE SET NULL,
CONSTRAINT uq_mining_targets_project_label UNIQUE (project_key, label)
);
CREATE TABLE IF NOT EXISTS miningcheck_dashboard_definitions (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
project_key VARCHAR(64) NOT NULL,
name VARCHAR(160) NOT NULL,
chart_type ENUM('line', 'bar', 'area', 'table') NOT NULL,
x_field VARCHAR(64) NOT NULL,
y_field VARCHAR(64) NOT NULL,
aggregation VARCHAR(32) NOT NULL DEFAULT 'none',
filters_json JSON NULL,
is_active TINYINT(1) NOT NULL DEFAULT 1,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_mining_dashboards_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
CONSTRAINT uq_mining_dashboards_project_name UNIQUE (project_key, name)
);
CREATE TABLE IF NOT EXISTS miningcheck_miner_offers (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
project_key VARCHAR(64) NOT NULL,
label VARCHAR(120) NOT NULL,
runtime_months INT NULL,
mining_speed_value DECIMAL(20,4) NULL,
mining_speed_unit VARCHAR(8) NULL,
bonus_speed_value DECIMAL(20,4) NULL,
bonus_speed_unit VARCHAR(8) NULL,
base_price_amount DECIMAL(20,8) NOT NULL,
base_price_currency VARCHAR(10) NOT NULL,
payment_type VARCHAR(10) NOT NULL DEFAULT 'fiat',
auto_renew TINYINT(1) NOT NULL DEFAULT 0,
note TEXT,
is_active TINYINT(1) NOT NULL DEFAULT 1,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_mining_miner_offers_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
CONSTRAINT fk_mining_miner_offers_base_price_currency_currency FOREIGN KEY (base_price_currency) REFERENCES miningcheck_currencies(code)
);
CREATE TABLE IF NOT EXISTS miningcheck_purchased_miners (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
project_key VARCHAR(64) NOT NULL,
miner_offer_id BIGINT UNSIGNED NULL,
purchased_at TIMESTAMP NOT NULL,
label VARCHAR(120) NOT NULL,
runtime_months INT NULL,
mining_speed_value DECIMAL(20,4) NULL,
mining_speed_unit VARCHAR(8) NULL,
bonus_speed_value DECIMAL(20,4) NULL,
bonus_speed_unit VARCHAR(8) NULL,
total_cost_amount DECIMAL(20,8) NOT NULL,
currency VARCHAR(10) NOT NULL,
usd_reference_amount DECIMAL(20,8) NULL,
reference_price_amount DECIMAL(20,8) NULL,
reference_price_currency VARCHAR(10) NULL,
auto_renew TINYINT(1) NOT NULL DEFAULT 0,
note TEXT,
is_active TINYINT(1) NOT NULL DEFAULT 1,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_mining_purchased_miners_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
CONSTRAINT fk_mining_purchased_miners_offer FOREIGN KEY (miner_offer_id) REFERENCES miningcheck_miner_offers(id) ON DELETE SET NULL,
CONSTRAINT fk_mining_purchased_miners_currency_currency FOREIGN KEY (currency) REFERENCES miningcheck_currencies(code),
CONSTRAINT fk_mining_purchased_miners_reference_price_currency_currency FOREIGN KEY (reference_price_currency) REFERENCES miningcheck_currencies(code)
);
CREATE TABLE IF NOT EXISTS miningcheck_fx_fetches (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
provider VARCHAR(32) NOT NULL DEFAULT 'currencyapi',
base_currency VARCHAR(10) NOT NULL,
rate_date DATE NOT NULL,
fetched_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_mining_fx_fetches_base_currency_currency FOREIGN KEY (base_currency) REFERENCES miningcheck_currencies(code),
KEY idx_miningcheck_fx_fetches_base_fetched (base_currency, fetched_at)
);
CREATE TABLE IF NOT EXISTS miningcheck_fx_rates (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
fetch_id BIGINT UNSIGNED NOT NULL,
currency_code VARCHAR(10) NOT NULL,
current_value DECIMAL(20,10) NOT NULL,
KEY idx_miningcheck_fx_rates_fetch (fetch_id),
KEY idx_miningcheck_fx_rates_currency (currency_code),
CONSTRAINT fk_mining_fx_rates_fetch FOREIGN KEY (fetch_id) REFERENCES miningcheck_fx_fetches(id) ON DELETE CASCADE,
CONSTRAINT fk_mining_fx_rates_currency_code_currency FOREIGN KEY (currency_code) REFERENCES miningcheck_currencies(code)
);

View File

@@ -0,0 +1,244 @@
CREATE TABLE IF NOT EXISTS miningcheck_projects (
project_key VARCHAR(64) PRIMARY KEY,
project_name VARCHAR(160) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS miningcheck_currencies (
code VARCHAR(10) PRIMARY KEY,
name VARCHAR(64) NOT NULL,
symbol VARCHAR(8),
is_active BOOLEAN NOT NULL DEFAULT TRUE,
is_crypto BOOLEAN NOT NULL DEFAULT FALSE,
sort_order INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS miningcheck_currency_aliases (
alias_code VARCHAR(10) PRIMARY KEY,
currency_code VARCHAR(10) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_mining_currency_aliases_currency FOREIGN KEY (currency_code) REFERENCES miningcheck_currencies(code) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS miningcheck_settings (
id BIGSERIAL PRIMARY KEY,
project_key VARCHAR(64) NOT NULL,
owner_sub VARCHAR(128) NOT NULL,
baseline_measured_at TIMESTAMP NOT NULL,
baseline_coins_total NUMERIC(20,6) NOT NULL,
daily_cost_amount NUMERIC(20,10) NOT NULL,
daily_cost_currency VARCHAR(10) NOT NULL,
report_currency VARCHAR(10),
crypto_currency VARCHAR(10),
display_timezone VARCHAR(64),
fx_max_age_hours NUMERIC(10,2),
module_theme_mode VARCHAR(16),
module_theme_accent VARCHAR(16),
preferred_currencies JSONB,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_mining_settings_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
CONSTRAINT fk_mining_settings_daily_cost_currency_currency FOREIGN KEY (daily_cost_currency) REFERENCES miningcheck_currencies(code),
CONSTRAINT fk_mining_settings_report_currency_currency FOREIGN KEY (report_currency) REFERENCES miningcheck_currencies(code),
CONSTRAINT fk_mining_settings_crypto_currency_currency FOREIGN KEY (crypto_currency) REFERENCES miningcheck_currencies(code),
CONSTRAINT uq_mining_settings_project_owner UNIQUE (project_key, owner_sub)
);
CREATE TABLE IF NOT EXISTS miningcheck_cost_plans (
id BIGSERIAL PRIMARY KEY,
project_key VARCHAR(64) NOT NULL,
owner_sub VARCHAR(128) NOT NULL,
label VARCHAR(120) NOT NULL,
starts_at TIMESTAMP NOT NULL,
runtime_months INTEGER NOT NULL,
mining_speed_value NUMERIC(20,4),
mining_speed_unit VARCHAR(8),
bonus_speed_value NUMERIC(20,4),
bonus_speed_unit VARCHAR(8),
auto_renew BOOLEAN NOT NULL DEFAULT FALSE,
base_price_amount NUMERIC(20,8),
payment_type VARCHAR(10) NOT NULL DEFAULT 'fiat',
total_cost_amount NUMERIC(20,8) NOT NULL,
currency VARCHAR(10) NOT NULL,
note TEXT,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_mining_cost_plans_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
CONSTRAINT fk_mining_cost_plans_currency_currency FOREIGN KEY (currency) REFERENCES miningcheck_currencies(code)
);
CREATE INDEX IF NOT EXISTS idx_miningcheck_cost_plans_project_start
ON miningcheck_cost_plans(project_key, owner_sub, starts_at);
CREATE TABLE IF NOT EXISTS miningcheck_measurements (
id BIGSERIAL PRIMARY KEY,
project_key VARCHAR(64) NOT NULL,
owner_sub VARCHAR(128) NOT NULL,
measured_at TIMESTAMP NOT NULL,
coins_total NUMERIC(20,6) NOT NULL,
price_per_coin NUMERIC(20,8),
price_currency VARCHAR(10),
note TEXT,
source VARCHAR(16) NOT NULL CHECK (source IN ('manual', 'image_ocr', 'seed_import')),
image_path VARCHAR(255),
ocr_raw_text TEXT,
ocr_confidence NUMERIC(6,4),
ocr_flags JSONB,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_mining_measurements_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
CONSTRAINT fk_mining_measurements_price_currency_currency FOREIGN KEY (price_currency) REFERENCES miningcheck_currencies(code),
CONSTRAINT uq_mining_measurements_unique UNIQUE (project_key, owner_sub, measured_at, coins_total)
);
CREATE INDEX IF NOT EXISTS idx_miningcheck_measurements_project_measured_at
ON miningcheck_measurements(project_key, owner_sub, measured_at);
CREATE TABLE IF NOT EXISTS miningcheck_measurement_rates (
id BIGSERIAL PRIMARY KEY,
measurement_id BIGINT NOT NULL,
project_key VARCHAR(64) NOT NULL,
owner_sub VARCHAR(128) NOT NULL,
base_currency VARCHAR(10) NOT NULL,
quote_currency VARCHAR(10) NOT NULL,
rate NUMERIC(20,10) NOT NULL,
provider VARCHAR(32) NOT NULL DEFAULT 'derived',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_mining_measurement_rates_measurement FOREIGN KEY (measurement_id) REFERENCES miningcheck_measurements(id) ON DELETE CASCADE,
CONSTRAINT fk_mining_measurement_rates_base_currency_currency FOREIGN KEY (base_currency) REFERENCES miningcheck_currencies(code),
CONSTRAINT fk_mining_measurement_rates_quote_currency_currency FOREIGN KEY (quote_currency) REFERENCES miningcheck_currencies(code),
CONSTRAINT uq_mining_measurement_rate_pair UNIQUE (measurement_id, base_currency, quote_currency)
);
CREATE INDEX IF NOT EXISTS idx_miningcheck_measurement_rates_project_measurement
ON miningcheck_measurement_rates(project_key, owner_sub, measurement_id);
CREATE TABLE IF NOT EXISTS miningcheck_payouts (
id BIGSERIAL PRIMARY KEY,
project_key VARCHAR(64) NOT NULL,
owner_sub VARCHAR(128) NOT NULL,
payout_at TIMESTAMP NOT NULL,
coins_amount NUMERIC(20,6) NOT NULL,
payout_currency VARCHAR(10) NOT NULL DEFAULT 'DOGE',
note TEXT,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_mining_payouts_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
CONSTRAINT fk_mining_payouts_payout_currency_currency FOREIGN KEY (payout_currency) REFERENCES miningcheck_currencies(code)
);
CREATE INDEX IF NOT EXISTS idx_miningcheck_payouts_project_payout_at
ON miningcheck_payouts(project_key, owner_sub, payout_at);
CREATE TABLE IF NOT EXISTS miningcheck_targets (
id BIGSERIAL PRIMARY KEY,
project_key VARCHAR(64) NOT NULL,
owner_sub VARCHAR(128) NOT NULL,
label VARCHAR(120) NOT NULL,
target_amount_fiat NUMERIC(20,2) NOT NULL,
currency VARCHAR(10) NOT NULL,
miner_offer_id BIGINT,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_mining_targets_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
CONSTRAINT fk_mining_targets_currency_currency FOREIGN KEY (currency) REFERENCES miningcheck_currencies(code),
CONSTRAINT fk_mining_targets_offer FOREIGN KEY (miner_offer_id) REFERENCES miningcheck_miner_offers(id) ON DELETE SET NULL,
CONSTRAINT uq_mining_targets_project_label UNIQUE (project_key, owner_sub, label)
);
CREATE TABLE IF NOT EXISTS miningcheck_dashboard_definitions (
id BIGSERIAL PRIMARY KEY,
project_key VARCHAR(64) NOT NULL,
owner_sub VARCHAR(128) NOT NULL,
name VARCHAR(160) NOT NULL,
chart_type VARCHAR(16) NOT NULL CHECK (chart_type IN ('line', 'bar', 'area', 'table')),
x_field VARCHAR(64) NOT NULL,
y_field VARCHAR(64) NOT NULL,
aggregation VARCHAR(32) NOT NULL DEFAULT 'none',
filters_json JSONB,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_mining_dashboards_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
CONSTRAINT uq_mining_dashboards_project_name UNIQUE (project_key, owner_sub, name)
);
CREATE TABLE IF NOT EXISTS miningcheck_miner_offers (
id BIGSERIAL PRIMARY KEY,
project_key VARCHAR(64) NOT NULL,
label VARCHAR(120) NOT NULL,
runtime_months INTEGER,
mining_speed_value NUMERIC(20,4),
mining_speed_unit VARCHAR(8),
bonus_speed_value NUMERIC(20,4),
bonus_speed_unit VARCHAR(8),
base_price_amount NUMERIC(20,8) NOT NULL,
base_price_currency VARCHAR(10) NOT NULL,
payment_type VARCHAR(10) NOT NULL DEFAULT 'fiat',
auto_renew BOOLEAN NOT NULL DEFAULT FALSE,
note TEXT,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_mining_miner_offers_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
CONSTRAINT fk_mining_miner_offers_base_price_currency_currency FOREIGN KEY (base_price_currency) REFERENCES miningcheck_currencies(code)
);
CREATE TABLE IF NOT EXISTS miningcheck_purchased_miners (
id BIGSERIAL PRIMARY KEY,
project_key VARCHAR(64) NOT NULL,
owner_sub VARCHAR(128) NOT NULL,
miner_offer_id BIGINT,
purchased_at TIMESTAMP NOT NULL,
label VARCHAR(120) NOT NULL,
runtime_months INTEGER,
mining_speed_value NUMERIC(20,4),
mining_speed_unit VARCHAR(8),
bonus_speed_value NUMERIC(20,4),
bonus_speed_unit VARCHAR(8),
total_cost_amount NUMERIC(20,8) NOT NULL,
currency VARCHAR(10) NOT NULL,
usd_reference_amount NUMERIC(20,8),
reference_price_amount NUMERIC(20,8),
reference_price_currency VARCHAR(10),
auto_renew BOOLEAN NOT NULL DEFAULT FALSE,
note TEXT,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_mining_purchased_miners_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
CONSTRAINT fk_mining_purchased_miners_offer FOREIGN KEY (miner_offer_id) REFERENCES miningcheck_miner_offers(id) ON DELETE SET NULL,
CONSTRAINT fk_mining_purchased_miners_currency_currency FOREIGN KEY (currency) REFERENCES miningcheck_currencies(code),
CONSTRAINT fk_mining_purchased_miners_reference_price_currency_currency FOREIGN KEY (reference_price_currency) REFERENCES miningcheck_currencies(code)
);
CREATE TABLE IF NOT EXISTS miningcheck_fx_fetches (
id BIGSERIAL PRIMARY KEY,
provider VARCHAR(32) NOT NULL DEFAULT 'currencyapi',
base_currency VARCHAR(10) NOT NULL,
rate_date DATE NOT NULL,
fetched_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_mining_fx_fetches_base_currency_currency FOREIGN KEY (base_currency) REFERENCES miningcheck_currencies(code)
);
CREATE INDEX IF NOT EXISTS idx_miningcheck_fx_fetches_base_fetched
ON miningcheck_fx_fetches(base_currency, fetched_at);
CREATE TABLE IF NOT EXISTS miningcheck_fx_rates (
id BIGSERIAL PRIMARY KEY,
fetch_id BIGINT NOT NULL,
currency_code VARCHAR(10) NOT NULL,
current_value NUMERIC(20,10) NOT NULL,
CONSTRAINT fk_mining_fx_rates_fetch FOREIGN KEY (fetch_id) REFERENCES miningcheck_fx_fetches(id) ON DELETE CASCADE,
CONSTRAINT fk_mining_fx_rates_currency_code_currency FOREIGN KEY (currency_code) REFERENCES miningcheck_currencies(code)
);
CREATE INDEX IF NOT EXISTS idx_miningcheck_fx_rates_fetch
ON miningcheck_fx_rates(fetch_id);
CREATE INDEX IF NOT EXISTS idx_miningcheck_fx_rates_currency
ON miningcheck_fx_rates(currency_code);

View File

@@ -0,0 +1,226 @@
CREATE TABLE IF NOT EXISTS miningcheck_projects (
project_key VARCHAR(64) NOT NULL PRIMARY KEY,
project_name VARCHAR(160) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS miningcheck_currencies (
code VARCHAR(10) NOT NULL PRIMARY KEY,
name VARCHAR(64) NOT NULL,
symbol VARCHAR(8) NULL,
is_active TINYINT(1) NOT NULL DEFAULT 1,
is_crypto TINYINT(1) NOT NULL DEFAULT 0,
sort_order INT NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS miningcheck_currency_aliases (
alias_code VARCHAR(10) NOT NULL PRIMARY KEY,
currency_code VARCHAR(10) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_mining_currency_aliases_currency FOREIGN KEY (currency_code) REFERENCES miningcheck_currencies(code) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS miningcheck_settings (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
project_key VARCHAR(64) NOT NULL,
baseline_measured_at DATETIME NOT NULL,
baseline_coins_total DECIMAL(20,6) NOT NULL,
daily_cost_amount DECIMAL(20,10) NOT NULL,
daily_cost_currency VARCHAR(10) NOT NULL,
report_currency VARCHAR(10) NULL,
crypto_currency VARCHAR(10) NULL,
display_timezone VARCHAR(64) NULL,
fx_max_age_hours DECIMAL(10,2) NULL,
module_theme_mode VARCHAR(16) NULL,
module_theme_accent VARCHAR(16) NULL,
preferred_currencies JSON NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_mining_settings_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
CONSTRAINT fk_mining_settings_daily_cost_currency_currency FOREIGN KEY (daily_cost_currency) REFERENCES miningcheck_currencies(code),
CONSTRAINT fk_mining_settings_report_currency_currency FOREIGN KEY (report_currency) REFERENCES miningcheck_currencies(code),
CONSTRAINT fk_mining_settings_crypto_currency_currency FOREIGN KEY (crypto_currency) REFERENCES miningcheck_currencies(code),
CONSTRAINT uq_mining_settings_project UNIQUE (project_key)
);
CREATE TABLE IF NOT EXISTS miningcheck_cost_plans (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
project_key VARCHAR(64) NOT NULL,
label VARCHAR(120) NOT NULL,
starts_at DATETIME NOT NULL,
runtime_months INT NOT NULL,
mining_speed_value DECIMAL(20,4) NULL,
mining_speed_unit VARCHAR(8) NULL,
bonus_speed_value DECIMAL(20,4) NULL,
bonus_speed_unit VARCHAR(8) NULL,
auto_renew TINYINT(1) NOT NULL DEFAULT 0,
base_price_amount DECIMAL(20,8) NULL,
payment_type VARCHAR(10) NOT NULL DEFAULT 'fiat',
total_cost_amount DECIMAL(20,8) NOT NULL,
currency VARCHAR(10) NOT NULL,
note TEXT NULL,
is_active TINYINT(1) NOT NULL DEFAULT 1,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_mining_cost_plans_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
CONSTRAINT fk_mining_cost_plans_currency_currency FOREIGN KEY (currency) REFERENCES miningcheck_currencies(code)
);
CREATE INDEX idx_miningcheck_cost_plans_project_start
ON miningcheck_cost_plans(project_key, starts_at);
CREATE TABLE IF NOT EXISTS miningcheck_measurements (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
project_key VARCHAR(64) NOT NULL,
measured_at DATETIME NOT NULL,
coins_total DECIMAL(20,6) NOT NULL,
price_per_coin DECIMAL(20,8) NULL,
price_currency VARCHAR(10) NULL,
note TEXT NULL,
source ENUM('manual', 'image_ocr', 'seed_import') NOT NULL,
image_path VARCHAR(255) NULL,
ocr_raw_text MEDIUMTEXT NULL,
ocr_confidence DECIMAL(6,4) NULL,
ocr_flags JSON NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_mining_measurements_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
CONSTRAINT fk_mining_measurements_price_currency_currency FOREIGN KEY (price_currency) REFERENCES miningcheck_currencies(code),
CONSTRAINT uq_mining_measurements_unique UNIQUE (project_key, measured_at, coins_total)
);
CREATE INDEX idx_miningcheck_measurements_project_measured_at
ON miningcheck_measurements(project_key, measured_at);
CREATE TABLE IF NOT EXISTS miningcheck_measurement_rates (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
measurement_id BIGINT UNSIGNED NOT NULL,
project_key VARCHAR(64) NOT NULL,
base_currency VARCHAR(10) NOT NULL,
quote_currency VARCHAR(10) NOT NULL,
rate DECIMAL(20,10) NOT NULL,
provider VARCHAR(32) NOT NULL DEFAULT 'derived',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_mining_measurement_rates_measurement FOREIGN KEY (measurement_id) REFERENCES miningcheck_measurements(id) ON DELETE CASCADE,
CONSTRAINT fk_mining_measurement_rates_base_currency_currency FOREIGN KEY (base_currency) REFERENCES miningcheck_currencies(code),
CONSTRAINT fk_mining_measurement_rates_quote_currency_currency FOREIGN KEY (quote_currency) REFERENCES miningcheck_currencies(code),
CONSTRAINT uq_mining_measurement_rate_pair UNIQUE (measurement_id, base_currency, quote_currency),
KEY idx_miningcheck_measurement_rates_project_measurement (project_key, measurement_id)
);
CREATE TABLE IF NOT EXISTS miningcheck_payouts (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
project_key VARCHAR(64) NOT NULL,
payout_at TIMESTAMP NOT NULL,
coins_amount DECIMAL(20,6) NOT NULL,
payout_currency VARCHAR(10) NOT NULL DEFAULT 'DOGE',
note TEXT,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_mining_payouts_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
CONSTRAINT fk_mining_payouts_payout_currency_currency FOREIGN KEY (payout_currency) REFERENCES miningcheck_currencies(code),
KEY idx_miningcheck_payouts_project_payout_at (project_key, payout_at)
);
CREATE TABLE IF NOT EXISTS miningcheck_targets (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
project_key VARCHAR(64) NOT NULL,
label VARCHAR(120) NOT NULL,
target_amount_fiat DECIMAL(20,2) NOT NULL,
currency VARCHAR(10) NOT NULL,
miner_offer_id BIGINT UNSIGNED NULL,
is_active TINYINT(1) NOT NULL DEFAULT 1,
sort_order INT NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_mining_targets_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
CONSTRAINT fk_mining_targets_currency_currency FOREIGN KEY (currency) REFERENCES miningcheck_currencies(code),
CONSTRAINT fk_mining_targets_offer FOREIGN KEY (miner_offer_id) REFERENCES miningcheck_miner_offers(id) ON DELETE SET NULL,
CONSTRAINT uq_mining_targets_project_label UNIQUE (project_key, label)
);
CREATE TABLE IF NOT EXISTS miningcheck_dashboard_definitions (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
project_key VARCHAR(64) NOT NULL,
name VARCHAR(160) NOT NULL,
chart_type ENUM('line', 'bar', 'area', 'table') NOT NULL,
x_field VARCHAR(64) NOT NULL,
y_field VARCHAR(64) NOT NULL,
aggregation VARCHAR(32) NOT NULL DEFAULT 'none',
filters_json JSON NULL,
is_active TINYINT(1) NOT NULL DEFAULT 1,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_mining_dashboards_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
CONSTRAINT uq_mining_dashboards_project_name UNIQUE (project_key, name)
);
CREATE TABLE IF NOT EXISTS miningcheck_miner_offers (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
project_key VARCHAR(64) NOT NULL,
label VARCHAR(120) NOT NULL,
runtime_months INT NULL,
mining_speed_value DECIMAL(20,4) NULL,
mining_speed_unit VARCHAR(8) NULL,
bonus_speed_value DECIMAL(20,4) NULL,
bonus_speed_unit VARCHAR(8) NULL,
base_price_amount DECIMAL(20,8) NOT NULL,
base_price_currency VARCHAR(10) NOT NULL,
payment_type VARCHAR(10) NOT NULL DEFAULT 'fiat',
auto_renew TINYINT(1) NOT NULL DEFAULT 0,
note TEXT,
is_active TINYINT(1) NOT NULL DEFAULT 1,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_mining_miner_offers_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
CONSTRAINT fk_mining_miner_offers_base_price_currency_currency FOREIGN KEY (base_price_currency) REFERENCES miningcheck_currencies(code)
);
CREATE TABLE IF NOT EXISTS miningcheck_purchased_miners (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
project_key VARCHAR(64) NOT NULL,
miner_offer_id BIGINT UNSIGNED NULL,
purchased_at TIMESTAMP NOT NULL,
label VARCHAR(120) NOT NULL,
runtime_months INT NULL,
mining_speed_value DECIMAL(20,4) NULL,
mining_speed_unit VARCHAR(8) NULL,
bonus_speed_value DECIMAL(20,4) NULL,
bonus_speed_unit VARCHAR(8) NULL,
total_cost_amount DECIMAL(20,8) NOT NULL,
currency VARCHAR(10) NOT NULL,
usd_reference_amount DECIMAL(20,8) NULL,
reference_price_amount DECIMAL(20,8) NULL,
reference_price_currency VARCHAR(10) NULL,
auto_renew TINYINT(1) NOT NULL DEFAULT 0,
note TEXT,
is_active TINYINT(1) NOT NULL DEFAULT 1,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_mining_purchased_miners_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
CONSTRAINT fk_mining_purchased_miners_offer FOREIGN KEY (miner_offer_id) REFERENCES miningcheck_miner_offers(id) ON DELETE SET NULL,
CONSTRAINT fk_mining_purchased_miners_currency_currency FOREIGN KEY (currency) REFERENCES miningcheck_currencies(code),
CONSTRAINT fk_mining_purchased_miners_reference_price_currency_currency FOREIGN KEY (reference_price_currency) REFERENCES miningcheck_currencies(code)
);
CREATE TABLE IF NOT EXISTS miningcheck_fx_fetches (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
provider VARCHAR(32) NOT NULL DEFAULT 'currencyapi',
base_currency VARCHAR(10) NOT NULL,
rate_date DATE NOT NULL,
fetched_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_mining_fx_fetches_base_currency_currency FOREIGN KEY (base_currency) REFERENCES miningcheck_currencies(code),
KEY idx_miningcheck_fx_fetches_base_fetched (base_currency, fetched_at)
);
CREATE TABLE IF NOT EXISTS miningcheck_fx_rates (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
fetch_id BIGINT UNSIGNED NOT NULL,
currency_code VARCHAR(10) NOT NULL,
current_value DECIMAL(20,10) NOT NULL,
KEY idx_miningcheck_fx_rates_fetch (fetch_id),
KEY idx_miningcheck_fx_rates_currency (currency_code),
CONSTRAINT fk_mining_fx_rates_fetch FOREIGN KEY (fetch_id) REFERENCES miningcheck_fx_fetches(id) ON DELETE CASCADE,
CONSTRAINT fk_mining_fx_rates_currency_code_currency FOREIGN KEY (currency_code) REFERENCES miningcheck_currencies(code)
);

View File

@@ -0,0 +1,91 @@
INSERT INTO miningcheck_projects (project_key, project_name)
VALUES ('doge-main', 'DOGE Mining Main')
ON DUPLICATE KEY UPDATE project_name = VALUES(project_name);
INSERT INTO miningcheck_currencies (code, name, symbol, is_active, sort_order)
VALUES
('EUR', 'Euro', 'EUR', 1, 10),
('USD', 'US-Dollar', 'USD', 1, 20),
('DOGE', 'Dogecoin', 'DOGE', 1, 100),
('BTC', 'Bitcoin', 'BTC', 1, 110),
('ETH', 'Ethereum', 'ETH', 1, 120),
('LTC', 'Litecoin', 'LTC', 1, 130),
('USDT', 'Tether', 'USDT', 1, 140),
('USDC', 'USD Coin', 'USDC', 1, 150)
ON DUPLICATE KEY UPDATE
name = VALUES(name),
symbol = VALUES(symbol),
is_active = VALUES(is_active),
sort_order = VALUES(sort_order);
INSERT INTO miningcheck_settings (
project_key,
baseline_measured_at,
baseline_coins_total,
daily_cost_amount,
daily_cost_currency,
preferred_currencies
)
VALUES (
'doge-main',
'2026-03-16 01:32:00',
27.617864,
0.3123287671,
'EUR',
'["DOGE","USD","EUR"]'
)
ON DUPLICATE KEY UPDATE
baseline_measured_at = VALUES(baseline_measured_at),
baseline_coins_total = VALUES(baseline_coins_total),
daily_cost_amount = VALUES(daily_cost_amount),
daily_cost_currency = VALUES(daily_cost_currency),
preferred_currencies = VALUES(preferred_currencies);
INSERT INTO miningcheck_targets (project_key, label, target_amount_fiat, currency, is_active, sort_order)
VALUES
('doge-main', 'Ziel A', 10.82, 'EUR', 1, 10),
('doge-main', 'Ziel B', 19.50, 'EUR', 1, 20)
ON DUPLICATE KEY UPDATE
target_amount_fiat = VALUES(target_amount_fiat),
currency = VALUES(currency),
is_active = VALUES(is_active),
sort_order = VALUES(sort_order);
INSERT INTO miningcheck_dashboard_definitions (
project_key, name, chart_type, x_field, y_field, aggregation, filters_json, is_active
)
VALUES
('doge-main', 'Mining-Verlauf', 'line', 'measured_at', 'coins_total', 'none', NULL, 1),
('doge-main', 'DOGE pro Tag', 'area', 'measured_date', 'doge_per_day_interval', 'avg', NULL, 1),
('doge-main', 'Kurs-Verlauf', 'line', 'measured_at', 'price_per_coin', 'none', '{"currencies":["EUR","USD"]}', 1)
ON DUPLICATE KEY UPDATE
chart_type = VALUES(chart_type),
x_field = VALUES(x_field),
y_field = VALUES(y_field),
aggregation = VALUES(aggregation),
filters_json = VALUES(filters_json),
is_active = VALUES(is_active);
INSERT INTO miningcheck_measurements (
project_key, measured_at, coins_total, price_per_coin, price_currency, note, source, image_path, ocr_raw_text, ocr_confidence, ocr_flags
)
VALUES
('doge-main', '2026-03-16 01:32:00', 27.617864, NULL, NULL, 'Basiswert', 'seed_import', NULL, NULL, NULL, NULL),
('doge-main', '2026-03-17 02:41:00', 33.751904, NULL, NULL, 'Kurs wurde spaeter separat genannt, aber nicht sicher exakt diesem Messpunkt zuordenbar', 'seed_import', NULL, NULL, NULL, NULL),
('doge-main', '2026-03-17 07:15:00', 34.825695, 0.10037, 'EUR', NULL, 'seed_import', NULL, NULL, NULL, NULL),
('doge-main', '2026-03-17 13:21:00', 36.328140, 0.10002, 'EUR', NULL, 'seed_import', NULL, NULL, NULL, NULL),
('doge-main', '2026-03-17 18:53:00', 37.682757, 0.10062, 'EUR', NULL, 'seed_import', NULL, NULL, NULL, NULL),
('doge-main', '2026-03-18 00:08:00', 38.934351, 0.10097, 'EUR', NULL, 'seed_import', NULL, NULL, NULL, NULL),
('doge-main', '2026-03-18 07:40:00', 40.782006, 0.10040, 'EUR', NULL, 'seed_import', NULL, NULL, NULL, NULL),
('doge-main', '2026-03-18 13:32:00', 42.223449, 0.09607, 'EUR', 'Originaleingabe im Chat: 18.6.2026', 'seed_import', NULL, NULL, NULL, NULL),
('doge-main', '2026-03-18 21:15:00', 44.191018, 0.09446, 'EUR', NULL, 'seed_import', NULL, NULL, NULL, NULL),
('doge-main', '2026-03-19 00:09:00', 44.908500, 0.09507, 'EUR', NULL, 'seed_import', NULL, NULL, NULL, NULL),
('doge-main', '2026-03-19 02:33:00', 45.546924, 0.09499, 'USD', 'aus Screenshot extrahiert', 'seed_import', NULL, NULL, NULL, JSON_ARRAY('source:screenshot')),
('doge-main', '2026-03-19 07:01:00', 46.694127, 0.09460, 'USD', 'aus Screenshot extrahiert', 'seed_import', NULL, NULL, NULL, JSON_ARRAY('source:screenshot')),
('doge-main', '2026-03-19 12:24:00', 48.056494, 0.09419, 'USD', 'aus Screenshot extrahiert', 'seed_import', NULL, NULL, NULL, JSON_ARRAY('source:screenshot')),
('doge-main', '2026-03-19 21:39:00', 50.427943, 0.09361, 'USD', 'aus Screenshot extrahiert', 'seed_import', NULL, NULL, NULL, JSON_ARRAY('source:screenshot'))
ON DUPLICATE KEY UPDATE
price_per_coin = VALUES(price_per_coin),
price_currency = VALUES(price_currency),
note = VALUES(note),
source = VALUES(source);

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,937 @@
<?php
declare(strict_types=1);
namespace Modules\MiningChecker\Domain;
use Modules\MiningChecker\Support\ApiException;
final class AnalyticsService
{
private ?FxService $fx;
public function __construct(?FxService $fx = null)
{
$this->fx = $fx;
}
public function enrichMeasurements(array $measurements, array $settings): array
{
$baselineCoins = (float) ($settings['baseline_coins_total'] ?? 0.0);
$baselineAt = (string) ($settings['baseline_measured_at'] ?? '');
$costPlans = is_array($settings['cost_plans'] ?? null) ? $settings['cost_plans'] : [];
$payouts = is_array($settings['payouts'] ?? null) ? $settings['payouts'] : [];
$measurementRates = is_array($settings['measurement_rates'] ?? null) ? $settings['measurement_rates'] : [];
$baselineTs = $this->utcTimestamp($baselineAt);
$previous = null;
$previousIntervalRate = null;
$result = [];
$payoutIndex = 0;
$cumulativePayouts = 0.0;
$latestPriceByCurrency = [];
foreach ($measurements as $row) {
$measuredTs = $this->utcTimestamp((string) $row['measured_at']);
while (isset($payouts[$payoutIndex])) {
$payoutTs = $this->utcTimestamp((string) ($payouts[$payoutIndex]['payout_at'] ?? ''));
if ($payoutTs <= 0 || $payoutTs > $measuredTs) {
break;
}
$cumulativePayouts += (float) ($payouts[$payoutIndex]['coins_amount'] ?? 0);
$payoutIndex++;
}
$visibleCoinsTotal = (float) $row['coins_total'];
$effectiveCoinsTotal = $visibleCoinsTotal + $cumulativePayouts;
$growth = $effectiveCoinsTotal - $baselineCoins;
$hoursSinceBaseline = $baselineTs > 0 && $measuredTs > $baselineTs ? ($measuredTs - $baselineTs) / 3600 : 0.0;
$perHourSinceBaseline = $hoursSinceBaseline > 0 ? $growth / $hoursSinceBaseline : null;
$perDaySinceBaseline = $perHourSinceBaseline !== null ? $perHourSinceBaseline * 24 : null;
$intervalHours = null;
$intervalGrowth = null;
$perHourInterval = null;
$perDayInterval = null;
if (is_array($previous)) {
$intervalHours = max(0.0, ($measuredTs - ($this->utcTimestamp((string) $previous['measured_at']) ?: $measuredTs)) / 3600);
$intervalGrowth = $effectiveCoinsTotal - (float) ($previous['coins_total_effective'] ?? $previous['coins_total']);
$perHourInterval = $intervalHours > 0 ? $intervalGrowth / $intervalHours : null;
$perDayInterval = $perHourInterval !== null ? $perHourInterval * 24 : null;
}
$trendLabel = 'stabil';
if ($perHourInterval !== null && $previousIntervalRate !== null) {
$delta = $perHourInterval - $previousIntervalRate;
$threshold = max(abs($previousIntervalRate) * 0.05, 0.01);
if ($delta > $threshold) {
$trendLabel = 'steigend';
} elseif ($delta < -$threshold) {
$trendLabel = 'fallend';
}
}
$rawPrice = isset($row['price_per_coin']) && $row['price_per_coin'] !== null ? (float) $row['price_per_coin'] : null;
$rawPriceCurrency = $row['price_currency'] ?: null;
if ($rawPrice !== null && $rawPriceCurrency !== null) {
$latestPriceByCurrency[(string) $rawPriceCurrency] = $rawPrice;
}
$measurementDerivedPrices = $this->measurementDerivedPrices($measurementRates, (int) ($row['id'] ?? 0));
foreach ($measurementDerivedPrices as $derivedCurrency => $derivedPrice) {
$latestPriceByCurrency[$derivedCurrency] = $derivedPrice;
}
$priceCurrency = $rawPriceCurrency !== null
? (string) $rawPriceCurrency
: $this->preferredPriceCurrency($latestPriceByCurrency, $measurementDerivedPrices);
$price = $rawPrice;
if ($price === null && $priceCurrency !== null && isset($measurementDerivedPrices[$priceCurrency])) {
$price = (float) $measurementDerivedPrices[$priceCurrency];
}
if ($price === null && $priceCurrency !== null && isset($latestPriceByCurrency[$priceCurrency])) {
$price = (float) $latestPriceByCurrency[$priceCurrency];
}
if ($price === null) {
foreach (['USD', 'EUR'] as $fallbackCurrency) {
$fxPrice = $this->convertAmount(1.0, 'DOGE', $fallbackCurrency);
if ($fxPrice !== null && $fxPrice > 0) {
$latestPriceByCurrency[$fallbackCurrency] = $fxPrice;
}
}
$priceCurrency = $priceCurrency ?? $this->preferredPriceCurrency($latestPriceByCurrency, $measurementDerivedPrices);
if ($priceCurrency !== null && isset($latestPriceByCurrency[$priceCurrency])) {
$price = (float) $latestPriceByCurrency[$priceCurrency];
}
}
$effectiveDailyCost = $this->effectiveDailyCost($costPlans, $measuredTs, $priceCurrency);
$currentValue = $price !== null ? $visibleCoinsTotal * $price : null;
$currentValueEffective = $price !== null ? $effectiveCoinsTotal * $price : null;
$theoreticalDailyRevenue = ($price !== null && $perDayInterval !== null) ? $perDayInterval * $price : null;
$theoreticalDailyProfit = (
$theoreticalDailyRevenue !== null &&
$effectiveDailyCost !== null
) ? $theoreticalDailyRevenue - $effectiveDailyCost : null;
$breakEvenPricePerCoin = (
$effectiveDailyCost !== null &&
$perDayInterval !== null &&
$perDayInterval > 0
) ? $effectiveDailyCost / $perDayInterval : null;
$profitMarginPercent = (
$theoreticalDailyRevenue !== null &&
$theoreticalDailyRevenue > 0 &&
$theoreticalDailyProfit !== null
) ? ($theoreticalDailyProfit / $theoreticalDailyRevenue) * 100 : null;
$normalizedFlags = $row['ocr_flags'];
if (is_string($normalizedFlags) && $normalizedFlags !== '') {
$decoded = json_decode($normalizedFlags, true);
$normalizedFlags = is_array($decoded) ? $decoded : [$normalizedFlags];
}
$result[] = array_merge($row, [
'coins_total_visible' => $this->roundOrNull($visibleCoinsTotal, 6),
'coins_total_effective' => $this->roundOrNull($effectiveCoinsTotal, 6),
'payouts_cumulative' => $this->roundOrNull($cumulativePayouts, 6),
'growth_since_baseline' => $this->roundOrNull($growth, 6),
'hours_since_baseline' => $this->roundOrNull($hoursSinceBaseline, 4),
'doge_per_hour_since_baseline' => $this->roundOrNull($perHourSinceBaseline, 6),
'doge_per_day_since_baseline' => $this->roundOrNull($perDaySinceBaseline, 6),
'interval_hours' => $this->roundOrNull($intervalHours, 4),
'interval_growth' => $this->roundOrNull($intervalGrowth, 6),
'doge_per_hour_interval' => $this->roundOrNull($perHourInterval, 6),
'doge_per_day_interval' => $this->roundOrNull($perDayInterval, 6),
'trend_label' => $trendLabel,
'effective_price_per_coin' => $this->roundOrNull($price, 8),
'effective_price_currency' => $priceCurrency,
'price_is_fallback' => $rawPrice === null && $price !== null,
'current_value' => $this->roundOrNull($currentValue, 8),
'current_value_effective' => $this->roundOrNull($currentValueEffective, 8),
'effective_daily_cost' => $this->roundOrNull($effectiveDailyCost, 8),
'theoretical_daily_revenue' => $this->roundOrNull($theoreticalDailyRevenue, 8),
'theoretical_daily_profit' => $this->roundOrNull($theoreticalDailyProfit, 8),
'break_even_price_per_coin' => $this->roundOrNull($breakEvenPricePerCoin, 8),
'profit_margin_percent' => $this->roundOrNull($profitMarginPercent, 4),
'measured_date' => substr((string) $row['measured_at'], 0, 10),
'ocr_flags' => is_array($normalizedFlags) ? $normalizedFlags : [],
]);
if ($perHourInterval !== null) {
$previousIntervalRate = $perHourInterval;
}
$previous = $row;
}
return $result;
}
public function buildSummary(array $measurements, array $settings, array $targets): array
{
if ($measurements === []) {
return [
'latest_measurement' => null,
'baseline' => $settings,
'targets' => [],
'payouts' => [],
'miner_offers' => [],
];
}
$latest = $measurements[array_key_last($measurements)];
$latestPriceByCurrency = [];
foreach ($measurements as $measurement) {
if ($measurement['price_per_coin'] !== null && $measurement['price_currency'] !== null) {
$latestPriceByCurrency[(string) $measurement['price_currency']] = (float) $measurement['price_per_coin'];
}
}
$payouts = is_array($settings['payouts'] ?? null) ? $settings['payouts'] : [];
$purchasedMiners = is_array($settings['purchased_miners'] ?? null) ? $settings['purchased_miners'] : [];
$minerOffers = is_array($settings['miner_offers'] ?? null) ? $settings['miner_offers'] : [];
$currentHashrateMh = $this->totalHashrateMh(array_merge(
is_array($settings['cost_plans'] ?? null) ? $settings['cost_plans'] : [],
$purchasedMiners
));
$offerSummary = [];
foreach ($minerOffers as $offer) {
$offerSummary[] = $this->evaluateMinerOffer($offer, $latest, $latestPriceByCurrency, $currentHashrateMh, $settings);
}
$targetSummary = [];
foreach ($targets as $target) {
$currency = (string) $target['currency'];
$targetAmount = is_numeric($target['target_amount_fiat'] ?? null) ? (float) $target['target_amount_fiat'] : null;
$linkedOffer = null;
if (is_numeric($target['miner_offer_id'] ?? null)) {
foreach ($offerSummary as $offer) {
if ((int) ($offer['id'] ?? 0) === (int) $target['miner_offer_id']) {
$linkedOffer = $offer;
break;
}
}
}
if (is_array($linkedOffer)) {
$currency = (string) ($linkedOffer['reference_price_currency'] ?? $linkedOffer['effective_price_currency'] ?? $currency);
$targetAmount = is_numeric($linkedOffer['reference_price_amount'] ?? null)
? (float) $linkedOffer['reference_price_amount']
: (is_numeric($linkedOffer['effective_price_amount'] ?? null) ? (float) $linkedOffer['effective_price_amount'] : $targetAmount);
}
$price = $latestPriceByCurrency[$currency] ?? $this->convertLatestPrice($latestPriceByCurrency, $currency);
$requiredDoge = ($price && $targetAmount !== null) ? $targetAmount / $price : null;
$remainingDoge = $requiredDoge !== null ? $requiredDoge - (float) ($latest['coins_total_effective'] ?? $latest['coins_total']) : null;
$remainingDays = (
$remainingDoge !== null &&
$latest['doge_per_day_interval'] !== null &&
(float) $latest['doge_per_day_interval'] > 0
) ? $remainingDoge / (float) $latest['doge_per_day_interval'] : null;
$targetEtaAt = null;
if ($remainingDays !== null) {
if ($remainingDays <= 0) {
$targetEtaAt = (string) ($latest['measured_at'] ?? '');
} elseif (!empty($latest['measured_at'])) {
try {
$targetEtaAt = $this->formatUtcTimestamp(
$this->utcTimestamp((string) $latest['measured_at']) + (int) round($remainingDays * 86400)
);
} catch (\Throwable) {
$targetEtaAt = null;
}
}
}
$targetSummary[] = array_merge($target, [
'effective_target_amount_fiat' => $this->roundOrNull($targetAmount, 2),
'effective_currency' => $currency,
'linked_offer_id' => $linkedOffer['id'] ?? ($target['miner_offer_id'] ?? null),
'linked_offer_label' => $linkedOffer['label'] ?? null,
'latest_price_for_currency' => $price,
'required_doge' => $this->roundOrNull($requiredDoge, 6),
'remaining_doge' => $this->roundOrNull($remainingDoge, 6),
'remaining_days' => $this->roundOrNull($remainingDays, 4),
'target_eta_at' => $targetEtaAt,
'status' => $remainingDoge !== null && $remainingDoge <= 0 ? 'reached' : 'open',
]);
}
$latestCurrency = (string) ($latest['effective_price_currency'] ?? $latest['price_currency'] ?? '');
$investedCapital = $latestCurrency !== ''
? $this->totalInvestmentBasis(
is_array($settings['cost_plans'] ?? null) ? $settings['cost_plans'] : [],
$purchasedMiners,
$this->utcTimestamp((string) ($latest['measured_at'] ?? '')),
$latestCurrency
)
: null;
$currentDailyRevenue = is_numeric($latest['theoretical_daily_revenue'] ?? null) ? (float) $latest['theoretical_daily_revenue'] : null;
$breakEvenRemainingAmount = $investedCapital;
$breakEvenDaysOverall = (
$investedCapital !== null &&
$currentDailyRevenue !== null &&
$currentDailyRevenue > 0
) ? ($investedCapital / $currentDailyRevenue) : null;
$latestSummary = array_merge($latest, [
'invested_capital' => $this->roundOrNull($investedCapital, 8),
'break_even_remaining_amount' => $this->roundOrNull($breakEvenRemainingAmount, 8),
'break_even_days_overall' => $this->roundOrNull($breakEvenDaysOverall, 4),
]);
$currentProjection = $this->projectPerformance(
is_array($settings['cost_plans'] ?? null) ? $settings['cost_plans'] : [],
$purchasedMiners,
$latestSummary,
730
);
$latestSummary = array_merge($latestSummary, [
'projection_days' => $currentProjection['days'],
'projection_two_year_revenue' => $this->roundOrNull($currentProjection['revenue'], 8),
'projection_two_year_cost' => $this->roundOrNull($currentProjection['cost'], 8),
'projection_two_year_profit' => $this->roundOrNull($currentProjection['profit'], 8),
]);
$offerSummary = array_map(
fn (array $offer): array => $this->enrichOfferScenario(
$offer,
$latestSummary,
$currentHashrateMh,
is_array($settings['cost_plans'] ?? null) ? $settings['cost_plans'] : [],
$purchasedMiners
),
$offerSummary
);
return [
'latest_measurement' => $latestSummary,
'baseline' => $settings,
'targets' => $targetSummary,
'payouts' => [
'total_count' => count($payouts),
'total_coins' => $this->roundOrNull(array_sum(array_map(static fn (array $payout): float => (float) ($payout['coins_amount'] ?? 0), $payouts)), 6),
'current_visible_coins' => $this->roundOrNull((float) ($latest['coins_total_visible'] ?? $latest['coins_total']), 6),
'current_effective_coins' => $this->roundOrNull((float) ($latest['coins_total_effective'] ?? $latest['coins_total']), 6),
],
'current_hashrate_mh' => $this->roundOrNull($currentHashrateMh, 4),
'miner_offers' => $offerSummary,
];
}
public function dashboardData(array $measurements, string $xField, string $yField, string $aggregation, array $filters = []): array
{
$allowedX = ['measured_at', 'measured_date', 'source', 'price_currency', 'trend_label'];
$allowedY = [
'coins_total',
'price_per_coin',
'growth_since_baseline',
'doge_per_hour_since_baseline',
'doge_per_day_since_baseline',
'doge_per_hour_interval',
'doge_per_day_interval',
'current_value',
'theoretical_daily_revenue',
'theoretical_daily_profit',
];
if (!in_array($xField, $allowedX, true) || !in_array($yField, $allowedY, true)) {
throw new ApiException('Dashboard-Felder sind nicht erlaubt.', 422, ['x_field' => $xField, 'y_field' => $yField]);
}
$filtered = array_values(array_filter($measurements, static function (array $row) use ($filters): bool {
if (!empty($filters['source']) && $row['source'] !== $filters['source']) {
return false;
}
if (!empty($filters['currency']) && $row['price_currency'] !== $filters['currency']) {
return false;
}
if (!empty($filters['date_from']) && (string) $row['measured_at'] < (string) $filters['date_from']) {
return false;
}
if (!empty($filters['date_to']) && (string) $row['measured_at'] > (string) $filters['date_to']) {
return false;
}
return true;
}));
if ($aggregation === 'none') {
return array_map(static fn (array $row): array => [
'x' => $row[$xField] ?? null,
'y' => $row[$yField] ?? null,
'row' => $row,
], $filtered);
}
$groups = [];
foreach ($filtered as $row) {
$key = (string) ($row[$xField] ?? 'unknown');
$groups[$key][] = $row;
}
$result = [];
foreach ($groups as $key => $rows) {
$values = array_values(array_filter(array_map(static fn (array $row) => $row[$yField] ?? null, $rows), static fn ($value): bool => $value !== null));
$aggregated = match ($aggregation) {
'avg' => $values === [] ? null : array_sum($values) / count($values),
'sum' => $values === [] ? null : array_sum($values),
'min' => $values === [] ? null : min($values),
'max' => $values === [] ? null : max($values),
'count' => count($rows),
'latest' => $rows[array_key_last($rows)][$yField] ?? null,
default => null,
};
$result[] = [
'x' => $key,
'y' => $this->roundOrNull(is_numeric($aggregated) ? (float) $aggregated : null, 6),
'points' => count($rows),
];
}
return $result;
}
private function roundOrNull(?float $value, int $precision): ?float
{
return $value === null ? null : round($value, $precision);
}
private function effectiveDailyCost(array $costPlans, int $measurementTs, ?string $currency): ?float
{
if ($currency === null) {
return null;
}
$dailyTotal = 0.0;
$matched = false;
foreach ($costPlans as $plan) {
if (empty($plan['is_active'])) {
continue;
}
$startTs = $this->utcTimestamp((string) ($plan['starts_at'] ?? ''));
$runtimeMonths = (int) ($plan['runtime_months'] ?? 0);
if ($startTs <= 0 || $runtimeMonths <= 0 || $measurementTs < $startTs) {
continue;
}
$runtimeDays = $runtimeMonths * 30.4375;
$endTs = (int) round($startTs + ($runtimeDays * 86400));
$isCovered = !empty($plan['auto_renew']) || $measurementTs <= $endTs;
if (!$isCovered) {
continue;
}
$planDailyCost = (float) $plan['total_cost_amount'] / $runtimeDays;
$convertedDailyCost = $this->convertAmount(
$planDailyCost,
(string) ($plan['currency'] ?? ''),
$currency
);
if ($convertedDailyCost === null) {
continue;
}
$matched = true;
$dailyTotal += $convertedDailyCost;
}
return $matched ? $dailyTotal : null;
}
private function convertLatestPrice(array $latestPriceByCurrency, string $targetCurrency): ?float
{
foreach ($latestPriceByCurrency as $sourceCurrency => $price) {
$converted = $this->convertAmount((float) $price, (string) $sourceCurrency, $targetCurrency);
if ($converted !== null) {
return $converted;
}
}
return null;
}
private function preferredPriceCurrency(array $latestPriceByCurrency, array $measurementDerivedPrices = []): ?string
{
foreach (['USD', 'EUR', 'DOGE'] as $preferredCurrency) {
if (array_key_exists($preferredCurrency, $latestPriceByCurrency)) {
return $preferredCurrency;
}
if (array_key_exists($preferredCurrency, $measurementDerivedPrices)) {
return $preferredCurrency;
}
}
$first = array_key_first($latestPriceByCurrency);
if (is_string($first)) {
return $first;
}
$derivedFirst = array_key_first($measurementDerivedPrices);
return is_string($derivedFirst) ? $derivedFirst : null;
}
private function measurementDerivedPrices(array $measurementRates, int $measurementId): array
{
if ($measurementId <= 0 || $measurementRates === []) {
return [];
}
$prices = [];
foreach ($measurementRates as $row) {
if ((int) ($row['measurement_id'] ?? 0) !== $measurementId) {
continue;
}
$baseCurrency = strtoupper(trim((string) ($row['base_currency'] ?? '')));
$quoteCurrency = strtoupper(trim((string) ($row['target_currency'] ?? $row['quote_currency'] ?? '')));
$rate = is_numeric($row['rate'] ?? null) ? (float) $row['rate'] : null;
if ($baseCurrency !== 'DOGE' || $quoteCurrency === '' || $rate === null || $rate <= 0) {
continue;
}
$prices[$quoteCurrency] = $rate;
}
return $prices;
}
private function convertAmount(?float $amount, ?string $fromCurrency, ?string $toCurrency): ?float
{
if ($amount === null || $fromCurrency === null || $toCurrency === null) {
return null;
}
$from = strtoupper(trim($fromCurrency));
$to = strtoupper(trim($toCurrency));
if ($from === '' || $to === '') {
return null;
}
if ($from === $to) {
return $amount;
}
if ($this->fx === null) {
return null;
}
return $this->fx->convert($amount, $from, $to);
}
private function totalHashrateMh(array $entries): float
{
$total = 0.0;
foreach ($entries as $entry) {
if (array_key_exists('is_active', $entry) && empty($entry['is_active'])) {
continue;
}
if (!$this->entryIsCovered($entry, null)) {
continue;
}
$total += $this->normalizeHashrateMh($entry['mining_speed_value'] ?? null, $entry['mining_speed_unit'] ?? null);
$total += $this->normalizeHashrateMh($entry['bonus_speed_value'] ?? null, $entry['bonus_speed_unit'] ?? null);
}
return $total;
}
private function totalPurchasedMinerCost(array $purchasedMiners, string $targetCurrency, ?int $measurementTs = null): ?float
{
$target = strtoupper(trim($targetCurrency));
if ($target === '') {
return null;
}
$total = 0.0;
$matched = false;
foreach ($purchasedMiners as $miner) {
if (array_key_exists('is_active', $miner) && empty($miner['is_active'])) {
continue;
}
if (!$this->entryIsCovered($miner, $measurementTs > 0 ? $measurementTs : null, 'purchased_at')) {
continue;
}
$amount = is_numeric($miner['total_cost_amount'] ?? null) ? (float) $miner['total_cost_amount'] : null;
$currency = strtoupper(trim((string) ($miner['currency'] ?? '')));
if ($amount === null || $amount <= 0 || $currency === '') {
continue;
}
$converted = $this->convertAmount($amount, $currency, $target);
if ($converted === null) {
continue;
}
$matched = true;
$total += $converted;
}
return $matched ? $total : null;
}
private function totalInvestmentBasis(array $costPlans, array $purchasedMiners, int $measurementTs, string $targetCurrency): ?float
{
$target = strtoupper(trim($targetCurrency));
if ($target === '') {
return null;
}
$total = 0.0;
$matched = false;
$purchasedTotal = $this->totalPurchasedMinerCost($purchasedMiners, $target, $measurementTs);
if ($purchasedTotal !== null) {
$matched = true;
$total += $purchasedTotal;
}
foreach ($costPlans as $plan) {
if (empty($plan['is_active'])) {
continue;
}
$startTs = $this->utcTimestamp((string) ($plan['starts_at'] ?? ''));
$runtimeMonths = (int) ($plan['runtime_months'] ?? 0);
if ($startTs <= 0 || $runtimeMonths <= 0 || ($measurementTs > 0 && $measurementTs < $startTs)) {
continue;
}
$runtimeDays = $runtimeMonths * 30.4375;
$endTs = (int) round($startTs + ($runtimeDays * 86400));
$isCovered = !empty($plan['auto_renew']) || $measurementTs <= 0 || $measurementTs <= $endTs;
if (!$isCovered) {
continue;
}
$amount = is_numeric($plan['total_cost_amount'] ?? null) ? (float) $plan['total_cost_amount'] : null;
$currency = strtoupper(trim((string) ($plan['currency'] ?? '')));
if ($amount === null || $amount <= 0 || $currency === '') {
continue;
}
$converted = $this->convertAmount($amount, $currency, $target);
if ($converted === null) {
continue;
}
$matched = true;
$total += $converted;
}
return $matched ? $total : null;
}
private function entryIsCovered(array $entry, ?int $measurementTs = null, string $startField = 'starts_at'): bool
{
$runtimeMonths = (int) ($entry['runtime_months'] ?? 0);
if ($runtimeMonths <= 0) {
return true;
}
$startTs = $this->utcTimestamp((string) ($entry[$startField] ?? ''));
if ($startTs <= 0) {
return true;
}
$checkTs = $measurementTs ?? time();
if ($checkTs < $startTs) {
return false;
}
$runtimeDays = $runtimeMonths * 30.4375;
$endTs = (int) round($startTs + ($runtimeDays * 86400));
return !empty($entry['auto_renew']) || $checkTs <= $endTs;
}
private function normalizeHashrateMh(mixed $value, mixed $unit): float
{
if (!is_numeric($value) || !is_string($unit) || trim($unit) === '') {
return 0.0;
}
$numeric = (float) $value;
return match (trim($unit)) {
'MH/s' => $numeric,
'kH/s' => $numeric / 1000,
default => 0.0,
};
}
private function evaluateMinerOffer(array $offer, array $latest, array $latestPriceByCurrency, float $currentHashrateMh, array $settings): array
{
$offerHashrateMh = $this->normalizeHashrateMh($offer['mining_speed_value'] ?? null, $offer['mining_speed_unit'] ?? null)
+ $this->normalizeHashrateMh($offer['bonus_speed_value'] ?? null, $offer['bonus_speed_unit'] ?? null);
$paymentType = (string) ($offer['payment_type'] ?? '');
if ($paymentType === '') {
$paymentType = !empty($offer['price_currency']) && in_array(strtoupper((string) $offer['price_currency']), ['ADA','ARB','BNB','BTC','DAI','DOGE','DOT','ETH','LINK','LTC','SOL','USDC','USDT','XRP'], true)
? 'crypto'
: 'fiat';
}
$basePriceAmount = is_numeric($offer['base_price_amount'] ?? null)
? (float) $offer['base_price_amount']
: (is_numeric($offer['reference_price_amount'] ?? null)
? (float) $offer['reference_price_amount']
: (is_numeric($offer['usd_reference_amount'] ?? null)
? (float) $offer['usd_reference_amount']
: (is_numeric($offer['price_amount'] ?? null) ? (float) $offer['price_amount'] : null)));
$basePriceCurrency = (string) ($offer['base_price_currency'] ?? $offer['reference_price_currency'] ?? (is_numeric($offer['usd_reference_amount'] ?? null) ? 'USD' : ($offer['price_currency'] ?? '')));
$effectivePriceCurrency = $paymentType === 'crypto'
? (string) ($settings['crypto_currency'] ?? 'DOGE')
: $basePriceCurrency;
$effectivePriceAmount = $basePriceAmount;
if ($basePriceAmount !== null && $basePriceAmount > 0 && $basePriceCurrency !== '' && $effectivePriceCurrency !== '' && strtoupper($basePriceCurrency) !== strtoupper($effectivePriceCurrency)) {
$convertedReference = $this->convertAmount($basePriceAmount, $basePriceCurrency, $effectivePriceCurrency);
if ($convertedReference !== null && $convertedReference > 0) {
$effectivePriceAmount = $convertedReference;
}
}
$referencePriceAmount = $basePriceAmount;
$referencePriceCurrency = $basePriceCurrency !== '' ? $basePriceCurrency : null;
$expectedDogePerDay = null;
if ($currentHashrateMh > 0 && $offerHashrateMh > 0 && is_numeric($latest['doge_per_day_interval'] ?? null)) {
$expectedDogePerDay = ((float) $latest['doge_per_day_interval'] / $currentHashrateMh) * $offerHashrateMh;
}
$offerCurrencyPrice = $effectivePriceCurrency !== '' ? ($latestPriceByCurrency[$effectivePriceCurrency] ?? $this->convertLatestPrice($latestPriceByCurrency, $effectivePriceCurrency)) : null;
$expectedDailyRevenue = ($expectedDogePerDay !== null && $offerCurrencyPrice !== null)
? $expectedDogePerDay * $offerCurrencyPrice
: null;
$breakEvenDays = ($effectivePriceAmount !== null && $expectedDailyRevenue !== null && $expectedDailyRevenue > 0)
? $effectivePriceAmount / $expectedDailyRevenue
: null;
$recommendation = 'keine Basis';
if ($breakEvenDays !== null) {
if ($breakEvenDays <= 180) {
$recommendation = 'lohnt eher';
} elseif ($breakEvenDays <= 365) {
$recommendation = 'abwaegen';
} else {
$recommendation = 'eher warten';
}
}
return array_merge($offer, [
'payment_type' => $paymentType,
'base_price_amount' => $this->roundOrNull($basePriceAmount, 8),
'base_price_currency' => $basePriceCurrency !== '' ? $basePriceCurrency : null,
'offer_hashrate_mh' => $this->roundOrNull($offerHashrateMh, 4),
'effective_price_amount' => $this->roundOrNull($effectivePriceAmount, 8),
'effective_price_currency' => $effectivePriceCurrency,
'reference_price_amount' => $this->roundOrNull($referencePriceAmount, 8),
'reference_price_currency' => $referencePriceCurrency !== '' ? $referencePriceCurrency : null,
'expected_doge_per_day' => $this->roundOrNull($expectedDogePerDay, 6),
'expected_daily_revenue' => $this->roundOrNull($expectedDailyRevenue, 8),
'break_even_days' => $this->roundOrNull($breakEvenDays, 2),
'recommendation' => $recommendation,
]);
}
private function enrichOfferScenario(array $offer, array $latest, float $currentHashrateMh, array $costPlans, array $purchasedMiners): array
{
$latestCurrency = (string) ($latest['effective_price_currency'] ?? $latest['price_currency'] ?? '');
$currentDogePerDay = is_numeric($latest['doge_per_day_interval'] ?? null) ? (float) $latest['doge_per_day_interval'] : null;
$currentDailyProfit = is_numeric($latest['theoretical_daily_profit'] ?? null) ? (float) $latest['theoretical_daily_profit'] : null;
$currentDailyCost = is_numeric($latest['effective_daily_cost'] ?? null) ? (float) $latest['effective_daily_cost'] : null;
$investedCapital = is_numeric($latest['invested_capital'] ?? null) ? (float) $latest['invested_capital'] : null;
$effectivePricePerCoin = is_numeric($latest['effective_price_per_coin'] ?? null) ? (float) $latest['effective_price_per_coin'] : null;
$expectedDogePerDay = is_numeric($offer['expected_doge_per_day'] ?? null) ? (float) $offer['expected_doge_per_day'] : null;
$offerPriceAmount = is_numeric($offer['effective_price_amount'] ?? null) ? (float) $offer['effective_price_amount'] : null;
$offerPriceCurrency = (string) ($offer['effective_price_currency'] ?? '');
$scenarioOfferCost = (
$offerPriceAmount !== null &&
$offerPriceCurrency !== '' &&
$latestCurrency !== ''
) ? $this->convertAmount($offerPriceAmount, $offerPriceCurrency, $latestCurrency) : null;
$scenarioDogePerDay = (
$currentDogePerDay !== null &&
$expectedDogePerDay !== null
) ? ($currentDogePerDay + $expectedDogePerDay) : null;
$scenarioDailyRevenue = (
$scenarioDogePerDay !== null &&
$effectivePricePerCoin !== null
) ? ($scenarioDogePerDay * $effectivePricePerCoin) : null;
$scenarioDailyProfit = (
$scenarioDailyRevenue !== null &&
$currentDailyCost !== null
) ? ($scenarioDailyRevenue - $currentDailyCost) : null;
$scenarioInvestedCapital = (
$investedCapital !== null &&
$scenarioOfferCost !== null
) ? ($investedCapital + $scenarioOfferCost) : null;
$scenarioRemainingAmount = $scenarioInvestedCapital;
$scenarioBreakEvenDays = (
$scenarioInvestedCapital !== null &&
$scenarioDailyRevenue !== null &&
$scenarioDailyRevenue > 0
) ? ($scenarioInvestedCapital / $scenarioDailyRevenue) : null;
$scenarioDate = null;
$measuredTs = $this->utcTimestamp((string) ($latest['measured_at'] ?? ''));
if ($measuredTs > 0 && $scenarioBreakEvenDays !== null) {
$scenarioDate = $this->formatUtcTimestamp((int) round($measuredTs + ($scenarioBreakEvenDays * 86400)));
}
$scenarioPurchasedMiners = $purchasedMiners;
if ($scenarioOfferCost !== null && $latestCurrency !== '') {
$scenarioPurchasedMiners[] = [
'purchased_at' => $latest['measured_at'] ?? $this->currentUtcDateTime(),
'runtime_months' => $offer['runtime_months'] ?? null,
'mining_speed_value' => $offer['mining_speed_value'] ?? null,
'mining_speed_unit' => $offer['mining_speed_unit'] ?? null,
'bonus_speed_value' => $offer['bonus_speed_value'] ?? null,
'bonus_speed_unit' => $offer['bonus_speed_unit'] ?? null,
'total_cost_amount' => $scenarioOfferCost,
'currency' => $latestCurrency,
'auto_renew' => !empty($offer['auto_renew']) ? 1 : 0,
'is_active' => 1,
];
}
$currentProjection = $this->projectPerformance($costPlans, $purchasedMiners, $latest, 730);
$scenarioProjection = $this->projectPerformance($costPlans, $scenarioPurchasedMiners, $latest, 730);
return array_merge($offer, [
'scenario_currency' => $latestCurrency !== '' ? $latestCurrency : null,
'scenario_current_hashrate_mh' => $this->roundOrNull($currentHashrateMh, 4),
'scenario_hashrate_mh' => $this->roundOrNull($currentHashrateMh + (float) ($offer['offer_hashrate_mh'] ?? 0), 4),
'scenario_current_doge_per_day' => $this->roundOrNull($currentDogePerDay, 6),
'scenario_doge_per_day' => $this->roundOrNull($scenarioDogePerDay, 6),
'scenario_current_daily_profit' => $this->roundOrNull($currentDailyProfit, 8),
'scenario_daily_profit' => $this->roundOrNull($scenarioDailyProfit, 8),
'scenario_daily_profit_delta' => (
$scenarioDailyProfit !== null &&
$currentDailyProfit !== null
) ? $this->roundOrNull($scenarioDailyProfit - $currentDailyProfit, 8) : null,
'scenario_current_invested_capital' => $this->roundOrNull($investedCapital, 8),
'scenario_invested_capital' => $this->roundOrNull($scenarioInvestedCapital, 8),
'scenario_offer_cost' => $this->roundOrNull($scenarioOfferCost, 8),
'scenario_break_even_remaining_amount' => $this->roundOrNull($scenarioRemainingAmount, 8),
'scenario_break_even_days' => $this->roundOrNull($scenarioBreakEvenDays, 4),
'scenario_break_even_date' => $scenarioDate,
'scenario_projection_days' => $scenarioProjection['days'],
'scenario_current_two_year_profit' => $this->roundOrNull($currentProjection['profit'], 8),
'scenario_two_year_profit' => $this->roundOrNull($scenarioProjection['profit'], 8),
'scenario_two_year_profit_delta' => (
$scenarioProjection['profit'] !== null &&
$currentProjection['profit'] !== null
) ? $this->roundOrNull($scenarioProjection['profit'] - $currentProjection['profit'], 8) : null,
'scenario_two_year_revenue' => $this->roundOrNull($scenarioProjection['revenue'], 8),
'scenario_two_year_cost' => $this->roundOrNull($scenarioProjection['cost'], 8),
]);
}
private function projectPerformance(array $costPlans, array $purchasedMiners, array $latest, int $days): array
{
$currency = strtoupper(trim((string) ($latest['effective_price_currency'] ?? $latest['price_currency'] ?? '')));
$pricePerCoin = is_numeric($latest['effective_price_per_coin'] ?? null) ? (float) $latest['effective_price_per_coin'] : null;
$dogePerDay = is_numeric($latest['doge_per_day_interval'] ?? null) ? (float) $latest['doge_per_day_interval'] : null;
$currentHashrateMh = $this->totalHashrateMh(array_merge($costPlans, $purchasedMiners));
$baseTs = $this->utcTimestamp((string) ($latest['measured_at'] ?? ''));
if ($baseTs <= 0) {
$baseTs = $this->utcTimestamp($this->currentUtcDateTime());
}
if ($currency === '' || $pricePerCoin === null || $dogePerDay === null || $currentHashrateMh <= 0 || $days <= 0) {
return ['days' => $days, 'revenue' => null, 'cost' => null, 'profit' => null];
}
$dogePerDayPerMh = $dogePerDay / $currentHashrateMh;
$revenue = 0.0;
$cost = 0.0;
for ($day = 0; $day < $days; $day++) {
$checkTs = $baseTs + ($day * 86400);
$activeHashrate = 0.0;
foreach ($costPlans as $plan) {
if (empty($plan['is_active']) || !$this->entryIsCovered($plan, $checkTs)) {
continue;
}
$activeHashrate += $this->normalizeHashrateMh($plan['mining_speed_value'] ?? null, $plan['mining_speed_unit'] ?? null);
$activeHashrate += $this->normalizeHashrateMh($plan['bonus_speed_value'] ?? null, $plan['bonus_speed_unit'] ?? null);
$runtimeMonths = (int) ($plan['runtime_months'] ?? 0);
if ($runtimeMonths > 0 && is_numeric($plan['total_cost_amount'] ?? null)) {
$runtimeDays = $runtimeMonths * 30.4375;
$dailyCost = $this->convertAmount((float) $plan['total_cost_amount'] / $runtimeDays, (string) ($plan['currency'] ?? ''), $currency);
if ($dailyCost !== null) {
$cost += $dailyCost;
}
}
}
foreach ($purchasedMiners as $miner) {
if ((array_key_exists('is_active', $miner) && empty($miner['is_active'])) || !$this->entryIsCovered($miner, $checkTs, 'purchased_at')) {
continue;
}
$activeHashrate += $this->normalizeHashrateMh($miner['mining_speed_value'] ?? null, $miner['mining_speed_unit'] ?? null);
$activeHashrate += $this->normalizeHashrateMh($miner['bonus_speed_value'] ?? null, $miner['bonus_speed_unit'] ?? null);
$runtimeMonths = (int) ($miner['runtime_months'] ?? 0);
if ($runtimeMonths > 0 && is_numeric($miner['total_cost_amount'] ?? null)) {
$runtimeDays = $runtimeMonths * 30.4375;
$dailyCost = $this->convertAmount((float) $miner['total_cost_amount'] / $runtimeDays, (string) ($miner['currency'] ?? ''), $currency);
if ($dailyCost !== null) {
$cost += $dailyCost;
}
}
}
$revenue += $activeHashrate * $dogePerDayPerMh * $pricePerCoin;
}
return [
'days' => $days,
'revenue' => $revenue,
'cost' => $cost,
'profit' => $revenue - $cost,
];
}
private function utcTimestamp(?string $value): int
{
$normalized = trim((string) $value);
if ($normalized === '') {
return 0;
}
$utc = new \DateTimeZone('UTC');
$formats = ['Y-m-d H:i:s', 'Y-m-d H:i', \DateTimeInterface::ATOM];
foreach ($formats as $format) {
$date = \DateTimeImmutable::createFromFormat($format, $normalized, $utc);
if ($date instanceof \DateTimeImmutable) {
return $date->getTimestamp();
}
}
try {
return (new \DateTimeImmutable($normalized, $utc))->getTimestamp();
} catch (\Throwable) {
return 0;
}
}
private function formatUtcTimestamp(int $timestamp): string
{
return (new \DateTimeImmutable('@' . $timestamp))
->setTimezone(new \DateTimeZone('UTC'))
->format('Y-m-d H:i:s');
}
private function currentUtcDateTime(): string
{
return $this->formatUtcTimestamp(time());
}
}

View File

@@ -0,0 +1,759 @@
<?php
declare(strict_types=1);
namespace Modules\MiningChecker\Domain;
use Modules\MiningChecker\Infrastructure\MiningRepository;
use Modules\MiningChecker\Support\DebugTrace;
final class FxService
{
private ?MiningRepository $repository;
private string $provider;
private string $apiBaseUrl;
private string $currenciesApiBaseUrl;
private string $apiKey;
private int $timeout;
private int $cacheTtl;
private bool $autoFetchOnMiss;
private array $memoryCache = [];
private ?DebugTrace $debug;
public function __construct(
?MiningRepository $repository = null,
string $apiBaseUrl = 'https://currencyapi.net',
string $currenciesApiBaseUrl = 'https://currencyapi.net',
int $timeout = 10,
int $cacheTtl = 21600,
bool $autoFetchOnMiss = false,
string $provider = 'currencyapi',
string $apiKey = '',
?DebugTrace $debug = null
)
{
$this->repository = $repository;
$this->provider = trim(strtolower($provider)) !== '' ? trim(strtolower($provider)) : 'currencyapi';
$this->apiBaseUrl = rtrim($apiBaseUrl, '/');
$this->currenciesApiBaseUrl = rtrim($currenciesApiBaseUrl, '/');
$this->apiKey = trim($apiKey);
$this->timeout = max(2, $timeout);
$this->cacheTtl = max(60, $cacheTtl);
$this->autoFetchOnMiss = $autoFetchOnMiss;
$this->debug = $debug;
}
public function convert(?float $amount, ?string $from, ?string $to): ?float
{
if ($amount === null || $from === null || $to === null) {
return null;
}
$rate = $this->rate($from, $to);
return $rate === null ? null : $amount * $rate;
}
public function rate(?string $from, ?string $to): ?float
{
$base = strtoupper(trim((string) $from));
$target = strtoupper(trim((string) $to));
if ($base === '' || $target === '') {
return null;
}
if ($base === $target) {
return 1.0;
}
$cacheKey = $base . ':' . $target;
if (array_key_exists($cacheKey, $this->memoryCache)) {
return $this->memoryCache[$cacheKey];
}
$stored = $this->storedRate($base, $target);
if ($stored !== null) {
$this->memoryCache[$cacheKey] = $stored;
return $stored;
}
$cached = $this->readFileCache($cacheKey);
if ($cached !== null) {
$this->memoryCache[$cacheKey] = $cached;
return $cached;
}
if (!$this->autoFetchOnMiss) {
return null;
}
$rate = $this->fetchAndPersistRate($base, $target);
$this->memoryCache[$cacheKey] = $rate;
if ($rate !== null) {
$this->writeFileCache($cacheKey, $rate);
}
return $rate;
}
public function refreshLatestRates(?array $currencies = null, string $base = 'EUR'): array
{
$normalizedBase = strtoupper(trim($base));
$targets = $currencies === null
? null
: array_values(array_unique(array_filter(array_map(
static fn ($code): string => strtoupper(trim((string) $code)),
$currencies
), static fn (string $code): bool => $code !== '' && $code !== $normalizedBase)));
$payload = $this->fetchLatestPayload($normalizedBase, $targets);
$rates = is_array($payload['rates'] ?? null) ? $payload['rates'] : [];
$rateDate = $this->normalizeRateDate($payload['date'] ?? null);
$forwardRates = [];
foreach ($rates as $target => $rate) {
if (!is_numeric($rate)) {
continue;
}
$targetCode = strtoupper((string) $target);
if ($targetCode === '' || $targetCode === $normalizedBase) {
continue;
}
$forwardRates[$targetCode] = (float) $rate;
}
$updated = $this->persistRateSet($normalizedBase, $forwardRates, $rateDate);
return [
'base' => $normalizedBase,
'rate_date' => $rateDate,
'updated_count' => count($updated),
'rates' => $updated,
];
}
public function ensureFreshLatestRates(float $maxAgeHours = 3.0, string $base = 'USD'): array
{
$normalizedBase = strtoupper(trim($base));
$maxAgeHours = $maxAgeHours > 0 ? $maxAgeHours : 3.0;
if ($this->repository === null) {
return $this->refreshLatestRates(null, $normalizedBase);
}
$latestFetch = $this->repository->getLatestFxFetch($normalizedBase);
$latestFetchedAt = is_array($latestFetch) ? strtotime((string) ($latestFetch['fetched_at'] ?? '')) : false;
$ageSeconds = $latestFetchedAt ? (time() - $latestFetchedAt) : null;
$maxAgeSeconds = (int) round($maxAgeHours * 3600);
if ($ageSeconds !== null && $ageSeconds >= 0 && $ageSeconds <= $maxAgeSeconds) {
$this->debug?->add('fx.latest.reuse', [
'base' => $normalizedBase,
'fetched_at' => $latestFetch['fetched_at'] ?? null,
'age_seconds' => $ageSeconds,
'max_age_seconds' => $maxAgeSeconds,
]);
return [
'base' => $normalizedBase,
'rate_date' => $latestFetch['rate_date'] ?? null,
'updated_count' => 0,
'rates' => [],
'reused' => true,
'fetched_at' => $latestFetch['fetched_at'] ?? null,
];
}
$this->debug?->add('fx.latest.refresh_required', [
'base' => $normalizedBase,
'previous_fetched_at' => $latestFetch['fetched_at'] ?? null,
'age_seconds' => $ageSeconds,
'max_age_seconds' => $maxAgeSeconds,
]);
$result = $this->refreshLatestRates(null, $normalizedBase);
$result['reused'] = false;
return $result;
}
public function probeLatestRates(string $base = 'EUR'): array
{
$normalizedBase = strtoupper(trim($base));
return $this->fetchLatestProbe($normalizedBase);
}
public function refreshCurrencyCatalog(): array
{
if ($this->repository === null) {
return [
'synced_count' => 0,
'currencies' => [],
];
}
$payload = $this->fetchCurrenciesPayload();
$items = is_array($payload['currencies'] ?? null) ? $payload['currencies'] : [];
if ($items === []) {
return [
'synced_count' => 0,
'currencies' => [],
];
}
$synced = [];
$sortOrder = 1000;
foreach ($items as $code => $name) {
$normalizedCode = strtoupper(trim((string) $code));
$normalizedName = trim((string) $name);
if ($normalizedCode === '' || $normalizedName === '') {
continue;
}
$currency = [
'code' => substr($normalizedCode, 0, 10),
'name' => function_exists('mb_substr') ? mb_substr($normalizedName, 0, 64) : substr($normalizedName, 0, 64),
'symbol' => substr($normalizedCode, 0, 8),
'is_active' => 1,
'is_crypto' => $this->isCryptoCode($normalizedCode) ? 1 : 0,
'sort_order' => $this->catalogSortOrder($normalizedCode, $sortOrder),
];
$synced[] = $currency;
$sortOrder++;
}
$this->repository->saveCurrencies($synced);
usort($synced, static function (array $left, array $right): int {
return [$left['sort_order'], $left['code']] <=> [$right['sort_order'], $right['code']];
});
return [
'synced_count' => count($synced),
'currencies' => $synced,
];
}
public function probeCurrencyCatalog(): array
{
return $this->fetchCurrenciesProbe();
}
private function fetchAndPersistRate(string $base, string $target): ?float
{
$payload = $this->fetchLatestPayload($base, [$target]);
$rates = is_array($payload['rates'] ?? null) ? $payload['rates'] : [];
$rate = $rates[$target] ?? null;
if (!is_numeric($rate)) {
return null;
}
$numericRate = (float) $rate;
$rateDate = $this->normalizeRateDate($payload['date'] ?? null);
$this->persistRateSet($base, [$target => $numericRate], $rateDate);
return $numericRate;
}
private function fetchLatestPayload(string $base, ?array $targets = null): array
{
if (!function_exists('curl_init')) {
return [];
}
$url = $this->buildLatestUrl($base, $targets);
if ($url === null) {
$this->debug?->add('fx.latest.skip', ['reason' => 'missing_url_or_key', 'base' => $base]);
return [];
}
$this->debug?->add('fx.latest.request', [
'base' => $base,
'url' => $this->maskUrl($url),
'targets' => $targets,
]);
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => $url,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_TIMEOUT => $this->timeout,
CURLOPT_HTTPHEADER => ['Accept: application/json'],
]);
$response = curl_exec($ch);
$httpStatus = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$curlError = curl_error($ch);
curl_close($ch);
$this->debug?->add('fx.latest.response', [
'http_status' => $httpStatus,
'curl_error' => $curlError,
'response_bytes' => is_string($response) ? strlen($response) : 0,
'response_preview' => is_string($response) ? substr($response, 0, 1200) : null,
]);
if ($response === false || $curlError !== '' || $httpStatus >= 400) {
return [];
}
$payload = json_decode((string) $response, true);
return $this->normalizePayload($payload, $base, $targets);
}
private function fetchLatestProbe(string $base): array
{
if (!function_exists('curl_init')) {
return ['ok' => false, 'message' => 'curl_init ist nicht verfuegbar.'];
}
$url = $this->buildLatestUrl($base, null);
if ($url === null) {
return ['ok' => false, 'message' => 'FX-URL oder API-Key fehlt.'];
}
$this->debug?->add('fx.latest.probe.request', [
'base' => $base,
'url' => $this->maskUrl($url),
]);
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => $url,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_TIMEOUT => $this->timeout,
CURLOPT_HEADER => true,
CURLOPT_HTTPHEADER => ['Accept: application/json'],
]);
$response = curl_exec($ch);
$httpStatus = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$headerSize = (int) curl_getinfo($ch, CURLINFO_HEADER_SIZE);
$curlError = curl_error($ch);
curl_close($ch);
$rawHeaders = is_string($response) ? substr($response, 0, $headerSize) : '';
$body = is_string($response) ? substr($response, $headerSize) : '';
$result = [
'ok' => $response !== false && $curlError === '' && $httpStatus < 400,
'url' => $this->maskUrl($url),
'http_status' => $httpStatus,
'curl_error' => $curlError,
'response_headers' => $rawHeaders,
'response_body' => substr($body, 0, 4000),
];
$this->debug?->add('fx.latest.probe.response', $result);
return $result;
}
private function fetchCurrenciesPayload(): array
{
if (!function_exists('curl_init') || $this->apiKey === '') {
return [];
}
$url = sprintf(
'%s/api/v2/currencies?output=json&key=%s',
$this->currenciesApiBaseUrl,
rawurlencode($this->apiKey)
);
$this->debug?->add('fx.currencies.request', [
'url' => $this->maskUrl($url),
]);
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => $url,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_TIMEOUT => $this->timeout,
CURLOPT_HTTPHEADER => ['Accept: application/json'],
]);
$response = curl_exec($ch);
$httpStatus = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$curlError = curl_error($ch);
curl_close($ch);
$this->debug?->add('fx.currencies.response', [
'http_status' => $httpStatus,
'curl_error' => $curlError,
'response_bytes' => is_string($response) ? strlen($response) : 0,
'response_preview' => is_string($response) ? substr($response, 0, 1200) : null,
]);
if ($response === false || $curlError !== '' || $httpStatus >= 400) {
return [];
}
$payload = json_decode((string) $response, true);
if (!is_array($payload)) {
throw new \RuntimeException('Waehrungskatalog konnte nicht gelesen werden.');
}
if (($payload['valid'] ?? false) !== true || !is_array($payload['currencies'] ?? null)) {
throw new \RuntimeException($this->extractProviderError($payload, 'Waehrungskatalog konnte nicht geladen werden.'));
}
return $payload;
}
private function fetchCurrenciesProbe(): array
{
if (!function_exists('curl_init') || $this->apiKey === '') {
return ['ok' => false, 'message' => 'curl_init oder API-Key fehlt.'];
}
$url = sprintf(
'%s/api/v2/currencies?output=json&key=%s',
$this->currenciesApiBaseUrl,
rawurlencode($this->apiKey)
);
$this->debug?->add('fx.currencies.probe.request', [
'url' => $this->maskUrl($url),
]);
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => $url,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_TIMEOUT => $this->timeout,
CURLOPT_HEADER => true,
CURLOPT_HTTPHEADER => ['Accept: application/json'],
]);
$response = curl_exec($ch);
$httpStatus = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$headerSize = (int) curl_getinfo($ch, CURLINFO_HEADER_SIZE);
$curlError = curl_error($ch);
curl_close($ch);
$rawHeaders = is_string($response) ? substr($response, 0, $headerSize) : '';
$body = is_string($response) ? substr($response, $headerSize) : '';
$result = [
'ok' => $response !== false && $curlError === '' && $httpStatus < 400,
'url' => $this->maskUrl($url),
'http_status' => $httpStatus,
'curl_error' => $curlError,
'response_headers' => $rawHeaders,
'response_body' => substr($body, 0, 4000),
];
$this->debug?->add('fx.currencies.probe.response', $result);
return $result;
}
private function storedRate(string $base, string $target): ?float
{
if ($this->repository === null) {
return null;
}
try {
$direct = $this->repository->getLatestFxRate($base, $target);
if (is_array($direct) && is_numeric($direct['rate'] ?? null)) {
return (float) $direct['rate'];
}
$inverse = $this->repository->getLatestFxRate($target, $base);
if (is_array($inverse) && is_numeric($inverse['rate'] ?? null) && (float) $inverse['rate'] > 0) {
return 1 / (float) $inverse['rate'];
}
$measurementRate = $this->repository->getLatestMeasurementRate($base, $target);
if (is_array($measurementRate) && is_numeric($measurementRate['rate'] ?? null)) {
return (float) $measurementRate['rate'];
}
$inverseMeasurementRate = $this->repository->getLatestMeasurementRate($target, $base);
if (
is_array($inverseMeasurementRate) &&
is_numeric($inverseMeasurementRate['rate'] ?? null) &&
(float) $inverseMeasurementRate['rate'] > 0
) {
return 1 / (float) $inverseMeasurementRate['rate'];
}
foreach (['USD', 'EUR'] as $viaBase) {
if ($base === $viaBase || $target === $viaBase) {
continue;
}
$fromVia = $this->repository->getLatestFxRate($viaBase, $base);
$toVia = $this->repository->getLatestFxRate($viaBase, $target);
if (
is_array($fromVia) && is_numeric($fromVia['rate'] ?? null) &&
is_array($toVia) && is_numeric($toVia['rate'] ?? null) &&
(float) $fromVia['rate'] > 0
) {
return (float) $toVia['rate'] / (float) $fromVia['rate'];
}
$fromViaInverse = $this->repository->getLatestFxRate($base, $viaBase);
$toViaInverse = $this->repository->getLatestFxRate($target, $viaBase);
if (
is_array($fromViaInverse) && is_numeric($fromViaInverse['rate'] ?? null) &&
is_array($toViaInverse) && is_numeric($toViaInverse['rate'] ?? null) &&
(float) $toViaInverse['rate'] > 0
) {
return (1 / (float) $fromViaInverse['rate']) / (1 / (float) $toViaInverse['rate']);
}
}
} catch (\Throwable) {
return null;
}
return null;
}
private function persistRateSet(string $base, array $rates, string $rateDate): array
{
$normalizedBase = strtoupper($base);
$normalizedRates = [];
foreach ($rates as $target => $rate) {
if (!is_numeric($rate)) {
continue;
}
$normalizedTarget = strtoupper((string) $target);
$normalizedRates[$normalizedTarget] = (float) $rate;
$this->memoryCache[$normalizedBase . ':' . $normalizedTarget] = (float) $rate;
$this->writeFileCache($normalizedBase . ':' . $normalizedTarget, (float) $rate);
}
if ($this->repository === null) {
$result = [];
foreach ($normalizedRates as $target => $rate) {
$result[] = [
'base_currency' => $normalizedBase,
'target_currency' => $target,
'rate' => $rate,
'rate_date' => $rateDate,
'provider' => $this->provider,
];
}
return $result;
}
try {
$saved = $this->repository->saveFxFetch($normalizedBase, $this->provider, $rateDate, $normalizedRates);
return is_array($saved['rates'] ?? null) ? $saved['rates'] : [];
} catch (\Throwable) {
$result = [];
foreach ($normalizedRates as $target => $rate) {
$result[] = [
'base_currency' => $normalizedBase,
'target_currency' => $target,
'rate' => $rate,
'rate_date' => $rateDate,
'provider' => $this->provider,
];
}
return $result;
}
}
private function buildLatestUrl(string $base, ?array $targets = null): ?string
{
if ($this->provider === 'currencyapi') {
if ($this->apiKey === '') {
return null;
}
return sprintf(
'%s/api/v2/rates?base=%s&output=json&key=%s',
$this->apiBaseUrl,
rawurlencode($base),
rawurlencode($this->apiKey)
);
}
$targets = $targets ?? $this->defaultCurrencies();
return sprintf(
'%s/latest?base=%s&symbols=%s',
$this->apiBaseUrl,
rawurlencode($base),
rawurlencode(implode(',', $targets))
);
}
private function normalizePayload(mixed $payload, string $base, ?array $targets = null): array
{
if (!is_array($payload)) {
return [];
}
if ($this->provider === 'currencyapi') {
if (($payload['valid'] ?? false) !== true || !is_array($payload['rates'] ?? null)) {
throw new \RuntimeException($this->extractProviderError($payload, 'FX-Kurse konnten nicht geladen werden.'));
}
$allRates = $payload['rates'];
$filteredRates = [];
if ($targets === null) {
foreach ($allRates as $target => $rate) {
$targetCode = strtoupper((string) $target);
if ($targetCode === $base || !is_numeric($rate)) {
continue;
}
$filteredRates[$targetCode] = (float) $rate;
}
} else {
foreach ($targets as $target) {
$targetCode = strtoupper((string) $target);
if ($targetCode === $base) {
continue;
}
$rate = $allRates[$targetCode] ?? null;
if (is_numeric($rate)) {
$filteredRates[$targetCode] = (float) $rate;
}
}
}
return [
'base' => strtoupper((string) ($payload['base'] ?? $base)),
'date' => $payload['updated'] ?? null,
'rates' => $filteredRates,
];
}
if (!is_array($payload['rates'] ?? null)) {
return [];
}
if (array_key_exists('success', $payload) && $payload['success'] !== true) {
return [];
}
return $payload;
}
private function extractProviderError(array $payload, string $fallback): string
{
foreach (['error', 'message', 'msg'] as $field) {
$value = $payload[$field] ?? null;
if (is_string($value) && trim($value) !== '') {
return trim($value);
}
}
$errors = $payload['errors'] ?? null;
if (is_array($errors)) {
$flat = [];
array_walk_recursive($errors, static function ($value) use (&$flat): void {
if (is_string($value) && trim($value) !== '') {
$flat[] = trim($value);
}
});
if ($flat !== []) {
return implode(' | ', array_values(array_unique($flat)));
}
}
return $fallback;
}
private function defaultCurrencies(): array
{
if ($this->repository === null) {
return ['EUR', 'USD'];
}
try {
$currencies = $this->repository->listActiveFiatCurrencies();
return array_map(static fn (array $currency): string => (string) $currency['code'], $currencies);
} catch (\Throwable) {
return ['EUR', 'USD'];
}
}
private function normalizeRateDate(mixed $value): string
{
if (is_int($value) || is_float($value) || (is_string($value) && ctype_digit(trim($value)))) {
$timestamp = (int) $value;
if ($timestamp > 0) {
return date('Y-m-d', $timestamp);
}
}
if (is_string($value) && trim($value) !== '') {
$timestamp = strtotime($value);
if ($timestamp !== false) {
return date('Y-m-d', $timestamp);
}
if (preg_match('/^\d{4}-\d{2}-\d{2}/', $value, $matches) === 1) {
return $matches[0];
}
}
return date('Y-m-d');
}
private function catalogSortOrder(string $code, int $fallback): int
{
return match (strtoupper($code)) {
'EUR' => 10,
'USD' => 20,
'DOGE' => 30,
'BTC' => 40,
'ETH' => 50,
'USDT' => 60,
'USDC' => 70,
default => $fallback,
};
}
private function isCryptoCode(string $code): bool
{
return in_array(strtoupper($code), [
'ADA', 'ARB', 'BNB', 'BTC', 'DAI', 'DOGE', 'DOT', 'ETH', 'LINK', 'LTC',
'SOL', 'USDC', 'USDT', 'XRP',
], true);
}
private function cacheFile(string $cacheKey): string
{
return rtrim(sys_get_temp_dir(), '/') . '/mining-checker-fx-' . md5($cacheKey) . '.json';
}
private function readFileCache(string $cacheKey): ?float
{
$file = $this->cacheFile($cacheKey);
if (!is_file($file) || (time() - filemtime($file)) > $this->cacheTtl) {
return null;
}
$payload = json_decode((string) file_get_contents($file), true);
$rate = $payload['rate'] ?? null;
return is_numeric($rate) ? (float) $rate : null;
}
private function writeFileCache(string $cacheKey, float $rate): void
{
@file_put_contents($this->cacheFile($cacheKey), json_encode([
'rate' => $rate,
'cached_at' => time(),
], JSON_UNESCAPED_UNICODE));
}
private function maskUrl(string $url): string
{
return preg_replace_callback('/([?&]key=)([^&]+)/i', static function (array $matches): string {
$key = $matches[2] ?? '';
if (strlen($key) <= 8) {
return $matches[1] . $key;
}
return $matches[1] . substr($key, 0, 6) . '...' . substr($key, -4);
}, $url) ?: $url;
}
}

View File

@@ -0,0 +1,400 @@
<?php
declare(strict_types=1);
namespace Modules\MiningChecker\Domain;
use Modules\MiningChecker\Infrastructure\ModuleConfig;
use Modules\MiningChecker\Support\ApiException;
final class OcrService
{
private ModuleConfig $config;
public function __construct(ModuleConfig $config)
{
$this->config = $config;
}
public function preview(array $file, array $input): array
{
if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
throw new ApiException('Screenshot-Upload fehlt oder ist fehlerhaft.', 422);
}
$mime = mime_content_type($file['tmp_name']) ?: '';
if (!in_array($mime, ['image/png', 'image/jpeg', 'image/webp'], true)) {
throw new ApiException('Nur PNG, JPEG und WEBP werden akzeptiert.', 422, ['mime' => $mime]);
}
$projectKey = (string) ($input['project_key'] ?? $this->config->defaultProjectKey());
$uploadDir = $this->resolveUploadDir($projectKey);
$extension = pathinfo((string) ($file['name'] ?? 'upload.png'), PATHINFO_EXTENSION) ?: 'png';
$filename = date('Ymd-His') . '-' . bin2hex(random_bytes(4)) . '.' . strtolower($extension);
$targetFile = $uploadDir . '/' . $filename;
if (!move_uploaded_file($file['tmp_name'], $targetFile)) {
throw new ApiException('Bild konnte nicht gespeichert werden.', 500);
}
$rawText = trim((string) ($input['ocr_hint_text'] ?? ''));
$flags = [];
if ($rawText === '') {
['text' => $rawText, 'flags' => $providerFlags] = $this->extractRawText($targetFile);
$flags = array_merge($flags, $providerFlags);
} else {
$flags[] = 'ocr_hint_text_used';
}
$parsed = $this->parseText($rawText, (string) ($input['date_context'] ?? date('Y-m-d')));
$parsed['image_path'] = $targetFile;
$parsed['raw_text'] = $rawText;
$parsed['flags'] = array_values(array_unique(array_merge($flags, $parsed['flags'])));
return $parsed;
}
private function resolveUploadDir(string $projectKey): string
{
$safeProjectKey = preg_replace('~[^a-zA-Z0-9_-]~', '-', $projectKey) ?: 'default';
$candidates = [
rtrim($this->config->uploadsDir(), '/') . '/' . $safeProjectKey,
rtrim(sys_get_temp_dir(), '/') . '/mining-checker/uploads/' . $safeProjectKey,
];
foreach ($candidates as $candidate) {
if ($this->ensureWritableDirectory($candidate)) {
return $candidate;
}
}
throw new ApiException('Upload-Verzeichnis konnte nicht erstellt werden.', 500, [
'candidates' => $candidates,
]);
}
private function ensureWritableDirectory(string $directory): bool
{
if (is_dir($directory)) {
return is_writable($directory);
}
return @mkdir($directory, 0775, true) || is_dir($directory);
}
private function extractRawText(string $imagePath): array
{
$ocrConfig = $this->config->ocr();
$providers = $ocrConfig['providers'] ?? ['tesseract'];
$flags = [];
if (!is_array($providers) || $providers === []) {
$providers = ['tesseract'];
}
foreach ($providers as $provider) {
$providerName = strtolower(trim((string) $provider));
if ($providerName === '') {
continue;
}
if ($providerName === 'ocrspace') {
$result = $this->runOcrSpace((array) ($ocrConfig['ocrspace'] ?? []), $imagePath);
} elseif ($providerName === 'tesseract') {
$result = $this->runTesseract((array) ($ocrConfig['tesseract'] ?? []), $imagePath);
} else {
$flags[] = 'ocr_provider_unsupported:' . $providerName;
continue;
}
$flags = array_merge($flags, $result['flags']);
if (($result['text'] ?? '') !== '') {
return [
'text' => (string) $result['text'],
'flags' => array_values(array_unique(array_merge(
$flags,
['ocr_provider:' . $providerName]
))),
];
}
}
return [
'text' => '',
'flags' => array_values(array_unique(array_merge($flags, ['ocr_engine_missing']))),
];
}
private function runOcrSpace(array $providerConfig, string $imagePath): array
{
if (!function_exists('curl_init') || !class_exists(\CURLFile::class)) {
return [
'text' => '',
'flags' => ['ocr_provider_missing:ocrspace', 'ocr_transport_missing:curl'],
];
}
$url = trim((string) ($providerConfig['url'] ?? ''));
if ($url === '') {
return [
'text' => '',
'flags' => ['ocr_provider_missing:ocrspace', 'ocrspace_url_missing'],
];
}
$apiKey = trim((string) ($providerConfig['api_key'] ?? ''));
if ($apiKey === '') {
return [
'text' => '',
'flags' => ['ocr_provider_missing:ocrspace', 'ocrspace_api_key_missing'],
];
}
$postFields = [
'file' => new \CURLFile($imagePath),
'language' => (string) ($providerConfig['language'] ?? 'eng'),
'OCREngine' => (string) ((int) ($providerConfig['engine'] ?? 2)),
'scale' => (string) ($providerConfig['scale'] ?? 'true'),
'detectOrientation' => (string) ($providerConfig['detect_orientation'] ?? 'true'),
'isTable' => (string) ($providerConfig['is_table'] ?? 'false'),
'isOverlayRequired' => 'false',
];
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => $url,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $postFields,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_TIMEOUT => max(5, (int) ($providerConfig['timeout'] ?? 25)),
CURLOPT_HTTPHEADER => [
'Accept: application/json',
'apikey: ' . $apiKey,
],
]);
$response = curl_exec($ch);
$curlError = curl_error($ch);
$httpStatus = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
if ($response === false || $curlError !== '') {
return [
'text' => '',
'flags' => ['ocr_provider_empty:ocrspace', 'ocrspace_request_failed'],
];
}
$payload = json_decode((string) $response, true);
if (!is_array($payload)) {
return [
'text' => '',
'flags' => ['ocr_provider_empty:ocrspace', 'ocrspace_invalid_response'],
];
}
$flags = [];
$rawText = '';
$parsedResults = $payload['ParsedResults'] ?? null;
if (is_array($parsedResults)) {
$texts = [];
foreach ($parsedResults as $result) {
if (!is_array($result)) {
continue;
}
$fileExitCode = (string) ($result['FileParseExitCode'] ?? '');
if ($fileExitCode !== '') {
$flags[] = 'ocrspace_file_exit_code:' . $fileExitCode;
}
$parsedText = trim((string) ($result['ParsedText'] ?? ''));
if ($parsedText !== '') {
$texts[] = $parsedText;
}
$resultError = trim((string) ($result['ErrorMessage'] ?? ''));
if ($resultError !== '') {
$flags[] = 'ocrspace_result_error';
}
}
$rawText = trim(implode("\n", $texts));
}
$ocrExitCode = (string) ($payload['OCRExitCode'] ?? '');
$isErroredOnProcessing = !empty($payload['IsErroredOnProcessing']);
$errorMessage = trim((string) ($payload['ErrorMessage'] ?? ''));
$errorDetails = trim((string) ($payload['ErrorDetails'] ?? ''));
if ($httpStatus >= 400) {
$flags[] = 'ocrspace_http_error';
}
if ($ocrExitCode !== '') {
$flags[] = 'ocrspace_exit_code:' . $ocrExitCode;
}
$flags[] = 'ocrspace_engine:' . (string) ((int) ($providerConfig['engine'] ?? 2));
if ($isErroredOnProcessing) {
$flags[] = 'ocrspace_processing_error';
}
if ($errorMessage !== '' || $errorDetails !== '') {
$flags[] = 'ocrspace_error';
}
return [
'text' => $rawText,
'flags' => $rawText === '' ? array_values(array_unique(array_merge($flags, ['ocr_provider_empty:ocrspace']))) : array_values(array_unique($flags)),
];
}
private function runTesseract(array $providerConfig, string $imagePath): array
{
$binary = (string) ($providerConfig['binary'] ?? 'tesseract');
if (!$this->binaryExists($binary)) {
return [
'text' => '',
'flags' => ['ocr_provider_missing:tesseract'],
];
}
$language = (string) ($providerConfig['language'] ?? 'eng');
$tmpBase = tempnam(sys_get_temp_dir(), 'mc-ocr-');
if ($tmpBase === false) {
return [
'text' => '',
'flags' => ['ocr_tempfile_failed:tesseract'],
];
}
@unlink($tmpBase);
$command = sprintf(
'%s %s %s -l %s 2>/dev/null',
escapeshellcmd($binary),
escapeshellarg($imagePath),
escapeshellarg($tmpBase),
escapeshellarg($language)
);
shell_exec($command);
$txtFile = $tmpBase . '.txt';
$text = is_file($txtFile) ? (string) file_get_contents($txtFile) : '';
@unlink($txtFile);
return [
'text' => trim($text),
'flags' => trim($text) === '' ? ['ocr_provider_empty:tesseract'] : [],
];
}
private function binaryExists(string $binary): bool
{
return $binary !== '' && trim((string) shell_exec('command -v ' . escapeshellarg($binary) . ' 2>/dev/null')) !== '';
}
private function parseText(string $rawText, string $dateContext): array
{
$flags = [];
$suggestedTime = null;
$coinsTotal = null;
$price = null;
$currency = null;
$normalizedText = preg_replace('/[[:space:]]+/u', ' ', trim($rawText)) ?: '';
if ($normalizedText === '') {
$flags[] = 'ocr_raw_text_empty';
}
if (preg_match('/\b([01]?\d|2[0-3]):([0-5]\d)\b/', $normalizedText, $timeMatch)) {
$suggestedTime = sprintf('%s %02d:%02d:00', $dateContext, (int) $timeMatch[1], (int) $timeMatch[2]);
}
preg_match_all('/\b\d+(?:[.,]\d+)?\b/', $normalizedText, $numberMatches);
$decimalCandidates = [];
foreach ($numberMatches[0] ?? [] as $candidate) {
$normalized = (float) str_replace(',', '.', $candidate);
if ($normalized <= 0) {
continue;
}
$decimalCandidates[] = [
'raw' => $candidate,
'value' => $normalized,
'precision' => str_contains($candidate, ',') || str_contains($candidate, '.')
? strlen((string) preg_replace('/^\d+[.,]/', '', $candidate))
: 0,
];
}
if (preg_match('/DOGE\s*\/\s*(USD|EUR|USDT|USDC|BTC|ETH|LTC)/i', $normalizedText, $pairMatch)) {
$currency = strtoupper((string) $pairMatch[1]);
} elseif (preg_match('/\b(EUR|USD|USDT|USDC|BTC|ETH|LTC)\b/i', $normalizedText, $currencyMatch)) {
$currency = strtoupper((string) $currencyMatch[1]);
} elseif (str_contains($normalizedText, '$')) {
$currency = 'USD';
} else {
$flags[] = 'currency_missing';
}
if (preg_match('/DOGE\s*\/\s*(?:USD|EUR|USDT|USDC|BTC|ETH|LTC)[^\d]{0,20}(\d+[.,]\d{3,8})/i', $normalizedText, $priceMatch)) {
$price = round((float) str_replace(',', '.', $priceMatch[1]), 8);
}
$coinsCandidates = array_values(array_filter($decimalCandidates, static fn (array $item): bool => $item['value'] > 10 && $item['precision'] >= 4));
if ($coinsCandidates !== []) {
usort($coinsCandidates, static function (array $a, array $b): int {
return [$b['precision'], $b['value']] <=> [$a['precision'], $a['value']];
});
$coinsTotal = round((float) $coinsCandidates[0]['value'], 6);
if (count($coinsCandidates) > 1) {
$flags[] = 'coins_ambiguous';
}
} else {
$flags[] = 'coins_missing';
}
$priceCandidates = array_values(array_filter(
$decimalCandidates,
static fn (array $item): bool => $item['value'] > 0 && $item['value'] < 1
));
if ($price === null && $priceCandidates !== []) {
usort($priceCandidates, static function (array $a, array $b): int {
return [$b['precision'], $a['value']] <=> [$a['precision'], $b['value']];
});
$price = round((float) $priceCandidates[0]['value'], 8);
if (count($priceCandidates) > 1 && count(array_filter($priceCandidates, static fn (array $item): bool => $item['precision'] >= 4)) > 1) {
$flags[] = 'price_ambiguous';
}
}
if ($price === null && $coinsTotal !== null && preg_match('/~\s*(\d+[.,]\d+)\s*\$/', $normalizedText, $fiatMatch)) {
$fiatValue = (float) str_replace(',', '.', $fiatMatch[1]);
if ($fiatValue > 0) {
$price = round($fiatValue / $coinsTotal, 8);
$flags[] = 'price_derived_from_balance_value';
$currency = $currency ?? 'USD';
}
}
$matchedFields = 0;
foreach ([$coinsTotal, $price, $currency] as $field) {
if ($field !== null) {
$matchedFields++;
}
}
$confidence = max(0.05, min(0.99, ($matchedFields / 3) - (count($flags) * 0.04)));
return [
'suggested' => [
'measured_at' => $suggestedTime,
'coins_total' => $coinsTotal,
'price_per_coin' => $price,
'price_currency' => $currency,
'note' => null,
'source' => 'image_ocr',
],
'confidence' => round($confidence, 4),
'flags' => $flags,
];
}
}

View File

@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace Modules\MiningChecker\Domain;
final class SeedData
{
public static function projectKey(): string
{
return 'doge-main';
}
public static function projectName(): string
{
return 'DOGE Mining Main';
}
public static function settings(): array
{
return [
'baseline_measured_at' => '2026-03-16 01:32:00',
'baseline_coins_total' => 27.617864,
'daily_cost_amount' => 0.3123287671,
'daily_cost_currency' => 'EUR',
'preferred_currencies' => ['DOGE', 'USD', 'EUR'],
];
}
public static function currencies(): array
{
return [
['code' => 'EUR', 'name' => 'Euro', 'symbol' => 'EUR', 'is_active' => 1, 'sort_order' => 10],
['code' => 'USD', 'name' => 'US-Dollar', 'symbol' => 'USD', 'is_active' => 1, 'sort_order' => 20],
['code' => 'DOGE', 'name' => 'Dogecoin', 'symbol' => 'DOGE', 'is_active' => 1, 'sort_order' => 100],
['code' => 'BTC', 'name' => 'Bitcoin', 'symbol' => 'BTC', 'is_active' => 1, 'sort_order' => 110],
['code' => 'ETH', 'name' => 'Ethereum', 'symbol' => 'ETH', 'is_active' => 1, 'sort_order' => 120],
['code' => 'LTC', 'name' => 'Litecoin', 'symbol' => 'LTC', 'is_active' => 1, 'sort_order' => 130],
['code' => 'USDT', 'name' => 'Tether', 'symbol' => 'USDT', 'is_active' => 1, 'sort_order' => 140],
['code' => 'USDC', 'name' => 'USD Coin', 'symbol' => 'USDC', 'is_active' => 1, 'sort_order' => 150],
];
}
public static function measurements(): array
{
return [
['measured_at' => '2026-03-16 01:32:00', 'coins_total' => 27.617864, 'price_per_coin' => null, 'price_currency' => null, 'note' => 'Basiswert', 'source' => 'seed_import'],
['measured_at' => '2026-03-17 02:41:00', 'coins_total' => 33.751904, 'price_per_coin' => null, 'price_currency' => null, 'note' => 'Kurs wurde spaeter separat genannt, aber nicht sicher exakt diesem Messpunkt zuordenbar', 'source' => 'seed_import'],
['measured_at' => '2026-03-17 07:15:00', 'coins_total' => 34.825695, 'price_per_coin' => 0.10037, 'price_currency' => 'EUR', 'note' => null, 'source' => 'seed_import'],
['measured_at' => '2026-03-17 13:21:00', 'coins_total' => 36.328140, 'price_per_coin' => 0.10002, 'price_currency' => 'EUR', 'note' => null, 'source' => 'seed_import'],
['measured_at' => '2026-03-17 18:53:00', 'coins_total' => 37.682757, 'price_per_coin' => 0.10062, 'price_currency' => 'EUR', 'note' => null, 'source' => 'seed_import'],
['measured_at' => '2026-03-18 00:08:00', 'coins_total' => 38.934351, 'price_per_coin' => 0.10097, 'price_currency' => 'EUR', 'note' => null, 'source' => 'seed_import'],
['measured_at' => '2026-03-18 07:40:00', 'coins_total' => 40.782006, 'price_per_coin' => 0.10040, 'price_currency' => 'EUR', 'note' => null, 'source' => 'seed_import'],
['measured_at' => '2026-03-18 13:32:00', 'coins_total' => 42.223449, 'price_per_coin' => 0.09607, 'price_currency' => 'EUR', 'note' => 'Originaleingabe im Chat: 18.6.2026', 'source' => 'seed_import'],
['measured_at' => '2026-03-18 21:15:00', 'coins_total' => 44.191018, 'price_per_coin' => 0.09446, 'price_currency' => 'EUR', 'note' => null, 'source' => 'seed_import'],
['measured_at' => '2026-03-19 00:09:00', 'coins_total' => 44.908500, 'price_per_coin' => 0.09507, 'price_currency' => 'EUR', 'note' => null, 'source' => 'seed_import'],
['measured_at' => '2026-03-19 02:33:00', 'coins_total' => 45.546924, 'price_per_coin' => 0.09499, 'price_currency' => 'USD', 'note' => 'aus Screenshot extrahiert', 'source' => 'seed_import', 'ocr_flags' => ['source:screenshot']],
['measured_at' => '2026-03-19 07:01:00', 'coins_total' => 46.694127, 'price_per_coin' => 0.09460, 'price_currency' => 'USD', 'note' => 'aus Screenshot extrahiert', 'source' => 'seed_import', 'ocr_flags' => ['source:screenshot']],
['measured_at' => '2026-03-19 12:24:00', 'coins_total' => 48.056494, 'price_per_coin' => 0.09419, 'price_currency' => 'USD', 'note' => 'aus Screenshot extrahiert', 'source' => 'seed_import', 'ocr_flags' => ['source:screenshot']],
['measured_at' => '2026-03-19 21:39:00', 'coins_total' => 50.427943, 'price_per_coin' => 0.09361, 'price_currency' => 'USD', 'note' => 'aus Screenshot extrahiert', 'source' => 'seed_import', 'ocr_flags' => ['source:screenshot']],
];
}
public static function targets(): array
{
return [
['label' => 'Ziel A', 'target_amount_fiat' => 10.82, 'currency' => 'EUR', 'is_active' => 1, 'sort_order' => 10],
['label' => 'Ziel B', 'target_amount_fiat' => 19.50, 'currency' => 'EUR', 'is_active' => 1, 'sort_order' => 20],
];
}
public static function dashboards(): array
{
return [
['name' => 'Mining-Verlauf', 'chart_type' => 'line', 'x_field' => 'measured_at', 'y_field' => 'coins_total', 'aggregation' => 'none', 'filters' => [], 'is_active' => 1],
['name' => 'Performance-Verlauf', 'chart_type' => 'area', 'x_field' => 'measured_date', 'y_field' => 'doge_per_day_interval', 'aggregation' => 'avg', 'filters' => [], 'is_active' => 1],
['name' => 'Kurs-Verlauf', 'chart_type' => 'line', 'x_field' => 'measured_at', 'y_field' => 'price_per_coin', 'aggregation' => 'none', 'filters' => [], 'is_active' => 1],
];
}
}

View File

@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace Modules\MiningChecker\Domain;
use Modules\MiningChecker\Infrastructure\MiningRepository;
final class SeedImporter
{
private MiningRepository $repository;
public function __construct(MiningRepository $repository)
{
$this->repository = $repository;
}
public function import(string $projectKey): array
{
$seedProjectKey = SeedData::projectKey();
if ($projectKey !== $seedProjectKey) {
return ['inserted' => 0, 'project_key' => $projectKey, 'warning' => 'Seed-Daten sind nur fuer doge-main definiert.'];
}
$this->repository->ensureProject($projectKey, SeedData::projectName());
foreach (SeedData::currencies() as $currency) {
$this->repository->saveCurrency($currency);
}
$this->repository->saveSettings($projectKey, SeedData::settings());
$insertedMeasurements = 0;
foreach (SeedData::measurements() as $measurement) {
try {
$this->repository->createMeasurement($projectKey, array_merge([
'image_path' => null,
'ocr_raw_text' => null,
'ocr_confidence' => null,
'ocr_flags' => null,
], $measurement));
$insertedMeasurements++;
} catch (\Throwable $exception) {
// Duplicate seeds are expected on repeated imports.
}
}
$targetCount = 0;
foreach (SeedData::targets() as $target) {
$this->repository->saveTarget($projectKey, $target);
$targetCount++;
}
$dashboardCount = 0;
foreach (SeedData::dashboards() as $dashboard) {
$this->repository->saveDashboard($projectKey, $dashboard);
$dashboardCount++;
}
return [
'project_key' => $projectKey,
'imported_measurements' => $insertedMeasurements,
'historical_rows_total' => count(SeedData::measurements()),
'targets_synced' => $targetCount,
'dashboards_synced' => $dashboardCount,
'currencies_synced' => count(SeedData::currencies()),
];
}
}

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace Modules\MiningChecker\Infrastructure;
use App\Database as AppDatabase;
use Modules\MiningChecker\Support\ApiException;
use PDO;
final class ConnectionFactory
{
public static function make(ModuleConfig $config): PDO
{
if (!$config->useProjectDatabase()) {
throw new ApiException('Mining-Checker erwartet aktuell die Projekt-Datenbank. Eigene Modul-Datenbanken sind hier noch nicht implementiert.', 500);
}
$dbConfig = app()->config()->dbConfig;
if ($dbConfig === []) {
throw new ApiException('Projekt-Datenbankkonfiguration fehlt in config/db_settings_basic.php.', 500);
}
$driver = strtolower((string) ($dbConfig['driver'] ?? ($dbConfig['dsn'] ?? '')));
if ($driver !== '' && !in_array($driver, ['mysql', 'pgsql'], true) && !str_starts_with($driver, 'mysql:') && !str_starts_with($driver, 'pgsql:' )) {
throw new ApiException(
'Mining-Checker unterstuetzt aktuell MySQL/MariaDB und PostgreSQL. Stelle in config/db_settings_basic.php den Driver auf mysql oder pgsql.',
500,
['driver' => $dbConfig['driver'] ?? 'unknown']
);
}
if (method_exists(AppDatabase::class, 'connectFromConfig')) {
return AppDatabase::connectFromConfig($dbConfig);
}
return AppDatabase::createFromArray($dbConfig);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace Modules\MiningChecker\Infrastructure;
final class ModuleConfig
{
private array $config;
public function __construct(array $config)
{
$this->config = $config;
}
public static function load(string $moduleBasePath): self
{
$config = require $moduleBasePath . '/config/module.php';
return new self(is_array($config) ? $config : []);
}
public function defaultProjectKey(): string
{
return (string) ($this->config['default_project_key'] ?? 'doge-main');
}
public function useProjectDatabase(): bool
{
return (bool) ($this->config['use_project_database'] ?? true);
}
public function tablePrefix(): string
{
return (string) ($this->config['table_prefix'] ?? 'miningcheck_');
}
public function uploadsDir(): string
{
return (string) ($this->config['uploads_dir'] ?? sys_get_temp_dir());
}
public function uploadsPublicPrefix(): string
{
return rtrim((string) ($this->config['uploads_public_prefix'] ?? '/uploads'), '/');
}
public function ocr(): array
{
return (array) ($this->config['ocr'] ?? []);
}
public function fx(): array
{
return (array) ($this->config['fx'] ?? []);
}
public function debug(): array
{
return (array) ($this->config['debug'] ?? []);
}
public function debugDir(): string
{
$debug = $this->debug();
return (string) ($debug['dir'] ?? (dirname($this->uploadsDir()) . '/debug'));
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace Modules\MiningChecker\Support;
use RuntimeException;
final class ApiException extends RuntimeException
{
private int $statusCode;
private array $context;
public function __construct(string $message, int $statusCode = 400, array $context = [])
{
parent::__construct($message);
$this->statusCode = $statusCode;
$this->context = $context;
}
public function statusCode(): int
{
return $this->statusCode;
}
public function context(): array
{
return $this->context;
}
}

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace Modules\MiningChecker\Support;
final class DebugState
{
private static array $trace = [];
private static ?string $latestFilePath = null;
public static function replace(array $trace): void
{
self::$trace = $trace;
}
public static function export(): array
{
return self::$trace;
}
public static function clear(): void
{
self::$trace = [];
}
public static function setLatestFilePath(?string $filePath): void
{
self::$latestFilePath = $filePath;
}
public static function latestFilePath(): ?string
{
return self::$latestFilePath;
}
}

View File

@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace Modules\MiningChecker\Support;
final class DebugTrace
{
private bool $enabled;
private array $entries = [];
private ?string $filePath;
public function __construct(bool $enabled = false, ?string $filePath = null)
{
$this->enabled = $enabled;
$this->filePath = $enabled ? $filePath : null;
DebugState::replace([]);
if ($this->enabled && $this->filePath !== null) {
$this->persist();
}
}
public function enabled(): bool
{
return $this->enabled;
}
public function add(string $event, array $context = []): void
{
if (!$this->enabled) {
return;
}
$this->entries[] = [
'time' => date('c'),
'event' => $event,
'context' => $context,
];
DebugState::replace($this->entries);
$this->persist();
}
public function export(): array
{
return $this->enabled ? $this->entries : [];
}
private function persist(): void
{
if (!$this->enabled || $this->filePath === null) {
return;
}
$directory = dirname($this->filePath);
if (!is_dir($directory)) {
@mkdir($directory, 0775, true);
}
@file_put_contents($this->filePath, json_encode($this->entries, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT));
}
}

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace Modules\MiningChecker\Support;
final class Http
{
public static function json(array $payload, int $statusCode = 200): never
{
http_response_code($statusCode);
header('Content-Type: application/json; charset=utf-8');
echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
}
public static function input(): array
{
$contentType = $_SERVER['CONTENT_TYPE'] ?? '';
if (str_contains($contentType, 'application/json')) {
$raw = file_get_contents('php://input');
$data = json_decode($raw ?: '[]', true);
return is_array($data) ? $data : [];
}
return $_POST;
}
}

View File

@@ -0,0 +1,252 @@
.pihole-page .card { background: var(--panel); }
.pihole-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 14px;
}
.pihole-stat {
padding: 16px;
background: var(--panel-2);
}
.pihole-stat-value {
font-size: 1.6rem;
font-weight: 700;
margin-top: 6px;
}
.pihole-stat-sub {
margin-top: 4px;
color: var(--muted);
}
.pihole-section-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.pihole-actions {
margin-top: 12px;
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.pihole-inline {
display: inline-flex;
gap: 8px;
align-items: center;
}
.pihole-inline input {
width: 110px;
}
.pihole-instance-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 14px;
margin-top: 12px;
}
.pihole-instance {
padding: 16px;
background: var(--panel-2);
}
.pihole-instance-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 12px;
}
.pihole-instance-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 10px;
margin-top: 12px;
}
.pihole-instance-value {
font-weight: 700;
margin-top: 4px;
}
.pihole-status {
padding: 6px 10px;
border-radius: 999px;
font-size: 0.85rem;
font-weight: 600;
background: rgba(0, 179, 164, 0.15);
color: #0a6b63;
}
.pihole-status.is-disabled {
background: rgba(255, 90, 61, 0.15);
color: #a83a28;
}
.pihole-status.is-partial {
background: rgba(255, 166, 0, 0.18);
color: #8a5a00;
}
.pihole-split {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 14px;
}
.pihole-list {
display: grid;
gap: 8px;
margin-top: 12px;
}
.pihole-list-row {
display: flex;
justify-content: space-between;
gap: 12px;
padding: 8px 10px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.7);
}
.pihole-list-row strong {
font-weight: 600;
}
.pihole-update {
margin-top: 10px;
font-size: 0.95rem;
color: var(--muted);
}
.pihole-error {
margin-top: 8px;
font-size: 0.9rem;
color: #a83a28;
}
.pihole-blocked {
display: grid;
gap: 8px;
margin-top: 12px;
}
.pihole-blocked-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 8px 10px;
border-radius: 12px;
background: rgba(255, 90, 61, 0.08);
}
.pihole-form {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 12px;
align-items: end;
margin-top: 12px;
}
.pihole-form label {
display: grid;
gap: 6px;
}
.pihole-instance-card {
padding: 16px;
background: var(--panel-2);
display: grid;
gap: 12px;
}
.pihole-card-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.pihole-test-result {
margin-top: 6px;
font-size: 0.9rem;
color: var(--muted);
}
.pihole-test-result.is-ok { color: #0a6b63; }
.pihole-test-result.is-auth { color: #a83a28; }
.pihole-test-result.is-unreachable { color: #a83a28; }
.pihole-test-result.is-invalid { color: #8a5a00; }
.pihole-test-result.is-error { color: #a83a28; }
.form-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 12px;
}
.form-field {
display: grid;
gap: 6px;
}
.icon-button {
border: 1px solid var(--line);
background: var(--panel);
border-radius: 10px;
width: 36px;
height: 36px;
cursor: pointer;
font-size: 1.1rem;
}
.modal {
position: fixed;
inset: 0;
background: rgba(10, 14, 24, 0.55);
display: none;
align-items: center;
justify-content: center;
padding: 24px;
z-index: 40;
}
.modal.is-open { display: flex; }
.modal-card {
width: min(880px, 96vw);
max-height: 90vh;
overflow: auto;
background: var(--panel);
border: 1px solid var(--line);
border-radius: 16px;
box-shadow: var(--shadow);
padding: 16px;
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
@media (max-width: 680px) {
.pihole-actions {
flex-direction: column;
align-items: stretch;
}
.pihole-inline {
width: 100%;
}
.pihole-inline input {
width: 100%;
}
}

View File

@@ -0,0 +1,273 @@
(() => {
const page = document.querySelector('[data-pihole-page]');
if (!page) return;
const fmt = new Intl.NumberFormat('de-DE');
const fmtDate = (ts) => {
if (!ts) return '';
const d = new Date(ts * 1000);
return d.toLocaleString('de-DE');
};
const apiCall = async (action, payload = {}) => {
const res = await fetch(`/module/pihole/api?action=${encodeURIComponent(action)}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
}
);
const data = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error(data.error || `HTTP ${res.status}`);
}
if (data && data.results && typeof data.results === 'object') {
const failures = Object.values(data.results).filter((row) => row && row.ok === false);
if (failures.length) {
const first = failures[0];
throw new Error(first.error || 'action_failed');
}
}
return data;
};
const setText = (el, value) => {
if (!el) return;
el.textContent = value;
};
const statusLabel = (status) => {
if (status === 'enabled') return 'Aktiv';
if (status === 'disabled') return 'Deaktiviert';
if (status === 'partial') return 'Teilweise';
return 'Unbekannt';
};
const setStatusBadge = (el, status) => {
if (!el) return;
el.textContent = statusLabel(status);
el.classList.remove('is-disabled', 'is-partial');
if (status === 'disabled') el.classList.add('is-disabled');
if (status === 'partial') el.classList.add('is-partial');
};
const mapToList = (map, limit = 10) => {
if (!map || typeof map !== 'object') return [];
const entries = Object.entries(map)
.filter(([, value]) => typeof value === 'number' || typeof value === 'string')
.map(([key, value]) => [key, Number(value)])
.sort((a, b) => b[1] - a[1]);
return entries.slice(0, limit);
};
const renderList = (container, map, emptyText) => {
if (!container) return;
const rows = mapToList(map, 10);
container.innerHTML = '';
if (!rows.length) {
const div = document.createElement('div');
div.className = 'muted';
div.textContent = emptyText || 'Keine Daten';
container.appendChild(div);
return;
}
rows.forEach(([label, value]) => {
const row = document.createElement('div');
row.className = 'pihole-list-row';
row.innerHTML = `<strong>${label}</strong><span>${fmt.format(value)}</span>`;
container.appendChild(row);
});
};
const renderBlocked = (container, list) => {
if (!container) return;
container.innerHTML = '';
if (!Array.isArray(list) || !list.length) {
const div = document.createElement('div');
div.className = 'muted';
div.textContent = 'Keine Daten';
container.appendChild(div);
return;
}
list.slice(0, 20).forEach((entry) => {
const domain = entry.domain || entry;
const instance = entry.instance || '';
const row = document.createElement('div');
row.className = 'pihole-blocked-row';
row.innerHTML = `<span>${domain}</span><span class="muted">${instance}</span>`;
container.appendChild(row);
});
};
const renderInstances = (instances) => {
const holder = document.querySelector('[data-instance-cards]');
const tpl = document.querySelector('#pihole-instance-template');
if (!holder || !tpl) return;
holder.innerHTML = '';
Object.values(instances).forEach((entry) => {
const node = tpl.content.cloneNode(true);
const root = node.querySelector('[data-instance]');
if (!root) return;
root.dataset.instance = entry.meta.id;
const summary = entry.summary || {};
setText(root.querySelector('[data-instance-name]'), entry.meta.name || entry.meta.id);
setText(root.querySelector('[data-instance-url]'), entry.meta.url || '');
setText(root.querySelector('[data-instance-dns]'), fmt.format(Number(summary.dns_queries_today || 0)));
setText(root.querySelector('[data-instance-ads]'), fmt.format(Number(summary.ads_blocked_today || 0)));
const percent = summary.ads_percentage_today || summary.ads_percentage || 0;
setText(root.querySelector('[data-instance-percent]'), `${Number(percent).toFixed(2)}%`);
setStatusBadge(root.querySelector('[data-instance-status]'), summary.status || 'unknown');
const actions = root.querySelectorAll('[data-instance]');
actions.forEach((btn) => {
if (btn.dataset.instance === '') {
btn.dataset.instance = entry.meta.id;
}
});
const customInput = root.querySelector('[data-custom-minutes]');
if (customInput) {
customInput.dataset.customMinutes = entry.meta.id;
}
const updateEl = root.querySelector('[data-instance-update]');
if (entry.updates && entry.updates.available) {
updateEl.textContent = 'Updates verfuegbar';
} else {
updateEl.textContent = 'Keine Updates erkannt';
}
const errorEl = root.querySelector('[data-instance-errors]');
if (errorEl) {
if (Array.isArray(entry.errors) && entry.errors.length) {
const lines = entry.errors.map((err) => {
const code = err.http_code ? `HTTP ${err.http_code}` : 'HTTP ?';
const scope = err.scope || 'request';
const msg = err.error || 'error';
return `${scope}: ${msg} (${code})`;
});
errorEl.textContent = `API Fehler: ${lines.join(' | ')}`;
} else {
errorEl.textContent = '';
}
}
holder.appendChild(node);
});
};
const bindActionButtons = () => {
document.addEventListener('click', async (e) => {
const btn = e.target.closest('[data-action]');
if (!btn) return;
const action = btn.dataset.action;
const instance = btn.dataset.instance || 'all';
const minutes = btn.dataset.minutes;
const scope = btn.closest('.pihole-instance') || document;
const customInput = scope.querySelector(`[data-custom-minutes="${instance}"]`);
let payload = { instance };
if (action === 'disable') {
payload.minutes = Number(minutes || 0);
}
if (action === 'disable-custom') {
payload.minutes = Number(customInput?.value || 0);
}
try {
if (action === 'enable') {
await apiCall('enable', payload);
} else if (action === 'disable' || action === 'disable-custom') {
if (!payload.minutes || payload.minutes <= 0) {
alert('Bitte Minuten angeben.');
return;
}
await apiCall('disable', payload);
} else if (action === 'gravity') {
await apiCall('gravity', payload);
const status = document.querySelector('[data-list-update-status]');
if (status) status.textContent = 'Listen-Update gestartet.';
} else if (action === 'update') {
await apiCall('update', payload);
}
await loadDashboard();
} catch (err) {
alert(`Aktion fehlgeschlagen: ${err.message}`);
}
});
};
const bindForms = () => {
const domainForm = document.querySelector('[data-domain-form]');
if (domainForm) {
domainForm.addEventListener('submit', async (e) => {
e.preventDefault();
const formData = new FormData(domainForm);
const type = String(formData.get('type') || 'block');
const domain = String(formData.get('domain') || '').trim();
const status = document.querySelector('[data-domain-status]');
if (!domain) return;
try {
await apiCall('domain_add', { type, domain, instance: 'primary' });
if (status) status.textContent = `Domain hinzugefuegt: ${domain}`;
domainForm.reset();
} catch (err) {
if (status) status.textContent = `Fehler: ${err.message}`;
}
});
}
const adlistForm = document.querySelector('[data-adlist-form]');
if (adlistForm) {
adlistForm.addEventListener('submit', async (e) => {
e.preventDefault();
const formData = new FormData(adlistForm);
const url = String(formData.get('url') || '').trim();
const status = document.querySelector('[data-adlist-status]');
if (!url) return;
try {
await apiCall('adlist_add', { url, instance: 'primary' });
if (status) status.textContent = `Adlist hinzugefuegt: ${url}`;
adlistForm.reset();
} catch (err) {
if (status) status.textContent = `Fehler: ${err.message}`;
}
});
}
};
const loadDashboard = async () => {
try {
const data = await apiCall('dashboard');
if (!data.ok) throw new Error(data.error || 'API error');
const summary = data.aggregate?.summary || {};
setText(document.querySelector('[data-summary-dns]'), fmt.format(Number(summary.dns_queries_today || 0)));
setText(document.querySelector('[data-summary-blocked]'), fmt.format(Number(summary.ads_blocked_today || 0)));
setText(document.querySelector('[data-summary-percent]'), `${Number(summary.ads_percentage_today || 0).toFixed(2)}%`);
setText(document.querySelector('[data-summary-clients]'), fmt.format(Number(summary.unique_clients || 0)));
setStatusBadge(document.querySelector('[data-summary-status]'), summary.status || 'unknown');
setText(document.querySelector('[data-summary-last-refresh]'), `Letztes Update: ${fmtDate(data.ts)}`);
renderInstances(data.instances || {});
renderList(document.querySelector('[data-query-types]'), data.aggregate?.query_types, 'Keine Daten');
renderList(document.querySelector('[data-forward-destinations]'), data.aggregate?.forward_destinations, 'Keine Daten');
renderList(document.querySelector('[data-top-ads]'), data.aggregate?.top_ads, 'Keine Daten');
renderList(document.querySelector('[data-top-queries]'), data.aggregate?.top_queries, 'Keine Daten');
renderList(document.querySelector('[data-top-clients]'), data.aggregate?.query_sources, 'Keine Daten');
renderBlocked(document.querySelector('[data-recent-blocked]'), data.aggregate?.recent_blocked);
} catch (err) {
const message = document.createElement('div');
message.className = 'card';
message.style.marginTop = '1rem';
message.textContent = `Fehler beim Laden der Pi-hole Daten: ${err.message}`;
page.appendChild(message);
}
};
bindActionButtons();
bindForms();
loadDashboard();
})();

View File

@@ -0,0 +1,105 @@
(() => {
const modal = document.querySelector('[data-instance-modal]');
const form = document.querySelector('[data-instance-form]');
if (!modal || !form) return;
const title = document.querySelector('[data-instance-modal-title]');
const closeBtn = document.querySelector('[data-instance-close]');
const newBtn = document.querySelector('[data-instance-new]');
const cancelBtn = document.querySelector('[data-instance-cancel]');
const currentIdInput = form.querySelector('input[name="current_id"]');
const idInput = form.querySelector('input[name="instance_id"]');
const nameInput = form.querySelector('input[name="name"]');
const urlInput = form.querySelector('input[name="url"]');
const tokenInput = form.querySelector('input[name="token"]');
const primaryInput = form.querySelector('input[name="is_primary"]');
const resetForm = () => {
if (currentIdInput) currentIdInput.value = '';
if (idInput) idInput.value = '';
if (nameInput) nameInput.value = '';
if (urlInput) urlInput.value = '';
if (tokenInput) tokenInput.value = '';
if (primaryInput) primaryInput.checked = false;
};
const openModal = () => {
modal.classList.add('is-open');
modal.setAttribute('aria-hidden', 'false');
};
const closeModal = () => {
modal.classList.remove('is-open');
modal.setAttribute('aria-hidden', 'true');
};
document.querySelectorAll('[data-instance-edit]').forEach((btn) => {
btn.addEventListener('click', () => {
const card = btn.closest('[data-instance-id]');
if (!card) return;
if (currentIdInput) currentIdInput.value = card.dataset.instanceId || '';
if (idInput) idInput.value = card.dataset.instanceId || '';
if (nameInput) nameInput.value = card.dataset.name || '';
if (urlInput) urlInput.value = card.dataset.url || '';
if (tokenInput) tokenInput.value = '';
if (primaryInput) primaryInput.checked = card.dataset.primary === '1';
if (title) title.textContent = 'Instanz bearbeiten';
openModal();
});
});
const apiCall = async (action, payload = {}) => {
const res = await fetch(`/module/pihole/api?action=${encodeURIComponent(action)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
return data;
};
document.querySelectorAll('[data-instance-test]').forEach((btn) => {
btn.addEventListener('click', async () => {
const card = btn.closest('[data-instance-id]');
if (!card) return;
const instanceId = card.dataset.instanceId || '';
const resultEl = card.querySelector('[data-instance-result]');
if (resultEl) {
resultEl.classList.remove('is-ok', 'is-auth', 'is-unreachable', 'is-invalid', 'is-error');
resultEl.textContent = 'Teste Verbindung...';
}
try {
const res = await apiCall('test', { instance: instanceId });
if (resultEl) {
const statusClass = res.status ? `is-${res.status}` : 'is-ok';
resultEl.classList.add(statusClass);
resultEl.textContent = res.message || 'Verbindung OK.';
}
} catch (err) {
if (resultEl) {
const msg = err.message || 'Fehler';
resultEl.classList.add('is-error');
resultEl.textContent = `Test fehlgeschlagen: ${msg}`;
}
}
});
});
if (newBtn) {
newBtn.addEventListener('click', () => {
resetForm();
if (title) title.textContent = 'Neue Instanz';
openModal();
});
}
if (closeBtn) {
closeBtn.addEventListener('click', () => closeModal());
}
if (cancelBtn) {
cancelBtn.addEventListener('click', () => resetForm());
}
})();

View File

@@ -0,0 +1,107 @@
<?php
use App\ModuleConfigException;
$moduleName = 'pihole';
$mm = isset($modules) && $modules instanceof App\ModuleManager ? $modules : modules();
$mm->registerFunction($moduleName, 'settings', function () use ($moduleName): array {
return modules()->settings($moduleName);
});
$mm->registerFunction($moduleName, 'instances', function () use ($moduleName): array {
$settings = modules()->settings($moduleName);
$apiPath = trim((string)($settings['api_path'] ?? '/admin/api.php'));
if ($apiPath === '') {
$apiPath = '/admin/api.php';
}
if ($apiPath[0] !== '/') {
$apiPath = '/' . $apiPath;
}
$timeout = (int)($settings['api_timeout_sec'] ?? 8);
if ($timeout <= 0) {
$timeout = 8;
}
$verifyTls = !isset($settings['verify_tls']) || $settings['verify_tls'] === '1' || $settings['verify_tls'] === 1 || $settings['verify_tls'] === true;
$instances = [];
$rawJson = trim((string)($settings['instances_json'] ?? ''));
if ($rawJson !== '') {
$decoded = json_decode($rawJson, true);
if (is_array($decoded)) {
foreach ($decoded as $row) {
if (!is_array($row)) {
continue;
}
$id = trim((string)($row['id'] ?? ''));
$url = trim((string)($row['url'] ?? ''));
if ($id === '' || $url === '') {
continue;
}
$instances[$id] = [
'id' => $id,
'name' => trim((string)($row['name'] ?? '')) ?: $id,
'url' => rtrim($url, '/'),
'token' => trim((string)($row['token'] ?? '')),
'api_path' => $apiPath,
'timeout' => $timeout,
'verify_tls' => $verifyTls,
'is_primary' => !empty($row['is_primary']),
];
}
}
}
if (empty($instances)) {
foreach (['primary', 'secondary'] as $key) {
$urlKey = $key . '_url';
$tokenKey = $key . '_token';
$nameKey = $key . '_name';
$url = trim((string)($settings[$urlKey] ?? ''));
if ($url === '') {
continue;
}
$instances[$key] = [
'id' => $key,
'name' => trim((string)($settings[$nameKey] ?? '')) ?: ($key === 'primary' ? 'Primaer' : 'Sekundaer'),
'url' => rtrim($url, '/'),
'token' => trim((string)($settings[$tokenKey] ?? '')),
'api_path' => $apiPath,
'timeout' => $timeout,
'verify_tls' => $verifyTls,
'is_primary' => $key === 'primary',
];
}
}
$primaryId = trim((string)($settings['primary_id'] ?? ''));
if ($primaryId !== '' && isset($instances[$primaryId])) {
foreach ($instances as $id => &$row) {
$row['is_primary'] = ($id === $primaryId);
}
unset($row);
} else {
$hasPrimary = false;
foreach ($instances as $row) {
if (!empty($row['is_primary'])) {
$hasPrimary = true;
break;
}
}
if (!$hasPrimary && $instances) {
$firstKey = array_key_first($instances);
if ($firstKey !== null) {
$instances[$firstKey]['is_primary'] = true;
}
}
}
return $instances;
});
$mm->registerFunction($moduleName, 'lists_primary_only', function () use ($moduleName): bool {
$settings = modules()->settings($moduleName);
return !empty($settings['lists_primary_only']);
});

View File

@@ -0,0 +1,19 @@
{
"title": "Pi-hole",
"version": "0.1.0",
"description": "Pi-hole Monitoring, Listen und Steuerung fuer zwei Instanzen.",
"menu": [
{ "label": "Dashboard", "href": "/module/pihole" },
{ "label": "Instanzen", "href": "/module/pihole/instances" }
],
"sidebar": {
"enabled": true,
"collapsible": true,
"default": "collapsed",
"items": [
{ "label": "Dashboard", "href": "/module/pihole" },
{ "label": "Instanzen", "href": "/module/pihole/instances" }
]
},
"setup": { "fields": [] }
}

View File

@@ -0,0 +1,843 @@
<?php
require_auth();
header('Content-Type: application/json; charset=utf-8');
$action = (string)($_GET['action'] ?? '');
$instances = module_fn('pihole', 'instances');
$listsPrimaryOnly = module_fn('pihole', 'lists_primary_only');
$body = file_get_contents('php://input');
$payload = [];
if ($body !== false && $body !== '') {
$decoded = json_decode($body, true);
if (is_array($decoded)) {
$payload = $decoded;
}
}
$respond = function (array $data, int $status = 200): void {
http_response_code($status);
echo json_encode($data, JSON_UNESCAPED_UNICODE);
exit;
};
$normalizeApiPath = function (string $baseUrl, string $apiPath): string {
$base = rtrim($baseUrl, '/');
$path = $apiPath;
if ($path === '') {
$path = '/admin/api.php';
}
if ($path[0] !== '/') {
$path = '/' . $path;
}
return $base . $path;
};
$httpRequest = function (string $method, string $url, array $headers, ?string $body, bool $verify, int $timeout): array {
$raw = '';
$httpCode = 0;
$error = '';
if (function_exists('curl_init')) {
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => $timeout,
CURLOPT_CONNECTTIMEOUT => $timeout,
CURLOPT_SSL_VERIFYPEER => $verify,
CURLOPT_SSL_VERIFYHOST => $verify ? 2 : 0,
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_HTTPHEADER => $headers,
]);
if ($body !== null) {
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
}
$raw = (string)curl_exec($ch);
if ($raw === '' && curl_errno($ch)) {
$error = curl_error($ch);
}
$httpCode = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
} else {
$ctx = stream_context_create([
'http' => [
'method' => $method,
'timeout' => $timeout,
'header' => implode("\r\n", $headers),
'content' => $body ?? '',
],
'ssl' => [
'verify_peer' => $verify,
'verify_peer_name' => $verify,
],
]);
$raw = (string)@file_get_contents($url, false, $ctx);
$httpCode = 200;
if ($raw === '') {
$error = 'HTTP request failed';
}
}
if ($error !== '') {
return ['ok' => false, 'error' => $error, 'http_code' => $httpCode, 'url' => $url];
}
$data = json_decode($raw, true);
if (!is_array($data)) {
return ['ok' => false, 'error' => 'invalid_json', 'http_code' => $httpCode, 'raw' => $raw, 'url' => $url];
}
return ['ok' => true, 'data' => $data, 'http_code' => $httpCode, 'url' => $url];
};
$v5Request = function (array $instance, array $params) use ($normalizeApiPath, $httpRequest): array {
if (!empty($instance['token']) && !isset($params['auth'])) {
$params['auth'] = $instance['token'];
}
$url = $normalizeApiPath((string)$instance['url'], (string)$instance['api_path']);
$qs = http_build_query($params, '', '&', PHP_QUERY_RFC3986);
$full = $qs !== '' ? $url . '?' . $qs : $url;
$timeout = (int)($instance['timeout'] ?? 8);
if ($timeout <= 0) {
$timeout = 8;
}
$verify = !empty($instance['verify_tls']);
return $httpRequest('GET', $full, ['Accept: application/json'], null, $verify, $timeout);
};
$v6Auth = function (array $instance) use ($httpRequest): array {
$base = rtrim((string)$instance['url'], '/');
$url = $base . '/api/auth';
$timeout = (int)($instance['timeout'] ?? 8);
if ($timeout <= 0) {
$timeout = 8;
}
$verify = !empty($instance['verify_tls']);
$payload = ['password' => (string)($instance['token'] ?? '')];
$body = json_encode($payload, JSON_UNESCAPED_UNICODE);
$res = $httpRequest('POST', $url, ['Accept: application/json', 'Content-Type: application/json'], $body, $verify, $timeout);
if (!$res['ok']) {
return $res;
}
$data = (array)($res['data'] ?? []);
$session = (array)($data['session'] ?? []);
$sid = (string)($session['sid'] ?? '');
$res['sid'] = $sid;
return $res;
};
$v6Request = function (array $instance, string $path, string $method, array $payload, string $sid) use ($httpRequest): array {
$base = rtrim((string)$instance['url'], '/');
$path = ltrim($path, '/');
$url = $base . '/api/' . $path;
$timeout = (int)($instance['timeout'] ?? 8);
if ($timeout <= 0) {
$timeout = 8;
}
$verify = !empty($instance['verify_tls']);
$headers = ['Accept: application/json'];
if ($sid !== '') {
$headers[] = 'sid: ' . $sid;
$headers[] = 'X-FTL-SID: ' . $sid;
}
$body = null;
if ($method !== 'GET') {
if ($sid !== '' && !isset($payload['sid'])) {
$payload['sid'] = $sid;
}
$body = json_encode($payload, JSON_UNESCAPED_UNICODE);
$headers[] = 'Content-Type: application/json';
}
return $httpRequest($method, $url, $headers, $body, $verify, $timeout);
};
$detectApi = function (array $instance) use ($v6Auth, $v6Request, $v5Request): array {
$sid = '';
$authRes = null;
if (!empty($instance['token'])) {
$authRes = $v6Auth($instance);
if (($authRes['ok'] ?? false) && !empty($authRes['sid'])) {
$sid = (string)$authRes['sid'];
}
}
$probe = $v6Request($instance, 'stats/summary', 'GET', [], $sid);
if ($probe['ok'] || in_array((int)($probe['http_code'] ?? 0), [401, 403], true)) {
return ['version' => 6, 'sid' => $sid, 'probe' => $probe, 'auth' => $authRes];
}
$legacy = $v5Request($instance, ['summaryRaw' => 1]);
if ($legacy['ok']) {
return ['version' => 5, 'sid' => '', 'probe' => $legacy];
}
return ['version' => 0, 'sid' => '', 'probe' => $probe, 'legacy' => $legacy];
};
$resolvePrimaryId = function () use ($instances): ?string {
foreach ($instances as $id => $row) {
if (!empty($row['is_primary'])) {
return $id;
}
}
$first = array_key_first($instances);
return $first !== null ? (string)$first : null;
};
$pickInstances = function (string $target) use ($instances, $resolvePrimaryId): array {
if ($target === 'all') {
return $instances;
}
if ($target === 'primary') {
$primaryId = $resolvePrimaryId();
if ($primaryId !== null && isset($instances[$primaryId])) {
return [$primaryId => $instances[$primaryId]];
}
}
if (isset($instances[$target])) {
return [$target => $instances[$target]];
}
return [];
};
$aggregateTopList = function (array $items, array &$bucket): void {
foreach ($items as $label => $count) {
if (!is_string($label)) {
continue;
}
$bucket[$label] = ($bucket[$label] ?? 0) + (int)$count;
}
};
$aggregateMap = function (array $map, array &$bucket): void {
foreach ($map as $label => $count) {
if (!is_string($label)) {
continue;
}
$bucket[$label] = ($bucket[$label] ?? 0) + (int)$count;
}
};
$extractQueryTypes = function (array $data): array {
if (isset($data['querytypes']) && is_array($data['querytypes'])) {
return $data['querytypes'];
}
return $data;
};
$extractForwardDestinations = function (array $data): array {
if (isset($data['forward_destinations']) && is_array($data['forward_destinations'])) {
return $data['forward_destinations'];
}
return $data;
};
$extractSources = function (array $data): array {
if (isset($data['query_sources']) && is_array($data['query_sources'])) {
return $data['query_sources'];
}
return $data;
};
$parseUpdates = function (?array $versions): array {
if (!$versions) {
return ['available' => false, 'details' => []];
}
$details = [];
$available = false;
foreach (['core', 'web', 'FTL'] as $key) {
$current = $versions[$key . '_current'] ?? null;
$latest = $versions[$key . '_latest'] ?? null;
$updateFlag = $versions[$key . '_update'] ?? null;
$needs = false;
if (is_string($updateFlag)) {
$needs = $updateFlag === 'true' || $updateFlag === '1';
} elseif (is_bool($updateFlag)) {
$needs = $updateFlag;
}
if ($current && $latest && $current !== $latest) {
$needs = true;
}
$details[$key] = [
'current' => $current,
'latest' => $latest,
'update' => $needs,
];
if ($needs) {
$available = true;
}
}
return ['available' => $available, 'details' => $details];
};
if ($action === 'dashboard') {
if (empty($instances)) {
$respond(['ok' => false, 'error' => 'no_instances'], 400);
}
$aggregate = [
'summary' => [
'dns_queries_today' => 0,
'ads_blocked_today' => 0,
'unique_clients' => 0,
'unique_domains' => 0,
'queries_forwarded' => 0,
'queries_cached' => 0,
'status' => 'unknown',
],
'top_ads' => [],
'top_queries' => [],
'query_types' => [],
'query_sources' => [],
'forward_destinations' => [],
'recent_blocked' => [],
'updates' => ['available' => false, 'details' => []],
];
$instancePayloads = [];
$statuses = [];
$makeError = function (string $scope, array $result): array {
return [
'scope' => $scope,
'error' => $result['error'] ?? 'error',
'http_code' => $result['http_code'] ?? 0,
'url' => $result['url'] ?? '',
];
};
$now = time();
$from = $now - 3600;
foreach ($instances as $id => $instance) {
$errors = [];
$summaryData = null;
$topData = null;
$queryTypesData = null;
$querySourcesData = null;
$forwardDestData = null;
$recentData = null;
$updates = ['available' => false, 'details' => []];
$versionsData = null;
$apiInfo = $detectApi($instance);
if ($apiInfo['version'] === 6) {
$sid = (string)($apiInfo['sid'] ?? '');
$summary = $apiInfo['probe'];
if (!$summary['ok']) {
$errors[] = $makeError('summary', $summary);
}
$blocking = $v6Request($instance, 'dns/blocking', 'GET', [], $sid);
if (!$blocking['ok']) {
$errors[] = $makeError('blocking', $blocking);
}
$queries = $v6Request($instance, 'queries?from=' . $from . '&until=' . $now . '&length=2000', 'GET', [], $sid);
if (!$queries['ok']) {
$errors[] = $makeError('queries', $queries);
}
$upstreams = $v6Request($instance, 'stats/upstreams', 'GET', [], $sid);
if (!$upstreams['ok']) {
$errors[] = $makeError('upstreams', $upstreams);
}
if ($summary['ok'] && is_array($summary['data'])) {
$sum = $summary['data'];
$queriesBlock = (array)($sum['queries'] ?? []);
$clientsBlock = (array)($sum['clients'] ?? []);
$status = 'unknown';
if ($blocking['ok'] && is_array($blocking['data'])) {
$blockingState = $blocking['data']['blocking'] ?? null;
if ($blockingState === true || $blockingState === 'enabled') {
$status = 'enabled';
} elseif ($blockingState === false || $blockingState === 'disabled') {
$status = 'disabled';
}
}
$summaryData = [
'dns_queries_today' => (int)($queriesBlock['total'] ?? 0),
'ads_blocked_today' => (int)($queriesBlock['blocked'] ?? 0),
'unique_clients' => (int)($clientsBlock['active'] ?? $clientsBlock['total'] ?? 0),
'unique_domains' => (int)($queriesBlock['unique_domains'] ?? 0),
'queries_forwarded' => (int)($queriesBlock['forwarded'] ?? 0),
'queries_cached' => (int)($queriesBlock['cached'] ?? 0),
'status' => $status,
];
if ($summaryData['dns_queries_today'] > 0) {
$summaryData['ads_percentage_today'] = round(
$summaryData['ads_blocked_today'] / $summaryData['dns_queries_today'] * 100,
2
);
} else {
$summaryData['ads_percentage_today'] = 0;
}
$queryTypesData = (array)($queriesBlock['types'] ?? []);
}
if ($queries['ok'] && is_array($queries['data'])) {
$queryList = (array)($queries['data']['queries'] ?? []);
$topAds = [];
$topQueries = [];
$sources = [];
$recent = [];
foreach ($queryList as $entry) {
if (!is_array($entry)) {
continue;
}
$domainRaw = $entry['domain'] ?? '';
$domain = '';
if (is_array($domainRaw)) {
$domain = (string)($domainRaw['name'] ?? $domainRaw['domain'] ?? '');
} else {
$domain = (string)$domainRaw;
}
$clientRaw = $entry['client'] ?? '';
$client = '';
if (is_array($clientRaw)) {
$name = (string)($clientRaw['name'] ?? '');
$ip = (string)($clientRaw['ip'] ?? '');
$client = $name !== '' ? ($name . ' (' . $ip . ')') : ($ip !== '' ? $ip : 'unknown');
} else {
$client = (string)$clientRaw;
}
if ($client !== '') {
$sources[$client] = ($sources[$client] ?? 0) + 1;
}
if ($domain !== '') {
$topQueries[$domain] = ($topQueries[$domain] ?? 0) + 1;
}
$statusVal = (string)($entry['status'] ?? '');
$isBlocked = str_contains($statusVal, 'GRAVITY') || str_contains($statusVal, 'BLOCK');
if ($isBlocked && $domain !== '') {
$topAds[$domain] = ($topAds[$domain] ?? 0) + 1;
$recent[] = ['domain' => $domain, 'instance' => $instance['name']];
}
}
$topData = ['top_ads' => $topAds, 'top_queries' => $topQueries];
$querySourcesData = $sources;
$recentData = $recent;
}
if ($upstreams['ok'] && is_array($upstreams['data'])) {
$upList = (array)($upstreams['data']['upstreams'] ?? []);
$forwardDestData = [];
foreach ($upList as $item) {
if (!is_array($item)) {
continue;
}
$label = (string)($item['name'] ?? $item['ip'] ?? '');
if ($label === '' && isset($item['port'])) {
$label = 'upstream:' . (string)$item['port'];
}
if ($label === '') {
continue;
}
$forwardDestData[$label] = (int)($item['count'] ?? 0);
}
}
} elseif ($apiInfo['version'] === 5) {
$summary = $apiInfo['probe'];
$topItems = $v5Request($instance, ['topItems' => 50]);
$queryTypes = $v5Request($instance, ['getQueryTypes' => 1]);
$querySources = $v5Request($instance, ['getQuerySources' => 1]);
$forwardDest = $v5Request($instance, ['getForwardDestinations' => 1]);
$recentBlocked = $v5Request($instance, ['recentBlocked' => 30]);
$versions = $v5Request($instance, ['versions' => 1]);
if (!$summary['ok']) {
$errors[] = $makeError('summary', $summary);
}
if (!$topItems['ok']) {
$errors[] = $makeError('topItems', $topItems);
}
if (!$queryTypes['ok']) {
$errors[] = $makeError('queryTypes', $queryTypes);
}
if (!$querySources['ok']) {
$errors[] = $makeError('querySources', $querySources);
}
if (!$forwardDest['ok']) {
$errors[] = $makeError('forwardDestinations', $forwardDest);
}
if (!$recentBlocked['ok']) {
$errors[] = $makeError('recentBlocked', $recentBlocked);
}
if (!$versions['ok']) {
$errors[] = $makeError('versions', $versions);
}
$summaryData = $summary['ok'] ? $summary['data'] : null;
$topData = $topItems['ok'] ? $topItems['data'] : null;
$queryTypesData = $queryTypes['ok'] ? $extractQueryTypes($queryTypes['data']) : null;
$querySourcesData = $querySources['ok'] ? $extractSources($querySources['data']) : null;
$forwardDestData = $forwardDest['ok'] ? $extractForwardDestinations($forwardDest['data']) : null;
$recentData = $recentBlocked['ok'] ? $recentBlocked['data'] : null;
$versionsData = $versions['ok'] ? $versions['data'] : null;
$updates = $parseUpdates(is_array($versionsData) ? $versionsData : null);
} else {
$probe = $apiInfo['probe'] ?? ['error' => 'unknown'];
$errors[] = $makeError('probe', is_array($probe) ? $probe : ['error' => 'unknown']);
}
if (is_array($summaryData)) {
$aggregate['summary']['dns_queries_today'] += (int)($summaryData['dns_queries_today'] ?? 0);
$aggregate['summary']['ads_blocked_today'] += (int)($summaryData['ads_blocked_today'] ?? 0);
$aggregate['summary']['unique_clients'] += (int)($summaryData['unique_clients'] ?? 0);
$aggregate['summary']['unique_domains'] += (int)($summaryData['unique_domains'] ?? 0);
$aggregate['summary']['queries_forwarded'] += (int)($summaryData['queries_forwarded'] ?? 0);
$aggregate['summary']['queries_cached'] += (int)($summaryData['queries_cached'] ?? 0);
$status = (string)($summaryData['status'] ?? 'unknown');
$statuses[] = $status;
}
if (is_array($topData)) {
if (!empty($topData['top_ads']) && is_array($topData['top_ads'])) {
$aggregateTopList($topData['top_ads'], $aggregate['top_ads']);
}
if (!empty($topData['top_queries']) && is_array($topData['top_queries'])) {
$aggregateTopList($topData['top_queries'], $aggregate['top_queries']);
}
}
if (is_array($queryTypesData)) {
$aggregateMap($queryTypesData, $aggregate['query_types']);
}
if (is_array($querySourcesData)) {
$aggregateMap($querySourcesData, $aggregate['query_sources']);
}
if (is_array($forwardDestData)) {
$aggregateMap($forwardDestData, $aggregate['forward_destinations']);
}
if (is_array($recentData)) {
foreach ($recentData as $entry) {
if (is_string($entry)) {
$aggregate['recent_blocked'][] = ['domain' => $entry, 'instance' => $instance['name']];
} elseif (is_array($entry) && isset($entry['domain'])) {
$aggregate['recent_blocked'][] = ['domain' => (string)$entry['domain'], 'instance' => $instance['name']];
}
}
}
if (!empty($updates['available'])) {
$aggregate['updates']['available'] = true;
}
$aggregate['updates']['details'][$id] = $updates;
$instancePayloads[$id] = [
'meta' => [
'id' => $id,
'name' => $instance['name'],
'url' => $instance['url'],
'is_primary' => !empty($instance['is_primary']),
],
'summary' => $summaryData,
'top_items' => $topData,
'query_types' => $queryTypesData,
'query_sources' => $querySourcesData,
'forward_destinations' => $forwardDestData,
'recent_blocked' => $recentData,
'versions' => $versionsData,
'updates' => $updates,
'errors' => $errors,
];
}
if ($aggregate['summary']['dns_queries_today'] > 0) {
$aggregate['summary']['ads_percentage_today'] = round(
$aggregate['summary']['ads_blocked_today'] / $aggregate['summary']['dns_queries_today'] * 100,
2
);
} else {
$aggregate['summary']['ads_percentage_today'] = 0;
}
$status = 'unknown';
if ($statuses) {
$allEnabled = count(array_filter($statuses, fn($s) => $s === 'enabled')) === count($statuses);
$allDisabled = count(array_filter($statuses, fn($s) => $s === 'disabled')) === count($statuses);
if ($allEnabled) {
$status = 'enabled';
} elseif ($allDisabled) {
$status = 'disabled';
} else {
$status = 'partial';
}
}
$aggregate['summary']['status'] = $status;
$respond([
'ok' => true,
'ts' => time(),
'instances' => $instancePayloads,
'aggregate' => $aggregate,
]);
}
if ($action === 'test') {
require_admin();
$target = (string)($payload['instance'] ?? '');
if ($target === '') {
$respond(['ok' => false, 'error' => 'missing_instance'], 400);
}
if (!isset($instances[$target])) {
$respond(['ok' => false, 'error' => 'invalid_instance'], 400);
}
$instance = $instances[$target];
$apiInfo = $detectApi($instance);
if ($apiInfo['version'] === 6) {
$sid = (string)($apiInfo['sid'] ?? '');
$result = $apiInfo['probe'];
if ($result['ok']) {
$respond([
'ok' => true,
'status' => 'ok',
'message' => 'Verbindung OK. API v6 antwortet.',
]);
}
$httpCode = (int)($result['http_code'] ?? 0);
$error = (string)($result['error'] ?? 'error');
$status = 'error';
$message = 'Unbekannter Fehler.';
if ($httpCode === 0) {
$status = 'unreachable';
$message = 'Host nicht erreichbar oder kein HTTP-Response.';
} elseif ($httpCode === 401 || $httpCode === 403) {
$status = 'auth';
$message = 'API Passwort falsch oder nicht berechtigt.';
} elseif ($error === 'invalid_json') {
$status = 'invalid';
$message = 'API antwortet nicht mit JSON. URL pruefen.';
} else {
$status = 'error';
$message = 'API Fehler: ' . $error . ' (HTTP ' . $httpCode . ')';
}
$respond([
'ok' => false,
'status' => $status,
'message' => $message,
'http_code' => $httpCode,
'error' => $error,
'url' => (string)($result['url'] ?? ''),
]);
}
$result = $v5Request($instance, ['summaryRaw' => 1]);
if ($result['ok']) {
$respond([
'ok' => true,
'status' => 'ok',
'message' => 'Verbindung OK. API v5 antwortet.',
]);
}
$httpCode = (int)($result['http_code'] ?? 0);
$error = (string)($result['error'] ?? 'error');
$status = 'error';
$message = 'Unbekannter Fehler.';
if ($httpCode === 0) {
$status = 'unreachable';
$message = 'Host nicht erreichbar oder kein HTTP-Response.';
} elseif ($httpCode === 401 || $httpCode === 403) {
$status = 'auth';
$message = 'API Token/Passwort falsch oder nicht berechtigt.';
} elseif ($error === 'invalid_json') {
$status = 'invalid';
$message = 'API antwortet nicht mit JSON. URL oder API-Pfad pruefen.';
} else {
$status = 'error';
$message = 'API Fehler: ' . $error . ' (HTTP ' . $httpCode . ')';
}
$respond([
'ok' => false,
'status' => $status,
'message' => $message,
'http_code' => $httpCode,
'error' => $error,
'url' => (string)($result['url'] ?? ''),
]);
}
if ($action === 'disable') {
require_admin();
$minutes = (int)($payload['minutes'] ?? 0);
$target = (string)($payload['instance'] ?? 'all');
if ($minutes <= 0) {
$respond(['ok' => false, 'error' => 'invalid_minutes'], 400);
}
$targets = $pickInstances($target);
if (!$targets) {
$respond(['ok' => false, 'error' => 'invalid_instance'], 400);
}
$results = [];
foreach ($targets as $id => $instance) {
$apiInfo = $detectApi($instance);
if ($apiInfo['version'] === 6) {
$sid = (string)($apiInfo['sid'] ?? '');
$result = $v6Request($instance, 'dns/blocking', 'POST', [
'blocking' => false,
'timer' => $minutes * 60,
], $sid);
} elseif ($apiInfo['version'] === 5) {
$result = $v5Request($instance, ['disable' => $minutes * 60]);
} else {
$result = ['ok' => false, 'error' => 'unknown_api', 'http_code' => 0, 'url' => ''];
}
$results[$id] = $result;
}
$respond(['ok' => true, 'results' => $results]);
}
if ($action === 'enable') {
require_admin();
$target = (string)($payload['instance'] ?? 'all');
$targets = $pickInstances($target);
if (!$targets) {
$respond(['ok' => false, 'error' => 'invalid_instance'], 400);
}
$results = [];
foreach ($targets as $id => $instance) {
$apiInfo = $detectApi($instance);
if ($apiInfo['version'] === 6) {
$sid = (string)($apiInfo['sid'] ?? '');
$result = $v6Request($instance, 'dns/blocking', 'POST', [
'blocking' => true,
], $sid);
} elseif ($apiInfo['version'] === 5) {
$result = $v5Request($instance, ['enable' => 1]);
} else {
$result = ['ok' => false, 'error' => 'unknown_api', 'http_code' => 0, 'url' => ''];
}
$results[$id] = $result;
}
$respond(['ok' => true, 'results' => $results]);
}
if ($action === 'gravity') {
require_admin();
$target = $listsPrimaryOnly ? 'primary' : (string)($payload['instance'] ?? 'primary');
$targets = $pickInstances($target);
if (!$targets) {
$respond(['ok' => false, 'error' => 'invalid_instance'], 400);
}
$results = [];
foreach ($targets as $id => $instance) {
$apiInfo = $detectApi($instance);
if ($apiInfo['version'] === 6) {
$sid = (string)($apiInfo['sid'] ?? '');
$result = $v6Request($instance, 'action/gravity', 'POST', [], $sid);
} elseif ($apiInfo['version'] === 5) {
$result = $v5Request($instance, ['updateGravity' => 1]);
} else {
$result = ['ok' => false, 'error' => 'unknown_api', 'http_code' => 0, 'url' => ''];
}
$results[$id] = $result;
}
$respond(['ok' => true, 'results' => $results]);
}
if ($action === 'domain_add') {
require_admin();
$domain = trim((string)($payload['domain'] ?? ''));
$type = (string)($payload['type'] ?? 'block');
if ($domain === '') {
$respond(['ok' => false, 'error' => 'missing_domain'], 400);
}
$target = $listsPrimaryOnly ? 'primary' : (string)($payload['instance'] ?? 'primary');
$targets = $pickInstances($target);
if (!$targets) {
$respond(['ok' => false, 'error' => 'invalid_instance'], 400);
}
$params = $type === 'allow'
? ['list' => 'white', 'add' => $domain]
: ['list' => 'black', 'add' => $domain];
$results = [];
foreach ($targets as $id => $instance) {
$apiInfo = $detectApi($instance);
if ($apiInfo['version'] === 6) {
$result = ['ok' => false, 'error' => 'not_supported_v6', 'http_code' => 400, 'url' => ''];
} elseif ($apiInfo['version'] === 5) {
$result = $v5Request($instance, $params);
} else {
$result = ['ok' => false, 'error' => 'unknown_api', 'http_code' => 0, 'url' => ''];
}
$results[$id] = $result;
}
$respond(['ok' => true, 'results' => $results]);
}
if ($action === 'adlist_add') {
require_admin();
$url = trim((string)($payload['url'] ?? ''));
if ($url === '') {
$respond(['ok' => false, 'error' => 'missing_url'], 400);
}
$target = $listsPrimaryOnly ? 'primary' : (string)($payload['instance'] ?? 'primary');
$targets = $pickInstances($target);
if (!$targets) {
$respond(['ok' => false, 'error' => 'invalid_instance'], 400);
}
$results = [];
foreach ($targets as $id => $instance) {
$apiInfo = $detectApi($instance);
if ($apiInfo['version'] === 6) {
$result = ['ok' => false, 'error' => 'not_supported_v6', 'http_code' => 400, 'url' => ''];
} elseif ($apiInfo['version'] === 5) {
$result = $v5Request($instance, ['list' => 'adlist', 'add' => $url]);
} else {
$result = ['ok' => false, 'error' => 'unknown_api', 'http_code' => 0, 'url' => ''];
}
$results[$id] = $result;
}
$respond(['ok' => true, 'results' => $results]);
}
if ($action === 'update') {
require_admin();
$target = (string)($payload['instance'] ?? 'primary');
$targets = $pickInstances($target);
if (!$targets) {
$respond(['ok' => false, 'error' => 'invalid_instance'], 400);
}
$results = [];
foreach ($targets as $id => $instance) {
$apiInfo = $detectApi($instance);
if ($apiInfo['version'] === 6) {
$result = ['ok' => false, 'error' => 'not_supported_v6', 'http_code' => 400, 'url' => ''];
} elseif ($apiInfo['version'] === 5) {
$result = $v5Request($instance, ['update' => 1]);
} else {
$result = ['ok' => false, 'error' => 'unknown_api', 'http_code' => 0, 'url' => ''];
}
$results[$id] = $result;
}
$respond(['ok' => true, 'results' => $results]);
}
$respond(['ok' => false, 'error' => 'unknown_action'], 400);

View File

@@ -0,0 +1,31 @@
<?php
$file = (string)($_GET['file'] ?? '');
$base = realpath(__DIR__ . '/../assets');
$map = [
'pihole.css' => $base . '/pihole.css',
'pihole.js' => $base . '/pihole.js',
'pihole_instances.js' => $base . '/pihole_instances.js',
];
if (!isset($map[$file])) {
http_response_code(404);
exit('Not found');
}
$path = $map[$file];
if (!$base || !is_file($path) || !str_starts_with($path, $base)) {
http_response_code(404);
exit('Not found');
}
$ext = pathinfo($path, PATHINFO_EXTENSION);
if ($ext === 'css') {
header('Content-Type: text/css; charset=utf-8');
} elseif ($ext === 'js') {
header('Content-Type: application/javascript; charset=utf-8');
} else {
header('Content-Type: application/octet-stream');
}
readfile($path);
exit;

View File

@@ -0,0 +1,136 @@
<?php
$assets = app()->assets();
$assets->addStyle('/module/pihole/asset?file=pihole.css');
$assets->addScript('/module/pihole/asset?file=pihole.js', 'footer', true);
$instances = module_fn('pihole', 'instances');
$hasConfig = !empty($instances);
?>
<div class="card pihole-page" data-pihole-page="dashboard">
<div class="pill">Pi-hole</div>
<h1 style="margin-top:.75rem;">Pi-hole Dashboard</h1>
<p class="muted">Status, Blockings, Usage und Steuerung fuer beide Instanzen.</p>
<div class="card" style="margin-top:1rem;">
<div class="pihole-section-header">
<strong>Hosts</strong>
<a class="nav-link" href="/module/pihole/instances">Instanzen verwalten</a>
</div>
<?php if (!$instances): ?>
<div class="muted" style="margin-top:.75rem;">Keine Pi-hole Instanzen vorhanden. Bitte zuerst hinzufuegen.</div>
<div style="margin-top:.75rem;"><a class="cta-button" href="/module/pihole/instances">+ Neue Instanz</a></div>
<?php else: ?>
<div class="pihole-list" style="margin-top:1rem;">
<?php foreach ($instances as $instance): ?>
<div class="pihole-list-row">
<div>
<strong><?= e((string)($instance['name'] ?? $instance['id'] ?? '')) ?></strong>
<div class="muted"><?= e((string)($instance['url'] ?? '')) ?></div>
</div>
<?php if (!empty($instance['is_primary'])): ?>
<span class="pihole-status">Primaer</span>
<?php endif; ?>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
<?php if (!$hasConfig): ?>
<?php return; ?>
<?php else: ?>
<div class="pihole-grid" style="margin-top:1rem;">
<div class="card pihole-stat">
<div class="muted">DNS Queries (heute)</div>
<div class="pihole-stat-value" data-summary-dns></div>
</div>
<div class="card pihole-stat">
<div class="muted">Ads geblockt</div>
<div class="pihole-stat-value" data-summary-blocked></div>
<div class="pihole-stat-sub" data-summary-percent></div>
</div>
<div class="card pihole-stat">
<div class="muted">Unique Clients</div>
<div class="pihole-stat-value" data-summary-clients></div>
</div>
<div class="card pihole-stat">
<div class="muted">Status</div>
<div class="pihole-stat-value" data-summary-status></div>
</div>
</div>
<div class="card" style="margin-top:1.25rem;">
<div class="pihole-section-header">
<strong>Blocker steuern (alle Instanzen)</strong>
<span class="muted" data-summary-last-refresh>Letztes Update: </span>
</div>
<div class="pihole-actions" data-global-actions>
<button class="cta-button" data-action="enable" data-instance="all">Aktivieren</button>
<button class="nav-link" data-action="disable" data-minutes="5" data-instance="all">5 Min</button>
<button class="nav-link" data-action="disable" data-minutes="10" data-instance="all">10 Min</button>
<button class="nav-link" data-action="disable" data-minutes="20" data-instance="all">20 Min</button>
<button class="nav-link" data-action="disable" data-minutes="30" data-instance="all">30 Min</button>
<button class="nav-link" data-action="disable" data-minutes="60" data-instance="all">60 Min</button>
<div class="pihole-inline">
<input type="number" min="1" max="1440" placeholder="Minuten" data-custom-minutes="all">
<button class="nav-link" data-action="disable-custom" data-instance="all">Custom</button>
</div>
</div>
</div>
<div class="card" style="margin-top:1.25rem;">
<div class="pihole-section-header">
<strong>Instanzen</strong>
<span class="muted">Einzeln steuerbar &amp; getrennte Updates</span>
</div>
<div class="pihole-instance-grid" data-instance-cards></div>
</div>
<div class="card" style="margin-top:1.25rem;">
<div class="pihole-section-header">
<strong>Usage (Aggregiert)</strong>
<span class="muted">Query-Typen und Weiterleitungen</span>
</div>
<div class="pihole-split" style="margin-top:1rem;">
<div>
<div class="muted">Query-Typen</div>
<div class="pihole-list" data-query-types></div>
</div>
<div>
<div class="muted">Forward Destinations</div>
<div class="pihole-list" data-forward-destinations></div>
</div>
</div>
</div>
<?php endif; ?>
</div>
<template id="pihole-instance-template">
<div class="card pihole-instance" data-instance="">
<div class="pihole-instance-header">
<div>
<strong data-instance-name></strong>
<div class="muted" data-instance-url></div>
</div>
<div class="pihole-status" data-instance-status></div>
</div>
<div class="pihole-instance-stats">
<div><span class="muted">DNS heute</span><div class="pihole-instance-value" data-instance-dns></div></div>
<div><span class="muted">Ads geblockt</span><div class="pihole-instance-value" data-instance-ads></div></div>
<div><span class="muted">% Blocked</span><div class="pihole-instance-value" data-instance-percent></div></div>
</div>
<div class="pihole-actions" data-instance-actions>
<button class="cta-button" data-action="enable" data-instance="">Aktivieren</button>
<button class="nav-link" data-action="disable" data-minutes="5" data-instance="">5 Min</button>
<button class="nav-link" data-action="disable" data-minutes="10" data-instance="">10 Min</button>
<button class="nav-link" data-action="disable" data-minutes="30" data-instance="">30 Min</button>
<div class="pihole-inline">
<input type="number" min="1" max="1440" placeholder="Minuten" data-custom-minutes="">
<button class="nav-link" data-action="disable-custom" data-instance="">Custom</button>
</div>
<button class="nav-link" data-action="update" data-instance="">Pi-hole Update</button>
</div>
<div class="pihole-update" data-instance-update></div>
<div class="pihole-error" data-instance-errors></div>
</div>
</template>

View File

@@ -0,0 +1,224 @@
<?php
$moduleName = 'pihole';
$assets = app()->assets();
$assets->addStyle('/module/pihole/asset?file=pihole.css');
$assets->addScript('/module/pihole/asset?file=pihole_instances.js', 'footer', true);
require_admin();
$settings = modules()->settings($moduleName);
$notice = null;
$error = null;
$loadInstances = function (array $settings): array {
$instances = [];
$rawJson = trim((string)($settings['instances_json'] ?? ''));
if ($rawJson !== '') {
$decoded = json_decode($rawJson, true);
if (is_array($decoded)) {
foreach ($decoded as $row) {
if (!is_array($row)) {
continue;
}
$id = trim((string)($row['id'] ?? ''));
$url = trim((string)($row['url'] ?? ''));
if ($id === '' || $url === '') {
continue;
}
$instances[$id] = [
'id' => $id,
'name' => trim((string)($row['name'] ?? '')) ?: $id,
'url' => $url,
'token' => trim((string)($row['token'] ?? '')),
'is_primary' => !empty($row['is_primary']),
];
}
}
}
if (!$instances) {
foreach (['primary', 'secondary'] as $key) {
$url = trim((string)($settings[$key . '_url'] ?? ''));
if ($url === '') {
continue;
}
$instances[$key] = [
'id' => $key,
'name' => trim((string)($settings[$key . '_name'] ?? '')) ?: ($key === 'primary' ? 'Primaer' : 'Sekundaer'),
'url' => $url,
'token' => trim((string)($settings[$key . '_token'] ?? '')),
'is_primary' => $key === 'primary',
];
}
}
return $instances;
};
$instances = $loadInstances($settings);
$sanitizeId = function (string $id): string {
$id = preg_replace('/[^a-zA-Z0-9_-]/', '', $id);
return trim((string)$id);
};
$saveInstances = function (array $settings, array $instances): void {
$payload = $settings;
$payload['instances_json'] = json_encode(array_values($instances), JSON_UNESCAPED_UNICODE);
modules()->saveSettings('pihole', $payload);
};
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$deleteId = trim((string)($_POST['delete_id'] ?? ''));
$currentId = trim((string)($_POST['current_id'] ?? ''));
$instanceId = trim((string)($_POST['instance_id'] ?? ''));
$name = trim((string)($_POST['name'] ?? ''));
$url = trim((string)($_POST['url'] ?? ''));
$token = trim((string)($_POST['token'] ?? ''));
$isPrimary = isset($_POST['is_primary']);
if ($deleteId !== '') {
if (isset($instances[$deleteId])) {
unset($instances[$deleteId]);
$notice = 'Instanz geloescht.';
}
} else {
$instanceId = $sanitizeId($instanceId);
if ($instanceId === '' || $url === '') {
$error = 'Bitte ID und URL angeben.';
} else {
$existingToken = '';
if ($currentId !== '' && isset($instances[$currentId])) {
$existingToken = (string)($instances[$currentId]['token'] ?? '');
}
$tokenToStore = $token !== '' ? $token : $existingToken;
if ($currentId !== '' && $currentId !== $instanceId) {
unset($instances[$currentId]);
}
$instances[$instanceId] = [
'id' => $instanceId,
'name' => $name !== '' ? $name : $instanceId,
'url' => $url,
'token' => $tokenToStore,
'is_primary' => $isPrimary,
];
if ($isPrimary) {
foreach ($instances as $id => &$row) {
$row['is_primary'] = ($id === $instanceId);
}
unset($row);
$settings['primary_id'] = $instanceId;
}
$notice = $currentId !== '' ? 'Instanz aktualisiert.' : 'Instanz gespeichert.';
}
}
if (!$error) {
$saveInstances($settings, $instances);
$settings = modules()->settings($moduleName);
$instances = $loadInstances($settings);
}
}
$primaryId = trim((string)($settings['primary_id'] ?? ''));
if ($primaryId === '') {
foreach ($instances as $id => $row) {
if (!empty($row['is_primary'])) {
$primaryId = $id;
break;
}
}
}
?>
<div class="card">
<div class="pill">Pi-hole</div>
<div style="display:flex; align-items:center; justify-content:space-between; gap:12px; flex-wrap:wrap; margin-top:.75rem;">
<h1 style="margin:0;">Instanzen</h1>
<div style="display:flex; gap:10px; flex-wrap:wrap;">
<button class="cta-button" type="button" data-instance-new>+ Neue Instanz</button>
</div>
</div>
<p class="muted">Pi-hole Instanzen hinzufuegen, bearbeiten und loeschen.</p>
<?php if ($error): ?>
<div class="card notice-card" style="margin-top:1rem; border-color:#ffb4a8; background:#fff5f3; color:#7a2114;">
<?= e($error) ?>
</div>
<?php elseif ($notice): ?>
<div class="card notice-card" style="margin-top:1rem; border-color:var(--accent-2);">
<?= e($notice) ?>
</div>
<?php endif; ?>
<div class="pihole-instance-grid" style="margin-top:1rem;">
<?php if (!$instances): ?>
<div class="card" style="padding:16px;">Keine Instanzen vorhanden.</div>
<?php else: ?>
<?php foreach ($instances as $instance): ?>
<div class="card pihole-instance-card"
data-instance-id="<?= e((string)$instance['id']) ?>"
data-name="<?= e((string)($instance['name'] ?? '')) ?>"
data-url="<?= e((string)($instance['url'] ?? '')) ?>"
data-primary="<?= !empty($instance['is_primary']) ? '1' : '0' ?>">
<div class="pihole-instance-header">
<div>
<strong><?= e((string)($instance['name'] ?? '')) ?></strong>
<div class="muted">ID: <?= e((string)($instance['id'] ?? '')) ?></div>
<div class="muted">URL: <?= e((string)($instance['url'] ?? '')) ?></div>
</div>
<?php if (!empty($instance['is_primary']) || $instance['id'] === $primaryId): ?>
<span class="pihole-status">Primaer</span>
<?php endif; ?>
</div>
<div class="pihole-card-actions">
<button class="nav-link" type="button" data-instance-edit>Bearbeiten</button>
<button class="nav-link" type="button" data-instance-test>Test Verbindung</button>
<form method="post" onsubmit="return confirm('Instanz wirklich loeschen?')">
<input type="hidden" name="delete_id" value="<?= e((string)($instance['id'] ?? '')) ?>">
<button class="nav-link" type="submit">Loeschen</button>
</form>
</div>
<div class="pihole-test-result" data-instance-result></div>
</div>
<?php endforeach; ?>
<?php endif; ?>
</div>
</div>
<div class="modal" data-instance-modal aria-hidden="true">
<div class="modal-card">
<div class="modal-header">
<strong data-instance-modal-title>Neue Instanz</strong>
<button class="icon-button" type="button" data-instance-close>×</button>
</div>
<form method="post" class="form-grid" style="margin-top:.75rem;" data-instance-form>
<input type="hidden" name="current_id" value="">
<label class="form-field">
<span class="muted">ID</span>
<input type="text" name="instance_id" placeholder="z.B. pihole-main" required>
</label>
<label class="form-field">
<span class="muted">Name</span>
<input type="text" name="name" placeholder="z.B. Pi-hole Main" required>
</label>
<label class="form-field">
<span class="muted">URL</span>
<input type="text" name="url" placeholder="http://pi-hole.local" required>
</label>
<label class="form-field">
<span class="muted">API Token / Web-Passwort (v6) (leer lassen = unveraendert)</span>
<input type="password" name="token" placeholder="Token" autocomplete="new-password">
</label>
<label class="form-field" style="align-items:center;">
<span class="muted">Als Primaer verwenden</span>
<input type="checkbox" name="is_primary" value="1">
</label>
<div style="display:flex; gap:10px; flex-wrap:wrap;">
<button class="cta-button" type="submit" data-instance-submit>Speichern</button>
<button class="nav-link" type="button" data-instance-cancel>Zuruecksetzen</button>
</div>
</form>
</div>
</div>

View File

@@ -0,0 +1,86 @@
<?php
$assets = app()->assets();
$assets->addStyle('/module/pihole/asset?file=pihole.css');
$assets->addScript('/module/pihole/asset?file=pihole.js', 'footer', true);
$instances = module_fn('pihole', 'instances');
$hasConfig = !empty($instances);
?>
<div class="card pihole-page" data-pihole-page="lists">
<div class="pill">Pi-hole</div>
<h1 style="margin-top:.75rem;">Listen &amp; Domains</h1>
<p class="muted">Top-Domains, Listen-Updates und neue Eintraege (Primaer-Instanz).</p>
<?php if (!$hasConfig): ?>
<div class="card" style="margin-top:1rem; border-color:var(--accent);">
<strong>Keine Instanzen konfiguriert</strong>
<div class="muted" style="margin-top:.35rem;">Bitte zuerst eine Pi-hole Instanz hinzufuegen.</div>
<div style="margin-top:.75rem;"><a class="nav-link" href="/module/pihole/instances">Instanzen verwalten</a></div>
</div>
<?php else: ?>
<div class="card" style="margin-top:1rem;">
<div class="pihole-section-header">
<strong>Listen-Updates</strong>
<span class="muted">Gravity / Blocklisten neu laden</span>
</div>
<div class="pihole-actions" data-list-actions>
<button class="cta-button" data-action="gravity" data-instance="primary">Listen aktualisieren (Primaer)</button>
</div>
<div class="pihole-update" data-list-update-status></div>
</div>
<div class="pihole-split" style="margin-top:1.25rem;">
<div class="card">
<div class="pihole-section-header">
<strong>Top geblockte Domains (Aggregiert)</strong>
<span class="muted">Letzte Statistiken</span>
</div>
<div class="pihole-list" data-top-ads></div>
</div>
<div class="card">
<div class="pihole-section-header">
<strong>Top erlaubte Domains (Aggregiert)</strong>
<span class="muted">Letzte Statistiken</span>
</div>
<div class="pihole-list" data-top-queries></div>
</div>
</div>
<div class="card" style="margin-top:1.25rem;">
<div class="pihole-section-header">
<strong>Domainlisten erweitern</strong>
<span class="muted">Eintraege werden auf der Primaer-Instanz gesetzt</span>
</div>
<form class="pihole-form" data-domain-form>
<label>
<span class="muted">Typ</span>
<select name="type">
<option value="block">Blacklist (Blocken)</option>
<option value="allow">Whitelist (Erlauben)</option>
</select>
</label>
<label>
<span class="muted">Domain</span>
<input type="text" name="domain" placeholder="example.com" required>
</label>
<button class="cta-button" type="submit">Hinzufuegen</button>
</form>
<div class="pihole-update" data-domain-status></div>
</div>
<div class="card" style="margin-top:1.25rem;">
<div class="pihole-section-header">
<strong>Adlist-URL hinzufuegen</strong>
<span class="muted">Optional: unterstuetzt nur wenn die API den Endpunkt anbietet.</span>
</div>
<form class="pihole-form" data-adlist-form>
<label>
<span class="muted">Adlist URL</span>
<input type="text" name="url" placeholder="https://example.com/list.txt" required>
</label>
<button class="nav-link" type="submit">Adlist hinzufuegen</button>
</form>
<div class="pihole-update" data-adlist-status></div>
</div>
<?php endif; ?>
</div>

View File

@@ -0,0 +1,38 @@
<?php
$assets = app()->assets();
$assets->addStyle('/module/pihole/asset?file=pihole.css');
$assets->addScript('/module/pihole/asset?file=pihole.js', 'footer', true);
$instances = module_fn('pihole', 'instances');
$hasConfig = !empty($instances);
?>
<div class="card pihole-page" data-pihole-page="queries">
<div class="pill">Pi-hole</div>
<h1 style="margin-top:.75rem;">Zugriffe &amp; Blockings</h1>
<p class="muted">Aktuelle Blockings, Top Clients und Status pro Instanz.</p>
<?php if (!$hasConfig): ?>
<div class="card" style="margin-top:1rem; border-color:var(--accent);">
<strong>Keine Instanzen konfiguriert</strong>
<div class="muted" style="margin-top:.35rem;">Bitte zuerst eine Pi-hole Instanz hinzufuegen.</div>
<div style="margin-top:.75rem;"><a class="nav-link" href="/module/pihole/instances">Instanzen verwalten</a></div>
</div>
<?php else: ?>
<div class="pihole-split" style="margin-top:1rem;">
<div class="card">
<div class="pihole-section-header">
<strong>Aktuelle Blockings</strong>
<span class="muted">Letzte geblockte Domains</span>
</div>
<div class="pihole-blocked" data-recent-blocked></div>
</div>
<div class="card">
<div class="pihole-section-header">
<strong>Top Clients (Aggregiert)</strong>
<span class="muted">Anfragen nach Client</span>
</div>
<div class="pihole-list" data-top-clients></div>
</div>
</div>
<?php endif; ?>
</div>

View File

@@ -50,11 +50,12 @@ $user = [
'sub' => (string)($claims['sub'] ?? ''),
'email' => (string)($claims['email'] ?? ''),
'name' => (string)($claims['name'] ?? ($claims['preferred_username'] ?? '')),
'username' => (string)($claims['preferred_username'] ?? $claims['email'] ?? $claims['sub'] ?? ''),
'groups' => $groups,
'id_token' => $idToken,
];
$_SESSION['auth_user'] = $user;
app()->auth()->storeUser($claims, $groups, $idToken);
if (defined('APP_AUTH_DEBUG') && APP_AUTH_DEBUG) {
$log = [
@@ -77,4 +78,6 @@ if (defined('APP_AUTH_DEBUG') && APP_AUTH_DEBUG) {
@file_put_contents(__DIR__ . '/../../../debug/oidc_login.log', json_encode($log) . PHP_EOL, FILE_APPEND);
}
redirect('/');
$returnTo = (string)($_SESSION['oidc_return_to'] ?? '/');
unset($_SESSION['oidc_return_to']);
redirect($returnTo !== '' && str_starts_with($returnTo, '/') && !str_starts_with($returnTo, '//') ? $returnTo : '/');

View File

@@ -10,7 +10,7 @@ if (!empty($_SESSION['auth_user']['id_token'])) {
$idToken = (string)$_SESSION['auth_user']['id_token'];
}
unset($_SESSION['auth_user']);
unset($_SESSION['auth_user'], $_SESSION['auth_id_token'], $_SESSION['auth_expires_at']);
if ($config->authEnabled) {
$client = new OidcClient($config);

View File

@@ -1,34 +1,76 @@
<?php
$modules = modules()->all();
declare(strict_types=1);
$auth = app()->auth();
$authUser = $auth->user();
$modules = array_values(array_filter(
$auth->filterModules(modules()->all()),
static fn (array $module): bool => !empty($module['enabled'])
));
?>
<div class="card">
<div class="pill">Core</div>
<h1 style="margin-top:.75rem;">Nexus Basis-System</h1>
<p class="muted">Aktive Module verwalten und neue Module initialisieren.</p>
<section class="home-hero" data-reveal>
<a class="brand-mark" href="/" aria-label="Nexus">
<img src="/assets/images/kusche-logo.png" alt="Kusche Logo">
</a>
<div class="brand-copy">
<span class="eyebrow">Nexus</span>
<h1><?= e(defined('APP_DOMAIN_PRIMARY') ? (string)APP_DOMAIN_PRIMARY : 'Nexus') ?></h1>
<p>Kompakter Einstieg fuer die verfuegbaren Module.</p>
</div>
<div class="theme-switcher" aria-label="Farbschema">
<?php if ($auth->isEnabled()): ?>
<a class="auth-pill" href="<?= $authUser === null ? '/auth/login' : '/auth/logout' ?>">
<?= $authUser === null ? 'Login' : 'Logout ' . e((string)($authUser['username'] ?? $authUser['name'] ?? '')) ?>
</a>
<?php endif; ?>
<label>
<span>Modus</span>
<select data-theme-mode>
<option value="day">Day</option>
<option value="night">Night</option>
</select>
</label>
<label>
<span>Farbe</span>
<select data-theme-accent>
<option value="logo">Logo</option>
<option value="pink">Pink</option>
<option value="cyan">Cyan</option>
<option value="orange">Orange</option>
<option value="green">Gruen</option>
</select>
</label>
</div>
</section>
<div style="margin-top:1rem;">
<a class="nav-link" href="/modules">Module verwalten</a>
<section class="module-list-section" data-reveal>
<div class="section-head">
<div>
<h2 class="section-title">Verfuegbare Module</h2>
<p>Module mit Login-Pflicht erscheinen erst nach passender Anmeldung.</p>
</div>
<?php if ($authUser !== null): ?>
<a class="nav-link" href="/modules">Module verwalten</a>
<?php endif; ?>
</div>
<div style="margin-top:1.5rem;" class="grid">
<?php foreach ($modules as $module): ?>
<div class="card" style="background:var(--panel-2);">
<div style="display:flex; align-items:center; justify-content:space-between; gap:12px;">
<div>
<strong><?= e($module['title']) ?></strong>
<div class="muted" style="font-size:.85rem;"><?= e($module['description'] ?? '') ?></div>
</div>
<?php if (!empty($module['enabled'])): ?>
<span class="pill" style="border-color:var(--accent-2); color:var(--accent-2);">aktiv</span>
<?php else: ?>
<span class="pill">inaktiv</span>
<?php endif; ?>
</div>
<div style="margin-top:.75rem; display:flex; gap:10px; flex-wrap:wrap;">
<a class="nav-link" href="/module/<?= e($module['name']) ?>">Öffnen</a>
<a class="nav-link" href="/modules/setup/<?= e($module['name']) ?>">Setup</a>
</div>
</div>
<?php endforeach; ?>
</div>
</div>
<?php if ($modules === []): ?>
<div class="empty-state" data-reveal>
Keine Module fuer den aktuellen Zugriff sichtbar.
</div>
<?php else: ?>
<div class="module-list">
<?php foreach ($modules as $module): ?>
<a class="module-row" href="<?= e((string)($module['entry'] ?? ('/module/' . $module['name']))) ?>">
<span class="module-row__icon"><?= e(strtoupper(substr((string)($module['title'] ?? $module['name']), 0, 1))) ?></span>
<span class="module-row__content">
<span class="module-kicker"><?= e((string)($module['name'] ?? '')) ?></span>
<strong class="module-title"><?= e((string)($module['title'] ?? $module['name'] ?? 'Modul')) ?></strong>
<span class="module-desc"><?= e((string)($module['description'] ?? '')) ?></span>
</span>
<span class="module-row__action">Oeffnen</span>
</a>
<?php endforeach; ?>
</div>
<?php endif; ?>
</section>

View File

@@ -69,9 +69,16 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
}
modules()->saveSettings($moduleName, $payload);
modules()->saveAuth($moduleName, [
'required' => isset($_POST['auth_required']),
'users' => (string)($_POST['auth_users'] ?? ''),
'groups' => (string)($_POST['auth_groups'] ?? ''),
]);
$notice = 'Setup gespeichert.';
$current = array_replace_recursive($current, $payload);
$module = modules()->get($moduleName) ?: $module;
}
$authConfig = is_array($module['auth'] ?? null) ? $module['auth'] : ['required' => false, 'users' => [], 'groups' => []];
?>
<div class="card">
<div class="pill">Setup</div>
@@ -122,6 +129,23 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
</label>
<?php endforeach; ?>
<div class="card" style="padding:14px; background:var(--panel-2); display:grid; gap:12px;">
<strong>Modulzugriff</strong>
<label class="muted" style="display:flex; align-items:center; gap:10px;">
<input type="checkbox" name="auth_required" value="1" <?= !empty($authConfig['required']) ? 'checked' : '' ?>>
<span>Login fuer dieses Modul erforderlich</span>
</label>
<label class="muted" style="display:grid; gap:6px;">
<span>Erlaubte Benutzer</span>
<textarea name="auth_users" rows="3" placeholder="Keycloak-Sub, Benutzername oder E-Mail, je Zeile oder Komma"><?= e(implode("\n", is_array($authConfig['users'] ?? null) ? $authConfig['users'] : [])) ?></textarea>
</label>
<label class="muted" style="display:grid; gap:6px;">
<span>Erlaubte Gruppen</span>
<textarea name="auth_groups" rows="3" placeholder="/admin oder mining-users, je Zeile oder Komma"><?= e(implode("\n", is_array($authConfig['groups'] ?? null) ? $authConfig['groups'] : [])) ?></textarea>
</label>
<small class="muted">Wenn Login aktiv ist und Benutzer/Gruppen leer bleiben, darf jeder eingeloggte Benutzer das Modul oeffnen.</small>
</div>
<div style="display:flex; gap:10px;">
<button class="cta-button" type="submit">Speichern</button>
<a class="nav-link" href="/modules">Zurück</a>

View File

@@ -19,7 +19,7 @@ $sidebarDefault = ($moduleSidebar['default'] ?? 'collapsed') === 'open' ? 'open'
$sidebarItems = $moduleSidebar['items'] ?? [];
?>
<!doctype html>
<html lang="en">
<html lang="de" data-theme="<?= e($theme) ?>" data-accent="logo">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
@@ -27,7 +27,7 @@ $sidebarItems = $moduleSidebar['items'] ?? [];
<?php asset_styles(); ?>
<?php asset_scripts('header'); ?>
</head>
<body data-theme="<?= e($theme) ?>">
<body>
<div class="bg-orb orb-a"></div>
<div class="bg-orb orb-b"></div>
@@ -40,7 +40,7 @@ $sidebarItems = $moduleSidebar['items'] ?? [];
<div class="dropdown">
<button class="nav-link dropdown-toggle" type="button">Module ▾</button>
<div class="dropdown-menu">
<?php foreach (modules()->all() as $m): ?>
<?php foreach (app()->auth()->filterModules(modules()->all()) as $m): ?>
<?php if (!empty($m['enabled'])): ?>
<a class="dropdown-item" href="/module/<?= e($m['name']) ?>"><?= e($m['title']) ?></a>
<?php endif; ?>

View File

@@ -496,3 +496,386 @@ body {
.site-footer { margin: 0 12px 12px; }
.header-nav { flex-wrap: wrap; justify-content: flex-end; }
}
:root {
--surface: rgba(255, 255, 255, 0.9);
--surface-strong: #ffffff;
--accent-pink: #ed1671;
--accent-cyan: #06a9c8;
--accent-orange: #f6aa21;
--accent-green: #8bc53f;
--brand-accent: var(--accent-pink);
--brand-accent-2: var(--accent-cyan);
--brand-accent-3: var(--accent-orange);
}
:root[data-accent="pink"] {
--brand-accent: var(--accent-pink);
--brand-accent-2: var(--accent-orange);
--brand-accent-3: var(--accent-cyan);
}
:root[data-accent="cyan"] {
--brand-accent: var(--accent-cyan);
--brand-accent-2: var(--accent-green);
--brand-accent-3: var(--accent-pink);
}
:root[data-accent="orange"] {
--brand-accent: var(--accent-orange);
--brand-accent-2: var(--accent-pink);
--brand-accent-3: var(--accent-cyan);
}
:root[data-accent="green"] {
--brand-accent: var(--accent-green);
--brand-accent-2: var(--accent-cyan);
--brand-accent-3: var(--accent-orange);
}
:root[data-theme="day"] {
--bg: #f7fbfb;
--panel: rgba(255, 255, 255, 0.92);
--panel-2: #f1fbf7;
--surface: rgba(255, 255, 255, 0.9);
--surface-strong: #ffffff;
--text: #10212b;
--muted: #66737b;
--accent: var(--brand-accent);
--accent-2: var(--brand-accent-2);
--line: rgba(16, 33, 43, 0.12);
}
:root[data-theme="night"] {
--bg: #07121a;
--panel: rgba(8, 18, 28, 0.9);
--panel-2: rgba(18, 33, 48, 0.92);
--surface: rgba(8, 18, 28, 0.88);
--surface-strong: #101d2a;
--text: #eff8fb;
--muted: #a6b8c2;
--accent: var(--brand-accent);
--accent-2: var(--brand-accent-2);
--line: rgba(255, 255, 255, 0.12);
--shadow: 0 22px 60px rgba(0, 0, 0, 0.34);
}
html {
min-height: 100%;
background:
radial-gradient(circle at top left, color-mix(in srgb, var(--brand-accent-2) 20%, transparent), transparent 26%),
radial-gradient(circle at top right, color-mix(in srgb, var(--brand-accent) 18%, transparent), transparent 24%),
linear-gradient(135deg, #f7fbfb 0%, #eef7f5 52%, #fff4df 100%);
}
:root[data-theme="night"] {
background:
radial-gradient(circle at top left, color-mix(in srgb, var(--brand-accent-2) 28%, transparent), transparent 28%),
radial-gradient(circle at top right, color-mix(in srgb, var(--brand-accent) 24%, transparent), transparent 24%),
linear-gradient(135deg, #050b12 0%, #0c1721 52%, #111827 100%);
}
body {
background:
radial-gradient(circle at 12% 20%, color-mix(in srgb, var(--accent-green) 16%, transparent), transparent 24%),
radial-gradient(circle at 90% 6%, color-mix(in srgb, var(--accent-orange) 16%, transparent), transparent 20%),
var(--bg);
}
.site-header {
padding: 8px 14px;
}
.site-logo {
height: 46px;
}
.main-content {
width: min(80vw, 1680px);
margin: 0 auto;
background: transparent;
box-shadow: none;
padding: 0;
}
.main-content > .card,
.home-hero,
.module-row,
.empty-state,
.module-host-card {
border: 1px solid var(--line);
background: var(--surface);
box-shadow: 0 12px 30px rgba(1, 22, 32, 0.08);
backdrop-filter: blur(8px);
}
.home-hero {
position: relative;
overflow: hidden;
display: flex;
align-items: center;
gap: 18px;
margin-bottom: 16px;
padding: 16px 18px;
border-radius: 20px;
}
.brand-mark {
position: relative;
z-index: 1;
display: inline-grid;
place-items: center;
width: 76px;
height: 76px;
flex: 0 0 auto;
border-radius: 20px;
background: #ffffff;
box-shadow: inset 0 0 0 1px rgba(16, 33, 43, 0.08), 0 12px 30px rgba(6, 169, 200, 0.12);
}
.brand-mark img {
display: block;
width: 62px;
height: 62px;
object-fit: contain;
}
.brand-copy {
position: relative;
z-index: 1;
min-width: 0;
}
.eyebrow,
.module-kicker {
color: var(--brand-accent);
font-size: 0.74rem;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.eyebrow {
display: inline-flex;
margin-bottom: 6px;
padding: 4px 9px;
border-radius: 999px;
background: color-mix(in srgb, var(--brand-accent) 12%, transparent);
}
.home-hero h1,
.section-title {
margin: 0;
font-weight: 700;
letter-spacing: -0.03em;
}
.home-hero h1 {
font-size: clamp(1.5rem, 4vw, 2.35rem);
line-height: 1;
}
.home-hero p,
.section-head p,
.module-desc {
color: var(--muted);
}
.section-head {
display: flex;
align-items: end;
justify-content: space-between;
gap: 16px;
margin: 8px 0 12px;
}
.module-list {
display: grid;
gap: 10px;
}
.module-row {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 14px;
padding: 14px;
border-radius: 18px;
transition: transform 160ms ease, box-shadow 160ms ease, border-color 160ms ease, background 160ms ease;
}
.module-row:hover,
.module-row:focus-visible {
transform: translateY(-2px);
border-color: color-mix(in srgb, var(--brand-accent) 36%, transparent);
background: var(--surface-strong);
}
.module-row__icon {
display: inline-grid;
place-items: center;
width: 44px;
height: 44px;
border-radius: 14px;
color: #ffffff;
font-weight: 800;
background: linear-gradient(135deg, var(--brand-accent-2), var(--brand-accent)), var(--brand-accent);
}
.module-row__content {
display: grid;
gap: 3px;
min-width: 0;
}
.module-row__action,
.auth-pill {
display: inline-flex;
align-items: center;
padding: 9px 12px;
border-radius: 999px;
color: #ffffff;
font-size: 0.86rem;
font-weight: 800;
background: linear-gradient(135deg, var(--brand-accent), var(--brand-accent-3));
}
.module-row__action::after {
content: "->";
margin-left: 8px;
}
.theme-switcher {
position: relative;
z-index: 1;
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-left: auto;
}
.theme-switcher label {
display: grid;
gap: 4px;
color: var(--muted);
font-size: 0.68rem;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.theme-switcher select,
.card select {
background: var(--surface-strong);
color: var(--text);
}
.theme-switcher select {
min-width: 118px;
border: 1px solid var(--line);
border-radius: 999px;
padding: 8px 30px 8px 11px;
font: inherit;
font-size: 0.86rem;
letter-spacing: 0;
text-transform: none;
}
.empty-state {
padding: 28px;
border-radius: 18px;
color: var(--muted);
line-height: 1.7;
}
.module-host-card {
position: relative;
overflow: hidden;
border-radius: 18px;
}
.reveal {
opacity: 0;
transform: translateY(18px);
animation: rise 480ms ease forwards;
}
@keyframes rise {
to {
opacity: 1;
transform: translateY(0);
}
}
@media (max-width: 720px) {
.site-header {
align-items: flex-start;
flex-direction: column;
margin: 10px;
}
.header-nav {
width: 100%;
justify-content: flex-start;
}
.layout-body,
.module-subnav {
padding-left: 0;
padding-right: 0;
margin-left: 0;
margin-right: 0;
}
.main-content {
width: min(100% - 20px, 1680px);
}
.main-content:has(#mining-checker-app) {
width: 100%;
}
.module-host-card:has(#mining-checker-app) {
border-left: 0;
border-right: 0;
border-radius: 0;
}
.home-hero {
align-items: flex-start;
flex-wrap: wrap;
padding: 14px;
border-radius: 18px;
}
.theme-switcher {
width: 100%;
margin-left: 0;
}
.brand-mark {
width: 60px;
height: 60px;
border-radius: 16px;
}
.brand-mark img {
width: 49px;
height: 49px;
}
.module-row {
grid-template-columns: auto minmax(0, 1fr);
}
.module-row__action {
grid-column: 2;
justify-self: start;
padding: 7px 10px;
}
.section-head {
align-items: start;
flex-direction: column;
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

View File

@@ -28,6 +28,55 @@
});
})();
document.documentElement.classList.add('js');
function readThemePreference(key, fallback) {
try {
return localStorage.getItem(key) || fallback;
} catch (error) {
return fallback;
}
}
const themeMode = readThemePreference('nexus.theme', document.documentElement.dataset.theme || 'day');
const themeAccent = readThemePreference('nexus.accent', document.documentElement.dataset.accent || 'logo');
function applyTheme(mode, accent) {
const normalizedMode = ['day', 'night'].includes(mode) ? mode : 'day';
const normalizedAccent = ['logo', 'pink', 'cyan', 'orange', 'green'].includes(accent) ? accent : 'logo';
document.documentElement.dataset.theme = normalizedMode;
document.documentElement.dataset.accent = normalizedAccent;
try {
localStorage.setItem('nexus.theme', normalizedMode);
localStorage.setItem('nexus.accent', normalizedAccent);
} catch (error) {
// Ignore blocked storage; the current page still receives the theme.
}
}
applyTheme(themeMode, themeAccent);
const themeModeSelect = document.querySelector('[data-theme-mode]');
const themeAccentSelect = document.querySelector('[data-theme-accent]');
if (themeModeSelect) {
themeModeSelect.value = document.documentElement.dataset.theme;
themeModeSelect.addEventListener('change', () => {
applyTheme(themeModeSelect.value, document.documentElement.dataset.accent);
});
}
if (themeAccentSelect) {
themeAccentSelect.value = document.documentElement.dataset.accent;
themeAccentSelect.addEventListener('change', () => {
applyTheme(document.documentElement.dataset.theme, themeAccentSelect.value);
});
}
for (const element of document.querySelectorAll('[data-reveal]')) {
element.classList.add('reveal');
}
(() => {
const openBtn = document.querySelector('[data-debug-open]');
const modal = document.getElementById('debug-modal');

View File

@@ -1,6 +1,9 @@
<?php
declare(strict_types=1);
use Modules\MiningChecker\Support\ApiException as MiningApiException;
use Modules\MiningChecker\Support\DebugState as MiningDebugState;
// boot application (config, autoload, services)
require_once __DIR__ . '/../config/fileload.php';
@@ -8,6 +11,8 @@ require_once __DIR__ . '/../config/fileload.php';
$uriPath = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/';
$uriPath = preg_replace('~/{2,}~', '/', $uriPath);
$uriPath = trim($uriPath, '/');
$projectRoot = dirname(__DIR__);
$auth = app()->auth();
$isRetoolPath = ($uriPath === 'retool' || str_starts_with($uriPath, 'retool/'));
if (defined('APP_BASIC_AUTH') && APP_BASIC_AUTH && !$isRetoolPath) {
$authUser = getenv('STAGING_AUTH_USER') ?: 'staging';
@@ -27,9 +32,15 @@ $publicPaths = [
'auth/login',
'auth/callback',
'auth/logout',
'auth/keycloak/login',
'auth/keycloak/callback',
'auth/keycloak/logout',
'auth/me',
'module/pi_control/terminal_info',
];
if (defined('APP_AUTH_ENABLED') && APP_AUTH_ENABLED && !in_array($uriPath, $publicPaths, true)) {
$requiresGlobalAuth = in_array($uriPath, ['settings', 'users', 'modules', 'modules/install', 'debug'], true)
|| str_starts_with($uriPath, 'modules/setup/');
if (defined('APP_AUTH_ENABLED') && APP_AUTH_ENABLED && $requiresGlobalAuth && !in_array($uriPath, $publicPaths, true)) {
$user = auth_user();
if (!$user) {
header('Location: /auth/login', true, 302);
@@ -43,6 +54,139 @@ if (str_contains($uriPath, '..')) {
exit('Bad request');
}
if ($uriPath === 'auth/keycloak/login') {
$returnTo = (string)($_GET['return_to'] ?? '/');
$auth->login($returnTo);
}
if ($uriPath === 'auth/keycloak/callback') {
$uriPath = 'auth/callback';
}
if ($uriPath === 'auth/keycloak/logout') {
$uriPath = 'auth/logout';
}
if ($uriPath === 'auth/me') {
header('Content-Type: application/json; charset=utf-8');
echo json_encode([
'authenticated' => $auth->isAuthenticated(),
'user' => $auth->user(),
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
}
if (preg_match('~^api/module-auth/([a-zA-Z0-9_-]+)$~', $uriPath, $moduleAuthMatches)) {
$moduleName = $moduleAuthMatches[1];
$moduleMeta = app()->modules()->get($moduleName);
if ($moduleMeta === null) {
http_response_code(404);
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['error' => 'module_not_found'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
}
if (!$auth->isAuthenticated()) {
http_response_code(401);
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['error' => 'auth_required'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
}
if (!$auth->canAccessModule($moduleMeta)) {
http_response_code(403);
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['error' => 'forbidden'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
}
header('Content-Type: application/json; charset=utf-8');
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
echo json_encode(['data' => ($moduleMeta['auth'] ?? ['required' => false, 'users' => [], 'groups' => []])], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
}
if ($_SERVER['REQUEST_METHOD'] === 'PUT') {
$input = json_decode((string)file_get_contents('php://input'), true);
if (!is_array($input)) {
$input = [];
}
echo json_encode(['data' => app()->modules()->saveAuth($moduleName, $input)], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
}
http_response_code(405);
echo json_encode(['error' => 'method_not_allowed'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
}
if (preg_match('~^api/mining-checker(?:/(.*))?$~', $uriPath, $apiMatches)) {
$moduleMeta = app()->modules()->get('mining-checker') ?? ['auth' => ['required' => false]];
if (!$auth->canAccessModule($moduleMeta)) {
http_response_code($auth->isAuthenticated() ? 403 : 401);
header('Content-Type: application/json; charset=utf-8');
echo json_encode([
'error' => $auth->isAuthenticated() ? 'forbidden' : 'auth_required',
'login_url' => '/auth/login',
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
}
require_once $projectRoot . '/modules/mining-checker/bootstrap.php';
try {
(new Modules\MiningChecker\Api\Router($projectRoot . '/modules/mining-checker'))->handle($apiMatches[1] ?? '');
} catch (MiningApiException $exception) {
$debugTrace = MiningDebugState::export();
http_response_code($exception->statusCode());
header('Content-Type: application/json; charset=utf-8');
echo json_encode([
'error' => $exception->getMessage(),
'context' => $exception->context(),
'debug' => $debugTrace !== [] ? $debugTrace : null,
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
} catch (Throwable $exception) {
$debugTrace = MiningDebugState::export();
http_response_code(500);
header('Content-Type: application/json; charset=utf-8');
echo json_encode([
'error' => 'Unerwarteter Mining-Checker Fehler.',
'context' => ['message' => $exception->getMessage()],
'debug' => $debugTrace !== [] ? $debugTrace : null,
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
}
}
if (preg_match('~^module-assets/([a-zA-Z0-9_-]+)/(.*)$~', $uriPath, $assetMatches)) {
$module = $assetMatches[1];
$relativeAssetPath = trim($assetMatches[2], '/');
if ($relativeAssetPath === '' || str_contains($relativeAssetPath, '..')) {
http_response_code(400);
exit('Bad request');
}
$assetFile = $projectRoot . '/modules/' . $module . '/assets/' . $relativeAssetPath;
if (!is_file($assetFile)) {
http_response_code(404);
exit('Asset not found');
}
$extension = strtolower(pathinfo($assetFile, PATHINFO_EXTENSION));
$contentType = match ($extension) {
'css' => 'text/css; charset=utf-8',
'js' => 'application/javascript; charset=utf-8',
'json' => 'application/json; charset=utf-8',
'png' => 'image/png',
'jpg', 'jpeg' => 'image/jpeg',
'webp' => 'image/webp',
'svg' => 'image/svg+xml',
default => 'application/octet-stream',
};
header('Content-Type: ' . $contentType);
readfile($assetFile);
exit;
}
// Basispfad fuer Landingpages
$pagesBase = realpath(__DIR__ . '/../partials/landingpages') ?: (__DIR__ . '/../partials/landingpages');
$page404 = $pagesBase . '/errorpages/404.php';
@@ -68,7 +212,15 @@ if (str_starts_with($uriPath, 'modules/install')) {
} elseif (preg_match('~^module/([a-zA-Z0-9_-]+)(?:/(.+))?$~', $uriPath, $m)) {
$module = $m[1];
$page = isset($m[2]) && $m[2] !== '' ? trim($m[2], '/') : 'index';
$moduleMeta = app()->modules()->get($module);
if ($moduleMeta !== null) {
$auth->requireModuleAccess($moduleMeta);
}
$modulePage = app()->modules()->resolvePage($module, $page);
$moduleBootstrap = $projectRoot . '/modules/' . $module . '/bootstrap.php';
if (is_file($moduleBootstrap)) {
require_once $moduleBootstrap;
}
if ($modulePage) {
$target = $modulePage;
} else {

View File

@@ -15,6 +15,7 @@ final class App
private ?\PDO $pdo;
private ?\PDO $basePdo;
private ModuleManager $modules;
private AuthService $auth;
private function __construct(private Config $config)
{
@@ -41,6 +42,7 @@ final class App
}
$this->modules = new ModuleManager($this->basePdo, $modulesPath);
$this->modules->bootEnabled();
$this->auth = new AuthService($this);
}
public static function init(Config $config): self
@@ -68,4 +70,5 @@ final class App
public function pdo(): ?\PDO { return $this->pdo; }
public function basePdo(): ?\PDO { return $this->basePdo; }
public function modules(): ModuleManager { return $this->modules; }
public function auth(): AuthService { return $this->auth; }
}

172
src/App/AuthService.php Normal file
View File

@@ -0,0 +1,172 @@
<?php
declare(strict_types=1);
namespace App;
final class AuthService
{
private const SESSION_TTL = 604800;
public function __construct(private App $app) {}
public function isEnabled(): bool
{
return $this->app->config()->authEnabled;
}
public function start(): void
{
$this->app->session()->start();
}
public function user(): ?array
{
$this->start();
$user = $_SESSION['auth_user'] ?? null;
if (!is_array($user)) {
return null;
}
$expiresAt = (int) ($_SESSION['auth_expires_at'] ?? 0);
if ($expiresAt > 0 && $expiresAt < time()) {
$this->clearLocalSession();
return null;
}
$_SESSION['auth_expires_at'] = time() + self::SESSION_TTL;
return $user;
}
public function isAuthenticated(): bool
{
return $this->user() !== null;
}
public function login(string $returnTo = '/'): void
{
if (!$this->isEnabled()) {
redirect($returnTo !== '' ? $returnTo : '/');
}
$this->start();
$_SESSION['oidc_return_to'] = $this->safeReturnTo($returnTo);
redirect('/auth/login');
}
public function callback(): void
{
$query = (string) ($_SERVER['QUERY_STRING'] ?? '');
redirect('/auth/callback' . ($query !== '' ? '?' . $query : ''));
}
public function logout(): void
{
redirect('/auth/logout');
}
public function storeUser(array $claims, array $groups, string $idToken): void
{
$username = (string) ($claims['preferred_username'] ?? $claims['email'] ?? $claims['sub'] ?? '');
$_SESSION['auth_user'] = [
'sub' => (string) ($claims['sub'] ?? ''),
'username' => $username,
'email' => (string) ($claims['email'] ?? ''),
'name' => (string) ($claims['name'] ?? $username),
'groups' => $groups,
'id_token' => $idToken,
];
$_SESSION['auth_id_token'] = $idToken;
$_SESSION['auth_expires_at'] = time() + self::SESSION_TTL;
}
public function canAccessModule(array $module): bool
{
$auth = is_array($module['auth'] ?? null) ? $module['auth'] : [];
$required = (bool) ($auth['required'] ?? false);
if (!$required || !$this->isEnabled()) {
return true;
}
$user = $this->user();
if ($user === null) {
return false;
}
$allowedUsers = $this->normalizeList($auth['users'] ?? []);
$allowedGroups = $this->normalizeList($auth['groups'] ?? []);
if ($allowedUsers === [] && $allowedGroups === []) {
return true;
}
$username = strtolower((string) ($user['username'] ?? ''));
$email = strtolower((string) ($user['email'] ?? ''));
$sub = strtolower((string) ($user['sub'] ?? ''));
foreach ($allowedUsers as $allowedUser) {
if ($allowedUser === $username || ($email !== '' && $allowedUser === $email) || ($sub !== '' && $allowedUser === $sub)) {
return true;
}
}
$userGroups = $this->normalizeList($user['groups'] ?? []);
return array_intersect($allowedGroups, $userGroups) !== [];
}
public function requireModuleAccess(array $module): void
{
if ($this->canAccessModule($module)) {
return;
}
if (!$this->isAuthenticated()) {
$this->login($this->currentPath());
}
http_response_code(403);
exit('Forbidden');
}
public function filterModules(array $modules): array
{
return array_values(array_filter($modules, fn (array $module): bool => $this->canAccessModule($module)));
}
private function normalizeList(mixed $values): array
{
if (is_string($values)) {
$values = preg_split('/[,\\n]+/', $values) ?: [];
}
if (!is_array($values)) {
$values = [$values];
}
$normalized = [];
foreach ($values as $value) {
$item = strtolower(trim((string) $value));
if ($item !== '') {
$normalized[] = $item;
}
}
return array_values(array_unique($normalized));
}
private function clearLocalSession(): void
{
unset($_SESSION['auth_user'], $_SESSION['auth_id_token'], $_SESSION['auth_expires_at']);
}
private function currentPath(): string
{
$uri = (string) ($_SERVER['REQUEST_URI'] ?? '/');
return $this->safeReturnTo($uri);
}
private function safeReturnTo(string $returnTo): string
{
$returnTo = trim($returnTo);
if ($returnTo === '' || !str_starts_with($returnTo, '/') || str_starts_with($returnTo, '//')) {
return '/';
}
return $returnTo;
}
}

View File

@@ -26,6 +26,7 @@ class Config
public string $oidcAdminGroup;
public string $oidcUserGroup;
public string $modulesPath;
public array $dbConfig;
public function __construct(
public array $db,
@@ -40,17 +41,18 @@ class Config
$this->keaDbVersion = defined('APP_KEA_DB_VERSION') ? (string)APP_KEA_DB_VERSION : '';
$this->baseDb = $baseDb;
$this->baseDbEnabled = $baseDbEnabled;
$this->authEnabled = defined('APP_AUTH_ENABLED') ? (bool)APP_AUTH_ENABLED : false;
$this->oidcIssuer = defined('APP_OIDC_ISSUER') ? (string)APP_OIDC_ISSUER : '';
$this->oidcClientId = defined('APP_OIDC_CLIENT_ID') ? (string)APP_OIDC_CLIENT_ID : '';
$this->oidcClientSecret = defined('APP_OIDC_CLIENT_SECRET') ? (string)APP_OIDC_CLIENT_SECRET : '';
$this->oidcRedirectUri = defined('APP_OIDC_REDIRECT_URI') ? (string)APP_OIDC_REDIRECT_URI : '';
$this->oidcAuthEndpoint = defined('APP_OIDC_AUTH_ENDPOINT') ? (string)APP_OIDC_AUTH_ENDPOINT : '';
$this->oidcTokenEndpoint = defined('APP_OIDC_TOKEN_ENDPOINT') ? (string)APP_OIDC_TOKEN_ENDPOINT : '';
$this->oidcUserinfoEndpoint = defined('APP_OIDC_USERINFO_ENDPOINT') ? (string)APP_OIDC_USERINFO_ENDPOINT : '';
$this->oidcLogoutEndpoint = defined('APP_OIDC_LOGOUT_ENDPOINT') ? (string)APP_OIDC_LOGOUT_ENDPOINT : '';
$this->oidcPostLogoutRedirectUri = defined('APP_OIDC_POST_LOGOUT_REDIRECT_URI') ? (string)APP_OIDC_POST_LOGOUT_REDIRECT_URI : '';
$this->oidcGroupClaim = defined('APP_OIDC_GROUP_CLAIM') ? (string)APP_OIDC_GROUP_CLAIM : 'groups';
$this->dbConfig = $baseDbEnabled && !empty($baseDb) ? $baseDb : $db;
$this->authEnabled = defined('APP_AUTH_ENABLED') ? (bool)APP_AUTH_ENABLED : (defined('KEYCLOAK_ENABLED') ? (bool)KEYCLOAK_ENABLED : false);
$this->oidcIssuer = defined('APP_OIDC_ISSUER') ? (string)APP_OIDC_ISSUER : (defined('KEYCLOAK_ISSUER') ? (string)KEYCLOAK_ISSUER : '');
$this->oidcClientId = defined('APP_OIDC_CLIENT_ID') ? (string)APP_OIDC_CLIENT_ID : (defined('KEYCLOAK_CLIENT_ID') ? (string)KEYCLOAK_CLIENT_ID : '');
$this->oidcClientSecret = defined('APP_OIDC_CLIENT_SECRET') ? (string)APP_OIDC_CLIENT_SECRET : (defined('KEYCLOAK_CLIENT_SECRET') ? (string)KEYCLOAK_CLIENT_SECRET : '');
$this->oidcRedirectUri = defined('APP_OIDC_REDIRECT_URI') ? (string)APP_OIDC_REDIRECT_URI : (defined('KEYCLOAK_REDIRECT_URI') ? (string)KEYCLOAK_REDIRECT_URI : '');
$this->oidcAuthEndpoint = defined('APP_OIDC_AUTH_ENDPOINT') ? (string)APP_OIDC_AUTH_ENDPOINT : (defined('KEYCLOAK_AUTH_ENDPOINT') ? (string)KEYCLOAK_AUTH_ENDPOINT : '');
$this->oidcTokenEndpoint = defined('APP_OIDC_TOKEN_ENDPOINT') ? (string)APP_OIDC_TOKEN_ENDPOINT : (defined('KEYCLOAK_TOKEN_ENDPOINT') ? (string)KEYCLOAK_TOKEN_ENDPOINT : '');
$this->oidcUserinfoEndpoint = defined('APP_OIDC_USERINFO_ENDPOINT') ? (string)APP_OIDC_USERINFO_ENDPOINT : (defined('KEYCLOAK_USERINFO_ENDPOINT') ? (string)KEYCLOAK_USERINFO_ENDPOINT : '');
$this->oidcLogoutEndpoint = defined('APP_OIDC_LOGOUT_ENDPOINT') ? (string)APP_OIDC_LOGOUT_ENDPOINT : (defined('KEYCLOAK_LOGOUT_ENDPOINT') ? (string)KEYCLOAK_LOGOUT_ENDPOINT : '');
$this->oidcPostLogoutRedirectUri = defined('APP_OIDC_POST_LOGOUT_REDIRECT_URI') ? (string)APP_OIDC_POST_LOGOUT_REDIRECT_URI : (defined('KEYCLOAK_POST_LOGOUT_REDIRECT_URI') ? (string)KEYCLOAK_POST_LOGOUT_REDIRECT_URI : '');
$this->oidcGroupClaim = defined('APP_OIDC_GROUP_CLAIM') ? (string)APP_OIDC_GROUP_CLAIM : (defined('KEYCLOAK_GROUP_CLAIM') ? (string)KEYCLOAK_GROUP_CLAIM : 'groups');
$this->oidcAdminGroup = defined('APP_OIDC_ADMIN_GROUP') ? (string)APP_OIDC_ADMIN_GROUP : 'admin';
$this->oidcUserGroup = defined('APP_OIDC_USER_GROUP') ? (string)APP_OIDC_USER_GROUP : 'user';
$this->modulesPath = defined('APP_MODULES_PATH') ? (string)APP_MODULES_PATH : '';

View File

@@ -245,7 +245,8 @@ final class ModuleManager
$module = [
'name' => $name,
'title' => $data['title'] ?? $name,
'slug' => $name,
'title' => $data['title'] ?? $data['name'] ?? $name,
'version' => $data['version'] ?? '',
'description' => $data['description'] ?? '',
'setup' => $data['setup'] ?? [],
@@ -253,6 +254,9 @@ final class ModuleManager
'sidebar' => $data['sidebar'] ?? [],
'db_defaults' => $data['db_defaults'] ?? [],
'path' => $dir,
'entry' => '/module/' . rawurlencode($name),
'auth' => is_array($data['auth'] ?? null) ? $data['auth'] : ['required' => false, 'users' => [], 'groups' => []],
'enabled_by_default' => (bool)($data['enabled_by_default'] ?? false),
'enabled' => false,
];
@@ -268,7 +272,7 @@ final class ModuleManager
private function loadEnabledState(string $name, array $module): bool
{
if (!$this->basePdo) {
return false;
return (bool)($module['enabled_by_default'] ?? false);
}
$stmt = $this->basePdo->prepare(
@@ -287,8 +291,64 @@ final class ModuleManager
$stmt->bindValue(':name', $name, \PDO::PARAM_STR);
$stmt->bindValue(':title', (string)$module['title'], \PDO::PARAM_STR);
$stmt->bindValue(':version', (string)$module['version'], \PDO::PARAM_STR);
$stmt->bindValue(':enabled', false, \PDO::PARAM_BOOL);
$enabledByDefault = (bool)($module['enabled_by_default'] ?? false);
$stmt->bindValue(':enabled', $enabledByDefault, \PDO::PARAM_BOOL);
$stmt->execute();
return false;
return $enabledByDefault;
}
public function saveAuth(string $name, array $auth): array
{
if (!preg_match('/^[a-zA-Z0-9_\-]+$/', $name)) {
throw new \RuntimeException('Invalid module name.');
}
$module = $this->get($name);
if (!$module) {
throw new \RuntimeException('Module not found.');
}
$manifest = $module['path'] . '/module.json';
$raw = is_file($manifest) ? file_get_contents($manifest) : '';
$data = $raw ? json_decode($raw, true) : [];
if (!is_array($data)) {
$data = [];
}
$data['auth'] = [
'required' => (bool) ($auth['required'] ?? false),
'users' => $this->normalizeList($auth['users'] ?? []),
'groups' => $this->normalizeList($auth['groups'] ?? []),
];
$json = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
if (!is_string($json)) {
throw new \RuntimeException('Could not encode module metadata.');
}
file_put_contents($manifest, $json . PHP_EOL, LOCK_EX);
$this->scanModules();
return $data['auth'];
}
private function normalizeList(mixed $values): array
{
if (is_string($values)) {
$values = preg_split('/[,\\n]+/', $values) ?: [];
}
if (!is_array($values)) {
$values = [$values];
}
$normalized = [];
foreach ($values as $value) {
$item = trim((string) $value);
if ($item !== '') {
$normalized[] = $item;
}
}
return array_values(array_unique($normalized));
}
}

View File

@@ -12,7 +12,7 @@ final class OidcClient
$params = [
'client_id' => $this->config->oidcClientId,
'response_type' => 'code',
'scope' => 'openid profile email',
'scope' => 'openid profile email groups',
'redirect_uri' => $this->config->oidcRedirectUri,
'state' => $state,
'nonce' => $nonce,

View File

@@ -5,6 +5,8 @@ namespace App;
final class SessionManager
{
private const SESSION_TTL = 604800;
private string $sessionCookieName;
private string $clientCookieName;
@@ -30,15 +32,16 @@ final class SessionManager
|| (isset($_SERVER['HTTP_X_FORWARDED_PROTO']) && $_SERVER['HTTP_X_FORWARDED_PROTO'] === 'https');
session_set_cookie_params([
'lifetime' => 0,
'lifetime' => self::SESSION_TTL,
'path' => '/',
'domain' => $this->config->cookieDomain(),
'domain' => (string)($this->config->cookieDomain() ?? ''),
'secure' => $secure,
'httponly' => true,
'samesite' => 'Lax',
]);
session_start();
$this->extendSessionCookie($secure);
}
public function ensureClientId(int $lifetimeSeconds = 31536000): string
@@ -57,7 +60,7 @@ final class SessionManager
setcookie($this->clientCookieName, $id, [
'expires' => time() + $lifetimeSeconds,
'path' => '/',
'domain' => $this->config->cookieDomain(),
'domain' => (string)($this->config->cookieDomain() ?? ''),
'secure' => $secure,
'httponly' => false, // accessible to JS if needed
'samesite' => 'Lax',
@@ -68,4 +71,22 @@ final class SessionManager
return $id;
}
private function extendSessionCookie(bool $secure): void
{
$name = session_name();
$value = session_id();
if ($name === '' || $value === '') {
return;
}
setcookie($name, $value, [
'expires' => time() + self::SESSION_TTL,
'path' => '/',
'domain' => (string)($this->config->cookieDomain() ?? ''),
'secure' => $secure,
'httponly' => true,
'samesite' => 'Lax',
]);
}
}

View File

@@ -32,7 +32,11 @@ function user_theme(): string
$stmt->execute(['id' => $clientId]);
$row = $stmt->fetch(\PDO::FETCH_ASSOC);
$theme = is_array($row) ? (string)($row['theme'] ?? '') : '';
return $theme !== '' ? $theme : 'light';
return match ($theme) {
'dark', 'night' => 'night',
'light', 'day', '' => 'day',
default => $theme,
};
}
function set_user_theme(string $theme): void
@@ -64,14 +68,12 @@ function current_module_name(): ?string
function auth_enabled(): bool
{
return app()->config()->authEnabled;
return app()->auth()->isEnabled();
}
function auth_user(): ?array
{
$session = app()->session();
$session->start();
return $_SESSION['auth_user'] ?? null;
return app()->auth()->user();
}
function auth_display_name(): string