feat: file/screenshot attachments on support tickets (v0.49.1)

Support form now accepts up to 5 attachments (images, PDF, text, JSON,
zip; 10MB each) via the existing S3/MinIO presigned-upload flow. New
support_ticket_attachments table; attachment links get folded into the
Gitea issue body and shown as thumbnails in both the user's ticket
history and the admin support view.

New presign endpoint (/api/v1/support/attachments/presign, rate-limited
20/hr) scoped to the uploading user rather than a ticket id, since the
ticket doesn't exist yet at upload time.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Arnaud
2026-07-18 12:09:40 +02:00
parent 811d4cad42
commit 12c2ec213a
19 changed files with 5855 additions and 64 deletions
@@ -0,0 +1,48 @@
import { NextRequest, NextResponse } from "next/server";
import crypto from "node:crypto";
import { z } from "zod";
import { requireSession } from "@/lib/api-auth";
import { createPresignedUploadPost } from "@/lib/storage";
import { applyRateLimit } from "@/lib/rate-limit";
const ALLOWED_TYPES = [
"image/jpeg",
"image/png",
"image/webp",
"image/avif",
"image/gif",
"application/pdf",
"text/plain",
"application/json",
"application/zip",
] as const;
type AllowedType = (typeof ALLOWED_TYPES)[number];
const MAX_FILE_SIZE = 10 * 1024 * 1024;
const Schema = z.object({
contentType: z.string().refine((t): t is AllowedType => (ALLOWED_TYPES as readonly string[]).includes(t), {
message: "Unsupported file type",
}),
fileSize: z.number().int().positive().max(MAX_FILE_SIZE, "File exceeds 10MB limit"),
});
export async function POST(req: NextRequest) {
const { session, response } = await requireSession();
if (response) return response;
const rateLimitResponse = await applyRateLimit(`rl:support-attachment:${session!.user.id}`, 20, 3600);
if (rateLimitResponse) return rateLimitResponse;
const body = (await req.json()) as unknown;
const parsed = Schema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: "Validation error", issues: parsed.error.issues }, { status: 400 });
}
const { contentType, fileSize } = parsed.data;
const ext = contentType.split("/")[1] ?? "bin";
const key = `support/${session!.user.id}/${crypto.randomUUID()}.${ext}`;
const { url, fields } = await createPresignedUploadPost(key, contentType, MAX_FILE_SIZE);
return NextResponse.json({ url, fields, key, contentType, fileSize });
}