Release 2.16.1
This commit is contained in:
1 parent
3514d514a3
commit
8ce8228091
25 files changed
+1307
-613
No files matched your search
@@ -0,0 +1,124 @@
|
||||
const { API_AUTH_FIELDS, API_REQUEST_SELECT, buildApiSourcePayload } = require('#src/data/api-sources');
|
||||
const { fetchPagedRows, validateMaxLength, parseJsonSafe } = require('#src/data/utils');
|
||||
|
||||
function toFormFields(value) {
|
||||
const fields = Object.assign({}, value);
|
||||
Object.keys(value || {}).forEach(function (key) {
|
||||
fields[key.replace(/[A-Z]/g, function (letter) { return '_' + letter.toLowerCase(); })] = value[key];
|
||||
});
|
||||
return fields;
|
||||
}
|
||||
|
||||
function validationError(message, statusCode) {
|
||||
const error = new Error(message);
|
||||
error.statusCode = statusCode || 400;
|
||||
return error;
|
||||
}
|
||||
|
||||
function buildApiGroupPayload(body) {
|
||||
let requests = body.requests_json;
|
||||
if (typeof requests === 'string') {
|
||||
try { requests = JSON.parse(requests); } catch (_error) { throw validationError('Requests must contain valid JSON.'); }
|
||||
}
|
||||
if (!Array.isArray(requests) || requests.length < 1 || requests.length > 50) {
|
||||
throw validationError('Add between 1 and 50 API requests.');
|
||||
}
|
||||
const name = validateMaxLength(body.name, 255, 'API source name');
|
||||
if (!name) throw validationError('API source name is required.');
|
||||
const ids = new Set();
|
||||
const names = new Set();
|
||||
const parsedRequests = requests.map(function (request) {
|
||||
if (!request || typeof request !== 'object' || Array.isArray(request)) throw validationError('Invalid API request.');
|
||||
const id = request.id === '' || request.id === null || request.id === undefined ? null : Number(request.id);
|
||||
if (id !== null && (!Number.isSafeInteger(id) || id < 1 || ids.has(id))) throw validationError('Invalid or repeated API request ID.');
|
||||
if (id !== null) ids.add(id);
|
||||
const requestName = validateMaxLength(request.name, 255, 'Request name');
|
||||
if (!requestName || names.has(requestName.toLowerCase())) throw validationError('Each request needs a unique name.');
|
||||
names.add(requestName.toLowerCase());
|
||||
const fields = toFormFields(request);
|
||||
API_AUTH_FIELDS.forEach(function (field) { fields[field] = body[field]; });
|
||||
fields.name = requestName;
|
||||
const payload = buildApiSourcePayload({ body: fields });
|
||||
return Object.assign(payload, { id: id, enabled: request.enabled === false || request.enabled === 0 || request.enabled === '0' ? 0 : 1 });
|
||||
});
|
||||
const auth = toFormFields(parsedRequests[0]);
|
||||
return { name: name, auth: auth, requests: parsedRequests };
|
||||
}
|
||||
|
||||
async function fetchApiGroupById(pool, id) {
|
||||
const [groups] = await pool.query('SELECT * FROM i_api_source_groups WHERE id = ?', [id]);
|
||||
if (!groups.length) return null;
|
||||
const [requests] = await pool.query(API_REQUEST_SELECT + ' WHERE request.auth_source_id = ? ORDER BY request.id', [id]);
|
||||
return Object.assign({}, groups[0], { requests: requests.map(function (request) { return Object.assign({}, request, { name: request.request_name }); }) });
|
||||
}
|
||||
|
||||
async function fetchApiGroupsPage(pool, page, pageSize, search, sort, direction) {
|
||||
const data = await fetchPagedRows(pool, {
|
||||
selectSql: 'SELECT auth.*, (SELECT COUNT(*) FROM i_api_sources request WHERE request.auth_source_id = auth.id) AS request_count, (SELECT MAX(last_pulled_at) FROM i_api_sources request WHERE request.auth_source_id = auth.id) AS last_pulled_at FROM i_api_source_groups auth ORDER BY auth.modified_at DESC, auth.id DESC',
|
||||
countSql: 'SELECT COUNT(*) AS count FROM i_api_source_groups auth',
|
||||
searchColumns: ['auth.name', 'auth.token_url'], searchTerm: search,
|
||||
sortColumns: { name: 'auth.name', requests: 'request_count', last_pulled: 'last_pulled_at' },
|
||||
sortKey: sort, sortDirection: direction, page: page, pageSize: pageSize
|
||||
});
|
||||
return Object.assign({ apiSources: data.rows }, data);
|
||||
}
|
||||
|
||||
async function getApiRequestUsage(pool) {
|
||||
const [slides] = await pool.query('SELECT content_json FROM c_slides WHERE content_json IS NOT NULL');
|
||||
const ids = new Set();
|
||||
function walk(value) {
|
||||
if (!value || typeof value !== 'object') return;
|
||||
if (value.source_id) ids.add(Number(value.source_id));
|
||||
Object.values(value).forEach(walk);
|
||||
}
|
||||
slides.forEach(function (slide) { walk(parseJsonSafe(slide.content_json)); });
|
||||
return ids;
|
||||
}
|
||||
|
||||
async function saveApiGroup(pool, id, payload, actorId, canDelete) {
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
await connection.beginTransaction();
|
||||
if (id) {
|
||||
const [groups] = await connection.query('SELECT id FROM i_api_source_groups WHERE id = ? FOR UPDATE', [id]);
|
||||
if (!groups.length) throw validationError('API source not found.', 404);
|
||||
}
|
||||
const [duplicates] = await connection.query('SELECT id FROM i_api_source_groups WHERE name = ? AND id <> ?', [payload.name, id || 0]);
|
||||
if (duplicates.length) throw validationError('An API source with that name already exists.');
|
||||
const [existing] = id ? await connection.query('SELECT id FROM i_api_sources WHERE auth_source_id = ? FOR UPDATE', [id]) : [[]];
|
||||
const existingIds = new Set(existing.map(function (request) { return Number(request.id); }));
|
||||
const retained = new Set(payload.requests.map(function (request) { return request.id; }).filter(Boolean));
|
||||
if (Array.from(retained).some(function (requestId) { return !existingIds.has(requestId); })) throw validationError('A request does not belong to this API source.');
|
||||
const removed = Array.from(existingIds).filter(function (requestId) { return !retained.has(requestId); });
|
||||
if (removed.length) {
|
||||
if (!canDelete) throw validationError('You do not have permission to remove API requests.', 403);
|
||||
const usage = await getApiRequestUsage(connection);
|
||||
if (removed.some(function (requestId) { return usage.has(requestId); })) throw validationError('A request being removed is still used by one or more slides.');
|
||||
}
|
||||
const values = [payload.name].concat(API_AUTH_FIELDS.map(function (field) { return payload.auth[field] === undefined ? null : payload.auth[field]; }));
|
||||
if (id) {
|
||||
await connection.query('UPDATE i_api_source_groups SET name = ?, ' + API_AUTH_FIELDS.map(function (field) { return field + ' = ?'; }).join(', ') + ', modified_by = ? WHERE id = ?', values.concat([actorId, id]));
|
||||
} else {
|
||||
const [result] = await connection.query('INSERT INTO i_api_source_groups (name, ' + API_AUTH_FIELDS.join(', ') + ', created_by, modified_by) VALUES (' + Array(values.length + 2).fill('?').join(', ') + ')', values.concat([actorId, actorId]));
|
||||
id = result.insertId;
|
||||
}
|
||||
for (const request of payload.requests) {
|
||||
const requestValues = [request.name, request.apiUrl, request.requestMethod, request.requestBodyJson || null, request.itemsPath || null, request.enabled, request.updateIntervalValue, request.updateIntervalUnit, actorId];
|
||||
if (request.id) {
|
||||
await connection.query('UPDATE i_api_sources SET name = ?, api_url = ?, request_method = ?, request_body_json = ?, items_path = ?, enabled = ?, update_interval_value = ?, update_interval_unit = ?, modified_by = ? WHERE id = ? AND auth_source_id = ?', requestValues.concat([request.id, id]));
|
||||
} else {
|
||||
const [result] = await connection.query('INSERT INTO i_api_sources (name, api_url, request_method, request_body_json, items_path, enabled, update_interval_value, update_interval_unit, modified_by, auth_source_id, created_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', requestValues.concat([id, actorId]));
|
||||
request.id = result.insertId;
|
||||
}
|
||||
}
|
||||
if (removed.length) await connection.query('DELETE FROM i_api_sources WHERE auth_source_id = ? AND id IN (?)', [id, removed]);
|
||||
await connection.commit();
|
||||
return { id: Number(id), removed: removed, requests: payload.requests };
|
||||
} catch (error) {
|
||||
await connection.rollback();
|
||||
if (error.code === 'ER_DUP_ENTRY') throw validationError('An API source with that name already exists.');
|
||||
throw error;
|
||||
} finally { connection.release(); }
|
||||
}
|
||||
|
||||
module.exports = { buildApiGroupPayload, fetchApiGroupById, fetchApiGroupsPage, getApiRequestUsage, saveApiGroup, toFormFields };
|
||||
+196
-37
@@ -2,7 +2,8 @@
|
||||
|
||||
const http = require('http');
|
||||
const https = require('https');
|
||||
const { fetchPagedRows, validateMaxLength } = require('#src/data/utils');
|
||||
const { randomUUID } = require('crypto');
|
||||
const { validateMaxLength } = require('#src/data/utils');
|
||||
|
||||
const NAME_MAX_LENGTH = 255;
|
||||
const URL_MAX_LENGTH = 1024;
|
||||
@@ -14,6 +15,121 @@ const TOKEN_RESPONSE_PATH_MAX_LENGTH = 255;
|
||||
const TOKEN_HEADER_PREFIX_MAX_LENGTH = 64;
|
||||
const tokenCache = new Map();
|
||||
const tokenRequests = new Map();
|
||||
const diagnosticContext = Symbol('apiSourceDiagnostics');
|
||||
const API_AUTH_FIELDS = ['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'];
|
||||
const API_REQUEST_SELECT = 'SELECT request.*, request.name AS request_name, auth.name AS group_name, CONCAT(auth.name, \' / \', request.name) AS name, ' + API_AUTH_FIELDS.map(function (field) { return 'auth.' + field; }).join(', ') + ' FROM i_api_sources request JOIN i_api_source_groups auth ON auth.id = request.auth_source_id';
|
||||
|
||||
function createDiagnosticSanitizer(source) {
|
||||
const secrets = [];
|
||||
const sensitiveKey = /password|secret|token|authorization|cookie|api.?key|authHeaderValue|auth_header_value|authUsername|auth_username/i;
|
||||
const oauthMetadataKey = /^(scope|grant_type|token_type|tokenType)$/i;
|
||||
function collect(value, force) {
|
||||
if (value && typeof value === 'object') {
|
||||
Object.keys(value).forEach(function (key) {
|
||||
if (oauthMetadataKey.test(key)) return;
|
||||
const configuration = /url|path|prefix|header.?name|auth.?method/i.test(key);
|
||||
collect(value[key], force || (!configuration && sensitiveKey.test(key)) || /body.?json/i.test(key));
|
||||
});
|
||||
} else if (typeof value === 'string' && value) {
|
||||
if (force) secrets.push(value);
|
||||
try {
|
||||
const url = new URL(value);
|
||||
if (url.username) secrets.push(url.username);
|
||||
if (url.password) secrets.push(url.password);
|
||||
url.searchParams.forEach(function (item) { if (item) secrets.push(item); });
|
||||
} catch (_error) {}
|
||||
try { collect(JSON.parse(value), true); } catch (_error) {}
|
||||
}
|
||||
}
|
||||
collect(source, false);
|
||||
function redactText(value) {
|
||||
let text = String(value || '');
|
||||
secrets.sort(function (left, right) { return right.length - left.length; }).forEach(function (secret) {
|
||||
text = text.split(secret).join('[REDACTED]');
|
||||
});
|
||||
return text.replace(/(Bearer\s+)[^\s"<>]+/gi, '$1[REDACTED]');
|
||||
}
|
||||
function sanitize(value) {
|
||||
if (Array.isArray(value)) return value.map(sanitize);
|
||||
if (value && typeof value === 'object') {
|
||||
return Object.fromEntries(Object.entries(value).map(function ([key, item]) {
|
||||
const sensitive = sensitiveKey.test(key) && !oauthMetadataKey.test(key);
|
||||
return [redactText(key), sensitive && item !== null && item !== '' ? '[REDACTED]' : sanitize(item)];
|
||||
}));
|
||||
}
|
||||
return typeof value === 'string' ? redactText(value) : value;
|
||||
}
|
||||
return {
|
||||
add: function (value) { collect(value, false); },
|
||||
secret: function (value) { if (value) secrets.push(String(value)); },
|
||||
text: redactText,
|
||||
body: function (text) {
|
||||
try { return JSON.stringify(sanitize(JSON.parse(text)), null, 2).slice(0, 12000); }
|
||||
catch (_error) { return '[Non-JSON response omitted]'; }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function diagnosticUrl(value) {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
url.username = '';
|
||||
url.password = '';
|
||||
url.search = '';
|
||||
url.hash = '';
|
||||
return url.toString();
|
||||
} catch (_error) { return '[Invalid URL]'; }
|
||||
}
|
||||
|
||||
async function loadUrlText(url, options, source, stage) {
|
||||
const context = source && source[diagnosticContext];
|
||||
const sanitizer = context ? context.sanitizer : createDiagnosticSanitizer(source || {});
|
||||
Object.entries(options.headers || {}).forEach(function ([key, value]) {
|
||||
if (!/^(accept|content-type|user-agent)$/i.test(key)) sanitizer.secret(value);
|
||||
});
|
||||
const step = { stage: stage, method: options.method, url: sanitizer.text(diagnosticUrl(url)) };
|
||||
if (context) context.steps.push(step);
|
||||
const started = Date.now();
|
||||
try {
|
||||
const response = await requestUrlText(url, options);
|
||||
step.status = response.statusCode;
|
||||
step.contentType = response.contentType;
|
||||
try {
|
||||
const parsed = JSON.parse(response.bodyText);
|
||||
sanitizer.add(parsed);
|
||||
if (stage === 'Authentication' || stage === 'Token refresh') {
|
||||
sanitizer.secret(resolveResponsePath(parsed, source.token_response_path || source.tokenResponsePath || 'access_token'));
|
||||
sanitizer.secret(resolveResponsePath(parsed, source.token_refresh_response_path || source.tokenRefreshResponsePath || 'refresh_token'));
|
||||
}
|
||||
} catch (_error) {}
|
||||
step.response = sanitizer.body(response.bodyText);
|
||||
step.ok = response.ok;
|
||||
if (!response.ok) {
|
||||
const hints = {
|
||||
400: ' Check the request body and grant type.',
|
||||
401: ' Check the credentials or token validity.',
|
||||
403: ' Check the token scopes and application permissions.',
|
||||
404: ' Check the endpoint URL and API version.',
|
||||
429: ' The provider is rate limiting requests; reduce the refresh frequency.'
|
||||
};
|
||||
const hint = hints[response.statusCode] || '';
|
||||
const error = new Error(stage + ' failed: HTTP ' + response.statusCode + ' at ' + step.url + '.' + hint + ' ' + step.response.slice(0, 1000));
|
||||
error.apiDiagnostic = true;
|
||||
step.error = error.message;
|
||||
response.diagnosticError = error;
|
||||
}
|
||||
return response;
|
||||
} catch (error) {
|
||||
step.ok = false;
|
||||
const code = error.cause && error.cause.code || error.code || error.name;
|
||||
const safeCode = /^[A-Z_0-9]+$/.test(String(code)) ? ' (' + code + ')' : '';
|
||||
step.error = error.apiDiagnostic ? error.message : stage + ' request failed (network, TLS, or timeout)' + safeCode + '.';
|
||||
if (error.apiDiagnostic) throw error;
|
||||
throw new Error(step.error);
|
||||
} finally {
|
||||
step.durationMs = Date.now() - started;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeUpdateIntervalUnit(value) {
|
||||
const unit = String(value || '').trim().toLowerCase();
|
||||
@@ -60,46 +176,22 @@ function buildAuthHeaders(source) {
|
||||
|
||||
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'
|
||||
"SELECT request.id, request.auth_source_id, request.name AS request_name, auth.name AS group_name, CONCAT(auth.name, ' / ', request.name) AS name, request.items_path, request.enabled, request.update_interval_value, request.update_interval_unit, request.last_pulled_at, request.last_pull_error, request.last_response_status, request.last_response_content_type, request.last_response_json FROM i_api_sources request JOIN i_api_source_groups auth ON auth.id = request.auth_source_id ORDER BY auth.name, request.id"
|
||||
);
|
||||
|
||||
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 = ?',
|
||||
API_REQUEST_SELECT + ' WHERE request.id = ?',
|
||||
[id]
|
||||
);
|
||||
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
async function loadUrlText(urlValue, requestOptions) {
|
||||
async function requestUrlText(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;
|
||||
@@ -108,7 +200,7 @@ async function loadUrlText(urlValue, requestOptions) {
|
||||
'User-Agent': 'Pulse Signage API Reader'
|
||||
}, extraHeaders);
|
||||
if (typeof fetch === 'function') {
|
||||
const fetchOptions = { method: method, headers: headers };
|
||||
const fetchOptions = { method: method, headers: headers, signal: AbortSignal.timeout(15000) };
|
||||
if (body !== undefined && method !== 'GET' && method !== 'HEAD') {
|
||||
fetchOptions.body = body;
|
||||
}
|
||||
@@ -149,6 +241,7 @@ async function loadUrlText(urlValue, requestOptions) {
|
||||
request.write(body);
|
||||
}
|
||||
request.end();
|
||||
request.setTimeout(15000, function () { request.destroy(new Error('Request timed out')); });
|
||||
request.on('error', reject);
|
||||
});
|
||||
}
|
||||
@@ -181,7 +274,7 @@ function resolveResponsePath(value, responsePath) {
|
||||
|
||||
function getTokenCacheKey(source) {
|
||||
return JSON.stringify([
|
||||
source && source.id || '',
|
||||
source && (source.auth_source_id || source.authSourceId || 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',
|
||||
@@ -252,7 +345,7 @@ 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.');
|
||||
throw new Error('Token response did not contain a token at the configured path: ' + createDiagnosticSanitizer(source).text(tokenPath) + '. Check the token response path.');
|
||||
}
|
||||
|
||||
const refreshPath = source.token_refresh_response_path || source.tokenRefreshResponsePath || 'refresh_token';
|
||||
@@ -293,7 +386,7 @@ async function refreshLoginToken(source, cached) {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(refreshBody)
|
||||
});
|
||||
}, source, 'Token refresh');
|
||||
return parseTokenResponse(response, source, cached.refreshToken);
|
||||
}
|
||||
|
||||
@@ -318,10 +411,10 @@ async function fetchLoginTokenUncached(source) {
|
||||
method: 'POST',
|
||||
headers: tokenHeaders,
|
||||
body: tokenBody === undefined ? undefined : JSON.stringify(tokenBody)
|
||||
});
|
||||
}, source, 'Authentication');
|
||||
const record = await parseTokenResponse(response, source, cached && cached.refreshToken);
|
||||
if (!record) {
|
||||
throw new Error(`Unable to obtain API token (${response.statusCode}).`);
|
||||
throw response.diagnosticError || new Error(`Unable to obtain API token (${response.statusCode}).`);
|
||||
}
|
||||
return record;
|
||||
}
|
||||
@@ -363,7 +456,7 @@ async function fetchApiSourceResponse(apiSource) {
|
||||
method: requestMethod,
|
||||
headers: Object.assign({}, await buildRequestHeaders(source), requestBody === undefined ? {} : { 'Content-Type': 'application/json' }),
|
||||
body: requestBody === undefined ? undefined : JSON.stringify(requestBody)
|
||||
});
|
||||
}, source, 'Data request');
|
||||
if (response.statusCode === 401 && normalizeAuthMethod(source.auth_method || source.authMethod) === 'token_login' && !tokenRetry) {
|
||||
clearCachedToken(source);
|
||||
tokenRetry = true;
|
||||
@@ -372,7 +465,7 @@ async function fetchApiSourceResponse(apiSource) {
|
||||
}
|
||||
} while (true);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Unable to load API response (${response.statusCode}).`);
|
||||
throw response.diagnosticError || new Error(`Unable to load API response (${response.statusCode}).`);
|
||||
}
|
||||
|
||||
const text = String(response.bodyText || '').trim();
|
||||
@@ -394,6 +487,69 @@ async function fetchApiSourceResponse(apiSource) {
|
||||
};
|
||||
}
|
||||
|
||||
async function runApiSourceDiagnostic(source, run) {
|
||||
const context = { steps: [], sanitizer: createDiagnosticSanitizer(source) };
|
||||
const id = 'diagnostic-' + randomUUID();
|
||||
const isolatedSource = Object.assign({}, source, { id: id, auth_source_id: id, authSourceId: null, [diagnosticContext]: context });
|
||||
const report = { ok: false, steps: context.steps };
|
||||
try {
|
||||
await run(isolatedSource, context, report);
|
||||
} catch (error) {
|
||||
report.error = context.sanitizer.text(error.message);
|
||||
const lastStep = context.steps[context.steps.length - 1];
|
||||
if (lastStep && lastStep.stage === 'Data request' && lastStep.ok) {
|
||||
context.steps.push({ stage: 'JSON / items extraction', ok: false, error: report.error });
|
||||
} else if (lastStep) {
|
||||
lastStep.ok = false;
|
||||
lastStep.error = report.error;
|
||||
}
|
||||
} finally {
|
||||
clearCachedToken(isolatedSource);
|
||||
}
|
||||
return report;
|
||||
}
|
||||
|
||||
function testApiSourceAuthentication(source) {
|
||||
return runApiSourceDiagnostic(source, async function (isolatedSource, context, report) {
|
||||
await buildRequestHeaders(isolatedSource);
|
||||
if (!context.steps.length) context.steps.push({ stage: 'Authentication', ok: true, message: normalizeAuthMethod(source.auth_method || source.authMethod) === 'none' ? 'No authentication required.' : 'Authentication headers configured. Test a request to verify provider acceptance.' });
|
||||
report.ok = true;
|
||||
});
|
||||
}
|
||||
|
||||
function testApiSourceConnection(source) {
|
||||
return runApiSourceDiagnostic(source, async function (isolatedSource, context, report) {
|
||||
if (normalizeAuthMethod(source.auth_method || source.authMethod) !== 'token_login') {
|
||||
context.steps.push({ stage: 'Authentication', ok: true, skipped: true, message: 'No token exchange required.' });
|
||||
}
|
||||
const result = await fetchApiSourceResponse(isolatedSource);
|
||||
const parsed = JSON.parse(result.responseJson);
|
||||
const paths = [];
|
||||
function walk(value, path, depth) {
|
||||
if (depth > 6 || paths.length >= 100 || !value || typeof value !== 'object') return;
|
||||
Object.keys(value).slice(0, Array.isArray(value) ? 1 : 100).forEach(function (key) {
|
||||
if (paths.length >= 100 || /password|secret|token|authorization|cookie|api.?key/i.test(key)) return;
|
||||
const nextPath = path ? path + '.' + key : key;
|
||||
paths.push(context.sanitizer.text(nextPath));
|
||||
walk(value[key], nextPath, depth + 1);
|
||||
});
|
||||
}
|
||||
walk(parsed, '', 0);
|
||||
report.paths = paths;
|
||||
report.preview = context.sanitizer.body(result.responseJson);
|
||||
const itemsPath = String(source.items_path || source.itemsPath || '').trim();
|
||||
const items = itemsPath ? resolveResponsePath(parsed, itemsPath) : parsed;
|
||||
const matched = items !== undefined && items !== null;
|
||||
context.steps.push({
|
||||
stage: 'JSON / items extraction', ok: matched,
|
||||
message: matched
|
||||
? 'Valid JSON. ' + (itemsPath ? 'Items path matched. ' : 'Using response root. ') + (Array.isArray(items) ? items.length + ' items.' : 'Selected value is ' + typeof items + '.')
|
||||
: 'Items path did not match the response: ' + context.sanitizer.text(itemsPath)
|
||||
});
|
||||
report.ok = matched;
|
||||
});
|
||||
}
|
||||
|
||||
function buildApiSourcePayload(req, existingApiSource) {
|
||||
const fallback = existingApiSource || {};
|
||||
const body = req && req.body ? req.body : {};
|
||||
@@ -543,9 +699,12 @@ function buildApiSourcePayload(req, existingApiSource) {
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
API_AUTH_FIELDS,
|
||||
API_REQUEST_SELECT,
|
||||
fetchApiSourcesData: fetchApiSourcesData,
|
||||
fetchApiSourcesPage: fetchApiSourcesPage,
|
||||
fetchApiSourceById: fetchApiSourceById,
|
||||
fetchApiSourceResponse: fetchApiSourceResponse,
|
||||
testApiSourceConnection: testApiSourceConnection,
|
||||
testApiSourceAuthentication,
|
||||
buildApiSourcePayload: buildApiSourcePayload
|
||||
};
|
||||
+1
-2
@@ -5,7 +5,7 @@ const { ANNOUNCEMENT_TYPES, ANNOUNCEMENT_COLORS, ANNOUNCEMENT_ICONS, DEFAULT_ANN
|
||||
const { ANNOUNCEMENT_ICON_OPTIONS, ANNOUNCEMENT_ICON_LABELS } = require('#src/data/announcement-icons');
|
||||
const { fetchPlaylistById } = require('#src/data/playlists');
|
||||
const { normalizeDisplayMode, fetchTimetablesData, fetchTimetableGroupsPage, fetchTimetableGroupById, fetchTimetableEntriesByGroupId, buildTimetableGroupPayload } = require('#src/data/timetables');
|
||||
const { fetchApiSourcesData, fetchApiSourcesPage, fetchApiSourceById, fetchApiSourceResponse, buildApiSourcePayload } = require('#src/data/api-sources');
|
||||
const { fetchApiSourcesData, fetchApiSourceById, fetchApiSourceResponse, buildApiSourcePayload } = require('#src/data/api-sources');
|
||||
const { fetchRssFeedsData, fetchRssFeedsPage, fetchRssFeedById, fetchRssFeedItemsByFeedId, normalizeRssFeedItem, buildRssFeedPayload, fetchRssFeedItems, replaceRssFeedItems } = require('#src/data/rss-feeds');
|
||||
const { fetchWeatherLocationsData, fetchWeatherLocationsPage, fetchWeatherLocationById, fetchWeatherLocationSuggestions, fetchWeatherLocationForecast, buildWeatherLocationPayload } = require('#src/data/weather');
|
||||
const { slugify, uniqueScreenSlug, fetchScreenById, fetchScreenEditData, fetchScreenPlayerUrls, fetchPlayerPublicBaseUrl, fetchScreenPlayerRecord, fetchPlayerRecordByIdentifier } = require('#src/data/screens');
|
||||
@@ -48,7 +48,6 @@ module.exports = {
|
||||
fetchTimetableEntriesByGroupId,
|
||||
buildTimetableGroupPayload,
|
||||
fetchApiSourcesData,
|
||||
fetchApiSourcesPage,
|
||||
fetchApiSourceById,
|
||||
fetchApiSourceResponse,
|
||||
buildApiSourcePayload,
|
||||
|
||||
Reference in new issue
Block a user