# 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.