| @@ -6,3 +6,4 @@ config/* | |||
| data/* | |||
| !config/config.yml.sample | |||
| !data/schema.sql | |||
| @@ -17,8 +17,9 @@ Guide | |||
| ### Setup | |||
| cp config.yml.sample config.yml | |||
| vim config.yml | |||
| cp config/config.yml.sample config/config.yml | |||
| vim config/config.yml # follow instructions | |||
| cat data/schema.sql | sqlite3 data/db.sqlite3 | |||
| ... | |||
| ### Test | |||
| @@ -5,30 +5,46 @@ from pathlib import Path | |||
| from flask import Flask, g | |||
| 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 | |||
| basepath = Path(__file__).resolve().parent | |||
| app = Flask(__name__) | |||
| basepath = Path(__file__).resolve().parent | |||
| config = Config(basepath / "config") | |||
| Database.path = str(basepath / "data" / "db.sqlite3") | |||
| eve = EVE() | |||
| auth = AuthManager(config, eve) | |||
| MakoTemplates(app) | |||
| set_up_hash_versioning(app) | |||
| config.install(app) | |||
| @app.before_request | |||
| def prepare_request(): | |||
| g.auth = auth | |||
| g.config = config | |||
| g.eve = eve | |||
| g.version = calefaction.__version__ | |||
| app.before_request(Database.pre_hook) | |||
| app.teardown_appcontext(Database.post_hook) | |||
| @app.route("/") | |||
| @catch_errors(app) | |||
| def index(): | |||
| return render_template("landing.mako") | |||
| @app.route("/login") | |||
| @catch_errors(app) | |||
| def login(): | |||
| return "login" # ... | |||
| if __name__ == "__main__": | |||
| app.run(debug=True, port=8080) | |||
| @@ -0,0 +1,36 @@ | |||
| # -*- coding: utf-8 -*- | |||
| from flask import g, session, url_for | |||
| from itsdangerous import URLSafeSerializer | |||
| __all__ = ["AuthManager"] | |||
| _SCOPES = ["publicData", "characterAssetsRead"] # ... | |||
| class AuthManager: | |||
| 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): | |||
| if "id" not in session: | |||
| session["id"] = self._new_session_id() | |||
| return session["id"] | |||
| def get_state_hash(self): | |||
| key = self._config.get("auth.session_key") | |||
| serializer = URLSafeSerializer(key) | |||
| return serializer.dumps(self.get_session_id()) | |||
| def make_login_link(self): | |||
| cid = self._config.get("auth.client_id") | |||
| target = url_for("login", _external=True, _scheme=self._config.scheme) | |||
| scopes = _SCOPES | |||
| state = self.get_state_hash() | |||
| return self._eve.sso.get_authorize_url(cid, target, scopes, state) | |||
| @@ -22,3 +22,12 @@ class Config: | |||
| return default | |||
| obj = obj[item] | |||
| return obj | |||
| @property | |||
| def scheme(self): | |||
| return "https" if self.get("site.https") else "http" | |||
| def install(self, app): | |||
| app.config["SERVER_NAME"] = self.get("site.canonical") | |||
| app.config["PREFERRED_URL_SCHEME"] = self.scheme | |||
| app.secret_key = self.get("auth.session_key") | |||
| @@ -0,0 +1,41 @@ | |||
| # -*- coding: utf-8 -*- | |||
| import sqlite3 | |||
| from flask import g | |||
| from werkzeug.local import LocalProxy | |||
| __all__ = ["Database"] | |||
| class Database: | |||
| 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__() | |||
| def __exit__(self, exc_type, exc_value, trace): | |||
| return self._conn.__exit__(exc_type, exc_value, trace) | |||
| @classmethod | |||
| def _get(cls): | |||
| if not hasattr(g, "_db"): | |||
| g._db = cls() | |||
| return g._db | |||
| @classmethod | |||
| def pre_hook(cls): | |||
| g.db = LocalProxy(cls._get) | |||
| @classmethod | |||
| def post_hook(cls, exc): | |||
| if hasattr(g, "_db"): | |||
| g._db.close() | |||
| def close(self): | |||
| return self._conn.close() | |||
| @@ -2,6 +2,7 @@ | |||
| from .clock import Clock | |||
| from .image import ImageServer | |||
| from .sso import SSOManager | |||
| __all__ = ["EVE"] | |||
| @@ -10,6 +11,7 @@ class EVE: | |||
| def __init__(self): | |||
| self._clock = Clock() | |||
| self._image = ImageServer() | |||
| self._sso = SSOManager() | |||
| @property | |||
| def clock(self): | |||
| @@ -18,3 +20,7 @@ class EVE: | |||
| @property | |||
| def image(self): | |||
| return self._image | |||
| @property | |||
| def sso(self): | |||
| return self._sso | |||
| @@ -4,10 +4,10 @@ from datetime import datetime | |||
| __all__ = ["Clock"] | |||
| YEAR_DELTA = 1898 | |||
| _YEAR_DELTA = 1898 | |||
| class Clock: | |||
| def now(self): | |||
| dt = datetime.utcnow() | |||
| return str(dt.year - YEAR_DELTA) + dt.strftime("-%m-%d %H:%M") | |||
| return str(dt.year - _YEAR_DELTA) + dt.strftime("-%m-%d %H:%M") | |||
| @@ -0,0 +1,21 @@ | |||
| # -*- coding: utf-8 -*- | |||
| from urllib.parse import urlencode | |||
| __all__ = ["SSOManager"] | |||
| class SSOManager: | |||
| def get_authorize_url(self, client_id, redirect_uri, scopes=None, | |||
| state=None): | |||
| baseurl = "https://login.eveonline.com/oauth/authorize?" | |||
| params = { | |||
| "response_type": "code", | |||
| "redirect_uri": redirect_uri, | |||
| "client_id": client_id | |||
| } | |||
| if scopes: | |||
| params["scope"] = " ".join(scopes) | |||
| if state is not None: | |||
| params["state"] = state | |||
| return baseurl + urlencode(params) | |||
| @@ -2,14 +2,37 @@ | |||
| # Copy this to config.yml and modify it to set up your website. | |||
| # You must restart the server after making any changes. | |||
| site: | |||
| # Full canonical server name; include port if not default: | |||
| canonical: example.com | |||
| # Assume HTTPS? This affects how URLs are generated, not how the site is | |||
| # served (setting up TLS is your responsibility): | |||
| https: yes | |||
| corp: | |||
| # Find your corp's ID at, e.g., https://zkillboard.com/corporation/917701062/ | |||
| # You need to reset the database if this value is changed in the future. | |||
| # Find your corp's ID at e.g. https://zkillboard.com/corporation/917701062/: | |||
| id: 123456789 | |||
| # Full corp name (doesn't need to match in-game name exactly, but it should) | |||
| # Full corp name (doesn't need to match in-game name exactly, but it should): | |||
| name: My Corp Name Here | |||
| # Default stylesheet from static/styles/*.css: | |||
| # one of "amarr", "caldari", "gallente", "minmatar", or add your own | |||
| auth: | |||
| # Secure session signing key. Never share with anyone. Can generate with | |||
| # "import base64, os; base64.b64encode(os.urandom(24))": | |||
| session_key: sEQMbNbxRxHBhyGtt8cuLEMN6sDM1JcP | |||
| # You need to create an application at | |||
| # https://developers.eveonline.com/applications for this corp's website. | |||
| # Set the callback URL to http(s)://<your domain>/login (match the protocol | |||
| # with "site.https" above) and the scopes to: | |||
| # - publicData | |||
| # - ... | |||
| # SSO client ID: | |||
| client_id: a290afea820b8dd8c46d3883898ab66d | |||
| # SSO client secret: | |||
| client_secret: XXAPGc0LM6wdOJAwSNQmliZ2QhQpoBuUutQY6Rlc | |||
| # Default stylesheet from static/styles/*.css; | |||
| # one of "amarr", "caldari", "gallente", "minmatar", or create your own: | |||
| style: null | |||
| welcome: |- | |||
| @@ -0,0 +1,21 @@ | |||
| -- Schema for Calefaction's internal database | |||
| DROP TABLE IF EXISTS session; | |||
| CREATE TABLE session ( | |||
| session_id INTEGER PRIMARY KEY AUTOINCREMENT, | |||
| session_character INTEGER DEFAULT 0, | |||
| session_touched TIMESTAMP DEFAULT CURRENT_TIMESTAMP | |||
| ); | |||
| 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 | |||
| ); | |||
| @@ -34,7 +34,7 @@ a:hover { | |||
| } | |||
| main, header, footer { | |||
| background-color: rgba(0, 0, 0, 0.75); | |||
| background-color: rgba(0, 0, 0, 0.8); | |||
| border-color: #4A4A4A; | |||
| } | |||
| @@ -6,6 +6,7 @@ | |||
| <%block name="title">${g.config.get("corp.name") | h}</%block> | |||
| </title> | |||
| <meta name="viewport" content="width=device-width, initial-scale=1"> | |||
| <link rel="canonical" href="${g.config.scheme}://${g.config.get('site.canonical')}${request.script_root}${request.path}"> | |||
| <link rel="stylesheet" type="text/css" href="${url_for('staticv', filename='main.css')}"/> | |||
| % if g.config.get("style"): | |||
| <% stylesheet = "styles/{}.css".format(g.config.get("style")) %> | |||
| @@ -21,10 +22,10 @@ | |||
| <div> | |||
| <div class="left"> | |||
| <%block name="lefthead"> | |||
| <a href="/"> | |||
| <a href="${url_for('index')}"> | |||
| <img id="corp-masthead" class="aligned" title="Home" alt="Home" src="${g.eve.image.corp(g.config.get('corp.id'), 256)}"/> | |||
| </a> | |||
| <a href="/" class="aligned">${g.config.get("corp.name") | h}</a> | |||
| <a href="${url_for('index')}" class="aligned">${g.config.get("corp.name") | h}</a> | |||
| </%block> | |||
| </div> | |||
| <div class="right"> | |||
| @@ -1,6 +1,8 @@ | |||
| <%inherit file="_base.mako"/> | |||
| <%block name="righthead"> | |||
| <img id="login-button" class="aligned" src="${url_for('staticv', filename='images/eve-login.png')}"/> | |||
| <a href="${g.auth.make_login_link()}"> | |||
| <img id="login-button" class="aligned" title="Log in with EVE Online" alt="Log in with EVE Online" src="${url_for('staticv', filename='images/eve-login.png')}"/> | |||
| </a> | |||
| </%block> | |||
| <div id="welcome"> | |||
| % for paragraph in g.config.get("welcome").split("\n\n"): | |||