diff --git a/src/SIL.XForge.Scripture/ClientApp/src/app/app.component.ts b/src/SIL.XForge.Scripture/ClientApp/src/app/app.component.ts index b515598ed05..0a9bdd4ae48 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/app/app.component.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/app/app.component.ts @@ -16,6 +16,7 @@ import { TextInfo } from 'realtime-server/lib/esm/scriptureforge/models/text-inf import { Canon } from 'realtime-server/lib/esm/scriptureforge/scripture-utils/canon'; import { combineLatest, Observable, of, Subscription } from 'rxjs'; import { distinctUntilChanged, filter, map, startWith, tap } from 'rxjs/operators'; +import { AnalyticsService } from 'xforge-common/analytics.service'; import { AuthService } from 'xforge-common/auth.service'; import { DataLoadingComponent } from 'xforge-common/data-loading-component'; import { DialogService } from 'xforge-common/dialog.service'; @@ -47,8 +48,6 @@ import { ProjectDeletedDialogComponent } from './project-deleted-dialog/project- import { SettingsAuthGuard, SyncAuthGuard, UsersAuthGuard } from './shared/project-router.guard'; import { projectLabel } from './shared/utils'; -declare function gtag(...args: any): void; - export const CONNECT_PROJECT_OPTION = '*connect-project*'; @Component({ @@ -106,6 +105,7 @@ export class AppComponent extends DataLoadingComponent implements OnInit, OnDest readonly urls: ExternalUrlService, readonly featureFlags: FeatureFlagService, private readonly pwaService: PwaService, + private readonly analytics: AnalyticsService, iconRegistry: MdcIconRegistry, sanitizer: DomSanitizer ) { @@ -148,8 +148,7 @@ export class AppComponent extends DataLoadingComponent implements OnInit, OnDest ); this.subscribe(navEndEvent$, e => { if (this.isAppOnline) { - // eslint-disable-next-line @typescript-eslint/naming-convention - gtag('config', 'UA-22170471-15', { page_path: e.urlAfterRedirects }); + this.analytics.logNavigation(e.urlAfterRedirects); } }); } diff --git a/src/SIL.XForge.Scripture/ClientApp/src/environments/environment.prod.ts b/src/SIL.XForge.Scripture/ClientApp/src/environments/environment.prod.ts index 25b5bfc8896..563435c28ef 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/environments/environment.prod.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/environments/environment.prod.ts @@ -15,5 +15,6 @@ export const environment = { realtimeUrl: '/realtime-api/', authDomain: 'login.languagetechnology.org', authClientId: 'tY2wXn40fsL5VsPM4uIHNtU6ZUEXGeFn', - offlineDBVersion: 5 + offlineDBVersion: 5, + googleTagId: 'G-SVKBDV7K3Q' }; diff --git a/src/SIL.XForge.Scripture/ClientApp/src/environments/environment.pwa-test.ts b/src/SIL.XForge.Scripture/ClientApp/src/environments/environment.pwa-test.ts index 86bcf79ca2b..f3f675c402e 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/environments/environment.pwa-test.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/environments/environment.pwa-test.ts @@ -15,5 +15,6 @@ export const environment = { realtimeUrl: '/', authDomain: 'sil-appbuilder.auth0.com', authClientId: 'aoAGb9Yx1H5WIsvCW6JJCteJhSa37ftH', - offlineDBVersion: 5 + offlineDBVersion: 5, + googleTagId: null }; diff --git a/src/SIL.XForge.Scripture/ClientApp/src/environments/environment.staging.ts b/src/SIL.XForge.Scripture/ClientApp/src/environments/environment.staging.ts index 9671708b67a..ec68932cd43 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/environments/environment.staging.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/environments/environment.staging.ts @@ -15,5 +15,6 @@ export const environment = { realtimeUrl: '/realtime-api/', authDomain: 'dev-sillsdev.auth0.com', authClientId: '4eHLjo40mAEGFU6zUxdYjnpnC1K1Ydnj', - offlineDBVersion: 5 + offlineDBVersion: 5, + googleTagId: null }; diff --git a/src/SIL.XForge.Scripture/ClientApp/src/environments/environment.ts b/src/SIL.XForge.Scripture/ClientApp/src/environments/environment.ts index 694172369cd..b682541c049 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/environments/environment.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/environments/environment.ts @@ -22,5 +22,6 @@ export const environment = { realtimeUrl: '/', authDomain: 'sil-appbuilder.auth0.com', authClientId: 'aoAGb9Yx1H5WIsvCW6JJCteJhSa37ftH', - offlineDBVersion: 5 + offlineDBVersion: 5, + googleTagId: null }; diff --git a/src/SIL.XForge.Scripture/ClientApp/src/index.html b/src/SIL.XForge.Scripture/ClientApp/src/index.html index 009c74da4ff..c2eb29da180 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/index.html +++ b/src/SIL.XForge.Scripture/ClientApp/src/index.html @@ -1,14 +1,15 @@ - - + diff --git a/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/analytics.service.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/analytics.service.spec.ts new file mode 100644 index 00000000000..388c9580b69 --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/analytics.service.spec.ts @@ -0,0 +1,13 @@ +import { sanitizeUrl } from './analytics.service'; + +fdescribe('AnalyticsService', () => { + it('should redact the access token from URL', () => { + const url = 'https://example.com/#access_token=123'; + expect(sanitizeUrl(url)).toEqual('https://example.com/#access_token=redacted'); + }); + + it('should redact the join key from URL', () => { + const url = 'https://example.com/join/123'; + expect(sanitizeUrl(url)).toEqual('https://example.com/join/redacted'); + }); +}); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/analytics.service.ts b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/analytics.service.ts new file mode 100644 index 00000000000..bbc668e9202 --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/analytics.service.ts @@ -0,0 +1,78 @@ +import { Injectable } from '@angular/core'; +import { environment } from '../environments/environment'; +import { PwaService } from './pwa.service'; + +declare function gtag(...args: any): void; + +// Using a type rather than interface because I intend to turn in into a union type later for each type of event that +// can be reported. +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions +type EventParams = { + page_path: string; +}; + +@Injectable({ + providedIn: 'root' +}) +export class AnalyticsService { + constructor(private readonly pwaService: PwaService) {} + + /** + * Logs the page navigation event to the analytics service. This method is responsible for sanitizing the URL before + * logging it. + * @param url The URL of the page that was navigated to. + */ + logNavigation(url: string): void { + const sanitizedUrl = sanitizeUrl(url); + this.logEvent('page_view', { page_path: sanitizedUrl }); + } + + private logEvent(eventName: string, eventParams: EventParams): void { + if (this.pwaService.isOnline && typeof environment.googleTagId === 'string') { + gtag(eventName, environment.googleTagId, eventParams); + } + } +} + +const redacted = 'redacted'; + +// redact access token from the hash +function redactAccessToken(url: string): string { + const urlObj = new URL(url); + const hash = urlObj.hash; + + if (hash === '') return url; + + const hashObj = new URLSearchParams(hash.slice(1)); + const accessToken = hashObj.get('access_token'); + + if (accessToken === null) return url; + + hashObj.set('access_token', redacted); + urlObj.hash = hashObj.toString(); + return urlObj.toString(); +} + +function redactJoinKey(url: string): string { + const urlObj = new URL(url); + const pathParts = urlObj.pathname.split('/'); + const joinIndex = pathParts.indexOf('join'); + + if (joinIndex === -1) { + return url; + } + + pathParts[joinIndex + 1] = redacted; + urlObj.pathname = pathParts.join('/'); + return urlObj.toString(); +} + +/** + * Redacts sensitive information from the given URL. Currently this only redacts the access token and the join key, so + * if relying on this method in the future, be sure to check that it is still redacting everything you need it to. + * @param url The URL to sanitize. + * @returns A sanitized version of the URL. + */ +export function sanitizeUrl(url: string): string { + return redactAccessToken(redactJoinKey(url)); +} diff --git a/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/error-reporting-service.spec.ts b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/error-reporting-service.spec.ts index 8081fb5b9af..33411ac8b1b 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/error-reporting-service.spec.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/error-reporting-service.spec.ts @@ -43,10 +43,8 @@ describe('ErrorReportingService', () => { ErrorReportingService.beforeSend({}, event); expect(event.breadcrumbs[0].metadata.from).toEqual('http://localhost:5000/somewhere&access_token=thing'); expect(event.breadcrumbs[0].metadata.to).toEqual('http://localhost:5000/somewhere'); - expect(event.breadcrumbs[1].metadata.from).toEqual( - 'http://localhost:5000/projects#access_token=redacted_for_error_report' - ); + expect(event.breadcrumbs[1].metadata.from).toEqual('http://localhost:5000/projects#access_token=redacted'); expect(event.breadcrumbs[1].metadata.to).toEqual('http://localhost:5000/projects'); - expect(event.request.url).toEqual('http://localhost:5000/projects#access_token=redacted_for_error_report'); + expect(event.request.url).toEqual('http://localhost:5000/projects#access_token=redacted'); }); }); diff --git a/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/error-reporting.service.ts b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/error-reporting.service.ts index 79182aaced3..50bf3519d6c 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/error-reporting.service.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/error-reporting.service.ts @@ -1,5 +1,6 @@ import { Injectable } from '@angular/core'; import Bugsnag, { Event, NotifiableError } from '@bugsnag/js'; +import { sanitizeUrl } from './analytics.service'; export interface EventMetadata { [key: string]: object; @@ -9,14 +10,14 @@ export interface EventMetadata { providedIn: 'root' }) export class ErrorReportingService { - static beforeSend(metaData: EventMetadata, event: Event) { + static beforeSend(metaData: EventMetadata, event: Event): void { if (typeof event.request.url === 'string') { - event.request.url = ErrorReportingService.redactAccessToken(event.request.url as string); + event.request.url = sanitizeUrl(event.request.url as string); } event.breadcrumbs = event.breadcrumbs.map(breadcrumb => { if (breadcrumb.type === 'navigation' && breadcrumb.metadata && typeof breadcrumb.metadata.from === 'string') { - breadcrumb.metadata.from = ErrorReportingService.redactAccessToken(breadcrumb.metadata.from); - breadcrumb.metadata.to = ErrorReportingService.redactAccessToken(breadcrumb.metadata.to); + breadcrumb.metadata.from = sanitizeUrl(breadcrumb.metadata.from); + breadcrumb.metadata.to = sanitizeUrl(breadcrumb.metadata.to); } return breadcrumb; }); @@ -40,13 +41,9 @@ export class ErrorReportingService { } else return error; } - private static redactAccessToken(url: string): string { - return url.replace(/^(.*#access_token=).*$/, '$1redacted_for_error_report'); - } - private metadata: EventMetadata = {}; - addMeta(data: object, tabName: string = 'custom') { + addMeta(data: object, tabName: string = 'custom'): void { this.metadata[tabName] = { ...this.metadata[tabName], ...data }; } @@ -54,7 +51,7 @@ export class ErrorReportingService { Bugsnag.notify(error, event => ErrorReportingService.beforeSend(this.metadata, event), callback); } - silentError(message: string, metadata?: object) { + silentError(message: string, metadata?: object): void { if (metadata != null) { this.addMeta(metadata); } diff --git a/src/SIL.XForge.Scripture/Pages/Shared/_Layout.cshtml b/src/SIL.XForge.Scripture/Pages/Shared/_Layout.cshtml index 677287774d4..0d3db61144a 100644 --- a/src/SIL.XForge.Scripture/Pages/Shared/_Layout.cshtml +++ b/src/SIL.XForge.Scripture/Pages/Shared/_Layout.cshtml @@ -7,14 +7,15 @@ - - +