diff --git a/npm/ng-packs/packages/core/src/lib/services/dom-insertion.service.ts b/npm/ng-packs/packages/core/src/lib/services/dom-insertion.service.ts new file mode 100644 index 0000000000..f1a59e4e43 --- /dev/null +++ b/npm/ng-packs/packages/core/src/lib/services/dom-insertion.service.ts @@ -0,0 +1,17 @@ +import { Injectable } from '@angular/core'; +import { ContentStrategy } from '../strategies/content.strategy'; +import { generateHash } from '../utils'; + +@Injectable({ providedIn: 'root' }) +export class DomInsertionService { + readonly inserted = new Set(); + + insertContent(contentStrategy: ContentStrategy) { + const hash = generateHash(contentStrategy.content); + + if (this.inserted.has(hash)) return; + + contentStrategy.insertElement(); + this.inserted.add(hash); + } +} diff --git a/npm/ng-packs/packages/core/src/lib/services/index.ts b/npm/ng-packs/packages/core/src/lib/services/index.ts index ad23b74fae..a64e721c67 100644 --- a/npm/ng-packs/packages/core/src/lib/services/index.ts +++ b/npm/ng-packs/packages/core/src/lib/services/index.ts @@ -1,6 +1,7 @@ export * from './application-configuration.service'; export * from './auth.service'; export * from './config-state.service'; +export * from './dom-insertion.service'; export * from './lazy-load.service'; export * from './localization.service'; export * from './profile-state.service'; diff --git a/npm/ng-packs/packages/core/src/lib/services/lazy-load.service.ts b/npm/ng-packs/packages/core/src/lib/services/lazy-load.service.ts index 5e605d8c5d..cec9d4a6cf 100644 --- a/npm/ng-packs/packages/core/src/lib/services/lazy-load.service.ts +++ b/npm/ng-packs/packages/core/src/lib/services/lazy-load.service.ts @@ -13,6 +13,10 @@ export class LazyLoadService { loadedLibraries: { [url: string]: ReplaySubject } = {}; load(strategy: LoadingStrategy, retryTimes?: number, retryDelay?: number): Observable; + /** + * + * @deprecated Use other overload that requires a strategy as first param + */ load( urlOrUrls: string | string[], type: 'script' | 'style', diff --git a/npm/ng-packs/packages/core/src/lib/strategies/content-security.strategy.ts b/npm/ng-packs/packages/core/src/lib/strategies/content-security.strategy.ts index 2875916ef2..a848feabe3 100644 --- a/npm/ng-packs/packages/core/src/lib/strategies/content-security.strategy.ts +++ b/npm/ng-packs/packages/core/src/lib/strategies/content-security.strategy.ts @@ -14,7 +14,7 @@ export class LooseContentSecurityStrategy extends ContentSecurityStrategy { } } -export class StrictContentSecurityStrategy extends ContentSecurityStrategy { +export class NoContentSecurityStrategy extends ContentSecurityStrategy { constructor() { super(); } @@ -26,7 +26,7 @@ export const CONTENT_SECURITY_STRATEGY = { Loose(nonce: string) { return new LooseContentSecurityStrategy(nonce); }, - Strict() { - return new StrictContentSecurityStrategy(); + None() { + return new NoContentSecurityStrategy(); }, }; diff --git a/npm/ng-packs/packages/core/src/lib/strategies/content.strategy.ts b/npm/ng-packs/packages/core/src/lib/strategies/content.strategy.ts new file mode 100644 index 0000000000..1d58f6bb3d --- /dev/null +++ b/npm/ng-packs/packages/core/src/lib/strategies/content.strategy.ts @@ -0,0 +1,52 @@ +import { ContentSecurityStrategy, CONTENT_SECURITY_STRATEGY } from './content-security.strategy'; +import { DomStrategy, DOM_STRATEGY } from './dom.strategy'; + +export abstract class ContentStrategy { + constructor( + public content: string, + protected domStrategy: DomStrategy = DOM_STRATEGY.AppendToHead(), + protected contentSecurityStrategy: ContentSecurityStrategy = CONTENT_SECURITY_STRATEGY.None(), + ) {} + + abstract createElement(): T; + + insertElement() { + const element = this.createElement(); + + this.contentSecurityStrategy.applyCSP(element); + this.domStrategy.insertElement(element); + } +} + +export class StyleContentStrategy extends ContentStrategy { + createElement(): HTMLStyleElement { + const element = document.createElement('style'); + element.textContent = this.content; + + return element; + } +} + +export class ScriptContentStrategy extends ContentStrategy { + createElement(): HTMLScriptElement { + const element = document.createElement('script'); + element.textContent = this.content; + + return element; + } +} + +export const CONTENT_STRATEGY = { + AppendScriptToBody(content: string) { + return new ScriptContentStrategy(content, DOM_STRATEGY.AppendToBody()); + }, + AppendScriptToHead(content: string) { + return new ScriptContentStrategy(content, DOM_STRATEGY.AppendToHead()); + }, + AppendStyleToHead(content: string) { + return new StyleContentStrategy(content, DOM_STRATEGY.AppendToHead()); + }, + PrependStyleToHead(content: string) { + return new StyleContentStrategy(content, DOM_STRATEGY.PrependToHead()); + }, +}; diff --git a/npm/ng-packs/packages/core/src/lib/strategies/index.ts b/npm/ng-packs/packages/core/src/lib/strategies/index.ts index 93904af549..2d6be484dd 100644 --- a/npm/ng-packs/packages/core/src/lib/strategies/index.ts +++ b/npm/ng-packs/packages/core/src/lib/strategies/index.ts @@ -1,4 +1,5 @@ export * from './content-security.strategy'; +export * from './content.strategy'; export * from './cross-origin.strategy'; export * from './dom.strategy'; export * from './loading.strategy'; diff --git a/npm/ng-packs/packages/core/src/lib/tests/content-security.strategy.spec.ts b/npm/ng-packs/packages/core/src/lib/tests/content-security.strategy.spec.ts index c617e35893..701da5b756 100644 --- a/npm/ng-packs/packages/core/src/lib/tests/content-security.strategy.spec.ts +++ b/npm/ng-packs/packages/core/src/lib/tests/content-security.strategy.spec.ts @@ -1,7 +1,7 @@ import { CONTENT_SECURITY_STRATEGY, LooseContentSecurityStrategy, - StrictContentSecurityStrategy, + NoContentSecurityStrategy, } from '../strategies'; import { uuid } from '../utils'; @@ -18,10 +18,10 @@ describe('LooseContentSecurityStrategy', () => { }); }); -describe('StrictContentSecurityStrategy', () => { +describe('NoContentSecurityStrategy', () => { describe('#applyCSP', () => { it('should not set nonce attribute', () => { - const strategy = new StrictContentSecurityStrategy(); + const strategy = new NoContentSecurityStrategy(); const element = document.createElement('link'); strategy.applyCSP(element); @@ -32,9 +32,9 @@ describe('StrictContentSecurityStrategy', () => { describe('CONTENT_SECURITY_STRATEGY', () => { test.each` - name | Strategy | nonce - ${'Loose'} | ${LooseContentSecurityStrategy} | ${uuid()} - ${'Strict'} | ${StrictContentSecurityStrategy} | ${undefined} + name | Strategy | nonce + ${'Loose'} | ${LooseContentSecurityStrategy} | ${uuid()} + ${'None'} | ${NoContentSecurityStrategy} | ${undefined} `('should successfully map $name to $Strategy.name', ({ name, Strategy, nonce }) => { expect(CONTENT_SECURITY_STRATEGY[name](nonce)).toEqual(new Strategy(nonce)); }); diff --git a/npm/ng-packs/packages/core/src/lib/tests/content.strategy.spec.ts b/npm/ng-packs/packages/core/src/lib/tests/content.strategy.spec.ts new file mode 100644 index 0000000000..e742313135 --- /dev/null +++ b/npm/ng-packs/packages/core/src/lib/tests/content.strategy.spec.ts @@ -0,0 +1,82 @@ +import { + CONTENT_STRATEGY, + StyleContentStrategy, + ScriptContentStrategy, + DOM_STRATEGY, + CONTENT_SECURITY_STRATEGY, +} from '../strategies'; +import { uuid } from '../utils'; + +describe('StyleContentStrategy', () => { + describe('#createElement', () => { + it('should create a style element', () => { + const strategy = new StyleContentStrategy(''); + const element = strategy.createElement(); + + expect(element.tagName).toBe('STYLE'); + }); + }); + + describe('#insertElement', () => { + it('should use given dom and content security strategies', () => { + const domStrategy = DOM_STRATEGY.PrependToHead(); + const contentSecurityStrategy = CONTENT_SECURITY_STRATEGY.None(); + + contentSecurityStrategy.applyCSP = jest.fn((el: HTMLScriptElement) => {}); + domStrategy.insertElement = jest.fn((el: HTMLScriptElement) => {}) as any; + + const strategy = new StyleContentStrategy('', domStrategy, contentSecurityStrategy); + const element = strategy.createElement(); + strategy.insertElement(); + + expect(contentSecurityStrategy.applyCSP).toHaveBeenCalledWith(element); + expect(domStrategy.insertElement).toHaveBeenCalledWith(element); + }); + }); +}); + +describe('ScriptContentStrategy', () => { + describe('#createElement', () => { + it('should create a style element', () => { + const nonce = uuid(); + const strategy = new ScriptContentStrategy(''); + const element = strategy.createElement(); + + expect(element.tagName).toBe('SCRIPT'); + }); + }); + + describe('#insertElement', () => { + it('should use given dom and content security strategies', () => { + const nonce = uuid(); + + const domStrategy = DOM_STRATEGY.PrependToHead(); + const contentSecurityStrategy = CONTENT_SECURITY_STRATEGY.Loose(nonce); + + contentSecurityStrategy.applyCSP = jest.fn((el: HTMLScriptElement) => {}); + domStrategy.insertElement = jest.fn((el: HTMLScriptElement) => {}) as any; + + const strategy = new ScriptContentStrategy('', domStrategy, contentSecurityStrategy); + const element = strategy.createElement(); + strategy.insertElement(); + + expect(contentSecurityStrategy.applyCSP).toHaveBeenCalledWith(element); + expect(domStrategy.insertElement).toHaveBeenCalledWith(element); + }); + }); +}); + +describe('CONTENT_STRATEGY', () => { + test.each` + name | Strategy | domStrategy + ${'AppendScriptToBody'} | ${ScriptContentStrategy} | ${'AppendToBody'} + ${'AppendScriptToHead'} | ${ScriptContentStrategy} | ${'AppendToHead'} + ${'AppendStyleToHead'} | ${StyleContentStrategy} | ${'AppendToHead'} + ${'PrependStyleToHead'} | ${StyleContentStrategy} | ${'PrependToHead'} + `( + 'should successfully map $name to $Strategy.name with $domStrategy dom strategy', + ({ name, Strategy, domStrategy }) => { + expect(CONTENT_STRATEGY[name]('')).toEqual(new Strategy('', DOM_STRATEGY[domStrategy]())); + }, + ); +}); diff --git a/npm/ng-packs/packages/core/src/lib/tests/dom-insertion.service.spec.ts b/npm/ng-packs/packages/core/src/lib/tests/dom-insertion.service.spec.ts new file mode 100644 index 0000000000..2570636197 --- /dev/null +++ b/npm/ng-packs/packages/core/src/lib/tests/dom-insertion.service.spec.ts @@ -0,0 +1,15 @@ +import { createServiceFactory, SpectatorService } from '@ngneat/spectator'; +import { DomInsertionService } from '../services'; +import { CONTENT_STRATEGY } from '../strategies'; + +describe('DomInsertionService', () => { + let spectator: SpectatorService; + const createService = createServiceFactory(DomInsertionService); + + beforeEach(() => (spectator = createService())); + + it('should be insert an element', () => { + spectator.service.insertContent(CONTENT_STRATEGY.AppendStyleToHead('.test {}')); + expect(spectator.service.inserted.has(1437348290)).toBe(true); + }); +}); diff --git a/npm/ng-packs/packages/core/src/lib/tests/generator-utils.spec.ts b/npm/ng-packs/packages/core/src/lib/tests/generator-utils.spec.ts new file mode 100644 index 0000000000..e09be3fb4f --- /dev/null +++ b/npm/ng-packs/packages/core/src/lib/tests/generator-utils.spec.ts @@ -0,0 +1,10 @@ +import { generateHash } from '../utils'; + +describe('GeneratorUtils', () => { + describe('#generateHash', () => { + test('should generate a hash', async () => { + const hash = generateHash('some content \n with second line'); + expect(hash).toBe(1112440527); + }); + }); +}); diff --git a/npm/ng-packs/packages/core/src/lib/utils/generator-utils.ts b/npm/ng-packs/packages/core/src/lib/utils/generator-utils.ts index 4256489efa..cc88e216d6 100644 --- a/npm/ng-packs/packages/core/src/lib/utils/generator-utils.ts +++ b/npm/ng-packs/packages/core/src/lib/utils/generator-utils.ts @@ -1,6 +1,19 @@ +// tslint:disable: no-bitwise + export function uuid(a?: any): string { return a - ? // tslint:disable-next-line: no-bitwise - (a ^ ((Math.random() * 16) >> (a / 4))).toString(16) + ? (a ^ ((Math.random() * 16) >> (a / 4))).toString(16) : ('' + 1e7 + -1e3 + -4e3 + -8e3 + -1e11).replace(/[018]/g, uuid); } + +export function generateHash(value: string): number { + let hashed = 0; + let charCode: number; + + for (let i = 0; i < value.length; i++) { + charCode = value.charCodeAt(i); + hashed = (hashed << 5) - hashed + charCode; + hashed |= 0; + } + return hashed; +} diff --git a/npm/ng-packs/packages/theme-basic/src/lib/services/initial.service.ts b/npm/ng-packs/packages/theme-basic/src/lib/services/initial.service.ts index 21fb1756bc..2510b77a8d 100644 --- a/npm/ng-packs/packages/theme-basic/src/lib/services/initial.service.ts +++ b/npm/ng-packs/packages/theme-basic/src/lib/services/initial.service.ts @@ -1,4 +1,4 @@ -import { LazyLoadService, AddReplaceableComponent } from '@abp/ng.core'; +import { DomInsertionService, AddReplaceableComponent, CONTENT_STRATEGY } from '@abp/ng.core'; import { Injectable } from '@angular/core'; import { Store } from '@ngxs/store'; import styles from '../constants/styles'; @@ -8,8 +8,9 @@ import { EmptyLayoutComponent } from '../components/empty-layout/empty-layout.co @Injectable({ providedIn: 'root' }) export class InitialService { - constructor(private lazyLoadService: LazyLoadService, private store: Store) { - this.appendStyle().subscribe(); + constructor(private domInsertion: DomInsertionService, private store: Store) { + this.appendStyle(); + this.store.dispatch([ new AddReplaceableComponent({ key: 'Theme.ApplicationLayoutComponent', @@ -27,6 +28,6 @@ export class InitialService { } appendStyle() { - return this.lazyLoadService.load(null, 'style', styles, 'head', 'beforeend'); + this.domInsertion.insertContent(CONTENT_STRATEGY.AppendStyleToHead(styles)); } } diff --git a/npm/ng-packs/packages/theme-shared/src/lib/tests/append-content.token.spec.ts b/npm/ng-packs/packages/theme-shared/src/lib/tests/append-content.token.spec.ts new file mode 100644 index 0000000000..5bfbd169e8 --- /dev/null +++ b/npm/ng-packs/packages/theme-shared/src/lib/tests/append-content.token.spec.ts @@ -0,0 +1,29 @@ +import { Component } from '@angular/core'; +import { createComponentFactory, Spectator } from '@ngneat/spectator'; +import { THEME_SHARED_APPEND_CONTENT } from '../tokens/append-content.token'; +import { DomInsertionService } from '@abp/ng.core'; +import { chartJsLoaded$ } from '../utils'; + +@Component({ selector: 'abp-dummy', template: '' }) +class DummyComponent {} + +describe('AppendContentToken', () => { + let spectator: Spectator; + const createComponent = createComponentFactory(DummyComponent); + + beforeEach(() => (spectator = createComponent())); + + it('should insert a style element to the DOM', () => { + spectator.get(THEME_SHARED_APPEND_CONTENT); + expect(spectator.get(DomInsertionService).inserted.size).toBe(1); + }); + + it('should be loaded the chart.js', done => { + chartJsLoaded$.subscribe(loaded => { + expect(loaded).toBe(true); + done(); + }); + + spectator.get(THEME_SHARED_APPEND_CONTENT); + }); +}); diff --git a/npm/ng-packs/packages/theme-shared/src/lib/theme-shared.module.ts b/npm/ng-packs/packages/theme-shared/src/lib/theme-shared.module.ts index 205e2a488c..15ba212944 100644 --- a/npm/ng-packs/packages/theme-shared/src/lib/theme-shared.module.ts +++ b/npm/ng-packs/packages/theme-shared/src/lib/theme-shared.module.ts @@ -1,4 +1,4 @@ -import { CoreModule, LazyLoadService } from '@abp/ng.core'; +import { CoreModule, noop, LazyLoadService } from '@abp/ng.core'; import { DatePipe } from '@angular/common'; import { APP_INITIALIZER, Injector, ModuleWithProviders, NgModule } from '@angular/core'; import { NgbDateParserFormatter, NgbPaginationModule } from '@ng-bootstrap/ng-bootstrap'; @@ -25,7 +25,13 @@ import { chartJsLoaded$ } from './utils/widget-utils'; import { PaginationComponent } from './components/pagination/pagination.component'; import { LoadingComponent } from './components/loading/loading.component'; import { LoadingDirective } from './directives/loading.directive'; +import { THEME_SHARED_APPEND_CONTENT } from './tokens/append-content.token'; +/** + * + * @deprecated To be deleted in v2.6 + * + */ export function appendScript(injector: Injector) { const fn = () => { import('chart.js').then(() => chartJsLoaded$.next(true)); @@ -87,8 +93,8 @@ export class ThemeSharedModule { { provide: APP_INITIALIZER, multi: true, - deps: [Injector], - useFactory: appendScript, + deps: [THEME_SHARED_APPEND_CONTENT], + useFactory: noop, }, { provide: HTTP_ERROR_CONFIG, useValue: options.httpErrorConfig }, { diff --git a/npm/ng-packs/packages/theme-shared/src/lib/tokens/append-content.token.ts b/npm/ng-packs/packages/theme-shared/src/lib/tokens/append-content.token.ts new file mode 100644 index 0000000000..2ba7898480 --- /dev/null +++ b/npm/ng-packs/packages/theme-shared/src/lib/tokens/append-content.token.ts @@ -0,0 +1,15 @@ +import { CONTENT_STRATEGY, DomInsertionService } from '@abp/ng.core'; +import { inject, InjectionToken } from '@angular/core'; +import styles from '../constants/styles'; +import { chartJsLoaded$ } from '../utils/widget-utils'; + +export const THEME_SHARED_APPEND_CONTENT = new InjectionToken('THEME_SHARED_APPEND_CONTENT', { + providedIn: 'root', + factory: () => { + const domInsertion: DomInsertionService = inject(DomInsertionService); + + domInsertion.insertContent(CONTENT_STRATEGY.AppendStyleToHead(styles)); + + import('chart.js').then(() => chartJsLoaded$.next(true)); + }, +});