06/15 Phase 1 + 2 codes

This commit is contained in:
2026-06-15 11:23:05 -04:00
commit c2064b84b4
62 changed files with 2937 additions and 0 deletions
View File
View File
+39
View File
@@ -0,0 +1,39 @@
"""Auth forms (Flask-WTF). CSRF automatic. Labels wrapped for i18n."""
from flask_wtf import FlaskForm
from wtforms import StringField, PasswordField, BooleanField, SubmitField
from wtforms.validators import DataRequired, Email, Length, EqualTo
from flask_babel import lazy_gettext as _l
class RegisterForm(FlaskForm):
display_name = StringField(_l("Display name"),
validators=[DataRequired(), Length(2, 80)])
email = StringField(_l("Email"),
validators=[DataRequired(), Email(), Length(max=255)])
password = PasswordField(_l("Password"),
validators=[DataRequired(), Length(min=8, max=128)])
confirm = PasswordField(_l("Confirm password"),
validators=[DataRequired(), EqualTo("password",
message=_l("Passwords must match"))])
submit = SubmitField(_l("Create account"))
class LoginForm(FlaskForm):
email = StringField(_l("Email"), validators=[DataRequired(), Email()])
password = PasswordField(_l("Password"), validators=[DataRequired()])
remember = BooleanField(_l("Remember me"))
submit = SubmitField(_l("Sign in"))
class ResetRequestForm(FlaskForm):
email = StringField(_l("Email"), validators=[DataRequired(), Email()])
submit = SubmitField(_l("Send reset link"))
class ResetForm(FlaskForm):
password = PasswordField(_l("New password"),
validators=[DataRequired(), Length(min=8, max=128)])
confirm = PasswordField(_l("Confirm password"),
validators=[DataRequired(), EqualTo("password",
message=_l("Passwords must match"))])
submit = SubmitField(_l("Reset password"))
+148
View File
@@ -0,0 +1,148 @@
"""Auth flows: register, login, logout, email verification, password reset."""
from datetime import datetime
from flask import (Blueprint, render_template, redirect, url_for, flash,
request, current_app, abort)
from flask_login import login_user, logout_user, login_required, current_user
from flask_babel import gettext as _
from app.extensions import db, limiter
from app.models.user import User
from app.models.plan import Plan
from app.models.enums import Role, TrustEventType
from app.utils.security import generate_token, read_token
from app.services.email import send_email
from app.services.turnstile import verify_turnstile, turnstile_enabled
from app.services.trust import record_event
from app.blueprints.auth.forms import (RegisterForm, LoginForm,
ResetRequestForm, ResetForm)
auth_bp = Blueprint("auth", __name__, url_prefix="/auth")
_VERIFY_SALT = "email-verify"
_RESET_SALT = "password-reset"
def _send_verify_email(user):
token = generate_token(user.id, _VERIFY_SALT)
link = url_for("auth.verify_email", token=token, _external=True)
send_email(user.email, _("Verify your email"),
_("Confirm your account: %(link)s", link=link))
@auth_bp.route("/register", methods=["GET", "POST"])
@limiter.limit("10 per hour", methods=["POST"])
def register():
if current_user.is_authenticated:
return redirect(url_for("main.index"))
form = RegisterForm()
if form.validate_on_submit():
if not verify_turnstile():
flash(_("Captcha verification failed."), "danger")
return render_template("auth/register.html", form=form,
turnstile=turnstile_enabled())
existing = User.query.filter_by(email=form.email.data.lower()).first()
if existing:
flash(_("An account with that email already exists."), "danger")
return render_template("auth/register.html", form=form,
turnstile=turnstile_enabled())
free_plan = Plan.query.filter_by(slug="free").first()
user = User(
email=form.email.data.lower(),
display_name=form.display_name.data.strip(),
role=Role.free,
tier_id=free_plan.id if free_plan else None,
)
user.set_password(form.password.data)
db.session.add(user)
db.session.commit()
_send_verify_email(user)
flash(_("Account created. Check your email to verify."), "success")
return redirect(url_for("auth.login"))
return render_template("auth/register.html", form=form,
turnstile=turnstile_enabled())
@auth_bp.route("/login", methods=["GET", "POST"])
@limiter.limit("20 per hour", methods=["POST"])
def login():
if current_user.is_authenticated:
return redirect(url_for("main.index"))
form = LoginForm()
if form.validate_on_submit():
user = User.query.filter_by(email=form.email.data.lower()).first()
if user is None or not user.check_password(form.password.data):
flash(_("Invalid email or password."), "danger")
return render_template("auth/login.html", form=form)
if not user.is_active:
flash(_("This account is suspended."), "danger")
return render_template("auth/login.html", form=form)
login_user(user, remember=form.remember.data)
user.last_login_at = datetime.utcnow()
db.session.commit()
nxt = request.args.get("next")
if nxt and nxt.startswith("/"):
return redirect(nxt)
return redirect(url_for("main.index"))
return render_template("auth/login.html", form=form)
@auth_bp.route("/logout")
@login_required
def logout():
logout_user()
flash(_("Signed out."), "info")
return redirect(url_for("main.index"))
@auth_bp.route("/verify/<token>")
def verify_email(token):
user_id = read_token(token, _VERIFY_SALT,
current_app.config["TOKEN_VERIFY_MAX_AGE"])
if user_id is None:
flash(_("Verification link is invalid or expired."), "danger")
return redirect(url_for("auth.login"))
user = User.query.get(user_id)
if user is None:
abort(404)
if not user.email_verified:
user.email_verified = True
record_event(user, TrustEventType.verified_email, 5)
db.session.commit()
flash(_("Email verified. You're all set."), "success")
return redirect(url_for("auth.login"))
@auth_bp.route("/reset", methods=["GET", "POST"])
@limiter.limit("5 per hour", methods=["POST"])
def reset_request():
form = ResetRequestForm()
if form.validate_on_submit():
user = User.query.filter_by(email=form.email.data.lower()).first()
if user:
token = generate_token(user.id, _RESET_SALT)
link = url_for("auth.reset_password", token=token, _external=True)
send_email(user.email, _("Reset your password"),
_("Reset link: %(link)s", link=link))
# Always show success to avoid email enumeration.
flash(_("If that email exists, a reset link has been sent."), "info")
return redirect(url_for("auth.login"))
return render_template("auth/reset_request.html", form=form)
@auth_bp.route("/reset/<token>", methods=["GET", "POST"])
def reset_password(token):
user_id = read_token(token, _RESET_SALT,
current_app.config["TOKEN_RESET_MAX_AGE"])
if user_id is None:
flash(_("Reset link is invalid or expired."), "danger")
return redirect(url_for("auth.reset_request"))
user = User.query.get(user_id)
if user is None:
abort(404)
form = ResetForm()
if form.validate_on_submit():
user.set_password(form.password.data)
db.session.commit()
flash(_("Password updated. Sign in."), "success")
return redirect(url_for("auth.login"))
return render_template("auth/reset.html", form=form, token=token)
View File
+38
View File
@@ -0,0 +1,38 @@
"""i18n: locale resolution + a route to switch UI language.
Order: explicit session choice -> authenticated user.locale -> Accept-Language -> default.
"""
from flask import Blueprint, session, redirect, request, current_app, url_for
from flask_login import current_user
i18n_bp = Blueprint("i18n", __name__)
def select_locale():
supported = current_app.config["SUPPORTED_LOCALES"]
# 1. explicit session override
lang = session.get("lang")
if lang in supported:
return lang
# 2. authenticated user's stored preference
if current_user.is_authenticated and current_user.locale in supported:
return current_user.locale
# 3. browser preference
best = request.accept_languages.best_match(supported)
if best:
return best
# 4. default
return current_app.config["DEFAULT_LOCALE"]
@i18n_bp.route("/lang/<code>")
def set_lang(code):
if code in current_app.config["SUPPORTED_LOCALES"]:
session["lang"] = code
# persist to profile when logged in
if current_user.is_authenticated:
from app.extensions import db
current_user.locale = code
db.session.commit()
target = request.referrer or url_for("main.index")
return redirect(target)
View File
+33
View File
@@ -0,0 +1,33 @@
"""Listing forms. Core fields only; category-specific fields are rendered and
parsed dynamically from the category field_schema (prefix `attr_`)."""
from flask_wtf import FlaskForm
from flask_wtf.file import FileField, FileAllowed
from wtforms import (StringField, TextAreaField, SelectField, DecimalField,
SubmitField, MultipleFileField)
from wtforms.validators import DataRequired, Length, Optional, NumberRange
from flask_babel import lazy_gettext as _l
class ListingForm(FlaskForm):
category_id = SelectField(_l("Category"), coerce=int,
validators=[DataRequired()])
title = StringField(_l("Title"), validators=[DataRequired(), Length(3, 140)])
body = TextAreaField(_l("Description"),
validators=[DataRequired(), Length(10, 8000)])
lang = SelectField(_l("Language"),
choices=[("en", "English"), ("vi", "Tiếng Việt"),
("es", "Español")], default="en")
price = DecimalField(_l("Price (USD)"), places=2,
validators=[Optional(), NumberRange(min=0)])
zip = StringField(_l("ZIP code"), validators=[Optional(), Length(3, 12)])
images = MultipleFileField(_l("Photos"),
validators=[FileAllowed(["jpg", "jpeg", "png", "webp"],
_l("Images only"))])
submit = SubmitField(_l("Publish"))
class ImageUploadForm(FlaskForm):
image = FileField(_l("Photo"),
validators=[DataRequired(),
FileAllowed(["jpg", "jpeg", "png", "webp"])])
submit = SubmitField(_l("Add photo"))
+267
View File
@@ -0,0 +1,267 @@
"""Listings: browse/search/detail + owner CRUD + image management + dev media."""
import os
from datetime import datetime
from flask import (Blueprint, render_template, request, redirect, url_for,
flash, abort, current_app, send_from_directory)
from flask_login import login_required, current_user
from flask_babel import gettext as _
from app.extensions import db, limiter
from app.models.category import Category
from app.models.listing import Listing, ListingImage
from app.models.enums import ListingStatus
from app.services import listings as svc
from app.services.geo import geocode_zip
from app.services.images import process_upload, delete_image_files, ImageError
from app.blueprints.listings.forms import ListingForm, ImageUploadForm
listings_bp = Blueprint("listings", __name__)
PER_PAGE = 20
def _category_choices():
cats = Category.query.filter_by(is_active=True).order_by(
Category.sort_order, Category.name).all()
return cats
def _parse_attributes(category):
"""Pull attr_<name> fields from the submitted form."""
raw = {}
for f in category.fields():
raw[f["name"]] = request.form.get("attr_" + f["name"])
return raw
# --- browse + search ---
@listings_bp.route("/listings")
def browse():
page = request.args.get("page", 1, type=int)
category_id = request.args.get("category", type=int)
q = request.args.get("q", type=str)
state = request.args.get("state", type=str)
zip_code = request.args.get("zip", type=str)
radius = request.args.get("radius", type=int)
min_price = request.args.get("min_price", type=int)
max_price = request.args.get("max_price", type=int)
condition = request.args.get("condition", type=str)
base = svc.browse_query(category_id=category_id, q=q, state=state,
min_price=(min_price * 100 if min_price else None),
max_price=(max_price * 100 if max_price else None),
condition=condition)
base = svc.order_default(base)
near = None
if zip_code and radius:
geo = geocode_zip(zip_code)
if geo:
lat, lng = geo[0], geo[1]
results = svc.search_with_radius(base, lat, lng, radius)
# paginate manually for radius results
total = len(results)
start = (page - 1) * PER_PAGE
items = results[start:start + PER_PAGE]
near = {"zip": zip_code, "radius": radius, "total": total}
return render_template("listings/browse.html",
results=items, near=near,
categories=_category_choices(),
filters=request.args, page=page,
has_next=start + PER_PAGE < total)
flash(_("ZIP not found; showing all results."), "warning")
pagination = base.paginate(page=page, per_page=PER_PAGE, error_out=False)
results = [(l, None) for l in pagination.items]
return render_template("listings/browse.html", results=results, near=None,
categories=_category_choices(), filters=request.args,
page=page, has_next=pagination.has_next)
# --- detail ---
@listings_bp.route("/listings/<int:listing_id>")
def detail(listing_id):
listing = Listing.query.get_or_404(listing_id)
is_owner = current_user.is_authenticated and listing.user_id == current_user.id
if not listing.is_live and not (is_owner or
(current_user.is_authenticated
and current_user.is_moderator)):
abort(404)
if not is_owner:
listing.view_count = (listing.view_count or 0) + 1
db.session.commit()
return render_template("listings/detail.html", listing=listing,
is_owner=is_owner)
# --- create ---
@listings_bp.route("/listings/new", methods=["GET", "POST"])
@login_required
@limiter.limit("30 per hour", methods=["POST"])
def create():
if not svc.can_create(current_user):
flash(_("You've reached your plan's active-listing limit."), "warning")
return redirect(url_for("listings.mine"))
form = ListingForm()
cats = _category_choices()
form.category_id.choices = [(c.id, c.name) for c in cats]
category = None
if request.method == "POST" and form.category_id.data:
category = Category.query.get(form.category_id.data)
if form.validate_on_submit() and category:
raw_attrs = _parse_attributes(category)
price_cents = int(form.price.data * 100) if form.price.data is not None else None
try:
listing = svc.create_listing(
current_user, category,
title=form.title.data, body=form.body.data, lang=form.lang.data,
price_cents=price_cents, zip_code=form.zip.data,
raw_attributes=raw_attrs)
except svc.ListingError as e:
for fld, msg in (e.field_errors or {}).items():
flash(_("%(f)s: %(m)s", f=fld, m=msg), "danger")
if not e.field_errors:
flash(str(e), "danger")
return render_template("listings/form.html", form=form,
categories=cats, category=category,
listing=None)
# process any uploaded images, honoring per-plan cap
_save_images(form.images.data, listing)
flash(_("Listing published."), "success")
return redirect(url_for("listings.detail", listing_id=listing.id))
return render_template("listings/form.html", form=form, categories=cats,
category=category, listing=None)
# --- edit ---
@listings_bp.route("/listings/<int:listing_id>/edit", methods=["GET", "POST"])
@login_required
def edit(listing_id):
listing = Listing.query.get_or_404(listing_id)
if listing.user_id != current_user.id and not current_user.is_moderator:
abort(403)
cats = _category_choices()
form = ListingForm(obj=None)
form.category_id.choices = [(c.id, c.name) for c in cats]
if request.method == "GET":
form.category_id.data = listing.category_id
form.title.data = listing.title
form.body.data = listing.body
form.lang.data = listing.lang.value
form.price.data = (listing.price_cents / 100) if listing.price_cents else None
form.zip.data = listing.zip
category = Category.query.get(form.category_id.data or listing.category_id)
if form.validate_on_submit() and category:
raw_attrs = _parse_attributes(category)
price_cents = int(form.price.data * 100) if form.price.data is not None else None
try:
svc.update_listing(listing, category, title=form.title.data,
body=form.body.data, lang=form.lang.data,
price_cents=price_cents, zip_code=form.zip.data,
raw_attributes=raw_attrs)
except svc.ListingError as e:
for fld, msg in (e.field_errors or {}).items():
flash(_("%(f)s: %(m)s", f=fld, m=msg), "danger")
return render_template("listings/form.html", form=form,
categories=cats, category=category,
listing=listing)
_save_images(form.images.data, listing)
flash(_("Listing updated."), "success")
return redirect(url_for("listings.detail", listing_id=listing.id))
return render_template("listings/form.html", form=form, categories=cats,
category=category, listing=listing)
# --- delete ---
@listings_bp.route("/listings/<int:listing_id>/delete", methods=["POST"])
@login_required
def delete(listing_id):
listing = Listing.query.get_or_404(listing_id)
if listing.user_id != current_user.id and not current_user.is_moderator:
abort(403)
for img in list(listing.images):
delete_image_files(img)
db.session.delete(listing)
db.session.commit()
flash(_("Listing deleted."), "info")
return redirect(url_for("listings.mine"))
# --- mark sold ---
@listings_bp.route("/listings/<int:listing_id>/sold", methods=["POST"])
@login_required
def mark_sold(listing_id):
listing = Listing.query.get_or_404(listing_id)
if listing.user_id != current_user.id:
abort(403)
listing.status = ListingStatus.sold
db.session.commit()
flash(_("Marked as sold."), "success")
return redirect(url_for("listings.detail", listing_id=listing.id))
# --- my listings ---
@listings_bp.route("/my/listings")
@login_required
def mine():
items = (Listing.query.filter_by(user_id=current_user.id)
.order_by(Listing.created_at.desc()).all())
cap = svc._limit(current_user, "active_listings", 3)
return render_template("listings/mine.html", items=items,
active=svc.active_count(current_user), cap=cap)
# --- image delete ---
@listings_bp.route("/listings/<int:listing_id>/images/<int:image_id>/delete",
methods=["POST"])
@login_required
def delete_image(listing_id, image_id):
listing = Listing.query.get_or_404(listing_id)
if listing.user_id != current_user.id and not current_user.is_moderator:
abort(403)
img = ListingImage.query.get_or_404(image_id)
if img.listing_id != listing.id:
abort(404)
delete_image_files(img)
db.session.delete(img)
db.session.commit()
flash(_("Photo removed."), "info")
return redirect(url_for("listings.edit", listing_id=listing.id))
# --- dev media serving (Nginx serves /media in prod) ---
@listings_bp.route("/media/<path:rel>")
def media(rel):
root = current_app.config.get("MEDIA_ROOT") or os.path.join(
current_app.instance_path, "media")
return send_from_directory(root, rel)
def _save_images(file_list, listing):
if not file_list:
return
cap = svc.image_cap(current_user)
existing = len(listing.images)
saved = 0
for fs in file_list:
if not getattr(fs, "filename", ""):
continue
if cap is not None and existing + saved >= cap:
flash(_("Photo limit (%(n)s) reached for your plan.", n=cap), "warning")
break
try:
img = process_upload(fs, listing.id, sort_order=existing + saved)
db.session.add(img)
saved += 1
except ImageError as e:
flash(_("Image skipped: %(m)s", m=str(e)), "warning")
if saved:
db.session.commit()
View File
+14
View File
@@ -0,0 +1,14 @@
"""Main blueprint: landing page and health check."""
from flask import Blueprint, render_template, jsonify
main_bp = Blueprint("main", __name__)
@main_bp.route("/")
def index():
return render_template("index.html")
@main_bp.route("/healthz")
def healthz():
return jsonify(status="ok")