Release 2.8.7
This commit is contained in:
@@ -2,6 +2,12 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## 2.8.7 - 2026-08-22
|
||||
|
||||
### Added
|
||||
|
||||
- Added configurable JSON POST requests and two-step login-then-token authentication for API sources.
|
||||
|
||||
## 2.8.6 - 2026-08-18
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "pulse-signage-player",
|
||||
"version": "2.8.6",
|
||||
"version": "2.8.7",
|
||||
"private": false,
|
||||
"description": "Pulse Signage player application bundle",
|
||||
"engines": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "pulse-signage-web",
|
||||
"version": "2.8.6",
|
||||
"version": "2.8.7",
|
||||
"private": false,
|
||||
"description": "Pulse Signage web and bridge application bundle",
|
||||
"engines": {
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "pulse-signage",
|
||||
"version": "2.8.6",
|
||||
"version": "2.8.7",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "pulse-signage",
|
||||
"version": "2.8.6",
|
||||
"version": "2.8.7",
|
||||
"dependencies": {
|
||||
"@sparticuz/chromium": "^149.0.0",
|
||||
"animate.css": "^4.1.1",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "pulse-signage",
|
||||
"version": "2.8.6",
|
||||
"version": "2.8.7",
|
||||
"private": false,
|
||||
"description": "Pulse Signage application with MySQL and media storage",
|
||||
"engines": {
|
||||
|
||||
+179
-17
@@ -8,6 +8,11 @@ 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();
|
||||
|
||||
function normalizeUpdateIntervalUnit(value) {
|
||||
const unit = String(value || '').trim().toLowerCase();
|
||||
@@ -19,7 +24,11 @@ function normalizeUpdateIntervalUnit(value) {
|
||||
|
||||
function normalizeAuthMethod(value) {
|
||||
const method = String(value || '').trim().toLowerCase();
|
||||
return ['basic', 'bearer', 'api_key_header'].includes(method) ? method : 'none';
|
||||
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) {
|
||||
@@ -50,7 +59,7 @@ function buildAuthHeaders(source) {
|
||||
|
||||
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'
|
||||
'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_header_name, token_header_prefix, 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 };
|
||||
@@ -58,7 +67,7 @@ async function fetchApiSourcesData(pool) {
|
||||
|
||||
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',
|
||||
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_header_name, token_header_prefix, 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,
|
||||
@@ -82,7 +91,7 @@ async function fetchApiSourcesPage(pool, page, pageSize, searchTerm, sortKey, so
|
||||
|
||||
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 = ?',
|
||||
'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_header_name, token_header_prefix, 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]
|
||||
);
|
||||
|
||||
@@ -91,13 +100,18 @@ async function fetchApiSourceById(pool, id) {
|
||||
|
||||
async function loadUrlText(urlValue, requestOptions) {
|
||||
const extraHeaders = requestOptions && requestOptions.headers ? requestOptions.headers : {};
|
||||
if (typeof fetch === 'function') {
|
||||
const response = await fetch(urlValue, {
|
||||
headers: Object.assign({
|
||||
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)
|
||||
});
|
||||
}, 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,
|
||||
@@ -110,12 +124,9 @@ async function loadUrlText(urlValue, requestOptions) {
|
||||
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
|
||||
const request = transport.request(url, Object.assign({}, requestOptions || {}, {
|
||||
method: method,
|
||||
headers: headers
|
||||
}), function (response) {
|
||||
response.setEncoding('utf8');
|
||||
let body = '';
|
||||
@@ -133,15 +144,126 @@ async function loadUrlText(urlValue, requestOptions) {
|
||||
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_header_name || source.tokenHeaderName) || 'Authorization',
|
||||
source && (source.token_header_prefix || source.tokenHeaderPrefix) || 'Bearer'
|
||||
]);
|
||||
}
|
||||
|
||||
function clearCachedToken(source) {
|
||||
tokenCache.delete(getTokenCacheKey(source));
|
||||
}
|
||||
|
||||
async function fetchLoginToken(source) {
|
||||
const cacheKey = getTokenCacheKey(source);
|
||||
const cached = tokenCache.get(cacheKey);
|
||||
if (cached && cached.expiresAt > Date.now()) {
|
||||
return cached.value;
|
||||
}
|
||||
|
||||
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)
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Unable to obtain API token (${response.statusCode}).`);
|
||||
}
|
||||
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(String(response.bodyText || '').trim());
|
||||
} catch (_error) {
|
||||
throw new Error('Token response was not valid JSON.');
|
||||
}
|
||||
|
||||
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 expiresIn = Number(parsed && (parsed.expires_in || parsed.expiresIn));
|
||||
const lifetimeMs = Number.isFinite(expiresIn) && expiresIn > 0
|
||||
? Math.max(30000, expiresIn * 1000 - 60000)
|
||||
: 300000;
|
||||
tokenCache.set(cacheKey, { value: String(token), expiresAt: Date.now() + lifetimeMs });
|
||||
return String(token);
|
||||
}
|
||||
|
||||
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 response = await loadUrlText(source.api_url, {
|
||||
headers: buildAuthHeaders(source)
|
||||
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}).`);
|
||||
}
|
||||
@@ -178,11 +300,18 @@ function buildApiSourcePayload(req, existingApiSource) {
|
||||
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 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');
|
||||
@@ -238,15 +367,48 @@ function buildApiSourcePayload(req, existingApiSource) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
parseJsonRequestBody(requestBodyJson, 'API request body');
|
||||
parseJsonRequestBody(tokenRequestBodyJson, 'Login 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;
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
tokenHeaderName: tokenHeaderName,
|
||||
tokenHeaderPrefix: tokenHeaderPrefix,
|
||||
itemsPath: itemsPath,
|
||||
updateIntervalValue: Math.floor(updateIntervalValue),
|
||||
updateIntervalUnit: updateIntervalUnit
|
||||
|
||||
@@ -233,6 +233,20 @@ async function ensureSchema(pool, options) {
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
api_url VARCHAR(1024) NOT NULL,
|
||||
request_method VARCHAR(10) NOT NULL DEFAULT 'GET',
|
||||
request_body_json MEDIUMTEXT NULL,
|
||||
auth_method VARCHAR(32) NOT NULL DEFAULT 'none',
|
||||
auth_username VARCHAR(255) NULL,
|
||||
auth_password MEDIUMTEXT NULL,
|
||||
auth_bearer_token MEDIUMTEXT NULL,
|
||||
auth_header_name VARCHAR(255) NULL,
|
||||
auth_header_value MEDIUMTEXT NULL,
|
||||
token_url VARCHAR(1024) NULL,
|
||||
token_request_body_json MEDIUMTEXT NULL,
|
||||
token_response_path VARCHAR(255) NULL DEFAULT 'access_token',
|
||||
token_header_name VARCHAR(255) NULL DEFAULT 'Authorization',
|
||||
token_header_prefix VARCHAR(64) NULL DEFAULT 'Bearer',
|
||||
items_path VARCHAR(255) NULL,
|
||||
update_interval_value INT NOT NULL DEFAULT 60,
|
||||
update_interval_unit VARCHAR(10) NOT NULL DEFAULT 'minutes',
|
||||
last_pulled_at TIMESTAMP NULL,
|
||||
|
||||
@@ -492,6 +492,19 @@ const VERSIONED_MIGRATIONS = [
|
||||
AND permissions.permission_key = 'audit-log.allow'`
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
version: '2.8.7',
|
||||
label: 'v2.8.7 API request and token authentication schema',
|
||||
run: async function (pool) {
|
||||
await ensureColumn(pool, 'i_api_sources', 'request_method', "VARCHAR(10) NOT NULL DEFAULT 'GET'", 'api_url');
|
||||
await ensureColumn(pool, 'i_api_sources', 'request_body_json', 'MEDIUMTEXT NULL', 'request_method');
|
||||
await ensureColumn(pool, 'i_api_sources', 'token_url', 'VARCHAR(1024) NULL', 'auth_header_value');
|
||||
await ensureColumn(pool, 'i_api_sources', 'token_request_body_json', 'MEDIUMTEXT NULL', 'token_url');
|
||||
await ensureColumn(pool, 'i_api_sources', 'token_response_path', "VARCHAR(255) NULL DEFAULT 'access_token'", 'token_request_body_json');
|
||||
await ensureColumn(pool, 'i_api_sources', 'token_header_name', "VARCHAR(255) NULL DEFAULT 'Authorization'", 'token_response_path');
|
||||
await ensureColumn(pool, 'i_api_sources', 'token_header_prefix', "VARCHAR(64) NULL DEFAULT 'Bearer'", 'token_header_name');
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
@@ -11,6 +11,9 @@
|
||||
var panels = Array.prototype.slice.call(form.querySelectorAll('[data-api-source-auth-panel]'));
|
||||
var bearerTokenInput = form.querySelector('[data-api-source-bearer-token-input]');
|
||||
var bearerTokenToggle = form.querySelector('[data-api-source-bearer-token-toggle]');
|
||||
var requestMethodSelect = form.querySelector('[data-api-source-request-method]');
|
||||
var requestSection = form.querySelector('[data-api-source-request-section]');
|
||||
var requestBodyInput = form.querySelector('[data-api-source-request-body]');
|
||||
|
||||
function updateBearerTokenToggle() {
|
||||
if (!bearerTokenInput || !bearerTokenToggle) {
|
||||
@@ -47,14 +50,28 @@
|
||||
});
|
||||
}
|
||||
|
||||
function updateRequestBodyVisibility() {
|
||||
if (!requestMethodSelect || !requestSection || !requestBodyInput) {
|
||||
return;
|
||||
}
|
||||
|
||||
var isPost = String(requestMethodSelect.value || 'GET').toUpperCase() === 'POST';
|
||||
requestSection.hidden = !isPost;
|
||||
}
|
||||
|
||||
if (methodSelect) {
|
||||
methodSelect.addEventListener('change', updatePanels);
|
||||
}
|
||||
|
||||
if (requestMethodSelect) {
|
||||
requestMethodSelect.addEventListener('change', updateRequestBodyVisibility);
|
||||
}
|
||||
|
||||
if (bearerTokenToggle && bearerTokenInput) {
|
||||
bearerTokenToggle.addEventListener('click', toggleBearerTokenVisibility);
|
||||
updateBearerTokenToggle();
|
||||
}
|
||||
|
||||
updatePanels();
|
||||
updateRequestBodyVisibility();
|
||||
}());
|
||||
@@ -21,6 +21,13 @@ function buildDuplicateApiSource(apiSource, duplicateName) {
|
||||
authBearerToken: apiSource.auth_bearer_token || '',
|
||||
authHeaderName: apiSource.auth_header_name || 'X-API-Key',
|
||||
authHeaderValue: apiSource.auth_header_value || '',
|
||||
requestMethod: apiSource.request_method || 'GET',
|
||||
requestBodyJson: apiSource.request_body_json || '',
|
||||
tokenUrl: apiSource.token_url || '',
|
||||
tokenRequestBodyJson: apiSource.token_request_body_json || '',
|
||||
tokenResponsePath: apiSource.token_response_path || 'access_token',
|
||||
tokenHeaderName: apiSource.token_header_name || 'Authorization',
|
||||
tokenHeaderPrefix: apiSource.token_header_prefix || 'Bearer',
|
||||
itemsPath: apiSource.items_path || ''
|
||||
});
|
||||
}
|
||||
|
||||
@@ -18,6 +18,13 @@ module.exports = function renderApiSourceEditPage(apiSource, data, message, curr
|
||||
authBearerToken: apiSource.auth_bearer_token || '',
|
||||
authHeaderName: apiSource.auth_header_name || 'X-API-Key',
|
||||
authHeaderValue: apiSource.auth_header_value || '',
|
||||
itemsPath: apiSource.items_path || ''
|
||||
tokenUrl: apiSource.token_url || '',
|
||||
tokenRequestBodyJson: apiSource.token_request_body_json || '',
|
||||
tokenResponsePath: apiSource.token_response_path || 'access_token',
|
||||
tokenHeaderName: apiSource.token_header_name || 'Authorization',
|
||||
tokenHeaderPrefix: apiSource.token_header_prefix || 'Bearer',
|
||||
itemsPath: apiSource.items_path || '',
|
||||
requestMethod: apiSource.request_method || 'GET',
|
||||
requestBodyJson: apiSource.request_body_json || ''
|
||||
}), message, currentUser, viewData, true));
|
||||
};
|
||||
@@ -5,12 +5,19 @@ function buildDefaultApiSource() {
|
||||
id: null,
|
||||
name: '',
|
||||
apiUrl: '',
|
||||
requestMethod: 'GET',
|
||||
requestBodyJson: '',
|
||||
authMethod: 'none',
|
||||
authUsername: '',
|
||||
authPassword: '',
|
||||
authBearerToken: '',
|
||||
authHeaderName: 'X-API-Key',
|
||||
authHeaderValue: '',
|
||||
tokenUrl: '',
|
||||
tokenRequestBodyJson: '',
|
||||
tokenResponsePath: 'access_token',
|
||||
tokenHeaderName: 'Authorization',
|
||||
tokenHeaderPrefix: 'Bearer',
|
||||
itemsPath: '',
|
||||
updateIntervalValue: 60,
|
||||
updateIntervalUnit: 'minutes',
|
||||
|
||||
@@ -103,6 +103,8 @@ module.exports = function registerApiSourceRoutes(app, deps) {
|
||||
authBearerToken: apiSource.auth_bearer_token || '',
|
||||
authHeaderName: apiSource.auth_header_name || 'X-API-Key',
|
||||
authHeaderValue: apiSource.auth_header_value || '',
|
||||
tokenUrl: apiSource.token_url || '',
|
||||
tokenResponsePath: apiSource.token_response_path || 'access_token',
|
||||
itemsPath: apiSource.items_path || '',
|
||||
intervalLabel: apiSource.update_interval_unit === 'seconds'
|
||||
? (Math.max(1, Number(apiSource.update_interval_value) || 0) === 1 ? 'Every second' : `Every ${Math.max(1, Number(apiSource.update_interval_value) || 0)} seconds`)
|
||||
@@ -178,6 +180,11 @@ module.exports = function registerApiSourceRoutes(app, deps) {
|
||||
authBearerToken: apiSource.auth_bearer_token || '',
|
||||
authHeaderName: apiSource.auth_header_name || 'X-API-Key',
|
||||
authHeaderValue: apiSource.auth_header_value || '',
|
||||
tokenUrl: apiSource.token_url || '',
|
||||
tokenRequestBodyJson: apiSource.token_request_body_json || '',
|
||||
tokenResponsePath: apiSource.token_response_path || 'access_token',
|
||||
tokenHeaderName: apiSource.token_header_name || 'Authorization',
|
||||
tokenHeaderPrefix: apiSource.token_header_prefix || 'Bearer',
|
||||
itemsPath: apiSource.items_path || '',
|
||||
updateIntervalValue: apiSource.update_interval_value,
|
||||
updateIntervalUnit: apiSource.update_interval_unit || 'minutes',
|
||||
@@ -235,8 +242,8 @@ module.exports = function registerApiSourceRoutes(app, deps) {
|
||||
const actorId = getAuditUserId(req);
|
||||
await connection.beginTransaction();
|
||||
const [result] = await connection.query(
|
||||
'INSERT INTO i_api_sources (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_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
[payload.name, payload.apiUrl, payload.authMethod, payload.authUsername || null, payload.authPassword || null, payload.authBearerToken || null, payload.authHeaderName || null, payload.authHeaderValue || null, payload.itemsPath || null, payload.updateIntervalValue, payload.updateIntervalUnit, null, null, null, null, null, actorId, actorId]
|
||||
'INSERT INTO i_api_sources (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_header_name, token_header_prefix, 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_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
[payload.name, payload.apiUrl, payload.requestMethod, payload.requestBodyJson || null, payload.authMethod, payload.authUsername || null, payload.authPassword || null, payload.authBearerToken || null, payload.authHeaderName || null, payload.authHeaderValue || null, payload.tokenUrl || null, payload.tokenRequestBodyJson || null, payload.tokenResponsePath || null, payload.tokenHeaderName || null, payload.tokenHeaderPrefix || null, payload.itemsPath || null, payload.updateIntervalValue, payload.updateIntervalUnit, null, null, null, null, null, actorId, actorId]
|
||||
);
|
||||
await connection.commit();
|
||||
dataSourceTasks.registerRecurringRefresh('api-source', result.insertId, payload.name, payload.updateIntervalValue, payload.updateIntervalUnit, function () {
|
||||
@@ -288,8 +295,8 @@ module.exports = function registerApiSourceRoutes(app, deps) {
|
||||
const actorId = getAuditUserId(req);
|
||||
await connection.beginTransaction();
|
||||
await connection.query(
|
||||
'UPDATE i_api_sources SET 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 = ?, modified_by = ? WHERE id = ?',
|
||||
[payload.name, payload.apiUrl, payload.authMethod, payload.authUsername || null, payload.authPassword || null, payload.authBearerToken || null, payload.authHeaderName || null, payload.authHeaderValue || null, payload.itemsPath || null, payload.updateIntervalValue, payload.updateIntervalUnit, null, null, null, null, null, actorId, apiSource.id]
|
||||
'UPDATE i_api_sources SET 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_header_name = ?, token_header_prefix = ?, items_path = ?, update_interval_value = ?, update_interval_unit = ?, last_pulled_at = ?, last_pull_error = ?, last_response_status = ?, last_response_content_type = ?, last_response_json = ?, modified_by = ? WHERE id = ?',
|
||||
[payload.name, payload.apiUrl, payload.requestMethod, payload.requestBodyJson || null, payload.authMethod, payload.authUsername || null, payload.authPassword || null, payload.authBearerToken || null, payload.authHeaderName || null, payload.authHeaderValue || null, payload.tokenUrl || null, payload.tokenRequestBodyJson || null, payload.tokenResponsePath || null, payload.tokenHeaderName || null, payload.tokenHeaderPrefix || null, payload.itemsPath || null, payload.updateIntervalValue, payload.updateIntervalUnit, null, null, null, null, null, actorId, apiSource.id]
|
||||
);
|
||||
await connection.commit();
|
||||
dataSourceTasks.registerRecurringRefresh('api-source', apiSource.id, payload.name, payload.updateIntervalValue, payload.updateIntervalUnit, function () {
|
||||
|
||||
@@ -12,14 +12,30 @@
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-12 col-lg-6">
|
||||
<div class="col-12 col-lg-5">
|
||||
<label for="api-source-name" class="form-label">Name</label>
|
||||
<input id="api-source-name" name="name" class="form-control" value="{{apiSource.name}}" maxlength="128" data-limit-text-length required />
|
||||
</div>
|
||||
<div class="col-12 col-lg-6">
|
||||
<div class="col-12 col-lg-5">
|
||||
<label for="api-source-url" class="form-label">API URL</label>
|
||||
<input id="api-source-url" name="api_url" type="url" class="form-control" value="{{apiSource.apiUrl}}" maxlength="1024" data-limit-text-length placeholder="https://example.com/api.json" required />
|
||||
</div>
|
||||
<div class="col-12 col-lg-2">
|
||||
<label for="api-source-request-method" class="form-label">Request method</label>
|
||||
<select id="api-source-request-method" name="request_method" class="form-select" data-api-source-request-method>
|
||||
<option value="GET" {{#if (eq apiSource.requestMethod 'GET')}}selected{{/if}}>GET</option>
|
||||
<option value="POST" {{#if (eq apiSource.requestMethod 'POST')}}selected{{/if}}>POST</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-4" data-api-source-request-section>
|
||||
<h4 class="api-source-section-heading">Request</h4>
|
||||
<div class="row g-3 align-items-end">
|
||||
<div class="col-12">
|
||||
<div class="form-text mb-2">POST requests use JSON with the existing authentication settings.</div>
|
||||
<textarea id="api-source-request-body" name="request_body_json" class="form-control font-monospace" rows="5" data-api-source-request-body placeholder="{ "ids": [1, 2, 3] }" spellcheck="false">{{apiSource.requestBodyJson}}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<h4 class="api-source-section-heading">Access and parsing</h4>
|
||||
@@ -31,6 +47,7 @@
|
||||
<option value="basic" {{#if (eq apiSource.authMethod 'basic')}}selected{{/if}}>Basic</option>
|
||||
<option value="bearer" {{#if (eq apiSource.authMethod 'bearer')}}selected{{/if}}>Bearer token</option>
|
||||
<option value="api_key_header" {{#if (eq apiSource.authMethod 'api_key_header')}}selected{{/if}}>API key header</option>
|
||||
<option value="token_login" {{#if (eq apiSource.authMethod 'token_login')}}selected{{/if}}>Login then token</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-12 col-md-8">
|
||||
@@ -40,7 +57,7 @@
|
||||
</div>
|
||||
<div class="row mt-2">
|
||||
<div class="col-12 col-md-8 offset-md-4">
|
||||
<div class="form-text">Optional. Use dot notation to point at the array this source should iterate through.</div>
|
||||
<div class="form-text">Optional. Use dot notation to change the default base for placeholders.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -77,6 +94,30 @@
|
||||
<input id="api-source-auth-header-value" name="auth_header_value" class="form-control" value="{{apiSource.authHeaderValue}}" maxlength="255" data-limit-text-length />
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-3 mb-3" data-api-source-auth-panel="token_login" hidden>
|
||||
<div class="col-12 col-md-8">
|
||||
<label for="api-source-token-url" class="form-label">Login / token URL</label>
|
||||
<input id="api-source-token-url" name="token_url" type="url" class="form-control" value="{{apiSource.tokenUrl}}" maxlength="1024" placeholder="https://example.com/login" />
|
||||
<div class="form-text">The login request is sent as POST with the JSON body below.</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-4">
|
||||
<label for="api-source-token-response-path" class="form-label">Token response path</label>
|
||||
<input id="api-source-token-response-path" name="token_response_path" class="form-control" value="{{apiSource.tokenResponsePath}}" placeholder="access_token" />
|
||||
<div class="form-text">Example: <code>data.token</code></div>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label for="api-source-token-body" class="form-label">Login request body</label>
|
||||
<textarea id="api-source-token-body" name="token_request_body_json" class="form-control font-monospace" rows="4" placeholder="{ "username": "...", "password": "..." }" spellcheck="false">{{apiSource.tokenRequestBodyJson}}</textarea>
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<label for="api-source-token-header-name" class="form-label">API token header</label>
|
||||
<input id="api-source-token-header-name" name="token_header_name" class="form-control" value="{{apiSource.tokenHeaderName}}" placeholder="Authorization" />
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<label for="api-source-token-header-prefix" class="form-label">Token prefix</label>
|
||||
<input id="api-source-token-header-prefix" name="token_header_prefix" class="form-control" value="{{apiSource.tokenHeaderPrefix}}" placeholder="Bearer" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="api-source-section-heading">Refresh</h4>
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const { fetchApiSourceResponse, buildApiSourcePayload } = require('../src/data/api-sources');
|
||||
|
||||
function jsonResponse(status, body) {
|
||||
return {
|
||||
status: status,
|
||||
ok: status >= 200 && status < 300,
|
||||
headers: { get: () => 'application/json' },
|
||||
text: async () => JSON.stringify(body)
|
||||
};
|
||||
}
|
||||
|
||||
test('API source payload accepts POST request and token-login settings', () => {
|
||||
const payload = buildApiSourcePayload({
|
||||
body: {
|
||||
name: 'Protected report',
|
||||
api_url: 'https://example.com/report',
|
||||
request_method: 'POST',
|
||||
request_body_json: '{"site_id":42}',
|
||||
auth_method: 'token_login',
|
||||
token_url: 'https://example.com/login',
|
||||
token_request_body_json: '{"username":"demo","password":"secret"}',
|
||||
token_response_path: 'data.accessToken',
|
||||
token_header_name: 'Authorization',
|
||||
token_header_prefix: 'Bearer',
|
||||
update_interval_value: '5',
|
||||
update_interval_unit: 'minutes'
|
||||
}
|
||||
});
|
||||
|
||||
assert.equal(payload.requestMethod, 'POST');
|
||||
assert.equal(payload.requestBodyJson, '{"site_id":42}');
|
||||
assert.equal(payload.authMethod, 'token_login');
|
||||
assert.equal(payload.tokenUrl, 'https://example.com/login');
|
||||
assert.equal(payload.tokenResponsePath, 'data.accessToken');
|
||||
});
|
||||
|
||||
test('API source sends login POST, uses token, and refreshes once after 401', async () => {
|
||||
const originalFetch = global.fetch;
|
||||
const calls = [];
|
||||
let loginCount = 0;
|
||||
let apiCount = 0;
|
||||
global.fetch = async function (url, options) {
|
||||
calls.push({ url, options });
|
||||
if (url === 'https://example.com/login') {
|
||||
loginCount += 1;
|
||||
return jsonResponse(200, { accessToken: 'token-' + loginCount, expires_in: 300 });
|
||||
}
|
||||
apiCount += 1;
|
||||
return apiCount === 1
|
||||
? jsonResponse(401, { error: 'expired' })
|
||||
: jsonResponse(200, { items: [{ id: 1 }] });
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await fetchApiSourceResponse({
|
||||
id: 987654,
|
||||
api_url: 'https://example.com/report',
|
||||
request_method: 'POST',
|
||||
request_body_json: JSON.stringify({ site_id: 42 }),
|
||||
auth_method: 'token_login',
|
||||
token_url: 'https://example.com/login',
|
||||
token_request_body_json: JSON.stringify({ username: 'demo', password: 'secret' }),
|
||||
token_response_path: 'accessToken',
|
||||
token_header_name: 'Authorization',
|
||||
token_header_prefix: 'Bearer'
|
||||
});
|
||||
|
||||
assert.deepEqual(JSON.parse(result.responseJson), { items: [{ id: 1 }] });
|
||||
assert.equal(loginCount, 2);
|
||||
assert.equal(apiCount, 2);
|
||||
assert.equal(calls[0].options.method, 'POST');
|
||||
assert.equal(calls[0].options.headers['Content-Type'], 'application/json');
|
||||
assert.deepEqual(JSON.parse(calls[0].options.body), { username: 'demo', password: 'secret' });
|
||||
assert.equal(calls[1].options.headers.Authorization, 'Bearer token-1');
|
||||
assert.equal(calls[3].options.headers.Authorization, 'Bearer token-2');
|
||||
assert.deepEqual(JSON.parse(calls[3].options.body), { site_id: 42 });
|
||||
} finally {
|
||||
global.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
@@ -44,7 +44,7 @@ test('pending migrations are empty when the schema already matches the app versi
|
||||
}
|
||||
]);
|
||||
|
||||
const pendingMigrations = await getPendingMigrations(pool, { currentVersion: '2.8.0' });
|
||||
const pendingMigrations = await getPendingMigrations(pool, { currentVersion: '2.8.7' });
|
||||
|
||||
assert.equal(pendingMigrations.length, 0);
|
||||
});
|
||||
@@ -74,7 +74,7 @@ test('pending migrations are reported when an older schema still needs scripts',
|
||||
const pendingMigrations = await getPendingMigrations(pool, { currentVersion: '2.6.17' });
|
||||
|
||||
assert.ok(pendingMigrations.length > 0);
|
||||
assert.equal(pendingMigrations.filter(function (migration) { return migration.version.indexOf('2.8.') === 0; }).length, 1);
|
||||
assert.equal(pendingMigrations.filter(function (migration) { return migration.version.indexOf('2.8.') === 0; }).length, 2);
|
||||
assert.equal(pendingMigrations.find(function (migration) { return migration.version.indexOf('2.8.') === 0; }).version, '2.8.0');
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user