commit d23c98e5e767cfb3406da939ead0010e6f97780d Author: Blake DeMarcy Date: Wed Mar 1 10:59:33 2017 -0600 hi diff --git a/apidoc b/apidoc new file mode 100644 index 0000000..ab36823 --- /dev/null +++ b/apidoc @@ -0,0 +1,162 @@ +Text Entities +------------- + +The `entities` attribute is an array of objects that represent blocks +of text within a post that have special properties. Clients may safely +ignore these things without losing too much meaning, but in a rich +application like an Emacs or GUI implementation, they can provide +some highlighting and navigation perks. The array object may be +empty. If its not, its populated with arrays representing the +modifications to be made. + +Objects **always** have a minimum of 3 attributes: +``` +["quote", 5, 7] +``` +object[0] is a string representing the attribute type. They are +documented below. The next two items are the indices of the +property in the body string. The way clients are to access these +indices is beyond the scope of this document; accessing a subsequence +varies a lot between programming languages. + +Some objects will provide further arguments beyond those 3. + +|--------------|----------------------------------------------------------| +| Name | Description | +|--------------|----------------------------------------------------------| +| `quote` | This is a string that refers to a previous post number. | +| | These are formatted like >>5, which means it is a | +| | reference to `post_id` 5. These are not processed in | +| | thread OPs. >>0 may be used to refer to the OP. | +|--------------|----------------------------------------------------------| +| `blockquote` | This is a block of text, denoted by a newline during | +| | composure, representing text that is assumed to be | +| | a quote of someone else. | +|--------------|----------------------------------------------------------| +| `color` | This is a block of text, denoted by [[color: body]] | +| | during composure. The body may span across newlines. | +| | A fourth item is provided in the array: it is one of the | +| | following strings representing the color. | +| | `red`, `green`, `yellow`, `blue`, `magenta`, or `cyan`. | +|--------------|----------------------------------------------------------| +| `bold` | Like color, except that no additional attribute is | +| | provided. it is denoted as [[bold: body]] during | +| | composure. | +|--------------|----------------------------------------------------------| + + + +Threads & Replies +----------------- + +Threads are represented the same when using `thread_index` and +`thread_load`, except that the `replies` attribute is only +present with `thread_load`. The following attributes are +available on the parent object: + +|---------------|------------------------------------------------------| +| Name | Description | +|---------------|------------------------------------------------------| +| `author` | The ID of the author | +|---------------|------------------------------------------------------| +| `thread_id` | The ID of the thread. | +|---------------|------------------------------------------------------| +| `title` | The title string of the thread | +|---------------|------------------------------------------------------| +| `body` | The body of the post's text. | +|---------------|------------------------------------------------------| +| `entities` | A (possibly empty) array of entity objects for | +| | the post `body`. | +|---------------|------------------------------------------------------| +| `tags` | An array of strings representing tags the | +| | author gave to the thread at creation. | +| | When empty, it is an array with no elements. | +|---------------|------------------------------------------------------| +| `replies` | An array containing full reply objects in | +| | the order they were posted. Your clients | +| | do not need to sort these. | +|---------------|------------------------------------------------------| +| `reply_count` | An integer representing the number of replies | +| | that have been posted in this thread. | +|---------------|------------------------------------------------------| +| `lastmod` | Unix timestamp of when the thread was last | +| | posted in, or a message was edited. | +|---------------|------------------------------------------------------| +| `edited` | Boolean of whether this post has been edited since | +| | it was made. | +|---------------|------------------------------------------------------| +| `created` | Unix timestamp of when the post was originally made. | +|---------------|------------------------------------------------------| + +The following attributes are available on each reply object in `replies`: + + +|------------|---------------------------------------------------------| +| Name | Description | +|------------|---------------------------------------------------------| +| `post_id` | An integer of the posts ID; unlike thread and user ids, | +| | this is not a uuid but instead is incremental, starting | +| | from 1 as the first reply and going up by one for each | +| | post. | +|------------|---------------------------------------------------------| +| `author` | Author ID | +|------------|---------------------------------------------------------| +| `body` | The body the reply's text. | +|------------|---------------------------------------------------------| +| `entities` | A (possibly empty) array of entity objects for | +| | the reply `body`. | +|------------|---------------------------------------------------------| +| `lastmod` | Unix timestamp of when the post was last edited, or | +| | the same as `created` if it never was. | +|------------|---------------------------------------------------------| +| `edited` | A boolean of whether the post was edited. | +|------------|---------------------------------------------------------| +| `created` | Unix timestamp of when the reply was originally posted. | +|------------|---------------------------------------------------------| + + +Errors +------ + +Errors are represented in the `error` field of the response. The error +field is always present, but is usually false. If its not false, it is +an object with the fields `code` and `description`. `code` is an integer +representing the type of failure, and `description` is a string describing +the problem. `description` is intended for human consumption; in your client +code, use the error codes to handle conditions. The `presentable` column +indicates whether the `description` should be shown to users verbatim. + +|------|--------------|--------------------------------------------------| +| Code | Presentable | Documentation | +|------|--------------|--------------------------------------------------| +| 0 | Never, fix | Malformed json input. `description` is the error | +| | your client | string thrown by the server-side json decoder. | +|------|--------------|--------------------------------------------------| +| 1 | Not a good | Internal server error. Unaltered exception text | +| | idea, the | is returned as `description`. This shouldn't | +| | exceptions | happen, and if it does, make a bug report. | +| | are not | clients should not attempt to intelligently | +| | helpful | recover from any errors of this class. | +|------|--------------|--------------------------------------------------| +| 2 | Nadda. | Unknown `method` was requested. | +|------|--------------|--------------------------------------------------| +| 3 | Fix. Your. | Missing or malformed parameter values for the | +| | Client. | requested `method`. | +|------|--------------|--------------------------------------------------| +| 4 | Only during | Invalid or unprovided `user`. During | +| | registration | registration, this code is returned if the name | +| | | is already occupied or contains illegal chars. | +|------|--------------|--------------------------------------------------| +| 5 | Always | `user` is not registered. | +|------|--------------|--------------------------------------------------| +| 6 | Always | User `auth_hash` failed or was not provided. | +|------|--------------|--------------------------------------------------| +| 7 | Always | Requested thread does not exist. | +|------|--------------|--------------------------------------------------| +| 8 | Always | Requested thread does not allow posts. | +|------|--------------|--------------------------------------------------| +| 9 | Always | Message edit failed; there is a 24hr limit for | +| | | editing posts. | +|------|--------------|--------------------------------------------------| +| 10 | Always | User action requires `admin` privilege. | +|------|--------------|--------------------------------------------------| diff --git a/bbj.el b/bbj.el new file mode 100644 index 0000000..b45d9d6 --- /dev/null +++ b/bbj.el @@ -0,0 +1,37 @@ +(require 'json) + +(defvar bbj:host "localhost") +(defvar bbj:port "7066") +(defvar bbj:logged-in nil) +(defvar bbj:user nil) +(defvar bbj:hash nil) + + +(defun bbj:request (&rest cells) + (with-temp-buffer + (insert (json-encode cells)) + (shell-command-on-region + (point-min) (point-max) + (format "nc %s %s" bbj:host bbj:port))) + (with-current-buffer "*Shell Command Output*" + (json-read-from-string + (buffer-substring-no-properties + (point-min) (point-max))))) + + +(bbj:request '(user . "desvox") + '(auth_hash . "nrr") + '(method . "check_auth")) + +(defun bbj:login () + (interactive) + (setq bbj:user (read-from-minibuffer "(BBJ Username)> ")) + (if (bbj:request '(method . "is_registered") + `(target_user . ,bbj:user)) + (setq bbj:hash (secure-hash 'sha256 (read-from-minibuffer "(BBJ Password)> "))) + (when (y-or-n-p (format "Register for BBJ as %s? " bbj:user)) + (message + (bbj:request (cons 'auth_hash bbj:hash) + (cons 'user bbj:user) + (cons 'avatar nil) + (cons 'bio (read-from-minibuffer "(Enter a short bio about youself!)> "))))))) diff --git a/main.py b/main.py new file mode 100644 index 0000000..40aa5e2 --- /dev/null +++ b/main.py @@ -0,0 +1,4 @@ +from src import schema +from src import server + +server.run("localhost", 7066) diff --git a/src/db.py b/src/db.py new file mode 100644 index 0000000..e9b1ee5 --- /dev/null +++ b/src/db.py @@ -0,0 +1,114 @@ +from uuid import uuid1 +from src import schema +from time import time +from os import path +import json + +PATH = "/home/desvox/bbj/" + +if not path.isdir(PATH): + path.os.mkdir(PATH, mode=0o744) + +if not path.isdir(path.join(PATH, "threads")): + path.os.mkdir(path.join(PATH, "threads"), mode=0o744) + +try: + with open(path.join(PATH, "userdb"), "r") as f: + USERDB = json.loads(f.read()) + +except FileNotFoundError: + USERDB = dict(mapname=dict()) + with open(path.join(PATH, "userdb"), "w") as f: + f.write(json.dumps(USERDB)) + path.os.chmod(path.join(PATH, "userdb"), 0o600) + +### THREAD MANAGEMENT ### + +def thread_index(key="lastmod"): + result = list() + for ID in path.os.listdir(path.join(PATH, "threads")): + thread = thread_load(ID) + thread.pop("replies") + result.append(thread) + return sorted(result, key=lambda i: i[key]) + + +def thread_create(author, body, title, tags): + ID = uuid1().hex + # make sure None, False, and empty arrays are always repped consistently + tags = tags if tags else [] + scheme = schema.thread(ID, author, body, title, tags) + thread_dump(ID, scheme) + return scheme + + +def thread_load(ID): + try: + with open(path.join(PATH, "threads", ID), "r") as f: + return json.loads(f.read()) + except FileNotFoundError: + return False + + +def thread_dump(ID, obj): + with open(path.join(PATH, "threads", ID), "w") as f: + f.write(json.dumps(obj)) + + +def thread_reply(ID, author, body): + thread = thread_load(ID) + if not thread: + return schema.error(7, "Requested thread does not exist.") + + thread["reply_count"] += 1 + thread["lastmod"] = time() + reply = schema.reply(thread["reply_count"], author, body) + thread["replies"].append(reply) + thread_dump(ID, thread) + return reply + + +### USER MANAGEMENT ### + +def user_dbdump(dictionary): + with open(path.join(PATH, "userdb"), "w") as f: + f.write(json.dumps(dictionary)) + + +def user_resolve(name_or_id): + check = USERDB.get(name_or_id) + try: + if check: + return name_or_id + else: + return USERDB["mapname"][name_or_id] + except KeyError: + return False + + +def user_register(auth_hash, name, avatar, bio): + if USERDB["mapname"].get(name): + return schema.error(4, "Username taken.") + + ID = uuid1().hex + scheme = schema.user_internal(ID, auth_hash, name, avatar, bio, False) + USERDB.update({ID: scheme}) + USERDB["mapname"].update({name: ID}) + user_dbdump(USERDB) + return scheme + + +def user_get(ID): + user = USERDB[ID] + return schema.user_external( + ID, user["name"], user["avatar"], + user["bio"], user["admin"]) + + +def user_auth(ID, auth_hash): + return auth_hash == USERDB[ID]["auth_hash"] + + +def user_update(ID, **params): + USERDB[ID].update(params) + return USERDB[ID] diff --git a/src/endpoints.py b/src/endpoints.py new file mode 100644 index 0000000..03235b0 --- /dev/null +++ b/src/endpoints.py @@ -0,0 +1,77 @@ +from src import schema +from src import db +from json import dumps + +endpoints = { + "check_auth": ["user", "auth_hash"], + "is_registered": ["target_user"], + "thread_load": ["thread_id"], + "thread_index": [], + "thread_create": ["title", "body", "tags"], + "thread_reply": ["thread_id", "body"], + "user_register": ["user", "auth_hash", "avatar", "bio"], + "user_get": ["target_user"], +} + + +authless = [ + "is_registered", + "user_register" +] + + +def create_usermap(thread, index=False): + if index: + return {user: db.user_get(user) for user in + {i["author"] for i in thread}} + + result = {reply["author"] for reply in thread["replies"]} + result.add(thread["author"]) + return {x: db.user_get(x) for x in result} + + +def is_registered(json): + return dumps(bool(db.USERDB["mapname"].get(json["target_user"]))) + + +def check_auth(json): + return dumps(bool(db.user_auth(json["user"], json["auth_hash"]))) + + +def user_register(json): + return schema.response( + db.user_register( + json["auth_hash"], + json["user"], + json["avatar"], + json["bio"])) + + +def thread_index(json): + index = db.thread_index() + return schema.response( + {"threads": index}, create_usermap(index, True)) + + +def thread_load(json): + thread = db.thread_load(json["thread_id"]) + if not thread: + return schema.error(7, "Requested thread does not exist") + return schema.response(thread, create_usermap(thread)) + + +def thread_create(json): + thread = db.thread_create( + json["user"], + json["body"], + json["title"], + json["tags"]) + return schema.response(thread, create_usermap(thread)) + + +def thread_reply(json): + thread = db.thread_reply( + json["thread_id"], + json["user"], + json["body"]) + return schema.response(thread) diff --git a/src/schema.py b/src/schema.py new file mode 100644 index 0000000..233ff22 --- /dev/null +++ b/src/schema.py @@ -0,0 +1,77 @@ +from time import time + +def base(): + return { + "usermap": {}, + "error": False + } + + +def response(dictionary, usermap=None): + result = base() + result.update(dictionary) + if usermap: + result["usermap"] = usermap + return result + + +def error(code, description): + result = base() + result.update({ + "error": { + "description": description, + "code": code + } + }) + return result + + +def user_internal(ID, auth_hash, name, avatar, bio, admin): + return { + "user_id": ID, + "avatar": avatar, + "name": name, + "bio": bio, + "admin": admin, + "auth_hash": auth_hash + } + + +def user_external(ID, name, avatar, bio, admin): + return { + "user_id": ID, + "avatar": avatar, + "name": name, + "bio": bio, + "admin": admin + } + + +def thread(ID, author, body, title, tags): + now = time() + return { + "thread_id": ID, + "author": author, + "body": body, + "entities": list(), + "title": title, + "tags": tags, + "replies": list(), + "reply_count": 0, + "lastmod": now, + "edited": False, + "created": now + } + + +def reply(ID, author, body): + now = time() + return { + "post_id": ID, + "author": author, + "body": body, + "entities": list(), + "lastmod": now, + "edited": False, + "created": now + } diff --git a/src/server.py b/src/server.py new file mode 100644 index 0000000..a7ce3dc --- /dev/null +++ b/src/server.py @@ -0,0 +1,78 @@ +from socketserver import StreamRequestHandler, TCPServer +from src import endpoints +from src import schema +from src import db +import json + + +class RequestHandler(StreamRequestHandler): + """ + Receieves and processes json input; dispatches input to the + approproate endpoint, or responds with error objects. + """ + + + def reply(self, dictionary): + self.wfile.write(bytes(json.dumps(dictionary), "utf8")) + + + def handle(self): + try: + request = json.loads(str(self.rfile.read(), "utf8")) + endpoint = request.get("method") + + if endpoint not in endpoints.endpoints: + raise IndexError("Invalid endpoint") + + # check to make sure all the arguments for endpoint are provided + elif any([key not in request for key in endpoints.endpoints[endpoint]]): + raise ValueError("{} requires: {}".format( + endpoint, ", ".join(endpoints.endpoints[endpoint]))) + + elif endpoint not in endpoints.authless: + if not request.get("user"): + raise ConnectionError("No username provided.") + + user = db.user_resolve(request["user"]) + request["user"] = user + + if not user: + raise ConnectionAbortedError("User not registered") + + elif endpoint != "check_auth" and not db.user_auth(user, request.get("auth_hash")): + raise ConnectionRefusedError("Authorization failed.") + + except json.decoder.JSONDecodeError as E: + return self.reply(schema.error(0, str(E))) + + except IndexError as E: + return self.reply(schema.error(2, str(E))) + + except ValueError as E: + return self.reply(schema.error(3, str(E))) + + except ConnectionError as E: + return self.reply(schema.error(4, str(E))) + + except ConnectionAbortedError as E: + return self.reply(schema.error(5, str(E))) + + except ConnectionRefusedError as E: + return self.reply(schema.error(6, str(E))) + + except Exception as E: + return self.reply(schema.error(1, str(E))) + + try: + self.reply(eval("endpoints." + endpoint)(request)) + except Exception as E: + self.reply(schema.error(1, str(E))) + + +def run(host, port): + server = TCPServer((host, port), RequestHandler) + try: + server.serve_forever() + except KeyboardInterrupt: + print("bye") + server.server_close()