| @@ -3,7 +3,7 @@ | |||
| from pathlib import Path | |||
| from flask import Flask, g, redirect, request, url_for | |||
| from flask import Flask, flash, g, redirect, request, url_for | |||
| from flask_mako import MakoTemplates, render_template | |||
| import calefaction | |||
| @@ -11,8 +11,10 @@ from calefaction.auth import AuthManager | |||
| from calefaction.config import Config | |||
| from calefaction.database import Database | |||
| from calefaction.eve import EVE | |||
| from calefaction.exceptions import AccessDeniedError, EVEAPIError | |||
| from calefaction.util import catch_errors, set_up_asset_versioning | |||
| from calefaction.messages import Messages | |||
| from calefaction.util import ( | |||
| try_func, make_error_catcher, make_route_restricter, | |||
| set_up_asset_versioning) | |||
| app = Flask(__name__) | |||
| @@ -21,6 +23,9 @@ config = Config(basepath / "config") | |||
| Database.path = str(basepath / "data" / "db.sqlite3") | |||
| eve = EVE(config) | |||
| auth = AuthManager(config, eve) | |||
| catch_exceptions = make_error_catcher(app, "error.mako") | |||
| route_restricted = make_route_restricter( | |||
| auth, lambda: redirect(url_for("index"), 303)) | |||
| MakoTemplates(app) | |||
| set_up_asset_versioning(app) | |||
| @@ -37,32 +42,44 @@ app.before_request(Database.pre_hook) | |||
| app.teardown_appcontext(Database.post_hook) | |||
| @app.route("/") | |||
| @catch_errors(app) | |||
| @catch_exceptions | |||
| def index(): | |||
| ... # handle flashed error messages in _base.mako | |||
| if auth.is_authenticated(): # ... need to check for exceptions | |||
| success, _ = try_func(auth.is_authenticated) | |||
| if success: | |||
| return render_template("home.mako") | |||
| return render_template("landing.mako") | |||
| @app.route("/login", methods=["GET", "POST"]) | |||
| @catch_errors(app) | |||
| @catch_exceptions | |||
| def login(): | |||
| code = request.args.get("code") | |||
| state = request.args.get("state") | |||
| try: | |||
| auth.handle_login(code, state) | |||
| except EVEAPIError: | |||
| ... # flash error message | |||
| except AccessDeniedError: | |||
| ... # flash error message | |||
| if getattr(g, "_session_expired"): | |||
| ... # flash error message | |||
| success, caught = try_func(lambda: auth.handle_login(code, state)) | |||
| if success: | |||
| flash(Messages.LOGGED_IN, "success") | |||
| elif getattr(g, "_session_expired", False): | |||
| flash(Messages.SESSION_EXPIRED, "error") | |||
| elif not caught: | |||
| flash(Messages.LOGIN_FAILED, "error") | |||
| return redirect(url_for("index"), 303) | |||
| # @app.route("/logout") ... | |||
| @app.route("/logout", methods=["GET", "POST"]) | |||
| @catch_exceptions | |||
| def logout(): | |||
| if request.method == "GET": | |||
| return render_template("logout.mako") | |||
| auth.handle_logout() | |||
| flash(Messages.LOGGED_OUT, "success") | |||
| return redirect(url_for("index"), 303) | |||
| # @auth.route_restricted ... | |||
| # check for same exceptions as login() and use same flashes | |||
| @app.route("/test") | |||
| @catch_exceptions | |||
| @route_restricted | |||
| def test(): | |||
| ... | |||
| return "Success! You are authenticated!" | |||
| if __name__ == "__main__": | |||
| app.run(debug=True, port=8080) | |||
| @@ -238,3 +238,11 @@ class AuthManager: | |||
| g.db.attach_session(sid, char_id) | |||
| g.db.touch_session(sid) | |||
| return True | |||
| def handle_logout(self): | |||
| """Log out the user if they are logged in. | |||
| Invalidates their session and clears the session cookie. | |||
| """ | |||
| self._invalidate_session() | |||
| session.clear() | |||
| @@ -0,0 +1,17 @@ | |||
| # -*- coding: utf-8 -*- | |||
| __all__ = ["Messages"] | |||
| class Messages: | |||
| """Namespace for user interface message strings.""" | |||
| # success | |||
| LOGGED_IN = "Logged in." | |||
| LOGGED_OUT = "Logged out." | |||
| # error | |||
| LOG_IN_FIRST = "You need to log in to access that page." | |||
| ACCESS_DENIED = "Your character is not permitted to access this site." | |||
| SESSION_EXPIRED = "Session expired. You need to log in again." | |||
| LOGIN_FAILED = "Login failed." | |||
| EVE_API_ERROR = ("There was an error communicating with EVE's servers. " | |||
| "Please wait a while and try again.") | |||
| @@ -5,12 +5,33 @@ from hashlib import md5 | |||
| from os import path | |||
| from traceback import format_exc | |||
| from flask import url_for | |||
| from flask import flash, url_for | |||
| from flask_mako import render_template, TemplateError | |||
| __all__ = ["catch_errors", "set_up_asset_versioning"] | |||
| from .exceptions import AccessDeniedError, EVEAPIError | |||
| from .messages import Messages | |||
| def catch_errors(app): | |||
| __all__ = [ | |||
| "try_func", "make_error_catcher", "make_route_restricter", | |||
| "set_up_asset_versioning"] | |||
| def try_func(inner): | |||
| """Evaluate inner(), catching subclasses of CalefactionError. | |||
| If nothing was caught, return (inner(), False). Otherwise, flash an | |||
| appropriate error message and return (False, True). | |||
| """ | |||
| try: | |||
| result = inner() | |||
| return (result, False) | |||
| except EVEAPIError: | |||
| flash(Messages.EVE_API_ERROR, "error") | |||
| return (False, True) | |||
| except AccessDeniedError: | |||
| flash(Messages.ACCESS_DENIED, "error") | |||
| return (False, True) | |||
| def make_error_catcher(app, error_template): | |||
| """Wrap a route to display and log any uncaught exceptions.""" | |||
| def callback(func): | |||
| @wraps(func) | |||
| @@ -19,10 +40,24 @@ def catch_errors(app): | |||
| return func(*args, **kwargs) | |||
| except TemplateError as exc: | |||
| app.logger.error("Caught exception:\n{0}".format(exc.text)) | |||
| return render_template("error.mako", traceback=exc.text) | |||
| return render_template(error_template, traceback=exc.text) | |||
| except Exception: | |||
| app.logger.exception("Caught exception:") | |||
| return render_template("error.mako", traceback=format_exc()) | |||
| return render_template(error_template, traceback=format_exc()) | |||
| return inner | |||
| return callback | |||
| def make_route_restricter(auth, on_failure): | |||
| """Wrap a route to ensure the user is authenticated.""" | |||
| def callback(func): | |||
| @wraps(func) | |||
| def inner(*args, **kwargs): | |||
| success, caught = try_func(auth.is_authenticated) | |||
| if success: | |||
| return func(*args, **kwargs) | |||
| if not caught: | |||
| flash(Messages.LOG_IN_FIRST, "error") | |||
| return on_failure() | |||
| return inner | |||
| return callback | |||
| @@ -40,8 +75,8 @@ def set_up_asset_versioning(app): | |||
| if cache and cache[0] == mtime: | |||
| hashstr = cache[1] | |||
| else: | |||
| with open(fpath, "rb") as f: | |||
| hashstr = md5(f.read()).hexdigest() | |||
| with open(fpath, "rb") as fp: | |||
| hashstr = md5(fp.read()).hexdigest() | |||
| app._hash_cache[fpath] = (mtime, hashstr) | |||
| return url_for("static", filename=filename, v=hashstr) | |||
| raise error | |||
| @@ -149,3 +149,23 @@ footer ul li:not(:last-child):after { | |||
| #error pre { | |||
| white-space: pre-wrap; | |||
| } | |||
| #flashes { | |||
| margin-top: 0.5em; | |||
| } | |||
| #flashes > div { | |||
| padding: 0.5em 0.75em; | |||
| border-left-width: 4px; | |||
| border-left-style: solid; | |||
| } | |||
| #flashes > .success { | |||
| border-color: #33AA22; | |||
| background-color: rgba(60, 255, 30, 0.2); | |||
| } | |||
| #flashes > .error { | |||
| border-color: #AA3322; | |||
| background-color: rgba(255, 60, 30, 0.2); | |||
| } | |||
| @@ -38,6 +38,16 @@ | |||
| <div id="container"> | |||
| <div> | |||
| <main> | |||
| <%block name="flashes"> | |||
| <% messages = get_flashed_messages(with_categories=True) %> | |||
| % if messages: | |||
| <div id="flashes"> | |||
| % for category, message in messages: | |||
| <div class="${category | h}">${message | h}</div> | |||
| % endfor | |||
| </div> | |||
| % endif | |||
| </%block> | |||
| ${next.body()} | |||
| </main> | |||
| </div> | |||
| @@ -6,6 +6,6 @@ | |||
| </nav> | |||
| </%block> | |||
| <%block name="righthead"> | |||
| PLAYER_NAME... [logout] | |||
| PLAYER_NAME... [logout] <!-- use GET /logout here and JS switch it to a POST form --> | |||
| </%block> | |||
| ${next.body()} | |||
| @@ -0,0 +1,9 @@ | |||
| <%inherit file="_base.mako"/> | |||
| <%block name="title"> | |||
| Log out – ${g.config.get("corp.name") | h} | |||
| </%block> | |||
| <h1>Log out</h1> <!-- ... style --> | |||
| <p>Use the button below to safely log out and clear your session.</p> | |||
| <form method="post"> | |||
| <input type="submit" value="Log out"> | |||
| </form> | |||