diff --git a/backend/package.json b/backend/package.json index c81a34a..fff3602 100644 --- a/backend/package.json +++ b/backend/package.json @@ -17,10 +17,12 @@ "@fastify/static": "^7.0.4", "@prisma/client": "^5.20.0", "fastify": "^4.28.1", + "geoip-lite": "^1.4.10", "prisma": "^5.20.0", "zod": "^3.23.8" }, "devDependencies": { + "@types/geoip-lite": "^1.4.4", "@types/node": "^20.14.15", "tsx": "^4.19.1", "typescript": "^5.6.2" diff --git a/backend/prisma/migrations/20260911000000_add_analytics_feedback/migration.sql b/backend/prisma/migrations/20260911000000_add_analytics_feedback/migration.sql new file mode 100644 index 0000000..fbf006d --- /dev/null +++ b/backend/prisma/migrations/20260911000000_add_analytics_feedback/migration.sql @@ -0,0 +1,36 @@ +-- CreateTable +CREATE TABLE "page_visits" ( + "id" TEXT NOT NULL, + "path" TEXT NOT NULL, + "system" TEXT, + "ip" TEXT NOT NULL, + "port" INTEGER, + "country" TEXT, + "city" TEXT, + "userAgent" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "page_visits_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "feedback" ( + "id" TEXT NOT NULL, + "message" TEXT NOT NULL, + "email" TEXT, + "rating" INTEGER, + "page" TEXT, + "ip" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "feedback_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "page_visits_createdAt_idx" ON "page_visits"("createdAt"); + +-- CreateIndex +CREATE INDEX "page_visits_system_idx" ON "page_visits"("system"); + +-- CreateIndex +CREATE INDEX "feedback_createdAt_idx" ON "feedback"("createdAt"); diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index fae86d9..5a80c47 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -40,6 +40,35 @@ model Structure { @@map("structures") } +model PageVisit { + id String @id @default(cuid()) + path String + system String? + ip String + port Int? + country String? + city String? + userAgent String? + createdAt DateTime @default(now()) + + @@index([createdAt]) + @@index([system]) + @@map("page_visits") +} + +model Feedback { + id String @id @default(cuid()) + message String + email String? + rating Int? + page String? + ip String? + createdAt DateTime @default(now()) + + @@index([createdAt]) + @@map("feedback") +} + model QuizCard { id String @id @default(cuid()) structureId String @unique diff --git a/backend/src/lib/adminAuth.ts b/backend/src/lib/adminAuth.ts new file mode 100644 index 0000000..a30159d --- /dev/null +++ b/backend/src/lib/adminAuth.ts @@ -0,0 +1,39 @@ +import crypto from "node:crypto"; +import type { FastifyReply, FastifyRequest } from "fastify"; + +// Painel de admin de uso pessoal (um único usuário) — token assinado com +// HMAC via módulo nativo `crypto`, sem dependência extra de JWT. Mesmo +// padrão de "segredo em variável de ambiente com default" já usado pelas +// credenciais do Postgres neste projeto (ver docker-compose.yml). +const SECRET = process.env.ADMIN_JWT_SECRET ?? "troque-este-segredo-em-producao"; +const TOKEN_TTL_MS = 12 * 60 * 60 * 1000; + +export function signAdminToken(email: string): string { + const payload = JSON.stringify({ email, exp: Date.now() + TOKEN_TTL_MS }); + const body = Buffer.from(payload).toString("base64url"); + const sig = crypto.createHmac("sha256", SECRET).update(body).digest("base64url"); + return `${body}.${sig}`; +} + +function isValidToken(token: string | undefined): boolean { + if (!token) return false; + const [body, sig] = token.split("."); + if (!body || !sig) return false; + const expected = crypto.createHmac("sha256", SECRET).update(body).digest("base64url"); + if (sig.length !== expected.length) return false; + if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) return false; + try { + const payload = JSON.parse(Buffer.from(body, "base64url").toString()); + return typeof payload.exp === "number" && payload.exp > Date.now(); + } catch { + return false; + } +} + +export async function requireAdmin(req: FastifyRequest, reply: FastifyReply): Promise { + const auth = req.headers.authorization; + const token = auth?.startsWith("Bearer ") ? auth.slice("Bearer ".length) : undefined; + if (!isValidToken(token)) { + reply.code(401).send({ error: "unauthorized" }); + } +} diff --git a/backend/src/lib/presence.ts b/backend/src/lib/presence.ts new file mode 100644 index 0000000..0893b35 --- /dev/null +++ b/backend/src/lib/presence.ts @@ -0,0 +1,18 @@ +// Contagem de "usuários online" — em memória, sem persistir no banco (é um +// número instantâneo, não histórico). O frontend manda um heartbeat a cada +// ~20s enquanto a aba está aberta; consideramos "online" quem mandou um +// heartbeat nos últimos 45s (tolera uma falha de rede pontual). +const heartbeats = new Map(); +const TTL_MS = 45_000; + +export function touchSession(sessionId: string): void { + heartbeats.set(sessionId, Date.now()); +} + +export function getOnlineCount(): number { + const now = Date.now(); + for (const [id, lastSeen] of heartbeats) { + if (now - lastSeen > TTL_MS) heartbeats.delete(id); + } + return heartbeats.size; +} diff --git a/backend/src/lib/requestMeta.ts b/backend/src/lib/requestMeta.ts new file mode 100644 index 0000000..5c8b47e --- /dev/null +++ b/backend/src/lib/requestMeta.ts @@ -0,0 +1,32 @@ +import type { FastifyRequest } from "fastify"; +import geoip from "geoip-lite"; + +// Requer `trustProxy: true` no Fastify (ver server.ts) — aí `req.ip` já +// reflete o primeiro IP de X-Forwarded-For, que o nginx interno preenche +// (ver nginx/nginx.conf) a partir do que o reverse proxy externo do usuário +// mandar. Sem isso, todo mundo apareceria com o IP do container "web". +export function resolveIp(req: FastifyRequest): string { + return req.ip; +} + +// A porta de origem do cliente normalmente NÃO sobrevive a um reverse +// proxy (o socket que a API enxerga é o do nginx, não o do navegador do +// visitante) — só é possível saber a porta real se algum proxy na cadeia +// mandar um cabeçalho explícito (X-Forwarded-Port não costuma carregar a +// porta do cliente, mas deixamos o suporte pronto caso o proxy externo do +// usuário seja configurado para isso). Na ausência dele, usamos a porta do +// socket TCP direto (a porta efêmera de quem conectou nesse hop). +export function resolvePort(req: FastifyRequest): number | null { + const header = req.headers["x-forwarded-port"]; + const value = Array.isArray(header) ? header[0] : header; + if (value) { + const port = Number(value.split(",")[0]); + if (Number.isFinite(port)) return port; + } + return req.socket?.remotePort ?? null; +} + +export function resolveGeo(ip: string): { country: string | null; city: string | null } { + const geo = geoip.lookup(ip); + return { country: geo?.country ?? null, city: geo?.city ?? null }; +} diff --git a/backend/src/routes/admin.ts b/backend/src/routes/admin.ts new file mode 100644 index 0000000..662f1bd --- /dev/null +++ b/backend/src/routes/admin.ts @@ -0,0 +1,83 @@ +import type { FastifyInstance } from "fastify"; +import { z } from "zod"; +import { prisma } from "../lib/prisma.js"; +import { signAdminToken, requireAdmin } from "../lib/adminAuth.js"; +import { getOnlineCount } from "../lib/presence.js"; + +// Mesmo padrão de "credencial em variável de ambiente com default" já usado +// pelo Postgres neste compose (POSTGRES_USER/POSTGRES_PASSWORD) — painel de +// uso pessoal (um único admin), sem necessidade de tabela de usuários. +const ADMIN_EMAIL = process.env.ADMIN_EMAIL ?? "rodrigo@martinsevieira.adv.br"; +const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD ?? "34641747"; + +const loginSchema = z.object({ + email: z.string(), + password: z.string(), +}); + +export async function adminRoutes(app: FastifyInstance) { + app.post("/api/admin/login", async (req, reply) => { + const { email, password } = loginSchema.parse(req.body); + if (email !== ADMIN_EMAIL || password !== ADMIN_PASSWORD) { + reply.code(401).send({ error: "Credenciais inválidas" }); + return; + } + return { token: signAdminToken(email) }; + }); + + app.register(async (protectedApp) => { + protectedApp.addHook("preHandler", requireAdmin); + + protectedApp.get("/api/admin/stats", async () => { + const since24h = new Date(Date.now() - 24 * 60 * 60 * 1000); + const since7d = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000); + + const [ + totalVisits, + visitsLast24h, + visitsLast7d, + visitsBySystem, + visitsByCountry, + recentVisits, + feedback, + ] = await Promise.all([ + prisma.pageVisit.count(), + prisma.pageVisit.count({ where: { createdAt: { gte: since24h } } }), + prisma.pageVisit.count({ where: { createdAt: { gte: since7d } } }), + prisma.pageVisit.groupBy({ + by: ["system"], + _count: { system: true }, + where: { system: { not: null } }, + orderBy: { _count: { system: "desc" } }, + }), + prisma.pageVisit.groupBy({ + by: ["country"], + _count: { country: true }, + orderBy: { _count: { country: "desc" } }, + }), + prisma.pageVisit.findMany({ + orderBy: { createdAt: "desc" }, + take: 200, + }), + prisma.feedback.findMany({ + orderBy: { createdAt: "desc" }, + take: 200, + }), + ]); + + return { + onlineNow: getOnlineCount(), + totalVisits, + visitsLast24h, + visitsLast7d, + visitsBySystem: visitsBySystem.map((v) => ({ system: v.system, count: v._count.system })), + visitsByCountry: visitsByCountry.map((v) => ({ + country: v.country ?? "Desconhecido", + count: v._count.country, + })), + recentVisits, + feedback, + }; + }); + }); +} diff --git a/backend/src/routes/analytics.ts b/backend/src/routes/analytics.ts new file mode 100644 index 0000000..6b2bb64 --- /dev/null +++ b/backend/src/routes/analytics.ts @@ -0,0 +1,43 @@ +import type { FastifyInstance } from "fastify"; +import { z } from "zod"; +import { prisma } from "../lib/prisma.js"; +import { resolveGeo, resolveIp, resolvePort } from "../lib/requestMeta.js"; +import { getOnlineCount, touchSession } from "../lib/presence.js"; + +const visitSchema = z.object({ + path: z.string().min(1).max(300), + system: z.string().max(100).optional(), +}); + +const heartbeatSchema = z.object({ + sessionId: z.string().min(8).max(64), +}); + +export async function analyticsRoutes(app: FastifyInstance) { + app.post("/api/analytics/visit", async (req) => { + const { path, system } = visitSchema.parse(req.body); + const ip = resolveIp(req); + const port = resolvePort(req); + const { country, city } = resolveGeo(ip); + await prisma.pageVisit.create({ + data: { + path, + system: system ?? null, + ip, + port: port ?? undefined, + country, + city, + userAgent: (req.headers["user-agent"] as string | undefined) ?? null, + }, + }); + return { ok: true }; + }); + + app.post("/api/analytics/heartbeat", async (req) => { + const { sessionId } = heartbeatSchema.parse(req.body); + touchSession(sessionId); + return { ok: true }; + }); + + app.get("/api/analytics/online-count", async () => ({ count: getOnlineCount() })); +} diff --git a/backend/src/routes/feedback.ts b/backend/src/routes/feedback.ts new file mode 100644 index 0000000..4edb83e --- /dev/null +++ b/backend/src/routes/feedback.ts @@ -0,0 +1,28 @@ +import type { FastifyInstance } from "fastify"; +import { z } from "zod"; +import { prisma } from "../lib/prisma.js"; +import { resolveIp } from "../lib/requestMeta.js"; + +const feedbackSchema = z.object({ + message: z.string().min(1).max(2000), + email: z.string().email().max(200).optional().or(z.literal("")), + rating: z.number().int().min(1).max(5).optional(), + page: z.string().max(200).optional(), +}); + +export async function feedbackRoutes(app: FastifyInstance) { + app.post("/api/feedback", async (req, reply) => { + const data = feedbackSchema.parse(req.body); + await prisma.feedback.create({ + data: { + message: data.message, + email: data.email || null, + rating: data.rating ?? null, + page: data.page ?? null, + ip: resolveIp(req), + }, + }); + reply.code(201); + return { ok: true }; + }); +} diff --git a/backend/src/server.ts b/backend/src/server.ts index 466ca51..e2607ac 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -6,11 +6,17 @@ import { fileURLToPath } from "node:url"; import { systemsRoutes } from "./routes/systems.js"; import { structuresRoutes } from "./routes/structures.js"; import { quizRoutes } from "./routes/quiz.js"; +import { analyticsRoutes } from "./routes/analytics.js"; +import { adminRoutes } from "./routes/admin.js"; +import { feedbackRoutes } from "./routes/feedback.js"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const MODELS_DIR = process.env.MODELS_DIR ?? path.resolve(__dirname, "../models"); -const app = Fastify({ logger: true }); +// trustProxy: essencial pra "req.ip" refletir o visitante real (via +// X-Forwarded-For) em vez do IP do nginx interno — ver nginx/nginx.conf, +// que já repassa X-Forwarded-For a partir do reverse proxy externo. +const app = Fastify({ logger: true, trustProxy: true }); await app.register(cors, { origin: true }); @@ -27,6 +33,9 @@ app.get("/api/health", async () => ({ status: "ok" })); await app.register(systemsRoutes); await app.register(structuresRoutes); await app.register(quizRoutes); +await app.register(analyticsRoutes); +await app.register(adminRoutes); +await app.register(feedbackRoutes); const port = Number(process.env.PORT ?? 3333); app diff --git a/data-pipeline/scripts/extract-manifest.mjs b/data-pipeline/scripts/extract-manifest.mjs index fa033ff..9f739f2 100644 --- a/data-pipeline/scripts/extract-manifest.mjs +++ b/data-pipeline/scripts/extract-manifest.mjs @@ -103,23 +103,37 @@ const LEADER_LINE_SOURCE_KEYS = new Set(["nervous", "regions"]); function isLeaderLineMesh(mesh) { try { - const prims = mesh.listPrimitives(); - if (prims.length === 0) return false; - return prims.every((prim) => isLeaderLinePrimitive(prim)); + // Antes, cada primitivo era julgado sozinho ("every"). Isso deixava + // passar batido linhas de chamada com um pequeno "gancho"/marcador na + // ponta (comum no app original, pra indicar o ponto exato) — esse + // gancho é um primitivo à parte que não é colinear, então "every" + // reprovava a malha inteira. Agora juntamos os vértices de TODOS os + // primitivos da malha num só cálculo e usamos o percentil 90 do desvio + // (em vez do máximo), assim uma pontinha que afeta só uma minoria dos + // vértices não derruba a classificação, mas uma estrutura real (curva, + // ramificada, com volume) continua reprovando. + const points = []; + for (const prim of mesh.listPrimitives()) { + const accessor = prim.getAttribute("POSITION"); + if (!accessor) continue; + const count = accessor.getCount(); + const arr = accessor.getArray(); + if (!arr) continue; + for (let i = 0; i < count; i++) { + points.push([arr[i * 3], arr[i * 3 + 1], arr[i * 3 + 2]]); + } + } + return isLeaderLinePointCloud(points); } catch { return false; } } -function isLeaderLinePrimitive(prim) { - const accessor = prim.getAttribute("POSITION"); - if (!accessor) return false; - const count = accessor.getCount(); +function isLeaderLinePointCloud(points) { + const count = points.length; if (count < 2) return false; - const arr = accessor.getArray(); - if (!arr) return false; - const at = (i) => [arr[i * 3], arr[i * 3 + 1], arr[i * 3 + 2]]; + const at = (i) => points[i]; const dist2 = (a, b) => { const dx = a[0] - b[0], dy = a[1] - b[1], dz = a[2] - b[2]; return dx * dx + dy * dy + dz * dz; @@ -144,18 +158,21 @@ function isLeaderLinePrimitive(prim) { if (axisLen < 0.02) return false; // curto demais pra ser uma linha de chamada (poste real de âncora) const ax = [(b[0] - a[0]) / axisLen, (b[1] - a[1]) / axisLen, (b[2] - a[2]) / axisLen]; - let maxPerp = 0; + const perps = new Array(count); for (let i = 0; i < count; i++) { const p = at(i); const v = [p[0] - a[0], p[1] - a[1], p[2] - a[2]]; const t = v[0] * ax[0] + v[1] * ax[1] + v[2] * ax[2]; const perp2 = (v[0] - t * ax[0]) ** 2 + (v[1] - t * ax[1]) ** 2 + (v[2] - t * ax[2]) ** 2; - if (perp2 > maxPerp) maxPerp = perp2; + perps[i] = Math.sqrt(perp2); } - // "Linha reta e fina": todos os pontos ficam muito perto do próprio eixo - // mais longo (desvio perpendicular pequeno comparado ao comprimento). - return Math.sqrt(maxPerp) < axisLen * 0.08; + perps.sort((x, y) => x - y); + const p90 = perps[Math.floor(0.9 * (count - 1))]; + // "Linha reta e fina": 90% dos pontos ficam muito perto do próprio eixo + // mais longo (desvio perpendicular pequeno comparado ao comprimento) — + // tolera até 10% de vértices "fora da linha" (gancho/marcador na ponta). + return p90 < axisLen * 0.1; } function classifySystem(key, rawName) { diff --git a/docker-compose.yml b/docker-compose.yml index dfb2777..004823d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -40,6 +40,10 @@ services: DATABASE_URL: postgresql://${POSTGRES_USER:-anatomia}:${POSTGRES_PASSWORD:-anatomia}@anatomia-db:5432/${POSTGRES_DB:-anatomia}?connection_limit=10 PORT: 3333 MODELS_DIR: /app/models + # Login do painel de estatísticas (/estatisticas no frontend). + ADMIN_EMAIL: ${ADMIN_EMAIL:-rodrigo@martinsevieira.adv.br} + ADMIN_PASSWORD: ${ADMIN_PASSWORD:-34641747} + ADMIN_JWT_SECRET: ${ADMIN_JWT_SECRET:-troque-este-segredo-em-producao} volumes: - anatomia_models_data:/app/models:ro networks: diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 759ed59..14cd830 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4,6 +4,7 @@ import AtlasPage from "./routes/AtlasPage.js"; import PlanesPage from "./routes/PlanesPage.js"; import QuizPage from "./routes/QuizPage.js"; import PhysicalExamPage from "./routes/PhysicalExamPage.js"; +import AdminStatsPage from "./routes/AdminStatsPage.js"; export default function App() { return ( @@ -14,6 +15,8 @@ export default function App() { } /> } /> + {/* Fora do Layout: painel interno, sem o menu/nav do atlas. */} + } /> ); } diff --git a/frontend/src/components/CookieConsent.tsx b/frontend/src/components/CookieConsent.tsx new file mode 100644 index 0000000..4ab349f --- /dev/null +++ b/frontend/src/components/CookieConsent.tsx @@ -0,0 +1,49 @@ +import { useEffect, useState } from "react"; +import { getConsent, setConsent, startHeartbeat, type ConsentValue } from "../lib/analytics.js"; + +export function CookieConsent() { + const [visible, setVisible] = useState(false); + + useEffect(() => { + const current = getConsent(); + if (current === null) { + setVisible(true); + } else if (current === "accepted") { + startHeartbeat(); + } + }, []); + + function choose(value: ConsentValue) { + setConsent(value); + setVisible(false); + if (value === "accepted") startHeartbeat(); + } + + if (!visible) return null; + + return ( +
+
+

+ Este site coleta dados básicos de uso (endereço IP, país aproximado e páginas + acessadas) apenas para estatísticas anônimas de acesso. Nenhum dado é vendido ou + compartilhado com terceiros. Você pode recusar sem perder acesso ao atlas. +

+
+ + +
+
+
+ ); +} diff --git a/frontend/src/components/FeedbackWidget.tsx b/frontend/src/components/FeedbackWidget.tsx new file mode 100644 index 0000000..611b5b0 --- /dev/null +++ b/frontend/src/components/FeedbackWidget.tsx @@ -0,0 +1,123 @@ +import { useState } from "react"; +import { useLocation } from "react-router-dom"; +import { api } from "../lib/api.js"; + +export function FeedbackWidget() { + const location = useLocation(); + const [open, setOpen] = useState(false); + const [message, setMessage] = useState(""); + const [email, setEmail] = useState(""); + const [rating, setRating] = useState(null); + const [status, setStatus] = useState<"idle" | "sending" | "sent" | "error">("idle"); + + function reset() { + setMessage(""); + setEmail(""); + setRating(null); + setStatus("idle"); + } + + async function submit() { + if (!message.trim()) return; + setStatus("sending"); + try { + await api.sendFeedback({ + message: message.trim(), + email: email.trim() || undefined, + rating: rating ?? undefined, + page: location.pathname, + }); + setStatus("sent"); + } catch { + setStatus("error"); + } + } + + return ( + <> + + + {open && ( +
+
+ {status === "sent" ? ( +
+

Obrigado pelo feedback! 🙏

+ +
+ ) : ( + <> +
+

Enviar feedback

+ +
+ +
+ {[1, 2, 3, 4, 5].map((n) => ( + + ))} +
+ +