diff --git a/backend/src/config/early/host.config.service.ts b/backend/src/config/early/host.config.service.ts index 32e2146..c7cd1ba 100644 --- a/backend/src/config/early/host.config.service.ts +++ b/backend/src/config/early/host.config.service.ts @@ -3,7 +3,7 @@ import { ConfigService } from '@nestjs/config'; import { ParseBool, ParseInt, - ParseString, + ParseString } from 'picsur-shared/dist/util/parse-simple'; import { EnvPrefix } from '../config.static'; @@ -16,8 +16,15 @@ export class HostConfigService { this.logger.log('Verbose: ' + this.isVerbose()); this.logger.log('Host: ' + this.getHost()); this.logger.log('Port: ' + this.getPort()); - this.logger.log('Demo: ' + this.isDemo()); - this.logger.log('Demo Interval: ' + this.getDemoInterval() / 1000 + 's'); + + if (this.isDemo()) { + this.logger.log('Running in demo mode'); + this.logger.log('Demo Interval: ' + this.getDemoInterval() / 1000 + 's'); + } + + if (!this.isTelemetry()) { + this.logger.log('Telemetry disabled'); + } } public getHost(): string { @@ -47,6 +54,10 @@ export class HostConfigService { return ParseBool(this.configService.get(`${EnvPrefix}VERBOSE`), false); } + public isTelemetry() { + return ParseBool(this.configService.get(`${EnvPrefix}TELEMETRY`), true); + } + public getVersion() { return ParseString(this.configService.get(`npm_package_version`), '0.0.0'); } diff --git a/frontend/src/app/app.component.ts b/frontend/src/app/app.component.ts index 5e160b3..52ae3d0 100644 --- a/frontend/src/app/app.component.ts +++ b/frontend/src/app/app.component.ts @@ -5,11 +5,12 @@ import { ActivatedRoute, NavigationEnd, NavigationError, - Router, + Router } from '@angular/router'; import { AutoUnsubscribe } from 'ngx-auto-unsubscribe-decorator'; import { RouteTransitionAnimations } from './app.animation'; import { PRouteData } from './models/dto/picsur-routes.dto'; +import { UmamiService } from './services/tracking/umami.service'; import { BootstrapService } from './util/bootstrap.service'; @Component({ @@ -33,6 +34,7 @@ export class AppComponent implements OnInit { private readonly router: Router, private readonly activatedRoute: ActivatedRoute, private readonly bootstrapService: BootstrapService, + private readonly umami: UmamiService, ) {} public getRouteAnimData() { diff --git a/frontend/src/app/services/tracking/umami.service.ts b/frontend/src/app/services/tracking/umami.service.ts new file mode 100644 index 0000000..7cd3ca0 --- /dev/null +++ b/frontend/src/app/services/tracking/umami.service.ts @@ -0,0 +1,170 @@ +import { DOCUMENT } from '@angular/common'; +import { Inject, Injectable } from '@angular/core'; +import { LOCATION, NAVIGATOR, WINDOW } from '@ng-web-apis/common'; +import { Logger } from '../logger/logger.service'; + +type UmamiCollectType = 'pageview' | 'event'; + +interface UmamiBasePayload { + website: string; + hostname: string; + screen: string; + language: string; + url: string; +} + +interface UmamiPayload { + pageview: UmamiBasePayload & { + referer: string; + }; + event: UmamiBasePayload & { + event_name: string; + event_data?: string; + }; +} + +type FunctionOnly = { + [K in keyof O]: O[K] extends (...args: any) => any ? O[K] : never; +}; + +const hook = , M extends keyof FT>( + self: FT, + method: M, + callback: (...args: Parameters) => any, +): FT[M] => { + const orig = self[method]; + + return ((...args: Parameters) => { + callback(...args); + + return orig.apply(self, args); + }) as FT[M]; +}; + +@Injectable({ + providedIn: 'root', +}) +export class UmamiService { + private readonly logger = new Logger(UmamiService.name); + + private doNotTrack = false; + + private SITE_ID = '8dd2491e-1984-4f22-9f41-ca880e630ffe'; + private REPORT_URL = 'http://localhost:3000/api/collect'; + + private umami_cache: string = ''; + + constructor( + @Inject(WINDOW) private readonly window: Window, + @Inject(DOCUMENT) private readonly document: Document, + @Inject(NAVIGATOR) private readonly navigator: Navigator, + @Inject(LOCATION) private readonly location: Location, + ) { + //this.doNotTrack = + // this.navigator.doNotTrack === '1' || this.navigator.doNotTrack === 'yes'; + + if (this.doNotTrack) this.logger.warn('Do not track is enabled'); + + this.setup(); + } + + private async setup() { + if (this.doNotTrack) return; + + const { history } = this.window; + history.pushState = hook(history, 'pushState', this.handlePush.bind(this)); + history.replaceState = hook( + history, + 'replaceState', + this.handlePush.bind(this), + ); + + const update = async () => { + if (document.readyState !== 'complete') return; + await this.trackView(); + }; + + document.addEventListener('readystatechange', update, true); + await update(); + } + + public async sendEvent(event: string, data?: string) { + if (this.doNotTrack) return; + return await this.trackEvent(event, data); + } + + private async handlePush( + data: any, + unused: string, + url?: string | URL | null, + ) { + if (!url) return; + + let currentUrl = this.currentUrl; + const referrer = currentUrl; + const newUrl = url.toString(); + + if (newUrl.substring(0, 4) === 'http') { + currentUrl = '/' + newUrl.split('/').splice(3).join('/'); + } else { + currentUrl = newUrl; + } + + if (currentUrl === referrer) return; + + return await this.trackView(currentUrl, referrer); + } + + private getPayload(): UmamiBasePayload { + const { hostname } = this.location; + const screen = `${this.window.screen.width}x${this.window.screen.height}`; + const { language } = this.navigator; + + return { + website: this.SITE_ID, + hostname, + screen, + language, + url: this.currentUrl, + }; + } + + private async collect( + type: T, + payload: UmamiPayload[T], + ) { + return this.window + .fetch(this.REPORT_URL, { + method: 'POST', + body: JSON.stringify({ type, payload }), + headers: { + 'Content-Type': 'application/json', + ['x-umami-cache']: this.umami_cache, + }, + }) + .then((res) => res.text()) + .then((text) => (this.umami_cache = text)); + } + + private async trackView(url?: string, referrer?: string) { + url = url || this.currentUrl; + referrer = referrer ?? this.document.referrer; + return this.collect('pageview', { + ...this.getPayload(), + url, + referer: referrer, + }); + } + + private async trackEvent(event: string, data?: string) { + return this.collect('event', { + ...this.getPayload(), + event_name: event, + event_data: data, + }); + } + + private get currentUrl() { + return this.location.pathname + this.location.search; + } +}