From f8fd78d21ab8ed1cf0f6cb9facfbf8296ecf655a Mon Sep 17 00:00:00 2001 From: Myx Date: Sun, 15 Mar 2026 16:12:21 +0100 Subject: [PATCH] Add server variables --- .env.example | 1 + README.md | 6 ++- server/src/config/variables.ts | 93 ++++++++++++++++++++++++++++++++-- server/src/index.ts | 74 ++++++++++++++++++++------- 4 files changed, 152 insertions(+), 22 deletions(-) diff --git a/.env.example b/.env.example index 254ad5a..8d5b43c 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,5 @@ # Toggle SSL for local development (true/false) # When true: ng serve uses --ssl, Express API uses HTTPS, Electron loads https:// # When false: plain HTTP everywhere (only works on localhost) +# Overrides server/data/variables.json for local development only SSL=true diff --git a/README.md b/README.md index bac7521..703e378 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ Desktop chat app with three parts: Root `.env`: - `SSL=true` uses HTTPS for Angular, the server, and Electron dev mode -- `PORT=3001` changes the server port +- `PORT=3001` changes the server port in local development and overrides the server app setting If `SSL=true`, run `./generate-cert.sh` once. @@ -25,6 +25,10 @@ Server files: - `server/data/variables.json` holds `klipyApiKey` - `server/data/variables.json` also holds `releaseManifestUrl` for desktop auto updates +- `server/data/variables.json` can now also hold optional `serverHost` (an IP address or hostname to bind to) +- `server/data/variables.json` can now also hold `serverProtocol` (`http` or `https`) +- `server/data/variables.json` can now also hold `serverPort` (1-65535) +- When `serverProtocol` is `https`, the certificate must match the configured `serverHost` or IP ## Main commands diff --git a/server/src/config/variables.ts b/server/src/config/variables.ts index 6aa26dd..443a601 100644 --- a/server/src/config/variables.ts +++ b/server/src/config/variables.ts @@ -2,13 +2,20 @@ import fs from 'fs'; import path from 'path'; import { resolveRuntimePath } from '../runtime-paths'; +export type ServerHttpProtocol = 'http' | 'https'; + export interface ServerVariablesConfig { klipyApiKey: string; releaseManifestUrl: string; + serverPort: number; + serverProtocol: ServerHttpProtocol; + serverHost: string; } const DATA_DIR = resolveRuntimePath('data'); const VARIABLES_FILE = path.join(DATA_DIR, 'variables.json'); +const DEFAULT_SERVER_PORT = 3001; +const DEFAULT_SERVER_PROTOCOL: ServerHttpProtocol = 'http'; function normalizeKlipyApiKey(value: unknown): string { return typeof value === 'string' ? value.trim() : ''; @@ -18,6 +25,51 @@ function normalizeReleaseManifestUrl(value: unknown): string { return typeof value === 'string' ? value.trim() : ''; } +function normalizeServerHost(value: unknown): string { + return typeof value === 'string' ? value.trim() : ''; +} + +function normalizeServerProtocol( + value: unknown, + fallback: ServerHttpProtocol = DEFAULT_SERVER_PROTOCOL +): ServerHttpProtocol { + if (typeof value === 'boolean') { + return value ? 'https' : 'http'; + } + + if (typeof value !== 'string') { + return fallback; + } + + const normalized = value.trim().toLowerCase(); + + if (normalized === 'https' || normalized === 'true') { + return 'https'; + } + + if (normalized === 'http' || normalized === 'false') { + return 'http'; + } + + return fallback; +} + +function normalizeServerPort(value: unknown, fallback = DEFAULT_SERVER_PORT): number { + const parsed = typeof value === 'number' + ? value + : typeof value === 'string' + ? Number.parseInt(value.trim(), 10) + : Number.NaN; + + return Number.isInteger(parsed) && parsed >= 1 && parsed <= 65535 + ? parsed + : fallback; +} + +function hasEnvironmentOverride(value: string | undefined): value is string { + return typeof value === 'string' && value.trim().length > 0; +} + function readRawVariables(): { rawContents: string; parsed: Record } { if (!fs.existsSync(VARIABLES_FILE)) { return { rawContents: '', parsed: {} }; @@ -52,10 +104,14 @@ export function ensureVariablesConfig(): ServerVariablesConfig { } const { rawContents, parsed } = readRawVariables(); + const { serverIpAddress: legacyServerIpAddress, ...remainingParsed } = parsed; const normalized = { - ...parsed, - klipyApiKey: normalizeKlipyApiKey(parsed.klipyApiKey), - releaseManifestUrl: normalizeReleaseManifestUrl(parsed.releaseManifestUrl) + ...remainingParsed, + klipyApiKey: normalizeKlipyApiKey(remainingParsed.klipyApiKey), + releaseManifestUrl: normalizeReleaseManifestUrl(remainingParsed.releaseManifestUrl), + serverPort: normalizeServerPort(remainingParsed.serverPort), + serverProtocol: normalizeServerProtocol(remainingParsed.serverProtocol), + serverHost: normalizeServerHost(remainingParsed.serverHost ?? legacyServerIpAddress) }; const nextContents = JSON.stringify(normalized, null, 2) + '\n'; @@ -65,7 +121,10 @@ export function ensureVariablesConfig(): ServerVariablesConfig { return { klipyApiKey: normalized.klipyApiKey, - releaseManifestUrl: normalized.releaseManifestUrl + releaseManifestUrl: normalized.releaseManifestUrl, + serverPort: normalized.serverPort, + serverProtocol: normalized.serverProtocol, + serverHost: normalized.serverHost }; } @@ -84,3 +143,29 @@ export function hasKlipyApiKey(): boolean { export function getReleaseManifestUrl(): string { return getVariablesConfig().releaseManifestUrl; } + +export function getServerProtocol(): ServerHttpProtocol { + if (hasEnvironmentOverride(process.env.SSL)) { + return normalizeServerProtocol(process.env.SSL); + } + + return getVariablesConfig().serverProtocol; +} + +export function getServerPort(): number { + if (hasEnvironmentOverride(process.env.PORT)) { + return normalizeServerPort(process.env.PORT); + } + + return getVariablesConfig().serverPort; +} + +export function getServerHost(): string | undefined { + const serverHost = getVariablesConfig().serverHost; + + return serverHost || undefined; +} + +export function isHttpsServerEnabled(): boolean { + return getServerProtocol() === 'https'; +} diff --git a/server/src/index.ts b/server/src/index.ts index 0b2b611..3ad1d5d 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -14,23 +14,39 @@ import { deleteStaleJoinRequests } from './cqrs'; import { createApp } from './app'; import { ensureVariablesConfig, + getServerHost, getVariablesConfigPath, - hasKlipyApiKey + getServerPort, + getServerProtocol, + ServerHttpProtocol } from './config/variables'; import { setupWebSocket } from './websocket'; -const USE_SSL = (process.env.SSL ?? 'false').toLowerCase() === 'true'; -const PORT = process.env.PORT || 3001; +function formatHostForUrl(host: string): string { + if (host.startsWith('[') || !host.includes(':')) { + return host; + } -function buildServer(app: ReturnType) { - if (USE_SSL) { + return `[${host}]`; +} + +function getDisplayHost(serverHost: string | undefined): string { + if (!serverHost || serverHost === '0.0.0.0' || serverHost === '::') { + return 'localhost'; + } + + return serverHost; +} + +function buildServer(app: ReturnType, serverProtocol: ServerHttpProtocol) { + if (serverProtocol === 'https') { const certDir = resolveCertificateDirectory(); const certFile = path.join(certDir, 'localhost.crt'); const keyFile = path.join(certDir, 'localhost.key'); if (!fs.existsSync(certFile) || !fs.existsSync(keyFile)) { - console.error(`SSL=true but certs not found in ${certDir}`); - console.error('Run ./generate-cert.sh first.'); + console.error(`HTTPS is enabled but certs were not found in ${certDir}`); + console.error('Add localhost.crt and localhost.key there, or switch serverProtocol to "http".'); process.exit(1); } @@ -44,17 +60,31 @@ function buildServer(app: ReturnType) { } async function bootstrap(): Promise { - ensureVariablesConfig(); + const variablesConfig = ensureVariablesConfig(); + const serverProtocol = getServerProtocol(); + const serverPort = getServerPort(); + const serverHost = getServerHost(); + const bindHostLabel = serverHost || 'default interface'; + console.log('[Config] Variables loaded from:', getVariablesConfigPath()); - if (!hasKlipyApiKey()) { + if ( + variablesConfig.serverProtocol !== serverProtocol + || variablesConfig.serverPort !== serverPort + ) { + console.log(`[Config] Server runtime override active: protocol=${serverProtocol}, host=${bindHostLabel}, port=${serverPort}`); + } else { + console.log(`[Config] Server runtime config: protocol=${serverProtocol}, host=${bindHostLabel}, port=${serverPort}`); + } + + if (!variablesConfig.klipyApiKey) { console.log('[KLIPY] API key not configured. GIF search is disabled.'); } await initDatabase(); const app = createApp(); - const server = buildServer(app); + const server = buildServer(app, serverProtocol); setupWebSocket(server); @@ -64,14 +94,24 @@ async function bootstrap(): Promise { .catch(err => console.error('Failed to clean up stale join requests:', err)); }, 60 * 1000); - server.listen(PORT, () => { - const proto = USE_SSL ? 'https' : 'http'; - const wsProto = USE_SSL ? 'wss' : 'ws'; + const onListening = () => { + const displayHost = formatHostForUrl(getDisplayHost(serverHost)); + const wsProto = serverProtocol === 'https' ? 'wss' : 'ws'; - console.log(`MetoYou signaling server running on port ${PORT} (SSL=${USE_SSL})`); - console.log(` REST API: ${proto}://localhost:${PORT}/api`); - console.log(` WebSocket: ${wsProto}://localhost:${PORT}`); - }); + console.log(`MetoYou signaling server running on port ${serverPort} (${serverProtocol.toUpperCase()}, bind host=${bindHostLabel})`); + console.log(` REST API: ${serverProtocol}://${displayHost}:${serverPort}/api`); + console.log(` WebSocket: ${wsProto}://${displayHost}:${serverPort}`); + + if (serverProtocol === 'https' && serverHost && !['localhost', '127.0.0.1', '::1'].includes(serverHost)) { + console.warn('[Config] HTTPS certificates must match the configured serverHost/server IP.'); + } + }; + + if (serverHost) { + server.listen(serverPort, serverHost, onListening); + } else { + server.listen(serverPort, onListening); + } } bootstrap().catch((err) => {