feat: Add S3 metadata handling and soft-lock mechanism in backend, enhance ASNs, Domains, and IPRanges managers with ETag support and change preview functionality for improved data integrity and user experience
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 5m30s

This commit is contained in:
2025-08-10 23:10:25 +07:00
parent 817a8981f4
commit 4635c05f8c
5 changed files with 513 additions and 66 deletions
+161 -48
View File
@@ -55,6 +55,36 @@ function buildNestedGatewayBlocks(gatewayGroups, baseIndentSpaces = 4) {
return buildAt(0, baseIndentSpaces);
}
// Helper: read text file from S3 and return { body, etag, lastModified, contentLength }
async function readS3TextObject(key) {
const params = { Bucket: BUCKET_NAME, Key: key };
const data = await s3.getObject(params).promise();
return {
body: data.Body.toString('utf-8'),
etag: data.ETag ? String(data.ETag).replace(/\"/g, '"') : undefined,
lastModified: data.LastModified ? data.LastModified.toISOString() : undefined,
contentLength: typeof data.ContentLength === 'number' ? data.ContentLength : undefined
};
}
// Helper: head object and return current ETag
async function headS3ObjectEtag(key) {
const head = await s3.headObject({ Bucket: BUCKET_NAME, Key: key }).promise();
return head.ETag ? String(head.ETag).replace(/\"/g, '"') : undefined;
}
// Simple in-memory soft locks with TTL
const locks = new Map(); // key -> { owner, expiresAt }
function cleanupExpiredLocks() {
const now = Date.now();
for (const [k, v] of locks.entries()) {
if (!v || typeof v.expiresAt !== 'number' || v.expiresAt <= now) {
locks.delete(k);
}
}
}
setInterval(cleanupExpiredLocks, 30_000);
// Get domains from S3
app.get('/api/domains', async (req, res) => {
const params = {
@@ -73,6 +103,15 @@ app.get('/api/domains', async (req, res) => {
const type = parts[1] || '';
return { domain, type };
});
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) {
if (error.code === 'NoSuchKey') {
@@ -84,10 +123,22 @@ app.get('/api/domains', async (req, res) => {
}
});
// Update domains in S3
// Update domains in S3 with optimistic concurrency via ETag check
app.post('/api/domains', async (req, res) => {
const { domains } = req.body;
const fileContent = domains.map(d => `${d.domain} ${d.type}`).join('\n');
const { domains, etag } = req.body;
const fileContent = (domains || []).map(d => `${String(d.domain || '').trim()} ${String(d.type || '').trim()}`.trim()).filter(Boolean).join('\n');
// Concurrency guard: if client sent etag, ensure current ETag matches
try {
if (etag) {
const current = await headS3ObjectEtag(FILE_KEY).catch(() => undefined);
if (current && current.replace(/\"/g, '"') !== String(etag)) {
return res.status(412).json({ message: 'Precondition Failed: ETag mismatch' });
}
}
} catch (e) {
// ignore if head fails due to NoSuchKey; proceed to create
}
const params = {
Bucket: BUCKET_NAME,
@@ -97,7 +148,8 @@ app.post('/api/domains', async (req, res) => {
};
try {
await s3.putObject(params).promise();
const put = await s3.putObject(params).promise();
res.set('ETag', put.ETag || '');
res.send('File updated successfully');
} catch (error) {
console.error(error);
@@ -123,6 +175,9 @@ app.get('/api/asns', async (req, res) => {
const type = parts[1] || '';
return { domain, type };
});
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) {
if (error.code === 'NoSuchKey') {
@@ -136,8 +191,17 @@ app.get('/api/asns', async (req, res) => {
// Update ASNs in S3
app.post('/api/asns', async (req, res) => {
const { domains: asns } = req.body; // Keep name 'domains' for consistency
const fileContent = asns.map(a => `${a.domain} ${a.type}`).join('\n');
const { domains: asns, etag } = req.body; // Keep name 'domains' for consistency
const fileContent = (asns || []).map(a => `${String(a.domain || '').trim()} ${String(a.type || '').trim()}`.trim()).filter(Boolean).join('\n');
try {
if (etag) {
const current = await headS3ObjectEtag('bgp_data/asns.txt').catch(() => undefined);
if (current && current.replace(/\"/g, '"') !== String(etag)) {
return res.status(412).json({ message: 'Precondition Failed: ETag mismatch' });
}
}
} catch {}
const params = {
Bucket: BUCKET_NAME,
@@ -147,7 +211,8 @@ app.post('/api/asns', async (req, res) => {
};
try {
await s3.putObject(params).promise();
const put = await s3.putObject(params).promise();
if (put.ETag) res.set('ETag', String(put.ETag));
res.send('File updated successfully');
} catch (error) {
console.error(error);
@@ -173,6 +238,9 @@ app.get('/api/domains-new', async (req, res) => {
const community = parts[1] || '';
return { domain, community };
});
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) {
if (error.code === 'NoSuchKey') {
@@ -186,8 +254,17 @@ app.get('/api/domains-new', async (req, res) => {
// Update domains-new in S3
app.post('/api/domains-new', async (req, res) => {
const { domains } = req.body;
const fileContent = domains.map(d => `${d.domain} ${d.community}`).join('\n');
const { domains, etag } = req.body;
const fileContent = (domains || []).map(d => `${String(d.domain || '').trim()} ${String(d.community || '').trim()}`.trim()).filter(Boolean).join('\n');
try {
if (etag) {
const current = await headS3ObjectEtag('bgp_data/domains_community.txt').catch(() => undefined);
if (current && current.replace(/\"/g, '"') !== String(etag)) {
return res.status(412).json({ message: 'Precondition Failed: ETag mismatch' });
}
}
} catch {}
const params = {
Bucket: BUCKET_NAME,
@@ -197,7 +274,8 @@ app.post('/api/domains-new', async (req, res) => {
};
try {
await s3.putObject(params).promise();
const put = await s3.putObject(params).promise();
if (put.ETag) res.set('ETag', String(put.ETag));
res.send('File updated successfully');
} catch (error) {
console.error(error);
@@ -223,6 +301,9 @@ app.get('/api/ip-ranges', async (req, res) => {
const community = parts[1] || '';
return { ipRange, community };
});
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) {
if (error.code === 'NoSuchKey') {
@@ -236,8 +317,17 @@ app.get('/api/ip-ranges', async (req, res) => {
// Update IP ranges in S3
app.post('/api/ip-ranges', async (req, res) => {
const { ipRanges } = req.body;
const fileContent = ipRanges.map(ip => `${ip.ipRange} ${ip.community}`).join('\n');
const { ipRanges, etag } = req.body;
const fileContent = (ipRanges || []).map(ip => `${String(ip.ipRange || '').trim()} ${String(ip.community || '').trim()}`.trim()).filter(Boolean).join('\n');
try {
if (etag) {
const current = await headS3ObjectEtag('bgp_data/ips.txt').catch(() => undefined);
if (current && current.replace(/\"/g, '"') !== String(etag)) {
return res.status(412).json({ message: 'Precondition Failed: ETag mismatch' });
}
}
} catch {}
const params = {
Bucket: BUCKET_NAME,
@@ -247,7 +337,8 @@ app.post('/api/ip-ranges', async (req, res) => {
};
try {
await s3.putObject(params).promise();
const put = await s3.putObject(params).promise();
if (put.ETag) res.set('ETag', String(put.ETag));
res.send('File updated successfully');
} catch (error) {
console.error(error);
@@ -554,49 +645,71 @@ app.post('/api/filters', async (req, res) => {
}
});
// Новый эндпоинт для получения дат последнего изменения файлов S3
// Эндпоинт метаданных S3 по ключевым файлам (Last-Modified, ETag, Content-Length)
app.get('/api/s3/last-modified', async (req, res) => {
try {
const results = await Promise.allSettled([
s3.headObject({ Bucket: BUCKET_NAME, Key: 'bgp_data/domains_community.txt' }).promise(),
s3.headObject({ Bucket: BUCKET_NAME, Key: 'bgp_data/domains_community.txt' }).promise(),
s3.headObject({ Bucket: BUCKET_NAME, Key: 'bgp_data/asns.txt' }).promise(),
s3.headObject({ Bucket: BUCKET_NAME, Key: 'servers.json' }).promise(),
s3.headObject({ Bucket: BUCKET_NAME, Key: 'filters.json' }).promise()
]);
const response = {
domainsLastModified: null,
domainsNewLastModified: null,
asnsLastModified: null,
serversLastModified: null,
filtersLastModified: null
};
// Обрабатываем результаты
if (results[0].status === 'fulfilled') {
response.domainsLastModified = results[0].value.LastModified ? results[0].value.LastModified.toISOString() : null;
}
if (results[1].status === 'fulfilled') {
response.domainsNewLastModified = results[1].value.LastModified ? results[1].value.LastModified.toISOString() : null;
}
if (results[2].status === 'fulfilled') {
response.asnsLastModified = results[2].value.LastModified ? results[2].value.LastModified.toISOString() : null;
}
if (results[3].status === 'fulfilled') {
response.serversLastModified = results[3].value.LastModified ? results[3].value.LastModified.toISOString() : null;
}
if (results[4].status === 'fulfilled') {
response.filtersLastModified = results[4].value.LastModified ? results[4].value.LastModified.toISOString() : null;
}
res.json(response);
const keys = [
{ name: 'domainsNew', key: 'bgp_data/domains_community.txt' },
{ name: 'asns', key: 'bgp_data/asns.txt' },
{ name: 'servers', key: 'servers.json' },
{ name: 'filters', key: 'filters.json' },
{ name: 'ipRanges', key: 'bgp_data/ips.txt' }
];
const results = await Promise.allSettled(
keys.map(k => s3.headObject({ Bucket: BUCKET_NAME, Key: k.key }).promise())
);
const out = {};
results.forEach((r, idx) => {
const name = keys[idx].name;
if (r.status === 'fulfilled') {
out[name] = {
lastModified: r.value.LastModified ? r.value.LastModified.toISOString() : null,
etag: r.value.ETag || null,
contentLength: typeof r.value.ContentLength === 'number' ? r.value.ContentLength : null
};
} else {
out[name] = null;
}
});
res.json(out);
} catch (error) {
console.error('Error fetching last modified dates from S3:', error);
res.status(500).send('Error fetching last modified dates from S3');
}
});
// Soft-lock endpoints
// GET lock status
app.get('/api/locks/:resource', (req, res) => {
cleanupExpiredLocks();
const { resource } = req.params;
const info = locks.get(resource);
if (!info) return res.json({ locked: false });
res.json({ locked: true, owner: info.owner, expiresAt: info.expiresAt });
});
// POST acquire/refresh lock
app.post('/api/locks/:resource', (req, res) => {
cleanupExpiredLocks();
const { resource } = req.params;
const { owner = 'anonymous', ttlSeconds = 120 } = req.body || {};
const now = Date.now();
const existing = locks.get(resource);
if (existing && existing.expiresAt > now && existing.owner !== owner) {
return res.status(423).json({ message: 'Resource is locked by another user', owner: existing.owner, expiresAt: existing.expiresAt });
}
const expiresAt = now + Math.max(30, Math.min(600, Number(ttlSeconds) || 120)) * 1000;
locks.set(resource, { owner, expiresAt });
res.json({ locked: true, owner, expiresAt });
});
// DELETE release lock
app.delete('/api/locks/:resource', (req, res) => {
const { resource } = req.params;
locks.delete(resource);
res.json({ released: true });
});
// Generate MikroTik configuration from filters
app.get('/api/filters/generate-config', async (req, res) => {
const params = {