2019-05-25 20:40:06 +00:00
|
|
|
#--depends-on commands
|
|
|
|
#--depends-on config
|
|
|
|
#--depends-on permissions
|
2018-10-05 22:16:34 +00:00
|
|
|
|
2019-10-04 11:39:36 +00:00
|
|
|
import binascii, http.server, json, os, socket, ssl, threading, urllib.parse
|
2018-10-12 17:07:23 +00:00
|
|
|
from src import ModuleManager, utils
|
2018-10-04 15:01:13 +00:00
|
|
|
|
2019-09-15 10:37:32 +00:00
|
|
|
DEFAULT_PORT = 5001
|
|
|
|
DEFAULT_PUBLIC_PORT = 5000
|
|
|
|
|
2019-09-10 12:38:25 +00:00
|
|
|
class Response(object):
|
|
|
|
def __init__(self, compact=False):
|
|
|
|
self._compact = compact
|
|
|
|
self._headers = {}
|
|
|
|
self._data = b""
|
|
|
|
self.code = 200
|
|
|
|
self.content_type = "text/plain"
|
|
|
|
def write(self, data):
|
|
|
|
self._data += data
|
|
|
|
def write_text(self, data):
|
|
|
|
self._data += data.encode("utf8")
|
|
|
|
def write_json(self, obj):
|
|
|
|
if self._compact:
|
2019-09-10 14:08:06 +00:00
|
|
|
data = json.dumps(obj, separators=(",", ":"))
|
2019-09-10 12:38:25 +00:00
|
|
|
else:
|
2019-09-10 12:39:59 +00:00
|
|
|
data = json.dumps(obj, sort_keys=True, indent=4)
|
2019-09-10 12:38:25 +00:00
|
|
|
self._data += data.encode("utf8")
|
|
|
|
|
|
|
|
def set_header(self, key: str, value: str):
|
|
|
|
self._headers[key] = value
|
|
|
|
def get_headers(self):
|
|
|
|
headers = {}
|
|
|
|
has_content_type = False
|
|
|
|
for key, value in self._headers.items():
|
|
|
|
if key.lower() == "content-type":
|
|
|
|
has_content_type = True
|
|
|
|
headers[key] = value
|
|
|
|
if not has_content_type:
|
|
|
|
headers["Content-Type"] = self.content_type
|
2019-09-11 10:00:55 +00:00
|
|
|
headers["Content-Length"] = len(self._data)
|
2019-09-10 12:38:25 +00:00
|
|
|
return headers
|
|
|
|
|
|
|
|
def get_data(self):
|
|
|
|
return self._data
|
|
|
|
|
2019-09-16 13:55:29 +00:00
|
|
|
_module = None
|
2018-10-04 15:01:13 +00:00
|
|
|
_bot = None
|
|
|
|
_events = None
|
2018-11-06 17:22:50 +00:00
|
|
|
_log = None
|
2018-10-04 15:01:13 +00:00
|
|
|
class Handler(http.server.BaseHTTPRequestHandler):
|
2018-10-04 16:10:05 +00:00
|
|
|
timeout = 10
|
2019-02-08 21:52:24 +00:00
|
|
|
|
|
|
|
def _path_data(self):
|
|
|
|
path = urllib.parse.urlparse(self.path).path
|
2018-10-05 21:49:06 +00:00
|
|
|
_, _, endpoint = path[1:].partition("/")
|
|
|
|
endpoint, _, args = endpoint.partition("/")
|
|
|
|
args = list(filter(None, args.split("/")))
|
2019-02-08 21:56:58 +00:00
|
|
|
return path, endpoint, args
|
2019-02-08 21:52:24 +00:00
|
|
|
|
|
|
|
def _url_params(self):
|
|
|
|
parsed = urllib.parse.urlparse(self.path)
|
2019-02-08 21:55:42 +00:00
|
|
|
query = urllib.parse.parse_qs(parsed.query)
|
2019-02-08 21:52:24 +00:00
|
|
|
return dict([(k, v[0]) for k, v in query.items()])
|
|
|
|
|
|
|
|
def _body(self):
|
|
|
|
content_length = int(self.headers.get("content-length", 0))
|
|
|
|
return self.rfile.read(content_length)
|
|
|
|
|
2019-09-10 12:38:25 +00:00
|
|
|
def _respond(self, response):
|
|
|
|
self.send_response(response.code)
|
|
|
|
for key, value in response.get_headers().items():
|
2019-02-08 22:04:39 +00:00
|
|
|
self.send_header(key, value)
|
|
|
|
self.end_headers()
|
2019-09-10 12:38:25 +00:00
|
|
|
self.wfile.write(response.get_data())
|
2019-02-08 22:04:39 +00:00
|
|
|
|
2019-09-10 12:55:06 +00:00
|
|
|
def _key_settings(self, key):
|
|
|
|
return _bot.get_setting("api-key-%s" % key, {})
|
|
|
|
def _minify_setting(self):
|
|
|
|
return _bot.get_setting("rest-api-minify", False)
|
2019-03-11 12:16:56 +00:00
|
|
|
|
2019-09-10 14:40:01 +00:00
|
|
|
def _url_for(self, headers):
|
2019-09-20 09:41:52 +00:00
|
|
|
return (lambda route, endpoint, args=[], get_params={}:
|
|
|
|
_module._url_for(route, endpoint, args, get_params,
|
2019-09-20 09:43:23 +00:00
|
|
|
headers.get("Host", None)))
|
2019-09-10 14:40:01 +00:00
|
|
|
|
2019-09-10 12:55:06 +00:00
|
|
|
def _handle(self, method, path, endpoint, args):
|
2018-12-08 09:00:12 +00:00
|
|
|
headers = utils.CaseInsensitiveDict(dict(self.headers.items()))
|
2019-02-08 21:52:24 +00:00
|
|
|
params = self._url_params()
|
|
|
|
data = self._body()
|
2018-10-04 15:01:13 +00:00
|
|
|
|
2019-09-10 12:55:06 +00:00
|
|
|
response = Response(compact=self._minify_setting())
|
2019-09-10 12:38:25 +00:00
|
|
|
response.code = 404
|
2018-10-04 16:59:24 +00:00
|
|
|
|
2018-10-05 21:49:06 +00:00
|
|
|
hooks = _events.on("api").on(method).on(endpoint).get_hooks()
|
|
|
|
if hooks:
|
2019-09-10 12:45:14 +00:00
|
|
|
response.code = 200
|
2018-10-05 21:49:06 +00:00
|
|
|
hook = hooks[0]
|
|
|
|
authenticated = hook.get_kwarg("authenticated", True)
|
|
|
|
key = params.get("key", None)
|
2019-09-10 12:55:06 +00:00
|
|
|
key_setting = self._key_settings(key)
|
2018-11-12 18:18:07 +00:00
|
|
|
permissions = key_setting.get("permissions", [])
|
2018-11-10 21:54:08 +00:00
|
|
|
|
2019-01-23 22:08:26 +00:00
|
|
|
if key_setting:
|
2019-01-23 22:23:21 +00:00
|
|
|
_log.debug("[HTTP] %s from API key %s (%s)",
|
2019-01-23 22:10:32 +00:00
|
|
|
[method, key, key_setting["comment"]])
|
2019-01-23 22:08:26 +00:00
|
|
|
|
2018-11-11 08:53:37 +00:00
|
|
|
if not authenticated or path in permissions or "*" in permissions:
|
2018-10-05 21:49:06 +00:00
|
|
|
if path.startswith("/api/"):
|
2018-10-06 08:24:43 +00:00
|
|
|
event_response = None
|
2018-10-06 08:22:11 +00:00
|
|
|
try:
|
2019-09-10 12:55:06 +00:00
|
|
|
event_response = _events.on("api").on(method).on(
|
2019-06-26 10:04:41 +00:00
|
|
|
endpoint).call_for_result_unsafe(params=params,
|
2019-09-20 09:48:24 +00:00
|
|
|
args=args, data=data, headers=headers,
|
2019-09-10 14:40:01 +00:00
|
|
|
response=response, url_for=self._url_for(headers))
|
2018-11-06 17:22:50 +00:00
|
|
|
except Exception as e:
|
|
|
|
_log.error("failed to call API endpoint \"%s\"",
|
|
|
|
[path], exc_info=True)
|
2019-09-10 12:38:25 +00:00
|
|
|
response.code = 500
|
2018-10-04 15:01:13 +00:00
|
|
|
|
2018-11-06 14:09:13 +00:00
|
|
|
if not event_response == None:
|
2019-09-10 12:38:25 +00:00
|
|
|
response.write_json(event_response)
|
|
|
|
response.content_type = "application/json"
|
2018-11-10 21:54:08 +00:00
|
|
|
else:
|
2019-09-10 12:38:25 +00:00
|
|
|
response.code = 401
|
2019-09-10 12:55:06 +00:00
|
|
|
return response
|
|
|
|
|
|
|
|
def _handle_wrap(self, method):
|
|
|
|
path, endpoint, args = self._path_data()
|
|
|
|
_log.debug("[HTTP] starting _handle for %s from %s:%d: %s",
|
|
|
|
[method, self.client_address[0], self.client_address[1], path])
|
2019-02-08 22:04:39 +00:00
|
|
|
|
2019-09-10 12:55:06 +00:00
|
|
|
response = _bot.trigger(lambda: self._handle(method, path, endpoint,
|
|
|
|
args))
|
2019-09-10 12:38:25 +00:00
|
|
|
self._respond(response)
|
2019-02-08 22:04:39 +00:00
|
|
|
|
2019-03-13 13:33:53 +00:00
|
|
|
_log.debug("[HTTP] finishing _handle for %s from %s:%d (%d)",
|
2019-09-10 12:45:55 +00:00
|
|
|
[method, self.client_address[0], self.client_address[1],
|
|
|
|
response.code])
|
2018-10-04 15:01:13 +00:00
|
|
|
|
2018-10-05 21:49:06 +00:00
|
|
|
def do_GET(self):
|
2019-09-10 12:55:06 +00:00
|
|
|
self._handle_wrap("GET")
|
2018-10-05 21:49:06 +00:00
|
|
|
|
|
|
|
def do_POST(self):
|
2019-09-10 12:55:06 +00:00
|
|
|
self._handle_wrap("POST")
|
2018-10-05 21:49:06 +00:00
|
|
|
|
2018-11-14 23:01:22 +00:00
|
|
|
def log_message(self, format, *args):
|
2019-03-11 12:16:56 +00:00
|
|
|
return
|
2018-11-14 23:01:22 +00:00
|
|
|
|
2019-08-13 11:58:23 +00:00
|
|
|
class BitBotIPv6HTTPd(http.server.HTTPServer):
|
|
|
|
address_family = socket.AF_INET6
|
|
|
|
|
2019-06-28 22:16:05 +00:00
|
|
|
@utils.export("botset",
|
|
|
|
utils.BoolSetting("rest-api", "Enable/disable REST API"))
|
|
|
|
@utils.export("botset",
|
2019-09-10 14:00:47 +00:00
|
|
|
utils.BoolSetting("rest-api-minify", "Enable/disable REST API minifying"))
|
2019-09-15 10:37:32 +00:00
|
|
|
@utils.export("botset",
|
|
|
|
utils.Setting("rest-api-host", "Public hostname:port for the REST API"))
|
2018-10-12 17:07:23 +00:00
|
|
|
class Module(ModuleManager.BaseModule):
|
2019-10-04 11:39:36 +00:00
|
|
|
_name = "REST"
|
|
|
|
|
2018-10-12 17:07:23 +00:00
|
|
|
def on_load(self):
|
2019-09-16 13:55:29 +00:00
|
|
|
global _module
|
|
|
|
_module = self
|
|
|
|
|
2018-10-04 15:01:13 +00:00
|
|
|
global _bot
|
2018-10-12 17:07:23 +00:00
|
|
|
_bot = self.bot
|
2018-10-04 15:01:13 +00:00
|
|
|
|
|
|
|
global _events
|
2018-10-12 17:07:23 +00:00
|
|
|
_events = self.events
|
2018-10-04 15:01:13 +00:00
|
|
|
|
2018-11-06 17:22:50 +00:00
|
|
|
global _log
|
|
|
|
_log = self.log
|
|
|
|
|
2018-12-08 08:56:47 +00:00
|
|
|
self.httpd = None
|
2018-10-12 17:07:23 +00:00
|
|
|
if self.bot.get_setting("rest-api", False):
|
2019-11-04 10:52:41 +00:00
|
|
|
self._start_httpd()
|
2019-02-10 12:38:53 +00:00
|
|
|
|
2019-11-04 10:52:41 +00:00
|
|
|
def _start_httpd(self):
|
|
|
|
port = int(self.bot.config.get("api-port", str(DEFAULT_PORT)))
|
|
|
|
self.httpd = BitBotIPv6HTTPd(("::1", port), Handler)
|
2018-10-04 15:01:13 +00:00
|
|
|
|
2019-11-04 10:52:41 +00:00
|
|
|
self.thread = threading.Thread(target=self.httpd.serve_forever)
|
|
|
|
self.thread.daemon = True
|
|
|
|
self.thread.start()
|
|
|
|
def _stop_httpd(self):
|
2018-12-08 08:56:47 +00:00
|
|
|
if self.httpd:
|
|
|
|
self.httpd.shutdown()
|
2018-10-04 16:09:35 +00:00
|
|
|
|
2019-11-04 10:52:41 +00:00
|
|
|
def on_resume(self):
|
|
|
|
self._start_httpd()
|
|
|
|
|
|
|
|
def unload(self):
|
|
|
|
self._stop_httpd()
|
|
|
|
def on_pause(self):
|
|
|
|
self._stop_httpd()
|
|
|
|
|
2019-10-04 11:39:36 +00:00
|
|
|
@utils.hook("received.command.apikey")
|
|
|
|
@utils.kwarg("private_only", True)
|
2020-02-05 16:40:15 +00:00
|
|
|
@utils.spec("!'list ?<alias>wordlower")
|
|
|
|
@utils.spec("!'add !<alias>wordlower ?<endpoints>words")
|
|
|
|
@utils.spec("!'remove !<alias>wordlower")
|
2019-10-18 14:17:04 +00:00
|
|
|
@utils.kwarg("permission", "apikey")
|
2019-10-04 11:39:36 +00:00
|
|
|
def apikey(self, event):
|
2020-02-05 16:40:15 +00:00
|
|
|
subcommand = event["spec"][0]
|
|
|
|
alias = event["spec"][1]
|
2019-10-04 11:39:36 +00:00
|
|
|
found = None
|
|
|
|
|
|
|
|
api_keys = {}
|
2019-10-07 11:46:52 +00:00
|
|
|
for key, value in self.bot.find_settings(prefix="api-key-"):
|
2019-10-04 11:39:36 +00:00
|
|
|
api_keys[key] = value
|
2020-02-05 16:40:15 +00:00
|
|
|
if alias and value["comment"].lower() == alias:
|
2019-10-04 11:39:36 +00:00
|
|
|
found = key
|
|
|
|
|
|
|
|
if subcommand == "list":
|
2020-02-05 16:40:15 +00:00
|
|
|
aliases = {v["comment"]: v for v in api_keys.values()}
|
|
|
|
if alias:
|
|
|
|
if not alias in aliases:
|
|
|
|
event["stderr"].write("API key '%s' not found" % alias)
|
|
|
|
event["stdout"].write("API key %s ('%s') can access: %s" %
|
|
|
|
(key, alias, " ".join(aliases[alias]["permissions"])))
|
|
|
|
else:
|
|
|
|
event["stdout"].write("API keys: %s"
|
|
|
|
% ", ".join(sorted(aliases.keys())))
|
2019-10-04 11:39:36 +00:00
|
|
|
elif subcommand == "add":
|
|
|
|
if found == None:
|
|
|
|
new_key = binascii.hexlify(os.urandom(16)).decode("ascii")
|
|
|
|
self.bot.set_setting("api-key-%s" % new_key, {
|
2020-02-05 16:40:15 +00:00
|
|
|
"comment": alias, "permissions": event["spec"][2] or []
|
2019-10-04 11:39:36 +00:00
|
|
|
})
|
|
|
|
event["stdout"].write("New API key '%s': %s" %
|
2020-02-05 16:40:15 +00:00
|
|
|
(alias, new_key))
|
2019-10-04 11:39:36 +00:00
|
|
|
else:
|
|
|
|
event["stderr"].write("API key alias '%s' already exists" %
|
|
|
|
alias)
|
|
|
|
elif subcommand == "remove":
|
|
|
|
if not len(event["args_split"]) > 1:
|
|
|
|
raise utils.EventError("Please provide a key alias to remove")
|
|
|
|
|
|
|
|
if not found == None:
|
|
|
|
self.bot.del_setting(found)
|
|
|
|
key = found.replace("api-key-", "", 1)
|
|
|
|
event["stdout"].write("Deleted API key %s ('%s')" %
|
|
|
|
(key, alias))
|
|
|
|
else:
|
|
|
|
event["stderr"].write("Count not find API key '%s'" % alias)
|
2019-09-16 13:55:29 +00:00
|
|
|
|
2020-02-19 17:29:10 +00:00
|
|
|
@utils.export("url-for")
|
2019-09-20 09:41:52 +00:00
|
|
|
def _url_for(self, route, endpoint, args=[], get_params={},
|
|
|
|
host_override=None):
|
2019-09-16 13:55:29 +00:00
|
|
|
host = host_override or self.bot.get_setting("rest-api-host", None)
|
|
|
|
|
|
|
|
host, _, port = host.partition(":")
|
|
|
|
if not port:
|
|
|
|
port = str(_bot.config.get("api-port", DEFAULT_PUBLIC_PORT))
|
|
|
|
host = "%s:%s" % (host, port)
|
|
|
|
|
|
|
|
if host:
|
2019-09-20 09:41:52 +00:00
|
|
|
args_str = ""
|
|
|
|
if args:
|
|
|
|
args_str = "/%s" % "/".join(args)
|
2019-09-16 13:55:29 +00:00
|
|
|
get_params_str = ""
|
|
|
|
if get_params:
|
|
|
|
get_params_str = "?%s" % urllib.parse.urlencode(get_params)
|
2019-09-20 09:41:52 +00:00
|
|
|
return "%s/%s/%s%s%s" % (host, route, endpoint, args_str,
|
|
|
|
get_params_str)
|
2019-09-16 13:55:29 +00:00
|
|
|
else:
|
|
|
|
return None
|