diff --git a/app/__init__.py b/app/__init__.py index 3e9c65d..be73026 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -4,6 +4,8 @@ from flask_login import LoginManager from flask_migrate import Migrate from flask_mail import Mail from flask_wtf.csrf import CSRFProtect +from flask_limiter import Limiter +from flask_limiter.util import get_remote_address from config import config import os import logging @@ -14,6 +16,11 @@ login_manager = LoginManager() migrate = Migrate() mail = Mail() csrf = CSRFProtect() # initialized here; .init_app() called in create_app() +limiter = Limiter( + key_func = get_remote_address, + default_limits = [], # no global limit — applied per-route only + storage_uri = 'memory://', # in-process; swap for 'redis://...' in multi-worker setups +) def create_app(config_name='default'): @@ -25,6 +32,7 @@ def create_app(config_name='default'): migrate.init_app(app, db) mail.init_app(app) csrf.init_app(app) # enables CSRF protection for all web routes + limiter.init_app(app) # rate limiting — applied per-route via @limiter.limit() login_manager.login_view = 'auth.login' login_manager.login_message = 'Please log in to access this page.' diff --git a/app/api/auth.py b/app/api/auth.py index a976138..cbeff41 100644 --- a/app/api/auth.py +++ b/app/api/auth.py @@ -31,7 +31,7 @@ GET /api/v1/auth/me import logging from flask import Blueprint, request, g -from app import db +from app import db, limiter from app.models.user import User from app.models.api_token import RefreshToken, DeviceToken from app.api.errors import api_ok, api_error @@ -59,6 +59,7 @@ def _user_payload(user: User) -> dict: # ── Login ───────────────────────────────────────────────────────────────────── @bp.route('/auth/login', methods=['POST']) +@limiter.limit('10 per minute; 3 per second') def login(): """ Authenticate with username + password. @@ -116,6 +117,23 @@ def login(): ) db.session.commit() + # Passive cleanup — delete expired/revoked tokens for this user only + # so the table never accumulates dead rows without a cron dependency. + try: + from app.utils.time_utils import now_eastern + now = now_eastern() + RefreshToken.query.filter( + RefreshToken.user_id == user.id, + db.or_( + RefreshToken.expires_at < now, + RefreshToken.revoked == True, # noqa: E712 + ), + ).delete(synchronize_session=False) + db.session.commit() + except Exception as _cleanup_exc: + logger.warning('API LOGIN passive token cleanup failed: %s', _cleanup_exc) + db.session.rollback() + log_action(ACTION_LOGIN, 'User', user.id, user.username, f'source=mobile_api; device_id={device_id}') @@ -134,6 +152,7 @@ def login(): # ── Refresh ─────────────────────────────────────────────────────────────────── @bp.route('/auth/refresh', methods=['POST']) +@limiter.limit('30 per minute; 5 per second') def refresh(): """ Exchange a valid refresh token for a new access token. diff --git a/app/routes/auth.py b/app/routes/auth.py index b1b7f45..afdcf1a 100644 --- a/app/routes/auth.py +++ b/app/routes/auth.py @@ -1,7 +1,7 @@ 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 import db, limiter from app.models.user import User from app.utils.forms import LoginForm, UserForm, ProfileForm from app.utils.decorators import admin_required, supervisor_required @@ -30,6 +30,7 @@ def _safe_next(next_url: str | None) -> str: @bp.route('/login', methods=['GET', 'POST']) +@limiter.limit('20 per minute; 5 per second') def login(): if current_user.is_authenticated: return redirect(url_for('dashboard.index')) @@ -254,7 +255,7 @@ def toggle_active(user_id): f'account {action_label} by {current_user.username}', ) flash(f'User {user.username} has been {action_label}.', 'success') - return redirect(request.referrer or url_for('auth.list_users')) + return redirect(_safe_next(request.referrer) or url_for('auth.list_users')) # ── Notification Matrix ─────────────────────────────────────────────────────── diff --git a/app/routes/customers.py b/app/routes/customers.py index e425e2a..680da8a 100644 --- a/app/routes/customers.py +++ b/app/routes/customers.py @@ -13,6 +13,7 @@ Provides a single screen to: """ import logging +from urllib.parse import urlparse from flask import Blueprint, render_template, redirect, url_for, flash, request, abort from flask_login import login_required, current_user from app import db @@ -24,6 +25,17 @@ from app.utils.decorators import admin_required, supervisor_required from app.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE, ACTION_DELETE from app.utils.scope import get_customer_scope + +def _safe_referrer(fallback: str) -> str: + """Return request.referrer only if it is a safe relative URL, else fallback.""" + ref = request.referrer + if not ref: + return fallback + parsed = urlparse(ref) + if parsed.netloc or parsed.scheme: + return fallback + return ref + logger = logging.getLogger(__name__) bp = Blueprint('customers', __name__, url_prefix='/customers') @@ -88,12 +100,24 @@ def index(): # All active projects for the assignment modal projects = Project.query.filter_by(active=True).order_by(Project.name).all() + # ── Expired pending-setup invitations ───────────────────────────────── + # Surface customer accounts whose invitation token has expired but + # password_set is still False — they need a fresh invite to log in. + from app.utils.time_utils import now_eastern + expired_invitations = [ + c for c in customers + if not c.password_set + and c.set_password_token_expires is not None + and c.set_password_token_expires < now_eastern() + ] + return render_template( 'customers/index.html', - customers = customers, - assignment_map = assignment_map, - scope_map = scope_map, - projects = projects, + customers = customers, + assignment_map = assignment_map, + scope_map = scope_map, + projects = projects, + expired_invitations = expired_invitations, ) @@ -467,7 +491,7 @@ def toggle_active(customer_id): log_action(ACTION_UPDATE, 'User', customer.id, customer.username, f'account {label} via customer_mgmt by {current_user.username}') flash(f'Customer "{customer.username}" has been {label}.', 'success') - return redirect(request.referrer or url_for('customers.index')) + return redirect(_safe_referrer(url_for('customers.index'))) # ── AJAX: facilities for a project (used by add-assignment form) ────────────── diff --git a/app/routes/dashboard.py b/app/routes/dashboard.py index 49c662f..6d058b8 100644 --- a/app/routes/dashboard.py +++ b/app/routes/dashboard.py @@ -66,10 +66,10 @@ def index(): open_issues_q = open_issues_q.join( Area, Issue.area_id == Area.id ).filter(Area.facility_id.in_(customer_facility_ids)) - open_issues = open_issues_q.count() - # Severity breakdown for the open issues card + # Single query — derive count from the list to avoid hitting the DB twice open_issues_all = open_issues_q.all() + open_issues = len(open_issues_all) severity_breakdown = { 'critical': sum(1 for i in open_issues_all if i.severity == 'critical'), 'high': sum(1 for i in open_issues_all if i.severity == 'high'), diff --git a/app/routes/notifications.py b/app/routes/notifications.py index 0e8a8b4..dfb91f5 100644 --- a/app/routes/notifications.py +++ b/app/routes/notifications.py @@ -223,4 +223,46 @@ def check_sla(): sent = send_sla_alerts() logger.info('SLA CHECK TRIGGERED | notifications_sent=%s', sent) - return jsonify({'ok': True, 'notifications_sent': sent}) \ No newline at end of file + return jsonify({'ok': True, 'notifications_sent': sent}) + + +# ── Expired token cleanup (called by cron) ──────────────────────────────────── + +@bp.route('/cleanup-tokens', methods=['POST']) +@csrf.exempt +def cleanup_tokens(): + """Purge expired and revoked refresh tokens from api_refresh_tokens. + + Safe to run frequently — only deletes rows where expires_at has passed + OR revoked=True. Keeps the table lean without touching live sessions. + + Recommended cron schedule — nightly is sufficient: + + 0 3 * * * curl -s -X POST https://yourdomain.com/notifications/cleanup-tokens \\ + -d "token=YOUR_DIGEST_SECRET" + """ + token = request.form.get('token') or request.args.get('token') + expected = current_app.config.get('DIGEST_SECRET') + + if not expected or token != expected: + logger.warning('TOKEN CLEANUP REJECTED | bad or missing token') + abort(403) + + from app.models.api_token import RefreshToken + from app.utils.time_utils import now_eastern + + now = now_eastern() + deleted = ( + RefreshToken.query + .filter( + db.or_( + RefreshToken.expires_at < now, + RefreshToken.revoked == True, # noqa: E712 + ) + ) + .delete(synchronize_session=False) + ) + db.session.commit() + + logger.info('TOKEN CLEANUP | deleted=%s expired/revoked rows', deleted) + return jsonify({'ok': True, 'deleted': deleted}) \ No newline at end of file diff --git a/app/templates/base.html b/app/templates/base.html index 81c8348..e2c728e 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -43,6 +43,19 @@ .notif-body { font-size: 0.78rem; color: #555; white-space: normal; } .notif-time { font-size: 0.7rem; color: #999; } .notif-empty { padding: 24px; text-align: center; color: #aaa; font-size: 0.85rem; } + + /* ── Active nav tab ── */ + .navbar-dark .navbar-nav .nav-link.active { + background-color: rgba(255, 255, 255, 0.18); + color: #ffffff !important; + border-radius: 6px; + font-weight: 600; + box-shadow: inset 0 -2px 0 rgba(255,255,255,0.6); + } + .navbar-dark .navbar-nav .nav-link:not(.active):hover { + background-color: rgba(255, 255, 255, 0.08); + border-radius: 6px; + } diff --git a/app/templates/customers/index.html b/app/templates/customers/index.html index c357228..d7a38cf 100644 --- a/app/templates/customers/index.html +++ b/app/templates/customers/index.html @@ -18,6 +18,35 @@ {% if customers %} + +{% if expired_invitations %} + +{% endif %}
diff --git a/app/templates/notifications/preferences.html b/app/templates/notifications/preferences.html index 754083e..b844736 100644 --- a/app/templates/notifications/preferences.html +++ b/app/templates/notifications/preferences.html @@ -21,6 +21,20 @@
+ {# ── Pause all emails banner ── #} + {% set any_email_on = prefs_map.values() | selectattr('email_enabled') | list | length > 0 + or prefs_map | length == 0 %} +
+
+ + Pause all email notifications + — in-app notifications are unaffected +
+ +
+
@@ -292,6 +306,42 @@ badge.textContent = 'Live'; } } + + // ── Pause all emails button ────────────────────────────────────────────── + var pauseBtn = document.getElementById('pauseAllBtn'); + if (pauseBtn) { + pauseBtn.addEventListener('click', function () { + var allEmailToggles = document.querySelectorAll('.email-toggle'); + var anyOn = Array.from(allEmailToggles).some(function (c) { return c.checked; }); + + allEmailToggles.forEach(function (emailChk) { + var event = emailChk.dataset.event; + var digestChk = document.getElementById('digest_' + event); + var freqSelect = document.getElementById('freq_' + event); + var badge = document.getElementById('badge-' + event); + + if (anyOn) { + // Pause: turn everything off + emailChk.checked = false; + digestChk.checked = false; + digestChk.disabled = true; + freqSelect.disabled = true; + updateBadge(badge, false, false); + } else { + // Resume: re-enable email (digest stays off until user opts back in) + emailChk.checked = true; + digestChk.disabled = false; + updateBadge(badge, true, false); + } + }); + + // Update button label to reflect new state + var nowPaused = !anyOn ? false : true; + pauseBtn.innerHTML = nowPaused + ? 'Resume All Emails' + : 'Pause All Emails'; + }); + } })(); {% endblock %} \ No newline at end of file diff --git a/migrations/versions/phase12_performance_indexes.py b/migrations/versions/phase12_performance_indexes.py new file mode 100644 index 0000000..4c9b362 --- /dev/null +++ b/migrations/versions/phase12_performance_indexes.py @@ -0,0 +1,82 @@ +"""Phase 12: Add performance indexes on high-filter columns + +Revision ID: phase12_performance_indexes +Revises: phase11_director_role +Create Date: 2026-04-25 + +Rationale +--------- +The following columns are filtered or ordered on every page load but had no +DB index, causing full table scans as row counts grow: + + inspections + - status — filtered on list/dashboard/SLA queries + - facility_id — filtered for customer-scoped views and reports + - inspector_id — filtered for inspector-scoped views + - inspection_date — used in all trend/score queries (ORDER BY, range filter) + + issues + - status — filtered on every issues list/dashboard load + - severity — filtered in dashboard breakdown and issues list + - assigned_to — filtered for inspector-scoped views + - reported_at — used for ordering + +All indexes are created with IF NOT EXISTS so the migration is safe to re-run. +""" + +from alembic import op + +revision = 'phase12_performance_indexes' +down_revision = 'phase11_director_role' +branch_labels = None +depends_on = None + + +def upgrade(): + # ── inspections ────────────────────────────────────────────────────────── + op.execute( + "CREATE INDEX IF NOT EXISTS ix_inspections_status " + "ON inspections (status)" + ) + op.execute( + "CREATE INDEX IF NOT EXISTS ix_inspections_facility_id " + "ON inspections (facility_id)" + ) + op.execute( + "CREATE INDEX IF NOT EXISTS ix_inspections_inspector_id " + "ON inspections (inspector_id)" + ) + op.execute( + "CREATE INDEX IF NOT EXISTS ix_inspections_inspection_date " + "ON inspections (inspection_date)" + ) + + # ── issues ─────────────────────────────────────────────────────────────── + op.execute( + "CREATE INDEX IF NOT EXISTS ix_issues_status " + "ON issues (status)" + ) + op.execute( + "CREATE INDEX IF NOT EXISTS ix_issues_severity " + "ON issues (severity)" + ) + op.execute( + "CREATE INDEX IF NOT EXISTS ix_issues_assigned_to " + "ON issues (assigned_to)" + ) + op.execute( + "CREATE INDEX IF NOT EXISTS ix_issues_reported_at " + "ON issues (reported_at)" + ) + + +def downgrade(): + op.execute("DROP INDEX IF EXISTS ix_inspections_status ON inspections") + op.execute("DROP INDEX IF EXISTS ix_inspections_facility_id ON inspections") + op.execute("DROP INDEX IF EXISTS ix_inspections_inspector_id ON inspections") + op.execute("DROP INDEX IF EXISTS ix_inspections_inspection_date ON inspections") + + op.execute("DROP INDEX IF EXISTS ix_issues_status ON issues") + op.execute("DROP INDEX IF EXISTS ix_issues_severity ON issues") + op.execute("DROP INDEX IF EXISTS ix_issues_assigned_to ON issues") + op.execute("DROP INDEX IF EXISTS ix_issues_reported_at ON issues") diff --git a/requirements.txt b/requirements.txt index a9dc21a..321bc24 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,6 +4,7 @@ Flask-Login Flask-WTF Flask-Mail Flask-Migrate +Flask-Limiter PyMySQL cryptography python-dotenv