diff --git a/app/api/files/route.ts b/app/api/files/route.ts new file mode 100644 index 00000000..61f4c15c --- /dev/null +++ b/app/api/files/route.ts @@ -0,0 +1,32 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getFilesHandler } from "@/lib/files/getFilesHandler"; + +/** + * OPTIONS handler for CORS preflight requests. + * + * @returns A NextResponse with CORS headers. + */ +export async function OPTIONS() { + return new NextResponse(null, { + status: 200, + headers: getCorsHeaders(), + }); +} + +/** + * GET /api/files + * + * Lists files for an accessible artist account. Returns the matching file rows + * along with the owner's primary email address when one exists. + * + * @param request - The request object containing query parameters. + * @returns A NextResponse with file rows or an error response. + */ +export async function GET(request: NextRequest) { + return getFilesHandler(request); +} + +export const dynamic = "force-dynamic"; +export const fetchCache = "force-no-store"; +export const revalidate = 0; diff --git a/lib/files/__tests__/getFilesHandler.test.ts b/lib/files/__tests__/getFilesHandler.test.ts new file mode 100644 index 00000000..6165bc4a --- /dev/null +++ b/lib/files/__tests__/getFilesHandler.test.ts @@ -0,0 +1,110 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { NextResponse, type NextRequest } from "next/server"; +import { getFilesHandler } from "../getFilesHandler"; +import { validateGetFilesQuery } from "../validateGetFilesQuery"; +import { listFilesByArtist } from "../listFilesByArtist"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), +})); + +vi.mock("../validateGetFilesQuery", () => ({ + validateGetFilesQuery: vi.fn(), +})); + +vi.mock("../listFilesByArtist", () => ({ + listFilesByArtist: vi.fn(), +})); + +/** + * Creates a mock request for GET /api/files tests. + * + * @returns A mocked NextRequest instance. + */ +function createRequest(): NextRequest { + return { + url: "http://localhost/api/files", + headers: new Headers({ authorization: "Bearer token" }), + } as unknown as NextRequest; +} + +const baseFile = { + id: "550e8400-e29b-41d4-a716-446655440010", + owner_account_id: "550e8400-e29b-41d4-a716-446655440100", + artist_account_id: "550e8400-e29b-41d4-a716-446655440000", + file_name: "report.md", + storage_key: + "files/550e8400-e29b-41d4-a716-446655440100/550e8400-e29b-41d4-a716-446655440000/report.md", + mime_type: "text/markdown", + is_directory: false, + size_bytes: 123, + description: null, + tags: null, + created_at: "2026-04-09T00:00:00.000Z", + updated_at: "2026-04-09T00:00:00.000Z", +}; + +describe("getFilesHandler", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns validation response errors directly", async () => { + vi.mocked(validateGetFilesQuery).mockResolvedValue( + NextResponse.json({ status: "error", error: "Unauthorized" }, { status: 401 }), + ); + + const result = await getFilesHandler(createRequest()); + + expect(result.status).toBe(401); + }); + + it("returns enriched files", async () => { + vi.mocked(validateGetFilesQuery).mockResolvedValue({ + artist_account_id: "550e8400-e29b-41d4-a716-446655440000", + recursive: false, + }); + vi.mocked(listFilesByArtist).mockResolvedValue([baseFile]); + + const result = await getFilesHandler(createRequest()); + const body = await result.json(); + + expect(listFilesByArtist).toHaveBeenCalledWith( + "550e8400-e29b-41d4-a716-446655440000", + undefined, + false, + ); + expect(result.status).toBe(200); + expect(body).toEqual({ + files: [baseFile], + }); + }); + + it("returns an empty files array when no files match", async () => { + vi.mocked(validateGetFilesQuery).mockResolvedValue({ + artist_account_id: "550e8400-e29b-41d4-a716-446655440000", + recursive: false, + }); + vi.mocked(listFilesByArtist).mockResolvedValue([]); + + const result = await getFilesHandler(createRequest()); + const body = await result.json(); + + expect(body).toEqual({ files: [] }); + }); + + it("returns 500 when file lookup throws", async () => { + vi.mocked(validateGetFilesQuery).mockResolvedValue({ + artist_account_id: "550e8400-e29b-41d4-a716-446655440000", + recursive: false, + }); + vi.mocked(listFilesByArtist).mockRejectedValue(new Error("db blew up")); + + const result = await getFilesHandler(createRequest()); + + expect(result.status).toBe(500); + await expect(result.json()).resolves.toMatchObject({ + error: "db blew up", + }); + }); +}); diff --git a/lib/files/__tests__/validateGetFilesQuery.test.ts b/lib/files/__tests__/validateGetFilesQuery.test.ts new file mode 100644 index 00000000..ea5f4621 --- /dev/null +++ b/lib/files/__tests__/validateGetFilesQuery.test.ts @@ -0,0 +1,161 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextResponse, type NextRequest } from "next/server"; +import { validateGetFilesQuery } from "../validateGetFilesQuery"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { checkAccountArtistAccess } from "@/lib/artists/checkAccountArtistAccess"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), +})); + +vi.mock("@/lib/auth/validateAuthContext", () => ({ + validateAuthContext: vi.fn(), +})); + +vi.mock("@/lib/artists/checkAccountArtistAccess", () => ({ + checkAccountArtistAccess: vi.fn(), +})); + +/** + * Creates a mock NextRequest for GET /api/files validation tests. + * + * @param url - Request URL. + * @returns A mocked NextRequest instance. + */ +function createRequest(url: string): NextRequest { + return { + url, + headers: new Headers({ authorization: "Bearer token" }), + } as unknown as NextRequest; +} + +describe("validateGetFilesQuery", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns auth errors directly", async () => { + vi.mocked(validateAuthContext).mockResolvedValue( + NextResponse.json({ status: "error", error: "Unauthorized" }, { status: 401 }), + ); + + const result = await validateGetFilesQuery(createRequest("http://localhost/api/files")); + + expect(result).toBeInstanceOf(NextResponse); + expect((result as NextResponse).status).toBe(401); + }); + + it("returns 400 when artist_account_id is missing", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "account-123", + authToken: "token", + orgId: null, + }); + + const result = await validateGetFilesQuery(createRequest("http://localhost/api/files")); + + expect((result as NextResponse).status).toBe(400); + await expect((result as NextResponse).json()).resolves.toMatchObject({ + error: "Invalid input: expected string, received undefined", + }); + }); + + it("returns 400 when artist_account_id is invalid", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "account-123", + authToken: "token", + orgId: null, + }); + + const result = await validateGetFilesQuery( + createRequest("http://localhost/api/files?artist_account_id=not-a-uuid"), + ); + + expect((result as NextResponse).status).toBe(400); + await expect((result as NextResponse).json()).resolves.toMatchObject({ + error: "artist_account_id must be a valid UUID", + }); + }); + + it("returns 403 when requester cannot access the artist", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "account-123", + authToken: "token", + orgId: null, + }); + vi.mocked(checkAccountArtistAccess).mockResolvedValue(false); + + const result = await validateGetFilesQuery( + createRequest( + "http://localhost/api/files?artist_account_id=550e8400-e29b-41d4-a716-446655440000", + ), + ); + + expect(checkAccountArtistAccess).toHaveBeenCalledWith( + "account-123", + "550e8400-e29b-41d4-a716-446655440000", + ); + expect((result as NextResponse).status).toBe(403); + }); + + it("returns validated params for direct artist access", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "account-123", + authToken: "token", + orgId: null, + }); + vi.mocked(checkAccountArtistAccess).mockResolvedValue(true); + + const result = await validateGetFilesQuery( + createRequest( + "http://localhost/api/files?artist_account_id=550e8400-e29b-41d4-a716-446655440000&path=reports&recursive=true", + ), + ); + + expect(result).toEqual({ + artist_account_id: "550e8400-e29b-41d4-a716-446655440000", + path: "reports", + recursive: true, + }); + }); + + it("parses recursive=false as false", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "account-123", + authToken: "token", + orgId: null, + }); + vi.mocked(checkAccountArtistAccess).mockResolvedValue(true); + + const result = await validateGetFilesQuery( + createRequest( + "http://localhost/api/files?artist_account_id=550e8400-e29b-41d4-a716-446655440000&recursive=false", + ), + ); + + expect(result).toEqual({ + artist_account_id: "550e8400-e29b-41d4-a716-446655440000", + recursive: false, + }); + }); + + it("returns validated params for shared-org artist access", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "account-456", + authToken: "token", + orgId: null, + }); + vi.mocked(checkAccountArtistAccess).mockResolvedValue(true); + + const result = await validateGetFilesQuery( + createRequest( + "http://localhost/api/files?artist_account_id=550e8400-e29b-41d4-a716-446655440001", + ), + ); + + expect(result).toEqual({ + artist_account_id: "550e8400-e29b-41d4-a716-446655440001", + recursive: false, + }); + }); +}); diff --git a/lib/files/constants.ts b/lib/files/constants.ts new file mode 100644 index 00000000..62cadc43 --- /dev/null +++ b/lib/files/constants.ts @@ -0,0 +1 @@ +export const STORAGE_KEY_RELATIVE_PATH_REGEX = /^files\/[^/]+\/[^/]+\/(.+)$/; diff --git a/lib/files/filterFilesByPath.ts b/lib/files/filterFilesByPath.ts new file mode 100644 index 00000000..104483f3 --- /dev/null +++ b/lib/files/filterFilesByPath.ts @@ -0,0 +1,36 @@ +import type { Tables } from "@/types/database.types"; +import { STORAGE_KEY_RELATIVE_PATH_REGEX } from "@/lib/files/constants"; + +type FileRecord = Tables<"files">; + +/** + * Filters file rows to immediate children of the provided relative path. + * + * @param files - File rows to filter. + * @param path - Optional relative directory path. + * @returns The filtered file rows. + */ +export function filterFilesByPath(files: FileRecord[], path?: string): FileRecord[] { + return files.filter(file => { + const match = file.storage_key.match(STORAGE_KEY_RELATIVE_PATH_REGEX); + if (!match) return false; + + const relativePath = match[1]; + + if (path) { + const pathPrefix = path.endsWith("/") ? path : `${path}/`; + if (!relativePath.startsWith(pathPrefix)) return false; + + const relativeToFilter = relativePath.slice(pathPrefix.length); + const trimmed = relativeToFilter.endsWith("/") + ? relativeToFilter.slice(0, -1) + : relativeToFilter; + + return trimmed.length > 0 && !trimmed.includes("/"); + } + + const trimmed = relativePath.endsWith("/") ? relativePath.slice(0, -1) : relativePath; + + return trimmed.length > 0 && !trimmed.includes("/"); + }); +} diff --git a/lib/files/getFilesHandler.ts b/lib/files/getFilesHandler.ts new file mode 100644 index 00000000..80881a11 --- /dev/null +++ b/lib/files/getFilesHandler.ts @@ -0,0 +1,45 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateGetFilesQuery } from "@/lib/files/validateGetFilesQuery"; +import { listFilesByArtist } from "@/lib/files/listFilesByArtist"; + +/** + * Handles GET /api/files requests. + * + * @param request - The incoming request. + * @returns A NextResponse with files or an error. + */ +export async function getFilesHandler(request: NextRequest): Promise { + try { + const validatedQuery = await validateGetFilesQuery(request); + if (validatedQuery instanceof NextResponse) { + return validatedQuery; + } + + const files = await listFilesByArtist( + validatedQuery.artist_account_id, + validatedQuery.path, + validatedQuery.recursive, + ); + + return NextResponse.json( + { files }, + { + status: 200, + headers: getCorsHeaders(), + }, + ); + } catch (error) { + return NextResponse.json( + { + status: "error", + error: error instanceof Error ? error.message : "Internal server error", + }, + { + status: 500, + headers: getCorsHeaders(), + }, + ); + } +} diff --git a/lib/files/listFilesByArtist.ts b/lib/files/listFilesByArtist.ts new file mode 100644 index 00000000..1afefb1d --- /dev/null +++ b/lib/files/listFilesByArtist.ts @@ -0,0 +1,37 @@ +import type { Tables } from "@/types/database.types"; +import { STORAGE_KEY_RELATIVE_PATH_REGEX } from "@/lib/files/constants"; +import { filterFilesByPath } from "@/lib/files/filterFilesByPath"; +import { getFilesByArtistId } from "@/lib/supabase/files/getFilesByArtistId"; + +type FileRecord = Tables<"files">; + +/** + * Lists files for an artist, optionally filtered by path. + * + * @param artistAccountId - Artist account whose files should be listed. + * @param path - Optional relative directory path. + * @param recursive - Whether to include descendant files recursively. + * @returns Matching file rows. + */ +export async function listFilesByArtist( + artistAccountId: string, + path?: string, + recursive: boolean = false, +): Promise { + const allFiles = await getFilesByArtistId(artistAccountId); + + if (recursive) { + if (path) { + const pathPrefix = path.endsWith("/") ? path : `${path}/`; + return allFiles.filter(file => { + const match = file.storage_key.match(STORAGE_KEY_RELATIVE_PATH_REGEX); + if (!match) return false; + return match[1] !== pathPrefix && match[1].startsWith(pathPrefix); + }); + } + + return allFiles; + } + + return filterFilesByPath(allFiles, path); +} diff --git a/lib/files/validateGetFilesQuery.ts b/lib/files/validateGetFilesQuery.ts new file mode 100644 index 00000000..be9360fb --- /dev/null +++ b/lib/files/validateGetFilesQuery.ts @@ -0,0 +1,69 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { checkAccountArtistAccess } from "@/lib/artists/checkAccountArtistAccess"; +import { booleanFromString } from "@/lib/content/booleanFromString"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; + +export const getFilesQuerySchema = z.object({ + artist_account_id: z.string().uuid("artist_account_id must be a valid UUID"), + path: z.string().optional(), + recursive: booleanFromString.optional().default(false), +}); + +export type ValidatedGetFilesQuery = z.infer; + +/** + * Validates GET /api/files query parameters and artist access. + * + * @param request - The incoming request. + * @returns A validation error response or the validated query. + */ +export async function validateGetFilesQuery( + request: NextRequest, +): Promise { + const authResult = await validateAuthContext(request); + if (authResult instanceof NextResponse) { + return authResult; + } + + const { searchParams } = new URL(request.url); + const validationResult = getFilesQuerySchema.safeParse( + Object.fromEntries(searchParams.entries()), + ); + + if (!validationResult.success) { + const firstError = validationResult.error.issues[0]; + return NextResponse.json( + { + status: "error", + missing_fields: firstError.path, + error: firstError.message, + }, + { + status: 400, + headers: getCorsHeaders(), + }, + ); + } + + const hasArtistAccess = await checkAccountArtistAccess( + authResult.accountId, + validationResult.data.artist_account_id, + ); + + if (!hasArtistAccess) { + return NextResponse.json( + { + status: "error", + error: "Access denied to specified artist_account_id", + }, + { + status: 403, + headers: getCorsHeaders(), + }, + ); + } + + return validationResult.data; +} diff --git a/lib/supabase/files/getFilesByArtistId.ts b/lib/supabase/files/getFilesByArtistId.ts new file mode 100644 index 00000000..c5cf9dc6 --- /dev/null +++ b/lib/supabase/files/getFilesByArtistId.ts @@ -0,0 +1,24 @@ +import supabase from "@/lib/supabase/serverClient"; +import type { Tables } from "@/types/database.types"; + +type FileRecord = Tables<"files">; + +/** + * Fetches all file rows for an artist, newest first. + * + * @param artistAccountId - Artist account whose files should be fetched. + * @returns File rows ordered by creation time descending. + */ +export async function getFilesByArtistId(artistAccountId: string): Promise { + const { data, error } = await supabase + .from("files") + .select("*") + .eq("artist_account_id", artistAccountId) + .order("created_at", { ascending: false }); + + if (error) { + throw new Error(`Failed to get files by artist: ${error.message}`); + } + + return data || []; +}