Browse Source

feat(server): Implement interceptor for asset preview urls

Relates to #22.
Michael Bromley 7 years ago
parent
commit
17fcf2fb7a

+ 6 - 1
server/src/api/api.module.ts

@@ -1,5 +1,5 @@
 import { Module } from '@nestjs/common';
 import { Module } from '@nestjs/common';
-import { APP_GUARD } from '@nestjs/core';
+import { APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core';
 import { GraphQLModule } from '@nestjs/graphql';
 import { GraphQLModule } from '@nestjs/graphql';
 
 
 import { ConfigModule } from '../config/config.module';
 import { ConfigModule } from '../config/config.module';
@@ -7,6 +7,7 @@ import { I18nModule } from '../i18n/i18n.module';
 import { ServiceModule } from '../service/service.module';
 import { ServiceModule } from '../service/service.module';
 
 
 import { AdministratorResolver } from './administrator/administrator.resolver';
 import { AdministratorResolver } from './administrator/administrator.resolver';
+import { AssetInterceptor } from './asset-interceptor';
 import { AssetResolver } from './asset/asset.resolver';
 import { AssetResolver } from './asset/asset.resolver';
 import { AuthGuard } from './auth-guard';
 import { AuthGuard } from './auth-guard';
 import { AuthResolver } from './auth/auth.resolver';
 import { AuthResolver } from './auth/auth.resolver';
@@ -60,6 +61,10 @@ const exportedProviders = [
             provide: APP_GUARD,
             provide: APP_GUARD,
             useClass: RolesGuard,
             useClass: RolesGuard,
         },
         },
+        {
+            provide: APP_INTERCEPTOR,
+            useClass: AssetInterceptor,
+        },
     ],
     ],
     exports: exportedProviders,
     exports: exportedProviders,
 })
 })

+ 129 - 0
server/src/api/asset-interceptor.spec.ts

@@ -0,0 +1,129 @@
+import { ExecutionContextHost } from '@nestjs/core/helpers/execution-context.host';
+import { of } from 'rxjs';
+
+import { MockConfigService } from '../config/config.service.mock';
+import { Asset } from '../entity/asset/asset.entity';
+
+import { AssetInterceptor } from './asset-interceptor';
+
+describe('AssetInterceptor', () => {
+    function testInterceptor<T>(
+        response: T,
+        assertFn: (response: T, result: T, toAbsoluteUrlFn: jest.Mock) => void,
+    ) {
+        return (done: jest.DoneCallback) => {
+            const toAbsoluteUrl = jest.fn().mockReturnValue('visited');
+            const configService = new MockConfigService();
+            configService.assetStorageStrategy = { toAbsoluteUrl };
+            const interceptor = new AssetInterceptor(configService as any);
+            const executionContext = new ExecutionContextHost([0, 0, { req: {} }]);
+            const call$ = of(response);
+
+            interceptor.intercept(executionContext, call$).subscribe(result => {
+                assertFn(response, result, toAbsoluteUrl);
+                done();
+            });
+        };
+    }
+
+    function mockAsset() {
+        return new Asset({ preview: 'original', source: 'original' });
+    }
+
+    it(
+        'passes through responses without Assets',
+        testInterceptor(
+            {
+                foo: 1,
+                bar: {
+                    baz: false,
+                },
+            },
+            (response, result, toAbsoluteUrl) => {
+                expect(result).toBe(response);
+                expect(toAbsoluteUrl).toHaveBeenCalledTimes(0);
+            },
+        ),
+    );
+
+    it(
+        'visits a top-level Asset',
+        testInterceptor(
+            {
+                foo: 1,
+                bar: mockAsset(),
+            },
+            (response, result, toAbsoluteUrl) => {
+                expect(result.bar.source).toBe('visited');
+                expect(result.bar.preview).toBe('visited');
+                expect(toAbsoluteUrl).toHaveBeenCalledTimes(2);
+            },
+        ),
+    );
+
+    it(
+        'visits a top-level array of Assets',
+        testInterceptor(
+            {
+                foo: 1,
+                bar: [mockAsset(), mockAsset()],
+            },
+            (response, result, toAbsoluteUrl) => {
+                expect(result.bar[0].source).toBe('visited');
+                expect(result.bar[0].preview).toBe('visited');
+                expect(result.bar[1].source).toBe('visited');
+                expect(result.bar[1].preview).toBe('visited');
+                expect(toAbsoluteUrl).toHaveBeenCalledTimes(4);
+            },
+        ),
+    );
+
+    it(
+        'visits a nested Asset',
+        testInterceptor(
+            {
+                foo: 1,
+                bar: [
+                    {
+                        baz: {
+                            quux: mockAsset(),
+                        },
+                    },
+                ],
+            },
+            (response, result, toAbsoluteUrl) => {
+                expect(result.bar[0].baz.quux.source).toBe('visited');
+                expect(result.bar[0].baz.quux.preview).toBe('visited');
+                expect(toAbsoluteUrl).toHaveBeenCalledTimes(2);
+            },
+        ),
+    );
+
+    it(
+        'handles null values',
+        testInterceptor(
+            {
+                foo: 1,
+                bar: null,
+            },
+            (response, result, toAbsoluteUrl) => {
+                expect(result).toEqual({ foo: 1, bar: null });
+                expect(toAbsoluteUrl).toHaveBeenCalledTimes(0);
+            },
+        ),
+    );
+
+    it(
+        'handles undefined values',
+        testInterceptor(
+            {
+                foo: 1,
+                bar: undefined,
+            },
+            (response, result, toAbsoluteUrl) => {
+                expect(result).toEqual({ foo: 1, bar: undefined });
+                expect(toAbsoluteUrl).toHaveBeenCalledTimes(0);
+            },
+        ),
+    );
+});

+ 61 - 0
server/src/api/asset-interceptor.ts

@@ -0,0 +1,61 @@
+import { ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
+import { GqlExecutionContext } from '@nestjs/graphql';
+import { Observable } from 'rxjs';
+import { map } from 'rxjs/operators';
+import { Type } from 'shared/shared-types';
+
+import { AssetStorageStrategy } from '../config/asset-storage-strategy/asset-storage-strategy';
+import { ConfigService } from '../config/config.service';
+import { Asset } from '../entity/asset/asset.entity';
+
+/**
+ * Transforms outputs so that any Asset instances are run through the {@link AssetStorageStrategy.toAbsoluteUrl}
+ * method before being returned in the response.
+ */
+@Injectable()
+export class AssetInterceptor implements NestInterceptor {
+    private readonly toAbsoluteUrl: AssetStorageStrategy['toAbsoluteUrl'] | undefined;
+
+    constructor(private configService: ConfigService) {
+        const { assetStorageStrategy } = this.configService;
+        if (assetStorageStrategy.toAbsoluteUrl) {
+            this.toAbsoluteUrl = assetStorageStrategy.toAbsoluteUrl.bind(assetStorageStrategy);
+        }
+    }
+
+    intercept<T = any>(context: ExecutionContext, call$: Observable<T>): Observable<T> {
+        const toAbsoluteUrl = this.toAbsoluteUrl;
+        if (toAbsoluteUrl === undefined) {
+            return call$;
+        }
+        const ctx = GqlExecutionContext.create(context).getContext();
+        const request = ctx.req;
+        return call$.pipe(
+            map(data => {
+                visitType(data, Asset, asset => {
+                    asset.preview = toAbsoluteUrl(request, asset.preview);
+                    asset.source = toAbsoluteUrl(request, asset.source);
+                });
+                return data;
+            }),
+        );
+    }
+}
+
+/**
+ * Traverses the object and when encountering a property with a value which
+ * is an instance of class T, invokes the visitor function on that value.
+ */
+function visitType<T>(obj: any, type: Type<T>, visit: (instance: T) => void) {
+    const keys = Object.keys(obj || {});
+    for (const key of keys) {
+        const value = obj[key];
+        if (value instanceof type) {
+            visit(value);
+        } else {
+            if (typeof value === 'object') {
+                visitType(value, type, visit);
+            }
+        }
+    }
+}

+ 2 - 1
server/src/api/auth/auth.resolver.ts

@@ -1,4 +1,5 @@
 import { Args, Context, Mutation, Query, Resolver } from '@nestjs/graphql';
 import { Args, Context, Mutation, Query, Resolver } from '@nestjs/graphql';
+import { Request } from 'express';
 import { Permission } from 'shared/generated-types';
 import { Permission } from 'shared/generated-types';
 
 
 import { User } from '../../entity/user/user.entity';
 import { User } from '../../entity/user/user.entity';
@@ -31,7 +32,7 @@ export class AuthResolver {
      */
      */
     @Query()
     @Query()
     @Allow(Permission.Authenticated)
     @Allow(Permission.Authenticated)
-    async me(@Context('req') request: any) {
+    async me(@Context('req') request: Request & { user: User }) {
         const user = await this.authService.validateUser(request.user.identifier);
         const user = await this.authService.validateUser(request.user.identifier);
         return user ? this.publiclyAccessibleUser(user) : null;
         return user ? this.publiclyAccessibleUser(user) : null;
     }
     }

+ 3 - 2
server/src/api/common/request-context.service.ts

@@ -1,4 +1,5 @@
 import { Injectable } from '@nestjs/common';
 import { Injectable } from '@nestjs/common';
+import { Request } from 'express';
 
 
 import { ConfigService } from '../../config/config.service';
 import { ConfigService } from '../../config/config.service';
 import { I18nError } from '../../i18n/i18n-error';
 import { I18nError } from '../../i18n/i18n-error';
@@ -16,13 +17,13 @@ export class RequestContextService {
     /**
     /**
      * Creates a new RequestContext based on an Express request object.
      * Creates a new RequestContext based on an Express request object.
      */
      */
-    fromRequest(req: any): RequestContext {
+    fromRequest(req: Request): RequestContext {
         const tokenKey = this.configService.channelTokenKey;
         const tokenKey = this.configService.channelTokenKey;
         let token = '';
         let token = '';
         if (req && req.query && req.query[tokenKey]) {
         if (req && req.query && req.query[tokenKey]) {
             token = req.query[tokenKey];
             token = req.query[tokenKey];
         } else if (req && req.headers && req.headers[tokenKey]) {
         } else if (req && req.headers && req.headers[tokenKey]) {
-            token = req.headers[tokenKey];
+            token = req.headers[tokenKey] as string;
         }
         }
         if (token) {
         if (token) {
             const channel = this.channelService.getChannelFromToken(token);
             const channel = this.channelService.getChannelFromToken(token);

+ 5 - 13
server/src/bootstrap.ts

@@ -1,10 +1,9 @@
 import { INestApplication } from '@nestjs/common';
 import { INestApplication } from '@nestjs/common';
 import { NestFactory } from '@nestjs/core';
 import { NestFactory } from '@nestjs/core';
-import * as path from 'path';
 import { Type } from 'shared/shared-types';
 import { Type } from 'shared/shared-types';
 import { EntitySubscriberInterface } from 'typeorm';
 import { EntitySubscriberInterface } from 'typeorm';
 
 
-import { LocalAssetStorageStrategy } from './config/asset-storage-strategy/local-asset-storage-strategy';
+import { ReadOnlyRequired } from './common/types/common-types';
 import { getConfig, setConfig, VendureConfig } from './config/vendure-config';
 import { getConfig, setConfig, VendureConfig } from './config/vendure-config';
 import { VendureEntity } from './entity/base/base.entity';
 import { VendureEntity } from './entity/base/base.entity';
 import { registerCustomEntityFields } from './entity/custom-entity-fields';
 import { registerCustomEntityFields } from './entity/custom-entity-fields';
@@ -22,16 +21,7 @@ export async function bootstrap(userConfig: Partial<VendureConfig>): Promise<INe
     // tslint:disable-next-line:whitespace
     // tslint:disable-next-line:whitespace
     const appModule = await import('./app.module');
     const appModule = await import('./app.module');
     const app = await NestFactory.create(appModule.AppModule, { cors: config.cors });
     const app = await NestFactory.create(appModule.AppModule, { cors: config.cors });
-
-    // If the LocalAssetStorageStrategy is being used, set up the server to serve
-    // the asset files from the configured directory (defaults to /assets/)
-    const assetStore = config.assetStorageStrategy;
-    if (assetStore instanceof LocalAssetStorageStrategy) {
-        const uploadPath = assetStore.setAbsoluteUploadPath(path.join(__dirname, '..'));
-        app.useStaticAssets(uploadPath, {
-            prefix: `/${assetStore.uploadDir}`,
-        });
-    }
+    await config.assetStorageStrategy.init(app);
 
 
     return app.listen(config.port);
     return app.listen(config.port);
 }
 }
@@ -39,7 +29,9 @@ export async function bootstrap(userConfig: Partial<VendureConfig>): Promise<INe
 /**
 /**
  * Setting the global config must be done prior to loading the AppModule.
  * Setting the global config must be done prior to loading the AppModule.
  */
  */
-export async function preBootstrapConfig(userConfig: Partial<VendureConfig>): Promise<VendureConfig> {
+export async function preBootstrapConfig(
+    userConfig: Partial<VendureConfig>,
+): Promise<ReadOnlyRequired<VendureConfig>> {
     if (userConfig) {
     if (userConfig) {
         setConfig(userConfig);
         setConfig(userConfig);
     }
     }

+ 12 - 2
server/src/config/asset-storage-strategy/asset-storage-strategy.ts

@@ -1,3 +1,5 @@
+import { INestApplication, INestExpressApplication } from '@nestjs/common';
+import { Request } from 'express';
 import { Stream } from 'stream';
 import { Stream } from 'stream';
 
 
 /**
 /**
@@ -5,6 +7,12 @@ import { Stream } from 'stream';
  * and retrieved.
  * and retrieved.
  */
  */
 export interface AssetStorageStrategy {
 export interface AssetStorageStrategy {
+    /**
+     * Perform any setup required on bootstrapping the app, such as registering
+     * a static server or procuring keys from a 3rd-party service.
+     */
+    init(app: INestApplication & INestExpressApplication): Promise<void>;
+
     /**
     /**
      * Writes a buffer to the store and returns a unique identifier for that
      * Writes a buffer to the store and returns a unique identifier for that
      * file such as a file path or a URL.
      * file such as a file path or a URL.
@@ -31,7 +39,9 @@ export interface AssetStorageStrategy {
 
 
     /**
     /**
      * Convert an identifier as generated by the writeFile... methods into an absolute
      * Convert an identifier as generated by the writeFile... methods into an absolute
-     * url (if it is not already in that form)
+     * url (if it is not already in that form). If no conversion step is needed
+     * (i.e. the identifier is already an absolute url) then this method
+     * should not be implemented.
      */
      */
-    toAbsoluteUrl(identifier: string): string;
+    toAbsoluteUrl?(reqest: Request, identifier: string): string;
 }
 }

+ 14 - 4
server/src/config/asset-storage-strategy/local-asset-storage-strategy.ts

@@ -1,3 +1,5 @@
+import { INestApplication, INestExpressApplication } from '@nestjs/common';
+import { Request } from 'express';
 import * as fs from 'fs-extra';
 import * as fs from 'fs-extra';
 import * as path from 'path';
 import * as path from 'path';
 import { Stream } from 'stream';
 import { Stream } from 'stream';
@@ -5,13 +7,22 @@ import { Stream } from 'stream';
 import { AssetStorageStrategy } from './asset-storage-strategy';
 import { AssetStorageStrategy } from './asset-storage-strategy';
 
 
 /**
 /**
- * A persistence strategy which saves files to the local file system.
+ * A persistence strategy which saves files to the local file system and
+ * adds a static route to the server config to serve them.
  */
  */
 export class LocalAssetStorageStrategy implements AssetStorageStrategy {
 export class LocalAssetStorageStrategy implements AssetStorageStrategy {
     private uploadPath: string;
     private uploadPath: string;
 
 
     constructor(public uploadDir: string = 'assets') {}
     constructor(public uploadDir: string = 'assets') {}
 
 
+    async init(app: INestApplication & INestExpressApplication): Promise<any> {
+        // tslint:disable-next-line
+        const uploadPath = this.setAbsoluteUploadPath(path.join(path.basename(require.main!.filename), '..'));
+        app.useStaticAssets(uploadPath, {
+            prefix: `/${this.uploadDir}`,
+        });
+    }
+
     setAbsoluteUploadPath(rootDir: string): string {
     setAbsoluteUploadPath(rootDir: string): string {
         this.uploadPath = path.join(rootDir, this.uploadDir);
         this.uploadPath = path.join(rootDir, this.uploadDir);
         if (!fs.existsSync(this.uploadPath)) {
         if (!fs.existsSync(this.uploadPath)) {
@@ -45,9 +56,8 @@ export class LocalAssetStorageStrategy implements AssetStorageStrategy {
         return Promise.resolve(readStream);
         return Promise.resolve(readStream);
     }
     }
 
 
-    toAbsoluteUrl(identifier: string): string {
-        // TODO: implement this as part of an interceptor.
-        return `http://localhost:3000/${identifier}`;
+    toAbsoluteUrl(request: Request, identifier: string): string {
+        return `${request.protocol}://${request.get('host')}/${identifier}`;
     }
     }
 
 
     private filePathToIdentifier(filePath: string): string {
     private filePathToIdentifier(filePath: string): string {

+ 3 - 0
server/src/config/config.service.mock.ts

@@ -13,6 +13,9 @@ export class MockConfigService implements MockClass<ConfigService> {
     jwtSecret = 'secret';
     jwtSecret = 'secret';
     defaultLanguageCode: jest.Mock<any>;
     defaultLanguageCode: jest.Mock<any>;
     entityIdStrategy = new MockIdStrategy();
     entityIdStrategy = new MockIdStrategy();
+    assetStorageStrategy = {} as any;
+    assetPreviewStrategy = {} as any;
+    uploadMaxFileSize = 1024;
     dbConnectionOptions = {};
     dbConnectionOptions = {};
     customFields = {};
     customFields = {};
 }
 }

+ 1 - 1
server/src/config/vendure-config.ts

@@ -80,7 +80,7 @@ export interface VendureConfig {
     /**
     /**
      * The max file size in bytes for uploaded assets.
      * The max file size in bytes for uploaded assets.
      */
      */
-    uploadMaxFileSize: number;
+    uploadMaxFileSize?: number;
 }
 }
 
 
 const defaultConfig: ReadOnlyRequired<VendureConfig> = {
 const defaultConfig: ReadOnlyRequired<VendureConfig> = {