diff --git a/.env b/.env index 316be2e08a7e37be9dd7f2b9542dbfbcac1393e3..898eedf44db50d05a7e361dcc90fa0f38d0432c3 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 d87a7e111aafbd5a29f949cdda18894644700832..b4031e65149902274eed29c3fac22c1ee108a129 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 0a04a8ab24636ce3a1765f16ea54cbb2fe567fef..f757cd404033f974be715071304ff14dec41292d 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 f9e9a840d8f44df8d04cca29d97f8b52e89a2da2..27fad6848095e93a49e764d640d37aaed0817f18 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 db252e22e775e19e590bd5bea55ba136f0ddd6b6..8de85bcff8d70cd057b2ca7db9486daa6803ff5b 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 fd26add5f76cc0789a50a17a3d8ee2aaf81799d0..ab64f2dee130e26d7002b646cbe72cbe4d9c8ded 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 dd2a0704078ffa52ace9f60441fb5b73ffed634e..c4df283353a14ef1c6140cd210adc734eec946c9 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 6e91d74c1fcbc6f3cdc0bfdfa1391c8f12c595ca..3f933713a482203afc3afb88b631b96abe24f6b2 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 0000000000000000000000000000000000000000..87e5b147d523877977563ccdbe9f71208acbbff5 --- /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 318a59e1c742f5baf407d1a258c4586e93b5847c..7537369a2ecc9b292aa20aaa8c3c554562c0a930 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 a0cbc58b6491a141835a51b74debabaf76e45725..0247987a11a3bbe2035afd448ed1e300eb1d6c77 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 7d7fda6dae924da05661f5f025f7ee6e19e19c55..834f2ce1e6bbec7fb60dbe3eeaf03cf6ba665ef3 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); +}