Files
inkwell/src/thoughtsync/app.py
T
Bryan Van Deusen ef8aa9340f
Android / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
Android / Kotlin + Rust (APK) (push) Skipped
CI & Build / Python lint (push) Successful in 2s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
Desktop (Tauri) / Tauri desktop (Linux) (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Skipped
Desktop (Tauri) / Update manifest (push) Skipped
CI & Build / TypeScript typecheck (push) Successful in 8s
CI & Build / Python tests (push) Successful in 11s
CI & Build / integration (push) Successful in 15s
CI & Build / Build & push image (push) Successful in 30s
clients: the server hands out five platforms, not "the Android client"
`client_dist.py` was written for one platform and everything structural in it was
already right — drop-in beats baked, the pair must describe one build, absence is
an ordinary answer, metadata public and bytes authenticated. This widens it to a
table rather than building beside it. Its own docstring made the argument years
before there was a second platform: a self-hoster should not need an account on
someone else's forge to get the app for their own notes.

Server side only. CI bakes nothing new until step 3 and the UI reads nothing new
until step 4, so this lands green and inert.

Five rows — android, linux-deb, linux-pacman, linux-appimage, windows — each
naming its artifact, sidecar and mimetype. Fixed filenames, version only in the
sidecar: a version-stamped name would force a glob, and a glob over a directory an
operator drops files into is how you serve the older of two builds, which is the
failure write-manifest.sh already carries a comment about.

THE ANDROID NAMES AND ROUTE DO NOT MOVE. The lane publishes those exact filenames,
clients in the field poll /api/client/android, and `android_client` stays on
/api/config beside the new `clients` map. Renaming them to match the pattern would
buy tidiness and strand every installed phone; retiring the key belongs to a later
change made when nothing polls it, not to the change introducing its replacement.
Fields were added, not moved — `ClientRelease` in core is a plain serde struct and
ignores what it does not know.

PRECEDENCE IS PER PLATFORM, which is the trap the table introduces. "First
directory holding anything wins" would mean dropping in an APK silently retracts
the four desktop downloads. Pinned by a test.

The AppImage needs a third file. It is the only bundle that replaces itself in
place, so the updater verifies a minisign signature before it does — and a bundle
that cannot be verified cannot be offered. A missing or empty `.sig` therefore
makes it absent rather than merely unsigned, and the signature travels WITH the
version so an updater can never pair one build's version with another's signature.

The tests parametrize over the table instead of testing Android and trusting the
rest. The bugs this module can have are not platform-specific, and a suite that
only exercised one platform is how the other four would ship untested.
2026-08-30 12:52:40 -04:00

228 lines
11 KiB
Python

from __future__ import annotations
import asyncio
import logging
import mimetypes
import os
import secrets
from contextlib import suppress
from datetime import timedelta
from quart import Quart, jsonify, send_from_directory
from quart.sessions import SecureCookieSessionInterface
from .auth import bp as auth_bp
from .client_dist import advertisement as client_advertisement, bp as client_bp
from .config import Config
from .db import session_scope
from .labels import bp as labels_bp
from .notes import bp as notes_bp
from .proxy import is_https
from .retention import run_sweeper
from .saved_filters import bp as saved_filters_bp
from .settings import get_public_config, get_setting, load_or_create_secret_key, refresh_live
from .settings_api import bp as settings_bp
from .sync import bp as sync_bp, protocol_advertisement
# Without this, `logger.info` from this package goes nowhere: hypercorn configures its
# own access/error loggers and leaves the root logger at WARNING, so the credential
# events in auth.py would be invisible in `docker compose logs` — which is exactly
# where they are meant to be read until an audit table exists (task 2939).
#
# `force=False` (the default) so a host that has already configured logging keeps its
# own setup; LOG_LEVEL lets an operator turn it up without a code change.
logging.basicConfig(
level=os.environ.get("THOUGHTSYNC_LOG_LEVEL", "INFO").upper(),
format="%(asctime)s %(levelname)s %(name)s %(message)s",
)
STATIC_DIR = os.path.join(os.path.dirname(__file__), "static")
# `.webmanifest` isn't in every base image's mime map; register it so the PWA
# manifest is served as application/manifest+json instead of octet-stream.
mimetypes.add_type("application/manifest+json", ".webmanifest")
class _AutoSecureSessionInterface(SecureCookieSessionInterface):
"""Mark the session cookie `Secure` whenever the request arrived over HTTPS —
directly, or via a TLS-terminating reverse proxy that sets X-Forwarded-Proto.
Auto-detecting per request (rather than a fixed SESSION_COOKIE_SECURE flag)
hardens the cookie on HTTPS deployments without breaking a plain-HTTP install on
a trusted LAN, where a hard-forced Secure flag would stop the browser from ever
sending the cookie back — i.e. silently break login. No configuration required.
"""
def get_cookie_secure(self, app: Quart) -> bool:
return is_https()
def create_app() -> Quart:
# static_folder=None: the SPA catch-all below owns static serving.
app = Quart(__name__, static_folder=None)
# Ephemeral/env secret so the app (and DB-free unit tests) construct without a
# database. before_serving swaps in the real, DB-persisted key before serving.
app.secret_key = Config.secret_key_env() or secrets.token_urlsafe(48)
# The RUNNING build, or an explicit "unknown" — never the packaging fallback.
#
# This read `os.environ.get("APP_VERSION", __version__)`, so a server started
# from a checkout reported `0.2.0`: a real-looking version that names no build
# anybody could get. `__init__.py` already claimed the honest answer was
# "APP_VERSION being missing, which app.py already handles" — it did not, and a
# comment asserting a behaviour two files away from the code is how that stayed
# true-sounding for months.
#
# It matters more than it used to. Note 3127 §5 removed version tags, so this
# string is the only answer to "which build is this?" and nothing exists to
# contradict it when it is wrong. `__version__` stays where it belongs, as
# packaging metadata, which is the one place "unknown" is not a legal value.
app.config["APP_VERSION"] = os.environ.get("APP_VERSION") or "unknown"
app.config["SESSION_COOKIE_HTTPONLY"] = True
app.config["SESSION_COOKIE_SAMESITE"] = "Lax"
# Auto-mark the session cookie Secure on HTTPS requests (see the interface above).
app.session_interface = _AutoSecureSessionInterface()
app.config["PERMANENT_SESSION_LIFETIME"] = timedelta(days=30)
# Hard request-body ceiling (any-file attachments, import zips, sync push). The
# per-file attachment limit is the DB-backed `max_attachment_mb` setting, enforced
# in the upload handler; this must stay >= the largest value that allows.
app.config["MAX_CONTENT_LENGTH"] = 64 * 1024 * 1024
app.register_blueprint(auth_bp)
app.register_blueprint(notes_bp)
app.register_blueprint(labels_bp)
app.register_blueprint(settings_bp)
app.register_blueprint(sync_bp)
app.register_blueprint(saved_filters_bp)
app.register_blueprint(client_bp)
@app.before_serving
async def _bootstrap() -> None:
# Load (or generate + persist) the real signing secret and the live session
# lifetime from the DB, before any request is served.
async with session_scope() as db:
app.secret_key = await load_or_create_secret_key(db)
try:
days = int(await get_setting(db, "session_ttl_days"))
app.config["PERMANENT_SESSION_LIFETIME"] = timedelta(days=days)
except (ValueError, TypeError, KeyError):
pass
# The security settings the throttle and the proxy trust read on hot
# paths. Cached rather than queried per request; until this runs they
# hold their registry defaults, which is the correct behaviour for a
# server that has not finished starting.
await refresh_live(db)
# Expire old trash in the background (retention.py). One task per process is
# correct because the image serves with a single hypercorn worker (Dockerfile);
# if that ever gains `--workers`, this needs a lock so N workers don't each
# sweep. Duplicate sweeps would be harmless but wasteful — a purged row is
# skipped by `purged_at IS NULL` — so this is about load, not correctness.
app.config["TRASH_SWEEPER"] = asyncio.create_task(run_sweeper())
@app.after_serving
async def _shutdown() -> None:
task = app.config.get("TRASH_SWEEPER")
if task is not None:
task.cancel()
# Await the cancellation so shutdown doesn't race a sweep mid-transaction.
with suppress(asyncio.CancelledError):
await task
@app.after_request
async def _security_headers(response):
"""Headers a publicly-reachable instance should be sending.
None of these change how the app behaves for a legitimate caller; they narrow
what a browser will do if something else goes wrong.
The CSP is the substantive one. `script-src 'self'` means that even if some
future path did manage to reflect user text into the page, the browser would
refuse to run it — the app has no inline scripts and no third-party scripts,
so nothing legitimate is given up. The exceptions are honest ones:
- `style-src 'unsafe-inline'` — Vue writes inline styles itself (`v-show`
toggling display, TransitionGroup's FLIP setting transforms). Inline
STYLE is not an execution primitive the way inline script is.
- `img-src https: http:` — link previews render the remote og:image of
whatever was linked, which is an arbitrary host by definition. Both
schemes, because a LAN install is served over http and would otherwise
lose every preview image; on an https instance the browser blocks the
http ones as mixed content anyway, so naming it concedes nothing.
- `blob:`/`data:` — attachment previews and the desktop's blob URI scheme.
`frame-ancestors 'none'` replaces the older X-Frame-Options and is what stops
the app being framed for clickjacking; `form-action 'self'` stops a form from
being pointed at another origin.
"""
response.headers.setdefault(
"Content-Security-Policy",
"default-src 'self'; "
"base-uri 'self'; "
"object-src 'none'; "
"frame-ancestors 'none'; "
"form-action 'self'; "
"script-src 'self'; "
"style-src 'self' 'unsafe-inline'; "
"img-src 'self' data: blob: https: http:; "
"font-src 'self' data:; "
"media-src 'self' blob:; "
"connect-src 'self'",
)
# Content-type sniffing turns a file we said was text into whatever the bytes
# look like. The attachment route already sets this; every other response
# deserves it too.
response.headers.setdefault("X-Content-Type-Options", "nosniff")
# Note titles and label names end up in the URL of a search or a label lens,
# and a full Referer would hand them to any site a link preview points at.
response.headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin")
# Nothing here uses a camera, a microphone or a location, so nothing embedded
# in a page should be able to ask on its behalf.
response.headers.setdefault(
"Permissions-Policy", "camera=(), microphone=(), geolocation=(), interest-cohort=()"
)
# HSTS only where the request already arrived over TLS — the same detection
# the session cookie uses. Sending it on a plain-HTTP LAN install would tell
# the browser to refuse the only scheme that install serves.
#
# Scoped to this host: no `includeSubDomains` and no `preload`, both of which
# commit domains this app does not own. A browser still remembers the policy
# for up to a year after the header stops being sent, which is the point of
# it — worth knowing before putting a hostname behind TLS temporarily.
if is_https():
response.headers.setdefault("Strict-Transport-Security", "max-age=31536000")
return response
@app.get("/api/health")
async def health():
return jsonify({"status": "ok", "version": app.config["APP_VERSION"]})
@app.get("/api/config")
async def public_config():
# Public: the login/register screen reads site name + whether signups are open.
async with session_scope() as db:
data = await get_public_config(db)
data["version"] = app.config["APP_VERSION"]
# The sync-protocol handshake (M10.6). A native client reads this BEFORE
# linking — while it still has no token and possibly no account — to decide
# whether it can talk to this server, and which optional features to offer.
data.update(protocol_advertisement())
# Which CLIENTS this server can hand out, if any — the whole set under
# `clients`, plus the older `android_client` key that phones in the field
# still read. Absent rather than null when it has none, so the web UI hides
# a download instead of offering a button that 404s.
data.update(client_advertisement())
return jsonify(data)
@app.get("/", defaults={"path": ""})
@app.get("/<path:path>")
async def spa(path: str):
if path.startswith("api/"):
return jsonify({"error": "not found"}), 404
candidate = os.path.join(STATIC_DIR, path)
if path and os.path.isfile(candidate):
return await send_from_directory(STATIC_DIR, path)
index = os.path.join(STATIC_DIR, "index.html")
if os.path.isfile(index):
return await send_from_directory(STATIC_DIR, "index.html")
return jsonify({"error": "frontend not built"}), 404
return app