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)) }, },