@solid/community-server
Version:
Community Solid Server: an open and modular implementation of the Solid specifications
260 lines • 13.1 kB
JavaScript
;
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