feat : update i guess
CI / Verify (push) Failing after 1m4s
CI / Build immutable images and deploy (push) Skipped

This commit is contained in:
2026-10-03 15:47:47 +07:00 Unverified
parent f5cfe0a22e
commit 95c45841e7
15 changed files with 4297 additions and 174 deletions
@@ -40,7 +40,8 @@ describe("commission message deletion", () => {
vi.clearAllMocks();
authorizeTicket.mockResolvedValue({ ticket: { userId: ownerId, status: "open" }, user: { id: ownerId } });
deleteWhere.mockImplementation(() => ({ returning: deleteReturning }));
deleteReturning.mockResolvedValue([{ imageObjectKey: "commission/messages/image-1" }]);
deleteReturning.mockResolvedValue([{ imageObjectKey: "commission/messages/image-1",
imageObjectKeys: ["commission/messages/image-2", "commission/messages/image-3"] }]);
updateWhere.mockResolvedValue(undefined);
deleteObject.mockResolvedValue(undefined);
notifyCommission.mockResolvedValue(undefined);
@@ -63,6 +64,8 @@ describe("commission message deletion", () => {
expect(query.params).toContain(messageId);
expect(query.params).toContain(ownerId);
expect(deleteObject).toHaveBeenCalledWith("commission/messages/image-1");
expect(deleteObject).toHaveBeenCalledWith("commission/messages/image-2");
expect(deleteObject).toHaveBeenCalledWith("commission/messages/image-3");
expect(notifyCommission).toHaveBeenCalledWith(ticketId, ownerId);
});
@@ -17,16 +17,20 @@ export async function DELETE(request: Request,
const { ticket, user } = await authorizeTicket(id);
if (ticket.status !== "open") throw new HttpError(409, "ticket-closed");
await limitRequest("commission-message-delete", user.id, 30);
const imageObjectKey = await getDb().transaction(async (tx) => {
const imageObjectKeys = await getDb().transaction(async (tx) => {
const [message] = await tx.delete(commissionMessages)
.where(and(eq(commissionMessages.id, messageId), eq(commissionMessages.ticketId, id),
eq(commissionMessages.authorId, user.id)))
.returning({ imageObjectKey: commissionMessages.imageObjectKey });
.returning({ imageObjectKey: commissionMessages.imageObjectKey,
imageObjectKeys: commissionMessages.imageObjectKeys });
if (!message) throw new HttpError(404, "message-not-found");
await tx.update(commissionTickets).set({ updatedAt: new Date() }).where(eq(commissionTickets.id, id));
return message.imageObjectKey;
return [message.imageObjectKey, ...message.imageObjectKeys].filter((key): key is string => Boolean(key));
});
if (imageObjectKey) await (await getMediaStorage()).delete(imageObjectKey).catch(() => undefined);
if (imageObjectKeys.length) {
const storage = await getMediaStorage();
await Promise.all(imageObjectKeys.map((key) => storage.delete(key).catch(() => undefined)));
}
await notifyCommission(id, ticket.userId);
return new Response(null, { status: 204 });
} catch (cause) { return errorResponse(cause); }
@@ -8,12 +8,17 @@ const selectWhere = vi.fn((condition: SQL) => { void condition; return { limit:
const insertReturning = vi.fn();
const insertValues = vi.fn(() => ({ returning: insertReturning }));
const updateWhere = vi.fn();
const writeObject = vi.fn();
const deleteObject = vi.fn();
const inspectImage = vi.fn();
const tx = { insert: vi.fn(() => ({ values: insertValues })), update: vi.fn(() => ({ set: () => ({ where: updateWhere }) })) };
const notifyCommission = vi.fn();
vi.mock("@/lib/commission/tickets", () => ({ authorizeTicket }));
vi.mock("@/lib/commission/server", () => ({ notifyCommission }));
vi.mock("@/lib/commission/push", () => ({ sendCommissionMessagePush: vi.fn() }));
vi.mock("@/lib/media/storage", () => ({ getMediaStorage: async () => ({ write: writeObject, delete: deleteObject }) }));
vi.mock("@/lib/media/inspect", () => ({ inspectImage }));
vi.mock("@/lib/security/rate-limit", () => ({ limitRequest: async () => undefined }));
vi.mock("next/server", () => ({ after: vi.fn() }));
vi.mock("@/db", () => ({ getDb: () => ({
@@ -47,6 +52,9 @@ describe("commission message replies", () => {
user: { id: "user-1", name: "User" } });
selectLimit.mockResolvedValue([{ id: parentId }]);
insertReturning.mockResolvedValue([{ id: messageId }]);
writeObject.mockResolvedValue(undefined);
deleteObject.mockResolvedValue(undefined);
inspectImage.mockResolvedValue(undefined);
});
it("accepts a reply only after looking up its parent in the same ticket", async () => {
@@ -65,4 +73,50 @@ describe("commission message replies", () => {
expect(response.status).toBe(404);
expect(insertValues).not.toHaveBeenCalled();
});
it("stores seven images in one message", async () => {
const body = new FormData();
for (let index = 0; index < 7; index++)
body.append("image", new File(["image"], `${index}.png`, { type: "image/png" }));
const response = await POST(new Request(`https://guide.sudloh.com/api/commission/tickets/${ticketId}/messages`, {
method: "POST", headers: { Origin: "https://guide.sudloh.com" }, body,
}), context());
expect(response.status).toBe(201);
expect(writeObject).toHaveBeenCalledTimes(7);
expect(insertValues).toHaveBeenCalledWith(expect.objectContaining({
imageObjectKeys: writeObject.mock.calls.map(([key]) => key),
}));
});
it("rejects more than ten images before uploading", async () => {
const body = new FormData();
for (let index = 0; index < 11; index++)
body.append("image", new File(["image"], `${index}.png`, { type: "image/png" }));
const response = await POST(new Request(`https://guide.sudloh.com/api/commission/tickets/${ticketId}/messages`, {
method: "POST", headers: { Origin: "https://guide.sudloh.com" }, body,
}), context());
expect(response.status).toBe(400);
expect(writeObject).not.toHaveBeenCalled();
});
it("cleans up uploaded images when saving the message fails", async () => {
insertReturning.mockRejectedValueOnce(new Error("database unavailable"));
const body = new FormData();
body.append("image", new File(["image"], "first.png", { type: "image/png" }));
const response = await POST(new Request(`https://guide.sudloh.com/api/commission/tickets/${ticketId}/messages`, {
method: "POST", headers: { Origin: "https://guide.sudloh.com" }, body,
}), context());
expect(response.status).toBe(503);
expect(deleteObject).toHaveBeenCalledWith(writeObject.mock.calls[0][0]);
});
it("rejects an image larger than ten MiB", async () => {
const body = new FormData();
body.append("image", new File([new Uint8Array(10 * 1024 * 1024 + 1)], "large.png", { type: "image/png" }));
const response = await POST(new Request(`https://guide.sudloh.com/api/commission/tickets/${ticketId}/messages`, {
method: "POST", headers: { Origin: "https://guide.sudloh.com" }, body,
}), context());
expect(response.status).toBe(415);
expect(writeObject).not.toHaveBeenCalled();
});
});
@@ -22,13 +22,13 @@ export async function POST(request: Request, context: RouteContext<"/api/commiss
if (!request.headers.get("content-type")?.startsWith("multipart/form-data;"))
throw new HttpError(415, "expected-multipart");
return await withUploadSlot(async () => {
const form = await boundedBody(request, 6 * 1024 * 1024 + 64 * 1024).formData();
const form = await boundedBody(request, 10 * 10 * 1024 * 1024 + 64 * 1024).formData();
const value = form.get("text");
const body = typeof value === "string" ? value.trim() : "";
const image = form.get("image");
const images = form.getAll("image");
const replyValue = form.get("replyToId");
const replyToId = replyValue === null || replyValue === "" ? null : replyValue;
if (body.length > 4000 || (image && !(image instanceof File)))
if (body.length > 4000 || images.length > 10 || images.some((image) => !(image instanceof File)))
throw new HttpError(400, "invalid-message");
if (replyToId !== null) {
if (typeof replyToId !== "string" || !z.uuid().safeParse(replyToId).success)
@@ -37,28 +37,34 @@ export async function POST(request: Request, context: RouteContext<"/api/commiss
.where(and(eq(commissionMessages.id, replyToId), eq(commissionMessages.ticketId, id))).limit(1);
if (!parent) throw new HttpError(404, "reply-not-found");
}
if (!body && !image) throw new HttpError(400, "empty-message");
let imageObjectKey: string | null = null;
if (image instanceof File) {
if (image.size === 0 || image.size > 6 * 1024 * 1024 ||
!["image/png", "image/jpeg", "image/webp"].includes(image.type))
throw new HttpError(415, "invalid-message-image");
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: "public-read" });
}
if (!body && !images.length) throw new HttpError(400, "empty-message");
const imageFiles = images as File[];
if (imageFiles.some((image) => image.size === 0 || image.size > 10 * 1024 * 1024 ||
!["image/png", "image/jpeg", "image/webp"].includes(image.type)))
throw new HttpError(415, "invalid-message-image");
const imageObjectKeys: string[] = [];
let messageId: string;
try {
const storage = imageFiles.length ? await getMediaStorage() : null;
for (const image of imageFiles) {
const bytes = new Uint8Array(await image.arrayBuffer());
await inspectImage(bytes, image.type as "image/png" | "image/jpeg" | "image/webp");
const key = `commission/messages/${crypto.randomUUID()}`;
imageObjectKeys.push(key);
await storage!.write(key, bytes, { type: image.type, acl: "public-read" });
}
messageId = await getDb().transaction(async (tx) => {
const [message] = await tx.insert(commissionMessages).values({ ticketId: id, authorId: user.id, replyToId,
text: body || null, imageObjectKey, imageMimeType: image instanceof File ? image.type : null })
text: body || null, imageObjectKeys })
.returning({ id: commissionMessages.id });
await tx.update(commissionTickets).set({ updatedAt: new Date() }).where(eq(commissionTickets.id, id));
return message.id;
});
} catch (cause) {
if (imageObjectKey) await (await getMediaStorage()).delete(imageObjectKey).catch(() => undefined);
if (imageObjectKeys.length) {
const storage = await getMediaStorage();
await Promise.all(imageObjectKeys.map((key) => storage.delete(key).catch(() => undefined)));
}
throw cause;
}
const message: CommissionMessageEvent = { messageId, ticketId: id,
+10
View File
@@ -0,0 +1,10 @@
export function keepCommissionTitle(getTitle: () => string) {
const sync = () => {
const title = getTitle();
if (document.title !== title) document.title = title;
};
const observer = new MutationObserver(sync);
observer.observe(document.head, { childList: true, characterData: true, subtree: true });
sync();
return () => observer.disconnect();
}
+3
View File
@@ -3,6 +3,7 @@
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { parseCommissionMessageEvent } from "@/lib/commission/message-event";
import { keepCommissionTitle } from "./keep-title";
export function CommissionLiveRefresh({ endpoint, userId }: { endpoint: string; userId: string }) {
const router = useRouter();
@@ -10,6 +11,7 @@ export function CommissionLiveRefresh({ endpoint, userId }: { endpoint: string;
const originalTitle = document.title;
let managedTitle = "";
let unread = 0;
const stopKeepingTitle = keepCommissionTitle(() => managedTitle || originalTitle);
const source = new EventSource(endpoint);
const refresh = (event: Event) => {
const message = parseCommissionMessageEvent((event as MessageEvent<string>).data);
@@ -25,6 +27,7 @@ export function CommissionLiveRefresh({ endpoint, userId }: { endpoint: string;
source.addEventListener("open", opened);
return () => {
source.removeEventListener("changed", refresh); source.removeEventListener("open", opened); source.close();
stopKeepingTitle();
if (managedTitle && document.title === managedTitle) document.title = originalTitle;
};
}, [endpoint, userId, router]);
+25 -5
View File
@@ -1,6 +1,9 @@
import Image from "next/image";
import { ArrowUpRightIcon } from "lucide-react";
import type { ReactNode } from "react";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Kbd } from "@/components/ui/kbd";
import { elementBackgroundColor } from "@/lib/catalog/element";
import { characterRarityGradientClass, rarityGradientClass } from "@/lib/catalog/rarity";
import type { CommissionRequest } from "@/lib/commission/request";
@@ -15,6 +18,22 @@ function ItemImage({ item, size = "small", character = false }: { item: Item; si
</div>;
}
function LunarisItemLink({ item, kind, children }: {
item: Item; kind: "weapon" | "artifact"; children?: ReactNode;
}) {
return <a href={`https://lunaris.moe/${kind}/${encodeURIComponent(item.key)}`}
target="_blank" rel="noreferrer"
aria-label={`เปิดข้อมูล${kind === "weapon" ? "อาวุธ" : "อาร์ติแฟกต์"} ${item.name} บน Lunaris`}
className="block min-w-0 rounded-lg outline-none focus-visible:ring-2 focus-visible:ring-ring">
<div className="relative"><ItemImage item={item} />
<Kbd className="absolute top-0 right-0 h-3 min-w-3 bg-transparent! p-0 text-white">
<ArrowUpRightIcon aria-hidden="true" />
</Kbd>
</div>
{children}
</a>;
}
function CommissionTeamCard({ team, index, catalog }: { team: CommissionRequest["teams"][number]; index: number; catalog: Catalog }) {
const characters = new Map(catalog.characters.map((item) => [item.key, item]));
const weapons = new Map(catalog.weapons.map((item) => [item.key, item]));
@@ -23,7 +42,7 @@ function CommissionTeamCard({ team, index, catalog }: { team: CommissionRequest[
<CardHeader className="flex flex-row items-center justify-between gap-2">
<CardTitle>ทีม {index + 1}</CardTitle><Badge variant="secondary">฿100</Badge>
</CardHeader>
<CardContent className="grid grid-cols-4 gap-1 lg:grid-cols-2 lg:gap-2 lg:@min-[560px]:grid-cols-4">
<CardContent className="grid grid-cols-2 gap-2 @min-[560px]:grid-cols-4">
{team.members.map((member, memberIndex) => {
const character = characters.get(member.characterKey);
const weapon = weapons.get(member.weaponKey);
@@ -32,8 +51,8 @@ function CommissionTeamCard({ team, index, catalog }: { team: CommissionRequest[
<div className="grid grid-cols-1 gap-1 lg:grid-cols-[2fr_1fr]">
{character ? <ItemImage item={character} size="large" character /> : <div className="aspect-square rounded-lg bg-muted" />}
<div className="grid min-w-0 grid-cols-2 gap-0.5 lg:grid-cols-1 lg:grid-rows-2 lg:gap-1">
{weapon ? <ItemImage item={weapon} /> : <div title={member.weaponKey} className="aspect-square rounded-lg bg-muted" />}
{artifact ? <ItemImage item={artifact} /> : <div title={member.artifactKey} className="aspect-square rounded-lg bg-muted" />}
{weapon ? <LunarisItemLink item={weapon} kind="weapon" /> : <div title={member.weaponKey} className="aspect-square rounded-lg bg-muted" />}
{artifact ? <LunarisItemLink item={artifact} kind="artifact" /> : <div title={member.artifactKey} className="aspect-square rounded-lg bg-muted" />}
</div>
</div>
<div className="mt-1 flex flex-wrap items-center justify-center gap-0.5 lg:flex-nowrap lg:gap-1">
@@ -71,9 +90,10 @@ export function CommissionRequestSummary({ request, catalog, amountBaht }: {
<div className="flex min-w-0 flex-wrap gap-2">
{set.weaponKeys.map((key) => {
const weapon = weapons.get(key);
return weapon ? <div key={key} className="w-16 shrink-0"><ItemImage item={weapon} />
return weapon ? <div key={key} className="w-16 shrink-0"><LunarisItemLink item={weapon} kind="weapon">
<div className="mt-1 flex justify-center"><Badge variant="secondary">R{set.refinements?.[key] ?? "1"}</Badge></div>
<p className="mt-1 truncate text-center text-xs" title={weapon.name}>{weapon.name}</p></div>
<p className="mt-1 truncate text-center text-xs" title={weapon.name}>{weapon.name}</p>
</LunarisItemLink></div>
: <p key={key} className="w-16 wrap-break-word text-xs">{key}</p>;
})}
</div>
+143 -140
View File
@@ -81,6 +81,7 @@ import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils";
import { parseCommissionMessageEvent } from "@/lib/commission/message-event";
import { CommissionPushEntryPrompt } from "./push-entry-prompt";
import { keepCommissionTitle } from "./keep-title";
type ChatMessage = {
id: string;
@@ -89,7 +90,7 @@ type ChatMessage = {
text: string | null;
authorImage: string | null;
replyToId: string | null;
imageUrl: string | null;
imageUrls: string[];
createdAt: Date;
reactions: { userId: string; emoji: string }[];
};
@@ -97,7 +98,7 @@ type OptimisticMessage = {
localId: string;
serverId: string | null;
text: string;
imageUrl: string | null;
imageUrls: string[];
replyToId: string | null;
status: "sending" | "sent";
};
@@ -105,6 +106,9 @@ type OptimisticReaction = { active: boolean; status: "sending" | "sent" };
const EmojiPicker = dynamic(() => import("emoji-picker-react"), { ssr: false });
const reactionKey = (messageId: string, emoji: string) =>
`${messageId}:${emoji}`;
const padTime = (value: number) => String(value).padStart(2, "0");
const imageUrlsOf = (message: { imageUrls?: string[]; imageUrl?: string | null }) =>
Array.isArray(message.imageUrls) ? message.imageUrls : message.imageUrl ? [message.imageUrl] : [];
function ReactionEmoji({ emoji }: { emoji: string }) {
const [failed, setFailed] = useState(false);
@@ -132,13 +136,13 @@ function ReplyPreview({ message }: { message: ChatMessage }) {
<div className="max-w-full rounded-md bg-muted/50 px-2 py-1 text-xs">
<span className="font-medium">{message.authorName}</span>
<p className="truncate text-muted-foreground">
{message.text || (message.imageUrl ? "รูปภาพ" : "ข้อความ")}
{message.text || (imageUrlsOf(message).length ? "รูปภาพ" : "ข้อความ")}
</p>
</div>
);
}
function CommissionMessageImage({ src }: { src: string }) {
function CommissionMessageImage({ src, compact = false }: { src: string; compact?: boolean }) {
const imageRef = useRef<HTMLImageElement>(null);
const [state, setState] = useState<"loading" | "loaded" | "error">("loading");
@@ -154,19 +158,21 @@ function CommissionMessageImage({ src }: { src: string }) {
<button
type="button"
disabled={state !== "loaded"}
className="block max-w-full cursor-zoom-in overflow-hidden rounded-lg disabled:cursor-default"
className={cn("block cursor-zoom-in overflow-hidden rounded-md disabled:cursor-default",
compact ? "relative aspect-square w-full" : "max-w-full")}
aria-label="ขยายรูปภาพที่แนบ"
/>
}
>
{state === "loading" && (
<Skeleton
className="h-40 w-48 max-w-full rounded-lg"
className={compact ? "absolute inset-0 size-full rounded-none" : "h-40 w-48 max-w-full rounded-lg"}
aria-label="กำลังโหลดรูปภาพ"
/>
)}
{state === "error" && (
<span className="block px-4 py-2.5 text-xs">โหลดรูปภาพไม่สำเร็จ</span>
<span className={cn("block px-4 py-2.5 text-xs", compact &&
"absolute inset-0 flex items-center justify-center p-1 text-center")}>โหลดรูปภาพไม่สำเร็จ</span>
)}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
@@ -176,7 +182,7 @@ function CommissionMessageImage({ src }: { src: string }) {
onLoad={() => setState("loaded")}
onError={() => setState("error")}
className={cn(
"max-h-72 max-w-full object-contain",
compact ? "absolute inset-0 size-full object-cover" : "max-h-72 max-w-full object-contain",
state === "loaded" ? "block" : "hidden",
)}
/>
@@ -194,6 +200,17 @@ function CommissionMessageImage({ src }: { src: string }) {
);
}
function CommissionMediaGrid({ urls, pending = false }: { urls: string[]; pending?: boolean }) {
if (!urls.length) return null;
if (urls.length === 1)
return <div className={cn(pending && "opacity-50")}><CommissionMessageImage src={urls[0]} /></div>;
return <div className={cn("grid max-w-full gap-1 overflow-hidden rounded-lg",
urls.length === 2 ? "w-[min(72vw,24rem)] grid-cols-2" : "w-[min(72vw,36rem)] grid-cols-3",
pending && "opacity-50")}>
{urls.map((url) => <CommissionMessageImage key={url} src={url} compact />)}
</div>;
}
export function CommissionTicketChat({
ticketId,
ticketTitle,
@@ -216,9 +233,9 @@ export function CommissionTicketChat({
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [text, setText] = useState("");
const [image, setImage] = useState<File | null>(null);
const [imagePreview, setImagePreview] = useState<string | null>(null);
const imagePreviewRef = useRef<string | null>(null);
const [images, setImages] = useState<File[]>([]);
const [imagePreviews, setImagePreviews] = useState<string[]>([]);
const imagePreviewsRef = useRef<string[]>([]);
const optimisticImageUrls = useRef(new Set<string>());
const [optimisticMessages, setOptimisticMessages] = useState<
OptimisticMessage[]
@@ -249,10 +266,10 @@ export function CommissionTicketChat({
(message) =>
!message.serverId ||
!messages.some((saved) => saved.id === message.serverId),
);
).map((message) => ({ ...message, imageUrls: imageUrlsOf(message) }));
const visibleMessages = messages.filter(
(message) => !deletedMessageIds.has(message.id),
);
).map((message) => ({ ...message, imageUrls: imageUrlsOf(message) }));
const visibleMessageById = new Map(
visibleMessages.map((message) => [message.id, message]),
);
@@ -262,7 +279,7 @@ export function CommissionTicketChat({
useEffect(
() => () => {
if (imagePreviewRef.current) URL.revokeObjectURL(imagePreviewRef.current);
for (const url of imagePreviewsRef.current) URL.revokeObjectURL(url);
for (const url of optimisticImageUrls.current) URL.revokeObjectURL(url);
},
[],
@@ -270,14 +287,15 @@ export function CommissionTicketChat({
useEffect(() => {
for (const message of optimisticMessages) {
const urls = imageUrlsOf(message);
if (
message.serverId &&
messages.some((saved) => saved.id === message.serverId) &&
message.imageUrl &&
optimisticImageUrls.current.has(message.imageUrl)
urls.some((url) => optimisticImageUrls.current.has(url))
) {
URL.revokeObjectURL(message.imageUrl);
optimisticImageUrls.current.delete(message.imageUrl);
for (const url of urls) {
if (optimisticImageUrls.current.delete(url)) URL.revokeObjectURL(url);
}
}
}
}, [messages, optimisticMessages]);
@@ -286,7 +304,7 @@ export function CommissionTicketChat({
const originalTitle = document.title;
let managedTitle = ticketTitle;
let unread = 0;
document.title = ticketTitle;
const stopKeepingTitle = keepCommissionTitle(() => managedTitle);
const markRead = () => {
if (document.hidden || !document.hasFocus()) return;
unread = 0;
@@ -353,6 +371,7 @@ export function CommissionTicketChat({
source.close();
document.removeEventListener("visibilitychange", markRead);
window.removeEventListener("focus", markRead);
stopKeepingTitle();
if (document.title === managedTitle) document.title = originalTitle;
};
}, [ticketId, ticketTitle, userId, admin, router]);
@@ -389,49 +408,50 @@ export function CommissionTicketChat({
}).catch(() => undefined);
}
function setAttachment(file: File | null) {
if (imagePreviewRef.current) URL.revokeObjectURL(imagePreviewRef.current);
const preview = file ? URL.createObjectURL(file) : null;
imagePreviewRef.current = preview;
setImage(file);
setImagePreview(preview);
function setAttachments(files: File[]) {
for (const url of imagePreviewsRef.current) URL.revokeObjectURL(url);
const previews = files.map((file) => URL.createObjectURL(file));
imagePreviewsRef.current = previews;
setImages(files);
setImagePreviews(previews);
}
function chooseAttachment(file: File | null) {
if (
file &&
(file.size > 6 * 1024 * 1024 ||
!["image/png", "image/jpeg", "image/webp"].includes(file.type))
) {
setError("แนบได้เฉพาะรูป PNG, JPEG หรือ WebP ขนาดไม่เกิน 6 MB");
function chooseAttachments(files: File[]) {
if (images.length + files.length > 10) {
setError("แนบรูปภาพได้ไม่เกิน 10 รูปต่อข้อความ");
return;
}
if (files.some((file) => file.size === 0 || file.size > 10 * 1024 * 1024 ||
!["image/png", "image/jpeg", "image/webp"].includes(file.type))) {
setError("แนบได้เฉพาะรูป PNG, JPEG หรือ WebP ขนาดไม่เกิน 10 MiB ต่อรูป");
return;
}
setError("");
setAttachment(file);
setAttachments([...images, ...files]);
}
function handleComposerPaste(event: ClipboardEvent<HTMLTextAreaElement>) {
const pastedImage = Array.from(event.clipboardData.items)
.find((item) => item.kind === "file" && item.type.startsWith("image/"))
?.getAsFile();
if (!pastedImage) return;
const pastedImages = Array.from(event.clipboardData.items)
.filter((item) => item.kind === "file" && item.type.startsWith("image/"))
.map((item) => item.getAsFile()).filter((file): file is File => file !== null);
if (!pastedImages.length) return;
event.preventDefault();
if (fileRef.current) fileRef.current.value = "";
chooseAttachment(pastedImage);
chooseAttachments(pastedImages);
}
async function send(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (busy || (!text.trim() && !image)) return;
if (busy || (!text.trim() && !images.length)) return;
setBusy(true);
setError("");
sendingRef.current = true;
const form = event.currentTarget;
const originalText = text;
const originalImage = image;
const originalImages = images;
const localId = String(++nextOptimisticId.current);
const imageUrl = image ? URL.createObjectURL(image) : null;
if (imageUrl) optimisticImageUrls.current.add(imageUrl);
const imageUrls = images.map((file) => URL.createObjectURL(file));
for (const url of imageUrls) optimisticImageUrls.current.add(url);
setOptimisticMessages((current) => [
...current.filter(
(message) =>
@@ -442,7 +462,7 @@ export function CommissionTicketChat({
localId,
serverId: null,
text: text.trim(),
imageUrl,
imageUrls,
replyToId: replyTarget?.id ?? null,
status: "sending",
},
@@ -453,11 +473,11 @@ export function CommissionTicketChat({
data.set("text", text.trim());
if (replyTarget) data.set("replyToId", replyTarget.id);
else data.delete("replyToId");
if (image) data.set("image", image);
else data.delete("image");
data.delete("image");
for (const image of images) data.append("image", image);
form.reset();
setText("");
setAttachment(null);
setAttachments([]);
setReplyToId(null);
sendTyping(false);
const response = await fetch(
@@ -478,15 +498,15 @@ export function CommissionTicketChat({
deferredRefresh.current = false;
router.refresh();
} catch (cause) {
if (imageUrl) {
URL.revokeObjectURL(imageUrl);
optimisticImageUrls.current.delete(imageUrl);
for (const url of imageUrls) {
URL.revokeObjectURL(url);
optimisticImageUrls.current.delete(url);
}
setOptimisticMessages((current) =>
current.filter((message) => message.localId !== localId),
);
setText(originalText);
setAttachment(originalImage);
setAttachments(originalImages);
setReplyToId(replyTarget?.id ?? null);
setError(cause instanceof Error ? cause.message : "ส่งข้อความไม่สำเร็จ");
sendingRef.current = false;
@@ -712,9 +732,17 @@ export function CommissionTicketChat({
</Empty>
) : (
<MessageGroup className="gap-3">
{visibleMessages.map((message) => {
{visibleMessages.map((message, index) => {
const mine = message.authorId === userId;
const sentAt = new Date(message.createdAt);
const previous = visibleMessages[index - 1];
const showAuthor = !previous || previous.authorId !== message.authorId;
const next = visibleMessages[index + 1];
const showAvatar = !next || next.authorId !== message.authorId;
const previousAt = previous ? new Date(previous.createdAt) : null;
const showTime = !previousAt || sentAt.getFullYear() !== previousAt.getFullYear() ||
sentAt.getMonth() !== previousAt.getMonth() || sentAt.getDate() !== previousAt.getDate() ||
sentAt.getHours() !== previousAt.getHours();
const parent = message.replyToId
? visibleMessageById.get(message.replyToId)
: null;
@@ -759,16 +787,17 @@ export function CommissionTicketChat({
.filter(({ count }) => count > 0);
return (
<Message key={message.id} align={mine ? "end" : "start"}>
<MessageAvatar>
<Avatar size="sm">
{message.authorImage && (
<AvatarImage src={message.authorImage} alt="" />
)}
<AvatarFallback>
{message.authorName.trim().charAt(0).toUpperCase() ||
"?"}
</AvatarFallback>
</Avatar>
<MessageAvatar className={showAvatar ? undefined : "bg-transparent"}>
{showAvatar && (
<Avatar size="sm">
{message.authorImage && (
<AvatarImage src={message.authorImage} alt="" />
)}
<AvatarFallback>
{message.authorName.trim().charAt(0).toUpperCase() || "?"}
</AvatarFallback>
</Avatar>
)}
</MessageAvatar>
<MessageContent
className={cn(
@@ -776,25 +805,21 @@ export function CommissionTicketChat({
mine ? "items-end" : "items-start",
)}
>
<div
className={cn(
"w-full px-1 text-center text-xs font-medium text-muted-foreground",
mine ? "translate-x-5" : "-translate-x-5",
)}
>
<time
dateTime={sentAt.toISOString()}
title={sentAt.toLocaleString("th-TH")}
{showTime && (
<div
className={cn(
"w-full px-1 text-center text-xs font-medium text-muted-foreground",
mine ? "translate-x-5" : "-translate-x-5",
)}
>
{sentAt.toLocaleTimeString("th-TH", {
hour: "2-digit",
minute: "2-digit",
})}
</time>
</div>
<MessageHeader className="px-1">
{message.authorName}
</MessageHeader>
<time dateTime={sentAt.toISOString()}>
{`${padTime(sentAt.getDate())}/${padTime(sentAt.getMonth() + 1)}/${sentAt.getFullYear()} ${padTime(sentAt.getHours())}:${padTime(sentAt.getMinutes())}`}
</time>
</div>
)}
{!mine && showAuthor && (
<MessageHeader className="px-1">{message.authorName}</MessageHeader>
)}
<div
className={cn(
"flex max-w-[min(82%,36rem)] items-center gap-1",
@@ -803,10 +828,10 @@ export function CommissionTicketChat({
>
<Bubble
variant={
message.imageUrl
message.imageUrls.length
? "ghost"
: mine
? "muted"
? "default"
: "secondary"
}
className="min-w-0 max-w-full"
@@ -814,7 +839,7 @@ export function CommissionTicketChat({
<BubbleContent
className={cn(
"max-w-full",
message.imageUrl
message.imageUrls.length
? "border-0 bg-transparent p-0 shadow-none"
: "px-4 py-2.5 shadow-sm",
mine ? "rounded-br-md" : "rounded-bl-md",
@@ -825,16 +850,14 @@ export function CommissionTicketChat({
<p
className={cn(
"whitespace-pre-wrap",
mine && "text-foreground",
message.imageUrl && "pb-2",
mine && message.imageUrls.length && "text-foreground",
message.imageUrls.length > 0 && "pb-2",
)}
>
{message.text}
</p>
)}
{message.imageUrl && (
<CommissionMessageImage src={message.imageUrl} />
)}
<CommissionMediaGrid urls={message.imageUrls} />
</BubbleContent>
</Bubble>
<div className="flex shrink-0 items-center gap-0.5 rounded-md bg-card/90 opacity-0 transition-opacity pointer-events-none group-hover/message:pointer-events-auto group-hover/message:opacity-100 group-focus-within/message:pointer-events-auto group-focus-within/message:opacity-100 [@media(hover:none)]:pointer-events-auto [@media(hover:none)]:opacity-100">
@@ -971,13 +994,13 @@ export function CommissionTicketChat({
<Message key={message.localId} align="end">
<MessageContent className="w-full items-end gap-1">
<Bubble
variant={message.imageUrl ? "ghost" : "muted"}
variant={message.imageUrls.length ? "ghost" : "default"}
className="max-w-[min(82%,36rem)]"
>
<BubbleContent
className={cn(
"max-w-full",
message.imageUrl
message.imageUrls.length
? "border-0 bg-transparent p-0 shadow-none"
: "px-4 py-2.5 shadow-sm",
)}
@@ -994,29 +1017,21 @@ export function CommissionTicketChat({
<p
className={cn(
"whitespace-pre-wrap",
message.status === "sending"
? "text-muted-foreground"
: "text-foreground",
message.imageUrl && "pb-2",
message.status === "sending" &&
(message.imageUrls.length ? "text-muted-foreground" : "text-primary-foreground/70"),
message.imageUrls.length > 0 && message.status !== "sending" && "text-foreground",
message.imageUrls.length > 0 && "pb-2",
)}
>
{message.text}
</p>
)}
{message.imageUrl && (
<div
className={cn(
message.status === "sending" && "opacity-50",
)}
>
<CommissionMessageImage src={message.imageUrl} />
</div>
)}
<CommissionMediaGrid urls={message.imageUrls} pending={message.status === "sending"} />
</BubbleContent>
</Bubble>
<MessageFooter className="w-full justify-center gap-1 px-1 group-data-[align=end]/message:justify-center">
{message.status === "sending" ? "กำลังส่ง..." : "ส่งแล้ว"}
</MessageFooter>
{message.status === "sending" && <MessageFooter className="w-full justify-end gap-1 px-1">
กำลังส่ง...
</MessageFooter>}
</MessageContent>
</Message>
))}
@@ -1048,29 +1063,24 @@ export function CommissionTicketChat({
</Button>
</div>
)}
{image && (
<div className="relative w-fit rounded-lg border bg-muted/50 p-2">
{/* eslint-disable-next-line @next/next/no-img-element */}
{imagePreview && (
<img
src={imagePreview}
alt="ตัวอย่างรูปภาพที่จะแนบ"
className="size-28 rounded-md bg-muted object-contain"
/>
)}
<Button
type="button"
variant="secondary"
size="icon-xs"
aria-label="นำรูปภาพออก"
className="absolute top-1 right-1 shadow-sm"
onClick={() => {
setAttachment(null);
if (fileRef.current) fileRef.current.value = "";
}}
>
<X />
</Button>
{images.length > 0 && (
<div>
<div className="grid max-w-md grid-cols-5 gap-1.5" aria-label={`รูปภาพที่แนบ ${images.length} รูป`}>
{imagePreviews.map((preview, index) => (
<div key={preview} className="relative aspect-square overflow-hidden rounded-md bg-muted">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={preview} alt={`ตัวอย่างรูปภาพที่แนบ ${index + 1}`}
className="absolute inset-0 size-full object-cover" />
<Button type="button" variant="secondary" size="icon-xs"
aria-label={`นำรูปภาพที่ ${index + 1} ออก`}
className="absolute top-0.5 right-0.5 shadow-sm"
onClick={() => setAttachments(images.filter((_, position) => position !== index))}>
<X />
</Button>
</div>
))}
</div>
<p className="mt-1 text-xs text-muted-foreground">{images.length}/10 รูป · สูงสุด 10 MiB ต่อรูป</p>
</div>
)}
<Field>
@@ -1086,7 +1096,7 @@ export function CommissionTicketChat({
type="button"
variant="ghost"
size="icon-sm"
disabled={busy}
disabled={busy || images.length >= 10}
aria-label="แนบรูปภาพ"
onClick={() => fileRef.current?.click()}
>
@@ -1120,7 +1130,7 @@ export function CommissionTicketChat({
variant="default"
size="sm"
aria-label={busy ? "กำลังส่งข้อความ" : "ส่งข้อความ"}
disabled={busy || (!text.trim() && !image)}
disabled={busy || (!text.trim() && !images.length)}
>
<SendHorizontal data-icon="inline-start" />
<span className="hidden sm:inline">
@@ -1141,17 +1151,10 @@ export function CommissionTicketChat({
aria-label="แนบรูปภาพ"
className="sr-only"
accept="image/png,image/jpeg,image/webp"
multiple
onChange={(event) => {
const selected = event.target.files?.[0] ?? null;
chooseAttachment(selected);
if (
selected &&
(selected.size > 6 * 1024 * 1024 ||
!["image/png", "image/jpeg", "image/webp"].includes(
selected.type,
))
)
event.target.value = "";
chooseAttachments(Array.from(event.target.files ?? []));
event.target.value = "";
}}
/>
</FieldGroup>
+3 -2
View File
@@ -28,8 +28,9 @@ export async function CommissionTicketDetail({ id, admin }: { id: string; admin:
</CardContent></Card>
} chat={<CommissionTicketChat ticketId={id} ticketTitle={data.ticket.title || `Ticket #${id.slice(0, 8)}`}
userId={data.currentUserId} status={data.ticket.status}
messages={data.messages.map(({ imageObjectKey, ...message }) => ({ ...message,
imageUrl: imageObjectKey ? publicMediaUrl(imageObjectKey) : null }))} admin={admin} />}
messages={data.messages.map(({ imageObjectKey, imageObjectKeys, ...message }) => ({ ...message,
imageUrls: [imageObjectKey, ...(imageObjectKeys ?? [])].filter((key): key is string => Boolean(key))
.map(publicMediaUrl) }))} admin={admin} />}
/>
</div>;
}
+1
View File
@@ -219,6 +219,7 @@ export const commissionMessages = commissionSchema.table("message", {
text: text("text"),
imageObjectKey: text("image_object_key"),
imageMimeType: varchar("image_mime_type", { length: 32 }),
imageObjectKeys: jsonb("image_object_keys").$type<string[]>().default([]).notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
}, (table) => [
index("commission_message_ticket_time_idx").on(table.ticketId, table.createdAt),
+1
View File
@@ -0,0 +1 @@
ALTER TABLE "commission"."message" ADD COLUMN "image_object_keys" jsonb DEFAULT '[]'::jsonb NOT NULL;
File diff suppressed because it is too large Load Diff
+7
View File
@@ -491,6 +491,13 @@
"when": 1790933217844,
"tag": "0069_useful_living_mummy",
"breakpoints": true
},
{
"idx": 70,
"version": "7",
"when": 1791015188934,
"tag": "0070_outgoing_chimera",
"breakpoints": true
}
]
}
+4
View File
@@ -9,6 +9,10 @@ describe("image decoding before publication", () => {
await expect(inspectImage(bytes, "image/jpeg")).rejects.toMatchObject({ status: 422 });
await expect(inspectImage(bytes.subarray(0, 40), "image/png")).rejects.toMatchObject({ status: 422 });
});
it.each(["jpeg", "webp"] as const)("accepts a decoded %s image", async (format) => {
const bytes = await sharp({ create: { width: 8, height: 12, channels: 3, background: "red" } })[format]().toBuffer();
await expect(inspectImage(bytes, `image/${format}`)).resolves.toEqual({ width: 8, height: 12 });
});
it("rejects a magic-byte-only fake", async () => {
await expect(inspectImage(Uint8Array.from([137, 80, 78, 71, 13, 10, 26, 10]), "image/png"))
.rejects.toMatchObject({ status: 422 });
+3 -6
View File
@@ -1,5 +1,3 @@
import sharp from "sharp";
import { HttpError } from "@/lib/security/http";
import { hasValidImageSignature, MAX_MEDIA_BYTES, type IMAGE_MIME_TYPES } from "./validation";
@@ -9,13 +7,12 @@ export async function inspectImage(bytes: Uint8Array, mimeType: (typeof IMAGE_MI
if (bytes.byteLength > MAX_MEDIA_BYTES) throw new HttpError(413, "image-too-large");
if (!hasValidImageSignature(bytes.subarray(0, 16), mimeType)) throw new HttpError(422, "invalid-image");
try {
const image = sharp(bytes, { limitInputPixels: MAX_IMAGE_PIXELS, failOn: "warning", animated: true });
const image = new Bun.Image(bytes, { maxPixels: MAX_IMAGE_PIXELS });
const metadata = await image.metadata();
const width = metadata.autoOrient.width || metadata.width;
const height = metadata.autoOrient.height || metadata.height;
const { width, height } = metadata;
if (!width || !height || width * height > MAX_IMAGE_PIXELS) throw new Error("invalid dimensions");
// Metadata alone accepts truncated images; decode pixels before publishing.
await image.stats();
await image.bytes();
return { width, height };
} catch {
throw new HttpError(422, "invalid-image");