Skip to content
Merged
Show file tree
Hide file tree
Changes from 12 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
41 changes: 41 additions & 0 deletions src/api/functions/s3.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { PutObjectCommand, type S3Client } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { InternalServerError } from "common/errors/index.js";

export type CreatePresignedPutInputs = {
s3client: S3Client;
bucketName: string;
key: string;
length: number;
mimeType: string;
md5hash: string; // Must be a base64-encoded MD5 hash
urlExpiresIn?: number;
};

export async function createPresignedPut({
s3client,
bucketName,
key,
length,
mimeType,
md5hash,
urlExpiresIn,
}: CreatePresignedPutInputs) {
const command = new PutObjectCommand({
Bucket: bucketName,
Key: key,
ContentLength: length,
ContentType: mimeType,
ContentMD5: md5hash,
});

const expiresIn = urlExpiresIn || 900;

try {
return await getSignedUrl(s3client, command, { expiresIn });
} catch (err) {
throw new InternalServerError({
message: "Could not create S3 upload presigned url.",
});
}
}
2 changes: 2 additions & 0 deletions src/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
"prettier:write": "prettier --write *.ts **/*.ts"
},
"dependencies": {
"@aws-sdk/s3-request-presigner": "^3.914.0",
"@aws-sdk/client-s3": "^3.914.0",
"@aws-sdk/client-dynamodb": "^3.914.0",
"@aws-sdk/client-lambda": "^3.914.0",
"@aws-sdk/client-secrets-manager": "^3.914.0",
Expand Down
46 changes: 41 additions & 5 deletions src/api/routes/roomRequests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,8 @@ import { FastifyPluginAsync } from "fastify";
import rateLimiter from "api/plugins/rateLimiter.js";
import {
formatStatus,
roomGetResponse,
RoomRequestFormValues,
roomRequestPostResponse,
roomRequestSchema,
RoomRequestStatus,
RoomRequestStatusUpdatePostBody,
roomRequestStatusUpdateRequest,
} from "common/types/roomRequest.js";
import { AppRoles } from "common/roles.js";
Expand Down Expand Up @@ -37,6 +33,8 @@ import {
nonEmptyCommaSeparatedStringSchema,
} from "common/utils.js";
import { ROOM_RESERVATION_RETENTION_DAYS } from "common/constants.js";
import { createPresignedPut } from "api/functions/s3.js";
import { S3Client } from "@aws-sdk/client-s3";

const roomRequestRoutes: FastifyPluginAsync = async (fastify, _options) => {
await fastify.register(rateLimiter, {
Expand All @@ -59,6 +57,18 @@ const roomRequestRoutes: FastifyPluginAsync = async (fastify, _options) => {
semesterId,
}),
body: roomRequestStatusUpdateRequest,
response: {
201: {
description: "The room request status was updated.",
content: {
"application/json": {
schema: z.object({
uploadUrl: z.optional(z.url()),
}),
},
},
},
},
}),
),
onRequest: fastify.authorizeFromSchema,
Expand All @@ -71,6 +81,9 @@ const roomRequestRoutes: FastifyPluginAsync = async (fastify, _options) => {
}
const requestId = request.params.requestId;
const semesterId = request.params.semesterId;
const attachmentS3key = request.body.attachmentInfo
? `roomRequests/${requestId}/${request.body.status}/${request.body.attachmentInfo.filename}`
: undefined;
const getReservationData = new QueryCommand({
TableName: genericConfig.RoomRequestsStatusTableName,
KeyConditionExpression: "requestId = :requestId",
Expand All @@ -83,6 +96,28 @@ const roomRequestRoutes: FastifyPluginAsync = async (fastify, _options) => {
":requestId": { S: requestId },
},
});
let uploadUrl: string | undefined = undefined;
if (request.body.attachmentInfo) {
const { md5hash, fileSizeBytes, contentType } =
request.body.attachmentInfo;
request.log.info(
request.body.attachmentInfo,
`Creating presigned URL to store attachment`,
);
if (!fastify.s3Client) {
fastify.s3Client = new S3Client({
region: genericConfig.AwsRegion,
});
}
uploadUrl = await createPresignedPut({
s3client: fastify.s3Client,
key: attachmentS3key!,
bucketName: fastify.environmentConfig.AssetsBucketId,
length: fileSizeBytes,
mimeType: contentType,
md5hash,
});
}
const createdNotified =
await fastify.dynamoClient.send(getReservationData);
if (!createdNotified.Items || createdNotified.Count === 0) {
Expand All @@ -108,6 +143,7 @@ const roomRequestRoutes: FastifyPluginAsync = async (fastify, _options) => {
expiresAt:
Math.floor(Date.now() / 1000) +
86400 * ROOM_RESERVATION_RETENTION_DAYS,
attachmentS3key,
...request.body,
},
{ removeUndefinedValues: true },
Expand Down Expand Up @@ -177,7 +213,7 @@ const roomRequestRoutes: FastifyPluginAsync = async (fastify, _options) => {
request.log.info(
`Queued room reservation email to SQS with message ID ${result.MessageId}`,
);
return reply.status(201).send();
return reply.status(201).send({ uploadUrl });
},
);
fastify.withTypeProvider<FastifyZodOpenApiTypeProvider>().get(
Expand Down
2 changes: 2 additions & 0 deletions src/api/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { SecretsManagerClient } from "@aws-sdk/client-secrets-manager";
import { SQSClient } from "@aws-sdk/client-sqs";
import { AvailableAuthorizationPolicy } from "common/policies/definition.js";
import type RedisModule from "ioredis";
import { type S3Client } from "@aws-sdk/client-s3";
export type Redis = RedisModule.default;
export type ValidLoggers = FastifyBaseLogger | pino.Logger;

Expand Down Expand Up @@ -42,6 +43,7 @@ declare module "fastify" {
nodeCache: NodeCache;
dynamoClient: DynamoDBClient;
sqsClient?: SQSClient;
s3Client?: S3Client;
redisClient: Redis;
secretsManagerClient: SecretsManagerClient;
secretConfig: SecretConfig | (SecretConfig & SecretTesting);
Expand Down
7 changes: 5 additions & 2 deletions src/common/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export type ConfigType = {
OrgAdminGithubParentTeam: number;
GithubIdpSyncEnabled: boolean
GithubOrgId: number;
AssetsBucketId: string;
};

export type GenericConfigType = {
Expand Down Expand Up @@ -142,7 +143,8 @@ const environmentConfig: EnvironmentConfigType = {
GithubOrgName: "acm-uiuc-testing",
GithubOrgId: 235748315,
OrgAdminGithubParentTeam: 14420860,
GithubIdpSyncEnabled: false
GithubIdpSyncEnabled: false,
AssetsBucketId: `427040638965-infra-core-api-assets-${genericConfig.AwsRegion}`
},
Comment on lines +146 to 148
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Fix Prettier-required trailing comma.

Prettier fails on this object because the new AssetsBucketId entry isn’t followed by a comma, so the lint step will block the build. Please add the trailing comma.

-    AssetsBucketId: `427040638965-infra-core-api-assets-${genericConfig.AwsRegion}`
+    AssetsBucketId: `427040638965-infra-core-api-assets-${genericConfig.AwsRegion}`,
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
GithubIdpSyncEnabled: false,
AssetsBucketId: `427040638965-infra-core-api-assets-${genericConfig.AwsRegion}`
},
GithubIdpSyncEnabled: false,
AssetsBucketId: `427040638965-infra-core-api-assets-${genericConfig.AwsRegion}`,
},
🧰 Tools
🪛 ESLint

[error] 147-147: Insert ,

(prettier/prettier)

🤖 Prompt for AI Agents
In src/common/config.ts around lines 146 to 148, the object literal ending with
the AssetsBucketId property is missing a trailing comma which breaks Prettier;
add a trailing comma after the AssetsBucketId line so the object entries are
properly comma-separated and the file passes Prettier/lint checks.

prod: {
UserFacingUrl: "https://core.acm.illinois.edu",
Expand Down Expand Up @@ -174,7 +176,8 @@ const environmentConfig: EnvironmentConfigType = {
GithubOrgName: "acm-uiuc",
GithubOrgId: 425738,
OrgAdminGithubParentTeam: 12025214,
GithubIdpSyncEnabled: true
GithubIdpSyncEnabled: true,
AssetsBucketId: `298118738376-infra-core-api-assets-${genericConfig.AwsRegion}`
},
};

Expand Down
6 changes: 3 additions & 3 deletions src/common/constants.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
export const STRIPE_LINK_RETENTION_DAYS = 90; // this number of days after the link is deactivated.
export const AUDIT_LOG_RETENTION_DAYS = 365;
export const ROOM_RESERVATION_RETENTION_DAYS = 730;
export const FULFILLED_PURCHASES_RETENTION_DAYS = 730; // ticketing/merch: after the purchase is marked as fulfilled.
export const ROOM_RESERVATION_RETENTION_DAYS = 1460;
export const FULFILLED_PURCHASES_RETENTION_DAYS = 1460; // ticketing/merch: after the purchase is marked as fulfilled.
export const EVENTS_EXPIRY_AFTER_LAST_OCCURRENCE_DAYS = 1460; // hold events for 4 years after last occurrence
// we keep data longer for historical analytics purposes
// we keep data longer for historical analytics purposes in S3 as needed
12 changes: 11 additions & 1 deletion src/common/types/roomRequest.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import * as z from "zod/v4";
import { AllOrganizationNameList } from "@acm-uiuc/js-shared";
import { illinoisSemesterId } from "./generic.js"
export const validMimeTypes = ['application/pdf', 'image/jpeg', 'image/heic', 'image/pdf']
export const maxAttachmentSizeBytes = 1e7; // 10MB

export const eventThemeOptions = [
"Arts & Music",
Expand Down Expand Up @@ -130,8 +132,16 @@ export enum RoomRequestStatus {
REJECTED_BY_UIUC = "rejected_by_uiuc",
}

export const roomRequestStatusAttachmentInfo = z.object({
filename: z.string().min(1).max(100),
md5hash: z.string().length(32),
fileSizeBytes: z.number().min(1).max(maxAttachmentSizeBytes),
contentType: z.enum(validMimeTypes)
})

export const roomRequestStatusUpdateRequest = z.object({
status: z.nativeEnum(RoomRequestStatus),
status: z.enum(RoomRequestStatus),
attachmentInfo: z.optional(roomRequestStatusAttachmentInfo),
notes: z.string().min(1).max(1000)
});

Expand Down
6 changes: 6 additions & 0 deletions terraform/envs/prod/main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,12 @@ module "frontend" {
LinkryEdgeFunctionArn = module.lambdas.linkry_redirect_function_arn
}

module "assets" {
source = "../../modules/assets"
ProjectId = var.ProjectId
BucketAllowedCorsOrigins = ["https://${var.CorePublicDomain}"]
}

resource "aws_lambda_event_source_mapping" "queue_consumer" {
region = "us-east-2"
depends_on = [module.lambdas, module.sqs_queues]
Expand Down
6 changes: 6 additions & 0 deletions terraform/envs/qa/main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,12 @@ module "frontend" {
LinkryEdgeFunctionArn = module.lambdas.linkry_redirect_function_arn
}

module "assets" {
source = "../../modules/assets"
ProjectId = var.ProjectId
BucketAllowedCorsOrigins = ["https://${var.CorePublicDomain}", "http://localhost:5173"]
}

// Multi-Region Failover: US-West-2

module "lambdas_usw2" {
Expand Down
71 changes: 71 additions & 0 deletions terraform/modules/assets/main.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
data "aws_caller_identity" "current" {}
data "aws_region" "current" {}

locals {
asset_bucket_prefix = "${data.aws_caller_identity.current.account_id}-${var.ProjectId}-assets"
}

module "buckets" {
source = "git::https://github.com/acm-uiuc/terraform-modules.git//multiregion-s3?ref=v2.0.0"
Region1 = var.PrimaryRegion
Region2 = var.SecondaryRegion
BucketPrefix = local.asset_bucket_prefix
}

resource "aws_s3_bucket_lifecycle_configuration" "expire_noncurrent" {
for_each = module.buckets.buckets_info
region = each.key
bucket = each.value.id

rule {
id = "expire-noncurrent-versions"
status = "Enabled"

noncurrent_version_expiration {
noncurrent_days = 3
}
}

rule {
id = "expire-delete-markers"
status = "Enabled"

expiration {
expired_object_delete_marker = true
}
}

rule {
id = "abort-incomplete-multipart"
status = "Enabled"

abort_incomplete_multipart_upload {
days_after_initiation = 3
}
}
}

resource "aws_s3_bucket_intelligent_tiering_configuration" "tiering" {
for_each = module.buckets.buckets_info
bucket = each.value.id
region = each.key
name = "EntireBucketIntelligentTiering"

tiering {
access_tier = "ARCHIVE_ACCESS"
days = 90
}
}

resource "aws_s3_bucket_cors_configuration" "ui_uploads" {
for_each = module.buckets.buckets_info
bucket = each.value.id
region = each.key
cors_rule {
allowed_headers = ["*"]
allowed_methods = ["GET", "PUT"]
allowed_origins = var.BucketAllowedCorsOrigins
expose_headers = ["ETag"]
max_age_seconds = 3000
}
}
19 changes: 19 additions & 0 deletions terraform/modules/assets/variables.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
variable "PrimaryRegion" {
type = string
default = "us-east-2"
}

variable "SecondaryRegion" {
type = string
default = "us-west-2"
}

variable "ProjectId" {
type = string
description = "Prefix before each resource"
}

variable "BucketAllowedCorsOrigins" {
type = list(string)
description = "List of URLs that bucket can be read/written from."
}
Loading
Loading