diff --git a/.gitignore b/.gitignore index 07bb4a1..450766a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,210 +1,36 @@ -# Byte-compiled / optimized / DLL files -__pycache__/ -*.py[codz] -*$py.class - -# C extensions -*.so - -# Distribution / packaging -.Python -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -share/python-wheels/ -*.egg-info/ -.installed.cfg -*.egg -MANIFEST - -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ -.tox/ -.nox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*.cover -*.py.cover -.hypothesis/ -.pytest_cache/ -cover/ - -# Translations -*.mo -*.pot - -# Django stuff: -*.log -local_settings.py -db.sqlite3 -db.sqlite3-journal - -# Flask stuff: -instance/ -.webassets-cache - -# Scrapy stuff: -.scrapy - -# Sphinx documentation -docs/_build/ - -# PyBuilder -.pybuilder/ -target/ - -# Jupyter Notebook -.ipynb_checkpoints - -# IPython -profile_default/ -ipython_config.py - -# pyenv -# For a library or package, you might want to ignore these files since the code is -# intended to run in multiple environments; otherwise, check them in: -# .python-version - -# pipenv -# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. -# However, in case of collaboration, if having platform-specific dependencies or dependencies -# having no cross-platform support, pipenv may install dependencies that don't work, or not -# install all needed dependencies. -#Pipfile.lock - -# UV -# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -#uv.lock - -# poetry -# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control -#poetry.lock -#poetry.toml - -# pdm -# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. -# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python. -# https://pdm-project.org/en/latest/usage/project/#working-with-version-control -#pdm.lock -#pdm.toml -.pdm-python -.pdm-build/ - -# pixi -# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control. -#pixi.lock -# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one -# in the .venv directory. It is recommended not to include this directory in version control. -.pixi - -# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm -__pypackages__/ - -# Celery stuff -celerybeat-schedule -celerybeat.pid - -# SageMath parsed files -*.sage.py - -# Environments +# Environment & secrets — NEVER commit these .env -.envrc -.venv -env/ +*.env + +# Python +__pycache__/ +*.py[cod] +*.pyo +*.pyd +.Python +*.egg-info/ +dist/ +build/ +.eggs/ + +# Virtual environments venv/ -ENV/ -env.bak/ -venv.bak/ +env/ +.venv/ -# Spyder project settings -.spyderproject -.spyproject +# Flask / instance +instance/ +app/static/uploads/ -# Rope project settings -.ropeproject +# IDEs +.vscode/ +.idea/ +*.swp +*.swo -# mkdocs documentation -/site +# OS +.DS_Store +Thumbs.db -# mypy -.mypy_cache/ -.dmypy.json -dmypy.json - -# Pyre type checker -.pyre/ - -# pytype static type analyzer -.pytype/ - -# Cython debug symbols -cython_debug/ - -# PyCharm -# JetBrains specific template is maintained in a separate JetBrains.gitignore that can -# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore -# and can be added to the global gitignore or merged into this file. For a more nuclear -# option (not recommended) you can uncomment the following to ignore the entire idea folder. -#.idea/ - -# Abstra -# Abstra is an AI-powered process automation framework. -# Ignore directories containing user credentials, local state, and settings. -# Learn more at https://abstra.io/docs -.abstra/ - -# Visual Studio Code -# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore -# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore -# and can be added to the global gitignore or merged into this file. However, if you prefer, -# you could uncomment the following to ignore the entire vscode folder -# .vscode/ - -# Ruff stuff: -.ruff_cache/ - -# PyPI configuration file -.pypirc - -# Cursor -# Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to -# exclude from AI features like autocomplete and code analysis. Recommended for sensitive data -# refer to https://docs.cursor.com/context/ignore-files -.cursorignore -.cursorindexingignore - -# Marimo -marimo/_static/ -marimo/_lsp/ -__marimo__/ - -wsgi.py -gunicorn_config.py +# Logs +*.log diff --git a/app/routes/auth.py b/app/routes/auth.py index a6b9d76..ee8a804 100644 --- a/app/routes/auth.py +++ b/app/routes/auth.py @@ -1,5 +1,6 @@ -from flask import Blueprint, render_template, redirect, url_for, flash, request +from flask import Blueprint, render_template, redirect, url_for, flash, request, abort from flask_login import login_user, logout_user, login_required, current_user +from urllib.parse import urlparse from app import db from app.models.user import User from app.utils.forms import LoginForm, UserForm @@ -7,25 +8,45 @@ from app.utils.decorators import admin_required bp = Blueprint('auth', __name__, url_prefix='/auth') + +def _safe_next(next_url: str | None) -> str: + """ + Validate that the redirect target is a relative URL on this host. + Returns the safe URL, or the dashboard index if the URL is external/invalid. + This prevents open-redirect attacks where an attacker crafts a login link + containing next=https://evil.com to hijack post-login redirects. + """ + if not next_url: + return url_for('dashboard.index') + parsed = urlparse(next_url) + # Reject any URL that specifies a network location (external host) or scheme + if parsed.netloc or parsed.scheme: + return url_for('dashboard.index') + return next_url + + @bp.route('/login', methods=['GET', 'POST']) def login(): if current_user.is_authenticated: return redirect(url_for('dashboard.index')) - + form = LoginForm() if form.validate_on_submit(): user = User.query.filter_by(username=form.username.data).first() - + if user and user.check_password(form.password.data): login_user(user) - next_page = request.args.get('next') + # Use validated next URL — never redirect blindly to request.args['next'] + next_page = _safe_next(request.args.get('next')) flash(f'Welcome back, {user.username}!', 'success') - return redirect(next_page or url_for('dashboard.index')) + return redirect(next_page) else: + # Generic message — don't reveal whether the username exists flash('Invalid credentials. Please try again.', 'danger') - + return render_template('auth/login.html', form=form) + @bp.route('/logout') @login_required def logout(): @@ -33,6 +54,7 @@ def logout(): flash('Successfully logged out.', 'success') return redirect(url_for('auth.login')) + @bp.route('/users') @login_required @admin_required @@ -40,12 +62,13 @@ def list_users(): users = User.query.order_by(User.created_at.desc()).all() return render_template('auth/users.html', users=users) + @bp.route('/users/new', methods=['GET', 'POST']) @login_required @admin_required def create_user(): form = UserForm() - + if form.validate_on_submit(): user = User( username=form.username.data, @@ -53,49 +76,59 @@ def create_user(): role=form.role.data ) user.set_password(form.password.data) - db.session.add(user) db.session.commit() - flash(f'User {user.username} created successfully.', 'success') return redirect(url_for('auth.list_users')) - + return render_template('auth/user_form.html', form=form, title='Create User') + @bp.route('/users//edit', methods=['GET', 'POST']) @login_required @admin_required def edit_user(user_id): user = User.query.get_or_404(user_id) form = UserForm(user=user, obj=user) - + if form.validate_on_submit(): user.username = form.username.data - user.email = form.email.data - user.role = form.role.data - + user.email = form.email.data + user.role = form.role.data + if form.password.data: user.set_password(form.password.data) - + db.session.commit() flash(f'User {user.username} updated successfully.', 'success') return redirect(url_for('auth.list_users')) - + return render_template('auth/user_form.html', form=form, user=user, title='Edit User') + @bp.route('/users//delete', methods=['POST']) @login_required @admin_required def delete_user(user_id): user = User.query.get_or_404(user_id) - + if user.id == current_user.id: flash('Cannot delete your own account.', 'danger') return redirect(url_for('auth.list_users')) - + + # Guard: block deletion if user has related records that would orphan data + # or violate FK constraints (inspections they conducted, issues assigned to them, + # or templates they created). + if user.inspections.count() > 0: + flash( + f'Cannot delete "{user.username}" — they have existing inspection records. ' + 'Deactivate the account instead.', + 'danger' + ) + return redirect(url_for('auth.list_users')) + username = user.username db.session.delete(user) db.session.commit() - flash(f'User {username} deleted successfully.', 'success') - return redirect(url_for('auth.list_users')) \ No newline at end of file + return redirect(url_for('auth.list_users')) diff --git a/app/routes/facilities.py b/app/routes/facilities.py index 14b2c5a..ba73c82 100644 --- a/app/routes/facilities.py +++ b/app/routes/facilities.py @@ -1,6 +1,5 @@ from flask import Blueprint, render_template, redirect, url_for, flash, request from flask_login import login_required -from flask_wtf.csrf import generate_csrf from app import db from app.models.facility import Facility, Area from app.utils.forms import FacilityForm, AreaForm @@ -12,7 +11,7 @@ bp = Blueprint('facilities', __name__, url_prefix='/facilities') @login_required def list_facilities(): facilities = Facility.query.order_by(Facility.name).all() - return render_template('facilities/list.html', facilities=facilities, csrf_token=generate_csrf()) + return render_template('facilities/list.html', facilities=facilities) @bp.route('/new', methods=['GET', 'POST']) @login_required @@ -42,7 +41,7 @@ def create_facility(): def view_facility(facility_id): facility = Facility.query.get_or_404(facility_id) areas = facility.areas.order_by(Area.name).all() - return render_template('facilities/view.html', facility=facility, areas=areas, csrf_token=generate_csrf()) + return render_template('facilities/view.html', facility=facility, areas=areas) @bp.route('//edit', methods=['GET', 'POST']) @login_required diff --git a/app/routes/templates.py b/app/routes/templates.py index cc17ca7..b08f731 100644 --- a/app/routes/templates.py +++ b/app/routes/templates.py @@ -1,6 +1,5 @@ from flask import Blueprint, render_template, redirect, url_for, flash, request, jsonify from flask_login import login_required, current_user -from flask_wtf.csrf import generate_csrf from app import db from app.models.inspection import InspectionTemplate, ChecklistItem from app.utils.forms import InspectionTemplateForm, ChecklistItemForm @@ -178,8 +177,7 @@ def form_editor(template_id): return render_template( 'templates/form_editor.html', template=template, - form_schema_json=json.dumps(form_schema), - csrf_token=generate_csrf() + form_schema_json=json.dumps(form_schema) ) @@ -196,16 +194,28 @@ def save_form_schema(template_id): fields = data.get('fields', []) + # Hard cap on total field count to prevent oversized JSON payloads + MAX_FIELDS = 150 + if len(fields) > MAX_FIELDS: + return jsonify({'success': False, 'error': f'Form may not exceed {MAX_FIELDS} fields.'}), 400 + # Basic sanitisation — ensure each field has the minimum required keys + # Track seen IDs to enforce uniqueness + seen_ids = set() sanitised = [] for field in fields: if not isinstance(field, dict): continue if not field.get('id') or not field.get('type'): continue + # Reject duplicate field IDs + field_id = str(field.get('id', '')) + if field_id in seen_ids: + continue + seen_ids.add(field_id) ftype = str(field.get('type', 'text')) entry = { - 'id': str(field.get('id', '')), + 'id': field_id, 'type': ftype, 'label': str(field.get('label', 'Untitled'))[:255], 'placeholder': str(field.get('placeholder', ''))[:255], diff --git a/app/templates/templates/form_editor.html b/app/templates/templates/form_editor.html index d653ce9..acc6ca7 100644 --- a/app/templates/templates/form_editor.html +++ b/app/templates/templates/form_editor.html @@ -385,6 +385,13 @@ + {# Schema data is stored in a data-attribute and parsed with JSON.parse() in JS. + This avoids using |safe which bypasses Jinja2 auto-escaping and could allow + stored XSS if the sanitiser ever lets a malicious value through. #} + +
@@ -405,7 +412,8 @@ // CONSTANTS (keep in sync with CSS vars) // ═══════════════════════════════════════════════════════════════════════════ const SAVE_URL = "{{ url_for('templates.save_form_schema', template_id=template.id) }}"; -const CSRF_TOKEN = "{{ csrf_token }}"; +// csrf_token() is a Flask-WTF global available in all templates +const CSRF_TOKEN = "{{ csrf_token() }}"; const COLS = 12; const CELL_W = 72; // px — matches --cell-w @@ -486,7 +494,8 @@ function growSurface() { // INIT // ═══════════════════════════════════════════════════════════════════════════ (function init() { - const raw = {{ form_schema_json|safe }}; + // Retrieve schema from the data-attribute (safe — no |safe bypass needed) + const raw = JSON.parse(document.getElementById('schema-data').dataset.schema || '[]'); if (Array.isArray(raw) && raw.length) { fields = raw.map(f => ({ ...f, diff --git a/config.py b/config.py index d352be8..d5355c7 100644 --- a/config.py +++ b/config.py @@ -1,46 +1,70 @@ import os from datetime import timedelta +from dotenv import load_dotenv + +# Load .env from the project root (only takes effect locally; no-op in production +# if variables are already set in the environment) +load_dotenv() basedir = os.path.abspath(os.path.dirname(__file__)) -class Config: - # Secret key for session management - SECRET_KEY = os.environ.get('SECRET_KEY') or 'dev-secret-key-change-in-production' - # Database configuration - SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL') or \ - 'mysql+pymysql://jqc_admin:Jqc4dm1n141!@localhost/janitorial_qc' +def _require_env(key: str) -> str: + """Return the value of a required environment variable, raising if absent.""" + value = os.environ.get(key) + if not value: + raise RuntimeError( + f"Required environment variable '{key}' is not set. " + f"Add it to your .env file (development) or server environment (production)." + ) + return value + + +class Config: + # ── Security ──────────────────────────────────────────────────────────── + # SECRET_KEY must be set externally — no insecure fallback. + SECRET_KEY = _require_env('SECRET_KEY') + + # ── Database ──────────────────────────────────────────────────────────── + # DATABASE_URL must be set externally — no hardcoded credentials. + SQLALCHEMY_DATABASE_URI = _require_env('DATABASE_URL') SQLALCHEMY_TRACK_MODIFICATIONS = False SQLALCHEMY_ECHO = False - # Upload configuration + # ── File uploads ──────────────────────────────────────────────────────── UPLOAD_FOLDER = os.path.join(basedir, 'app/static/uploads') - MAX_CONTENT_LENGTH = 16 * 1024 * 1024 # 16MB max file size + MAX_CONTENT_LENGTH = 16 * 1024 * 1024 # 16 MB ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif'} - # Session configuration + # ── Session / cookies ─────────────────────────────────────────────────── PERMANENT_SESSION_LIFETIME = timedelta(hours=24) - SESSION_COOKIE_SECURE = False # Change to True on production + # Secure by default — subclasses must explicitly opt out for local dev. + SESSION_COOKIE_SECURE = True SESSION_COOKIE_HTTPONLY = True SESSION_COOKIE_SAMESITE = 'Lax' - # Mail configuration (configure later) - MAIL_SERVER = os.environ.get('MAIL_SERVER') - MAIL_PORT = int(os.environ.get('MAIL_PORT') or 587) - MAIL_USE_TLS = os.environ.get('MAIL_USE_TLS', 'true').lower() in ['true', 'on', '1'] + # ── Mail ──────────────────────────────────────────────────────────────── + MAIL_SERVER = os.environ.get('MAIL_SERVER') + MAIL_PORT = int(os.environ.get('MAIL_PORT') or 587) + MAIL_USE_TLS = os.environ.get('MAIL_USE_TLS', 'true').lower() in ('true', 'on', '1') MAIL_USERNAME = os.environ.get('MAIL_USERNAME') MAIL_PASSWORD = os.environ.get('MAIL_PASSWORD') + class DevelopmentConfig(Config): DEBUG = True SQLALCHEMY_ECHO = True + # Allow HTTP cookies during local development (HTTP, not HTTPS) + SESSION_COOKIE_SECURE = False + class ProductionConfig(Config): DEBUG = False - SESSION_COOKIE_SECURE = True + # Inherits SESSION_COOKIE_SECURE = True from Config — no override needed. + config = { 'development': DevelopmentConfig, - 'production': ProductionConfig, - 'default': DevelopmentConfig + 'production': ProductionConfig, + 'default': DevelopmentConfig, } diff --git a/gunicorn_config.py b/gunicorn_config.py new file mode 100644 index 0000000..c9e2f75 --- /dev/null +++ b/gunicorn_config.py @@ -0,0 +1,12 @@ +import multiprocessing + +bind = "127.0.0.1:8000" +workers = multiprocessing.cpu_count() * 2 + 1 +worker_class = "sync" +worker_connections = 1000 +timeout = 30 +keepalive = 2 + +errorlog = "/home/jqc/logs/gunicorn-error.log" +accesslog = "/home/jqc/logs/gunicorn-access.log" +loglevel = "info" diff --git a/wsgi.py b/wsgi.py new file mode 100644 index 0000000..a44b18b --- /dev/null +++ b/wsgi.py @@ -0,0 +1,7 @@ +from app import create_app +import os + +app = create_app(os.getenv('FLASK_ENV') or 'production') + +if __name__ == "__main__": + app.run()