diff --git a/src/backend/common/vendor/LastfmApiClient.ts b/src/backend/common/vendor/LastfmApiClient.ts index bbb97d3f..1fca76f5 100644 --- a/src/backend/common/vendor/LastfmApiClient.ts +++ b/src/backend/common/vendor/LastfmApiClient.ts @@ -8,7 +8,8 @@ import LastFm, { } from "lastfm-node-client"; import { PlayObject } from "../../../core/Atomic.js"; import { nonEmptyStringOrDefault, splitByFirstFound } from "../../../core/StringUtils.js"; -import { joinedUrl, readJson, removeUndefinedKeys, sleep, writeFile } from "../../utils.js"; +import { readJson, removeUndefinedKeys, sleep, writeFile } from "../../utils.js"; +import { joinedUrl } from "../../utils/NetworkUtils.js"; import { getScrobbleTsSOCDate } from "../../utils/TimeUtils.js"; import { getNodeNetworkException, isNodeNetworkException } from "../errors/NodeErrors.js"; import { UpstreamError } from "../errors/UpstreamError.js"; diff --git a/src/backend/ioc.ts b/src/backend/ioc.ts index de08740d..186ae54a 100644 --- a/src/backend/ioc.ts +++ b/src/backend/ioc.ts @@ -8,7 +8,8 @@ import { WildcardEmitter } from "./common/WildcardEmitter.js"; import { Notifiers } from "./notifier/Notifiers.js"; import ScrobbleClients from "./scrobblers/ScrobbleClients.js"; import ScrobbleSources from "./sources/ScrobbleSources.js"; -import { generateBaseURL } from "./utils.js"; + +import { generateBaseURL } from "./utils/NetworkUtils.js"; let version: string = 'unknown'; diff --git a/src/backend/notifier/AppriseWebhookNotifier.ts b/src/backend/notifier/AppriseWebhookNotifier.ts index 72e4e459..6501c9f6 100644 --- a/src/backend/notifier/AppriseWebhookNotifier.ts +++ b/src/backend/notifier/AppriseWebhookNotifier.ts @@ -11,6 +11,8 @@ import { WebhookPayload } from "../common/infrastructure/config/health/webhooks.js"; import { AbstractWebhookNotifier } from "./AbstractWebhookNotifier.js"; +import { URLData } from "../../core/Atomic.js"; +import { isPortReachable, joinedUrl, normalizeWebAddress } from "../utils/NetworkUtils.js"; const shortKey = truncateStringToLength(10); @@ -20,6 +22,8 @@ export class AppriseWebhookNotifier extends AbstractWebhookNotifier { priorities: PrioritiesConfig; + protected endpoint: URLData; + urls: string[]; keys: string[]; @@ -42,18 +46,24 @@ export class AppriseWebhookNotifier extends AbstractWebhookNotifier { } initialize = async () => { - // check url is correct + // check url is correct as a courtesy + this.endpoint = normalizeWebAddress(this.config.host); + this.logger.verbose(`Config URL: '${this.config.host}' => Normalized: '${this.endpoint.normal}'`) + + this.initialized = true; // always set as ready to go. Server issues may be transient. + try { - await request.get(this.config.host); + await isPortReachable(this.endpoint.port, { host: this.endpoint.url.hostname }); } catch (e) { - this.logger.error(new Error('Failed to contact Apprise server', {cause: e})); + this.logger.warn(new Error('Unable to detect if server is reachable', { cause: e })); + return; } if (this.keys.length > 0) { let anyOk = false; for (const key of this.keys) { try { - const resp = await request.get(`${this.config.host}/json/urls/${key}`); + const resp = await request.get(joinedUrl(this.endpoint.url, `/json/urls/${key}`).toString()); if (resp.statusCode === 204) { this.logger.warn(`Details for Config ${shortKey(key)} returned no content. Double check the key is set correctly or that the apprise Config is not empty.`); } else { @@ -64,9 +74,7 @@ export class AppriseWebhookNotifier extends AbstractWebhookNotifier { } } if (!anyOk) { - this.logger.error('No Apprise Configs were valid!'); - this.initialized = false; - return; + this.logger.warn('No Apprise Configs were valid!'); } } this.initialized = true; @@ -83,7 +91,7 @@ export class AppriseWebhookNotifier extends AbstractWebhookNotifier { if (this.keys.length > 0) { for (const key of this.keys) { try { - const resp = await this.callApi(request.post(`${this.config.host}/notify/${key}`) + const resp = await this.callApi(request.post(joinedUrl(this.endpoint.url, `/notify/${key}`).toString()) .type('json') .send(body)); anyOk = true; @@ -99,7 +107,7 @@ export class AppriseWebhookNotifier extends AbstractWebhookNotifier { body.urls = this.urls.join(',') } try { - const resp = await this.callApi(request.post(`${this.config.host}/notify`) + const resp = await this.callApi(request.post(joinedUrl(this.endpoint.url, '/notify').toString()) .type('json') .send(body)); anyOk = true; diff --git a/src/backend/notifier/GotifyWebhookNotifier.ts b/src/backend/notifier/GotifyWebhookNotifier.ts index b0bedfc6..73719f80 100644 --- a/src/backend/notifier/GotifyWebhookNotifier.ts +++ b/src/backend/notifier/GotifyWebhookNotifier.ts @@ -4,6 +4,8 @@ import { gotify } from 'gotify'; import request from 'superagent'; import { GotifyConfig, PrioritiesConfig, WebhookPayload } from "../common/infrastructure/config/health/webhooks.js"; import { AbstractWebhookNotifier } from "./AbstractWebhookNotifier.js"; +import { isPortReachable, normalizeWebAddress } from "../utils/NetworkUtils.js"; +import { URLData } from "../../core/Atomic.js"; export class GotifyWebhookNotifier extends AbstractWebhookNotifier { @@ -11,6 +13,8 @@ export class GotifyWebhookNotifier extends AbstractWebhookNotifier { priorities: PrioritiesConfig; + protected endpoint: URLData; + constructor(defaultName: string, config: GotifyConfig, logger: Logger) { super('Gotify', defaultName, config, logger); this.requiresAuth = true; @@ -29,14 +33,25 @@ export class GotifyWebhookNotifier extends AbstractWebhookNotifier { } initialize = async () => { - // check url is correct + // check url is correct as a courtesy + this.endpoint = normalizeWebAddress(this.config.url); + this.logger.verbose(`Config URL: '${this.config.url}' => Normalized: '${this.endpoint.normal}'`) + + this.initialized = true; // always set as ready to go. Server issues may be transient. + + try { + await isPortReachable(this.endpoint.port, { host: this.endpoint.url.hostname }); + } catch (e) { + this.logger.warn(new Error('Unable to detect if server is reachable', { cause: e })); + return; + } + try { const url = this.config.url; const resp = await request.get(`${url}/version`); - this.logger.verbose(`Initialized. Found Server version ${resp.body.version}`); - this.initialized = true; + this.logger.verbose(`Found Server version ${resp.body.version}`); } catch (e) { - this.logger.error(`Failed to contact server | Error: ${e.message}`); + this.logger.warn(new Error('Server was reachable but could not determine version', { cause: e })); } } @@ -48,19 +63,19 @@ export class GotifyWebhookNotifier extends AbstractWebhookNotifier { doNotify = async (payload: WebhookPayload) => { try { await gotify({ - server: this.config.url, + server: this.endpoint.normal, app: this.config.token, message: payload.message, title: payload.title, priority: this.priorities[payload.priority] }); this.logger.verbose(`Pushed notification.`); - } catch (e: any) { + } catch (e) { if(e instanceof HTTPError && e.response.statusCode === 401) { - this.logger.warn(`Unable to push notification. Error returned with 401 which means the TOKEN provided is probably incorrect. Disabling Notifier | Error => ${e.response.body}`); + this.logger.warn(new Error(`Unable to push notification. Error returned with 401 which means the TOKEN provided is probably incorrect. Disabling Notifier \n Response Error => ${e.response.body}`, {cause: e})); this.authed = false; } else { - this.logger.warn(`Failed to push notification | Error => ${e.message}`); + this.logger.warn(new Error('Failed to push notification', {cause: e})); } } } diff --git a/src/backend/notifier/NtfyWebhookNotifier.ts b/src/backend/notifier/NtfyWebhookNotifier.ts index 34389ddb..aee2e066 100644 --- a/src/backend/notifier/NtfyWebhookNotifier.ts +++ b/src/backend/notifier/NtfyWebhookNotifier.ts @@ -3,6 +3,8 @@ import { Config, publish } from 'ntfy'; import request from "superagent"; import { NtfyConfig, PrioritiesConfig, WebhookPayload } from "../common/infrastructure/config/health/webhooks.js"; import { AbstractWebhookNotifier } from "./AbstractWebhookNotifier.js"; +import { URLData } from "../../core/Atomic.js"; +import { isPortReachable, normalizeWebAddress } from "../utils/NetworkUtils.js"; export class NtfyWebhookNotifier extends AbstractWebhookNotifier { @@ -10,6 +12,8 @@ export class NtfyWebhookNotifier extends AbstractWebhookNotifier { priorities: PrioritiesConfig; + protected endpoint: URLData; + constructor(defaultName: string, config: NtfyConfig, logger: Logger) { super('Ntfy', defaultName, config, logger); const { @@ -27,24 +31,35 @@ export class NtfyWebhookNotifier extends AbstractWebhookNotifier { } initialize = async () => { - // check url is correct + // check url is correct as a courtesy + this.endpoint = normalizeWebAddress(this.config.url); + this.logger.verbose(`Config URL: '${this.config.url}' => Normalized: '${this.endpoint.normal}'`) + + this.initialized = true; // always set as ready to go. Server issues may be transient. + + try { + await isPortReachable(this.endpoint.port, { host: this.endpoint.url.hostname }); + } catch (e) { + this.logger.warn(new Error('Unable to detect if server is reachable', { cause: e })); + return; + } + try { const url = this.config.url; const resp = await request.get(`${url}/v1/health`); if(resp.body !== undefined && typeof resp.body === 'object') { const {health} = resp.body; if(health === false) { - this.logger.error('Found Ntfy server but it responded that it was not ready.') + this.logger.warn('Found server but it responded that it was not ready.') return; } } else { - this.logger.error(`Found Ntfy server but expected a response with 'health' in payload. Found => ${resp.body}`); + this.logger.warn(`Found server but expected a response with 'health' in payload. Found => ${resp.body}`); return; } - this.logger.info('Initialized. Found Ntfy server'); - this.initialized = true; + this.logger.info('Found Ntfy server'); } catch (e) { - this.logger.error(`Failed to contact Ntfy server | Error: ${e.message}`); + this.logger.error(new Error('Failed to contact server', {cause: e})); } } @@ -54,7 +69,7 @@ export class NtfyWebhookNotifier extends AbstractWebhookNotifier { message: payload.message, topic: this.config.topic, title: payload.title, - server: this.config.url, + server: this.endpoint.normal, priority: this.priorities[payload.priority], }; if (this.config.username !== undefined) { diff --git a/src/backend/scrobblers/ScrobbleClients.ts b/src/backend/scrobblers/ScrobbleClients.ts index a40c95ea..f6bb7898 100644 --- a/src/backend/scrobblers/ScrobbleClients.ts +++ b/src/backend/scrobblers/ScrobbleClients.ts @@ -10,7 +10,8 @@ import { ListenBrainzClientConfig } from "../common/infrastructure/config/client import { MalojaClientConfig } from "../common/infrastructure/config/client/maloja.js"; import { WildcardEmitter } from "../common/WildcardEmitter.js"; import { Notifiers } from "../notifier/Notifiers.js"; -import { joinedUrl, readJson, thresholdResultSummary } from "../utils.js"; +import { readJson, thresholdResultSummary } from "../utils.js"; +import { joinedUrl } from "../utils/NetworkUtils.js"; import { getTypeSchemaFromConfigGenerator } from "../utils/SchemaUtils.js"; import { validateJson } from "../utils/ValidationUtils.js"; import AbstractScrobbleClient from "./AbstractScrobbleClient.js"; diff --git a/src/backend/server/index.ts b/src/backend/server/index.ts index 90baa9db..872df819 100644 --- a/src/backend/server/index.ts +++ b/src/backend/server/index.ts @@ -10,7 +10,8 @@ import path from "path"; import ViteExpress from "vite-express"; import { projectDir } from "../common/index.js"; import { getRoot } from "../ioc.js"; -import { getAddress, parseBool } from "../utils.js"; +import { parseBool } from "../utils.js"; +import { getAddress } from "../utils/NetworkUtils.js"; import { setupApi } from "./api.js"; const app = addAsync(express()); diff --git a/src/backend/sources/DeezerSource.ts b/src/backend/sources/DeezerSource.ts index ee4db3a3..485be395 100644 --- a/src/backend/sources/DeezerSource.ts +++ b/src/backend/sources/DeezerSource.ts @@ -6,7 +6,8 @@ import request from 'superagent'; import { PlayObject } from "../../core/Atomic.js"; import { DEFAULT_RETRY_MULTIPLIER, FormatPlayObjectOptions, InternalConfig } from "../common/infrastructure/Atomic.js"; import { DeezerSourceConfig } from "../common/infrastructure/config/source/deezer.js"; -import { joinedUrl, parseRetryAfterSecsFromObj, readJson, sleep, sortByOldestPlayDate, writeFile, } from "../utils.js"; +import { parseRetryAfterSecsFromObj, readJson, sleep, sortByOldestPlayDate, writeFile, } from "../utils.js"; +import { joinedUrl } from "../utils/NetworkUtils.js"; import AbstractSource, { RecentlyPlayedOptions } from "./AbstractSource.js"; export default class DeezerSource extends AbstractSource { diff --git a/src/backend/sources/JellyfinApiSource.ts b/src/backend/sources/JellyfinApiSource.ts index c60dde83..c3739dfb 100644 --- a/src/backend/sources/JellyfinApiSource.ts +++ b/src/backend/sources/JellyfinApiSource.ts @@ -50,7 +50,8 @@ import { PlayPlatformId, REPORTED_PLAYER_STATUSES } from "../common/infrastructure/Atomic.js"; import { JellyApiSourceConfig } from "../common/infrastructure/config/source/jellyfin.js"; -import { combinePartsToString, genGroupIdStr, getPlatformIdFromData, joinedUrl, parseBool, } from "../utils.js"; +import { combinePartsToString, genGroupIdStr, getPlatformIdFromData, parseBool, } from "../utils.js"; +import { joinedUrl } from "../utils/NetworkUtils.js"; import { parseArrayFromMaybeString } from "../utils/StringUtils.js"; import { MemoryPositionalSource } from "./MemoryPositionalSource.js"; diff --git a/src/backend/sources/PlexApiSource.ts b/src/backend/sources/PlexApiSource.ts index 5774605c..e62618f5 100644 --- a/src/backend/sources/PlexApiSource.ts +++ b/src/backend/sources/PlexApiSource.ts @@ -9,7 +9,7 @@ import { PlayerStateDataMaybePlay, PlayPlatformId, REPORTED_PLAYER_STATUSES } from "../common/infrastructure/Atomic.js"; -import { combinePartsToString, genGroupIdStr, getFirstNonEmptyString, getPlatformIdFromData, joinedUrl, parseBool, } from "../utils.js"; +import { combinePartsToString, genGroupIdStr, getFirstNonEmptyString, getPlatformIdFromData, parseBool, } from "../utils.js"; import { parseArrayFromMaybeString } from "../utils/StringUtils.js"; import { GetSessionsMetadata } from "@lukehagar/plexjs/sdk/models/operations/getsessions.js"; import { PlexAPI } from "@lukehagar/plexjs"; @@ -17,7 +17,7 @@ import { SDKValidationError, } from "@lukehagar/plexjs/sdk/models/errors"; import { PlexApiSourceConfig } from "../common/infrastructure/config/source/plex.js"; -import { isPortReachable } from '../utils/NetworkUtils.js'; +import { isPortReachable, joinedUrl } from '../utils/NetworkUtils.js'; import normalizeUrl from 'normalize-url'; import { GetTokenDetailsResponse, GetTokenDetailsUserPlexAccount } from '@lukehagar/plexjs/sdk/models/operations/gettokendetails.js'; import { parseRegexSingle } from '@foxxmd/regex-buddy-core'; diff --git a/src/backend/sources/SpotifySource.ts b/src/backend/sources/SpotifySource.ts index 066213a8..bafc803f 100644 --- a/src/backend/sources/SpotifySource.ts +++ b/src/backend/sources/SpotifySource.ts @@ -19,7 +19,6 @@ import { import { SpotifySourceConfig } from "../common/infrastructure/config/source/spotify.js"; import { combinePartsToString, - joinedUrl, parseRetryAfterSecsFromObj, readJson, sleep, @@ -27,6 +26,7 @@ import { writeFile, } from "../utils.js"; import { findCauseByFunc } from "../utils/ErrorUtils.js"; +import { joinedUrl } from "../utils/NetworkUtils.js"; import { RecentlyPlayedOptions } from "./AbstractSource.js"; import AlbumObjectSimplified = SpotifyApi.AlbumObjectSimplified; import ArtistObjectSimplified = SpotifyApi.ArtistObjectSimplified; diff --git a/src/backend/sources/WebScrobblerSource.ts b/src/backend/sources/WebScrobblerSource.ts index a95dd8b2..69c15d05 100644 --- a/src/backend/sources/WebScrobblerSource.ts +++ b/src/backend/sources/WebScrobblerSource.ts @@ -15,7 +15,8 @@ import { WebScrobblerPayload, WebScrobblerSong } from "../common/vendor/webscrobbler/interfaces.js"; -import { joinedUrl } from "../utils.js"; + +import { joinedUrl } from "../utils/NetworkUtils.js"; import MemorySource from "./MemorySource.js"; export class WebScrobblerSource extends MemorySource { diff --git a/src/backend/tests/utils/strings.test.ts b/src/backend/tests/utils/strings.test.ts index b2e7e901..2738a095 100644 --- a/src/backend/tests/utils/strings.test.ts +++ b/src/backend/tests/utils/strings.test.ts @@ -1,6 +1,7 @@ import { assert, expect } from 'chai'; import { describe, it } from 'mocha'; -import { generateBaseURL, intersect, joinedUrl } from "../../utils.js"; +import { intersect } from "../../utils.js"; +import { generateBaseURL, joinedUrl, normalizeWebAddress } from "../../utils/NetworkUtils.js"; import { compareNormalizedStrings, normalizeStr, @@ -203,13 +204,61 @@ describe('URL Parsing', function () { }); }); - describe('String Splitting', function() { + describe('Normalizing', function() { - it('should not split string with no delimiter', function() { - const artistName = undefined; - const artist = "Phil Collins"; - const artistStrings = splitByFirstFound(artist, [','], [artistName]); - expect(artistStrings).to.eql(['Phil Collins']) + const anIP = '192.168.0.100'; + + it('Should unwrap a quoted value', function () { + expect(normalizeWebAddress(`"${anIP}"`).url.hostname).to.eq(anIP); + }); + + it('Should normalize an IP to HTTP protocol', function () { + expect(normalizeWebAddress(anIP).url.protocol).to.eq('http:'); + }); + + it('Should normalize an IP without a port to port 80', function () { + expect(normalizeWebAddress(anIP).port).to.eq(80); + }); + + it('Should normalize an IP to an HTTP URL', function () { + expect(normalizeWebAddress(anIP).normal).to.eq(`http://${anIP}`); + }); + + it('Should normalize an IP with port 443 to an HTTPS URL', function () { + expect(normalizeWebAddress(`${anIP}:443`).url.protocol).to.eq(`https:`); + expect(normalizeWebAddress(`${anIP}:443`).url.toString()).to.include(`https:`); + expect(normalizeWebAddress(`${anIP}:443`).normal).to.include(`https:`); + expect(normalizeWebAddress(`${anIP}:443`).port).to.eq(443); + }); + + it('Should not normalize an IP with port 443 if protocol is specified', function () { + expect(normalizeWebAddress(`http://${anIP}:443`).url.protocol).to.eq(`http:`); + expect(normalizeWebAddress(`http://${anIP}:443`).url.toString()).to.include(`http:`); + expect(normalizeWebAddress(`http://${anIP}:443`).normal).to.include(`http:`); + expect(normalizeWebAddress(`http://${anIP}:443`).port).to.eq(443); }); + + it('Should normalize an IP with a port and preserve port', function () { + expect(normalizeWebAddress(`${anIP}:5000`).port).to.eq(5000); + expect(normalizeWebAddress(`${anIP}:5000`).normal).to.eq(`http://${anIP}:5000`); + expect(normalizeWebAddress(`${anIP}:5000`).url.protocol).to.eq('http:'); + expect(normalizeWebAddress(`${anIP}:5000`).url.port).to.eq('5000'); + }); + + it('Should remove trailing slash', function () { + expect(normalizeWebAddress(`${anIP}:5000/`).normal).to.eq(`http://${anIP}:5000`); + }); + }); + +}); + +describe('String Splitting', function() { + + it('should not split string with no delimiter', function() { + const artistName = undefined; + const artist = "Phil Collins"; + const artistStrings = splitByFirstFound(artist, [','], [artistName]); + expect(artistStrings).to.eql(['Phil Collins']) }); }); + diff --git a/src/backend/utils.ts b/src/backend/utils.ts index 29b73ddc..d81bdc6f 100644 --- a/src/backend/utils.ts +++ b/src/backend/utils.ts @@ -1,7 +1,4 @@ -import { Logger } from '@foxxmd/logging'; -import { parseRegexSingle } from "@foxxmd/regex-buddy-core"; import backoffStrategies from '@kenyip/backoff-strategies'; -import address from "address"; import { replaceResultTransformer, stripIndentTransformer, TemplateTag, trimResultTransformer } from 'common-tags'; import dayjs, { Dayjs } from 'dayjs'; import { Duration } from "dayjs/plugin/duration.js"; @@ -10,18 +7,14 @@ import { Request } from "express"; import { accessSync, constants, promises } from "fs"; import JSON5 from 'json5'; // https://github.com/jfromaniello/url-join#in-nodejs -import { join as joinPath } from 'node:path/posix'; -import normalizeUrl from "normalize-url"; import pathUtil from "path"; import { TimeoutError, WebapiError } from "spotify-web-api-node/src/response-error.js"; import { PlayObject } from "../core/Atomic.js"; import { - asPlayerStateData, asPlayerStateDataMaybePlay, NO_DEVICE, NO_USER, numberFormatOptions, - PlayerStateData, PlayerStateDataMaybePlay, PlayPlatformId, ProgressAwarePlayObject, @@ -669,34 +662,6 @@ export const durationToHuman = (dur: Duration): string => { parts.push(`${nTime.seconds}sec`); return parts.join(' '); } -export const getAddress = (host = '0.0.0.0', logger?: Logger): { v4?: string, v6?: string, host: string } => { - const local = host === '0.0.0.0' || host === '::' ? 'localhost' : host; - let v4: string, - v6: string; - try { - v4 = address.ip(); - v6 = address.ipv6(); - } catch (e) { - if (process.env.DEBUG_MODE === 'true') { - if (logger !== undefined) { - logger.warn(new Error('Could not get machine IP address', {cause: e})); - } else { - console.warn('Could not get machine IP address'); - console.warn(e); - } - } - } - return { - host: local, - v4, - v6 - }; -} - -const IPV4_REGEX = new RegExp(/^((25[0-5]|(2[0-4]|1\d|[1-9]|)\d)\.?\b){4}$/); -export const isIPv4 = (address: string): boolean => { - return parseRegexSingleOrFail(IPV4_REGEX, address) !== undefined; -} export const comparingMultipleArtists = (existing: PlayObject, candidate: PlayObject): boolean => { const { @@ -713,36 +678,6 @@ export const comparingMultipleArtists = (existing: PlayObject, candidate: PlayOb return eArtists.length > 1 || cArtists.length > 1; } -const QUOTES_UNWRAP_REGEX: RegExp = new RegExp(/^"(.*)"$/); -export const generateBaseURL = (userUrl: string | undefined, defaultPort: number | string): URL => { - const urlStr = userUrl ?? `http://localhost:${defaultPort}`; - let cleanUserUrl = urlStr.trim(); - const results = parseRegexSingle(QUOTES_UNWRAP_REGEX, cleanUserUrl); - if(results !== undefined && results.groups && results.groups.length > 0) { - cleanUserUrl = results.groups[0]; - } - const base = normalizeUrl(cleanUserUrl, {removeSingleSlash: true}); - const u = new URL(base); - if(u.port === '') { - if(u.protocol === 'https:') { - u.port = '443'; - } else if(userUrl.includes(`${u.hostname}:80`)) { - u.port = '80'; - } else { - u.port = defaultPort.toString(); - } - } - return u; -} - -export const joinedUrl = (url: URL, ...paths: string[]): URL => { - // https://github.com/jfromaniello/url-join#in-nodejs - const finalUrl = new URL(url); - finalUrl.pathname = joinPath(url.pathname, ...(paths.filter(x => x.trim() !== ''))); - const f = getFirstNonEmptyVal(['something']); - return finalUrl; -} - export const getFirstNonEmptyVal = (values: unknown[], options: {ofType?: string, test?: (val: T) => boolean} = {}): NonNullable | undefined => { for(const v of values) { if(v === undefined || v === null) { @@ -759,4 +694,4 @@ export const getFirstNonEmptyVal = (values: unknown[], options: {of return undefined; } -export const getFirstNonEmptyString = (values: unknown[]) => getFirstNonEmptyVal(values, {ofType: 'string', test: (v) => v.trim() !== ''}); \ No newline at end of file +export const getFirstNonEmptyString = (values: unknown[]) => getFirstNonEmptyVal(values, {ofType: 'string', test: (v) => v.trim() !== ''}); diff --git a/src/backend/utils/NetworkUtils.ts b/src/backend/utils/NetworkUtils.ts index 29684780..e64427d6 100644 --- a/src/backend/utils/NetworkUtils.ts +++ b/src/backend/utils/NetworkUtils.ts @@ -1,4 +1,11 @@ +import { Logger } from "@foxxmd/logging"; +import { parseRegexSingle } from "@foxxmd/regex-buddy-core"; +import address from "address"; import net from 'node:net'; +import normalizeUrl from "normalize-url"; +import { join as joinPath } from "path"; +import { getFirstNonEmptyVal, parseRegexSingleOrFail } from "../utils.js"; +import { URLData } from "../../core/Atomic.js"; export interface PortReachableOpts { host: string, @@ -39,3 +46,89 @@ export const isPortReachable = async (port: number, opts: PortReachableOpts) => throw e; } } + +const QUOTES_UNWRAP_REGEX: RegExp = new RegExp(/^"(.*)"$/); + +export const normalizeWebAddress = (val: string): URLData => { + let cleanUserUrl = val.trim(); + const results = parseRegexSingle(QUOTES_UNWRAP_REGEX, val); + if (results !== undefined && results.groups && results.groups.length > 0) { + cleanUserUrl = results.groups[0]; + } + + let normal = normalizeUrl(cleanUserUrl, {removeTrailingSlash: true}); + const u = new URL(normal); + let port: number; + + if (u.port === '') { + port = u.protocol === 'https:' ? 443 : 80; + } else { + port = parseInt(u.port); + // if user val does not include protocol and port is 443 then auto set to https + if(port === 443 && !val.includes('http')) { + if(u.protocol === 'http:') { + u.protocol = 'https:'; + } + normal = normal.replace('http:', 'https:'); + } + } + return { + url: u, + normal, + port + } +} + +export const generateBaseURL = (userUrl: string | undefined, defaultPort: number | string): URL => { + const urlStr = userUrl ?? `http://localhost:${defaultPort}`; + let cleanUserUrl = urlStr.trim(); + const results = parseRegexSingle(QUOTES_UNWRAP_REGEX, cleanUserUrl); + if (results !== undefined && results.groups && results.groups.length > 0) { + cleanUserUrl = results.groups[0]; + } + const base = normalizeUrl(cleanUserUrl, {removeSingleSlash: true}); + const u = new URL(base); + if (u.port === '') { + if (u.protocol === 'https:') { + u.port = '443'; + } else if (userUrl.includes(`${u.hostname}:80`)) { + u.port = '80'; + } else { + u.port = defaultPort.toString(); + } + } + return u; +} +export const joinedUrl = (url: URL, ...paths: string[]): URL => { + // https://github.com/jfromaniello/url-join#in-nodejs + const finalUrl = new URL(url); + finalUrl.pathname = joinPath(url.pathname, ...(paths.filter(x => x.trim() !== ''))); + return finalUrl; +} +export const getAddress = (host = '0.0.0.0', logger?: Logger): { v4?: string, v6?: string, host: string } => { + const local = host === '0.0.0.0' || host === '::' ? 'localhost' : host; + let v4: string, + v6: string; + try { + v4 = address.ip(); + v6 = address.ipv6(); + } catch (e) { + if (process.env.DEBUG_MODE === 'true') { + if (logger !== undefined) { + logger.warn(new Error('Could not get machine IP address', {cause: e})); + } else { + console.warn('Could not get machine IP address'); + console.warn(e); + } + } + } + return { + host: local, + v4, + v6 + }; +} +const IPV4_REGEX = new RegExp(/^((25[0-5]|(2[0-4]|1\d|[1-9]|)\d)\.?\b){4}$/); +export const isIPv4 = (address: string): boolean => { + return parseRegexSingleOrFail(IPV4_REGEX, address) !== undefined; +} diff --git a/src/core/Atomic.ts b/src/core/Atomic.ts index ae1f46ff..cbc96b42 100644 --- a/src/core/Atomic.ts +++ b/src/core/Atomic.ts @@ -266,3 +266,9 @@ export const SOURCE_SOT = { export interface LeveledLogData extends LogDataPretty { levelLabel: string } + +export interface URLData { + url: URL + normal: string + port: number +} \ No newline at end of file