pull/4/head
Blake DeMarcy 2017-03-01 10:59:33 -06:00
commit d23c98e5e7
7 changed files with 549 additions and 0 deletions

162
apidoc 100644
View File

@ -0,0 +1,162 @@
Text Entities
-------------
The `entities` attribute is an array of objects that represent blocks
of text within a post that have special properties. Clients may safely
ignore these things without losing too much meaning, but in a rich
application like an Emacs or GUI implementation, they can provide
some highlighting and navigation perks. The array object may be
empty. If its not, its populated with arrays representing the
modifications to be made.
Objects **always** have a minimum of 3 attributes:
```
["quote", 5, 7]
```
object[0] is a string representing the attribute type. They are
documented below. The next two items are the indices of the
property in the body string. The way clients are to access these
indices is beyond the scope of this document; accessing a subsequence
varies a lot between programming languages.
Some objects will provide further arguments beyond those 3.
|--------------|----------------------------------------------------------|
| Name | Description |
|--------------|----------------------------------------------------------|
| `quote` | This is a string that refers to a previous post number. |
| | These are formatted like >>5, which means it is a |
| | reference to `post_id` 5. These are not processed in |
| | thread OPs. >>0 may be used to refer to the OP. |
|--------------|----------------------------------------------------------|
| `blockquote` | This is a block of text, denoted by a newline during |
| | composure, representing text that is assumed to be |
| | a quote of someone else. |
|--------------|----------------------------------------------------------|
| `color` | This is a block of text, denoted by [[color: body]] |
| | during composure. The body may span across newlines. |
| | A fourth item is provided in the array: it is one of the |
| | following strings representing the color. |
| | `red`, `green`, `yellow`, `blue`, `magenta`, or `cyan`. |
|--------------|----------------------------------------------------------|
| `bold` | Like color, except that no additional attribute is |
| | provided. it is denoted as [[bold: body]] during |
| | composure. |
|--------------|----------------------------------------------------------|
Threads & Replies
-----------------
Threads are represented the same when using `thread_index` and
`thread_load`, except that the `replies` attribute is only
present with `thread_load`. The following attributes are
available on the parent object:
|---------------|------------------------------------------------------|
| Name | Description |
|---------------|------------------------------------------------------|
| `author` | The ID of the author |
|---------------|------------------------------------------------------|
| `thread_id` | The ID of the thread. |
|---------------|------------------------------------------------------|
| `title` | The title string of the thread |
|---------------|------------------------------------------------------|
| `body` | The body of the post's text. |
|---------------|------------------------------------------------------|
| `entities` | A (possibly empty) array of entity objects for |
| | the post `body`. |
|---------------|------------------------------------------------------|
| `tags` | An array of strings representing tags the |
| | author gave to the thread at creation. |
| | When empty, it is an array with no elements. |
|---------------|------------------------------------------------------|
| `replies` | An array containing full reply objects in |
| | the order they were posted. Your clients |
| | do not need to sort these. |
|---------------|------------------------------------------------------|
| `reply_count` | An integer representing the number of replies |
| | that have been posted in this thread. |
|---------------|------------------------------------------------------|
| `lastmod` | Unix timestamp of when the thread was last |
| | posted in, or a message was edited. |
|---------------|------------------------------------------------------|
| `edited` | Boolean of whether this post has been edited since |
| | it was made. |
|---------------|------------------------------------------------------|
| `created` | Unix timestamp of when the post was originally made. |
|---------------|------------------------------------------------------|
The following attributes are available on each reply object in `replies`:
|------------|---------------------------------------------------------|
| Name | Description |
|------------|---------------------------------------------------------|
| `post_id` | An integer of the posts ID; unlike thread and user ids, |
| | this is not a uuid but instead is incremental, starting |
| | from 1 as the first reply and going up by one for each |
| | post. |
|------------|---------------------------------------------------------|
| `author` | Author ID |
|------------|---------------------------------------------------------|
| `body` | The body the reply's text. |
|------------|---------------------------------------------------------|
| `entities` | A (possibly empty) array of entity objects for |
| | the reply `body`. |
|------------|---------------------------------------------------------|
| `lastmod` | Unix timestamp of when the post was last edited, or |
| | the same as `created` if it never was. |
|------------|---------------------------------------------------------|
| `edited` | A boolean of whether the post was edited. |
|------------|---------------------------------------------------------|
| `created` | Unix timestamp of when the reply was originally posted. |
|------------|---------------------------------------------------------|
Errors
------
Errors are represented in the `error` field of the response. The error
field is always present, but is usually false. If its not false, it is
an object with the fields `code` and `description`. `code` is an integer
representing the type of failure, and `description` is a string describing
the problem. `description` is intended for human consumption; in your client
code, use the error codes to handle conditions. The `presentable` column
indicates whether the `description` should be shown to users verbatim.
|------|--------------|--------------------------------------------------|
| Code | Presentable | Documentation |
|------|--------------|--------------------------------------------------|
| 0 | Never, fix | Malformed json input. `description` is the error |
| | your client | string thrown by the server-side json decoder. |
|------|--------------|--------------------------------------------------|
| 1 | Not a good | Internal server error. Unaltered exception text |
| | idea, the | is returned as `description`. This shouldn't |
| | exceptions | happen, and if it does, make a bug report. |
| | are not | clients should not attempt to intelligently |
| | helpful | recover from any errors of this class. |
|------|--------------|--------------------------------------------------|
| 2 | Nadda. | Unknown `method` was requested. |
|------|--------------|--------------------------------------------------|
| 3 | Fix. Your. | Missing or malformed parameter values for the |
| | Client. | requested `method`. |
|------|--------------|--------------------------------------------------|
| 4 | Only during | Invalid or unprovided `user`. During |
| | registration | registration, this code is returned if the name |
| | | is already occupied or contains illegal chars. |
|------|--------------|--------------------------------------------------|
| 5 | Always | `user` is not registered. |
|------|--------------|--------------------------------------------------|
| 6 | Always | User `auth_hash` failed or was not provided. |
|------|--------------|--------------------------------------------------|
| 7 | Always | Requested thread does not exist. |
|------|--------------|--------------------------------------------------|
| 8 | Always | Requested thread does not allow posts. |
|------|--------------|--------------------------------------------------|
| 9 | Always | Message edit failed; there is a 24hr limit for |
| | | editing posts. |
|------|--------------|--------------------------------------------------|
| 10 | Always | User action requires `admin` privilege. |
|------|--------------|--------------------------------------------------|

37
bbj.el 100644
View File

@ -0,0 +1,37 @@
(require 'json)
(defvar bbj:host "localhost")
(defvar bbj:port "7066")
(defvar bbj:logged-in nil)
(defvar bbj:user nil)
(defvar bbj:hash nil)
(defun bbj:request (&rest cells)
(with-temp-buffer
(insert (json-encode cells))
(shell-command-on-region
(point-min) (point-max)
(format "nc %s %s" bbj:host bbj:port)))
(with-current-buffer "*Shell Command Output*"
(json-read-from-string
(buffer-substring-no-properties
(point-min) (point-max)))))
(bbj:request '(user . "desvox")
'(auth_hash . "nrr")
'(method . "check_auth"))
(defun bbj:login ()
(interactive)
(setq bbj:user (read-from-minibuffer "(BBJ Username)> "))
(if (bbj:request '(method . "is_registered")
`(target_user . ,bbj:user))
(setq bbj:hash (secure-hash 'sha256 (read-from-minibuffer "(BBJ Password)> ")))
(when (y-or-n-p (format "Register for BBJ as %s? " bbj:user))
(message
(bbj:request (cons 'auth_hash bbj:hash)
(cons 'user bbj:user)
(cons 'avatar nil)
(cons 'bio (read-from-minibuffer "(Enter a short bio about youself!)> ")))))))

4
main.py 100644
View File

@ -0,0 +1,4 @@
from src import schema
from src import server
server.run("localhost", 7066)

114
src/db.py 100644
View File

@ -0,0 +1,114 @@
from uuid import uuid1
from src import schema
from time import time
from os import path
import json
PATH = "/home/desvox/bbj/"
if not path.isdir(PATH):
path.os.mkdir(PATH, mode=0o744)
if not path.isdir(path.join(PATH, "threads")):
path.os.mkdir(path.join(PATH, "threads"), mode=0o744)
try:
with open(path.join(PATH, "userdb"), "r") as f:
USERDB = json.loads(f.read())
except FileNotFoundError:
USERDB = dict(mapname=dict())
with open(path.join(PATH, "userdb"), "w") as f:
f.write(json.dumps(USERDB))
path.os.chmod(path.join(PATH, "userdb"), 0o600)
### THREAD MANAGEMENT ###
def thread_index(key="lastmod"):
result = list()
for ID in path.os.listdir(path.join(PATH, "threads")):
thread = thread_load(ID)
thread.pop("replies")
result.append(thread)
return sorted(result, key=lambda i: i[key])
def thread_create(author, body, title, tags):
ID = uuid1().hex
# make sure None, False, and empty arrays are always repped consistently
tags = tags if tags else []
scheme = schema.thread(ID, author, body, title, tags)
thread_dump(ID, scheme)
return scheme
def thread_load(ID):
try:
with open(path.join(PATH, "threads", ID), "r") as f:
return json.loads(f.read())
except FileNotFoundError:
return False
def thread_dump(ID, obj):
with open(path.join(PATH, "threads", ID), "w") as f:
f.write(json.dumps(obj))
def thread_reply(ID, author, body):
thread = thread_load(ID)
if not thread:
return schema.error(7, "Requested thread does not exist.")
thread["reply_count"] += 1
thread["lastmod"] = time()
reply = schema.reply(thread["reply_count"], author, body)
thread["replies"].append(reply)
thread_dump(ID, thread)
return reply
### USER MANAGEMENT ###
def user_dbdump(dictionary):
with open(path.join(PATH, "userdb"), "w") as f:
f.write(json.dumps(dictionary))
def user_resolve(name_or_id):
check = USERDB.get(name_or_id)
try:
if check:
return name_or_id
else:
return USERDB["mapname"][name_or_id]
except KeyError:
return False
def user_register(auth_hash, name, avatar, bio):
if USERDB["mapname"].get(name):
return schema.error(4, "Username taken.")
ID = uuid1().hex
scheme = schema.user_internal(ID, auth_hash, name, avatar, bio, False)
USERDB.update({ID: scheme})
USERDB["mapname"].update({name: ID})
user_dbdump(USERDB)
return scheme
def user_get(ID):
user = USERDB[ID]
return schema.user_external(
ID, user["name"], user["avatar"],
user["bio"], user["admin"])
def user_auth(ID, auth_hash):
return auth_hash == USERDB[ID]["auth_hash"]
def user_update(ID, **params):
USERDB[ID].update(params)
return USERDB[ID]

77
src/endpoints.py 100644
View File

@ -0,0 +1,77 @@
from src import schema
from src import db
from json import dumps
endpoints = {
"check_auth": ["user", "auth_hash"],
"is_registered": ["target_user"],
"thread_load": ["thread_id"],
"thread_index": [],
"thread_create": ["title", "body", "tags"],
"thread_reply": ["thread_id", "body"],
"user_register": ["user", "auth_hash", "avatar", "bio"],
"user_get": ["target_user"],
}
authless = [
"is_registered",
"user_register"
]
def create_usermap(thread, index=False):
if index:
return {user: db.user_get(user) for user in
{i["author"] for i in thread}}
result = {reply["author"] for reply in thread["replies"]}
result.add(thread["author"])
return {x: db.user_get(x) for x in result}
def is_registered(json):
return dumps(bool(db.USERDB["mapname"].get(json["target_user"])))
def check_auth(json):
return dumps(bool(db.user_auth(json["user"], json["auth_hash"])))
def user_register(json):
return schema.response(
db.user_register(
json["auth_hash"],
json["user"],
json["avatar"],
json["bio"]))
def thread_index(json):
index = db.thread_index()
return schema.response(
{"threads": index}, create_usermap(index, True))
def thread_load(json):
thread = db.thread_load(json["thread_id"])
if not thread:
return schema.error(7, "Requested thread does not exist")
return schema.response(thread, create_usermap(thread))
def thread_create(json):
thread = db.thread_create(
json["user"],
json["body"],
json["title"],
json["tags"])
return schema.response(thread, create_usermap(thread))
def thread_reply(json):
thread = db.thread_reply(
json["thread_id"],
json["user"],
json["body"])
return schema.response(thread)

77
src/schema.py 100644
View File

@ -0,0 +1,77 @@
from time import time
def base():
return {
"usermap": {},
"error": False
}
def response(dictionary, usermap=None):
result = base()
result.update(dictionary)
if usermap:
result["usermap"] = usermap
return result
def error(code, description):
result = base()
result.update({
"error": {
"description": description,
"code": code
}
})
return result
def user_internal(ID, auth_hash, name, avatar, bio, admin):
return {
"user_id": ID,
"avatar": avatar,
"name": name,
"bio": bio,
"admin": admin,
"auth_hash": auth_hash
}
def user_external(ID, name, avatar, bio, admin):
return {
"user_id": ID,
"avatar": avatar,
"name": name,
"bio": bio,
"admin": admin
}
def thread(ID, author, body, title, tags):
now = time()
return {
"thread_id": ID,
"author": author,
"body": body,
"entities": list(),
"title": title,
"tags": tags,
"replies": list(),
"reply_count": 0,
"lastmod": now,
"edited": False,
"created": now
}
def reply(ID, author, body):
now = time()
return {
"post_id": ID,
"author": author,
"body": body,
"entities": list(),
"lastmod": now,
"edited": False,
"created": now
}

78
src/server.py 100644
View File

@ -0,0 +1,78 @@
from socketserver import StreamRequestHandler, TCPServer
from src import endpoints
from src import schema
from src import db
import json
class RequestHandler(StreamRequestHandler):
"""
Receieves and processes json input; dispatches input to the
approproate endpoint, or responds with error objects.
"""
def reply(self, dictionary):
self.wfile.write(bytes(json.dumps(dictionary), "utf8"))
def handle(self):
try:
request = json.loads(str(self.rfile.read(), "utf8"))
endpoint = request.get("method")
if endpoint not in endpoints.endpoints:
raise IndexError("Invalid endpoint")
# check to make sure all the arguments for endpoint are provided
elif any([key not in request for key in endpoints.endpoints[endpoint]]):
raise ValueError("{} requires: {}".format(
endpoint, ", ".join(endpoints.endpoints[endpoint])))
elif endpoint not in endpoints.authless:
if not request.get("user"):
raise ConnectionError("No username provided.")
user = db.user_resolve(request["user"])
request["user"] = user
if not user:
raise ConnectionAbortedError("User not registered")
elif endpoint != "check_auth" and not db.user_auth(user, request.get("auth_hash")):
raise ConnectionRefusedError("Authorization failed.")
except json.decoder.JSONDecodeError as E:
return self.reply(schema.error(0, str(E)))
except IndexError as E:
return self.reply(schema.error(2, str(E)))
except ValueError as E:
return self.reply(schema.error(3, str(E)))
except ConnectionError as E:
return self.reply(schema.error(4, str(E)))
except ConnectionAbortedError as E:
return self.reply(schema.error(5, str(E)))
except ConnectionRefusedError as E:
return self.reply(schema.error(6, str(E)))
except Exception as E:
return self.reply(schema.error(1, str(E)))
try:
self.reply(eval("endpoints." + endpoint)(request))
except Exception as E:
self.reply(schema.error(1, str(E)))
def run(host, port):
server = TCPServer((host, port), RequestHandler)
try:
server.serve_forever()
except KeyboardInterrupt:
print("bye")
server.server_close()