feat : use cdn
This commit is contained in:
+1
-1
@@ -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
|
||||||
|
|||||||
@@ -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); }
|
|
||||||
}
|
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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")}>
|
||||||
|
|||||||
@@ -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>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user