push notificaitons

This commit is contained in:
toni
2026-03-16 00:46:30 +01:00
parent 2620538a2c
commit e3184be911
20 changed files with 237 additions and 253 deletions
@@ -2,7 +2,6 @@ import express from "express";
import { import {
Controller, Controller,
Get, Get,
Post,
Request, Request,
Route, Route,
Security, Security,
@@ -17,13 +16,6 @@ import { KC_SECURITY_NAME } from "../services/keycloak/user/keycloak-user.js";
export class FoundEventsController extends Controller { export class FoundEventsController extends Controller {
private readonly foundEventsService = inject(FoundEventsService); private readonly foundEventsService = inject(FoundEventsService);
@Post("crawl")
@Security(KC_SECURITY_NAME)
@SuccessResponse("200", "OK")
public async crawl(): Promise<void> {
await this.foundEventsService.crawl();
}
@Get("future") @Get("future")
@Security(KC_SECURITY_NAME) @Security(KC_SECURITY_NAME)
@SuccessResponse("200", "OK") @SuccessResponse("200", "OK")
+2 -28
View File
@@ -32,39 +32,13 @@ export class PushContoller extends Controller {
); );
} }
@Post("reset")
@Security(KC_SECURITY_NAME)
@SuccessResponse("200", "OK")
public async resetSubscriptions(
@Request() request: express.Request,
): Promise<void> {
return await this.pushService.reset(request);
}
@Post("clearSingle") @Post("clearSingle")
@Security(KC_SECURITY_NAME) @Security(KC_SECURITY_NAME)
@SuccessResponse("200", "OK") @SuccessResponse("200", "OK")
public async clearSubscription( public async clearSubscription(
@Request() request: express.Request, @Request() request: express.Request,
@Query() clientId: string, @Query() clientId?: string,
): Promise<void> { ): Promise<void> {
await this.pushService.clearSubscription(clientId, request); await this.pushService.clearSubscription(clientId ?? null, request);
}
//TODO remove
@Post("test-publish")
@Security(KC_SECURITY_NAME)
@SuccessResponse("200", "OK")
public async publishTestMessage(
@Request() request: express.Request,
): Promise<void> {
this.pushService.sendtestNotification(request);
}
//TODO remove
@Post("notify")
@SuccessResponse("200", "OK")
public async notifyEvents(): Promise<void> {
await this.pushService.sendEventNotifications();
} }
} }
@@ -0,0 +1,26 @@
import { Controller, Post, Route, Security, SuccessResponse } from "tsoa";
import { Claim } from "../dtos/claim.js";
import { inject } from "../infrastructure/di/index.js";
import { FoundEventsService } from "../services/foundEvents/foundEventsService.js";
import { KC_SECURITY_NAME } from "../services/keycloak/user/keycloak-user.js";
import { PushService } from "../services/push/pushService.js";
@Route("test")
export class TestContoller extends Controller {
private readonly pushService = inject(PushService);
private readonly foundEventsService = inject(FoundEventsService);
@Post("notify")
@Security(KC_SECURITY_NAME, [Claim.UserAdmin])
@SuccessResponse("200", "OK")
public async notifyEvents(): Promise<void> {
await this.pushService.sendEventNotifications();
}
@Post("crawl")
@Security(KC_SECURITY_NAME)
@SuccessResponse("200", "OK")
public async crawl(): Promise<void> {
await this.foundEventsService.crawl();
}
}
+45 -2
View File
@@ -1,5 +1,6 @@
import constants from "constants"; import constants from "constants";
import cors from "cors"; import cors from "cors";
import { CronJob } from "cron";
import express, { import express, {
type NextFunction, type NextFunction,
type Request, type Request,
@@ -11,7 +12,9 @@ import { RegisterRoutes } from "./generated/routes.js";
import { inject } from "./infrastructure/di/injector.js"; import { inject } from "./infrastructure/di/injector.js";
import { sessionHandler } from "./infrastructure/sessionHandler.js"; import { sessionHandler } from "./infrastructure/sessionHandler.js";
import { EnvironmentService } from "./services/environmentService.js"; import { EnvironmentService } from "./services/environmentService.js";
import { FoundEventsService } from "./services/foundEvents/foundEventsService.js";
import { KeycloakUser } from "./services/keycloak/user/keycloak-user.js"; import { KeycloakUser } from "./services/keycloak/user/keycloak-user.js";
import { PushService } from "./services/push/pushService.js";
// import helmet from "helmet"; // import helmet from "helmet";
// TODO import http2 from 'http2'; // TODO import http2 from 'http2';
@@ -67,12 +70,13 @@ async function run() {
// key = fs.readFileSync("src/key.pem"); // key = fs.readFileSync("src/key.pem");
port = 3000; port = 3000;
app.listen(port, error => { const server = app.listen(port, error => {
if (error) { if (error) {
console.error("Startup crashed:", error); console.error("Startup crashed:", error);
} }
console.log(`Server is running at http://localhost:${port}`); console.log(`Server is running at http://localhost:${port}`);
}); });
server.on("close", () => stopCron());
} else { } else {
port = 5443; port = 5443;
const SSL_CERT_PATH = process.env["SSL_CERT_PATH"]; const SSL_CERT_PATH = process.env["SSL_CERT_PATH"];
@@ -100,17 +104,56 @@ async function run() {
constants.SSL_OP_NO_SSLv2 | constants.SSL_OP_NO_SSLv2 |
constants.SSL_OP_NO_SSLv3, constants.SSL_OP_NO_SSLv3,
}; };
https.createServer(sslOptions, app).listen(port, () => { const server = https.createServer(sslOptions, app).listen(port, () => {
console.log(`Server is running on port ${port}`); console.log(`Server is running on port ${port}`);
}); });
server.on("close", () => stopCron());
} }
// const h2SslOptions={}; // const h2SslOptions={};
// http2.createServer(h2SslOptions, app).listen(port, ()=>{ // http2.createServer(h2SslOptions, app).listen(port, ()=>{
// console.log(`HTTP/2 server is running on port ${port}`); // console.log(`HTTP/2 server is running on port ${port}`);
// }) // })
initJobs();
startCron();
console.log("setup done"); console.log("setup done");
} }
const jobs: CronJob[] = [];
function initJobs() {
const eventsService = inject(FoundEventsService);
const crawlJob = CronJob.from({
cronTime: "35 4 * * *",
onTick: async () => {
await eventsService.crawl();
},
});
jobs.push(crawlJob);
const pushService = inject(PushService);
const notifyJob = CronJob.from({
cronTime: "10 12 * * *",
onTick: async () => {
await pushService.sendEventNotifications();
},
});
jobs.push(notifyJob);
}
function startCron() {
console.log("starting jobs");
for (const job of jobs) {
job.start();
}
}
async function stopCron() {
console.log("stopping jobs");
for (const job of jobs) {
job.stop();
}
}
run(); run();
@@ -22,7 +22,7 @@ export class FoundEventsService {
maxRequestsPerMinute: 100, maxRequestsPerMinute: 100,
maxConcurrency: 2, maxConcurrency: 2,
requestHandler: async ({ request, page, log }) => { requestHandler: async ({ request, page, log }) => {
if (request.retryCount > 0) return; // if (request.retryCount > 0) return;
const label = request.label; const label = request.label;
@@ -67,8 +67,8 @@ export async function parseTUSDetail(
const aboluteUrl = const aboluteUrl =
url != null url != null
? isAbsolute ? isAbsolute
? new URL(url) ? new URL(url).toString()
: new URL(url, pageUrl) : new URL(url, pageUrl).toString()
: ""; : "";
return { return {
url: aboluteUrl, url: aboluteUrl,
+24 -103
View File
@@ -134,60 +134,43 @@ export class PushService {
lang: "de-DE", lang: "de-DE",
// TODO renotify: true, re-enable when tag // TODO renotify: true, re-enable when tag
requireInteraction: true, requireInteraction: true,
timestamp: date?.valueOf(), // timestamp: date?.valueOf(),
vibrate: [100], vibrate: [100],
// TODO tag: Topic.EVENTS, implement grouping in Angular SW // TODO tag: Topic.EVENTS, implement grouping in Angular SW
}, },
}; };
for (const subscription of subscriptions) { for (const s of subscriptions) {
if ( if (s.lastNotified && s.lastNotified >= event.foundDate) {
subscription.lastNotified &&
subscription.lastNotified >= event.foundDate
) {
// this subscription has already seen this event // this subscription has already seen this event
continue; continue;
} }
if ( if (!this.eventMatchesFilter(event, s.topicConfiguration)) {
!this.eventMatchesFilter(
event,
subscription.topicConfiguration,
)
) {
// this subscription does not care for this event // this subscription does not care for this event
continue; continue;
} }
console.log(`sending ${subscriptions.length} test messages`);
for (const s of subscriptions) {
try { try {
let backoff = 500; let backoff = 500;
let retry: boolean; let retry: boolean;
let maxRetry = 5;
do { do {
console.log("SENDING");
console.dir("payload");
retry = await this.sendNotification( retry = await this.sendNotification(
s, s,
payload, payload,
backoff, backoff,
); );
if (maxRetry <= 0) {
throw new Error("Exceeded max retry");
}
backoff *= 2; backoff *= 2;
maxRetry--;
} while (retry); } while (retry);
} catch (error) {
console.error("Cannot send notification: ", error);
throw error;
}
}
}
}
await this.db.doRequest( await this.db.doRequest(
async prisma => async prisma =>
prisma.pushSubscription.updateMany({ prisma.pushSubscription.update({
where: { where: {
id: { id: s.id,
in: subscriptions.map(s => s.id),
},
}, },
data: { data: {
lastNotified: new Date(Date.now()), lastNotified: new Date(Date.now()),
@@ -195,92 +178,21 @@ export class PushService {
}), }),
null, null,
); );
}
public async sendtestNotification(request: Request) {
const subscriptions = await this.db.doRequest(
async prisma =>
prisma.pushSubscription.findMany({ where: { topic: "TEST" } }),
request,
);
const payload: AngularPushPayload = {
notification: {
title: "ATitle",
actions: [
{
action: "explode",
title: "Make Boom",
},
],
body: "DatBod",
data: {
onActionClick: {
default: {
operation: NotificationActionOperation.OPEN_WINDOW,
},
explode: {
operation: NotificationActionOperation.OPEN_WINDOW,
url: "explode.html",
},
},
},
icon: "https://taekwondo-chemnitz.toni714.de/favicon.ico",
lang: "de-DE",
renotify: true,
requireInteraction: true,
tag: "group-tag",
timestamp: Date.now(),
vibrate: [100, 50, 100],
},
};
console.log(`sending ${subscriptions.length} test messages`);
for (const s of subscriptions) {
const subscription: webpush.PushSubscription = {
endpoint: s.endpoint,
keys: {
auth: s.auth,
p256dh: s.p256dh,
},
expirationTime: s.expirationTime,
};
try {
const result = await webpush.sendNotification(
subscription,
JSON.stringify(payload),
{
topic: Topic.TEST,
},
);
console.log("success");
console.dir(result);
} catch (error) { } catch (error) {
console.error("Cannot send notification: ", error); console.error("Cannot send notification: ", error);
throw error; // throw error;
} }
} }
} }
public async reset(request: Request): Promise<void> {
const userId = await this.userService.getUid(request, request.res);
const subscriptions = await this.db.doRequest(
async prisma =>
await prisma.pushSubscription.deleteMany({
where: { userId: userId },
}),
request,
);
console.log(`deleted ${subscriptions.count} subs for ${userId}`);
} }
public async clearSubscription( public async clearSubscription(
clientId: string, clientId: string | null,
request: Request, request: Request,
): Promise<number> { ): Promise<number> {
const userId = await this.userService.getUid(request, request.res); const userId = await this.userService.getUid(request, request.res);
const batchResult = await this.db.doRequest( const batchResult = clientId
? await this.db.doRequest(
async prisma => async prisma =>
await prisma.pushSubscription.deleteMany({ await prisma.pushSubscription.deleteMany({
where: { where: {
@@ -291,6 +203,15 @@ export class PushService {
}, },
}), }),
request, request,
)
: await this.db.doRequest(
async prisma =>
await prisma.pushSubscription.deleteMany({
where: {
userId: userId,
},
}),
request,
); );
return batchResult.count; return batchResult.count;
} }
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 17 KiB

+2 -14
View File
@@ -1,6 +1,6 @@
{ {
"name": "frontend", "name": "TKD Club Chemnitz Datenbank",
"short_name": "frontend", "short_name": "TCC DB",
"display": "standalone", "display": "standalone",
"scope": "./", "scope": "./",
"start_url": "./", "start_url": "./",
@@ -40,18 +40,6 @@
"sizes": "192x192", "sizes": "192x192",
"type": "image/png", "type": "image/png",
"purpose": "maskable any" "purpose": "maskable any"
},
{
"src": "icons/icon-384x384.png",
"sizes": "384x384",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "icons/icon-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable any"
} }
] ]
} }
@@ -74,6 +74,6 @@ export class Events implements OnInit {
await this.pushService.resetSubscriptions(); await this.pushService.resetSubscriptions();
} }
protected async subscribe() { protected async subscribe() {
this.pushService.subscribeToEvents(); await this.pushService.subscribeToEvents();
} }
} }
@@ -1,20 +1,28 @@
<h1>Testeite für interne Berechtigung</h1> <h1>Testeite für interne Berechtigung</h1>
<p>API: {{apiStatus()}}</p> <p>API: {{apiStatus()}}</p>
<p>Auth: {{authTest()}}</p> <p>Auth: {{authTest()}}</p>
<button (click)="pushService.subscribeToEvents()">Subscribe</button> <p>Capabilities: {{pushCaps|json}}</p>
<button (click)="pushService.resetSubscriptions()">Unsubscribe</button> <pre>More Caps: {{asyncCaps()|json}}</pre>
<button (click)="pushService.publishTestMessage()">TestMessage</button> <button [claimGuard]="Claim.Useradmin" (click)="crawl()">Crawl</button>
<button
[claimGuard]="Claim.Useradmin"
(click)="notifyAll()"
class="secondary-btn"
matButton="filled">
Notify All
</button>
Claim: Claim:
<ul> <ul>
@let claims = this.auth.claims(); @let claims = this.auth.claims(); @if(claims) {
@if(claims) { <pre>
<pre>
{{claims | json}} {{claims | json}}
</pre> </pre
>
} }
</ul> </ul>
<details name="UserInfo:"> <details name="UserInfo:">
<pre> <pre>
{{this.auth.userInfo() | json}} {{this.auth.userInfo() | json}}
</pre> </pre
>
</details> </details>
@@ -6,25 +6,35 @@ import {
inject, inject,
signal, signal,
} from '@angular/core'; } from '@angular/core';
import { ClaimGuardDirective } from '../../directives/claim-guard.directive';
import * as api from '../../generated-api/api'; import * as api from '../../generated-api/api';
import { Authentication } from '../../services/authentication'; import { Authentication } from '../../services/authentication';
import { PushService } from '../../services/push/pushService'; import { PushService } from '../../services/push/pushService';
@Component({ @Component({
selector: 'app-test', selector: 'app-test',
imports: [JsonPipe], imports: [JsonPipe, ClaimGuardDirective],
templateUrl: './test.html', templateUrl: './test.html',
styleUrl: './test.scss', styleUrl: './test.scss',
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class Test { export class Test {
protected readonly pushService = inject(PushService); protected readonly pushService = inject(PushService);
protected auth = inject(Authentication); protected readonly auth = inject(Authentication);
protected apiStatus = signal<string>('Waiting'); protected readonly apiStatus = signal<string>('Waiting');
protected authTest = signal<string>('Waiting'); protected readonly authTest = signal<string>('Waiting');
protected readonly Claim = api.Claim;
protected readonly pushCaps = {
serviceWorker: 'serviceWorker' in navigator,
pushManager: 'PushManager' in window,
notification: 'Notification' in window,
notifyPermission: Notification.permission,
};
protected asyncCaps = signal<any>(null);
public constructor() { public constructor() {
this.loadCaps();
effect(() => { effect(() => {
const loggedIn = this.auth.loggedIn(); const loggedIn = this.auth.loggedIn();
if (!loggedIn) { if (!loggedIn) {
@@ -51,4 +61,22 @@ export class Test {
}); });
}); });
} }
private async loadCaps() {
const caps = {
registraion: await navigator.serviceWorker.getRegistrations(),
subscription: await (
await navigator.serviceWorker.ready
).pushManager.getSubscription(),
};
this.asyncCaps.set(caps);
}
protected async notifyAll() {
await api.notifyEvents();
}
protected async crawl() {
await api.crawl();
}
} }
@@ -25,16 +25,21 @@ export class PushService {
public async subscribeToEvents() { public async subscribeToEvents() {
try { try {
console.log('trying to subscribe');
const subscription = await this.swPush.requestSubscription({ const subscription = await this.swPush.requestSubscription({
serverPublicKey: VAPID.publicKey, serverPublicKey: VAPID.publicKey,
}); });
console.log('returned');
console.dir(subscription);
console.log('end');
const previousCliendId = localStorage.getItem( const previousCliendId = localStorage.getItem(
SUBSCRIPTION_CLIENT_ID, SUBSCRIPTION_CLIENT_ID,
); );
if (previousCliendId) { if (previousCliendId) {
try { try {
await api.clearSubscription(previousCliendId); await api.clearSubscription({ clientId: previousCliendId });
console.log('previous subscription cleared'); console.log('previous subscription cleared');
} catch {} } catch {}
} }
@@ -62,11 +67,10 @@ export class PushService {
} }
public async resetSubscriptions() { public async resetSubscriptions() {
const clientId = localStorage.getItem(SUBSCRIPTION_CLIENT_ID);
await this.swPush.unsubscribe(); await this.swPush.unsubscribe();
await api.resetSubscriptions(); await api.clearSubscription({
} clientId: clientId ?? undefined,
});
public async publishTestMessage() {
await api.publishTestMessage();
} }
} }