No description
  • Python 44.8%
  • HTML 35%
  • CSS 16.1%
  • JavaScript 4.1%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
2026-07-28 11:59:13 -04:00
static Feature Update 2 2026-07-28 11:59:13 -04:00
templates Feature Update 2 2026-07-28 11:59:13 -04:00
.env.example Feature Update 2 2026-07-28 11:59:13 -04:00
.gitignore Feature Update 2 2026-07-28 11:59:13 -04:00
account.py Feature Update 2 2026-07-28 11:59:13 -04:00
admin.py Feature Update 2 2026-07-28 11:59:13 -04:00
app.py Feature Update 2 2026-07-28 11:59:13 -04:00
auth.py Feature Update 2 2026-07-28 11:59:13 -04:00
authz.py Feature Update 2 2026-07-28 11:59:13 -04:00
bot_protection.py Feature Update 2 2026-07-28 11:59:13 -04:00
extensions.py Feature Update 2 2026-07-28 11:59:13 -04:00
forms.py Feature Update 2 2026-07-28 11:59:13 -04:00
ghost_api.py Feature Update 2 2026-07-28 11:59:13 -04:00
media_services.py Feature Update 2 2026-07-28 11:59:13 -04:00
models.py Feature Update 2 2026-07-28 11:59:13 -04:00
page_views.py Feature Update 2 2026-07-28 11:59:13 -04:00
pages.py Feature Update 2 2026-07-28 11:59:13 -04:00
public_url.py Feature Update 2 2026-07-28 11:59:13 -04:00
README.md Feature Update 2 2026-07-28 11:59:13 -04:00
requirements.txt Feature Update 2 2026-07-28 11:59:13 -04:00
site-portal.service Feature Update 2 2026-07-28 11:59:13 -04:00
twofactor.py Feature Update 2 2026-07-28 11:59:13 -04:00
views.py Feature Update 2 2026-07-28 11:59:13 -04:00

Site Portal

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 small, separate Flask app: an editable hub/menu page, an admin panel, and Jellyfin/Jellyseerr access provisioning - that shares its login with the aircraft selector app rather than having its own separate set of accounts.

They are two completely independent codebases and two separate running processes. There's no API call between them and no shared code import - the link is entirely at the data layer:

  1. Same database. This app's DATABASE_URL must point at the exact same PostgreSQL database the aircraft selector app uses (that app requires Postgres too - no SQLite fallback in either app). models.User here maps to that app's existing users table (same table name, a subset of its columns) instead of creating a competing one. A login here checks the literal same username/password/2FA/backup-codes row as a login over there.
  2. Same SECRET_KEY. Flask's session cookie is a signed blob; both apps need the identical key to read/write it. Copy the value from the aircraft app's .env verbatim - don't generate a new one.
  3. Same host. Serve both apps under the same domain (different URL paths or ports, routed by your reverse proxy) so the browser sends the same session cookie to both. Concretely: put the aircraft app at / and gunicorn on one port, and this app at (say) /hub, /my-services, /admin and gunicorn on another port, with your reverse proxy routing by path to whichever backend. Log in on either one and the other already recognizes you - no separate login, no OAuth/OIDC handshake, no extra moving parts.

This app has its own signup form too now (auth.signup), using the exact same validation and bot-protection shape as the aircraft app's - either app can create an account into the one shared users table, so it doesn't matter which one someone happens to land on first.

One column, is_admin, belongs to this app alone - the aircraft app never reads or writes it. See "Setup" below for adding it to the shared database.

If you'd rather run this on a different subdomain instead of a path prefix on the same domain, you can - but you'll additionally need to set SESSION_COOKIE_DOMAIN (e.g. .example.com) in both apps so the cookie is valid across subdomains; that's not wired up in either app by default, since the same-domain-different-path approach above needs zero extra config on the aircraft app's side.

Setup

cd site-portal
python3 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt

cp .env.example .env
# Fill in SECRET_KEY (copy from the aircraft app's .env) and
# DATABASE_URL (point at that same database) by hand.

flask --app app:create_app migrate-schema   # adds is_admin to the shared users table
flask --app app:create_app init-db          # creates menu_items, service_grants
flask --app app:create_app make-admin <your-username>

flask --app app:create_app --debug run

migrate-schema and init-db are both safe to run more than once - same idempotent pattern as the aircraft app's own schema commands. make-admin is the only way to become an admin; nobody starts as one, including the very first account.

Two-factor authentication

Full enrollment lives here too now (account.py/twofactor.py), not just verification during login - My Account lets someone turn 2FA on (QR code + manual key, confirmed with a real code before it actually enables), turn it off, or regenerate backup codes, all from this app. This isn't a separate "portal 2FA": enrolling here writes to the exact same totp_secret/totp_enabled/backup_codes_json columns on the shared users table that the aircraft selector app reads during its own login - turning 2FA on from either app protects the same account's login on both, and a backup code used on one app can't be reused on the other, since it's the same column either way.

Jellyfin & Jellyseerr

All of it lives in media_services.py, written against Jellyfin's and Jellyseerr's documented REST APIs. This hasn't been exercised against a live instance of either (no network access while building it) - the endpoint shapes are correct as documented, but self-hosted app APIs shift between versions, so test this against your actual installed versions before relying on it in production, and expect to adjust field names if your version has moved on.

  • Jellyfin: generate an API key (dashboard -> Advanced -> API Keys), set JELLYFIN_URL/JELLYFIN_API_KEY. Granting access from /admin/users creates a real Jellyfin account with a random temporary password, shown once to the admin to pass along. The person sets their own real password from My Services, which calls Jellyfin's password-change API directly - neither password is ever stored by this app.
  • Jellyseerr: doesn't need its own account created at all - it can log in with Jellyfin credentials natively. Set JELLYSEERR_URL/JELLYSEERR_API_KEY and granting Jellyfin access also best-effort imports the account into Jellyseerr's user list immediately, instead of only appearing the first time they log into it directly. This import is non-fatal if it fails - Jellyseerr picks it up on first login regardless.
  • Revoking access disables the Jellyfin account rather than deleting it - reversible, and doesn't lose watch history/favorites if access is restored later.
  • Leaving either unconfigured makes every function in media_services.py raise a clear error rather than fail silently; the admin panel shows "Not configured" plainly on the dashboard.

The editable menu bar

MenuItem rows, managed entirely from /admin/menu - add, edit, reorder (lowest sort_order first), or delete a tile with no code or template changes. Each has a visibility level (everyone / members / admins) so you can build one hub that shows different tiles depending on who's looking, including admin-only shortcuts.

The collapsible sidebar

A separate hamburger-menu sidebar, present on every page, with its own independently-curated link list (SidebarLink, managed at /admin/sidebar) - deliberately not the same data as the hub's tile grid above, even though the two models are nearly identical in shape. Same visibility levels and ordering as the menu bar.

Available on every page via a Flask context processor (inject_sidebar_links in app.py) rather than each route passing it in individually - that's what makes it show up on login, account pages, admin pages, everywhere, without every view function needing to remember to include it. Toggling is a small external script (static/js/sidebar.js) - CSP here is script-src 'self' with no inline JS, so this couldn't be a plain onclick attribute.

Ghost blog

The blog itself is a separate deployment - Ghost is a complete third-party platform (Node.js, its own database, its own admin/auth), not something built into this Flask app. See ../ghost-deploy/ (a sibling directory to this app) for the Docker Compose config and a custom theme matching this app's exact design tokens.

On accounts: Ghost's login (/ghost/ on the blog's own domain) is genuinely separate from this app's accounts - Ghost isn't designed to have its schema or authentication externally managed, so there's no real unification to build there beyond linking to it. What is integrated:

  • ghost_api.py calls Ghost's public Content API to show a "Latest from the blog" section on the hub - best-effort only, the section just doesn't appear if GHOST_URL/GHOST_CONTENT_API_KEY aren't set or Ghost is unreachable, same fail-quiet pattern as the Jellyfin integration.
  • Linking the blog itself into the site's nav needs no code at all - add it as a regular entry in the editable menu bar (Admin -> Menu bar), same as any other link.

Custom pages (pages.py, models.Page)

/admin/pages lets an admin write pages with raw HTML, published at /p/<path> for anyone to view - not a restricted rich-text editor, actual HTML.

This is a real, deliberate trust boundary, stated plainly: a page's html_content is rendered unescaped (the one | safe in this app - see templates/page_view.html). That's not a new level of trust beyond what an admin account already has here, though - an admin can already grant other accounts admin, add arbitrary URLs to the public menu bar, and provision Jellyfin access; page authorship being unrestricted is consistent with that, not an exception to it. One thing worth knowing: this app's CSP is script-src 'self' with no 'unsafe-inline', so a <script> tag pasted into a page's HTML won't execute in a CSP-enforcing browser - intentional defense in depth, not a bug to route around if you're expecting inline JS to run there.

Each page can also carry its own meta_description, shown in that page's Open Graph tags when the link is shared (Discord, Slack, etc.) and in its search-result snippet. Leave it blank and one is derived automatically from the page's own content (tags stripped, truncated to a clean word boundary) - a real excerpt of what the page actually says is more useful in a preview than just repeating the title back.

Branding, SEO, and social previews

  • Favicon and nav icon are the transgender pride flag (public domain design) - SVG (static/img/flag.svg) with a PNG fallback (static/img/favicon.png) for browsers and 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, including custom Pages (static/img/og-image.png as the shared preview image everywhere).
  • /robots.txt and /sitemap.xml are live routes (views.py), not static files - the sitemap lists the hub plus every published custom page, kept current automatically as pages are added or removed.
  • Both use external_url (public_url.py) rather than plain url_for(_external=True), so link previews and what search engines index stay correct behind a reverse proxy - set PUBLIC_BASE_URL if this sits behind one (same mechanism as the aircraft app's share links, see that app's README for the full reasoning).

Traffic dashboard

/admin/traffic shows visitor counts over the last 24 hours, 7 days, and 30 days - both total page views and a unique-visitor count, plus a breakdown by app and a top-10 list of the most-visited pages over the last week. It covers both this app and the aircraft selector app together, since they log to one shared page_views table (page_views.py, identical in both codebases) - the same reason either app's login recognizes the same account.

What counts as a "visit": a GET request that isn't a static asset, isn't one of the aircraft app's /api/* JSON endpoints, and got back a normal (non-error) response. A single page load is one row; a background API call the page happens to make isn't counted separately.

Privacy: no IP address is ever stored. Each row's visitor_hash is a one-way HMAC-SHA256 of the visitor's IP, keyed with that app's own SECRET_KEY. That's enough to tell whether two visits came from the same address - which is what makes "unique visitors" meaningfully different from "page views" - but there is no way to recover an actual IP address from what's stored, even with full database access.

Rows older than 35 days are pruned by flask prune-page-views (same command exists in both apps' app.py - only needs to run from one of them, on a cron schedule, since they share the table):

# crontab -e, on whichever server is more convenient:
0 3 * * * cd /var/www/site-portal && .venv/bin/flask --app app:create_app prune-page-views >> /var/log/prune-page-views.log 2>&1

Seamless navigation between this app's own pages

static/css/style.css sets @view-transition { navigation: auto; } - the native browser mechanism for smooth cross-document transitions on same-origin link navigation, no JavaScript involved. It only affects navigating between this app's own pages (hub, my services, admin); links out to Jellyfin, Jellyseerr, or the aircraft selector app are genuinely different applications and navigate normally - there's nothing to make "seamless" about leaving this app's own pages. In a browser that doesn't support this yet, it simply has no effect; there's nothing to detect or fall back on.

Unified theme

static/css/style.css and the small shared utility scripts (theme.js, theme-init.js, confirm-submit.js, password-toggle.js) are copied from the aircraft selector app rather than referenced across origins - each app is a fully independent deployment, so there's no runtime dependency between them for styling, just the same design tokens and components in both places. If the aircraft app's theme ever changes, copy the updated style.css over here to keep them in sync.

Security

  • CSP is the same shape as the aircraft app and for the same reasons: script-src 'self' with no inline <script> blocks or onclick/onsubmit attributes anywhere - confirm-submit.js replaces what would otherwise be inline confirmation dialogs, which a CSP-enforcing browser silently blocks. style-src allows 'unsafe-inline' for the same one-off-layout-attribute reason as that app.
  • Admin routes 404 (not 403) for a non-admin, so a regular account can't even confirm /admin exists, matching the aircraft app's own IDOR-avoidance pattern.
  • 2FA-aware login: if an account has 2FA enabled (set up from either app - see "Two-factor authentication" above), logging in requires the same second factor regardless of which app it was enrolled through, since both check the exact same totp_enabled/totp_secret/backup_codes_json columns on the shared row - 2FA can't be bypassed by logging in through whichever app didn't set it up.
  • 2FA setup requires confirming a real code before it actually turns on (not just generating a secret and trusting it was scanned correctly), and backup codes are shown exactly once, stored only as hashes, and each is deleted from the shared column the moment it's used anywhere - same properties as the aircraft app's own enrollment, since it's the same logic copied over.
  • Passwords for linked services are never stored: the Jellyfin temporary password is shown once to the admin (not persisted), and setting a new one calls Jellyfin's API directly with both the old and new password in a single request - neither is written to this app's database at any point.
  • Signup bot protection (bot_protection.py, copied from the aircraft app): an invisible honeypot field, not a timing check or a CAPTCHA - a filled-in honeypot gets the same generic failure message a real validation error would show, so nothing reveals to a bot (or a confused real user) that it was specifically detected.
  • Rate limiting via Flask-Limiter on signup, login, the 2FA step, service grants/revokes, and My Services actions.
  • Everything else (CSRF on every form, SQL injection via the ORM only, XSS via untouched Jinja autoescaping, session cookie flags) matches the aircraft app's own hardening - see that app's README for the full detail, since it applies here identically.

Running in production

gunicorn "app:create_app()" --workers 2 --bind 127.0.0.1:8010

Route your reverse proxy to this port for whichever paths this app owns (see "How this links..." above), and set FORCE_HTTPS=1 once it's actually behind TLS.