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