audit: production safety fixes, cleanup, and documentation overhaul
CRITICAL fixes: - Fix SQL injection in geoportal search (template literal in $queryRaw) - Preserve enrichment data during GIS re-sync (upsert update explicit fields only) - Fix ePay version race condition (advisory lock in transaction) - Add requireAuth() to compress-pdf and unlock routes (were unauthenticated) - Remove hardcoded Stirling PDF API key (env vars now required) IMPORTANT fixes: - Add admin role check on registratura debug-sequences endpoint - Fix reserved slot race condition with advisory lock in transaction - Use SSO identity in close-guard-dialog instead of hardcoded "Utilizator" - Storage DELETE catches only P2025 (not found), re-throws real errors - Add onDelete: SetNull for GisFeature → GisSyncRun relation - Move portal-only users to PORTAL_ONLY_USERS env var - Add security headers (X-Frame-Options, X-Content-Type-Options, Referrer-Policy) - Add periodic cleanup for eTerra/ePay session caches and progress store - Log warning when ePay dataDocument is missing (expiry fallback) Cleanup: - Delete orphaned rgi-test page (1086 lines, unregistered, inaccessible) - Delete legacy/ folder (5 files, unreferenced from src/) - Remove unused ensureBucketExists() from minio-client.ts Documentation: - Optimize CLAUDE.md: 464 → 197 lines (moved per-module details to docs/) - Create docs/ARCHITECTURE-QUICK.md (80 lines: data flow, deps, env vars) - Create docs/MODULE-MAP.md (140 lines: entry points, API routes, cross-deps) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,20 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { requireAuth } from "./auth-check";
|
||||
|
||||
const STIRLING_PDF_URL =
|
||||
process.env.STIRLING_PDF_URL ?? "http://10.10.10.166:8087";
|
||||
const STIRLING_PDF_API_KEY =
|
||||
process.env.STIRLING_PDF_API_KEY ?? "cd829f62-6eef-43eb-a64d-c91af727b53a";
|
||||
const STIRLING_PDF_URL = process.env.STIRLING_PDF_URL;
|
||||
const STIRLING_PDF_API_KEY = process.env.STIRLING_PDF_API_KEY;
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const authErr = await requireAuth(req);
|
||||
if (authErr) return authErr;
|
||||
|
||||
if (!STIRLING_PDF_URL || !STIRLING_PDF_API_KEY) {
|
||||
return NextResponse.json(
|
||||
{ error: "Stirling PDF nu este configurat" },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
// Buffer the full body then forward to Stirling — streaming passthrough
|
||||
// (req.body + duplex:half) is unreliable for large files in Next.js.
|
||||
|
||||
@@ -1,11 +1,20 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { requireAuth } from "../auth-check";
|
||||
|
||||
const STIRLING_PDF_URL =
|
||||
process.env.STIRLING_PDF_URL ?? "http://10.10.10.166:8087";
|
||||
const STIRLING_PDF_API_KEY =
|
||||
process.env.STIRLING_PDF_API_KEY ?? "cd829f62-6eef-43eb-a64d-c91af727b53a";
|
||||
const STIRLING_PDF_URL = process.env.STIRLING_PDF_URL;
|
||||
const STIRLING_PDF_API_KEY = process.env.STIRLING_PDF_API_KEY;
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const authErr = await requireAuth(req);
|
||||
if (authErr) return authErr;
|
||||
|
||||
if (!STIRLING_PDF_URL || !STIRLING_PDF_API_KEY) {
|
||||
return NextResponse.json(
|
||||
{ error: "Stirling PDF nu este configurat" },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
// Stream body directly to Stirling — avoids FormData re-serialization
|
||||
// failure on large files ("Failed to parse body as FormData")
|
||||
|
||||
@@ -78,7 +78,7 @@ export async function GET(req: Request) {
|
||||
WHERE geom IS NOT NULL
|
||||
AND "layerId" LIKE 'TERENURI%'
|
||||
AND ("cadastralRef" ILIKE ${pattern}
|
||||
OR enrichment::text ILIKE ${'%"NR_CAD":"' + q + '%'})
|
||||
OR enrichment::text ILIKE ${`%"NR_CAD":"${q}%`})
|
||||
ORDER BY "cadastralRef"
|
||||
LIMIT ${limit}
|
||||
` as Array<{
|
||||
|
||||
@@ -11,11 +11,21 @@ import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/core/storage/prisma";
|
||||
import { getAuthSession } from "@/core/auth";
|
||||
|
||||
export async function GET() {
|
||||
async function requireAdmin(): Promise<NextResponse | null> {
|
||||
const session = await getAuthSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
const u = session.user as { role?: string } | undefined;
|
||||
if (u?.role !== "admin") {
|
||||
return NextResponse.json({ error: "Admin access required" }, { status: 403 });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const denied = await requireAdmin();
|
||||
if (denied) return denied;
|
||||
|
||||
// Get all sequence counters
|
||||
const counters = await prisma.$queryRaw<
|
||||
@@ -79,10 +89,8 @@ export async function GET() {
|
||||
}
|
||||
|
||||
export async function POST() {
|
||||
const session = await getAuthSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
const denied = await requireAdmin();
|
||||
if (denied) return denied;
|
||||
|
||||
// Delete ALL old counters
|
||||
const deleted = await prisma.$executeRaw`DELETE FROM "RegistrySequence"`;
|
||||
@@ -146,10 +154,8 @@ export async function POST() {
|
||||
* Rewrites the "number" field inside the JSONB value for matching entries.
|
||||
*/
|
||||
export async function PATCH() {
|
||||
const session = await getAuthSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
const denied = await requireAdmin();
|
||||
if (denied) return denied;
|
||||
|
||||
// Map old 3-letter prefixes to new single-letter
|
||||
const migrations: Array<{ old: string; new: string }> = [
|
||||
|
||||
@@ -213,27 +213,33 @@ export async function POST(req: NextRequest) {
|
||||
let claimedSlotId: string | undefined;
|
||||
|
||||
if (isPastMonth && direction === "intrat") {
|
||||
// Try to claim a reserved slot
|
||||
const allEntries = await loadAllEntries(true);
|
||||
const slot = findAvailableReservedSlot(
|
||||
allEntries,
|
||||
company,
|
||||
docDate.getFullYear(),
|
||||
docDate.getMonth(),
|
||||
);
|
||||
// Try to claim a reserved slot — use advisory lock to prevent concurrent claims
|
||||
const lockKey = `reserved:${company}-${docDate.getFullYear()}-${docDate.getMonth()}`;
|
||||
const claimed = await prisma.$transaction(async (tx) => {
|
||||
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${lockKey}))`;
|
||||
const allEntries = await loadAllEntries(true);
|
||||
const slot = findAvailableReservedSlot(
|
||||
allEntries,
|
||||
company,
|
||||
docDate.getFullYear(),
|
||||
docDate.getMonth(),
|
||||
);
|
||||
if (!slot) return null;
|
||||
// Delete the placeholder slot within the lock
|
||||
await tx.keyValueStore.delete({
|
||||
where: { namespace_key: { namespace: "registratura", key: slot.id } },
|
||||
});
|
||||
return slot;
|
||||
});
|
||||
|
||||
if (slot) {
|
||||
// Claim the reserved slot — reuse its number
|
||||
registryNumber = slot.number;
|
||||
if (claimed) {
|
||||
registryNumber = claimed.number;
|
||||
registrationType = "reserved-claimed";
|
||||
claimedSlotId = slot.id;
|
||||
|
||||
// Delete the placeholder slot
|
||||
await deleteEntryFromDB(slot.id);
|
||||
claimedSlotId = claimed.id;
|
||||
|
||||
await logAuditEvent({
|
||||
entryId: slot.id,
|
||||
entryNumber: slot.number,
|
||||
entryId: claimed.id,
|
||||
entryNumber: claimed.number,
|
||||
action: "reserved_claimed",
|
||||
actor: actor.id,
|
||||
actorName: actor.name,
|
||||
|
||||
@@ -144,8 +144,8 @@ export async function DELETE(request: NextRequest) {
|
||||
},
|
||||
},
|
||||
})
|
||||
.catch(() => {
|
||||
// Ignore error if item doesn't exist
|
||||
.catch((err: { code?: string }) => {
|
||||
if (err.code !== "P2025") throw err;
|
||||
});
|
||||
} else {
|
||||
// Clear namespace
|
||||
|
||||
@@ -18,16 +18,3 @@ if (process.env.NODE_ENV !== "production")
|
||||
globalForMinio.minioClient = minioClient;
|
||||
|
||||
export const MINIO_BUCKET_NAME = process.env.MINIO_BUCKET_NAME || "tools";
|
||||
|
||||
// Helper to ensure bucket exists
|
||||
export async function ensureBucketExists() {
|
||||
try {
|
||||
const exists = await minioClient.bucketExists(MINIO_BUCKET_NAME);
|
||||
if (!exists) {
|
||||
await minioClient.makeBucket(MINIO_BUCKET_NAME, "eu-west-1");
|
||||
console.log(`Bucket '${MINIO_BUCKET_NAME}' created successfully.`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error checking/creating MinIO bucket:", error);
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@ export async function middleware(request: NextRequest) {
|
||||
if (token) {
|
||||
const { pathname } = request.nextUrl;
|
||||
// Portal-only users: redirect to /portal when accessing main app
|
||||
const portalUsers = ["dtiurbe", "d.tiurbe"];
|
||||
const portalUsers = (process.env.PORTAL_ONLY_USERS ?? "dtiurbe,d.tiurbe").split(",").map(s => s.trim().toLowerCase());
|
||||
const tokenEmail = String(token.email ?? "").toLowerCase();
|
||||
const tokenName = String(token.name ?? "").toLowerCase();
|
||||
const isPortalUser = portalUsers.some(
|
||||
|
||||
@@ -54,11 +54,24 @@ type SessionEntry = {
|
||||
|
||||
const globalStore = globalThis as {
|
||||
__epaySessionCache?: Map<string, SessionEntry>;
|
||||
__epayCleanupTimer?: ReturnType<typeof setInterval>;
|
||||
};
|
||||
const sessionCache =
|
||||
globalStore.__epaySessionCache ?? new Map<string, SessionEntry>();
|
||||
globalStore.__epaySessionCache = sessionCache;
|
||||
|
||||
// Periodic cleanup of expired sessions (every 5 minutes, 9-min TTL)
|
||||
if (!globalStore.__epayCleanupTimer) {
|
||||
globalStore.__epayCleanupTimer = setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [key, entry] of sessionCache.entries()) {
|
||||
if (now - entry.lastUsed > 9 * 60_000) {
|
||||
sessionCache.delete(key);
|
||||
}
|
||||
}
|
||||
}, 5 * 60_000);
|
||||
}
|
||||
|
||||
const makeCacheKey = (u: string, p: string) =>
|
||||
crypto.createHash("sha256").update(`epay:${u}:${p}`).digest("hex");
|
||||
|
||||
|
||||
@@ -117,27 +117,29 @@ export async function enqueueBatch(
|
||||
const items: QueueItem[] = [];
|
||||
|
||||
for (const input of inputs) {
|
||||
// Create DB record in "queued" status
|
||||
const record = await prisma.cfExtract.create({
|
||||
data: {
|
||||
nrCadastral: input.nrCadastral,
|
||||
nrCF: input.nrCF ?? input.nrCadastral,
|
||||
siruta: input.siruta,
|
||||
judetIndex: input.judetIndex,
|
||||
judetName: input.judetName,
|
||||
uatId: input.uatId,
|
||||
uatName: input.uatName,
|
||||
gisFeatureId: input.gisFeatureId,
|
||||
prodId: input.prodId ?? 14200,
|
||||
status: "queued",
|
||||
version:
|
||||
((
|
||||
await prisma.cfExtract.aggregate({
|
||||
where: { nrCadastral: input.nrCadastral },
|
||||
_max: { version: true },
|
||||
})
|
||||
)._max.version ?? 0) + 1,
|
||||
},
|
||||
// Create DB record in "queued" status — use transaction + advisory lock
|
||||
// to prevent duplicate version numbers from concurrent requests
|
||||
const record = await prisma.$transaction(async (tx) => {
|
||||
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${'cfextract:' + input.nrCadastral}))`;
|
||||
const agg = await tx.cfExtract.aggregate({
|
||||
where: { nrCadastral: input.nrCadastral },
|
||||
_max: { version: true },
|
||||
});
|
||||
return tx.cfExtract.create({
|
||||
data: {
|
||||
nrCadastral: input.nrCadastral,
|
||||
nrCF: input.nrCF ?? input.nrCadastral,
|
||||
siruta: input.siruta,
|
||||
judetIndex: input.judetIndex,
|
||||
judetName: input.judetName,
|
||||
uatId: input.uatId,
|
||||
uatName: input.uatName,
|
||||
gisFeatureId: input.gisFeatureId,
|
||||
prodId: input.prodId ?? 14200,
|
||||
status: "queued",
|
||||
version: (agg._max.version ?? 0) + 1,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
items.push({ extractId: record.id, input });
|
||||
@@ -418,7 +420,10 @@ async function processBatch(
|
||||
},
|
||||
);
|
||||
|
||||
// Complete
|
||||
// Complete — require document date from ANCPI for accurate expiry
|
||||
if (!doc.dataDocument) {
|
||||
console.warn(`[epay-queue] Missing dataDocument for extract ${item.extractId}, using download date`);
|
||||
}
|
||||
const documentDate = doc.dataDocument
|
||||
? new Date(doc.dataDocument)
|
||||
: new Date();
|
||||
|
||||
@@ -79,11 +79,24 @@ type SessionEntry = {
|
||||
|
||||
const globalStore = globalThis as {
|
||||
__eterraSessionStore?: Map<string, SessionEntry>;
|
||||
__eterraCleanupTimer?: ReturnType<typeof setInterval>;
|
||||
};
|
||||
const sessionStore =
|
||||
globalStore.__eterraSessionStore ?? new Map<string, SessionEntry>();
|
||||
globalStore.__eterraSessionStore = sessionStore;
|
||||
|
||||
// Periodic cleanup of expired sessions (every 5 minutes, 9-min TTL)
|
||||
if (!globalStore.__eterraCleanupTimer) {
|
||||
globalStore.__eterraCleanupTimer = setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [key, entry] of sessionStore.entries()) {
|
||||
if (now - entry.lastUsed > 9 * 60_000) {
|
||||
sessionStore.delete(key);
|
||||
}
|
||||
}
|
||||
}, 5 * 60_000);
|
||||
}
|
||||
|
||||
const makeCacheKey = (u: string, p: string) =>
|
||||
crypto.createHash("sha256").update(`${u}:${p}`).digest("hex");
|
||||
|
||||
|
||||
@@ -16,10 +16,24 @@ export type SyncProgress = {
|
||||
|
||||
type ProgressStore = Map<string, SyncProgress>;
|
||||
|
||||
const g = globalThis as { __parcelSyncProgressStore?: ProgressStore };
|
||||
const g = globalThis as {
|
||||
__parcelSyncProgressStore?: ProgressStore;
|
||||
__progressCleanupTimer?: ReturnType<typeof setInterval>;
|
||||
};
|
||||
const store: ProgressStore = g.__parcelSyncProgressStore ?? new Map();
|
||||
g.__parcelSyncProgressStore = store;
|
||||
|
||||
// Periodic cleanup of stale progress entries (every 30 minutes)
|
||||
if (!g.__progressCleanupTimer) {
|
||||
g.__progressCleanupTimer = setInterval(() => {
|
||||
for (const [jobId, p] of store.entries()) {
|
||||
if (p.status === "done" || p.status === "error") {
|
||||
store.delete(jobId);
|
||||
}
|
||||
}
|
||||
}, 30 * 60_000);
|
||||
}
|
||||
|
||||
export const setProgress = (p: SyncProgress) => store.set(p.jobId, p);
|
||||
export const getProgress = (jobId: string) => store.get(jobId);
|
||||
export const clearProgress = (jobId: string) => store.delete(jobId);
|
||||
|
||||
@@ -237,8 +237,16 @@ export async function syncLayer(
|
||||
},
|
||||
create: item,
|
||||
update: {
|
||||
...item,
|
||||
siruta: item.siruta,
|
||||
inspireId: item.inspireId,
|
||||
cadastralRef: item.cadastralRef,
|
||||
areaValue: item.areaValue,
|
||||
isActive: item.isActive,
|
||||
attributes: item.attributes,
|
||||
geometry: item.geometry,
|
||||
syncRunId: item.syncRunId,
|
||||
updatedAt: new Date(),
|
||||
// enrichment + enrichedAt preserved — not overwritten
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
X,
|
||||
FileText,
|
||||
} from "lucide-react";
|
||||
import { useAuth } from "@/core/auth";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import { Input } from "@/shared/components/ui/input";
|
||||
import { Label } from "@/shared/components/ui/label";
|
||||
@@ -66,6 +67,7 @@ export function CloseGuardDialog({
|
||||
activeDeadlines,
|
||||
onConfirmClose,
|
||||
}: CloseGuardDialogProps) {
|
||||
const { user } = useAuth();
|
||||
const [search, setSearch] = useState("");
|
||||
const [selectedEntryId, setSelectedEntryId] = useState("");
|
||||
const [resolution, setResolution] = useState<ClosureResolution>("finalizat");
|
||||
@@ -130,7 +132,7 @@ export function CloseGuardDialog({
|
||||
onConfirmClose({
|
||||
resolution,
|
||||
reason: reason.trim(),
|
||||
closedBy: "Utilizator", // TODO: replace with SSO identity
|
||||
closedBy: user?.name ?? "Utilizator",
|
||||
closedAt: new Date().toISOString(),
|
||||
linkedEntryId: selectedEntryId || undefined,
|
||||
linkedEntryNumber: selectedEntry?.number,
|
||||
|
||||
Reference in New Issue
Block a user