web-service/apps/catalog/src/services/create-content.service.ts

626 lines
17 KiB
TypeScript

import { Injectable, Inject } from '@nestjs/common';
import { ClientProxy } from '@nestjs/microservices';
import { InjectEntityManager } from '@nestjs/typeorm';
import { EntityManager } from 'typeorm';
import {
ContentAssetTypeProperties,
CreateContentFiles,
CreateContentPricing,
CreateContentRequest,
CreateContentRevisionRequest,
} from '../interfaces/create-content-request.interface';
import {
BadRequestRpcException,
ForbiddenRpcException,
NotFoundRpcException,
PreconditionFailedRpcException,
UserInfo,
ValidationRpcException,
parseBoolean,
} from '@freeblox/shared';
import {
ALLOWED_IMAGE_MIME,
ALLOWED_MESH_MIME,
ALLOWED_SOUND_MIME,
CHARACTER_ACCESSORIES,
CHARACTER_CLOTHING,
CREATOR_ITEMS,
} from '../constants/constants';
import { ContentType } from '../enums/content-type.enum';
import { ContentEntity } from '../database/entities/content.entity';
import { validate } from 'class-validator';
import { ContentPriceEntity } from '../database/entities/content-price.entity';
import { ContentModerationEntity } from '../database/entities/content-moderation.entity';
import { ContentRevisionEntity } from '../database/entities/content-revision.entity';
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 { 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,
) {}
async createContent(
body: CreateContentRequest,
user: UserInfo,
files?: Express.Multer.File[],
gameId?: number,
) {
if (!body.type) {
throw new BadRequestRpcException('Content type is mandatory.');
}
// Check permission for create
this.checkCreatePermission(body.type, user);
const newContent = await this.manager.transaction(async (manager) => {
// Main object
// TODO: automatic moderation for name and description
const content = manager.create(ContentEntity, {
name: body.name,
description: body.description,
userId: user.sub,
parentId: body.parentId,
onsale: parseBoolean(body.onsale) || false,
published: false,
commentsEnabled: parseBoolean(body.commentsEnabled) || false,
openSource: parseBoolean(body.openSource) || false,
privacy: body.privacy,
createdBy: user.sub,
updatedBy: user.sub,
type: body.type,
stock: body.stock,
license: body.license,
});
const errors = await validate(content);
if (errors?.length) new ValidationRpcException();
// Set the object ID to the same as the game ID.
// Game object is created first.
if (gameId) {
content.id = gameId;
}
await manager.save(ContentEntity, content);
// Save pricing
await this.saveContentPricing(content, manager, body.prices);
return content;
});
// Save content revision
await this.createContentRevision(
newContent.id,
{ files: body.files },
user,
files,
);
// TODO: log save
return instanceToPlain(newContent);
}
/**
* Update content details
* @param contentId Content ID
* @param body Update body
* @param user User token
* @returns Updated content
*/
async updateContentDetails(
contentId: number,
body: Partial<CreateContentRequest>,
user: UserInfo,
) {
const content = await this.manager.findOne(ContentEntity, {
where: {
id: contentId,
userId: user.sub, // TODO: delegate update permissions
restricted: false,
deletedAt: null,
},
});
if (!content) {
throw new NotFoundRpcException(`Content ${contentId} not found`);
}
const updatedContent = await this.manager.transaction(async (manager) => {
// Main object
// TODO: automatic moderation for name and description
const data = manager.merge(ContentEntity, content, {
name: body.name,
description: body.description,
userId: user.sub,
parentId: body.parentId,
onsale: parseBoolean(body.onsale),
commentsEnabled: parseBoolean(body.commentsEnabled),
openSource: parseBoolean(body.openSource),
privacy: body.privacy,
updatedBy: user.sub,
type: body.type,
stock: Number(body.stock) || null,
license: body.license,
});
const errors = await validate(data);
if (errors?.length) new ValidationRpcException();
await manager.save(ContentEntity, data);
// Save pricing
await this.saveContentPricing(data, manager, body.prices);
return data;
});
// TODO: log save
return instanceToPlain(updatedContent);
}
/**
* Publish content, generate thumbnails
* @param contentId Content ID
* @param user User token
* @returns Updated content
*/
async publishContentItem(contentId: number, user: UserInfo) {
const content = await this.manager.findOne(ContentEntity, {
where: {
id: contentId,
userId: user.sub, // TODO: delegate update permissions
restricted: false,
deletedAt: null,
},
});
if (!content) {
throw new NotFoundRpcException(`Content ${contentId} not found`);
}
// TODO: generate thumbnails
const getAssets = await lastValueFrom(
this.client.send('catalog.assets.byId', { id: contentId }),
);
if (!getAssets.length) {
throw new PreconditionFailedRpcException(
'You must publish some content to the platform first',
);
}
await this.manager.save(
ContentEntity,
this.manager.merge(ContentEntity, content, {
published: true,
}),
);
return instanceToPlain(content);
}
/**
* Save content pricing.
* @param content Content
* @param manager Manager
* @param pricing Pricing
*/
async saveContentPricing(
content: ContentEntity,
manager: EntityManager,
pricing?: CreateContentPricing[],
) {
if (!pricing) return;
// Deduplicate currencies
// Can't have multiple prices for the same currency
const prices = (pricing || []).filter(
(item, index, array) =>
array.findIndex((entry) => entry.currency === item.currency) === index,
);
// Delete existing currencies
const existingPricing = await manager.findBy(ContentPriceEntity, {
contentId: content.id,
});
await Promise.all(
existingPricing
.filter(
(existing) =>
!prices.some((entry) => entry.currency !== existing.currency),
)
.map((entry) => manager.remove(ContentPriceEntity, entry)),
);
// Update or create pricing
for (const price of prices) {
const existing = existingPricing.find(
(entry) => entry.currency === price.currency,
);
const priceObject = manager.create(ContentPriceEntity, {
currency: price.currency,
price: price.price,
contentId: content.id,
});
const priceErrors = await validate(priceObject);
if (priceErrors?.length) new ValidationRpcException();
// Update
if (existing) priceObject.id = existing.id;
await manager.save(ContentPriceEntity, priceObject);
}
}
/**
* Create a content revision and upload assets.
* @param contentId Content ID
* @param body Revision request
* @param files Attached files
*/
async createContentRevision(
contentId: number,
body: CreateContentRevisionRequest,
user: UserInfo,
files?: Express.Multer.File[],
) {
// TODO: check paid service upload permission
const content = await this.manager.findOne(ContentEntity, {
where: {
id: contentId,
userId: user.sub, // TODO: delegate revision permissions
deletedAt: null,
restricted: false,
},
});
if (!content) {
throw new NotFoundRpcException(`Content ${contentId} not found`);
}
const newRevision = this.manager.transaction(async (manager) => {
// Save new revision
const revision = await manager.save(ContentRevisionEntity, {
content,
createdBy: user.sub,
updatedBy: user.sub,
});
// Save assets
await this.uploadAssets(
content,
revision,
body.files,
files,
manager,
user,
);
// Moderator review
await this.createPendingReview(content, revision, manager);
return revision;
});
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
* @param content Content
* @param revision Revision
* @param manager Entity manager
*/
async createPendingReview(
content: ContentEntity,
revision: ContentRevisionEntity,
manager = this.manager,
) {
await manager.save(ContentModerationEntity, {
content,
revision,
action: ModeratorAction.PENDING,
});
}
/**
* Upload assets for content.
* @private
* @param types Asset types
* @param files Files to upload
* @returns Created assets
*/
async uploadAssets(
content: ContentEntity,
revision: ContentRevisionEntity,
types: CreateContentFiles = {},
files: Express.Multer.File[] = [],
manager = this.manager,
user?: UserInfo,
) {
const typeKeys = Object.keys(types);
if (!typeKeys.length) return [];
const createdAssets: ContentAssetEntity[] = [];
let fileIndex = 0;
for (const fileKey of typeKeys) {
const fileType = types[fileKey];
const uploadedFile = files.find((file) => file.fieldname === fileKey);
if (!uploadedFile) {
throw new BadRequestRpcException(
`File was not included for key ${fileKey}`,
);
}
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,
uploadedFile.mimetype,
);
if (!allowed) {
throw new BadRequestRpcException(
`File of type ${fileType.type} (${fileType.typeName || 'N/A'}) ${
uploadedFile.mimetype
} is not allowed for content of type ${content.type}`,
);
}
const asset = manager.create(ContentAssetEntity, {
content,
revision,
type: fileType.type,
typeName: fileType.typeName,
source: fileType.source,
index: fileIndex++,
});
const assetErrors = await validate(asset);
if (assetErrors?.length) new ValidationRpcException();
// 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);
}
return createdAssets;
}
/**
* Check if provided asset type can be uploaded
* @private
* @param contentType Content type
* @param props Asset type properties
*/
async checkAssetType(
contentType: ContentType,
props: ContentAssetTypeProperties,
mime: string,
) {
// TODO: check files for validity
// Textures for clothes and accessories
if (
(CHARACTER_CLOTHING.includes(contentType) ||
CHARACTER_ACCESSORIES.includes(contentType)) &&
props.type === ContentAssetType.TEXTURE &&
!props.typeName
) {
if (!ALLOWED_IMAGE_MIME.includes(mime)) return false;
return true;
}
// Meshes for accessories
if (
CHARACTER_ACCESSORIES.includes(contentType) &&
props.type === ContentAssetType.MESH &&
!props.typeName
) {
if (!ALLOWED_MESH_MIME.includes(mime)) return false;
return true;
}
// Meshes
if (
contentType === ContentType.MESH &&
props.type === ContentAssetType.MESH &&
!props.typeName
) {
if (!ALLOWED_MESH_MIME.includes(mime)) return false;
return true;
}
// Textures
if (
contentType === ContentType.TEXTURE &&
props.type === ContentAssetType.TEXTURE &&
!props.typeName
) {
if (!ALLOWED_IMAGE_MIME.includes(mime)) return false;
return true;
}
// Game objects
if (
contentType === ContentType.GAMEOBJECT &&
props.type === ContentAssetType.GAMEOBJECT &&
!props.typeName
) {
if (mime !== 'application/json') return false;
return true;
}
// Characters
if (
contentType === ContentType.CHARACTER &&
props.type === ContentAssetType.CHARACTER &&
!props.typeName
) {
if (mime !== 'application/json') return false;
return true;
}
// World/Game
if (
(contentType === ContentType.WORLD || contentType === ContentType.GAME) &&
props.type === ContentAssetType.WORLD
) {
if (mime !== 'application/json') return false;
return true;
}
// Sound
if (
contentType === ContentType.SOUND &&
props.type === ContentAssetType.SOUND
) {
if (!ALLOWED_SOUND_MIME.includes(mime)) return false;
return true;
}
// World/Game thumbnail
if (
(contentType === ContentType.WORLD || contentType === ContentType.GAME) &&
props.type === ContentAssetType.IMAGE &&
(props.typeName === 'thumbnail' || props.typeName === 'icon')
) {
if (!ALLOWED_IMAGE_MIME.includes(mime)) return false;
return true;
}
return false;
}
/**
* Check if user is allowed to create this content.
* @private
* @param type Content type
* @param user User token
*/
checkCreatePermission(type: ContentType, user: UserInfo) {
// Check clothing permission
if (
CHARACTER_CLOTHING.includes(type) &&
!user.privileges.includes('create:clothing')
) {
throw new ForbiddenRpcException(
`Create content of type ${type} is forbidden`,
);
}
// Check accessory permission
if (
CHARACTER_ACCESSORIES.includes(type) &&
!user.privileges.includes('create:accessory')
) {
throw new ForbiddenRpcException(
`Create content of type ${type} is forbidden`,
);
}
// Check game permission
if (
[...CREATOR_ITEMS, ContentType.GAME, ContentType.WORLD].includes(type) &&
!user.privileges.includes('create:game')
) {
throw new ForbiddenRpcException(
`Create content of type ${type} is forbidden`,
);
}
// Check character permission
if (
type === ContentType.CHARACTER &&
!user.privileges.includes('create:character')
) {
throw new ForbiddenRpcException(
`Create content of type character is forbidden`,
);
}
if (type === ContentType.COMMENT || type === ContentType.STATUS) {
throw new BadRequestRpcException(
`Content of type ${type} cannot be created through this API`,
);
}
}
}