Skip to content

Instantly share code, notes, and snippets.

@blindman2k
Created May 28, 2014 23:00
Show Gist options
  • Save blindman2k/d01153902a966db90f83 to your computer and use it in GitHub Desktop.
Save blindman2k/d01153902a966db90f83 to your computer and use it in GitHub Desktop.
// -----------------------------------------------------------------------------
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