Created
May 28, 2014 23:00
-
-
Save blindman2k/d01153902a966db90f83 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// ----------------------------------------------------------------------------- | |
class Rocky | |
{ | |
_handlers = null; | |
_timeout = 10; | |
// --------------------[ PUBLIC FUNCTIONS ]--------------------------------- | |
// ......................................................................... | |
constructor() { | |
_handlers = { timeout = null, notfound = null, exception = null, authorise = null, unauthorised = null}; | |
http.onrequest(_onrequest.bindenv(this)); | |
} | |
// ......................................................................... | |
function on(verb, signature, callback) { | |
// Register this signature and verb against the callback | |
verb = verb.toupper(); | |
signature = signature.tolower(); | |
if (!(signature in _handlers)) _handlers[signature] <- {}; | |
_handlers[signature][verb] <- callback; | |
return this; | |
} | |
// ......................................................................... | |
function post(signature, callback) { | |
return on("POST", signature, callback); | |
} | |
// ......................................................................... | |
function get(signature, callback) { | |
return on("GET", signature, callback); | |
} | |
// ......................................................................... | |
function put(signature, callback) { | |
return on("PUT", signature, callback); | |
} | |
// ......................................................................... | |
function timeout(callback, timeout = 10) { | |
_handlers.timeout <- callback; | |
_timeout = timeout; | |
} | |
// ......................................................................... | |
function notfound(callback) { | |
_handlers.notfound <- callback; | |
} | |
// ......................................................................... | |
function exception(callback) { | |
_handlers.exception <- callback; | |
} | |
// ......................................................................... | |
function authorise(callback) { | |
_handlers.authorise <- callback; | |
} | |
// ......................................................................... | |
function unauthorised(callback) { | |
_handlers.unauthorised <- callback; | |
} | |
// ......................................................................... | |
// This should come from the context bind not the class | |
function access_control() { | |
// We should probably put this as a default OPTION handler, but for now this will do | |
// It is probably never required tho as this is an API handler not a HTML handler | |
res.header("Access-Control-Allow-Origin", "*") | |
res.header("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept"); | |
} | |
// -------------------------[ PRIVATE FUNCTIONS ]--------------------------- | |
// ......................................................................... | |
function _onrequest(req, res) { | |
// Setup the context for the callbacks | |
local context = Context(req, res); | |
try { | |
// Immediately reject insecure connections | |
if ("x-forwarded-proto" in req.headers && req.headers["x-forwarded-proto"] != "https") { | |
context.send(405, "HTTP not allowed."); | |
return; | |
} | |
// Parse the request body back into the body | |
try { | |
req.body = _parse_body(req); | |
} catch (e) { | |
context.send(400, e); | |
return; | |
} | |
// Are we authorised | |
if (_handlers.authorise) { | |
local credentials = _parse_authorisation(context); | |
if (_handlers.authorise(context, credentials)) { | |
// The application accepted the user credentials. No need to keep anything but the user name. | |
context.user = credentials.user; | |
} else { | |
// The application rejected the user credentials | |
if (_handlers.unauthorised) { | |
_handlers.unauthorised(context); | |
} | |
context.send(401, "Unauthorized"); | |
return; | |
} | |
} | |
// Do we have a handler for this request? | |
local handler = _handler_match(req); | |
if (!handler && _handlers.notfound) { | |
// No, be we have a not found handler | |
handler = _extract_parts(_handlers.notfound, req.path.tolower()) | |
} | |
// If we have a handler, then execute it | |
if (handler) { | |
context.path = handler.path; | |
context.matches = handler.matches; | |
context.set_timeout(_timeout, _handlers.timeout); | |
handler.callback(context); | |
} else { | |
// We have no handler | |
context.send(404) | |
} | |
} catch (e) { | |
// Offload to the provided exception handler if we have one | |
if (_handlers.exception) { | |
_handlers.exception(context, e); | |
} else { | |
server.log("Exception: " + e) | |
} | |
// If we get to here without sending anything, send something. | |
context.send(500, "Unhandled exception") | |
} | |
} | |
// ......................................................................... | |
function _parse_body(req) { | |
if ("content-type" in req.headers && req.headers["content-type"] == "application/json") { | |
if (req.body == "" || req.body == null) return null; | |
else return http.jsondecode(req.body); | |
} | |
if ("content-type" in req.headers && req.headers["content-type"] == "application/x-www-form-urlencoded") { | |
return http.urldecode(req.body); | |
} | |
if ("content-type" in req.headers && req.headers["content-type"].slice(0,20) == "multipart/form-data;") { | |
local parts = []; | |
local boundary = req.headers["content-type"].slice(30); | |
local bindex = -1; | |
do { | |
bindex = req.body.find("--" + boundary + "\r\n", bindex+1); | |
if (bindex != null) { | |
// Locate all the parts | |
local hstart = bindex + boundary.len() + 4; | |
local nstart = req.body.find("name=\"", hstart) + 6; | |
local nfinish = req.body.find("\"", nstart); | |
local fnstart = req.body.find("filename=\"", hstart) + 10; | |
local fnfinish = req.body.find("\"", fnstart); | |
local bstart = req.body.find("\r\n\r\n", hstart) + 4; | |
local fstart = req.body.find("\r\n--" + boundary, bstart); | |
// Pull out the parts as strings | |
local headers = req.body.slice(hstart, bstart); | |
local name = null; | |
local filename = null; | |
local type = null; | |
foreach (header in split(headers, ";\n")) { | |
local kv = split(header, ":="); | |
if (kv.len() == 2) { | |
switch (strip(kv[0]).tolower()) { | |
case "name": | |
name = strip(kv[1]).slice(1, -1); | |
break; | |
case "filename": | |
filename = strip(kv[1]).slice(1, -1); | |
break; | |
case "content-type": | |
type = strip(kv[1]); | |
break; | |
} | |
} | |
} | |
local data = req.body.slice(bstart, fstart); | |
local part = { "name": name, "filename": filename, "data": data, "content-type": type }; | |
parts.push(part); | |
} | |
} while (bindex != null); | |
return parts; | |
} | |
// Nothing matched, send back the original body | |
return req.body; | |
} | |
// ......................................................................... | |
function _parse_authorisation(context) { | |
if ("authorization" in context.req.headers) { | |
local auth = split(context.req.headers.authorization, " "); | |
if (auth.len() == 2 && auth[0] == "Basic") { | |
// Note the username and password can't have colons in them | |
local creds = http.base64decode(auth[1]).tostring(); | |
creds = split(creds, ":"); | |
if (creds.len() == 2) { | |
return { authtype = "Basic", user = creds[0], pass = creds[1] }; | |
} | |
} else if (auth.len() == 2 && auth[0] == "Bearer") { | |
// The bearer is just the password | |
if (auth[1].len() > 0) { | |
return { authtype = "Bearer", user = auth[1], pass = auth[1] }; | |
} | |
} | |
} | |
return { authtype = "None", user = "", pass = "" }; | |
} | |
// ......................................................................... | |
function _extract_parts(callback, path, regexp = null) { | |
local parts = {path = [], matches = [], callback = callback}; | |
// Split the path into parts | |
foreach (part in split(path, "/")) { | |
parts.path.push(part); | |
} | |
// Capture regular expression matches | |
if (regexp != null) { | |
local caps = regexp.capture(path); | |
local matches = []; | |
foreach (cap in caps) { | |
parts.matches.push(path.slice(cap.begin, cap.end)); | |
} | |
} | |
return parts; | |
} | |
// ......................................................................... | |
function _handler_match(req) { | |
local signature = req.path.tolower(); | |
local verb = req.method.toupper(); | |
if ((signature in _handlers) && (verb in _handlers[signature])) { | |
// We have an exact signature match | |
return _extract_parts(_handlers[signature][verb], signature); | |
} else if ((signature in _handlers) && ("*" in _handlers[signature])) { | |
// We have a partial signature match | |
return _extract_parts(_handlers[signature]["*"], signature); | |
} else { | |
// Let's iterate through all handlers and search for a regular expression match | |
foreach (_signature,_handler in _handlers) { | |
if (typeof _handler == "table") { | |
foreach (_verb,_callback in _handler) { | |
if (_verb == verb || _verb == "*") { | |
try { | |
local ex = regexp(_signature); | |
if (ex.match(signature)) { | |
// We have a regexp handler match | |
return _extract_parts(_callback, signature, ex); | |
} | |
} catch (e) { | |
// Don't care about invalid regexp. | |
} | |
} | |
} | |
} | |
} | |
} | |
return false; | |
} | |
} | |
// ----------------------------------------------------------------------------- | |
class Context | |
{ | |
req = null; | |
res = null; | |
sent = false; | |
id = null; | |
time = null; | |
user = null; | |
path = null; | |
matches = null; | |
timer = null; | |
static _contexts = {}; | |
constructor(_req, _res) { | |
req = _req; | |
res = _res; | |
sent = false; | |
time = date(); | |
// Identify and store the context | |
do { | |
id = math.rand(); | |
} while (id in _contexts); | |
_contexts[id] <- this; | |
} | |
// ......................................................................... | |
function get(id) { | |
if (id in _contexts) { | |
return _contexts[id]; | |
} else { | |
return null; | |
} | |
} | |
// ......................................................................... | |
function isbrowser() { | |
return (("accept" in req.headers) && (req.headers.accept.find("text/html") != null)); | |
} | |
// ......................................................................... | |
function header(key, def = null) { | |
key = key.tolower(); | |
if (key in req.headers) return req.headers[key]; | |
else return def; | |
} | |
// ......................................................................... | |
function set_header(key, value) { | |
return res.header(key, value); | |
} | |
// ......................................................................... | |
function send(code, message = null) { | |
// Cancel the timeout | |
if (timer) { | |
imp.cancelwakeup(timer); | |
timer = null; | |
} | |
// Remove the context from the store | |
if (id in _contexts) { | |
delete Context._contexts[id]; | |
} | |
// Has this context been closed already? | |
if (sent) { | |
return false; | |
} | |
if (message == null && typeof code == "integer") { | |
// Empty result code | |
res.send(code, ""); | |
} else if (message == null && typeof code == "string") { | |
// No result code, assume 200 | |
res.send(200, code); | |
} else if (message == null && (typeof code == "table" || typeof code == "array")) { | |
// No result code, assume 200 ... and encode a json object | |
res.header("Content-Type", "application/json; charset=utf-8"); | |
res.send(200, http.jsonencode(code)); | |
} else if (typeof code == "integer" && (typeof message == "table" || typeof message == "array")) { | |
// Encode a json object | |
res.header("Content-Type", "application/json; charset=utf-8"); | |
res.send(code, http.jsonencode(message)); | |
} else { | |
// Normal result | |
res.send(code, message); | |
} | |
sent = true; | |
} | |
// ......................................................................... | |
function set_timeout(timeout, callback) { | |
// Set the timeout timer | |
if (timer) imp.cancelwakeup(timer); | |
timer = imp.wakeup(timeout, function() { | |
if (callback == null) { | |
send(502, "Timeout"); | |
} else { | |
callback(this); | |
} | |
}.bindenv(this)) | |
} | |
} | |
// ==============================[ Application code ]================================ | |
// This random key should be replaced with a custom per-user EI API Key | |
const PROXY_API_KEY = "xIrFI7yfgCuNIcb462bUFNo32X0JoML6"; | |
rest <- Rocky(); | |
channels <- {}; // Holds all the valid channel keys, also recording the last agent that came online | |
clients <- {}; // Holds all the valid client ID's, also recording the last agent that came online | |
agents <- {}; // Holds all the agent ID's and the devices they | |
codes <- {}; // Holds all the agent ID's which double as the auth tokens. | |
code_timers <- {}; // Holds the temporary timers that expire security codes after a minute | |
// ............................................................................. | |
// Check the CHANNEL KEY | |
rest.authorise(function(context, credentials) { | |
switch (context.req.path) { | |
case "/oauth2/authorize": | |
return context.req.method == "GET" || context.req.method == "POST"; | |
case "/oauth2/token": | |
return context.req.method == "POST"; | |
case "/oauth2/register/user": | |
case "/oauth2/register/agent": | |
return credentials.pass == PROXY_API_KEY; | |
case "/ifttt/v1/status": | |
case "/ifttt/v1/test/setup": | |
return context.header("IFTTT-Channel-Key") in channels; | |
default: | |
return credentials.user in agents; | |
} | |
}.bindenv(this)); | |
// ............................................................................. | |
// This is not a valid request, respond with an error code | |
rest.unauthorised(function(context) { | |
context.send(401, { "errors": [{"message": "Unauthorised" }] } ); | |
}.bindenv(this)); | |
// ............................................................................. | |
// The user has requested to register a one-time code against his agent | |
rest.on("POST", "/oauth2/register/user", function(context) { | |
// Check the parameters | |
local body = context.req.body; | |
if (body.code.len() < 6 || body.agentid.len() < 12) { | |
return context.send(400, "Invalid request"); | |
} else { | |
// Expire codes after 60 seconds | |
codes[body.code] <- body; | |
code_timers[body.code] <- imp.wakeup(60, function() { | |
delete code_timers[body.code]; | |
delete codes[body.code]; | |
}.bindenv(this)); | |
server.log(format("Registered one-time code for agent '%s'", body.agentid)) | |
return context.send("OK"); | |
} | |
}.bindenv(this)) | |
// ............................................................................. | |
// A device is registering itself | |
rest.on("POST", "/oauth2/register/agent", function(context) { | |
// Check the parameters | |
local body = context.req.body; | |
if (body.channelkey.len() < 64 || body.agentid.len() < 12 || body.clientid.len() == 0) { | |
return context.send(400, "Invalid request"); | |
} else { | |
channels[body.channelkey] <- body.agentid; | |
clients[body.clientid] <- body.agentid; | |
agents[body.agentid] <- body.agentid; | |
save_state(); | |
server.log(format("Saved channel '%s' for agent '%s'", body.clientid, body.agentid)) | |
return context.send("OK"); | |
} | |
}.bindenv(this)) | |
// ............................................................................. | |
rest.on("GET", "/oauth2/authorize", function(context) { | |
server.log("OAuth2 authorize request"); | |
local query = context.req.query; | |
try { | |
if ( query.scope == "ifttt" | |
&& query.client_id in clients | |
&& query.redirect_uri.len() > 5 | |
&& query.state.len() > 5 | |
&& query.response_type == "code" | |
) { | |
local html = @"<form method='post'> | |
Enter the one-time code here: <input name='code'><br/> | |
<button type='submit'>Submit</button> | |
</form>"; | |
html = format(html, query.scope, query.client_id, query.response_type, query.redirect_uri); | |
context.set_header("Content-Type", "text/html") | |
return context.send(html); | |
} | |
} catch (e) { | |
server.error("Exception in /oauth2/authorize: " + e); | |
} | |
context.set_header("Location", query.redirect_uri + "?error=access_denied") | |
context.send(302, "Access rejected"); | |
}); | |
// ............................................................................. | |
rest.on("POST", "/oauth2/authorize", function(context) { | |
server.log("OAuth2 authorize response"); | |
local query = context.req.body; | |
foreach (k, v in context.req.query) query[k] <- v; | |
try { | |
if ( query.scope == "ifttt" | |
&& query.client_id in clients | |
&& query.redirect_uri.len() > 5 | |
&& query.state.len() > 5 | |
&& query.response_type == "code" | |
&& query.code.len() >= 6 | |
&& query.code in codes | |
) { | |
context.set_header("Location", query.redirect_uri + "?code=" + codes[query.code].agentid + "&state=" + query.state) | |
context.send(302, "Access granted"); | |
return; | |
} | |
} catch (e) { | |
server.error("Exception in /oauth2/authorize: " + e); | |
} | |
context.set_header("Location", query.redirect_uri + "?error=access_denied") | |
context.send(302, "Access rejected"); | |
}); | |
// ............................................................................. | |
rest.on("POST", "/oauth2/token", function(context) { | |
server.log("OAuth2 token"); | |
try { | |
local query = http.urldecode(context.req.body); | |
if ( query.grant_type == "authorization_code" | |
&& query.code in agents) { | |
// Pass this request on to the agent | |
local url = format("https://agents.electricimp.com/%s/oauth2/token", query.code); | |
http.post(url, context.req.headers, context.req.body).sendasync(function(res) { | |
foreach (k,v in res.headers) context.set_header(k, v); | |
context.send(res.statuscode, res.body); | |
}); | |
return; | |
} | |
} catch (e) { | |
server.error("Exception in /oauth2/token: " + e); | |
} | |
context.send(401, "Access rejected"); | |
}); | |
// ............................................................................. | |
rest.on("GET", "/ifttt/v1/status", function(context) { | |
server.log("Status check"); | |
context.send("OK"); | |
}); | |
// ............................................................................. | |
rest.notfound(function(context) { | |
server.log("Forwarding: " + context.req.path); | |
// Pick a default user for test setup | |
if (context.user == "" && context.header("IFTTT-Channel-Key") in channels) { | |
context.user = channels[context.header("IFTTT-Channel-Key")]; | |
} | |
local url = format("https://agents.electricimp.com/%s%s", context.user, context.req.path); | |
local body = ""; | |
if (typeof context.req.body == "table") body = http.jsonencode(context.req.body); | |
else if (typeof context.req.body == "null") body = ""; | |
else body = context.req.body; | |
if ("content-length" in context.req.headers) delete context.req.headers["content-length"]; | |
// Pass this request on to the agent | |
http.request(context.req.method, url, context.req.headers, body).sendasync(function(res) { | |
foreach (k,v in res.headers) context.set_header(k, v); | |
context.send(res.statuscode, res.body); | |
}); | |
}) | |
// ............................................................................. | |
// Saves the state of the agent in case of a reboot | |
function save_state() { | |
local save = { agents = agents, channels = channels, clients = clients }; | |
server.save(save); | |
} | |
// ............................................................................. | |
// Restores the state of the agent after a reboot | |
function restore_state() { | |
local saved = server.load(); | |
if ("channels" in saved) channels = saved.channels; | |
if ("clients" in saved) clients = saved.clients; | |
if ("agents" in saved) agents = saved.agents; | |
} | |
restore_state(); | |
server.log("Rebooted") |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment