510 lines
24 KiB
JavaScript
510 lines
24 KiB
JavaScript
import { createHash, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
|
|
import express from 'express';
|
|
import { loadConfig, publicConfig, saveConfig } from './lib/config-store.js';
|
|
import { RateLimiter, createSheetsClient, listTabs, readTabs } from './lib/google-sheets.js';
|
|
import { clearRequests, recentRequests, recordRequest } from './lib/request-log.js';
|
|
|
|
const app = express();
|
|
const config = await loadConfig();
|
|
const limiters = new Map();
|
|
const consumerLimiters = new Map();
|
|
const cache = new Map();
|
|
const endpointHealth = new Map();
|
|
const refreshingPublications = new Map();
|
|
const tabCache = new Map();
|
|
const tabCacheMilliseconds = 5 * 60 * 1000;
|
|
const refreshIntervalMilliseconds = 5_000;
|
|
const startedAt = Date.now();
|
|
const metrics = { requests: 0, errors: 0, staleResponses: 0, refreshes: 0, refreshErrors: 0, refreshDurationMilliseconds: 0 };
|
|
|
|
app.set('trust proxy', config.settings.trustedProxyHops);
|
|
|
|
app.use(express.json({ limit: '1mb' }));
|
|
app.use('/api', (request, response, next) => {
|
|
const origin = request.get('origin');
|
|
if (!origin) return next();
|
|
if (!config.settings.corsOrigins.includes('*') && !config.settings.corsOrigins.includes(origin)) return response.status(403).json({ error: 'Origin is not permitted.' });
|
|
response.set('Access-Control-Allow-Origin', origin);
|
|
response.set('Vary', 'Origin');
|
|
response.set('Access-Control-Allow-Headers', 'Authorization, Content-Type');
|
|
response.set('Access-Control-Allow-Methods', 'GET, OPTIONS');
|
|
if (request.method === 'OPTIONS') return response.status(204).end();
|
|
next();
|
|
});
|
|
app.use((request, response, next) => {
|
|
request.startedAt = Date.now();
|
|
next();
|
|
});
|
|
app.use('/vendor/lucide', express.static('node_modules/lucide/dist/umd'));
|
|
app.use(express.static('public'));
|
|
|
|
function sameSecret(received, expected) {
|
|
if (!received || !expected) return false;
|
|
const receivedBuffer = Buffer.from(received);
|
|
const expectedBuffer = Buffer.from(expected);
|
|
return receivedBuffer.length === expectedBuffer.length && timingSafeEqual(receivedBuffer, expectedBuffer);
|
|
}
|
|
|
|
function requireAdmin(request, response, next) {
|
|
if (!sameSecret(request.get('x-admin-token'), config.adminToken)) {
|
|
return response.status(401).json({ error: 'Admin authentication required.' });
|
|
}
|
|
next();
|
|
}
|
|
|
|
function requiredText(value, label) {
|
|
if (!value?.trim()) throw new Error(`${label} is required.`);
|
|
return value.trim();
|
|
}
|
|
|
|
function spreadsheetIdFrom(value) {
|
|
const input = requiredText(value, 'Google Sheets URL');
|
|
const match = input.match(/\/spreadsheets\/d\/([a-zA-Z0-9_-]+)/);
|
|
if (match) return match[1];
|
|
if (/^[a-zA-Z0-9_-]{20,}$/.test(input)) return input;
|
|
throw new Error('Enter a valid Google Sheets URL.');
|
|
}
|
|
|
|
function credentialFor(id) {
|
|
const credential = config.credentials.find((item) => item.id === id);
|
|
if (!credential) throw new Error('The selected credential profile no longer exists.');
|
|
return credential;
|
|
}
|
|
|
|
function documentFor(id) {
|
|
const document = config.documents.find((item) => item.id === id);
|
|
if (!document) throw new Error('The selected document no longer exists.');
|
|
return document;
|
|
}
|
|
|
|
function limiterFor(credential) {
|
|
const identity = credential.mode === 'serviceAccount'
|
|
? JSON.parse(credential.serviceAccountJson).client_email
|
|
: credential.apiKey;
|
|
const key = createHash('sha256').update(`${credential.mode}:${identity}`).digest('hex');
|
|
if (!limiters.has(key)) limiters.set(key, new RateLimiter());
|
|
return limiters.get(key);
|
|
}
|
|
|
|
function consumerIsLimited(publication) {
|
|
const now = Date.now();
|
|
const windowMilliseconds = 60_000;
|
|
const limit = Math.max(1, Number(publication.consumerRequestsPerMinute) || 120);
|
|
const current = consumerLimiters.get(publication.id) || { startedAt: now, count: 0 };
|
|
if (now - current.startedAt >= windowMilliseconds) {
|
|
current.startedAt = now;
|
|
current.count = 0;
|
|
}
|
|
if (current.count >= limit) return true;
|
|
current.count += 1;
|
|
consumerLimiters.set(publication.id, current);
|
|
return false;
|
|
}
|
|
|
|
async function refreshPublication(publication) {
|
|
if (refreshingPublications.has(publication.id)) return refreshingPublications.get(publication.id);
|
|
const refresh = (async () => {
|
|
const health = endpointHealth.get(publication.id) || {};
|
|
health.lastAttemptAt = new Date().toISOString();
|
|
try {
|
|
const refreshStartedAt = Date.now();
|
|
const document = documentFor(publication.documentId);
|
|
const credential = credentialFor(document.credentialId);
|
|
const payload = {
|
|
publication: publication.name,
|
|
document: document.name,
|
|
spreadsheetId: document.spreadsheetId,
|
|
updatedAt: new Date().toISOString(),
|
|
sheets: await readTabs(createSheetsClient(credential), {
|
|
spreadsheetId: document.spreadsheetId,
|
|
sheets: publication.sheets,
|
|
dataFormat: publication.dataFormat,
|
|
range: publication.range,
|
|
omitBlankRows: publication.omitBlankRows
|
|
}, limiterFor(credential), credential.requestsPerMinute)
|
|
};
|
|
cache.set(publication.id, { payload, expiresAt: Date.now() + publication.cacheSeconds * 1000 });
|
|
endpointHealth.set(publication.id, { ...health, lastSuccessAt: new Date().toISOString(), lastError: '' });
|
|
metrics.refreshes += 1;
|
|
metrics.refreshDurationMilliseconds += Date.now() - refreshStartedAt;
|
|
return payload;
|
|
} catch (error) {
|
|
endpointHealth.set(publication.id, { ...health, lastError: error.message });
|
|
metrics.refreshErrors += 1;
|
|
throw error;
|
|
} finally {
|
|
refreshingPublications.delete(publication.id);
|
|
}
|
|
})();
|
|
refreshingPublications.set(publication.id, refresh);
|
|
return refresh;
|
|
}
|
|
|
|
function endpointStatus(publication) {
|
|
const cached = cache.get(publication.id);
|
|
const health = endpointHealth.get(publication.id) || {};
|
|
return {
|
|
id: publication.id,
|
|
name: publication.name,
|
|
enabled: publication.enabled !== false,
|
|
cacheState: !cached ? 'empty' : cached.expiresAt > Date.now() ? 'fresh' : 'stale',
|
|
cacheExpiresAt: cached ? new Date(cached.expiresAt).toISOString() : null,
|
|
...health
|
|
};
|
|
}
|
|
|
|
async function tabsFor(credential, spreadsheetId) {
|
|
const identity = credential.mode === 'serviceAccount'
|
|
? JSON.parse(credential.serviceAccountJson).client_email
|
|
: credential.apiKey;
|
|
const key = createHash('sha256').update(`${credential.mode}:${identity}:${spreadsheetId}`).digest('hex');
|
|
const existing = tabCache.get(key);
|
|
if (existing?.expiresAt > Date.now()) return existing.result;
|
|
|
|
const result = listTabs(createSheetsClient(credential), spreadsheetId, limiterFor(credential), credential.requestsPerMinute);
|
|
tabCache.set(key, { expiresAt: Date.now() + tabCacheMilliseconds, result });
|
|
try {
|
|
return await result;
|
|
} catch (error) {
|
|
tabCache.delete(key);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
function auditEndpointRequest(request, publicationId, status, details = {}) {
|
|
metrics.requests += 1;
|
|
if (status >= 400) metrics.errors += 1;
|
|
if (details.stale) metrics.staleResponses += 1;
|
|
recordRequest({
|
|
at: new Date().toISOString(),
|
|
publicationId,
|
|
status,
|
|
durationMs: Date.now() - request.startedAt,
|
|
remoteAddress: request.ip,
|
|
...details
|
|
}).catch((error) => console.error('Unable to write request log:', error));
|
|
}
|
|
|
|
app.get('/health', (request, response) => response.json({
|
|
status: 'ok',
|
|
uptimeSeconds: Math.floor((Date.now() - startedAt) / 1000),
|
|
publications: config.publications.length
|
|
}));
|
|
|
|
app.get('/metrics', requireAdmin, (request, response) => {
|
|
const averageRefreshSeconds = metrics.refreshes ? metrics.refreshDurationMilliseconds / metrics.refreshes / 1_000 : 0;
|
|
response.type('text/plain').send([
|
|
'# TYPE sheet_relay_requests_total counter',
|
|
`sheet_relay_requests_total ${metrics.requests}`,
|
|
'# TYPE sheet_relay_errors_total counter',
|
|
`sheet_relay_errors_total ${metrics.errors}`,
|
|
'# TYPE sheet_relay_stale_responses_total counter',
|
|
`sheet_relay_stale_responses_total ${metrics.staleResponses}`,
|
|
'# TYPE sheet_relay_refreshes_total counter',
|
|
`sheet_relay_refreshes_total ${metrics.refreshes}`,
|
|
'# TYPE sheet_relay_refresh_errors_total counter',
|
|
`sheet_relay_refresh_errors_total ${metrics.refreshErrors}`,
|
|
'# TYPE sheet_relay_refresh_duration_seconds gauge',
|
|
`sheet_relay_refresh_duration_seconds ${averageRefreshSeconds}`
|
|
].join('\n') + '\n');
|
|
});
|
|
|
|
app.get('/admin/config', requireAdmin, (request, response) => response.json(publicConfig(config)));
|
|
|
|
app.post('/admin/token/rotate', requireAdmin, async (request, response, next) => {
|
|
try {
|
|
config.adminToken = randomBytes(24).toString('base64url');
|
|
await saveConfig(config);
|
|
response.json({ token: config.adminToken });
|
|
} catch (error) { next(error); }
|
|
});
|
|
|
|
app.put('/admin/settings', requireAdmin, async (request, response, next) => {
|
|
try {
|
|
const corsOrigins = Array.isArray(request.body.corsOrigins)
|
|
? request.body.corsOrigins.map((origin) => origin.trim()).filter(Boolean)
|
|
: [];
|
|
const trustedProxyHops = Math.max(0, Math.min(10, Number(request.body.trustedProxyHops) || 0));
|
|
if (corsOrigins.some((origin) => origin !== '*' && !/^https?:\/\/[^/]+$/i.test(origin))) throw new Error('CORS origins must be * or an http or https origin without a path.');
|
|
config.settings.corsOrigins = [...new Set(corsOrigins)];
|
|
config.settings.trustedProxyHops = trustedProxyHops;
|
|
app.set('trust proxy', trustedProxyHops);
|
|
await saveConfig(config);
|
|
response.json(config.settings);
|
|
} catch (error) { next(error); }
|
|
});
|
|
|
|
app.get('/admin/status', requireAdmin, async (request, response, next) => {
|
|
try {
|
|
response.json({
|
|
status: 'ok',
|
|
uptimeSeconds: Math.floor((Date.now() - startedAt) / 1000),
|
|
caches: { endpointEntries: cache.size, tabEntries: tabCache.size },
|
|
endpoints: config.publications.map(endpointStatus),
|
|
recentRequests: await recentRequests()
|
|
});
|
|
} catch (error) { next(error); }
|
|
});
|
|
|
|
app.get('/admin/requests', requireAdmin, async (request, response, next) => {
|
|
try {
|
|
const requests = await recentRequests(500);
|
|
const filtered = requests.filter((entry) => {
|
|
if (request.query.publicationId && entry.publicationId !== request.query.publicationId) return false;
|
|
if (request.query.status && String(entry.status) !== String(request.query.status)) return false;
|
|
if (request.query.result === 'cached' && !entry.cached) return false;
|
|
if (request.query.result === 'refresh' && entry.cached) return false;
|
|
if (request.query.result === 'stale' && !entry.stale) return false;
|
|
return true;
|
|
});
|
|
response.json(filtered);
|
|
} catch (error) { next(error); }
|
|
});
|
|
|
|
app.delete('/admin/requests', requireAdmin, async (request, response, next) => {
|
|
try {
|
|
await clearRequests();
|
|
response.status(204).end();
|
|
} catch (error) { next(error); }
|
|
});
|
|
|
|
app.get('/admin/config/export', requireAdmin, (request, response) => {
|
|
const exported = publicConfig(config);
|
|
response.json({
|
|
version: 1,
|
|
settings: exported.settings,
|
|
credentials: exported.credentials.map(({ configured, ...credential }) => credential),
|
|
documents: exported.documents,
|
|
publications: exported.publications.map(({ tokenConfigured, ...publication }) => ({ ...publication, enabled: false }))
|
|
});
|
|
});
|
|
|
|
app.post('/admin/config/import', requireAdmin, async (request, response, next) => {
|
|
try {
|
|
const imported = request.body;
|
|
if (!imported || !Array.isArray(imported.credentials) || !Array.isArray(imported.documents) || !Array.isArray(imported.publications)) throw new Error('Choose a valid exported configuration file.');
|
|
const credentialIds = new Map();
|
|
const credentials = imported.credentials.map((credential) => {
|
|
if (!['apiKey', 'serviceAccount'].includes(credential.mode)) throw new Error('Imported credential has an invalid authentication type.');
|
|
const id = randomUUID();
|
|
credentialIds.set(credential.id, id);
|
|
return { id, name: requiredText(credential.name, 'Imported credential name'), mode: credential.mode, apiKey: '', serviceAccountJson: '', requestsPerMinute: Math.max(1, Math.min(10_000, Number(credential.requestsPerMinute) || 45)) };
|
|
});
|
|
const documentIds = new Map();
|
|
const documents = imported.documents.map((document) => {
|
|
const credentialId = credentialIds.get(document.credentialId);
|
|
if (!credentialId || !Array.isArray(document.sheets) || !document.sheets.length) throw new Error('Imported documents must reference an included credential and have at least one sheet.');
|
|
const id = randomUUID();
|
|
documentIds.set(document.id, id);
|
|
return { id, name: requiredText(document.name, 'Imported document name'), spreadsheetId: requiredText(document.spreadsheetId, 'Imported spreadsheet ID'), credentialId, sheets: document.sheets };
|
|
});
|
|
const publications = imported.publications.map((publication) => {
|
|
const documentId = documentIds.get(publication.documentId);
|
|
if (!documentId || !Array.isArray(publication.sheets) || !publication.sheets.length) throw new Error('Imported endpoints must reference an included document and have at least one sheet.');
|
|
return { id: randomUUID(), name: requiredText(publication.name, 'Imported endpoint name'), documentId, sheets: publication.sheets, apiToken: randomBytes(24).toString('base64url'), cacheSeconds: Math.max(0, Math.min(86_400, Number(publication.cacheSeconds) || 0)), dataFormat: publication.dataFormat === 'objects' ? 'objects' : 'rows', range: String(publication.range || '').trim(), omitBlankRows: Boolean(publication.omitBlankRows), maxRows: Math.max(1, Math.min(100_000, Number(publication.maxRows) || 10_000)), consumerRequestsPerMinute: Math.max(1, Math.min(100_000, Number(publication.consumerRequestsPerMinute) || 120)), scheduledRefresh: Boolean(publication.scheduledRefresh), enabled: false };
|
|
});
|
|
config.settings = { corsOrigins: Array.isArray(imported.settings?.corsOrigins) ? imported.settings.corsOrigins : [], trustedProxyHops: Math.max(0, Math.min(10, Number(imported.settings?.trustedProxyHops) || 0)) };
|
|
config.credentials = credentials;
|
|
config.documents = documents;
|
|
config.publications = publications;
|
|
cache.clear();
|
|
endpointHealth.clear();
|
|
app.set('trust proxy', config.settings.trustedProxyHops);
|
|
await saveConfig(config);
|
|
response.json(publicConfig(config));
|
|
} catch (error) { next(error); }
|
|
});
|
|
|
|
app.post('/admin/credentials', requireAdmin, async (request, response, next) => {
|
|
try {
|
|
const { id, name, mode, apiKey, serviceAccountJson, requestsPerMinute } = request.body;
|
|
if (!['apiKey', 'serviceAccount'].includes(mode)) throw new Error('Choose an authentication type.');
|
|
const existing = config.credentials.find((item) => item.id === id);
|
|
const suppliedSecret = mode === 'apiKey' ? apiKey?.trim() : serviceAccountJson?.trim();
|
|
const secret = suppliedSecret || (existing?.mode === mode ? (mode === 'apiKey' ? existing.apiKey : existing.serviceAccountJson) : '');
|
|
requiredText(secret, mode === 'apiKey' ? 'Google API key' : 'Service account JSON');
|
|
if (mode === 'serviceAccount') JSON.parse(secret);
|
|
const credential = {
|
|
id: id || randomUUID(),
|
|
name: requiredText(name, 'Profile name'),
|
|
mode,
|
|
apiKey: mode === 'apiKey' ? secret : '',
|
|
serviceAccountJson: mode === 'serviceAccount' ? secret : '',
|
|
requestsPerMinute: Math.max(1, Math.min(10_000, Number(requestsPerMinute) || 45))
|
|
};
|
|
const index = config.credentials.findIndex((item) => item.id === credential.id);
|
|
if (index >= 0) config.credentials[index] = credential;
|
|
else config.credentials.push(credential);
|
|
await saveConfig(config);
|
|
response.status(201).json(publicConfig(config).credentials);
|
|
} catch (error) { next(error); }
|
|
});
|
|
|
|
app.delete('/admin/credentials/:id', requireAdmin, async (request, response, next) => {
|
|
try {
|
|
if (config.documents.some((document) => document.credentialId === request.params.id)) throw new Error('Remove or reassign documents using this profile first.');
|
|
config.credentials = config.credentials.filter((credential) => credential.id !== request.params.id);
|
|
limiters.delete(request.params.id);
|
|
await saveConfig(config);
|
|
response.status(204).end();
|
|
} catch (error) { next(error); }
|
|
});
|
|
|
|
app.post('/admin/documents/discover', requireAdmin, async (request, response, next) => {
|
|
try {
|
|
const credential = credentialFor(request.body.credentialId);
|
|
const spreadsheetId = spreadsheetIdFrom(request.body.url);
|
|
const result = await tabsFor(credential, spreadsheetId);
|
|
response.json({ ...result, spreadsheetId });
|
|
} catch (error) { next(error); }
|
|
});
|
|
|
|
app.post('/admin/documents', requireAdmin, async (request, response, next) => {
|
|
try {
|
|
const { id, name, url, credentialId, sheets } = request.body;
|
|
credentialFor(credentialId);
|
|
if (!Array.isArray(sheets) || !sheets.length) throw new Error('Select at least one sheet tab.');
|
|
const document = { id: id || randomUUID(), name: requiredText(name, 'Document name'), spreadsheetId: spreadsheetIdFrom(url), credentialId, sheets };
|
|
const index = config.documents.findIndex((item) => item.id === document.id);
|
|
if (index >= 0) config.documents[index] = document;
|
|
else config.documents.push(document);
|
|
await saveConfig(config);
|
|
response.status(201).json(publicConfig(config).documents);
|
|
} catch (error) { next(error); }
|
|
});
|
|
|
|
app.delete('/admin/documents/:id', requireAdmin, async (request, response, next) => {
|
|
try {
|
|
if (config.publications.some((publication) => publication.documentId === request.params.id)) throw new Error('Remove endpoints using this document first.');
|
|
config.documents = config.documents.filter((document) => document.id !== request.params.id);
|
|
await saveConfig(config);
|
|
response.status(204).end();
|
|
} catch (error) { next(error); }
|
|
});
|
|
|
|
app.post('/admin/publications', requireAdmin, async (request, response, next) => {
|
|
try {
|
|
const { id, name, documentId, sheets, cacheSeconds, dataFormat, range, omitBlankRows, maxRows, consumerRequestsPerMinute, scheduledRefresh } = request.body;
|
|
const document = documentFor(documentId);
|
|
const existing = config.publications.find((item) => item.id === id);
|
|
const selectedSheets = Array.isArray(sheets) && sheets.length ? sheets : document.sheets;
|
|
if (!selectedSheets.every((sheet) => document.sheets.some((available) => available.title === sheet.title))) throw new Error('Endpoint sheets must belong to the selected document.');
|
|
if (!['rows', 'objects'].includes(dataFormat || 'rows')) throw new Error('Choose a valid data format.');
|
|
const publication = { id: id || randomUUID(), name: requiredText(name, 'Endpoint name'), documentId, sheets: selectedSheets, apiToken: existing?.apiToken || randomBytes(24).toString('base64url'), cacheSeconds: Math.max(0, Math.min(86_400, Number(cacheSeconds) || 0)), dataFormat: dataFormat || 'rows', range: String(range || '').trim(), omitBlankRows: Boolean(omitBlankRows), maxRows: Math.max(1, Math.min(100_000, Number(maxRows) || 10_000)), consumerRequestsPerMinute: Math.max(1, Math.min(100_000, Number(consumerRequestsPerMinute) || 120)), scheduledRefresh: Boolean(scheduledRefresh), enabled: existing?.enabled !== false };
|
|
const index = config.publications.findIndex((item) => item.id === publication.id);
|
|
if (index >= 0) config.publications[index] = publication;
|
|
else config.publications.push(publication);
|
|
cache.delete(publication.id);
|
|
endpointHealth.delete(publication.id);
|
|
await saveConfig(config);
|
|
response.status(201).json(publicConfig(config).publications);
|
|
} catch (error) { next(error); }
|
|
});
|
|
|
|
app.delete('/admin/publications/:id', requireAdmin, async (request, response) => {
|
|
config.publications = config.publications.filter((publication) => publication.id !== request.params.id);
|
|
cache.delete(request.params.id);
|
|
consumerLimiters.delete(request.params.id);
|
|
endpointHealth.delete(request.params.id);
|
|
await saveConfig(config);
|
|
response.status(204).end();
|
|
});
|
|
|
|
app.post('/admin/publications/:id/rotate-token', requireAdmin, async (request, response, next) => {
|
|
try {
|
|
const publication = config.publications.find((item) => item.id === request.params.id);
|
|
if (!publication) throw new Error('The endpoint no longer exists.');
|
|
publication.apiToken = randomBytes(24).toString('base64url');
|
|
cache.delete(publication.id);
|
|
await saveConfig(config);
|
|
response.json({ token: publication.apiToken });
|
|
} catch (error) { next(error); }
|
|
});
|
|
|
|
app.post('/admin/publications/:id/refresh', requireAdmin, async (request, response, next) => {
|
|
try {
|
|
const publication = config.publications.find((item) => item.id === request.params.id);
|
|
if (!publication) throw new Error('The endpoint no longer exists.');
|
|
await refreshPublication(publication);
|
|
response.json(endpointStatus(publication));
|
|
} catch (error) { next(error); }
|
|
});
|
|
|
|
app.post('/admin/publications/:id/enabled', requireAdmin, async (request, response, next) => {
|
|
try {
|
|
const publication = config.publications.find((item) => item.id === request.params.id);
|
|
if (!publication) throw new Error('The endpoint no longer exists.');
|
|
publication.enabled = Boolean(request.body.enabled);
|
|
await saveConfig(config);
|
|
response.json({ enabled: publication.enabled });
|
|
} catch (error) { next(error); }
|
|
});
|
|
|
|
app.get('/api/v1/:publicationId', async (request, response, next) => {
|
|
try {
|
|
const publication = config.publications.find((item) => item.id === request.params.publicationId);
|
|
const token = request.get('authorization')?.replace(/^Bearer\s+/i, '');
|
|
if (!publication || !sameSecret(token, publication.apiToken)) {
|
|
auditEndpointRequest(request, request.params.publicationId, 401);
|
|
return response.status(401).json({ error: 'Invalid API credentials.' });
|
|
}
|
|
if (publication.enabled === false) {
|
|
auditEndpointRequest(request, publication.id, 503, { error: 'Endpoint is disabled.' });
|
|
return response.status(503).json({ error: 'This endpoint is temporarily disabled.' });
|
|
}
|
|
if (consumerIsLimited(publication)) {
|
|
auditEndpointRequest(request, publication.id, 429, { error: 'Consumer rate limit exceeded.' });
|
|
response.set('Retry-After', '60');
|
|
return response.status(429).json({ error: 'Consumer rate limit exceeded. Try again in a minute.' });
|
|
}
|
|
const cached = cache.get(publication.id);
|
|
if (cached && cached.expiresAt > Date.now()) {
|
|
auditEndpointRequest(request, publication.id, 200, { cached: true, stale: false });
|
|
return response.json({ ...cached.payload, cached: true, stale: false });
|
|
}
|
|
try {
|
|
const payload = await refreshPublication(publication);
|
|
auditEndpointRequest(request, publication.id, 200, { cached: false, stale: false });
|
|
response.json({ ...payload, cached: false, stale: false });
|
|
} catch (error) {
|
|
if (cached) {
|
|
auditEndpointRequest(request, publication.id, 200, { cached: true, stale: true, error: error.message });
|
|
return response.json({ ...cached.payload, cached: true, stale: true, staleAt: new Date(cached.expiresAt).toISOString() });
|
|
}
|
|
throw error;
|
|
}
|
|
} catch (error) {
|
|
auditEndpointRequest(request, request.params.publicationId, 502, { error: error.message });
|
|
response.status(502).json({ error: error.message || 'Unable to refresh endpoint data.' });
|
|
}
|
|
});
|
|
|
|
app.use((error, request, response, next) => {
|
|
console.error(error);
|
|
response.status(400).json({ error: error.message || 'Request failed.' });
|
|
});
|
|
|
|
const port = Number(process.env.PORT) || 3000;
|
|
app.listen(port, () => {
|
|
console.log(`Google Sheets API publisher listening on http://localhost:${port}`);
|
|
console.log('Admin token: data/config.json -> adminToken');
|
|
});
|
|
|
|
app.get('/admin/publications/:id/token', requireAdmin, (request, response, next) => {
|
|
try {
|
|
const publication = config.publications.find((item) => item.id === request.params.id);
|
|
if (!publication) throw new Error('The endpoint no longer exists.');
|
|
response.json({ token: publication.apiToken });
|
|
} catch (error) { next(error); }
|
|
});
|
|
|
|
setInterval(() => {
|
|
for (const publication of config.publications) {
|
|
if (!publication.scheduledRefresh || publication.cacheSeconds <= 0) continue;
|
|
const cached = cache.get(publication.id);
|
|
const refreshLeadMilliseconds = Math.min(10_000, publication.cacheSeconds * 200);
|
|
if (!cached || cached.expiresAt - Date.now() <= refreshLeadMilliseconds) {
|
|
refreshPublication(publication).catch((error) => console.error(`Scheduled refresh failed for ${publication.name}:`, error.message));
|
|
}
|
|
}
|
|
}, refreshIntervalMilliseconds).unref(); |