UNPKG

@solid/community-server

Version:

Community Solid Server: an open and modular implementation of the Solid specifications

260 lines 13.1 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.BaseChannelType = exports.DEFAULT_SUBSCRIPTION_SHACL = exports.DEFAULT_NOTIFICATION_FEATURES = void 0; const node_crypto_1 = require("node:crypto"); const node_stream_1 = require("node:stream"); const policy_engine_1 = require("@solidlab/policy-engine"); const context_entries_1 = require("@comunica/context-entries"); const iso8601_duration_1 = require("iso8601-duration"); const n3_1 = require("n3"); const rdf_parse_1 = require("rdf-parse"); const rdf_validate_shacl_1 = __importDefault(require("rdf-validate-shacl")); const ConversionUtil_1 = require("../../storage/conversion/ConversionUtil"); const UnprocessableEntityHttpError_1 = require("../../util/errors/UnprocessableEntityHttpError"); const IdentifierMap_1 = require("../../util/map/IdentifierMap"); const PathUtil_1 = require("../../util/PathUtil"); const StreamUtil_1 = require("../../util/StreamUtil"); const StringUtil_1 = require("../../util/StringUtil"); const Vocabularies_1 = require("../../util/Vocabularies"); const Notification_1 = require("./Notification"); /** * All the necessary fields of the default features that are possible for all Notification Channels. */ const featureDefinitions = [ { predicate: Vocabularies_1.NOTIFY.terms.accept, key: 'accept', dataType: Vocabularies_1.XSD.string }, { predicate: Vocabularies_1.NOTIFY.terms.endAt, key: 'endAt', dataType: Vocabularies_1.XSD.dateTime }, { predicate: Vocabularies_1.NOTIFY.terms.rate, key: 'rate', dataType: Vocabularies_1.XSD.duration }, { predicate: Vocabularies_1.NOTIFY.terms.startAt, key: 'startAt', dataType: Vocabularies_1.XSD.dateTime }, { predicate: Vocabularies_1.NOTIFY.terms.state, key: 'state', dataType: Vocabularies_1.XSD.string }, ]; /** * The default notification features that are available on all channel types. */ exports.DEFAULT_NOTIFICATION_FEATURES = featureDefinitions.map((feat) => feat.predicate.value); // This context is slightly outdated but seems to be the only "official" source for a SHACL context. const CONTEXT_SHACL = 'https://w3c.github.io/shacl/shacl-jsonld-context/shacl.context.ld.json'; /** * The SHACL shape for the minimum requirements on a notification channel subscription request. */ exports.DEFAULT_SUBSCRIPTION_SHACL = { // eslint-disable-next-line ts/naming-convention '@context': [CONTEXT_SHACL], // eslint-disable-next-line ts/naming-convention '@type': 'sh:NodeShape', // Use the topic predicate to find the focus node targetSubjectsOf: Vocabularies_1.NOTIFY.topic, closed: true, property: [ { path: Vocabularies_1.RDF.type, minCount: 1, maxCount: 1, nodeKind: 'sh:IRI' }, { path: Vocabularies_1.NOTIFY.topic, minCount: 1, maxCount: 1, nodeKind: 'sh:IRI' }, ...featureDefinitions.map((feat) => ({ path: feat.predicate.value, maxCount: 1, datatype: feat.dataType })), ], }; /** * A {@link NotificationChannelType} that handles the base case of parsing and serializing a notification channel. * Note that the `extractModes` call always requires Read permissions on the target resource. * * Uses SHACL to validate the incoming data in `initChannel`. * Classes extending this can pass extra SHACL properties in the constructor to extend the validation check. * * The `completeChannel` implementation is an empty function. */ class BaseChannelType { type; path; shacl; shaclQuads; features; /** * @param type - The URI of the notification channel type. * This will be added to the SHACL shape to validate incoming subscription data. * @param route - The route corresponding to the URL of the subscription service of this channel type. * Channel identifiers will be generated by appending a value to this URL. * @param features - The features that should be enabled for this channel type. * Values are expected to be full URIs, but the `notify:` prefix can also be used. * @param additionalShaclProperties - Any additional properties that need to be added to the default SHACL shape. */ constructor(type, route, features = exports.DEFAULT_NOTIFICATION_FEATURES, additionalShaclProperties = []) { this.type = type; this.path = route.getPath(); this.features = features.map((feature) => { if (feature.startsWith('notify:')) { feature = `${Vocabularies_1.NOTIFY.namespace}${feature.slice('notify:'.length)}`; } return n3_1.DataFactory.namedNode(feature); }); // Inject requested properties into default SHACL shape this.shacl = { ...exports.DEFAULT_SUBSCRIPTION_SHACL, property: [ ...exports.DEFAULT_SUBSCRIPTION_SHACL.property, // Add type check // eslint-disable-next-line ts/naming-convention { path: Vocabularies_1.RDF.type, hasValue: { '@id': type.value } }, ...additionalShaclProperties, ], }; } getDescription() { return { // eslint-disable-next-line ts/naming-convention '@context': [Notification_1.CONTEXT_NOTIFICATION], id: this.path, // At the time of writing, there is no base value for URIs in the notification context, // so we use the full URI instead. channelType: this.type.value, // Shorten known features to make the resulting JSON more readable feature: this.features.map((node) => { if (exports.DEFAULT_NOTIFICATION_FEATURES.includes(node.value)) { return node.value.slice(Vocabularies_1.NOTIFY.namespace.length); } if (node.value.startsWith(Vocabularies_1.NOTIFY.namespace)) { return `notify:${node.value.slice(Vocabularies_1.NOTIFY.namespace.length)}`; } return node.value; }), }; } /** * Initiates the channel by first calling {@link validateSubscription} followed by {@link quadsToChannel}. * Subclasses can override either function safely to impact the result of the function. */ // eslint-disable-next-line unused-imports/no-unused-vars async initChannel(data, credentials) { const subject = await this.validateSubscription(data); return this.quadsToChannel(data, subject); } /** * Returns an N3.js {@link Store} containing quads corresponding to the stored SHACL representation. * Caches this result so the conversion from JSON-LD to quads only has to happen once. */ async getShaclQuads() { if (!this.shaclQuads) { const shaclStream = rdf_parse_1.rdfParser.parse(node_stream_1.Readable.from(JSON.stringify(this.shacl)), { contentType: 'application/ld+json', // Make sure our internal version of the context gets used [context_entries_1.KeysRdfParseJsonLd.documentLoader.name]: new ConversionUtil_1.ContextDocumentLoader({ [CONTEXT_SHACL]: '@css:templates/contexts/shacl.jsonld', }), }); // Typing issue with rdf-parse library this.shaclQuads = await (0, StreamUtil_1.readableToQuads)(shaclStream); } return this.shaclQuads; } /** * Validates whether the given data conforms to the stored SHACL shape. * Will throw an {@link UnprocessableEntityHttpError} if validation fails. * Along with the SHACL check, this also makes sure there is only one matching entry in the dataset. * * @param data - The data to validate. * * @returns The focus node that corresponds to the subject of the found notification channel description. */ async validateSubscription(data) { // Need to make sure there is exactly one matching entry, which can't be done with SHACL. // The predicate used here must be the same as is used for `targetSubjectsOf` in the SHACL shape. const focusNodes = data.getSubjects(Vocabularies_1.NOTIFY.terms.topic, null, null); if (focusNodes.length === 0) { throw new UnprocessableEntityHttpError_1.UnprocessableEntityHttpError('Missing topic value.'); } if (focusNodes.length > 1) { throw new UnprocessableEntityHttpError_1.UnprocessableEntityHttpError('Only one subscription can be done at the same time.'); } const validator = new rdf_validate_shacl_1.default(await this.getShaclQuads()); const report = validator.validate(data); if (!report.conforms) { // Use the first error to generate error message const result = report.results[0]; const message = result.message[0]; throw new UnprocessableEntityHttpError_1.UnprocessableEntityHttpError(`${message.value} - ${result.path?.value}`); } // From this point on, we can assume the subject corresponds to a valid subscription request return focusNodes[0]; } /** * Converts a set of quads to a {@link NotificationChannel}. * Assumes the data is valid, so this should be called after {@link validateSubscription}. * * The generated identifier will be a URL made by combining the base URL of the channel type with a unique identifier. * * The values of the default features will be added to the resulting channel, * subclasses with additional features that need to be added are responsible for parsing those quads. * * @param data - Data to convert. * @param subject - The identifier of the notification channel description in the dataset. * * @returns The generated {@link NotificationChannel}. */ async quadsToChannel(data, subject) { const topic = data.getObjects(subject, Vocabularies_1.NOTIFY.terms.topic, null)[0]; const type = data.getObjects(subject, Vocabularies_1.RDF.terms.type, null)[0]; const channel = { id: (0, PathUtil_1.joinUrl)(this.path, (0, node_crypto_1.randomUUID)()), type: type.value, topic: topic.value, }; // Apply the values for all present features that are enabled for (const feature of exports.DEFAULT_NOTIFICATION_FEATURES) { const featNode = this.features.find((node) => node.value === feature); if (featNode) { const objects = data.getObjects(subject, feature, null); if (objects.length === 1) { // Will always succeed since we are iterating over a list which was built using `featureDefinitions` const { dataType, key } = featureDefinitions.find((feat) => feat.predicate.value === feature); let val = objects[0].value; if (dataType === Vocabularies_1.XSD.dateTime) { val = Date.parse(val); } else if (dataType === Vocabularies_1.XSD.duration) { val = (0, iso8601_duration_1.toSeconds)((0, iso8601_duration_1.parse)(val)) * 1000; } // Need to convince TS that we can assign `string | number` to this key channel[key] = val; } } } return channel; } /** * Converts the given channel to a JSON-LD description. * All fields found in the channel, except `lastEmit`, will be part of the result subject, * so subclasses should remove any fields that should not be exposed. */ async toJsonLd(channel) { const result = { // eslint-disable-next-line ts/naming-convention '@context': [ Notification_1.CONTEXT_NOTIFICATION, ], ...channel, }; // No need to expose this field delete result.lastEmit; // Convert all the epoch values back to the expected date/rate format for (const { key, dataType } of featureDefinitions) { const value = channel[key]; if (value) { if (dataType === Vocabularies_1.XSD.dateTime) { result[key] = new Date(value).toISOString(); } else if (dataType === Vocabularies_1.XSD.duration) { result[key] = (0, StringUtil_1.msToDuration)(value); } } } return result; } async extractModes(channel) { return new IdentifierMap_1.IdentifierSetMultiMap([[{ path: channel.topic }, policy_engine_1.PERMISSIONS.Read]]); } // eslint-disable-next-line unused-imports/no-unused-vars async completeChannel(channel) { // Do nothing } } exports.BaseChannelType = BaseChannelType; //# sourceMappingURL=BaseChannelType.js.map