diff --git a/decorators.py b/decorators.py index e8d0b8b..7b345c6 100644 --- a/decorators.py +++ b/decorators.py @@ -2,33 +2,26 @@ import flask from flask import request import functools import time -import uuid import warnings -def _generate_session_token(): - token = str(uuid.uuid4()) - #print('MAKE SESSION', token) - return token +import jsonify -def give_session_token(function): - @functools.wraps(function) - def wrapped(*args, **kwargs): - # Inject new token so the function doesn't know the difference - token = request.cookies.get('etiquette_session', None) - if not token: - token = _generate_session_token() - request.cookies = dict(request.cookies) - request.cookies['etiquette_session'] = token - ret = function(*args, **kwargs) - - # Send the token back to the client - if not isinstance(ret, flask.Response): - ret = flask.Response(ret) - ret.set_cookie('etiquette_session', value=token, max_age=60) - - return ret - return wrapped +def required_fields(fields): + ''' + Declare that the endpoint requires certain POST body fields. Without them, + we respond with 400 and a message. + ''' + def with_required_fields(function): + @functools.wraps(function) + def wrapped(*args, **kwargs): + if not all(field in request.form for field in fields): + response = {'error': 'Required fields: %s' % ', '.join(fields)} + response = jsonify.make_json_response(response, status=400) + return response + return function(*args, **kwargs) + return wrapped + return with_required_fields def not_implemented(function): ''' diff --git a/etiquette.py b/etiquette.py index b54da92..1bb3d6d 100644 --- a/etiquette.py +++ b/etiquette.py @@ -12,6 +12,7 @@ import exceptions import helpers import jsonify import phototagger +import sessions # pip install # https://raw.githubusercontent.com/voussoir/else/master/_voussoirkit/voussoirkit.zip @@ -27,6 +28,7 @@ site.debug = True P = phototagger.PhotoDB() +session_manager = sessions.SessionManager() #################################################################################################### #################################################################################################### @@ -34,6 +36,9 @@ P = phototagger.PhotoDB() #################################################################################################### +def back_url(): + return request.args.get('goto') or request.referrer or '/' + def create_tag(easybake_string): notes = P.easybake(easybake_string) notes = [{'action': action, 'tagname': tagname} for (action, tagname) in notes] @@ -60,12 +65,6 @@ def delete_synonym(synonym): master_tag.remove_synonym(synonym) return {'action':'delete_synonym', 'synonym': synonym} -def make_json_response(j, *args, **kwargs): - dumped = json.dumps(j) - response = flask.Response(dumped, *args, **kwargs) - response.headers['Content-Type'] = 'application/json;charset=utf-8' - return response - def P_album(albumid): try: return P.get_album(albumid) @@ -159,11 +158,82 @@ def send_file(filepath): #################################################################################################### #################################################################################################### + @site.route('/') -@decorators.give_session_token +@session_manager.give_token def root(): motd = random.choice(P.config['motd_strings']) - return flask.render_template('root.html', motd=motd) + session = session_manager.get(request) + return flask.render_template('root.html', motd=motd, session=session) + +@site.route('/login', methods=['GET']) +@session_manager.give_token +def get_login(): + session = session_manager.get(request) + return flask.render_template('login.html', session=session) + +@site.route('/register', methods=['GET']) +def get_register(): + return flask.redirect('/login') + +@site.route('/login', methods=['POST']) +@session_manager.give_token +@decorators.required_fields(['username', 'password']) +def post_login(): + if session_manager.get(request): + flask.abort(403, 'You\'re already signed in.') + + username = request.form['username'] + password = request.form['password'] + user = P.get_user(username=username) + try: + user = P.login(user.id, password) + except exceptions.WrongLogin: + flask.abort(422, 'Wrong login.') + session = sessions.Session(request, user) + session_manager.add(session) + response = flask.Response('redirect', status=302, headers={'Location': '/'}) + return response + +@site.route('/register', methods=['POST']) +@session_manager.give_token +@decorators.required_fields(['username', 'password_1', 'password_2']) +def post_register(): + if session_manager.get(request): + flask.abort(403, 'You\'re already signed in.') + + username = request.form['username'] + password_1 = request.form['password_1'] + password_2 = request.form['password_2'] + + if password_1 != password_2: + flask.abort(422, 'Passwords do not match.') + + try: + user = P.register_user(username, password_1) + except exceptions.UsernameTooShort as e: + flask.abort(422, 'Username shorter than minimum of %d' % P.config['min_username_length']) + except exceptions.UsernameTooLong as e: + flask.abort(422, 'Username longer than maximum of %d' % P.config['max_username_length']) + except exceptions.InvalidUsernameChars as e: + flask.abort(422, 'Username contains invalid characters %s' % e.args[0]) + except exceptions.PasswordTooShort as e: + flask.abort(422, 'Password is shorter than minimum of %d' % P.config['min_password_length']) + except exceptions.UserExists as e: + flask.abort(422, 'User %s already exists' % e.args[0]) + + session = sessions.Session(request, user) + session_manager.add(session) + response = flask.Response('redirect', status=302, headers={'Location': '/'}) + return response + + +@site.route('/logout', methods=['GET', 'POST']) +@session_manager.give_token +def logout(): + session_manager.remove(request) + response = flask.Response('redirect', status=302, headers={'Location': back_url()}) + return response @site.route('/favicon.ico') @@ -182,22 +252,24 @@ def get_album_core(albumid): return album @site.route('/album/') -@decorators.give_session_token +@session_manager.give_token def get_album_html(albumid): album = get_album_core(albumid) + session = session_manager.get(request) response = flask.render_template( 'album.html', album=album, photos=album['photos'], + session=session, view=request.args.get('view', 'grid'), ) return response @site.route('/album/.json') -@decorators.give_session_token +@session_manager.give_token def get_album_json(albumid): album = get_album_core(albumid) - return make_json_response(album) + return jsonify.make_json_response(album) @site.route('/album/.tar') @@ -218,21 +290,24 @@ def get_albums_core(): return albums @site.route('/albums') -@decorators.give_session_token +@session_manager.give_token def get_albums_html(): albums = get_albums_core() - return flask.render_template('albums.html', albums=albums) + session = session_manager.get(request) + return flask.render_template('albums.html', albums=albums, session=session) @site.route('/albums.json') -@decorators.give_session_token +@session_manager.give_token def get_albums_json(): albums = get_albums_core() - return make_json_response(albums) + return jsonify.make_json_response(albums) @site.route('/bookmarks') +@session_manager.give_token def get_bookmarks(): - return flask.render_template('bookmarks.html') + session = session_manager.get(request) + return flask.render_template('bookmarks.html', session=session) @site.route('/file/') @@ -268,20 +343,20 @@ def get_photo_core(photoid): return photo @site.route('/photo/', methods=['GET']) -@decorators.give_session_token +@session_manager.give_token def get_photo_html(photoid): photo = get_photo_core(photoid) photo['tags'].sort(key=lambda x: x['qualified_name']) - return flask.render_template('photo.html', photo=photo) + session = session_manager.get(request) + return flask.render_template('photo.html', photo=photo, session=session) @site.route('/photo/.json', methods=['GET']) -@decorators.give_session_token +@session_manager.give_token def get_photo_json(photoid): photo = get_photo_core(photoid) - photo = make_json_response(photo) + photo = jsonify.make_json_response(photo) return photo - def get_search_core(): #print(request.args) @@ -418,11 +493,12 @@ def get_search_core(): return final_results @site.route('/search') -@decorators.give_session_token +@session_manager.give_token def get_search_html(): search_results = get_search_core() search_kwargs = search_results['search_kwargs'] qualname_map = search_results['qualname_map'] + session = session_manager.get(request) response = flask.render_template( 'search.html', next_page_url=search_results['next_page_url'], @@ -430,13 +506,14 @@ def get_search_html(): photos=search_results['photos'], qualname_map=json.dumps(qualname_map), search_kwargs=search_kwargs, + session=session, total_tags=search_results['total_tags'], warns=search_results['warns'], ) return response @site.route('/search.json') -@decorators.give_session_token +@session_manager.give_token def get_search_json(): search_results = get_search_core() #search_kwargs = search_results['search_kwargs'] @@ -445,7 +522,7 @@ def get_search_json(): include_qualname_map = helpers.truthystring(include_qualname_map) if not include_qualname_map: search_results.pop('qualname_map') - return make_json_response(search_results) + return jsonify.make_json_response(search_results) @site.route('/static/') @@ -468,18 +545,19 @@ def get_tags_core(specific_tag=None): @site.route('/tags') @site.route('/tags/') -@decorators.give_session_token +@session_manager.give_token def get_tags_html(specific_tag=None): tags = get_tags_core(specific_tag) - return flask.render_template('tags.html', tags=tags) + session = session_manager.get(request) + return flask.render_template('tags.html', tags=tags, session=session) @site.route('/tags.json') @site.route('/tags/.json') -@decorators.give_session_token +@session_manager.give_token def get_tags_json(specific_tag=None): tags = get_tags_core(specific_tag) tags = [t[0] for t in tags] - return make_json_response(tags) + return jsonify.make_json_response(tags) @site.route('/thumbnail/') @@ -495,7 +573,7 @@ def get_thumbnail(photoid): @site.route('/album/', methods=['POST']) @site.route('/album/.json', methods=['POST']) -@decorators.give_session_token +@session_manager.give_token def post_edit_album(albumid): ''' Edit the album's title and description. @@ -512,18 +590,18 @@ def post_edit_album(albumid): tag = P_tag(tag) except exceptions.NoSuchTag: response = {'error': 'That tag doesnt exist', 'tagname': tag} - return make_json_response(response, status=404) + return jsonify.make_json_response(response, status=404) recursive = request.form.get('recursive', False) recursive = helpers.truthystring(recursive) album.add_tag_to_all(tag, nested_children=recursive) response['action'] = action response['tagname'] = tag.name - return make_json_response(response) + return jsonify.make_json_response(response) @site.route('/photo/', methods=['POST']) @site.route('/photo/.json', methods=['POST']) -@decorators.give_session_token +@session_manager.give_token def post_edit_photo(photoid): ''' Add and remove tags from photos. @@ -548,22 +626,22 @@ def post_edit_photo(photoid): tag = P.get_tag(tag) except exceptions.NoSuchTag: response = {'error': 'That tag doesnt exist', 'tagname': tag} - return make_json_response(response, status=404) + return jsonify.make_json_response(response, status=404) method(tag) response['action'] = action #response['tagid'] = tag.id response['tagname'] = tag.name - return make_json_response(response) + return jsonify.make_json_response(response) @site.route('/tags', methods=['POST']) -@decorators.give_session_token +@session_manager.give_token def post_edit_tags(): ''' Create and delete tags and synonyms. ''' - print(request.form) + #print(request.form) status = 200 if 'create_tag' in request.form: action = 'create_tag' @@ -605,6 +683,13 @@ def post_edit_tags(): return response +@site.route('/apitest') +@session_manager.give_token +def apitest(): + response = flask.Response('testing') + response.set_cookie('etiquette_session', 'don\'t overwrite me') + return response + if __name__ == '__main__': #site.run(threaded=True) pass diff --git a/helpers.py b/helpers.py index c1138b1..bf82cde 100644 --- a/helpers.py +++ b/helpers.py @@ -2,10 +2,10 @@ import datetime import math import mimetypes import os +import warnings import constants import exceptions -import warnings from voussoirkit import bytestring diff --git a/jsonify.py b/jsonify.py index 375eff7..1354f1f 100644 --- a/jsonify.py +++ b/jsonify.py @@ -1,4 +1,12 @@ +import flask import helpers +import json + +def make_json_response(j, *args, **kwargs): + dumped = json.dumps(j) + response = flask.Response(dumped, *args, **kwargs) + response.headers['Content-Type'] = 'application/json;charset=utf-8' + return response def album(a, minimal=False): j = { diff --git a/sessions.py b/sessions.py new file mode 100644 index 0000000..c472812 --- /dev/null +++ b/sessions.py @@ -0,0 +1,79 @@ +import flask +from flask import request +import functools +import helpers +import uuid + +def _generate_token(): + token = str(uuid.uuid4()) + #print('MAKE SESSION', token) + return token + +def _normalize_token(token): + if isinstance(token, flask.Request): + token = token.cookies.get('etiquette_session', None) + +class SessionManager: + def __init__(self): + self.sessions = {} + + def add(self, session): + self.sessions[session.token] = session + + def get(self, token): + token = _normalize_token(token) + return self.sessions.get(token, None) + + def give_token(self, function): + ''' + This decorator ensures that the user has an `etiquette_session` cookie + before reaching the request handler. + If the user does not have the cookie, they are given one. + If they do, its lifespan is reset. + ''' + @functools.wraps(function) + def wrapped(*args, **kwargs): + # Inject new token so the function doesn't know the difference + token = request.cookies.get('etiquette_session', None) + if not token: + token = _generate_token() + request.cookies = dict(request.cookies) + request.cookies['etiquette_session'] = token + + response = function(*args, **kwargs) + + if not isinstance(response, flask.Response): + response = flask.Response(response) + + # Send the token back to the client + # but only if the endpoint didn't manually set the cookie. + for (headerkey, value) in response.headers: + if headerkey == 'Set-Cookie' and value.startswith('etiquette_session='): + break + else: + response.set_cookie('etiquette_session', value=token, max_age=86400) + self.maintain(token) + + return response + return wrapped + + def maintain(self, token): + session = self.get(token) + if session: + session.maintain() + + def remove(self, token): + token = _normalize_token(token) + if token in self.sessions: + self.sessions.pop(token) + +class Session: + def __init__(self, request, user): + self.token = _normalize_token(request) + self.user = user + self.ip_address = request.remote_addr + self.user_agent = request.headers.get('User-Agent', '') + self.last_activity = int(helpers.now()) + + def maintain(self): + self.last_activity = int(helpers.now()) diff --git a/templates/album.html b/templates/album.html index 669b2df..90e6cee 100644 --- a/templates/album.html +++ b/templates/album.html @@ -22,7 +22,7 @@ p -{{header.make_header()}} +{{header.make_header(session=session)}}

{{album["title"]}}

{{album["description"]}}

diff --git a/templates/albums.html b/templates/albums.html index 7fde163..839ddec 100644 --- a/templates/albums.html +++ b/templates/albums.html @@ -16,7 +16,7 @@ -{{header.make_header()}} +{{header.make_header(session=session)}}
{% for album in albums %} {% if album["title"] %} diff --git a/templates/bookmarks.html b/templates/bookmarks.html index 97216fe..20d4e73 100644 --- a/templates/bookmarks.html +++ b/templates/bookmarks.html @@ -13,7 +13,7 @@ - {{header.make_header()}} + {{header.make_header(session=session)}} diff --git a/templates/header.html b/templates/header.html index b62e74c..92ad6aa 100644 --- a/templates/header.html +++ b/templates/header.html @@ -1,7 +1,13 @@ -{% macro make_header() %} +{% macro make_header(session) %} {% endmacro %} \ No newline at end of file diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000..36750ac --- /dev/null +++ b/templates/login.html @@ -0,0 +1,74 @@ + + + + {% import "header.html" as header %} + Login/Register + + + + + + + + + + {{header.make_header(session=session)}} +
+
+
+ Log in + + + +
+
+ Register + + + + +
+
+
+ + + + + diff --git a/templates/photo.html b/templates/photo.html index 4a6d108..1ac346f 100644 --- a/templates/photo.html +++ b/templates/photo.html @@ -87,7 +87,7 @@ -{{header.make_header()}} +{{header.make_header(session=session)}}
diff --git a/templates/root.html b/templates/root.html index 52c6ce5..6833466 100644 --- a/templates/root.html +++ b/templates/root.html @@ -34,6 +34,11 @@ a:hover Browse tags Browse albums Bookmarks + {% if session %} + {{session.user.username}} + {% else %} + Log in + {% endif %} diff --git a/templates/search.html b/templates/search.html index 9396f04..602b74a 100644 --- a/templates/search.html +++ b/templates/search.html @@ -119,7 +119,7 @@ form - {{header.make_header()}} + {{header.make_header(session=session)}}
{% for warn in warns %} {{warn}} diff --git a/templates/tags.html b/templates/tags.html index c4871f6..2ae7b32 100644 --- a/templates/tags.html +++ b/templates/tags.html @@ -59,7 +59,7 @@ body -{{header.make_header()}} +{{header.make_header(session=session)}}