140 lines
4.2 KiB
TypeScript
140 lines
4.2 KiB
TypeScript
import "server-only";
|
|
|
|
import { mkdir, writeFile } from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { and, eq, max } from "drizzle-orm";
|
|
import { db, User, UsersBadges } from "@/lib/db";
|
|
import { toBadgeGif } from "@/lib/images/badge-gif";
|
|
import { rcon } from "@/lib/services/rcon";
|
|
import {
|
|
notFound,
|
|
positiveInteger,
|
|
record,
|
|
text,
|
|
validation,
|
|
} from "./mutation-runtime-input";
|
|
import type {
|
|
EconomyMutationContext,
|
|
EconomyMutationOperation,
|
|
EconomyMutationSnapshot,
|
|
} from "./mutations";
|
|
import { EconomyCommittedExternalFailure } from "./mutations-production";
|
|
|
|
const BADGE_CODE = /^[A-Za-z0-9_-]{1,32}$/u;
|
|
const BADGE_TYPES = new Set(["image/gif", "image/png"]);
|
|
const MAX_BADGE_BYTES = 1024 * 1024;
|
|
|
|
async function badgeGive(input: unknown): Promise<EconomyMutationSnapshot> {
|
|
const data = record(input);
|
|
const code = text(data.badge ?? data.code, 32, true);
|
|
if (!BADGE_CODE.test(code))
|
|
throw validation({ badge: ["invalid badge code"] });
|
|
let userId: number;
|
|
let username: string;
|
|
if (data.userId) {
|
|
userId = positiveInteger(data.userId);
|
|
const [user] = await db
|
|
.select({ id: User.id, username: User.username })
|
|
.from(User)
|
|
.where(eq(User.id, userId))
|
|
.limit(1);
|
|
if (!user) throw notFound();
|
|
username = user.username;
|
|
} else {
|
|
username = text(data.username, 25, true);
|
|
const [user] = await db
|
|
.select({ id: User.id, username: User.username })
|
|
.from(User)
|
|
.where(eq(User.username, username))
|
|
.limit(1);
|
|
if (!user) throw notFound();
|
|
userId = user.id;
|
|
}
|
|
|
|
const inserted = await db.transaction(async (transaction) => {
|
|
const [existing] = await transaction
|
|
.select({ id: UsersBadges.id, slotId: UsersBadges.slotId })
|
|
.from(UsersBadges)
|
|
.where(
|
|
and(eq(UsersBadges.userId, userId), eq(UsersBadges.badgeCode, code)),
|
|
)
|
|
.limit(1);
|
|
if (existing) return false;
|
|
const [aggregate] = await transaction
|
|
.select({ maxSlot: max(UsersBadges.slotId) })
|
|
.from(UsersBadges)
|
|
.where(eq(UsersBadges.userId, userId));
|
|
await transaction.insert(UsersBadges).values({
|
|
userId,
|
|
slotId: (aggregate?.maxSlot ?? 0) + 1,
|
|
badgeCode: code,
|
|
});
|
|
return true;
|
|
});
|
|
const snapshot: EconomyMutationSnapshot = {
|
|
before: { userId: String(userId), badge: code, present: !inserted },
|
|
after: { userId: String(userId), username, badge: code, present: true },
|
|
output: { userId: String(userId), badge: code, inserted },
|
|
};
|
|
try {
|
|
const delivered = await rcon.giveBadge(userId, code);
|
|
if (!delivered) throw new Error("badge grant rejected");
|
|
return snapshot;
|
|
} catch {
|
|
throw new EconomyCommittedExternalFailure(snapshot);
|
|
}
|
|
}
|
|
|
|
async function badgeUpload(input: unknown): Promise<EconomyMutationSnapshot> {
|
|
const data = record(input);
|
|
const code = text(data.code, 32, true);
|
|
if (!BADGE_CODE.test(code))
|
|
throw validation({ code: ["invalid badge code"] });
|
|
const file = data.file;
|
|
if (!(file instanceof File)) throw validation({ file: ["file required"] });
|
|
if (
|
|
file.size <= 0 ||
|
|
file.size > MAX_BADGE_BYTES ||
|
|
!BADGE_TYPES.has(file.type)
|
|
) {
|
|
throw validation({ file: ["GIF or PNG up to 1 MB required"] });
|
|
}
|
|
const configuredDirectory = process.env.BADGE_UPLOAD_DIR;
|
|
if (!configuredDirectory) {
|
|
throw new Error("badge upload directory unavailable");
|
|
}
|
|
const baseDirectory = path.resolve(configuredDirectory);
|
|
const target = path.resolve(baseDirectory, `${code}.gif`);
|
|
if (!target.startsWith(baseDirectory + path.sep)) throw validation();
|
|
const gif = await toBadgeGif(Buffer.from(await file.arrayBuffer()));
|
|
await mkdir(baseDirectory, { recursive: true });
|
|
await writeFile(target, gif);
|
|
return {
|
|
before: null,
|
|
after: { code, filename: `${code}.gif`, size: gif.byteLength },
|
|
output: { code },
|
|
};
|
|
}
|
|
|
|
const HANDLERS: Partial<
|
|
Record<
|
|
EconomyMutationOperation,
|
|
(
|
|
input: unknown,
|
|
context: EconomyMutationContext,
|
|
) => Promise<EconomyMutationSnapshot>
|
|
>
|
|
> = {
|
|
"badge.give": (input) => badgeGive(input),
|
|
"badge.upload": (input) => badgeUpload(input),
|
|
};
|
|
|
|
export async function executeEconomyRewardsMutation(
|
|
operation: EconomyMutationOperation,
|
|
input: unknown,
|
|
context: EconomyMutationContext,
|
|
): Promise<EconomyMutationSnapshot | null> {
|
|
const handler = HANDLERS[operation];
|
|
return handler ? handler(input, context) : null;
|
|
}
|