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 { 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 }).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"); const token = randomBytes(32).toString("base64url"); const digest = digestToken(token); const redis = await getRedisClient(); await redis.set(tokenKey(digest), JSON.stringify({ checkoutId, userId }), "EX", TTL_SECONDS); await redis.set(activeKey(checkoutId), digest, "EX", TTL_SECONDS); return { token, digest, expiresAt: Date.now() + TTL_SECONDS * 1000 }; } 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); 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 }).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 (!/^[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)); }