summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore2
-rw-r--r--.jscsrc5
-rw-r--r--INSTALL.md87
-rw-r--r--README.md23
-rw-r--r--alembic.ini39
-rwxr-xr-xbin/szurubooru30
-rw-r--r--config.ini.dist90
-rw-r--r--package.json23
-rw-r--r--public/.gitignore2
-rw-r--r--requirements.txt5
-rw-r--r--static/css/forms.css79
-rw-r--r--static/css/main.css74
-rw-r--r--static/css/users.css16
-rw-r--r--static/html/index.htm71
-rw-r--r--static/js/.gitignore1
-rw-r--r--static/js/config.js4
-rw-r--r--static/js/controllers/auth_controller.js34
-rw-r--r--static/js/controllers/comments_controller.js13
-rw-r--r--static/js/controllers/help_controller.js13
-rw-r--r--static/js/controllers/history_controller.js13
-rw-r--r--static/js/controllers/home_controller.js17
-rw-r--r--static/js/controllers/posts_controller.js25
-rw-r--r--static/js/controllers/tags_controller.js13
-rw-r--r--static/js/controllers/top_navigation_controller.js69
-rw-r--r--static/js/controllers/users_controller.js38
-rw-r--r--static/js/main.js70
-rw-r--r--static/js/views/base_view.js37
-rw-r--r--static/js/views/registration_view.js35
-rw-r--r--static/js/views/top_navigation_view.js30
-rw-r--r--static/prepare_config.js33
-rw-r--r--szurubooru/__init__.py0
-rw-r--r--szurubooru/app.py40
-rw-r--r--szurubooru/config.py11
-rw-r--r--szurubooru/middleware/__init__.py3
-rw-r--r--szurubooru/middleware/authenticator.py32
-rw-r--r--szurubooru/middleware/json_translator.py27
-rw-r--r--szurubooru/middleware/require_json.py7
-rw-r--r--szurubooru/migrations/env.py74
-rw-r--r--szurubooru/migrations/script.py.mako21
-rw-r--r--szurubooru/migrations/versions/e5c1216a8503_create_user_table.py31
-rw-r--r--szurubooru/model/__init__.py2
-rw-r--r--szurubooru/model/base.py2
-rw-r--r--szurubooru/model/user.py18
-rw-r--r--szurubooru/rest/__init__.py0
-rw-r--r--szurubooru/rest/users.py23
-rw-r--r--szurubooru/services/__init__.py2
-rw-r--r--szurubooru/services/auth_service.py39
-rw-r--r--szurubooru/services/user_service.py8
48 files changed, 1331 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..53668de
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,2 @@
+config.ini
+node_modules
diff --git a/.jscsrc b/.jscsrc
new file mode 100644
index 0000000..c12b28d
--- /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 0000000..4b6ead3
--- /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 0000000..699d59d
--- /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 0000000..a49947c
--- /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 0000000..8b369ff
--- /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 0000000..8e557c6
--- /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 0000000..bdea6e8
--- /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.js >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 0000000..67c04e4
--- /dev/null
+++ b/public/.gitignore
@@ -0,0 +1,2 @@
+*.*
+!.gitignore
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..0122fa6
--- /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 0000000..f486549
--- /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 0000000..1a338e0
--- /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 0000000..cf2e10d
--- /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 0000000..543d50c
--- /dev/null
+++ b/static/html/index.htm
@@ -0,0 +1,71 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <title>szurubooru</title>
+ <meta charset='utf-8'/>
+ <link href='/bundle.min.css' rel='stylesheet' type='text/css'>
+ <link href='//fonts.googleapis.com/css?family=Droid+Sans' rel='stylesheet' type='text/css'>
+ <link href='//fonts.googleapis.com/css?family=Inconsolata' rel='stylesheet' type='text/css'>
+ <!-- TODO: configurable favicon -->
+ <template id='top-nav-template'>
+ <nav id='top-nav' class='text-nav'>
+ <ul><!--
+ -->{{#each items}}<!--
+ -->{{#if this.available}}<!--
+ --><li data-name='{{@key}}'><!--
+ --><a href='{{this.url}}'>{{this.name}}</a><!--
+ --></li><!--
+ -->{{/if}}<!--
+ -->{{/each}}<!--
+ --></ul>
+ </nav>
+ </template>
+
+ <template id='user-registration-template'>
+ <div class='center' id='user-registration'>
+ <h1>Registration</h1>
+ <br/>
+ <form autocomplete='off'>
+ <fieldset class='input'>
+ <ul>
+ <li>
+ <label for='user-name'>User name:</label>
+ <input id='user-name' name='user-name' type='text' autocomplete='off' placeholder='e.g. darth_vader' required/>
+ </li>
+ <li>
+ <label for='user-password'>Password:</label>
+ <input id='user-password' name='user-password' type='password' autocomplete='off' placeholder='e.g. cupcake' required/>
+ </li>
+ <li>
+ <label for='user-email'>Email (optional):</label>
+ <input id='user-email' name='user-email' type='email' autocomplete='off' placeholder='e.g. vader@empire.gov'/>
+ </li>
+ </ul>
+ </fieldset>
+ <hr/>
+ <p>By clicking "Create an account" button below, you are agreeing to the <a href='/help/tos'>Terms of Service</a>.</p>
+ <hr/>
+ <fieldset class='buttons'>
+ <input type='submit' value='Create an account'/>
+ </fieldset>
+ </form>
+ <div class='info'>
+ <p>Registered users can:</p>
+ <ul>
+ <li>upload new posts</li>
+ <li>mark them as favorite</li>
+ <li>add comments</li>
+ <li>vote up/down on posts and comments</li>
+ </ul>
+ <p>Your e-mail will be used to show your <a href='http://gravatar.com/'>Gravatar</a> and for password reminders only. Leave blank for random Gravatar.</p>
+ </div>
+ </div>
+ </template>
+
+</head>
+<body>
+ <div id='top-nav-holder'></div>
+ <div id='content-holder'></div>
+<script type='text/javascript' src='/bundle.min.js'></script>
+</body>
+</html>
diff --git a/static/js/.gitignore b/static/js/.gitignore
new file mode 100644
index 0000000..16e3bbe
--- /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 0000000..4524be6
--- /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 0000000..e23d753
--- /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 0000000..b6ff02c
--- /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 0000000..78eeab3
--- /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 0000000..ff0b264
--- /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 0000000..1b913fa
--- /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 0000000..bbd540c
--- /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 0000000..3124d40
--- /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 0000000..2bd098d
--- /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 0000000..a5e800d
--- /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 0000000..bd9216e
--- /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 0000000..06baabd
--- /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 0000000..2f1a31b
--- /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 0000000..e7a6d0e
--- /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 0000000..356e9f9
--- /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 0000000..e69de29
--- /dev/null
+++ b/szurubooru/__init__.py
diff --git a/szurubooru/app.py b/szurubooru/app.py
new file mode 100644
index 0000000..c30b9cd
--- /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 0000000..1710fa9
--- /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 0000000..54a3121
--- /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 0000000..f3fa26d
--- /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 0000000..0bf5ebb
--- /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 0000000..b128ec1
--- /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 0000000..c10f0a1
--- /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 0000000..13adc51
--- /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 0000000..0826448
--- /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 0000000..86a3148
--- /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 0000000..7506f00
--- /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 0000000..37d3f62
--- /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 0000000..e69de29
--- /dev/null
+++ b/szurubooru/rest/__init__.py
diff --git a/szurubooru/rest/users.py b/szurubooru/rest/users.py
new file mode 100644
index 0000000..8eced5a
--- /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 0000000..23489ba
--- /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 0000000..a907c78
--- /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 0000000..9afc061
--- /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()