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 DISCORD_LOG_CHANNEL_ID=1547193755772125184
COMMISSION_DISCORD_WEBHOOK_URL=replace-with-commission-discord-webhook-url 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_ENDPOINT=https://s3.example.internal
S3_PUBLIC_URL=https://buzz-cdn.astrxl.dev S3_PUBLIC_URL=https://buzz-cdn.astrxl.dev
S3_REGION=auto 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 Slip2Go expects for its receiver check. Set `SLIP2GO_VERIFY_URL` to the full
image-verification endpoint from your Slip2Go API Connect account. Set image-verification endpoint from your Slip2Go API Connect account. Set
`SLIP2GO_API_SECRET` to the raw secret; the server adds the `Bearer` authorization `SLIP2GO_API_SECRET` to the raw secret; the server adds the `Bearer` authorization
prefix when calling Slip2Go. Private prefix when calling Slip2Go. Payment slips and ticket images use the configured
payment slips and ticket images use the configured S3 bucket with private ACL; S3 bucket and are served from `S3_PUBLIC_URL`. Anyone with an attachment URL can
the bucket/CDN must honor private object access. Apply the commission database view it. Apply the commission database migration before enabling checkout.
migration before enabling checkout.
Traefik must overwrite `X-Real-Ip` for every request, and the web pods must be 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 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()); const bytes = new Uint8Array(await image.arrayBuffer());
await inspectImage(bytes, image.type as "image/png" | "image/jpeg" | "image/webp"); await inspectImage(bytes, image.type as "image/png" | "image/jpeg" | "image/webp");
imageObjectKey = `commission/messages/${crypto.randomUUID()}`; 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; let messageId: string;
try { 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 { Dialog, DialogContent, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Skeleton } from "@/components/ui/skeleton"; 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"); const [imageState, setImageState] = useState<"loading" | "loaded" | "error">("loading");
return <Dialog> return <Dialog>
@@ -17,7 +17,7 @@ export function CommissionSlipDialog({ ticketId }: { ticketId: string }) {
{imageState === "loading" && <Skeleton className="h-80 w-full" aria-label="กำลังโหลดสลิป" />} {imageState === "loading" && <Skeleton className="h-80 w-full" aria-label="กำลังโหลดสลิป" />}
{imageState === "error" && <p className="text-sm text-destructive">โหลดสลิปไม่สำเร็จ</p>} {imageState === "error" && <p className="text-sm text-destructive">โหลดสลิปไม่สำเร็จ</p>}
{/* eslint-disable-next-line @next/next/no-img-element */} {/* 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")} onLoad={() => setImageState("loaded")} onError={() => setImageState("error")}
className={imageState === "loaded" ? "max-h-[calc(90svh-7rem)] w-full object-contain" : "hidden"} /> className={imageState === "loaded" ? "max-h-[calc(90svh-7rem)] w-full object-contain" : "hidden"} />
</DialogContent> </DialogContent>
+5 -5
View File
@@ -21,7 +21,7 @@ import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
type ChatMessage = { id: string; authorId: string; authorName: string; text: string | null; 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; type OptimisticMessage = { localId: string; serverId: string | null; text: string; imageUrl: string | null;
status: "sending" | "sent" }; status: "sending" | "sent" };
type OptimisticReaction = { active: boolean; 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>} </AvatarFallback></Avatar></MessageAvatar>}
<MessageContent className={cn("w-auto max-w-[min(82%,36rem)] gap-1", mine ? "items-end" : "items-start")}> <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> <MessageHeader className="px-1">{mine ? "คุณ" : message.authorName}</MessageHeader>
<Bubble variant={message.imageObjectKey ? "ghost" : mine ? "muted" : "secondary"} className="max-w-full"> <Bubble variant={message.imageUrl ? "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")}> <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.imageObjectKey && "pb-2")}>{message.text}</p>} {message.text && <p className={cn("whitespace-pre-wrap", mine && "text-foreground", message.imageUrl && "pb-2")}>{message.text}</p>}
{message.imageObjectKey && <CommissionMessageImage src={`/api/commission/tickets/${ticketId}/images/${message.id}`} />} {message.imageUrl && <CommissionMessageImage src={message.imageUrl} />}
</BubbleContent> </BubbleContent>
</Bubble> </Bubble>
{counts.length > 0 && <div className={cn("flex flex-wrap gap-1", mine && "justify-end")}> {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 { notFound } from "next/navigation";
import { getCommissionLabels } from "@/lib/commission/catalog"; import { getCommissionLabels } from "@/lib/commission/catalog";
import { getCommissionTicket } from "@/lib/commission/tickets"; import { getCommissionTicket } from "@/lib/commission/tickets";
import { publicMediaUrl } from "@/lib/media/storage";
import { HttpError } from "@/lib/security/http"; import { HttpError } from "@/lib/security/http";
import { CommissionRequestSummary } from "./request-summary"; import { CommissionRequestSummary } from "./request-summary";
import { CommissionTicketChat } from "./ticket-chat"; 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> <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} />} <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> <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> </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>
</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 verified = await verifyCommissionSlip(file, checkout.amountBaht, checkout.createdAt);
const objectKey = `commission/slips/${crypto.randomUUID()}`; const objectKey = `commission/slips/${crypto.randomUUID()}`;
const storage = await getMediaStorage(); 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; let ticketId: string;
try { try {
ticketId = await getDb().transaction(async (tx) => { ticketId = await getDb().transaction(async (tx) => {