AGPL-3.0-only, a source link in the app, and everything on dev since #5 #6

Merged
bvandeusen merged 214 commits from dev into main 2026-10-21 21:40:00 -04:00
7 changed files with 442 additions and 18 deletions
Showing only changes of commit 871878de41 - Show all commits
+5
View File
@@ -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
+184 -18
View File
@@ -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 <url> | sh -s -- --channel dev
The options can also come from TS_SERVER and TS_CHANNEL. Through a pipe, pass
them after `--`: curl -fsSL <url> | 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/<platform>` 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
@@ -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 {
</li>
</ul>
</div>
<div v-if="hasLinux" class="mt-4">
<p class="text-xs text-neutral-400">
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.
</p>
<!-- select-all: one click takes the whole command, which is the only thing
anyone does with it. -->
<code
class="mt-1 block select-all break-all rounded bg-neutral-100 px-2 py-1 text-xs text-neutral-700 dark:bg-neutral-900 dark:text-neutral-200"
>{{ installCommand }}</code
>
</div>
</section>
</template>
+2
View File
@@ -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:
+105
View File
@@ -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")
+3
View File
@@ -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",
+123
View File
@@ -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