← back to Trademarks Copyright

src/app/api/drops/cron/route.ts

52 lines

import { NextRequest, NextResponse } from "next/server";
import { timingSafeEqual } from "node:crypto";
import { composeDropForDate } from "@/lib/drops";
import { purgeExpiredRateLimits } from "@/lib/rateLimit";

export const runtime = "nodejs";
export const maxDuration = 300;

/**
 * Daily cron tick. Secure via CRON_TOKEN env (provide as ?token=... or Authorization header).
 * Step 1: compose today's drop if not already composed.
 * Step 2: send it by POSTing to /api/drops/send.
 */
export async function POST(req: NextRequest) {
  const requiredToken = process.env.CRON_TOKEN;
  if (!requiredToken) {
    return NextResponse.json({ error: "cron disabled — CRON_TOKEN not configured" }, { status: 503 });
  }

  const token = req.nextUrl.searchParams.get("token") || req.headers.get("authorization")?.replace(/^Bearer\s+/, "") || "";
  // P1 fix 2026-05-04: timing-safe compare — was plain !== which leaks token
  // length via response time; admin middleware uses HMAC compare, this should match.
  const tBuf = Buffer.from(token);
  const rBuf = Buffer.from(requiredToken);
  const ok = tBuf.length === rBuf.length && timingSafeEqual(tBuf, rBuf);
  if (!ok) {
    return NextResponse.json({ error: "bad token" }, { status: 403 });
  }

  const today = new Date();
  let composed: Awaited<ReturnType<typeof composeDropForDate>>;
  try {
    composed = await composeDropForDate({ date: today, itemsPerDrop: 10 });
  } catch (e) {
    return NextResponse.json({ error: (e as Error).message, step: "compose" }, { status: 500 });
  }

  const sendRes = await fetch(`${req.nextUrl.origin}/api/drops/send`, {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify({ dropId: composed.dropId }),
  });
  const sendJson = await sendRes.json();

  // Daily housekeeping: honor the 30-day rate-limit retention we promise in the privacy policy.
  const purged = await purgeExpiredRateLimits().catch(() => 0);

  return NextResponse.json({ ok: true, composed, send: sendJson, purged });
}

export async function GET(req: NextRequest) { return POST(req); }