Do
This commit is contained in:
parent
3b1f82cf17
commit
c5e2e70332
30 changed files with 574 additions and 639 deletions
27
.devcontainer/devcontainer.json
Normal file
27
.devcontainer/devcontainer.json
Normal 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"
|
||||||
|
}
|
||||||
|
|
@ -1 +1,3 @@
|
||||||
TODO
|
TODO
|
||||||
|
|
||||||
|
Use the devcontainer and `poetry run start`
|
||||||
138
poetry.lock
generated
138
poetry.lock
generated
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
10
src/buybuilds/blueprints/__init__.py
Normal file
10
src/buybuilds/blueprints/__init__.py
Normal 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)
|
||||||
52
src/buybuilds/blueprints/admin/__init__.py
Normal file
52
src/buybuilds/blueprints/admin/__init__.py
Normal 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)
|
||||||
11
src/buybuilds/blueprints/admin/templates/dashboard.html
Normal file
11
src/buybuilds/blueprints/admin/templates/dashboard.html
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
{% extends 'base.html' %}
|
||||||
|
|
||||||
|
{% block title %}Admin Dashboard{% endblock %}
|
||||||
|
|
||||||
|
{% block header %}
|
||||||
|
<h2>Admin Dashboard</h2>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
11
src/buybuilds/blueprints/admin/templates/manage_user.html
Normal file
11
src/buybuilds/blueprints/admin/templates/manage_user.html
Normal 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 %}
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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 %}
|
||||||
|
|
@ -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 %}
|
||||||
15
src/buybuilds/blueprints/pages/__init__.py
Normal file
15
src/buybuilds/blueprints/pages/__init__.py
Normal 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')
|
||||||
11
src/buybuilds/blueprints/pages/templates/index.html
Normal file
11
src/buybuilds/blueprints/pages/templates/index.html
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}BuyBuilds{% endblock %}
|
||||||
|
|
||||||
|
{% block header %}
|
||||||
|
<h1>BuyBuilds</h1>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<p>TODO</p>
|
||||||
|
{% endblock %}
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 %}
|
|
||||||
|
|
@ -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 %}
|
|
||||||
|
|
@ -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 %}
|
||||||
|
|
@ -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 %}
|
||||||
|
|
@ -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
29
src/buybuilds/login.py
Normal 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
|
||||||
|
|
||||||
|
|
@ -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)
|
||||||
db.session.commit()
|
|
||||||
|
try:
|
||||||
|
db.session.commit()
|
||||||
|
except IntegrityError:
|
||||||
|
raise UserExistsError()
|
||||||
|
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
def change_password(self, new_password: str) -> None:
|
||||||
|
self.password_hash = _hash_password(new_password)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
@ -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',
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
48
src/buybuilds/scheduler.py
Normal file
48
src/buybuilds/scheduler.py
Normal 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")
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
@ -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 %}
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue