Files
deflock/api/server.ts
T
Will Freeman dafca17132 otel key fix
2026-04-29 21:42:23 -06:00

267 lines
8.5 KiB
TypeScript

import './telemetry';
import { otelLogger, SeverityNumber, meter } from './telemetry';
import Fastify, { FastifyInstance, FastifyError } from 'fastify';
declare module 'fastify' {
interface FastifyRequest {
errorHandled?: boolean;
meta?: Record<string, string>;
}
}
function classifyError(error: FastifyError): string {
if (error.code === 'FST_ERR_VALIDATION') return 'validation_error';
const msg = error.message.toLowerCase();
if (msg.includes('geocode') || msg.includes('nominatim')) return 'upstream_error:nominatim';
if (msg.includes('sponsors') || msg.includes('github')) return 'upstream_error:github';
if (msg.includes('zammad') || msg.includes('ticket')) return 'upstream_error:zammad';
if (msg.includes('turnstile') || msg.includes('siteverify')) return 'upstream_error:turnstile';
return 'internal_error';
}
function classifyByStatus(statusCode: number): string {
if (statusCode === 404) return 'not_found';
if (statusCode === 400) return 'client_error';
if (statusCode === 401 || statusCode === 403) return 'auth_error';
if (statusCode >= 400 && statusCode < 500) return 'client_error';
return 'internal_error';
}
import cors from '@fastify/cors';
import { NominatimClient, NominatimResultSchema } from './services/NominatimClient';
import { classifyGeoQuery } from './services/GeoQueryClassifier';
import { GithubClient, SponsorsResponseSchema } from './services/GithubClient';
import { TurnstileClient } from './services/TurnstileClient';
import { ZammadClient, ContactMessageBodySchema, ContactMessageBody } from './services/ZammadClient';
const start = async () => {
const server: FastifyInstance = Fastify({
logger: {
level: 'error',
transport: {
target: 'pino-pretty',
options: {
colorize: true,
translateTime: 'HH:MM:ss Z',
ignore: 'pid,hostname',
},
},
},
trustProxy: true,
});
// Global error handler
server.setErrorHandler((error: FastifyError, request, reply) => {
const errorType = classifyError(error);
const statusCode = error.statusCode ?? 500;
otelLogger.emit({
severityNumber: SeverityNumber.ERROR,
severityText: 'ERROR',
body: error.message,
attributes: {
'error.type': errorType,
'http.route': (request.routeOptions as { url?: string })?.url ?? '',
'http.request.method': request.method,
'http.response.status_code': statusCode,
'exception.message': error.message,
'exception.stacktrace': error.stack ?? '',
},
});
request.errorHandled = true;
if (statusCode !== 404) {
server.log.error({
url: request.url,
method: request.method,
error: error.message,
stack: error.stack,
}, 'Request error');
}
reply.status(statusCode).send({ error: 'Internal Server Error' });
});
// Coors Banquet Config
await server.register(cors, {
origin: (origin, cb) => {
const allowedOrigins = [
'http://localhost:5173', // DeFlock Legacy
'http://localhost:3000', // FlockHopper
'https://deflock.org',
'https://www.deflock.org',
'https://maps.deflock.org'
];
if (!origin || allowedOrigins.includes(origin) || /^https:\/\/.*\.deflock\.pages\.dev$/.test(origin)) {
cb(null, true);
} else {
cb(null, false);
}
},
methods: ['GET', 'HEAD', 'POST'],
});
server.addHook('onResponse', (request, reply, done) => {
const statusCode = reply.statusCode;
const route = (request.routeOptions as { url?: string })?.url ?? request.url.split('?')[0];
requestCounter.add(1, {
'http.route': route,
'http.request.method': request.method,
'http.response.status_code': statusCode,
...request.meta,
});
if (!request.errorHandled && statusCode >= 400 && statusCode !== 404) {
otelLogger.emit({
severityNumber: statusCode >= 500 ? SeverityNumber.ERROR : SeverityNumber.WARN,
severityText: statusCode >= 500 ? 'ERROR' : 'WARN',
body: `HTTP ${statusCode} ${request.method} ${route}`,
attributes: {
'error.type': classifyByStatus(statusCode),
'http.route': route,
'http.request.method': request.method,
'http.response.status_code': statusCode,
...request.meta,
},
});
}
done();
});
const requestCounter = meter.createCounter('http.server.requests.total', {
description: 'Total number of HTTP requests, by route, method, and status code',
});
const nominatim = new NominatimClient();
const githubClient = new GithubClient();
const turnstileClient = new TurnstileClient();
const zammadClient = new ZammadClient();
const shutdown = async () => {
server.log.info("Shutting down");
await server.close();
process.exit(0);
};
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
server.get('/geocode', {
schema: {
querystring: {
type: 'object',
properties: {
query: { type: 'string' },
},
required: ['query'],
},
response: {
200: NominatimResultSchema,
404: { type: 'object', properties: { error: { type: 'string' } } },
500: { type: 'object', properties: { error: { type: 'string' } } },
},
},
}, async (request, reply) => {
const { query } = request.query as { query: string };
reply.header('Cache-Control', 'public, max-age=86400, s-maxage=86400');
const result = await nominatim.geocodeSingleResult(query);
if (!result) {
return reply.status(404).send({ error: 'No results found' });
}
return result;
});
server.get('/geocode/multi', {
schema: {
querystring: {
type: 'object',
properties: {
query: { type: 'string' },
source: { type: 'string' },
},
required: ['query'],
},
response: {
200: {
type: 'array',
items: NominatimResultSchema,
},
500: { type: 'object', properties: { error: { type: 'string' } } },
},
},
}, async (request, reply) => {
const { query, source } = request.query as { query: string, source?: string };
const geoType = classifyGeoQuery(query);
request.meta = {
'geocode.source': geoType === 'zip_code' ? 'local_zip' : 'nominatim',
'geocode.initiator': source ?? '',
'geocode.query_type': geoType,
};
reply.header('Cache-Control', 'public, max-age=86400, s-maxage=86400');
const result = await nominatim.geocodePhrase(query, false);
return result;
});
server.get('/sponsors/github', {
schema: {
querystring: {
type: 'object',
properties: {
username: { type: 'string', default: 'frillweeman' },
},
},
response: {
200: SponsorsResponseSchema,
500: { type: 'object', properties: { error: { type: 'string' } } },
},
},
}, async (request, reply) => {
const { username } = request.query as { username?: string };
reply.header('Cache-Control', 'public, max-age=60, s-maxage=600');
const result = await githubClient.getSponsors(username || 'frillweeman');
return result;
});
server.post('/contact/message', {
schema: {
body: ContactMessageBodySchema,
response: {
201: { type: 'object', properties: {} },
400: { type: 'object', properties: { error: { type: 'string' } } },
500: { type: 'object', properties: { error: { type: 'string' } } },
},
},
}, async (request, reply) => {
const { name, email, topic, subject, message, turnstileToken } = request.body as ContactMessageBody;
const remoteIp = request.ip;
const valid = await turnstileClient.verify(turnstileToken, remoteIp);
if (!valid) {
return reply.status(400).send({ error: 'Invalid captcha' });
}
await zammadClient.createTicket({ name, email, topic, subject, message });
return reply.status(201).send({});
});
server.head('/healthcheck', async (request, reply) => {
reply.status(200).send();
});
try {
const defaultPort = process.env.NODE_ENV === 'development' ? 3420 : 3000;
const port = process.env.PORT ? parseInt(process.env.PORT, 10) : defaultPort;
await server.listen({ host: '0.0.0.0', port });
console.log(`Server listening on port ${port}`);
} catch (err) {
console.error('Failed to start server:', err);
server.log.error(err);
process.exit(1);
}
};
start().catch(err => {
console.error('Fatal error:', err);
process.exit(1);
});