@@ -15,11 +15,19 @@ import {
1515 TransactWriteItemsCommand ,
1616 TransactWriteItem ,
1717 TransactionCanceledException ,
18+ PutItemCommand ,
19+ PutItemCommandInput ,
20+ ConditionalCheckFailedException ,
1821} from "@aws-sdk/client-dynamodb" ;
1922import { genericConfig } from "../../common/config.js" ;
2023import { marshall , unmarshall } from "@aws-sdk/util-dynamodb" ;
2124import 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" ;
2331import {
2432 extractUniqueSlugs ,
2533 fetchOwnerRecords ,
@@ -28,12 +36,18 @@ import {
2836 getDelegatedLinks ,
2937 fetchLinkEntry ,
3038 getAllLinks ,
39+ fetchOrgRecords ,
3140} from "api/functions/linkry.js" ;
3241import { 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" ;
3446import { Modules } from "common/modules.js" ;
3547import { FastifyZodOpenApiTypeProvider } from "fastify-zod-openapi" ;
3648import { 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
3852type 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+
4669type 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