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

View file

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

View file

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

138
poetry.lock generated
View file

@ -1,4 +1,32 @@
# This file is automatically @generated by Poetry 2.1.1 and should not be changed by hand.
# This file is automatically @generated by Poetry 2.1.2 and should not be changed by hand.
[[package]]
name = "apscheduler"
version = "3.11.0"
description = "In-process task scheduler with Cron-like capabilities"
optional = false
python-versions = ">=3.8"
groups = ["main"]
files = [
{file = "APScheduler-3.11.0-py3-none-any.whl", hash = "sha256:fc134ca32e50f5eadcc4938e3a4545ab19131435e851abb40b34d63d5141c6da"},
{file = "apscheduler-3.11.0.tar.gz", hash = "sha256:4c622d250b0955a65d5d0eb91c33e6d43fd879834bf541e0a18661ae60460133"},
]
[package.dependencies]
tzlocal = ">=3.0"
[package.extras]
doc = ["packaging", "sphinx", "sphinx-rtd-theme (>=1.3.0)"]
etcd = ["etcd3", "protobuf (<=3.21.0)"]
gevent = ["gevent"]
mongodb = ["pymongo (>=3.0)"]
redis = ["redis (>=3.0)"]
rethinkdb = ["rethinkdb (>=2.4.0)"]
sqlalchemy = ["sqlalchemy (>=1.4)"]
test = ["APScheduler[etcd,mongodb,redis,rethinkdb,sqlalchemy,tornado,zookeeper]", "PySide6 ; platform_python_implementation == \"CPython\" and python_version < \"3.14\"", "anyio (>=4.5.2)", "gevent ; python_version < \"3.14\"", "pytest", "pytz", "twisted ; python_version < \"3.14\""]
tornado = ["tornado (>=4.3)"]
twisted = ["twisted"]
zookeeper = ["kazoo"]
[[package]]
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"

View file

@ -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]

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,10 +1,11 @@
from flask import Blueprint, Flask, render_template, request, redirect, url_for, session, flash
from 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)

View file

@ -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 %}

View file

@ -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 %}

View file

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

View file

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

View file

@ -1,12 +1,16 @@
from flask import Blueprint, jsonify, Flask, request, render_template, redirect, url_for, flash
from 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

View file

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

View file

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

View file

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

View file

@ -4,17 +4,6 @@
{% block header %}
<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 %}

View file

@ -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
View file

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

View file

@ -1,45 +1,85 @@
from argon2.exceptions import VerifyMismatchError
from 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)

View file

@ -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',
}

View file

@ -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

View file

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

View file

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

View file

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

View file

@ -1,38 +1,64 @@
{% from 'bootstrap5/form.html' import render_form %}
{% from 'bootstrap5/nav.html' import render_nav_item %}
{% from 'bootstrap5/utils.html' import render_messages %}
<!DOCTYPE html>
<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>

View file

@ -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>