Browse Source

Flesh out database, authentication, EVE SSO, docstrings.

master
Ben Kurtovic 5 years ago
parent
commit
d29d4b12cd
15 changed files with 571 additions and 30 deletions
  1. +25
    -7
      app.py
  2. +1
    -0
      calefaction/__init__.py
  3. +215
    -11
      calefaction/auth.py
  4. +9
    -0
      calefaction/config.py
  5. +148
    -1
      calefaction/database.py
  6. +31
    -2
      calefaction/eve/__init__.py
  7. +2
    -0
      calefaction/eve/clock.py
  8. +13
    -0
      calefaction/eve/esi.py
  9. +13
    -0
      calefaction/eve/image.py
  10. +75
    -0
      calefaction/eve/sso.py
  11. +13
    -0
      calefaction/exceptions.py
  12. +5
    -3
      calefaction/util.py
  13. +2
    -0
      config/config.yml.sample
  14. +12
    -6
      data/schema.sql
  15. +7
    -0
      templates/home.mako

+ 25
- 7
app.py View File

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

+ 1
- 0
calefaction/__init__.py View File

@@ -1 +1,2 @@
__version__ = "0.1.dev0"
__release__ = "0.1"

+ 215
- 11
calefaction/auth.py View File

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

+ 9
- 0
calefaction/config.py View File

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

+ 148
- 1
calefaction/database.py View File

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

+ 31
- 2
calefaction/eve/__init__.py View File

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

+ 2
- 0
calefaction/eve/clock.py View File

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

+ 13
- 0
calefaction/eve/esi.py View File

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

+ 13
- 0
calefaction/eve/image.py View File

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

+ 75
- 0
calefaction/eve/sso.py View File

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

+ 13
- 0
calefaction/exceptions.py View File

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

+ 5
- 3
calefaction/util.py View File

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

+ 2
- 0
config/config.yml.sample View File

@@ -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.


+ 12
- 6
data/schema.sql View File

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

+ 7
- 0
templates/home.mako View File

@@ -0,0 +1,7 @@
<%inherit file="_default.mako"/>
<div id="welcome">
<p><em>Hi, ${g.auth.get_character_prop("name")}!</em></p>
% for paragraph in g.config.get("welcome").split("\n\n"):
<p>${paragraph.replace("\n", " ")}</p>
% endfor
</div>

Loading…
Cancel
Save