feat(aep): carte AEP — push Gitea 2026-04-28

This commit is contained in:
Jules Neny
2026-04-28 14:00:05 +02:00
commit 21c44d8193
86 changed files with 31855 additions and 0 deletions

View File

@@ -0,0 +1,87 @@
/**
* Circuit breaker budget IA
* Spec F §6 — seuil 20€/mois
*
* Avant chaque appel IA (worker ou chatbot) :
* const { blocked } = await checkBudget(config)
* if (blocked) throw createError({ statusCode: 503, ... })
*
* Paliers :
* >= 15€ → email Jules (géré par le worker)
* >= 18€ → flag budget_warning (bandeau site)
* >= 20€ → hard stop, HTTP 503
*/
export const BUDGET_MAX_EUR = 20
export const BUDGET_WARN_EUR = 18
export interface BudgetStatus {
cumulEur: number
blocked: boolean
warning: boolean
}
/**
* Calcule le cumul de dépenses IA du mois courant depuis stats_usage NocoDB.
* Retourne blocked=true si le budget est atteint.
*/
export async function checkBudget(config: {
nocodbUrl: string
nocodbToken: string
statsTableId: string
}): Promise<BudgetStatus> {
const { nocodbUrl, nocodbToken, statsTableId } = config
// Premier du mois courant à minuit UTC
const now = new Date()
const monthStart = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1))
const monthStartIso = monthStart.toISOString()
try {
// Fetch toutes les entrées du mois courant (NocoDB v2)
const url = `${nocodbUrl}/api/v2/tables/${statsTableId}/records`
const res = await $fetch<{ list: { cout_eur: number | null; timestamp: string }[] }>(
url,
{
headers: { 'xc-token': nocodbToken },
query: {
where: `(timestamp,gte,${monthStartIso})`,
limit: 1000,
fields: 'cout_eur,timestamp',
},
},
)
const rows = res?.list ?? []
const cumulEur = rows.reduce((sum, row) => sum + (Number(row.cout_eur) || 0), 0)
return {
cumulEur,
blocked: cumulEur >= BUDGET_MAX_EUR,
warning: cumulEur >= BUDGET_WARN_EUR,
}
} catch (e) {
// En cas d'erreur de lecture, on ne bloque PAS pour ne pas pénaliser les utilisateurs
console.warn('[circuitBreaker] Erreur lecture stats_usage — budget non vérifié:', (e as Error).message)
return { cumulEur: 0, blocked: false, warning: false }
}
}
/**
* Calcule le coût en EUR d'un appel Mistral Small.
* Prix : $0.20/M tokens_in, $0.60/M tokens_out (converti en EUR @0.93)
*/
export function calcCoutMistralSmall(tokensIn: number, tokensOut: number): number {
const usd = (tokensIn / 1_000_000) * 0.2 + (tokensOut / 1_000_000) * 0.6
return usd * 0.93
}
/**
* Calcule le coût en EUR d'un appel Mistral Nemo.
* Prix : $0.02/M tokens_in, $0.04/M tokens_out (converti en EUR @0.93)
*/
export function calcCoutMistralNemo(tokensIn: number, tokensOut: number): number {
const usd = (tokensIn / 1_000_000) * 0.02 + (tokensOut / 1_000_000) * 0.04
return usd * 0.93
}

109
server/utils/rateLimit.ts Normal file
View File

@@ -0,0 +1,109 @@
/**
* Rate limiting via Redis (ioredis)
* Fallback : compteur en mémoire si Redis indisponible (dev local sans Docker)
*
* Usage :
* const allowed = await checkRateLimit(ip, 'submit', 3)
* if (!allowed) throw createError({ statusCode: 429, ... })
*/
import Redis from 'ioredis'
let redisClient: Redis | null = null
let redisConnected = false
// Compteur en mémoire (fallback si Redis KO)
const memoryCounters: Map<string, { count: number; resetAt: number }> = new Map()
function getRedisClient(): Redis | null {
if (redisClient !== null) return redisConnected ? redisClient : null
try {
const config = useRuntimeConfig()
const url = config.redisUrl || 'redis://127.0.0.1:6379'
redisClient = new Redis(url, {
lazyConnect: true,
enableOfflineQueue: false,
maxRetriesPerRequest: 1,
connectTimeout: 2000,
})
redisClient.on('connect', () => {
redisConnected = true
console.log('[rateLimit] Redis connecté')
})
redisClient.on('error', (err) => {
redisConnected = false
console.warn('[rateLimit] Redis erreur — fallback mémoire activé:', err.message)
})
redisClient.connect().catch(() => {
redisConnected = false
})
return redisConnected ? redisClient : null
} catch (e) {
console.warn('[rateLimit] Impossible de créer le client Redis — fallback mémoire')
return null
}
}
/**
* Vérifie et incrémente le compteur d'appels.
* @param ip Adresse IP du client
* @param action Clé d'action (ex : 'submit', 'comment')
* @param maxPerDay Nombre max d'appels autorisés par jour
* @returns true si autorisé, false si limite dépassée
*/
export async function checkRateLimit(
ip: string,
action: string,
maxPerDay: number,
): Promise<boolean> {
const key = `ratelimit:${action}:${ip}:${todayKey()}`
const client = getRedisClient()
if (client && redisConnected) {
try {
const count = await client.incr(key)
if (count === 1) {
// Expire à minuit (secondes restantes dans la journée)
await client.expireat(key, tomorrowMidnightUnix())
}
return count <= maxPerDay
} catch (e) {
console.warn('[rateLimit] Redis incr échoué — fallback mémoire:', (e as Error).message)
}
}
// Fallback mémoire
return memoryRateLimit(key, maxPerDay)
}
// ── Helpers ────────────────────────────────────────────────────────────────────
function todayKey(): string {
const d = new Date()
return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, '0')}-${String(d.getUTCDate()).padStart(2, '0')}`
}
function tomorrowMidnightUnix(): number {
const d = new Date()
d.setUTCHours(24, 0, 0, 0)
return Math.floor(d.getTime() / 1000)
}
function memoryRateLimit(key: string, maxPerDay: number): boolean {
const now = Date.now()
const entry = memoryCounters.get(key)
if (!entry || entry.resetAt <= now) {
memoryCounters.set(key, { count: 1, resetAt: tomorrowMidnightUnix() * 1000 })
return true
}
entry.count++
return entry.count <= maxPerDay
}

View File

@@ -0,0 +1,87 @@
/**
* Rate limiting via fichiers JSON locaux
* Implémentation recommandée spec F §8 : /tmp/nav-ratelimit/{IP_hash}.json
*
* - IP hashée SHA-256 (RGPD — pas de stockage IP en clair)
* - Fichier JSON par IP, reset automatique si date != today
* - Dossier créé au premier appel si absent
*
* Usage :
* const allowed = await checkRateLimitJson(ip, 'chatbot', 10)
* if (!allowed) throw createError({ statusCode: 429, ... })
*/
import { createHash } from 'crypto'
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
import { join } from 'path'
const RATELIMIT_DIR = '/tmp/nav-ratelimit'
type RateLimitFile = {
[action: string]: { count: number; date: string }
}
function ensureDir() {
if (!existsSync(RATELIMIT_DIR)) {
mkdirSync(RATELIMIT_DIR, { recursive: true })
}
}
function hashIp(ip: string): string {
return createHash('sha256').update(ip).digest('hex')
}
function todayStr(): string {
const d = new Date()
return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, '0')}-${String(d.getUTCDate()).padStart(2, '0')}`
}
function readFile(ipHash: string): RateLimitFile {
const path = join(RATELIMIT_DIR, `${ipHash}.json`)
try {
return JSON.parse(readFileSync(path, 'utf-8'))
} catch {
return {}
}
}
function writeFile(ipHash: string, data: RateLimitFile) {
const path = join(RATELIMIT_DIR, `${ipHash}.json`)
writeFileSync(path, JSON.stringify(data), 'utf-8')
}
/**
* Vérifie et incrémente le compteur pour une IP et une action.
* @param ip Adresse IP du client (sera hashée SHA-256)
* @param action Clé d'action (ex : 'chatbot', 'submit', 'comment')
* @param maxPerDay Nombre max d'appels autorisés par jour
* @returns true si autorisé, false si limite dépassée
*/
export function checkRateLimitJson(
ip: string,
action: string,
maxPerDay: number,
): boolean {
ensureDir()
const ipHash = hashIp(ip)
const today = todayStr()
const data = readFile(ipHash)
const entry = data[action]
if (!entry || entry.date !== today) {
// Nouveau jour ou premier appel : reset et autoriser
data[action] = { count: 1, date: today }
writeFile(ipHash, data)
return true
}
if (entry.count >= maxPerDay) {
return false
}
entry.count++
writeFile(ipHash, data)
return true
}