mirror of
https://github.com/melgarafael/DeskcommCRM.git
synced 2026-10-02 01:28:34 +08:00
The client base URL is the origin of every link the server hands to third parties. With SUPABASE_SERVER_URL filled, `signedUrl` and `data.url` came out as `http://kong:8000` — media, avatars, product photos, the LGPD PDF and the Google login pointed at an address those callers cannot reach. server.ts, admin.ts and proxy.ts now keep `NEXT_PUBLIC_SUPABASE_URL` as the base and pass `global.fetch = fetchDoServidor(interna, publica)`, a fetch that rewrites the public prefix to the internal one: requests (storage sign, rest/v1, auth/v1/user) take the short path while links stay public. Raw fetches (health, request-deps, agent worker) keep swapping the URL only — they generate no link. provedor-google and convite-no-gotrue now go through the same resolver. Adds lib/supabase/fetch-do-servidor.test.ts: with the variable filled, createSignedUrl and signInWithOAuth return the public origin and the request still goes to the internal one — red before this commit. The three `presente:` cases in tests/unit/supabase-server-url-opcional.test.ts follow the new contract (public base, internal transport). docs/deploy-selfhost/README.md and .env.example no longer claim REST, Realtime and Storage stop needing to be public: the browser still uses Realtime and Storage on the public URL, so it keeps serving Auth, Realtime and Storage. The gain is the server path.
155 lines
5.8 KiB
TypeScript
155 lines
5.8 KiB
TypeScript
import { createServerClient, type CookieOptions } from "@supabase/ssr";
|
|
import { cookieSecure } from "@/lib/supabase/cookie-secure";
|
|
import { NextResponse, type NextRequest } from "next/server";
|
|
import { env } from "@/lib/env";
|
|
import { fetchDoServidor } from "@/lib/supabase/fetch-do-servidor";
|
|
import { urlDoSupabaseNoServidor } from "@/lib/supabase/url-do-servidor";
|
|
import { isPublicPath } from "@/lib/auth/public-paths";
|
|
import {
|
|
verifyImpersonateCookieEdge,
|
|
IMPERSONATE_COOKIE_NAME_EDGE,
|
|
} from "@/lib/impersonate/cookie-edge";
|
|
|
|
const COOKIE_NAME = "sb-deskcomm-auth";
|
|
|
|
export async function proxy(request: NextRequest) {
|
|
const response = NextResponse.next({ request: { headers: request.headers } });
|
|
|
|
// Inject X-Request-Id for downstream correlation (audit log, error wrappers).
|
|
const requestId = request.headers.get("x-request-id") ?? crypto.randomUUID();
|
|
response.headers.set("x-request-id", requestId);
|
|
|
|
const { pathname, search } = request.nextUrl;
|
|
// Recupera retornos de OAuth social já emitidos antes da landing pública existir.
|
|
// Apenas a navegação é tratada: o vínculo de conta segue protegido pelos guards canônicos.
|
|
// Passa adiante só o SINAL `connected=1` — nunca o `connect_token` nem o valor recebido.
|
|
if (
|
|
request.method === "GET" &&
|
|
pathname === "/app/connections" &&
|
|
request.nextUrl.searchParams.has("connected") &&
|
|
request.nextUrl.searchParams.has("connect_token")
|
|
) {
|
|
const landing = NextResponse.redirect(new URL("/auth/social-return?connected=1", request.url));
|
|
landing.headers.set("Cache-Control", "no-store");
|
|
landing.headers.set("Referrer-Policy", "no-referrer");
|
|
return landing;
|
|
}
|
|
// Expose pathname to Server Components via header (used by onboarding layout).
|
|
response.headers.set("x-pathname", pathname);
|
|
request.headers.set("x-pathname", pathname);
|
|
|
|
// EPIC-11: the admin surface is reached by PATH (`/admin/*`) — the self-host kit
|
|
// points `NEXT_PUBLIC_ADMIN_URL` at the same host as the app and maps no `admin.`
|
|
// sub-domain. The host-based branch below stays a NOOP today and only exists as
|
|
// documentation of the intended deploy topology.
|
|
const host = request.headers.get("host") ?? "";
|
|
const isAdminSurface = host.startsWith("admin.") || pathname.startsWith("/admin");
|
|
|
|
if (isPublicPath(pathname)) {
|
|
return response;
|
|
}
|
|
|
|
const supabase = createServerClient(
|
|
// #1082: base na URL pública, endereço interno só no transporte (ver
|
|
// `lib/supabase/fetch-do-servidor.ts`). O middleware não gera link, mas
|
|
// segue a mesma regra dos outros dois clients — um desenho só.
|
|
env.NEXT_PUBLIC_SUPABASE_URL,
|
|
env.NEXT_PUBLIC_SUPABASE_ANON_KEY,
|
|
{
|
|
global: {
|
|
fetch: fetchDoServidor(
|
|
urlDoSupabaseNoServidor(env.SUPABASE_SERVER_URL, env.NEXT_PUBLIC_SUPABASE_URL),
|
|
env.NEXT_PUBLIC_SUPABASE_URL,
|
|
),
|
|
},
|
|
cookies: {
|
|
getAll() {
|
|
return request.cookies.getAll();
|
|
},
|
|
setAll(cookiesToSet: { name: string; value: string; options: CookieOptions }[]) {
|
|
cookiesToSet.forEach(({ name, value, options }) => {
|
|
request.cookies.set(name, value);
|
|
response.cookies.set(name, value, options);
|
|
});
|
|
},
|
|
},
|
|
cookieOptions: {
|
|
name: COOKIE_NAME,
|
|
sameSite: "strict",
|
|
httpOnly: true,
|
|
secure: cookieSecure(),
|
|
path: "/",
|
|
},
|
|
},
|
|
);
|
|
|
|
// Validate JWT server-side (NEVER use getSession on backend per CLAUDE.md).
|
|
const {
|
|
data: { user },
|
|
} = await supabase.auth.getUser();
|
|
|
|
if (!user) {
|
|
// API routes must respond with JSON envelope (contract: {error:{code,message}})
|
|
// — never redirect HTML to JSON consumers. UI routes redirect to /login as before.
|
|
if (pathname.startsWith("/api/")) {
|
|
return new NextResponse(
|
|
JSON.stringify({
|
|
error: {
|
|
code: "unauthenticated",
|
|
message: "Authentication required",
|
|
},
|
|
}),
|
|
{
|
|
status: 401,
|
|
headers: {
|
|
"content-type": "application/json",
|
|
"x-request-id": requestId,
|
|
},
|
|
},
|
|
);
|
|
}
|
|
const loginUrl = new URL("/login", request.url);
|
|
loginUrl.searchParams.set("next", pathname + search);
|
|
return NextResponse.redirect(loginUrl);
|
|
}
|
|
|
|
// EPIC-11 S-11.07: validate impersonate cookie on /app/* paths. Middleware
|
|
// runs in Edge — no DB access, only HMAC + expiry. On any failure we delete
|
|
// the presentation cookie. The database support session remains authoritative:
|
|
// expired/revoked support still blocks the app until explicit exit.
|
|
if (pathname.startsWith("/app")) {
|
|
const impCookie = request.cookies.get(IMPERSONATE_COOKIE_NAME_EDGE)?.value;
|
|
if (impCookie) {
|
|
const result = await verifyImpersonateCookieEdge(
|
|
impCookie,
|
|
env.IMPERSONATE_COOKIE_SECRET ?? "",
|
|
);
|
|
if (!result.valid) {
|
|
console.warn(
|
|
`[middleware] impersonate cookie invalid (${result.reason ?? "unknown"}) — clearing`,
|
|
);
|
|
response.cookies.delete(IMPERSONATE_COOKIE_NAME_EDGE);
|
|
}
|
|
}
|
|
}
|
|
|
|
// /admin/* additionally requires platform_admin (early gate — authoritative
|
|
// check is server-side in `requirePlatformAdmin`). Skip the RPC for
|
|
// `/admin/forbidden` (rendered to non-admins, would otherwise loop).
|
|
if (isAdminSurface && pathname.startsWith("/admin") && pathname !== "/admin/forbidden") {
|
|
const { data: isAdmin, error } = await supabase.rpc("fn_is_platform_admin");
|
|
if (error || !isAdmin) {
|
|
return NextResponse.redirect(new URL("/admin/forbidden", request.url));
|
|
}
|
|
}
|
|
|
|
return response;
|
|
}
|
|
|
|
export const config = {
|
|
matcher: [
|
|
// Run on all paths except static assets / Next internals.
|
|
"/((?!_next/static|_next/image|favicon.ico|robots.txt|sitemap.xml|.*\\.(?:svg|png|jpg|jpeg|gif|webp|ico|css|js)$).*)",
|
|
],
|
|
};
|