Release v2.2.0

This commit is contained in:
2026-08-01 21:41:44 +01:00
parent c643d2fb07
commit d6417b667c
673 changed files with 146752 additions and 7389 deletions
+96 -14
View File
@@ -1,3 +1,5 @@
// API source data access, pagination, and remote fetch helpers.
const http = require('http');
const https = require('https');
const { fetchPagedRows } = require('./utils');
@@ -7,9 +9,44 @@ function normalizeUpdateIntervalUnit(value) {
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, 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, 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 };
@@ -17,7 +54,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, 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, 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,
@@ -41,20 +78,21 @@ async function fetchApiSourcesPage(pool, page, pageSize, searchTerm, sortKey, so
async function fetchApiSourceById(pool, id) {
const [rows] = await pool.query(
'SELECT id, name, api_url, 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, 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) {
async function loadUrlText(urlValue, requestOptions) {
const extraHeaders = requestOptions && requestOptions.headers ? requestOptions.headers : {};
if (typeof fetch === 'function') {
const response = await fetch(urlValue, {
headers: {
headers: Object.assign({
Accept: 'application/json, text/plain;q=0.9, */*;q=0.8',
'User-Agent': 'Pulse Signage API Reader'
}
}, extraHeaders)
});
return {
@@ -68,12 +106,13 @@ async function loadUrlText(urlValue) {
return await new Promise(function (resolve, reject) {
const url = new URL(urlValue);
const transport = url.protocol === 'https:' ? https : http;
const request = transport.get(url, {
headers: {
Accept: 'application/json, text/plain;q=0.9, */*;q=0.8',
'User-Agent': 'Pulse Signage API Reader'
}
}, function (response) {
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) {
@@ -94,8 +133,11 @@ async function loadUrlText(urlValue) {
});
}
async function fetchApiSourceResponse(apiUrl) {
const response = await loadUrlText(apiUrl);
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}).`);
}
@@ -121,8 +163,23 @@ async function fetchApiSourceResponse(apiUrl) {
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 = String(req.body.name || fallback.name || '').trim();
const apiUrl = String(req.body.api_url || req.body.apiUrl || fallback.api_url || '').trim();
const authMethod = normalizeAuthMethod(readBodyValue('auth_method', readBodyValue('authMethod', fallback.auth_method || 'none')));
const authUsername = String(readBodyValue('auth_username', readBodyValue('authUsername', fallback.auth_username || '')) || '').trim();
const authPassword = String(readBodyValue('auth_password', readBodyValue('authPassword', fallback.auth_password || '')) || '');
const authBearerToken = String(readBodyValue('auth_bearer_token', readBodyValue('authBearerToken', fallback.auth_bearer_token || '')) || '').trim();
const authHeaderName = String(readBodyValue('auth_header_name', readBodyValue('authHeaderName', fallback.auth_header_name || 'X-API-Key')) || 'X-API-Key').trim() || 'X-API-Key';
const authHeaderValue = String(readBodyValue('auth_header_value', readBodyValue('authHeaderValue', fallback.auth_header_value || '')) || '').trim();
const itemsPath = String(readBodyValue('items_path', readBodyValue('itemsPath', fallback.items_path || '')) || '').trim();
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');
@@ -159,9 +216,34 @@ function buildApiSourcePayload(req, existingApiSource) {
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
};