Reach the rest of what lldap exposes — photos, real names, group rename

lldap lets you edit five things about a user and one about a group; the page reached two of
them, so correcting a surname or a group's name still meant opening the container's own WebUI.
This commit is contained in:
Gmer4Lfe
2026-08-15 11:45:23 -04:00
parent 693ac693a4
commit 81aeb0619b
3 changed files with 338 additions and 12 deletions
+108 -6
View File
@@ -283,9 +283,76 @@ function vv_lldap_gql(string $query, array $variables = []): array {
}
function vv_lldap_list_users(): array {
$r = vv_lldap_gql('query { users { id displayName email creationDate groups { id displayName } } }');
// firstName/lastName/uuid were never requested, so the page could not show or edit them —
// 31 of the 33 users here have them set and none of it was reachable without opening lldap's
// own WebUI.
//
// The avatar itself is deliberately NOT in this query. It is a base64 JPEG stored inline, and
// the six that exist here come to 470 KB — a third of a megabyte added to every load of the
// tab, re-fetched on every refresh, to draw six thumbnails. The attribute *names* are enough
// to know who has one, and the bytes are fetched per user by vv_lldap_avatar() through an
// endpoint the browser can cache like any other image.
$r = vv_lldap_gql('query { users { id displayName email firstName lastName uuid creationDate '
. 'groups { id displayName } attributes { name } } }');
if (isset($r['errors'])) return ['ok' => false, 'error' => $r['errors'][0]['message'] ?? 'Query failed'];
return ['ok' => true, 'users' => $r['data']['users'] ?? []];
$users = $r['data']['users'] ?? [];
foreach ($users as &$u) {
$names = array_column($u['attributes'] ?? [], 'name');
$u['has_avatar'] = in_array('avatar', $names, true);
// Sent to the browser as a flag, not a list. Nothing on the page reads the attribute names
// and shipping 33 copies of the same nine strings is pure weight.
unset($u['attributes']);
}
unset($u);
return ['ok' => true, 'users' => $users];
}
// Raw JPEG bytes for one user, or '' when they have no avatar. Returned as bytes rather than
// base64 because the only caller streams it to an <img>, and re-encoding it to hand the browser
// something it would immediately decode again is a third of a megabyte of nothing.
function vv_lldap_avatar(string $userId): string {
$r = vv_lldap_gql('query Avatar($id: String!) { user(userId: $id) { avatar } }', ['id' => $userId]);
if (isset($r['errors'])) return '';
$b64 = $r['data']['user']['avatar'] ?? '';
if (!is_string($b64) || $b64 === '') return '';
$raw = base64_decode($b64, true);
return ($raw !== false && vv_lldap_is_jpeg($raw)) ? $raw : '';
}
// lldap types this attribute JPEG_PHOTO and rejects anything else, so the check happens here where
// the answer can name the problem. A rejection from the server arrives as a generic GraphQL error
// several layers from the file the operator picked.
function vv_lldap_is_jpeg(string $raw): bool {
return strlen($raw) > 3 && substr($raw, 0, 3) === "\xFF\xD8\xFF";
}
// One megabyte of JPEG, decoded. The browser resizes before upload so nothing near this should
// arrive; the cap is here because this value is stored inline in the directory and read back on
// every user query, and an unbounded one would be paid for on every page load forever.
const VV_LLDAP_AVATAR_MAX = 1048576;
function vv_lldap_set_avatar(string $userId, string $b64): array {
$b64 = preg_replace('#^data:image/[a-z+]+;base64,#i', '', trim($b64));
$raw = base64_decode($b64, true);
if ($raw === false || $raw === '') return ['ok' => false, 'error' => 'Image data could not be decoded'];
if (!vv_lldap_is_jpeg($raw)) return ['ok' => false, 'error' => 'lldap stores avatars as JPEG only — that file is not one'];
if (strlen($raw) > VV_LLDAP_AVATAR_MAX)
return ['ok' => false, 'error' => 'Image is ' . round(strlen($raw) / 1024) . ' KB; the limit is '
. round(VV_LLDAP_AVATAR_MAX / 1024) . ' KB'];
$r = vv_lldap_gql('mutation SetAvatar($user: UpdateUserInput!) { updateUser(user: $user) { ok } }',
['user' => ['id' => $userId, 'avatar' => base64_encode($raw)]]);
if (isset($r['errors'])) return ['ok' => false, 'error' => $r['errors'][0]['message'] ?? 'Avatar update failed'];
return ['ok' => true, 'bytes' => strlen($raw)];
}
// Cleared through removeAttributes rather than by setting avatar to an empty string: lldap treats
// an empty avatar as a value to validate, and it is not a JPEG.
function vv_lldap_remove_avatar(string $userId): array {
$r = vv_lldap_gql('mutation ClearAvatar($user: UpdateUserInput!) { updateUser(user: $user) { ok } }',
['user' => ['id' => $userId, 'removeAttributes' => ['avatar']]]);
if (isset($r['errors'])) return ['ok' => false, 'error' => $r['errors'][0]['message'] ?? 'Avatar removal failed'];
return ['ok' => true];
}
function vv_lldap_list_groups(): array {
@@ -294,20 +361,42 @@ function vv_lldap_list_groups(): array {
return ['ok' => true, 'groups' => $r['data']['groups'] ?? []];
}
function vv_lldap_create_user(string $id, string $email, string $displayName, string $password): array {
function vv_lldap_create_user(string $id, string $email, string $displayName, string $password,
string $firstName = '', string $lastName = ''): array {
$user = ['id' => $id, 'email' => $email, 'displayName' => $displayName];
// Omitted when blank rather than sent as "". lldap distinguishes the two, and an empty string
// creates the attribute holding nothing, which then shows as set everywhere that tests for it.
if ($firstName !== '') $user['firstName'] = $firstName;
if ($lastName !== '') $user['lastName'] = $lastName;
$r = vv_lldap_gql(
'mutation CreateUser($user: CreateUserInput!) { createUser(user: $user) { id displayName email } }',
['user' => ['id' => $id, 'email' => $email, 'displayName' => $displayName]]
['user' => $user]
);
if (isset($r['errors'])) return ['ok' => false, 'error' => $r['errors'][0]['message'] ?? 'Create failed'];
if ($password) vv_lldap_set_password($id, $password);
return ['ok' => true, 'user' => $r['data']['createUser'] ?? []];
}
function vv_lldap_update_user(string $id, string $email, string $displayName): array {
// $firstName/$lastName are nullable on purpose: null means "the form did not offer this field, so
// leave it alone", '' means "the operator cleared it". Passing '' for an absent field would erase
// a name that 31 of the 33 users here have set.
function vv_lldap_update_user(string $id, string $email, string $displayName,
?string $firstName = null, ?string $lastName = null): array {
$user = ['id' => $id, 'email' => $email, 'displayName' => $displayName];
$remove = [];
foreach (['firstName' => $firstName, 'lastName' => $lastName] as $k => $v) {
if ($v === null) continue;
if ($v === '') $remove[] = $k === 'firstName' ? 'first_name' : 'last_name';
else $user[$k] = $v;
}
// Clearing goes through removeAttributes — setting the field to "" leaves the attribute in
// place holding an empty string, which is a different thing to lldap and to anything reading
// the directory over LDAP.
if ($remove) $user['removeAttributes'] = $remove;
$r = vv_lldap_gql(
'mutation UpdateUser($user: UpdateUserInput!) { updateUser(user: $user) { ok } }',
['user' => ['id' => $id, 'email' => $email, 'displayName' => $displayName]]
['user' => $user]
);
if (isset($r['errors'])) return ['ok' => false, 'error' => $r['errors'][0]['message'] ?? 'Update failed'];
return ['ok' => true];
@@ -353,6 +442,19 @@ function vv_lldap_create_group(string $name): array {
return ['ok' => true, 'group' => $r['data']['createGroup'] ?? []];
}
// The only editable field a group has. Without it the sole way to correct a group's name was to
// delete it and make a new one — which drops every member, and on this directory those group names
// are what the Authelia rules match on, so the rule would keep naming a group that no longer
// exists and quietly stop admitting anyone.
function vv_lldap_rename_group(int $id, string $displayName): array {
$r = vv_lldap_gql(
'mutation UpdateGroup($group: UpdateGroupInput!) { updateGroup(group: $group) { ok } }',
['group' => ['id' => $id, 'displayName' => $displayName]]
);
if (isset($r['errors'])) return ['ok' => false, 'error' => $r['errors'][0]['message'] ?? 'Rename failed'];
return ['ok' => true];
}
function vv_lldap_delete_group(int $id): array {
$r = vv_lldap_gql(
'mutation DeleteGroup($groupId: Int!) { deleteGroup(groupId: $groupId) { ok } }',