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