feat(setup): Erstinstallation ueber den Update-Agent, Installationskonto, Downloads
Bisher gab es nur den Update-Weg: eine Anwendung musste bereits installiert und eingerichtet sein, damit sich etwas aktualisieren liess. Die Erstinstallation auf einem neuen System war Handarbeit - Paket kopieren, Konfiguration abtippen, Token besorgen. Setup-API (neu) - POST /api/setup/v1/login tauscht Benutzername und Passwort gegen ein Token mit 30 Minuten Gueltigkeit und ausschliesslich setup:install. Es wird nicht mitgeschrieben und lebt im Installer nur im Speicher. - GET /api/setup/v1/catalog zeigt nur, was zur Laufzeitkennung des anfragenden Systems passt. Ein Projekt mit ausschliesslich Windows-Paket taucht auf einem Linux-Rechner gar nicht erst auf. - POST /api/setup/v1/token stellt das Dauertoken der Anwendung aus. Welche Rechte vergeben werden, entscheidet der Server; die Anfrage kann nur einschraenken. Sonst waere der Umweg ueber ein kurzlebiges Token wirkungslos. Rollentrennung (Migration 012) - dc_users bekommt role, disabled und last_login_at. Die Rolle "installer" darf sich ueber den Setup-Weg anmelden und nicht am WebUI. Die Zugangsdaten werden auf jedem Zielsystem eingetippt; mit einem Administratorkonto verteilte man damit den Zugang zu Tokens, Lizenzen und Monitoren auf jeden Rechner, auf dem je etwas installiert wurde. - Auth::verifyCredentials() prueft sessionfrei, damit Setup- und WebUI-Login nicht zwei verschiedene Haertungsgrade haben (Drosselung, Timing-Angleichung, Rehash gelten fuer beide). - Konten mit hinterlegtem TOTP-Geheimnis werden am Setup-Weg mit 501 abgewiesen. Eine TOTP-Pruefung gibt es im Deploymentcenter noch nicht; sie stillschweigend zu uebergehen waere ein Rueckschritt. - Benutzerverwaltung im WebUI - es gab bisher gar keine, nur den einen von install_db.php angelegten Admin. Das letzte aktive Administratorkonto laesst sich weder deaktivieren noch loeschen. Installer - update-agent --action install fuehrt durch Anmeldung, Auswahl, Zielverzeichnis, Installation und Einrichtung. Die Dateien kommen ueber denselben Pfad wie ein Update - mit Pruefsumme, Signatur, Staging und Rollback. Ein zweiter Download-Weg waere ein zweiter Ort fuer dieselben Fehler. - --action configure holt die Einrichtung nachtraeglich. - setup.json im Paket beschreibt die benoetigten Werte. Bewusst im Paket und nicht zentral: so ist sie mit der Anwendung versioniert. - Gefragt wird nur, was uebrig bleibt: bereits gesetzt -> detect:... -> provision -> fragen. Platzhalter wie changeme oder <dein-wert> gelten dabei nicht als eingerichtet, sonst liefe die Anwendung mit der Vorlage los. - SetupWriter erhaelt vorhandene Inhalte. Eine appsettings.json fuehrt neben den abgefragten Werten meist Logging und anderes; sie neu zu erzeugen waere bequemer und verloere das - bei einer Neuinstallation ohne Backup. int und bool landen als JSON-Typ, nicht als Zeichenkette. Downloads - scripts/build_installer.ps1 baut selbstenthaltende Einzeldateien fuer win-x64, linux-x64 und linux-arm64 (rund 34 MB, .NET-Laufzeit inbegriffen). Ohne NativeAOT und ohne Trimming: Spectre.Console loest ueber Reflexion auf und braeche sonst erst beim Anwender. - scripts/upload_installer.py laedt sie nach /installer/. Getrennt von deploy.py, das client-dotnet bewusst ausklammert. - Bereich "Installer" auf der UpdateService-Seite mit Groessen, Pruefsummen und den wget-Befehlen; die Angaben stammen aus installer.json statt aus fest eingetragenem Text. - install.sh und install.ps1 laden, pruefen die Pruefsumme und legen ab - sie richten bewusst nichts selbst ein. Das Manifest wird BOM-frei geschrieben, sonst scheitert json_decode() daran. Enthaelt ausserdem die bislang nicht committete Arbeit an den RocketChat-Benachrichtigungen (Migrationen 010 und 011) sowie die Loesch- und Editierfunktion des UpdateService; die betroffenen Dateien liessen sich nicht getrennt stagen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
2388b5abe1
commit
c8f3e78635
@@ -515,6 +515,154 @@ $spec = [
|
||||
],
|
||||
],
|
||||
|
||||
'/api/setup/v1/login' => [
|
||||
'post' => [
|
||||
'tags' => ['Setup'],
|
||||
'summary' => 'Anmelden und ein kurzlebiges Setup-Token holen',
|
||||
'description' =>
|
||||
"Ohne Token erreichbar - hier entsteht das Token erst.\n\n"
|
||||
. "Gedacht fuer Konten der Rolle `installer`, die sich ihrerseits **nicht** am "
|
||||
. "WebUI anmelden koennen. Die Zugangsdaten werden auf jedem Zielsystem "
|
||||
. "eingegeben; mit einem Administratorkonto verteilte man damit den Zugang zur "
|
||||
. "gesamten Verwaltung.\n\n"
|
||||
. "Das Token laeuft nach 30 Minuten ab, traegt nur `setup:install` und wird "
|
||||
. "nicht mitgeschrieben - es ist nach der Ausgabe nirgends mehr abrufbar.\n\n"
|
||||
. "Konten mit hinterlegtem TOTP-Geheimnis werden mit 501 abgewiesen: eine "
|
||||
. "TOTP-Pruefung gibt es noch nicht, und sie stillschweigend zu uebergehen "
|
||||
. "waere ein Rueckschritt.",
|
||||
'security' => [],
|
||||
'requestBody' => [
|
||||
'required' => true,
|
||||
'content' => ['application/json' => ['schema' => [
|
||||
'type' => 'object',
|
||||
'required' => ['username', 'password'],
|
||||
'properties' => [
|
||||
'username' => ['type' => 'string'],
|
||||
'password' => ['type' => 'string', 'format' => 'password'],
|
||||
'hostname' => ['type' => 'string', 'description' => 'Rechnername, erscheint im Token-Namen und im Protokoll.'],
|
||||
],
|
||||
]]],
|
||||
],
|
||||
'responses' => [
|
||||
'201' => [
|
||||
'description' => 'Angemeldet',
|
||||
'content' => ['application/json' => ['schema' => [
|
||||
'type' => 'object',
|
||||
'properties' => [
|
||||
'setup_token' => ['type' => 'string'],
|
||||
'expires_at' => ['type' => 'string', 'format' => 'date-time'],
|
||||
'expires_in' => ['type' => 'integer', 'example' => 1800],
|
||||
'scopes' => ['type' => 'array', 'items' => ['type' => 'string']],
|
||||
'role' => ['type' => 'string', 'enum' => ['admin', 'installer']],
|
||||
],
|
||||
]]],
|
||||
],
|
||||
'401' => $errorResponse,
|
||||
'429' => $errorResponse,
|
||||
'501' => $errorResponse,
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
'/api/setup/v1/catalog' => [
|
||||
'get' => [
|
||||
'tags' => ['Setup'],
|
||||
'summary' => 'Installierbare Anwendungen fuer dieses System',
|
||||
'description' =>
|
||||
"Scope `setup:catalog`. Zeigt ausschliesslich, was zur angefragten "
|
||||
. "Laufzeitkennung passt - ein Projekt, von dem es nur ein Windows-Paket gibt, "
|
||||
. "taucht auf einem Linux-Rechner gar nicht erst auf.\n\n"
|
||||
. "Eine WebUI-Session wird hier bewusst **nicht** akzeptiert.",
|
||||
'parameters' => [
|
||||
['name' => 'platform', 'in' => 'query', 'schema' => ['type' => 'string', 'example' => 'win-x64'],
|
||||
'description' => 'Laufzeitkennung des Zielsystems. Ohne Angabe gelten nur Releases mit platform=any.'],
|
||||
],
|
||||
'responses' => [
|
||||
'200' => [
|
||||
'description' => 'Katalog',
|
||||
'content' => ['application/json' => ['schema' => [
|
||||
'type' => 'object',
|
||||
'properties' => [
|
||||
'platform' => ['type' => 'string'],
|
||||
'count' => ['type' => 'integer'],
|
||||
'projects' => [
|
||||
'type' => 'array',
|
||||
'items' => [
|
||||
'type' => 'object',
|
||||
'properties' => [
|
||||
'slug' => ['type' => 'string'],
|
||||
'name' => ['type' => 'string'],
|
||||
'notes' => ['type' => 'string', 'nullable' => true],
|
||||
'channels' => [
|
||||
'type' => 'array',
|
||||
'items' => [
|
||||
'type' => 'object',
|
||||
'properties' => [
|
||||
'channel' => ['type' => 'string'],
|
||||
'version' => ['type' => 'string'],
|
||||
'platform' => ['type' => 'string'],
|
||||
'size_bytes' => ['type' => 'integer'],
|
||||
'is_critical' => ['type' => 'boolean'],
|
||||
'signed' => ['type' => 'boolean'],
|
||||
'download_url' => ['type' => 'string'],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
]]],
|
||||
],
|
||||
'401' => $errorResponse,
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
'/api/setup/v1/token' => [
|
||||
'post' => [
|
||||
'tags' => ['Setup'],
|
||||
'summary' => 'Dauertoken fuer die eingerichtete Anwendung',
|
||||
'description' =>
|
||||
"Scope `setup:install`. Stellt das Token aus, mit dem die frisch installierte "
|
||||
. "Anwendung spaeter Heartbeats sendet und Fehler meldet.\n\n"
|
||||
. "**Welche Rechte vergeben werden, entscheidet der Server.** Die angefragten "
|
||||
. "`scopes` koennen nur einschraenken; erlaubt sind ausschliesslich "
|
||||
. "`watchdog:ping`, `watchdog:read`, `bugtracker:report` und "
|
||||
. "`updateservice:read`. Andernfalls waere der Umweg ueber ein kurzlebiges "
|
||||
. "Setup-Token wirkungslos - wer einmal installieren darf, stellte sich sonst "
|
||||
. "ein Token mit allen Rechten aus und behielte es.",
|
||||
'requestBody' => [
|
||||
'required' => true,
|
||||
'content' => ['application/json' => ['schema' => [
|
||||
'type' => 'object',
|
||||
'required' => ['project'],
|
||||
'properties' => [
|
||||
'project' => ['type' => 'string'],
|
||||
'scopes' => ['type' => 'array', 'items' => ['type' => 'string']],
|
||||
'hostname' => ['type' => 'string'],
|
||||
],
|
||||
]]],
|
||||
],
|
||||
'responses' => [
|
||||
'201' => [
|
||||
'description' => 'Token ausgestellt - wird nur einmal ausgeliefert',
|
||||
'content' => ['application/json' => ['schema' => [
|
||||
'type' => 'object',
|
||||
'properties' => [
|
||||
'token' => ['type' => 'string'],
|
||||
'project' => ['type' => 'string'],
|
||||
'scopes' => ['type' => 'array', 'items' => ['type' => 'string']],
|
||||
],
|
||||
]]],
|
||||
],
|
||||
'401' => $errorResponse,
|
||||
'403' => $errorResponse,
|
||||
'404' => $errorResponse,
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
'/api/watchdog/v1/ping' => [
|
||||
'post' => [
|
||||
'tags' => ['Watchdog'],
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Setup API - Erstinstallation von Anwendungen
|
||||
*
|
||||
* POST /api/setup/v1/login Benutzername + Passwort -> kurzlebiges Token
|
||||
* GET /api/setup/v1/catalog Was ist hier installierbar?
|
||||
* POST /api/setup/v1/token Dauertoken fuer die eingerichtete Anwendung
|
||||
*
|
||||
* Warum ein eigener Anmeldeweg statt eines Dauertokens: Ein Token muesste
|
||||
* vorher jemand erzeugen und auf das Zielsystem bringen - genau die Huerde,
|
||||
* die eine Erstinstallation umstaendlich macht. Zugangsdaten hat der
|
||||
* Einrichtende ohnehin.
|
||||
*
|
||||
* Das ausgestellte Token laeuft nach 30 Minuten ab und traegt nur
|
||||
* setup:install. Es liegt auf einem fremden Rechner; alles Langlebige waere
|
||||
* dort falsch aufgehoben. Was nach der Installation zurueckbleibt, ist das
|
||||
* Sub-Token der Anwendung selbst mit genau den Rechten, die sie braucht.
|
||||
*
|
||||
* ROLLENTRENNUNG: Anmelden darf sich hier jedes Konto, aber gedacht ist der
|
||||
* Weg fuer Konten der Rolle "installer" - die sich ihrerseits nicht am WebUI
|
||||
* anmelden koennen. Wer auf jedem Zielsystem sein Admin-Passwort eingibt,
|
||||
* verteilt den Zugang zur gesamten Verwaltung.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../../../../src/bootstrap.php';
|
||||
|
||||
use Deploymentcenter\Core\ApiAuth;
|
||||
use Deploymentcenter\Core\Auth;
|
||||
use Deploymentcenter\Core\Db;
|
||||
use Deploymentcenter\Core\Http;
|
||||
use Deploymentcenter\Core\Logger;
|
||||
use Deploymentcenter\Core\TokenManager;
|
||||
use Deploymentcenter\Modules\License\RateLimiter;
|
||||
use Deploymentcenter\Modules\Setup\SetupCatalog;
|
||||
use Deploymentcenter\Modules\UpdateService\UpdateManager;
|
||||
|
||||
Http::beginJson(['GET', 'POST', 'OPTIONS'], true);
|
||||
|
||||
$db = Db::init();
|
||||
|
||||
/** Gueltigkeit des ausgestellten Tokens in Sekunden. */
|
||||
const SETUP_TOKEN_TTL = 1800;
|
||||
|
||||
switch (resolveSetupAction()) {
|
||||
|
||||
case 'login':
|
||||
if (Http::method() !== 'POST') {
|
||||
Http::fail(405, 'method_not_allowed', 'Die Anmeldung erwartet POST.');
|
||||
}
|
||||
|
||||
// Deutlich enger als die uebrigen Endpunkte: hier werden Passwoerter
|
||||
// geprueft. Die Sperre aus Auth greift zusaetzlich pro IP, sie zaehlt
|
||||
// aber erst ab dem zehnten Fehlversuch.
|
||||
$limiter = new RateLimiter($db, 10, 60, 'setup_login');
|
||||
if (!$limiter->check(Http::clientIp())) {
|
||||
Http::fail(429, 'rate_limited', 'Zu viele Anmeldeversuche. Bitte kurz warten.');
|
||||
}
|
||||
|
||||
$username = Http::str('username');
|
||||
$password = Http::str('password');
|
||||
|
||||
if ($username === null || $password === null || $username === '' || $password === '') {
|
||||
Http::fail(400, 'missing_credentials', 'Benutzername und Passwort werden benoetigt.');
|
||||
}
|
||||
|
||||
$lockout = Auth::lockoutSeconds($db, Http::clientIp());
|
||||
if ($lockout > 0) {
|
||||
Http::fail(429, 'locked_out', sprintf(
|
||||
'Zu viele fehlgeschlagene Anmeldungen. Bitte in %d Minute(n) erneut versuchen.',
|
||||
(int)ceil($lockout / 60)
|
||||
));
|
||||
}
|
||||
|
||||
$user = Auth::verifyCredentials($db, $username, $password);
|
||||
|
||||
if ($user === null) {
|
||||
// Bewusst dieselbe Meldung fuer falschen Benutzer, falsches
|
||||
// Passwort und deaktiviertes Konto.
|
||||
Http::fail(401, 'invalid_credentials', 'Ungueltige Anmeldedaten.');
|
||||
}
|
||||
|
||||
// TOTP ist in dc_users vorgesehen, wird aber noch von keinem
|
||||
// Anmeldeweg geprueft. Ein Konto mit hinterlegtem Geheimnis hier
|
||||
// durchzulassen, ohne den zweiten Faktor zu verlangen, waere ein
|
||||
// stiller Rueckschritt - deshalb wird es ausdruecklich abgelehnt.
|
||||
if (!empty($user['totp_secret'])) {
|
||||
Logger::warning('Setup-Anmeldung mit TOTP-Konto abgelehnt', [
|
||||
'username' => $username,
|
||||
'ip' => Http::clientIp(),
|
||||
]);
|
||||
Http::fail(
|
||||
501,
|
||||
'totp_not_supported',
|
||||
'Fuer dieses Konto ist ein zweiter Faktor hinterlegt. Die Setup-Anmeldung '
|
||||
. 'unterstuetzt ihn noch nicht - bitte ein eigenes Installationskonto verwenden.'
|
||||
);
|
||||
}
|
||||
|
||||
$manager = new TokenManager($db);
|
||||
$hostname = Http::str('hostname') ?? Http::str('instance_id');
|
||||
|
||||
$token = $manager->createEphemeralToken(
|
||||
sprintf('Setup %s%s', $username, $hostname !== null ? ' @ ' . $hostname : ''),
|
||||
['setup:install'],
|
||||
SETUP_TOKEN_TTL,
|
||||
$hostname
|
||||
);
|
||||
|
||||
Logger::info('Setup-Anmeldung erfolgreich', [
|
||||
'username' => $username,
|
||||
'role' => Auth::roleOf($user),
|
||||
'hostname' => $hostname,
|
||||
'ip' => Http::clientIp(),
|
||||
'token_id' => $token['token_id'],
|
||||
]);
|
||||
|
||||
Http::ok([
|
||||
'setup_token' => $token['raw_token'],
|
||||
'token_id' => $token['token_id'],
|
||||
'expires_at' => $token['expires_at'],
|
||||
'expires_in' => SETUP_TOKEN_TTL,
|
||||
'scopes' => $token['scopes'],
|
||||
'role' => Auth::roleOf($user),
|
||||
'message' => 'Angemeldet. Das Token laeuft nach 30 Minuten ab und wird nicht gespeichert.',
|
||||
], 201);
|
||||
|
||||
case 'catalog':
|
||||
// allowSession bewusst aus: dieser Weg ist fuer den Installer, nicht
|
||||
// fuer einen angemeldeten Browser.
|
||||
$context = ApiAuth::requireScope($db, 'setup:catalog', null, false);
|
||||
|
||||
$platform = Http::str('platform') ?? Http::str('rid');
|
||||
$catalog = (new SetupCatalog($db))->forPlatform(
|
||||
$platform,
|
||||
ApiAuth::projectFilter($context)
|
||||
);
|
||||
|
||||
Http::ok([
|
||||
'platform' => UpdateManager::normalizePlatform($platform),
|
||||
'count' => count($catalog),
|
||||
'projects' => $catalog,
|
||||
'message' => $catalog === []
|
||||
? 'Fuer diese Plattform ist nichts hinterlegt. Releases ohne Plattformangabe gelten als "any".'
|
||||
: sprintf('%d Anwendung(en) installierbar.', count($catalog)),
|
||||
]);
|
||||
|
||||
case 'token':
|
||||
if (Http::method() !== 'POST') {
|
||||
Http::fail(405, 'method_not_allowed', 'Dieser Endpunkt erwartet POST.');
|
||||
}
|
||||
|
||||
$context = ApiAuth::requireScope($db, 'setup:install', null, false);
|
||||
|
||||
$project = Http::str('project') ?? Http::str('product_slug');
|
||||
if ($project === null || $project === '') {
|
||||
Http::fail(400, 'missing_project', 'Der Parameter "project" wird benoetigt.');
|
||||
}
|
||||
|
||||
ApiAuth::enforceProject($context, $project);
|
||||
|
||||
$exists = $db->prepare('SELECT slug FROM dc_projects WHERE slug = :slug LIMIT 1');
|
||||
$exists->execute([':slug' => $project]);
|
||||
if ($exists->fetch() === false) {
|
||||
Http::fail(404, 'unknown_project', sprintf('Ein Projekt "%s" ist nicht angelegt.', $project));
|
||||
}
|
||||
|
||||
$requested = Http::input('scopes', []);
|
||||
if (is_string($requested)) {
|
||||
$requested = array_map('trim', explode(',', $requested));
|
||||
}
|
||||
if (!is_array($requested) || $requested === []) {
|
||||
$requested = ['watchdog:ping'];
|
||||
}
|
||||
|
||||
// Was ein Installer ausstellen darf, ist fest umrissen und nicht
|
||||
// verhandelbar. Wuerde die Anfrage die Rechte bestimmen, waere der
|
||||
// Umweg ueber ein kurzlebiges Setup-Token wirkungslos: wer einmal
|
||||
// installieren darf, koennte sich sonst ein Token mit allen Rechten
|
||||
// ausstellen und behalten.
|
||||
$allowed = ['watchdog:ping', 'watchdog:read', 'bugtracker:report', 'updateservice:read'];
|
||||
|
||||
$granted = array_values(array_intersect(
|
||||
array_map(static fn($s): string => strtolower(trim((string)$s)), $requested),
|
||||
$allowed
|
||||
));
|
||||
|
||||
if ($granted === []) {
|
||||
Http::fail(403, 'scopes_not_allowed', 'Keines der angeforderten Rechte darf ueber den Setup-Weg vergeben werden.', null, [
|
||||
'allowed' => $allowed,
|
||||
]);
|
||||
}
|
||||
|
||||
$hostname = Http::str('hostname') ?? Http::str('instance_id');
|
||||
|
||||
$manager = new TokenManager($db);
|
||||
$appToken = $manager->createMasterToken(
|
||||
sprintf('%s @ %s', $project, $hostname ?? 'unbekannt'),
|
||||
$project,
|
||||
null,
|
||||
'host',
|
||||
$hostname,
|
||||
$granted,
|
||||
Http::str('environment') ?? 'all'
|
||||
);
|
||||
|
||||
Logger::info('Anwendungstoken ueber den Setup-Weg ausgestellt', [
|
||||
'project' => $project,
|
||||
'hostname' => $hostname,
|
||||
'scopes' => $granted,
|
||||
'actor' => $context['actor'],
|
||||
'token_id' => $appToken['token_id'],
|
||||
]);
|
||||
|
||||
Http::ok([
|
||||
'token' => $appToken['raw_token'],
|
||||
'token_id' => $appToken['token_id'],
|
||||
'project' => $project,
|
||||
'scopes' => $granted,
|
||||
'message' => 'Token ausgestellt. Es wird nur einmal ausgeliefert.',
|
||||
], 201);
|
||||
|
||||
default:
|
||||
Http::fail(404, 'unknown_action', 'Endpunkt nicht gefunden.', null, [
|
||||
'available' => ['login', 'catalog', 'token'],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bestimmt die Aktion aus dem Pfadsegment oder ?action=.
|
||||
* Feste Liste statt Teilstring-Suche.
|
||||
*/
|
||||
function resolveSetupAction(): string
|
||||
{
|
||||
$explicit = Http::str('action');
|
||||
if ($explicit !== null) {
|
||||
return strtolower($explicit);
|
||||
}
|
||||
|
||||
$segments = array_values(array_filter(
|
||||
explode('/', trim(Http::path(), '/')),
|
||||
static fn(string $s): bool => $s !== ''
|
||||
));
|
||||
|
||||
$last = strtolower((string)end($segments));
|
||||
|
||||
return match ($last) {
|
||||
'login', 'catalog', 'token' => $last,
|
||||
default => 'unknown',
|
||||
};
|
||||
}
|
||||
@@ -244,9 +244,132 @@ switch ($action) {
|
||||
),
|
||||
], $result['created'] ? 201 : 200);
|
||||
|
||||
case 'update':
|
||||
case 'edit':
|
||||
if (Http::method() !== 'POST') {
|
||||
Http::fail(405, 'method_not_allowed', 'Das Bearbeiten eines Releases erwartet POST.');
|
||||
}
|
||||
|
||||
$context = ApiAuth::requireScope($db, 'updateservice:publish');
|
||||
|
||||
$releaseId = Http::int('release_id', Http::int('id', 0));
|
||||
if ($releaseId <= 0) {
|
||||
Http::fail(400, 'missing_release_id', 'Der Parameter "release_id" oder "id" ist erforderlich.');
|
||||
}
|
||||
|
||||
$existing = $manager->getReleaseById($releaseId);
|
||||
if ($existing === null) {
|
||||
Http::fail(404, 'release_not_found', sprintf('Release mit ID %d wurde nicht gefunden.', $releaseId));
|
||||
}
|
||||
|
||||
$product = Http::str('product_slug') ?? Http::str('product') ?? $existing['product_slug'];
|
||||
$version = Http::str('version') ?? $existing['version'];
|
||||
$url = Http::str('download_url') ?? $existing['download_url'];
|
||||
$channel = Http::str('channel') ?? $existing['channel'];
|
||||
$platform = UpdateManager::normalizePlatform(Http::str('platform') ?? Http::str('rid') ?? $existing['platform']);
|
||||
$notes = Http::str('release_notes') ?? $existing['release_notes'];
|
||||
$hash = Http::str('sha256_hash') ?? $existing['sha256_hash'];
|
||||
$git = Http::str('git_commit') ?? $existing['git_commit'];
|
||||
$size = Http::int('size_bytes', (int)$existing['size_bytes']);
|
||||
$critical = Http::input('is_critical', null) !== null
|
||||
? filter_var(Http::input('is_critical'), FILTER_VALIDATE_BOOLEAN)
|
||||
: (bool)$existing['is_critical'];
|
||||
|
||||
ApiAuth::enforceProject($context, (string)$product);
|
||||
|
||||
if ($hash !== null && $hash !== '' && preg_match('/^[0-9a-f]{64}$/i', $hash) !== 1) {
|
||||
Http::fail(400, 'invalid_hash', 'sha256_hash muss 64 Hexadezimalzeichen enthalten.');
|
||||
}
|
||||
|
||||
$signature = ReleaseSigner::sign(ReleaseSigner::canonical(
|
||||
(string)$product,
|
||||
(string)$version,
|
||||
(string)$channel,
|
||||
(string)$platform,
|
||||
$hash !== '' ? $hash : null,
|
||||
(string)$url,
|
||||
$size
|
||||
));
|
||||
|
||||
try {
|
||||
$ok = $manager->updateRelease(
|
||||
$releaseId,
|
||||
(string)$product,
|
||||
(string)$version,
|
||||
(string)$channel,
|
||||
$notes,
|
||||
(string)$url,
|
||||
$hash !== '' ? $hash : null,
|
||||
$git,
|
||||
$size,
|
||||
$existing['manifest_json'] ?? null,
|
||||
$critical,
|
||||
$context['actor'],
|
||||
(string)$platform,
|
||||
$signature
|
||||
);
|
||||
} catch (\PDOException $e) {
|
||||
if (str_contains($e->getMessage(), 'uq_prod_ver_chan_plat') || $e->getCode() === '23000') {
|
||||
Http::fail(409, 'conflict', 'Ein Release mit dieser Kombination aus Produkt, Version, Kanal und Plattform existiert bereits.');
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
|
||||
Http::ok([
|
||||
'release_id' => $releaseId,
|
||||
'updated' => $ok,
|
||||
'platform' => $platform,
|
||||
'signed' => $signature !== null,
|
||||
'message' => sprintf('Release %s (%s, %s) fuer "%s" erfolgreich aktualisiert.', $version, $channel, $platform, $product),
|
||||
]);
|
||||
|
||||
case 'delete':
|
||||
if (Http::method() !== 'POST' && Http::method() !== 'DELETE') {
|
||||
Http::fail(405, 'method_not_allowed', 'Das Loeschen erwartet POST oder DELETE.');
|
||||
}
|
||||
|
||||
$context = ApiAuth::requireScope($db, 'updateservice:publish');
|
||||
|
||||
$releaseId = Http::int('release_id', Http::int('id', 0));
|
||||
if ($releaseId <= 0) {
|
||||
$product = Http::str('product_slug') ?? Http::str('product');
|
||||
$version = Http::str('version');
|
||||
$channel = Http::str('channel') ?? 'prod';
|
||||
$platform = Http::str('platform') ?? Http::str('rid');
|
||||
|
||||
if ($product !== null && $version !== null) {
|
||||
$found = $manager->findRelease($product, $version, $channel, $platform);
|
||||
if ($found !== null) {
|
||||
$releaseId = (int)$found['id'];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($releaseId <= 0) {
|
||||
Http::fail(400, 'missing_release_id', 'Ungueltige oder fehlende release_id.');
|
||||
}
|
||||
|
||||
$existing = $manager->getReleaseById($releaseId);
|
||||
if ($existing === null) {
|
||||
Http::fail(404, 'release_not_found', sprintf('Release mit ID %d wurde nicht gefunden.', $releaseId));
|
||||
}
|
||||
|
||||
ApiAuth::enforceProject($context, (string)$existing['product_slug']);
|
||||
|
||||
$deleted = $manager->deleteRelease($releaseId);
|
||||
if (!$deleted) {
|
||||
Http::fail(500, 'delete_failed', 'Das Release konnte nicht geloescht werden.');
|
||||
}
|
||||
|
||||
Http::ok([
|
||||
'release_id' => $releaseId,
|
||||
'deleted' => true,
|
||||
'message' => sprintf('Release v%s (%s, %s) fuer "%s" geloescht.', $existing['version'], $existing['channel'], $existing['platform'] ?? 'any', $existing['product_slug']),
|
||||
]);
|
||||
|
||||
default:
|
||||
Http::fail(404, 'unknown_action', 'Endpunkt nicht gefunden.', null, [
|
||||
'available' => ['check', 'latest', 'releases', 'pubkey', 'publish'],
|
||||
'available' => ['check', 'latest', 'releases', 'pubkey', 'publish', 'update', 'delete'],
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -270,7 +393,7 @@ function resolveUpdateAction(): string
|
||||
$last = strtolower((string)end($segments));
|
||||
|
||||
return match ($last) {
|
||||
'check', 'latest', 'releases', 'pubkey', 'publish' => $last,
|
||||
'check', 'latest', 'releases', 'pubkey', 'publish', 'update', 'edit', 'delete' => $last === 'edit' ? 'update' : $last,
|
||||
'publish_release' => 'publish',
|
||||
default => 'check',
|
||||
};
|
||||
|
||||
@@ -188,12 +188,20 @@ switch ($action) {
|
||||
// ein Agenten-Token soll den Zustand aller Monitore nicht umschreiben.
|
||||
ApiAuth::requireScope($db, 'watchdog:evaluate');
|
||||
|
||||
$reportRes = null;
|
||||
if (Http::str('force_report') === '1' || Http::str('report') === '1') {
|
||||
$reportRes = \Deploymentcenter\Modules\Notify\RocketChatNotifier::sendStatusReport($db, true);
|
||||
}
|
||||
|
||||
$result = Evaluator::run($db);
|
||||
Http::ok($result + ['message' => sprintf(
|
||||
'%d Monitor(e) geprueft, %d Zustandswechsel.',
|
||||
$result['checked'],
|
||||
$result['changed']
|
||||
)]);
|
||||
Http::ok($result + [
|
||||
'rocketchat_report' => $reportRes,
|
||||
'message' => sprintf(
|
||||
'%d Monitor(e) geprueft, %d Zustandswechsel.',
|
||||
$result['checked'],
|
||||
$result['changed']
|
||||
)
|
||||
]);
|
||||
|
||||
default:
|
||||
Http::fail(404, 'unknown_action', 'Endpunkt nicht gefunden.', null, [
|
||||
|
||||
@@ -224,6 +224,31 @@ function dc_flash(string $message, string $type = 'success'): void
|
||||
$_SESSION['dc_flash'] = ['message' => $message, 'type' => $type];
|
||||
}
|
||||
|
||||
/**
|
||||
* Wie viele handlungsfaehige Administratorkonten gibt es noch?
|
||||
*
|
||||
* Grundlage fuer die Sperren beim Deaktivieren und Loeschen: Faellt das
|
||||
* letzte Konto weg, das sich am WebUI anmelden kann, kommt niemand mehr an
|
||||
* die Oberflaeche - auch nicht, um den Schritt rueckgaengig zu machen.
|
||||
* Installationskonten zaehlen bewusst nicht mit, sie duerfen sich hier gar
|
||||
* nicht anmelden.
|
||||
*/
|
||||
function dc_active_admin_count(PDO $pdo): int
|
||||
{
|
||||
try {
|
||||
$stmt = $pdo->query("SELECT COUNT(*) FROM dc_users WHERE role = 'admin' AND disabled = 0");
|
||||
return (int)$stmt->fetchColumn();
|
||||
} catch (Throwable $e) {
|
||||
// Vor Migration 012 gibt es weder role noch disabled; dann sind alle
|
||||
// Konten Administratoren.
|
||||
try {
|
||||
return (int)$pdo->query('SELECT COUNT(*) FROM dc_users')->fetchColumn();
|
||||
} catch (Throwable $inner) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Post/Redirect/Get: leitet nach einer Aktion auf dieselbe Seite um, damit
|
||||
* ein Neuladen das Formular nicht erneut abschickt.
|
||||
@@ -767,6 +792,122 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
dc_redirect('#tab-updateservice');
|
||||
}
|
||||
|
||||
case 'edit_release': {
|
||||
$releaseId = (int)($_POST['release_id'] ?? 0);
|
||||
if ($releaseId <= 0) {
|
||||
dc_flash('Ungültige Release-ID.', 'danger');
|
||||
dc_redirect('#tab-updateservice');
|
||||
}
|
||||
|
||||
$updateManager = new UpdateManager($pdo);
|
||||
$existing = $updateManager->getReleaseById($releaseId);
|
||||
if ($existing === null) {
|
||||
dc_flash('Das zu bearbeitende Release wurde nicht gefunden.', 'danger');
|
||||
dc_redirect('#tab-updateservice');
|
||||
}
|
||||
|
||||
$productSlug = trim((string)($_POST['product_slug'] ?? ''));
|
||||
$version = trim((string)($_POST['version'] ?? ''));
|
||||
$url = trim((string)($_POST['download_url'] ?? ''));
|
||||
|
||||
if ($productSlug === '' || $version === '' || $url === '') {
|
||||
dc_flash('Projekt, Version und Download-URL sind Pflichtfelder.', 'danger');
|
||||
dc_redirect('#tab-updateservice');
|
||||
}
|
||||
|
||||
$hash = trim((string)($_POST['sha256_hash'] ?? ''));
|
||||
if ($hash !== '' && preg_match('/^[0-9a-f]{64}$/i', $hash) !== 1) {
|
||||
dc_flash('Der SHA256-Hash muss aus genau 64 Hexadezimalzeichen bestehen.', 'danger');
|
||||
dc_redirect('#tab-updateservice');
|
||||
}
|
||||
|
||||
$channel = trim((string)($_POST['channel'] ?? 'prod'));
|
||||
$platform = UpdateManager::normalizePlatform((string)($_POST['platform'] ?? ''));
|
||||
$size = (int)($_POST['size_bytes'] ?? 0);
|
||||
|
||||
$signature = ReleaseSigner::sign(ReleaseSigner::canonical(
|
||||
$productSlug,
|
||||
$version,
|
||||
$channel,
|
||||
$platform,
|
||||
$hash !== '' ? $hash : null,
|
||||
$url,
|
||||
$size
|
||||
));
|
||||
|
||||
try {
|
||||
$ok = $updateManager->updateRelease(
|
||||
$releaseId,
|
||||
$productSlug,
|
||||
$version,
|
||||
$channel,
|
||||
trim((string)($_POST['release_notes'] ?? '')),
|
||||
$url,
|
||||
$hash !== '' ? $hash : null,
|
||||
trim((string)($_POST['git_commit'] ?? '')),
|
||||
$size,
|
||||
$existing['manifest_json'] ?? null,
|
||||
isset($_POST['is_critical']),
|
||||
$actor,
|
||||
$platform,
|
||||
$signature
|
||||
);
|
||||
|
||||
if ($ok) {
|
||||
dc_flash(sprintf(
|
||||
'Release %s (%s, %s) für "%s" erfolgreich aktualisiert.%s',
|
||||
e($version),
|
||||
e($channel),
|
||||
e($platform),
|
||||
e($productSlug),
|
||||
$signature === null
|
||||
? ' Hinweis: unsigniert, kein Signierschlüssel hinterlegt.'
|
||||
: ''
|
||||
));
|
||||
} else {
|
||||
dc_flash('Das Release konnte nicht aktualisiert werden.', 'danger');
|
||||
}
|
||||
} catch (\PDOException $e) {
|
||||
if (str_contains($e->getMessage(), 'uq_prod_ver_chan_plat') || $e->getCode() === '23000') {
|
||||
dc_flash('Ein Release mit dieser Kombination aus Projekt, Version, Kanal und Plattform existiert bereits.', 'danger');
|
||||
} else {
|
||||
dc_flash('Fehler beim Aktualisieren des Releases: ' . $e->getMessage(), 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
dc_redirect('#tab-updateservice');
|
||||
}
|
||||
|
||||
case 'delete_release': {
|
||||
$releaseId = (int)($_POST['release_id'] ?? 0);
|
||||
if ($releaseId <= 0) {
|
||||
dc_flash('Ungültige Release-ID.', 'danger');
|
||||
dc_redirect('#tab-updateservice');
|
||||
}
|
||||
|
||||
$updateManager = new UpdateManager($pdo);
|
||||
$existing = $updateManager->getReleaseById($releaseId);
|
||||
if ($existing === null) {
|
||||
dc_flash('Das zu löschende Release wurde nicht gefunden.', 'danger');
|
||||
dc_redirect('#tab-updateservice');
|
||||
}
|
||||
|
||||
$deleted = $updateManager->deleteRelease($releaseId);
|
||||
if ($deleted) {
|
||||
dc_flash(sprintf(
|
||||
'Release v%s (%s, %s) für "%s" erfolgreich gelöscht.',
|
||||
e($existing['version']),
|
||||
e($existing['channel']),
|
||||
e($existing['platform'] ?? 'any'),
|
||||
e($existing['product_slug'])
|
||||
));
|
||||
} else {
|
||||
dc_flash('Das Release konnte nicht gelöscht werden.', 'danger');
|
||||
}
|
||||
|
||||
dc_redirect('#tab-updateservice');
|
||||
}
|
||||
|
||||
// ------------------------------------------------------ Tokens
|
||||
case 'create_master_token': {
|
||||
$name = trim((string)($_POST['name'] ?? ''));
|
||||
@@ -1025,6 +1166,199 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
dc_redirect('#tab-bugtracker');
|
||||
}
|
||||
|
||||
case 'save_rocketchat_settings': {
|
||||
$enabled = isset($_POST['rocketchat_enabled']) ? '1' : '0';
|
||||
$url = trim((string)($_POST['rocketchat_url'] ?? ''));
|
||||
$username = trim((string)($_POST['rocketchat_username'] ?? ''));
|
||||
$password = (string)($_POST['rocketchat_password'] ?? '');
|
||||
$statusChannel = trim((string)($_POST['rocketchat_status_channel'] ?? ''));
|
||||
$alertChannel = trim((string)($_POST['rocketchat_alert_channel'] ?? ''));
|
||||
$verifySsl = isset($_POST['rocketchat_verify_ssl']) ? '1' : '0';
|
||||
$reportInterval = max(300, (int)($_POST['rocketchat_report_interval'] ?? 43200));
|
||||
|
||||
$settings = [
|
||||
'rocketchat_enabled' => $enabled,
|
||||
'rocketchat_url' => $url,
|
||||
'rocketchat_username' => $username,
|
||||
'rocketchat_password' => $password,
|
||||
'rocketchat_status_channel' => $statusChannel,
|
||||
'rocketchat_alert_channel' => $alertChannel,
|
||||
'rocketchat_verify_ssl' => $verifySsl,
|
||||
'rocketchat_report_interval' => (string)$reportInterval,
|
||||
];
|
||||
|
||||
$stmt = $pdo->prepare("
|
||||
INSERT INTO dc_settings (skey, svalue) VALUES (:k, :v)
|
||||
ON DUPLICATE KEY UPDATE svalue = VALUES(svalue)
|
||||
");
|
||||
foreach ($settings as $k => $v) {
|
||||
$stmt->execute([':k' => $k, ':v' => $v]);
|
||||
}
|
||||
|
||||
$stmtCron = $pdo->prepare("
|
||||
INSERT INTO watchdog_cron_jobs (name, interval_sec, enabled)
|
||||
VALUES ('rocketchat_status_report', :interval, :enabled)
|
||||
ON DUPLICATE KEY UPDATE interval_sec = VALUES(interval_sec), enabled = VALUES(enabled)
|
||||
");
|
||||
$stmtCron->execute([':interval' => $reportInterval, ':enabled' => (int)$enabled]);
|
||||
|
||||
dc_flash('Rocket.Chat Einstellungen erfolgreich gespeichert.', 'success');
|
||||
dc_redirect('#sub-system-rocketchat');
|
||||
}
|
||||
|
||||
case 'test_rocketchat_connection': {
|
||||
$config = \Deploymentcenter\Modules\Notify\RocketChatNotifier::getConfig($pdo);
|
||||
$res = \Deploymentcenter\Modules\Notify\RocketChatNotifier::testConnection($config);
|
||||
dc_flash($res['message'], $res['success'] ? 'success' : 'danger');
|
||||
dc_redirect('#sub-system-rocketchat');
|
||||
}
|
||||
|
||||
case 'send_rocketchat_report_now': {
|
||||
$res = \Deploymentcenter\Modules\Notify\RocketChatNotifier::sendStatusReport($pdo, true);
|
||||
dc_flash($res['reason'], $res['sent'] ? 'success' : 'warning');
|
||||
dc_redirect('#sub-system-rocketchat');
|
||||
}
|
||||
|
||||
// ------------------------------------------------- Benutzerkonten
|
||||
case 'add_user': {
|
||||
$newUser = trim((string)($_POST['username'] ?? ''));
|
||||
$newPass = (string)($_POST['password'] ?? '');
|
||||
$newRole = ($_POST['role'] ?? 'installer') === 'admin' ? 'admin' : 'installer';
|
||||
|
||||
if (preg_match('/^[A-Za-z0-9._-]{3,64}$/', $newUser) !== 1) {
|
||||
dc_flash('Der Benutzername muss 3 bis 64 Zeichen lang sein und darf Buchstaben, Ziffern, Punkt, Bindestrich und Unterstrich enthalten.', 'danger');
|
||||
dc_redirect('#sub-system-users');
|
||||
}
|
||||
|
||||
// Ein Installationskonto wird auf fremden Rechnern eingegeben.
|
||||
// Ein kurzes Passwort waere dort schnell mitgelesen und liesse
|
||||
// sich ebenso schnell durchprobieren.
|
||||
if (strlen($newPass) < 12) {
|
||||
dc_flash('Das Passwort muss mindestens 12 Zeichen lang sein.', 'danger');
|
||||
dc_redirect('#sub-system-users');
|
||||
}
|
||||
|
||||
$exists = $pdo->prepare('SELECT id FROM dc_users WHERE username = :u LIMIT 1');
|
||||
$exists->execute([':u' => $newUser]);
|
||||
if ($exists->fetch() !== false) {
|
||||
dc_flash(sprintf('Der Benutzername "%s" ist bereits vergeben.', e($newUser)), 'danger');
|
||||
dc_redirect('#sub-system-users');
|
||||
}
|
||||
|
||||
$ins = $pdo->prepare('
|
||||
INSERT INTO dc_users (username, password_hash, role, created_at)
|
||||
VALUES (:u, :h, :r, UTC_TIMESTAMP())
|
||||
');
|
||||
$ins->execute([
|
||||
':u' => $newUser,
|
||||
':h' => password_hash($newPass, PASSWORD_DEFAULT),
|
||||
':r' => $newRole,
|
||||
]);
|
||||
|
||||
Logger::info('Benutzerkonto angelegt', [
|
||||
'username' => $newUser,
|
||||
'role' => $newRole,
|
||||
'actor' => $actor,
|
||||
]);
|
||||
|
||||
dc_flash(sprintf(
|
||||
'Konto "%s" mit der Rolle %s angelegt.%s',
|
||||
e($newUser),
|
||||
e($newRole),
|
||||
$newRole === 'installer'
|
||||
? ' Es kann sich nicht am WebUI anmelden, sondern nur Anwendungen einrichten.'
|
||||
: ''
|
||||
));
|
||||
dc_redirect('#sub-system-users');
|
||||
}
|
||||
|
||||
case 'set_user_password': {
|
||||
$userId = (int)($_POST['user_id'] ?? 0);
|
||||
$newPass = (string)($_POST['password'] ?? '');
|
||||
|
||||
if (strlen($newPass) < 12) {
|
||||
dc_flash('Das Passwort muss mindestens 12 Zeichen lang sein.', 'danger');
|
||||
dc_redirect('#sub-system-users');
|
||||
}
|
||||
|
||||
$upd = $pdo->prepare('UPDATE dc_users SET password_hash = :h WHERE id = :id');
|
||||
$upd->execute([':h' => password_hash($newPass, PASSWORD_DEFAULT), ':id' => $userId]);
|
||||
|
||||
Logger::info('Passwort geaendert', ['user_id' => $userId, 'actor' => $actor]);
|
||||
dc_flash($upd->rowCount() > 0 ? 'Passwort geaendert.' : 'Konto nicht gefunden.',
|
||||
$upd->rowCount() > 0 ? 'success' : 'danger');
|
||||
dc_redirect('#sub-system-users');
|
||||
}
|
||||
|
||||
case 'toggle_user_disabled': {
|
||||
$userId = (int)($_POST['user_id'] ?? 0);
|
||||
|
||||
$row = $pdo->prepare('SELECT id, username, role, disabled FROM dc_users WHERE id = :id LIMIT 1');
|
||||
$row->execute([':id' => $userId]);
|
||||
$target = $row->fetch();
|
||||
|
||||
if (!is_array($target)) {
|
||||
dc_flash('Konto nicht gefunden.', 'danger');
|
||||
dc_redirect('#sub-system-users');
|
||||
}
|
||||
|
||||
$disable = (int)$target['disabled'] === 0;
|
||||
|
||||
// Ohne diese Sperre liesse sich das letzte handlungsfaehige
|
||||
// Administratorkonto abschalten - danach kaeme niemand mehr an
|
||||
// die Oberflaeche, auch nicht, um es rueckgaengig zu machen.
|
||||
if ($disable && $target['role'] === 'admin' && dc_active_admin_count($pdo) <= 1) {
|
||||
dc_flash('Das letzte aktive Administratorkonto kann nicht deaktiviert werden.', 'danger');
|
||||
dc_redirect('#sub-system-users');
|
||||
}
|
||||
|
||||
$upd = $pdo->prepare('UPDATE dc_users SET disabled = :d WHERE id = :id');
|
||||
$upd->execute([':d' => $disable ? 1 : 0, ':id' => $userId]);
|
||||
|
||||
Logger::info('Konto ' . ($disable ? 'deaktiviert' : 'aktiviert'), [
|
||||
'username' => $target['username'],
|
||||
'actor' => $actor,
|
||||
]);
|
||||
|
||||
dc_flash(sprintf('Konto "%s" wurde %s.', e((string)$target['username']),
|
||||
$disable ? 'deaktiviert' : 'wieder aktiviert'));
|
||||
dc_redirect('#sub-system-users');
|
||||
}
|
||||
|
||||
case 'delete_user': {
|
||||
$userId = (int)($_POST['user_id'] ?? 0);
|
||||
|
||||
$row = $pdo->prepare('SELECT id, username, role FROM dc_users WHERE id = :id LIMIT 1');
|
||||
$row->execute([':id' => $userId]);
|
||||
$target = $row->fetch();
|
||||
|
||||
if (!is_array($target)) {
|
||||
dc_flash('Konto nicht gefunden.', 'danger');
|
||||
dc_redirect('#sub-system-users');
|
||||
}
|
||||
|
||||
if ($userId === Auth::userId()) {
|
||||
dc_flash('Das eigene Konto kann nicht geloescht werden.', 'danger');
|
||||
dc_redirect('#sub-system-users');
|
||||
}
|
||||
|
||||
if ($target['role'] === 'admin' && dc_active_admin_count($pdo) <= 1) {
|
||||
dc_flash('Das letzte Administratorkonto kann nicht geloescht werden.', 'danger');
|
||||
dc_redirect('#sub-system-users');
|
||||
}
|
||||
|
||||
$del = $pdo->prepare('DELETE FROM dc_users WHERE id = :id');
|
||||
$del->execute([':id' => $userId]);
|
||||
|
||||
Logger::info('Benutzerkonto geloescht', [
|
||||
'username' => $target['username'],
|
||||
'actor' => $actor,
|
||||
]);
|
||||
|
||||
dc_flash(sprintf('Konto "%s" wurde geloescht.', e((string)$target['username'])));
|
||||
dc_redirect('#sub-system-users');
|
||||
}
|
||||
|
||||
default:
|
||||
dc_flash('Unbekannte Aktion.', 'danger');
|
||||
dc_redirect();
|
||||
@@ -1271,6 +1605,39 @@ $recentEvents = $eventLog->getRecentEvents(100);
|
||||
$updateManager = new UpdateManager($pdo);
|
||||
$releases = $updateManager->getReleases();
|
||||
|
||||
// Benutzerkonten fuer die Verwaltung. Der Passwort-Hash wird bewusst nicht
|
||||
// mitgelesen - er hat in der Ansicht nichts verloren.
|
||||
try {
|
||||
$users = $pdo->query('
|
||||
SELECT id, username, role, disabled, last_login_at, created_at
|
||||
FROM dc_users ORDER BY role ASC, username ASC
|
||||
')->fetchAll() ?: [];
|
||||
} catch (Throwable $e) {
|
||||
// Vor Migration 012 fehlen role, disabled und last_login_at.
|
||||
$users = $pdo->query('SELECT id, username, created_at FROM dc_users ORDER BY username ASC')->fetchAll() ?: [];
|
||||
}
|
||||
$usersSchemaReady = $users === [] || array_key_exists('role', $users[0]);
|
||||
|
||||
// Installer-Downloads. Die Binaries liegen unter /installer/ im Webroot und
|
||||
// werden nicht von deploy.py uebertragen, sondern von
|
||||
// scripts/upload_installer.py - der Quelltext des Agenten gehoert nicht auf
|
||||
// den Webserver, die uebersetzten Dateien schon.
|
||||
$installerManifest = null;
|
||||
$installerPath = dirname(__DIR__) . '/installer/installer.json';
|
||||
|
||||
if (is_file($installerPath)) {
|
||||
$raw = (string)file_get_contents($installerPath);
|
||||
|
||||
// Ein vorangestelltes BOM laesst json_decode() scheitern. Windows-Werkzeuge
|
||||
// schreiben es gern mit; die Datei ist deswegen nicht kaputt.
|
||||
$raw = preg_replace('/^\xEF\xBB\xBF/', '', $raw) ?? $raw;
|
||||
|
||||
$decoded = json_decode($raw, true);
|
||||
if (is_array($decoded) && isset($decoded['binaries']) && is_array($decoded['binaries'])) {
|
||||
$installerManifest = $decoded;
|
||||
}
|
||||
}
|
||||
|
||||
$coreTokenManager = new CoreTokenManager($pdo);
|
||||
$coreMasterTokens = $coreTokenManager->getAllMasterTokens();
|
||||
$coreAllTokens = $coreTokenManager->getAllTokens();
|
||||
@@ -1391,6 +1758,10 @@ $evaluatorLastRun = is_array($evaluatorJob) ? ($evaluatorJob['last_run_utc'] ??
|
||||
$evaluatorStale = $evaluatorLastRun === null
|
||||
|| (time() - (int)strtotime((string)$evaluatorLastRun . ' UTC')) > 900;
|
||||
|
||||
// Rocket.Chat Konfiguration und Cron-Status
|
||||
$rcConfig = \Deploymentcenter\Modules\Notify\RocketChatNotifier::getConfig($pdo);
|
||||
$rcCronJob = $pdo->query("SELECT * FROM watchdog_cron_jobs WHERE name = 'rocketchat_status_report'")->fetch() ?: [];
|
||||
|
||||
$baseUrl = Http::baseUrl();
|
||||
$csrfField = Csrf::field();
|
||||
?>
|
||||
@@ -2625,6 +2996,7 @@ Antwort:
|
||||
<th>Release Notes</th>
|
||||
<th>Download & SHA256</th>
|
||||
<th>Datum</th>
|
||||
<th>Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -2664,6 +3036,10 @@ Antwort:
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td><?= htmlspecialchars($r['created_at']) ?></td>
|
||||
<td style="white-space:nowrap;">
|
||||
<button type="button" class="btn btn-sm btn-secondary" onclick="openEditReleaseModal(<?= htmlspecialchars(json_encode($r, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP)) ?>)" title="Release bearbeiten">✏️ Bearbeiten</button>
|
||||
<button type="button" class="btn btn-sm btn-danger" onclick="openDeleteReleaseModal(<?= (int)$r['id'] ?>, '<?= htmlspecialchars(addslashes($r['version'])) ?>', '<?= htmlspecialchars(addslashes($r['product_slug'])) ?>')" title="Release löschen">🗑️ Löschen</button>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
@@ -2671,6 +3047,110 @@ Antwort:
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="sub-update-installer" class="subtab-content">
|
||||
<div class="card">
|
||||
<div class="card-header"><h2 class="card-title">⬇️ Installer herunterladen</h2></div>
|
||||
|
||||
<p style="color:var(--text-muted); font-size:0.875rem; margin-bottom:1.25rem;">
|
||||
Ein Werkzeug für alles: Dasselbe Programm richtet eine Anwendung erstmalig
|
||||
ein und aktualisiert sie später. Es bringt die .NET-Laufzeit mit — auf dem
|
||||
Zielsystem muss nichts vorinstalliert sein.
|
||||
</p>
|
||||
|
||||
<?php if ($installerManifest === null): ?>
|
||||
<div class="alert" style="background:rgba(255,180,0,0.12); border:1px solid rgba(255,180,0,0.3); color:#fde68a; font-weight:500;">
|
||||
<strong>Noch nichts hinterlegt.</strong> Die Binaries werden mit
|
||||
<code>scripts/build_installer.ps1</code> gebaut und mit
|
||||
<code>scripts/upload_installer.py</code> nach <code>/installer/</code>
|
||||
hochgeladen.
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div style="font-size:0.8rem; color:var(--text-muted); margin-bottom:1rem;">
|
||||
Version <strong><?= e((string)($installerManifest['version'] ?? '?')) ?></strong>
|
||||
· Build <code><?= e((string)($installerManifest['gitCommit'] ?? '?')) ?></code>
|
||||
· <?= e(substr((string)($installerManifest['buildDateUtc'] ?? ''), 0, 10)) ?>
|
||||
</div>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Plattform</th>
|
||||
<th>Größe</th>
|
||||
<th>SHA256</th>
|
||||
<th>Download</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($installerManifest['binaries'] as $bin): ?>
|
||||
<?php
|
||||
$binFile = (string)($bin['file'] ?? '');
|
||||
$binHash = (string)($bin['sha256'] ?? '');
|
||||
$binUrl = $baseUrl . '/installer/' . rawurlencode($binFile);
|
||||
?>
|
||||
<tr>
|
||||
<td><code><?= e((string)($bin['platform'] ?? '?')) ?></code></td>
|
||||
<td style="font-family:'Roboto Mono',monospace; font-size:0.8rem;">
|
||||
<?= number_format(((int)($bin['sizeBytes'] ?? 0)) / (1024 * 1024), 1, ',', '.') ?> MB
|
||||
</td>
|
||||
<td>
|
||||
<code style="font-size:0.65rem; color:var(--text-muted); word-break:break-all;" title="<?= e($binHash) ?>">
|
||||
<?= e(substr($binHash, 0, 24)) ?>…
|
||||
</code>
|
||||
</td>
|
||||
<td>
|
||||
<a href="<?= e($binUrl) ?>" class="btn btn-sm">⬇️ Herunterladen</a>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="alert" style="background:rgba(91,157,255,0.12); border:1px solid rgba(91,157,255,0.3); color:#bfdbfe; margin-top:1rem;">
|
||||
<strong>Prüfsumme vergleichen.</strong> „Programm herunterladen und
|
||||
Zugangsdaten eingeben“ ist genau das Muster, das Phishing nachahmt.
|
||||
Die vollständigen Hashes stehen im Tooltip und unter
|
||||
<a href="<?= e($baseUrl) ?>/installer/installer.json" target="_blank" rel="noopener" style="color:#bfdbfe;">installer.json</a>.
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header"><h2 class="card-title">Direkt auf dem Zielsystem</h2></div>
|
||||
|
||||
<p style="color:var(--text-muted); font-size:0.875rem; margin-bottom:0.75rem;">
|
||||
<strong>Linux</strong> — lädt das passende Binary, prüft die Prüfsumme und legt es ab:
|
||||
</p>
|
||||
<div class="prompt-box">wget -qO- <?= e($baseUrl) ?>/installer/install.sh | sh</div>
|
||||
|
||||
<p style="color:var(--text-muted); font-size:0.875rem; margin:1rem 0 0.75rem;">
|
||||
Oder ohne Skript, direkt das Binary:
|
||||
</p>
|
||||
<div class="prompt-box">wget <?= e($baseUrl) ?>/installer/update-agent-linux-x64 -O update-agent
|
||||
chmod +x update-agent
|
||||
./update-agent --action install</div>
|
||||
|
||||
<p style="color:var(--text-muted); font-size:0.875rem; margin:1rem 0 0.75rem;">
|
||||
<strong>Windows</strong> (PowerShell):
|
||||
</p>
|
||||
<div class="prompt-box">irm <?= e($baseUrl) ?>/installer/install.ps1 | iex</div>
|
||||
|
||||
<p style="color:var(--text-muted); font-size:0.8rem; margin-top:1rem;">
|
||||
Die Skripte richten <strong>nichts</strong> von selbst ein — sie laden, prüfen
|
||||
und sagen, wie es weitergeht. Ein Skript aus dem Netz, das ungefragt eine
|
||||
Anwendung aufsetzt und dabei nach Zugangsdaten fragt, wäre genau das, wovor
|
||||
man Nutzer sonst warnt.
|
||||
</p>
|
||||
|
||||
<p style="color:var(--text-muted); font-size:0.8rem; margin-top:0.75rem;">
|
||||
Für die Installation wird ein Konto der Rolle <strong>installer</strong>
|
||||
gebraucht — anzulegen unter <em>System → 👤 Benutzer</em>. Ein
|
||||
Administratorkonto funktioniert auch, gehört aber nicht auf ein Zielsystem:
|
||||
seine Zugangsdaten würden damit auf jedem Rechner landen, auf dem je etwas
|
||||
installiert wurde.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="sub-update-publish" class="subtab-content">
|
||||
<div class="card">
|
||||
<div class="card-header"><h2 class="card-title">Neues Software-Release veröffentlichen</h2></div>
|
||||
@@ -2740,6 +3220,106 @@ Antwort:
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit Release Modal -->
|
||||
<div class="modal-backdrop" id="editReleaseModal">
|
||||
<div class="modal-card">
|
||||
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:1.5rem;">
|
||||
<h3 style="margin:0;">✏️ Release bearbeiten</h3>
|
||||
<button type="button" onclick="closeEditReleaseModal()" style="background:none; border:none; color:var(--text-muted); font-size:1.25rem; cursor:pointer;">×</button>
|
||||
</div>
|
||||
<form method="POST" action="index.php#sub-update-releases">
|
||||
<?= $csrfField ?>
|
||||
<input type="hidden" name="action" value="edit_release">
|
||||
<input type="hidden" name="release_id" id="edit_release_id" value="">
|
||||
|
||||
<div class="form-grid">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Projekt-Slug</label>
|
||||
<select name="product_slug" id="edit_release_product_slug" class="form-input" required>
|
||||
<?php foreach ($projects as $p): ?>
|
||||
<option value="<?= htmlspecialchars($p['slug']) ?>"><?= htmlspecialchars($p['name']) ?> (<?= htmlspecialchars($p['slug']) ?>)</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Release Kanal</label>
|
||||
<select name="channel" id="edit_release_channel" class="form-input" required>
|
||||
<option value="prod">prod (Produktiv)</option>
|
||||
<option value="beta">beta (Vorab-Test)</option>
|
||||
<option value="dev">dev (Entwicklung)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Plattform</label>
|
||||
<select name="platform" id="edit_release_platform" class="form-input">
|
||||
<option value="any">any (plattformunabhängig)</option>
|
||||
<option value="win-x64">win-x64</option>
|
||||
<option value="win-arm64">win-arm64</option>
|
||||
<option value="linux-x64">linux-x64</option>
|
||||
<option value="linux-arm64">linux-arm64</option>
|
||||
<option value="linux-musl-x64">linux-musl-x64</option>
|
||||
<option value="osx-x64">osx-x64</option>
|
||||
<option value="osx-arm64">osx-arm64</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Version (z. B. 1.2.0)</label>
|
||||
<input type="text" name="version" id="edit_release_version" class="form-input" required placeholder="1.2.0">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Git Commit (Kurz-Hash)</label>
|
||||
<input type="text" name="git_commit" id="edit_release_git_commit" class="form-input" placeholder="a1b2c3d">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Download URL</label>
|
||||
<input type="url" name="download_url" id="edit_release_download_url" class="form-input" required placeholder="https://...">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">SHA256 Hash (Optional)</label>
|
||||
<input type="text" name="sha256_hash" id="edit_release_sha256_hash" class="form-input" placeholder="e3b0c44298fc1c149afbf4c8996fb924...">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Größe (Bytes)</label>
|
||||
<input type="number" name="size_bytes" id="edit_release_size_bytes" class="form-input" min="0" placeholder="0">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group" style="margin-bottom:1rem;">
|
||||
<label class="form-label">Release Notes / Changelog</label>
|
||||
<textarea name="release_notes" id="edit_release_release_notes" class="form-input" rows="3" placeholder="Changelog..."></textarea>
|
||||
</div>
|
||||
<div style="margin-bottom:1.25rem;">
|
||||
<label style="display:inline-flex; align-items:center; gap:0.5rem; font-size:0.875rem; cursor:pointer;">
|
||||
<input type="checkbox" name="is_critical" id="edit_release_is_critical" value="1"> Kritisches Sicherheits-Update (Rollout priorisieren)
|
||||
</label>
|
||||
</div>
|
||||
<div style="display:flex; justify-content:flex-end; gap:0.75rem;">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeEditReleaseModal()">Abbrechen</button>
|
||||
<button type="submit" class="btn">Änderungen Speichern</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete Release Modal -->
|
||||
<div class="modal-backdrop" id="deleteReleaseModal">
|
||||
<div class="modal-card" style="max-width:480px;">
|
||||
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:1.5rem;">
|
||||
<h3 style="margin:0; color:#ef4444;">🗑️ Release löschen</h3>
|
||||
<button type="button" onclick="closeDeleteReleaseModal()" style="background:none; border:none; color:var(--text-muted); font-size:1.25rem; cursor:pointer;">×</button>
|
||||
</div>
|
||||
<p>Möchten Sie das Release <strong id="delete_release_version_display"></strong> für <strong id="delete_release_product_display"></strong> wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.</p>
|
||||
<form method="POST" action="index.php#sub-update-releases" style="margin-top:1.5rem;">
|
||||
<?= $csrfField ?>
|
||||
<input type="hidden" name="action" value="delete_release">
|
||||
<input type="hidden" name="release_id" id="delete_release_id" value="">
|
||||
<div style="display:flex; justify-content:flex-end; gap:0.75rem;">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeDeleteReleaseModal()">Abbrechen</button>
|
||||
<button type="submit" class="btn btn-danger">Release unwiderruflich löschen</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ================= MODULE: BUGTRACKER & FEATURE-TRACKER ================= -->
|
||||
@@ -3853,6 +4433,229 @@ Antwort:
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="sub-system-rocketchat" class="subtab-content">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2 class="card-title">💬 Rocket.Chat Benachrichtigungen & Statusberichte</h2>
|
||||
</div>
|
||||
<p style="color:var(--text-muted); font-size:0.875rem; margin-bottom:1.5rem;">
|
||||
Hier konfigurieren Sie die automatischen Rocket.Chat-Benachrichtigungen. Alle 12 Stunden wird ein detaillierter Systemstatusbericht an den Status-Kanal gesendet. Kritische Systemausfälle und Bugs landen sofort im Alarm-Kanal.
|
||||
</p>
|
||||
|
||||
<form method="POST">
|
||||
<?= $csrfField ?>
|
||||
<input type="hidden" name="action" value="save_rocketchat_settings">
|
||||
|
||||
<div class="form-grid">
|
||||
<div class="form-group" style="grid-column: span 2;">
|
||||
<label class="form-label" style="display:flex; align-items:center; gap:0.5rem; cursor:pointer;">
|
||||
<input type="checkbox" name="rocketchat_enabled" value="1" <?= !empty($rcConfig['enabled']) ? 'checked' : '' ?>>
|
||||
<strong>Rocket.Chat Benachrichtigungen aktivieren</strong>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Rocket.Chat Server-URL</label>
|
||||
<input type="url" name="rocketchat_url" class="form-input" required value="<?= e($rcConfig['url']) ?>" placeholder="https://chat.wh1.mhdf.de">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Benutzername</label>
|
||||
<input type="text" name="rocketchat_username" class="form-input" required value="<?= e($rcConfig['username']) ?>" placeholder="deploymentcenter">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Passwort</label>
|
||||
<input type="password" name="rocketchat_password" class="form-input" required value="<?= e($rcConfig['password']) ?>">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">SSL-Zertifikat verifizieren</label>
|
||||
<label class="form-label" style="display:flex; align-items:center; gap:0.5rem; cursor:pointer; font-weight:normal; margin-top:0.3rem;">
|
||||
<input type="checkbox" name="rocketchat_verify_ssl" value="1" <?= !empty($rcConfig['verify_ssl']) ? 'checked' : '' ?>>
|
||||
SSL-Zertifikat prüfen (bei selbstsignierten/unsicheren Zertifikaten deaktivieren)
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Status-Kanal (12h Bericht)</label>
|
||||
<input type="text" name="rocketchat_status_channel" class="form-input" required value="<?= e($rcConfig['status_channel']) ?>" placeholder="#DC-Systemstatus">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Alarm-Kanal (Kritische Ausfälle & Bugs)</label>
|
||||
<input type="text" name="rocketchat_alert_channel" class="form-input" required value="<?= e($rcConfig['alert_channel']) ?>" placeholder="#DC-Alerts">
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="grid-column: span 2;">
|
||||
<label class="form-label">Statusbericht Intervall (Sekunden)</label>
|
||||
<input type="number" name="rocketchat_report_interval" class="form-input" min="300" step="60" value="<?= e((string)$rcConfig['report_interval']) ?>">
|
||||
<span style="font-size:0.75rem; color:var(--text-muted);">Standard: 43200 Sekunden (12 Stunden)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-top:1.5rem; display:flex; gap:1rem;">
|
||||
<button type="submit" class="btn btn-primary">💾 Einstellungen Speichern</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card" style="margin-top:1.5rem;">
|
||||
<div class="card-header">
|
||||
<h2 class="card-title">🧪 Aktionen & Testlauf</h2>
|
||||
</div>
|
||||
<p style="color:var(--text-muted); font-size:0.875rem; margin-bottom:1.5rem;">
|
||||
Hier können Sie die Erreichbarkeit Ihres Rocket.Chat-Servers testen oder sofort einen 12-Stunden-Statusbericht auslösen.
|
||||
</p>
|
||||
|
||||
<div style="display:flex; flex-wrap:wrap; gap:1rem;">
|
||||
<form method="POST">
|
||||
<?= $csrfField ?>
|
||||
<input type="hidden" name="action" value="test_rocketchat_connection">
|
||||
<button type="submit" class="btn btn-secondary">🧪 Verbindung & Kanäle Testen</button>
|
||||
</form>
|
||||
|
||||
<form method="POST">
|
||||
<?= $csrfField ?>
|
||||
<input type="hidden" name="action" value="send_rocketchat_report_now">
|
||||
<button type="submit" class="btn btn-secondary">📊 12h Statusbericht Jetzt Senden</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div style="margin-top:1.5rem; background:rgba(0,0,0,0.2); padding:1rem; border-radius:8px; font-size:0.85rem; color:var(--text-muted);">
|
||||
<strong>📌 Status des automatischen 12h Cron-Jobs:</strong><br>
|
||||
• Letzter Bericht: <?= !empty($rcCronJob['last_run_utc']) ? e(dc_dt((string)$rcCronJob['last_run_utc'])) . ' (' . e(dc_ago((string)$rcCronJob['last_run_utc'])) . ')' : 'Noch nie gelaufen' ?><br>
|
||||
• Letzter Status: <span class="badge badge-<?= ($rcCronJob['last_status'] ?? '') === 'ok' ? 'up' : 'warning' ?>"><?= e((string)($rcCronJob['last_status'] ?? 'bereit')) ?></span><br>
|
||||
• Nächster geplanter Lauf: In ca. <?= !empty($rcCronJob['last_run_utc']) ? max(0, (int)round(($rcConfig['report_interval'] - (time() - strtotime((string)$rcCronJob['last_run_utc'] . ' UTC'))) / 3600)) . ' Std.' : 'sofort beim nächsten Tick' ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="sub-system-users" class="subtab-content">
|
||||
<div class="card">
|
||||
<div class="card-header"><h2 class="card-title">👤 Benutzerkonten</h2></div>
|
||||
|
||||
<?php if (!$usersSchemaReady): ?>
|
||||
<div class="alert" style="background:rgba(255,180,0,0.12); border:1px solid rgba(255,180,0,0.3); color:#fde68a; font-weight:500;">
|
||||
<strong>Migration 012 fehlt.</strong> Rollen, Deaktivierung und der Zeitpunkt
|
||||
der letzten Anmeldung stehen erst danach zur Verfügung. Bis dahin gelten alle
|
||||
Konten als Administratoren.
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<p style="color:var(--text-muted); font-size:0.875rem; margin-bottom:1.25rem;">
|
||||
Für die Erstinstallation von Anwendungen gibt es die Rolle
|
||||
<strong>installer</strong>. Solche Konten melden sich ausschließlich über
|
||||
<code>/api/setup/v1/login</code> an und bekommen dafür ein Token, das nach
|
||||
30 Minuten verfällt. <strong>Am WebUI können sie sich nicht anmelden.</strong>
|
||||
Der Grund: Die Zugangsdaten werden auf jedem Zielsystem eingegeben — mit einem
|
||||
Administratorkonto verteilte man damit den Zugang zu Tokens, Lizenzen und
|
||||
Monitoren auf jeden Rechner, auf dem je etwas installiert wurde.
|
||||
</p>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Benutzer</th>
|
||||
<th>Rolle</th>
|
||||
<th>Status</th>
|
||||
<th>Letzte Anmeldung</th>
|
||||
<th>Angelegt</th>
|
||||
<th>Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($users as $u): ?>
|
||||
<?php
|
||||
$uRole = (string)($u['role'] ?? 'admin');
|
||||
$uDisabled = (int)($u['disabled'] ?? 0) === 1;
|
||||
$isSelf = (int)$u['id'] === Auth::userId();
|
||||
?>
|
||||
<tr<?= $uDisabled ? ' style="opacity:0.55;"' : '' ?>>
|
||||
<td>
|
||||
<strong><?= e((string)$u['username']) ?></strong>
|
||||
<?php if ($isSelf): ?><span class="badge badge-up">Sie</span><?php endif; ?>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge <?= $uRole === 'admin' ? 'badge-up' : 'badge-warning' ?>">
|
||||
<?= e(strtoupper($uRole)) ?>
|
||||
</span>
|
||||
</td>
|
||||
<td><?= $uDisabled ? '<span class="badge badge-down">DEAKTIVIERT</span>' : 'aktiv' ?></td>
|
||||
<td style="font-size:0.8rem; color:var(--text-muted);">
|
||||
<?= !empty($u['last_login_at']) ? e((string)$u['last_login_at']) : 'nie' ?>
|
||||
</td>
|
||||
<td style="font-size:0.8rem; color:var(--text-muted);"><?= e((string)($u['created_at'] ?? '-')) ?></td>
|
||||
<td>
|
||||
<div style="display:flex; flex-wrap:wrap; gap:0.35rem;">
|
||||
<form method="POST" action="index.php" style="display:inline-flex; gap:0.25rem;"
|
||||
onsubmit="return confirm('Passwort für <?= e((string)$u['username']) ?> wirklich ändern?');">
|
||||
<?= $csrfField ?>
|
||||
<input type="hidden" name="action" value="set_user_password">
|
||||
<input type="hidden" name="_return" value="sub-system-users">
|
||||
<input type="hidden" name="user_id" value="<?= (int)$u['id'] ?>">
|
||||
<input type="password" name="password" class="form-input" required minlength="12"
|
||||
placeholder="Neues Passwort" style="width:170px; padding:0.3rem 0.5rem; font-size:0.8rem;">
|
||||
<button type="submit" class="btn btn-sm btn-secondary">Setzen</button>
|
||||
</form>
|
||||
|
||||
<?php if (!$isSelf): ?>
|
||||
<form method="POST" action="index.php" style="display:inline;">
|
||||
<?= $csrfField ?>
|
||||
<input type="hidden" name="action" value="toggle_user_disabled">
|
||||
<input type="hidden" name="_return" value="sub-system-users">
|
||||
<input type="hidden" name="user_id" value="<?= (int)$u['id'] ?>">
|
||||
<button type="submit" class="btn btn-sm btn-secondary">
|
||||
<?= $uDisabled ? 'Aktivieren' : 'Deaktivieren' ?>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<form method="POST" action="index.php" style="display:inline;"
|
||||
onsubmit="return confirm('Konto <?= e((string)$u['username']) ?> endgültig löschen?');">
|
||||
<?= $csrfField ?>
|
||||
<input type="hidden" name="action" value="delete_user">
|
||||
<input type="hidden" name="_return" value="sub-system-users">
|
||||
<input type="hidden" name="user_id" value="<?= (int)$u['id'] ?>">
|
||||
<button type="submit" class="btn btn-sm btn-danger">Löschen</button>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header"><h2 class="card-title">Neues Konto anlegen</h2></div>
|
||||
<form method="POST" action="index.php">
|
||||
<?= $csrfField ?>
|
||||
<input type="hidden" name="action" value="add_user">
|
||||
<input type="hidden" name="_return" value="sub-system-users">
|
||||
<div class="form-grid">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Benutzername</label>
|
||||
<input type="text" name="username" class="form-input" required
|
||||
pattern="[A-Za-z0-9._\-]{3,64}" placeholder="installer">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Rolle</label>
|
||||
<select name="role" class="form-input">
|
||||
<option value="installer" selected>installer (nur Erstinstallation)</option>
|
||||
<option value="admin">admin (volle Verwaltung)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Passwort (mindestens 12 Zeichen)</label>
|
||||
<input type="password" name="password" class="form-input" required minlength="12">
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn">Konto anlegen</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="sub-system-swagger" class="subtab-content">
|
||||
<div class="card">
|
||||
<div class="card-header"><h2 class="card-title">📖 API-Dokumentation</h2></div>
|
||||
@@ -3944,6 +4747,7 @@ SYSTEM
|
||||
],
|
||||
'updateservice': [
|
||||
{ id: 'sub-update-releases', label: '📊 Releases Overview', active: true },
|
||||
{ id: 'sub-update-installer', label: '⬇️ Installer' },
|
||||
{ id: 'sub-update-publish', label: '➕ Release Veröffentlichen' }
|
||||
],
|
||||
'bugtracker': [
|
||||
@@ -3959,6 +4763,8 @@ SYSTEM
|
||||
],
|
||||
'system': [
|
||||
{ id: 'sub-system-status', label: '⚙️ System-Status', active: true },
|
||||
{ id: 'sub-system-users', label: '👤 Benutzer' },
|
||||
{ id: 'sub-system-rocketchat', label: '💬 Rocket.Chat' },
|
||||
{ id: 'sub-system-swagger', label: '📖 API Swagger Docs' },
|
||||
{ id: 'sub-system-migration', label: '🗄️ DB Migration' }
|
||||
]
|
||||
@@ -4373,6 +5179,55 @@ SYSTEM
|
||||
document.getElementById('projSubmitBtn').innerText = 'Projekt Anlegen';
|
||||
}
|
||||
|
||||
// UpdateService Release Modal Functions
|
||||
function openEditReleaseModal(r) {
|
||||
const setVal = (id, val) => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.value = val;
|
||||
};
|
||||
|
||||
setVal('edit_release_id', r.id || 0);
|
||||
setVal('edit_release_product_slug', r.product_slug || '');
|
||||
setVal('edit_release_channel', r.channel || 'prod');
|
||||
setVal('edit_release_platform', r.platform || 'any');
|
||||
setVal('edit_release_version', r.version || '');
|
||||
setVal('edit_release_git_commit', r.git_commit || '');
|
||||
setVal('edit_release_download_url', r.download_url || '');
|
||||
setVal('edit_release_sha256_hash', r.sha256_hash || '');
|
||||
setVal('edit_release_size_bytes', r.size_bytes || 0);
|
||||
setVal('edit_release_release_notes', r.release_notes || '');
|
||||
|
||||
const critEl = document.getElementById('edit_release_is_critical');
|
||||
if (critEl) critEl.checked = Boolean(Number(r.is_critical));
|
||||
|
||||
const modal = document.getElementById('editReleaseModal');
|
||||
if (modal) modal.classList.add('active');
|
||||
}
|
||||
|
||||
function closeEditReleaseModal() {
|
||||
const modal = document.getElementById('editReleaseModal');
|
||||
if (modal) modal.classList.remove('active');
|
||||
}
|
||||
|
||||
function openDeleteReleaseModal(id, version, product) {
|
||||
const idEl = document.getElementById('delete_release_id');
|
||||
if (idEl) idEl.value = id;
|
||||
|
||||
const verEl = document.getElementById('delete_release_version_display');
|
||||
if (verEl) verEl.textContent = 'v' + version;
|
||||
|
||||
const prodEl = document.getElementById('delete_release_product_display');
|
||||
if (prodEl) prodEl.textContent = product;
|
||||
|
||||
const modal = document.getElementById('deleteReleaseModal');
|
||||
if (modal) modal.classList.add('active');
|
||||
}
|
||||
|
||||
function closeDeleteReleaseModal() {
|
||||
const modal = document.getElementById('deleteReleaseModal');
|
||||
if (modal) modal.classList.remove('active');
|
||||
}
|
||||
|
||||
// Project Safety Deletion Modal
|
||||
function openDeleteProjectModal(id, slug, name) {
|
||||
document.getElementById('deleteProjId').value = id;
|
||||
|
||||
Reference in New Issue
Block a user