Initial commit
This commit is contained in:
commit
3b1f82cf17
33 changed files with 2566 additions and 0 deletions
11
.env.dev
Normal file
11
.env.dev
Normal 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
58
.gitignore
vendored
Normal 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
60
Dockerfile
Normal 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
5
LICENSE.txt
Normal 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
1
README.md
Normal file
|
@ -0,0 +1 @@
|
|||
TODO
|
16
entrypoint.sh
Normal file
16
entrypoint.sh
Normal 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
1150
poetry.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
36
pyproject.toml
Normal file
36
pyproject.toml
Normal 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
90
src/buybuilds/__init__.py
Normal 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)
|
32
src/buybuilds/auth/annonations.py
Normal file
32
src/buybuilds/auth/annonations.py
Normal 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
|
97
src/buybuilds/blueprints/auth/__init__.py
Normal file
97
src/buybuilds/blueprints/auth/__init__.py
Normal 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'))
|
18
src/buybuilds/blueprints/auth/templates/login.html
Normal file
18
src/buybuilds/blueprints/auth/templates/login.html
Normal 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 %}
|
21
src/buybuilds/blueprints/auth/templates/register.html
Normal file
21
src/buybuilds/blueprints/auth/templates/register.html
Normal 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 %}
|
|
@ -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 %}
|
40
src/buybuilds/blueprints/publisher/__init__.py
Normal file
40
src/buybuilds/blueprints/publisher/__init__.py
Normal 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)
|
||||
|
25
src/buybuilds/blueprints/publisher/create_resource.html
Normal file
25
src/buybuilds/blueprints/publisher/create_resource.html
Normal 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 %}
|
45
src/buybuilds/blueprints/publisher/dashboard.html
Normal file
45
src/buybuilds/blueprints/publisher/dashboard.html
Normal 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 %}
|
164
src/buybuilds/blueprints/publisher/settings.html
Normal file
164
src/buybuilds/blueprints/publisher/settings.html
Normal 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 %}
|
21
src/buybuilds/blueprints/resource/__init__.py
Normal file
21
src/buybuilds/blueprints/resource/__init__.py
Normal 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)
|
14
src/buybuilds/blueprints/resource/templates/resource.html
Normal file
14
src/buybuilds/blueprints/resource/templates/resource.html
Normal 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
79
src/buybuilds/database.py
Normal 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.")
|
27
src/buybuilds/error_handlers.py
Normal file
27
src/buybuilds/error_handlers.py
Normal 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
|
31
src/buybuilds/migrations/__init__.py
Normal file
31
src/buybuilds/migrations/__init__.py
Normal 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
|
4
src/buybuilds/models/__init__.py
Normal file
4
src/buybuilds/models/__init__.py
Normal file
|
@ -0,0 +1,4 @@
|
|||
from .resource import Resource
|
||||
from .user import User
|
||||
|
||||
__all__ = ['Resource', 'User']
|
17
src/buybuilds/models/resource.py
Normal file
17
src/buybuilds/models/resource.py
Normal 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
|
45
src/buybuilds/models/user.py
Normal file
45
src/buybuilds/models/user.py
Normal 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
|
29
src/buybuilds/ratelimit.py
Normal file
29
src/buybuilds/ratelimit.py
Normal 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
11
src/buybuilds/roles.py
Normal 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
47
src/buybuilds/session.py
Normal 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)
|
286
src/buybuilds/static/css/styles.css
Normal file
286
src/buybuilds/static/css/styles.css
Normal 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;
|
||||
}
|
15
src/buybuilds/templates/_formhelpers.html
Normal file
15
src/buybuilds/templates/_formhelpers.html
Normal 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 %}
|
38
src/buybuilds/templates/base.html
Normal file
38
src/buybuilds/templates/base.html
Normal 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>
|
16
src/buybuilds/templates/error.html
Normal file
16
src/buybuilds/templates/error.html
Normal 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 %}
|
Loading…
Add table
Add a link
Reference in a new issue