feat(storage): MinIO object storage, Neon Auth, Supabase removal
Deploy to route.crispygoat.com / deploy (push) Failing after 3m1s

- Add MinIO/S3-compatible storage client (src/lib/storage.ts) with uploadObject,
  deleteObject, presigned URL helpers, and BUCKETS constant
- Wire product images, brand logos, and water log photos to MinIO via the
  new storage client
- Migrate forgot-password to Neon Auth (remove Supabase /auth/v1/recover call)
- Migrate send-scheduled cron to direct Postgres + Resend (remove Supabase Edge
  Function proxy)
- Add logoUrl to email types (OrderReceipt, Welcome, PasswordReset) and pass
  brand_settings.logo_url from all call sites
- Update email templates to use dynamic logoUrl instead of hardcoded Supabase
  bucket URLs
- Remove hardcoded Supabase URLs from TuxedoVideoHero, TuxedoAboutPage,
  TimeTrackingFieldClient; use brand_settings props + local public/ fallback
- Download brand logos (3) and tuxedo-hero.mp4 (36MB) from Supabase bucket to
  public/ for local development
- Add MinIO env vars to .env.example (endpoint, access key, secret, buckets)
- Fix TimeTrackingFieldClient to destructure logoUrl and brandAccent props
- Fix admin/users.ts logoUrl type (null → undefined for optional string)
- Remove stale sb- cookie from wholesale-auth
- Migrate tuxedo/about page to remove supabase import and use pool query for
  wholesale_settings lookup
This commit is contained in:
openclaw
2026-06-09 11:32:17 -06:00
parent bb464bda65
commit 916ad39176
349 changed files with 6706 additions and 3343 deletions
+1 -1
View File
@@ -61,7 +61,7 @@ Use "trending" for product popularity questions.
Use "top_customers" for customer ranking questions.
Use "recent_orders" for recent order questions.`;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const client = aiResult.client as { chat: { completions: { create: (opts: unknown) => Promise<{ choices: Array<{ message: { content: string } }> }> } } };
const response = await client.chat.completions.create({
model: aiResult.model,
+1 -1
View File
@@ -58,7 +58,7 @@ Historical Order Data: ${JSON.stringify(historicalData ?? [])}
Analyze demand patterns and return JSON with currentTrend, prediction (nextStopVolume, nextWeekVolume, confidence, confidenceReason), recommendedStock (units, reasoning), seasonalFactors array, and riskFlags array.`;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const client = aiResult.client as { chat: { completions: { create: (opts: unknown) => Promise<{ choices: Array<{ message: { content: string } }> }> } } };
const response = await client.chat.completions.create({
model: aiResult.model,
+1 -1
View File
@@ -58,7 +58,7 @@ Historical Sales (last 30-90 days): ${JSON.stringify(historicalSales ?? [])}
Analyze pricing and return JSON with currentState, recommendations array, opportunities, and warnings.`;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const client = aiResult.client as { chat: { completions: { create: (opts: unknown) => Promise<{ choices: Array<{ message: { content: string } }> }> } } };
const response = await client.chat.completions.create({
model: aiResult.model,
+1 -1
View File
@@ -63,7 +63,7 @@ ${JSON.stringify(sampleRows, null, 2)}
Return JSON with summary, keyInsights (array), and suggestedActions (array).`;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const client = aiResult.client as { chat: { completions: { create: (opts: unknown) => Promise<{ choices: Array<{ message: { content: string } }> }> } } };
const response = await client.chat.completions.create({
model: aiResult.model,
+1 -1
View File
@@ -62,7 +62,7 @@ ${stopsList}
Optimize the route for efficiency. Return JSON with optimizedSequence, totalEstimatedDistance, totalEstimatedDriveTime, warnings, and suggestions.`;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const client = aiResult.client as { chat: { completions: { create: (opts: unknown) => Promise<{ choices: Array<{ message: { content: string } }> }> } } };
const response = await client.chat.completions.create({
model: aiResult.model,
+1 -1
View File
@@ -64,7 +64,7 @@ Customer Count: ${customerCount ?? "unknown"}
Analyze this stop's context and order history to recommend the optimal stop blast message.
Return JSON with timingRecommendation, subjectLine, bodyPreview, audienceSize, audienceRecommendation, contentAngles array, and warnings.`;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const client = aiResult.client as { chat: { completions: { create: (opts: unknown) => Promise<{ choices: Array<{ message: { content: string } }> }> } } };
const response = await client.chat.completions.create({
model: aiResult.model,
+13 -4
View File
@@ -1,4 +1,13 @@
// Auth.js v5 route handler — re-exports the GET/POST handlers from src/lib/auth.ts
// Mounted at /api/auth/* (signin, signout, callback, session, csrf, providers, etc.)
import { handlers } from "@/lib/auth";
export const { GET, POST } = handlers;
// Neon Auth API route handles all auth endpoints via Better Auth server proxy.
// Mounted at /api/auth/* (sign-in, sign-out, session, callbacks, etc.)
import { NextRequest } from "next/server";
import { handlersGET, handlersPOST } from "@/lib/auth";
// Re-export as GET and POST for Next.js route handler
export const GET = async (req: NextRequest, ctx: { params: Promise<{ nextauth: string[] }> }) => {
return handlersGET(req as unknown as Request, ctx as unknown as { params: Promise<{ path: string[] }> });
};
export const POST = async (req: NextRequest, ctx: { params: Promise<{ nextauth: string[] }> }) => {
return handlersPOST(req as unknown as Request, ctx as unknown as { params: Promise<{ path: string[] }> });
};
+78
View File
@@ -0,0 +1,78 @@
import { NextRequest, NextResponse } from "next/server";
import { getSession, setUserPassword } from "@/lib/auth";
interface ChangePasswordBody {
password: string;
userId?: string;
}
export async function POST(req: NextRequest) {
let body: ChangePasswordBody;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Invalid request body." }, { status: 400 });
}
const { password, userId } = body;
if (!password || password.length < 8) {
return NextResponse.json(
{ error: "Password must be at least 8 characters." },
{ status: 400 }
);
}
// Get the current session
const { data: session } = await getSession();
const sessionUserId = session?.user?.id;
if (!sessionUserId) {
return NextResponse.json(
{ error: "Not authenticated." },
{ status: 401 }
);
}
// If userId is provided, require admin privileges (handled by updatePasswordAction)
// If no userId, the user is changing their own password
const targetUserId = userId ?? sessionUserId;
// Non-admin users can only change their own password
if (userId && userId !== sessionUserId) {
// For now, only admins can change other users' passwords
// This is handled by the updatePasswordAction in admin/password.ts
return NextResponse.json(
{ error: "Use the admin panel to change other users' passwords." },
{ status: 403 }
);
}
try {
const result = await setUserPassword({
userId: targetUserId,
newPassword: password,
});
if (result.error) {
console.error("[auth/change-password] Failed:", result.error);
return NextResponse.json(
{
error: result.error.code ?? "ChangeFailed",
message: result.error.message ?? "Failed to change password.",
},
{ status: 400 }
);
}
console.log("[auth/change-password] Password changed for user:", targetUserId);
return NextResponse.json({ success: true });
} catch (err) {
console.error("[auth/change-password] Unexpected error:", err);
return NextResponse.json(
{ error: "InternalError", message: "An unexpected error occurred." },
{ status: 500 }
);
}
}
+49
View File
@@ -0,0 +1,49 @@
import { NextRequest, NextResponse } from "next/server";
import { requestPasswordReset } from "@/lib/auth";
interface ForgotPasswordBody {
email: string;
}
export async function POST(req: NextRequest) {
let body: ForgotPasswordBody;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Invalid request body." }, { status: 400 });
}
const { email } = body;
const trimmedEmail = typeof email === "string" ? email.trim().toLowerCase() : "";
if (!trimmedEmail) {
return NextResponse.json(
{ error: "Email is required." },
{ status: 400 }
);
}
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? "http://localhost:4000";
try {
const result = await requestPasswordReset({
email: trimmedEmail,
redirectTo: `${siteUrl}/reset-password`,
});
if (result.error) {
console.error("[auth/forgot-password] Request failed:", result.error);
// Don't reveal whether the email exists or not for security
return NextResponse.json({ success: true });
}
console.log("[auth/forgot-password] Reset email sent to:", trimmedEmail);
// Always return success to prevent email enumeration
return NextResponse.json({ success: true });
} catch (err) {
console.error("[auth/forgot-password] Unexpected error:", err);
// Don't reveal error details to the client
return NextResponse.json({ success: true });
}
}
+51
View File
@@ -0,0 +1,51 @@
import { NextRequest, NextResponse } from "next/server";
import { resetPassword } from "@/lib/auth";
interface ResetPasswordBody {
password: string;
}
export async function POST(req: NextRequest) {
let body: ResetPasswordBody;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Invalid request body." }, { status: 400 });
}
const { password } = body;
if (!password || password.length < 8) {
return NextResponse.json(
{ error: "Password must be at least 8 characters." },
{ status: 400 }
);
}
try {
const result = await resetPassword({
newPassword: password,
});
if (result.error) {
console.error("[auth/reset-password] Reset failed:", result.error);
return NextResponse.json(
{
error: result.error.code ?? "ResetFailed",
message: result.error.message ?? "Failed to reset password. The link may have expired.",
},
{ status: 400 }
);
}
console.log("[auth/reset-password] Password reset successful");
return NextResponse.json({ success: true });
} catch (err) {
console.error("[auth/reset-password] Unexpected error:", err);
return NextResponse.json(
{ error: "InternalError", message: "An unexpected error occurred." },
{ status: 500 }
);
}
}
+52
View File
@@ -0,0 +1,52 @@
import { NextRequest, NextResponse } from "next/server";
import { signIn } from "@/lib/auth";
interface SignInBody {
email: string;
password: string;
}
export async function POST(req: NextRequest) {
let body: SignInBody;
try {
body = await req.json();
} catch (err) {
return NextResponse.json({ error: "Invalid request body." }, { status: 400 });
}
const email = body?.email;
const password = body?.password;
if (!email || !password) {
return NextResponse.json(
{ error: "MissingCredentials", message: "Email and password are required." },
{ status: 400 }
);
}
try {
const result = await signIn.email({
email,
password,
});
console.log("[sign-in] signIn.email result:", JSON.stringify(result));
if (result.error) {
console.log("[sign-in] sign-in error:", result.error);
return NextResponse.json(
{ error: result.error.code ?? "SignInFailed", message: result.error.message ?? "Invalid email or password." },
{ status: 401 }
);
}
console.log("[sign-in] sign-in success, session should be set");
return NextResponse.json({ success: true });
} catch (err) {
console.error("[sign-in] Unexpected error:", err);
return NextResponse.json(
{ error: "ServiceError", message: "An unexpected error occurred." },
{ status: 500 }
);
}
}
+14
View File
@@ -0,0 +1,14 @@
import { NextResponse } from "next/server";
import { signOut } from "@/lib/auth";
export async function POST() {
try {
await signOut();
// This line won't be reached because signOut() redirects
return NextResponse.json({ success: true });
} catch (err) {
console.error("[auth/sign-out] Error:", err);
// Even if signOut throws, redirect to login
return NextResponse.redirect(new URL("/login", process.env.NEXT_PUBLIC_SITE_URL ?? "http://localhost:4000"));
}
}
+89 -28
View File
@@ -1,35 +1,96 @@
import { NextResponse } from "next/server";
import { svcHeaders } from "@/lib/svc-headers";
import { pool } from "@/lib/db";
import { sendCampaignEmail } from "@/lib/email-service";
export async function GET() {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
if (!supabaseUrl || !supabaseKey) {
return NextResponse.json({ error: "Missing configuration" }, { status: 500 });
/**
* Cron: /api/cron/send-scheduled
* Runs daily at 09:00 (declared in vercel.json).
* Finds all campaigns with status='scheduled' and scheduled_at <= now(),
* sends each one to all opted-in contacts for that brand, then marks
* the campaign as 'sent'.
*
* Auth: Bearer token via CRON_SECRET header.
*/
export async function GET(request: Request) {
const authHeader = request.headers.get("authorization") ?? "";
const expected = `Bearer ${process.env.CRON_SECRET ?? ""}`;
if (!expected || authHeader !== expected) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const response = await fetch(
`${supabaseUrl}/functions/v1/send-scheduled-campaigns`,
{ headers: { ...svcHeaders(supabaseKey!), "Content-Type": "application/json" } }
);
const text = await response.text();
if (!response.ok) {
return NextResponse.json({ error: "Failed to send campaigns", detail: text }, { status: 500 });
}
let results: unknown[] = [];
try {
const data = JSON.parse(text);
if (data && typeof data === "object") {
const d = data as Record<string, unknown>;
if (Array.isArray(d.results)) results = d.results as unknown[];
else if (Array.isArray(d.send_scheduled_campaigns)) results = d.send_scheduled_campaigns as unknown[];
else if (Array.isArray(data)) results = data as unknown[];
}
} catch { /* ignore */ }
// 1. Find campaigns due to be sent
const { rows: campaigns } = await pool.query<{
id: string;
brand_id: string;
name: string;
subject: string | null;
body_html: string | null;
brand_name: string | null;
}>(
`SELECT id, brand_id, name, subject, body_html, brand_name
FROM communication_campaigns
WHERE status = 'scheduled'
AND scheduled_at IS NOT NULL
AND scheduled_at <= now()
ORDER BY scheduled_at ASC`
);
return NextResponse.json({ success: true, timestamp: new Date().toISOString(), results });
if (campaigns.length === 0) {
return NextResponse.json({ success: true, timestamp: new Date().toISOString(), results: [] });
}
const results: Array<{ campaignId: string; name: string; sent: number; errors: number }> = [];
for (const campaign of campaigns) {
if (!campaign.body_html) {
await pool.query(
`UPDATE communication_campaigns SET status = 'sent', sent_at = now() WHERE id = $1`,
[campaign.id]
);
results.push({ campaignId: campaign.id, name: campaign.name, sent: 0, errors: 0 });
continue;
}
// 2. Find opted-in contacts for this brand
const { rows: contacts } = await pool.query<{ id: string; email: string | null; full_name: string | null }>(
`SELECT id, email, full_name
FROM communication_contacts
WHERE brand_id = $1
AND email_opt_in = true
AND email IS NOT NULL
AND unsubscribed_at IS NULL`,
[campaign.brand_id]
);
let sent = 0;
let errors = 0;
for (const contact of contacts) {
if (!contact.email) continue;
const ok = await sendCampaignEmail({
to: contact.email,
subject: campaign.subject ?? campaign.name,
html: campaign.body_html,
});
if (ok) sent++;
else errors++;
}
// 3. Mark campaign as sent
await pool.query(
`UPDATE communication_campaigns
SET status = 'sent', sent_at = now(), recipient_count = $2
WHERE id = $1`,
[campaign.id, sent]
);
results.push({ campaignId: campaign.id, name: campaign.name, sent, errors });
}
return NextResponse.json({ success: true, timestamp: new Date().toISOString(), results });
} catch (err) {
console.error("[cron/send-scheduled] Error:", err);
return NextResponse.json({ error: "Internal error" }, { status: 500 });
}
}
+29 -28
View File
@@ -1,36 +1,37 @@
import { NextResponse } from "next/server";
import { headers } from "next/headers";
import { NextRequest, NextResponse } from "next/server";
import { requestPasswordReset } from "@/lib/auth";
export async function POST(request: Request) {
const formData = await request.formData();
const email = formData.get("email") as string;
export async function POST(req: NextRequest) {
let body: { email?: string };
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Invalid request body." }, { status: 400 });
}
const email = typeof body?.email === "string" ? body.email.trim().toLowerCase() : "";
if (!email) {
return NextResponse.json({ error: "Email is required." }, { status: 400 });
}
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
if (!supabaseUrl || !supabaseAnonKey) {
return NextResponse.json({ error: "Server misconfiguration." }, { status: 500 });
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? "http://localhost:4000";
try {
const result = await requestPasswordReset({
email,
redirectTo: `${siteUrl}/reset-password`,
});
if (result.error) {
console.error("[auth/forgot-password] Neon Auth error:", result.error);
}
// Always return success to prevent email enumeration
return NextResponse.json({ success: true });
} catch (err) {
console.error("[auth/forgot-password] Unexpected error:", err);
return NextResponse.json({ success: true });
}
const origin = (await headers()).get("origin") ?? "http://localhost:3000";
const res = await fetch(`${supabaseUrl}/auth/v1/recover`, {
method: "POST",
headers: {
"Content-Type": "application/json",
apikey: supabaseAnonKey,
},
body: JSON.stringify({ email, redirectTo: `${origin}/auth/callback` }),
});
// Always return 200 to avoid email enumeration — Supabase may still send or not
if (!res.ok) {
const data = await res.json().catch(() => null);
return NextResponse.json({ error: data?.message ?? "Failed to send reset link." }, { status: 400 });
}
return NextResponse.json({ ok: true });
}
@@ -9,7 +9,7 @@ export async function GET() {
.from("brands")
.select("id, name")
.eq("slug", brandSlug)
.single();
.single() as unknown as { data: { id: string; name: string } | null };
if (!brand?.id) {
return new NextResponse("Brand not found", { status: 404 });
@@ -20,13 +20,13 @@ export async function GET() {
.select("*")
.eq("brand_id", brand.id)
.eq("active", true)
.order("date", { ascending: true });
.order("date", { ascending: true }) as unknown as { data: { city: string; state: string; date: string; time: string; location: string }[] | null };
const { data: settings } = await supabase
.from("brand_settings")
.select("logo_url, email, phone, schedule_pdf_notes")
.eq("brand_id", brand.id)
.single();
.single() as unknown as { data: { logo_url: string | null; email: string | null; phone: string | null; schedule_pdf_notes: string | null } | null };
const pdfBytes = await buildProfessionalSchedulePdf({
brandName: brand.name,
+5 -5
View File
@@ -41,9 +41,9 @@ export async function GET(req: NextRequest) {
db
.select({
id: orders.id,
tenantId: orders.tenantId,
brandId: orders.brandId,
customerId: orders.customerId,
customerName: customers.name,
customerName: customers.fullName,
customerEmail: customers.email,
status: orders.status,
totalCents: orders.totalCents,
@@ -51,7 +51,7 @@ export async function GET(req: NextRequest) {
})
.from(orders)
.leftJoin(customers, eq(customers.id, orders.customerId))
.where(eq(orders.tenantId, brandId))
.where(eq(orders.brandId, brandId))
.orderBy(desc(orders.placedAt))
.limit(1000),
)
@@ -59,9 +59,9 @@ export async function GET(req: NextRequest) {
db
.select({
id: orders.id,
tenantId: orders.tenantId,
brandId: orders.brandId,
customerId: orders.customerId,
customerName: customers.name,
customerName: customers.fullName,
customerEmail: customers.email,
status: orders.status,
totalCents: orders.totalCents,
+2 -2
View File
@@ -74,8 +74,8 @@ export async function GET(req: NextRequest) {
// Collect all logs across brands
let allLogs: LogEntry[] = [];
let allSettings: Awaited<ReturnType<typeof getTimeTrackingSettings>>[] = [];
let allWorkers: Awaited<ReturnType<typeof getTimeTrackingWorkers>>[] = [];
const allSettings: Awaited<ReturnType<typeof getTimeTrackingSettings>>[] = [];
const allWorkers: Awaited<ReturnType<typeof getTimeTrackingWorkers>>[] = [];
for (const brandId of brandIds) {
const [workers, settings, logs] = await Promise.all([
+4 -4
View File
@@ -17,7 +17,7 @@ export async function GET() {
.from("brands")
.select("id, name")
.eq("slug", brandSlug)
.single();
.single() as unknown as { data: { id: string; name: string } | null };
if (!brand?.id) {
return new NextResponse("Brand not found", { status: 404 });
@@ -25,16 +25,16 @@ export async function GET() {
const { data: stops } = await supabase
.from("stops")
.select("*")
.select("city, state, date, time, location")
.eq("brand_id", brand.id)
.eq("active", true)
.order("date", { ascending: true });
.order("date", { ascending: true }) as unknown as { data: { city: string; state: string; date: string; time: string; location: string }[] | null };
const { data: settings } = await supabase
.from("brand_settings")
.select("logo_url, email, phone, schedule_pdf_notes")
.eq("brand_id", brand.id)
.single();
.single() as unknown as { data: { logo_url: string | null; email: string | null; phone: string | null; schedule_pdf_notes: string | null } | null };
const pdfBytes = await buildProfessionalSchedulePdf({
brandName: brand.name,
+13
View File
@@ -5,6 +5,7 @@ import { emailLimiter, checkRateLimit, rateLimitExceeded, securityHeaders } from
import { analytics } from "@/lib/analytics";
import { captureError } from "@/lib/sentry";
import { pool } from "@/lib/db";
import { getAdminUser } from "@/lib/admin-permissions";
// Helper functions
function apiResponse(data: unknown, status: number = 200) {
@@ -36,6 +37,12 @@ const createCampaignSchema = z.object({
export async function POST(req: NextRequest) {
try {
// Authentication check
const adminUser = await getAdminUser();
if (!adminUser) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// Rate limiting
const rateCheck = await checkRateLimit(emailLimiter, req.headers.get("x-forwarded-for") || "anonymous");
if (!rateCheck.success) {
@@ -83,6 +90,12 @@ export async function POST(req: NextRequest) {
export async function GET(req: NextRequest) {
try {
// Authentication check
const adminUser = await getAdminUser();
if (!adminUser) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { searchParams } = new URL(req.url);
const brand_id = searchParams.get("brand_id");
+17 -4
View File
@@ -7,6 +7,7 @@ import { captureError } from "@/lib/sentry";
import { withDb, withPlatformAdmin } from "@/db/client";
import { products, type Product } from "@/db/schema";
import { and, eq, ilike, or, sql } from "drizzle-orm";
import { getAdminUser } from "@/lib/admin-permissions";
// Helper functions
function apiResponse(data: unknown, status: number = 200) {
@@ -36,6 +37,12 @@ const getProductsSchema = z.object({
export async function GET(req: NextRequest) {
try {
// Authentication check
const adminUser = await getAdminUser();
if (!adminUser) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// Rate limiting
const rateCheck = await checkRateLimit(apiLimiter, req.headers.get("x-forwarded-for") || "anonymous");
if (!rateCheck.success) {
@@ -60,10 +67,10 @@ export async function GET(req: NextRequest) {
// Build the WHERE conditions. We use `withDb` (no tenant GUC) here
// because this is a public API — RLS isn't enforced via the new
// schema's app.current_tenant_id GUC, so we filter explicitly.
// schema's app.current_brand_id GUC, so we filter explicitly.
const whereParts = [];
if (validation.data.brand_id) {
whereParts.push(eq(products.tenantId, validation.data.brand_id));
whereParts.push(eq(products.brandId, validation.data.brand_id));
}
if (validation.data.is_active !== undefined) {
whereParts.push(eq(products.active, validation.data.is_active));
@@ -82,7 +89,7 @@ export async function GET(req: NextRequest) {
void validation.data.category;
// Public read across all tenants (this is a public catalog API, not
// an admin endpoint), so use `withDb` rather than `withTenant`.
// an admin endpoint), so use `withDb` rather than `withBrand`.
const rows = await withDb(async (db) =>
db
.select()
@@ -107,6 +114,12 @@ export async function GET(req: NextRequest) {
export async function POST(req: NextRequest) {
try {
// Authentication check
const adminUser = await getAdminUser();
if (!adminUser) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// Rate limiting
const rateCheck = await checkRateLimit(apiLimiter, req.headers.get("x-forwarded-for") || "anonymous");
if (!rateCheck.success) {
@@ -139,7 +152,7 @@ export async function POST(req: NextRequest) {
const [row] = await db
.insert(products)
.values({
tenantId: brand_id,
brandId: brand_id,
name,
description: description ?? null,
priceCents: Math.round(price * 100),
+13
View File
@@ -5,6 +5,7 @@ import { apiLimiter, checkRateLimit, rateLimitExceeded, securityHeaders } from "
import { analytics } from "@/lib/analytics";
import { captureError } from "@/lib/sentry";
import { pool } from "@/lib/db";
import { getAdminUser } from "@/lib/admin-permissions";
// Helper functions
function apiResponse(data: unknown, status: number = 200) {
@@ -31,6 +32,12 @@ const createReferralSchema = z.object({
export async function POST(req: NextRequest) {
try {
// Authentication check
const adminUser = await getAdminUser();
if (!adminUser) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// Rate limiting
const rateCheck = await checkRateLimit(apiLimiter, req.headers.get("x-forwarded-for") || "anonymous");
if (!rateCheck.success) {
@@ -69,6 +76,12 @@ export async function POST(req: NextRequest) {
export async function GET(req: NextRequest) {
try {
// Authentication check
const adminUser = await getAdminUser();
if (!adminUser) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { searchParams } = new URL(req.url);
const brand_id = searchParams.get("brand_id");
+7
View File
@@ -4,6 +4,7 @@ import { z } from "zod";
import { apiLimiter, checkRateLimit, rateLimitExceeded, securityHeaders } from "@/lib/rate-limit";
import { captureError } from "@/lib/sentry";
import { pool } from "@/lib/db";
import { getAdminUser } from "@/lib/admin-permissions";
// Helper functions
function apiResponse(data: unknown, status: number = 200) {
@@ -31,6 +32,12 @@ const reportFiltersSchema = z.object({
export async function GET(req: NextRequest) {
try {
// Authentication check
const adminUser = await getAdminUser();
if (!adminUser) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// Rate limiting
const rateCheck = await checkRateLimit(apiLimiter, req.headers.get("x-forwarded-for") || "anonymous");
if (!rateCheck.success) {
+13
View File
@@ -4,6 +4,7 @@ import { z } from "zod";
import { apiLimiter, checkRateLimit, rateLimitExceeded, securityHeaders } from "@/lib/rate-limit";
import { captureError } from "@/lib/sentry";
import { pool } from "@/lib/db";
import { getAdminUser } from "@/lib/admin-permissions";
// Helper functions
function apiResponse(data: unknown, status: number = 200) {
@@ -35,6 +36,12 @@ const createWaterLogSchema = z.object({
export async function POST(req: NextRequest) {
try {
// Authentication check
const adminUser = await getAdminUser();
if (!adminUser) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// Rate limiting
const rateCheck = await checkRateLimit(apiLimiter, req.headers.get("x-forwarded-for") || "anonymous");
if (!rateCheck.success) {
@@ -77,6 +84,12 @@ export async function POST(req: NextRequest) {
export async function GET(req: NextRequest) {
try {
// Authentication check
const adminUser = await getAdminUser();
if (!adminUser) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { searchParams } = new URL(req.url);
const brand_id = searchParams.get("brand_id");
+13 -28
View File
@@ -1,6 +1,6 @@
import { NextResponse } from "next/server";
import { cookies } from "next/headers";
import { svcHeaders } from "@/lib/svc-headers";
import { uploadObject, BUCKETS } from "@/lib/storage";
export async function POST(request: Request) {
try {
@@ -15,8 +15,8 @@ export async function POST(request: Request) {
const file = formData.get("file") as File | null;
const bucket = formData.get("bucket") as string | null;
if (!file || !bucket) {
return NextResponse.json({ error: "Missing file or bucket" }, { status: 400 });
if (!file) {
return NextResponse.json({ error: "Missing file" }, { status: 400 });
}
if (file.size > 5 * 1024 * 1024) {
@@ -27,34 +27,19 @@ export async function POST(request: Request) {
return NextResponse.json({ error: "Only JPG or PNG allowed" }, { status: 400 });
}
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabasePat = process.env.SUPABASE_PAT!;
const ext = file.type === "image/jpeg" ? "jpg" : "png";
const fileName = `${Date.now()}-${Math.random().toString(36).slice(2)}.${ext}`;
const key = `water-logs/${Date.now()}-${Math.random().toString(36).slice(2)}.${ext}`;
const arrayBuffer = await file.arrayBuffer();
const uint8 = new Uint8Array(arrayBuffer);
// bucket param is accepted for backwards compat but ignored — always use WATER_LOGS
const buffer = Buffer.from(await file.arrayBuffer());
const url = await uploadObject({
bucket: BUCKETS.WATER_LOGS,
key,
body: buffer,
contentType: file.type,
});
const uploadRes = await fetch(
`${supabaseUrl}/storage/v1/object/${bucket}/${fileName}`,
{
method: "POST",
headers: {
...svcHeaders(supabasePat),
"Content-Type": file.type,
"x-upsert": "true",
},
body: uint8,
}
);
if (!uploadRes.ok) {
return NextResponse.json({ error: "Upload failed" }, { status: 500 });
}
const publicUrl = `${supabaseUrl}/storage/v1/object/public/${bucket}/${fileName}`;
return NextResponse.json({ url: publicUrl });
return NextResponse.json({ url });
} catch (err) {
return NextResponse.json({ error: "Server error" }, { status: 500 });
}
@@ -2,6 +2,59 @@ import { NextResponse } from "next/server";
import crypto from "crypto";
import { pool } from "@/lib/db";
/**
* Validates a webhook URL to prevent SSRF attacks.
* Blocks:
* - Non-HTTPS URLs
* - localhost and 127.0.0.1
* - Private IP ranges (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16)
* - Link-local IPv6 addresses (fc00::/7, fe80::/10)
* - AWS metadata endpoint (169.254.169.254)
*/
function validateWebhookUrl(url: string): boolean {
try {
const parsed = new URL(url);
// Only allow HTTPS URLs
if (parsed.protocol !== "https:") {
return false;
}
const hostname = parsed.hostname.toLowerCase();
// Block localhost and loopback
if (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1") {
return false;
}
// Block private IP ranges using regex patterns
const blockedPatterns = [
// 10.0.0.0/8 - Class A private network
/^10\./,
// 172.16.0.0/12 - Class B private network (172.16.x.x - 172.31.x.x)
/^172\.(1[6-9]|2[0-9]|3[01])\./,
// 192.168.0.0/16 - Class C private network
/^192\.168\./,
// AWS metadata endpoint
/^169\.254\.169\.254$/,
// Link-local IPv6 (fc00::/7 and fe80::/10)
/^fc00:/i,
/^fe80:/i,
];
for (const pattern of blockedPatterns) {
if (pattern.test(hostname)) {
return false;
}
}
return true;
} catch {
// Invalid URL format
return false;
}
}
// POST /api/wholesale/webhooks/dispatch
// Processes pending webhook events from wholesale_sync_log and dispatches to configured URLs.
// Called by a cron job or manually after enqueue_wholesale_webhook has queued events.
@@ -33,6 +86,19 @@ export async function POST() {
let dispatched = 0;
for (const webhook of pending) {
// Validate URL to prevent SSRF attacks
if (!validateWebhookUrl(webhook.url)) {
console.error("[SSRF_BLOCKED]", {
webhookId: webhook.id,
brandId: webhook.brand_id,
url: webhook.url,
eventType: webhook.event_type,
timestamp: new Date().toISOString(),
});
await markFailed(webhook.id, "SSRF attempt blocked: invalid webhook URL");
continue;
}
const payload = webhook.payload ?? {};
const payloadString = JSON.stringify(payload);