feat: add support for signed public urls for channels

This commit is contained in:
Mohamed Marrouchi 2024-12-30 10:34:48 +01:00
parent 4ea98abfbf
commit 03cf7e6877
10 changed files with 127 additions and 4 deletions

View File

@ -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) {

View File

@ -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.
*

View File

@ -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<N>[];
@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');
}
}
}

View File

@ -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).

View File

@ -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,

View File

@ -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,

View File

@ -81,6 +81,7 @@ export type Config = {
storageMode: 'disk' | 'memory';
maxUploadSize: number;
appName: string;
signedUrl: TJwtOptions;
};
pagination: {
limit: number;

View File

@ -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: [
{

View File

@ -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: [
{

View File

@ -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