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
207 lines
9.3 KiB
PHP
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'));
|
|
}
|
|
}
|