// API source data access, pagination, and remote fetch helpers. const http = require('http'); const https = require('https'); const { fetchPagedRows, validateMaxLength } = require('./utils'); const NAME_MAX_LENGTH = 255; const URL_MAX_LENGTH = 1024; const AUTH_MAX_LENGTH = 255; const ITEMS_PATH_MAX_LENGTH = 255; const REQUEST_BODY_MAX_LENGTH = 1000000; const TOKEN_URL_MAX_LENGTH = 1024; const TOKEN_RESPONSE_PATH_MAX_LENGTH = 255; const TOKEN_HEADER_PREFIX_MAX_LENGTH = 64; const tokenCache = new Map(); const tokenRequests = new Map(); function normalizeUpdateIntervalUnit(value) { const unit = String(value || '').trim().toLowerCase(); if (unit === 'seconds' || unit === 'minutes' || unit === 'hours') { return unit; } return 'minutes'; } function normalizeAuthMethod(value) { const method = String(value || '').trim().toLowerCase(); return ['basic', 'bearer', 'api_key_header', 'token_login'].includes(method) ? method : 'none'; } function normalizeRequestMethod(value) { return String(value || '').trim().toUpperCase() === 'POST' ? 'POST' : 'GET'; } function buildAuthHeaders(source) { const method = normalizeAuthMethod(source && (source.auth_method || source.authMethod)); const headers = {}; if (method === 'basic') { const username = String(source && (source.auth_username || source.authUsername) || '').trim(); const password = String(source && (source.auth_password || source.authPassword) || ''); if (username || password) { headers.Authorization = 'Basic ' + Buffer.from(username + ':' + password, 'utf8').toString('base64'); } } else if (method === 'bearer') { const token = String(source && (source.auth_bearer_token || source.authBearerToken) || '').trim(); if (token) { headers.Authorization = 'Bearer ' + token; } } else if (method === 'api_key_header') { const headerName = String(source && (source.auth_header_name || source.authHeaderName) || 'X-API-Key').trim() || 'X-API-Key'; const headerValue = String(source && (source.auth_header_value || source.authHeaderValue) || '').trim(); if (headerValue) { headers[headerName] = headerValue; } } return headers; } async function fetchApiSourcesData(pool) { const [apiSources] = await pool.query( 'SELECT id, name, api_url, request_method, request_body_json, auth_method, auth_username, auth_password, auth_bearer_token, auth_header_name, auth_header_value, token_url, token_request_body_json, token_response_path, token_refresh_url, token_refresh_request_body_json, token_refresh_response_path, token_header_name, token_header_prefix, items_path, enabled, update_interval_value, update_interval_unit, last_pulled_at, last_pull_error, last_response_status, last_response_content_type, last_response_json, created_at, modified_at, created_by, modified_by FROM i_api_sources ORDER BY modified_at DESC, id DESC' ); return { apiSources: apiSources }; } async function fetchApiSourcesPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) { const paged = await fetchPagedRows(pool, { selectSql: 'SELECT id, name, api_url, request_method, request_body_json, auth_method, auth_username, auth_password, auth_bearer_token, auth_header_name, auth_header_value, token_url, token_request_body_json, token_response_path, token_refresh_url, token_refresh_request_body_json, token_refresh_response_path, token_header_name, token_header_prefix, items_path, enabled, update_interval_value, update_interval_unit, last_pulled_at, last_pull_error, last_response_status, last_response_content_type, last_response_json, created_at, modified_at, created_by, modified_by FROM i_api_sources ORDER BY modified_at DESC, id DESC', countSql: 'SELECT COUNT(*) AS count FROM i_api_sources', searchColumns: ['name', 'api_url', 'last_pull_error'], searchTerm: searchTerm, sortColumns: { name: 'name', url: 'api_url', interval: ['update_interval_value', 'update_interval_unit'], last_pulled: 'last_pulled_at', last_response: 'last_response_status', created: 'created_at', modified: 'modified_at' }, sortKey: sortKey, sortDirection: sortDirection, page: page, pageSize: pageSize }); return Object.assign({ apiSources: paged.rows }, paged); } async function fetchApiSourceById(pool, id) { const [rows] = await pool.query( 'SELECT id, name, api_url, request_method, request_body_json, auth_method, auth_username, auth_password, auth_bearer_token, auth_header_name, auth_header_value, token_url, token_request_body_json, token_response_path, token_refresh_url, token_refresh_request_body_json, token_refresh_response_path, token_header_name, token_header_prefix, items_path, enabled, update_interval_value, update_interval_unit, last_pulled_at, last_pull_error, last_response_status, last_response_content_type, last_response_json, created_at, modified_at, created_by, modified_by FROM i_api_sources WHERE id = ?', [id] ); return rows[0] || null; } async function loadUrlText(urlValue, requestOptions) { const extraHeaders = requestOptions && requestOptions.headers ? requestOptions.headers : {}; const method = String(requestOptions && requestOptions.method || 'GET').toUpperCase(); const body = requestOptions && requestOptions.body !== undefined ? requestOptions.body : undefined; const headers = Object.assign({ Accept: 'application/json, text/plain;q=0.9, */*;q=0.8', 'User-Agent': 'Pulse Signage API Reader' }, extraHeaders); if (typeof fetch === 'function') { const fetchOptions = { method: method, headers: headers }; if (body !== undefined && method !== 'GET' && method !== 'HEAD') { fetchOptions.body = body; } const response = await fetch(urlValue, fetchOptions); return { statusCode: response.status, contentType: response.headers.get('content-type') || '', bodyText: await response.text(), ok: response.ok }; } return await new Promise(function (resolve, reject) { const url = new URL(urlValue); const transport = url.protocol === 'https:' ? https : http; const request = transport.request(url, Object.assign({}, requestOptions || {}, { method: method, headers: headers }), function (response) { response.setEncoding('utf8'); let body = ''; response.on('data', function (chunk) { body += chunk; }); response.on('end', function () { resolve({ statusCode: response.statusCode || 0, contentType: String(response.headers['content-type'] || ''), bodyText: body, ok: !response.statusCode || response.statusCode < 400 }); }); response.on('error', reject); }); if (body !== undefined && method !== 'GET' && method !== 'HEAD') { request.write(body); } request.end(); request.on('error', reject); }); } function parseJsonRequestBody(value, fieldName) { const text = String(value || '').trim(); if (!text) { return undefined; } try { return JSON.parse(text); } catch (_error) { const error = new Error(fieldName + ' must contain valid JSON.'); error.statusCode = 400; throw error; } } function resolveResponsePath(value, responsePath) { let current = value; String(responsePath || '').split('.').forEach(function (segment) { if (current === undefined || current === null) { current = undefined; return; } current = current[segment]; }); return current; } function getTokenCacheKey(source) { return JSON.stringify([ source && source.id || '', source && (source.token_url || source.tokenUrl) || '', source && (source.token_request_body_json || source.tokenRequestBodyJson) || '', source && (source.token_response_path || source.tokenResponsePath) || 'access_token', source && (source.token_refresh_url || source.tokenRefreshUrl) || '', source && (source.token_refresh_request_body_json || source.tokenRefreshRequestBodyJson) || '', source && (source.token_refresh_response_path || source.tokenRefreshResponsePath) || 'refresh_token', source && (source.token_header_name || source.tokenHeaderName) || 'Authorization', source && (source.token_header_prefix || source.tokenHeaderPrefix) || 'Bearer' ]); } function clearCachedToken(source) { tokenCache.delete(getTokenCacheKey(source)); } function replaceRefreshToken(value, refreshToken) { if (typeof value === 'string') { return value.split('{{refresh_token}}').join(refreshToken); } if (Array.isArray(value)) { return value.map(function (item) { return replaceRefreshToken(item, refreshToken); }); } if (value && typeof value === 'object') { return Object.keys(value).reduce(function (result, key) { result[key] = replaceRefreshToken(value[key], refreshToken); return result; }, {}); } return value; } function resolveTokenExpiry(parsed, token) { const expiresIn = Number(parsed && (parsed.expires_in || parsed.expiresIn)); if (Number.isFinite(expiresIn) && expiresIn > 0) { return { lifetimeMs: expiresIn * 1000 }; } const explicitExpiry = parsed && (parsed.expires_at || parsed.expiresAt); if (explicitExpiry !== undefined && explicitExpiry !== null) { const expiryNumber = Number(explicitExpiry); const expiryMs = Number.isFinite(expiryNumber) ? (expiryNumber < 100000000000 ? expiryNumber * 1000 : expiryNumber) : Date.parse(String(explicitExpiry)); if (Number.isFinite(expiryMs) && expiryMs > Date.now()) { return { expiresAt: expiryMs }; } } const tokenParts = String(token).split('.'); if (tokenParts.length === 3) { try { const payload = JSON.parse(Buffer.from(tokenParts[1], 'base64url').toString('utf8')); const expiryMs = Number(payload.exp) * 1000; if (Number.isFinite(expiryMs) && expiryMs > Date.now()) { return { expiresAt: expiryMs }; } } catch (_error) { // Opaque tokens do not contain a readable JWT expiry. } } return { lifetimeMs: 300000 }; } function cacheTokenResponse(source, parsed, previousRefreshToken) { const tokenPath = source.token_response_path || source.tokenResponsePath || 'access_token'; const token = resolveResponsePath(parsed, tokenPath); if (token === undefined || token === null || String(token).trim() === '') { throw new Error('Token response did not contain a token at the configured path.'); } const refreshPath = source.token_refresh_response_path || source.tokenRefreshResponsePath || 'refresh_token'; const responseRefreshToken = resolveResponsePath(parsed, refreshPath); const refreshToken = responseRefreshToken === undefined || responseRefreshToken === null || String(responseRefreshToken).trim() === '' ? previousRefreshToken : String(responseRefreshToken); const expiry = resolveTokenExpiry(parsed, token); const expiresAt = expiry.expiresAt || Date.now() + Math.max(1000, expiry.lifetimeMs - Math.min(60000, expiry.lifetimeMs * 0.1)); const record = { value: String(token), refreshToken: refreshToken, expiresAt: expiresAt }; tokenCache.set(getTokenCacheKey(source), record); return record; } async function parseTokenResponse(response, source, previousRefreshToken) { if (!response.ok) { return null; } let parsed; try { parsed = JSON.parse(String(response.bodyText || '').trim()); } catch (_error) { throw new Error('Token response was not valid JSON.'); } return cacheTokenResponse(source, parsed, previousRefreshToken); } async function refreshLoginToken(source, cached) { const refreshUrl = source.token_refresh_url || source.tokenRefreshUrl || source.token_url || source.tokenUrl; if (!cached || !cached.refreshToken) { return null; } const configuredBody = parseJsonRequestBody(source.token_refresh_request_body_json || source.tokenRefreshRequestBodyJson, 'Refresh request body'); const refreshBody = configuredBody === undefined ? { grant_type: 'refresh_token', refresh_token: cached.refreshToken } : replaceRefreshToken(configuredBody, cached.refreshToken); const response = await loadUrlText(refreshUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(refreshBody) }); return parseTokenResponse(response, source, cached.refreshToken); } async function fetchLoginTokenUncached(source) { const cacheKey = getTokenCacheKey(source); const cached = tokenCache.get(cacheKey); if (cached && cached.expiresAt > Date.now()) { return cached; } if (cached && cached.refreshToken) { const refreshed = await refreshLoginToken(source, cached); if (refreshed) { return refreshed; } } const tokenUrl = source.token_url || source.tokenUrl; const tokenBody = parseJsonRequestBody(source.token_request_body_json || source.tokenRequestBodyJson, 'Login request body'); const tokenHeaders = { 'Content-Type': 'application/json' }; const response = await loadUrlText(tokenUrl, { method: 'POST', headers: tokenHeaders, body: tokenBody === undefined ? undefined : JSON.stringify(tokenBody) }); const record = await parseTokenResponse(response, source, cached && cached.refreshToken); if (!record) { throw new Error(`Unable to obtain API token (${response.statusCode}).`); } return record; } async function fetchLoginToken(source) { const cacheKey = getTokenCacheKey(source); const cached = tokenCache.get(cacheKey); if (cached && cached.expiresAt > Date.now()) { return cached.value; } if (!tokenRequests.has(cacheKey)) { tokenRequests.set(cacheKey, fetchLoginTokenUncached(source).finally(function () { tokenRequests.delete(cacheKey); })); } return (await tokenRequests.get(cacheKey)).value; } async function buildRequestHeaders(source) { const method = normalizeAuthMethod(source && (source.auth_method || source.authMethod)); if (method !== 'token_login') { return buildAuthHeaders(source); } const token = await fetchLoginToken(source); const headerName = String(source.token_header_name || source.tokenHeaderName || 'Authorization').trim() || 'Authorization'; const prefix = String(source.token_header_prefix || source.tokenHeaderPrefix || 'Bearer').trim(); return { [headerName]: prefix ? prefix + ' ' + token : token }; } async function fetchApiSourceResponse(apiSource) { const source = apiSource && typeof apiSource === 'object' ? apiSource : { api_url: apiSource }; const requestMethod = normalizeRequestMethod(source.request_method || source.requestMethod); const requestBody = parseJsonRequestBody(source.request_body_json || source.requestBodyJson, 'API request body'); let response; let tokenRetry = false; do { response = await loadUrlText(source.api_url || source.apiUrl, { method: requestMethod, headers: Object.assign({}, await buildRequestHeaders(source), requestBody === undefined ? {} : { 'Content-Type': 'application/json' }), body: requestBody === undefined ? undefined : JSON.stringify(requestBody) }); if (response.statusCode === 401 && normalizeAuthMethod(source.auth_method || source.authMethod) === 'token_login' && !tokenRetry) { clearCachedToken(source); tokenRetry = true; } else { break; } } while (true); if (!response.ok) { throw new Error(`Unable to load API response (${response.statusCode}).`); } const text = String(response.bodyText || '').trim(); if (!text) { throw new Error('API response did not return JSON.'); } let parsed; try { parsed = JSON.parse(text); } catch (_error) { throw new Error('API response was not valid JSON.'); } return { responseJson: JSON.stringify(parsed, null, 2), responseStatus: response.statusCode, responseContentType: response.contentType }; } function buildApiSourcePayload(req, existingApiSource) { const fallback = existingApiSource || {}; const body = req && req.body ? req.body : {}; function readBodyValue(fieldName, fallbackValue) { if (Object.prototype.hasOwnProperty.call(body, fieldName)) { return body[fieldName]; } return fallbackValue; } const name = validateMaxLength(req.body.name || fallback.name || '', NAME_MAX_LENGTH, 'API source name'); const apiUrl = validateMaxLength(req.body.api_url || req.body.apiUrl || fallback.api_url || '', URL_MAX_LENGTH, 'API source URL'); const authMethod = normalizeAuthMethod(readBodyValue('auth_method', readBodyValue('authMethod', fallback.auth_method || 'none'))); const requestMethod = normalizeRequestMethod(readBodyValue('request_method', readBodyValue('requestMethod', fallback.request_method || 'GET'))); const requestBodyJson = validateMaxLength(readBodyValue('request_body_json', readBodyValue('requestBodyJson', fallback.request_body_json || '')) || '', REQUEST_BODY_MAX_LENGTH, 'API request body'); const authUsername = validateMaxLength(readBodyValue('auth_username', readBodyValue('authUsername', fallback.auth_username || '')) || '', AUTH_MAX_LENGTH, 'API source username'); const authPassword = validateMaxLength(readBodyValue('auth_password', readBodyValue('authPassword', fallback.auth_password || '')) || '', AUTH_MAX_LENGTH, 'API source password'); const authBearerToken = validateMaxLength(readBodyValue('auth_bearer_token', readBodyValue('authBearerToken', fallback.auth_bearer_token || '')) || '', AUTH_MAX_LENGTH, 'API source bearer token'); const authHeaderName = validateMaxLength(readBodyValue('auth_header_name', readBodyValue('authHeaderName', fallback.auth_header_name || 'X-API-Key')) || 'X-API-Key', AUTH_MAX_LENGTH, 'API source header name') || 'X-API-Key'; const authHeaderValue = validateMaxLength(readBodyValue('auth_header_value', readBodyValue('authHeaderValue', fallback.auth_header_value || '')) || '', AUTH_MAX_LENGTH, 'API source header value'); const tokenUrl = validateMaxLength(readBodyValue('token_url', readBodyValue('tokenUrl', fallback.token_url || '')) || '', TOKEN_URL_MAX_LENGTH, 'API token URL'); const tokenRequestBodyJson = validateMaxLength(readBodyValue('token_request_body_json', readBodyValue('tokenRequestBodyJson', fallback.token_request_body_json || '')) || '', REQUEST_BODY_MAX_LENGTH, 'Login request body'); const tokenResponsePath = validateMaxLength(readBodyValue('token_response_path', readBodyValue('tokenResponsePath', fallback.token_response_path || 'access_token')) || 'access_token', TOKEN_RESPONSE_PATH_MAX_LENGTH, 'Token response path'); const tokenRefreshUrl = validateMaxLength(readBodyValue('token_refresh_url', readBodyValue('tokenRefreshUrl', fallback.token_refresh_url || '')) || '', TOKEN_URL_MAX_LENGTH, 'API refresh URL'); const tokenRefreshRequestBodyJson = validateMaxLength(readBodyValue('token_refresh_request_body_json', readBodyValue('tokenRefreshRequestBodyJson', fallback.token_refresh_request_body_json || '')) || '', REQUEST_BODY_MAX_LENGTH, 'Refresh request body'); const tokenRefreshResponsePath = validateMaxLength(readBodyValue('token_refresh_response_path', readBodyValue('tokenRefreshResponsePath', fallback.token_refresh_response_path || 'refresh_token')) || 'refresh_token', TOKEN_RESPONSE_PATH_MAX_LENGTH, 'Refresh token response path'); const tokenHeaderName = validateMaxLength(readBodyValue('token_header_name', readBodyValue('tokenHeaderName', fallback.token_header_name || 'Authorization')) || 'Authorization', AUTH_MAX_LENGTH, 'Token header name'); const tokenHeaderPrefix = validateMaxLength(readBodyValue('token_header_prefix', readBodyValue('tokenHeaderPrefix', fallback.token_header_prefix || 'Bearer')) || '', TOKEN_HEADER_PREFIX_MAX_LENGTH, 'Token prefix'); const itemsPath = validateMaxLength(readBodyValue('items_path', readBodyValue('itemsPath', fallback.items_path || '')) || '', ITEMS_PATH_MAX_LENGTH, 'API source items path'); const updateIntervalValue = Math.max(1, Number(req.body.update_interval_value || req.body.updateIntervalValue || fallback.update_interval_value || 60)); const updateIntervalUnit = normalizeUpdateIntervalUnit(req.body.update_interval_unit || req.body.updateIntervalUnit || fallback.update_interval_unit || 'minutes'); if (!name) { const error = new Error('API source name is required.'); error.statusCode = 400; throw error; } if (!apiUrl) { const error = new Error('API source URL is required.'); error.statusCode = 400; throw error; } let parsedUrl; try { parsedUrl = new URL(apiUrl); } catch (_error) { const error = new Error('Enter a valid API URL.'); error.statusCode = 400; throw error; } if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') { const error = new Error('API URL must start with http or https.'); error.statusCode = 400; throw error; } if (!Number.isFinite(updateIntervalValue)) { const error = new Error('Update interval must be a number.'); error.statusCode = 400; throw error; } if (authMethod === 'basic' && !authUsername && !authPassword) { const error = new Error('Basic auth requires a username or password.'); error.statusCode = 400; throw error; } if (authMethod === 'bearer' && !authBearerToken) { const error = new Error('Bearer auth requires a token.'); error.statusCode = 400; throw error; } if (authMethod === 'api_key_header' && !authHeaderValue) { const error = new Error('API key auth requires a header value.'); error.statusCode = 400; throw error; } parseJsonRequestBody(requestBodyJson, 'API request body'); parseJsonRequestBody(tokenRequestBodyJson, 'Login request body'); parseJsonRequestBody(tokenRefreshRequestBodyJson, 'Refresh request body'); if (authMethod === 'token_login') { if (!tokenUrl) { const error = new Error('Token login requires a login URL.'); error.statusCode = 400; throw error; } try { const tokenParsedUrl = new URL(tokenUrl); if (tokenParsedUrl.protocol !== 'http:' && tokenParsedUrl.protocol !== 'https:') { throw new Error('invalid protocol'); } } catch (_error) { const error = new Error('Enter a valid API token URL.'); error.statusCode = 400; throw error; } if (!tokenRequestBodyJson) { const error = new Error('Token login requires a JSON request body.'); error.statusCode = 400; throw error; } } if (tokenRefreshUrl) { try { const refreshParsedUrl = new URL(tokenRefreshUrl); if (refreshParsedUrl.protocol !== 'http:' && refreshParsedUrl.protocol !== 'https:') { throw new Error('invalid protocol'); } } catch (_error) { const error = new Error('Enter a valid API refresh URL.'); error.statusCode = 400; throw error; } } return { name: name, apiUrl: parsedUrl.toString(), requestMethod: requestMethod, requestBodyJson: requestBodyJson, authMethod: authMethod, authUsername: authUsername, authPassword: authPassword, authBearerToken: authBearerToken, authHeaderName: authHeaderName, authHeaderValue: authHeaderValue, tokenUrl: tokenUrl, tokenRequestBodyJson: tokenRequestBodyJson, tokenResponsePath: tokenResponsePath, tokenRefreshUrl: tokenRefreshUrl, tokenRefreshRequestBodyJson: tokenRefreshRequestBodyJson, tokenRefreshResponsePath: tokenRefreshResponsePath, tokenHeaderName: tokenHeaderName, tokenHeaderPrefix: tokenHeaderPrefix, itemsPath: itemsPath, updateIntervalValue: Math.floor(updateIntervalValue), updateIntervalUnit: updateIntervalUnit }; } module.exports = { fetchApiSourcesData: fetchApiSourcesData, fetchApiSourcesPage: fetchApiSourcesPage, fetchApiSourceById: fetchApiSourceById, fetchApiSourceResponse: fetchApiSourceResponse, buildApiSourcePayload: buildApiSourcePayload };