Просмотр исходного кода

Merge branch 'master' into next

Michael Bromley 5 лет назад
Родитель
Сommit
617e9eb2ea
57 измененных файлов с 550 добавлено и 178 удалено
  1. 25 0
      CHANGELOG.md
  2. 2 0
      docs/content/docs/plugins/writing-a-vendure-plugin.md
  3. 1 1
      lerna.json
  4. 3 0
      package.json
  5. 3 3
      packages/admin-ui-plugin/package.json
  6. 2 2
      packages/admin-ui/package.json
  7. 4 3
      packages/admin-ui/src/lib/catalog/src/components/facet-list/facet-list.component.ts
  8. 2 0
      packages/admin-ui/src/lib/core/src/common/generated-types.ts
  9. 1 1
      packages/admin-ui/src/lib/core/src/common/version.ts
  10. 1 0
      packages/admin-ui/src/lib/core/src/data/definitions/settings-definitions.ts
  11. 4 4
      packages/admin-ui/src/lib/core/src/data/server-config.ts
  12. 8 1
      packages/admin-ui/src/lib/core/src/data/utils/add-custom-fields.ts
  13. 4 4
      packages/admin-ui/src/lib/core/src/shared/components/asset-file-input/asset-file-input.component.html
  14. 7 1
      packages/admin-ui/src/lib/core/src/shared/components/asset-file-input/asset-file-input.component.ts
  15. 3 0
      packages/admin-ui/src/lib/core/src/shared/components/formatted-address/formatted-address.component.html
  16. 4 0
      packages/admin-ui/src/lib/core/src/shared/components/formatted-address/formatted-address.component.scss
  17. 11 0
      packages/admin-ui/src/lib/core/src/shared/components/formatted-address/formatted-address.component.ts
  18. 7 7
      packages/admin-ui/src/lib/customer/src/components/customer-detail/customer-detail.component.html
  19. 20 20
      packages/admin-ui/src/lib/customer/src/components/customer-detail/customer-detail.component.ts
  20. 1 0
      packages/asset-server-plugin/e2e/graphql/generated-e2e-asset-server-plugin-types.ts
  21. 3 3
      packages/asset-server-plugin/package.json
  22. 1 1
      packages/common/package.json
  23. 1 0
      packages/common/src/generated-shop-types.ts
  24. 1 0
      packages/common/src/generated-types.ts
  25. 84 35
      packages/core/e2e/asset.e2e-spec.ts
  26. 2 0
      packages/core/e2e/fixtures/assets/dummy.pdf
  27. 1 0
      packages/core/e2e/fixtures/assets/dummy.txt
  28. 1 0
      packages/core/e2e/graphql/generated-e2e-admin-types.ts
  29. 1 0
      packages/core/e2e/graphql/generated-e2e-shop-types.ts
  30. 2 2
      packages/core/package.json
  31. 15 18
      packages/core/src/api/common/graphql-value-transformer.ts
  32. 22 0
      packages/core/src/api/config/__snapshots__/graphql-custom-fields.spec.ts.snap
  33. 18 0
      packages/core/src/api/config/graphql-custom-fields.spec.ts
  34. 10 0
      packages/core/src/api/config/graphql-custom-fields.ts
  35. 4 12
      packages/core/src/api/middleware/asset-interceptor-plugin.ts
  36. 5 0
      packages/core/src/api/resolvers/admin/asset.resolver.ts
  37. 2 0
      packages/core/src/api/resolvers/admin/global-settings.resolver.ts
  38. 1 0
      packages/core/src/api/schema/type/global-settings.type.graphql
  39. 55 8
      packages/core/src/bootstrap.ts
  40. 8 1
      packages/core/src/config/default-config.ts
  41. 102 0
      packages/core/src/config/vendure-config.ts
  42. 1 0
      packages/core/src/i18n/messages/en.json
  43. 36 5
      packages/core/src/service/services/asset.service.ts
  44. 3 3
      packages/create/package.json
  45. 15 8
      packages/create/src/create-vendure-app.ts
  46. 0 1
      packages/create/src/gather-user-responses.ts
  47. 1 2
      packages/create/templates/vendure-config.hbs
  48. 1 0
      packages/dev-server/index-worker.ts
  49. 1 0
      packages/dev-server/index.ts
  50. 9 9
      packages/dev-server/package.json
  51. 1 0
      packages/elasticsearch-plugin/e2e/graphql/generated-e2e-elasticsearch-plugin-types.ts
  52. 3 3
      packages/elasticsearch-plugin/package.json
  53. 3 3
      packages/email-plugin/package.json
  54. 17 9
      packages/email-plugin/src/default-email-handlers.ts
  55. 3 3
      packages/testing/package.json
  56. 0 1
      packages/testing/src/simple-graphql-client.ts
  57. 4 4
      packages/ui-devkit/package.json

+ 25 - 0
CHANGELOG.md

@@ -1,3 +1,28 @@
+## <small>0.14.1 (2020-08-18)</small>
+
+
+#### Fixes
+
+* **admin-ui** Fix notification for customer verification email ([6c76ebe](https://github.com/vendure-ecommerce/vendure/commit/6c76ebe)), closes [#438](https://github.com/vendure-ecommerce/vendure/issues/438)
+* **admin-ui** Make emailAddress required in CustomerDetail form ([2a9ee2e](https://github.com/vendure-ecommerce/vendure/commit/2a9ee2e)), closes [#438](https://github.com/vendure-ecommerce/vendure/issues/438)
+* **admin-ui** Update facets cache after deletion ([f4eec6a](https://github.com/vendure-ecommerce/vendure/commit/f4eec6a)), closes [#424](https://github.com/vendure-ecommerce/vendure/issues/424)
+* **core** Correct shipping calculator typing ([18f5bcd](https://github.com/vendure-ecommerce/vendure/commit/18f5bcd))
+* **core** Correctly handle aliases when transforming Asset urls ([18bbeee](https://github.com/vendure-ecommerce/vendure/commit/18bbeee)), closes [#417](https://github.com/vendure-ecommerce/vendure/issues/417)
+* **email-plugin** Add filter of emailVerificationHandler ([a68b18e](https://github.com/vendure-ecommerce/vendure/commit/a68b18e)), closes [#438](https://github.com/vendure-ecommerce/vendure/issues/438)
+
+#### Features
+
+* **admin-ui** Add Address custom fields to order detail ([c4ca2d0](https://github.com/vendure-ecommerce/vendure/commit/c4ca2d0)), closes [#409](https://github.com/vendure-ecommerce/vendure/issues/409)
+* **admin-ui** Restrict Asset input based on permitted file types ([dc668d9](https://github.com/vendure-ecommerce/vendure/commit/dc668d9)), closes [#421](https://github.com/vendure-ecommerce/vendure/issues/421)
+* **asset-server-plugin** Extended S3Config to accept aws-sdk configuration properties ([ce903ad](https://github.com/vendure-ecommerce/vendure/commit/ce903ad))
+* **core** Add Address custom fields to OrderAddress ([6f35493](https://github.com/vendure-ecommerce/vendure/commit/6f35493)), closes [#409](https://github.com/vendure-ecommerce/vendure/issues/409)
+* **core** Custom field length configuration for localeString ([9fab7e8](https://github.com/vendure-ecommerce/vendure/commit/9fab7e8))
+* **core** Expose all cookie options in VendureConfig ([ad089ea](https://github.com/vendure-ecommerce/vendure/commit/ad089ea)), closes [#436](https://github.com/vendure-ecommerce/vendure/issues/436)
+* **core** Expose permitted Asset types in ServerConfig type ([66abc7f](https://github.com/vendure-ecommerce/vendure/commit/66abc7f)), closes [#421](https://github.com/vendure-ecommerce/vendure/issues/421)
+* **core** Implement permitted mime types for Assets ([272b2db](https://github.com/vendure-ecommerce/vendure/commit/272b2db)), closes [#421](https://github.com/vendure-ecommerce/vendure/issues/421)
+* **core** Validate DB table structure on worker bootstrap ([c1ccaa1](https://github.com/vendure-ecommerce/vendure/commit/c1ccaa1))
+* **core** Verbose query error logging (#433) ([8cf7483](https://github.com/vendure-ecommerce/vendure/commit/8cf7483)), closes [#433](https://github.com/vendure-ecommerce/vendure/issues/433)
+
 ## 0.14.0 (2020-07-20)
 
 

+ 2 - 0
docs/content/docs/plugins/writing-a-vendure-plugin.md

@@ -9,6 +9,8 @@ This is a complete example of how to implement a simple plugin step-by-step.
 
 {{< alert "primary" >}}
   For a complete working example of a Vendure plugin, see the [real-world-vendure Reviews plugin](https://github.com/vendure-ecommerce/real-world-vendure/tree/master/src/plugins/reviews)
+  
+  If you intend to write a shared plugin to be distributed as an npm package, see the [vendure plugin-template repo](https://github.com/vendure-ecommerce/plugin-template)
 {{< /alert >}}
 
 ## Example: RandomCatPlugin

+ 1 - 1
lerna.json

@@ -2,7 +2,7 @@
   "packages": [
     "packages/*"
   ],
-  "version": "0.14.0",
+  "version": "0.14.1",
   "npmClient": "yarn",
   "useWorkspaces": true,
   "command": {

+ 3 - 0
package.json

@@ -55,6 +55,9 @@
     "tslint": "^5.11.0",
     "typescript": "3.8.3"
   },
+  "resolutions": {
+    "npm-packlist": "1.1.12"
+  },
   "workspaces": {
     "packages": [
       "packages/*"

+ 3 - 3
packages/admin-ui-plugin/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@vendure/admin-ui-plugin",
-  "version": "0.14.0",
+  "version": "0.14.1",
   "main": "lib/index.js",
   "types": "lib/index.d.ts",
   "files": [
@@ -19,8 +19,8 @@
   "devDependencies": {
     "@types/express": "^4.0.39",
     "@types/fs-extra": "^8.0.1",
-    "@vendure/common": "^0.14.0",
-    "@vendure/core": "^0.14.0",
+    "@vendure/common": "^0.14.1",
+    "@vendure/core": "^0.14.1",
     "express": "^4.16.4",
     "rimraf": "^3.0.0",
     "typescript": "3.8.3"

+ 2 - 2
packages/admin-ui/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@vendure/admin-ui",
-  "version": "0.14.0",
+  "version": "0.14.1",
   "license": "MIT",
   "scripts": {
     "ng": "ng",
@@ -35,7 +35,7 @@
     "@ng-select/ng-select": "^3.7.2",
     "@ngx-translate/core": "^12.1.2",
     "@ngx-translate/http-loader": "^4.0.0",
-    "@vendure/common": "^0.14.0",
+    "@vendure/common": "^0.14.1",
     "@webcomponents/custom-elements": "^1.2.4",
     "apollo-angular": "^1.8.0",
     "apollo-cache-inmemory": "^1.6.5",

+ 4 - 3
packages/admin-ui/src/lib/catalog/src/components/facet-list/facet-list.component.ts

@@ -1,14 +1,13 @@
 import { Component } from '@angular/core';
 import { ActivatedRoute, Router } from '@angular/router';
 import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker';
-import { EMPTY } from 'rxjs';
-import { map, switchMap } from 'rxjs/operators';
-
 import { BaseListComponent } from '@vendure/admin-ui/core';
 import { DeletionResult, GetFacetList } from '@vendure/admin-ui/core';
 import { NotificationService } from '@vendure/admin-ui/core';
 import { DataService } from '@vendure/admin-ui/core';
 import { ModalService } from '@vendure/admin-ui/core';
+import { EMPTY } from 'rxjs';
+import { map, switchMap } from 'rxjs/operators';
 
 @Component({
     selector: 'vdr-facet-list',
@@ -52,6 +51,8 @@ export class FacetListComponent extends BaseListComponent<GetFacetList.Query, Ge
                         );
                     }
                 }),
+                // Refresh the cached facets to reflect the changes
+                switchMap(() => this.dataService.facet.getAllFacets(true).single$),
             )
             .subscribe(
                 () => {

+ 2 - 0
packages/admin-ui/src/lib/core/src/common/generated-types.ts

@@ -3405,6 +3405,7 @@ export type SearchResultSortParameter = {
 export type ServerConfig = {
    __typename?: 'ServerConfig';
   orderProcess: Array<OrderProcessState>;
+  permittedAssetTypes: Array<Scalars['String']>;
   customFieldConfig: CustomFields;
 };
 
@@ -6414,6 +6415,7 @@ export type GetServerConfigQuery = (
     { __typename?: 'GlobalSettings' }
     & { serverConfig: (
       { __typename?: 'ServerConfig' }
+      & Pick<ServerConfig, 'permittedAssetTypes'>
       & { orderProcess: Array<(
         { __typename?: 'OrderProcessState' }
         & Pick<OrderProcessState, 'name' | 'to'>

+ 1 - 1
packages/admin-ui/src/lib/core/src/common/version.ts

@@ -1,2 +1,2 @@
 // Auto-generated by the set-version.js script.
-export const ADMIN_UI_VERSION = '0.14.0';
+export const ADMIN_UI_VERSION = '0.14.1';

+ 1 - 0
packages/admin-ui/src/lib/core/src/data/definitions/settings-definitions.ts

@@ -547,6 +547,7 @@ export const GET_SERVER_CONFIG = gql`
                     name
                     to
                 }
+                permittedAssetTypes
                 customFieldConfig {
                     Address {
                         ...CustomFields

+ 4 - 4
packages/admin-ui/src/lib/core/src/data/server-config.ts

@@ -47,10 +47,10 @@ export class ServerConfigService {
             .query<GetServerConfig.Query>(GET_SERVER_CONFIG)
             .single$.toPromise()
             .then(
-                (result) => {
+                result => {
                     this._serverConfig = result.globalSettings.serverConfig;
                 },
-                (err) => {
+                err => {
                     // Let the error fall through to be caught by the http interceptor.
                 },
             );
@@ -59,11 +59,11 @@ export class ServerConfigService {
     getAvailableLanguages() {
         return this.baseDataService
             .query<GetGlobalSettings.Query>(GET_GLOBAL_SETTINGS, {}, 'cache-first')
-            .mapSingle((res) => res.globalSettings.availableLanguages);
+            .mapSingle(res => res.globalSettings.availableLanguages);
     }
 
     /**
-     * When any of the GLobalSettings are modified, this method should be called to update the Apollo cache.
+     * When any of the GlobalSettings are modified, this method should be called to update the Apollo cache.
      */
     refreshGlobalSettings() {
         return this.baseDataService.query<GetGlobalSettings.Query>(GET_GLOBAL_SETTINGS, {}, 'network-only')

+ 8 - 1
packages/admin-ui/src/lib/core/src/data/utils/add-custom-fields.ts

@@ -17,10 +17,17 @@ export function addCustomFields(documentNode: DocumentNode, customFields: Custom
     const fragmentDefs = documentNode.definitions.filter(isFragmentDefinition);
 
     for (const fragmentDef of fragmentDefs) {
-        const entityType = fragmentDef.typeCondition.name.value as keyof Pick<
+        let entityType = fragmentDef.typeCondition.name.value as keyof Pick<
             CustomFields,
             Exclude<keyof CustomFields, '__typename'>
         >;
+
+        if (entityType === ('OrderAddress' as any)) {
+            // OrderAddress is a special case of the Address entity, and shares its custom fields
+            // so we treat it as an alias
+            entityType = 'Address';
+        }
+
         const customFieldsForType = customFields[entityType];
         if (customFieldsForType && customFieldsForType.length) {
             (fragmentDef.selectionSet.selections as SelectionNode[]).push({

+ 4 - 4
packages/admin-ui/src/lib/core/src/shared/components/asset-file-input/asset-file-input.component.html

@@ -1,12 +1,12 @@
-<input type="file" class="file-input" #fileInput (change)="select($event)" multiple />
+<input type="file" class="file-input" #fileInput (change)="select($event)" multiple [accept]="accept" />
 <button class="btn btn-primary" (click)="fileInput.click()" [disabled]="uploading">
-    <ng-container *ngIf="uploading; else selectable" >
+    <ng-container *ngIf="uploading; else selectable">
         <clr-spinner clrInline></clr-spinner>
         {{ 'asset.uploading' | translate }}
     </ng-container>
     <ng-template #selectable>
-    <clr-icon shape="upload-cloud"></clr-icon>
-    {{ 'asset.upload-assets' | translate }}
+        <clr-icon shape="upload-cloud"></clr-icon>
+        {{ 'asset.upload-assets' | translate }}
     </ng-template>
 </button>
 <div

+ 7 - 1
packages/admin-ui/src/lib/core/src/shared/components/asset-file-input/asset-file-input.component.ts

@@ -9,6 +9,8 @@ import {
 } from '@angular/core';
 import { notNullOrUndefined } from '@vendure/common/lib/shared-utils';
 
+import { ServerConfigService } from '../../../data/server-config';
+
 /**
  * A component for selecting files to upload as new Assets.
  */
@@ -34,8 +36,12 @@ export class AssetFileInputComponent implements OnInit {
         'top.px': 0,
         'left.px': 0,
     };
+    accept: string;
+
+    constructor(private serverConfig: ServerConfigService) {}
 
     ngOnInit() {
+        this.accept = this.serverConfig.serverConfig.permittedAssetTypes.join(',');
         this.fitDropZoneToTarget();
     }
 
@@ -65,7 +71,7 @@ export class AssetFileInputComponent implements OnInit {
         this.dragging = false;
         this.overDropZone = false;
         const files = Array.from(event.dataTransfer ? event.dataTransfer.items : [])
-            .map((i) => i.getAsFile())
+            .map(i => i.getAsFile())
             .filter(notNullOrUndefined);
         this.selectFiles.emit(files);
     }

+ 3 - 0
packages/admin-ui/src/lib/core/src/shared/components/formatted-address/formatted-address.component.html

@@ -13,4 +13,7 @@
         <clr-icon shape="phone-handset" size="12"></clr-icon>
         {{ address.phoneNumber }}
     </li>
+    <li *ngFor="let customField of getCustomFields()" class="custom-field">
+        <vdr-labeled-data [label]="customField.key">{{ customField.value }}</vdr-labeled-data>
+    </li>
 </ul>

+ 4 - 0
packages/admin-ui/src/lib/core/src/shared/components/formatted-address/formatted-address.component.scss

@@ -4,3 +4,7 @@
     list-style-type: none;
     line-height: 1.2em;
 }
+
+.custom-field {
+    margin-top: 6px;
+}

+ 11 - 0
packages/admin-ui/src/lib/core/src/shared/components/formatted-address/formatted-address.component.ts

@@ -19,6 +19,17 @@ export class FormattedAddressComponent {
         }
     }
 
+    getCustomFields(): Array<{ key: string; value: any }> {
+        const customFields = (this.address as any).customFields;
+        if (customFields) {
+            return Object.entries(customFields)
+                .filter(([key]) => key !== '__typename')
+                .map(([key, value]) => ({ key, value: (value as any)?.toString() ?? '-' }));
+        } else {
+            return [];
+        }
+    }
+
     private isAddressFragment(input: AddressFragment | OrderAddress): input is AddressFragment {
         return typeof input.country !== 'string';
     }

+ 7 - 7
packages/admin-ui/src/lib/customer/src/components/customer-detail/customer-detail.component.html

@@ -49,13 +49,6 @@
     >
         <input id="lastName" type="text" formControlName="lastName" />
     </vdr-form-field>
-    <vdr-form-field
-        [label]="'customer.phone-number' | translate"
-        for="phoneNumber"
-        [readOnlyToggle]="!(isNew$ | async)"
-    >
-        <input id="phoneNumber" type="text" formControlName="phoneNumber" />
-    </vdr-form-field>
     <vdr-form-field
         [label]="'customer.email-address' | translate"
         for="emailAddress"
@@ -63,6 +56,13 @@
     >
         <input id="emailAddress" type="text" formControlName="emailAddress" />
     </vdr-form-field>
+    <vdr-form-field
+           [label]="'customer.phone-number' | translate"
+           for="phoneNumber"
+           [readOnlyToggle]="!(isNew$ | async)"
+       >
+           <input id="phoneNumber" type="text" formControlName="phoneNumber" />
+       </vdr-form-field>
     <vdr-form-field [label]="'customer.password' | translate" for="password" *ngIf="isNew$ | async">
         <input id="password" type="password" formControlName="password" />
     </vdr-form-field>

+ 20 - 20
packages/admin-ui/src/lib/customer/src/components/customer-detail/customer-detail.component.ts

@@ -80,7 +80,7 @@ export class CustomerDetailComponent extends BaseDetailComponent<CustomerWithOrd
                 firstName: ['', Validators.required],
                 lastName: ['', Validators.required],
                 phoneNumber: '',
-                emailAddress: '',
+                emailAddress: ['', [Validators.required, Validators.email]],
                 password: '',
                 customFields: this.formBuilder.group(
                     this.customFields.reduce((hash, field) => ({ ...hash, [field.name]: '' }), {}),
@@ -94,12 +94,12 @@ export class CustomerDetailComponent extends BaseDetailComponent<CustomerWithOrd
         this.init();
         this.availableCountries$ = this.dataService.settings
             .getAvailableCountries()
-            .mapSingle((result) => result.countries.items)
+            .mapSingle(result => result.countries.items)
             .pipe(shareReplay(1));
 
         const customerWithUpdates$ = this.entity$.pipe(merge(this.orderListUpdates$));
-        this.orders$ = customerWithUpdates$.pipe(map((customer) => customer.orders.items));
-        this.ordersCount$ = this.entity$.pipe(map((customer) => customer.orders.totalItems));
+        this.orders$ = customerWithUpdates$.pipe(map(customer => customer.orders.items));
+        this.ordersCount$ = this.entity$.pipe(map(customer => customer.orders.totalItems));
         this.history$ = this.fetchHistory.pipe(
             startWith(null),
             switchMap(() => {
@@ -109,7 +109,7 @@ export class CustomerDetailComponent extends BaseDetailComponent<CustomerWithOrd
                             createdAt: SortOrder.DESC,
                         },
                     })
-                    .mapStream((data) => data.customer?.history.items);
+                    .mapStream(data => data.customer?.history.items);
             }),
         );
     }
@@ -182,11 +182,11 @@ export class CustomerDetailComponent extends BaseDetailComponent<CustomerWithOrd
             customFields,
         };
         this.dataService.customer.createCustomer(customer, formValue.password).subscribe(
-            (data) => {
+            data => {
                 this.notificationService.success(_('common.notify-create-success'), {
                     entity: 'Customer',
                 });
-                if (!formValue.password) {
+                if (data.createCustomer.emailAddress && !formValue.password) {
                     this.notificationService.notify({
                         message: _('customer.email-verification-sent'),
                         translationVars: { emailAddress: formValue.emailAddress },
@@ -199,7 +199,7 @@ export class CustomerDetailComponent extends BaseDetailComponent<CustomerWithOrd
                 this.changeDetector.markForCheck();
                 this.router.navigate(['../', data.createCustomer.id], { relativeTo: this.route });
             },
-            (err) => {
+            err => {
                 this.notificationService.error(_('common.notify-create-error'), {
                     entity: 'Customer',
                 });
@@ -265,7 +265,7 @@ export class CustomerDetailComponent extends BaseDetailComponent<CustomerWithOrd
                 }),
             )
             .subscribe(
-                (data) => {
+                data => {
                     this.notificationService.success(_('common.notify-update-success'), {
                         entity: 'Customer',
                     });
@@ -274,7 +274,7 @@ export class CustomerDetailComponent extends BaseDetailComponent<CustomerWithOrd
                     this.changeDetector.markForCheck();
                     this.fetchHistory.next();
                 },
-                (err) => {
+                err => {
                     this.notificationService.error(_('common.notify-update-error'), {
                         entity: 'Customer',
                     });
@@ -288,11 +288,11 @@ export class CustomerDetailComponent extends BaseDetailComponent<CustomerWithOrd
                 size: 'md',
             })
             .pipe(
-                switchMap((groupIds) => (groupIds ? from(groupIds) : EMPTY)),
-                concatMap((groupId) => this.dataService.customer.addCustomersToGroup(groupId, [this.id])),
+                switchMap(groupIds => (groupIds ? from(groupIds) : EMPTY)),
+                concatMap(groupId => this.dataService.customer.addCustomersToGroup(groupId, [this.id])),
             )
             .subscribe({
-                next: (res) => {
+                next: res => {
                     this.notificationService.success(_(`customer.add-customers-to-group-success`), {
                         customerCount: 1,
                         groupName: res.addCustomersToGroup.name,
@@ -315,14 +315,14 @@ export class CustomerDetailComponent extends BaseDetailComponent<CustomerWithOrd
                 ],
             })
             .pipe(
-                switchMap((response) =>
+                switchMap(response =>
                     response
                         ? this.dataService.customer.removeCustomersFromGroup(group.id, [this.id])
                         : EMPTY,
                 ),
                 switchMap(() => this.dataService.customer.getCustomer(this.id, { take: 0 }).single$),
             )
-            .subscribe((result) => {
+            .subscribe(result => {
                 this.notificationService.success(_(`customer.remove-customers-from-group-success`), {
                     customerCount: 1,
                     groupName: group.name,
@@ -350,7 +350,7 @@ export class CustomerDetailComponent extends BaseDetailComponent<CustomerWithOrd
                 },
             })
             .pipe(
-                switchMap((result) => {
+                switchMap(result => {
                     if (result) {
                         return this.dataService.customer.updateCustomerNote({
                             noteId: entry.id,
@@ -361,7 +361,7 @@ export class CustomerDetailComponent extends BaseDetailComponent<CustomerWithOrd
                     }
                 }),
             )
-            .subscribe((result) => {
+            .subscribe(result => {
                 this.fetchHistory.next();
                 this.notificationService.success(_('common.notify-update-success'), {
                     entity: 'Note',
@@ -379,7 +379,7 @@ export class CustomerDetailComponent extends BaseDetailComponent<CustomerWithOrd
                     { type: 'danger', label: _('common.delete'), returnValue: true },
                 ],
             })
-            .pipe(switchMap((res) => (res ? this.dataService.customer.deleteCustomerNote(entry.id) : EMPTY)))
+            .pipe(switchMap(res => (res ? this.dataService.customer.deleteCustomerNote(entry.id) : EMPTY)))
             .subscribe(() => {
                 this.fetchHistory.next();
                 this.notificationService.success(_('common.notify-delete-success'), {
@@ -441,9 +441,9 @@ export class CustomerDetailComponent extends BaseDetailComponent<CustomerWithOrd
                 skip: (this.currentOrdersPage - 1) * this.ordersPerPage,
             })
             .single$.pipe(
-                map((data) => data.customer),
+                map(data => data.customer),
                 filter(notNullOrUndefined),
             )
-            .subscribe((result) => this.orderListUpdates$.next(result));
+            .subscribe(result => this.orderListUpdates$.next(result));
     }
 }

+ 1 - 0
packages/asset-server-plugin/e2e/graphql/generated-e2e-asset-server-plugin-types.ts

@@ -3235,6 +3235,7 @@ export type SearchResultSortParameter = {
 export type ServerConfig = {
     __typename?: 'ServerConfig';
     orderProcess: Array<OrderProcessState>;
+    permittedAssetTypes: Array<Scalars['String']>;
     customFieldConfig: CustomFields;
 };
 

+ 3 - 3
packages/asset-server-plugin/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@vendure/asset-server-plugin",
-  "version": "0.14.0",
+  "version": "0.14.1",
   "main": "lib/index.js",
   "types": "lib/index.d.ts",
   "files": [
@@ -22,8 +22,8 @@
     "@types/fs-extra": "^8.0.1",
     "@types/node-fetch": "^2.5.4",
     "@types/sharp": "^0.24.0",
-    "@vendure/common": "^0.14.0",
-    "@vendure/core": "^0.14.0",
+    "@vendure/common": "^0.14.1",
+    "@vendure/core": "^0.14.1",
     "aws-sdk": "^2.670.0",
     "express": "^4.16.4",
     "node-fetch": "^2.6.0",

+ 1 - 1
packages/common/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@vendure/common",
-  "version": "0.14.0",
+  "version": "0.14.1",
   "main": "index.js",
   "license": "MIT",
   "scripts": {

+ 1 - 0
packages/common/src/generated-shop-types.ts

@@ -2208,6 +2208,7 @@ export type SearchResultSortParameter = {
 export type ServerConfig = {
     __typename?: 'ServerConfig';
     orderProcess: Array<OrderProcessState>;
+    permittedAssetTypes: Array<Scalars['String']>;
     customFieldConfig: CustomFields;
 };
 

+ 1 - 0
packages/common/src/generated-types.ts

@@ -3362,6 +3362,7 @@ export type SearchResultSortParameter = {
 export type ServerConfig = {
    __typename?: 'ServerConfig';
   orderProcess: Array<OrderProcessState>;
+  permittedAssetTypes: Array<Scalars['String']>;
   customFieldConfig: CustomFields;
 };
 

+ 84 - 35
packages/core/e2e/asset.e2e-spec.ts

@@ -1,11 +1,12 @@
 /* tslint:disable:no-non-null-assertion */
 import { omit } from '@vendure/common/lib/omit';
+import { mergeConfig } from '@vendure/core';
 import { createTestEnvironment } from '@vendure/testing';
 import gql from 'graphql-tag';
 import path from 'path';
 
 import { initialData } from '../../../e2e-common/e2e-initial-data';
-import { TEST_SETUP_TIMEOUT_MS, testConfig } from '../../../e2e-common/test-config';
+import { testConfig, TEST_SETUP_TIMEOUT_MS } from '../../../e2e-common/test-config';
 
 import { ASSET_FRAGMENT } from './graphql/fragments';
 import {
@@ -24,9 +25,16 @@ import {
     GET_PRODUCT_WITH_VARIANTS,
     UPDATE_ASSET,
 } from './graphql/shared-definitions';
+import { assertThrowsWithMessage } from './utils/assert-throws-with-message';
 
 describe('Asset resolver', () => {
-    const { server, adminClient } = createTestEnvironment(testConfig);
+    const { server, adminClient } = createTestEnvironment(
+        mergeConfig(testConfig, {
+            assetOptions: {
+                permittedFileTypes: ['image/*', '.pdf'],
+            },
+        }),
+    );
 
     let firstAssetId: string;
     let createdAssetId: string;
@@ -57,7 +65,7 @@ describe('Asset resolver', () => {
         );
 
         expect(assets.totalItems).toBe(4);
-        expect(assets.items.map((a) => omit(a, ['id']))).toEqual([
+        expect(assets.items.map(a => omit(a, ['id']))).toEqual([
             {
                 fileSize: 1680,
                 mimeType: 'image/jpeg',
@@ -113,41 +121,82 @@ describe('Asset resolver', () => {
         });
     });
 
-    it('createAssets', async () => {
-        const filesToUpload = [
-            path.join(__dirname, 'fixtures/assets/pps1.jpg'),
-            path.join(__dirname, 'fixtures/assets/pps2.jpg'),
-        ];
-        const { createAssets }: CreateAssets.Mutation = await adminClient.fileUploadMutation({
-            mutation: CREATE_ASSETS,
-            filePaths: filesToUpload,
-            mapVariables: (filePaths) => ({
-                input: filePaths.map((p) => ({ file: null })),
-            }),
+    describe('createAssets', () => {
+        it('permitted types by mime type', async () => {
+            const filesToUpload = [
+                path.join(__dirname, 'fixtures/assets/pps1.jpg'),
+                path.join(__dirname, 'fixtures/assets/pps2.jpg'),
+            ];
+            const { createAssets }: CreateAssets.Mutation = await adminClient.fileUploadMutation({
+                mutation: CREATE_ASSETS,
+                filePaths: filesToUpload,
+                mapVariables: filePaths => ({
+                    input: filePaths.map(p => ({ file: null })),
+                }),
+            });
+
+            expect(createAssets.map(a => omit(a, ['id'])).sort((a, b) => (a.name < b.name ? -1 : 1))).toEqual(
+                [
+                    {
+                        fileSize: 1680,
+                        focalPoint: null,
+                        mimeType: 'image/jpeg',
+                        name: 'pps1.jpg',
+                        preview: 'test-url/test-assets/pps1__preview.jpg',
+                        source: 'test-url/test-assets/pps1.jpg',
+                        type: 'IMAGE',
+                    },
+                    {
+                        fileSize: 1680,
+                        focalPoint: null,
+                        mimeType: 'image/jpeg',
+                        name: 'pps2.jpg',
+                        preview: 'test-url/test-assets/pps2__preview.jpg',
+                        source: 'test-url/test-assets/pps2.jpg',
+                        type: 'IMAGE',
+                    },
+                ],
+            );
+
+            createdAssetId = createAssets[0].id;
         });
 
-        expect(createAssets.map((a) => omit(a, ['id'])).sort((a, b) => (a.name < b.name ? -1 : 1))).toEqual([
-            {
-                fileSize: 1680,
-                focalPoint: null,
-                mimeType: 'image/jpeg',
-                name: 'pps1.jpg',
-                preview: 'test-url/test-assets/pps1__preview.jpg',
-                source: 'test-url/test-assets/pps1.jpg',
-                type: 'IMAGE',
-            },
-            {
-                fileSize: 1680,
-                focalPoint: null,
-                mimeType: 'image/jpeg',
-                name: 'pps2.jpg',
-                preview: 'test-url/test-assets/pps2__preview.jpg',
-                source: 'test-url/test-assets/pps2.jpg',
-                type: 'IMAGE',
-            },
-        ]);
+        it('permitted type by file extension', async () => {
+            const filesToUpload = [path.join(__dirname, 'fixtures/assets/dummy.pdf')];
+            const { createAssets }: CreateAssets.Mutation = await adminClient.fileUploadMutation({
+                mutation: CREATE_ASSETS,
+                filePaths: filesToUpload,
+                mapVariables: filePaths => ({
+                    input: filePaths.map(p => ({ file: null })),
+                }),
+            });
+
+            expect(createAssets.map(a => omit(a, ['id']))).toEqual([
+                {
+                    fileSize: 1680,
+                    focalPoint: null,
+                    mimeType: 'application/pdf',
+                    name: 'dummy.pdf',
+                    preview: 'test-url/test-assets/dummy__preview.pdf.png',
+                    source: 'test-url/test-assets/dummy.pdf',
+                    type: 'BINARY',
+                },
+            ]);
+        });
 
-        createdAssetId = createAssets[0].id;
+        it(
+            'not permitted type',
+            assertThrowsWithMessage(async () => {
+                const filesToUpload = [path.join(__dirname, 'fixtures/assets/dummy.txt')];
+                const { createAssets }: CreateAssets.Mutation = await adminClient.fileUploadMutation({
+                    mutation: CREATE_ASSETS,
+                    filePaths: filesToUpload,
+                    mapVariables: filePaths => ({
+                        input: filePaths.map(p => ({ file: null })),
+                    }),
+                });
+            }, `The MIME type 'text/plain' is not permitted.`),
+        );
     });
 
     describe('updateAsset', () => {

+ 2 - 0
packages/core/e2e/fixtures/assets/dummy.pdf

@@ -0,0 +1,2 @@
+Dummy PDF file
+

+ 1 - 0
packages/core/e2e/fixtures/assets/dummy.txt

@@ -0,0 +1 @@
+hi!

+ 1 - 0
packages/core/e2e/graphql/generated-e2e-admin-types.ts

@@ -3235,6 +3235,7 @@ export type SearchResultSortParameter = {
 export type ServerConfig = {
     __typename?: 'ServerConfig';
     orderProcess: Array<OrderProcessState>;
+    permittedAssetTypes: Array<Scalars['String']>;
     customFieldConfig: CustomFields;
 };
 

+ 1 - 0
packages/core/e2e/graphql/generated-e2e-shop-types.ts

@@ -2208,6 +2208,7 @@ export type SearchResultSortParameter = {
 export type ServerConfig = {
     __typename?: 'ServerConfig';
     orderProcess: Array<OrderProcessState>;
+    permittedAssetTypes: Array<Scalars['String']>;
     customFieldConfig: CustomFields;
 };
 

+ 2 - 2
packages/core/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@vendure/core",
-  "version": "0.14.0",
+  "version": "0.14.1",
   "description": "A modern, headless ecommerce framework",
   "repository": {
     "type": "git",
@@ -47,7 +47,7 @@
     "@nestjs/testing": "7.0.5",
     "@nestjs/typeorm": "7.0.0",
     "@types/fs-extra": "^8.0.1",
-    "@vendure/common": "^0.14.0",
+    "@vendure/common": "^0.14.1",
     "apollo-server-express": "2.11.0",
     "bcrypt": "^4.0.1",
     "body-parser": "^1.19.0",

+ 15 - 18
packages/core/src/api/common/graphql-value-transformer.ts

@@ -90,7 +90,7 @@ export class GraphqlValueTransformer {
                         parent: currentNode,
                         children: {},
                     };
-                    currentNode.children[fieldDef.name] = newNode;
+                    currentNode.children[node.alias?.value ?? node.name.value] = newNode;
                     currentNode = newNode;
                 }
                 if (node.kind === 'FragmentSpread') {
@@ -186,23 +186,20 @@ export class GraphqlValueTransformer {
         inputType: GraphQLInputObjectType,
         parent: TypeTreeNode,
     ): { [name: string]: TypeTreeNode } {
-        return Object.entries(inputType.getFields()).reduce(
-            (result, [key, field]) => {
-                const namedType = getNamedType(field.type);
-                const child: TypeTreeNode = {
-                    type: namedType,
-                    isList: this.isList(field.type),
-                    parent,
-                    fragmentRefs: [],
-                    children: {},
-                };
-                if (isInputObjectType(namedType)) {
-                    child.children = this.getChildrenTreeNodes(namedType, child);
-                }
-                return { ...result, [key]: child };
-            },
-            {} as { [name: string]: TypeTreeNode },
-        );
+        return Object.entries(inputType.getFields()).reduce((result, [key, field]) => {
+            const namedType = getNamedType(field.type);
+            const child: TypeTreeNode = {
+                type: namedType,
+                isList: this.isList(field.type),
+                parent,
+                fragmentRefs: [],
+                children: {},
+            };
+            if (isInputObjectType(namedType)) {
+                child.children = this.getChildrenTreeNodes(namedType, child);
+            }
+            return { ...result, [key]: child };
+        }, {} as { [name: string]: TypeTreeNode });
     }
 
     private isList(t: any): boolean {

+ 22 - 0
packages/core/src/api/config/__snapshots__/graphql-custom-fields.spec.ts.snap

@@ -1,5 +1,27 @@
 // Jest Snapshot v1, https://goo.gl/fbAQLP
 
+exports[`addGraphQLCustomFields() extends OrderAddress if Address custom fields defined 1`] = `
+"type Address {
+  id: ID
+  streetLine1: String
+  customFields: AddressCustomFields
+}
+
+type AddressCustomFields {
+  instructions: String
+}
+
+scalar DateTime
+
+scalar JSON
+
+type OrderAddress {
+  streetLine1: String
+  customFields: AddressCustomFields
+}
+"
+`;
+
 exports[`addGraphQLCustomFields() extends a type 1`] = `
 "scalar DateTime
 

+ 18 - 0
packages/core/src/api/config/graphql-custom-fields.spec.ts

@@ -203,6 +203,24 @@ describe('addGraphQLCustomFields()', () => {
         const result = addGraphQLCustomFields(input, customFieldConfig, true);
         expect(printSchema(result)).toMatchSnapshot();
     });
+
+    it('extends OrderAddress if Address custom fields defined', () => {
+        const input = `
+             type Address {
+                 id: ID
+                 streetLine1: String
+             }
+
+             type OrderAddress {
+                 streetLine1: String
+             }
+        `;
+        const customFieldConfig: CustomFields = {
+            Address: [{ name: 'instructions', type: 'string' }],
+        };
+        const result = addGraphQLCustomFields(input, customFieldConfig, true);
+        expect(printSchema(result)).toMatchSnapshot();
+    });
 });
 
 describe('addOrderLineCustomFieldsInput()', () => {

+ 10 - 0
packages/core/src/api/config/graphql-custom-fields.ts

@@ -53,6 +53,16 @@ export function addGraphQLCustomFields(
                         customFields: ${entityName}CustomFields
                     }
                 `;
+
+                // For custom fields on the Address entity, we also extend the OrderAddress
+                // type (which is used to store address snapshots on Orders)
+                if (entityName === 'Address' && schema.getType('OrderAddress')) {
+                    customFieldTypeDefs += `
+                        extend type OrderAddress {
+                            customFields: ${entityName}CustomFields
+                        }
+                    `;
+                }
             } else {
                 customFieldTypeDefs += `
                     extend type ${entityName} {

+ 4 - 12
packages/core/src/api/middleware/asset-interceptor-plugin.ts

@@ -49,7 +49,7 @@ export class AssetInterceptorPlugin implements ApolloServerPlugin {
             return;
         }
         this.graphqlValueTransformer.transformValues(typeTree, data, (value, type) => {
-            const isAssetType = type && type.name === 'Asset';
+            const isAssetType = type && (type.name === 'Asset' || type.name === 'SearchResultAsset');
             if (isAssetType) {
                 if (value && !Array.isArray(value)) {
                     if (value.preview) {
@@ -60,20 +60,12 @@ export class AssetInterceptorPlugin implements ApolloServerPlugin {
                     }
                 }
             }
+
+            // TODO: This path is deprecated and should be removed in a future version
+            // once the fields are removed from the GraphQL API
             const isSearchResultType = type && type.name === 'SearchResult';
             if (isSearchResultType) {
                 if (value && !Array.isArray(value)) {
-                    if (value.productAsset) {
-                        value.productAsset.preview = toAbsoluteUrl(request, value.productAsset.preview);
-                    }
-                    if (value.productVariantAsset) {
-                        value.productVariantAsset.preview = toAbsoluteUrl(
-                            request,
-                            value.productVariantAsset.preview,
-                        );
-                    }
-                    // TODO: This path is deprecated and should be removed in a future version
-                    // once the fields are removed from the GraphQL API
                     if (value.productPreview) {
                         value.productPreview = toAbsoluteUrl(request, value.productPreview);
                     }

+ 5 - 0
packages/core/src/api/resolvers/admin/asset.resolver.ts

@@ -35,6 +35,11 @@ export class AssetResolver {
     @Mutation()
     @Allow(Permission.CreateCatalog)
     async createAssets(@Ctx() ctx: RequestContext, @Args() args: MutationCreateAssetsArgs): Promise<Asset[]> {
+        // TODO: Currently we validate _all_ mime types up-front due to limitations
+        // with the existing error handling mechanisms. With a solution as described
+        // in https://github.com/vendure-ecommerce/vendure/issues/437 we could defer
+        // this check to the individual processing of a single Asset.
+        await this.assetService.validateInputMimeTypes(args.input);
         // TODO: Is there some way to parellelize this while still preserving
         // the order of files in the upload? Non-deterministic IDs mess up the e2e test snapshots.
         const assets: Asset[] = [];

+ 2 - 0
packages/core/src/api/resolvers/admin/global-settings.resolver.ts

@@ -35,6 +35,7 @@ export class GlobalSettingsResolver {
     serverConfig(): {
         customFieldConfig: CustomFields;
         orderProcess: OrderProcessState[];
+        permittedAssetTypes: string[];
     } {
         // Do not expose custom fields marked as "internal".
         const exposedCustomFieldConfig: CustomFields = {};
@@ -46,6 +47,7 @@ export class GlobalSettingsResolver {
         return {
             customFieldConfig: exposedCustomFieldConfig,
             orderProcess: this.orderService.getOrderProcessStates(),
+            permittedAssetTypes: this.configService.assetOptions.permittedFileTypes,
         };
     }
 

+ 1 - 0
packages/core/src/api/schema/type/global-settings.type.graphql

@@ -15,4 +15,5 @@ type OrderProcessState {
 # Programatically extended by the addGraphQLCustomFields function
 type ServerConfig {
     orderProcess: [OrderProcessState!]!
+    permittedAssetTypes: [String!]!
 }

+ 55 - 8
packages/core/src/bootstrap.ts

@@ -1,15 +1,17 @@
 import { INestApplication, INestMicroservice } from '@nestjs/common';
 import { NestFactory } from '@nestjs/core';
 import { TcpClientOptions, Transport } from '@nestjs/microservices';
+import { getConnectionToken } from '@nestjs/typeorm';
 import { Type } from '@vendure/common/lib/shared-types';
 import cookieSession = require('cookie-session');
-import { ConnectionOptions, EntitySubscriberInterface } from 'typeorm';
+import { Connection, ConnectionOptions, EntitySubscriberInterface } from 'typeorm';
 
 import { InternalServerError } from './common/error/errors';
 import { getConfig, setConfig } from './config/config-helpers';
 import { DefaultLogger } from './config/logger/default-logger';
 import { Logger } from './config/logger/vendure-logger';
 import { RuntimeVendureConfig, VendureConfig } from './config/vendure-config';
+import { Administrator } from './entity/administrator/administrator.entity';
 import { coreEntitiesMap } from './entity/entities';
 import { registerCustomEntityFields } from './entity/register-custom-entity-fields';
 import { setEntityIdStrategy } from './entity/set-entity-id-strategy';
@@ -54,12 +56,14 @@ export async function bootstrap(userConfig: Partial<VendureConfig>): Promise<INe
     app.useLogger(new Logger());
     await runBeforeBootstrapHooks(config, app);
     if (config.authOptions.tokenMethod === 'cookie') {
-        const cookieHandler = cookieSession({
-            name: 'session',
-            secret: config.authOptions.sessionSecret,
-            httpOnly: true,
-        });
-        app.use(cookieHandler);
+        const { sessionSecret, cookieOptions } = config.authOptions;
+        app.use(
+            cookieSession({
+                ...cookieOptions,
+                // TODO: Remove once the deprecated sessionSecret field is removed
+                ...(sessionSecret ? { secret: sessionSecret } : {}),
+            }),
+        );
     }
     await app.listen(port, hostname || '');
     app.enableShutdownHooks();
@@ -70,7 +74,7 @@ export async function bootstrap(userConfig: Partial<VendureConfig>): Promise<INe
             Logger.warn(`[VendureConfig.workerOptions.runInMainProcess = true]`);
             closeWorkerOnAppClose(app, worker);
         } catch (e) {
-            Logger.error(`Could not start the worker process: ${e.message}`, 'Vendure Worker');
+            Logger.error(`Could not start the worker process: ${e.message || e}`, 'Vendure Worker');
         }
     }
     logWelcomeMessage(config);
@@ -130,6 +134,7 @@ async function bootstrapWorkerInternal(
     DefaultLogger.restoreOriginalLogLevel();
     workerApp.useLogger(new Logger());
     workerApp.enableShutdownHooks();
+    await validateDbTablesForWorker(workerApp);
     await runBeforeWorkerBootstrapHooks(config, workerApp);
     // A work-around to correctly handle errors when attempting to start the
     // microservice server listening.
@@ -338,6 +343,48 @@ function disableSynchronize(userConfig: Readonly<RuntimeVendureConfig>): Readonl
     return config;
 }
 
+/**
+ * Check that the Database tables exist. When running Vendure server & worker
+ * concurrently for the first time, the worker will attempt to access the
+ * DB tables before the server has populated them (assuming synchronize = true
+ * in config). This method will use polling to check the existence of a known table
+ * before allowing the rest of the worker bootstrap to continue.
+ * @param worker
+ */
+async function validateDbTablesForWorker(worker: INestMicroservice) {
+    const connection: Connection = worker.get(getConnectionToken());
+    await new Promise(async (resolve, reject) => {
+        const checkForTables = async (): Promise<boolean> => {
+            try {
+                const adminCount = await connection.getRepository(Administrator).count();
+                return 0 < adminCount;
+            } catch (e) {
+                return false;
+            }
+        };
+
+        const pollIntervalMs = 5000;
+        let attempts = 0;
+        const maxAttempts = 10;
+        let validTableStructure = false;
+        Logger.verbose('Checking for expected DB table structure...');
+        while (!validTableStructure && attempts < maxAttempts) {
+            attempts++;
+            validTableStructure = await checkForTables();
+            if (validTableStructure) {
+                Logger.verbose('Table structure verified');
+                resolve();
+                return;
+            }
+            Logger.verbose(
+                `Table structure could not be verified, trying again after ${pollIntervalMs}ms (attempt ${attempts} of ${maxAttempts})`,
+            );
+            await new Promise(resolve1 => setTimeout(resolve1, pollIntervalMs));
+        }
+        reject(`Could not validate DB table structure. Aborting bootstrap.`);
+    });
+}
+
 function checkForDeprecatedOptions(config: Partial<VendureConfig>) {
     const deprecatedApiOptions = [
         'hostname',

+ 8 - 1
packages/core/src/config/default-config.ts

@@ -58,7 +58,13 @@ export const defaultConfig: RuntimeVendureConfig = {
     authOptions: {
         disableAuth: false,
         tokenMethod: 'cookie',
-        sessionSecret: 'session-secret',
+        sessionSecret: '',
+        cookieOptions: {
+            secret: Math.random()
+                .toString(36)
+                .substr(3),
+            httpOnly: true,
+        },
         authTokenHeaderKey: DEFAULT_AUTH_TOKEN_HEADER_KEY,
         sessionDuration: '1y',
         sessionCacheStrategy: new InMemorySessionCacheStrategy(),
@@ -80,6 +86,7 @@ export const defaultConfig: RuntimeVendureConfig = {
         assetNamingStrategy: new DefaultAssetNamingStrategy(),
         assetStorageStrategy: new NoAssetStorageStrategy(),
         assetPreviewStrategy: new NoAssetPreviewStrategy(),
+        permittedFileTypes: ['image/*', 'video/*', 'audio/*', '.pdf'],
         uploadMaxFileSize: 20971520,
     },
     dbConnectionOptions: {

+ 102 - 0
packages/core/src/config/vendure-config.ts

@@ -137,6 +137,90 @@ export interface ApiOptions {
     apolloServerPlugins?: PluginDefinition[];
 }
 
+/**
+ * @description
+ * Options for the handling of the cookies used to track sessions (only applicable if
+ * `authOptions.tokenMethod` is set to `'cookie'`). These options are passed directly
+ * to the Express [cookie-session middleware](https://github.com/expressjs/cookie-session).
+ *
+ * @docsCategory auth
+ */
+export interface CookieOptions {
+    /**
+     * @description
+     * The name of the cookie to set.
+     *
+     * @default 'session'
+     */
+    name?: string;
+
+    /**
+     * @description
+     * A string which will be used as single key if keys is not provided.
+     *
+     * @default (random character string)
+     */
+    secret?: string;
+
+    /**
+     * @description
+     * a string indicating the path of the cookie.
+     *
+     * @default '/'
+     */
+    path?: string;
+
+    /**
+     * @description
+     * a string indicating the domain of the cookie (no default).
+     */
+    domain?: string;
+
+    /**
+     * @description
+     * a boolean or string indicating whether the cookie is a "same site" cookie (false by default). This can be set to 'strict',
+     * 'lax', 'none', or true (which maps to 'strict').
+     *
+     * @default false
+     */
+    sameSite?: 'strict' | 'lax' | 'none' | boolean;
+
+    /**
+     * @description
+     * a boolean indicating whether the cookie is only to be sent over HTTPS (false by default for HTTP, true by default for HTTPS).
+     */
+    secure?: boolean;
+
+    /**
+     * @description
+     * a boolean indicating whether the cookie is only to be sent over HTTPS (use this if you handle SSL not in your node process).
+     */
+    secureProxy?: boolean;
+
+    /**
+     * @description
+     * a boolean indicating whether the cookie is only to be sent over HTTP(S), and not made available to client JavaScript (true by default).
+     *
+     * @default true
+     */
+    httpOnly?: boolean;
+
+    /**
+     * @description
+     * a boolean indicating whether the cookie is to be signed (true by default). If this is true, another cookie of the same name with the .sig
+     * suffix appended will also be sent, with a 27-byte url-safe base64 SHA1 value representing the hash of cookie-name=cookie-value against the
+     * first Keygrip key. This signature key is used to detect tampering the next time a cookie is received.
+     */
+    signed?: boolean;
+
+    /**
+     * @description
+     * a boolean indicating whether to overwrite previously set cookies of the same name (true by default). If this is true, all cookies set during
+     * the same request with the same name (regardless of path or domain) are filtered out of the Set-Cookie header when setting this cookie.
+     */
+    overwrite?: boolean;
+}
+
 /**
  * @description
  * The AuthOptions define how authentication and authorization is managed.
@@ -172,6 +256,8 @@ export interface AuthOptions {
     tokenMethod?: 'cookie' | 'bearer';
     /**
      * @description
+     * **Deprecated** use `cookieConfig.secret` instead.
+     *
      * The secret used for signing the session cookies for authenticated users. Only applies when
      * tokenMethod is set to 'cookie'.
      *
@@ -180,8 +266,14 @@ export interface AuthOptions {
      * file not under source control, or from an environment variable, for example.
      *
      * @default 'session-secret'
+     * @deprecated use `cookieConfig.secret` instead
      */
     sessionSecret?: string;
+    /**
+     * @description
+     * Options related to the handling of cookies when using the 'cookie' tokenMethod.
+     */
+    cookieOptions?: CookieOptions;
     /**
      * @description
      * Sets the header property which will be used to send the auth token when using the 'bearer' method.
@@ -354,6 +446,16 @@ export interface AssetOptions {
      * @default NoAssetPreviewStrategy
      */
     assetPreviewStrategy: AssetPreviewStrategy;
+    /**
+     * @description
+     * An array of the permitted file types that may be uploaded as Assets. Each entry
+     * should be in the form of a valid
+     * [unique file type specifier](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/file#Unique_file_type_specifiers)
+     * i.e. either a file extension (".pdf") or a mime type ("image/*", "audio/mpeg" etc.).
+     *
+     * @default image, audio, video MIME types plus PDFs
+     */
+    permittedFileTypes: string[];
     /**
      * @description
      * The max file size in bytes for uploaded assets.

+ 1 - 0
packages/core/src/i18n/messages/en.json

@@ -53,6 +53,7 @@
     "identifier-change-token-has-expired": "Identifier change token has expired",
     "invalid-sort-field": "The sort field '{ fieldName }' is invalid. Valid fields are: { validFields }",
     "language-not-available-in-global-settings": "Language \"{code}\" is not available. First enable it via GlobalSettings and try again.",
+    "mime-type-not-permitted": "The MIME type '{ mimetype }' is not permitted.",
     "missing-password-on-registration": "A password must be provided when `authOptions.requireVerification` is set to \"false\"",
     "no-active-tax-zone": "The active tax zone could not be determined. Ensure a default tax zone is set for the current channel.",
     "no-search-plugin-configured": "No search plugin has been configured",

+ 36 - 5
packages/core/src/service/services/asset.service.ts

@@ -16,7 +16,7 @@ import { Stream } from 'stream';
 import { Connection, Like } from 'typeorm';
 
 import { RequestContext } from '../../api/common/request-context';
-import { InternalServerError } from '../../common/error/errors';
+import { InternalServerError, UserInputError } from '../../common/error/errors';
 import { ListQueryOptions } from '../../common/types/common-types';
 import { getAssetType, idsAreEqual } from '../../common/utils';
 import { ConfigService } from '../../config/config.service';
@@ -47,12 +47,22 @@ export interface EntityAssetInput {
 
 @Injectable()
 export class AssetService {
+    private permittedMimeTypes: Array<{ type: string; subtype: string }> = [];
+
     constructor(
         @InjectConnection() private connection: Connection,
         private configService: ConfigService,
         private listQueryBuilder: ListQueryBuilder,
         private eventBus: EventBus,
-    ) {}
+    ) {
+        this.permittedMimeTypes = this.configService.assetOptions.permittedFileTypes
+            .map(val => (/\.[\w]+/.test(val) ? mime.lookup(val) || undefined : val))
+            .filter(notNullOrUndefined)
+            .map(val => {
+                const [type, subtype] = val.split('/');
+                return { type, subtype };
+            });
+    }
 
     findOne(id: ID): Promise<Asset | undefined> {
         return this.connection.getRepository(Asset).findOne(id);
@@ -91,7 +101,7 @@ export class AssetService {
                 });
             assets = (entityWithAssets && entityWithAssets.assets) || [];
         }
-        return assets.sort((a, b) => a.position - b.position).map((a) => a.asset);
+        return assets.sort((a, b) => a.position - b.position).map(a => a.asset);
     }
 
     async updateFeaturedAsset<T extends EntityWithAssets>(entity: T, input: EntityAssetInput): Promise<T> {
@@ -121,7 +131,7 @@ export class AssetService {
         if (assetIds && assetIds.length) {
             const assets = await this.connection.getRepository(Asset).findByIds(assetIds);
             const sortedAssets = assetIds
-                .map((id) => assets.find((a) => idsAreEqual(a.id, id)))
+                .map(id => assets.find(a => idsAreEqual(a.id, id)))
                 .filter(notNullOrUndefined);
             await this.removeExistingOrderableAssets(entity);
             entity.assets = await this.createOrderableAssets(entity, sortedAssets);
@@ -131,6 +141,15 @@ export class AssetService {
         return entity;
     }
 
+    async validateInputMimeTypes(inputs: CreateAssetInput[]): Promise<void> {
+        for (const input of inputs) {
+            const { mimetype } = await input.file;
+            if (!this.validateMimeType(mimetype)) {
+                throw new UserInputError('error.mime-type-not-permitted', { mimetype });
+            }
+        }
+    }
+
     /**
      * Create an Asset based on a file uploaded via the GraphQL API.
      */
@@ -214,6 +233,9 @@ export class AssetService {
 
     private async createAssetInternal(stream: Stream, filename: string, mimetype: string): Promise<Asset> {
         const { assetOptions } = this.configService;
+        if (!this.validateMimeType(mimetype)) {
+            throw new UserInputError('error.mime-type-not-permitted', { mimetype });
+        }
         const { assetPreviewStrategy, assetStorageStrategy } = assetOptions;
         const sourceFileName = await this.getSourceFileName(filename);
         const previewFileName = await this.getPreviewFileName(sourceFileName);
@@ -310,7 +332,7 @@ export class AssetService {
     private getOrderableAssetType(entity: EntityWithAssets): Type<OrderableAsset> {
         const assetRelation = this.connection
             .getRepository(entity.constructor)
-            .metadata.relations.find((r) => r.propertyName === 'assets');
+            .metadata.relations.find(r => r.propertyName === 'assets');
         if (!assetRelation || typeof assetRelation.type === 'string') {
             throw new InternalServerError('error.could-not-find-matching-orderable-asset');
         }
@@ -331,6 +353,15 @@ export class AssetService {
         }
     }
 
+    private validateMimeType(mimeType: string): boolean {
+        const [type, subtype] = mimeType.split('/');
+        const typeMatch = this.permittedMimeTypes.find(t => t.type === type);
+        if (typeMatch) {
+            return typeMatch.subtype === subtype || typeMatch.subtype === '*';
+        }
+        return false;
+    }
+
     /**
      * Find the entities which reference the given Asset as a featuredAsset.
      */

+ 3 - 3
packages/create/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@vendure/create",
-  "version": "0.14.0",
+  "version": "0.14.1",
   "license": "MIT",
   "bin": {
     "create": "./index.js"
@@ -26,13 +26,13 @@
     "@types/handlebars": "^4.1.0",
     "@types/listr": "^0.14.0",
     "@types/semver": "^6.0.0",
-    "@vendure/core": "^0.14.0",
+    "@vendure/core": "^0.14.1",
     "rimraf": "^3.0.0",
     "ts-node": "^8.4.1",
     "typescript": "3.8.3"
   },
   "dependencies": {
-    "@vendure/common": "^0.14.0",
+    "@vendure/common": "^0.14.1",
     "chalk": "^3.0.0",
     "commander": "^5.0.0",
     "cross-spawn": "^7.0.1",

+ 15 - 8
packages/create/src/create-vendure-app.ts

@@ -38,7 +38,7 @@ program
     .version(packageJson.version)
     .arguments('<project-directory>')
     .usage(`${chalk.green('<project-directory>')} [options]`)
-    .action((name) => {
+    .action(name => {
         projectName = name;
     })
     .option(
@@ -124,7 +124,7 @@ async function createApp(
         {
             title: 'Installing dependencies',
             task: (() => {
-                return new Observable((subscriber) => {
+                return new Observable(subscriber => {
                     subscriber.next('Creating package.json');
                     fs.writeFileSync(
                         path.join(root, 'package.json'),
@@ -145,14 +145,14 @@ async function createApp(
                             }
                         })
                         .then(() => subscriber.complete())
-                        .catch((err) => subscriber.error(err));
+                        .catch(err => subscriber.error(err));
                 });
             }) as any,
         },
         {
             title: 'Generating app scaffold',
-            task: (ctx) => {
-                return new Observable((subscriber) => {
+            task: ctx => {
+                return new Observable(subscriber => {
                     fs.ensureDirSync(path.join(root, 'src'));
                     const assetPath = (fileName: string) => path.join(__dirname, '../assets', fileName);
                     const srcPathScript = (fileName: string): string =>
@@ -187,13 +187,13 @@ async function createApp(
                             subscriber.next(`Copied email templates`);
                             subscriber.complete();
                         })
-                        .catch((err) => subscriber.error(err));
+                        .catch(err => subscriber.error(err));
                 });
             },
         },
         {
             title: 'Initializing server',
-            task: async (ctx) => {
+            task: async ctx => {
                 try {
                     if (usingTs) {
                         // register ts-node so that the config file can be loaded
@@ -249,8 +249,15 @@ async function createApp(
                         app = await populate(bootstrapFn, initialDataPath);
                     }
                     // Pause to ensure the worker jobs have time to complete.
-                    await new Promise((resolve) => setTimeout(resolve, isCi ? 20000 : 2000));
+                    if (isCi) {
+                        console.log('[CI] Pausing before close...');
+                    }
+                    await new Promise(resolve => setTimeout(resolve, isCi ? 30000 : 2000));
                     await app.close();
+                    if (isCi) {
+                        console.log('[CI] Pausing after close...');
+                        await new Promise(resolve => setTimeout(resolve, 10000));
+                    }
                 } catch (e) {
                     console.log(e);
                     throw e;

+ 0 - 1
packages/create/src/gather-user-responses.ts

@@ -190,7 +190,6 @@ async function generateSources(
         isSQLite: answers.dbType === 'sqlite',
         isSQLjs: answers.dbType === 'sqljs',
         requiresConnection: answers.dbType !== 'sqlite' && answers.dbType !== 'sqljs',
-        sessionSecret: Math.random().toString(36).substr(3),
     };
     const configTemplate = await fs.readFile(assetPath('vendure-config.hbs'), 'utf-8');
     const configSource = Handlebars.compile(configTemplate)(templateContext);

+ 1 - 2
packages/create/templates/vendure-config.hbs

@@ -36,7 +36,7 @@ const path = require('path');
         },// turn this off for production
         adminApiDebug: true, // turn this off for production
         shopApiPath: 'shop-api',
-        shopApiPlayground: { 
+        shopApiPlayground: {
             settings: {
                 'request.credentials': 'include',
             }{{#if isTs}} as any{{/if}},
@@ -44,7 +44,6 @@ const path = require('path');
         shopApiDebug: true,// turn this off for production
     },
     authOptions: {
-        sessionSecret: '{{ sessionSecret }}',
         superadminCredentials: {
             identifier: '{{ superadminIdentifier }}',
             password: '{{ superadminPassword }}',

+ 1 - 0
packages/dev-server/index-worker.ts

@@ -9,4 +9,5 @@ devConfig.dbConnectionOptions = { ...devConfig.dbConnectionOptions, synchronize:
 bootstrapWorker(devConfig).catch(err => {
     // tslint:disable-next-line
     console.log(err);
+    process.exit(1);
 });

+ 1 - 0
packages/dev-server/index.ts

@@ -8,4 +8,5 @@ import { devConfig } from './dev-config';
 bootstrap(devConfig).catch(err => {
     // tslint:disable-next-line
     console.log(err);
+    process.exit(1);
 });

+ 9 - 9
packages/dev-server/package.json

@@ -1,6 +1,6 @@
 {
   "name": "dev-server",
-  "version": "0.14.0",
+  "version": "0.14.1",
   "main": "index.js",
   "license": "MIT",
   "private": true,
@@ -14,18 +14,18 @@
     "load-test:100k": "node -r ts-node/register load-testing/run-load-test.ts 100000"
   },
   "dependencies": {
-    "@vendure/admin-ui-plugin": "^0.14.0",
-    "@vendure/asset-server-plugin": "^0.14.0",
-    "@vendure/common": "^0.14.0",
-    "@vendure/core": "^0.14.0",
-    "@vendure/elasticsearch-plugin": "^0.14.0",
-    "@vendure/email-plugin": "^0.14.0",
+    "@vendure/admin-ui-plugin": "^0.14.1",
+    "@vendure/asset-server-plugin": "^0.14.1",
+    "@vendure/common": "^0.14.1",
+    "@vendure/core": "^0.14.1",
+    "@vendure/elasticsearch-plugin": "^0.14.1",
+    "@vendure/email-plugin": "^0.14.1",
     "typescript": "3.8.3"
   },
   "devDependencies": {
     "@types/csv-stringify": "^3.1.0",
-    "@vendure/testing": "^0.14.0",
-    "@vendure/ui-devkit": "^0.14.0",
+    "@vendure/testing": "^0.14.1",
+    "@vendure/ui-devkit": "^0.14.1",
     "concurrently": "^5.0.0",
     "csv-stringify": "^5.3.3"
   }

+ 1 - 0
packages/elasticsearch-plugin/e2e/graphql/generated-e2e-elasticsearch-plugin-types.ts

@@ -3235,6 +3235,7 @@ export type SearchResultSortParameter = {
 export type ServerConfig = {
     __typename?: 'ServerConfig';
     orderProcess: Array<OrderProcessState>;
+    permittedAssetTypes: Array<Scalars['String']>;
     customFieldConfig: CustomFields;
 };
 

+ 3 - 3
packages/elasticsearch-plugin/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@vendure/elasticsearch-plugin",
-  "version": "0.14.0",
+  "version": "0.14.1",
   "license": "MIT",
   "main": "lib/index.js",
   "types": "lib/index.d.ts",
@@ -22,8 +22,8 @@
     "deepmerge": "^4.0.0"
   },
   "devDependencies": {
-    "@vendure/common": "^0.14.0",
-    "@vendure/core": "^0.14.0",
+    "@vendure/common": "^0.14.1",
+    "@vendure/core": "^0.14.1",
     "rimraf": "^3.0.0",
     "typescript": "3.8.3"
   }

+ 3 - 3
packages/email-plugin/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@vendure/email-plugin",
-  "version": "0.14.0",
+  "version": "0.14.1",
   "license": "MIT",
   "main": "lib/index.js",
   "types": "lib/index.d.ts",
@@ -33,8 +33,8 @@
     "@types/handlebars": "^4.1.0",
     "@types/mjml": "^4.0.2",
     "@types/nodemailer": "^6.4.0",
-    "@vendure/common": "^0.14.0",
-    "@vendure/core": "^0.14.0",
+    "@vendure/common": "^0.14.1",
+    "@vendure/core": "^0.14.1",
     "rimraf": "^3.0.0",
     "typescript": "3.8.3"
   }

+ 17 - 9
packages/email-plugin/src/default-email-handlers.ts

@@ -2,6 +2,7 @@
 import {
     AccountRegistrationEvent,
     IdentifierChangeRequestEvent,
+    NativeAuthenticationMethod,
     OrderStateTransitionEvent,
     PasswordResetEvent,
 } from '@vendure/core';
@@ -17,39 +18,46 @@ import {
 
 export const orderConfirmationHandler = new EmailEventListener('order-confirmation')
     .on(OrderStateTransitionEvent)
-    .filter((event) => event.toState === 'PaymentSettled' && !!event.order.customer)
-    .setRecipient((event) => event.order.customer!.emailAddress)
+    .filter(event => event.toState === 'PaymentSettled' && !!event.order.customer)
+    .setRecipient(event => event.order.customer!.emailAddress)
     .setFrom(`{{ fromAddress }}`)
     .setSubject(`Order confirmation for #{{ order.code }}`)
-    .setTemplateVars((event) => ({ order: event.order }))
+    .setTemplateVars(event => ({ order: event.order }))
     .setMockEvent(mockOrderStateTransitionEvent);
 
 export const emailVerificationHandler = new EmailEventListener('email-verification')
     .on(AccountRegistrationEvent)
-    .setRecipient((event) => event.user.identifier)
+    .filter(event => !!event.user.getNativeAuthenticationMethod().identifier)
+    .filter(event => {
+        const nativeAuthMethod = event.user.authenticationMethods.find(
+            m => m instanceof NativeAuthenticationMethod,
+        ) as NativeAuthenticationMethod | undefined;
+        return (nativeAuthMethod && !!nativeAuthMethod.identifier) || false;
+    })
+    .setRecipient(event => event.user.identifier)
     .setFrom(`{{ fromAddress }}`)
     .setSubject(`Please verify your email address`)
-    .setTemplateVars((event) => ({
+    .setTemplateVars(event => ({
         verificationToken: event.user.getNativeAuthenticationMethod().verificationToken,
     }))
     .setMockEvent(mockAccountRegistrationEvent);
 
 export const passwordResetHandler = new EmailEventListener('password-reset')
     .on(PasswordResetEvent)
-    .setRecipient((event) => event.user.identifier)
+    .setRecipient(event => event.user.identifier)
     .setFrom(`{{ fromAddress }}`)
     .setSubject(`Forgotten password reset`)
-    .setTemplateVars((event) => ({
+    .setTemplateVars(event => ({
         passwordResetToken: event.user.getNativeAuthenticationMethod().passwordResetToken,
     }))
     .setMockEvent(mockPasswordResetEvent);
 
 export const emailAddressChangeHandler = new EmailEventListener('email-address-change')
     .on(IdentifierChangeRequestEvent)
-    .setRecipient((event) => event.user.getNativeAuthenticationMethod().pendingIdentifier!)
+    .setRecipient(event => event.user.getNativeAuthenticationMethod().pendingIdentifier!)
     .setFrom(`{{ fromAddress }}`)
     .setSubject(`Please verify your change of email address`)
-    .setTemplateVars((event) => ({
+    .setTemplateVars(event => ({
         identifierChangeToken: event.user.getNativeAuthenticationMethod().identifierChangeToken,
     }))
     .setMockEvent(mockEmailAddressChangeEvent);

+ 3 - 3
packages/testing/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@vendure/testing",
-  "version": "0.14.0",
+  "version": "0.14.1",
   "description": "End-to-end testing tools for Vendure projects",
   "keywords": [
     "vendure",
@@ -33,7 +33,7 @@
   },
   "dependencies": {
     "@types/node-fetch": "^2.5.4",
-    "@vendure/common": "^0.14.0",
+    "@vendure/common": "^0.14.1",
     "faker": "^4.1.0",
     "form-data": "^3.0.0",
     "graphql": "^14.5.8",
@@ -44,7 +44,7 @@
   "devDependencies": {
     "@types/mysql": "^2.15.8",
     "@types/pg": "^7.14.1",
-    "@vendure/core": "^0.14.0",
+    "@vendure/core": "^0.14.1",
     "mysql": "^2.17.1",
     "pg": "^7.17.1",
     "rimraf": "^3.0.0",

+ 0 - 1
packages/testing/src/simple-graphql-client.ts

@@ -236,7 +236,6 @@ export class SimpleGraphQLClient {
         const response = await result.json();
         if (response.errors && response.errors.length) {
             const error = response.errors[0];
-            console.log(JSON.stringify(error.extensions, null, 2));
             throw new Error(error.message);
         }
         return response.data;

+ 4 - 4
packages/ui-devkit/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@vendure/ui-devkit",
-  "version": "0.14.0",
+  "version": "0.14.1",
   "description": "A library for authoring Vendure Admin UI extensions",
   "keywords": [
     "vendure",
@@ -39,8 +39,8 @@
     "@angular/cli": "^9.0.5",
     "@angular/compiler": "^9.0.6",
     "@angular/compiler-cli": "^9.0.6",
-    "@vendure/admin-ui": "^0.14.0",
-    "@vendure/common": "^0.14.0",
+    "@vendure/admin-ui": "^0.14.1",
+    "@vendure/common": "^0.14.1",
     "chalk": "^3.0.0",
     "chokidar": "^3.3.1",
     "fs-extra": "^9.0.0",
@@ -51,7 +51,7 @@
     "@rollup/plugin-node-resolve": "^7.1.1",
     "@types/fs-extra": "^8.1.0",
     "@types/glob": "^7.1.1",
-    "@vendure/core": "^0.14.0",
+    "@vendure/core": "^0.14.1",
     "rimraf": "^3.0.0",
     "rollup": "^2.2.0",
     "rollup-plugin-terser": "^5.3.0",