Jesse Daniel Brown commited on
Commit
b1137bb
·
1 Parent(s): 33167e7

Add signing and expiry to local download URLs

Browse files
src/app/api/storage/sign-download/route.test.ts CHANGED
@@ -1,7 +1,12 @@
1
  import { Role } from "@prisma/client";
2
- import { describe, expect, it, vi } from "vitest";
3
 
4
  import { auth } from "@/lib/auth";
 
 
 
 
 
5
  import { POST as signDownload } from "./route";
6
 
7
  vi.mock("@/lib/auth", () => ({
@@ -26,6 +31,13 @@ const adminSession = {
26
  expires: new Date(Date.now() + 60_000).toISOString(),
27
  };
28
 
 
 
 
 
 
 
 
29
  describe("/api/storage/sign-download", () => {
30
  it("retorna 403 quando a chave não pertence à organização do usuário", async () => {
31
  vi.mocked(auth).mockResolvedValue(adminSession as any);
@@ -42,4 +54,36 @@ describe("/api/storage/sign-download", () => {
42
  error: "Chave não pertence à organização.",
43
  });
44
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
45
  });
 
1
  import { Role } from "@prisma/client";
2
+ import { afterEach, describe, expect, it, vi } from "vitest";
3
 
4
  import { auth } from "@/lib/auth";
5
+ import prisma from "@/lib/db";
6
+ import {
7
+ LOCAL_URL_EXPIRATION_SECONDS,
8
+ generateLocalSignature,
9
+ } from "@/lib/storage/local-signature";
10
  import { POST as signDownload } from "./route";
11
 
12
  vi.mock("@/lib/auth", () => ({
 
31
  expires: new Date(Date.now() + 60_000).toISOString(),
32
  };
33
 
34
+ const originalEnv = { ...process.env };
35
+
36
+ afterEach(() => {
37
+ vi.resetAllMocks();
38
+ process.env = { ...originalEnv };
39
+ });
40
+
41
  describe("/api/storage/sign-download", () => {
42
  it("retorna 403 quando a chave não pertence à organização do usuário", async () => {
43
  vi.mocked(auth).mockResolvedValue(adminSession as any);
 
54
  error: "Chave não pertence à organização.",
55
  });
56
  });
57
+
58
+ it("retorna uma URL assinada com expiração curta para o driver local", async () => {
59
+ process.env.STORAGE_DRIVER = "local";
60
+ process.env.NODE_ENV = "development";
61
+ process.env.AUTH_SECRET = "local-secret";
62
+
63
+ vi.mocked(auth).mockResolvedValue(adminSession as any);
64
+ vi.mocked(prisma.recording.findFirst).mockResolvedValue({ id: "rec-1" } as any);
65
+
66
+ const key = "org/org-1/students/s1/recordings/file.webm";
67
+ const response = await signDownload(
68
+ new Request("http://localhost/api/storage/sign-download", {
69
+ method: "POST",
70
+ body: JSON.stringify({ key }),
71
+ })
72
+ );
73
+
74
+ expect(response.status).toBe(200);
75
+
76
+ const { downloadUrl } = await response.json();
77
+ const parsed = new URL(downloadUrl);
78
+ const expires = Number(parsed.searchParams.get("expires"));
79
+ const signature = parsed.searchParams.get("signature");
80
+
81
+ expect(parsed.searchParams.get("key")).toBe(key);
82
+ expect(Number.isFinite(expires)).toBe(true);
83
+ expect(expires).toBeGreaterThan(Math.floor(Date.now() / 1000));
84
+ expect(expires).toBeLessThanOrEqual(
85
+ Math.floor(Date.now() / 1000) + LOCAL_URL_EXPIRATION_SECONDS + 1
86
+ );
87
+ expect(signature).toBe(generateLocalSignature(key, expires));
88
+ });
89
  });
src/app/api/storage/sign-download/route.ts CHANGED
@@ -4,6 +4,10 @@ import { z } from "zod";
4
  import { auth } from "@/lib/auth";
5
  import prisma from "@/lib/db";
6
  import { getStorageProvider } from "@/lib/storage";
 
 
 
 
7
 
8
  const downloadSchema = z.object({
9
  key: z.string().min(1),
@@ -13,6 +17,23 @@ function keyMatchesOrg(key: string, orgId: string) {
13
  return key.startsWith(`org/${orgId}/`);
14
  }
15
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16
  export async function POST(request: Request) {
17
  const session = await auth();
18
 
@@ -46,7 +67,13 @@ export async function POST(request: Request) {
46
  const storage = getStorageProvider();
47
  const { url } = await storage.getSignedDownloadUrl({ key: data.key });
48
 
49
- return NextResponse.json({ downloadUrl: url });
 
 
 
 
 
 
50
  } catch (error) {
51
  if (error instanceof z.ZodError) {
52
  const message = error.issues[0]?.message ?? "Dados inválidos.";
 
4
  import { auth } from "@/lib/auth";
5
  import prisma from "@/lib/db";
6
  import { getStorageProvider } from "@/lib/storage";
7
+ import {
8
+ LOCAL_URL_EXPIRATION_SECONDS,
9
+ generateLocalSignature,
10
+ } from "@/lib/storage/local-signature";
11
 
12
  const downloadSchema = z.object({
13
  key: z.string().min(1),
 
17
  return key.startsWith(`org/${orgId}/`);
18
  }
19
 
20
+ function buildLocalSignedDownloadUrl(baseUrl: string, key: string) {
21
+ const url = new URL(baseUrl);
22
+
23
+ // Garantimos que os parâmetros usados para validação estejam presentes e
24
+ // recém-gerados, evitando links reutilizáveis por tempo indefinido.
25
+ url.search = "";
26
+
27
+ const expires =
28
+ Math.floor(Date.now() / 1000) + LOCAL_URL_EXPIRATION_SECONDS;
29
+
30
+ url.searchParams.set("key", key);
31
+ url.searchParams.set("expires", expires.toString());
32
+ url.searchParams.set("signature", generateLocalSignature(key, expires));
33
+
34
+ return url.toString();
35
+ }
36
+
37
  export async function POST(request: Request) {
38
  const session = await auth();
39
 
 
67
  const storage = getStorageProvider();
68
  const { url } = await storage.getSignedDownloadUrl({ key: data.key });
69
 
70
+ const driver = process.env.STORAGE_DRIVER ?? "s3";
71
+ const downloadUrl =
72
+ driver === "local"
73
+ ? buildLocalSignedDownloadUrl(url, data.key)
74
+ : url;
75
+
76
+ return NextResponse.json({ downloadUrl });
77
  } catch (error) {
78
  if (error instanceof z.ZodError) {
79
  const message = error.issues[0]?.message ?? "Dados inválidos.";