diff --git a/apps/server/index.ts b/apps/server/index.ts index e930d57daa1..17f5ff2b4ea 100644 --- a/apps/server/index.ts +++ b/apps/server/index.ts @@ -23,6 +23,16 @@ import {formatDate} from './util/TimeUtil'; const server = new Server(serverConfig, clientConfig); +function getUnhandledRejectionType(unhandledRejection: unknown): string { + if (unhandledRejection instanceof Error) { + return unhandledRejection.name; + } + if (unhandledRejection === null) { + return 'null'; + } + return typeof unhandledRejection; +} + server .start() .then(port => { @@ -37,5 +47,5 @@ process.on('uncaughtException', error => console.error(`[${formatDate()}] Uncaught exception: ${error.message}`, error), ); process.on('unhandledRejection', error => - console.error(`[${formatDate()}] Uncaught rejection "${error.constructor.name}"`, error), + console.error(`[${formatDate()}] Uncaught rejection "${getUnhandledRejectionType(error)}"`, error), ); diff --git a/apps/server/routes/Root.ts b/apps/server/routes/Root.ts index 35598eb130e..5b3a86ef5ac 100644 --- a/apps/server/routes/Root.ts +++ b/apps/server/routes/Root.ts @@ -25,11 +25,20 @@ async function addGeoIP(req: Request) { let countryCode = ''; try { - const ip = req.header('X-Forwarded-For') || req.ip; - const lookup = await maxmind.open(geolite2.paths.country); - const result = lookup.get(ip); + const ipAddress = req.header('X-Forwarded-For') ?? req.ip ?? ''; + if (ipAddress.length === 0) { + return; + } + + const countryDatabasePath = geolite2.paths.country; + if (countryDatabasePath === undefined) { + return; + } + + const lookup = await maxmind.open(countryDatabasePath); + const result = lookup.get(ipAddress); if (result) { - countryCode = result.country.iso_code; + countryCode = result.country?.iso_code ?? ''; } } catch (error) { // It's okay to go without a detected country. diff --git a/apps/server/routes/client-version-check/ClientBuildDate.test.ts b/apps/server/routes/client-version-check/ClientBuildDate.test.ts index 416fc56a554..5221604f280 100644 --- a/apps/server/routes/client-version-check/ClientBuildDate.test.ts +++ b/apps/server/routes/client-version-check/ClientBuildDate.test.ts @@ -1,6 +1,8 @@ import {Maybe, Result} from 'true-myth'; import {parseMinimumRequiredClientBuildDate, ParseMinimumRequiredClientBuildDateDependencies} from './ClientBuildDate'; +const validClientVersion = '2026.02.12.17.51.00'; + type Overrides = { readonly parseClientVersion?: jest.Mock; readonly clientVersion?: string | undefined; @@ -16,20 +18,34 @@ function createParseMinimumRequiredClientBuildDateDependencies( } describe('parseMinimumRequiredClientBuildDate()', () => { - it('returns a Nothing when parseClientVersion() returns a Result Err', () => { + it('returns a Nothing when no client version is configured', () => { + const parseClientVersion = jest.fn(); const dependencies = createParseMinimumRequiredClientBuildDateDependencies({ - parseClientVersion: jest.fn().mockReturnValue(Result.err()), + parseClientVersion, + clientVersion: undefined, }); expect(parseMinimumRequiredClientBuildDate(dependencies)).toStrictEqual(Maybe.nothing()); + expect(parseClientVersion).not.toHaveBeenCalled(); }); - it('returns a Just when parseClientVersion() returns a Result Ok', () => { - const expectedDate = new Date(2026, 1, 12, 17, 51, 0); + it.each([ + { + description: 'returns a Nothing when parseClientVersion() returns a Result Err', + parseResult: Result.err(), + expectedResult: Maybe.nothing(), + }, + { + description: 'returns a Just when parseClientVersion() returns a Result Ok', + parseResult: Result.ok(new Date(2026, 1, 12, 17, 51, 0)), + expectedResult: Maybe.just(new Date(2026, 1, 12, 17, 51, 0)), + }, + ])('$description', ({parseResult, expectedResult}) => { const dependencies = createParseMinimumRequiredClientBuildDateDependencies({ - parseClientVersion: jest.fn().mockReturnValue(Result.ok(expectedDate)), + parseClientVersion: jest.fn().mockReturnValue(parseResult), + clientVersion: validClientVersion, }); - expect(parseMinimumRequiredClientBuildDate(dependencies)).toStrictEqual(Maybe.just(expectedDate)); + expect(parseMinimumRequiredClientBuildDate(dependencies)).toStrictEqual(expectedResult); }); }); diff --git a/apps/server/routes/client-version-check/ClientBuildDate.ts b/apps/server/routes/client-version-check/ClientBuildDate.ts index 07e081024bd..69eb2fc2d30 100644 --- a/apps/server/routes/client-version-check/ClientBuildDate.ts +++ b/apps/server/routes/client-version-check/ClientBuildDate.ts @@ -29,5 +29,9 @@ export function parseMinimumRequiredClientBuildDate( ): Maybe { const {parseClientVersion, clientVersion} = dependencies; + if (clientVersion === undefined || clientVersion.length === 0) { + return Maybe.nothing(); + } + return toolbelt.fromResult(parseClientVersion(clientVersion)); } diff --git a/apps/server/util/BrowserUtil.test.ts b/apps/server/util/BrowserUtil.test.ts index f76f3a212b1..86785935876 100644 --- a/apps/server/util/BrowserUtil.test.ts +++ b/apps/server/util/BrowserUtil.test.ts @@ -19,36 +19,61 @@ import * as BrowserUtil from './BrowserUtil'; -describe('BrowserUtil', () => { - it('detects iPhone', () => { - const userAgent = - 'Mozilla/5.0 (iPhone; CPU iPhone OS 10_0_1 like Mac OS X) AppleWebKit/602.1.50 (KHTML, like Gecko) Version/10.0 Mobile/14A403 Safari/602.1'; - - expect(BrowserUtil.parseUserAgent(userAgent).is.ios).toBe(true); - expect(BrowserUtil.parseUserAgent(userAgent).is.mobile).toBe(true); - }); +type UserAgentExpectation = readonly [ + description: string, + userAgent: string, + expectedIos: boolean, + expectedAndroid: boolean, + expectedMobile: boolean, +]; - it('detects iPad', () => { - const userAgent = - 'Mozilla/5.0 (iPad; CPU OS 11_0 like Mac OS X) AppleWebKit/604.1.34 (KHTML, like Gecko) Version/11.0 Mobile/15A5341f Safari/604.1'; +function parseUserAgentOrThrow(userAgent: string) { + const parsedUserAgent = BrowserUtil.parseUserAgent(userAgent); - expect(BrowserUtil.parseUserAgent(userAgent).is.ios).toBe(true); - expect(BrowserUtil.parseUserAgent(userAgent).is.mobile).toBe(true); - }); + if (parsedUserAgent === null) { + throw new Error('Expected parseUserAgent to return a parsed user agent.'); + } - it('detects Android', () => { - const userAgent = - 'Mozilla/5.0 (Linux; Android 8.0; Pixel 2 Build/OPD3.170816.012) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.110 Mobile Safari/537.36'; + return parsedUserAgent; +} - expect(BrowserUtil.parseUserAgent(userAgent).is.android).toBe(true); - expect(BrowserUtil.parseUserAgent(userAgent).is.mobile).toBe(true); - }); +const userAgentExpectations: UserAgentExpectation[] = [ + [ + 'detects iPhone', + 'Mozilla/5.0 (iPhone; CPU iPhone OS 10_0_1 like Mac OS X) AppleWebKit/602.1.50 (KHTML, like Gecko) Version/10.0 Mobile/14A403 Safari/602.1', + true, + false, + true, + ], + [ + 'detects iPad', + 'Mozilla/5.0 (iPad; CPU OS 11_0 like Mac OS X) AppleWebKit/604.1.34 (KHTML, like Gecko) Version/11.0 Mobile/15A5341f Safari/604.1', + true, + false, + true, + ], + [ + 'detects Android', + 'Mozilla/5.0 (Linux; Android 8.0; Pixel 2 Build/OPD3.170816.012) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.110 Mobile Safari/537.36', + false, + true, + true, + ], + [ + 'detects Android tablet', + 'Mozilla/5.0 (Linux; Android 7.1; vivo 1716 Build/N2G47H) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/61.0.3163.98 Mobile Safari/537.36', + false, + true, + true, + ], +]; - it('detects Android tablet', () => { - const userAgent = - 'Mozilla/5.0 (Linux; Android 7.1; vivo 1716 Build/N2G47H) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/61.0.3163.98 Mobile Safari/537.36'; +describe('BrowserUtil', () => { + it.each(userAgentExpectations)('%s', (_description, userAgent, expectedIos, expectedAndroid, expectedMobile) => { + const parsedUserAgent = parseUserAgentOrThrow(userAgent); - expect(BrowserUtil.parseUserAgent(userAgent).is.android).toBe(true); - expect(BrowserUtil.parseUserAgent(userAgent).is.mobile).toBe(true); + expect(parsedUserAgent.is.ios).toBe(expectedIos); + expect(parsedUserAgent.is.android).toBe(expectedAndroid); + expect(parsedUserAgent.is.mobile).toBe(expectedMobile); }); }); diff --git a/apps/webapp/tsconfig.json b/apps/webapp/tsconfig.json index 80621983d5f..0f238a744aa 100644 --- a/apps/webapp/tsconfig.json +++ b/apps/webapp/tsconfig.json @@ -27,6 +27,7 @@ "rootDirs": ["src"], "target": "ESNext", "strict": true, + "strictNullChecks": false, "strictFunctionTypes": false }, "types": ["@types/wicg-file-system-access", "jest", "@testing-library/jest-dom"], diff --git a/libraries/config/src/server.config.spec.ts b/libraries/config/src/server.config.spec.ts index 2c177c1cf40..fa6e7fbd82e 100644 --- a/libraries/config/src/server.config.spec.ts +++ b/libraries/config/src/server.config.spec.ts @@ -65,12 +65,33 @@ describe('Server Config', () => { expect(config.ENVIRONMENT).toBe('production'); }); - it('should map URL parameters correctly', () => { - const config = generateConfig(mockParams, mockEnv); - - expect(config.APP_BASE).toBe('https://app.wire.com'); - expect(config.BACKEND_REST).toBe('https://prod-nginz-https.wire.com'); - expect(config.BACKEND_WS).toBe('wss://prod-nginz-ssl.wire.com'); + it.each([ + { + description: 'maps configured URL parameters', + params: mockParams, + expectedAppBase: 'https://app.wire.com', + expectedBackendRest: 'https://prod-nginz-https.wire.com', + expectedBackendWebSocket: 'wss://prod-nginz-ssl.wire.com', + expectedTlsEnabled: true, + }, + { + description: 'defaults missing URL parameters to empty strings', + params: { + ...mockParams, + urls: {}, + }, + expectedAppBase: '', + expectedBackendRest: '', + expectedBackendWebSocket: '', + expectedTlsEnabled: false, + }, + ])('should $description', ({params, expectedAppBase, expectedBackendRest, expectedBackendWebSocket, expectedTlsEnabled}) => { + const config = generateConfig(params, mockEnv); + + expect(config.APP_BASE).toBe(expectedAppBase); + expect(config.BACKEND_REST).toBe(expectedBackendRest); + expect(config.BACKEND_WS).toBe(expectedBackendWebSocket); + expect(config.DEVELOPMENT_ENABLE_TLS).toBe(expectedTlsEnabled); }); it('should parse PORT correctly with default', () => { diff --git a/libraries/config/src/server.config.ts b/libraries/config/src/server.config.ts index 539d7b32bd0..5c24a96283e 100644 --- a/libraries/config/src/server.config.ts +++ b/libraries/config/src/server.config.ts @@ -50,7 +50,7 @@ const logger = logdown('config', { markdown: false, }); -function readFile(filePath: string, fallback?: string): string { +function readFile(filePath: string, fallback: string = ''): string { try { return fs.readFileSync(filePath, {encoding: 'utf8', flag: 'r'}); } catch (error) { @@ -59,15 +59,28 @@ function readFile(filePath: string, fallback?: string): string { } } -function parseCommaSeparatedList(list: string = ''): string[] { +function parseCommaSeparatedList(list: string | undefined = ''): string[] { const cleanedList = list.replace(/\s/g, ''); - if (!cleanedList) { + if (cleanedList.length === 0) { return []; } return cleanedList.split(','); } -function mergedCSP({urls}: ConfigGeneratorParams, env: Record): Record> { +function getNonEmptyStringValueOrDefault(value: string | undefined, fallback: string): string { + return value !== undefined && value.length > 0 ? value : fallback; +} + +function resolveServerCertificatePath(certificateFileName: string): string { + const certificatePathInWorkspaceRoot = path.resolve(process.cwd(), `certificate/${certificateFileName}`); + if (fs.existsSync(certificatePathInWorkspaceRoot)) { + return certificatePathInWorkspaceRoot; + } + + return path.resolve(process.cwd(), `apps/server/dist/certificate/${certificateFileName}`); +} + +function mergedCSP({urls}: ConfigGeneratorParams, env: Env): Record> { const objectSrc = parseCommaSeparatedList(env.CSP_EXTRA_OBJECT_SRC); const csp = { connectSrc: [ @@ -96,16 +109,27 @@ function mergedCSP({urls}: ConfigGeneratorParams, env: Record): export function generateConfig(params: ConfigGeneratorParams, env: Env) { const {commit, version, urls, env: nodeEnv} = params; + const baseUrl = urls.base ?? ''; + const apiUrl = urls.api ?? ''; + const websocketUrl = urls.ws ?? ''; + const parsedHttpPort = Number(env.PORT); + const isHttpPortMissingOrZero = Number.isNaN(parsedHttpPort) || parsedHttpPort === 0; + const httpPort = isHttpPortMissingOrZero ? 21080 : parsedHttpPort; + const defaultSslCertificateKeyPath = resolveServerCertificatePath('development-key.pem'); + const defaultSslCertificatePath = resolveServerCertificatePath('development-cert.pem'); + const sslCertificateKeyPath = getNonEmptyStringValueOrDefault(env.SSL_CERTIFICATE_KEY_PATH, defaultSslCertificateKeyPath); + const sslCertificatePath = getNonEmptyStringValueOrDefault(env.SSL_CERTIFICATE_PATH, defaultSslCertificatePath); + return { - APP_BASE: urls.base, + APP_BASE: baseUrl, COMMIT: commit, VERSION: version, CACHE_DURATION_SECONDS: 300, CSP: mergedCSP(params, env), - BACKEND_REST: urls.api, - BACKEND_WS: urls.ws, + BACKEND_REST: apiUrl, + BACKEND_WS: websocketUrl, DEVELOPMENT: nodeEnv === 'development', - DEVELOPMENT_ENABLE_TLS: urls.base.startsWith('https://'), + DEVELOPMENT_ENABLE_TLS: baseUrl.startsWith('https://'), ENFORCE_HTTPS: env.ENFORCE_HTTPS != 'false', ENVIRONMENT: nodeEnv, GOOGLE_WEBMASTER_ID: env.GOOGLE_WEBMASTER_ID, @@ -115,23 +139,15 @@ export function generateConfig(params: ConfigGeneratorParams, env: Env) { TITLE: env.OPEN_GRAPH_TITLE, }, ENABLE_DYNAMIC_HOSTNAME: env.ENABLE_DYNAMIC_HOSTNAME === 'true', - PORT_HTTP: Number(env.PORT) || 21080, + PORT_HTTP: httpPort, MINIMUM_REQUIRED_CLIENT_BUILD_DATE: env.MINIMUM_REQUIRED_CLIENT_BUILD_DATE, ROBOTS: { ALLOW: readFile(ROBOTS_ALLOW_FILE, 'User-agent: *\r\nDisallow: /'), ALLOWED_HOSTS: ['app.wire.com'], DISALLOW: readFile(ROBOTS_DISALLOW_FILE, 'User-agent: *\r\nDisallow: /'), }, - SSL_CERTIFICATE_KEY_PATH: - env.SSL_CERTIFICATE_KEY_PATH || - (fs.existsSync(path.resolve(process.cwd(), 'certificate/development-key.pem')) - ? path.resolve(process.cwd(), 'certificate/development-key.pem') - : path.resolve(process.cwd(), 'apps/server/dist/certificate/development-key.pem')), - SSL_CERTIFICATE_PATH: - env.SSL_CERTIFICATE_PATH || - (fs.existsSync(path.resolve(process.cwd(), 'certificate/development-cert.pem')) - ? path.resolve(process.cwd(), 'certificate/development-cert.pem') - : path.resolve(process.cwd(), 'apps/server/dist/certificate/development-cert.pem')), + SSL_CERTIFICATE_KEY_PATH: sslCertificateKeyPath, + SSL_CERTIFICATE_PATH: sslCertificatePath, } as const; } diff --git a/libraries/core/src/linkPreview/LinkPreviewService.test.ts b/libraries/core/src/linkPreview/LinkPreviewService.test.ts new file mode 100644 index 00000000000..a4ba64f96d5 --- /dev/null +++ b/libraries/core/src/linkPreview/LinkPreviewService.test.ts @@ -0,0 +1,75 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +import {QualifiedId} from '@wireapp/api-client/lib/user'; + +import {AssetService} from '../conversation'; +import {LinkPreviewContent} from '../conversation/content'; +import {EncryptedAssetUploaded} from '../cryptography'; + +import {LinkPreviewService} from './LinkPreviewService'; + +const conversationIdentifier: QualifiedId = { + domain: 'wire.com', + id: 'conversation-id', +}; + +const linkPreviewWithMissingTitle: LinkPreviewContent = { + url: 'https://wire.com', + urlOffset: 0, + title: null, + image: { + data: new Uint8Array([1, 2, 3]), + height: 100, + type: 'image/png', + width: 100, + }, +}; + +describe('LinkPreviewService', () => { + it('uses an empty filename for audit uploads when the link preview title is missing', async () => { + const uploadedAsset: EncryptedAssetUploaded = { + cipherText: new Uint8Array([1, 2, 3]), + domain: 'wire.com', + key: 'asset-key', + keyBytes: new Uint8Array([4, 5, 6]), + sha256: new Uint8Array([7, 8, 9]), + token: 'asset-token', + }; + + const uploadAsset = jest.fn().mockResolvedValue({ + cancel: jest.fn(), + response: Promise.resolve(uploadedAsset), + }); + + const assetService: AssetService = {uploadAsset} as unknown as AssetService; + const linkPreviewService = new LinkPreviewService(assetService); + + await linkPreviewService.uploadLinkPreviewImage(linkPreviewWithMissingTitle, conversationIdentifier, true); + + expect(uploadAsset).toHaveBeenCalledWith(linkPreviewWithMissingTitle.image.data, { + domain: conversationIdentifier.domain, + auditData: { + conversationId: conversationIdentifier, + filename: '', + filetype: linkPreviewWithMissingTitle.image.type, + }, + }); + }); +}); diff --git a/libraries/core/src/linkPreview/LinkPreviewService.ts b/libraries/core/src/linkPreview/LinkPreviewService.ts index 31129330eef..f88e4929987 100644 --- a/libraries/core/src/linkPreview/LinkPreviewService.ts +++ b/libraries/core/src/linkPreview/LinkPreviewService.ts @@ -31,7 +31,7 @@ export class LinkPreviewService { isAuditLogEnabled: boolean = false, ): Promise { const {image, ...preview} = linkPreview; - if (!image) { + if (image === undefined || image === null) { return preview; } @@ -40,7 +40,7 @@ export class LinkPreviewService { await this.assetService.uploadAsset(linkPreview.image.data, { domain: conversationId.domain, ...(isAuditLogEnabled && { - auditData: {conversationId, filename: linkPreview.title, filetype: linkPreview.image.type}, + auditData: {conversationId, filename: linkPreview.title ?? '', filetype: linkPreview.image.type}, }), }) ).response; diff --git a/tsconfig.base.json b/tsconfig.base.json index d2c67addab6..0206bd9c9f7 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -12,7 +12,7 @@ "downlevelIteration": true, "alwaysStrict": true, "strict": true, - "strictNullChecks": false, + "strictNullChecks": true, "useUnknownInCatchVariables": false, "forceConsistentCasingInFileNames": true, "noImplicitAny": true,