s3 bucket logic for assets
This commit is contained in:
parent
f92af930d7
commit
1b504f4318
@ -1,4 +1,5 @@
|
||||
/database
|
||||
/storage
|
||||
/dist
|
||||
/node_modules
|
||||
/.env
|
||||
|
1
.gitignore
vendored
1
.gitignore
vendored
@ -3,6 +3,7 @@
|
||||
/node_modules
|
||||
/database
|
||||
/private
|
||||
/storage
|
||||
|
||||
# Logs
|
||||
logs
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { AssetsController } from './assets.controller';
|
||||
import { AssetsService } from './assets.service';
|
||||
import { AssetsService } from './services/assets.service';
|
||||
|
||||
describe('AssetsController', () => {
|
||||
let assetsController: AssetsController;
|
||||
|
@ -1,12 +1,36 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { AssetsService } from './assets.service';
|
||||
import { Controller } from '@nestjs/common';
|
||||
import { AssetsService } from './services/assets.service';
|
||||
import { MessagePattern } from '@nestjs/microservices';
|
||||
import { AssetUploadRequest } from './interfaces/upload-request.interface';
|
||||
import { UserInfo } from '@freeblox/shared';
|
||||
|
||||
@Controller()
|
||||
export class AssetsController {
|
||||
constructor(private readonly assetsService: AssetsService) {}
|
||||
|
||||
@Get()
|
||||
getHello(): string {
|
||||
return this.assetsService.getHello();
|
||||
@MessagePattern('assets.upload')
|
||||
async uploadAsset({
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
@ -1,29 +1,42 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { Module, OnModuleInit } from '@nestjs/common';
|
||||
import { AssetsController } from './assets.controller';
|
||||
import { AssetsService } from './assets.service';
|
||||
import { AssetsService } from './services/assets.service';
|
||||
import { makeKnex, makeTypeOrm, natsClient } from '@freeblox/shared';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { ClientsModule } from '@nestjs/microservices';
|
||||
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({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
ignoreEnvFile: process.env.NODE_ENV === 'development',
|
||||
load: [makeKnex('assets', __dirname), makeTypeOrm('assets')],
|
||||
load: [makeKnex('assets', __dirname), makeTypeOrm('assets'), s3Config],
|
||||
}),
|
||||
TypeOrmModule.forRootAsync({
|
||||
imports: [ConfigModule],
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService) => config.get('typeorm'),
|
||||
useFactory: (config: ConfigService) => ({
|
||||
...config.get('typeorm'),
|
||||
entities,
|
||||
}),
|
||||
ClientsModule.register([
|
||||
natsClient('assets'),
|
||||
natsClient('auth'),
|
||||
natsClient('player'),
|
||||
]),
|
||||
}),
|
||||
TypeOrmModule.forFeature(entities),
|
||||
ClientsModule.register([natsClient('assets'), natsClient('auth')]),
|
||||
],
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
@ -1,8 +0,0 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
export class AssetsService {
|
||||
getHello(): string {
|
||||
return 'Hello World!';
|
||||
}
|
||||
}
|
8
apps/assets/src/config/s3.config.ts
Normal file
8
apps/assets/src/config/s3.config.ts
Normal 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,
|
||||
}));
|
56
apps/assets/src/database/entities/asset.entity.ts
Normal file
56
apps/assets/src/database/entities/asset.entity.ts
Normal 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);
|
||||
}
|
||||
}
|
22
apps/assets/src/database/migrations/20230722190600_assets.ts
Normal file
22
apps/assets/src/database/migrations/20230722190600_assets.ts
Normal 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');
|
||||
}
|
13
apps/assets/src/interfaces/upload-request.interface.ts
Normal file
13
apps/assets/src/interfaces/upload-request.interface.ts
Normal 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;
|
||||
}
|
5
apps/assets/src/knexfile.ts
Normal file
5
apps/assets/src/knexfile.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { getKnex } from '../../../libs/shared/src/';
|
||||
|
||||
module.exports = {
|
||||
development: getKnex('assets', __dirname, ['.ts', '.js']),
|
||||
};
|
@ -1,6 +1,7 @@
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { AssetsModule } from './assets.module';
|
||||
import { MicroserviceOptions, Transport } from '@nestjs/microservices';
|
||||
import { HttpRpcExceptionFilter } from '@freeblox/shared';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.createMicroservice<MicroserviceOptions>(
|
||||
@ -13,6 +14,7 @@ async function bootstrap() {
|
||||
},
|
||||
);
|
||||
|
||||
app.useGlobalFilters(new HttpRpcExceptionFilter());
|
||||
await app.listen();
|
||||
}
|
||||
bootstrap();
|
||||
|
137
apps/assets/src/services/assets.service.ts
Normal file
137
apps/assets/src/services/assets.service.ts
Normal 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 {}
|
||||
}
|
||||
}
|
78
apps/assets/src/services/s3.service.ts
Normal file
78
apps/assets/src/services/s3.service.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
@ -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')
|
||||
async updateItem({
|
||||
id,
|
||||
|
@ -60,6 +60,7 @@ const entities = [
|
||||
natsClient('catalog'),
|
||||
natsClient('auth'),
|
||||
natsClient('player'),
|
||||
natsClient('assets'),
|
||||
]),
|
||||
],
|
||||
controllers: [CatalogController],
|
||||
|
@ -9,6 +9,7 @@ import {
|
||||
import { ContentAssetType } from '../../enums/content-asset-type.enum';
|
||||
import { ContentRevisionEntity } from './content-revision.entity';
|
||||
import { ContentEntity } from './content.entity';
|
||||
import { AssetSource } from '../../enums/asset-source.enum';
|
||||
|
||||
@Entity('content_asset')
|
||||
@Expose()
|
||||
@ -31,6 +32,9 @@ export class ContentAssetEntity {
|
||||
@Column({ name: 'type_name', nullable: true })
|
||||
typeName?: string;
|
||||
|
||||
@Column({ type: 'enum', enum: AssetSource, default: AssetSource.USER })
|
||||
source: AssetSource;
|
||||
|
||||
@Column({ default: 0 })
|
||||
index: number;
|
||||
|
||||
|
@ -10,6 +10,7 @@ export async function up(knex: Knex): Promise<void> {
|
||||
|
||||
table.string('type').notNullable().index();
|
||||
table.string('type_name').nullable();
|
||||
table.enum('source', ['user', 'generated']).notNullable().defaultTo('user');
|
||||
table.integer('index').notNullable().defaultTo(0);
|
||||
|
||||
table.foreign('content_id').references('content.id').onDelete('CASCADE');
|
||||
|
4
apps/catalog/src/enums/asset-source.enum.ts
Normal file
4
apps/catalog/src/enums/asset-source.enum.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export enum AssetSource {
|
||||
USER = 'user',
|
||||
GENERATED = 'generated',
|
||||
}
|
@ -1,3 +1,4 @@
|
||||
import { AssetSource } from '../enums/asset-source.enum';
|
||||
import { ContentAssetType } from '../enums/content-asset-type.enum';
|
||||
import { ContentType } from '../enums/content-type.enum';
|
||||
import { Currency } from '../enums/currency.enum';
|
||||
@ -6,6 +7,7 @@ import { Privacy } from '../enums/privacy.enum';
|
||||
export interface ContentAssetTypeProperties {
|
||||
type: ContentAssetType;
|
||||
typeName?: string;
|
||||
source?: AssetSource;
|
||||
}
|
||||
|
||||
export interface CreateContentFiles {
|
||||
|
@ -36,14 +36,15 @@ import { ModeratorAction } from '../enums/moderation-action.enum';
|
||||
import { instanceToPlain } from 'class-transformer';
|
||||
import { ContentAssetEntity } from '../database/entities/content-asset.entity';
|
||||
import { ContentAssetType } from '../enums/content-asset-type.enum';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { lastValueFrom } from 'rxjs';
|
||||
import { AssetSource } from '../enums/asset-source.enum';
|
||||
|
||||
@Injectable()
|
||||
export class CreateContentService {
|
||||
constructor(
|
||||
@InjectEntityManager() private manager: EntityManager,
|
||||
@Inject('auth') private authClient: ClientProxy,
|
||||
@Inject('assets') private assetsClient: ClientProxy,
|
||||
@Inject('catalog') private client: ClientProxy,
|
||||
) {}
|
||||
|
||||
@ -300,7 +301,14 @@ export class CreateContentService {
|
||||
});
|
||||
|
||||
// Save assets
|
||||
await this.uploadAssets(content, revision, body.files, files, manager);
|
||||
await this.uploadAssets(
|
||||
content,
|
||||
revision,
|
||||
body.files,
|
||||
files,
|
||||
manager,
|
||||
user,
|
||||
);
|
||||
|
||||
// Moderator review
|
||||
await this.createPendingReview(content, revision, manager);
|
||||
@ -311,6 +319,45 @@ export class CreateContentService {
|
||||
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.
|
||||
* @private
|
||||
@ -343,6 +390,7 @@ export class CreateContentService {
|
||||
types: CreateContentFiles = {},
|
||||
files: Express.Multer.File[] = [],
|
||||
manager = this.manager,
|
||||
user?: UserInfo,
|
||||
) {
|
||||
const typeKeys = Object.keys(types);
|
||||
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(
|
||||
content.type,
|
||||
fileType,
|
||||
@ -376,17 +430,33 @@ export class CreateContentService {
|
||||
const asset = manager.create(ContentAssetEntity, {
|
||||
content,
|
||||
revision,
|
||||
assetId: randomUUID(),
|
||||
type: fileType.type,
|
||||
typeName: fileType.typeName,
|
||||
source: fileType.source,
|
||||
index: fileIndex++,
|
||||
});
|
||||
|
||||
const assetErrors = await validate(asset);
|
||||
if (assetErrors?.length) new ValidationRpcException();
|
||||
|
||||
// TODO: actually upload the files somewhere
|
||||
// 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);
|
||||
createdAssets.push(asset);
|
||||
|
@ -1,26 +1,25 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
|
||||
import { AppController } from './app.controller';
|
||||
import { AppService } from './app.service';
|
||||
import { natsClient } from '@freeblox/shared';
|
||||
import { ClientsModule } from '@nestjs/microservices';
|
||||
import { AuthModule } from './services/auth/auth.module';
|
||||
import { CatalogModule } from './services/catalog/catalog.module';
|
||||
import { UserMiddleware } from './middleware/user.middleware';
|
||||
import { AssetsModule } from './services/assets/assets.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ClientsModule.register([
|
||||
natsClient('auth'),
|
||||
natsClient('catalog'),
|
||||
natsClient('game'),
|
||||
natsClient('session'),
|
||||
natsClient('player'),
|
||||
natsClient('server'),
|
||||
natsClient('session'),
|
||||
]),
|
||||
ClientsModule.register([natsClient('auth')]),
|
||||
AuthModule,
|
||||
CatalogModule,
|
||||
AssetsModule,
|
||||
],
|
||||
controllers: [AppController],
|
||||
providers: [AppService],
|
||||
})
|
||||
export class AppModule {}
|
||||
export class AppModule implements NestModule {
|
||||
configure(consumer: MiddlewareConsumer) {
|
||||
consumer.apply(UserMiddleware).forRoutes('*');
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,4 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
|
||||
export const Privilege = (...privileges: string[]) =>
|
||||
SetMetadata('privileges', privileges);
|
@ -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 ')}`,
|
||||
}),
|
||||
);
|
@ -1,34 +1,10 @@
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
Inject,
|
||||
Injectable,
|
||||
} from '@nestjs/common';
|
||||
import { ClientProxy } from '@nestjs/microservices';
|
||||
import { Request, Response } from 'express';
|
||||
import { lastValueFrom } from 'rxjs';
|
||||
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
|
||||
import { Response } from 'express';
|
||||
|
||||
@Injectable()
|
||||
export class AuthGuard implements CanActivate {
|
||||
constructor(@Inject('auth') private authClient: ClientProxy) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest() as Request;
|
||||
const response = context.switchToHttp().getResponse() as Response;
|
||||
if (!request.headers.authorization) return false;
|
||||
|
||||
// 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;
|
||||
return !!response.locals.user;
|
||||
}
|
||||
}
|
||||
|
25
apps/freeblox-web-service/src/guards/privileges.guard.ts
Normal file
25
apps/freeblox-web-service/src/guards/privileges.guard.ts
Normal 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 || []);
|
||||
}
|
||||
}
|
34
apps/freeblox-web-service/src/middleware/user.middleware.ts
Normal file
34
apps/freeblox-web-service/src/middleware/user.middleware.ts
Normal 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();
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
@ -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 {}
|
@ -7,34 +7,28 @@ import {
|
||||
Post,
|
||||
Body,
|
||||
UploadedFiles,
|
||||
UseGuards,
|
||||
Patch,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
} from '@nestjs/common';
|
||||
import { ClientProxy } from '@nestjs/microservices';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiOkResponse,
|
||||
ApiOperation,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
import { ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { User } from '../../decorators/user.decorator';
|
||||
import { UserInfo } from '@freeblox/shared';
|
||||
import { CreateContentDto } from './dtos/create-content.dto';
|
||||
import { AnyFilesInterceptor } from '@nestjs/platform-express';
|
||||
import { AuthGuard } from '../../guards/auth.guard';
|
||||
import { CreateContentRevisionDto } from './dtos/create-content-revision.dto';
|
||||
import { ContentResponseDto } from './dtos/content-response.dto';
|
||||
import { ContentAssetType } from 'apps/catalog/src/enums/content-asset-type.enum';
|
||||
import { lastValueFrom } from 'rxjs';
|
||||
import { CategoryResponseDto } from './dtos/category-response.dto';
|
||||
import { ContentAssetDto } from './dtos/content-asset.dto';
|
||||
import { RequirePrivileges } from '../../decorators/require-privileges.decorator';
|
||||
|
||||
@Controller({
|
||||
version: '1',
|
||||
path: 'catalog',
|
||||
})
|
||||
@ApiBearerAuth()
|
||||
@ApiTags('Catalog')
|
||||
@UseInterceptors(ClassSerializerInterceptor)
|
||||
export class CatalogController {
|
||||
@ -50,7 +44,7 @@ export class CatalogController {
|
||||
@Post('content')
|
||||
@ApiOperation({ summary: 'Create new content' })
|
||||
@ApiOkResponse({ type: ContentResponseDto })
|
||||
@UseGuards(AuthGuard)
|
||||
@RequirePrivileges('create:*', 'contentedit')
|
||||
@UseInterceptors(AnyFilesInterceptor())
|
||||
async createContent(
|
||||
@User() user: UserInfo,
|
||||
@ -63,7 +57,6 @@ export class CatalogController {
|
||||
@Get('content/:id')
|
||||
@ApiOperation({ summary: 'Get content details' })
|
||||
@ApiOkResponse({ type: ContentResponseDto })
|
||||
@UseGuards(AuthGuard)
|
||||
async getContent(
|
||||
@Param('id', new ParseIntPipe()) id: number,
|
||||
@User() user: UserInfo,
|
||||
@ -73,8 +66,7 @@ export class CatalogController {
|
||||
|
||||
@Get('content/:id/thumbnail')
|
||||
@ApiOperation({ summary: 'Get content thumbnail ID' })
|
||||
@ApiOkResponse({ type: ContentResponseDto })
|
||||
@UseGuards(AuthGuard)
|
||||
@ApiOkResponse({ type: ContentAssetDto })
|
||||
async getContentThumbnail(
|
||||
@Param('id', new ParseIntPipe()) id: number,
|
||||
@User() user: UserInfo,
|
||||
@ -93,7 +85,7 @@ export class CatalogController {
|
||||
@Patch('content/:id')
|
||||
@ApiOperation({ summary: 'Update content details' })
|
||||
@ApiOkResponse({ type: ContentResponseDto })
|
||||
@UseGuards(AuthGuard)
|
||||
@RequirePrivileges('create:*', 'contentedit')
|
||||
async updateContent(
|
||||
@Param('id', new ParseIntPipe()) id: number,
|
||||
@User() user: UserInfo,
|
||||
@ -106,7 +98,7 @@ export class CatalogController {
|
||||
@ApiOperation({
|
||||
summary: 'Create a new revision (upload new content for item)',
|
||||
})
|
||||
@UseGuards(AuthGuard)
|
||||
@RequirePrivileges('create:*', 'contentedit')
|
||||
@UseInterceptors(AnyFilesInterceptor())
|
||||
async createContentRevision(
|
||||
@Param('id', new ParseIntPipe()) id: number,
|
||||
@ -127,7 +119,7 @@ export class CatalogController {
|
||||
@ApiOperation({
|
||||
summary: 'Publish content',
|
||||
})
|
||||
@UseGuards(AuthGuard)
|
||||
@RequirePrivileges('create:*', 'contentedit')
|
||||
async publishContent(
|
||||
@Param('id', new ParseIntPipe()) id: number,
|
||||
@User() user: UserInfo,
|
||||
|
@ -0,0 +1,6 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class ContentAssetDto {
|
||||
@ApiProperty()
|
||||
assetId: string;
|
||||
}
|
@ -1,6 +1,7 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { UserResponseDto } from './user-response.dto';
|
||||
import { Currency } from 'apps/catalog/src/enums/currency.enum';
|
||||
import { ContentAssetDto } from './content-asset.dto';
|
||||
|
||||
export class ContentPriceResponseDto {
|
||||
@ApiProperty()
|
||||
@ -64,4 +65,7 @@ export class ContentResponseDto {
|
||||
|
||||
@ApiProperty({ type: UserResponseDto })
|
||||
user: UserResponseDto;
|
||||
|
||||
@ApiProperty({ type: ContentAssetDto, isArray: true })
|
||||
assets: ContentAssetDto[];
|
||||
}
|
||||
|
@ -158,6 +158,11 @@ services:
|
||||
- POSTGRES_HOST=postgres
|
||||
- POSTGRES_USER=freeblox
|
||||
- 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:
|
||||
- ./apps:/usr/src/app/apps
|
||||
- ./libs:/usr/src/app/libs
|
||||
@ -189,6 +194,20 @@ services:
|
||||
environment:
|
||||
- PGADMIN_DEFAULT_EMAIL=freeblox@freeblox.gg
|
||||
- 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:
|
||||
fblx:
|
||||
volumes:
|
||||
|
@ -3,6 +3,7 @@ export * from './shared.service';
|
||||
export * from './utils/nats-client';
|
||||
export * from './utils/tokens';
|
||||
export * from './utils/parse-boolean';
|
||||
export * from './utils/match-privileges';
|
||||
export * from './database/make-typeorm';
|
||||
export * from './database/make-knex';
|
||||
export * from './database/metaentity';
|
||||
|
21
libs/shared/src/utils/match-privileges.ts
Normal file
21
libs/shared/src/utils/match-privileges.ts
Normal 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;
|
||||
};
|
@ -21,6 +21,13 @@
|
||||
"test:e2e": "jest --config ./apps/freeblox-web-service/test/jest-e2e.json"
|
||||
},
|
||||
"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/common": "^10.0.3",
|
||||
"@nestjs/config": "^3.0.0",
|
||||
|
1168
pnpm-lock.yaml
1168
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user