From 0e39b199a19a47c71dae203f362b08ea2585e41b Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Fri, 2 Oct 2026 22:10:29 +0200 Subject: [PATCH] feat: link the running build's source from the About card #1161 --- .env.example | 6 ++++++ app/api/config/route.ts | 2 ++ components/settings/about-data-settings.tsx | 9 ++++++--- hooks/use-config.ts | 4 ++++ lib/__tests__/config-route.test.ts | 18 +++++++++++++++++- lib/__tests__/fixtures/config.ts | 1 + lib/__tests__/lite-config.test.ts | 7 +++++++ lib/admin/types.ts | 4 ++++ lib/config-validation.ts | 16 ++++++++++++++++ lib/lite-config.ts | 3 +++ locales/ar/common.json | 3 ++- locales/ca/common.json | 3 ++- locales/cs/common.json | 3 ++- locales/da/common.json | 3 ++- locales/de/common.json | 3 ++- locales/en/common.json | 3 ++- locales/es/common.json | 3 ++- locales/fa/common.json | 3 ++- locales/fr/common.json | 3 ++- locales/he/common.json | 3 ++- locales/hu/common.json | 3 ++- locales/it/common.json | 3 ++- locales/ja/common.json | 3 ++- locales/ko/common.json | 3 ++- locales/lv/common.json | 3 ++- locales/mn/common.json | 3 ++- locales/nb/common.json | 3 ++- locales/nl/common.json | 3 ++- locales/pl/common.json | 3 ++- locales/pt/common.json | 3 ++- locales/ro/common.json | 3 ++- locales/ru/common.json | 3 ++- locales/sk/common.json | 3 ++- locales/tr/common.json | 3 ++- locales/uk/common.json | 3 ++- locales/zh-TW/common.json | 3 ++- locales/zh/common.json | 3 ++- 37 files changed, 120 insertions(+), 31 deletions(-) diff --git a/.env.example b/.env.example index fac2b79bf..baf9a0043 100644 --- a/.env.example +++ b/.env.example @@ -302,6 +302,12 @@ LOGIN_COMPANY_NAME=Bulwark Webmail # URL for the company website link on the login page. LOGIN_WEBSITE_URL=https://bulwarkmail.org +# Where users get the source code of this build, linked from Settings > +# About. Bulwark is AGPL-3.0-only: if you run a modified build, set this to +# the source of exactly what you deploy (your fork, at the deployed commit). +# Unset links to the upstream repository. Only http(s) URLs are used. +# SOURCE_CODE_URL=https://git.example.com/acme/webmail/tree/v1.2.3 + # Cap the login logo's rendered size. Any CSS length ("120px", "8rem"). # Unset means the logo renders at its natural size. # LOGIN_LOGO_MAX_HEIGHT=96px diff --git a/app/api/config/route.ts b/app/api/config/route.ts index 5afcba1cf..1f01fa83e 100644 --- a/app/api/config/route.ts +++ b/app/api/config/route.ts @@ -4,6 +4,7 @@ import { configManager } from '@/lib/admin/config-manager'; import { parseJmapServers, redactJmapServers } from '@/lib/admin/jmap-servers'; import { hasSessionSecret } from '@/lib/auth/session-secret'; import { getOauthScopes } from '@/lib/oauth/tokens'; +import { httpUrlOrEmpty } from '@/lib/config-validation'; import { matchDomainBranding, parseDomainBranding, @@ -95,6 +96,7 @@ export async function GET(request: NextRequest) { autoSsoEnabled: configManager.get('autoSsoEnabled', false), embeddedMode: !!allowedFrameAncestors && allowedFrameAncestors !== "'none'", parentOrigin: configManager.get('parentOrigin', ''), + sourceCodeUrl: httpUrlOrEmpty(configManager.get('sourceCodeUrl', '')), }, { // Runtime settings can change without new asset URLs. Prevent stale diff --git a/components/settings/about-data-settings.tsx b/components/settings/about-data-settings.tsx index 2417fabaf..37c89e7cc 100644 --- a/components/settings/about-data-settings.tsx +++ b/components/settings/about-data-settings.tsx @@ -16,6 +16,7 @@ import { SpamSiegeGame } from './spam-siege-game'; const APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || "0.0.0"; const GIT_COMMIT = process.env.NEXT_PUBLIC_GIT_COMMIT || "unknown"; +const UPSTREAM_SOURCE_URL = "https://github.com/bulwarkmail/webmail"; function VersionUpdateTag() { const status = useUpdateStore((s) => s.status); @@ -54,7 +55,9 @@ export function AboutDataSettings() { const tSettings = useTranslations('settings'); const { settingsSyncDisabled, updateSetting, resetToDefaults, exportSettings, importSettings } = useSettingsStore(); - const { settingsSyncEnabled } = useConfig(); + // A modified build must offer its own source (AGPL-3.0 §13), so a + // configured URL replaces the upstream repository link. + const { settingsSyncEnabled, sourceCodeUrl } = useConfig(); const [showResetConfirm, setShowResetConfirm] = useState(false); const [showRefreshConfirm, setShowRefreshConfirm] = useState(false); const fileInputRef = useRef(null); @@ -157,12 +160,12 @@ export function AboutDataSettings() { - GitHub + {sourceCodeUrl ? t('about.source_code') : 'GitHub'} diff --git a/hooks/use-config.ts b/hooks/use-config.ts index 6164378e7..55811caa2 100644 --- a/hooks/use-config.ts +++ b/hooks/use-config.ts @@ -44,6 +44,7 @@ export interface ConfigData { jmapServerAutoPickByDomain: boolean; embeddedMode: boolean; parentOrigin: string; + sourceCodeUrl: string; } interface AppConfig extends ConfigData { @@ -183,6 +184,7 @@ export function useConfig(): AppConfig { jmapServerAutoPickByDomain: configCache?.jmapServerAutoPickByDomain || false, embeddedMode: configCache?.embeddedMode || false, parentOrigin: configCache?.parentOrigin || '', + sourceCodeUrl: configCache?.sourceCodeUrl || '', isLoading: !configCache, error: null, }); @@ -226,6 +228,7 @@ export function useConfig(): AppConfig { jmapServerAutoPickByDomain: configCache.jmapServerAutoPickByDomain || false, embeddedMode: configCache.embeddedMode, parentOrigin: configCache.parentOrigin, + sourceCodeUrl: configCache.sourceCodeUrl, isLoading: false, error: null, }); @@ -270,6 +273,7 @@ export function useConfig(): AppConfig { jmapServerAutoPickByDomain: data.jmapServerAutoPickByDomain || false, embeddedMode: data.embeddedMode, parentOrigin: data.parentOrigin, + sourceCodeUrl: data.sourceCodeUrl, isLoading: false, error: null, }); diff --git a/lib/__tests__/config-route.test.ts b/lib/__tests__/config-route.test.ts index 7b9102fbf..25069c263 100644 --- a/lib/__tests__/config-route.test.ts +++ b/lib/__tests__/config-route.test.ts @@ -34,7 +34,7 @@ const MANAGED_ENV = [ 'SESSION_SECRET_FILE', 'SETTINGS_SYNC_ENABLED', 'STALWART_FEATURES', 'DEV_MOCK_JMAP', 'FAVICON_URL', 'APP_LOGO_LIGHT_URL', 'APP_LOGO_DARK_URL', 'LOGIN_COMPANY_NAME', 'LOGIN_IMPRINT_URL', 'LOGIN_PRIVACY_POLICY_URL', 'LOGIN_WEBSITE_URL', 'DOMAIN_BRANDING', - 'JMAP_SERVERS', 'JMAP_SERVER_AUTO_PICK_BY_DOMAIN', + 'JMAP_SERVERS', 'JMAP_SERVER_AUTO_PICK_BY_DOMAIN', 'SOURCE_CODE_URL', ] as const; describe('config API route', () => { @@ -107,6 +107,7 @@ describe('config API route', () => { expect(config.faviconUrl).toBe('/branding/Bulwark_Favicon.svg'); expect(config.appLogoLightUrl).toBe(''); expect(config.appLogoDarkUrl).toBe(''); + expect(config.sourceCodeUrl).toBe(''); }); // The domain lists name every organisation served by this instance. @@ -161,6 +162,21 @@ describe('config API route', () => { expect(config.loginWebsiteUrl).toBe('https://acme.com'); }); + it('publishes the source code URL for the About card (#1161)', async () => { + vi.stubEnv('SOURCE_CODE_URL', 'https://git.example.com/acme/webmail/tree/v1.2.3'); + + expect((await getConfig()).sourceCodeUrl).toBe('https://git.example.com/acme/webmail/tree/v1.2.3'); + }); + + it.each(['javascript:alert(1)', 'data:text/html,x', '/relative/path', 'not a url'])( + 'drops a source code URL that is not absolute http(s): %s', + async (value) => { + vi.stubEnv('SOURCE_CODE_URL', value); + + expect((await getConfig()).sourceCodeUrl).toBe(''); + }, + ); + it('should handle partial login customization', async () => { vi.stubEnv('LOGIN_COMPANY_NAME', 'Partial Corp'); // Leave URLs unset diff --git a/lib/__tests__/fixtures/config.ts b/lib/__tests__/fixtures/config.ts index b6972950e..5983ab035 100644 --- a/lib/__tests__/fixtures/config.ts +++ b/lib/__tests__/fixtures/config.ts @@ -38,6 +38,7 @@ export function createConfig(overrides: Partial = {}): ConfigData { jmapServerAutoPickByDomain: false, embeddedMode: false, parentOrigin: '', + sourceCodeUrl: '', ...overrides, }; } diff --git a/lib/__tests__/lite-config.test.ts b/lib/__tests__/lite-config.test.ts index a86f951a3..e347d1b80 100644 --- a/lib/__tests__/lite-config.test.ts +++ b/lib/__tests__/lite-config.test.ts @@ -63,6 +63,13 @@ describe('applyLiteConfig (config.json for the static build)', () => { expect(applyLiteConfig('garbage').appName).toBe('Webmail'); }); + it('keeps an http(s) source code URL and drops any other scheme', () => { + expect(applyLiteConfig({}).sourceCodeUrl).toBe(''); + expect(applyLiteConfig({ sourceCodeUrl: 'https://git.example.com/acme/webmail' }).sourceCodeUrl) + .toBe('https://git.example.com/acme/webmail'); + expect(applyLiteConfig({ sourceCodeUrl: 'javascript:alert(1)' }).sourceCodeUrl).toBe(''); + }); + it('keeps deployer-facing branding and login toggles', () => { const config = applyLiteConfig({ appName: 'Acme Mail', diff --git a/lib/admin/types.ts b/lib/admin/types.ts index 286a503d9..adceaf4df 100644 --- a/lib/admin/types.ts +++ b/lib/admin/types.ts @@ -215,6 +215,10 @@ export const CONFIG_ENV_MAP: Record; +/** + * An absolute http(s) URL as given, or '' for anything else, so a configured + * link can never carry a `javascript:` or other script-running scheme. + */ +export function httpUrlOrEmpty(value: unknown): string { + if (typeof value !== 'string') return ''; + const trimmed = value.trim(); + try { + const { protocol } = new URL(trimmed); + return protocol === 'http:' || protocol === 'https:' ? trimmed : ''; + } catch { + return ''; + } +} + /** Validate the server's complete response without rewriting configured values. */ export function isConfigData(value: unknown): value is ConfigData { return isRecord(value) diff --git a/lib/lite-config.ts b/lib/lite-config.ts index 1ace2d717..38d8dd4b4 100644 --- a/lib/lite-config.ts +++ b/lib/lite-config.ts @@ -1,6 +1,7 @@ import type { ConfigData } from '@/hooks/use-config'; import type { SettingsPolicy } from '@/lib/admin/types'; import { DEFAULT_POLICY } from '@/lib/admin/types'; +import { httpUrlOrEmpty } from '@/lib/config-validation'; /** * Runtime configuration for the static Lite build. @@ -40,6 +41,7 @@ export const LITE_CONFIG_KEYS = [ 'loginShowVersion', 'embeddedMode', 'parentOrigin', + 'sourceCodeUrl', ] as const; /** @@ -129,6 +131,7 @@ export function applyLiteConfig(raw: unknown, defaults?: { jmapServerUrl?: strin jmapServerAutoPickByDomain: bool(input.jmapServerAutoPickByDomain, false), embeddedMode: bool(input.embeddedMode, false), parentOrigin: str(input.parentOrigin, ''), + sourceCodeUrl: httpUrlOrEmpty(input.sourceCodeUrl), ...LITE_FORCED_FLAGS, }; } diff --git a/locales/ar/common.json b/locales/ar/common.json index 8d719aa4c..817746096 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -1980,7 +1980,8 @@ "button": "استيراد" }, "about": { - "title": "بريد Bulwark الإلكتروني" + "title": "بريد Bulwark الإلكتروني", + "source_code": "الشيفرة المصدرية" }, "spam_siege": { "title": "Spam Siege", diff --git a/locales/ca/common.json b/locales/ca/common.json index 0c48ee741..d8a7af10c 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -1980,7 +1980,8 @@ "button": "Importa" }, "about": { - "title": "Bulwark Webmail" + "title": "Bulwark Webmail", + "source_code": "Codi font" }, "spam_siege": { "title": "Spam Siege", diff --git a/locales/cs/common.json b/locales/cs/common.json index 7ddd19968..cf008fe2a 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -1973,7 +1973,8 @@ "button": "Importovat" }, "about": { - "title": "Bulwark Webmail" + "title": "Bulwark Webmail", + "source_code": "Zdrojový kód" }, "spam_siege": { "title": "Spam Siege", diff --git a/locales/da/common.json b/locales/da/common.json index addd4a317..a03b933b4 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -1976,7 +1976,8 @@ "button": "Importér" }, "about": { - "title": "Bulwark Webmail" + "title": "Bulwark Webmail", + "source_code": "Kildekode" }, "spam_siege": { "title": "Spam Siege", diff --git a/locales/de/common.json b/locales/de/common.json index aadb47176..e77fc30a9 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -1973,7 +1973,8 @@ "button": "Importieren" }, "about": { - "title": "Bulwark Webmail" + "title": "Bulwark Webmail", + "source_code": "Quellcode" }, "spam_siege": { "title": "Spam Siege", diff --git a/locales/en/common.json b/locales/en/common.json index a13e735ce..920e57698 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -1980,7 +1980,8 @@ "button": "Import" }, "about": { - "title": "Bulwark Webmail" + "title": "Bulwark Webmail", + "source_code": "Source code" }, "spam_siege": { "title": "Spam Siege", diff --git a/locales/es/common.json b/locales/es/common.json index 4a6fca1a9..504799d7b 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -1973,7 +1973,8 @@ "button": "Importar" }, "about": { - "title": "Bulwark Webmail" + "title": "Bulwark Webmail", + "source_code": "Código fuente" }, "spam_siege": { "title": "Spam Siege", diff --git a/locales/fa/common.json b/locales/fa/common.json index 007a02616..364dc5532 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -1980,7 +1980,8 @@ "button": "وارد کردن" }, "about": { - "title": "Bulwark Webmail" + "title": "Bulwark Webmail", + "source_code": "کد منبع" }, "spam_siege": { "title": "Spam Siege", diff --git a/locales/fr/common.json b/locales/fr/common.json index 9b24ea339..c3ecce1df 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -1973,7 +1973,8 @@ "button": "Importer" }, "about": { - "title": "Bulwark Webmail" + "title": "Bulwark Webmail", + "source_code": "Code source" }, "spam_siege": { "title": "Spam Siege", diff --git a/locales/he/common.json b/locales/he/common.json index ea21b5482..a4939c48b 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -1935,7 +1935,8 @@ "button": "ייבוא" }, "about": { - "title": "Bulwark Webmail" + "title": "Bulwark Webmail", + "source_code": "קוד מקור" }, "spam_siege": { "title": "Spam Siege", diff --git a/locales/hu/common.json b/locales/hu/common.json index 0f6833a8e..8a6a5b340 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -1976,7 +1976,8 @@ "button": "Importálás" }, "about": { - "title": "Bulwark Webmail" + "title": "Bulwark Webmail", + "source_code": "Forráskód" }, "spam_siege": { "title": "Spam Siege", diff --git a/locales/it/common.json b/locales/it/common.json index a0f54bde7..16817d83a 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -1973,7 +1973,8 @@ "button": "Importa" }, "about": { - "title": "Bulwark Webmail" + "title": "Bulwark Webmail", + "source_code": "Codice sorgente" }, "spam_siege": { "title": "Spam Siege", diff --git a/locales/ja/common.json b/locales/ja/common.json index 3bfab1019..1b6ccc57a 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -1973,7 +1973,8 @@ "button": "インポート" }, "about": { - "title": "Bulwark Webmail" + "title": "Bulwark Webmail", + "source_code": "ソースコード" }, "spam_siege": { "title": "Spam Siege", diff --git a/locales/ko/common.json b/locales/ko/common.json index 641add449..592df9bf2 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -1973,7 +1973,8 @@ "button": "가져오기" }, "about": { - "title": "Bulwark Webmail 정보" + "title": "Bulwark Webmail 정보", + "source_code": "소스 코드" }, "spam_siege": { "title": "Spam Siege", diff --git a/locales/lv/common.json b/locales/lv/common.json index 5e228f6e9..517a920fc 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -1973,7 +1973,8 @@ "contacts_description": "Kontaktu sinhronizācija, adrešu grāmatu darbības un uzticamie sūtītāji" }, "about": { - "title": "Bulwark Webmail" + "title": "Bulwark Webmail", + "source_code": "Pirmkods" }, "spam_siege": { "title": "Spam Siege", diff --git a/locales/mn/common.json b/locales/mn/common.json index a517c81cd..39c3d65e7 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -1980,7 +1980,8 @@ "button": "Импорт" }, "about": { - "title": "Тухай" + "title": "Тухай", + "source_code": "Эх код" }, "spam_siege": { "title": "Spam Siege", diff --git a/locales/nb/common.json b/locales/nb/common.json index 565ecae5f..94b86ef91 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -1980,7 +1980,8 @@ "button": "Importer" }, "about": { - "title": "Bulwark nettpost" + "title": "Bulwark nettpost", + "source_code": "Kildekode" }, "spam_siege": { "title": "Spam Siege", diff --git a/locales/nl/common.json b/locales/nl/common.json index 5dceb596d..f30b98f64 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -1973,7 +1973,8 @@ "button": "Importeren" }, "about": { - "title": "Bulwark Webmail" + "title": "Bulwark Webmail", + "source_code": "Broncode" }, "spam_siege": { "title": "Spam Siege", diff --git a/locales/pl/common.json b/locales/pl/common.json index f83b1018c..89d2ddb5c 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -1973,7 +1973,8 @@ "button": "Importuj" }, "about": { - "title": "Bulwark Webmail" + "title": "Bulwark Webmail", + "source_code": "Kod źródłowy" }, "spam_siege": { "title": "Spam Siege", diff --git a/locales/pt/common.json b/locales/pt/common.json index 8af09f2db..97cf3f31c 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -1973,7 +1973,8 @@ "button": "Importar" }, "about": { - "title": "Bulwark Webmail" + "title": "Bulwark Webmail", + "source_code": "Código-fonte" }, "spam_siege": { "title": "Spam Siege", diff --git a/locales/ro/common.json b/locales/ro/common.json index 5b72a115e..b75fc4a65 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -1980,7 +1980,8 @@ "button": "Import" }, "about": { - "title": "Bulwark Webmail" + "title": "Bulwark Webmail", + "source_code": "Cod sursă" }, "spam_siege": { "title": "Spam Siege", diff --git a/locales/ru/common.json b/locales/ru/common.json index 43f88167e..5ed68aedc 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -1973,7 +1973,8 @@ "button": "Импортировать" }, "about": { - "title": "Bulwark Webmail" + "title": "Bulwark Webmail", + "source_code": "Исходный код" }, "spam_siege": { "title": "Spam Siege", diff --git a/locales/sk/common.json b/locales/sk/common.json index 98128d621..11ab6bb3f 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -1980,7 +1980,8 @@ "button": "Importovať" }, "about": { - "title": "Bulwark Webmail" + "title": "Bulwark Webmail", + "source_code": "Zdrojový kód" }, "spam_siege": { "title": "Spam Siege", diff --git a/locales/tr/common.json b/locales/tr/common.json index 930639832..cd0ebc216 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -1973,7 +1973,8 @@ "button": "İçe Aktar" }, "about": { - "title": "Bulwark Webmail" + "title": "Bulwark Webmail", + "source_code": "Kaynak kodu" }, "spam_siege": { "title": "Spam Siege", diff --git a/locales/uk/common.json b/locales/uk/common.json index 0c9c75322..17f0cba80 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -1973,7 +1973,8 @@ "button": "Імпорт" }, "about": { - "title": "Оплот веб-пошти" + "title": "Оплот веб-пошти", + "source_code": "Вихідний код" }, "spam_siege": { "title": "Spam Siege", diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index c43188116..9ab6ff7cb 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -1980,7 +1980,8 @@ "button": "匯入" }, "about": { - "title": "Bulwark 網頁郵件" + "title": "Bulwark 網頁郵件", + "source_code": "原始碼" }, "spam_siege": { "title": "Spam Siege", diff --git a/locales/zh/common.json b/locales/zh/common.json index c5c1f0f48..553cab50a 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -1973,7 +1973,8 @@ "button": "导入" }, "about": { - "title": "Bulwark 网页邮箱" + "title": "Bulwark 网页邮箱", + "source_code": "源代码" }, "spam_siege": { "title": "Spam Siege",