diff --git a/config/defaults.js b/config/defaults.js index 6382305f4..22b9e3d1e 100644 --- a/config/defaults.js +++ b/config/defaults.js @@ -13,7 +13,8 @@ module.exports = { webid: true, strictOrigin: true, trustedOrigins: [], - dataBrowserPath: 'default' + dataBrowserPath: 'default', + // paymentOracle: 'http://localhost:8402' // For use in Enterprises to configure a HTTP proxy for all outbound HTTP requests from the SOLID server (we use // https://www.npmjs.com/package/global-tunnel-ng). diff --git a/lib/acl-checker.js b/lib/acl-checker.js index 93371a53b..0755fc7c8 100644 --- a/lib/acl-checker.js +++ b/lib/acl-checker.js @@ -23,6 +23,29 @@ const ACL = rdf.Namespace('http://www.w3.org/ns/auth/acl#') const EXPIRY_MS = parseInt(process.env.ACL_CACHE_TIME) || 10000 // 10 seconds let temporaryCache = {} +class PaymentOracleClient { + constructor (oracleEndpoint) { + this.oracleEndpoint = oracleEndpoint + } + + async hasPaid (agent, resource) { + if (!this.oracleEndpoint) { + return false + } + try { + const agentStr = (agent ? encodeURIComponent(agent.value) : '') + const resourceStr = (resource ? encodeURIComponent(resource.value) : '') + const url = `${this.oracleEndpoint}/?agent=${agentStr}&resource=${resourceStr}` + const response = await httpFetch(url) + const body = await response.text() + return JSON.parse(body) + } catch (e) { + // console.error('Oracle failure', e.message) + return { ok: false } + } + } +} + // An ACLChecker exposes the permissions on a specific resource class ACLChecker { constructor (resource, options = {}) { @@ -37,6 +60,7 @@ class ACLChecker { this.messagesCached = {} this.requests = {} this.slug = options.slug + this.paymentOracleClient = options.paymentOracleClient } // Returns a fulfilled promise when the user can access the resource @@ -72,10 +96,13 @@ class ACLChecker { const directory = acl.isContainer ? rdf.sym(ACLChecker.getDirectory(acl.acl)) : null const aclFile = rdf.sym(acl.acl) const agent = user ? rdf.sym(user) : null + const currentPaid = await this.paymentOracleClient.hasPaid(agent, rdf.sym(this.resource)) + const modes = [ACL(mode)] const agentOrigin = this.agentOrigin const trustedOrigins = this.trustedOrigins let originTrustedModes = [] + let payingWouldHelp = false try { this.fetch(aclFile.doc().value) originTrustedModes = await aclCheck.getTrustedModesForOrigin(acl.graph, resource, directory, aclFile, agentOrigin, (uriNode) => { @@ -85,20 +112,35 @@ class ACLChecker { // FIXME: https://github.com/solid/acl-check/issues/23 // console.error(e.message) } - let accessDenied = aclCheck.accessDenied(acl.graph, resource, directory, aclFile, agent, modes, agentOrigin, trustedOrigins, originTrustedModes) + + let accessDenied = aclCheck.accessDenied(acl.graph, resource, directory, aclFile, agent, modes, agentOrigin, trustedOrigins, originTrustedModes, (agent) => { + if (!currentPaid.ok) { + payingWouldHelp = currentPaid + } + return currentPaid.ok + }) // For create and update HTTP methods if ((method === 'PUT' || method === 'PATCH' || method === 'COPY') && directory) { // if resource and acl have same parent container, // and resource does not exist, then accessTo Append from parent is required if (directory.value === dirname(aclFile.value) + '/' && !resourceExists) { - const accessDeniedAccessTo = aclCheck.accessDenied(acl.graph, directory, null, aclFile, agent, [ACL('Append')], agentOrigin, trustedOrigins, originTrustedModes) + const accessDeniedAccessTo = aclCheck.accessDenied(acl.graph, directory, null, aclFile, agent, [ACL('Append')], agentOrigin, trustedOrigins, originTrustedModes, () => { + if (!currentPaid) { + payingWouldHelp = currentPaid + } + return currentPaid + }) const accessResult = !accessDenied && !accessDeniedAccessTo accessDenied = accessResult ? false : accessDenied || accessDeniedAccessTo // debugCache('accessDenied result ' + accessDenied) } } - if (accessDenied && user) { + if (accessDenied && payingWouldHelp) { + const err = HTTPError(402, 'Payment Required') + err.payHeaders = currentPaid.payHeaders + this.messagesCached[cacheKey].push(err) + } else if (accessDenied && user) { this.messagesCached[cacheKey].push(HTTPError(403, accessDenied)) } else if (accessDenied) { this.messagesCached[cacheKey].push(HTTPError(401, 'Unauthenticated')) @@ -199,6 +241,7 @@ class ACLChecker { suffix: ldp.suffixAcl, strictOrigin: ldp.strictOrigin, trustedOrigins, + paymentOracleClient: new PaymentOracleClient(ldp.paymentOracle), slug: decodeURIComponent(req.headers.slug) }) } diff --git a/lib/handlers/allow.js b/lib/handlers/allow.js index a6886b104..7f22cfc3b 100644 --- a/lib/handlers/allow.js +++ b/lib/handlers/allow.js @@ -76,6 +76,13 @@ function allow (mode) { if (resourceUrl.endsWith('.acl') && userId === await ldp.getOwner(req.hostname)) return next() const error = req.authError || await req.acl.getError(userId, mode) + if (error.status === 402) { + if (Array.isArray(error.payHeaders)) { + error.payHeaders.forEach(str => { + res.set('Pay', str) + }) + } + } debug(`${mode} access denied to ${userId || '(none)'}: ${error.status} - ${error.message}`) next(error) } diff --git a/lib/server-config.js b/lib/server-config.js index 11299881f..801777e82 100644 --- a/lib/server-config.js +++ b/lib/server-config.js @@ -25,6 +25,7 @@ function printDebugInfo (options) { debug.settings('Multi-user: ' + !!options.multiuser) debug.settings('Suppress default data browser app: ' + options.suppressDataBrowser) debug.settings('Default data browser app file path: ' + options.dataBrowserPath) + debug.settings('Payment Oracle for web monetization: ' + options.paymentOracle) } /**