diff --git a/Dockerfile b/Dockerfile index 6264949..32a4867 100644 --- a/Dockerfile +++ b/Dockerfile @@ -43,6 +43,11 @@ COPY alembic/ alembic/ # rest (client_dist.py). COPY client/ src/inkwell/client/ +# The desktop installer, served at /install.sh with this server's own address +# written into it (installer.py). The SAME script the forge serves raw, copied rather +# than moved so the operator's documented forge URL keeps working. +COPY desktop/packaging/install.sh src/inkwell/install.sh + ENV PYTHONPATH=/app/src ARG BUILD_VERSION=dev diff --git a/desktop/packaging/install.sh b/desktop/packaging/install.sh index 0cb1c8a..effc940 100755 --- a/desktop/packaging/install.sh +++ b/desktop/packaging/install.sh @@ -2,8 +2,16 @@ # # Inkwell desktop — one-command Linux installer. # +# curl -fsSL https://notes.example.com/install.sh | sh (from your server) # curl -fsSL https://git.fabledsword.com/bvandeusen/inkwell/raw/branch/dev/desktop/packaging/install.sh | sh # +# FROM A SERVER, the script installs the build that server holds — the same one its +# Account page offers — and asks for a device token to download it with, because the +# bytes are not for anyone who can reach the port. The server writes its own address +# into the copy it serves (src/inkwell/installer.py), so nothing needs editing. +# Everything below about channels is the FORGE path; a server serves one build, and +# which channel that is was decided when its image was built. +# # Two channels, the SAME two the app's own updater offers (src-tauri/src/update.rs): # stable (default) — the rolling build from every merge to `main`. # dev — the rolling build from every green push to `dev`. @@ -46,25 +54,43 @@ usage() { cat <<'USAGE' Inkwell desktop installer. - install.sh [--channel stable|dev] + install.sh [--server URL] [--channel stable|dev] - --channel stable newest build from main (default) - --channel dev rolling build from the latest green push to `dev` + --server URL install from this Inkwell server (set already when the + script came from one) + --channel stable from the forge: newest build from main (default) + --channel dev from the forge: rolling build from the latest green push to `dev` -h, --help this text -The channel can also come from TS_CHANNEL. Through a pipe, pass options after -`--`: curl -fsSL | sh -s -- --channel dev +The options can also come from TS_SERVER and TS_CHANNEL. Through a pipe, pass +them after `--`: curl -fsSL | sh -s -- --channel dev + +From a server, the download needs a device token: make one in the web app under +Account → Linked devices and paste it when asked. A token typed at the prompt is +revoked again once the download is done. TS_TOKEN supplies one without a prompt, +and is left alone, since whoever set it is managing it. USAGE } +# The address of the server that served this script. Written by that server +# (src/inkwell/installer.py rewrites exactly this line) and empty in the copy the +# forge serves, which is what keeps the forge path the default there. +TS_SERVER_DEFAULT="" + # --- channel ---------------------------------------------------------------- channel="${TS_CHANNEL:-stable}" +channel_asked="${TS_CHANNEL:-}" +server="${TS_SERVER:-$TS_SERVER_DEFAULT}" while [ $# -gt 0 ]; do case "$1" in --channel) [ $# -ge 2 ] || die "--channel needs a value (stable or dev)." - channel="$2"; shift 2 ;; - --channel=*) channel="${1#*=}"; shift ;; + channel="$2"; channel_asked="$2"; shift 2 ;; + --channel=*) channel="${1#*=}"; channel_asked="$channel"; shift ;; + --server) + [ $# -ge 2 ] || die "--server needs an address (https://…)." + server="$2"; shift 2 ;; + --server=*) server="${1#*=}"; shift ;; -h | --help) usage; exit 0 ;; *) die "unknown option: $1 (try --help)" ;; esac @@ -76,6 +102,25 @@ esac have curl || die "curl is required." +# --- server address --------------------------------------------------------- +# Checked HERE as well as by the server that wrote it: this value goes into every +# URL below, and a script should not trust a string because of where it came from. +# The same shape the server enforces — a scheme, then only characters that cannot +# mean anything to a shell or a URL parser beyond what they say. +server="${server%/}" +if [ -n "$server" ]; then + case "$server" in + http://?* | https://?*) : ;; + *) die "the server address must start with http:// or https:// (got: $server)." ;; + esac + case "$server" in + *[!A-Za-z0-9:/._~-]*) die "the server address has characters an address cannot have (got: $server)." ;; + esac + if [ -n "$channel_asked" ]; then + say "Note: a server serves the one build it holds; --channel only applies to the forge." + fi +fi + # --- architecture gate ------------------------------------------------------ # Only x86_64 is built today; arm64 will be added when the CI matrix grows. The # release-asset naming carries the arch, but since only one arch ships now we @@ -86,7 +131,99 @@ case "$arch" in *) die "Inkwell ships x86_64 Linux builds only right now (this machine: $arch)." ;; esac -# --- resolve the release for this channel ----------------------------------- +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT INT TERM + +# Every download goes through here, so the server path's token is sent on all of them +# and on nothing else. From a FILE, not the command line, where any user on the +# machine could read it out of `ps` while curl runs. +fetch() { + if [ -n "$server" ]; then + curl -fSL -H @"$tmp/auth" -o "$2" "$1" + else + curl -fSL -o "$2" "$1" + fi +} + +# The download against the digest its server published, BEFORE it reaches pacman, +# dpkg or a menu entry: a truncated file installs as something broken rather than +# failing. The forge path publishes no digest per bundle, so it passes an empty one +# and this checks nothing there; that is the forge path exactly as it always was. +verify() { + [ -n "$2" ] || return 0 + if have sha256sum; then + got="$(sha256sum "$1" | cut -d' ' -f1)" + elif have shasum; then + got="$(shasum -a 256 "$1" | cut -d' ' -f1)" + else + die "can't check the download: neither sha256sum nor shasum is installed." + fi + [ "$got" = "$2" ] || die "the download doesn't match what the server published (sha256 $got, expected $2). Nothing was installed." +} + +pkg_sha=""; deb_sha=""; appimage_sha="" + +if [ -n "$server" ]; then +# --- resolve from a server ---------------------------------------------------- +# `/api/client/` is public, so what the server holds is known before any +# token is asked for. The download URL is BUILT here from the platform id, never read +# from the reply — the same rule the apps follow (core/src/sync/client.rs): a reply +# that named some other host would otherwise be fetched, with the token attached. +say "Finding the Inkwell build $server holds…" + +# One string field out of the flat JSON object the server returns. sed rather than +# a parser, for the same reason as the forge path's grep: no jq on most machines. +jfield() { + printf '%s' "$1" | sed -n "s/.*\"$2\"[[:space:]]*:[[:space:]]*\"\([^\"]*\)\".*/\1/p" | head -1 +} +meta() { curl -fsS "$server/api/client/$1" 2>/dev/null || true; } + +pkg_meta="$(meta linux-pacman)" +deb_meta="$(meta linux-deb)" +appimage_meta="$(meta linux-appimage)" +[ -n "$pkg_meta" ] || [ -n "$deb_meta" ] || [ -n "$appimage_meta" ] || + die "$server has no Linux client to install (or isn't an Inkwell server — is the address right?)." + +pkg_url=""; deb_url=""; appimage_url="" +if [ -n "$pkg_meta" ]; then + pkg_url="$server/api/client/linux-pacman/download"; pkg_sha="$(jfield "$pkg_meta" sha256)" +fi +if [ -n "$deb_meta" ]; then + deb_url="$server/api/client/linux-deb/download"; deb_sha="$(jfield "$deb_meta" sha256)" +fi +if [ -n "$appimage_meta" ]; then + appimage_url="$server/api/client/linux-appimage/download"; appimage_sha="$(jfield "$appimage_meta" sha256)" +fi +# One build, four bundles: any of them names the version. +version="$(jfield "$pkg_meta$deb_meta$appimage_meta" version)" +say "Installing ${version:-unknown} from $server" + +# The token, from the environment or from the person at the keyboard. Read from the +# TERMINAL, not stdin: through `curl | sh`, stdin is this script. +token="${TS_TOKEN:-}" +prompted="" +if [ -z "$token" ]; then + { [ -r /dev/tty ] && [ -w /dev/tty ]; } || + die "the download needs a device token and there's no terminal to ask on; set TS_TOKEN." + printf 'A device token is needed to download from %s.\n' "$server" > /dev/tty + printf 'Make one in the web app under Account → Linked devices (it is revoked once the download is done).\n' > /dev/tty + printf 'Token: ' > /dev/tty + stty -echo < /dev/tty 2>/dev/null || true + IFS= read -r token < /dev/tty || true + stty echo < /dev/tty 2>/dev/null || true + printf '\n' > /dev/tty + prompted=1 +fi +[ -n "$token" ] || die "no token given." +# The server's tokens are URL-safe base64. Anything else is a paste gone wrong, and a +# newline in it would be a second header. +case "$token" in + *[!A-Za-z0-9_-]*) die "that doesn't look like a device token (they're letters, digits, - and _)." ;; +esac +( umask 077; printf 'Authorization: Bearer %s\n' "$token" > "$tmp/auth" ) + +else +# --- resolve from the forge, for this channel -------------------------------- say "Finding the latest Inkwell build on the $channel channel…" # ONE lookup, both channels. Each is a release whose tag never moves and whose assets @@ -117,9 +254,19 @@ version="$(printf '%s' "$json" | grep -oE '"tag_name":"[^"]+"' | head -1 | sed - [ -n "$appimage_url" ] || [ -n "$deb_url" ] || [ -n "$pkg_url" ] || die "the $channel release (${version:-unknown}) has no installable Linux asset." say "Installing ${version:-unknown} from the $channel channel" +fi -tmp="$(mktemp -d)" -trap 'rm -rf "$tmp"' EXIT INT TERM +# A token typed at the prompt was made for this one install; once the bytes are here +# it has nothing left to do, so it goes. Best-effort: the install does not depend on +# it, and the person is told if it is still live. +revoke_token() { + [ -n "$server" ] && [ -n "$prompted" ] || return 0 + if curl -fsS -o /dev/null -X DELETE -H @"$tmp/auth" "$server/api/auth/devices/self" 2>/dev/null; then + say "Revoked the download token." + else + say "Couldn't revoke the download token; remove it under Account → Linked devices." + fi +} # Tell the app which channel it was installed from. The installer is the only thing # that knows, and without this the app kept its own `stable` default and a dev install @@ -133,11 +280,21 @@ trap 'rm -rf "$tmp"' EXIT INT TERM # # The directory is Tauri's app-data dir for identifier com.fabledsword.inkwell; # both sides hardcode it, so a change to the identifier has to change both. +# +# From a server, the sibling `install-server` is written instead: which server the app +# came from, for the updater to follow (milestone 325, step 6). The channel marker is +# left alone on that path, because a server's channel is whatever its image was built +# with and nothing here can know it. record_channel() { marker_dir="${XDG_DATA_HOME:-$HOME/.local/share}/com.fabledsword.inkwell" # Best-effort: a failure here costs the channel setting, not the install, and a # native install run as root would only be writing into root's home anyway. - mkdir -p "$marker_dir" 2>/dev/null && printf '%s\n' "$channel" > "$marker_dir/install-channel" 2>/dev/null || true + mkdir -p "$marker_dir" 2>/dev/null || return 0 + if [ -n "$server" ]; then + printf '%s\n' "$server" > "$marker_dir/install-server" 2>/dev/null || true + else + printf '%s\n' "$channel" > "$marker_dir/install-channel" 2>/dev/null || true + fi } # Both native paths install system-wide, so they need root. Resolved once here @@ -155,7 +312,9 @@ need_root() { # than letting someone discover it from a greyed-out button. native_update_note() { printf ' A package-manager install can'\''t update itself in-app.\n' - if [ "$channel" = "dev" ]; then + if [ -n "$server" ]; then + printf ' Re-run this script from %s to move to the build it holds.\n' "$server" + elif [ "$channel" = "dev" ]; then printf ' Re-run this script with --channel dev to move to a newer dev build.\n' else printf ' Re-run this script to move to a newer release.\n' @@ -171,8 +330,11 @@ if have pacman && [ -n "$pkg_url" ]; then say "Arch-family system detected — installing the native pacman package" # Keep the published filename: pacman -U expects a *.pkg.tar.* name and refuses # a file that doesn't look like a package, whatever its actual contents. - pkg_file="$tmp/$(basename "$pkg_url")" - curl -fSL -o "$pkg_file" "$pkg_url" + # From a server the URL ends in `/download`, so the name comes from the platform. + if [ -n "$server" ]; then pkg_file="$tmp/inkwell.pkg.tar.zst"; else pkg_file="$tmp/$(basename "$pkg_url")"; fi + fetch "$pkg_url" "$pkg_file" + verify "$pkg_file" "$pkg_sha" + revoke_token need_root $sudo pacman -U --noconfirm "$pkg_file" record_channel @@ -184,7 +346,9 @@ fi # --- native .deb path (Debian/Ubuntu) --------------------------------------- if have dpkg && have apt-get && [ -n "$deb_url" ]; then say "Debian-family system detected — installing the native .deb" - curl -fSL -o "$tmp/inkwell.deb" "$deb_url" + fetch "$deb_url" "$tmp/inkwell.deb" + verify "$tmp/inkwell.deb" "$deb_sha" + revoke_token need_root # apt-get resolves the .deb's dependencies (webkit2gtk etc.). dpkg is the # fallback if this apt is too old for local-file installs — it leaves the deps @@ -207,8 +371,10 @@ say "Installing the de-bundled AppImage (user-local, no sudo)" apps_dir="$HOME/Applications" dest="$apps_dir/Inkwell.AppImage" mkdir -p "$apps_dir" -say "Downloading $(basename "$appimage_url")…" -curl -fSL -o "$tmp/Inkwell.AppImage" "$appimage_url" +say "Downloading the AppImage…" +fetch "$appimage_url" "$tmp/Inkwell.AppImage" +verify "$tmp/Inkwell.AppImage" "$appimage_sha" +revoke_token chmod +x "$tmp/Inkwell.AppImage" mv -f "$tmp/Inkwell.AppImage" "$dest" @@ -257,7 +423,7 @@ say "Installed to $dest" printf ' Launch it from your application menu, or run \033[1minkwell\033[0m' printf ' (if ~/.local/bin is on your PATH).\n' # This is the one path where the app can update itself, so say what it will follow. -if [ "$channel" = "dev" ]; then +if [ -z "$server" ] && [ "$channel" = "dev" ]; then printf ' In-app updates will follow the \033[1mdev\033[0m channel.' printf ' Change it in Sync → App updates.\n' fi diff --git a/frontend/src/components/ClientDownloads.vue b/frontend/src/components/ClientDownloads.vue index 1047e9a..8a70e33 100644 --- a/frontend/src/components/ClientDownloads.vue +++ b/frontend/src/components/ClientDownloads.vue @@ -101,6 +101,13 @@ const missingPlatform = computed(() => !lead.value.length && (family === "mac" || family === "ios") ? FAMILY_TITLE[family] : "", ); +// The one-command Linux install (milestone 325 step 5), shown wherever the server +// holds a Linux client to install. Against the address this page was reached on, +// which is the one the person can evidently reach; the script the server sends +// back carries Settings → Public address when that is set. +const hasLinux = computed(() => Object.keys(config.clients).some((id) => id.startsWith("linux-"))); +const installCommand = `curl -fsSL ${window.location.origin}/install.sh | sh`; + // One decimal below 10 MB, none above: these sit in one list where a 2.7 MB // package and a 95 MB AppImage are compared, and "3 MB" next to "95 MB" loses the // only distinction that matters at the small end. @@ -163,5 +170,18 @@ function readableSize(bytes: number): string { + +
+

+ Or on Linux, from a terminal. It picks the right package for the system and asks + for a token from Linked devices to download it with. +

+ + {{ installCommand }} +
diff --git a/src/inkwell/app.py b/src/inkwell/app.py index ff8c83e..87d968f 100644 --- a/src/inkwell/app.py +++ b/src/inkwell/app.py @@ -14,6 +14,7 @@ from quart.sessions import SecureCookieSessionInterface from .accounts_api import bp as accounts_bp from .auth import bp as auth_bp from .client_dist import advertisement as client_advertisement, bp as client_bp +from .installer import bp as installer_bp from .groups_api import bp as groups_bp from .config import Config from .db import session_scope @@ -100,6 +101,7 @@ def create_app() -> Quart: app.register_blueprint(shares_bp) app.register_blueprint(sync_bp) app.register_blueprint(client_bp) + app.register_blueprint(installer_bp) @app.before_serving async def _bootstrap() -> None: diff --git a/src/inkwell/installer.py b/src/inkwell/installer.py new file mode 100644 index 0000000..4f0d93b --- /dev/null +++ b/src/inkwell/installer.py @@ -0,0 +1,105 @@ +"""`GET /install.sh` — the desktop installer, pointed at the server that served it. + + curl -fsSL https://notes.example.com/install.sh | sh + +## Why the server templates it + +`curl | sh` gives a script no way to learn where it came from: no argv, no referrer, +nothing. So the server writes its own address into the copy it hands out, and the +script installs from there without being told. Anything else means a self-hoster +editing a URL by hand, which is the friction `client_dist.py` exists to remove. + +## Which way it fails + +A server that can put arbitrary text into a script a person pipes to `sh` is the +whole attack. So the substitution is ONE variable, `TS_SERVER_DEFAULT`, given a value +that has already passed [SAFE_ADDRESS] — no quote, no space, no `$`, nothing a shell +reads as anything but characters — and the script checks the shape again before it +uses it. If the address cannot pass, the route refuses rather than serving a script +that would install from somewhere else. + +## Where the script comes from + +ONE script. The forge serves `desktop/packaging/install.sh` raw, and that is how the +operator installs. The image carries a copy beside this module (the Dockerfile copies +it in), and a source checkout reads the original. Same bytes either way; this module +changes exactly one line of them. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +from quart import Blueprint, Response, request + +from .proxy import is_https +from .responses import json_error +from .settings import live + +bp = Blueprint("installer", __name__) + +# The copy the image carries (Dockerfile), and the original in a source checkout. +BAKED_SCRIPT = Path(__file__).resolve().parent / "install.sh" +SOURCE_SCRIPT = Path(__file__).resolve().parents[2] / "desktop" / "packaging" / "install.sh" + +# The one line the server rewrites. The script carries it exactly like this, and +# `render` refuses a script that does not, rather than serving one with no address in. +PLACEHOLDER = 'TS_SERVER_DEFAULT=""' + +# What a server address may look like before it goes into a shell script: a scheme, +# a host name or IPv4 address, an optional port and an optional path. Deliberately +# narrower than a URL. Every character it admits is inert inside single quotes, and +# an address it turns away (an IPv6 literal, a path with `%` in it) is answered by +# setting Settings → Public address, not by widening this. +SAFE_ADDRESS = re.compile(r"https?://[A-Za-z0-9.-]+(:[0-9]{1,5})?(/[A-Za-z0-9._~/-]*)?") + + +def script_text() -> str | None: + """The installer as published, or None when this server has no copy of it.""" + for path in (BAKED_SCRIPT, SOURCE_SCRIPT): + try: + return path.read_text(encoding="utf-8") + except OSError: + continue + return None + + +def server_address(public_url: str, scheme: str, host: str) -> str | None: + """Where the installer should fetch from, or None if no safe answer exists. + + Settings → Public address when it is set: it is the address the operator says + people use, and behind a reverse proxy the request's own Host may be an internal + one. Otherwise the address this request arrived on, which is the one the person + running `curl` just reached. + """ + address = (public_url or f"{scheme}://{host}").rstrip("/") + return address if SAFE_ADDRESS.fullmatch(address) else None + + +def render(script: str, address: str) -> str | None: + """`script` with `address` as its default server, or None if it cannot be done safely.""" + if not SAFE_ADDRESS.fullmatch(address) or script.count(PLACEHOLDER) != 1: + return None + return script.replace(PLACEHOLDER, f"TS_SERVER_DEFAULT='{address}'") + + +@bp.get("/install.sh") +async def install_script(): + """Public, like the client metadata: the script holds nothing secret, and the + bytes it goes on to fetch are behind a device token.""" + script = script_text() + if script is None: + return json_error("this server has no installer", 404) + address = server_address(live("public_url"), "https" if is_https() else "http", request.host) + if address is None: + return json_error( + "this server cannot state its own address safely; set Settings → Public address", + 500, + ) + body = render(script, address) + if body is None: + return json_error("this server's installer is not the shape it expects", 500) + # Plain text so a browser shows it rather than downloading it: "read it before you + # pipe it" is the script's own advice. + return Response(body, mimetype="text/plain") diff --git a/src/inkwell/settings.py b/src/inkwell/settings.py index a05e446..44f9de0 100644 --- a/src/inkwell/settings.py +++ b/src/inkwell/settings.py @@ -316,6 +316,9 @@ async def get_admin_settings(db) -> list[dict]: # and again whenever an admin saves. Same live-update contract `session_ttl_days` # already has in settings_api.py. _LIVE_KEYS = ( + # Read by GET /install.sh, which writes it into the installer it serves + # (installer.py) and must not need a database to answer. + "public_url", "trusted_proxy_hops", "signin_limit_per_account", "signin_limit_per_address", diff --git a/tests/test_installer.py b/tests/test_installer.py new file mode 100644 index 0000000..4430a7c --- /dev/null +++ b/tests/test_installer.py @@ -0,0 +1,123 @@ +import pytest + +from inkwell import installer, settings +from inkwell.app import create_app +from inkwell.installer import PLACEHOLDER, SOURCE_SCRIPT, render, server_address + +# DB-free, like test_client_dist: the route reads `public_url` from the live cache +# rather than from the database, which is what makes it testable here. + +SCRIPT = f"#!/bin/sh\nset -eu\n{PLACEHOLDER}\nserver=\"${{TS_SERVER:-$TS_SERVER_DEFAULT}}\"\n" + + +@pytest.fixture(autouse=True) +def _defaults(): + settings.reset_live() + yield + settings.reset_live() + + +@pytest.fixture +def app(): + return create_app() + + +def test_the_published_script_carries_the_line_the_server_rewrites_exactly_once(): + """The forge-served script and the server-served one are the same file; this is + the one line that differs. Edit it and the server stops serving rather than + serving a script with no address in it — this test is what says so first.""" + assert SOURCE_SCRIPT.read_text(encoding="utf-8").count(PLACEHOLDER) == 1 + + +def test_render_writes_the_address_into_the_one_variable(): + body = render(SCRIPT, "https://notes.example.com") + assert "TS_SERVER_DEFAULT='https://notes.example.com'" in body + assert PLACEHOLDER not in body + # Nothing else moved. + assert body.replace("TS_SERVER_DEFAULT='https://notes.example.com'", PLACEHOLDER) == SCRIPT + + +@pytest.mark.parametrize( + "address", + [ + "https://notes.example.com/sub/path", + "http://192.168.1.20:5000", + "https://notes.example.com:8443", + ], +) +def test_an_ordinary_address_is_accepted(address): + assert render(SCRIPT, address) is not None + + +@pytest.mark.parametrize( + "address", + [ + # Each is a way out of the single quotes, or a second command. + "https://x.example.com'; rm -rf ~; '", + "https://x.example.com/$(id)", + "https://x.example.com/`id`", + "https://x.example.com\nrm -rf ~", + "https://x.example.com/a b", + "https://x.example.com/;id", + # Not an address at all. + "ftp://x.example.com", + "x.example.com", + "", + ], +) +def test_an_address_that_could_mean_something_to_a_shell_is_refused(address): + assert render(SCRIPT, address) is None + + +@pytest.mark.parametrize("script", ["#!/bin/sh\n", SCRIPT + PLACEHOLDER + "\n"]) +def test_a_script_without_exactly_one_placeholder_is_not_served(script): + assert render(script, "https://notes.example.com") is None + + +def test_the_public_address_setting_beats_the_request_host(): + """Behind a reverse proxy the Host this request arrived with may be an internal + one; Settings → Public address is what the operator says people use.""" + assert server_address("https://notes.example.com", "http", "inkwell:5000") == "https://notes.example.com" + + +def test_without_the_setting_the_request_address_is_used(): + assert server_address("", "https", "notes.example.com") == "https://notes.example.com" + + +def test_a_trailing_slash_is_dropped(): + assert server_address("https://notes.example.com/", "http", "ignored") == "https://notes.example.com" + + +def test_a_host_header_that_is_not_an_address_yields_none(): + assert server_address("", "http", "evil.example.com'$(id)") is None + + +async def test_the_route_serves_the_script_pointed_at_this_server(app): + resp = await app.test_client().get("/install.sh", headers={"Host": "notes.example.com"}) + assert resp.status_code == 200 + assert resp.mimetype == "text/plain" + body = await resp.get_data(as_text=True) + assert "TS_SERVER_DEFAULT='http://notes.example.com'" in body + assert body.startswith("#!/bin/sh") + + +async def test_the_route_uses_the_public_address_when_one_is_set(app, monkeypatch): + monkeypatch.setitem(settings._live, "public_url", "https://notes.example.com") + resp = await app.test_client().get("/install.sh", headers={"Host": "inkwell:5000"}) + assert "TS_SERVER_DEFAULT='https://notes.example.com'" in await resp.get_data(as_text=True) + + +async def test_the_route_refuses_rather_than_serve_an_unsafe_address(app, monkeypatch): + # A Public address saved before it was validated this strictly: Settings only + # checks the scheme, so this is reachable. + monkeypatch.setitem(settings._live, "public_url", "https://notes.example.com/'$(id)'") + resp = await app.test_client().get("/install.sh", headers={"Host": "notes.example.com"}) + assert resp.status_code == 500 + assert "TS_SERVER_DEFAULT" not in await resp.get_data(as_text=True) + + +async def test_a_server_with_no_copy_of_the_script_404s(app, monkeypatch, tmp_path): + monkeypatch.setattr(installer, "BAKED_SCRIPT", tmp_path / "absent") + monkeypatch.setattr(installer, "SOURCE_SCRIPT", tmp_path / "also-absent") + resp = await app.test_client().get("/install.sh") + assert resp.status_code == 404