s3 bucket logic for assets
This commit is contained in:
parent
f92af930d7
commit
1b504f4318
@ -1,4 +1,5 @@
|
|||||||
/database
|
/database
|
||||||
|
/storage
|
||||||
/dist
|
/dist
|
||||||
/node_modules
|
/node_modules
|
||||||
/.env
|
/.env
|
||||||
|
1
.gitignore
vendored
1
.gitignore
vendored
@ -3,6 +3,7 @@
|
|||||||
/node_modules
|
/node_modules
|
||||||
/database
|
/database
|
||||||
/private
|
/private
|
||||||
|
/storage
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
logs
|
logs
|
||||||
|
@ -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;
|
||||||
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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([
|
TypeOrmModule.forFeature(entities),
|
||||||
natsClient('assets'),
|
ClientsModule.register([natsClient('assets'), natsClient('auth')]),
|
||||||
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -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 { 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();
|
||||||
|
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')
|
@MessagePattern('catalog.items.update')
|
||||||
async updateItem({
|
async updateItem({
|
||||||
id,
|
id,
|
||||||
|
@ -60,6 +60,7 @@ const entities = [
|
|||||||
natsClient('catalog'),
|
natsClient('catalog'),
|
||||||
natsClient('auth'),
|
natsClient('auth'),
|
||||||
natsClient('player'),
|
natsClient('player'),
|
||||||
|
natsClient('assets'),
|
||||||
]),
|
]),
|
||||||
],
|
],
|
||||||
controllers: [CatalogController],
|
controllers: [CatalogController],
|
||||||
|
@ -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;
|
||||||
|
|
||||||
|
@ -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');
|
||||||
|
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 { 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 {
|
||||||
|
@ -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);
|
||||||
|
@ -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('*');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -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 {
|
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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
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,
|
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,
|
||||||
|
@ -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 { 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[];
|
||||||
}
|
}
|
||||||
|
@ -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:
|
||||||
|
@ -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';
|
||||||
|
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"
|
"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",
|
||||||
|
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