diff --git a/package.json b/package.json index 1808889..d42f551 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ "@types/bcrypt": "^5.0.0", "@types/express": "^4.17.17", "@types/jest": "^29.5.2", + "@types/multer": "^1.4.10", "@types/node": "^20.3.1", "@types/ramda": "^0.29.3", "@types/slate-irc": "^0.0.29", diff --git a/src/kv/kv.controller.spec.ts b/src/kv/kv.controller.spec.ts deleted file mode 100644 index 5af12e1..0000000 --- a/src/kv/kv.controller.spec.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { KvController } from './KvController'; - -describe('KvController', () => { - let controller: KvController; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - controllers: [KvController], - }).compile(); - - controller = module.get(KvController); - }); - - it('should be defined', () => { - expect(controller).toBeDefined(); - }); -}); diff --git a/src/kv/kv.controller.ts b/src/kv/kv.controller.ts index 36aeb31..931e1e1 100644 --- a/src/kv/kv.controller.ts +++ b/src/kv/kv.controller.ts @@ -6,11 +6,18 @@ import { Body, Param, HttpException, + Patch, + UseInterceptors, + UploadedFile, } from '@nestjs/common'; import { KvService } from './kv.service'; import { Request } from 'express'; +import { ApiTags } from '@nestjs/swagger'; +import exp from 'constants'; +import { FileInterceptor } from '@nestjs/platform-express'; @Controller('kv') +@ApiTags('kv') export class KvController { constructor(private readonly kvService: KvService) {} @@ -42,28 +49,32 @@ export class KvController { ): Promise { if (request.headers['x-secret-key']) { try { - return await this.kvService.getWithSecretKey( - namespace, - key, - request.headers['x-secret-key'] as string, - ); + return ( + await this.kvService.getWithSecretKey( + namespace, + key, + request.headers['x-secret-key'] as string, + ) + ).toString(); } catch (e) { throw new HttpException(e.message, 403); } } try { - return await this.kvService.get(namespace, key); + return (await this.kvService.get(namespace, key)).toString(); } catch (e) { throw new HttpException(e.message, 403); } } @Post(':namespace/:key') + @UseInterceptors(FileInterceptor('file')) async create( @Param('namespace') namespace: string, @Param('key') key: string, @Req() request: Request, - @Body() body: object, + @Body() body: object | string, + @UploadedFile('file') file: Express.Multer.File, ) { const secretKey = (request.headers['x-secret-key'] as string | undefined) ?? ''; @@ -72,14 +83,42 @@ export class KvController { return await this.kvService.set( namespace, key, - JSON.stringify(body), + file === undefined + ? typeof body === 'object' + ? Buffer.from(JSON.stringify(body)) + : Buffer.from(body) + : file.buffer, secretKey, - { public: publicFlag }, + { public: publicFlag, mimeType: file.mimetype ?? 'text/plain' }, ); } return 'No secret key provided within X-Secret-Key header'; } + @Post(':namespace/:key/share') + async share( + @Param('namespace') namespace: string, + @Param('key') key: string, + @Req() request: Request, + @Body() body: { expiry?: number }, + ) { + const secretKey = + (request.headers['x-secret-key'] as string | undefined) ?? ''; + if (secretKey) { + return { + expiry: body.expiry, + namespace, + key, + url: await this.kvService.shareFile( + namespace, + key, + secretKey, + body.expiry, + ), + }; + } + } + @Post(':namespace') async claimNamespace( @Param('namespace') namespace: string, @@ -91,4 +130,22 @@ export class KvController { throw new HttpException(e.message, 403); } } + + @Patch(':namespace/secretKey') + async rotateSecretKey( + @Param('namespace') namespace: string, + @Req() request: Request, + ) { + if (!request.headers['x-secret-key']) { + throw new HttpException("Missing 'X-Secret-Key' header", 403); + } + try { + return await this.kvService.rotateSecretKey( + namespace, + request.headers['x-secret-key'] as string, + ); + } catch (e) { + throw new HttpException(e.message, 403); + } + } } diff --git a/src/kv/kv.service.spec.ts b/src/kv/kv.service.spec.ts deleted file mode 100644 index 49960d6..0000000 --- a/src/kv/kv.service.spec.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { KvService } from './kv.service'; - -describe('KvService', () => { - let service: KvService; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - providers: [KvService], - }).compile(); - - service = module.get(KvService); - }); - - it('should be defined', () => { - expect(service).toBeDefined(); - }); -}); diff --git a/src/kv/kv.service.ts b/src/kv/kv.service.ts index 39cd7d8..59ce350 100644 --- a/src/kv/kv.service.ts +++ b/src/kv/kv.service.ts @@ -30,6 +30,25 @@ export class KvService { ); } + private generateSecretKey(): string { + return Math.random().toString(36).substring(2); + } + + public async rotateSecretKey( + namespace: string, + secretKey: string, + ): Promise { + const metadata = await this.getMetadataFile().then((buffer) => + JSON.parse(buffer.toString()), + ); + if (metadata[namespace]?.secretKey !== secretKey) { + throw new Error('Secret key does not match'); + } + metadata[namespace].secretKey = this.generateSecretKey(); + await this.setMetadataFile(Buffer.from(JSON.stringify(metadata))); + return metadata[namespace]; + } + public async queryObjectMetadata( namespace: string, key: string, @@ -48,34 +67,35 @@ export class KvService { return objectMetadata?.metaData; } - public async get(namespace: string, key: string): Promise { + public async get(namespace: string, key: string): Promise { const objectMetadata = await this.minioService.getObjectMetadata( this.kvBucketName, this.generateFilePath(namespace, key), ); - console.log(objectMetadata); if (objectMetadata?.metaData.public !== 'true') { throw new Error('Object is not public'); } - return await this.minioService - .getBuffer(this.kvBucketName, this.generateFilePath(namespace, key)) - .then((buffer) => buffer.toString()); + return await this.minioService.getBuffer( + this.kvBucketName, + this.generateFilePath(namespace, key), + ); } public async getWithSecretKey( namespace: string, key: string, secretKey: string, - ): Promise { + ): Promise { const metadata = await this.getMetadataFile().then((buffer) => JSON.parse(buffer.toString()), ); if (metadata[namespace]?.secretKey !== secretKey) { throw new Error('Secret key does not match'); } - return await this.minioService - .getBuffer(this.kvBucketName, this.generateFilePath(namespace, key)) - .then((buffer) => buffer.toString()); + return await this.minioService.getBuffer( + this.kvBucketName, + this.generateFilePath(namespace, key), + ); } public async claimNamespace( @@ -90,7 +110,7 @@ export class KvService { } metadata[namespace] = { ...extraData, - secretKey: Math.random().toString(36).substring(2), + secretKey: this.generateSecretKey(), claimed: new Date().toISOString(), }; await this.setMetadataFile(Buffer.from(JSON.stringify(metadata))); @@ -100,10 +120,11 @@ export class KvService { public async set( namespace: string, key: string, - value: string, + value: Buffer, secretKey: string, options: { public: boolean; + mimeType?: string; } = { public: false, }, @@ -132,4 +153,24 @@ export class KvService { this.generateFilePath(namespace, key), ); } + + public async shareFile( + namespace: string, + key: string, + secretKey: string, + // Default expiry is 7 days + expiry: number = 60 * 60 * 24 * 7, + ): Promise { + const metadata = await this.getMetadataFile().then((buffer) => + JSON.parse(buffer.toString()), + ); + if (metadata[namespace]?.secretKey !== secretKey) { + throw new Error('Incorrect secret key for namespace'); + } + return await this.minioService.generatePresignedUrl( + this.kvBucketName, + this.generateFilePath(namespace, key), + expiry, + ); + } } diff --git a/src/main.ts b/src/main.ts index 4ac0115..92c4cb0 100644 --- a/src/main.ts +++ b/src/main.ts @@ -17,7 +17,9 @@ async function bootstrap() { .build(); const document = SwaggerModule.createDocument(app, config); SwaggerModule.setup('api', app, document); - app.enableCors({origin: "*"}); + app.enableCors({ origin: '*' }); + app.useBodyParser('text'); + app.disable('x-powered-by'); await app.listen(3000); } bootstrap(); diff --git a/src/minio/minio.service.spec.ts b/src/minio/minio.service.spec.ts deleted file mode 100644 index 8cc5a96..0000000 --- a/src/minio/minio.service.spec.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { MinioService } from './minio.service'; - -describe('MinioService', () => { - let service: MinioService; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - providers: [MinioService], - }).compile(); - - service = module.get(MinioService); - }); - - it('should be defined', () => { - expect(service).toBeDefined(); - }); -}); diff --git a/src/minio/minio.service.ts b/src/minio/minio.service.ts index cfa324a..c481991 100644 --- a/src/minio/minio.service.ts +++ b/src/minio/minio.service.ts @@ -88,4 +88,19 @@ export class MinioService { ): Promise { await this.client.removeObject(bucketName, objectName); } + + /** + * + * @param bucketName + * @param objectName + * @param expiry Expiry in seconds + * @returns + */ + public async generatePresignedUrl( + bucketName: string, + objectName: string, + expiry: number = 60 * 60 * 24, + ): Promise { + return await this.client.presignedGetObject(bucketName, objectName, expiry); + } } diff --git a/yarn.lock b/yarn.lock index c6818f1..9cdc19c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1003,6 +1003,16 @@ "@types/range-parser" "*" "@types/send" "*" +"@types/express@*": + version "4.17.21" + resolved "https://registry.yarnpkg.com/@types/express/-/express-4.17.21.tgz#c26d4a151e60efe0084b23dc3369ebc631ed192d" + integrity sha512-ejlPM315qwLpaQlQDTjPdsUFSc6ZsP4AN6AlWnogPjQ7CVi7PYF3YVz+CY3jE2pwYf7E/7HlDAN0rV2GxTG0HQ== + dependencies: + "@types/body-parser" "*" + "@types/express-serve-static-core" "^4.17.33" + "@types/qs" "*" + "@types/serve-static" "*" + "@types/express@^4.17.17": version "4.17.17" resolved "https://registry.yarnpkg.com/@types/express/-/express-4.17.17.tgz#01d5437f6ef9cfa8668e616e13c2f2ac9a491ae4" @@ -1074,6 +1084,13 @@ resolved "https://registry.yarnpkg.com/@types/mime/-/mime-1.3.2.tgz#93e25bf9ee75fe0fd80b594bc4feb0e862111b5a" integrity sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw== +"@types/multer@^1.4.10": + version "1.4.10" + resolved "https://registry.yarnpkg.com/@types/multer/-/multer-1.4.10.tgz#6bca159aaaf40ec130e99831a08e3d0ed54be611" + integrity sha512-6l9mYMhUe8wbnz/67YIjc7ZJyQNZoKq7fRXVf7nMdgWgalD0KyzJ2ywI7hoATUSXSbTu9q2HBiEwzy0tNN1v2w== + dependencies: + "@types/express" "*" + "@types/node@*", "@types/node@^20.3.1": version "20.5.9" resolved "https://registry.yarnpkg.com/@types/node/-/node-20.5.9.tgz#a70ec9d8fa0180a314c3ede0e20ea56ff71aed9a"