'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 ). // POST /avatars { dataUrl } → définit MON avatar (self) // POST /avatars?as= { 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 () 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 /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 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= 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 }