aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorBlake DeMarcy <ofunknowndescent@gmail.com>2017-04-02 02:35:58 -0500
committerBlake DeMarcy <ofunknowndescent@gmail.com>2017-04-02 02:35:58 -0500
commit26b6dc190733facb01edbb32d3454b4824bac4bc (patch)
tree6dc257da0ab984baea5d461205a004a8fd34de1c /src
parentf9e4783f7544134bfeb7db5396d09c684a7560a9 (diff)
downloadbbj-26b6dc190733facb01edbb32d3454b4824bac4bc.tar.gz
initial commit of non-prototype
Diffstat (limited to 'src')
-rw-r--r--src/db.py637
-rw-r--r--src/endpoints.py176
-rw-r--r--src/exceptions.py54
-rw-r--r--src/formatting.py68
-rw-r--r--src/schema.py114
-rw-r--r--src/server.py70
-rw-r--r--src/utils.py30
7 files changed, 655 insertions, 494 deletions
diff --git a/src/db.py b/src/db.py
index 0cbaf5a..93e37e6 100644
--- a/src/db.py
+++ b/src/db.py
@@ -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("&gt;&gt;([0-9]+)")
-LINEQUOTES = re.compile("^(&gt;.+)$", flags=re.MULTILINE)
+markup = [
+ "bold", "italic", "underline", "strike"
+]
+tokens = re.compile(
+ r"\[({}): (.+?)]".format(
+ "|".join(colors + markup)),
+ flags=re.DOTALL)
+quotes = re.compile("&gt;&gt;([0-9]+)")
+linequotes = re.compile("^(&gt;.+)$",
+ 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")
Un proyecto texto-plano.xyz