muitas melhorias pra descrever
This commit is contained in:
1 parent
89b08a19ef
commit
c8fba4d240
21 files changed
+971
-17
No files matched your search
@@ -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"
|
||||
|
||||
@@ -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");
|
||||
@@ -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
|
||||
|
||||
@@ -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<void> {
|
||||
const auth = req.headers.authorization;
|
||||
const token = auth?.startsWith("Bearer ") ? auth.slice("Bearer ".length) : undefined;
|
||||
if (!isValidToken(token)) {
|
||||
reply.code(401).send({ error: "unauthorized" });
|
||||
}
|
||||
}
|
||||
@@ -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<string, number>();
|
||||
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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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() }));
|
||||
}
|
||||
@@ -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 };
|
||||
});
|
||||
}
|
||||
+10
-1
@@ -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
|
||||
|
||||
Reference in new issue
Block a user