├── .gitignore ├── LICENSE ├── Makejail ├── README.md ├── appjail-files ├── config.json └── pkg.latest.conf ├── clients ├── emacs │ └── bbj.el ├── network_client.py └── urwid │ ├── main.py │ └── network.py ├── config.json.example ├── css ├── 9x1.css ├── base.css └── themeassets │ ├── ms_sans_serif.woff │ ├── ms_sans_serif.woff2 │ ├── ms_sans_serif_bold.woff │ ├── ms_sans_serif_bold.woff2 │ ├── redblocks.bmp │ └── waves.bmp ├── dbupdate.py ├── docs ├── _config.yml ├── docs │ ├── api_overview.md │ ├── errors.md │ ├── img │ │ └── screenshot.png │ ├── index.md │ └── validation.md ├── mkdocs.yml └── site │ ├── 404.html │ ├── api_overview │ └── index.html │ ├── css │ ├── base.css │ ├── bootstrap-custom.min.css │ ├── font-awesome-4.5.0.css │ └── highlight.css │ ├── errors │ └── index.html │ ├── fonts │ ├── fontawesome-webfont.eot │ ├── fontawesome-webfont.svg │ ├── fontawesome-webfont.ttf │ ├── fontawesome-webfont.woff │ ├── fontawesome-webfont.woff2 │ ├── glyphicons-halflings-regular.eot │ ├── glyphicons-halflings-regular.svg │ ├── glyphicons-halflings-regular.ttf │ ├── glyphicons-halflings-regular.woff │ └── glyphicons-halflings-regular.woff2 │ ├── img │ ├── favicon.ico │ ├── grid.png │ └── screenshot.png │ ├── index.html │ ├── js │ ├── base.js │ ├── bootstrap-3.0.3.min.js │ ├── highlight.pack.js │ └── jquery-1.10.2.min.js │ ├── mkdocs │ ├── js │ │ ├── lunr.min.js │ │ ├── mustache.min.js │ │ ├── require.js │ │ ├── search-results-template.mustache │ │ ├── search.js │ │ └── text.js │ └── search_index.json │ ├── sitemap.xml │ └── validation │ └── index.html ├── gendocs.sh ├── js ├── datetime.js └── postboxes.js ├── mkendpoints.py ├── prototype ├── clients │ ├── elisp │ │ └── bbj.el │ ├── network_client.py │ └── urwid │ │ ├── main.py │ │ └── src │ │ ├── network.py │ │ └── widgets.py ├── docs │ └── protocol.org ├── main.py └── src │ ├── db.py │ ├── endpoints.py │ ├── formatting.py │ ├── schema.py │ └── server.py ├── readme.png ├── schema.sql ├── server.py ├── setup.sh ├── src ├── db.py ├── exceptions.py ├── formatting.py ├── schema.py └── utils.py ├── templates ├── account.html ├── threadIndex.html └── threadLoad.html └── todo.org /.gitignore: -------------------------------------------------------------------------------- 1 | /*.db 2 | /config.json 3 | *.sqlite 4 | *__pycache__* 5 | /logs/ 6 | /bin/ 7 | /lib/ 8 | /lib64 9 | /pyvenv.cfg 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Blake DeMarcy 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makejail: -------------------------------------------------------------------------------- 1 | ARG bbj_config=appjail-files/config.json 2 | 3 | OPTION overwrite=force 4 | OPTION start 5 | OPTION volume=bbj-data mountpoint:/bbj/data owner:1001 group:1001 6 | 7 | INCLUDE gh+DtxdF/efficient-makejail 8 | 9 | CMD mkdir -p /usr/local/etc/pkg/repos 10 | COPY appjail-files/pkg.latest.conf /usr/local/etc/pkg/repos/Latest.conf 11 | 12 | PKG python py311-pip py311-sqlite3 git-tiny 13 | 14 | CMD pw useradd -n bbj -d /bbj -c "bulletin board server for small communities" 15 | CMD mkdir -p /bbj /bbj/data 16 | CMD chown bbj:bbj /bbj /bbj/data 17 | 18 | USER bbj 19 | 20 | WORKDIR /bbj 21 | 22 | RUN git clone --depth 1 https://github.com/DtxdF/bbj.git src 23 | 24 | COPY ${bbj_config} src/config.json 25 | CMD chown bbj:bbj /bbj/src/config.json 26 | 27 | RUN pip install cherrypy 28 | RUN pip install urwid 29 | RUN pip install jinja2 30 | 31 | CMD if [ ! -f /bbj/data/bbj.db ]; then sqlite3 /bbj/data/bbj.db < /bbj/src/schema.sql; fi 32 | CMD chown bbj:bbj /bbj/data/bbj.db 33 | CMD chmod 600 /bbj/data/bbj.db 34 | 35 | STOP 36 | 37 | STAGE start 38 | 39 | USER bbj 40 | WORKDIR /bbj/src 41 | 42 | RUN daemon \ 43 | -t "bulletin board server for small communities" \ 44 | -p /bbj/data/pid \ 45 | -o /bbj/data/log \ 46 | python server.py 47 | 48 | STAGE custom:bbj_status 49 | 50 | CMD if [ -f "/bbj/data/pid" ]; then \ 51 | top -ap `head -1 /bbj/data/pid`; \ 52 | fi 53 | 54 | STAGE custom:bbj_log 55 | 56 | CMD if [ -f "/bbj/data/log" ]; then \ 57 | less -R /bbj/data/log; \ 58 | fi 59 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Bulletin Butter & Jelly 2 | 3 | BBJ is a trivial collection of python scripts and database queries that 4 | create a fully functional, text-driven community bulletin board. 5 | Requires Python 3.4 and up for the server and the official TUI client (clients/urwid/). 6 | 7 | ![AAAAAAAAAAAAAAAAAAAA](readme.png) 8 |

it boots!

9 | 10 | It's all driven by an API sitting on top of CherryPy. Currently it does not 11 | serve HTML but this is planned for the (distant?) future. 12 | 13 | The two official client implementations are a stand alone TUI client for 14 | the unix terminal, and GNU Emacs. The API is simple and others are welcome 15 | to join the party at some point. 16 | -------------------------------------------------------------------------------- /appjail-files/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "admins": [ 3 | "admin" 4 | ], 5 | "port": 7099, 6 | "host": "0.0.0.0", 7 | "instance_name": "BBJ (AppJail)", 8 | "allow_anon": false, 9 | "debug": false, 10 | "codes": {}, 11 | "dbname": "/bbj/data/bbj.db" 12 | } 13 | -------------------------------------------------------------------------------- /appjail-files/pkg.latest.conf: -------------------------------------------------------------------------------- 1 | FreeBSD: { 2 | url: "pkg+https://pkg.FreeBSD.org/${ABI}/latest", 3 | mirror_type: "srv", 4 | signature_type: "fingerprints", 5 | fingerprints: "/usr/share/keys/pkg", 6 | enabled: yes 7 | } 8 | -------------------------------------------------------------------------------- /config.json.example: -------------------------------------------------------------------------------- 1 | { 2 | "port": 7099, 3 | "host": "127.0.0.1", 4 | "instance_name": "BBJ", 5 | "allow_anon": true, 6 | "debug": false 7 | } 8 | -------------------------------------------------------------------------------- /css/9x1.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: ms-sans-serif; 3 | src: url(themeassets/ms_sans_serif.woff); 4 | } 5 | 6 | @font-face { 7 | font-family: ms-sans-serif-bold; 8 | src: url(themeassets/ms_sans_serif_bold.woff); 9 | } 10 | 11 | body { 12 | background-color: #008080; 13 | padding-top: 1em; 14 | font-size: 11px; 15 | font-family: ms-sans-serif; 16 | font-smooth: never; 17 | -webkit-font-smoothing : none; 18 | } 19 | 20 | .indexContainer { 21 | box-shadow: -2px -2px #e0dede, -2px 0 #e0dede, 0 -2px #e0dede, -4px -4px white, -4px 0 white, 0 -4px white, 2px 2px #818181, 0 2px #818181, 2px 0 #818181, 2px -2px #e0dede, -2px 2px #818181, -4px 2px white, -4px 4px black, 4px 4px black, 4px 0 black, 0 4px black, 2px -4px white, 4px -4px black; 22 | background-color: #c3c3c3; 23 | max-width: 45em; 24 | margin: 1em; 25 | margin-bottom: 1.8em; 26 | padding-bottom: 0.2em; 27 | } 28 | 29 | h2 { 30 | font-size: 11px; 31 | font-family: ms-sans-serif-bold; 32 | background-color: #010081; 33 | color: white; 34 | padding: 0.1em; 35 | margin: 0px; 36 | background-image: linear-gradient(to right, #010081, #008080); 37 | } 38 | 39 | h3 { 40 | font-size: 11px; 41 | font-family: ms-sans-serif-bold; 42 | } 43 | 44 | .threadTitle { 45 | color: white; 46 | font-family: ms-sans-serif-bold; 47 | padding: 0.3em; 48 | max-width: 50em; 49 | background-color: #010081; 50 | background-image: linear-gradient(to right, #010081, #008080); 51 | font-size: 11px; 52 | 53 | } 54 | 55 | .threadLink { 56 | font-family: ms-sans-serif-bold; 57 | } 58 | 59 | .thread { 60 | margin: 1em; 61 | } 62 | 63 | a, a:visited { 64 | color: #010081; 65 | } 66 | 67 | .postId { 68 | margin-right: 0.5em; 69 | } 70 | 71 | .messagesContainer { 72 | margin: 1em; 73 | box-shadow: -2px -2px #e0dede, -2px 0 #e0dede, 0 -2px #e0dede, -4px -4px white, -4px 0 white, 0 -4px white, 2px 2px #818181, 0 2px #818181, 2px 0 #818181, 2px -2px #e0dede, -2px 2px #818181, -4px 2px white, -4px 4px black, 4px 4px black, 4px 0 black, 0 4px black, 2px -4px white, 4px -4px black; 74 | max-width: 50em; 75 | background-color: #c3c3c3; 76 | } 77 | 78 | .messageHeader { 79 | font-size: 11px; 80 | } 81 | 82 | .message { 83 | padding: 1em; 84 | border-bottom: 1px dotted black; 85 | 86 | } 87 | 88 | #navbar { 89 | overflow: hidden; 90 | background-color: #c3c3c3; 91 | /* font-size: 1em; */ 92 | padding-top: 0.12em; 93 | padding-bottom: 0.12em; 94 | margin: 0px; 95 | position: fixed; 96 | top: 0; 97 | left: 0; 98 | width: 100%; 99 | color: black; 100 | border-bottom: 2px solid black; 101 | } 102 | 103 | .bbjLogo { 104 | padding: 0.10em; 105 | padding-top: 0.12em; 106 | padding-bottom: 0.12em; 107 | margin: 0px; 108 | text-decoration: underline; 109 | } 110 | 111 | .indexLink { 112 | padding: 0.10em; 113 | padding-top: 0.12em; 114 | padding-bottom: 0.12em; 115 | margin: 0px; 116 | color: black; 117 | } 118 | 119 | .newPostButton { 120 | box-shadow: inset -1px -1px #0a0a0a,inset 1px 1px #fff,inset -2px -2px grey,inset 2px 2px #dfdfdf; 121 | background-color: #c3c3c3; 122 | padding: 0.5em; 123 | margin: 1em; 124 | margin-top: 1.5em; 125 | display: inline-block; 126 | text-decoration: none; 127 | color: black; 128 | } 129 | 130 | .newReplyButton { 131 | box-shadow: inset -1px -1px #0a0a0a,inset 1px 1px #fff,inset -2px -2px grey,inset 2px 2px #dfdfdf; 132 | padding: 0.5em; 133 | color: black; 134 | text-decoration: none; 135 | display: inline-block; 136 | margin-left: 1em; 137 | } 138 | 139 | input[type=submit] { 140 | box-shadow: inset -1px -1px #0a0a0a,inset 1px 1px #fff,inset -2px -2px grey,inset 2px 2px #dfdfdf; 141 | /* padding: 0.5em; */ 142 | color: black; 143 | background-color: #c3c3c3; 144 | } 145 | 146 | .settingsContainer { 147 | box-shadow: inset -1px -1px #0a0a0a,inset 1px 1px #fff,inset -2px -2px grey,inset 2px 2px #dfdfdf; 148 | background-color: #c3c3c3; 149 | max-width: 40em; 150 | padding: 1em; 151 | margin-top: 1em; 152 | } 153 | 154 | pre { 155 | white-space: pre-wrap; 156 | font-family: inherit; 157 | } 158 | 159 | .color1 { 160 | color: red; 161 | } 162 | 163 | .color2 { 164 | color: #AC6A00; 165 | } 166 | 167 | .color3 { 168 | color: green; 169 | } 170 | 171 | .color4 { 172 | color: blue; 173 | } 174 | 175 | .color5 { 176 | color: #008b99; 177 | } 178 | 179 | .color6 { 180 | color: magenta; 181 | } 182 | 183 | -------------------------------------------------------------------------------- /css/base.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: monospace; 3 | font-size: 110%; 4 | padding-top: 1em; 5 | } 6 | 7 | .thread { 8 | padding: 1em; 9 | margin: 1em; 10 | border: 2px solid black; 11 | max-width: 40em; 12 | } 13 | 14 | .pinned { 15 | background-color: #c1ffea; 16 | } 17 | 18 | .bookmarked { 19 | background-color: #c4e8ff; 20 | } 21 | 22 | .message { 23 | padding: 1em; 24 | margin: 1em; 25 | border: 2px solid black; 26 | max-width: 60em; 27 | } 28 | 29 | #navbar { 30 | overflow: hidden; 31 | background-color: #CCC; 32 | font-size: 1em; 33 | padding-top: 0.12em; 34 | padding-bottom: 0.12em; 35 | margin: 0px; 36 | position: fixed; 37 | top: 0; 38 | left: 0; 39 | width: 100%; 40 | } 41 | 42 | .bbjLogo { 43 | background-color: black; 44 | color: white; 45 | padding: 0.10em; 46 | padding-top: 0.12em; 47 | padding-bottom: 0.12em; 48 | margin: 0px; 49 | 50 | } 51 | 52 | .indexLink, .indexLink:visited { 53 | background-color: black; 54 | color: white; 55 | padding: 0.10em; 56 | padding-top: 0.12em; 57 | padding-bottom: 0.12em; 58 | margin: 0px; 59 | } 60 | 61 | .newPostButton { 62 | font-size: 140%; 63 | padding: 0.5em; 64 | margin: 1em; 65 | margin-top: 1.5em; 66 | background-color: black; 67 | color: white; 68 | text-decoration: none; 69 | display: inline-block; 70 | } 71 | 72 | .newReplyButton { 73 | font-size: 120%; 74 | padding: 0.25em; 75 | background-color: black; 76 | color: white; 77 | text-decoration: none; 78 | display: inline-block; 79 | } 80 | 81 | pre { 82 | white-space: pre-wrap; 83 | } 84 | 85 | .color1 { 86 | color: red; 87 | } 88 | 89 | .color2 { 90 | color: #F39C11; 91 | } 92 | 93 | .color3 { 94 | color: green; 95 | } 96 | 97 | .color4 { 98 | color: blue; 99 | } 100 | 101 | .color5 { 102 | color: #0dabbb; 103 | } 104 | 105 | .color6 { 106 | color: magenta; 107 | } 108 | 109 | -------------------------------------------------------------------------------- /css/themeassets/ms_sans_serif.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bbj-dev/bbj/50ffb5ebc96221d947502c06a5f1fbda1a062219/css/themeassets/ms_sans_serif.woff -------------------------------------------------------------------------------- /css/themeassets/ms_sans_serif.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bbj-dev/bbj/50ffb5ebc96221d947502c06a5f1fbda1a062219/css/themeassets/ms_sans_serif.woff2 -------------------------------------------------------------------------------- /css/themeassets/ms_sans_serif_bold.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bbj-dev/bbj/50ffb5ebc96221d947502c06a5f1fbda1a062219/css/themeassets/ms_sans_serif_bold.woff -------------------------------------------------------------------------------- /css/themeassets/ms_sans_serif_bold.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bbj-dev/bbj/50ffb5ebc96221d947502c06a5f1fbda1a062219/css/themeassets/ms_sans_serif_bold.woff2 -------------------------------------------------------------------------------- /css/themeassets/redblocks.bmp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bbj-dev/bbj/50ffb5ebc96221d947502c06a5f1fbda1a062219/css/themeassets/redblocks.bmp -------------------------------------------------------------------------------- /css/themeassets/waves.bmp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bbj-dev/bbj/50ffb5ebc96221d947502c06a5f1fbda1a062219/css/themeassets/waves.bmp -------------------------------------------------------------------------------- /dbupdate.py: -------------------------------------------------------------------------------- 1 | import sqlite3 2 | 3 | with sqlite3.connect("data.sqlite") as _con: 4 | _con.execute('ALTER TABLE threads ADD COLUMN last_author text DEFAULT ""') 5 | _con.commit() 6 | for tid in _con.execute("SELECT thread_id FROM threads"): 7 | author = _con.execute("SELECT author FROM messages WHERE thread_id = ? ORDER BY post_id", tid).fetchall()[-1] 8 | _con.execute("UPDATE threads SET last_author = ? WHERE thread_id = ?", author + tid) 9 | _con.commit() 10 | -------------------------------------------------------------------------------- /docs/_config.yml: -------------------------------------------------------------------------------- 1 | theme: jekyll-theme-midnight -------------------------------------------------------------------------------- /docs/docs/api_overview.md: -------------------------------------------------------------------------------- 1 | 2 | # How to BBJ? 3 | 4 | ## Input 5 | 6 | BBJ is interacted with entirely through POST requests, whose bodies are 7 | json objects. 8 | 9 | The endpoints, all listed below, can be contacted at the path /api/ relative 10 | to the root of where BBJ is hosted. If bbj is hosted on a server on port 80 11 | at the root: 12 | 13 | `http://server.com/api/endpoint_here` 14 | 15 | The body of your request contains all of it's argument fields, instead of 16 | using URL parameters. As a demonstration, to call `thread_create`, 17 | it requires two arguments: `title`, and `body`. We put those argument 18 | names at the root of the json object, and their values are the info 19 | passed into the API for that spot. Your input will look like this: 20 | 21 | ```json 22 | { 23 | "title": "Hello world!!", 24 | "body": "Hi! I am exploring this cool board thing!!" 25 | } 26 | ``` 27 | 28 | And you will POST this body to `http://server.com:PORT/api/thread_create`. 29 | 30 | A few endpoints do not require any arguments. These can still be POSTed to, 31 | but the body may be completely empty or an empty json object. You can even 32 | GET these if you so choose. 33 | 34 | For all endpoints, argument keys that are not consumed by the endpoint are 35 | ignored. Posting an object with a key/value pair of `"sandwich": True` will 36 | not clog up any pipes :) In the same vein, endpoints who dont take arguments 37 | don't care if you supply them anyway. 38 | 39 | ## Output 40 | 41 | BBJ returns data in a consistently formatted json object. The base object 42 | has three keys: `data`, `usermap`, and `error`. Visualizied: 43 | 44 | ```javascript 45 | { 46 | "error": false, // boolean false or error object 47 | "data": null, // null or the requested data from endpoint. 48 | "usermap": {} // potentially empty object, maps user_ids to user objects 49 | } 50 | 51 | // If "error" is true, it looks like this: 52 | 53 | { 54 | "error": { 55 | "code": // an integer from 0 to 5, 56 | "description": // a string describing the error in detail. 57 | } 58 | "data": null // ALWAYS null if error is not false 59 | "usermap": {} // ALWAYS empty if error is not false 60 | } 61 | ``` 62 | 63 | ### data 64 | 65 | `data` is what the endpoint actually returns. The type of contents vary 66 | by endpoint and are documented below. If an endpoint says it returns a 67 | boolean, it will look like `"data": True`. If it says it returns an array, 68 | it will look like `"data": ["stuff", "goes", "here"]` 69 | 70 | ### usermap 71 | 72 | The usermap is a json object mapping user_ids within `data` to full user 73 | objects. BBJ handles users entirely by an ID system, meaning any references 74 | to them inside of response data will not include vital information like their 75 | username, or their profile information. Instead, we fetch those values from 76 | this usermap object. All of it's root keys are user_id's and their values 77 | are user objects. It should be noted that the anonymous user has it's own 78 | ID and profile object as well. 79 | 80 | ### error 81 | 82 | `error` is typically `false`. If it is __not__ false, then the request failed 83 | and the json object that `error` contains should be inspected. (see the above 84 | visualation) Errors follow a strict code system, making it easy for your client 85 | to map these responses to native exception types or signals in your language of 86 | choice. See [the full error page](errors.md) for details. 87 | 88 | 89 |

90 | # Authorization 91 | ------ 92 | See also [the Authorization page](authorization.md). 93 | ## check_auth 94 | 95 | **Arguments:** 96 | 97 | * __target_user__: string: either a user_name or a user_id 98 | 99 | * __target_hash__: string: sha256 hash for the password to check 100 | 101 | 102 | 103 | Returns boolean `true` or `false` of whether the hash given 104 | is correct for the given user. 105 | 106 | 107 |
108 |

109 | # Threads & Messages 110 | ------ 111 | ## delete_post 112 | 113 | **Arguments:** 114 | 115 | * __thread_id__: string: the id of the thread this message was posted in. 116 | 117 | * __post_id__: integer: the id of the target message. 118 | 119 | 120 | 121 | Requires the arguments `thread_id` and `post_id`. 122 | 123 | Delete a message from a thread. The same rules apply 124 | here as `edit_post` and `edit_query`: the logged in user 125 | must either be the one who posted the message within 24hrs, 126 | or have admin rights. The same error descriptions and code 127 | are returned on falilure. Boolean true is returned on 128 | success. 129 | 130 | If the post_id is 0, the whole thread is deleted. 131 | 132 | 133 |
134 | ## edit_post 135 | 136 | **Arguments:** 137 | 138 | * __thread_id__: string: the thread the message was posted in. 139 | 140 | * __post_id__: integer: the target post_id to edit. 141 | 142 | * __body__: string: the new message body. 143 | 144 | * __OPTIONAL: send_raw__: boolean: set the formatting mode for the target message. 145 | 146 | 147 | 148 | Replace a post with a new body. Requires the arguments 149 | `thread_id`, `post_id`, and `body`. This method verifies 150 | that the user can edit a post before commiting the change, 151 | otherwise an error object is returned whose description 152 | should be shown to the user. 153 | 154 | To perform sanity checks and retrieve the unformatted body 155 | of a post without actually attempting to replace it, use 156 | `edit_query` first. 157 | 158 | Optionally you may also include the argument `send_raw` to 159 | set the message's formatting flag. However, if this is the 160 | only change you would like to make, you should use the 161 | endpoint `set_post_raw` instead. 162 | 163 | Returns the new message object. 164 | 165 | 166 |
167 | ## edit_query 168 | 169 | **Arguments:** 170 | 171 | * __thread_id__: string: the id of the thread the message was posted in. 172 | 173 | * __post_id__: integer: the id of the target message. 174 | 175 | 176 | 177 | Queries the database to ensure the user can edit a given 178 | message. Requires the arguments `thread_id` and `post_id` 179 | (does not require a new body) 180 | 181 | Returns the original message object without any formatting 182 | on success. Returns a descriptive code 4 otherwise. 183 | 184 | 185 |
186 | ## message_feed 187 | 188 | **Arguments:** 189 | 190 | * __time__: int/float: epoch/unix time of the earliest point of interest 191 | 192 | * __OPTIONAL: format__: string: the specifier for the desired formatting engine 193 | 194 | 195 | 196 | Returns a special object representing all activity on the board since `time`. 197 | 198 | ```javascript 199 | { 200 | "threads": { 201 | "thread_id": { 202 | // ...thread object 203 | }, 204 | // ...more thread_id/object pairs 205 | }, 206 | "messages": [ 207 | ...standard message object array sorted by date 208 | ] 209 | } 210 | ``` 211 | 212 | The message objects in `messages` are the same objects returned 213 | in threads normally. They each have a thread_id parameter, and 214 | you can access metadata for these threads by the `threads` object 215 | which is also provided. 216 | 217 | The `messages` array is already sorted by submission time, newest 218 | first. The order in the threads object is undefined and you should 219 | instead use their `last_mod` attribute if you intend to list them 220 | out visually. 221 | 222 | 223 |
224 | ## set_post_raw 225 | 226 | **Arguments:** 227 | 228 | * __thread_id__: string: the id of the thread the message was posted in. 229 | 230 | * __post_id__: integer: the id of the target message. 231 | 232 | * __value__: boolean: the new `send_raw` value to apply to the message. 233 | 234 | 235 | 236 | Requires the boolean argument of `value`, string argument 237 | `thread_id`, and integer argument `post_id`. `value`, when false, 238 | means that the message will be passed through message formatters 239 | before being sent to clients. When `value` is true, this means 240 | it will never go through formatters, all of its whitespace is 241 | sent to clients verbatim and expressions are not processed. 242 | 243 | The same rules for editing messages (see `edit_query`) apply here 244 | and the same error objects are returned for violations. 245 | 246 | You may optionally set this value as well when using `edit_post`, 247 | but if this is the only change you want to make to the message, 248 | using this endpoint instead is preferable. 249 | 250 | 251 |
252 | ## set_thread_pin 253 | 254 | **Arguments:** 255 | 256 | * __thread_id__: string: the id of the thread to modify. 257 | 258 | * __value__: boolean: `true` to pin thread, `false` otherwise. 259 | 260 | 261 | 262 | Requires the arguments `thread_id` and `value`. `value` 263 | must be a boolean of what the pinned status should be. 264 | This method requires that the caller is logged in and 265 | has admin status on their account. 266 | 267 | Returns the same boolean you supply as `value` 268 | 269 | 270 |
271 | ## thread_create 272 | 273 | **Arguments:** 274 | 275 | * __body__: string: The body of the first message 276 | 277 | * __title__: string: The title name for this thread 278 | 279 | * __OPTIONAL: send_raw__: boolean: formatting mode for the first message. 280 | 281 | 282 | 283 | Creates a new thread and returns it. Requires the non-empty 284 | string arguments `body` and `title`. 285 | 286 | If the argument `send_raw` is specified and has a non-nil 287 | value, the OP message will never recieve special formatting. 288 | 289 | 290 |
291 | ## thread_index 292 | 293 | **Arguments:** 294 | 295 | * __OPTIONAL: include_op__: boolean: Include a `messages` object containing the original post 296 | 297 | 298 | 299 | Return an array with all the server's threads. They are already sorted for 300 | you; most recently modified threads are at the beginning of the array. 301 | Unless you supply `include_op`, these threads have no `messages` parameter. 302 | If you do, the `messages` parameter is an array with a single message object 303 | for the original post. 304 | 305 | 306 |
307 | ## thread_load 308 | 309 | **Arguments:** 310 | 311 | * __thread_id__: string: the thread to load. 312 | 313 | * __OPTIONAL: op_only__: boolean: include only the original message in `messages` 314 | 315 | * __OPTIONAL: format__: string: the formatting type of the returned messages. 316 | 317 | 318 | 319 | Returns the thread object with all of its messages loaded. 320 | Requires the argument `thread_id`. `format` may also be 321 | specified as a formatter to run the messages through. 322 | Currently only "sequential" is supported. 323 | 324 | You may also supply the parameter `op_only`. When it's value 325 | is non-nil, the messages array will only include post_id 0 (the first) 326 | 327 | 328 |
329 | ## thread_reply 330 | 331 | **Arguments:** 332 | 333 | * __thread_id__: string: the id for the thread this message should post to. 334 | 335 | * __body__: string: the message's body of text. 336 | 337 | * __OPTIONAL: send_raw__: boolean: formatting mode for the posted message. 338 | 339 | 340 | 341 | Creates a new reply for the given thread and returns it. 342 | Requires the string arguments `thread_id` and `body` 343 | 344 | If the argument `send_raw` is specified and has a non-nil 345 | value, the message will never recieve special formatting. 346 | 347 | 348 |
349 |

350 | # Tools 351 | ------ 352 | ## db_validate 353 | 354 | **Arguments:** 355 | 356 | * __key__: string: the identifier for the ruleset to check. 357 | 358 | * __value__: VARIES: the object for which `key` will check for. 359 | 360 | * __OPTIONAL: error__: boolean: when `true`, will return an API error response instead of a special object. 361 | 362 | 363 | 364 | See also [the Input Validation page](validation.md). 365 | 366 | Requires the arguments `key` and `value`. Returns an object 367 | with information about the database sanity criteria for 368 | key. This can be used to validate user input in the client 369 | before trying to send it to the server. 370 | 371 | If the argument `error` is supplied with a non-nil value, 372 | the server will return a standard error object on failure 373 | instead of the special object described below. 374 | 375 | The returned object has two keys: 376 | 377 | { 378 | "bool": true/false, 379 | "description": null/"why this value is bad" 380 | } 381 | 382 | If bool == false, description is a string describing the 383 | problem. If bool == true, description is null and the 384 | provided value is safe to use. 385 | 386 | 387 |
388 | ## format_message 389 | 390 | **Arguments:** 391 | 392 | * __body__: string: the message body to apply formatting to. 393 | 394 | * __format__: string: the specifier for the desired formatting engine 395 | 396 | 397 | 398 | Requires the arguments `body` and `format`. Applies 399 | `format` to `body` and returns the new object. See 400 | `thread_load` for supported specifications for `format`. 401 | 402 | 403 |
404 | ## user_map 405 | 406 | _requires no arguments_ 407 | 408 | Returns an array with all registered user_ids, with the usermap 409 | object populated by their full objects. This method is _NEVER_ 410 | neccesary when using other endpoints, as the usermap returned 411 | on those requests already contains all the information you will 412 | need. This endpoint is useful for statistic purposes only. 413 | 414 | 415 |
416 |

417 | # Users 418 | ------ 419 | ## get_me 420 | 421 | _requires no arguments_ 422 | 423 | Requires no arguments. Returns your internal user object, 424 | including your `auth_hash`. 425 | 426 | 427 |
428 | ## is_admin 429 | 430 | **Arguments:** 431 | 432 | * __target_user__: string: user_id or user_name to check against. 433 | 434 | 435 | 436 | Requires the argument `target_user`. Returns a boolean 437 | of whether that user is an admin. 438 | 439 | 440 |
441 | ## user_get 442 | 443 | **Arguments:** 444 | 445 | * __target_user__: string: either a user_name or a user_id 446 | 447 | 448 | 449 | Returns a user object for the given target. 450 | 451 | 452 |
453 | ## user_is_registered 454 | 455 | **Arguments:** 456 | 457 | * __target_user__: string: either a user_name or a user_id 458 | 459 | 460 | 461 | Returns boolean `true` or `false` of whether the given target is 462 | registered on the server. 463 | 464 | 465 |
466 | ## user_register 467 | 468 | **Arguments:** 469 | 470 | * __user_name__: string: the desired display name 471 | 472 | * __auth_hash__: string: a sha256 hash of a password 473 | 474 | * __code__: string: invitation code 475 | 476 | 477 | 478 | Register a new user into the system and return the new user object 479 | on success. The returned object includes the same `user_name` and 480 | `auth_hash` that you supply, in addition to all the default user 481 | parameters. Returns code 4 errors for any failures. 482 | 483 | 484 |
485 | ## user_update 486 | 487 | **Arguments:** 488 | 489 | * __Any of the following may be submitted__: 490 | 491 | * __user_name__: string: a desired display name 492 | 493 | * __auth_hash__: string: sha256 hash for a new password 494 | 495 | * __quip__: string: a short string that can be used as a signature 496 | 497 | * __bio__: string: a user biography for their profile 498 | 499 | * __color__: integer: 0-6, a display color for the user 500 | 501 | 502 | 503 | Receives new parameters and assigns them to the user object. 504 | This method requires that you send a valid User/Auth header 505 | pair with your request, and the changes are made to that 506 | account. 507 | 508 | Take care to keep your client's User/Auth header pair up to date 509 | after using this method. 510 | 511 | The newly updated user object is returned on success, 512 | including the `auth_hash`. 513 | 514 | 515 |
516 | -------------------------------------------------------------------------------- /docs/docs/errors.md: -------------------------------------------------------------------------------- 1 | ## Handling Error Responses 2 | 3 | Errors in BBJ are separated into 6 different codes, to allow easy mapping to 4 | native exception and signaling systems available in the client's programming 5 | language. Errors are all or nothing, there are no "warnings". If a response has 6 | a non-false error field, then data will always be null. An error response from 7 | the api looks like this... 8 | 9 | ```javascript 10 | { 11 | "error": { 12 | "code": // an integer from 0 to 5, 13 | "description": // a string describing the error in detail. 14 | } 15 | "data": null // ALWAYS null if error is not false 16 | "usermap": {} // ALWAYS empty if error is not false 17 | } 18 | ``` 19 | 20 | The codes split errors into categories. Some are oriented 21 | to client developers while others should be shown directly to 22 | users. 23 | 24 | * **Code 0**: Malformed but non-empty json input. An empty json input where it is required is handled by code 3. This is just decoding errors. The exception text is returned as description. 25 | 26 | * **Code 1**: Internal server error. A short representation of the internal exception as well as the code the server logged it as is returned in the description. Your clients cannot recover from this class of error, and its probably not your fault if you encounter it. If you ever get one, file a bug report. 27 | 28 | * **Code 2**: Server HTTP error: This is similar to the above but captures errors for the HTTP server rather than BBJs own codebase. The description contains the HTTP error code and server description. This notably covers 404s and thus invalid endpoint names. The HTTP error code is left intact, so you may choose to let your HTTP library or tool of choice handle these for you. 29 | 30 | * **Code 3**: Parameter error: client sent erroneous input for its method. This could mean missing arguments, type errors, etc. It generalizes errors that should be fixed by the client developer and the returned descriptions are geared to them rather than end users. 31 | 32 | * **Code 4**: User error: These errors regard actions that the user has taken that are invalid, but not really errors in a traditional sense. The description field should be shown to users verbatim, in a clear and noticeable fashion. They are formatted as concise English sentences and end with appropriate punctuation marks. 33 | 34 | * **Code 5**: Authorization error: This code represents an erroneous User/Auth header pair. This should trigger the user to provide correct credentials or fall back to anon mode. 35 | -------------------------------------------------------------------------------- /docs/docs/img/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bbj-dev/bbj/50ffb5ebc96221d947502c06a5f1fbda1a062219/docs/docs/img/screenshot.png -------------------------------------------------------------------------------- /docs/docs/index.md: -------------------------------------------------------------------------------- 1 | # Bulletin Butter & Jelly 2 | ## A simple community textboard 3 | ### BBJ is a trivial collection of python scripts and database queries that miraculously shit out a fully functional client-server textboard with Python 3.4+ 4 | 5 | See also: the [GitHub repository](https://github.com/desvox/bbj). 6 | 7 | BBJ is heavily inspired by image boards like 4chan, but it offers a simple 8 | account system to allow users to identify themselves and set profile 9 | attributes like a more traditional forum. Registration is optional and there 10 | are only minimal restrictions on anonymous participation. 11 | 12 | ![screenshot](img/screenshot.png) 13 | 14 | Being a command-line-oriented text board, BBJ has no avatars or file sharing 15 | capabilties, so its easier to administrate and can't be used to distribute illegal 16 | content like imageboards. It has very few dependancies and is easy to set up. 17 | 18 | The API is simple and doesn't use require complex authorization schemes or session management. 19 | It is fully documented on this site (though the verbage is still being revised for friendliness) 20 | -------------------------------------------------------------------------------- /docs/docs/validation.md: -------------------------------------------------------------------------------- 1 | ## Implementing good sanity checks in your client. 2 | 3 | The server has an endpoint called `db_validate`. What this does is take 4 | a `key` and a `value` argument, and compares `value` to a set of rules specified by 5 | `key`. This is the same function used internally by the database to check 6 | values before committing them to the database. By default it returns a 7 | descriptive object under `data`, but you can specify the key/value pair 8 | `"error": True` to get a standard error response back. A standard call 9 | to `db_validate` will look like this. 10 | 11 | ``` 12 | { 13 | "key": "title", 14 | "value": "this title\nis bad \nbecause it contains \nnewlines" 15 | } 16 | ``` 17 | 18 | and the server will respond like this when the input should be corrected. 19 | 20 | ``` 21 | { 22 | "data": { 23 | "bool": False, 24 | "description": "Titles cannot contain whitespace characters besides spaces." 25 | }, 26 | "error": False, 27 | "usermap": {} 28 | } 29 | ``` 30 | 31 | if everything is okay, the data object will look like this instead. 32 | 33 | ``` 34 | "data": { 35 | "bool": True, 36 | "description": null 37 | }, 38 | ``` 39 | 40 | Alternatively, you can supply `"error": True` in the request. 41 | 42 | ``` 43 | { 44 | "error": True, 45 | "key": "title", 46 | "value": "this title\nis bad \nbecause it contains \nnewlines" 47 | } 48 | // and you get... 49 | { 50 | "data": null, 51 | "usermap": {}, 52 | "error": { 53 | "code": 4, 54 | "description": "Titles cannot contain whitespace characters besides spaces." 55 | } 56 | } 57 | ``` 58 | 59 | The following keys are currently available. 60 | 61 | * "user_name" 62 | * "auth_hash" 63 | * "quip" 64 | * "bio" 65 | * "title" 66 | * "body" 67 | * "color" 68 | 69 | The descriptions returned are friendly, descriptive, and should be shown 70 | directly to users 71 | 72 | 73 | By using this endpoint, you will never have to validate values in your 74 | own code before sending them to the server. This means you can do things 75 | like implement an interactive prompt which will not allow the user to 76 | submit it unless the value is correct. 77 | 78 | This is used in the elisp client when registering users and for the thread 79 | title prompt which is shown before opening a composure window. The reason 80 | for rejection is displayed clearly to the user and input window is restored. 81 | 82 | ```lisp 83 | (defun bbj-sane-value (prompt key) 84 | "Opens an input loop with the user, where the response is 85 | passed to the server to check it for validity before the 86 | user is allowed to continue. Will recurse until the input 87 | is valid, then it is returned." 88 | (let* ((value (read-from-minibuffer prompt)) 89 | (response (bbj-request! 'db_validate 'value value 'key key))) 90 | (if (alist-get 'bool response) 91 | value ;; return the user's input back to the caller 92 | (message (alist-get 'description response)) 93 | (sit-for 2) 94 | (bbj-sane-value prompt key)))) 95 | ``` 96 | -------------------------------------------------------------------------------- /docs/mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: BBJ 2 | pages: 3 | - Home: index.md 4 | - API: 5 | - 'Overview & Endpoints': api_overview.md 6 | - 'Errors': errors.md 7 | - 'Input Validation': validation.md 8 | -------------------------------------------------------------------------------- /docs/site/404.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | BBJ 12 | 13 | 14 | 15 | 16 | 17 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 80 | 81 |
82 | 83 |
84 |
85 |

404

86 |

Page not found

87 |
88 |
89 | 90 | 91 |
92 | 93 | 97 | 98 | 99 | 123 | 124 | 125 | 126 | -------------------------------------------------------------------------------- /docs/site/css/base.css: -------------------------------------------------------------------------------- 1 | body { 2 | padding-top: 70px; 3 | background: url(../img/grid.png) repeat-x; 4 | background-attachment: fixed; 5 | background-color: #f8f8f8; 6 | } 7 | 8 | body > .container { 9 | min-height: 400px; 10 | } 11 | 12 | ul.nav .main { 13 | font-weight: bold; 14 | } 15 | 16 | .col-md-3 { 17 | padding-left: 0; 18 | } 19 | 20 | .col-md-9 { 21 | padding-bottom: 100px; 22 | } 23 | 24 | .source-links { 25 | float: right; 26 | } 27 | 28 | .col-md-9 img { 29 | max-width: 100%; 30 | display: inline-block; 31 | padding: 4px; 32 | line-height: 1.428571429; 33 | background-color: #fff; 34 | border: 1px solid #ddd; 35 | border-radius: 4px; 36 | margin: 20px auto 30px auto; 37 | } 38 | 39 | /* 40 | * The code below adds some padding to the top of the current anchor target so 41 | * that, when navigating to it, the header isn't hidden by the navbar at the 42 | * top. This is especially complicated because we want to *remove* the padding 43 | * after navigation so that hovering over the header shows the permalink icon 44 | * correctly. Thus, we create a CSS animation to remove the extra padding after 45 | * a second. We have two animations so that navigating to an anchor within the 46 | * page always restarts the animation. 47 | * 48 | * See for more details. 49 | */ 50 | :target::before { 51 | content: ""; 52 | display: block; 53 | margin-top: -75px; 54 | height: 75px; 55 | pointer-events: none; 56 | animation: 0s 1s forwards collapse-anchor-padding-1; 57 | } 58 | 59 | .clicky :target::before { 60 | animation-name: collapse-anchor-padding-2; 61 | } 62 | 63 | @keyframes collapse-anchor-padding-1 { 64 | to { 65 | margin-top: 0; 66 | height: 0; 67 | } 68 | } 69 | 70 | @keyframes collapse-anchor-padding-2 { 71 | to { 72 | margin-top: 0; 73 | height: 0; 74 | } 75 | } 76 | 77 | h1 { 78 | color: #444; 79 | font-weight: 400; 80 | font-size: 42px; 81 | } 82 | 83 | h2, h3, h4, h5, h6 { 84 | color: #444; 85 | font-weight: 300; 86 | } 87 | 88 | hr { 89 | border-top: 1px solid #aaa; 90 | } 91 | 92 | pre, .rst-content tt { 93 | max-width: 100%; 94 | background: #fff; 95 | border: solid 1px #e1e4e5; 96 | color: #333; 97 | overflow-x: auto; 98 | } 99 | 100 | code.code-large, .rst-content tt.code-large { 101 | font-size: 90%; 102 | } 103 | 104 | code { 105 | padding: 2px 5px; 106 | background: #fff; 107 | border: solid 1px #e1e4e5; 108 | color: #333; 109 | white-space: pre-wrap; 110 | word-wrap: break-word; 111 | } 112 | 113 | pre code { 114 | background: transparent; 115 | border: none; 116 | white-space: pre; 117 | word-wrap: normal; 118 | font-family: monospace,serif; 119 | font-size: 12px; 120 | } 121 | 122 | a code { 123 | color: #2FA4E7; 124 | } 125 | 126 | a:hover code, a:focus code { 127 | color: #157AB5; 128 | } 129 | 130 | footer { 131 | margin-top: 30px; 132 | margin-bottom: 10px; 133 | text-align: center; 134 | font-weight: 200; 135 | } 136 | 137 | .modal-dialog { 138 | margin-top: 60px; 139 | } 140 | 141 | /* 142 | * Side navigation 143 | * 144 | * Scrollspy and affixed enhanced navigation to highlight sections and secondary 145 | * sections of docs content. 146 | */ 147 | 148 | /* By default it's not affixed in mobile views, so undo that */ 149 | .bs-sidebar.affix { /* csslint allow: adjoining-classes */ 150 | position: static; 151 | } 152 | 153 | .bs-sidebar.well { /* csslint allow: adjoining-classes */ 154 | padding: 0; 155 | max-height: 90%; 156 | overflow-y: auto; 157 | } 158 | 159 | /* First level of nav */ 160 | .bs-sidenav { 161 | padding-top: 10px; 162 | padding-bottom: 10px; 163 | border-radius: 5px; 164 | } 165 | 166 | /* All levels of nav */ 167 | .bs-sidebar .nav > li > a { 168 | display: block; 169 | padding: 5px 20px; 170 | z-index: 1; 171 | } 172 | .bs-sidebar .nav > li > a:hover, 173 | .bs-sidebar .nav > li > a:focus { 174 | text-decoration: none; 175 | border-right: 1px solid; 176 | } 177 | .bs-sidebar .nav > .active > a, 178 | .bs-sidebar .nav > .active:hover > a, 179 | .bs-sidebar .nav > .active:focus > a { 180 | font-weight: bold; 181 | background-color: transparent; 182 | border-right: 1px solid; 183 | } 184 | 185 | /* Nav: second level (shown on .active) */ 186 | .bs-sidebar .nav .nav { 187 | display: none; /* Hide by default, but at >768px, show it */ 188 | margin-bottom: 8px; 189 | } 190 | .bs-sidebar .nav .nav > li > a { 191 | padding-top: 3px; 192 | padding-bottom: 3px; 193 | padding-left: 30px; 194 | font-size: 90%; 195 | } 196 | 197 | /* Show and affix the side nav when space allows it */ 198 | @media (min-width: 992px) { 199 | .bs-sidebar .nav > .active > ul { 200 | display: block; 201 | } 202 | /* Widen the fixed sidebar */ 203 | .bs-sidebar.affix, /* csslint allow: adjoining-classes */ 204 | .bs-sidebar.affix-bottom { /* csslint allow: adjoining-classes */ 205 | width: 213px; 206 | } 207 | .bs-sidebar.affix { /* csslint allow: adjoining-classes */ 208 | position: fixed; /* Undo the static from mobile first approach */ 209 | top: 80px; 210 | } 211 | .bs-sidebar.affix-bottom { /* csslint allow: adjoining-classes */ 212 | position: absolute; /* Undo the static from mobile first approach */ 213 | } 214 | .bs-sidebar.affix-bottom .bs-sidenav, /* csslint allow: adjoining-classes */ 215 | .bs-sidebar.affix .bs-sidenav { /* csslint allow: adjoining-classes */ 216 | margin-top: 0; 217 | margin-bottom: 0; 218 | } 219 | } 220 | @media (min-width: 1200px) { 221 | /* Widen the fixed sidebar again */ 222 | .bs-sidebar.affix-bottom, /* csslint allow: adjoining-classes */ 223 | .bs-sidebar.affix { /* csslint allow: adjoining-classes */ 224 | width: 263px; 225 | } 226 | } 227 | 228 | .headerlink { 229 | font-family: FontAwesome; 230 | font-size: 14px; 231 | display: none; 232 | padding-left: .5em; 233 | } 234 | 235 | h1:hover .headerlink, h2:hover .headerlink, h3:hover .headerlink, h4:hover .headerlink, h5:hover .headerlink, h6:hover .headerlink{ 236 | display:inline-block; 237 | } 238 | 239 | 240 | 241 | .admonition { 242 | padding: 15px; 243 | margin-bottom: 20px; 244 | border: 1px solid transparent; 245 | border-radius: 4px; 246 | text-align: left; 247 | } 248 | 249 | .admonition.note { /* csslint allow: adjoining-classes */ 250 | color: #3a87ad; 251 | background-color: #d9edf7; 252 | border-color: #bce8f1; 253 | } 254 | 255 | .admonition.warning { /* csslint allow: adjoining-classes */ 256 | color: #c09853; 257 | background-color: #fcf8e3; 258 | border-color: #fbeed5; 259 | } 260 | 261 | .admonition.danger { /* csslint allow: adjoining-classes */ 262 | color: #b94a48; 263 | background-color: #f2dede; 264 | border-color: #eed3d7; 265 | } 266 | 267 | .admonition-title { 268 | font-weight: bold; 269 | text-align: left; 270 | } 271 | 272 | 273 | .dropdown-submenu { 274 | position: relative; 275 | } 276 | 277 | .dropdown-submenu>.dropdown-menu { 278 | top: 0; 279 | left: 100%; 280 | margin-top: -6px; 281 | margin-left: -1px; 282 | -webkit-border-radius: 0 6px 6px 6px; 283 | -moz-border-radius: 0 6px 6px; 284 | border-radius: 0 6px 6px 6px; 285 | } 286 | 287 | .dropdown-submenu:hover>.dropdown-menu { 288 | display: block; 289 | } 290 | 291 | .dropdown-submenu>a:after { 292 | display: block; 293 | content: " "; 294 | float: right; 295 | width: 0; 296 | height: 0; 297 | border-color: transparent; 298 | border-style: solid; 299 | border-width: 5px 0 5px 5px; 300 | border-left-color: #ccc; 301 | margin-top: 5px; 302 | margin-right: -10px; 303 | } 304 | 305 | .dropdown-submenu:hover>a:after { 306 | border-left-color: #fff; 307 | } 308 | 309 | .dropdown-submenu.pull-left { /* csslint allow: adjoining-classes */ 310 | float: none; 311 | } 312 | 313 | .dropdown-submenu.pull-left>.dropdown-menu { /* csslint allow: adjoining-classes */ 314 | left: -100%; 315 | margin-left: 10px; 316 | -webkit-border-radius: 6px 0 6px 6px; 317 | -moz-border-radius: 6px 0 6px 6px; 318 | border-radius: 6px 0 6px 6px; 319 | } 320 | -------------------------------------------------------------------------------- /docs/site/css/highlight.css: -------------------------------------------------------------------------------- 1 | /* 2 | This is the GitHub theme for highlight.js 3 | 4 | github.com style (c) Vasily Polovnyov 5 | 6 | */ 7 | 8 | .hljs { 9 | display: block; 10 | overflow-x: auto; 11 | color: #333; 12 | -webkit-text-size-adjust: none; 13 | } 14 | 15 | .hljs-comment, 16 | .diff .hljs-header, 17 | .hljs-javadoc { 18 | color: #998; 19 | font-style: italic; 20 | } 21 | 22 | .hljs-keyword, 23 | .css .rule .hljs-keyword, 24 | .hljs-winutils, 25 | .nginx .hljs-title, 26 | .hljs-subst, 27 | .hljs-request, 28 | .hljs-status { 29 | color: #333; 30 | font-weight: bold; 31 | } 32 | 33 | .hljs-number, 34 | .hljs-hexcolor, 35 | .ruby .hljs-constant { 36 | color: #008080; 37 | } 38 | 39 | .hljs-string, 40 | .hljs-tag .hljs-value, 41 | .hljs-phpdoc, 42 | .hljs-dartdoc, 43 | .tex .hljs-formula { 44 | color: #d14; 45 | } 46 | 47 | .hljs-title, 48 | .hljs-id, 49 | .scss .hljs-preprocessor { 50 | color: #900; 51 | font-weight: bold; 52 | } 53 | 54 | .hljs-list .hljs-keyword, 55 | .hljs-subst { 56 | font-weight: normal; 57 | } 58 | 59 | .hljs-class .hljs-title, 60 | .hljs-type, 61 | .vhdl .hljs-literal, 62 | .tex .hljs-command { 63 | color: #458; 64 | font-weight: bold; 65 | } 66 | 67 | .hljs-tag, 68 | .hljs-tag .hljs-title, 69 | .hljs-rule .hljs-property, 70 | .django .hljs-tag .hljs-keyword { 71 | color: #000080; 72 | font-weight: normal; 73 | } 74 | 75 | .hljs-attribute, 76 | .hljs-variable, 77 | .lisp .hljs-body, 78 | .hljs-name { 79 | color: #008080; 80 | } 81 | 82 | .hljs-regexp { 83 | color: #009926; 84 | } 85 | 86 | .hljs-symbol, 87 | .ruby .hljs-symbol .hljs-string, 88 | .lisp .hljs-keyword, 89 | .clojure .hljs-keyword, 90 | .scheme .hljs-keyword, 91 | .tex .hljs-special, 92 | .hljs-prompt { 93 | color: #990073; 94 | } 95 | 96 | .hljs-built_in { 97 | color: #0086b3; 98 | } 99 | 100 | .hljs-preprocessor, 101 | .hljs-pragma, 102 | .hljs-pi, 103 | .hljs-doctype, 104 | .hljs-shebang, 105 | .hljs-cdata { 106 | color: #999; 107 | font-weight: bold; 108 | } 109 | 110 | .hljs-deletion { 111 | background: #fdd; 112 | } 113 | 114 | .hljs-addition { 115 | background: #dfd; 116 | } 117 | 118 | .diff .hljs-change { 119 | background: #0086b3; 120 | } 121 | 122 | .hljs-chunk { 123 | color: #aaa; 124 | } 125 | -------------------------------------------------------------------------------- /docs/site/errors/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | Errors - BBJ 12 | 13 | 14 | 15 | 16 | 17 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 90 | 91 |
92 |
97 |
98 | 99 |

Handling Error Responses

100 |

Errors in BBJ are separated into 6 different codes, to allow easy mapping to 101 | native exception and signaling systems available in the client's programming 102 | language. Errors are all or nothing, there are no "warnings". If a response has 103 | a non-false error field, then data will always be null. An error response from 104 | the api looks like this...

105 |
{
106 |   "error": {
107 |       "code": // an integer from 0 to 5,
108 |       "description": // a string describing the error in detail.
109 |   }
110 |   "data": null   // ALWAYS null if error is not false
111 |   "usermap": {}  // ALWAYS empty if error is not false
112 | }
113 | 
114 | 115 |

The codes split errors into categories. Some are oriented 116 | to client developers while others should be shown directly to 117 | users.

118 |
    119 |
  • 120 |

    Code 0: Malformed but non-empty json input. An empty json input where it is required is handled by code 3. This is just decoding errors. The exception text is returned as description.

    121 |
  • 122 |
  • 123 |

    Code 1: Internal server error. A short representation of the internal exception as well as the code the server logged it as is returned in the description. Your clients cannot recover from this class of error, and its probably not your fault if you encounter it. If you ever get one, file a bug report.

    124 |
  • 125 |
  • 126 |

    Code 2: Server HTTP error: This is similar to the above but captures errors for the HTTP server rather than BBJs own codebase. The description contains the HTTP error code and server description. This notably covers 404s and thus invalid endpoint names. The HTTP error code is left intact, so you may choose to let your HTTP library or tool of choice handle these for you.

    127 |
  • 128 |
  • 129 |

    Code 3: Parameter error: client sent erroneous input for its method. This could mean missing arguments, type errors, etc. It generalizes errors that should be fixed by the client developer and the returned descriptions are geared to them rather than end users.

    130 |
  • 131 |
  • 132 |

    Code 4: User error: These errors regard actions that the user has taken that are invalid, but not really errors in a traditional sense. The description field should be shown to users verbatim, in a clear and noticeable fashion. They are formatted as concise English sentences and end with appropriate punctuation marks.

    133 |
  • 134 |
  • 135 |

    Code 5: Authorization error: This code represents an erroneous User/Auth header pair. This should trigger the user to provide correct credentials or fall back to anon mode.

    136 |
  • 137 |
138 |
139 | 140 |
141 |
142 |

Documentation built with MkDocs.

143 |
144 | 145 | 146 | 170 | 171 | 172 | 173 | -------------------------------------------------------------------------------- /docs/site/fonts/fontawesome-webfont.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bbj-dev/bbj/50ffb5ebc96221d947502c06a5f1fbda1a062219/docs/site/fonts/fontawesome-webfont.eot -------------------------------------------------------------------------------- /docs/site/fonts/fontawesome-webfont.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bbj-dev/bbj/50ffb5ebc96221d947502c06a5f1fbda1a062219/docs/site/fonts/fontawesome-webfont.ttf -------------------------------------------------------------------------------- /docs/site/fonts/fontawesome-webfont.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bbj-dev/bbj/50ffb5ebc96221d947502c06a5f1fbda1a062219/docs/site/fonts/fontawesome-webfont.woff -------------------------------------------------------------------------------- /docs/site/fonts/fontawesome-webfont.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bbj-dev/bbj/50ffb5ebc96221d947502c06a5f1fbda1a062219/docs/site/fonts/fontawesome-webfont.woff2 -------------------------------------------------------------------------------- /docs/site/fonts/glyphicons-halflings-regular.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bbj-dev/bbj/50ffb5ebc96221d947502c06a5f1fbda1a062219/docs/site/fonts/glyphicons-halflings-regular.eot -------------------------------------------------------------------------------- /docs/site/fonts/glyphicons-halflings-regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bbj-dev/bbj/50ffb5ebc96221d947502c06a5f1fbda1a062219/docs/site/fonts/glyphicons-halflings-regular.ttf -------------------------------------------------------------------------------- /docs/site/fonts/glyphicons-halflings-regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bbj-dev/bbj/50ffb5ebc96221d947502c06a5f1fbda1a062219/docs/site/fonts/glyphicons-halflings-regular.woff -------------------------------------------------------------------------------- /docs/site/fonts/glyphicons-halflings-regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bbj-dev/bbj/50ffb5ebc96221d947502c06a5f1fbda1a062219/docs/site/fonts/glyphicons-halflings-regular.woff2 -------------------------------------------------------------------------------- /docs/site/img/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bbj-dev/bbj/50ffb5ebc96221d947502c06a5f1fbda1a062219/docs/site/img/favicon.ico -------------------------------------------------------------------------------- /docs/site/img/grid.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bbj-dev/bbj/50ffb5ebc96221d947502c06a5f1fbda1a062219/docs/site/img/grid.png -------------------------------------------------------------------------------- /docs/site/img/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bbj-dev/bbj/50ffb5ebc96221d947502c06a5f1fbda1a062219/docs/site/img/screenshot.png -------------------------------------------------------------------------------- /docs/site/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | BBJ 12 | 13 | 14 | 15 | 16 | 17 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 90 | 91 |
92 | 98 |
99 | 100 |

Bulletin Butter & Jelly

101 |

A simple community textboard

102 |

BBJ is a trivial collection of python scripts and database queries that miraculously shit out a fully functional client-server textboard with Python 3.4+

103 |

See also: the GitHub repository.

104 |

BBJ is heavily inspired by image boards like 4chan, but it offers a simple 105 | account system to allow users to identify themselves and set profile 106 | attributes like a more traditional forum. Registration is optional and there 107 | are only minimal restrictions on anonymous participation.

108 |

screenshot

109 |

Being a command-line-oriented text board, BBJ has no avatars or file sharing 110 | capabilties, so its easier to administrate and can't be used to distribute illegal 111 | content like imageboards. It has very few dependancies and is easy to set up.

112 |

The API is simple and doesn't use require complex authorization schemes or session management. 113 | It is fully documented on this site (though the verbage is still being revised for friendliness)

114 |
115 | 116 |
117 |
118 |

Documentation built with MkDocs.

119 |
120 | 121 | 122 | 146 | 147 | 148 | 149 | 150 | 154 | -------------------------------------------------------------------------------- /docs/site/js/base.js: -------------------------------------------------------------------------------- 1 | function getSearchTerm() 2 | { 3 | var sPageURL = window.location.search.substring(1); 4 | var sURLVariables = sPageURL.split('&'); 5 | for (var i = 0; i < sURLVariables.length; i++) 6 | { 7 | var sParameterName = sURLVariables[i].split('='); 8 | if (sParameterName[0] == 'q') 9 | { 10 | return sParameterName[1]; 11 | } 12 | } 13 | } 14 | 15 | $(document).ready(function() { 16 | 17 | var search_term = getSearchTerm(), 18 | $search_modal = $('#mkdocs_search_modal'); 19 | 20 | if(search_term){ 21 | $search_modal.modal(); 22 | } 23 | 24 | // make sure search input gets autofocus everytime modal opens. 25 | $search_modal.on('shown.bs.modal', function () { 26 | $search_modal.find('#mkdocs-search-query').focus(); 27 | }); 28 | 29 | // Highlight.js 30 | hljs.initHighlightingOnLoad(); 31 | $('table').addClass('table table-striped table-hover'); 32 | 33 | // Improve the scrollspy behaviour when users click on a TOC item. 34 | $(".bs-sidenav a").on("click", function() { 35 | var clicked = this; 36 | setTimeout(function() { 37 | var active = $('.nav li.active a'); 38 | active = active[active.length - 1]; 39 | if (clicked !== active) { 40 | $(active).parent().removeClass("active"); 41 | $(clicked).parent().addClass("active"); 42 | } 43 | }, 50); 44 | }); 45 | 46 | }); 47 | 48 | 49 | $('body').scrollspy({ 50 | target: '.bs-sidebar', 51 | }); 52 | 53 | /* Toggle the `clicky` class on the body when clicking links to let us 54 | retrigger CSS animations. See ../css/base.css for more details. */ 55 | $('a').click(function(e) { 56 | $('body').toggleClass('clicky'); 57 | }); 58 | 59 | /* Prevent disabled links from causing a page reload */ 60 | $("li.disabled a").click(function() { 61 | event.preventDefault(); 62 | }); 63 | -------------------------------------------------------------------------------- /docs/site/mkdocs/js/lunr.min.js: -------------------------------------------------------------------------------- 1 | /** 2 | * lunr - http://lunrjs.com - A bit like Solr, but much smaller and not as bright - 0.7.0 3 | * Copyright (C) 2016 Oliver Nightingale 4 | * MIT Licensed 5 | * @license 6 | */ 7 | !function(){var t=function(e){var n=new t.Index;return n.pipeline.add(t.trimmer,t.stopWordFilter,t.stemmer),e&&e.call(n,n),n};t.version="0.7.0",t.utils={},t.utils.warn=function(t){return function(e){t.console&&console.warn&&console.warn(e)}}(this),t.utils.asString=function(t){return void 0===t||null===t?"":t.toString()},t.EventEmitter=function(){this.events={}},t.EventEmitter.prototype.addListener=function(){var t=Array.prototype.slice.call(arguments),e=t.pop(),n=t;if("function"!=typeof e)throw new TypeError("last argument must be a function");n.forEach(function(t){this.hasHandler(t)||(this.events[t]=[]),this.events[t].push(e)},this)},t.EventEmitter.prototype.removeListener=function(t,e){if(this.hasHandler(t)){var n=this.events[t].indexOf(e);this.events[t].splice(n,1),this.events[t].length||delete this.events[t]}},t.EventEmitter.prototype.emit=function(t){if(this.hasHandler(t)){var e=Array.prototype.slice.call(arguments,1);this.events[t].forEach(function(t){t.apply(void 0,e)})}},t.EventEmitter.prototype.hasHandler=function(t){return t in this.events},t.tokenizer=function(e){return arguments.length&&null!=e&&void 0!=e?Array.isArray(e)?e.map(function(e){return t.utils.asString(e).toLowerCase()}):e.toString().trim().toLowerCase().split(t.tokenizer.seperator):[]},t.tokenizer.seperator=/[\s\-]+/,t.tokenizer.load=function(t){var e=this.registeredFunctions[t];if(!e)throw new Error("Cannot load un-registered function: "+t);return e},t.tokenizer.label="default",t.tokenizer.registeredFunctions={"default":t.tokenizer},t.tokenizer.registerFunction=function(e,n){n in this.registeredFunctions&&t.utils.warn("Overwriting existing tokenizer: "+n),e.label=n,this.registeredFunctions[n]=e},t.Pipeline=function(){this._stack=[]},t.Pipeline.registeredFunctions={},t.Pipeline.registerFunction=function(e,n){n in this.registeredFunctions&&t.utils.warn("Overwriting existing registered function: "+n),e.label=n,t.Pipeline.registeredFunctions[e.label]=e},t.Pipeline.warnIfFunctionNotRegistered=function(e){var n=e.label&&e.label in this.registeredFunctions;n||t.utils.warn("Function is not registered with pipeline. This may cause problems when serialising the index.\n",e)},t.Pipeline.load=function(e){var n=new t.Pipeline;return e.forEach(function(e){var i=t.Pipeline.registeredFunctions[e];if(!i)throw new Error("Cannot load un-registered function: "+e);n.add(i)}),n},t.Pipeline.prototype.add=function(){var e=Array.prototype.slice.call(arguments);e.forEach(function(e){t.Pipeline.warnIfFunctionNotRegistered(e),this._stack.push(e)},this)},t.Pipeline.prototype.after=function(e,n){t.Pipeline.warnIfFunctionNotRegistered(n);var i=this._stack.indexOf(e);if(-1==i)throw new Error("Cannot find existingFn");i+=1,this._stack.splice(i,0,n)},t.Pipeline.prototype.before=function(e,n){t.Pipeline.warnIfFunctionNotRegistered(n);var i=this._stack.indexOf(e);if(-1==i)throw new Error("Cannot find existingFn");this._stack.splice(i,0,n)},t.Pipeline.prototype.remove=function(t){var e=this._stack.indexOf(t);-1!=e&&this._stack.splice(e,1)},t.Pipeline.prototype.run=function(t){for(var e=[],n=t.length,i=this._stack.length,r=0;n>r;r++){for(var o=t[r],s=0;i>s&&(o=this._stack[s](o,r,t),void 0!==o&&""!==o);s++);void 0!==o&&""!==o&&e.push(o)}return e},t.Pipeline.prototype.reset=function(){this._stack=[]},t.Pipeline.prototype.toJSON=function(){return this._stack.map(function(e){return t.Pipeline.warnIfFunctionNotRegistered(e),e.label})},t.Vector=function(){this._magnitude=null,this.list=void 0,this.length=0},t.Vector.Node=function(t,e,n){this.idx=t,this.val=e,this.next=n},t.Vector.prototype.insert=function(e,n){this._magnitude=void 0;var i=this.list;if(!i)return this.list=new t.Vector.Node(e,n,i),this.length++;if(en.idx?n=n.next:(i+=e.val*n.val,e=e.next,n=n.next);return i},t.Vector.prototype.similarity=function(t){return this.dot(t)/(this.magnitude()*t.magnitude())},t.SortedSet=function(){this.length=0,this.elements=[]},t.SortedSet.load=function(t){var e=new this;return e.elements=t,e.length=t.length,e},t.SortedSet.prototype.add=function(){var t,e;for(t=0;t1;){if(o===t)return r;t>o&&(e=r),o>t&&(n=r),i=n-e,r=e+Math.floor(i/2),o=this.elements[r]}return o===t?r:-1},t.SortedSet.prototype.locationFor=function(t){for(var e=0,n=this.elements.length,i=n-e,r=e+Math.floor(i/2),o=this.elements[r];i>1;)t>o&&(e=r),o>t&&(n=r),i=n-e,r=e+Math.floor(i/2),o=this.elements[r];return o>t?r:t>o?r+1:void 0},t.SortedSet.prototype.intersect=function(e){for(var n=new t.SortedSet,i=0,r=0,o=this.length,s=e.length,a=this.elements,h=e.elements;;){if(i>o-1||r>s-1)break;a[i]!==h[r]?a[i]h[r]&&r++:(n.add(a[i]),i++,r++)}return n},t.SortedSet.prototype.clone=function(){var e=new t.SortedSet;return e.elements=this.toArray(),e.length=e.elements.length,e},t.SortedSet.prototype.union=function(t){var e,n,i;this.length>=t.length?(e=this,n=t):(e=t,n=this),i=e.clone();for(var r=0,o=n.toArray();rp;p++)c[p]===a&&d++;h+=d/f*l.boost}}this.tokenStore.add(a,{ref:o,tf:h})}n&&this.eventEmitter.emit("add",e,this)},t.Index.prototype.remove=function(t,e){var n=t[this._ref],e=void 0===e?!0:e;if(this.documentStore.has(n)){var i=this.documentStore.get(n);this.documentStore.remove(n),i.forEach(function(t){this.tokenStore.remove(t,n)},this),e&&this.eventEmitter.emit("remove",t,this)}},t.Index.prototype.update=function(t,e){var e=void 0===e?!0:e;this.remove(t,!1),this.add(t,!1),e&&this.eventEmitter.emit("update",t,this)},t.Index.prototype.idf=function(t){var e="@"+t;if(Object.prototype.hasOwnProperty.call(this._idfCache,e))return this._idfCache[e];var n=this.tokenStore.count(t),i=1;return n>0&&(i=1+Math.log(this.documentStore.length/n)),this._idfCache[e]=i},t.Index.prototype.search=function(e){var n=this.pipeline.run(this.tokenizerFn(e)),i=new t.Vector,r=[],o=this._fields.reduce(function(t,e){return t+e.boost},0),s=n.some(function(t){return this.tokenStore.has(t)},this);if(!s)return[];n.forEach(function(e,n,s){var a=1/s.length*this._fields.length*o,h=this,u=this.tokenStore.expand(e).reduce(function(n,r){var o=h.corpusTokens.indexOf(r),s=h.idf(r),u=1,l=new t.SortedSet;if(r!==e){var c=Math.max(3,r.length-e.length);u=1/Math.log(c)}o>-1&&i.insert(o,a*s*u);for(var f=h.tokenStore.get(r),d=Object.keys(f),p=d.length,v=0;p>v;v++)l.add(f[d[v]].ref);return n.union(l)},new t.SortedSet);r.push(u)},this);var a=r.reduce(function(t,e){return t.intersect(e)});return a.map(function(t){return{ref:t,score:i.similarity(this.documentVector(t))}},this).sort(function(t,e){return e.score-t.score})},t.Index.prototype.documentVector=function(e){for(var n=this.documentStore.get(e),i=n.length,r=new t.Vector,o=0;i>o;o++){var s=n.elements[o],a=this.tokenStore.get(s)[e].tf,h=this.idf(s);r.insert(this.corpusTokens.indexOf(s),a*h)}return r},t.Index.prototype.toJSON=function(){return{version:t.version,fields:this._fields,ref:this._ref,tokenizer:this.tokenizerFn.label,documentStore:this.documentStore.toJSON(),tokenStore:this.tokenStore.toJSON(),corpusTokens:this.corpusTokens.toJSON(),pipeline:this.pipeline.toJSON()}},t.Index.prototype.use=function(t){var e=Array.prototype.slice.call(arguments,1);e.unshift(this),t.apply(this,e)},t.Store=function(){this.store={},this.length=0},t.Store.load=function(e){var n=new this;return n.length=e.length,n.store=Object.keys(e.store).reduce(function(n,i){return n[i]=t.SortedSet.load(e.store[i]),n},{}),n},t.Store.prototype.set=function(t,e){this.has(t)||this.length++,this.store[t]=e},t.Store.prototype.get=function(t){return this.store[t]},t.Store.prototype.has=function(t){return t in this.store},t.Store.prototype.remove=function(t){this.has(t)&&(delete this.store[t],this.length--)},t.Store.prototype.toJSON=function(){return{store:this.store,length:this.length}},t.stemmer=function(){var t={ational:"ate",tional:"tion",enci:"ence",anci:"ance",izer:"ize",bli:"ble",alli:"al",entli:"ent",eli:"e",ousli:"ous",ization:"ize",ation:"ate",ator:"ate",alism:"al",iveness:"ive",fulness:"ful",ousness:"ous",aliti:"al",iviti:"ive",biliti:"ble",logi:"log"},e={icate:"ic",ative:"",alize:"al",iciti:"ic",ical:"ic",ful:"",ness:""},n="[^aeiou]",i="[aeiouy]",r=n+"[^aeiouy]*",o=i+"[aeiou]*",s="^("+r+")?"+o+r,a="^("+r+")?"+o+r+"("+o+")?$",h="^("+r+")?"+o+r+o+r,u="^("+r+")?"+i,l=new RegExp(s),c=new RegExp(h),f=new RegExp(a),d=new RegExp(u),p=/^(.+?)(ss|i)es$/,v=/^(.+?)([^s])s$/,g=/^(.+?)eed$/,m=/^(.+?)(ed|ing)$/,y=/.$/,S=/(at|bl|iz)$/,w=new RegExp("([^aeiouylsz])\\1$"),k=new RegExp("^"+r+i+"[^aeiouwxy]$"),x=/^(.+?[^aeiou])y$/,b=/^(.+?)(ational|tional|enci|anci|izer|bli|alli|entli|eli|ousli|ization|ation|ator|alism|iveness|fulness|ousness|aliti|iviti|biliti|logi)$/,E=/^(.+?)(icate|ative|alize|iciti|ical|ful|ness)$/,F=/^(.+?)(al|ance|ence|er|ic|able|ible|ant|ement|ment|ent|ou|ism|ate|iti|ous|ive|ize)$/,_=/^(.+?)(s|t)(ion)$/,z=/^(.+?)e$/,O=/ll$/,P=new RegExp("^"+r+i+"[^aeiouwxy]$"),T=function(n){var i,r,o,s,a,h,u;if(n.length<3)return n;if(o=n.substr(0,1),"y"==o&&(n=o.toUpperCase()+n.substr(1)),s=p,a=v,s.test(n)?n=n.replace(s,"$1$2"):a.test(n)&&(n=n.replace(a,"$1$2")),s=g,a=m,s.test(n)){var T=s.exec(n);s=l,s.test(T[1])&&(s=y,n=n.replace(s,""))}else if(a.test(n)){var T=a.exec(n);i=T[1],a=d,a.test(i)&&(n=i,a=S,h=w,u=k,a.test(n)?n+="e":h.test(n)?(s=y,n=n.replace(s,"")):u.test(n)&&(n+="e"))}if(s=x,s.test(n)){var T=s.exec(n);i=T[1],n=i+"i"}if(s=b,s.test(n)){var T=s.exec(n);i=T[1],r=T[2],s=l,s.test(i)&&(n=i+t[r])}if(s=E,s.test(n)){var T=s.exec(n);i=T[1],r=T[2],s=l,s.test(i)&&(n=i+e[r])}if(s=F,a=_,s.test(n)){var T=s.exec(n);i=T[1],s=c,s.test(i)&&(n=i)}else if(a.test(n)){var T=a.exec(n);i=T[1]+T[2],a=c,a.test(i)&&(n=i)}if(s=z,s.test(n)){var T=s.exec(n);i=T[1],s=c,a=f,h=P,(s.test(i)||a.test(i)&&!h.test(i))&&(n=i)}return s=O,a=c,s.test(n)&&a.test(n)&&(s=y,n=n.replace(s,"")),"y"==o&&(n=o.toLowerCase()+n.substr(1)),n};return T}(),t.Pipeline.registerFunction(t.stemmer,"stemmer"),t.generateStopWordFilter=function(t){var e=t.reduce(function(t,e){return t[e]=e,t},{});return function(t){return t&&e[t]!==t?t:void 0}},t.stopWordFilter=t.generateStopWordFilter(["a","able","about","across","after","all","almost","also","am","among","an","and","any","are","as","at","be","because","been","but","by","can","cannot","could","dear","did","do","does","either","else","ever","every","for","from","get","got","had","has","have","he","her","hers","him","his","how","however","i","if","in","into","is","it","its","just","least","let","like","likely","may","me","might","most","must","my","neither","no","nor","not","of","off","often","on","only","or","other","our","own","rather","said","say","says","she","should","since","so","some","than","that","the","their","them","then","there","these","they","this","tis","to","too","twas","us","wants","was","we","were","what","when","where","which","while","who","whom","why","will","with","would","yet","you","your"]),t.Pipeline.registerFunction(t.stopWordFilter,"stopWordFilter"),t.trimmer=function(t){return t.replace(/^\W+/,"").replace(/\W+$/,"")},t.Pipeline.registerFunction(t.trimmer,"trimmer"),t.TokenStore=function(){this.root={docs:{}},this.length=0},t.TokenStore.load=function(t){var e=new this;return e.root=t.root,e.length=t.length,e},t.TokenStore.prototype.add=function(t,e,n){var n=n||this.root,i=t.charAt(0),r=t.slice(1);return i in n||(n[i]={docs:{}}),0===r.length?(n[i].docs[e.ref]=e,void(this.length+=1)):this.add(r,e,n[i])},t.TokenStore.prototype.has=function(t){if(!t)return!1;for(var e=this.root,n=0;n":">",'"':""","'":"'","/":"/"};function escapeHtml(string){return String(string).replace(/[&<>"'\/]/g,function(s){return entityMap[s]})}var whiteRe=/\s*/;var spaceRe=/\s+/;var equalsRe=/\s*=/;var curlyRe=/\s*\}/;var tagRe=/#|\^|\/|>|\{|&|=|!/;function parseTemplate(template,tags){if(!template)return[];var sections=[];var tokens=[];var spaces=[];var hasTag=false;var nonSpace=false;function stripSpace(){if(hasTag&&!nonSpace){while(spaces.length)delete tokens[spaces.pop()]}else{spaces=[]}hasTag=false;nonSpace=false}var openingTagRe,closingTagRe,closingCurlyRe;function compileTags(tags){if(typeof tags==="string")tags=tags.split(spaceRe,2);if(!isArray(tags)||tags.length!==2)throw new Error("Invalid tags: "+tags);openingTagRe=new RegExp(escapeRegExp(tags[0])+"\\s*");closingTagRe=new RegExp("\\s*"+escapeRegExp(tags[1]));closingCurlyRe=new RegExp("\\s*"+escapeRegExp("}"+tags[1]))}compileTags(tags||mustache.tags);var scanner=new Scanner(template);var start,type,value,chr,token,openSection;while(!scanner.eos()){start=scanner.pos;value=scanner.scanUntil(openingTagRe);if(value){for(var i=0,valueLength=value.length;i0?sections[sections.length-1][4]:nestedTokens;break;default:collector.push(token)}}return nestedTokens}function Scanner(string){this.string=string;this.tail=string;this.pos=0}Scanner.prototype.eos=function(){return this.tail===""};Scanner.prototype.scan=function(re){var match=this.tail.match(re);if(!match||match.index!==0)return"";var string=match[0];this.tail=this.tail.substring(string.length);this.pos+=string.length;return string};Scanner.prototype.scanUntil=function(re){var index=this.tail.search(re),match;switch(index){case-1:match=this.tail;this.tail="";break;case 0:match="";break;default:match=this.tail.substring(0,index);this.tail=this.tail.substring(index)}this.pos+=match.length;return match};function Context(view,parentContext){this.view=view;this.cache={".":this.view};this.parent=parentContext}Context.prototype.push=function(view){return new Context(view,this)};Context.prototype.lookup=function(name){var cache=this.cache;var value;if(name in cache){value=cache[name]}else{var context=this,names,index,lookupHit=false;while(context){if(name.indexOf(".")>0){value=context.view;names=name.split(".");index=0;while(value!=null&&index")value=this._renderPartial(token,context,partials,originalTemplate);else if(symbol==="&")value=this._unescapedValue(token,context);else if(symbol==="name")value=this._escapedValue(token,context);else if(symbol==="text")value=this._rawValue(token);if(value!==undefined)buffer+=value}return buffer};Writer.prototype._renderSection=function(token,context,partials,originalTemplate){var self=this;var buffer="";var value=context.lookup(token[1]);function subRender(template){return self.render(template,context,partials)}if(!value)return;if(isArray(value)){for(var j=0,valueLength=value.length;jthis.depCount&&!this.defined){if(G(l)){if(this.events.error&&this.map.isDefine||g.onError!==ca)try{f=i.execCb(c,l,b,f)}catch(d){a=d}else f=i.execCb(c,l,b,f);this.map.isDefine&&void 0===f&&((b=this.module)?f=b.exports:this.usingExports&& 19 | (f=this.exports));if(a)return a.requireMap=this.map,a.requireModules=this.map.isDefine?[this.map.id]:null,a.requireType=this.map.isDefine?"define":"require",w(this.error=a)}else f=l;this.exports=f;if(this.map.isDefine&&!this.ignore&&(r[c]=f,g.onResourceLoad))g.onResourceLoad(i,this.map,this.depMaps);y(c);this.defined=!0}this.defining=!1;this.defined&&!this.defineEmitted&&(this.defineEmitted=!0,this.emit("defined",this.exports),this.defineEmitComplete=!0)}}else this.fetch()}},callPlugin:function(){var a= 20 | this.map,b=a.id,d=p(a.prefix);this.depMaps.push(d);q(d,"defined",u(this,function(f){var l,d;d=m(aa,this.map.id);var e=this.map.name,P=this.map.parentMap?this.map.parentMap.name:null,n=i.makeRequire(a.parentMap,{enableBuildCallback:!0});if(this.map.unnormalized){if(f.normalize&&(e=f.normalize(e,function(a){return c(a,P,!0)})||""),f=p(a.prefix+"!"+e,this.map.parentMap),q(f,"defined",u(this,function(a){this.init([],function(){return a},null,{enabled:!0,ignore:!0})})),d=m(h,f.id)){this.depMaps.push(f); 21 | if(this.events.error)d.on("error",u(this,function(a){this.emit("error",a)}));d.enable()}}else d?(this.map.url=i.nameToUrl(d),this.load()):(l=u(this,function(a){this.init([],function(){return a},null,{enabled:!0})}),l.error=u(this,function(a){this.inited=!0;this.error=a;a.requireModules=[b];B(h,function(a){0===a.map.id.indexOf(b+"_unnormalized")&&y(a.map.id)});w(a)}),l.fromText=u(this,function(f,c){var d=a.name,e=p(d),P=M;c&&(f=c);P&&(M=!1);s(e);t(j.config,b)&&(j.config[d]=j.config[b]);try{g.exec(f)}catch(h){return w(C("fromtexteval", 22 | "fromText eval for "+b+" failed: "+h,h,[b]))}P&&(M=!0);this.depMaps.push(e);i.completeLoad(d);n([d],l)}),f.load(a.name,n,l,j))}));i.enable(d,this);this.pluginMaps[d.id]=d},enable:function(){V[this.map.id]=this;this.enabling=this.enabled=!0;v(this.depMaps,u(this,function(a,b){var c,f;if("string"===typeof a){a=p(a,this.map.isDefine?this.map:this.map.parentMap,!1,!this.skipMap);this.depMaps[b]=a;if(c=m(L,a.id)){this.depExports[b]=c(this);return}this.depCount+=1;q(a,"defined",u(this,function(a){this.defineDep(b, 23 | a);this.check()}));this.errback?q(a,"error",u(this,this.errback)):this.events.error&&q(a,"error",u(this,function(a){this.emit("error",a)}))}c=a.id;f=h[c];!t(L,c)&&(f&&!f.enabled)&&i.enable(a,this)}));B(this.pluginMaps,u(this,function(a){var b=m(h,a.id);b&&!b.enabled&&i.enable(a,this)}));this.enabling=!1;this.check()},on:function(a,b){var c=this.events[a];c||(c=this.events[a]=[]);c.push(b)},emit:function(a,b){v(this.events[a],function(a){a(b)});"error"===a&&delete this.events[a]}};i={config:j,contextName:b, 24 | registry:h,defined:r,urlFetched:S,defQueue:A,Module:Z,makeModuleMap:p,nextTick:g.nextTick,onError:w,configure:function(a){a.baseUrl&&"/"!==a.baseUrl.charAt(a.baseUrl.length-1)&&(a.baseUrl+="/");var b=j.shim,c={paths:!0,bundles:!0,config:!0,map:!0};B(a,function(a,b){c[b]?(j[b]||(j[b]={}),U(j[b],a,!0,!0)):j[b]=a});a.bundles&&B(a.bundles,function(a,b){v(a,function(a){a!==b&&(aa[a]=b)})});a.shim&&(B(a.shim,function(a,c){H(a)&&(a={deps:a});if((a.exports||a.init)&&!a.exportsFn)a.exportsFn=i.makeShimExports(a); 25 | b[c]=a}),j.shim=b);a.packages&&v(a.packages,function(a){var b,a="string"===typeof a?{name:a}:a;b=a.name;a.location&&(j.paths[b]=a.location);j.pkgs[b]=a.name+"/"+(a.main||"main").replace(ia,"").replace(Q,"")});B(h,function(a,b){!a.inited&&!a.map.unnormalized&&(a.map=p(b))});if(a.deps||a.callback)i.require(a.deps||[],a.callback)},makeShimExports:function(a){return function(){var b;a.init&&(b=a.init.apply(ba,arguments));return b||a.exports&&da(a.exports)}},makeRequire:function(a,e){function j(c,d,m){var n, 26 | q;e.enableBuildCallback&&(d&&G(d))&&(d.__requireJsBuild=!0);if("string"===typeof c){if(G(d))return w(C("requireargs","Invalid require call"),m);if(a&&t(L,c))return L[c](h[a.id]);if(g.get)return g.get(i,c,a,j);n=p(c,a,!1,!0);n=n.id;return!t(r,n)?w(C("notloaded",'Module name "'+n+'" has not been loaded yet for context: '+b+(a?"":". Use require([])"))):r[n]}J();i.nextTick(function(){J();q=s(p(null,a));q.skipMap=e.skipMap;q.init(c,d,m,{enabled:!0});D()});return j}e=e||{};U(j,{isBrowser:z,toUrl:function(b){var d, 27 | e=b.lastIndexOf("."),k=b.split("/")[0];if(-1!==e&&(!("."===k||".."===k)||1e.attachEvent.toString().indexOf("[native code"))&& 34 | !Y?(M=!0,e.attachEvent("onreadystatechange",b.onScriptLoad)):(e.addEventListener("load",b.onScriptLoad,!1),e.addEventListener("error",b.onScriptError,!1)),e.src=d,J=e,D?y.insertBefore(e,D):y.appendChild(e),J=null,e;if(ea)try{importScripts(d),b.completeLoad(c)}catch(m){b.onError(C("importscripts","importScripts failed for "+c+" at "+d,m,[c]))}};z&&!q.skipDataMain&&T(document.getElementsByTagName("script"),function(b){y||(y=b.parentNode);if(I=b.getAttribute("data-main"))return s=I,q.baseUrl||(E=s.split("/"), 35 | s=E.pop(),O=E.length?E.join("/")+"/":"./",q.baseUrl=O),s=s.replace(Q,""),g.jsExtRegExp.test(s)&&(s=I),q.deps=q.deps?q.deps.concat(s):[s],!0});define=function(b,c,d){var e,g;"string"!==typeof b&&(d=c,c=b,b=null);H(c)||(d=c,c=null);!c&&G(d)&&(c=[],d.length&&(d.toString().replace(ka,"").replace(la,function(b,d){c.push(d)}),c=(1===d.length?["require"]:["require","exports","module"]).concat(c)));if(M){if(!(e=J))N&&"interactive"===N.readyState||T(document.getElementsByTagName("script"),function(b){if("interactive"=== 36 | b.readyState)return N=b}),e=N;e&&(b||(b=e.getAttribute("data-requiremodule")),g=F[e.getAttribute("data-requirecontext")])}(g?g.defQueue:R).push([b,c,d])};define.amd={jQuery:!0};g.exec=function(b){return eval(b)};g(q)}})(this); 37 | -------------------------------------------------------------------------------- /docs/site/mkdocs/js/search-results-template.mustache: -------------------------------------------------------------------------------- 1 | 5 | -------------------------------------------------------------------------------- /docs/site/mkdocs/js/search.js: -------------------------------------------------------------------------------- 1 | require([ 2 | base_url + '/mkdocs/js/mustache.min.js', 3 | base_url + '/mkdocs/js/lunr.min.js', 4 | 'text!search-results-template.mustache', 5 | 'text!../search_index.json', 6 | ], function (Mustache, lunr, results_template, data) { 7 | "use strict"; 8 | 9 | function getSearchTerm() 10 | { 11 | var sPageURL = window.location.search.substring(1); 12 | var sURLVariables = sPageURL.split('&'); 13 | for (var i = 0; i < sURLVariables.length; i++) 14 | { 15 | var sParameterName = sURLVariables[i].split('='); 16 | if (sParameterName[0] == 'q') 17 | { 18 | return decodeURIComponent(sParameterName[1].replace(/\+/g, '%20')); 19 | } 20 | } 21 | } 22 | 23 | var index = lunr(function () { 24 | this.field('title', {boost: 10}); 25 | this.field('text'); 26 | this.ref('location'); 27 | }); 28 | 29 | data = JSON.parse(data); 30 | var documents = {}; 31 | 32 | for (var i=0; i < data.docs.length; i++){ 33 | var doc = data.docs[i]; 34 | doc.location = base_url + doc.location; 35 | index.add(doc); 36 | documents[doc.location] = doc; 37 | } 38 | 39 | var search = function(){ 40 | 41 | var query = document.getElementById('mkdocs-search-query').value; 42 | var search_results = document.getElementById("mkdocs-search-results"); 43 | while (search_results.firstChild) { 44 | search_results.removeChild(search_results.firstChild); 45 | } 46 | 47 | if(query === ''){ 48 | return; 49 | } 50 | 51 | var results = index.search(query); 52 | 53 | if (results.length > 0){ 54 | for (var i=0; i < results.length; i++){ 55 | var result = results[i]; 56 | doc = documents[result.ref]; 57 | doc.base_url = base_url; 58 | doc.summary = doc.text.substring(0, 200); 59 | var html = Mustache.to_html(results_template, doc); 60 | search_results.insertAdjacentHTML('beforeend', html); 61 | } 62 | } else { 63 | search_results.insertAdjacentHTML('beforeend', "

No results found

"); 64 | } 65 | 66 | if(jQuery){ 67 | /* 68 | * We currently only automatically hide bootstrap models. This 69 | * requires jQuery to work. 70 | */ 71 | jQuery('#mkdocs_search_modal a').click(function(){ 72 | jQuery('#mkdocs_search_modal').modal('hide'); 73 | }); 74 | } 75 | 76 | }; 77 | 78 | var search_input = document.getElementById('mkdocs-search-query'); 79 | 80 | var term = getSearchTerm(); 81 | if (term){ 82 | search_input.value = term; 83 | search(); 84 | } 85 | 86 | search_input.addEventListener("keyup", search); 87 | 88 | }); 89 | -------------------------------------------------------------------------------- /docs/site/mkdocs/js/text.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @license RequireJS text 2.0.12 Copyright (c) 2010-2014, The Dojo Foundation All Rights Reserved. 3 | * Available via the MIT or new BSD license. 4 | * see: http://github.com/requirejs/text for details 5 | */ 6 | /*jslint regexp: true */ 7 | /*global require, XMLHttpRequest, ActiveXObject, 8 | define, window, process, Packages, 9 | java, location, Components, FileUtils */ 10 | 11 | define(['module'], function (module) { 12 | 'use strict'; 13 | 14 | var text, fs, Cc, Ci, xpcIsWindows, 15 | progIds = ['Msxml2.XMLHTTP', 'Microsoft.XMLHTTP', 'Msxml2.XMLHTTP.4.0'], 16 | xmlRegExp = /^\s*<\?xml(\s)+version=[\'\"](\d)*.(\d)*[\'\"](\s)*\?>/im, 17 | bodyRegExp = /]*>\s*([\s\S]+)\s*<\/body>/im, 18 | hasLocation = typeof location !== 'undefined' && location.href, 19 | defaultProtocol = hasLocation && location.protocol && location.protocol.replace(/\:/, ''), 20 | defaultHostName = hasLocation && location.hostname, 21 | defaultPort = hasLocation && (location.port || undefined), 22 | buildMap = {}, 23 | masterConfig = (module.config && module.config()) || {}; 24 | 25 | text = { 26 | version: '2.0.12', 27 | 28 | strip: function (content) { 29 | //Strips declarations so that external SVG and XML 30 | //documents can be added to a document without worry. Also, if the string 31 | //is an HTML document, only the part inside the body tag is returned. 32 | if (content) { 33 | content = content.replace(xmlRegExp, ""); 34 | var matches = content.match(bodyRegExp); 35 | if (matches) { 36 | content = matches[1]; 37 | } 38 | } else { 39 | content = ""; 40 | } 41 | return content; 42 | }, 43 | 44 | jsEscape: function (content) { 45 | return content.replace(/(['\\])/g, '\\$1') 46 | .replace(/[\f]/g, "\\f") 47 | .replace(/[\b]/g, "\\b") 48 | .replace(/[\n]/g, "\\n") 49 | .replace(/[\t]/g, "\\t") 50 | .replace(/[\r]/g, "\\r") 51 | .replace(/[\u2028]/g, "\\u2028") 52 | .replace(/[\u2029]/g, "\\u2029"); 53 | }, 54 | 55 | createXhr: masterConfig.createXhr || function () { 56 | //Would love to dump the ActiveX crap in here. Need IE 6 to die first. 57 | var xhr, i, progId; 58 | if (typeof XMLHttpRequest !== "undefined") { 59 | return new XMLHttpRequest(); 60 | } else if (typeof ActiveXObject !== "undefined") { 61 | for (i = 0; i < 3; i += 1) { 62 | progId = progIds[i]; 63 | try { 64 | xhr = new ActiveXObject(progId); 65 | } catch (e) {} 66 | 67 | if (xhr) { 68 | progIds = [progId]; // so faster next time 69 | break; 70 | } 71 | } 72 | } 73 | 74 | return xhr; 75 | }, 76 | 77 | /** 78 | * Parses a resource name into its component parts. Resource names 79 | * look like: module/name.ext!strip, where the !strip part is 80 | * optional. 81 | * @param {String} name the resource name 82 | * @returns {Object} with properties "moduleName", "ext" and "strip" 83 | * where strip is a boolean. 84 | */ 85 | parseName: function (name) { 86 | var modName, ext, temp, 87 | strip = false, 88 | index = name.indexOf("."), 89 | isRelative = name.indexOf('./') === 0 || 90 | name.indexOf('../') === 0; 91 | 92 | if (index !== -1 && (!isRelative || index > 1)) { 93 | modName = name.substring(0, index); 94 | ext = name.substring(index + 1, name.length); 95 | } else { 96 | modName = name; 97 | } 98 | 99 | temp = ext || modName; 100 | index = temp.indexOf("!"); 101 | if (index !== -1) { 102 | //Pull off the strip arg. 103 | strip = temp.substring(index + 1) === "strip"; 104 | temp = temp.substring(0, index); 105 | if (ext) { 106 | ext = temp; 107 | } else { 108 | modName = temp; 109 | } 110 | } 111 | 112 | return { 113 | moduleName: modName, 114 | ext: ext, 115 | strip: strip 116 | }; 117 | }, 118 | 119 | xdRegExp: /^((\w+)\:)?\/\/([^\/\\]+)/, 120 | 121 | /** 122 | * Is an URL on another domain. Only works for browser use, returns 123 | * false in non-browser environments. Only used to know if an 124 | * optimized .js version of a text resource should be loaded 125 | * instead. 126 | * @param {String} url 127 | * @returns Boolean 128 | */ 129 | useXhr: function (url, protocol, hostname, port) { 130 | var uProtocol, uHostName, uPort, 131 | match = text.xdRegExp.exec(url); 132 | if (!match) { 133 | return true; 134 | } 135 | uProtocol = match[2]; 136 | uHostName = match[3]; 137 | 138 | uHostName = uHostName.split(':'); 139 | uPort = uHostName[1]; 140 | uHostName = uHostName[0]; 141 | 142 | return (!uProtocol || uProtocol === protocol) && 143 | (!uHostName || uHostName.toLowerCase() === hostname.toLowerCase()) && 144 | ((!uPort && !uHostName) || uPort === port); 145 | }, 146 | 147 | finishLoad: function (name, strip, content, onLoad) { 148 | content = strip ? text.strip(content) : content; 149 | if (masterConfig.isBuild) { 150 | buildMap[name] = content; 151 | } 152 | onLoad(content); 153 | }, 154 | 155 | load: function (name, req, onLoad, config) { 156 | //Name has format: some.module.filext!strip 157 | //The strip part is optional. 158 | //if strip is present, then that means only get the string contents 159 | //inside a body tag in an HTML string. For XML/SVG content it means 160 | //removing the declarations so the content can be inserted 161 | //into the current doc without problems. 162 | 163 | // Do not bother with the work if a build and text will 164 | // not be inlined. 165 | if (config && config.isBuild && !config.inlineText) { 166 | onLoad(); 167 | return; 168 | } 169 | 170 | masterConfig.isBuild = config && config.isBuild; 171 | 172 | var parsed = text.parseName(name), 173 | nonStripName = parsed.moduleName + 174 | (parsed.ext ? '.' + parsed.ext : ''), 175 | url = req.toUrl(nonStripName), 176 | useXhr = (masterConfig.useXhr) || 177 | text.useXhr; 178 | 179 | // Do not load if it is an empty: url 180 | if (url.indexOf('empty:') === 0) { 181 | onLoad(); 182 | return; 183 | } 184 | 185 | //Load the text. Use XHR if possible and in a browser. 186 | if (!hasLocation || useXhr(url, defaultProtocol, defaultHostName, defaultPort)) { 187 | text.get(url, function (content) { 188 | text.finishLoad(name, parsed.strip, content, onLoad); 189 | }, function (err) { 190 | if (onLoad.error) { 191 | onLoad.error(err); 192 | } 193 | }); 194 | } else { 195 | //Need to fetch the resource across domains. Assume 196 | //the resource has been optimized into a JS module. Fetch 197 | //by the module name + extension, but do not include the 198 | //!strip part to avoid file system issues. 199 | req([nonStripName], function (content) { 200 | text.finishLoad(parsed.moduleName + '.' + parsed.ext, 201 | parsed.strip, content, onLoad); 202 | }); 203 | } 204 | }, 205 | 206 | write: function (pluginName, moduleName, write, config) { 207 | if (buildMap.hasOwnProperty(moduleName)) { 208 | var content = text.jsEscape(buildMap[moduleName]); 209 | write.asModule(pluginName + "!" + moduleName, 210 | "define(function () { return '" + 211 | content + 212 | "';});\n"); 213 | } 214 | }, 215 | 216 | writeFile: function (pluginName, moduleName, req, write, config) { 217 | var parsed = text.parseName(moduleName), 218 | extPart = parsed.ext ? '.' + parsed.ext : '', 219 | nonStripName = parsed.moduleName + extPart, 220 | //Use a '.js' file name so that it indicates it is a 221 | //script that can be loaded across domains. 222 | fileName = req.toUrl(parsed.moduleName + extPart) + '.js'; 223 | 224 | //Leverage own load() method to load plugin value, but only 225 | //write out values that do not have the strip argument, 226 | //to avoid any potential issues with ! in file names. 227 | text.load(nonStripName, req, function (value) { 228 | //Use own write() method to construct full module value. 229 | //But need to create shell that translates writeFile's 230 | //write() to the right interface. 231 | var textWrite = function (contents) { 232 | return write(fileName, contents); 233 | }; 234 | textWrite.asModule = function (moduleName, contents) { 235 | return write.asModule(moduleName, fileName, contents); 236 | }; 237 | 238 | text.write(pluginName, nonStripName, textWrite, config); 239 | }, config); 240 | } 241 | }; 242 | 243 | if (masterConfig.env === 'node' || (!masterConfig.env && 244 | typeof process !== "undefined" && 245 | process.versions && 246 | !!process.versions.node && 247 | !process.versions['node-webkit'])) { 248 | //Using special require.nodeRequire, something added by r.js. 249 | fs = require.nodeRequire('fs'); 250 | 251 | text.get = function (url, callback, errback) { 252 | try { 253 | var file = fs.readFileSync(url, 'utf8'); 254 | //Remove BOM (Byte Mark Order) from utf8 files if it is there. 255 | if (file.indexOf('\uFEFF') === 0) { 256 | file = file.substring(1); 257 | } 258 | callback(file); 259 | } catch (e) { 260 | if (errback) { 261 | errback(e); 262 | } 263 | } 264 | }; 265 | } else if (masterConfig.env === 'xhr' || (!masterConfig.env && 266 | text.createXhr())) { 267 | text.get = function (url, callback, errback, headers) { 268 | var xhr = text.createXhr(), header; 269 | xhr.open('GET', url, true); 270 | 271 | //Allow plugins direct access to xhr headers 272 | if (headers) { 273 | for (header in headers) { 274 | if (headers.hasOwnProperty(header)) { 275 | xhr.setRequestHeader(header.toLowerCase(), headers[header]); 276 | } 277 | } 278 | } 279 | 280 | //Allow overrides specified in config 281 | if (masterConfig.onXhr) { 282 | masterConfig.onXhr(xhr, url); 283 | } 284 | 285 | xhr.onreadystatechange = function (evt) { 286 | var status, err; 287 | //Do not explicitly handle errors, those should be 288 | //visible via console output in the browser. 289 | if (xhr.readyState === 4) { 290 | status = xhr.status || 0; 291 | if (status > 399 && status < 600) { 292 | //An http 4xx or 5xx error. Signal an error. 293 | err = new Error(url + ' HTTP status: ' + status); 294 | err.xhr = xhr; 295 | if (errback) { 296 | errback(err); 297 | } 298 | } else { 299 | callback(xhr.responseText); 300 | } 301 | 302 | if (masterConfig.onXhrComplete) { 303 | masterConfig.onXhrComplete(xhr, url); 304 | } 305 | } 306 | }; 307 | xhr.send(null); 308 | }; 309 | } else if (masterConfig.env === 'rhino' || (!masterConfig.env && 310 | typeof Packages !== 'undefined' && typeof java !== 'undefined')) { 311 | //Why Java, why is this so awkward? 312 | text.get = function (url, callback) { 313 | var stringBuffer, line, 314 | encoding = "utf-8", 315 | file = new java.io.File(url), 316 | lineSeparator = java.lang.System.getProperty("line.separator"), 317 | input = new java.io.BufferedReader(new java.io.InputStreamReader(new java.io.FileInputStream(file), encoding)), 318 | content = ''; 319 | try { 320 | stringBuffer = new java.lang.StringBuffer(); 321 | line = input.readLine(); 322 | 323 | // Byte Order Mark (BOM) - The Unicode Standard, version 3.0, page 324 324 | // http://www.unicode.org/faq/utf_bom.html 325 | 326 | // Note that when we use utf-8, the BOM should appear as "EF BB BF", but it doesn't due to this bug in the JDK: 327 | // http://bugs.sun.com/bugdatabase/view_bug.do?bug_id=4508058 328 | if (line && line.length() && line.charAt(0) === 0xfeff) { 329 | // Eat the BOM, since we've already found the encoding on this file, 330 | // and we plan to concatenating this buffer with others; the BOM should 331 | // only appear at the top of a file. 332 | line = line.substring(1); 333 | } 334 | 335 | if (line !== null) { 336 | stringBuffer.append(line); 337 | } 338 | 339 | while ((line = input.readLine()) !== null) { 340 | stringBuffer.append(lineSeparator); 341 | stringBuffer.append(line); 342 | } 343 | //Make sure we return a JavaScript string and not a Java string. 344 | content = String(stringBuffer.toString()); //String 345 | } finally { 346 | input.close(); 347 | } 348 | callback(content); 349 | }; 350 | } else if (masterConfig.env === 'xpconnect' || (!masterConfig.env && 351 | typeof Components !== 'undefined' && Components.classes && 352 | Components.interfaces)) { 353 | //Avert your gaze! 354 | Cc = Components.classes; 355 | Ci = Components.interfaces; 356 | Components.utils['import']('resource://gre/modules/FileUtils.jsm'); 357 | xpcIsWindows = ('@mozilla.org/windows-registry-key;1' in Cc); 358 | 359 | text.get = function (url, callback) { 360 | var inStream, convertStream, fileObj, 361 | readData = {}; 362 | 363 | if (xpcIsWindows) { 364 | url = url.replace(/\//g, '\\'); 365 | } 366 | 367 | fileObj = new FileUtils.File(url); 368 | 369 | //XPCOM, you so crazy 370 | try { 371 | inStream = Cc['@mozilla.org/network/file-input-stream;1'] 372 | .createInstance(Ci.nsIFileInputStream); 373 | inStream.init(fileObj, 1, 0, false); 374 | 375 | convertStream = Cc['@mozilla.org/intl/converter-input-stream;1'] 376 | .createInstance(Ci.nsIConverterInputStream); 377 | convertStream.init(inStream, "utf-8", inStream.available(), 378 | Ci.nsIConverterInputStream.DEFAULT_REPLACEMENT_CHARACTER); 379 | 380 | convertStream.readString(inStream.available(), readData); 381 | convertStream.close(); 382 | inStream.close(); 383 | callback(readData.value); 384 | } catch (e) { 385 | throw new Error((fileObj && fileObj.path || '') + ': ' + e); 386 | } 387 | }; 388 | } 389 | return text; 390 | }); 391 | -------------------------------------------------------------------------------- /docs/site/sitemap.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | / 7 | 2017-09-06 8 | daily 9 | 10 | 11 | 12 | 13 | 14 | 15 | /api_overview/ 16 | 2017-09-06 17 | daily 18 | 19 | 20 | 21 | /errors/ 22 | 2017-09-06 23 | daily 24 | 25 | 26 | 27 | /validation/ 28 | 2017-09-06 29 | daily 30 | 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /docs/site/validation/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | Input Validation - BBJ 12 | 13 | 14 | 15 | 16 | 17 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 90 | 91 |
92 | 97 |
98 | 99 |

Implementing good sanity checks in your client.

100 |

The server has an endpoint called db_validate. What this does is take 101 | a key and a value argument, and compares value to a set of rules specified by 102 | key. This is the same function used internally by the database to check 103 | values before committing them to the database. By default it returns a 104 | descriptive object under data, but you can specify the key/value pair 105 | "error": True to get a standard error response back. A standard call 106 | to db_validate will look like this.

107 |
{
108 |     "key": "title",
109 |     "value": "this title\nis bad \nbecause it contains \nnewlines"
110 | }
111 | 
112 | 113 |

and the server will respond like this when the input should be corrected.

114 |
{
115 |     "data": {
116 |         "bool": False,
117 |         "description": "Titles cannot contain whitespace characters besides spaces."
118 |     },
119 |     "error": False,
120 |     "usermap": {}
121 | }
122 | 
123 | 124 |

if everything is okay, the data object will look like this instead.

125 |
    "data": {
126 |         "bool": True,
127 |         "description": null
128 |     },
129 | 
130 | 131 |

Alternatively, you can supply "error": True in the request.

132 |
{
133 |     "error": True,
134 |     "key": "title",
135 |     "value": "this title\nis bad \nbecause it contains \nnewlines"
136 | }
137 | // and you get...
138 | {
139 |     "data": null,
140 |     "usermap": {},
141 |     "error": {
142 |         "code": 4,
143 |         "description": "Titles cannot contain whitespace characters besides spaces."
144 |     }
145 | }
146 | 
147 | 148 |

The following keys are currently available.

149 |
    150 |
  • "user_name"
  • 151 |
  • "auth_hash"
  • 152 |
  • "quip"
  • 153 |
  • "bio"
  • 154 |
  • "title"
  • 155 |
  • "body"
  • 156 |
  • "color"
  • 157 |
158 |

The descriptions returned are friendly, descriptive, and should be shown 159 | directly to users

160 |

By using this endpoint, you will never have to validate values in your 161 | own code before sending them to the server. This means you can do things 162 | like implement an interactive prompt which will not allow the user to 163 | submit it unless the value is correct.

164 |

This is used in the elisp client when registering users and for the thread 165 | title prompt which is shown before opening a composure window. The reason 166 | for rejection is displayed clearly to the user and input window is restored.

167 |
(defun bbj-sane-value (prompt key)
168 |   "Opens an input loop with the user, where the response is
169 | passed to the server to check it for validity before the
170 | user is allowed to continue. Will recurse until the input
171 | is valid, then it is returned."
172 |   (let* ((value (read-from-minibuffer prompt))
173 |          (response (bbj-request! 'db_validate 'value value 'key key)))
174 |     (if (alist-get 'bool response)
175 |         value ;; return the user's input back to the caller
176 |       (message (alist-get 'description response))
177 |       (sit-for 2)
178 |       (bbj-sane-value prompt key))))
179 | 
180 |
181 | 182 |
183 |
184 |

Documentation built with MkDocs.

185 |
186 | 187 | 188 | 212 | 213 | 214 | 215 | -------------------------------------------------------------------------------- /gendocs.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # Generate the documentation site. 3 | # Invoke with no arguments in the base repo directory. 4 | # Nothing magical here. 5 | 6 | python3 ./mkendpoints.py 7 | cd ./docs 8 | mkdocs build 9 | cd .. 10 | -------------------------------------------------------------------------------- /js/datetime.js: -------------------------------------------------------------------------------- 1 | function divmod(x, y) { 2 | return [Math.floor(x / y), x % y]; 3 | } 4 | 5 | function timestring(timestamp) { 6 | time_milliseconds = timestamp * 1000 7 | date = new Date(time_milliseconds); 8 | return date.toLocaleDateString() + " " + date.toLocaleTimeString() 9 | } 10 | 11 | function readable_delta(timestamp) { 12 | current_time_seconds = Date.now() / 1000 13 | delta = current_time_seconds - timestamp 14 | mod = divmod(delta, 3600) 15 | hours = mod[0] 16 | remainder = mod[1] 17 | if (hours > 48) { 18 | return timestring(timestamp) 19 | } 20 | else if (hours > 1) { 21 | return hours + " hours ago" 22 | } 23 | else if (hours == 1) { 24 | return "about an hour ago" 25 | } 26 | mod = divmod(remainder, 60) 27 | minutes = mod[0] 28 | remainder = mod[1] 29 | if (minutes > 1) { 30 | return minutes + " minutes ago" 31 | } 32 | return "less than a minute ago" 33 | } 34 | 35 | timestamps = document.getElementsByClassName("datetime") 36 | for (let i = 0; i < timestamps.length; i++) { 37 | delta = readable_delta(timestamps[i].textContent) 38 | timestamps[i].textContent = delta 39 | } -------------------------------------------------------------------------------- /js/postboxes.js: -------------------------------------------------------------------------------- 1 | function revealPostReplyBox(post_id) { 2 | domElement = document.getElementById("replyBox" + post_id) 3 | document.getElementById("replyLink" + post_id).remove() 4 | 5 | form = document.createElement("form") 6 | form.setAttribute("method", "post") 7 | form.setAttribute("action", "/threadReply") 8 | 9 | textarea = document.createElement("textarea") 10 | textarea.setAttribute("class", "directReplyBox") 11 | textarea.setAttribute("id", "postContent") 12 | textarea.setAttribute("name", "postContent") 13 | textarea.setAttribute("rows", "10") 14 | textarea.setAttribute("cols", "50") 15 | textarea.value = ">>" + post_id + " \n" 16 | 17 | input = document.createElement("input") 18 | input.setAttribute("name", "threadId") 19 | input.value = thread_id 20 | input.style = "display:none" 21 | 22 | submit = document.createElement("input") 23 | submit.setAttribute("class", "directReplyBox") 24 | submit.setAttribute("type", "submit") 25 | submit.setAttribute("value", "Submit") 26 | 27 | form.appendChild(textarea) 28 | form.appendChild(input) 29 | form.appendChild(document.createElement("br")) 30 | form.appendChild(submit) 31 | 32 | domElement.appendChild(form) 33 | 34 | } 35 | 36 | function revealThreadCreateForm() { 37 | form = document.getElementById("threadCreateBox") 38 | if (form.style.display == "none") { 39 | form.style.display = "block" 40 | } else { 41 | form.style.display = "none" 42 | } 43 | } 44 | 45 | function revealPostReplyForm() { 46 | form = document.getElementById("postReplyBox") 47 | if (form.style.display == "none") { 48 | form.style.display = "block" 49 | } else { 50 | form.style.display = "none" 51 | } 52 | } -------------------------------------------------------------------------------- /mkendpoints.py: -------------------------------------------------------------------------------- 1 | """ 2 | This is a small script that creates the endpoint doc page. It should be 3 | evoked from the command line each time changes are made. It writes 4 | to ./docs/docs/api_overview.md 5 | 6 | The code used in this script is the absolute minimum required to 7 | get the job done; it can be considered a crude hack at best. I am 8 | more interested in writing good documentation than making sure that 9 | the script that shits it out is politcally correct ;) 10 | """ 11 | 12 | from server import API 13 | import pydoc 14 | 15 | body = """ 16 | # How to BBJ? 17 | 18 | ## Input 19 | 20 | BBJ is interacted with entirely through POST requests, whose bodies are 21 | json objects. 22 | 23 | The endpoints, all listed below, can be contacted at the path /api/ relative 24 | to the root of where BBJ is hosted. If bbj is hosted on a server on port 80 25 | at the root: 26 | 27 | `http://server.com/api/endpoint_here` 28 | 29 | The body of your request contains all of it's argument fields, instead of 30 | using URL parameters. As a demonstration, to call `thread_create`, 31 | it requires two arguments: `title`, and `body`. We put those argument 32 | names at the root of the json object, and their values are the info 33 | passed into the API for that spot. Your input will look like this: 34 | 35 | ```json 36 | { 37 | "title": "Hello world!!", 38 | "body": "Hi! I am exploring this cool board thing!!" 39 | } 40 | ``` 41 | 42 | And you will POST this body to `http://server.com:PORT/api/thread_create`. 43 | 44 | A few endpoints do not require any arguments. These can still be POSTed to, 45 | but the body may be completely empty or an empty json object. You can even 46 | GET these if you so choose. 47 | 48 | For all endpoints, argument keys that are not consumed by the endpoint are 49 | ignored. Posting an object with a key/value pair of `"sandwich": True` will 50 | not clog up any pipes :) In the same vein, endpoints who dont take arguments 51 | don't care if you supply them anyway. 52 | 53 | ## Output 54 | 55 | BBJ returns data in a consistently formatted json object. The base object 56 | has three keys: `data`, `usermap`, and `error`. Visualizied: 57 | 58 | ```javascript 59 | { 60 | "error": false, // boolean false or error object 61 | "data": null, // null or the requested data from endpoint. 62 | "usermap": {} // potentially empty object, maps user_ids to user objects 63 | } 64 | 65 | // If "error" is true, it looks like this: 66 | 67 | { 68 | "error": { 69 | "code": // an integer from 0 to 5, 70 | "description": // a string describing the error in detail. 71 | } 72 | "data": null // ALWAYS null if error is not false 73 | "usermap": {} // ALWAYS empty if error is not false 74 | } 75 | ``` 76 | 77 | ### data 78 | 79 | `data` is what the endpoint actually returns. The type of contents vary 80 | by endpoint and are documented below. If an endpoint says it returns a 81 | boolean, it will look like `"data": True`. If it says it returns an array, 82 | it will look like `"data": ["stuff", "goes", "here"]` 83 | 84 | ### usermap 85 | 86 | The usermap is a json object mapping user_ids within `data` to full user 87 | objects. BBJ handles users entirely by an ID system, meaning any references 88 | to them inside of response data will not include vital information like their 89 | username, or their profile information. Instead, we fetch those values from 90 | this usermap object. All of it's root keys are user_id's and their values 91 | are user objects. It should be noted that the anonymous user has it's own 92 | ID and profile object as well. 93 | 94 | ### error 95 | 96 | `error` is typically `false`. If it is __not__ false, then the request failed 97 | and the json object that `error` contains should be inspected. (see the above 98 | visualation) Errors follow a strict code system, making it easy for your client 99 | to map these responses to native exception types or signals in your language of 100 | choice. See [the full error page](errors.md) for details. 101 | 102 | 103 | """ 104 | 105 | extra = { 106 | "Authorization": "See also [the Authorization page](authorization.md).", 107 | "Users": "", 108 | "Threads & Messages": "", 109 | "Tools": "" 110 | } 111 | 112 | endpoints = [ 113 | ref for name, ref in API.__dict__.items() 114 | if hasattr(ref, "exposed") 115 | ] 116 | 117 | types = { 118 | function.doctype: list() for function in endpoints 119 | } 120 | 121 | for function in endpoints: 122 | types[function.doctype].append(function) 123 | 124 | for doctype in sorted(types.keys()): 125 | body += "

\n# %s\n------\n" % doctype 126 | desc = extra[doctype] 127 | if desc: 128 | body += desc + "\n" 129 | funcs = sorted(types[doctype], key=lambda _: _.__name__) 130 | for f in funcs: 131 | body += "## %s\n\n" % f.__name__ 132 | if f.arglist[0][0]: 133 | body += "**Arguments:**\n\n" 134 | for key, value in f.arglist: 135 | body += " * __%s__: %s\n\n" % (key, value) 136 | else: 137 | body += "_requires no arguments_" 138 | 139 | body += "\n\n" + pydoc.getdoc(f) + "\n\n" 140 | body += "\n
\n" 141 | 142 | with open("docs/docs/api_overview.md", "w") as output: 143 | output.write(body) 144 | -------------------------------------------------------------------------------- /prototype/clients/network_client.py: -------------------------------------------------------------------------------- 1 | from hashlib import sha256 2 | import socket 3 | import json 4 | 5 | 6 | class BBJ: 7 | def __init__(self, host, port): 8 | self.host = host 9 | self.port = port 10 | self.username = None 11 | self.auth_hash = None 12 | 13 | 14 | def __call__(self, method, **params): 15 | return self.request(method, **params) 16 | 17 | 18 | def setuser(self, username, unhashed_password): 19 | self.auth_hash = sha256(bytes(unhashed_password, "utf8")).hexdigest() 20 | self.username = username 21 | return self.auth_hash 22 | 23 | 24 | def request(self, method, **params): 25 | params["method"] = method 26 | 27 | if not params.get("user") and self.username: 28 | params["user"] = self.username 29 | 30 | if not params.get("auth_hash") and self.auth_hash: 31 | params["auth_hash"] = self.auth_hash 32 | 33 | 34 | connection = socket.create_connection((self.host, self.port)) 35 | connection.sendall(bytes(json.dumps(params), "utf8")) 36 | connection.shutdown(socket.SHUT_WR) 37 | 38 | try: 39 | buff, length = bytes(), 1 40 | while length != 0: 41 | recv = connection.recv(2048) 42 | length = len(recv) 43 | buff += recv 44 | 45 | finally: 46 | connection.close() 47 | 48 | response = json.loads(str(buff, "utf8")) 49 | if not isinstance(response, dict): 50 | return response 51 | 52 | error = response.get("error") 53 | if not error: 54 | return response 55 | 56 | code, desc = error["code"], error["description"] 57 | 58 | # tfw no qt3.14 python case switches 59 | if error in (0, 1): 60 | raise ChildProcessError("internal server error: " + desc) 61 | elif error in (2, 3): 62 | raise ChildProcessError(desc) 63 | 64 | return response 65 | -------------------------------------------------------------------------------- /prototype/clients/urwid/main.py: -------------------------------------------------------------------------------- 1 | from src import network 2 | 3 | 4 | bbj = network.BBJ("192.168.1.137", 7066) 5 | 6 | 7 | def geterr(obj): 8 | """ 9 | Returns false if there are no errors in a network response, 10 | else a tuple of (code integer, description string) 11 | """ 12 | error = obj.get("error") 13 | if not error: 14 | return False 15 | return (error["code"], error["description"]) 16 | 17 | 18 | def register_prompt(user, initial=True): 19 | if initial: 20 | print("Register for BBJ as {}?".format(user)) 21 | reply = input("(y[es], d[ifferent name], q[uit])> ").lower() 22 | 23 | if reply.startswith("d"): 24 | register_prompt(input("(Username)> ")) 25 | elif reply.startswith("q"): 26 | exit("bye!") 27 | 28 | def getpass(ok): 29 | p1 = input( 30 | "(Choose a password)> " if ok else \ 31 | "(Those didn't match. Try again)> ") 32 | p2 = input("(Now type it one more time)> ") 33 | return p1 if p1 == p2 else getpass(False) 34 | 35 | # this method will sha256 it for us 36 | bbj.setuser(user, getpass(True)) 37 | 38 | response = bbj("user_register", quip="", bio="") 39 | error = geterr(response) 40 | if error: 41 | exit("Registration error: " + error[1]) 42 | return response 43 | 44 | 45 | def login(user, ok=True): 46 | if not bbj("is_registered", target_user=user): 47 | register_prompt(user) 48 | else: 49 | bbj.setuser(user, input( 50 | "(Password)> " if ok else \ 51 | "(Invalid password, try again)> ")) 52 | 53 | if not bbj("check_auth"): 54 | login(user, ok=False) 55 | 56 | return bbj("user_get", target_user=user) 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | # user = input("(BBJ Username)> ") 65 | # if not bbj("is_registered", target_user=user): 66 | 67 | 68 | login(input("(Username)> ")) 69 | 70 | import urwid 71 | 72 | f = urwid.Frame( 73 | urwid.ListBox( 74 | urwid.SimpleFocusListWalker( 75 | [urwid.Text(i["body"]) for i in bbj("thread_index")["threads"]] 76 | ) 77 | ) 78 | ) 79 | 80 | t = urwid.Overlay( 81 | f, urwid.SolidFill('!'), 82 | align='center', 83 | width=('relative', 80), 84 | height=('relative', 80), 85 | valign='middle' 86 | ) 87 | 88 | loop = urwid.MainLoop(t) 89 | -------------------------------------------------------------------------------- /prototype/clients/urwid/src/network.py: -------------------------------------------------------------------------------- 1 | from hashlib import sha256 2 | import socket 3 | import json 4 | 5 | 6 | class BBJ: 7 | def __init__(self, host, port): 8 | self.host = host 9 | self.port = port 10 | self.username = None 11 | self.auth_hash = None 12 | 13 | 14 | def __call__(self, method, **params): 15 | return self.request(method, **params) 16 | 17 | 18 | def setuser(self, username, unhashed_password): 19 | self.auth_hash = sha256(bytes(unhashed_password, "utf8")).hexdigest() 20 | self.username = username 21 | return self.auth_hash 22 | 23 | 24 | def request(self, method, **params): 25 | params["method"] = method 26 | 27 | if not params.get("user") and self.username: 28 | params["user"] = self.username 29 | 30 | if not params.get("auth_hash") and self.auth_hash: 31 | params["auth_hash"] = self.auth_hash 32 | 33 | 34 | connection = socket.create_connection((self.host, self.port)) 35 | connection.sendall(bytes(json.dumps(params), "utf8")) 36 | connection.shutdown(socket.SHUT_WR) 37 | 38 | try: 39 | buff, length = bytes(), 1 40 | while length != 0: 41 | recv = connection.recv(2048) 42 | length = len(recv) 43 | buff += recv 44 | 45 | finally: 46 | connection.close() 47 | 48 | response = json.loads(str(buff, "utf8")) 49 | if not isinstance(response, dict): 50 | return response 51 | 52 | error = response.get("error") 53 | if not error: 54 | return response 55 | 56 | code, desc = error["code"], error["description"] 57 | 58 | # tfw no qt3.14 python case switches 59 | if error in (0, 1): 60 | raise ChildProcessError("internal server error: " + desc) 61 | elif error in (2, 3): 62 | raise ChildProcessError(desc) 63 | 64 | return response 65 | -------------------------------------------------------------------------------- /prototype/clients/urwid/src/widgets.py: -------------------------------------------------------------------------------- 1 | import urwid 2 | 3 | class PostBox(urwid.ListBox): 4 | pass 5 | -------------------------------------------------------------------------------- /prototype/docs/protocol.org: -------------------------------------------------------------------------------- 1 | Data Standards 2 | -------------- 3 | 4 | 5 | * UTF-8 in, UTF-8 out. No exceptions. 6 | 7 | * SHA256 for auth_hash. Server will do a basic check to make sure of this. 8 | 9 | * Security is not a #1 concern. Basic authorization will be implemented 10 | to **help prevent** users from impersonating each other, but this isn't 11 | intended to be bulletproof and you shouldn't trust the system with a 12 | password you use elsewhere. All clients should inform the user of this. 13 | 14 | * Command-line, on-tilde comes first. Local clients should be possible using 15 | SSH port binding, however features like inline images, graphical elements 16 | and the like will never be implemented as part of the protocol. Local clients 17 | can definitely do things like URL image previews though. Hyperlinks with a 18 | different text then the link itself will never be implemented. 19 | 20 | 21 | Text Entities 22 | ------------- 23 | 24 | The `entities` attribute is an array of objects that represent blocks 25 | of text within a post that have special properties. Clients may safely 26 | ignore these things without losing too much meaning, but in a rich 27 | implementation like an Emacs or GUI, they can provide 28 | some highlighting and navigation perks. The array object may be 29 | empty. If its not, its populated with arrays representing the 30 | modifications to be made. 31 | 32 | Objects **always** have a minimum of 3 attributes: 33 | ``` 34 | ["quote", 5, 7] 35 | ``` 36 | object[0] is a string representing the attribute type. They are 37 | documented below. The next two items are the indices of the 38 | property in the body string. The way clients are to access these 39 | indices is beyond the scope of this document; accessing a subsequence 40 | varies a lot between programming languages. 41 | 42 | Some objects will provide further arguments beyond those 3. They will 43 | always be at the end of the array. 44 | 45 | | Name | Description | 46 | |-------------+----------------------------------------------------------| 47 | | `quote` | This is a string that refers to a previous post number. | 48 | | | These are formatted like >>5, which means it is a | 49 | | | reference to `post_id` 5. These are not processed in | 50 | | | thread OPs. >>0 may be used to refer to the OP. In | 51 | | | addition to the indices at i[1] and i[2], a fourth value | 52 | | | is provided, which is an integer of the `post_id` being | 53 | | | quoted. Note that the string indices include the >>'s. | 54 | |-------------+----------------------------------------------------------| 55 | | `linequote` | This is a line of text, denoted by a newline during | 56 | | | composure, representing text that is assumed to be | 57 | | | a quote of someone else. The indices span from the > | 58 | | | until (not including) the newline. | 59 | |-------------+----------------------------------------------------------| 60 | | `color` | This is a block of text, denoted by [[color: body]] | 61 | | | during composure. The body may span across newlines. | 62 | | | A fourth item is provided in the array: it is one of the | 63 | | | following strings representing the color. | 64 | | | `red`, `green`, `yellow`, `blue`, `magenta`, or `cyan`. | 65 | |-------------+----------------------------------------------------------| 66 | | `bold` | Like color, except that no additional attribute is | 67 | | `italic` | provided. it is denoted as [[directive: body]] during | 68 | | `underline` | composure. | 69 | 70 | 71 | Threads & Replies 72 | ----------------- 73 | 74 | Threads are represented the same when using `thread_index` and 75 | `thread_load`, except that the `replies` attribute is only 76 | present with `thread_load`. The following attributes are 77 | available on the parent object: 78 | 79 | | Name | Description | 80 | |---------------|------------------------------------------------------| 81 | | `author` | The ID string of the author. | 82 | |---------------|------------------------------------------------------| 83 | | `thread_id` | The ID string of the thread. | 84 | |---------------|------------------------------------------------------| 85 | | `title` | The title string of the thread. | 86 | |---------------|------------------------------------------------------| 87 | | `body` | The body string of the post's text. | 88 | |---------------|------------------------------------------------------| 89 | | `entities` | A (possibly empty) array of entity objects for | 90 | | | the post `body`. | 91 | |---------------|------------------------------------------------------| 92 | | `tags` | An array of strings representing tags the | 93 | | | author gave to the thread at creation. | 94 | | | When empty, it is an array with no elements. | 95 | |---------------|------------------------------------------------------| 96 | | `replies` | An array containing full reply objects in | 97 | | | the order they were posted. Your clients | 98 | | | do not need to sort these. Array can be empty. | 99 | |---------------|------------------------------------------------------| 100 | | `reply_count` | An integer representing the number of replies | 101 | | | that have been posted in this thread. | 102 | |---------------|------------------------------------------------------| 103 | | `lastmod` | Unix timestamp of when the thread was last | 104 | | | posted in, or a message was edited. | 105 | |---------------|------------------------------------------------------| 106 | | `edited` | Boolean of whether the post has been edited. | 107 | |---------------|------------------------------------------------------| 108 | | `created` | Unix timestamp of when the post was originally made. | 109 | 110 | The following attributes are available on each reply object in `replies`: 111 | 112 | 113 | | Name | Description | 114 | |------------|---------------------------------------------------------| 115 | | `post_id` | An integer of the posts ID; unlike thread and user ids, | 116 | | | this is not a uuid but instead is incremental, starting | 117 | | | from 1 as the first reply and going up by one for each | 118 | | | post. These may be referenced by `quote` entities. | 119 | |------------|---------------------------------------------------------| 120 | | `author` | Author ID string | 121 | |------------|---------------------------------------------------------| 122 | | `body` | The body string the reply's text. | 123 | |------------|---------------------------------------------------------| 124 | | `entities` | A (possibly empty) array of entity objects for | 125 | | | the reply `body`. | 126 | |------------|---------------------------------------------------------| 127 | | `lastmod` | Unix timestamp of when the post was last edited, or | 128 | | | the same as `created` if it never was. | 129 | |------------|---------------------------------------------------------| 130 | | `edited` | A boolean of whether the post was edited. | 131 | |------------|---------------------------------------------------------| 132 | | `created` | Unix timestamp of when the reply was originally posted. | 133 | 134 | 135 | Errors 136 | ------ 137 | 138 | Errors are represented in the `error` field of the response. The error 139 | field is always present, but is usually false. If its not false, it is 140 | an object with the fields `code` and `description`. `code` is an integer 141 | representing the type of failure, and `description` is a string describing 142 | the problem. `description` is intended for human consumption; in your client 143 | code, use the error codes to handle conditions. The `presentable` column 144 | indicates whether the `description` should be shown to users verbatim. 145 | 146 | | Code | Presentable | Documentation | 147 | |------+--------------+----------------------------------------------------| 148 | | 0 | Never, fix | Malformed json input. `description` is the error | 149 | | | your client | string thrown by the server-side json decoder. | 150 | |------+--------------+----------------------------------------------------| 151 | | 1 | Not a good | Internal server error. Unaltered exception text | 152 | | | idea, the | is returned as `description`. This shouldn't | 153 | | | exceptions | happen, and if it does, make a bug report. | 154 | | | are not | clients should not attempt to intelligently | 155 | | | helpful | recover from any errors of this class. | 156 | |------+--------------+----------------------------------------------------| 157 | | 2 | Nadda. | Unknown `method` was requested. | 158 | |------+--------------+----------------------------------------------------| 159 | | 3 | Fix. Your. | Missing, malformed, or otherwise incorrect | 160 | | | Client. | parameters or values for the requested `method`. | 161 | | | | This is returned, for example, when a request to | 162 | | | | `edit_post` tries to edit a post_id that does | 163 | | | | not exist. Its also used to indicate a lack of | 164 | | | | required arguments for a method. This is a generic | 165 | | | | error class that can cover programming errors | 166 | | | | but never user errors. | 167 | |------+--------------+----------------------------------------------------| 168 | | 4 | Only during | Invalid or unprovided `user`. | 169 | | | registration | | 170 | | | | During registration, this code is returned with a | 171 | | | | `description` that should be shown to the user. | 172 | | | | It could indicate an invalid name input, an | 173 | | | | occupied username, invalid/missing `auth_hash`, | 174 | | | | etc. | 175 | |------+--------------+----------------------------------------------------| 176 | | 5 | Always | `user` is not registered. | 177 | |------+--------------+----------------------------------------------------| 178 | | 6 | Always | User `auth_hash` failed or was not provided. | 179 | |------+--------------+----------------------------------------------------| 180 | | 7 | Always | Requested thread does not exist. | 181 | |------+--------------+----------------------------------------------------| 182 | | 8 | Always | Requested thread does not allow posts. | 183 | |------+--------------+----------------------------------------------------| 184 | | 9 | Always | Message edit failed; there is a 24hr limit for | 185 | | | | editing posts. | 186 | |------+--------------+----------------------------------------------------| 187 | | 10 | Always | User action requires `admin` privilege. | 188 | |------+--------------+----------------------------------------------------| 189 | | 11 | Always | Invalid formatting directives in text submission. | 190 | -------------------------------------------------------------------------------- /prototype/main.py: -------------------------------------------------------------------------------- 1 | from src import schema 2 | from src import server 3 | 4 | if __name__ == '__main__': 5 | server.run("localhost", 7066) 6 | -------------------------------------------------------------------------------- /prototype/src/db.py: -------------------------------------------------------------------------------- 1 | from src import formatting 2 | from uuid import uuid1 3 | from src import schema 4 | from time import time 5 | from os import path 6 | import json 7 | 8 | PATH = "/home/desvox/bbj/" 9 | 10 | if not path.isdir(PATH): 11 | path.os.mkdir(PATH, mode=0o744) 12 | 13 | if not path.isdir(path.join(PATH, "threads")): 14 | path.os.mkdir(path.join(PATH, "threads"), mode=0o744) 15 | 16 | try: 17 | with open(path.join(PATH, "userdb"), "r") as f: 18 | USERDB = json.loads(f.read()) 19 | 20 | except FileNotFoundError: 21 | USERDB = dict(namemap=dict()) 22 | with open(path.join(PATH, "userdb"), "w") as f: 23 | f.write(json.dumps(USERDB)) 24 | path.os.chmod(path.join(PATH, "userdb"), 0o600) 25 | 26 | 27 | ### THREAD MANAGEMENT ### 28 | 29 | def thread_index(key="lastmod", markup=True): 30 | result = list() 31 | for ID in path.os.listdir(path.join(PATH, "threads")): 32 | thread = thread_load(ID, markup) 33 | thread.pop("replies") 34 | result.append(thread) 35 | return sorted(result, key=lambda i: i[key], reverse=True) 36 | 37 | 38 | def thread_create(author, body, title, tags): 39 | ID = uuid1().hex 40 | if tags: 41 | tags = [tag.strip() for tag in tags.split(",")] 42 | else: # make sure None, False, and empty arrays are always repped consistently 43 | tags = [] 44 | scheme = schema.thread(ID, author, body, title, tags) 45 | thread_dump(ID, scheme) 46 | return scheme 47 | 48 | 49 | def thread_load(ID, markup=True): 50 | try: 51 | with open(path.join(PATH, "threads", ID), "r") as f: 52 | return json.loads(f.read()) 53 | except FileNotFoundError: 54 | return False 55 | 56 | 57 | def thread_dump(ID, obj): 58 | with open(path.join(PATH, "threads", ID), "w") as f: 59 | f.write(json.dumps(obj)) 60 | 61 | 62 | def thread_reply(ID, author, body): 63 | thread = thread_load(ID) 64 | if not thread: 65 | return schema.error(7, "Requested thread does not exist.") 66 | 67 | thread["reply_count"] += 1 68 | thread["lastmod"] = time() 69 | 70 | if thread["replies"]: 71 | lastpost = thread["replies"][-1]["post_id"] 72 | else: 73 | lastpost = 1 74 | 75 | reply = schema.reply(lastpost + 1, author, body) 76 | thread["replies"].append(reply) 77 | thread_dump(ID, thread) 78 | return reply 79 | 80 | 81 | def index_reply(reply_list, post_id): 82 | for index, reply in enumerate(reply_list): 83 | if reply["post_id"] == post_id: 84 | return index 85 | else: 86 | raise IndexError 87 | 88 | 89 | def edit_handler(json, thread=None): 90 | try: 91 | target_id = json["post_id"] 92 | if not thread: 93 | thread = thread_load(json["thread_id"]) 94 | if not thread: 95 | return False, schema.error(7, "Requested thread does not exist.") 96 | 97 | 98 | if target_id == 1: 99 | target = thread 100 | else: 101 | target = thread["replies"][ 102 | index_reply(thread["replies"], target_id)] 103 | 104 | if not user_is_admin(json["user"]): 105 | if json["user"] != target["author"]: 106 | return False, schema.error(10, 107 | "non-admin attempt to edit another user's message") 108 | 109 | elif (time() - target["created"]) > 86400: 110 | return False, schema.error(9, 111 | "message is too old to edit (24hr limit)") 112 | 113 | return True, target 114 | 115 | except IndexError: 116 | return False, schema.error(3, "post_id out of bounds for requested thread") 117 | 118 | 119 | ### USER MANAGEMENT ### 120 | 121 | def user_dbdump(dictionary): 122 | with open(path.join(PATH, "userdb"), "w") as f: 123 | f.write(json.dumps(dictionary)) 124 | 125 | 126 | def user_resolve(name_or_id): 127 | check = USERDB.get(name_or_id) 128 | try: 129 | if check: 130 | return name_or_id 131 | else: 132 | return USERDB["namemap"][name_or_id] 133 | except KeyError: 134 | return False 135 | 136 | 137 | def user_register(auth_hash, name, quip, bio): 138 | if USERDB["namemap"].get(name): 139 | return schema.error(4, "Username taken.") 140 | 141 | for ok, error in [ 142 | user_namecheck(name), 143 | user_authcheck(auth_hash), 144 | user_quipcheck(quip), 145 | user_biocheck(bio)]: 146 | 147 | if not ok: 148 | return error 149 | 150 | ID = uuid1().hex 151 | scheme = schema.user_internal(ID, auth_hash, name, quip, bio, False) 152 | USERDB.update({ID: scheme}) 153 | USERDB["namemap"].update({name: ID}) 154 | user_dbdump(USERDB) 155 | return scheme 156 | 157 | 158 | def user_get(ID): 159 | user = USERDB[ID] 160 | return schema.user_external( 161 | ID, user["name"], user["quip"], 162 | user["bio"], user["admin"]) 163 | 164 | 165 | def user_auth(ID, auth_hash): 166 | return auth_hash == USERDB[ID]["auth_hash"] 167 | 168 | 169 | def user_is_admin(ID): 170 | return USERDB[ID]["admin"] 171 | 172 | 173 | def user_update(ID, **params): 174 | USERDB[ID].update(params) 175 | return USERDB[ID] 176 | 177 | 178 | ### SANITY CHECKS ### 179 | 180 | def contains_nonspaces(string): 181 | return any([char in string for char in "\t\n\r\x0b\x0c"]) 182 | 183 | 184 | def user_namecheck(name): 185 | if not name: 186 | return False, schema.error(4, 187 | "Username may not be empty.") 188 | 189 | elif contains_nonspaces(name): 190 | return False, schema.error(4, 191 | "Username cannot contain whitespace chars besides spaces.") 192 | 193 | elif not name.strip(): 194 | return False, schema.error(4, 195 | "Username must contain at least one non-space character") 196 | 197 | 198 | elif len(name) > 24: 199 | return False, schema.error(4, 200 | "Username is too long (max 24 chars)") 201 | 202 | return True, True 203 | 204 | 205 | def user_authcheck(auth_hash): 206 | if not auth_hash: 207 | return False, schema.error(3, 208 | "auth_hash may not be empty") 209 | 210 | elif len(auth_hash) != 64: 211 | return False, schema.error(4, 212 | "Client error: invalid SHA-256 hash.") 213 | 214 | return True, True 215 | 216 | 217 | def user_quipcheck(quip): 218 | if not quip: 219 | return True, True 220 | 221 | elif contains_nonspaces(quip): 222 | return False, schema.error(4, 223 | "Quip cannot contain whitespace chars besides spaces.") 224 | 225 | elif len(quip) > 120: 226 | return False, schema.error(4, 227 | "Quip is too long (max 120 chars)") 228 | 229 | return True, True 230 | 231 | 232 | def user_biocheck(bio): 233 | if not bio: 234 | return True, True 235 | 236 | elif len(bio) > 4096: 237 | return False, schema.error(4, 238 | "Bio is too long (max 4096 chars)") 239 | 240 | return True, True 241 | -------------------------------------------------------------------------------- /prototype/src/endpoints.py: -------------------------------------------------------------------------------- 1 | from src import formatting 2 | from src import schema 3 | from time import time 4 | from src import db 5 | 6 | 7 | endpoints = { 8 | "check_auth": ["user", "auth_hash"], 9 | "is_registered": ["target_user"], 10 | "is_admin": ["target_user"], 11 | "thread_index": [], 12 | "thread_load": ["thread_id"], 13 | "thread_create": ["title", "body", "tags"], 14 | "thread_reply": ["thread_id", "body"], 15 | "edit_post": ["thread_id", "post_id", "body"], 16 | "edit_query": ["thread_id", "post_id"], 17 | "can_edit": ["thread_id", "post_id"], 18 | "user_register": ["user", "auth_hash", "quip", "bio"], 19 | "user_get": ["target_user"], 20 | "user_name_to_id": ["target_user"] 21 | } 22 | 23 | 24 | authless = [ 25 | "is_registered", 26 | "user_register" 27 | ] 28 | 29 | 30 | # this is not actually an endpoint, but produces a required 31 | # element of thread responses. 32 | def create_usermap(thread, index=False): 33 | if index: 34 | return {user: db.user_get(user) for user in 35 | {i["author"] for i in thread}} 36 | 37 | result = {reply["author"] for reply in thread["replies"]} 38 | result.add(thread["author"]) 39 | return {x: db.user_get(x) for x in result} 40 | 41 | 42 | def user_name_to_id(json): 43 | """ 44 | Returns a string of the target_user's ID when it is 45 | part of the database: a non-existent user will return 46 | a boolean false. 47 | """ 48 | return db.user_resolve(json["target_user"]) 49 | 50 | 51 | def is_registered(json): 52 | """ 53 | Returns true or false whether target_user is registered 54 | in the system. This function only takes usernames: not 55 | user IDs. 56 | """ 57 | return bool(db.USERDB["namemap"].get(json["target_user"])) 58 | 59 | 60 | def check_auth(json): 61 | "Returns true or false whether auth_hashes matches user." 62 | return bool(db.user_auth(json["user"], json["auth_hash"])) 63 | 64 | 65 | def is_admin(json): 66 | """ 67 | Returns true or false whether target_user is a system 68 | administrator. Takes a username or user ID. Nonexistent 69 | users return false. 70 | """ 71 | user = db.user_resolve(json["target_user"]) 72 | if user: 73 | return db.user_is_admin(user) 74 | return False 75 | 76 | 77 | def user_register(json): 78 | """ 79 | Registers a new user into the system. Returns the new internal user 80 | object on success, or an error response. 81 | 82 | auth_hash should be a hexadecimal SHA-256 string, produced from a 83 | UTF-8 password string. 84 | 85 | user should be a string containing no newlines and 86 | under 24 characters in length. 87 | 88 | quip is a string, up to 120 characters, provided by the user 89 | the acts as small bio, suitable for display next to posts 90 | if the client wants to. Whitespace characters besides space 91 | are not allowed. The string may be empty. 92 | 93 | bio is a string, up to 4096 chars, provided by the user that 94 | can be shown on profiles. There are no character type limits 95 | for this entry. The string may be empty. 96 | 97 | All errors for this endpoint with code 4 should show the 98 | description direcrtly to the user. 99 | 100 | """ 101 | 102 | return schema.response( 103 | db.user_register( 104 | json["auth_hash"], 105 | json["user"], 106 | json["quip"], 107 | json["bio"])) 108 | 109 | 110 | def user_get(json): 111 | """ 112 | On success, returns an external user object for target_user (ID or name). 113 | If the user isn't in the system, returns false. 114 | """ 115 | user = db.user_resolve(json["target_user"]) 116 | if not user: 117 | return False 118 | return db.user_get(user) 119 | 120 | 121 | def thread_index(json): 122 | index = db.thread_index(markup=not json.get("nomarkup")) 123 | return schema.response({"threads": index}, create_usermap(index, True)) 124 | 125 | 126 | def thread_load(json): 127 | thread = db.thread_load(json["thread_id"], not json.get("nomarkup")) 128 | if not thread: 129 | return schema.error(7, "Requested thread does not exist") 130 | return schema.response(thread, create_usermap(thread)) 131 | 132 | 133 | def thread_create(json): 134 | thread = db.thread_create( 135 | json["user"], 136 | json["body"], 137 | json["title"], 138 | json["tags"]) 139 | return schema.response(thread) 140 | 141 | 142 | def thread_reply(json): 143 | reply = db.thread_reply( 144 | json["thread_id"], 145 | json["user"], 146 | json["body"]) 147 | return schema.response(reply) 148 | 149 | 150 | def edit_query(json): 151 | return db.edit_handler(json)[1] 152 | 153 | 154 | def can_edit(json): 155 | return db.edit_handler(json)[0] 156 | 157 | 158 | def edit_post(json): 159 | thread = db.thread_load(json["thread_id"]) 160 | admin = db.user_is_admin(json["user"]) 161 | target_id = json["post_id"] 162 | ok, obj = db.edit_handler(json, thread) 163 | 164 | if ok: 165 | 166 | if json.get("reformat"): 167 | json["body"] = formatting.parse(json["body"]) 168 | 169 | obj["body"] = json["body"] 170 | obj["lastmod"] = time() 171 | obj["edited"] = True 172 | db.thread_dump(json["thread_id"], thread) 173 | 174 | return obj 175 | -------------------------------------------------------------------------------- /prototype/src/formatting.py: -------------------------------------------------------------------------------- 1 | from markdown import markdown 2 | from html import escape 3 | import re 4 | 5 | 6 | COLORS = ["red", "green", "yellow", "blue", "magenta", "cyan"] 7 | MARKUP = ["bold", "italic", "underline", "strike"] 8 | TOKENS = re.compile(r"\[({}): (.+?)]".format("|".join(COLORS + MARKUP)), flags=re.DOTALL) 9 | QUOTES = re.compile(">>([0-9]+)") 10 | LINEQUOTES = re.compile("^(>.+)$", flags=re.MULTILINE) 11 | 12 | 13 | def map_html(match): 14 | directive, body = match.group(1).lower(), match.group(2) 15 | if directive in COLORS: 16 | return '{1}'.format(directive, body) 17 | elif directive in MARKUP: 18 | return '<{0}>{1}'.format(directive[0], body) 19 | return body 20 | 21 | 22 | def parse(text, doquotes=True): 23 | text = TOKENS.sub(map_html, escape(text)) 24 | if doquotes: 25 | text = QUOTES.sub(r'\g<0>', text) 26 | return markdown( 27 | LINEQUOTES.sub(r'\1
', text) 28 | ) 29 | -------------------------------------------------------------------------------- /prototype/src/schema.py: -------------------------------------------------------------------------------- 1 | from src import formatting 2 | from time import time 3 | 4 | 5 | def base(): 6 | return { 7 | "error": False 8 | } 9 | 10 | 11 | def response(dictionary, usermap=None): 12 | result = base() 13 | result.update(dictionary) 14 | if usermap: 15 | result["usermap"] = usermap 16 | return result 17 | 18 | 19 | def error(code, description): 20 | result = base() 21 | result.update({ 22 | "error": { 23 | "description": description, # string 24 | "code": code # integer 25 | } 26 | }) 27 | return result 28 | 29 | 30 | def user_internal(ID, auth_hash, name, quip, bio, admin): 31 | if not quip: 32 | quip = "" 33 | 34 | if not bio: 35 | bio = "" 36 | 37 | return { 38 | "user_id": ID, # string 39 | "quip": quip, # (possibly empty) string 40 | "bio": bio, # (possibly empty) string 41 | "name": name, # string 42 | "admin": admin, # boolean 43 | "auth_hash": auth_hash # SHA256 string 44 | } 45 | 46 | 47 | def user_external(ID, name, quip, bio, admin): 48 | if not quip: 49 | quip = "" 50 | 51 | if not bio: 52 | bio = "" 53 | 54 | return { 55 | "user_id": ID, # string 56 | "quip": quip, # (possibly empty) string 57 | "name": name, # string 58 | "bio": bio, # string 59 | "admin": admin # boolean 60 | } 61 | 62 | 63 | def thread(ID, author, body, title, tags): 64 | if not tags: 65 | tags = list() 66 | 67 | body = formatting.parse(body, doquotes=False) 68 | now = time() 69 | 70 | return { 71 | "thread_id": ID, # string 72 | "post_id": 1, # integer 73 | "author": author, # string 74 | "body": body, # string 75 | "title": title, # string 76 | "tags": tags, # (possibly empty) list of strings 77 | "replies": list(), # (possibly empty) list of reply objects 78 | "reply_count": 0, # integer 79 | "edited": False, # boolean 80 | "lastmod": now, # floating point unix timestamp 81 | "created": now # floating point unix timestamp 82 | } 83 | 84 | 85 | def reply(ID, author, body): 86 | 87 | body = formatting.parse(body) 88 | now = time() 89 | 90 | return { 91 | "post_id": ID, # integer 92 | "author": author, # string 93 | "body": body, # string 94 | "edited": False, # boolean 95 | "lastmod": now, # floating point unix timestamp 96 | "created": now # floating point unix timestamp 97 | } 98 | -------------------------------------------------------------------------------- /prototype/src/server.py: -------------------------------------------------------------------------------- 1 | from socketserver import StreamRequestHandler, TCPServer 2 | from src import endpoints 3 | from src import schema 4 | from src import db 5 | import json 6 | 7 | 8 | class RequestHandler(StreamRequestHandler): 9 | """ 10 | Receieves and processes json input; dispatches input to the 11 | requested endpoint, or responds with error objects. 12 | """ 13 | 14 | 15 | def reply(self, obj): 16 | self.wfile.write(bytes(json.dumps(obj), "utf8")) 17 | 18 | 19 | def handle(self): 20 | try: 21 | request = json.loads(str(self.rfile.read(), "utf8")) 22 | endpoint = request.get("method") 23 | 24 | if endpoint not in endpoints.endpoints: 25 | return self.reply(schema.error(2, "Invalid endpoint")) 26 | 27 | # check to make sure all the arguments for endpoint are provided 28 | elif any([key not in request for key in endpoints.endpoints[endpoint]]): 29 | return self.reply(schema.error(3, "{} requires: {}".format( 30 | endpoint, ", ".join(endpoints.endpoints[endpoint])))) 31 | 32 | elif endpoint not in endpoints.authless: 33 | if not request.get("user"): 34 | return self.reply(schema.error(4, "No username provided.")) 35 | 36 | user = db.user_resolve(request["user"]) 37 | request["user"] = user 38 | 39 | if not user: 40 | return self.reply(schema.error(5, "User not registered")) 41 | 42 | elif endpoint != "check_auth" and not \ 43 | db.user_auth(user, request.get("auth_hash")): 44 | return self.reply(schema.error(6, "Authorization failed.")) 45 | 46 | # post_ids are always returned as integers, but for callers who 47 | # provide them as something else, try to convert them. 48 | if isinstance(request.get("post_id"), (float, str)): 49 | try: request["post_id"] = int(request["post_id"]) 50 | except Exception: 51 | return schema.error(3, "Non-numeric post_id") 52 | 53 | # exception handling is now passed to the endpoints; 54 | # anything unhandled beyond here is a code 1 55 | self.reply(eval("endpoints." + endpoint)(request)) 56 | 57 | except json.decoder.JSONDecodeError as E: 58 | return self.reply(schema.error(0, str(E))) 59 | 60 | except Exception as E: 61 | return self.reply(schema.error(1, str(E))) 62 | 63 | 64 | def run(host, port): 65 | server = TCPServer((host, port), RequestHandler) 66 | try: 67 | server.serve_forever() 68 | except KeyboardInterrupt: 69 | print("bye") 70 | server.server_close() 71 | -------------------------------------------------------------------------------- /readme.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bbj-dev/bbj/50ffb5ebc96221d947502c06a5f1fbda1a062219/readme.png -------------------------------------------------------------------------------- /schema.sql: -------------------------------------------------------------------------------- 1 | drop table if exists users; 2 | drop table if exists threads; 3 | drop table if exists messages; 4 | 5 | 6 | create table users ( 7 | user_id text, -- string (uuid1) 8 | user_name text, -- string 9 | auth_hash text, -- string (sha256 hash) 10 | quip text, -- string (possibly empty) 11 | bio text, -- string (possibly empty) 12 | color int, -- int (from 0 to 6) 13 | is_admin int, -- bool 14 | created real -- floating point unix timestamp (when this user registered) 15 | ); 16 | 17 | 18 | create table threads ( 19 | thread_id text, -- uuid string 20 | author text, -- string (uuid1, user.user_id) 21 | title text, -- string 22 | last_mod real, -- floating point unix timestamp (of last post or post edit) 23 | created real, -- floating point unix timestamp (when thread was made) 24 | reply_count int, -- integer (incremental, starting with 0) 25 | pinned int, -- boolean 26 | last_author text -- uuid string 27 | ); 28 | 29 | 30 | create table messages ( 31 | thread_id text, -- string (uuid1 of parent thread) 32 | post_id int, -- integer (incrementing from 1) 33 | author text, -- string (uuid1, user.user_id) 34 | created real, -- floating point unix timestamp (when reply was posted) 35 | edited int, -- bool 36 | body text, -- string 37 | send_raw int -- bool (1/true == never apply formatting) 38 | ); 39 | -------------------------------------------------------------------------------- /setup.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | DEPS=( 4 | cherrypy 5 | urwid 6 | jinja2 7 | ) 8 | 9 | case $1 in 10 | --help ) 11 | cat <>34 or >>0 for OP 79 | quotes = re.compile(">>([0-9]+)") 80 | bold = re.compile(r"(?"): 95 | return False 96 | _fp = line.find(" ") 97 | return not quotes.search(line[:_fp] if _fp != -1 else line) 98 | 99 | 100 | def parse_segments(text, sanitize_linequotes=True): 101 | """ 102 | Parse linequotes, quotes, and paragraphs into their appropriate 103 | representations. Paragraphs are represented as separate strings 104 | in the returned list, and quote-types are compiled to their 105 | [bracketed] representations. 106 | """ 107 | result = list() 108 | for paragraph in re.split("\n{2,}", text): 109 | pg = str() 110 | for line in paragraph.split("\n"): 111 | if linequote_p(line): 112 | if sanitize_linequotes: 113 | inner = line.replace("]", "\\]") 114 | else: 115 | inner = apply_directives(line) 116 | pg += "[linequote: %s]" % inner 117 | else: 118 | pg += apply_directives(line) 119 | pg += "\n" 120 | result.append(pg.rstrip()) 121 | return result 122 | 123 | 124 | def sequential_expressions(string): 125 | """ 126 | Takes a string, creates key/value pairs of formatting directives, and 127 | returns a list of lists who contain tuples. Each list of tuples represents 128 | a paragraph. Within each paragraph, [0] is either None or a markup 129 | directive, and [1] is the body of text to which it applies. This 130 | representation is very easy to handle for a client. It semi-supports 131 | nesting: eg, the expression [red: this [blue: is [green: mixed]]] will 132 | return [("red", "this "), ("blue", "is "), ("green", "mixed")], but this 133 | cannot effectively express an input like [bold: [red: bolded colors.]], in 134 | which case the innermost expression will take precedence. 135 | For the input: 136 | "[bold: [red: this] is some stuff [green: it cant handle]]" 137 | you get: 138 | [('red', 'this'), ('bold', ' is some stuff '), ('green', 'it cant handle')] 139 | 140 | """ 141 | # abandon all hope ye who enter here 142 | directives = colors + markup 143 | result = list() 144 | for paragraph in parse_segments(string): 145 | stack = [[None, str()]] 146 | skip_iters = 0 147 | nest = [None] 148 | escaped = False 149 | for index, char in enumerate(paragraph): 150 | if skip_iters: 151 | skip_iters -= 1 152 | continue 153 | 154 | if not escaped and char == "[": 155 | directive = paragraph[index+1:paragraph.find(": ", index+1)] 156 | open_p = directive in directives 157 | else: 158 | open_p = False 159 | clsd_p = not escaped and nest[-1] != None and char == "]" 160 | 161 | # dont splice other directives into linequotes: that is far 162 | # too confusing for the client to determine where to put line 163 | # breaks 164 | if open_p and nest[-1] != "linequote": 165 | stack.append([directive, str()]) 166 | nest.append(directive) 167 | skip_iters += len(directive) + 2 168 | 169 | elif clsd_p: 170 | nest.pop() 171 | stack.append([nest[-1], str()]) 172 | 173 | else: 174 | escaped = char == "\\" 175 | try: 176 | n = paragraph[index + 1] 177 | except IndexError: 178 | n = " " 179 | if not (escaped and n in "[]"): 180 | stack[-1][1] += char 181 | # filter out unused bodies, eg ["red", ""] 182 | result.append([(directive, body) for directive, body in stack if body]) 183 | return result 184 | 185 | 186 | def apply_formatting(msg_obj, formatter): 187 | """ 188 | Receives a messages object from a thread and returns it with 189 | all the message bodies passed through FORMATTER. Not all 190 | formatting functions have to return a string. Refer to the 191 | documentation for each formatter. 192 | """ 193 | for x, obj in enumerate(msg_obj): 194 | if not msg_obj[x].get("send_raw"): 195 | msg_obj[x]["body"] = formatter(obj["body"]) 196 | return msg_obj 197 | 198 | 199 | def raw(text): 200 | """ 201 | Just return the message in the same state that it was submitted. 202 | """ 203 | return text 204 | -------------------------------------------------------------------------------- /src/schema.py: -------------------------------------------------------------------------------- 1 | """ 2 | This module provides functions that return API objects in 3 | a clearly defined, consistent manner. Schmea representing 4 | data types mirror the column order used by the sqlite 5 | database. An sql object can be unpacked by using star 6 | expansion as an argument, such as thread(*sql_thread_obj) 7 | 8 | Each response has a base layout as follows: 9 | 10 | { 11 | "error": false, // boolean false or error object 12 | "data": null, // null or the requested data from endpoint. 13 | "usermap": {} // a potentially empty object mapping user_ids to their objects 14 | } 15 | 16 | If "error" is true, it looks like this: 17 | 18 | { 19 | "error": { 20 | "code": an integer from 0 to 5, 21 | "description": a string describing the error in detail. 22 | } 23 | "data": null // ALWAYS null if error is not false 24 | "usermap": {} // ALWAYS empty if error is not false 25 | } 26 | 27 | "data" can be anything. It could be a boolean, it could be a string, 28 | object, anything. The return value for an endpoint is described clearly 29 | in its documentation. However, no endpoint will EVER return null. If 30 | "data" is null, then "error" is filled. 31 | 32 | "usermap" is filled with objects whenever users are present in 33 | "data". its keys are all the user_ids that occur in the "data" 34 | object. Use this to get information about users, as follows: 35 | usermap[id]["user_name"] 36 | """ 37 | 38 | def base(): 39 | return { 40 | "error": False, 41 | "data": None, 42 | "usermap": dict() 43 | } 44 | 45 | 46 | def response(data, usermap={}): 47 | result = base() 48 | result["data"] = data 49 | result["usermap"].update(usermap) 50 | return result 51 | 52 | 53 | def error(code, description): 54 | result = base() 55 | result.update({ 56 | "error": { 57 | "description": description, # string 58 | "code": code # integer 59 | } 60 | }) 61 | return result 62 | 63 | 64 | def user_internal( 65 | user_id, # string (uuid1) 66 | user_name, # string 67 | auth_hash, # string (sha256 hash) 68 | quip, # string (possibly empty) 69 | bio, # string (possibly empty) 70 | color, # int from 0 to 6 71 | is_admin, # bool (supply as either False/True or 0/1) 72 | created): # floating point unix timestamp (when user registered) 73 | 74 | if not quip: 75 | quip = "" 76 | 77 | if not bio: 78 | bio = "" 79 | 80 | if not color: 81 | color = 0 82 | 83 | return { 84 | "user_id": user_id, 85 | "user_name": user_name, 86 | "auth_hash": auth_hash.lower(), 87 | "quip": quip, 88 | "bio": bio, 89 | "color": color, 90 | "is_admin": bool(is_admin), 91 | "created": created 92 | } 93 | 94 | 95 | def user_external( 96 | user_id, # string (uuid1) 97 | user_name, # string 98 | quip, # string (possibly empty) 99 | bio, # string (possibly empty) 100 | color, # int from 0 to 6 101 | admin, # bool (can be supplied as False/True or 0/1) 102 | created): # floating point unix timestamp (when user registered) 103 | 104 | if not quip: 105 | quip = "" 106 | 107 | if not bio: 108 | bio = "" 109 | 110 | if not color: 111 | color = 0 112 | 113 | return { 114 | "user_id": user_id, 115 | "user_name": user_name, 116 | "quip": quip, 117 | "bio": bio, 118 | "color": color, 119 | "is_admin": bool(admin), 120 | "created": created 121 | } 122 | 123 | 124 | def thread( 125 | thread_id, # uuid string 126 | author, # string (uuid1, user.user_id) 127 | title, # string 128 | last_mod, # floating point unix timestamp (of last post or post edit) 129 | created, # floating point unix timestamp (when thread was made) 130 | reply_count, # integer (incremental, starting with 0) 131 | pinned, # boolean 132 | last_author): # uuid string 133 | 134 | return { 135 | "thread_id": thread_id, 136 | "author": author, 137 | "title": title, 138 | "last_mod": last_mod, 139 | "created": created, 140 | "reply_count": reply_count, 141 | "pinned": bool(pinned), 142 | "last_author": last_author 143 | } 144 | 145 | 146 | def message( 147 | thread_id, # string (uuid1 of parent thread) 148 | post_id, # integer (incrementing from 1) 149 | author, # string (uuid1, user.user_id) 150 | created, # floating point unix timestamp (when reply was posted) 151 | edited, # bool 152 | body, # string 153 | send_raw): # bool 154 | 155 | return { 156 | "thread_id": thread_id, 157 | "post_id": post_id, 158 | "author": author, 159 | "created": created, 160 | "edited": bool(edited), 161 | "body": body, 162 | "send_raw": bool(send_raw) 163 | } 164 | -------------------------------------------------------------------------------- /src/utils.py: -------------------------------------------------------------------------------- 1 | from src import schema 2 | 3 | def ordered_keys(subscriptable_object, *keys): 4 | """ 5 | returns a tuple with the values for KEYS in the order KEYS are provided, 6 | from SUBSCRIPTABLE_OBJECT. Useful for working with dictionaries when 7 | parameter ordering is important. Used for sql transactions. 8 | """ 9 | return tuple([subscriptable_object[key] for key in keys]) 10 | 11 | 12 | def schema_values(scheme, obj): 13 | """ 14 | Returns the values in the database order for a given 15 | schema. Used for sql transactions. 16 | """ 17 | if scheme == "user": 18 | return ordered_keys(obj, 19 | "user_id", "user_name", "auth_hash", "quip", 20 | "bio", "color", "is_admin", "created") 21 | 22 | elif scheme == "thread": 23 | return ordered_keys(obj, 24 | "thread_id", "author", "title", 25 | "last_mod", "created", "reply_count", 26 | "pinned", "last_author") 27 | 28 | elif scheme == "message": 29 | return ordered_keys(obj, 30 | "thread_id", "post_id", "author", 31 | "created", "edited", "body", "send_raw") 32 | -------------------------------------------------------------------------------- /templates/account.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Log In: BBJ 5 | 6 | 7 | 8 | 9 | 10 | 21 | 22 |
23 | {% if authorized_user %} 24 |
25 |

Login Info

26 | Currently logged in as ~{{ authorized_user["user_name"] }} 27 |
28 | Log out. 29 |
30 |
31 |

Change Username

32 |
33 |
34 |

35 | 36 |
37 |
38 |
39 |

Change Password

40 |
41 |
42 |
43 |
44 |

45 | 46 |
47 |
48 |
49 |

Username Color

50 |
51 | 52 |
53 | 54 |
55 | 56 |
57 | 58 |
59 | 60 |
61 | 62 |
63 | 64 |
65 |
66 | 67 |
68 |
69 | 70 | 71 | {% else %} 72 |
73 |
74 | 75 |
76 | 77 |
78 | 79 |
80 |
81 | {% endif %} 82 | 83 |
84 |

Theme

85 |
86 | {% for theme in available_themes %} 87 | 88 | 89 |
90 | {% endfor %} 91 |
92 | 93 |
94 |
95 |
96 | 97 | -------------------------------------------------------------------------------- /templates/threadIndex.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Thread Index: BBJ 9 | 10 | 11 | 12 | 25 | 26 | {% if authorized_user %} 27 | Create New Thread 28 | 37 | {% endif %} 38 | 39 | {% if pinned_threads %} 40 |
41 |

Pinned Threads

42 | {% for thread in pinned_threads %} 43 |
44 | {{ thread["title"] }} 45 |
46 | by {{ usermap[thread["author"]]["user_name"] }} 47 |
48 | Created: {{ thread["created"] }} 49 |
50 | {{ thread["reply_count"] }} replies; active {{ thread["last_mod"] }} 51 |
52 | last active by {{ usermap[thread["last_author"]]["user_name"] }} 53 |
54 | {% endfor %} 55 |
56 | {% endif %} 57 | 58 | {% if bookmarked_threads %} 59 |
60 |

Bookmarked Threads

61 | {% for thread in bookmarked_threads %} 62 |
63 | {{ thread["title"] }} 64 |
65 | by {{ usermap[thread["author"]]["user_name"] }} 66 |
67 | Created: {{ thread["created"] }} 68 |
69 | {{ thread["reply_count"] }} replies; active {{ thread["last_mod"] }} 70 |
71 | last active by {{ usermap[thread["last_author"]]["user_name"] }} 72 |
Unbookmark this thread. 73 |
74 | {% endfor %} 75 |
76 | {% endif %} 77 | 78 | {% if threads %} 79 |
80 |

Threads

81 | {% for thread in threads %} 82 |
83 | {{ thread["title"] }} 84 |
85 | by {{ usermap[thread["author"]]["user_name"] }} 86 |
87 | Created: {{ thread["created"] }} 88 |
89 | {{ thread["reply_count"] }} replies; active {{ thread["last_mod"] }} 90 |
91 | last active by {{ usermap[thread["last_author"]]["user_name"] }} 92 |
Bookmark this thread. 93 |
94 | {% endfor %} 95 |
96 | {% endif %} 97 | 98 | {% if not threads %} 99 |

There are no threads!

100 | {% endif %} 101 | 102 | 103 | 104 | 105 | replies -------------------------------------------------------------------------------- /templates/threadLoad.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | {{ thread["title"] }}: BBJ 9 | 10 | 13 | 14 | 27 | 28 |
29 |

{{ usermap[thread["author"]]["user_name"] }}: {{ thread["title"] }}

30 | {% if authorized_user %} 31 | Reply to Thread 32 | 41 | {% endif %} 42 | 43 | {% for message in thread["messages"] %} 44 |
45 |
46 | >{{ message["post_id"] }} 47 | {{ usermap[message["author"]]["user_name"] }} 48 | @ {{ message["created"] }} 49 |
50 |
51 |
{{ message["body"] }}
52 |
53 |
54 | {% if authorized_user %} 55 | Direct reply. 56 |
57 | {% endif %} 58 |
59 |
60 | {% endfor %} 61 |
62 | 63 | 64 | 65 | -------------------------------------------------------------------------------- /todo.org: -------------------------------------------------------------------------------- 1 | * TODO add notification system server-side and to the client 2 | it should allow users on the tilde to subscribe to a mailing list for 3 | selected threads, and the api should allow for event listings per thread 4 | when given a certain time (kind of like `message_feed`) 5 | * TODO Author-only "announcement" threads 6 | * TODO HTML output from the formatter 7 | * TODO integrate thread pins to urwid client 8 | * TODO add search highlights to in-thread searching 9 | --------------------------------------------------------------------------------