Skip to content
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
33 changes: 0 additions & 33 deletions cloudformation/iam.yml
Original file line number Diff line number Diff line change
Expand Up @@ -76,8 +76,6 @@ Resources:
- Sid: DynamoDBCacheAccess
Effect: Allow
Action:
- dynamodb:BatchGetItem
- dynamodb:BatchWriteItem
- dynamodb:ConditionCheckItem
- dynamodb:PutItem
- dynamodb:DescribeTable
Expand All @@ -87,15 +85,6 @@ Resources:
- dynamodb:UpdateItem
Resource:
- Fn::Sub: arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-core-api-cache
Condition:
ForAllValues:StringEquals:
dynamodb:LeadingKeys:
- testing # add any keys that must be accessible
ForAllValues:StringLike:
dynamodb:Attributes:
- primaryKey
- expireAt
- "*"

- Sid: DynamoDBRateLimitTableAccess
Effect: Allow
Expand Down Expand Up @@ -188,28 +177,6 @@ Resources:
Effect: Allow
Resource:
- Fn::Sub: arn:aws:secretsmanager:${AWS::Region}:${AWS::AccountId}:secret:infra-core-api-entra*
- Action:
- dynamodb:BatchGetItem
- dynamodb:GetItem
- dynamodb:Query
- dynamodb:DescribeTable
- dynamodb:BatchWriteItem
- dynamodb:ConditionCheckItem
- dynamodb:PutItem
- dynamodb:DeleteItem
- dynamodb:UpdateItem
Effect: Allow
Resource:
- Fn::Sub: arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-core-api-cache
Condition:
ForAllValues:StringEquals:
dynamodb:LeadingKeys:
- entra_id_access_token # add any keys that must be accessible
ForAllValues:StringLike:
dynamodb:Attributes:
- primaryKey
- expireAt
- "*"

# SQS Lambda IAM Role
SqsLambdaIAMRole:
Expand Down
79 changes: 79 additions & 0 deletions src/api/functions/cache.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import {
DeleteItemCommand,
DynamoDBClient,
GetItemCommand,
PutItemCommand,
QueryCommand,
UpdateItemCommand,
} from "@aws-sdk/client-dynamodb";
import { genericConfig } from "../../common/config.js";
import { marshall, unmarshall } from "@aws-sdk/util-dynamodb";
Expand Down Expand Up @@ -52,3 +55,79 @@ export async function insertItemIntoCache(
}),
);
}

export async function atomicIncrementCacheCounter(
dynamoClient: DynamoDBClient,
key: string,
amount: number,
returnOld: boolean = false,
): Promise<number> {
const response = await dynamoClient.send(
new UpdateItemCommand({
TableName: genericConfig.CacheDynamoTableName,
Key: marshall({
primaryKey: key,
}),
UpdateExpression: "ADD #counterValue :increment",
ExpressionAttributeNames: {
"#counterValue": "counterValue",
},
ExpressionAttributeValues: marshall({
":increment": amount,
}),
ReturnValues: returnOld ? "UPDATED_OLD" : "UPDATED_NEW",
}),
);

if (!response.Attributes) {
return returnOld ? 0 : amount;
}

const value = unmarshall(response.Attributes).counter;
return typeof value === "number" ? value : 0;
}

export async function getCacheCounter(
dynamoClient: DynamoDBClient,
key: string,
defaultValue: number = 0,
): Promise<number> {
const response = await dynamoClient.send(
new GetItemCommand({
TableName: genericConfig.CacheDynamoTableName,
Key: marshall({
primaryKey: key,
}),
ProjectionExpression: "counterValue", // Only retrieve the value attribute
}),
);

if (!response.Item) {
return defaultValue;
}

const value = unmarshall(response.Item).counterValue;
return typeof value === "number" ? value : defaultValue;
}

export async function deleteCacheCounter(
dynamoClient: DynamoDBClient,
key: string,
): Promise<number | null> {
const params = {
TableName: genericConfig.CacheDynamoTableName,
Key: marshall({
primaryKey: key,
}),
ReturnValue: "ALL_OLD",
};

const response = await dynamoClient.send(new DeleteItemCommand(params));

if (response.Attributes) {
const item = unmarshall(response.Attributes);
const value = item.counterValue;
return typeof value === "number" ? value : 0;
}
return null;
}
85 changes: 85 additions & 0 deletions src/api/routes/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@ import { randomUUID } from "crypto";
import moment from "moment-timezone";
import { IUpdateDiscord, updateDiscord } from "../functions/discord.js";
import rateLimiter from "api/plugins/rateLimiter.js";
import {
atomicIncrementCacheCounter,
deleteCacheCounter,
getCacheCounter,
} from "api/functions/cache.js";

const repeatOptions = ["weekly", "biweekly"] as const;
const CLIENT_HTTP_CACHE_POLICY = `public, max-age=${EVENT_CACHED_DURATION}, stale-while-revalidate=420, stale-if-error=3600`;
Expand Down Expand Up @@ -119,7 +124,27 @@ const eventsPlugin: FastifyPluginAsync = async (fastify, _options) => {
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 {
const ifNoneMatch = request.headers["if-none-match"];
if (ifNoneMatch) {
const etag = await getCacheCounter(
fastify.dynamoClient,
"events-etag-all",
);

if (
ifNoneMatch === `"${etag.toString()}"` ||
ifNoneMatch === etag.toString()
) {
return reply
.code(304)
.header("ETag", etag)
.header("Cache-Control", CLIENT_HTTP_CACHE_POLICY)
.send();
}
reply.header("etag", etag);
}
let command;
if (host) {
command = new QueryCommand({
Expand All @@ -137,6 +162,14 @@ const eventsPlugin: FastifyPluginAsync = async (fastify, _options) => {
TableName: genericConfig.EventsDynamoTableName,
});
}
if (!ifNoneMatch) {
const etag = await getCacheCounter(
fastify.dynamoClient,
"events-etag-all",
);
reply.header("etag", etag);
}

const response = await fastify.dynamoClient.send(command);
const items = response.Items?.map((item) => unmarshall(item));
const currentTimeChicago = moment().tz("America/Chicago");
Expand Down Expand Up @@ -277,6 +310,18 @@ const eventsPlugin: FastifyPluginAsync = async (fastify, _options) => {
}
throw new DiscordEventError({});
}
await atomicIncrementCacheCounter(
fastify.dynamoClient,
`events-etag-${entryUUID}`,
1,
false,
);
await atomicIncrementCacheCounter(
fastify.dynamoClient,
"events-etag-all",
1,
false,
);
reply.status(201).send({
id: entryUUID,
resource: `/api/v1/events/${entryUUID}`,
Expand Down Expand Up @@ -335,6 +380,13 @@ const eventsPlugin: FastifyPluginAsync = async (fastify, _options) => {
message: "Failed to delete event from Dynamo table.",
});
}
await deleteCacheCounter(fastify.dynamoClient, `events-etag-${id}`);
await atomicIncrementCacheCounter(
fastify.dynamoClient,
"events-etag-all",
1,
false,
);
request.log.info(
{ type: "audit", actor: request.username, target: id },
`deleted event "${id}"`,
Expand All @@ -357,7 +409,30 @@ const eventsPlugin: FastifyPluginAsync = async (fastify, _options) => {
async (request: FastifyRequest<EventGetRequest>, reply) => {
const id = request.params.id;
const ts = request.query?.ts;

try {
// Check If-None-Match header
const ifNoneMatch = request.headers["if-none-match"];
if (ifNoneMatch) {
const etag = await getCacheCounter(
fastify.dynamoClient,
`events-etag-${id}`,
);

if (
ifNoneMatch === `"${etag.toString()}"` ||
ifNoneMatch === etag.toString()
) {
return reply
.code(304)
.header("ETag", etag)
.header("Cache-Control", CLIENT_HTTP_CACHE_POLICY)
.send();
}

reply.header("etag", etag);
}

const response = await fastify.dynamoClient.send(
new GetItemCommand({
TableName: genericConfig.EventsDynamoTableName,
Expand All @@ -372,6 +447,16 @@ const eventsPlugin: FastifyPluginAsync = async (fastify, _options) => {
if (!ts) {
reply.header("Cache-Control", CLIENT_HTTP_CACHE_POLICY);
}

// Only get the etag now if we didn't already get it above
if (!ifNoneMatch) {
const etag = await getCacheCounter(
fastify.dynamoClient,
`events-etag-${id}`,
);
reply.header("etag", etag);
}

return reply.send(item);
} catch (e) {
if (e instanceof BaseError) {
Expand Down
Loading
Loading