Skip to content
This repository has been archived by the owner on Apr 16, 2021. It is now read-only.

Commit

Permalink
Support downloading skylinks with paths and query params
Browse files Browse the repository at this point in the history
  • Loading branch information
mrcnski committed Sep 9, 2020
1 parent 014b3a5 commit 681e633
Show file tree
Hide file tree
Showing 6 changed files with 162 additions and 67 deletions.
2 changes: 2 additions & 0 deletions src/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ export class SkynetClient {

config.onUploadProgress(progress, { loaded, total });
},
maxContentLength: Infinity,
maxBodyLength: Infinity,
});
}
}
34 changes: 27 additions & 7 deletions src/download.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,34 +28,50 @@ const defaultResolveHnsOptions = {
* @param {string} skylink - 46 character skylink.
* @param {Object} [customOptions={}] - Additional settings that can optionally be set.
* @param {string} [customOptions.endpointPath="/"] - The relative URL path of the portal endpoint to contact.
* @param {string} [customOptions.path] - An array of path elements to append to the skylink. Each element will be URI-encoded (e.g. "?" -> "%3F") so make sure it is not already encoded. Then the elements are joined to full the full path, e.g. `dir1/dir2/file`.
* @returns {string} - The full URL that was used.
*/
SkynetClient.prototype.downloadFile = function (skylink, customOptions = {}) {
const opts = { ...defaultDownloadOptions, ...this.customOptions, ...customOptions, download: true };
const url = this.getSkylinkUrl(skylink, opts);

// Download the url.
window.location = url;

return url;
};

/**
* Initiates a download of the content of the skylink at the Handshake domain.
* @param {string} domain - Handshake domain.
* @param {Object} [customOptions={}] - Additional settings that can optionally be set.
* @param {string} [customOptions.endpointPath="/hns"] - The relative URL path of the portal endpoint to contact.
* @returns {string} - The full URL that was used.
*/
SkynetClient.prototype.downloadFileHns = async function (domain, customOptions = {}) {
SkynetClient.prototype.downloadFileHns = function (domain, customOptions = {}) {
const opts = { ...defaultDownloadHnsOptions, ...this.customOptions, ...customOptions, download: true };
const url = this.getHnsUrl(domain, opts);

// Download the url.
window.location = url;

return url;
};

SkynetClient.prototype.getSkylinkUrl = function (skylink, customOptions = {}) {
const opts = { ...defaultDownloadOptions, ...this.customOptions, ...customOptions };
const query = opts.download ? { attachment: true } : {};

const url = makeUrl(this.portalUrl, opts.endpointPath, parseSkylink(skylink));
let path = "";
if (opts.path) {
if (!Array.isArray(opts.path)) {
throw new Error(`opts.path has to be an array, ${typeof opts.path} provided`);
}
// Encode each element of the path separately and join them.
path = opts.path.map((element) => encodeURIComponent(element)).join("/");
}

const url = makeUrl(this.portalUrl, opts.endpointPath, parseSkylink(skylink), path);
return addUrlQuery(url, query);
};

Expand Down Expand Up @@ -83,28 +99,32 @@ SkynetClient.prototype.getMetadata = async function (skylink, customOptions = {}
/**
* Opens the content of the skylink within the browser.
* @param {string} skylink - 46 character skylink.
* @param {Object} [customOptions={}] - Additional settings that can optionally be set.
* @param {string} [customOptions.endpointPath="/"] - The relative URL path of the portal endpoint to contact.
* @param {Object} [customOptions={}] - Additional settings that can optionally be set. See `downloadFile` for the full list.
* @returns {string} - The full URL that was used.
*/
SkynetClient.prototype.openFile = function (skylink, customOptions = {}) {
const opts = { ...defaultDownloadOptions, ...this.customOptions, ...customOptions };
const url = this.getSkylinkUrl(skylink, opts);

window.open(url, "_blank");

return url;
};

/**
* Opens the content of the skylink from the given Handshake domain within the browser.
* @param {string} domain - Handshake domain.
* @param {Object} [customOptions={}] - Additional settings that can optionally be set.
* @param {string} [customOptions.endpointPath="/hns"] - The relative URL path of the portal endpoint to contact.
* @param {Object} [customOptions={}] - Additional settings that can optionally be set. See `downloadFileHns` for the full list.
* @returns {string} - The full URL that was used.
*/
SkynetClient.prototype.openFileHns = async function (domain, customOptions = {}) {
SkynetClient.prototype.openFileHns = function (domain, customOptions = {}) {
const opts = { ...defaultDownloadHnsOptions, ...this.customOptions, ...customOptions };
const url = this.getHnsUrl(domain, opts);

// Open the url in a new tab.
window.open(url, "_blank");

return url;
};

/**
Expand Down
123 changes: 77 additions & 46 deletions src/download.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,26 @@ import { SkynetClient, defaultSkynetPortalUrl } from "./index";

const portalUrl = defaultSkynetPortalUrl;
const hnsLink = "foo";
const hnsUrl = `${portalUrl}/hns/${hnsLink}`;
const hnsresUrl = `${portalUrl}/hnsres/${hnsLink}`;
const client = new SkynetClient(portalUrl);
const skylink = "XABvi7JtJbQSMAcDwnUnmp2FKDPjg8_tTTFP4BwMSxVdEg";

const expectedUrl = `${portalUrl}/${skylink}`;
const attachment = "?attachment=true";
const validSkylinkVariations = [
skylink,
`sia:${skylink}`,
`sia://${skylink}`,
`${portalUrl}/${skylink}`,
`${portalUrl}/${skylink}/foo/bar`,
`${portalUrl}/${skylink}?foo=bar`,
[skylink, ""],
[`sia:${skylink}`, ""],
[`sia://${skylink}`, ""],
[`${portalUrl}/${skylink}`, ""],
[`${portalUrl}/${skylink}/foo/bar`, "/foo/bar"],
[`${portalUrl}/${skylink}/foo%3Fbar`, "/foo%3Fbar"],
[`${portalUrl}/${skylink}?foo=bar`, "?foo=bar"],
[`${portalUrl}/${skylink}/?foo=bar`, "?foo=bar"],
[`${portalUrl}/${skylink}#foobar`, "#foobar"],
[`${portalUrl}/${skylink}/#foobar`, "#foobar"],
];

const expectedHnsUrl = `${portalUrl}/hns/${hnsLink}`;
const expectedHnsresUrl = `${portalUrl}/hnsres/${hnsLink}`;
const validHnsLinkVariations = [hnsLink, `hns:${hnsLink}`, `hns://${hnsLink}`];
const validHnsresLinkVariations = [hnsLink, `hnsres:${hnsLink}`, `hnsres://${hnsLink}`];

Expand All @@ -27,91 +35,114 @@ describe("downloadFile", () => {
mock = new MockAdapter(axios);
});

it("should call window.open with a download url with attachment set", () => {
const windowOpen = jest.spyOn(window, "open").mockImplementation();
it("should download with a skylink url with attachment set", () => {
validSkylinkVariations.forEach(([fullSkylink, path]) => {
const url = client.downloadFile(fullSkylink);

validSkylinkVariations.forEach((input) => {
windowOpen.mockReset();
expect(mock.history.get.length).toBe(0);

let expectedUrl2 = `${expectedUrl}${path}${attachment}`;
if (path.startsWith("#")) {
// Hash should come after query.
expectedUrl2 = `${expectedUrl}${attachment}${path}`;
}
// Change ?attachment=true to &attachment=true if need be.
if ((expectedUrl2.match(/\?/g) || []).length > 1) {
expectedUrl2 = expectedUrl2.replace(attachment, "&attachment=true");
}
expect(url).toEqual(expectedUrl2);
});
});

it("should download with the optional path being correctly URI-encoded", () => {
const url = client.downloadFile(skylink, { path: ["test?encoding"] });

expect(url).toEqual(`${expectedUrl}/test%3Fencoding${attachment}`);
});
});

client.downloadFile(input);
describe("downloadFileHns", () => {
let mock;

beforeEach(() => {
mock = new MockAdapter(axios);
});

it("should download with the correct hns link", async () => {
for (const input of validHnsLinkVariations) {
const url = client.downloadFileHns(input);

expect(mock.history.get.length).toBe(0);
});

expect(url).toEqual(`${expectedHnsUrl}${attachment}`);
}
});
});

describe("getHnsUrl", () => {
it("should return correctly formed hns URL", () => {
validHnsLinkVariations.forEach((input) => {
expect(client.getHnsUrl(input)).toEqual(hnsUrl);
expect(client.getHnsUrl(input)).toEqual(expectedHnsUrl);
});
});

it("should return correctly formed hns URL with forced download", () => {
const url = client.getHnsUrl(hnsLink, { download: true });

expect(url).toEqual(`${hnsUrl}?attachment=true`);
expect(url).toEqual(`${expectedHnsUrl}${attachment}`);
});
});

describe("getHnsresUrl", () => {
it("should return correctly formed hnsres URL", () => {
validHnsresLinkVariations.forEach((input) => {
expect(client.getHnsresUrl(input)).toEqual(hnsresUrl);
expect(client.getHnsresUrl(input)).toEqual(expectedHnsresUrl);
});
});
});

describe("getSkylinkUrl", () => {
const expectedUrl = `${portalUrl}/${skylink}`;

it("should return correctly formed skylink URL", () => {
validSkylinkVariations.forEach((input) => {
expect(client.getSkylinkUrl(input)).toEqual(`${portalUrl}/${skylink}`);
validSkylinkVariations.forEach(([fullSkylink, path]) => {
expect(client.getSkylinkUrl(fullSkylink)).toEqual(`${expectedUrl}${path}`);
});
});

it("should return correctly formed URLs when path is given", () => {
expect(client.getSkylinkUrl(skylink, { path: ["foo", "bar"] })).toEqual(`${expectedUrl}/foo/bar`);
expect(client.getSkylinkUrl(skylink, { path: ["foo?bar"] })).toEqual(`${expectedUrl}/foo%3Fbar`);
});

it("should return correctly formed URL with forced download", () => {
const url = client.getSkylinkUrl(skylink, { download: true, endpointPath: "skynet/skylink" });

expect(url).toEqual(`${portalUrl}/skynet/skylink/${skylink}?attachment=true`);
expect(url).toEqual(`${portalUrl}/skynet/skylink/${skylink}${attachment}`);
});

it("should return correctly formed URLs with forced download and path", () => {
expect(client.getSkylinkUrl(skylink, { download: true, path: ["foo?bar"] })).toEqual(
`${expectedUrl}/foo%3Fbar${attachment}`
);
});
});

describe("open", () => {
describe("openFile", () => {
it("should call window.openFile", () => {
const windowOpen = jest.spyOn(window, "open").mockImplementation();

validSkylinkVariations.forEach((input) => {
validSkylinkVariations.forEach(([fullSkylink, path]) => {
windowOpen.mockReset();

client.openFile(input);
client.openFile(fullSkylink);

expect(windowOpen).toHaveBeenCalledTimes(1);
expect(windowOpen).toHaveBeenCalledWith(`${portalUrl}/${skylink}`, "_blank");
expect(windowOpen).toHaveBeenCalledWith(`${expectedUrl}${path}`, "_blank");
});
});
});

describe("downloadFileHns", () => {
let mock;

beforeEach(() => {
mock = new MockAdapter(axios);
});

it("should set domain with the portal and hns link and then call window.openFile with attachment set", async () => {
const windowOpen = jest.spyOn(window, "open").mockImplementation();

for (const input of validHnsLinkVariations) {
mock.resetHistory();
windowOpen.mockReset();

await client.downloadFileHns(input);

expect(mock.history.get.length).toBe(0);
}
});
});

describe("openFileHns", () => {
const hnsUrl = `${portalUrl}/hns/${hnsLink}`;
let mock;
Expand Down Expand Up @@ -142,7 +173,7 @@ describe("resolveHns", () => {

beforeEach(() => {
mock = new MockAdapter(axios);
mock.onGet(hnsresUrl).reply(200, { skylink: skylink });
mock.onGet(expectedHnsresUrl).reply(200, { skylink: skylink });
});

it("should call axios.get with the portal and hnsres link and return the json body", async () => {
Expand Down
17 changes: 15 additions & 2 deletions src/upload.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ describe("uploadFile", () => {

beforeEach(() => {
mock = new MockAdapter(axios);
mock.onPost(url).reply(200, { skylink: skylink });
mock.onPost(url).replyOnce(200, { skylink: skylink });
mock.resetHistory();
});

Expand Down Expand Up @@ -121,7 +121,7 @@ describe("uploadDirectory", () => {

beforeEach(() => {
mock = new MockAdapter(axios);
mock.onPost(url).reply(200, { skylink: skylink });
mock.onPost(url).replyOnce(200, { skylink: skylink });
mock.resetHistory();
});

Expand Down Expand Up @@ -150,4 +150,17 @@ describe("uploadDirectory", () => {

expect(data).toEqual(sialink);
});

it("should encode special characters in the URL", async () => {
const filename = "encoding?test";
const url = `${portalUrl}/skynet/skyfile?filename=encoding%3Ftest`;
mock.resetHandlers();
mock.onPost(url).replyOnce(200, { skylink: skylink });

const data = await client.uploadDirectory(directory, filename);

expect(mock.history.post.length).toBe(1);

expect(data).toEqual(sialink);
});
});
21 changes: 17 additions & 4 deletions src/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,11 @@ export const uriHandshakeResolverPrefix = "hnsres:";
export const uriSkynetPrefix = "sia:";

export function addUrlQuery(url, query) {
const parsed = parse(url);
const parsed = parse(url, true);
if (parsed.query) {
// Combine the desired query params with the already existing ones.
query = { ...parsed.query, ...query };
}
parsed.set("query", query);
return parsed.toString();
}
Expand Down Expand Up @@ -61,13 +65,17 @@ export function makeUrl(...args) {
});
}

const SKYLINK_MATCHER = "([a-zA-Z0-9_-]{46})";
// Allow ?, /, and # to end the hash portion of a skylink.
const SKYLINK_BOUNDARY = "[?|/|#]";
const SKYLINK_MATCHER = `([a-zA-Z0-9_-]{46}${SKYLINK_BOUNDARY}*.*)`;
const SKYLINK_DIRECT_REGEX = new RegExp(`^${SKYLINK_MATCHER}$`);
const SKYLINK_PATHNAME_REGEX = new RegExp(`^/${SKYLINK_MATCHER}`);
const SKYLINK_REGEXP_MATCH_POSITION = 1;

export function parseSkylink(skylink) {
if (typeof skylink !== "string") throw new Error(`Skylink has to be a string, ${typeof skylink} provided`);
if (typeof skylink !== "string") {
throw new Error(`Skylink has to be a string, ${typeof skylink} provided`);
}

// check for direct skylink match
const matchDirect = skylink.match(SKYLINK_DIRECT_REGEX);
Expand All @@ -82,7 +90,12 @@ export function parseSkylink(skylink) {
// example: https://siasky.net/XABvi7JtJbQSMAcDwnUnmp2FKDPjg8_tTTFP4BwMSxVdEg
const parsed = parse(skylink);
const matchPathname = parsed.pathname.match(SKYLINK_PATHNAME_REGEX);
if (matchPathname) return matchPathname[SKYLINK_REGEXP_MATCH_POSITION];
if (matchPathname) {
const query = parsed.query;
// const hash = parsed.hash ? `#${parsed.hash}` : "";
const hash = parsed.hash;
return `${matchPathname[SKYLINK_REGEXP_MATCH_POSITION]}${query}${hash}`;
}

throw new Error(`Could not extract skylink from '${skylink}'`);
}
Expand Down
Loading

0 comments on commit 681e633

Please sign in to comment.