No description
  • Python 52.5%
  • HTML 23.4%
  • CSS 13.3%
  • JavaScript 10.8%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
2026-07-28 12:28:28 -04:00
static Small Feature Update 2026-07-28 12:10:23 -04:00
templates Small Feature Update 2026-07-28 12:10:23 -04:00
.env.example Feature Update 2 2026-07-27 07:15:43 -04:00
.gitignore Initial Commit 2026-07-26 14:45:21 -04:00
.~lock.aircraft-selector.service# Small Feature Update 2026-07-28 12:10:23 -04:00
aircraft-selector.service Feature Update 2 2026-07-27 07:15:43 -04:00
aircraft_data.json Initial Commit 2026-07-26 14:45:21 -04:00
app.py Small Feature Update 2026-07-28 12:10:23 -04:00
auth.py Feature Update 2 2026-07-27 07:15:43 -04:00
bot_protection.py Bugfix 3 2026-07-26 16:28:54 -04:00
extensions.py Initial Commit 2026-07-26 14:45:21 -04:00
forms.py Small Feature Update 2026-07-28 12:10:23 -04:00
git_data.py Initial Commit 2026-07-26 14:45:21 -04:00
mailer.py Major Feature Update 1 2026-07-26 19:53:14 -04:00
main.py Small Feature Update 2026-07-28 12:10:23 -04:00
models.py Small Feature Update 2026-07-28 12:10:23 -04:00
page_views.py Major Feature Update 1 2026-07-26 19:53:14 -04:00
public_url.py Major Feature Update 1 2026-07-26 19:53:14 -04:00
README.md Feature Update 2 2026-07-27 07:15:43 -04:00
requirements.txt Feature Update 2 2026-07-27 07:15:43 -04:00
selector_logic.py Bugfix 3 2026-07-26 16:28:54 -04:00
tokens.py Feature Update 2 2026-07-27 07:15:43 -04:00
twofactor.py Feature Update 2 2026-07-27 07:15:43 -04:00
wiki_lookup.py Small Feature Update 2026-07-28 12:10:23 -04:00

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

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_URL into instance/data-repo/ and reads aircraft_data.json from inside it.
  • After that, a refresh (git fetch + git reset --hard) runs at most once per AIRCRAFT_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, git isn'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.json is read.
  • Repo URL/branch are read from server-side config only (never a request), passed to git as 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/2fa instead 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 shared aircraft_data.json baseline 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 plain url_for(_external=True). Behind a reverse proxy, that matters: if a hop in the chain doesn't relay X-Forwarded-Host/Proto correctly, 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. Set PUBLIC_BASE_URL and 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/history returns the last 10, newest first; POST /api/history/clear deletes 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 in aircraft_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), with history.pushState updating the URL to #result purely 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.html sets a real meta description, canonical link, and full Open Graph + Twitter Card tags on every page (static/img/og-image.png as the shared preview image) - override meta_description/og_title/ og_description per-template if a specific page wants something more specific than the site-wide default.
  • /robots.txt and /sitemap.xml are 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 (see public_url.py) rather than plain url_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. Set PUBLIC_BASE_URL if 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 |safe on user input); the JS-rendered result panel escapes every value before inserting it into the DOM (escapeHtml() in generator.js).
  • CSRF: every form has a Flask-WTF token; JSON /api/* endpoints require it via the X-CSRFToken header.
  • Open redirect: the post-login next redirect 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 - not type="hidden", which some scrapers know to skip - and rendered readonly. 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, set RATELIMIT_STORAGE_URI to 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=Lax always; Secure once FORCE_HTTPS=1 is set.
  • Content-Security-Policy: script-src 'self' with no exceptions - every script is a same-origin file under static/js/, no inline <script> blocks or onclick/onsubmit attributes anywhere (confirm-submit.js and copy-button.js replace what would otherwise be inline handlers - a CSP-enforcing browser silently blocks those, which would break "are you sure?" confirmations entirely). style-src does allow 'unsafe-inline': templates use many one-off inline style="..." 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/2fa has 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/generate is 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_URL come from environment variables only, never hardcoded; the app refuses to start without a real SECRET_KEY when FLASK_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.