feat: Enhance backend API with pagination and validation for ASNs, Domains, and IP Ranges, and update frontend managers to support new API structure for improved data handling and user experience
Publish Fast Tabler Docker image / build-and-push-fast (push) Failing after 2m14s

This commit is contained in:
2025-08-10 23:55:44 +07:00
parent 1fb53c3467
commit d25e8fc810
5 changed files with 253 additions and 45 deletions
+3 -1
View File
@@ -19,7 +19,9 @@
"aws-sdk": "^2.1692.0", "aws-sdk": "^2.1692.0",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^17.0.1", "dotenv": "^17.0.1",
"express": "^4.19.2" "express": "^4.19.2",
"compression": "^1.7.4",
"ajv": "^8.17.1"
}, },
"devDependencies": { "devDependencies": {
"nodemon": "^3.1.10" "nodemon": "^3.1.10"
+239 -36
View File
@@ -3,12 +3,15 @@ const express = require('express');
const AWS = require('aws-sdk'); const AWS = require('aws-sdk');
const cors = require('cors'); const cors = require('cors');
const path = require('path'); const path = require('path');
const compression = require('compression');
const Ajv = require('ajv');
const app = express(); const app = express();
const port = 3001; const port = 3001;
app.use(cors()); app.use(cors());
app.use(express.json()); app.use(express.json());
app.use(compression());
// Serve static files from the React app // Serve static files from the React app
app.use(express.static(path.join(__dirname, 'public'))); app.use(express.static(path.join(__dirname, 'public')));
@@ -26,6 +29,95 @@ const s3 = new AWS.S3({
const BUCKET_NAME = process.env.S3_BUCKET_NAME; const BUCKET_NAME = process.env.S3_BUCKET_NAME;
const FILE_KEY = 'bgp_data/domains.txt'; const FILE_KEY = 'bgp_data/domains.txt';
// AJV setup and schemas
const ajv = new Ajv({ allErrors: true, removeAdditional: 'failing' });
const schemaDomainsNew = {
type: 'array',
items: {
type: 'object',
required: ['domain', 'community'],
additionalProperties: false,
properties: {
domain: { type: 'string' },
community: { type: 'string' }
}
}
};
const schemaAsns = {
type: 'array',
items: {
type: 'object',
required: ['domain', 'type'],
additionalProperties: false,
properties: {
domain: { type: 'string' },
type: { type: 'string' }
}
}
};
const schemaIpRanges = {
type: 'array',
items: {
type: 'object',
required: ['ipRange', 'community'],
additionalProperties: false,
properties: {
ipRange: { type: 'string' },
community: { type: 'string' }
}
}
};
const schemaFilters = {
type: 'array',
items: {
type: 'object',
required: ['community', 'gateway'],
additionalProperties: true,
properties: {
community: { type: 'string' },
gateway: { type: 'string' },
description: { type: 'string' }
}
}
};
const schemaServers = {
type: 'array',
items: {
type: 'object',
required: ['ip', 'dns', 'country', 'provider', 'tunnel'],
additionalProperties: true,
properties: {
ip: { type: 'string' },
dns: { type: 'string' },
country: { type: 'string' },
provider: { type: 'string' },
tunnel: { type: 'string' },
gateway: { type: 'string' }
}
}
};
const schemaBilling = {
type: 'array',
items: {
type: 'object',
required: ['hostName', 'country', 'provider'],
additionalProperties: true,
properties: {
hostName: { type: 'string' },
country: { type: 'string' },
provider: { type: 'string' }
}
}
};
const validateDomainsNew = ajv.compile(schemaDomainsNew);
const validateAsns = ajv.compile(schemaAsns);
const validateIpRanges = ajv.compile(schemaIpRanges);
const validateFilters = ajv.compile(schemaFilters);
const validateServers = ajv.compile(schemaServers);
const validateBilling = ajv.compile(schemaBilling);
// Helper: build MikroTik nested if/else blocks (RouterOS v7 filter language does not support 'else if') // Helper: build MikroTik nested if/else blocks (RouterOS v7 filter language does not support 'else if')
function buildNestedGatewayBlocks(gatewayGroups, baseIndentSpaces = 4) { function buildNestedGatewayBlocks(gatewayGroups, baseIndentSpaces = 4) {
const indent = (n) => ' '.repeat(n); const indent = (n) => ' '.repeat(n);
@@ -73,6 +165,57 @@ async function headS3ObjectEtag(key) {
return head.ETag ? String(head.ETag).replace(/\"/g, '"') : undefined; return head.ETag ? String(head.ETag).replace(/\"/g, '"') : undefined;
} }
// Helper: stream and paginate big text files (line-based)
async function streamPaginatedText({ key, mapLine, q, offset = 0, limit = 0 }) {
return new Promise(async (resolve, reject) => {
let total = 0;
const items = [];
let sent = 0;
let buffered = '';
const matchesQuery = (line) => {
if (!q) return true;
return line.toLowerCase().includes(String(q).toLowerCase());
};
const stream = s3.getObject({ Bucket: BUCKET_NAME, Key: key }).createReadStream();
stream.on('data', (chunk) => {
buffered += chunk.toString('utf-8');
let lines = buffered.split('\n');
buffered = lines.pop();
for (const lnRaw of lines) {
const line = lnRaw.trim();
if (!line) continue;
if (!matchesQuery(line)) continue;
total++;
const pos = total - 1; // position among matches
if (limit > 0) {
if (pos >= offset && sent < limit) {
items.push(mapLine(line));
sent++;
}
} else {
items.push(mapLine(line));
}
}
});
stream.on('end', () => {
const last = (buffered || '').trim();
if (last) {
if (!q || last.toLowerCase().includes(String(q).toLowerCase())) {
total++;
if (limit > 0) {
const pos = total - 1;
if (pos >= offset && items.length < limit) items.push(mapLine(last));
} else {
items.push(mapLine(last));
}
}
}
resolve({ items, total });
});
stream.on('error', reject);
});
}
// Simple in-memory soft locks with TTL // Simple in-memory soft locks with TTL
const locks = new Map(); // key -> { owner, expiresAt } const locks = new Map(); // key -> { owner, expiresAt }
function cleanupExpiredLocks() { function cleanupExpiredLocks() {
@@ -161,24 +304,41 @@ app.post('/api/domains', async (req, res) => {
// Get ASNs from S3 // Get ASNs from S3
app.get('/api/asns', async (req, res) => { app.get('/api/asns', async (req, res) => {
const { q = '', offset, limit } = req.query || {};
const params = { const params = {
Bucket: BUCKET_NAME, Bucket: BUCKET_NAME,
Key: 'bgp_data/asns.txt', Key: 'bgp_data/asns.txt',
}; };
try { try {
const data = await s3.getObject(params).promise(); if (limit !== undefined) {
const fileContent = data.Body.toString('utf-8'); const { items, total } = await streamPaginatedText({
const asns = fileContent.split('\n').filter(line => line).map(line => { key: 'bgp_data/asns.txt',
const parts = line.trim().split(/\s+/); q,
const domain = parts[0] || ''; // Keep name 'domain' for consistency in component offset: Number(offset) || 0,
const type = parts[1] || ''; limit: Number(limit) || 0,
return { domain, type }; mapLine: (line) => {
}); const parts = line.trim().split(/\s+/);
if (data.ETag) res.set('ETag', String(data.ETag)); return { domain: parts[0] || '', type: parts[1] || '' };
if (data.LastModified) res.set('Last-Modified', new Date(data.LastModified).toUTCString()); }
if (typeof data.ContentLength === 'number') res.set('Content-Length-Source', String(data.ContentLength)); });
res.json(asns); if (!validateAsns(items)) return res.status(500).json({ message: 'Invalid data format' });
return res.json({ items, total });
} else {
const data = await s3.getObject(params).promise();
const fileContent = data.Body.toString('utf-8');
const asns = fileContent.split('\n').filter(line => line).map(line => {
const parts = line.trim().split(/\s+/);
const domain = parts[0] || '';
const type = parts[1] || '';
return { domain, type };
});
if (!validateAsns(asns)) return res.status(500).json({ message: 'Invalid data format' });
if (data.ETag) res.set('ETag', String(data.ETag));
if (data.LastModified) res.set('Last-Modified', new Date(data.LastModified).toUTCString());
if (typeof data.ContentLength === 'number') res.set('Content-Length-Source', String(data.ContentLength));
res.json(asns);
}
} catch (error) { } catch (error) {
if (error.code === 'NoSuchKey') { if (error.code === 'NoSuchKey') {
res.json([]); res.json([]);
@@ -195,6 +355,9 @@ app.post('/api/asns', async (req, res) => {
const fileContent = (asns || []).map(a => `${String(a.domain || '').trim()} ${String(a.type || '').trim()}`.trim()).filter(Boolean).join('\n'); const fileContent = (asns || []).map(a => `${String(a.domain || '').trim()} ${String(a.type || '').trim()}`.trim()).filter(Boolean).join('\n');
try { try {
if (!validateAsns(asns || [])) {
return res.status(400).json({ message: 'Invalid payload format for asns' });
}
if (etag) { if (etag) {
const current = await headS3ObjectEtag('bgp_data/asns.txt').catch(() => undefined); const current = await headS3ObjectEtag('bgp_data/asns.txt').catch(() => undefined);
if (current && current.replace(/\"/g, '"') !== String(etag)) { if (current && current.replace(/\"/g, '"') !== String(etag)) {
@@ -224,24 +387,41 @@ app.post('/api/asns', async (req, res) => {
// Get domains-new from S3 // Get domains-new from S3
app.get('/api/domains-new', async (req, res) => { app.get('/api/domains-new', async (req, res) => {
const { q = '', offset, limit } = req.query || {};
const params = { const params = {
Bucket: BUCKET_NAME, Bucket: BUCKET_NAME,
Key: 'bgp_data/domains_community.txt', Key: 'bgp_data/domains_community.txt',
}; };
try { try {
const data = await s3.getObject(params).promise(); if (limit !== undefined) {
const fileContent = data.Body.toString('utf-8'); const { items, total } = await streamPaginatedText({
const domains = fileContent.split('\n').filter(line => line).map(line => { key: 'bgp_data/domains_community.txt',
const parts = line.trim().split(/\s+/); q,
const domain = parts[0] || ''; offset: Number(offset) || 0,
const community = parts[1] || ''; limit: Number(limit) || 0,
return { domain, community }; mapLine: (line) => {
}); const parts = line.trim().split(/\s+/);
if (data.ETag) res.set('ETag', String(data.ETag)); return { domain: parts[0] || '', community: parts[1] || '' };
if (data.LastModified) res.set('Last-Modified', new Date(data.LastModified).toUTCString()); }
if (typeof data.ContentLength === 'number') res.set('Content-Length-Source', String(data.ContentLength)); });
res.json(domains); if (!validateDomainsNew(items)) return res.status(500).json({ message: 'Invalid data format' });
return res.json({ items, total });
} else {
const data = await s3.getObject(params).promise();
const fileContent = data.Body.toString('utf-8');
const domains = fileContent.split('\n').filter(line => line).map(line => {
const parts = line.trim().split(/\s+/);
const domain = parts[0] || '';
const community = parts[1] || '';
return { domain, community };
});
if (!validateDomainsNew(domains)) return res.status(500).json({ message: 'Invalid data format' });
if (data.ETag) res.set('ETag', String(data.ETag));
if (data.LastModified) res.set('Last-Modified', new Date(data.LastModified).toUTCString());
if (typeof data.ContentLength === 'number') res.set('Content-Length-Source', String(data.ContentLength));
res.json(domains);
}
} catch (error) { } catch (error) {
if (error.code === 'NoSuchKey') { if (error.code === 'NoSuchKey') {
res.json([]); // Return empty array if file does not exist res.json([]); // Return empty array if file does not exist
@@ -258,6 +438,9 @@ app.post('/api/domains-new', async (req, res) => {
const fileContent = (domains || []).map(d => `${String(d.domain || '').trim()} ${String(d.community || '').trim()}`.trim()).filter(Boolean).join('\n'); const fileContent = (domains || []).map(d => `${String(d.domain || '').trim()} ${String(d.community || '').trim()}`.trim()).filter(Boolean).join('\n');
try { try {
if (!validateDomainsNew(domains || [])) {
return res.status(400).json({ message: 'Invalid payload format for domains-new' });
}
if (etag) { if (etag) {
const current = await headS3ObjectEtag('bgp_data/domains_community.txt').catch(() => undefined); const current = await headS3ObjectEtag('bgp_data/domains_community.txt').catch(() => undefined);
if (current && current.replace(/\"/g, '"') !== String(etag)) { if (current && current.replace(/\"/g, '"') !== String(etag)) {
@@ -287,24 +470,41 @@ app.post('/api/domains-new', async (req, res) => {
// Get IP ranges from S3 // Get IP ranges from S3
app.get('/api/ip-ranges', async (req, res) => { app.get('/api/ip-ranges', async (req, res) => {
const { q = '', offset, limit } = req.query || {};
const params = { const params = {
Bucket: BUCKET_NAME, Bucket: BUCKET_NAME,
Key: 'bgp_data/ips.txt', Key: 'bgp_data/ips.txt',
}; };
try { try {
const data = await s3.getObject(params).promise(); if (limit !== undefined) {
const fileContent = data.Body.toString('utf-8'); const { items, total } = await streamPaginatedText({
const ipRanges = fileContent.split('\n').filter(line => line).map(line => { key: 'bgp_data/ips.txt',
const parts = line.trim().split(/\s+/); q,
const ipRange = parts[0] || ''; offset: Number(offset) || 0,
const community = parts[1] || ''; limit: Number(limit) || 0,
return { ipRange, community }; mapLine: (line) => {
}); const parts = line.trim().split(/\s+/);
if (data.ETag) res.set('ETag', String(data.ETag)); return { ipRange: parts[0] || '', community: parts[1] || '' };
if (data.LastModified) res.set('Last-Modified', new Date(data.LastModified).toUTCString()); }
if (typeof data.ContentLength === 'number') res.set('Content-Length-Source', String(data.ContentLength)); });
res.json(ipRanges); if (!validateIpRanges(items)) return res.status(500).json({ message: 'Invalid data format' });
return res.json({ items, total });
} else {
const data = await s3.getObject(params).promise();
const fileContent = data.Body.toString('utf-8');
const ipRanges = fileContent.split('\n').filter(line => line).map(line => {
const parts = line.trim().split(/\s+/);
const ipRange = parts[0] || '';
const community = parts[1] || '';
return { ipRange, community };
});
if (!validateIpRanges(ipRanges)) return res.status(500).json({ message: 'Invalid data format' });
if (data.ETag) res.set('ETag', String(data.ETag));
if (data.LastModified) res.set('Last-Modified', new Date(data.LastModified).toUTCString());
if (typeof data.ContentLength === 'number') res.set('Content-Length-Source', String(data.ContentLength));
res.json(ipRanges);
}
} catch (error) { } catch (error) {
if (error.code === 'NoSuchKey') { if (error.code === 'NoSuchKey') {
res.json([]); // Return empty array if file does not exist res.json([]); // Return empty array if file does not exist
@@ -321,6 +521,9 @@ app.post('/api/ip-ranges', async (req, res) => {
const fileContent = (ipRanges || []).map(ip => `${String(ip.ipRange || '').trim()} ${String(ip.community || '').trim()}`.trim()).filter(Boolean).join('\n'); const fileContent = (ipRanges || []).map(ip => `${String(ip.ipRange || '').trim()} ${String(ip.community || '').trim()}`.trim()).filter(Boolean).join('\n');
try { try {
if (!validateIpRanges(ipRanges || [])) {
return res.status(400).json({ message: 'Invalid payload format for ip-ranges' });
}
if (etag) { if (etag) {
const current = await headS3ObjectEtag('bgp_data/ips.txt').catch(() => undefined); const current = await headS3ObjectEtag('bgp_data/ips.txt').catch(() => undefined);
if (current && current.replace(/\"/g, '"') !== String(etag)) { if (current && current.replace(/\"/g, '"') !== String(etag)) {
+3 -2
View File
@@ -95,8 +95,9 @@ function ASNsNewManager() {
const fetchItems = async () => { const fetchItems = async () => {
setLoading(true); setLoading(true);
try { try {
const response = await axios.get(`${API_URL}/asns`); const response = await axios.get(`${API_URL}/asns`, { params: { offset: 0, limit: 0 } });
const mapped = response.data.map(item => ({ asn: item.domain, community: item.type })); const payload = Array.isArray(response.data?.items) ? response.data.items : (Array.isArray(response.data) ? response.data : []);
const mapped = payload.map(item => ({ asn: item.domain, community: item.type }));
setItems(mapped); setItems(mapped);
setOriginalItems(mapped); setOriginalItems(mapped);
setEtag(response.headers?.etag || ''); setEtag(response.headers?.etag || '');
+4 -3
View File
@@ -100,9 +100,10 @@ function DomainsNewManager() {
const fetchItems = async () => { const fetchItems = async () => {
setLoading(true); setLoading(true);
try { try {
const response = await axios.get(`${API_URL}/domains-new`); const response = await axios.get(`${API_URL}/domains-new`, { params: { offset: 0, limit: 0 } });
setItems(response.data); const payload = Array.isArray(response.data?.items) ? response.data.items : (Array.isArray(response.data) ? response.data : []);
setOriginalItems(response.data); setItems(payload);
setOriginalItems(payload);
setEtag(response.headers?.etag || ''); setEtag(response.headers?.etag || '');
setLastModified(response.headers?.['last-modified'] || ''); setLastModified(response.headers?.['last-modified'] || '');
const lengthHeader = response.headers?.['content-length-source']; const lengthHeader = response.headers?.['content-length-source'];
+4 -3
View File
@@ -111,9 +111,10 @@ function IPRangesManager() {
const fetchItems = async () => { const fetchItems = async () => {
setLoading(true); setLoading(true);
try { try {
const response = await axios.get(`${API_URL}/ip-ranges`); const response = await axios.get(`${API_URL}/ip-ranges`, { params: { offset: 0, limit: 0 } });
setItems(response.data); const payload = Array.isArray(response.data?.items) ? response.data.items : (Array.isArray(response.data) ? response.data : []);
setOriginalItems(response.data); setItems(payload);
setOriginalItems(payload);
setEtag(response.headers?.etag || ''); setEtag(response.headers?.etag || '');
setLastModified(response.headers?.['last-modified'] || ''); setLastModified(response.headers?.['last-modified'] || '');
const lengthHeader = response.headers?.['content-length-source']; const lengthHeader = response.headers?.['content-length-source'];