security: replace public PDF URLs with authenticated API route, private bucket
All checks were successful
Staging Build / build (push) Successful in 3m33s
All checks were successful
Staging Build / build (push) Successful in 3m33s
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { createClient } from "@/lib/supabase/server";
|
||||||
import { createAdminClient } from "@/lib/supabase/admin";
|
import { createAdminClient } from "@/lib/supabase/admin";
|
||||||
import { InvoicePDF } from "@/components/invoice-pdf";
|
import { InvoicePDF } from "@/components/invoice-pdf";
|
||||||
import { renderToBuffer } from "@react-pdf/renderer";
|
import { renderToBuffer } from "@react-pdf/renderer";
|
||||||
@@ -10,13 +11,31 @@ export async function GET(
|
|||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const adminDb = createAdminClient();
|
|
||||||
const inline = request.nextUrl.searchParams.get("inline") === "true";
|
const inline = request.nextUrl.searchParams.get("inline") === "true";
|
||||||
|
|
||||||
// 1. Get the order from database
|
// 1. Auth check
|
||||||
|
const supabase = await createClient();
|
||||||
|
const { data: { user } } = await supabase.auth.getUser();
|
||||||
|
if (!user) {
|
||||||
|
return new NextResponse("Unauthorized", { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: dbUser } = await supabase
|
||||||
|
.from("users")
|
||||||
|
.select("role, company_id")
|
||||||
|
.eq("id", user.id)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (!dbUser) {
|
||||||
|
return new NextResponse("Unauthorized", { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const adminDb = createAdminClient();
|
||||||
|
|
||||||
|
// 2. Get the order from database
|
||||||
const { data: order, error } = await adminDb
|
const { data: order, error } = await adminDb
|
||||||
.from("orders")
|
.from("orders")
|
||||||
.select("customer_data, order_data, total_price, created_at, pdf_url, order_number")
|
.select("customer_data, order_data, total_price, created_at, pdf_url, order_number, company_id, user_id")
|
||||||
.eq("id", id)
|
.eq("id", id)
|
||||||
.single();
|
.single();
|
||||||
|
|
||||||
@@ -24,10 +43,19 @@ export async function GET(
|
|||||||
return new NextResponse("Order not found", { status: 404 });
|
return new NextResponse("Order not found", { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 3. Authorization: admin, owner, or company member
|
||||||
|
const isAdmin = dbUser.role === "admin";
|
||||||
|
const isOwner = order.user_id === user.id;
|
||||||
|
const isCompanyMember = dbUser.company_id && order.company_id === dbUser.company_id;
|
||||||
|
|
||||||
|
if (!isAdmin && !isOwner && !isCompanyMember) {
|
||||||
|
return new NextResponse("Forbidden", { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
const storageFileName = `ab_${id}.pdf`;
|
const storageFileName = `ab_${id}.pdf`;
|
||||||
let fileBuffer: Uint8Array | ArrayBuffer | null = null;
|
let fileBuffer: Uint8Array | ArrayBuffer | null = null;
|
||||||
|
|
||||||
// 2. Try to download from storage if pdf_url is set
|
// 4. Try to download from storage if pdf_url is set
|
||||||
if (order.pdf_url) {
|
if (order.pdf_url) {
|
||||||
try {
|
try {
|
||||||
const { data: fileData, error: downloadError } = await adminDb
|
const { data: fileData, error: downloadError } = await adminDb
|
||||||
@@ -43,7 +71,7 @@ export async function GET(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. If file not found or pdf_url not set, generate dynamically
|
// 5. If file not found or pdf_url not set, generate dynamically
|
||||||
if (!fileBuffer) {
|
if (!fileBuffer) {
|
||||||
try {
|
try {
|
||||||
const formattedDate = new Date(order.created_at).toLocaleDateString('de-DE', {
|
const formattedDate = new Date(order.created_at).toLocaleDateString('de-DE', {
|
||||||
@@ -73,9 +101,7 @@ export async function GET(
|
|||||||
if (uploadError) {
|
if (uploadError) {
|
||||||
console.error("Failed to upload regenerated PDF:", uploadError);
|
console.error("Failed to upload regenerated PDF:", uploadError);
|
||||||
} else {
|
} else {
|
||||||
// Update db with pdf_url
|
await adminDb.from("orders").update({ pdf_url: storageFileName }).eq("id", id);
|
||||||
const path = `invoices/${storageFileName}`;
|
|
||||||
await adminDb.from("orders").update({ pdf_url: path }).eq("id", id);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fileBuffer = buffer;
|
fileBuffer = buffer;
|
||||||
@@ -85,7 +111,7 @@ export async function GET(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Return as PDF response
|
// 6. Return as PDF response
|
||||||
const downloadFileName = `Anfragebestaetigung_${order.order_number || id.slice(0, 8)}.pdf`;
|
const downloadFileName = `Anfragebestaetigung_${order.order_number || id.slice(0, 8)}.pdf`;
|
||||||
|
|
||||||
const headers = new Headers();
|
const headers = new Headers();
|
||||||
|
|||||||
133
shop/app/api/orders/[id]/download/route.ts
Normal file
133
shop/app/api/orders/[id]/download/route.ts
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { createClient } from "@/lib/supabase/server";
|
||||||
|
import { createAdminClient } from "@/lib/supabase/admin";
|
||||||
|
import { InvoicePDF } from "@/components/invoice-pdf";
|
||||||
|
import { renderToBuffer } from "@react-pdf/renderer";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { id } = await params;
|
||||||
|
const inline = request.nextUrl.searchParams.get("inline") === "true";
|
||||||
|
|
||||||
|
// 1. Auth check — any authenticated user
|
||||||
|
const supabase = await createClient();
|
||||||
|
const { data: { user } } = await supabase.auth.getUser();
|
||||||
|
if (!user) {
|
||||||
|
return new NextResponse("Unauthorized", { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: dbUser } = await supabase
|
||||||
|
.from("users")
|
||||||
|
.select("role, company_id")
|
||||||
|
.eq("id", user.id)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (!dbUser) {
|
||||||
|
return new NextResponse("Unauthorized", { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const adminDb = createAdminClient();
|
||||||
|
|
||||||
|
// 2. Get the order from database
|
||||||
|
const { data: order, error } = await adminDb
|
||||||
|
.from("orders")
|
||||||
|
.select("customer_data, order_data, total_price, created_at, pdf_url, order_number, company_id, user_id")
|
||||||
|
.eq("id", id)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (error || !order) {
|
||||||
|
return new NextResponse("Order not found", { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Authorization: admin, owner, or company member
|
||||||
|
const isAdmin = dbUser.role === "admin";
|
||||||
|
const isOwner = order.user_id === user.id;
|
||||||
|
const isCompanyMember = dbUser.company_id && order.company_id === dbUser.company_id;
|
||||||
|
|
||||||
|
if (!isAdmin && !isOwner && !isCompanyMember) {
|
||||||
|
return new NextResponse("Forbidden", { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const storageFileName = `ab_${id}.pdf`;
|
||||||
|
let fileBuffer: Uint8Array | ArrayBuffer | null = null;
|
||||||
|
|
||||||
|
// 4. Try to download from storage if pdf_url is set
|
||||||
|
if (order.pdf_url) {
|
||||||
|
try {
|
||||||
|
const { data: fileData, error: downloadError } = await adminDb
|
||||||
|
.storage
|
||||||
|
.from("invoices")
|
||||||
|
.download(storageFileName);
|
||||||
|
|
||||||
|
if (!downloadError && fileData) {
|
||||||
|
fileBuffer = await fileData.arrayBuffer();
|
||||||
|
}
|
||||||
|
} catch (storageErr) {
|
||||||
|
console.warn("Could not retrieve file from storage, will regenerate:", storageErr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. If file not found or pdf_url not set, generate dynamically
|
||||||
|
if (!fileBuffer) {
|
||||||
|
try {
|
||||||
|
const formattedDate = new Date(order.created_at).toLocaleDateString('de-DE', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
});
|
||||||
|
|
||||||
|
const buffer = await renderToBuffer(
|
||||||
|
React.createElement(InvoicePDF, {
|
||||||
|
orderNumber: order.order_number || `AE-${id.slice(0, 8)}`,
|
||||||
|
dateStr: formattedDate,
|
||||||
|
customer: order.customer_data,
|
||||||
|
orderData: order.order_data,
|
||||||
|
totalPrice: Number(order.total_price),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Upload/Upsert to storage bucket
|
||||||
|
const { error: uploadError } = await adminDb.storage
|
||||||
|
.from("invoices")
|
||||||
|
.upload(storageFileName, Buffer.from(buffer), {
|
||||||
|
contentType: "application/pdf",
|
||||||
|
upsert: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (uploadError) {
|
||||||
|
console.error("Failed to upload regenerated PDF:", uploadError);
|
||||||
|
} else {
|
||||||
|
await adminDb.from("orders").update({ pdf_url: storageFileName }).eq("id", id);
|
||||||
|
}
|
||||||
|
|
||||||
|
fileBuffer = buffer;
|
||||||
|
} catch (genErr: any) {
|
||||||
|
console.error("PDF generation failed inside API:", genErr);
|
||||||
|
return new NextResponse("Error generating PDF: " + genErr.message, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Return as PDF response
|
||||||
|
const downloadFileName = `Anfragebestaetigung_${order.order_number || id.slice(0, 8)}.pdf`;
|
||||||
|
|
||||||
|
const headers = new Headers();
|
||||||
|
if (inline) {
|
||||||
|
headers.append("Content-Disposition", "inline");
|
||||||
|
} else {
|
||||||
|
headers.append("Content-Disposition", `attachment; filename="${downloadFileName}"`);
|
||||||
|
}
|
||||||
|
headers.append("Content-Type", "application/pdf");
|
||||||
|
|
||||||
|
return new NextResponse(fileBuffer as any, {
|
||||||
|
status: 200,
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error("Download endpoint failed:", err);
|
||||||
|
return new NextResponse(err.message || "Internal Server Error", { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,7 +6,6 @@ import { Button } from '@/components/ui/button'
|
|||||||
import { Card, CardContent } from '@/components/ui/card'
|
import { Card, CardContent } from '@/components/ui/card'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import type { Order } from '@/lib/types'
|
import type { Order } from '@/lib/types'
|
||||||
import { resolveSupabaseUrl } from '@/lib/utils'
|
|
||||||
|
|
||||||
const statusLabel: Record<string, string> = {
|
const statusLabel: Record<string, string> = {
|
||||||
pending: 'Eingegangen',
|
pending: 'Eingegangen',
|
||||||
@@ -154,12 +153,12 @@ export default async function MyOrdersPage() {
|
|||||||
{o.pdf_url && (
|
{o.pdf_url && (
|
||||||
<>
|
<>
|
||||||
<Button variant="ghost" size="sm" asChild className="text-xs text-slate-400 hover:text-white">
|
<Button variant="ghost" size="sm" asChild className="text-xs text-slate-400 hover:text-white">
|
||||||
<a href={resolveSupabaseUrl(o.pdf_url)} target="_blank" rel="noopener noreferrer">
|
<a href={`/api/orders/${o.id}/download?inline=true`} target="_blank" rel="noopener noreferrer">
|
||||||
<ExternalLink className="w-3 h-3 mr-1" /> Anfrage ansehen
|
<ExternalLink className="w-3 h-3 mr-1" /> Anfrage ansehen
|
||||||
</a>
|
</a>
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" size="sm" asChild className="text-xs text-slate-400 hover:text-white">
|
<Button variant="ghost" size="sm" asChild className="text-xs text-slate-400 hover:text-white">
|
||||||
<a href={resolveSupabaseUrl(o.pdf_url)} download>
|
<a href={`/api/orders/${o.id}/download`} download>
|
||||||
<Download className="w-3 h-3 mr-1" /> PDF
|
<Download className="w-3 h-3 mr-1" /> PDF
|
||||||
</a>
|
</a>
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
|||||||
import { Separator } from '@/components/ui/separator'
|
import { Separator } from '@/components/ui/separator'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import type { Order } from '@/lib/types'
|
import type { Order } from '@/lib/types'
|
||||||
import { resolveSupabaseUrl } from '@/lib/utils'
|
|
||||||
|
|
||||||
export default async function OrderSuccessPage({
|
export default async function OrderSuccessPage({
|
||||||
searchParams,
|
searchParams,
|
||||||
@@ -223,12 +222,12 @@ export default async function OrderSuccessPage({
|
|||||||
{o.pdf_url && (
|
{o.pdf_url && (
|
||||||
<div className="flex gap-2 pt-2">
|
<div className="flex gap-2 pt-2">
|
||||||
<Button variant="outline" size="sm" asChild className="border-white/10 hover:bg-white/10">
|
<Button variant="outline" size="sm" asChild className="border-white/10 hover:bg-white/10">
|
||||||
<a href={resolveSupabaseUrl(o.pdf_url)} target="_blank" rel="noopener noreferrer">
|
<a href={`/api/orders/${o.id}/download?inline=true`} target="_blank" rel="noopener noreferrer">
|
||||||
<ExternalLink className="w-4 h-4 mr-2" /> Anfragebestätigung ansehen
|
<ExternalLink className="w-4 h-4 mr-2" /> Anfragebestätigung ansehen
|
||||||
</a>
|
</a>
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="secondary" size="sm" asChild>
|
<Button variant="secondary" size="sm" asChild>
|
||||||
<a href={resolveSupabaseUrl(o.pdf_url)} download>
|
<a href={`/api/orders/${o.id}/download`} download>
|
||||||
<Download className="w-4 h-4 mr-2" /> Herunterladen
|
<Download className="w-4 h-4 mr-2" /> Herunterladen
|
||||||
</a>
|
</a>
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -227,9 +227,8 @@ export async function submitOrder(params: {
|
|||||||
if (uploadError) {
|
if (uploadError) {
|
||||||
console.error('PDF Upload Error:', uploadError)
|
console.error('PDF Upload Error:', uploadError)
|
||||||
} else {
|
} else {
|
||||||
const { data: publicUrlData } = supabase.storage.from('invoices').getPublicUrl(fileName)
|
await supabase.from('orders').update({ pdf_url: fileName }).eq('id', order.id)
|
||||||
await supabase.from('orders').update({ pdf_url: publicUrlData.publicUrl }).eq('id', order.id)
|
order.pdf_url = fileName
|
||||||
order.pdf_url = publicUrlData.publicUrl
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 7. E-Mail mit PDF-Anhang an den Besteller senden
|
// 7. E-Mail mit PDF-Anhang an den Besteller senden
|
||||||
@@ -655,9 +654,8 @@ export async function updateOrder(
|
|||||||
if (uploadError) {
|
if (uploadError) {
|
||||||
console.error('PDF Upload Error:', uploadError)
|
console.error('PDF Upload Error:', uploadError)
|
||||||
} else {
|
} else {
|
||||||
const { data: publicUrlData } = supabase.storage.from('invoices').getPublicUrl(fileName)
|
await admin.from('orders').update({ pdf_url: fileName }).eq('id', order.id)
|
||||||
await admin.from('orders').update({ pdf_url: publicUrlData.publicUrl }).eq('id', order.id)
|
order.pdf_url = fileName
|
||||||
order.pdf_url = publicUrlData.publicUrl
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. E-Mail mit PDF-Anhang an den Besteller senden (Info über Änderung)
|
// 4. E-Mail mit PDF-Anhang an den Besteller senden (Info über Änderung)
|
||||||
|
|||||||
Reference in New Issue
Block a user