rust gateway for cms and comments filter

This commit is contained in:
2026-04-24 03:33:53 +07:00
parent e8b9eb5c52
commit 84f58220d2
14 changed files with 8326 additions and 104 deletions
+2 -2
View File
@@ -54,9 +54,9 @@ export default async function RootLayout({
> >
<Header dict={dict.nav} lang={lang} /> <Header dict={dict.nav} lang={lang} />
<main className="flex-1 mt-32">{children}</main> {/* УБРАЛИ mt-32 ТУТ */}
<main className="flex-1">{children}</main>
{/* Добавляем футер и передаем нужную часть словаря */}
<Footer dict={dict.footer} lang={lang} /> <Footer dict={dict.footer} lang={lang} />
</ThemeProvider> </ThemeProvider>
</body> </body>
+25
View File
@@ -177,3 +177,28 @@
.animate-scan { .animate-scan {
animation: scan 3s cubic-bezier(0.4, 0, 0.6, 1) infinite; animation: scan 3s cubic-bezier(0.4, 0, 0.6, 1) infinite;
} }
@layer utilities {
.custom-scrollbar-retro::-webkit-scrollbar {
width: 16px;
}
.custom-scrollbar-retro::-webkit-scrollbar-track {
background: #dfdfdf;
box-shadow:
inset 1px 1px #fff,
inset -1px -1px #808080;
}
.custom-scrollbar-retro::-webkit-scrollbar-thumb {
background: #c0c0c0;
border: 2px solid #dfdfdf;
box-shadow:
1px 1px #808080,
-1px -1px #fff;
}
.dark .custom-scrollbar-retro::-webkit-scrollbar-track {
background: #1a1a1a;
}
.dark .custom-scrollbar-retro::-webkit-scrollbar-thumb {
background: #333;
}
}
+178 -72
View File
@@ -1,10 +1,13 @@
"use client"; "use client";
import { useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { LanguageSwitcher } from "@/components/sections/lang/language-switcher"; import { LanguageSwitcher } from "@/components/sections/lang/language-switcher";
import { ThemeToggle } from "@/components/sections/theme/theme-toggle"; import { ThemeToggle } from "@/components/sections/theme/theme-toggle";
import { Dictionary } from "@/lib/IDict"; import { Dictionary } from "@/lib/IDict";
import { CyberButton } from "../ui/cyber-button"; import { CyberButton } from "../ui/cyber-button";
import { MenuSquare, X, Terminal, FolderOpen, ArrowRight } from "lucide-react";
import { motion, AnimatePresence } from "framer-motion";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
export function Header({ export function Header({
@@ -14,83 +17,186 @@ export function Header({
dict: Dictionary["nav"]; dict: Dictionary["nav"];
lang: string; lang: string;
}) { }) {
const [isMenuOpen, setIsMenuOpen] = useState(false);
// Ссылки для мобильного меню (расширенный список, как в футере)
const mobileLinks = [
{ name: dict.features, href: `/${lang}#features`, icon: Terminal },
{ name: dict.pricing, href: `/${lang}#pricing`, icon: FolderOpen },
{ name: dict.faq, href: `/${lang}#faq`, icon: FolderOpen },
{ name: dict.blog, href: `/${lang}/blog`, icon: FolderOpen },
];
return ( return (
<header className="fixed top-4 md:top-6 left-1/2 -translate-x-1/2 w-[95%] max-w-5xl z-50 bg-card/60 dark:bg-card/40 backdrop-blur-2xl border border-border/50 rounded-full shadow-lg transition-all duration-500 hover:bg-card/80 dark:hover:bg-card/60"> <>
<div className="px-4 md:px-8 h-14 md:h-16 flex items-center justify-between"> <header className="fixed top-4 md:top-6 left-1/2 -translate-x-1/2 w-[95%] max-w-5xl z-50 bg-card/30 dark:bg-card/20 backdrop-blur-xl border border-border/50 rounded-full shadow-lg transition-all duration-500 hover:bg-card/50 dark:hover:bg-card/40">
{/* ЛЕВЫЙ БЛОК: Адаптивный Логотип */} <div className="px-4 md:px-8 h-14 md:h-16 flex items-center justify-between">
<Link {/* ЛЕВЫЙ БЛОК: Адаптивный Логотип */}
href={`/${lang}`} <Link
className="flex items-center gap-2 md:gap-3 group hover:scale-105 transition-transform duration-300 w-fit" href={`/${lang}`}
> className="flex items-center gap-2 md:gap-3 group hover:scale-105 transition-transform duration-300 w-fit relative z-50"
<div className="text-lg sm:text-2xl md:text-3xl tracking-wide md:tracking-widest flex items-center select-none font-bold uppercase"> onClick={() => setIsMenuOpen(false)}
<span >
className="text-transparent animate-neon-flicker transition-all duration-300" <div className="text-lg sm:text-2xl md:text-3xl tracking-wide md:tracking-widest flex items-center select-none font-bold uppercase">
style={{ <span
WebkitTextStroke: "1px #00E5F2", className="text-transparent animate-neon-flicker transition-all duration-300"
filter: "drop-shadow(0 0 5px rgba(0,229,242,0.6))", style={{
}} WebkitTextStroke: "1px #00E5F2",
filter: "drop-shadow(0 0 5px rgba(0,229,242,0.6))",
}}
>
Net
</span>
<span
className="text-transparent transition-all duration-300"
style={{
WebkitTextStroke: "1px #00E5F2",
filter: "drop-shadow(0 0 5px rgba(0,229,242,0.6))",
}}
>
runner
</span>
<span
className="ml-1.5 md:ml-2 text-transparent opacity-90 group-hover:opacity-100 transition-all duration-300"
style={{
WebkitTextStroke: "1px #8B3DFF",
filter: "drop-shadow(0 0 5px rgba(139,61,255,0.6))",
}}
>
VPN
</span>
</div>
</Link>
{/* ЦЕНТРАЛЬНЫЙ БЛОК: Навигация (скрыта на мобилках) */}
<nav className="hidden md:flex items-center gap-8 text-sm font-bold text-muted-foreground">
<Link
href={`/${lang}#features`}
className="hover:text-primary transition-colors duration-200"
> >
Net {dict.features}
</span> </Link>
<span <Link
className="text-transparent transition-all duration-300" href={`/${lang}#pricing`}
style={{ className="hover:text-primary transition-colors duration-200"
WebkitTextStroke: "1px #00E5F2",
filter: "drop-shadow(0 0 5px rgba(0,229,242,0.6))",
}}
> >
runner {dict.pricing}
</span> </Link>
<span <Link
className="ml-1.5 md:ml-2 text-transparent opacity-90 group-hover:opacity-100 transition-all duration-300" href={`/${lang}#faq`}
style={{ className="hover:text-primary transition-colors duration-200"
WebkitTextStroke: "1px #8B3DFF",
filter: "drop-shadow(0 0 5px rgba(139,61,255,0.6))",
}}
> >
VPN {dict.faq}
</span> </Link>
</nav>
{/* ПРАВЫЙ БЛОК: Контролы */}
<div className="flex items-center gap-1.5 md:gap-4 relative z-50">
<div className="flex items-center gap-1 md:gap-2">
<LanguageSwitcher />
<ThemeToggle />
</div>
<CyberButton
href={`/${lang}/download`}
variant="primary"
className="hidden sm:block scale-75 md:scale-90 origin-right"
>
{dict.cta}
</CyberButton>
{/* КНОПКА МОБИЛЬНОГО МЕНЮ */}
<button
onClick={() => setIsMenuOpen(!isMenuOpen)}
className="sm:hidden p-2 rounded-xl bg-background/50 border border-border text-foreground hover:bg-muted transition-colors"
aria-label="Toggle System Menu"
>
{isMenuOpen ? (
<X className="size-5" />
) : (
<MenuSquare className="size-5" />
)}
</button>
</div> </div>
</Link>
{/* ЦЕНТРАЛЬНЫЙ БЛОК: Навигация (скрыта на мобилках) */}
<nav className="hidden md:flex items-center gap-8 text-sm font-bold text-muted-foreground">
<Link
href={`/${lang}#features`}
className="hover:text-primary transition-colors duration-200"
>
{dict.features}
</Link>
<Link
href={`/${lang}#pricing`}
className="hover:text-primary transition-colors duration-200"
>
{dict.pricing}
</Link>
<Link
href={`/${lang}#faq`}
className="hover:text-primary transition-colors duration-200"
>
{dict.faq}
</Link>
</nav>
{/* ПРАВЫЙ БЛОК: Контролы */}
<div className="flex items-center gap-1.5 md:gap-4">
<div className="flex items-center gap-1 md:gap-2">
<LanguageSwitcher />
<ThemeToggle />
</div>
<CyberButton
href={`/${lang}/download`}
variant="primary"
className="hidden sm:block scale-90 md:scale-100 origin-right"
>
{dict.cta}
</CyberButton>
</div> </div>
</div> </header>
</header>
{/* ВСПЛЫВАЮЩЕЕ ОКНО (Мобильное меню в стиле ретро-ОС) */}
<AnimatePresence>
{isMenuOpen && (
<motion.div
initial={{ opacity: 0, y: -20, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -20, scale: 0.95 }}
transition={{ type: "spring", stiffness: 300, damping: 25 }}
className="fixed top-24 left-4 right-4 z-40 sm:hidden origin-top"
>
{/* Окно-папка */}
<div className="bg-[#E5E5E5] dark:bg-[#1E1E1E] border-2 border-t-white border-l-white border-b-gray-500 border-r-gray-500 dark:border-t-gray-600 dark:border-l-gray-600 dark:border-b-black dark:border-r-black p-1 shadow-[4px_4px_0_0_rgba(0,0,0,0.5)]">
{/* Заголовок окна (Title bar) */}
<div className="bg-blue-800 text-white px-2 py-1 flex items-center justify-between mb-2 font-mono text-xs font-bold tracking-wider">
<span className="flex items-center gap-2">
<Terminal className="size-3" /> C:\NETRUNNER\MENU.EXE
</span>
</div>
{/* Содержимое папки (Ссылки) */}
<div className="bg-white dark:bg-black border-2 border-inset border-t-gray-500 border-l-gray-500 border-b-white border-r-white dark:border-t-black dark:border-l-black dark:border-b-gray-700 dark:border-r-gray-700 p-2 font-mono flex flex-col gap-1 max-h-[70vh] overflow-y-auto custom-scrollbar-retro">
{mobileLinks.map((link) => (
<Link
key={link.name}
href={link.href}
onClick={() => setIsMenuOpen(false)}
className="flex items-center gap-3 p-3 hover:bg-blue-800 hover:text-white dark:hover:bg-primary/20 dark:hover:text-primary transition-colors group cursor-pointer text-sm text-black dark:text-white shrink-0"
>
<link.icon className="size-5 text-gray-500 group-hover:text-white dark:group-hover:text-primary transition-colors fill-yellow-200 dark:fill-transparent" />
<span className="flex-1">{link.name}</span>
<span className="opacity-0 group-hover:opacity-100 transition-opacity">
_
</span>
</Link>
))}
{/* Разделитель */}
<div className="my-2 border-t border-dashed border-gray-400 dark:border-gray-700 shrink-0" />
{/* Доп ссылки из футера */}
<Link
href={`/${lang}/docs`}
onClick={() => setIsMenuOpen(false)}
className="flex items-center gap-3 p-3 hover:bg-blue-800 hover:text-white dark:hover:bg-primary/20 dark:hover:text-primary transition-colors group cursor-pointer text-xs text-gray-600 dark:text-gray-400 shrink-0"
>
<Terminal className="size-4" />
<span className="flex-1 uppercase tracking-widest">
Documentation
</span>
</Link>
{/* Кнопка теперь идет СРАЗУ за списком, а не прибита к низу h-[60vh] */}
<div className="pt-4 pb-2 px-2 shrink-0">
<CyberButton
href={`/${lang}/download`}
variant="primary"
className="w-full text-xs"
>
<span className="flex items-center justify-center gap-2">
{dict.cta} <ArrowRight className="size-3" />
</span>
</CyberButton>
</div>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
{/* Оверлей для закрытия меню по клику вне его */}
{isMenuOpen && (
<div
className="fixed inset-0 z-30 sm:hidden bg-black/20 backdrop-blur-sm"
onClick={() => setIsMenuOpen(false)}
/>
)}
</>
); );
} }
+8 -2
View File
@@ -178,15 +178,21 @@ export function Hero({
return ( return (
<section className="relative pt-32 pb-20 md:pt-48 md:pb-32 flex flex-col items-center text-center px-4 min-h-[95vh] justify-center overflow-hidden"> <section className="relative pt-32 pb-20 md:pt-48 md:pb-32 flex flex-col items-center text-center px-4 min-h-[95vh] justify-center overflow-hidden">
{/* КОНТЕЙНЕР ФОНА: Теперь он inset-0, без верхних границ */}
<div className="absolute inset-0 -z-10 bg-background pointer-events-none"> <div className="absolute inset-0 -z-10 bg-background pointer-events-none">
<MatrixBackground isSecure={isVpnOn} theme={resolvedTheme} /> <MatrixBackground isSecure={isVpnOn} theme={resolvedTheme} />
{/* ВИНЬЕТКА: Сделаем ее погуще сверху, чтобы хедер читался идеально */}
<div className="absolute inset-0 bg-[radial-gradient(circle_at_center,_transparent_10%,_var(--background)_110%)] opacity-80" />
{/* Дополнительный мягкий градиент сверху для "воздуха" */}
<div className="absolute inset-x-0 top-0 h-40 bg-gradient-to-b from-background via-transparent to-transparent opacity-90" />
</div> </div>
<div className="absolute inset-0 bg-[radial-gradient(circle_at_center,_transparent_20%,_var(--background)_100%)] -z-10 opacity-70" />
<motion.div <motion.div
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }} transition={{ duration: 0.5 }}
className="relative z-10 w-full max-w-4xl mx-auto" className="relative z-10 w-full max-w-4xl mx-auto pt-40 md:pt-48 pb-20"
> >
{/* ИНВЕРСИЯ БЕЙДЖА */} {/* ИНВЕРСИЯ БЕЙДЖА */}
<motion.div <motion.div
+15 -24
View File
@@ -10,13 +10,12 @@ export default function MatrixBackground({
theme?: string; theme?: string;
}) { }) {
const canvasRef = useRef<HTMLCanvasElement>(null); const canvasRef = useRef<HTMLCanvasElement>(null);
// Используем Ref вместо State для позиции мыши, чтобы не перерисовывать компонент
const mousePos = useRef({ x: -1000, y: -1000 }); const mousePos = useRef({ x: -1000, y: -1000 });
useEffect(() => { useEffect(() => {
const canvas = canvasRef.current; const canvas = canvasRef.current;
if (!canvas) return; if (!canvas) return;
const ctx = canvas.getContext("2d", { alpha: false }); // Оптимизация производительности const ctx = canvas.getContext("2d", { alpha: false });
if (!ctx) return; if (!ctx) return;
let width = (canvas.width = window.innerWidth); let width = (canvas.width = window.innerWidth);
@@ -26,6 +25,7 @@ export default function MatrixBackground({
const columns = Math.floor(width / fontSize); const columns = Math.floor(width / fontSize);
const drops = Array(columns).fill(1); const drops = Array(columns).fill(1);
// Хакерский набор символов
const chars = "01ヲアウエオカキケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワン".split(""); const chars = "01ヲアウエオカキケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワン".split("");
const isDark = theme === "dark"; const isDark = theme === "dark";
@@ -41,11 +41,6 @@ export default function MatrixBackground({
const handleResize = () => { const handleResize = () => {
width = canvas.width = window.innerWidth; width = canvas.width = window.innerWidth;
height = canvas.height = window.innerHeight; height = canvas.height = window.innerHeight;
// При ресайзе пересчитываем колонки без сброса анимации
const newCols = Math.floor(width / fontSize);
if (newCols > drops.length) {
for (let i = drops.length; i < newCols; i++) drops[i] = 1;
}
}; };
window.addEventListener("mousemove", handleMouseMove); window.addEventListener("mousemove", handleMouseMove);
@@ -58,33 +53,30 @@ export default function MatrixBackground({
if (deltaTime < interval) return; if (deltaTime < interval) return;
lastTime = currentTime - (deltaTime % interval); lastTime = currentTime - (deltaTime % interval);
// Заливка фона (создает шлейф) // Рисуем шлейф: чем меньше alpha, тем длиннее "хвосты" у цифр
// В светлой теме прозрачность шлейфа должна быть выше, чтобы буквы не исчезали мгновенно
ctx.fillStyle = isDark ctx.fillStyle = isDark
? "rgba(11, 13, 23, 0.15)" ? "rgba(11, 13, 23, 0.25)"
: "rgba(248, 250, 252, 0.25)"; : "rgba(248, 250, 252, 0.30)";
ctx.fillRect(0, 0, width, height); ctx.fillRect(0, 0, width, height);
ctx.font = `bold ${fontSize}px monospace`; ctx.font = `${fontSize}px monospace`;
ctx.shadowBlur = 0; // Сбрасываем тени для основных букв (оптимизация)
for (let i = 0; i < drops.length; i++) { for (let i = 0; i < drops.length; i++) {
const x = i * fontSize; const x = i * fontSize;
const y = drops[i] * fontSize; const y = drops[i] * fontSize;
// Расчет яркости в зависимости от курсора
const dx = x - mousePos.current.x; const dx = x - mousePos.current.x;
const dy = y - mousePos.current.y; const dy = y - mousePos.current.y;
const distance = Math.sqrt(dx * dx + dy * dy); const distance = Math.sqrt(dx * dx + dy * dy);
const radius = 300; // Радиус "взгляда" const radius = 350; // Радиус подсветки за курсором
// Плавное затухание: у курсора ярко, вдали - почти не видно
const highlight = Math.max(0, 1 - distance / radius); const highlight = Math.max(0, 1 - distance / radius);
// Подбираем альфу так, чтобы буквы было видно даже без мыши, но очень тускло // Базовая яркость очень низкая (0.05), чтобы фон не бил в глаза
const baseOpacity = isDark ? 0.08 : 0.15; const baseOpacity = isDark ? 0.15 : 0.2;
const finalOpacity = baseOpacity + highlight * 0.5; const finalOpacity = baseOpacity + highlight * 0.6;
// Основной цвет
if (isSecure) { if (isSecure) {
ctx.fillStyle = isDark ctx.fillStyle = isDark
? `rgba(0, 240, 255, ${finalOpacity})` ? `rgba(0, 240, 255, ${finalOpacity})`
@@ -95,8 +87,8 @@ export default function MatrixBackground({
: `rgba(109, 40, 217, ${finalOpacity})`; : `rgba(109, 40, 217, ${finalOpacity})`;
} }
// Добавляем небольшой Bloom эффект только самым ярким символам у курсора // Мягкое свечение только там, где "взгляд" (highlight)
if (isDark && highlight > 0.7) { if (isDark && highlight > 0.8) {
ctx.shadowBlur = 10; ctx.shadowBlur = 10;
ctx.shadowColor = isSecure ? "#00F0FF" : "#8B3DFF"; ctx.shadowColor = isSecure ? "#00F0FF" : "#8B3DFF";
} else { } else {
@@ -106,8 +98,7 @@ export default function MatrixBackground({
const char = chars[Math.floor(Math.random() * chars.length)]; const char = chars[Math.floor(Math.random() * chars.length)];
ctx.fillText(char, x, y); ctx.fillText(char, x, y);
// Скорость падения и рандомный сброс if (y > height && Math.random() > 0.985) {
if (y > height && Math.random() > 0.98) {
drops[i] = 0; drops[i] = 0;
} }
drops[i]++; drops[i]++;
@@ -121,7 +112,7 @@ export default function MatrixBackground({
window.removeEventListener("mousemove", handleMouseMove); window.removeEventListener("mousemove", handleMouseMove);
window.removeEventListener("resize", handleResize); window.removeEventListener("resize", handleResize);
}; };
}, [isSecure, theme]); // mousePos удален из зависимостей }, [isSecure, theme]);
return ( return (
<canvas <canvas
+22 -3
View File
@@ -32,6 +32,7 @@ export function TerminalGuestbook({ dict }: { dict: Dictionary["guestbook"] }) {
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
useEffect(() => { useEffect(() => {
pb.autoCancellation(false);
// Начальная загрузка // Начальная загрузка
const fetchComments = async () => { const fetchComments = async () => {
try { try {
@@ -86,9 +87,27 @@ export function TerminalGuestbook({ dict }: { dict: Dictionary["guestbook"] }) {
try { try {
setIsSubmitting(true); setIsSubmitting(true);
await pb
.collection("comments") const proxyUrl =
.create({ nickname: nickname.trim(), message: message.trim() }); process.env.NEXT_PUBLIC_PROXY_URL || "http://localhost:3001";
const res = await fetch(`${proxyUrl}/api/comments`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
nickname: nickname.trim(),
message: message.trim(),
}),
});
if (!res.ok) {
// Если Rust вернул 406 Not Acceptable (грязь в тексте)
if (res.status === 406) {
throw new Error(dict.statusErrorBreach); // или кастомная ошибка "Мат запрещен"
}
throw new Error("Server error");
}
setNickname(""); setNickname("");
setMessage(""); setMessage("");
setStatus({ type: "success", text: dict.statusSuccess }); setStatus({ type: "success", text: dict.statusSuccess });
+14 -1
View File
@@ -8,12 +8,13 @@ services:
environment: environment:
- NODE_ENV=production - NODE_ENV=production
- NEXT_PUBLIC_PB_URL=https://netrunner-vpn.com/cms-api - NEXT_PUBLIC_PB_URL=https://netrunner-vpn.com/cms-api
# URL прокси для продакшена (если фронт стучится напрямую, или через nginx)
- NEXT_PUBLIC_PROXY_URL=https://netrunner-vpn.com/api/proxy
netrunner-cms: netrunner-cms:
image: ghcr.io/muchobien/pocketbase:latest image: ghcr.io/muchobien/pocketbase:latest
container_name: netrunner-cms container_name: netrunner-cms
restart: unless-stopped restart: unless-stopped
# Указываем только аргументы, так как путь к бинарнику уже в ENTRYPOINT
command: command:
- serve - serve
- --http=0.0.0.0:8090 - --http=0.0.0.0:8090
@@ -22,3 +23,15 @@ services:
- "127.0.0.1:8090:8090" - "127.0.0.1:8090:8090"
volumes: volumes:
- ./pb_data:/pb_data - ./pb_data:/pb_data
ws-proxy:
build: ./proxy-ws
container_name: netrunner-ws-proxy
restart: always
ports:
- "127.0.0.1:3001:3001"
environment:
# Важно: прокси стучится в CMS по ВНУТРЕННЕМУ имени докера
- PB_INTERNAL_URL=http://netrunner-cms:8090
depends_on:
- netrunner-cms
+9
View File
@@ -15,6 +15,15 @@ if (isServer) {
pb.autoCancellation(false); pb.autoCancellation(false);
} }
if (typeof window !== "undefined") {
// Фильтруем ошибки абортов (CANCELLED), которые генерит PocketBase
const originalError = console.error;
console.error = (...args) => {
if (args[0]?.message?.includes("The request was autocancelled")) return;
originalError.apply(console, args);
};
}
/** /**
* Базовый интерфейс для записей PocketBase. * Базовый интерфейс для записей PocketBase.
* Наследуемся от RecordModel, чтобы иметь системные поля (id, created, updated, и т.д.) * Наследуемся от RecordModel, чтобы иметь системные поля (id, created, updated, и т.д.)
+1
View File
@@ -0,0 +1 @@
/target
+1927
View File
File diff suppressed because it is too large Load Diff
+14
View File
@@ -0,0 +1,14 @@
[package]
name = "proxy-ws"
version = "0.1.0"
edition = "2024"
[dependencies]
axum = "0.7"
tokio = { version = "1", features = ["full"] }
reqwest = { version = "0.11", features = ["json"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
rustrict = "0.7"
regex = "1.10"
unicode-segmentation = "1.10" # Для корректной работы с юникодом (эмодзи и т.д.)
+32
View File
@@ -0,0 +1,32 @@
# proxy-ws/Dockerfile
# Stage 1: Build
FROM rust:1.80-alpine AS builder
WORKDIR /app
# Установка зависимостей для сборки под Alpine (musl)
RUN apk add --no-cache musl-dev
# Кэшируем зависимости (создаем пустой проект)
COPY Cargo.toml ./
RUN mkdir src && echo "fn main() {}" > src/main.rs
RUN cargo build --release
RUN rm -rf src
# Копируем реальный код и собираем
COPY src ./src
# Обновляем время файла, чтобы cargo точно пересобрал
RUN touch src/main.rs
RUN cargo build --release
# Stage 2: Runtime
FROM alpine:latest
WORKDIR /app
# Копируем бинарник из первой стадии
COPY --from=builder /app/target/release/proxy-ws ./proxy-ws
# Прокси будет слушать порт 3001
EXPOSE 3001
CMD ["./proxy-ws"]
+159
View File
@@ -0,0 +1,159 @@
use axum::{Json, Router, http::StatusCode, response::IntoResponse, routing::post};
use regex::Regex;
use reqwest::Client;
use rustrict::CensorStr;
use serde::{Deserialize, Serialize};
use std::sync::LazyLock;
use unicode_segmentation::UnicodeSegmentation;
#[derive(Deserialize, Serialize, Debug)]
struct CommentPayload {
nickname: String,
message: String,
}
// Расширенный список КОРНЕЙ. Регулярка будет искать эти корни внутри слов.
// Используем \p{L}* (любые буквы) вокруг корня, чтобы ловить склонения.
static BAD_WORDS_REGEX: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?i)(хуй|хуя|пизд|ебат|ебан|бляд|блят|шлюх|сука|сучк|порно|наркот|убить|насилие|heroin|meth|murder|gore|соли|закладк|мефедрон|гашиш)").unwrap()
});
// Функция жесткой нормализации текста перед проверкой
fn normalize_for_filter(input: &str) -> String {
let mut normalized = String::new();
let mut prev_char: Option<char> = None;
// 1. Приводим к нижнему регистру
let lower_input = input.to_lowercase();
// 2. Идем по символам (с учетом юникода)
for g in lower_input.graphemes(true) {
let mut ch = g.chars().next().unwrap_or(' ');
// Пропускаем символы, которые не являются буквами или цифрами (запятые, точки, пробелы)
if !ch.is_alphanumeric() {
continue;
}
// 3. Замена "leetspeak" и похожих английских букв на русские (базовая защита)
ch = match ch {
'a' | '@' => 'а',
'b' => 'в',
'c' => 'с',
'e' | '3' => 'е',
'o' | '0' => 'о',
'p' => 'р',
'x' => 'х',
'y' => 'у',
'm' => 'м',
't' => 'т',
'k' => 'к',
_ => ch,
};
// 4. Сжатие повторяющихся символов (хххуууууууй -> хуй)
if let Some(p) = prev_char {
if p == ch {
continue; // Пропускаем дубликат
}
}
normalized.push(ch);
prev_char = Some(ch);
}
normalized
}
// Функция проверки на спам (КАПС или слишком длинные слова)
fn is_spam(input: &str) -> bool {
let alpha_chars: String = input.chars().filter(|c| c.is_alphabetic()).collect();
if alpha_chars.is_empty() {
return false;
}
// Считаем процент заглавных букв
let uppercase_count = alpha_chars.chars().filter(|c| c.is_uppercase()).count();
let uppercase_ratio = uppercase_count as f64 / alpha_chars.len() as f64;
// Если больше 70% текста - КАПС (при длине текста больше 10 символов)
if input.len() > 10 && uppercase_ratio > 0.7 {
return true;
}
// Проверка на слишком длинные "слова" без пробелов (защита от aaaaaaaaaaaaaa)
for word in input.split_whitespace() {
if word.len() > 35 {
// Нормальных слов длиннее 35 символов почти нет
return true;
}
}
false
}
#[tokio::main]
async fn main() {
let app = Router::new().route("/api/comments", post(create_comment));
println!("🚀 Netrunner Gateway started on 0.0.0.0:3001");
let listener = tokio::net::TcpListener::bind("0.0.0.0:3001").await.unwrap();
axum::serve(listener, app).await.unwrap();
}
async fn create_comment(Json(payload): Json<CommentPayload>) -> impl IntoResponse {
// --- 1. Проверка на спам (КАПС, длинные слова) ---
if is_spam(&payload.message) || is_spam(&payload.nickname) {
println!("🗑️ Blocked by SPAM filter: {}", payload.nickname);
return (
StatusCode::NOT_ACCEPTABLE,
"Transmission rejected: Spam pattern detected.",
);
}
// --- 2. Английский фильтр (Crate rustrict) ---
if payload.message.is_inappropriate() || payload.nickname.is_inappropriate() {
println!("🛡️ Blocked by rustrict: {}", payload.nickname);
return (
StatusCode::NOT_ACCEPTABLE,
"Transmission rejected: Policy violation.",
);
}
// --- 3. Русский хардкорный фильтр ---
let normalized_msg = normalize_for_filter(&payload.message);
let normalized_nick = normalize_for_filter(&payload.nickname);
if BAD_WORDS_REGEX.is_match(&normalized_msg) || BAD_WORDS_REGEX.is_match(&normalized_nick) {
println!(
"🛡️ Blocked by Deep Regex: [Original: {}] -> [Normalized: {}]",
payload.message, normalized_msg
);
return (
StatusCode::NOT_ACCEPTABLE,
"Transmission rejected: Policy violation.",
);
}
// --- 4. ОТПРАВКА В POCKETBASE ---
let pb_base_url =
std::env::var("PB_INTERNAL_URL").unwrap_or_else(|_| "http://localhost:8090".to_string());
let pb_url = format!("{}/api/collections/comments/records", pb_base_url);
let client = Client::new();
let pb_response = client.post(pb_url).json(&payload).send().await;
match pb_response {
Ok(res) if res.status().is_success() => {
(StatusCode::OK, "Transmission secured and delivered.")
}
Ok(res) => {
println!("⚠️ PocketBase rejected: {:?}", res.status());
(StatusCode::BAD_REQUEST, "Database rejected transmission.")
}
Err(e) => {
println!("💥 PB Connection error: {}", e);
(StatusCode::INTERNAL_SERVER_ERROR, "Uplink offline.")
}
}
}
+5920
View File
File diff suppressed because it is too large Load Diff