From d29d4b12cdf4274eeda9e944e705d5ef54c21ec7 Mon Sep 17 00:00:00 2001 From: Ben Kurtovic Date: Mon, 19 Dec 2016 03:09:42 -0500 Subject: [PATCH] Flesh out database, authentication, EVE SSO, docstrings. --- app.py | 32 +++++-- calefaction/__init__.py | 1 + calefaction/auth.py | 226 +++++++++++++++++++++++++++++++++++++++++--- calefaction/config.py | 9 ++ calefaction/database.py | 149 ++++++++++++++++++++++++++++- calefaction/eve/__init__.py | 33 ++++++- calefaction/eve/clock.py | 2 + calefaction/eve/esi.py | 13 +++ calefaction/eve/image.py | 13 +++ calefaction/eve/sso.py | 75 +++++++++++++++ calefaction/exceptions.py | 13 +++ calefaction/util.py | 8 +- config/config.yml.sample | 2 + data/schema.sql | 18 ++-- templates/home.mako | 7 ++ 15 files changed, 571 insertions(+), 30 deletions(-) create mode 100644 calefaction/eve/esi.py create mode 100644 calefaction/exceptions.py create mode 100644 templates/home.mako diff --git a/app.py b/app.py index 13f1ced..a80349d 100755 --- a/app.py +++ b/app.py @@ -3,27 +3,27 @@ from pathlib import Path -from flask import Flask, g +from flask import Flask, g, redirect, request, url_for from flask_mako import MakoTemplates, render_template -from werkzeug.local import LocalProxy import calefaction from calefaction.auth import AuthManager from calefaction.config import Config from calefaction.database import Database from calefaction.eve import EVE -from calefaction.util import catch_errors, set_up_hash_versioning +from calefaction.exceptions import AccessDeniedError, EVEAPIError +from calefaction.util import catch_errors, set_up_asset_versioning app = Flask(__name__) basepath = Path(__file__).resolve().parent config = Config(basepath / "config") Database.path = str(basepath / "data" / "db.sqlite3") -eve = EVE() +eve = EVE(config) auth = AuthManager(config, eve) MakoTemplates(app) -set_up_hash_versioning(app) +set_up_asset_versioning(app) config.install(app) @app.before_request @@ -39,12 +39,30 @@ app.teardown_appcontext(Database.post_hook) @app.route("/") @catch_errors(app) def index(): + ... # handle flashed error messages in _base.mako + if auth.is_authenticated(): # ... need to check for exceptions + return render_template("home.mako") return render_template("landing.mako") -@app.route("/login") +@app.route("/login", methods=["GET", "POST"]) @catch_errors(app) def login(): - return "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 + return redirect(url_for("index"), 303) + +# @app.route("/logout") ... + +# @auth.route_restricted ... +# check for same exceptions as login() and use same flashes if __name__ == "__main__": app.run(debug=True, port=8080) diff --git a/calefaction/__init__.py b/calefaction/__init__.py index 1f7f0b3..d3bf032 100644 --- a/calefaction/__init__.py +++ b/calefaction/__init__.py @@ -1 +1,2 @@ __version__ = "0.1.dev0" +__release__ = "0.1" diff --git a/calefaction/auth.py b/calefaction/auth.py index 7a5ab5c..c945793 100644 --- a/calefaction/auth.py +++ b/calefaction/auth.py @@ -1,36 +1,240 @@ # -*- coding: utf-8 -*- +from datetime import datetime, timedelta + from flask import g, session, url_for -from itsdangerous import URLSafeSerializer +from itsdangerous import BadSignature, URLSafeSerializer + +from .exceptions import AccessDeniedError __all__ = ["AuthManager"] _SCOPES = ["publicData", "characterAssetsRead"] # ... class AuthManager: + """Authentication manager. Handles user access and management.""" + EXPIRY_THRESHOLD = 30 def __init__(self, config, eve): self._config = config self._eve = eve - def _new_session_id(self): - with g.db as conn: - cur = conn.execute("INSERT INTO session DEFAULT VALUES") - return cur.lastrowid - - def get_session_id(self): + def _get_session_id(self): + """Return the current session ID, allocating a new one if necessary.""" if "id" not in session: - session["id"] = self._new_session_id() + session["id"] = g.db.new_session() + g._session_checked = True + g._session_expired = False return session["id"] - def get_state_hash(self): + def _invalidate_session(self): + """Mark the current session as invalid. + + Remove it from the database and from the user's cookies. + """ + if "id" in session: + g.db.drop_session(session["id"]) + del session["id"] + + def _check_session(self): + """Return whether the user has a valid, non-expired session. + + This checks for the session existing in the database, but does not + check that the user is logged in or has any particular access roles. + """ + if "id" not in session: + return False + + if hasattr(g, "_session_checked"): + return g._session_checked + + g._session_checked = check = g.db.has_session(session["id"]) + if not check: + g._session_expired = True + self._invalidate_session() + return check + + def _get_state_hash(self): + """Return a hash of the user's session ID suitable for OAuth2 state. + + Allocates a new session ID if necessary. + """ + key = self._config.get("auth.session_key") + serializer = URLSafeSerializer(key) + return serializer.dumps(self._get_session_id()) + + def _verify_state_hash(self, state): + """Confirm that a state hash is correct for the user's session. + + Assumes we've already checked the session ID. If the state is invalid, + the session will be invalidated. + """ key = self._config.get("auth.session_key") serializer = URLSafeSerializer(key) - return serializer.dumps(self.get_session_id()) + try: + value = serializer.loads(state) + except BadSignature: + self._invalidate_session() + return False + if value != session["id"]: + self._invalidate_session() + return False + return True + + def _fetch_new_token(self, code, refresh=False): + """Given an auth code or refresh token, get a new token and other data. + + If refresh is True, code should be a refresh token, otherwise an auth + code. If successful, we'll return a 5-tuple of (access_token, + token_expiry, refresh_token, char_id, char_name). If the token was + invalid, we'll return None. We may also raise EVEAPIError if there was + an internal API error. + """ + cid = self._config.get("auth.client_id") + secret = self._config.get("auth.client_secret") + result = self._eve.sso.get_access_token(cid, secret, code, refresh) + if not result: + return None + + token, expiry, refresh = result + expires = datetime.utcnow() + timedelta(seconds=expiry) + + result = self._eve.sso.get_character_info(token) + if not result: + return None + + char_id, char_name = result + return token, expires, refresh, char_id, char_name + + def _get_token(self, cid): + """Return a valid access token for the given character, or None. + + If the database doesn't have an auth entry for this character, return + None. If the database's token is expired but the refresh token is + valid, then refresh it, update the database, and return the new token. + If the token has become invalid and couldn't be refreshed, drop the + auth information from the database and return None. + """ + result = g.db.get_auth(cid) + if not result: + return None + + token, expires, refresh = result + seconds_til_expiry = (expires - datetime.utcnow()).total_seconds() + if seconds_til_expiry >= self.EXPIRY_THRESHOLD: + return token + + result = self._fetch_new_token(refresh, refresh=True) + if not result: + g.db.drop_auth(cid) + return None + + token, expires, refresh, char_id, char_name = result + if char_id != cid: + g.db.drop_auth(cid) + return None + + g.db.put_character(cid, char_name) + g.db.update_auth(cid, token, expires, refresh) + return token + + def _check_access(self, token, char_id): + """"Check whether the given character is allowed to access this site. + + If allowed, do nothing. If not, raise AccessDeniedError. + """ + resp = self._eve.esi(token).v3.characters(char_id).get() + if resp.get("corporation_id") != self._config.get("corp.id"): + g.db.drop_auth(char_id) + self._invalidate_session() + raise AccessDeniedError() + + def get_character_id(self): + """Return the character ID associated with the current session. + + Returns None if the session is invalid or is not associated with a + character. + """ + if not self._check_session(): + return None + + if not hasattr(g, "_character_id"): + g._character_id = g.db.read_session(session["id"]) + return g._character_id + + def get_character_prop(self, prop): + """Look up a property for the current session's character. + + Returns None if the session is invalid, is not associated with a + character, or the property has no non-default value. + """ + cid = self.get_character_id() + if not cid: + return None + + if not hasattr(g, "_character_props"): + g._character_props = g.db.read_character(cid) + return g._character_props.get(prop) + + def is_authenticated(self): + """Return whether the user has permission to access this site. + + We confirm that they have a valid, non-expired session that is + associated with a character that is permitted to be here. + + EVEAPIError or AccessDeniedError may be raised. + """ + cid = self.get_character_id() + if not cid: + return False + + token = self._get_token(cid) + if not token: + self._invalidate_session() + return False + + self._check_access(token, cid) + + g.db.touch_session(session["id"]) + return True def make_login_link(self): + """Return a complete EVE SSO link that the user can use to log in.""" cid = self._config.get("auth.client_id") target = url_for("login", _external=True, _scheme=self._config.scheme) scopes = _SCOPES - state = self.get_state_hash() + state = self._get_state_hash() return self._eve.sso.get_authorize_url(cid, target, scopes, state) + + def handle_login(self, code, state): + """Given an OAuth2 code and state, try to authenticate the user. + + If the user has a legitimate session and the state is valid, we'll + check the code with EVE SSO to fetch an authentication token. If the + token corresponds to a character that is allowed to access the site, + we'll update their session to indicate so. + + Return whether authentication was successful. EVEAPIError or + AccessDeniedError may be raised. + """ + if not code or not state: + return False + if not self._check_session(): + return False + if not self._verify_state_hash(state): + return False + + result = self._fetch_new_token(code) + if not result: + self._invalidate_session() + return False + + token, expires, refresh, char_id, char_name = result + self._check_access(token, char_id) + + sid = session["id"] + g.db.put_character(char_id, char_name) + g.db.set_auth(char_id, token, expires, refresh) + g.db.attach_session(sid, char_id) + g.db.touch_session(sid) + return True diff --git a/calefaction/config.py b/calefaction/config.py index 31d0b49..7345685 100644 --- a/calefaction/config.py +++ b/calefaction/config.py @@ -5,6 +5,7 @@ import yaml __all__ = ["Config"] class Config: + """Stores application-wide configuration info.""" def __init__(self, confdir): self._filename = confdir / "config.yml" @@ -12,10 +13,16 @@ class Config: self._load() def _load(self): + """Load config from the config file.""" with self._filename.open("rb") as fp: self._data = yaml.load(fp) def get(self, key="", default=None): + """Acts like a dict lookup in the config file. + + Dots can be used to separate keys. For example, + config["foo.bar"] == config["foo"]["bar"]. + """ obj = self._data for item in key.split("."): if item not in obj: @@ -25,9 +32,11 @@ class Config: @property def scheme(self): + """Return the site's configured scheme, either "http" or "https".""" return "https" if self.get("site.https") else "http" def install(self, app): + """Install relevant config parameters into the application.""" app.config["SERVER_NAME"] = self.get("site.canonical") app.config["PREFERRED_URL_SCHEME"] = self.scheme app.secret_key = self.get("auth.session_key") diff --git a/calefaction/database.py b/calefaction/database.py index 74f3a4e..3d8dbc2 100644 --- a/calefaction/database.py +++ b/calefaction/database.py @@ -1,5 +1,7 @@ # -*- coding: utf-8 -*- +from datetime import datetime +import random import sqlite3 from flask import g @@ -8,13 +10,16 @@ from werkzeug.local import LocalProxy __all__ = ["Database"] class Database: + """Database manager for low-level authentication actions.""" + MAX_SESSION_STALENESS = 30 * 60 # 30 minutes + MAX_SESSION_AGE = 6 * 60 * 60 # 6 hours + SESSION_GRACE = 60 * 60 # 1 hour path = None def __init__(self): if self.path is None: raise RuntimeError("Database.path not set") self._conn = sqlite3.connect(self.path) - import traceback def __enter__(self): return self._conn.__enter__() @@ -24,18 +29,160 @@ class Database: @classmethod def _get(cls): + """Return the current database, or allocate a new one if necessary.""" if not hasattr(g, "_db"): g._db = cls() return g._db @classmethod def pre_hook(cls): + """Hook to be called before a request context. + + Sets up the g.db proxy. + """ g.db = LocalProxy(cls._get) @classmethod def post_hook(cls, exc): + """Hook to be called when tearing down an application context. + + Closes the database if necessary. + """ if hasattr(g, "_db"): g._db.close() def close(self): + """Close the database connection.""" return self._conn.close() + + def _clear_old_sessions(self): + """Remove old sessions from the database. + + Sessions can expire if they are not touched (accessed) in a certain + period of time, or if their absolute age exceeds some number. We don't + actually remove them until a bit after this time. + """ + query = """DELETE FROM session WHERE + strftime("%s", "now") - strftime("%s", session_created) >= {} OR + strftime("%s", "now") - strftime("%s", session_touched) >= {}""" + create_thresh = self.MAX_SESSION_AGE + self.SESSION_GRACE + touch_thresh = self.MAX_SESSION_STALENESS + self.SESSION_GRACE + + with self._conn as conn: + conn.execute(query.format(create_thresh, touch_thresh)) + + def new_session(self): + """Allocate a new session in the database and return its ID.""" + with self._conn as conn: + cur = conn.execute("INSERT INTO session DEFAULT VALUES") + return cur.lastrowid + + def has_session(self, sid): + """Return whether the given session ID exists in the database. + + Will only be True for non-expired sessions. This function randomly does + database maintenance; very old expired sessions may be cleared. + """ + if random.random() <= 0.2: + self._clear_old_sessions() + + query = """SELECT 1 FROM session + WHERE session_id = ? AND + strftime("%s", "now") - strftime("%s", session_created) < {} AND + strftime("%s", "now") - strftime("%s", session_touched) < {}""" + query = query.format(self.MAX_SESSION_AGE, self.MAX_SESSION_STALENESS) + + cur = self._conn.execute(query, (sid,)) + return bool(cur.fetchall()) + + def read_session(self, sid): + """Return the character associated with the given session, or None.""" + query = """SELECT session_character FROM session + WHERE session_id = ? AND + strftime("%s", "now") - strftime("%s", session_created) < {} AND + strftime("%s", "now") - strftime("%s", session_touched) < {}""" + query = query.format(self.MAX_SESSION_AGE, self.MAX_SESSION_STALENESS) + + res = self._conn.execute(query, (sid,)).fetchall() + return res[0][0] if res else None + + def touch_session(self, sid): + """Update the given session's last access timestamp.""" + query = """UPDATE session + SET session_touched = CURRENT_TIMESTAMP + WHERE session_id = ?""" + + with self._conn as conn: + conn.execute(query, (sid,)) + + def attach_session(self, sid, cid): + """Attach the given session to a character. Does not touch it.""" + query = """UPDATE session + SET session_character = ? + WHERE session_id = ?""" + + with self._conn as conn: + conn.execute(query, (cid, sid)) + + def drop_session(self, sid): + """Remove the given session from the database.""" + with self._conn as conn: + conn.execute("DELETE FROM session WHERE session_id = ?", (sid,)) + + def put_character(self, cid, name): + """Put a character into the database if they don't already exist.""" + with self._conn as conn: + cur = conn.execute("BEGIN TRANSACTION") + cur.execute( + """UPDATE character SET character_name = ? + WHERE character_id = ?""", (name, cid)) + if cur.rowcount == 0: + cur.execute( + """INSERT INTO character (character_id, character_name) + VALUES (?, ?)""", (cid, name)) + + def read_character(self, cid): + """Return a dictionary of properties for the given character.""" + query = """SELECT character_name, character_style + FROM character WHERE character_id = ?""" + res = self._conn.execute(query, (cid,)).fetchall() + return {"name": res[0][0], "style": res[0][1]} if res else {} + + def set_auth(self, cid, token, expires, refresh): + """Set the authentication info for the given character.""" + with self._conn as conn: + conn.execute("""INSERT OR REPLACE INTO auth + (auth_character, auth_token, auth_token_expiry, auth_refresh) + VALUES (?, ?, ?, ?)""", (cid, token, expires, refresh)) + + def update_auth(self, cid, token, expires, refresh): + """Update the authentication info for the given character. + + Functionally equivalent to set_auth provided that the character has an + existing auth entry, but is more efficient. + """ + with self._conn as conn: + conn.execute("""UPDATE auth + SET auth_token = ?, auth_token_expiry = ?, auth_refresh = ? + WHERE auth_character = ?""", (token, expires, refresh, cid)) + + def get_auth(self, cid): + """Return authentication info for the given character. + + Return a 3-tuple of (access_token, token_expiry, refresh_token), or + None if there is no auth info. + """ + query = """SELECT auth_token, auth_token_expiry, auth_refresh + FROM auth WHERE auth_character = ?""" + res = self._conn.execute(query, (cid,)).fetchall() + if not res: + return None + + token, expiry, refresh = res[0] + expires = datetime.strptime(expiry, "%Y-%m-%d %H:%M:%S.%f") + return token, expires, refresh + + def drop_auth(self, cid): + """Drop any authentication info for the given character.""" + with self._conn as conn: + conn.execute("DELETE FROM auth WHERE auth_character = ?", (cid,)) diff --git a/calefaction/eve/__init__.py b/calefaction/eve/__init__.py index 96b428e..2ae5a2e 100644 --- a/calefaction/eve/__init__.py +++ b/calefaction/eve/__init__.py @@ -1,26 +1,55 @@ # -*- coding: utf-8 -*- +import platform + +import requests + from .clock import Clock +from .esi import EVESwaggerInterface from .image import ImageServer from .sso import SSOManager +from .. import __release__ __all__ = ["EVE"] class EVE: + """Interface to EVE's various APIs.""" + + def __init__(self, config): + session = requests.Session() + agent = self._get_user_agent(config.get("site.contact")) + session.headers["User-Agent"] = agent - def __init__(self): self._clock = Clock() + self._esi = EVESwaggerInterface(session) self._image = ImageServer() - self._sso = SSOManager() + self._sso = SSOManager(session) + + @staticmethod + def _get_user_agent(contact): + """Return the user agent when accessing APIs.""" + template = ("Calefaction/{} ({}; Python/{}; {}; " + "https://github.com/earwig/calefaction)") + return template.format( + __release__, requests.utils.default_user_agent(), + platform.python_version(), contact) @property def clock(self): + """The Clock API module.""" return self._clock @property + def esi(self): + """The EVE Swagger Interface API module.""" + return self._esi + + @property def image(self): + """The ImageServer API module.""" return self._image @property def sso(self): + """The Single Sign-On API module.""" return self._sso diff --git a/calefaction/eve/clock.py b/calefaction/eve/clock.py index 1272c7e..bc21d3c 100644 --- a/calefaction/eve/clock.py +++ b/calefaction/eve/clock.py @@ -7,7 +7,9 @@ __all__ = ["Clock"] _YEAR_DELTA = 1898 class Clock: + """EVE API module for the in-game clock.""" def now(self): + """Return the current date and time in the YC calendar as a string.""" dt = datetime.utcnow() return str(dt.year - _YEAR_DELTA) + dt.strftime("-%m-%d %H:%M") diff --git a/calefaction/eve/esi.py b/calefaction/eve/esi.py new file mode 100644 index 0000000..3727c67 --- /dev/null +++ b/calefaction/eve/esi.py @@ -0,0 +1,13 @@ +# -*- coding: utf-8 -*- + +__all__ = ["EVESwaggerInterface"] + +class EVESwaggerInterface: + """EVE API module for the EVE Swagger Interface (ESI).""" + + def __init__(self, session): + self._session = session + + def __call__(self, *args): + ... + raise NotImplementedError() diff --git a/calefaction/eve/image.py b/calefaction/eve/image.py index 9d5cb6f..53004b5 100644 --- a/calefaction/eve/image.py +++ b/calefaction/eve/image.py @@ -3,48 +3,61 @@ __all__ = ["ImageServer"] class ImageServer: + """EVE API module for the image server.""" def __init__(self): self._url = "https://imageserver.eveonline.com/" @property def alliance_widths(self): + """Return a list of valid widths for alliance logos.""" return [32, 64, 128] @property def corp_widths(self): + """Return a list of valid widths for corporation logos.""" return [32, 64, 128, 256] @property def character_widths(self): + """Return a list of valid widths for character portraits.""" return [32, 64, 128, 256, 512, 1024] @property def faction_widths(self): + """Return a list of valid widths for faction logos.""" return [32, 64, 128] @property def inventory_widths(self): + """Return a list of valid widths for inventory item images.""" return [32, 64] @property def render_widths(self): + """Return a list of valid widths for ship render images.""" return [32, 64, 128, 256, 512] def alliance(self, id, width): + """Return a URL for an alliance logo.""" return self._url + "Alliance/{}_{}.png".format(id, width) def corp(self, id, width): + """Return a URL for a corporation logo.""" return self._url + "Corporation/{}_{}.png".format(id, width) def character(self, id, width): + """Return a URL for a character portrait.""" return self._url + "Character/{}_{}.jpg".format(id, width) def faction(self, id, width): + """Return a URL for a faction logo.""" return self._url + "Alliance/{}_{}.jpg".format(id, width) def inventory(self, id, width): + """Return a URL for an inventory item image.""" return self._url + "Type/{}_{}.jpg".format(id, width) def render(self, id, width): + """Return a URL for ship render image.""" return self._url + "Render/{}_{}.jpg".format(id, width) diff --git a/calefaction/eve/sso.py b/calefaction/eve/sso.py index 88b8e15..312d546 100644 --- a/calefaction/eve/sso.py +++ b/calefaction/eve/sso.py @@ -2,12 +2,21 @@ from urllib.parse import urlencode +import requests + +from ..exceptions import EVEAPIError + __all__ = ["SSOManager"] class SSOManager: + """EVE API module for Single Sign-On (SSO).""" + + def __init__(self, session): + self._session = session def get_authorize_url(self, client_id, redirect_uri, scopes=None, state=None): + """Return a URL that the end user can use to start a login.""" baseurl = "https://login.eveonline.com/oauth/authorize?" params = { "response_type": "code", @@ -19,3 +28,69 @@ class SSOManager: if state is not None: params["state"] = state return baseurl + urlencode(params) + + def get_access_token(self, client_id, client_secret, code, refresh=False): + """Given an auth code or refresh token, return an access token. + + If refresh is True, code should be a refresh token. Otherwise, it + should be an authorization code. + + Does a step of OAuth2 and returns a 3-tuple of (access_token, + token_expiry, refresh_token) if successful. Returns None if one of the + arguments is not valid. Raises EVEAPIError if the API did not respond + in a sensible way or looks to be down. + """ + url = "https://login.eveonline.com/oauth/token" + params = {"code": code} + if refresh: + params["grant_type"] = "refresh_token" + else: + params["grant_type"] = "authorization_code" + + try: + resp = self._session.post(url, data=params, timeout=10, + auth=(client_id, client_secret)) + json = resp.json() + except (requests.RequestException, ValueError): + raise EVEAPIError() + + if not resp.ok or "error" in json: + return None + + if json.get("token_type") != "Bearer": + raise EVEAPIError() + + token = json.get("access_token") + expiry = json.get("expires_in") + refresh = json.get("refresh_token") + + if not token or not expiry or not refresh: + raise EVEAPIError() + + return token, expiry, refresh + + def get_character_info(self, token): + """Given an access token, return character info. + + If successful, returns a 2-tuple of (character_id, character_name). + Returns None if the token isn't valid. Raises EVEAPIError if the API + did not respond in a sensible way or looks to be down. + """ + url = "https://login.eveonline.com/oauth/verify" + headers = {"Authorization": "Bearer " + token} + try: + resp = self._session.get(url, timeout=10, headers=headers) + json = resp.json() + except (requests.RequestException, ValueError): + raise EVEAPIError() + + if not resp.ok or "error" in json: + return None + + char_id = json.get("CharacterID") + char_name = json.get("CharacterName") + + if not char_id or not char_name: + raise EVEAPIError() + + return char_id, char_name diff --git a/calefaction/exceptions.py b/calefaction/exceptions.py new file mode 100644 index 0000000..980cab2 --- /dev/null +++ b/calefaction/exceptions.py @@ -0,0 +1,13 @@ +# -*- coding: utf-8 -*- + +class CalefactionError(RuntimeError): + """Base exception class for errors within Calefaction.""" + pass + +class EVEAPIError(CalefactionError): + """Represents (generally external) errors while using the EVE APIs.""" + pass + +class AccessDeniedError(CalefactionError): + """The user tried to do something they don't have permission for.""" + pass diff --git a/calefaction/util.py b/calefaction/util.py index cf9d46e..85ea51b 100644 --- a/calefaction/util.py +++ b/calefaction/util.py @@ -8,9 +8,10 @@ from traceback import format_exc from flask import url_for from flask_mako import render_template, TemplateError -__all__ = ["catch_errors", "set_up_hash_versioning"] +__all__ = ["catch_errors", "set_up_asset_versioning"] def catch_errors(app): + """Wrap a route to display and log any uncaught exceptions.""" def callback(func): @wraps(func) def inner(*args, **kwargs): @@ -25,7 +26,8 @@ def catch_errors(app): return inner return callback -def set_up_hash_versioning(app): +def set_up_asset_versioning(app): + """Add a staticv endpoint that adds hash versioning to static assets.""" def callback(app, error, endpoint, values): if endpoint == "staticv": filename = values["filename"] @@ -45,4 +47,4 @@ def set_up_hash_versioning(app): raise error app._hash_cache = {} - app.url_build_error_handlers.append(lambda *args: callback(app, *args)) + app.url_build_error_handlers.append(lambda a, b, c: callback(app, a, b, c)) diff --git a/config/config.yml.sample b/config/config.yml.sample index beebfe6..f1472eb 100644 --- a/config/config.yml.sample +++ b/config/config.yml.sample @@ -8,6 +8,8 @@ site: # Assume HTTPS? This affects how URLs are generated, not how the site is # served (setting up TLS is your responsibility): https: yes + # Contact info reported in the User-Agent when making requests to EVE's API: + contact: webmaster@example.com corp: # You need to reset the database if this value is changed in the future. diff --git a/data/schema.sql b/data/schema.sql index 20fcd4e..c2b7bf3 100644 --- a/data/schema.sql +++ b/data/schema.sql @@ -4,7 +4,8 @@ DROP TABLE IF EXISTS session; CREATE TABLE session ( session_id INTEGER PRIMARY KEY AUTOINCREMENT, - session_character INTEGER DEFAULT 0, + session_character INTEGER DEFAULT NULL, + session_created TIMESTAMP DEFAULT CURRENT_TIMESTAMP, session_touched TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); @@ -13,9 +14,14 @@ DROP TABLE IF EXISTS character; CREATE TABLE character ( character_id INTEGER PRIMARY KEY, character_name TEXT, - character_token BLOB, - character_refresh BLOB, - character_token_expiry TIMESTAMP, - character_last_verify TIMESTAMP, - character_style TEXT + character_style TEXT DEFAULT NULL +); + +DROP TABLE IF EXISTS auth; + +CREATE TABLE auth ( + auth_character INTEGER PRIMARY KEY, + auth_token BLOB, + auth_refresh BLOB, + auth_token_expiry TIMESTAMP ); diff --git a/templates/home.mako b/templates/home.mako new file mode 100644 index 0000000..ff548be --- /dev/null +++ b/templates/home.mako @@ -0,0 +1,7 @@ +<%inherit file="_default.mako"/> +
+

Hi, ${g.auth.get_character_prop("name")}!

+ % for paragraph in g.config.get("welcome").split("\n\n"): +

${paragraph.replace("\n", " ")}

+ % endfor +