Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: 🛂 implement rate limit on api requests #681

Merged
merged 2 commits into from
Jan 9, 2025
Merged
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
3 changes: 3 additions & 0 deletions .github/workflows/main-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,9 @@ jobs:
run: IMAGE_TAG=${{ needs.build-backend.outputs.image_tag }} docker compose -f docker-compose.yml -f docker-compose.ci.yml up -d
- run: docker compose exec backend coverage run -m pytest
- run: docker compose exec backend coverage xml --ignore-errors
- name: Rate limit test
run: docker compose exec backend locust -f tests/locustfile.py -t 5s --headless -H http://localhost:5000/api
continue-on-error: true
- name: Produce the coverage report
uses: insightsengineering/coverage-action@v2
with:
Expand Down
10 changes: 9 additions & 1 deletion backend/pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,2 +1,10 @@
[tool.ruff]
exclude = ["frontend"]
exclude = ["frontend"]

[tool.locust]
locustfile = "/tests/locustfile.py"
headless = true
host = "http://localhost:5000/api"
run-time = "5s"
spawn-rate = 25
users = 2
4 changes: 3 additions & 1 deletion backend/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,12 @@ boto3==1.35.92
autodynatrace==2.1.1
PyJWT==2.10.1
cryptography==44.0.0
slowapi===0.1.9
# ML
basegun-ml==2.0.5
numpy<3.0.0
# Dev
pytest==8.3.4
coverage==7.6.10
Faker==33.3.0
Faker==33.3.0
locust==2.32.5
6 changes: 6 additions & 0 deletions backend/src/main.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from slowapi import Limiter, _rate_limit_exceeded_handler
from slowapi.errors import RateLimitExceeded
from slowapi.util import get_remote_address

from .config import HEADERS
from .router import router

limiter = Limiter(key_func=get_remote_address)
app = FastAPI(docs_url="/api/docs")
app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)

app.add_middleware(
CORSMiddleware,
Expand Down
18 changes: 16 additions & 2 deletions backend/src/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@
status,
)
from fastapi.responses import PlainTextResponse
from slowapi import Limiter
from slowapi.util import get_remote_address
from user_agents import parse

from .config import (
Expand All @@ -35,21 +37,26 @@
from .utils import get_current_user, send_mail, upload_image

router = APIRouter(prefix="/api")
limiter = Limiter(key_func=get_remote_address)


@router.get("/", response_class=PlainTextResponse)
def home():
@limiter.limit("60/minute")
def home(request: Request):
return "Basegun backend"


@router.get("/version", response_class=PlainTextResponse)
def version():
@limiter.limit("60/minute")
def version(request: Request):
return APP_VERSION


@router.get("/contact-details")
@limiter.limit("60/minute")
async def phone_number(
current_user: Annotated[dict, Depends(get_current_user)],
request: Request,
):
if current_user.get("idp") != "proxyma":
raise HTTPException(
Expand All @@ -63,6 +70,7 @@ async def phone_number(


@router.post("/upload")
@limiter.limit("60/minute")
async def imageupload(
request: Request,
response: Response,
Expand Down Expand Up @@ -141,6 +149,7 @@ async def imageupload(


@router.post("/identification-feedback")
@limiter.limit("60/minute")
async def log_feedback(request: Request, user_id: Union[str, None] = Cookie(None)):
res = await request.json()

Expand All @@ -155,6 +164,7 @@ async def log_feedback(request: Request, user_id: Union[str, None] = Cookie(None


@router.post("/tutorial-feedback")
@limiter.limit("60/minute")
async def log_tutorial_feedback(
request: Request, user_id: Union[str, None] = Cookie(None)
):
Expand All @@ -178,7 +188,9 @@ async def log_tutorial_feedback(


@router.post("/expert-contact")
@limiter.limit("60/minute")
async def expert_contact(
request: Request,
firstname: Annotated[str, Form()],
lastname: Annotated[str, Form()],
nigend: Annotated[str, Form()],
Expand Down Expand Up @@ -219,7 +231,9 @@ async def expert_contact(


@router.post("/identification-alarm-gun")
@limiter.limit("60/minute")
async def image_alarm_gun(
request: Request,
image: UploadFile = File(...),
):
try:
Expand Down
18 changes: 18 additions & 0 deletions backend/tests/locustfile.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import time

from locust import HttpUser, task


class TestLocust(HttpUser):
@task
def rate_limit_version(self):
self.client.get("/version")

@task
def test_upload(self):
with open("./tests/images/revolver.jpg", "rb") as f:
self.client.post(
"/upload",
files={"image": f},
data={"date": time.time()},
)
1 change: 1 addition & 0 deletions frontend/src/components.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ declare module 'vue' {
DsfrAlert: typeof import('@gouvminint/vue-dsfr')['DsfrAlert']
DsfrButton: typeof import('@gouvminint/vue-dsfr')['DsfrButton']
DsfrCheckbox: typeof import('@gouvminint/vue-dsfr')['DsfrCheckbox']
DsfrErrorPage: typeof import('@gouvminint/vue-dsfr')['DsfrErrorPage']
DsfrFileUpload: typeof import('@gouvminint/vue-dsfr')['DsfrFileUpload']
DsfrHeader: typeof import('@gouvminint/vue-dsfr')['DsfrHeader']
DsfrInput: typeof import('@gouvminint/vue-dsfr')['DsfrInput']
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/main.css
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@
h2 {
font-size: 1.3rem;
}

.error-img {
width: 120%;
}
}
.text-blue {
color: var(--blue-france-sun-113-625);
Expand Down
17 changes: 6 additions & 11 deletions frontend/src/router/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ const HomePage = () => import("@/views/HomePage.vue");
const StartPage = () => import("@/views/StartPage.vue");
const InstructionsPage = () => import("@/views/InstructionsPage.vue");
const ErrorPage = () => import("@/views/ErrorPage.vue");
const PageNotFound = () => import("@/views/PageNotFound.vue");
const AboutPage = () => import("@/views/AboutPage.vue");
const LegalPage = () => import("@/views/LegalPage.vue");
const ContactPage = () => import("@/views/ContactPage.vue");
Expand Down Expand Up @@ -173,6 +172,7 @@ const routes: RouteRecordRaw[] = [
},
{
path: "/erreur",
alias: "/:pathMach(.*)*",
name: "ErrorPage",
component: ErrorPage,
meta: {
Expand All @@ -190,15 +190,6 @@ const routes: RouteRecordRaw[] = [
title: "Accessibilité",
},
},
{
path: "/:pathMach(.*)*",
name: "PageNotFound",
component: PageNotFound,
meta: {
wholeLogo: true,
title: "Page non trouvée",
},
},
{
path: "/guide-contact",
name: "ExpertContact",
Expand Down Expand Up @@ -269,7 +260,11 @@ const router = createRouter({
router.beforeEach((to, from, next) => {
console.log(to);
document.title = "Basegun | " + to.meta.title;
next();
if (to.name === "ErrorPage" && !to.query.status) {
next({ ...to, query: { ...to.query, status: 404 } });
} else {
next();
}
});

export default router;
66 changes: 59 additions & 7 deletions frontend/src/views/ErrorPage.vue
Original file line number Diff line number Diff line change
@@ -1,15 +1,67 @@
<script lang="ts" setup>
import { useRoute } from "vue-router";
const route = useRoute();
const errorStatus = route.query.status || 0o0;
const errorMessage = computed(() => {
if (errorStatus == 429) {
return {
title: " - Trop de requêtes",
description:
"Il semblerait que vous ayez effectué plusieurs requêtes en quelques minutes. Veuillez réessayer plus tard.",
};
} else if (errorStatus == 500) {
return {
title: " - Erreur interne du serveur",
description:
"Une erreur interne lié au serveur est survenue. Veuillez réessayer plus tard.",
};
} else if (errorStatus == 401) {
return {
title: " - Non autorisé",
description:
"Vous n'êtes pas autorisé à accéder à cette page. Veuillez vous authentifier.",
};
} else if (errorStatus == 404) {
return {
title: " - Page non trouvée",
description:
"Vous avez saisi ou suivi un lien vers une page qui n'existe pas.",
};
} else {
return {
title: " - Erreur inconnue",
description: "Une erreur est survenue. Veuillez réessayer plus tard.",
};
}
});
</script>

<template>
<div class="fr-container">
<div class="text-center relative top-1/6 m-4">
<h1>Erreur</h1>
<p>Une erreur est survenue dans le traitement de votre requête.</p>
<div class="text-center relative top-1/6 <md:top-4 m-4">
<DsfrErrorPage
:title="'Erreur ' + errorStatus.concat('', errorMessage.title)"
subtitle=""
:description="errorMessage.description"
help="Si vous êtes sur cette page, c'est que vous avez rencontré un problème. Réessayer plus tard et si le problème persiste, contactez-nous."
/>

<p>
Veuillez réessayer ou
<router-link :to="{ name: 'ContactPage' }"> nous contacter </router-link
>.
Vous pouvez retourner vers la page d'accueil ou
<strong
><router-link :to="{ name: 'ContactPage' }">
nous contacter.
</router-link></strong
>
</p>
<router-link v-slot="{ navigate }" :to="{ name: 'StartPage' }">
<DsfrButton label="Retour" @click="navigate()" />
<DsfrButton
icon="ri-home-4-line"
label="Retour vers l'accueil"
@click="navigate()"
/>
</router-link>
</div>
</div>
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/views/InstructionsPage.vue
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,8 @@ async function uploadImage(base64: string, fileName: string) {
router.push(nextRoute);
} catch (error) {
console.log(error);
router.push({ name: "ErrorPage" });
const status = error.status;
router.push({ name: "ErrorPage", query: { status } });
}
try {
Expand Down
11 changes: 0 additions & 11 deletions frontend/src/views/PageNotFound.vue

This file was deleted.

Loading