s3 bucket logic for assets

This commit is contained in:
Evert Prants 2023-07-23 11:12:21 +03:00
parent f92af930d7
commit 1b504f4318
Signed by: evert
GPG Key ID: 1688DA83D222D0B5
37 changed files with 1881 additions and 84 deletions

View File

@ -1,4 +1,5 @@
/database /database
/storage
/dist /dist
/node_modules /node_modules
/.env /.env

1
.gitignore vendored
View File

@ -3,6 +3,7 @@
/node_modules /node_modules
/database /database
/private /private
/storage
# Logs # Logs
logs logs

View File

@ -1,6 +1,6 @@
import { Test, TestingModule } from '@nestjs/testing'; import { Test, TestingModule } from '@nestjs/testing';
import { AssetsController } from './assets.controller'; import { AssetsController } from './assets.controller';
import { AssetsService } from './assets.service'; import { AssetsService } from './services/assets.service';
describe('AssetsController', () => { describe('AssetsController', () => {
let assetsController: AssetsController; let assetsController: AssetsController;

View File

@ -1,12 +1,36 @@
import { Controller, Get } from '@nestjs/common'; import { Controller } from '@nestjs/common';
import { AssetsService } from './assets.service'; import { AssetsService } from './services/assets.service';
import { MessagePattern } from '@nestjs/microservices';
import { AssetUploadRequest } from './interfaces/upload-request.interface';
import { UserInfo } from '@freeblox/shared';
@Controller() @Controller()
export class AssetsController { export class AssetsController {
constructor(private readonly assetsService: AssetsService) {} constructor(private readonly assetsService: AssetsService) {}
@Get() @MessagePattern('assets.upload')
getHello(): string { async uploadAsset({
return this.assetsService.getHello(); body,
user,
}: {
body: AssetUploadRequest;
user?: UserInfo;
}) {
return this.assetsService.uploadFile(body, user);
}
@MessagePattern('assets.info.byId')
async assetInfo({ id, user }: { id: string; user?: UserInfo }) {
return this.assetsService.getAssetInfoById(id, user);
}
@MessagePattern('assets.download.byId')
async downloadAsset({ id, user }: { id: string; user?: UserInfo }) {
return this.assetsService.downloadAssetById(id, user);
}
@MessagePattern('assets.delete.byId')
async deleteAsset({ id }: { id: string }) {
return this.assetsService.deleteAsset(id);
} }
} }

View File

@ -1,29 +1,42 @@
import { Module } from '@nestjs/common'; import { Module, OnModuleInit } from '@nestjs/common';
import { AssetsController } from './assets.controller'; import { AssetsController } from './assets.controller';
import { AssetsService } from './assets.service'; import { AssetsService } from './services/assets.service';
import { makeKnex, makeTypeOrm, natsClient } from '@freeblox/shared'; import { makeKnex, makeTypeOrm, natsClient } from '@freeblox/shared';
import { ConfigModule, ConfigService } from '@nestjs/config'; import { ConfigModule, ConfigService } from '@nestjs/config';
import { ClientsModule } from '@nestjs/microservices'; import { ClientsModule } from '@nestjs/microservices';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import knex from 'knex';
import { AssetEntity } from './database/entities/asset.entity';
import { s3Config } from './config/s3.config';
import { S3Service } from './services/s3.service';
const entities = [AssetEntity];
@Module({ @Module({
imports: [ imports: [
ConfigModule.forRoot({ ConfigModule.forRoot({
ignoreEnvFile: process.env.NODE_ENV === 'development', ignoreEnvFile: process.env.NODE_ENV === 'development',
load: [makeKnex('assets', __dirname), makeTypeOrm('assets')], load: [makeKnex('assets', __dirname), makeTypeOrm('assets'), s3Config],
}), }),
TypeOrmModule.forRootAsync({ TypeOrmModule.forRootAsync({
imports: [ConfigModule], imports: [ConfigModule],
inject: [ConfigService], inject: [ConfigService],
useFactory: (config: ConfigService) => config.get('typeorm'), useFactory: (config: ConfigService) => ({
...config.get('typeorm'),
entities,
}), }),
ClientsModule.register([ }),
natsClient('assets'), TypeOrmModule.forFeature(entities),
natsClient('auth'), ClientsModule.register([natsClient('assets'), natsClient('auth')]),
natsClient('player'),
]),
], ],
controllers: [AssetsController], controllers: [AssetsController],
providers: [AssetsService], providers: [S3Service, AssetsService],
}) })
export class AssetsModule {} export class AssetsModule implements OnModuleInit {
constructor(private readonly config: ConfigService) {}
async onModuleInit() {
const knexInstance = knex(this.config.get('knex'));
await knexInstance.migrate.latest();
}
}

View File

@ -1,8 +0,0 @@
import { Injectable } from '@nestjs/common';
@Injectable()
export class AssetsService {
getHello(): string {
return 'Hello World!';
}
}

View File

@ -0,0 +1,8 @@
import { registerAs } from '@nestjs/config';
export const s3Config = registerAs('s3', () => ({
endpoint: process.env.S3_ENDPOINT,
bucket: process.env.S3_BUCKET,
region: process.env.S3_REGION,
forcePathStyle: !!process.env.S3_ENDPOINT,
}));

View File

@ -0,0 +1,56 @@
import { MetaEntity } from '@freeblox/shared';
import { Exclude, Expose } from 'class-transformer';
import {
Column,
DeleteDateColumn,
Entity,
PrimaryGeneratedColumn,
} from 'typeorm';
@Entity('assets')
@Expose()
export class AssetEntity extends MetaEntity {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ nullable: true, name: 'user_id' })
userId: string;
@Column({ nullable: true, name: 'asset_tag' })
assetTag: string;
@Column({ name: 'source_uri' })
@Exclude()
sourceUri: string;
@Column({ nullable: true })
@Exclude()
source: string;
@Column()
@Exclude()
originalname: string;
@Column({ default: 'application/octet-stream' })
mimetype: string;
@Column({ unsigned: true })
filesize: number;
@Column({ default: false })
@Exclude()
public: boolean;
@Column({ nullable: true, name: 'upload_ip' })
@Exclude()
uploadIp: string;
@DeleteDateColumn({ name: 'deleted_at' })
@Exclude()
deletedAt: Date;
@Expose()
get extension() {
return this.originalname.split('.').at(-1);
}
}

View File

@ -0,0 +1,22 @@
import { Knex } from 'knex';
export async function up(knex: Knex): Promise<void> {
return knex.schema.createTable('assets', (table) => {
table.uuid('id').primary().defaultTo(knex.raw('uuid_generate_v4()'));
table.uuid('user_id').nullable();
table.text('asset_tag').nullable();
table.text('source_uri').notNullable();
table.text('source').nullable();
table.text('originalname').notNullable();
table.string('mimetype').notNullable();
table.integer('filesize').unsigned().notNullable();
table.text('upload_ip').nullable();
table.boolean('public').defaultTo(false);
table.timestamps(true, true);
table.timestamp('deleted_at');
});
}
export async function down(knex: Knex): Promise<void> {
return knex.schema.dropTable('assets');
}

View File

@ -0,0 +1,13 @@
export interface AssetUploadRequest {
id?: string;
userId?: string;
assetTag?: string;
buffer: Buffer;
originalname: string;
mimetype: string;
filesize: number;
public?: boolean;
uploadIp?: string;
}

View File

@ -0,0 +1,5 @@
import { getKnex } from '../../../libs/shared/src/';
module.exports = {
development: getKnex('assets', __dirname, ['.ts', '.js']),
};

View File

@ -1,6 +1,7 @@
import { NestFactory } from '@nestjs/core'; import { NestFactory } from '@nestjs/core';
import { AssetsModule } from './assets.module'; import { AssetsModule } from './assets.module';
import { MicroserviceOptions, Transport } from '@nestjs/microservices'; import { MicroserviceOptions, Transport } from '@nestjs/microservices';
import { HttpRpcExceptionFilter } from '@freeblox/shared';
async function bootstrap() { async function bootstrap() {
const app = await NestFactory.createMicroservice<MicroserviceOptions>( const app = await NestFactory.createMicroservice<MicroserviceOptions>(
@ -13,6 +14,7 @@ async function bootstrap() {
}, },
); );
app.useGlobalFilters(new HttpRpcExceptionFilter());
await app.listen(); await app.listen();
} }
bootstrap(); bootstrap();

View File

@ -0,0 +1,137 @@
import { Injectable } from '@nestjs/common';
import { S3Service } from './s3.service';
import { AssetUploadRequest } from '../interfaces/upload-request.interface';
import {
NotFoundRpcException,
UnauthorizedRpcException,
UserInfo,
} from '@freeblox/shared';
import { InjectEntityManager } from '@nestjs/typeorm';
import { EntityManager } from 'typeorm';
import { AssetEntity } from '../database/entities/asset.entity';
import { ConfigService } from '@nestjs/config';
import { instanceToPlain } from 'class-transformer';
@Injectable()
export class AssetsService {
constructor(
@InjectEntityManager() private manager: EntityManager,
private readonly s3Service: S3Service,
private readonly config: ConfigService,
) {}
/**
* Upload a new asset.
* @param body Upload info
* @param user User
* @returns Uploaded asset
*/
async uploadFile(body: AssetUploadRequest, user?: UserInfo) {
const file = Buffer.from(body.buffer as any, 'base64');
const uploaded = this.manager.transaction(async (manager) => {
const asset = manager.create(AssetEntity, {
id: body.id,
userId: body.userId || user?.sub,
assetTag: body.assetTag,
sourceUri: 'fblxassetid://stub',
uploadIp: body.uploadIp,
public: body.public,
originalname: body.originalname,
mimetype: body.mimetype,
filesize: body.filesize,
});
// Create ID if not provided
await manager.save(AssetEntity, asset);
// Upload to S3
const key = `fblxassetid-${asset.id}`;
const bucket = this.config.get('s3.bucket');
await this.s3Service.uploadFile(key, file, bucket);
// Save S3 keys
asset.source = bucket;
asset.sourceUri = key;
return manager.save(AssetEntity, asset);
});
return instanceToPlain(uploaded);
}
/**
* Get asset info by ID.
* @param id Asset ID
* @param user (optional) User
* @returns Asset info
*/
async getAssetInfoById(id: string, user?: UserInfo) {
const asset = await this.manager.findOne(AssetEntity, {
where: {
deletedAt: null,
id: id,
},
});
if (!asset) {
throw new NotFoundRpcException('Asset not found');
}
if (!user && !asset.public) {
throw new UnauthorizedRpcException('Unauthorized');
}
return instanceToPlain(asset);
}
/**
* Download asset by ID.
* @param id Asset ID
* @param user (optional) User
* @returns Buffer
*/
async downloadAssetById(id: string, user?: UserInfo) {
const asset = await this.manager.findOne(AssetEntity, {
where: {
deletedAt: null,
id: id,
},
});
if (!asset) {
throw new NotFoundRpcException('Asset not found');
}
if (!user && !asset.public) {
throw new UnauthorizedRpcException('Unauthorized');
}
const url = await this.s3Service.getFileUrl(asset.sourceUri, asset.source);
return {
url,
mimetype: asset.mimetype,
filename: `${asset.id}.${asset.extension}`,
};
}
/**
* Delete asset.
* @param id Asset ID
*/
async deleteAsset(id: string) {
const asset = await this.manager.findOne(AssetEntity, {
where: {
deletedAt: null,
id: id,
},
});
if (!asset) {
throw new NotFoundRpcException('Asset not found');
}
await this.manager.softRemove(AssetEntity, asset);
try {
await this.s3Service.deleteFile(asset.sourceUri, asset.source);
} catch {}
}
}

View File

@ -0,0 +1,78 @@
import { Injectable, OnModuleInit } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import {
DeleteObjectCommand,
PutObjectCommand,
S3Client,
} from '@aws-sdk/client-s3';
import { BadRequestRpcException } from '@freeblox/shared';
import { HttpRequest } from '@aws-sdk/protocol-http';
import { S3RequestPresigner } from '@aws-sdk/s3-request-presigner';
import { parseUrl } from '@aws-sdk/url-parser';
import { formatUrl } from '@aws-sdk/util-format-url';
import { Hash } from '@aws-sdk/hash-node';
import { fromEnv } from '@aws-sdk/credential-providers';
@Injectable()
export class S3Service implements OnModuleInit {
private s3: S3Client;
private presigner: S3RequestPresigner;
constructor(private config: ConfigService) {}
onModuleInit() {
const s3Config = this.config.get('s3');
this.s3 = new S3Client({
...s3Config,
});
this.presigner = new S3RequestPresigner({
credentials: fromEnv(),
region: s3Config.region,
sha256: Hash.bind(null, 'sha256'),
});
}
async uploadFile(key: string, data: Buffer, bucket?: string) {
try {
const command = new PutObjectCommand({
Key: key,
Bucket: bucket || this.config.get('s3.bucket'),
Body: data,
});
await this.s3.send(command);
} catch (error) {
throw new BadRequestRpcException(`Upload failed: ${error.message}`);
}
}
async getFileUrl(key: string, bucket?: string) {
const s3Config = this.config.get('s3');
bucket = bucket || s3Config.bucket;
let url: ReturnType<typeof parseUrl>;
if (s3Config.endpoint) {
url = parseUrl(`${s3Config.endpoint}/${bucket}/${key}`);
} else {
url = parseUrl(
`https://${bucket}.s3${
s3Config.region ? '-' + s3Config.region : ''
}.amazonaws.com/${key}`,
);
}
const signedUrlObject = await this.presigner.presign(new HttpRequest(url));
return formatUrl(signedUrlObject);
}
async deleteFile(key: string, bucket?: string) {
try {
const command = new DeleteObjectCommand({
Key: key,
Bucket: bucket || this.config.get('s3.bucket'),
});
await this.s3.send(command);
} catch {
throw new BadRequestRpcException('Delete failed');
}
}
}

View File

@ -96,6 +96,26 @@ export class CatalogController {
); );
} }
@MessagePattern('catalog.items.appendToRevision')
async appendToRevision({
id,
body,
user,
files,
}: {
id: number;
body: CreateContentRevisionRequest;
user: UserInfo;
files?: Express.Multer.File[];
}) {
return this.createContentService.addFilesToLatestRevision(
id,
body.files,
files,
user,
);
}
@MessagePattern('catalog.items.update') @MessagePattern('catalog.items.update')
async updateItem({ async updateItem({
id, id,

View File

@ -60,6 +60,7 @@ const entities = [
natsClient('catalog'), natsClient('catalog'),
natsClient('auth'), natsClient('auth'),
natsClient('player'), natsClient('player'),
natsClient('assets'),
]), ]),
], ],
controllers: [CatalogController], controllers: [CatalogController],

View File

@ -9,6 +9,7 @@ import {
import { ContentAssetType } from '../../enums/content-asset-type.enum'; import { ContentAssetType } from '../../enums/content-asset-type.enum';
import { ContentRevisionEntity } from './content-revision.entity'; import { ContentRevisionEntity } from './content-revision.entity';
import { ContentEntity } from './content.entity'; import { ContentEntity } from './content.entity';
import { AssetSource } from '../../enums/asset-source.enum';
@Entity('content_asset') @Entity('content_asset')
@Expose() @Expose()
@ -31,6 +32,9 @@ export class ContentAssetEntity {
@Column({ name: 'type_name', nullable: true }) @Column({ name: 'type_name', nullable: true })
typeName?: string; typeName?: string;
@Column({ type: 'enum', enum: AssetSource, default: AssetSource.USER })
source: AssetSource;
@Column({ default: 0 }) @Column({ default: 0 })
index: number; index: number;

View File

@ -10,6 +10,7 @@ export async function up(knex: Knex): Promise<void> {
table.string('type').notNullable().index(); table.string('type').notNullable().index();
table.string('type_name').nullable(); table.string('type_name').nullable();
table.enum('source', ['user', 'generated']).notNullable().defaultTo('user');
table.integer('index').notNullable().defaultTo(0); table.integer('index').notNullable().defaultTo(0);
table.foreign('content_id').references('content.id').onDelete('CASCADE'); table.foreign('content_id').references('content.id').onDelete('CASCADE');

View File

@ -0,0 +1,4 @@
export enum AssetSource {
USER = 'user',
GENERATED = 'generated',
}

View File

@ -1,3 +1,4 @@
import { AssetSource } from '../enums/asset-source.enum';
import { ContentAssetType } from '../enums/content-asset-type.enum'; import { ContentAssetType } from '../enums/content-asset-type.enum';
import { ContentType } from '../enums/content-type.enum'; import { ContentType } from '../enums/content-type.enum';
import { Currency } from '../enums/currency.enum'; import { Currency } from '../enums/currency.enum';
@ -6,6 +7,7 @@ import { Privacy } from '../enums/privacy.enum';
export interface ContentAssetTypeProperties { export interface ContentAssetTypeProperties {
type: ContentAssetType; type: ContentAssetType;
typeName?: string; typeName?: string;
source?: AssetSource;
} }
export interface CreateContentFiles { export interface CreateContentFiles {

View File

@ -36,14 +36,15 @@ import { ModeratorAction } from '../enums/moderation-action.enum';
import { instanceToPlain } from 'class-transformer'; import { instanceToPlain } from 'class-transformer';
import { ContentAssetEntity } from '../database/entities/content-asset.entity'; import { ContentAssetEntity } from '../database/entities/content-asset.entity';
import { ContentAssetType } from '../enums/content-asset-type.enum'; import { ContentAssetType } from '../enums/content-asset-type.enum';
import { randomUUID } from 'crypto';
import { lastValueFrom } from 'rxjs'; import { lastValueFrom } from 'rxjs';
import { AssetSource } from '../enums/asset-source.enum';
@Injectable() @Injectable()
export class CreateContentService { export class CreateContentService {
constructor( constructor(
@InjectEntityManager() private manager: EntityManager, @InjectEntityManager() private manager: EntityManager,
@Inject('auth') private authClient: ClientProxy, @Inject('auth') private authClient: ClientProxy,
@Inject('assets') private assetsClient: ClientProxy,
@Inject('catalog') private client: ClientProxy, @Inject('catalog') private client: ClientProxy,
) {} ) {}
@ -300,7 +301,14 @@ export class CreateContentService {
}); });
// Save assets // Save assets
await this.uploadAssets(content, revision, body.files, files, manager); await this.uploadAssets(
content,
revision,
body.files,
files,
manager,
user,
);
// Moderator review // Moderator review
await this.createPendingReview(content, revision, manager); await this.createPendingReview(content, revision, manager);
@ -311,6 +319,45 @@ export class CreateContentService {
return instanceToPlain(newRevision); return instanceToPlain(newRevision);
} }
/**
* Add files to current revision, usually generated thumnails / icons.
* @param contentId Content ID
* @param types File types
* @param files Files
* @param user User
* @returns Current revision
*/
async addFilesToLatestRevision(
contentId: number,
types: CreateContentFiles = {},
files: Express.Multer.File[] = [],
user?: UserInfo,
) {
const latestRevision = await this.manager.findOne(ContentRevisionEntity, {
where: {
content: { id: contentId },
},
order: { createdAt: 'DESC' },
relations: ['assets', 'content'],
});
if (!latestRevision || !!latestRevision.deletedAt) {
throw new BadRequestRpcException('No valid revision exists');
}
// Save assets
await this.uploadAssets(
latestRevision.content,
latestRevision,
types,
files,
undefined,
user,
);
return instanceToPlain(latestRevision);
}
/** /**
* Create a pending moderator review for content revision. * Create a pending moderator review for content revision.
* @private * @private
@ -343,6 +390,7 @@ export class CreateContentService {
types: CreateContentFiles = {}, types: CreateContentFiles = {},
files: Express.Multer.File[] = [], files: Express.Multer.File[] = [],
manager = this.manager, manager = this.manager,
user?: UserInfo,
) { ) {
const typeKeys = Object.keys(types); const typeKeys = Object.keys(types);
if (!typeKeys.length) return []; if (!typeKeys.length) return [];
@ -359,6 +407,12 @@ export class CreateContentService {
); );
} }
if (!!user && fileType.source === AssetSource.GENERATED) {
throw new BadRequestRpcException(
`File ${fileKey} cannot be source of generated - Users cannot upload generated assets`,
);
}
const allowed = await this.checkAssetType( const allowed = await this.checkAssetType(
content.type, content.type,
fileType, fileType,
@ -376,17 +430,33 @@ export class CreateContentService {
const asset = manager.create(ContentAssetEntity, { const asset = manager.create(ContentAssetEntity, {
content, content,
revision, revision,
assetId: randomUUID(),
type: fileType.type, type: fileType.type,
typeName: fileType.typeName, typeName: fileType.typeName,
source: fileType.source,
index: fileIndex++, index: fileIndex++,
}); });
const assetErrors = await validate(asset); const assetErrors = await validate(asset);
if (assetErrors?.length) new ValidationRpcException(); if (assetErrors?.length) new ValidationRpcException();
// TODO: actually upload the files somewhere
// TODO: convert file types into universal formats // TODO: convert file types into universal formats
const assetObject = await lastValueFrom(
this.assetsClient.send('assets.upload', {
body: {
id: asset.assetId,
userId: user?.sub,
assetTag: content.id,
originalname: uploadedFile.originalname,
mimetype: uploadedFile.mimetype,
filesize: uploadedFile.size,
buffer: Buffer.from(uploadedFile.buffer).toString('base64'),
public: true,
},
user,
}),
);
asset.assetId = assetObject.id;
await manager.save(ContentAssetEntity, asset); await manager.save(ContentAssetEntity, asset);
createdAssets.push(asset); createdAssets.push(asset);

View File

@ -1,26 +1,25 @@
import { Module } from '@nestjs/common'; import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
import { AppController } from './app.controller'; import { AppController } from './app.controller';
import { AppService } from './app.service'; import { AppService } from './app.service';
import { natsClient } from '@freeblox/shared'; import { natsClient } from '@freeblox/shared';
import { ClientsModule } from '@nestjs/microservices'; import { ClientsModule } from '@nestjs/microservices';
import { AuthModule } from './services/auth/auth.module'; import { AuthModule } from './services/auth/auth.module';
import { CatalogModule } from './services/catalog/catalog.module'; import { CatalogModule } from './services/catalog/catalog.module';
import { UserMiddleware } from './middleware/user.middleware';
import { AssetsModule } from './services/assets/assets.module';
@Module({ @Module({
imports: [ imports: [
ClientsModule.register([ ClientsModule.register([natsClient('auth')]),
natsClient('auth'),
natsClient('catalog'),
natsClient('game'),
natsClient('session'),
natsClient('player'),
natsClient('server'),
natsClient('session'),
]),
AuthModule, AuthModule,
CatalogModule, CatalogModule,
AssetsModule,
], ],
controllers: [AppController], controllers: [AppController],
providers: [AppService], providers: [AppService],
}) })
export class AppModule {} export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer.apply(UserMiddleware).forRoutes('*');
}
}

View File

@ -0,0 +1,4 @@
import { SetMetadata } from '@nestjs/common';
export const Privilege = (...privileges: string[]) =>
SetMetadata('privileges', privileges);

View File

@ -0,0 +1,14 @@
import { UseGuards, applyDecorators } from '@nestjs/common';
import { Privilege } from './privilege.decorator';
import { PrivilegesGuard } from '../guards/privileges.guard';
import { ApiBearerAuth, ApiForbiddenResponse } from '@nestjs/swagger';
export const RequirePrivileges = (...privileges: string[]) =>
applyDecorators(
Privilege(...privileges),
UseGuards(PrivilegesGuard),
ApiBearerAuth(),
ApiForbiddenResponse({
description: `Privileges required: ${privileges.join(' or ')}`,
}),
);

View File

@ -1,34 +1,10 @@
import { import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
CanActivate, import { Response } from 'express';
ExecutionContext,
HttpException,
HttpStatus,
Inject,
Injectable,
} from '@nestjs/common';
import { ClientProxy } from '@nestjs/microservices';
import { Request, Response } from 'express';
import { lastValueFrom } from 'rxjs';
@Injectable() @Injectable()
export class AuthGuard implements CanActivate { export class AuthGuard implements CanActivate {
constructor(@Inject('auth') private authClient: ClientProxy) {}
async canActivate(context: ExecutionContext): Promise<boolean> { async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest() as Request;
const response = context.switchToHttp().getResponse() as Response; const response = context.switchToHttp().getResponse() as Response;
if (!request.headers.authorization) return false; return !!response.locals.user;
// Verify token by auth microservice
const [, token] = request.headers.authorization.split(' ');
const user = await lastValueFrom(
this.authClient.send('auth.verify', { token }),
).catch((err) => {
throw new HttpException(err.response, err.status || HttpStatus.FORBIDDEN);
});
// Add token contents to locals
response.locals.user = user;
return true;
} }
} }

View File

@ -0,0 +1,25 @@
import { UserInfo, matchPrivileges } from '@freeblox/shared';
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Response } from 'express';
@Injectable()
export class PrivilegesGuard implements CanActivate {
constructor(private reflector: Reflector) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const privileges = this.reflector.get<string[]>(
'privileges',
context.getHandler(),
);
if (!privileges) {
return true;
}
const response = context.switchToHttp().getResponse() as Response;
const user = response.locals.user as UserInfo;
if (!user) return false;
return matchPrivileges(privileges, user.privileges || []);
}
}

View File

@ -0,0 +1,34 @@
import {
HttpException,
HttpStatus,
Inject,
Injectable,
NestMiddleware,
} from '@nestjs/common';
import { ClientProxy } from '@nestjs/microservices';
import { Request, Response, NextFunction } from 'express';
import { lastValueFrom } from 'rxjs';
@Injectable()
export class UserMiddleware implements NestMiddleware {
constructor(@Inject('auth') private authClient: ClientProxy) {}
async use(req: Request, res: Response, next: NextFunction) {
if (!req.headers.authorization) return next();
// Verify token by auth microservice
const [, token] = req.headers.authorization.split(' ');
const user = await lastValueFrom(
this.authClient.send('auth.verify', { token }),
).catch((err) => {
throw new HttpException(
err.response,
Number(err.status) || HttpStatus.FORBIDDEN,
);
});
// Add token contents to locals
res.locals.user = user;
next();
}
}

View File

@ -0,0 +1,61 @@
import {
Controller,
UseInterceptors,
ClassSerializerInterceptor,
Inject,
Get,
Param,
ParseUUIDPipe,
Res,
} from '@nestjs/common';
import { ClientProxy } from '@nestjs/microservices';
import { ApiTags, ApiOperation } from '@nestjs/swagger';
import { User } from '../../decorators/user.decorator';
import { UserInfo } from '@freeblox/shared';
import { lastValueFrom } from 'rxjs';
import type { Response } from 'express';
import { HttpService } from '@nestjs/axios';
@Controller({
version: '1',
path: 'assets',
})
@ApiTags('Assets')
@UseInterceptors(ClassSerializerInterceptor)
export class AssetsController {
constructor(
@Inject('assets') private assets: ClientProxy,
private http: HttpService,
) {}
@Get(':assetId')
@ApiOperation({ summary: 'Get asset info' })
async getAsset(
@Param('assetId', new ParseUUIDPipe()) assetId: string,
@User() user?: UserInfo,
) {
return this.assets.send('assets.info.byId', { id: assetId, user });
}
@Get(':assetId/download')
@ApiOperation({ summary: 'Download asset' })
async downloadAsset(
@Param('assetId', new ParseUUIDPipe()) assetId: string,
@Res() res: Response,
@User() user?: UserInfo,
) {
const download = await lastValueFrom(
this.assets.send('assets.download.byId', { id: assetId, user }),
);
const { data } = await lastValueFrom(
this.http.get(download.url, { responseType: 'stream' }),
);
res.set({
'Content-Type': download.mimetype,
'Content-Disposition': `attachment; filename="${download.filename}"`,
});
data.pipe(res);
}
}

View File

@ -0,0 +1,14 @@
import { natsClient } from '@freeblox/shared';
import { Module } from '@nestjs/common';
import { ClientsModule } from '@nestjs/microservices';
import { AssetsController } from './assets.controller';
import { HttpModule } from '@nestjs/axios';
@Module({
imports: [
ClientsModule.register([natsClient('assets'), natsClient('auth')]),
HttpModule,
],
controllers: [AssetsController],
})
export class AssetsModule {}

View File

@ -7,34 +7,28 @@ import {
Post, Post,
Body, Body,
UploadedFiles, UploadedFiles,
UseGuards,
Patch, Patch,
Param, Param,
ParseIntPipe, ParseIntPipe,
} from '@nestjs/common'; } from '@nestjs/common';
import { ClientProxy } from '@nestjs/microservices'; import { ClientProxy } from '@nestjs/microservices';
import { import { ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger';
ApiBearerAuth,
ApiOkResponse,
ApiOperation,
ApiTags,
} from '@nestjs/swagger';
import { User } from '../../decorators/user.decorator'; import { User } from '../../decorators/user.decorator';
import { UserInfo } from '@freeblox/shared'; import { UserInfo } from '@freeblox/shared';
import { CreateContentDto } from './dtos/create-content.dto'; import { CreateContentDto } from './dtos/create-content.dto';
import { AnyFilesInterceptor } from '@nestjs/platform-express'; import { AnyFilesInterceptor } from '@nestjs/platform-express';
import { AuthGuard } from '../../guards/auth.guard';
import { CreateContentRevisionDto } from './dtos/create-content-revision.dto'; import { CreateContentRevisionDto } from './dtos/create-content-revision.dto';
import { ContentResponseDto } from './dtos/content-response.dto'; import { ContentResponseDto } from './dtos/content-response.dto';
import { ContentAssetType } from 'apps/catalog/src/enums/content-asset-type.enum'; import { ContentAssetType } from 'apps/catalog/src/enums/content-asset-type.enum';
import { lastValueFrom } from 'rxjs'; import { lastValueFrom } from 'rxjs';
import { CategoryResponseDto } from './dtos/category-response.dto'; import { CategoryResponseDto } from './dtos/category-response.dto';
import { ContentAssetDto } from './dtos/content-asset.dto';
import { RequirePrivileges } from '../../decorators/require-privileges.decorator';
@Controller({ @Controller({
version: '1', version: '1',
path: 'catalog', path: 'catalog',
}) })
@ApiBearerAuth()
@ApiTags('Catalog') @ApiTags('Catalog')
@UseInterceptors(ClassSerializerInterceptor) @UseInterceptors(ClassSerializerInterceptor)
export class CatalogController { export class CatalogController {
@ -50,7 +44,7 @@ export class CatalogController {
@Post('content') @Post('content')
@ApiOperation({ summary: 'Create new content' }) @ApiOperation({ summary: 'Create new content' })
@ApiOkResponse({ type: ContentResponseDto }) @ApiOkResponse({ type: ContentResponseDto })
@UseGuards(AuthGuard) @RequirePrivileges('create:*', 'contentedit')
@UseInterceptors(AnyFilesInterceptor()) @UseInterceptors(AnyFilesInterceptor())
async createContent( async createContent(
@User() user: UserInfo, @User() user: UserInfo,
@ -63,7 +57,6 @@ export class CatalogController {
@Get('content/:id') @Get('content/:id')
@ApiOperation({ summary: 'Get content details' }) @ApiOperation({ summary: 'Get content details' })
@ApiOkResponse({ type: ContentResponseDto }) @ApiOkResponse({ type: ContentResponseDto })
@UseGuards(AuthGuard)
async getContent( async getContent(
@Param('id', new ParseIntPipe()) id: number, @Param('id', new ParseIntPipe()) id: number,
@User() user: UserInfo, @User() user: UserInfo,
@ -73,8 +66,7 @@ export class CatalogController {
@Get('content/:id/thumbnail') @Get('content/:id/thumbnail')
@ApiOperation({ summary: 'Get content thumbnail ID' }) @ApiOperation({ summary: 'Get content thumbnail ID' })
@ApiOkResponse({ type: ContentResponseDto }) @ApiOkResponse({ type: ContentAssetDto })
@UseGuards(AuthGuard)
async getContentThumbnail( async getContentThumbnail(
@Param('id', new ParseIntPipe()) id: number, @Param('id', new ParseIntPipe()) id: number,
@User() user: UserInfo, @User() user: UserInfo,
@ -93,7 +85,7 @@ export class CatalogController {
@Patch('content/:id') @Patch('content/:id')
@ApiOperation({ summary: 'Update content details' }) @ApiOperation({ summary: 'Update content details' })
@ApiOkResponse({ type: ContentResponseDto }) @ApiOkResponse({ type: ContentResponseDto })
@UseGuards(AuthGuard) @RequirePrivileges('create:*', 'contentedit')
async updateContent( async updateContent(
@Param('id', new ParseIntPipe()) id: number, @Param('id', new ParseIntPipe()) id: number,
@User() user: UserInfo, @User() user: UserInfo,
@ -106,7 +98,7 @@ export class CatalogController {
@ApiOperation({ @ApiOperation({
summary: 'Create a new revision (upload new content for item)', summary: 'Create a new revision (upload new content for item)',
}) })
@UseGuards(AuthGuard) @RequirePrivileges('create:*', 'contentedit')
@UseInterceptors(AnyFilesInterceptor()) @UseInterceptors(AnyFilesInterceptor())
async createContentRevision( async createContentRevision(
@Param('id', new ParseIntPipe()) id: number, @Param('id', new ParseIntPipe()) id: number,
@ -127,7 +119,7 @@ export class CatalogController {
@ApiOperation({ @ApiOperation({
summary: 'Publish content', summary: 'Publish content',
}) })
@UseGuards(AuthGuard) @RequirePrivileges('create:*', 'contentedit')
async publishContent( async publishContent(
@Param('id', new ParseIntPipe()) id: number, @Param('id', new ParseIntPipe()) id: number,
@User() user: UserInfo, @User() user: UserInfo,

View File

@ -0,0 +1,6 @@
import { ApiProperty } from '@nestjs/swagger';
export class ContentAssetDto {
@ApiProperty()
assetId: string;
}

View File

@ -1,6 +1,7 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { UserResponseDto } from './user-response.dto'; import { UserResponseDto } from './user-response.dto';
import { Currency } from 'apps/catalog/src/enums/currency.enum'; import { Currency } from 'apps/catalog/src/enums/currency.enum';
import { ContentAssetDto } from './content-asset.dto';
export class ContentPriceResponseDto { export class ContentPriceResponseDto {
@ApiProperty() @ApiProperty()
@ -64,4 +65,7 @@ export class ContentResponseDto {
@ApiProperty({ type: UserResponseDto }) @ApiProperty({ type: UserResponseDto })
user: UserResponseDto; user: UserResponseDto;
@ApiProperty({ type: ContentAssetDto, isArray: true })
assets: ContentAssetDto[];
} }

View File

@ -158,6 +158,11 @@ services:
- POSTGRES_HOST=postgres - POSTGRES_HOST=postgres
- POSTGRES_USER=freeblox - POSTGRES_USER=freeblox
- POSTGRES_PASSWORD=FREEBLOXDataBaseDEV@123 - POSTGRES_PASSWORD=FREEBLOXDataBaseDEV@123
- S3_ENDPOINT=http://minio:9000
- S3_BUCKET=freeblox-assets
- S3_REGION=eu-central-1
- AWS_ACCESS_KEY_ID=freeblox@freeblox.gg
- AWS_SECRET_ACCESS_KEY=password
volumes: volumes:
- ./apps:/usr/src/app/apps - ./apps:/usr/src/app/apps
- ./libs:/usr/src/app/libs - ./libs:/usr/src/app/libs
@ -189,6 +194,20 @@ services:
environment: environment:
- PGADMIN_DEFAULT_EMAIL=freeblox@freeblox.gg - PGADMIN_DEFAULT_EMAIL=freeblox@freeblox.gg
- PGADMIN_DEFAULT_PASSWORD=password - PGADMIN_DEFAULT_PASSWORD=password
minio:
container_name: fblx-minio
image: minio/minio
ports:
- '9000:9000'
- '9001:9001'
networks:
- fblx
environment:
- MINIO_ROOT_USER=freeblox@freeblox.gg
- MINIO_ROOT_PASSWORD=password
volumes:
- ./storage:/data
command: server --console-address ":9001" /data
networks: networks:
fblx: fblx:
volumes: volumes:

View File

@ -3,6 +3,7 @@ export * from './shared.service';
export * from './utils/nats-client'; export * from './utils/nats-client';
export * from './utils/tokens'; export * from './utils/tokens';
export * from './utils/parse-boolean'; export * from './utils/parse-boolean';
export * from './utils/match-privileges';
export * from './database/make-typeorm'; export * from './database/make-typeorm';
export * from './database/make-knex'; export * from './database/make-knex';
export * from './database/metaentity'; export * from './database/metaentity';

View File

@ -0,0 +1,21 @@
export const matchPrivileges = (required: string[], privileges: string[]) => {
let someAvailable = false;
for (const want of required) {
if (someAvailable) break;
if (privileges.includes(want)) {
someAvailable = true;
continue;
}
if (want.endsWith('*')) {
const asteriskLess = want.replace('*', '');
if (privileges.some((item) => item.startsWith(asteriskLess))) {
someAvailable = true;
continue;
}
}
}
return someAvailable;
};

View File

@ -21,6 +21,13 @@
"test:e2e": "jest --config ./apps/freeblox-web-service/test/jest-e2e.json" "test:e2e": "jest --config ./apps/freeblox-web-service/test/jest-e2e.json"
}, },
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.374.0",
"@aws-sdk/credential-providers": "^3.370.0",
"@aws-sdk/hash-node": "^3.374.0",
"@aws-sdk/protocol-http": "^3.374.0",
"@aws-sdk/s3-request-presigner": "^3.375.0",
"@aws-sdk/url-parser": "^3.374.0",
"@aws-sdk/util-format-url": "^3.370.0",
"@nestjs/axios": "^3.0.0", "@nestjs/axios": "^3.0.0",
"@nestjs/common": "^10.0.3", "@nestjs/common": "^10.0.3",
"@nestjs/config": "^3.0.0", "@nestjs/config": "^3.0.0",

File diff suppressed because it is too large Load Diff