Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
2 changes: 0 additions & 2 deletions .talismanrc

This file was deleted.

4 changes: 3 additions & 1 deletion backend/configure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
249 changes: 249 additions & 0 deletions backend/controllers/contributions.ts
Original file line number Diff line number Diff line change
@@ -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<boolean> {
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?.[0] || "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 })
}
}
2 changes: 2 additions & 0 deletions backend/routes-loader/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand All @@ -29,6 +30,7 @@ simulationRoutes(api)
supportRoutes(api)
teleservicesRoutes(api)
webhookRoutes(api)
contributionsRoutes(api)

api.all("*", function (req, res) {
res.sendStatus(404)
Expand Down
20 changes: 20 additions & 0 deletions backend/routes/contributions.ts
Original file line number Diff line number Diff line change
@@ -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)
}
16 changes: 16 additions & 0 deletions src/components/home/contribute-banner.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<template>
<div class="fr-container fr-my-4w">
<div class="fr-alert fr-alert--info fr-mb-4w">
<p class="fr-text--sm">
Vous connaissez une aide manquante ? Vous pouvez désormais proposer une
nouvelle aide directement depuis notre formulaire de contribution
simplifié.
</p>
<p>
<router-link to="/contribuer" class="fr-link"
>Contribuer en ajoutant une aide</router-link
>
</p>
</div>
</div>
</template>
8 changes: 8 additions & 0 deletions src/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading
Loading