Initial commit

This commit is contained in:
Minecon724 2025-04-11 20:32:43 +02:00
commit 3b1f82cf17
Signed by: Minecon724
GPG key ID: A02E6E67AB961189
33 changed files with 2566 additions and 0 deletions

11
.env.dev Normal file
View file

@ -0,0 +1,11 @@
# .env for development
SECRET_KEY=123456
FLASK_ENV=development
DATABASE_URL=sqlite:///anonchat.db
SESSION_TYPE=filesystem
RATELIMIT_STORAGE_URL=memory://
ADMIN_USERNAME=admin
ADMIN_PASSWORD=admin

58
.gitignore vendored Normal file
View file

@ -0,0 +1,58 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
dist/
build/
*.egg-info/
*.egg
# Virtual environments
venv/
env/
ENV/
.venv/
.env
# Unit test / coverage reports
htmlcov/
.tox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
.hypothesis/
.pytest_cache/
# Flask instance folder
instance/
# SQLite database files
*.sqlite
*.db
# Log files
*.log
# IDE specific files
.idea/
.vscode/
*.swp
*.swo
# Local development settings
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# Redis dump file
dump.rdb

60
Dockerfile Normal file
View file

@ -0,0 +1,60 @@
# Use an official Python runtime as a parent image
FROM python:3.11-slim
# Set the working directory in the container
WORKDIR /app
# Install system dependencies
RUN apt-get update && apt-get install -y \
curl \
&& rm -rf /var/lib/apt/lists/*
# Install Poetry
RUN pip install poetry
# Create a non-root user and group with proper home directory
RUN groupadd -r appuser && \
useradd -r -g appuser -d /home/appuser -m appuser && \
mkdir -p /home/appuser/.cache && \
chown -R appuser:appuser /home/appuser
# Copy the dependency files
COPY pyproject.toml poetry.lock ./
# Set proper Poetry configuration to use app directory
ENV POETRY_CACHE_DIR=/app/.poetry_cache
# Install project dependencies
# --no-root avoids installing the project itself, only dependencies
# --only main installs only the main dependencies (not dev)
RUN poetry install --no-root --only main && \
mkdir -p /app/.poetry_cache && \
chown -R appuser:appuser /app/.poetry_cache
# Copy the rest of the application code
COPY src/ ./src/
COPY entrypoint.sh ./
# Make the entrypoint script executable
RUN chmod +x ./entrypoint.sh
# Change ownership of the application files to the non-root user
RUN chown -R appuser:appuser /app
# Make port 5000 available to the world outside this container
EXPOSE 5000
# Switch to non-root user
USER appuser
# Configure health check
# Check every 30 seconds with 3 second timeout, 3 retries, and 5 second start period
HEALTHCHECK --interval=30s --timeout=3s --retries=3 --start-period=5s CMD curl -f http://localhost:5000/health || exit 1
# Set the entrypoint script
ENTRYPOINT ["./entrypoint.sh"]
# Define the command to run the application using Gunicorn
# This will be passed as arguments to the entrypoint script
# Use `poetry run` to execute the command within the Poetry environment
CMD ["poetry", "run", "gunicorn", "--bind", "0.0.0.0:5000", "src.buybuilds:app"]

5
LICENSE.txt Normal file
View file

@ -0,0 +1,5 @@
Copyright (C) 2025 by Minecon724 <dm@m724.eu>
Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.

1
README.md Normal file
View file

@ -0,0 +1 @@
TODO

16
entrypoint.sh Normal file
View file

@ -0,0 +1,16 @@
#!/bin/bash
set -e
# Run database migrations/initialization
echo "Running database initialization..."
poetry run flask --app src/buybuilds init-db
echo "Database initialization complete."
# Run database migrations to ensure schema is up to date
echo "Running database migrations..."
poetry run flask --app src/buybuilds run-migrations
echo "Database migrations complete."
# Execute the command passed as arguments (CMD in Dockerfile)
echo "Executing command: $@"
exec "$@"

1150
poetry.lock generated Normal file

File diff suppressed because it is too large Load diff

36
pyproject.toml Normal file
View file

@ -0,0 +1,36 @@
[project]
name = "buybuilds"
version = "0.1.0"
description = ""
authors = [
{name = "Minecon724", email = "dm@m724.eu"}
]
license = "0BSD"
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
"flask (>=3.1.0,<4.0.0)",
"flask-sqlalchemy (>=3.1.0,<4.0.0)",
"sqlalchemy (>=2.0.0,<3.0.0)",
"python-dotenv (>=1.0.0,<2.0.0)",
"flask-limiter (>=3.12,<4.0)",
"flask-wtf (>=1.2.0,<2.0.0)",
"gunicorn (>=23.0.0,<24.0.0)",
"psycopg2-binary (>=2.9.9,<3.0.0)",
"redis (>=5.0.0,<6.0.0)",
"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)"
]
[tool.poetry]
packages = [{include = "buybuilds", from = "src"}]
[tool.poetry.scripts]
start = "buybuilds:run"
[build-system]
requires = ["poetry-core>=2.0.0,<3.0.0"]
build-backend = "poetry.core.masonry.api"

90
src/buybuilds/__init__.py Normal file
View file

@ -0,0 +1,90 @@
import logging
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()
DEFAULTS = {
'SECRET_KEY': '',
'BEHIND_PROXY': False
}
def get(key, type: type = str):
value = os.environ.get(key)
if value is None:
return DEFAULTS[key]
if type == bool:
value = value.lower() == 'true'
return value
app = Flask(__name__)
app.config['SECRET_KEY'] = get('SECRET_KEY')
app.config['BEHIND_PROXY'] = get('BEHIND_PROXY', bool)
app.url_map.strict_slashes = False
if app.config['BEHIND_PROXY']:
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=2)
# Initialize password hasher
# Parameters source: https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html#argon2id
password_hasher = PasswordHasher(
time_cost=2,
memory_cost=19456,
parallelism=1
)
from .session import create_session
create_session(app)
# Initialize limiter with custom key_func
from .ratelimit import create_ratelimit
limiter = create_ratelimit(app)
# Initialize database
from .database import create_database
db = create_database(app)
from .auth.annonations import get_user
app.jinja_env.filters['user'] = get_user
# Health check endpoint
@app.route('/health', methods=['GET'])
def health_check():
return jsonify(status="healthy"), 200
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)
# Flask CLI command to run migrations
@app.cli.command("run-migrations")
def run_migrations_command():
"""Run database migrations to update schema."""
database.run_migrations(app, db)
# Flask CLI command to initialize the database
@app.cli.command("init-db")
def init_db_command():
"""Initialize the database."""
database.initialize_database(app, db)
def run() -> None:
app.logger.setLevel(logging.DEBUG)
database.initialize_database(app, db)
app.run(debug=True)

View file

@ -0,0 +1,32 @@
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,97 @@
from flask import Blueprint, Flask, render_template, request, redirect, url_for, session, flash
from wtforms import StringField, validators
from flask_wtf import FlaskForm
from zxcvbn import zxcvbn
from wtforms.validators import ValidationError
from buybuilds.models import User
from buybuilds import db
blueprint = Blueprint(
name='auth',
import_name=__name__,
template_folder='templates',
url_prefix='/auth'
)
class LoginForm(FlaskForm):
email = StringField('Email', [validators.Email()])
password = StringField('Password', [validators.Length(min=8, max=64)]) # per OWASP recommendations
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')])
def validate_password(self, field):
results = zxcvbn(field.data, user_inputs=[self.email.data, self.username.data])
if results['score'] < 3:
warning = results['feedback']['warning']
if not warning:
suggestions = results['feedback']['suggestions']
warning = suggestions[0] if len(suggestions) > 0 else 'Password is too weak.'
raise ValidationError(warning)
def register_routes(app: Flask):
app.register_blueprint(blueprint)
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
return redirect(next)
@blueprint.route('/login', methods=['GET', 'POST'])
def login():
form = LoginForm(request.form)
if form.validate_on_submit():
email = form.email.data
password = form.password.data
user = User.query.filter_by(email=email).first()
if user and user.verify_password(password):
session['user_id'] = user.id
return redirect_to_next(default=url_for('.login_success'))
else:
flash('Invalid username or password', 'error')
return render_template('login.html', form=form)
@blueprint.route('/register', methods=['GET', 'POST'])
def register():
form = RegisterForm(request.form)
if form.validate_on_submit():
email = form.email.data
username = form.username.data
password = form.password.data
User.create(email, username, password)
# TODO send verification email and remember about next url
return redirect(url_for('.register_success'))
return render_template('register.html', form=form)
@blueprint.route('/register_success')
def register_success():
return render_template('register_success.html')
@blueprint.route('/login_success')
def login_success():
# TODO do something if no next url
return "OK"
@blueprint.route('/logout')
def logout():
session.clear()
flash('You have been logged out', 'success')
return redirect_to_next(default=url_for('.login'))

View file

@ -0,0 +1,18 @@
{% extends 'base.html' %}
{% block title %}Login{% endblock %}
{% block header %}
<h2>Login</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>
{% endblock %}

View file

@ -0,0 +1,21 @@
{% extends 'base.html' %}
{% block title %}Login{% endblock %}
{% block header %}
<h2>Login</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>
{% endblock %}

View file

@ -0,0 +1,17 @@
{% extends 'base.html' %}
{% block title %}Verification Email Sent{% endblock %}
{% block header %}
<h2>Verification Email Sent</h2>
{% endblock %}
{% block content %}
<p>
A verification email has been sent to the email address you provided.
Please follow the instructions in the email to verify your account.
</p>
<p>
You may close this page.
</p>
{% endblock %}

View file

@ -0,0 +1,40 @@
from flask import Blueprint, jsonify, Flask, request, render_template, redirect, url_for, flash
from wtforms import Form, StringField, IntegerField, validators
from buybuilds.models.resource import Resource
from buybuilds import db
blueprint = Blueprint(
name='publisher',
import_name=__name__,
url_prefix='/publisher'
)
def register_routes(app: Flask):
app.register_blueprint(blueprint)
class CreateResourceForm(Form):
name = StringField('Name', [validators.Length(min=2, max=30)])
price = IntegerField('Price', [validators.NumberRange(min=0, max=100)])
@blueprint.route('/', methods=['GET'])
def index():
return jsonify(status="healthy"), 200
@blueprint.route('/create_resource', methods=['GET'])
def create_resource():
form = CreateResourceForm(request.form)
if request.method == 'POST' and form.validate():
name = form.name.data
price = form.price.data
resource = Resource(name=name, price=price * 100)
db.session.add(resource)
db.session.commit()
flash("Resource created successfully", "success")
return redirect(url_for('resource.resource', resource_id=resource.id))
return render_template('create_resource.html', form=form)

View file

@ -0,0 +1,25 @@
{% 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

@ -0,0 +1,45 @@
{% extends 'base.html' %}
{% block title %}Publisher Dashboard{% endblock %}
{% 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 %}
<h3>All Resources</h3>
{% if resources %}
<table>
<thead>
<tr>
<th>ID</th>
<th>Name</th>
<th>Price</th>
</tr>
</thead>
<tbody>
{% for resource in resources %}
<tr>
<td>{{ resource.id }}</td>
<td>{{ resource.name }}</td>
<td>{{ resource.price }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p>No resources found. <a href="{{ url_for('publisher_create_resource') }}">Create a new resource</a></p>
{% endif %}
{% endblock %}

View file

@ -0,0 +1,164 @@
{% 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,21 @@
from flask import Blueprint, Flask, render_template, abort
from buybuilds.models.resource import Resource
blueprint = Blueprint(
name='resource',
import_name=__name__,
url_prefix='/resource'
)
def register_routes(app: Flask):
app.register_blueprint(blueprint)
@blueprint.route('/<resource_id>', methods=['GET'])
def resource(resource_id: int):
resource = Resource.query.get(resource_id)
if not resource:
abort(404)
return render_template('resource.html', resource=resource)

View file

@ -0,0 +1,14 @@
{% extends "base.html" %}
{% block title %}Resource{% endblock %}
{% block header %}
<h2>Resource</h2>
{% endblock %}
{% block content %}
<p>ID: {{ resource.id }}</p>
<p>Name: {{ resource.name }}</p>
<p>Price: {{ resource.price }}</p>
<p>Publisher: {{ resource.user.username }}</p>
{% endblock %}

79
src/buybuilds/database.py Normal file
View file

@ -0,0 +1,79 @@
import os
from flask import Flask
from flask_sqlalchemy import SQLAlchemy
DEFAULTS = {
'DATABASE_URL': 'sqlite:///buybuilds.db'
}
def get(key, type: type = str):
value = os.environ.get(key)
if value is None:
return DEFAULTS[key]
if type == bool:
value = value.lower() == 'true'
return value
def create_database(app: Flask) -> SQLAlchemy:
"""Create a new database instance."""
app.config['SQLALCHEMY_DATABASE_URI'] = get('DATABASE_URL')
return SQLAlchemy(app)
def initialize_database(app: Flask, db: SQLAlchemy):
"""Create database tables and initialize default data."""
from .models import User, Resource
from .roles import Role
with app.app_context():
app.logger.info("Creating database tables...")
db.create_all() # Should now create all tables including Inquiry
# Run migrations after creating tables
# This ensures any new columns added to existing tables are properly added
app.logger.info("Running database migrations...")
from .migrations import run_migrations
run_migrations(db)
app.logger.info("Initializing admin user...")
# Initialize admin user if it doesn't exist
# Note: Models already imported above
user = User.query.filter_by(role=Role.ADMIN).first()
if not user or os.environ.get('ADMIN_FORCE_RESET'):
admin_password = os.environ.get('ADMIN_PASSWORD')
if admin_password is not None:
if user:
user.password_hash = User.hash_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
)
db.session.add(user)
db.session.commit()
app.logger.info("Admin user initialized.")
else:
if os.environ.get('ADMIN_FORCE_RESET'):
app.logger.warning("Admin force reset is enabled, but no password was provided. Skipping admin user initialization.")
else:
app.logger.warning("Admin user not found and no password provided. Skipping admin user initialization.")
else:
app.logger.info("No need to initialize admin user.")
app.logger.info("Database initialization complete.")
def run_migrations(app: Flask, db: SQLAlchemy):
with app.app_context():
from .migrations import run_migrations
app.logger.info("Running database migrations...")
migrations_run = run_migrations(db)
app.logger.info(f"Database migrations completed: {migrations_run} migrations applied.")

View file

@ -0,0 +1,27 @@
from flask import request, render_template, jsonify
import werkzeug.exceptions
def register_error_handlers(app):
@app.errorhandler(werkzeug.exceptions.NotFound)
def page_not_found(e):
return render_error(title="Page Not Found", message="The page you are looking for does not exist. It may have been moved or deleted.", status_code=e.code)
# Rate limit exceeded handler
@app.errorhandler(werkzeug.exceptions.TooManyRequests)
def ratelimit_handler(e):
return render_error(title="Rate limit exceeded", message="You have made too many requests. Please try again later.", status_code=e.code)
# CSRF error handler
@app.errorhandler(werkzeug.exceptions.BadRequest)
def handle_bad_request(e):
return render_error(title="Invalid request", message="A security error occurred. Please try again.", status_code=e.code)
@app.errorhandler(werkzeug.exceptions.InternalServerError)
def internal_server_error(e):
return render_error(title="Internal Server Error", message="An unexpected error occurred. Please try again later.", status_code=e.code)
def render_error(title="Error", message="An unexpected error occurred.", status_code=500):
if request.is_json or request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return jsonify(error=title, message=message), status_code
else:
return render_template('error.html', title=title, message=message, status_code=status_code), status_code

View file

@ -0,0 +1,31 @@
from flask import current_app
from importlib import import_module
import os
import pkgutil
def run_migrations(db):
"""Run all migrations in the migrations directory"""
current_app.logger.info("Starting database migrations")
migrations_run = 0
# Get the list of all migration modules in this package
# This will look for all Python modules in the migrations directory
migrations_pkg = __name__
for _, name, ispkg in pkgutil.iter_modules([os.path.dirname(__file__)]):
if name.startswith('_') or ispkg:
continue
# Import the migration module
current_app.logger.info(f"Importing migration: {name}")
migration_module = import_module(f"{migrations_pkg}.{name}")
# Run the migration if it has a run_migration function
if hasattr(migration_module, 'run_migration'):
current_app.logger.info(f"Running migration: {name}")
if migration_module.run_migration(db):
migrations_run += 1
else:
current_app.logger.warning(f"Migration {name} has no run_migration function")
current_app.logger.info(f"Database migrations completed. {migrations_run} migrations applied.")
return migrations_run

View file

@ -0,0 +1,4 @@
from .resource import Resource
from .user import User
__all__ = ['Resource', 'User']

View file

@ -0,0 +1,17 @@
from .. import db
from .user import User
class Resource(db.Model):
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(30), unique=True, nullable=False, index=True)
price = db.Column(db.Integer, nullable=False)
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False, index=True)
user = db.relationship('User', back_populates='resources')
@classmethod
def create(cls, name: str, price: int, user: User) -> 'Resource':
resource = cls(name=name, price=price, user=user)
db.session.add(resource)
db.session.commit()
return resource

View file

@ -0,0 +1,45 @@
from argon2.exceptions import VerifyMismatchError
from .. import db, password_hasher
from ..roles import Role
class User(db.Model):
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)
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()
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)
return True
except VerifyMismatchError:
return False
@classmethod
def create(cls, email: str, username: str, password: str) -> 'User':
user = cls(
email=email,
username=username,
password_hash=User.hash_password(password)
)
db.session.add(user)
db.session.commit()
return user

View file

@ -0,0 +1,29 @@
import os
from flask import Flask
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address
DEFAULTS = {
'RATELIMIT_STORAGE_URI': os.environ.get('REDIS_URL'),
'RATELIMIT_HEADERS_ENABLED': True,
'RATELIMIT_KEY_PREFIX': 'buybuilds_rate_limit',
}
def get(key, type: type = str):
value = os.environ.get(key)
if value is None:
return DEFAULTS[key]
if type == bool:
value = value.lower() == 'true'
return value
def create_ratelimit(app: Flask):
app.config['RATELIMIT_STORAGE_URI'] = get('RATELIMIT_STORAGE_URI')
app.config['RATELIMIT_HEADERS_ENABLED'] = get('RATELIMIT_HEADERS_ENABLED')
app.config['RATELIMIT_KEY_PREFIX'] = get('RATELIMIT_KEY_PREFIX')
return Limiter(get_remote_address, app=app)

11
src/buybuilds/roles.py Normal file
View file

@ -0,0 +1,11 @@
from enum import Enum
class Role(Enum):
UNVERIFIED = -1
USER = 0
PUBLISHER = 1
MODERATOR = 2
ADMIN = 100
def __str__(self):
return self.name.capitalize()

47
src/buybuilds/session.py Normal file
View file

@ -0,0 +1,47 @@
from flask import Flask
from flask_session import Session
import os
import redis
DEFAULTS = {
'SESSION_TYPE': 'redis',
'SESSION_PERMANENT': False,
'SESSION_USE_SIGNER': True,
# If SESSION_TYPE is redis
'SESSION_REDIS': redis.from_url(os.environ.get('REDIS_URL', 'redis://localhost:6379/0')),
# If SESSION_TYPE is filesystem
'SESSION_FILE_DIR': '/dev/shm/flask_session',
'SESSION_FILE_THRESHOLD': 100,
'SESSION_FILE_MODE': 384,
'SESSION_KEY_PREFIX': 'buybuilds_session:'
}
def get(key, type: type = str):
value = os.environ.get(key)
if value is None:
return DEFAULTS[key]
if type == bool:
value = value.lower() == 'true'
return value
def create_session(app: Flask):
"""Create a new session instance."""
app.config['SESSION_TYPE'] = get('SESSION_TYPE')
app.config['SESSION_PERMANENT'] = get('SESSION_PERMANENT')
app.config['SESSION_USE_SIGNER'] = get('SESSION_USE_SIGNER')
if app.config['SESSION_TYPE'] == 'redis':
app.config['SESSION_REDIS'] = get('SESSION_REDIS')
elif app.config['SESSION_TYPE'] == 'filesystem':
app.config['SESSION_FILE_DIR'] = get('SESSION_FILE_DIR', '/dev/shm/flask_session')
app.config['SESSION_FILE_THRESHOLD'] = get('SESSION_FILE_THRESHOLD', 100)
app.config['SESSION_FILE_MODE'] = get('SESSION_FILE_MODE', 384)
app.config['SESSION_KEY_PREFIX'] = get('SESSION_KEY_PREFIX')
return Session(app)

View file

@ -0,0 +1,286 @@
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

@ -0,0 +1,15 @@
{% 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

@ -0,0 +1,38 @@
<!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') }}">
</head>
<body>
<div id="app">
<header>
{% block header %}{% endblock %}
</header>
<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 }}
</div>
{% endfor %}
{% endif %}
{% endwith %}
{% block content %}{% endblock %}
</main>
</div>
<footer>
Powered by <a href="https://git.m724.eu/Minecon724/buybuilds" style="color: #4CAF50; text-decoration: none;">buybuilds</a>
</footer>
</body>
</html>

View file

@ -0,0 +1,16 @@
{% extends "base.html" %}
{% block title %}{{ title }}{% endblock %}
{% block content %}
<div class="error-container">
<div class="error-code">{{ status_code | default("Error") }}</div>
<h2>{{ title }}</h2>
<p>{{ message }}</p>
<div class="error-actions">
{% if request.referrer %}
<a href="{{ request.referrer }}" class="btn-secondary">Go Back</a>
{% endif %}
</div>
</div>
{% endblock %}