aboutsummaryrefslogtreecommitdiffstats
path: root/prototype
diff options
context:
space:
mode:
authorBlake DeMarcy <ofunknowndescent@gmail.com>2017-04-02 02:35:58 -0500
committerBlake DeMarcy <ofunknowndescent@gmail.com>2017-04-02 02:35:58 -0500
commit26b6dc190733facb01edbb32d3454b4824bac4bc (patch)
tree6dc257da0ab984baea5d461205a004a8fd34de1c /prototype
parentf9e4783f7544134bfeb7db5396d09c684a7560a9 (diff)
downloadbbj-26b6dc190733facb01edbb32d3454b4824bac4bc.tar.gz
initial commit of non-prototype
Diffstat (limited to 'prototype')
-rw-r--r--prototype/clients/elisp/bbj.el637
-rw-r--r--prototype/clients/network_client.py64
-rw-r--r--prototype/clients/urwid/main.py88
l---------prototype/clients/urwid/src/network.py1
-rw-r--r--prototype/clients/urwid/src/widgets.py4
-rw-r--r--prototype/docs/protocol.org189
-rw-r--r--prototype/main.py5
-rw-r--r--prototype/src/db.py240
-rw-r--r--prototype/src/endpoints.py174
-rw-r--r--prototype/src/formatting.py28
-rw-r--r--prototype/src/schema.py97
-rw-r--r--prototype/src/server.py70
12 files changed, 1597 insertions, 0 deletions
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 "<down>") '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 "<up>") 'bbj-prev-post)
+
+ (local-set-key (kbd "g") 'bbj-refresh)
+ (local-set-key (kbd "<f5>") '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 "<S-down>")'scroll-up-line)
+
+ (local-set-key (kbd "K") 'scroll-down-line)
+ (local-set-key (kbd "P") 'scroll-down-line)
+ (local-set-key (kbd "<S-up>") 'scroll-down-line)
+ (local-set-key (kbd "<S-backspace>") '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 "<right>") 'bbj-enter)
+
+ (local-set-key (kbd "q") 'quit-window)
+ (local-set-key (kbd "<left>") 'quit-window)
+ (local-set-key (kbd "<escape>")'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("&gt;&gt;([0-9]+)")
+LINEQUOTES = re.compile("^(&gt;.+)$", flags=re.MULTILINE)
+
+
+def map_html(match):
+ directive, body = match.group(1).lower(), match.group(2)
+ if directive in COLORS:
+ return '<span color="{0}" style="color: {0};">{1}</span>'.format(directive, body)
+ elif directive in MARKUP:
+ return '<{0}>{1}</{0}>'.format(directive[0], body)
+ return body
+
+
+def parse(text, doquotes=True):
+ text = TOKENS.sub(map_html, escape(text))
+ if doquotes:
+ text = QUOTES.sub(r'<span post="\1" class="quote">\g<0></span>', text)
+ return markdown(
+ LINEQUOTES.sub(r'<span class="linequote">\1</span><br>', text)
+ )
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()
Un proyecto texto-plano.xyz