diff --git a/clients/network_client.py b/clients/network_client.py new file mode 100644 index 0000000..ec8e045 --- /dev/null +++ b/clients/network_client.py @@ -0,0 +1,206 @@ +import urllib.request as url +from hashlib import sha256 +import json + + +class BBJ: + """ + A python implementation to the BBJ api: all of its endpoints are + mapped to native methods, it maps error responses to exceptions, and + it includes helper functions for several common patterns. + + It should be noted that endpoints utilizing usermaps are returned as + tuples, where [0] is the value and [1] is the usermap dictionary. + Methods who do this will mention it in their documentation. + You can call them like `threads, usermap = bbj.thread_index()` + + __init__ can take a host string and a port value (which can be + either int or str). It defaults to "127.0.0.1" and 8080, expanding + out to http://127.0.0.1:8080/. + + Standard library exceptions are used, but several new attributes are + attached to them before raising: .code, .description, and .body. + code and description map the same values returned by the api. body + is the raw error object. Classes are mapped as follows: + + 0, 1, 2: ChildProcessError + 3: ValueError + 4: UserWarning + 5: ConnectionRefusedError + + attributes can be accessed as follows: + + try: + response = bbj.endpoint(): + except UserWarning as e: + assert e.code == 4 + print(e.description) + # want the raw error object? thats weird, but whatever. + return e.body + + See the offical API error documentation for more details. + """ + def __init__(self, host="127.0.0.1", port=8080): + self.base = "http://{}:{}/api/%s".format(host, port) + self.user_name = None + self.user_auth = None + self.send_auth = True + + + def __call__(self, *args, **kwargs): + return self.request(*args, **kwargs) + + + def request(self, endpoint, **params): + headers = {"Content-Type": "application/json"} + if params.get("no_auth"): + params.pop("no_auth") + + elif all([self.send_auth, self.user_name, self.user_auth]): + headers.update({"User": self.user_name, "Auth": self.user_auth}) + + data = bytes(json.dumps(params), "utf8") + request = url.Request( + self.base % endpoint, + data=data, + headers=headers) + + try: + with url.urlopen(request) as _r: + response = _r.read() + except url.HTTPError as e: + response = e.file.read() + value = json.loads(str(response, "utf8")) + + if value and value.get("error"): + self.raise_exception(value["error"]) + + return value + + + def raise_exception(self, error_object): + """ + Takes an API error object and raises the appropriate exception. + """ + description = error_object["description"] + code = error_object["code"] + if code in [0, 1, 2]: + e = ChildProcessError(description) + elif code == 3: + e = ValueError(description) + elif code == 4: + e = UserWarning(description) + elif code == 5: + e = ConnectionRefusedError(description) + e.code, e.description, e.body = code, description, error_object + raise e + + + def validate(self, key, value, exception=AssertionError): + """ + Uses the server's db_sanity_check method to verify the validty + of value by key. If it is invalid, kwarg exception (default + AssertionError) is raised with the exception containing the + attribute .description as the server's reason. + """ + response = self( + "db_sanity_check", + no_auth=True, + key=key, + value=value + ) + + if not response["data"]["bool"]: + description = response["data"]["description"] + error = exception(description) + error.description = description + raise error + + return True + + + def validate_all(self, keys_and_values, exception=AssertionError): + """ + Accepts an iterable of tuples, where in each, [0] is a key and + [1] a value to pass to validate. + """ + for key, value in keys_and_values: + self.validate(key, value, exception) + + + + def set_credentials(self, user_name, user_auth, hash_auth=True, check_validity=True): + """ + Internalizes user_name and user_auth. Unless hash_auth=False is + specified, user_auth is assumed to be an unhashed password + string and it gets hashed with sha256. If you want to handle + hashing yourself, make sure to disable that. + + Unless check_validity is set to false, the new credentials are + sent to the server and a ConnectionRefusedError is raised if + they do not match server authentication data. ValueError is + raised if the credentials contain illegal values, or the + specified user is not registered. + + On success, True is returned and the values are set. + """ + if hash_auth: + user_auth = sha256(bytes(user_auth, "utf8")).hexdigest() + + if check_validity and not self.validate_credentials(user_name, user_auth): + self.user_auth = None + self.user_name = None + raise ConnectionRefusedError("Auth and User do not match") + + self.user_auth = user_auth + self.user_name = user_name + return True + + + def validate_credentials(self, user_name, user_auth): + """ + Pings the server to check that user_name can be authenticated with + user_auth. Raises ConnectionRefusedError if they cannot. Raises + ValueError if the credentials contain illegal values. + """ + self.validate_all([ + ("user_name", user_name), + ("auth_hash", user_auth) + ], ValueError) + response = self("check_auth", + no_auth=True, + target_user=user_name, + target_hash=user_auth + ) + return response["data"] + + + def user_is_registered(self, user_name): + """ + Returns True or False whether user_name is registered + into the system. + """ + return self( + "user_is_registered", + no_auth=True, + target_user=user_name + )["data"] + + + def user_register(self, user_name, user_auth, hash_auth=True): + """ + Register user_name into the system with user_auth. Unless hash_auth + is set to false, user_auth should be a password string. + """ + pass + # return self( + + # ) + + def thread_index(self): + """ + Returns a tuple where [0] is a list of all threads ordered by + most recently interacted, and [1] is a usermap object. + """ + response = self("thread_index") + return response["data"], response["usermap"] diff --git a/documentation/ERRORS.md b/documentation/ERRORS.md new file mode 100644 index 0000000..5daa714 --- /dev/null +++ b/documentation/ERRORS.md @@ -0,0 +1,27 @@ +Errors in BBJ are separated into 6 different codes to help +ease handling a little bit. Errors are all or nothing, there +are no "warnings". If a response has a non-false error field, +then data will always be null. An error response from the api +looks like this... + +``` +{ + "error": { + "code": // an integer from 0 to 5, + "description": // a string describing the error in detail. + } + "data": null // ALWAYS null if error is not false + "usermap": {} // ALWAYS empty if error is not false +} +``` + +The codes split errors into a few categories. Some are oriented +to client developers while others should be shown directly to +users. + + * 0: Malformed but non-empty json input. An empty json input where it is required is handled by code 3. This is just decoding errors. The exception text is returned as description. + * 1: Internal server error. A short representation of the internal exception as well as the code the server logged it as is returned in the description. Your clients cannot recover from this class of error, and its probably not your fault if you encounter it. If you ever get one, file a bug report. + * 2: Server HTTP error: This is similar to the above but captures errors for the HTTP server rather than BBJs own codebase. The description contains the HTTP error code and server description. This notably covers 404s and thus invalid endpoint names. + * 3: Parameter error: client sent erroneous input for its method. This could mean missing arguments, type errors, etc. It generalizes errors that should be fixed by the client developer and the returned descriptions are geared to them rather than end users. + * 4: User error: These errors regard actions that the user has taken that are invalid, but not really errors in a traditional sense. The description field should be shown to users verbatim, in a clear and noticeable fashion. They are formatted as concise English sentences and end with appropriate punctuation marks. + * 5: Authorization error: This code represents an erroneous User/Auth header pair. This should trigger the user to provide correct credentials or fall back to anon mode. diff --git a/schema.sql b/schema.sql index 8afa657..336a63e 100644 --- a/schema.sql +++ b/schema.sql @@ -9,7 +9,7 @@ create table users ( auth_hash text, -- string (sha256 hash) quip text, -- string (possibly empty) bio text, -- string (possibly empty) - color int, -- int (from 0 to 8) + color int, -- int (from 0 to 6) is_admin int, -- bool created real -- floating point unix timestamp (when this user registered) ); diff --git a/server.py b/server.py index c93b564..0aceee1 100644 --- a/server.py +++ b/server.py @@ -44,8 +44,7 @@ def api_method(function): auth = cherrypy.request.headers.get("Auth") if (username and not auth) or (auth and not username): - return json.dumps(schema.error(5, - "User or Auth was given without the other.")) + raise BBJParameterError("User or Auth was given without the other.") elif not username and not auth: user = db.anon @@ -55,9 +54,8 @@ def api_method(function): if not user: raise BBJUserError("User %s is not registered" % username) - if auth != user["auth_hash"]: - return json.dumps(schema.error(5, - "Invalid authorization key for user.")) + elif auth != user["auth_hash"]: + raise BBJException(5, "Invalid authorization key for user.") # api_methods may choose to bind a usermap into the thread_data # which will send it off with the response @@ -342,23 +340,33 @@ class API(object): test.exposed = True -# user anonymity is achieved in the laziest possible way: a literal user -# named anonymous. may god have mercy on my soul. -_c = sqlite3.connect(dbname) -try: - db.anon = db.user_resolve(_c, "anonymous") - if not db.anon: - db.anon = db.user_register( - _c, "anonymous", # this is the hash for "anon" - "5430eeed859cad61d925097ec4f53246" - "1ccf1ab6b9802b09a313be1478a4d614") -finally: - _c.close() - del _c +def api_http_error(status, message, traceback, version): + return json.dumps(schema.error(2, "HTTP error {}: {}".format(status, message))) + + +CONFIG = { + "/": { + "error_page.default": api_http_error + } +} def run(): - cherrypy.quickstart(API(), "/api") + # user anonymity is achieved in the laziest possible way: a literal user + # named anonymous. may god have mercy on my soul. + _c = sqlite3.connect(dbname) + try: + db.anon = db.user_resolve(_c, "anonymous") + if not db.anon: + db.anon = db.user_register( + _c, "anonymous", # this is the hash for "anon" + "5430eeed859cad61d925097ec4f53246" + "1ccf1ab6b9802b09a313be1478a4d614") + finally: + _c.close() + del _c + + cherrypy.quickstart(API(), "/api", CONFIG) if __name__ == "__main__": diff --git a/src/db.py b/src/db.py index 5d53eb2..d41494b 100644 --- a/src/db.py +++ b/src/db.py @@ -365,9 +365,9 @@ def validate(keys_and_values): elif key == "color": - if color in range(0, 9): + if value in range(0, 6): continue raise BBJParameterError( - "Color specification out of range (int 0-8)") + "Color specification out of range (int 0-6)") return True diff --git a/src/schema.py b/src/schema.py index f2aadfb..a6b6d5f 100644 --- a/src/schema.py +++ b/src/schema.py @@ -67,7 +67,7 @@ def user_internal( auth_hash, # string (sha256 hash) quip, # string (possibly empty) bio, # string (possibly empty) - color, # int from 0 to 8 + color, # int from 0 to 6 is_admin, # bool (supply as either False/True or 0/1) created): # floating point unix timestamp (when user registered) @@ -97,7 +97,7 @@ def user_external( user_name, # string quip, # string (possibly empty) bio, # string (possibly empty) - color, # int from 0 to 8 + color, # int from 0 to 6 admin, # bool (can be supplied as False/True or 0/1) created): # floating point unix timestamp (when user registered)