import { Express } from 'express'; import { Logger } from "@foxxmd/logging"; import passport from "passport"; import { ExpressHandler } from "../common/infrastructure/Atomic.js"; import LastfmScrobbler from "../scrobblers/LastfmScrobbler.js"; import ScrobbleClients from "../scrobblers/ScrobbleClients.js"; import LastfmSource from "../sources/LastfmSource.js"; import ScrobbleSources from "../sources/ScrobbleSources.js"; import SpotifySource from "../sources/SpotifySource.js"; import YTMusicSource from "../sources/YTMusicSource.js"; import { sortAndDeduplicateDiagnostics } from "typescript"; import { source } from "common-tags"; import TealScrobbler from "../scrobblers/TealfmScrobbler.js"; import { parseRegexSingle } from "@foxxmd/regex-buddy-core"; import { BlueSkyOauthApiClient } from "../common/vendor/bluesky/BlueSkyOauthApiClient.js"; import LibrefmScrobbler from "../scrobblers/LibrefmScrobbler.js"; import LibrefmSource from "../sources/LibrefmSource.js"; import e from "express"; import AbstractSource from "../sources/AbstractSource.js"; export const setupAuthRoutes = (app: Express, logger: Logger, sourceMiddle: ExpressHandler, clientMiddle: ExpressHandler, scrobbleSources: ScrobbleSources, scrobbleClients: ScrobbleClients) => { app.use('/api/client/auth', clientMiddle); app.get('/api/client/auth', async (req, res) => { const { scrobbleClient, } = req as any; switch (scrobbleClient.type) { case 'lastfm': case 'librefm': res.redirect(scrobbleClient.api.getAuthUrl()); break; case 'tealfm': const url = await scrobbleClient.getAuthorizeUrl(); res.redirect(url); break; default: return res.status(400).send(`Specified client does not have auth implemented (${scrobbleClient.type})`); } }); app.use('/api/source/auth', sourceMiddle); app.get('/api/source/auth', async (req, res, next) => { const { // @ts-expect-error TS(2339): Property 'scrobbleSource' does not exist on type '... Remove this comment to see the full error message scrobbleSource: source, // @ts-expect-error TS(2339): Property 'sourceName' does not exist on type 'Requ... Remove this comment to see the full error message sourceName: name, } = req; switch (source.type) { case 'spotify': if (source.spotifyApi === undefined) { res.status(400).send('Spotify configuration is not valid'); } else { logger.info('Redirecting to spotify authorization url'); res.redirect(source.createAuthUrl()); } break; case 'lastfm': case 'librefm': res.redirect(source.api.getAuthUrl()); break; case 'deezer': // @ts-expect-error TS(2339): Property 'deezerSource' does not exist on type 'Se... Remove this comment to see the full error message req.session.deezerSource = name; return passport.authenticate(`deezer-${source.name}`)(req,res,next); case 'ytmusic': await (source as YTMusicSource).reauthenticate(); res.redirect((source as YTMusicSource).verificationUrl); break; default: return res.status(400).send(`Specified source does not have auth implemented (${source.type})`); } }); app.get(/.*callback$/, async (req, res, next) => { if(req.url.indexOf('/api') !== 0) { return res.redirect(307, `/api${req.url}`); } const { query: { state, name } = {} } = req; const entityTypeHint = req.url.includes('lastfm') ? 'lastfm' : 'librefm'; if (req.url.includes('lastfm') || req.url.includes('librefm')) { const { query: { token } = {} } = req; let entity: LastfmScrobbler | LastfmSource | LibrefmScrobbler | LibrefmSource | undefined; if(req.url.includes('lastfm')) { entity = scrobbleClients.getByNameAndType(state as string, 'lastfm', true) as (LastfmScrobbler | LastfmSource | undefined); } else { entity = scrobbleClients.getByNameAndType(state as string, 'librefm', true) as (LibrefmScrobbler | LibrefmSource | undefined); } if(entity === undefined) { entity = scrobbleSources.getByName(state, true) as LastfmSource | LibrefmSource; } try { if(entity === undefined) { const error = new Error(`No source/client with type '${entityTypeHint}' and name ${state} was found`); logger.error(error); throw error; } await entity.api.authenticate(token); entity.authFailure = false; if(entity instanceof AbstractSource) { entity.poll().catch((e) => logger.error(e)); } else { entity.initialize() .catch((e) => logger.error(e)) .then(() => { entity.initScrobbleMonitoring().catch((e) => logger.error(e)); }) } return res.send('OK'); } catch (e) { return res.send(e.message); } } else if(req.url.includes('ytmusic')) { const entity: YTMusicSource | undefined = scrobbleSources.getByName(name) as (YTMusicSource | undefined); if(entity === undefined) { logger.error(`No YTMUsic source with name ${state} was found`); } const result = await entity.handleAuthCodeCallback(req.query); let responseContent = 'OK'; if(result === true) { entity.authFailure = false; entity.poll().catch((e) => logger.error(e)); } else { responseContent = result; } return res.send(responseContent); } else { // TODO right now all sources requiring source interaction are covered by logic branches (deezer above and spotify here) // but eventually should update all source callbacks to url specific URLS to avoid ambiguity... // wish we could use state param to identify name/source but not all auth strategies and auth provides may provide access to that logger.info('Received auth code callback from Spotify', {label: 'Spotify'}); const source = scrobbleSources.getByNameAndType(state as string, 'spotify', true) as SpotifySource; const tokenResult = await source.handleAuthCodeCallback(req.query); let responseContent = 'OK'; if (tokenResult === true) { source.authFailure = false; source.poll().catch((e) => logger.error(e)); } else { responseContent = tokenResult; } return res.send(responseContent); } }); app.get(/(\/api\/tealfm\/.*)/, async function (req, res) { const clients = scrobbleClients.getByType('tealfm') as TealScrobbler[]; if (clients.length === 0) { logger.warn('Received callback to Teal OAuth but no TealFM scrobble clients are configured'); } // const { // query: { // state // } = {} // } = req; // const name = (state as string).replace('tfm',''); // const validClient = clients.find(x => x.name === name); // if (validClient === undefined) { // logger.warn(`No Tealfm scrobble matched => URL: ${req.originalUrl} | State: ${state}`); // } const intents = getTealUrlIntent(req.originalUrl); if(intents === undefined) { logger.warn(`Tealfm url was not formed correctly. Should be '/api/tealfm/SCROBBLER_NAME/SOME_ACTION' but found ${req.originalUrl}`); return res.status(404); } const validClient = clients.find(x => x.name === intents[0]); if(validClient === undefined) { logger.warn(`No Tealfm client found with the name ${intents[0]}. Url: ${req.originalUrl}`); return res.status(404); } if(intents[1].includes('login')) { const { query: { handle } = {} } = req; const url = await (validClient.client as BlueSkyOauthApiClient).createAuthorizeUrl(handle as string); res.redirect(url) } if(intents[1].includes('client-metadata.json')) { return res.json((validClient.client as BlueSkyOauthApiClient).getMetadata()); } if(intents[1].includes('oauth/callback')) { const result = await (validClient.client as BlueSkyOauthApiClient).handleCallback(new URLSearchParams(req.query as Record)); if(result) { return res.status(200); } return res.status(500); } }); } const TEAL_NAME_REGEX = new RegExp(/\/api\/tealfm\/([^\/])\/(.*)/); const getTealUrlIntent = (url: string): [string, string] | undefined => { const res = parseRegexSingle(TEAL_NAME_REGEX, url); if(res === undefined) { return undefined; } return res.groups as [string, string]; }