Files

88 lines
4.9 KiB
TypeScript

import "server-only";
import { createHash, randomBytes } from "node:crypto";
import { and, eq } from "drizzle-orm";
import { getDb } from "@/db";
import { commissionCheckouts, commissionTickets } from "@/db/schema";
import { checkoutExpired, checkoutExpiresAt } from "@/lib/commission/checkout-expiration";
import { getRedisClient, redisEventChannel } from "@/lib/redis/client";
import { HttpError } from "@/lib/security/http";
const TTL_SECONDS = 600;
const prefix = () => `${process.env.REDIS_SECURITY_PREFIX || "buzz:security"}:commission-mobile-slip`;
const activeKey = (checkoutId: string) => `${prefix()}:active:${checkoutId}`;
const tokenKey = (digest: string) => `${prefix()}:token:${digest}`;
const errorKey = (digest: string) => `${prefix()}:error:${digest}`;
const digestToken = (token: string) => createHash("sha256").update(token).digest("hex");
export async function createMobileSlipLink(checkoutId: string, userId: string) {
const [checkout] = await getDb().select({ id: commissionCheckouts.id, createdAt: commissionCheckouts.createdAt }).from(commissionCheckouts)
.where(and(eq(commissionCheckouts.id, checkoutId), eq(commissionCheckouts.userId, userId))).limit(1);
if (!checkout) throw new HttpError(404, "checkout-not-found");
const [ticket] = await getDb().select({ id: commissionTickets.id }).from(commissionTickets)
.where(eq(commissionTickets.checkoutId, checkoutId)).limit(1);
if (ticket) throw new HttpError(409, "checkout-already-paid");
if (checkoutExpired(checkout.createdAt)) throw new HttpError(410, "checkout-expired");
const token = randomBytes(32).toString("base64url");
const digest = digestToken(token);
const redis = await getRedisClient();
const expiresAt = Math.min(Date.now() + TTL_SECONDS * 1000, checkoutExpiresAt(checkout.createdAt));
const ttl = Math.max(1, Math.ceil((expiresAt - Date.now()) / 1000));
await redis.set(tokenKey(digest), JSON.stringify({ checkoutId, userId }), "EX", ttl);
await redis.set(activeKey(checkoutId), digest, "EX", ttl);
return { token, digest, expiresAt };
}
export async function authorizeMobileSlip(token: string) {
if (!/^[A-Za-z0-9_-]{43}$/.test(token)) throw new HttpError(404, "upload-link-invalid");
const digest = digestToken(token);
const redis = await getRedisClient();
const raw = await redis.get(tokenKey(digest));
if (!raw) throw new HttpError(404, "upload-link-expired");
const { checkoutId, userId } = JSON.parse(raw) as { checkoutId: string; userId: string };
if (await redis.get(activeKey(checkoutId)) !== digest)
throw new HttpError(404, "upload-link-expired");
const [checkout] = await getDb().select().from(commissionCheckouts)
.where(and(eq(commissionCheckouts.id, checkoutId), eq(commissionCheckouts.userId, userId))).limit(1);
if (!checkout) throw new HttpError(404, "checkout-not-found");
const [ticket] = await getDb().select({ id: commissionTickets.id }).from(commissionTickets)
.where(eq(commissionTickets.checkoutId, checkoutId)).limit(1);
if (!ticket && checkoutExpired(checkout.createdAt)) throw new HttpError(410, "checkout-expired");
return { checkout, ticketId: ticket?.id ?? null, digest };
}
export async function mobileSlipStatus(checkoutId: string, userId: string, digest: string) {
const [checkout] = await getDb().select({ id: commissionCheckouts.id, createdAt: commissionCheckouts.createdAt }).from(commissionCheckouts)
.where(and(eq(commissionCheckouts.id, checkoutId), eq(commissionCheckouts.userId, userId))).limit(1);
if (!checkout) throw new HttpError(404, "checkout-not-found");
const [ticket] = await getDb().select({ id: commissionTickets.id }).from(commissionTickets)
.where(eq(commissionTickets.checkoutId, checkoutId)).limit(1);
if (ticket) return { state: "complete", ticketId: ticket.id };
if (checkoutExpired(checkout.createdAt)) return { state: "expired" };
if (!/^[a-f0-9]{64}$/.test(digest)) throw new HttpError(400, "invalid-upload-link");
const redis = await getRedisClient();
if (await redis.get(activeKey(checkoutId)) !== digest) return { state: "expired" };
return { state: "pending", error: await redis.get(errorKey(digest)) };
}
export async function recordMobileSlipError(digest: string, userId: string, cause: unknown) {
const code = cause instanceof HttpError ? cause.message : "service-unavailable";
const redis = await getRedisClient();
const ttl = await redis.ttl(tokenKey(digest));
if (ttl > 0) {
await redis.set(errorKey(digest), code, "EX", ttl);
await redis.publish(redisEventChannel(`commission:user:${userId}`), "changed");
}
}
export async function consumeMobileSlipLink(checkoutId: string, digest: string) {
const redis = await getRedisClient();
await redis.eval("if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('DEL', KEYS[1]) end return 0", 1,
activeKey(checkoutId), digest);
}
export async function invalidateMobileSlipLink(checkoutId: string) {
await (await getRedisClient()).del(activeKey(checkoutId));
}