feat : use cdn
CI / Verify (push) Successful in 1m44s
CI / Build immutable images and deploy (push) Successful in 2m54s

This commit is contained in:
2026-10-01 22:28:22 +07:00 Unverified
parent 9fc12c1874
commit 61dfc9c9e5
9 changed files with 18 additions and 57 deletions
+1 -1
View File
@@ -36,7 +36,7 @@ DISCORD_CHANNEL_ID=replace-with-private-channel-id
DISCORD_LOG_CHANNEL_ID=1547193755772125184
COMMISSION_DISCORD_WEBHOOK_URL=replace-with-commission-discord-webhook-url
# S3-compatible media storage; direct guide uploads use public CDN objects.
# S3-compatible media storage; guide and commission uploads use public CDN objects.
S3_ENDPOINT=https://s3.example.internal
S3_PUBLIC_URL=https://buzz-cdn.astrxl.dev
S3_REGION=auto
+3 -4
View File
@@ -231,10 +231,9 @@ Set `COMMISSION_RECEIVER_ACCOUNT_NUMBER` to the recipient value in the format
Slip2Go expects for its receiver check. Set `SLIP2GO_VERIFY_URL` to the full
image-verification endpoint from your Slip2Go API Connect account. Set
`SLIP2GO_API_SECRET` to the raw secret; the server adds the `Bearer` authorization
prefix when calling Slip2Go. Private
payment slips and ticket images use the configured S3 bucket with private ACL;
the bucket/CDN must honor private object access. Apply the commission database
migration before enabling checkout.
prefix when calling Slip2Go. Payment slips and ticket images use the configured
S3 bucket and are served from `S3_PUBLIC_URL`. Anyone with an attachment URL can
view it. Apply the commission database migration before enabling checkout.
Traefik must overwrite `X-Real-Ip` for every request, and the web pods must be
reachable only through Traefik before enabling the configured client IP based
@@ -1,21 +0,0 @@
import { and, eq } from "drizzle-orm";
import { getDb } from "@/db";
import { commissionMessages } from "@/db/schema";
import { authorizeTicket } from "@/lib/commission/tickets";
import { getMediaStorage } from "@/lib/media/storage";
import { errorResponse, HttpError } from "@/lib/security/http";
export async function GET(_request: Request,
context: RouteContext<"/api/commission/tickets/[id]/images/[messageId]">) {
try {
const { id, messageId } = await context.params;
await authorizeTicket(id);
const [message] = await getDb().select({ key: commissionMessages.imageObjectKey,
type: commissionMessages.imageMimeType }).from(commissionMessages)
.where(and(eq(commissionMessages.id, messageId), eq(commissionMessages.ticketId, id))).limit(1);
if (!message?.key || !message.type) throw new HttpError(404, "image-not-found");
const file = (await getMediaStorage()).file(message.key);
return new Response(await file.bytes(), { headers: { "Content-Type": message.type,
"Cache-Control": "private, no-store", "X-Content-Type-Options": "nosniff" } });
} catch (cause) { return errorResponse(cause); }
}
@@ -33,7 +33,7 @@ export async function POST(request: Request, context: RouteContext<"/api/commiss
const bytes = new Uint8Array(await image.arrayBuffer());
await inspectImage(bytes, image.type as "image/png" | "image/jpeg" | "image/webp");
imageObjectKey = `commission/messages/${crypto.randomUUID()}`;
await (await getMediaStorage()).write(imageObjectKey, bytes, { type: image.type, acl: "private" });
await (await getMediaStorage()).write(imageObjectKey, bytes, { type: image.type, acl: "public-read" });
}
let messageId: string;
try {
@@ -1,20 +0,0 @@
import { eq } from "drizzle-orm";
import { getDb } from "@/db";
import { commissionPayments } from "@/db/schema";
import { authorizeTicket } from "@/lib/commission/tickets";
import { getMediaStorage } from "@/lib/media/storage";
import { errorResponse, HttpError } from "@/lib/security/http";
export async function GET(_request: Request, context: RouteContext<"/api/commission/tickets/[id]/slip">) {
try {
const { id } = await context.params;
await authorizeTicket(id);
const [payment] = await getDb().select({ key: commissionPayments.slipObjectKey,
type: commissionPayments.slipMimeType }).from(commissionPayments)
.where(eq(commissionPayments.ticketId, id)).limit(1);
if (!payment) throw new HttpError(404, "slip-not-found");
const file = (await getMediaStorage()).file(payment.key);
return new Response(await file.bytes(), { headers: { "Content-Type": payment.type,
"Cache-Control": "private, no-store", "X-Content-Type-Options": "nosniff" } });
} catch (cause) { return errorResponse(cause); }
}
+2 -2
View File
@@ -5,7 +5,7 @@ import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Skeleton } from "@/components/ui/skeleton";
export function CommissionSlipDialog({ ticketId }: { ticketId: string }) {
export function CommissionSlipDialog({ src }: { src: string }) {
const [imageState, setImageState] = useState<"loading" | "loaded" | "error">("loading");
return <Dialog>
@@ -17,7 +17,7 @@ export function CommissionSlipDialog({ ticketId }: { ticketId: string }) {
{imageState === "loading" && <Skeleton className="h-80 w-full" aria-label="กำลังโหลดสลิป" />}
{imageState === "error" && <p className="text-sm text-destructive">โหลดสลิปไม่สำเร็จ</p>}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={`/api/commission/tickets/${ticketId}/slip`} alt="สลิปการชำระเงิน"
<img src={src} alt="สลิปการชำระเงิน"
onLoad={() => setImageState("loaded")} onError={() => setImageState("error")}
className={imageState === "loaded" ? "max-h-[calc(90svh-7rem)] w-full object-contain" : "hidden"} />
</DialogContent>
+5 -5
View File
@@ -21,7 +21,7 @@ import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils";
type ChatMessage = { id: string; authorId: string; authorName: string; text: string | null;
imageObjectKey: string | null; createdAt: Date; reactions: { userId: string; emoji: string }[] };
imageUrl: string | null; createdAt: Date; reactions: { userId: string; emoji: string }[] };
type OptimisticMessage = { localId: string; serverId: string | null; text: string; imageUrl: string | null;
status: "sending" | "sent" };
type OptimisticReaction = { active: boolean; status: "sending" | "sent" };
@@ -313,10 +313,10 @@ export function CommissionTicketChat({ ticketId, userId, status, messages, admin
</AvatarFallback></Avatar></MessageAvatar>}
<MessageContent className={cn("w-auto max-w-[min(82%,36rem)] gap-1", mine ? "items-end" : "items-start")}>
<MessageHeader className="px-1">{mine ? "คุณ" : message.authorName}</MessageHeader>
<Bubble variant={message.imageObjectKey ? "ghost" : mine ? "muted" : "secondary"} className="max-w-full">
<BubbleContent className={cn("max-w-full", message.imageObjectKey ? "border-0 bg-transparent p-0 shadow-none" : "px-4 py-2.5 shadow-sm", mine ? "rounded-br-md" : "rounded-bl-md")}>
{message.text && <p className={cn("whitespace-pre-wrap", mine && "text-foreground", message.imageObjectKey && "pb-2")}>{message.text}</p>}
{message.imageObjectKey && <CommissionMessageImage src={`/api/commission/tickets/${ticketId}/images/${message.id}`} />}
<Bubble variant={message.imageUrl ? "ghost" : mine ? "muted" : "secondary"} className="max-w-full">
<BubbleContent className={cn("max-w-full", message.imageUrl ? "border-0 bg-transparent p-0 shadow-none" : "px-4 py-2.5 shadow-sm", mine ? "rounded-br-md" : "rounded-bl-md")}>
{message.text && <p className={cn("whitespace-pre-wrap", mine && "text-foreground", message.imageUrl && "pb-2")}>{message.text}</p>}
{message.imageUrl && <CommissionMessageImage src={message.imageUrl} />}
</BubbleContent>
</Bubble>
{counts.length > 0 && <div className={cn("flex flex-wrap gap-1", mine && "justify-end")}>
+5 -2
View File
@@ -1,6 +1,7 @@
import { notFound } from "next/navigation";
import { getCommissionLabels } from "@/lib/commission/catalog";
import { getCommissionTicket } from "@/lib/commission/tickets";
import { publicMediaUrl } from "@/lib/media/storage";
import { HttpError } from "@/lib/security/http";
import { CommissionRequestSummary } from "./request-summary";
import { CommissionTicketChat } from "./ticket-chat";
@@ -31,9 +32,11 @@ export async function CommissionTicketDetail({ id, admin }: { id: string; admin:
<Card className="h-fit lg:max-h-[78svh] lg:overflow-y-auto"><CardHeader><CardTitle>คำขอที่ชำระเงินแล้ว - ฿{data.checkout.amountBaht}</CardTitle></CardHeader>
<CardContent className="flex flex-col gap-3">{catalog && <CommissionRequestSummary request={data.checkout.request} catalog={catalog} amountBaht={data.checkout.amountBaht} />}
<p className="text-sm text-muted-foreground">การชำระเงิน: {data.payment.transRef} - {data.payment.transferredAt.toLocaleString("th-TH")}</p>
<CommissionSlipDialog ticketId={id} />
<CommissionSlipDialog src={publicMediaUrl(data.payment.slipObjectKey)} />
</CardContent></Card>
<CommissionTicketChat ticketId={id} userId={data.currentUserId} status={data.ticket.status} messages={data.messages} admin={admin} />
<CommissionTicketChat ticketId={id} userId={data.currentUserId} status={data.ticket.status}
messages={data.messages.map(({ imageObjectKey, ...message }) => ({ ...message,
imageUrl: imageObjectKey ? publicMediaUrl(imageObjectKey) : null }))} admin={admin} />
</div>
</div>;
}
+1 -1
View File
@@ -34,7 +34,7 @@ export async function verifyAndCreateTicket(checkout: Checkout, file: FormDataEn
const verified = await verifyCommissionSlip(file, checkout.amountBaht, checkout.createdAt);
const objectKey = `commission/slips/${crypto.randomUUID()}`;
const storage = await getMediaStorage();
await storage.write(objectKey, bytes, { type: file.type, acl: "private" });
await storage.write(objectKey, bytes, { type: file.type, acl: "public-read" });
let ticketId: string;
try {
ticketId = await getDb().transaction(async (tx) => {