add rate limit to user

This commit is contained in:
2025-11-30 22:31:46 -03:00
parent 6e726601d2
commit 8d312893fa
5 changed files with 264 additions and 94 deletions

View File

@@ -1,13 +1,22 @@
from http import HTTPStatus
from typing import Annotated from typing import Annotated
from aws_lambda_powertools.event_handler.api_gateway import Router from aws_lambda_powertools.event_handler.api_gateway import Router
from aws_lambda_powertools.event_handler.exceptions import ( from aws_lambda_powertools.event_handler.exceptions import (
NotFoundError, NotFoundError,
ServiceError,
) )
from aws_lambda_powertools.event_handler.openapi.params import Body from aws_lambda_powertools.event_handler.openapi.params import Body
from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair from layercake.dateutils import now, ttl
from layercake.dynamodb import (
DynamoDBPersistenceLayer,
KeyPair,
SortKey,
TransactKey,
)
from layercake.extra_types import CpfStr, NameStr from layercake.extra_types import CpfStr, NameStr
from api_gateway import JSONResponse
from boto3clients import dynamodb_client from boto3clients import dynamodb_client
from config import USER_TABLE from config import USER_TABLE
@@ -21,17 +30,96 @@ router = Router()
dyn = DynamoDBPersistenceLayer(USER_TABLE, dynamodb_client) dyn = DynamoDBPersistenceLayer(USER_TABLE, dynamodb_client)
class UserNotFoundError(NotFoundError): ...
class CPFConflictError(ServiceError):
def __init__(self, msg: str | dict):
super().__init__(HTTPStatus.CONFLICT, msg)
class RateLimitExceededError(ServiceError):
def __init__(self, msg: str | dict):
super().__init__(HTTPStatus.TOO_MANY_REQUESTS, msg)
@router.get('/<user_id>') @router.get('/<user_id>')
def get_user(user_id: str): def get_user(user_id: str):
return dyn.collection.get_item( user = dyn.collection.get_items(
KeyPair(user_id, '0'), TransactKey(user_id)
exc_cls=NotFoundError, + SortKey('0')
+ SortKey(
sk='RATE_LIMIT_EXCEEDED',
rename_key='rate_limit_exceeded',
)
+ SortKey(
sk='CREATED_BY',
rename_key='created_by',
),
) )
if not user:
return UserNotFoundError('User not found')
return user
@router.patch('/<user_id>') @router.patch('/<user_id>')
def update( def update(
user_id: str, user_id: str,
name: Annotated[NameStr, Body(embed=True)], name: Annotated[NameStr, Body(embed=True)],
cpf: Annotated[CpfStr, Body(embed=True)], new_cpf: Annotated[CpfStr, Body(embed=True, alias='cpf')],
): ... ):
now_ = now()
old_cpf = dyn.collection.get_item(
KeyPair(
pk=user_id,
sk=SortKey(
'0',
path_spec='cpf',
),
),
)
with dyn.transact_writer() as transact:
transact.put(
item={
'id': user_id,
'sk': 'RATE_LIMIT_EXCEEDED',
'ttl': ttl(start_dt=now_, hours=24),
'created_at': now_,
},
cond_expr='attribute_not_exists(sk)',
exc_cls=RateLimitExceededError,
)
transact.update(
key=KeyPair(user_id, '0'),
update_expr='SET #name = :name, cpf = :cpf, updated_at = :now',
expr_attr_names={
'#name': 'name',
},
expr_attr_values={
':name': name,
':cpf': new_cpf,
':now': now_,
},
cond_expr='attribute_exists(sk)',
)
if old_cpf != new_cpf:
transact.put(
item={
'id': 'cpf',
'cpf': new_cpf,
'user_id': user_id,
'created_at': now_,
},
cond_expr='attribute_not_exists(sk)',
exc_cls=CPFConflictError,
)
if old_cpf:
transact.delete(key=KeyPair('cpf', old_cpf))
return JSONResponse(status_code=HTTPStatus.OK)

View File

@@ -0,0 +1,31 @@
from http import HTTPMethod, HTTPStatus
from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair
from ...conftest import HttpApiProxy, LambdaContext
def test_update(
app,
seeds,
http_api_proxy: HttpApiProxy,
lambda_context: LambdaContext,
dynamodb_persistence_layer: DynamoDBPersistenceLayer,
):
user_id = '15bacf02-1535-4bee-9022-19d106fd7518'
r = app.lambda_handler(
http_api_proxy(
raw_path=f'/users/{user_id}',
method=HTTPMethod.PATCH,
body={
'name': 'Sérgio Rafael Siqueira',
'cpf': '07879819908',
},
),
lambda_context,
)
assert r['statusCode'] == HTTPStatus.OK
r = dynamodb_persistence_layer.collection.get_item(KeyPair(user_id, '0'))
assert r['name'] == 'Sérgio Rafael Siqueira'

View File

@@ -1,5 +1,6 @@
import type { Route } from './+types/profile' import type { Route } from './+types/profile'
import { AlertCircleIcon } from 'lucide-react'
import { useForm } from 'react-hook-form' import { useForm } from 'react-hook-form'
import { Link, useOutletContext } from 'react-router' import { Link, useOutletContext } from 'react-router'
import { PatternFormat } from 'react-number-format' import { PatternFormat } from 'react-number-format'
@@ -24,14 +25,34 @@ import {
import { Input } from '@repo/ui/components/ui/input' import { Input } from '@repo/ui/components/ui/input'
import { Spinner } from '@repo/ui/components/ui/spinner' import { Spinner } from '@repo/ui/components/ui/spinner'
import { type User } from '@repo/ui/routes/users/data' import { type User } from '@repo/ui/routes/users/data'
import { request as req, HttpMethod } from '@repo/util/request'
import { formSchema, type Schema } from './emails/data' import { formSchema, type Schema } from './emails/data'
import { useFetcher } from 'react-router' import { useFetcher } from 'react-router'
import { userContext } from '@repo/auth/context'
import {
Alert,
AlertDescription,
AlertTitle
} from '@repo/ui/components/ui/alert'
export async function action({ request }: Route.ActionArgs) { export async function action({ request, context }: Route.ActionArgs) {
const body = await request.json() const body = await request.json()
console.log(body) const user = context.get(userContext)
return { ok: true } const r = await req({
url: `/users/${user.sub}`,
method: HttpMethod.PATCH,
headers: new Headers({ 'Content-Type': 'application/json' }),
body: JSON.stringify(body),
request,
context
})
if (r.ok) {
return { ok: true }
}
return { ok: false, error: await r.json() }
} }
export default function Route({}: Route.ComponentProps) { export default function Route({}: Route.ComponentProps) {
@@ -52,95 +73,124 @@ export default function Route({}: Route.ComponentProps) {
return ( return (
<Form {...form}> <Form {...form}>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4"> <form onSubmit={handleSubmit(onSubmit)}>
<Card> <fieldset disabled={!!user?.rate_limit_exceeded} className="space-y-4">
<CardHeader> {user?.rate_limit_exceeded && (
<CardTitle className="text-2xl">Meu perfil</CardTitle> <Alert variant="destructive">
<CardDescription> <AlertCircleIcon />
Mantenha os dados do seu perfil atualizados para que apareçam <AlertTitle>Limite de atualizações excedido</AlertTitle>
corretamente em seus cursos e certificados. <AlertDescription>
</CardDescription> Nova tentativa disponível a partir de{' '}
</CardHeader> {remainingTime(user.rate_limit_exceeded.ttl)}
</AlertDescription>
</Alert>
)}
<CardContent className="space-y-4"> <Card>
<FormField <CardHeader>
control={control} <CardTitle className="text-2xl">Meu perfil</CardTitle>
name="name" <CardDescription>
render={({ field }) => ( Mantenha os dados do seu perfil atualizados para que apareçam
<FormItem> corretamente em seus cursos e certificados.
<FormLabel>Nome</FormLabel> </CardDescription>
<FormControl> </CardHeader>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField <CardContent className="space-y-4">
control={control} <FormField
name="email" control={control}
disabled={true} name="name"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Email</FormLabel> <FormLabel>Nome</FormLabel>
<FormControl> <FormControl>
<Input {...field} /> <Input {...field} />
</FormControl> </FormControl>
<FormLabel className="text-sm font-normal text-muted-foreground"> <FormMessage />
<span> </FormItem>
Para gerenciar os emails ou trocar o email principal, use )}
as{' '} />
<Link
to="emails"
className="text-blue-400 underline hover:no-underline"
>
configurações de emails
</Link>
</span>
</FormLabel>
<FormMessage />
</FormItem>
)}
/>
<FormField <FormField
control={control} control={control}
name="cpf" name="email"
render={({ field: { onChange, ref, ...props } }) => ( disabled={true}
<FormItem> render={({ field }) => (
<FormLabel>CPF</FormLabel> <FormItem>
<FormControl> <FormLabel>Email</FormLabel>
<PatternFormat <FormControl>
format="###.###.###-##" <Input {...field} />
mask="_" </FormControl>
placeholder="___.___.___-__" <FormLabel className="text-sm font-normal text-muted-foreground">
customInput={Input} <span>
getInputRef={ref} Para gerenciar os emails ou trocar o email principal,
onValueChange={({ value }) => { use as{' '}
onChange(value) <Link
}} to="emails"
{...props} className="text-blue-400 underline hover:no-underline"
/> >
</FormControl> configurações de emails
<FormMessage /> </Link>
</FormItem> </span>
)} </FormLabel>
/> <FormMessage />
</CardContent> </FormItem>
</Card> )}
/>
<div className="flex justify-end"> <FormField
<Button control={control}
type="submit" name="cpf"
className="cursor-pointer" render={({ field: { onChange, ref, ...props } }) => (
disabled={formState.isSubmitting} <FormItem>
> <FormLabel>CPF</FormLabel>
{formState.isSubmitting && <Spinner />} <FormControl>
Atualizar perfil <PatternFormat
</Button> format="###.###.###-##"
</div> mask="_"
placeholder="___.___.___-__"
customInput={Input}
getInputRef={ref}
onValueChange={({ value }) => {
onChange(value)
}}
{...props}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
<div className="flex justify-end">
<Button
type="submit"
className="cursor-pointer"
disabled={formState.isSubmitting}
>
{formState.isSubmitting && <Spinner />}
Atualizar perfil
</Button>
</div>
</fieldset>
</form> </form>
</Form> </Form>
) )
} }
function remainingTime(ttl: number) {
const date = new Date(ttl * 1000)
const day = date.toLocaleDateString('pt-BR', {
day: '2-digit',
month: '2-digit'
})
const time = date.toLocaleTimeString('pt-BR', {
hour: '2-digit',
minute: '2-digit'
})
return `${day} às ${time}`
}

View File

@@ -41,7 +41,7 @@ private_jwk = JsonWebKey.import_key(private_key)
# https://docs.authlib.org/en/v0.12/specs/rfc6750.html#authlib.oauth2.rfc6750.BearerToken.GRANT_TYPES_EXPIRES_IN # https://docs.authlib.org/en/v0.12/specs/rfc6750.html#authlib.oauth2.rfc6750.BearerToken.GRANT_TYPES_EXPIRES_IN
GRANT_TYPES_EXPIRES_IN = { GRANT_TYPES_EXPIRES_IN = {
'authorization_code': 60 * 3, # 3 minutes 'authorization_code': 3600, # 1 hour
'refresh_token': 3600, # 1 hour 'refresh_token': 3600, # 1 hour
} }

View File

@@ -5,6 +5,7 @@ export type User = {
name: string name: string
email: string email: string
cpf: string cpf: string
rate_limit_exceeded?: any
} }
export const headers = { export const headers = {