gigafibre-fsm/services/targo-hub/lib/avatars.js
louispaulb 14530787bb consolidate uncommitted multi-session work (NL staff-agent, user avatars/profil, HelpHint, ticket detail modules, skill icons, hub agent/payments/sync)
Lot de travaux accumulés non commités (plusieurs sessions), vérifiés : hub `node --check`
tout OK + build SPA propre.
- ops : AssignmentField, CommandPalette, IssueDetail/DetailModal + detail-sections/modules,
  UserAvatar/useAvatar, UserProfileDialog/EmployeeEditDialog, HelpHint, useSkillIcons,
  pages (Dashboard/Equipe/Clients/Tickets/Reports/Settings/Evaluations/LegacySync…),
  usePermissions/useUserGroups/useDetailModal, MainLayout, TicketStatusControl
- hub : staff-agent.js (couche commandes NL), agent.js/agent-tools.json/voice-agent.js,
  avatars.js, conversation.js, payments.js, sync-orchestrator.js, auth.js, campaigns.js,
  legacy-dispatch-sync.js, server.js

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 20:43:50 -04:00

111 lines
5.9 KiB
JavaScript

'use strict'
// ─────────────────────────────────────────────────────────────────────────────
// Photos de profil (avatars) par utilisateur. Stockage serveur keyé par courriel
// Authentik → suit l'usager entre appareils. Même patron d'identité que
// user-prefs.js : l'identité = x-authentik-email (forward-auth SSO). Un usager ne
// peut modifier QUE son propre avatar ; un admin peut cibler un autre via ?as=.
//
// GET /avatars/{email} → l'image (ou 404). Public-ish (chargée par <img>).
// POST /avatars { dataUrl } → définit MON avatar (self)
// POST /avatars?as=<e> { dataUrl } → (ADMIN) définit l'avatar d'un autre
// DELETE /avatars → supprime MON avatar (self) · ?as= pour admin
//
// Repli visuel (initiales + couleur) = côté client (<UserAvatar>) sur 404/erreur.
// ─────────────────────────────────────────────────────────────────────────────
const fs = require('fs')
const path = require('path')
const crypto = require('crypto')
const { json, parseBody, readJsonFile, writeJsonFile } = require('./helpers')
const DIR = '/app/data/avatars'
const INDEX = path.join(DIR, '_index.json') // { emailLower: { ext, mime, ts } }
function ensureDir () { if (!fs.existsSync(DIR)) fs.mkdirSync(DIR, { recursive: true }) }
// SVG volontairement EXCLU (une image de profil SVG = vecteur XSS via <img>/inline).
const ALLOWED = { 'image/png': 'png', 'image/jpeg': 'jpg', 'image/webp': 'webp', 'image/gif': 'gif' }
const MAX_BYTES = 1024 * 1024 // 1 Mo — les avatars sont petits ; le client redimensionne avant l'envoi.
const meOf = (req) => String(req.headers['x-authentik-email'] || '').toLowerCase()
function isAdmin (req) {
const g = String(req.headers['x-authentik-groups'] || req.headers['x-authentik-group'] || '')
return /(^|[,|;])\s*(ops-?admin|admin(istrateurs?)?|superviseurs?)\s*([,|;]|$)/i.test(g)
}
function decodeDataUrl (dataUrl) {
if (typeof dataUrl !== 'string') return null
const m = dataUrl.match(/^data:([^;,]+)(;base64)?,(.*)$/)
if (!m) return null
const mime = m[1].toLowerCase()
if (!ALLOWED[mime]) return { error: 'type' }
if (!m[2]) return { error: 'type' } // on exige du base64
let buffer
try { buffer = Buffer.from(m[3], 'base64') } catch { return null }
if (!buffer.length) return null
if (buffer.length > MAX_BYTES) return { error: 'too_large', size: buffer.length }
return { mime, ext: ALLOWED[mime], buffer }
}
const fileFor = (email, ext) => path.join(DIR, crypto.createHash('sha1').update(email).digest('hex') + '.' + ext)
async function handle (req, res, method, path_) {
// GET /avatars → index { avatars: { emailLower: version } } : liste des courriels
// AYANT une photo (+ version pour cache-bust). Le client s'en sert pour n'afficher
// le <img> QUE pour ces courriels et éviter un 404 par avatar sans photo.
if (method === 'GET' && (path_ === '/avatars' || path_ === '/avatars/')) {
const idx = readJsonFile(INDEX, {})
const avatars = {}
for (const [email, meta] of Object.entries(idx)) avatars[email] = (meta && meta.ts) || 1
res.setHeader('Cache-Control', 'no-cache')
return json(res, 200, { avatars })
}
// GET /avatars/{email}
if (method === 'GET') {
const email = decodeURIComponent(path_.replace(/^\/avatars\/?/, '')).trim().toLowerCase()
if (!email) return json(res, 400, { error: 'email requis' })
const idx = readJsonFile(INDEX, {})
const meta = idx[email]
if (!meta) { res.statusCode = 404; return res.end() }
const p = fileFor(email, meta.ext)
if (!fs.existsSync(p)) { res.statusCode = 404; return res.end() }
res.setHeader('Content-Type', meta.mime || 'application/octet-stream')
// Change de contenu à la même URL → revalidation courte + ?v=<ts> côté client pour le cache-busting immédiat.
res.setHeader('Cache-Control', 'public, max-age=300, must-revalidate')
res.statusCode = 200
return res.end(fs.readFileSync(p))
}
const me = meOf(req)
const as = String(new URL(req.url, 'http://localhost').searchParams.get('as') || '').toLowerCase()
const target = (as && as !== me && isAdmin(req)) ? as : me
if (!target) return json(res, 400, { error: 'utilisateur inconnu (x-authentik-email absent)' })
if (as && as !== me && !isAdmin(req)) return json(res, 403, { error: 'réservé aux administrateurs' })
if (method === 'POST') {
const b = await parseBody(req)
const decoded = decodeDataUrl(b && b.dataUrl)
if (!decoded) return json(res, 400, { error: 'dataUrl image invalide' })
if (decoded.error === 'type') return json(res, 415, { error: 'format non supporté (png, jpeg, webp, gif)' })
if (decoded.error === 'too_large') return json(res, 413, { error: `image trop lourde (${Math.round(decoded.size / 1024)} Ko > 1 Mo)` })
ensureDir()
const idx = readJsonFile(INDEX, {})
// Retire un ancien fichier d'extension différente pour éviter les orphelins.
if (idx[target] && idx[target].ext !== decoded.ext) { try { fs.unlinkSync(fileFor(target, idx[target].ext)) } catch { /* absent */ } }
fs.writeFileSync(fileFor(target, decoded.ext), decoded.buffer)
const ts = Date.now()
idx[target] = { ext: decoded.ext, mime: decoded.mime, ts }
writeJsonFile(INDEX, idx)
return json(res, 200, { ok: true, email: target, version: ts })
}
if (method === 'DELETE') {
const idx = readJsonFile(INDEX, {})
if (idx[target]) { try { fs.unlinkSync(fileFor(target, idx[target].ext)) } catch { /* absent */ } ; delete idx[target]; writeJsonFile(INDEX, idx) }
return json(res, 200, { ok: true, email: target })
}
return json(res, 405, { error: 'method not allowed' })
}
module.exports = { handle }