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
This commit is contained in:
commit
797ace982f
48 changed files with 1331 additions and 0 deletions
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
config.ini
|
||||||
|
node_modules
|
5
.jscsrc
Normal file
5
.jscsrc
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
{
|
||||||
|
"preset": "google",
|
||||||
|
"fileExtensions": [".js", "jscs"],
|
||||||
|
"validateIndentation": 4,
|
||||||
|
}
|
87
INSTALL.md
Normal file
87
INSTALL.md
Normal file
|
@ -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!
|
23
README.md
Normal file
23
README.md
Normal file
|
@ -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.
|
39
alembic.ini
Normal file
39
alembic.ini
Normal file
|
@ -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
|
30
bin/szurubooru
Executable file
30
bin/szurubooru
Executable file
|
@ -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()
|
90
config.ini.dist
Normal file
90
config.ini.dist
Normal file
|
@ -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
|
23
package.json
Normal file
23
package.json
Normal file
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
2
public/.gitignore
vendored
Normal file
2
public/.gitignore
vendored
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
*.*
|
||||||
|
!.gitignore
|
5
requirements.txt
Normal file
5
requirements.txt
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
alembic>=0.8.5
|
||||||
|
configobj>=5.0.6
|
||||||
|
falcon>=0.3.0
|
||||||
|
psycopg2>=2.6.1
|
||||||
|
SQLAlchemy>=1.0.12
|
79
static/css/forms.css
Normal file
79
static/css/forms.css
Normal file
|
@ -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;
|
||||||
|
}
|
74
static/css/main.css
Normal file
74
static/css/main.css
Normal file
|
@ -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;
|
||||||
|
}
|
16
static/css/users.css
Normal file
16
static/css/users.css
Normal file
|
@ -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;
|
||||||
|
}
|
71
static/html/index.htm
Normal file
71
static/html/index.htm
Normal file
|
@ -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>
|
1
static/js/.gitignore
vendored
Normal file
1
static/js/.gitignore
vendored
Normal file
|
@ -0,0 +1 @@
|
||||||
|
.config.autogen.json
|
4
static/js/config.js
Normal file
4
static/js/config.js
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const config = require('./.config.autogen.json');
|
||||||
|
module.exports = config;
|
34
static/js/controllers/auth_controller.js
Normal file
34
static/js/controllers/auth_controller.js
Normal file
|
@ -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;
|
13
static/js/controllers/comments_controller.js
Normal file
13
static/js/controllers/comments_controller.js
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
class CommentsController {
|
||||||
|
constructor(topNavigationController) {
|
||||||
|
this.topNavigationController = topNavigationController;
|
||||||
|
}
|
||||||
|
|
||||||
|
listCommentsRoute() {
|
||||||
|
this.topNavigationController.activate('comments');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = CommentsController;
|
13
static/js/controllers/help_controller.js
Normal file
13
static/js/controllers/help_controller.js
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
class HelpController {
|
||||||
|
constructor(topNavigationController) {
|
||||||
|
this.topNavigationController = topNavigationController;
|
||||||
|
}
|
||||||
|
|
||||||
|
showHelpRoute() {
|
||||||
|
this.topNavigationController.activate('help');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = HelpController;
|
13
static/js/controllers/history_controller.js
Normal file
13
static/js/controllers/history_controller.js
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
class HistoryController {
|
||||||
|
constructor(topNavigationController) {
|
||||||
|
this.topNavigationController = topNavigationController;
|
||||||
|
}
|
||||||
|
|
||||||
|
listHistoryRoute() {
|
||||||
|
this.topNavigationController.activate('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = HistoryController;
|
17
static/js/controllers/home_controller.js
Normal file
17
static/js/controllers/home_controller.js
Normal file
|
@ -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;
|
25
static/js/controllers/posts_controller.js
Normal file
25
static/js/controllers/posts_controller.js
Normal file
|
@ -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;
|
13
static/js/controllers/tags_controller.js
Normal file
13
static/js/controllers/tags_controller.js
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
class TagsController {
|
||||||
|
constructor(topNavigationController) {
|
||||||
|
this.topNavigationController = topNavigationController;
|
||||||
|
}
|
||||||
|
|
||||||
|
listTagsRoute() {
|
||||||
|
this.topNavigationController.activate('tags');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = TagsController;
|
69
static/js/controllers/top_navigation_controller.js
Normal file
69
static/js/controllers/top_navigation_controller.js
Normal file
|
@ -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;
|
38
static/js/controllers/users_controller.js
Normal file
38
static/js/controllers/users_controller.js
Normal file
|
@ -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;
|
70
static/js/main.js
Normal file
70
static/js/main.js
Normal file
|
@ -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();
|
37
static/js/views/base_view.js
Normal file
37
static/js/views/base_view.js
Normal file
|
@ -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;
|
35
static/js/views/registration_view.js
Normal file
35
static/js/views/registration_view.js
Normal file
|
@ -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;
|
30
static/js/views/top_navigation_view.js
Normal file
30
static/js/views/top_navigation_view.js
Normal file
|
@ -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;
|
33
static/prepare_config.js
Normal file
33
static/prepare_config.js
Normal file
|
@ -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));
|
0
szurubooru/__init__.py
Normal file
0
szurubooru/__init__.py
Normal file
40
szurubooru/app.py
Normal file
40
szurubooru/app.py
Normal file
|
@ -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
|
11
szurubooru/config.py
Normal file
11
szurubooru/config.py
Normal file
|
@ -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]
|
3
szurubooru/middleware/__init__.py
Normal file
3
szurubooru/middleware/__init__.py
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
from szurubooru.middleware.authenticator import Authenticator
|
||||||
|
from szurubooru.middleware.json_translator import JsonTranslator
|
||||||
|
from szurubooru.middleware.require_json import RequireJson
|
32
szurubooru/middleware/authenticator.py
Normal file
32
szurubooru/middleware/authenticator.py
Normal file
|
@ -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)))
|
27
szurubooru/middleware/json_translator.py
Normal file
27
szurubooru/middleware/json_translator.py
Normal file
|
@ -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'])
|
7
szurubooru/middleware/require_json.py
Normal file
7
szurubooru/middleware/require_json.py
Normal file
|
@ -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.')
|
74
szurubooru/migrations/env.py
Normal file
74
szurubooru/migrations/env.py
Normal file
|
@ -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()
|
21
szurubooru/migrations/script.py.mako
Normal file
21
szurubooru/migrations/script.py.mako
Normal file
|
@ -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"}
|
|
@ -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')
|
2
szurubooru/model/__init__.py
Normal file
2
szurubooru/model/__init__.py
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
from szurubooru.model.base import Base
|
||||||
|
from szurubooru.model.user import User
|
2
szurubooru/model/base.py
Normal file
2
szurubooru/model/base.py
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
from sqlalchemy.ext.declarative import declarative_base
|
||||||
|
Base = declarative_base() # pylint: disable=C0103
|
18
szurubooru/model/user.py
Normal file
18
szurubooru/model/user.py
Normal file
|
@ -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
|
0
szurubooru/rest/__init__.py
Normal file
0
szurubooru/rest/__init__.py
Normal file
23
szurubooru/rest/users.py
Normal file
23
szurubooru/rest/users.py
Normal file
|
@ -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}
|
2
szurubooru/services/__init__.py
Normal file
2
szurubooru/services/__init__.py
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
from szurubooru.services.auth_service import AuthService
|
||||||
|
from szurubooru.services.user_service import UserService
|
39
szurubooru/services/auth_service.py
Normal file
39
szurubooru/services/auth_service.py
Normal file
|
@ -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
|
8
szurubooru/services/user_service.py
Normal file
8
szurubooru/services/user_service.py
Normal file
|
@ -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()
|
Loading…
Reference in a new issue