From 2e127de15b48cf98026c3406316f96773d58c856 Mon Sep 17 00:00:00 2001 From: Andrey Flat Date: Sat, 7 Mar 2026 04:05:46 +0800 Subject: [PATCH] restore --- netlify/functions/privateviewlogger.js | 172 +++++++++++++++++++++++++ 1 file changed, 172 insertions(+) create mode 100644 netlify/functions/privateviewlogger.js diff --git a/netlify/functions/privateviewlogger.js b/netlify/functions/privateviewlogger.js new file mode 100644 index 0000000..07a9302 --- /dev/null +++ b/netlify/functions/privateviewlogger.js @@ -0,0 +1,172 @@ +// netlify/functions/privateViewLogger.js +// Требуется package.json с {"type":"module"} + +import https from "https"; +import { createClient } from "@supabase/supabase-js"; + +// node-fetch (ленивая подгрузка) +const fetch = (...args) => import("node-fetch").then(({ default: f }) => f(...args)); + +// --- ENV --- +const BOT_TOKEN = process.env.BOT_TOKEN; // опционально +const CHAT_ID = process.env.CHAT_ID; // опционально +const SECRET_KEY = process.env.SECRET_KEY; // обязателен +const SUPABASE_URL = process.env.SUPABASE_URL; // обязателен +const SUPABASE_KEY = process.env.SUPABASE_SERVICE_KEY; // обязателен + +const supabase = createClient(SUPABASE_URL, SUPABASE_KEY, { auth: { persistSession: false } }); + +// ---------- helpers ---------- +function extractIp(headers = {}) { + return ( + headers["x-nf-client-connection-ip"] || + (headers["x-forwarded-for"] || "").split(",")[0]?.trim() || + headers["client-ip"] || + headers["x-real-ip"] || + "" + ); +} + +function isPrivateIp(ip) { + if (!ip) return true; + const cleaned = ip.replace(/^\[?([^\]]+)\]?(:\d+)?$/, "$1"); + if (/^\d{1,3}(\.\d{1,3}){3}$/.test(cleaned)) { + const [a,b] = cleaned.split(".").map(Number); + if (a === 10 || a === 127) return true; + if (a === 100 && b >= 64 && b <= 127) return true; + if (a === 192 && b === 168) return true; + if (a === 172 && b >= 16 && b <= 31) return true; + return false; + } + const lc = cleaned.toLowerCase(); + if (lc === "::1" || lc.startsWith("fc") || lc.startsWith("fd")) return true; + return false; +} + +function parseUA(ua = "") { + let os = "Unknown OS", browser = "Unknown Browser"; + if (/\bWindows\b/i.test(ua)) os = "Windows"; + else if (/\bMac OS X|Macintosh\b/i.test(ua)) os = "macOS"; + else if (/\bAndroid\b/i.test(ua)) os = "Android"; + else if (/\biPhone|iPad\b/i.test(ua)) os = "iOS"; + else if (/\bX11|Linux\b/i.test(ua)) os = "Linux"; + + if (/Firefox\/\d+/i.test(ua)) browser = "Firefox"; + else if (/\bEdg\/\d+/i.test(ua)) browser = "Edge"; + else if (/Chrome\/\d+/i.test(ua) && !/Chromium/i.test(ua)) browser = "Chrome"; + else if (/Safari\/\d+/i.test(ua) && !/Chrome/i.test(ua)) browser = "Safari"; + else if (/Chromium\/\d+/i.test(ua)) browser = "Chromium"; + + return { os, browser }; +} + +async function sendTelegram(text) { + if (!BOT_TOKEN || !CHAT_ID) return { ok: true }; + const data = JSON.stringify({ chat_id: CHAT_ID, text, parse_mode: "Markdown" }); + + return new Promise((resolve) => { + const req = https.request( + { + hostname: "api.telegram.org", + path: `/bot${BOT_TOKEN}/sendMessage`, + method: "POST", + headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(data) }, + }, + (res) => { + res.on("data", () => {}); + res.on("end", () => resolve({ ok: res.statusCode === 200, status: res.statusCode })); + } + ); + req.on("error", () => resolve({ ok: false })); + req.write(data); + req.end(); + }); +} + +// ---------- handler ---------- +export async function handler(event) { + // доступ: ключ ИЛИ тот же домен; блочим TelegramBot UA + const qs = event.queryStringParameters || {}; + const { key } = qs; + + const headers = event.headers || {}; + const ua = headers["user-agent"] || ""; + const referer = (headers["referer"] || "").toLowerCase(); + + const sameOrigin = referer.includes("andreyflat.space"); + const keyOk = key && SECRET_KEY && key === SECRET_KEY; + + if (ua.includes("TelegramBot") || (!keyOk && !sameOrigin)) { + return { + statusCode: 403, + headers: { "Cache-Control": "no-store, no-cache, must-revalidate, max-age=0" }, + body: JSON.stringify({ error: "Forbidden" }), + }; + } + + // --- извлекаем данные для статистики --- + const ipRaw = extractIp(headers) || "unknown"; // используем ТОЛЬКО для гео, НЕ пишем в БД + const { os, browser } = parseUA(ua); + + // 1) сначала пробуем заголовки Netlify + let country = headers["x-country"] || null; // ISO-2, например "ID" + let city = null; + try { + if (headers["x-nf-geo"]) { + const g = JSON.parse(headers["x-nf-geo"]); + if (g?.country?.name || g?.country?.code) country = g.country.name || g.country.code || country; + if (g?.city) city = g.city; + } + } catch {} + + // 2) если города нет и IP публичный — мягко добираем через ipapi.co (таймаут 3с) + if (!city && ipRaw !== "unknown" && !isPrivateIp(ipRaw)) { + try { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), 3000); + const r = await fetch(`https://ipapi.co/${ipRaw}/json/`, { signal: ctrl.signal }); + clearTimeout(t); + if (r.ok) { + const j = await r.json(); + city = j.city || city; + country = j.country_name || j.country || country; + } + } catch { + // игнор + } + } + + // --- запись в БД (без IP и без path) --- + const insertPayload = { + referrer: headers["referer"] || null, + country: country || null, + city: city || null, + ua_os: os, + ua_browser: browser, + }; + + const { error } = await supabase.from("pageviews").insert([insertPayload]); + + // --- уведомление в Телеграм (опционально) --- + const loc = country ? (city ? `${country}, ${city}` : country) : "Unknown"; + const text = +`🔔 *New View* +📍 ${loc} +📱 ${os} / ${browser}`; + + if (!error) await sendTelegram(text); + + if (error) { + return { + statusCode: 500, + headers: { "Cache-Control": "no-store, no-cache, must-revalidate, max-age=0" }, + body: JSON.stringify({ error: error.message }), + }; + } + + return { + statusCode: 200, + headers: { "Cache-Control": "no-store, no-cache, must-revalidate, max-age=0" }, + body: JSON.stringify({ ok: true }), + }; +}