feat: link the running build's source from the About card #1161

This commit is contained in:
Linus Rath
2026-10-02 22:10:29 +02:00
parent cbde644441
commit 0e39b199a1
37 changed files with 120 additions and 31 deletions
+6
View File
@@ -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
+2
View File
@@ -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<boolean>('autoSsoEnabled', false),
embeddedMode: !!allowedFrameAncestors && allowedFrameAncestors !== "'none'",
parentOrigin: configManager.get<string>('parentOrigin', ''),
sourceCodeUrl: httpUrlOrEmpty(configManager.get<string>('sourceCodeUrl', '')),
},
{
// Runtime settings can change without new asset URLs. Prevent stale
+6 -3
View File
@@ -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<HTMLInputElement>(null);
@@ -157,12 +160,12 @@ export function AboutDataSettings() {
</div>
</button>
<a
href="https://github.com/bulwarkmail/webmail"
href={sourceCodeUrl || UPSTREAM_SOURCE_URL}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
GitHub <ExternalLink className="w-3 h-3" />
{sourceCodeUrl ? t('about.source_code') : 'GitHub'} <ExternalLink className="w-3 h-3" />
</a>
</div>
</div>
+4
View File
@@ -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,
});
+17 -1
View File
@@ -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
+1
View File
@@ -38,6 +38,7 @@ export function createConfig(overrides: Partial<ConfigData> = {}): ConfigData {
jmapServerAutoPickByDomain: false,
embeddedMode: false,
parentOrigin: '',
sourceCodeUrl: '',
...overrides,
};
}
+7
View File
@@ -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',
+4
View File
@@ -215,6 +215,10 @@ export const CONFIG_ENV_MAP: Record<string, { envVar: string; fileEnvVar?: strin
loginImprintUrl: { envVar: 'LOGIN_IMPRINT_URL', type: 'url', defaultValue: '' },
loginPrivacyPolicyUrl: { envVar: 'LOGIN_PRIVACY_POLICY_URL', type: 'url', defaultValue: '' },
loginWebsiteUrl: { envVar: 'LOGIN_WEBSITE_URL', type: 'url', defaultValue: '' },
// Where users get the source of the build they are running (AGPL-3.0 §13).
// Operators of a modified build point it at their fork; the About card then
// links there instead of the upstream repository (#1161). Empty = upstream.
sourceCodeUrl: { envVar: 'SOURCE_CODE_URL', type: 'url', defaultValue: '' },
// Login header customization. The logo box is otherwise a fixed 64×64
// (w-16/h-16), which fits a wide wordmark to ~13px tall; set a max height
// and/or width (any CSS length, e.g. "230px" or "3rem") to size it. The
+16
View File
@@ -62,8 +62,24 @@ const configValidators = {
jmapServerAutoPickByDomain: isBoolean,
embeddedMode: isBoolean,
parentOrigin: isString,
sourceCodeUrl: isString,
} satisfies Validators<ConfigData>;
/**
* 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)
+3
View File
@@ -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,
};
}
+2 -1
View File
@@ -1980,7 +1980,8 @@
"button": "استيراد"
},
"about": {
"title": "بريد Bulwark الإلكتروني"
"title": "بريد Bulwark الإلكتروني",
"source_code": "الشيفرة المصدرية"
},
"spam_siege": {
"title": "Spam Siege",
+2 -1
View File
@@ -1980,7 +1980,8 @@
"button": "Importa"
},
"about": {
"title": "Bulwark Webmail"
"title": "Bulwark Webmail",
"source_code": "Codi font"
},
"spam_siege": {
"title": "Spam Siege",
+2 -1
View File
@@ -1973,7 +1973,8 @@
"button": "Importovat"
},
"about": {
"title": "Bulwark Webmail"
"title": "Bulwark Webmail",
"source_code": "Zdrojový kód"
},
"spam_siege": {
"title": "Spam Siege",
+2 -1
View File
@@ -1976,7 +1976,8 @@
"button": "Importér"
},
"about": {
"title": "Bulwark Webmail"
"title": "Bulwark Webmail",
"source_code": "Kildekode"
},
"spam_siege": {
"title": "Spam Siege",
+2 -1
View File
@@ -1973,7 +1973,8 @@
"button": "Importieren"
},
"about": {
"title": "Bulwark Webmail"
"title": "Bulwark Webmail",
"source_code": "Quellcode"
},
"spam_siege": {
"title": "Spam Siege",
+2 -1
View File
@@ -1980,7 +1980,8 @@
"button": "Import"
},
"about": {
"title": "Bulwark Webmail"
"title": "Bulwark Webmail",
"source_code": "Source code"
},
"spam_siege": {
"title": "Spam Siege",
+2 -1
View File
@@ -1973,7 +1973,8 @@
"button": "Importar"
},
"about": {
"title": "Bulwark Webmail"
"title": "Bulwark Webmail",
"source_code": "Código fuente"
},
"spam_siege": {
"title": "Spam Siege",
+2 -1
View File
@@ -1980,7 +1980,8 @@
"button": "وارد کردن"
},
"about": {
"title": "Bulwark Webmail"
"title": "Bulwark Webmail",
"source_code": "کد منبع"
},
"spam_siege": {
"title": "Spam Siege",
+2 -1
View File
@@ -1973,7 +1973,8 @@
"button": "Importer"
},
"about": {
"title": "Bulwark Webmail"
"title": "Bulwark Webmail",
"source_code": "Code source"
},
"spam_siege": {
"title": "Spam Siege",
+2 -1
View File
@@ -1935,7 +1935,8 @@
"button": "ייבוא"
},
"about": {
"title": "Bulwark Webmail"
"title": "Bulwark Webmail",
"source_code": "קוד מקור"
},
"spam_siege": {
"title": "Spam Siege",
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -1973,7 +1973,8 @@
"button": "Importa"
},
"about": {
"title": "Bulwark Webmail"
"title": "Bulwark Webmail",
"source_code": "Codice sorgente"
},
"spam_siege": {
"title": "Spam Siege",
+2 -1
View File
@@ -1973,7 +1973,8 @@
"button": "インポート"
},
"about": {
"title": "Bulwark Webmail"
"title": "Bulwark Webmail",
"source_code": "ソースコード"
},
"spam_siege": {
"title": "Spam Siege",
+2 -1
View File
@@ -1973,7 +1973,8 @@
"button": "가져오기"
},
"about": {
"title": "Bulwark Webmail 정보"
"title": "Bulwark Webmail 정보",
"source_code": "소스 코드"
},
"spam_siege": {
"title": "Spam Siege",
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -1980,7 +1980,8 @@
"button": "Импорт"
},
"about": {
"title": "Тухай"
"title": "Тухай",
"source_code": "Эх код"
},
"spam_siege": {
"title": "Spam Siege",
+2 -1
View File
@@ -1980,7 +1980,8 @@
"button": "Importer"
},
"about": {
"title": "Bulwark nettpost"
"title": "Bulwark nettpost",
"source_code": "Kildekode"
},
"spam_siege": {
"title": "Spam Siege",
+2 -1
View File
@@ -1973,7 +1973,8 @@
"button": "Importeren"
},
"about": {
"title": "Bulwark Webmail"
"title": "Bulwark Webmail",
"source_code": "Broncode"
},
"spam_siege": {
"title": "Spam Siege",
+2 -1
View File
@@ -1973,7 +1973,8 @@
"button": "Importuj"
},
"about": {
"title": "Bulwark Webmail"
"title": "Bulwark Webmail",
"source_code": "Kod źródłowy"
},
"spam_siege": {
"title": "Spam Siege",
+2 -1
View File
@@ -1973,7 +1973,8 @@
"button": "Importar"
},
"about": {
"title": "Bulwark Webmail"
"title": "Bulwark Webmail",
"source_code": "Código-fonte"
},
"spam_siege": {
"title": "Spam Siege",
+2 -1
View File
@@ -1980,7 +1980,8 @@
"button": "Import"
},
"about": {
"title": "Bulwark Webmail"
"title": "Bulwark Webmail",
"source_code": "Cod sursă"
},
"spam_siege": {
"title": "Spam Siege",
+2 -1
View File
@@ -1973,7 +1973,8 @@
"button": "Импортировать"
},
"about": {
"title": "Bulwark Webmail"
"title": "Bulwark Webmail",
"source_code": "Исходный код"
},
"spam_siege": {
"title": "Spam Siege",
+2 -1
View File
@@ -1980,7 +1980,8 @@
"button": "Importovať"
},
"about": {
"title": "Bulwark Webmail"
"title": "Bulwark Webmail",
"source_code": "Zdrojový kód"
},
"spam_siege": {
"title": "Spam Siege",
+2 -1
View File
@@ -1973,7 +1973,8 @@
"button": "İçe Aktar"
},
"about": {
"title": "Bulwark Webmail"
"title": "Bulwark Webmail",
"source_code": "Kaynak kodu"
},
"spam_siege": {
"title": "Spam Siege",
+2 -1
View File
@@ -1973,7 +1973,8 @@
"button": "Імпорт"
},
"about": {
"title": "Оплот веб-пошти"
"title": "Оплот веб-пошти",
"source_code": "Вихідний код"
},
"spam_siege": {
"title": "Spam Siege",
+2 -1
View File
@@ -1980,7 +1980,8 @@
"button": "匯入"
},
"about": {
"title": "Bulwark 網頁郵件"
"title": "Bulwark 網頁郵件",
"source_code": "原始碼"
},
"spam_siege": {
"title": "Spam Siege",
+2 -1
View File
@@ -1973,7 +1973,8 @@
"button": "导入"
},
"about": {
"title": "Bulwark 网页邮箱"
"title": "Bulwark 网页邮箱",
"source_code": "源代码"
},
"spam_siege": {
"title": "Spam Siege",