From de120ba7ca27ab61418e581f18eb659fd47ec71e Mon Sep 17 00:00:00 2001 From: jcoffey <51408202+jcoffey-dev@users.noreply.github.com> Date: Sun, 20 Sep 2026 16:07:30 -0700 Subject: [PATCH] The source offer is a link to this fork, not a tarball in the image (#2) INBUXA's webmail built its own source into every image: the whole tree, web and server, packed as dist/source.tar.gz with an identity string beside the link naming the exact tree it came from. The sign-in page and Settings > About offered that download. It answered the AGPL precisely -- the source of *this* build, uncommitted work and all -- but it paid for that precision by carrying 2.5 MB of source into production on every deploy, to a repository that is public and already has it. The fork is at github.com/inbuxa/ihasmail-inbuxa; the version shown directly above the link already names the commit the build came from, so the link and the version together say the same thing the archive said. Both links now go there, through the mechanism upstream ihasmail already has and this fork had replaced: the server's SOURCE_URL, read from /api/config on the sign-in page and from the session in About, with web/src/lib/source.ts as the fallback before either answers. That mechanism is better than a hardcoded URL for the deployer who patches this tree -- they set SOURCE_URL and both links follow -- which is the case the AGPL is actually about. The defaults in config.ts, the compose file and .env.example move from the upstream repo to this one, since a build from this tree is a modified ihasmail and its offer is ours. Removed with it: scripts/source-archive.mjs and its type stub, the Vite plugin that ran it, __SOURCE_ID__, and SOURCE_ARCHIVE/SOURCE_ID. The build no longer shells out to git or tar, and nothing is written next to the app. Links to Coffey-Labs/ihasmail that are credit rather than a source offer -- the README's "built on", the translation issue link -- are left alone. No new strings: "AGPL-3.0 source" is unchanged, and the About line keeps its existing {source} placeholder, now filled with the host and path instead of a file name. --- .env.example | 5 +- README.md | 11 ++- docker-compose.yml | 2 +- scripts/source-archive.d.mts | 8 -- scripts/source-archive.mjs | 105 ----------------------- server/src/config.ts | 5 +- web/src/env.d.ts | 2 - web/src/lib/source.ts | 5 +- web/src/lib/version.ts | 7 -- web/src/views/Login.tsx | 13 ++- web/src/views/settings/AboutSettings.tsx | 8 +- web/vite.config.ts | 24 +----- 12 files changed, 31 insertions(+), 164 deletions(-) delete mode 100644 scripts/source-archive.d.mts delete mode 100644 scripts/source-archive.mjs diff --git a/.env.example b/.env.example index 90c23cd..eb81223 100644 --- a/.env.example +++ b/.env.example @@ -73,8 +73,9 @@ APP_NAME=ihasmail # Where this instance's source can be had. ihasmail is AGPL-3.0-or-later, which # asks whoever runs a modified version to offer *that* version's source -- so if # you have patched it, point this at your own tree. Shown on the sign-in page -# and in Settings > About. -SOURCE_URL=https://github.com/Coffey-Labs/ihasmail +# and in Settings > About. INBUXA's webmail is itself a modified ihasmail, so +# the default is this fork. +SOURCE_URL=https://github.com/inbuxa/ihasmail-inbuxa # ---- Settings this installation decides (all optional) ---- # diff --git a/README.md b/README.md index ca70e38..a2bf470 100644 --- a/README.md +++ b/README.md @@ -69,10 +69,13 @@ docker compose up --build -d ## Source code -Every build carries its own source. The sign-in page and Settings › About -link to `source.tar.gz`, the exact tree the running version was built from, -uncommitted work included. It's written next to the app at build time and -named after that tree. +INBUXA webmail is a modified ihasmail, so the AGPL's offer is this fork: +. The sign-in page and Settings › +About link there, beside the version, which names the commit the running build +came from. + +Run your own patched build and that offer becomes yours, not ours: point +`SOURCE_URL` at your tree and both links follow it. ## Development diff --git a/docker-compose.yml b/docker-compose.yml index 5764e30..84b9c1c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -29,7 +29,7 @@ services: APP_SECRET: ${APP_SECRET:?set APP_SECRET in .env (openssl rand -base64 48)} APP_NAME: ${APP_NAME:-ihasmail} BASE_PATH: ${BASE_PATH:-} - SOURCE_URL: ${SOURCE_URL:-https://github.com/Coffey-Labs/ihasmail} + SOURCE_URL: ${SOURCE_URL:-https://github.com/inbuxa/ihasmail-inbuxa} TRUST_PROXY: "1" IMAGE_PROXY: "1" volumes: diff --git a/scripts/source-archive.d.mts b/scripts/source-archive.d.mts deleted file mode 100644 index d394e7e..0000000 --- a/scripts/source-archive.d.mts +++ /dev/null @@ -1,8 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2026 Coffey Labs - * - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -export function sourceIdentity(root: string): { ref: string | null; id: string }; -export function writeSourceArchive(root: string, outFile: string, name: string, identity: { ref: string | null; id: string }): void; diff --git a/scripts/source-archive.mjs b/scripts/source-archive.mjs deleted file mode 100644 index c052ecd..0000000 --- a/scripts/source-archive.mjs +++ /dev/null @@ -1,105 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2026 Coffey Labs - * - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -/* - * The AGPL's offer, for this build: the exact source it was built from, - * written next to the app as `source.tar.gz`, and an identity for it that the - * interface shows beside the download link. - * - * In a git checkout, "exact" includes uncommitted work, new files too: every - * file git doesn't ignore goes into a throwaway index, never the real one, and - * the tree that makes is what gets archived. The identity is HEAD's short id, - * with `+local-` when the tree differs from HEAD's. - * - * In the Docker build there is no git (.dockerignore keeps .git out on - * purpose), so the build context's files are packed as they are, minus what - * .dockerignore already dropped and what the build made. The identity is then - * a hash of those files' paths and contents, so the same source always gets - * the same name. - */ -import { execFileSync } from "node:child_process"; -import { createHash } from "node:crypto"; -import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { dirname, join, relative } from "node:path"; - -const SKIP = new Set(["node_modules", "dist", ".git", "coverage"]); -/** Local state, never source: the session file's folder. */ -const SKIP_PATHS = new Set(["server/data"]); - -function git(args, cwd, env) { - return execFileSync("git", args, { cwd, env: env ?? process.env, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim(); -} - -function hasGit(root) { - try { - return git(["rev-parse", "--is-inside-work-tree"], root) === "true"; - } catch { - return false; - } -} - -/** Every file that isn't build output, dependencies or local data, sorted. */ -function projectFiles(root) { - const out = []; - const walk = (dir) => { - for (const entry of readdirSync(dir, { withFileTypes: true })) { - const full = join(dir, entry.name); - if (SKIP.has(entry.name) || entry.name === "source.tar.gz" || SKIP_PATHS.has(relative(root, full))) continue; - if (entry.isDirectory()) walk(full); - else if (entry.isFile()) out.push(relative(root, full)); - } - }; - walk(root); - return out.sort(); -} - -/** @returns {{ ref: string | null, id: string }} */ -export function sourceIdentity(root) { - if (hasGit(root)) { - const dir = mkdtempSync(join(tmpdir(), "inbuxa-source-")); - try { - const env = { ...process.env, GIT_INDEX_FILE: join(dir, "index") }; - git(["read-tree", "HEAD"], root, env); - git(["add", "--all", "."], root, env); - const tree = git(["write-tree"], root, env); - const head = git(["rev-parse", "--short=12", "HEAD"], root); - return tree === git(["rev-parse", "HEAD^{tree}"], root) - ? { ref: tree, id: head } - : { ref: tree, id: `${head}+local-${tree.slice(0, 12)}` }; - } finally { - rmSync(dir, { recursive: true, force: true }); - } - } - const hash = createHash("sha256"); - for (const file of projectFiles(root)) { - hash.update(file).update("\0").update(readFileSync(join(root, file))).update("\0"); - } - return { ref: null, id: `files-${hash.digest("hex").slice(0, 12)}` }; -} - -/** Write the archive of `identity`'s tree to `outFile`. */ -export function writeSourceArchive(root, outFile, name, identity) { - if (!existsSync(dirname(outFile))) mkdirSync(dirname(outFile), { recursive: true }); - const prefix = `${name}-${identity.id}`; - if (identity.ref) { - execFileSync("git", ["archive", "--format=tar.gz", `--prefix=${prefix}/`, "-o", outFile, identity.ref], { cwd: root }); - return; - } - // Staged under the prefix and packed from there: BusyBox tar, in the Alpine - // image, can't rewrite paths as it packs. - const stage = mkdtempSync(join(tmpdir(), "inbuxa-source-")); - try { - for (const file of projectFiles(root)) { - const to = join(stage, prefix, file); - mkdirSync(dirname(to), { recursive: true }); - copyFileSync(join(root, file), to); - } - execFileSync("tar", ["-czf", outFile, "-C", stage, prefix]); - } finally { - rmSync(stage, { recursive: true, force: true }); - } -} diff --git a/server/src/config.ts b/server/src/config.ts index 1400bd5..ce26650 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -292,9 +292,10 @@ export const config = { * * The AGPL asks whoever *runs* a modified version to offer that version's * source, not the one it was forked from -- so anyone deploying a patched - * ihasmail should point this at their own tree. + * ihasmail should point this at their own tree. ihasmail-inbuxa is itself + * such a tree, so the default is INBUXA's fork. */ - sourceUrl: env("SOURCE_URL", "https://github.com/Coffey-Labs/ihasmail"), + sourceUrl: env("SOURCE_URL", "https://github.com/inbuxa/ihasmail-inbuxa"), host: env("HOST", "0.0.0.0"), port: int("PORT", 8080), /** diff --git a/web/src/env.d.ts b/web/src/env.d.ts index 2e4f777..e49a916 100644 --- a/web/src/env.d.ts +++ b/web/src/env.d.ts @@ -6,5 +6,3 @@ * `scripts/version.mjs`. */ declare const __IHASMAIL_VERSION__: string; -/** ihasmail-inbuxa: the identity of the source this build was made from (scripts/source-archive.mjs). */ -declare const __SOURCE_ID__: string; diff --git a/web/src/lib/source.ts b/web/src/lib/source.ts index 9750935..74e6266 100644 --- a/web/src/lib/source.ts +++ b/web/src/lib/source.ts @@ -4,5 +4,8 @@ * The AGPL asks whoever runs a modified version to offer *that* version's * source. The server says where its own lives, via SOURCE_URL; this is only the * fallback for when it has not been asked yet, or has nothing to say. + * + * ihasmail-inbuxa: INBUXA runs a modified ihasmail, so the offer is INBUXA's + * fork and not the project it came from. */ -export const DEFAULT_SOURCE_URL = "https://github.com/Coffey-Labs/ihasmail"; +export const DEFAULT_SOURCE_URL = "https://github.com/inbuxa/ihasmail-inbuxa"; diff --git a/web/src/lib/version.ts b/web/src/lib/version.ts index ffdf289..7bfb016 100644 --- a/web/src/lib/version.ts +++ b/web/src/lib/version.ts @@ -6,10 +6,3 @@ * parts are what they are. */ export const APP_VERSION = __IHASMAIL_VERSION__; - -/** - * ihasmail-inbuxa: the source this build was made from, which the build writes - * next to the app as `source.tar.gz`. The AGPL's offer links there. - */ -export const SOURCE_ID = __SOURCE_ID__; -export const SOURCE_ARCHIVE = "/source.tar.gz"; diff --git a/web/src/views/Login.tsx b/web/src/views/Login.tsx index 8315576..63d9fdb 100644 --- a/web/src/views/Login.tsx +++ b/web/src/views/Login.tsx @@ -3,7 +3,8 @@ import { Eye, EyeOff, LogIn } from "lucide-react"; import { useSession } from "@/store/session"; import { ApiError } from "@/jmap/client"; import { withBase } from "@/lib/basePath"; -import { APP_VERSION, SOURCE_ARCHIVE, SOURCE_ID } from "@/lib/version"; +import { APP_VERSION } from "@/lib/version"; +import { DEFAULT_SOURCE_URL } from "@/lib/source"; import { DEFAULT_APP_NAME } from "@/lib/brand"; import { t } from "@/lib/i18n"; import { InbuxaWordmark } from "@/ui/InbuxaWordmark"; @@ -11,9 +12,8 @@ import { InbuxaWordmark } from "@/ui/InbuxaWordmark"; export function LoginPage() { const login = useSession((s) => s.login); // The AGPL's offer has to reach everyone who interacts with the app over the - // network, and that includes whoever is looking at this form. ihasmail-inbuxa - // offers the exact source of this build, which the build writes next to the - // app (see SOURCE_ARCHIVE), rather than a repository link that can drift. + // network, and that includes whoever is looking at this form. + const [sourceUrl, setSourceUrl] = useState(DEFAULT_SOURCE_URL); /* * What this instance calls itself. * @@ -41,6 +41,7 @@ export function LoginPage() { .then((r) => (r.ok ? r.json() : null)) .then((c) => { if (!live || !c) return; + if (c.sourceUrl) setSourceUrl(c.sourceUrl as string); if (typeof c.appName === "string" && c.appName.trim()) setAppName(c.appName.trim()); setSignIn(c.signIn === "oauth" ? "oauth" : "password"); setDirect(c.signIn === "oauth" && c.signInDirect === true); @@ -156,9 +157,7 @@ export function LoginPage() {
inbuxa.org {" · "} - {t("AGPL-3.0 source")} - {" "} - ({SOURCE_ID}) + {t("AGPL-3.0 source")}

diff --git a/web/src/views/settings/AboutSettings.tsx b/web/src/views/settings/AboutSettings.tsx index 066dfd4..2dae186 100644 --- a/web/src/views/settings/AboutSettings.tsx +++ b/web/src/views/settings/AboutSettings.tsx @@ -1,7 +1,8 @@ import { useSession } from "@/store/session"; import { useAppName } from "@/lib/brand"; import { client } from "@/jmap/client"; -import { APP_VERSION, SOURCE_ARCHIVE, SOURCE_ID } from "@/lib/version"; +import { APP_VERSION } from "@/lib/version"; +import { DEFAULT_SOURCE_URL } from "@/lib/source"; import { withBase } from "@/lib/basePath"; import { t, tNode } from "@/lib/i18n"; import { InbuxaWordmark } from "@/ui/InbuxaWordmark"; @@ -10,7 +11,8 @@ export function AboutSettings() { const appName = useAppName(); const session = useSession((s) => s.session); const caps = Object.keys(session?.capabilities ?? {}); - // The exact source of this build, written next to the app by the build. + // A deployment running modified code should offer its own source, not ours. + const sourceUrl = session?.ihasmail?.sourceUrl ?? DEFAULT_SOURCE_URL; return (
{/* ihasmail-inbuxa: INBUXA's webmail, built on ihasmail. The version and @@ -26,7 +28,7 @@ export function AboutSettings() { {/* A product name and a version string: neither is a word to translate. */}
{appName} webmail v{APP_VERSION}
{tNode("Built on {project}", { project: ihasmail })}
-
{tNode("AGPL-3.0-or-later · {source}", { source: source.tar.gz ({SOURCE_ID}) })}
+
{tNode("AGPL-3.0-or-later · {source}", { source: {sourceUrl.replace(/^https?:\/\//, "")} })}

{t("Server")}

diff --git a/web/vite.config.ts b/web/vite.config.ts index 44be812..7006acc 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -3,31 +3,11 @@ import react from "@vitejs/plugin-react"; import { fileURLToPath, URL } from "node:url"; import { resolveVersion } from "../scripts/version.mjs"; import { baseUrlOf } from "../scripts/basePath.mjs"; -import { sourceIdentity, writeSourceArchive } from "../scripts/source-archive.mjs"; // Resolved here, at build time: the browser has no git to ask, and neither does // the Docker build, which is handed the answer as IHASMAIL_VERSION instead. const version = resolveVersion(); -/* - * ihasmail-inbuxa: the AGPL's offer for this build. The whole project's source - * (web and server), exactly as built, goes into dist/source.tar.gz, and its - * identity into the app so the download link can name it. See - * scripts/source-archive.mjs. - */ -const projectRoot = fileURLToPath(new URL("..", import.meta.url)); -const source = sourceIdentity(projectRoot); - -function sourceArchive(): Plugin { - return { - name: "inbuxa-source-archive", - apply: "build", - closeBundle() { - writeSourceArchive(projectRoot, fileURLToPath(new URL("./dist/source.tar.gz", import.meta.url)), "ihasmail-inbuxa", source); - }, - }; -} - /* * Where the app is mounted. Unlike everything else ihasmail is told, this one * cannot wait until the process starts: the hashed asset URLs are written into @@ -79,8 +59,8 @@ function assetList(): Plugin { export default defineConfig({ base, - plugins: [react(), assetList(), sourceArchive()], - define: { __IHASMAIL_VERSION__: JSON.stringify(version), __SOURCE_ID__: JSON.stringify(source.id) }, + plugins: [react(), assetList()], + define: { __IHASMAIL_VERSION__: JSON.stringify(version) }, resolve: { alias: { "@": fileURLToPath(new URL("./src", import.meta.url)) }, },