diff --git a/examples/vuejectron/package.json b/examples/vuejectron/package.json index 1ce9c2e6..ea24c69d 100644 --- a/examples/vuejectron/package.json +++ b/examples/vuejectron/package.json @@ -4,7 +4,7 @@ "private": true, "scripts": { "dev": "vite", - "build": "vue-tsc --noEmit && vite build", + "build": "pnpm build:ldo && vue-tsc --noEmit && vite build", "preview": "vite preview", "lint": "eslint . --fix --ignore-path .gitignore", "build:ldo": "ldo build --input ./shapes --output ./ldo" diff --git a/examples/vuejectron/src/store/app.ts b/examples/vuejectron/src/store/app.ts index 1fceae50..a9782049 100644 --- a/examples/vuejectron/src/store/app.ts +++ b/examples/vuejectron/src/store/app.ts @@ -5,7 +5,7 @@ import { Agent, FileInstance, ImageInstance, Registration } from '@/models'; import { useCoreStore } from './core'; import { LdoBase } from '@ldo/ldo'; import { DataInstance } from '@janeirodigital/interop-data-model'; -import { changeData as ldoChangeData, commitData, createSolidLdoDataset, type SolidLdoDataset } from "@ldo/solid" +import { changeData as ldoChangeData, commitData, createSolidLdoDataset, type SolidLdoDataset } from '@ldo/solid'; import { getDefaultSession } from '@inrupt/solid-client-authn-browser'; import { Application, SaiEvent } from '@janeirodigital/interop-application'; @@ -32,22 +32,22 @@ type AgentId = string; type ProjectId = string; type ProjectInfo = { - instance: DataInstance - registration: RegistrationId - agent: AgentId -} + instance: DataInstance; + registration: RegistrationId; + agent: AgentId; +}; type ProjectChildInfo = { - instance: DataInstance - agent: AgentId - project: ProjectId -} + instance: DataInstance; + agent: AgentId; + project: ProjectId; +}; export const useAppStore = defineStore('app', () => { - const projectInstances: Record = {} - const taskInstances: Record = {} - const imageInstances: Record = {} - const fileInstances: Record = {} + const projectInstances: Record = {}; + const taskInstances: Record = {}; + const imageInstances: Record = {}; + const fileInstances: Record = {}; const coreStore = useCoreStore(); const agents = ref([]); const registrations = ref>({}); @@ -58,12 +58,11 @@ export const useAppStore = defineStore('app', () => { const currentProject = ref(); const saiError = ref(); const pushSubscription = ref(null); - - let solidLdoDataset: SolidLdoDataset + + let solidLdoDataset: SolidLdoDataset; const ldoProjects = ref>({}); const ldoTasks = ref>({}); - async function getPushSubscription() { const registration = await navigator.serviceWorker.ready; const subscription = await registration.pushManager.getSubscription(); @@ -75,8 +74,8 @@ export const useAppStore = defineStore('app', () => { async function enableNotifications() { const session = await ensureSaiSession(); if (!session.webPushService) { - return null - } + return null; + } const result = await Notification.requestPermission(); if (result === 'granted') { const registration = await navigator.serviceWorker.ready; @@ -94,7 +93,10 @@ export const useAppStore = defineStore('app', () => { async function subscribeViaPush(): Promise { const session = await ensureSaiSession(); - await session.subscribeViaPush(pushSubscription.value!, session.registrationIri); + await session.subscribeViaPush( + pushSubscription.value!, + 'http://localhost:3000/alice-work/dataRegistry/tasks/task-b' + ); } async function loadAgents(force = false): Promise { @@ -121,7 +123,7 @@ export const useAppStore = defineStore('app', () => { async function loadProjects(ownerId: string): Promise { if (registrations.value[ownerId]) return; - + const session = await ensureSaiSession(); const user = session.dataOwners.find((agent) => agent.iri === ownerId); if (!user) { @@ -144,17 +146,15 @@ export const useAppStore = defineStore('app', () => { instance: dataInstance, agent: ownerId, registration: registration.iri - } + }; ownerIndex[dataInstance.iri] = ownerId; - + // @ldo-solid - const ldoResource = solidLdoDataset.getResource(dataInstance.iri) - const readResult = await ldoResource.read() - if (readResult.isError) throw readResult + const ldoResource = solidLdoDataset.getResource(dataInstance.iri); + const readResult = await ldoResource.read(); + if (readResult.isError) throw readResult; - const ldoSolidProject = solidLdoDataset - .usingType(ProjectShapeType) - .fromSubject(dataInstance.iri) + const ldoSolidProject = solidLdoDataset.usingType(ProjectShapeType).fromSubject(dataInstance.iri); ldoOwnerProjects[registration.iri].push(ldoSolidProject); } } @@ -175,42 +175,38 @@ export const useAppStore = defineStore('app', () => { instance: dataInstance, agent: project.agent, project: projectId - } + }; // @ldo-solid - const ldoResource = solidLdoDataset.getResource(dataInstance.iri) - const readResult = await ldoResource.read() - if (readResult.isError) throw readResult + const ldoResource = solidLdoDataset.getResource(dataInstance.iri); + const readResult = await ldoResource.read(); + if (readResult.isError) throw readResult; - const ldoSolidTask = solidLdoDataset - .usingType(TaskShapeType) - .fromSubject(dataInstance.iri) + const ldoSolidTask = solidLdoDataset.usingType(TaskShapeType).fromSubject(dataInstance.iri); ldoProjectTasks.push(ldoSolidTask); } - ldoTasks.value[projectId] = ldoProjectTasks + ldoTasks.value[projectId] = ldoProjectTasks; } - + async function draftTask(projectId: string): Promise { - const projectInfo = projectInstances[projectId] - const newTaskInstance = await projectInfo.instance.newChildDataInstance(shapeTrees.task) + const projectInfo = projectInstances[projectId]; + const newTaskInstance = await projectInfo.instance.newChildDataInstance(shapeTrees.task); taskInstances[newTaskInstance.iri] = { instance: newTaskInstance, agent: projectInfo.agent, project: projectInfo.instance.iri - } - const ldoSolidTask = solidLdoDataset - .usingType(TaskShapeType) - .fromSubject(newTaskInstance.iri) + }; + const ldoSolidTask = solidLdoDataset.usingType(TaskShapeType).fromSubject(newTaskInstance.iri); ldoTasks.value[projectId].push(ldoSolidTask); - return ldoSolidTask + return ldoSolidTask; // return solidLdoDataset.createData(TaskShapeType, newTaskInstance.iri, ldoResource) } async function updateTask(task: Task) { await ensureSaiSession(); - if (!task['@id']) throw task - const info = getProjectChildInfo(task['@id']) + if (!task['@id']) throw task; + const info = getProjectChildInfo(task['@id']); const project = projectInstances[info.project]; if (!project) { throw new Error(`project not found ${info.project}`); @@ -220,33 +216,32 @@ export const useAppStore = defineStore('app', () => { throw new Error(`Data Instance not found for: ${task['@id']}`); } - const ldoProject = ldoProjects.value[project.registration] - .find(p => p['@id'] === project.instance.iri) - if (!ldoProject) throw new Error(`ldo project not found: ${project.instance.iri}`) - const isDraft = !ldoProject.hasTask?.find(t => t['@id'] === task['@id']) + const ldoProject = ldoProjects.value[project.registration].find((p) => p['@id'] === project.instance.iri); + if (!ldoProject) throw new Error(`ldo project not found: ${project.instance.iri}`); + const isDraft = !ldoProject.hasTask?.find((t) => t['@id'] === task['@id']); if (isDraft) { // add reference to new task - const cProject = changeData(ldoProject) - if (!cProject.hasTask) cProject.hasTask = [] - cProject.hasTask.push({ '@id': task['@id'] }) - const result = await commitData(cProject) - if (result.isError) throw result + const cProject = changeData(ldoProject); + if (!cProject.hasTask) cProject.hasTask = []; + cProject.hasTask.push({ '@id': task['@id'] }); + const result = await commitData(cProject); + if (result.isError) throw result; } - const result = await commitData(task) - if (result.isError) throw result + const result = await commitData(task); + if (result.isError) throw result; const indexToUpdate = ldoTasks.value[info.project].findIndex((t) => t['@id'] === task['@id']); - if (indexToUpdate === -1) throw new Error(`task not found: ${task['@id']}`) + if (indexToUpdate === -1) throw new Error(`task not found: ${task['@id']}`); // trigger effects const same = ldoTasks.value[info.project][indexToUpdate]; - delete ldoTasks.value[info.project][indexToUpdate] - ldoTasks.value[info.project][indexToUpdate] = same + delete ldoTasks.value[info.project][indexToUpdate]; + ldoTasks.value[info.project][indexToUpdate] = same; } async function deleteTask(task: Task) { await ensureSaiSession(); - if (!task['@id']) throw task - const info = getProjectChildInfo(task['@id']) + if (!task['@id']) throw task; + const info = getProjectChildInfo(task['@id']); const toDelete = tasks.value[info.project].find((t) => t['@id'] === task['@id']); if (!toDelete) { throw new Error(`task not found: ${task['@id']}`); @@ -280,17 +275,17 @@ export const useAppStore = defineStore('app', () => { if (!project) { throw new Error(`project not found ${file.project}`); } - + instance = await project.instance.newChildDataInstance(shapeTrees.file); instance.replaceValue(NFO.fileName, blob.name); instance.replaceValue(AWOL.type, blob.type); - fileInstances[instance.iri] ={ + fileInstances[instance.iri] = { instance, agent: project.agent, project: project.instance.iri - } + }; } - + const updated = instance2File(instance, file.project, file.owner); if (file.id === 'DRAFT') { @@ -322,52 +317,50 @@ export const useAppStore = defineStore('app', () => { share(projectId); } - function changeData(input: Type): Type { const resource = solidLdoDataset.getResource(input['@id']); return ldoChangeData(input, resource); - } function getProjectChildInfo(id: string): ProjectChildInfo { - return taskInstances[id] || imageInstances[id] || fileInstances[id] + return taskInstances[id] || imageInstances[id] || fileInstances[id]; } - + function getInfo(id: string): ProjectInfo | ProjectChildInfo { - return projectInstances[id] || getProjectChildInfo(id) + return projectInstances[id] || getProjectChildInfo(id); } function canUpdate(id: string): boolean { - const info = getInfo(id) - return info.instance.accessMode.includes(ACL.Update.value) + const info = getInfo(id); + return info.instance.accessMode.includes(ACL.Update.value); } - + function canDelete(id: string): boolean { - const info = getInfo(id) - return info.instance.accessMode.includes(ACL.Delete.value) + const info = getInfo(id); + return info.instance.accessMode.includes(ACL.Delete.value); } function canAddTasks(id: string): boolean { - const info = projectInstances[id] - return info.instance.findChildGrant(shapeTrees.task)?.accessMode.includes(ACL.Create.value) + const info = projectInstances[id]; + return info.instance.findChildGrant(shapeTrees.task)?.accessMode.includes(ACL.Create.value); } function canAddImages(id: string): boolean { - const info = projectInstances[id] - return info.instance.findChildGrant(shapeTrees.image)?.accessMode.includes(ACL.Create.value) + const info = projectInstances[id]; + return info.instance.findChildGrant(shapeTrees.image)?.accessMode.includes(ACL.Create.value); } function canAddFiles(id: string): boolean { - const info = projectInstances[id] - return info.instance.findChildGrant(shapeTrees.file)?.accessMode.includes(ACL.Create.value) + const info = projectInstances[id]; + return info.instance.findChildGrant(shapeTrees.file)?.accessMode.includes(ACL.Create.value); } function getAgentId(id: string): string { - return getInfo(id).agent + return getInfo(id).agent; } function getRegistrationId(id: string): string { - return projectInstances[id].registration + return projectInstances[id].registration; } function instance2File(instance: DataInstance, project: string, owner: string): FileInstance { @@ -375,7 +368,7 @@ export const useAppStore = defineStore('app', () => { id: instance.iri, filename: instance.getObject(NFO.fileName)?.value, project, - owner, + owner }; } @@ -403,9 +396,9 @@ export const useAppStore = defineStore('app', () => { saiError.value = err.message; if (err.response) console.error(err.response); } - throw err + throw err; } - solidLdoDataset = createSolidLdoDataset({ fetch: authnFetch}) + solidLdoDataset = createSolidLdoDataset({ fetch: authnFetch }); return saiSession; } @@ -457,7 +450,7 @@ export const useAppStore = defineStore('app', () => { instance, agent: project.agent, project: project.instance.iri - } + }; files.push(instance2File(instance, projectId, ownerIndex[projectId])); } @@ -476,7 +469,7 @@ export const useAppStore = defineStore('app', () => { instance, agent: project.agent, project: project.instance.iri - } + }; images.push(instance2File(instance, projectId, ownerIndex[projectId])); } @@ -490,7 +483,6 @@ export const useAppStore = defineStore('app', () => { .then((blb) => URL.createObjectURL(blb)); } - return { pushSubscription, getPushSubscription, diff --git a/package.json b/package.json index c4d32dd8..e6c03c23 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,7 @@ "volta": { "node": "20.7.0" }, - "packageManager": "pnpm@8.8.0", + "packageManager": "pnpm@9.0.2", "lint-staged": { "*.ts": [ "prettier --write", diff --git a/packages/application/src/application.ts b/packages/application/src/application.ts index 064a0e68..4ace33a0 100644 --- a/packages/application/src/application.ts +++ b/packages/application/src/application.ts @@ -38,14 +38,18 @@ export class Application { authorizationRedirectEndpoint: string; - webPushService?: {id: string, vapidPublicKey: string}; + webPushService?: { id: string; vapidPublicKey: string }; registrationIri: string; // TODO rename hasApplicationRegistration?: ReadableApplicationRegistration; - constructor(public webId: string, public applicationId: string, dependencies: ApplicationDependencies) { + constructor( + public webId: string, + public applicationId: string, + dependencies: ApplicationDependencies + ) { this.rawFetch = dependencies.fetch; this.fetch = fetchWrapper(this.rawFetch); this.factory = new ApplicationFactory({ fetch: this.fetch, randomUUID: dependencies.randomUUID }); @@ -62,10 +66,7 @@ export class Application { this.rawFetch ); // TODO: avoid double fetch - this.webPushService = await discoverWebPushService( - this.authorizationAgentIri, - this.rawFetch - ); + this.webPushService = await discoverWebPushService(this.authorizationAgentIri, this.rawFetch); if (!this.registrationIri) return; await this.buildRegistration(); await this.subscribeToRegistration(); @@ -100,13 +101,19 @@ export class Application { async subscribeViaPush(subscription: PushSubscription, topic: string): Promise { if (!this.webPushService) throw new Error('Web Push Service not found'); const channel = { - "@context": [ - "https://www.w3.org/ns/solid/notifications-context/v1" + '@context': [ + 'https://www.w3.org/ns/solid/notifications-context/v1', + { + notify: 'http://www.w3.org/ns/solid/notifications#' + } ], - type: "WebPushChannel2023", + type: 'notify:WebPushChannel2023', topic, - sentTo: subscription.endpoint, - keys: subscription.toJSON()['keys'] + sendTo: subscription.endpoint, + 'notify:keys': { + 'notify:auth': subscription.toJSON()['keys']['auth'], + 'notify:p256dh': subscription.toJSON()['keys']['p256dh'] + } }; const response = await this.fetch(this.webPushService.id, { method: 'POST', diff --git a/packages/service/config/controllers/web-push-webhooks.json b/packages/service/config/controllers/web-push-webhooks.json new file mode 100644 index 00000000..a70e6e6c --- /dev/null +++ b/packages/service/config/controllers/web-push-webhooks.json @@ -0,0 +1,31 @@ +{ + "@context": [ + "https://linkedsoftwaredependencies.org/bundles/npm/@janeirodigital/sai-server/^0.0.0/components/context.jsonld", + "https://linkedsoftwaredependencies.org/bundles/npm/@digita-ai/handlersjs-core/^0.0.0/components/context.jsonld", + "https://linkedsoftwaredependencies.org/bundles/npm/@digita-ai/handlersjs-http/^0.0.0/components/context.jsonld" + ], + "@graph": [ + { + "@id": "urn:solid:authorization-agent:controller:WebPushWebhooks", + "@type": "HttpHandlerController", + "label": "WebPushWebhooks Controller", + "routes": [ + { + "@type": "HttpHandlerRoute", + "path": "/agents/:encodedWebId/webhook-push/:encodedApplicationId", + "operations": [ + { + "@type": "HttpHandlerOperation", + "method": "POST", + "publish": false + } + ], + "handler": { + "@type": "WebPushWebhooksHandler", + "sessionManager": { "@id": "urn:ssv:SessionManager" } + } + } + ] + } + ] +} diff --git a/packages/service/config/controllers/web-push.json b/packages/service/config/controllers/web-push.json index 1184ee97..aa06a26c 100644 --- a/packages/service/config/controllers/web-push.json +++ b/packages/service/config/controllers/web-push.json @@ -9,6 +9,14 @@ "@id": "urn:solid:authorization-agent:controller:WebPush", "@type": "HttpHandlerController", "label": "WebPush Controller", + "preResponseHandler": { + "@type": "HttpSequenceContextHandler", + "contextHandlers": [ + { + "@type": "AuthnContextHandler" + } + ] + }, "routes": [ { "@type": "HttpHandlerRoute", @@ -21,7 +29,8 @@ } ], "handler": { - "@type": "WebPushHandler" + "@type": "WebPushHandler", + "sessionManager": { "@id": "urn:ssv:SessionManager" } } } ] diff --git a/packages/service/config/service.json b/packages/service/config/service.json index ee1106f7..ee2fd285 100644 --- a/packages/service/config/service.json +++ b/packages/service/config/service.json @@ -10,7 +10,8 @@ "./controllers/api.json", "./controllers/webhooks.json", "./controllers/invitations.json", - "./controllers/web-push.json" + "./controllers/web-push.json", + "./controllers/web-push-webhooks.json" ], "@graph": [ { @@ -35,6 +36,7 @@ { "@id": "urn:solid:authorization-agent:controller:API" }, { "@id": "urn:solid:authorization-agent:controller:Webhooks" }, { "@id": "urn:solid:authorization-agent:controller:WebPush" }, + { "@id": "urn:solid:authorization-agent:controller:WebPushWebhooks" }, { "@id": "urn:solid:authorization-agent:controller:Invitations" } ] } diff --git a/packages/service/src/handlers/agents-handler.ts b/packages/service/src/handlers/agents-handler.ts index ef0f4954..43989e91 100644 --- a/packages/service/src/handlers/agents-handler.ts +++ b/packages/service/src/handlers/agents-handler.ts @@ -14,7 +14,7 @@ function clientIdDocument(agentUrl: string) { { interop: 'http://www.w3.org/ns/solid/interop#', notify: 'http://www.w3.org/ns/solid/notifications#' - }, + } ], client_id: agentUrl, client_name: 'Solid Authorization Agent', @@ -22,9 +22,9 @@ function clientIdDocument(agentUrl: string) { grant_types: ['refresh_token', 'authorization_code'], 'interop:hasAuthorizationRedirectEndpoint': process.env.FRONTEND_AUTHORIZATION_URL!, 'interop:pushService': { - 'id': `${process.env.BASE_URL}/agents/${agentUrl2encodedWebId(agentUrl)}/webpush`, - "channelType": 'notify:WebPushChannel2023', - "notify:vapidPublicKey": process.env.VAPID_PUBLIC_KEY!, + id: `${process.env.BASE_URL}/agents/${agentUrl2encodedWebId(agentUrl)}/webpush`, + channelType: 'notify:WebPushChannel2023', + 'notify:vapidPublicKey': process.env.VAPID_PUBLIC_KEY! } }; } diff --git a/packages/service/src/handlers/web-push-handler.ts b/packages/service/src/handlers/web-push-handler.ts index ebb4a935..bf00b9b5 100644 --- a/packages/service/src/handlers/web-push-handler.ts +++ b/packages/service/src/handlers/web-push-handler.ts @@ -1,33 +1,53 @@ /* eslint-disable class-methods-use-this */ import { from, Observable } from 'rxjs'; -import { HttpHandler, HttpHandlerResponse, HttpHandlerContext } from '@digita-ai/handlersjs-http'; +import { HttpHandler, HttpHandlerResponse, ForbiddenHttpError } from '@digita-ai/handlersjs-http'; import { getLogger } from '@digita-ai/handlersjs-logging'; -import { IQueue } from '@janeirodigital/sai-server-interfaces'; import { validateContentType } from '../utils/http-validators'; -import { decodeWebId } from '../url-templates'; -import { IDelegatedGrantsJobData, IPushNotificationsJobData } from '../models/jobs'; +import { decodeWebId, webhookPushUrl } from '../url-templates'; +import { getOneMatchingQuad, NOTIFY, parseJsonld } from '@janeirodigital/interop-utils'; +import { SessionManager } from '../session-manager'; +import { SubscriptionClient } from '@solid-notifications/subscription'; +import { AuthenticatedAuthnContext } from '../models/http-solid-context'; export class WebPushHandler extends HttpHandler { private logger = getLogger(); - constructor( - // private grantsQueue: IQueue, - // private pushQueue: IQueue - ) { + constructor(private sessionManager: SessionManager) { super(); this.logger.info('WebPushHandler::constructor'); } - - async handleAsync(context: HttpHandlerContext): Promise { - // validateContentType(context, 'application/ld+json'); + // TODO: validate channel info + async handleAsync(context: AuthenticatedAuthnContext): Promise { + validateContentType(context, 'application/ld+json'); + const applicationId = context.authn.clientId; const webId = decodeWebId(context.request.parameters!.encodedWebId); - console.log(context.request.body); + if (webId !== context.authn.webId) { + throw new ForbiddenHttpError('wrong authorization agent'); + } + const requestedChannel = await parseJsonld(context.request.body); + + const pushChannelInfo = { + 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); + const topic = getOneMatchingQuad(requestedChannel, null, NOTIFY.topic)!.object.value; + + if (await this.sessionManager.addWebhookPushTopic(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)); + } return { body: {}, status: 200, headers: {} }; } - handle(context: HttpHandlerContext): Observable { + handle(context: AuthenticatedAuthnContext): Observable { this.logger.info('WebPushHandler::handle'); return from(this.handleAsync(context)); } diff --git a/packages/service/src/handlers/web-push-webhooks-handler.ts b/packages/service/src/handlers/web-push-webhooks-handler.ts new file mode 100644 index 00000000..6e66caf0 --- /dev/null +++ b/packages/service/src/handlers/web-push-webhooks-handler.ts @@ -0,0 +1,69 @@ +/* eslint-disable class-methods-use-this */ + +import { from, Observable } from 'rxjs'; +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(); + + constructor(private sessionManager: SessionManager) { + super(); + + this.logger.info('WebPushWebhooksHandler::constructor'); + } + + async handleAsync(context: HttpHandlerContext): Promise { + validateContentType(context, 'application/ld+json'); + + 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!, + process.env.VAPID_PUBLIC_KEY!, + process.env.VAPID_PRIVATE_KEY! + ); + + // TODO: i18n + const notificationPayload = { + notification: { + title: 'SAI update', + body: `data changed`, + data: body + } + }; + const subscriptions = await this.sessionManager.getWebhookPushSubscription(webId, applicationId); + console.log('subscriptions', subscriptions); + + try { + await Promise.all( + subscriptions.map((info) => { + const sub = { + endpoint: info.sendTo, + keys: { + auth: info.keys.auth, + p256dh: info.keys.p256dh + } + }; + return webpush.sendNotification(sub, JSON.stringify(notificationPayload)); + }) + ); + } catch (error) { + this.logger.error('WebPushWebhooksHandler::handleAsync', error); + } + + return { body: {}, status: 200, headers: {} }; + } + + handle(context: HttpHandlerContext): Observable { + this.logger.info('WebPushWebhooksHandler::handle'); + return from(this.handleAsync(context)); + } +} diff --git a/packages/service/src/index.ts b/packages/service/src/index.ts index 89e44c3f..481474e4 100644 --- a/packages/service/src/index.ts +++ b/packages/service/src/index.ts @@ -11,6 +11,7 @@ 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-webhooks-handler'; // Models export * from './models/http-solid-context'; diff --git a/packages/service/src/session-manager.ts b/packages/service/src/session-manager.ts index 540e226f..2290c461 100644 --- a/packages/service/src/session-manager.ts +++ b/packages/service/src/session-manager.ts @@ -8,6 +8,14 @@ import { webId2agentUrl } from './url-templates'; type WebId = string; +type PushChannelInfo = { + sendTo: string; + keys: { + auth: string; + p256dh: string; + }; +}; + const cache = new Map(); const prefixes = { @@ -65,7 +73,7 @@ export class SessionManager implements ISessionManager { const duplicate = existing.find((existingSubscription) => existingSubscription.endpoint === subscription.endpoint); if (duplicate) return; - await this.storage.set(key, JSON.stringify([subscription, ...existing])); + return this.storage.set(key, JSON.stringify([subscription, ...existing])); } async getWebhookSubscription(webId: string, peerWebId: string): Promise { @@ -74,8 +82,34 @@ export class SessionManager implements ISessionManager { return value ? (JSON.parse(value) as NotificationChannel) : undefined; } - async setWebhookSubscription(webId: string, peerWebId: string, subscription: NotificationChannel): Promise { + setWebhookSubscription(webId: string, peerWebId: string, subscription: NotificationChannel): Promise { const key = `${prefixes.webhook}${webId}:${peerWebId}`; return this.storage.set(key, JSON.stringify(subscription)); } + + async getWebhookPushSubscription(webId: string, applicationId: string): Promise { + const key = `${prefixes.push}${webId}:${applicationId}`; + const value = await this.storage.get(key); + + return value ? (JSON.parse(value) as PushChannelInfo[]) : []; + } + + async addWebhookPushSubscription(webId: string, applicationId: string, pushChannel: PushChannelInfo): Promise { + const key = `${prefixes.push}${webId}:${applicationId}`; + const existing = await this.getWebhookPushSubscription(webId, applicationId); + const duplicate = existing.find((existingSubscription) => existingSubscription.sendTo === pushChannel.sendTo); + if (duplicate) return; + + return this.storage.set(key, JSON.stringify([pushChannel, ...existing])); + } + + async addWebhookPushTopic(webId: string, applicationId: string, topic: string): Promise { + const key = `${prefixes.push}${webId}:${applicationId}:${topic}`; + const existing = await this.storage.get(key); + + if (existing) return false; + + await this.storage.set(key, 'true'); + return true; + } } diff --git a/packages/service/src/url-templates.ts b/packages/service/src/url-templates.ts index eca9cd3b..a3cb4817 100644 --- a/packages/service/src/url-templates.ts +++ b/packages/service/src/url-templates.ts @@ -1,15 +1,23 @@ import 'dotenv/config'; +function encodeBase64(str: string): string { + return Buffer.from(str).toString('base64'); +} + +function decodeBase64(encoded: string): string { + return Buffer.from(encoded, 'base64').toString('ascii'); +} + export const baseUrl: string = process.env.BASE_URL!; export const frontendUrl: string = process.env.FRONTEND_URL!; export function encodeWebId(webId: string): string { - return Buffer.from(webId).toString('base64'); + return encodeBase64(webId); } export function decodeWebId(encoded: string): string { - return Buffer.from(encoded, 'base64').toString('ascii'); + return decodeBase64(encoded); } export function webId2agentUrl(webId: string): string { @@ -33,6 +41,10 @@ export function webhookTargetUrl(webId: string, peerWebId: string): string { return `${baseUrl}/agents/${encodeWebId(webId)}/webhook/${encodeWebId(peerWebId)}`; } +export function webhookPushUrl(webId: string, applicationId: string): string { + return `${baseUrl}/agents/${encodeWebId(webId)}/webhook-push/${encodeBase64(applicationId)}`; +} + export function invitationCapabilityUrl(webId: string, uuid: string): string { return `${baseUrl}/agents/${encodeWebId(webId)}/invitation/${uuid}`; }