initial commit of non-prototype

pull/4/head
Blake DeMarcy 2017-04-02 02:35:58 -05:00
parent f9e4783f75
commit 26b6dc1907
23 changed files with 1682 additions and 431 deletions

1
.gitignore vendored 100644
View File

@ -0,0 +1 @@
/*.db

40
plan.org 100644
View File

@ -0,0 +1,40 @@
* running info
** port 7066
** http via flask
** endpoint specified by http point instead of a method param
* required endpoints
** old scheme
{
"check_auth": ["user", "auth_hash"],
"is_registered": ["target_user"],
"is_admin": ["target_user"],
"thread_index": [],
"thread_load": ["thread_id"],
"thread_create": ["title", "body"],
"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"]
}
** checking auth for user/hash pair (bool)
** checking if a user is registered (bool)
** checking is a user is an admin (bool)
** index of all threads sans replies
** load a thread by id
** create a thread
** reply to a thread
** edit a post within 24hrs (unless admin)
** query a postid with the current auth info to see if elegible (bool)
** register a new user into the system
** retrieve a full user object by name or ID
** retrieve a username for ID and vice versa
* authorization
** SHA256 passhash and username in headers
* what to do differently
** concurrency, sql oriented from the beginning
** dont tell anyone about it until its done
** http instead of socket ports
** start with formatting in mind, store it in plain and render accordingly on the fly

View File

@ -62,6 +62,7 @@
(local-set-key (kbd "c") 'bbj-compose) (local-set-key (kbd "c") 'bbj-compose)
(local-set-key (kbd "C-h SPC") 'bbj-pop-help) (local-set-key (kbd "C-h SPC") 'bbj-pop-help)
(local-set-key (kbd "?") 'bbj-pop-help)
(local-set-key (kbd "e") 'bbj-edit-post) (local-set-key (kbd "e") 'bbj-edit-post)
(local-set-key (kbd "C-c C-c") 'bbj-aux) (local-set-key (kbd "C-c C-c") 'bbj-aux)
(local-set-key (kbd "r") 'bbj-quote-current-post)) (local-set-key (kbd "r") 'bbj-quote-current-post))

View File

@ -0,0 +1,64 @@
from hashlib import sha256
import socket
import json
class BBJ:
def __init__(self, host, port):
self.host = host
self.port = port
self.username = None
self.auth_hash = None
def __call__(self, method, **params):
return self.request(method, **params)
def setuser(self, username, unhashed_password):
self.auth_hash = sha256(bytes(unhashed_password, "utf8")).hexdigest()
self.username = username
return self.auth_hash
def request(self, method, **params):
params["method"] = method
if not params.get("user") and self.username:
params["user"] = self.username
if not params.get("auth_hash") and self.auth_hash:
params["auth_hash"] = self.auth_hash
connection = socket.create_connection((self.host, self.port))
connection.sendall(bytes(json.dumps(params), "utf8"))
connection.shutdown(socket.SHUT_WR)
try:
buff, length = bytes(), 1
while length != 0:
recv = connection.recv(2048)
length = len(recv)
buff += recv
finally:
connection.close()
response = json.loads(str(buff, "utf8"))
if not isinstance(response, dict):
return response
error = response.get("error")
if not error:
return response
code, desc = error["code"], error["description"]
# tfw no qt3.14 python case switches
if error in (0, 1):
raise ChildProcessError("internal server error: " + desc)
elif error in (2, 3):
raise ChildProcessError(desc)
return response

View File

@ -0,0 +1,88 @@
from src import network
bbj = network.BBJ("192.168.1.137", 7066)
def geterr(obj):
"""
Returns false if there are no errors in a network response,
else a tuple of (code integer, description string)
"""
error = obj.get("error")
if not error:
return False
return (error["code"], error["description"])
def register_prompt(user, initial=True):
if initial:
print("Register for BBJ as {}?".format(user))
reply = input("(y[es], d[ifferent name], q[uit])> ").lower()
if reply.startswith("d"):
register_prompt(input("(Username)> "))
elif reply.startswith("q"):
exit("bye!")
def getpass(ok):
p1 = input(
"(Choose a password)> " if ok else \
"(Those didn't match. Try again)> ")
p2 = input("(Now type it one more time)> ")
return p1 if p1 == p2 else getpass(False)
# this method will sha256 it for us
bbj.setuser(user, getpass(True))
response = bbj("user_register", quip="", bio="")
error = geterr(response)
if error:
exit("Registration error: " + error[1])
return response
def login(user, ok=True):
if not bbj("is_registered", target_user=user):
register_prompt(user)
else:
bbj.setuser(user, input(
"(Password)> " if ok else \
"(Invalid password, try again)> "))
if not bbj("check_auth"):
login(user, ok=False)
return bbj("user_get", target_user=user)
# user = input("(BBJ Username)> ")
# if not bbj("is_registered", target_user=user):
login(input("(Username)> "))
import urwid
f = urwid.Frame(
urwid.ListBox(
urwid.SimpleFocusListWalker(
[urwid.Text(i["body"]) for i in bbj("thread_index")["threads"]]
)
)
)
t = urwid.Overlay(
f, urwid.SolidFill('!'),
align='center',
width=('relative', 80),
height=('relative', 80),
valign='middle'
)
loop = urwid.MainLoop(t)

View File

@ -0,0 +1 @@
../../network_client.py

View File

@ -0,0 +1,4 @@
import urwid
class PostBox(urwid.ListBox):
pass

240
prototype/src/db.py 100644
View File

@ -0,0 +1,240 @@
from src import formatting
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(namemap=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", 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_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)
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 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_is_admin(ID):
return USERDB[ID]["admin"]
def user_update(ID, **params):
USERDB[ID].update(params)
return USERDB[ID]
### SANITY CHECKS ###
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

View File

@ -0,0 +1,174 @@
from src import formatting
from src import schema
from time import time
from src import db
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

View File

@ -0,0 +1,28 @@
from markdown import markdown
from html import escape
import re
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)
def map_html(match):
directive, body = match.group(1).lower(), match.group(2)
if directive in COLORS:
return '<span color="{0}" style="color: {0};">{1}</span>'.format(directive, body)
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)
)

View File

@ -0,0 +1,97 @@
from src import formatting
from time import time
def base():
return {
"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, # string
"code": code # integer
}
})
return result
def user_internal(ID, auth_hash, name, quip, bio, admin):
if not quip:
quip = ""
if not bio:
bio = ""
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
}
def user_external(ID, name, quip, bio, admin):
if not quip:
quip = ""
if not bio:
bio = ""
return {
"user_id": ID, # string
"quip": quip, # (possibly empty) string
"name": name, # string
"bio": bio, # string
"admin": admin # boolean
}
def thread(ID, author, body, title, tags):
if not tags:
tags = list()
body = formatting.parse(body, doquotes=False)
now = time()
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
}
def reply(ID, author, body):
body = formatting.parse(body)
now = time()
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
}

35
schema.sql 100644
View File

@ -0,0 +1,35 @@
drop table if exists users;
drop table if exists threads;
drop table if exists messages;
create table users (
user_id text, -- string (uuid1)
user_name text, -- string
auth_hash text, -- string (sha256 hash)
quip text, -- string (possibly empty)
bio text, -- string (possibly empty)
color int, -- int (from 0 to 8)
is_admin int, -- bool
created real -- floating point unix timestamp (when this user registered)
);
create table threads (
thread_id text, -- uuid string
author text, -- string (uuid1, user.user_id)
title text, -- string
last_mod real, -- floating point unix timestamp (of last post or post edit)
created real, -- floating point unix timestamp (when thread was made)
reply_count int -- integer (incremental, starting with 0)
);
create table messages (
thread_id text, -- string (uuid1 of parent thread)
post_id int, -- integer (incrementing from 1)
author text, -- string (uuid1, user.user_id)
created real, -- floating point unix timestamp (when reply was posted)
edited int, -- bool
body text -- string
);

220
server.py 100644
View File

@ -0,0 +1,220 @@
from src.exceptions import BBJException, BBJParameterError
from src import db, schema, endpoints
from functools import wraps
import cherrypy
import sqlite3
import json
dbname = "data.sqlite"
with sqlite3.connect(dbname) as _c:
if not db.user_resolve(_c, "anonymous"):
db.user_register(_c, *db.anonymous)
# creates a database connection for each thread
def connect(_):
cherrypy.thread_data.db = sqlite3.connect(dbname)
cherrypy.engine.subscribe('start_thread', connect)
def bbjapi(function):
"""
A wrapper that handles encoding of objects and errors to a
standard format for the API, resolves and authorizes users
from header data, and prepares thread data to handle the
request.
In addition, all BBJException's will return their attached
schema, and unhandled exceptions return a code 1 error schema.
"""
@wraps(function)
def wrapper(*args, **kwargs):
headers = cherrypy.request.headers
username = headers.get("User")
auth = headers.get("Auth")
anon = False
if not username and not auth:
user = db.user_resolve(cherrypy.thread_data.db, "anonymous")
anon = True
elif not username or not auth:
return json.dumps(schema.error(5,
"User or Auth was given without the other."))
if not anon:
user = db.user_resolve(cherrypy.thread_data.db, username)
if not user:
return json.dumps(schema.error(4,
"Username is not registered."))
elif auth != user["auth_hash"]:
return json.dumps(schema.error(5,
"Invalid authorization key for user."))
cherrypy.thread_data.user = user
cherrypy.thread_data.anon = anon
try:
value = function(*args, **kwargs)
except BBJException as e:
value = e.schema
except Exception as e:
value = schema.error(1, str(e))
return json.dumps(value)
return wrapper
def create_usermap(connection, obj):
"""
Creates a mapping of all the user_ids that occur in OBJ to
their full user objects (names, profile info, etc). Can
be a thread_index or a messages object from one.
"""
if isinstance(obj, dict):
# this is a message object for a thread, unravel it
obj = [value for key, value in obj.items()]
return {
user_id: db.user_resolve(
connection,
user_id,
externalize=True,
return_false=False)
for user_id in {
item["author"] for item in obj
}
}
def validate(json, args):
"""
Ensure the json object contains all the keys needed to satisfy
its endpoint.
"""
for arg in args:
if arg not in json.keys():
raise BBJParameterError(
"Required parameter %s is "
"absent from the request." % arg)
APICONFIG = {
"/": {
"tools.response_headers.on": True,
"tools.response_headers.headers": [
("Content-Type", "application/json")
],
}
}
class API(object):
@bbjapi
@cherrypy.expose
def thread_index(self):
threads = db.thread_index(cherrypy.thread_data.db)
usermap = create_usermap(cherrypy.thread_data.db, threads)
return schema.response({
"data": threads,
"usermap": usermap
})
@bbjapi
@cherrypy.expose
@cherrypy.tools.json_in()
def thread_create(self):
args = cherrypy.request.json
validate(args, ["body", "title"])
thread = db.thread_create(
cherrypy.thread_data.db,
cherrypy.thread_data.user["user_id"],
args["body"], args["title"])
usermap = {
cherrypy.thread_data.user["user_id"]:
cherrypy.thread_data.user
}
return schema.response({
"data": thread,
"usermap": usermap
})
@bbjapi
@cherrypy.expose
@cherrypy.tools.json_in()
def thread_reply(self):
args = cherrypy.request.json
validate(args, ["thread_id", "body"])
return schema.response({
"data": db.thread_reply(
cherrypy.thread_data.db,
cherrypy.thread_data.user["user_id"],
args["thread_id"], args["body"])
})
@bbjapi
@cherrypy.expose
@cherrypy.tools.json_in()
def thread_load(self):
args = cherrypy.request.json
validate(args, ["thread_id"])
thread = db.thread_get(
cherrypy.thread_data.db,
args["thread_id"])
usermap = create_usermap(
cherrypy.thread_data.db,
thread["messages"])
return schema.response({
"data": thread,
"usermap": usermap
})
@bbjapi
@cherrypy.expose
@cherrypy.tools.json_in()
def user_register(self):
args = cherrypy.request.json
validate(args, ["user_name", "auth_hash"])
return schema.response({
"data": db.user_register(
cherrypy.thread_data.db,
args["user_name"], args["auth_hash"])
})
@bbjapi
@cherrypy.expose
@cherrypy.tools.json_in()
def edit_query(self):
args = cherrypy.request.json
validate(args, ["thread_id", "post_id"])
return schema.response({
"data": message_edit_query(
cherrypy.thread_data.db,
cherrypy.thread_data.user["user_id"],
args["thread_id"],
args["post_id"])
})
def run():
cherrypy.quickstart(API(), "/api")
if __name__ == "__main__":
print("wew")

27
setup.sh 100755
View File

@ -0,0 +1,27 @@
#!/bin/bash
set -e
if [[ $1 == --init ]]; then
sqlite3 bbj.db < schema.sql
echo cleared
exit
fi
DEPS=(
cherrypy
markdown
)
if [[ -z $1 ]]; then
cat << EOF
Pass the python interpreter to use for pip installation
(either a venv or a system interpreter)
EOF
exit
fi
$1 -m pip install ${DEPS[*]}
echo "Enter [i] to initialize a new database"
read CLEAR
[[ $CLEAR == "i" ]] && sqlite3 bbj.db < schema.sql

651
src/db.py
View File

@ -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 src import schema
from uuid import uuid1
from time import time from time import time
from os import path import pickle
import json import json
import os
PATH = "/home/desvox/bbj/" anonymous = \
("anonymous",
"5430eeed859cad61d925097ec4f53246"
"1ccf1ab6b9802b09a313be1478a4d614")
# this is the hash for "anon"
if not path.isdir(PATH): # if os.path.exists("cache"):
path.os.mkdir(PATH, mode=0o744) # os.rmdir("cache")
# os.mkdir("cache")
if not path.isdir(path.join(PATH, "threads")): ### THREADS ###
path.os.mkdir(path.join(PATH, "threads"), mode=0o744)
try: def thread_get(connection, thread_id, messages=True):
with open(path.join(PATH, "userdb"), "r") as f: """
USERDB = json.loads(f.read()) Fetch the thread_id from the database, and assign and format
all of its messages as requested.
except FileNotFoundError: MESSAGES, if False, will omit the inclusion of a thread's messages
USERDB = dict(namemap=dict()) and only get its metadata, such as title, author, etc.
with open(path.join(PATH, "userdb"), "w") as f:
f.write(json.dumps(USERDB))
path.os.chmod(path.join(PATH, "userdb"), 0o600)
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()
### THREAD MANAGEMENT ###
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_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)
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 thread_reply(ID, author, body):
thread = thread_load(ID)
if not thread: if not thread:
return schema.error(7, "Requested thread does not exist.") raise BBJParameterError("Thread does not exist.")
thread = schema.thread(*thread)
thread["reply_count"] += 1 if messages:
thread["lastmod"] = time() 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()]}
if thread["replies"]: return thread
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): def thread_index(connection):
for index, reply in enumerate(reply_list): c = connection.cursor()
if reply["post_id"] == post_id: c.execute("""
return index SELECT thread_id FROM threads
else: ORDER BY last_mod DESC""")
raise IndexError threads = [
thread_get(connection, obj[0], messages=False)
for obj in c.fetchall()
]
return threads
def edit_handler(json, thread=None): def thread_create(connection, author_id, body, title):
try: validate([
target_id = json["post_id"] ("body", body),
if not thread: ("title", title)
thread = thread_load(json["thread_id"]) ])
if not thread:
return False, schema.error(7, "Requested thread does not exist.")
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
if target_id == 1: connection.cursor().execute("""
target = thread INSERT INTO threads
else: VALUES (?,?,?,?,?,?)
target = thread["replies"][ """, schema_values("thread", scheme))
index_reply(thread["replies"], target_id)] connection.commit()
if not user_is_admin(json["user"]): scheme["messages"] = {
if json["user"] != target["author"]: 0: thread_reply(connection, author_id, thread_id, body, time_override=now)
return False, schema.error(10, }
"non-admin attempt to edit another user's message") scheme["reply_count"] = 0
# note that thread_reply returns a schema object
elif (time() - target["created"]) > 86400: # after committing the new message to the database.
return False, schema.error(9, # here i mimic a real thread_get by including a mock
"message is too old to edit (24hr limit)") # message dictionary, and then setting the reply_count
# to reflect its new database value, so the response
return True, target # can be loaded as a normal thread object
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 return scheme
def user_get(ID): def thread_reply(connection, author_id, thread_id, body, time_override=None):
user = USERDB[ID] validate([("body", body)])
return schema.user_external(
ID, user["name"], user["quip"], now = time_override or time()
user["bio"], user["admin"]) thread = thread_get(connection, thread_id, messages=False)
count = thread["reply_count"] + 1
scheme = schema.message(
thread_id, count, author_id,
now, False, body)
c = connection.cursor()
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))
connection.commit()
return scheme
def user_auth(ID, auth_hash): def message_edit_query(connection, author, thread_id, post_id):
return auth_hash == USERDB[ID]["auth_hash"] 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")
if not user["admin"]:
if not user["user_id"] == message["author"]:
raise BBJUserError(
"non-admin attempt to edit another user's message")
elif (time() - message["created"]) > 86400:
raise BBJUserError(
"message is too old to edit (24hr limit)")
return message
def user_is_admin(ID): def message_edit_commit(connection, author_id, thread_id, post_id, new_body):
return USERDB[ID]["admin"] validate([("body", new_body)])
message = message_edit_query(author_id, thread_id, post_id)
message["body"] = new_body
message["edited"] = True
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 user_update(ID, **params): ### USERS ####
USERDB[ID].update(params)
return USERDB[ID]
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)
])
if user_resolve(connection, user_name):
raise BBJUserError("Username already registered")
scheme = schema.user_internal(
uuid1().hex, user_name, auth_hash,
"", "", 0, False, time())
connection.cursor().execute("""
INSERT INTO users
VALUES (?,?,?,?,?,?,?,?)
""", schema_values("user", scheme))
connection.commit()
return scheme
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
if return_false:
return False
raise BBJParameterError(
"Requested user element ({})"
" does not exist".format(name_or_id))
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
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)
connection.commit()
return user_resolve(connection, user_id)
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_auth(auth_hash, user_object):
# nominating this for most useless function in the program
return auth_hash == user_object["auth_hash"]
### SANITY CHECKS ### ### SANITY CHECKS ###
@ -181,60 +277,225 @@ def contains_nonspaces(string):
return any([char in string for char in "\t\n\r\x0b\x0c"]) return any([char in string for char in "\t\n\r\x0b\x0c"])
def user_namecheck(name): def validate(keys_and_values):
if not name: """
return False, schema.error(4, The line of defense against garbage user input.
"Username may not be empty.")
elif contains_nonspaces(name): Recieves an iterable containing iterables, where [0]
return False, schema.error(4, is a string representing the value type, and [1]
"Username cannot contain whitespace chars besides spaces.") 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:
elif not name.strip(): if key == "user_name":
return False, schema.error(4, if not value:
"Username must contain at least one non-space character") 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 len(name) > 24: elif key == "color":
return False, schema.error(4, if color in range(0, 9):
"Username is too long (max 24 chars)") continue
raise BBJParameterError(
"Color specification out of range (int 0-8)")
return True, True return True
def user_authcheck(auth_hash): ### OLD SHIT ###
if not auth_hash:
return False, schema.error(3,
"auth_hash may not be empty")
elif len(auth_hash) != 64: # def thread_index(key="lastmod", markup=True):
return False, schema.error(4, # result = list()
"Client error: invalid SHA-256 hash.") # for ID in path.os.listdir(path.join(PATH, "threads")):
# thread = thread_load(ID, markup)
return True, True # thread.pop("replies")
# result.append(thread)
# return sorted(result, key=lambda i: i[key], reverse=True)
def user_quipcheck(quip): #
if not quip: #
return True, True #
#
elif contains_nonspaces(quip): # def thread_load(ID, markup=True):
return False, schema.error(4, # try:
"Quip cannot contain whitespace chars besides spaces.") # with open(path.join(PATH, "threads", ID), "r") as f:
# return json.loads(f.read())
elif len(quip) > 120: # except FileNotFoundError:
return False, schema.error(4, # return False
"Quip is too long (max 120 chars)") #
#
return True, True # def thread_dump(ID, obj):
# with open(path.join(PATH, "threads", ID), "w") as f:
# f.write(json.dumps(obj))
def user_biocheck(bio): #
if not bio: #
return True, True # def thread_reply(ID, author, body):
# thread = thread_load(ID)
elif len(bio) > 4096: # if not thread:
return False, schema.error(4, # return schema.error(7, "Requested thread does not exist.")
"Bio is too long (max 4096 chars)") #
# thread["reply_count"] += 1
return True, True # 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]
#
#

View File

@ -1,174 +1,4 @@
from src import formatting from src import db, schema
from src import schema
from time import time
from src import db
def user_register(user_name, auth_hash):
endpoints = { return db.user_register(user_name, auth_hash)
"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

54
src/exceptions.py 100644
View File

@ -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)

View File

@ -2,27 +2,63 @@ from markdown import markdown
from html import escape from html import escape
import re import re
colors = [
"red", "green", "yellow", "blue", "magenta", "cyan"
]
COLORS = ["red", "green", "yellow", "blue", "magenta", "cyan"] markup = [
MARKUP = ["bold", "italic", "underline", "strike"] "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) 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): def map_html(match):
directive, body = match.group(1).lower(), match.group(2) 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) 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 '<{0}>{1}</{0}>'.format(directive[0], body)
return 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)
)

View File

@ -1,18 +1,12 @@
from src import formatting
from time import time
def base(): def base():
return { return {
"error": False "error": False
} }
def response(dictionary, usermap=None): def response(dictionary):
result = base() result = base()
result.update(dictionary) result.update(dictionary)
if usermap:
result["usermap"] = usermap
return result return result
@ -27,71 +21,97 @@ def error(code, description):
return result 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: if not quip:
quip = "" quip = ""
if not bio: if not bio:
bio = "" bio = ""
if not color:
color = 0
return { return {
"user_id": ID, # string "user_id": user_id,
"quip": quip, # (possibly empty) string "user_name": user_name,
"bio": bio, # (possibly empty) string "auth_hash": auth_hash,
"name": name, # string "quip": quip,
"admin": admin, # boolean "bio": bio,
"auth_hash": auth_hash # SHA256 string "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: if not quip:
quip = "" quip = ""
if not bio: if not bio:
bio = "" bio = ""
if not color:
color = 0
return { return {
"user_id": ID, # string "user_id": user_id,
"quip": quip, # (possibly empty) string "user_name": user_name,
"name": name, # string "quip": quip,
"bio": bio, # string "bio": bio,
"admin": admin # boolean "color": color,
"is_admin": admin,
"created": created
} }
def thread(ID, author, body, title, tags): def thread(
if not tags: thread_id, # uuid string
tags = list() author, # string (uuid1, user.user_id)
title, # string
body = formatting.parse(body, doquotes=False) last_mod, # floating point unix timestamp (of last post or post edit)
now = time() created, # floating point unix timestamp (when thread was made)
reply_count): # integer (incremental, starting with 0)
return { return {
"thread_id": ID, # string "thread_id": thread_id,
"post_id": 1, # integer "author": author,
"author": author, # string "title": title,
"body": body, # string "last_mod": last_mod,
"title": title, # string "created": created,
"tags": tags, # (possibly empty) list of strings "reply_count": reply_count,
"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
} }
def reply(ID, author, body): def message(
thread_id, # string (uuid1 of parent thread)
body = formatting.parse(body) post_id, # integer (incrementing from 1)
now = time() author, # string (uuid1, user.user_id)
created, # floating point unix timestamp (when reply was posted)
edited, # bool
body): # string
return { return {
"post_id": ID, # integer "thread_id": thread_id,
"author": author, # string "post_id": post_id,
"body": body, # string "author": author,
"edited": False, # boolean "created": created,
"lastmod": now, # floating point unix timestamp "edited": bool(edited),
"created": now # floating point unix timestamp "body": body
} }

30
src/utils.py 100644
View File

@ -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")