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
This commit is contained in:
1 parent
1126d96271
commit
b313c126ce
35 files changed
+1174
-119
No files matched your search
+14
-4
@@ -1,6 +1,9 @@
|
||||
# Build-context trimming for `docker build` (docker/php/Dockerfile).
|
||||
# The compose stack bind-mounts the project at runtime, so the image only needs
|
||||
# the entrypoint; keep the context small.
|
||||
# Build context for docker/php/Dockerfile (context = repo root).
|
||||
#
|
||||
# prod target — COPYs the application in, so keep app code but drop
|
||||
# everything the image rebuilds or must never ship (vendor/,
|
||||
# node_modules/, .env, tests/, docs/, host state in ./data).
|
||||
# dev target — bakes nothing; compose.dev.yaml bind-mounts the repo.
|
||||
|
||||
.git
|
||||
.github
|
||||
@@ -37,5 +40,12 @@ phpunit.xml
|
||||
.phpunit.cache
|
||||
|
||||
*.md
|
||||
.dockerignore
|
||||
|
||||
# Compose files + local overrides + persistent state — never needed in the image.
|
||||
docker/compose.yaml
|
||||
docker/compose.dev.yaml
|
||||
docker/compose.override.yaml
|
||||
docker/compose.override.yml
|
||||
docker/compose.dev.override.yaml
|
||||
docker/*.template
|
||||
docker/data
|
||||
+9
-1
@@ -21,6 +21,13 @@ APP_URL=https://adconsole.example.com
|
||||
# bare-metal install.
|
||||
APP_DOMAIN=adconsole.example.com
|
||||
|
||||
# Docker image for docker/compose.yaml. By default it pulls the release the
|
||||
# Gitea workflow published; ADCONSOLE_VERSION pins a tag, ADCONSOLE_IMAGE
|
||||
# replaces the whole reference (e.g. a private mirror). Ignored on bare metal
|
||||
# and by `docker compose ... up --build` (which rebuilds locally).
|
||||
# ADCONSOLE_VERSION=1.1.0
|
||||
# ADCONSOLE_IMAGE=gitea.kzbikowski.pl/kzbkowski/adconsole:1.1.0
|
||||
|
||||
# Interface language: pl | en. FALLBACK is used for missing translation keys.
|
||||
APP_LOCALE=pl
|
||||
APP_FALLBACK_LOCALE=en
|
||||
@@ -48,7 +55,8 @@ DB_USERNAME=adconsole
|
||||
DB_PASSWORD=
|
||||
|
||||
# Only read by docker/compose.yaml when it first initialises the MariaDB
|
||||
# volume. Changing them later does NOT change an existing database.
|
||||
# data dir (docker/data/db). Changing them later does NOT change an existing
|
||||
# database.
|
||||
DB_ROOT_PASSWORD=
|
||||
|
||||
# --- Session / cache / queue --------------------------------------------
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Security vulnerability
|
||||
url: https://gitea.kzbikowski.pl/kzbkowski/ADconsle/src/branch/main/SECURITY.md
|
||||
about: Do not file security issues publicly — email the maintainer (see SECURITY.md).
|
||||
url: https://gitea.kzbikowski.pl/kzbkowski/ADconsle/src/branch/main/docs/SECURITY.md
|
||||
about: Do not file security issues publicly — email the maintainer (see docs/SECURITY.md).
|
||||
- name: Question / help
|
||||
url: https://gitea.kzbikowski.pl/kzbkowski/ADconsle/issues
|
||||
about: Deployment and configuration questions — search existing issues first.
|
||||
@@ -10,7 +10,7 @@
|
||||
- [ ] **Database migration** included
|
||||
- [ ] New **`.env` key** — added to `.env.example` and `docs/configuration.md`
|
||||
- [ ] New user-facing text — added to **both** `lang/pl` and `lang/en`
|
||||
- [ ] `CHANGELOG.md` updated (Unreleased)
|
||||
- [ ] `docs/CHANGELOG.md` updated (Unreleased)
|
||||
|
||||
## Checks
|
||||
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
name: Release image
|
||||
|
||||
# Builds the self-contained production image (docker/php/Dockerfile, target
|
||||
# `prod`) and pushes it to this Gitea instance's container registry whenever a
|
||||
# vX.Y.Z tag is pushed. `workflow_dispatch` builds an untagged snapshot.
|
||||
#
|
||||
# gitea.kzbikowski.pl/kzbkowski/adconsole:1.1.0
|
||||
# gitea.kzbikowski.pl/kzbkowski/adconsole:1.1
|
||||
# gitea.kzbikowski.pl/kzbkowski/adconsole:latest
|
||||
#
|
||||
# Needs `packages: write`. The built-in GITHUB_TOKEN works for the repo owner's
|
||||
# package namespace; set a `REGISTRY_TOKEN` secret if the instance restricts it.
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ['v*']
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
env:
|
||||
REGISTRY: gitea.kzbikowski.pl
|
||||
IMAGE_NAME: ${{ github.repository_owner }}/adconsole
|
||||
|
||||
jobs:
|
||||
image:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to the container registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.REGISTRY_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Image metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=raw,value=latest,enable=${{ github.ref_type == 'tag' }}
|
||||
type=sha,enable=${{ github.ref_type != 'tag' }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: docker/php/Dockerfile
|
||||
target: prod
|
||||
push: true
|
||||
provenance: false
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
@@ -32,6 +32,10 @@ Thumbs.db
|
||||
# Local Docker overrides (ports, extra services) - not for distribution.
|
||||
/docker/compose.override.yaml
|
||||
/docker/compose.override.yml
|
||||
/docker/compose.dev.override.yaml
|
||||
|
||||
# Persistent state for the production stack (docker/compose.yaml): DB + storage.
|
||||
/docker/data/
|
||||
|
||||
# AI assistant / editor local state.
|
||||
/.claude
|
||||
|
||||
+1
-1
@@ -38,7 +38,7 @@ dashboard and an org chart. PHP 8.2+. Interface is PL/EN.
|
||||
| `app/Http/Controllers/Admin/*` | one controller per module (Users, Computers, Groups, Gpo, OrgChart, Dashboard, Audit, Config) |
|
||||
| `app/Ldap/User.php` | AD user model + attribute map + `toPanelProfile()` |
|
||||
| `app/Ldap/Rules/MemberOfLoginGroup.php` | login authorisation rule |
|
||||
| `app/Services/` | `PanelSettingsService` (config, DB-backed), `AttributeCipher`, `LdapAttributeHelper` (UAC bits, FileTime), `OuTreeService` (cached OU tree), `AuditLogger` (writes `panel_audit`; never throws — a log failure is only `report()`ed) |
|
||||
| `app/Services/` | `PanelSettingsService` (config, DB-backed), `ConfigBackupService` (JSON export/import of the panel config), `AttributeCipher`, `LdapAttributeHelper` (UAC bits, FileTime), `OuTreeService` (cached OU tree), `AuditLogger` (writes `panel_audit`; never throws — a log failure is only `report()`ed) |
|
||||
| `app/Models/PanelSetting.php` | key→JSON row: general panel config (`panel_settings`) |
|
||||
| `app/Models/PanelAttribute.php` | one row per configured AD attribute (`panel_attributes`, ordered by `position`) |
|
||||
| `app/Models/AuditLog.php` | one row per audited panel action (`panel_audit`); written only via `AuditLogger`, read only by `AuditController` |
|
||||
|
||||
+2
-2
@@ -13,10 +13,10 @@ from the repository root.
|
||||
|
||||
Human-facing documentation (installation, configuration, deployment,
|
||||
troubleshooting, …) lives in [`../docs/`](../docs/). Contribution rules are in
|
||||
[`../CONTRIBUTING.md`](../CONTRIBUTING.md).
|
||||
[`../docs/CONTRIBUTING.md`](../docs/CONTRIBUTING.md).
|
||||
|
||||
## Keeping it current
|
||||
|
||||
Update `AGENTS.md` whenever you change the architecture, the golden rules, the
|
||||
top-level layout, or the commands in the dev loop. If you rename or move it,
|
||||
update the `@`-import in `../CLAUDE.md` and the links in `../CONTRIBUTING.md`.
|
||||
update the `@`-import in `../CLAUDE.md` and the links in `../docs/CONTRIBUTING.md`.
|
||||
@@ -24,7 +24,7 @@ is no database copy of your directory to drift out of sync.
|
||||
PHP 8.2+ host.
|
||||
|
||||
> ⚠️ ADconsole performs privileged directory operations (password resets,
|
||||
> account enable/disable, deletions). Read [`SECURITY.md`](SECURITY.md) and the
|
||||
> account enable/disable, deletions). Read [`SECURITY.md`](docs/SECURITY.md) and the
|
||||
> [hardening checklist](docs/deployment.md#hardening-checklist) before exposing it.
|
||||
|
||||
---
|
||||
@@ -64,17 +64,23 @@ git clone https://gitea.kzbikowski.pl/kzbkowski/ADconsle.git adconsole && cd adc
|
||||
cp .env.example .env
|
||||
# edit .env: APP_URL/APP_DOMAIN, DB_* passwords, and the whole LDAP_* / AD_* block
|
||||
|
||||
# With an external Traefik proxy (see docker/compose.yaml):
|
||||
docker compose -f docker/compose.yaml up -d --build
|
||||
# Production — pulls the released image, external Traefik proxy. Run from the
|
||||
# repo root and point Compose at the repo-root .env:
|
||||
docker compose --env-file .env -f docker/compose.yaml up -d
|
||||
|
||||
# Or without a proxy — publish a local port instead:
|
||||
cp docker/compose.override.yaml.template docker/compose.override.yaml
|
||||
docker compose -f docker/compose.yaml -f docker/compose.override.yaml up -d --build
|
||||
docker compose --env-file .env -f docker/compose.yaml -f docker/compose.override.yaml up -d
|
||||
# -> http://localhost:8080 (set APP_URL to match)
|
||||
|
||||
# Live-reload dev stack (bind-mounts the working tree):
|
||||
docker compose --env-file .env -f docker/compose.dev.yaml up --build
|
||||
```
|
||||
|
||||
The container entrypoint installs dependencies, generates `APP_KEY`, runs
|
||||
migrations and warms the caches on first boot. Details and production notes:
|
||||
`docker/compose.yaml` runs the self-contained image the Gitea release workflow
|
||||
publishes (`…/adconsole:1.1.0`); `up -d` pulls it, `--build` rebuilds from your
|
||||
tree. Only `.env` and `docker/data/` (DB + storage) are mounted. The entrypoint
|
||||
generates `APP_KEY`, runs migrations and warms the caches on first boot. Details:
|
||||
[`docs/deployment.md`](docs/deployment.md).
|
||||
|
||||
## Quick start — bare metal
|
||||
@@ -148,7 +154,9 @@ public/js/main.js hand-written JS
|
||||
routes/web.php English URL segments and route names
|
||||
lang/{pl,en}/ structured translation keys
|
||||
docs/ installation / AD / deployment / architecture / …
|
||||
docker/ compose stack + PHP image
|
||||
docker/compose.yaml production stack (self-contained image + MariaDB)
|
||||
docker/compose.dev.yaml live-reload dev stack (bind-mounts the working tree)
|
||||
docker/php/ PHP 8.4 + Apache Dockerfile (base/dev/prod) + entrypoint
|
||||
```
|
||||
|
||||
## Documentation
|
||||
@@ -164,6 +172,10 @@ Everything lives in [`docs/`](docs/):
|
||||
[upgrading](docs/upgrading.md) ·
|
||||
[publishing](docs/publishing.md).
|
||||
|
||||
Project meta: [changelog](docs/CHANGELOG.md) ·
|
||||
[contributing](docs/CONTRIBUTING.md) ·
|
||||
[security policy](docs/SECURITY.md).
|
||||
|
||||
## Roadmap / non-goals
|
||||
|
||||
Planned: server-side password-policy validation before write; optional TOTP for
|
||||
@@ -193,10 +205,10 @@ When you deploy ADconsole your organisation is the **data controller** for the
|
||||
personal data it reads from AD (names, logins, e-mail, phone, photo, …). You are
|
||||
responsible for the lawful basis, retention and data-subject rights; restrict
|
||||
panel access to authorised admins and always use LDAPS/STARTTLS. See the
|
||||
in-app **About → Legal** page and [`SECURITY.md`](SECURITY.md).
|
||||
in-app **About → Legal** page and [`SECURITY.md`](docs/SECURITY.md).
|
||||
|
||||
## Contributing
|
||||
|
||||
Issues and PRs welcome — see [`CONTRIBUTING.md`](CONTRIBUTING.md) and the
|
||||
[`CODE_OF_CONDUCT.md`](CODE_OF_CONDUCT.md). Security reports:
|
||||
[`SECURITY.md`](SECURITY.md).
|
||||
Issues and PRs welcome — see [`CONTRIBUTING.md`](docs/CONTRIBUTING.md) and the
|
||||
[`CODE_OF_CONDUCT.md`](docs/CODE_OF_CONDUCT.md). Security reports:
|
||||
[`SECURITY.md`](docs/SECURITY.md).
|
||||
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
namespace App\Exceptions;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Wgrywany plik kopii zapasowej konfiguracji jest nieczytelny albo w
|
||||
* nieobsługiwanym formacie. Łapane w App\Http\Controllers\Admin\ConfigController
|
||||
* i zamieniane na błąd walidacji pola formularza.
|
||||
*/
|
||||
class ConfigBackupException extends RuntimeException {}
|
||||
@@ -2,9 +2,12 @@
|
||||
|
||||
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;
|
||||
|
||||
@@ -140,4 +143,64 @@ class ConfigController extends Controller
|
||||
->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'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
# =============================================================================
|
||||
# Local / no-Traefik override for the development stack (docker/compose.dev.yaml).
|
||||
#
|
||||
# cp docker/compose.dev.override.yaml.template docker/compose.dev.override.yaml
|
||||
# docker compose --env-file .env \
|
||||
# -f docker/compose.dev.yaml -f docker/compose.dev.override.yaml up --build
|
||||
#
|
||||
# The app is published on http://localhost:8080 and the external
|
||||
# `traefik_public` network becomes a normal local one. Set
|
||||
# APP_URL=http://localhost:8080 in .env.
|
||||
# =============================================================================
|
||||
|
||||
services:
|
||||
app:
|
||||
ports:
|
||||
- "8080:80"
|
||||
networks:
|
||||
- internal
|
||||
labels: []
|
||||
|
||||
networks:
|
||||
traefik_public:
|
||||
external: false
|
||||
@@ -0,0 +1,94 @@
|
||||
name: adconsole-dev
|
||||
|
||||
# =============================================================================
|
||||
# Development stack — live-reload. The working tree is bind-mounted over
|
||||
# /var/www/html, so edits to PHP / Blade / assets take effect on the next
|
||||
# request (opcache revalidates every hit in the `dev` image target). The
|
||||
# entrypoint runs `composer install` (incl. dev deps) and `key:generate` on
|
||||
# the first boot of a fresh clone.
|
||||
#
|
||||
# Persistent state is kept in named volumes (not docker/data), so `migrate:fresh`
|
||||
# and `down -v` behave like a throwaway dev box:
|
||||
#
|
||||
# app_storage -> /var/www/html/storage
|
||||
# db_data -> /var/lib/mysql
|
||||
#
|
||||
# Run from the repo root:
|
||||
# cp .env.example .env # APP_ENV=local, APP_DEBUG=true, real LDAP_*
|
||||
# docker compose --env-file .env -f docker/compose.dev.yaml up --build
|
||||
#
|
||||
# Reverse proxy: an external Traefik on `traefik_public`, same as production.
|
||||
# Without Traefik, uncomment the `ports:` block below and open
|
||||
# http://localhost:8080 (set APP_URL to match).
|
||||
# =============================================================================
|
||||
|
||||
services:
|
||||
app:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: docker/php/Dockerfile
|
||||
target: dev
|
||||
container_name: adconsole_dev_app
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
# Reach the DB service by name regardless of DB_HOST in the bind-mounted
|
||||
# .env (Laravel's dotenv does not override a real env var). Kept minimal so
|
||||
# `artisan test` inside the container still picks up phpunit.xml's env.
|
||||
DB_HOST: mariadb
|
||||
volumes:
|
||||
- ..:/var/www/html
|
||||
- app_storage:/var/www/html/storage
|
||||
# ports:
|
||||
# - "8080:80"
|
||||
depends_on:
|
||||
mariadb:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-fsS", "http://localhost/up"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 120s
|
||||
networks:
|
||||
- internal
|
||||
- traefik_public
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.docker.network=traefik_public
|
||||
- traefik.http.routers.adconsole-dev.rule=Host(`${APP_DOMAIN:-adconsole.localhost}`)
|
||||
- traefik.http.routers.adconsole-dev.entrypoints=websecure
|
||||
- traefik.http.routers.adconsole-dev.tls=true
|
||||
- traefik.http.services.adconsole-dev.loadbalancer.server.port=80
|
||||
|
||||
mariadb:
|
||||
image: mariadb:11
|
||||
container_name: adconsole_dev_db
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
MARIADB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD:-change-me-root}
|
||||
MARIADB_DATABASE: ${DB_DATABASE:-adconsole}
|
||||
MARIADB_USER: ${DB_USERNAME:-adconsole}
|
||||
MARIADB_PASSWORD: ${DB_PASSWORD:-change-me}
|
||||
MARIADB_AUTO_UPGRADE: "1"
|
||||
volumes:
|
||||
- db_data:/var/lib/mysql
|
||||
# Expose the DB on the host for a GUI client.
|
||||
ports:
|
||||
- "3306:3306"
|
||||
healthcheck:
|
||||
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
start_period: 30s
|
||||
networks:
|
||||
- internal
|
||||
|
||||
volumes:
|
||||
app_storage:
|
||||
db_data:
|
||||
|
||||
networks:
|
||||
internal:
|
||||
traefik_public:
|
||||
external: true
|
||||
@@ -1,14 +1,16 @@
|
||||
# =============================================================================
|
||||
# Local / no-Traefik override.
|
||||
# Local / no-Traefik override for the production stack (docker/compose.yaml).
|
||||
#
|
||||
# Copy to docker/compose.override.yaml (git-ignored) to run the stack without
|
||||
# a reverse proxy - the app is published straight on a host port and the
|
||||
# external `traefik_public` network is neutralised.
|
||||
# Copy it, then pass both files (the override is NOT auto-merged because it
|
||||
# lives under docker/, not the project root):
|
||||
#
|
||||
# cp docker/compose.override.yaml.template docker/compose.override.yaml
|
||||
# docker compose -f docker/compose.yaml -f docker/compose.override.yaml up -d --build
|
||||
# docker compose --env-file .env \
|
||||
# -f docker/compose.yaml -f docker/compose.override.yaml up -d --build
|
||||
#
|
||||
# Then open http://localhost:8080 (set APP_URL=http://localhost:8080 in .env).
|
||||
# The app is published on a host port and the external `traefik_public`
|
||||
# network becomes a normal local one. Open http://localhost:8080
|
||||
# (set APP_URL=http://localhost:8080 in .env).
|
||||
# =============================================================================
|
||||
|
||||
services:
|
||||
|
||||
+46
-16
@@ -1,30 +1,64 @@
|
||||
name: adconsole
|
||||
|
||||
# =============================================================================
|
||||
# Production-ish Compose stack: PHP/Apache app + MariaDB.
|
||||
# Production stack — a self-contained PHP/Apache image + MariaDB, behind an
|
||||
# external Traefik proxy on the `traefik_public` network.
|
||||
#
|
||||
# Reverse proxy: this file assumes an external Traefik instance on the
|
||||
# `traefik_public` network. If you don't use Traefik, drop in a local override
|
||||
# that publishes a port instead - see docker/compose.override.yaml.template
|
||||
# and docs/deployment.md.
|
||||
# By default this runs the image the Gitea release workflow publishes
|
||||
# (built from docker/php/Dockerfile, target `prod` — code + `composer install
|
||||
# --no-dev` baked in). `up -d` pulls it; `up -d --build` rebuilds from the
|
||||
# working tree instead. Nothing from the tree is mounted except `.env`.
|
||||
# Persistent state lives next to this file:
|
||||
#
|
||||
# Run from the repo root:
|
||||
# docker compose -f docker/compose.yaml up -d --build
|
||||
# docker/data/app_storage -> /var/www/html/storage (logs, framework cache)
|
||||
# docker/data/db -> /var/lib/mysql (database — BACK UP)
|
||||
#
|
||||
# Run FROM THE REPO ROOT and point Compose at the repo-root .env for
|
||||
# ${...} interpolation:
|
||||
#
|
||||
# cp .env.example .env # then fill it in
|
||||
# docker compose --env-file .env -f docker/compose.yaml up -d
|
||||
# docker compose --env-file .env -f docker/compose.yaml logs -f app
|
||||
#
|
||||
# No Traefik? Add the local port-publishing override:
|
||||
# cp docker/compose.override.yaml.template docker/compose.override.yaml
|
||||
# docker compose --env-file .env \
|
||||
# -f docker/compose.yaml -f docker/compose.override.yaml up -d
|
||||
# # -> http://localhost:8080 (set APP_URL to match)
|
||||
# =============================================================================
|
||||
|
||||
services:
|
||||
app:
|
||||
# Default: the image the Gitea release workflow (.github/workflows/release.yml)
|
||||
# publishes for the current release. `docker compose ... pull` fetches it;
|
||||
# `... up -d --build` rebuilds it locally under the same name instead. Pin a
|
||||
# different build with ADCONSOLE_VERSION, or override the whole ref with
|
||||
# ADCONSOLE_IMAGE.
|
||||
image: ${ADCONSOLE_IMAGE:-gitea.kzbikowski.pl/kzbkowski/adconsole:${ADCONSOLE_VERSION:-1.1.0}}
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: docker/php/Dockerfile
|
||||
target: prod
|
||||
container_name: adconsole_app
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
# Reach the DB service by name — overrides the dev-oriented DB_HOST in the
|
||||
# bind-mounted .env (Laravel's dotenv does not override a real env var).
|
||||
DB_HOST: mariadb
|
||||
volumes:
|
||||
- ..:/var/www/html
|
||||
- app_storage:/var/www/html/storage
|
||||
# `.env` is git-ignored and holds the service-account password; it is
|
||||
# provided at runtime, not baked into the image. Laravel reads it directly.
|
||||
- ../.env:/var/www/html/.env
|
||||
- ./data/app_storage:/var/www/html/storage
|
||||
depends_on:
|
||||
mariadb:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-fsS", "http://localhost/up"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 60s
|
||||
networks:
|
||||
- internal
|
||||
- traefik_public
|
||||
@@ -49,7 +83,7 @@ services:
|
||||
MARIADB_PASSWORD: ${DB_PASSWORD:-change-me}
|
||||
MARIADB_AUTO_UPGRADE: "1"
|
||||
volumes:
|
||||
- db_data:/var/lib/mysql
|
||||
- ./data/db:/var/lib/mysql
|
||||
healthcheck:
|
||||
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
|
||||
interval: 10s
|
||||
@@ -59,13 +93,9 @@ services:
|
||||
networks:
|
||||
- internal
|
||||
|
||||
volumes:
|
||||
db_data:
|
||||
app_storage:
|
||||
|
||||
networks:
|
||||
internal:
|
||||
# Provided by your Traefik deployment. Remove this (and the `traefik_public`
|
||||
# entry + labels under `app`) if you use the local port-publishing override.
|
||||
# Provided by your Traefik deployment. If you use the port-publishing
|
||||
# override instead, it redefines this as a local network.
|
||||
traefik_public:
|
||||
external: true
|
||||
+79
-14
@@ -1,7 +1,25 @@
|
||||
FROM php:8.4-apache
|
||||
# syntax=docker/dockerfile:1
|
||||
# =============================================================================
|
||||
# ADconsole container image — PHP 8.4 + Apache. Multi-stage:
|
||||
#
|
||||
# base : PHP + extensions + Apache (docroot = public/) + Composer + entrypoint
|
||||
# dev : base only. The repo is bind-mounted at runtime (compose.dev.yaml);
|
||||
# the entrypoint installs deps + generates APP_KEY on first boot and
|
||||
# opcache re-checks files on every request, so edits are live.
|
||||
# prod : base + the application COPYed in and `composer install --no-dev`
|
||||
# run at build time → a self-contained image (docker/compose.yaml, or
|
||||
# `docker build`) that you can tag and push straight from the repo;
|
||||
# only .env and docker/data/ are mounted at runtime.
|
||||
#
|
||||
# Build context is the repo root. Default target is `prod` (last stage):
|
||||
# docker build -f docker/php/Dockerfile -t adconsole:local .
|
||||
# =============================================================================
|
||||
|
||||
FROM php:8.4-apache AS base
|
||||
|
||||
# --- System packages + PHP extensions -------------------------------------
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
curl \
|
||||
libcurl4-openssl-dev \
|
||||
libonig-dev \
|
||||
libzip-dev \
|
||||
@@ -11,21 +29,14 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
&& docker-php-ext-install -j"$(nproc)" curl mbstring zip pdo pdo_mysql ldap opcache \
|
||||
&& apt-get clean && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Sensible opcache defaults for production.
|
||||
RUN { \
|
||||
echo 'opcache.enable=1'; \
|
||||
echo 'opcache.enable_cli=0'; \
|
||||
echo 'opcache.memory_consumption=128'; \
|
||||
echo 'opcache.max_accelerated_files=20000'; \
|
||||
echo 'opcache.validate_timestamps=1'; \
|
||||
echo 'opcache.revalidate_freq=60'; \
|
||||
} > /usr/local/etc/php/conf.d/opcache.ini
|
||||
|
||||
# Apache: enable rewrite + serve from public/.
|
||||
# Apache: enable rewrite + serve from public/ + honour Laravel's public/.htaccess.
|
||||
ENV APACHE_DOCUMENT_ROOT=/var/www/html/public
|
||||
RUN a2enmod rewrite \
|
||||
RUN a2enmod rewrite headers \
|
||||
&& sed -ri -e 's!/var/www/html!${APACHE_DOCUMENT_ROOT}!g' /etc/apache2/sites-available/*.conf \
|
||||
&& sed -ri -e 's!/var/www/html!${APACHE_DOCUMENT_ROOT}!g' /etc/apache2/apache2.conf /etc/apache2/conf-available/*.conf
|
||||
&& sed -ri -e 's!/var/www/html!${APACHE_DOCUMENT_ROOT}!g' /etc/apache2/apache2.conf /etc/apache2/conf-available/*.conf \
|
||||
&& printf '<Directory ${APACHE_DOCUMENT_ROOT}>\n\tAllowOverride All\n\tRequire all granted\n</Directory>\n' \
|
||||
> /etc/apache2/conf-available/adconsole.conf \
|
||||
&& a2enconf adconsole
|
||||
|
||||
# Composer (from the official image, no network installer).
|
||||
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
|
||||
@@ -33,7 +44,61 @@ COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
|
||||
COPY docker/php/entrypoint.sh /usr/local/bin/adconsole-entrypoint
|
||||
RUN chmod +x /usr/local/bin/adconsole-entrypoint
|
||||
|
||||
ENV COMPOSER_ALLOW_SUPERUSER=1
|
||||
WORKDIR /var/www/html
|
||||
|
||||
ENTRYPOINT ["adconsole-entrypoint"]
|
||||
CMD ["apache2-foreground"]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# dev — no app code baked in; compose.dev.yaml bind-mounts the repo over
|
||||
# /var/www/html. opcache revalidates on every hit so changes show immediately.
|
||||
# =============================================================================
|
||||
FROM base AS dev
|
||||
|
||||
ENV ADCONSOLE_ENV=dev
|
||||
|
||||
RUN { \
|
||||
echo 'opcache.enable=1'; \
|
||||
echo 'opcache.enable_cli=0'; \
|
||||
echo 'opcache.validate_timestamps=1'; \
|
||||
echo 'opcache.revalidate_freq=0'; \
|
||||
} > /usr/local/etc/php/conf.d/opcache.ini
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# prod (default) — self-contained image. The build context is trimmed by
|
||||
# .dockerignore (no vendor/, node_modules/, tests/, docs/, .env, ./data …).
|
||||
# =============================================================================
|
||||
FROM base AS prod
|
||||
|
||||
ENV ADCONSOLE_ENV=prod
|
||||
|
||||
# Aggressive opcache: the code in the image never changes at runtime.
|
||||
RUN { \
|
||||
echo 'opcache.enable=1'; \
|
||||
echo 'opcache.enable_cli=0'; \
|
||||
echo 'opcache.memory_consumption=128'; \
|
||||
echo 'opcache.max_accelerated_files=20000'; \
|
||||
echo 'opcache.validate_timestamps=0'; \
|
||||
} > /usr/local/etc/php/conf.d/opcache.ini
|
||||
|
||||
# 1. Dependencies — manifests first so this layer survives code-only edits.
|
||||
COPY composer.json composer.lock ./
|
||||
RUN composer install --no-dev --no-interaction --prefer-dist --no-scripts --no-autoloader
|
||||
|
||||
# 2. Application code.
|
||||
COPY . .
|
||||
|
||||
# 3. Optimised autoloader + package discovery, then the writable runtime tree.
|
||||
RUN composer install --no-dev --no-interaction --prefer-dist --optimize-autoloader \
|
||||
&& mkdir -p \
|
||||
storage/framework/cache/data \
|
||||
storage/framework/sessions \
|
||||
storage/framework/views \
|
||||
storage/logs \
|
||||
storage/app/public \
|
||||
storage/app/private \
|
||||
bootstrap/cache \
|
||||
&& chown -R www-data:www-data storage bootstrap/cache
|
||||
+29
-10
@@ -1,21 +1,30 @@
|
||||
#!/bin/sh
|
||||
# First-run bootstrap for the ADconsole container. The project is bind-mounted,
|
||||
# so a fresh `git clone` has no vendor/ and no APP_KEY yet - handle both here so
|
||||
# `docker compose up` just works. Safe to run on every start (all steps are
|
||||
# idempotent).
|
||||
# First-run / every-boot bootstrap for the ADconsole container. Every step is
|
||||
# idempotent, so it is safe to run on each start.
|
||||
#
|
||||
# dev (ADCONSOLE_ENV=dev): the repo is bind-mounted. Install deps (incl.
|
||||
# dev), generate APP_KEY, clear caches only — no
|
||||
# config cache, so .env edits take effect on reload.
|
||||
# prod (default): code + vendor are baked into the image. Just fix
|
||||
# storage ownership, migrate and warm the caches.
|
||||
set -e
|
||||
|
||||
cd /var/www/html
|
||||
|
||||
if [ ! -f .env ]; then
|
||||
echo "[adconsole] .env is missing - copy .env.example to .env and fill it in." >&2
|
||||
echo "[adconsole] .env is missing — mount your filled-in .env at /var/www/html/.env" >&2
|
||||
echo " (cp .env.example .env, edit it, then start the stack again)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 1. PHP dependencies.
|
||||
# 1. PHP dependencies — only when absent (fresh clone in dev; never in prod).
|
||||
if [ ! -f vendor/autoload.php ]; then
|
||||
echo "[adconsole] installing composer dependencies..."
|
||||
composer install --no-dev --no-interaction --prefer-dist --optimize-autoloader
|
||||
if [ "$ADCONSOLE_ENV" = "dev" ]; then
|
||||
composer install --no-interaction --prefer-dist
|
||||
else
|
||||
composer install --no-dev --no-interaction --prefer-dist --optimize-autoloader
|
||||
fi
|
||||
fi
|
||||
|
||||
# 2. Application key.
|
||||
@@ -24,15 +33,25 @@ if ! grep -qE '^APP_KEY=.+' .env; then
|
||||
php artisan key:generate --force
|
||||
fi
|
||||
|
||||
# 3. Writable paths (bind-mounted repo is owned by the host user).
|
||||
# 3. Writable storage tree — the bind mount / volume can start empty.
|
||||
mkdir -p \
|
||||
storage/framework/cache/data \
|
||||
storage/framework/sessions \
|
||||
storage/framework/views \
|
||||
storage/logs \
|
||||
storage/app/public \
|
||||
storage/app/private \
|
||||
bootstrap/cache
|
||||
chown -R www-data:www-data storage bootstrap/cache 2>/dev/null || true
|
||||
|
||||
# 4. Database schema + storage symlink.
|
||||
php artisan migrate --force
|
||||
php artisan storage:link 2>/dev/null || true
|
||||
|
||||
# 5. Framework caches (config/route/view/event). Refreshed on every boot.
|
||||
# 5. Framework caches.
|
||||
php artisan optimize:clear
|
||||
php artisan optimize
|
||||
if [ "$ADCONSOLE_ENV" != "dev" ]; then
|
||||
php artisan optimize
|
||||
fi
|
||||
|
||||
exec "$@"
|
||||
@@ -8,12 +8,49 @@ incompatible changes to configuration, the database schema or AD-permission
|
||||
requirements; **MINOR** for backwards-compatible features; **PATCH** for
|
||||
backwards-compatible fixes. Each release is a git tag `vX.Y.Z`; the latest tag on
|
||||
`main` is the supported version. Upgrade notes live in
|
||||
[`docs/upgrading.md`](docs/upgrading.md).
|
||||
[`upgrading.md`](upgrading.md).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
_Nothing yet._
|
||||
|
||||
## [1.1.0] - 2026-09-10
|
||||
|
||||
### Added
|
||||
|
||||
- **Configuration backup.** `/settings` → *General* tab can now export the whole
|
||||
panel configuration (general settings + attribute definitions) to a JSON file
|
||||
and import it back. Export can optionally leave out the attribute-encryption
|
||||
key. Both actions are recorded in the audit log (`config.export`,
|
||||
`config.import`). No Active Directory data is included.
|
||||
- **`docker/compose.dev.yaml`** — a live-reload development stack that
|
||||
bind-mounts the working tree; `docker/php/Dockerfile` is now multi-stage with
|
||||
`dev` / `prod` targets. Each stack has a no-Traefik override template.
|
||||
- **Release image.** `.github/workflows/release.yml` builds the `prod` image on
|
||||
every `vX.Y.Z` tag and pushes it to the Gitea container registry as
|
||||
`gitea.kzbikowski.pl/kzbkowski/adconsole:{X.Y.Z, X.Y, latest}`.
|
||||
`docker/compose.yaml` now defaults to that image (`ADCONSOLE_VERSION` pins a
|
||||
tag, `ADCONSOLE_IMAGE` replaces the reference); `docker compose … up -d`
|
||||
pulls it, `… --build` still rebuilds locally.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Docker: the production image is now self-contained.** `docker/compose.yaml`
|
||||
builds `docker/php/Dockerfile` target `prod`, which `COPY`s the app in and
|
||||
runs `composer install --no-dev` at build time — the working tree is no longer
|
||||
bind-mounted (only `.env`). Persistent state moved from Docker named volumes
|
||||
to **`docker/data/app_storage`** and **`docker/data/db`** bind mounts.
|
||||
Apache now honours Laravel's `public/.htaccess` (`AllowOverride All`).
|
||||
|
||||
Run Compose from the repo root with `--env-file .env`, e.g.
|
||||
`docker compose --env-file .env -f docker/compose.yaml up -d`.
|
||||
|
||||
**Upgrading an existing Docker deployment:** copy the old named volumes into
|
||||
`docker/data/` before the first `up` with the new file, e.g.
|
||||
`docker run --rm -v adconsole_db_data:/from -v "$PWD/docker/data/db":/to alpine cp -a /from/. /to/`
|
||||
(same for `adconsole_app_storage` → `docker/data/app_storage`). See
|
||||
[upgrading.md](upgrading.md#v110--docker-layout).
|
||||
|
||||
## [1.0.0] - 2026-09-08
|
||||
|
||||
First public release. Consolidates the prior internal builds (`0.1.0`) into a
|
||||
@@ -106,5 +143,6 @@ Initial internal release: users, computers, groups, read-only GPO, org chart,
|
||||
dashboard, panel configuration, PL/EN UI, LDAP-group login. Never tagged or
|
||||
published; superseded by `1.0.0`.
|
||||
|
||||
[Unreleased]: https://gitea.kzbikowski.pl/kzbkowski/ADconsle/compare/v1.0.0...HEAD
|
||||
[Unreleased]: https://gitea.kzbikowski.pl/kzbkowski/ADconsle/compare/v1.1.0...HEAD
|
||||
[1.1.0]: https://gitea.kzbikowski.pl/kzbkowski/ADconsle/compare/v1.0.0...v1.1.0
|
||||
[1.0.0]: https://gitea.kzbikowski.pl/kzbkowski/ADconsle/releases/tag/v1.0.0
|
||||
File renamed without changes.
@@ -6,8 +6,8 @@ very welcome.
|
||||
|
||||
## Before you start
|
||||
|
||||
- Read [`CLAUDE/AGENTS.md`](CLAUDE/AGENTS.md) (the architecture in one page) and
|
||||
[`docs/architecture.md`](docs/architecture.md).
|
||||
- Read [`CLAUDE/AGENTS.md`](../CLAUDE/AGENTS.md) (the architecture in one page) and
|
||||
[`architecture.md`](architecture.md).
|
||||
- By participating you agree to the [`CODE_OF_CONDUCT.md`](CODE_OF_CONDUCT.md).
|
||||
- For anything non-trivial, open an issue first so we can agree on the approach.
|
||||
|
||||
@@ -20,7 +20,7 @@ very welcome.
|
||||
3. **Keep it dependency-light.** No new runtime Composer/npm package without a
|
||||
clear reason in the issue.
|
||||
4. **Least privilege.** New AD operations must be documented in
|
||||
[`docs/active-directory.md`](docs/active-directory.md) with the exact right
|
||||
[`active-directory.md`](active-directory.md) with the exact right
|
||||
they require.
|
||||
5. **Security-sensitive by nature** — think about what a hostile panel user or a
|
||||
compromised service account could do with your change.
|
||||
@@ -35,7 +35,7 @@ php artisan test
|
||||
vendor/bin/pint
|
||||
```
|
||||
|
||||
See [`docs/development.md`](docs/development.md) for the details (SQLite for dev,
|
||||
See [`development.md`](development.md) for the details (SQLite for dev,
|
||||
the fake `ldap` guard in tests, the harmless `DEPR` tag, etc.).
|
||||
|
||||
## Pull requests
|
||||
@@ -64,4 +64,4 @@ and steps to reproduce.
|
||||
## Licence
|
||||
|
||||
By contributing you agree your work is released under the project's
|
||||
[MIT Licence](LICENSE).
|
||||
[MIT Licence](../LICENSE).
|
||||
+5
-1
@@ -11,7 +11,11 @@
|
||||
| [troubleshooting.md](troubleshooting.md) | Fix "the list is empty", "password reset fails", etc. |
|
||||
| [upgrading.md](upgrading.md) | Pull a new version safely. |
|
||||
| [publishing.md](publishing.md) | Turn this tree into a clean shared/public repo. |
|
||||
| [CONTRIBUTING.md](CONTRIBUTING.md) | Set up a dev loop and get a change merged. |
|
||||
| [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md) | Know the behaviour expected of contributors. |
|
||||
| [CHANGELOG.md](CHANGELOG.md) | See what changed between releases. |
|
||||
| [SECURITY.md](SECURITY.md) | Report a vulnerability; understand the security scope. |
|
||||
|
||||
Start with the [project README](../README.md) for the 5-minute overview.
|
||||
|
||||
Security policy and vulnerability reporting: [../SECURITY.md](../SECURITY.md).
|
||||
Security policy and vulnerability reporting: [SECURITY.md](SECURITY.md).
|
||||
@@ -40,12 +40,12 @@ ADconsole is a privileged tool. When you deploy it, **you** are responsible for:
|
||||
- An encrypted LDAP channel (`LDAP_SSL` / `LDAP_TLS`) and, ideally,
|
||||
`LDAP_TLS_VERIFY=true`.
|
||||
- Delegating the service account **only** the AD rights you actually use
|
||||
([docs/active-directory.md](docs/active-directory.md)).
|
||||
([active-directory.md](active-directory.md)).
|
||||
- Keeping `AD_LOGIN_GROUP` small and audited.
|
||||
- Protecting `.env` (file mode `600`) — it contains the service-account password.
|
||||
- Applying updates promptly.
|
||||
|
||||
See the [hardening checklist](docs/deployment.md#hardening-checklist).
|
||||
See the [hardening checklist](deployment.md#hardening-checklist).
|
||||
|
||||
## Supported versions
|
||||
|
||||
+17
-1
@@ -35,7 +35,7 @@ panel's own config (`panel_settings`, `panel_attributes`) and the change log
|
||||
|---|---|
|
||||
| `DB_CONNECTION` | `mysql` (MariaDB/MySQL), `pgsql`, or `sqlite`. |
|
||||
| `DB_HOST` / `DB_PORT` / `DB_DATABASE` / `DB_USERNAME` / `DB_PASSWORD` | Connection. |
|
||||
| `DB_ROOT_PASSWORD` | Only consumed by `docker/compose.yaml` on first volume init. |
|
||||
| `DB_ROOT_PASSWORD` | Only consumed by `docker/compose.yaml` when it first initialises `docker/data/db`. |
|
||||
|
||||
### Sessions / cache / queue
|
||||
|
||||
@@ -122,3 +122,19 @@ Locked rows that can't be removed or retyped: `login` (sAMAccountName),
|
||||
`distinguishedName`, the computed `status` and `password_expiry`, and
|
||||
`account_expiry` (fixed `date`, `accountExpires`). If deleted directly in SQL
|
||||
they are re-created from the defaults on the next read.
|
||||
|
||||
### Backup / restore
|
||||
|
||||
The *General* tab has a **Configuration backup** card:
|
||||
|
||||
- **Export** downloads a JSON file with the General-tab settings and the full
|
||||
attribute list (`App\Services\ConfigBackupService::export()`). Tick *leave the
|
||||
encryption key out* to blank `encryption.key` in the file.
|
||||
- **Import** reads such a file back and overwrites `panel_settings` (key `panel`)
|
||||
and `panel_attributes`. Values are re-validated on the way in (ranges clamped,
|
||||
locked attribute rows re-asserted), so a hand-edited file can't push the panel
|
||||
into a bad state. A file that isn't a `format: adconsole-config` JSON, or that
|
||||
declares a newer schema `version`, is rejected with a form error.
|
||||
|
||||
Both actions write a `panel_audit` row (`config.export` / `config.import`). The
|
||||
backup never contains Active Directory data or the audit log itself.
|
||||
+90
-29
@@ -1,17 +1,39 @@
|
||||
# Deployment (Docker)
|
||||
|
||||
The repo ships a small production stack in `docker/`:
|
||||
Everything Docker lives in `docker/`:
|
||||
|
||||
- **`docker/compose.yaml`** — production. A **self-contained image**: the app
|
||||
code and `composer install --no-dev` are baked in at build time
|
||||
(`docker/php/Dockerfile`, target `prod`). Nothing from the working tree is
|
||||
mounted except `.env`. Persistent state lives in `docker/data/`. Wired for an
|
||||
**external Traefik** proxy on the `traefik_public` network.
|
||||
- **`docker/compose.dev.yaml`** — development / live-reload. Bind-mounts the
|
||||
working tree over `/var/www/html`; edits to PHP / Blade / assets apply on the
|
||||
next request. State is in throwaway named volumes. See
|
||||
[development.md](development.md).
|
||||
- `docker/compose.override.yaml.template` /
|
||||
`docker/compose.dev.override.yaml.template` — copy each to the matching
|
||||
`*.override.yaml` (git-ignored) to run **without** Traefik by publishing a
|
||||
host port.
|
||||
- `docker/php/Dockerfile` — PHP 8.4 + Apache, `ldap`/`pdo_mysql`/`opcache`,
|
||||
Composer, and an entrypoint that self-bootstraps on first boot.
|
||||
- `docker/php/entrypoint.sh` — installs `vendor/` if missing, generates
|
||||
`APP_KEY`, fixes `storage/` ownership, runs `migrate --force`, warms caches,
|
||||
then starts Apache. Idempotent — runs on every start.
|
||||
- `docker/compose.yaml` — `app` + `mariadb`, wired for an **external Traefik**
|
||||
proxy on the `traefik_public` network.
|
||||
- `docker/compose.override.yaml.template` — copy to
|
||||
`docker/compose.override.yaml` (git-ignored) to run **without** Traefik by
|
||||
publishing a host port.
|
||||
Composer. Stages: `base` → `dev` (no code baked in) / `prod` (code + deps
|
||||
baked in).
|
||||
- `docker/php/entrypoint.sh` — on every boot: install `vendor/` if missing,
|
||||
generate `APP_KEY`, create + `chown` the `storage/` tree, `migrate --force`,
|
||||
warm caches (prod) / just clear them (dev). Idempotent.
|
||||
|
||||
## Invocation
|
||||
|
||||
Because the Compose files sit in `docker/`, always run **from the repo root**
|
||||
and pass `--env-file .env` so `${DB_*}` / `${APP_DOMAIN}` interpolation reads
|
||||
the repo-root `.env`:
|
||||
|
||||
```sh
|
||||
docker compose --env-file .env -f docker/compose.yaml <command>
|
||||
```
|
||||
|
||||
(Tip: `export COMPOSE_FILE=docker/compose.yaml COMPOSE_ENV_FILES=.env` once per
|
||||
shell, then plain `docker compose …` works.)
|
||||
|
||||
## With Traefik
|
||||
|
||||
@@ -23,51 +45,89 @@ cp .env.example .env
|
||||
# DB_PASSWORD / DB_ROOT_PASSWORD <- set real values
|
||||
# LDAP_* / AD_* <- your domain
|
||||
|
||||
docker compose -f docker/compose.yaml up -d --build
|
||||
docker compose -f docker/compose.yaml logs -f app # watch the bootstrap
|
||||
docker compose --env-file .env -f docker/compose.yaml up -d # pulls the release image
|
||||
docker compose --env-file .env -f docker/compose.yaml logs -f app # watch bootstrap
|
||||
```
|
||||
|
||||
`docker/compose.yaml` defaults to the image the Gitea release workflow publishes
|
||||
(`gitea.kzbikowski.pl/kzbkowski/adconsole:1.1.0`), so `up -d` **pulls** it — no
|
||||
local build. Add `--build` to build from your working tree instead; the result
|
||||
is tagged with the same name.
|
||||
|
||||
Enable a certificate resolver by uncommenting the `certresolver` label in
|
||||
`docker/compose.yaml` and setting its name to match your Traefik config.
|
||||
|
||||
The `.env` file is **bind-mounted**, not baked in: edit it and
|
||||
`docker compose restart app` — no rebuild needed. (This is deliberate; see the
|
||||
comment in `compose.yaml`.)
|
||||
The `.env` file is **bind-mounted**, not baked into the image: edit it and
|
||||
`docker compose … restart app` — no rebuild needed. Everything else is
|
||||
immutable.
|
||||
|
||||
### The image
|
||||
|
||||
`.github/workflows/release.yml` builds `docker/php/Dockerfile` target `prod` on
|
||||
every `vX.Y.Z` tag and pushes `…/adconsole:{X.Y.Z, X.Y, latest}` to the Gitea
|
||||
container registry (needs Actions enabled and `packages: write`; set a
|
||||
`REGISTRY_TOKEN` secret if the built-in token is not allowed to push).
|
||||
|
||||
Deploy a specific release:
|
||||
|
||||
```sh
|
||||
ADCONSOLE_VERSION=1.1.0 \
|
||||
docker compose --env-file .env -f docker/compose.yaml pull
|
||||
ADCONSOLE_VERSION=1.1.0 \
|
||||
docker compose --env-file .env -f docker/compose.yaml up -d
|
||||
```
|
||||
|
||||
Set `ADCONSOLE_VERSION` (or the full `ADCONSOLE_IMAGE`) in `.env` to make it
|
||||
stick. A different registry / private mirror: `ADCONSOLE_IMAGE=host/adconsole:tag`.
|
||||
To publish from a workstation instead of CI:
|
||||
`docker compose … build && docker compose … push`.
|
||||
|
||||
## Without Traefik
|
||||
|
||||
```sh
|
||||
cp docker/compose.override.yaml.template docker/compose.override.yaml
|
||||
# APP_URL=http://localhost:8080 in .env
|
||||
docker compose -f docker/compose.yaml -f docker/compose.override.yaml up -d --build
|
||||
docker compose --env-file .env \
|
||||
-f docker/compose.yaml -f docker/compose.override.yaml up -d --build
|
||||
```
|
||||
|
||||
App on `http://localhost:8080`, MariaDB on `localhost:3306`. Put a TLS-terminating
|
||||
proxy (nginx, Caddy, your load balancer) in front for anything but a lab.
|
||||
App on `http://localhost:8080`, MariaDB on `localhost:3306`. Put a
|
||||
TLS-terminating proxy (nginx, Caddy, your load balancer) in front for anything
|
||||
but a lab.
|
||||
|
||||
## Data & backups
|
||||
|
||||
| What | Where | Back up? |
|
||||
|---|---|---|
|
||||
| Database (`panel_settings` + `panel_attributes` config, `panel_audit` change log, sessions, cache) | `db_data` volume | **Yes** — `panel_settings` / `panel_attributes` are your config and `panel_audit` is your only record of who changed what. `mysqldump adconsole`. |
|
||||
| `storage/` (logs, framework cache, sessions if file-driver) | `app_storage` volume | Optional (logs only really). |
|
||||
| `.env` | bind-mounted from the repo | **Yes**, securely — it holds the service-account password. |
|
||||
Production state is under **`docker/data/`** (git-ignored), created on first run:
|
||||
|
||||
Restore config elsewhere: `mysqldump`/import the `panel_settings` and
|
||||
`panel_attributes` tables, or re-enter it in `/settings`.
|
||||
| What | Path | Back up? |
|
||||
|---|---|---|
|
||||
| Database (`panel_settings` + `panel_attributes` config, `panel_audit` change log, sessions, cache) | `docker/data/db/` | **Yes** — `panel_settings` / `panel_attributes` are your config and `panel_audit` is your only record of who changed what. `docker compose … exec mariadb mariadb-dump -uroot -p adconsole`. |
|
||||
| `storage/` (logs, framework cache, sessions if file-driver) | `docker/data/app_storage/` | Optional (logs only really). |
|
||||
| `.env` | bind-mounted from the repo root | **Yes**, securely — it holds the service-account password. |
|
||||
|
||||
A file-level copy of `docker/data/db/` is only consistent with the container
|
||||
stopped; prefer `mariadb-dump` on a running stack. The panel's own config also
|
||||
has a JSON export at `/settings` → *General* → *Configuration backup*.
|
||||
|
||||
> On **rootless Docker** the MariaDB image can fail to `chown` a bind-mounted
|
||||
> datadir. Either run the daemon rootful, or switch `docker/compose.yaml`'s
|
||||
> `mariadb` volume to a named volume (as `docker/compose.dev.yaml` already does).
|
||||
|
||||
**Audit-log retention.** `panel_audit` grows with panel activity and nothing
|
||||
prunes it automatically. If you need a retention window, trim it with a scheduled
|
||||
job of your own, e.g.
|
||||
prunes it automatically. If you need a retention window, trim it with a
|
||||
scheduled job of your own, e.g.
|
||||
`DELETE FROM panel_audit WHERE created_at < NOW() - INTERVAL 365 DAY;`, and
|
||||
back it up (or ship it to a SIEM) before trimming if the entries have
|
||||
compliance value.
|
||||
|
||||
## Upgrades
|
||||
|
||||
Bump `ADCONSOLE_VERSION` in `.env` (or `git pull` if you track `main` and rely
|
||||
on the `latest` tag), then:
|
||||
|
||||
```sh
|
||||
git pull
|
||||
docker compose -f docker/compose.yaml up -d --build
|
||||
docker compose --env-file .env -f docker/compose.yaml pull
|
||||
docker compose --env-file .env -f docker/compose.yaml up -d
|
||||
```
|
||||
|
||||
The entrypoint runs `migrate --force` and `optimize` on the new container. See
|
||||
@@ -84,6 +144,7 @@ The entrypoint runs `migrate --force` and `optimize` on the new container. See
|
||||
- [ ] Panel reachable only from admin networks / VPN (proxy ACL or firewall)
|
||||
- [ ] DB not exposed to the internet; strong `DB_PASSWORD` / `DB_ROOT_PASSWORD`
|
||||
- [ ] `.env` file permissions `600`, owned by the deploy user
|
||||
- [ ] `docker/data/` on a backed-up filesystem, permissions restricted to the deploy user
|
||||
- [ ] Log shipping / retention configured; review `LDAP_LOGGING` volume
|
||||
- [ ] Backups of the database tested
|
||||
|
||||
|
||||
@@ -27,6 +27,39 @@ You still need a reachable domain controller for the AD-backed screens. Without
|
||||
one, the auth and list pages will error — the test suite does **not** need a DC
|
||||
(it fakes the guard and mocks LdapRecord).
|
||||
|
||||
## Local setup — Docker (live reload)
|
||||
|
||||
```sh
|
||||
git clone https://gitea.kzbikowski.pl/kzbkowski/ADconsle.git adconsole && cd adconsole
|
||||
cp .env.example .env # APP_ENV=local, APP_DEBUG=true, real LDAP_* / AD_*
|
||||
docker compose --env-file .env -f docker/compose.dev.yaml up --build
|
||||
```
|
||||
|
||||
Run from the repo root; `--env-file .env` lets Compose interpolate `${DB_*}`
|
||||
from the repo-root `.env` (the file lives in `docker/`). Set
|
||||
`export COMPOSE_FILE=docker/compose.dev.yaml COMPOSE_ENV_FILES=.env` once to
|
||||
drop the flags.
|
||||
|
||||
The working tree is bind-mounted over `/var/www/html`, so PHP / Blade / CSS / JS
|
||||
edits apply on the next request (the `dev` image target sets
|
||||
`opcache.revalidate_freq=0`). On the first boot of a fresh clone the entrypoint
|
||||
runs `composer install` (with dev deps) and `php artisan key:generate`, then
|
||||
`migrate`. `vendor/` lands back on the host, so your IDE resolves it too.
|
||||
|
||||
Database and `storage/` are throwaway named volumes — add `-v` to
|
||||
`docker compose … down` to reset the box. MariaDB is published on
|
||||
`localhost:3306`. Run artisan / tools inside the container:
|
||||
|
||||
```sh
|
||||
docker compose --env-file .env -f docker/compose.dev.yaml exec app php artisan migrate:fresh
|
||||
docker compose --env-file .env -f docker/compose.dev.yaml exec app php artisan test
|
||||
docker compose --env-file .env -f docker/compose.dev.yaml exec app vendor/bin/pint
|
||||
```
|
||||
|
||||
`compose.dev.yaml` expects an external Traefik on `traefik_public`; without one,
|
||||
`cp docker/compose.dev.override.yaml.template docker/compose.dev.override.yaml`
|
||||
and add `-f docker/compose.dev.override.yaml` (publishes `http://localhost:8080`).
|
||||
|
||||
## Tests
|
||||
|
||||
```sh
|
||||
|
||||
+15
-7
@@ -13,7 +13,7 @@ If you re-publish under a different owner/host, revisit these:
|
||||
| File | Was | Now / put |
|
||||
|---|---|---|
|
||||
| `README.md`, `docs/installation.md`, `docs/development.md` | `<your-fork-url>` in the `git clone` lines | `https://gitea.kzbikowski.pl/kzbkowski/ADconsle.git` |
|
||||
| `SECURITY.md` | `<SET A SECURITY CONTACT HERE>` | `mail@kzbikowski.pl` |
|
||||
| `docs/SECURITY.md` | `<SET A SECURITY CONTACT HERE>` | `mail@kzbikowski.pl` |
|
||||
| `.github/ISSUE_TEMPLATE/config.yml` | `https://github.com/OWNER/REPO/...` | The repo's Gitea issue/wiki URLs |
|
||||
| `LICENSE` | `2026 Kacper Żbikowski` | Copyright year + holder, if different |
|
||||
| `lang/{pl,en}/pages.php` → `author.*` | `Kacper Żbikowski` | Author/maintainer shown on the in-app *About* page |
|
||||
@@ -33,7 +33,8 @@ none of it slipped in:
|
||||
- `vendor/`, `node_modules/`, `public/build/`, `public/hot/`
|
||||
- `storage/` runtime (logs, sessions, framework cache) — only the `.gitignore`
|
||||
keep-files should be tracked
|
||||
- `docker/compose.override.yaml`
|
||||
- `docker/compose.override.yaml`, `docker/compose.dev.override.yaml`
|
||||
- `docker/data/` (production DB + storage state)
|
||||
- IDE / agent dirs (`.idea/`, `.vscode/`, `.claude/`, …)
|
||||
|
||||
```sh
|
||||
@@ -82,11 +83,18 @@ git push origin v1.0.0
|
||||
`laravel`, `ldaprecord`, `admin-panel`, `self-hosted`.
|
||||
- Enable **Issues**. Point the "Question / help" contact link at the repo's
|
||||
issues (Gitea has no Discussions).
|
||||
- The GitHub-format templates in `.github/` (issue/PR templates, the
|
||||
Pint + PHPUnit CI workflow on PHP 8.2/8.3/8.4) are read by Gitea Actions too if
|
||||
Actions is enabled on the instance; otherwise they are harmless.
|
||||
- Cut a **release** from the `v1.0.0` tag and paste the matching `CHANGELOG.md`
|
||||
section as the notes.
|
||||
- The workflows in `.github/workflows/` are read by Gitea Actions if Actions is
|
||||
enabled on the instance (otherwise harmless):
|
||||
- `ci.yml` — Pint + PHPUnit on PHP 8.2/8.3/8.4, on every push / PR.
|
||||
- `release.yml` — on a `vX.Y.Z` tag, builds `docker/php/Dockerfile` target
|
||||
`prod` and pushes `…/adconsole:{X.Y.Z, X.Y, latest}` to the instance's
|
||||
container registry. Needs `packages: write`; if the built-in token can't
|
||||
push, add a `REGISTRY_TOKEN` repo secret. Adjust `REGISTRY` / `IMAGE_NAME`
|
||||
in the workflow (and the default in `docker/compose.yaml`) for a different
|
||||
host or owner.
|
||||
- Release: `git tag -a vX.Y.Z -m "ADconsole vX.Y.Z" && git push origin vX.Y.Z`
|
||||
(that fires `release.yml`), then cut a **release** from the tag on the Git host
|
||||
and paste the matching `docs/CHANGELOG.md` section as the notes.
|
||||
|
||||
## 6. First-deploy sanity (for whoever installs it)
|
||||
|
||||
|
||||
+40
-4
@@ -14,7 +14,7 @@ From `v1.0.0` on, releases are [semantic](https://semver.org/) git tags
|
||||
|
||||
The latest tag on `main` is the supported version; there are no long-term-support
|
||||
branches. Per-version notes are under [Version notes](#version-notes) below and
|
||||
in [`CHANGELOG.md`](../CHANGELOG.md).
|
||||
in [`CHANGELOG.md`](CHANGELOG.md).
|
||||
|
||||
## General procedure
|
||||
|
||||
@@ -29,13 +29,49 @@ php artisan optimize:clear
|
||||
php artisan optimize # config + route + view + event cache
|
||||
```
|
||||
|
||||
Docker: `docker compose -f docker/compose.yaml up -d --build` — the entrypoint
|
||||
runs `migrate --force` and `optimize` for you.
|
||||
Docker (from the repo root): bump `ADCONSOLE_VERSION` in `.env`, then
|
||||
`docker compose --env-file .env -f docker/compose.yaml pull` and
|
||||
`… up -d`. The entrypoint runs `migrate --force` and `optimize` for you.
|
||||
|
||||
Roll back = restore the DB dump and check out the previous revision.
|
||||
Roll back = restore the DB dump and either check out the previous revision or set
|
||||
`ADCONSOLE_VERSION` back to the previous tag.
|
||||
|
||||
## Version notes
|
||||
|
||||
### v1.1.0 — Docker layout
|
||||
|
||||
The Docker files moved and the production image changed shape. One-time steps
|
||||
for an **existing** Docker install:
|
||||
|
||||
1. `git pull` — `docker/compose.yaml` now lives in `docker/`, builds a
|
||||
self-contained image, and reads persistent state from **`docker/data/`**
|
||||
bind mounts instead of the `adconsole_db_data` / `adconsole_app_storage`
|
||||
named volumes.
|
||||
2. Stop the old stack: `docker compose -f docker/compose.yaml down` (old file).
|
||||
3. Copy the volume contents into `docker/data/` **before** the first new `up`:
|
||||
|
||||
```sh
|
||||
mkdir -p docker/data/db docker/data/app_storage
|
||||
docker run --rm -v adconsole_db_data:/from -v "$PWD/docker/data/db":/to \
|
||||
alpine cp -a /from/. /to/
|
||||
docker run --rm -v adconsole_app_storage:/from -v "$PWD/docker/data/app_storage":/to \
|
||||
alpine cp -a /from/. /to/
|
||||
```
|
||||
|
||||
4. Start it, always from the repo root and with `--env-file .env`:
|
||||
|
||||
```sh
|
||||
docker compose --env-file .env -f docker/compose.yaml up -d
|
||||
```
|
||||
|
||||
(`export COMPOSE_FILE=docker/compose.yaml COMPOSE_ENV_FILES=.env` once to drop
|
||||
the flags.) `up -d` now **pulls** the released image; add `--build` to build
|
||||
from the working tree.
|
||||
|
||||
Rootless Docker can't `chown` a bind-mounted MariaDB datadir — run the daemon
|
||||
rootful, or switch the `mariadb` volume in `docker/compose.yaml` to a named
|
||||
volume. Nothing changes for a **bare-metal** install.
|
||||
|
||||
### Panel configuration moved into the database
|
||||
|
||||
Two migrations, both automatic — just run `php artisan migrate` (the Docker
|
||||
|
||||
@@ -37,6 +37,8 @@ return [
|
||||
'actions' => [
|
||||
'config.settings' => 'Panel settings',
|
||||
'config.attributes' => 'Attribute configuration',
|
||||
'config.export' => 'Configuration export',
|
||||
'config.import' => 'Configuration import',
|
||||
|
||||
'user.create' => 'User created',
|
||||
'user.update' => 'User edited',
|
||||
|
||||
@@ -74,6 +74,23 @@ return [
|
||||
'tpl_ldap_placeholder' => 'e.g. extensionAttribute5',
|
||||
],
|
||||
|
||||
'backup' => [
|
||||
'heading' => 'Configuration backup',
|
||||
'intro' => 'Download the whole panel configuration (the settings from the <strong>General</strong> tab and the attribute definitions) as a JSON file, or restore it from a file you saved earlier. The backup does not include Active Directory data or the audit log.',
|
||||
'export_heading' => 'Export (download file)',
|
||||
'export_btn' => 'Download backup',
|
||||
'exclude_key' => 'Leave the attribute encryption key out of the file',
|
||||
'import_heading' => 'Import (load file)',
|
||||
'import_btn' => 'Load and overwrite',
|
||||
'import_confirm' => 'Loading a file overwrites the current panel configuration and the attribute list. Continue?',
|
||||
'warning' => 'The backup file holds the full panel configuration — <strong>including the attribute encryption key</strong> unless you tick the option to leave it out. Keep it somewhere safe.',
|
||||
'flash_imported' => 'Configuration loaded from the backup file.',
|
||||
'error_not_json' => 'The file could not be read — it is not valid JSON.',
|
||||
'error_bad_format' => 'This file is not an ADconsole configuration backup.',
|
||||
'error_bad_version' => 'The backup file comes from a newer version of the panel and cannot be loaded.',
|
||||
'error_empty' => 'The backup file contains no settings or attributes.',
|
||||
],
|
||||
|
||||
'flash' => [
|
||||
'saved' => 'Panel configuration has been saved.',
|
||||
'attributes_saved' => 'Attribute configuration has been saved.',
|
||||
|
||||
@@ -37,6 +37,8 @@ return [
|
||||
'actions' => [
|
||||
'config.settings' => 'Ustawienia panelu',
|
||||
'config.attributes' => 'Konfiguracja atrybutów',
|
||||
'config.export' => 'Eksport konfiguracji',
|
||||
'config.import' => 'Import konfiguracji',
|
||||
|
||||
'user.create' => 'Utworzenie użytkownika',
|
||||
'user.update' => 'Edycja użytkownika',
|
||||
|
||||
@@ -74,6 +74,23 @@ return [
|
||||
'tpl_ldap_placeholder' => 'np. extensionAttribute5',
|
||||
],
|
||||
|
||||
'backup' => [
|
||||
'heading' => 'Kopia zapasowa konfiguracji',
|
||||
'intro' => 'Pobierz całą konfigurację panelu (ustawienia z zakładki <strong>Ogólne</strong> oraz definicje atrybutów) do pliku JSON albo wczytaj ją z wcześniej pobranego pliku. Kopia nie obejmuje danych z Active Directory ani dziennika zmian.',
|
||||
'export_heading' => 'Eksport (pobierz plik)',
|
||||
'export_btn' => 'Pobierz kopię',
|
||||
'exclude_key' => 'Pomiń w pliku klucz szyfrujący atrybuty',
|
||||
'import_heading' => 'Import (wczytaj plik)',
|
||||
'import_btn' => 'Wczytaj i nadpisz',
|
||||
'import_confirm' => 'Wczytanie pliku nadpisze bieżącą konfigurację panelu oraz listę atrybutów. Kontynuować?',
|
||||
'warning' => 'Plik kopii zawiera pełną konfigurację panelu — <strong>w tym klucz szyfrujący atrybuty</strong>, o ile nie zaznaczysz opcji jego pominięcia. Przechowuj go w bezpiecznym miejscu.',
|
||||
'flash_imported' => 'Konfigurację wczytano z pliku kopii zapasowej.',
|
||||
'error_not_json' => 'Nie udało się odczytać pliku — to nie jest poprawny JSON.',
|
||||
'error_bad_format' => 'Ten plik nie jest kopią zapasową konfiguracji ADconsole.',
|
||||
'error_bad_version' => 'Plik kopii pochodzi z nowszej wersji panelu i nie może zostać wczytany.',
|
||||
'error_empty' => 'Plik kopii nie zawiera żadnych ustawień ani atrybutów.',
|
||||
],
|
||||
|
||||
'flash' => [
|
||||
'saved' => 'Konfiguracja panelu została zapisana.',
|
||||
'attributes_saved' => 'Konfiguracja atrybutów została zapisana.',
|
||||
|
||||
@@ -133,6 +133,55 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-3 mt-1">
|
||||
<div class="col-md-8">
|
||||
<div class="card card-custom">
|
||||
<div class="card-body">
|
||||
<h6 class="fw-bold text-uppercase text-muted mb-3" style="font-size: 0.75rem; letter-spacing: 0.05em;">{{ __('config.backup.heading') }}</h6>
|
||||
<p class="form-text mb-3" style="max-width: 640px;">{!! __('config.backup.intro') !!}</p>
|
||||
|
||||
<div class="row g-4">
|
||||
{{-- Eksport --}}
|
||||
<div class="col-md-6">
|
||||
<form method="GET" action="{{ route('config.export') }}">
|
||||
<div class="fw-semibold small mb-2">{{ __('config.backup.export_heading') }}</div>
|
||||
<div class="form-check mb-2">
|
||||
<input class="form-check-input" type="checkbox" name="exclude_key" value="1" id="cfgBackupExcludeKey">
|
||||
<label class="form-check-label small" for="cfgBackupExcludeKey">{{ __('config.backup.exclude_key') }}</label>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-outline-primary btn-sm">
|
||||
<i class="bi bi-download me-1"></i> {{ __('config.backup.export_btn') }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{{-- Import --}}
|
||||
<div class="col-md-6">
|
||||
<form method="POST" action="{{ route('config.import') }}" enctype="multipart/form-data"
|
||||
onsubmit="return confirm('{{ __('config.backup.import_confirm') }}');">
|
||||
@csrf
|
||||
<div class="fw-semibold small mb-2">{{ __('config.backup.import_heading') }}</div>
|
||||
<input type="file" class="form-control form-control-sm mb-2 @error('config_file') is-invalid @enderror"
|
||||
name="config_file" accept=".json,application/json" required>
|
||||
@error('config_file')
|
||||
<div class="invalid-feedback d-block mb-2">{{ $message }}</div>
|
||||
@enderror
|
||||
<button type="submit" class="btn btn-outline-danger btn-sm">
|
||||
<i class="bi bi-upload me-1"></i> {{ __('config.backup.import_btn') }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-warning small mt-3 mb-0">
|
||||
<i class="bi bi-shield-exclamation me-1"></i>
|
||||
{!! __('config.backup.warning') !!}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- ================= ZAKŁADKA: ATRYBUTY ================= --}}
|
||||
|
||||
@@ -140,4 +140,8 @@ Route::prefix('settings')->name('config.')->middleware('auth:ldap')->group(funct
|
||||
Route::get('/', [ConfigController::class, 'index'])->name('index');
|
||||
Route::put('/', [ConfigController::class, 'update'])->name('update');
|
||||
Route::put('/attributes', [ConfigController::class, 'updateAttributes'])->name('attributes.update');
|
||||
|
||||
// Kopia zapasowa konfiguracji panelu (bez danych z AD).
|
||||
Route::get('/export', [ConfigController::class, 'export'])->name('export');
|
||||
Route::post('/import', [ConfigController::class, 'import'])->name('import');
|
||||
});
|
||||
@@ -0,0 +1,175 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Services\ConfigBackupService;
|
||||
use App\Services\PanelSettingsService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ConfigBackupTest extends TestCase
|
||||
{
|
||||
// Konfiguracja panelu jest w bazie - każdy test startuje na czystej,
|
||||
// czyli na wbudowanych domyślnych.
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->actingAsPanelAdmin();
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $overrides */
|
||||
protected function backupFile(array $overrides = []): UploadedFile
|
||||
{
|
||||
$payload = array_replace(
|
||||
app(ConfigBackupService::class)->export(),
|
||||
$overrides,
|
||||
);
|
||||
|
||||
return UploadedFile::fake()->createWithContent(
|
||||
'adconsole-config.json',
|
||||
json_encode($payload, JSON_UNESCAPED_UNICODE),
|
||||
);
|
||||
}
|
||||
|
||||
public function test_export_downloads_a_json_backup_with_the_expected_shape(): void
|
||||
{
|
||||
$res = $this->get('/settings/export')->assertOk();
|
||||
|
||||
$this->assertStringContainsString('attachment', $res->headers->get('content-disposition'));
|
||||
$this->assertStringContainsString('adconsole-config-', $res->headers->get('content-disposition'));
|
||||
|
||||
$res->assertJsonPath('format', 'adconsole-config')
|
||||
->assertJsonPath('version', 1)
|
||||
->assertJsonStructure(['format', 'version', 'exported_at', 'settings', 'attributes']);
|
||||
|
||||
$data = $res->json();
|
||||
$this->assertSame(42, $data['settings']['password_policy']['max_age_days']);
|
||||
// Pełna lista domyślna: 24 atrybuty AD + 2 kolumny wyliczane.
|
||||
$this->assertCount(26, $data['attributes']);
|
||||
}
|
||||
|
||||
public function test_export_can_leave_out_the_encryption_key(): void
|
||||
{
|
||||
$this->put('/settings', [
|
||||
'reset_password' => ['must_change_password' => '1', 'unlock_account' => '1'],
|
||||
'password_policy' => ['max_age_days' => '42'],
|
||||
'encryption' => ['key' => 'TopSecret!'],
|
||||
])->assertRedirect();
|
||||
|
||||
$this->get('/settings/export')
|
||||
->assertJsonPath('settings.encryption.key', 'TopSecret!')
|
||||
->assertJsonPath('encryption_key_included', true);
|
||||
|
||||
$this->get('/settings/export?exclude_key=1')
|
||||
->assertJsonPath('settings.encryption.key', '')
|
||||
->assertJsonPath('encryption_key_included', false);
|
||||
}
|
||||
|
||||
public function test_import_restores_general_settings_and_attributes(): void
|
||||
{
|
||||
// Zmień konfigurację, zrób kopię, przywróć domyślne, wczytaj kopię.
|
||||
$this->put('/settings', [
|
||||
'reset_password' => ['must_change_password' => '0', 'unlock_account' => '0'],
|
||||
'password_policy' => ['max_age_days' => '17'],
|
||||
'audit' => ['require_reason' => '1'],
|
||||
])->assertRedirect();
|
||||
|
||||
$this->put('/settings/attributes', [
|
||||
'attributes' => [
|
||||
['key' => 'title', 'label' => 'Stanowisko', 'ldap' => 'title', 'category' => 'org', 'type' => 'text', 'column' => '1'],
|
||||
['key' => 'badgeId', 'label' => 'Identyfikator', 'ldap' => 'extensionAttribute9', 'category' => 'hr', 'type' => 'text', 'column' => '1'],
|
||||
],
|
||||
])->assertRedirect();
|
||||
|
||||
$file = $this->backupFile();
|
||||
|
||||
// Wróć do domyślnych.
|
||||
app(PanelSettingsService::class)->save(app(PanelSettingsService::class)->defaults());
|
||||
app(PanelSettingsService::class)->saveAttributes([]);
|
||||
$this->assertSame(42, app(PanelSettingsService::class)->passwordMaxAgeDays());
|
||||
|
||||
$this->post('/settings/import', ['config_file' => $file])
|
||||
->assertRedirect()
|
||||
->assertSessionHas('status')
|
||||
->assertSessionHasNoErrors();
|
||||
|
||||
$fresh = app(PanelSettingsService::class);
|
||||
$this->assertSame(17, $fresh->passwordMaxAgeDays());
|
||||
$this->assertTrue($fresh->requireReason());
|
||||
|
||||
$byKey = collect($fresh->attributes())->keyBy('key');
|
||||
$this->assertTrue($byKey->has('badgeId'));
|
||||
$this->assertSame('extensionAttribute9', $byKey['badgeId']['ldap']);
|
||||
$this->assertTrue($byKey['title']['column']);
|
||||
|
||||
// Pozycje systemowe / wyliczane zostają odtworzone przez saveAttributes().
|
||||
$this->assertTrue($byKey->has('login'));
|
||||
$this->assertTrue($byKey->has('status'));
|
||||
}
|
||||
|
||||
public function test_import_writes_an_audit_entry(): void
|
||||
{
|
||||
$this->post('/settings/import', ['config_file' => $this->backupFile()])
|
||||
->assertRedirect();
|
||||
|
||||
$this->assertDatabaseHas('panel_audit', ['action' => 'config.import']);
|
||||
}
|
||||
|
||||
public function test_import_rejects_a_file_that_is_not_an_adconsole_backup(): void
|
||||
{
|
||||
$file = UploadedFile::fake()->createWithContent('nope.json', json_encode(['hello' => 'world']));
|
||||
|
||||
$this->post('/settings/import', ['config_file' => $file])
|
||||
->assertRedirect()
|
||||
->assertSessionHasErrors('config_file');
|
||||
|
||||
$this->assertDatabaseMissing('panel_audit', ['action' => 'config.import']);
|
||||
}
|
||||
|
||||
public function test_import_rejects_a_file_that_is_not_valid_json(): void
|
||||
{
|
||||
$file = UploadedFile::fake()->createWithContent('broken.json', '{ not json ');
|
||||
|
||||
$this->post('/settings/import', ['config_file' => $file])
|
||||
->assertRedirect()
|
||||
->assertSessionHasErrors('config_file');
|
||||
}
|
||||
|
||||
public function test_import_rejects_a_backup_from_a_newer_schema_version(): void
|
||||
{
|
||||
$file = $this->backupFile(['version' => 999]);
|
||||
|
||||
$this->post('/settings/import', ['config_file' => $file])
|
||||
->assertRedirect()
|
||||
->assertSessionHasErrors('config_file');
|
||||
}
|
||||
|
||||
public function test_import_sanitises_out_of_range_values_from_a_hand_edited_file(): void
|
||||
{
|
||||
$file = $this->backupFile([
|
||||
'settings' => [
|
||||
'password_policy' => ['max_age_days' => 999999, 'generator_length' => 2],
|
||||
'audit' => ['require_reason' => 'yes'],
|
||||
],
|
||||
]);
|
||||
|
||||
$this->post('/settings/import', ['config_file' => $file])->assertRedirect()->assertSessionHasNoErrors();
|
||||
|
||||
$fresh = app(PanelSettingsService::class);
|
||||
$this->assertSame(3650, $fresh->passwordMaxAgeDays());
|
||||
$this->assertSame(8, $fresh->passwordGenerator()['length']);
|
||||
$this->assertTrue($fresh->requireReason());
|
||||
}
|
||||
|
||||
public function test_settings_page_shows_the_backup_card(): void
|
||||
{
|
||||
$this->get('/settings')
|
||||
->assertOk()
|
||||
->assertSee(__('config.backup.heading'))
|
||||
->assertSee(route('config.export'), escape: false);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user