From 797ace982f7ae2952654d078e61bbe0c9e7bb9a3 Mon Sep 17 00:00:00 2001 From: rr- Date: Sat, 19 Mar 2016 21:37:04 +0100 Subject: [PATCH] start Done so far Basic backend skeleton - technology choices - database migration outline - basic self hosting facade - basic REST outline - proof of concept for auth and privileges Basic frontend skeleton - technology choices - pretty robust frontend compilation - top navigation - proof of concept for registration form --- .gitignore | 2 + .jscsrc | 5 ++ INSTALL.md | 87 ++++++++++++++++++ README.md | 23 +++++ alembic.ini | 39 ++++++++ bin/szurubooru | 30 +++++++ config.ini.dist | 90 +++++++++++++++++++ package.json | 23 +++++ public/.gitignore | 2 + requirements.txt | 5 ++ static/css/forms.css | 79 ++++++++++++++++ static/css/main.css | 74 +++++++++++++++ static/css/users.css | 16 ++++ static/html/index.htm | 71 +++++++++++++++ static/js/.gitignore | 1 + static/js/config.js | 4 + static/js/controllers/auth_controller.js | 34 +++++++ static/js/controllers/comments_controller.js | 13 +++ static/js/controllers/help_controller.js | 13 +++ static/js/controllers/history_controller.js | 13 +++ static/js/controllers/home_controller.js | 17 ++++ static/js/controllers/posts_controller.js | 25 ++++++ static/js/controllers/tags_controller.js | 13 +++ .../controllers/top_navigation_controller.js | 69 ++++++++++++++ static/js/controllers/users_controller.js | 38 ++++++++ static/js/main.js | 70 +++++++++++++++ static/js/views/base_view.js | 37 ++++++++ static/js/views/registration_view.js | 35 ++++++++ static/js/views/top_navigation_view.js | 30 +++++++ static/prepare_config.js | 33 +++++++ szurubooru/__init__.py | 0 szurubooru/app.py | 40 +++++++++ szurubooru/config.py | 11 +++ szurubooru/middleware/__init__.py | 3 + szurubooru/middleware/authenticator.py | 32 +++++++ szurubooru/middleware/json_translator.py | 27 ++++++ szurubooru/middleware/require_json.py | 7 ++ szurubooru/migrations/env.py | 74 +++++++++++++++ szurubooru/migrations/script.py.mako | 21 +++++ .../e5c1216a8503_create_user_table.py | 31 +++++++ szurubooru/model/__init__.py | 2 + szurubooru/model/base.py | 2 + szurubooru/model/user.py | 18 ++++ szurubooru/rest/__init__.py | 0 szurubooru/rest/users.py | 23 +++++ szurubooru/services/__init__.py | 2 + szurubooru/services/auth_service.py | 39 ++++++++ szurubooru/services/user_service.py | 8 ++ 48 files changed, 1331 insertions(+) create mode 100644 .gitignore create mode 100644 .jscsrc create mode 100644 INSTALL.md create mode 100644 README.md create mode 100644 alembic.ini create mode 100755 bin/szurubooru create mode 100644 config.ini.dist create mode 100644 package.json create mode 100644 public/.gitignore create mode 100644 requirements.txt create mode 100644 static/css/forms.css create mode 100644 static/css/main.css create mode 100644 static/css/users.css create mode 100644 static/html/index.htm create mode 100644 static/js/.gitignore create mode 100644 static/js/config.js create mode 100644 static/js/controllers/auth_controller.js create mode 100644 static/js/controllers/comments_controller.js create mode 100644 static/js/controllers/help_controller.js create mode 100644 static/js/controllers/history_controller.js create mode 100644 static/js/controllers/home_controller.js create mode 100644 static/js/controllers/posts_controller.js create mode 100644 static/js/controllers/tags_controller.js create mode 100644 static/js/controllers/top_navigation_controller.js create mode 100644 static/js/controllers/users_controller.js create mode 100644 static/js/main.js create mode 100644 static/js/views/base_view.js create mode 100644 static/js/views/registration_view.js create mode 100644 static/js/views/top_navigation_view.js create mode 100644 static/prepare_config.js create mode 100644 szurubooru/__init__.py create mode 100644 szurubooru/app.py create mode 100644 szurubooru/config.py create mode 100644 szurubooru/middleware/__init__.py create mode 100644 szurubooru/middleware/authenticator.py create mode 100644 szurubooru/middleware/json_translator.py create mode 100644 szurubooru/middleware/require_json.py create mode 100644 szurubooru/migrations/env.py create mode 100644 szurubooru/migrations/script.py.mako create mode 100644 szurubooru/migrations/versions/e5c1216a8503_create_user_table.py create mode 100644 szurubooru/model/__init__.py create mode 100644 szurubooru/model/base.py create mode 100644 szurubooru/model/user.py create mode 100644 szurubooru/rest/__init__.py create mode 100644 szurubooru/rest/users.py create mode 100644 szurubooru/services/__init__.py create mode 100644 szurubooru/services/auth_service.py create mode 100644 szurubooru/services/user_service.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..53668de2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +config.ini +node_modules diff --git a/.jscsrc b/.jscsrc new file mode 100644 index 00000000..c12b28dc --- /dev/null +++ b/.jscsrc @@ -0,0 +1,5 @@ +{ + "preset": "google", + "fileExtensions": [".js", "jscs"], + "validateIndentation": 4, +} diff --git a/INSTALL.md b/INSTALL.md new file mode 100644 index 00000000..4b6ead31 --- /dev/null +++ b/INSTALL.md @@ -0,0 +1,87 @@ +This guide assumes Arch Linux. Although exact instructions for other +distributions are different, the steps stay roughly the same. + +#### Installing hard dependencies + +```bash +user@host:~$ sudo pacman -S postgres +user@host:~$ sudo pacman -S python +user@host:~$ sudo pacman -S python-pip +user@host:~$ sudo pacman -S npm +user@host:~$ python --version +Python 3.5.1 +``` + +#### Setting up a database + +First, basic `postgres` configuration: + +```bash +user@host:~$ sudo -i -u postgres initdb --locale en_US.UTF-8 -E UTF8 -D /var/lib/postgres/data +user@host:~$ sudo systemctl start postgresql +user@host:~$ sudo systemctl enable postgresql +``` + +Then creating a database: + +```bash +user@host:~$ sudo -i -u postgres createuser --interactive +Enter name of role to add: szuru +Shall the new role be a superuser? (y/n) n +Shall the new role be allowed to create databases? (y/n) n +Shall the new role be allowed to create more new roles? (y/n) n +user@host:~$ sudo -i -u postgres createdb szuru +user@host:~$ sudo -i -u postgres psql -c "ALTER USER szuru PASSWORD 'dog';" +``` + +#### Installing `szurubooru's` dependencies + +```bash +user@host:path/to/szurubooru$ sudo pip install -r requirements.txt # installs backend deps +user@host:path/to/szurubooru$ npm install # installs frontend deps +``` + +#### Preparing `szurubooru` for first use + +```bash +user@host:path/to/szurubooru$ npm run build # compiles frontend +user@host:path/to/szurubooru$ alembic update head # runs all DB upgrades +``` + +Time to configure things: + +```bash +user@host:path/to/szurubooru$ cp config.ini.dist config.ini +user@host:path/to/szurubooru$ vim config.ini +``` + +Pay extra attention to the `[database]` and `[smtp]` sections, and API URL in +`[basic]`. + +### Upgrading the database + + [user@host:path/to/szurubooru] alembic upgrade HEAD + +Alembic should have been installed during installation of `szurubooru`'s +dependencies. + +### Wiring `szurubooru` to the web server + +`szurubooru` is divided into two parts: public static files, and the API. It +tries not to impose any networking configurations on the user, so it is the +user's responsibility to wire these to their web server. + +Below are described the methods to integrate the API into a web server: + +1. Run API locally with `waitress`, and bind it with a reverse proxy. In this + approach, the user needs to install `waitress` with `pip install waitress` + and then start `szurubooru` with `./bin/szurubooru` (see `--help` for + details). Then the user needs to add a virtual host that delegates the API + requests to the local API server, and the browser requests to the `public/` + directory. +2. Alternatively, Apache users can use `mod_wsgi`. +3. Alternatively, users can use other WSGI frontends such as `gunicorn` or + `uwsgi`, but they'll need to write wrapper scripts themselves. + +Note that the API URL in the virtual host configuration needs to be the same as +the one in the `config.ini`, so that client knows how to access the backend! diff --git a/README.md b/README.md new file mode 100644 index 00000000..699d59d4 --- /dev/null +++ b/README.md @@ -0,0 +1,23 @@ +This is rewrite of `szurubooru` 0.9.x that intends to + +- Improve user experience within frontend. No more vertical user list. Better + upload form, larger thumbnails, make top navigation stay out of user way. + Maybe other goodies! +- Finally define sane REST API (with no bullshit such as SQL queries, request + timings or exception stack traces this time) +- Simplify registration - user registers, and they're able to post. No + activation e-mails, no nothing (email's going to be used **ONLY** for + password reminders, yes, *not even* for confirmation). Note that you will + have control over permissions, user ranks and the default user rank, so you + might be able to setup a system where user needs to be approved by mod to + join the community. +- Maybe simplify permission system +- Ditch PHP in favor of something more serious (python 3.5) +- Ditch in-house JS monstrosities in favor of something more serious (I've got + EmberJS on my radar) +- Replace dependencies such as composer, npm, grunt, and all that crap with + just python, and a few pip packages +- Simplify hosting: offer simple self hosted app combinable with reverse proxies +- Replace MySQL (/ MariaDB) with Postgres +- Less god damn code! 24KSLOC? For a thing this simple? The goal is to fit + within 15KSLOC. Let's see if I can accomplish this. diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 00000000..a49947cc --- /dev/null +++ b/alembic.ini @@ -0,0 +1,39 @@ +[alembic] +script_location = szurubooru/migrations + +# overriden from within config.ini +sqlalchemy.url = + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/bin/szurubooru b/bin/szurubooru new file mode 100755 index 00000000..8b369ffe --- /dev/null +++ b/bin/szurubooru @@ -0,0 +1,30 @@ +#!/bin/python3 + +''' +Script facade for direct execution with waitress WSGI server. +Note that szurubooru can be also run using ``python -m szurubooru``, when in +the repository's root directory. +''' + +import argparse +import os.path +import sys +import waitress + +sys.path.append(os.path.join(os.path.dirname(os.path.realpath(__file__)), os.path.pardir)) +from szurubooru.app import create_app + +def main(): + parser = argparse.ArgumentParser('Starts szurubooru using waitress.') + parser.add_argument( + '-p', '--port', + type=int, help='port to listen on', default=6666) + parser.add_argument( + '--host', help='IP to listen on', default='0.0.0.0') + args = parser.parse_args() + + app = create_app() + waitress.serve(app, host=args.host, port=args.port) + +if __name__ == '__main__': + main() diff --git a/config.ini.dist b/config.ini.dist new file mode 100644 index 00000000..8e557c64 --- /dev/null +++ b/config.ini.dist @@ -0,0 +1,90 @@ +# rather than editing this file, it is strongly suggested to create config.ini +# and override only what you need. + +[basic] +name = szurubooru +debug = 0 +secret = change +api_url = http://api.example.com/ # see INSTALL.md + +[database] +schema = postgres +host = localhost +port = 5432 +user = szuru +pass = dog +name = szuru + +[smtp] +# used to send password reminders +host = localhost +port = 25 +user = bot +pass = groovy123 + +[service] +# note: anonymous, admin and nobody are always reserved +user_ranks = regular_user, power_user, mod +default_user_rank = regular_user +users_per_page = 20 +posts_per_page = 40 +max_comment_length = 5000 +tag_categories = meta, artist, character, copyright, other unique + +# don't change these regexes, unless you want to annoy people. but if you do +# customize them, make sure to update the instructions in the registration form +# template as well. +password_regex = ^.{5,}$ +user_name_regex = ^[a-zA-Z0-9_-]{1,32}$ + +[privileges] +users:create = anonymous +users:list = regular_user +users:view = regular_user +users:edit:any:name = mod +users:edit:any:pass = mod +users:edit:any:email = mod +users:edit:any:avatar = mod +# note: promoting people to higher rank than one's own is always impossible +users:edit:any:rank = mod +users:edit:self:name = regular_user +users:edit:self:pass = regular_user +users:edit:self:email = regular_user +users:edit:self:avatar = regular_user +users:edit:self:rank = mod +users:delete:any = admin +users:delete:self = restricted_user + +posts:create:anonymous = regular_user +posts:create:identified = regular_user +posts:list = anonymous +posts:view = anonymous +posts:edit:content = power_user +posts:edit:flags = regular_user +posts:edit:notes = regular_user +posts:edit:relations = regular_user +posts:edit:safety = power_user +posts:edit:source = regular_user +posts:edit:tags = regular_user +posts:edit:thumbnail = power_user +posts:feature = mod +posts:delete = mod + +tags:create = regular_user +tags:edit:name = power_user +tags:edit:category = power_user +tags:edit:implications = power_user +tags:edit:suggestions = power_user +tags:list = regular_user +tags:masstag = power_user +tags:merge = mod +tags:delete = mod + +comments:create = regular_user +comments:delete:any = mod +comments:delete:own = regular_user +comments:edit:any = mod +comments:edit:own = regular_user +comments:list = regular_user + +history:view = power_user diff --git a/package.json b/package.json new file mode 100644 index 00000000..bdea6e8b --- /dev/null +++ b/package.json @@ -0,0 +1,23 @@ +{ + "name": "szurubooru", + "private": true, + "scripts": { + "browserify": "browserify static/js/*.js -o public/bundle.js", + "build:html": "cat static/html/index.htm >public/index.htm", + "build:css": "cat static/css/*.css >public/bundle.css && cssmin public/bundle.css >public/bundle.min.css", + "build:js": "node static/prepare_config.js && npm run browserify -o public/bundle.js && uglifyjs public/bundle.min.js", + "build": "npm run build:html && npm run build:js && npm run build:css", + "watch": "watch 'npm run build' static --wait=0 --ignoreDotFiles" + }, + "dependencies": { + "browserify": "^13.0.0", + "camelcase-keys": "^2.1.0", + "cssmin": "^0.4.3", + "handlebars": "^4.0.5", + "ini": "^1.3.4", + "merge": "^1.2.0", + "page": "^1.7.1", + "uglify-js": "git://github.com/mishoo/UglifyJS2.git#harmony", + "watch": "latest" + } +} diff --git a/public/.gitignore b/public/.gitignore new file mode 100644 index 00000000..67c04e47 --- /dev/null +++ b/public/.gitignore @@ -0,0 +1,2 @@ +*.* +!.gitignore diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 00000000..0122fa69 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +alembic>=0.8.5 +configobj>=5.0.6 +falcon>=0.3.0 +psycopg2>=2.6.1 +SQLAlchemy>=1.0.12 diff --git a/static/css/forms.css b/static/css/forms.css new file mode 100644 index 00000000..f4865492 --- /dev/null +++ b/static/css/forms.css @@ -0,0 +1,79 @@ +form fieldset { + margin: 0; + padding: 0; + border: 0; +} + +form fieldset legend { + display: block; + text-align: center; + width: 100%; + font-size: 17pt; +} + +form ul { + list-style-type: none; + margin: 0; + padding: 0; +} + +form.tabular ul { + display: table; + border-spacing: 0.5em; + margin: 0.5em -0.5em; + width: 100%; +} +form.tabular ul li { + display: table-row; +} +form.tabular ul li label { + display: table-cell; + width: 33%; + padding: 0; +} +form.tabular .buttons { + margin-left: 33%; +} + +form:not(.tabular) ul li label { + display: block; + padding: 0.5em 0; +} + +textarea, +input[type=text], +input[type=email], +input[type=password] { + font-size: 100%; + font-family: 'Inconsolata', monospace; + padding: 0.3em; + border: 1px solid #EEE; + background: #FAFAFA; + text-overflow: ellipsis; + width: 100%; + box-sizing: border-box; + box-shadow: none; /* :-moz-submit-invalid on FF */ +} + +form.show-validation fieldset.input input:invalid { + outline: none; + border: 1px solid #FCC; + background: #FFF5F5; +} +form.show-validation fieldset.input input:valid { + outline: none; + border: 1px solid #D3E3D3; + background: #F5FFF5; +} + +button, +input[type=button], +input[type=submit] { + cursor: pointer; + font-size: 100%; + line-height: 100%; + padding: 0.3em 0.7em; + border: 1px solid #24AADD; + background: #24AADD; + color: white; +} diff --git a/static/css/main.css b/static/css/main.css new file mode 100644 index 00000000..1a338e05 --- /dev/null +++ b/static/css/main.css @@ -0,0 +1,74 @@ +body { + margin: 0; + color: #111; + font-family: 'Droid Sans' !important; + font-size: 12pt; +} + +#content-holder { + margin-top: 1em; + text-align: center; +} +#content-holder>.center { + text-align: left; + display: inline-block; + margin: 0 auto; +} +hr { + border: 0; + border-top: 1px solid #ddd; + margin: 1em 0; + padding: 0; +} + +nav ul { + list-style-type: none; + padding: 0; + margin: 0; + display: inline-block; +} +nav ul li { + display: inline-block; + padding: 0; + margin: 0; +} +nav ul li a { + display: inline-block; +} +nav ul li img { + margin: 0; + vertical-align: top; /* fix ghost margin under the image */ +} + +nav.text-nav { + margin: 1em 0; +} +nav.text-nav ul li a { + padding: 0.3em 1.2em; + text-decoration: none; +} +nav.text-nav ul li:not(.active) a { + color: #888; +} +nav.text-nav ul li.active a { + background: #24AADD; + color: white; +} + +#top-nav { + background: #F5F5F5; + margin: 0; +} +#top-nav ul { + display: block; + text-align: right; +} +#top-nav ul li { + float: left; +} +#top-nav ul li[data-name=register], +#top-nav ul li[data-name=login], +#top-nav ul li[data-name=logout], +#top-nav ul li[data-name=help] { + float: none; +} diff --git a/static/css/users.css b/static/css/users.css new file mode 100644 index 00000000..cf2e10da --- /dev/null +++ b/static/css/users.css @@ -0,0 +1,16 @@ +#user-registration form { + display: block; + width: 20em; + float: left; +} +#user-registration div.info { + float: left; + margin-left: 3em; + border-radius: 0.2em; + width: 20em; +} +#user-registration .info p:first-child, +#user-registration form li:first-child label { + padding-top: 0; + margin-top: 0; +} diff --git a/static/html/index.htm b/static/html/index.htm new file mode 100644 index 00000000..543d50c0 --- /dev/null +++ b/static/html/index.htm @@ -0,0 +1,71 @@ + + + + szurubooru + + + + + + + + + + + +
+
+ + + diff --git a/static/js/.gitignore b/static/js/.gitignore new file mode 100644 index 00000000..16e3bbec --- /dev/null +++ b/static/js/.gitignore @@ -0,0 +1 @@ +.config.autogen.json diff --git a/static/js/config.js b/static/js/config.js new file mode 100644 index 00000000..4524be6e --- /dev/null +++ b/static/js/config.js @@ -0,0 +1,4 @@ +'use strict'; + +const config = require('./.config.autogen.json'); +module.exports = config; diff --git a/static/js/controllers/auth_controller.js b/static/js/controllers/auth_controller.js new file mode 100644 index 00000000..e23d7535 --- /dev/null +++ b/static/js/controllers/auth_controller.js @@ -0,0 +1,34 @@ +'use strict'; + +class AuthController { + constructor(topNavigationController) { + this.topNavigationController = topNavigationController; + this.currentUser = null; + } + + isLoggedIn() { + return this.currentUser !== null; + } + + hasPrivilege() { + return true; + } + + login(user) { + this.currentUser = user; + } + + logout(user) { + this.currentUser = null; + } + + loginRoute() { + this.topNavigationController.activate('login'); + } + + logoutRoute() { + this.topNavigationController.activate('logout'); + } +} + +module.exports = AuthController; diff --git a/static/js/controllers/comments_controller.js b/static/js/controllers/comments_controller.js new file mode 100644 index 00000000..b6ff02cd --- /dev/null +++ b/static/js/controllers/comments_controller.js @@ -0,0 +1,13 @@ +'use strict'; + +class CommentsController { + constructor(topNavigationController) { + this.topNavigationController = topNavigationController; + } + + listCommentsRoute() { + this.topNavigationController.activate('comments'); + } +} + +module.exports = CommentsController; diff --git a/static/js/controllers/help_controller.js b/static/js/controllers/help_controller.js new file mode 100644 index 00000000..78eeab31 --- /dev/null +++ b/static/js/controllers/help_controller.js @@ -0,0 +1,13 @@ +'use strict'; + +class HelpController { + constructor(topNavigationController) { + this.topNavigationController = topNavigationController; + } + + showHelpRoute() { + this.topNavigationController.activate('help'); + } +} + +module.exports = HelpController; diff --git a/static/js/controllers/history_controller.js b/static/js/controllers/history_controller.js new file mode 100644 index 00000000..ff0b2644 --- /dev/null +++ b/static/js/controllers/history_controller.js @@ -0,0 +1,13 @@ +'use strict'; + +class HistoryController { + constructor(topNavigationController) { + this.topNavigationController = topNavigationController; + } + + listHistoryRoute() { + this.topNavigationController.activate(''); + } +} + +module.exports = HistoryController; diff --git a/static/js/controllers/home_controller.js b/static/js/controllers/home_controller.js new file mode 100644 index 00000000..1b913fad --- /dev/null +++ b/static/js/controllers/home_controller.js @@ -0,0 +1,17 @@ +'use strict'; + +class HomeController { + constructor(topNavigationController) { + this.topNavigationController = topNavigationController; + } + + indexRoute() { + this.topNavigationController.activate('home'); + } + + notFoundRoute() { + this.topNavigationController.activate(''); + } +} + +module.exports = HomeController; diff --git a/static/js/controllers/posts_controller.js b/static/js/controllers/posts_controller.js new file mode 100644 index 00000000..bbd540ce --- /dev/null +++ b/static/js/controllers/posts_controller.js @@ -0,0 +1,25 @@ +'use strict'; + +class PostsController { + constructor(topNavigationController) { + this.topNavigationController = topNavigationController; + } + + uploadPostsRoute() { + this.topNavigationController.activate('upload'); + } + + listPostsRoute() { + this.topNavigationController.activate('posts'); + } + + showPostRoute(id) { + this.topNavigationController.activate('posts'); + } + + editPostRoute(id) { + this.topNavigationController.activate('posts'); + } +} + +module.exports = PostsController; diff --git a/static/js/controllers/tags_controller.js b/static/js/controllers/tags_controller.js new file mode 100644 index 00000000..3124d40c --- /dev/null +++ b/static/js/controllers/tags_controller.js @@ -0,0 +1,13 @@ +'use strict'; + +class TagsController { + constructor(topNavigationController) { + this.topNavigationController = topNavigationController; + } + + listTagsRoute() { + this.topNavigationController.activate('tags'); + } +} + +module.exports = TagsController; diff --git a/static/js/controllers/top_navigation_controller.js b/static/js/controllers/top_navigation_controller.js new file mode 100644 index 00000000..2bd098dd --- /dev/null +++ b/static/js/controllers/top_navigation_controller.js @@ -0,0 +1,69 @@ +'use strict'; + +class NavigationItem { + constructor(name, url) { + this.name = name; + this.url = url; + this.available = true; + } +} + +class TopNavigationController { + constructor(topNavigationView, authController) { + this.authController = authController; + this.topNavigationView = topNavigationView; + + this.items = { + 'home': new NavigationItem('Home', '/'), + 'posts': new NavigationItem('Posts', '/posts'), + 'upload': new NavigationItem('Upload', '/upload'), + 'comments': new NavigationItem('Comments', '/comments'), + 'tags': new NavigationItem('Tags', '/tags'), + 'users': new NavigationItem('Users', '/users'), + 'account': new NavigationItem('Account', '/user/{me}'), + 'register': new NavigationItem('Register', '/register'), + 'login': new NavigationItem('Login', '/login'), + 'logout': new NavigationItem('Logout', '/logout'), + 'help': new NavigationItem('Help', '/help'), + }; + + this.updateVisibility(); + + this.topNavigationView.render(this.items); + } + + updateVisibility() { + const b = Object.keys(this.items); + for (let key of b) { + this.items[key].available = true; + } + if (!this.authController.hasPrivilege('posts:list')) { + this.items.posts.available = false; + } + if (!this.authController.hasPrivilege('posts:upload')) { + this.items.upload.available = false; + } + if (!this.authController.hasPrivilege('comments:list')) { + this.items.comments.available = false; + } + if (!this.authController.hasPrivilege('tags:list')) { + this.items.tags.available = false; + } + if (!this.authController.hasPrivilege('users:list')) { + this.items.users.available = false; + } + if (this.authController.isLoggedIn()) { + this.items.register.available = false; + this.items.login.available = false; + } else { + this.items.account.available = false; + this.items.logout.available = false; + } + } + + activate(itemName) { + this.topNavigationView.activate(itemName); + } +} + +module.exports = TopNavigationController; diff --git a/static/js/controllers/users_controller.js b/static/js/controllers/users_controller.js new file mode 100644 index 00000000..a5e800de --- /dev/null +++ b/static/js/controllers/users_controller.js @@ -0,0 +1,38 @@ +'use strict'; + +class UsersController { + constructor(topNavigationController, authController, registrationView) { + this.topNavigationController = topNavigationController; + this.authController = authController; + this.registrationView = registrationView; + } + + listUsersRoute() { + this.topNavigationController.activate('users'); + } + + createUserRoute() { + const self = this; + this.topNavigationController.activate('register'); + this.registrationView.render({ + onRegistered: (user) => { + alert(user); + self.authController.login(user); + }}); + } + + showUserRoute(user) { + if (this.authController.isLoggedIn() && + user == this.authController.getCurrentUser().name) { + this.topNavigationController.activate('account'); + } else { + this.topNavigationController.activate('users'); + } + } + + editUserRoute(user) { + this.topNavigationController.activate('users'); + } +} + +module.exports = UsersController; diff --git a/static/js/main.js b/static/js/main.js new file mode 100644 index 00000000..bd9216e2 --- /dev/null +++ b/static/js/main.js @@ -0,0 +1,70 @@ +'use strict'; + +// ------------------ +// - import objects - +// ------------------ +const page = require('page'); +const handlebars = require('handlebars'); + +const RegistrationView = require('./views/registration_view.js'); +const TopNavigationView = require('./views/top_navigation_view.js'); +const TopNavigationController + = require('./controllers/top_navigation_controller.js'); + +const HomeController = require('./controllers/home_controller.js'); +const PostsController = require('./controllers/posts_controller.js'); +const UsersController = require('./controllers/users_controller.js'); +const HelpController = require('./controllers/help_controller.js'); +const AuthController = require('./controllers/auth_controller.js'); +const CommentsController = require('./controllers/comments_controller.js'); +const HistoryController = require('./controllers/history_controller.js'); +const TagsController = require('./controllers/tags_controller.js'); + +// ------------------- +// - resolve objects - +// ------------------- +const topNavigationView = new TopNavigationView(handlebars); +const registrationView = new RegistrationView(handlebars); + +const authController = new AuthController(null); +const topNavigationController + = new TopNavigationController(topNavigationView, authController); +// break cyclic dependency topNavigationView<->authController +authController.topNavigationController = topNavigationController; + +const homeController = new HomeController(topNavigationController); +const postsController = new PostsController(topNavigationController); +const usersController = new UsersController( + topNavigationController, + authController, + registrationView); +const helpController = new HelpController(topNavigationController); +const commentsController = new CommentsController(topNavigationController); +const historyController = new HistoryController(topNavigationController); +const tagsController = new TagsController(topNavigationController); + +// ----------------- +// - setup routing - +// ----------------- +page('/', () => { homeController.indexRoute(); }); + +page('/upload', () => { postsController.uploadPostsRoute(); }); +page('/posts', () => { postsController.listPostsRoute(); }); +page('/post/:id', (id) => { postsController.showPostRoute(id); }); +page('/post/:id/edit', (id) => { postsController.editPostRoute(id); }); + +page('/register', () => { usersController.createUserRoute(); }); +page('/users', () => { usersController.listUsersRoute(); }); +page('/user/:user', (user) => { usersController.showUserRoute(user); }); +page('/user/:user/edit', (user) => { usersController.editUserRoute(user); }); + +page('/history', () => { historyController.showHistoryRoute(); }); +page('/tags', () => { tagsController.listTagsRoute(); }); +page('/comments', () => { commentsController.listCommentsRoute(); }); +page('/login', () => { authController.loginRoute(); }); +page('/logout', () => { authController.logoutRoute(); }); +page('/help', () => { helpController.showHelpRoute(); }); + +page('*', () => { homeController.notFoundRoute(); }); + +page(); diff --git a/static/js/views/base_view.js b/static/js/views/base_view.js new file mode 100644 index 00000000..06baabd7 --- /dev/null +++ b/static/js/views/base_view.js @@ -0,0 +1,37 @@ +'use strict'; + +// fix iterating over NodeList in Chrome and Opera +NodeList.prototype[Symbol.iterator] = Array.prototype[Symbol.iterator]; + +class BaseView { + constructor(handlebars) { + this.handlebars = handlebars; + this.contentHolder = document.getElementById('content-holder'); + } + + getTemplate(templatePath) { + const templateElement = document.getElementById(templatePath); + if (!templateElement) { + console.log('Missing template: ' + templatePath); + return null; + } + const templateText = templateElement.innerHTML; + return this.handlebars.compile(templateText); + } + + decorateValidator(form) { + // postpone showing form fields validity until user actually tries + // to submit it (seeing red/green form w/o doing anything breaks POLA) + const submitButton + = document.querySelector('#content-holder .buttons input'); + submitButton.addEventListener('click', (e) => { + form.classList.add('show-validation'); + }); + } + + showView(html) { + this.contentHolder.innerHTML = html; + } +} + +module.exports = BaseView; diff --git a/static/js/views/registration_view.js b/static/js/views/registration_view.js new file mode 100644 index 00000000..2f1a31b2 --- /dev/null +++ b/static/js/views/registration_view.js @@ -0,0 +1,35 @@ +'use strict'; + +const config = require('../config.js'); +const BaseView = require('./base_view.js'); + +class RegistrationView extends BaseView { + constructor(handlebars) { + super(handlebars); + this.template = this.getTemplate('user-registration-template'); + } + + render(settings) { + this.showView(this.template()); + const form = document.querySelector('#content-holder form'); + this.decorateValidator(form); + + const userNameField = document.getElementById('user-name'); + const passwordField = document.getElementById('user-password'); + const emailField = document.getElementById('user-email'); + userNameField.setAttribute('pattern', config.service.userNameRegex); + passwordField.setAttribute('pattern', config.service.passwordRegex); + + form.addEventListener('submit', (e) => { + e.preventDefault(); + const user = { + name: userNameField.value, + password: passwordField.value, + email: emailField.value, + }; + settings.onRegistered(user); + }); + } +} + +module.exports = RegistrationView; diff --git a/static/js/views/top_navigation_view.js b/static/js/views/top_navigation_view.js new file mode 100644 index 00000000..e7a6d0ed --- /dev/null +++ b/static/js/views/top_navigation_view.js @@ -0,0 +1,30 @@ +'use strict'; + +const BaseView = require('./base_view.js'); + +class TopNavigationView extends BaseView { + constructor(handlebars) { + super(handlebars); + this.template = this.getTemplate('top-nav-template'); + this.navHolder = document.getElementById('top-nav-holder'); + } + + render(items) { + this.navHolder.innerHTML = this.template({items: items}); + } + + activate(itemName) { + const allItemsSelector = '#top-nav-holder [data-name]'; + const currentItemSelector = + '#top-nav-holder [data-name="' + itemName + '"]'; + for (let item of document.querySelectorAll(allItemsSelector)) { + item.className = ''; + } + const currentItem = document.querySelectorAll(currentItemSelector); + if (currentItem.length > 0) { + currentItem[0].className = 'active'; + } + } +} + +module.exports = TopNavigationView; diff --git a/static/prepare_config.js b/static/prepare_config.js new file mode 100644 index 00000000..356e9f94 --- /dev/null +++ b/static/prepare_config.js @@ -0,0 +1,33 @@ +'use strict'; + +const fs = require('fs'); +const ini = require('ini'); +const merge = require('merge'); +const camelcaseKeys = require('camelcase-keys'); + +function parseIniFile(path) { + let result = ini.parse(fs.readFileSync(path, 'utf-8') + .replace(/#.+$/gm, '') + .replace(/\s+$/gm, '')); + Object.keys(result).map((key, _) => { + result[key] = camelcaseKeys(result[key]); + }); + return result; +} + +let config = parseIniFile('./config.ini.dist'); + +try { + const localConfig = parseIniFile('./config.ini'); + config = merge.recursive(config, localConfig); +} catch (e) { + console.warn('Local config does not exist, ignoring'); +} + +delete config.basic.secret; +delete config.smtp; +delete config.database; +config.service.userRanks = config.service.userRanks.split(/,\s*/); +config.service.tagCategories = config.service.tagCategories.split(/,\s*/); + +fs.writeFileSync('./static/js/.config.autogen.json', JSON.stringify(config)); diff --git a/szurubooru/__init__.py b/szurubooru/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/szurubooru/app.py b/szurubooru/app.py new file mode 100644 index 00000000..c30b9cd5 --- /dev/null +++ b/szurubooru/app.py @@ -0,0 +1,40 @@ +import os +import falcon +import sqlalchemy +import sqlalchemy.orm +import szurubooru.rest.users +from szurubooru.config import Config +from szurubooru.middleware import Authenticator, JsonTranslator, RequireJson +from szurubooru.services import AuthService, UserService + +def create_app(): + config = Config() + root_dir = os.path.dirname(__file__) + static_dir = os.path.join(root_dir, os.pardir, 'static') + + engine = sqlalchemy.create_engine( + '{schema}://{user}:{password}@{host}:{port}/{name}'.format( + schema=config['database']['schema'], + user=config['database']['user'], + password=config['database']['pass'], + host=config['database']['host'], + port=config['database']['port'], + name=config['database']['name'])) + session = sqlalchemy.orm.sessionmaker(bind=engine)() + + user_service = UserService(session) + auth_service = AuthService(config, user_service) + + user_list = szurubooru.rest.users.UserList(auth_service) + user = szurubooru.rest.users.User(auth_service) + + app = falcon.API(middleware=[ + RequireJson(), + JsonTranslator(), + Authenticator(auth_service), + ]) + + app.add_route('/users/', user_list) + app.add_route('/user/{user_id}', user) + + return app diff --git a/szurubooru/config.py b/szurubooru/config.py new file mode 100644 index 00000000..1710fa9d --- /dev/null +++ b/szurubooru/config.py @@ -0,0 +1,11 @@ +import os +import configobj + +class Config(object): + def __init__(self): + self.config = configobj.ConfigObj('config.ini.dist') + if os.path.exists('config.ini'): + self.config.merge(configobj.ConfigObj('config.ini')) + + def __getitem__(self, key): + return self.config[key] diff --git a/szurubooru/middleware/__init__.py b/szurubooru/middleware/__init__.py new file mode 100644 index 00000000..54a31219 --- /dev/null +++ b/szurubooru/middleware/__init__.py @@ -0,0 +1,3 @@ +from szurubooru.middleware.authenticator import Authenticator +from szurubooru.middleware.json_translator import JsonTranslator +from szurubooru.middleware.require_json import RequireJson diff --git a/szurubooru/middleware/authenticator.py b/szurubooru/middleware/authenticator.py new file mode 100644 index 00000000..f3fa26db --- /dev/null +++ b/szurubooru/middleware/authenticator.py @@ -0,0 +1,32 @@ +import base64 +import falcon + +class Authenticator(object): + def __init__(self, auth_service): + self._auth_service = auth_service + + def process_request(self, request, response): + request.context['user'] = self._get_user(request) + + def _get_user(self, request): + if not request.auth: + return self._auth_service.authenticate(None, None) + + try: + auth_type, user_and_password = request.auth.split(' ', 1) + + if auth_type.lower() != 'basic': + raise falcon.HTTPBadRequest( + 'Invalid authentication type', + 'Only basic authorization is supported.') + + username, password = base64.decodestring( + user_and_password.encode('ascii')).decode('utf8').split(':') + + return self._auth_service.authenticate(username, password) + except ValueError as err: + msg = 'Basic authentication header value not properly formed. ' \ + + 'Supplied header {0}. Got error: {1}' + raise falcon.HTTPBadRequest( + 'Malformed authentication request', + msg.format(request.auth, str(err))) diff --git a/szurubooru/middleware/json_translator.py b/szurubooru/middleware/json_translator.py new file mode 100644 index 00000000..0bf5ebbb --- /dev/null +++ b/szurubooru/middleware/json_translator.py @@ -0,0 +1,27 @@ +import json +import falcon + +class JsonTranslator(object): + def process_request(self, request, response): + if request.content_length in (None, 0): + return + + body = request.stream.read() + if not body: + raise falcon.HTTPBadRequest( + 'Empty request body', + 'A valid JSON document is required.') + + try: + request.context['doc'] = json.loads(body.decode('utf-8')) + except (ValueError, UnicodeDecodeError): + raise falcon.HTTPError( + falcon.HTTP_401, + 'Malformed JSON', + 'Could not decode the request body. The ' + 'JSON was incorrect or not encoded as UTF-8.') + + def process_response(self, request, response, resource): + if 'result' not in request.context: + return + response.body = json.dumps(request.context['result']) diff --git a/szurubooru/middleware/require_json.py b/szurubooru/middleware/require_json.py new file mode 100644 index 00000000..b128ec11 --- /dev/null +++ b/szurubooru/middleware/require_json.py @@ -0,0 +1,7 @@ +import falcon + +class RequireJson(object): + def process_request(self, req, resp): + if not req.client_accepts_json: + raise falcon.HTTPNotAcceptable( + 'This API only supports responses encoded as JSON.') diff --git a/szurubooru/migrations/env.py b/szurubooru/migrations/env.py new file mode 100644 index 00000000..c10f0a16 --- /dev/null +++ b/szurubooru/migrations/env.py @@ -0,0 +1,74 @@ +import os +import sys + +import alembic +import sqlalchemy +import logging.config + +# make szurubooru module importable +dir_to_self = os.path.dirname(os.path.realpath(__file__)) +sys.path.append(os.path.join(dir_to_self, *[os.pardir] * 2)) + +import szurubooru.model.base +import szurubooru.config + +alembic_config = alembic.context.config +logging.config.fileConfig(alembic_config.config_file_name) + +szuru_config = szurubooru.config.Config() +alembic_config.set_main_option( + 'sqlalchemy.url', + '{schema}://{user}:{password}@{host}:{port}/{name}'.format( + schema=szuru_config['database']['schema'], + user=szuru_config['database']['user'], + password=szuru_config['database']['pass'], + host=szuru_config['database']['host'], + port=szuru_config['database']['port'], + name=szuru_config['database']['name'])) + +target_metadata = szurubooru.model.Base.metadata + +def run_migrations_offline(): + ''' + Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + ''' + url = alembic_config.get_main_option('sqlalchemy.url') + alembic.context.configure( + url=url, target_metadata=target_metadata, literal_binds=True) + + with alembic.context.begin_transaction(): + alembic.context.run_migrations() + + +def run_migrations_online(): + ''' + Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + ''' + connectable = sqlalchemy.engine_from_config( + alembic_config.get_section(alembic_config.config_ini_section), + prefix='sqlalchemy.', + poolclass=sqlalchemy.pool.NullPool) + + with connectable.connect() as connection: + alembic.context.configure( + connection=connection, + target_metadata=target_metadata) + + with alembic.context.begin_transaction(): + alembic.context.run_migrations() + +if alembic.context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/szurubooru/migrations/script.py.mako b/szurubooru/migrations/script.py.mako new file mode 100644 index 00000000..13adc519 --- /dev/null +++ b/szurubooru/migrations/script.py.mako @@ -0,0 +1,21 @@ +''' +${message} + +Revision ID: ${up_revision} +Created at: ${create_date} +''' + +import sqlalchemy as sa +from alembic import op +${imports if imports else ""} + +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + +def upgrade(): + ${upgrades if upgrades else "pass"} + +def downgrade(): + ${downgrades if downgrades else "pass"} diff --git a/szurubooru/migrations/versions/e5c1216a8503_create_user_table.py b/szurubooru/migrations/versions/e5c1216a8503_create_user_table.py new file mode 100644 index 00000000..0826448f --- /dev/null +++ b/szurubooru/migrations/versions/e5c1216a8503_create_user_table.py @@ -0,0 +1,31 @@ +''' +Create user table + +Revision ID: e5c1216a8503 +Created at: 2016-03-20 15:53:25.030415 +''' + +import sqlalchemy as sa +from alembic import op + +revision = 'e5c1216a8503' +down_revision = None +branch_labels = None +depends_on = None + +def upgrade(): + op.create_table( + 'user', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=50), nullable=False), + sa.Column('password_hash', sa.String(length=64), nullable=False), + sa.Column('pasword_salt', sa.String(length=32), nullable=True), + sa.Column('email', sa.String(length=200), nullable=True), + sa.Column('access_rank', sa.Integer(), nullable=False), + sa.Column('creation_time', sa.DateTime(), nullable=False), + sa.Column('last_login_time', sa.DateTime(), nullable=False), + sa.Column('avatar_style', sa.Integer(), nullable=False), + sa.PrimaryKeyConstraint('id')) + +def downgrade(): + op.drop_table('user') diff --git a/szurubooru/model/__init__.py b/szurubooru/model/__init__.py new file mode 100644 index 00000000..86a31482 --- /dev/null +++ b/szurubooru/model/__init__.py @@ -0,0 +1,2 @@ +from szurubooru.model.base import Base +from szurubooru.model.user import User diff --git a/szurubooru/model/base.py b/szurubooru/model/base.py new file mode 100644 index 00000000..7506f00a --- /dev/null +++ b/szurubooru/model/base.py @@ -0,0 +1,2 @@ +from sqlalchemy.ext.declarative import declarative_base +Base = declarative_base() # pylint: disable=C0103 diff --git a/szurubooru/model/user.py b/szurubooru/model/user.py new file mode 100644 index 00000000..37d3f62d --- /dev/null +++ b/szurubooru/model/user.py @@ -0,0 +1,18 @@ +import sqlalchemy as sa +from szurubooru.model.base import Base + +class User(Base): + __tablename__ = 'user' + + user_id = sa.Column('id', sa.Integer, primary_key=True) + name = sa.Column('name', sa.String(50), nullable=False) + password_hash = sa.Column('password_hash', sa.String(64), nullable=False) + password_salt = sa.Column('pasword_salt', sa.String(32)) + email = sa.Column('email', sa.String(200), nullable=True) + access_rank = sa.Column('access_rank', sa.Integer, nullable=False) + creation_time = sa.Column('creation_time', sa.DateTime, nullable=False) + last_login_time = sa.Column('last_login_time', sa.DateTime, nullable=False) + avatar_style = sa.Column('avatar_style', sa.Integer, nullable=False) + + def has_password(self, password): + return self.password == password diff --git a/szurubooru/rest/__init__.py b/szurubooru/rest/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/szurubooru/rest/users.py b/szurubooru/rest/users.py new file mode 100644 index 00000000..8eced5a4 --- /dev/null +++ b/szurubooru/rest/users.py @@ -0,0 +1,23 @@ +class UserList(object): + def __init__(self, auth_service): + self._auth_service = auth_service + + def on_get(self, request, response): + self._auth_service.verify_privilege(request.context['user'], 'users:list') + request.context['reuslt'] = {'message': 'Searching for users'} + + def on_post(self, request, response): + self._auth_service.verify_privilege(request.context['user'], 'users:create') + request.context['result'] = {'message': 'Creating user'} + +class User(object): + def __init__(self, auth_service): + self._auth_service = auth_service + + def on_get(self, request, response, user_id): + self._auth_service.verify_privilege(request.context['user'], 'users:view') + request.context['result'] = {'message': 'Getting user ' + user_id} + + def on_put(self, request, response, user_id): + self._auth_service.verify_privilege(request.context['user'], 'users:edit') + request.context['result'] = {'message': 'Updating user ' + user_id} diff --git a/szurubooru/services/__init__.py b/szurubooru/services/__init__.py new file mode 100644 index 00000000..23489bab --- /dev/null +++ b/szurubooru/services/__init__.py @@ -0,0 +1,2 @@ +from szurubooru.services.auth_service import AuthService +from szurubooru.services.user_service import UserService diff --git a/szurubooru/services/auth_service.py b/szurubooru/services/auth_service.py new file mode 100644 index 00000000..a907c78a --- /dev/null +++ b/szurubooru/services/auth_service.py @@ -0,0 +1,39 @@ +import falcon +from szurubooru.model.user import User + +class AuthService(object): + def __init__(self, config, user_service): + self._config = config + self._user_service = user_service + + def authenticate(self, username, password): + if not username: + return self._create_anonymous_user() + user = self._user_service.get_by_name(username) + if not user: + raise falcon.HTTPForbidden( + 'Authentication failed', 'No such user.') + if not user.has_password(password): + raise falcon.HTTPForbidden( + 'Authentication failed', 'Invalid password.') + return user + + def verify_privilege(self, user, privilege_name): + all_ranks = ['anonymous'] \ + + self._config['service']['user_ranks'] \ + + ['admin', 'nobody'] + + assert privilege_name in self._config['privileges'] + assert user.rank in all_ranks + minimal_rank = self._config['privileges'][privilege_name] + good_ranks = all_ranks[all_ranks.index(minimal_rank):] + if user.rank not in good_ranks: + raise falcon.HTTPForbidden( + 'Authentication failed', 'Insufficient privileges to do this.') + + def _create_anonymous_user(self): + user = User() + user.name = None + user.rank = 'anonymous' + user.password = None + return user diff --git a/szurubooru/services/user_service.py b/szurubooru/services/user_service.py new file mode 100644 index 00000000..9afc0610 --- /dev/null +++ b/szurubooru/services/user_service.py @@ -0,0 +1,8 @@ +from szurubooru.model.user import User + +class UserService(object): + def __init__(self, session): + self._session = session + + def get_by_name(self, name): + self._session.query(User).filter_by(name=name).first()