diff --git a/helpers/mu/sparql-tag.js b/helpers/mu/sparql-tag.js new file mode 100644 index 0000000..6254398 --- /dev/null +++ b/helpers/mu/sparql-tag.js @@ -0,0 +1,20 @@ +import Term from './term.js'; + +/** + * ECMAScript 2015 tagged template function for building safe SPARQL queries. + * + * Interpolated values are converted to their SPARQL representation using + * Term.create so they are properly escaped and typed. + * + * @example + * const name = "O'Brien"; + * const query = SPARQL`SELECT * WHERE { ?s foaf:name ${name} }`; + * // name becomes """O'Brien""" + */ +export default function SPARQL(template, ...substitutions) { + let result = template[0]; + substitutions.forEach((value, i) => { + result += Term.create(value).format() + template[i + 1]; + }); + return result; +} diff --git a/helpers/mu/sparql.js b/helpers/mu/sparql.js index c86a8b7..5c065fb 100644 --- a/helpers/mu/sparql.js +++ b/helpers/mu/sparql.js @@ -1,60 +1,16 @@ import httpContext from 'express-http-context'; -import SC2 from 'sparql-client-2'; import env from 'env-var'; - -const { SparqlClient, SPARQL } = SC2; +import SPARQL from './sparql-tag.js'; const LOG_SPARQL_QUERIES = process.env.LOG_SPARQL_QUERIES != undefined ? env.get('LOG_SPARQL_QUERIES').asBool() : env.get('LOG_SPARQL_ALL').asBool(); const LOG_SPARQL_UPDATES = process.env.LOG_SPARQL_UPDATES != undefined ? env.get('LOG_SPARQL_UPDATES').asBool() : env.get('LOG_SPARQL_ALL').asBool(); const DEBUG_AUTH_HEADERS = env.get('DEBUG_AUTH_HEADERS').asBool(); +const MU_SPARQL_ENDPOINT = process.env.MU_SPARQL_ENDPOINT || 'http://database:8890/sparql'; //==-- logic --==// -// builds a new sparqlClient -function newSparqlClient(userOptions) { - let options = { requestDefaults: { headers: { } } }; - - if (userOptions.sudo === true) { - if (env.get("ALLOW_MU_AUTH_SUDO").asBool()) { - options.requestDefaults.headers['mu-auth-sudo'] = "true"; - } else { - throw "Error, sudo request but service lacks ALLOW_MU_AUTH_SUDO header"; - } - } - - if (userOptions.scope) { - options.requestDefaults.headers['mu-auth-scope'] = userOptions.scope; - } else if (process.env.DEFAULT_MU_AUTH_SCOPE) { - options.requestDefaults.headers['mu-auth-scope'] = process.env.DEFAULT_MU_AUTH_SCOPE; - } - - if (httpContext.get('request')) { - options.requestDefaults.headers['mu-session-id'] = httpContext.get('request').get('mu-session-id'); - options.requestDefaults.headers['mu-call-id'] = httpContext.get('request').get('mu-call-id'); - options.requestDefaults.headers['mu-auth-allowed-groups'] = httpContext.get('request').get('mu-auth-allowed-groups'); // groups of incoming request - } - - if (httpContext.get('response')) { - const allowedGroups = httpContext.get('response').get('mu-auth-allowed-groups'); // groups returned by a previous SPARQL query - if (allowedGroups) - options.requestDefaults.headers['mu-auth-allowed-groups'] = allowedGroups; - } - - if (DEBUG_AUTH_HEADERS) { - console.log(`Headers set on SPARQL client: ${JSON.stringify(options)}`); - } - - return new SparqlClient(process.env.MU_SPARQL_ENDPOINT, options); -} - /** - * @typedef {Object} QueryOptions - * @property {boolean?} sudo Execute the query as sudo - * @property {string?} scope URI of the scope with whith the query is executed. Use the environment variable `DEFAULT_MU_AUTH_SCOPE` if possible. - */ - -/** - * Execute a sparql QUERY. Intended for use with QUERY and ASK. + * Execute a sparql QUERY. Intended for use with SELECT and ASK. * * See environment variables for logging: `LOG_SPARQL_ALL`, `LOG_SPARQL_QUERIES`, `DEBUG_AUTH_HEADERS` * @@ -62,15 +18,15 @@ function newSparqlClient(userOptions) { * @param { QueryOptions? } options Operational changes to the SPARQL query. * @return { Promise } The response is returned as a parsed JSON object, or null if the response could not be parsed as JSON. */ -function query( queryString, options ) { +function query(queryString, options = {}) { if (LOG_SPARQL_QUERIES) { console.log(queryString); } return executeQuery(queryString, options); -}; +} /** - * Execute a sparql QUERY. + * Execute a sparql UPDATE. * Intended for use with `DELETE {} INSERT {} WHERE {}`, `INSERT DATA` and `DELETE DATA`. * * See environment variables for logging: `LOG_SPARQL_ALL`, `LOG_SPARQL_UPDATES`, `DEBUG_AUTH_HEADERS` @@ -79,58 +35,150 @@ function query( queryString, options ) { * @param { QueryOptions? } options Operational changes to the SPARQL query. * @return { Promise } The response is returned as a parsed JSON object, or null if the response could not be parsed as JSON. */ -function update( queryString, options ) { +function update(queryString, options = {}) { if (LOG_SPARQL_UPDATES) { console.log(queryString); } return executeQuery(queryString, options); -}; +} + +/** + * Build the default headers for a SPARQL request from the current HTTP + * context, forwarding mu-auth headers so mu-authorization can apply the + * correct access rules. + */ +function defaultHeaders() { + const headers = new Headers(); + headers.set('content-type', 'application/x-www-form-urlencoded'); + headers.set('Accept', 'application/sparql-results+json'); + + const req = httpContext.get('request'); + if (req) { + const muSessionId = req.get('mu-session-id'); + if (muSessionId) headers.set('mu-session-id', muSessionId); + + const muCallId = req.get('mu-call-id'); + if (muCallId) headers.set('mu-call-id', muCallId); + + // Forward allowed-groups from the incoming request so mu-authorization + // does not have to recompute them on every SPARQL call. + const allowedGroups = req.get('mu-auth-allowed-groups'); + if (allowedGroups) headers.set('mu-auth-allowed-groups', allowedGroups); + } + + const res = httpContext.get('response'); + if (res) { + // If a previous SPARQL query within this request already resolved the + // allowed groups, forward them to avoid redundant lookups. + const allowedGroups = res.get('mu-auth-allowed-groups'); + if (allowedGroups) headers.set('mu-auth-allowed-groups', allowedGroups); + } + + return headers; +} -function executeQuery( queryString, options ) { - return newSparqlClient(options || {}).query(queryString).executeRaw().then(response => { - const temp = httpContext; - - if (httpContext.get('response') && !httpContext.get('response').headersSent) { - // set mu-auth-allowed-groups on outgoing response - const allowedGroups = response.headers['mu-auth-allowed-groups']; - if (allowedGroups) { - httpContext.get('response').setHeader('mu-auth-allowed-groups', allowedGroups); - if (DEBUG_AUTH_HEADERS) { - console.log(`Update mu-auth-allowed-groups to ${allowedGroups}`); - } - } else { - httpContext.get('response').removeHeader('mu-auth-allowed-groups'); - if (DEBUG_AUTH_HEADERS) { - console.log('Remove mu-auth-allowed-groups'); - } - } - - // set mu-auth-used-groups on outgoing response - const usedGroups = response.headers['mu-auth-used-groups']; - if (usedGroups) { - httpContext.get('response').setHeader('mu-auth-used-groups', usedGroups); - if (DEBUG_AUTH_HEADERS) { - console.log(`Update mu-auth-used-groups to ${usedGroups}`); - } - } else { - httpContext.get('response').removeHeader('mu-auth-used-groups'); - if (DEBUG_AUTH_HEADERS) { - console.log('Remove mu-auth-used-groups'); - } - } +/** + * @typedef {Object} QueryOptions + * @property {boolean?} sudo Execute the query with mu-auth-sudo privileges. + * @property {string?} scope URI of the scope to use. Falls back to the DEFAULT_MU_AUTH_SCOPE environment variable. + * @property {object?} extraHeaders Additional headers to include in the request. + */ + +/** + * Send a SPARQL query to the configured endpoint and return the parsed JSON + * response. + * + * @param { string } queryString SPARQL query as a string. + * @param { QueryOptions? } options Operational changes to the SPARQL query. + * @return { Promise } The response is returned as a parsed JSON object, or null if the response could not be parsed as JSON. + */ +async function executeQuery(queryString, options = {}) { + const headers = defaultHeaders(); + + const extraHeaders = options.extraHeaders ?? {}; + for (const key of Object.keys(extraHeaders)) { + headers.append(key, extraHeaders[key]); + } + + if (options.sudo === true) { + if (env.get('ALLOW_MU_AUTH_SUDO').asBool()) { + headers.set('mu-auth-sudo', 'true'); + } else { + throw new Error('sudo query requested but ALLOW_MU_AUTH_SUDO is not set'); } + } + + if (options.scope) { + headers.set('mu-auth-scope', options.scope); + } else if (process.env.DEFAULT_MU_AUTH_SCOPE) { + headers.set('mu-auth-scope', process.env.DEFAULT_MU_AUTH_SCOPE); + } + + if (DEBUG_AUTH_HEADERS) { + const muHeaders = Array.from(headers.entries()) + .filter(([key]) => key.startsWith('mu-')) + .map(([key, value]) => `${key}: ${value}`) + .join(', '); + console.log(`SPARQL request mu-headers: ${muHeaders}`); + } + + const formData = new URLSearchParams(); + formData.set('query', queryString); - function maybeParseJSON(body) { - // Catch invalid JSON - try { - return JSON.parse(body); - } catch (ex) { - return null; - } + try { + const response = await fetch(MU_SPARQL_ENDPOINT, { + method: 'POST', + body: formData.toString(), + headers, + }); + + updateResponseHeaders(response); + + if (!response.ok) { + throw new Error(`SPARQL endpoint returned HTTP ${response.status} ${response.statusText}`); } - return maybeParseJSON(response.body); - }); + return await maybeJSON(response); + } catch (ex) { + console.log(`Failed Query: +${queryString}`); + throw ex; + } +} + +/** + * Copy mu-auth group headers from the SPARQL response back onto the outgoing + * HTTP response so the client receives up-to-date group information. + */ +function updateResponseHeaders(response) { + const res = httpContext.get('response'); + if (!res || res.headersSent) return; + + const allowedGroups = response.headers.get('mu-auth-allowed-groups'); + if (allowedGroups) { + res.setHeader('mu-auth-allowed-groups', allowedGroups); + if (DEBUG_AUTH_HEADERS) console.log(`Forwarded mu-auth-allowed-groups: ${allowedGroups}`); + } else { + res.removeHeader('mu-auth-allowed-groups'); + if (DEBUG_AUTH_HEADERS) console.log('Removed mu-auth-allowed-groups from response'); + } + + const usedGroups = response.headers.get('mu-auth-used-groups'); + if (usedGroups) { + res.setHeader('mu-auth-used-groups', usedGroups); + if (DEBUG_AUTH_HEADERS) console.log(`Forwarded mu-auth-used-groups: ${usedGroups}`); + } else { + res.removeHeader('mu-auth-used-groups'); + if (DEBUG_AUTH_HEADERS) console.log('Removed mu-auth-used-groups from response'); + } +} + +async function maybeJSON(response) { + try { + return await response.json(); + } catch (_) { + return null; + } } /** @@ -198,9 +246,10 @@ function sparqlEscapeDate( value ){ }; /** - * Escapes a date string or date object into an xsd:dateTime for use in a SPARQL. + * Escape date string or date object into an xsd:dateTime for use in a SPARQL string. * - * @param { Date | string | number } value Date representation (understood by `new Date`) to convert. + * @param { Date | string | number } value Date representation + * (understood by `new Date`) to convert. * @return { string } Date representation for SPARQL query. */ function sparqlEscapeDateTime( value ){ @@ -217,14 +266,6 @@ function sparqlEscapeBool( value ){ return value ? '"true"^^xsd:boolean' : '"false"^^xsd:boolean'; }; -/** - * Escapes a value based on the supplide type rather than the separately published functions. Prefer to use the - * functions. - * - * @param { "string"|"uri"|"bool"|"decimal"|"int"|"float"|"date"|"dateTime"} type The value to be escaped. - * @param {*} value The value to be escaped. - * @return { string } Boolean representation for SPARQL query. - */ function sparqlEscape( value, type ){ switch(type) { case 'string': @@ -251,25 +292,23 @@ function sparqlEscape( value, type ){ //==-- exports --==// const exports = { - newSparqlClient: newSparqlClient, - SPARQL: SPARQL, + SPARQL, sparql: SPARQL, - query: query, - update: update, - sparqlEscape: sparqlEscape, - sparqlEscapeString: sparqlEscapeString, - sparqlEscapeUri: sparqlEscapeUri, - sparqlEscapeDecimal: sparqlEscapeDecimal, - sparqlEscapeInt: sparqlEscapeInt, - sparqlEscapeFloat: sparqlEscapeFloat, - sparqlEscapeDate: sparqlEscapeDate, - sparqlEscapeDateTime: sparqlEscapeDateTime, - sparqlEscapeBool: sparqlEscapeBool -} + query, + update, + sparqlEscape, + sparqlEscapeString, + sparqlEscapeUri, + sparqlEscapeDecimal, + sparqlEscapeInt, + sparqlEscapeFloat, + sparqlEscapeDate, + sparqlEscapeDateTime, + sparqlEscapeBool, +}; export default exports; export { - newSparqlClient, SPARQL as SPARQL, SPARQL as sparql, query, @@ -282,5 +321,5 @@ export { sparqlEscapeFloat, sparqlEscapeDate, sparqlEscapeDateTime, - sparqlEscapeBool + sparqlEscapeBool, }; diff --git a/helpers/mu/term.js b/helpers/mu/term.js new file mode 100644 index 0000000..4b63170 --- /dev/null +++ b/helpers/mu/term.js @@ -0,0 +1,228 @@ +/** + * RDF Term types for use in SPARQL template literal interpolation. + * + * This software incorporates code derived from node-sparql-client + * (https://github.com/eddieantonio/node-sparql-client), MIT License. + * + * Adapted from the original: merged into a single ESM file to eliminate + * circular module dependencies, and modernised to ES2015 class syntax. + */ + +// ── Term (abstract base) ───────────────────────────────────────────────────── + +class Term { + format() { + throw new Error("term MUST implement a #format method!"); + } +} + +// ── IRI ────────────────────────────────────────────────────────────────────── + +class IRI extends Term { + /** + * Creates an IRI from a string or a single-key object {prefix: localname}. + */ + static create(value) { + if (typeof value === "string") return new IRIReference(value); + if (typeof value === "object" && value !== null) return IRI.createFromObject(value); + throw new TypeError("Invalid IRI: expected string or object, got " + typeof value); + } + + static createFromObject(object) { + const keys = Object.keys(object); + if (keys.length !== 1) throw new Error("Invalid prefixed IRI: object must have exactly one key."); + const namespace = keys[0]; + const local = object[namespace]; + if (typeof local !== "string") throw new TypeError("Invalid prefixed IRI: local name must be a string."); + if (!/^[^\s;.,<|$]+$/.test(local)) throw new Error("Invalid IRI identifier: " + local); + return new PrefixedNameIRI(namespace, local); + } +} + +class PrefixedNameIRI extends IRI { + constructor(namespace, id) { + super(); + this.namespace = namespace; + this.id = id; + } + + format() { + return this.namespace + ":" + this.id; + } +} + +class IRIReference extends IRI { + constructor(iri) { + super(); + /* Reject characters forbidden in IRIREF per SPARQL 1.1 spec: + * < > " { } | ^ backtick backslash and codepoints 0x00-0x20 */ + if (/[<>"{}|^`\\]/.test(iri) || [...iri].some(ch => ch.codePointAt(0) <= 0x20)) { + throw new Error("Invalid IRI: " + iri); + } + this.iri = iri; + } + + format() { + return "<" + this.iri + ">"; + } +} + +// ── Literal ────────────────────────────────────────────────────────────────── + +const SPARQL_LITERAL_PATTERNS = { + boolean: /^true$|^false$/, + integer: /^[-+]?[0-9]+$/, + double: /^[-+]?(?:[0-9]+\.[0-9]*|\.[0-9]+|[0-9]+)[eE][+-]?[0-9]+$/, + decimal: /^[-+]?[0-9]*\.[0-9]+$/, +}; + +class Literal extends Term { + constructor(value, datatype) { + super(); + this.value = "" + value; + if (datatype !== undefined) { + this.datatype = IRI.create(datatype); + } + } + + static create(value) { + return new StringLiteral(value); + } + + static createWithLanguageTag(value, languageTag) { + if (typeof languageTag !== "string") { + throw new TypeError("Language tag must be a string."); + } + return new StringLiteral(value, languageTag); + } + + /** @deprecated Use createWithLanguageTag (fixes typo in original API name). */ + static createWithLangaugeTag(value, languageTag) { + return Literal.createWithLanguageTag(value, languageTag); + } + + static createWithDataType(value, datatype) { + if (datatype === undefined) throw new TypeError("Undefined datatype provided."); + return new Literal(value, datatype); + } + + format() { + if (isKnownXsdDatatype(this.datatype)) { + const term = tryFormatXsdType(this.value, this.datatype.id); + if (term !== undefined) { + return term.wrapAsString + ? formatStringWithDataType(term.literal, this.datatype) + : term.literal; + } + } + return formatStringWithDataType(this.value, this.datatype); + } +} + +class StringLiteral extends Literal { + constructor(value, languageTag) { + super(value); + if (languageTag !== undefined) { + if (!/^[a-zA-Z]+(?:-[a-zA-Z0-9]+)*$/.test(languageTag)) { + throw new Error("Invalid language tag: " + languageTag); + } + this.languageTag = languageTag; + } + } + + format() { + const str = formatRDFString(this.value); + return this.languageTag !== undefined ? str + "@" + this.languageTag : str; + } +} + +function isKnownXsdDatatype(iri) { + return iri != null && iri.namespace === "xsd" && iri.id in SPARQL_LITERAL_PATTERNS; +} + +function tryFormatXsdType(value, type) { + const stringified = "" + value; + if (type === "double") { + if (Math.abs(+value) === Infinity) { + return { literal: (value < 0 ? "-" : "") + "INF", wrapAsString: true }; + } + if (SPARQL_LITERAL_PATTERNS.double.test(stringified)) return { literal: stringified }; + const withExponent = stringified + "e0"; + if (SPARQL_LITERAL_PATTERNS.double.test(withExponent)) return { literal: withExponent }; + return undefined; + } + if (SPARQL_LITERAL_PATTERNS[type].test(stringified)) return { literal: stringified }; +} + +/** + * Formats a string value as a SPARQL RDF literal. + * Uses triple double-quotes to support newlines and most special characters; + * only backslash and embedded triple-quote sequences need escaping. + */ +function formatRDFString(value) { + const str = "" + value; + const escaped = str + .replace(/\\/g, "\\\\") + .replace(/"""/g, '""\\"'); + return '"""' + escaped + '"""'; +} + +function formatStringWithDataType(value, datatype) { + const str = formatRDFString(value); + return datatype !== undefined ? str + "^^" + datatype.format() : str; +} + +// ── Term.create ─────────────────────────────────────────────────────────────── + +const KNOWN_XSD_DATATYPES = { boolean: 1, decimal: 1, double: 1, integer: 1 }; + +Term.create = function create(value, options) { + if (options) return createTerm(Object.assign({}, options, { value })); + return createTerm(value); +}; + +function createTerm(value) { + const rawValue = value == null ? value : value.valueOf(); + if (rawValue === null || rawValue === undefined) { + throw new TypeError("Cannot bind null or undefined value"); + } + const type = typeof rawValue; + switch (type) { + case "string": return Literal.create(rawValue); + case "number": return Literal.createWithDataType(rawValue, { xsd: "double" }); + case "boolean": return Literal.createWithDataType(rawValue, { xsd: "boolean" }); + case "object": return createTermFromObject(rawValue); + } + throw new TypeError("Cannot bind " + type + " value: " + value); +} + +function createTermFromObject(object) { + if (Object.keys(object).length === 1) return IRI.createFromObject(object); + + const { value } = object; + if (value === undefined) { + throw new Error( + "Binding must contain a `value` property. " + + "To bind a URI, write { value: 'http://...', type: 'uri' }." + ); + } + + resolveDataTypeShortcuts(object); + + if (object.type === "uri") return IRI.create(value); + if (object.lang !== undefined) return Literal.createWithLanguageTag(value, object.lang); + if (object["xml:lang"] !== undefined) return Literal.createWithLanguageTag(value, object["xml:lang"]); + if (object.datatype !== undefined) return Literal.createWithDataType(value, object.datatype); + + throw new Error("Could not bind object: " + JSON.stringify(object)); +} + +function resolveDataTypeShortcuts(object) { + const TERM_TYPES = { bnode: 1, literal: 1, uri: 1 }; + const { type } = object; + if (type === undefined || type in TERM_TYPES) return; + object.datatype = type in KNOWN_XSD_DATATYPES ? { xsd: type } : type; + object.type = "literal"; +} + +export default Term; diff --git a/package.json b/package.json index 3e3b3ee..09593d4 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,6 @@ "env-var": "^7.0.0", "express": "^4.17.1", "express-http-context": "~1.2.4", - "sparql-client-2": "https://github.com/erikap/node-sparql-client.git", "typescript": "^4.6.2", "uuid": "^9.0.0" },