Harden publication and transactional flows

codex/redisenio-dominio-musical
dev 4 weeks ago
parent 5889fad361
commit 3bcf92b638

@ -0,0 +1,47 @@
-- Un usuario solo puede tener un carrito. Antes había únicamente un índice no
-- único y dos primeras peticiones simultáneas podían crear dos carritos.
--
-- La migración conserva el carrito más antiguo de cada usuario, copia en él
-- los elementos de los demás y elimina los duplicados antes de crear la guarda.
WITH canonicos AS (
SELECT usuario_id, min(creado_en) AS primera_fecha
FROM carrito
WHERE usuario_id IS NOT NULL
GROUP BY usuario_id
), elegidos AS (
SELECT c.usuario_id, min(c.id) AS carrito_id
FROM carrito c
INNER JOIN canonicos x
ON x.usuario_id = c.usuario_id AND x.primera_fecha = c.creado_en
GROUP BY c.usuario_id
)
INSERT INTO carrito_item (carrito_id, cancion_id, anadido_en)
SELECT e.carrito_id, ci.cancion_id, min(ci.anadido_en)
FROM carrito c
INNER JOIN elegidos e ON e.usuario_id = c.usuario_id
INNER JOIN carrito_item ci ON ci.carrito_id = c.id
WHERE c.id <> e.carrito_id
GROUP BY e.carrito_id, ci.cancion_id
ON CONFLICT (carrito_id, cancion_id) DO NOTHING;--> statement-breakpoint
WITH canonicos AS (
SELECT usuario_id, min(creado_en) AS primera_fecha
FROM carrito
WHERE usuario_id IS NOT NULL
GROUP BY usuario_id
), elegidos AS (
SELECT c.usuario_id, min(c.id) AS carrito_id
FROM carrito c
INNER JOIN canonicos x
ON x.usuario_id = c.usuario_id AND x.primera_fecha = c.creado_en
GROUP BY c.usuario_id
)
DELETE FROM carrito c
USING elegidos e
WHERE c.usuario_id = e.usuario_id AND c.id <> e.carrito_id;--> statement-breakpoint
DROP INDEX IF EXISTS "carrito_usuario";--> statement-breakpoint
CREATE UNIQUE INDEX "carrito_usuario_unico"
ON "carrito" ("usuario_id")
WHERE "usuario_id" IS NOT NULL;

@ -11,7 +11,7 @@
*/
import { createHash, randomBytes, randomInt, timingSafeEqual } from 'node:crypto';
import { and, desc, eq, gt, isNull } from 'drizzle-orm';
import { and, desc, eq, gt, isNull, sql } from 'drizzle-orm';
import type { Cookies } from '@sveltejs/kit';
import { db } from './db';
import { codigoAcceso, sesion, usuario, type Usuario } from './db/schema';
@ -66,15 +66,21 @@ export async function crearCodigoDeAcceso(email: string): Promise<string> {
const destino = normalizarEmail(email);
const codigo = generarCodigo();
await db
.update(codigoAcceso)
.set({ usadoEn: new Date() })
.where(and(eq(codigoAcceso.email, destino), isNull(codigoAcceso.usadoEn)));
await db.transaction(async (tx) => {
// Emisión y consumo se serializan por correo, incluso cuando todavía no
// existe ninguna fila que se pueda bloquear.
await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${destino}, 0))`);
await db.insert(codigoAcceso).values({
email: destino,
hash: hashear(codigo),
expiraEn: new Date(Date.now() + MINUTOS_CODIGO * 60_000)
await tx
.update(codigoAcceso)
.set({ usadoEn: new Date() })
.where(and(eq(codigoAcceso.email, destino), isNull(codigoAcceso.usadoEn)));
await tx.insert(codigoAcceso).values({
email: destino,
hash: hashear(codigo),
expiraEn: new Date(Date.now() + MINUTOS_CODIGO * 60_000)
});
});
return codigo;
@ -93,57 +99,49 @@ export async function verificarCodigo(
): Promise<ResultadoVerificacion> {
const destino = normalizarEmail(email);
const [registro] = await db
.select()
.from(codigoAcceso)
.where(
and(
eq(codigoAcceso.email, destino),
isNull(codigoAcceso.usadoEn),
gt(codigoAcceso.expiraEn, new Date())
return db.transaction(async (tx) => {
await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${destino}, 0))`);
const [registro] = await tx
.select()
.from(codigoAcceso)
.where(
and(
eq(codigoAcceso.email, destino),
isNull(codigoAcceso.usadoEn),
gt(codigoAcceso.expiraEn, new Date())
)
)
)
// El mas reciente primero: crear uno nuevo invalida los anteriores,
// pero el orden explicito evita depender de ese detalle.
.orderBy(desc(codigoAcceso.creadoEn))
.limit(1);
if (!registro) return { ok: false, motivo: 'caducado' };
if (!mismoHash(registro.hash, hashear(codigo.trim()))) {
const intentos = registro.intentos + 1;
const agotado = intentos >= MAX_INTENTOS;
await db
// El más reciente primero: crear uno nuevo invalida los anteriores.
.orderBy(desc(codigoAcceso.creadoEn))
.limit(1);
if (!registro) return { ok: false, motivo: 'caducado' };
if (!mismoHash(registro.hash, hashear(codigo.trim()))) {
const intentos = registro.intentos + 1;
const agotado = intentos >= MAX_INTENTOS;
await tx
.update(codigoAcceso)
.set({ intentos, usadoEn: agotado ? new Date() : null })
.where(eq(codigoAcceso.id, registro.id));
return { ok: false, motivo: agotado ? 'agotado' : 'incorrecto' };
}
await tx
.update(codigoAcceso)
.set({ intentos, usadoEn: agotado ? new Date() : null })
.where(eq(codigoAcceso.id, registro.id));
return { ok: false, motivo: agotado ? 'agotado' : 'incorrecto' };
}
.set({ usadoEn: new Date() })
.where(and(eq(codigoAcceso.id, registro.id), isNull(codigoAcceso.usadoEn)));
await db
.update(codigoAcceso)
.set({ usadoEn: new Date() })
.where(eq(codigoAcceso.id, registro.id));
const ahora = new Date();
const [cuenta] = await tx
.insert(usuario)
.values({ email: destino, ultimoAccesoEn: ahora })
.onConflictDoUpdate({ target: usuario.email, set: { ultimoAccesoEn: ahora } })
.returning();
return { ok: true, usuario: await obtenerOCrearUsuario(destino) };
}
async function obtenerOCrearUsuario(email: string): Promise<Usuario> {
const [existente] = await db.select().from(usuario).where(eq(usuario.email, email)).limit(1);
if (existente) {
await db
.update(usuario)
.set({ ultimoAccesoEn: new Date() })
.where(eq(usuario.id, existente.id));
return { ...existente, ultimoAccesoEn: new Date() };
}
const [creado] = await db
.insert(usuario)
.values({ email, ultimoAccesoEn: new Date() })
.returning();
return creado;
return { ok: true, usuario: cuenta };
});
}
/* -------------------------------------------------------------------------

@ -11,7 +11,7 @@
* direcciones— se habla de slugs; en la base se guarda identidad.
*/
import { eq, inArray } from 'drizzle-orm';
import { and, eq, inArray, isNull } from 'drizzle-orm';
import { db } from './db';
import { cancion } from './db/schema';
@ -27,7 +27,9 @@ export async function idDeCancion(slug: string): Promise<string | null> {
const [fila] = await db
.select({ id: cancion.id })
.from(cancion)
.where(eq(cancion.slug, slug))
.where(
and(eq(cancion.slug, slug), eq(cancion.estado, 'publicado'), isNull(cancion.versionDeId))
)
.limit(1);
return fila?.id ?? null;
}
@ -45,7 +47,13 @@ export async function idsDeCanciones(slugs: string[]): Promise<Map<string, strin
const filas = await db
.select({ id: cancion.id, slug: cancion.slug })
.from(cancion)
.where(inArray(cancion.slug, slugs));
.where(
and(
inArray(cancion.slug, slugs),
eq(cancion.estado, 'publicado'),
isNull(cancion.versionDeId)
)
);
return new Map(filas.map((f) => [f.slug, f.id]));
}

@ -45,8 +45,21 @@ async function carritoDeUsuario(usuarioId: string): Promise<string> {
.limit(1);
if (existente) return existente.id;
const [creado] = await db.insert(carrito).values({ usuarioId }).returning({ id: carrito.id });
return creado.id;
const [creado] = await db
.insert(carrito)
.values({ usuarioId })
.onConflictDoNothing()
.returning({ id: carrito.id });
if (creado) return creado.id;
// Otra petición creó el carrito después de nuestro primer SELECT.
const [ganador] = await db
.select({ id: carrito.id })
.from(carrito)
.where(eq(carrito.usuarioId, usuarioId))
.limit(1);
if (!ganador) throw new Error('No se pudo crear ni recuperar el carrito de la cuenta.');
return ganador.id;
}
/**
@ -148,20 +161,22 @@ export async function fusionarCarrito(cookies: Cookies, usuarioId: string): Prom
const destino = await carritoDeUsuario(usuarioId);
const origen = cookies.get(COOKIE_CARRITO);
if (origen && origen !== destino && (await existe(origen))) {
const items = await db
.select({ cancionId: carritoItem.cancionId })
.from(carritoItem)
.where(eq(carritoItem.carritoId, origen));
if (items.length > 0) {
await db
.insert(carritoItem)
.values(items.map((i) => ({ carritoId: destino, cancionId: i.cancionId })))
.onConflictDoNothing();
}
await db.delete(carrito).where(eq(carrito.id, origen));
if (origen && origen !== destino) {
await db.transaction(async (tx) => {
const items = await tx
.select({ cancionId: carritoItem.cancionId })
.from(carritoItem)
.where(eq(carritoItem.carritoId, origen));
if (items.length > 0) {
await tx
.insert(carritoItem)
.values(items.map((i) => ({ carritoId: destino, cancionId: i.cancionId })))
.onConflictDoNothing();
}
await tx.delete(carrito).where(eq(carrito.id, origen));
});
}
guardarCookie(cookies, destino);

@ -24,7 +24,7 @@
* haya, esto tendrá que invalidarse al guardar, no por reloj.
*/
import { asc, eq } from 'drizzle-orm';
import { and, asc, eq, isNull, or } from 'drizzle-orm';
import { alias } from 'drizzle-orm/pg-core';
import { db } from './db';
import {
@ -137,7 +137,11 @@ async function cargar(): Promise<Catalogo> {
creditos,
generosDeCancion
] = await Promise.all([
db.select().from(estilo).orderBy(asc(estilo.orden), asc(estilo.nombre)),
db
.select()
.from(estilo)
.where(eq(estilo.estado, 'publicado'))
.orderBy(asc(estilo.orden), asc(estilo.nombre)),
db
.select({
@ -146,10 +150,12 @@ async function cargar(): Promise<Catalogo> {
paisNombre: pais.nombre
})
.from(album)
.innerJoin(estilo, eq(estilo.id, album.estiloId))
.leftJoin(portadaAlbum, eq(portadaAlbum.id, album.portadaId))
// El archivo decía «España» y la base guarda «ES»: el nombre sale de
// la tabla de países, que es donde vive una sola vez.
.leftJoin(pais, eq(pais.codigo, album.paisEdicionCodigo))
.where(and(eq(album.estado, 'publicado'), eq(estilo.estado, 'publicado')))
.orderBy(asc(album.fecha)),
db
@ -166,6 +172,13 @@ async function cargar(): Promise<Catalogo> {
.leftJoin(album, eq(album.id, cancion.albumId))
.leftJoin(audio, eq(audio.id, cancion.audioId))
.leftJoin(portadaCancion, eq(portadaCancion.id, cancion.portadaId))
.where(
and(
eq(cancion.estado, 'publicado'),
eq(estilo.estado, 'publicado'),
or(isNull(cancion.albumId), eq(album.estado, 'publicado'))
)
)
.orderBy(asc(cancion.numero), asc(cancion.titulo)),
db
@ -460,7 +473,7 @@ export async function letraDeCancion(slug: string): Promise<string | null> {
const [fila] = await db
.select({ letra: cancion.letra })
.from(cancion)
.where(eq(cancion.slug, slug))
.where(and(eq(cancion.slug, slug), eq(cancion.estado, 'publicado')))
.limit(1);
return fila?.letra ?? null;
}
@ -476,7 +489,7 @@ export async function textoDeEstilo(slug: string): Promise<string> {
const [fila] = await db
.select({ cuerpo: estilo.cuerpo })
.from(estilo)
.where(eq(estilo.slug, slug))
.where(and(eq(estilo.slug, slug), eq(estilo.estado, 'publicado')))
.limit(1);
return componer(fila?.cuerpo);
}
@ -486,7 +499,7 @@ export async function notasDeAlbum(slug: string): Promise<string> {
const [fila] = await db
.select({ cuerpo: album.cuerpo })
.from(album)
.where(eq(album.slug, slug))
.where(and(eq(album.slug, slug), eq(album.estado, 'publicado')))
.limit(1);
return componer(fila?.cuerpo);
}

@ -17,7 +17,8 @@
* significan nada.
*/
import { index, integer, pgTable, primaryKey, text } from 'drizzle-orm/pg-core';
import { sql } from 'drizzle-orm';
import { index, integer, pgTable, primaryKey, text, uniqueIndex } from 'drizzle-orm/pg-core';
import { ahora, id, instante } from './comun';
import { cancion } from './catalogo';
import { usuario } from './cuentas';
@ -39,7 +40,11 @@ export const carrito = pgTable(
creadoEn: ahora('creado_en'),
actualizadoEn: ahora('actualizado_en')
},
(t) => [index('carrito_usuario').on(t.usuarioId)]
(t) => [
uniqueIndex('carrito_usuario_unico')
.on(t.usuarioId)
.where(sql`${t.usuarioId} is not null`)
]
);
export const carritoItem = pgTable(

@ -0,0 +1,33 @@
/** Registro auditable del inicio de una descarga autorizada. */
import { eq } from 'drizzle-orm';
import { db } from './db';
import { cancion, descarga } from './db/schema';
export interface EntregaIniciada {
usuarioId: string;
cancionSlug: string;
ip: string | null;
agente: string | null;
}
/**
* Registra que el servidor ha empezado a entregar el archivo. No afirma que el
* navegador haya terminado de guardarlo: HTTP no proporciona ese acuse.
*/
export async function registrarEntregaIniciada(datos: EntregaIniciada): Promise<void> {
const [tema] = await db
.select({ id: cancion.id })
.from(cancion)
.where(eq(cancion.slug, datos.cancionSlug))
.limit(1);
if (!tema) throw new Error(`No existe la canción descargada «${datos.cancionSlug}».`);
await db.insert(descarga).values({
usuarioId: datos.usuarioId,
cancionId: tema.id,
ip: datos.ip,
agente: datos.agente
});
}

@ -16,7 +16,7 @@
* montar una base de datos.
*/
import { eq, lt } from 'drizzle-orm';
import { lt, sql } from 'drizzle-orm';
import { db } from './db';
import { limite } from './db/schema';
@ -94,22 +94,31 @@ export async function registrarIntento(
opciones: OpcionesLimite,
ahora: number = Date.now()
): Promise<ResultadoLimite> {
return db.transaction(async (tx) => {
const [fila] = await tx.select().from(limite).where(eq(limite.clave, clave)).limit(1);
const { resultado, siguiente } = decidirLimite(
fila ? { intentos: fila.intentos, expiraEn: fila.expiraEn } : null,
opciones,
ahora
);
await tx
.insert(limite)
.values({ clave, ...siguiente })
.onConflictDoUpdate({ target: limite.clave, set: siguiente });
return resultado;
});
const nuevaExpiracion = ahora + opciones.ventanaMs;
const filas = await db.execute<{ intentos: number; expiraEn: number }>(sql`
insert into limite (clave, intentos, expira_en)
values (${clave}, 1, ${nuevaExpiracion})
on conflict (clave) do update set
intentos = case
when limite.expira_en <= ${ahora} then 1
else limite.intentos + 1
end,
expira_en = case
when limite.expira_en <= ${ahora} then ${nuevaExpiracion}
else limite.expira_en
end
returning intentos, expira_en as "expiraEn"
`);
const estado = filas.rows[0];
if (!estado) throw new Error(`No se pudo registrar el límite «${clave}».`);
const permitido = estado.intentos <= opciones.maximo;
return {
permitido,
restantes: permitido ? opciones.maximo - estado.intentos : 0,
esperaSegundos: permitido ? 0 : Math.max(0, Math.ceil((estado.expiraEn - ahora) / 1000))
};
}
/**

@ -73,28 +73,30 @@ export async function crearPedido(datos: DatosDePedido): Promise<string> {
const perdidos = datos.items.filter((i) => !ids.has(i.slug)).map((i) => i.slug);
if (perdidos.length > 0) throw new TemaDesconocido(perdidos);
const [creado] = await db
.insert(pedido)
.values({
usuarioId: datos.usuarioId,
sesionPagoId: datos.sesionPagoId,
packs: datos.packs,
totalCentimos: datos.totalCentimos,
moneda: MONEDA
})
.returning({ id: pedido.id });
if (datos.items.length > 0) {
await db.insert(pedidoItem).values(
datos.items.map((item) => ({
pedidoId: creado.id,
cancionId: ids.get(item.slug)!,
titulo: item.titulo
}))
);
}
return db.transaction(async (tx) => {
const [creado] = await tx
.insert(pedido)
.values({
usuarioId: datos.usuarioId,
sesionPagoId: datos.sesionPagoId,
packs: datos.packs,
totalCentimos: datos.totalCentimos,
moneda: MONEDA
})
.returning({ id: pedido.id });
if (datos.items.length > 0) {
await tx.insert(pedidoItem).values(
datos.items.map((item) => ({
pedidoId: creado.id,
cancionId: ids.get(item.slug)!,
titulo: item.titulo
}))
);
}
return creado.id;
return creado.id;
});
}
/**
@ -105,26 +107,20 @@ export async function crearPedido(datos: DatosDePedido): Promise<string> {
* nada, que es justo lo que necesita un webhook que puede repetirse.
*/
export async function finalizarPedido(sesionPagoId: string): Promise<boolean> {
const [registro] = await db
.select()
.from(pedido)
.where(eq(pedido.sesionPagoId, sesionPagoId))
.limit(1);
if (!registro || registro.estado === 'pagado') return false;
const items = await db
.select({ cancionId: pedidoItem.cancionId })
.from(pedidoItem)
.where(eq(pedidoItem.pedidoId, registro.id));
const comprados = items.map((i) => i.cancionId);
await db.transaction(async (tx) => {
await tx
return db.transaction(async (tx) => {
const [registro] = await tx
.update(pedido)
.set({ estado: 'pagado', pagadoEn: new Date() })
.where(eq(pedido.id, registro.id));
.where(and(eq(pedido.sesionPagoId, sesionPagoId), eq(pedido.estado, 'pendiente')))
.returning();
if (!registro) return false;
const items = await tx
.select({ cancionId: pedidoItem.cancionId })
.from(pedidoItem)
.where(eq(pedidoItem.pedidoId, registro.id));
const comprados = items.map((i) => i.cancionId);
if (comprados.length > 0) {
await tx
@ -153,9 +149,9 @@ export async function finalizarPedido(sesionPagoId: string): Promise<boolean> {
);
}
}
});
return true;
return true;
});
}
/** Marca un pedido como cancelado si seguia pendiente. */

@ -3,6 +3,7 @@ import { extname } from 'node:path';
import { obtenerCancion } from '$lib/server/catalogo';
import { respuestaDeDescarga } from '$lib/server/medios';
import { tieneCompra } from '$lib/server/pedidos';
import { registrarEntregaIniciada } from '$lib/server/descargas';
import type { RequestHandler } from './$types';
/**
@ -13,7 +14,7 @@ import type { RequestHandler } from './$types';
* tiene la sesión delante, y una URL firmada solo añadiría una copia del
* permiso que puede quedarse obsoleta.
*/
export const GET: RequestHandler = async ({ params, locals }) => {
export const GET: RequestHandler = async ({ params, locals, request, getClientAddress }) => {
if (!locals.usuario) {
error(401, 'Entra en tu cuenta para descargar este tema.');
}
@ -43,5 +44,12 @@ export const GET: RequestHandler = async ({ params, locals }) => {
const respuesta = await respuestaDeDescarga(clave, nombre);
if (!respuesta) error(404, 'El archivo no está disponible ahora mismo.');
await registrarEntregaIniciada({
usuarioId: locals.usuario.id,
cancionSlug: cancion.slug,
ip: getClientAddress(),
agente: request.headers.get('user-agent')
});
return respuesta;
};

Loading…
Cancel
Save

Powered by TurnKey Linux.