From 4635c05f8c108dafaa5c1158a11bfe94f5f73b3b Mon Sep 17 00:00:00 2001 From: Denis Shatskiy Date: Sun, 10 Aug 2025 23:10:25 +0700 Subject: [PATCH] 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 --- backend/server.js | 209 ++++++++++++++++++++++------- frontend/src/ASNsNewManager.jsx | 107 ++++++++++++++- frontend/src/Dashboard.jsx | 6 +- frontend/src/DomainsNewManager.jsx | 131 +++++++++++++++++- frontend/src/IPRangesManager.jsx | 126 ++++++++++++++++- 5 files changed, 513 insertions(+), 66 deletions(-) diff --git a/backend/server.js b/backend/server.js index 11d75ff..6f9e257 100644 --- a/backend/server.js +++ b/backend/server.js @@ -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 = { diff --git a/frontend/src/ASNsNewManager.jsx b/frontend/src/ASNsNewManager.jsx index b42a900..8c24275 100644 --- a/frontend/src/ASNsNewManager.jsx +++ b/frontend/src/ASNsNewManager.jsx @@ -19,6 +19,10 @@ const API_URL = '/api'; function ASNsNewManager() { const [items, setItems] = useState([]); + const [originalItems, setOriginalItems] = useState([]); + const [etag, setEtag] = useState(''); + const [lastModified, setLastModified] = useState(''); + const [contentLength, setContentLength] = useState(null); const [newItem, setNewItem] = useState({ asn: '', community: '' }); const [newInvalid, setNewInvalid] = useState({ asn: false, community: false }); const [error, setError] = useState(''); @@ -66,14 +70,33 @@ function ASNsNewManager() { const isValidCommunity = (value) => /^(\d+|\d+:\d+)$/.test(String(value).trim()); useEffect(() => { + // Acquire soft lock + const owner = localStorage.getItem('uiOwner') || `ui-${Math.random().toString(36).slice(2,8)}`; + localStorage.setItem('uiOwner', owner); + const resource = 'asns'; + const acquire = async () => { + try { await axios.post(`${API_URL}/locks/${resource}`, { owner, ttlSeconds: 180 }); } catch {} + }; + acquire(); + const interval = setInterval(acquire, 60_000); fetchItems(); + return () => { + clearInterval(interval); + axios.delete(`${API_URL}/locks/${resource}`).catch(() => {}); + }; }, []); const fetchItems = async () => { setLoading(true); try { const response = await axios.get(`${API_URL}/asns`); - setItems(response.data.map(item => ({ asn: item.domain, community: item.type }))); + const mapped = response.data.map(item => ({ asn: item.domain, community: item.type })); + setItems(mapped); + setOriginalItems(mapped); + setEtag(response.headers?.etag || ''); + setLastModified(response.headers?.['last-modified'] || ''); + const lengthHeader = response.headers?.['content-length-source']; + setContentLength(typeof lengthHeader !== 'undefined' ? Number(lengthHeader) : null); setError(''); } catch (error) { console.error('Error fetching ASNs:', error); @@ -135,12 +158,64 @@ function ASNsNewManager() { } }; + const deduplicate = (arr) => { + const seen = new Set(); + const out = []; + for (const i of arr) { + const key = String(i.asn).trim(); + if (!key) continue; + if (seen.has(key)) continue; + seen.add(key); + out.push({ asn: key, community: String(i.community || '').trim() }); + } + return out; + }; + + const computeDiff = (before, after) => { + const mapBefore = new Map(before.map(i => [i.asn, i])); + const mapAfter = new Map(after.map(i => [i.asn, i])); + const added = []; + const removed = []; + const changed = []; + for (const [k, v] of mapAfter) { + if (!mapBefore.has(k)) { added.push(v); continue; } + const prev = mapBefore.get(k); + if (String(prev.community) !== String(v.community)) changed.push({ from: prev, to: v }); + } + for (const [k, v] of mapBefore) { + if (!mapAfter.has(k)) removed.push(v); + } + return { added, removed, changed }; + }; + + const [showDiff, setShowDiff] = useState(false); + const [diff, setDiff] = useState({ added: [], removed: [], changed: [] }); + + const handlePreviewDiff = () => { + const valid = items.filter(i => isValidAsn(i.asn) && isValidCommunity(i.community)) + .map(i => ({ asn: String(i.asn).trim(), community: String(i.community).trim() })); + const unique = deduplicate(valid); + setDiff(computeDiff(originalItems, unique)); + setShowDiff(true); + }; + const handleSaveChanges = async () => { setLoading(true); try { // API ожидает domains: [{domain, type}] - const valid = items.filter(i => isValidAsn(i.asn) && isValidCommunity(i.community)); - await axios.post(`${API_URL}/asns`, { domains: valid.map(i => ({ domain: i.asn, type: i.community })) }); + const valid = items.filter(i => isValidAsn(i.asn) && isValidCommunity(i.community)) + .map(i => ({ asn: String(i.asn).trim(), community: String(i.community).trim() })); + const unique = deduplicate(valid); + const payload = { domains: unique.map(i => ({ domain: i.asn, type: i.community })), etag }; + const response = await axios.post(`${API_URL}/asns`, payload, { validateStatus: () => true }); + if (response.status === 412) { + setError('Данные изменились в S3 (ETag mismatch). Обновите список и попробуйте снова.'); + return; + } + if (response.status >= 400) throw new Error(`Save failed with status ${response.status}`); + setEtag(response.headers?.etag || etag); + setOriginalItems(unique); + setItems(unique); setSuccess('Изменения успешно сохранены!'); setTimeout(() => setSuccess(''), 3000); } catch (error) { @@ -157,9 +232,17 @@ function ASNsNewManager() { const lines = text.split(/\r?\n/).map(l => l.trim()).filter(Boolean); const parsed = lines.map(l => { const [a, c] = l.split(/\s+/); - return { asn: a || '', community: c || '' }; + return { asn: (a || '').trim(), community: (c || '').trim() }; + }).filter(i => isValidAsn(i.asn) && isValidCommunity(i.community)); + setItems(prev => { + const merged = [...prev, ...parsed]; + const map = new Map(); + for (const it of merged) { + const key = String(it.asn).trim(); + if (!map.has(key)) map.set(key, { asn: key, community: String(it.community).trim() }); + } + return Array.from(map.values()); }); - setItems(prev => [...prev, ...parsed]); }; const handleExport = () => { @@ -369,6 +452,13 @@ function ASNsNewManager() { Экспорт CSV +