livrare website cu erp si crm

This commit is contained in:
DP Software services 2026-07-28 14:47:28 +03:00
parent 28773e3a72
commit 5c7bf7c295
257 changed files with 31929 additions and 0 deletions

View file

@ -0,0 +1,147 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { getCustomerByKeycloakId } from "@/lib/erpnext";
const ERPNEXT_URL = process.env.ERPNEXT_API_URL || "http://localhost:8080";
const API_KEY = process.env.ERPNEXT_API_KEY || "";
const API_SECRET = process.env.ERPNEXT_API_SECRET || "";
/**
* Server-side proxy to ERPNext API.
* All client-side dashboard calls go through /api/erp/... instead of hitting ERPNext directly.
* This avoids CORS issues and keeps API credentials server-side.
*
* SECURITY: requires an authenticated session AND restricts which ERPNext
* doctypes/resources can be reached the proxy carries admin credentials, so
* it must never be an open relay. Reads are limited to CMS/catalog data;
* writes are limited to lead capture. Everything else (Customer, Sales Invoice,
* Payment Log, ...) is handled by dedicated, ownership-checked routes.
*/
// Resources the authenticated dashboard legitimately reads (see lib/api.ts).
// NOTE: filtering by customer is still done client-side — a hardened version
// should inject the session's own customer server-side to prevent IDOR. This
// allowlist at least stops the proxy from reaching unrelated doctypes
// (User, Role, API keys, ...) and requires a valid session.
const READ_ALLOW = [
"resource/Website Content",
"resource/Item",
"resource/Subscription Plan",
"resource/Subscription",
"resource/Sales Invoice",
"resource/Customer",
"method/frappe.client.get_list",
"method/frappe.client.get_value",
];
// Write endpoints allowed through the generic proxy.
const WRITE_ALLOW = ["resource/Lead", "resource/Customer"];
function isAllowed(erpPath: string, allow: string[]): boolean {
const decoded = decodeURIComponent(erpPath).replace(/^\/api\//, "");
return allow.some((a) => decoded === a || decoded.startsWith(a + "/") || decoded.startsWith(a + "?"));
}
// Doctypes whose rows belong to a specific customer. Any request touching one
// of these must reference ONLY the caller's own customer — otherwise it's an
// IDOR (reading/altering another user's data). Website Content / Item /
// Subscription Plan are shared catalog data and are not scoped.
const CUSTOMER_SCOPED = ["Customer", "Sales Invoice", "Subscription"];
/**
* Enforce that a request to a customer-scoped resource only references the
* caller's own ERPNext customer. Returns null if OK, or an error response.
*/
function enforceOwnership(
erpPath: string,
search: string,
ownCustomer: string | null
): NextResponse | null {
const decoded = decodeURIComponent(erpPath).replace(/^\/api\//, "");
const scoped = CUSTOMER_SCOPED.find(
(dt) => decoded === `resource/${dt}` || decoded.startsWith(`resource/${dt}/`) || decoded.startsWith(`resource/${dt}?`)
);
if (!scoped) return null; // shared/catalog resource — no scoping needed
if (!ownCustomer) {
return NextResponse.json({ error: "No customer profile for this account" }, { status: 403 });
}
// Direct access by name: /resource/Customer/<NAME> or /resource/Sales Invoice/<NAME>
const nameMatch = decoded.match(new RegExp(`^resource/${scoped}/(.+)$`));
if (nameMatch) {
const name = decodeURIComponent(nameMatch[1]);
// Sales Invoice / Subscription names aren't the customer — allow by name only
// for Customer (which IS the customer). Invoice/Subscription by-name reads
// are covered by the filter check below; deny bare Customer/<other>.
if (scoped === "Customer" && name !== ownCustomer) {
return NextResponse.json({ error: "Forbidden: not your resource" }, { status: 403 });
}
return null;
}
// Filtered list: the customer/party filter value must be the caller's own.
const q = decodeURIComponent(search);
const referenced = [...q.matchAll(/"(?:customer|party)"\s*,\s*"="\s*,\s*"([^"]+)"/g)].map((m) => m[1]);
if (referenced.some((c) => c !== ownCustomer)) {
return NextResponse.json({ error: "Forbidden: not your resource" }, { status: 403 });
}
// A list with no customer filter on a scoped doctype would leak all rows — deny.
if (referenced.length === 0) {
return NextResponse.json({ error: "Forbidden: customer filter required" }, { status: 403 });
}
return null;
}
async function proxy(request: NextRequest, path: string[], method: "GET" | "POST" | "PUT") {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const erpPath = `/api/${path.join("/")}`;
const allow = method === "GET" ? READ_ALLOW : WRITE_ALLOW;
if (!isAllowed(erpPath, allow)) {
return NextResponse.json(
{ error: "Forbidden: resource not permitted via generic proxy" },
{ status: 403 }
);
}
const search = request.nextUrl.searchParams.toString();
// Prevent IDOR: scope customer-owned resources to the caller's own customer.
const ownCustomer = session.user.id
? await getCustomerByKeycloakId(session.user.id)
: null;
const ownershipError = enforceOwnership(erpPath, search, ownCustomer);
if (ownershipError) return ownershipError;
const url = `${ERPNEXT_URL}${erpPath}${method === "GET" && search ? `?${search}` : ""}`;
try {
const res = await fetch(url, {
method,
headers: {
Authorization: `token ${API_KEY}:${API_SECRET}`,
...(method !== "GET" ? { "Content-Type": "application/json" } : {}),
},
...(method !== "GET" ? { body: await request.text() } : {}),
});
const data = await res.json();
return NextResponse.json(data, { status: res.status });
} catch {
return NextResponse.json({ error: "ERPNext API unavailable" }, { status: 502 });
}
}
export async function GET(request: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
return proxy(request, (await params).path, "GET");
}
export async function POST(request: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
return proxy(request, (await params).path, "POST");
}
export async function PUT(request: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
return proxy(request, (await params).path, "PUT");
}