Files
ADconsle/app/Services/ConfigBackupService.php
T
kzbkowskiandClaude Sonnet 5 b313c126ce
CI / PHP 8.2 (push) Failing after 1m40s
CI / PHP 8.3 (push) Failing after 55s
CI / PHP 8.4 (push) Failing after 1m11s
Release image / image (push) Successful in 2m33s
ADconsole v1.1.0
Config backup, a Docker dev/prod split, and a Gitea-built release image.

Added
- /settings -> General -> "Configuration backup": JSON export/import of the
  panel's general settings + attribute definitions
  (App\Services\ConfigBackupService); audited as config.export / config.import.
  Export can leave out the attribute-encryption key. No AD data included.
- docker/compose.dev.yaml: live-reload dev stack that bind-mounts the working
  tree. docker/php/Dockerfile is now multi-stage (base -> dev / prod).
- .github/workflows/release.yml: on every vX.Y.Z tag, builds the prod image and
  pushes gitea.kzbikowski.pl/kzbkowski/adconsole:{X.Y.Z,X.Y,latest}.

Changed
- docker/compose.yaml: self-contained prod image (app code + composer install
  --no-dev baked in via COPY); persistent state in docker/data/{app_storage,db};
  defaults to the released image (ADCONSOLE_VERSION pins a tag, ADCONSOLE_IMAGE
  replaces the ref). Apache now honours public/.htaccess (AllowOverride All).
  Run from the repo root with --env-file .env.
- Repo root now holds only README.md; CHANGELOG / CONTRIBUTING / SECURITY /
  CODE_OF_CONDUCT moved under docs/.

Upgrade notes: docs/upgrading.md#v110--docker-layout. Full log: docs/CHANGELOG.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JKT9p3bfP8RZesExdYCS52
2026-09-10 09:13:04 +02:00

189 lines
7.4 KiB
PHP

<?php
namespace App\Services;
use App\Exceptions\ConfigBackupException;
/**
* Eksport i import konfiguracji panelu (kopia zapasowa do pobrania):
* - ustawienia ogólne (wiersz 'panel' w `panel_settings`),
* - definicje atrybutów AD i kolumn listy (`panel_attributes`).
*
* Nie dotyka danych z Active Directory ani infrastruktury Laravela
* (sesje / cache / kolejki / dziennik `panel_audit`). Cały odczyt i zapis
* konfiguracji idzie przez PanelSettingsService - ta klasa tylko pakuje go
* do pliku JSON i z powrotem.
*/
class ConfigBackupService
{
/** Znacznik formatu pliku kopii zapasowej (pole `format`). */
public const FORMAT = 'adconsole-config';
/** Najwyższa obsługiwana wersja schematu pliku (pole `version`). */
public const SCHEMA_VERSION = 1;
public function __construct(protected PanelSettingsService $settings) {}
/**
* Buduje strukturę kopii zapasowej gotową do zapisania jako JSON.
*
* @param bool $includeEncryptionKey gdy false, klucz szyfrujący atrybuty
* nie trafia do pliku (bezpieczniejsze
* przy przesyłaniu / archiwizacji).
* @return array<string, mixed>
*/
public function export(bool $includeEncryptionKey = true): array
{
$general = $this->settings->all();
if (! $includeEncryptionKey) {
$general['encryption']['key'] = '';
}
$keyIncluded = ($general['encryption']['key'] ?? '') !== '';
return [
'format' => self::FORMAT,
'version' => self::SCHEMA_VERSION,
'generator' => 'ADconsole',
'exported_at' => now()->toIso8601String(),
'encryption_key_included' => $keyIncluded,
'settings' => $general,
'attributes' => array_map(
static fn (array $a): array => [
'key' => $a['key'],
'label' => $a['label'],
'ldap' => $a['ldap'],
'category' => $a['category'],
'type' => $a['type'],
'regex' => $a['regex'],
'required' => $a['required'],
'help' => $a['help'],
'column' => $a['column'],
],
$this->settings->attributes(),
),
];
}
/**
* Waliduje i wgrywa kopię zapasową. Nadpisuje ustawienia ogólne i/lub
* listę atrybutów tym, co jest w pliku; sekcje nieobecne w pliku zostają
* bez zmian.
*
* @param mixed $data zdekodowana zawartość pliku (oczekiwana: tablica)
* @return array{settings: bool, attributes: int} co zostało wgrane
*
* @throws ConfigBackupException gdy plik jest pusty, w złym formacie albo
* pochodzi z nowszej wersji panelu
*/
public function import(mixed $data): array
{
if (! is_array($data)) {
throw new ConfigBackupException(__('config.backup.error_not_json'));
}
if (($data['format'] ?? null) !== self::FORMAT) {
throw new ConfigBackupException(__('config.backup.error_bad_format'));
}
$version = (int) ($data['version'] ?? 0);
if ($version < 1 || $version > self::SCHEMA_VERSION) {
throw new ConfigBackupException(__('config.backup.error_bad_version'));
}
$hasSettings = isset($data['settings']) && is_array($data['settings']);
$hasAttributes = isset($data['attributes']) && is_array($data['attributes']);
if (! $hasSettings && ! $hasAttributes) {
throw new ConfigBackupException(__('config.backup.error_empty'));
}
$applied = ['settings' => false, 'attributes' => 0];
if ($hasSettings) {
$this->settings->save($this->sanitizeSettings($data['settings']));
$applied['settings'] = true;
}
if ($hasAttributes) {
// saveAttributes() sam normalizuje wiersze, wymusza reguły pozycji
// systemowych / wyliczanych i odtwarza te, których w pliku brak.
$this->settings->saveAttributes($this->sanitizeAttributes($data['attributes']));
$applied['attributes'] = count($this->settings->attributes());
}
return $applied;
}
/**
* Sprowadza dowolne wejście do kształtu ustawień ogólnych: start od
* wbudowanych domyślnych, nadpisanie wyłącznie znanych kluczy, z kontrolą
* typu i zakresu. Odpowiada regułom z ConfigController::update(), żeby plik
* z ręcznie podmienioną wartością nie wprowadził panelu w zły stan.
*
* @param array<string, mixed> $in
* @return array<string, mixed>
*/
protected function sanitizeSettings(array $in): array
{
$d = $this->settings->defaults();
$reset = is_array($in['reset_password'] ?? null) ? $in['reset_password'] : [];
$policy = is_array($in['password_policy'] ?? null) ? $in['password_policy'] : [];
$enc = is_array($in['encryption'] ?? null) ? $in['encryption'] : [];
$audit = is_array($in['audit'] ?? null) ? $in['audit'] : [];
return [
'reset_password' => [
'must_change_password' => filter_var($reset['must_change_password'] ?? $d['reset_password']['must_change_password'], FILTER_VALIDATE_BOOL),
'unlock_account' => filter_var($reset['unlock_account'] ?? $d['reset_password']['unlock_account'], FILTER_VALIDATE_BOOL),
],
'password_policy' => [
'max_age_days' => max(0, min(3650, (int) ($policy['max_age_days'] ?? $d['password_policy']['max_age_days']))),
'generator_length' => max(8, min(128, (int) ($policy['generator_length'] ?? $d['password_policy']['generator_length']))),
'generator_symbols' => filter_var($policy['generator_symbols'] ?? $d['password_policy']['generator_symbols'], FILTER_VALIDATE_BOOL),
],
'encryption' => [
'key' => trim((string) ($enc['key'] ?? '')),
],
'audit' => [
'require_reason' => filter_var($audit['require_reason'] ?? $d['audit']['require_reason'], FILTER_VALIDATE_BOOL),
],
];
}
/**
* Sprowadza listę atrybutów z pliku do wierszy przyjmowanych przez
* PanelSettingsService::saveAttributes() (ta klasa dokończy normalizację).
* Wiersze bez klucza są pomijane.
*
* @param array<int|string, mixed> $in
* @return list<array<string, mixed>>
*/
protected function sanitizeAttributes(array $in): array
{
$rows = [];
foreach ($in as $row) {
if (! is_array($row) || (string) ($row['key'] ?? '') === '') {
continue;
}
$rows[] = [
'key' => (string) ($row['key'] ?? ''),
'label' => (string) ($row['label'] ?? ''),
'ldap' => (string) ($row['ldap'] ?? ''),
'category' => (string) ($row['category'] ?? 'general'),
'type' => (string) ($row['type'] ?? 'text'),
'regex' => (string) ($row['regex'] ?? ''),
'required' => filter_var($row['required'] ?? false, FILTER_VALIDATE_BOOL),
'help' => (string) ($row['help'] ?? ''),
'column' => filter_var($row['column'] ?? false, FILTER_VALIDATE_BOOL),
];
}
return $rows;
}
}