// 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; function normalizeUpdateIntervalUnit(value) { const unit = String(value || '').trim().toLowerCase(); return unit === 'seconds' ? 'seconds' : 'minutes'; } function normalizeAuthMethod(value) { const method = String(value || '').trim().toLowerCase(); return ['basic', 'bearer', 'api_key_header'].includes(method) ? method : 'none'; } function getItemsPath(source) { return String(source && (source.items_path || source.itemsPath) || '').trim(); } 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, auth_method, auth_username, auth_password, auth_bearer_token, auth_header_name, auth_header_value, items_path, 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, auth_method, auth_username, auth_password, auth_bearer_token, auth_header_name, auth_header_value, items_path, 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, auth_method, auth_username, auth_password, auth_bearer_token, auth_header_name, auth_header_value, items_path, 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 : {}; if (typeof fetch === 'function') { const response = await fetch(urlValue, { headers: Object.assign({ Accept: 'application/json, text/plain;q=0.9, */*;q=0.8', 'User-Agent': 'Pulse Signage API Reader' }, extraHeaders) }); 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 requestHeaders = Object.assign({ Accept: 'application/json, text/plain;q=0.9, */*;q=0.8', 'User-Agent': 'Pulse Signage API Reader' }, extraHeaders); const request = transport.get(url, Object.assign({}, requestOptions || {}, { headers: requestHeaders }), 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); }); request.on('error', reject); }); } async function fetchApiSourceResponse(apiSource) { const source = apiSource && typeof apiSource === 'object' ? apiSource : { api_url: apiSource }; const response = await loadUrlText(source.api_url, { headers: buildAuthHeaders(source) }); 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 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 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; } return { name: name, apiUrl: parsedUrl.toString(), authMethod: authMethod, authUsername: authUsername, authPassword: authPassword, authBearerToken: authBearerToken, authHeaderName: authHeaderName, authHeaderValue: authHeaderValue, itemsPath: itemsPath, updateIntervalValue: Math.floor(updateIntervalValue), updateIntervalUnit: updateIntervalUnit }; } module.exports = { fetchApiSourcesData: fetchApiSourcesData, fetchApiSourcesPage: fetchApiSourcesPage, fetchApiSourceById: fetchApiSourceById, fetchApiSourceResponse: fetchApiSourceResponse, buildApiSourcePayload: buildApiSourcePayload };