diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..be0ea69 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -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" +} diff --git a/README.md b/README.md index 30404ce..cbc0428 100644 --- a/README.md +++ b/README.md @@ -1 +1,3 @@ -TODO \ No newline at end of file +TODO + +Use the devcontainer and `poetry run start` \ No newline at end of file diff --git a/poetry.lock b/poetry.lock index bc0e691..e205d26 100644 --- a/poetry.lock +++ b/poetry.lock @@ -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" diff --git a/pyproject.toml b/pyproject.toml index 2020dd2..e7584a0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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] diff --git a/src/buybuilds/__init__.py b/src/buybuilds/__init__.py index c1a0f0e..4b17992 100644 --- a/src/buybuilds/__init__.py +++ b/src/buybuilds/__init__.py @@ -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") diff --git a/src/buybuilds/auth/annonations.py b/src/buybuilds/auth/annonations.py deleted file mode 100644 index 20089cf..0000000 --- a/src/buybuilds/auth/annonations.py +++ /dev/null @@ -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 \ No newline at end of file diff --git a/src/buybuilds/blueprints/__init__.py b/src/buybuilds/blueprints/__init__.py new file mode 100644 index 0000000..a083803 --- /dev/null +++ b/src/buybuilds/blueprints/__init__.py @@ -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) \ No newline at end of file diff --git a/src/buybuilds/blueprints/admin/__init__.py b/src/buybuilds/blueprints/admin/__init__.py new file mode 100644 index 0000000..f79a740 --- /dev/null +++ b/src/buybuilds/blueprints/admin/__init__.py @@ -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/', 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) \ No newline at end of file diff --git a/src/buybuilds/blueprints/admin/templates/dashboard.html b/src/buybuilds/blueprints/admin/templates/dashboard.html new file mode 100644 index 0000000..5e25a2c --- /dev/null +++ b/src/buybuilds/blueprints/admin/templates/dashboard.html @@ -0,0 +1,11 @@ +{% extends 'base.html' %} + +{% block title %}Admin Dashboard{% endblock %} + +{% block header %} +

Admin Dashboard

+{% endblock %} + +{% block content %} + +{% endblock %} \ No newline at end of file diff --git a/src/buybuilds/blueprints/admin/templates/manage_user.html b/src/buybuilds/blueprints/admin/templates/manage_user.html new file mode 100644 index 0000000..913eda1 --- /dev/null +++ b/src/buybuilds/blueprints/admin/templates/manage_user.html @@ -0,0 +1,11 @@ +{% extends 'base.html' %} + +{% block title %}Manage User{% endblock %} + +{% block header %} +

Manage User {{ user.username }}

+{% endblock %} + +{% block content %} + {{ render_form() }} +{% endblock %} \ No newline at end of file diff --git a/src/buybuilds/blueprints/auth/__init__.py b/src/buybuilds/blueprints/auth/__init__.py index 7385eec..dd20ee5 100644 --- a/src/buybuilds/blueprints/auth/__init__.py +++ b/src/buybuilds/blueprints/auth/__init__.py @@ -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')) \ No newline at end of file + return redirect_to_next(default=url_for('.login')) + +@blueprint.route('/verify/') +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) \ No newline at end of file diff --git a/src/buybuilds/blueprints/auth/templates/login.html b/src/buybuilds/blueprints/auth/templates/login.html index 76c964e..0337922 100644 --- a/src/buybuilds/blueprints/auth/templates/login.html +++ b/src/buybuilds/blueprints/auth/templates/login.html @@ -3,16 +3,9 @@ {% block title %}Login{% endblock %} {% block header %} -

Login

+

Login (or Register)

{% endblock %} {% block content %} - {% from "_formhelpers.html" import render_field %} -
- {{ form.csrf_token }} - {{ render_field(form.username) }} - {{ render_field(form.password) }} - - -
+ {{ render_form(form) }} {% endblock %} \ No newline at end of file diff --git a/src/buybuilds/blueprints/auth/templates/register.html b/src/buybuilds/blueprints/auth/templates/register.html index abf63f0..da2316c 100644 --- a/src/buybuilds/blueprints/auth/templates/register.html +++ b/src/buybuilds/blueprints/auth/templates/register.html @@ -1,21 +1,11 @@ {% extends 'base.html' %} -{% block title %}Login{% endblock %} +{% block title %}Register{% endblock %} {% block header %} -

Login

+

Register (or Login)

{% endblock %} {% block content %} - {% from "_formhelpers.html" import render_field %} -
- {{ form.csrf_token }} - - {{ render_field(form.email) }} - {{ render_field(form.username) }} - {{ render_field(form.password) }} - {{ render_field(form.confirm_password) }} - - -
+ {{ render_form(form) }} {% endblock %} \ No newline at end of file diff --git a/src/buybuilds/blueprints/pages/__init__.py b/src/buybuilds/blueprints/pages/__init__.py new file mode 100644 index 0000000..91505a6 --- /dev/null +++ b/src/buybuilds/blueprints/pages/__init__.py @@ -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') \ No newline at end of file diff --git a/src/buybuilds/blueprints/pages/templates/index.html b/src/buybuilds/blueprints/pages/templates/index.html new file mode 100644 index 0000000..48f54da --- /dev/null +++ b/src/buybuilds/blueprints/pages/templates/index.html @@ -0,0 +1,11 @@ +{% extends "base.html" %} + +{% block title %}BuyBuilds{% endblock %} + +{% block header %} +

BuyBuilds

+{% endblock %} + +{% block content %} +

TODO

+{% endblock %} diff --git a/src/buybuilds/blueprints/publisher/__init__.py b/src/buybuilds/blueprints/publisher/__init__.py index 123daf0..38f5737 100644 --- a/src/buybuilds/blueprints/publisher/__init__.py +++ b/src/buybuilds/blueprints/publisher/__init__.py @@ -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 diff --git a/src/buybuilds/blueprints/publisher/create_resource.html b/src/buybuilds/blueprints/publisher/create_resource.html deleted file mode 100644 index acba277..0000000 --- a/src/buybuilds/blueprints/publisher/create_resource.html +++ /dev/null @@ -1,25 +0,0 @@ -{% extends "base.html" %} - -{% block title %}Create New Resource{% endblock %} - -{% block header %} -

Create New Resource

-{% endblock %} - -{% block content %} -
-

Create a new resource to start selling your builds.

- - {% from "_formhelpers.html" import render_field %} -
-
- {{ render_field(form.username) }} - {{ render_field(form.email) }} - {{ render_field(form.password) }} - {{ render_field(form.confirm) }} - {{ render_field(form.accept_tos) }} -
-

-

-
-{% endblock %} \ No newline at end of file diff --git a/src/buybuilds/blueprints/publisher/settings.html b/src/buybuilds/blueprints/publisher/settings.html deleted file mode 100644 index 19efac8..0000000 --- a/src/buybuilds/blueprints/publisher/settings.html +++ /dev/null @@ -1,164 +0,0 @@ -{% extends 'base.html' %} - -{% block title %}Admin Settings{% endblock %} - -{% block header %} -

Admin Settings

-{% endblock %} - -{% block content %} -
-

Change Admin Password

-
- -
- - -
-
- - - Password must be at least 8 characters -
-
- - -
- -
-
- -
- -
-

General Settings

- -
- - - - -
- - - -
- - -
-
- -
- -
-

Webhook Notification Settings

- -
-

Configure webhook settings to receive notifications for new inquiries and responses.

-

Events will be sent as POST requests with JSON payloads. Event types include:

-
    -
  • inquiry_created - Triggered when a new inquiry is created. Contains the inquiry ID and the initial message.
  • -
  • inquiry_reopened - Triggered when an inquiry is reopened. Contains the inquiry ID.
  • -
  • inquiry_closed - Triggered when an inquiry is closed. Contains the inquiry ID.
  • -
  • inquiry_message - Triggered when a user adds a message to an existing inquiry. Contains the inquiry ID and the message content.
  • -
-
- -
- -
- -
- -
- - -

The URL that will receive webhook events

-
- -
- - -

If provided, webhooks will include a signature header (X-Webhook-Signature)

-
- - -
- -
-

Webhook Payload Example

-
-    {
-      "event_type": "inquiry_created",
-      "timestamp": "2023-04-01T12:34:56.789Z",
-      "data": {
-        "inquiry_id": "abcdef1234567890",
-        "message": "Hello, I have a question..."
-      }
-    }
-
-
- -
- - -{% endblock %} \ No newline at end of file diff --git a/src/buybuilds/blueprints/publisher/templates/create_resource.html b/src/buybuilds/blueprints/publisher/templates/create_resource.html new file mode 100644 index 0000000..5842bc1 --- /dev/null +++ b/src/buybuilds/blueprints/publisher/templates/create_resource.html @@ -0,0 +1,15 @@ +{% extends "base.html" %} + +{% block title %}Create New Resource{% endblock %} + +{% block header %} +

Create New Resource

+{% endblock %} + +{% block content %} +
+

Create a new resource to start selling your builds.

+ + {{ render_form(form) }} +
+{% endblock %} \ No newline at end of file diff --git a/src/buybuilds/blueprints/publisher/dashboard.html b/src/buybuilds/blueprints/publisher/templates/dashboard.html similarity index 60% rename from src/buybuilds/blueprints/publisher/dashboard.html rename to src/buybuilds/blueprints/publisher/templates/dashboard.html index 13dd020..06cd858 100644 --- a/src/buybuilds/blueprints/publisher/dashboard.html +++ b/src/buybuilds/blueprints/publisher/templates/dashboard.html @@ -4,17 +4,6 @@ {% block header %}

Publisher Dashboard

- - {% endblock %} {% block content %} @@ -40,6 +29,6 @@ {% else %} -

No resources found. Create a new resource

+

No resources found. Create a new resource

{% endif %} {% endblock %} \ No newline at end of file diff --git a/src/buybuilds/database.py b/src/buybuilds/database.py index 818a19e..845226a 100644 --- a/src/buybuilds/database.py +++ b/src/buybuilds/database.py @@ -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'): diff --git a/src/buybuilds/login.py b/src/buybuilds/login.py new file mode 100644 index 0000000..d2cd971 --- /dev/null +++ b/src/buybuilds/login.py @@ -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 + diff --git a/src/buybuilds/models/user.py b/src/buybuilds/models/user.py index 94fae57..0675ee7 100644 --- a/src/buybuilds/models/user.py +++ b/src/buybuilds/models/user.py @@ -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 \ No newline at end of file + + 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) \ No newline at end of file diff --git a/src/buybuilds/ratelimit.py b/src/buybuilds/ratelimit.py index 2260cd0..68249d0 100644 --- a/src/buybuilds/ratelimit.py +++ b/src/buybuilds/ratelimit.py @@ -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', } diff --git a/src/buybuilds/roles.py b/src/buybuilds/roles.py index f255def..7e3dfbf 100644 --- a/src/buybuilds/roles.py +++ b/src/buybuilds/roles.py @@ -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 diff --git a/src/buybuilds/scheduler.py b/src/buybuilds/scheduler.py new file mode 100644 index 0000000..b44ba4f --- /dev/null +++ b/src/buybuilds/scheduler.py @@ -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") \ No newline at end of file diff --git a/src/buybuilds/static/css/styles.css b/src/buybuilds/static/css/styles.css deleted file mode 100644 index bc1ecb2..0000000 --- a/src/buybuilds/static/css/styles.css +++ /dev/null @@ -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; -} \ No newline at end of file diff --git a/src/buybuilds/templates/_formhelpers.html b/src/buybuilds/templates/_formhelpers.html deleted file mode 100644 index 952b97b..0000000 --- a/src/buybuilds/templates/_formhelpers.html +++ /dev/null @@ -1,15 +0,0 @@ -{% macro render_field(field) %} - {{ field.label }}: - - {% if field.errors %} -
- {% for error in field.errors %} - {{ error }} -
- {% endfor %} -
- {% endif %} - - {{ field(**kwargs)|safe }} - -{% endmacro %} \ No newline at end of file diff --git a/src/buybuilds/templates/base.html b/src/buybuilds/templates/base.html index 4cfa21f..a170616 100644 --- a/src/buybuilds/templates/base.html +++ b/src/buybuilds/templates/base.html @@ -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 %} + {% block title %}{% endblock %} - {{ config.SITE_TITLE }} - + {{ bootstrap.load_css() }} -
-
- {% block header %}{% endblock %} -
+
+ - {% block content %}{% endblock %} - -
+
+ {% block header %}{% endblock %} +
+ - +
+ {{ render_messages(container=False, dismissible=True, dismiss_animate=True) }} + + {% block content %}{% endblock %} +
+ + \ No newline at end of file diff --git a/src/buybuilds/templates/error.html b/src/buybuilds/templates/error.html index ec2ee5c..3dcc69a 100644 --- a/src/buybuilds/templates/error.html +++ b/src/buybuilds/templates/error.html @@ -3,10 +3,10 @@ {% block title %}{{ title }}{% endblock %} {% block content %} -
-
{{ status_code | default("Error") }}
+
+

{{ status_code | default("Error") }}

{{ title }}

-

{{ message }}

+

{{ message }}

{% if request.referrer %} Go Back