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]]
|
||||
name = "argon2-cffi"
|
||||
|
@ -84,6 +112,22 @@ files = [
|
|||
{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]]
|
||||
name = "cachelib"
|
||||
version = "0.13.0"
|
||||
|
@ -282,6 +326,22 @@ Werkzeug = ">=3.1"
|
|||
async = ["asgiref (>=3.2)"]
|
||||
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]]
|
||||
name = "flask-limiter"
|
||||
version = "3.12"
|
||||
|
@ -306,6 +366,22 @@ mongodb = ["limits[mongodb]"]
|
|||
redis = ["limits[redis]"]
|
||||
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]]
|
||||
name = "flask-session"
|
||||
version = "0.8.0"
|
||||
|
@ -846,6 +922,21 @@ files = [
|
|||
[package.extras]
|
||||
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]]
|
||||
name = "python-dotenv"
|
||||
version = "1.1.0"
|
||||
|
@ -899,6 +990,18 @@ pygments = ">=2.13.0,<3.0.0"
|
|||
[package.extras]
|
||||
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]]
|
||||
name = "sqlalchemy"
|
||||
version = "2.0.40"
|
||||
|
@ -1007,6 +1110,37 @@ files = [
|
|||
{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]]
|
||||
name = "werkzeug"
|
||||
version = "3.1.3"
|
||||
|
@ -1147,4 +1281,4 @@ files = [
|
|||
[metadata]
|
||||
lock-version = "2.1"
|
||||
python-versions = ">=3.11"
|
||||
content-hash = "21bbc12935994eeb014387c69841f9a8f2462939b4eb0675cf759c2f7999d917"
|
||||
content-hash = "a16f3abd2ab44cdb6b1db5d56dd3ac32688a8232449b6e477de9dee531268d9a"
|
||||
|
|
|
@ -21,7 +21,10 @@ dependencies = [
|
|||
"flask-session (>=0.5.0,<1.0.0)",
|
||||
"argon2-cffi (>=23.0.0,<24.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]
|
||||
|
|
|
@ -3,10 +3,8 @@ from flask import Flask
|
|||
import os
|
||||
from dotenv import load_dotenv
|
||||
from flask import jsonify
|
||||
from flask_wtf.csrf import CSRFProtect
|
||||
from werkzeug.middleware.proxy_fix import ProxyFix
|
||||
from argon2 import PasswordHasher
|
||||
|
||||
# Load environment variables from .env file
|
||||
load_dotenv()
|
||||
|
||||
|
@ -54,8 +52,11 @@ limiter = create_ratelimit(app)
|
|||
from .database import create_database
|
||||
db = create_database(app)
|
||||
|
||||
from .auth.annonations import get_user
|
||||
app.jinja_env.filters['user'] = get_user
|
||||
from .login import create_login_manager
|
||||
login_manager = create_login_manager(app)
|
||||
|
||||
from flask_bootstrap import Bootstrap5
|
||||
bootstrap = Bootstrap5(app)
|
||||
|
||||
# Health check endpoint
|
||||
@app.route('/health', methods=['GET'])
|
||||
|
@ -65,11 +66,15 @@ def health_check():
|
|||
from .error_handlers import register_error_handlers
|
||||
register_error_handlers(app)
|
||||
|
||||
# Import routes
|
||||
from .blueprints import auth, resource, publisher
|
||||
auth.register_routes(app)
|
||||
resource.register_routes(app)
|
||||
publisher.register_routes(app)
|
||||
from .blueprints import register_blueprints
|
||||
register_blueprints(app)
|
||||
|
||||
from .scheduler import initialize_scheduler
|
||||
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
|
||||
@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 wtforms import StringField, validators
|
||||
from flask import Blueprint, Flask, render_template, request, redirect, url_for, flash, abort
|
||||
from wtforms import StringField, validators, SubmitField
|
||||
from flask_wtf import FlaskForm
|
||||
from zxcvbn import zxcvbn
|
||||
from wtforms.validators import ValidationError
|
||||
from flask_login import login_user, logout_user
|
||||
|
||||
from buybuilds.models import User
|
||||
from buybuilds import db
|
||||
|
||||
blueprint = Blueprint(
|
||||
name='auth',
|
||||
|
@ -16,12 +17,14 @@ blueprint = Blueprint(
|
|||
class LoginForm(FlaskForm):
|
||||
email = StringField('Email', [validators.Email()])
|
||||
password = StringField('Password', [validators.Length(min=8, max=64)]) # per OWASP recommendations
|
||||
submit = SubmitField('Login')
|
||||
|
||||
class RegisterForm(FlaskForm):
|
||||
email = StringField('Email', [validators.Email()])
|
||||
username = StringField('Username', [validators.Length(min=2, max=30)])
|
||||
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')])
|
||||
submit = SubmitField('Register')
|
||||
|
||||
def validate_password(self, field):
|
||||
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):
|
||||
"""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
|
||||
|
||||
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)
|
||||
|
||||
@blueprint.route('/login', methods=['GET', 'POST'])
|
||||
|
@ -56,7 +65,7 @@ def login():
|
|||
|
||||
user = User.query.filter_by(email=email).first()
|
||||
if user and user.verify_password(password):
|
||||
session['user_id'] = user.id
|
||||
login_user(user)
|
||||
|
||||
return redirect_to_next(default=url_for('.login_success'))
|
||||
else:
|
||||
|
@ -73,9 +82,13 @@ def register():
|
|||
username = form.username.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'))
|
||||
|
||||
|
@ -87,11 +100,23 @@ def register_success():
|
|||
|
||||
@blueprint.route('/login_success')
|
||||
def login_success():
|
||||
# TODO do something if no next url
|
||||
return "OK"
|
||||
return redirect_to_next()
|
||||
|
||||
@blueprint.route('/logout')
|
||||
def logout():
|
||||
session.clear()
|
||||
logout_user()
|
||||
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 header %}
|
||||
<h2>Login</h2>
|
||||
<h2>Login (or <a href="{{ url_for('auth.register') }}">Register</a>)</h2>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% from "_formhelpers.html" import render_field %}
|
||||
<form method="POST">
|
||||
{{ form.csrf_token }}
|
||||
{{ render_field(form.username) }}
|
||||
{{ render_field(form.password) }}
|
||||
|
||||
<input type=submit value=Login>
|
||||
</form>
|
||||
{{ render_form(form) }}
|
||||
{% endblock %}
|
|
@ -1,21 +1,11 @@
|
|||
{% extends 'base.html' %}
|
||||
|
||||
{% block title %}Login{% endblock %}
|
||||
{% block title %}Register{% endblock %}
|
||||
|
||||
{% block header %}
|
||||
<h2>Login</h2>
|
||||
<h2>Register (or <a href="{{ url_for('auth.login') }}">Login</a>)</h2>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% from "_formhelpers.html" import render_field %}
|
||||
<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>
|
||||
{{ render_form(form) }}
|
||||
{% 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 wtforms import Form, StringField, IntegerField, validators
|
||||
from flask_login import login_required
|
||||
|
||||
from buybuilds.models.resource import Resource
|
||||
from buybuilds import db
|
||||
from buybuilds.login import require_role
|
||||
from buybuilds.roles import Role
|
||||
|
||||
blueprint = Blueprint(
|
||||
name='publisher',
|
||||
import_name=__name__,
|
||||
template_folder='templates',
|
||||
url_prefix='/publisher'
|
||||
)
|
||||
|
||||
|
@ -19,13 +23,21 @@ class CreateResourceForm(Form):
|
|||
|
||||
@blueprint.route('/', methods=['GET'])
|
||||
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():
|
||||
form = CreateResourceForm(request.form)
|
||||
|
||||
if request.method == 'POST' and form.validate():
|
||||
if form.validate_on_submit():
|
||||
name = form.name.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 %}
|
||||
<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 %}
|
||||
|
||||
{% block content %}
|
||||
|
@ -40,6 +29,6 @@
|
|||
</tbody>
|
||||
</table>
|
||||
{% 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 %}
|
||||
{% endblock %}
|
|
@ -27,7 +27,7 @@ def create_database(app: Flask) -> SQLAlchemy:
|
|||
|
||||
def initialize_database(app: Flask, db: SQLAlchemy):
|
||||
"""Create database tables and initialize default data."""
|
||||
from .models import User, Resource
|
||||
from .models import User
|
||||
from .roles import Role
|
||||
|
||||
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'):
|
||||
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 user:
|
||||
user.password_hash = User.hash_password(admin_password)
|
||||
user.change_password(admin_password)
|
||||
app.logger.info("Admin user password reset.")
|
||||
else:
|
||||
user = User(
|
||||
username=os.environ.get('ADMIN_USERNAME'),
|
||||
password_hash=User.hash_password(admin_password),
|
||||
role=Role.ADMIN
|
||||
user = User.create(
|
||||
email = os.environ.get('ADMIN_EMAIL'),
|
||||
username = 'Administrator',
|
||||
password = admin_password,
|
||||
role = Role.ADMIN
|
||||
)
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
app.logger.info("Admin user initialized.")
|
||||
else:
|
||||
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 datetime import datetime
|
||||
from secrets import token_hex
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from flask_login import UserMixin
|
||||
from .. import db, password_hasher
|
||||
from ..roles import Role
|
||||
|
||||
class User(db.Model):
|
||||
class User(db.Model, UserMixin):
|
||||
id = db.Column(db.Integer, primary_key=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)
|
||||
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')
|
||||
|
||||
@classmethod
|
||||
def hash_password(cls, password: str) -> str:
|
||||
"""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()
|
||||
|
||||
verification_token = db.Column(db.String(32), nullable=True)
|
||||
verification_token_generated = db.Column(db.DateTime, nullable=True)
|
||||
|
||||
def verify_password(self, password: str) -> bool:
|
||||
"""Verify a password against the stored hash"""
|
||||
try:
|
||||
password_hasher.verify(self.password_hash, password)
|
||||
if password_hasher.check_needs_rehash(self.password_hash):
|
||||
self.rehash_password(password)
|
||||
self.password_hash = _hash_password(password)
|
||||
db.session.commit()
|
||||
return True
|
||||
except VerifyMismatchError:
|
||||
return False
|
||||
|
||||
@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(
|
||||
email=email,
|
||||
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)
|
||||
|
||||
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()
|
||||
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)
|
|
@ -5,7 +5,7 @@ from flask_limiter import Limiter
|
|||
from flask_limiter.util import get_remote_address
|
||||
|
||||
DEFAULTS = {
|
||||
'RATELIMIT_STORAGE_URI': os.environ.get('REDIS_URL'),
|
||||
'RATELIMIT_STORAGE_URI': os.environ.get('REDIS_URL', "memory://"),
|
||||
'RATELIMIT_HEADERS_ENABLED': True,
|
||||
'RATELIMIT_KEY_PREFIX': 'buybuilds_rate_limit',
|
||||
}
|
||||
|
|
|
@ -1,7 +1,8 @@
|
|||
from enum import Enum
|
||||
from functools import total_ordering
|
||||
|
||||
@total_ordering
|
||||
class Role(Enum):
|
||||
UNVERIFIED = -1
|
||||
USER = 0
|
||||
PUBLISHER = 1
|
||||
MODERATOR = 2
|
||||
|
@ -9,3 +10,8 @@ class Role(Enum):
|
|||
|
||||
def __str__(self):
|
||||
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>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{% block title %}{% endblock %} - {{ config.SITE_TITLE }}</title>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/styles.css') }}">
|
||||
{{ bootstrap.load_css() }}
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<header>
|
||||
{% block header %}{% endblock %}
|
||||
</header>
|
||||
<header>
|
||||
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
|
||||
<div class="container">
|
||||
<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>
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div style="background-color: {% if category == 'error' %}#e8c7ca{% else %}#c4ddca{% endif %};
|
||||
color: {% if category == 'error' %}#5a0000{% else %}#003500{% endif %};
|
||||
padding: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
border-radius: 0.25rem;">
|
||||
{{ message }}
|
||||
{% if current_user.is_authenticated %}
|
||||
<div class="dropdown">
|
||||
<a class="btn btn-dark dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
{{ current_user.username }}
|
||||
</a>
|
||||
<ul class="dropdown-menu">
|
||||
<li class="dropdown-item">[{{ current_user.role }}] {{ current_user.username }}</li>
|
||||
|
||||
<li class="dropdown-divider"></li>
|
||||
<a class="dropdown-item" href="{{ url_for('auth.logout') }}">Logout</a>
|
||||
</ul>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
{% else %}
|
||||
<a class="btn btn-success" href="{{ url_for('auth.login') }}">Login</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
</div>
|
||||
<div class="container my-4 px-4">
|
||||
{% block header %}{% endblock %}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<footer>
|
||||
Powered by <a href="https://git.m724.eu/Minecon724/buybuilds" style="color: #4CAF50; text-decoration: none;">buybuilds</a>
|
||||
</footer>
|
||||
<main class="container">
|
||||
{{ render_messages(container=False, dismissible=True, dismiss_animate=True) }}
|
||||
|
||||
{% 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>
|
||||
</html>
|
|
@ -3,10 +3,10 @@
|
|||
{% block title %}{{ title }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="error-container">
|
||||
<div class="error-code">{{ status_code | default("Error") }}</div>
|
||||
<div class="container">
|
||||
<h1 class="display-1 px-3">{{ status_code | default("Error") }}</h1v>
|
||||
<h2>{{ title }}</h2>
|
||||
<p>{{ message }}</p>
|
||||
<p class="lead">{{ message }}</p>
|
||||
<div class="error-actions">
|
||||
{% if request.referrer %}
|
||||
<a href="{{ request.referrer }}" class="btn-secondary">Go Back</a>
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue