flatspace/netlify/functions/privateviewlogger.js
2026-03-07 04:05:46 +08:00

172 lines
5.9 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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 }),
};
}