primitive message editing support; new sanity checks

pull/4/head
Blake DeMarcy 2017-03-03 16:26:59 -06:00
parent a9634b9aea
commit e892d20e18
5 changed files with 294 additions and 69 deletions

44
bbj.el
View File

@ -8,7 +8,8 @@
;; blah blah user servicable parts blah blaheiu hre ;;;;;;;;;;r;r;r;r;;;q;q;;; ;; blah blah user servicable parts blah blaheiu hre ;;;;;;;;;;r;r;r;r;;;q;q;;;
(defvar bbj-old-p (eq emacs-major-version 24)) (defvar bbj-old-p (eq emacs-major-version 24))
(defvar bbj-logged-in nil) (defvar bbj-logged-in nil)
(defvar bbj-user nil) (defvar bbj-username nil)
(defvar bbj-userid nil)
(defvar bbj-hash nil) (defvar bbj-hash nil)
(make-variable-buffer-local (make-variable-buffer-local
(defvar bbj-*usermap* nil)) (defvar bbj-*usermap* nil))
@ -53,6 +54,7 @@
(local-set-key (kbd "+") 'bbj-compose) (local-set-key (kbd "+") 'bbj-compose)
(local-set-key (kbd "c") 'bbj-compose) (local-set-key (kbd "c") 'bbj-compose)
(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))
@ -99,7 +101,7 @@ for all JSON tourists."
;; json-false/json-nil are bound as nil here to stop them from being silly keywords ;; json-false/json-nil are bound as nil here to stop them from being silly keywords
(let (json message json-false json-null (let (json message json-false json-null
(data (list (data (list
(cons 'user bbj-user) (cons 'user bbj-username)
(cons 'auth_hash bbj-hash) (cons 'auth_hash bbj-hash)
(cons 'method method)))) (cons 'method method))))
;; populate a query with our hash and username, then the func arguments ;; populate a query with our hash and username, then the func arguments
@ -112,9 +114,12 @@ for all JSON tourists."
(point-min) (point-max) (point-min) (point-max)
shell-file-name t t nil ;; meow meow shell-file-name t t nil ;; meow meow
"-c" (format "nc %s %s" bbj-host bbj-port)) "-c" (format "nc %s %s" bbj-host bbj-port))
(when (eq (point-min) (point-max))
(user-error "Server is down"))
(setq json (progn (goto-char (point-min)) (json-read)))) (setq json (progn (goto-char (point-min)) (json-read))))
(if (eq json t) t ;; a few enpoints just return true/false ;; if the response is an atom, just return it. otherwise check for errors
(if (not (and (listp json) (eq json nil))) json
(setq message (bbj-descend json 'error 'description)) (setq message (bbj-descend json 'error 'description))
(case (bbj-descend json 'error 'code) (case (bbj-descend json 'error 'code)
;; haha epic handling ;; haha epic handling
@ -128,7 +133,6 @@ for all JSON tourists."
(defun bbj-sethash (&optional password) (defun bbj-sethash (&optional password)
"Either prompt for or take the arg `PASSWORD', and then sha256-hash it. "Either prompt for or take the arg `PASSWORD', and then sha256-hash it.
Sets it globally and also returns it." Sets it globally and also returns it."
(interactive)
(unless password (setq password (unless password (setq password
(read-from-minibuffer "(Password)> "))) (read-from-minibuffer "(Password)> ")))
(setq bbj-hash (secure-hash 'sha256 password))) (setq bbj-hash (secure-hash 'sha256 password)))
@ -139,18 +143,20 @@ Sets it globally and also returns it."
care of that. Jumps to the index afterward. This function only needs to be used care of that. Jumps to the index afterward. This function only needs to be used
once per emacs session." once per emacs session."
(interactive) (interactive)
(setq bbj-user (read-from-minibuffer "(BBJ Username)> ")) (setq bbj-username (read-from-minibuffer "(BBJ Username)> "))
(cond (cond
((bbj-request "is_registered" 'target_user bbj-user) ((bbj-request "is_registered" 'target_user bbj-username)
(bbj-sethash) (bbj-sethash)
(if (bbj-request "check_auth") (if (bbj-request "check_auth")
(progn (progn
(setq bbj-logged-in t) (setq bbj-logged-in t
bbj-userid (bbj-request "user_name_to_id"
'target_user bbj-username))
(bbj-browse-index) (bbj-browse-index)
(message "Logged in as %s!" bbj-user)) (message "Logged in as %s!" bbj-username))
(message "(Invalid Password!)") (message "(Invalid Password!)")
(run-at-time 1 nil #'bbj-login))) (run-at-time 1 nil #'bbj-login)))
((y-or-n-p (format "Register for BBJ as %s? " bbj-user)) ((y-or-n-p (format "Register for BBJ as %s? " bbj-username))
(bbj-sethash) (bbj-sethash)
(let ((response (let ((response
(bbj-request "user_register" (bbj-request "user_register"
@ -160,7 +166,7 @@ once per emacs session."
(message "%s" (alist-get 'error response)) (message "%s" (alist-get 'error response))
(setq bbj-logged-in t) (setq bbj-logged-in t)
(bbj-browse-index) (bbj-browse-index)
(message "Logged in as %s!" bbj-user)))))) (message "Logged in as %s!" bbj-username))))))
;;;; user navigation shit. a LOT of user navigation shit. ;;;; ;;;; user navigation shit. a LOT of user navigation shit. ;;;;
@ -339,7 +345,6 @@ assign CALLBACK to C-c C-c."
(defun bbj-consume-window (buffer) (defun bbj-consume-window (buffer)
"Consume all text in the current buffer, delete the window if "Consume all text in the current buffer, delete the window if
it is one, and kill the buffer. Returns property-free string." it is one, and kill the buffer. Returns property-free string."
(interactive)
(with-current-buffer buffer (with-current-buffer buffer
(let ((content (buffer-substring-no-properties (let ((content (buffer-substring-no-properties
(point-min) (point-max)))) (point-min) (point-max))))
@ -460,6 +465,23 @@ it worked on emacs 24."
'type 'end)))) 'type 'end))))
(defun bbj-edit-post ()
(interactive)
(let ((adminp (bbj-request "is_admin" 'target_user bbj-username))
(callback `(lambda ()
(let* ((message (bbj-consume-window (current-buffer)))
(request (bbj-request "edit_post"
'post_id ,(alist-get 'post_id (bbj-post-prop 'data))
'body message 'thread_id ,thread-id)))
(if (numberp (bbj-descend request 'error 'code))
(message bbj-descend request 'error 'description)
(message "post edited")
(bbj-enter-thread ,thread-id))))))
(cond
((and (not (eq ))))
)))
(defun bbj-browse-index () (defun bbj-browse-index ()
(interactive) (interactive)
(let* ((inhibit-read-only t) (let* ((inhibit-read-only t)

View File

@ -143,41 +143,47 @@ the problem. `description` is intended for human consumption; in your client
code, use the error codes to handle conditions. The `presentable` column code, use the error codes to handle conditions. The `presentable` column
indicates whether the `description` should be shown to users verbatim. indicates whether the `description` should be shown to users verbatim.
| Code | Presentable | Documentation | | Code | Presentable | Documentation |
|------|--------------|---------------------------------------------------| |------+--------------+----------------------------------------------------|
| 0 | Never, fix | Malformed json input. `description` is the error | | 0 | Never, fix | Malformed json input. `description` is the error |
| | your client | string thrown by the server-side json decoder. | | | your client | string thrown by the server-side json decoder. |
|------|--------------|---------------------------------------------------| |------+--------------+----------------------------------------------------|
| 1 | Not a good | Internal server error. Unaltered exception text | | 1 | Not a good | Internal server error. Unaltered exception text |
| | idea, the | is returned as `description`. This shouldn't | | | idea, the | is returned as `description`. This shouldn't |
| | exceptions | happen, and if it does, make a bug report. | | | exceptions | happen, and if it does, make a bug report. |
| | are not | clients should not attempt to intelligently | | | are not | clients should not attempt to intelligently |
| | helpful | recover from any errors of this class. | | | helpful | recover from any errors of this class. |
|------|--------------|---------------------------------------------------| |------+--------------+----------------------------------------------------|
| 2 | Nadda. | Unknown `method` was requested. | | 2 | Nadda. | Unknown `method` was requested. |
|------|--------------|---------------------------------------------------| |------+--------------+----------------------------------------------------|
| 3 | Fix. Your. | Missing or malformed parameter values for the | | 3 | Fix. Your. | Missing, malformed, or otherwise incorrect |
| | Client. | requested `method`. | | | Client. | parameters or values for the requested `method`. |
|------|--------------|---------------------------------------------------| | | | This is returned, for example, when a request to |
| 4 | Only during | Invalid or unprovided `user`. | | | | `edit_post` tries to edit a post_id that does |
| | registration | | | | | not exist. Its also used to indicate a lack of |
| | | During registration, this code is returned with a | | | | required arguments for a method. This is a generic |
| | | `description` that should be shown to the user. | | | | error class that can cover programming errors |
| | | It could indicate an invalid name input, an | | | | but never user errors. |
| | | occupied username, invalid/missing `auth_hash`, | |------+--------------+----------------------------------------------------|
| | | etc. | | 4 | Only during | Invalid or unprovided `user`. |
|------|--------------|---------------------------------------------------| | | registration | |
| 5 | Always | `user` is not registered. | | | | During registration, this code is returned with a |
|------|--------------|---------------------------------------------------| | | | `description` that should be shown to the user. |
| 6 | Always | User `auth_hash` failed or was not provided. | | | | It could indicate an invalid name input, an |
|------|--------------|---------------------------------------------------| | | | occupied username, invalid/missing `auth_hash`, |
| 7 | Always | Requested thread does not exist. | | | | etc. |
|------|--------------|---------------------------------------------------| |------+--------------+----------------------------------------------------|
| 8 | Always | Requested thread does not allow posts. | | 5 | Always | `user` is not registered. |
|------|--------------|---------------------------------------------------| |------+--------------+----------------------------------------------------|
| 9 | Always | Message edit failed; there is a 24hr limit for | | 6 | Always | User `auth_hash` failed or was not provided. |
| | | editing posts. | |------+--------------+----------------------------------------------------|
|------|--------------|---------------------------------------------------| | 7 | Always | Requested thread does not exist. |
| 10 | Always | User action requires `admin` privilege. | |------+--------------+----------------------------------------------------|
|------|--------------|---------------------------------------------------| | 8 | Always | Requested thread does not allow posts. |
| 11 | Always | Invalid formatting directives in text submission. | |------+--------------+----------------------------------------------------|
| 9 | Always | Message edit failed; there is a 24hr limit for |
| | | editing posts. |
|------+--------------+----------------------------------------------------|
| 10 | Always | User action requires `admin` privilege. |
|------+--------------+----------------------------------------------------|
| 11 | Always | Invalid formatting directives in text submission. |

127
src/db.py
View File

@ -23,6 +23,7 @@ except FileNotFoundError:
f.write(json.dumps(USERDB)) f.write(json.dumps(USERDB))
path.os.chmod(path.join(PATH, "userdb"), 0o600) path.os.chmod(path.join(PATH, "userdb"), 0o600)
### THREAD MANAGEMENT ### ### THREAD MANAGEMENT ###
def thread_index(key="lastmod", markup=True): def thread_index(key="lastmod", markup=True):
@ -36,8 +37,10 @@ def thread_index(key="lastmod", markup=True):
def thread_create(author, body, title, tags): def thread_create(author, body, title, tags):
ID = uuid1().hex ID = uuid1().hex
# make sure None, False, and empty arrays are always repped consistently if tags:
tags = tags if tags else [] 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) scheme = schema.thread(ID, author, body, title, tags)
thread_dump(ID, scheme) thread_dump(ID, scheme)
return scheme return scheme
@ -46,14 +49,7 @@ def thread_create(author, body, title, tags):
def thread_load(ID, markup=True): def thread_load(ID, markup=True):
try: try:
with open(path.join(PATH, "threads", ID), "r") as f: with open(path.join(PATH, "threads", ID), "r") as f:
thread = json.loads(f.read()) return json.loads(f.read())
if not markup:
thread["body"] = formatting.cleanse(thread["body"])
for x in range(len(thread["replies"])):
thread["replies"][x]["body"] = formatting.cleanse(
thread["replies"][x]["body"])
return thread
except FileNotFoundError: except FileNotFoundError:
return False return False
@ -82,6 +78,44 @@ def thread_reply(ID, author, body):
return reply 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 ### ### USER MANAGEMENT ###
def user_dbdump(dictionary): def user_dbdump(dictionary):
@ -104,6 +138,15 @@ def user_register(auth_hash, name, quip, bio):
if USERDB["namemap"].get(name): if USERDB["namemap"].get(name):
return schema.error(4, "Username taken.") 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 ID = uuid1().hex
scheme = schema.user_internal(ID, auth_hash, name, quip, bio, False) scheme = schema.user_internal(ID, auth_hash, name, quip, bio, False)
USERDB.update({ID: scheme}) USERDB.update({ID: scheme})
@ -123,6 +166,70 @@ def user_auth(ID, auth_hash):
return auth_hash == USERDB[ID]["auth_hash"] return auth_hash == USERDB[ID]["auth_hash"]
def user_is_admin(ID):
return USERDB[ID]["admin"]
def user_update(ID, **params): def user_update(ID, **params):
USERDB[ID].update(params) USERDB[ID].update(params)
return USERDB[ID] 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 len(username) > 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 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 ""
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 ""
elif len(bio) > 4096:
return False, schema.error(4,
"Bio is too long (max 4096 chars)")
return True, True

View File

@ -1,17 +1,22 @@
from src import formatting from src import formatting
from src import schema from src import schema
from time import time
from src import db from src import db
from json import dumps
endpoints = { endpoints = {
"check_auth": ["user", "auth_hash"], "check_auth": ["user", "auth_hash"],
"is_registered": ["target_user"], "is_registered": ["target_user"],
"thread_load": ["thread_id"], "is_admin": ["target_user"],
"thread_index": [], "thread_index": [],
"thread_create": ["title", "body", "tags"], "thread_load": ["thread_id"],
"thread_reply": ["thread_id", "body"], "thread_create": ["title", "body", "tags"],
"user_register": ["user", "auth_hash", "quip", "bio"], "thread_reply": ["thread_id", "body"],
"user_get": ["target_user"], "edit_post": ["thread_id", "post_id", "body"],
"can_edit": ["thread_id", "post_id"],
"user_register": ["user", "auth_hash", "quip", "bio"],
"user_get": ["target_user"],
"user_name_to_id": ["target_user"]
} }
@ -21,6 +26,8 @@ authless = [
] ]
# this is not actually an endpoint, but produces a required
# element of thread responses.
def create_usermap(thread, index=False): def create_usermap(thread, index=False):
if index: if index:
return {user: db.user_get(user) for user in return {user: db.user_get(user) for user in
@ -31,23 +38,81 @@ def create_usermap(thread, index=False):
return {x: db.user_get(x) for x in result} 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): 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"])) return bool(db.USERDB["namemap"].get(json["target_user"]))
def check_auth(json): def check_auth(json):
"Returns true or false whether auth_hashes matches user."
return bool(db.user_auth(json["user"], json["auth_hash"])) 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): 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( return schema.response(
db.user_register( db.user_register(
json["auth_hash"],
json["user"], json["user"],
json["auth_hash"],
json["quip"], json["quip"],
json["bio"])) json["bio"]))
def user_get(json):
user = db.user_resolve(json["target_user"])
if not user:
return False
return db.user_get(user)
def thread_index(json): def thread_index(json):
index = db.thread_index(markup=not json.get("nomarkup")) index = db.thread_index(markup=not json.get("nomarkup"))
return schema.response({"threads": index}, create_usermap(index, True)) return schema.response({"threads": index}, create_usermap(index, True))
@ -79,3 +144,21 @@ def thread_reply(json):
if json.get("nomarkup"): if json.get("nomarkup"):
reply["body"] = formatting.cleanse(reply["body"]) reply["body"] = formatting.cleanse(reply["body"])
return schema.response(reply) return schema.response(reply)
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"]
query, obj = db.edit_handler(json, thread)
if query:
obj["body"] = json["body"]
obj["lastmod"] = time()
obj["edited"] = True
db.thread_dump(json["thread_id"], thread)
return obj

View File

@ -43,6 +43,13 @@ class RequestHandler(StreamRequestHandler):
db.user_auth(user, request.get("auth_hash")): db.user_auth(user, request.get("auth_hash")):
return self.reply(schema.error(6, "Authorization failed.")) 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; # exception handling is now passed to the endpoints;
# anything unhandled beyond here is a code 1 # anything unhandled beyond here is a code 1
self.reply(eval("endpoints." + endpoint)(request)) self.reply(eval("endpoints." + endpoint)(request))