From 86f74c5e52da3b0299ad4f11a873a111463d3e63 Mon Sep 17 00:00:00 2001 From: Rahul Gupta Date: Thu, 29 Aug 2024 03:36:47 +0530 Subject: [PATCH] feat: Add Solid-PREP Notifications Add Solid/Activity Streams format notifications: + Provides notifictions in JSON-LD and Turtle. + Extends notifications to PUT and POST methods. + Add Event-ID header field to the response of a write method. --- lib/handlers/delete.js | 2 + lib/handlers/get.js | 8 ++- lib/handlers/notify.js | 103 ++++++++++++++++++++++++++++--- lib/handlers/patch.js | 2 + lib/handlers/post.js | 4 ++ lib/handlers/put.js | 2 + lib/ldp-middleware.js | 4 +- lib/rdf-notification-template.js | 66 ++++++++++++++++++++ 8 files changed, 179 insertions(+), 12 deletions(-) create mode 100644 lib/rdf-notification-template.js diff --git a/lib/handlers/delete.js b/lib/handlers/delete.js index 77eb7f05f..c267cd266 100644 --- a/lib/handlers/delete.js +++ b/lib/handlers/delete.js @@ -9,6 +9,8 @@ async function handler (req, res, next) { try { await ldp.delete(req) debug('DELETE -- Ok.') + // Add event-id for notifications + res.setHeader('Event-ID', res.setEventID()) res.sendStatus(200) next() } catch (err) { diff --git a/lib/handlers/get.js b/lib/handlers/get.js index 422665410..af29a741d 100644 --- a/lib/handlers/get.js +++ b/lib/handlers/get.js @@ -19,6 +19,8 @@ const error = require('../http-error') const RDFs = require('../ldp').mimeTypesAsArray() const isRdf = require('../ldp').mimeTypeIsRdf +const prepConfig = 'accept=("message/rfc822" "application/ld+json" "text/turtle")' + async function handler (req, res, next) { const ldp = req.app.locals.ldp const includeBody = req.method === 'GET' @@ -104,7 +106,7 @@ async function handler (req, res, next) { debug(' sending data browser file: ' + dataBrowserPath) res.sendFile(dataBrowserPath) return - } else if (stream) { + } else if (stream) { // EXIT text/html res.setHeader('Content-Type', contentType) return stream.pipe(res) } @@ -137,7 +139,7 @@ async function handler (req, res, next) { } if (isRdf(contentType) && !res.sendEvents({ - config: { prep: '' }, + config: { prep: prepConfig }, body: stream, isBodyStream: true, headers @@ -159,7 +161,7 @@ async function handler (req, res, next) { 'Content-Type': possibleRDFType } if (isRdf(contentType) && !res.sendEvents({ - config: { prep: '' }, + config: { prep: prepConfig }, body: data, headers })) return diff --git a/lib/handlers/notify.js b/lib/handlers/notify.js index 42ec21fa7..fad9a6892 100644 --- a/lib/handlers/notify.js +++ b/lib/handlers/notify.js @@ -1,12 +1,101 @@ module.exports = handler +const libPath = require('path/posix') + +const headerTemplate = require('express-prep/templates').header +const solidRDFTemplate = require('../rdf-notification-template') + +const ALLOWED_RDF_MIME_TYPES = [ + 'application/ld+json', + 'application/activity+json', + 'text/turtle' +] + +function getActivity (method) { + if (method === 'DELETE') { + return 'Delete' + } + return 'Update' +} + +function getParentActivity (method, status) { + if (method === 'DELETE') { + return 'Remove' + } + if (status === 201) { + return 'Add' + } + return 'Update' +} + function handler (req, res, next) { - res.events.prep.trigger({ - generateNotifications () { - return res.events.prep.defaultNotification({ - ...(res.method === 'POST') && { location: res.getHeader('Content-Location') } - }) - } - }) + const { trigger, defaultNotification } = res.events.prep + + const { method } = req + const { statusCode } = res + const eventID = res.getHeader('event-id') + + const parent = `${libPath.dirname(req.path)}/` + const parentID = res.setEventID(parent) + const fullUrl = new URL(req.path, `${req.protocol}://${req.hostname}/`) + const parentUrl = new URL(parent, fullUrl) + + // Date is a hack since node does not seem to provide access to send date. + // Date needs to be shared with parent notification + const eventDate = res._header.match(/^Date: (.*?)$/m)?.[1] || + new Date().toUTCString() + + // If the resource itself newly created, + // it could not have been subscribed for notifications already + if (!((method === 'PUT' || method === 'PATCH') && statusCode === 201)) { + trigger({ + generateNotification ( + negotiatedFields + ) { + const mediaType = negotiatedFields['content-type'] + + if (ALLOWED_RDF_MIME_TYPES.includes(mediaType?.[0])) { + return `${headerTemplate(negotiatedFields)}\r\n${solidRDFTemplate({ + activity: getActivity(method), + eventID, + object: String(fullUrl), + date: eventDate, + // We use eTag as a proxy for state for now + state: res.getHeader('ETag'), + mediaType + })}` + } else { + return defaultNotification({ + ...(res.method === 'POST') && { location: res.getHeader('Content-Location') } + }) + } + } + }) + } + + // Write a notification to parent container + // POST in Solid creates a child resource + if (method !== 'POST') { + trigger({ + path: parent, + generateNotification ( + negotiatedFields + ) { + const mediaType = negotiatedFields['content-type'] + if (ALLOWED_RDF_MIME_TYPES.includes(mediaType?.[0])) { + return `${headerTemplate(negotiatedFields)}\r\n${solidRDFTemplate({ + activity: getParentActivity(method, statusCode), + eventID: parentID, + date: eventDate, + object: String(parentUrl), + target: statusCode === 201 ? String(fullUrl) : undefined, + eTag: undefined, + mediaType + })}` + } + } + }) + } + next() } diff --git a/lib/handlers/patch.js b/lib/handlers/patch.js index 481027fdf..c98e1470e 100644 --- a/lib/handlers/patch.js +++ b/lib/handlers/patch.js @@ -91,6 +91,8 @@ async function patchHandler (req, res, next) { return writeGraph(graph, resource, ldp.resourceMapper.resolveFilePath(req.hostname), ldp.serverUri) }) + // Add event-id for notifications + res.setHeader('Event-ID', res.setEventID()) // Send the status and result to the client res.status(resourceExists ? 200 : 201) res.send(result) diff --git a/lib/handlers/post.js b/lib/handlers/post.js index 5942519f9..cd136bd9b 100644 --- a/lib/handlers/post.js +++ b/lib/handlers/post.js @@ -72,6 +72,8 @@ async function handler (req, res, next) { // Handled by backpressure of streams! busboy.on('finish', function () { debug('Done storing files') + // Add event-id for notifications + res.setHeader('Event-ID', res.setEventID()) res.sendStatus(200) next() }) @@ -91,6 +93,8 @@ async function handler (req, res, next) { debug('File stored in ' + resourcePath) header.addLinks(res, links) res.set('Location', resourcePath) + // Add event-id for notifications + res.setHeader('Event-ID', res.setEventID()) res.sendStatus(201) next() }, diff --git a/lib/handlers/put.js b/lib/handlers/put.js index ba698ff97..d6b984b66 100644 --- a/lib/handlers/put.js +++ b/lib/handlers/put.js @@ -77,6 +77,8 @@ async function putStream (req, res, next, stream = req) { // Fails with Append on existing resource if (!req.originalUrl.endsWith('.acl')) await checkPermission(req, resourceExists) await ldp.put(req, stream, getContentType(req.headers)) + // Add event-id for notifications + res.setHeader('Event-ID', res.setEventID()) res.sendStatus(resourceExists ? 204 : 201) return next() } catch (err) { diff --git a/lib/ldp-middleware.js b/lib/ldp-middleware.js index cb76d341b..53eb90c27 100644 --- a/lib/ldp-middleware.js +++ b/lib/ldp-middleware.js @@ -24,9 +24,9 @@ function LdpMiddleware (corsSettings) { router.copy('/*', allow('Write'), copy) router.get('/*', index, allow('Read'), header.addPermissions, get) - router.post('/*', allow('Append'), post) + router.post('/*', allow('Append'), post, notify) router.patch('/*', allow('Append'), patch, notify) - router.put('/*', allow('Append'), put) + router.put('/*', allow('Append'), put, notify) router.delete('/*', allow('Write'), del, notify) return router diff --git a/lib/rdf-notification-template.js b/lib/rdf-notification-template.js new file mode 100644 index 000000000..8eeee2c7a --- /dev/null +++ b/lib/rdf-notification-template.js @@ -0,0 +1,66 @@ +const CONTEXT_ACTIVITYSTREAMS = 'https://www.w3.org/ns/activitystreams' +const CONTEXT_NOTIFICATION = 'https://www.w3.org/ns/solid/notification/v1' +const CONTEXT_XML_SCHEMA = 'http://www.w3.org/2001/XMLSchema' + +function generateJSONNotification ({ + activity: type, + eventId: id, + date: published, + object, + target, + state = undefined +}) { + return { + published, + type, + id, + object, + ...(type === 'Add') && { target }, + ...(type === 'Remove') && { origin: target }, + ...(state) && { state } + } +} + +function generateTurtleNotification ({ + activity, + eventId, + date, + object, + target, + state = undefined +}) { + const stateLine = `\n notify:state "${state}" ;` + + return `@prefix as: <${CONTEXT_ACTIVITYSTREAMS}#> . +@prefix notify: <${CONTEXT_NOTIFICATION}#> . +@prefix xsd: <${CONTEXT_XML_SCHEMA}#> . + +<${eventId}> a as:${activity} ;${state && stateLine} + as:object ${object} ; + as:published "${date}"^^xsd:dateTime .`.replaceAll('\n', '\r\n') +} + +function serializeToJSONLD (notification, isActivityStreams = false) { + notification['@context'] = [CONTEXT_NOTIFICATION] + if (!isActivityStreams) { + notification['@context'].unshift(CONTEXT_ACTIVITYSTREAMS) + } + return JSON.stringify(notification, null, 2) +} + +function rdfTemplate (props) { + const { mediaType } = props + if (mediaType[0] === 'application/activity+json' || (mediaType[0] === 'application/ld+json' && mediaType[1].get('profile')?.toLowerCase() === 'https://www.w3.org/ns/activitystreams')) { + return serializeToJSONLD(generateJSONNotification(props), true) + } + + if (mediaType[0] === 'application/ld+json') { + return serializeToJSONLD(generateJSONNotification(props)) + } + + if (mediaType[0] === 'text/turtle') { + return generateTurtleNotification(props) + } +} + +module.exports = rdfTemplate