This commit is contained in:
Minecon724 2025-04-13 16:37:46 +00:00
commit c5e2e70332
Signed by untrusted user who does not match committer: m724
GPG key ID: A02E6E67AB961189
30 changed files with 574 additions and 639 deletions

View file

@ -0,0 +1,27 @@
// For format details, see https://aka.ms/devcontainer.json. For config options, see the
// README at: https://github.com/devcontainers/templates/tree/main/src/python
{
"name": "Python 3",
// Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile
"image": "mcr.microsoft.com/devcontainers/python:1-3.12-bullseye",
"features": {
"ghcr.io/itsmechlark/features/postgresql:1": {},
"ghcr.io/itsmechlark/features/redis-server:1": {}
},
"postCreateCommand": "pipx install poetry; poetry install"
// Features to add to the dev container. More info: https://containers.dev/features.
// "features": {},
// Use 'forwardPorts' to make a list of ports inside the container available locally.
// "forwardPorts": [],
// Use 'postCreateCommand' to run commands after the container is created.
// "postCreateCommand": "pip3 install --user -r requirements.txt",
// Configure tool-specific properties.
// "customizations": {},
// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
// "remoteUser": "root"
}

View file

@ -1 +1,3 @@
TODO TODO
Use the devcontainer and `poetry run start`

138
poetry.lock generated
View file

@ -1,4 +1,32 @@
# This file is automatically @generated by Poetry 2.1.1 and should not be changed by hand. # This file is automatically @generated by Poetry 2.1.2 and should not be changed by hand.
[[package]]
name = "apscheduler"
version = "3.11.0"
description = "In-process task scheduler with Cron-like capabilities"
optional = false
python-versions = ">=3.8"
groups = ["main"]
files = [
{file = "APScheduler-3.11.0-py3-none-any.whl", hash = "sha256:fc134ca32e50f5eadcc4938e3a4545ab19131435e851abb40b34d63d5141c6da"},
{file = "apscheduler-3.11.0.tar.gz", hash = "sha256:4c622d250b0955a65d5d0eb91c33e6d43fd879834bf541e0a18661ae60460133"},
]
[package.dependencies]
tzlocal = ">=3.0"
[package.extras]
doc = ["packaging", "sphinx", "sphinx-rtd-theme (>=1.3.0)"]
etcd = ["etcd3", "protobuf (<=3.21.0)"]
gevent = ["gevent"]
mongodb = ["pymongo (>=3.0)"]
redis = ["redis (>=3.0)"]
rethinkdb = ["rethinkdb (>=2.4.0)"]
sqlalchemy = ["sqlalchemy (>=1.4)"]
test = ["APScheduler[etcd,mongodb,redis,rethinkdb,sqlalchemy,tornado,zookeeper]", "PySide6 ; platform_python_implementation == \"CPython\" and python_version < \"3.14\"", "anyio (>=4.5.2)", "gevent ; python_version < \"3.14\"", "pytest", "pytz", "twisted ; python_version < \"3.14\""]
tornado = ["tornado (>=4.3)"]
twisted = ["twisted"]
zookeeper = ["kazoo"]
[[package]] [[package]]
name = "argon2-cffi" name = "argon2-cffi"
@ -84,6 +112,22 @@ files = [
{file = "blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf"}, {file = "blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf"},
] ]
[[package]]
name = "bootstrap-flask"
version = "2.4.2"
description = "Bootstrap 4 & 5 helper for your Flask projects."
optional = false
python-versions = "*"
groups = ["main"]
files = [
{file = "bootstrap_flask-2.4.2-py3-none-any.whl", hash = "sha256:44b72e57e62a532b2578f6327fd584e75326b734e4141404b04003ab0b071f5c"},
{file = "bootstrap_flask-2.4.2.tar.gz", hash = "sha256:8dcd6d6ed5458cfb76da4d57d2277f447da08d7977d551c82f51919f62c9586c"},
]
[package.dependencies]
Flask = "*"
WTForms = "*"
[[package]] [[package]]
name = "cachelib" name = "cachelib"
version = "0.13.0" version = "0.13.0"
@ -282,6 +326,22 @@ Werkzeug = ">=3.1"
async = ["asgiref (>=3.2)"] async = ["asgiref (>=3.2)"]
dotenv = ["python-dotenv"] dotenv = ["python-dotenv"]
[[package]]
name = "flask-apscheduler"
version = "1.13.1"
description = "Adds APScheduler support to Flask"
optional = false
python-versions = ">=3.8"
groups = ["main"]
files = [
{file = "Flask-APScheduler-1.13.1.tar.gz", hash = "sha256:b929846f026fb339b76360b0e4fc75da78b75c6d08625715bd0d37949bd607da"},
]
[package.dependencies]
apscheduler = ">=3.2.0,<4.0.0"
flask = ">=2.2.5,<4.0.0"
python-dateutil = ">=2.4.2"
[[package]] [[package]]
name = "flask-limiter" name = "flask-limiter"
version = "3.12" version = "3.12"
@ -306,6 +366,22 @@ mongodb = ["limits[mongodb]"]
redis = ["limits[redis]"] redis = ["limits[redis]"]
valkey = ["limits[valkey]"] valkey = ["limits[valkey]"]
[[package]]
name = "flask-login"
version = "0.6.3"
description = "User authentication and session management for Flask."
optional = false
python-versions = ">=3.7"
groups = ["main"]
files = [
{file = "Flask-Login-0.6.3.tar.gz", hash = "sha256:5e23d14a607ef12806c699590b89d0f0e0d67baeec599d75947bf9c147330333"},
{file = "Flask_Login-0.6.3-py3-none-any.whl", hash = "sha256:849b25b82a436bf830a054e74214074af59097171562ab10bfa999e6b78aae5d"},
]
[package.dependencies]
Flask = ">=1.0.4"
Werkzeug = ">=1.0.1"
[[package]] [[package]]
name = "flask-session" name = "flask-session"
version = "0.8.0" version = "0.8.0"
@ -846,6 +922,21 @@ files = [
[package.extras] [package.extras]
windows-terminal = ["colorama (>=0.4.6)"] windows-terminal = ["colorama (>=0.4.6)"]
[[package]]
name = "python-dateutil"
version = "2.9.0.post0"
description = "Extensions to the standard Python datetime module"
optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7"
groups = ["main"]
files = [
{file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"},
{file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"},
]
[package.dependencies]
six = ">=1.5"
[[package]] [[package]]
name = "python-dotenv" name = "python-dotenv"
version = "1.1.0" version = "1.1.0"
@ -899,6 +990,18 @@ pygments = ">=2.13.0,<3.0.0"
[package.extras] [package.extras]
jupyter = ["ipywidgets (>=7.5.1,<9)"] jupyter = ["ipywidgets (>=7.5.1,<9)"]
[[package]]
name = "six"
version = "1.17.0"
description = "Python 2 and 3 compatibility utilities"
optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7"
groups = ["main"]
files = [
{file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"},
{file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"},
]
[[package]] [[package]]
name = "sqlalchemy" name = "sqlalchemy"
version = "2.0.40" version = "2.0.40"
@ -1007,6 +1110,37 @@ files = [
{file = "typing_extensions-4.13.2.tar.gz", hash = "sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef"}, {file = "typing_extensions-4.13.2.tar.gz", hash = "sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef"},
] ]
[[package]]
name = "tzdata"
version = "2025.2"
description = "Provider of IANA time zone data"
optional = false
python-versions = ">=2"
groups = ["main"]
markers = "platform_system == \"Windows\""
files = [
{file = "tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8"},
{file = "tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9"},
]
[[package]]
name = "tzlocal"
version = "5.3.1"
description = "tzinfo object for the local timezone"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "tzlocal-5.3.1-py3-none-any.whl", hash = "sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d"},
{file = "tzlocal-5.3.1.tar.gz", hash = "sha256:cceffc7edecefea1f595541dbd6e990cb1ea3d19bf01b2809f362a03dd7921fd"},
]
[package.dependencies]
tzdata = {version = "*", markers = "platform_system == \"Windows\""}
[package.extras]
devenv = ["check-manifest", "pytest (>=4.3)", "pytest-cov", "pytest-mock (>=3.3)", "zest.releaser"]
[[package]] [[package]]
name = "werkzeug" name = "werkzeug"
version = "3.1.3" version = "3.1.3"
@ -1147,4 +1281,4 @@ files = [
[metadata] [metadata]
lock-version = "2.1" lock-version = "2.1"
python-versions = ">=3.11" python-versions = ">=3.11"
content-hash = "21bbc12935994eeb014387c69841f9a8f2462939b4eb0675cf759c2f7999d917" content-hash = "a16f3abd2ab44cdb6b1db5d56dd3ac32688a8232449b6e477de9dee531268d9a"

View file

@ -21,7 +21,10 @@ dependencies = [
"flask-session (>=0.5.0,<1.0.0)", "flask-session (>=0.5.0,<1.0.0)",
"argon2-cffi (>=23.0.0,<24.0.0)", "argon2-cffi (>=23.0.0,<24.0.0)",
"zxcvbn (>=4.5.0,<5.0.0)", "zxcvbn (>=4.5.0,<5.0.0)",
"email-validator (>=2.2.0,<3.0.0)" "email-validator (>=2.2.0,<3.0.0)",
"flask-apscheduler (>=1.13.1,<2.0.0)",
"flask-login (>=0.6.3,<0.7.0)",
"bootstrap-flask (>=2.4.2,<3.0.0)"
] ]
[tool.poetry] [tool.poetry]

View file

@ -3,10 +3,8 @@ from flask import Flask
import os import os
from dotenv import load_dotenv from dotenv import load_dotenv
from flask import jsonify from flask import jsonify
from flask_wtf.csrf import CSRFProtect
from werkzeug.middleware.proxy_fix import ProxyFix from werkzeug.middleware.proxy_fix import ProxyFix
from argon2 import PasswordHasher from argon2 import PasswordHasher
# Load environment variables from .env file # Load environment variables from .env file
load_dotenv() load_dotenv()
@ -54,8 +52,11 @@ limiter = create_ratelimit(app)
from .database import create_database from .database import create_database
db = create_database(app) db = create_database(app)
from .auth.annonations import get_user from .login import create_login_manager
app.jinja_env.filters['user'] = get_user login_manager = create_login_manager(app)
from flask_bootstrap import Bootstrap5
bootstrap = Bootstrap5(app)
# Health check endpoint # Health check endpoint
@app.route('/health', methods=['GET']) @app.route('/health', methods=['GET'])
@ -65,11 +66,15 @@ def health_check():
from .error_handlers import register_error_handlers from .error_handlers import register_error_handlers
register_error_handlers(app) register_error_handlers(app)
# Import routes from .blueprints import register_blueprints
from .blueprints import auth, resource, publisher register_blueprints(app)
auth.register_routes(app)
resource.register_routes(app) from .scheduler import initialize_scheduler
publisher.register_routes(app) initialize_scheduler(app)
# TODO find a better way to do this
from .roles import Role
app.jinja_env.globals['Role'] = Role
# Flask CLI command to run migrations # Flask CLI command to run migrations
@app.cli.command("run-migrations") @app.cli.command("run-migrations")

View file

@ -1,32 +0,0 @@
from flask import session, flash, redirect, url_for, request, abort
from functools import wraps
from ..roles import Role
from ..models import User
def get_user_id() -> int | None:
return session.get('user_id')
def get_user() -> User | None:
user_id = user_id()
if user_id is None:
return None
return User.query.get(user_id)
def require_role(role: Role):
def decorator(f):
@wraps(f)
def decorated_function(*args, **kwargs):
user = get_user()
if user is None:
flash('You must be logged in to access this page', 'error')
return redirect(url_for('auth.login', next=request.url))
if user.role < role:
return abort(403)
return f(*args, **kwargs)
return decorated_function
return decorator

View file

@ -0,0 +1,10 @@
from flask import Flask
from . import auth, resource, publisher, pages, admin
def register_blueprints(app: Flask) -> None:
auth.register_routes(app)
resource.register_routes(app)
publisher.register_routes(app)
pages.register_routes(app)
admin.register_routes(app)

View file

@ -0,0 +1,52 @@
from flask import Blueprint, Flask, render_template, redirect, url_for, abort, request
from flask_login import login_required
from flask_wtf import FlaskForm
from wtforms import validators, StringField, SubmitField
from buybuilds.login import require_role
from buybuilds.roles import Role
from buybuilds import login_manager
blueprint = Blueprint(
name='admin',
import_name=__name__,
template_folder='templates',
url_prefix='/admin'
)
class ManageUserForm(FlaskForm):
email = StringField('Email', [validators.Email()])
username = StringField('Username', [validators.Length(min=2, max=30)])
reset_password = SubmitField('Reset Password', description="Emails the user a random password.")
submit = SubmitField('Apply changes')
def register_routes(app: Flask):
app.register_blueprint(blueprint)
@blueprint.route('/', methods=['GET'])
def index():
return redirect(url_for('.dashboard'))
@blueprint.route('/dashboard')
@login_required
@require_role(Role.ADMIN)
def dashboard():
return render_template('dashboard.html')
@blueprint.route('/manage_user/<id>', methods=['GET', 'POST'])
@login_required
@require_role(Role.ADMIN)
def manage_user(id: int):
form = ManageUserForm(request.form)
if form.validate_on_submit():
pass
user = login_manager.user_loader(id)
if user is None:
abort(404)
form.email.data = user.email
form.username.data = user.username
return render_template('manage_user.html', user=user)

View file

@ -0,0 +1,11 @@
{% extends 'base.html' %}
{% block title %}Admin Dashboard{% endblock %}
{% block header %}
<h2>Admin Dashboard</h2>
{% endblock %}
{% block content %}
{% endblock %}

View file

@ -0,0 +1,11 @@
{% extends 'base.html' %}
{% block title %}Manage User{% endblock %}
{% block header %}
<h2>Manage User {{ user.username }}</h2>
{% endblock %}
{% block content %}
{{ render_form() }}
{% endblock %}

View file

@ -1,10 +1,11 @@
from flask import Blueprint, Flask, render_template, request, redirect, url_for, session, flash from flask import Blueprint, Flask, render_template, request, redirect, url_for, flash, abort
from wtforms import StringField, validators from wtforms import StringField, validators, SubmitField
from flask_wtf import FlaskForm from flask_wtf import FlaskForm
from zxcvbn import zxcvbn from zxcvbn import zxcvbn
from wtforms.validators import ValidationError from wtforms.validators import ValidationError
from flask_login import login_user, logout_user
from buybuilds.models import User from buybuilds.models import User
from buybuilds import db
blueprint = Blueprint( blueprint = Blueprint(
name='auth', name='auth',
@ -16,12 +17,14 @@ blueprint = Blueprint(
class LoginForm(FlaskForm): class LoginForm(FlaskForm):
email = StringField('Email', [validators.Email()]) email = StringField('Email', [validators.Email()])
password = StringField('Password', [validators.Length(min=8, max=64)]) # per OWASP recommendations password = StringField('Password', [validators.Length(min=8, max=64)]) # per OWASP recommendations
submit = SubmitField('Login')
class RegisterForm(FlaskForm): class RegisterForm(FlaskForm):
email = StringField('Email', [validators.Email()]) email = StringField('Email', [validators.Email()])
username = StringField('Username', [validators.Length(min=2, max=30)]) username = StringField('Username', [validators.Length(min=2, max=30)])
password = StringField('Password', [validators.Length(min=8, max=64)]) # per OWASP recommendations password = StringField('Password', [validators.Length(min=8, max=64)]) # per OWASP recommendations
confirm_password = StringField('Confirm Password', [validators.Length(min=8, max=64), validators.EqualTo('password', message='Passwords do not match')]) confirm_password = StringField('Confirm Password', [validators.Length(min=8, max=64), validators.EqualTo('password', message='Passwords do not match')])
submit = SubmitField('Register')
def validate_password(self, field): def validate_password(self, field):
results = zxcvbn(field.data, user_inputs=[self.email.data, self.username.data]) results = zxcvbn(field.data, user_inputs=[self.email.data, self.username.data])
@ -40,10 +43,16 @@ def register_routes(app: Flask):
def redirect_to_next(default: str = None): def redirect_to_next(default: str = None):
"""Redirect to the next URL, or the default URL if no next URL is provided.""" """Redirect to the next URL, or the default URL if no next URL is provided."""
if default is None:
default = url_for('index')
next = request.args.get('next') if request.args.get('next') is not None else default next = request.args.get('next') if request.args.get('next') is not None else default
if next is None:
if default is None:
next = url_for('pages.index')
else:
next = default
# TODO check if next is allowed i.e. if it's on the same domain
return redirect(next) return redirect(next)
@blueprint.route('/login', methods=['GET', 'POST']) @blueprint.route('/login', methods=['GET', 'POST'])
@ -56,7 +65,7 @@ def login():
user = User.query.filter_by(email=email).first() user = User.query.filter_by(email=email).first()
if user and user.verify_password(password): if user and user.verify_password(password):
session['user_id'] = user.id login_user(user)
return redirect_to_next(default=url_for('.login_success')) return redirect_to_next(default=url_for('.login_success'))
else: else:
@ -73,9 +82,13 @@ def register():
username = form.username.data username = form.username.data
password = form.password.data password = form.password.data
User.create(email, username, password) user = User.create(email, username, password)
# TODO send verification email and remember about next url verification_url = url_for('.verify', token=user.verification_token, next=request.args.get('next'), _external=True)
print("Created user", user.username, "of ID", user.id, "with verification URL", verification_url)
# TODO send verification email
return redirect(url_for('.register_success')) return redirect(url_for('.register_success'))
@ -87,11 +100,23 @@ def register_success():
@blueprint.route('/login_success') @blueprint.route('/login_success')
def login_success(): def login_success():
# TODO do something if no next url return redirect_to_next()
return "OK"
@blueprint.route('/logout') @blueprint.route('/logout')
def logout(): def logout():
session.clear() logout_user()
flash('You have been logged out', 'success') flash('You have been logged out', 'success')
return redirect_to_next(default=url_for('.login')) return redirect_to_next(default=url_for('.login'))
@blueprint.route('/verify/<token>')
def verify(token):
user = User.query.filter_by(verification_token=token).first()
if user:
user.verify_user()
login_user(user)
flash('You have been verified', 'success')
return redirect_to_next(default=url_for('publisher.dashboard'))
abort(404)

View file

@ -3,16 +3,9 @@
{% block title %}Login{% endblock %} {% block title %}Login{% endblock %}
{% block header %} {% block header %}
<h2>Login</h2> <h2>Login (or <a href="{{ url_for('auth.register') }}">Register</a>)</h2>
{% endblock %} {% endblock %}
{% block content %} {% block content %}
{% from "_formhelpers.html" import render_field %} {{ render_form(form) }}
<form method="POST">
{{ form.csrf_token }}
{{ render_field(form.username) }}
{{ render_field(form.password) }}
<input type=submit value=Login>
</form>
{% endblock %} {% endblock %}

View file

@ -1,21 +1,11 @@
{% extends 'base.html' %} {% extends 'base.html' %}
{% block title %}Login{% endblock %} {% block title %}Register{% endblock %}
{% block header %} {% block header %}
<h2>Login</h2> <h2>Register (or <a href="{{ url_for('auth.login') }}">Login</a>)</h2>
{% endblock %} {% endblock %}
{% block content %} {% block content %}
{% from "_formhelpers.html" import render_field %} {{ render_form(form) }}
<form method="POST">
{{ form.csrf_token }}
{{ render_field(form.email) }}
{{ render_field(form.username) }}
{{ render_field(form.password) }}
{{ render_field(form.confirm_password) }}
<input type=submit value=Register>
</form>
{% endblock %} {% endblock %}

View file

@ -0,0 +1,15 @@
from flask import Blueprint, Flask, render_template
blueprint = Blueprint(
name='pages',
import_name=__name__,
template_folder='templates',
url_prefix='/'
)
def register_routes(app: Flask):
app.register_blueprint(blueprint)
@blueprint.route('/')
def index():
return render_template('index.html')

View file

@ -0,0 +1,11 @@
{% extends "base.html" %}
{% block title %}BuyBuilds{% endblock %}
{% block header %}
<h1>BuyBuilds</h1>
{% endblock %}
{% block content %}
<p>TODO</p>
{% endblock %}

View file

@ -1,12 +1,16 @@
from flask import Blueprint, jsonify, Flask, request, render_template, redirect, url_for, flash from flask import Blueprint, jsonify, Flask, request, render_template, redirect, url_for, flash
from wtforms import Form, StringField, IntegerField, validators from wtforms import Form, StringField, IntegerField, validators
from flask_login import login_required
from buybuilds.models.resource import Resource from buybuilds.models.resource import Resource
from buybuilds import db from buybuilds import db
from buybuilds.login import require_role
from buybuilds.roles import Role
blueprint = Blueprint( blueprint = Blueprint(
name='publisher', name='publisher',
import_name=__name__, import_name=__name__,
template_folder='templates',
url_prefix='/publisher' url_prefix='/publisher'
) )
@ -19,13 +23,21 @@ class CreateResourceForm(Form):
@blueprint.route('/', methods=['GET']) @blueprint.route('/', methods=['GET'])
def index(): def index():
return jsonify(status="healthy"), 200 return redirect(url_for('.dashboard'))
@blueprint.route('/create_resource', methods=['GET']) @blueprint.route('/dashboard')
@login_required
@require_role(Role.PUBLISHER)
def dashboard():
return render_template('dashboard.html')
@blueprint.route('/create_resource', methods=['GET', 'POST'])
@login_required
@require_role(Role.PUBLISHER)
def create_resource(): def create_resource():
form = CreateResourceForm(request.form) form = CreateResourceForm(request.form)
if request.method == 'POST' and form.validate(): if form.validate_on_submit():
name = form.name.data name = form.name.data
price = form.price.data price = form.price.data

View file

@ -1,25 +0,0 @@
{% extends "base.html" %}
{% block title %}Create New Resource{% endblock %}
{% block header %}
<h2>Create New Resource</h2>
{% endblock %}
{% block content %}
<div class="create-inquiry">
<p>Create a new resource to start selling your builds.</p>
{% from "_formhelpers.html" import render_field %}
<form method=post>
<dl>
{{ render_field(form.username) }}
{{ render_field(form.email) }}
{{ render_field(form.password) }}
{{ render_field(form.confirm) }}
{{ render_field(form.accept_tos) }}
</dl>
<p><input type=submit value=Register>
</form>
</div>
{% endblock %}

View file

@ -1,164 +0,0 @@
{% extends 'base.html' %}
{% block title %}Admin Settings{% endblock %}
{% block header %}
<h2>Admin Settings</h2>
{% endblock %}
{% block content %}
<div class="settings-form">
<h3>Change Admin Password</h3>
<form method="POST" action="{{ url_for('admin_settings_password') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<div>
<label for="current_password">Current Password:</label>
<input type="password" id="current_password" name="current_password" required>
</div>
<div>
<label for="new_password">New Password:</label>
<input type="password" id="new_password" name="new_password" required>
<small style="display: block; margin-top: 0.25rem; color: #6c757d;">Password must be at least 8 characters</small>
</div>
<div>
<label for="confirm_password">Confirm New Password:</label>
<input type="password" id="confirm_password" name="confirm_password" required>
</div>
<button type="submit">Update Password</button>
</form>
</div>
<br>
<div class="general-settings">
<h3>General Settings</h3>
<form method="POST" action="{{ url_for('admin_settings_general') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<label for="auto_delete_hours">
Auto-Delete After (hours):
<input type="number" id="auto_delete_hours" name="auto_delete_hours" value="{{ settings.auto_delete_hours }}" min="1" max="8760">
<small style="display: block; margin-top: 0.5rem;">
Set to how many hours inquiries should be kept before automatic deletion. (1-8760 hours, 8760 = 1 year)
<br>
This will have no effect on inquiries that are already closed.
</small>
</label>
<br>
<label>
<input type="checkbox" name="notification_show_message" {% if settings.notification_show_message %}checked{% endif %}>
Include message content in email notifications
</label>
<br>
<button type="submit">Save Settings</button>
</form>
</div>
<br>
<div class="webhook-settings">
<h3>Webhook Notification Settings</h3>
<div style="margin-bottom: 2rem;">
<p>Configure webhook settings to receive notifications for new inquiries and responses.</p>
<p>Events will be sent as POST requests with JSON payloads. Event types include:</p>
<ul>
<li><strong>inquiry_created</strong> - Triggered when a new inquiry is created. Contains the inquiry ID and the initial message.</li>
<li><strong>inquiry_reopened</strong> - Triggered when an inquiry is reopened. Contains the inquiry ID.</li>
<li><strong>inquiry_closed</strong> - Triggered when an inquiry is closed. Contains the inquiry ID.</li>
<li><strong>inquiry_message</strong> - Triggered when a user adds a message to an existing inquiry. Contains the inquiry ID and the message content.</li>
</ul>
</div>
<form method="POST" action="{{ url_for('admin_settings_webhook') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<div>
<label>
<input type="checkbox" name="webhook_enabled" {% if settings.webhook_enabled %}checked{% endif %}>
Enable Webhooks
</label>
</div>
<div style="margin-top: 1rem;">
<label for="webhook_url">Webhook URL:</label>
<input type="text" id="webhook_url" name="webhook_url" value="{{ settings.webhook_url or '' }}" placeholder="https://example.com/webhook">
<p style="font-size: 0.8rem; color: #666;">The URL that will receive webhook events</p>
</div>
<div style="margin-top: 1rem;">
<label for="webhook_secret">Webhook Secret (optional):</label>
<input type="text" id="webhook_secret" name="webhook_secret" value="{{ settings.webhook_secret or '' }}" placeholder="Secret key for HMAC signature">
<p style="font-size: 0.8rem; color: #666;">If provided, webhooks will include a signature header (X-Webhook-Signature)</p>
</div>
<button type="submit" style="margin-top: 1rem;">Save Settings</button>
</form>
<div style="padding: 1rem;">
<h3>Webhook Payload Example</h3>
<pre style="background-color: #1e1e1e; padding: 1rem; overflow-x: auto; border-radius: 0.25rem; color: #d4d4d4;">
{
"event_type": "inquiry_created",
"timestamp": "2023-04-01T12:34:56.789Z",
"data": {
"inquiry_id": "abcdef1234567890",
"message": "Hello, I have a question..."
}
}</pre>
</div>
</div>
<br>
<div class="email-settings">
<h3>Email Notification Settings</h3>
<div style="margin-bottom: 2rem;">
<p>Configure email settings to receive notifications for new inquiries and responses.</p>
</div>
<form method="POST" action="{{ url_for('admin_settings_email') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<div>
<label>
<input type="checkbox" name="email_notifications_enabled" {% if settings.email_notifications_enabled %}checked{% endif %}>
Enable Email Notifications
</label>
</div>
<div style="margin-top: 1rem;">
<label for="smtp_server">SMTP Server:</label>
<input type="text" id="smtp_server" name="smtp_server" value="{{ settings.smtp_server or '' }}" placeholder="smtp.example.com">
</div>
<div style="margin-top: 1rem;">
<label>
<input type="checkbox" name="smtp_use_ssl" {% if settings.smtp_use_ssl %}checked{% endif %}>
Use SSL
</label>
</div>
<div style="margin-top: 1rem;">
<label for="smtp_username">SMTP Username / Sender Email:</label>
<input type="text" id="smtp_username" name="smtp_username" value="{{ settings.smtp_username or '' }}" placeholder="username@example.com">
</div>
<div style="margin-top: 1rem;">
<label for="smtp_password">SMTP Password:</label>
<input type="password" id="smtp_password" name="smtp_password" value="{{ settings.smtp_password or '' }}" placeholder="Password">
</div>
<div style="margin-top: 1rem;">
<label for="recipient_email">Recipient Email:</label>
<input type="email" id="recipient_email" name="recipient_email" value="{{ settings.recipient_email or '' }}" placeholder="admin@example.com">
</div>
<button type="submit" style="margin-top: 1rem;">Save Email Settings</button>
</form>
</div>
{% endblock %}

View file

@ -0,0 +1,15 @@
{% extends "base.html" %}
{% block title %}Create New Resource{% endblock %}
{% block header %}
<h2>Create New Resource</h2>
{% endblock %}
{% block content %}
<div class="create-inquiry">
<p>Create a new resource to start selling your builds.</p>
{{ render_form(form) }}
</div>
{% endblock %}

View file

@ -4,17 +4,6 @@
{% block header %} {% block header %}
<h2>Publisher Dashboard</h2> <h2>Publisher Dashboard</h2>
<nav>
{% if is_admin() %}
<div style="margin-bottom: 1rem;">
<span style="margin-right: 1rem;">Publisher</span>
<a href="{{ url_for('publisher.dashboard') }}" style="margin-right: 1rem;">Dashboard</a>
<a href="{{ url_for('publisher.settings') }}" style="margin-right: 1rem;">Settings</a>
<a href="{{ url_for('auth.logout') }}" style="margin-right: 1rem;">Logout</a>
</div>
{% endif %}
</nav>
{% endblock %} {% endblock %}
{% block content %} {% block content %}
@ -40,6 +29,6 @@
</tbody> </tbody>
</table> </table>
{% else %} {% else %}
<p>No resources found. <a href="{{ url_for('publisher_create_resource') }}">Create a new resource</a></p> <p>No resources found. <a href="{{ url_for('publisher.create_resource') }}">Create a new resource</a></p>
{% endif %} {% endif %}
{% endblock %} {% endblock %}

View file

@ -27,7 +27,7 @@ def create_database(app: Flask) -> SQLAlchemy:
def initialize_database(app: Flask, db: SQLAlchemy): def initialize_database(app: Flask, db: SQLAlchemy):
"""Create database tables and initialize default data.""" """Create database tables and initialize default data."""
from .models import User, Resource from .models import User
from .roles import Role from .roles import Role
with app.app_context(): with app.app_context():
@ -47,18 +47,21 @@ def initialize_database(app: Flask, db: SQLAlchemy):
if not user or os.environ.get('ADMIN_FORCE_RESET'): if not user or os.environ.get('ADMIN_FORCE_RESET'):
admin_password = os.environ.get('ADMIN_PASSWORD') admin_password = os.environ.get('ADMIN_PASSWORD')
if len(admin_password) < 8:
app.logger.warning("Admin password is less than 8 chars long, you won't be able to log in.")
if admin_password is not None: if admin_password is not None:
if user: if user:
user.password_hash = User.hash_password(admin_password) user.change_password(admin_password)
app.logger.info("Admin user password reset.") app.logger.info("Admin user password reset.")
else: else:
user = User( user = User.create(
username=os.environ.get('ADMIN_USERNAME'), email = os.environ.get('ADMIN_EMAIL'),
password_hash=User.hash_password(admin_password), username = 'Administrator',
role=Role.ADMIN password = admin_password,
role = Role.ADMIN
) )
db.session.add(user)
db.session.commit()
app.logger.info("Admin user initialized.") app.logger.info("Admin user initialized.")
else: else:
if os.environ.get('ADMIN_FORCE_RESET'): if os.environ.get('ADMIN_FORCE_RESET'):

29
src/buybuilds/login.py Normal file
View file

@ -0,0 +1,29 @@
from flask_login import LoginManager, current_user, login_required
from flask import abort
from functools import wraps
from buybuilds.models import User
from .roles import Role
def create_login_manager(app):
login_manager = LoginManager(app)
login_manager.init_app(app)
login_manager.login_view = 'auth.login'
@login_manager.user_loader
def user_loader(id):
return User.query.get(id)
return login_manager
def require_role(role: Role):
def decorator(f):
@wraps(f)
def decorated_function(*args, **kwargs):
if current_user.is_authenticated and current_user.role < role:
abort(403)
return f(*args, **kwargs)
return decorated_function
return decorator

View file

@ -1,45 +1,85 @@
from argon2.exceptions import VerifyMismatchError from argon2.exceptions import VerifyMismatchError
from datetime import datetime
from secrets import token_hex
from sqlalchemy.exc import IntegrityError
from flask_login import UserMixin
from .. import db, password_hasher from .. import db, password_hasher
from ..roles import Role from ..roles import Role
class User(db.Model): class User(db.Model, UserMixin):
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
email = db.Column(db.String(254), unique=True, nullable=True, index=True) email = db.Column(db.String(254), unique=True, nullable=True, index=True)
username = db.Column(db.String(30), unique=True, nullable=False, index=True) username = db.Column(db.String(30), unique=True, nullable=False, index=True)
password_hash = db.Column(db.String(97), nullable=False) password_hash = db.Column(db.String(97), nullable=False)
role = db.Column(db.Enum(Role), nullable=False, default=Role.UNVERIFIED) role = db.Column(db.Enum(Role), nullable=False, default=Role.USER)
resources = db.relationship('Resource', back_populates='user', cascade='all, delete-orphan') resources = db.relationship('Resource', back_populates='user', cascade='all, delete-orphan')
@classmethod verification_token = db.Column(db.String(32), nullable=True)
def hash_password(cls, password: str) -> str: verification_token_generated = db.Column(db.DateTime, nullable=True)
"""Hash a password using Argon2id"""
return password_hasher.hash(password)
def rehash_password(self, password: str) -> None:
"""Rehash a password using Argon2id"""
self.password_hash = self.hash_password(password)
db.session.commit()
def verify_password(self, password: str) -> bool: def verify_password(self, password: str) -> bool:
"""Verify a password against the stored hash""" """Verify a password against the stored hash"""
try: try:
password_hasher.verify(self.password_hash, password) password_hasher.verify(self.password_hash, password)
if password_hasher.check_needs_rehash(self.password_hash): if password_hasher.check_needs_rehash(self.password_hash):
self.rehash_password(password) self.password_hash = _hash_password(password)
db.session.commit()
return True return True
except VerifyMismatchError: except VerifyMismatchError:
return False return False
@classmethod @classmethod
def create(cls, email: str, username: str, password: str) -> 'User': def create(cls, email: str, username: str, password: str, role: Role = Role.USER) -> 'User':
user = cls( user = cls(
email=email, email=email,
username=username, username=username,
password_hash=User.hash_password(password) password_hash=_hash_password(password),
role=role,
verification_token=_generate_verification_token(),
verification_token_generated=datetime.now(),
) )
db.session.add(user) db.session.add(user)
try:
db.session.commit()
except IntegrityError:
raise UserExistsError()
return user
def change_password(self, new_password: str) -> None:
self.password_hash = _hash_password(new_password)
db.session.commit() db.session.commit()
return user
def verify_user(self) -> None:
self.verification_token = None
self.verification_token_generated = None
db.session.commit()
def is_authenticated(self) -> bool:
return True
def is_active(self) -> bool:
return self.verification_token is None
def is_anonymous(self) -> bool:
return False
def get_id(self) -> str:
return str(self.id)
class UserExistsError(Exception):
"""Exception raised when a user already exists"""
pass
def _hash_password(password: str) -> str:
"""Hash a password using Argon2id"""
return password_hasher.hash(password)
def _generate_verification_token() -> str:
"""Generate a verification token"""
return token_hex(16)

View file

@ -5,7 +5,7 @@ from flask_limiter import Limiter
from flask_limiter.util import get_remote_address from flask_limiter.util import get_remote_address
DEFAULTS = { DEFAULTS = {
'RATELIMIT_STORAGE_URI': os.environ.get('REDIS_URL'), 'RATELIMIT_STORAGE_URI': os.environ.get('REDIS_URL', "memory://"),
'RATELIMIT_HEADERS_ENABLED': True, 'RATELIMIT_HEADERS_ENABLED': True,
'RATELIMIT_KEY_PREFIX': 'buybuilds_rate_limit', 'RATELIMIT_KEY_PREFIX': 'buybuilds_rate_limit',
} }

View file

@ -1,7 +1,8 @@
from enum import Enum from enum import Enum
from functools import total_ordering
@total_ordering
class Role(Enum): class Role(Enum):
UNVERIFIED = -1
USER = 0 USER = 0
PUBLISHER = 1 PUBLISHER = 1
MODERATOR = 2 MODERATOR = 2
@ -9,3 +10,8 @@ class Role(Enum):
def __str__(self): def __str__(self):
return self.name.capitalize() return self.name.capitalize()
def __lt__(self, other):
if self.__class__ is other.__class__:
return self.value < other.value
return NotImplemented

View file

@ -0,0 +1,48 @@
from flask_apscheduler import APScheduler
from datetime import datetime, timezone, timedelta
from flask import current_app
from .models import User
from .roles import Role
from . import db
def initialize_scheduler(app):
# Initialize scheduler
scheduler = APScheduler()
scheduler.init_app(app)
scheduler.add_job(
id='delete_unverified_users',
func=delete_unverified_users,
trigger='interval',
hours=24
)
# Start the scheduler
scheduler.start()
def delete_unverified_users():
"""Check and delete inquiries that have been closed for more than the configured number of hours"""
# Get app context from current_app
app = current_app._get_current_object()
with app.app_context():
app.logger.info(f"Running scheduled task: delete_unverified_users")
before = datetime.now(timezone.utc) + timedelta(days=1)
unverified_users = User.query.filter(
User.role == Role.UNVERIFIED,
User.verification_token_generated <= before
).all()
deleted_count = 0
for user in unverified_users:
# Delete the user
db.session.delete(user)
deleted_count += 1
if deleted_count > 0:
db.session.commit()
app.logger.info(f"Automatically deleted {deleted_count} unverified users")

View file

@ -1,286 +0,0 @@
body {
font-family: Arial, sans-serif;
line-height: 1.6;
margin: 0;
padding: 0;
background-color: #1a1a1a;
color: #e0e0e0;
}
/* Adding link styles for dark theme */
a {
color: #6da8ff;
text-decoration: none;
}
a:hover {
color: #8fc0ff;
text-decoration: underline;
}
a:visited {
color: #c792ea;
}
#app {
width: 80%;
max-width: 800px;
margin: 4rem auto 2rem auto;
padding: 1rem;
background-color: #2c2c2c;
border-radius: 5px;
box-shadow: 0 2px 5px rgba(0,0,0,0.3);
}
footer {
font-size: small;
color: #999;
text-align: center;
}
h1 {
color: #bbb;
}
form {
margin-bottom: 1rem;
}
textarea, input[type="text"], input[type="password"], input[type="email"] {
width: 100%;
padding: 0.5rem;
margin-bottom: 1rem;
border: 1px solid #555;
border-radius: 4px;
box-sizing: border-box;
background-color: #333;
color: #e0e0e0;
}
button {
background-color: #3b8c3e;
color: white;
padding: 0.7rem 1.2rem;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 1rem;
}
button:hover {
background-color: #2d6c30;
}
.message {
margin-bottom: 1rem;
padding: 1rem;
border-radius: 4px;
background-color: #333;
}
.admin-message {
background-color: #263844;
border-left: 3px solid #1a73e8;
}
.user-message {
background-color: #333;
}
.admin-badge {
background-color: #1a73e8;
color: white;
padding: 2px 6px;
border-radius: 3px;
font-size: 0.8rem;
font-weight: bold;
display: inline-block;
margin-right: 5px;
}
.timestamp {
font-size: 0.8rem;
color: #888;
margin-top: 0.3rem;
}
table {
width: 100%;
border-collapse: collapse;
margin-bottom: 1rem;
}
th, td {
padding: 8px;
text-align: left;
border-bottom: 1px solid #444;
}
th {
background-color: #222;
}
/* 404 Error Page Styles */
.error-container {
text-align: center;
padding: 2rem 1rem;
}
.error-code {
font-size: 6rem;
font-weight: bold;
color: #3b8c3e;
margin-bottom: 1rem;
text-shadow: 2px 2px 4px rgba(0,0,0,0.3);
}
.error-container h2 {
font-size: 2rem;
margin-bottom: 1rem;
color: #bbb;
}
.error-container p {
font-size: 1.1rem;
margin-bottom: 2rem;
color: #999;
}
.error-actions {
margin-top: 2rem;
}
.btn-primary {
background-color: #3b8c3e;
color: white;
padding: 0.7rem 1.5rem;
border-radius: 4px;
text-decoration: none;
display: inline-block;
font-weight: bold;
}
.btn-primary:hover {
background-color: #2d6c30;
text-decoration: none;
}
/* Recent Inquiries Styles */
.recent-inquiries {
margin-top: 2rem;
padding-top: 1.5rem;
border-top: 1px solid #444;
}
.recent-header {
display: flex;
align-items: center;
margin-bottom: 1rem;
}
.recent-header h3 {
margin: 0;
color: #bbb;
}
.recent-header form {
margin: 0;
}
.forget-button {
background-color: #8c3b3b;
color: white;
padding: 0.4rem 0.8rem;
font-size: 0.9rem;
margin-left: 1rem;
}
.forget-button:hover {
background-color: #a04343;
}
.inquiry-list {
list-style: none;
padding: 0;
margin: 0;
}
.inquiry-list li {
margin-bottom: 0.8rem;
}
.inquiry-list a {
text-decoration: none;
}
.inquiry-list a:hover {
text-decoration: none;
}
.inquiry-item {
padding: 1rem;
background-color: #333;
border-radius: 4px;
border-left: 3px solid #070;
}
/* Closed inquiry styling */
.inquiry-item.closed {
filter: grayscale(70%);
background-color: #2a2a2a;
}
.inquiry-item.closed:hover {
background-color: #303030;
}
.inquiry-item:hover {
background-color: #3a3a3a;
}
/* Unread inquiry styling */
.inquiry-item.unread {
border-left: 3px solid #3b8c3e;
background-color: #383f38;
}
.inquiry-item.unread:hover {
background-color: #404a40;
}
.inquiry-header {
display: flex;
align-items: center;
margin-bottom: 0.5rem;
}
.inquiry-id {
font-weight: bold;
display: inline-block;
margin-right: 0.5rem;
}
.inquiry-status {
font-size: 0.8rem;
padding: 2px 6px;
border-radius: 3px;
display: inline-block;
}
.inquiry-status.open {
background-color: #3b8c3e;
color: white;
}
.inquiry-status.closed {
background-color: #8c3b3b;
color: white;
}
.unread-badge {
margin-left: auto;
background-color: #3b8c3e;
color: white;
padding: 2px 8px;
border-radius: 10px;
font-size: 0.75rem;
font-weight: bold;
animation: pulse 2s infinite;
}
@keyframes pulse {
0% {
box-shadow: 0 0 0 0 rgba(59, 140, 62, 0.4);
}
70% {
box-shadow: 0 0 0 6px rgba(59, 140, 62, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(59, 140, 62, 0);
}
}
.inquiry-message {
margin: 0.4rem 0;
color: #bbb;
font-size: 0.9rem;
}
h2, h3 {
padding-left: 1rem;
}

View file

@ -1,15 +0,0 @@
{% macro render_field(field) %}
{{ field.label }}:
{% if field.errors %}
<div style="color: red;">
{% for error in field.errors %}
{{ error }}
<br>
{% endfor %}
</div>
{% endif %}
{{ field(**kwargs)|safe }}
{% endmacro %}

View file

@ -1,38 +1,64 @@
{% from 'bootstrap5/form.html' import render_form %}
{% from 'bootstrap5/nav.html' import render_nav_item %}
{% from 'bootstrap5/utils.html' import render_messages %}
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}{% endblock %} - {{ config.SITE_TITLE }}</title> <title>{% block title %}{% endblock %} - {{ config.SITE_TITLE }}</title>
<link rel="stylesheet" href="{{ url_for('static', filename='css/styles.css') }}"> {{ bootstrap.load_css() }}
</head> </head>
<body> <body>
<div id="app"> <header>
<header> <nav class="navbar navbar-expand-lg navbar-dark bg-dark">
{% block header %}{% endblock %} <div class="container">
</header> <a class="navbar-brand" href="{{ url_for('pages.index') }}">BuyBuilds</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarSupportedContent">
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
{% if current_user.is_authenticated %}
{% if current_user.role >= Role.ADMIN %}
{{ render_nav_item('admin.dashboard', 'Admin Dashboard') }}
{% elif current_user.role >= Role.PUBLISHER %}
{{ render_nav_item('publisher.dashboard', 'Publisher Dashboard') }}
{% endif %}
{% endif %}
</ul>
<main> {% if current_user.is_authenticated %}
{% with messages = get_flashed_messages(with_categories=true) %} <div class="dropdown">
{% if messages %} <a class="btn btn-dark dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
{% for category, message in messages %} {{ current_user.username }}
<div style="background-color: {% if category == 'error' %}#e8c7ca{% else %}#c4ddca{% endif %}; </a>
color: {% if category == 'error' %}#5a0000{% else %}#003500{% endif %}; <ul class="dropdown-menu">
padding: 0.75rem; <li class="dropdown-item">[{{ current_user.role }}] {{ current_user.username }}</li>
margin-bottom: 1rem;
border-radius: 0.25rem;"> <li class="dropdown-divider"></li>
{{ message }} <a class="dropdown-item" href="{{ url_for('auth.logout') }}">Logout</a>
</ul>
</div> </div>
{% endfor %} {% else %}
{% endif %} <a class="btn btn-success" href="{{ url_for('auth.login') }}">Login</a>
{% endwith %} {% endif %}
</div>
</div>
</nav>
{% block content %}{% endblock %} <div class="container my-4 px-4">
</main> {% block header %}{% endblock %}
</div> </div>
</header>
<footer> <main class="container">
Powered by <a href="https://git.m724.eu/Minecon724/buybuilds" style="color: #4CAF50; text-decoration: none;">buybuilds</a> {{ render_messages(container=False, dismissible=True, dismiss_animate=True) }}
</footer>
{% block content %}{% endblock %}
</main>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.5/dist/js/bootstrap.bundle.min.js" integrity="sha384-k6d4wzSIapyDyv1kpU366/PK5hCdSbCRGRCMv+eplOQJWyd1fbcAu9OCUj5zNLiq" crossorigin="anonymous"></script>
</body> </body>
</html> </html>

View file

@ -3,10 +3,10 @@
{% block title %}{{ title }}{% endblock %} {% block title %}{{ title }}{% endblock %}
{% block content %} {% block content %}
<div class="error-container"> <div class="container">
<div class="error-code">{{ status_code | default("Error") }}</div> <h1 class="display-1 px-3">{{ status_code | default("Error") }}</h1v>
<h2>{{ title }}</h2> <h2>{{ title }}</h2>
<p>{{ message }}</p> <p class="lead">{{ message }}</p>
<div class="error-actions"> <div class="error-actions">
{% if request.referrer %} {% if request.referrer %}
<a href="{{ request.referrer }}" class="btn-secondary">Go Back</a> <a href="{{ request.referrer }}" class="btn-secondary">Go Back</a>