From 26b6dc190733facb01edbb32d3454b4824bac4bc Mon Sep 17 00:00:00 2001 From: Blake DeMarcy Date: Sun, 2 Apr 2017 02:35:58 -0500 Subject: initial commit of non-prototype --- prototype/clients/elisp/bbj.el | 637 +++++++++++++++++++++++++++++++++ prototype/clients/network_client.py | 64 ++++ prototype/clients/urwid/main.py | 88 +++++ prototype/clients/urwid/src/network.py | 1 + prototype/clients/urwid/src/widgets.py | 4 + prototype/docs/protocol.org | 189 ++++++++++ prototype/main.py | 5 + prototype/src/db.py | 240 +++++++++++++ prototype/src/endpoints.py | 174 +++++++++ prototype/src/formatting.py | 28 ++ prototype/src/schema.py | 97 +++++ prototype/src/server.py | 70 ++++ 12 files changed, 1597 insertions(+) create mode 100644 prototype/clients/elisp/bbj.el create mode 100644 prototype/clients/network_client.py create mode 100644 prototype/clients/urwid/main.py create mode 120000 prototype/clients/urwid/src/network.py create mode 100644 prototype/clients/urwid/src/widgets.py create mode 100644 prototype/docs/protocol.org create mode 100644 prototype/main.py create mode 100644 prototype/src/db.py create mode 100644 prototype/src/endpoints.py create mode 100644 prototype/src/formatting.py create mode 100644 prototype/src/schema.py create mode 100644 prototype/src/server.py (limited to 'prototype') diff --git a/prototype/clients/elisp/bbj.el b/prototype/clients/elisp/bbj.el new file mode 100644 index 0000000..8e71b25 --- /dev/null +++ b/prototype/clients/elisp/bbj.el @@ -0,0 +1,637 @@ +(require 'json) +(require 'shr) +(require 'cl) + +(defvar bbj-host "localhost") +(defvar bbj-port "7066") +(defvar bbj-width 80) + +;; 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-username nil) +(defvar bbj-userid nil) +(defvar bbj-hash nil) + +(make-variable-buffer-local + (defvar bbj-refresh-timer nil)) +(make-variable-buffer-local + (defvar bbj-*usermap* nil)) +(make-variable-buffer-local + (defvar bbj-buffer-type nil)) +(make-variable-buffer-local + (defvar bbj-aux-callback #'ignore)) + + +(define-derived-mode bbj-mode fundamental-mode "BBJ" + "Mode for browsing and posting to BBJ." + :group 'bbj-mode + (local-set-key (kbd "SPC") 'bbj-next-post) + (local-set-key (kbd "j") 'bbj-next-post) + (local-set-key (kbd "n") 'bbj-next-post) + (local-set-key (kbd "") 'bbj-next-post) + + (local-set-key (kbd "DEL") 'bbj-prev-post) + (local-set-key (kbd "k") 'bbj-prev-post) + (local-set-key (kbd "p") 'bbj-prev-post) + (local-set-key (kbd "") 'bbj-prev-post) + + (local-set-key (kbd "g") 'bbj-refresh) + (local-set-key (kbd "") 'bbj-refresh) + + (local-set-key (kbd "J") 'scroll-up-line) + (local-set-key (kbd "N") 'scroll-up-line) + (local-set-key (kbd "S-SPC") 'scroll-up-line) + (local-set-key (kbd "")'scroll-up-line) + + (local-set-key (kbd "K") 'scroll-down-line) + (local-set-key (kbd "P") 'scroll-down-line) + (local-set-key (kbd "") 'scroll-down-line) + (local-set-key (kbd "") 'scroll-down-line) + + (local-set-key (kbd "RET") 'bbj-enter) + (local-set-key (kbd "l") 'bbj-enter) + (local-set-key (kbd "o") 'bbj-enter) + (local-set-key (kbd "") 'bbj-enter) + + (local-set-key (kbd "q") 'quit-window) + (local-set-key (kbd "") 'quit-window) + (local-set-key (kbd "")'quit-window) + + (local-set-key (kbd "+") 'bbj-compose) + (local-set-key (kbd "c") 'bbj-compose) + + (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 "C-c C-c") 'bbj-aux) + (local-set-key (kbd "r") 'bbj-quote-current-post)) + +(ignore-errors + (evil-set-initial-state 'bbj-mode 'emacs)) + +;;;; shit to put up with outdated emacs on the tilde ;;;; + +(when bbj-old-p + + (defun alist-get (key alist &optional default remove) + (ignore remove) ;;Silence byte-compiler. + (let ((x (assq key alist))) + (if x (cdr x) default))) + + (defsubst string-trim-left (string) + "Remove leading whitespace from STRING." + (if (string-match "\\`[ \t\n\r]+" string) + (replace-match "" t t string) + string)) + + (defsubst string-trim-right (string) + "Remove trailing whitespace from STRING." + (if (string-match "[ \t\n\r]+\\'" string) + (replace-match "" t t string) + string)) + + (defsubst string-trim (string) + "Remove leading and trailing whitespace from STRING." + (string-trim-left (string-trim-right string)))) + +;;;; network shit ;;;; + +(defun bbj-descend (alist &rest keys) + "Recursively retrieve symbols from a nested alist. A required beverage +for all JSON tourists." + (while keys + (setq alist (alist-get (pop keys) alist))) + alist) + + +(defun bbj-request (method &rest pairs) + "Poke netcat to poke the server who will hopefully poke us back" + ;; 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-username) + (cons 'auth_hash bbj-hash) + (cons 'method method)))) + ;; populate a query with our hash and username, then the func arguments + (while pairs + (push (cons (pop pairs) (pop pairs)) data)) + + (with-temp-buffer + (insert (json-encode data)) + (call-process-region + (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 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 + (4 (user-error + (if bbj-logged-in message + "Not logged in. Call M-x bbj-login"))) + ((5 6 7) (user-error message)) + (otherwise json))))) + + +(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." + (unless password (setq password + (read-from-minibuffer "(Password)> "))) + (setq bbj-hash (secure-hash 'sha256 password))) + + +(defun bbj-login () + "Prompts the user for a name and password. If it isn't registered, we'll take +care of that. Jumps to the index afterward. This function only needs to be used +once per emacs session." + (interactive) + (setq bbj-username (read-from-minibuffer "(BBJ Username)> ")) + (cond + ((bbj-request "is_registered" 'target_user bbj-username) + (bbj-sethash) + (if (bbj-request "check_auth") + (progn + (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-username)) + (message "(Invalid Password!)") + (run-at-time 1 nil #'bbj-login))) + ((y-or-n-p (format "Register for BBJ as %s? " bbj-username)) + (bbj-sethash) + (let ((response + (bbj-request "user_register" + ;; need to add some cute prompts for these + 'quip "" 'bio ""))) + (if (alist-get 'error response) + (message "%s" (alist-get 'error response)) + (setq bbj-logged-in t) + (bbj-browse-index) + (message "Logged in as %s!" bbj-username)))))) + + +;;;; user navigation shit. a LOT of user navigation shit. ;;;; +(defun bbj-next-pos (string &optional regex prop backward group bound) + ;; haha yes i ripped this from one of my other projects + "Takes a STRING and returns the char position of the beginning of its +next occurence from point in `current-buffer'. Returns nil if not found. +A simpler way to call this is to use `bbj-next-prop'. + +When REGEX is non-nil, STRING is interpreted as a regular expression. + +PROP, when non-nil, will only return matches if they have the corresponding +value for a property. This can either be a symbol or a cons cell. If it's +a symbol, the property key used is 'type. As a cons, The key and expected +value are given, eg '(type . end) + +BACKWARD, when non-nil, does what it says on the tin. + +When GROUP is non-nil and an integer, returns start pos of that match +group. When PROP is in effect, it checks property at this position instead +of 0. + +BOUND can be a buffer position (integer) that the search will not exceed." + (save-excursion + (let ((search (if backward (if regex 're-search-backward 'search-backward) + (if regex 're-search-forward 'search-forward))) + (group (or group 0)) + (propkey (if (consp prop) (car prop) 'type)) + (propval (if (consp prop) (cdr prop) prop)) + found) + (while (and (not found) (funcall search string bound t)) + (if prop (setq found + (eql propval (get-char-property + (match-beginning group) propkey))) + (setq found t))) + (when found + (match-beginning group))))) + + +(defun bbj-next-prop (prop &optional backward bound) + "Like the `bbj-next-pos', but doesnt care about strings and +just hunts for a specific text property." + (bbj-next-pos "." t prop backward nil bound)) + + +(defun bbj-post-prop (prop &optional id) + "retrieve PROP from the current post. needs ID-seeking support" + (save-excursion + (bbj-assert-post-start) + (get-char-property (point) prop))) + + +;; returns positions of the next head and ending seps, respectively +(defun bbj-head-pos (&optional backward) + (bbj-next-prop 'head backward)) +(defun bbj-sep-pos (&optional backward) + (bbj-next-prop 'end backward)) + + +(defun bbj-assert-post-start () + (unless (eql 'head (get-char-property (point) 'type)) + (goto-char (bbj-head-pos t)))) + + +(defun bbj-point-to-post (dir &optional nocenter) + "Move the cursor from the head of one post to another, in (symbol) DIR" + (let ((check (case dir + (prev (bbj-head-pos t)) + (next (save-excursion ;; or else point will stick + (while (eq 'head (get-char-property (point) 'type)) + (goto-char (next-property-change (point)))) + (bbj-head-pos)))))) + (when check + (goto-char check) + (back-to-indentation) + (unless nocenter (recenter 1))))) + + +(defun bbj-next-post () + (interactive) + (bbj-point-to-post 'next)) + + +(defun bbj-prev-post () + (interactive) + (bbj-point-to-post 'prev)) + + +(defun bbj-first-post () + ;; does interactive work like this? i never checked tbh + (interactive (push-mark)) + (goto-char (+ 1 bbj-width (point-min)))) + + +(defun bbj-seek-post (id) + "Locate a post number and jump to it." + (when (eq bbj-buffer-type 'thread) + (if (member id '("0" 0)) + (bbj-first-post) + (let ((pos (bbj-next-pos (format ">>%s" id) nil 'head))) + (if (not pos) + (message "post %s not found" id) + (goto-char pos) + (recenter t)))))) + + +(defun bbj-aux () + "just some random lazy callback shitty thing for C-c C-c" + (interactive) + (funcall bbj-aux-callback)) + + +(defun bbj-enter () + "Handles the RETURN key (and other similar binds) depending on +content type. Currently only opens threads." + (interactive) + (case bbj-buffer-type + (index + (bbj-enter-thread + (alist-get 'thread_id (bbj-post-prop 'data)))))) + + +(defun bbj-quote-current-post () + "Pop a composer, and insert the post number at point as a quote." + (interactive) + (case bbj-buffer-type + (thread + (let ((id (alist-get 'post_id (bbj-post-prop 'data)))) + (bbj-compose) + (insert (format ">>%s\n\n" id)))) + (index + ;; recursion haha yes + (let ((buffer (current-buffer))) + (bbj-enter) + (unless (equal buffer (current-buffer)) + (bbj-quote-current-post)))))) + + +(defun bbj-compose () + "Construct an appropriate callback to either create a thread or +reply to one. Pops a new window; window is killed and the message +is sent using C-c C-c." + (interactive) + (let ((params (case bbj-buffer-type + (index + `("Composing a new thread (C-c C-c to send)" + (lambda () + (let* ((message (bbj-consume-window (current-buffer))) + (request (bbj-request "thread_create" + 'body message + 'title ,(read-from-minibuffer "(Thread Title)> ") + 'tags ,(read-from-minibuffer "(Comma-seperated tags, if any)> ")))) + (if (numberp (bbj-descend request 'error 'code)) + (message "%s" request) + (message "thread submitted") + (bbj-browse-index)))))) + (thread + `("Replying to thread (C-c C-c to send)" + (lambda () + (let* ((message (bbj-consume-window (current-buffer))) + (request (bbj-request "thread_reply" + 'body message 'thread_id ,thread-id))) + (if (numberp (bbj-descend request 'error 'code)) + (message "%s" request) + (message "reply submitted") + (bbj-enter-thread ,thread-id) + (goto-char (point-max)) + (bbj-point-to-post 'prev) + (recenter nil))))))))) + + (apply #'bbj-compose-in-window params))) + + +(defun bbj-compose-in-window (title callback &rest cbargs) + "Create a new buffer, pop it, set TITLE as the header line, and +assign CALLBACK to C-c C-c." + (let ((buffer (get-buffer-create "BBJ: Compose"))) + (pop-to-buffer buffer) + (with-current-buffer buffer + (erase-buffer) + (text-mode) + (use-local-map (copy-keymap text-mode-map)) + (local-set-key (kbd "C-c C-c") 'bbj-aux) + (setq header-line-format title + bbj-aux-callback callback)))) + + +(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." + (with-current-buffer buffer + (let ((content (buffer-substring-no-properties + (point-min) (point-max)))) + (quit-window t) + content))) + + +;; rendering shit + +(defun bbj-postprocess () + "Makes all the whitespace in and between posts consistent." + (bbj-first-post) + (save-excursion + (while (re-search-forward "\n\n\n+" nil t) + (replace-match "\n\n")))) + + +(defun bbj-render-body (string &optional return-string notrim) + "takes an html STRING. If RETURN-STRING is non nil, it renders +it in a temp buffer and returns the string. Otherwise, inserts +and renders the content in the current buffer." + (let* ((shr-width bbj-width) + (shr-external-rendering-functions + '((span . bbj-render-tag-span))) + result) + (if (not return-string) + (let ((start (point))) + (insert string) + (shr-render-region start (point-max)) + (insert "\n\n")) + (setq result + (with-temp-buffer + (insert string) + (shr-render-region (point-min) (point-max)) + (buffer-substring (point-min) (point-max)))) + (if notrim result (string-trim result))))) + + +(defun bbj-timestring (epoch) + "Make a cute timestring out of the epoch (for post heads)" + (format-time-string "%H:%M %a %m/%d/%y" (seconds-to-time epoch))) + + +(defun bbj-render-post (object) + "Render an API object into the current buffer. Can be either the parent object +or any of its children." + (let* ((userdata (cdr (assoc-string (alist-get 'author object) bbj-*usermap*))) + (title (alist-get 'title object)) + (indicator (format ">>%s " (or title (alist-get 'post_id object))))) + (insert (propertize indicator + 'face 'font-lock-function-name-face + 'type 'head 'data object)) + (when title (insert "\n")) + (insert (propertize + (concat "~" (alist-get 'name userdata) " ") + 'face 'font-lock-keyword-face)) + (insert (if (eq bbj-buffer-type 'index) + (propertize (format "@ %s\n%s replies; last active %s\n" + (bbj-timestring (alist-get 'created object)) + (alist-get 'reply_count object) + (bbj-timestring (alist-get 'lastmod object))) + 'face 'font-lock-comment-face) + (propertize (format "@ %s\n\n" (bbj-timestring (alist-get 'created object))) + 'face 'font-lock-comment-face))) + (when (eq bbj-buffer-type 'thread) + (bbj-render-body (alist-get 'body object))) + (bbj-insert-sep))) + + +(defun bbj-render-tag-span (dom) + "A highly bastardized version of e25's `shr-tag-span', beaten and +maimed until it worked on emacs 24." + (let ((class (if bbj-old-p + (alist-get :class dom) + (dom-attr dom 'class))) + (text (if bbj-old-p + (alist-get 'text dom) + (car (last dom))))) + (cond + ((equal class "quote") + (insert (propertize text + 'face 'font-lock-constant-face + 'type 'quote))) + ((equal class "linequote") + (unless (bolp) (insert "\n")) + (insert (propertize text + 'face 'font-lock-string-face + 'type 'linequote))) + (t (shr-generic dom))))) + + +(defun bbj-mksep () + (format "\n%s\n" (make-string bbj-width ?\-))) + + +(defun bbj-insert-sep (&optional drop-newline) + (let ((sep (bbj-mksep))) + (insert (propertize + (if drop-newline (subseq sep 1) sep) + 'face 'font-lock-comment-face + 'type 'end)))) + + +(defun bbj-pop-help () + "Displays the help text." + (interactive) + ;; yes lets embed this shit in the source code haha epic + (let ((help "hi this is help pleased to meet ye 8) + +Please note the keys described below apply to thread and index buffers, +not this help page. + +n, j, down arrow, and spacebar all move down by a whole post. +p, k, up arrow, and backspace go up, again by a whole post. + +for n/p/j/k, hold shift (or use caps lock if you're into that) to scroll up or +down by one exactly one line. You can also use shift with the arrow keys, and +space/backspace, but if you are using terminal emacs, your terminal emulator may +not work properly with these combos. + +The normal emacs paging and cursor movement keys (besides arrows) are not +affected, if you are already familiar with them. C-n goes down a line, C-p goes +up, C-v pages down, M-v pages up. (reminder: M means alt here in Emacs lala +land) The keyboard's dedicated paging keys will work too. + +Open a thread with enter, or the o key. + +The make-a-post keys are c and + (the plus key). If you are in the index, it +will make a new thread and prompt you at the minibuffer for a title and some +tags. If you dont want tags, just press enter again. If you're in a thread, this +will begin composing a new reply. + +In the composure window, press control-c twice to fire it off to the server. If +you would like to discard the post, you can kill or hide this buffer using the +standard emacs keys. C-x 4 0 will kill the buffer and the window. C-x 0 will +just hide the window. C-x k RET will kill the buffer but not the window. + +In addition to the composure keys, r is bound to insert a quote element for the +post at point after popping a reply window. Post quotes look like >>32, that is, +they are two angle brackets pointing at a number. They currently dont do +anything special. Later versions of the client will support navigation features +using them. r is not required to use these: you can also type them in yourself. + +Pressing e on a post will pop a new window to edit it, given that the post is +yours and is not older than 24 hours. Currently this returns the html-rendered +output of the markdown parser, which is quite clunky and i will fix that. But +pressing C-c C-c will update it for everyone with the new data. + +g or f5 will reload whatever buffer you are in, thread or index. If you are in a +thread, it will save whatever post your cursor is positioned at. Use this to +check for new messages. + +q will get out of a thread and back to the index. If you're on the index, it +will kill that too. If you've killed the index, you can get back using the +command alt+x bbj-browse-index (tab completion is available) + +The command names to log in, and browse to the index are bbj-login and +bbj-browse-index respectively. + +Thats about it for now. +")) + (let ((buffer (get-buffer-create "BBJ: Help")) + (inhibit-read-only t)) + (with-current-buffer buffer + (erase-buffer) + (insert help) + (goto-char (point-min)) + (text-mode) + (use-local-map (copy-keymap text-mode-map)) + (local-set-key (kbd "q") 'kill-buffer-and-window) + (setq header-line-format + "Press q or C-x 4 0 to get out. Arrows can scroll." + buffer-read-only t)) + (pop-to-buffer buffer) + (ignore-errors + (evil-emacs-state))))) + + +(defun bbj-refresh () + "Reload current buffer. Tries to keep point position in threads." + (interactive) + (case bbj-buffer-type + (index + (let ((point (point))) + (bbj-browse-index) + (goto-char point) + (recenter t))) + (thread + (let ((post (alist-get 'post_id (bbj-post-prop 'data)))) + (bbj-enter-thread thread-id) + (bbj-seek-post post))))) + + +(defun bbj-edit-post () + (interactive) + (when (eq bbj-buffer-type 'index) + (let ((buffer (current-buffer))) + (bbj-enter) + (unless (eql buffer (current-buffer)) + (bbj-edit-post)))) + + (let* ((post (alist-get 'post_id (bbj-post-prop 'data))) + (adminp (bbj-request "is_admin" 'target_user bbj-username)) + (message (alist-get 'body (bbj-post-prop 'data))) + (query (bbj-request "edit_query" 'post_id post 'thread_id thread-id)) + (callback `(lambda () + (let* ((message (bbj-consume-window (current-buffer))) + (request (bbj-request "edit_post" + 'post_id ,post + '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 + ((numberp (bbj-descend query 'error 'code)) + (message (bbj-descend query 'error 'description))) + (t + (bbj-compose-in-window "Editing post (including html) (C-c C-c to send)" callback) + (insert message) + (goto-char (point-min)))))) + + +(defun bbj-browse-index () + (interactive) + (let* ((inhibit-read-only t) + (buffer (get-buffer-create "BBJ Index")) + (response (bbj-request "thread_index")) + (bbj-*usermap* (alist-get 'usermap response)) + (count 0)) + (with-current-buffer buffer + (erase-buffer) + (bbj-mode) + (setq bbj-buffer-type 'index + bbj-*usermap* (alist-get 'usermap response)) + (bbj-insert-sep t) + (loop for thread across (alist-get 'threads response) do + (bbj-render-post thread) + (incf count)) + (bbj-postprocess) + (setq header-line-format (format + "%d posts. g to refresh. Control+h then spacebar for help." + count))) + (switch-to-buffer buffer) + (setq buffer-read-only t))) + + +(defalias 'bbj-index #'bbj-browse-index) + + +(defun bbj-enter-thread (id) + (interactive) + (let* ((inhibit-read-only t) + (response (bbj-request "thread_load" 'thread_id id)) + (buffer (get-buffer-create (format "BBJ: %s" (alist-get 'title response))))) + (with-current-buffer buffer + (erase-buffer) + (bbj-mode) + (setq bbj-buffer-type 'thread + bbj-*usermap* (alist-get 'usermap response)) + (setq-local thread-id id) + (bbj-insert-sep t) + (bbj-render-post response) + (loop for reply across (alist-get 'replies response) do + (bbj-render-post reply)) + (bbj-postprocess)) + (switch-to-buffer buffer) + (setq buffer-read-only t))) diff --git a/prototype/clients/network_client.py b/prototype/clients/network_client.py new file mode 100644 index 0000000..64e5e8d --- /dev/null +++ b/prototype/clients/network_client.py @@ -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 diff --git a/prototype/clients/urwid/main.py b/prototype/clients/urwid/main.py new file mode 100644 index 0000000..d29bcac --- /dev/null +++ b/prototype/clients/urwid/main.py @@ -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) diff --git a/prototype/clients/urwid/src/network.py b/prototype/clients/urwid/src/network.py new file mode 120000 index 0000000..62f1ad5 --- /dev/null +++ b/prototype/clients/urwid/src/network.py @@ -0,0 +1 @@ +../../network_client.py \ No newline at end of file diff --git a/prototype/clients/urwid/src/widgets.py b/prototype/clients/urwid/src/widgets.py new file mode 100644 index 0000000..aa35748 --- /dev/null +++ b/prototype/clients/urwid/src/widgets.py @@ -0,0 +1,4 @@ +import urwid + +class PostBox(urwid.ListBox): + pass diff --git a/prototype/docs/protocol.org b/prototype/docs/protocol.org new file mode 100644 index 0000000..1a4a319 --- /dev/null +++ b/prototype/docs/protocol.org @@ -0,0 +1,189 @@ +Data Standards +-------------- + + + * UTF-8 in, UTF-8 out. No exceptions. + + * SHA256 for auth_hash. Server will do a basic check to make sure of this. + + * Security is not a #1 concern. Basic authorization will be implemented + to **help prevent** users from impersonating each other, but this isn't + intended to be bulletproof and you shouldn't trust the system with a + password you use elsewhere. All clients should inform the user of this. + + * Command-line, on-tilde comes first. Local clients should be possible using + SSH port binding, however features like inline images, graphical elements + and the like will never be implemented as part of the protocol. Local clients + can definitely do things like URL image previews though. Hyperlinks with a + different text then the link itself will never be implemented. + + +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 +implementation like an Emacs or GUI, 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. They will +always be at the end of the array. + +| 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. In | +| | addition to the indices at i[1] and i[2], a fourth value | +| | is provided, which is an integer of the `post_id` being | +| | quoted. Note that the string indices include the >>'s. | +|-------------+----------------------------------------------------------| +| `linequote` | This is a line of text, denoted by a newline during | +| | composure, representing text that is assumed to be | +| | a quote of someone else. The indices span from the > | +| | until (not including) the newline. | +|-------------+----------------------------------------------------------| +| `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 | +| `italic` | provided. it is denoted as [[directive: body]] during | +| `underline` | 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 string of the author. | +|---------------|------------------------------------------------------| +| `thread_id` | The ID string of the thread. | +|---------------|------------------------------------------------------| +| `title` | The title string of the thread. | +|---------------|------------------------------------------------------| +| `body` | The body string 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. Array can be empty. | +|---------------|------------------------------------------------------| +| `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 the post has been edited. | +|---------------|------------------------------------------------------| +| `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. These may be referenced by `quote` entities. | +|------------|---------------------------------------------------------| +| `author` | Author ID string | +|------------|---------------------------------------------------------| +| `body` | The body string 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, 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/prototype/main.py b/prototype/main.py new file mode 100644 index 0000000..78a55a6 --- /dev/null +++ b/prototype/main.py @@ -0,0 +1,5 @@ +from src import schema +from src import server + +if __name__ == '__main__': + server.run("localhost", 7066) diff --git a/prototype/src/db.py b/prototype/src/db.py new file mode 100644 index 0000000..0cbaf5a --- /dev/null +++ b/prototype/src/db.py @@ -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 diff --git a/prototype/src/endpoints.py b/prototype/src/endpoints.py new file mode 100644 index 0000000..973a528 --- /dev/null +++ b/prototype/src/endpoints.py @@ -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 diff --git a/prototype/src/formatting.py b/prototype/src/formatting.py new file mode 100644 index 0000000..56614c3 --- /dev/null +++ b/prototype/src/formatting.py @@ -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 '{1}'.format(directive, body) + elif directive in MARKUP: + return '<{0}>{1}'.format(directive[0], body) + return body + + +def parse(text, doquotes=True): + text = TOKENS.sub(map_html, escape(text)) + if doquotes: + text = QUOTES.sub(r'\g<0>', text) + return markdown( + LINEQUOTES.sub(r'\1
', text) + ) diff --git a/prototype/src/schema.py b/prototype/src/schema.py new file mode 100644 index 0000000..8e3ca35 --- /dev/null +++ b/prototype/src/schema.py @@ -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 + } diff --git a/prototype/src/server.py b/prototype/src/server.py new file mode 100644 index 0000000..345cc68 --- /dev/null +++ b/prototype/src/server.py @@ -0,0 +1,70 @@ +from socketserver import StreamRequestHandler, TCPServer +from src import endpoints +from src import schema +from src import db +import json + + +class RequestHandler(StreamRequestHandler): + """ + Receieves and processes json input; dispatches input to the + requested endpoint, or responds with error objects. + """ + + + def reply(self, obj): + self.wfile.write(bytes(json.dumps(obj), "utf8")) + + + def handle(self): + try: + request = json.loads(str(self.rfile.read(), "utf8")) + endpoint = request.get("method") + + if endpoint not in endpoints.endpoints: + return self.reply(schema.error(2, "Invalid endpoint")) + + # check to make sure all the arguments for endpoint are provided + elif any([key not in request for key in endpoints.endpoints[endpoint]]): + return self.reply(schema.error(3, "{} requires: {}".format( + endpoint, ", ".join(endpoints.endpoints[endpoint])))) + + elif endpoint not in endpoints.authless: + if not request.get("user"): + return self.reply(schema.error(4, "No username provided.")) + + user = db.user_resolve(request["user"]) + request["user"] = user + + if not user: + return self.reply(schema.error(5, "User not registered")) + + elif endpoint != "check_auth" and not \ + db.user_auth(user, request.get("auth_hash")): + return self.reply(schema.error(6, "Authorization failed.")) + + # post_ids are always returned as integers, but for callers who + # provide them as something else, try to convert them. + if isinstance(request.get("post_id"), (float, str)): + try: request["post_id"] = int(request["post_id"]) + except Exception: + return schema.error(3, "Non-numeric post_id") + + # exception handling is now passed to the endpoints; + # anything unhandled beyond here is a code 1 + self.reply(eval("endpoints." + endpoint)(request)) + + except json.decoder.JSONDecodeError as E: + return self.reply(schema.error(0, str(E))) + + except Exception as E: + return self.reply(schema.error(1, str(E))) + + +def run(host, port): + server = TCPServer((host, port), RequestHandler) + try: + server.serve_forever() + except KeyboardInterrupt: + print("bye") + server.server_close() -- cgit v1.2.3