feat(frontend,api): allow uploading videos via the website
@@ -24,6 +24,8 @@ /** the user who opened the ticket, if it was not opened by the subject */
openedBy: User | null; /** the user who is the subject of the ticket, i.e. the reporter */ subject: User; + + attachments: Attachment[]; }; export type Message = {
@@ -150,6 +150,26 @@ new TextDisplayBuilder().setContent("# Ticket created!"),
new TextDisplayBuilder().setContent(formatted), ) .addActionRowComponents(controls), + new ContainerBuilder() + .setAccentColor(0x40a02b) + .addTextDisplayComponents( + new TextDisplayBuilder().setContent("# Uploading Videos"), + new TextDisplayBuilder().setContent( + [ + `You can either upload videos via Discord, or our ticket website. We **do not`, + `restrict file sizes** at all. You will have to sign in with Discord, but we`, + `won't see your email, or any other personal details.`, + ].join(" "), + ), + ) + .addActionRowComponents( + new ActionRowBuilder<ButtonBuilder>().addComponents( + new ButtonBuilder() + .setURL(`https://tickets.vt3e.cat/tickets/${ticketNumber}#upload`) + .setLabel("Upload a video") + .setStyle(ButtonStyle.Link), + ), + ), ]; if (targetUser) { const container = new ContainerBuilder().setAccentColor(0x40a02b);
@@ -1,9 +1,11 @@
import config from "@/config"; -import type { Ticket } from "@/database/schema"; +import { attachments, ticketMessages, type Ticket } from "@/database/schema"; import client from "@/discord"; -import { marshallIntoApiUser } from "./api"; +import { intoApiAttachment, marshallIntoApiUser } from "./api"; import _logger from "@/utils/logging"; import type { User as UserProfile, Ticket as ApiTicket } from "@stealth-developers/api"; +import { asc, eq, or, and, inArray } from "drizzle-orm"; +import { db } from "@/database"; const logger = _logger.child({ name: "server" });@@ -58,7 +60,7 @@ }
await getUsers([...allIds]); - return allTickets.map((ticket) => { + const promises = allTickets.map(async (ticket) => { const fallbackSubject: UserProfile = { id: ticket.authorId, name: "Subject",@@ -66,6 +68,33 @@ avatarUrl: "",
isModerator: false, }; + const messages = await db + .select() + .from(ticketMessages) + .where(eq(ticketMessages.ticketId, ticket.id)) + .orderBy(asc(ticketMessages.createdAt)); + + const messageIds = messages.map((m) => m.id); + + const ticketAttachments = await db + .select() + .from(attachments) + .where( + or( + and(eq(attachments.ownerType, "ticket"), eq(attachments.ownerId, ticket.id)), + messageIds.length > 0 + ? and( + eq(attachments.ownerType, "ticket_message"), + inArray(attachments.ownerId, messageIds), + ) + : undefined, + ), + ); + + const ticketLevelAttachments = ticketAttachments.filter( + (a) => a.ownerType === "ticket" && a.ownerId === ticket.id, + ); + return { number: String(ticket.id), status: ticket.status as "open" | "closed" | "archived",@@ -80,6 +109,20 @@
openedAt: ticket.createdAt?.toUTCString(), openedBy: ticket.openedBy ? (cache.get(ticket.openedBy) ?? null) : null, subject: cache.get(ticket.authorId) ?? fallbackSubject, - }; + + attachments: await Promise.all(ticketLevelAttachments.map(intoApiAttachment)), + } satisfies ApiTicket; }); + + return Promise.all(promises); +} + +export function notify(channelId: string, message: string) { + const channel = client.channels.cache.get(channelId); + if (!channel) return Promise.resolve(); + if (channel.isSendable()) + channel.send({ + content: message, + allowedMentions: { parse: [] }, + }); }
@@ -1,11 +1,13 @@
+import type { BunRequest } from "bun"; import { desc, eq, asc, lt, and, isNotNull, ne, gte, inArray, or } from "drizzle-orm"; -import type { BunRequest } from "bun"; +import { nanoid } from "nanoid"; import _logger from "@/utils/logging"; import config, { isProd } from "@/config"; import { db, tickets, ticketMessages, moderators, sessions, attachments } from "@/database"; -import { getUser, getUsers, hydrateTickets } from "./discord"; +import { getUser, getUsers, hydrateTickets, notify } from "./discord"; import { intoApiAttachment } from "./api"; +import { getPublicUrl, getPresignedUrl } from "@/utils/s3"; import type { TicketResponse,@@ -235,6 +237,14 @@ : undefined,
), ); + const ticketLevelAttachments = ticketAttachments.filter( + (a) => a.ownerType === "ticket" && a.ownerId === ticketId, + ); + const hydratedTicketWithAttachments = { + ...hydratedTicket, + attachments: await Promise.all(ticketLevelAttachments.map(intoApiAttachment)), + }; + const newMessages: Message[] = await Promise.all( messages.map(async (message) => { const author = await getUser(message.authorId);@@ -267,7 +277,7 @@ }),
); const response: TicketResponse = { - ticket: hydratedTicket, + ticket: hydratedTicketWithAttachments as any, messages: newMessages, };@@ -286,6 +296,109 @@ const deleted = await db.delete(tickets).where(eq(tickets.id, ticketId)).returning();
if (deleted.length === 0) return new Response("Ticket not found", { status: 404 }); return Response.json({ success: true }); + }, + }, + + "/api/tickets/:id/presign": { + POST: async (req: BunRequest) => { + const { user, isMod } = await authenticate(req); + if (!user) return new Response("Unauthorized", { status: 401 }); + + const ticketId = Number.parseInt(req.params.id, 10); + if (isNaN(ticketId)) return new Response("Invalid ID", { status: 400 }); + + const ticketList = await db.select().from(tickets).where(eq(tickets.id, ticketId)).limit(1); + const ticket = ticketList[0]; + if (!ticket) return new Response("Ticket not found", { status: 404 }); + + if (!isMod && ticket.authorId !== user.id) + return new Response("Forbidden", { status: 403 }); + + let body: any = {}; + try { + body = await req.json(); + } catch {} + + const fileName = body?.fileName || body?.name; + if (!fileName) return new Response("fileName required", { status: 400 }); + + const safeName = String(fileName) + .replace(/[^a-zA-Z0-9._-]/g, "_") + .slice(0, 200); + + const key = `${ticket.anonymousId}/web/${nanoid(8)}_${safeName}`; + + const requestedMethod = (body?.method || "PUT").toUpperCase(); + const allowed = ["PUT", "POST", "GET", "DELETE"]; + if (!allowed.includes(requestedMethod)) + return new Response("invalid method", { status: 400 }); + + const contentType = body?.contentType; + const headers: Record<string, string> | undefined = contentType + ? { "content-type": contentType } + : undefined; + + const url = getPresignedUrl(key, { method: requestedMethod as any, headers }); + + if (ticket.channelId) + notify(ticket.channelId, `Attachment being uploaded by <@${user.id}>...`); + + return Response.json({ + url, + key, + bucket: config.s3.bucket, + method: requestedMethod, + headers, + }); + }, + }, + + "/api/tickets/:id/attachments": { + POST: async (req: BunRequest) => { + const { user, isMod } = await authenticate(req); + if (!user) return new Response("Unauthorized", { status: 401 }); + + const ticketId = Number.parseInt(req.params.id, 10); + if (isNaN(ticketId)) return new Response("Invalid ID", { status: 400 }); + + const ticketList = await db.select().from(tickets).where(eq(tickets.id, ticketId)).limit(1); + const ticket = ticketList[0]; + if (!ticket) return new Response("Ticket not found", { status: 404 }); + + if (!isMod && ticket.authorId !== user.id) + return new Response("Forbidden", { status: 403 }); + + let body: any = {}; + try { + body = await req.json(); + } catch {} + + const { id, fileName, contentType, size, bucket, key } = body || {}; + if (!id || !fileName || !contentType || !size || !bucket || !key) + return new Response("missing fields", { status: 400 }); + + const finalUrl = getPublicUrl(key); + await db.insert(attachments).values({ + id, + ownerType: "ticket", + ownerId: ticketId, + authorId: user.id, + fileName, + contentType, + size, + bucket, + key, + url: finalUrl, + }); + + const att = await db.select().from(attachments).where(eq(attachments.id, id)).limit(1); + if (att.length === 0) return new Response("failed to insert", { status: 500 }); + const apiAtt = await intoApiAttachment(att[0]); + + if (ticket.channelId) + notify(ticket.channelId, `Attachment uploaded: ${finalUrl} by <@${user.id}>`); + + return Response.json(apiAtt); }, },
@@ -16,3 +16,17 @@ }
return s3.file(key).presign({ expiresIn: 3600 }); } + +export type PresignOptions = { + expiresIn?: number; + method?: "GET" | "PUT" | "POST" | "DELETE"; + headers?: Record<string, string>; +}; + +export function getPresignedUrl(key: string, opts: PresignOptions = {}) { + const { expiresIn = 3600, method, headers } = opts; + const presignOpts: any = { expiresIn }; + if (method) presignOpts.method = method; + if (headers) presignOpts.headers = headers; + return s3.file(key).presign(presignOpts); +}
@@ -1,16 +1,8 @@
-import { - type Attachment, - type Ticket, - attachments, - db, - ticketMessages, -} from "@/database"; +import { type Attachment, type Ticket, attachments, db, ticketMessages } from "@/database"; import { getPublicUrl } from "@/utils/s3"; -import { and, asc, eq } from "drizzle-orm"; +import { and, asc, eq, or, inArray } from "drizzle-orm"; -export async function generateTranscript( - ticket: Ticket, -): Promise<[string, Attachment[]]> { +export async function generateTranscript(ticket: Ticket): Promise<[string, Attachment[]]> { const messages = await db .select() .from(ticketMessages)@@ -22,14 +14,24 @@
const ticketAttachments = await db .select() .from(attachments) - .where(and(eq(attachments.ownerType, "ticket_message"))); + .where( + or( + and(eq(attachments.ownerType, "ticket"), eq(attachments.ownerId, ticket.id)), + messageIds.length > 0 + ? and( + eq(attachments.ownerType, "ticket_message"), + inArray(attachments.ownerId, messageIds), + ) + : undefined, + ), + ); - const relevantAttachments = ticketAttachments.filter((att) => - messageIds.includes(att.ownerId), + const ticketLevelAttachments = ticketAttachments.filter( + (a) => a.ownerType === "ticket" && a.ownerId === ticket.id, ); const attachmentMap = new Map<number, Attachment[]>(); - for (const att of relevantAttachments) { + for (const att of ticketLevelAttachments) { const existing = attachmentMap.get(att.ownerId) ?? []; attachmentMap.set(att.ownerId, [...existing, att]); }@@ -74,5 +76,5 @@ separator,
"\n", ].join("\n"); - return [header + lines.join("\n"), relevantAttachments]; + return [header + lines.join("\n"), ticketLevelAttachments]; }
@@ -130,7 +130,7 @@
background-color: hsla(var(--surface0) / 1); } -a.login-button { +a.login-button, .button { display: inline-block; padding: 0.75rem 1.5rem; background-color: hsla(var(--accent) / 0.9);
@@ -14,6 +14,12 @@ const ticketId = computed(() => route.params.id as string)
const ticket = ref<Ticket | null>(null) const messages = ref<Message[]>([]) +const uploading = ref(false) +const uploadProgress = ref(0) +const uploadError = ref<string | null>(null) + +const highlightUploadButton = ref(false) + const users = computed(() => { const messageAuthors = messages.value .map((msg) => msg.author.id)@@ -27,12 +33,17 @@ {} as Record<string, User>,
) }) +async function loadTicket(id: string) { + const ticketRes = await fetch(`/api/tickets/${id}`).then((res) => res.json()) + ticket.value = ticketRes.ticket + messages.value = ticketRes.messages + stateStore.addViewedTicket(id) +} watch(ticketId, async (newTicketId: string) => { - const ticketRes = await fetch(`/api/tickets/${newTicketId}`).then((res) => res.json()) - ticket.value = ticketRes.ticket - messages.value = ticketRes.messages - stateStore.addViewedTicket(newTicketId) + if (route.hash) highlightUploadButton.value = true + + await loadTicket(newTicketId) }, { immediate: true }) const groupedMessages = computed(() => {@@ -79,6 +90,96 @@ if (!dateString) return '/'
const date = new Date(dateString) return date.toLocaleString() } + +const formatSize = (bytes: number) => { + if (bytes === 0) return '0 B' + const k = 1024 + const sizes = ['B', 'KB', 'MB', 'GB', 'TB'] + const i = Math.floor(Math.log(bytes) / Math.log(k)) + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i] +} + +async function uploadFile(file: File) { + if (!ticketId.value) return + uploading.value = true + uploadError.value = null + highlightUploadButton.value = false + + try { + const presignRes = await fetch(`/api/tickets/${ticketId.value}/presign`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ fileName: file.name, contentType: file.type }), + }) + + if (!presignRes.ok) throw new Error('Failed to get upload url') + const presign = await presignRes.json() + const { url, key, bucket, method: uploadMethod, headers: requiredHeaders } = presign + + const uploadHeaders: Record<string, string> = {} + if (requiredHeaders && typeof requiredHeaders === 'object') { + Object.assign(uploadHeaders, requiredHeaders) + } + + uploadProgress.value = 0 + await new Promise<void>((resolve, reject) => { + const xhr = new XMLHttpRequest() + xhr.open((uploadMethod as string) || 'PUT', url) + + for (const [key, value] of Object.entries(uploadHeaders)) { + xhr.setRequestHeader(key, value) + } + + xhr.upload.onprogress = (e) => { + if (e.lengthComputable) { + uploadProgress.value = Math.round((e.loaded * 100) / e.total) + } + } + + xhr.onload = () => { + if (xhr.status >= 200 && xhr.status < 300) { + resolve() + } else { + reject(new Error(`Upload failed with status ${xhr.status}`)) + } + } + + xhr.onerror = () => reject(new Error('Upload failed')) + + xhr.send(file) + }) + + const rnd = (crypto as Crypto & { randomUUID?: () => string }).randomUUID?.() + const id = rnd ?? `${Date.now()}-${Math.random().toString(36).slice(2,8)}` + const notifyRes = await fetch(`/api/tickets/${ticketId.value}/attachments`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id, fileName: file.name, contentType: file.type || 'application/octet-stream', size: file.size, bucket, key, url }), + }) + if (!notifyRes.ok) throw new Error('Failed to register attachment') + + await loadTicket(ticketId.value) + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err) + uploadError.value = msg + } finally { + uploading.value = false + } +} + +async function handleFiles(e: Event) { + const input = e.target as HTMLInputElement + if (!input.files) return + for (let i = 0; i < input.files.length; i++) { + const f = input.files.item(i) + if (!f) continue + await uploadFile(f) + } + input.value = '' +} + +const ticketAttachments = computed(() => ((ticket.value)?.attachments ?? [])) + </script> <template>@@ -114,11 +215,58 @@ <div class="label">Private Reason</div>
<div class="value">{{ ticket.privateReason }}</div> </div> </div> + + <div class="ticket-attachments"> + <div class="attachments-header"> + <div class="label">Attachments</div> + </div> + + <div class="attachments" v-if="ticketAttachments.length > 0"> + <template v-for="att in ticketAttachments" :key="att.id"> + <div v-if="att.contentType.startsWith('video/')" class="attachment"> + <video :src="att.url" controls class="attachment-media"></video> + </div> + <a v-else :href="att.url" target="_blank" class="attachment link-attachment"> + <img + v-if="att.contentType.startsWith('image/')" + :src="att.url" + :alt="att.fileName" + class="attachment-media" + /> + <div class="attachment-file" v-else> + <div class="file-info"> + <span class="filename">{{ att.fileName }}</span> + <span class="size">{{ formatSize(att.size) }}</span> + </div> + </div> + </a> + </template> + </div> + <div class="ticket-attachments-upload"> + <label + class="upload-btn button" + :class="{ highlighted: highlightUploadButton }" + @click="highlightUploadButton = false" + > + <input type="file" multiple @change="handleFiles" class="hidden-input" /> + <span>Add Attachments</span> + </label> + <div class="upload-status" :class="{ visible: uploading || uploadError }"> + <span v-if="uploading" class="uploading">Uploading… {{ uploadProgress }}%</span> + <span v-if="uploadError" class="error">{{ uploadError }}</span> + </div> + <div class="progress-bar-container" :class="{ visible: uploading }"> + <div class="progress-bar" :style="{ width: uploadProgress + '%' }"></div> + </div> + </div> + </div> </div> <div class="ticket-messages"> <UserMessage v-for="(group, index) in groupedMessages" :key="index" :messages="group" /> </div> + + </template> <style scoped>@@ -204,71 +352,196 @@ white-space: pre-wrap;
} } } +} - .ticket-attachments { - margin-top: 1.5rem; +.ticket-attachments { + margin-top: 0.25rem; + padding-top: 0.25rem; + border-top: 2px solid hsla(var(--surface0)); + + .attachments-header { + display: flex; + justify-content: space-between; + align-items: center; .label { - font-weight: bold; - color: hsl(var(--subtext0)); - margin-bottom: 0.5rem; + font-weight: 600; + font-size: 1rem; + } + } + + .ticket-attachments-upload { + display: flex; + align-items: center; + gap: 1rem; + margin-top: 0.5rem; + + .upload-btn { + display: inline-block; + padding: 0.4rem 0.8rem; + background-color: hsla(var(--surface0) / 0.9); + color: hsl(var(--accent)); + text-decoration: none; + border-radius: 5rem; + + &:hover { + background-color: hsla(var(--accent) / 1); + color: hsl(var(--on-accent)); + } + &:active { + background-color: hsla(var(--accent) / 0.75); + scale: 0.975; + } + + &.highlighted { + position: relative; + overflow: hidden; + border: 3px solid hsl(var(--accent)); + + &::after { + content: ""; + position: absolute; + top: -50%; + left: -60%; + width: 20%; + height: 200%; + background: white; + opacity: 0.4; + transform: rotate(30deg); + animation: shimmer 2s infinite; + filter: blur(5px); + } + } + + + + .hidden-input { + display: none; + } + } + + .upload-status { + font-size: 0.85rem; + min-width: 130px; + font-variant-numeric: tabular-nums; + opacity: 0; + transition: opacity 0.2s; + + &.visible { + opacity: 1; + } + + .uploading { + color: hsl(var(--accent)); + font-weight: 500; + } + .error { + color: hsl(var(--red)); + font-weight: 500; + } + } + + .progress-bar-container { + width: 200px; + height: 8px; + background-color: hsla(var(--surface1)); + border-radius: 4px; + overflow: hidden; + opacity: 0; + transition: opacity 0.2s; + + &.visible { + opacity: 1; + } + + .progress-bar { + height: 100%; + background-color: hsl(var(--accent)); + transition: width 0.1s ease; + } } + } - .attachments { + .attachments { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + + .attachment { display: flex; - flex-wrap: wrap; - gap: 0.5rem; + flex-direction: column; + border-radius: 0.5rem; + overflow: hidden; + background-color: hsla(var(--surface0)); + border: 1px solid hsla(var(--surface1)); + + &.link-attachment { + text-decoration: none; + } - .attachment { + .attachment-media { display: block; - border-radius: 0.5rem; - overflow: hidden; - background-color: hsla(var(--surface0) / 0.5); - border: 1px solid hsla(var(--surface1)); + max-width: 100%; + max-height: 150px; + object-fit: cover; + } - &.link-attachment { - text-decoration: none; - transition: border-color 0.2s; + img.attachment-media { + min-width: 150px; + } - &:hover { - border-color: hsla(var(--accent) / 0.5); - } - } + video.attachment-media { + max-width: 250px; + } - .attachment-media { - display: block; - max-width: 100%; - max-height: 200px; - object-fit: contain; + .attachment-file { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.75rem 1rem; + color: hsl(var(--text)); + background-color: hsla(var(--surface0)); + + .file-icon { + color: hsl(var(--subtext0)); } - .attachment-file { + .file-info { display: flex; - align-items: center; - gap: 0.5rem; - padding: 0.5rem 0.75rem; - color: hsl(var(--text)); + flex-direction: column; .filename { font-weight: 500; + font-size: 0.9rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; - max-width: 200px; + max-width: 150px; } .size { color: hsl(var(--subtext0)); - font-size: 0.8rem; - white-space: nowrap; + font-size: 0.75rem; } } } } } + + .no-attachments { + color: hsl(var(--subtext0)); + font-style: italic; + font-size: 0.9rem; + padding: 0.5rem 0; + } } .ticket-messages { margin: 0 -1rem; +} + +@keyframes shimmer { + 0% { left: -60%; } + 100% { left: 150%; } } </style>