diff --git a/src/core/classes/uploads/UploadBuilder.ts b/src/core/classes/uploads/UploadBuilder.ts index d7159d7..64d6a37 100644 --- a/src/core/classes/uploads/UploadBuilder.ts +++ b/src/core/classes/uploads/UploadBuilder.ts @@ -1,4 +1,9 @@ -import type { PinataConfig, PinataMetadata, UploadOptions } from "../../types"; +import type { + PinataConfig, + PinataMetadata, + UploadOptions, + CidVersion, +} from "../../types"; export class UploadBuilder { private config: PinataConfig | undefined; @@ -15,6 +20,7 @@ export class UploadBuilder { private isStreamable: boolean | undefined; private peerAddresses: string[] | undefined; private carFormat: boolean | undefined; + private _cidVersion: CidVersion | undefined; constructor( config: PinataConfig | undefined, @@ -61,10 +67,10 @@ export class UploadBuilder { return this; } - // cidVersion(v: 0 | 1): UploadBuilder { - // this.version = v; - // return this; - // } + cidVersion(v: CidVersion): UploadBuilder { + this._cidVersion = v; + return this; + } group(groupId: string): UploadBuilder { this.groupId = groupId; @@ -121,6 +127,9 @@ export class UploadBuilder { if (this.carFormat) { options.car = this.carFormat; } + if (this._cidVersion !== undefined) { + options.cid_version = this._cidVersion; + } this.args[this.args.length - 1] = options; return this.uploadFunction(this.config, ...this.args).then( onfulfilled, diff --git a/src/core/functions/uploads/file.ts b/src/core/functions/uploads/file.ts index fbca94c..e7fb40d 100644 --- a/src/core/functions/uploads/file.ts +++ b/src/core/functions/uploads/file.ts @@ -59,13 +59,25 @@ export const uploadFile = async ( metadata += `,car ${btoa("true")}`; } - let updatedEndpoint: string = `${endpoint}/files`; + if (options?.cid_version !== undefined) { + metadata += `,cid_version ${btoa(options.cid_version)}`; + } + // Build URL with query parameters for chunked uploads + let updatedEndpoint: string = `${endpoint}/files`; if (options?.url) { updatedEndpoint = options.url; } - const urlReq = await fetch(updatedEndpoint, { + const requestUrl = new URL(updatedEndpoint); + if (options?.cid_version !== undefined) { + requestUrl.searchParams.set( + "X-Upload-Option-Cid-Version", + options.cid_version.toString(), + ); + } + + const urlReq = await fetch(requestUrl.toString(), { method: "POST", headers: { "Upload-Length": `${file.size}`, @@ -98,7 +110,7 @@ export const uploadFile = async ( while (retryCount <= maxRetries) { try { - uploadReq = await fetch(url as string, { + uploadReq = await fetch(url!, { method: "PATCH", headers: { "Content-Type": "application/offset+octet-stream", @@ -248,9 +260,15 @@ export const uploadFile = async ( data.append("car", "true"); } + if (options?.cid_version !== undefined) { + data.append("cid_version", options.cid_version.toString()); + } + if (options?.url) { try { - const request = await fetch(options.url, { + const url = new URL(options.url); + + const request = await fetch(url.toString(), { method: "POST", headers: headers, body: data, diff --git a/src/core/types/uploads.ts b/src/core/types/uploads.ts index 6bdc2ee..100159d 100644 --- a/src/core/types/uploads.ts +++ b/src/core/types/uploads.ts @@ -1,5 +1,7 @@ import { PinataMetadata } from "./"; +export type CidVersion = "v0" | "v1"; + export type UploadResponse = { id: string; name: string; @@ -26,6 +28,10 @@ export type UploadOptions = { streamable?: boolean; peerAddresses?: string[]; car?: boolean; + /** + * CID version "v1" or "v0" (defaults to v1 if falsy) + */ + cid_version?: CidVersion; }; export type SignedUploadUrlOptions = { @@ -39,6 +45,10 @@ export type SignedUploadUrlOptions = { mimeTypes?: string[]; streamable?: boolean; car?: boolean; + /** + * CID version "v1" or "v0" (defaults to v1 if falsy) + */ + cid_version?: CidVersion; }; export type UploadCIDOptions = { diff --git a/tests/uploads/file.test.ts b/tests/uploads/file.test.ts index 925bd1c..b69b0a3 100644 --- a/tests/uploads/file.test.ts +++ b/tests/uploads/file.test.ts @@ -4,6 +4,7 @@ import type { UploadOptions, UploadResponse, PinataMetadata, + CidVersion, } from "../../src"; import { PinataError, @@ -203,4 +204,178 @@ describe("uploadFile function", () => { }), ); }); + + it("should upload file with CID version 0", async () => { + global.fetch = jest.fn().mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce({ data: mockResponse }), + }); + + const mockOptions: UploadOptions = { + cid_version: "v0" as CidVersion, + }; + + await uploadFile(mockConfig, mockFile, "public", mockOptions); + + const fetchCall = (global.fetch as jest.Mock).mock.calls[0]; + const formData = fetchCall[1].body as FormData; + + expect(formData.get("cid_version")).toBe("v0"); + expect(global.fetch).toHaveBeenCalledWith( + "https://uploads.pinata.cloud/v3/files", + expect.objectContaining({ + method: "POST", + headers: { + Source: "sdk/file", + Authorization: "Bearer test-jwt", + }, + body: expect.any(FormData), + }), + ); + }); + + it("should upload file with CID version 1", async () => { + global.fetch = jest.fn().mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce({ data: mockResponse }), + }); + + const mockOptions: UploadOptions = { + cid_version: "v1" as CidVersion, + }; + + await uploadFile(mockConfig, mockFile, "public", mockOptions); + + const fetchCall = (global.fetch as jest.Mock).mock.calls[0]; + const formData = fetchCall[1].body as FormData; + + expect(formData.get("cid_version")).toBe("v1"); + expect(global.fetch).toHaveBeenCalledWith( + "https://uploads.pinata.cloud/v3/files", + expect.objectContaining({ + method: "POST", + headers: { + Source: "sdk/file", + Authorization: "Bearer test-jwt", + }, + body: expect.any(FormData), + }), + ); + }); + + it("should upload same file with both CID versions", async () => { + const testFile = new File(["test content for cid versions"], "test.txt", { + type: "text/plain", + }); + + // Mock for CID version 0 + global.fetch = jest + .fn() + .mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce({ + data: { ...mockResponse, cid: "QmTestVersion0" }, + }), + }) + .mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce({ + data: { + ...mockResponse, + cid: "bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi", + }, + }), + }); + + // Upload with CID version 0 + const result0 = await uploadFile(mockConfig, testFile, "public", { + cid_version: "v0" as CidVersion, + }); + + // Upload with CID version 1 + const result1 = await uploadFile(mockConfig, testFile, "public", { + cid_version: "v1" as CidVersion, + }); + + // Verify both uploads + expect(result0.cid).toBe("QmTestVersion0"); + expect(result1.cid).toBe( + "bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi", + ); + + // Verify fetch was called twice + expect(global.fetch).toHaveBeenCalledTimes(2); + + // Check CID version 0 FormData + const fetchCall0 = (global.fetch as jest.Mock).mock.calls[0]; + const formData0 = fetchCall0[1].body as FormData; + expect(formData0.get("cid_version")).toBe("v0"); + + // Check CID version 1 FormData + const fetchCall1 = (global.fetch as jest.Mock).mock.calls[1]; + const formData1 = fetchCall1[1].body as FormData; + expect(formData1.get("cid_version")).toBe("v1"); + }); + + it("should upload file with CID version combined with other options", async () => { + global.fetch = jest.fn().mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce({ data: mockResponse }), + }); + + const mockOptions: UploadOptions = { + cid_version: "v1" as CidVersion, + car: true, + groupId: "test-group", + streamable: true, + }; + + await uploadFile(mockConfig, mockFile, "public", mockOptions); + + const fetchCall = (global.fetch as jest.Mock).mock.calls[0]; + const formData = fetchCall[1].body as FormData; + + expect(formData.get("cid_version")).toBe("v1"); + expect(formData.get("car")).toBe("true"); + expect(formData.get("group_id")).toBe("test-group"); + expect(formData.get("streamable")).toBe("true"); + }); + + it("should include CID version in metadata for large file upload", async () => { + // Create a large file (over 90MB) to trigger chunked upload metadata path + const largeFileSize = 94371841; // Just over the threshold + const largeFile = new File( + [new ArrayBuffer(largeFileSize)], + "large-test.txt", + { + type: "text/plain", + }, + ); + + // Mock the first call (initial upload request) to verify metadata + global.fetch = jest.fn().mockImplementationOnce(() => { + // We're just testing that the request is made with correct metadata + // Return a promise that we can inspect the call and then reject to avoid complex mocking + return Promise.reject(new Error("Test stopped after metadata check")); + }); + + const mockOptions: UploadOptions = { + cid_version: "v1" as CidVersion, + }; + + try { + await uploadFile(mockConfig, largeFile, "private", mockOptions); + } catch (error) { + // Expected to fail, we just want to check the metadata + } + + // Check that the initial request contains cid_version in Upload-Metadata + expect(global.fetch).toHaveBeenCalledTimes(1); + const initialCall = (global.fetch as jest.Mock).mock.calls[0]; + const uploadMetadata = initialCall[1].headers["Upload-Metadata"]; + + // The metadata should contain base64 encoded cid_version + expect(uploadMetadata).toContain("cid_version"); + expect(uploadMetadata).toContain(btoa("v1")); + }); });