cached results and queries, add users and auth for admin
This commit is contained in:
parent
6787b3db50
commit
18a801a82e
|
|
@ -12,3 +12,12 @@ SUPABASE_URL="http://localhost:8000"
|
|||
SUPABASE_ANON_KEY="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJhbm9uIiwKICAgICJpc3MiOiAic3VwYWJhc2UtZGVtbyIsCiAgICAiaWF0IjogMTY0MTc2OTIwMCwKICAgICJleHAiOiAxNzk5NTM1NjAwCn0.dc_X5iR_VP_qT0zsiyj_I_OZ2T9FtRU2BBNWN8Bu4GE"
|
||||
SUPABASE_SERVICE_ROLE_KEY="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJzZXJ2aWNlX3JvbGUiLAogICAgImlzcyI6ICJzdXBhYmFzZS1kZW1vIiwKICAgICJpYXQiOiAxNjQxNzY5MjAwLAogICAgImV4cCI6IDE3OTk1MzU2MDAKfQ.DaYlNEoUrrEn2Ig7tqibS-PHK5vgusbcbo7X36XVt4Q"
|
||||
POSTGRES_PASSWORD=postgres
|
||||
|
||||
# Admin auth — API key (para consumo externo de /api/admin/*)
|
||||
ADMIN_API_KEY="CHANGE_ME_admin_api_key"
|
||||
|
||||
# Admin auth — credenciales del seed (solo se usan en scripts/seed-admin.ts)
|
||||
ADMIN_EMAIL="admin@example.com"
|
||||
ADMIN_USERNAME="admin"
|
||||
ADMIN_PASSWORD="CHANGE_ME_password"
|
||||
ADMIN_NOMBRE="Admin"
|
||||
|
|
|
|||
|
|
@ -63,6 +63,7 @@ jobs:
|
|||
TURNSTILE_SECRET_KEY="${{ secrets.TURNSTILE_SECRET_KEY }}"
|
||||
CLOUDFLARE_API_TOKEN="${{ secrets.CLOUDFLARE_API_TOKEN }}"
|
||||
CLOUDFLARE_ACCOUNT_ID="${{ secrets.CLOUDFLARE_ACCOUNT_ID }}"
|
||||
ADMIN_API_KEY="${{ secrets.ADMIN_API_KEY }}"
|
||||
ENVEOF
|
||||
pm2 reload ecosystem.config.cjs --only ${{ env.APP_NAME }} --update-env || \
|
||||
pm2 start ecosystem.config.cjs --only ${{ env.APP_NAME }} --update-env
|
||||
|
|
|
|||
|
|
@ -0,0 +1,25 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "admins" ALTER COLUMN "auth_user_id" DROP NOT NULL;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "admins" ADD COLUMN "password_hash" TEXT;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "sessions" (
|
||||
"id" UUID NOT NULL,
|
||||
"token_hash" TEXT NOT NULL,
|
||||
"admin_id" UUID NOT NULL,
|
||||
"expires_at" TIMESTAMPTZ NOT NULL,
|
||||
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "sessions_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "sessions_token_hash_key" ON "sessions"("token_hash");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "sessions_admin_id_idx" ON "sessions"("admin_id");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "sessions" ADD CONSTRAINT "sessions_admin_id_fkey" FOREIGN KEY ("admin_id") REFERENCES "admins"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "admins" ADD COLUMN "username" TEXT;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "admins_username_key" ON "admins"("username");
|
||||
|
|
@ -130,14 +130,31 @@ model FormVersion {
|
|||
|
||||
model Admin {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
auth_user_id String @unique @db.Uuid
|
||||
auth_user_id String? @unique @db.Uuid
|
||||
email String @unique
|
||||
username String? @unique
|
||||
nombre String
|
||||
password_hash String?
|
||||
rol String @default("coordinador")
|
||||
activo Boolean @default(true)
|
||||
ultimo_acceso DateTime? @db.Timestamptz
|
||||
created_at DateTime @default(now()) @db.Timestamptz
|
||||
|
||||
sessions Session[]
|
||||
|
||||
@@index([email])
|
||||
@@map("admins")
|
||||
}
|
||||
|
||||
model Session {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
token_hash String @unique
|
||||
admin_id String @db.Uuid
|
||||
expires_at DateTime @db.Timestamptz
|
||||
created_at DateTime @default(now()) @db.Timestamptz
|
||||
|
||||
admin Admin @relation(fields: [admin_id], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([admin_id])
|
||||
@@map("sessions")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -159,12 +159,14 @@ function translatePath(newLocale: string) {
|
|||
}
|
||||
{isAdmin && (
|
||||
<li>
|
||||
<a
|
||||
class="hover:text-colorPrimary transition"
|
||||
href="#"
|
||||
>
|
||||
Acceder
|
||||
</a>
|
||||
<form method="POST" action="/api/admin/auth/logout">
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full text-left text-white hover:text-colorPrimary transition cursor-pointer"
|
||||
>
|
||||
Salir
|
||||
</button>
|
||||
</form>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
|
|
@ -215,12 +217,14 @@ function translatePath(newLocale: string) {
|
|||
</div>
|
||||
{isAdmin && (
|
||||
<div class="hidden md:block">
|
||||
<Button
|
||||
class="px-4 py-2 uppercase"
|
||||
title="Acceder"
|
||||
url="#"
|
||||
variant="secondary"
|
||||
/>
|
||||
<form method="POST" action="/api/admin/auth/logout">
|
||||
<button
|
||||
type="submit"
|
||||
class="bg-[#22523F] text-[#EBE6D2] border-0 hover:bg-[#EBE6D2]/90 hover:text-tertiary uppercase rounded-none font-bold transition block text-center px-4 py-2 cursor-pointer"
|
||||
>
|
||||
Salir
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
<div class="dropdown dropdown-end md:block hidden">
|
||||
|
|
|
|||
|
|
@ -152,6 +152,7 @@
|
|||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from "vue";
|
||||
import { cachedFetch } from "../../lib/adminCache";
|
||||
|
||||
const loading = ref(false);
|
||||
const error = ref("");
|
||||
|
|
@ -194,7 +195,7 @@ async function fetchStats() {
|
|||
loading.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
const res = await fetch("/api/admin/voluntarios/stats");
|
||||
const res = await cachedFetch("/api/admin/voluntarios/stats", { ttl: 60 });
|
||||
const json = await res.json();
|
||||
if (!json.success) throw new Error(json.message || "Error al cargar estadísticas");
|
||||
stats.value = json;
|
||||
|
|
|
|||
|
|
@ -68,6 +68,7 @@
|
|||
|
||||
<script setup>
|
||||
import { ref, watch, onMounted, onBeforeUnmount } from "vue";
|
||||
import { cachedFetch } from "../../lib/adminCache";
|
||||
|
||||
const props = defineProps({
|
||||
field: { type: String, required: true },
|
||||
|
|
@ -130,7 +131,7 @@ async function fetchOptions() {
|
|||
params.set("limit", String(props.limit));
|
||||
if (query.value.trim().length >= 2) params.set("q", query.value.trim());
|
||||
try {
|
||||
const res = await fetch(`/api/admin/voluntarios/distinct?${params.toString()}`);
|
||||
const res = await cachedFetch(`/api/admin/voluntarios/distinct?${params.toString()}`, { ttl: 300 });
|
||||
const json = await res.json();
|
||||
if (version !== fetchVersion) return;
|
||||
options.value = json.success ? json.data : [];
|
||||
|
|
|
|||
|
|
@ -107,6 +107,7 @@
|
|||
|
||||
<script setup>
|
||||
import { ref, computed, watch, onMounted } from "vue";
|
||||
import { cachedFetch } from "../../lib/adminCache";
|
||||
|
||||
const props = defineProps({
|
||||
numero: { type: [Number, String], required: true },
|
||||
|
|
@ -234,7 +235,7 @@ async function fetchData() {
|
|||
loading.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
const res = await fetch(`/api/admin/voluntarios/${props.numero}`);
|
||||
const res = await cachedFetch(`/api/admin/voluntarios/${props.numero}`, { ttl: 60 });
|
||||
const json = await res.json();
|
||||
if (!json.success) throw new Error(json.message || "Voluntario no encontrado");
|
||||
data.value = json.data;
|
||||
|
|
|
|||
|
|
@ -285,6 +285,7 @@
|
|||
import { ref, reactive, computed, watch, onMounted, onUnmounted } from "vue";
|
||||
import SearchableCombobox from "./SearchableCombobox.vue";
|
||||
import VoluntarioDetail from "./VoluntarioDetail.vue";
|
||||
import { cachedFetch } from "../../lib/adminCache";
|
||||
|
||||
const rows = ref([]);
|
||||
const total = ref(0);
|
||||
|
|
@ -484,7 +485,7 @@ async function fetchData() {
|
|||
for (const f of responseFields) if (respFilters[f]) params.set(f, respFilters[f]);
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/admin/voluntarios?${params.toString()}`);
|
||||
const res = await cachedFetch(`/api/admin/voluntarios?${params.toString()}`, { ttl: 30 });
|
||||
const json = await res.json();
|
||||
if (!json.success) throw new Error(json.message || "Error al cargar");
|
||||
rows.value = json.data;
|
||||
|
|
@ -500,7 +501,7 @@ async function fetchData() {
|
|||
|
||||
async function fetchOptions() {
|
||||
try {
|
||||
const res = await fetch("/api/admin/voluntarios/options");
|
||||
const res = await cachedFetch("/api/admin/voluntarios/options", { ttl: 300 });
|
||||
const json = await res.json();
|
||||
if (json.success) {
|
||||
statuses.value = json.statuses;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,63 @@
|
|||
type CacheEntry = {
|
||||
body: string;
|
||||
status: number;
|
||||
expiresAt: number;
|
||||
};
|
||||
|
||||
const PREFIX = "adminCache:";
|
||||
|
||||
export interface CachedFetchOptions {
|
||||
ttl?: number;
|
||||
}
|
||||
|
||||
export async function cachedFetch(
|
||||
url: string,
|
||||
options: CachedFetchOptions = {}
|
||||
): Promise<Response> {
|
||||
const ttl = options.ttl ?? 60;
|
||||
const key = PREFIX + url;
|
||||
const now = Date.now();
|
||||
|
||||
try {
|
||||
const raw = sessionStorage.getItem(key);
|
||||
if (raw) {
|
||||
const entry = JSON.parse(raw) as CacheEntry;
|
||||
if (
|
||||
entry &&
|
||||
typeof entry.expiresAt === "number" &&
|
||||
entry.expiresAt > now
|
||||
) {
|
||||
return new Response(entry.body, {
|
||||
status: entry.status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// sessionStorage no disponible o entrada inválida
|
||||
}
|
||||
|
||||
const res = await fetch(url);
|
||||
|
||||
if (res.ok) {
|
||||
try {
|
||||
const body = await res.text();
|
||||
sessionStorage.setItem(
|
||||
key,
|
||||
JSON.stringify({
|
||||
body,
|
||||
status: res.status,
|
||||
expiresAt: now + ttl * 1000,
|
||||
})
|
||||
);
|
||||
return new Response(body, {
|
||||
status: res.status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
} catch {
|
||||
// no se pudo cachear, devolver la respuesta original
|
||||
}
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
|
|
@ -1,12 +1,51 @@
|
|||
import { defineMiddleware } from "astro:middleware";
|
||||
import { getSession, isApiAuthorized } from "./pages/api/lib/auth";
|
||||
|
||||
export const onRequest = defineMiddleware((context, next) => {
|
||||
const { url, redirect } = context;
|
||||
const { pathname } = url;
|
||||
const LOCALES = new Set(["es", "en", "fr", "he", "uk", "pt", "ru", "rw", "kr"]);
|
||||
|
||||
export const onRequest = defineMiddleware(async (context, next) => {
|
||||
const { url, request, redirect } = context;
|
||||
const pathname = url.pathname;
|
||||
|
||||
if (pathname === "/admin" || pathname === "/admin/") {
|
||||
return redirect("/es/admin", 302);
|
||||
}
|
||||
|
||||
if (pathname.startsWith("/api/admin/")) {
|
||||
const isAuthEndpoint =
|
||||
pathname.endsWith("/auth/login") || pathname.endsWith("/auth/logout");
|
||||
|
||||
if (request.method !== "GET" && !isAuthEndpoint) {
|
||||
return new Response(
|
||||
JSON.stringify({ success: false, message: "Method not allowed" }),
|
||||
{ status: 405, headers: { "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthEndpoint && !(await isApiAuthorized(request))) {
|
||||
return new Response(
|
||||
JSON.stringify({ success: false, message: "Unauthorized" }),
|
||||
{ status: 401, headers: { "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
return next();
|
||||
}
|
||||
|
||||
const segments = pathname.split("/").filter(Boolean);
|
||||
if (segments.length >= 2 && segments[1] === "admin") {
|
||||
const locale = LOCALES.has(segments[0]) ? segments[0] : "es";
|
||||
const isLogin = segments[2] === "login";
|
||||
const session = await getSession(request);
|
||||
|
||||
if (!isLogin && !session) {
|
||||
return redirect(`/${locale}/admin/login`, 302);
|
||||
}
|
||||
|
||||
if (isLogin && session) {
|
||||
return redirect(`/${locale}/admin`, 302);
|
||||
}
|
||||
}
|
||||
|
||||
return next();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,156 @@
|
|||
---
|
||||
import "../../../styles/global.css";
|
||||
import "@fontsource/poppins/400.css";
|
||||
import "@fontsource/poppins/500.css";
|
||||
import "@fontsource/poppins/700.css";
|
||||
import "@fontsource-variable/kameron";
|
||||
import { Icon } from "astro-icon/components";
|
||||
|
||||
const { locale } = Astro.params;
|
||||
const redirectTo = `/${locale}/admin`;
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
<html lang={locale} data-theme="cdrpj">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
<title>Acceso administrativo</title>
|
||||
</head>
|
||||
<body class="font-primary">
|
||||
<div class="min-h-screen flex">
|
||||
<div
|
||||
class="hidden lg:flex lg:w-1/2 relative flex-col justify-center items-center p-12 bg-gradient-to-br from-[#003421] to-[#22523F] text-colorPrimary overflow-hidden"
|
||||
>
|
||||
<img
|
||||
src="/img/opacity-logo.png"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="absolute inset-0 h-full w-full object-cover opacity-15 mix-blend-luminosity pointer-events-none"
|
||||
/>
|
||||
<div class="relative z-10 flex flex-col items-center">
|
||||
<div class="flex justify-center items-center gap-2 w-110 mb-10">
|
||||
<img src=/img/logo-metalico.webp alt="Logo" class="w-32" />
|
||||
<h1 class="font-secondary text-3xl xl:text-4xl font-bold text-colorSecondary mb-4 text-center">
|
||||
Centro del Reino de Paz y Justicia
|
||||
</h1>
|
||||
</div>
|
||||
<p class="text-lg text-colorPrimary/80 max-w-md text-center">
|
||||
Panel de administración — acceso restringido
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="relative overflow-hidden flex-1 flex flex-col justify-center items-center p-8 bg-colorPrimary bg-gradient-to-br from-[#003421] to-[#22523F] lg:bg-none">
|
||||
<img
|
||||
src="/img/opacity-logo.png"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="absolute inset-0 h-full w-full object-cover opacity-15 mix-blend-luminosity pointer-events-none lg:hidden"
|
||||
/>
|
||||
<div class="relative z-10 w-full max-w-sm">
|
||||
<div class="lg:hidden flex flex-col items-center mb-8">
|
||||
<img src=/img/logo-metalico.webp alt="Logo" class="w-40 mb-4" />
|
||||
</div>
|
||||
|
||||
<h2 class="font-secondary text-3xl font-bold text-colorPrimary lg:text-tertiary mb-1">
|
||||
Iniciar sesión
|
||||
</h2>
|
||||
<p class="text-colorPrimary/80 lg:text-tertiary/70 mb-8">
|
||||
Ingresa tus credenciales de administrador
|
||||
</p>
|
||||
|
||||
<form id="login-form" class="space-y-4">
|
||||
<div>
|
||||
<label for="username" class="block text-sm font-medium text-colorPrimary lg:text-tertiary mb-1">
|
||||
Usuario o correo
|
||||
</label>
|
||||
<input
|
||||
id="username"
|
||||
name="username"
|
||||
type="text"
|
||||
required
|
||||
autocomplete="username"
|
||||
class="input w-full bg-white border border-tertiary/20"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="password" class="block text-sm font-medium text-colorPrimary lg:text-tertiary mb-1">
|
||||
Contraseña
|
||||
</label>
|
||||
<div class="relative">
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
required
|
||||
autocomplete="current-password"
|
||||
class="input w-full bg-white border border-tertiary/20 pr-10"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
id="toggle-password"
|
||||
aria-label="Mostrar u ocultar contraseña"
|
||||
class="absolute right-2 top-1/2 -translate-y-1/2 text-tertiary hover:text-colorSecondary"
|
||||
>
|
||||
<Icon id="eye-open" name="ph:eye" class="w-5 h-5" />
|
||||
<Icon id="eye-closed" name="ph:eye-slash" class="w-5 h-5 hidden" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p id="error" class="text-red-300 lg:text-error text-sm hidden"></p>
|
||||
|
||||
<button type="submit" class="btn btn-primary w-full">Entrar</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script define:vars={{ redirectTo }}>
|
||||
const form = document.getElementById("login-form");
|
||||
const errorEl = document.getElementById("error");
|
||||
|
||||
const togglePassword = document.getElementById("toggle-password");
|
||||
const passwordInput = document.getElementById("password");
|
||||
const eyeOpen = document.getElementById("eye-open");
|
||||
const eyeClosed = document.getElementById("eye-closed");
|
||||
|
||||
togglePassword.addEventListener("click", () => {
|
||||
const show = passwordInput.type === "password";
|
||||
passwordInput.type = show ? "text" : "password";
|
||||
eyeOpen.classList.toggle("hidden", show);
|
||||
eyeClosed.classList.toggle("hidden", !show);
|
||||
});
|
||||
|
||||
form.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
errorEl.classList.add("hidden");
|
||||
|
||||
const username = document.getElementById("username").value;
|
||||
const password = document.getElementById("password").value;
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/admin/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
window.location.href = redirectTo;
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await res.json().catch(() => ({}));
|
||||
errorEl.textContent = data.message || "Credenciales inválidas";
|
||||
errorEl.classList.remove("hidden");
|
||||
} catch {
|
||||
errorEl.textContent = "Error de conexión";
|
||||
errorEl.classList.remove("hidden");
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
import type { APIRoute } from "astro";
|
||||
import { prisma } from "../../lib/prisma";
|
||||
import { createSession, sessionCookieFor, verifyPassword } from "../../lib/auth";
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
export const POST: APIRoute = async ({ request }) => {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const identifier = String(body?.username || body?.email || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const password = String(body?.password || "");
|
||||
|
||||
if (!identifier || !password) {
|
||||
return Response.json(
|
||||
{ success: false, message: "Credenciales requeridas" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const admin = await prisma.admin.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
{ username: { equals: identifier, mode: "insensitive" } },
|
||||
{ email: { equals: identifier, mode: "insensitive" } },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
if (
|
||||
!admin ||
|
||||
!admin.activo ||
|
||||
!admin.password_hash ||
|
||||
!verifyPassword(password, admin.password_hash)
|
||||
) {
|
||||
return Response.json(
|
||||
{ success: false, message: "Credenciales inválidas" },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
await prisma.admin.update({
|
||||
where: { id: admin.id },
|
||||
data: { ultimo_acceso: new Date() },
|
||||
});
|
||||
|
||||
const token = await createSession(admin.id);
|
||||
|
||||
return new Response(JSON.stringify({ success: true }), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Set-Cookie": sessionCookieFor(token),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error en /api/admin/auth/login:", error);
|
||||
return Response.json(
|
||||
{ success: false, message: "Error en el login" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
import type { APIRoute } from "astro";
|
||||
import { clearSessionCookie, revokeSession } from "../../lib/auth";
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
export const POST: APIRoute = async ({ request }) => {
|
||||
await revokeSession(request);
|
||||
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
headers: {
|
||||
Location: "/",
|
||||
"Set-Cookie": clearSessionCookie(),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
@ -237,6 +237,8 @@ export const GET: APIRoute = async ({ url }) => {
|
|||
page,
|
||||
limit,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
}, {
|
||||
headers: { "Cache-Control": "private, max-age=30, stale-while-revalidate=120" },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error en /api/admin/voluntarios:", error);
|
||||
|
|
|
|||
|
|
@ -86,6 +86,8 @@ export const GET: APIRoute = async ({ params }) => {
|
|||
})),
|
||||
},
|
||||
form: { sections },
|
||||
}, {
|
||||
headers: { "Cache-Control": "private, max-age=60, stale-while-revalidate=120" },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error en /api/admin/voluntarios/[numero]:", error);
|
||||
|
|
|
|||
|
|
@ -45,6 +45,8 @@ export const GET: APIRoute = async ({ url }) => {
|
|||
success: true,
|
||||
field,
|
||||
data: rows.map((r) => ({ value: r.value, count: r.count })),
|
||||
}, {
|
||||
headers: { "Cache-Control": "private, max-age=300, stale-while-revalidate=600" },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error en /api/admin/voluntarios/distinct:", error);
|
||||
|
|
|
|||
|
|
@ -45,6 +45,8 @@ export const GET: APIRoute = async () => {
|
|||
.map((f) => f.form_version)
|
||||
.filter((f): f is string => Boolean(f))
|
||||
.sort(),
|
||||
}, {
|
||||
headers: { "Cache-Control": "private, max-age=300, stale-while-revalidate=600" },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error en /api/admin/voluntarios/options:", error);
|
||||
|
|
|
|||
|
|
@ -127,6 +127,8 @@ export const GET: APIRoute = async () => {
|
|||
paises: formatPais,
|
||||
statuses: statuses.map((s) => ({ value: s.status, count: s._count._all })),
|
||||
condiciones: condicionesObj,
|
||||
}, {
|
||||
headers: { "Cache-Control": "private, max-age=60, stale-while-revalidate=300" },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error en /api/admin/voluntarios/stats:", error);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,129 @@
|
|||
import {
|
||||
createHash,
|
||||
randomBytes,
|
||||
scryptSync,
|
||||
timingSafeEqual,
|
||||
} from "node:crypto";
|
||||
import { prisma } from "./prisma";
|
||||
|
||||
const SESSION_COOKIE = "cdrdpyj_admin";
|
||||
const SESSION_TTL_MS = 8 * 60 * 60 * 1000;
|
||||
|
||||
export interface AuthSession {
|
||||
adminId: string;
|
||||
email: string;
|
||||
nombre: string;
|
||||
rol: string;
|
||||
}
|
||||
|
||||
export function sessionCookieName(): string {
|
||||
return SESSION_COOKIE;
|
||||
}
|
||||
|
||||
export function hashPassword(password: string): string {
|
||||
const salt = randomBytes(16).toString("hex");
|
||||
const hash = scryptSync(password, salt, 64).toString("hex");
|
||||
return `${salt}:${hash}`;
|
||||
}
|
||||
|
||||
export function verifyPassword(password: string, stored: string): boolean {
|
||||
const [salt, hash] = stored.split(":");
|
||||
if (!salt || !hash) return false;
|
||||
const test = scryptSync(password, salt, 64);
|
||||
const ref = Buffer.from(hash, "hex");
|
||||
return test.length === ref.length && timingSafeEqual(test, ref);
|
||||
}
|
||||
|
||||
function sha256(value: string): string {
|
||||
return createHash("sha256").update(value).digest("hex");
|
||||
}
|
||||
|
||||
function timingSafeEqualStr(a: string, b: string): boolean {
|
||||
const ab = Buffer.from(a);
|
||||
const bb = Buffer.from(b);
|
||||
return ab.length === bb.length && timingSafeEqual(ab, bb);
|
||||
}
|
||||
|
||||
export function sessionCookieFor(token: string): string {
|
||||
const secure = process.env.NODE_ENV === "production" ? "; Secure" : "";
|
||||
return `${SESSION_COOKIE}=${token}; HttpOnly; Path=/; SameSite=Lax; Max-Age=${Math.floor(
|
||||
SESSION_TTL_MS / 1000
|
||||
)}${secure}`;
|
||||
}
|
||||
|
||||
export function clearSessionCookie(): string {
|
||||
const secure = process.env.NODE_ENV === "production" ? "; Secure" : "";
|
||||
return `${SESSION_COOKIE}=; HttpOnly; Path=/; SameSite=Lax; Max-Age=0${secure}`;
|
||||
}
|
||||
|
||||
function readSessionToken(request: Request): string | null {
|
||||
const cookie = request.headers.get("cookie");
|
||||
if (!cookie) return null;
|
||||
const entry = cookie
|
||||
.split(";")
|
||||
.map((c) => c.trim())
|
||||
.find((c) => c.startsWith(`${SESSION_COOKIE}=`));
|
||||
if (!entry) return null;
|
||||
return entry.slice(SESSION_COOKIE.length + 1) || null;
|
||||
}
|
||||
|
||||
export async function createSession(adminId: string): Promise<string> {
|
||||
const token = randomBytes(32).toString("base64url");
|
||||
await prisma.session.create({
|
||||
data: {
|
||||
token_hash: sha256(token),
|
||||
admin_id: adminId,
|
||||
expires_at: new Date(Date.now() + SESSION_TTL_MS),
|
||||
},
|
||||
});
|
||||
return token;
|
||||
}
|
||||
|
||||
export async function getSession(request: Request): Promise<AuthSession | null> {
|
||||
const token = readSessionToken(request);
|
||||
if (!token) return null;
|
||||
|
||||
const session = await prisma.session.findUnique({
|
||||
where: { token_hash: sha256(token) },
|
||||
include: { admin: true },
|
||||
});
|
||||
|
||||
if (
|
||||
!session ||
|
||||
session.expires_at.getTime() <= Date.now() ||
|
||||
!session.admin.activo
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
adminId: session.admin.id,
|
||||
email: session.admin.email,
|
||||
nombre: session.admin.nombre,
|
||||
rol: session.admin.rol,
|
||||
};
|
||||
}
|
||||
|
||||
export async function revokeSession(request: Request): Promise<void> {
|
||||
const token = readSessionToken(request);
|
||||
if (!token) return;
|
||||
try {
|
||||
await prisma.session.delete({ where: { token_hash: sha256(token) } });
|
||||
} catch {
|
||||
// sesión inexistente, no hacer nada
|
||||
}
|
||||
}
|
||||
|
||||
export async function isApiAuthorized(request: Request): Promise<boolean> {
|
||||
if (await getSession(request)) return true;
|
||||
|
||||
const apiKey = process.env.ADMIN_API_KEY;
|
||||
if (!apiKey) return false;
|
||||
|
||||
const key =
|
||||
request.headers.get("x-api-key") ??
|
||||
request.headers.get("authorization")?.replace(/^Bearer\s+/i, "");
|
||||
if (!key) return false;
|
||||
|
||||
return timingSafeEqualStr(key, apiKey);
|
||||
}
|
||||
|
|
@ -80,6 +80,11 @@ export async function sendEmailCf(data: Record<string, string>, to: string, type
|
|||
const apiToken = process.env.CLOUDFLARE_API_TOKEN;
|
||||
const accountId = process.env.CLOUDFLARE_ACCOUNT_ID;
|
||||
|
||||
if (!apiToken || !accountId) {
|
||||
await sendEmail(data, to, type);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const client = new Cloudflare({ apiToken });
|
||||
const { subject } = TEMPLATE_URLS[type];
|
||||
|
|
|
|||
Loading…
Reference in New Issue