Files
Momento/memento-note/app/api/ai/title-suggestions/route.ts
Antigravity 4d96605144
All checks were successful
CI / Lint, Unit Tests & Build (push) Successful in 5m42s
CI / Deploy production (on server) (push) Successful in 33s
fix(security): Phase 1 P0 hardening from cross-project audit
Close open uploads, image-proxy SSRF, fail-open AI quotas in production,
auth gaps on app routes, and MCP tenant isolation issues.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-20 16:53:19 +00:00

162 lines
6.2 KiB
TypeScript

import { NextRequest, NextResponse } from 'next/server'
import { runLaneWithBillingUser, willUseByokForLane } from '@/lib/ai/provider-for-user'
import { getSystemConfig } from '@/lib/config'
import { auth } from '@/auth'
import { getAISettings } from '@/app/actions/ai-settings'
import { reserveUsageOrThrow, QuotaExceededError, QuotaServiceUnavailableError } from '@/lib/entitlements'
import { z } from 'zod'
import { hasUserAiConsent } from '@/lib/consent/server-consent'
const requestSchema = z.object({
content: z.string().min(1, "Le contenu ne peut pas être vide"),
})
/** Supprime les balises HTML pour extraire le texte brut */
function stripHtml(html: string): string {
return html
.replace(/<[^>]+>/g, ' ')
.replace(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/\s+/g, ' ')
.trim()
}
export async function POST(req: NextRequest) {
try {
// Check authentication and user setting
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// GDPR AI Consent check
if (!(await hasUserAiConsent())) {
return NextResponse.json({ error: 'ai_consent_required' }, { status: 403 })
}
const settings = await getAISettings(session.user.id)
if (settings.titleSuggestions === false) {
return NextResponse.json({ suggestions: [] })
}
const body = await req.json()
const { content: rawContent } = requestSchema.parse(body)
// Nettoyer le HTML (l'éditeur TipTap envoie du HTML)
const content = stripHtml(rawContent)
// Vérifier qu'il y a au moins 10 mots
const wordCount = content.split(/\s+/).filter(w => w.length > 0).length
if (wordCount < 10) {
return NextResponse.json(
{ error: 'Le contenu doit avoir au moins 10 mots' },
{ status: 400 }
)
}
const config = await getSystemConfig()
const { usedByok: willUseByok } = await willUseByokForLane('tags', config, session.user.id)
if (!willUseByok) {
try {
await reserveUsageOrThrow(session.user.id, 'auto_title')
} catch (err) {
if (err instanceof QuotaExceededError) {
return NextResponse.json(err.toJSON(), { status: 402 })
}
if (err instanceof QuotaServiceUnavailableError || process.env.NODE_ENV === 'production') {
return NextResponse.json({ error: 'QUOTA_SERVICE_UNAVAILABLE' }, { status: 503 })
}
console.error('[/api/ai/title-suggestions] Quota check error (fail-open):', err)
}
}
// Détecter la langue du contenu (simple détection basée sur les caractères et mots)
const isPersian = /[\u0600-\u06FF]/.test(content)
const isChinese = /[\u4E00-\u9FFF]/.test(content)
const isRussian = /[\u0400-\u04FF]/.test(content)
const isArabic = /[\u0600-\u06FF]/.test(content)
// Détection du français par des mots et caractères caractéristiques
const frenchWords = /\b(le|la|les|un|une|des|et|ou|mais|donc|pour|dans|sur|avec|sans|très|plus|moins|tout|tous|toute|toutes|ce|cette|ces|mon|ma|mes|ton|ta|tes|son|sa|ses|notre|nos|votre|vos|leur|leurs|je|tu|il|elle|nous|vous|ils|elles|est|sont|été|être|avoir|faire|aller|venir|voir|savoir|pouvoir|vouloir|falloir|comme|que|qui|dont|où|quand|pourquoi|comment|quel|quelle|quels|quelles)\b/i
const frenchAccents = /[éèêàâôûùïüç]/i
const isFrench = frenchWords.test(content) || frenchAccents.test(content)
// Déterminer la langue du prompt système
let promptLanguage = 'en'
let responseLanguage = 'English'
if (isFrench) {
promptLanguage = 'fr' // Français
responseLanguage = 'French'
} else if (isPersian) {
promptLanguage = 'fa' // Persan
responseLanguage = 'Persian'
} else if (isChinese) {
promptLanguage = 'zh' // Chinois
responseLanguage = 'Chinese'
} else if (isRussian) {
promptLanguage = 'ru' // Russe
responseLanguage = 'Russian'
} else if (isArabic) {
promptLanguage = 'ar' // Arabe
responseLanguage = 'Arabic'
}
// Générer des titres appropriés basés sur le contenu
const titlePrompt = promptLanguage === 'en'
? `You are a title generator. Generate 3 concise, descriptive titles for the following content.
IMPORTANT INSTRUCTIONS:
- Use ONLY the content provided below between the CONTENT_START and CONTENT_END markers
- Do NOT use any external knowledge or training data
- Focus on the main topics and themes in THIS SPECIFIC content
- Be specific to what is actually discussed
CONTENT_START: ${content.substring(0, 3000)} CONTENT_END
Respond ONLY with a JSON array: [{"title": "title1", "confidence": 0.95}, {"title": "title2", "confidence": 0.85}, {"title": "title3", "confidence": 0.75}]`
: `Tu es un générateur de titres. Génère 3 titres concis et descriptifs pour le contenu suivant en ${responseLanguage}.
INSTRUCTIONS IMPORTANTES :
- Utilise SEULEMENT le contenu fourni entre les marqueurs CONTENT_START et CONTENT_END
- N'utilise AUCUNE connaissance externe ou données d'entraînement
- Concentre-toi sur les sujets principaux et thèmes de CE CONTENU SPÉCIFIQUE
- Sois spécifique à ce qui est réellement discuté
CONTENT_START: ${content.substring(0, 3000)} CONTENT_END
Réponds SEULEMENT avec un tableau JSON: [{"title": "titre1", "confidence": 0.95}, {"title": "titre2", "confidence": 0.85}, {"title": "titre3", "confidence": 0.75}]`
const { result: titles } = await runLaneWithBillingUser(
'tags',
config,
session.user.id,
(provider) => provider.generateTitles(titlePrompt),
)
// Créer les suggestions
const suggestions = titles.map((t: any) => ({
title: t.title,
confidence: Math.round(t.confidence * 100),
reasoning: `Basé sur le contenu`
}))
return NextResponse.json({ suggestions })
} catch (error: any) {
if (error instanceof z.ZodError) {
return NextResponse.json({ error: error.issues }, { status: 400 })
}
return NextResponse.json(
{ error: error.message || 'Erreur lors de la génération des titres' },
{ status: 500 }
)
}
}