From 0297c6213d903bee93ff55a0a19f087af958fb2b Mon Sep 17 00:00:00 2001
From: Caetan Tojeiro Carpente <caetan.tojeiro.carpente@cern.ch>
Date: Mon, 10 Jul 2023 19:11:09 +0200
Subject: [PATCH] [#230] Short URL implementation

---
 .env                                          | 11 ++-
 README.md                                     |  7 ++
 src/app-data-source.ts                        |  1 +
 src/controllers/channels/dto.ts               | 17 +++++
 src/models/cern-authorization-service.ts      | 17 +++++
 src/models/channel.ts                         | 64 ++++++++++++++++-
 src/services/impl/channels/create-channel.ts  |  9 +++
 src/services/impl/channels/delete-channel.ts  |  7 ++
 .../impl/channels/manage-short-url.ts         | 70 +++++++++++++++++++
 src/services/impl/channels/update-channel.ts  | 10 +++
 .../impl/notifications/send-notification.ts   |  9 +++
 src/utils/fetch.ts                            |  5 ++
 12 files changed, 225 insertions(+), 2 deletions(-)
 create mode 100644 src/services/impl/channels/manage-short-url.ts

diff --git a/.env b/.env
index 316be2e0..898eedf4 100644
--- a/.env
+++ b/.env
@@ -97,4 +97,13 @@ SWAGGER_CHANNEL_ID=a8b1b6db-2543-4549-b143-442735739fed
 # Set to True in order to deploy an internal backend (without authentication)
 EXPOSE_UNAUTHENTICATED_ROUTES=False
 
-RECOMMENDER_SYSTEM_SECRET=fill-me
\ No newline at end of file
+APPSIGNAL_PUSH_API_KEY=fill-me
+APPSIGNAL_ENV=master
+
+RECOMMENDER_SYSTEM_SECRET=fill-me
+MAX_RECOMMENDATIONS=5
+
+SHORT_URL_BASE_URL=https://web-redirector-v2-qa.web.cern.ch/_/api/shorturlentry
+SHORT_URL_AUDIENCE=web-redirector-v2-qa
+AUTHORIZATION_SERVICE_API_TOKEN_URL_SHORT_URL=https://auth.cern.ch/auth/realms/cern/api-access/token
+OAUTH_CLIENT_SECRET_SHORT_URL=fill-me
diff --git a/README.md b/README.md
index d87a7e11..b4031e65 100644
--- a/README.md
+++ b/README.md
@@ -106,3 +106,10 @@ Entries management: via OpenShift ```backend-internal``` pod using ```curl```:
 - Add a new message, with default or specific validity: 
     - ```curl -X POST http://localhost:8080/servicestatus -H "Content-Type: application/json" --data '{"message":"Service will be upgraded tonight at 20pm"}'```
     - ```curl -X POST http://localhost:8080/servicestatus -H "Content-Type: application/json" --data '{"message":"Service will be upgraded tonight at 20pm", "validity": 1}'```
+
+## Short URL
+
+Channel's short URLs will be created during channel creation, channel editing and notification sending.
+In case of short url service disruption, the flow will continue. For recovery and initial setup of existing channels, a subscriber has been registered.
+
+[Web Redirector API](https://gitlab.cern.ch/webservices/web-redirector-v2/-/blob/master/app/api/README.md)
diff --git a/src/app-data-source.ts b/src/app-data-source.ts
index 0a04a8ab..f757cd40 100644
--- a/src/app-data-source.ts
+++ b/src/app-data-source.ts
@@ -21,4 +21,5 @@ export const AppDataSource = new DataSource({
     max: Number(process.env.TYPEORM_POOL_SIZE || 20),
   },
   applicationName: `${process.env.APP_NAME} - ${os.hostname()}`,
+  subscribers: ['src/models/*.ts'],
 });
diff --git a/src/controllers/channels/dto.ts b/src/controllers/channels/dto.ts
index f9e9a840..27fad684 100644
--- a/src/controllers/channels/dto.ts
+++ b/src/controllers/channels/dto.ts
@@ -12,6 +12,7 @@ import {
   IsUUID,
   Min,
   IsDateString,
+  IsUrl,
 } from 'class-validator';
 import { Type } from 'class-transformer';
 import { JSONSchema } from 'class-validator-jsonschema';
@@ -774,6 +775,13 @@ export class EditChannelResponse {
   })
   slug: string;
 
+  @IsUrl()
+  @JSONSchema({
+    description: 'Channel short URL. Self generated, non editable.',
+    example: 'cern.ch/n-abc',
+  })
+  shortUrl: string;
+
   @IsNotEmpty()
   @JSONSchema({
     description: 'Channel owner.',
@@ -916,6 +924,7 @@ export class EditChannelResponse {
     this.id = channel.id;
     this.name = channel.name;
     this.slug = channel.slug;
+    this.shortUrl = channel.shortUrl;
     this.owner = channel.owner;
     this.description = channel.description;
     this.adminGroup = channel.adminGroup;
@@ -1062,6 +1071,13 @@ export class ChannelResponse {
   })
   slug: string;
 
+  @IsUrl()
+  @JSONSchema({
+    description: 'Channel short URL. Self generated, non editable.',
+    example: 'cern.ch/n-abc',
+  })
+  shortUrl: string;
+
   @IsNotEmpty()
   @JSONSchema({
     description: "The channel's owner.",
@@ -1341,6 +1357,7 @@ export class ChannelResponse {
   constructor(channel: Channel) {
     this.id = channel.id;
     this.slug = channel.slug;
+    this.shortUrl = channel.shortUrl;
     this.owner = channel.owner;
     this.name = channel.name;
     this.description = channel.description;
diff --git a/src/models/cern-authorization-service.ts b/src/models/cern-authorization-service.ts
index db252e22..8de85bcf 100644
--- a/src/models/cern-authorization-service.ts
+++ b/src/models/cern-authorization-service.ts
@@ -29,6 +29,23 @@ export class CernAuthorizationService {
     return CernAuthorizationService.memoizedGetAuthToken();
   }
 
+  static async getShortURLAuthToken(): Promise<{ authorization: string }> {
+    const shortUrlToken = await postWithTimeout(process.env.AUTHORIZATION_SERVICE_API_TOKEN_URL_SHORT_URL, {
+      headers: {
+        'Content-Type': 'application/x-www-form-urlencoded',
+      },
+      body: new URLSearchParams(
+        `grant_type=client_credentials&client_id=${process.env.OAUTH_CLIENT_ID}&client_secret=${process.env.OAUTH_CLIENT_SECRET_SHORT_URL}&audience=${process.env.SHORT_URL_AUDIENCE}`,
+      ),
+    });
+    const authorization = {
+      'Content-Type': 'application/json',
+      authorization: `Bearer ${shortUrlToken['access_token']}`,
+    };
+
+    return authorization;
+  }
+
   static async getHeaders() {
     const accessToken = (await CernAuthorizationService.getAuthToken())['access_token'];
     return {
diff --git a/src/models/channel.ts b/src/models/channel.ts
index fd26add5..ab64f2de 100644
--- a/src/models/channel.ts
+++ b/src/models/channel.ts
@@ -6,16 +6,19 @@ import {
   DeleteDateColumn,
   Entity,
   EntityManager,
+  EntitySubscriberInterface,
+  EventSubscriber,
   In,
   Index,
   JoinTable,
+  LoadEvent,
   ManyToMany,
   ManyToOne,
   OneToMany,
   PrimaryGeneratedColumn,
   Raw,
 } from 'typeorm';
-import { IsEmail, IsOptional, Validate } from 'class-validator';
+import { IsEmail, IsOptional, IsUrl, Validate } from 'class-validator';
 import { User } from './user';
 import { Tag } from './tag';
 import { Category } from './category';
@@ -39,6 +42,8 @@ import { SelectQueryBuilder } from 'typeorm/query-builder/SelectQueryBuilder';
 import { UserChannelCollection, UserChannelCollectionType } from './user-channel-collection';
 import { Group as AuthGroup } from './cern-authorization-service';
 import { ForbiddenError } from 'routing-controllers';
+import { createShortURL } from '../services/impl/channels/manage-short-url';
+import { UpdateResult } from 'typeorm/query-builder/result/UpdateResult';
 
 @Entity('Channels')
 @Index(['slug', 'deleteDate'], { unique: true })
@@ -51,6 +56,12 @@ export class Channel extends ApiKeyObject {
   @Column()
   slug: string;
 
+  @IsOptional()
+  @IsUrl()
+  @Index({ unique: true })
+  @Column({ nullable: true })
+  shortUrl: string;
+
   @ManyToOne(type => User, user => user.ownedChannels)
   owner: User;
 
@@ -188,6 +199,7 @@ export class Channel extends ApiKeyObject {
     if (channel) {
       this.id = channel.id;
       this.slug = channel.slug;
+      this.shortUrl = channel.shortUrl;
       this.name = channel.name;
       this.description = channel.description;
       this.category = channel.category;
@@ -1002,3 +1014,53 @@ export class Channel extends ApiKeyObject {
     await transactionManager.createQueryBuilder().relation(Channel, 'members').of(this).remove(user);
   }
 }
+
+@EventSubscriber()
+export class ChannelSubscriber implements EntitySubscriberInterface {
+  /**
+   * Indicates that this subscriber only listen to Post events.
+   */
+  listenTo(): typeof Channel {
+    return Channel;
+  }
+
+  /**
+   * Fill Short URL field: needed for initialization of existing channels.
+   * Need for automatic retry/recovery in case of the short URL API being down or some failure happening.
+   *
+   * @param entity
+   * @param event
+   */
+  async _fillShortURL(entity: Channel, event: LoadEvent<Channel>): Promise<UpdateResult> {
+    if (!Object.prototype.hasOwnProperty.call(entity, 'shortUrl')) {
+      console.debug(`Short Url: Channel obj not fully loaded : ${JSON.stringify(entity)} - skipping!`);
+      return;
+    }
+
+    if (entity.shortUrl) {
+      console.debug(`Short url for channel ${entity.name} already exists: ${entity.shortUrl} - skipping!`);
+      return;
+    }
+
+    console.debug(`Generating short url for channel ${entity.name}!`);
+
+    try {
+      const shortUrl = await createShortURL(entity.id);
+      await event.manager.update(Channel, { id: entity.id }, { shortUrl });
+    } catch (e) {
+      console.error(`Error creating the short URL for ${entity.name} channel on after load event`, e);
+    }
+  }
+
+  /**
+   * Called after entity is loaded.
+   */
+  async afterLoad(entity: Channel, event: LoadEvent<Channel>): Promise<void> {
+    if (!entity.id) {
+      console.debug(`Short Url: Could not find channel for : ${JSON.stringify(entity)} - skipping!`);
+      return;
+    }
+
+    await this._fillShortURL(entity, event);
+  }
+}
diff --git a/src/services/impl/channels/create-channel.ts b/src/services/impl/channels/create-channel.ts
index dd2a0704..c4df2833 100644
--- a/src/services/impl/channels/create-channel.ts
+++ b/src/services/impl/channels/create-channel.ts
@@ -10,11 +10,17 @@ import { prepareValidationErrorList } from './validation-utils';
 import { AuditChannels } from '../../../log/auditing';
 import { ChannelResponse, CreateChannelRequest } from '../../../controllers/channels/dto';
 import { SubscriptionPolicy } from '../../../models/channel-enums';
+import { createShortURL } from './manage-short-url';
 
 export class CreateChannel implements Command {
   constructor(private channel: CreateChannelRequest, private authorizationBag: AuthorizationBag) {}
 
   async execute(transactionManager: EntityManager): Promise<ChannelResponse> {
+    // whitelist: strip all properties that don't have any decorators
+    await validate(this.channel, { whitelist: true }).then(errors => {
+      if (errors.length > 0) throw new BadRequestError('Form validation failed: ' + prepareValidationErrorList(errors));
+    });
+
     // Get owner (user who have created the channel) to set as user by default
     const user = await transactionManager.findOneBy(User, {
       id: this.authorizationBag.userId,
@@ -64,6 +70,9 @@ export class CreateChannel implements Command {
 
     const createdChannel = await transactionManager.save(channel);
 
+    createdChannel.shortUrl = await createShortURL(createdChannel.id);
+    await transactionManager.save(createdChannel);
+
     await AuditChannels.setValue(createdChannel.id, {
       event: 'Create',
       user: this.authorizationBag.email,
diff --git a/src/services/impl/channels/delete-channel.ts b/src/services/impl/channels/delete-channel.ts
index 6e91d74c..3f933713 100644
--- a/src/services/impl/channels/delete-channel.ts
+++ b/src/services/impl/channels/delete-channel.ts
@@ -4,6 +4,7 @@ import { Channel } from '../../../models/channel';
 import { ForbiddenError, NotFoundError } from 'routing-controllers';
 import { AuthorizationBag } from '../../../models/authorization-bag';
 import { AuditChannels } from '../../../log/auditing';
+import { deleteShortURL } from './manage-short-url';
 
 export class DeleteChannel implements Command {
   constructor(private channelId: string, private authorizationBag: AuthorizationBag) {}
@@ -27,6 +28,12 @@ export class DeleteChannel implements Command {
     channel.slug = `${channel.slug}-DELETED-${Date.now()}`;
     await transactionManager.save(channel);
 
+    try {
+      await deleteShortURL(channel.shortUrl);
+    } catch (e) {
+      console.error('Error deleting the short URL for', channel.name, 'channel:', e);
+    }
+
     const result = await transactionManager.softDelete(Channel, channel.id);
     if (result.affected === 1) {
       await AuditChannels.setValue(channel.id, {
diff --git a/src/services/impl/channels/manage-short-url.ts b/src/services/impl/channels/manage-short-url.ts
new file mode 100644
index 00000000..87e5b147
--- /dev/null
+++ b/src/services/impl/channels/manage-short-url.ts
@@ -0,0 +1,70 @@
+import { deleteWithTimeout, getWithTimeout, postWithTimeout } from '../../../utils/fetch';
+import { CernAuthorizationService } from '../../../models/cern-authorization-service';
+
+function generateRandomSlug(): string {
+  let slug = '';
+  const characters = 'abcdefghijklmnopqrstuvwxyz0123456789';
+  let counter = 0;
+  while (counter < 3) {
+    slug += characters.charAt(Math.floor(Math.random() * characters.length));
+    counter += 1;
+  }
+  return slug;
+}
+
+export async function _createShortURL(channelId: string): Promise<string> {
+  const shortURLToken = await CernAuthorizationService.getShortURLAuthToken();
+
+  try {
+    const shortURLs = await postWithTimeout(`${process.env.SHORT_URL_BASE_URL}`, {
+      headers: shortURLToken,
+      body: JSON.stringify({
+        slug: `n-${generateRandomSlug()}`,
+        targetUrl: `${process.env.BASE_WEBSITE_URL}/channels/${channelId}/notifications`,
+        description: `Notifications: Short URL for channel ${channelId}`,
+        appendQuery: true,
+      }),
+    });
+
+    return `${new URL(shortURLs.url).host}/${shortURLs.slug}`;
+  } catch (e) {
+    if (e.message.includes('Entry already exists.')) {
+      console.error(`Channel Short URL: The generated slug already exists.`);
+    }
+    return null;
+  }
+}
+
+export async function createShortURL(channelId: string): Promise<string> {
+  let url = await _createShortURL(channelId);
+  if (url) {
+    return url;
+  }
+
+  console.error(`Channel Short URL: Failed creating. Attempting again...`);
+  url = await _createShortURL(channelId);
+  if (url) {
+    return url;
+  }
+
+  console.error(`Channel Short URL: Failed creating.`);
+  return null;
+}
+
+export async function deleteShortURL(shortUrl: string): Promise<void> {
+  const shortURLToken = await CernAuthorizationService.getShortURLAuthToken();
+  const slug = shortUrl.split('/')[1];
+  const shortUrls = await getWithTimeout(`${process.env.SHORT_URL_BASE_URL}?slug=${slug}`, {
+    headers: shortURLToken,
+  });
+  const shortUrlId = shortUrls.find(url => {
+    return url.slug === slug;
+  })?.id;
+  if (!shortUrlId || shortUrls.length == 0) {
+    console.error(`Channel Short URL: Failed deleting. Could not find shortUrl: ${shortUrl}`);
+  }
+
+  await deleteWithTimeout(`${process.env.SHORT_URL_BASE_URL}/${shortUrlId}`, {
+    headers: shortURLToken,
+  });
+}
diff --git a/src/services/impl/channels/update-channel.ts b/src/services/impl/channels/update-channel.ts
index 318a59e1..7537369a 100644
--- a/src/services/impl/channels/update-channel.ts
+++ b/src/services/impl/channels/update-channel.ts
@@ -7,11 +7,17 @@ import { prepareValidationErrorList } from './validation-utils';
 import { AuditChannels } from '../../../log/auditing';
 import { UpdateChannelRequest, ChannelResponse } from '../../../controllers/channels/dto';
 import { validate } from 'class-validator';
+import { createShortURL } from './manage-short-url';
 
 export class UpdateChannel implements Command {
   constructor(private channel: UpdateChannelRequest, private authorizationBag: AuthorizationBag) {}
 
   async execute(transactionManager: EntityManager): Promise<ChannelResponse> {
+    // whitelist: strip all properties that don't have any decorators
+    await validate(this.channel, { whitelist: true }).then(errors => {
+      if (errors.length > 0) throw new BadRequestError('Form validation failed: ' + prepareValidationErrorList(errors));
+    });
+
     const channel = await transactionManager.findOne(Channel, {
       relations: ['adminGroup', 'groups', 'owner'],
       where: {
@@ -19,6 +25,10 @@ export class UpdateChannel implements Command {
       },
     });
 
+    if (!channel.shortUrl) {
+      channel.shortUrl = await createShortURL(this.channel.id);
+    }
+
     validate(channel).then(errors => {
       console.log(errors);
     });
diff --git a/src/services/impl/notifications/send-notification.ts b/src/services/impl/notifications/send-notification.ts
index a0cbc58b..0247987a 100644
--- a/src/services/impl/notifications/send-notification.ts
+++ b/src/services/impl/notifications/send-notification.ts
@@ -17,6 +17,7 @@ import { GroupsServiceInterface } from '../../groups-service';
 import * as memoize from 'memoizee';
 import { AuditNotifications } from '../../../log/auditing';
 import { GetNotificationResponse, SendNotificationRequest } from '../../../controllers/notifications/dto';
+import { createShortURL } from '../channels/manage-short-url';
 
 import { CernAuthorizationService } from '../../../models/cern-authorization-service';
 import { SubmissionByForm } from '../../../models/channel-enums';
@@ -141,6 +142,14 @@ export class SendNotification implements Command {
 
     const source = this.setSource(this.authorizationBag, this.notification.source);
 
+    if (!targetChannel.shortUrl) {
+      await transactionManager.update(
+        Channel,
+        { id: targetChannel.id },
+        { shortUrl: await createShortURL(targetChannel.id) },
+      );
+    }
+
     const newNotification = await transactionManager.save(
       new Notification({
         ...this.notification,
diff --git a/src/utils/fetch.ts b/src/utils/fetch.ts
index 7d7fda6d..834f2ce1 100644
--- a/src/utils/fetch.ts
+++ b/src/utils/fetch.ts
@@ -46,3 +46,8 @@ async function fetchWithTimeout(uri: URL, options: RequestInit): Promise<any> {
     clearTimeout(timeout);
   }
 }
+
+export async function deleteWithTimeout(uri: URL, options: RequestInit): Promise<any> {
+  options.method = 'delete';
+  return fetchWithTimeout(uri, options);
+}
-- 
GitLab