asdsad
This commit is contained in:
@@ -181,6 +181,7 @@ final class KeycloakAuth
|
||||
'username' => (string) ($userInfo['preferred_username'] ?? ''),
|
||||
'name' => (string) ($userInfo['name'] ?? ''),
|
||||
'email' => (string) ($userInfo['email'] ?? ''),
|
||||
'groups' => $this->extractGroups($tokenPayload, $userInfo),
|
||||
],
|
||||
];
|
||||
}
|
||||
@@ -312,6 +313,65 @@ final class KeycloakAuth
|
||||
return strtolower($first);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $tokenPayload
|
||||
* @param array<string, mixed> $userInfo
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function extractGroups(array $tokenPayload, array $userInfo): array
|
||||
{
|
||||
$groups = [];
|
||||
|
||||
foreach ([$userInfo, $this->decodeJwtClaims((string) ($tokenPayload['access_token'] ?? '')), $this->decodeJwtClaims((string) ($tokenPayload['id_token'] ?? ''))] as $source) {
|
||||
$candidateGroups = $source['groups'] ?? [];
|
||||
|
||||
if (!is_array($candidateGroups)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($candidateGroups as $group) {
|
||||
$group = trim((string) $group);
|
||||
|
||||
if ($group === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$group = trim($group, '/');
|
||||
$groups[$group] = $group;
|
||||
}
|
||||
}
|
||||
|
||||
return array_values($groups);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function decodeJwtClaims(string $jwt): array
|
||||
{
|
||||
if ($jwt === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$parts = explode('.', $jwt);
|
||||
|
||||
if (count($parts) < 2) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$payload = $parts[1];
|
||||
$payload .= str_repeat('=', (4 - strlen($payload) % 4) % 4);
|
||||
$decoded = base64_decode(strtr($payload, '-_', '+/'), true);
|
||||
|
||||
if ($decoded === false) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$claims = json_decode($decoded, true);
|
||||
|
||||
return is_array($claims) ? $claims : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $fields
|
||||
* @return array{success: bool, data?: array<string, mixed>, error?: string}
|
||||
|
||||
37
src/App/RegistrationAccess.php
Normal file
37
src/App/RegistrationAccess.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App;
|
||||
|
||||
final class RegistrationAccess
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $config
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly array $config,
|
||||
) {
|
||||
}
|
||||
|
||||
public function canManage(): bool
|
||||
{
|
||||
$auth = $_SESSION['desktop_auth'] ?? null;
|
||||
|
||||
if (!is_array($auth)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$user = is_array($auth['user'] ?? null) ? $auth['user'] : [];
|
||||
$username = strtolower((string) ($user['username'] ?? ''));
|
||||
$groups = array_map('strtolower', array_values(array_map('strval', (array) ($user['groups'] ?? []))));
|
||||
$approverGroups = array_map('strtolower', array_values(array_map('strval', (array) ($this->config['approver_groups'] ?? []))));
|
||||
$approverUsernames = array_map('strtolower', array_values(array_map('strval', (array) ($this->config['approver_usernames'] ?? []))));
|
||||
|
||||
if ($username !== '' && in_array($username, $approverUsernames, true)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return array_intersect($groups, $approverGroups) !== [];
|
||||
}
|
||||
}
|
||||
161
src/App/RegistrationLdifBuilder.php
Normal file
161
src/App/RegistrationLdifBuilder.php
Normal file
@@ -0,0 +1,161 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App;
|
||||
|
||||
final class RegistrationLdifBuilder
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $config
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly array $config,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $request
|
||||
* @param array<string, mixed> $provisioning
|
||||
*/
|
||||
public function build(array $request, array $provisioning): string
|
||||
{
|
||||
$username = (string) ($request['username'] ?? '');
|
||||
$usersDn = (string) ($this->config['ldap']['users_dn'] ?? '');
|
||||
$uidNumber = trim((string) ($provisioning['uid_number'] ?? ''));
|
||||
$gidNumber = trim((string) ($provisioning['gid_number'] ?? ''));
|
||||
$homeDirectory = trim((string) ($provisioning['home_directory'] ?? ''));
|
||||
$loginShell = trim((string) ($provisioning['login_shell'] ?? ($this->config['ldap']['default_login_shell'] ?? '/bin/sh')));
|
||||
$memberOf = array_values(array_filter(array_map('trim', explode(',', (string) ($provisioning['member_of_csv'] ?? '')))));
|
||||
$displayName = trim((string) ($provisioning['display_name'] ?? $request['display_name'] ?? ''));
|
||||
$givenName = trim((string) ($request['given_name'] ?? ''));
|
||||
$familyName = trim((string) ($request['family_name'] ?? ''));
|
||||
$cn = trim((string) ($provisioning['cn'] ?? ($displayName !== '' ? $displayName : $username)));
|
||||
$gecos = trim((string) ($provisioning['gecos'] ?? ($displayName !== '' ? $displayName : trim($givenName . ' ' . $familyName))));
|
||||
$mail = trim((string) ($request['email'] ?? ''));
|
||||
$appleGeneratedUid = trim((string) ($provisioning['apple_generateduid'] ?? $this->generateUuidV4()));
|
||||
$sambaSid = trim((string) ($provisioning['samba_sid'] ?? ''));
|
||||
$shadowExpire = (string) ($this->config['ldap']['shadow_expire'] ?? 1);
|
||||
$shadowFlag = (string) ($this->config['ldap']['shadow_flag'] ?? 0);
|
||||
$shadowInactive = (string) ($this->config['ldap']['shadow_inactive'] ?? 0);
|
||||
$shadowMax = (string) ($this->config['ldap']['shadow_max'] ?? 99999);
|
||||
$shadowMin = (string) ($this->config['ldap']['shadow_min'] ?? 0);
|
||||
$shadowWarning = (string) ($this->config['ldap']['shadow_warning'] ?? 7);
|
||||
$placeholderPassword = $this->generatePlaceholderPassword();
|
||||
$passwordHash = $this->cryptSha512($placeholderPassword);
|
||||
$warnings = [];
|
||||
|
||||
if ($uidNumber === '') {
|
||||
$warnings[] = '# WARNUNG: uidNumber fehlt und muss vor dem LDAP-Import gesetzt werden.';
|
||||
}
|
||||
|
||||
if ($gidNumber === '') {
|
||||
$warnings[] = '# WARNUNG: gidNumber fehlt und muss vor dem LDAP-Import gesetzt werden.';
|
||||
}
|
||||
|
||||
if ($sambaSid === '') {
|
||||
$warnings[] = '# WARNUNG: sambaSID fehlt. Viele NAS-Setups erwarten diesen Wert.';
|
||||
}
|
||||
|
||||
if ($memberOf === []) {
|
||||
$memberOf = array_values(array_map('strval', (array) ($this->config['ldap']['default_groups'] ?? [])));
|
||||
}
|
||||
|
||||
$lines = [
|
||||
'version: 1',
|
||||
'',
|
||||
];
|
||||
|
||||
foreach ($warnings as $warning) {
|
||||
$lines[] = $warning;
|
||||
}
|
||||
|
||||
if ($warnings !== []) {
|
||||
$lines[] = '# Das Passwort unten ist ein zufälliger Platzhalter für den inaktiven Zustand.';
|
||||
$lines[] = '# Platzhalter-Passwort: ' . $placeholderPassword;
|
||||
$lines[] = '';
|
||||
}
|
||||
|
||||
$lines[] = 'dn: uid=' . $username . ',' . $usersDn;
|
||||
$lines[] = 'objectClass: apple-user';
|
||||
$lines[] = 'objectClass: extensibleObject';
|
||||
$lines[] = 'objectClass: inetOrgPerson';
|
||||
$lines[] = 'objectClass: organizationalPerson';
|
||||
$lines[] = 'objectClass: person';
|
||||
$lines[] = 'objectClass: posixAccount';
|
||||
$lines[] = 'objectClass: sambaIdmapEntry';
|
||||
$lines[] = 'objectClass: sambaSamAccount';
|
||||
$lines[] = 'objectClass: shadowAccount';
|
||||
$lines[] = 'objectClass: top';
|
||||
$lines[] = 'cn: ' . $cn;
|
||||
|
||||
if ($gidNumber !== '') {
|
||||
$lines[] = 'gidNumber: ' . $gidNumber;
|
||||
}
|
||||
|
||||
$lines[] = 'homeDirectory: ' . $homeDirectory;
|
||||
|
||||
if ($sambaSid !== '') {
|
||||
$lines[] = 'sambaSID: ' . $sambaSid;
|
||||
}
|
||||
|
||||
$lines[] = 'sn: ' . ($familyName !== '' ? $familyName : $username);
|
||||
$lines[] = 'uid: ' . $username;
|
||||
|
||||
if ($uidNumber !== '') {
|
||||
$lines[] = 'uidNumber: ' . $uidNumber;
|
||||
}
|
||||
|
||||
$lines[] = 'apple-generateduid: ' . strtoupper($appleGeneratedUid);
|
||||
$lines[] = 'authAuthority: ;basic;';
|
||||
$lines[] = 'displayName: ' . ($displayName !== '' ? $displayName : $username);
|
||||
|
||||
if ($gecos !== '') {
|
||||
$lines[] = 'gecos: ' . $gecos;
|
||||
}
|
||||
|
||||
if ($givenName !== '') {
|
||||
$lines[] = 'givenName: ' . $givenName;
|
||||
}
|
||||
|
||||
$lines[] = 'loginShell: ' . $loginShell;
|
||||
$lines[] = 'mail: ' . $mail;
|
||||
|
||||
foreach ($memberOf as $groupDn) {
|
||||
$lines[] = 'memberOf: ' . $groupDn;
|
||||
}
|
||||
|
||||
$lines[] = 'shadowExpire: ' . $shadowExpire;
|
||||
$lines[] = 'shadowFlag: ' . $shadowFlag;
|
||||
$lines[] = 'shadowInactive: ' . $shadowInactive;
|
||||
$lines[] = 'shadowLastChange: 1';
|
||||
$lines[] = 'shadowMax: ' . $shadowMax;
|
||||
$lines[] = 'shadowMin: ' . $shadowMin;
|
||||
$lines[] = 'shadowWarning: ' . $shadowWarning;
|
||||
$lines[] = 'userPassword: ' . $passwordHash;
|
||||
|
||||
return implode(PHP_EOL, $lines) . PHP_EOL;
|
||||
}
|
||||
|
||||
private function generateUuidV4(): string
|
||||
{
|
||||
$bytes = random_bytes(16);
|
||||
$bytes[6] = chr((ord($bytes[6]) & 0x0f) | 0x40);
|
||||
$bytes[8] = chr((ord($bytes[8]) & 0x3f) | 0x80);
|
||||
|
||||
return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($bytes), 4));
|
||||
}
|
||||
|
||||
private function generatePlaceholderPassword(): string
|
||||
{
|
||||
return bin2hex(random_bytes(12));
|
||||
}
|
||||
|
||||
private function cryptSha512(string $password): string
|
||||
{
|
||||
$salt = '$6$' . bin2hex(random_bytes(8)) . '$';
|
||||
$hash = crypt($password, $salt);
|
||||
|
||||
return str_starts_with($hash, '$6$') ? '{CRYPT}' . $hash : $hash;
|
||||
}
|
||||
}
|
||||
274
src/App/RegistrationService.php
Normal file
274
src/App/RegistrationService.php
Normal file
@@ -0,0 +1,274 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App;
|
||||
|
||||
final class RegistrationService
|
||||
{
|
||||
private RegistrationStore $store;
|
||||
private RegistrationLdifBuilder $ldifBuilder;
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $config
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly string $projectRoot,
|
||||
private readonly array $config,
|
||||
) {
|
||||
$storagePath = $this->resolveProjectPath((string) ($this->config['storage_path'] ?? 'data/registration-requests.json'));
|
||||
$this->store = new RegistrationStore($storagePath);
|
||||
$this->ldifBuilder = new RegistrationLdifBuilder($this->config);
|
||||
}
|
||||
|
||||
public function isEnabled(): bool
|
||||
{
|
||||
return (bool) ($this->config['enabled'] ?? false);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function all(): array
|
||||
{
|
||||
return $this->store->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
public function find(string $id): ?array
|
||||
{
|
||||
return $this->store->find($id);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $input
|
||||
* @return array{success: bool, errors?: array<int, string>, request?: array<string, mixed>}
|
||||
*/
|
||||
public function submit(array $input): array
|
||||
{
|
||||
$username = strtolower(trim((string) ($input['username'] ?? '')));
|
||||
$givenName = trim((string) ($input['given_name'] ?? ''));
|
||||
$familyName = trim((string) ($input['family_name'] ?? ''));
|
||||
$email = strtolower(trim((string) ($input['email'] ?? '')));
|
||||
$note = trim((string) ($input['note'] ?? ''));
|
||||
$displayName = trim($givenName . ' ' . $familyName);
|
||||
$errors = [];
|
||||
|
||||
if (!preg_match('/^[a-z0-9._-]{3,32}$/', $username)) {
|
||||
$errors[] = 'Der Benutzername muss 3 bis 32 Zeichen lang sein und darf nur Kleinbuchstaben, Zahlen, Punkt, Minus oder Unterstrich enthalten.';
|
||||
}
|
||||
|
||||
if ($givenName === '') {
|
||||
$errors[] = 'Der Vorname ist erforderlich.';
|
||||
}
|
||||
|
||||
if ($familyName === '') {
|
||||
$errors[] = 'Der Nachname ist erforderlich.';
|
||||
}
|
||||
|
||||
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
$errors[] = 'Bitte gib eine gültige E-Mail-Adresse an.';
|
||||
}
|
||||
|
||||
if ($note !== '' && mb_strlen($note) > 2000) {
|
||||
$errors[] = 'Die Zusatzinfo ist zu lang.';
|
||||
}
|
||||
|
||||
$conflict = $this->store->findConflict($username, $email);
|
||||
|
||||
if ($conflict !== null) {
|
||||
$errors[] = 'Für diesen Benutzernamen oder diese E-Mail existiert bereits eine offene oder freigegebene Registrierung.';
|
||||
}
|
||||
|
||||
if ($errors !== []) {
|
||||
return [
|
||||
'success' => false,
|
||||
'errors' => $errors,
|
||||
];
|
||||
}
|
||||
|
||||
$request = [
|
||||
'id' => bin2hex(random_bytes(8)),
|
||||
'status' => 'pending',
|
||||
'created_at' => gmdate('c'),
|
||||
'updated_at' => gmdate('c'),
|
||||
'username' => $username,
|
||||
'given_name' => $givenName,
|
||||
'family_name' => $familyName,
|
||||
'display_name' => $displayName,
|
||||
'email' => $email,
|
||||
'note' => $note,
|
||||
'origin' => [
|
||||
'ip' => (string) ($_SERVER['REMOTE_ADDR'] ?? ''),
|
||||
'user_agent' => (string) ($_SERVER['HTTP_USER_AGENT'] ?? ''),
|
||||
'host' => (string) ($_SERVER['HTTP_HOST'] ?? ''),
|
||||
],
|
||||
'review' => null,
|
||||
'provisioning' => null,
|
||||
];
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'request' => $this->store->create($request),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $input
|
||||
* @return array{success: bool, errors?: array<int, string>, request?: array<string, mixed>}
|
||||
*/
|
||||
public function approve(string $id, array $input, string $reviewer): array
|
||||
{
|
||||
$request = $this->store->find($id);
|
||||
|
||||
if ($request === null) {
|
||||
return ['success' => false, 'errors' => ['Die Registrierung wurde nicht gefunden.']];
|
||||
}
|
||||
|
||||
$uidNumber = trim((string) ($input['uid_number'] ?? ''));
|
||||
$gidNumber = trim((string) ($input['gid_number'] ?? ''));
|
||||
$homeDirectory = trim((string) ($input['home_directory'] ?? ''));
|
||||
$sambaSid = trim((string) ($input['samba_sid'] ?? ''));
|
||||
$memberOfCsv = trim((string) ($input['member_of_csv'] ?? ''));
|
||||
$adminNote = trim((string) ($input['admin_note'] ?? ''));
|
||||
$errors = [];
|
||||
|
||||
if ($uidNumber === '' || !ctype_digit($uidNumber)) {
|
||||
$errors[] = 'uidNumber ist erforderlich und muss numerisch sein.';
|
||||
}
|
||||
|
||||
if ($gidNumber === '' || !ctype_digit($gidNumber)) {
|
||||
$errors[] = 'gidNumber ist erforderlich und muss numerisch sein.';
|
||||
}
|
||||
|
||||
if ($homeDirectory === '') {
|
||||
$errors[] = 'homeDirectory ist erforderlich.';
|
||||
}
|
||||
|
||||
if ($errors !== []) {
|
||||
return ['success' => false, 'errors' => $errors];
|
||||
}
|
||||
|
||||
$provisioning = [
|
||||
'uid_number' => $uidNumber,
|
||||
'gid_number' => $gidNumber,
|
||||
'home_directory' => $homeDirectory,
|
||||
'login_shell' => trim((string) ($input['login_shell'] ?? ($this->config['ldap']['default_login_shell'] ?? '/bin/sh'))),
|
||||
'member_of_csv' => $memberOfCsv,
|
||||
'samba_sid' => $sambaSid,
|
||||
'apple_generateduid' => trim((string) ($input['apple_generateduid'] ?? '')),
|
||||
'display_name' => trim((string) ($input['display_name'] ?? (string) ($request['display_name'] ?? ''))),
|
||||
'cn' => trim((string) ($input['cn'] ?? (string) ($request['display_name'] ?? $request['username'] ?? ''))),
|
||||
'gecos' => trim((string) ($input['gecos'] ?? (string) ($request['display_name'] ?? ''))),
|
||||
'admin_note' => $adminNote,
|
||||
'ldif_generated_at' => gmdate('c'),
|
||||
];
|
||||
|
||||
$request = $this->store->update(
|
||||
$id,
|
||||
static function (array $item) use ($provisioning, $reviewer, $adminNote): array {
|
||||
$item['status'] = 'approved_pending_provisioning';
|
||||
$item['updated_at'] = gmdate('c');
|
||||
$item['review'] = [
|
||||
'reviewed_at' => gmdate('c'),
|
||||
'reviewed_by' => $reviewer,
|
||||
'decision' => 'approved',
|
||||
'note' => $adminNote,
|
||||
];
|
||||
$item['provisioning'] = $provisioning;
|
||||
|
||||
return $item;
|
||||
}
|
||||
);
|
||||
|
||||
if ($request === null) {
|
||||
return ['success' => false, 'errors' => ['Die Registrierung konnte nicht aktualisiert werden.']];
|
||||
}
|
||||
|
||||
$ldif = $this->ldifBuilder->build($request, $provisioning);
|
||||
$ldifPath = $this->writeLdifDraft((string) ($request['id'] ?? ''), (string) ($request['username'] ?? ''), $ldif);
|
||||
|
||||
$request = $this->store->update(
|
||||
$id,
|
||||
static function (array $item) use ($ldifPath): array {
|
||||
$item['updated_at'] = gmdate('c');
|
||||
$item['provisioning']['ldif_path'] = $ldifPath;
|
||||
$item['provisioning']['status'] = 'ldif_generated';
|
||||
|
||||
return $item;
|
||||
}
|
||||
);
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'request' => $request,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{success: bool, errors?: array<int, string>, request?: array<string, mixed>}
|
||||
*/
|
||||
public function reject(string $id, string $reason, string $reviewer): array
|
||||
{
|
||||
$reason = trim($reason);
|
||||
|
||||
if ($reason === '') {
|
||||
return ['success' => false, 'errors' => ['Bitte gib einen Ablehnungsgrund an.']];
|
||||
}
|
||||
|
||||
$request = $this->store->update(
|
||||
$id,
|
||||
static function (array $item) use ($reason, $reviewer): array {
|
||||
$item['status'] = 'rejected';
|
||||
$item['updated_at'] = gmdate('c');
|
||||
$item['review'] = [
|
||||
'reviewed_at' => gmdate('c'),
|
||||
'reviewed_by' => $reviewer,
|
||||
'decision' => 'rejected',
|
||||
'note' => $reason,
|
||||
];
|
||||
|
||||
return $item;
|
||||
}
|
||||
);
|
||||
|
||||
if ($request === null) {
|
||||
return ['success' => false, 'errors' => ['Die Registrierung wurde nicht gefunden.']];
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'request' => $request,
|
||||
];
|
||||
}
|
||||
|
||||
private function writeLdifDraft(string $id, string $username, string $content): string
|
||||
{
|
||||
$directory = $this->resolveProjectPath((string) ($this->config['ldif_export_dir'] ?? 'data/registration-ldif'));
|
||||
|
||||
if (!is_dir($directory)) {
|
||||
mkdir($directory, 0775, true);
|
||||
}
|
||||
|
||||
$filename = $id . '-' . preg_replace('/[^a-z0-9._-]+/i', '-', $username) . '.ldif';
|
||||
$path = rtrim($directory, '/') . '/' . $filename;
|
||||
file_put_contents($path, $content, LOCK_EX);
|
||||
|
||||
return $path;
|
||||
}
|
||||
|
||||
private function resolveProjectPath(string $path): string
|
||||
{
|
||||
if ($path === '') {
|
||||
return $this->projectRoot . '/data/registration-requests.json';
|
||||
}
|
||||
|
||||
if (str_starts_with($path, '/')) {
|
||||
return $path;
|
||||
}
|
||||
|
||||
return $this->projectRoot . '/' . ltrim($path, '/');
|
||||
}
|
||||
}
|
||||
150
src/App/RegistrationStore.php
Normal file
150
src/App/RegistrationStore.php
Normal file
@@ -0,0 +1,150 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App;
|
||||
|
||||
final class RegistrationStore
|
||||
{
|
||||
public function __construct(
|
||||
private readonly string $storagePath,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function all(): array
|
||||
{
|
||||
$items = $this->read();
|
||||
usort(
|
||||
$items,
|
||||
static fn (array $left, array $right): int => strcmp(
|
||||
(string) ($right['created_at'] ?? ''),
|
||||
(string) ($left['created_at'] ?? '')
|
||||
)
|
||||
);
|
||||
|
||||
return $items;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
public function find(string $id): ?array
|
||||
{
|
||||
foreach ($this->read() as $item) {
|
||||
if ((string) ($item['id'] ?? '') === $id) {
|
||||
return $item;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
public function findConflict(string $username, string $email): ?array
|
||||
{
|
||||
$username = strtolower(trim($username));
|
||||
$email = strtolower(trim($email));
|
||||
|
||||
foreach ($this->read() as $item) {
|
||||
$status = (string) ($item['status'] ?? 'pending');
|
||||
|
||||
if ($status === 'rejected') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (strtolower((string) ($item['username'] ?? '')) === $username) {
|
||||
return $item;
|
||||
}
|
||||
|
||||
if (strtolower((string) ($item['email'] ?? '')) === $email) {
|
||||
return $item;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function create(array $payload): array
|
||||
{
|
||||
$items = $this->read();
|
||||
$items[] = $payload;
|
||||
$this->write($items);
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param callable(array<string, mixed>): array<string, mixed> $mutator
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
public function update(string $id, callable $mutator): ?array
|
||||
{
|
||||
$items = $this->read();
|
||||
$updated = null;
|
||||
|
||||
foreach ($items as $index => $item) {
|
||||
if ((string) ($item['id'] ?? '') !== $id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$items[$index] = $mutator($item);
|
||||
$updated = $items[$index];
|
||||
break;
|
||||
}
|
||||
|
||||
if ($updated === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$this->write($items);
|
||||
|
||||
return $updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function read(): array
|
||||
{
|
||||
if (!is_file($this->storagePath)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$raw = file_get_contents($this->storagePath);
|
||||
|
||||
if ($raw === false || trim($raw) === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$decoded = json_decode($raw, true);
|
||||
|
||||
return is_array($decoded) ? array_values(array_filter($decoded, 'is_array')) : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $items
|
||||
*/
|
||||
private function write(array $items): void
|
||||
{
|
||||
$directory = dirname($this->storagePath);
|
||||
|
||||
if (!is_dir($directory)) {
|
||||
mkdir($directory, 0775, true);
|
||||
}
|
||||
|
||||
file_put_contents(
|
||||
$this->storagePath,
|
||||
json_encode($items, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . PHP_EOL,
|
||||
LOCK_EX
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user