899c54e1fc
Auth: drop localStorage session token entirely, talk to netrunner-backend via credentials:"include" HttpOnly cookies shared over the common parent domain, and redirect to the account subdomain (ЛК) after login instead of a dead local /profile route. Config: NEXT_PUBLIC_* values used by client components (API_URL, ACCOUNT_ORIGIN, BOT_USERNAME, PB_URL) get inlined into the JS bundle at image build time and can't be overridden by docker-compose environment at container start. Moved these reads into server components (page.tsx) and thread them down as props, so one built image can genuinely serve both prod and dev by config alone. CI/CD: split the old single auto-deploy-to-prod workflow into build.yml (build+push on every push to main) and deploy.yml with deploy-dev (auto, same VPS as netrunner-backend's dev environment) and deploy-prod (manual dispatch only) — mirrors netrunner-backend's pipeline shape. Added docker-compose.dev-remote.yml pointing at the dev backend. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
373 lines
16 KiB
TypeScript
373 lines
16 KiB
TypeScript
"use client";
|
||
|
||
import { useState, useEffect, useRef } from "react";
|
||
import { motion, AnimatePresence } from "framer-motion";
|
||
import {
|
||
Terminal,
|
||
Send,
|
||
ShieldAlert,
|
||
CheckCircle2,
|
||
User,
|
||
MessageSquare,
|
||
Zap,
|
||
} from "lucide-react";
|
||
import { pb } from "@/lib/pb";
|
||
import { Dictionary } from "@/lib/IDict";
|
||
import { CyberButton } from "../ui/cyber-button";
|
||
import { cn } from "@/lib/utils";
|
||
|
||
/**
|
||
* Встроенная в главную страницу гостевая книга ("Terminal Guestbook").
|
||
* Данные читаются напрямую из PocketBase (коллекция `comments`) с realtime-подпиской,
|
||
* а отправка идёт не напрямую в PocketBase, а через Rust-шлюз `/api/proxy/api/comments`
|
||
* (см. next.config.ts rewrites) — там сообщение проходит модерацию (в т.ч. ИИ-фильтр
|
||
* на мат/спам/токсичность) прежде чем попасть в базу.
|
||
*
|
||
* Защита от спама:
|
||
* - honeypot-поле "website" — невидимое обычным пользователям поле; если оно заполнено,
|
||
* значит форму заполнил бот, и мы тихо отклоняем отправку;
|
||
* - клиентский троттлинг через localStorage (last_comment_time) — блокирует повторную
|
||
* отправку в течение 60 секунд ещё до запроса на сервер;
|
||
* - сервер (Rust) отдельно тоже может отклонить сообщение через 406 Not Acceptable —
|
||
* это отдельный кейс от обычных сетевых ошибок, обрабатываем его конкретным текстом
|
||
* про нарушение политики, а не общим "сервер недоступен".
|
||
*/
|
||
export function TerminalGuestbook({
|
||
dict,
|
||
pbUrl,
|
||
}: {
|
||
dict: Dictionary["guestbook"];
|
||
/**
|
||
* Адрес PocketBase — приходит пропсом из серверного page.tsx, а не читается
|
||
* напрямую из process.env.NEXT_PUBLIC_* здесь (см. комментарий в
|
||
* app/[lang]/auth/auth-client.tsx для полного объяснения).
|
||
*/
|
||
pbUrl: string;
|
||
}) {
|
||
const [comments, setComments] = useState<any[]>([]);
|
||
const [nickname, setNickname] = useState("");
|
||
const [message, setMessage] = useState("");
|
||
const [honeypot, setHoneypot] = useState("");
|
||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||
const [cooldown, setCooldown] = useState(0);
|
||
const [status, setStatus] = useState<{
|
||
type: "success" | "error" | null;
|
||
text: string;
|
||
}>({ type: null, text: "" });
|
||
|
||
const scrollRef = useRef<HTMLDivElement>(null);
|
||
|
||
useEffect(() => {
|
||
// Отключаем автоотмену и в клиентском экземпляре: без этого PocketBase SDK
|
||
// отменяет параллельные одинаковые запросы (getList вызывается и здесь, и в
|
||
// терминальной странице /terminal), что приводит к ложным AbortError.
|
||
pb.autoCancellation(false);
|
||
pb.baseURL = pbUrl;
|
||
// Начальная загрузка
|
||
const fetchComments = async () => {
|
||
try {
|
||
const records = await pb
|
||
.collection("comments")
|
||
.getList(1, 50, { sort: "-created", requestKey: null });
|
||
setComments(records.items);
|
||
} catch (e) {
|
||
console.error(e);
|
||
}
|
||
};
|
||
fetchComments();
|
||
|
||
// Кулдаун
|
||
const lastPost = localStorage.getItem("last_comment_time");
|
||
if (lastPost) {
|
||
const timePassed = Math.floor((Date.now() - parseInt(lastPost)) / 1000);
|
||
if (timePassed < 60) setCooldown(60 - timePassed);
|
||
}
|
||
|
||
// Реалтайм
|
||
pb.collection("comments").subscribe("*", (e) => {
|
||
if (e.action === "create") {
|
||
setComments((prev) => [e.record, ...prev].slice(0, 50));
|
||
}
|
||
});
|
||
return () => {
|
||
pb.collection("comments").unsubscribe("*");
|
||
};
|
||
}, [pbUrl]);
|
||
|
||
useEffect(() => {
|
||
if (cooldown > 0) {
|
||
const timer = setTimeout(() => setCooldown((c) => c - 1), 1000);
|
||
return () => clearTimeout(timer);
|
||
}
|
||
}, [cooldown]);
|
||
|
||
const handleSubmit = async (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
// Honeypot: обычный пользователь это поле не видит и не заполняет, боты — заполняют.
|
||
if (honeypot)
|
||
return setStatus({ type: "error", text: dict.statusErrorBreach });
|
||
if (!nickname.trim() || !message.trim())
|
||
return setStatus({ type: "error", text: dict.statusErrorEmpty });
|
||
if (nickname.length > 20 || message.length > 150)
|
||
return setStatus({ type: "error", text: dict.statusErrorSize });
|
||
if (cooldown > 0)
|
||
return setStatus({
|
||
type: "error",
|
||
text: dict.statusErrorFirewall.replace("{{time}}", cooldown.toString()),
|
||
});
|
||
|
||
try {
|
||
setIsSubmitting(true);
|
||
|
||
const proxyUrl = "/api/proxy";
|
||
|
||
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("FILTER_VIOLATION");
|
||
}
|
||
throw new Error("SERVER_ERROR");
|
||
}
|
||
|
||
setNickname("");
|
||
setMessage("");
|
||
setStatus({ type: "success", text: dict.statusSuccess });
|
||
setCooldown(60);
|
||
localStorage.setItem("last_comment_time", Date.now().toString());
|
||
} catch (err: any) {
|
||
// 🔥 Обработка ошибки фильтра нейронки
|
||
if (err.message === "FILTER_VIOLATION") {
|
||
setStatus({
|
||
type: "error",
|
||
// Если добавишь statusErrorFilter в словари — будет тянуть оттуда, иначе фоллбек
|
||
text:
|
||
(dict as any).statusErrorFilter ||
|
||
"⛔ Отклонено: Ваше сообщение нарушает политику безопасности (мат, спам или токсичность). Пожалуйста, измените формулировку для прохождения фильтра.",
|
||
});
|
||
} else {
|
||
setStatus({ type: "error", text: dict.statusErrorOffline });
|
||
}
|
||
} finally {
|
||
setIsSubmitting(false);
|
||
// Увеличил таймаут до 8 секунд, чтобы юзер успел прочитать длинный текст ошибки
|
||
setTimeout(() => setStatus({ type: null, text: "" }), 8000);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<section
|
||
id="guestbook"
|
||
className="py-32 px-4 container mx-auto max-w-6xl relative overflow-hidden"
|
||
>
|
||
{/* Мягкие свечения */}
|
||
<div className="absolute -top-20 -right-20 w-[800px] h-[800px] bg-primary/5 dark:bg-primary/10 blur-[150px] rounded-full -z-10 opacity-30 dark:opacity-60" />
|
||
<div className="absolute -bottom-20 -left-20 w-[800px] h-[800px] bg-secondary/5 dark:bg-secondary/10 blur-[150px] rounded-full -z-10 opacity-30 dark:opacity-60" />
|
||
|
||
{/* Заголовок секции */}
|
||
<div className="text-center mb-16">
|
||
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full border border-secondary/30 bg-secondary/10 text-secondary text-xs font-bold tracking-[0.2em] mb-4 uppercase">
|
||
<Zap className="size-3 fill-current" /> {dict.subtitle}
|
||
</div>
|
||
<h2 className="text-4xl md:text-6xl font-black tracking-tighter text-foreground font-mono">
|
||
{dict.title}
|
||
</h2>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8 items-start">
|
||
{/* ЛЕВАЯ КОЛОНКА (4/12): Философия и Форма */}
|
||
<div className="lg:col-span-5 space-y-8">
|
||
{/* Блок философии */}
|
||
<div className="bg-primary/5 border-l-4 border-primary p-6 rounded-r-2xl backdrop-blur-sm">
|
||
<h3 className="text-xl font-bold text-primary mb-3 flex items-center gap-2 font-mono uppercase tracking-tight">
|
||
<ShieldAlert className="size-5" /> {dict.philosophyTitle}
|
||
</h3>
|
||
<p className="text-muted-foreground leading-relaxed text-sm antialiased italic">
|
||
"{dict.philosophyText}"
|
||
</p>
|
||
</div>
|
||
|
||
{/* Форма отправки */}
|
||
<div className="bg-card/40 backdrop-blur-xl border border-border/50 rounded-[2.5rem] p-8 shadow-xl">
|
||
<form onSubmit={handleSubmit} className="space-y-5">
|
||
<div className="space-y-2">
|
||
<label
|
||
className={cn(
|
||
"text-[10px] font-mono uppercase tracking-[0.2em] flex items-center gap-2 ml-1 mb-2",
|
||
"text-primary/80 dark:text-primary font-bold",
|
||
)}
|
||
>
|
||
<User className="size-3" /> {dict.labelAlias}
|
||
</label>
|
||
<input
|
||
type="text"
|
||
maxLength={20}
|
||
value={nickname}
|
||
onChange={(e) => setNickname(e.target.value)}
|
||
placeholder={dict.placeholderAlias}
|
||
disabled={isSubmitting || cooldown > 0}
|
||
className={cn(
|
||
"w-full rounded-2xl px-5 py-4 text-sm outline-none transition-all font-mono",
|
||
"disabled:opacity-50 text-foreground",
|
||
"bg-background/50 border border-border/80 placeholder:text-muted-foreground/60 focus:border-primary focus:bg-background shadow-sm",
|
||
"dark:bg-background/50 dark:border-border/50 dark:placeholder:text-muted-foreground/30 dark:focus:border-primary/50",
|
||
)}
|
||
/>
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
<label className="text-[10px] font-mono text-primary uppercase tracking-[0.2em] flex items-center gap-2 ml-1">
|
||
<MessageSquare className="size-3" /> {dict.labelPayload}
|
||
</label>
|
||
<textarea
|
||
maxLength={150}
|
||
rows={4}
|
||
value={message}
|
||
onChange={(e) => setMessage(e.target.value)}
|
||
placeholder={dict.placeholderPayload}
|
||
disabled={isSubmitting || cooldown > 0}
|
||
className={cn(
|
||
"w-full rounded-2xl px-5 py-4 text-sm outline-none transition-all font-mono",
|
||
"disabled:opacity-50 text-foreground",
|
||
"bg-background/50 border border-border/80 placeholder:text-muted-foreground/60 focus:border-primary focus:bg-background shadow-sm",
|
||
"dark:bg-background/50 dark:border-border/50 dark:placeholder:text-muted-foreground/30 dark:focus:border-primary/50",
|
||
)}
|
||
/>
|
||
<div className="text-right text-[10px] text-muted-foreground/40 font-mono pr-2">
|
||
{message.length}/150
|
||
</div>
|
||
</div>
|
||
|
||
<input
|
||
type="text"
|
||
name="website"
|
||
tabIndex={-1}
|
||
autoComplete="off"
|
||
value={honeypot}
|
||
onChange={(e) => setHoneypot(e.target.value)}
|
||
style={{ position: "absolute", left: "-9999px" }}
|
||
/>
|
||
|
||
<AnimatePresence mode="wait">
|
||
{status.text && (
|
||
<motion.div
|
||
initial={{ opacity: 0, height: 0 }}
|
||
animate={{ opacity: 1, height: "auto" }}
|
||
exit={{ opacity: 0, height: 0 }}
|
||
className={`p-4 rounded-2xl text-xs font-mono flex items-start gap-3 border ${
|
||
status.type === "error"
|
||
? "bg-destructive/10 border-destructive/20 text-destructive"
|
||
: "bg-secondary/10 border-secondary/20 text-secondary"
|
||
}`}
|
||
>
|
||
{status.type === "error" ? (
|
||
<ShieldAlert className="size-4 shrink-0 mt-0.5" />
|
||
) : (
|
||
<CheckCircle2 className="size-4 shrink-0 mt-0.5" />
|
||
)}
|
||
<span className="leading-relaxed">{status.text}</span>
|
||
</motion.div>
|
||
)}
|
||
</AnimatePresence>
|
||
|
||
<CyberButton
|
||
type="submit"
|
||
variant="primary"
|
||
disabled={isSubmitting || cooldown > 0}
|
||
// Добавляем cursor-net-wait при загрузке
|
||
className={cn("w-full", isSubmitting && "cursor-net-wait")}
|
||
>
|
||
{isSubmitting ? (
|
||
dict.btnEncrypting
|
||
) : cooldown > 0 ? (
|
||
dict.cooldown.replace("{{time}}", cooldown.toString())
|
||
) : (
|
||
<span className="flex items-center gap-2">
|
||
<Send className="size-4" /> {dict.btnBroadcast}
|
||
</span>
|
||
)}
|
||
</CyberButton>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
|
||
{/* ПРАВАЯ КОЛОНКА (7/12): Фид сообщений */}
|
||
<div
|
||
className={cn(
|
||
"lg:col-span-7 h-[700px] flex flex-col p-8 rounded-[3rem] backdrop-blur-md transition-all",
|
||
"bg-white/90 border border-border/80 shadow-xl",
|
||
"dark:bg-black/20 dark:border-primary/20 dark:shadow-2xl",
|
||
)}
|
||
>
|
||
<div className="flex justify-between items-center mb-8 px-4">
|
||
<div className="flex items-center gap-3">
|
||
<div className="size-2 rounded-full bg-secondary animate-pulse shadow-[0_0_10px_rgba(0,229,242,0.8)]" />
|
||
<span className="font-mono text-xs text-secondary uppercase tracking-[0.3em] font-bold">
|
||
{dict.liveFeed}
|
||
</span>
|
||
</div>
|
||
<span className="text-[10px] font-mono text-muted-foreground/40 uppercase">
|
||
Encrypted_Uplink_v2.4
|
||
</span>
|
||
</div>
|
||
|
||
<div
|
||
ref={scrollRef}
|
||
className="flex-1 overflow-y-auto pr-4 space-y-6 custom-scrollbar-cyber cursor-net-crosshair"
|
||
>
|
||
<AnimatePresence initial={false}>
|
||
{comments.map((comment) => (
|
||
<motion.div
|
||
key={comment.id}
|
||
initial={{ opacity: 0, y: 20 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
className="relative group"
|
||
>
|
||
<div className="absolute -left-2 top-0 bottom-0 w-0.5 bg-gradient-to-b from-secondary/0 via-secondary/20 to-secondary/0 group-hover:via-secondary/50 transition-all" />
|
||
{/* Сообщение */}
|
||
<div
|
||
className={cn(
|
||
"p-5 rounded-[1.5rem] transition-all group-hover:translate-x-1",
|
||
"bg-muted/30 border border-border/50 group-hover:bg-muted/60",
|
||
"dark:bg-white/5 dark:border-white/5 dark:group-hover:bg-white/[0.08] dark:group-hover:border-white/10",
|
||
)}
|
||
>
|
||
<div className="flex justify-between items-center mb-3">
|
||
<span className="font-black text-secondary font-mono text-sm tracking-tight">
|
||
@{comment.nickname}
|
||
</span>
|
||
<span className="text-[10px] text-muted-foreground/40 font-mono">
|
||
{new Date(comment.created).toLocaleTimeString()}
|
||
</span>
|
||
</div>
|
||
<p className="text-muted-foreground text-[15px] leading-relaxed break-words font-medium">
|
||
{comment.message}
|
||
</p>
|
||
</div>
|
||
</motion.div>
|
||
))}
|
||
</AnimatePresence>
|
||
|
||
{comments.length === 0 && (
|
||
<div className="h-full flex flex-col items-center justify-center text-muted-foreground/20 font-mono space-y-4">
|
||
<Terminal className="size-12 opacity-10" />
|
||
<span className="text-xs uppercase tracking-[0.2em]">
|
||
{dict.noTransmissions}
|
||
</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|