harden(epay): cart hygiene, auth/IDOR gates, single-page fetch, parallel downloads
Live-path hardening from the 2026-06-04 deep-dive (11 confirmed criticals). ArchiTools-only; the legacy queue is still the sole fulfiller. Security: - requireCfAccess() — staff-only, portal accounts blocked, fail-closed in-route on download / download-zip / cf-status / orders (C4 IDOR/PII) and order / recover (C3). order also enforces a daily credit cap (ANCPI_DAILY_CREDIT_CAP, default 200) and stamps userId. - /api/ancpi/test returns 404 in production — it was a GET that spends 2 real credits, CSRF-able (C5). - drop the token-metadata debug blob from the session (QW8). Correctness / robustness: - cart hygiene (C1): build the ePay cart under an invariant — the Nth add must report N items; any excess = pre-existing junk, so we wipe + abort (never submit a cart we didn't fully build). Pre-submit failures clean up our basket rows; post-submit we never touch the cart (recover owns it). metadata-less rows are deleted from the cart. - getOrderStatus fetches the whole order in ONE page (itemsPerPage, QW4); navDir loop kept only as fallback. index-fallback matches are flagged 'review' instead of silently 'completed' with a possibly-wrong PDF (R4). - downloadDocument asserts %PDF magic bytes — a login page returned mid session no longer gets stored as a .pdf (R2). Session reuse TTL aligned under ANCPI's ~10min expiry. - recover accepts ?extractId= and pre-submit states; retry buttons in the ePay tab re-run poll+download with no new charge (QW2/QW3). Performance: - parallel document downloads (V1, concurrency 4); poll writes only on status change via updateMany (QW5); getNextFileIndex scans the cadastral prefix instead of the whole bucket — and actually works now (it was ^-anchoring the full key, so every file got index 1) (V2); download-zip streams instead of buffering the whole archive, capped at 100 (V3). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,20 +1,31 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/core/storage/prisma";
|
||||
import { getCfExtractStream } from "@/modules/parcel-sync/services/epay-storage";
|
||||
import { requireCfAccess } from "@/core/auth/cf-access";
|
||||
import JSZip from "jszip";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// Cap per request so a "Descarcă tot" over hundreds of extracts can't
|
||||
// balloon the in-memory ZIP buffer (V3). The UI batches above this.
|
||||
const MAX_ZIP_IDS = 100;
|
||||
|
||||
/**
|
||||
* GET /api/ancpi/download-zip?ids=id1,id2,id3
|
||||
*
|
||||
* Streams a ZIP file containing all requested CF extract PDFs.
|
||||
* Files named: {index:02d}_Extras CF_{nrCadastral} - {DD-MM-YYYY}.pdf
|
||||
* Index = position in the ids array (preserves list order).
|
||||
* Guarded: PDFs contain owner PII — staff only, no portal accounts (C4).
|
||||
*/
|
||||
export async function GET(req: Request) {
|
||||
try {
|
||||
const access = await requireCfAccess();
|
||||
if (!access.ok) {
|
||||
return NextResponse.json({ error: access.error }, { status: access.status });
|
||||
}
|
||||
|
||||
const url = new URL(req.url);
|
||||
const idsParam = url.searchParams.get("ids");
|
||||
|
||||
@@ -32,6 +43,12 @@ export async function GET(req: Request) {
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
if (ids.length > MAX_ZIP_IDS) {
|
||||
return NextResponse.json(
|
||||
{ error: `Prea multe extrase într-o arhivă (max ${MAX_ZIP_IDS}).` },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Fetch all extract records
|
||||
const extracts = await prisma.cfExtract.findMany({
|
||||
@@ -90,21 +107,31 @@ export async function GET(req: Request) {
|
||||
);
|
||||
}
|
||||
|
||||
const zipBuffer = await zip.generateAsync({
|
||||
type: "nodebuffer",
|
||||
compression: "DEFLATE",
|
||||
compressionOptions: { level: 6 },
|
||||
});
|
||||
|
||||
const today = new Date();
|
||||
const todayStr = `${String(today.getDate()).padStart(2, "0")}-${String(today.getMonth() + 1).padStart(2, "0")}-${today.getFullYear()}`;
|
||||
const archiveName = `Extrase_CF_${filesAdded}_${todayStr}.zip`;
|
||||
|
||||
return new Response(new Uint8Array(zipBuffer), {
|
||||
// Stream the ZIP out instead of materializing the whole archive in
|
||||
// memory (V3) — pull from JSZip's internal stream into a Web stream,
|
||||
// and drop Content-Length (unknown until the stream ends).
|
||||
const nodeStream = zip.generateInternalStream({
|
||||
type: "uint8array",
|
||||
compression: "DEFLATE",
|
||||
compressionOptions: { level: 6 },
|
||||
});
|
||||
const webStream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
nodeStream.on("data", (chunk: Uint8Array) => controller.enqueue(chunk));
|
||||
nodeStream.on("end", () => controller.close());
|
||||
nodeStream.on("error", (err: Error) => controller.error(err));
|
||||
nodeStream.resume();
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(webStream, {
|
||||
headers: {
|
||||
"Content-Type": "application/zip",
|
||||
"Content-Disposition": `attachment; filename="${encodeURIComponent(archiveName)}"`,
|
||||
"Content-Length": String(zipBuffer.length),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/core/storage/prisma";
|
||||
import { getCfExtractStream } from "@/modules/parcel-sync/services/epay-storage";
|
||||
import { Readable } from "stream";
|
||||
import { requireCfAccess } from "@/core/auth/cf-access";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -10,9 +10,15 @@ export const dynamic = "force-dynamic";
|
||||
* GET /api/ancpi/download?id={extractId}
|
||||
*
|
||||
* Streams the CF extract PDF from MinIO with proper filename.
|
||||
* Guarded: PDFs contain owner PII — staff only, no portal accounts (C4).
|
||||
*/
|
||||
export async function GET(req: Request) {
|
||||
try {
|
||||
const access = await requireCfAccess();
|
||||
if (!access.ok) {
|
||||
return NextResponse.json({ error: access.error }, { status: access.status });
|
||||
}
|
||||
|
||||
const url = new URL(req.url);
|
||||
const id = url.searchParams.get("id");
|
||||
|
||||
|
||||
@@ -5,7 +5,8 @@ import {
|
||||
enqueueBatch,
|
||||
} from "@/modules/parcel-sync/services/epay-queue";
|
||||
import type { CfExtractCreateInput } from "@/modules/parcel-sync/services/epay-types";
|
||||
import { getAuthSession } from "@/core/auth/require-auth";
|
||||
import { requireCfAccess } from "@/core/auth/cf-access";
|
||||
import { prisma } from "@/core/storage/prisma";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -48,6 +49,13 @@ function cleanupNonceMap(): void {
|
||||
*/
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
// C3: spending the company's credits requires a staff session — not
|
||||
// merely a connected ePay session. Portal accounts are rejected.
|
||||
const access = await requireCfAccess();
|
||||
if (!access.ok) {
|
||||
return NextResponse.json({ error: access.error }, { status: access.status });
|
||||
}
|
||||
|
||||
const creds = getEpayCredentials();
|
||||
if (!creds) {
|
||||
return NextResponse.json(
|
||||
@@ -96,14 +104,38 @@ export async function POST(req: Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// C3: daily spend guardrail. ePay credits are real money; a runaway
|
||||
// loop or fat-finger batch shouldn't be able to drain the account.
|
||||
// Counts credit-consuming rows created today (UTC) and refuses the
|
||||
// batch if it would push past the cap. Default 200/day, override via
|
||||
// ANCPI_DAILY_CREDIT_CAP.
|
||||
const dailyCap = parseInt(process.env.ANCPI_DAILY_CREDIT_CAP ?? "200", 10);
|
||||
if (dailyCap > 0) {
|
||||
const startOfDay = new Date();
|
||||
startOfDay.setUTCHours(0, 0, 0, 0);
|
||||
const spentToday = await prisma.cfExtract.count({
|
||||
where: {
|
||||
type: "epay",
|
||||
createdAt: { gte: startOfDay },
|
||||
status: { notIn: ["failed", "cancelled"] },
|
||||
},
|
||||
});
|
||||
if (spentToday + parcels.length > dailyCap) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `Plafon zilnic de credite atins (${spentToday}/${dailyCap}). Comanda de ${parcels.length} depășește limita.`,
|
||||
spentToday,
|
||||
dailyCap,
|
||||
},
|
||||
{ status: 429 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Stamp the orderer's session id on each enqueued row so CfExtract
|
||||
// carries ownership info (was NULL before — see
|
||||
// feedback_cfextract_schema_drift.md). Falls back to undefined when
|
||||
// the route is hit without a session (dev tools / cron).
|
||||
const session = await getAuthSession();
|
||||
const userId =
|
||||
((session?.user as { id?: string } | undefined)?.id ||
|
||||
session?.user?.email) ?? undefined;
|
||||
// feedback_cfextract_schema_drift.md).
|
||||
const userId = access.actor.userId;
|
||||
const stampedParcels: CfExtractCreateInput[] = parcels.map((p) => ({
|
||||
...p,
|
||||
userId: p.userId ?? userId,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/core/storage/prisma";
|
||||
import { requireCfAccess } from "@/core/auth/cf-access";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -21,6 +22,11 @@ export const dynamic = "force-dynamic";
|
||||
*/
|
||||
export async function GET(req: Request) {
|
||||
try {
|
||||
const access = await requireCfAccess();
|
||||
if (!access.ok) {
|
||||
return NextResponse.json({ error: access.error }, { status: access.status });
|
||||
}
|
||||
|
||||
const url = new URL(req.url);
|
||||
const nrCadastralParam = url.searchParams.get("nrCadastral") || undefined;
|
||||
const status = url.searchParams.get("status") || undefined;
|
||||
@@ -92,7 +98,25 @@ export async function GET(req: Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// Also check for active (in-progress) orders
|
||||
// QW6: surface terminal failure/review so the UI can flag them (a
|
||||
// cadastral whose latest record failed used to show as "none"). Only
|
||||
// applies where there's no valid extract — a fresh valid one wins.
|
||||
const attentionRecords = await prisma.cfExtract.findMany({
|
||||
where: {
|
||||
nrCadastral: { in: cadastralNumbers },
|
||||
status: { in: ["failed", "review"] },
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: { nrCadastral: true, status: true },
|
||||
});
|
||||
for (const rec of attentionRecords) {
|
||||
if (statusMap[rec.nrCadastral] === "none") {
|
||||
statusMap[rec.nrCadastral] = rec.status; // "failed" | "review"
|
||||
}
|
||||
}
|
||||
|
||||
// Active (in-progress) orders take priority over none/failed/review/
|
||||
// expired — an in-flight re-order should read as "processing".
|
||||
const activeRecords = await prisma.cfExtract.findMany({
|
||||
where: {
|
||||
nrCadastral: { in: cadastralNumbers },
|
||||
@@ -104,8 +128,7 @@ export async function GET(req: Request) {
|
||||
});
|
||||
|
||||
for (const rec of activeRecords) {
|
||||
// If there's an active order, mark as "processing" (takes priority over "none")
|
||||
if (statusMap[rec.nrCadastral] === "none") {
|
||||
if (statusMap[rec.nrCadastral] !== "valid") {
|
||||
statusMap[rec.nrCadastral] = "processing";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,38 +1,79 @@
|
||||
// GET /api/ancpi/recover?orderId=<id>
|
||||
// GET /api/ancpi/recover?orderId=<id> — recover a whole ANCPI order
|
||||
// GET /api/ancpi/recover?extractId=<id> — retry one row (resolves its order)
|
||||
//
|
||||
// Recovery for ePay orders that ANCPI processed even though our
|
||||
// EditCartSubmit request timed out: the CfExtract rows sit on
|
||||
// status "failed" / "timeout..." with no orderId while the credits are
|
||||
// already spent (2026-06-04: order 10009605, 15 extracts, Feleacu).
|
||||
// Recovery for ePay orders that ANCPI processed/finalized even though our
|
||||
// pipeline lost them (EditCartSubmit timed out, or the container restarted
|
||||
// mid-batch): the CfExtract rows sit on a non-terminal/failed status while
|
||||
// the credits are ALREADY spent. This re-attaches the order and re-runs the
|
||||
// shared poll → download → MinIO pipeline (epay-queue.recoverBatch). It does
|
||||
// NOT place a new order or spend new credits — so it's idempotent and safe
|
||||
// to re-run. Requires an active ePay session (connect in the ePay tab first).
|
||||
//
|
||||
// Picks up the recent failed-without-orderId rows, attaches the given
|
||||
// orderId and runs the shared poll → download → MinIO pipeline
|
||||
// (epay-queue.finalizeOrder via recoverBatch). Requires an active ePay
|
||||
// session (connect in the ePay tab first). Idempotent: already-completed
|
||||
// rows are not selected; re-running after a partial failure only retries
|
||||
// the still-failed rows.
|
||||
//
|
||||
// GET (not POST) on purpose — it's an operator action triggered from the
|
||||
// browser URL bar with the NextAuth session cookie.
|
||||
// GET on purpose — operator action from the browser URL bar / a retry button
|
||||
// with the NextAuth cookie. No new spend, so no CSRF exposure to credits;
|
||||
// still staff-gated (C3).
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/core/storage/prisma";
|
||||
import { recoverBatch } from "@/modules/parcel-sync/services/epay-queue";
|
||||
import { requireCfAccess } from "@/core/auth/cf-access";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// Pre-submit / in-flight states that a crash can orphan. Widened from the
|
||||
// original "failed only" so a batch killed mid-flight (orderId still null)
|
||||
// can be attached to an operator-supplied orderId (C2/QW2).
|
||||
const RECOVERABLE_STATES = [
|
||||
"failed",
|
||||
"queued",
|
||||
"cart",
|
||||
"searching",
|
||||
"ordering",
|
||||
"polling",
|
||||
"downloading",
|
||||
];
|
||||
|
||||
// Single-flight: poll+download for a batch takes minutes; a second click
|
||||
// must not start a parallel run over the same rows.
|
||||
const g = globalThis as { __epayRecoverRunning?: boolean };
|
||||
|
||||
export async function GET(req: Request) {
|
||||
// Staff only — recovery completes/downloads orders with PII.
|
||||
const access = await requireCfAccess();
|
||||
if (!access.ok) {
|
||||
return NextResponse.json({ error: access.error }, { status: access.status });
|
||||
}
|
||||
|
||||
const url = new URL(req.url);
|
||||
const orderId = url.searchParams.get("orderId")?.trim() ?? "";
|
||||
let orderId = url.searchParams.get("orderId")?.trim() ?? "";
|
||||
const extractId = url.searchParams.get("extractId")?.trim() ?? "";
|
||||
|
||||
// QW3: retry-by-row. Resolve the row's orderId server-side; recover the
|
||||
// whole sibling set that shares it.
|
||||
if (!orderId && extractId) {
|
||||
const row = await prisma.cfExtract.findUnique({
|
||||
where: { id: extractId },
|
||||
select: { orderId: true, status: true },
|
||||
});
|
||||
if (!row) {
|
||||
return NextResponse.json({ error: "Extras inexistent." }, { status: 404 });
|
||||
}
|
||||
if (!row.orderId) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
"Extrasul nu are încă un orderId ANCPI — recuperează cu ?orderId=<id> de pe portalul ePay.",
|
||||
},
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
orderId = row.orderId;
|
||||
}
|
||||
|
||||
if (!/^\d+$/.test(orderId)) {
|
||||
return NextResponse.json(
|
||||
{ error: "orderId lipsă sau invalid. Folosește ?orderId=<id ePay>." },
|
||||
{ error: "orderId lipsă sau invalid. Folosește ?orderId=<id> sau ?extractId=<id>." },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
@@ -44,18 +85,19 @@ export async function GET(req: Request) {
|
||||
);
|
||||
}
|
||||
|
||||
// Candidate rows: recent paid-flow failures that never got an orderId,
|
||||
// plus rows already attached to this order by a previous partial run.
|
||||
// Candidate rows: anything already tagged with this order that isn't
|
||||
// terminal, PLUS recent orphaned rows (orderId:null) in a recoverable
|
||||
// state — the operator asserts they belong to this order.
|
||||
const rows = await prisma.cfExtract.findMany({
|
||||
where: {
|
||||
type: "epay",
|
||||
OR: [
|
||||
{ orderId, status: { notIn: ["completed", "cancelled"] } },
|
||||
{
|
||||
status: "failed",
|
||||
orderId: null,
|
||||
status: { in: RECOVERABLE_STATES },
|
||||
createdAt: { gte: new Date(Date.now() - 48 * 60 * 60 * 1000) },
|
||||
},
|
||||
{ orderId, status: { notIn: ["completed", "cancelled"] } },
|
||||
],
|
||||
},
|
||||
select: {
|
||||
@@ -73,7 +115,7 @@ export async function GET(req: Request) {
|
||||
if (rows.length === 0) {
|
||||
return NextResponse.json({
|
||||
recovered: 0,
|
||||
message: "Niciun extras failed (ultimele 48h) de recuperat.",
|
||||
message: "Niciun extras de recuperat pentru această comandă.",
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -36,6 +36,14 @@ if (gTestDedup.__testOrderDedup === undefined) gTestDedup.__testOrderDedup = nul
|
||||
* Zero discovery calls needed!
|
||||
*/
|
||||
export async function GET(req: Request) {
|
||||
// SECURITY (C5): this is a GET with real side-effects — step=order spends
|
||||
// 2 ePay credits on hardcoded parcels. A GET endpoint that spends money is
|
||||
// CSRF-able (<img src=...?step=order>) from any page an authenticated
|
||||
// operator visits. Disabled entirely outside development.
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const url = new URL(req.url);
|
||||
const step = url.searchParams.get("step") ?? "login";
|
||||
|
||||
|
||||
@@ -6,11 +6,18 @@
|
||||
*/
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/core/storage/prisma";
|
||||
import { requireCfAccess } from "@/core/auth/cf-access";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(req: Request) {
|
||||
// Guarded: returns extract id + download URL — staff only (C4).
|
||||
const access = await requireCfAccess();
|
||||
if (!access.ok) {
|
||||
return NextResponse.json({ error: access.error }, { status: access.status });
|
||||
}
|
||||
|
||||
const url = new URL(req.url);
|
||||
const nrCad = url.searchParams.get("nrCad")?.trim();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user