forked from coolaj86/goldilocks.js
560 lines
18 KiB
JavaScript
560 lines
18 KiB
JavaScript
'use strict';
|
|
|
|
module.exports.dependencies = [ 'OAUTH3', 'storage.owners', 'options.device' ];
|
|
module.exports.create = function (deps, conf) {
|
|
var scmp = require('scmp');
|
|
var crypto = require('crypto');
|
|
var jwt = require('jsonwebtoken');
|
|
var bodyParser = require('body-parser');
|
|
var jsonParser = bodyParser.json({
|
|
inflate: true, limit: '100kb', reviver: null, strict: true /* type, verify */
|
|
});
|
|
|
|
function handleCors(req, res, methods) {
|
|
if (!methods) {
|
|
methods = ['GET', 'POST'];
|
|
}
|
|
if (!Array.isArray(methods)) {
|
|
methods = [ methods ];
|
|
}
|
|
|
|
res.setHeader('Access-Control-Allow-Origin', req.headers.origin || '*');
|
|
res.setHeader('Access-Control-Allow-Methods', methods.join(', '));
|
|
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
|
|
|
|
if (req.method.toUpperCase() === 'OPTIONS') {
|
|
res.setHeader('Allow', methods.join(', '));
|
|
res.end();
|
|
return true;
|
|
}
|
|
|
|
if (methods.indexOf('*') >= 0) {
|
|
return false;
|
|
}
|
|
if (methods.indexOf(req.method.toUpperCase()) < 0) {
|
|
res.statusCode = 405;
|
|
res.setHeader('Content-Type', 'application/json');
|
|
res.end(JSON.stringify({ error: { message: 'method '+req.method+' not allowed', code: 'EBADMETHOD'}}));
|
|
return true;
|
|
}
|
|
}
|
|
function makeCorsHandler(methods) {
|
|
return function corsHandler(req, res, next) {
|
|
if (!handleCors(req, res, methods)) {
|
|
next();
|
|
}
|
|
};
|
|
}
|
|
|
|
function handlePromise(req, res, prom) {
|
|
prom.then(function (result) {
|
|
res.send(deps.recase.snakeCopy(result));
|
|
}).catch(function (err) {
|
|
res.statusCode = err.statusCode || 500;
|
|
err.message = err.message || err.toString();
|
|
res.end(JSON.stringify({error: {message: err.message, code: err.code}}));
|
|
});
|
|
}
|
|
|
|
function isAuthorized(req, res, fn) {
|
|
var auth = jwt.decode((req.headers.authorization||'').replace(/^bearer\s+/i, ''));
|
|
if (!auth) {
|
|
res.statusCode = 401;
|
|
res.setHeader('Content-Type', 'application/json;');
|
|
res.end(JSON.stringify({ error: { message: "no token", code: 'E_NO_TOKEN', uri: undefined } }));
|
|
return;
|
|
}
|
|
|
|
var id = crypto.createHash('sha256').update(auth.sub).digest('hex');
|
|
return deps.storage.owners.exists(id).then(function (exists) {
|
|
if (!exists) {
|
|
res.statusCode = 401;
|
|
res.setHeader('Content-Type', 'application/json;');
|
|
res.end(JSON.stringify({ error: { message: "not authorized", code: 'E_NO_AUTHZ', uri: undefined } }));
|
|
return;
|
|
}
|
|
|
|
req.userId = id;
|
|
fn();
|
|
});
|
|
}
|
|
|
|
function checkPaywall() {
|
|
var url = require('url');
|
|
var PromiseA = require('bluebird');
|
|
var testDomains = [
|
|
'daplie.com'
|
|
, 'duckduckgo.com'
|
|
, 'google.com'
|
|
, 'amazon.com'
|
|
, 'facebook.com'
|
|
, 'msn.com'
|
|
, 'yahoo.com'
|
|
];
|
|
|
|
// While this is not being developed behind a paywall the current idea is that
|
|
// a paywall will either manipulate DNS queries to point to the paywall gate,
|
|
// or redirect HTTP requests to the paywall gate. So we check for both and
|
|
// hope we can detect most hotel/ISP paywalls out there in the world.
|
|
//
|
|
// It is also possible that the paywall will prevent any unknown traffic from
|
|
// leaving the network, so the DNS queries could fail if the unit is set to
|
|
// use nameservers other than the paywall router.
|
|
return PromiseA.resolve()
|
|
.then(function () {
|
|
var dns = PromiseA.promisifyAll(require('dns'));
|
|
var proms = testDomains.map(function (dom) {
|
|
return dns.resolve6Async(dom)
|
|
.catch(function () {
|
|
return dns.resolve4Async(dom);
|
|
})
|
|
.then(function (result) {
|
|
return result[0];
|
|
}, function () {
|
|
return null;
|
|
});
|
|
});
|
|
|
|
return PromiseA.all(proms).then(function (addrs) {
|
|
var unique = addrs.filter(function (value, ind, self) {
|
|
return value && self.indexOf(value) === ind;
|
|
});
|
|
// It is possible some walls might have exceptions that leave some of the domains
|
|
// we test alone, so we might have more than one unique address even behind an
|
|
// active paywall.
|
|
return unique.length < addrs.length;
|
|
});
|
|
})
|
|
.then(function (paywall) {
|
|
if (paywall) {
|
|
return paywall;
|
|
}
|
|
var request = deps.request.defaults({
|
|
followRedirect: false
|
|
, headers: {
|
|
connection: 'close'
|
|
}
|
|
});
|
|
|
|
var proms = testDomains.map(function (dom) {
|
|
return request('http://'+dom).then(function (resp) {
|
|
if (resp.statusCode >= 300 && resp.statusCode < 400) {
|
|
return url.parse(resp.headers.location).hostname;
|
|
} else {
|
|
return dom;
|
|
}
|
|
});
|
|
});
|
|
|
|
return PromiseA.all(proms).then(function (urls) {
|
|
var unique = urls.filter(function (value, ind, self) {
|
|
return value && self.indexOf(value) === ind;
|
|
});
|
|
return unique.length < urls.length;
|
|
});
|
|
})
|
|
;
|
|
}
|
|
|
|
// This object contains all of the API endpoints written before we changed how
|
|
// the API routing is handled. Eventually it will hopefully disappear, but for
|
|
// now we're focusing on the things that need changing more.
|
|
var oldEndPoints = {
|
|
init: function (req, res) {
|
|
if (handleCors(req, res, ['GET', 'POST'])) {
|
|
return;
|
|
}
|
|
|
|
if ('POST' !== req.method) {
|
|
// It should be safe to give the list of owner IDs to an un-authenticated
|
|
// request because the ID is the sha256 of the PPID and shouldn't be reversible
|
|
return deps.storage.owners.all().then(function (results) {
|
|
var ids = results.map(function (owner) {
|
|
return owner.id;
|
|
});
|
|
res.setHeader('Content-Type', 'application/json');
|
|
res.end(JSON.stringify(ids));
|
|
});
|
|
}
|
|
|
|
jsonParser(req, res, function () {
|
|
|
|
return deps.PromiseA.resolve().then(function () {
|
|
console.log('init POST body', req.body);
|
|
|
|
var auth = jwt.decode((req.headers.authorization||'').replace(/^bearer\s+/i, ''));
|
|
var token = jwt.decode(req.body.access_token);
|
|
var refresh = jwt.decode(req.body.refresh_token);
|
|
auth.sub = auth.sub || auth.acx.id;
|
|
token.sub = token.sub || token.acx.id;
|
|
refresh.sub = refresh.sub || refresh.acx.id;
|
|
|
|
// TODO validate token with issuer, but as-is the sub is already a secret
|
|
var id = crypto.createHash('sha256').update(auth.sub).digest('hex');
|
|
var tid = crypto.createHash('sha256').update(token.sub).digest('hex');
|
|
var rid = crypto.createHash('sha256').update(refresh.sub).digest('hex');
|
|
var session = {
|
|
access_token: req.body.access_token
|
|
, token: token
|
|
, refresh_token: req.body.refresh_token
|
|
, refresh: refresh
|
|
};
|
|
|
|
console.log('ids', id, tid, rid);
|
|
|
|
if (req.body.ip_url) {
|
|
// TODO set options / GunDB
|
|
conf.ip_url = req.body.ip_url;
|
|
}
|
|
|
|
return deps.storage.owners.all().then(function (results) {
|
|
console.log('results', results);
|
|
var err;
|
|
|
|
// There is no owner yet. First come, first serve.
|
|
if (!results || !results.length) {
|
|
if (tid !== id || rid !== id) {
|
|
err = new Error(
|
|
"When creating an owner the Authorization Bearer and Token and Refresh must all match"
|
|
);
|
|
err.statusCode = 400;
|
|
return deps.PromiseA.reject(err);
|
|
}
|
|
console.log('no owner, creating');
|
|
return deps.storage.owners.set(id, session);
|
|
}
|
|
console.log('has results');
|
|
|
|
// There are onwers. Is this one of them?
|
|
if (!results.some(function (token) {
|
|
return scmp(id, token.id);
|
|
})) {
|
|
err = new Error("Authorization token does not belong to an existing owner.");
|
|
err.statusCode = 401;
|
|
return deps.PromiseA.reject(err);
|
|
}
|
|
console.log('has correct owner');
|
|
|
|
// We're adding an owner, unless it already exists
|
|
if (!results.some(function (token) {
|
|
return scmp(tid, token.id);
|
|
})) {
|
|
console.log('adds new owner with existing owner');
|
|
return deps.storage.owners.set(tid, session);
|
|
}
|
|
}).then(function () {
|
|
res.setHeader('Content-Type', 'application/json;');
|
|
res.end(JSON.stringify({ success: true }));
|
|
});
|
|
})
|
|
.catch(function (err) {
|
|
res.setHeader('Content-Type', 'application/json;');
|
|
res.statusCode = err.statusCode || 500;
|
|
res.end(JSON.stringify({ error: { message: err.message, code: err.code, uri: err.uri } }));
|
|
});
|
|
|
|
});
|
|
}
|
|
, request: function (req, res) {
|
|
if (handleCors(req, res, '*')) {
|
|
return;
|
|
}
|
|
isAuthorized(req, res, function () {
|
|
jsonParser(req, res, function () {
|
|
|
|
deps.request({
|
|
method: req.body.method || 'GET'
|
|
, url: req.body.url
|
|
, headers: req.body.headers
|
|
, body: req.body.data
|
|
}).then(function (resp) {
|
|
if (resp.body instanceof Buffer || 'string' === typeof resp.body) {
|
|
resp.body = JSON.parse(resp.body);
|
|
}
|
|
|
|
return {
|
|
statusCode: resp.statusCode
|
|
, status: resp.status
|
|
, headers: resp.headers
|
|
, body: resp.body
|
|
, data: resp.data
|
|
};
|
|
}).then(function (result) {
|
|
res.send(result);
|
|
});
|
|
|
|
});
|
|
});
|
|
}
|
|
, paywall_check: function (req, res) {
|
|
if (handleCors(req, res, 'GET')) {
|
|
return;
|
|
}
|
|
isAuthorized(req, res, function () {
|
|
res.setHeader('Content-Type', 'application/json;');
|
|
|
|
checkPaywall().then(function (paywall) {
|
|
res.end(JSON.stringify({paywall: paywall}));
|
|
}, function (err) {
|
|
err.message = err.message || err.toString();
|
|
res.statusCode = 500;
|
|
res.end(JSON.stringify({error: {message: err.message, code: err.code}}));
|
|
});
|
|
});
|
|
}
|
|
, socks5: function (req, res) {
|
|
if (handleCors(req, res, ['GET', 'POST', 'DELETE'])) {
|
|
return;
|
|
}
|
|
isAuthorized(req, res, function () {
|
|
var method = req.method.toUpperCase();
|
|
var prom;
|
|
|
|
if (method === 'POST') {
|
|
prom = deps.socks5.start();
|
|
} else if (method === 'DELETE') {
|
|
prom = deps.socks5.stop();
|
|
} else {
|
|
prom = deps.socks5.curState();
|
|
}
|
|
|
|
res.setHeader('Content-Type', 'application/json;');
|
|
prom.then(function (result) {
|
|
res.end(JSON.stringify(result));
|
|
}, function (err) {
|
|
err.message = err.message || err.toString();
|
|
res.statusCode = 500;
|
|
res.end(JSON.stringify({error: {message: err.message, code: err.code}}));
|
|
});
|
|
});
|
|
}
|
|
};
|
|
|
|
function handleOldApis(req, res, next) {
|
|
if (typeof oldEndPoints[req.params.name] === 'function') {
|
|
oldEndPoints[req.params.name](req, res);
|
|
} else {
|
|
next();
|
|
}
|
|
}
|
|
|
|
var config = { restful: {} };
|
|
config.restful.readConfig = function (req, res, next) {
|
|
var part = new (require('./config').ConfigChanger)(conf);
|
|
if (req.params.group) {
|
|
part = part[req.params.group];
|
|
}
|
|
if (part && req.params.domId) {
|
|
part = part.domains.findId(req.params.domId);
|
|
}
|
|
if (part && req.params.mod) {
|
|
part = part[req.params.mod];
|
|
}
|
|
if (part && req.params.modGrp) {
|
|
part = part[req.params.modGrp];
|
|
}
|
|
if (part && req.params.modId) {
|
|
part = part.findId(req.params.modId);
|
|
}
|
|
|
|
if (part) {
|
|
res.send(deps.recase.snakeCopy(part));
|
|
} else {
|
|
next();
|
|
}
|
|
};
|
|
|
|
config.save = function (changer) {
|
|
var errors = changer.validate();
|
|
if (errors.length) {
|
|
throw Object.assign(new Error(), errors[0], {statusCode: 400});
|
|
}
|
|
|
|
return deps.storage.config.save(changer);
|
|
};
|
|
config.restful.saveBaseConfig = function (req, res, next) {
|
|
console.log('config POST body', JSON.stringify(req.body));
|
|
if (req.params.group === 'domains') {
|
|
next();
|
|
return;
|
|
}
|
|
|
|
var promise = deps.PromiseA.resolve().then(function () {
|
|
var update;
|
|
if (req.params.group) {
|
|
update = {};
|
|
update[req.params.group] = req.body;
|
|
} else {
|
|
update = req.body;
|
|
}
|
|
|
|
var changer = new (require('./config').ConfigChanger)(conf);
|
|
changer.update(update);
|
|
return config.save(changer);
|
|
}).then(function (newConf) {
|
|
if (req.params.group) {
|
|
return newConf[req.params.group];
|
|
}
|
|
return newConf;
|
|
});
|
|
handlePromise(req, res, promise);
|
|
};
|
|
|
|
config.extractModList = function (changer, params) {
|
|
var err;
|
|
if (params.domId) {
|
|
var dom = changer.domains.find(function (dom) {
|
|
return dom.id === params.domId;
|
|
});
|
|
|
|
if (!dom) {
|
|
err = new Error("no domain with ID '"+params.domId+"'");
|
|
} else if (!dom.modules[params.group]) {
|
|
err = new Error("domains don't contain '"+params.group+"' modules");
|
|
} else {
|
|
return dom.modules[params.group];
|
|
}
|
|
} else {
|
|
if (!changer[params.group] || !changer[params.group].modules) {
|
|
err = new Error("'"+params.group+"' is not a valid settings group or doesn't support modules");
|
|
} else {
|
|
return changer[params.group].modules;
|
|
}
|
|
}
|
|
|
|
err.statusCode = 404;
|
|
throw err;
|
|
};
|
|
config.restful.createModule = function (req, res, next) {
|
|
if (req.params.group === 'domains') {
|
|
next();
|
|
return;
|
|
}
|
|
|
|
var promise = deps.PromiseA.resolve().then(function () {
|
|
var changer = new (require('./config').ConfigChanger)(conf);
|
|
var modList = config.extractModList(changer, req.params);
|
|
|
|
var update = req.body;
|
|
if (!Array.isArray(update)) {
|
|
update = [ update ];
|
|
}
|
|
update.forEach(modList.add, modList);
|
|
|
|
return config.save(changer);
|
|
}).then(function (newConf) {
|
|
return config.extractModList(newConf, req.params);
|
|
});
|
|
handlePromise(req, res, promise);
|
|
};
|
|
config.restful.updateModule = function (req, res, next) {
|
|
if (req.params.group === 'domains') {
|
|
next();
|
|
return;
|
|
}
|
|
|
|
var promise = deps.PromiseA.resolve().then(function () {
|
|
var changer = new (require('./config').ConfigChanger)(conf);
|
|
var modList = config.extractModList(changer, req.params);
|
|
modList.update(req.params.modId, req.body);
|
|
return config.save(changer);
|
|
}).then(function (newConf) {
|
|
return config.extractModule(newConf, req.params).find(function (mod) {
|
|
return mod.id === req.params.modId;
|
|
});
|
|
});
|
|
handlePromise(req, res, promise);
|
|
};
|
|
config.restful.removeModule = function (req, res, next) {
|
|
if (req.params.group === 'domains') {
|
|
next();
|
|
return;
|
|
}
|
|
|
|
var promise = deps.PromiseA.resolve().then(function () {
|
|
var changer = new (require('./config').ConfigChanger)(conf);
|
|
var modList = config.extractModList(changer, req.params);
|
|
modList.remove(req.params.modId);
|
|
return config.save(changer);
|
|
}).then(function (newConf) {
|
|
return config.extractModList(newConf, req.params);
|
|
});
|
|
handlePromise(req, res, promise);
|
|
};
|
|
|
|
config.restful.createDomain = function (req, res) {
|
|
var promise = deps.PromiseA.resolve().then(function () {
|
|
var changer = new (require('./config').ConfigChanger)(conf);
|
|
|
|
var update = req.body;
|
|
if (!Array.isArray(update)) {
|
|
update = [ update ];
|
|
}
|
|
update.forEach(changer.domains.add, changer.domains);
|
|
return config.save(changer);
|
|
}).then(function (newConf) {
|
|
return newConf.domains;
|
|
});
|
|
handlePromise(req, res, promise);
|
|
};
|
|
config.restful.updateDomain = function (req, res) {
|
|
var promise = deps.PromiseA.resolve().then(function () {
|
|
if (req.body.modules) {
|
|
throw Object.assign(new Error('do not add modules with this route'), {statusCode: 400});
|
|
}
|
|
|
|
var changer = new (require('./config').ConfigChanger)(conf);
|
|
changer.domains.update(req.params.domId, req.body);
|
|
return config.save(changer);
|
|
}).then(function (newConf) {
|
|
return newConf.domains.find(function (dom) {
|
|
return dom.id === req.params.domId;
|
|
});
|
|
});
|
|
handlePromise(req, res, promise);
|
|
};
|
|
config.restful.removeDomain = function (req, res) {
|
|
var promise = deps.PromiseA.resolve().then(function () {
|
|
var changer = new (require('./config').ConfigChanger)(conf);
|
|
changer.domains.remove(req.params.domId);
|
|
return config.save(changer);
|
|
}).then(function (newConf) {
|
|
return newConf.domains;
|
|
});
|
|
handlePromise(req, res, promise);
|
|
};
|
|
|
|
var app = require('express')();
|
|
|
|
// Handle all of the API endpoints using the old definition style, and then we can
|
|
// add middleware without worrying too much about the consequences to older code.
|
|
app.use('/:name', handleOldApis);
|
|
|
|
app.use('/', isAuthorized, jsonParser);
|
|
|
|
// Not all config routes support PUT or DELETE, but not worth making this more specific
|
|
app.use( '/config', makeCorsHandler(['GET', 'POST', 'PUT', 'DELETE']));
|
|
app.get( '/config', config.restful.readConfig);
|
|
app.get( '/config/:group', config.restful.readConfig);
|
|
app.get( '/config/:group/:mod(modules)/:modId?', config.restful.readConfig);
|
|
app.get( '/config/domains/:domId/:mod(modules)?', config.restful.readConfig);
|
|
app.get( '/config/domains/:domId/:mod(modules)/:modGrp/:modId?', config.restful.readConfig);
|
|
|
|
app.post( '/config', config.restful.saveBaseConfig);
|
|
app.post( '/config/:group', config.restful.saveBaseConfig);
|
|
|
|
app.post( '/config/:group/modules', config.restful.createModule);
|
|
app.put( '/config/:group/modules/:modId', config.restful.updateModule);
|
|
app.delete('/config/:group/modules/:modId', config.restful.removeModule);
|
|
|
|
app.post( '/config/domains/:domId/modules/:group', config.restful.createModule);
|
|
app.put( '/config/domains/:domId/modules/:group/:modId', config.restful.updateModule);
|
|
app.delete('/config/domains/:domId/modules/:group/:modId', config.restful.removeModule);
|
|
|
|
app.post( '/config/domains', config.restful.createDomain);
|
|
app.put( '/config/domains/:domId', config.restful.updateDomain);
|
|
app.delete('/config/domains/:domId', config.restful.removeDomain);
|
|
|
|
return app;
|
|
};
|