An original, production-minded Persian classifieds marketplace (the genre of Divar / Craigslist): local listings by city and category, rich per-category attributes, phone + OTP sign-in, buyer–seller chat, moderation, and a security posture designed in from the start.
- Backend: Python 3.12, FastAPI, SQLite (WAL + FTS5), Pillow
- Frontend: dependency-free ES-module SPA (no build step), RTL-first, light/dark, mobile-first
- Tests: 43 pytest tests (security + functional)
.\run.ps1 # Windows: creates .venv, installs deps, seeds demo data, starts the server./run.sh # macOS / LinuxOpen http://127.0.0.1:8000. Re-seed with .\run.ps1 -Reset (~1.5 min for 180 listings with images).
Demo accounts (in development the OTP is shown in the sign-in dialog; nothing is sent by SMS):
| Role | Phone |
|---|---|
| Admin | 09120000000 |
| Seller | 09121111111 |
| Buyer | 09122222222 |
Run the tests: .venv\Scripts\python -m pytest -q tests
API explorer (dev only): http://127.0.0.1:8000/api/docs
Buyers
- City picker (asked on first visit, remembered), category tree, full-text Persian search with category suggestions
- Filters generated from each category's schema: districts, price range (shown in words, e.g. «۱۲ میلیون تومان»), area/year/mileage ranges, multi-select options, "has elevator", "has photos"
- Sort by newest / cheapest / most expensive; infinite scroll with keyset pagination; back button restores the list and scroll position
- Ad page: swipeable gallery + lightbox, key-facts grid, features, safety warnings, similar ads, share, bookmark, report
- Contact reveal (login + rate-limited) or in-app chat with read receipts
- Recently-viewed list kept only on the device
Sellers
- Category-first posting wizard with dynamic fields, inline validation mirrored from the server, Persian-digit input, price-in-words
- Multi-image upload: drag & drop, client-side downscale, progress, retry, choose cover
- Draft autosave, edit, delete, renew near expiry (ads live 30 days), status badges, rejection reasons, view counts
- Choose contact channels (phone and/or chat)
Account & safety
- Phone + 6-digit OTP sign-in, active-device list with remote sign-out
- Auto-moderation (blocked contact info, banned terms → review queue), user reports
- Admin console: review queue with approve/reject reasons, reports, bans (revokes sessions, hides ads), all audited
UX quality
- True RTL using CSS logical properties; Vazirmatn font; Persian digits & dates (
Intlfa-IR) - Responsive: sidebar layout on desktop, bottom navigation + bottom sheets on phones
- Accessible: semantic landmarks, skip link, focus-trapped dialogs with
inertbackground, ARIA combobox search, keyboard-navigable gallery, visible focus,prefers-reduced-motion - Skeleton loaders, empty/error states with retry, optimistic chat sends
raste/
├─ app/
│ ├─ main.py app factory: middleware, error mapping, static files, SPA + OG-meta injection, janitor task
│ ├─ config.py env-driven settings with safe production defaults
│ ├─ security.py tokens, OTP HMAC, rate limiter, origin check, CSP & security headers
│ ├─ deps.py DB connection, session resolution, CSRF check, role guards
│ ├─ db.py / schema.sql SQLite schema, transactions, FTS indexing, audit log
│ ├─ catalog.py categories + attribute schemas + cities (single source of truth)
│ ├─ text.py Persian normalisation, phone parsing, contact-info detection, safe FTS queries
│ ├─ serializers.py row → API shapes
│ └─ routers/ auth · account(meta, me) · posts · media · chat · admin
├─ web/
│ ├─ index.html
│ └─ assets/
│ ├─ css/app.css design tokens, components, light/dark themes
│ └─ js/
│ ├─ core/ dom (safe builder), api, router, store, format
│ ├─ components/ shell, auth, ui (modal/toast), gallery, uploader, postCard, icons, states
│ └─ pages/ list, post, editor, my, chat, admin, static
├─ scripts/seed.py demo data with generated images
└─ tests/ pytest suite
Key decisions
- Schema-driven categories.
catalog.pydefines each category's attributes once; the API validates against it (unknown keys dropped) and the client renders both the posting form and the search filters from the same definition served by/api/meta. - Keyset pagination (
bumped_at,id/price,idcursors) keeps infinite scroll stable and O(1) per page. - Persian search normalises ي/ی, ك/ک, digits, ZWNJ and diacritics identically on index and query, with prefix matching.
- No build step, no runtime third-party JS — the whole client is first-party ES modules, which makes a strict
script-src 'self'CSP possible. - SQLite is right for a single node (WAL, FTS5, JSON1). The data layer is plain SQL behind small helpers, so moving to PostgreSQL (+
pg_trgm/OpenSearch) is mechanical.
| Method | Path | Notes |
|---|---|---|
| POST | /api/auth/otp/request · /api/auth/otp/verify |
phone sign-in |
| GET/POST | /api/auth/me · /api/auth/logout · /api/auth/sessions |
session & devices |
| GET | /api/meta |
categories (with attribute schemas) and cities |
| GET | /api/posts?city&category&q&district&price_min&price_max&has_image&sort&cursor&a.<attr>… |
search |
| GET | /api/posts/{id} · /api/posts/{id}/similar |
detail |
| POST/PUT/DELETE | /api/posts · /api/posts/{id} |
create / edit / delete (owner) |
| POST | /api/posts/{id}/contact · /bookmark · /report · /renew |
interactions |
| POST | /api/media/images |
upload (re-encoded) |
| GET/POST | /api/chat/conversations[/{id}/messages] · /api/chat/unread |
chat |
| GET/PATCH | /api/me · /api/me/posts · /api/me/bookmarks |
my area |
| * | /api/admin/… |
moderation (admin only) |
Errors are uniform: {"error": {"code", "message" (Persian, user-facing), "fields"?: {field: message}, "retry_after"?}}.
export RASTE_ENV=production
export RASTE_SECRET_KEY="$(python -c 'import secrets;print(secrets.token_urlsafe(48))')"
export RASTE_ALLOWED_ORIGINS=https://raste.example
export RASTE_ADMIN_PHONES=09xxxxxxxxx
export RASTE_TRUST_PROXY=1 # only behind your own reverse proxy
python -m uvicorn app.main:_app_factory --factory --host 127.0.0.1 --port 8000 --proxy-headers --workers 1In production mode: OTP codes are never returned by the API, cookies are Secure, HSTS is sent, and /api/docs is disabled.
Checklist before real traffic:
- Put it behind HTTPS (nginx/Caddy) with request-size and connection limits.
- Wire
send_sms()inapp/routers/auth.pyto an SMS provider. - Move rate limiting to Redis if you run more than one process (
RateLimiterhas a singlehit()method to swap). - Serve
/mediafrom object storage/CDN; keep thenosniffheader. - Back up
data/raste.db(e.g. Litestream) or migrate to PostgreSQL when write volume grows. - Self-host the Vazirmatn font to drop the Google Fonts allowance from the CSP.
See SECURITY.md for the threat model and controls.