05/06 Fix some notable and quality issues
This commit is contained in:
+4
-21
@@ -1,10 +1,9 @@
|
||||
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, limiter
|
||||
from app.models.user import User
|
||||
from app.utils.forms import LoginForm, UserForm, ProfileForm
|
||||
from app.utils.decorators import admin_required, supervisor_required
|
||||
from app.utils.decorators import admin_required, supervisor_required, safe_redirect_url
|
||||
import logging
|
||||
from app.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE, ACTION_DELETE, ACTION_LOGIN, ACTION_LOGOUT
|
||||
|
||||
@@ -13,22 +12,6 @@ logger = logging.getLogger(__name__)
|
||||
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'])
|
||||
@limiter.limit('20 per minute; 5 per second')
|
||||
def login():
|
||||
@@ -52,7 +35,7 @@ def login():
|
||||
return render_template('auth/login.html', form=form)
|
||||
login_user(user, remember=form.remember_me.data)
|
||||
# Use validated next URL — never redirect blindly to request.args['next']
|
||||
next_page = _safe_next(request.args.get('next'))
|
||||
next_page = safe_redirect_url(request.args.get('next'))
|
||||
log_action(ACTION_LOGIN, 'User', user.id, user.username)
|
||||
flash(f'Welcome back, {user.username}!', 'success')
|
||||
return redirect(next_page)
|
||||
@@ -282,7 +265,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(_safe_next(request.referrer) or url_for('auth.list_users'))
|
||||
return redirect(safe_redirect_url(request.referrer, fallback=url_for('auth.list_users')))
|
||||
|
||||
# ── Notification Matrix ───────────────────────────────────────────────────────
|
||||
|
||||
@@ -334,4 +317,4 @@ def notification_matrix():
|
||||
matrix_roles = MATRIX_ROLES,
|
||||
defaults = MATRIX_DEFAULTS,
|
||||
state = state,
|
||||
)
|
||||
)
|
||||
|
||||
+2
-14
@@ -13,7 +13,6 @@ 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
|
||||
@@ -21,21 +20,10 @@ from app.models.user import User
|
||||
from app.models.project import Project, CustomerAssignment
|
||||
from app.models.facility import Facility
|
||||
from app.utils.forms import CustomerUserForm, CustomerAssignmentForm, CustomerInviteForm, SetPasswordForm
|
||||
from app.utils.decorators import admin_required, supervisor_required
|
||||
from app.utils.decorators import admin_required, supervisor_required, safe_redirect_url
|
||||
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')
|
||||
@@ -496,7 +484,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(_safe_referrer(url_for('customers.index')))
|
||||
return redirect(safe_redirect_url(request.referrer, fallback=url_for('customers.index')))
|
||||
|
||||
|
||||
# ── AJAX: facilities for a project (used by add-assignment form) ──────────────
|
||||
|
||||
@@ -370,7 +370,8 @@ def execute(inspection_id):
|
||||
inspection.completed_at = now_eastern()
|
||||
|
||||
_save_responses(inspection, responses)
|
||||
db.session.commit()
|
||||
# NOTE: do NOT commit here — inspection fields and all notification
|
||||
# rows are staged together and committed atomically below.
|
||||
|
||||
inspection_link = url_for('inspections.view', inspection_id=inspection.id)
|
||||
score_display = f'{score:.1f}%' if score is not None else 'N/A'
|
||||
@@ -388,7 +389,7 @@ def execute(inspection_id):
|
||||
facility_id = inspection.facility_id,
|
||||
exclude_user_ids = {current_user.id},
|
||||
)
|
||||
db.session.commit()
|
||||
db.session.commit() # Single atomic commit: inspection fields + notification rows
|
||||
log_action(ACTION_UPDATE, 'Inspection', inspection.id,
|
||||
f'{inspection.template.name} @ {inspection.facility.name}',
|
||||
f'status=completed; score={score}')
|
||||
|
||||
@@ -50,13 +50,20 @@ bp = Blueprint('scheduled_reports', __name__, url_prefix='/scheduled-reports')
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def _compute_next_send(frequency: str, from_dt: datetime = None) -> datetime:
|
||||
"""Return the next send datetime for a given frequency."""
|
||||
"""Return the next send datetime for a given frequency.
|
||||
|
||||
Monthly cadence always targets the 1st of the next month at 07:00.
|
||||
The December branch is required because datetime.replace(month=13)
|
||||
raises ValueError — incrementing the month directly is not safe in
|
||||
general, but targeting day=1 avoids the separate last-day-of-month
|
||||
(28/29/30/31) edge case that would affect mid-month scheduling.
|
||||
"""
|
||||
now = from_dt or now_eastern()
|
||||
if frequency == 'daily':
|
||||
return (now + timedelta(days=1)).replace(hour=7, minute=0, second=0, microsecond=0)
|
||||
if frequency == 'weekly':
|
||||
return (now + timedelta(weeks=1)).replace(hour=7, minute=0, second=0, microsecond=0)
|
||||
# monthly: first of next month
|
||||
# monthly: first of next month — December rolls over to Jan of next year.
|
||||
if now.month == 12:
|
||||
return now.replace(year=now.year + 1, month=1, day=1, hour=7, minute=0, second=0, microsecond=0)
|
||||
return now.replace(month=now.month + 1, day=1, hour=7, minute=0, second=0, microsecond=0)
|
||||
|
||||
Reference in New Issue
Block a user