From a04c1f4e413a03e4174b8fa92127851b7c54ba5b Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Mon, 28 Oct 2024 16:31:48 +0000 Subject: [PATCH] feat(notifications): Make init more granular and always complete init * Notifications are not mission critical so its okay if they fail on init because service outage may be transient. Always init notification services and always attempt to push (we catch failures anyway) * Parse more info from notification URL and test simple reachability of host/port to make troubleshooting easier Fixes #215 --- src/backend/common/vendor/LastfmApiClient.ts | 3 +- src/backend/ioc.ts | 3 +- .../notifier/AppriseWebhookNotifier.ts | 26 ++++-- src/backend/notifier/GotifyWebhookNotifier.ts | 31 +++++-- src/backend/notifier/NtfyWebhookNotifier.ts | 29 ++++-- src/backend/scrobblers/ScrobbleClients.ts | 3 +- src/backend/server/index.ts | 3 +- src/backend/sources/DeezerSource.ts | 3 +- src/backend/sources/JellyfinApiSource.ts | 3 +- src/backend/sources/PlexApiSource.ts | 4 +- src/backend/sources/SpotifySource.ts | 2 +- src/backend/sources/WebScrobblerSource.ts | 3 +- src/backend/tests/utils/strings.test.ts | 63 +++++++++++-- src/backend/utils.ts | 67 +------------ src/backend/utils/NetworkUtils.ts | 93 +++++++++++++++++++ src/core/Atomic.ts | 6 ++ 16 files changed, 235 insertions(+), 107 deletions(-) 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