Files
ADconsle/app/Http/Controllers/Admin/ConfigController.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

207 lines
9.3 KiB
PHP

<?php
namespace App\Http\Controllers\Admin;
use App\Exceptions\ConfigBackupException;
use App\Http\Controllers\Controller;
use App\Services\AuditLogger;
use App\Services\ConfigBackupService;
use App\Services\PanelSettingsService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
class ConfigController extends Controller
{
public function __construct(protected AuditLogger $audit) {}
public function index(PanelSettingsService $settings)
{
return view('admin.config.index', [
'settings' => $settings->all(),
'attributes' => $settings->attributes(),
'attributeCategories' => $settings->categories(),
'attributeTypeLabels' => PanelSettingsService::ATTRIBUTE_TYPE_LABELS,
'editableAttributeTypes' => PanelSettingsService::EDITABLE_ATTRIBUTE_TYPES,
'ldapDefaults' => [
'user_ou' => config('ldap.defaults.user_ou'),
'computer_ou' => config('ldap.defaults.computer_ou'),
'groups_ou' => config('ldap.defaults.groups_ou'),
'domain_label' => config('ldap.defaults.domain_label'),
],
'ldapConnection' => [
'hosts' => config('ldap.connections.default.hosts'),
'base_dn' => config('ldap.connections.default.base_dn'),
'encrypted' => (bool) config('ldap.connections.default.use_tls')
|| (bool) config('ldap.connections.default.use_starttls'),
'username' => config('ldap.connections.default.username'),
],
]);
}
public function update(Request $request, PanelSettingsService $settings): RedirectResponse
{
$data = $request->validate([
'reset_password.must_change_password' => ['nullable', 'boolean'],
'reset_password.unlock_account' => ['nullable', 'boolean'],
'password_policy.max_age_days' => ['required', 'integer', 'min:0', 'max:3650'],
'password_policy.generator_length' => ['nullable', 'integer', 'min:8', 'max:128'],
'password_policy.generator_symbols' => ['nullable', 'boolean'],
'encryption.key' => ['nullable', 'string', 'max:2048'],
'audit.require_reason' => ['nullable', 'boolean'],
]);
$current = $settings->all();
$current['reset_password']['must_change_password'] = (bool) ($data['reset_password']['must_change_password'] ?? false);
$current['reset_password']['unlock_account'] = (bool) ($data['reset_password']['unlock_account'] ?? false);
$current['password_policy']['max_age_days'] = (int) $data['password_policy']['max_age_days'];
$current['password_policy']['generator_length'] = (int) ($data['password_policy']['generator_length'] ?? 16);
$current['password_policy']['generator_symbols'] = (bool) ($data['password_policy']['generator_symbols'] ?? false);
$current['encryption']['key'] = trim((string) ($data['encryption']['key'] ?? ''));
$current['audit']['require_reason'] = (bool) ($data['audit']['require_reason'] ?? false);
$settings->save($current);
$this->audit->log('config.settings', [
'context' => [
'max_age_days' => $current['password_policy']['max_age_days'],
'require_reason' => $current['audit']['require_reason'],
'encryption_key_set' => $current['encryption']['key'] !== '',
],
]);
return back()->with('status', __('config.flash.saved'));
}
/**
* Zapisuje graficznie skonfigurowaną listę atrybutów AD (zakładka "Atrybuty"):
* dodanie / edycja / usunięcie, widoczność jako kolumna listy użytkowników
* oraz przypisanie do kategorii w oknie podglądu użytkownika.
*/
public function updateAttributes(Request $request, PanelSettingsService $settings): RedirectResponse
{
$validCategories = array_keys($settings->categories());
$data = $request->validate([
'attributes' => ['array'],
'attributes.*.key' => ['nullable', 'string', 'max:64', 'regex:/^[A-Za-z0-9_-]*$/'],
'attributes.*.label' => ['nullable', 'string', 'max:128'],
'attributes.*.ldap' => ['nullable', 'string', 'max:128', 'regex:/^[A-Za-z0-9-]*$/'],
'attributes.*.category' => ['nullable', 'string'],
'attributes.*.type' => ['nullable', 'string', 'in:'.implode(',', PanelSettingsService::ATTRIBUTE_TYPES)],
'attributes.*.column' => ['nullable', 'boolean'],
'attributes.*.regex' => ['nullable', 'string', 'max:191', function (string $attribute, mixed $value, \Closure $fail) {
if (is_string($value) && $value !== '' && @preg_match($value, '') === false) {
$fail(__('config.validation.regex_invalid'));
}
}],
'attributes.*.required' => ['nullable', 'boolean'],
'attributes.*.help' => ['nullable', 'string', 'max:255'],
], [
'attributes.*.key.regex' => __('config.validation.key_regex'),
'attributes.*.ldap.regex' => __('config.validation.ldap_regex'),
]);
$computedKeys = PanelSettingsService::COMPUTED_ATTRIBUTE_KEYS;
$rows = collect($data['attributes'] ?? [])
->map(function (array $row) use ($validCategories, $computedKeys) {
$key = trim((string) ($row['key'] ?? ''));
$category = $row['category'] ?? 'general';
$isComputed = in_array($key, $computedKeys, true);
return [
'key' => $key,
'label' => trim((string) ($row['label'] ?? '')),
// Kolumny wyliczane nie mają atrybutu AD - podstawiamy klucz,
// żeby przeszły przez filtr; PanelSettingsService i tak to wymusi.
'ldap' => $isComputed ? $key : trim((string) ($row['ldap'] ?? '')),
'category' => in_array($category, $validCategories, true) ? $category : 'general',
// Typ (tekst / liczba / data / tekst szyfrowany ...) - dla
// pozycji wbudowanych i tak wymusi go PanelSettingsService.
'type' => (string) ($row['type'] ?? 'text'),
// Reguły walidacji pola profilu - PanelSettingsService czyści
// je dla pozycji nieedytowalnych.
'regex' => trim((string) ($row['regex'] ?? '')),
'required' => (bool) ($row['required'] ?? false),
'help' => trim((string) ($row['help'] ?? '')),
'column' => (bool) ($row['column'] ?? false),
];
})
->filter(fn (array $row) => $row['key'] !== '' && $row['ldap'] !== '')
->values()
->all();
$settings->saveAttributes($rows);
$this->audit->log('config.attributes', [
'context' => ['count' => count($rows)],
]);
return back()
->with('status', __('config.flash.attributes_saved'))
->withFragment('attributes');
}
/**
* Pobranie kopii zapasowej konfiguracji panelu jako plik JSON: ustawienia
* ogólne + definicje atrybutów (bez danych z AD). Parametr `exclude_key=1`
* pomija w pliku klucz szyfrujący atrybuty.
*/
public function export(Request $request, ConfigBackupService $backup): JsonResponse
{
$includeKey = ! $request->boolean('exclude_key');
$payload = $backup->export($includeKey);
$this->audit->log('config.export', [
'context' => [
'attributes' => count($payload['attributes']),
'encryption_key_included' => $payload['encryption_key_included'],
],
]);
$filename = 'adconsole-config-'.now()->format('Ymd-His').'.json';
return response()->json($payload, 200, [
'Content-Disposition' => 'attachment; filename="'.$filename.'"',
'Cache-Control' => 'no-store',
], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
}
/**
* Wczytanie kopii zapasowej z pliku JSON. Nadpisuje ustawienia ogólne i/lub
* listę atrybutów zawartością pliku; sekcje nieobecne w pliku zostają bez
* zmian. Błąd formatu wraca jako błąd walidacji pola `config_file`.
*/
public function import(Request $request, ConfigBackupService $backup): RedirectResponse
{
$request->validate([
'config_file' => ['required', 'file', 'max:1024', 'mimes:json,txt'],
]);
$raw = (string) file_get_contents($request->file('config_file')->getRealPath());
$data = json_decode($raw, true);
try {
if (json_last_error() !== JSON_ERROR_NONE) {
throw new ConfigBackupException(__('config.backup.error_not_json'));
}
$result = $backup->import($data);
} catch (ConfigBackupException $e) {
return back()->withErrors(['config_file' => $e->getMessage()]);
}
$this->audit->log('config.import', [
'context' => [
'settings' => $result['settings'],
'attributes' => $result['attributes'],
],
]);
return back()->with('status', __('config.backup.flash_imported'));
}
}