diff --git a/CryptoMath.lua b/CryptoMath.lua new file mode 100644 index 0000000..f207d2f --- /dev/null +++ b/CryptoMath.lua @@ -0,0 +1,56 @@ +local uv = require("uv") +local TableUtils = require("./TableUtils.lua") + +local CryptoMath = {} + +function CryptoMath.uvrandom(min, max) + assert(min, 'expected lower bound') + assert(max, 'expected upper bound') + assert(max > min, 'expected max > min') + local range = max - min + + local log256range = math.ceil(math.log(range, 256)) -- number of bytes required to store range + + local bytes = uv.random(log256range * 2) -- get double the bytes required so we can distribute evenly with modulo + local random = 0 + + for i = 1, #bytes do + random = bit.lshift(random, 8) + bytes:byte(i, i) + end + + return random % range + min +end + +local charIgnoreList = { + 58, + 59, + 60, + 61, + 62, + 63, + 64, + 91, + 92, + 93, + 94, + 96, + 123, + 124, + 125 +} + +function CryptoMath.RandomString(length) + local str = "" + for i = 1, length do + --local randomNum = cryptoMath.uvrandom(65,126) + local randomNum + repeat + randomNum = CryptoMath.uvrandom(48,126) + until not TableUtils.Find(charIgnoreList, randomNum) + local randomChar = string.char(randomNum) + str = str..randomChar + end + return str +end + +return CryptoMath \ No newline at end of file diff --git a/Endpoints/GetInfo.lua b/Endpoints/GetInfo.lua new file mode 100644 index 0000000..e839f25 --- /dev/null +++ b/Endpoints/GetInfo.lua @@ -0,0 +1,20 @@ +local SpotifyAPI = require("./../SpotifyAPIServer.lua") + +local GetInfo = {} + +GetInfo.method = "GET" + +GetInfo.path = "/GetInfo" + +GetInfo.callback = function(req, res) + local inforaw = SpotifyAPI:GetInfo(true) + + if inforaw then + res.body = inforaw + res.code = 200 + else + res.code = 500 + end +end + +return GetInfo \ No newline at end of file diff --git a/Endpoints/GetMuted.lua b/Endpoints/GetMuted.lua new file mode 100644 index 0000000..dc4d3e9 --- /dev/null +++ b/Endpoints/GetMuted.lua @@ -0,0 +1,23 @@ +local SpotifyAPI = require("./../SpotifyAPIServer.lua") +local json = require("json") + +local GetMuted = {} + +GetMuted.method = "GET" + +GetMuted.path = "/GetMuted" + +GetMuted.callback = function(req, res) + local isMutedRes = json.stringify({ + muted = SpotifyAPI.muted + }) + + if isMutedRes then + res.body = isMutedRes + res.code = 200 + else + res.code = 500 + end +end + +return GetMuted \ No newline at end of file diff --git a/Endpoints/Next.lua b/Endpoints/Next.lua new file mode 100644 index 0000000..2bd9287 --- /dev/null +++ b/Endpoints/Next.lua @@ -0,0 +1,15 @@ +local SpotifyAPI = require("./../SpotifyAPIServer.lua") + +local Next = {} + +Next.method = "GET" + +Next.path = "/Next" + +Next.callback = function(req, res) + SpotifyAPI:SkipForward() + + res.code = 204 +end + +return Next \ No newline at end of file diff --git a/Endpoints/Pause.lua b/Endpoints/Pause.lua new file mode 100644 index 0000000..d71c96d --- /dev/null +++ b/Endpoints/Pause.lua @@ -0,0 +1,15 @@ +local SpotifyAPI = require("./../SpotifyAPIServer.lua") + +local Pause = {} + +Pause.method = "GET" + +Pause.path = "/Pause" + +Pause.callback = function(req, res) + SpotifyAPI:Pause() + + res.code = 204 +end + +return Pause \ No newline at end of file diff --git a/Endpoints/Previous.lua b/Endpoints/Previous.lua new file mode 100644 index 0000000..a2b7020 --- /dev/null +++ b/Endpoints/Previous.lua @@ -0,0 +1,15 @@ +local SpotifyAPI = require("./../SpotifyAPIServer.lua") + +local Previous = {} + +Previous.method = "GET" + +Previous.path = "/Previous" + +Previous.callback = function(req, res) + SpotifyAPI:SkipBackward() + + res.code = 204 +end + +return Previous \ No newline at end of file diff --git a/Endpoints/Resume.lua b/Endpoints/Resume.lua new file mode 100644 index 0000000..59fdfea --- /dev/null +++ b/Endpoints/Resume.lua @@ -0,0 +1,15 @@ +local SpotifyAPI = require("./../SpotifyAPIServer.lua") + +local Resume = {} + +Resume.method = "GET" + +Resume.path = "/Resume" + +Resume.callback = function(req, res) + SpotifyAPI:Resume() + + res.code = 204 +end + +return Resume \ No newline at end of file diff --git a/Endpoints/SetMuted.lua b/Endpoints/SetMuted.lua new file mode 100644 index 0000000..3f63c1d --- /dev/null +++ b/Endpoints/SetMuted.lua @@ -0,0 +1,22 @@ +local SpotifyAPI = require("./../SpotifyAPIServer.lua") +local json = require("json") + +local SetMuted = {} + +SetMuted.method = "POST" + +SetMuted.path = "/SetMuted" + +SetMuted.callback = function(req, res) + local params = json.parse(req.body) + + if params then + local mute = params["mute"] + SpotifyAPI:SetMuted(mute) + res.code = 204 + else + res.code = 400 + end +end + +return SetMuted \ No newline at end of file diff --git a/Endpoints/ToggleMute.lua b/Endpoints/ToggleMute.lua new file mode 100644 index 0000000..5f98b53 --- /dev/null +++ b/Endpoints/ToggleMute.lua @@ -0,0 +1,29 @@ +local SpotifyAPI = require("./../SpotifyAPIServer.lua") +local json = require("json") + +local ToggleMute = {} + +ToggleMute.method = "GET" + +ToggleMute.path = "/ToggleMute" + +ToggleMute.callback = function(req, res) + local info = SpotifyAPI:GetInfo() + + if info then + if not SpotifyAPI.muted then + local infoJSON = json.parse(info) + SpotifyAPI.muted = true + SpotifyAPI.volumeBeforeMute = infoJSON["device"]["volume_percent"] + SpotifyAPI:SetVolume(0) + else + SpotifyAPI.muted = false + SpotifyAPI:SetVolume(SpotifyAPI.volumeBeforeMute) + SpotifyAPI.volumeBeforeMute = 100 + end + end + + res.code = 204 +end + +return ToggleMute \ No newline at end of file diff --git a/Endpoints/TogglePlaying.lua b/Endpoints/TogglePlaying.lua new file mode 100644 index 0000000..5562605 --- /dev/null +++ b/Endpoints/TogglePlaying.lua @@ -0,0 +1,15 @@ +local SpotifyAPI = require("./../SpotifyAPIServer.lua") + +local TogglePlaying = {} + +TogglePlaying.method = "GET" + +TogglePlaying.path = "/TogglePlaying" + +TogglePlaying.callback = function(req, res) + SpotifyAPI:TogglePlaying() + + res.code = 204 +end + +return TogglePlaying \ No newline at end of file diff --git a/SpotifyAPIServer.lua b/SpotifyAPIServer.lua new file mode 100644 index 0000000..dc1fe82 --- /dev/null +++ b/SpotifyAPIServer.lua @@ -0,0 +1,337 @@ +local bundle = require("luvi").bundle +local uv = require("uv") +local p = require('pretty-print').prettyPrint +local QueryString = require("querystring") +local request = require("coro-http").request +local json = require("json") +local timer = require("timer") + +local APIServer = {} + +APIServer.clientId = nil +APIServer.currentApp = nil +APIServer.currentCode = nil +APIServer.currentToken = nil +APIServer.refreshToken = nil + +APIServer.muted = false +APIServer.volumeBeforeMute = 100 + +function APIServer:Start(clientId, codeVerifier, redirectUri) + self.clientId = clientId + -- This returns a table that is the app instance. + -- All it's functions return the same table for chaining calls. + self.currentApp = require('weblit-app') + + .bind({ + host = "0.0.0.0", + port = 8080 + }) + + -- Include a few useful middlewares. Weblit uses a layered approach. + .use(require('weblit-logger')) + .use(require('weblit-auto-headers')) + .use(require('weblit-etag-cache')) + + -- This is a custom route handler + .route({ + method = "GET", + path = "/" + }, function (req, res) + p(req) + + local code = req.query["code"] + local error = req.query["error"] + + if code then + self.currentCode = code + self:RequestAccessToken(codeVerifier, redirectUri) + elseif error then + error(error) + end + + res.body = "Authentication successful" + res.code = 200 + end) + + for _, endpoint in ipairs(bundle.readdir("./Endpoints/")) do + local endpointModule = require("bundle:/Endpoints/"..endpoint) + p(endpointModule) + assert(endpointModule.path ~= "/", endpoint.." is attempting to use a protected endpoint") + self.currentApp.route({ + method = endpointModule.method, + path = endpointModule.path, + }, endpointModule.callback) + end + + self.currentApp.start() + uv.run() -- keep program from exiting +end + +function APIServer:Stop() + self.currentApp.stop() +end + +function APIServer:GetInfo(raw) + do + local Res, Body = request( + "GET", + "https://api.spotify.com/v1/me/player", + { + {"Content-Length", "0"}, + {"Authorization", "Bearer "..self.currentToken} + } + ) + + if Res.code == 200 then + if raw then + return Body + else + return json.parse(Body) + end + else + print("oof") + p(Res) + p(Body) + end + end +end + +function constructForm(tbl) + local first = true + local str = "" + for index,v in pairs(tbl) do + if first then + str = str..index.."="..QueryString.urlencode(v) + first = false + else + str = str.."&"..index.."="..QueryString.urlencode(v) + end + end + return str +end + +function APIServer:SetMuted(muted) + local info = self:GetInfo() + + if info then + if muted then + self.muted = true + self.volumeBeforeMute = info["device"]["volume_percent"] + self:SetVolume(0) + else + self.muted = false + self:SetVolume(self.volumeBeforeMute) + self.volumeBeforeMute = 100 + end + end +end + +function APIServer:Resume() + do + local Res, Body = request( + "PUT", + "https://api.spotify.com/v1/me/player/play", + { + {"Content-Length", "0"}, + {"Authorization", "Bearer "..self.currentToken} + } + ) + + if Res.code == 204 then + print("Yes") + else + p(Res) + p(Body) + end + end +end + +function APIServer:Pause() + do + local Res, Body = request( + "PUT", + "https://api.spotify.com/v1/me/player/pause", + { + {"Content-Length", "0"}, + {"Authorization", "Bearer "..self.currentToken} + } + ) + + if Res.code == 204 then + print("Yes") + else + p(Res) + p(Body) + end + end +end + +function APIServer:TogglePlaying() + do + local Res, Body = request( + "GET", + "https://api.spotify.com/v1/me/player", + { + {"Content-Length", "0"}, + {"Authorization", "Bearer "..self.currentToken} + } + ) + + if Res.code == 200 then + local BodyJSON = json.parse(Body) + p(BodyJSON) + if BodyJSON["is_playing"] then + self:Pause() + else + self:Resume() + end + else + print("oof") + p(Res) + p(Body) + end + end +end + +function APIServer:SkipForward() + do + local Res, Body = request( + "POST", + "https://api.spotify.com/v1/me/player/next", + { + {"Content-Length", "0"}, + {"Authorization", "Bearer "..self.currentToken} + } + ) + + if Res.code == 204 then + print("Yes") + else + p(Res) + p(Body) + end + end +end + +function APIServer:SkipBackward() + do + local Res, Body = request( + "POST", + "https://api.spotify.com/v1/me/player/previous", + { + {"Content-Length", "0"}, + {"Authorization", "Bearer "..self.currentToken} + } + ) + + if Res.code == 204 then + print("Yes") + else + p(Res) + p(Body) + end + end +end + +function APIServer:SetVolume(volume) + --[[ + local form = constructForm({ + volume_percent = tostring(volume) + }) + ]] + do + local Res, Body = request( + "PUT", + "https://api.spotify.com/v1/me/player/volume?volume_percent="..tostring(volume), + { + {"Content-Length", "0"}, + {"Authorization", "Bearer "..self.currentToken} + } + ) + + if Res.code == 204 then + print("Volume set") + else + p(Res) + p(Body) + end + end +end + +local function TokenRefresher(expiresIn) + print("Resuming refresh coroutine") + + timer.sleep((expiresIn-60)*1000) -- refresh 1 minute early to make sure token doesn't expire before refresh + APIServer:RefreshAccessToken() +end + +function APIServer:RequestAccessToken(codeVerifier, redirectUri) + local form = constructForm({ + client_id = self.clientId, + grant_type = "authorization_code", + code = self.currentCode, + redirect_uri = redirectUri, + code_verifier = codeVerifier + }) + do + local AccessTokenResponse, Body = request( + "POST", + "https://accounts.spotify.com/api/token", + { + {"Content-Type", "application/x-www-form-urlencoded"}, + {"Accept", "application/json"} + }, + form + ) + print(form) + if AccessTokenResponse.code == 200 then + local BodyJSON = json.parse(Body) + + local RefreshCoroutine = coroutine.create(TokenRefresher) + coroutine.resume(RefreshCoroutine, BodyJSON["expires_in"]) + + p(BodyJSON) + self.currentToken = BodyJSON["access_token"] + self.refreshToken = BodyJSON["refresh_token"] + else + p(AccessTokenResponse) + p(Body) + end + end +end + +function APIServer:RefreshAccessToken() + local form = constructForm({ + grant_type = "refresh_token", + refresh_token = self.refreshToken, + client_id = self.clientId, + }) + do + local AccessTokenResponse, Body = request( + "POST", + "https://accounts.spotify.com/api/token", + { + {"Content-Type", "application/x-www-form-urlencoded"}, + {"Accept", "application/json"} + }, + form + ) + print(form) + if AccessTokenResponse.code == 200 then + local BodyJSON = json.parse(Body) + + local RefreshCoroutine = coroutine.create(TokenRefresher) + coroutine.resume(RefreshCoroutine, BodyJSON["expires_in"]) + + p(BodyJSON) + self.currentToken = BodyJSON["access_token"] + self.refreshToken = BodyJSON["refresh_token"] + else + p(AccessTokenResponse) + p(Body) + end + end +end + +return APIServer \ No newline at end of file diff --git a/deps/base64-url/init.lua b/deps/base64-url/init.lua new file mode 100644 index 0000000..0bd0a2c --- /dev/null +++ b/deps/base64-url/init.lua @@ -0,0 +1,37 @@ +-- converted from https://github.com/joaquimserafim/base64-url +local Buffer = require('buffer').Buffer + +local function unescape (str) + local len = 4 - #str % 4 + local newstr = {} + str:gsub("%S+", function(c) table.insert(newstr, c) end) + for i = 1, len do + table.insert(newstr, "") + end + newstr = table.concat(newstr, '=') + newstr = newstr:gsub("(-)", "+") + newstr = newstr:gsub("(_)", "/") + return newstr +end + +local function escape (str) + str = str:gsub("(%+)", "-") + str = str:gsub("(/)", "_") + str = str:gsub("(%=)", "") + return str +end + +local function encode (str) + return escape(Buffer:new(str):toString()) +end + +local function decode (str) + return Buffer:new(unescape(str)):toString() +end + +return { + unescape = unescape, + escape = escape, + encode = encode, + decode = decode +} \ No newline at end of file diff --git a/deps/base64-url/package.lua b/deps/base64-url/package.lua new file mode 100644 index 0000000..61bd802 --- /dev/null +++ b/deps/base64-url/package.lua @@ -0,0 +1,14 @@ +return { + name = "james2doyle/base64-url", + version = "0.0.2", + description = "Base64 encode, decode, escape and unescape for URL applications.", + tags = { "base64", "encode", "decode", "escape", "unescape", "url" }, + license = "MIT", + author = { name = "james2doyle", email = "james2doyle@gmail.com" }, + homepage = "https://github.com/james2doyle/lit-base64-url", + dependencies = {}, + files = { + "**.lua", + "!test.lua" + } +} diff --git a/deps/base64.lua b/deps/base64.lua new file mode 100644 index 0000000..abe3191 --- /dev/null +++ b/deps/base64.lua @@ -0,0 +1,114 @@ +--[[lit-meta + name = "creationix/base64" + description = "A pure lua implemention of base64 using bitop" + tags = {"crypto", "base64", "bitop"} + version = "2.0.0" + license = "MIT" + author = { name = "Tim Caswell" } +]] + +local bit = require 'bit' +local rshift = bit.rshift +local lshift = bit.lshift +local bor = bit.bor +local band = bit.band +local char = string.char +local byte = string.byte +local concat = table.concat +local codes = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=' + +-- Loop over input 3 bytes at a time +-- a,b,c are 3 x 8-bit numbers +-- they are encoded into groups of 4 x 6-bit numbers +-- aaaaaa aabbbb bbbbcc cccccc +-- if there is no c, then pad the 4th with = +-- if there is also no b then pad the 3rd with = +local function base64Encode(str) + local parts = {} + local j = 1 + for i = 1, #str, 3 do + local a, b, c = byte(str, i, i + 2) + parts[j] = char( + -- Higher 6 bits of a + byte(codes, rshift(a, 2) + 1), + -- Lower 2 bits of a + high 4 bits of b + byte(codes, bor( + lshift(band(a, 3), 4), + b and rshift(b, 4) or 0 + ) + 1), + -- Low 4 bits of b + High 2 bits of c + b and byte(codes, bor( + lshift(band(b, 15), 2), + c and rshift(c, 6) or 0 + ) + 1) or 61, -- 61 is '=' + -- Lower 6 bits of c + c and byte(codes, band(c, 63) + 1) or 61 -- 61 is '=' + ) + j = j + 1 + end + return concat(parts) +end + +-- Reverse map from character code to 6-bit integer +local map = {} +for i = 1, #codes do + map[byte(codes, i)] = i - 1 +end + +-- loop over input 4 characters at a time +-- The characters are mapped to 4 x 6-bit integers a,b,c,d +-- They need to be reassalbled into 3 x 8-bit bytes +-- aaaaaabb bbbbcccc ccdddddd +-- if d is padding then there is no 3rd byte +-- if c is padding then there is no 2nd byte +local function base64Decode(data) + local bytes = {} + local j = 1 + for i = 1, #data, 4 do + local a = map[byte(data, i)] + local b = map[byte(data, i + 1)] + local c = map[byte(data, i + 2)] + local d = map[byte(data, i + 3)] + + -- higher 6 bits are the first char + -- lower 2 bits are upper 2 bits of second char + bytes[j] = char(bor(lshift(a, 2), rshift(b, 4))) + + -- if the third char is not padding, we have a second byte + if c < 64 then + -- high 4 bits come from lower 4 bits in b + -- low 4 bits come from high 4 bits in c + bytes[j + 1] = char(bor(lshift(band(b, 0xf), 4), rshift(c, 2))) + + -- if the fourth char is not padding, we have a third byte + if d < 64 then + -- Upper 2 bits come from Lower 2 bits of c + -- Lower 6 bits come from d + bytes[j + 2] = char(bor(lshift(band(c, 3), 6), d)) + end + end + j = j + 3 + end + return concat(bytes) +end + +assert(base64Encode("") == "") +assert(base64Encode("f") == "Zg==") +assert(base64Encode("fo") == "Zm8=") +assert(base64Encode("foo") == "Zm9v") +assert(base64Encode("foob") == "Zm9vYg==") +assert(base64Encode("fooba") == "Zm9vYmE=") +assert(base64Encode("foobar") == "Zm9vYmFy") + +assert(base64Decode("") == "") +assert(base64Decode("Zg==") == "f") +assert(base64Decode("Zm8=") == "fo") +assert(base64Decode("Zm9v") == "foo") +assert(base64Decode("Zm9vYg==") == "foob") +assert(base64Decode("Zm9vYmE=") == "fooba") +assert(base64Decode("Zm9vYmFy") == "foobar") + +return { + encode = base64Encode, + decode = base64Decode, +} diff --git a/deps/coro-channel.lua b/deps/coro-channel.lua new file mode 100644 index 0000000..15f7fcb --- /dev/null +++ b/deps/coro-channel.lua @@ -0,0 +1,183 @@ +--[[lit-meta + name = "creationix/coro-channel" + version = "3.0.1" + homepage = "https://github.com/luvit/lit/blob/master/deps/coro-channel.lua" + description = "An adapter for wrapping uv streams as coro-streams." + tags = {"coro", "adapter"} + license = "MIT" + author = { name = "Tim Caswell" } +]] + +-- local p = require('pretty-print').prettyPrint + +local function makeCloser(socket) + local closer = { + read = false, + written = false, + errored = false, + } + + local closed = false + + local function close() + if closed then return end + closed = true + if not closer.readClosed then + closer.readClosed = true + if closer.onClose then + closer.onClose() + end + end + if not socket:is_closing() then + socket:close() + end + end + + closer.close = close + + function closer.check() + if closer.errored or (closer.read and closer.written) then + return close() + end + end + + return closer +end + +local function makeRead(socket, closer) + local paused = true + + local queue = {} + local tindex = 0 + local dindex = 0 + + local function dispatch(data) + + -- p("<-", data[1]) + + if tindex > dindex then + local thread = queue[dindex] + queue[dindex] = nil + dindex = dindex + 1 + assert(coroutine.resume(thread, unpack(data))) + else + queue[dindex] = data + dindex = dindex + 1 + if not paused then + paused = true + assert(socket:read_stop()) + end + end + end + + closer.onClose = function () + if not closer.read then + closer.read = true + return dispatch {nil, closer.errored} + end + end + + local function onRead(err, chunk) + if err then + closer.errored = err + return closer.check() + end + if not chunk then + if closer.read then return end + closer.read = true + dispatch {} + return closer.check() + end + return dispatch {chunk} + end + + local function read() + if dindex > tindex then + local data = queue[tindex] + queue[tindex] = nil + tindex = tindex + 1 + return unpack(data) + end + if paused then + paused = false + assert(socket:read_start(onRead)) + end + queue[tindex] = coroutine.running() + tindex = tindex + 1 + return coroutine.yield() + end + + -- Auto use wrapper library for backwards compat + return read +end + +local function makeWrite(socket, closer) + + local function wait() + local thread = coroutine.running() + return function (err) + assert(coroutine.resume(thread, err)) + end + end + + local function write(chunk) + if closer.written then + return nil, "already shutdown" + end + + -- p("->", chunk) + + if chunk == nil then + closer.written = true + closer.check() + local success, err = socket:shutdown(wait()) + if not success then + return nil, err + end + err = coroutine.yield() + return not err, err + end + + local success, err = socket:write(chunk, wait()) + if not success then + closer.errored = err + closer.check() + return nil, err + end + err = coroutine.yield() + return not err, err + end + + return write +end + +local function wrapRead(socket) + local closer = makeCloser(socket) + closer.written = true + return makeRead(socket, closer), closer.close +end + +local function wrapWrite(socket) + local closer = makeCloser(socket) + closer.read = true + return makeWrite(socket, closer), closer.close +end + +local function wrapStream(socket) + assert(socket + and socket.write + and socket.shutdown + and socket.read_start + and socket.read_stop + and socket.is_closing + and socket.close, "socket does not appear to be a socket/uv_stream_t") + + local closer = makeCloser(socket) + return makeRead(socket, closer), makeWrite(socket, closer), closer.close +end + +return { + wrapRead = wrapRead, + wrapWrite = wrapWrite, + wrapStream = wrapStream, +} diff --git a/deps/coro-fs.lua b/deps/coro-fs.lua new file mode 100644 index 0000000..af0ef16 --- /dev/null +++ b/deps/coro-fs.lua @@ -0,0 +1,251 @@ +--[[lit-meta + name = "creationix/coro-fs" + version = "2.2.3" + homepage = "https://github.com/luvit/lit/blob/master/deps/coro-fs.lua" + description = "A coro style interface to the filesystem." + tags = {"coro", "fs"} + license = "MIT" + dependencies = { + "creationix/pathjoin@2.0.0" + } + author = { name = "Tim Caswell" } + contributors = {"Tim Caswell", "Alex Iverson"} +]] + +local uv = require('uv') +local fs = {} +local pathJoin = require('pathjoin').pathJoin + +local function noop() end + +local function makeCallback() + local thread = coroutine.running() + return function (err, value, ...) + if err then + assert(coroutine.resume(thread, nil, err)) + else + assert(coroutine.resume(thread, value == nil and true or value, ...)) + end + end +end + +function fs.mkdir(path, mode) + uv.fs_mkdir(path, mode or 511, makeCallback()) + return coroutine.yield() +end +function fs.open(path, flags, mode) + uv.fs_open(path, flags or "r", mode or 438, makeCallback()) + return coroutine.yield() +end +function fs.unlink(path) + uv.fs_unlink(path, makeCallback()) + return coroutine.yield() +end +function fs.stat(path) + uv.fs_stat(path, makeCallback()) + return coroutine.yield() +end +function fs.lstat(path) + uv.fs_lstat(path, makeCallback()) + return coroutine.yield() +end +function fs.symlink(target, path) + uv.fs_symlink(target, path, makeCallback()) + return coroutine.yield() +end +function fs.readlink(path) + uv.fs_readlink(path, makeCallback()) + return coroutine.yield() +end +function fs.fstat(fd) + uv.fs_fstat(fd, makeCallback()) + return coroutine.yield() +end +function fs.chmod(fd, path) + uv.fs_chmod(fd, path, makeCallback()) + return coroutine.yield() +end +function fs.fchmod(fd, mode) + uv.fs_fchmod(fd, mode, makeCallback()) + return coroutine.yield() +end +function fs.read(fd, length, offset) + uv.fs_read(fd, length or 1024*48, offset or -1, makeCallback()) + return coroutine.yield() +end +function fs.write(fd, data, offset) + uv.fs_write(fd, data, offset or -1, makeCallback()) + return coroutine.yield() +end +function fs.close(fd) + uv.fs_close(fd, makeCallback()) + return coroutine.yield() +end +function fs.access(path, flags) + uv.fs_access(path, flags or "", makeCallback()) + return coroutine.yield() +end +function fs.rename(path, newPath) + uv.fs_rename(path, newPath, makeCallback()) + return coroutine.yield() +end +function fs.rmdir(path) + uv.fs_rmdir(path, makeCallback()) + return coroutine.yield() +end +function fs.rmrf(path) + local success, err + success, err = fs.rmdir(path) + if success then return success end + if err:match("^ENOTDIR:") then return fs.unlink(path) end + if not err:match("^ENOTEMPTY:") then return success, err end + for entry in assert(fs.scandir(path)) do + local subPath = pathJoin(path, entry.name) + if entry.type == "directory" then + success, err = fs.rmrf(pathJoin(path, entry.name)) + else + success, err = fs.unlink(subPath) + end + if not success then return success, err end + end + return fs.rmdir(path) +end +function fs.scandir(path) + uv.fs_scandir(path, makeCallback()) + local req, err = coroutine.yield() + if not req then return nil, err end + return function () + local name, typ = uv.fs_scandir_next(req) + if not name then return name, typ end + if type(name) == "table" then return name end + return { + name = name, + type = typ + } + end +end + +function fs.readFile(path) + local fd, stat, data, err + fd, err = fs.open(path) + if err then return nil, err end + stat, err = fs.fstat(fd) + if stat then + --special case files on virtual filesystem + if stat.size == 0 and stat.birthtime.sec == 0 and stat.birthtime.nsec == 0 then + -- handle magic files the kernel generates as requested. + -- hopefully the heuristic works everywhere + local buffs = {} + local offs = 0 + local size = 1024 * 48 + repeat + data, err = fs.read(fd, size, offs) + table.insert(buffs, data) + offs = offs + (data and #data or 0) + until err or #data < size + if not err then + data = table.concat(buffs) + end + else + -- normal case for normal files. + data, err = fs.read(fd, stat.size) + end + end + uv.fs_close(fd, noop) + return data, err +end + +function fs.writeFile(path, data, mkdir) + local fd, success, err + fd, err = fs.open(path, "w") + if err then + if mkdir and string.match(err, "^ENOENT:") then + success, err = fs.mkdirp(pathJoin(path, "..")) + if success then return fs.writeFile(path, data) end + end + return nil, err + end + success, err = fs.write(fd, data) + uv.fs_close(fd, noop) + return success, err +end + +function fs.mkdirp(path, mode) + local success, err = fs.mkdir(path, mode) + if success or string.match(err, "^EEXIST") then + return true + end + if string.match(err, "^ENOENT:") then + success, err = fs.mkdirp(pathJoin(path, ".."), mode) + if not success then return nil, err end + return fs.mkdir(path, mode) + end + return nil, err +end + +function fs.chroot(base) + local chroot = { + base = base, + fstat = fs.fstat, + fchmod = fs.fchmod, + read = fs.read, + write = fs.write, + close = fs.close, + } + local function resolve(path) + assert(path, "path missing") + return pathJoin(base, pathJoin("./".. path)) + end + function chroot.mkdir(path, mode) + return fs.mkdir(resolve(path), mode) + end + function chroot.mkdirp(path, mode) + return fs.mkdirp(resolve(path), mode) + end + function chroot.open(path, flags, mode) + return fs.open(resolve(path), flags, mode) + end + function chroot.unlink(path) + return fs.unlink(resolve(path)) + end + function chroot.stat(path) + return fs.stat(resolve(path)) + end + function chroot.lstat(path) + return fs.lstat(resolve(path)) + end + function chroot.symlink(target, path) + -- TODO: should we resolve absolute target paths or treat it as opaque data? + return fs.symlink(target, resolve(path)) + end + function chroot.readlink(path) + return fs.readlink(resolve(path)) + end + function chroot.chmod(path, mode) + return fs.chmod(resolve(path), mode) + end + function chroot.access(path, flags) + return fs.access(resolve(path), flags) + end + function chroot.rename(path, newPath) + return fs.rename(resolve(path), resolve(newPath)) + end + function chroot.rmdir(path) + return fs.rmdir(resolve(path)) + end + function chroot.rmrf(path) + return fs.rmrf(resolve(path)) + end + function chroot.scandir(path, iter) + return fs.scandir(resolve(path), iter) + end + function chroot.readFile(path) + return fs.readFile(resolve(path)) + end + function chroot.writeFile(path, data, mkdir) + return fs.writeFile(resolve(path), data, mkdir) + end + return chroot +end + +return fs diff --git a/deps/coro-http.lua b/deps/coro-http.lua new file mode 100644 index 0000000..0e0ef3f --- /dev/null +++ b/deps/coro-http.lua @@ -0,0 +1,195 @@ +--[[lit-meta + name = "creationix/coro-http" + version = "3.1.0" + dependencies = { + "creationix/coro-net@3.0.0", + "luvit/http-codec@3.0.0" + } + homepage = "https://github.com/luvit/lit/blob/master/deps/coro-http.lua" + description = "An coro style http(s) client and server helper." + tags = {"coro", "http"} + license = "MIT" + author = { name = "Tim Caswell" } +]] + +local httpCodec = require('http-codec') +local net = require('coro-net') + +local function createServer(host, port, onConnect) + net.createServer({ + host = host, + port = port, + encode = httpCodec.encoder(), + decode = httpCodec.decoder(), + }, function (read, write, socket) + for head in read do + local parts = {} + for part in read do + if #part > 0 then + parts[#parts + 1] = part + else + break + end + end + local body = table.concat(parts) + head, body = onConnect(head, body, socket) + write(head) + if body then write(body) end + write("") + if not head.keepAlive then break end + end + end) +end + +local function parseUrl(url) + local protocol, host, hostname, port, path = url:match("^(https?:)//(([^/:]+):?([0-9]*))(/?.*)$") + if not protocol then error("Not a valid http url: " .. url) end + local tls = protocol == "https:" + port = port and tonumber(port) or (tls and 443 or 80) + if path == "" then path = "/" end + return { + tls = tls, + host = host, + hostname = hostname, + port = port, + path = path + } +end + +local connections = {} + +local function getConnection(host, port, tls, timeout) + for i = #connections, 1, -1 do + local connection = connections[i] + if connection.host == host and connection.port == port and connection.tls == tls then + table.remove(connections, i) + -- Make sure the connection is still alive before reusing it. + if not connection.socket:is_closing() then + connection.reused = true + connection.socket:ref() + return connection + end + end + end + local read, write, socket, updateDecoder, updateEncoder = assert(net.connect { + host = host, + port = port, + tls = tls, + timeout = timeout, + encode = httpCodec.encoder(), + decode = httpCodec.decoder() + }) + return { + socket = socket, + host = host, + port = port, + tls = tls, + read = read, + write = write, + updateEncoder = updateEncoder, + updateDecoder = updateDecoder, + reset = function () + -- This is called after parsing the response head from a HEAD request. + -- If you forget, the codec might hang waiting for a body that doesn't exist. + updateDecoder(httpCodec.decoder()) + end + } +end + +local function saveConnection(connection) + if connection.socket:is_closing() then return end + connections[#connections + 1] = connection + connection.socket:unref() +end + +local function request(method, url, headers, body, timeout) + local uri = parseUrl(url) + local connection = getConnection(uri.hostname, uri.port, uri.tls, timeout) + local read = connection.read + local write = connection.write + + local req = { + method = method, + path = uri.path, + {"Host", uri.host} + } + local contentLength + local chunked + if headers then + for i = 1, #headers do + local key, value = unpack(headers[i]) + key = key:lower() + if key == "content-length" then + contentLength = value + elseif key == "content-encoding" and value:lower() == "chunked" then + chunked = true + end + req[#req + 1] = headers[i] + end + end + + if type(body) == "string" then + if not chunked and not contentLength then + req[#req + 1] = {"Content-Length", #body} + end + end + + write(req) + if body then write(body) end + local res = read() + if not res then + if not connection.socket:is_closing() then + connection.socket:close() + end + -- If we get an immediate close on a reused socket, try again with a new socket. + -- TODO: think about if this could resend requests with side effects and cause + -- them to double execute in the remote server. + if connection.reused then + return request(method, url, headers, body) + end + error("Connection closed") + end + + body = {} + if req.method == "HEAD" then + connection.reset() + else + while true do + local item = read() + if not item then + res.keepAlive = false + break + end + if #item == 0 then + break + end + body[#body + 1] = item + end + end + + if res.keepAlive then + saveConnection(connection) + else + write() + end + + -- Follow redirects + if method == "GET" and (res.code == 302 or res.code == 307) then + for i = 1, #res do + local key, location = unpack(res[i]) + if key:lower() == "location" then + return request(method, location, headers) + end + end + end + + return res, table.concat(body) +end + +return { + createServer = createServer, + parseUrl = parseUrl, + getConnection = getConnection, + saveConnection = saveConnection, + request = request, +} diff --git a/deps/coro-net.lua b/deps/coro-net.lua new file mode 100644 index 0000000..1871452 --- /dev/null +++ b/deps/coro-net.lua @@ -0,0 +1,181 @@ +--[[lit-meta + name = "creationix/coro-net" + version = "3.2.0" + dependencies = { + "creationix/coro-channel@3.0.0", + "creationix/coro-wrapper@3.0.0", + } + optionalDependencies = { + "luvit/secure-socket@1.0.0" + } + homepage = "https://github.com/luvit/lit/blob/master/deps/coro-net.lua" + description = "An coro style client and server helper for tcp and pipes." + tags = {"coro", "tcp", "pipe", "net"} + license = "MIT" + author = { name = "Tim Caswell" } +]] + +local uv = require('uv') +local wrapStream = require('coro-channel').wrapStream +local wrapper = require('coro-wrapper') +local merger = wrapper.merger +local decoder = wrapper.decoder +local encoder = wrapper.encoder +local secureSocket -- Lazy required from "secure-socket" on first use. + +local function makeCallback(timeout) + local thread = coroutine.running() + local timer, done + if timeout then + timer = uv.new_timer() + timer:start(timeout, 0, function () + if done then return end + done = true + timer:close() + return assert(coroutine.resume(thread, nil, "timeout")) + end) + end + return function (err, data) + if done then return end + done = true + if timer then timer:close() end + if err then + return assert(coroutine.resume(thread, nil, err)) + end + return assert(coroutine.resume(thread, data or true)) + end +end + +local function normalize(options, server) + local t = type(options) + if t == "string" then + options = {path=options} + elseif t == "number" then + options = {port=options} + elseif t ~= "table" then + assert("Net options must be table, string, or number") + end + if options.port or options.host then + options.isTcp = true + options.host = options.host or "127.0.0.1" + assert(options.port, "options.port is required for tcp connections") + elseif options.path then + options.isTcp = false + else + error("Must set either options.path or options.port") + end + if options.tls == true then + options.tls = {} + end + if options.tls then + if server then + options.tls.server = true + assert(options.tls.cert, "TLS servers require a certificate") + assert(options.tls.key, "TLS servers require a key") + else + options.tls.server = false + options.tls.servername = options.host + end + end + return options +end + +local function connect(options) + local socket, success, err + options = normalize(options) + if options.isTcp then + success, err = uv.getaddrinfo(options.host, options.port, { + socktype = options.socktype or "stream", + family = options.family or "inet", + }, makeCallback(options.timeout)) + if not success then return nil, err end + local res + res, err = coroutine.yield() + if not res then return nil, err end + socket = uv.new_tcp() + socket:connect(res[1].addr, res[1].port, makeCallback(options.timeout)) + else + socket = uv.new_pipe(false) + socket:connect(options.path, makeCallback(options.timeout)) + end + success, err = coroutine.yield() + if not success then return nil, err end + local dsocket + if options.tls then + if not secureSocket then secureSocket = require('secure-socket') end + dsocket, err = secureSocket(socket, options.tls) + if not dsocket then + return nil, err + end + else + dsocket = socket + end + + local read, write, close = wrapStream(dsocket) + local updateDecoder, updateEncoder + if options.scan then + -- TODO: Should we expose updateScan somehow? + read = merger(read, options.scan) + end + if options.decode then + read, updateDecoder = decoder(read, options.decode) + end + if options.encode then + write, updateEncoder = encoder(write, options.encode) + end + return read, write, dsocket, updateDecoder, updateEncoder, close +end + +local function createServer(options, onConnect) + local server + options = normalize(options, true) + if options.isTcp then + server = uv.new_tcp() + assert(server:bind(options.host, options.port)) + else + server = uv.new_pipe(false) + assert(server:bind(options.path)) + end + assert(server:listen(256, function (err) + assert(not err, err) + local socket = options.isTcp and uv.new_tcp() or uv.new_pipe(false) + server:accept(socket) + coroutine.wrap(function () + local success, failure = xpcall(function () + local dsocket + if options.tls then + if not secureSocket then secureSocket = require('secure-socket') end + dsocket = assert(secureSocket(socket, options.tls)) + dsocket.socket = socket + else + dsocket = socket + end + + local read, write = wrapStream(dsocket) + local updateDecoder, updateEncoder + if options.scan then + -- TODO: should we expose updateScan somehow? + read = merger(read, options.scan) + end + if options.decode then + read, updateDecoder = decoder(read, options.decode) + end + if options.encode then + write, updateEncoder = encoder(write, options.encode) + end + + return onConnect(read, write, dsocket, updateDecoder, updateEncoder) + end, debug.traceback) + if not success then + print(failure) + end + end)() + end)) + return server +end + +return { + makeCallback = makeCallback, + connect = connect, + createServer = createServer, +} diff --git a/deps/coro-websocket.lua b/deps/coro-websocket.lua new file mode 100644 index 0000000..50eee9e --- /dev/null +++ b/deps/coro-websocket.lua @@ -0,0 +1,196 @@ +--[[lit-meta + name = "creationix/coro-websocket" + version = "3.1.0" + dependencies = { + "luvit/http-codec@3.0.0", + "creationix/websocket-codec@3.0.0", + "creationix/coro-net@3.0.0", + } + homepage = "https://github.com/luvit/lit/blob/master/deps/coro-websocket.lua" + description = "Websocket helpers assuming coro style I/O." + tags = {"coro", "websocket"} + license = "MIT" + author = { name = "Tim Caswell" } +]] + +local uv = require('uv') +local httpCodec = require('http-codec') +local websocketCodec = require('websocket-codec') +local net = require('coro-net') + +local function parseUrl(url) + local protocol, host, port, pathname = string.match(url, "^(wss?)://([^:/]+):?(%d*)(/?[^#?]*)") + local tls + if protocol == "ws" then + port = tonumber(port) or 80 + tls = false + elseif protocol == "wss" then + port = tonumber(port) or 443 + tls = true + else + return nil, "Sorry, only ws:// or wss:// protocols supported" + end + return { + host = host, + port = port, + tls = tls, + pathname = pathname + } +end + +local function wrapIo(rawRead, rawWrite, options) + + local closeSent = false + + local timer + + local function cleanup() + if timer then + if not timer:is_closing() then + timer:close() + end + timer = nil + end + end + + local function write(message) + if message then + message.mask = options.mask + if message.opcode == 8 then + closeSent = true + rawWrite(message) + cleanup() + return rawWrite() + end + else + if not closeSent then + return write({ + opcode = 8, + payload = "" + }) + end + end + return rawWrite(message) + end + + + local function read() + while true do + local message = rawRead() + if not message then + return cleanup() + end + if message.opcode < 8 then + return message + end + if not closeSent then + if message.opcode == 8 then + write { + opcode = 8, + payload = message.payload + } + elseif message.opcode == 9 then + write { + opcode = 10, + payload = message.payload + } + end + return message + end + end + end + + if options.heartbeat then + local interval = options.heartbeat + timer = uv.new_timer() + timer:unref() + timer:start(interval, interval, function () + coroutine.wrap(function () + local success, err = write { + opcode = 10, + payload = "" + } + if not success then + timer:close() + print(err) + end + end)() + end) + end + + return read, write +end + +-- options table to configure connection +-- options.path +-- options.host +-- options.port +-- options.tls +-- options.pathname +-- options.subprotocol +-- options.headers (as list of header/value pairs) +-- options.timeout +-- options.heartbeat +-- returns res, read, write (res.socket has socket) +local function connect(options) + options = options or {} + local config = { + path = options.path, + host = options.host, + port = options.port, + tls = options.tls, + encode = httpCodec.encoder(), + decode = httpCodec.decoder(), + } + local read, write, socket, updateDecoder, updateEncoder + = net.connect(config, options.timeout or 10000) + if not read then + return nil, write + end + + local res + + local success, err = websocketCodec.handshake({ + host = options.host, + path = options.pathname, + protocol = options.subprotocol + }, function (req) + local headers = options.headers + if headers then + for i = 1, #headers do + req[#req + 1] = headers[i] + end + end + write(req) + res = read() + if not res then error("Missing server response") end + if res.code == 400 then + -- p { req = req, res = res } + local reason = read() or res.reason + error("Invalid request: " .. reason) + end + return res + end) + if not success then + return nil, err + end + + -- Upgrade the protocol to websocket + updateDecoder(websocketCodec.decode) + updateEncoder(websocketCodec.encode) + + read, write = wrapIo(read, write, { + mask = true, + heartbeat = options.heartbeat + }) + + res.socket = socket + return res, read, write + +end + +return { + parseUrl = parseUrl, + wrapIo = wrapIo, + connect = connect, +} diff --git a/deps/coro-wrapper.lua b/deps/coro-wrapper.lua new file mode 100644 index 0000000..c43671e --- /dev/null +++ b/deps/coro-wrapper.lua @@ -0,0 +1,151 @@ +--[[lit-meta + name = "creationix/coro-wrapper" + version = "3.1.0" + homepage = "https://github.com/luvit/lit/blob/master/deps/coro-wrapper.lua" + description = "An adapter for applying decoders to coro-streams." + tags = {"coro", "decoder", "adapter"} + license = "MIT" + author = { name = "Tim Caswell" } +]] + +local concat = table.concat +local sub = string.sub + +-- Merger allows for effecient merging of many chunks. +-- The scan function returns truthy when the chunk contains a useful delimeter +-- Or in other words, when there is enough data to flush to the decoder. +-- merger(read, scan) -> read, updateScan +-- read() -> chunk or nil +-- scan(chunk) -> should_flush +-- updateScan(scan) +local function merger(read, scan) + local parts = {} + + -- Return a new read function that combines chunks smartly + return function () + + while true do + -- Read the next event from upstream. + local chunk = read() + + -- We got an EOS (end of stream) + if not chunk then + -- If there is nothing left to flush, emit EOS here. + if #parts == 0 then return end + + -- Flush the buffer + chunk = concat(parts) + parts = {} + return chunk + end + + -- Accumulate the chunk + parts[#parts + 1] = chunk + + -- Flush the buffer if scan tells us to. + if scan(chunk) then + chunk = concat(parts) + parts = {} + return chunk + end + + end + end, + + -- This is used to update or disable the scan function. It's useful for + -- protocols that change mid-stream (like HTTP upgrades in websockets) + function (newScan) + scan = newScan + end +end + +-- Decoder takes in a read function and a decode function and returns a new +-- read function that emits decoded events. When decode returns `nil` it means +-- that it needs more data before it can parse. The index output in decode is +-- the index to start the next decode. If output index if nil it means nothing +-- is leftover and next decode starts fresh. +-- decoder(read, decode) -> read, updateDecode +-- read() -> chunk or nil +-- decode(chunk, index) -> nil or (data, index) +-- updateDecode(Decode) +local function decoder(read, decode) + local buffer, index + local want = true + return function () + + while true do + -- If there isn't enough data to decode then get more data. + if want then + local chunk = read() + if buffer then + -- If we had leftover data in the old buffer, trim it down. + if index > 1 then + buffer = sub(buffer, index) + index = 1 + end + if chunk then + -- Concatenate the chunk with the old data + buffer = buffer .. chunk + end + else + -- If there was no leftover data, set new data in the buffer + if chunk then + buffer = chunk + index = 1 + else + buffer = nil + index = nil + end + end + end + + -- Return nil if the buffer is empty + if buffer == '' or buffer == nil then + return nil + end + + -- If we have data, lets try to decode it + local item, newIndex = decode(buffer, index) + + want = not newIndex + if item or newIndex then + -- There was enough data to emit an event! + if newIndex then + assert(type(newIndex) == "number", "index must be a number if set") + -- There was leftover data + index = newIndex + else + want = true + -- There was no leftover data + buffer = nil + index = nil + end + -- Emit the event + return item + end + + + end + end, + function (newDecode) + decode = newDecode + end +end + +local function encoder(write, encode) + return function (item) + if not item then + return write() + end + return write(encode(item)) + end, + function (newEncode) + encode = newEncode + end +end + +return { + merger = merger, + decoder = decoder, + encoder = encoder, +} diff --git a/deps/http-codec.lua b/deps/http-codec.lua new file mode 100644 index 0000000..30ce78b --- /dev/null +++ b/deps/http-codec.lua @@ -0,0 +1,301 @@ +--[[ + +Copyright 2014-2015 The Luvit Authors. All Rights Reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS-IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +--]] + +--[[lit-meta + name = "luvit/http-codec" + version = "3.0.5" + homepage = "https://github.com/luvit/luvit/blob/master/deps/http-codec.lua" + description = "A simple pair of functions for converting between hex and raw strings." + tags = {"codec", "http"} + license = "Apache 2" + author = { name = "Tim Caswell" } +]] + +local sub = string.sub +local gsub = string.gsub +local lower = string.lower +local find = string.find +local format = string.format +local concat = table.concat +local match = string.match + +local STATUS_CODES = { + [100] = 'Continue', + [101] = 'Switching Protocols', + [102] = 'Processing', -- RFC 2518, obsoleted by RFC 4918 + [200] = 'OK', + [201] = 'Created', + [202] = 'Accepted', + [203] = 'Non-Authoritative Information', + [204] = 'No Content', + [205] = 'Reset Content', + [206] = 'Partial Content', + [207] = 'Multi-Status', -- RFC 4918 + [300] = 'Multiple Choices', + [301] = 'Moved Permanently', + [302] = 'Moved Temporarily', + [303] = 'See Other', + [304] = 'Not Modified', + [305] = 'Use Proxy', + [307] = 'Temporary Redirect', + [400] = 'Bad Request', + [401] = 'Unauthorized', + [402] = 'Payment Required', + [403] = 'Forbidden', + [404] = 'Not Found', + [405] = 'Method Not Allowed', + [406] = 'Not Acceptable', + [407] = 'Proxy Authentication Required', + [408] = 'Request Time-out', + [409] = 'Conflict', + [410] = 'Gone', + [411] = 'Length Required', + [412] = 'Precondition Failed', + [413] = 'Request Entity Too Large', + [414] = 'Request-URI Too Large', + [415] = 'Unsupported Media Type', + [416] = 'Requested Range Not Satisfiable', + [417] = 'Expectation Failed', + [418] = "I'm a teapot", -- RFC 2324 + [422] = 'Unprocessable Entity', -- RFC 4918 + [423] = 'Locked', -- RFC 4918 + [424] = 'Failed Dependency', -- RFC 4918 + [425] = 'Unordered Collection', -- RFC 4918 + [426] = 'Upgrade Required', -- RFC 2817 + [428] = 'Precondition Required', -- RFC 6585 + [429] = 'Too Many Requests', -- RFC 6585 + [431] = 'Request Header Fields Too Large', -- RFC 6585 + [500] = 'Internal Server Error', + [501] = 'Not Implemented', + [502] = 'Bad Gateway', + [503] = 'Service Unavailable', + [504] = 'Gateway Time-out', + [505] = 'HTTP Version not supported', + [506] = 'Variant Also Negotiates', -- RFC 2295 + [507] = 'Insufficient Storage', -- RFC 4918 + [509] = 'Bandwidth Limit Exceeded', + [510] = 'Not Extended', -- RFC 2774 + [511] = 'Network Authentication Required' -- RFC 6585 +} + +local function encoder() + + local mode + local encodeHead, encodeRaw, encodeChunked + + function encodeHead(item) + if not item or item == "" then + return item + elseif not (type(item) == "table") then + error("expected a table but got a " .. type(item) .. " when encoding data") + end + local head, chunkedEncoding + local version = item.version or 1.1 + if item.method then + local path = item.path + assert(path and #path > 0, "expected non-empty path") + head = { item.method .. ' ' .. item.path .. ' HTTP/' .. version .. '\r\n' } + else + local reason = item.reason or STATUS_CODES[item.code] + head = { 'HTTP/' .. version .. ' ' .. item.code .. ' ' .. reason .. '\r\n' } + end + for i = 1, #item do + local key, value = unpack(item[i]) + local lowerKey = lower(key) + if lowerKey == "transfer-encoding" then + chunkedEncoding = lower(value) == "chunked" + end + value = gsub(tostring(value), "[\r\n]+", " ") + head[#head + 1] = key .. ': ' .. tostring(value) .. '\r\n' + end + head[#head + 1] = '\r\n' + + mode = chunkedEncoding and encodeChunked or encodeRaw + return concat(head) + end + + function encodeRaw(item) + if type(item) ~= "string" then + mode = encodeHead + return encodeHead(item) + end + return item + end + + function encodeChunked(item) + if type(item) ~= "string" then + mode = encodeHead + local extra = encodeHead(item) + if extra then + return "0\r\n\r\n" .. extra + else + return "0\r\n\r\n" + end + end + if #item == 0 then + mode = encodeHead + end + return format("%x", #item) .. "\r\n" .. item .. "\r\n" + end + + mode = encodeHead + return function (item) + return mode(item) + end +end + +local function decoder() + + -- This decoder is somewhat stateful with 5 different parsing states. + local decodeHead, decodeEmpty, decodeRaw, decodeChunked, decodeCounted + local mode -- state variable that points to various decoders + local bytesLeft -- For counted decoder + + -- This state is for decoding the status line and headers. + function decodeHead(chunk, index) + if not chunk or index > #chunk then return end + + local _, last = find(chunk, "\r?\n\r?\n", index) + -- First make sure we have all the head before continuing + if not last then + if (#chunk - index) <= 8 * 1024 then return end + -- But protect against evil clients by refusing heads over 8K long. + error("entity too large") + end + + -- Parse the status/request line + local head = {} + local _, offset + local version + _, offset, version, head.code, head.reason = + find(chunk, "^HTTP/(%d%.%d) (%d+) ([^\r\n]*)\r?\n", index) + if offset then + head.code = tonumber(head.code) + else + _, offset, head.method, head.path, version = + find(chunk, "^(%u+) ([^ ]+) HTTP/(%d%.%d)\r?\n", index) + if not offset then + error("expected HTTP data") + end + end + version = tonumber(version) + head.version = version + head.keepAlive = version > 1.0 + + -- We need to inspect some headers to know how to parse the body. + local contentLength + local chunkedEncoding + + -- Parse the header lines + while true do + local key, value + _, offset, key, value = find(chunk, "^([^:\r\n]+): *([^\r\n]*)\r?\n", offset + 1) + if not offset then break end + local lowerKey = lower(key) + + -- Inspect a few headers and remember the values + if lowerKey == "content-length" then + contentLength = tonumber(value) + elseif lowerKey == "transfer-encoding" then + chunkedEncoding = lower(value) == "chunked" + elseif lowerKey == "connection" then + head.keepAlive = lower(value) == "keep-alive" + end + head[#head + 1] = {key, value} + end + + if head.keepAlive and (not (chunkedEncoding or (contentLength and contentLength > 0))) + or (head.method == "GET" or head.method == "HEAD") then + mode = decodeEmpty + elseif chunkedEncoding then + mode = decodeChunked + elseif contentLength then + bytesLeft = contentLength + mode = decodeCounted + elseif not head.keepAlive then + mode = decodeRaw + end + return head, last + 1 + + end + + -- This is used for inserting a single empty string into the output string for known empty bodies + function decodeEmpty(chunk, index) + mode = decodeHead + return "", index + end + + function decodeRaw(chunk, index) + if #chunk < index then return end + return sub(chunk, index) + end + + function decodeChunked(chunk, index) + local len, term + len, term = match(chunk, "^(%x+)(..)", index) + if not len then return end + if term ~= "\r\n" then + -- Wait for full chunk-size\r\n header + if #chunk < 18 then return end + -- But protect against evil clients by refusing chunk-sizes longer than 16 hex digits. + error("chunk-size field too large") + end + index = index + #len + 2 + local offset = index - 1 + local length = tonumber(len, 16) + if #chunk < offset + length + 2 then return end + if length == 0 then + mode = decodeHead + end + assert(sub(chunk, index + length, index + length + 1) == "\r\n") + local piece = sub(chunk, index, index + length - 1) + return piece, index + length + 2 + end + + function decodeCounted(chunk, index) + if bytesLeft == 0 then + mode = decodeEmpty + return mode(chunk, index) + end + local offset = index - 1 + local length = #chunk - offset + -- Make sure we have at least one byte to process + if length == 0 then return end + + -- If there isn't enough data left, emit what we got so far + if length < bytesLeft then + bytesLeft = bytesLeft - length + return sub(chunk, index) + end + + mode = decodeEmpty + return sub(chunk, index, offset + bytesLeft), index + bytesLeft + end + + -- Switch between states by changing which decoder mode points to + mode = decodeHead + return function (chunk, index) + return mode(chunk, index) + end + +end + +return { + encoder = encoder, + decoder = decoder, +} diff --git a/deps/json.lua b/deps/json.lua new file mode 100644 index 0000000..faff60b --- /dev/null +++ b/deps/json.lua @@ -0,0 +1,733 @@ +--[[lit-meta + name = "luvit/json" + version = "2.5.2" + homepage = "http://dkolf.de/src/dkjson-lua.fsl" + description = "David Kolf's JSON library repackaged for lit." + tags = {"json", "codec"} + license = "MIT" + author = { + name = "David Kolf", + homepage = "http://dkolf.de/", + } + contributors = { + "Tim Caswell", + } +]] + +-- Module options: +local always_try_using_lpeg = true +local register_global_module_table = false +local global_module_name = 'json' + +--[==[ + +David Kolf's JSON module for Lua 5.1/5.2 + +Version 2.5 + + +For the documentation see the corresponding readme.txt or visit +. + +You can contact the author by sending an e-mail to 'david' at the +domain 'dkolf.de'. + + +Copyright (C) 2010-2013 David Heiko Kolf + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS +BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN +ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +--]==] + +-- global dependencies: +local pairs, type, tostring, tonumber, getmetatable, setmetatable = + pairs, type, tostring, tonumber, getmetatable, setmetatable +local error, require, pcall, select = error, require, pcall, select +local floor, huge = math.floor, math.huge +local strrep, gsub, strsub, strbyte, strchar, strfind, strlen, strformat = + string.rep, string.gsub, string.sub, string.byte, string.char, + string.find, string.len, string.format +local strmatch = string.match +local concat = table.concat + +local json = {} +json.original_version = "dkjson 2.5" + +if register_global_module_table then + _G[global_module_name] = json +end + +_ENV = nil -- blocking globals in Lua 5.2 + +pcall (function() + -- Enable access to blocked metatables. + -- Don't worry, this module doesn't change anything in them. + local debmeta = require "debug".getmetatable + if debmeta then getmetatable = debmeta end +end) + +json.null = setmetatable ({}, { + __tojson = function () return "null" end +}) + +local function isarray (tbl) + local max, n, arraylen = 0, 0, 0 + for k,v in pairs (tbl) do + if k == 'n' and type(v) == 'number' then + arraylen = v + if v > max then + max = v + end + else + if type(k) ~= 'number' or k < 1 or floor(k) ~= k then + return false + end + if k > max then + max = k + end + n = n + 1 + end + end + if max > 10 and max > arraylen and max > n * 2 then + return false -- don't create an array with too many holes + end + return true, max +end + +local escapecodes = { + ["\""] = "\\\"", ["\\"] = "\\\\", ["\b"] = "\\b", ["\f"] = "\\f", + ["\n"] = "\\n", ["\r"] = "\\r", ["\t"] = "\\t" +} + +local function escapeutf8 (uchar) + local value = escapecodes[uchar] + if value then + return value + end + local a, b, c, d = strbyte (uchar, 1, 4) + a, b, c, d = a or 0, b or 0, c or 0, d or 0 + if a <= 0x7f then + value = a + elseif 0xc0 <= a and a <= 0xdf and b >= 0x80 then + value = (a - 0xc0) * 0x40 + b - 0x80 + elseif 0xe0 <= a and a <= 0xef and b >= 0x80 and c >= 0x80 then + value = ((a - 0xe0) * 0x40 + b - 0x80) * 0x40 + c - 0x80 + elseif 0xf0 <= a and a <= 0xf7 and b >= 0x80 and c >= 0x80 and d >= 0x80 then + value = (((a - 0xf0) * 0x40 + b - 0x80) * 0x40 + c - 0x80) * 0x40 + d - 0x80 + else + return "" + end + if value <= 0xffff then + return strformat ("\\u%.4x", value) + elseif value <= 0x10ffff then + -- encode as UTF-16 surrogate pair + value = value - 0x10000 + local highsur, lowsur = 0xD800 + floor (value/0x400), 0xDC00 + (value % 0x400) + return strformat ("\\u%.4x\\u%.4x", highsur, lowsur) + else + return "" + end +end + +local function fsub (str, pattern, repl) + -- gsub always builds a new string in a buffer, even when no match + -- exists. First using find should be more efficient when most strings + -- don't contain the pattern. + if strfind (str, pattern) then + return gsub (str, pattern, repl) + else + return str + end +end + +local function quotestring (value) + -- based on the regexp "escapable" in https://github.com/douglascrockford/JSON-js + value = fsub (value, "[%z\1-\31\"\\\127]", escapeutf8) + if strfind (value, "[\194\216\220\225\226\239]") then + value = fsub (value, "\194[\128-\159\173]", escapeutf8) + value = fsub (value, "\216[\128-\132]", escapeutf8) + value = fsub (value, "\220\143", escapeutf8) + value = fsub (value, "\225\158[\180\181]", escapeutf8) + value = fsub (value, "\226\128[\140-\143\168-\175]", escapeutf8) + value = fsub (value, "\226\129[\160-\175]", escapeutf8) + value = fsub (value, "\239\187\191", escapeutf8) + value = fsub (value, "\239\191[\176-\191]", escapeutf8) + end + return "\"" .. value .. "\"" +end +json.quotestring = quotestring + +local function replace(str, o, n) + local i, j = strfind (str, o, 1, true) + if i then + return strsub(str, 1, i-1) .. n .. strsub(str, j+1, -1) + else + return str + end +end + +-- locale independent num2str and str2num functions +local decpoint, numfilter + +local function updatedecpoint () + decpoint = strmatch(tostring(0.5), "([^05+])") + -- build a filter that can be used to remove group separators + numfilter = "[^0-9%-%+eE" .. gsub(decpoint, "[%^%$%(%)%%%.%[%]%*%+%-%?]", "%%%0") .. "]+" +end + +updatedecpoint() + +local function num2str (num) + return replace(fsub(tostring(num), numfilter, ""), decpoint, ".") +end + +local function str2num (str) + local num = tonumber(replace(str, ".", decpoint)) + if not num then + updatedecpoint() + num = tonumber(replace(str, ".", decpoint)) + end + return num +end + +local function addnewline2 (level, buffer, buflen) + buffer[buflen+1] = "\n" + buffer[buflen+2] = strrep (" ", level) + buflen = buflen + 2 + return buflen +end + +function json.addnewline (state) + if state.indent then + state.bufferlen = addnewline2 (state.level or 0, + state.buffer, state.bufferlen or #(state.buffer)) + end +end + +local encode2 -- forward declaration + +local function addpair (key, value, prev, indent, level, buffer, buflen, tables, globalorder, state) + local kt = type (key) + if kt ~= 'string' and kt ~= 'number' then + return nil, "type '" .. kt .. "' is not supported as a key by JSON." + end + if prev then + buflen = buflen + 1 + buffer[buflen] = "," + end + if indent then + buflen = addnewline2 (level, buffer, buflen) + end + buffer[buflen+1] = quotestring (key) + buffer[buflen+2] = ":" + return encode2 (value, indent, level, buffer, buflen + 2, tables, globalorder, state) +end + +local function appendcustom(res, buffer, state) + local buflen = state.bufferlen + if type (res) == 'string' then + buflen = buflen + 1 + buffer[buflen] = res + end + return buflen +end + +local function exception(reason, value, state, buffer, buflen, defaultmessage) + defaultmessage = defaultmessage or reason + local handler = state.exception + if not handler then + return nil, defaultmessage + else + state.bufferlen = buflen + local ret, msg = handler (reason, value, state, defaultmessage) + if not ret then return nil, msg or defaultmessage end + return appendcustom(ret, buffer, state) + end +end + +function json.encodeexception(reason, value, state, defaultmessage) + return quotestring("<" .. defaultmessage .. ">") +end + +encode2 = function (value, indent, level, buffer, buflen, tables, globalorder, state) + local valtype = type (value) + local valmeta = getmetatable (value) + valmeta = type (valmeta) == 'table' and valmeta -- only tables + local valtojson = valmeta and valmeta.__tojson + if valtojson then + if tables[value] then + return exception('reference cycle', value, state, buffer, buflen) + end + tables[value] = true + state.bufferlen = buflen + local ret, msg = valtojson (value, state) + if not ret then return exception('custom encoder failed', value, state, buffer, buflen, msg) end + tables[value] = nil + buflen = appendcustom(ret, buffer, state) + elseif value == nil then + buflen = buflen + 1 + buffer[buflen] = "null" + elseif valtype == 'number' then + local s + if value ~= value or value >= huge or -value >= huge then + -- This is the behaviour of the original JSON implementation. + s = "null" + else + s = num2str (value) + end + buflen = buflen + 1 + buffer[buflen] = s + elseif valtype == 'boolean' then + buflen = buflen + 1 + buffer[buflen] = value and "true" or "false" + elseif valtype == 'string' then + buflen = buflen + 1 + buffer[buflen] = quotestring (value) + elseif valtype == 'table' then + if tables[value] then + return exception('reference cycle', value, state, buffer, buflen) + end + tables[value] = true + level = level + 1 + local isa, n = isarray (value) + if n == 0 and valmeta and valmeta.__jsontype == 'object' then + isa = false + end + local msg + if isa then -- JSON array + buflen = buflen + 1 + buffer[buflen] = "[" + for i = 1, n do + buflen, msg = encode2 (value[i], indent, level, buffer, buflen, tables, globalorder, state) + if not buflen then return nil, msg end + if i < n then + buflen = buflen + 1 + buffer[buflen] = "," + end + end + buflen = buflen + 1 + buffer[buflen] = "]" + else -- JSON object + local prev = false + buflen = buflen + 1 + buffer[buflen] = "{" + local order = valmeta and valmeta.__jsonorder or globalorder + if order then + local used = {} + n = #order + for i = 1, n do + local k = order[i] + local v = value[k] + local _ + if v then + used[k] = true + buflen, _ = addpair (k, v, prev, indent, level, buffer, buflen, tables, globalorder, state) + prev = true -- add a seperator before the next element + end + end + for k,v in pairs (value) do + if not used[k] then + buflen, msg = addpair (k, v, prev, indent, level, buffer, buflen, tables, globalorder, state) + if not buflen then return nil, msg end + prev = true -- add a seperator before the next element + end + end + else -- unordered + for k,v in pairs (value) do + buflen, msg = addpair (k, v, prev, indent, level, buffer, buflen, tables, globalorder, state) + if not buflen then return nil, msg end + prev = true -- add a seperator before the next element + end + end + if indent then + buflen = addnewline2 (level - 1, buffer, buflen) + end + buflen = buflen + 1 + buffer[buflen] = "}" + end + tables[value] = nil + else + return exception ('unsupported type', value, state, buffer, buflen, + "type '" .. valtype .. "' is not supported by JSON.") + end + return buflen +end + +function json.encode (value, state) + state = state or {} + local oldbuffer = state.buffer + local buffer = oldbuffer or {} + state.buffer = buffer + updatedecpoint() + local ret, msg = encode2 (value, state.indent, state.level or 0, + buffer, state.bufferlen or 0, state.tables or {}, state.keyorder, state) + if not ret then + error (msg, 2) + elseif oldbuffer == buffer then + state.bufferlen = ret + return true + else + state.bufferlen = nil + state.buffer = nil + return concat (buffer) + end +end + +local function loc (str, where) + local line, pos, linepos = 1, 1, 0 + while true do + pos = strfind (str, "\n", pos, true) + if pos and pos < where then + line = line + 1 + linepos = pos + pos = pos + 1 + else + break + end + end + return "line " .. line .. ", column " .. (where - linepos) +end + +local function unterminated (str, what, where) + return nil, strlen (str) + 1, "unterminated " .. what .. " at " .. loc (str, where) +end + +local function scanwhite (str, pos) + while true do + pos = strfind (str, "%S", pos) + if not pos then return nil end + local sub2 = strsub (str, pos, pos + 1) + if sub2 == "\239\187" and strsub (str, pos + 2, pos + 2) == "\191" then + -- UTF-8 Byte Order Mark + pos = pos + 3 + elseif sub2 == "//" then + pos = strfind (str, "[\n\r]", pos + 2) + if not pos then return nil end + elseif sub2 == "/*" then + pos = strfind (str, "*/", pos + 2) + if not pos then return nil end + pos = pos + 2 + else + return pos + end + end +end + +local escapechars = { + ["\""] = "\"", ["\\"] = "\\", ["/"] = "/", ["b"] = "\b", ["f"] = "\f", + ["n"] = "\n", ["r"] = "\r", ["t"] = "\t" +} + +local function unichar (value) + if value < 0 then + return nil + elseif value <= 0x007f then + return strchar (value) + elseif value <= 0x07ff then + return strchar (0xc0 + floor(value/0x40), + 0x80 + (floor(value) % 0x40)) + elseif value <= 0xffff then + return strchar (0xe0 + floor(value/0x1000), + 0x80 + (floor(value/0x40) % 0x40), + 0x80 + (floor(value) % 0x40)) + elseif value <= 0x10ffff then + return strchar (0xf0 + floor(value/0x40000), + 0x80 + (floor(value/0x1000) % 0x40), + 0x80 + (floor(value/0x40) % 0x40), + 0x80 + (floor(value) % 0x40)) + else + return nil + end +end + +local function scanstring (str, pos) + local lastpos = pos + 1 + local buffer, n = {}, 0 + while true do + local nextpos = strfind (str, "[\"\\]", lastpos) + if not nextpos then + return unterminated (str, "string", pos) + end + if nextpos > lastpos then + n = n + 1 + buffer[n] = strsub (str, lastpos, nextpos - 1) + end + if strsub (str, nextpos, nextpos) == "\"" then + lastpos = nextpos + 1 + break + else + local escchar = strsub (str, nextpos + 1, nextpos + 1) + local value + if escchar == "u" then + value = tonumber (strsub (str, nextpos + 2, nextpos + 5), 16) + if value then + local value2 + if 0xD800 <= value and value <= 0xDBff then + -- we have the high surrogate of UTF-16. Check if there is a + -- low surrogate escaped nearby to combine them. + if strsub (str, nextpos + 6, nextpos + 7) == "\\u" then + value2 = tonumber (strsub (str, nextpos + 8, nextpos + 11), 16) + if value2 and 0xDC00 <= value2 and value2 <= 0xDFFF then + value = (value - 0xD800) * 0x400 + (value2 - 0xDC00) + 0x10000 + else + value2 = nil -- in case it was out of range for a low surrogate + end + end + end + value = value and unichar (value) + if value then + if value2 then + lastpos = nextpos + 12 + else + lastpos = nextpos + 6 + end + end + end + end + if not value then + value = escapechars[escchar] or escchar + lastpos = nextpos + 2 + end + n = n + 1 + buffer[n] = value + end + end + if n == 1 then + return buffer[1], lastpos + elseif n > 1 then + return concat (buffer), lastpos + else + return "", lastpos + end +end + +local scanvalue -- forward declaration + +local function scantable (what, closechar, str, startpos, nullval, objectmeta, arraymeta) + local tbl, n = {}, 0 + local pos = startpos + 1 + if what == 'object' then + setmetatable (tbl, objectmeta) + else + setmetatable (tbl, arraymeta) + end + while true do + pos = scanwhite (str, pos) + if not pos then return unterminated (str, what, startpos) end + local char = strsub (str, pos, pos) + if char == closechar then + return tbl, pos + 1 + end + local val1, err + val1, pos, err = scanvalue (str, pos, nullval, objectmeta, arraymeta) + if err then return nil, pos, err end + pos = scanwhite (str, pos) + if not pos then return unterminated (str, what, startpos) end + char = strsub (str, pos, pos) + if char == ":" then + if val1 == nil then + return nil, pos, "cannot use nil as table index (at " .. loc (str, pos) .. ")" + end + pos = scanwhite (str, pos + 1) + if not pos then return unterminated (str, what, startpos) end + local val2 + val2, pos, err = scanvalue (str, pos, nullval, objectmeta, arraymeta) + if err then return nil, pos, err end + tbl[val1] = val2 + pos = scanwhite (str, pos) + if not pos then return unterminated (str, what, startpos) end + char = strsub (str, pos, pos) + else + n = n + 1 + tbl[n] = val1 + end + if char == "," then + pos = pos + 1 + end + end +end + +scanvalue = function (str, pos, nullval, objectmeta, arraymeta) + pos = pos or 1 + pos = scanwhite (str, pos) + if not pos then + return nil, strlen (str) + 1, "no valid JSON value (reached the end)" + end + local char = strsub (str, pos, pos) + if char == "{" then + return scantable ('object', "}", str, pos, nullval, objectmeta, arraymeta) + elseif char == "[" then + return scantable ('array', "]", str, pos, nullval, objectmeta, arraymeta) + elseif char == "\"" then + return scanstring (str, pos) + else + local pstart, pend = strfind (str, "^%-?[%d%.]+[eE]?[%+%-]?%d*", pos) + if pstart then + local number = str2num (strsub (str, pstart, pend)) + if number then + return number, pend + 1 + end + end + pstart, pend = strfind (str, "^%a%w*", pos) + if pstart then + local name = strsub (str, pstart, pend) + if name == "true" then + return true, pend + 1 + elseif name == "false" then + return false, pend + 1 + elseif name == "null" then + return nullval, pend + 1 + end + end + return nil, pos, "no valid JSON value at " .. loc (str, pos) + end +end + +local function optionalmetatables(...) + if select("#", ...) > 0 then + return ... + else + return {__jsontype = 'object'}, {__jsontype = 'array'} + end +end + +function json.decode (str, pos, nullval, ...) + local objectmeta, arraymeta = optionalmetatables(...) + return scanvalue (str, pos, nullval, objectmeta, arraymeta) +end + +function json.use_lpeg () + local g = require ("lpeg") + + if g.version() == "0.11" then + error "due to a bug in LPeg 0.11, it cannot be used for JSON matching" + end + + local pegmatch = g.match + local P, S, R = g.P, g.S, g.R + + local function ErrorCall (str, pos, msg, state) + if not state.msg then + state.msg = msg .. " at " .. loc (str, pos) + state.pos = pos + end + return false + end + + local function Err (msg) + return g.Cmt (g.Cc (msg) * g.Carg (2), ErrorCall) + end + + local SingleLineComment = P"//" * (1 - S"\n\r")^0 + local MultiLineComment = P"/*" * (1 - P"*/")^0 * P"*/" + local Space = (S" \n\r\t" + P"\239\187\191" + SingleLineComment + MultiLineComment)^0 + + local PlainChar = 1 - S"\"\\\n\r" + local EscapeSequence = (P"\\" * g.C (S"\"\\/bfnrt" + Err "unsupported escape sequence")) / escapechars + local HexDigit = R("09", "af", "AF") + local function UTF16Surrogate (match, pos, high, low) + high, low = tonumber (high, 16), tonumber (low, 16) + if 0xD800 <= high and high <= 0xDBff and 0xDC00 <= low and low <= 0xDFFF then + return true, unichar ((high - 0xD800) * 0x400 + (low - 0xDC00) + 0x10000) + else + return false + end + end + local function UTF16BMP (hex) + return unichar (tonumber (hex, 16)) + end + local U16Sequence = (P"\\u" * g.C (HexDigit * HexDigit * HexDigit * HexDigit)) + local UnicodeEscape = g.Cmt (U16Sequence * U16Sequence, UTF16Surrogate) + U16Sequence/UTF16BMP + local Char = UnicodeEscape + EscapeSequence + PlainChar + local String = P"\"" * g.Cs (Char ^ 0) * (P"\"" + Err "unterminated string") + local Integer = P"-"^(-1) * (P"0" + (R"19" * R"09"^0)) + local Fractal = P"." * R"09"^0 + local Exponent = (S"eE") * (S"+-")^(-1) * R"09"^1 + local Number = (Integer * Fractal^(-1) * Exponent^(-1))/str2num + local Constant = P"true" * g.Cc (true) + P"false" * g.Cc (false) + P"null" * g.Carg (1) + local SimpleValue = Number + String + Constant + local ArrayContent, ObjectContent + + -- The functions parsearray and parseobject parse only a single value/pair + -- at a time and store them directly to avoid hitting the LPeg limits. + local function parsearray (str, pos, nullval, state) + local obj, cont + local npos + local t, nt = {}, 0 + repeat + obj, cont, npos = pegmatch (ArrayContent, str, pos, nullval, state) + if not npos then break end + pos = npos + nt = nt + 1 + t[nt] = obj + until cont == 'last' + return pos, setmetatable (t, state.arraymeta) + end + + local function parseobject (str, pos, nullval, state) + local obj, key, cont + local npos + local t = {} + repeat + key, obj, cont, npos = pegmatch (ObjectContent, str, pos, nullval, state) + if not npos then break end + pos = npos + t[key] = obj + until cont == 'last' + return pos, setmetatable (t, state.objectmeta) + end + + local Array = P"[" * g.Cmt (g.Carg(1) * g.Carg(2), parsearray) * Space * (P"]" + Err "']' expected") + local Object = P"{" * g.Cmt (g.Carg(1) * g.Carg(2), parseobject) * Space * (P"}" + Err "'}' expected") + local Value = Space * (Array + Object + SimpleValue) + local ExpectedValue = Value + Space * Err "value expected" + ArrayContent = Value * Space * (P"," * g.Cc'cont' + g.Cc'last') * g.Cp() + local Pair = g.Cg (Space * String * Space * (P":" + Err "colon expected") * ExpectedValue) + ObjectContent = Pair * Space * (P"," * g.Cc'cont' + g.Cc'last') * g.Cp() + local DecodeValue = ExpectedValue * g.Cp () + + function json.decode (str, pos, nullval, ...) + local state = {} + state.objectmeta, state.arraymeta = optionalmetatables(...) + local obj, retpos = pegmatch (DecodeValue, str, pos, nullval, state) + if state.msg then + return nil, state.pos, state.msg + else + return obj, retpos + end + end + + -- use this function only once: + json.use_lpeg = function () return json end + + json.using_lpeg = true + + return json -- so you can get the module using json = require "dkjson".use_lpeg() +end + +if always_try_using_lpeg then + pcall (json.use_lpeg) +end + +json.parse = json.decode +json.stringify = json.encode + +return json diff --git a/deps/mime.lua b/deps/mime.lua new file mode 100644 index 0000000..591b997 --- /dev/null +++ b/deps/mime.lua @@ -0,0 +1,195 @@ +--[[lit-meta + name = "creationix/mime" + version = "2.0.0" + description = "A simple mime type database useful for serving static files over http." + tags = {"mime", "static"} + license = "MIT" + author = { name = "Tim Caswell" } + homepage = "https://github.com/creationix/weblit/blob/master/libs/mime.lua" +]] + +local mime = {} +local table = { + ["3gp"] = "video/3gpp", + a = "application/octet-stream", + ai = "application/postscript", + aif = "audio/x-aiff", + aiff = "audio/x-aiff", + asc = "application/pgp-signature", + asf = "video/x-ms-asf", + asm = "text/x-asm", + asx = "video/x-ms-asf", + atom = "application/atom+xml", + au = "audio/basic", + avi = "video/x-msvideo", + bat = "application/x-msdownload", + bin = "application/octet-stream", + bmp = "image/bmp", + bz2 = "application/x-bzip2", + c = "text/x-c", + cab = "application/vnd.ms-cab-compressed", + cc = "text/x-c", + chm = "application/vnd.ms-htmlhelp", + class = "application/octet-stream", + com = "application/x-msdownload", + conf = "text/plain", + cpp = "text/x-c", + crt = "application/x-x509-ca-cert", + css = "text/css", + csv = "text/csv", + cxx = "text/x-c", + deb = "application/x-debian-package", + der = "application/x-x509-ca-cert", + diff = "text/x-diff", + djv = "image/vnd.djvu", + djvu = "image/vnd.djvu", + dll = "application/x-msdownload", + dmg = "application/octet-stream", + doc = "application/msword", + dot = "application/msword", + dtd = "application/xml-dtd", + dvi = "application/x-dvi", + ear = "application/java-archive", + eml = "message/rfc822", + eps = "application/postscript", + exe = "application/x-msdownload", + f = "text/x-fortran", + f77 = "text/x-fortran", + f90 = "text/x-fortran", + flv = "video/x-flv", + ["for"] = "text/x-fortran", + gem = "application/octet-stream", + gemspec = "text/x-script.ruby", + gif = "image/gif", + gz = "application/x-gzip", + h = "text/x-c", + hh = "text/x-c", + htm = "text/html", + html = "text/html", + ico = "image/vnd.microsoft.icon", + ics = "text/calendar", + ifb = "text/calendar", + iso = "application/octet-stream", + jar = "application/java-archive", + java = "text/x-java-source", + jnlp = "application/x-java-jnlp-file", + jpeg = "image/jpeg", + jpg = "image/jpeg", + js = "application/javascript", + json = "application/json", + less = "text/css", + log = "text/plain", + lua = "text/x-lua", + luac = "application/x-lua-bytecode", + m3u = "audio/x-mpegurl", + m4v = "video/mp4", + man = "text/troff", + manifest = "text/cache-manifest", + markdown = "text/markdown", + mathml = "application/mathml+xml", + mbox = "application/mbox", + mdoc = "text/troff", + md = "text/markdown", + me = "text/troff", + mid = "audio/midi", + midi = "audio/midi", + mime = "message/rfc822", + mml = "application/mathml+xml", + mng = "video/x-mng", + mov = "video/quicktime", + mp3 = "audio/mpeg", + mp4 = "video/mp4", + mp4v = "video/mp4", + mpeg = "video/mpeg", + mpg = "video/mpeg", + ms = "text/troff", + msi = "application/x-msdownload", + odp = "application/vnd.oasis.opendocument.presentation", + ods = "application/vnd.oasis.opendocument.spreadsheet", + odt = "application/vnd.oasis.opendocument.text", + ogg = "application/ogg", + p = "text/x-pascal", + pas = "text/x-pascal", + pbm = "image/x-portable-bitmap", + pdf = "application/pdf", + pem = "application/x-x509-ca-cert", + pgm = "image/x-portable-graymap", + pgp = "application/pgp-encrypted", + pkg = "application/octet-stream", + pl = "text/x-script.perl", + pm = "text/x-script.perl-module", + png = "image/png", + pnm = "image/x-portable-anymap", + ppm = "image/x-portable-pixmap", + pps = "application/vnd.ms-powerpoint", + ppt = "application/vnd.ms-powerpoint", + ps = "application/postscript", + psd = "image/vnd.adobe.photoshop", + py = "text/x-script.python", + qt = "video/quicktime", + ra = "audio/x-pn-realaudio", + rake = "text/x-script.ruby", + ram = "audio/x-pn-realaudio", + rar = "application/x-rar-compressed", + rb = "text/x-script.ruby", + rdf = "application/rdf+xml", + roff = "text/troff", + rpm = "application/x-redhat-package-manager", + rss = "application/rss+xml", + rtf = "application/rtf", + ru = "text/x-script.ruby", + s = "text/x-asm", + sgm = "text/sgml", + sgml = "text/sgml", + sh = "application/x-sh", + sig = "application/pgp-signature", + snd = "audio/basic", + so = "application/octet-stream", + svg = "image/svg+xml", + svgz = "image/svg+xml", + swf = "application/x-shockwave-flash", + t = "text/troff", + tar = "application/x-tar", + tbz = "application/x-bzip-compressed-tar", + tci = "application/x-topcloud", + tcl = "application/x-tcl", + tex = "application/x-tex", + texi = "application/x-texinfo", + texinfo = "application/x-texinfo", + text = "text/plain", + tif = "image/tiff", + tiff = "image/tiff", + torrent = "application/x-bittorrent", + tr = "text/troff", + ttf = "application/x-font-ttf", + txt = "text/plain", + vcf = "text/x-vcard", + vcs = "text/x-vcalendar", + vrml = "model/vrml", + war = "application/java-archive", + wav = "audio/x-wav", + webm = "video/webm", + wma = "audio/x-ms-wma", + wmv = "video/x-ms-wmv", + wmx = "video/x-ms-wmx", + wrl = "model/vrml", + wsdl = "application/wsdl+xml", + xbm = "image/x-xbitmap", + xhtml = "application/xhtml+xml", + xls = "application/vnd.ms-excel", + xml = "application/xml", + xpm = "image/x-xpixmap", + xsl = "application/xml", + xslt = "application/xslt+xml", + yaml = "text/yaml", + yml = "text/yaml", + zip = "application/zip", +} +mime.table = table +mime.default = "application/octet-stream" + +function mime.getType(path) + return mime.table[path:lower():match("[^.]*$")] or mime.default +end + +return mime diff --git a/deps/pathjoin.lua b/deps/pathjoin.lua new file mode 100644 index 0000000..ce20f77 --- /dev/null +++ b/deps/pathjoin.lua @@ -0,0 +1,124 @@ +--[[lit-meta + name = "creationix/pathjoin" + description = "The path utilities that used to be part of luvi" + version = "2.0.0" + tags = {"path"} + license = "MIT" + author = { name = "Tim Caswell" } +]] + +local getPrefix, splitPath, joinParts + +local isWindows +if _G.jit then + isWindows = _G.jit.os == "Windows" +else + isWindows = not not package.path:match("\\") +end + +if isWindows then + -- Windows aware path utilities + function getPrefix(path) + return path:match("^%a:\\") or + path:match("^/") or + path:match("^\\+") + end + function splitPath(path) + local parts = {} + for part in string.gmatch(path, '([^/\\]+)') do + table.insert(parts, part) + end + return parts + end + function joinParts(prefix, parts, i, j) + if not prefix then + return table.concat(parts, '/', i, j) + elseif prefix ~= '/' then + return prefix .. table.concat(parts, '\\', i, j) + else + return prefix .. table.concat(parts, '/', i, j) + end + end +else + -- Simple optimized versions for UNIX systems + function getPrefix(path) + return path:match("^/") + end + function splitPath(path) + local parts = {} + for part in string.gmatch(path, '([^/]+)') do + table.insert(parts, part) + end + return parts + end + function joinParts(prefix, parts, i, j) + if prefix then + return prefix .. table.concat(parts, '/', i, j) + end + return table.concat(parts, '/', i, j) + end +end + +local function pathJoin(...) + local inputs = {...} + local l = #inputs + + -- Find the last segment that is an absolute path + -- Or if all are relative, prefix will be nil + local i = l + local prefix + while true do + prefix = getPrefix(inputs[i]) + if prefix or i <= 1 then break end + i = i - 1 + end + + -- If there was one, remove its prefix from its segment + if prefix then + inputs[i] = inputs[i]:sub(#prefix) + end + + -- Split all the paths segments into one large list + local parts = {} + while i <= l do + local sub = splitPath(inputs[i]) + for j = 1, #sub do + parts[#parts + 1] = sub[j] + end + i = i + 1 + end + + -- Evaluate special segments in reverse order. + local skip = 0 + local reversed = {} + for idx = #parts, 1, -1 do + local part = parts[idx] + if part ~= '.' then + if part == '..' then + skip = skip + 1 + elseif skip > 0 then + skip = skip - 1 + else + reversed[#reversed + 1] = part + end + end + end + + -- Reverse the list again to get the correct order + parts = reversed + for idx = 1, #parts / 2 do + local j = #parts - idx + 1 + parts[idx], parts[j] = parts[j], parts[idx] + end + + local path = joinParts(prefix, parts) + return path +end + +return { + isWindows = isWindows, + getPrefix = getPrefix, + splitPath = splitPath, + joinParts = joinParts, + pathJoin = pathJoin, +} diff --git a/deps/querystring.lua b/deps/querystring.lua new file mode 100644 index 0000000..cde4298 --- /dev/null +++ b/deps/querystring.lua @@ -0,0 +1,111 @@ +--[[ + +Copyright 2015 The Luvit Authors. All Rights Reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS-IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +--]] + +--[[lit-meta + name = "luvit/querystring" + version = "2.0.1" + license = "Apache 2" + homepage = "https://github.com/luvit/luvit/blob/master/deps/querystring.lua" + description = "Node-style query-string codec for luvit" + tags = {"luvit", "url", "codec"} +]] + +local find = string.find +local gsub = string.gsub +local char = string.char +local byte = string.byte +local format = string.format +local match = string.match +local gmatch = string.gmatch + +local function urldecode(str) + str = gsub(str, '+', ' ') + str = gsub(str, '%%(%x%x)', function(h) + return char(tonumber(h, 16)) + end) + str = gsub(str, '\r\n', '\n') + return str +end + +local function urlencode(str) + if str then + str = gsub(str, '\n', '\r\n') + str = gsub(str, '([^%w-_.~])', function(c) + return format('%%%02X', byte(c)) + end) + end + return str +end + +local function stringifyPrimitive(v) + return tostring(v) +end + +local function stringify(params, sep, eq) + if not sep then sep = '&' end + if not eq then eq = '=' end + if type(params) == "table" then + local fields = {} + for key,value in pairs(params) do + local keyString = urlencode(stringifyPrimitive(key)) .. eq + if type(value) == "table" then + for _, v in ipairs(value) do + table.insert(fields, keyString .. urlencode(stringifyPrimitive(v))) + end + else + table.insert(fields, keyString .. urlencode(stringifyPrimitive(value))) + end + end + return table.concat(fields, sep) + end + return '' +end + +-- parse querystring into table. urldecode tokens +local function parse(str, sep, eq) + if not sep then sep = '&' end + if not eq then eq = '=' end + local vars = {} + for pair in gmatch(tostring(str), '[^' .. sep .. ']+') do + if not find(pair, eq) then + vars[urldecode(pair)] = '' + else + local key, value = match(pair, '([^' .. eq .. ']*)' .. eq .. '(.*)') + if key then + key = urldecode(key) + value = urldecode(value) + local type = type(vars[key]) + if type=='nil' then + vars[key] = value + elseif type=='table' then + table.insert(vars[key], value) + else + vars[key] = {vars[key],value} + end + end + end + end + return vars +end + +return { + urldecode = urldecode, + urlencode = urlencode, + stringify = stringify, + parse = parse, +} diff --git a/deps/resource.lua b/deps/resource.lua new file mode 100644 index 0000000..f8e73cc --- /dev/null +++ b/deps/resource.lua @@ -0,0 +1,88 @@ +--[[ + +Copyright 2014-2016 The Luvit Authors. All Rights Reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS-IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +--]] + +--[[lit-meta + name = "luvit/resource" + version = "2.1.0" + license = "Apache 2" + homepage = "https://github.com/luvit/luvit/blob/master/deps/resource.lua" + description = "Utilities for loading relative resources" + dependencies = { + "creationix/pathjoin@2.0.0" + } + tags = {"luvit", "relative", "resource"} +]] + +local pathJoin = require('pathjoin').pathJoin +local bundle = require('luvi').bundle +local uv = require('uv') + +local function getPath() + local caller = debug.getinfo(2, "S").source + if caller:sub(1,1) == "@" then + return caller:sub(2) + elseif caller:sub(1, 7) == "bundle:" then + return caller + end + error("Unknown file path type: " .. caller) +end + +local function getDir() + local caller = debug.getinfo(2, "S").source + if caller:sub(1,1) == "@" then + return pathJoin(caller:sub(2), "..") + elseif caller:sub(1, 7) == "bundle:" then + return "bundle:" .. pathJoin(caller:sub(8), "..") + end + error("Unknown file path type: " .. caller) +end + +local function innerResolve(path, resolveOnly) + local caller = debug.getinfo(2, "S").source + if caller:sub(1,1) == "@" then + path = pathJoin(caller:sub(2), "..", path) + if resolveOnly then return path end + local fd = assert(uv.fs_open(path, "r", 420)) + local stat = assert(uv.fs_fstat(fd)) + local data = assert(uv.fs_read(fd, stat.size, 0)) + uv.fs_close(fd) + return data, path + elseif caller:sub(1, 7) == "bundle:" then + path = pathJoin(caller:sub(8), "..", path) + if resolveOnly then return path end + return bundle.readfile(path), "bundle:" .. path + end +end + +local function resolve(path) + return innerResolve(path, true) +end + +local function load(path) + return innerResolve(path, false) +end + +local function getProp(self, key) + if key == "path" then return getPath() end + if key == "dir" then return getDir() end +end + +return setmetatable({ + resolve = resolve, + load = load, +}, { __index = getProp }) diff --git a/deps/secure-socket/biowrap.lua b/deps/secure-socket/biowrap.lua new file mode 100644 index 0000000..50f9f19 --- /dev/null +++ b/deps/secure-socket/biowrap.lua @@ -0,0 +1,115 @@ +--[[ + +Copyright 2016 The Luvit Authors. All Rights Reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS-IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +--]] +local openssl = require('openssl') + +-- writeCipher is called when ssl needs something written on the socket +-- handshakeComplete is called when the handhake is complete and it's safe +-- onPlain is called when plaintext comes out. +return function (ctx, isServer, socket, handshakeComplete, servername) + + local bin, bout = openssl.bio.mem(8192), openssl.bio.mem(8192) + local ssl = ctx:ssl(bin, bout, isServer) + + if not isServer and servername then + ssl:set('hostname', servername) + end + + local ssocket = {tls=true} + local onPlain + + local function flush(callback) + local chunks = {} + local i = 0 + while bout:pending() > 0 do + i = i + 1 + chunks[i] = bout:read() + end + if i == 0 then + if callback then callback() end + return true + end + return socket:write(chunks, callback) + end + + local function handshake(callback) + if ssl:handshake() then + local success, result = ssl:getpeerverification() + socket:read_stop() + if not success and result then + handshakeComplete("Error verifying peer: " .. result[1].error_string) + end + handshakeComplete(nil, ssocket) + end + return flush(callback) + end + + local function onCipher(err, data) + if not onPlain then + if err or not data then + return handshakeComplete(err or "Peer aborted the SSL handshake", data) + end + bin:write(data) + return handshake() + end + if err or not data then + return onPlain(err, data) + end + bin:write(data) + while true do + local plain = ssl:read() + if not plain then break end + onPlain(nil, plain) + end + end + + -- When requested to start reading, start the real socket and setup + -- onPlain handler + function ssocket.read_start(_, onRead) + onPlain = onRead + return socket:read_start(onCipher) + end + + -- When requested to write plain data, encrypt it and write to socket + function ssocket.write(_, plain, callback) + ssl:write(plain) + return flush(callback) + end + + function ssocket.shutdown(_, ...) + return socket:shutdown(...) + end + function ssocket.read_stop(_, ...) + return socket:read_stop(...) + end + function ssocket.is_closing(_, ...) + return socket:is_closing(...) + end + function ssocket.close(_, ...) + return socket:close(...) + end + function ssocket.unref(_, ...) + return socket:unref(...) + end + function ssocket.ref(_, ...) + return socket:ref(...) + end + + handshake() + socket:read_start(onCipher) + +end diff --git a/deps/secure-socket/context.lua b/deps/secure-socket/context.lua new file mode 100644 index 0000000..3c3d244 --- /dev/null +++ b/deps/secure-socket/context.lua @@ -0,0 +1,121 @@ +--[[ + +Copyright 2016 The Luvit Authors. All Rights Reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS-IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +--]] +local openssl = require('openssl') + +local loadResource +if type(module) == "table" then + function loadResource(path) + return module:load(path) + end +else + loadResource = require('resource').load +end +local bit = require('bit') + +local DEFAULT_SECUREPROTOCOL +do + local _, _, V = openssl.version() + local isLibreSSL = V:find('^LibreSSL') + + _, _, V = openssl.version(true) + local isTLSv1_3 = not isLibreSSL and V > 0x10100000 + + if isTLSv1_3 then + DEFAULT_SECUREPROTOCOL = 'TLS' + else + DEFAULT_SECUREPROTOCOL = 'SSLv23' + end +end +local DEFAULT_CIPHERS = 'TLS_AES_128_GCM_SHA256:TLS_AES_128_CCM_SHA256:' .. --TLS 1.3 + 'ECDHE-RSA-AES128-SHA256:AES128-GCM-SHA256:' .. --TLS 1.2 + 'RC4:HIGH:!MD5:!aNULL:!EDH' --TLS 1.0 +local DEFAULT_CA_STORE +do + local data = assert(loadResource("./root_ca.dat")) + DEFAULT_CA_STORE = openssl.x509.store:new() + local index = 1 + local dataLength = #data + while index < dataLength do + local len = bit.bor(bit.lshift(data:byte(index), 8), data:byte(index + 1)) + index = index + 2 + local cert = assert(openssl.x509.read(data:sub(index, index + len))) + index = index + len + assert(DEFAULT_CA_STORE:add(cert)) + end +end + +local function returnOne() + return 1 +end + +return function (options) + local ctx = openssl.ssl.ctx_new( + options.protocol or DEFAULT_SECUREPROTOCOL, + options.ciphers or DEFAULT_CIPHERS) + + local key, cert, ca + if options.key then + key = assert(openssl.pkey.read(options.key, true, 'pem')) + end + if options.cert then + cert = {} + for chunk in options.cert:gmatch("%-+BEGIN[^-]+%-+[^-]+%-+END[^-]+%-+") do + cert[#cert + 1] = assert(openssl.x509.read(chunk)) + end + end + if options.ca then + if type(options.ca) == "string" then + ca = { assert(openssl.x509.read(options.ca)) } + elseif type(options.ca) == "table" then + ca = {} + for i = 1, #options.ca do + ca[i] = assert(openssl.x509.read(options.ca[i])) + end + else + error("options.ca must be string or table of strings") + end + end + if key and cert then + local first = table.remove(cert, 1) + assert(ctx:use(key, first)) + if #cert > 0 then + -- TODO: find out if there is a way to not need to duplicate the last cert here + -- as a dummy fill for the root CA cert + assert(ctx:add(cert[#cert], cert)) + end + end + if ca then + local store = openssl.x509.store:new() + for i = 1, #ca do + assert(store:add(ca[i])) + end + ctx:cert_store(store) + elseif DEFAULT_CA_STORE then + ctx:cert_store(DEFAULT_CA_STORE) + end + if not (options.insecure or options.key) then + ctx:verify_mode(openssl.ssl.peer, returnOne) + end + + ctx:options(bit.bor( + openssl.ssl.no_sslv2, + openssl.ssl.no_sslv3, + openssl.ssl.no_compression)) + + return ctx +end diff --git a/deps/secure-socket/init.lua b/deps/secure-socket/init.lua new file mode 100644 index 0000000..ffbfa1d --- /dev/null +++ b/deps/secure-socket/init.lua @@ -0,0 +1,34 @@ +--[[ + +Copyright 2016 The Luvit Authors. All Rights Reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS-IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +--]] +local getContext = require('./context') +local bioWrap = require('./biowrap') + +return function (socket, options, callback) + if options == true then options = {} end + local ctx = getContext(options) + local thread + if not callback then + thread = coroutine.running() + end + bioWrap(ctx, options.server, socket, callback or function (err, ssocket) + return assert(coroutine.resume(thread, ssocket, err)) +end, options.servername) + if not callback then + return coroutine.yield() + end +end diff --git a/deps/secure-socket/package.lua b/deps/secure-socket/package.lua new file mode 100644 index 0000000..c5851d6 --- /dev/null +++ b/deps/secure-socket/package.lua @@ -0,0 +1,12 @@ +return { + name = "luvit/secure-socket", + version = "1.2.2", + homepage = "https://github.com/luvit/luvit/blob/master/deps/secure-socket", + description = "Wrapper for luv streams to apply ssl/tls", + dependencies = { + "luvit/resource@2.1.0" + }, + tags = {"ssl", "socket","tls"}, + license = "Apache 2", + author = { name = "Tim Caswell" } +} diff --git a/deps/secure-socket/root_ca.dat b/deps/secure-socket/root_ca.dat new file mode 100644 index 0000000..8c9097e Binary files /dev/null and b/deps/secure-socket/root_ca.dat differ diff --git a/deps/sha1.lua b/deps/sha1.lua new file mode 100644 index 0000000..2a65391 --- /dev/null +++ b/deps/sha1.lua @@ -0,0 +1,194 @@ +--[[lit-meta + name = "creationix/sha1" + version = "1.0.3" + homepage = "https://github.com/luvit/lit/blob/master/deps/sha1.lua" + description = "Pure Lua implementation of SHA1 using bitop" + authors = { + "Tim Caswell" + } +]] + +-- http://csrc.nist.gov/groups/ST/toolkit/documents/Examples/SHA_All.pdf + +local bit = require('bit') +local band = bit.band +local bor = bit.bor +local bxor = bit.bxor +local lshift = bit.lshift +local rshift = bit.rshift +local rol = bit.rol +local tobit = bit.tobit +local tohex = bit.tohex + +local byte = string.byte +local concat = table.concat +local floor = table.floor + +local hasFFi, ffi = pcall(require, "ffi") +local newBlock = hasFFi and function () + return ffi.new("uint32_t[80]") +end or function () + local t = {} + for i = 0, 79 do + t[i] = 0 + end + return t +end + +local shared = newBlock() + +local function unsigned(n) + return n < 0 and (n + 0x100000000) or n +end + +local function create(sync) + local h0 = 0x67452301 + local h1 = 0xEFCDAB89 + local h2 = 0x98BADCFE + local h3 = 0x10325476 + local h4 = 0xC3D2E1F0 + -- The first 64 bytes (16 words) is the data chunk + local W = sync and shared or newBlock() + local offset = 0 + local shift = 24 + local totalLength = 0 + + local update, write, processBlock, digest + + -- The user gave us more data. Store it! + function update(chunk) + local length = #chunk + totalLength = totalLength + length * 8 + for i = 1, length do + write(byte(chunk, i)) + end + end + + function write(data) + W[offset] = bor(W[offset], lshift(band(data, 0xff), shift)) + if shift > 0 then + shift = shift - 8 + else + offset = offset + 1 + shift = 24 + end + if offset == 16 then + return processBlock() + end + end + + -- No more data will come, pad the block, process and return the result. + function digest() + -- Pad + write(0x80) + if offset > 14 or (offset == 14 and shift < 24) then + processBlock() + end + offset = 14 + shift = 24 + + -- 64-bit length big-endian + write(0x00) -- numbers this big aren't accurate in lua anyway + write(0x00) -- ..So just hard-code to zero. + write(totalLength > 0xffffffffff and floor(totalLength / 0x10000000000) or 0x00) + write(totalLength > 0xffffffff and floor(totalLength / 0x100000000) or 0x00) + for s = 24, 0, -8 do + write(rshift(totalLength, s)) + end + + -- At this point one last processBlock() should trigger and we can pull out the result. + return concat { + tohex(h0), + tohex(h1), + tohex(h2), + tohex(h3), + tohex(h4) + } + end + + -- We have a full block to process. Let's do it! + function processBlock() + + -- Extend the sixteen 32-bit words into eighty 32-bit words: + for i = 16, 79, 1 do + W[i] = + rol(bxor(W[i - 3], W[i - 8], W[i - 14], W[i - 16]), 1) + end + + -- print("Block Contents:") + -- for i = 0, 15 do + -- print(string.format(" W[%d] = %s", i, tohex(W[i]))) + -- end + -- print() + + -- Initialize hash value for this chunk: + local a = h0 + local b = h1 + local c = h2 + local d = h3 + local e = h4 + local f, k + + -- print(" A B C D E") + -- local format = + -- "t=%02d: %s %s %s %s %s" + -- Main loop: + for t = 0, 79 do + if t < 20 then + f = bxor(d, band(b, bxor(c, d))) + k = 0x5A827999 + elseif t < 40 then + f = bxor(b, c, d) + k = 0x6ED9EBA1 + elseif t < 60 then + f = bor(band(b, c), (band(d, bor(b, c)))) + k = 0x8F1BBCDC + else + f = bxor(b, c, d) + k = 0xCA62C1D6 + end + e, d, c, b, a = + d, + c, + rol(b, 30), + a, + tobit( + unsigned(rol(a, 5)) + + unsigned(f) + + unsigned(e) + + unsigned(k) + + W[t] + ) + -- print(string.format(format, t, tohex(a), tohex(b), tohex(c), tohex(d), tohex(e))) + end + + -- Add this chunk's hash to result so far: + h0 = tobit(unsigned(h0) + a) + h1 = tobit(unsigned(h1) + b) + h2 = tobit(unsigned(h2) + c) + h3 = tobit(unsigned(h3) + d) + h4 = tobit(unsigned(h4) + e) + + -- The block is now reusable. + offset = 0 + for i = 0, 15 do + W[i] = 0 + end + end + + return { + update = update, + digest = digest + } + +end + +return function (buffer) + -- Pass in false or nil to get a streaming interface. + if not buffer then + return create(false) + end + local shasum = create(true) + shasum.update(buffer) + return shasum.digest() +end diff --git a/deps/weblit-app.lua b/deps/weblit-app.lua new file mode 100644 index 0000000..df64859 --- /dev/null +++ b/deps/weblit-app.lua @@ -0,0 +1,39 @@ +--[[lit-meta + name = "creationix/weblit-app" + version = "3.2.0" + dependencies = { + 'creationix/coro-net@3.0.0', + 'luvit/http-codec@3.0.0', + 'luvit/querystring@2.0.0', + 'creationix/weblit-server@3.0.0', + 'creationix/weblit-router@3.0.0' + } + description = "Weblit is a webapp framework designed around routes and middleware layers." + tags = {"weblit", "router", "framework"} + license = "MIT" + author = { name = "Tim Caswell" } + homepage = "https://github.com/creationix/weblit/blob/master/libs/weblit-app.lua" +]] + +-- Ignore SIGPIPE if it exists on platform +local uv = require('uv') +if uv.constants.SIGPIPE then + uv.new_signal():start("sigpipe") +end + +local router = require('weblit-router').newRouter() +local server = require('weblit-server').newServer(router.run) + +-- Forward router methods from app instance +local serverMeta = {} +function serverMeta:__index(name) + if type(router[name] == "function") then + return function(...) + router[name](...) + return self + end + end +end +setmetatable(server, serverMeta) + +return server diff --git a/deps/weblit-auto-headers.lua b/deps/weblit-auto-headers.lua new file mode 100644 index 0000000..d62899d --- /dev/null +++ b/deps/weblit-auto-headers.lua @@ -0,0 +1,97 @@ +--[[lit-meta + name = "creationix/weblit-auto-headers" + version = "2.1.0" + description = "The auto-headers middleware helps Weblit apps implement proper HTTP semantics" + tags = {"weblit", "middleware", "http"} + license = "MIT" + author = { name = "Tim Caswell" } + homepage = "https://github.com/creationix/weblit/blob/master/libs/weblit-auto-headers.lua" +]] + + +--[[ + +Response automatic values: + - Auto Server header + - Auto Date Header + - code defaults to 404 with body "Not Found\n" + - if string body and no Content-Type, use text/plain for valid utf-8, application/octet-stream otherwise + - Auto add "; charset=utf-8" to Content-Type when body is known to be valid utf-8 + - Auto 304 responses for if-none-match requests + - Auto strip body with HEAD requests + - Auto chunked encoding if body with unknown length + - if Connection header set and not keep-alive, set res.keepAlive to false + - Add Connection Keep-Alive/Close if not found based on res.keepAlive + +--TODO: utf8 scanning + +]] + +local date = require('os').date + +local success, parent = pcall(require, '../package') +local serverName = success and (parent.name .. " v" .. parent.version) + +return function (req, res, go) + local isHead = false + if req.method == "HEAD" then + req.method = "GET" + isHead = true + end + + local requested = req.headers["if-none-match"] + + go() + + -- We could use the fancy metatable, but this is much faster + local lowerHeaders = {} + local headers = res.headers + for i = 1, #headers do + local key, value = unpack(headers[i]) + lowerHeaders[key:lower()] = value + end + + + if not lowerHeaders.server then + headers[#headers + 1] = {"Server", serverName} + end + if not lowerHeaders.date then + headers[#headers + 1] = {"Date", date("!%a, %d %b %Y %H:%M:%S GMT")} + end + + if not lowerHeaders.connection then + if req.keepAlive then + lowerHeaders.connection = "Keep-Alive" + headers[#headers + 1] = {"Connection", "Keep-Alive"} + else + headers[#headers + 1] = {"Connection", "Close"} + end + end + res.keepAlive = lowerHeaders.connection and lowerHeaders.connection:lower() == "keep-alive" + + local body = res.body + if body then + local needLength = not lowerHeaders["content-length"] and not lowerHeaders["transfer-encoding"] + if type(body) == "string" then + if needLength then + headers[#headers + 1] = {"Content-Length", #body} + end + else + if needLength then + headers[#headers + 1] = {"Transfer-Encoding", "chunked"} + end + end + if not lowerHeaders["content-type"] then + headers[#headers + 1] = {"Content-Type", "text/plain"} + end + end + + local etag = lowerHeaders.etag + if requested and res.code >= 200 and res.code < 300 and requested == etag then + res.code = 304 + body = nil + end + + if isHead then body = nil end + res.body = body +end diff --git a/deps/weblit-cors.lua b/deps/weblit-cors.lua new file mode 100644 index 0000000..17a45a8 --- /dev/null +++ b/deps/weblit-cors.lua @@ -0,0 +1,15 @@ +--[[lit-meta + name = "creationix/weblit-cors" + version = "2.0.0" + description = "The logger middleware To add uncionditional CORS headers." + tags = {"weblit", "middleware", "cors"} + license = "MIT" + author = { name = "Tim Caswell" } + homepage = "https://github.com/creationix/weblit/blob/master/libs/weblit-cors.lua" +]] + +return function (_, res, go) + go() + res.headers["Access-Control-Allow-Origin"] = "*" + res.headers["Access-Control-Allow-Headers"] = "Origin, X-Requested-With, Content-Type, Accept" +end diff --git a/deps/weblit-etag-cache.lua b/deps/weblit-etag-cache.lua new file mode 100644 index 0000000..6a9fbb7 --- /dev/null +++ b/deps/weblit-etag-cache.lua @@ -0,0 +1,48 @@ +--[[lit-meta + name = "creationix/weblit-etag-cache" + version = "2.0.0" + description = "The etag-cache middleware caches WebLit responses in ram and uses etags to support conditional requests." + tags = {"weblit", "middleware", "etag"} + license = "MIT" + author = { name = "Tim Caswell" } + homepage = "https://github.com/creationix/weblit/blob/master/libs/weblit-etag-cache.lua" +]] + +local function clone(headers) + local copy = setmetatable({}, getmetatable(headers)) + for i = 1, #headers do + copy[i] = headers[i] + end + return copy +end + +local cache = {} +return function (req, res, go) + local requested = req.headers["If-None-Match"] + local host = req.headers.Host + local key = host and host .. "|" .. req.path or req.path + local cached = cache[key] + if not requested and cached then + req.headers["If-None-Match"] = cached.etag + end + go() + local etag = res.headers.ETag + if not etag then return end + if res.code >= 200 and res.code < 300 then + local body = res.body + if not body or type(body) == "string" then + cache[key] = { + etag = etag, + code = res.code, + headers = clone(res.headers), + body = body + } + end + elseif res.code == 304 then + if not requested and cached and etag == cached.etag then + res.code = cached.code + res.headers = clone(cached.headers) + res.body = cached.body + end + end +end diff --git a/deps/weblit-force-https.lua b/deps/weblit-force-https.lua new file mode 100644 index 0000000..9f8ab19 --- /dev/null +++ b/deps/weblit-force-https.lua @@ -0,0 +1,16 @@ +--[[lit-meta + name = "creationix/weblit-force-https" + version = "2.0.1" + description = "Redirects http request to https." + tags = {"weblit", "middleware", "https"} + license = "MIT" + author = { name = "Tim Caswell" } + homepage = "https://github.com/creationix/weblit/blob/master/libs/weblit-force-https.lua" +]] + +return function (req, res, go) + if req.socket.tls then return go() end + res.code = 301 + res.headers["Location"] = "https://" .. req.headers.Host .. req.path + res.body = "Redirecting to HTTPS...\n" +end diff --git a/deps/weblit-logger.lua b/deps/weblit-logger.lua new file mode 100644 index 0000000..200eb84 --- /dev/null +++ b/deps/weblit-logger.lua @@ -0,0 +1,19 @@ +--[[lit-meta + name = "creationix/weblit-logger" + version = "2.0.0" + description = "The logger middleware for Weblit logs basic request and response information." + tags = {"weblit", "middleware", "logger"} + license = "MIT" + author = { name = "Tim Caswell" } + homepage = "https://github.com/creationix/weblit/blob/master/libs/weblit-logger.lua" +]] + +return function (req, res, go) + -- Skip this layer for clients who don't send User-Agent headers. + local userAgent = req.headers["user-agent"] + if not userAgent then return go() end + -- Run all inner layers first. + go() + -- And then log after everything is done + print(string.format("%s %s %s %s", req.method, req.path, userAgent, res.code)) +end diff --git a/deps/weblit-router.lua b/deps/weblit-router.lua new file mode 100644 index 0000000..6a55379 --- /dev/null +++ b/deps/weblit-router.lua @@ -0,0 +1,129 @@ +--[[lit-meta + name = "creationix/weblit-router" + version = "3.0.1" + dependencies = { + 'luvit/querystring@2.0.0' + } + description = "Weblit is a webapp framework designed around routes and middleware layers." + tags = {"weblit", "router", "framework"} + license = "MIT" + author = { name = "Tim Caswell" } + homepage = "https://github.com/creationix/weblit/blob/master/libs/weblit-app.lua" +]] + +local parseQuery = require('querystring').parse + +local quotepattern = '(['..("%^$().[]*+-?"):gsub("(.)", "%%%1")..'])' +local function escape(str) + return str:gsub(quotepattern, "%%%1") +end + +local function compileGlob(glob) + local parts = {"^"} + for a, b in glob:gmatch("([^*]*)(%**)") do + if #a > 0 then + parts[#parts + 1] = escape(a) + end + if #b > 0 then + parts[#parts + 1] = "(.*)" + end + end + parts[#parts + 1] = "$" + local pattern = table.concat(parts) + return function (string) + return string and string:match(pattern) + end +end + +local function compileRoute(route) + local parts = {"^"} + local names = {} + for a, b, c, d in route:gmatch("([^:]*):([_%a][_%w]*)(:?)([^:]*)") do + if #a > 0 then + parts[#parts + 1] = escape(a) + end + if #c > 0 then + parts[#parts + 1] = "(.*)" + else + parts[#parts + 1] = "([^/]*)" + end + names[#names + 1] = b + if #d > 0 then + parts[#parts + 1] = escape(d) + end + end + if #parts == 1 then + return function (string) + if string == route then return {} end + end + end + parts[#parts + 1] = "$" + local pattern = table.concat(parts) + return function (string) + local matches = {string:match(pattern)} + if #matches > 0 then + local results = {} + for i = 1, #matches do + results[i] = matches[i] + results[names[i]] = matches[i] + end + return results + end + end +end + +local function newRouter() + local handlers = {} + local router = {} + + function router.use(handler) + handlers[#handlers + 1] = handler + return router + end + + function router.route(options, handler) + local method = options.method + local path = options.path and compileRoute(options.path) + local host = options.host and compileGlob(options.host) + local filter = options.filter + router.use(function (req, res, go) + if method and req.method ~= method then return go() end + if host and not host(req.headers.host) then return go() end + if filter and not filter(req) then return go() end + local params + if path then + local pathname, query = req.path:match("^([^?]*)%??(.*)") + params = path(pathname) + if not params then return go() end + if #query > 0 then + req.query = parseQuery(query) + end + end + req.params = params or {} + return handler(req, res, go) + end) + return router + end + + function router.run(req, res, go) + local len = #handlers + local function run(i) + if i > len then + return (go or function () end)() + end + return handlers[i](req, res, function () + return run(i + 1) + end) + end + run(1) + end + + return router +end + +return { + newRouter = newRouter, + escape, escape, + compileGlob, compileGlob, + compileRoute, compileRoute, +} diff --git a/deps/weblit-server.lua b/deps/weblit-server.lua new file mode 100644 index 0000000..8f5f84e --- /dev/null +++ b/deps/weblit-server.lua @@ -0,0 +1,211 @@ +--[[lit-meta + name = "creationix/weblit-server" + version = "3.1.2" + dependencies = { + 'creationix/coro-net@3.0.0', + 'luvit/http-codec@3.0.0' + } + description = "Weblit is a webapp framework designed around routes and middleware layers." + tags = {"weblit", "server", "framework"} + license = "MIT" + author = { name = "Tim Caswell" } + homepage = "https://github.com/creationix/weblit/blob/master/libs/weblit-app.lua" +]] + +local uv = require('uv') +local createServer = require('coro-net').createServer +local httpCodec = require('http-codec') + +-- Provide a nice case insensitive interface to headers. +local headerMeta = {} +function headerMeta:__index(name) + if type(name) ~= "string" then + return rawget(self, name) + end + name = name:lower() + for i = 1, #self do + local key, value = unpack(self[i]) + if key:lower() == name then return value end + end +end +function headerMeta:__newindex(name, value) + -- non-string keys go through as-is. + if type(name) ~= "string" then + return rawset(self, name, value) + end + -- First remove any existing pairs with matching key + local lowerName = name:lower() + for i = #self, 1, -1 do + if self[i][1]:lower() == lowerName then + table.remove(self, i) + end + end + -- If value is nil, we're done + if value == nil then return end + -- Otherwise, set the key(s) + if (type(value) == "table") then + -- We accept a table of strings + for i = 1, #value do + rawset(self, #self + 1, {name, tostring(value[i])}) + end + else + -- Or a single value interperted as string + rawset(self, #self + 1, {name, tostring(value)}) + end +end + +local function newServer(run) + local server = {} + local bindings = {} + + run = run or function () end + + local function handleRequest(head, input, socket) + local req = { + socket = socket, + method = head.method, + path = head.path, + headers = setmetatable({}, headerMeta), + version = head.version, + keepAlive = head.keepAlive, + body = input + } + for i = 1, #head do + req.headers[i] = head[i] + end + + local res = { + code = 404, + headers = setmetatable({}, headerMeta), + body = "Not Found\n", + } + + local success, err = pcall(function () + run(req, res, function() end) + end) + if not success then + res.code = 500 + res.headers = setmetatable({}, headerMeta) + res.body = err + print(err) + end + + local out = { + code = res.code, + keepAlive = res.keepAlive, + } + for i = 1, #res.headers do + out[i] = res.headers[i] + end + return out, res.body, res.upgrade + end + + local function handleConnection(read, write, socket, updateDecoder, updateEncoder) + + for head in read do + local parts = {} + for chunk in read do + if #chunk > 0 then + parts[#parts + 1] = chunk + else + break + end + end + local res, body, upgrade = handleRequest(head, #parts > 0 and table.concat(parts) or nil, socket) + write(res) + if upgrade then + return upgrade(read, write, updateDecoder, updateEncoder, socket) + end + write(body) + if not (res.keepAlive and head.keepAlive) then + break + end + end + write() + + end + + function server.setRun(newRun) + run = newRun + end + + function server.bind(options) + if not options.port then + local getuid = require('uv').getuid + options.port = (getuid and getuid() == 0) and + (options.tls and 443 or 80) or + (options.tls and 8443 or 8080) + end + bindings[#bindings + 1] = options + return server + end + + function server.start() + if #bindings == 0 then + server.bind({}) + end + + local function show(options) + local protocol = options.tls and 'https' or 'http' + local port = "" + if options.port ~= (options.tls and 443 or 80) then + port = ":" .. options.port + end + local host = options.host + if host:match(":") then host = "[" .. host .. "]" end + print(" " .. protocol .. '://' .. host .. port .. '/') + end + + print("Weblit server listening at:") + + for i = 1, #bindings do + local options = bindings[i] + if options.host then + show(options) + end + end + + local ips = {} + for i = 1, #bindings do + local options = bindings[i] + if options.host then + local list = uv.getaddrinfo(options.host, nil, {}) + for i = 1, #list do + local entry = list[i] + ips[entry.addr .. " " .. options.port] = options + end + else + for name, list in pairs(uv.interface_addresses()) do + for i = 1, #list do + local data = list[i] + if data.family == "inet" then + ips[data.ip .. ' ' .. options.port] = options + end + end + end + end + end + + for addr, options in pairs(ips) do + local host, port = addr:match("(.*) (.*)") + port = tonumber(port) + options.decode = httpCodec.decoder() + options.encode = httpCodec.encoder() + options.host = host + options.port = port + createServer(options, handleConnection) + show(options) + end + + return server + end + + return server +end + +return { + newServer = newServer, + handleRequest = handleRequest, + handleConnection = handleConnection, + headerMeta = headerMeta +} diff --git a/deps/weblit-static.lua b/deps/weblit-static.lua new file mode 100644 index 0000000..0472643 --- /dev/null +++ b/deps/weblit-static.lua @@ -0,0 +1,109 @@ +--[[lit-meta + name = "creationix/weblit-static" + version = "2.2.2" + dependencies = { + "creationix/mime@2.0.0", + "creationix/coro-fs@2.2.2", + "luvit/json@2.5.2", + "creationix/sha1@1.0.0", + } + description = "A weblit middleware for serving static files from disk or bundle." + tags = {"weblit", "middleware", "static"} + license = "MIT" + author = { name = "Tim Caswell" } + homepage = "https://github.com/creationix/weblit/blob/master/libs/weblit-auto-headers.lua" +]] + +local getType = require("mime").getType +local jsonStringify = require('json').stringify +local sha1 = require('sha1') + +return function (rootPath) + local fs + local i, j = rootPath:find("^bundle:") + if i then + local pathJoin = require('luvi').path.join + local prefix = rootPath:sub(j + 1) + if prefix:byte(1) == 47 then + prefix = prefix:sub(2) + end + local bundle = require('luvi').bundle + fs = {} + -- bundle.stat + -- bundle.readdir + -- bundle.readfile + function fs.stat(path) + return bundle.stat(pathJoin(prefix, path)) + end + function fs.scandir(path) + local dir = bundle.readdir(pathJoin(prefix, path)) + local offset = 1 + return function () + local name = dir[offset] + if not name then return end + offset = offset + 1 + local stat = bundle.stat(pathJoin(prefix, path, name)) + stat.name = name + return stat + end + end + function fs.readFile(path) + return bundle.readfile(pathJoin(prefix, path)) + end + else + fs = require('coro-fs').chroot(rootPath) + end + + return function (req, res, go) + if req.method ~= "GET" then return go() end + local path = (req.params and req.params.path) or req.path + path = path:match("^[^?#]*") + if path:byte(1) == 47 then + path = path:sub(2) + end + local stat = fs.stat(path) + if not stat then return go() end + + local function renderFile() + local body = assert(fs.readFile(path)) + res.code = 200 + res.headers["Content-Type"] = getType(path) + res.headers["ETag"] = '"' .. sha1(body) .. '"' + res.body = body + return + end + + local function renderDirectory() + if req.path:byte(-1) ~= 47 then + res.code = 301 + res.headers.Location = req.path .. '/' + return + end + local files = {} + for entry in fs.scandir(path) do + if entry.name == "index.html" and entry.type == "file" then + path = (#path > 0 and path .. "/" or "") .. "index.html" + return renderFile() + end + files[#files + 1] = entry + entry.url = "http://" .. req.headers.host .. req.path .. entry.name + end + local body = jsonStringify(files) .. "\n" + res.code = 200 + res.headers["Content-Type"] = "application/json" + res.body = body + return + end + + if stat.type == "directory" then + return renderDirectory() + elseif stat.type == "file" then + if req.path:byte(-1) == 47 then + res.code = 301 + res.headers.Location = req.path:match("^(.*[^/])/+$") + return + end + return renderFile() + end + end +end diff --git a/deps/weblit-websocket.lua b/deps/weblit-websocket.lua new file mode 100644 index 0000000..4c200bf --- /dev/null +++ b/deps/weblit-websocket.lua @@ -0,0 +1,100 @@ +--[[lit-meta + name = "creationix/weblit-websocket" + version = "3.0.0" + dependencies = { + "creationix/websocket-codec@3.0.0", + "creationix/coro-websocket@3.0.0", + } + description = "The websocket middleware for Weblit enables handling websocket clients." + tags = {"weblit", "middleware", "websocket"} + license = "MIT" + author = { name = "Tim Caswell" } + homepage = "https://github.com/creationix/weblit/blob/master/libs/weblit-websocket.lua" +]] + +local websocketCodec = require('websocket-codec') +local wrapIo = require('coro-websocket').wrapIo + +local function websocketHandler(options, handler) + return function (req, res, go) + -- Websocket connections must be GET requests + -- with 'Upgrade: websocket' + -- and 'Connection: Upgrade' headers + local headers = req.headers + local connection = headers.connection + local upgrade = headers.upgrade + if not ( + req.method == "GET" and + upgrade and upgrade:lower():find("websocket", 1, true) and + connection and connection:lower():find("upgrade", 1, true) + ) then + return go() + end + + if options.filter and not options.filter(req) then + return go() + end + + -- If there is a sub-protocol specified, filter on it. + local protocol = options.protocol + if protocol then + local list = headers["sec-websocket-protocol"] + local foundProtocol + if list then + for item in list:gmatch("[^, ]+") do + if item == protocol then + foundProtocol = true + break + end + end + end + if not foundProtocol then + return go() + end + end + + -- Make sure it's a new client speaking v13 of the protocol + assert(tonumber(headers["sec-websocket-version"]) >= 13, "only websocket protocol v13 supported") + + -- Get the security key + local key = assert(headers["sec-websocket-key"], "websocket security required") + + res.code = 101 + headers = res.headers + headers.Upgrade = "websocket" + headers.Connection = "Upgrade" + headers["Sec-WebSocket-Accept"] = websocketCodec.acceptKey(key) + if protocol then + headers["Sec-WebSocket-Protocol"] = protocol + end + function res.upgrade(read, write, updateDecoder, updateEncoder) + updateDecoder(websocketCodec.decode) + updateEncoder(websocketCodec.encode) + read, write = wrapIo(read, write, { + mask = false, + heartbeat = options.heartbeat + }) + local success, err = pcall(handler, req, read, write) + if not success then + print(err) + write({ + opcode = 1, + payload = err, + }) + return write() + end + end + end +end + +local server = require('weblit-app') +function server.websocket(options, handler) + server.route({ + method = "GET", + path = options.path, + host = options.host, + }, websocketHandler(options, handler)) + return server +end + +return websocketHandler diff --git a/deps/weblit.lua b/deps/weblit.lua new file mode 100644 index 0000000..583f03c --- /dev/null +++ b/deps/weblit.lua @@ -0,0 +1,32 @@ +--[[lit-meta + name = "creationix/weblit" + version = "3.1.2" + dependencies = { + "creationix/weblit-app@3.0.0", + "creationix/weblit-auto-headers@2.0.2", + "creationix/weblit-etag-cache@2.0.0", + "creationix/weblit-logger@02.0.0", + "creationix/weblit-cors@2.0.0", + "creationix/weblit-static@2.2.2", + "creationix/weblit-websocket@3.0.0", + "creationix/weblit-force-https@2" + } + files = { + "package.lua", + "init.lua", + } + description = "This Weblit metapackage brings in all the official Weblit modules." + tags = {"weblit", "meta"} + license = "MIT" + author = { name = "Tim Caswell" } + homepage = "https://github.com/creationix/weblit" +--]] + +return { + app = require('weblit-app'), + autoHeaders = require('weblit-auto-headers'), + etagCache = require('weblit-etag-cache'), + logger = require('weblit-logger'), + static = require('weblit-static'), + websocket = require('weblit-websocket'), +} diff --git a/deps/websocket-codec.lua b/deps/websocket-codec.lua new file mode 100644 index 0000000..3ea1611 --- /dev/null +++ b/deps/websocket-codec.lua @@ -0,0 +1,301 @@ +--[[lit-meta + name = "creationix/websocket-codec" + description = "A codec implementing websocket framing and helpers for handshakeing" + version = "3.0.2" + dependencies = { + "creationix/base64@2.0.0", + "creationix/sha1@1.0.0", + } + homepage = "https://github.com/luvit/lit/blob/master/deps/websocket-codec.lua" + tags = {"http", "websocket", "codec"} + license = "MIT" + author = { name = "Tim Caswell" } +]] + +local base64 = require('base64').encode +local sha1 = require('sha1') +local bit = require('bit') + +local band = bit.band +local bor = bit.bor +local bxor = bit.bxor +local rshift = bit.rshift +local lshift = bit.lshift +local char = string.char +local byte = string.byte +local sub = string.sub +local gmatch = string.gmatch +local lower = string.lower +local gsub = string.gsub +local concat = table.concat +local floor = math.floor +local random = math.random + +local function rand4() + -- Generate 32 bits of pseudo random data + local num = floor(random() * 0x100000000) + -- Return as a 4-byte string + return char( + rshift(num, 24), + band(rshift(num, 16), 0xff), + band(rshift(num, 8), 0xff), + band(num, 0xff) + ) +end + +local function applyMask(data, mask) + local bytes = { + [0] = byte(mask, 1), + [1] = byte(mask, 2), + [2] = byte(mask, 3), + [3] = byte(mask, 4) + } + local out = {} + for i = 1, #data do + out[i] = char( + bxor(byte(data, i), bytes[(i - 1) % 4]) + ) + end + return concat(out) +end + +local function decode(chunk, index) + local start = index - 1 + local length = #chunk - start + if length < 2 then return end + local second = byte(chunk, start + 2) + local len = band(second, 0x7f) + local offset + if len == 126 then + if length < 4 then return end + len = bor( + lshift(byte(chunk, start + 3), 8), + byte(chunk, start + 4)) + offset = 4 + elseif len == 127 then + if length < 10 then return end + len = bor( + lshift(byte(chunk, start + 3), 24), + lshift(byte(chunk, start + 4), 16), + lshift(byte(chunk, start + 5), 8), + byte(chunk, start + 6) + ) * 0x100000000 + bor( + lshift(byte(chunk, start + 7), 24), + lshift(byte(chunk, start + 8), 16), + lshift(byte(chunk, start + 9), 8), + byte(chunk, start + 10) + ) + offset = 10 + else + offset = 2 + end + local mask = band(second, 0x80) > 0 + if mask then + offset = offset + 4 + end + offset = offset + start + if #chunk < offset + len then return end + + local first = byte(chunk, start + 1) + local payload = sub(chunk, offset + 1, offset + len) + assert(#payload == len, "Length mismatch") + if mask then + payload = applyMask(payload, sub(chunk, offset - 3, offset)) + end + return { + fin = band(first, 0x80) > 0, + rsv1 = band(first, 0x40) > 0, + rsv2 = band(first, 0x20) > 0, + rsv3 = band(first, 0x10) > 0, + opcode = band(first, 0xf), + mask = mask, + len = len, + payload = payload + }, offset + len + 1 +end + +local function encode(item) + if type(item) == "string" then + item = { + opcode = 2, + payload = item + } + end + local payload = item.payload + assert(type(payload) == "string", "payload must be string") + local len = #payload + local fin = item.fin + if fin == nil then fin = true end + local rsv1 = item.rsv1 + local rsv2 = item.rsv2 + local rsv3 = item.rsv3 + local opcode = item.opcode or 2 + local mask = item.mask + local chars = { + char(bor( + fin and 0x80 or 0, + rsv1 and 0x40 or 0, + rsv2 and 0x20 or 0, + rsv3 and 0x10 or 0, + opcode + )), + char(bor( + mask and 0x80 or 0, + len < 126 and len or (len < 0x10000) and 126 or 127 + )) + } + if len >= 0x10000 then + local high = len / 0x100000000 + chars[3] = char(band(rshift(high, 24), 0xff)) + chars[4] = char(band(rshift(high, 16), 0xff)) + chars[5] = char(band(rshift(high, 8), 0xff)) + chars[6] = char(band(high, 0xff)) + chars[7] = char(band(rshift(len, 24), 0xff)) + chars[8] = char(band(rshift(len, 16), 0xff)) + chars[9] = char(band(rshift(len, 8), 0xff)) + chars[10] = char(band(len, 0xff)) + elseif len >= 126 then + chars[3] = char(band(rshift(len, 8), 0xff)) + chars[4] = char(band(len, 0xff)) + end + if mask then + local key = rand4() + return concat(chars) .. key .. applyMask(payload, key) + end + return concat(chars) .. payload +end + +local websocketGuid = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" + +-- Given two hex characters, return a single character +local function hexToBin(cc) + return string.char(tonumber(cc, 16)) +end + +local function decodeHex(hex) + local bin = string.gsub(hex, "..", hexToBin) + return bin +end + +local function acceptKey(key) + return gsub(base64(decodeHex(sha1(key .. websocketGuid))), "\n", "") +end + +-- Make a client handshake connection +local function handshake(options, request) + -- Generate 20 bytes of pseudo-random data + local key = concat({rand4(), rand4(), rand4(), rand4(), rand4()}) + key = base64(key) + local host = options.host + local path = options.path or "/" + local protocol = options.protocol + local req = { + method = "GET", + path = path, + {"Connection", "Upgrade"}, + {"Upgrade", "websocket"}, + {"Sec-WebSocket-Version", "13"}, + {"Sec-WebSocket-Key", key}, + } + for i = 1, #options do + req[#req + 1] = options[i] + end + if host then + req[#req + 1] = {"Host", host} + end + if protocol then + req[#req + 1] = {"Sec-WebSocket-Protocol", protocol} + end + local res = request(req) + if not res then + return nil, "Missing response from server" + end + -- Parse the headers for quick reading + if res.code ~= 101 then + return nil, "response must be code 101" + end + + local headers = {} + for i = 1, #res do + local name, value = unpack(res[i]) + headers[lower(name)] = value + end + + if not headers.connection or lower(headers.connection) ~= "upgrade" then + return nil, "Invalid or missing connection upgrade header in response" + end + if headers["sec-websocket-accept"] ~= acceptKey(key) then + return nil, "challenge key missing or mismatched" + end + if protocol and headers["sec-websocket-protocol"] ~= protocol then + return nil, "protocol missing or mistmatched" + end + return true +end + +local function handleHandshake(head, protocol) + + -- WebSocket connections must be GET requests + if not head.method == "GET" then return end + + -- Parse the headers for quick reading + local headers = {} + for i = 1, #head do + local name, value = unpack(head[i]) + headers[lower(name)] = value + end + + -- Must have 'Upgrade: websocket' and 'Connection: Upgrade' headers + if not (headers.connection and headers.upgrade and + headers.connection:lower():find("upgrade", 1, true) and + headers.upgrade:lower():find("websocket", 1, true)) then return end + + -- Make sure it's a new client speaking v13 of the protocol + if tonumber(headers["sec-websocket-version"]) < 13 then + return nil, "only websocket protocol v13 supported" + end + + local key = headers["sec-websocket-key"] + if not key then + return nil, "websocket security key missing" + end + + -- If the server wants a specified protocol, check for it. + if protocol then + local foundProtocol = false + local list = headers["sec-websocket-protocol"] + if list then + for item in gmatch(list, "[^, ]+") do + if item == protocol then + foundProtocol = true + break + end + end + end + if not foundProtocol then + return nil, "specified protocol missing in request" + end + end + + local accept = acceptKey(key) + + local res = { + code = 101, + {"Upgrade", "websocket"}, + {"Connection", "Upgrade"}, + {"Sec-WebSocket-Accept", accept}, + } + if protocol then + res[#res + 1] = {"Sec-WebSocket-Protocol", protocol} + end + + return res +end + +return { + decode = decode, + encode = encode, + acceptKey = acceptKey, + handshake = handshake, + handleHandshake = handleHandshake, +} diff --git a/main.lua b/main.lua new file mode 100644 index 0000000..61fb931 --- /dev/null +++ b/main.lua @@ -0,0 +1,53 @@ +local openssl = require("openssl") +local digest = openssl.digest +local CryptoMath = require("bundle:/CryptoMath.lua") +local Base64URL = require("base64-url") +local QueryString = require("querystring") + +local function StringToSHA256(str) + return digest.digest("sha256", str, true) +end + +local function StringToBase64URL(str) + local url = openssl.base64(str) + return Base64URL.escape(url) +end + +local function Base64URLToString(str) + local url = Base64URL.unescape(str) + return openssl.base64(url, false) +end + +local urlTemplate = "%s?client_id=%s^&response_type=%s^&redirect_uri=%s^&code_challenge_method=%s^&code_challenge=%s^&scope=%s" + +local function CreateAuthURI(host, clientId, responseType, redirectUri, codeChallengeMethod, codeChallenge, scope) + return string.format(urlTemplate, host, clientId, responseType, redirectUri, codeChallengeMethod, codeChallenge, scope) +end + +local str = CryptoMath.RandomString(128) + +print("Random: "..str) +local hashedStr = StringToSHA256(str) +print("SHA256: "..hashedStr) + +print("Base64: "..openssl.base64(hashedStr)) + +local base64HashedStr = StringToBase64URL(hashedStr) +print("Base64URL: "..base64HashedStr) + +local clientId = "95565900c1c84bdd813b4a4d48a68c08" + +local authURL = CreateAuthURI( + "https://accounts.spotify.com/authorize", + clientId, + "code", + QueryString.urlencode("http://localhost:8080/"), + "S256", + base64HashedStr, + QueryString.urlencode("user-modify-playback-state user-read-currently-playing user-read-playback-state") +) + +os.execute("start "..authURL) + +local APIServer = require("bundle:/SpotifyAPIServer.lua") +APIServer:Start(clientId, str, "http://localhost:8080/") \ No newline at end of file diff --git a/package.lua b/package.lua new file mode 100644 index 0000000..7cd81b8 --- /dev/null +++ b/package.lua @@ -0,0 +1,20 @@ + return { + name = "studio-spotify-server", + version = "0.1.0", + description = "The intermediary server between Roblox and the Spotify API", + tags = { "luvit", "roblox-studio", "spotify-api", "server" }, + license = "GNU GPLv3", + author = { name = "Filip", email = "filip@masken8.com" }, + homepage = "https://github.com/studio-spotify-server", + dependencies = { + "james2doyle/base64-url", + "creationix/coro-http", + "creationix/weblit", + "luvit/secure-socket" + }, + files = { + "**.lua", + "!test*" + } + } + \ No newline at end of file diff --git a/selene.toml b/selene.toml new file mode 100644 index 0000000..48f2cc6 --- /dev/null +++ b/selene.toml @@ -0,0 +1 @@ +std = "lua51" \ No newline at end of file