@@ -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 } ;
0 commit comments