From 61dfc9c9e58963c6b3b98390361a241090e5ebc3 Mon Sep 17 00:00:00 2001 From: gunshiz Date: Thu, 1 Oct 2026 22:28:22 +0700 Subject: [PATCH] feat : use cdn --- .env.example | 2 +- README.md | 7 +++---- .../tickets/[id]/images/[messageId]/route.ts | 21 ------------------- .../commission/tickets/[id]/messages/route.ts | 2 +- app/api/commission/tickets/[id]/slip/route.ts | 20 ------------------ components/commission/slip-dialog.tsx | 4 ++-- components/commission/ticket-chat.tsx | 10 ++++----- components/commission/ticket-detail.tsx | 7 +++++-- lib/commission/slip-upload.ts | 2 +- 9 files changed, 18 insertions(+), 57 deletions(-) delete mode 100644 app/api/commission/tickets/[id]/images/[messageId]/route.ts delete mode 100644 app/api/commission/tickets/[id]/slip/route.ts diff --git a/.env.example b/.env.example index 0b07d0e..c682511 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/README.md b/README.md index c69dff3..d41b3ff 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/app/api/commission/tickets/[id]/images/[messageId]/route.ts b/app/api/commission/tickets/[id]/images/[messageId]/route.ts deleted file mode 100644 index 9268fe3..0000000 --- a/app/api/commission/tickets/[id]/images/[messageId]/route.ts +++ /dev/null @@ -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); } -} diff --git a/app/api/commission/tickets/[id]/messages/route.ts b/app/api/commission/tickets/[id]/messages/route.ts index c24e8b7..4ff4c52 100644 --- a/app/api/commission/tickets/[id]/messages/route.ts +++ b/app/api/commission/tickets/[id]/messages/route.ts @@ -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 { diff --git a/app/api/commission/tickets/[id]/slip/route.ts b/app/api/commission/tickets/[id]/slip/route.ts deleted file mode 100644 index a04cd35..0000000 --- a/app/api/commission/tickets/[id]/slip/route.ts +++ /dev/null @@ -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); } -} diff --git a/components/commission/slip-dialog.tsx b/components/commission/slip-dialog.tsx index 36e76c6..b7748fd 100644 --- a/components/commission/slip-dialog.tsx +++ b/components/commission/slip-dialog.tsx @@ -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 @@ -17,7 +17,7 @@ export function CommissionSlipDialog({ ticketId }: { ticketId: string }) { {imageState === "loading" && } {imageState === "error" &&

โหลดสลิปไม่สำเร็จ

} {/* eslint-disable-next-line @next/next/no-img-element */} - สลิปการชำระเงิน setImageState("loaded")} onError={() => setImageState("error")} className={imageState === "loaded" ? "max-h-[calc(90svh-7rem)] w-full object-contain" : "hidden"} /> diff --git a/components/commission/ticket-chat.tsx b/components/commission/ticket-chat.tsx index be1d75a..b3c1a41 100644 --- a/components/commission/ticket-chat.tsx +++ b/components/commission/ticket-chat.tsx @@ -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 } {mine ? "คุณ" : message.authorName} - - - {message.text &&

{message.text}

} - {message.imageObjectKey && } + + + {message.text &&

{message.text}

} + {message.imageUrl && }
{counts.length > 0 &&
diff --git a/components/commission/ticket-detail.tsx b/components/commission/ticket-detail.tsx index cca9bc8..85c5630 100644 --- a/components/commission/ticket-detail.tsx +++ b/components/commission/ticket-detail.tsx @@ -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: คำขอที่ชำระเงินแล้ว - ฿{data.checkout.amountBaht} {catalog && }

การชำระเงิน: {data.payment.transRef} - {data.payment.transferredAt.toLocaleString("th-TH")}

- +
- + ({ ...message, + imageUrl: imageObjectKey ? publicMediaUrl(imageObjectKey) : null }))} admin={admin} />
; } diff --git a/lib/commission/slip-upload.ts b/lib/commission/slip-upload.ts index b53f23a..1f181d8 100644 --- a/lib/commission/slip-upload.ts +++ b/lib/commission/slip-upload.ts @@ -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) => {