| @@ -3,7 +3,7 @@ | |||||
| from pathlib import Path | 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 | from flask_mako import MakoTemplates, render_template | ||||
| import calefaction | import calefaction | ||||
| @@ -11,8 +11,10 @@ from calefaction.auth import AuthManager | |||||
| from calefaction.config import Config | from calefaction.config import Config | ||||
| from calefaction.database import Database | from calefaction.database import Database | ||||
| from calefaction.eve import EVE | 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__) | app = Flask(__name__) | ||||
| @@ -21,6 +23,9 @@ config = Config(basepath / "config") | |||||
| Database.path = str(basepath / "data" / "db.sqlite3") | Database.path = str(basepath / "data" / "db.sqlite3") | ||||
| eve = EVE(config) | eve = EVE(config) | ||||
| auth = AuthManager(config, eve) | 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) | MakoTemplates(app) | ||||
| set_up_asset_versioning(app) | set_up_asset_versioning(app) | ||||
| @@ -37,32 +42,44 @@ app.before_request(Database.pre_hook) | |||||
| app.teardown_appcontext(Database.post_hook) | app.teardown_appcontext(Database.post_hook) | ||||
| @app.route("/") | @app.route("/") | ||||
| @catch_errors(app) | |||||
| @catch_exceptions | |||||
| def index(): | 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("home.mako") | ||||
| return render_template("landing.mako") | return render_template("landing.mako") | ||||
| @app.route("/login", methods=["GET", "POST"]) | @app.route("/login", methods=["GET", "POST"]) | ||||
| @catch_errors(app) | |||||
| @catch_exceptions | |||||
| def login(): | def login(): | ||||
| code = request.args.get("code") | code = request.args.get("code") | ||||
| state = request.args.get("state") | 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) | 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__": | if __name__ == "__main__": | ||||
| app.run(debug=True, port=8080) | app.run(debug=True, port=8080) | ||||
| @@ -238,3 +238,11 @@ class AuthManager: | |||||
| g.db.attach_session(sid, char_id) | g.db.attach_session(sid, char_id) | ||||
| g.db.touch_session(sid) | g.db.touch_session(sid) | ||||
| return True | 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 os import path | ||||
| from traceback import format_exc | from traceback import format_exc | ||||
| from flask import url_for | |||||
| from flask import flash, url_for | |||||
| from flask_mako import render_template, TemplateError | 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.""" | """Wrap a route to display and log any uncaught exceptions.""" | ||||
| def callback(func): | def callback(func): | ||||
| @wraps(func) | @wraps(func) | ||||
| @@ -19,10 +40,24 @@ def catch_errors(app): | |||||
| return func(*args, **kwargs) | return func(*args, **kwargs) | ||||
| except TemplateError as exc: | except TemplateError as exc: | ||||
| app.logger.error("Caught exception:\n{0}".format(exc.text)) | 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: | except Exception: | ||||
| app.logger.exception("Caught 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 inner | ||||
| return callback | return callback | ||||
| @@ -40,8 +75,8 @@ def set_up_asset_versioning(app): | |||||
| if cache and cache[0] == mtime: | if cache and cache[0] == mtime: | ||||
| hashstr = cache[1] | hashstr = cache[1] | ||||
| else: | 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) | app._hash_cache[fpath] = (mtime, hashstr) | ||||
| return url_for("static", filename=filename, v=hashstr) | return url_for("static", filename=filename, v=hashstr) | ||||
| raise error | raise error | ||||
| @@ -149,3 +149,23 @@ footer ul li:not(:last-child):after { | |||||
| #error pre { | #error pre { | ||||
| white-space: pre-wrap; | 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 id="container"> | ||||
| <div> | <div> | ||||
| <main> | <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()} | ${next.body()} | ||||
| </main> | </main> | ||||
| </div> | </div> | ||||
| @@ -6,6 +6,6 @@ | |||||
| </nav> | </nav> | ||||
| </%block> | </%block> | ||||
| <%block name="righthead"> | <%block name="righthead"> | ||||
| PLAYER_NAME... [logout] | |||||
| PLAYER_NAME... [logout] <!-- use GET /logout here and JS switch it to a POST form --> | |||||
| </%block> | </%block> | ||||
| ${next.body()} | ${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> | |||||