From 6dc40e50ad47f29ccf7872401fc3d92e797b6867 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Thu, 14 Sep 2023 12:38:20 -0400 Subject: [PATCH] feat: Implement Base URL * Allows users to define a base URL to be used for default redirect URI generation * Convenience mostly for docker users so they do not have to explicitly define redirect uri for each source/client * Use in UI start up logging hints --- docker-compose.yml | 4 +++ docsite/docs/configuration/configuration.md | 15 +++++++++ docsite/docs/installation/installation.md | 18 ++++++++++- docsite/static/configExample.json | 1 + .../common/infrastructure/config/aioConfig.ts | 14 +++++++++ src/backend/common/schema/aio.json | 11 +++++++ src/backend/common/vendor/LastfmApiClient.ts | 8 ++--- src/backend/index.ts | 3 +- src/backend/ioc.ts | 31 ++++++++++++------- src/backend/server/index.ts | 9 +++++- src/backend/sources/DeezerSource.ts | 7 ++++- src/backend/sources/LastfmSource.ts | 2 +- src/backend/sources/SpotifySource.ts | 9 ++++++ 13 files changed, 111 insertions(+), 21 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 1936fc05..4e54a122 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,6 +13,10 @@ services: #- MALOJA_API_KEY= #- PUID=1000 # required if running docker on linux host, see main README Docker setup instructions #- PGID=1000 # required if running docker on linux host, see main README Docker setup instructions + + # set if using a source/client with redirect URI that you have not explicitly set and MS is NOT running on the same machine that you will view the dashboard from + # EX: You will view MS dashboard at 'http://192.168.0.101:9078' -> set BASE_URL=http://192.168.0.101:9078 + #- BASE_URL=http://MyHostIP:9078 volumes: - /path_on_host/multi-scrobbler-config:/config #networks: diff --git a/docsite/docs/configuration/configuration.md b/docsite/docs/configuration/configuration.md index cce28c9c..45a69ae3 100644 --- a/docsite/docs/configuration/configuration.md +++ b/docsite/docs/configuration/configuration.md @@ -168,6 +168,21 @@ Example directory structure: See the [/config](https://github.com/FoxxMD/multi-scrobbler/blob/master/config) directory of this project for examples of each type of config file or reference specific files below. +# Application Options + +These options affect multi-scrobbler's behavior and are not specific to any source/client. + +#### Base URL + +Defines the URL that is used to generate default redirect URLs for authentication on [spotify](#spotify), [lastfm](#lastfm), and [deezer](#deezer) -- as well as some logging hints. + +* Default => `http://localhost` +* Set with [ENV](#env-based-configuration) `BASE_URL` or `baseUrl` [all-in-one configuration](#all-in-one-file-configuration) + +EX: Lastfm Redirect Url is `BASE_URL:PORT/lastfm/callback` -- Set `BASE_URL=http://192.168.0.101` => Redirect URL is `http://192.168.0.101:9078/lastfm/callback` (when no other redirectUri is specified for [lastfm configuration](#lastfm)) + +Useful when running with [docker](../installation/installation.md#docker) so that you do not need to specify redirect URLs for each configuration. + # Source Configurations ## [Spotify](https://www.spotify.com) diff --git a/docsite/docs/installation/installation.md b/docsite/docs/installation/installation.md index ee65be0d..765f25d0 100644 --- a/docsite/docs/installation/installation.md +++ b/docsite/docs/installation/installation.md @@ -84,7 +84,7 @@ Recommended configuration steps for docker or docker-compose usage: #### Storage -You **must** bind a host directory into the container for storing configurations and credentials. Otherwise these will be lost when the container is updated. +You **must** bind a host directory into the container for storing configurations and credentials. Otherwise, these will be lost when the container is updated. * [Using `-v` method for docker](https://docs.docker.com/storage/bind-mounts/#start-a-container-with-a-bind-mount): `-v /path/on/host/config:/config` * [Using docker-compose](https://docs.docker.com/compose/compose-file/compose-file-v3/#short-syntax-3): `- /path/on/host/config:/config` @@ -98,6 +98,20 @@ The default container port is `9078`. To map container to host port: * With [docker](https://docs.docker.com/engine/reference/commandline/run/#publish): `-p 9078:9078` (first port is the port on the host to use) * With [docker-compose](https://docs.docker.com/compose/compose-file/compose-file-v3/#short-syntax-1): `- "9078:9078"` +##### Base URL + +Optionally, when + +* using a [Source or Client](../configuration/configuration.md) that has a "Redirect URI" that you have not explicitly defined +* and + * using a bridge network or + * installing MS on a different machine than the one used to view the dashboard + +set the [Base URL](../configuration/configuration.md#base-url) as the IP of the host machine. (This is the IP you would use to view the dashboard in a browser) + +* With docker: `-e BASE_URL="http://hostMachineIP"` (first port is the port on the host to use) +* With docker-compose: [see comments in docker-compose.yml](../../../docker-compose.yml) + #### Other * (Optionally) set the [timezone](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) for the container using the environmental variable `TZ` ([docker](https://docs.docker.com/engine/reference/commandline/run/#env)) ([docker-compose](https://docs.docker.com/compose/compose-file/compose-file-v3/#environment)) @@ -120,6 +134,8 @@ To get the UID and GID for the current user run these commands from a terminal: ## Docker Usage Examples +If installing on a different machine make sure all redirect URIs are defined or that you have set a [Base URL](#base-url). + ### Using [env-based](../configuration/configuration.md#env-based-configuration) configuration ```bash diff --git a/docsite/static/configExample.json b/docsite/static/configExample.json index bd49c58f..b4b50f09 100644 --- a/docsite/static/configExample.json +++ b/docsite/static/configExample.json @@ -8,6 +8,7 @@ "maxRequestRetries": 1, "retryMultiplier": 1.5 }, + "baseUrl": "http://localhost", "sources": [ { "type": "spotify", diff --git a/src/backend/common/infrastructure/config/aioConfig.ts b/src/backend/common/infrastructure/config/aioConfig.ts index 7e468b55..9ff3c07b 100644 --- a/src/backend/common/infrastructure/config/aioConfig.ts +++ b/src/backend/common/infrastructure/config/aioConfig.ts @@ -28,6 +28,20 @@ export interface AIOConfig { * */ port?: number + /** + * Set the Base URL the application should assume the UI is served from. + * + * This will affect how default redirect URLs are generated (spotify, lastfm, deezer) and some logging messages. + * + * It will NOT set the actual interface/IP that the application is listening on. + * + * This can also be set using the BASE_URL environmental variable. + * + * @default "http://localhost" + * @examples ["http://localhost", "http://192.168.0.101", "https://ms.myDomain.tld"] + * */ + baseUrl?: string + logging?: LogOptions /** diff --git a/src/backend/common/schema/aio.json b/src/backend/common/schema/aio.json index 7cbe5d05..94ec3e58 100644 --- a/src/backend/common/schema/aio.json +++ b/src/backend/common/schema/aio.json @@ -2479,6 +2479,17 @@ } }, "properties": { + "baseUrl": { + "default": "http://localhost", + "description": "Set the Base URL the application should assume the UI is served from.\n\nThis will affect how default redirect URLs are generated (spotify, lastfm, deezer) and some logging messages.\n\nIt will NOT set the actual interface/IP that the application is listening on.\n\nThis can also be set using the BASE_URL environmental variable.", + "examples": [ + "http://localhost", + "http://192.168.0.101", + "https://ms.myDomain.tld" + ], + "title": "baseUrl", + "type": "string" + }, "clientDefaults": { "$ref": "#/definitions/RequestRetryOptions", "title": "clientDefaults" diff --git a/src/backend/common/vendor/LastfmApiClient.ts b/src/backend/common/vendor/LastfmApiClient.ts index 193c9400..f67305a4 100644 --- a/src/backend/common/vendor/LastfmApiClient.ts +++ b/src/backend/common/vendor/LastfmApiClient.ts @@ -25,10 +25,10 @@ export default class LastfmApiClient extends AbstractApiClient { user?: string; declare config: LastfmData; - constructor(name: any, config: Partial & {configDir: string}, options = {}) { + constructor(name: any, config: Partial & {configDir: string, localUrl: string}, options = {}) { super('lastfm', name, config, options); const {redirectUri, apiKey, secret, session, configDir} = config; - this.redirectUri = `${redirectUri}?state=${name}`; + this.redirectUri = `${redirectUri ?? `${config.localUrl}/lastfm/callback`}?state=${name}`; if (apiKey === undefined) { this.logger.warn("'apiKey' not found in config!"); } @@ -109,8 +109,7 @@ export default class LastfmApiClient extends AbstractApiClient { } getAuthUrl = () => { - const redir = `${this.config.redirectUri}?state=${this.name}`; - return `http://www.last.fm/api/auth/?api_key=${this.config.apiKey}&cb=${encodeURIComponent(redir)}` + return `http://www.last.fm/api/auth/?api_key=${this.config.apiKey}&cb=${encodeURIComponent(this.redirectUri)}` } authenticate = async (token: any) => { @@ -146,6 +145,7 @@ export default class LastfmApiClient extends AbstractApiClient { testAuth = async () => { if (this.client.sessionKey === undefined) { this.logger.info('No session key found. User interaction for authentication required.'); + this.logger.info(`Redirect URL that will be used on auth callback: '${this.redirectUri}'`); return false; } try { diff --git a/src/backend/index.ts b/src/backend/index.ts index 5b01ea50..7164eb45 100644 --- a/src/backend/index.ts +++ b/src/backend/index.ts @@ -58,7 +58,6 @@ const configDir = process.env.CONFIG_DIR || path.resolve(projectDir, `./config`) const { webhooks = [], - port, logging = {}, debugMode, } = (config || {}) as AIOConfig; @@ -72,7 +71,7 @@ const configDir = process.env.CONFIG_DIR || path.resolve(projectDir, `./config`) process.env.DEBUG_MODE = b.toString(); } - const root = getRoot(port); + const root = getRoot(config); logger = getLogger(logging, 'app'); diff --git a/src/backend/ioc.ts b/src/backend/ioc.ts index 6fb9e76c..7c7acdaa 100644 --- a/src/backend/ioc.ts +++ b/src/backend/ioc.ts @@ -7,22 +7,25 @@ import {Notifiers} from "./notifier/Notifiers"; import {EventEmitter} from "events"; import {logPath} from "./common/logging"; import { WildcardEmitter } from "./common/WildcardEmitter"; -//const port = process.env.PORT ?? 9078; - -/*let logPath = path.resolve(projectDir, `./logs`); -if(typeof process.env.CONFIG_DIR === 'string') { - logPath = path.resolve(process.env.CONFIG_DIR, './logs'); -}*/ +import normalizeUrl from 'normalize-url'; let root: ReturnType; -const createRoot = (port: number | string | undefined) => { +export interface RootOptions { + baseUrl?: string, + port?: string | number +} + +const createRoot = (options?: RootOptions) => { + const { + port, + baseUrl = process.env.BASE_URL, + } = options || {}; const configDir = process.env.CONFIG_DIR || path.resolve(projectDir, `./config`); const envPort = process.env.PORT; return createContainer().add({ configDir: configDir, logDir: logPath, - //localUrl: `http://localhost:${port}`, isProd: process.env.NODE_ENV !== undefined && (process.env.NODE_ENV === 'production' || process.env.NODE_ENV === 'prod'), configPort: port, apiPort: process.env.API_PORT ?? 9079, @@ -35,20 +38,26 @@ const createRoot = (port: number | string | undefined) => { if (usedPort === undefined) { usedPort = items.isProd ? items.mainPort : items.apiPort; } - const localUrl = `http://localhost:${items.mainPort}`; + const base = normalizeUrl(baseUrl ?? 'http://localhost', {removeSingleSlash: true}); + const u = new URL(base); + let localUrl = u.toString(); + if(u.port === '' && u.pathname === '/') { + localUrl = `${u.origin}:${items.mainPort}`; + } return { port: usedPort, clients: () => new ScrobbleClients(items.clientEmitter, items.sourceEmitter, localUrl, items.configDir), sources: () => new ScrobbleSources(items.sourceEmitter, localUrl, items.configDir), notifiers: () => new Notifiers(items.notifierEmitter, items.clientEmitter, items.sourceEmitter), localUrl, + hasDefinedBaseUrl: baseUrl !== undefined } }); } -export const getRoot = (port?: number | string) => { +export const getRoot = (options?: RootOptions) => { if(root === undefined) { - root = createRoot(port); + root = createRoot(options); } return root; } diff --git a/src/backend/server/index.ts b/src/backend/server/index.ts index c63906ba..f13e3fdd 100644 --- a/src/backend/server/index.ts +++ b/src/backend/server/index.ts @@ -11,6 +11,7 @@ import { setupApi } from "./api"; import { getAddress, mergeArr, parseBool } from "../utils"; import {stripIndents} from "common-tags"; import {ErrorWithCause} from "pony-cause"; +import {or} from "ajv/dist/compile/codegen/index.js"; const buildDir = path.join(process.cwd() + "/build"); @@ -42,6 +43,8 @@ export const initServer = async (parentLogger: Logger, initialOutput: LogInfo[] const apiPort = root.get('apiPort'); const mainPort = root.get('mainPort'); const port = root.get('port'); + const local = root.get('localUrl'); + const localDefined = root.get('hasDefinedBaseUrl'); setupApi(app, logger, initialOutput); @@ -63,7 +66,7 @@ export const initServer = async (parentLogger: Logger, initialOutput: LogInfo[] --- HINT --- MS is likely being run in a container with BRIDGE networking which means the above addresses are not accessible from outside this container. To ensure the container is accessible make sure you have mapped the *container* port ${port} to a *host* port. https://foxxmd.github.io/multi-scrobbler/docs/installation#networking - The container will then be accessible at http://HOST_MACHINE_IP:HOST_PORT + The container will then be accessible at http://HOST_MACHINE_IP:HOST_PORT${localDefined ? ` (or ${local} since you defined this!)` : ''} --- HINT --- `; } @@ -84,6 +87,10 @@ export const initServer = async (parentLogger: Logger, initialOutput: LogInfo[] ${addresses.join('\n')}${dockerHint !== '' ? `\n${dockerHint}` : ''}` logger.info(start); + + if(localDefined) { + logger.info(`User-defined base URL for UI and redirect URLs (spotify, deezer, lastfm): ${local}`) + } } catch (e) { logger.error(new ErrorWithCause('Server crashed with uncaught exception', {cause: e})); } diff --git a/src/backend/sources/DeezerSource.ts b/src/backend/sources/DeezerSource.ts index 1271ee2a..df62931d 100644 --- a/src/backend/sources/DeezerSource.ts +++ b/src/backend/sources/DeezerSource.ts @@ -22,6 +22,7 @@ export default class DeezerSource extends AbstractSource { requiresAuthInteraction = true; baseUrl = 'https://api.deezer.com'; + redirectUri: string; declare config: DeezerSourceConfig; @@ -30,6 +31,7 @@ export default class DeezerSource extends AbstractSource { const { data: { interval = 60, + redirectUri, } = {}, } = config; @@ -38,6 +40,7 @@ export default class DeezerSource extends AbstractSource { } this.config.data.interval = interval; + this.redirectUri = redirectUri || `${this.localUrl}/deezer/callback`; this.workingCredsPath = `${this.configDir}/currentCreds-${name}.json`; this.canPoll = true; @@ -92,6 +95,8 @@ export default class DeezerSource extends AbstractSource { } else if(this.config.data.clientSecret === undefined) { throw new Error('clientSecret must be defined when accessToken is not present'); } + this.logger.info(`No access token is present. User interaction for authentication is required.`); + this.logger.info(`Redirect URL that will be used on auth callback: '${this.redirectUri}'`); } this.initialized = true; return this.initialized; @@ -185,7 +190,7 @@ export default class DeezerSource extends AbstractSource { return new DeezerStrategy({ clientID: this.config.data.clientId, clientSecret: this.config.data.clientSecret, - callbackURL: this.config.data.redirectUri || `${this.localUrl}/deezer/callback`, + callbackURL: this.redirectUri, scope: ['listening_history','offline_access'], }, (accessToken: any, refreshToken: any, profile: any, done: any) => { // return done(null, { diff --git a/src/backend/sources/LastfmSource.ts b/src/backend/sources/LastfmSource.ts index 225dd325..43034cf1 100644 --- a/src/backend/sources/LastfmSource.ts +++ b/src/backend/sources/LastfmSource.ts @@ -18,7 +18,7 @@ export default class LastfmSource extends AbstractSource { constructor(name: any, config: LastfmClientConfig, internal: InternalConfig, emitter: EventEmitter) { super('lastfm', name, config, internal, emitter); this.canPoll = true; - this.api = new LastfmApiClient(name, {...config.data, configDir: internal.configDir}); + this.api = new LastfmApiClient(name, {...config.data, configDir: internal.configDir, localUrl: internal.localUrl}); } static formatPlayObj(obj: any, options: FormatPlayObjectOptions = {}): PlayObject { diff --git a/src/backend/sources/SpotifySource.ts b/src/backend/sources/SpotifySource.ts index 61e46756..5fbfeb35 100644 --- a/src/backend/sources/SpotifySource.ts +++ b/src/backend/sources/SpotifySource.ts @@ -214,6 +214,11 @@ export default class SpotifySource extends MemorySource { throw new Error('Failed to initialize a Spotify source'); } + if(accessToken === undefined || refreshToken === undefined) { + this.logger.info(`No access or refresh token is present. User interaction for authentication is required.`); + this.logger.info(`Redirect URL that will be used on auth callback: '${rdUri}'`); + } + this.spotifyApi = new SpotifyWebApi(apiConfig); } @@ -227,6 +232,10 @@ export default class SpotifySource extends MemorySource { testAuth = async () => { try { + if(undefined === this.spotifyApi.getAccessToken()) { + this.authed = false; + return; + } await this.callApi>(((api: any) => api.getMe())); this.authed = true; } catch (e) { -- 2.51.2