module Application.Helper.ApiRateLimit where -- Rate limiting and request logging for /api/v2/ endpoints. -- Called before action dispatch in all ApiV2* controllers. import Generated.Types import IHP.Prelude import IHP.ModelSupport import IHP.ControllerPrelude import Web.Routes () import Data.Aeson (object, (.=)) import Database.PostgreSQL.Simple (Only(..)) import Web.Controller.Api.V2.Auth (respondWithStatus) -- | Log a request to api_request_log and enforce rate limit / quota. -- Returns () on success; calls respondWithStatus and exits on limit exceeded. checkRateLimitAndLog :: ( ?context :: ControllerContext , ?modelContext :: ModelContext , ?respond :: Respond , ?request :: Request ) => ApiConsumer -> Text -> -- HTTP method Text -> -- endpoint path IO () checkRateLimitAndLog consumer endpoint method = do -- Check rate limit: requests in last 60 seconds rows1 <- sqlQuery "SELECT COUNT(*)::int FROM api_request_log \ \WHERE api_consumer_id = ? AND requested_at >= NOW() - INTERVAL '60 seconds'" (Only consumer.id) let reqCount = case rows1 of [Only (n :: Int)] -> n _ -> 0 when (reqCount >= consumer.rateLimitPerMinute) do respondWithStatus 429 $ object [ "error" .= ("Rate limit exceeded" :: Text) , "code" .= ("rate_limited" :: Text) , "retry_after" .= (60 :: Int) ] -- Check daily quota rows2 <- sqlQuery "SELECT COUNT(*)::int FROM api_request_log \ \WHERE api_consumer_id = ? AND requested_at >= ? - INTERVAL '1 day'" (consumer.id, consumer.quotaResetsAt) let quotaUsed = case rows2 of [Only (n :: Int)] -> n _ -> 0 when (quotaUsed >= consumer.quotaPerDay) do respondWithStatus 429 $ object [ "error" .= ("Daily quota exceeded" :: Text) , "code" .= ("quota_exceeded" :: Text) , "quota_resets_at" .= consumer.quotaResetsAt ] -- Log the request (status_code will be 0 here; update after response) sqlExec "INSERT INTO api_request_log (id, api_consumer_id, endpoint, method, status_code, requested_at) \ \VALUES (uuid_generate_v4(), ?, ?, ?, 200, NOW())" (consumer.id, endpoint, method) pure ()