626 lines
17 KiB
TypeScript
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`,
|
|
);
|
|
}
|
|
}
|
|
}
|