ops 订阅开通改为 Next.js 直连 Supabase,免去 VPS 鉴权链路
This commit is contained in:
@@ -1,25 +1,168 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { applyAuthResponseCookies, buildBackendRequestHeaders, BACKEND_ENTITLEMENT_HEADER } from "@/lib/backend-auth";
|
||||
import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
const ENTITLEMENT_TOKEN = process.env.POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN?.trim() || "";
|
||||
|
||||
function parseAdminEmails() {
|
||||
return String(process.env.POLYWEATHER_OPS_ADMIN_EMAILS || "")
|
||||
.split(",")
|
||||
.map((item) => item.trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
async function getBearerEmail(req: NextRequest) {
|
||||
const auth = String(req.headers.get("authorization") || "").trim();
|
||||
const token = auth.replace(/^bearer\s+/i, "").trim();
|
||||
const supabaseUrl = String(process.env.NEXT_PUBLIC_SUPABASE_URL || "").trim();
|
||||
const anonKey = String(process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || "").trim();
|
||||
if (!token || !supabaseUrl || !anonKey) return "";
|
||||
const res = await fetch(`${supabaseUrl.replace(/\/$/, "")}/auth/v1/user`, {
|
||||
headers: {
|
||||
apikey: anonKey,
|
||||
Authorization: `Bearer ${token}`,
|
||||
Accept: "application/json",
|
||||
},
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!res.ok) return "";
|
||||
const data = (await res.json()) as { email?: string };
|
||||
return String(data.email || "").trim().toLowerCase();
|
||||
}
|
||||
|
||||
async function findSupabaseUserIdByEmail(email: string) {
|
||||
const supabaseUrl = String(process.env.SUPABASE_URL || process.env.NEXT_PUBLIC_SUPABASE_URL || "")
|
||||
.trim()
|
||||
.replace(/\/$/, "");
|
||||
const serviceRoleKey = String(process.env.SUPABASE_SERVICE_ROLE_KEY || "").trim();
|
||||
if (!supabaseUrl || !serviceRoleKey) {
|
||||
throw new Error("Supabase service role is not configured on Vercel");
|
||||
}
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/auth/v1/admin/users?filter=${encodeURIComponent(`email.eq.${email}`)}`,
|
||||
{
|
||||
headers: {
|
||||
apikey: serviceRoleKey,
|
||||
Authorization: `Bearer ${serviceRoleKey}`,
|
||||
Accept: "application/json",
|
||||
},
|
||||
cache: "no-store",
|
||||
},
|
||||
);
|
||||
const data = (await res.json().catch(() => ({}))) as {
|
||||
users?: Array<{ id?: string }>;
|
||||
};
|
||||
if (!res.ok) throw new Error(`Supabase user lookup failed: ${JSON.stringify(data).slice(0, 200)}`);
|
||||
const userId = String(data.users?.[0]?.id || "").trim();
|
||||
if (!userId) {
|
||||
const error = new Error(`user not found: ${email}`);
|
||||
(error as Error & { status?: number }).status = 404;
|
||||
throw error;
|
||||
}
|
||||
return { supabaseUrl, serviceRoleKey, userId };
|
||||
}
|
||||
|
||||
async function grantSubscriptionDirectly(req: NextRequest, bodyText: string, authEmail?: string | null) {
|
||||
const adminEmail = String(authEmail || (await getBearerEmail(req)) || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const allowedEmails = parseAdminEmails();
|
||||
if (!adminEmail) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
if (!allowedEmails.includes(adminEmail)) {
|
||||
return NextResponse.json({ error: "ops admin required" }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = JSON.parse(bodyText || "{}") as {
|
||||
email?: string;
|
||||
plan_code?: string;
|
||||
days?: number;
|
||||
};
|
||||
const email = String(body.email || "").trim().toLowerCase();
|
||||
const planCode = String(body.plan_code || "pro_monthly").trim();
|
||||
const days = Math.max(1, Math.min(365, Number(body.days || 30)));
|
||||
if (!email) return NextResponse.json({ error: "email is required" }, { status: 400 });
|
||||
if (planCode !== "pro_monthly") {
|
||||
return NextResponse.json({ error: "invalid plan_code" }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const { supabaseUrl, serviceRoleKey, userId } = await findSupabaseUserIdByEmail(email);
|
||||
const now = new Date();
|
||||
const expires = new Date(now.getTime() + days * 86_400_000);
|
||||
const payload = {
|
||||
user_id: userId,
|
||||
email,
|
||||
plan_code: planCode,
|
||||
"status": "active",
|
||||
starts_at: now.toISOString(),
|
||||
expires_at: expires.toISOString(),
|
||||
source: "ops_manual_grant_next_fallback",
|
||||
created_at: now.toISOString(),
|
||||
updated_at: now.toISOString(),
|
||||
};
|
||||
const insert = await fetch(`${supabaseUrl}/rest/v1/subscriptions`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
apikey: serviceRoleKey,
|
||||
Authorization: `Bearer ${serviceRoleKey}`,
|
||||
"Content-Type": "application/json",
|
||||
Prefer: "return=representation",
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
cache: "no-store",
|
||||
});
|
||||
const raw = await insert.text();
|
||||
if (!insert.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: "Supabase insert failed", detail: raw.slice(0, 300) },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
user_id: userId,
|
||||
plan_code: planCode,
|
||||
days,
|
||||
expires_at: expires.toISOString(),
|
||||
fallback: "next_supabase_direct",
|
||||
});
|
||||
} catch (error) {
|
||||
const status = Number((error as Error & { status?: number }).status || 500);
|
||||
return NextResponse.json({ error: String(error) }, { status });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
if (!API_BASE) return NextResponse.json({ error: "API_BASE not configured" }, { status: 500 });
|
||||
try {
|
||||
const auth = await buildBackendRequestHeaders(req);
|
||||
const body = await req.text();
|
||||
const headers: Record<string, string> = { ...auth.headers as Record<string, string>, "Content-Type": "application/json" };
|
||||
// Ops endpoints: pass entitlement token as Bearer for robust admin auth.
|
||||
if (ENTITLEMENT_TOKEN) {
|
||||
headers.Authorization = `Bearer ${ENTITLEMENT_TOKEN}`;
|
||||
if (!API_BASE) {
|
||||
return grantSubscriptionDirectly(req, body, auth.authEmail);
|
||||
}
|
||||
|
||||
const res = await fetch(`${API_BASE}/api/ops/subscriptions/grant`, {
|
||||
method: "POST", headers, body, cache: "no-store",
|
||||
method: "POST",
|
||||
headers: { ...(auth.headers as Record<string, string>), "Content-Type": "application/json" },
|
||||
body,
|
||||
cache: "no-store",
|
||||
});
|
||||
const raw = await res.text();
|
||||
const response = new NextResponse(raw, { status: res.status, headers: { "Content-Type": "application/json", "Cache-Control": "no-store" } });
|
||||
if (res.status === 404) {
|
||||
const fallback = await grantSubscriptionDirectly(req, body, auth.authEmail);
|
||||
return applyAuthResponseCookies(fallback, auth.response);
|
||||
}
|
||||
const response = new NextResponse(raw, {
|
||||
status: res.status,
|
||||
headers: {
|
||||
"Content-Type": res.headers.get("content-type") || "application/json",
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (e) { return buildProxyExceptionResponse(e, { publicMessage: "Subscription grant failed" }); }
|
||||
} catch (e) {
|
||||
return buildProxyExceptionResponse(e, { publicMessage: "Subscription grant failed" });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,18 @@ export function runTests() {
|
||||
path.join(projectRoot, "app", "api", "analytics", "events", "route.ts"),
|
||||
"utf8",
|
||||
);
|
||||
const grantRouteSource = fs.readFileSync(
|
||||
path.join(
|
||||
projectRoot,
|
||||
"app",
|
||||
"api",
|
||||
"ops",
|
||||
"subscriptions",
|
||||
"grant",
|
||||
"route.ts",
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
const subscriptionsPageSource = fs.readFileSync(
|
||||
path.join(
|
||||
projectRoot,
|
||||
@@ -84,4 +96,10 @@ export function runTests() {
|
||||
subscriptionsPageSource.includes("/api/ops/subscriptions/extend"),
|
||||
"ops manual subscription grant/extend must send the Supabase bearer token to avoid 401 when route cookies are stale",
|
||||
);
|
||||
assert(
|
||||
grantRouteSource.includes("grantSubscriptionDirectly") &&
|
||||
grantRouteSource.includes("res.status === 404") &&
|
||||
grantRouteSource.includes('"status": "active"'),
|
||||
"ops subscription grant route must fall back to direct Supabase grant when the VPS backend route is missing",
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user