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>
111 lines
5.9 KiB
JavaScript
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 }
|