From 03cf7e68777908d561634b792833fe225b15d041 Mon Sep 17 00:00:00 2001 From: Mohamed Marrouchi Date: Mon, 30 Dec 2024 10:34:48 +0100 Subject: [PATCH] feat: add support for signed public urls for channels --- api/src/channel/channel.module.ts | 3 +- api/src/channel/channel.service.ts | 13 ++++ api/src/channel/lib/Handler.ts | 76 ++++++++++++++++++- api/src/channel/webhook.controller.ts | 24 +++++- api/src/chat/services/bot.service.spec.ts | 2 + api/src/config/index.ts | 5 ++ api/src/config/types.ts | 1 + .../channels/web/__test__/index.spec.ts | 2 + .../channels/web/__test__/wrapper.spec.ts | 2 + docker/.env.example | 3 +- 10 files changed, 127 insertions(+), 4 deletions(-) diff --git a/api/src/channel/channel.module.ts b/api/src/channel/channel.module.ts index f67d6877..9306aeb2 100644 --- a/api/src/channel/channel.module.ts +++ b/api/src/channel/channel.module.ts @@ -13,6 +13,7 @@ import { Module, RequestMethod, } from '@nestjs/common'; +import { JwtModule } from '@nestjs/jwt'; import { InjectDynamicProviders } from 'nestjs-dynamic-providers'; import { AttachmentModule } from '@/attachment/attachment.module'; @@ -38,10 +39,10 @@ export interface ChannelModuleOptions { 'dist/.hexabot/custom/extensions/channels/**/*.channel.js', ) @Module({ + imports: [ChatModule, AttachmentModule, CmsModule, HttpModule, JwtModule], controllers: [WebhookController, ChannelController], providers: [ChannelService], exports: [ChannelService], - imports: [ChatModule, AttachmentModule, CmsModule, HttpModule], }) export class ChannelModule { configure(consumer: MiddlewareConsumer) { diff --git a/api/src/channel/channel.service.ts b/api/src/channel/channel.service.ts index 4c2d9986..66745c97 100644 --- a/api/src/channel/channel.service.ts +++ b/api/src/channel/channel.service.ts @@ -98,6 +98,19 @@ export class ChannelService { handler.handle(req, res); } + /** + * Handles a download request for a specific channel. + * + * @param channel - The channel for which the request is being handled. + * @param token - The file JWT token. + * @param req - The HTTP express request object. + * @returns A promise that resolves a streamable if the signed url is valid. + */ + async download(channel: string, token: string, req: Request) { + const handler = this.getChannelHandler(`${channel}-channel`); + return await handler.download(token, req); + } + /** * Handles a websocket request for the web channel. * diff --git a/api/src/channel/lib/Handler.ts b/api/src/channel/lib/Handler.ts index fb277fdf..85554a62 100644 --- a/api/src/channel/lib/Handler.ts +++ b/api/src/channel/lib/Handler.ts @@ -8,17 +8,28 @@ import path from 'path'; -import { Injectable, OnModuleInit } from '@nestjs/common'; +import { + Inject, + Injectable, + NotFoundException, + OnModuleInit, +} from '@nestjs/common'; +import { JwtService, JwtSignOptions } from '@nestjs/jwt'; +import { plainToClass } from 'class-transformer'; import { NextFunction, Request, Response } from 'express'; +import { Attachment } from '@/attachment/schemas/attachment.schema'; +import { AttachmentService } from '@/attachment/services/attachment.service'; import { SubscriberCreateDto } from '@/chat/dto/subscriber.dto'; import { StdOutgoingEnvelope, StdOutgoingMessage, } from '@/chat/schemas/types/message'; +import { config } from '@/config'; import { LoggerService } from '@/logger/logger.service'; import { SettingService } from '@/setting/services/setting.service'; import { Extension } from '@/utils/generics/extension'; +import { buildURL } from '@/utils/helpers/URL'; import { HyphenToUnderscore } from '@/utils/types/extension'; import { SocketRequest } from '@/websocket/utils/socket-request'; import { SocketResponse } from '@/websocket/utils/socket-response'; @@ -37,6 +48,19 @@ export default abstract class ChannelHandler< { private readonly settings: ChannelSetting[]; + @Inject(AttachmentService) + protected readonly attachmentService: AttachmentService; + + @Inject(JwtService) + protected readonly jwtService: JwtService; + + protected readonly jwtSignOptions: JwtSignOptions = { + secret: config.parameters.signedUrl.secret, + expiresIn: config.parameters.signedUrl.expiresIn, + algorithm: 'HS256', + encoding: 'utf-8', + }; + constructor( name: N, protected readonly settingService: SettingService, @@ -200,4 +224,54 @@ export default abstract class ChannelHandler< // Do nothing, override in channel next(); } + + /** + * Generates a signed URL for downloading an attachment. + * + * This function creates a signed URL for a given attachment using a JWT token. + * The signed URL includes the attachment name and a token as query parameters. + * + * @param attachment The attachment ID or object to generate a signed URL for. + * @return A signed URL string for downloading the specified attachment. + */ + protected async getPublicUrl(attachment: string | Attachment) { + const resource = + typeof attachment === 'string' + ? await this.attachmentService.findOne(attachment) + : attachment; + + if (!resource) { + throw new NotFoundException('Unable to find attachment'); + } + + const token = this.jwtService.sign({ ...resource }, this.jwtSignOptions); + const [name, _suffix] = this.getName().split('-'); + return buildURL( + config.apiBaseUrl, + `/webhook/${name}/download/${resource.name}?t=${encodeURIComponent(token)}`, + ); + } + + /** + * Downloads an attachment using a signed token. + * + * This function verifies the provided token and retrieves the corresponding + * attachment as a streamable file. If the verification fails or the attachment + * cannot be located, it throws a NotFoundException. + * + * @param token The signed token used to verify and locate the attachment. + * @param req - The HTTP express request object. + * @return A streamable file of the attachment. + * @throws NotFoundException if the attachment cannot be found or the token is invalid. + */ + public async download(token: string, _req: Request) { + try { + const result = this.jwtService.verify(token, this.jwtSignOptions); + const attachment = plainToClass(Attachment, result); + return await this.attachmentService.download(attachment); + } catch (err) { + this.logger.error('Failed to download attachment', err); + throw new NotFoundException('Unable to locate attachment'); + } + } } diff --git a/api/src/channel/webhook.controller.ts b/api/src/channel/webhook.controller.ts index 985e0da0..e432bf88 100644 --- a/api/src/channel/webhook.controller.ts +++ b/api/src/channel/webhook.controller.ts @@ -6,7 +6,7 @@ * 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file). */ -import { Controller, Get, Param, Post, Req, Res } from '@nestjs/common'; +import { Controller, Get, Param, Post, Query, Req, Res } from '@nestjs/common'; import { Request, Response } from 'express'; // Import the Express request and response types import { LoggerService } from '@/logger/logger.service'; @@ -21,6 +21,28 @@ export class WebhookController { private readonly logger: LoggerService, ) {} + /** + * Handles GET requests to download a file + * + * @param channel - The name of the channel for which the request is being sent. + * @param filename - The name of the requested file + * @param t - The JWT Token query param. + * @param req - The HTTP express request object. + * + * @returns A promise that resolves a streamable file. + */ + @Roles('public') + @Get(':channel/download/:name') + async handleDownload( + @Param('channel') channel: string, + @Param('name') name: string, + @Query('t') token: string, + @Req() req: Request, + ) { + this.logger.log('Channel download request: ', channel, name); + return await this.channelService.download(channel, token, req); + } + /** * Handles GET requests of a specific channel. * This endpoint is accessible to public access (messaging platforms). diff --git a/api/src/chat/services/bot.service.spec.ts b/api/src/chat/services/bot.service.spec.ts index 5dab34e6..bf71c0b3 100644 --- a/api/src/chat/services/bot.service.spec.ts +++ b/api/src/chat/services/bot.service.spec.ts @@ -8,6 +8,7 @@ import { CACHE_MANAGER } from '@nestjs/cache-manager'; import { EventEmitter2 } from '@nestjs/event-emitter'; +import { JwtModule } from '@nestjs/jwt'; import { MongooseModule } from '@nestjs/mongoose'; import { Test } from '@nestjs/testing'; @@ -101,6 +102,7 @@ describe('BlockService', () => { ContextVarModel, LanguageModel, ]), + JwtModule, ], providers: [ EventEmitter2, diff --git a/api/src/config/index.ts b/api/src/config/index.ts index 429e9506..9a9936b6 100644 --- a/api/src/config/index.ts +++ b/api/src/config/index.ts @@ -114,6 +114,11 @@ export const config: Config = { ? Number(process.env.UPLOAD_MAX_SIZE_IN_BYTES) : 50 * 1024 * 1024, // 50 MB in bytes appName: 'Hexabot.ai', + signedUrl: { + salt: parseInt(process.env.SALT_LENGTH || '12'), + secret: process.env.SIGNED_URL_SECRET || 'DEFAULT_SIGNED_URL_SECRET', + expiresIn: process.env.SIGNED_URL_EXPIRES_IN || '24H', + }, }, pagination: { limit: 10, diff --git a/api/src/config/types.ts b/api/src/config/types.ts index 7d63613d..57ac8cf9 100644 --- a/api/src/config/types.ts +++ b/api/src/config/types.ts @@ -81,6 +81,7 @@ export type Config = { storageMode: 'disk' | 'memory'; maxUploadSize: number; appName: string; + signedUrl: TJwtOptions; }; pagination: { limit: number; diff --git a/api/src/extensions/channels/web/__test__/index.spec.ts b/api/src/extensions/channels/web/__test__/index.spec.ts index 4038f632..ffd12cfe 100644 --- a/api/src/extensions/channels/web/__test__/index.spec.ts +++ b/api/src/extensions/channels/web/__test__/index.spec.ts @@ -8,6 +8,7 @@ import { CACHE_MANAGER } from '@nestjs/cache-manager'; import { EventEmitter2 } from '@nestjs/event-emitter'; +import { JwtModule } from '@nestjs/jwt'; import { MongooseModule } from '@nestjs/mongoose'; import { Test, TestingModule } from '@nestjs/testing'; import { Request } from 'express'; @@ -78,6 +79,7 @@ describe('WebChannelHandler', () => { LabelModel, UserModel, ]), + JwtModule, ], providers: [ { diff --git a/api/src/extensions/channels/web/__test__/wrapper.spec.ts b/api/src/extensions/channels/web/__test__/wrapper.spec.ts index 66fae47e..9d2839e3 100644 --- a/api/src/extensions/channels/web/__test__/wrapper.spec.ts +++ b/api/src/extensions/channels/web/__test__/wrapper.spec.ts @@ -8,6 +8,7 @@ import { CACHE_MANAGER } from '@nestjs/cache-manager'; import { EventEmitter2 } from '@nestjs/event-emitter'; +import { JwtModule } from '@nestjs/jwt'; import { MongooseModule } from '@nestjs/mongoose'; import { Test, TestingModule } from '@nestjs/testing'; @@ -57,6 +58,7 @@ describe(`Web event wrapper`, () => { MessageModel, MenuModel, ]), + JwtModule, ], providers: [ { diff --git a/docker/.env.example b/docker/.env.example index aeb135a1..441411b1 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -23,7 +23,8 @@ PASSWORD_RESET_JWT_SECRET=dev_only PASSWORD_RESET_EXPIRES_IN=1h CONFIRM_ACCOUNT_SECRET=dev_only CONFIRM_ACCOUNT_EXPIRES_IN=1h -FRONTEND_DOCKER_IMAGE=linuxtry +SIGNED_URL_SECRET=dev_only +SIGNED_URL_EXPIRES_IN=1h I18N_TRANSLATION_FILENAME=messages # Mongo configs