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

Preferred portal refresh #361

Merged
merged 26 commits into from
Feb 14, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
8423cf4
Redirect page when getting preferred portal (on login)
mrcnski Dec 15, 2021
cd9db11
Redirect after loading MySky
mrcnski Dec 17, 2021
51681b0
Merge branch 'master' into preferred-portal-refresh
mrcnski Dec 17, 2021
96d48ff
Enable jsdoc lint for classes and methods
mrcnski Dec 20, 2021
9591d3b
Address review comments
mrcnski Dec 20, 2021
8962ba9
Merge branch 'master' into preferred-portal-refresh
mrcnski Dec 20, 2021
271608f
Enforce singleton pattern for MySky
mrcnski Dec 21, 2021
20db9f3
Add an explanatory comment for MySky client creation
mrcnski Dec 21, 2021
fad4110
Update flow to account for local storage
mrcnski Dec 21, 2021
03ba7d1
Fix Promise.allSettled build error
mrcnski Dec 22, 2021
9d6b881
Add export needed for MySky
mrcnski Jan 11, 2022
42bfed1
Fix handling of MySky client when on localhost
mrcnski Jan 17, 2022
0a73d8f
Add loginFn for MySky auto-relogin
mrcnski Jan 17, 2022
abffa5f
Fix bug loginFn when is provided
mrcnski Jan 19, 2022
25a4db0
Test loginFn
mrcnski Jan 21, 2022
a908e99
Implement auto-relogin
mrcnski Jan 21, 2022
b4bf9a9
Fix domain extraction when on specific portal server
mrcnski Jan 25, 2022
a975d43
Fix domain extraction tests and add more cases
mrcnski Jan 28, 2022
7234fc2
Address some missing test coverage
mrcnski Jan 28, 2022
9f97745
Fix bugs
mrcnski Feb 1, 2022
0994d18
Fix tests and a bug (same as referrer bug in MySky)
mrcnski Feb 1, 2022
73a9983
- Fix bug with getting user settings (bad URL)
mrcnski Feb 4, 2022
29ec63f
Merge branch 'master' into preferred-portal-refresh
ro-tex Feb 7, 2022
eab66b6
Address review comment
mrcnski Feb 11, 2022
25faddb
Merge remote-tracking branch 'origin/preferred-portal-refresh' into p…
mrcnski Feb 11, 2022
d75bc7c
Merge branch 'master' into preferred-portal-refresh
ro-tex Feb 14, 2022
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
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}`;
ro-tex marked this conversation as resolved.
Show resolved Hide resolved

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