06/15 Fix issues

This commit is contained in:
2026-06-15 16:32:13 -04:00
parent ca4b598647
commit b1e60535a4
8 changed files with 44 additions and 17 deletions
+10 -5
View File
@@ -80,13 +80,13 @@ def login():
user.last_login_at = datetime.utcnow() user.last_login_at = datetime.utcnow()
db.session.commit() db.session.commit()
nxt = request.args.get("next") nxt = request.args.get("next")
if nxt and nxt.startswith("/"): if nxt and nxt.startswith("/") and not nxt.startswith("//"):
return redirect(nxt) return redirect(nxt)
return redirect(url_for("main.index")) return redirect(url_for("main.index"))
return render_template("auth/login.html", form=form) return render_template("auth/login.html", form=form)
@auth_bp.route("/logout") @auth_bp.route("/logout", methods=["POST"])
@login_required @login_required
def logout(): def logout():
logout_user() logout_user()
@@ -119,7 +119,7 @@ def reset_request():
if form.validate_on_submit(): if form.validate_on_submit():
user = User.query.filter_by(email=form.email.data.lower()).first() user = User.query.filter_by(email=form.email.data.lower()).first()
if user: if user:
token = generate_token(user.id, _RESET_SALT) token = generate_token((user.id, user.password_hash[:20]), _RESET_SALT)
link = url_for("auth.reset_password", token=token, _external=True) link = url_for("auth.reset_password", token=token, _external=True)
send_email(user.email, _("Reset your password"), send_email(user.email, _("Reset your password"),
_("Reset link: %(link)s", link=link)) _("Reset link: %(link)s", link=link))
@@ -131,14 +131,19 @@ def reset_request():
@auth_bp.route("/reset/<token>", methods=["GET", "POST"]) @auth_bp.route("/reset/<token>", methods=["GET", "POST"])
def reset_password(token): def reset_password(token):
user_id = read_token(token, _RESET_SALT, payload = read_token(token, _RESET_SALT,
current_app.config["TOKEN_RESET_MAX_AGE"]) current_app.config["TOKEN_RESET_MAX_AGE"])
if user_id is None: # payload is (user_id, pw_fingerprint) — fingerprint invalidates on use
if not isinstance(payload, (list, tuple)) or len(payload) != 2:
flash(_("Reset link is invalid or expired."), "danger") flash(_("Reset link is invalid or expired."), "danger")
return redirect(url_for("auth.reset_request")) return redirect(url_for("auth.reset_request"))
user_id, pw_fingerprint = payload
user = db.session.get(User, user_id) user = db.session.get(User, user_id)
if user is None: if user is None:
abort(404) abort(404)
if user.password_hash[:20] != pw_fingerprint:
flash(_("Reset link has already been used."), "danger")
return redirect(url_for("auth.reset_request"))
form = ResetForm() form = ResetForm()
if form.validate_on_submit(): if form.validate_on_submit():
user.set_password(form.password.data) user.set_password(form.password.data)
+2 -2
View File
@@ -4,6 +4,7 @@ Order: explicit session choice -> authenticated user.locale -> Accept-Language -
""" """
from flask import Blueprint, session, redirect, request, current_app, url_for from flask import Blueprint, session, redirect, request, current_app, url_for
from flask_login import current_user from flask_login import current_user
from app.utils import safe_referrer
i18n_bp = Blueprint("i18n", __name__) i18n_bp = Blueprint("i18n", __name__)
@@ -34,5 +35,4 @@ def set_lang(code):
from app.extensions import db from app.extensions import db
current_user.locale = code current_user.locale = code
db.session.commit() db.session.commit()
target = request.referrer or url_for("main.index") return redirect(safe_referrer(url_for("main.index")))
return redirect(target)
+7 -4
View File
@@ -11,6 +11,7 @@ from app.services import messaging as msvc
from app.services import favorites as fsvc from app.services import favorites as fsvc
from app.services.contact import contact_revealed, mask_body from app.services.contact import contact_revealed, mask_body
from app.blueprints.messaging.forms import MessageForm from app.blueprints.messaging.forms import MessageForm
from app.utils import safe_referrer
messaging_bp = Blueprint("messaging", __name__) messaging_bp = Blueprint("messaging", __name__)
@@ -37,6 +38,7 @@ def conversation(conv_id):
abort(403) abort(403)
msvc.mark_conversation_read(conv, current_user) msvc.mark_conversation_read(conv, current_user)
# reveal controls the "contact info hidden" banner for the current reader
reveal = contact_revealed(current_user) reveal = contact_revealed(current_user)
form = MessageForm() form = MessageForm()
@@ -47,9 +49,10 @@ def conversation(conv_id):
except msvc.MessagingError as e: except msvc.MessagingError as e:
flash(str(e), "danger") flash(str(e), "danger")
# mask contact info in messages for low-trust users # Mask is based on the *sender's* trust so a low-trust sender cannot
# slip contact info through to a trusted reader.
masked_messages = [ masked_messages = [
(msg, mask_body(msg.body, reveal=reveal)) (msg, mask_body(msg.body, reveal=contact_revealed(msg.sender)))
for msg in conv.messages for msg in conv.messages
] ]
return render_template("messaging/conversation.html", return render_template("messaging/conversation.html",
@@ -103,8 +106,8 @@ def toggle_favorite(listing_id):
if request.headers.get("X-Requested-With") == "XMLHttpRequest": if request.headers.get("X-Requested-With") == "XMLHttpRequest":
return jsonify(favorited=now_fav) return jsonify(favorited=now_fav)
flash(_("Saved.") if now_fav else _("Removed from saved."), "info") flash(_("Saved.") if now_fav else _("Removed from saved."), "info")
return redirect(request.referrer or url_for("listings.detail", return redirect(safe_referrer(url_for("listings.detail",
listing_id=listing_id)) listing_id=listing_id)))
@messaging_bp.route("/my/favorites") @messaging_bp.route("/my/favorites")
+2 -2
View File
@@ -14,8 +14,8 @@ from app.models.enums import TrustTier
# patterns # patterns
_PHONE_RE = re.compile( _PHONE_RE = re.compile(
r"(\+?1[\s\-.]?)?" r"(\+?1[\s\-.]?)?"
r"(\(?\d{3}\)?[\s\-.])" r"(\(?\d{3}\)?[\s\-.]?)"
r"\d{3}[\s\-.]\d{4}" r"\d{3}[\s\-.]?\d{4}"
) )
_EMAIL_RE = re.compile(r"[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}") _EMAIL_RE = re.compile(r"[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}")
_URL_RE = re.compile(r"https?://\S+|www\.\S+", re.I) _URL_RE = re.compile(r"https?://\S+|www\.\S+", re.I)
+2 -1
View File
@@ -4,6 +4,7 @@ Portable across MySQL and SQLite (no spatial extension needed). For large-scale
deployments, see README for the MySQL POINT + SPATIAL INDEX upgrade. deployments, see README for the MySQL POINT + SPATIAL INDEX upgrade.
""" """
import math import math
from app.extensions import db
from app.models.geo import ZipGeo from app.models.geo import ZipGeo
EARTH_MI = 3958.7613 # mean earth radius, miles EARTH_MI = 3958.7613 # mean earth radius, miles
@@ -11,7 +12,7 @@ EARTH_MI = 3958.7613 # mean earth radius, miles
def geocode_zip(zip_code): def geocode_zip(zip_code):
"""Return (lat, lng, city, state, metro) or None.""" """Return (lat, lng, city, state, metro) or None."""
row = ZipGeo.query.get((zip_code or "").strip()) row = db.session.get(ZipGeo, (zip_code or "").strip())
if row is None: if row is None:
return None return None
return row.lat, row.lng, row.city, row.state, row.metro return row.lat, row.lng, row.city, row.state, row.metro
+4
View File
@@ -130,3 +130,7 @@ table.list td{padding:8px 10px;border-bottom:1px solid var(--line)}
.reply-box{padding:16px} .reply-box{padding:16px}
.mask-notice{background:#fdf4e3;border:1px solid #f0dcae;border-radius:8px; .mask-notice{background:#fdf4e3;border:1px solid #f0dcae;border-radius:8px;
padding:8px 12px;margin-top:8px} padding:8px 12px;margin-top:8px}
.link-btn{background:none;border:none;padding:0;color:inherit;font:inherit;cursor:pointer;text-decoration:none}
.link-btn:hover{text-decoration:underline}
.logout-form{display:inline}
+4 -1
View File
@@ -22,7 +22,10 @@
{% if unread_count %}<span class="badge-count">{{ unread_count }}</span>{% endif %} {% if unread_count %}<span class="badge-count">{{ unread_count }}</span>{% endif %}
</a> </a>
<span class="hi">{{ current_user.display_name }}</span> <span class="hi">{{ current_user.display_name }}</span>
<a href="{{ url_for('auth.logout') }}">{{ _('Sign out') }}</a> <form method="post" action="{{ url_for('auth.logout') }}" class="logout-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="link-btn">{{ _('Sign out') }}</button>
</form>
{% else %} {% else %}
<a href="{{ url_for('auth.login') }}">{{ _('Sign in') }}</a> <a href="{{ url_for('auth.login') }}">{{ _('Sign in') }}</a>
<a class="btn" href="{{ url_for('auth.register') }}">{{ _('Register') }}</a> <a class="btn" href="{{ url_for('auth.register') }}">{{ _('Register') }}</a>
+13 -2
View File
@@ -1,10 +1,21 @@
"""RBAC decorators. Never trust the client; gate on server side.""" """RBAC decorators and shared request utilities."""
from functools import wraps from functools import wraps
from flask import abort from urllib.parse import urlparse
from flask import abort, request
from flask_login import current_user from flask_login import current_user
from app.models.enums import Role from app.models.enums import Role
def safe_referrer(fallback: str) -> str:
"""Return request.referrer only when it is same-origin; else fallback."""
ref = request.referrer
if ref:
parsed = urlparse(ref)
if parsed.netloc in ("", request.host):
return ref
return fallback
def role_required(*roles): def role_required(*roles):
"""Require the current user to hold one of the given roles.""" """Require the current user to hold one of the given roles."""
def decorator(fn): def decorator(fn):