diff --git a/package.json b/package.json index 7ad9bf2e..76c4862e 100644 --- a/package.json +++ b/package.json @@ -6,13 +6,14 @@ "workspaces": [ "src/api", "src/ui", - "src/archival" + "src/archival", + "src/linkryEdgeFunction" ], "packageManager": "yarn@1.22.22", "scripts": { "postinstall": "npm run setup", "setup": "git config blame.ignoreRevsFile .git-blame-ignore-revs", - "build": "concurrently --names 'api,ui,archival' 'yarn workspace infra-core-api run build' 'yarn workspace infra-core-ui run build' 'yarn workspace infra-core-archival run build'", + "build": "concurrently --names 'api,ui,archival,linkryEdge' 'yarn workspace infra-core-api run build' 'yarn workspace infra-core-ui run build' 'yarn workspace infra-core-archival run build' 'yarn workspace infra-core-linkry-edge run build'", "postbuild": "node src/api/createLambdaPackage.js && yarn lockfile-manage", "dev": "cross-env DISABLE_AUDIT_LOG=true concurrently --names 'api,ui' 'yarn workspace infra-core-api run dev' 'yarn workspace infra-core-ui run dev'", "lockfile-manage": "synp --with-workspace --source-file yarn.lock", @@ -94,4 +95,4 @@ "pdfjs-dist": "^4.8.69", "form-data": "^4.0.4" } -} +} \ No newline at end of file diff --git a/src/api/functions/linkry.ts b/src/api/functions/linkry.ts index 923b94dc..67e0e604 100644 --- a/src/api/functions/linkry.ts +++ b/src/api/functions/linkry.ts @@ -5,7 +5,7 @@ import { } from "@aws-sdk/client-dynamodb"; import { unmarshall } from "@aws-sdk/util-dynamodb"; import { LinkryGroupUUIDToGroupNameMap } from "common/config.js"; -import { DelegatedLinkRecord, LinkRecord } from "common/types/linkry.js"; +import { LinkRecord } from "common/types/linkry.js"; import { FastifyRequest } from "fastify"; export async function fetchLinkEntry( @@ -255,7 +255,7 @@ export async function getDelegatedLinks( ...ownerRecord, access: groupIds, owner: ownerRecord.access.replace("OWNER#", ""), - } as DelegatedLinkRecord; + } as LinkRecord; } catch (error) { console.error(`Error processing delegated slug ${slug}:`, error); return null; diff --git a/src/common/types/generic.ts b/src/common/types/generic.ts index 0eaf90d6..3a0005ef 100644 --- a/src/common/types/generic.ts +++ b/src/common/types/generic.ts @@ -1,3 +1,4 @@ +import { Organizations } from "@acm-uiuc/js-shared"; import * as z from "zod/v4"; @@ -23,3 +24,9 @@ export const illinoisNetId = z example: "rjjones", id: "IllinoisNetId", }); + +export const OrgUniqueId = z.enum(Object.keys(Organizations)).meta({ + description: "The unique org ID for a given ACM sub-organization. See https://github.com/acm-uiuc/js-shared/blob/main/src/orgs.ts#L15", + examples: ["A01", "C01"], + id: "OrgUniqueId" +}) diff --git a/src/common/types/linkry.ts b/src/common/types/linkry.ts index b9f2faed..5d8c448b 100644 --- a/src/common/types/linkry.ts +++ b/src/common/types/linkry.ts @@ -1,4 +1,5 @@ import * as z from "zod/v4"; +import { OrgUniqueId } from "./generic.js"; export type ShortLinkEntry = { slug: string; @@ -6,7 +7,7 @@ export type ShortLinkEntry = { redir?: string; }; -export const LINKRY_MAX_SLUG_LENGTH = 1000; +export const LINKRY_MAX_SLUG_LENGTH = 100; export const getRequest = z.object({ slug: z.string().min(1).max(LINKRY_MAX_SLUG_LENGTH).optional() @@ -19,7 +20,10 @@ export const linkryAccessList = z.array(z.string().min(1)).meta({ export const createRequest = z.object({ - slug: linkrySlug, + slug: linkrySlug.refine((url) => !url.includes('#'), { + message: "Slug must not contain a hashtag" + }), + orgId: z.optional(OrgUniqueId), access: linkryAccessList, redirect: z.url().min(1).meta({ description: "Full URL to redirect to when the short URL is visited.", example: "https://google.com" }) }); @@ -33,14 +37,8 @@ export const linkRecord = z.object({ owner: z.string().min(1) }); -export const delegatedLinkRecord = linkRecord.extend({ - owner: z.string().min(1) -}); - export type LinkRecord = z.infer; -export type DelegatedLinkRecord = z.infer; - export const getLinksResponse = z.object({ ownedLinks: z.array(linkRecord), delegatedLinks: z.array(linkRecord) diff --git a/src/linkryEdgeFunction/build.js b/src/linkryEdgeFunction/build.js new file mode 100644 index 00000000..72211b8d --- /dev/null +++ b/src/linkryEdgeFunction/build.js @@ -0,0 +1,42 @@ +/* eslint-disable no-console */ +import esbuild from "esbuild"; + +const commonParams = { + bundle: true, + format: "esm", + minify: true, + outExtension: { ".js": ".mjs" }, + loader: { + ".png": "file", + ".pkpass": "file", + ".json": "file", + }, // File loaders + target: "es2022", // Target ES2022 + sourcemap: true, + platform: "node", + external: ["@aws-sdk/*"], + banner: { + js: ` + import path from 'path'; + import { fileURLToPath } from 'url'; + import { createRequire as topLevelCreateRequire } from 'module'; + const require = topLevelCreateRequire(import.meta.url); + const __filename = fileURLToPath(import.meta.url); + const __dirname = path.dirname(__filename); + `.trim(), + }, // Banner for compatibility with CommonJS +}; + +esbuild + .build({ + ...commonParams, + entryPoints: ["linkryEdgeFunction/index.js"], + outdir: "../../dist/linkryEdgeFunction/", + }) + .then(() => + console.log("Linkry Edge Function lambda build completed successfully!"), + ) + .catch((error) => { + console.error("Linkry Edge Function lambda build failed:", error); + process.exit(1); + }); diff --git a/src/linkryEdgeFunction/index.ts b/src/linkryEdgeFunction/index.ts new file mode 100644 index 00000000..72ce5ad9 --- /dev/null +++ b/src/linkryEdgeFunction/index.ts @@ -0,0 +1,130 @@ +import { + DynamoDBClient, + QueryCommand, + QueryCommandInput, +} from "@aws-sdk/client-dynamodb"; +import type { + CloudFrontRequestEvent, + CloudFrontRequestResult, +} from "aws-lambda"; + +const DEFAULT_AWS_REGION = "us-east-2"; +const AVAILABLE_REPLICAS = ["us-west-2"]; +const DYNAMODB_TABLE = "infra-core-api-linkry"; +const FALLBACK_URL = process.env.FALLBACK_URL || "https://acm.illinois.edu/404"; +const DEFAULT_URL = process.env.DEFAULT_URL || "https://www.acm.illinois.edu"; +const CACHE_TTL = "30"; // seconds to hold response in PoP + +/** + * Determine which DynamoDB replica to use based on Lambda execution region + */ +function selectReplica(lambdaRegion: string): string { + // First check if Lambda is running in a replica region + if (AVAILABLE_REPLICAS.includes(lambdaRegion)) { + return lambdaRegion; + } + + // Otherwise, find nearest replica by region prefix matching + const regionPrefix = lambdaRegion.split("-").slice(0, 2).join("-"); + if (regionPrefix === "us") { + return DEFAULT_AWS_REGION; + } + + for (const replica of AVAILABLE_REPLICAS) { + if (replica.startsWith(regionPrefix)) { + return replica; + } + } + + return DEFAULT_AWS_REGION; +} + +const currentRegion = process.env.AWS_REGION || DEFAULT_AWS_REGION; +const targetRegion = selectReplica(currentRegion); +const dynamodb = new DynamoDBClient({ region: targetRegion }); + +console.log(`Lambda in ${currentRegion}, routing DynamoDB to ${targetRegion}`); + +export const handler = async ( + event: CloudFrontRequestEvent, +): Promise => { + const request = event.Records[0].cf.request; + const path = request.uri.replace(/^\/+/, ""); + + console.log(`Processing path: ${path}`); + + if (!path) { + return { + status: "301", + statusDescription: "Moved Permanently", + headers: { + location: [{ key: "Location", value: DEFAULT_URL }], + "cache-control": [ + { key: "Cache-Control", value: `public, max-age=${CACHE_TTL}` }, + ], + }, + }; + } + + // Query DynamoDB for records with PK=path and SK starting with "OWNER#" + try { + const queryParams: QueryCommandInput = { + TableName: DYNAMODB_TABLE, + KeyConditionExpression: + "slug = :slug AND begins_with(access, :owner_prefix)", + ExpressionAttributeValues: { + ":slug": { S: path }, + ":owner_prefix": { S: "OWNER#" }, + }, + ProjectionExpression: "redirect", + Limit: 1, // We only need one result + }; + + const response = await dynamodb.send(new QueryCommand(queryParams)); + + if (response.Items && response.Items.length > 0) { + const item = response.Items[0]; + + // Extract the redirect URL from the item + const redirectUrl = item.redirect?.S; + + if (redirectUrl) { + console.log(`Found redirect: ${path} -> ${redirectUrl}`); + return { + status: "302", + statusDescription: "Found", + headers: { + location: [{ key: "Location", value: redirectUrl }], + "cache-control": [ + { key: "Cache-Control", value: `public, max-age=${CACHE_TTL}` }, + ], + }, + }; + } + console.log(`Item found but no redirect attribute for path: ${path}`); + } else { + console.log(`No items found for path: ${path}`); + } + } catch (error) { + if (error instanceof Error) { + console.error( + `DynamoDB query failed for ${path} in region ${targetRegion}:`, + error.message, + ); + } else { + console.error(`Unexpected error:`, error); + } + } + + // Not found - redirect to fallback + return { + status: "307", + statusDescription: "Temporary Redirect", + headers: { + location: [{ key: "Location", value: FALLBACK_URL }], + "cache-control": [ + { key: "Cache-Control", value: `public, max-age=${CACHE_TTL}` }, + ], + }, + }; +}; diff --git a/src/linkryEdgeFunction/main.py b/src/linkryEdgeFunction/main.py deleted file mode 100644 index 75caeb4f..00000000 --- a/src/linkryEdgeFunction/main.py +++ /dev/null @@ -1,113 +0,0 @@ -import json -import os -import boto3 -from botocore.exceptions import ClientError - -DEFAULT_AWS_REGION = "us-east-2" -AVAILABLE_REPLICAS = [ - "us-west-2", -] -DYNAMODB_TABLE = "infra-core-api-linkry" -FALLBACK_URL = os.environ.get("FALLBACK_URL", "https://acm.illinois.edu/404") -DEFAULT_URL = os.environ.get("DEFAULT_URL", "https://www.acm.illinois.edu") -CACHE_TTL = "30" # seconds to hold response in PoP - - -def select_replica(lambda_region): - """Determine which DynamoDB replica to use based on Lambda execution region""" - # First check if Lambda is running in a replica region - if lambda_region in AVAILABLE_REPLICAS: - return lambda_region - - # Otherwise, find nearest replica by region prefix matching - region_prefix = "-".join(lambda_region.split("-")[:2]) - if region_prefix == "us": - return DEFAULT_AWS_REGION - - for replica in AVAILABLE_REPLICAS: - if replica.startswith(region_prefix): - return replica - - return DEFAULT_AWS_REGION - - -current_region = os.environ.get("AWS_REGION", "us-east-2") -target_region = select_replica(current_region) -dynamodb = boto3.client("dynamodb", region_name=target_region) - -print(f"Lambda in {current_region}, routing DynamoDB to {target_region}") - - -def handler(event, context): - request = event["Records"][0]["cf"]["request"] - path = request["uri"].lstrip("/") - - print(f"Processing path: {path}") - - if not path: - return { - "status": "301", - "statusDescription": "Moved Permanently", - "headers": { - "location": [{"key": "Location", "value": DEFAULT_URL}], - "cache-control": [ - {"key": "Cache-Control", "value": f"public, max-age={CACHE_TTL}"} - ], - }, - } - - # Query DynamoDB for records with PK=path and SK starting with "OWNER#" - try: - response = dynamodb.query( - TableName=DYNAMODB_TABLE, - KeyConditionExpression="slug = :slug AND begins_with(access, :owner_prefix)", - ExpressionAttributeValues={ - ":slug": {"S": path}, - ":owner_prefix": {"S": "OWNER#"}, - }, - ProjectionExpression="redirect", - Limit=1, # We only need one result - ) - - if response.get("Items") and len(response["Items"]) > 0: - item = response["Items"][0] - - # Extract the redirect URL from the item - redirect_url = item.get("redirect", {}).get("S") - - if redirect_url: - print(f"Found redirect: {path} -> {redirect_url}") - return { - "status": "302", - "statusDescription": "Found", - "headers": { - "location": [{"key": "Location", "value": redirect_url}], - "cache-control": [ - { - "key": "Cache-Control", - "value": f"public, max-age={CACHE_TTL}", - } - ], - }, - } - else: - print(f"Item found but no redirect attribute for path: {path}") - else: - print(f"No items found for path: {path}") - - except ClientError as e: - print(f"DynamoDB query failed for {path} in region {target_region}: {e}") - except Exception as e: - print(f"Unexpected error: {e}") - - # Not found - redirect to fallback - return { - "status": "307", - "statusDescription": "Temporary Redirect", - "headers": { - "location": [{"key": "Location", "value": FALLBACK_URL}], - "cache-control": [ - {"key": "Cache-Control", "value": f"public, max-age={CACHE_TTL}"} - ], - }, - } diff --git a/src/linkryEdgeFunction/package.json b/src/linkryEdgeFunction/package.json new file mode 100644 index 00000000..23ca956e --- /dev/null +++ b/src/linkryEdgeFunction/package.json @@ -0,0 +1,24 @@ +{ + "name": "infra-core-linkry-edge", + "version": "1.0.0", + "description": "Handles edge redirects", + "type": "module", + "main": "index.js", + "author": "ACM@UIUC", + "license": "BSD-3-Clause", + "scripts": { + "build": "tsc && node build.js", + "prettier": "prettier --check *.ts **/*.ts", + "lint": "eslint . --ext .ts --cache", + "prettier:write": "prettier --write *.ts **/*.ts" + }, + "devDependencies": { + "@types/aws-lambda": "^8.10.138", + "@types/node": "^24.3.0", + "typescript": "^5.9.2", + "esbuild": "^0.25.12" + }, + "dependencies": { + "@aws-sdk/client-dynamodb": "^3.922.0" + } +} \ No newline at end of file diff --git a/src/linkryEdgeFunction/tsconfig.json b/src/linkryEdgeFunction/tsconfig.json new file mode 100644 index 00000000..a7ea8819 --- /dev/null +++ b/src/linkryEdgeFunction/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "@tsconfig/node22/tsconfig.json", + "compilerOptions": { + "module": "Node16", + "rootDir": "../", + "outDir": "../../dist", + "baseUrl": "../" + }, + "ts-node": { + "esm": true + }, + "include": ["../api/**/*.ts", "../common/**/*.ts"], + "exclude": ["../../node_modules", "../../dist"] +} diff --git a/terraform/modules/lambdas/main.tf b/terraform/modules/lambdas/main.tf index a96307e2..52688128 100644 --- a/terraform/modules/lambdas/main.tf +++ b/terraform/modules/lambdas/main.tf @@ -12,7 +12,7 @@ data "archive_file" "sqs_lambda_code" { data "archive_file" "linkry_edge_lambda_code" { type = "zip" - source_dir = "${path.module}/../../../src/linkryEdgeFunction/" + source_dir = "${path.module}/../../../dist/linkryEdgeFunction/" output_path = "${path.module}/../../../dist/terraform/linkryEdgeFunction.zip" } @@ -509,8 +509,8 @@ resource "aws_lambda_function" "linkry_edge" { filename = data.archive_file.linkry_edge_lambda_code.output_path function_name = "${var.ProjectId}-linkry-edge" role = aws_iam_role.linkry_lambda_edge_role[0].arn - handler = "main.handler" - runtime = "python3.12" + handler = "index.handler" + runtime = "nodejs22.x" publish = true timeout = 5 memory_size = 128