commit c2064b84b4e5566b1aeef6df13787c1222c7ddfe Author: NguyenND Date: Mon Jun 15 11:23:05 2026 -0400 06/15 Phase 1 + 2 codes diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..f593a2b --- /dev/null +++ b/.env.example @@ -0,0 +1,43 @@ +# --- Core --- +FLASK_CONFIG=dev # dev | prod +SECRET_KEY=change-me-to-a-long-random-string +SERVER_NAME= # e.g. classifieds.example.com (prod) + +# --- Database (MySQL 8.0) --- +DB_USER=classifieds +DB_PASSWORD=change-me +DB_HOST=127.0.0.1 +DB_PORT=3306 +DB_NAME=classifieds +# Dev override: leave DATABASE_URL empty to build from parts above. +# For a quick local smoke test without MySQL you may set: +# DATABASE_URL=sqlite:///dev.db +DATABASE_URL= + +# --- Redis (sessions, rate-limit, cache, queue) --- +REDIS_URL=redis://127.0.0.1:6379/0 + +# --- i18n --- +DEFAULT_LOCALE=en +SUPPORTED_LOCALES=en,vi,es + +# --- Email (Brevo smart relay / SMTP) --- +MAIL_SERVER= +MAIL_PORT=587 +MAIL_USE_TLS=true +MAIL_USERNAME= +MAIL_PASSWORD= +MAIL_FROM=no-reply@example.com +MAIL_FROM_NAME=Classifieds + +# --- Media (uploaded images); default instance/media --- +MEDIA_ROOT= + +# --- Cloudflare Turnstile (CAPTCHA) --- +# Leave both blank in dev to bypass CAPTCHA verification. +TURNSTILE_SITE_KEY= +TURNSTILE_SECRET_KEY= + +# --- Security tokens --- +TOKEN_VERIFY_MAX_AGE=86400 # 24h email-verify link life (seconds) +TOKEN_RESET_MAX_AGE=3600 # 1h password-reset link life (seconds) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..27b9281 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +venv/ +__pycache__/ +*.pyc +.env +dev.db +*.mo +instance/ +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..e2334a3 --- /dev/null +++ b/README.md @@ -0,0 +1,161 @@ +# Classifieds — Phase 1 (Foundation) + +App factory, config, extensions, `users`/`plans` schema, full auth +(register / login / logout / email-verify / password-reset), RBAC, +i18n scaffold (EN/VI/ES) with accent-insensitive normalizer, Turnstile hook. + +## Stack +Flask · MySQL 8.0 · Redis · Gunicorn · systemd · Nginx · Ubuntu 22.04 + +--- + +## Local setup + +```bash +python3 -m venv venv && source venv/bin/activate +pip install -r requirements.txt +cp .env.example .env # edit secrets +``` + +### Quick smoke test (no MySQL/Redis needed) +A committed test exercises the whole Phase-1 surface against a temp SQLite file +with the limiter in-memory: +```bash +python -m tests.test_smoke # or: pytest -q +``` +It checks: plan seeding, accent normalizer (phở→pho, ñandú→nandu), register → +email-verify (+5 trust) → login/logout, password reset round-trip, duplicate-email +and bad-password rejection, home/healthz/404. + +> Note: it runs over `https://localhost` with a `Referer` header on purpose — +> production sets `PREFERRED_URL_SCHEME=https`, so Flask-WTF enforces the secure +> referrer CSRF check. Real browsers send `Referer`; the test mirrors that. + +### Manual run with the dev server +> Flask-SQLAlchemy resolves a *relative* `sqlite:///dev.db` against the **instance/** +> folder, not the CWD. To reset, delete `instance/dev.db` (or use an absolute +> `DATABASE_URL=sqlite:////abs/path/dev.db`). + +Set in `.env`: `DATABASE_URL=sqlite:////tmp/classifieds_dev.db` (absolute), then: +```bash +export FLASK_APP=wsgi:app +flask db init +flask db migrate -m "phase1 users+plans+trust" +flask db upgrade +python seed.py --admin admin@example.com 'StrongPass123' +flask run +``` +Open http://127.0.0.1:5000 + +### Real MySQL 8.0 +```sql +CREATE DATABASE classifieds CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +CREATE USER 'classifieds'@'127.0.0.1' IDENTIFIED BY 'change-me'; +GRANT ALL PRIVILEGES ON classifieds.* TO 'classifieds'@'127.0.0.1'; +FLUSH PRIVILEGES; +``` +Leave `DATABASE_URL` blank in `.env` (URI is built from `DB_*`). Then run the +same `flask db ...` + `seed.py` sequence. + +--- + +## i18n (translations) + +```bash +pybabel extract -F babel.cfg -o messages.pot . +pybabel init -i messages.pot -d app/translations -l vi +pybabel init -i messages.pot -d app/translations -l es +# ...translate the .po files... +pybabel compile -d app/translations +``` +Re-extract + `pybabel update` after adding new `_()` strings. + +--- + +## What works now +- Register → email verify link (dev: printed to console) → verify grants +5 trust. +- Login / logout, remember-me, rate-limited, suspended-account block. +- Password reset (enumeration-safe), signed expiring tokens. +- Language switch persists to session + profile. +- Argon2 hashing, CSRF on all forms, RBAC decorators ready. +- Free plan auto-assigned on registration. + +## Notes / Phase-1 limits +- Turnstile bypassed when keys blank (dev). Set keys in `.env` for prod. +- Email prints to console until SMTP (Brevo) configured. +- `requirements.txt` uses PyMySQL. For the SQLite smoke test no driver needed. + +--- + +## Production deploy (outline) +1. Code → `/opt/classifieds`, venv, `pip install -r requirements.txt`. +2. `.env` with prod secrets, `FLASK_CONFIG=prod`, real `SECRET_KEY`. +3. MySQL DB + user (above). `flask db upgrade && python seed.py`. +4. `deploy/classifieds.service` → `/etc/systemd/system/`, `systemctl enable --now classifieds`. +5. `deploy/nginx.conf.sample` → adapt, enable site, add TLS (certbot), reload Nginx. +6. Redis running for sessions + rate-limit + (later) queue. + +--- + +## Phase 2 — Listings core (built) + +New models: `Category` (self-referential subcategories + `field_schema` JSON), +`Listing` (+ `title_norm`, denormalized hot columns, lat/lng, status, expiry), +`ListingImage`, `ZipGeo`, `Metro`. + +Features: +- Listing CRUD with owner/moderator authorization; mark-sold; my-listings. +- Category-specific fields driven by `field_schema` (text/number/select/bool), + validated server-side; dynamic form toggles fields by selected category. +- Tier enforcement: active-listing cap, images-per-listing cap, listing life + (`expires_at`) — all read from `plans.config`. +- Image pipeline (Pillow): validate, re-encode to JPEG (strips EXIF/metadata), + thumbnail, randomized filenames, per-listing media folder. +- Browse + filters (category, state, price range, condition/job_type hot fields). +- Accent-insensitive keyword search via `title_norm` (phở matches "pho"). +- Radius search: ZIP geocode → bounding-box SQL prefilter → exact haversine refine. +- Expiry sweep: `flask expire-listings` (wire the systemd timer below). + +### Expiry sweep (systemd timer) +```bash +cp deploy/classifieds-expire.{service,timer} /etc/systemd/system/ +systemctl enable --now classifieds-expire.timer +``` + +### Seed +`python seed.py` now also seeds the 6 categories + subcategories and a small +sample of `zip_geo` rows. **Replace the ZIP sample with the full dataset** +(SimpleMaps US ZIP or Census ZCTA, ~42k rows) before production. + +--- + +## MySQL upgrades (optional, post-Phase-2) +The Phase-2 code is portable (runs on SQLite for tests, MySQL in prod). Three +spots can be upgraded to native MySQL features when you want them: + +1. **Spatial radius** — replace lat/lng bounding-box + haversine with a generated + `POINT` column + `SPATIAL INDEX` and `ST_Distance_Sphere`: + ```sql + ALTER TABLE listings ADD COLUMN geo POINT + GENERATED ALWAYS AS (ST_SRID(POINT(lng, lat), 4326)) STORED, + ADD SPATIAL INDEX spx_listings_geo (geo); + ``` +2. **Generated hot columns** — instead of app-maintained `attr_*` columns, derive + them from JSON: + ```sql + ALTER TABLE listings ADD COLUMN attr_condition VARCHAR(40) + GENERATED ALWAYS AS (JSON_UNQUOTE(JSON_EXTRACT(attributes,'$.condition'))) STORED, + ADD INDEX ix_attr_condition (attr_condition); + ``` +3. **Full-text search** — add a FULLTEXT index and switch the keyword filter to + `MATCH ... AGAINST` for relevance + speed: + ```sql + ALTER TABLE listings ADD FULLTEXT INDEX ftx_listings (title, body); + ``` + (Keep `title_norm` for accent-insensitive matching; combine as needed.) + +--- + +## Next: Phase 3 — Messaging + favorites +conversations, user-to-user messages, saved listings, contact masking gated by +trust tier. diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..3cdfaea --- /dev/null +++ b/app/__init__.py @@ -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).") diff --git a/app/blueprints/__init__.py b/app/blueprints/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/blueprints/auth/__init__.py b/app/blueprints/auth/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/blueprints/auth/forms.py b/app/blueprints/auth/forms.py new file mode 100644 index 0000000..025a199 --- /dev/null +++ b/app/blueprints/auth/forms.py @@ -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")) diff --git a/app/blueprints/auth/routes.py b/app/blueprints/auth/routes.py new file mode 100644 index 0000000..399b73f --- /dev/null +++ b/app/blueprints/auth/routes.py @@ -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/") +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/", 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) diff --git a/app/blueprints/i18n/__init__.py b/app/blueprints/i18n/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/blueprints/i18n/routes.py b/app/blueprints/i18n/routes.py new file mode 100644 index 0000000..7a05a5a --- /dev/null +++ b/app/blueprints/i18n/routes.py @@ -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/") +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) diff --git a/app/blueprints/listings/__init__.py b/app/blueprints/listings/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/blueprints/listings/forms.py b/app/blueprints/listings/forms.py new file mode 100644 index 0000000..0ad15b9 --- /dev/null +++ b/app/blueprints/listings/forms.py @@ -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")) diff --git a/app/blueprints/listings/routes.py b/app/blueprints/listings/routes.py new file mode 100644 index 0000000..64122b7 --- /dev/null +++ b/app/blueprints/listings/routes.py @@ -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_ 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/") +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//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//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//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//images//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/") +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() diff --git a/app/blueprints/main/__init__.py b/app/blueprints/main/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/blueprints/main/routes.py b/app/blueprints/main/routes.py new file mode 100644 index 0000000..3b8d18d --- /dev/null +++ b/app/blueprints/main/routes.py @@ -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") diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..95c71fb --- /dev/null +++ b/app/config.py @@ -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 diff --git a/app/extensions.py b/app/extensions.py new file mode 100644 index 0000000..8e2bee4 --- /dev/null +++ b/app/extensions.py @@ -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" diff --git a/app/models/__init__.py b/app/models/__init__.py new file mode 100644 index 0000000..1aa7ad6 --- /dev/null +++ b/app/models/__init__.py @@ -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"] diff --git a/app/models/category.py b/app/models/category.py new file mode 100644 index 0000000..48cf49f --- /dev/null +++ b/app/models/category.py @@ -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"" diff --git a/app/models/enums.py b/app/models/enums.py new file mode 100644 index 0000000..48f4b0f --- /dev/null +++ b/app/models/enums.py @@ -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" diff --git a/app/models/geo.py b/app/models/geo.py new file mode 100644 index 0000000..1925e18 --- /dev/null +++ b/app/models/geo.py @@ -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"" + + +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"" diff --git a/app/models/listing.py b/app/models/listing.py new file mode 100644 index 0000000..45e655d --- /dev/null +++ b/app/models/listing.py @@ -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"" + + +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"" diff --git a/app/models/plan.py b/app/models/plan.py new file mode 100644 index 0000000..b2096bd --- /dev/null +++ b/app/models/plan.py @@ -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"" diff --git a/app/models/trust.py b/app/models/trust.py new file mode 100644 index 0000000..69e7953 --- /dev/null +++ b/app/models/trust.py @@ -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"" diff --git a/app/models/user.py b/app/models/user.py new file mode 100644 index 0000000..e89cfde --- /dev/null +++ b/app/models/user.py @@ -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"" diff --git a/app/services/__init__.py b/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/services/email.py b/app/services/email.py new file mode 100644 index 0000000..068ad66 --- /dev/null +++ b/app/services/email.py @@ -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 diff --git a/app/services/field_schema.py b/app/services/field_schema.py new file mode 100644 index 0000000..072f329 --- /dev/null +++ b/app/services/field_schema.py @@ -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"), + } diff --git a/app/services/geo.py b/app/services/geo.py new file mode 100644 index 0000000..138fe03 --- /dev/null +++ b/app/services/geo.py @@ -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) diff --git a/app/services/images.py b/app/services/images.py new file mode 100644 index 0000000..347cd7d --- /dev/null +++ b/app/services/images.py @@ -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 diff --git a/app/services/listings.py b/app/services/listings.py new file mode 100644 index 0000000..6d512ba --- /dev/null +++ b/app/services/listings.py @@ -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 diff --git a/app/services/trust.py b/app/services/trust.py new file mode 100644 index 0000000..17e7290 --- /dev/null +++ b/app/services/trust.py @@ -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 diff --git a/app/services/turnstile.py b/app/services/turnstile.py new file mode 100644 index 0000000..02081eb --- /dev/null +++ b/app/services/turnstile.py @@ -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 diff --git a/app/static/style.css b/app/static/style.css new file mode 100644 index 0000000..b273397 --- /dev/null +++ b/app/static/style.css @@ -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}} diff --git a/app/templates/auth/_macros.html b/app/templates/auth/_macros.html new file mode 100644 index 0000000..b97ccbd --- /dev/null +++ b/app/templates/auth/_macros.html @@ -0,0 +1,18 @@ +{% macro field(f) %} +
+ {{ f.label }} + {{ f(class="input") }} + {% if f.errors %} +
    + {% for e in f.errors %}
  • {{ e }}
  • {% endfor %} +
+ {% endif %} +
+{% endmacro %} + +{% macro turnstile_widget(enabled) %} + {% if enabled and TURNSTILE_SITE_KEY %} +
+ + {% endif %} +{% endmacro %} diff --git a/app/templates/auth/login.html b/app/templates/auth/login.html new file mode 100644 index 0000000..7847d42 --- /dev/null +++ b/app/templates/auth/login.html @@ -0,0 +1,19 @@ +{% extends "base.html" %} +{% from "auth/_macros.html" import field %} +{% block title %}{{ _('Sign in') }}{% endblock %} +{% block content %} +
+

{{ _('Sign in') }}

+
+ {{ form.hidden_tag() }} + {{ field(form.email) }} + {{ field(form.password) }} + + {{ form.submit(class="btn") }} +
+

+ {{ _('Forgot password?') }} · + {{ _('Create account') }} +

+
+{% endblock %} diff --git a/app/templates/auth/register.html b/app/templates/auth/register.html new file mode 100644 index 0000000..e3da202 --- /dev/null +++ b/app/templates/auth/register.html @@ -0,0 +1,18 @@ +{% extends "base.html" %} +{% from "auth/_macros.html" import field, turnstile_widget %} +{% block title %}{{ _('Register') }}{% endblock %} +{% block content %} +
+

{{ _('Create account') }}

+
+ {{ form.hidden_tag() }} + {{ field(form.display_name) }} + {{ field(form.email) }} + {{ field(form.password) }} + {{ field(form.confirm) }} + {{ turnstile_widget(turnstile) }} + {{ form.submit(class="btn") }} +
+

{{ _('Already have an account? Sign in') }}

+
+{% endblock %} diff --git a/app/templates/auth/reset.html b/app/templates/auth/reset.html new file mode 100644 index 0000000..84263a6 --- /dev/null +++ b/app/templates/auth/reset.html @@ -0,0 +1,14 @@ +{% extends "base.html" %} +{% from "auth/_macros.html" import field %} +{% block title %}{{ _('Set new password') }}{% endblock %} +{% block content %} +
+

{{ _('Set new password') }}

+
+ {{ form.hidden_tag() }} + {{ field(form.password) }} + {{ field(form.confirm) }} + {{ form.submit(class="btn") }} +
+
+{% endblock %} diff --git a/app/templates/auth/reset_request.html b/app/templates/auth/reset_request.html new file mode 100644 index 0000000..1aeedae --- /dev/null +++ b/app/templates/auth/reset_request.html @@ -0,0 +1,13 @@ +{% extends "base.html" %} +{% from "auth/_macros.html" import field %} +{% block title %}{{ _('Reset password') }}{% endblock %} +{% block content %} +
+

{{ _('Reset password') }}

+
+ {{ form.hidden_tag() }} + {{ field(form.email) }} + {{ form.submit(class="btn") }} +
+
+{% endblock %} diff --git a/app/templates/base.html b/app/templates/base.html new file mode 100644 index 0000000..ff70b01 --- /dev/null +++ b/app/templates/base.html @@ -0,0 +1,52 @@ + + + + + + {% block title %}Classifieds{% endblock %} + + {% block head %}{% endblock %} + + + + +
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} +
+ {% for category, msg in messages %} +
{{ msg }}
+ {% endfor %} +
+ {% endif %} + {% endwith %} + {% block content %}{% endblock %} +
+ +
+
© Classifieds — Phase 1 scaffold
+
+ + diff --git a/app/templates/errors/403.html b/app/templates/errors/403.html new file mode 100644 index 0000000..5c415a9 --- /dev/null +++ b/app/templates/errors/403.html @@ -0,0 +1,3 @@ +{% extends "base.html" %} +{% block title %}403{% endblock %} +{% block content %}

403

{{ _('Forbidden — you do not have access.') }}

{{ _('Back home') }}

{% endblock %} diff --git a/app/templates/errors/404.html b/app/templates/errors/404.html new file mode 100644 index 0000000..ec7beac --- /dev/null +++ b/app/templates/errors/404.html @@ -0,0 +1,3 @@ +{% extends "base.html" %} +{% block title %}404{% endblock %} +{% block content %}

404

{{ _('Not found.') }}

{{ _('Back home') }}

{% endblock %} diff --git a/app/templates/errors/500.html b/app/templates/errors/500.html new file mode 100644 index 0000000..a1e65dc --- /dev/null +++ b/app/templates/errors/500.html @@ -0,0 +1,3 @@ +{% extends "base.html" %} +{% block title %}500{% endblock %} +{% block content %}

500

{{ _('Something went wrong.') }}

{{ _('Back home') }}

{% endblock %} diff --git a/app/templates/index.html b/app/templates/index.html new file mode 100644 index 0000000..168b4db --- /dev/null +++ b/app/templates/index.html @@ -0,0 +1,12 @@ +{% extends "base.html" %} +{% block title %}{{ _('Classifieds — Home') }}{% endblock %} +{% block content %} +
+

{{ _('Find what you need. Post what you offer.') }}

+

{{ _('Buy, sell, request, hire, and connect across your community.') }}

+ {{ _('Browse listings') }} + {% if not current_user.is_authenticated %} + {{ _('Get started') }} + {% endif %} +
+{% endblock %} diff --git a/app/templates/listings/browse.html b/app/templates/listings/browse.html new file mode 100644 index 0000000..6ad6b39 --- /dev/null +++ b/app/templates/listings/browse.html @@ -0,0 +1,68 @@ +{% extends "base.html" %} +{% block title %}{{ _('Browse listings') }}{% endblock %} +{% block content %} +
+ + +
+ {% if near %}

{{ _('%(n)s results within %(r)s mi of %(z)s', n=near.total, r=near.radius, z=near.zip) }}

{% endif %} + {% if not results %}

{{ _('No listings found.') }}

{% endif %} + + +
+ {% if page > 1 %}← {{ _('Prev') }}{% endif %} + {% if has_next %}{{ _('Next') }} →{% endif %} +
+
+
+{% endblock %} diff --git a/app/templates/listings/detail.html b/app/templates/listings/detail.html new file mode 100644 index 0000000..44cf836 --- /dev/null +++ b/app/templates/listings/detail.html @@ -0,0 +1,59 @@ +{% extends "base.html" %} +{% block title %}{{ listing.title }}{% endblock %} +{% block content %} +
+
+

{{ listing.title }}

+
+ {% if listing.price_display %}{{ listing.price_display }}{% endif %} + {{ listing.category.name }} + {% if not listing.is_live %}{{ listing.status.value }}{% endif %} +
+

+ {% if listing.city %}{{ listing.city }}, {{ listing.state }} {{ listing.zip }}{% endif %} + · {{ _('%(n)s views', n=listing.view_count) }} +

+ + {% if listing.images %} + + {% endif %} + +
{{ listing.body | replace('\n','
') | safe }}
+ + {% if listing.attributes %} + + {% for f in listing.category.fields() %} + {% if listing.attributes.get(f.name) is not none %} + + {% endif %} + {% endfor %} +
{{ f.label }}{{ listing.attributes.get(f.name) }}
+ {% endif %} +
+ + +
+{% endblock %} diff --git a/app/templates/listings/form.html b/app/templates/listings/form.html new file mode 100644 index 0000000..d1bb6cf --- /dev/null +++ b/app/templates/listings/form.html @@ -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 %} +
+

{{ _('Edit listing') if listing else _('New listing') }}

+
+ {{ form.hidden_tag() }} + {{ field(form.category_id) }} + {{ field(form.title) }} + {{ field(form.body) }} +
{{ field(form.lang) }}{{ field(form.price) }}
+ {{ field(form.zip) }} + + {# category-specific fields: one group per category, toggled by JS #} +
+ {% for c in categories %} + + {% endfor %} +
+ + {{ field(form.images) }} + {{ form.submit(class="btn") }} +
+ + {% if listing and listing.images %} +
+

{{ _('Current photos') }}

+
+ {% for img in listing.images %} +
+ +
+ + +
+
+ {% endfor %} +
+
+ {% endif %} +
+ + +{% endblock %} diff --git a/app/templates/listings/mine.html b/app/templates/listings/mine.html new file mode 100644 index 0000000..0a9b04f --- /dev/null +++ b/app/templates/listings/mine.html @@ -0,0 +1,24 @@ +{% extends "base.html" %} +{% block title %}{{ _('My listings') }}{% endblock %} +{% block content %} +
+
+

{{ _('My listings') }}

+ {{ _('Active: %(a)s', a=active) }}{% if cap is not none %} / {{ cap }}{% else %} / ∞{% endif %} + {{ _('Post new') }} +
+ {% if not items %}

{{ _('No listings yet.') }}

{% endif %} + + {% for l in items %} + + + + + + + + + {% endfor %} +
{{ l.title }}{{ l.category.name }}{{ l.price_display or '—' }}{{ l.status.value }}{{ l.view_count }} {{ _('views') }}{{ _('Edit') }}
+
+{% endblock %} diff --git a/app/utils/__init__.py b/app/utils/__init__.py new file mode 100644 index 0000000..00cd0d6 --- /dev/null +++ b/app/utils/__init__.py @@ -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 diff --git a/app/utils/security.py b/app/utils/security.py new file mode 100644 index 0000000..c81ad25 --- /dev/null +++ b/app/utils/security.py @@ -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 diff --git a/app/utils/text.py b/app/utils/text.py new file mode 100644 index 0000000..f816bb1 --- /dev/null +++ b/app/utils/text.py @@ -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() diff --git a/babel.cfg b/babel.cfg new file mode 100644 index 0000000..b42c3d5 --- /dev/null +++ b/babel.cfg @@ -0,0 +1,2 @@ +[python: app/**.py] +[jinja2: app/templates/**.html] diff --git a/deploy/classifieds-expire.service b/deploy/classifieds-expire.service new file mode 100644 index 0000000..3c53a8f --- /dev/null +++ b/deploy/classifieds-expire.service @@ -0,0 +1,12 @@ +[Unit] +Description=Classifieds: expire past-due listings (oneshot) +After=mysql.service + +[Service] +Type=oneshot +User=classifieds +Group=classifieds +WorkingDirectory=/opt/classifieds +Environment="PATH=/opt/classifieds/venv/bin" +EnvironmentFile=/opt/classifieds/.env +ExecStart=/opt/classifieds/venv/bin/flask expire-listings diff --git a/deploy/classifieds-expire.timer b/deploy/classifieds-expire.timer new file mode 100644 index 0000000..30222e5 --- /dev/null +++ b/deploy/classifieds-expire.timer @@ -0,0 +1,9 @@ +[Unit] +Description=Run classifieds listing-expiry sweep hourly + +[Timer] +OnCalendar=hourly +Persistent=true + +[Install] +WantedBy=timers.target diff --git a/deploy/classifieds.service b/deploy/classifieds.service new file mode 100644 index 0000000..39f7dd8 --- /dev/null +++ b/deploy/classifieds.service @@ -0,0 +1,20 @@ +[Unit] +Description=Classifieds Flask app (Gunicorn) +After=network.target mysql.service redis-server.service +Wants=redis-server.service + +[Service] +Type=notify +User=classifieds +Group=classifieds +WorkingDirectory=/opt/classifieds +Environment="PATH=/opt/classifieds/venv/bin" +EnvironmentFile=/opt/classifieds/.env +RuntimeDirectory=classifieds +ExecStart=/opt/classifieds/venv/bin/gunicorn -c deploy/gunicorn.conf.py wsgi:app +ExecReload=/bin/kill -s HUP $MAINPID +Restart=always +RestartSec=3 + +[Install] +WantedBy=multi-user.target diff --git a/deploy/gunicorn.conf.py b/deploy/gunicorn.conf.py new file mode 100644 index 0000000..a4d839b --- /dev/null +++ b/deploy/gunicorn.conf.py @@ -0,0 +1,12 @@ +"""Gunicorn config. Unix socket behind Nginx.""" +import multiprocessing + +bind = "unix:/run/classifieds/classifieds.sock" +workers = multiprocessing.cpu_count() * 2 + 1 +worker_class = "sync" +timeout = 60 +graceful_timeout = 30 +keepalive = 5 +accesslog = "-" +errorlog = "-" +loglevel = "info" diff --git a/deploy/nginx.conf.sample b/deploy/nginx.conf.sample new file mode 100644 index 0000000..e40ef26 --- /dev/null +++ b/deploy/nginx.conf.sample @@ -0,0 +1,36 @@ +server { + listen 80; + server_name classifieds.example.com; + return 301 https://$host$request_uri; +} + +server { + listen 443 ssl http2; + server_name classifieds.example.com; + + # ssl_certificate /etc/letsencrypt/live/classifieds.example.com/fullchain.pem; + # ssl_certificate_key /etc/letsencrypt/live/classifieds.example.com/privkey.pem; + + client_max_body_size 12m; # raise in Phase 2 for image uploads + + location /static/ { + alias /opt/classifieds/app/static/; + expires 30d; + access_log off; + } + + # Phase 2: user-uploaded media served direct by Nginx + location /media/ { + alias /opt/classifieds/instance/media/; + expires 7d; + access_log off; + } + + location / { + proxy_pass http://unix:/run/classifieds/classifieds.sock; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..e8f7918 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,18 @@ +Flask==3.0.3 +Flask-SQLAlchemy==3.1.1 +Flask-Migrate==4.0.7 +Flask-Login==0.6.3 +Flask-WTF==1.2.1 +Flask-Babel==4.0.0 +Flask-Limiter==3.8.0 +SQLAlchemy==2.0.34 +alembic==1.13.2 +argon2-cffi==23.1.0 +email-validator==2.2.0 +itsdangerous==2.2.0 +python-dotenv==1.0.1 +redis==5.0.8 +requests==2.32.3 +PyMySQL==1.1.1 +gunicorn==23.0.0 +Pillow==10.4.0 diff --git a/seed.py b/seed.py new file mode 100644 index 0000000..4970f4f --- /dev/null +++ b/seed.py @@ -0,0 +1,180 @@ +"""Seed reference data: the four plans, and an optional admin account. + +Usage: + python seed.py # seed plans only + python seed.py --admin EMAIL PASS # also create/upgrade an admin user +""" +import sys +from app import create_app +from app.extensions import db +from app.models.plan import Plan +from app.models.user import User +from app.models.category import Category +from app.models.geo import ZipGeo +from app.models.enums import Role + +PLANS = [ + { + "slug": "free", "name": "Free", "price_monthly_cents": 0, "sort_order": 0, + "config": { + "active_listings": 3, "listing_life_days": 14, "images_per_listing": 3, + "featured_per_month": 0, "auto_bump": None, "analytics": None, + "storefront": False, "verified_badge": False, "ad_free": False, + "scheduled_posting": False, "bulk_csv": False, "priority_support": False, + }, + }, + { + "slug": "basic", "name": "Basic", "price_monthly_cents": 999, "sort_order": 1, + "config": { + "active_listings": 15, "listing_life_days": 30, "images_per_listing": 8, + "featured_per_month": 1, "auto_bump": None, "analytics": "basic", + "storefront": False, "verified_badge": False, "ad_free": True, + "scheduled_posting": False, "bulk_csv": False, "priority_support": False, + }, + }, + { + "slug": "pro", "name": "Pro", "price_monthly_cents": 2499, "sort_order": 2, + "config": { + "active_listings": 50, "listing_life_days": 60, "images_per_listing": 15, + "featured_per_month": 5, "auto_bump": "weekly", "analytics": "full", + "storefront": True, "verified_badge": True, "ad_free": True, + "scheduled_posting": True, "bulk_csv": False, "priority_support": False, + }, + }, + { + "slug": "business", "name": "Business", "price_monthly_cents": 5999, "sort_order": 3, + "config": { + "active_listings": None, "listing_life_days": 90, "images_per_listing": 25, + "featured_per_month": 20, "auto_bump": "daily", "analytics": "full_export", + "storefront": True, "verified_badge": True, "ad_free": True, + "scheduled_posting": True, "bulk_csv": True, "priority_support": True, + }, + }, +] + + +def seed_plans(): + for p in PLANS: + existing = Plan.query.filter_by(slug=p["slug"]).first() + if existing: + existing.name = p["name"] + existing.price_monthly_cents = p["price_monthly_cents"] + existing.config = p["config"] + existing.sort_order = p["sort_order"] + else: + db.session.add(Plan(**p)) + db.session.commit() + print(f"Seeded {len(PLANS)} plans.") + + +CATEGORIES = [ + {"slug": "for-sale", "name": "For Sale", "icon": "tag", "sort_order": 0, + "field_schema": {"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}, + ]}, + "children": ["Furniture", "Electronics", "Cars & Trucks", "Appliances"]}, + {"slug": "wanted", "name": "Wanted", "icon": "search", "sort_order": 1, + "field_schema": {"fields": [ + {"name": "budget", "label": "Budget (USD)", "type": "number", "required": False, "min": 0}, + ]}, + "children": ["Items Wanted", "Housing Wanted"]}, + {"slug": "jobs", "name": "Jobs", "icon": "briefcase", "sort_order": 2, + "field_schema": {"fields": [ + {"name": "job_type", "label": "Job type", "type": "select", "required": True, + "options": ["full_time", "part_time", "contract", "temp"], "hot": True}, + {"name": "salary_min", "label": "Salary min (USD/yr)", "type": "number", + "required": False, "min": 0, "hot": True}, + {"name": "salary_max", "label": "Salary max (USD/yr)", "type": "number", + "required": False, "min": 0, "hot": True}, + {"name": "remote", "label": "Remote", "type": "bool", "required": False}, + ]}, + "children": ["Restaurant", "Salon & Spa", "Construction", "Office"]}, + {"slug": "services", "name": "Services", "icon": "tools", "sort_order": 3, + "field_schema": {"fields": [ + {"name": "rate", "label": "Rate (USD/hr)", "type": "number", "required": False, "min": 0}, + ]}, + "children": ["Home Services", "Beauty", "Tutoring", "Auto Repair"]}, + {"slug": "supplies", "name": "Supplies", "icon": "box", "sort_order": 4, + "field_schema": {"fields": [ + {"name": "min_order", "label": "Min order qty", "type": "number", "required": False, "min": 1}, + {"name": "condition", "label": "Condition", "type": "select", "required": False, + "options": ["new", "used"], "hot": True}, + ]}, + "children": ["Wholesale", "Restaurant Supply", "Salon Supply"]}, + {"slug": "community", "name": "Community", "icon": "users", "sort_order": 5, + "field_schema": {"fields": [ + {"name": "event_date", "label": "Event date", "type": "text", "required": False, "max": 40}, + ]}, + "children": ["Events", "Announcements", "Free Stuff", "Classes"]}, +] + +# Sample ZIPs for Vietnamese/Hispanic hubs (replace with full SimpleMaps/Census dataset). +ZIP_SAMPLE = [ + ("92683", "Westminster", "CA", 33.7513, -117.9939, "Orange County"), + ("92840", "Garden Grove", "CA", 33.7886, -117.9390, "Orange County"), + ("95111", "San Jose", "CA", 37.2956, -121.8200, "San Jose"), + ("95122", "San Jose", "CA", 37.3300, -121.8350, "San Jose"), + ("77036", "Houston", "TX", 29.7045, -95.5380, "Houston"), + ("77072", "Houston", "TX", 29.7060, -95.5930, "Houston"), + ("78228", "San Antonio", "TX", 29.4630, -98.5790, "San Antonio"), + ("33125", "Miami", "FL", 25.7840, -80.2370, "Miami"), + ("90250", "Hawthorne", "CA", 33.9130, -118.3490, "Los Angeles"), + ("22041", "Falls Church", "VA", 38.8480, -77.1370, "DC Metro"), +] + + +def seed_categories(): + for c in CATEGORIES: + parent = Category.query.filter_by(slug=c["slug"]).first() + if parent is None: + parent = Category(slug=c["slug"], name=c["name"], icon=c.get("icon"), + sort_order=c["sort_order"], field_schema=c["field_schema"]) + db.session.add(parent) + db.session.flush() + else: + parent.field_schema = c["field_schema"] + parent.sort_order = c["sort_order"] + for i, child_name in enumerate(c.get("children", [])): + cslug = f"{c['slug']}-{child_name.lower().replace(' & ', '-').replace(' ', '-')}" + if Category.query.filter_by(slug=cslug).first() is None: + db.session.add(Category(slug=cslug, name=child_name, + parent_id=parent.id, sort_order=i, + field_schema=c["field_schema"])) + db.session.commit() + print(f"Seeded {len(CATEGORIES)} top categories + subcategories.") + + +def seed_zip_sample(): + for z, city, st, lat, lng, metro in ZIP_SAMPLE: + if ZipGeo.query.get(z) is None: + db.session.add(ZipGeo(zip=z, city=city, state=st, lat=lat, lng=lng, metro=metro)) + db.session.commit() + print(f"Seeded {len(ZIP_SAMPLE)} sample ZIP rows.") + + +def make_admin(email, password): + user = User.query.filter_by(email=email.lower()).first() + if user is None: + user = User(email=email.lower(), display_name="Admin", role=Role.admin, + email_verified=True) + user.set_password(password) + db.session.add(user) + else: + user.role = Role.admin + user.email_verified = True + user.set_password(password) + db.session.commit() + print(f"Admin ready: {email}") + + +if __name__ == "__main__": + app = create_app() + with app.app_context(): + seed_plans() + seed_categories() + seed_zip_sample() + if "--admin" in sys.argv: + i = sys.argv.index("--admin") + make_admin(sys.argv[i + 1], sys.argv[i + 2]) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_smoke.py b/tests/test_smoke.py new file mode 100644 index 0000000..874bbe9 --- /dev/null +++ b/tests/test_smoke.py @@ -0,0 +1,253 @@ +"""Phase 1 smoke test. Runs against SQLite in-memory; no MySQL/Redis needed. + + python -m tests.test_smoke (or) pytest -q + +Production runs over HTTPS (PREFERRED_URL_SCHEME=https), so Flask-WTF enforces +the secure-referrer CSRF check. The client helper below sends an https base_url +and a matching Referer, exactly as a real browser would. +""" +import os +import re +import io +import logging + +import tempfile + +os.environ.setdefault("SECRET_KEY", "test-secret") +# File-based SQLite: in-memory sqlite:// gives each connection its own empty DB +# under Flask-SQLAlchemy, so use a temp file that all connections share. +_SMOKE_DB = os.path.join(tempfile.gettempdir(), "classifieds_smoke.db") +if os.path.exists(_SMOKE_DB): + os.remove(_SMOKE_DB) +os.environ.setdefault("DATABASE_URL", f"sqlite:///{_SMOKE_DB}") +os.environ.setdefault("REDIS_URL", "memory://") # limiter in-memory +os.environ.setdefault("FLASK_CONFIG", "dev") + +from app import create_app # noqa: E402 +from app.extensions import db # noqa: E402 +from app.models.plan import Plan # noqa: E402 +from app.models.user import User # noqa: E402 +from app.utils.text import normalize # noqa: E402 +from seed import seed_plans, seed_categories, seed_zip_sample # noqa: E402 + +BASE = "https://localhost" + + +def _csrf(html): + return re.search(r'name="csrf_token"[^>]*value="([^"]+)"', html).group(1) + + +def run(): + app = create_app() + with app.app_context(): + db.create_all() + seed_plans() + seed_categories() + seed_zip_sample() + plans = [(p.slug, p.limit("active_listings"), p.limit("ad_free")) + for p in Plan.query.order_by(Plan.sort_order)] + assert plans == [("free", 3, False), ("basic", 15, True), + ("pro", 50, True), ("business", None, True)], plans + print("plans seeded:", plans) + + assert normalize("Phở Bò Đặc Biệt") == "pho bo dac biet" + assert normalize("Ñandú Jalapeño") == "nandu jalapeno" + print("accent normalizer ok") + + c = app.test_client() + buf = io.StringIO() + h = logging.StreamHandler(buf); h.setLevel(logging.INFO) + app.logger.addHandler(h) + + def post(path, data, ref=None): + return c.post(path, base_url=BASE, data=data, follow_redirects=True, + headers={"Referer": ref or f"{BASE}{path}"}) + + def get(path): + return c.get(path, base_url=BASE, follow_redirects=True) + + # register + tok = _csrf(get("/auth/register").get_data(as_text=True)) + r = post("/auth/register", {"csrf_token": tok, "display_name": "Test User", + "email": "t@example.com", "password": "StrongPass123", + "confirm": "StrongPass123"}) + assert r.status_code == 200 and "verify" in r.get_data(as_text=True).lower() + print("register + verify email: ok") + + # verify email link + link = re.search(r"/auth/verify/(\S+)", buf.getvalue()).group(1) + r = get("/auth/verify/" + link) + assert "verified" in r.get_data(as_text=True).lower() + with app.app_context(): + u = User.query.filter_by(email="t@example.com").first() + assert u.email_verified and u.trust_score == 5 and u.tier.slug == "free" + print(f"verify: ok (trust={u.trust_score}, tier={u.tier.slug})") + + # login + tok = _csrf(get("/auth/login").get_data(as_text=True)) + r = post("/auth/login", {"csrf_token": tok, "email": "t@example.com", + "password": "StrongPass123"}) + assert "Test User" in r.get_data(as_text=True) + print("login: ok") + + # language switch + assert get("/lang/vi").status_code in (302, 200) + print("lang switch: ok") + + # bad password rejected + get("/auth/logout") + tok = _csrf(get("/auth/login").get_data(as_text=True)) + r = post("/auth/login", {"csrf_token": tok, "email": "t@example.com", + "password": "wrong"}) + assert "invalid" in r.get_data(as_text=True).lower() + print("bad login rejected: ok") + + # duplicate email rejected + tok = _csrf(get("/auth/register").get_data(as_text=True)) + r = post("/auth/register", {"csrf_token": tok, "display_name": "Dupe", + "email": "t@example.com", "password": "StrongPass123", + "confirm": "StrongPass123"}) + assert "already exists" in r.get_data(as_text=True).lower() + print("duplicate email rejected: ok") + + # password reset round-trip + tok = _csrf(get("/auth/reset").get_data(as_text=True)) + buf2 = io.StringIO(); h2 = logging.StreamHandler(buf2); h2.setLevel(logging.INFO) + app.logger.addHandler(h2) + post("/auth/reset", {"csrf_token": tok, "email": "t@example.com"}) + rlink = re.search(r"/auth/reset/(\S+)", buf2.getvalue()).group(1) + tok = _csrf(get("/auth/reset/" + rlink).get_data(as_text=True)) + r = post("/auth/reset/" + rlink, {"csrf_token": tok, + "password": "NewPass456", "confirm": "NewPass456"}) + assert "updated" in r.get_data(as_text=True).lower() + tok = _csrf(get("/auth/login").get_data(as_text=True)) + r = post("/auth/login", {"csrf_token": tok, "email": "t@example.com", + "password": "NewPass456"}) + assert "Test User" in r.get_data(as_text=True) + print("password reset round-trip: ok") + + # misc + assert get("/").status_code == 200 + assert get("/healthz").get_json() == {"status": "ok"} + assert get("/nope").status_code == 404 + print("home / healthz / 404: ok") + + _phase2(app) + + print("\nALL SMOKE CHECKS PASSED") + + +def _phase2(app): + """Phase 2: listings core — geocode, tier limits, accent search, radius, + image pipeline, expiry sweep.""" + import io + from datetime import datetime, timedelta + from werkzeug.datastructures import FileStorage + from PIL import Image + from app.models.user import User + from app.models.category import Category + from app.models.listing import Listing + from app.models.enums import ListingStatus + from app.services import listings as lsvc + from app.services.geo import geocode_zip, haversine_mi + from app.services.images import process_upload + + with app.app_context(): + user = User.query.filter_by(email="t@example.com").first() + for_sale = Category.query.filter_by(slug="for-sale").first() + assert for_sale and for_sale.hot_fields(), "category schema missing" + + # geocode sanity + g = geocode_zip("92683") + assert g and g[2] == "Westminster" and g[3] == "CA" + print(f"geocode 92683: ok ({g[2]}, {g[3]})") + + # create three (free cap = 3) + l1 = lsvc.create_listing(user, for_sale, title="Phở Bò Đặc Biệt", + body="Authentic beef noodle soup gear for sale.", + lang="vi", price_cents=2500, zip_code="92683", + raw_attributes={"condition": "good", "brand": "Acme"}) + l2 = lsvc.create_listing(user, for_sale, title="Used Sofa", + body="Comfortable three-seat sofa, gently used.", + lang="en", price_cents=15000, zip_code="77036", + raw_attributes={"condition": "good"}) + l3 = lsvc.create_listing(user, for_sale, title="iPhone 14", + body="Unlocked phone in great shape, no scratches.", + lang="en", price_cents=40000, zip_code="92840", + raw_attributes={"condition": "like_new"}) + assert l1.lat and l1.attr_condition == "good" and l1.title_norm == "pho bo dac biet" + print("create + geocode + hot-column + title_norm: ok") + + # tier limit: 4th create blocked + try: + lsvc.create_listing(user, for_sale, title="Fourth", body="blocked one two", + lang="en", price_cents=100, zip_code="92683", + raw_attributes={"condition": "fair"}) + assert False, "expected tier limit error" + except lsvc.ListingError as e: + assert "limit" in str(e).lower() + print("tier active-listing limit enforced: ok") + + # attribute validation (unit-level, independent of tier cap) + from app.services.field_schema import validate_attributes + _cleaned, _errs = validate_attributes(for_sale, {"condition": "banana"}) + assert _errs.get("condition") == "invalid option" + _cleaned, _errs = validate_attributes(for_sale, {"brand": "OK"}) + assert _errs.get("condition") == "required" # required select missing + _cleaned, _errs = validate_attributes(for_sale, {"condition": "new"}) + assert not _errs and _cleaned["condition"] == "new" + print("attribute schema validation: ok") + + # accent-insensitive search: 'pho' finds the diacritic title + found = lsvc.browse_query(q="pho").all() + assert l1 in found and l2 not in found + print("accent-insensitive search: ok") + + # price filter + cheap = lsvc.browse_query(max_price=5000).all() + assert l1 in cheap and l3 not in cheap + print("price filter: ok") + + # radius: near 92683 within 30 mi -> Westminster + Garden Grove, not Houston + base = lsvc.browse_query() + lat, lng = geocode_zip("92683")[0], geocode_zip("92683")[1] + near = lsvc.search_with_radius(base, lat, lng, 30) + ids = {l.id for l, d in near} + assert l1.id in ids and l3.id in ids and l2.id not in ids + d_gg = dict((l.id, d) for l, d in near)[l3.id] + assert d_gg < 15, d_gg + print(f"radius filter: ok (Garden Grove {d_gg} mi from Westminster)") + + # image pipeline: synthesize a PNG, process it + buf = io.BytesIO() + Image.new("RGB", (1200, 900), (80, 120, 200)).save(buf, "PNG") + buf.seek(0) + fs = FileStorage(stream=buf, filename="x.png", content_type="image/png") + img = process_upload(fs, l1.id, sort_order=0) + db.session.add(img); db.session.commit() + assert img.path.endswith(".jpg") and img.thumb_path and img.width <= 1600 + import os + media = os.path.join(app.instance_path, "media", str(l1.id)) + assert os.path.isdir(media) and len(os.listdir(media)) == 2 + print("image pipeline (re-encode + thumbnail + EXIF strip): ok") + + # expiry sweep: backdate l2, sweep, confirm status transition + l2.expires_at = datetime.utcnow() - timedelta(hours=1) + db.session.commit() + active_status_before = Listing.query.filter_by( + user_id=user.id, status=ListingStatus.active).count() + n = lsvc.expire_due_listings() + active_status_after = Listing.query.filter_by( + user_id=user.id, status=ListingStatus.active).count() + assert n == 1, n + assert active_status_after == active_status_before - 1 + assert Listing.query.get(l2.id).status == ListingStatus.expired + print(f"expiry sweep: ok (status active {active_status_before}->{active_status_after})") + + +def test_smoke(): # pytest entry + run() + + +if __name__ == "__main__": + run() diff --git a/wsgi.py b/wsgi.py new file mode 100644 index 0000000..d00cb6a --- /dev/null +++ b/wsgi.py @@ -0,0 +1,9 @@ +"""WSGI entry point. Used by Gunicorn and `flask` CLI. + + export FLASK_APP=wsgi:app + flask db init / migrate / upgrade + gunicorn -c deploy/gunicorn.conf.py wsgi:app +""" +from app import create_app + +app = create_app()