From d1f8d1f56131e61a1fc714043efae0d13f6564ff Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 21 Oct 2026 21:20:07 -0400 Subject: [PATCH] The app links to its source, from an admin setting (#6228) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #6228 "The app offers its source: a Source link from an admin setting (AGPL §13)". The AGPL requires a modified copy run for others over a network to offer them its source; Forge's #2282 is the shape followed. - Settings → General gains "Source code" (`source_url`), defaulting to the public repo so a stock install makes the offer. Empty hides it. - It is an http(s)-only URL setting, refused otherwise on save, and `safe_link` refuses anything else again when it is published, because the web renders it as an href. - /api/config carries it; the web shows "Source" beside the build number at the foot of the sidebar. The offline desktop's config has no such field, so the link stays hidden there. - ci.yml's client-bake comments no longer call the forge private; the repo has been public since #2091. Co-Authored-By: Claude Opus 5.5 --- .forgejo/workflows/ci.yml | 9 ++++----- frontend/src/components/AppShell.vue | 21 ++++++++++++++------- frontend/src/stores/config.ts | 6 ++++++ src/inkwell/settings.py | 18 ++++++++++++++++++ tests/test_retention.py | 2 ++ tests/test_settings.py | 19 ++++++++++++++++++- 6 files changed, 62 insertions(+), 13 deletions(-) diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index 4b94394..e3825bc 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -332,17 +332,16 @@ jobs: docker builder prune --keep-storage 5g -f || true # Bake EVERY client in, on every image build, so a self-hoster gets a working - # app for their machine from the server holding their notes — without an - # account on this forge, which is private, so its release pages serve nobody - # but the forge's own users. + # app for their machine from the server holding their notes, with no trip to a + # release page on the forge. # # ~104 MB on top of the ~85 MB image, almost all of it the AppImage. That is # the price of the product being complete, and the AppImage is not # optional within it: it is the ONLY bundle that can replace itself in place, # so a server without one cannot serve in-app updates to anyone. # - # Fetched by the JOB, not by the Dockerfile: the releases are private, and a - # token used inside a build lands in the context or a layer. + # Fetched by the JOB, not by the Dockerfile, so the token it may use never lands + # in the build context or a layer. # # NEVER fails the build — see the script. A platform with nothing published # means the server advertises nothing for it and the UI hides that download, diff --git a/frontend/src/components/AppShell.vue b/frontend/src/components/AppShell.vue index b4bbf8e..2163664 100644 --- a/frontend/src/components/AppShell.vue +++ b/frontend/src/components/AppShell.vue @@ -471,13 +471,20 @@ async function signOut() { -

- {{ buildVersion }} -

+ gets copied into bug reports. Beside it, the AGPL source offer, when the + server names a source address. --> +
+

{{ buildVersion }}

+ Source +
diff --git a/frontend/src/stores/config.ts b/frontend/src/stores/config.ts index 3c4e3f8..70c1c7a 100644 --- a/frontend/src/stores/config.ts +++ b/frontend/src/stores/config.ts @@ -46,6 +46,9 @@ export interface PublicConfig { // The shortest password an account may have (the server's MIN_PASSWORD_LEN). // Absent on an older server and on the offline desktop, which has no accounts. min_password_length?: number; + // Where this server's source code is, for the AGPL source offer; "" hides the link. + // Absent on an older server and on the offline desktop. + source_url?: string; // Every client this server holds, keyed by platform id. Absent on a server that // holds none, and absent on the desktop's own offline config — the Tauri build // answers `config_get` locally and has no clients to hand out. @@ -68,6 +71,7 @@ export const useConfigStore = defineStore("config", () => { const passwordResetByEmail = ref(false); // The server's own number; 8 only until /api/config answers, or if it never does. const minPasswordLength = ref(8); + const sourceUrl = ref(""); const loaded = ref(false); // Why the server would refuse this password, said before asking it; null when it @@ -90,6 +94,7 @@ export const useConfigStore = defineStore("config", () => { clients.value = cfg.clients ?? {}; passwordResetByEmail.value = cfg.password_reset_by_email ?? false; minPasswordLength.value = cfg.min_password_length ?? 8; + sourceUrl.value = cfg.source_url ?? ""; } catch { // Keep defaults if the config endpoint is unreachable. } finally { @@ -111,6 +116,7 @@ export const useConfigStore = defineStore("config", () => { clients, passwordResetByEmail, minPasswordLength, + sourceUrl, passwordRefusal, loaded, load, diff --git a/src/inkwell/settings.py b/src/inkwell/settings.py index 586de34..92183f5 100644 --- a/src/inkwell/settings.py +++ b/src/inkwell/settings.py @@ -65,6 +65,16 @@ REGISTRY: list[SettingDef] = [ "General", url=True, ), + SettingDef( + "source_url", + "string", + "https://git.fabledsword.com/bvandeusen/inkwell", + "Source code", + "Where this server's source code is published, linked from the app. The AGPL " + "requires a modified copy to offer its users its own source. Leave empty to hide the link.", + "General", + url=True, + ), SettingDef( "allow_registration", "bool", @@ -439,9 +449,17 @@ async def get_public_config(db) -> dict: # Whether the sign-in screen offers "Forgot password?". "password_reset_by_email": await mail_configured(db), "min_password_length": MIN_PASSWORD_LEN, + "source_url": safe_link(await get_setting(db, "source_url")), } +def safe_link(value: str) -> str: + """`value` if it is an http(s) address, else "". The web renders the result as an + href, so a stored `javascript:` value would run in every visitor's session; saving + refuses one, and this refuses it again for a row written some other way.""" + return value if value.startswith(("http://", "https://")) else "" + + async def mail_configured(db) -> bool: """Whether the server can send email: an SMTP server, someone to send as, and a public address for the links to point at. The last is not optional: built from diff --git a/tests/test_retention.py b/tests/test_retention.py index b7be84a..0bcc601 100644 --- a/tests/test_retention.py +++ b/tests/test_retention.py @@ -66,6 +66,8 @@ async def test_public_config_publishes_the_window(): cfg = await get_public_config(_NoRows()) assert cfg["trash_retention_days"] == 30 + # The AGPL source offer is on by default, so a stock install makes it. + assert cfg["source_url"] == "https://git.fabledsword.com/bvandeusen/inkwell" @pytest.mark.parametrize( diff --git a/tests/test_settings.py b/tests/test_settings.py index faa3776..7f989cd 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -1,4 +1,4 @@ -from inkwell.settings import MAX_BODY_MB, REGISTRY, validate_updates +from inkwell.settings import MAX_BODY_MB, REGISTRY, safe_link, validate_updates def test_registry_has_expected_keys(): @@ -70,6 +70,23 @@ def test_the_public_address_is_an_http_url_without_a_trailing_slash(): assert clean == {} and error +def test_the_source_link_takes_only_an_http_url(): + assert validate_updates({"source_url": "https://git.example.test/me/inkwell/"}) == ( + {"source_url": "https://git.example.test/me/inkwell"}, + None, + ) + assert validate_updates({"source_url": ""}) == ({"source_url": ""}, None) + clean, error = validate_updates({"source_url": "javascript:alert(1)"}) + assert clean == {} and error + + +def test_a_link_that_is_not_http_is_never_published(): + """A stored value reaches an href; one that bypassed validation must still not.""" + assert safe_link("https://git.example.test/me/inkwell") == "https://git.example.test/me/inkwell" + assert safe_link("javascript:alert(1)") == "" + assert safe_link("") == "" + + def test_choice_labels_name_every_choice(): """The UI pairs labels with choices by position, so a label list of another length would put one choice's name on another."""