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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.";
|