- Python 52.5%
- HTML 23.4%
- CSS 13.3%
- JavaScript 10.8%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
| static | ||
| templates | ||
| .env.example | ||
| .gitignore | ||
| .~lock.aircraft-selector.service# | ||
| aircraft-selector.service | ||
| aircraft_data.json | ||
| app.py | ||
| auth.py | ||
| bot_protection.py | ||
| extensions.py | ||
| forms.py | ||
| git_data.py | ||
| mailer.py | ||
| main.py | ||
| models.py | ||
| page_views.py | ||
| public_url.py | ||
| README.md | ||
| requirements.txt | ||
| selector_logic.py | ||
| tokens.py | ||
| twofactor.py | ||
| wiki_lookup.py | ||
Aircraft Selector
Note: This application was built with AI assistance (Claude). Review the code yourself before relying on it, especially anything security- or data-handling-related.
A Flask web app that picks a random aircraft matching whatever filters
you set, with accounts, personal databases, share links, two-factor
authentication, and light/dark mode. Originally a rewrite of a CLI
script (aircraftDeterminator()); the matching rule is unchanged (OR
within one filter, AND across filters - see selector_logic.match_aircraft),
everything else around it is new.
Contents
- Quick start
- Configuration
- Project layout
- Database
- Aircraft data source
- Wikipedia summaries
- Accounts and two-factor authentication
- Personal databases and sharing
- Generation history
- Branding, SEO, and social previews
- Visitor logging
- Security
- Deployment
- Mobile
- License
Quick start
Needs the git command line tool installed (used to sync the aircraft
data repo - see Aircraft data source) and a
PostgreSQL database already created (see Database - there's
no SQLite fallback). Everything else comes from requirements.txt.
cd aircraft-selector-app
python3 -m venv .venv && source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install -r requirements.txt
cp .env.example .env
python -c "import secrets; print(secrets.token_hex(32))" # paste into .env as SECRET_KEY
# then set DATABASE_URL in .env - see "Database" below
flask --app app:create_app init-db
flask --app app:create_app --debug run
Open http://127.0.0.1:5000. The generator works immediately with no
login; sign up to get a personal database and a share link.
Configuration
Everything is read from environment variables (.env locally - see
.env.example for the full list with worked examples). SECRET_KEY
and DATABASE_URL are both required; everything else has a sensible
default or is optional.
| Variable | Purpose | Default |
|---|---|---|
SECRET_KEY |
Session signing, CSRF, password-reset tokens | none - required |
DATABASE_URL |
PostgreSQL connection string - no SQLite fallback | none - required |
FORCE_HTTPS |
Marks the session cookie Secure, adds HSTS |
unset |
PUBLIC_BASE_URL |
Fixed base URL for share/reset links (see public_url.py) - set this if the app sits behind any reverse proxy/Docker/Tailscale hop |
derived from the request's Host header |
RATELIMIT_STORAGE_URI |
Shared rate-limit backend (Redis) for multi-worker deployments | in-memory |
AIRCRAFT_DATA_GIT_URL / _BRANCH / _FILE |
Where the default aircraft list is synced from | this repo |
AIRCRAFT_DATA_REFRESH_SECONDS |
How often to re-sync that repo | 300 |
Project layout
app.py application factory, config, security headers, CLI commands
extensions.py shared db / login / csrf / limiter instances
models.py User, AircraftEntry, GenerationHistory
forms.py all forms - auth, account, add-aircraft, recovery
selector_logic.py default-data caching/refresh, matching, choice sets
git_data.py syncs the default aircraft data from a git repo
wiki_lookup.py Wikipedia search + summary + photo lookup
bot_protection.py honeypot bot detection for login/signup
tokens.py signed password-reset tokens
public_url.py builds share/reset links from a fixed base
URL instead of trusting proxy-forwarded headers
twofactor.py TOTP codes, QR generation, backup codes
page_views.py privacy-conscious visitor logging, shared with the
site portal app's admin traffic dashboard
auth.py login/signup, 2FA login step, password recovery routes
main.py generator page, personal database, share link, JSON
API, My Account page
templates/ Jinja templates
static/ CSS + JS
aircraft_data.json bundled fallback copy of the default aircraft list
Database
PostgreSQL is required - there's no SQLite fallback. Create the database and a dedicated, low-privilege user (never point this at a superuser account):
CREATE DATABASE aircraft_app;
CREATE USER aircraft_app WITH PASSWORD 'choose-a-strong-password-here';
GRANT ALL PRIVILEGES ON DATABASE aircraft_app TO aircraft_app;
Then in .env:
DATABASE_URL=postgresql+psycopg2://aircraft_app:choose-a-strong-password-here@localhost:5432/aircraft_app
psycopg2-binary is already in requirements.txt, so no extra install
step. Run flask --app app:create_app init-db once to create the tables.
Schema changes: init-db runs db.create_all(), which creates
missing tables but never alters an existing one. For any future schema
change on a database that already has real data in it, use
Flask-Migrate (Alembic) rather
than re-running init-db.
Aircraft data source
The default aircraft list is kept in sync with a git repo rather than
only ever reading the bundled aircraft_data.json (git_data.py):
- On first use, the app shallow-clones
AIRCRAFT_DATA_GIT_URLintoinstance/data-repo/and readsaircraft_data.jsonfrom inside it. - After that, a refresh (
git fetch+git reset --hard) runs at most once perAIRCRAFT_DATA_REFRESH_SECONDS- push an update to the repo and it shows up in the running app within that window, no redeploy. - If the repo is unreachable,
gitisn't installed, or the fetched file is missing/malformed, the app keeps serving whatever data it already had, falling back to the bundled copy only if it's never synced successfully at all. - The repo is treated strictly as a data source: nothing from the
checkout is ever imported or executed, only
aircraft_data.jsonis read. - Repo URL/branch are read from server-side config only (never a
request), passed to
gitas separate argument-list items (no shell involved), and checked against an allow-list pattern before use.
One dataset quirk worth knowing: "None" (the propulsion value used
for gliders, which have no engine) is deliberately hidden from the
propulsion filter and the "add an aircraft" form
(selector_logic.HIDDEN_CHOICE_VALUES) - it only removes it as a
pickable option, gliders themselves still turn up in generation
whenever the propulsion filter is left blank. One side effect: a
personal entry currently can't be explicitly marked propulsion "None"
either.
Wikipedia summaries
There's no performance-data field (range, speed, etc.) on any
aircraft - the result page shows a live summary and photo pulled from
Wikipedia instead, since Wikipedia has a real public API meant for
exactly this kind of automated, attributed lookup, and its text/images
are openly licensed. All of it lives in wiki_lookup.py:
- Default aircraft: on each generation, the app searches Wikipedia by name (+ manufacturer) and shows that page's intro paragraph, a link to the article, and its lead image if one exists. Results are cached in memory per process.
- Personal entries: adding one on the My Database page triggers the same automatic search; a confident match gets saved already linked to that article. If nothing is found, a follow-up field lets you paste in the article's title or a link yourself, or save without one (shows "No Wikipedia summary found" later).
- Only the article title is stored (
AircraftEntry.wikipedia_title), never the summary text, so what's shown is always fetched live and stays current if the article changes. - Pasting in a link never causes the server to fetch that URL - it only extracts the page title text and sends that to Wikipedia's own API, so there's no server-side-request-forgery risk.
- Search pulls a handful of candidates rather than trusting Wikipedia's top relevance hit alone, since that's unreliable for aircraft families - a search for a specific variant can rank a more heavily- edited sibling above the article that actually covers it. When the aircraft's name contains a specific model/variant number, each candidate's own article text is checked for that number (both the full form and, since family articles often list variants by suffix alone, the suffix form) before it's accepted, falling back to the plain top hit only if none match.
- The extract is split into paragraphs (Wikipedia separates them with newlines, which HTML collapses by default) with everything past the first tucked behind a "Show more" toggle.
Accounts and two-factor authentication
Signup only ever requires a username and password - there's no email on accounts at all. Two-factor authentication is optional, but it's the only way to make an account recoverable if the password is forgotten; the signup page and My Account both say this plainly.
Two-factor authentication (TOTP - Google Authenticator, Authy, 1Password, etc.):
- Setup (
main.account_2fa_setup) shows a QR code (twofactor.qr_svg, no Pillow needed) and requires a real code from the app before it actually turns on, so a mis-scanned secret is caught early. - Turning it on generates 10 one-time backup codes
(
twofactor.generate_backup_codes), shown exactly once and stored only as hashes (User.set_backup_codes) - regenerating replaces them, it can't recover old ones. - Once on, a correct password lands on
/login/2fainstead of completing login (session["pending_2fa_uid"]holds identity in the meantime;login_user()isn't called until the second factor checks out), which accepts either a live code or a backup code. - Disabling 2FA or regenerating codes both require the current password.
Password recovery, for an account with 2FA set up: /forgot-password
asks for a username, then /recover-account offers a backup-code field
without revealing whether that account actually has 2FA enabled - a
wrong code and an account with none set up produce the identical
response, so this endpoint can't be used to enumerate who has recovery
available. A correct code lands on /reset-password/<token>.
No 2FA at all: the account page says so plainly, and the only way
back in is flask --app app:create_app reset-password <username> -
someone with server access sets a new password directly - or creating
a new account. There's deliberately no security-question system:
NIST's digital identity guidelines (SP 800-63B) advise against that
kind of knowledge-based authentication, since answers are frequently
guessable or discoverable, and unlike a password, can't really be
changed once exposed. There's also deliberately no email-based
password reset - one less thing to configure (no SMTP setup, no
outgoing-mail dependency) and one less place account recovery can
silently fail (a mistyped or abandoned address). 2FA + backup codes
covers the same need without either problem.
Personal databases and sharing
- Each user's added aircraft live in
AircraftEntry, keyed to their account. The sharedaircraft_data.jsonbaseline is never modified at runtime. - Every account gets a random
share_token(secrets.token_urlsafe(32))./shared/<token>is a public, read-only page: anyone with the link can view that user's added aircraft and generate against "default data + their personal data," but can't add, edit, or delete anything there. - "Reset link" on My Database swaps in a new token, invalidating the old shared URL immediately.
- My Database also has a field to paste in someone else's share link
or code (
main.find_shared) and jump straight to it, for when you've been sent one directly rather than clicking a link. - The displayed share link is built with
public_url.external_url(see Configuration -PUBLIC_BASE_URL), not plainurl_for(_external=True). Behind a reverse proxy, that matters: if a hop in the chain doesn't relayX-Forwarded-Host/Protocorrectly, the plain approach can build a link from an internal address (a Tailscale IP, a bare:8000) that looks fine on the page but goes nowhere for anyone who opens it. SetPUBLIC_BASE_URLand every link the app hands out - share links and password resets - is built from that fixed address instead, regardless of proxy headers.
Generation history
Tied to the account (GenerationHistory in models.py), not the
browser - it follows you across devices.
- Every successful generation for a logged-in user logs a row
(
main._record_generation), trimmed to the most recent 10 (main.HISTORY_LIMIT) - a recency log, not a full audit trail. GET /api/historyreturns the last 10, newest first;POST /api/history/cleardeletes them all.- Logged out, there's nothing to show - the panel is replaced with a login/signup prompt, since history requires an account.
Two related notes:
- Custom developers: the "Developer(s)" field on the add-aircraft
form is a multi-select from the default dataset's known developers,
plus a free-text "Add a new developer" field next to it
(
main._combine_developers) - not limited to developers already inaircraft_data.json. - No full-page reload on generate: clicking "Generate aircraft"
does a
fetch()POST to/api/generate; JavaScript swaps in a loading state, then the result (or empty/error state), withhistory.pushStateupdating the URL to#resultpurely for back-button/bookmark behavior - never a real navigation.
Branding, SEO, and social previews
- Favicon and nav icon are the transgender pride flag (public domain
design). SVG for browsers that support it (
static/img/flag.svg), PNG fallback (static/img/favicon.png) for the rest, including most social/chat link-preview crawlers that don't render SVG. base.htmlsets a real meta description, canonical link, and full Open Graph + Twitter Card tags on every page (static/img/og-image.pngas the shared preview image) - overridemeta_description/og_title/og_descriptionper-template if a specific page wants something more specific than the site-wide default./robots.txtand/sitemap.xmlare both live routes (main.py), not static files - the sitemap lists just the home page, and robots.txt explicitly disallows crawling/shared/<token>links, since those are meant to be shared privately with whoever they're sent to, not indexed.- Both use
external_url(seepublic_url.py) rather than plainurl_for(_external=True), so the URLs in a shared link's preview card - and what search engines actually index - stay correct behind a reverse proxy. SetPUBLIC_BASE_URLif this app sits behind one.
Visitor logging (page_views.py)
Every real page load (not static files, not /api/* calls, not error
responses) gets logged to a page_views table shared with the site
portal app - that app's /admin/traffic page is where the 24-hour/
7-day/30-day counts actually show up; this app only writes the rows.
No IP address is ever stored - each row's visitor_hash is a one-way
HMAC-SHA256 of the visitor's IP, keyed with this app's SECRET_KEY.
That's enough to tell whether two visits came from the same address
(so the dashboard can show unique visitors, not just raw page loads),
but there's no way to recover the original IP from what's stored, even
with full database access.
Rows older than 35 days should be pruned periodically:
flask --app app:create_app prune-page-views
Only needs to run from one of the two apps (they share the table) - put it on a nightly cron job on whichever server is more convenient.
Security
- Passwords: hashed with scrypt via Werkzeug, never plaintext.
- SQL injection: all queries go through the SQLAlchemy ORM with bound parameters - no string-built SQL anywhere.
- XSS: Jinja2 autoescaping is on and untouched (no
|safeon user input); the JS-rendered result panel escapes every value before inserting it into the DOM (escapeHtml()ingenerator.js). - CSRF: every form has a Flask-WTF token; JSON
/api/*endpoints require it via theX-CSRFTokenheader. - Open redirect: the post-login
nextredirect only accepts same-site relative paths. - Username enumeration: login shows one identical error whether the username doesn't exist or the password is wrong; the account-recovery flow has the same property (see above).
- Bot protection (
bot_protection.py), no email/CAPTCHA required: a honeypot field with a deliberately meaningless name (hpx1, not something like"website"that autofill heuristics specifically target), hidden via CSS - nottype="hidden", which some scrapers know to skip - and renderedreadonly. A hit is folded into the same generic error a real failure would show. Stacks with rate limiting; a CAPTCHA (e.g. Cloudflare Turnstile) would be the natural next layer if bots become a persistent problem. - Rate limiting: Flask-Limiter throttles login, signup, 2FA
verification, password recovery, and the
/api/*endpoints. With more than one gunicorn worker, setRATELIMIT_STORAGE_URIto a shared backend (Redis) - otherwise each worker tracks its own count. - IDOR: deleting a personal aircraft entry checks row ownership and returns 404 (not 403) on mismatch.
- Session cookie:
HttpOnly,SameSite=Laxalways;SecureonceFORCE_HTTPS=1is set. - Content-Security-Policy:
script-src 'self'with no exceptions - every script is a same-origin file understatic/js/, no inline<script>blocks oronclick/onsubmitattributes anywhere (confirm-submit.jsandcopy-button.jsreplace what would otherwise be inline handlers - a CSP-enforcing browser silently blocks those, which would break "are you sure?" confirmations entirely).style-srcdoes allow'unsafe-inline': templates use many one-off inlinestyle="..."attributes for layout, none built from user data, and that's a deliberately cheaper tradeoff than refactoring every one into a named class - inline script is where CSP's real protective value is, and that stays fully strict. - Password-reset tokens (
tokens.py): signed with itsdangerous (a Flask dependency already), not stored in a database table. A token embeds a fragment of the account's current password hash, so changing the password invalidates every reset link issued before that change, on top of itsdangerous's own 1-hour expiry. - 2FA brute-forcing:
/login/2fahas a tighter rate limit than the main login form, since a 6-digit code is a much smaller space to guess than a password. - Backup codes are stored hashed, never in plain text, and each is deleted the moment it's used.
- Changing 2FA settings requires the current password: disabling 2FA or regenerating backup codes both re-check it first, so a hijacked session alone can't strip an account's recovery method.
- Server-side input validation: every filter value submitted to
/api/generateis checked against the current, real choice sets before use (selector_logic.validate_selections). - No external asset loading: the UI uses system font stacks, not a
Google Fonts/CDN request, so the CSP can stay at
default-src 'self'(Wikimedia is the one explicit exception, for aircraft photos). - Secrets:
SECRET_KEY/DATABASE_URLcome from environment variables only, never hardcoded; the app refuses to start without a realSECRET_KEYwhenFLASK_ENV=production. - Git data sync: see Aircraft data source -
a fetched dataset is shape-checked before it's trusted
(
selector_logic._is_valid_dataset), so a bad push can't crash the app or run code.
Still your responsibility in production:
- Serve over HTTPS (nginx/Caddy + Let's Encrypt) and set
FORCE_HTTPS=1. - Turn debug mode off.
- Keep dependencies updated.
- Back up the database.
- If exposed beyond your own network, consider a per-account cap on personal database entries.
Deployment
gunicorn "app:create_app()" --workers 3 --bind 0.0.0.0:8000
Put a reverse proxy (nginx, Caddy, etc.) in front for TLS termination,
then set FORCE_HTTPS=1 in .env.
Mobile
Layout is a single fluid column under ~640px, tap targets are ≥44px,
and the filter dropdowns are custom checkbox panels (not native
<select multiple>, which is awkward with touch) that still look and
behave like a single dropdown. Tables with many columns (My Database's
entries list) collapse into stacked label/value cards below ~560px
rather than squeezing every column into an unreadable width.
License
GPLv3 - see LICENSE.