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 <noreply@anthropic.com>
This commit is contained in:
@@ -26,3 +26,14 @@ ALLOWED_HOSTS=localhost,127.0.0.1
|
|||||||
|
|
||||||
# Timezone (default: America/New_York)
|
# Timezone (default: America/New_York)
|
||||||
TIMEZONE=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
|
||||||
|
|||||||
@@ -0,0 +1,177 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* User Avatar API
|
||||||
|
*
|
||||||
|
* Serves profile pictures fetched from lldap via LDAP.
|
||||||
|
* Caches images locally to avoid repeated LDAP queries.
|
||||||
|
*
|
||||||
|
* GET /api/user_avatar.php?user_id=123
|
||||||
|
* Returns the user's JPEG avatar (from cache or LDAP).
|
||||||
|
* Returns 404 if the user has no avatar set in lldap.
|
||||||
|
*/
|
||||||
|
|
||||||
|
ini_set('display_errors', 0);
|
||||||
|
error_reporting(E_ALL);
|
||||||
|
|
||||||
|
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
||||||
|
RateLimitMiddleware::apply('api');
|
||||||
|
|
||||||
|
if (session_status() === PHP_SESSION_NONE) {
|
||||||
|
session_start();
|
||||||
|
}
|
||||||
|
|
||||||
|
require_once dirname(__DIR__) . '/config/config.php';
|
||||||
|
require_once dirname(__DIR__) . '/helpers/Database.php';
|
||||||
|
|
||||||
|
// Must be authenticated
|
||||||
|
if (!isset($_SESSION['user']['user_id'])) {
|
||||||
|
http_response_code(401);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
|
||||||
|
http_response_code(405);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$cfg = $GLOBALS['config'];
|
||||||
|
|
||||||
|
// Validate user_id parameter
|
||||||
|
$userId = isset($_GET['user_id']) ? (int)$_GET['user_id'] : 0;
|
||||||
|
if ($userId <= 0) {
|
||||||
|
http_response_code(400);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure LDAP is enabled and extension is loaded
|
||||||
|
if (!$cfg['LDAP_ENABLED'] || !extension_loaded('ldap')) {
|
||||||
|
http_response_code(404);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure avatar cache directory exists
|
||||||
|
$cacheDir = rtrim($cfg['AVATAR_CACHE_DIR'], '/');
|
||||||
|
if (!is_dir($cacheDir)) {
|
||||||
|
mkdir($cacheDir, 0755, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
$cacheFile = $cacheDir . '/user_' . $userId . '.jpg';
|
||||||
|
$cacheTtl = (int)($cfg['AVATAR_CACHE_TTL'] ?? 3600);
|
||||||
|
|
||||||
|
// Serve from cache if fresh
|
||||||
|
if (file_exists($cacheFile) && (time() - filemtime($cacheFile)) < $cacheTtl) {
|
||||||
|
header('Content-Type: image/jpeg');
|
||||||
|
header('Cache-Control: private, max-age=' . $cacheTtl);
|
||||||
|
header('X-Avatar-Source: cache');
|
||||||
|
readfile($cacheFile);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A sentinel empty file means "no avatar" — don't re-query LDAP until TTL expires
|
||||||
|
$noAvatarSentinel = $cacheDir . '/user_' . $userId . '.none';
|
||||||
|
if (file_exists($noAvatarSentinel) && (time() - filemtime($noAvatarSentinel)) < $cacheTtl) {
|
||||||
|
http_response_code(404);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look up username from DB
|
||||||
|
try {
|
||||||
|
$conn = Database::getConnection();
|
||||||
|
$stmt = $conn->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;
|
||||||
+14
-1
@@ -4522,7 +4522,20 @@ body.lt-is-offline .lt-main { margin-top: 2rem; transition: margin-top 0.25s eas
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
user-select: none;
|
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 */
|
/* Sizes */
|
||||||
.lt-avatar--xs { width: 1.5rem; height: 1.5rem; font-size: 0.55rem; }
|
.lt-avatar--xs { width: 1.5rem; height: 1.5rem; font-size: 0.55rem; }
|
||||||
.lt-avatar--sm { width: 2rem; height: 2rem; font-size: 0.65rem; }
|
.lt-avatar--sm { width: 2rem; height: 2rem; font-size: 0.65rem; }
|
||||||
|
|||||||
+12
-1
@@ -87,7 +87,18 @@ $GLOBALS['config'] = [
|
|||||||
// Default: America/New_York (EST/EDT)
|
// Default: America/New_York (EST/EDT)
|
||||||
// Common options: America/Chicago (CST), America/Denver (MST), America/Los_Angeles (PST), UTC
|
// Common options: America/Chicago (CST), America/Denver (MST), America/Los_Angeles (PST), UTC
|
||||||
'TIMEZONE' => $envVars['TIMEZONE'] ?? 'America/New_York',
|
'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
|
// Set PHP default timezone
|
||||||
|
|||||||
+11
-2
@@ -370,11 +370,12 @@ include __DIR__ . '/layout_header.php';
|
|||||||
$threadClass = $parentId ? 'comment-reply' : 'comment-root';
|
$threadClass = $parentId ? 'comment-reply' : 'comment-root';
|
||||||
$dateStr = date('M d, Y H:i', strtotime($comment['created_at']));
|
$dateStr = date('M d, Y H:i', strtotime($comment['created_at']));
|
||||||
$editedIndicator = !empty($comment['updated_at']) ? ' <span class="comment-edited lt-text-xs lt-text-muted">(edited)</span>' : '';
|
$editedIndicator = !empty($comment['updated_at']) ? ' <span class="comment-edited lt-text-xs lt-text-muted">(edited)</span>' : '';
|
||||||
// Avatar initials + color
|
// Avatar initials + color (fallback when no photo)
|
||||||
$words = array_filter(explode(' ', $displayName));
|
$words = array_filter(explode(' ', $displayName));
|
||||||
$initials = strtoupper(implode('', array_map(fn($w) => $w[0], array_slice($words, 0, 2))));
|
$initials = strtoupper(implode('', array_map(fn($w) => $w[0], array_slice($words, 0, 2))));
|
||||||
$avatarColors = ['lt-avatar--orange', 'lt-avatar--green', 'lt-avatar--purple', ''];
|
$avatarColors = ['lt-avatar--orange', 'lt-avatar--green', 'lt-avatar--purple', ''];
|
||||||
$avatarColor = $avatarColors[abs(crc32($displayName)) % count($avatarColors)];
|
$avatarColor = $avatarColors[abs(crc32($displayName)) % count($avatarColors)];
|
||||||
|
$commentUserId = (int)($comment['user_id'] ?? 0);
|
||||||
?>
|
?>
|
||||||
<div class="comment <?= $depthClass ?> <?= $threadClass ?>"
|
<div class="comment <?= $depthClass ?> <?= $threadClass ?>"
|
||||||
data-comment-id="<?= $commentId ?>"
|
data-comment-id="<?= $commentId ?>"
|
||||||
@@ -384,7 +385,15 @@ include __DIR__ . '/layout_header.php';
|
|||||||
<?php if ($parentId): ?><div class="thread-line" aria-hidden="true"></div><?php endif ?>
|
<?php if ($parentId): ?><div class="thread-line" aria-hidden="true"></div><?php endif ?>
|
||||||
<div class="comment-content">
|
<div class="comment-content">
|
||||||
<div class="comment-header lt-flex lt-flex-gap-sm lt-flex-align-center">
|
<div class="comment-header lt-flex lt-flex-gap-sm lt-flex-align-center">
|
||||||
<div class="lt-avatar lt-avatar--xs <?= $avatarColor ?>" aria-hidden="true"><?= htmlspecialchars($initials) ?></div>
|
<div class="lt-avatar lt-avatar--xs <?= $avatarColor ?>" aria-hidden="true">
|
||||||
|
<?php if ($commentUserId > 0): ?>
|
||||||
|
<img src="/api/user_avatar.php?user_id=<?= $commentUserId ?>"
|
||||||
|
alt=""
|
||||||
|
class="lt-avatar-img"
|
||||||
|
onerror="this.style.display='none'">
|
||||||
|
<?php endif ?>
|
||||||
|
<span class="lt-avatar-initials"><?= htmlspecialchars($initials) ?></span>
|
||||||
|
</div>
|
||||||
<span class="comment-user lt-text-amber"><?= htmlspecialchars($displayName) ?></span>
|
<span class="comment-user lt-text-amber"><?= htmlspecialchars($displayName) ?></span>
|
||||||
<span class="comment-date lt-text-xs lt-text-muted">
|
<span class="comment-date lt-text-xs lt-text-muted">
|
||||||
<span class="ts-cell"
|
<span class="ts-cell"
|
||||||
|
|||||||
+18
-1
@@ -151,7 +151,24 @@ $_lt_navActive = $activeNav ?? 'dashboard';
|
|||||||
|
|
||||||
<div class="lt-header-right">
|
<div class="lt-header-right">
|
||||||
<?php if (!empty($_lt_user)): ?>
|
<?php if (!empty($_lt_user)): ?>
|
||||||
<span class="lt-header-user"><?= htmlspecialchars($_lt_user['display_name'] ?? $_lt_user['username'] ?? '', ENT_QUOTES, 'UTF-8') ?></span>
|
<?php
|
||||||
|
$_lt_displayName = $_lt_user['display_name'] ?? $_lt_user['username'] ?? '';
|
||||||
|
$_lt_words = array_filter(explode(' ', $_lt_displayName));
|
||||||
|
$_lt_initials = strtoupper(implode('', array_map(fn($w) => $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)];
|
||||||
|
?>
|
||||||
|
<div class="lt-avatar lt-avatar--sm <?= $_lt_avatarColor ?>" aria-hidden="true" title="<?= htmlspecialchars($_lt_displayName, ENT_QUOTES, 'UTF-8') ?>">
|
||||||
|
<?php if ($_lt_userId > 0): ?>
|
||||||
|
<img src="/api/user_avatar.php?user_id=<?= $_lt_userId ?>"
|
||||||
|
alt=""
|
||||||
|
class="lt-avatar-img"
|
||||||
|
onerror="this.style.display='none'">
|
||||||
|
<?php endif ?>
|
||||||
|
<span class="lt-avatar-initials"><?= htmlspecialchars($_lt_initials) ?></span>
|
||||||
|
</div>
|
||||||
|
<span class="lt-header-user"><?= htmlspecialchars($_lt_displayName, ENT_QUOTES, 'UTF-8') ?></span>
|
||||||
<?php if ($_lt_isAdmin): ?>
|
<?php if ($_lt_isAdmin): ?>
|
||||||
<span class="lt-badge lt-badge-admin" aria-label="Administrator">ADMIN</span>
|
<span class="lt-badge lt-badge-admin" aria-label="Administrator">ADMIN</span>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|||||||
Reference in New Issue
Block a user