diff --git a/Makefile b/Makefile index 9d35f7a3..be9eb7d1 100644 --- a/Makefile +++ b/Makefile @@ -28,6 +28,8 @@ common_params = --no-confirm-changeset \ s3_bucket_prefix = "$(current_aws_account)-$(region)-$(application_key)" ui_s3_bucket = "$(s3_bucket_prefix)-ui" +docs_s3_bucket = "$(s3_bucket_prefix)-docs" + GIT_HASH := $(shell git rev-parse --short HEAD) @@ -66,19 +68,21 @@ build: src/ cloudformation/ local: VITE_BUILD_HASH=$(GIT_HASH) yarn run dev + +postdeploy: + @echo "Syncing S3 UI bucket..." + aws s3 sync $(dist_ui_directory_root) s3://$(ui_s3_bucket)/ --delete + make invalidate_cloudfront + deploy_prod: check_account_prod @echo "Deploying CloudFormation stack..." sam deploy $(common_params) --parameter-overrides $(run_env)=prod $(set_application_prefix)=$(application_key) $(set_application_name)="$(application_name)" S3BucketPrefix="$(s3_bucket_prefix)" - @echo "Syncing S3 bucket..." - aws s3 sync $(dist_ui_directory_root) s3://$(ui_s3_bucket)/ --delete - make invalidate_cloudfront + make postdeploy deploy_dev: check_account_dev @echo "Deploying CloudFormation stack..." sam deploy $(common_params) --parameter-overrides $(run_env)=dev $(set_application_prefix)=$(application_key) $(set_application_name)="$(application_name)" S3BucketPrefix="$(s3_bucket_prefix)" - @echo "Syncing S3 bucket..." - aws s3 sync $(dist_ui_directory_root) s3://$(ui_s3_bucket)/ --delete - make invalidate_cloudfront + make postdeploy invalidate_cloudfront: @echo "Creating CloudFront invalidation..." diff --git a/cloudformation/main.yml b/cloudformation/main.yml index 93cce72a..b42bbb80 100644 --- a/cloudformation/main.yml +++ b/cloudformation/main.yml @@ -608,14 +608,15 @@ Resources: Type: AWS::S3::Bucket Properties: BucketName: !Sub ${S3BucketPrefix}-ui - WebsiteConfiguration: - IndexDocument: index.html - CloudFrontOriginAccessIdentity: - Type: AWS::CloudFront::CloudFrontOriginAccessIdentity + AppCloudfrontS3OAC: + Type: AWS::CloudFront::OriginAccessControl Properties: - CloudFrontOriginAccessIdentityConfig: - Comment: !Sub "Access identity for ${AppFrontendS3Bucket}" + OriginAccessControlConfig: + Name: InfraCoreApi OAC + OriginAccessControlOriginType: s3 + SigningBehavior: always + SigningProtocol: sigv4 AppFrontendCloudfrontDistribution: Type: AWS::CloudFront::Distribution @@ -626,7 +627,8 @@ Resources: - Id: S3WebsiteOrigin DomainName: !GetAtt AppFrontendS3Bucket.RegionalDomainName S3OriginConfig: - OriginAccessIdentity: !Sub "origin-access-identity/cloudfront/${CloudFrontOriginAccessIdentity}" + OriginAccessIdentity: '' + OriginAccessControlId: !GetAtt AppCloudfrontS3OAC.Id - Id: LambdaOrigin DomainName: !Select [0, !Split ['/', !Select [1, !Split ['https://', !GetAtt AppLambdaUrl.FunctionUrl]]]] CustomOriginConfig: @@ -697,23 +699,6 @@ Resources: CachePolicyId: "658327ea-f89d-4fab-a63d-7e88639e58f6" OriginRequestPolicyId: b689b0a8-53d0-40ab-baf2-68738e2966ac Compress: true - - PathPattern: "/api/documentation*" - TargetOriginId: LambdaOrigin - ViewerProtocolPolicy: redirect-to-https - AllowedMethods: - - GET - - HEAD - - OPTIONS - - PUT - - POST - - DELETE - - PATCH - CachedMethods: - - GET - - HEAD - CachePolicyId: "658327ea-f89d-4fab-a63d-7e88639e58f6" - OriginRequestPolicyId: b689b0a8-53d0-40ab-baf2-68738e2966ac - Compress: true - PathPattern: "/api/*" TargetOriginId: LambdaOrigin ViewerProtocolPolicy: redirect-to-https @@ -750,9 +735,12 @@ Resources: Statement: - Effect: Allow Principal: - CanonicalUser: !GetAtt CloudFrontOriginAccessIdentity.S3CanonicalUserId + Service: cloudfront.amazonaws.com Action: s3:GetObject Resource: !Sub "${AppFrontendS3Bucket.Arn}/*" + Condition: + StringEquals: + AWS:SourceArn: !Sub "arn:aws:cloudfront::${AWS::AccountId}:distribution/${AppFrontendCloudfrontDistribution}" CloudfrontNoCachePolicy: Type: AWS::CloudFront::CachePolicy @@ -812,6 +800,9 @@ Resources: exports.handler = async (event) => { const request = event.Records[0].cf.request; const uri = request.uri; + if (uri === '/docs') { + request.uri = "/docs/index.html"; + } if (!uri.startsWith('/api') && !uri.match(/\.\w+$/)) { request.uri = "/index.html"; } diff --git a/src/api/build.js b/src/api/build.js index def5ba0b..1e11bd72 100644 --- a/src/api/build.js +++ b/src/api/build.js @@ -34,13 +34,6 @@ const commonParams = { `.trim(), }, // Banner for compatibility with CommonJS plugins: [ - copy({ - resolveFrom: "cwd", - assets: { - from: ["../../node_modules/@fastify/swagger-ui/static/*"], - to: ["../../dist/lambda/static"], - }, - }), copy({ resolveFrom: "cwd", assets: { diff --git a/src/api/createLambdaPackage.js b/src/api/createLambdaPackage.js index 82d7f546..d59f1fca 100644 --- a/src/api/createLambdaPackage.js +++ b/src/api/createLambdaPackage.js @@ -13,8 +13,6 @@ export const packagesToTransfer = [ "moment-timezone", "passkit-generator", "fastify", - "@fastify/swagger", - "@fastify/swagger-ui", "zod", "argon2", "ioredis", diff --git a/src/api/createSwagger.ts b/src/api/createSwagger.ts new file mode 100644 index 00000000..1404e590 --- /dev/null +++ b/src/api/createSwagger.ts @@ -0,0 +1,56 @@ +import { fileURLToPath } from "url"; +import path from "node:path"; +import { writeFile, mkdir } from "fs/promises"; +import init from "./index.js"; // Assuming this is your Fastify app initializer + +const html = ` + + + + + + + ACM @ UIUC Core API + + + +
+ + + + +`; +/** + * Generates and saves Swagger/OpenAPI specification files. + */ +async function createSwaggerFiles() { + try { + const app = await init(false, false); + await app.ready(); + console.log("App is ready. Generating specs..."); + const __filename = fileURLToPath(import.meta.url); + const __dirname = path.dirname(__filename); + const outputDir = path.resolve(__dirname, "..", "..", "dist_ui", "docs"); + await mkdir(outputDir, { recursive: true }); + const jsonSpec = JSON.stringify(app.swagger(), null, 2); + const yamlSpec = app.swagger({ yaml: true }); + await writeFile(path.join(outputDir, "openapi.json"), jsonSpec); + await writeFile(path.join(outputDir, "openapi.yaml"), yamlSpec); + await writeFile(path.join(outputDir, "index.html"), html); + + console.log(`✅ Swagger files successfully generated in ${outputDir}`); + await app.close(); + } catch (err) { + console.error("❌ Failed to generate Swagger files:", err); + process.exit(1); + } +} + +createSwaggerFiles(); diff --git a/src/api/index.ts b/src/api/index.ts index 48ec22b0..f425854c 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -15,7 +15,6 @@ import { environmentConfig, genericConfig, SecretConfig, - SecretTesting, } from "../common/config.js"; import organizationsPlugin from "./routes/organizations.js"; import authorizeFromSchemaPlugin from "./plugins/authorizeFromSchema.js"; @@ -36,8 +35,7 @@ import membershipPlugin from "./routes/membership.js"; import path from "path"; // eslint-disable-line import/no-nodejs-modules import roomRequestRoutes from "./routes/roomRequests.js"; import logsPlugin from "./routes/logs.js"; -import fastifySwagger from "@fastify/swagger"; -import fastifySwaggerUI from "@fastify/swagger-ui"; + import { fastifyZodOpenApiPlugin, fastifyZodOpenApiTransform, @@ -50,19 +48,18 @@ import { withTags } from "./components/index.js"; import apiKeyRoute from "./routes/apiKey.js"; import clearSessionRoute from "./routes/clearSession.js"; import RedisModule from "ioredis"; +import { fileURLToPath } from "url"; // eslint-disable-line import/no-nodejs-modules +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); dotenv.config(); const now = () => Date.now(); -async function init(prettyPrint: boolean = false) { - const dynamoClient = new DynamoDBClient({ - region: genericConfig.AwsRegion, - }); - - const secretsManagerClient = new SecretsManagerClient({ - region: genericConfig.AwsRegion, - }); +async function init(prettyPrint: boolean = false, initClients: boolean = true) { + const isRunningInLambda = + process.env.LAMBDA_TASK_ROOT || process.env.AWS_LAMBDA_FUNCTION_NAME; + let isSwaggerServer = false; const transport = prettyPrint ? { target: "pino-pretty", @@ -98,111 +95,122 @@ async function init(prettyPrint: boolean = false) { await app.register(evaluatePoliciesPlugin); await app.register(errorHandlerPlugin); await app.register(fastifyZodOpenApiPlugin); - await app.register(fastifySwagger, { - openapi: { - info: { - title: "ACM @ UIUC Core API", - description: "ACM @ UIUC Core Management Platform", - version: "1.0.0", - contact: { - name: "ACM @ UIUC Infrastructure Team", - email: "infra@acm.illinois.edu", - url: "infra.acm.illinois.edu", - }, - license: { - name: "BSD 3-Clause", - identifier: "BSD-3-Clause", - url: "https://github.com/acm-uiuc/core/blob/main/LICENSE", - }, - termsOfService: "https://core.acm.illinois.edu/tos", - }, - servers: [ - { - url: "https://core.acm.illinois.edu", - description: "Production API server", - }, - { - url: "https://core.aws.qa.acmuiuc.org", - description: "QA API server", - }, - ], + if (!isRunningInLambda) { + try { + const fastifySwagger = import("@fastify/swagger"); + const fastifySwaggerUI = import("@fastify/swagger-ui"); + await app.register(fastifySwagger, { + openapi: { + info: { + title: "ACM @ UIUC Core API", + description: "ACM @ UIUC Core Management Platform", + version: "1.0.0", + contact: { + name: "ACM @ UIUC Infrastructure Team", + email: "infra@acm.illinois.edu", + url: "infra.acm.illinois.edu", + }, + license: { + name: "BSD 3-Clause", + identifier: "BSD-3-Clause", + url: "https://github.com/acm-uiuc/core/blob/main/LICENSE", + }, + termsOfService: "https://core.acm.illinois.edu/tos", + }, + servers: [ + { + url: "https://core.acm.illinois.edu", + description: "Production API server", + }, + { + url: "https://core.aws.qa.acmuiuc.org", + description: "QA API server", + }, + ], - tags: [ - { - name: "Events", - description: - "Retrieve ACM @ UIUC-wide and organization-specific calendars and event metadata.", - }, - { - name: "Generic", - description: "Retrieve metadata about a user or ACM @ UIUC .", - }, - { - name: "iCalendar Integration", - description: - "Retrieve Events calendars in iCalendar format (for integration with external calendar clients).", - }, - { - name: "IAM", - description: "Identity and Access Management for internal services.", - }, - { name: "Linkry", description: "Link Shortener." }, - { - name: "Logging", - description: "View audit logs for various services.", - }, - { - name: "Membership", - description: "Purchasing or checking ACM @ UIUC membership.", - }, - { - name: "Tickets/Merchandise", - description: "Handling the tickets and merchandise lifecycle.", - }, - { - name: "Mobile Wallet", - description: "Issuing Apple/Google Wallet passes.", - }, - { - name: "Stripe", - description: - "Collecting payments for ACM @ UIUC invoices and other services.", - }, - { - name: "Room Requests", - description: - "Creating room reservation requests for ACM @ UIUC within University buildings.", - }, - { - name: "API Keys", - description: "Manage the lifecycle of API keys.", - }, - ], + tags: [ + { + name: "Events", + description: + "Retrieve ACM @ UIUC-wide and organization-specific calendars and event metadata.", + }, + { + name: "Generic", + description: "Retrieve metadata about a user or ACM @ UIUC .", + }, + { + name: "iCalendar Integration", + description: + "Retrieve Events calendars in iCalendar format (for integration with external calendar clients).", + }, + { + name: "IAM", + description: + "Identity and Access Management for internal services.", + }, + { name: "Linkry", description: "Link Shortener." }, + { + name: "Logging", + description: "View audit logs for various services.", + }, + { + name: "Membership", + description: "Purchasing or checking ACM @ UIUC membership.", + }, + { + name: "Tickets/Merchandise", + description: "Handling the tickets and merchandise lifecycle.", + }, + { + name: "Mobile Wallet", + description: "Issuing Apple/Google Wallet passes.", + }, + { + name: "Stripe", + description: + "Collecting payments for ACM @ UIUC invoices and other services.", + }, + { + name: "Room Requests", + description: + "Creating room reservation requests for ACM @ UIUC within University buildings.", + }, + { + name: "API Keys", + description: "Manage the lifecycle of API keys.", + }, + ], - openapi: "3.1.0" satisfies ZodOpenApiVersion, // If this is not specified, it will default to 3.1.0 - components: { - securitySchemes: { - bearerAuth: { - type: "http", - scheme: "bearer", - bearerFormat: "JWT", - description: - "Authorization: Bearer {token}\n\nThis API uses JWT tokens issued by Entra ID (Azure AD) with the Core API audience. Tokens must be included in the Authorization header as a Bearer token for all protected endpoints.", - }, - apiKeyAuth: { - type: "apiKey", - in: "header", - name: "X-Api-Key", + openapi: "3.1.0" satisfies ZodOpenApiVersion, // If this is not specified, it will default to 3.1.0 + components: { + securitySchemes: { + bearerAuth: { + type: "http", + scheme: "bearer", + bearerFormat: "JWT", + description: + "Authorization: Bearer {token}\n\nThis API uses JWT tokens issued by Entra ID (Azure AD) with the Core API audience. Tokens must be included in the Authorization header as a Bearer token for all protected endpoints.", + }, + apiKeyAuth: { + type: "apiKey", + in: "header", + name: "X-Api-Key", + }, + }, }, }, - }, - }, - transform: fastifyZodOpenApiTransform, - transformObject: fastifyZodOpenApiTransformObject, - }); - await app.register(fastifySwaggerUI, { - routePrefix: "/api/documentation", - }); + transform: fastifyZodOpenApiTransform, + transformObject: fastifyZodOpenApiTransformObject, + }); + await app.register(fastifySwaggerUI, { + routePrefix: "/api/documentation", + }); + isSwaggerServer = true; + } catch (e) { + app.log.warn("Fastify Swagger not created!"); + } + } + await app.register(fastifyStatic, { root: path.join(__dirname, "public"), prefix: "/", @@ -210,7 +218,18 @@ async function init(prettyPrint: boolean = false) { if (!process.env.RunEnvironment) { process.env.RunEnvironment = "dev"; } - + if (isRunningInLambda && !isSwaggerServer) { + // Serve docs from S3 + app.get("/api/documentation", (_request, response) => { + response.redirect("/docs/index.html", 308); + }); + app.get("/api/documentation/json", (_request, response) => { + response.redirect("/docs/openapi.json", 308); + }); + app.get("/api/documentation/yaml", (_request, response) => { + response.redirect("/docs/openapi.yaml", 308); + }); + } if (!runEnvironments.includes(process.env.RunEnvironment as RunEnvironment)) { throw new InternalServerError({ message: `Invalid run environment ${app.runEnvironment}.`, @@ -222,7 +241,7 @@ async function init(prettyPrint: boolean = false) { message: `Audit log can only be disabled if the run environment is "dev"!`, }); } - if (process.env.LAMBDA_TASK_ROOT || process.env.AWS_LAMBDA_FUNCTION_NAME) { + if (isRunningInLambda) { throw new InternalServerError({ message: `Audit log cannot be disabled when running in AWS Lambda environment!`, }); @@ -235,24 +254,30 @@ async function init(prettyPrint: boolean = false) { app.environmentConfig = environmentConfig[app.runEnvironment as RunEnvironment]; app.nodeCache = new NodeCache({ checkperiod: 30 }); - app.dynamoClient = dynamoClient; - app.secretsManagerClient = secretsManagerClient; - app.refreshSecretConfig = async () => { - app.log.debug( - `Getting secrets: ${JSON.stringify(app.environmentConfig.ConfigurationSecretIds)}.`, - ); - const allSecrets = await Promise.all( - app.environmentConfig.ConfigurationSecretIds.map((secretName) => - getSecretValue(app.secretsManagerClient, secretName), - ), - ); - app.secretConfig = allSecrets.reduce( - (acc, currentSecret) => ({ ...acc, ...currentSecret }), - {}, - ) as SecretConfig; - }; - await app.refreshSecretConfig(); - app.redisClient = new RedisModule.default(app.secretConfig.redis_url); + if (initClients) { + app.dynamoClient = new DynamoDBClient({ + region: genericConfig.AwsRegion, + }); + app.secretsManagerClient = new SecretsManagerClient({ + region: genericConfig.AwsRegion, + }); + app.refreshSecretConfig = async () => { + app.log.debug( + `Getting secrets: ${JSON.stringify(app.environmentConfig.ConfigurationSecretIds)}.`, + ); + const allSecrets = await Promise.all( + app.environmentConfig.ConfigurationSecretIds.map((secretName) => + getSecretValue(app.secretsManagerClient, secretName), + ), + ); + app.secretConfig = allSecrets.reduce( + (acc, currentSecret) => ({ ...acc, ...currentSecret }), + {}, + ) as SecretConfig; + }; + await app.refreshSecretConfig(); + app.redisClient = new RedisModule.default(app.secretConfig.redis_url); + } app.addHook("onRequest", (req, _, done) => { req.startTime = now(); const hostname = req.hostname; @@ -260,7 +285,6 @@ async function init(prettyPrint: boolean = false) { req.log.info({ hostname, url, method: req.method }, "received request"); done(); }); - app.addHook("onResponse", (req, reply, done) => { req.log.info( { diff --git a/src/api/package.json b/src/api/package.json index 44f945b9..5052fabe 100644 --- a/src/api/package.json +++ b/src/api/package.json @@ -7,7 +7,7 @@ "license": "BSD-3-Clause", "type": "module", "scripts": { - "build": "tsc && node build.js", + "build": "tsc && node build.js && tsx createSwagger.ts", "dev": "cross-env DISABLE_AUDIT_LOG=true cross-env LOG_LEVEL=debug concurrently --names 'esbuild,server' 'node esbuild.config.js --watch' 'cd ../../dist_devel && nodemon index.js'", "typecheck": "tsc --noEmit", "lint": "eslint . --ext .ts --cache", @@ -30,8 +30,6 @@ "@fastify/caching": "^9.0.1", "@fastify/cors": "^11.0.1", "@fastify/static": "^8.1.1", - "@fastify/swagger": "^9.5.0", - "@fastify/swagger-ui": "^5.2.2", "@middy/core": "^6.1.6", "@middy/event-normalizer": "^6.1.6", "@middy/sqs-partial-batch-failure": "^6.1.6", @@ -64,6 +62,8 @@ "zod-validation-error": "^3.3.1" }, "devDependencies": { + "@fastify/swagger": "^9.5.0", + "@fastify/swagger-ui": "^5.2.2", "@tsconfig/node22": "^22.0.1", "@types/aws-lambda": "^8.10.149", "@types/qrcode": "^1.5.5", diff --git a/src/api/routes/roomRequests.ts b/src/api/routes/roomRequests.ts index 97d1f6d7..29d70c0c 100644 --- a/src/api/routes/roomRequests.ts +++ b/src/api/routes/roomRequests.ts @@ -320,7 +320,6 @@ const roomRequestRoutes: FastifyPluginAsync = async (fastify, _options) => { "userId#requestId": `${request.username}#${requestId}`, semesterId: request.body.semester, }; - console.log("FUCK", body); const logStatement = buildAuditLogTransactPut({ entry: { module: Modules.ROOM_RESERVATIONS,