|
|
@@ -2,27 +2,28 @@ import { Injectable } from '@nestjs/common';
|
|
|
import {
|
|
|
AssetListOptions,
|
|
|
AssetType,
|
|
|
+ AssignAssetsToChannelInput,
|
|
|
CreateAssetInput,
|
|
|
CreateAssetResult,
|
|
|
DeletionResponse,
|
|
|
DeletionResult,
|
|
|
LogicalOperator,
|
|
|
+ Permission,
|
|
|
UpdateAssetInput,
|
|
|
} from '@vendure/common/lib/generated-types';
|
|
|
import { omit } from '@vendure/common/lib/omit';
|
|
|
import { ID, PaginatedList, Type } from '@vendure/common/lib/shared-types';
|
|
|
import { notNullOrUndefined } from '@vendure/common/lib/shared-utils';
|
|
|
+import { unique } from '@vendure/common/lib/unique';
|
|
|
import { ReadStream } from 'fs-extra';
|
|
|
import mime from 'mime-types';
|
|
|
import path from 'path';
|
|
|
import { Stream } from 'stream';
|
|
|
-import { Brackets } from 'typeorm';
|
|
|
|
|
|
import { RequestContext } from '../../api/common/request-context';
|
|
|
import { isGraphQlErrorResult } from '../../common/error/error-result';
|
|
|
-import { InternalServerError } from '../../common/error/errors';
|
|
|
+import { ForbiddenError, InternalServerError } from '../../common/error/errors';
|
|
|
import { MimeTypeError } from '../../common/error/generated-graphql-admin-errors';
|
|
|
-import { ListQueryOptions } from '../../common/types/common-types';
|
|
|
import { getAssetType, idsAreEqual } from '../../common/utils';
|
|
|
import { ConfigService } from '../../config/config.service';
|
|
|
import { Logger } from '../../config/logger/vendure-logger';
|
|
|
@@ -33,11 +34,14 @@ import { Collection } from '../../entity/collection/collection.entity';
|
|
|
import { ProductVariant } from '../../entity/product-variant/product-variant.entity';
|
|
|
import { Product } from '../../entity/product/product.entity';
|
|
|
import { EventBus } from '../../event-bus/event-bus';
|
|
|
+import { AssetChannelEvent } from '../../event-bus/events/asset-channel-event';
|
|
|
import { AssetEvent } from '../../event-bus/events/asset-event';
|
|
|
import { ListQueryBuilder } from '../helpers/list-query-builder/list-query-builder';
|
|
|
import { patchEntity } from '../helpers/utils/patch-entity';
|
|
|
import { TransactionalConnection } from '../transaction/transactional-connection';
|
|
|
|
|
|
+import { ChannelService } from './channel.service';
|
|
|
+import { RoleService } from './role.service';
|
|
|
import { TagService } from './tag.service';
|
|
|
// tslint:disable-next-line:no-var-requires
|
|
|
const sizeOf = require('image-size');
|
|
|
@@ -62,6 +66,8 @@ export class AssetService {
|
|
|
private listQueryBuilder: ListQueryBuilder,
|
|
|
private eventBus: EventBus,
|
|
|
private tagService: TagService,
|
|
|
+ private channelService: ChannelService,
|
|
|
+ private roleService: RoleService,
|
|
|
) {
|
|
|
this.permittedMimeTypes = this.configService.assetOptions.permittedFileTypes
|
|
|
.map(val => (/\.[\w]+/.test(val) ? mime.lookup(val) || undefined : val))
|
|
|
@@ -73,13 +79,14 @@ export class AssetService {
|
|
|
}
|
|
|
|
|
|
findOne(ctx: RequestContext, id: ID): Promise<Asset | undefined> {
|
|
|
- return this.connection.getRepository(ctx, Asset).findOne(id);
|
|
|
+ return this.connection.findOneInChannel(ctx, Asset, id, ctx.channelId);
|
|
|
}
|
|
|
|
|
|
findAll(ctx: RequestContext, options?: AssetListOptions): Promise<PaginatedList<Asset>> {
|
|
|
const qb = this.listQueryBuilder.build(Asset, options, {
|
|
|
ctx,
|
|
|
- relations: options?.tags ? ['tags'] : [],
|
|
|
+ relations: options?.tags ? ['tags', 'channels'] : ['channels'],
|
|
|
+ channelId: ctx.channelId,
|
|
|
});
|
|
|
const tags = options?.tags;
|
|
|
if (tags && tags.length) {
|
|
|
@@ -111,11 +118,15 @@ export class AssetService {
|
|
|
entity: T,
|
|
|
): Promise<Asset | undefined> {
|
|
|
const entityType: Type<EntityWithAssets> = Object.getPrototypeOf(entity).constructor;
|
|
|
- const entityWithFeaturedAsset = await this.connection
|
|
|
- .getRepository(ctx, entityType)
|
|
|
- .findOne(entity.id, {
|
|
|
+ const entityWithFeaturedAsset = await this.connection.findOneInChannel(
|
|
|
+ ctx,
|
|
|
+ entityType,
|
|
|
+ entity.id,
|
|
|
+ ctx.channelId,
|
|
|
+ {
|
|
|
relations: ['featuredAsset'],
|
|
|
- });
|
|
|
+ },
|
|
|
+ );
|
|
|
return (entityWithFeaturedAsset && entityWithFeaturedAsset.featuredAsset) || undefined;
|
|
|
}
|
|
|
|
|
|
@@ -126,9 +137,15 @@ export class AssetService {
|
|
|
let assets = entity.assets;
|
|
|
if (!assets) {
|
|
|
const entityType: Type<EntityWithAssets> = Object.getPrototypeOf(entity).constructor;
|
|
|
- const entityWithAssets = await this.connection.getRepository(ctx, entityType).findOne(entity.id, {
|
|
|
- relations: ['assets'],
|
|
|
- });
|
|
|
+ const entityWithAssets = await this.connection.findOneInChannel(
|
|
|
+ ctx,
|
|
|
+ entityType,
|
|
|
+ entity.id,
|
|
|
+ ctx.channelId,
|
|
|
+ {
|
|
|
+ relations: ['assets'],
|
|
|
+ },
|
|
|
+ );
|
|
|
assets = (entityWithAssets && entityWithAssets.assets) || [];
|
|
|
}
|
|
|
return assets.sort((a, b) => a.position - b.position).map(a => a.asset);
|
|
|
@@ -165,9 +182,9 @@ export class AssetService {
|
|
|
if (!entity.id) {
|
|
|
throw new InternalServerError('error.entity-must-have-an-id');
|
|
|
}
|
|
|
- const { assetIds, featuredAssetId } = input;
|
|
|
+ const { assetIds } = input;
|
|
|
if (assetIds && assetIds.length) {
|
|
|
- const assets = await this.connection.getRepository(ctx, Asset).findByIds(assetIds);
|
|
|
+ const assets = await this.connection.findByIdsInChannel(ctx, Asset, assetIds, ctx.channelId, {});
|
|
|
const sortedAssets = assetIds
|
|
|
.map(id => assets.find(a => idsAreEqual(a.id, id)))
|
|
|
.filter(notNullOrUndefined);
|
|
|
@@ -214,8 +231,18 @@ export class AssetService {
|
|
|
return updatedAsset;
|
|
|
}
|
|
|
|
|
|
- async delete(ctx: RequestContext, ids: ID[], force: boolean = false): Promise<DeletionResponse> {
|
|
|
- const assets = await this.connection.getRepository(ctx, Asset).findByIds(ids);
|
|
|
+ async delete(
|
|
|
+ ctx: RequestContext,
|
|
|
+ ids: ID[],
|
|
|
+ force: boolean = false,
|
|
|
+ deleteFromAllChannels: boolean = false,
|
|
|
+ ): Promise<DeletionResponse> {
|
|
|
+ const assets = await this.connection.findByIdsInChannel(ctx, Asset, ids, ctx.channelId, {
|
|
|
+ relations: ['channels'],
|
|
|
+ });
|
|
|
+ let channelsOfAssets: ID[] = [];
|
|
|
+ assets.forEach(a => a.channels.forEach(c => channelsOfAssets.push(c.id)));
|
|
|
+ channelsOfAssets = unique(channelsOfAssets);
|
|
|
const usageCount = {
|
|
|
products: 0,
|
|
|
variants: 0,
|
|
|
@@ -239,6 +266,86 @@ export class AssetService {
|
|
|
}),
|
|
|
};
|
|
|
}
|
|
|
+ const hasDeleteAllPermission = await this.hasDeletePermissionForChannels(ctx, channelsOfAssets);
|
|
|
+ if (deleteFromAllChannels && !hasDeleteAllPermission) {
|
|
|
+ throw new ForbiddenError();
|
|
|
+ }
|
|
|
+ if (!deleteFromAllChannels) {
|
|
|
+ await Promise.all(
|
|
|
+ assets.map(async asset => {
|
|
|
+ await this.channelService.removeFromChannels(ctx, Asset, asset.id, [ctx.channelId]);
|
|
|
+ this.eventBus.publish(new 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: 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, asset.id, channelsOfAssets);
|
|
|
+ this.eventBus.publish(new AssetChannelEvent(ctx, asset, ctx.channelId, 'removed'));
|
|
|
+ }),
|
|
|
+ );
|
|
|
+ return this.deleteUnconditional(ctx, assets);
|
|
|
+ }
|
|
|
+
|
|
|
+ async assignToChannel(ctx: RequestContext, input: AssignAssetsToChannelInput): Promise<Asset[]> {
|
|
|
+ const hasPermission = await this.roleService.userHasPermissionOnChannel(
|
|
|
+ ctx,
|
|
|
+ input.channelId,
|
|
|
+ Permission.UpdateCatalog,
|
|
|
+ );
|
|
|
+ if (!hasPermission) {
|
|
|
+ throw new ForbiddenError();
|
|
|
+ }
|
|
|
+ const assets = await this.connection.findByIdsInChannel(
|
|
|
+ ctx,
|
|
|
+ Asset,
|
|
|
+ input.assetIds,
|
|
|
+ ctx.channelId,
|
|
|
+ {},
|
|
|
+ );
|
|
|
+ await Promise.all(
|
|
|
+ assets.map(async asset => {
|
|
|
+ await this.channelService.assignToChannels(ctx, Asset, asset.id, [input.channelId]);
|
|
|
+ return this.eventBus.publish(new AssetChannelEvent(ctx, asset, input.channelId, 'assigned'));
|
|
|
+ }),
|
|
|
+ );
|
|
|
+ return this.connection.findByIdsInChannel(
|
|
|
+ ctx,
|
|
|
+ Asset,
|
|
|
+ assets.map(a => a.id),
|
|
|
+ ctx.channelId,
|
|
|
+ {},
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Create an Asset from a file stream created during data import.
|
|
|
+ */
|
|
|
+ async createFromFileStream(stream: ReadStream): Promise<CreateAssetResult> {
|
|
|
+ const filePath = stream.path;
|
|
|
+ if (typeof filePath === 'string') {
|
|
|
+ const filename = path.basename(filePath);
|
|
|
+ const mimetype = mime.lookup(filename) || 'application/octet-stream';
|
|
|
+ return this.createAssetInternal(RequestContext.empty(), stream, filename, mimetype);
|
|
|
+ } else {
|
|
|
+ throw new InternalServerError(`error.path-should-be-a-string-got-buffer`);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Unconditionally delete given assets.
|
|
|
+ * Does not remove assets from channels
|
|
|
+ */
|
|
|
+ private async deleteUnconditional(ctx: RequestContext, assets: Asset[]): Promise<DeletionResponse> {
|
|
|
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)
|
|
|
@@ -258,17 +365,15 @@ export class AssetService {
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
- * Create an Asset from a file stream created during data import.
|
|
|
+ * Check if current user has permissions to delete assets from all channels
|
|
|
*/
|
|
|
- async createFromFileStream(stream: ReadStream): Promise<CreateAssetResult> {
|
|
|
- const filePath = stream.path;
|
|
|
- if (typeof filePath === 'string') {
|
|
|
- const filename = path.basename(filePath);
|
|
|
- const mimetype = mime.lookup(filename) || 'application/octet-stream';
|
|
|
- return this.createAssetInternal(RequestContext.empty(), stream, filename, mimetype);
|
|
|
- } else {
|
|
|
- throw new InternalServerError(`error.path-should-be-a-string-got-buffer`);
|
|
|
- }
|
|
|
+ private async hasDeletePermissionForChannels(ctx: RequestContext, channelIds: ID[]): Promise<boolean> {
|
|
|
+ const permissions = await Promise.all(
|
|
|
+ channelIds.map(async channelId => {
|
|
|
+ return this.roleService.userHasPermissionOnChannel(ctx, channelId, Permission.DeleteCatalog);
|
|
|
+ }),
|
|
|
+ );
|
|
|
+ return !permissions.includes(false);
|
|
|
}
|
|
|
|
|
|
private async createAssetInternal(
|
|
|
@@ -276,7 +381,7 @@ export class AssetService {
|
|
|
stream: Stream,
|
|
|
filename: string,
|
|
|
mimetype: string,
|
|
|
- ): Promise<CreateAssetResult> {
|
|
|
+ ): Promise<Asset | MimeTypeError> {
|
|
|
const { assetOptions } = this.configService;
|
|
|
if (!this.validateMimeType(mimetype)) {
|
|
|
return new MimeTypeError(filename, mimetype);
|
|
|
@@ -312,6 +417,7 @@ export class AssetService {
|
|
|
preview: previewFileIdentifier,
|
|
|
focalPoint: null,
|
|
|
});
|
|
|
+ this.channelService.assignToCurrentChannel(asset, ctx);
|
|
|
return this.connection.getRepository(ctx, Asset).save(asset);
|
|
|
}
|
|
|
|