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:
rr- 2016-03-19 21:37:04 +01:00
commit 797ace982f
48 changed files with 1331 additions and 0 deletions

2
.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
config.ini
node_modules

5
.jscsrc Normal file
View file

@ -0,0 +1,5 @@
{
"preset": "google",
"fileExtensions": [".js", "jscs"],
"validateIndentation": 4,
}

87
INSTALL.md Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View file

@ -0,0 +1,2 @@
*.*
!.gitignore

5
requirements.txt Normal file
View 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
View 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
View 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
View 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
View 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
View file

@ -0,0 +1 @@
.config.autogen.json

4
static/js/config.js Normal file
View file

@ -0,0 +1,4 @@
'use strict';
const config = require('./.config.autogen.json');
module.exports = config;

View 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;

View file

@ -0,0 +1,13 @@
'use strict';
class CommentsController {
constructor(topNavigationController) {
this.topNavigationController = topNavigationController;
}
listCommentsRoute() {
this.topNavigationController.activate('comments');
}
}
module.exports = CommentsController;

View file

@ -0,0 +1,13 @@
'use strict';
class HelpController {
constructor(topNavigationController) {
this.topNavigationController = topNavigationController;
}
showHelpRoute() {
this.topNavigationController.activate('help');
}
}
module.exports = HelpController;

View file

@ -0,0 +1,13 @@
'use strict';
class HistoryController {
constructor(topNavigationController) {
this.topNavigationController = topNavigationController;
}
listHistoryRoute() {
this.topNavigationController.activate('');
}
}
module.exports = HistoryController;

View 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;

View 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;

View file

@ -0,0 +1,13 @@
'use strict';
class TagsController {
constructor(topNavigationController) {
this.topNavigationController = topNavigationController;
}
listTagsRoute() {
this.topNavigationController.activate('tags');
}
}
module.exports = TagsController;

View 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;

View 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
View 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();

View 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;

View 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;

View 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
View 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
View file

40
szurubooru/app.py Normal file
View 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
View 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]

View 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

View 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)))

View 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'])

View 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.')

View 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()

View 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"}

View file

@ -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')

View file

@ -0,0 +1,2 @@
from szurubooru.model.base import Base
from szurubooru.model.user import User

2
szurubooru/model/base.py Normal file
View 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
View 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

View file

23
szurubooru/rest/users.py Normal file
View 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}

View file

@ -0,0 +1,2 @@
from szurubooru.services.auth_service import AuthService
from szurubooru.services.user_service import UserService

View 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

View 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()