diff options
author | Blake DeMarcy <ofunknowndescent@gmail.com> | 2017-04-02 02:35:58 -0500 |
---|---|---|
committer | Blake DeMarcy <ofunknowndescent@gmail.com> | 2017-04-02 02:35:58 -0500 |
commit | 26b6dc190733facb01edbb32d3454b4824bac4bc (patch) | |
tree | 6dc257da0ab984baea5d461205a004a8fd34de1c /src | |
parent | f9e4783f7544134bfeb7db5396d09c684a7560a9 (diff) | |
download | bbj-26b6dc190733facb01edbb32d3454b4824bac4bc.tar.gz |
initial commit of non-prototype
Diffstat (limited to 'src')
-rw-r--r-- | src/db.py | 637 | ||||
-rw-r--r-- | src/endpoints.py | 176 | ||||
-rw-r--r-- | src/exceptions.py | 54 | ||||
-rw-r--r-- | src/formatting.py | 68 | ||||
-rw-r--r-- | src/schema.py | 114 | ||||
-rw-r--r-- | src/server.py | 70 | ||||
-rw-r--r-- | src/utils.py | 30 |
7 files changed, 655 insertions, 494 deletions
@@ -1,178 +1,274 @@ -from src import formatting -from uuid import uuid1 +""" +This module contains all of the interaction with the SQLite database. It +doesnt hold a connection itself, rather, a connection is passed in as +an argument to all the functions and is maintained by CherryPy's threading +system. This is clunky but fuck it, it works. + +All post and thread data are stored in the database without formatting. +This is questionable, as it causes formatting to be reapplied with each +pull for the database. Im debating whether posts should be stored in all +4 formats, or if maybe a caching system should be used. + +The database, nor ANY part of the server, DOES NOT HANDLE PASSWORD HASHING! +Clients are responsible for creation of hashes and passwords should never +be sent unhashed. User registration and update endpoints will not accept +hashes that != 64 characters in length, as a basic measure to enforce the +use of sha256. +""" + +from src.exceptions import BBJParameterError, BBJUserError +from src.utils import ordered_keys, schema_values from src import schema +from uuid import uuid1 from time import time -from os import path +import pickle import json +import os + +anonymous = \ + ("anonymous", + "5430eeed859cad61d925097ec4f53246" + "1ccf1ab6b9802b09a313be1478a4d614") + # this is the hash for "anon" + +# if os.path.exists("cache"): +# os.rmdir("cache") +# os.mkdir("cache") + +### THREADS ### + +def thread_get(connection, thread_id, messages=True): + """ + Fetch the thread_id from the database, and assign and format + all of its messages as requested. + + MESSAGES, if False, will omit the inclusion of a thread's messages + and only get its metadata, such as title, author, etc. + + FORMATTER should be a callable object who takes a body string + as it's only argument and returns an object to be sent in the + response. It isn't strictly necessary that it returns a string, + for example the entity parser will return an array with the + body string and another array with indices and types of objects + contained in it. + """ + c = connection.cursor() + c.execute("SELECT * FROM threads WHERE thread_id = ?", (thread_id,)) + thread = c.fetchone() -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) + if not thread: + raise BBJParameterError("Thread does not exist.") + thread = schema.thread(*thread) + + if messages: + c.execute("SELECT * FROM messages WHERE thread_id = ?", (thread_id,)) + # create a dictionary where each message is accessible by its + # integer post_id as a key + thread["messages"] = \ + {message["post_id"]: message for message in + [schema.message(*values) for values in c.fetchall()]} + + return thread + + +def thread_index(connection): + c = connection.cursor() + c.execute(""" + SELECT thread_id FROM threads + ORDER BY last_mod DESC""") + threads = [ + thread_get(connection, obj[0], messages=False) + for obj in c.fetchall() + ] + return threads + + +def thread_create(connection, author_id, body, title): + validate([ + ("body", body), + ("title", title) + ]) + + now = time() + thread_id = uuid1().hex + scheme = schema.thread( + thread_id, author_id, title, + now, now, -1) # see below for why i set -1 instead of 0 + + connection.cursor().execute(""" + INSERT INTO threads + VALUES (?,?,?,?,?,?) + """, schema_values("thread", scheme)) + connection.commit() + + scheme["messages"] = { + 0: thread_reply(connection, author_id, thread_id, body, time_override=now) + } + scheme["reply_count"] = 0 + # note that thread_reply returns a schema object + # after committing the new message to the database. + # here i mimic a real thread_get by including a mock + # message dictionary, and then setting the reply_count + # to reflect its new database value, so the response + # can be loaded as a normal thread object + return scheme -try: - with open(path.join(PATH, "userdb"), "r") as f: - USERDB = json.loads(f.read()) -except FileNotFoundError: - USERDB = dict(namemap=dict()) - with open(path.join(PATH, "userdb"), "w") as f: - f.write(json.dumps(USERDB)) - path.os.chmod(path.join(PATH, "userdb"), 0o600) +def thread_reply(connection, author_id, thread_id, body, time_override=None): + validate([("body", body)]) + now = time_override or time() + thread = thread_get(connection, thread_id, messages=False) + count = thread["reply_count"] + 1 + scheme = schema.message( + thread_id, count, author_id, + now, False, body) -### THREAD MANAGEMENT ### + c = connection.cursor() -def thread_index(key="lastmod", markup=True): - result = list() - for ID in path.os.listdir(path.join(PATH, "threads")): - thread = thread_load(ID, markup) - thread.pop("replies") - result.append(thread) - return sorted(result, key=lambda i: i[key], reverse=True) + c.execute(""" + INSERT INTO messages + VALUES (?,?,?,?,?,?) + """, schema_values("message", scheme)) + c.execute(""" + UPDATE threads SET + reply_count = ?, + last_mod = ? + WHERE thread_id = ? + """, (count, now, thread_id)) -def thread_create(author, body, title, tags): - ID = uuid1().hex - if tags: - tags = [tag.strip() for tag in tags.split(",")] - else: # make sure None, False, and empty arrays are always repped consistently - tags = [] - scheme = schema.thread(ID, author, body, title, tags) - thread_dump(ID, scheme) + connection.commit() return scheme -def thread_load(ID, markup=True): - 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 message_edit_query(connection, author, thread_id, post_id): + user = user_resolve(connection, author) + thread = thread_get(connection, thread_id) + try: message = thread["messages"][post_id] + except KeyError: + raise BBJParameterError("post_id out of bounds for requested thread") -def thread_reply(ID, author, body): - thread = thread_load(ID) - if not thread: - return schema.error(7, "Requested thread does not exist.") + if not user["admin"]: + if not user["user_id"] == message["author"]: + raise BBJUserError( + "non-admin attempt to edit another user's message") - thread["reply_count"] += 1 - thread["lastmod"] = time() + elif (time() - message["created"]) > 86400: + raise BBJUserError( + "message is too old to edit (24hr limit)") - if thread["replies"]: - lastpost = thread["replies"][-1]["post_id"] - else: - lastpost = 1 + return message - reply = schema.reply(lastpost + 1, author, body) - thread["replies"].append(reply) - thread_dump(ID, thread) - return reply +def message_edit_commit(connection, author_id, thread_id, post_id, new_body): + validate([("body", new_body)]) + message = message_edit_query(author_id, thread_id, post_id) + message["body"] = new_body + message["edited"] = True -def index_reply(reply_list, post_id): - for index, reply in enumerate(reply_list): - if reply["post_id"] == post_id: - return index - else: - raise IndexError + connection.cursor().excute(""" + UPDATE messages SET + body = ? edited = ? + WHERE + thread_id = ? AND post_id = ? + """, (new_body, True, thread_id, post_id)) + connection.commit() + return message -def edit_handler(json, thread=None): - try: - target_id = json["post_id"] - if not thread: - thread = thread_load(json["thread_id"]) - if not thread: - return False, schema.error(7, "Requested thread does not exist.") +### USERS #### - if target_id == 1: - target = thread - else: - target = thread["replies"][ - index_reply(thread["replies"], target_id)] - if not user_is_admin(json["user"]): - if json["user"] != target["author"]: - return False, schema.error(10, - "non-admin attempt to edit another user's message") +def user_register(connection, user_name, auth_hash): + """ + Registers a new user into the system. Ensures the user + is not already registered, and that the hash and name + meet the requirements of their respective sanity checks + """ + validate([ + ("user_name", user_name), + ("auth_hash", auth_hash) + ]) - elif (time() - target["created"]) > 86400: - return False, schema.error(9, - "message is too old to edit (24hr limit)") + if user_resolve(connection, user_name): + raise BBJUserError("Username already registered") - return True, target + scheme = schema.user_internal( + uuid1().hex, user_name, auth_hash, + "", "", 0, False, time()) - except IndexError: - return False, schema.error(3, "post_id out of bounds for requested thread") + connection.cursor().execute(""" + INSERT INTO users + VALUES (?,?,?,?,?,?,?,?) + """, schema_values("user", scheme)) + connection.commit() + return scheme -### USER MANAGEMENT ### -def user_dbdump(dictionary): - with open(path.join(PATH, "userdb"), "w") as f: - f.write(json.dumps(dictionary)) +def user_resolve(connection, name_or_id, externalize=False, return_false=True): + c = connection.cursor() + c.execute(""" + SELECT * FROM users + WHERE user_name = ? + OR user_id = ? + """, (name_or_id, name_or_id)) + user = c.fetchone() + if user: + user = schema.user_internal(*user) + if externalize: + return user_externalize(user) + return user -def user_resolve(name_or_id): - check = USERDB.get(name_or_id) - try: - if check: - return name_or_id - else: - return USERDB["namemap"][name_or_id] - except KeyError: + if return_false: return False + raise BBJParameterError( + "Requested user element ({})" + " does not exist".format(name_or_id)) -def user_register(auth_hash, name, quip, bio): - if USERDB["namemap"].get(name): - return schema.error(4, "Username taken.") - - for ok, error in [ - user_namecheck(name), - user_authcheck(auth_hash), - user_quipcheck(quip), - user_biocheck(bio)]: - - if not ok: - return error - - ID = uuid1().hex - scheme = schema.user_internal(ID, auth_hash, name, quip, bio, False) - USERDB.update({ID: scheme}) - USERDB["namemap"].update({name: ID}) - user_dbdump(USERDB) - return scheme - +def user_update(connection, user_object, parameters): + user_id = user_object["user_id"] + for key in ("user_name", "auth_hash", "quip", "bio", "color"): + value = parameters.get(key) + if value: + validate([(key, value)]) + user_object[key] = value -def user_get(ID): - user = USERDB[ID] - return schema.user_external( - ID, user["name"], user["quip"], - user["bio"], user["admin"]) + values = ordered_keys(user_object, + "user_name", "quip", "auth_hash", + "bio", "color", "user_id") + connection.cursor().execute(""" + UPDATE users SET + user_name = ?, quip = ?, + auth_hash = ?, bio = ?, + color = ? WHERE user_id = ? + """, values) -def user_auth(ID, auth_hash): - return auth_hash == USERDB[ID]["auth_hash"] + connection.commit() + return user_resolve(connection, user_id) -def user_is_admin(ID): - return USERDB[ID]["admin"] +def user_externalize(user_object): + """ + Cleanse private/internal data from a user object + and make it suitable to serve. + """ + # only secret value right now is the auth_hash, + # but this may change in the future + for key in ("auth_hash",): + user_object.pop(key) + return user_object -def user_update(ID, **params): - USERDB[ID].update(params) - return USERDB[ID] +def user_auth(auth_hash, user_object): + # nominating this for most useless function in the program + return auth_hash == user_object["auth_hash"] ### SANITY CHECKS ### @@ -181,60 +277,225 @@ def contains_nonspaces(string): return any([char in string for char in "\t\n\r\x0b\x0c"]) -def user_namecheck(name): - if not name: - return False, schema.error(4, - "Username may not be empty.") - - elif contains_nonspaces(name): - return False, schema.error(4, - "Username cannot contain whitespace chars besides spaces.") - - elif not name.strip(): - return False, schema.error(4, - "Username must contain at least one non-space character") - - - elif len(name) > 24: - return False, schema.error(4, - "Username is too long (max 24 chars)") - - return True, True - - -def user_authcheck(auth_hash): - if not auth_hash: - return False, schema.error(3, - "auth_hash may not be empty") - - elif len(auth_hash) != 64: - return False, schema.error(4, - "Client error: invalid SHA-256 hash.") - - return True, True - - -def user_quipcheck(quip): - if not quip: - return True, True - - elif contains_nonspaces(quip): - return False, schema.error(4, - "Quip cannot contain whitespace chars besides spaces.") - - elif len(quip) > 120: - return False, schema.error(4, - "Quip is too long (max 120 chars)") - - return True, True - - -def user_biocheck(bio): - if not bio: - return True, True - - elif len(bio) > 4096: - return False, schema.error(4, - "Bio is too long (max 4096 chars)") - - return True, True +def validate(keys_and_values): + """ + The line of defense against garbage user input. + + Recieves an iterable containing iterables, where [0] + is a string representing the value type, and [1] + is the value to compare against a set of rules for + it's type. The function returns the boolean value + True when everything is okay, or raises a BBJException + to be handled by higher levels of the program if something + is wrong (immediately stopping execution at the db level) + """ + for key, value in keys_and_values: + + if key == "user_name": + if not value: + raise BBJUserError( + "Username may not be empty.") + + elif contains_nonspaces(value): + raise BBJUserError( + "Username cannot contain whitespace chars besides spaces.") + + elif not value.strip(): + raise BBJUserError( + "Username must contain at least one non-space character") + + elif len(value) > 24: + raise BBJUserError( + "Username is too long (max 24 chars)") + + elif key == "auth_hash": + if not value: + raise BBJParameterError( + "auth_hash may not be empty") + + elif len(value) != 64: + raise BBJParameterError( + "Client error: invalid SHA-256 hash.") + + elif key == "quip": + if contains_nonspaces(value): + raise BBJUserError( + "Quip cannot contain whitespace chars besides spaces.") + + elif len(value) > 120: + raise BBJUserError( + "Quip is too long (max 120 chars)") + + elif key == "bio": + if len(value) > 4096: + raise BBJUserError( + "Bio is too long (max 4096 chars)") + + elif key == "title": + if not value: + raise BBJUserError( + "Title cannot be empty") + + elif contains_nonspaces(value): + raise BBJUserError( + "Titles cannot contain whitespace chars besides spaces.") + + elif len(value) > 120: + raise BBJUserError( + "Title is too long (max 120 chars)") + + elif key == "body": + if not value: + raise BBJUserError( + "Post body cannot be empty") + + + elif key == "color": + if color in range(0, 9): + continue + raise BBJParameterError( + "Color specification out of range (int 0-8)") + + return True + + +### OLD SHIT ### + +# def thread_index(key="lastmod", markup=True): +# result = list() +# for ID in path.os.listdir(path.join(PATH, "threads")): +# thread = thread_load(ID, markup) +# thread.pop("replies") +# result.append(thread) +# return sorted(result, key=lambda i: i[key], reverse=True) +# +# +# +# +# def thread_load(ID, markup=True): +# 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() +# +# if thread["replies"]: +# lastpost = thread["replies"][-1]["post_id"] +# else: +# lastpost = 1 +# +# reply = schema.reply(lastpost + 1, author, body) +# thread["replies"].append(reply) +# thread_dump(ID, thread) +# return reply +# +# +# def index_reply(reply_list, post_id): +# for index, reply in enumerate(reply_list): +# if reply["post_id"] == post_id: +# return index +# else: +# raise IndexError +# +# +# def edit_handler(json, thread=None): +# try: +# target_id = json["post_id"] +# if not thread: +# thread = thread_load(json["thread_id"]) +# if not thread: +# return False, schema.error(7, "Requested thread does not exist.") +# +# +# if target_id == 1: +# target = thread +# else: +# target = thread["replies"][ +# index_reply(thread["replies"], target_id)] +# +# if not user_is_admin(json["user"]): +# if json["user"] != target["author"]: +# return False, schema.error(10, +# "non-admin attempt to edit another user's message") +# +# elif (time() - target["created"]) > 86400: +# return False, schema.error(9, +# "message is too old to edit (24hr limit)") +# +# return True, target +# +# except IndexError: +# return False, schema.error(3, "post_id out of bounds for requested thread") +# +# +# ### 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["namemap"][name_or_id] +# except KeyError: +# return False +# +# +# def user_register(auth_hash, name, quip, bio): +# if USERDB["namemap"].get(name): +# return schema.error(4, "Username taken.") +# +# for ok, error in [ +# user_namecheck(name), +# user_authcheck(auth_hash), +# user_quipcheck(quip), +# user_biocheck(bio)]: +# +# if not ok: +# return error +# +# ID = uuid1().hex +# scheme = schema.user_internal(ID, auth_hash, name, quip, bio, False) +# USERDB.update({ID: scheme}) +# USERDB["namemap"].update({name: ID}) +# user_dbdump(USERDB) +# return scheme +# +# +# def user_get(ID): +# user = USERDB[ID] +# return schema.user_external( +# ID, user["name"], user["quip"], +# 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 index 973a528..f1b5913 100644 --- a/src/endpoints.py +++ b/src/endpoints.py @@ -1,174 +1,4 @@ -from src import formatting -from src import schema -from time import time -from src import db +from src import db, schema - -endpoints = { - "check_auth": ["user", "auth_hash"], - "is_registered": ["target_user"], - "is_admin": ["target_user"], - "thread_index": [], - "thread_load": ["thread_id"], - "thread_create": ["title", "body", "tags"], - "thread_reply": ["thread_id", "body"], - "edit_post": ["thread_id", "post_id", "body"], - "edit_query": ["thread_id", "post_id"], - "can_edit": ["thread_id", "post_id"], - "user_register": ["user", "auth_hash", "quip", "bio"], - "user_get": ["target_user"], - "user_name_to_id": ["target_user"] -} - - -authless = [ - "is_registered", - "user_register" -] - - -# this is not actually an endpoint, but produces a required -# element of thread responses. -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 user_name_to_id(json): - """ - Returns a string of the target_user's ID when it is - part of the database: a non-existent user will return - a boolean false. - """ - return db.user_resolve(json["target_user"]) - - -def is_registered(json): - """ - Returns true or false whether target_user is registered - in the system. This function only takes usernames: not - user IDs. - """ - return bool(db.USERDB["namemap"].get(json["target_user"])) - - -def check_auth(json): - "Returns true or false whether auth_hashes matches user." - return bool(db.user_auth(json["user"], json["auth_hash"])) - - -def is_admin(json): - """ - Returns true or false whether target_user is a system - administrator. Takes a username or user ID. Nonexistent - users return false. - """ - user = db.user_resolve(json["target_user"]) - if user: - return db.user_is_admin(user) - return False - - -def user_register(json): - """ - Registers a new user into the system. Returns the new internal user - object on success, or an error response. - - auth_hash should be a hexadecimal SHA-256 string, produced from a - UTF-8 password string. - - user should be a string containing no newlines and - under 24 characters in length. - - quip is a string, up to 120 characters, provided by the user - the acts as small bio, suitable for display next to posts - if the client wants to. Whitespace characters besides space - are not allowed. The string may be empty. - - bio is a string, up to 4096 chars, provided by the user that - can be shown on profiles. There are no character type limits - for this entry. The string may be empty. - - All errors for this endpoint with code 4 should show the - description direcrtly to the user. - - """ - - return schema.response( - db.user_register( - json["auth_hash"], - json["user"], - json["quip"], - json["bio"])) - - -def user_get(json): - """ - On success, returns an external user object for target_user (ID or name). - If the user isn't in the system, returns false. - """ - user = db.user_resolve(json["target_user"]) - if not user: - return False - return db.user_get(user) - - -def thread_index(json): - index = db.thread_index(markup=not json.get("nomarkup")) - return schema.response({"threads": index}, create_usermap(index, True)) - - -def thread_load(json): - thread = db.thread_load(json["thread_id"], not json.get("nomarkup")) - 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) - - -def thread_reply(json): - reply = db.thread_reply( - json["thread_id"], - json["user"], - json["body"]) - return schema.response(reply) - - -def edit_query(json): - return db.edit_handler(json)[1] - - -def can_edit(json): - return db.edit_handler(json)[0] - - -def edit_post(json): - thread = db.thread_load(json["thread_id"]) - admin = db.user_is_admin(json["user"]) - target_id = json["post_id"] - ok, obj = db.edit_handler(json, thread) - - if ok: - - if json.get("reformat"): - json["body"] = formatting.parse(json["body"]) - - obj["body"] = json["body"] - obj["lastmod"] = time() - obj["edited"] = True - db.thread_dump(json["thread_id"], thread) - - return obj +def user_register(user_name, auth_hash): + return db.user_register(user_name, auth_hash) diff --git a/src/exceptions.py b/src/exceptions.py new file mode 100644 index 0000000..a34e02c --- /dev/null +++ b/src/exceptions.py @@ -0,0 +1,54 @@ +from src.schema import error + + +class BBJException(Exception): + """ + Base class for all exceptions specific to BBJ. These also + hold schema error objects, reducing the amount of code + required to produce useful errors. + """ + def __init__(self, code, description): + self.schema = error(code, description) + self.description = description + self.code = code + + def __str__(self): + return self.description + + +class BBJParameterError(BBJException): + """ + This class of error holds code 3. This is a general + classification used to report errors on behalf of + the client. It covers malformed or missing parameter + values for endpoints, type errors, index errors, etc. + A complete client should not encounter these and the + descriptions are geared towards client developers + rather than users. + """ + def __init__(self, description): + super().__init__(3, description) + + +class BBJUserError(BBJException): + """ + This class of error holds code 4. Its description should + be shown verbatim in clients, as it deals with invalid user + actions rather than client or server errors. It is especially + useful during registration, and reporting lack of admin privs + when editing messages. + """ + def __init__(self, description): + super().__init__(4, description) + + +class BBJAuthError(BBJException): + """ + This class of error holds code 5. Similar to code 4, + these should be shown to users verbatim. Provided when: + + * a client tries to post without user/auth_hash pair + * the auth_hash does not match the given user + """ + def __init__(self, description): + super().__init__(5, description) diff --git a/src/formatting.py b/src/formatting.py index 56614c3..d327a24 100644 --- a/src/formatting.py +++ b/src/formatting.py @@ -2,27 +2,63 @@ from markdown import markdown from html import escape import re +colors = [ + "red", "green", "yellow", "blue", "magenta", "cyan" +] -COLORS = ["red", "green", "yellow", "blue", "magenta", "cyan"] -MARKUP = ["bold", "italic", "underline", "strike"] -TOKENS = re.compile(r"\[({}): (.+?)]".format("|".join(COLORS + MARKUP)), flags=re.DOTALL) -QUOTES = re.compile(">>([0-9]+)") -LINEQUOTES = re.compile("^(>.+)$", flags=re.MULTILINE) +markup = [ + "bold", "italic", "underline", "strike" +] +tokens = re.compile( + r"\[({}): (.+?)]".format( + "|".join(colors + markup)), + flags=re.DOTALL) +quotes = re.compile(">>([0-9]+)") +linequotes = re.compile("^(>.+)$", + flags=re.MULTILINE) + + +def apply_formatting(msg_obj, formatter): + """ + Receives a messages object from a thread and returns it with + all the message bodies passed through FORMATTER. + """ + for x in msg_obj["messages"].keys(): + msg_obj["messages"][x]["body"] = \ + formatter(msg_obj["messages"][x]["body"]) + return msg_obj + + +def raw(text): + """ + Just return the message in the same state that it was submitted. + """ + return text + + +def html(text): + """ + Returns messages in html format, after being sent through markdown. + Color directives are given as: + <span color="{COLOR}" style="color: {COLOR};">content</span> + + Directives may be nested. If you don't have access to a fully featured + and compliant html renderer in your client, you should use one of the + simpler directives like strip, indice, or raw. + """ + + text = TOKENS.sub(map_html, escape(text)) + text = QUOTES.sub(r'<span post="\1" class="quote">\g<0></span>', text) + return markdown( + LINEQUOTES.sub(r'<span class="linequote">\1</span><br>', text)) + +# and this is the callback used by the sub statement def map_html(match): directive, body = match.group(1).lower(), match.group(2) - if directive in COLORS: + if directive in colors: return '<span color="{0}" style="color: {0};">{1}</span>'.format(directive, body) - elif directive in MARKUP: + elif directive in markup: return '<{0}>{1}</{0}>'.format(directive[0], body) return body - - -def parse(text, doquotes=True): - text = TOKENS.sub(map_html, escape(text)) - if doquotes: - text = QUOTES.sub(r'<span post="\1" class="quote">\g<0></span>', text) - return markdown( - LINEQUOTES.sub(r'<span class="linequote">\1</span><br>', text) - ) diff --git a/src/schema.py b/src/schema.py index 8e3ca35..b376d53 100644 --- a/src/schema.py +++ b/src/schema.py @@ -1,18 +1,12 @@ -from src import formatting -from time import time - - def base(): return { "error": False } -def response(dictionary, usermap=None): +def response(dictionary): result = base() result.update(dictionary) - if usermap: - result["usermap"] = usermap return result @@ -27,71 +21,97 @@ def error(code, description): return result -def user_internal(ID, auth_hash, name, quip, bio, admin): +def user_internal( + user_id, # string (uuid1) + user_name, # string + auth_hash, # string (sha256 hash) + quip, # string (possibly empty) + bio, # string (possibly empty) + color, # int from 0 to 8 + is_admin, # bool (supply as either False/True or 0/1) + created): # floating point unix timestamp (when user registered) + if not quip: quip = "" if not bio: bio = "" + if not color: + color = 0 + return { - "user_id": ID, # string - "quip": quip, # (possibly empty) string - "bio": bio, # (possibly empty) string - "name": name, # string - "admin": admin, # boolean - "auth_hash": auth_hash # SHA256 string + "user_id": user_id, + "user_name": user_name, + "auth_hash": auth_hash, + "quip": quip, + "bio": bio, + "color": color, + "is_admin": bool(is_admin), + "created": created } -def user_external(ID, name, quip, bio, admin): +def user_external( + user_id, # string (uuid1) + user_name, # string + quip, # string (possibly empty) + bio, # string (possibly empty) + color, # int from 0 to 8 + admin, # bool (can be supplied as False/True or 0/1) + created): # floating point unix timestamp (when user registered) + if not quip: quip = "" if not bio: bio = "" + if not color: + color = 0 + return { - "user_id": ID, # string - "quip": quip, # (possibly empty) string - "name": name, # string - "bio": bio, # string - "admin": admin # boolean + "user_id": user_id, + "user_name": user_name, + "quip": quip, + "bio": bio, + "color": color, + "is_admin": admin, + "created": created } -def thread(ID, author, body, title, tags): - if not tags: - tags = list() - - body = formatting.parse(body, doquotes=False) - now = time() +def thread( + thread_id, # uuid string + author, # string (uuid1, user.user_id) + title, # string + last_mod, # floating point unix timestamp (of last post or post edit) + created, # floating point unix timestamp (when thread was made) + reply_count): # integer (incremental, starting with 0) return { - "thread_id": ID, # string - "post_id": 1, # integer - "author": author, # string - "body": body, # string - "title": title, # string - "tags": tags, # (possibly empty) list of strings - "replies": list(), # (possibly empty) list of reply objects - "reply_count": 0, # integer - "edited": False, # boolean - "lastmod": now, # floating point unix timestamp - "created": now # floating point unix timestamp + "thread_id": thread_id, + "author": author, + "title": title, + "last_mod": last_mod, + "created": created, + "reply_count": reply_count, } -def reply(ID, author, body): - - body = formatting.parse(body) - now = time() +def message( + thread_id, # string (uuid1 of parent thread) + post_id, # integer (incrementing from 1) + author, # string (uuid1, user.user_id) + created, # floating point unix timestamp (when reply was posted) + edited, # bool + body): # string return { - "post_id": ID, # integer - "author": author, # string - "body": body, # string - "edited": False, # boolean - "lastmod": now, # floating point unix timestamp - "created": now # floating point unix timestamp + "thread_id": thread_id, + "post_id": post_id, + "author": author, + "created": created, + "edited": bool(edited), + "body": body } diff --git a/src/server.py b/src/server.py deleted file mode 100644 index 345cc68..0000000 --- a/src/server.py +++ /dev/null @@ -1,70 +0,0 @@ -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 - requested endpoint, or responds with error objects. - """ - - - def reply(self, obj): - self.wfile.write(bytes(json.dumps(obj), "utf8")) - - - def handle(self): - try: - request = json.loads(str(self.rfile.read(), "utf8")) - endpoint = request.get("method") - - if endpoint not in endpoints.endpoints: - return self.reply(schema.error(2, "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]]): - return self.reply(schema.error(3, "{} requires: {}".format( - endpoint, ", ".join(endpoints.endpoints[endpoint])))) - - elif endpoint not in endpoints.authless: - if not request.get("user"): - return self.reply(schema.error(4, "No username provided.")) - - user = db.user_resolve(request["user"]) - request["user"] = user - - if not user: - return self.reply(schema.error(5, "User not registered")) - - elif endpoint != "check_auth" and not \ - db.user_auth(user, request.get("auth_hash")): - return self.reply(schema.error(6, "Authorization failed.")) - - # post_ids are always returned as integers, but for callers who - # provide them as something else, try to convert them. - if isinstance(request.get("post_id"), (float, str)): - try: request["post_id"] = int(request["post_id"]) - except Exception: - return schema.error(3, "Non-numeric post_id") - - # exception handling is now passed to the endpoints; - # anything unhandled beyond here is a code 1 - self.reply(eval("endpoints." + endpoint)(request)) - - except json.decoder.JSONDecodeError as E: - return self.reply(schema.error(0, str(E))) - - except Exception as E: - return 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() diff --git a/src/utils.py b/src/utils.py new file mode 100644 index 0000000..4f4ad0b --- /dev/null +++ b/src/utils.py @@ -0,0 +1,30 @@ +from src import schema + +def ordered_keys(subscriptable_object, *keys): + """ + returns a list with the values for KEYS in the order KEYS are provided, + from SUBSCRIPTABLE_OBJECT. Useful for working with dictionaries when + parameter ordering is important. Used for sql transactions + """ + return tuple([subscriptable_object[key] for key in keys]) + + +def schema_values(scheme, obj): + """ + Returns the values in the database order for a given + schema. Used for sql transactions + """ + if scheme == "user": + return ordered_keys(obj, + "user_id", "user_name", "auth_hash", "quip", + "bio", "color", "is_admin", "created") + + elif scheme == "thread": + return ordered_keys(obj, + "thread_id", "author", "title", + "last_mod", "created", "reply_count") + + elif scheme == "message": + return ordered_keys(obj, + "thread_id", "post_id", "author", + "created", "edited", "body") |