Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "pinata",
"version": "2.5.5",
"version": "2.5.6",
"description": "The official Pinata SDK",
"main": "./dist/index.js",
"module": "./dist/index.mjs",
Expand Down
8 changes: 6 additions & 2 deletions src/core/functions/uploads/createSignedUploadURL.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,12 @@ export const createSignedUploadURL = async (
payload.max_file_size = options.maxFileSize;
}

if (options.mimeTypes) {
payload.allow_mime_types = options.mimeTypes;
if (options.mimeTypes || options.forDirectory) {
const types = new Set(options.mimeTypes ?? []);
if (options.forDirectory) {
types.add("directory");
}
payload.allow_mime_types = Array.from(types);
}

let endpoint: string = "https://uploads.pinata.cloud/v3";
Expand Down
111 changes: 98 additions & 13 deletions src/core/functions/uploads/fileArray.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,104 @@ export const uploadFileArray = async (

const folder = options?.metadata?.name || "folder_from_sdk";

let headers: Record<string, string>;

if (config.customHeaders && Object.keys(config.customHeaders).length > 0) {
headers = {
Authorization: `Bearer ${jwt}`,
...config.customHeaders,
};
} else {
headers = {
Authorization: `Bearer ${jwt}`,
Source: "sdk/fileArray",
};
}

if (options?.url) {
const data = new FormData();

for (const file of Array.from(files)) {
const path = file.webkitRelativePath || `${folder}/${file.name}`;
data.append("file", file, path);
}

data.append("network", network);
data.append("name", folder);

if (options.groupId) {
data.append("group_id", options.groupId);
}

if (options.metadata?.keyvalues) {
data.append("keyvalues", JSON.stringify(options.metadata.keyvalues));
}

if (options.streamable) {
data.append("streamable", "true");
}

if (options.car) {
data.append("car", "true");
}

if (options.cid_version !== undefined) {
data.append("cid_version", options.cid_version.toString());
}

if (options.expires_at !== undefined) {
data.append("expires_at", options.expires_at.toString());
}

try {
const url = new URL(options.url);

const request = await fetch(url.toString(), {
method: "POST",
headers: headers,
body: data,
});

if (!request.ok) {
const errorData = await request.text();
if (request.status === 401 || request.status === 403) {
throw new AuthenticationError(
`Authentication failed: ${errorData}`,
request.status,
{
error: errorData,
code: "AUTH_ERROR",
metadata: {
requestUrl: request.url,
},
},
);
}
throw new NetworkError(`HTTP error: ${errorData}`, request.status, {
error: errorData,
code: "HTTP_ERROR",
metadata: {
requestUrl: request.url,
},
});
}

const res = await request.json();
const resData: UploadResponse = res.data;
return resData;
} catch (error) {
if (error instanceof PinataError) {
throw error;
}
if (error instanceof Error) {
throw new PinataError(`Error processing fileArray: ${error.message}`);
}
throw new PinataError(
"An unknown error occurred while uploading an array of files",
);
}
}

const data = new FormData();

for (const file of Array.from(files)) {
Expand Down Expand Up @@ -58,19 +156,6 @@ export const uploadFileArray = async (
}),
);

let headers: Record<string, string>;

if (config.customHeaders && Object.keys(config.customHeaders).length > 0) {
headers = {
Authorization: `Bearer ${jwt}`,
...config.customHeaders,
};
} else {
headers = {
Authorization: `Bearer ${jwt}`,
Source: "sdk/fileArray",
};
}
// Reserved for later release
//let endpoint: string = "https://uploads.pinata.cloud/v3";
let endpoint: string = "https://api.pinata.cloud/pinning/pinFileToIPFS";
Expand Down
21 changes: 21 additions & 0 deletions src/core/types/uploads.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,28 @@ export type SignedUploadUrlOptions = {
keyvalues?: Record<string, string>;
vectorize?: boolean;
maxFileSize?: number;
/**
* Restrict the signed URL to specific MIME types.
*
* NOTE: For directory uploads (e.g. `pinata.upload.public.fileArray(...).url(...)`),
* this array MUST include the synthetic `"directory"` MIME type — otherwise the
* upload will be rejected with `400 "Presigned URL does not grant permissions to
* upload detected MIME type: directory"`. Aliases like `application/x-directory` or
* `inode/directory` are NOT accepted, and a wildcard (`["*"]` / `["*\/*"]`) does not
* cover it.
*
* Example: `mimeTypes: ["directory"]` (or `["directory", "text/plain"]` to also
* allow plain-text files inside the directory).
*/
mimeTypes?: string[];
/**
* Mint this signed URL for directory uploads (e.g. `fileArray(...).url(...)`).
*
* Adds `"directory"` to `mimeTypes` so the API permits the synthetic directory
* MIME type. Any other entries in `mimeTypes` are preserved, so you can both
* allow a directory and constrain the file types inside it.
*/
forDirectory?: boolean;
streamable?: boolean;
car?: boolean;
/**
Expand Down
216 changes: 216 additions & 0 deletions tests/uploads/createSignedUploadURL.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
import { createSignedUploadURL } from "../../src/core/functions";
import type { PinataConfig, SignedUploadUrlOptions } from "../../src";
import {
NetworkError,
AuthenticationError,
ValidationError,
} from "../../src/utils/custom-errors";

describe("createSignedUploadURL function", () => {
let originalFetch: typeof fetch;

beforeEach(() => {
originalFetch = global.fetch;
});

afterEach(() => {
global.fetch = originalFetch;
jest.clearAllMocks();
});

const mockConfig: PinataConfig = {
pinataJwt: "test-jwt",
pinataGateway: "https://test.mypinata.cloud",
};

const signedUrl =
"https://uploads.pinata.cloud/v3/files/00000000-0000-0000-0000-000000000000?X-Algorithm=PINATA1&X-Signature=abc";

const getJsonBody = () => {
const fetchCall = (global.fetch as jest.Mock).mock.calls[0];
return JSON.parse(fetchCall[1].body as string);
};

it("should throw ValidationError if config is missing", async () => {
await expect(
createSignedUploadURL(undefined, { expires: 60 }, "public"),
).rejects.toThrow(ValidationError);
});

it("should POST to /files/sign with the basic payload and return data", async () => {
global.fetch = jest.fn().mockResolvedValueOnce({
ok: true,
json: jest.fn().mockResolvedValueOnce({ data: signedUrl }),
});

const url = await createSignedUploadURL(
mockConfig,
{ expires: 60 },
"public",
);

expect(url).toBe(signedUrl);
expect(global.fetch).toHaveBeenCalledWith(
"https://uploads.pinata.cloud/v3/files/sign",
expect.objectContaining({
method: "POST",
headers: expect.objectContaining({
Authorization: "Bearer test-jwt",
"Content-Type": "application/json",
}),
}),
);

const body = getJsonBody();
expect(body.expires).toBe(60);
expect(body.network).toBe("public");
});

describe("forDirectory option", () => {
it("should add 'directory' to allow_mime_types when forDirectory=true and no mimeTypes set", async () => {
global.fetch = jest.fn().mockResolvedValueOnce({
ok: true,
json: jest.fn().mockResolvedValueOnce({ data: signedUrl }),
});

const opts: SignedUploadUrlOptions = {
expires: 60,
forDirectory: true,
};
await createSignedUploadURL(mockConfig, opts, "public");

const body = getJsonBody();
expect(body.allow_mime_types).toEqual(["directory"]);
});

it("should merge 'directory' with caller-supplied mimeTypes", async () => {
global.fetch = jest.fn().mockResolvedValueOnce({
ok: true,
json: jest.fn().mockResolvedValueOnce({ data: signedUrl }),
});

const opts: SignedUploadUrlOptions = {
expires: 60,
forDirectory: true,
mimeTypes: ["text/plain", "image/png"],
};
await createSignedUploadURL(mockConfig, opts, "public");

const body = getJsonBody();
expect(body.allow_mime_types).toEqual(
expect.arrayContaining(["directory", "text/plain", "image/png"]),
);
expect(body.allow_mime_types).toHaveLength(3);
});

it("should not duplicate 'directory' if already in mimeTypes", async () => {
global.fetch = jest.fn().mockResolvedValueOnce({
ok: true,
json: jest.fn().mockResolvedValueOnce({ data: signedUrl }),
});

const opts: SignedUploadUrlOptions = {
expires: 60,
forDirectory: true,
mimeTypes: ["directory", "text/plain"],
};
await createSignedUploadURL(mockConfig, opts, "public");

const body = getJsonBody();
expect(body.allow_mime_types).toEqual(["directory", "text/plain"]);
});

it("should not set allow_mime_types when forDirectory is false/absent and no mimeTypes given", async () => {
global.fetch = jest.fn().mockResolvedValueOnce({
ok: true,
json: jest.fn().mockResolvedValueOnce({ data: signedUrl }),
});

await createSignedUploadURL(mockConfig, { expires: 60 }, "public");

const body = getJsonBody();
expect(body.allow_mime_types).toBeUndefined();
});

it("should pass mimeTypes through unchanged when forDirectory is not set", async () => {
global.fetch = jest.fn().mockResolvedValueOnce({
ok: true,
json: jest.fn().mockResolvedValueOnce({ data: signedUrl }),
});

await createSignedUploadURL(
mockConfig,
{ expires: 60, mimeTypes: ["application/json"] },
"public",
);

const body = getJsonBody();
expect(body.allow_mime_types).toEqual(["application/json"]);
});
});

it("should propagate other options to the request body", async () => {
global.fetch = jest.fn().mockResolvedValueOnce({
ok: true,
json: jest.fn().mockResolvedValueOnce({ data: signedUrl }),
});

await createSignedUploadURL(
mockConfig,
{
expires: 120,
groupId: "group-1",
name: "myfile.txt",
keyvalues: { env: "prod" },
maxFileSize: 1024,
streamable: true,
date: 1700000000,
},
"private",
);

const body = getJsonBody();
expect(body).toMatchObject({
expires: 120,
group_id: "group-1",
filename: "myfile.txt",
keyvalues: { env: "prod" },
max_file_size: 1024,
streamable: true,
date: 1700000000,
network: "private",
});
});

it("should throw AuthenticationError on 401 without retrying", async () => {
const fetchMock = jest.fn().mockResolvedValueOnce({
ok: false,
status: 401,
url: "https://uploads.pinata.cloud/v3/files/sign",
text: jest.fn().mockResolvedValueOnce("Unauthorized"),
});
global.fetch = fetchMock;

await expect(
createSignedUploadURL(mockConfig, { expires: 60 }, "public"),
).rejects.toThrow(AuthenticationError);

expect(fetchMock).toHaveBeenCalledTimes(1);
});

it("should throw NetworkError on 4xx (non-429) without retrying", async () => {
const fetchMock = jest.fn().mockResolvedValueOnce({
ok: false,
status: 400,
url: "https://uploads.pinata.cloud/v3/files/sign",
text: jest.fn().mockResolvedValueOnce("Bad Request"),
});
global.fetch = fetchMock;

await expect(
createSignedUploadURL(mockConfig, { expires: 60 }, "public"),
).rejects.toThrow(NetworkError);

expect(fetchMock).toHaveBeenCalledTimes(1);
});
});
Loading
Loading