livrare website cu erp si crm
This commit is contained in:
parent
28773e3a72
commit
5c7bf7c295
257 changed files with 31929 additions and 0 deletions
147
website/src/app/api/erp/[...path]/route.ts
Normal file
147
website/src/app/api/erp/[...path]/route.ts
Normal 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");
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue