diff --git a/.github/workflows/deploy-dev.yml b/.github/workflows/deploy-dev.yml index 240ddcb..7d8fe97 100644 --- a/.github/workflows/deploy-dev.yml +++ b/.github/workflows/deploy-dev.yml @@ -38,7 +38,7 @@ jobs: - name: Run unit testing run: make test_unit - deploy-dev: + deploy-test-dev: runs-on: ubuntu-latest permissions: id-token: write @@ -47,7 +47,7 @@ jobs: group: ${{ github.event.repository.name }}-dev-env cancel-in-progress: false environment: "AWS DEV" - name: Deploy to DEV + name: Deploy to DEV and Run Tests needs: - test-unit steps: @@ -90,38 +90,6 @@ jobs: HUSKY: "0" VITE_RUN_ENVIRONMENT: dev - test-dev: - runs-on: ubuntu-latest - name: Run Live Tests - needs: - - deploy-dev - concurrency: - group: ${{ github.event.repository.name }}-dev-env - cancel-in-progress: false - steps: - - uses: actions/checkout@v4 - env: - HUSKY: "0" - cache: 'yarn' - - - name: Set up Node - uses: actions/setup-node@v4 - with: - node-version: 22.x - - - name: Restore Yarn Cache - uses: actions/cache@v4 - with: - path: node_modules - key: yarn-modules-${{ runner.os }}-${{ hashFiles('**/yarn.lock') }}-dev - restore-keys: | - yarn-modules-${{ runner.os }}- - - - name: Set up Python 3.11 for testing - uses: actions/setup-python@v5 - with: - python-version: 3.11 - - name: Run health check run: make dev_health_check diff --git a/.github/workflows/deploy-prod.yml b/.github/workflows/deploy-prod.yml index eb810fe..1f1f09a 100644 --- a/.github/workflows/deploy-prod.yml +++ b/.github/workflows/deploy-prod.yml @@ -24,7 +24,8 @@ jobs: python-version: 3.11 - name: Run unit testing run: make test_unit - deploy-dev: + + deploy-test-dev: runs-on: ubuntu-latest concurrency: group: ${{ github.event.repository.name }}-dev @@ -33,7 +34,7 @@ jobs: id-token: write contents: read environment: "AWS DEV" - name: Deploy to DEV + name: Deploy to DEV and Run Tests needs: - test-unit steps: @@ -62,26 +63,6 @@ jobs: HUSKY: "0" VITE_RUN_ENVIRONMENT: dev - test-dev: - runs-on: ubuntu-latest - name: Run Live Tests - needs: - - deploy-dev - concurrency: - group: ${{ github.event.repository.name }}-dev - cancel-in-progress: false - steps: - - name: Set up Node - uses: actions/setup-node@v4 - with: - node-version: 22.x - - uses: actions/checkout@v4 - env: - HUSKY: "0" - - name: Set up Python 3.11 for testing - uses: actions/setup-python@v5 - with: - python-version: 3.11 - name: Run live testing run: make test_live_integration env: @@ -94,7 +75,7 @@ jobs: deploy-prod: runs-on: ubuntu-latest - name: Deploy to Prod + name: Deploy to Prod and Run Health Check concurrency: group: ${{ github.event.repository.name }}-prod cancel-in-progress: false @@ -102,7 +83,7 @@ jobs: id-token: write contents: read needs: - - test-dev + - deploy-test-dev environment: "AWS PROD" steps: - name: Set up Node for testing @@ -129,22 +110,5 @@ jobs: env: HUSKY: "0" VITE_RUN_ENVIRONMENT: prod - - health-check-prod: - runs-on: ubuntu-latest - name: Confirm services healthy - needs: - - deploy-prod - concurrency: - group: ${{ github.event.repository.name }}-prod - cancel-in-progress: false - steps: - - name: Set up Node for testing - uses: actions/setup-node@v4 - with: - node-version: 22.x - - uses: actions/checkout@v4 - env: - HUSKY: "0" - name: Call the health check script run: make prod_health_check diff --git a/cloudformation/custom-domain.yml b/cloudformation/custom-domain.yml index bb8b331..9919524 100644 --- a/cloudformation/custom-domain.yml +++ b/cloudformation/custom-domain.yml @@ -16,6 +16,8 @@ Parameters: AllowedValues: [ 'dev', 'prod' ] RecordName: Type: String + CloudfrontDomain: + Type: String Conditions: IsDev: !Equals [!Ref RunEnvironment, 'dev'] @@ -44,7 +46,7 @@ Resources: Properties: HostedZoneId: !Ref GWHostedZoneId Name: !Sub "${RecordName}.${GWBaseDomainName}" - Type: A - AliasTarget: - DNSName: !GetAtt CustomDomainName.RegionalDomainName - HostedZoneId: !GetAtt CustomDomainName.RegionalHostedZoneId + Type: CNAME + TTL: 300 + ResourceRecords: + - !Ref CloudfrontDomain diff --git a/cloudformation/iam.yml b/cloudformation/iam.yml index e44cca8..a9eb5ae 100644 --- a/cloudformation/iam.yml +++ b/cloudformation/iam.yml @@ -93,6 +93,14 @@ Resources: - expireAt - "*" + - Sid: DynamoDBRateLimitTableAccess + Effect: Allow + Action: + - dynamodb:DescribeTable + - dynamodb:UpdateItem + Resource: + - Fn::Sub: arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-core-api-rate-limiter + - Sid: DynamoDBIndexAccess Effect: Allow Action: diff --git a/cloudformation/main.yml b/cloudformation/main.yml index e5a6665..94b481b 100644 --- a/cloudformation/main.yml +++ b/cloudformation/main.yml @@ -119,6 +119,7 @@ Resources: GWApiId: !Ref AppApiGateway GWHostedZoneId: !FindInMap [ApiGwConfig, !Ref RunEnvironment, HostedZoneId] + CloudfrontDomain: !GetAtt [AppFrontendCloudfrontDistribution, DomainName] LinkryDomainProxy: Type: AWS::Serverless::Application @@ -138,6 +139,7 @@ Resources: GWApiId: !Ref AppApiGateway GWHostedZoneId: !FindInMap [ApiGwConfig, !Ref RunEnvironment, HostedZoneId] + CloudfrontDomain: !GetAtt [AppFrontendCloudfrontDistribution, DomainName] CoreUrlProd: Type: AWS::Serverless::Application @@ -158,6 +160,7 @@ Resources: GWApiId: !Ref AppApiGateway GWHostedZoneId: !FindInMap [ApiGwConfig, !Ref RunEnvironment, HostedZoneId] + CloudfrontDomain: !GetAtt [AppFrontendCloudfrontDistribution, DomainName] AppApiLambdaFunction: Type: AWS::Serverless::Function @@ -306,6 +309,30 @@ Resources: - AttributeName: userEmail KeyType: HASH + RateLimiterTable: + Type: "AWS::DynamoDB::Table" + DeletionPolicy: "Delete" + UpdateReplacePolicy: "Delete" + Properties: + BillingMode: "PAY_PER_REQUEST" + TableName: infra-core-api-rate-limiter + DeletionProtectionEnabled: true + PointInTimeRecoverySpecification: + PointInTimeRecoveryEnabled: false + AttributeDefinitions: + - AttributeName: PK + AttributeType: S + - AttributeName: SK + AttributeType: S + KeySchema: + - AttributeName: PK + KeyType: HASH + - AttributeName: SK + KeyType: RANGE + TimeToLiveSpecification: + AttributeName: ttl + Enabled: true + EventRecordsTable: Type: "AWS::DynamoDB::Table" DeletionPolicy: "Retain" @@ -562,6 +589,20 @@ Resources: - ApiGwConfig - !Ref RunEnvironment - UiDomainName + - !Join + - "" + - - "go." + - !FindInMap + - ApiGwConfig + - !Ref RunEnvironment + - EnvDomainName + - !Join + - "" + - - "ical." + - !FindInMap + - ApiGwConfig + - !Ref RunEnvironment + - EnvDomainName DefaultCacheBehavior: TargetOriginId: S3WebsiteOrigin @@ -578,6 +619,50 @@ Resources: Forward: none CachePolicyId: 658327ea-f89d-4fab-a63d-7e88639e58f6 # caching-optimized CacheBehaviors: + - PathPattern: "/api/v1/events" + TargetOriginId: ApiGatewayOrigin + ViewerProtocolPolicy: redirect-to-https + AllowedMethods: + - GET + - HEAD + - OPTIONS + - PUT + - POST + - DELETE + - PATCH + CachedMethods: + - GET + - HEAD + ForwardedValues: + QueryString: true + QueryStringCacheKeys: + - host + - ts + - upcomingOnly + Cookies: + Forward: none + - PathPattern: "/api/v1/events/*" + TargetOriginId: ApiGatewayOrigin + ViewerProtocolPolicy: redirect-to-https + AllowedMethods: + - GET + - HEAD + - OPTIONS + - PUT + - POST + - DELETE + - PATCH + CachedMethods: + - GET + - HEAD + ForwardedValues: + QueryString: true + QueryStringCacheKeys: + - host + - ts + - upcomingOnly + Cookies: + Forward: none - PathPattern: "/api/*" TargetOriginId: ApiGatewayOrigin ViewerProtocolPolicy: redirect-to-https diff --git a/src/api/functions/rateLimit.ts b/src/api/functions/rateLimit.ts new file mode 100644 index 0000000..9af78f8 --- /dev/null +++ b/src/api/functions/rateLimit.ts @@ -0,0 +1,66 @@ +import { + ConditionalCheckFailedException, + UpdateItemCommand, +} from "@aws-sdk/client-dynamodb"; +import { DynamoDBClient } from "@aws-sdk/client-dynamodb"; +import { genericConfig } from "common/config.js"; + +interface RateLimitParams { + ddbClient: DynamoDBClient; + rateLimitIdentifier: string; + duration: number; + limit: number; + userIdentifier: string; +} + +export async function isAtLimit({ + ddbClient, + rateLimitIdentifier, + duration, + limit, + userIdentifier, +}: RateLimitParams): Promise<{ + limited: boolean; + resetTime: number; + used: number; +}> { + const nowInSeconds = Math.floor(Date.now() / 1000); + const timeWindow = Math.floor(nowInSeconds / duration) * duration; + const PK = `rate-limit:${rateLimitIdentifier}:${userIdentifier}:${timeWindow}`; + + try { + const result = await ddbClient.send( + new UpdateItemCommand({ + TableName: genericConfig.RateLimiterDynamoTableName, + Key: { + PK: { S: PK }, + SK: { S: "counter" }, + }, + UpdateExpression: "ADD #rateLimitCount :inc SET #ttl = :ttl", + ConditionExpression: + "attribute_not_exists(#rateLimitCount) OR #rateLimitCount <= :limit", + ExpressionAttributeValues: { + ":inc": { N: "1" }, + ":limit": { N: limit.toString() }, + ":ttl": { N: (timeWindow + duration).toString() }, + }, + ExpressionAttributeNames: { + "#rateLimitCount": "rateLimitCount", + "#ttl": "ttl", + }, + ReturnValues: "UPDATED_NEW", + ReturnValuesOnConditionCheckFailure: "ALL_OLD", + }), + ); + return { + limited: false, + used: parseInt(result.Attributes?.rateLimitCount.N || "1", 10), + resetTime: timeWindow + duration, + }; + } catch (error) { + if (error instanceof ConditionalCheckFailedException) { + return { limited: true, resetTime: timeWindow + duration, used: limit }; + } + throw error; + } +} diff --git a/src/api/functions/sts.ts b/src/api/functions/sts.ts index 68be720..dd984ff 100644 --- a/src/api/functions/sts.ts +++ b/src/api/functions/sts.ts @@ -2,7 +2,6 @@ import { AssumeRoleCommand } from "@aws-sdk/client-sts"; import { STSClient } from "@aws-sdk/client-sts"; import { genericConfig } from "common/config.js"; import { InternalServerError } from "common/errors/index.js"; -import { duration } from "moment"; export async function getRoleCredentials( roleArn: string, diff --git a/src/api/plugins/rateLimiter.ts b/src/api/plugins/rateLimiter.ts new file mode 100644 index 0000000..9599ce3 --- /dev/null +++ b/src/api/plugins/rateLimiter.ts @@ -0,0 +1,63 @@ +import fp from "fastify-plugin"; +import { isAtLimit } from "api/functions/rateLimit.js"; +import { FastifyPluginAsync, FastifyRequest, FastifyReply } from "fastify"; + +interface RateLimiterOptions { + limit?: number | ((request: FastifyRequest) => number); + duration?: number; + rateLimitIdentifier?: string | ((request: FastifyRequest) => string); +} + +const rateLimiterPlugin: FastifyPluginAsync = async ( + fastify, + options, +) => { + const { + limit = 10, + duration = 60, + rateLimitIdentifier = "api-request", + } = options; + fastify.addHook( + "preHandler", + async (request: FastifyRequest, reply: FastifyReply) => { + const userIdentifier = request.ip; + let computedLimit = limit; + let computedIdentifier = rateLimitIdentifier; + if (typeof computedLimit === "function") { + computedLimit = computedLimit(request); + } + if (typeof computedIdentifier === "function") { + computedIdentifier = computedIdentifier(request); + } + const { limited, resetTime, used } = await isAtLimit({ + ddbClient: fastify.dynamoClient, + rateLimitIdentifier: computedIdentifier, + duration, + limit: computedLimit, + userIdentifier, + }); + reply.header("X-RateLimit-Limit", computedLimit.toString()); + reply.header("X-RateLimit-Reset", resetTime?.toString() || "0"); + reply.header( + "X-RateLimit-Remaining", + limited ? 0 : used ? computedLimit - used : computedLimit - 1, + ); + if (limited) { + const retryAfter = resetTime + ? resetTime - Math.floor(Date.now() / 1000) + : undefined; + reply.header("Retry-After", retryAfter?.toString() || "0"); + return reply.status(429).send({ + error: true, + name: "RateLimitExceededError", + id: 409, + message: "Rate limit exceeded.", + }); + } + }, + ); +}; + +export default fp(rateLimiterPlugin, { + name: "fastify-rate-limiter", +}); diff --git a/src/api/routes/events.ts b/src/api/routes/events.ts index a602ed8..180ef24 100644 --- a/src/api/routes/events.ts +++ b/src/api/routes/events.ts @@ -10,7 +10,7 @@ import { QueryCommand, ScanCommand, } from "@aws-sdk/client-dynamodb"; -import { genericConfig } from "../../common/config.js"; +import { EVENT_CACHED_DURATION, genericConfig } from "../../common/config.js"; import { marshall, unmarshall } from "@aws-sdk/util-dynamodb"; import { BaseError, @@ -23,13 +23,10 @@ import { import { randomUUID } from "crypto"; import moment from "moment-timezone"; import { IUpdateDiscord, updateDiscord } from "../functions/discord.js"; - -// POST +import rateLimiter from "api/plugins/rateLimiter.js"; const repeatOptions = ["weekly", "biweekly"] as const; -const EVENT_CACHE_SECONDS = 90; -const CLIENT_HTTP_CACHE_POLICY = - "public, max-age=180, stale-while-revalidate=420, stale-if-error=3600"; +const CLIENT_HTTP_CACHE_POLICY = `public, max-age=${EVENT_CACHED_DURATION}, stale-while-revalidate=420, stale-if-error=3600`; export type EventRepeatOptions = (typeof repeatOptions)[number]; const baseSchema = z.object({ @@ -64,6 +61,12 @@ type EventGetRequest = { Body: undefined; }; +type EventDeleteRequest = { + Params: { id: string }; + Querystring: undefined; + Body: undefined; +}; + const responseJsonSchema = zodToJsonSchema( z.object({ id: z.string(), @@ -81,13 +84,111 @@ const getEventJsonSchema = zodToJsonSchema(getEventSchema); const getEventsSchema = z.array(getEventSchema); export type EventsGetResponse = z.infer; -type EventsGetQueryParams = { - upcomingOnly?: boolean; - host?: string; - ts?: number; +type EventsGetRequest = { + Body: undefined; + Querystring?: { + upcomingOnly?: boolean; + host?: string; + ts?: number; + }; }; const eventsPlugin: FastifyPluginAsync = async (fastify, _options) => { + const limitedRoutes: FastifyPluginAsync = async (fastify) => { + fastify.register(rateLimiter, { + limit: 30, + duration: 60, + rateLimitIdentifier: "events", + }); + fastify.get( + "/", + { + schema: { + querystring: { + type: "object", + properties: { + upcomingOnly: { type: "boolean" }, + host: { type: "string" }, + ts: { type: "number" }, + }, + }, + response: { 200: getEventsSchema }, + }, + }, + async (request: FastifyRequest, reply) => { + const upcomingOnly = request.query?.upcomingOnly || false; + const host = request.query?.host; + const ts = request.query?.ts; // we only use this to disable cache control + try { + let command; + if (host) { + command = new QueryCommand({ + TableName: genericConfig.EventsDynamoTableName, + ExpressionAttributeValues: { + ":host": { + S: host, + }, + }, + KeyConditionExpression: "host = :host", + IndexName: "HostIndex", + }); + } else { + command = new ScanCommand({ + TableName: genericConfig.EventsDynamoTableName, + }); + } + const response = await fastify.dynamoClient.send(command); + const items = response.Items?.map((item) => unmarshall(item)); + const currentTimeChicago = moment().tz("America/Chicago"); + let parsedItems = getEventsSchema.parse(items); + if (upcomingOnly) { + parsedItems = parsedItems.filter((item) => { + try { + if (item.repeats && !item.repeatEnds) { + return true; + } + if (!item.repeats) { + const end = item.end || item.start; + const momentEnds = moment.tz(end, "America/Chicago"); + const diffTime = currentTimeChicago.diff(momentEnds); + return Boolean( + diffTime <= genericConfig.UpcomingEventThresholdSeconds, + ); + } + const momentRepeatEnds = moment.tz( + item.repeatEnds, + "America/Chicago", + ); + const diffTime = currentTimeChicago.diff(momentRepeatEnds); + return Boolean( + diffTime <= genericConfig.UpcomingEventThresholdSeconds, + ); + } catch (e: unknown) { + request.log.warn( + `Could not compute upcoming event status for event ${item.title}: ${e instanceof Error ? e.toString() : e} `, + ); + return false; + } + }); + } + if (!ts) { + reply.header("Cache-Control", CLIENT_HTTP_CACHE_POLICY); + } + return reply.send(parsedItems); + } catch (e: unknown) { + if (e instanceof Error) { + request.log.error("Failed to get from DynamoDB: " + e.toString()); + } else { + request.log.error(`Failed to get from DynamoDB.${e} `); + } + throw new DatabaseFetchError({ + message: "Failed to get events from Dynamo table.", + }); + } + }, + ); + }; + fastify.post<{ Body: EventPostRequest }>( "/:id?", { @@ -176,9 +277,6 @@ const eventsPlugin: FastifyPluginAsync = async (fastify, _options) => { } throw new DiscordEventError({}); } - fastify.nodeCache.del("events-upcoming_only=true"); - fastify.nodeCache.del("events-upcoming_only=false"); - fastify.nodeCache.del(`event-${entryUUID}`); reply.status(201).send({ id: entryUUID, resource: `/api/v1/events/${entryUUID}`, @@ -200,62 +298,6 @@ const eventsPlugin: FastifyPluginAsync = async (fastify, _options) => { } }, ); - fastify.get( - "/:id", - { - schema: { - querystring: { - type: "object", - properties: { - ts: { type: "number" }, - }, - }, - response: { 200: getEventJsonSchema }, - }, - }, - async (request: FastifyRequest, reply) => { - const id = request.params.id; - const ts = request.query?.ts; - const cachedResponse = fastify.nodeCache.get(`event-${id}`); - if (cachedResponse) { - if (!ts) { - reply.header("Cache-Control", CLIENT_HTTP_CACHE_POLICY); - } - return reply.header("x-acm-cache-status", "hit").send(cachedResponse); - } - - try { - const response = await fastify.dynamoClient.send( - new GetItemCommand({ - TableName: genericConfig.EventsDynamoTableName, - Key: marshall({ id }), - }), - ); - const item = response.Item ? unmarshall(response.Item) : null; - if (!item) { - throw new NotFoundError({ endpointName: request.url }); - } - fastify.nodeCache.set(`event-${id}`, item, EVENT_CACHE_SECONDS); - - if (!ts) { - reply.header("Cache-Control", CLIENT_HTTP_CACHE_POLICY); - } - return reply.header("x-acm-cache-status", "miss").send(item); - } catch (e) { - if (e instanceof BaseError) { - throw e; - } - throw new DatabaseFetchError({ - message: "Failed to get event from Dynamo table.", - }); - } - }, - ); - type EventDeleteRequest = { - Params: { id: string }; - Querystring: undefined; - Body: undefined; - }; fastify.delete( "/:id", { @@ -285,9 +327,6 @@ const eventsPlugin: FastifyPluginAsync = async (fastify, _options) => { id, resource: `/api/v1/events/${id}`, }); - fastify.nodeCache.del("events-upcoming_only=true"); - fastify.nodeCache.del("events-upcoming_only=false"); - fastify.nodeCache.del(`event-${id}`); } catch (e: unknown) { if (e instanceof Error) { request.log.error("Failed to delete from DynamoDB: " + e.toString()); @@ -302,117 +341,49 @@ const eventsPlugin: FastifyPluginAsync = async (fastify, _options) => { ); }, ); - type EventsGetRequest = { - Body: undefined; - Querystring?: EventsGetQueryParams; - }; - fastify.get( - "/", + fastify.get( + "/:id", { schema: { querystring: { type: "object", properties: { - upcomingOnly: { type: "boolean" }, - host: { type: "string" }, ts: { type: "number" }, }, }, - response: { 200: getEventsSchema }, + response: { 200: getEventJsonSchema }, }, }, - async (request: FastifyRequest, reply) => { - const upcomingOnly = request.query?.upcomingOnly || false; - const host = request.query?.host; - const ts = request.query?.ts; // we only use this to disable cache control - const cachedResponse = fastify.nodeCache.get( - `events-upcoming_only=${upcomingOnly}`, - ) as EventsGetResponse; - if (cachedResponse) { - if (!ts) { - reply.header("Cache-Control", CLIENT_HTTP_CACHE_POLICY); - } - let filteredResponse = cachedResponse; - if (host) { - filteredResponse = cachedResponse.filter((x) => x["host"] == host); - } - return reply.header("x-acm-cache-status", "hit").send(filteredResponse); - } + async (request: FastifyRequest, reply) => { + const id = request.params.id; + const ts = request.query?.ts; try { - let command; - if (host) { - command = new QueryCommand({ - TableName: genericConfig.EventsDynamoTableName, - ExpressionAttributeValues: { - ":host": { - S: host, - }, - }, - KeyConditionExpression: "host = :host", - IndexName: "HostIndex", - }); - } else { - command = new ScanCommand({ + const response = await fastify.dynamoClient.send( + new GetItemCommand({ TableName: genericConfig.EventsDynamoTableName, - }); - } - const response = await fastify.dynamoClient.send(command); - const items = response.Items?.map((item) => unmarshall(item)); - const currentTimeChicago = moment().tz("America/Chicago"); - let parsedItems = getEventsSchema.parse(items); - if (upcomingOnly) { - parsedItems = parsedItems.filter((item) => { - try { - if (item.repeats && !item.repeatEnds) { - return true; - } - if (!item.repeats) { - const end = item.end || item.start; - const momentEnds = moment.tz(end, "America/Chicago"); - const diffTime = currentTimeChicago.diff(momentEnds); - return Boolean( - diffTime <= genericConfig.UpcomingEventThresholdSeconds, - ); - } - const momentRepeatEnds = moment.tz( - item.repeatEnds, - "America/Chicago", - ); - const diffTime = currentTimeChicago.diff(momentRepeatEnds); - return Boolean( - diffTime <= genericConfig.UpcomingEventThresholdSeconds, - ); - } catch (e: unknown) { - request.log.warn( - `Could not compute upcoming event status for event ${item.title}: ${e instanceof Error ? e.toString() : e} `, - ); - return false; - } - }); - } - if (!host) { - fastify.nodeCache.set( - `events-upcoming_only=${upcomingOnly}`, - parsedItems, - EVENT_CACHE_SECONDS, - ); + Key: marshall({ id }), + }), + ); + const item = response.Item ? unmarshall(response.Item) : null; + if (!item) { + throw new NotFoundError({ endpointName: request.url }); } + if (!ts) { reply.header("Cache-Control", CLIENT_HTTP_CACHE_POLICY); } - return reply.header("x-acm-cache-status", "hit").send(parsedItems); - } catch (e: unknown) { - if (e instanceof Error) { - request.log.error("Failed to get from DynamoDB: " + e.toString()); - } else { - request.log.error(`Failed to get from DynamoDB.${e} `); + return reply.send(item); + } catch (e) { + if (e instanceof BaseError) { + throw e; } throw new DatabaseFetchError({ - message: "Failed to get events from Dynamo table.", + message: "Failed to get event from Dynamo table.", }); } }, ); + fastify.register(limitedRoutes); }; export default eventsPlugin; diff --git a/src/api/routes/ics.ts b/src/api/routes/ics.ts index 3a10af7..bf3eab8 100644 --- a/src/api/routes/ics.ts +++ b/src/api/routes/ics.ts @@ -16,6 +16,7 @@ import moment from "moment"; import { getVtimezoneComponent } from "@touch4it/ical-timezones"; import { OrganizationList } from "../../common/orgs.js"; import { EventRepeatOptions } from "./events.js"; +import rateLimiter from "api/plugins/rateLimiter.js"; const repeatingIcalMap: Record = { @@ -34,6 +35,11 @@ function generateHostName(host: string) { } const icalPlugin: FastifyPluginAsync = async (fastify, _options) => { + fastify.register(rateLimiter, { + limit: OrganizationList.length, + duration: 30, + rateLimitIdentifier: "ical", + }); fastify.get("/:host?", async (request, reply) => { const host = (request.params as Record).host; let queryParams: QueryCommandInput = { diff --git a/src/api/routes/membership.ts b/src/api/routes/membership.ts index 503db7c..1b24a09 100644 --- a/src/api/routes/membership.ts +++ b/src/api/routes/membership.ts @@ -11,6 +11,7 @@ import { genericConfig, roleArns } from "common/config.js"; import { getRoleCredentials } from "api/functions/sts.js"; import { SecretsManagerClient } from "@aws-sdk/client-secrets-manager"; import { DynamoDBClient } from "@aws-sdk/client-dynamodb"; +import rateLimiter from "api/plugins/rateLimiter.js"; const NONMEMBER_CACHE_SECONDS = 1800; // 30 minutes const MEMBER_CACHE_SECONDS = 43200; // 12 hours @@ -46,76 +47,88 @@ const membershipPlugin: FastifyPluginAsync = async (fastify, _options) => { }; } }; - fastify.get<{ - Body: undefined; - Querystring: { netId: string }; - }>( - "/:netId", - { - schema: { - querystring: { - type: "object", - properties: { - netId: { - type: "string", + const limitedRoutes: FastifyPluginAsync = async (fastify) => { + await fastify.register(rateLimiter, { + limit: 20, + duration: 30, + rateLimitIdentifier: "membership", + }); + fastify.get<{ + Body: undefined; + Querystring: { netId: string }; + }>( + "/:netId", + { + schema: { + querystring: { + type: "object", + properties: { + netId: { type: "string" }, }, }, }, }, - }, - async (request, reply) => { - const netId = (request.params as Record).netId; - if (!validateNetId(netId)) { - throw new ValidationError({ - message: `${netId} is not a valid Illinois NetID!`, - }); - } - if (fastify.nodeCache.get(`isMember_${netId}`) !== undefined) { - return reply.header("X-ACM-Data-Source", "cache").send({ + async (request, reply) => { + const netId = (request.params as Record).netId; + if (!validateNetId(netId)) { + throw new ValidationError({ + message: `${netId} is not a valid Illinois NetID!`, + }); + } + if (fastify.nodeCache.get(`isMember_${netId}`) !== undefined) { + return reply.header("X-ACM-Data-Source", "cache").send({ + netId, + isPaidMember: fastify.nodeCache.get(`isMember_${netId}`), + }); + } + const isDynamoMember = await checkPaidMembershipFromTable( netId, - isPaidMember: fastify.nodeCache.get(`isMember_${netId}`), - }); - } - const isDynamoMember = await checkPaidMembershipFromTable( - netId, - fastify.dynamoClient, - ); - // check Dynamo cache first - if (isDynamoMember) { - fastify.nodeCache.set(`isMember_${netId}`, true, MEMBER_CACHE_SECONDS); + fastify.dynamoClient, + ); + if (isDynamoMember) { + fastify.nodeCache.set( + `isMember_${netId}`, + true, + MEMBER_CACHE_SECONDS, + ); + return reply + .header("X-ACM-Data-Source", "dynamo") + .send({ netId, isPaidMember: true }); + } + const entraIdToken = await getEntraIdToken( + await getAuthorizedClients(), + fastify.environmentConfig.AadValidClientId, + ); + const paidMemberGroup = fastify.environmentConfig.PaidMemberGroupId; + const isAadMember = await checkPaidMembershipFromEntra( + netId, + entraIdToken, + paidMemberGroup, + ); + if (isAadMember) { + fastify.nodeCache.set( + `isMember_${netId}`, + true, + MEMBER_CACHE_SECONDS, + ); + reply + .header("X-ACM-Data-Source", "aad") + .send({ netId, isPaidMember: true }); + await setPaidMembershipInTable(netId, fastify.dynamoClient); + return; + } + fastify.nodeCache.set( + `isMember_${netId}`, + false, + NONMEMBER_CACHE_SECONDS, + ); return reply - .header("X-ACM-Data-Source", "dynamo") - .send({ netId, isPaidMember: true }); - } - // check AAD - const entraIdToken = await getEntraIdToken( - await getAuthorizedClients(), - fastify.environmentConfig.AadValidClientId, - ); - const paidMemberGroup = fastify.environmentConfig.PaidMemberGroupId; - const isAadMember = await checkPaidMembershipFromEntra( - netId, - entraIdToken, - paidMemberGroup, - ); - if (isAadMember) { - fastify.nodeCache.set(`isMember_${netId}`, true, MEMBER_CACHE_SECONDS); - reply .header("X-ACM-Data-Source", "aad") - .send({ netId, isPaidMember: true }); - await setPaidMembershipInTable(netId, fastify.dynamoClient); - return; - } - fastify.nodeCache.set( - `isMember_${netId}`, - false, - NONMEMBER_CACHE_SECONDS, - ); - return reply - .header("X-ACM-Data-Source", "aad") - .send({ netId, isPaidMember: false }); - }, - ); + .send({ netId, isPaidMember: false }); + }, + ); + }; + fastify.register(limitedRoutes); }; export default membershipPlugin; diff --git a/src/api/routes/mobileWallet.ts b/src/api/routes/mobileWallet.ts index c1f570a..958fbf0 100644 --- a/src/api/routes/mobileWallet.ts +++ b/src/api/routes/mobileWallet.ts @@ -13,6 +13,7 @@ import { import { SendMessageCommand, SQSClient } from "@aws-sdk/client-sqs"; import { genericConfig } from "../../common/config.js"; import { zodToJsonSchema } from "zod-to-json-schema"; +import rateLimiter from "api/plugins/rateLimiter.js"; const queuedResponseJsonSchema = zodToJsonSchema( z.object({ @@ -21,6 +22,11 @@ const queuedResponseJsonSchema = zodToJsonSchema( ); const mobileWalletRoute: FastifyPluginAsync = async (fastify, _options) => { + fastify.register(rateLimiter, { + limit: 5, + duration: 30, + rateLimitIdentifier: "mobileWallet", + }); fastify.post<{ Querystring: { email: string } }>( "/membership", { diff --git a/src/api/routes/organizations.ts b/src/api/routes/organizations.ts index da05b32..cf8c2bf 100644 --- a/src/api/routes/organizations.ts +++ b/src/api/routes/organizations.ts @@ -1,6 +1,7 @@ import { FastifyPluginAsync } from "fastify"; import { OrganizationList } from "../../common/orgs.js"; import fastifyCaching from "@fastify/caching"; +import rateLimiter from "api/plugins/rateLimiter.js"; const organizationsPlugin: FastifyPluginAsync = async (fastify, _options) => { fastify.register(fastifyCaching, { @@ -8,6 +9,11 @@ const organizationsPlugin: FastifyPluginAsync = async (fastify, _options) => { serverExpiresIn: 60 * 60 * 4, expiresIn: 60 * 60 * 4, }); + fastify.register(rateLimiter, { + limit: 60, + duration: 60, + rateLimitIdentifier: "organizations", + }); fastify.get("/", {}, async (request, reply) => { reply.send(OrganizationList); }); diff --git a/src/common/config.ts b/src/common/config.ts index 71b1d70..af6127a 100644 --- a/src/common/config.ts +++ b/src/common/config.ts @@ -21,6 +21,7 @@ export type ConfigType = { }; export type GenericConfigType = { + RateLimiterDynamoTableName: string; EventsDynamoTableName: string; CacheDynamoTableName: string; StripeLinksDynamoTableName: string; @@ -52,6 +53,7 @@ export const commChairsGroupId = "105e7d32-7289-435e-a67a-552c7f215507"; export const miscTestingGroupId = "ff25ec56-6a33-420d-bdb0-51d8a3920e46"; const genericConfig: GenericConfigType = { + RateLimiterDynamoTableName: "infra-core-api-rate-limiter", EventsDynamoTableName: "infra-core-api-events", StripeLinksDynamoTableName: "infra-core-api-stripe-links", CacheDynamoTableName: "infra-core-api-cache", @@ -124,4 +126,6 @@ const roleArns = { Entra: process.env.EntraRoleArn, }; +export const EVENT_CACHED_DURATION = 120; + export { genericConfig, environmentConfig, roleArns }; diff --git a/src/ui/pages/events/ManageEvent.page.tsx b/src/ui/pages/events/ManageEvent.page.tsx index 9dc36e7..c476d24 100644 --- a/src/ui/pages/events/ManageEvent.page.tsx +++ b/src/ui/pages/events/ManageEvent.page.tsx @@ -11,6 +11,7 @@ import { getRunEnvironmentConfig } from '@ui/config'; import { useApi } from '@ui/util/api'; import { OrganizationList as orgList } from '@common/orgs'; import { AppRoles } from '@common/roles'; +import { EVENT_CACHED_DURATION } from '@common/config'; export function capitalizeFirstLetter(string: string) { return string.charAt(0).toUpperCase() + string.slice(1); @@ -138,10 +139,10 @@ export const ManageEventPage: React.FC = () => { }; const eventURL = isEditing ? `/api/v1/events/${eventId}` : '/api/v1/events'; - const response = await api.post(eventURL, realValues); + await api.post(eventURL, realValues); notifications.show({ title: isEditing ? 'Event updated!' : 'Event created!', - message: isEditing ? undefined : `The event ID is "${response.data.id}".`, + message: `Changes may take up to ${Math.ceil(EVENT_CACHED_DURATION / 60)} minutes to reflect to users.`, }); navigate('/events/manage'); } catch (error) { diff --git a/src/ui/pages/events/ViewEvents.page.tsx b/src/ui/pages/events/ViewEvents.page.tsx index affbb1a..1f28108 100644 --- a/src/ui/pages/events/ViewEvents.page.tsx +++ b/src/ui/pages/events/ViewEvents.page.tsx @@ -116,12 +116,9 @@ export const ViewEventsPage: React.FC = () => { useEffect(() => { const getEvents = async () => { - const response = await api.get('/api/v1/events', { - headers: { 'Cache-Control': 'no-cache', Pragma: 'no-cache', Expires: 0 }, - }); - const upcomingEvents = await api.get(`/api/v1/events?upcomingOnly=true&ts=${Date.now()}`, { - headers: { 'Cache-Control': 'no-cache', Pragma: 'no-cache', Expires: 0 }, - }); + // setting ts lets us tell cloudfront I want fresh data + const response = await api.get(`/api/v1/events?ts=${Date.now()}`); + const upcomingEvents = await api.get(`/api/v1/events?upcomingOnly=true&ts=${Date.now()}`); const upcomingEventsSet = new Set(upcomingEvents.data.map((x: EventGetResponse) => x.id)); const events = response.data; events.sort((a: EventGetResponse, b: EventGetResponse) => { diff --git a/tests/live/ical.test.ts b/tests/live/ical.test.ts index 974f9e8..27684a9 100644 --- a/tests/live/ical.test.ts +++ b/tests/live/ical.test.ts @@ -2,24 +2,41 @@ import { expect, test } from "vitest"; import { describe } from "node:test"; import { OrganizationList } from "../../src/common/orgs.js"; import ical from "node-ical"; - const baseEndpoint = `https://core.aws.qa.acmuiuc.org`; -test("getting all events", async () => { - const response = await fetch(`${baseEndpoint}/api/v1/ical`); +const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +const fetchWithRateLimit = async (url) => { + const response = await fetch(url); expect(response.status).toBe(200); -}); -describe("Getting specific calendars", async () => { + // Check rate limit headers + const remaining = parseInt( + response.headers.get("X-RateLimit-Remaining") || "2", + 10, + ); + const reset = parseInt(response.headers.get("X-RateLimit-Reset") || "2", 10); + const currentTime = Math.floor(Date.now() / 1000); + + if (!isNaN(remaining) && !isNaN(reset) && remaining <= 1) { + const waitTime = (reset - currentTime) * 1000; + console.warn(`Rate limit reached, waiting ${waitTime / 1000} seconds...`); + await delay(waitTime); + } + + return response; +}; + +test("Get calendars with rate limit handling", { timeout: 30000 }, async () => { for (const org of OrganizationList) { - test(`Get ${org} calendar`, async () => { - const response = await fetch(`${baseEndpoint}/api/v1/ical/${org}`); - expect(response.status).toBe(200); - expect(response.headers.get("Content-Disposition")).toEqual( - 'attachment; filename="calendar.ics"', - ); - const calendar = ical.sync.parseICS(await response.text()); - expect(calendar["vcalendar"]["type"]).toEqual("VCALENDAR"); - }); + const response = await fetchWithRateLimit( + `${baseEndpoint}/api/v1/ical/${org}`, + ); + expect(response.status).toBe(200); + expect(response.headers.get("Content-Disposition")).toEqual( + 'attachment; filename="calendar.ics"', + ); + const calendar = ical.sync.parseICS(await response.text()); + expect(calendar["vcalendar"]["type"]).toEqual("VCALENDAR"); } }); diff --git a/tests/unit/vitest.setup.ts b/tests/unit/vitest.setup.ts index bfc60ce..a83ba6e 100644 --- a/tests/unit/vitest.setup.ts +++ b/tests/unit/vitest.setup.ts @@ -1,6 +1,18 @@ import { vi } from "vitest"; import { allAppRoles, AppRoles } from "../../src/common/roles.js"; -import { group } from "console"; + +vi.mock( + import("../../src/api/functions/rateLimit.js"), + async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + isAtLimit: vi.fn(async (_) => { + return { limited: false, resetTime: 0, used: 1 }; + }), + }; + }, +); vi.mock( import("../../src/api/functions/authorization.js"),