Files
ADconsle/app/Http/Controllers/Admin/DashboardController.php
T
kzbkowskiandClaude Sonnet 5 1126d96271
CI / PHP 8.2 (push) Failing after 2m46s
CI / PHP 8.3 (push) Failing after 59s
CI / PHP 8.4 (push) Failing after 1m7s
ADconsole v1.0.0
First public release.

- docs: document the audit log, user offboarding, CSV export and the
  "require a reason for destructive actions" setting across README, docs/
  and CLAUDE/AGENTS.md; note retention for panel_audit
- docs: fill the publishing placeholders (repo URL, security contact,
  issue-template links) and add a semantic-versioning policy
- move agent/contributor context into CLAUDE/ (AGENTS.md + a README);
  root CLAUDE.md is now a thin @-import pointer
- add a favicon (shield-lock, matching the login screen) to the login
  page and the app layout
- CHANGELOG: promote [Unreleased] to [1.0.0]; keep the earlier internal
  build as [0.1.0]

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UfuvfwexztdS7LDpGuKsuK
2026-09-08 23:14:39 +02:00

350 lines
14 KiB
PHP

<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Ldap\User;
use App\Services\LdapAttributeHelper;
use App\Services\PanelSettingsService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Cache;
use LdapRecord\Models\ActiveDirectory\Computer;
use LdapRecord\Models\ActiveDirectory\Group;
use LdapRecord\Query\Model\Builder as LdapBuilder;
class DashboardController extends Controller
{
/** Czas życia cache pulpitu (sekundy). */
protected const CACHE_TTL = 300;
/** Konfigurowalne karty raportów (pokaż/ukryj + kolejność - preferencje w cookie). */
public const REPORT_BLOCKS = [
'r_expiring', 'r_pwd_never', 'r_locked', 'r_no_manager',
'r_inactive', 'r_computers_inactive', 'r_recent',
];
/**
* Pulpit z podsumowaniem stanu domeny + krótkie raporty.
* Wynik cache'owany na 5 minut (przelicza wszystkie konta w PHP).
*/
public function index(Request $request, PanelSettingsService $settings)
{
$data = Cache::store('file')->remember(
'dashboard:summary',
self::CACHE_TTL,
fn () => $this->build($settings->passwordMaxAgeDays()),
);
$tsRaw = Cache::store('file')->get('dashboard:summary_ts');
$refreshedAt = $tsRaw ? Carbon::parse($tsRaw) : now();
Carbon::setLocale(app()->getLocale());
$data['refreshed_at'] = $refreshedAt->format('Y-m-d H:i');
$data['refreshed_at_human'] = $refreshedAt->diffForHumans();
// Dane powinny pochodzić z ostatniego cyklu cache; większy odstęp = coś
// nie przelicza się w tle (np. błąd LDAP przy odświeżaniu).
$data['refreshed_stale'] = $refreshedAt->lt(now()->subSeconds(self::CACHE_TTL + 60));
// Pusty stan globalny - LDAP nic nie zwrócił (błąd połączenia / złe OU).
$data['empty'] = ($data['stats']['users_total'] ?? 0) === 0
&& ($data['computers_total'] ?? 0) === 0
&& ($data['groups_total'] ?? 0) === 0;
// Preferencje układu pulpitu (widoczność sekcji + kolejność kart) - cookie.
$data['layout'] = $this->dashboardLayout($request);
return view('admin.dashboard.index', $data);
}
public function refresh()
{
Cache::store('file')->forget('dashboard:summary');
Cache::store('file')->forget('dashboard:summary_ts');
return redirect()->route('dashboard.index');
}
/**
* Zapisuje preferencje układu pulpitu w cookie (rok ważności).
*/
public function savePreferences(Request $request): RedirectResponse
{
$order = array_values(array_filter(
(array) $request->input('report_order', []),
fn ($k) => in_array($k, self::REPORT_BLOCKS, true),
));
// Dołóż karty pominięte w formularzu (np. dodane w nowej wersji).
foreach (self::REPORT_BLOCKS as $k) {
if (! in_array($k, $order, true)) {
$order[] = $k;
}
}
$visible = array_flip((array) $request->input('visible', []));
$payload = [
'tiles' => $request->boolean('section_tiles'),
'pwd_age' => $request->boolean('section_pwd_age'),
'reports' => array_map(
fn ($k) => ['key' => $k, 'visible' => isset($visible[$k])],
$order,
),
];
return redirect()
->route('dashboard.index')
->withCookie(cookie('dashboard_layout', json_encode($payload), 60 * 24 * 365));
}
/**
* Odczytuje preferencje układu z cookie i uzupełnia brakami z domyślnych.
*
* @return array{tiles:bool, pwd_age:bool, reports:list<array{key:string,visible:bool}>}
*/
protected function dashboardLayout(Request $request): array
{
$stored = json_decode((string) $request->cookie('dashboard_layout'), true);
$stored = is_array($stored) ? $stored : [];
$layout = [
'tiles' => ! array_key_exists('tiles', $stored) || (bool) $stored['tiles'],
'pwd_age' => ! array_key_exists('pwd_age', $stored) || (bool) $stored['pwd_age'],
'reports' => [],
];
$seen = [];
foreach ((array) ($stored['reports'] ?? []) as $r) {
$key = is_array($r) ? ($r['key'] ?? null) : null;
if (in_array($key, self::REPORT_BLOCKS, true) && ! isset($seen[$key])) {
$seen[$key] = true;
$layout['reports'][] = ['key' => $key, 'visible' => (bool) ($r['visible'] ?? true)];
}
}
foreach (self::REPORT_BLOCKS as $key) {
if (! isset($seen[$key])) {
$layout['reports'][] = ['key' => $key, 'visible' => true];
}
}
return $layout;
}
protected function build(int $maxPasswordAgeDays): array
{
$userOu = config('ldap.defaults.user_ou');
$now = now();
$cutInactive = $now->copy()->subDays(90);
$cutRecent = $now->copy()->subDays(30);
$expiryHorizon = $now->copy()->addDays(7);
$stats = [
'users_total' => 0,
'users_disabled' => 0,
'users_locked' => 0,
'pwd_never_expires' => 0,
'pwd_expiring_7d' => 0,
'pwd_expired' => 0,
'inactive_90d' => 0,
'account_expired' => 0,
];
$stats['users_no_manager'] = 0;
// Histogram wieku hasła (dni od pwdLastSet).
$pwdAge = ['lt30' => 0, 'd30_60' => 0, 'd60_90' => 0, 'gt90' => 0, 'unknown' => 0];
$expiringSoon = [];
$inactive = [];
$recent = [];
$pwdNever = [];
$locked = [];
$noManager = [];
// chunk() strumieniuje kolejne strony (paged results) - nie trzymamy w
// pamięci wszystkich obiektów naraz i liczymy KAŻDE konto w domenie.
User::query()
->in($userOu)->recursive()
->select(['cn', 'samaccountname', 'userAccountControl', 'pwdLastSet', 'lockoutTime', 'lastLogonTimestamp', 'accountExpires', 'whenCreated', 'mail', 'manager', 'directReports'])
->orderBy('cn')
->chunk(1000, function ($users) use (
&$stats, &$pwdAge, &$expiringSoon, &$inactive, &$recent, &$pwdNever, &$locked, &$noManager,
$now, $cutInactive, $cutRecent, $expiryHorizon, $maxPasswordAgeDays
) {
foreach ($users as $u) {
$stats['users_total']++;
$disabled = LdapAttributeHelper::isDisabled($u);
if ($disabled) {
$stats['users_disabled']++;
}
if (LdapAttributeHelper::isLocked($u)) {
$stats['users_locked']++;
$lockedSince = LdapAttributeHelper::windowsTimestampToCarbon($u->getFirstAttribute('lockouttime'));
$locked[] = ['cn' => $u->getFirstAttribute('cn'), 'sam' => $u->getFirstAttribute('samaccountname'), 'since' => $lockedSince?->format('Y-m-d H:i') ?? '—'];
}
if (LdapAttributeHelper::passwordNeverExpires($u)) {
$stats['pwd_never_expires']++;
if (! $disabled) {
$pwdNever[] = ['cn' => $u->getFirstAttribute('cn'), 'sam' => $u->getFirstAttribute('samaccountname')];
}
}
// "Dziura w org-chart": aktywne konto bez przełożonego i bez podwładnych.
if (! $disabled
&& trim((string) $u->getFirstAttribute('manager')) === ''
&& count(array_filter(array_map('trim', $u->getAttribute('directreports') ?? []))) === 0) {
$stats['users_no_manager']++;
$noManager[] = ['cn' => $u->getFirstAttribute('cn'), 'sam' => $u->getFirstAttribute('samaccountname')];
}
$exp = LdapAttributeHelper::passwordExpiresAt($u, $maxPasswordAgeDays);
if ($exp) {
if ($exp->isPast()) {
$stats['pwd_expired']++;
} elseif ($exp->lte($expiryHorizon)) {
$stats['pwd_expiring_7d']++;
$expiringSoon[] = ['cn' => $u->getFirstAttribute('cn'), 'sam' => $u->getFirstAttribute('samaccountname'), 'date' => $exp->format('Y-m-d')];
}
}
$lastSet = LdapAttributeHelper::passwordLastSet($u);
if (! $lastSet) {
$pwdAge['unknown']++;
} else {
$ageDays = (int) abs($lastSet->diffInDays($now));
if ($ageDays < 30) {
$pwdAge['lt30']++;
} elseif ($ageDays < 60) {
$pwdAge['d30_60']++;
} elseif ($ageDays < 90) {
$pwdAge['d60_90']++;
} else {
$pwdAge['gt90']++;
}
}
$lastLogon = LdapAttributeHelper::windowsTimestampToCarbon($u->getFirstAttribute('lastlogontimestamp'));
if (! $disabled && (! $lastLogon || $lastLogon->lt($cutInactive))) {
$stats['inactive_90d']++;
$inactive[] = ['cn' => $u->getFirstAttribute('cn'), 'sam' => $u->getFirstAttribute('samaccountname'), 'last' => $lastLogon?->format('Y-m-d') ?? 'nigdy'];
}
$accExp = LdapAttributeHelper::accountExpiresAt($u);
if ($accExp && $accExp->isPast()) {
$stats['account_expired']++;
}
$created = $this->parseWhenCreated($u->getFirstAttribute('whencreated'));
if ($created && $created->gt($cutRecent)) {
$recent[] = ['cn' => $u->getFirstAttribute('cn'), 'sam' => $u->getFirstAttribute('samaccountname'), 'created' => $created->format('Y-m-d')];
}
}
});
usort($expiringSoon, fn ($a, $b) => strcmp($a['date'], $b['date']));
usort($inactive, fn ($a, $b) => strcmp($a['last'], $b['last']));
usort($recent, fn ($a, $b) => strcmp($b['created'], $a['created']));
usort($pwdNever, fn ($a, $b) => strcasecmp((string) $a['cn'], (string) $b['cn']));
usort($locked, fn ($a, $b) => strcmp($b['since'], $a['since']));
usort($noManager, fn ($a, $b) => strcasecmp((string) $a['cn'], (string) $b['cn']));
$computersInactive = $this->scanInactiveComputers($cutInactive);
Cache::store('file')->put('dashboard:summary_ts', $now->format('Y-m-d H:i:s'), self::CACHE_TTL);
$stats['computers_inactive_90d'] = $computersInactive['count'];
return [
'stats' => $stats,
'pwd_age' => $pwdAge,
'computers_total' => $this->countAll(
Computer::query()->in(config('ldap.defaults.computer_ou'))->recursive()->select(['cn'])
),
'groups_total' => $this->countAll(
Group::query()->in(config('ldap.defaults.groups_ou') ?: $userOu)->recursive()->select(['cn'])
),
'expiring_soon' => array_slice($expiringSoon, 0, 15),
'inactive' => array_slice($inactive, 0, 15),
'recent' => array_slice($recent, 0, 15),
'pwd_never_list' => array_slice($pwdNever, 0, 50),
'locked_list' => array_slice($locked, 0, 50),
'no_manager_list' => array_slice($noManager, 0, 50),
'computers_inactive' => array_slice($computersInactive['list'], 0, 50),
'max_password_age_days' => $maxPasswordAgeDays,
];
}
/**
* Skan kont komputerów: aktywne, których lastLogonTimestamp jest starszy niż
* próg (lub w ogóle brak logowania). Strumieniuje strony jak countAll().
*
* @return array{count:int, list:list<array{cn:string,last:string}>}
*/
protected function scanInactiveComputers(Carbon $cutInactive): array
{
$count = 0;
$list = [];
Computer::query()
->in(config('ldap.defaults.computer_ou'))->recursive()
->select(['cn', 'lastLogonTimestamp', 'userAccountControl'])
->orderBy('cn')
->chunk(1000, function ($computers) use (&$count, &$list, $cutInactive) {
foreach ($computers as $c) {
if ((int) ($c->getFirstAttribute('useraccountcontrol') ?? 0) & LdapAttributeHelper::UAC_ACCOUNTDISABLE) {
continue;
}
$last = LdapAttributeHelper::windowsTimestampToCarbon($c->getFirstAttribute('lastlogontimestamp'));
if ($last && $last->gte($cutInactive)) {
continue;
}
$count++;
$list[] = ['cn' => $c->getFirstAttribute('cn'), 'last' => $last?->format('Y-m-d') ?? '—'];
}
});
usort($list, fn ($a, $b) => strcmp($a['last'], $b['last']));
return ['count' => $count, 'list' => $list];
}
/**
* Liczy wszystkie obiekty pasujące do zapytania, strumieniując strony -
* bez materializowania całej listy modeli w pamięci.
*/
protected function countAll(LdapBuilder $query): int
{
$count = 0;
$query->chunk(1000, function ($rows) use (&$count) {
$count += count($rows);
});
return $count;
}
protected function parseWhenCreated(?string $value): ?Carbon
{
if (! $value) {
return null;
}
try {
return Carbon::createFromFormat('YmdHis.0Z', $value);
} catch (\Throwable) {
try {
return Carbon::parse($value);
} catch (\Throwable) {
return null;
}
}
}
}