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:
@@ -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;
|
||||
Reference in New Issue
Block a user