Skip to content

Commit 30a57ed

Browse files
committed
Merge branch 'rv/patch/n3' into dz_oidc
* rv/patch/n3: Migrate to Solid vocabulary. Refactor PATCH tests with helper method. Verify read and write permissions for patches. Expose ACL and user ID on request. Refactor patch handler to perform everything but parsing. Add WHERE support to N3 patches. Test PATCH combined deletion and insertion. Test PATCH deletion. Use single-user setup for PATCH tests. Enable and test PATCH appending. Syntactically and structurally validate patches. Set up new PATCH tests. Clean up SPARQL UPDATE PATCH tests. Construct the patch URI through a hash of its contents. Add preliminary N3 patch support. Refactor SPARQL update patcher with promises.
2 parents 2b8f18b + 91c8106 commit 30a57ed

22 files changed

+699
-264
lines changed

lib/handlers/patch.js

Lines changed: 78 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -9,44 +9,50 @@ const debug = require('../debug').handlers
99
const utils = require('../utils.js')
1010
const error = require('../http-error')
1111
const $rdf = require('rdflib')
12+
const crypto = require('crypto')
1213

1314
const DEFAULT_TARGET_TYPE = 'text/turtle'
1415

15-
// Patch handlers by request body content type
16-
const PATCHERS = {
17-
'application/sparql-update': require('./patch/sparql-update-patcher.js')
16+
// Patch parsers by request body content type
17+
const PATCH_PARSERS = {
18+
'application/sparql-update': require('./patch/sparql-update-parser.js'),
19+
'text/n3': require('./patch/n3-patch-parser.js')
1820
}
1921

2022
// Handles a PATCH request
2123
function patchHandler (req, res, next) {
22-
debug('PATCH -- ' + req.originalUrl)
24+
debug(`PATCH -- ${req.originalUrl}`)
2325
res.header('MS-Author-Via', 'SPARQL')
2426

25-
// Obtain details of the patch document
26-
const patch = {
27-
text: req.body ? req.body.toString() : '',
28-
contentType: (req.get('content-type') || '').match(/^[^;\s]*/)[0]
29-
}
30-
const patchGraph = PATCHERS[patch.contentType]
31-
if (!patchGraph) {
32-
return next(error(415, 'Unknown patch content type: ' + patch.contentType))
33-
}
34-
debug('PATCH -- Received patch (%d bytes, %s)', patch.text.length, patch.contentType)
35-
3627
// Obtain details of the target resource
3728
const ldp = req.app.locals.ldp
38-
const root = !ldp.idp ? ldp.root : ldp.root + req.hostname + '/'
39-
const target = {
40-
file: utils.uriToFilename(req.path, root),
41-
uri: utils.uriAbs(req) + req.originalUrl
42-
}
29+
const root = !ldp.idp ? ldp.root : `${ldp.root}${req.hostname}/`
30+
const target = {}
31+
target.file = utils.uriToFilename(req.path, root)
32+
target.uri = utils.uriAbs(req) + req.originalUrl
4333
target.contentType = mime.lookup(target.file) || DEFAULT_TARGET_TYPE
4434
debug('PATCH -- Target <%s> (%s)', target.uri, target.contentType)
4535

46-
// Read the RDF graph to be patched from the file
47-
readGraph(target)
36+
// Obtain details of the patch document
37+
const patch = {}
38+
patch.text = req.body ? req.body.toString() : ''
39+
patch.uri = `${target.uri}#patch-${hash(patch.text)}`
40+
patch.contentType = (req.get('content-type') || '').match(/^[^;\s]*/)[0]
41+
debug('PATCH -- Received patch (%d bytes, %s)', patch.text.length, patch.contentType)
42+
const parsePatch = PATCH_PARSERS[patch.contentType]
43+
if (!parsePatch) {
44+
return next(error(415, `Unsupported patch content type: ${patch.contentType}`))
45+
}
46+
47+
// Parse the target graph and the patch document,
48+
// and verify permission for performing this specific patch
49+
Promise.all([
50+
readGraph(target),
51+
parsePatch(target.uri, patch.uri, patch.text)
52+
.then(patchObject => checkPermission(target, req, patchObject))
53+
])
4854
// Patch the graph and write it back to the file
49-
.then(graph => patchGraph(graph, target.uri, patch.text))
55+
.then(([graph, patchObject]) => applyPatch(patchObject, graph, target))
5056
.then(graph => writeGraph(graph, target))
5157
// Send the result to the client
5258
.then(result => { res.send(result) })
@@ -71,7 +77,7 @@ function readGraph (resource) {
7177
fileContents = ''
7278
// Fail on all other errors
7379
} else {
74-
return reject(error(500, 'Patch: Original file read error:' + err))
80+
return reject(error(500, `Original file read error: ${err}`))
7581
}
7682
}
7783
debug('PATCH -- Read target file (%d bytes)', fileContents.length)
@@ -85,25 +91,69 @@ function readGraph (resource) {
8591
try {
8692
$rdf.parse(fileContents, graph, resource.uri, resource.contentType)
8793
} catch (err) {
88-
throw error(500, 'Patch: Target ' + resource.contentType + ' file syntax error:' + err)
94+
throw error(500, `Patch: Target ${resource.contentType} file syntax error: ${err}`)
8995
}
9096
debug('PATCH -- Parsed target file')
9197
return graph
9298
})
9399
}
94100

101+
// Verifies whether the user is allowed to perform the patch on the target
102+
function checkPermission (target, request, patchObject) {
103+
// If no ACL object was passed down, assume permissions are okay.
104+
if (!request.acl) return Promise.resolve(patchObject)
105+
// At this point, we already assume append access,
106+
// as this can be checked upfront before parsing the patch.
107+
// Now that we know the details of the patch,
108+
// we might need to perform additional checks.
109+
let checks = []
110+
const { acl, session: { userId } } = request
111+
// Read access is required for DELETE and WHERE.
112+
// If we would allows users without read access,
113+
// they could use DELETE or WHERE to trigger 200 or 409,
114+
// and thereby guess the existence of certain triples.
115+
// DELETE additionally requires write access.
116+
if (patchObject.delete) {
117+
checks = [acl.can(userId, 'Read'), acl.can(userId, 'Write')]
118+
} else if (patchObject.where) {
119+
checks = [acl.can(userId, 'Read')]
120+
}
121+
return Promise.all(checks).then(() => patchObject)
122+
}
123+
124+
// Applies the patch to the RDF graph
125+
function applyPatch (patchObject, graph, target) {
126+
debug('PATCH -- Applying patch')
127+
return new Promise((resolve, reject) =>
128+
graph.applyPatch(patchObject, graph.sym(target.uri), (err) => {
129+
if (err) {
130+
const message = err.message || err // returns string at the moment
131+
debug(`PATCH -- FAILED. Returning 409. Message: '${message}'`)
132+
return reject(error(409, `The patch could not be applied. ${message}`))
133+
}
134+
resolve(graph)
135+
})
136+
)
137+
}
138+
95139
// Writes the RDF graph to the given resource
96140
function writeGraph (graph, resource) {
141+
debug('PATCH -- Writing patched file')
97142
return new Promise((resolve, reject) => {
98143
const resourceSym = graph.sym(resource.uri)
99144
const serialized = $rdf.serialize(resourceSym, graph, resource.uri, resource.contentType)
100145

101146
fs.writeFile(resource.file, serialized, {encoding: 'utf8'}, function (err) {
102147
if (err) {
103-
return reject(error(500, 'Failed to write file back after patch: ' + err))
148+
return reject(error(500, `Failed to write file after patch: ${err}`))
104149
}
105-
debug('PATCH -- applied OK (sync)')
106-
resolve('Patch applied OK\n')
150+
debug('PATCH -- applied successfully')
151+
resolve('Patch applied successfully.\n')
107152
})
108153
})
109154
}
155+
156+
// Creates a hash of the given text
157+
function hash (text) {
158+
return crypto.createHash('md5').update(text).digest('hex')
159+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
// Parses a text/n3 patch
2+
3+
module.exports = parsePatchDocument
4+
5+
const $rdf = require('rdflib')
6+
const error = require('../../http-error')
7+
8+
const PATCH_NS = 'http://www.w3.org/ns/solid/terms#'
9+
const PREFIXES = `PREFIX solid: <${PATCH_NS}>\n`
10+
11+
// Parses the given N3 patch document
12+
function parsePatchDocument (targetURI, patchURI, patchText) {
13+
// Parse the N3 document into triples
14+
return new Promise((resolve, reject) => {
15+
const patchGraph = $rdf.graph()
16+
$rdf.parse(patchText, patchGraph, patchURI, 'text/n3')
17+
resolve(patchGraph)
18+
})
19+
.catch(err => { throw error(400, `Patch document syntax error: ${err}`) })
20+
21+
// Query the N3 document for insertions and deletions
22+
.then(patchGraph => queryForFirstResult(patchGraph, `${PREFIXES}
23+
SELECT ?insert ?delete ?where WHERE {
24+
?patch solid:patches <${targetURI}>.
25+
OPTIONAL { ?patch solid:inserts ?insert. }
26+
OPTIONAL { ?patch solid:deletes ?delete. }
27+
OPTIONAL { ?patch solid:where ?where. }
28+
}`)
29+
.catch(err => { throw error(400, `No patch for ${targetURI} found.`, err) })
30+
)
31+
32+
// Return the insertions and deletions as an rdflib patch document
33+
.then(result => {
34+
const {'?insert': insert, '?delete': deleted, '?where': where} = result
35+
if (!insert && !deleted) {
36+
throw error(400, 'Patch should at least contain inserts or deletes.')
37+
}
38+
return {insert, delete: deleted, where}
39+
})
40+
}
41+
42+
// Queries the store with the given SPARQL query and returns the first result
43+
function queryForFirstResult (store, sparql) {
44+
return new Promise((resolve, reject) => {
45+
const query = $rdf.SPARQLToQuery(sparql, false, store)
46+
store.query(query, resolve, null, () => reject(new Error('No results.')))
47+
})
48+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
// Parses an application/sparql-update patch
2+
3+
module.exports = parsePatchDocument
4+
5+
const $rdf = require('rdflib')
6+
const error = require('../../http-error')
7+
8+
// Parses the given SPARQL UPDATE document
9+
function parsePatchDocument (targetURI, patchURI, patchText) {
10+
return new Promise((resolve, reject) => {
11+
const baseURI = patchURI.replace(/#.*/, '')
12+
try {
13+
resolve($rdf.sparqlUpdateParser(patchText, $rdf.graph(), baseURI))
14+
} catch (err) {
15+
reject(error(400, `Patch document syntax error: ${err}`))
16+
}
17+
})
18+
}

lib/handlers/patch/sparql-update-patcher.js

Lines changed: 0 additions & 36 deletions
This file was deleted.

lib/ldp-middleware.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ function LdpMiddleware (corsSettings) {
2424
router.copy('/*', allow('Write'), copy)
2525
router.get('/*', index, allow('Read'), header.addPermissions, get)
2626
router.post('/*', allow('Append'), post)
27-
router.patch('/*', allow('Write'), patch)
27+
router.patch('/*', allow('Append'), patch)
2828
router.put('/*', allow('Write'), put)
2929
router.delete('/*', allow('Write'), del)
3030

0 commit comments

Comments
 (0)