статистика
All checks were successful
Deploy hotel / deploy-kdo (push) Successful in 1m18s

This commit is contained in:
2026-07-26 20:50:19 +05:00
parent 5cceec5c40
commit 3f318c2691
5 changed files with 440 additions and 1 deletions

View File

@@ -331,6 +331,26 @@ function initDatabase(db) {
is_active INTEGER DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`, () => {
setupVisitorLogTable();
});
}
function setupVisitorLogTable() {
db.run(`CREATE TABLE IF NOT EXISTS visitor_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ip TEXT NOT NULL,
session_id TEXT,
user_agent TEXT,
accept_language TEXT,
country TEXT,
country_code TEXT,
page_path TEXT,
visit_date DATE DEFAULT (date('now')),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`, () => {
db.run(`CREATE INDEX IF NOT EXISTS idx_visitor_log_session ON visitor_log(session_id)`);
db.run(`CREATE INDEX IF NOT EXISTS idx_visitor_log_date ON visitor_log(visit_date)`);
db.run(`CREATE INDEX IF NOT EXISTS idx_visitor_log_country ON visitor_log(country_code)`);
setupIndices();
});
}

145
modules/visitors/index.js Normal file
View File

@@ -0,0 +1,145 @@
let db;
function init(database) {
db = database;
}
function getStats(req, res) {
db.get(`SELECT COUNT(DISTINCT session_id) as total FROM visitor_log`, [], (err, totalRow) => {
if (err) return res.status(500).json({ error: err.message });
db.get(`SELECT COUNT(DISTINCT session_id) as today FROM visitor_log WHERE visit_date = date('now')`, [], (err, todayRow) => {
if (err) return res.status(500).json({ error: err.message });
db.get(`SELECT COUNT(DISTINCT session_id) as week FROM visitor_log WHERE visit_date >= date('now', '-6 days')`, [], (err, weekRow) => {
if (err) return res.status(500).json({ error: err.message });
db.get(`SELECT COUNT(DISTINCT session_id) as month FROM visitor_log WHERE visit_date >= date('now', 'start of month')`, [], (err, monthRow) => {
if (err) return res.status(500).json({ error: err.message });
db.get(`SELECT COUNT(*) as page_views FROM visitor_log`, [], (err, pvRow) => {
if (err) return res.status(500).json({ error: err.message });
db.get(`SELECT COUNT(DISTINCT ip) as unique_ips FROM visitor_log`, [], (err, ipRow) => {
if (err) return res.status(500).json({ error: err.message });
res.json({
total: totalRow ? totalRow.total : 0,
today: todayRow ? todayRow.today : 0,
week: weekRow ? weekRow.week : 0,
month: monthRow ? monthRow.month : 0,
pageViews: pvRow ? pvRow.page_views : 0,
uniqueIps: ipRow ? ipRow.unique_ips : 0
});
});
});
});
});
});
});
}
function getCountries(req, res) {
db.all(`SELECT COALESCE(NULLIF(country, ''), 'Другое') as country,
COALESCE(NULLIF(country_code, ''), '--') as country_code,
COUNT(DISTINCT session_id) as count
FROM visitor_log
GROUP BY COALESCE(NULLIF(country_code, ''), '--')
ORDER BY count DESC LIMIT 30`, [], (err, rows) => {
if (err) return res.status(500).json({ error: err.message });
res.json(rows || []);
});
}
function getLanguages(req, res) {
db.all(`SELECT accept_language, COUNT(DISTINCT session_id) as count
FROM visitor_log
WHERE accept_language IS NOT NULL AND accept_language != ''
GROUP BY accept_language
ORDER BY count DESC`, [], (err, rows) => {
if (err) return res.status(500).json({ error: err.message });
const parsed = (rows || []).map(r => {
const primary = (r.accept_language || '').split(',')[0].split(';')[0].trim();
const lang = primary.split('-')[0];
return { lang, raw: primary, count: r.count };
});
res.json(parsed);
});
}
function getDaily(req, res) {
db.all(`SELECT visit_date, COUNT(DISTINCT session_id) as count, COUNT(*) as page_views
FROM visitor_log
WHERE visit_date >= date('now', '-29 days')
GROUP BY visit_date
ORDER BY visit_date ASC`, [], (err, rows) => {
if (err) return res.status(500).json({ error: err.message });
res.json(rows || []);
});
}
function getIPs(req, res) {
const limit = Math.min(parseInt(req.query.limit) || 50, 200);
const offset = parseInt(req.query.offset) || 0;
const { id, ip, country, lang, date_from, date_to, page } = req.query;
const whereClauses = [];
const params = [];
if (id) { whereClauses.push('id = ?'); params.push(parseInt(id)); }
if (ip) { whereClauses.push('ip LIKE ?'); params.push('%' + ip + '%'); }
if (country) { whereClauses.push('(country LIKE ? OR country_code LIKE ?)'); params.push('%' + country + '%'); params.push('%' + country + '%'); }
if (lang) { whereClauses.push('accept_language LIKE ?'); params.push('%' + lang + '%'); }
if (date_from) { whereClauses.push('visit_date >= ?'); params.push(date_from); }
if (date_to) { whereClauses.push('visit_date <= ?'); params.push(date_to); }
if (page) { whereClauses.push('page_path LIKE ?'); params.push('%' + page + '%'); }
const whereSQL = whereClauses.length > 0 ? 'WHERE ' + whereClauses.join(' AND ') : '';
db.get(`SELECT COUNT(*) as total FROM visitor_log ${whereSQL}`, params, (err, countRow) => {
if (err) return res.status(500).json({ error: err.message });
const allParams = [...params, limit, offset];
db.all(`SELECT id, ip, COALESCE(NULLIF(country, ''), 'Другое') as country,
country_code, session_id, visit_date, page_path,
COALESCE(NULLIF(accept_language, ''), '-') as accept_language,
created_at
FROM visitor_log
${whereSQL}
ORDER BY created_at DESC
LIMIT ? OFFSET ?`, allParams, (err, rows) => {
if (err) return res.status(500).json({ error: err.message });
res.json({
entries: rows || [],
total: countRow ? countRow.total : 0,
limit,
offset
});
});
});
}
function getFilterOptions(req, res) {
db.all(`SELECT DISTINCT COALESCE(NULLIF(country, ''), 'Другое') as country
FROM visitor_log ORDER BY country`, [], (err, countries) => {
if (err) return res.status(500).json({ error: err.message });
db.all(`SELECT DISTINCT accept_language FROM visitor_log
WHERE accept_language IS NOT NULL AND accept_language != ''
ORDER BY accept_language`, [], (err, langs) => {
if (err) return res.status(500).json({ error: err.message });
db.all(`SELECT DISTINCT page_path FROM visitor_log ORDER BY page_path`, [], (err, pages) => {
if (err) return res.status(500).json({ error: err.message });
res.json({
countries: (countries || []).map(c => c.country),
languages: (langs || []).map(l => l.accept_language),
pages: (pages || []).map(p => p.page_path)
});
});
});
});
}
function setupRoutes(app, authenticateToken) {
app.get('/api/admin/visitors/stats', authenticateToken, getStats);
app.get('/api/admin/visitors/countries', authenticateToken, getCountries);
app.get('/api/admin/visitors/languages', authenticateToken, getLanguages);
app.get('/api/admin/visitors/daily', authenticateToken, getDaily);
app.get('/api/admin/visitors/ips', authenticateToken, getIPs);
app.get('/api/admin/visitors/filters', authenticateToken, getFilterOptions);
}
module.exports = { init, setupRoutes };

View File

@@ -3,6 +3,7 @@
"bcryptjs": "^3.0.3",
"dotenv": "^17.4.2",
"express": "^5.2.1",
"geoip-lite": "^2.0.3",
"jsonwebtoken": "^9.0.3",
"multer": "^1.4.5-lts.1",
"node-cron": "^4.2.1",

View File

@@ -208,6 +208,7 @@ tr.row-checkout-today { background: #fef2f2 !important; border-left: 4px solid #
<a href="#" data-tab="seasonal"><i class="fas fa-tags"></i> Сезонные цены</a>
<a href="#" data-tab="activities"><i class="fas fa-umbrella-beach"></i> Развлечения</a>
<a href="#" data-tab="hero"><i class="fas fa-image"></i> Главный экран</a>
<a href="#" data-tab="visitors"><i class="fas fa-chart-bar"></i> Посетители</a>
<a href="#" data-tab="reviews"><i class="fas fa-star"></i> Отзывы</a>
<a href="#" data-tab="settings"><i class="fas fa-cog"></i> Настройки</a>
<a href="#" data-tab="profile"><i class="fas fa-user-circle"></i> Профиль</a>
@@ -724,7 +725,6 @@ tr.row-checkout-today { background: #fef2f2 !important; border-left: 4px solid #
</div>
</div>
</div>
<div id="tab-hero" class="tab-content">
<div class="top-bar">
<h1>Главный экран</h1>
@@ -736,6 +736,85 @@ tr.row-checkout-today { background: #fef2f2 !important; border-left: 4px solid #
</div>
</div>
</div>
<div id="tab-visitors" class="tab-content">
<div class="top-bar">
<h1>Посетители</h1>
</div>
<div class="card" style="margin-bottom: 20px;">
<div class="card-body-custom">
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(min(160px, 100%), 1fr)); gap: 16px;">
<div class="stat-card" style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color:#fff; padding: clamp(12px,2vw,20px); border-radius:12px; text-align:center;">
<div style="font-size:0.85rem;opacity:0.9;margin-bottom:6px;">Всего посетителей</div>
<div style="font-size:clamp(1.5rem,3vw,2.2rem);font-weight:700;" id="visTotal">-</div>
</div>
<div class="stat-card" style="background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%); color:#fff; padding: clamp(12px,2vw,20px); border-radius:12px; text-align:center;">
<div style="font-size:0.85rem;opacity:0.9;margin-bottom:6px;">Сегодня</div>
<div style="font-size:clamp(1.5rem,3vw,2.2rem);font-weight:700;" id="visToday">-</div>
</div>
<div class="stat-card" style="background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%); color:#fff; padding: clamp(12px,2vw,20px); border-radius:12px; text-align:center;">
<div style="font-size:0.85rem;opacity:0.9;margin-bottom:6px;">За 7 дней</div>
<div style="font-size:clamp(1.5rem,3vw,2.2rem);font-weight:700;" id="visWeek">-</div>
</div>
<div class="stat-card" style="background: linear-gradient(135deg, #43e97b 0%, #38f9d7 100%); color:#fff; padding: clamp(12px,2vw,20px); border-radius:12px; text-align:center;">
<div style="font-size:0.85rem;opacity:0.9;margin-bottom:6px;">За месяц</div>
<div style="font-size:clamp(1.5rem,3vw,2.2rem);font-weight:700;" id="visMonth">-</div>
</div>
<div class="stat-card" style="background: linear-gradient(135deg, #fa709a 0%, #fee140 100%); color:#fff; padding: clamp(12px,2vw,20px); border-radius:12px; text-align:center;">
<div style="font-size:0.85rem;opacity:0.9;margin-bottom:6px;">Просмотров страниц</div>
<div style="font-size:clamp(1.5rem,3vw,2.2rem);font-weight:700;" id="visPageViews">-</div>
</div>
<div class="stat-card" style="background: linear-gradient(135deg, #a18cd1 0%, #fbc2eb 100%); color:#fff; padding: clamp(12px,2vw,20px); border-radius:12px; text-align:center;">
<div style="font-size:0.85rem;opacity:0.9;margin-bottom:6px;">Уникальных IP</div>
<div style="font-size:clamp(1.5rem,3vw,2.2rem);font-weight:700;" id="visUniqueIps">-</div>
</div>
</div>
</div>
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 20px;">
<div class="card">
<div class="card-body-custom">
<h3 style="margin-bottom: 16px;">По странам</h3>
<div id="visCountriesTable"></div>
</div>
</div>
<div class="card">
<div class="card-body-custom">
<h3 style="margin-bottom: 16px;">Языки браузеров</h3>
<div id="visLanguagesTable"></div>
</div>
</div>
</div>
<div class="card" style="margin-top: 20px;">
<div class="card-body-custom">
<h3 style="margin-bottom: 16px;">Посещения за 30 дней</h3>
<div id="visDailyChart" style="overflow-x: auto;">
<canvas id="visitorsChart" style="width:100%;max-height:300px;"></canvas>
</div>
</div>
</div>
<div class="card" style="margin-top: 20px;">
<div class="card-body-custom">
<h3 style="margin-bottom: 16px;">IP-адреса и даты</h3>
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(min(150px, 100%), 1fr)); gap: 10px; margin-bottom: 14px;">
<div><label style="font-size:0.75rem;color:#64748b;display:block;margin-bottom:2px;">ID</label><input type="number" id="visFiltId" placeholder="123" style="width:100%;padding:6px 8px;border:1px solid #e5e7eb;border-radius:6px;font-size:0.85rem;" onchange="loadIPs(0)"></div>
<div><label style="font-size:0.75rem;color:#64748b;display:block;margin-bottom:2px;">IP / маска</label><input type="text" id="visFiltIp" placeholder="192.168." style="width:100%;padding:6px 8px;border:1px solid #e5e7eb;border-radius:6px;font-size:0.85rem;" onchange="loadIPs(0)"></div>
<div><label style="font-size:0.75rem;color:#64748b;display:block;margin-bottom:2px;">Страна</label><select id="visFiltCountry" style="width:100%;padding:6px 8px;border:1px solid #e5e7eb;border-radius:6px;font-size:0.85rem;" onchange="loadIPs(0)"><option value="">Все</option></select></div>
<div><label style="font-size:0.75rem;color:#64748b;display:block;margin-bottom:2px;">Язык</label><select id="visFiltLang" style="width:100%;padding:6px 8px;border:1px solid #e5e7eb;border-radius:6px;font-size:0.85rem;" onchange="loadIPs(0)"><option value="">Все</option></select></div>
<div><label style="font-size:0.75rem;color:#64748b;display:block;margin-bottom:2px;">Дата от</label><input type="date" id="visFiltDateFrom" style="width:100%;padding:6px 8px;border:1px solid #e5e7eb;border-radius:6px;font-size:0.85rem;" onchange="loadIPs(0)"></div>
<div><label style="font-size:0.75rem;color:#64748b;display:block;margin-bottom:2px;">Дата до</label><input type="date" id="visFiltDateTo" style="width:100%;padding:6px 8px;border:1px solid #e5e7eb;border-radius:6px;font-size:0.85rem;" onchange="loadIPs(0)"></div>
<div><label style="font-size:0.75rem;color:#64748b;display:block;margin-bottom:2px;">Страница</label><select id="visFiltPage" style="width:100%;padding:6px 8px;border:1px solid #e5e7eb;border-radius:6px;font-size:0.85rem;" onchange="loadIPs(0)"><option value="">Все</option></select></div>
<div style="display:flex;align-items:flex-end;"><button onclick="loadIPs(0)" style="padding:6px 16px;background:#c9a84c;color:#fff;border:none;border-radius:6px;font-size:0.85rem;cursor:pointer;">Найти</button></div>
</div>
<div id="visIPsTable" style="overflow-x: auto;"></div>
<div id="visIPsPagination" style="text-align:center;margin-top:12px;"></div>
</div>
</div>
</div>
</div>
</div>
@@ -911,6 +990,7 @@ function initTabs() {
if (tab === 'seasonal') loadSeasonalPrices();
if (tab === 'activities') loadActivitiesAdmin();
if (tab === 'hero') loadHeroMediaAdmin();
if (tab === 'visitors') loadVisitors();
if (tab === 'reviews') loadReviews();
if (tab === 'settings') loadSettings();
if (tab === 'profile') loadProfile();
@@ -2976,6 +3056,135 @@ function deleteHeroMedia(id) {
.then(() => { loadHeroMediaAdmin(); showToast('Медиа удалено'); })
.catch(err => showToast(err.message, 'error'));
}
async function loadVisitors() {
try {
const stats = await api('/api/admin/visitors/stats');
document.getElementById('visTotal').textContent = stats.total;
document.getElementById('visToday').textContent = stats.today;
document.getElementById('visWeek').textContent = stats.week;
document.getElementById('visMonth').textContent = stats.month;
document.getElementById('visPageViews').textContent = stats.pageViews;
document.getElementById('visUniqueIps').textContent = stats.uniqueIps;
const countries = await api('/api/admin/visitors/countries');
const ct = document.getElementById('visCountriesTable');
if (countries.length === 0) {
ct.innerHTML = '<div style="text-align:center;color:#94a3b8;padding:20px;">Нет данных</div>';
} else {
ct.innerHTML = '<table style="width:100%;border-collapse:collapse;"><thead><tr><th style="text-align:left;padding:8px;border-bottom:2px solid #e5e7eb;"><3E><>трана</th><th style="text-align:right;padding:8px;border-bottom:2px solid #e5e7eb;"><3E><>осетителей</th></tr></thead><tbody>' +
countries.map(c => `<tr><td style="padding:6px 8px;border-bottom:1px solid #f1f5f9;">${c.country || '—'} <span style="color:#94a3b8;font-size:0.75rem;">${c.country_code || ''}</span></td><td style="text-align:right;font-weight:600;padding:6px 8px;border-bottom:1px solid #f1f5f9;">${c.count}</td></tr>`).join('') +
'</tbody></table>';
}
const langs = await api('/api/admin/visitors/languages');
const lt = document.getElementById('visLanguagesTable');
if (langs.length === 0) {
lt.innerHTML = '<div style="text-align:center;color:#94a3b8;padding:20px;">Нет данных</div>';
} else {
lt.innerHTML = '<table style="width:100%;border-collapse:collapse;"><thead><tr><th style="text-align:left;padding:8px;border-bottom:2px solid #e5e7eb;">Язык</th><th style="text-align:right;padding:8px;border-bottom:2px solid #e5e7eb;">Посетителей</th></tr></thead><tbody>' +
langs.map(l => `<tr><td style="padding:6px 8px;border-bottom:1px solid #f1f5f9;">${l.raw || l.lang || '—'}</td><td style="text-align:right;font-weight:600;padding:6px 8px;border-bottom:1px solid #f1f5f9;">${l.count}</td></tr>`).join('') +
'</tbody></table>';
}
const daily = await api('/api/admin/visitors/daily');
if (daily.length > 0) {
let html = '<table style="width:100%;border-collapse:collapse;font-size:0.9rem;"><thead><tr><th style="text-align:left;padding:8px;border-bottom:2px solid #e5e7eb;">Дата</th><th style="text-align:right;padding:8px;border-bottom:2px solid #e5e7eb;">Посетителей</th><th style="text-align:right;padding:8px;border-bottom:2px solid #e5e7eb;">Просмотров</th></tr></thead><tbody>';
daily.reverse().forEach(d => {
html += `<tr><td style="padding:5px 8px;border-bottom:1px solid #f1f5f9;">${d.visit_date}</td><td style="text-align:right;font-weight:600;padding:5px 8px;border-bottom:1px solid #f1f5f9;">${d.count}</td><td style="text-align:right;padding:5px 8px;border-bottom:1px solid #f1f5f9;">${d.page_views}</td></tr>`;
});
html += '</tbody></table>';
document.getElementById('visDailyChart').innerHTML = html;
}
await loadFilterOptions();
loadIPs(0);
} catch (err) {
showToast(err.message, 'error');
}
}
function fillSelect(id, items) {
const sel = document.getElementById(id);
if (!sel) return;
const val = sel.value;
sel.innerHTML = '<option value="">Все</option>';
items.forEach(item => {
const opt = document.createElement('option');
opt.value = item;
opt.textContent = item;
sel.appendChild(opt);
});
if (val) sel.value = val;
}
async function loadFilterOptions() {
try {
const data = await api('/api/admin/visitors/filters');
fillSelect('visFiltCountry', data.countries || []);
fillSelect('visFiltLang', data.languages || []);
fillSelect('visFiltPage', data.pages || []);
} catch (e) {
// silently fail, filters stay as "Все"
}
}
async function loadIPs(offset) {
const limit = 50;
document.getElementById('visIPsTable').innerHTML = '<div style="text-align:center;color:#94a3b8;padding:20px;">Загрузка...</div>';
try {
const filters = {
id: document.getElementById('visFiltId').value.trim(),
ip: document.getElementById('visFiltIp').value.trim(),
country: document.getElementById('visFiltCountry').value.trim(),
lang: document.getElementById('visFiltLang').value.trim(),
date_from: document.getElementById('visFiltDateFrom').value,
date_to: document.getElementById('visFiltDateTo').value,
page: document.getElementById('visFiltPage').value.trim()
};
let qs = `limit=${limit}&offset=${offset}`;
Object.keys(filters).forEach(k => {
if (filters[k]) qs += '&' + k + '=' + encodeURIComponent(filters[k]);
});
const data = await api('/api/admin/visitors/ips?' + qs);
if (data.entries.length === 0) {
document.getElementById('visIPsTable').innerHTML = '<div style="text-align:center;color:#94a3b8;padding:20px;">Нет данных</div>';
document.getElementById('visIPsPagination').innerHTML = '';
return;
}
let html = '<table style="width:100%;border-collapse:collapse;font-size:0.85rem;"><thead><tr>' +
'<th style="text-align:left;padding:6px 8px;border-bottom:2px solid #e5e7eb;">IP</th>' +
'<th style="text-align:left;padding:6px 8px;border-bottom:2px solid #e5e7eb;">Страна</th>' +
'<th style="text-align:left;padding:6px 8px;border-bottom:2px solid #e5e7eb;">Язык</th>' +
'<th style="text-align:left;padding:6px 8px;border-bottom:2px solid #e5e7eb;">Дата</th>' +
'<th style="text-align:left;padding:6px 8px;border-bottom:2px solid #e5e7eb;">Страница</th>' +
'</tr></thead><tbody>';
data.entries.forEach(e => {
html += `<tr>` +
`<td style="padding:4px 8px;border-bottom:1px solid #f1f5f9;font-family:monospace;">${e.ip}</td>` +
`<td style="padding:4px 8px;border-bottom:1px solid #f1f5f9;">${e.country || '-'} <span style="color:#94a3b8;font-size:0.7rem;">${e.country_code || ''}</span></td>` +
`<td style="padding:4px 8px;border-bottom:1px solid #f1f5f9;">${e.accept_language || '-'}</td>` +
`<td style="padding:4px 8px;border-bottom:1px solid #f1f5f9;white-space:nowrap;">${e.created_at ? e.created_at.substring(0, 16) : e.visit_date}</td>` +
`<td style="padding:4px 8px;border-bottom:1px solid #f1f5f9;max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:0.8rem;color:#64748b;" title="${e.page_path}">${e.page_path}</td>` +
`</tr>`;
});
html += '</tbody></table>';
document.getElementById('visIPsTable').innerHTML = html;
const totalPages = Math.ceil(data.total / limit);
const currentPage = Math.floor(offset / limit) + 1;
let paginationHtml = `<span style="color:#94a3b8;font-size:0.85rem;">Строк: ${data.total} · Стр. ${currentPage} из ${totalPages}</span> `;
if (offset > 0) {
paginationHtml += `<button onclick="loadIPs(${Math.max(0, offset - limit)})" style="padding:4px 12px;border:1px solid #e5e7eb;border-radius:6px;background:#fff;cursor:pointer;margin:0 4px;">← Назад</button>`;
}
if (offset + limit < data.total) {
paginationHtml += `<button onclick="loadIPs(${offset + limit})" style="padding:4px 12px;border:1px solid #e5e7eb;border-radius:6px;background:#fff;cursor:pointer;margin:0 4px;">Вперёд →</button>`;
}
document.getElementById('visIPsPagination').innerHTML = paginationHtml;
} catch (err) {
document.getElementById('visIPsTable').innerHTML = '<div style="text-align:center;color:#ef4444;padding:20px;">Ошибка загрузки</div>';
}
}
</script>
<!-- Room Edit Modal -->

View File

@@ -188,6 +188,66 @@ app.use((req, res, next) => {
if (req.method === 'OPTIONS') return res.sendStatus(200);
next();
});
const geoip = require('geoip-lite');
const ipCountryCache = {};
function getCountryByIp(ip) {
if (!ip || ip === '127.0.0.1' || ip === '::1' || ip.startsWith('::ffff:127.')) return null;
if (ipCountryCache[ip] !== undefined) return ipCountryCache[ip];
try {
const geo = geoip.lookup(ip);
const result = geo ? { country: geo.country, code: geo.country } : null;
ipCountryCache[ip] = result;
return result;
} catch (e) {
ipCountryCache[ip] = null;
return null;
}
}
function parseCookies(header) {
const cookies = {};
if (!header) return cookies;
header.split(';').forEach(c => {
const idx = c.indexOf('=');
if (idx > 0) cookies[c.substring(0, idx).trim()] = c.substring(idx + 1).trim();
});
return cookies;
}
app.use((req, res, next) => {
if (req.method !== 'GET' || req.path.startsWith('/api/') ||
req.path.startsWith('/css/') || req.path.startsWith('/js/') ||
req.path.startsWith('/img/') || req.path.startsWith('/data/') ||
req.path.startsWith('/uploads/') || req.path.startsWith('/webfonts/') ||
req.path === '/metrics' || req.path === '/favicon.ico') {
return next();
}
const ip = req.ip || req.connection.remoteAddress || '';
const cookies = parseCookies(req.headers.cookie);
let sessionId = cookies.visitor_sid;
if (!sessionId) {
sessionId = require('crypto').randomUUID();
res.setHeader('Set-Cookie', `visitor_sid=${sessionId}; Path=/; Max-Age=86400; SameSite=Lax`);
}
const geo = getCountryByIp(ip);
db.run(
`INSERT INTO visitor_log (ip, session_id, user_agent, accept_language, country, country_code, page_path, visit_date)
VALUES (?, ?, ?, ?, ?, ?, ?, date('now'))`,
[ip, sessionId, (req.headers['user-agent'] || '').substring(0, 500),
(req.headers['accept-language'] || '').substring(0, 200),
geo ? geo.country : null, geo ? geo.code : null, req.path],
(err) => { if (err) console.error('Visitor log error:', err.message); }
);
next();
});
app.use(express.static(path.join(__dirname, 'public')));
app.use('/uploads', express.static(path.join(__dirname, 'uploads')));
app.use('/data/room_images', express.static(roomsUploadsDir));
@@ -272,6 +332,7 @@ const syncModule = require('./modules/sync');
const reportsModule = require('./modules/reports');
const activitiesModule = require('./modules/activities');
const heroModule = require('./modules/hero');
const visitorsModule = require('./modules/visitors');
const { runStartupTests } = require('./tests/runStartupTests');
modules.auth = authModule;
@@ -291,6 +352,7 @@ modules.sync = syncModule;
modules.reports = reportsModule;
modules.activities = activitiesModule;
modules.hero = heroModule;
modules.visitors = visitorsModule;
authModule.init(db, JWT_SECRET);
bookingsModule.init(db);
@@ -308,6 +370,7 @@ syncModule.init(db, settingsModule, emailModule, telegramModule);
reportsModule.init(db);
activitiesModule.init(db);
heroModule.init(db);
visitorsModule.init(db);
function initDefaultRooms() {
db.get("SELECT COUNT(*) as count FROM rooms", (err, row) => {
@@ -382,6 +445,7 @@ telegramModule.setupRoutes(app, authModule.authenticateToken, authModule.require
reportsModule.setupRoutes(app, authModule.authenticateToken, authModule.requireAdmin);
activitiesModule.setupRoutes(app, authModule.authenticateToken, authModule.requireAdmin, uploadActivity);
heroModule.setupRoutes(app, authModule.authenticateToken, authModule.requireAdmin, uploadHeroImage, uploadHeroVideo);
visitorsModule.setupRoutes(app, authModule.authenticateToken);
app.get('/api/translations/:lang', (req, res) => {
const lang = req.params.lang;