88 lines
4.9 KiB
TypeScript
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));
|
|
}
|