@vendure/core
Version:
A modern, headless ecommerce framework
745 lines • 37.5 kB
JavaScript
"use strict";
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
return c > 3 && r && Object.defineProperty(target, key, r), r;
};
var __metadata = (this && this.__metadata) || function (k, v) {
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
};
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.AssetService = void 0;
const common_1 = require("@nestjs/common");
const generated_types_1 = require("@vendure/common/lib/generated-types");
const omit_1 = require("@vendure/common/lib/omit");
const shared_utils_1 = require("@vendure/common/lib/shared-utils");
const unique_1 = require("@vendure/common/lib/unique");
const fs_1 = require("fs");
const fs_extra_1 = require("fs-extra");
const http_1 = require("http");
const image_size_1 = require("image-size");
const mime_types_1 = __importDefault(require("mime-types"));
const path_1 = __importDefault(require("path"));
const typeorm_1 = require("typeorm");
const StringUtils_1 = require("typeorm/util/StringUtils");
const request_context_1 = require("../../api/common/request-context");
const common_2 = require("../../common");
const error_result_1 = require("../../common/error/error-result");
const errors_1 = require("../../common/error/errors");
const generated_graphql_admin_errors_1 = require("../../common/error/generated-graphql-admin-errors");
const utils_1 = require("../../common/utils");
const config_service_1 = require("../../config/config.service");
const vendure_logger_1 = require("../../config/logger/vendure-logger");
const transactional_connection_1 = require("../../connection/transactional-connection");
const asset_translation_entity_1 = require("../../entity/asset/asset-translation.entity");
const asset_entity_1 = require("../../entity/asset/asset.entity");
const collection_entity_1 = require("../../entity/collection/collection.entity");
const product_variant_entity_1 = require("../../entity/product-variant/product-variant.entity");
const product_entity_1 = require("../../entity/product/product.entity");
const event_bus_1 = require("../../event-bus/event-bus");
const asset_channel_event_1 = require("../../event-bus/events/asset-channel-event");
const asset_event_1 = require("../../event-bus/events/asset-event");
const custom_field_relation_service_1 = require("../helpers/custom-field-relation/custom-field-relation.service");
const list_query_builder_1 = require("../helpers/list-query-builder/list-query-builder");
const translatable_saver_1 = require("../helpers/translatable-saver/translatable-saver");
const translator_service_1 = require("../helpers/translator/translator.service");
const patch_entity_1 = require("../helpers/utils/patch-entity");
const channel_service_1 = require("./channel.service");
const role_service_1 = require("./role.service");
const tag_service_1 = require("./tag.service");
/**
* Detects the MIME type of a buffer from its magic bytes. Returns `undefined` for content
* with no recognisable signature (e.g. plain text, SVG). The `file-type` package is ESM-only,
* so it is loaded via dynamic import (preserved by the NodeNext build).
*/
async function detectMimeTypeFromContents(buffer) {
const { fileTypeFromBuffer } = await import('file-type');
const result = await fileTypeFromBuffer(buffer);
return result === null || result === void 0 ? void 0 : result.mime;
}
/**
* The number of leading bytes to inspect for magic-byte detection. Signatures live at the
* start of a file and `file-type` samples only a small prefix, so peeking this much is
* sufficient to identify the content type without buffering the whole upload.
*/
const CONTENT_DETECTION_SAMPLE_BYTES = 4100;
/**
* Reads up to `byteCount` leading bytes from a stream for inspection so the uploaded content
* can be validated before it is persisted. Returns the peeked bytes and whether the stream was
* fully consumed in the process (`complete`):
*
* - If the stream ended within `byteCount` (a small file), the whole content is in `head` and
* the caller writes it directly — the stream cannot be replayed.
* - Otherwise the peeked bytes are unshifted back onto the stream, so the caller can write the
* full original stream (preserving its error handling) without buffering it whole.
*
* The stream is read in paused mode because a `for await … break` would destroy it.
*/
function peekStreamHead(stream, byteCount) {
return new Promise((resolve, reject) => {
const chunks = [];
let size = 0;
const cleanup = () => {
stream.removeListener('readable', onReadable);
stream.removeListener('end', onEnd);
stream.removeListener('error', onError);
};
const onEnd = () => {
cleanup();
resolve({ head: Buffer.concat(chunks), complete: true });
};
const onError = (err) => {
cleanup();
reject(err);
};
const onReadable = () => {
let chunk;
// eslint-disable-next-line no-cond-assign
while ((chunk = stream.read()) !== null) {
chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk);
size += chunks[chunks.length - 1].length;
if (size >= byteCount) {
cleanup();
const head = Buffer.concat(chunks);
stream.unshift(head);
resolve({ head, complete: false });
return;
}
}
};
stream.on('readable', onReadable);
stream.on('end', onEnd);
stream.on('error', onError);
});
}
/**
* @description
* Contains methods relating to {@link Asset} entities.
*
* @docsCategory services
* @docsWeight 0
*/
let AssetService = class AssetService {
constructor(connection, configService, listQueryBuilder, eventBus, tagService, channelService, roleService, customFieldRelationService, translatableSaver, translator) {
this.connection = connection;
this.configService = configService;
this.listQueryBuilder = listQueryBuilder;
this.eventBus = eventBus;
this.tagService = tagService;
this.channelService = channelService;
this.roleService = roleService;
this.customFieldRelationService = customFieldRelationService;
this.translatableSaver = translatableSaver;
this.translator = translator;
this.permittedMimeTypes = [];
this.permittedMimeTypes = this.configService.assetOptions.permittedFileTypes
.map(val => (/\.[\w]+/.test(val) ? mime_types_1.default.lookup(val) || undefined : val))
.filter(shared_utils_1.notNullOrUndefined)
.map(val => {
const [type, subtype] = val.split('/');
return { type, subtype };
});
}
findOne(ctx, id, relations) {
return this.connection
.findOneInChannel(ctx, asset_entity_1.Asset, id, ctx.channelId, {
relations: relations !== null && relations !== void 0 ? relations : [],
})
.then(result => (result ? this.translator.translate(result, ctx) : undefined));
}
findAll(ctx, options, relations) {
var _a;
const qb = this.listQueryBuilder.build(asset_entity_1.Asset, options, {
ctx,
relations: [...(relations !== null && relations !== void 0 ? relations : []), 'tags'],
channelId: ctx.channelId,
});
const tags = options === null || options === void 0 ? void 0 : options.tags;
if (tags && tags.length) {
const operator = (_a = options === null || options === void 0 ? void 0 : options.tagsOperator) !== null && _a !== void 0 ? _a : generated_types_1.LogicalOperator.AND;
const subquery = qb.connection
.createQueryBuilder()
.select('asset.id')
.from(asset_entity_1.Asset, 'asset')
.leftJoin('asset.tags', 'tags')
.where('tags.value IN (:...tags)');
if (operator === generated_types_1.LogicalOperator.AND) {
subquery.groupBy('asset.id').having('COUNT(asset.id) = :tagCount');
}
qb.andWhere(`asset.id IN (${subquery.getQuery()})`).setParameters({
tags,
tagCount: tags.length,
});
}
return qb.getManyAndCount().then(([items, totalItems]) => ({
items: items.map(asset => this.translator.translate(asset, ctx)),
totalItems,
}));
}
async getFeaturedAsset(ctx, entity) {
const entityType = Object.getPrototypeOf(entity).constructor;
let entityWithFeaturedAsset;
if (this.channelService.isChannelAware(entity)) {
entityWithFeaturedAsset = await this.connection.findOneInChannel(ctx, entityType, entity.id, ctx.channelId, {
relations: ['featuredAsset'],
loadEagerRelations: false,
});
}
else {
entityWithFeaturedAsset = await this.connection
.getRepository(ctx, entityType)
.findOne({
where: { id: entity.id },
relations: {
featuredAsset: true,
},
loadEagerRelations: false,
// TODO: satisfies
})
.then(result => result !== null && result !== void 0 ? result : undefined);
}
return (entityWithFeaturedAsset && entityWithFeaturedAsset.featuredAsset) || undefined;
}
/**
* @description
* Returns the Assets of an entity which has a well-ordered list of Assets, such as Product,
* ProductVariant or Collection.
*/
async getEntityAssets(ctx, entity) {
var _a, _b;
let orderableAssets = entity.assets;
if (!orderableAssets) {
const entityType = Object.getPrototypeOf(entity).constructor;
const entityWithAssets = await this.connection
.getRepository(ctx, entityType)
.createQueryBuilder('entity')
.leftJoinAndSelect('entity.assets', 'orderable_asset')
.leftJoinAndSelect('orderable_asset.asset', 'asset')
.leftJoinAndSelect('asset.translations', 'asset_translations')
.leftJoinAndSelect('asset.channels', 'asset_channel')
.where('entity.id = :id', { id: entity.id })
.andWhere('asset_channel.id = :channelId', { channelId: ctx.channelId })
.getOne();
orderableAssets = (_a = entityWithAssets === null || entityWithAssets === void 0 ? void 0 : entityWithAssets.assets) !== null && _a !== void 0 ? _a : [];
}
else if (0 < orderableAssets.length) {
// the Assets are already loaded, but we need to limit them by Channel
if ((_b = orderableAssets[0].asset) === null || _b === void 0 ? void 0 : _b.channels) {
orderableAssets = orderableAssets.filter(a => !!a.asset.channels.map(c => c.id).find(id => (0, utils_1.idsAreEqual)(id, ctx.channelId)));
}
else {
const assetsInChannel = await this.connection
.getRepository(ctx, asset_entity_1.Asset)
.createQueryBuilder('asset')
.leftJoinAndSelect('asset.channels', 'asset_channel')
.where('asset.id IN (:...ids)', { ids: orderableAssets.map(a => a.assetId) })
.andWhere('asset_channel.id = :channelId', { channelId: ctx.channelId })
.getMany();
orderableAssets = orderableAssets.filter(oa => !!assetsInChannel.find(a => (0, utils_1.idsAreEqual)(a.id, oa.assetId)));
}
}
else {
orderableAssets = [];
}
return orderableAssets.sort((a, b) => a.position - b.position).map(a => a.asset);
}
async updateFeaturedAsset(ctx, entity, input) {
const { assetIds, featuredAssetId } = input;
if (featuredAssetId === null || (assetIds && assetIds.length === 0)) {
entity.featuredAsset = null;
return entity;
}
if (featuredAssetId === undefined) {
return entity;
}
const featuredAsset = await this.findOne(ctx, featuredAssetId);
if (featuredAsset) {
entity.featuredAsset = featuredAsset;
}
return entity;
}
/**
* @description
* Updates the assets / featuredAsset of an entity, ensuring that only valid assetIds are used.
*/
async updateEntityAssets(ctx, entity, input) {
if (!entity.id) {
throw new errors_1.InternalServerError('error.entity-must-have-an-id');
}
const { assetIds } = input;
if (assetIds && assetIds.length) {
const assets = await this.connection.findByIdsInChannel(ctx, asset_entity_1.Asset, assetIds, ctx.channelId, {});
const sortedAssets = assetIds
.map(id => assets.find(a => (0, utils_1.idsAreEqual)(a.id, id)))
.filter(shared_utils_1.notNullOrUndefined);
await this.removeExistingOrderableAssets(ctx, entity);
if (sortedAssets.length > 0) {
entity.assets = await this.createOrderableAssets(ctx, entity, sortedAssets);
}
else {
entity.assets = [];
}
}
else if (assetIds && assetIds.length === 0) {
await this.removeExistingOrderableAssets(ctx, entity);
}
return entity;
}
/**
* @description
* Create an Asset based on a file uploaded via the GraphQL API. The file should be uploaded
* using the [GraphQL multipart request specification](https://github.com/jaydenseric/graphql-multipart-request-spec),
* e.g. using the [apollo-upload-client](https://github.com/jaydenseric/apollo-upload-client) npm package.
*
* See the [Uploading Files docs](/developer-guide/uploading-files) for an example of usage.
*/
async create(ctx, input) {
const { createReadStream, filename, mimetype } = await input.file;
const { stream, errorPromise } = this.makeStreamGuard(createReadStream);
const result = await Promise.race([
this.createAssetInternal(ctx, stream, filename, mimetype, input.customFields, input.translations),
errorPromise,
]);
if ((0, error_result_1.isGraphQlErrorResult)(result)) {
return result;
}
await this.customFieldRelationService.updateRelations(ctx, asset_entity_1.Asset, input, result);
if (input.tags) {
const tags = await this.tagService.valuesToTags(ctx, input.tags);
result.tags = tags;
await this.connection.getRepository(ctx, asset_entity_1.Asset).save(result);
}
const translatedAsset = this.translator.translate(result, ctx);
await this.eventBus.publish(new asset_event_1.AssetEvent(ctx, translatedAsset, 'created', input));
return translatedAsset;
}
/**
* @description
* Updates the name, focalPoint, tags & custom fields of an Asset.
*/
async update(ctx, input) {
var _a;
const asset = await this.connection.getEntityOrThrow(ctx, asset_entity_1.Asset, input.id);
if (input.focalPoint) {
const to3dp = (x) => +x.toFixed(3);
input.focalPoint.x = to3dp(input.focalPoint.x);
input.focalPoint.y = to3dp(input.focalPoint.y);
}
(0, patch_entity_1.patchEntity)(asset, (0, omit_1.omit)(input, ['tags', 'name', 'translations']));
if (input.tags) {
asset.tags = await this.tagService.valuesToTags(ctx, input.tags);
}
// Handle translations
const translationsInput = (_a = input.translations) !== null && _a !== void 0 ? _a : [];
// For backward compatibility: if name is provided without translations, update the current language translation
if (input.name != null && !translationsInput.some(t => t.languageCode === ctx.languageCode)) {
translationsInput.push({ languageCode: ctx.languageCode, name: input.name });
}
// Save asset first to ensure it exists for translation foreign key, and so that
// updateRelations() sees the freshly-patched scalar custom fields when it reloads
// the entity from the DB.
const savedAsset = await this.connection.getRepository(ctx, asset_entity_1.Asset).save(asset);
await this.customFieldRelationService.updateRelations(ctx, asset_entity_1.Asset, input, asset);
if (translationsInput.length > 0) {
await this.translatableSaver.update({
ctx,
input: { id: savedAsset.id, translations: translationsInput },
entityType: asset_entity_1.Asset,
translationType: asset_translation_entity_1.AssetTranslation,
});
}
const translatedAsset = await this.findOne(ctx, savedAsset.id);
if (!translatedAsset) {
throw new errors_1.InternalServerError('error.entity-not-found');
}
await this.eventBus.publish(new asset_event_1.AssetEvent(ctx, translatedAsset, 'updated', input));
return translatedAsset;
}
/**
* @description
* Deletes an Asset after performing checks to ensure that the Asset is not currently in use
* by a Product, ProductVariant or Collection.
*/
async delete(ctx, ids, force = false, deleteFromAllChannels = false) {
const assets = await this.connection.findByIdsInChannel(ctx, asset_entity_1.Asset, ids, ctx.channelId, {
relations: ['channels'],
});
let channelsOfAssets = [];
assets.forEach(a => a.channels.forEach(c => channelsOfAssets.push(c.id)));
channelsOfAssets = (0, unique_1.unique)(channelsOfAssets);
const usageCount = {
products: 0,
variants: 0,
collections: 0,
};
for (const asset of assets) {
const usages = await this.findAssetUsages(ctx, asset);
usageCount.products += usages.products.length;
usageCount.variants += usages.variants.length;
usageCount.collections += usages.collections.length;
}
const hasUsages = !!(usageCount.products || usageCount.variants || usageCount.collections);
if (hasUsages && !force) {
return {
result: generated_types_1.DeletionResult.NOT_DELETED,
message: ctx.translate('message.asset-to-be-deleted-is-featured', {
assetCount: assets.length,
products: usageCount.products,
variants: usageCount.variants,
collections: usageCount.collections,
}),
};
}
const hasDeleteAllPermission = await this.hasDeletePermissionForChannels(ctx, channelsOfAssets);
if (deleteFromAllChannels && !hasDeleteAllPermission) {
throw new errors_1.ForbiddenError();
}
if (!deleteFromAllChannels) {
await Promise.all(assets.map(async (asset) => {
await this.channelService.removeFromChannels(ctx, asset_entity_1.Asset, asset.id, [ctx.channelId]);
await this.eventBus.publish(new asset_channel_event_1.AssetChannelEvent(ctx, asset, ctx.channelId, 'removed'));
}));
const isOnlyChannel = channelsOfAssets.length === 1;
if (isOnlyChannel) {
// only channel, so also delete asset
await this.deleteUnconditional(ctx, assets);
}
return {
result: generated_types_1.DeletionResult.DELETED,
};
}
// This leaves us with deleteFromAllChannels with force or deleteFromAllChannels with no current usages
await Promise.all(assets.map(async (asset) => {
await this.channelService.removeFromChannels(ctx, asset_entity_1.Asset, asset.id, channelsOfAssets);
await this.eventBus.publish(new asset_channel_event_1.AssetChannelEvent(ctx, asset, ctx.channelId, 'removed'));
}));
return this.deleteUnconditional(ctx, assets);
}
async assignToChannel(ctx, input) {
const hasPermission = await this.roleService.userHasPermissionOnChannel(ctx, input.channelId, generated_types_1.Permission.UpdateCatalog);
if (!hasPermission) {
throw new errors_1.ForbiddenError();
}
const assets = await this.connection.findByIdsInChannel(ctx, asset_entity_1.Asset, input.assetIds, ctx.channelId, {});
await Promise.all(assets.map(async (asset) => {
await this.channelService.assignToChannels(ctx, asset_entity_1.Asset, asset.id, [input.channelId]);
return await this.eventBus.publish(new asset_channel_event_1.AssetChannelEvent(ctx, asset, input.channelId, 'assigned'));
}));
const updatedAssets = await this.connection.findByIdsInChannel(ctx, asset_entity_1.Asset, assets.map(a => a.id), ctx.channelId, {});
return updatedAssets.map(asset => this.translator.translate(asset, ctx));
}
async createFromFileStream(stream, maybeFilePathOrCtx, maybeCtx) {
const { assetImportStrategy } = this.configService.importExportOptions;
const filePathFromArgs = maybeFilePathOrCtx instanceof request_context_1.RequestContext ? undefined : maybeFilePathOrCtx;
const filePath = stream instanceof fs_extra_1.ReadStream || stream instanceof fs_1.ReadStream ? stream.path : filePathFromArgs;
if (typeof filePath === 'string') {
const filename = path_1.default.basename(filePath).split('?')[0];
const mimetype = this.getMimeType(stream, filename);
const ctx = maybeFilePathOrCtx instanceof request_context_1.RequestContext
? maybeFilePathOrCtx
: maybeCtx instanceof request_context_1.RequestContext
? maybeCtx
: request_context_1.RequestContext.empty();
const result = await this.createAssetInternal(ctx, stream, filename, mimetype);
if ((0, error_result_1.isGraphQlErrorResult)(result)) {
return result;
}
return this.translator.translate(result, ctx);
}
else {
throw new errors_1.InternalServerError('error.path-should-be-a-string-got-buffer');
}
}
getMimeType(stream, filename) {
if (stream instanceof http_1.IncomingMessage) {
const contentType = stream.headers['content-type'];
if (contentType) {
return contentType;
}
}
return mime_types_1.default.lookup(filename) || 'application/octet-stream';
}
/**
* @description
* Unconditionally delete given assets.
* Does not remove assets from channels
*/
async deleteUnconditional(ctx, assets) {
for (const asset of assets) {
// Create a new asset so that the id is still available
// after deletion (the .remove() method sets it to undefined)
const deletedAsset = new asset_entity_1.Asset(asset);
await this.connection.getRepository(ctx, asset_entity_1.Asset).remove(asset);
try {
await this.configService.assetOptions.assetStorageStrategy.deleteFile(asset.source);
await this.configService.assetOptions.assetStorageStrategy.deleteFile(asset.preview);
}
catch (e) {
vendure_logger_1.Logger.error('error.could-not-delete-asset-file', undefined, e.stack);
}
await this.eventBus.publish(new asset_event_1.AssetEvent(ctx, deletedAsset, 'deleted', deletedAsset.id));
}
return {
result: generated_types_1.DeletionResult.DELETED,
};
}
/**
* Check if current user has permissions to delete assets from all channels
*/
async hasDeletePermissionForChannels(ctx, channelIds) {
const permissions = await Promise.all(channelIds.map(async (channelId) => {
return this.roleService.userHasPermissionOnChannel(ctx, channelId, generated_types_1.Permission.DeleteCatalog);
}));
return !permissions.includes(false);
}
/**
* Validates an upload against the permitted MIME types using three independently spoofable
* signals (see GHSA-88rq-mq4v-frmm): the declared Content-Type, the file extension, and the
* actual content (magic bytes). Returns a {@link MimeTypeError} if any signal identifies a
* non-permitted type, or `undefined` if the file is permitted.
*/
getPermittedMimeTypeError(filename, declaredMimeType, contentMimeType) {
// 1. The declared Content-Type must be permitted (cheap, header-only check).
if (!this.validateMimeType(declaredMimeType)) {
return new generated_graphql_admin_errors_1.MimeTypeError({ fileName: filename, mimeType: declaredMimeType });
}
// 2. The file extension must be permitted: the stored file keeps it, and it is what
// determines whether a downstream web server might execute the file (e.g. `.php`).
// `mime.lookup` returns `false` for unrecognised extensions, handled by the content check.
const extensionMimeType = mime_types_1.default.lookup(filename) || undefined;
if (extensionMimeType && !this.validateMimeType(extensionMimeType)) {
return new generated_graphql_admin_errors_1.MimeTypeError({ fileName: filename, mimeType: extensionMimeType });
}
// 3. The actual content must not identify a non-permitted type. Text-based formats have no
// binary signature: plain text yields `undefined`, while XML-based formats such as SVG
// yield a generic `application/xml`/`text/xml` which is consistent with a permitted `*+xml`
// extension and so is not a mismatch.
if (contentMimeType && !this.validateMimeType(contentMimeType)) {
const contentIsGenericXml = contentMimeType === 'application/xml' || contentMimeType === 'text/xml';
const extensionIsXmlBased = !!extensionMimeType && extensionMimeType.endsWith('+xml');
if (!(contentIsGenericXml && extensionIsXmlBased)) {
return new generated_graphql_admin_errors_1.MimeTypeError({ fileName: filename, mimeType: contentMimeType });
}
}
// A file recognised neither by content nor by a permitted extension cannot be verified as
// a permitted type (e.g. a script with an unrecognised extension such as `.phtml`).
if (!contentMimeType && !extensionMimeType) {
return new generated_graphql_admin_errors_1.MimeTypeError({ fileName: filename, mimeType: 'application/octet-stream' });
}
return undefined;
}
async createAssetInternal(ctx, stream, filename, mimetype, customFields, translations) {
const { assetOptions } = this.configService;
// Inspect the leading bytes of the uploaded content (magic bytes) so the real content
// type can be validated, not just the spoofable declared Content-Type and extension. The
// stream is peeked rather than fully buffered so the file can still be written as a stream.
const { head, complete } = await peekStreamHead(stream, CONTENT_DETECTION_SAMPLE_BYTES);
const contentMimeType = await detectMimeTypeFromContents(head);
const mimeTypeError = this.getPermittedMimeTypeError(filename, mimetype, contentMimeType);
if (mimeTypeError) {
return mimeTypeError;
}
const { assetPreviewStrategy, assetStorageStrategy } = assetOptions;
const sourceFileName = await this.getSourceFileName(ctx, filename);
const previewFileName = await this.getPreviewFileName(ctx, sourceFileName);
// If the stream was fully consumed during the peek (a small file), `head` holds the
// whole content and the stream cannot be replayed; write the buffer directly.
// Otherwise the peeked bytes were unshifted back, so write the full stream.
const sourceFileIdentifier = complete
? await assetStorageStrategy.writeFileFromBuffer(sourceFileName, head)
: await assetStorageStrategy.writeFileFromStream(sourceFileName, stream);
const sourceFile = await assetStorageStrategy.readFileToBuffer(sourceFileIdentifier);
let preview;
try {
preview = await assetPreviewStrategy.generatePreviewImage(ctx, mimetype, sourceFile);
}
catch (e) {
const message = typeof e.message === 'string' ? e.message : e.message.toString();
vendure_logger_1.Logger.error(`Could not create Asset preview image: ${message}`, undefined, e.stack);
throw e;
}
const previewFileIdentifier = await assetStorageStrategy.writeFileFromBuffer(previewFileName, preview);
const type = (0, utils_1.getAssetType)(mimetype);
const { width, height } = this.getDimensions(type === generated_types_1.AssetType.IMAGE ? sourceFile : preview);
// Save asset first
const asset = new asset_entity_1.Asset({
type,
width,
height,
fileSize: sourceFile.byteLength,
mimeType: mimetype,
source: sourceFileIdentifier,
preview: previewFileIdentifier,
focalPoint: null,
customFields,
});
await this.channelService.assignToCurrentChannel(asset, ctx);
const savedAsset = await this.connection.getRepository(ctx, asset_entity_1.Asset).save(asset);
// Create and save translations with the base relationship set
// Use the original filename for the default translation name
const defaultName = filename;
let assetTranslations;
if (translations && translations.length > 0) {
assetTranslations = translations.map(t => {
var _a;
return new asset_translation_entity_1.AssetTranslation({
languageCode: t.languageCode,
name: (_a = t.name) !== null && _a !== void 0 ? _a : defaultName,
customFields: t.customFields,
base: savedAsset,
});
});
}
else {
// Create default translation using context language
assetTranslations = [
new asset_translation_entity_1.AssetTranslation({
languageCode: ctx.languageCode,
name: defaultName,
base: savedAsset,
}),
];
}
// Save translations
const savedTranslations = await this.connection
.getRepository(ctx, asset_translation_entity_1.AssetTranslation)
.save(assetTranslations);
// Return the asset with translations eagerly loaded
savedAsset.translations = savedTranslations;
return savedAsset;
}
async getSourceFileName(ctx, fileName) {
const { assetOptions } = this.configService;
return this.generateUniqueName(fileName, (name, conflict) => assetOptions.assetNamingStrategy.generateSourceFileName(ctx, name, conflict));
}
async getPreviewFileName(ctx, fileName) {
const { assetOptions } = this.configService;
return this.generateUniqueName(fileName, (name, conflict) => assetOptions.assetNamingStrategy.generatePreviewFileName(ctx, name, conflict));
}
async generateUniqueName(inputFileName, generateNameFn) {
const { assetOptions } = this.configService;
let outputFileName;
do {
outputFileName = generateNameFn(inputFileName, outputFileName);
} while (await assetOptions.assetStorageStrategy.fileExists(outputFileName));
return outputFileName;
}
getDimensions(imageFile) {
try {
const { width, height } = (0, image_size_1.imageSize)(imageFile);
return { width: width !== null && width !== void 0 ? width : 0, height: height !== null && height !== void 0 ? height : 0 };
}
catch (e) {
vendure_logger_1.Logger.error('Could not determine Asset dimensions: ' + JSON.stringify(e));
return { width: 0, height: 0 };
}
}
createOrderableAssets(ctx, entity, assets) {
const orderableAssets = assets.map((asset, i) => this.getOrderableAsset(ctx, entity, asset, i));
return this.connection.getRepository(ctx, orderableAssets[0].constructor).save(orderableAssets);
}
getOrderableAsset(ctx, entity, asset, index) {
const entityIdProperty = this.getHostEntityIdProperty(entity);
const orderableAssetType = this.getOrderableAssetType(ctx, entity);
return new orderableAssetType({
assetId: asset.id,
position: index,
[entityIdProperty]: entity.id,
});
}
async removeExistingOrderableAssets(ctx, entity) {
const propertyName = this.getHostEntityIdProperty(entity);
const orderableAssetType = this.getOrderableAssetType(ctx, entity);
await this.connection.getRepository(ctx, orderableAssetType).delete({
[propertyName]: entity.id,
});
}
getOrderableAssetType(ctx, entity) {
const assetRelation = this.connection
.getRepository(ctx, entity.constructor)
.metadata.relations.find(r => r.propertyName === 'assets');
if (!assetRelation || typeof assetRelation.type === 'string') {
throw new errors_1.InternalServerError('error.could-not-find-matching-orderable-asset');
}
return assetRelation.type;
}
getHostEntityIdProperty(entity) {
const entityName = entity.constructor.name;
switch (entityName) {
case 'Product':
return 'productId';
case 'ProductVariant':
return 'productVariantId';
case 'Collection':
return 'collectionId';
default:
return `${(0, StringUtils_1.camelCase)(entityName)}Id`;
}
}
validateMimeType(mimeType) {
const [type, subtype] = mimeType.split('/');
const typeMatches = this.permittedMimeTypes.filter(t => t.type === type);
for (const match of typeMatches) {
if (match.subtype === subtype || match.subtype === '*') {
return true;
}
}
return false;
}
/**
* Find the entities which reference the given Asset as a featuredAsset.
*/
async findAssetUsages(ctx, asset) {
const products = await this.connection.getRepository(ctx, product_entity_1.Product).find({
where: {
featuredAsset: { id: asset.id },
deletedAt: (0, typeorm_1.IsNull)(),
},
});
const variants = await this.connection.getRepository(ctx, product_variant_entity_1.ProductVariant).find({
where: {
featuredAsset: { id: asset.id },
deletedAt: (0, typeorm_1.IsNull)(),
},
});
const collections = await this.connection.getRepository(ctx, collection_entity_1.Collection).find({
where: {
featuredAsset: { id: asset.id },
},
});
return { products, variants, collections };
}
makeStreamGuard(createReadStream) {
let onReject;
const errorPromise = new Promise((_, rej) => {
onReject = rej;
});
// `fs-capacitor`'s `createReadStream` can throw if its `WriteStream` has already been destroyed
// sync error so will bubble to consumer immediately
const stream = createReadStream();
stream.on('error', err => {
onReject(err);
});
return { stream, errorPromise };
}
};
exports.AssetService = AssetService;
exports.AssetService = AssetService = __decorate([
(0, common_1.Injectable)(),
(0, common_2.Instrument)(),
__metadata("design:paramtypes", [transactional_connection_1.TransactionalConnection,
config_service_1.ConfigService,
list_query_builder_1.ListQueryBuilder,
event_bus_1.EventBus,
tag_service_1.TagService,
channel_service_1.ChannelService,
role_service_1.RoleService,
custom_field_relation_service_1.CustomFieldRelationService,
translatable_saver_1.TranslatableSaver,
translator_service_1.TranslatorService])
], AssetService);
//# sourceMappingURL=asset.service.js.map