From 18bf1fde0e250cce485bc0f8b6f2f260b2ec91ca Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Sat, 28 Mar 2026 20:47:08 -0400 Subject: [PATCH] feat: LDAP avatar support via lldap - Create tinker-tickets service account in lldap (lldap_strict_readonly) - Add /api/user_avatar.php: binds to lldap, fetches avatar attribute, caches JPEG to uploads/avatars/, returns 404 sentinel for missing photos - Install php8.2-ldap on LXC 132 (beta) and LXC coding server - Update layout_header.php: show lt-avatar with photo overlay + initials fallback - Update TicketView.php: comment avatars use photo overlay pattern - Add .lt-avatar-img / .lt-avatar-initials CSS for photo-over-initials layout - Add LDAP_* config keys to config.php and .env.example Co-Authored-By: Claude Sonnet 4.6 --- .env.example | 11 +++ api/user_avatar.php | 177 ++++++++++++++++++++++++++++++++++++++++ assets/css/base.css | 15 +++- config/config.php | 13 ++- views/TicketView.php | 13 ++- views/layout_header.php | 19 ++++- 6 files changed, 243 insertions(+), 5 deletions(-) create mode 100644 api/user_avatar.php diff --git a/.env.example b/.env.example index ab0bf97..2ed797e 100644 --- a/.env.example +++ b/.env.example @@ -26,3 +26,14 @@ ALLOWED_HOSTS=localhost,127.0.0.1 # Timezone (default: America/New_York) TIMEZONE=America/New_York + +# LDAP / lldap (for user avatar lookups) +LDAP_ENABLED=true +LDAP_HOST=10.10.10.39 +LDAP_PORT=3890 +LDAP_BIND_DN=uid=tinker-tickets,ou=people,dc=example,dc=com +LDAP_BIND_PW= +LDAP_BASE_DN=dc=example,dc=com +LDAP_USER_BASE=ou=people,dc=example,dc=com +# How long to cache avatar images locally (seconds, default 3600) +AVATAR_CACHE_TTL=3600 diff --git a/api/user_avatar.php b/api/user_avatar.php new file mode 100644 index 0000000..44daa36 --- /dev/null +++ b/api/user_avatar.php @@ -0,0 +1,177 @@ +prepare("SELECT username FROM users WHERE user_id = ? LIMIT 1"); + $stmt->bind_param('i', $userId); + $stmt->execute(); + $result = $stmt->get_result(); + $row = $result->fetch_assoc(); + $stmt->close(); + + if (!$row || empty($row['username'])) { + http_response_code(404); + exit; + } + $username = $row['username']; +} catch (Exception $e) { + error_log("user_avatar: DB error for user_id=$userId: " . $e->getMessage()); + http_response_code(500); + exit; +} + +// Query lldap via LDAP +$ldapHost = $cfg['LDAP_HOST']; +$ldapPort = $cfg['LDAP_PORT']; +$bindDn = $cfg['LDAP_BIND_DN']; +$bindPw = $cfg['LDAP_BIND_PW']; +$userBase = $cfg['LDAP_USER_BASE']; + +// Escape username for LDAP filter (RFC 4515) +$safeUsername = ldap_escape($username, '', LDAP_ESCAPE_FILTER); +$filter = "(uid=$safeUsername)"; + +$avatarData = null; + +try { + $ldap = @ldap_connect("ldap://$ldapHost:$ldapPort"); + if (!$ldap) { + throw new RuntimeException("ldap_connect failed"); + } + + ldap_set_option($ldap, LDAP_OPT_PROTOCOL_VERSION, 3); + ldap_set_option($ldap, LDAP_OPT_REFERRALS, 0); + ldap_set_option($ldap, LDAP_OPT_NETWORK_TIMEOUT, 3); + ldap_set_option($ldap, LDAP_OPT_TIMELIMIT, 3); + + if (!@ldap_bind($ldap, $bindDn, $bindPw)) { + throw new RuntimeException("LDAP bind failed: " . ldap_error($ldap)); + } + + $search = @ldap_search($ldap, $userBase, $filter, ['avatar'], 0, 1, 3); + if (!$search) { + throw new RuntimeException("LDAP search failed: " . ldap_error($ldap)); + } + + $entries = ldap_get_entries($ldap, $search); + if ($entries['count'] > 0 && !empty($entries[0]['avatar'][0])) { + // lldap stores the avatar as a base64-encoded string in the LDAP attribute + $avatarRaw = $entries[0]['avatar'][0]; + // ldap_get_entries already decodes binary attributes; but lldap stores + // the avatar as a base64 string within the attribute value itself. + // Detect: if it starts with /9j/ (JPEG magic in base64) decode it. + if (substr($avatarRaw, 0, 4) === '/9j/') { + $avatarData = base64_decode($avatarRaw); + } else { + // Raw binary JPEG (starts with FF D8) + $avatarData = $avatarRaw; + } + } + + ldap_unbind($ldap); +} catch (Exception $e) { + error_log("user_avatar: LDAP error for username=$username: " . $e->getMessage()); + // Fall through to 404 +} + +if ($avatarData === null || strlen($avatarData) < 100) { + // Write sentinel so we don't hammer LDAP for users without avatars + file_put_contents($noAvatarSentinel, ''); + http_response_code(404); + exit; +} + +// Validate it's actually a JPEG (magic bytes FF D8 FF) +if (substr($avatarData, 0, 3) !== "\xFF\xD8\xFF") { + error_log("user_avatar: non-JPEG data for username=$username"); + file_put_contents($noAvatarSentinel, ''); + http_response_code(404); + exit; +} + +// Cache to disk +file_put_contents($cacheFile, $avatarData); +// Remove stale sentinel if present +if (file_exists($noAvatarSentinel)) { + unlink($noAvatarSentinel); +} + +header('Content-Type: image/jpeg'); +header('Cache-Control: private, max-age=' . $cacheTtl); +header('X-Avatar-Source: ldap'); +echo $avatarData; diff --git a/assets/css/base.css b/assets/css/base.css index b3468ac..cfc111a 100644 --- a/assets/css/base.css +++ b/assets/css/base.css @@ -4522,7 +4522,20 @@ body.lt-is-offline .lt-main { margin-top: 2rem; transition: margin-top 0.25s eas flex-shrink: 0; user-select: none; } -.lt-avatar img { width: 100%; height: 100%; object-fit: cover; display: block; } +/* Photo overlay: img sits on top of initials text; hidden via onerror if no photo */ +.lt-avatar { position: relative; } +.lt-avatar-initials { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; pointer-events: none; } +.lt-avatar-img { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: cover; + border-radius: inherit; + z-index: 1; +} +/* Legacy: bare img inside lt-avatar (no .lt-avatar-img class) */ +.lt-avatar > img:not(.lt-avatar-img) { width: 100%; height: 100%; object-fit: cover; display: block; } /* Sizes */ .lt-avatar--xs { width: 1.5rem; height: 1.5rem; font-size: 0.55rem; } .lt-avatar--sm { width: 2rem; height: 2rem; font-size: 0.65rem; } diff --git a/config/config.php b/config/config.php index d47c76c..2a4132c 100644 --- a/config/config.php +++ b/config/config.php @@ -87,7 +87,18 @@ $GLOBALS['config'] = [ // Default: America/New_York (EST/EDT) // Common options: America/Chicago (CST), America/Denver (MST), America/Los_Angeles (PST), UTC 'TIMEZONE' => $envVars['TIMEZONE'] ?? 'America/New_York', - 'TIMEZONE_OFFSET' => null // Will be calculated below + 'TIMEZONE_OFFSET' => null, // Will be calculated below + + // LDAP / lldap settings (for user avatar lookups) + 'LDAP_HOST' => $envVars['LDAP_HOST'] ?? '10.10.10.39', + 'LDAP_PORT' => (int)($envVars['LDAP_PORT'] ?? 3890), + 'LDAP_BIND_DN' => $envVars['LDAP_BIND_DN'] ?? 'uid=tinker-tickets,ou=people,dc=example,dc=com', + 'LDAP_BIND_PW' => $envVars['LDAP_BIND_PW'] ?? '', + 'LDAP_BASE_DN' => $envVars['LDAP_BASE_DN'] ?? 'dc=example,dc=com', + 'LDAP_USER_BASE' => $envVars['LDAP_USER_BASE'] ?? 'ou=people,dc=example,dc=com', + 'LDAP_ENABLED' => filter_var($envVars['LDAP_ENABLED'] ?? 'true', FILTER_VALIDATE_BOOLEAN), + 'AVATAR_CACHE_DIR' => __DIR__ . '/../uploads/avatars', + 'AVATAR_CACHE_TTL' => (int)($envVars['AVATAR_CACHE_TTL'] ?? 3600), // seconds ]; // Set PHP default timezone diff --git a/views/TicketView.php b/views/TicketView.php index 5c90566..a58d08e 100644 --- a/views/TicketView.php +++ b/views/TicketView.php @@ -370,11 +370,12 @@ include __DIR__ . '/layout_header.php'; $threadClass = $parentId ? 'comment-reply' : 'comment-root'; $dateStr = date('M d, Y H:i', strtotime($comment['created_at'])); $editedIndicator = !empty($comment['updated_at']) ? ' (edited)' : ''; - // Avatar initials + color + // Avatar initials + color (fallback when no photo) $words = array_filter(explode(' ', $displayName)); $initials = strtoupper(implode('', array_map(fn($w) => $w[0], array_slice($words, 0, 2)))); $avatarColors = ['lt-avatar--orange', 'lt-avatar--green', 'lt-avatar--purple', '']; $avatarColor = $avatarColors[abs(crc32($displayName)) % count($avatarColors)]; + $commentUserId = (int)($comment['user_id'] ?? 0); ?>
- + - + $w[0], array_slice($_lt_words, 0, 2)))); + $_lt_userId = (int)($_lt_user['user_id'] ?? 0); + $_lt_avatarColors = ['lt-avatar--orange', 'lt-avatar--green', 'lt-avatar--purple', '']; + $_lt_avatarColor = $_lt_avatarColors[abs(crc32($_lt_displayName)) % count($_lt_avatarColors)]; + ?> + + ADMIN