| @@ -6,3 +6,4 @@ config/* | |||||
| data/* | data/* | ||||
| !config/config.yml.sample | !config/config.yml.sample | ||||
| !data/schema.sql | |||||
| @@ -17,8 +17,9 @@ Guide | |||||
| ### Setup | ### 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 | ### Test | ||||
| @@ -5,30 +5,46 @@ from pathlib import Path | |||||
| from flask import Flask, g | from flask import Flask, g | ||||
| from flask_mako import MakoTemplates, render_template | from flask_mako import MakoTemplates, render_template | ||||
| from werkzeug.local import LocalProxy | |||||
| import calefaction | import calefaction | ||||
| from calefaction.auth import AuthManager | |||||
| from calefaction.config import Config | from calefaction.config import Config | ||||
| from calefaction.database import Database | |||||
| from calefaction.eve import EVE | from calefaction.eve import EVE | ||||
| from calefaction.util import catch_errors, set_up_hash_versioning | from calefaction.util import catch_errors, set_up_hash_versioning | ||||
| basepath = Path(__file__).resolve().parent | |||||
| app = Flask(__name__) | app = Flask(__name__) | ||||
| basepath = Path(__file__).resolve().parent | |||||
| config = Config(basepath / "config") | config = Config(basepath / "config") | ||||
| Database.path = str(basepath / "data" / "db.sqlite3") | |||||
| eve = EVE() | eve = EVE() | ||||
| auth = AuthManager(config, eve) | |||||
| MakoTemplates(app) | MakoTemplates(app) | ||||
| set_up_hash_versioning(app) | set_up_hash_versioning(app) | ||||
| config.install(app) | |||||
| @app.before_request | @app.before_request | ||||
| def prepare_request(): | def prepare_request(): | ||||
| g.auth = auth | |||||
| g.config = config | g.config = config | ||||
| g.eve = eve | g.eve = eve | ||||
| g.version = calefaction.__version__ | g.version = calefaction.__version__ | ||||
| app.before_request(Database.pre_hook) | |||||
| app.teardown_appcontext(Database.post_hook) | |||||
| @app.route("/") | @app.route("/") | ||||
| @catch_errors(app) | @catch_errors(app) | ||||
| def index(): | def index(): | ||||
| return render_template("landing.mako") | return render_template("landing.mako") | ||||
| @app.route("/login") | |||||
| @catch_errors(app) | |||||
| def login(): | |||||
| return "login" # ... | |||||
| if __name__ == "__main__": | if __name__ == "__main__": | ||||
| app.run(debug=True, port=8080) | 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 | return default | ||||
| obj = obj[item] | obj = obj[item] | ||||
| return obj | 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 .clock import Clock | ||||
| from .image import ImageServer | from .image import ImageServer | ||||
| from .sso import SSOManager | |||||
| __all__ = ["EVE"] | __all__ = ["EVE"] | ||||
| @@ -10,6 +11,7 @@ class EVE: | |||||
| def __init__(self): | def __init__(self): | ||||
| self._clock = Clock() | self._clock = Clock() | ||||
| self._image = ImageServer() | self._image = ImageServer() | ||||
| self._sso = SSOManager() | |||||
| @property | @property | ||||
| def clock(self): | def clock(self): | ||||
| @@ -18,3 +20,7 @@ class EVE: | |||||
| @property | @property | ||||
| def image(self): | def image(self): | ||||
| return self._image | return self._image | ||||
| @property | |||||
| def sso(self): | |||||
| return self._sso | |||||
| @@ -4,10 +4,10 @@ from datetime import datetime | |||||
| __all__ = ["Clock"] | __all__ = ["Clock"] | ||||
| YEAR_DELTA = 1898 | |||||
| _YEAR_DELTA = 1898 | |||||
| class Clock: | class Clock: | ||||
| def now(self): | def now(self): | ||||
| dt = datetime.utcnow() | 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. | # Copy this to config.yml and modify it to set up your website. | ||||
| # You must restart the server after making any changes. | # 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: | 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 | 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 | 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 | style: null | ||||
| welcome: |- | 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 { | main, header, footer { | ||||
| background-color: rgba(0, 0, 0, 0.75); | |||||
| background-color: rgba(0, 0, 0, 0.8); | |||||
| border-color: #4A4A4A; | border-color: #4A4A4A; | ||||
| } | } | ||||
| @@ -6,6 +6,7 @@ | |||||
| <%block name="title">${g.config.get("corp.name") | h}</%block> | <%block name="title">${g.config.get("corp.name") | h}</%block> | ||||
| </title> | </title> | ||||
| <meta name="viewport" content="width=device-width, initial-scale=1"> | <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')}"/> | <link rel="stylesheet" type="text/css" href="${url_for('staticv', filename='main.css')}"/> | ||||
| % if g.config.get("style"): | % if g.config.get("style"): | ||||
| <% stylesheet = "styles/{}.css".format(g.config.get("style")) %> | <% stylesheet = "styles/{}.css".format(g.config.get("style")) %> | ||||
| @@ -21,10 +22,10 @@ | |||||
| <div> | <div> | ||||
| <div class="left"> | <div class="left"> | ||||
| <%block name="lefthead"> | <%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)}"/> | <img id="corp-masthead" class="aligned" title="Home" alt="Home" src="${g.eve.image.corp(g.config.get('corp.id'), 256)}"/> | ||||
| </a> | </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> | </%block> | ||||
| </div> | </div> | ||||
| <div class="right"> | <div class="right"> | ||||
| @@ -1,6 +1,8 @@ | |||||
| <%inherit file="_base.mako"/> | <%inherit file="_base.mako"/> | ||||
| <%block name="righthead"> | <%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> | </%block> | ||||
| <div id="welcome"> | <div id="welcome"> | ||||
| % for paragraph in g.config.get("welcome").split("\n\n"): | % for paragraph in g.config.get("welcome").split("\n\n"): | ||||