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
+100
View File
@@ -0,0 +1,100 @@
"""Application factory."""
from flask import Flask, render_template
from app.config import get_config
from app.extensions import (db, migrate, login_manager, csrf, babel, limiter)
def create_app(config_object=None):
app = Flask(__name__)
app.config.from_object(config_object or get_config())
_init_extensions(app)
_init_login(app)
_init_babel(app)
_register_blueprints(app)
_register_errorhandlers(app)
_register_context(app)
_register_cli(app)
return app
def _init_extensions(app):
# Honor X-Forwarded-* from the Nginx reverse proxy (scheme, host, client IP).
# Correct is_secure, secure cookies, and rate-limit keying behind the proxy.
from werkzeug.middleware.proxy_fix import ProxyFix
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1)
db.init_app(app)
migrate.init_app(app, db)
csrf.init_app(app)
limiter.init_app(app)
# ensure models are imported so migrations see them
from app import models # noqa: F401
def _init_login(app):
login_manager.init_app(app)
@login_manager.user_loader
def load_user(user_id):
from app.models.user import User
return User.query.get(int(user_id))
def _init_babel(app):
from app.blueprints.i18n.routes import select_locale
babel.init_app(app, locale_selector=select_locale)
def _register_blueprints(app):
from app.blueprints.main.routes import main_bp
from app.blueprints.auth.routes import auth_bp
from app.blueprints.i18n.routes import i18n_bp
from app.blueprints.listings.routes import listings_bp
app.register_blueprint(main_bp)
app.register_blueprint(auth_bp)
app.register_blueprint(i18n_bp)
app.register_blueprint(listings_bp)
def _register_errorhandlers(app):
@app.errorhandler(403)
def forbidden(e):
return render_template("errors/403.html"), 403
@app.errorhandler(404)
def not_found(e):
return render_template("errors/404.html"), 404
@app.errorhandler(500)
def server_error(e):
return render_template("errors/500.html"), 500
def _register_context(app):
from flask_babel import get_locale
from flask import request
def merge_query(**overrides):
merged = request.args.to_dict()
merged.update({k: v for k, v in overrides.items() if v is not None})
return merged
@app.context_processor
def inject_globals():
return {
"get_locale": get_locale,
"merge_query": merge_query,
"SUPPORTED_LOCALES": app.config["SUPPORTED_LOCALES"],
"TURNSTILE_SITE_KEY": app.config.get("TURNSTILE_SITE_KEY", ""),
}
def _register_cli(app):
@app.cli.command("expire-listings")
def expire_listings():
"""Sweep: flip past-due active listings to expired."""
from app.services.listings import expire_due_listings
n = expire_due_listings()
print(f"Expired {n} listing(s).")
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")
+85
View File
@@ -0,0 +1,85 @@
"""Application configuration. Env-driven, Dev/Prod classes."""
import os
from dotenv import load_dotenv
load_dotenv()
def _bool(val, default=False):
if val is None:
return default
return str(val).strip().lower() in ("1", "true", "yes", "on")
class BaseConfig:
SECRET_KEY = os.environ.get("SECRET_KEY", "dev-insecure-change-me")
# --- Database ---
@staticmethod
def _database_uri():
url = os.environ.get("DATABASE_URL")
if url:
return url
user = os.environ.get("DB_USER", "classifieds")
pwd = os.environ.get("DB_PASSWORD", "")
host = os.environ.get("DB_HOST", "127.0.0.1")
port = os.environ.get("DB_PORT", "3306")
name = os.environ.get("DB_NAME", "classifieds")
return f"mysql+pymysql://{user}:{pwd}@{host}:{port}/{name}?charset=utf8mb4"
SQLALCHEMY_DATABASE_URI = _database_uri.__func__()
SQLALCHEMY_TRACK_MODIFICATIONS = False
SQLALCHEMY_ENGINE_OPTIONS = {"pool_pre_ping": True, "pool_recycle": 280}
# --- Redis ---
REDIS_URL = os.environ.get("REDIS_URL", "redis://127.0.0.1:6379/0")
RATELIMIT_STORAGE_URI = REDIS_URL
# --- i18n ---
DEFAULT_LOCALE = os.environ.get("DEFAULT_LOCALE", "en")
SUPPORTED_LOCALES = [
s.strip() for s in os.environ.get("SUPPORTED_LOCALES", "en,vi,es").split(",") if s.strip()
]
BABEL_DEFAULT_LOCALE = DEFAULT_LOCALE
BABEL_TRANSLATION_DIRECTORIES = "translations"
# --- Email ---
MAIL_SERVER = os.environ.get("MAIL_SERVER", "")
MAIL_PORT = int(os.environ.get("MAIL_PORT", "587"))
MAIL_USE_TLS = _bool(os.environ.get("MAIL_USE_TLS"), True)
MAIL_USERNAME = os.environ.get("MAIL_USERNAME", "")
MAIL_PASSWORD = os.environ.get("MAIL_PASSWORD", "")
MAIL_FROM = os.environ.get("MAIL_FROM", "no-reply@example.com")
MAIL_FROM_NAME = os.environ.get("MAIL_FROM_NAME", "Classifieds")
# --- Turnstile ---
TURNSTILE_SITE_KEY = os.environ.get("TURNSTILE_SITE_KEY", "")
TURNSTILE_SECRET_KEY = os.environ.get("TURNSTILE_SECRET_KEY", "")
# --- Token lifetimes ---
TOKEN_VERIFY_MAX_AGE = int(os.environ.get("TOKEN_VERIFY_MAX_AGE", "86400"))
TOKEN_RESET_MAX_AGE = int(os.environ.get("TOKEN_RESET_MAX_AGE", "3600"))
# --- Media (uploaded images) ---
MEDIA_ROOT = os.environ.get("MEDIA_ROOT") or None # default: instance/media
# --- Session cookie hardening ---
SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_SAMESITE = "Lax"
PREFERRED_URL_SCHEME = "https"
class DevConfig(BaseConfig):
DEBUG = True
SESSION_COOKIE_SECURE = False
class ProdConfig(BaseConfig):
DEBUG = False
SESSION_COOKIE_SECURE = True
SERVER_NAME = os.environ.get("SERVER_NAME") or None
def get_config():
name = os.environ.get("FLASK_CONFIG", "dev").lower()
return ProdConfig if name == "prod" else DevConfig
+18
View File
@@ -0,0 +1,18 @@
"""Extension singletons. Initialized in the app factory."""
from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate
from flask_login import LoginManager
from flask_wtf import CSRFProtect
from flask_babel import Babel
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address
db = SQLAlchemy()
migrate = Migrate()
login_manager = LoginManager()
csrf = CSRFProtect()
babel = Babel()
limiter = Limiter(key_func=get_remote_address)
login_manager.login_view = "auth.login"
login_manager.login_message_category = "warning"
+10
View File
@@ -0,0 +1,10 @@
"""Model exports. Import order matters for relationship resolution."""
from app.models.plan import Plan
from app.models.user import User
from app.models.trust import TrustEvent
from app.models.category import Category
from app.models.listing import Listing, ListingImage
from app.models.geo import ZipGeo, Metro
__all__ = ["Plan", "User", "TrustEvent", "Category", "Listing",
"ListingImage", "ZipGeo", "Metro"]
+55
View File
@@ -0,0 +1,55 @@
"""Category model. Self-referential (parent_id) for subcategories.
`field_schema` is admin-editable JSON describing category-specific listing
fields. Shape:
{
"fields": [
{"name":"condition","label":"Condition","type":"select",
"required":true,"options":["new","like_new","good","fair"],"hot":true},
{"name":"brand","label":"Brand","type":"text","required":false,"max":80},
{"name":"salary_min","label":"Min salary","type":"number","required":false,"hot":true}
]
}
Supported types: text, number, select, bool. `hot:true` marks a field whose
value is denormalized onto an indexed Listing column for fast filtering.
"""
from datetime import datetime
from sqlalchemy import JSON
from app.extensions import db
class Category(db.Model):
__tablename__ = "categories"
id = db.Column(db.BigInteger().with_variant(db.Integer, "sqlite"),
primary_key=True, autoincrement=True)
slug = db.Column(db.String(60), unique=True, nullable=False, index=True)
name = db.Column(db.String(80), nullable=False)
parent_id = db.Column(db.BigInteger().with_variant(db.Integer, "sqlite"),
db.ForeignKey("categories.id"), nullable=True, index=True)
field_schema = db.Column(JSON, nullable=False, default=dict)
icon = db.Column(db.String(40), nullable=True)
sort_order = db.Column(db.Integer, nullable=False, default=0)
sponsor_id = db.Column(db.BigInteger, nullable=True) # FK wired in Phase 5
is_active = db.Column(db.Boolean, nullable=False, default=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
updated_at = db.Column(db.DateTime, default=datetime.utcnow,
onupdate=datetime.utcnow, nullable=False)
children = db.relationship("Category", backref=db.backref("parent",
remote_side=[id]), lazy="selectin")
listings = db.relationship("Listing", back_populates="category", lazy="dynamic")
@property
def is_subcategory(self):
return self.parent_id is not None
def fields(self):
return (self.field_schema or {}).get("fields", [])
def hot_fields(self):
return [f for f in self.fields() if f.get("hot")]
def __repr__(self):
return f"<Category {self.slug}>"
+44
View File
@@ -0,0 +1,44 @@
"""Shared enumerations used across models."""
import enum
class Role(str, enum.Enum):
free = "free"
subscriber = "subscriber"
moderator = "moderator"
admin = "admin"
class UserStatus(str, enum.Enum):
active = "active"
suspended = "suspended"
banned = "banned"
class TrustTier(str, enum.Enum):
new = "new"
basic = "basic"
trusted = "trusted"
verified = "verified"
class TrustEventType(str, enum.Enum):
account_age = "account_age"
listing_survived = "listing_survived"
flag_received = "flag_received"
verified_email = "verified_email"
payment = "payment"
class ListingStatus(str, enum.Enum):
active = "active"
flagged = "flagged"
sold = "sold"
expired = "expired"
removed = "removed"
class Lang(str, enum.Enum):
en = "en"
vi = "vi"
es = "es"
+36
View File
@@ -0,0 +1,36 @@
"""Geocoding reference tables.
ZipGeo: offline US ZIP -> lat/lng/city/state/metro (source the full ~42k-row
dataset from SimpleMaps US ZIP or Census ZCTA; a small sample is seeded for tests).
Metro: SEO landing-page anchor (pages built in Phase 7; column wired now).
"""
from app.extensions import db
class ZipGeo(db.Model):
__tablename__ = "zip_geo"
zip = db.Column(db.String(12), primary_key=True)
city = db.Column(db.String(80), nullable=False)
state = db.Column(db.String(2), nullable=False, index=True)
lat = db.Column(db.Float, nullable=False)
lng = db.Column(db.Float, nullable=False)
metro = db.Column(db.String(80), nullable=True, index=True)
def __repr__(self):
return f"<ZipGeo {self.zip} {self.city},{self.state}>"
class Metro(db.Model):
__tablename__ = "metros"
id = db.Column(db.BigInteger().with_variant(db.Integer, "sqlite"),
primary_key=True, autoincrement=True)
slug = db.Column(db.String(80), unique=True, nullable=False, index=True)
name = db.Column(db.String(120), nullable=False)
state = db.Column(db.String(2), nullable=True)
center_lat = db.Column(db.Float, nullable=True)
center_lng = db.Column(db.Float, nullable=True)
def __repr__(self):
return f"<Metro {self.slug}>"
+109
View File
@@ -0,0 +1,109 @@
"""Listing model.
Portability notes (see README "MySQL upgrades"):
- Location stored as lat/lng floats; radius search uses a bounding-box prefilter
+ haversine refine. On MySQL you may add a generated POINT + SPATIAL INDEX.
- Hot filter fields (condition/job_type/salary) are denormalized onto indexed
columns, populated in the service layer from `attributes`. On MySQL these can
instead be GENERATED ALWAYS columns over the JSON.
- Free-text search uses the accent-insensitive `title_norm` + body LIKE. On
MySQL add a FULLTEXT(title, body) index and switch to MATCH ... AGAINST.
"""
from datetime import datetime
from sqlalchemy import JSON, Index
from app.extensions import db
from app.models.enums import ListingStatus, Lang
class Listing(db.Model):
__tablename__ = "listings"
id = db.Column(db.BigInteger().with_variant(db.Integer, "sqlite"),
primary_key=True, autoincrement=True)
user_id = db.Column(db.BigInteger().with_variant(db.Integer, "sqlite"),
db.ForeignKey("users.id"), nullable=False, index=True)
category_id = db.Column(db.BigInteger().with_variant(db.Integer, "sqlite"),
db.ForeignKey("categories.id"), nullable=False, index=True)
title = db.Column(db.String(140), nullable=False)
title_norm = db.Column(db.String(140), nullable=False, index=True) # accent-stripped
body = db.Column(db.Text, nullable=False)
lang = db.Column(db.Enum(Lang), nullable=False, default=Lang.en)
price_cents = db.Column(db.Integer, nullable=True, index=True)
# location
zip = db.Column(db.String(12), nullable=True, index=True)
city = db.Column(db.String(80), nullable=True)
state = db.Column(db.String(2), nullable=True, index=True)
lat = db.Column(db.Float, nullable=True)
lng = db.Column(db.Float, nullable=True)
# category-specific values
attributes = db.Column(JSON, nullable=False, default=dict)
# denormalized hot filter columns (populated from attributes on save)
attr_condition = db.Column(db.String(40), nullable=True, index=True)
attr_job_type = db.Column(db.String(40), nullable=True, index=True)
attr_salary_min = db.Column(db.Integer, nullable=True, index=True)
attr_salary_max = db.Column(db.Integer, nullable=True, index=True)
status = db.Column(db.Enum(ListingStatus), nullable=False,
default=ListingStatus.active, index=True)
is_featured = db.Column(db.Boolean, nullable=False, default=False)
bump_at = db.Column(db.DateTime, nullable=True)
flag_count = db.Column(db.Integer, nullable=False, default=0)
view_count = db.Column(db.Integer, nullable=False, default=0)
expires_at = db.Column(db.DateTime, nullable=False, index=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
updated_at = db.Column(db.DateTime, default=datetime.utcnow,
onupdate=datetime.utcnow, nullable=False)
user = db.relationship("User", backref=db.backref("listings", lazy="dynamic"))
category = db.relationship("Category", back_populates="listings")
images = db.relationship("ListingImage", back_populates="listing",
order_by="ListingImage.sort_order",
cascade="all, delete-orphan", lazy="selectin")
__table_args__ = (
Index("ix_listing_browse", "status", "expires_at"),
Index("ix_listing_sort", "is_featured", "bump_at"),
)
@property
def is_live(self):
return (self.status == ListingStatus.active
and self.expires_at > datetime.utcnow())
@property
def price_display(self):
if self.price_cents is None:
return None
return f"${self.price_cents / 100:,.2f}"
@property
def cover(self):
return self.images[0] if self.images else None
def __repr__(self):
return f"<Listing {self.id} {self.title[:24]!r}>"
class ListingImage(db.Model):
__tablename__ = "listing_images"
id = db.Column(db.BigInteger().with_variant(db.Integer, "sqlite"),
primary_key=True, autoincrement=True)
listing_id = db.Column(db.BigInteger().with_variant(db.Integer, "sqlite"),
db.ForeignKey("listings.id"), nullable=False, index=True)
path = db.Column(db.String(255), nullable=False)
thumb_path = db.Column(db.String(255), nullable=True)
sort_order = db.Column(db.Integer, nullable=False, default=0)
width = db.Column(db.Integer, nullable=True)
height = db.Column(db.Integer, nullable=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
listing = db.relationship("Listing", back_populates="images")
def __repr__(self):
return f"<ListingImage {self.id} of L{self.listing_id}>"
+30
View File
@@ -0,0 +1,30 @@
"""Plan model. Tier limits live in `config` JSON, editable in admin without redeploy."""
from datetime import datetime
from sqlalchemy import JSON
from app.extensions import db
class Plan(db.Model):
__tablename__ = "plans"
id = db.Column(db.BigInteger().with_variant(db.Integer, "sqlite"), primary_key=True, autoincrement=True)
slug = db.Column(db.String(40), unique=True, nullable=False) # free|basic|pro|business
name = db.Column(db.String(80), nullable=False)
price_monthly_cents = db.Column(db.Integer, nullable=False, default=0)
stripe_price_id = db.Column(db.String(80), nullable=True)
config = db.Column(JSON, nullable=False, default=dict) # limits (Section 6)
is_active = db.Column(db.Boolean, nullable=False, default=True)
sort_order = db.Column(db.Integer, nullable=False, default=0)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
updated_at = db.Column(db.DateTime, default=datetime.utcnow,
onupdate=datetime.utcnow, nullable=False)
users = db.relationship("User", back_populates="tier", lazy="dynamic")
def limit(self, key, default=None):
"""Read a single limit from config JSON."""
return (self.config or {}).get(key, default)
def __repr__(self):
return f"<Plan {self.slug}>"
+19
View File
@@ -0,0 +1,19 @@
"""TrustEvent model. Append-only events that adjust a user's trust score."""
from datetime import datetime
from app.extensions import db
from app.models.enums import TrustEventType
class TrustEvent(db.Model):
__tablename__ = "trust_events"
id = db.Column(db.BigInteger().with_variant(db.Integer, "sqlite"), primary_key=True, autoincrement=True)
user_id = db.Column(db.BigInteger, db.ForeignKey("users.id"), nullable=False, index=True)
type = db.Column(db.Enum(TrustEventType), nullable=False)
delta = db.Column(db.Integer, nullable=False, default=0)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
user = db.relationship("User", back_populates="trust_events")
def __repr__(self):
return f"<TrustEvent u{self.user_id} {self.type.value} {self.delta:+d}>"
+67
View File
@@ -0,0 +1,67 @@
"""User model. Argon2 password hashing, role/trust fields, Flask-Login mixin."""
from datetime import datetime
from flask_login import UserMixin
from app.extensions import db
from app.models.enums import Role, UserStatus, TrustTier
from app.utils.security import hash_password, verify_password
class User(UserMixin, db.Model):
__tablename__ = "users"
id = db.Column(db.BigInteger().with_variant(db.Integer, "sqlite"), primary_key=True, autoincrement=True)
email = db.Column(db.String(255), unique=True, nullable=False, index=True)
password_hash = db.Column(db.String(255), nullable=False)
display_name = db.Column(db.String(80), nullable=False)
role = db.Column(db.Enum(Role), nullable=False, default=Role.free)
status = db.Column(db.Enum(UserStatus), nullable=False, default=UserStatus.active)
locale = db.Column(db.String(5), nullable=False, default="en")
tier_id = db.Column(db.BigInteger, db.ForeignKey("plans.id"), nullable=True)
tier = db.relationship("Plan", back_populates="users")
trust_score = db.Column(db.Integer, nullable=False, default=0)
trust_tier = db.Column(db.Enum(TrustTier), nullable=False, default=TrustTier.new)
verified = db.Column(db.Boolean, nullable=False, default=False) # identity/badge
email_verified = db.Column(db.Boolean, nullable=False, default=False)
last_login_at = db.Column(db.DateTime, nullable=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
updated_at = db.Column(db.DateTime, default=datetime.utcnow,
onupdate=datetime.utcnow, nullable=False)
trust_events = db.relationship("TrustEvent", back_populates="user",
lazy="dynamic", cascade="all, delete-orphan")
# --- password ---
def set_password(self, raw):
self.password_hash = hash_password(raw)
def check_password(self, raw):
return verify_password(self.password_hash, raw)
# --- RBAC helpers ---
@property
def is_admin(self):
return self.role == Role.admin
@property
def is_moderator(self):
return self.role in (Role.moderator, Role.admin)
@property
def is_subscriber(self):
return self.role == Role.subscriber
def has_role(self, *roles):
return self.role in roles
@property
def is_active(self):
# Flask-Login uses this to permit login.
return self.status == UserStatus.active
def __repr__(self):
return f"<User {self.email}>"
View File
+34
View File
@@ -0,0 +1,34 @@
"""Email service. Sends via SMTP relay (Brevo); falls back to console log in dev."""
import smtplib
from email.message import EmailMessage
from flask import current_app
def send_email(to_addr: str, subject: str, body: str) -> bool:
cfg = current_app.config
server = cfg.get("MAIL_SERVER")
# Dev fallback: no SMTP configured -> log to console.
if not server:
current_app.logger.info(
"[DEV EMAIL] to=%s subject=%s\n%s", to_addr, subject, body
)
return True
msg = EmailMessage()
msg["From"] = f"{cfg.get('MAIL_FROM_NAME')} <{cfg.get('MAIL_FROM')}>"
msg["To"] = to_addr
msg["Subject"] = subject
msg.set_content(body)
try:
with smtplib.SMTP(server, cfg.get("MAIL_PORT", 587), timeout=15) as s:
if cfg.get("MAIL_USE_TLS"):
s.starttls()
if cfg.get("MAIL_USERNAME"):
s.login(cfg.get("MAIL_USERNAME"), cfg.get("MAIL_PASSWORD"))
s.send_message(msg)
return True
except Exception as exc: # noqa: BLE001
current_app.logger.error("Email send failed: %s", exc)
return False
+75
View File
@@ -0,0 +1,75 @@
"""Validate listing `attributes` against a category's `field_schema`.
Returns (cleaned_attributes, errors). Caller rejects on any errors.
Supported field types: text, number, select, bool.
"""
_HOT_COLUMNS = {"condition", "job_type", "salary_min", "salary_max"}
class SchemaError(ValueError):
pass
def validate_attributes(category, raw):
raw = raw or {}
cleaned, errors = {}, {}
for field in category.fields():
name = field["name"]
ftype = field.get("type", "text")
required = field.get("required", False)
val = raw.get(name)
if val in (None, "", []):
if required:
errors[name] = "required"
continue
try:
cleaned[name] = _coerce(field, ftype, val)
except SchemaError as e:
errors[name] = str(e)
return cleaned, errors
def _coerce(field, ftype, val):
if ftype == "text":
s = str(val).strip()
mx = field.get("max", 255)
if len(s) > mx:
raise SchemaError(f"max length {mx}")
return s
if ftype == "number":
try:
n = int(val) if str(val).lstrip("-").isdigit() else float(val)
except (TypeError, ValueError):
raise SchemaError("must be a number")
if "min" in field and n < field["min"]:
raise SchemaError(f"min {field['min']}")
if "max" in field and n > field["max"]:
raise SchemaError(f"max {field['max']}")
return n
if ftype == "select":
opts = field.get("options", [])
if val not in opts:
raise SchemaError("invalid option")
return val
if ftype == "bool":
return bool(val) if isinstance(val, bool) else str(val).lower() in ("1", "true", "on", "yes")
raise SchemaError("unknown field type")
def hot_values(cleaned):
"""Map cleaned attributes -> denormalized Listing hot columns."""
return {
"attr_condition": cleaned.get("condition"),
"attr_job_type": cleaned.get("job_type"),
"attr_salary_min": cleaned.get("salary_min"),
"attr_salary_max": cleaned.get("salary_max"),
}
+35
View File
@@ -0,0 +1,35 @@
"""Geo helpers: ZIP geocoding, haversine distance, bounding-box radius filter.
Portable across MySQL and SQLite (no spatial extension needed). For large-scale
deployments, see README for the MySQL POINT + SPATIAL INDEX upgrade.
"""
import math
from app.models.geo import ZipGeo
EARTH_MI = 3958.7613 # mean earth radius, miles
def geocode_zip(zip_code):
"""Return (lat, lng, city, state, metro) or None."""
row = ZipGeo.query.get((zip_code or "").strip())
if row is None:
return None
return row.lat, row.lng, row.city, row.state, row.metro
def haversine_mi(lat1, lng1, lat2, lng2):
rlat1, rlat2 = math.radians(lat1), math.radians(lat2)
dlat = math.radians(lat2 - lat1)
dlng = math.radians(lng2 - lng1)
a = (math.sin(dlat / 2) ** 2
+ math.cos(rlat1) * math.cos(rlat2) * math.sin(dlng / 2) ** 2)
return EARTH_MI * 2 * math.asin(math.sqrt(a))
def bounding_box(lat, lng, radius_mi):
"""Lat/lng min/max box enclosing the radius. Cheap indexed prefilter."""
lat_delta = radius_mi / 69.0
# guard against poles; cos(lat) shrinks lng degrees-per-mile
cos_lat = max(math.cos(math.radians(lat)), 0.01)
lng_delta = radius_mi / (69.172 * cos_lat)
return (lat - lat_delta, lat + lat_delta, lng - lng_delta, lng + lng_delta)
+76
View File
@@ -0,0 +1,76 @@
"""Image pipeline. Validates, re-encodes (drops EXIF), thumbnails, saves to media.
Re-encoding through Pillow strips metadata and neutralizes polyglot/malicious
payloads. Filenames are randomized. Returns a ListingImage (uncommitted).
"""
import io
import os
import secrets
from PIL import Image
from flask import current_app
from app.models.listing import ListingImage
MAX_BYTES = 8 * 1024 * 1024 # 8 MB per upload
MAX_DIM = 1600 # longest edge for the full image
THUMB_DIM = 400
ALLOWED = {"JPEG", "PNG", "WEBP"}
class ImageError(ValueError):
pass
def _media_root():
root = current_app.config.get("MEDIA_ROOT") or os.path.join(
current_app.instance_path, "media")
os.makedirs(root, exist_ok=True)
return root
def process_upload(file_storage, listing_id, sort_order=0):
data = file_storage.read()
if not data:
raise ImageError("empty file")
if len(data) > MAX_BYTES:
raise ImageError("file too large")
try:
img = Image.open(io.BytesIO(data))
img.verify() # detect truncated/corrupt
img = Image.open(io.BytesIO(data)) # re-open after verify
except Exception:
raise ImageError("not a valid image")
if img.format not in ALLOWED:
raise ImageError("unsupported format")
img = img.convert("RGB") # normalize, drop alpha/EXIF
img.thumbnail((MAX_DIM, MAX_DIM))
root = _media_root()
sub = os.path.join(root, str(listing_id))
os.makedirs(sub, exist_ok=True)
name = secrets.token_hex(16)
full_rel = os.path.join(str(listing_id), f"{name}.jpg")
thumb_rel = os.path.join(str(listing_id), f"{name}_t.jpg")
img.save(os.path.join(root, full_rel), "JPEG", quality=85, optimize=True)
thumb = img.copy()
thumb.thumbnail((THUMB_DIM, THUMB_DIM))
thumb.save(os.path.join(root, thumb_rel), "JPEG", quality=80, optimize=True)
return ListingImage(
listing_id=listing_id, path=full_rel, thumb_path=thumb_rel,
sort_order=sort_order, width=img.width, height=img.height,
)
def delete_image_files(image):
root = _media_root()
for rel in (image.path, image.thumb_path):
if rel:
try:
os.remove(os.path.join(root, rel))
except OSError:
pass
+186
View File
@@ -0,0 +1,186 @@
"""Listing business logic: creation/edit with tier enforcement, the
browse/search/radius query builder, and the expiry sweep.
"""
from datetime import datetime, timedelta
from app.extensions import db
from app.models.listing import Listing
from app.models.enums import ListingStatus, Lang
from app.utils.text import normalize
from app.services.geo import geocode_zip, bounding_box, haversine_mi
from app.services.field_schema import validate_attributes, hot_values
class ListingError(ValueError):
def __init__(self, message, field_errors=None):
super().__init__(message)
self.field_errors = field_errors or {}
# --- tier limits ---
def _limit(user, key, default):
plan = user.tier
if plan is None:
return default
val = plan.limit(key, default)
return val
def active_count(user):
return Listing.query.filter(
Listing.user_id == user.id,
Listing.status == ListingStatus.active,
Listing.expires_at > datetime.utcnow(),
).count()
def can_create(user):
cap = _limit(user, "active_listings", 3)
if cap is None: # unlimited (business)
return True
return active_count(user) < cap
def image_cap(user):
return _limit(user, "images_per_listing", 3)
def _life_days(user):
return _limit(user, "listing_life_days", 14)
# --- create / update ---
def create_listing(user, category, *, title, body, lang, price_cents,
zip_code, raw_attributes):
if not can_create(user):
raise ListingError("active listing limit reached for your plan")
cleaned, errors = validate_attributes(category, raw_attributes)
if errors:
raise ListingError("attribute validation failed", errors)
listing = Listing(
user_id=user.id,
category_id=category.id,
title=title.strip(),
title_norm=normalize(title),
body=body.strip(),
lang=Lang(lang) if lang in Lang._value2member_map_ else Lang.en,
price_cents=price_cents,
attributes=cleaned,
status=ListingStatus.active,
expires_at=datetime.utcnow() + timedelta(days=_life_days(user)),
**hot_values(cleaned),
)
_apply_location(listing, zip_code)
db.session.add(listing)
db.session.commit()
return listing
def update_listing(listing, category, *, title, body, lang, price_cents,
zip_code, raw_attributes):
cleaned, errors = validate_attributes(category, raw_attributes)
if errors:
raise ListingError("attribute validation failed", errors)
listing.title = title.strip()
listing.title_norm = normalize(title)
listing.body = body.strip()
listing.lang = Lang(lang) if lang in Lang._value2member_map_ else listing.lang
listing.price_cents = price_cents
listing.attributes = cleaned
for col, val in hot_values(cleaned).items():
setattr(listing, col, val)
_apply_location(listing, zip_code)
db.session.commit()
return listing
def _apply_location(listing, zip_code):
listing.zip = (zip_code or "").strip() or None
geo = geocode_zip(zip_code) if zip_code else None
if geo:
listing.lat, listing.lng, listing.city, listing.state, _metro = geo
else:
listing.lat = listing.lng = listing.city = listing.state = None
# --- browse / search / radius ---
def browse_query(*, category_id=None, q=None, state=None, min_price=None,
max_price=None, condition=None, job_type=None):
"""Base query of live listings with optional filters (no radius)."""
query = Listing.query.filter(
Listing.status == ListingStatus.active,
Listing.expires_at > datetime.utcnow(),
)
if category_id:
query = query.filter(Listing.category_id == category_id)
if state:
query = query.filter(Listing.state == state.upper())
if min_price is not None:
query = query.filter(Listing.price_cents >= min_price)
if max_price is not None:
query = query.filter(Listing.price_cents <= max_price)
if condition:
query = query.filter(Listing.attr_condition == condition)
if job_type:
query = query.filter(Listing.attr_job_type == job_type)
if q:
term = f"%{normalize(q)}%"
# accent-insensitive on title_norm; body LIKE as a fallback.
query = query.filter(db.or_(
Listing.title_norm.like(term),
Listing.body.like(f"%{q.strip()}%"),
))
return query
def order_default(query):
"""Featured first, then most recently bumped/created."""
return query.order_by(
Listing.is_featured.desc(),
db.func.coalesce(Listing.bump_at, Listing.created_at).desc(),
)
def radius_filter(listings, lat, lng, radius_mi):
"""Refine an iterable of listings to those within radius (exact haversine).
Pair with a bounding-box DB prefilter for efficiency (see search_with_radius).
"""
out = []
for l in listings:
if l.lat is None or l.lng is None:
continue
d = haversine_mi(lat, lng, l.lat, l.lng)
if d <= radius_mi:
out.append((l, round(d, 1)))
out.sort(key=lambda t: t[1])
return out
def search_with_radius(base_query, lat, lng, radius_mi):
"""Apply a bounding-box prefilter in SQL, then exact-distance refine."""
min_lat, max_lat, min_lng, max_lng = bounding_box(lat, lng, radius_mi)
prefiltered = base_query.filter(
Listing.lat.between(min_lat, max_lat),
Listing.lng.between(min_lng, max_lng),
).all()
return radius_filter(prefiltered, lat, lng, radius_mi)
# --- expiry sweep (scheduler / CLI) ---
def expire_due_listings():
"""Flip active listings past expires_at to expired. Returns count."""
now = datetime.utcnow()
due = Listing.query.filter(
Listing.status == ListingStatus.active,
Listing.expires_at <= now,
)
n = 0
for listing in due:
listing.status = ListingStatus.expired
n += 1
if n:
db.session.commit()
return n
+31
View File
@@ -0,0 +1,31 @@
"""Trust scoring. Append events, recompute score + tier.
Phase 1 wiring: email verification grants trust. Listing-survival and flag
penalties hook in during Phase 2/3. Thresholds are intentionally simple here.
"""
from app.extensions import db
from app.models.trust import TrustEvent
from app.models.enums import TrustEventType, TrustTier
# tier thresholds by cumulative score
_TIER_THRESHOLDS = [
(50, TrustTier.verified),
(20, TrustTier.trusted),
(5, TrustTier.basic),
(0, TrustTier.new),
]
def record_event(user, event_type: TrustEventType, delta: int):
"""Append a trust event, bump score, recompute tier. Caller commits."""
db.session.add(TrustEvent(user_id=user.id, type=event_type, delta=delta))
user.trust_score = max(0, (user.trust_score or 0) + delta)
user.trust_tier = _tier_for(user.trust_score)
return user
def _tier_for(score: int) -> TrustTier:
for threshold, tier in _TIER_THRESHOLDS:
if score >= threshold:
return tier
return TrustTier.new
+34
View File
@@ -0,0 +1,34 @@
"""Cloudflare Turnstile verification. Bypasses in dev when no secret is set."""
import requests
from flask import current_app, request
_VERIFY_URL = "https://challenges.cloudflare.com/turnstile/v0/siteverify"
def turnstile_enabled() -> bool:
return bool(current_app.config.get("TURNSTILE_SECRET_KEY"))
def verify_turnstile() -> bool:
"""Validate the cf-turnstile-response token from the current request form."""
secret = current_app.config.get("TURNSTILE_SECRET_KEY")
if not secret:
return True # dev bypass
token = request.form.get("cf-turnstile-response", "")
if not token:
return False
try:
resp = requests.post(
_VERIFY_URL,
data={
"secret": secret,
"response": token,
"remoteip": request.remote_addr,
},
timeout=10,
)
return bool(resp.json().get("success"))
except Exception as exc: # noqa: BLE001
current_app.logger.error("Turnstile verify failed: %s", exc)
return False
+101
View File
@@ -0,0 +1,101 @@
:root{
--bg:#f6f7f9; --fg:#1c2430; --muted:#6b7785; --line:#e3e7ec;
--brand:#1f6feb; --brand-d:#1a5fd0; --ok:#1f9d55; --warn:#b7791f;
--danger:#d64545; --info:#2b6cb0; --card:#fff;
}
*{box-sizing:border-box}
body{margin:0;font:16px/1.5 system-ui,Segoe UI,Roboto,Helvetica,Arial,sans-serif;
background:var(--bg);color:var(--fg)}
.wrap{max-width:960px;margin:0 auto;padding:0 16px}
a{color:var(--brand);text-decoration:none}
a:hover{text-decoration:underline}
.site-header{background:var(--card);border-bottom:1px solid var(--line)}
.site-header .wrap{display:flex;align-items:center;justify-content:space-between;height:56px}
.brand{font-weight:700;font-size:18px;color:var(--fg)}
.nav{display:flex;align-items:center;gap:14px}
.nav .hi{color:var(--muted)}
.langs{display:flex;gap:6px;margin-left:8px}
.langs a{font-size:12px;color:var(--muted);border:1px solid var(--line);
padding:2px 6px;border-radius:4px}
.langs a.on{background:var(--brand);color:#fff;border-color:var(--brand)}
.btn{display:inline-block;background:var(--brand);color:#fff;border:0;
padding:9px 16px;border-radius:8px;cursor:pointer;font-size:15px}
.btn:hover{background:var(--brand-d);text-decoration:none}
.btn-lg{padding:12px 22px;font-size:17px}
main.wrap{padding-top:24px;padding-bottom:48px}
.hero{text-align:center;padding:48px 0}
.hero h1{font-size:32px;margin:0 0 8px}
.hero p{color:var(--muted);margin:0 0 24px}
.card{background:var(--card);border:1px solid var(--line);border-radius:12px;
padding:24px}
.card.narrow{max-width:420px;margin:0 auto}
.card h2{margin-top:0}
.field{margin-bottom:14px;display:flex;flex-direction:column;gap:4px}
.field label{font-size:14px;color:var(--muted)}
.input{padding:9px 11px;border:1px solid var(--line);border-radius:8px;font-size:15px}
.input:focus{outline:2px solid var(--brand);border-color:var(--brand)}
.check{display:flex;align-items:center;gap:6px;font-size:14px;color:var(--muted);
margin-bottom:14px}
.errors{color:var(--danger);font-size:13px;margin:2px 0 0;padding-left:18px}
.muted{color:var(--muted);font-size:14px;margin-top:14px}
.flashes{margin-bottom:18px;display:flex;flex-direction:column;gap:8px}
.flash{padding:10px 14px;border-radius:8px;border:1px solid var(--line)}
.flash-success{background:#e8f6ee;border-color:#bfe3cd;color:var(--ok)}
.flash-danger{background:#fdeaea;border-color:#f4c4c4;color:var(--danger)}
.flash-warning{background:#fdf4e3;border-color:#f0dcae;color:var(--warn)}
.flash-info{background:#e9f1fb;border-color:#cadcf3;color:var(--info)}
.site-footer{border-top:1px solid var(--line);color:var(--muted);
font-size:13px;padding:18px 0}
.cf-turnstile{margin:0 0 14px}
/* --- Phase 2: listings --- */
.btn.ghost{background:#fff;color:var(--brand);border:1px solid var(--brand)}
.btn.danger{background:var(--danger)}
.btn.tiny{padding:2px 8px;font-size:12px;border-radius:6px}
.row2{display:grid;grid-template-columns:1fr 1fr;gap:12px}
.form-wide{max-width:640px;margin:0 auto}
.browse{display:grid;grid-template-columns:260px 1fr;gap:20px;align-items:start}
.filters h3{margin-top:0}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:14px}
.tile{padding:0;overflow:hidden;display:block;color:var(--fg)}
.tile:hover{text-decoration:none;box-shadow:0 2px 10px rgba(0,0,0,.08)}
.thumb{width:100%;height:140px;object-fit:cover;display:block;background:#eef1f4}
.thumb.noimg{display:flex;align-items:center;justify-content:center;color:var(--muted);font-size:13px}
.tile-body{padding:10px}
.tile-title{font-weight:600;font-size:14px;line-height:1.3;margin-bottom:4px}
.tile-meta{display:flex;gap:8px;align-items:center}
.price{color:var(--ok);font-weight:700}
.price.big{font-size:24px}
.small{font-size:12px}
.badge{display:inline-block;font-size:11px;padding:2px 7px;border-radius:10px;background:#eef1f4;color:var(--muted)}
.badge.ok{background:#e8f6ee;color:var(--ok)}
.badge.warn{background:#fdf4e3;color:var(--warn)}
.badge.cat{background:#e9f1fb;color:var(--info)}
.pager{margin-top:18px;display:flex;gap:16px}
.detail{display:grid;grid-template-columns:1fr 280px;gap:20px;align-items:start}
.detail-meta{display:flex;gap:10px;align-items:center;margin:8px 0}
.gallery{display:flex;flex-wrap:wrap;gap:8px;margin:12px 0}
.gallery img{max-width:220px;border-radius:8px}
.body{margin:14px 0;line-height:1.6}
.attrs{border-collapse:collapse;width:100%}
.attrs th,.attrs td{text-align:left;padding:6px 10px;border-bottom:1px solid var(--line)}
.attrs th{color:var(--muted);font-weight:600;width:40%}
.detail-side .btn{display:block;margin-bottom:8px;text-align:center}
.detail-side form{margin:0}
.seller{margin-bottom:14px}
.mine-head{display:flex;align-items:center;gap:14px;margin-bottom:14px}
.mine-head .btn{margin-left:auto}
table.list{width:100%;border-collapse:collapse}
table.list td{padding:8px 10px;border-bottom:1px solid var(--line)}
.thumbs{display:flex;gap:10px;flex-wrap:wrap}
.thumb-wrap{position:relative}
.thumb-wrap img{width:90px;height:90px;object-fit:cover;border-radius:8px}
.thumb-wrap form{position:absolute;top:2px;right:2px;margin:0}
@media(max-width:760px){.browse,.detail{grid-template-columns:1fr}}
+18
View File
@@ -0,0 +1,18 @@
{% macro field(f) %}
<div class="field">
{{ f.label }}
{{ f(class="input") }}
{% if f.errors %}
<ul class="errors">
{% for e in f.errors %}<li>{{ e }}</li>{% endfor %}
</ul>
{% endif %}
</div>
{% endmacro %}
{% macro turnstile_widget(enabled) %}
{% if enabled and TURNSTILE_SITE_KEY %}
<div class="cf-turnstile" data-sitekey="{{ TURNSTILE_SITE_KEY }}"></div>
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
{% endif %}
{% endmacro %}
+19
View File
@@ -0,0 +1,19 @@
{% extends "base.html" %}
{% from "auth/_macros.html" import field %}
{% block title %}{{ _('Sign in') }}{% endblock %}
{% block content %}
<div class="card narrow">
<h2>{{ _('Sign in') }}</h2>
<form method="post" novalidate>
{{ form.hidden_tag() }}
{{ field(form.email) }}
{{ field(form.password) }}
<label class="check">{{ form.remember() }} {{ form.remember.label.text }}</label>
{{ form.submit(class="btn") }}
</form>
<p class="muted">
<a href="{{ url_for('auth.reset_request') }}">{{ _('Forgot password?') }}</a> ·
<a href="{{ url_for('auth.register') }}">{{ _('Create account') }}</a>
</p>
</div>
{% endblock %}
+18
View File
@@ -0,0 +1,18 @@
{% extends "base.html" %}
{% from "auth/_macros.html" import field, turnstile_widget %}
{% block title %}{{ _('Register') }}{% endblock %}
{% block content %}
<div class="card narrow">
<h2>{{ _('Create account') }}</h2>
<form method="post" novalidate>
{{ form.hidden_tag() }}
{{ field(form.display_name) }}
{{ field(form.email) }}
{{ field(form.password) }}
{{ field(form.confirm) }}
{{ turnstile_widget(turnstile) }}
{{ form.submit(class="btn") }}
</form>
<p class="muted"><a href="{{ url_for('auth.login') }}">{{ _('Already have an account? Sign in') }}</a></p>
</div>
{% endblock %}
+14
View File
@@ -0,0 +1,14 @@
{% extends "base.html" %}
{% from "auth/_macros.html" import field %}
{% block title %}{{ _('Set new password') }}{% endblock %}
{% block content %}
<div class="card narrow">
<h2>{{ _('Set new password') }}</h2>
<form method="post" novalidate>
{{ form.hidden_tag() }}
{{ field(form.password) }}
{{ field(form.confirm) }}
{{ form.submit(class="btn") }}
</form>
</div>
{% endblock %}
+13
View File
@@ -0,0 +1,13 @@
{% extends "base.html" %}
{% from "auth/_macros.html" import field %}
{% block title %}{{ _('Reset password') }}{% endblock %}
{% block content %}
<div class="card narrow">
<h2>{{ _('Reset password') }}</h2>
<form method="post" novalidate>
{{ form.hidden_tag() }}
{{ field(form.email) }}
{{ form.submit(class="btn") }}
</form>
</div>
{% endblock %}
+52
View File
@@ -0,0 +1,52 @@
<!doctype html>
<html lang="{{ get_locale() }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}Classifieds{% endblock %}</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
{% block head %}{% endblock %}
</head>
<body>
<header class="site-header">
<div class="wrap">
<a class="brand" href="{{ url_for('main.index') }}">Classifieds</a>
<nav class="nav">
<a href="{{ url_for('listings.browse') }}">{{ _('Browse') }}</a>
{% if current_user.is_authenticated %}
<a href="{{ url_for('listings.create') }}">{{ _('Post') }}</a>
<a href="{{ url_for('listings.mine') }}">{{ _('My listings') }}</a>
<span class="hi">{{ current_user.display_name }}</span>
<a href="{{ url_for('auth.logout') }}">{{ _('Sign out') }}</a>
{% else %}
<a href="{{ url_for('auth.login') }}">{{ _('Sign in') }}</a>
<a class="btn" href="{{ url_for('auth.register') }}">{{ _('Register') }}</a>
{% endif %}
<span class="langs">
{% for code in SUPPORTED_LOCALES %}
<a href="{{ url_for('i18n.set_lang', code=code) }}"
class="{{ 'on' if get_locale()|string == code else '' }}">{{ code|upper }}</a>
{% endfor %}
</span>
</nav>
</div>
</header>
<main class="wrap">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="flashes">
{% for category, msg in messages %}
<div class="flash flash-{{ category }}">{{ msg }}</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
{% block content %}{% endblock %}
</main>
<footer class="site-footer">
<div class="wrap">© Classifieds — Phase 1 scaffold</div>
</footer>
</body>
</html>
+3
View File
@@ -0,0 +1,3 @@
{% extends "base.html" %}
{% block title %}403{% endblock %}
{% block content %}<div class="card narrow"><h2>403</h2><p>{{ _('Forbidden — you do not have access.') }}</p><p><a href="{{ url_for('main.index') }}">{{ _('Back home') }}</a></p></div>{% endblock %}
+3
View File
@@ -0,0 +1,3 @@
{% extends "base.html" %}
{% block title %}404{% endblock %}
{% block content %}<div class="card narrow"><h2>404</h2><p>{{ _('Not found.') }}</p><p><a href="{{ url_for('main.index') }}">{{ _('Back home') }}</a></p></div>{% endblock %}
+3
View File
@@ -0,0 +1,3 @@
{% extends "base.html" %}
{% block title %}500{% endblock %}
{% block content %}<div class="card narrow"><h2>500</h2><p>{{ _('Something went wrong.') }}</p><p><a href="{{ url_for('main.index') }}">{{ _('Back home') }}</a></p></div>{% endblock %}
+12
View File
@@ -0,0 +1,12 @@
{% extends "base.html" %}
{% block title %}{{ _('Classifieds — Home') }}{% endblock %}
{% block content %}
<section class="hero">
<h1>{{ _('Find what you need. Post what you offer.') }}</h1>
<p>{{ _('Buy, sell, request, hire, and connect across your community.') }}</p>
<a class="btn btn-lg" href="{{ url_for('listings.browse') }}">{{ _('Browse listings') }}</a>
{% if not current_user.is_authenticated %}
<a class="btn btn-lg ghost" href="{{ url_for('auth.register') }}">{{ _('Get started') }}</a>
{% endif %}
</section>
{% endblock %}
+68
View File
@@ -0,0 +1,68 @@
{% extends "base.html" %}
{% block title %}{{ _('Browse listings') }}{% endblock %}
{% block content %}
<div class="browse">
<aside class="filters card">
<h3>{{ _('Filter') }}</h3>
<form method="get" action="{{ url_for('listings.browse') }}">
<div class="field">
<label>{{ _('Keyword') }}</label>
<input class="input" name="q" value="{{ filters.get('q','') }}">
</div>
<div class="field">
<label>{{ _('Category') }}</label>
<select class="input" name="category">
<option value="">{{ _('All') }}</option>
{% for c in categories %}
<option value="{{ c.id }}" {{ 'selected' if filters.get('category')|string == c.id|string }}>{{ c.name }}</option>
{% endfor %}
</select>
</div>
<div class="field"><label>{{ _('State') }}</label>
<input class="input" name="state" maxlength="2" value="{{ filters.get('state','') }}"></div>
<div class="row2">
<div class="field"><label>{{ _('Min $') }}</label>
<input class="input" name="min_price" type="number" value="{{ filters.get('min_price','') }}"></div>
<div class="field"><label>{{ _('Max $') }}</label>
<input class="input" name="max_price" type="number" value="{{ filters.get('max_price','') }}"></div>
</div>
<div class="row2">
<div class="field"><label>{{ _('Near ZIP') }}</label>
<input class="input" name="zip" value="{{ filters.get('zip','') }}"></div>
<div class="field"><label>{{ _('Radius (mi)') }}</label>
<input class="input" name="radius" type="number" value="{{ filters.get('radius','') }}"></div>
</div>
<button class="btn" type="submit">{{ _('Apply') }}</button>
</form>
</aside>
<section class="results">
{% if near %}<p class="muted">{{ _('%(n)s results within %(r)s mi of %(z)s', n=near.total, r=near.radius, z=near.zip) }}</p>{% endif %}
{% if not results %}<p class="muted">{{ _('No listings found.') }}</p>{% endif %}
<div class="grid">
{% for l, dist in results %}
<a class="tile card" href="{{ url_for('listings.detail', listing_id=l.id) }}">
{% if l.cover %}<img class="thumb" src="{{ url_for('listings.media', rel=l.cover.thumb_path) }}" alt="">
{% else %}<div class="thumb noimg">{{ _('No photo') }}</div>{% endif %}
<div class="tile-body">
<div class="tile-title">{{ l.title }}</div>
<div class="tile-meta">
{% if l.price_display %}<span class="price">{{ l.price_display }}</span>{% endif %}
{% if l.is_featured %}<span class="badge">{{ _('Featured') }}</span>{% endif %}
</div>
<div class="muted small">
{% if l.city %}{{ l.city }}, {{ l.state }}{% endif %}
{% if dist is not none %} · {{ dist }} mi{% endif %}
</div>
</div>
</a>
{% endfor %}
</div>
<div class="pager">
{% if page > 1 %}<a href="{{ url_for('listings.browse', **merge_query(page=page-1)) }}">&larr; {{ _('Prev') }}</a>{% endif %}
{% if has_next %}<a href="{{ url_for('listings.browse', **merge_query(page=page+1)) }}">{{ _('Next') }} &rarr;</a>{% endif %}
</div>
</section>
</div>
{% endblock %}
+59
View File
@@ -0,0 +1,59 @@
{% extends "base.html" %}
{% block title %}{{ listing.title }}{% endblock %}
{% block content %}
<article class="detail">
<div class="detail-main card">
<h1>{{ listing.title }}</h1>
<div class="detail-meta">
{% if listing.price_display %}<span class="price big">{{ listing.price_display }}</span>{% endif %}
<span class="badge cat">{{ listing.category.name }}</span>
{% if not listing.is_live %}<span class="badge warn">{{ listing.status.value }}</span>{% endif %}
</div>
<p class="muted small">
{% if listing.city %}{{ listing.city }}, {{ listing.state }} {{ listing.zip }}{% endif %}
· {{ _('%(n)s views', n=listing.view_count) }}
</p>
{% if listing.images %}
<div class="gallery">
{% for img in listing.images %}
<img src="{{ url_for('listings.media', rel=img.path) }}" alt="">
{% endfor %}
</div>
{% endif %}
<div class="body">{{ listing.body | replace('\n','<br>') | safe }}</div>
{% if listing.attributes %}
<table class="attrs">
{% for f in listing.category.fields() %}
{% if listing.attributes.get(f.name) is not none %}
<tr><th>{{ f.label }}</th><td>{{ listing.attributes.get(f.name) }}</td></tr>
{% endif %}
{% endfor %}
</table>
{% endif %}
</div>
<aside class="detail-side card">
<div class="seller">
<strong>{{ listing.user.display_name }}</strong>
{% if listing.user.verified %}<span class="badge ok">{{ _('Verified') }}</span>{% endif %}
</div>
{% if is_owner %}
<a class="btn" href="{{ url_for('listings.edit', listing_id=listing.id) }}">{{ _('Edit') }}</a>
{% if listing.status.value == 'active' %}
<form method="post" action="{{ url_for('listings.mark_sold', listing_id=listing.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><button class="btn ghost" type="submit">{{ _('Mark sold') }}</button>
</form>
{% endif %}
<form method="post" action="{{ url_for('listings.delete', listing_id=listing.id) }}"
onsubmit="return confirm('{{ _('Delete this listing?') }}')">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><button class="btn danger" type="submit">{{ _('Delete') }}</button>
</form>
{% else %}
<p class="muted small">{{ _('Messaging arrives in Phase 3.') }}</p>
{% endif %}
</aside>
</article>
{% endblock %}
+75
View File
@@ -0,0 +1,75 @@
{% extends "base.html" %}
{% from "auth/_macros.html" import field %}
{% block title %}{{ _('Edit listing') if listing else _('New listing') }}{% endblock %}
{% block content %}
<div class="card form-wide">
<h2>{{ _('Edit listing') if listing else _('New listing') }}</h2>
<form method="post" enctype="multipart/form-data" novalidate>
{{ form.hidden_tag() }}
{{ field(form.category_id) }}
{{ field(form.title) }}
{{ field(form.body) }}
<div class="row2">{{ field(form.lang) }}{{ field(form.price) }}</div>
{{ field(form.zip) }}
{# category-specific fields: one group per category, toggled by JS #}
<div id="attr-groups">
{% for c in categories %}
<div class="attr-group" data-cat="{{ c.id }}" style="display:none">
{% for f in c.fields() %}
<div class="field">
<label>{{ f.label }}{% if f.required %} *{% endif %}</label>
{% set val = (listing.attributes.get(f.name) if listing and listing.attributes else '') %}
{% if f.type == 'select' %}
<select class="input" name="attr_{{ f.name }}">
<option value=""></option>
{% for opt in f.options %}<option value="{{ opt }}" {{ 'selected' if val==opt }}>{{ opt }}</option>{% endfor %}
</select>
{% elif f.type == 'number' %}
<input class="input" type="number" name="attr_{{ f.name }}" value="{{ val }}">
{% elif f.type == 'bool' %}
<input type="checkbox" name="attr_{{ f.name }}" value="true" {{ 'checked' if val }}>
{% else %}
<input class="input" name="attr_{{ f.name }}" value="{{ val }}">
{% endif %}
</div>
{% endfor %}
</div>
{% endfor %}
</div>
{{ field(form.images) }}
{{ form.submit(class="btn") }}
</form>
{% if listing and listing.images %}
<div class="existing-images">
<h4>{{ _('Current photos') }}</h4>
<div class="thumbs">
{% for img in listing.images %}
<div class="thumb-wrap">
<img src="{{ url_for('listings.media', rel=img.thumb_path) }}" alt="">
<form method="post" action="{{ url_for('listings.delete_image', listing_id=listing.id, image_id=img.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="btn danger tiny" type="submit">×</button>
</form>
</div>
{% endfor %}
</div>
</div>
{% endif %}
</div>
<script>
(function(){
var sel = document.querySelector('[name=category_id]');
var groups = document.querySelectorAll('.attr-group');
function sync(){
groups.forEach(function(g){
g.style.display = (g.dataset.cat === sel.value) ? 'block' : 'none';
});
}
if (sel){ sel.addEventListener('change', sync); sync(); }
})();
</script>
{% endblock %}
+24
View File
@@ -0,0 +1,24 @@
{% extends "base.html" %}
{% block title %}{{ _('My listings') }}{% endblock %}
{% block content %}
<div class="card">
<div class="mine-head">
<h2>{{ _('My listings') }}</h2>
<span class="muted">{{ _('Active: %(a)s', a=active) }}{% if cap is not none %} / {{ cap }}{% else %} / ∞{% endif %}</span>
<a class="btn" href="{{ url_for('listings.create') }}">{{ _('Post new') }}</a>
</div>
{% if not items %}<p class="muted">{{ _('No listings yet.') }}</p>{% endif %}
<table class="list">
{% for l in items %}
<tr>
<td><a href="{{ url_for('listings.detail', listing_id=l.id) }}">{{ l.title }}</a></td>
<td>{{ l.category.name }}</td>
<td>{{ l.price_display or '—' }}</td>
<td><span class="badge {{ 'ok' if l.is_live else 'warn' }}">{{ l.status.value }}</span></td>
<td>{{ l.view_count }} {{ _('views') }}</td>
<td><a href="{{ url_for('listings.edit', listing_id=l.id) }}">{{ _('Edit') }}</a></td>
</tr>
{% endfor %}
</table>
</div>
{% endblock %}
+41
View File
@@ -0,0 +1,41 @@
"""RBAC decorators. Never trust the client; gate on server side."""
from functools import wraps
from flask import abort
from flask_login import current_user
from app.models.enums import Role
def role_required(*roles):
"""Require the current user to hold one of the given roles."""
def decorator(fn):
@wraps(fn)
def wrapper(*args, **kwargs):
if not current_user.is_authenticated:
abort(401)
if current_user.role not in roles:
abort(403)
return fn(*args, **kwargs)
return wrapper
return decorator
def admin_required(fn):
@wraps(fn)
def wrapper(*args, **kwargs):
if not current_user.is_authenticated:
abort(401)
if not current_user.is_admin:
abort(403)
return fn(*args, **kwargs)
return wrapper
def moderator_required(fn):
@wraps(fn)
def wrapper(*args, **kwargs):
if not current_user.is_authenticated:
abort(401)
if not current_user.is_moderator:
abort(403)
return fn(*args, **kwargs)
return wrapper
+42
View File
@@ -0,0 +1,42 @@
"""Security primitives: Argon2 password hashing + signed tokens for email/reset."""
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError, InvalidHashError
from itsdangerous import URLSafeTimedSerializer, BadSignature, SignatureExpired
from flask import current_app
_ph = PasswordHasher()
def hash_password(raw: str) -> str:
return _ph.hash(raw)
def verify_password(stored_hash: str, raw: str) -> bool:
try:
return _ph.verify(stored_hash, raw)
except (VerifyMismatchError, InvalidHashError, Exception):
return False
def needs_rehash(stored_hash: str) -> bool:
try:
return _ph.check_needs_rehash(stored_hash)
except Exception:
return False
# --- Signed tokens (email verify, password reset) ---
def _serializer(salt: str) -> URLSafeTimedSerializer:
return URLSafeTimedSerializer(current_app.config["SECRET_KEY"], salt=salt)
def generate_token(data, salt: str) -> str:
return _serializer(salt).dumps(data)
def read_token(token: str, salt: str, max_age: int):
"""Return payload or None if invalid/expired."""
try:
return _serializer(salt).loads(token, max_age=max_age)
except (BadSignature, SignatureExpired):
return None
+26
View File
@@ -0,0 +1,26 @@
"""Text normalization for accent-insensitive search.
Strips diacritics so "phở" -> "pho" and "ñandú" -> "nandu".
Used now for display/utility; drives `title_norm` shadow column in Phase 2 listings.
"""
import unicodedata
# Vietnamese đ/Đ do not decompose via NFKD, map explicitly.
_EXPLICIT = {
"đ": "d", "Đ": "d",
"ð": "d", "Ð": "d",
}
def normalize(text: str) -> str:
if not text:
return ""
out = []
for ch in text:
if ch in _EXPLICIT:
out.append(_EXPLICIT[ch])
continue
decomposed = unicodedata.normalize("NFKD", ch)
stripped = "".join(c for c in decomposed if not unicodedata.combining(c))
out.append(stripped)
return "".join(out).lower().strip()