Skip to content

Commit 916511b

Browse files
committed
update behavior
1 parent b6088b9 commit 916511b

File tree

2 files changed

+131
-184
lines changed

2 files changed

+131
-184
lines changed

src/api/routes/linkry.ts

Lines changed: 129 additions & 182 deletions
Original file line numberDiff line numberDiff line change
@@ -220,53 +220,40 @@ const linkryRoutes: FastifyPluginAsync = async (fastify, _options) => {
220220
},
221221
},
222222
async (request, reply) => {
223-
// Add to cloudfront key value store so that redirects happen at the edge
224-
const kvArn = await getLinkryKvArn(fastify.runEnvironment);
225-
let currentRecord = null;
226-
try {
227-
await setKey({
228-
key: request.body.slug,
229-
value: request.body.redirect,
230-
kvsClient: fastify.cloudfrontKvClient,
231-
arn: kvArn,
232-
});
233-
} catch (e) {
234-
fastify.log.error(e);
235-
if (e instanceof BaseError) {
236-
throw e;
223+
const { slug } = request.body;
224+
const tableName = genericConfig.LinkryDynamoTableName;
225+
const currentRecord = await fetchLinkEntry(
226+
slug,
227+
tableName,
228+
fastify.dynamoClient,
229+
);
230+
231+
if (currentRecord && !request.userRoles!.has(AppRoles.LINKS_ADMIN)) {
232+
const setUserGroups = new Set(request.tokenPayload?.groups || []);
233+
const mutualGroups = intersection(
234+
new Set(currentRecord["access"]),
235+
setUserGroups,
236+
);
237+
if (mutualGroups.size == 0) {
238+
throw new UnauthorizedError({
239+
message:
240+
"You do not own this record and have not been delegated access.",
241+
});
237242
}
238-
throw new DatabaseInsertError({
239-
message: "Failed to save redirect to Cloudfront KV store.",
240-
});
241243
}
242244

243245
// Use a transaction to handle if one/multiple of these writes fail
244246
const TransactItems: TransactWriteItem[] = [];
245247

246248
try {
247-
const queryOwnerCommand = new QueryCommand({
248-
TableName: genericConfig.LinkryDynamoTableName,
249-
KeyConditionExpression:
250-
"slug = :slug AND begins_with(access, :ownerPrefix)",
251-
ExpressionAttributeValues: marshall({
252-
":slug": request.body.slug,
253-
":ownerPrefix": "OWNER#",
254-
}),
255-
});
256-
257-
currentRecord = await dynamoClient.send(queryOwnerCommand);
258-
const mode =
259-
currentRecord.Items && currentRecord.Items.length > 0
260-
? "modify"
261-
: "create";
262-
const currentUpdatedAt =
263-
currentRecord.Items && currentRecord.Items.length > 0
264-
? unmarshall(currentRecord.Items[0]).updatedAt
265-
: null;
266-
const currentCreatedAt =
267-
currentRecord.Items && currentRecord.Items.length > 0
268-
? unmarshall(currentRecord.Items[0]).createdAt
269-
: null;
249+
const mode = currentRecord ? "modify" : "create";
250+
request.log.info(`Operating in ${mode} mode.`);
251+
const currentUpdatedAt = currentRecord
252+
? currentRecord["updatedAt"]
253+
: null;
254+
const currentCreatedAt = currentRecord
255+
? currentRecord["createdAt"]
256+
: null;
270257

271258
// Generate new timestamp for all records
272259
const creationTime: Date = new Date();
@@ -420,38 +407,11 @@ const linkryRoutes: FastifyPluginAsync = async (fastify, _options) => {
420407

421408
TransactItems.push(deleteItem);
422409
}
423-
console.log(JSON.stringify(TransactItems));
424410
await dynamoClient.send(
425411
new TransactWriteItemsCommand({ TransactItems }),
426412
);
427-
return reply.status(201).send();
428413
} catch (e) {
429414
fastify.log.error(e);
430-
431-
// Clean up cloudfront KV store on error
432-
if (currentRecord && currentRecord.Count && currentRecord.Count > 0) {
433-
if (
434-
currentRecord.Items &&
435-
currentRecord.Items.length > 0 &&
436-
currentRecord.Items[0].redirect.S
437-
) {
438-
fastify.log.info("Reverting CF Key store value due to error");
439-
await setKey({
440-
key: request.body.slug,
441-
value: currentRecord.Items[0].redirect.S,
442-
kvsClient: fastify.cloudfrontKvClient,
443-
arn: kvArn,
444-
});
445-
} else {
446-
// it didn't exist before
447-
fastify.log.info("Deleting CF Key store value due to error");
448-
await deleteKey({
449-
key: request.body.slug,
450-
kvsClient: fastify.cloudfrontKvClient,
451-
arn: kvArn,
452-
});
453-
}
454-
}
455415
// Handle optimistic concurrency control
456416
if (
457417
e instanceof TransactionCanceledException &&
@@ -477,6 +437,25 @@ const linkryRoutes: FastifyPluginAsync = async (fastify, _options) => {
477437
message: "Failed to save data to DynamoDB.",
478438
});
479439
}
440+
// Add to cloudfront key value store so that redirects happen at the edge
441+
const kvArn = await getLinkryKvArn(fastify.runEnvironment);
442+
try {
443+
await setKey({
444+
key: request.body.slug,
445+
value: request.body.redirect,
446+
kvsClient: fastify.cloudfrontKvClient,
447+
arn: kvArn,
448+
});
449+
} catch (e) {
450+
fastify.log.error(e);
451+
if (e instanceof BaseError) {
452+
throw e;
453+
}
454+
throw new DatabaseInsertError({
455+
message: "Failed to save redirect to Cloudfront KV store.",
456+
});
457+
}
458+
return reply.status(201).send();
480459
},
481460
);
482461

@@ -536,79 +515,99 @@ const linkryRoutes: FastifyPluginAsync = async (fastify, _options) => {
536515
AppRoles.LINKS_ADMIN,
537516
]);
538517

539-
const { slug: slug } = request.params;
540-
// Query to get all items with the specified slug
541-
const queryParams = {
542-
TableName: genericConfig.LinkryDynamoTableName,
543-
KeyConditionExpression: "slug = :slug",
544-
ExpressionAttributeValues: {
545-
":slug": { S: decodeURIComponent(slug) },
546-
},
547-
};
548-
549-
const queryCommand = new QueryCommand(queryParams);
550-
const queryResponse = await dynamoClient.send(queryCommand);
551-
552-
const items: object[] = queryResponse.Items || [];
553-
const unmarshalledItems: (OwnerRecord | AccessRecord)[] = [];
554-
for (const item of items) {
555-
unmarshalledItems.push(
556-
unmarshall(item as { [key: string]: AttributeValue }) as
557-
| OwnerRecord
558-
| AccessRecord,
559-
);
560-
}
561-
if (items.length == 0)
562-
throw new ValidationError({ message: "Slug does not exist" });
563-
564-
const ownerRecord: OwnerRecord = unmarshalledItems.filter(
565-
(item): item is OwnerRecord => "redirect" in item,
566-
)[0];
567-
568-
const accessGroupNames: string[] = [];
569-
for (const record of unmarshalledItems) {
570-
if (record && record != ownerRecord) {
571-
const accessGroupUUID: string = record.access.split("GROUP#")[1];
572-
accessGroupNames.push(accessGroupUUID);
573-
}
574-
}
575-
576-
if (!request.username) {
577-
throw new Error("Username is undefined");
518+
if (!fastify.cloudfrontKvClient) {
519+
fastify.cloudfrontKvClient = new CloudFrontKeyValueStoreClient({
520+
region: genericConfig.AwsRegion,
521+
});
578522
}
523+
},
524+
},
525+
async (request, reply) => {
526+
const { slug } = request.params;
527+
const tableName = genericConfig.LinkryDynamoTableName;
528+
const currentRecord = await fetchLinkEntry(
529+
slug,
530+
tableName,
531+
fastify.dynamoClient,
532+
);
579533

580-
// const allUserGroupUUIDs = await listGroupIDsByEmail(
581-
// entraIdToken,
582-
// request.username,
583-
// );
584-
585-
const allUserGroupUUIDs = request.tokenPayload?.groups ?? [];
534+
if (!currentRecord) {
535+
throw new NotFoundError({ endpointName: request.url });
536+
}
586537

587-
const userLinkryGroups = allUserGroupUUIDs.filter((groupId) =>
588-
[...LinkryGroupUUIDToGroupNameMap.keys()].includes(groupId),
538+
if (currentRecord && !request.userRoles!.has(AppRoles.LINKS_ADMIN)) {
539+
const setUserGroups = new Set(request.tokenPayload?.groups || []);
540+
const mutualGroups = intersection(
541+
new Set(currentRecord["access"]),
542+
setUserGroups,
589543
);
544+
if (mutualGroups.size == 0) {
545+
throw new UnauthorizedError({
546+
message:
547+
"You do not own this record and have not been delegated access.",
548+
});
549+
}
550+
}
590551

552+
const TransactItems: TransactWriteItem[] = [
553+
...currentRecord.access.map((x) => ({
554+
Delete: {
555+
TableName: genericConfig.LinkryDynamoTableName,
556+
Key: {
557+
slug: { S: slug },
558+
access: { S: `GROUP#${x}` },
559+
},
560+
ConditionExpression: "updatedAt = :updatedAt",
561+
ExpressionAttributeValues: marshall({
562+
":updatedAt": currentRecord.updatedAt,
563+
}),
564+
},
565+
})),
566+
{
567+
Delete: {
568+
TableName: genericConfig.LinkryDynamoTableName,
569+
Key: {
570+
slug: { S: slug },
571+
access: { S: `OWNER#${currentRecord.owner}` },
572+
},
573+
ConditionExpression: "updatedAt = :updatedAt",
574+
ExpressionAttributeValues: marshall({
575+
":updatedAt": currentRecord.updatedAt,
576+
}),
577+
},
578+
},
579+
];
580+
try {
581+
await dynamoClient.send(
582+
new TransactWriteItemsCommand({ TransactItems }),
583+
);
584+
} catch (e) {
585+
fastify.log.error(e);
586+
// Handle optimistic concurrency control
591587
if (
592-
(ownerRecord &&
593-
ownerRecord.access.split("OWNER#")[1] == request.username) ||
594-
userLinkryGroups.length > 0
588+
e instanceof TransactionCanceledException &&
589+
e.CancellationReasons &&
590+
e.CancellationReasons.some(
591+
(reason) => reason.Code === "ConditionalCheckFailed",
592+
)
595593
) {
596-
} else {
597-
throw new UnauthorizedError({
598-
message: "User does not have permission to delete slug.",
594+
for (const reason of e.CancellationReasons) {
595+
request.log.error(`Cancellation reason: ${reason.Message}`);
596+
}
597+
throw new ValidationError({
598+
message:
599+
"The record was modified by another process. Please try again.",
599600
});
600601
}
601-
if (!fastify.cloudfrontKvClient) {
602-
fastify.cloudfrontKvClient = new CloudFrontKeyValueStoreClient({
603-
region: genericConfig.AwsRegion,
604-
});
602+
603+
if (e instanceof BaseError) {
604+
throw e;
605605
}
606-
},
607-
},
608-
async (request, reply) => {
609-
const { slug: slug } = request.params;
610606

611-
// Delete from Cloudfront KV first
607+
throw new DatabaseInsertError({
608+
message: "Failed to delete data from DynamoDB.",
609+
});
610+
}
612611
const kvArn = await getLinkryKvArn(fastify.runEnvironment);
613612
try {
614613
await deleteKey({
@@ -625,59 +624,7 @@ const linkryRoutes: FastifyPluginAsync = async (fastify, _options) => {
625624
message: "Failed to delete redirect at Cloudfront KV store.",
626625
});
627626
}
628-
// Query to get all items with the specified slug
629-
const queryParams = {
630-
TableName: genericConfig.LinkryDynamoTableName, // Replace with your table name
631-
KeyConditionExpression: "slug = :slug",
632-
ExpressionAttributeValues: {
633-
":slug": { S: decodeURIComponent(slug) },
634-
},
635-
};
636-
637-
const queryCommand = new QueryCommand(queryParams);
638-
const queryResponse = await dynamoClient.send(queryCommand);
639-
640-
const items = queryResponse.Items || [];
641-
642-
const filteredItems = items.filter((item) => {
643-
if (item.access.S?.startsWith("OWNER#")) {
644-
return true;
645-
} // TODO Ethan: temporary solution, current filter deletes all owner tagged and group tagged, need to differentiate between deleting owner versus deleting specific groups...
646-
else {
647-
return (
648-
item.access.S &&
649-
[...LinkryGroupUUIDToGroupNameMap.keys()].includes(
650-
item.access.S.replace("GROUP#", ""),
651-
)
652-
);
653-
}
654-
});
655-
656-
// Delete all fetched items
657-
const deletePromises = (filteredItems || []).map((item) =>
658-
dynamoClient.send(
659-
new DeleteItemCommand({
660-
TableName: genericConfig.LinkryDynamoTableName,
661-
Key: { slug: item.slug, access: item.access },
662-
}),
663-
),
664-
);
665-
try {
666-
await Promise.all(deletePromises); // TODO: use TransactWriteItems, it can also do deletions
667-
} catch (e) {
668-
// TODO: revert Cloudfront KV store here
669-
fastify.log.error(e);
670-
if (e instanceof BaseError) {
671-
throw e;
672-
}
673-
throw new DatabaseDeleteError({
674-
message: "Failed to delete record in DynamoDB.",
675-
});
676-
}
677-
678-
reply.code(200).send({
679-
message: `All records with slug '${slug}' deleted successfully`,
680-
});
627+
reply.code(200).send();
681628
},
682629
);
683630
};

src/common/types/linkry.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,8 @@ export const createRequest = z.object({
2222
export const linkRecord = z.object({
2323
access: z.array(z.string()),
2424
slug: z.string().min(1),
25-
createdAt: z.coerce.date(),
26-
updatedAt: z.coerce.date(),
25+
createdAt: z.string().datetime(),
26+
updatedAt: z.string().datetime(),
2727
redirect: z.string().url(),
2828
owner: z.string().optional()
2929
})

0 commit comments

Comments
 (0)