feat: « Au fil de l'eau » — Reels mobile + uploader pro + favoris
All checks were successful
CI / test (pull_request) Successful in 2m18s
All checks were successful
CI / test (pull_request) Successful in 2m18s
This commit is contained in:
parent
a575d40163
commit
2545a5e1a8
20 changed files with 1569 additions and 72 deletions
66
src/app/api/uploads/finalize/route.ts
Normal file
66
src/app/api/uploads/finalize/route.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
|
||||
import { auth } from "@/auth";
|
||||
import { MediaType, UserRole } from "@/generated/prisma/enums";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { classifyMime } from "@/lib/uploads";
|
||||
import { recordAudit } from "@/lib/admin/audit";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const schema = z.object({
|
||||
carbetId: z.string().min(1),
|
||||
s3Key: z.string().min(5).max(500),
|
||||
s3Url: z.string().url(),
|
||||
mime: z.string().min(3).max(100),
|
||||
});
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
|
||||
}
|
||||
const parsed = schema.safeParse(await req.json().catch(() => ({})));
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Payload invalide" }, { status: 400 });
|
||||
}
|
||||
const kind = classifyMime(parsed.data.mime);
|
||||
if (!kind) return NextResponse.json({ error: "Type non supporté" }, { status: 400 });
|
||||
|
||||
const carbet = await prisma.carbet.findUnique({
|
||||
where: { id: parsed.data.carbetId },
|
||||
select: { id: true, ownerId: true },
|
||||
});
|
||||
if (!carbet) return NextResponse.json({ error: "Carbet introuvable" }, { status: 404 });
|
||||
const isOwner = carbet.ownerId === session.user.id;
|
||||
const isAdmin = session.user.role === UserRole.ADMIN;
|
||||
if (!isOwner && !isAdmin) {
|
||||
return NextResponse.json({ error: "Accès refusé" }, { status: 403 });
|
||||
}
|
||||
|
||||
// S3Key doit appartenir au carbet — verrou pour éviter qu'un user finalise une key étrangère.
|
||||
if (!parsed.data.s3Key.startsWith(`carbets/${carbet.id}/`)) {
|
||||
return NextResponse.json({ error: "s3Key invalide pour ce carbet" }, { status: 400 });
|
||||
}
|
||||
|
||||
const existingCount = await prisma.media.count({ where: { carbetId: carbet.id } });
|
||||
const media = await prisma.media.create({
|
||||
data: {
|
||||
carbetId: carbet.id,
|
||||
type: kind === "photo" ? MediaType.PHOTO : MediaType.VIDEO,
|
||||
s3Key: parsed.data.s3Key,
|
||||
s3Url: parsed.data.s3Url,
|
||||
sortOrder: existingCount,
|
||||
},
|
||||
select: { id: true, type: true, s3Url: true, s3Key: true, sortOrder: true },
|
||||
});
|
||||
await recordAudit({
|
||||
scope: "uploads",
|
||||
event: "media.finalize",
|
||||
target: media.id,
|
||||
actorEmail: session.user.email ?? null,
|
||||
details: { carbetId: carbet.id, kind },
|
||||
});
|
||||
return NextResponse.json({ media });
|
||||
}
|
||||
55
src/app/api/uploads/presign/route.ts
Normal file
55
src/app/api/uploads/presign/route.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
|
||||
import { auth } from "@/auth";
|
||||
import { UserRole } from "@/generated/prisma/enums";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { presignCarbetUpload } from "@/lib/uploads";
|
||||
import { rateLimitRequest } from "@/lib/rate-limit";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const schema = z.object({
|
||||
carbetId: z.string().min(1),
|
||||
mime: z.string().min(3).max(100),
|
||||
sizeBytes: z.coerce.number().int().min(1).max(500 * 1024 * 1024),
|
||||
});
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const rl = rateLimitRequest(req, "presign", 60_000, 60);
|
||||
if (!rl.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: `Trop de demandes. Réessayez dans ${rl.retryAfter}s.` },
|
||||
{ status: 429 },
|
||||
);
|
||||
}
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
|
||||
}
|
||||
const parsed = schema.safeParse(await req.json().catch(() => ({})));
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Payload invalide" }, { status: 400 });
|
||||
}
|
||||
|
||||
const carbet = await prisma.carbet.findUnique({
|
||||
where: { id: parsed.data.carbetId },
|
||||
select: { id: true, ownerId: true },
|
||||
});
|
||||
if (!carbet) return NextResponse.json({ error: "Carbet introuvable" }, { status: 404 });
|
||||
const isOwner = carbet.ownerId === session.user.id;
|
||||
const isAdmin = session.user.role === UserRole.ADMIN;
|
||||
if (!isOwner && !isAdmin) {
|
||||
return NextResponse.json({ error: "Accès refusé" }, { status: 403 });
|
||||
}
|
||||
|
||||
const result = await presignCarbetUpload({
|
||||
carbetId: carbet.id,
|
||||
mime: parsed.data.mime,
|
||||
sizeBytes: parsed.data.sizeBytes,
|
||||
});
|
||||
if ("error" in result) {
|
||||
return NextResponse.json({ error: result.error }, { status: 400 });
|
||||
}
|
||||
return NextResponse.json(result);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue