import mongoose from 'mongoose'; import path from 'path'; import { fileURLToPath } from 'url'; import Cache from '../libs/cache.js'; const { Schema } = mongoose; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const ClientsCacheFile = path.join(__dirname, '../data/clients.cache'); const clientSettingSchema = new Schema({ _id: { required: true, type: String, }, deviceId: { required: true, type: Number, }, }, { timestamps: true }); const ClientSetting = mongoose.model('ClientSetting', clientSettingSchema); const CLIENT_HEADER_FIELDS = [ 'X-Device', 'X-Version', 'X-Data-Type', 'X-Market-Type', 'X-Group-Sequence', 'X-Platform', ]; const CLIENT_KEY_FIELDS = [ 'X-Data-Type', 'X-Market-Type', 'X-Group-Sequence', 'X-Platform', ]; const CLIENT_FIELD_NAMES = { 'X-Data-Type': 'dataType', 'X-Device': 'device', 'X-Group-Sequence': 'groupSequence', 'X-Market-Type': 'marketType', 'X-Platform': 'platform', 'X-Version': 'version', }; const CLIENTS = { Items: {}, LegacySettings: {}, Settings: {}, }; let saveTimer = null; const normalizeHeaderValue = (value) => { if (Array.isArray(value)) { return value.join(','); } return value == null ? '' : String(value).trim(); } const getRequestHeader = (req, field) => { return normalizeHeaderValue(req.get?.(field)); } const getClientHeaders = (req) => { return CLIENT_HEADER_FIELDS.reduce((headers, field) => { headers[field] = getRequestHeader(req, field); return headers; }, {}); } const formatClientFields = (headers) => { return CLIENT_HEADER_FIELDS.reduce((clientFields, field) => { clientFields[CLIENT_FIELD_NAMES[field]] = headers[field]; return clientFields; }, {}); } const getClientKey = (headers) => { return CLIENT_KEY_FIELDS .map(field => `${field}:${headers[field] ?? ''}`) .join('|'); } const getClientIp = (req) => { const ip = getRequestHeader(req, 'X-Real-IP') || normalizeHeaderValue(req.ip) || normalizeHeaderValue(req.socket?.remoteAddress) || normalizeHeaderValue(req.connection?.remoteAddress); return ip.replace(/^::ffff:/, ''); } const scheduleSaveClientsToCache = () => { if (saveTimer) { clearTimeout(saveTimer); } saveTimer = setTimeout(saveClientsToCache, 1000); } const recordRequest = (req) => { const route = req.path; const headers = getClientHeaders(req); const clientFields = formatClientFields(headers); const key = getClientKey(headers); const now = Date.now(); const current = CLIENTS.Items[key] ?? {}; const setting = CLIENTS.Settings[key] ?? {}; CLIENTS.Items[key] = { key, ...clientFields, deviceId: setting.deviceId ?? current.deviceId, ip: getClientIp(req), route, firstRequestTime: current.firstRequestTime ?? now, lastRequestTime: now, requestCount: (current.requestCount ?? 0) + 1, }; scheduleSaveClientsToCache(); return CLIENTS.Items[key]; } const getClients = () => { return Object.values(CLIENTS.Items) .map(client => ({ ...client, deviceId: CLIENTS.Settings[client.key]?.deviceId ?? client.deviceId, })) .sort((a, b) => (b.lastRequestTime ?? 0) - (a.lastRequestTime ?? 0)); } const updateClient = async ({ key, deviceId } = {}) => { if (!key || !CLIENTS.Items[key]) { return Promise.reject(new Error('CLIENT_NOT_FOUND')); } if (deviceId === undefined || deviceId === null || deviceId === '') { delete CLIENTS.Settings[key]; delete CLIENTS.Items[key].deviceId; await ClientSetting.deleteOne({ _id: key }); saveClientsToCache(); return CLIENTS.Items[key]; } const parsedDeviceId = Number(deviceId); if (!Number.isInteger(parsedDeviceId)) { return Promise.reject(new Error('DEVICE_ID_INVALID')); } await ClientSetting.findByIdAndUpdate( key, { $set: { deviceId: parsedDeviceId } }, { new: true, upsert: true }, ); CLIENTS.Settings[key] = { deviceId: parsedDeviceId }; CLIENTS.Items[key] = { ...CLIENTS.Items[key], deviceId: parsedDeviceId, }; saveClientsToCache(); return CLIENTS.Items[key]; } const deleteClient = async (key) => { if (!key || !CLIENTS.Items[key]) { return Promise.reject(new Error('CLIENT_NOT_FOUND')); } delete CLIENTS.Items[key]; delete CLIENTS.Settings[key]; await ClientSetting.deleteOne({ _id: key }); saveClientsToCache(); } const loadClientSettings = async () => { const legacyEntries = Object.entries(CLIENTS.LegacySettings); if (legacyEntries.length) { await Promise.all(legacyEntries.map(([key, setting]) => { return ClientSetting.updateOne( { _id: key }, { $setOnInsert: { deviceId: setting.deviceId } }, { upsert: true }, ); })); CLIENTS.LegacySettings = {}; } const settings = await ClientSetting.find().lean(); CLIENTS.Settings = settings.reduce((map, setting) => { map[setting._id] = { deviceId: setting.deviceId }; if (CLIENTS.Items[setting._id]) { CLIENTS.Items[setting._id].deviceId = setting.deviceId; } return map; }, {}); return CLIENTS.Settings; } function saveClientsToCache() { if (saveTimer) { clearTimeout(saveTimer); saveTimer = null; } Cache.setData(ClientsCacheFile, { Items: CLIENTS.Items }); } function loadClientsFromCache() { const cachedClients = Cache.getData(ClientsCacheFile, true); if (!cachedClients?.Items) { return; } CLIENTS.Items = cachedClients.Items; Object.values(CLIENTS.Items).forEach(client => { if (Number.isInteger(client.deviceId)) { CLIENTS.LegacySettings[client.key] = { deviceId: client.deviceId }; } delete client.deviceId; }); } loadClientsFromCache(); loadClientSettings().catch(() => {}); process.on('exit', saveClientsToCache); process.on('SIGINT', () => { process.exit(0); }); process.on('SIGTERM', () => { process.exit(0); }); process.on('SIGUSR2', () => { process.exit(0); }); const Clients = { recordRequest, getClients, updateClient, deleteClient, loadClientSettings, }; export { recordRequest, getClients, updateClient, deleteClient, loadClientSettings }; export default Clients;