From e892d20e18a5a7002071f7ec0468437227e508a7 Mon Sep 17 00:00:00 2001 From: Blake DeMarcy Date: Fri, 3 Mar 2017 16:26:59 -0600 Subject: [PATCH] primitive message editing support; new sanity checks --- bbj.el | 44 ++++++++++++---- docs/protocol.org | 82 ++++++++++++++++-------------- src/db.py | 127 ++++++++++++++++++++++++++++++++++++++++++---- src/endpoints.py | 103 +++++++++++++++++++++++++++++++++---- src/server.py | 7 +++ 5 files changed, 294 insertions(+), 69 deletions(-) diff --git a/bbj.el b/bbj.el index 10c4cbe..298fae8 100644 --- a/bbj.el +++ b/bbj.el @@ -8,7 +8,8 @@ ;; 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-logged-in nil) -(defvar bbj-user nil) +(defvar bbj-username nil) +(defvar bbj-userid nil) (defvar bbj-hash nil) (make-variable-buffer-local (defvar bbj-*usermap* nil)) @@ -53,6 +54,7 @@ (local-set-key (kbd "+") '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 "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 (let (json message json-false json-null (data (list - (cons 'user bbj-user) + (cons 'user bbj-username) (cons 'auth_hash bbj-hash) (cons 'method method)))) ;; populate a query with our hash and username, then the func arguments @@ -112,9 +114,12 @@ for all JSON tourists." (point-min) (point-max) shell-file-name t t nil ;; meow meow "-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)))) - (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)) (case (bbj-descend json 'error 'code) ;; haha epic handling @@ -128,7 +133,6 @@ for all JSON tourists." (defun bbj-sethash (&optional password) "Either prompt for or take the arg `PASSWORD', and then sha256-hash it. Sets it globally and also returns it." - (interactive) (unless password (setq password (read-from-minibuffer "(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 once per emacs session." (interactive) - (setq bbj-user (read-from-minibuffer "(BBJ Username)> ")) + (setq bbj-username (read-from-minibuffer "(BBJ Username)> ")) (cond - ((bbj-request "is_registered" 'target_user bbj-user) + ((bbj-request "is_registered" 'target_user bbj-username) (bbj-sethash) (if (bbj-request "check_auth") (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) - (message "Logged in as %s!" bbj-user)) + (message "Logged in as %s!" bbj-username)) (message "(Invalid Password!)") (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) (let ((response (bbj-request "user_register" @@ -160,7 +166,7 @@ once per emacs session." (message "%s" (alist-get 'error response)) (setq bbj-logged-in t) (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. ;;;; @@ -339,7 +345,6 @@ assign CALLBACK to C-c C-c." (defun bbj-consume-window (buffer) "Consume all text in the current buffer, delete the window if it is one, and kill the buffer. Returns property-free string." - (interactive) (with-current-buffer buffer (let ((content (buffer-substring-no-properties (point-min) (point-max)))) @@ -460,6 +465,23 @@ it worked on emacs 24." '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 () (interactive) (let* ((inhibit-read-only t) diff --git a/docs/protocol.org b/docs/protocol.org index 161de62..1a4a319 100644 --- a/docs/protocol.org +++ b/docs/protocol.org @@ -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 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`. | -| | registration | | -| | | During registration, this code is returned with a | -| | | `description` that should be shown to the user. | -| | | It could indicate an invalid name input, an | -| | | occupied username, invalid/missing `auth_hash`, | -| | | etc. | -|------|--------------|---------------------------------------------------| -| 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. | -|------|--------------|---------------------------------------------------| -| 11 | Always | Invalid formatting directives in text submission. | +| 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, malformed, or otherwise incorrect | +| | Client. | parameters or values for the requested `method`. | +| | | This is returned, for example, when a request to | +| | | `edit_post` tries to edit a post_id that does | +| | | not exist. Its also used to indicate a lack of | +| | | required arguments for a method. This is a generic | +| | | error class that can cover programming errors | +| | | but never user errors. | +|------+--------------+----------------------------------------------------| +| 4 | Only during | Invalid or unprovided `user`. | +| | registration | | +| | | During registration, this code is returned with a | +| | | `description` that should be shown to the user. | +| | | It could indicate an invalid name input, an | +| | | occupied username, invalid/missing `auth_hash`, | +| | | etc. | +|------+--------------+----------------------------------------------------| +| 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. | +|------+--------------+----------------------------------------------------| +| 11 | Always | Invalid formatting directives in text submission. | diff --git a/src/db.py b/src/db.py index c7f2e46..0eb38d7 100644 --- a/src/db.py +++ b/src/db.py @@ -23,6 +23,7 @@ except FileNotFoundError: f.write(json.dumps(USERDB)) path.os.chmod(path.join(PATH, "userdb"), 0o600) + ### THREAD MANAGEMENT ### 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): ID = uuid1().hex - # make sure None, False, and empty arrays are always repped consistently - tags = tags if tags else [] + 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 @@ -46,14 +49,7 @@ def thread_create(author, body, title, tags): def thread_load(ID, markup=True): try: with open(path.join(PATH, "threads", ID), "r") as f: - thread = 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 - + return json.loads(f.read()) except FileNotFoundError: return False @@ -82,6 +78,44 @@ def thread_reply(ID, author, body): 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): @@ -104,6 +138,15 @@ 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}) @@ -123,6 +166,70 @@ 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 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 diff --git a/src/endpoints.py b/src/endpoints.py index 3042627..434f791 100644 --- a/src/endpoints.py +++ b/src/endpoints.py @@ -1,17 +1,22 @@ from src import formatting from src import schema +from time import time 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", "quip", "bio"], - "user_get": ["target_user"], + "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"], + "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): if index: 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} +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["auth_hash"], json["quip"], 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): index = db.thread_index(markup=not json.get("nomarkup")) return schema.response({"threads": index}, create_usermap(index, True)) @@ -79,3 +144,21 @@ def thread_reply(json): if json.get("nomarkup"): reply["body"] = formatting.cleanse(reply["body"]) 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 diff --git a/src/server.py b/src/server.py index f5bf305..345cc68 100644 --- a/src/server.py +++ b/src/server.py @@ -43,6 +43,13 @@ class RequestHandler(StreamRequestHandler): 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))