diff --git a/src/app/api/arma/v1/commands/ack/route.ts b/src/app/api/arma/v1/commands/ack/route.ts new file mode 100644 index 0000000..50f2af5 --- /dev/null +++ b/src/app/api/arma/v1/commands/ack/route.ts @@ -0,0 +1,23 @@ +import config from "@payload-config"; +import { getPayload } from "payload"; +import { NextRequest, NextResponse } from "next/server"; +import { authenticateArmaBridge } from "@/lib/arma-bridge/auth"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function POST(req: NextRequest) { + const serverId = authenticateArmaBridge(req); + if (serverId instanceof NextResponse) return serverId; + const body = await req.json().catch(() => null) as { id?: unknown; success?: unknown; result?: unknown; error?: unknown } | null; + if (!body || typeof body.id !== "string" || typeof body.success !== "boolean") return NextResponse.json({ error: "id and success are required." }, { status: 400 }); + const payload = await getPayload({ config }); + const servers = await payload.find({ collection: "game-servers", where: { serverId: { equals: serverId } }, limit: 1, overrideAccess: true }); + const server = servers.docs[0]; + if (!server) return NextResponse.json({ error: "Heartbeat is required first." }, { status: 409 }); + const commands = await payload.find({ collection: "arma-commands", where: { and: [{ commandId: { equals: body.id } }, { server: { equals: server.id } }] }, limit: 1, overrideAccess: true }); + const command = commands.docs[0]; + if (!command) return NextResponse.json({ error: "Command not found." }, { status: 404 }); + await payload.update({ collection: "arma-commands", id: command.id, data: { status: body.success ? "succeeded" : "failed", result: body.result ?? {}, error: typeof body.error === "string" ? body.error : undefined, completedAt: new Date().toISOString() }, overrideAccess: true }); + return NextResponse.json({ ok: true }); +} diff --git a/src/app/api/arma/v1/commands/pull/route.ts b/src/app/api/arma/v1/commands/pull/route.ts new file mode 100644 index 0000000..9eb0bec --- /dev/null +++ b/src/app/api/arma/v1/commands/pull/route.ts @@ -0,0 +1,20 @@ +import config from "@payload-config"; +import { getPayload } from "payload"; +import { NextRequest, NextResponse } from "next/server"; +import { authenticateArmaBridge } from "@/lib/arma-bridge/auth"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function POST(req: NextRequest) { + const serverId = authenticateArmaBridge(req); + if (serverId instanceof NextResponse) return serverId; + const payload = await getPayload({ config }); + const servers = await payload.find({ collection: "game-servers", where: { serverId: { equals: serverId } }, limit: 1, overrideAccess: true }); + const server = servers.docs[0]; + if (!server) return NextResponse.json({ error: "Heartbeat is required first." }, { status: 409 }); + const commands = await payload.find({ collection: "arma-commands", where: { and: [{ server: { equals: server.id } }, { status: { equals: "queued" } }] }, limit: 20, sort: "createdAt", overrideAccess: true }); + const deliveredAt = new Date().toISOString(); + await Promise.all(commands.docs.map((command) => payload.update({ collection: "arma-commands", id: command.id, data: { status: "delivered", deliveredAt }, overrideAccess: true }))); + return NextResponse.json({ commands: commands.docs.map((command) => ({ id: command.commandId, type: command.type, payload: command.payload })) }); +} diff --git a/src/app/api/arma/v1/events/route.ts b/src/app/api/arma/v1/events/route.ts new file mode 100644 index 0000000..88c77e1 --- /dev/null +++ b/src/app/api/arma/v1/events/route.ts @@ -0,0 +1,30 @@ +import config from "@payload-config"; +import { getPayload } from "payload"; +import { NextRequest, NextResponse } from "next/server"; +import { authenticateArmaBridge } from "@/lib/arma-bridge/auth"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function POST(req: NextRequest) { + const serverId = authenticateArmaBridge(req); + if (serverId instanceof NextResponse) return serverId; + const body = await req.json().catch(() => null) as { events?: unknown[] } | null; + if (!body?.events || !Array.isArray(body.events) || body.events.length > 100) return NextResponse.json({ error: "events must contain at most 100 entries." }, { status: 400 }); + const payload = await getPayload({ config }); + const servers = await payload.find({ collection: "game-servers", where: { serverId: { equals: serverId } }, limit: 1, overrideAccess: true }); + const server = servers.docs[0]; + if (!server) return NextResponse.json({ error: "Heartbeat is required first." }, { status: 409 }); + let accepted = 0; + let duplicates = 0; + for (const rawEvent of body.events) { + const event = rawEvent as Record; + if (!event || typeof event.id !== "string" || typeof event.type !== "string") continue; + const eventId = `${serverId}:${event.id}`; + const existing = await payload.find({ collection: "arma-sync-events", where: { eventId: { equals: eventId } }, limit: 1, overrideAccess: true }); + if (existing.docs.length) { duplicates++; continue; } + await payload.create({ collection: "arma-sync-events", data: { eventId, server: server.id, type: event.type, occurredAt: typeof event.occurredAt === "string" ? event.occurredAt : new Date().toISOString(), payload: event.payload ?? {} }, overrideAccess: true }); + accepted++; + } + return NextResponse.json({ ok: true, accepted, duplicates }); +} diff --git a/src/app/api/arma/v1/heartbeat/route.ts b/src/app/api/arma/v1/heartbeat/route.ts new file mode 100644 index 0000000..6fe6eeb --- /dev/null +++ b/src/app/api/arma/v1/heartbeat/route.ts @@ -0,0 +1,18 @@ +import config from "@payload-config"; +import { getPayload } from "payload"; +import { NextRequest, NextResponse } from "next/server"; +import { authenticateArmaBridge } from "@/lib/arma-bridge/auth"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function POST(req: NextRequest) { + const serverId = authenticateArmaBridge(req); + if (serverId instanceof NextResponse) return serverId; + const body = await req.json().catch(() => ({})); + const payload = await getPayload({ config }); + const found = await payload.find({ collection: "game-servers", where: { serverId: { equals: serverId } }, limit: 1, overrideAccess: true }); + const data = { name: typeof body.name === "string" ? body.name : serverId, status: "online" as const, lastSeenAt: new Date().toISOString(), metadata: body.metadata ?? {} }; + const server = found.docs[0] ? await payload.update({ collection: "game-servers", id: found.docs[0].id, data, overrideAccess: true }) : await payload.create({ collection: "game-servers", data: { serverId, ...data }, overrideAccess: true }); + return NextResponse.json({ ok: true, serverId: server.id }); +} diff --git a/src/lib/arma-bridge/auth.ts b/src/lib/arma-bridge/auth.ts new file mode 100644 index 0000000..8624175 --- /dev/null +++ b/src/lib/arma-bridge/auth.ts @@ -0,0 +1,10 @@ +import { NextRequest, NextResponse } from "next/server"; + +export function authenticateArmaBridge(req: NextRequest): string | NextResponse { + const secret = process.env.ARMA_BRIDGE_API_KEY; + if (!secret) return NextResponse.json({ error: "Arma bridge is not configured." }, { status: 503 }); + if (req.headers.get("x-arma-bridge-key") !== secret) return NextResponse.json({ error: "Unauthorized." }, { status: 401 }); + const serverId = req.headers.get("x-arma-server-id"); + if (!serverId || !/^[a-zA-Z0-9_-]{3,64}$/.test(serverId)) return NextResponse.json({ error: "Invalid server id." }, { status: 400 }); + return serverId; +}