Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion apps/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 => {
Expand All @@ -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),
);
17 changes: 13 additions & 4 deletions apps/server/routes/Root.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<CountryResponse>(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<CountryResponse>(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.
Expand Down
28 changes: 22 additions & 6 deletions apps/server/routes/client-version-check/ClientBuildDate.test.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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<Date, Error>(),
expectedResult: Maybe.nothing<Date>(),
},
{
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);
});
});
4 changes: 4 additions & 0 deletions apps/server/routes/client-version-check/ClientBuildDate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,5 +29,9 @@ export function parseMinimumRequiredClientBuildDate(
): Maybe<Date> {
const {parseClientVersion, clientVersion} = dependencies;

if (clientVersion === undefined || clientVersion.length === 0) {
return Maybe.nothing();
}

return toolbelt.fromResult(parseClientVersion(clientVersion));
}
75 changes: 50 additions & 25 deletions apps/server/util/BrowserUtil.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
1 change: 1 addition & 0 deletions apps/webapp/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down
33 changes: 27 additions & 6 deletions libraries/config/src/server.config.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
54 changes: 35 additions & 19 deletions libraries/config/src/server.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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<string, string>): Record<string, Iterable<string>> {
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<string, Iterable<string>> {
const objectSrc = parseCommaSeparatedList(env.CSP_EXTRA_OBJECT_SRC);
const csp = {
connectSrc: [
Expand Down Expand Up @@ -96,16 +109,27 @@ function mergedCSP({urls}: ConfigGeneratorParams, env: Record<string, string>):

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,
Expand All @@ -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;
}

Expand Down
Loading