diff --git a/.env.example b/.env.example index bfc1b08923..a8321dbc19 100644 --- a/.env.example +++ b/.env.example @@ -6,6 +6,10 @@ FRANCE_CONNECT_CLIENT_ID= FRANCE_CONNECT_CLIENT_SECRET= FRANCE_CONNECT_MESRI_ENDPOINT= +# Token GitHub utilisé par le serveur pour créer les branches et Pull Requests +# Scopes recommandés (fine-grained): Contents (read/write), Pull requests (read/write) +GITHUB_TOKEN= + MCP_CLIENT_ID= MCP_CLIENT_SECRET= MCP_PROVIDER= diff --git a/.talismanrc b/.talismanrc deleted file mode 100644 index 35ddf200cb..0000000000 --- a/.talismanrc +++ /dev/null @@ -1,2 +0,0 @@ -scopeconfig: - - scope: node diff --git a/backend/configure.ts b/backend/configure.ts index 6908776ef6..c01dbad015 100644 --- a/backend/configure.ts +++ b/backend/configure.ts @@ -23,7 +23,9 @@ export default function (app: express.Application) { loadRoutes(app) app.use(express.urlencoded({ extended: true, limit: "1024kb" })) - app.set("trust proxy", true) + // Configure trust proxy with the number of hops instead of 'true' + // This ensures rate limiting works correctly + app.set("trust proxy", 1) // The error handler must be before any other error middleware and after all controllers Sentry.setupExpressErrorHandler(app) diff --git a/backend/controllers/contributions.ts b/backend/controllers/contributions.ts new file mode 100644 index 0000000000..a1dbad2fa7 --- /dev/null +++ b/backend/controllers/contributions.ts @@ -0,0 +1,249 @@ +import axios from "axios" +import { dump as yamlDump } from "js-yaml" +import { Request, Response } from "express" +import * as Sentry from "@sentry/node" + +const owner = process.env.GITHUB_OWNER +const repository_name = process.env.GITHUB_REPOSITORY + +function slugify(input: string): string { + return input + .normalize("NFD") // Decompose accented characters (é → e + ´) + .replace(/[\u0300-\u036f]/g, "") // Remove diacritics + .toLowerCase() // Convert to lowercase + .replace(/[^a-z0-9\s_-]/g, "") // Keep only letters, digits, spaces, _ and - + .replace(/\s+/g, "-") // Replace multiple spaces with a single dash + .replace(/-+/g, "-") // Replace multiple dashes with a single dash + .slice(0, 80) // Limit to 80 characters +} + +function isValidSlug(value?: string) { + if (!value || typeof value !== "string") return false + if (value.includes("..") || value.includes("/") || value.includes("\\")) { + return false + } + return /^[a-z0-9_-]+$/.test(value) +} + +function sanitizeMultiline(str?: string) { + if (!str) return [] + return str + .split(/\r?\n/) + .map((l) => l.trim()) + .filter((l) => l.length) +} + +async function checkInstitutionExists( + institutionSlug: string, + githubApi: any, +): Promise { + const institutionPath = `data/institutions/${institutionSlug}.yml` + try { + await githubApi.get( + `/repos/${owner}/${repository_name}/contents/${encodeURIComponent(institutionPath)}?ref=main`, + ) + return true + } catch (error: any) { + if (error.response?.status === 404) return false + throw error + } +} + +function validateRequiredFields(body: any): string | null { + const { + contributorName, + institutionName, + institutionSlug, + title, + description, + } = body + + if ( + !contributorName || + !institutionName || + !institutionSlug || + !title || + !description + ) { + return "Champs obligatoires manquants" + } + if (!isValidSlug(institutionSlug)) { + return "Format institutionSlug invalide" + } + if (description.length > 420) { + return "Description > 420 caractères" + } + return null +} + +function buildBenefitData(body: any) { + const { + title, + institutionSlug, + description, + criteres, + profils, + urls, + typeCategorie, + periodicite, + autresConditions, + } = body + + return { + label: title, + institution: institutionSlug, + description, + conditions: sanitizeMultiline(autresConditions), + conditions_generales: Object.entries(criteres || {}) + .filter(([, v]) => v) + .map(([k, v]) => `${k}: ${v}`), + profils: profils || [], + link: urls?.information || undefined, + instructions: urls?.guide || undefined, + form: urls?.form || undefined, + teleservicePrefill: urls?.teleservice || undefined, + type: typeCategorie?.[0] || "bool", + periodicite: periodicite || "ponctuelle", + } +} + +function createInstitutionData( + institutionName: string, + institutionSlug: string, +) { + return { + name: institutionName, + type: "autre", + imgSrc: "img/institutions/placeholder.png", + slug: institutionSlug, + } +} + +function buildPullRequestBody(body: any): string { + const { + contributorName, + title, + institutionName, + typeCategorie, + periodicite, + description, + } = body + + const sections = [ + `Contributeur: **${contributorName}**`, + `Aide : **${title}**`, + `Institution: ${institutionName}`, + `Type: ${typeCategorie}`, + `Périodicité: ${periodicite}`, + `Description: ${description}`, + ] + return sections.join("\n") +} + +export async function handleBenefitContribution(req: Request, res: Response) { + if (!process.env.GITHUB_TOKEN) { + return res + .status(500) + .json({ message: "GITHUB_TOKEN manquant côté serveur" }) + } + + try { + const { institutionName, institutionSlug, title } = req.body || {} + + // Validation + const validationError = validateRequiredFields(req.body) + if (validationError) { + return res.status(400).json({ message: validationError }) + } + + // Generate and validate benefit slug + const benefitSlug = `${institutionSlug}_${slugify(title)}` + if (!isValidSlug(benefitSlug)) { + return res.status(400).json({ message: "Format benefitSlug invalide" }) + } + + // Build data + const benefitData = buildBenefitData(req.body) + + // GitHub API setup + const githubApi = axios.create({ + baseURL: "https://api.github.com", + headers: { + Authorization: `Bearer ${process.env.GITHUB_TOKEN}`, + Accept: "application/vnd.github+json", + "User-Agent": "aides-jeunes-contribution-service", + }, + }) + + const mainBranch = "main" + const newBranch = `contribution/${benefitSlug}` + + // Get main branch SHA + const refResp = await githubApi.get( + `/repos/${owner}/${repository_name}/git/ref/heads/${mainBranch}`, + ) + const baseSha = refResp.data.object.sha + + // Create new branch + await githubApi.post(`/repos/${owner}/${repository_name}/git/refs`, { + ref: `refs/heads/${newBranch}`, + sha: baseSha, + }) + + // Check if institution exists + const institutionExists = await checkInstitutionExists( + institutionSlug, + githubApi, + ) + + // Prepare files to commit + const files: { path: string; content: string }[] = [] + + if (!institutionExists) { + const institutionPath = `data/institutions/${institutionSlug}.yml` + const institutionFile = yamlDump( + createInstitutionData(institutionName, institutionSlug), + ) + files.push({ path: institutionPath, content: institutionFile }) + } + + const benefitPath = `data/benefits/javascript/${benefitSlug}.yml` + files.push({ path: benefitPath, content: yamlDump(benefitData) }) + + // Commit files to new branch + for (const f of files) { + const message = `Contribution: ajout fichier ${f.path}` + const contentB64 = Buffer.from(f.content, "utf8").toString("base64") + await githubApi.put( + `/repos/${owner}/${repository_name}/contents/${encodeURIComponent(f.path)}`, + { + message, + content: contentB64, + branch: newBranch, + }, + ) + } + + // Create pull request + const prResp = await githubApi.post( + `/repos/${owner}/${repository_name}/pulls`, + { + title: `[Contribution simplifiée] Ajout aide ${title}`, + head: newBranch, + base: mainBranch, + body: buildPullRequestBody(req.body), + maintainer_can_modify: true, + }, + ) + + return res.json({ pullRequestUrl: prResp.data.html_url }) + } catch (error: any) { + console.error("Contribution error", error?.response?.data || error) + Sentry.captureException(error) + const status = error?.response?.status || 500 + const message = + error?.response?.data?.message || + "Erreur serveur lors de la création de la PR" + return res.status(status).json({ message }) + } +} diff --git a/backend/routes-loader/api.ts b/backend/routes-loader/api.ts index 100648fdbc..5a3fd5332a 100644 --- a/backend/routes-loader/api.ts +++ b/backend/routes-loader/api.ts @@ -13,6 +13,7 @@ import supportRoutes from "../routes/support.js" import teleservicesRoutes from "../routes/teleservices.js" import webhookRoutes from "../routes/webhook.js" import moncompteproRoutes from "../routes/moncomptepro.js" +import contributionsRoutes from "../routes/contributions.js" const api = express() @@ -29,6 +30,7 @@ simulationRoutes(api) supportRoutes(api) teleservicesRoutes(api) webhookRoutes(api) +contributionsRoutes(api) api.all("*", function (req, res) { res.sendStatus(404) diff --git a/backend/routes/contributions.ts b/backend/routes/contributions.ts new file mode 100644 index 0000000000..89c3a4c7d6 --- /dev/null +++ b/backend/routes/contributions.ts @@ -0,0 +1,20 @@ +import express from "express" +import { rateLimit } from "express-rate-limit" +import { handleBenefitContribution } from "../controllers/contributions.js" + +const router = express.Router() +router.use(express.json({ limit: "512kb" })) + +const postBenefitLimiter = rateLimit({ + windowMs: 60 * 1000, // 1 minute window + max: 5, // limit each IP to 5 requests per windowMs + standardHeaders: true, + legacyHeaders: false, + message: { message: "Trop de requêtes. Veuillez réessayer plus tard." }, +}) + +router.post("/benefit", postBenefitLimiter, handleBenefitContribution) + +export default function contributionsRoutes(app: express.Application) { + app.use("/contributions", router) +} diff --git a/src/router.ts b/src/router.ts index 950a4190cd..342cbd9c27 100644 --- a/src/router.ts +++ b/src/router.ts @@ -15,6 +15,14 @@ const router = createRouter({ name: "home", component: context.Home, }, + { + path: "/contribuer", + name: "contribuer", + component: () => import("./views/contribuer.vue"), + meta: { + headTitle: `Proposer une nouvelle aide sur ${context.name}`, + }, + }, { path: "/callback", name: "callback", diff --git a/src/views/contribuer.vue b/src/views/contribuer.vue new file mode 100644 index 0000000000..99cbdb3076 --- /dev/null +++ b/src/views/contribuer.vue @@ -0,0 +1,900 @@ + + +