Skip to content
This repository has been archived by the owner on Jan 24, 2023. It is now read-only.

Commit

Permalink
Merge pull request #361 from SkynetLabs/preferred-portal-refresh
Browse files Browse the repository at this point in the history
Preferred portal refresh
  • Loading branch information
mrcnski committed Feb 14, 2022
2 parents 409bc5a + d75bc7c commit 6c009c5
Show file tree
Hide file tree
Showing 21 changed files with 936 additions and 142 deletions.
12 changes: 12 additions & 0 deletions .eslintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,18 @@
"@typescript-eslint/no-unused-vars": ["warn", { "argsIgnorePattern": "^_", "varsIgnorePattern": "^_" }],

"jsdoc/require-description": 1,
"jsdoc/require-jsdoc": [
"error",
{
"require": {
"FunctionDeclaration": true,
"MethodDefinition": true,
"ClassDeclaration": true,
"ArrowFunctionExpression": false,
"FunctionExpression": false
}
}
],
"jsdoc/require-throws": 1,

"jsdoc/require-param-type": 0,
Expand Down
81 changes: 74 additions & 7 deletions src/client.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import axios from "axios";
import MockAdapter from "axios-mock-adapter";

import { combineStrings } from "../utils/testing";
import { SkynetClient } from "./index";
import { SkynetClient, URI_SKYNET_PREFIX } from "./index";
import { buildRequestUrl } from "./request";
import { defaultSkynetPortalUrl } from "./utils/url";
import { combineStrings } from "../utils/testing";
import { DEFAULT_SKYNET_PORTAL_URL } from "./utils/url";

const portalUrl = defaultSkynetPortalUrl;
const portalUrl = DEFAULT_SKYNET_PORTAL_URL;
const client = new SkynetClient(portalUrl);
let mock: MockAdapter;

Expand All @@ -32,8 +32,8 @@ describe("new SkynetClient", () => {
mock.reset();
});

// Is localhost in Node tests.
const expectedPortalUrl = "http://localhost/";
// The default portal URL is localhost in Node tests.
const expectedPortalUrl = "http://localhost";

// Failure cases.
//
Expand Down Expand Up @@ -101,7 +101,7 @@ describe("buildRequestUrl", () => {
describe("localhost inputs", () => {
// `localhost` without a protocol prefix is not in this list because
// `buildRequestUrl` always ensures a prefix protocol for consistency.
const validExpectedLocalhosts = combineStrings(["https://", "http://"], ["localhost"], ["", "/"]);
const validExpectedLocalhosts = combineStrings(["https:", "http:"], ["", "//"], ["localhost"], ["", "/"]);
const localhostUrls = combineStrings(["", "https://", "https:", "http://", "http:"], ["localhost"], ["", "/"]);

it.each(localhostUrls)("should correctly handle input '%s'", async (localhostUrl) => {
Expand All @@ -110,3 +110,70 @@ describe("buildRequestUrl", () => {
});
});
});

describe("client options", () => {
const skylink = "XABvi7JtJbQSMAcDwnUnmp2FKDPjg8_tTTFP4BwMSxVdEg";
const sialink = `${URI_SKYNET_PREFIX}${skylink}`;

beforeEach(() => {
mock = new MockAdapter(axios);
mock.onHead(portalUrl).replyOnce(200, {}, { "skynet-portal-api": portalUrl });
});

describe("loginFn", () => {
it("should call 'loginFn' on a 401 response, and make another attempt at the request", async () => {
const skynetFileContents = { arbitrary: "json string" };
const headers = {
"skynet-portal-api": portalUrl,
"skynet-skylink": skylink,
"content-type": "application/json",
};

// loginFn should change the value of `loginFnWasCalled`.
let loginFnWasCalled = false;
const client = new SkynetClient(portalUrl, {
loginFn: async () => {
loginFnWasCalled = true;
},
});

// Return 401 for the first request and 200 for the second.
const skylinkUrl = await client.getSkylinkUrl(skylink);
mock.onGet(skylinkUrl).replyOnce(401).onGet(skylinkUrl).replyOnce(200, skynetFileContents, headers);

const { data, contentType, skylink: skylink2 } = await client.getFileContent(skylink);

// Assert that we got the expected data.
expect(data).toEqual(skynetFileContents);
expect(contentType).toEqual("application/json");
expect(skylink2).toEqual(sialink);

// Assert that loginFn was called.
expect(loginFnWasCalled).toBeTruthy();
});
});
});

describe("resolvePortalServerUrl", () => {
beforeEach(() => {
mock = new MockAdapter(axios);
});

it("should throw if portal does not send skynet-portal-api header", async () => {
mock.onHead(portalUrl).replyOnce(200, {}, {});

// @ts-expect-error - Using protected method.
await expect(client.resolvePortalServerUrl()).rejects.toThrowError(
"Could not get server portal URL for the given portal"
);
});

it("should throw if portal does not send headers", async () => {
mock.onHead(portalUrl).replyOnce(200, {});

// @ts-expect-error - Using protected method.
await expect(client.resolvePortalServerUrl()).rejects.toThrowError(
"Did not get 'headers' in response despite a successful request. Please try again and report this issue to the devs if it persists."
);
});
});
85 changes: 64 additions & 21 deletions src/client.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import axios, { AxiosError } from "axios";
import type { AxiosResponse, ResponseType, Method } from "axios";
import { ensureUrl } from "skynet-mysky-utils";

import {
uploadFile,
Expand Down Expand Up @@ -56,13 +57,15 @@ import { buildRequestHeaders, buildRequestUrl, ExecuteRequestError, Headers } fr
* @property [customCookie] - Custom cookie header to set. WARNING: the Cookie header cannot be set in browsers. This is meant for usage in server contexts.
* @property [onDownloadProgress] - Optional callback to track download progress.
* @property [onUploadProgress] - Optional callback to track upload progress.
* @property [loginFn] - A function that, if set, is called when a 401 is returned from the request before re-trying the request.
*/
export type CustomClientOptions = {
APIKey?: string;
customUserAgent?: string;
customCookie?: string;
onDownloadProgress?: (progress: number, event: ProgressEvent) => void;
onUploadProgress?: (progress: number, event: ProgressEvent) => void;
loginFn?: () => Promise<void>;
};

/**
Expand Down Expand Up @@ -212,7 +215,7 @@ export class SkynetClient {
initialPortalUrl = defaultPortalUrl();
} else {
// Portal was given, don't make the request for the resolved portal URL.
this.customPortalUrl = initialPortalUrl;
this.customPortalUrl = ensureUrl(initialPortalUrl);
}
this.initialPortalUrl = initialPortalUrl;
this.customOptions = customOptions;
Expand Down Expand Up @@ -306,37 +309,77 @@ export class SkynetClient {
};
}

// NOTE: The error type will be ExecuteRequestError as we set up a response
// interceptor above.
return await axios({
url,
method: config.method,
data: config.data,
headers,
auth,
onDownloadProgress,
onUploadProgress,
responseType: config.responseType,
transformRequest: config.transformRequest,
transformResponse: config.transformResponse,

maxContentLength: Infinity,
maxBodyLength: Infinity,
// Allow cross-site cookies.
withCredentials: true,
});
// NOTE: The error type will be `ExecuteRequestError` as we set up a
// response interceptor above.
try {
return await axios({
url,
method: config.method,
data: config.data,
headers,
auth,
onDownloadProgress,
onUploadProgress,
responseType: config.responseType,
transformRequest: config.transformRequest,
transformResponse: config.transformResponse,

maxContentLength: Infinity,
maxBodyLength: Infinity,
// Allow cross-site cookies.
withCredentials: true,
});
} catch (e) {
if (config.loginFn && (e as ExecuteRequestError).responseStatus === 401) {
// Try logging in again.
await config.loginFn();
return await this.executeRequest(config);
} else {
throw e;
}
}
}

// ===============
// Private Methods
// ===============

/**
* Gets the current server URL for the portal. You should generally use
* `portalUrl` instead - this method can be used for detecting whether the
* current URL is a server URL.
*
* @returns - The portal server URL.
*/
protected async resolvePortalServerUrl(): Promise<string> {
const response = await this.executeRequest({
...this.customOptions,
method: "head",
url: this.initialPortalUrl,
});

if (!response.headers) {
throw new Error(
"Did not get 'headers' in response despite a successful request. Please try again and report this issue to the devs if it persists."
);
}
const portalUrl = response.headers["skynet-server-api"];
if (!portalUrl) {
throw new Error("Could not get server portal URL for the given portal");
}
return portalUrl;
}

/**
* Make a request to resolve the provided `initialPortalUrl`.
*
* @returns - The portal URL.
*/
protected async resolvePortalUrl(): Promise<string> {
const response = await this.executeRequest({
...this.customOptions,
method: "head",
url: this.initialPortalUrl,
endpointPath: "/",
});

if (!response.headers) {
Expand Down
6 changes: 3 additions & 3 deletions src/download.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,15 @@ import axios from "axios";
import MockAdapter from "axios-mock-adapter";
import { combineStrings, extractNonSkylinkPath } from "../utils/testing";

import { SkynetClient, defaultSkynetPortalUrl, uriSkynetPrefix } from "./index";
import { SkynetClient, DEFAULT_SKYNET_PORTAL_URL, URI_SKYNET_PREFIX } from "./index";
import { trimForwardSlash } from "./utils/string";

const portalUrl = defaultSkynetPortalUrl;
const portalUrl = DEFAULT_SKYNET_PORTAL_URL;
const hnsLink = "foo";
const client = new SkynetClient(portalUrl);
const skylink = "XABvi7JtJbQSMAcDwnUnmp2FKDPjg8_tTTFP4BwMSxVdEg";
const skylinkBase32 = "bg06v2tidkir84hg0s1s4t97jaeoaa1jse1svrad657u070c9calq4g";
const sialink = `${uriSkynetPrefix}${skylink}`;
const sialink = `${URI_SKYNET_PREFIX}${skylink}`;
const entryLink = "AQDwh1jnoZas9LaLHC_D4-2yP9XYDdZzNtz62H4Dww1jDA";

const validSkylinkVariations = combineStrings(
Expand Down
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ export {
} from "./mysky/encrypted_files";
export { deriveDiscoverableFileTweak } from "./mysky/tweak";
export { ExecuteRequestError } from "./request";
export { DELETION_ENTRY_DATA } from "./skydb";
export { DELETION_ENTRY_DATA, getOrCreateSkyDBRegistryEntry } from "./skydb";
export { convertSkylinkToBase32, convertSkylinkToBase64 } from "./skylink/format";
export { parseSkylink } from "./skylink/parse";
export { isSkylinkV1, isSkylinkV2 } from "./skylink/sia";
Expand Down
28 changes: 28 additions & 0 deletions src/mysky/connector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,20 @@ export const DEFAULT_CONNECTOR_OPTIONS = {
handshakeAttemptsInterval: defaultHandshakeAttemptsInterval,
};

/**
* The object that connects to a child iframe and keeps track of information
* about it.
*/
export class Connector {
/**
* Creates a `Connector`.
*
* @param url - The iframe URL.
* @param client - The Skynet Client.
* @param childFrame - The iframe handle.
* @param connection - The postmessage handshake connection.
* @param options - The custom options.
*/
constructor(
public url: string,
public client: SkynetClient,
Expand All @@ -42,6 +55,14 @@ export class Connector {

// Static initializer

/**
* Initializes a `Connector` instance.
*
* @param client - The Skynet Client.
* @param domain - The MySky domain to open.
* @param [customOptions] - Additional settings that can optionally be set.
* @returns - The `Connector`.
*/
static async init(client: SkynetClient, domain: string, customOptions?: CustomConnectorOptions): Promise<Connector> {
const opts = { ...DEFAULT_CONNECTOR_OPTIONS, ...customOptions };

Expand Down Expand Up @@ -80,6 +101,13 @@ export class Connector {
return new Connector(domainUrl, client, childFrame, connection, opts);
}

/**
* Calls the given method with the given arguments.
*
* @param method - The remote method to call over the connection.
* @param args - The list of optional arguments.
* @returns - The result of the call.
*/
async call(method: string, ...args: unknown[]): Promise<unknown> {
return this.connection.remoteHandle().call(method, ...args);
}
Expand Down
23 changes: 23 additions & 0 deletions src/mysky/dac.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,41 @@ import { Permission } from "skynet-mysky-utils";
import { SkynetClient } from "../client";
import { Connector, CustomConnectorOptions } from "./connector";

/**
* The base DAC class with base and required methods.
*/
export abstract class DacLibrary {
protected connector?: Connector;

/**
* Constructs the DAC.
*
* @param dacDomain - The domain of the DAC.
*/
public constructor(protected dacDomain: string) {}

/**
* Initializes the `Connector` with the DAC iframe and calls `init` on the
* DAC.
*
* @param client - The Skynet Client.
* @param customOptions - The custom options.
*/
public async init(client: SkynetClient, customOptions: CustomConnectorOptions): Promise<void> {
this.connector = await Connector.init(client, this.dacDomain, customOptions);
await this.connector.connection.remoteHandle().call("init");
}

/**
* Returns the permissions required by the DAC.
*
* @returns - The DAC permissions.
*/
abstract getPermissions(): Permission[];

/**
* The hook to run on user login.
*/
async onUserLogin(): Promise<void> {
if (!this.connector) {
throw new Error("init was not called");
Expand Down
Loading

0 comments on commit 6c009c5

Please sign in to comment.