Skip to content

Commit

Permalink
improve webpush to webhook
Browse files Browse the repository at this point in the history
  • Loading branch information
elf-pavlik committed Apr 23, 2024
1 parent f7ac922 commit 5d0e84e
Show file tree
Hide file tree
Showing 13 changed files with 181 additions and 47 deletions.
1 change: 1 addition & 0 deletions .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ module.exports = {
'@typescript-eslint/ban-ts-comment': 'off',
'no-shadow': 'off',
'@typescript-eslint/no-shadow': 'warn',
'consistent-return': 'off'
},
overrides: [
{
Expand Down
38 changes: 33 additions & 5 deletions examples/vuejectron/src/store/app.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Utilities
import { defineStore } from 'pinia';
import { ref } from 'vue';
import { useStorage } from '@vueuse/core'
import { Agent, FileInstance, ImageInstance, Registration } from '@/models';
import { useCoreStore } from './core';
import { LdoBase } from '@ldo/ldo';
Expand Down Expand Up @@ -44,6 +45,7 @@ type ProjectChildInfo = {
};

export const useAppStore = defineStore('app', () => {
const subscriptions = useStorage<Map<string, string>>('subscriptions', new Map())
const projectInstances: Record<string, ProjectInfo> = {};
const taskInstances: Record<string, ProjectChildInfo> = {};
const imageInstances: Record<string, ProjectChildInfo> = {};
Expand All @@ -64,6 +66,7 @@ export const useAppStore = defineStore('app', () => {
const ldoTasks = ref<Record<ProjectId, Task[]>>({});

async function getPushSubscription() {
if (pushSubscription.value) return
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.getSubscription();
if (subscription) {
Expand Down Expand Up @@ -91,12 +94,35 @@ export const useAppStore = defineStore('app', () => {
return result;
}

async function subscribeViaPush(): Promise<void> {
async function subscribeViaPush(id: string): Promise<void> {
const session = await ensureSaiSession();
await session.subscribeViaPush(
pushSubscription.value!,
'http://localhost:3000/alice-work/dataRegistry/tasks/task-b'
);
await getPushSubscription()
const channel = await session.subscribeViaPush(pushSubscription.value!, id);
const info = getInfo(id) as ProjectInfo
const project = ldoProjects.value[info.registration].find(p => p['@id'] === id)
// also all the tasks
// @ts-expect-error
const taskChannels = await Promise.all(project?.hasTask.map(task => session.subscribeViaPush(pushSubscription.value!, task['@id']))) as NotificationChanel[]
subscriptions.value.set(id, channel.id)
for (const taskChannel of taskChannels) {
subscriptions.value.set(taskChannel.topic, taskChannel.id)
}
}

async function unsubscribeViaPush(id: string): Promise<void> {
const session = await ensureSaiSession();
const channelId = subscriptions.value.get(id)
if (!channelId) throw new Error('channel not found')
const response = await session.rawFetch(channelId, { method: 'DELETE'})
if (!response.ok) throw new Error('failed to unsubscribe')
const info = getInfo(id) as ProjectInfo
const project = ldoProjects.value[info.registration].find(p => p['@id'] === id)
// @ts-expect-error
const result = await Promise.all(project?.hasTask.map(task => session.unsubscribeFromPush(task['@id'], subscriptions.value.get(task['@id'])))) as boolean[]
if (result.includes(false)) throw new Error('failed to unsubscribe some of the tasks')
subscriptions.value.delete(id)
// @ts-expect-error
project?.hasTask.forEach(task => subscriptions.value.delete(task['@id']))
}

async function loadAgents(force = false): Promise<void> {
Expand Down Expand Up @@ -485,9 +511,11 @@ export const useAppStore = defineStore('app', () => {

return {
pushSubscription,
subscriptions,
getPushSubscription,
enableNotifications,
subscribeViaPush,
unsubscribeViaPush,
agents,
currentAgent,
currentProject,
Expand Down
16 changes: 9 additions & 7 deletions examples/vuejectron/src/sw.js
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
self.addEventListener('push', (event) => {

// show notification
self.registration.showNotification('SAI Auth', {
body: event.data.json().notification.body,
});
event.waitUntil(
self.registration.showNotification(event.data.json().notification.type, {
body: event.data.json().notification.object
})
)

// send to all clients
self.clients.matchAll().then((clientList) => {
for (const client of clientList) {
client.postMessage(event.data.json().notification)
}
});
for (const client of clientList) {
client.postMessage(event.data.json())
}
});

});

Expand Down
1 change: 0 additions & 1 deletion examples/vuejectron/src/views/Dashboard.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
<template>
<v-main>
<v-btn @click="appStore.subscribeViaPush">Test</v-btn>
<v-alert
v-if="!appStore.pushSubscription"
color="warning"
Expand Down
16 changes: 15 additions & 1 deletion examples/vuejectron/src/views/Main.vue
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@
<v-btn :icon="icon" @click="navigateUp"> </v-btn>
</template>
<template v-if="route.query.project" #append>
<v-btn icon="mdi-share-variant" @click="shareProject"> </v-btn>
<v-btn v-if="!appStore.subscriptions.has(route.query.project as string)" icon="mdi-bell-ring-outline" @click="subscribeToProject"></v-btn>
<v-btn v-else icon="mdi-bell-off-outline" @click="unsubscribeFromProject"></v-btn>
<v-btn icon="mdi-share-variant" @click="shareProject"></v-btn>
</template>
</v-app-bar>

Expand Down Expand Up @@ -74,6 +76,18 @@ function navigateUp() {
}
}
function subscribeToProject() {
if (appStore.currentProject) {
appStore.subscribeViaPush(appStore.currentProject['@id']!);
}
}
function unsubscribeFromProject() {
if (appStore.currentProject) {
appStore.unsubscribeViaPush(appStore.currentProject['@id']!);
}
}
function shareProject() {
if (appStore.currentProject) {
appStore.shareProject(appStore.currentProject['@id']!);
Expand Down
8 changes: 7 additions & 1 deletion packages/application/src/application.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ export class Application {
};
}

async subscribeViaPush(subscription: PushSubscription, topic: string): Promise<void> {
async subscribeViaPush(subscription: PushSubscription, topic: string): Promise<NotificationChannel> {
if (!this.webPushService) throw new Error('Web Push Service not found');
const channel = {
'@context': [
Expand All @@ -125,6 +125,12 @@ export class Application {
if (!response.ok) {
throw new Error('Failed to subscribe via push');
}
return response.json()
}

async unsubscribeFromPush(topic: string, channelId: string): Promise<boolean> {
const response = await this.rawFetch(channelId, { method: 'DELETE'})
return (response.ok)
}

get authorizationRedirectUri(): string {
Expand Down
17 changes: 16 additions & 1 deletion packages/service/config/controllers/web-push.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,22 @@
}
],
"handler": {
"@type": "WebPushHandler",
"@type": "WebPushSubscribeHandler",
"sessionManager": { "@id": "urn:ssv:SessionManager" }
}
},
{
"@type": "HttpHandlerRoute",
"path": "/agents/:encodedWebId/webpush/:encodedTopic",
"operations": [
{
"@type": "HttpHandlerOperation",
"method": "DELETE",
"publish": false
}
],
"handler": {
"@type": "WebPushUnsubscribeHandler",
"sessionManager": { "@id": "urn:ssv:SessionManager" }
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,22 @@

import { from, Observable } from 'rxjs';
import { HttpHandler, HttpHandlerResponse, ForbiddenHttpError } from '@digita-ai/handlersjs-http';
import { getOneMatchingQuad, NOTIFY, parseJsonld } from '@janeirodigital/interop-utils';
import { SubscriptionClient } from '@solid-notifications/subscription';
import { getLogger } from '@digita-ai/handlersjs-logging';
import { validateContentType } from '../utils/http-validators';
import { decodeWebId, webhookPushUrl } from '../url-templates';
import { getOneMatchingQuad, NOTIFY, parseJsonld } from '@janeirodigital/interop-utils';
import { decodeWebId, webhookPushUrl, webPushUnsubscribeUrl } from '../url-templates';
import { SessionManager } from '../session-manager';
import { SubscriptionClient } from '@solid-notifications/subscription';
import { AuthenticatedAuthnContext } from '../models/http-solid-context';

export class WebPushHandler extends HttpHandler {
export class WebPushSubscribeHandler extends HttpHandler {
private logger = getLogger();

constructor(private sessionManager: SessionManager) {
super();
this.logger.info('WebPushHandler::constructor');
this.logger.info('WebPushSubscribeHandler::constructor');
}

// TODO: validate channel info
async handleAsync(context: AuthenticatedAuthnContext): Promise<HttpHandlerResponse> {
validateContentType(context, 'application/ld+json');
Expand All @@ -27,28 +28,48 @@ export class WebPushHandler extends HttpHandler {
}
const requestedChannel = await parseJsonld(context.request.body);

const pushChannelInfo = {
const pushSubscriptionInfo = {
sendTo: getOneMatchingQuad(requestedChannel, null, NOTIFY.sendTo)!.object.value,
keys: {
auth: getOneMatchingQuad(requestedChannel, null, NOTIFY.auth)!.object.value,
p256dh: getOneMatchingQuad(requestedChannel, null, NOTIFY.p256dh)!.object.value
}
};

await this.sessionManager.addWebhookPushSubscription(webId, applicationId, pushChannelInfo);
await this.sessionManager.addWebhookPushSubscription(webId, applicationId, pushSubscriptionInfo);
const topic = getOneMatchingQuad(requestedChannel, null, NOTIFY.topic)!.object.value;

if (await this.sessionManager.addWebhookPushTopic(webId, applicationId, topic)) {
const webPushChannel = {
'@context': [
'https://www.w3.org/ns/solid/notifications-context/v1',
{
notify: 'http://www.w3.org/ns/solid/notifications#'
}
],
id: webPushUnsubscribeUrl(webId, topic),
type: 'notify:WebPushChannel2023',
topic,
sendTo: pushSubscriptionInfo.sendTo,
}

if (!await this.sessionManager.getWebhookPushTopic(webId, applicationId, topic)) {
const saiSession = await this.sessionManager.getSaiSession(webId);
const subscriptionClient = new SubscriptionClient(saiSession.rawFetch as typeof fetch); // TODO: remove as
await subscriptionClient.subscribe(topic, NOTIFY.WebhookChannel2023.value, webhookPushUrl(webId, applicationId));
const webhookChannel = await subscriptionClient.subscribe(topic, NOTIFY.WebhookChannel2023.value, webhookPushUrl(webId, applicationId));
await this.sessionManager.addWebhookPushTopic(webId, applicationId, topic, webhookChannel)
return { body: webPushChannel, status: 201, headers: {
'content-type': 'application/ld+json'
} };
}

return { body: {}, status: 200, headers: {} };
return { body: webPushChannel, status: 200, headers: {
'content-type': 'application/ld+json'
} };

}

handle(context: AuthenticatedAuthnContext): Observable<HttpHandlerResponse> {
this.logger.info('WebPushHandler::handle');
this.logger.info('WebPushSubscribeHandler::handle');
return from(this.handleAsync(context));
}
}
41 changes: 41 additions & 0 deletions packages/service/src/handlers/web-push-unsubscribe-handler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/* eslint-disable class-methods-use-this */

import { from, Observable } from 'rxjs';
import { HttpHandler, HttpHandlerResponse, ForbiddenHttpError, NotFoundHttpError } from '@digita-ai/handlersjs-http';
import { getLogger } from '@digita-ai/handlersjs-logging';
import { decodeBase64, decodeWebId } from '../url-templates';
import { SessionManager } from '../session-manager';
import { AuthenticatedAuthnContext } from '../models/http-solid-context';

export class WebPushUnsubscribeHandler extends HttpHandler {
private logger = getLogger();

constructor(private sessionManager: SessionManager) {
super();
this.logger.info('WebPushUnsubscribeHandler::constructor');
}

// TODO: validate channel info
async handleAsync(context: AuthenticatedAuthnContext): Promise<HttpHandlerResponse> {
const applicationId = context.authn.clientId;
const webId = decodeWebId(context.request.parameters!.encodedWebId);
if (webId !== context.authn.webId) {
throw new ForbiddenHttpError('wrong authorization agent');
}

const topic = decodeBase64(context.request.parameters.encodedTopic)
const webhookChannel = await this.sessionManager.getWebhookPushTopic(webId, applicationId, topic)
if (!webhookChannel) throw new NotFoundHttpError()

const saiSession = await this.sessionManager.getSaiSession(webId);
const response = await saiSession.rawFetch(webhookChannel.id, { method: 'DELETE' })
if (!response.ok) return { status: 502, headers: {} };
await this.sessionManager.deleteWebhookPushTopic(webId, applicationId, topic)
return { status: 204, headers: {} };
}

handle(context: AuthenticatedAuthnContext): Observable<HttpHandlerResponse> {
this.logger.info('WebPushUnsubscribeHandler::handle');
return from(this.handleAsync(context));
}
}
10 changes: 2 additions & 8 deletions packages/service/src/handlers/web-push-webhooks-handler.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
/* eslint-disable class-methods-use-this */

import { from, Observable } from 'rxjs';
import webpush from 'web-push';
import { HttpHandler, HttpHandlerResponse, HttpHandlerContext } from '@digita-ai/handlersjs-http';
import { getLogger } from '@digita-ai/handlersjs-logging';
import { validateContentType } from '../utils/http-validators';
import { decodeWebId } from '../url-templates';
import { SessionManager } from '../session-manager';
import 'dotenv/config';
import webpush from 'web-push';

export class WebPushWebhooksHandler extends HttpHandler {
private logger = getLogger();
Expand All @@ -23,7 +23,6 @@ export class WebPushWebhooksHandler extends HttpHandler {

const webId = decodeWebId(context.request.parameters!.encodedWebId);
const applicationId = decodeWebId(context.request.parameters!.encodedApplicationId);
const body = JSON.parse(context.request.body);

webpush.setVapidDetails(
process.env.PUSH_NOTIFICATION_EMAIL!,
Expand All @@ -33,14 +32,9 @@ export class WebPushWebhooksHandler extends HttpHandler {

// TODO: i18n
const notificationPayload = {
notification: {
title: 'SAI update',
body: `data changed`,
data: body
}
notification: JSON.parse(context.request.body)
};
const subscriptions = await this.sessionManager.getWebhookPushSubscription(webId, applicationId);
console.log('subscriptions', subscriptions);

try {
await Promise.all(
Expand Down
3 changes: 2 additions & 1 deletion packages/service/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ export * from './handlers/authn-context-handler';
export * from './handlers/api-handler';
export * from './handlers/webhooks-handler';
export * from './handlers/invitations-handler';
export * from './handlers/web-push-handler';
export * from './handlers/web-push-subscribe-handler';
export * from './handlers/web-push-unsubscribe-handler';
export * from './handlers/web-push-webhooks-handler';

// Models
Expand Down
Loading

0 comments on commit 5d0e84e

Please sign in to comment.