Skip to content

Commit 57e8a32

Browse files
Setup backend for org-managed short links (#396)
<!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Organizations can create, list, and delete org-scoped short links with authorization, audit logging, and optimistic concurrency control. * Subdomain-based routing maps org subdomains to their redirects. * CDN now accepts wildcard subdomains and QA adds wildcard DNS records. * **Tests** * Added an org-scoped health-check test; one purchases test commented out. * **Breaking Changes** * Public create request schema no longer accepts an orgId field. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
1 parent 087b45e commit 57e8a32

File tree

13 files changed

+615
-42
lines changed

13 files changed

+615
-42
lines changed

src/api/functions/linkry.ts

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import {
55
} from "@aws-sdk/client-dynamodb";
66
import { unmarshall } from "@aws-sdk/util-dynamodb";
77
import { LinkryGroupUUIDToGroupNameMap } from "common/config.js";
8-
import { LinkRecord } from "common/types/linkry.js";
8+
import { LinkRecord, OrgLinkRecord } from "common/types/linkry.js";
99
import { FastifyRequest } from "fastify";
1010

1111
export async function fetchLinkEntry(
@@ -72,6 +72,40 @@ export async function fetchOwnerRecords(
7272
});
7373
}
7474

75+
export async function fetchOrgRecords(
76+
orgId: string,
77+
tableName: string,
78+
dynamoClient: DynamoDBClient,
79+
) {
80+
const fetchAllOwnerRecords = new QueryCommand({
81+
TableName: tableName,
82+
IndexName: "AccessIndex",
83+
KeyConditionExpression: "#access = :accessVal",
84+
ExpressionAttributeNames: {
85+
"#access": "access",
86+
},
87+
ExpressionAttributeValues: {
88+
":accessVal": { S: `OWNER#${orgId}` },
89+
},
90+
ScanIndexForward: false,
91+
});
92+
93+
const result = await dynamoClient.send(fetchAllOwnerRecords);
94+
95+
// Process the results
96+
return (result.Items || []).map((item) => {
97+
const unmarshalledItem = unmarshall(item);
98+
99+
// Strip '#' from access field
100+
if (unmarshalledItem.access) {
101+
unmarshalledItem.access =
102+
unmarshalledItem.access.split("#")[1] || unmarshalledItem.access;
103+
}
104+
105+
return unmarshalledItem as OrgLinkRecord;
106+
});
107+
}
108+
75109
export function extractUniqueSlugs(records: LinkRecord[]) {
76110
return Array.from(
77111
new Set(records.filter((item) => item.slug).map((item) => item.slug)),

src/api/routes/linkry.ts

Lines changed: 298 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,19 @@ import {
1515
TransactWriteItemsCommand,
1616
TransactWriteItem,
1717
TransactionCanceledException,
18+
PutItemCommand,
19+
PutItemCommandInput,
20+
ConditionalCheckFailedException,
1821
} from "@aws-sdk/client-dynamodb";
1922
import { genericConfig } from "../../common/config.js";
2023
import { marshall, unmarshall } from "@aws-sdk/util-dynamodb";
2124
import rateLimiter from "api/plugins/rateLimiter.js";
22-
import { createRequest, linkrySlug } from "common/types/linkry.js";
25+
import {
26+
createOrgLinkRequest,
27+
createRequest,
28+
linkrySlug,
29+
orgLinkRecord,
30+
} from "common/types/linkry.js";
2331
import {
2432
extractUniqueSlugs,
2533
fetchOwnerRecords,
@@ -28,12 +36,18 @@ import {
2836
getDelegatedLinks,
2937
fetchLinkEntry,
3038
getAllLinks,
39+
fetchOrgRecords,
3140
} from "api/functions/linkry.js";
3241
import { intersection } from "api/plugins/auth.js";
33-
import { createAuditLogEntry } from "api/functions/auditLog.js";
42+
import {
43+
buildAuditLogTransactPut,
44+
createAuditLogEntry,
45+
} from "api/functions/auditLog.js";
3446
import { Modules } from "common/modules.js";
3547
import { FastifyZodOpenApiTypeProvider } from "fastify-zod-openapi";
3648
import { withRoles, withTags } from "api/components/index.js";
49+
import { AllOrganizationNameList, Organizations } from "@acm-uiuc/js-shared";
50+
import { authorizeByOrgRoleOrSchema } from "api/functions/authorization.js";
3751

3852
type OwnerRecord = {
3953
slug: string;
@@ -43,6 +57,15 @@ type OwnerRecord = {
4357
createdAt: string;
4458
};
4559

60+
type OrgRecord = {
61+
slug: string;
62+
redirect: string;
63+
access: string;
64+
updatedAt: string;
65+
createdAt: string;
66+
lastModifiedBy: string;
67+
};
68+
4669
type AccessRecord = {
4770
slug: string;
4871
access: string;
@@ -587,6 +610,279 @@ const linkryRoutes: FastifyPluginAsync = async (fastify, _options) => {
587610
reply.code(204).send();
588611
},
589612
);
613+
614+
fastify.withTypeProvider<FastifyZodOpenApiTypeProvider>().post(
615+
"/orgs/:orgId/redir",
616+
{
617+
schema: withRoles(
618+
[AppRoles.AT_LEAST_ONE_ORG_MANAGER, AppRoles.LINKS_ADMIN],
619+
withTags(["Linkry"], {
620+
body: createOrgLinkRequest,
621+
params: z.object({
622+
orgId: z.enum(Object.keys(Organizations)).meta({
623+
description: "ACM @ UIUC unique organization ID.",
624+
examples: ["A01"],
625+
}),
626+
}),
627+
summary: "Create a short link for a specific org",
628+
response: {
629+
201: {
630+
description: "The short link was modified.",
631+
content: {
632+
"application/json": {
633+
schema: z.null(),
634+
},
635+
},
636+
},
637+
},
638+
}),
639+
),
640+
preValidation: async (request, reply) => {
641+
const routeAlreadyExists = fastify.hasRoute({
642+
url: `/${request.params.orgId}#${request.body.slug}`,
643+
method: "GET",
644+
});
645+
646+
if (routeAlreadyExists) {
647+
throw new ValidationError({
648+
message: `Slug ${request.params.orgId}#${request.body.slug} is reserved by the system.`,
649+
});
650+
}
651+
},
652+
onRequest: async (request, reply) => {
653+
await authorizeByOrgRoleOrSchema(fastify, request, reply, {
654+
validRoles: [
655+
{ org: Organizations[request.params.orgId].name, role: "LEAD" },
656+
],
657+
});
658+
},
659+
},
660+
async (request, reply) => {
661+
const { slug, redirect } = request.body;
662+
const tableName = genericConfig.LinkryDynamoTableName;
663+
const realSlug = `${request.params.orgId}#${slug}`;
664+
const currentRecord = await fetchLinkEntry(
665+
realSlug,
666+
tableName,
667+
fastify.dynamoClient,
668+
);
669+
670+
try {
671+
const mode = currentRecord ? "modify" : "create";
672+
request.log.info(`Operating in ${mode} mode.`);
673+
const currentUpdatedAt =
674+
currentRecord && currentRecord.updatedAt
675+
? currentRecord.updatedAt
676+
: null;
677+
const currentCreatedAt =
678+
currentRecord && currentRecord.createdAt
679+
? currentRecord.createdAt
680+
: null;
681+
682+
const creationTime: Date = new Date();
683+
const newUpdatedAt = creationTime.toISOString();
684+
const newCreatedAt = currentCreatedAt || newUpdatedAt;
685+
686+
const ownerRecord: OrgRecord = {
687+
slug: realSlug,
688+
redirect,
689+
access: `OWNER#${request.params.orgId}`, // org records are owned by the org
690+
updatedAt: newUpdatedAt,
691+
createdAt: newCreatedAt,
692+
lastModifiedBy: request.username!,
693+
};
694+
695+
// Add the OWNER record with a condition check to ensure it hasn't been modified
696+
const ownerPutParams: PutItemCommandInput = {
697+
TableName: genericConfig.LinkryDynamoTableName,
698+
Item: marshall(ownerRecord, { removeUndefinedValues: true }),
699+
...(mode === "modify"
700+
? {
701+
ConditionExpression: "updatedAt = :updatedAt",
702+
ExpressionAttributeValues: marshall({
703+
":updatedAt": currentUpdatedAt,
704+
}),
705+
}
706+
: {}),
707+
};
708+
709+
await fastify.dynamoClient.send(new PutItemCommand(ownerPutParams));
710+
} catch (e) {
711+
fastify.log.error(e);
712+
if (e instanceof ConditionalCheckFailedException) {
713+
throw new ValidationError({
714+
message:
715+
"The record was modified by another process. Please try again.",
716+
});
717+
}
718+
719+
if (e instanceof BaseError) {
720+
throw e;
721+
}
722+
723+
throw new DatabaseInsertError({
724+
message: "Failed to save data to DynamoDB.",
725+
});
726+
}
727+
await createAuditLogEntry({
728+
dynamoClient: fastify.dynamoClient,
729+
entry: {
730+
module: Modules.LINKRY,
731+
actor: request.username!,
732+
target: `${Organizations[request.params.orgId].name}/${request.body.slug}`,
733+
message: `Created redirect to "${request.body.redirect}"`,
734+
},
735+
});
736+
const newResourceUrl = `${request.url}/slug/${request.body.slug}`;
737+
return reply.status(201).headers({ location: newResourceUrl }).send();
738+
},
739+
);
740+
fastify.withTypeProvider<FastifyZodOpenApiTypeProvider>().get(
741+
"/orgs/:orgId/redir",
742+
{
743+
schema: withRoles(
744+
[AppRoles.AT_LEAST_ONE_ORG_MANAGER, AppRoles.LINKS_ADMIN],
745+
withTags(["Linkry"], {
746+
params: z.object({
747+
orgId: z.enum(Object.keys(Organizations)).meta({
748+
description: "ACM @ UIUC organization ID.",
749+
examples: ["A01"],
750+
}),
751+
}),
752+
summary: "Retrieve short link for a specific org",
753+
response: {
754+
200: {
755+
description: "The short links were retrieved.",
756+
content: {
757+
"application/json": {
758+
schema: z.array(orgLinkRecord),
759+
},
760+
},
761+
},
762+
},
763+
}),
764+
),
765+
onRequest: async (request, reply) => {
766+
await authorizeByOrgRoleOrSchema(fastify, request, reply, {
767+
validRoles: [
768+
{ org: Organizations[request.params.orgId].name, role: "LEAD" },
769+
],
770+
});
771+
},
772+
},
773+
async (request, reply) => {
774+
let orgRecords;
775+
try {
776+
orgRecords = await fetchOrgRecords(
777+
request.params.orgId,
778+
genericConfig.LinkryDynamoTableName,
779+
fastify.dynamoClient,
780+
);
781+
} catch (e) {
782+
if (e instanceof BaseError) {
783+
throw e;
784+
}
785+
request.log.error(e);
786+
throw new DatabaseFetchError({
787+
message: "Failed to get links for org.",
788+
});
789+
}
790+
return reply.status(200).send(orgRecords);
791+
},
792+
);
793+
fastify.withTypeProvider<FastifyZodOpenApiTypeProvider>().delete(
794+
"/orgs/:orgId/redir/:slug",
795+
{
796+
schema: withRoles(
797+
[AppRoles.AT_LEAST_ONE_ORG_MANAGER, AppRoles.LINKS_ADMIN],
798+
withTags(["Linkry"], {
799+
params: z.object({
800+
orgId: z.enum(Object.keys(Organizations)).meta({
801+
description: "ACM @ UIUC organization ID.",
802+
examples: ["A01"],
803+
}),
804+
slug: linkrySlug,
805+
}),
806+
summary: "Delete a short link for a specific org",
807+
response: {
808+
204: {
809+
description: "The short links was deleted.",
810+
content: {
811+
"application/json": {
812+
schema: z.null(),
813+
},
814+
},
815+
},
816+
},
817+
}),
818+
),
819+
onRequest: async (request, reply) => {
820+
await authorizeByOrgRoleOrSchema(fastify, request, reply, {
821+
validRoles: [
822+
{ org: Organizations[request.params.orgId].name, role: "LEAD" },
823+
],
824+
});
825+
},
826+
},
827+
async (request, reply) => {
828+
const realSlug = `${request.params.orgId}#${request.params.slug}`;
829+
try {
830+
const tableName = genericConfig.LinkryDynamoTableName;
831+
const currentRecord = await fetchLinkEntry(
832+
realSlug,
833+
tableName,
834+
fastify.dynamoClient,
835+
);
836+
if (!currentRecord) {
837+
throw new NotFoundError({ endpointName: request.url });
838+
}
839+
} catch (e) {
840+
if (e instanceof BaseError) {
841+
throw e;
842+
}
843+
request.log.error(e);
844+
throw new DatabaseFetchError({
845+
message: "Failed to get link.",
846+
});
847+
}
848+
const logStatement = buildAuditLogTransactPut({
849+
entry: {
850+
module: Modules.LINKRY,
851+
actor: request.username!,
852+
target: `${Organizations[request.params.orgId].name}/${request.params.slug}`,
853+
message: `Deleted short link redirect.`,
854+
},
855+
});
856+
const TransactItems: TransactWriteItem[] = [
857+
...(logStatement ? [logStatement] : []),
858+
{
859+
Delete: {
860+
TableName: genericConfig.LinkryDynamoTableName,
861+
Key: {
862+
slug: { S: realSlug },
863+
access: { S: `OWNER#${request.params.orgId}` },
864+
},
865+
},
866+
},
867+
];
868+
869+
try {
870+
await fastify.dynamoClient.send(
871+
new TransactWriteItemsCommand({ TransactItems }),
872+
);
873+
} catch (e) {
874+
fastify.log.error(e);
875+
if (e instanceof BaseError) {
876+
throw e;
877+
}
878+
879+
throw new DatabaseDeleteError({
880+
message: "Failed to delete data from DynamoDB.",
881+
});
882+
}
883+
return reply.status(204).send();
884+
},
885+
);
590886
};
591887
fastify.register(limitedRoutes);
592888
};

0 commit comments

Comments
 (0)