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 3 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
8 changes: 4 additions & 4 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 { 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 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 Down
139 changes: 121 additions & 18 deletions src/mysky/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ import {
} from "../skydb";
import { Signature } from "../crypto";
import { deriveDiscoverableFileTweak } from "./tweak";
import { popupCenter } from "./utils";
import { getRedirectUrlOnPreferredPortal, popupCenter, shouldRedirectToPreferredPortalUrl } from "./utils";
import { extractOptions } from "../utils/options";
import { JsonData } from "../utils/types";
import {
Expand Down Expand Up @@ -132,7 +132,12 @@ export class MySky {
// Constructors
// ============

constructor(protected connector: Connector, permissions: Permission[], protected hostDomain: string) {
constructor(
protected connector: Connector,
permissions: Permission[],
protected hostDomain: string,
protected currentPortalUrl: string
) {
this.pendingPermissions = permissions;
}

Expand All @@ -152,7 +157,16 @@ export class MySky {
}
const connector = await Connector.init(client, domain, customOptions);

const hostDomain = await client.extractDomain(window.location.hostname);
// Create a new client on the current URL, in case the client the developer
// instantiated does not correspond to the portal of the current URL.
const currentUrlClient = new SkynetClient(window.location.hostname);
// Set the portal URL manually.
//
// @ts-expect-error - Using protected fields.
currentUrlClient.customPortalUrl = await currentUrlClient.resolvePortalUrl();

// Extract the skapp domain.
const hostDomain = await currentUrlClient.extractDomain(window.location.hostname);
const permissions = [];
if (skappDomain) {
const perm1 = new Permission(hostDomain, skappDomain, PermCategory.Discoverable, PermType.Write);
Expand All @@ -161,7 +175,13 @@ export class MySky {
permissions.push(perm1, perm2, perm3);
}

MySky.instance = new MySky(connector, permissions, hostDomain);
const currentPortalUrl = await currentUrlClient.portalUrl();
MySky.instance = new MySky(connector, permissions, hostDomain, currentPortalUrl);
mrcnski marked this conversation as resolved.
Show resolved Hide resolved

// Redirect if we're not on the preferred portal. See
// `redirectIfNotOnPreferredPortal` for full load flow.
await MySky.instance.redirectIfNotOnPreferredPortal();

return MySky.instance;
}

Expand Down Expand Up @@ -203,6 +223,13 @@ export class MySky {
this.pendingPermissions.push(...permissions);
}

/**
* Checks whether main MySky, living in an invisible iframe, is already logged
* in and all requested permissions are granted.
*
* @returns - A boolean indicating whether the user is logged in and all
* permissions are granted.
*/
async checkLogin(): Promise<boolean> {
const [seedFound, permissionsResponse]: [boolean, CheckPermissionsResponse] = await this.connector.connection
.remoteHandle()
Expand Down Expand Up @@ -248,10 +275,17 @@ export class MySky {
}
}

// TODO: Document what this does exactly.
async logout(): Promise<void> {
return await this.connector.connection.remoteHandle().call("logout");
}

/**
* Requests login access by opening the MySky UI window.
*
* @returns - A boolean indicating whether we successfully logged in and all
* requested permissions were granted.
*/
async requestLoginAccess(): Promise<boolean> {
let uiWindow: Window;
let uiConnection: Connection;
Expand All @@ -274,21 +308,19 @@ export class MySky {
});

try {
// Launch the UI.

// Launch and connect the UI.
uiWindow = this.launchUI();
uiConnection = await this.connectUi(uiWindow);

// Send the UI the list of required permissions.

//
// TODO: This should be a dual-promise that also calls ping() on an interval and rejects if no response was found in a given amount of time.
const [seedFoundResponse, permissionsResponse]: [boolean, CheckPermissionsResponse] = await uiConnection
.remoteHandle()
.call("requestLoginAccess", this.pendingPermissions);
seedFound = seedFoundResponse;

// Save failed permissions.

const { grantedPermissions, failedPermissions } = permissionsResponse;
this.grantedPermissions = grantedPermissions;
this.pendingPermissions = failedPermissions;
Expand Down Expand Up @@ -765,6 +797,10 @@ export class MySky {
return connection;
}

protected async getPreferredPortal(): Promise<string | null> {
peterjan marked this conversation as resolved.
Show resolved Hide resolved
return await this.connector.connection.remoteHandle().call("getPreferredPortal");
}

protected async loadDac(dac: DacLibrary): Promise<void> {
// Initialize DAC.
await dac.init(this.connector.client, this.connector.options);
Expand All @@ -774,18 +810,76 @@ export class MySky {
await this.addPermissions(...perms);
}

/**
* Handles the after-login logic.
*
* @param loggedIn - Whether the login was successful.
*/
protected async handleLogin(loggedIn: boolean): Promise<void> {
if (loggedIn) {
await Promise.all(
this.dacs.map(async (dac) => {
try {
await dac.onUserLogin();
} catch (error) {
// Don't throw on error, just print a console warning.
console.warn(error);
}
})
if (!loggedIn) {
return;
}
ro-tex marked this conversation as resolved.
Show resolved Hide resolved

// Call the `onUserLogin` hook for all DACs.
await Promise.all(
peterjan marked this conversation as resolved.
Show resolved Hide resolved
this.dacs.map(async (dac) => {
try {
await dac.onUserLogin();
} catch (error) {
// Don't throw on error, just print a console warning.
console.warn(error);
}
})
);

// Redirect if we're not on the preferred portal. See
// `redirectIfNotOnPreferredPortal` for full login flow.
await this.redirectIfNotOnPreferredPortal();
}

/**
* Get the preferred portal and redirect the page if it is different than
* the current portal.
*
* Load MySky redirect flow:
*
* 1. SDK opens MySky on the same portal as the skapp.
* 2. MySky always connects to siasky.net first.
* 3. MySky tries to get the saved portal preference.
* 1. If the portal is set, MySky switches to using the preferred portal.
* 2. If it is not set or we don't have the seed, MySky switches to using
* the current portal as opposed to siasky.net.
* 4. After MySky finishes loading, SDK queries `mySky.getPortalPreference`.
* 5. If the preferred portal is set and different than the current portal,
* SDK triggers refresh.
* 6. We go back to step 1 and repeat, but since we're on the right portal
* now we won't refresh in step 5.
*
* Login redirect flow:
*
* 1. SDK logs in either silently or through the UI.
* 2. If it was through the UI, MySky switches to siasky.net and tries to
* get the saved portal preference.
* 1. If the portal is set, MySky switches to using the preferred portal.
* 2. If it is not set or we don't have the seed, MySky switches to using
* the current portal as opposed to siasky.net.
* 3. SDK queries `mySky.getPortalPreference`.
* 4. If the preferred portal is set and different than the current portal,
* SDK triggers refresh.
* 5. We go to "Load MySky" step 1 and go through that flow, but we don't
* refresh in step 5.
*/
protected async redirectIfNotOnPreferredPortal(): Promise<void> {
const currentUrl = window.location.hostname;
const preferredPortalUrl = await this.getPreferredPortal();
if (preferredPortalUrl !== null && shouldRedirectToPreferredPortalUrl(currentUrl, preferredPortalUrl)) {
// Redirect.
const newUrl = await getRedirectUrlOnPreferredPortal(
this.currentPortalUrl,
window.location.hostname,
preferredPortalUrl
);
redirectPage(newUrl);
}
}

Expand All @@ -797,3 +891,12 @@ export class MySky {
return await this.connector.connection.remoteHandle().call("signEncryptedRegistryEntry", entry, path);
}
}

/**
* Redirects the page to the given URL.
*
* @param url - The URL.
*/
function redirectPage(url: string): void {
window.location.replace(url);
}
52 changes: 50 additions & 2 deletions src/mysky/utils.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import axios from "axios";
import MockAdapter from "axios-mock-adapter";
import { combineArrays, combineStrings, composeTestCases } from "../../utils/testing";
import { SkynetClient } from "../client";
import { defaultSkynetPortalUrl } from "../utils/url";
import { DEFAULT_SKYNET_PORTAL_URL } from "../utils/url";
import { getRedirectUrlOnPreferredPortal, shouldRedirectToPreferredPortalUrl } from "./utils";

const portalUrl = defaultSkynetPortalUrl;
const portalUrl = DEFAULT_SKYNET_PORTAL_URL;
const client = new SkynetClient(portalUrl);

describe("extractDomain", () => {
Expand Down Expand Up @@ -49,3 +51,49 @@ describe("getFullDomainUrl", () => {
expect(fullUrl).toEqual(expectedUrl);
});
});

// Test different variations of prefixes and trailing slashes.
const portal1Urls = combineStrings(["", "http://", "https://", "HTTPS://"], ["siasky.net"], ["", "/"]);
const portal2Urls = combineStrings(["", "http://", "https://", "HTTPS://"], ["siasky.xyz"], ["", "/"]);

// TODO: Test cases with portal servers.
describe("getRedirectUrlOnPreferredPortal", () => {
const portal1SkappUrls = combineStrings(["", "https://"], ["skapp.hns.siasky.net"], ["", "/"]);
const portal2SkappUrls = combineStrings(["", "https://"], ["skapp.hns.siasky.xyz"], ["", "/"]);

const cases: Array<[string, string, string, string]> = [
// Test redirecting from one portal to another.
...(composeTestCases(combineArrays(portal1SkappUrls, portal2Urls), "https://skapp.hns.siasky.xyz").map(
([[a, b], c]) => ["siasky.net", a, b, c]
) as [string, string, string, string][]),
...(composeTestCases(combineArrays(portal2SkappUrls, portal1Urls), "https://skapp.hns.siasky.net").map(
([[a, b], c]) => ["siasky.xyz", a, b, c]
) as [string, string, string, string][]),
];

it.each(cases)(
"('%s', '%s', '%s') should return '%s'",
(portalUrl, currentUrl, preferredPortalUrl, expectedResult) => {
const result = getRedirectUrlOnPreferredPortal(portalUrl, currentUrl, preferredPortalUrl);
expect(result).toEqual(expectedResult);
}
);
});

// TODO: Add cases with portal servers.
// Test the function that checks whether two portals are equal for the purposes
// of redirecting the user to a preferred portal.
describe("shouldRedirectToPreferredPortalUrl", () => {
const cases: Array<[string, string, boolean]> = [
// Add cases where the portal URLs are the same.
...composeTestCases(combineArrays(portal1Urls, portal1Urls), true),
// Test cases where the portals are different.
...composeTestCases(combineArrays(portal1Urls, portal2Urls), false),
...composeTestCases(combineArrays(portal2Urls, portal1Urls), false),
].map(([[a, b], c]) => [a, b, c]);

it.each(cases)("('%s', '%s') should return '%s'", (currentPortalUrl, preferredPortalUrl, expectedResult) => {
const result = shouldRedirectToPreferredPortalUrl(currentPortalUrl, preferredPortalUrl);
expect(result).toEqual(expectedResult);
});
});
38 changes: 38 additions & 0 deletions src/mysky/utils.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { SkynetClient } from "../client";
import { trimSuffix } from "../utils/string";
import { getFullDomainUrlForPortal, extractDomainForPortal, ensureUrlPrefix } from "../utils/url";

/**
Expand All @@ -15,6 +16,26 @@ export async function getFullDomainUrl(this: SkynetClient, domain: string): Prom
return getFullDomainUrlForPortal(portalUrl, domain);
}

// TODO: unit test
peterjan marked this conversation as resolved.
Show resolved Hide resolved
/**
* Gets the URL for the current skapp on the preferred portal, if we're not on
* the preferred portal already.
*
* @param currentPortalUrl - The current portal URL.
* @param currentUrl - The current page URL.
* @param preferredPortalUrl - The preferred portal URL.
* @returns - The URL for the current skapp on the preferred portal.
*/
export function getRedirectUrlOnPreferredPortal(
currentPortalUrl: string,
currentUrl: string,
preferredPortalUrl: string
): string {
// Get the current skapp on the preferred portal.
const skappDomain = extractDomainForPortal(currentPortalUrl, currentUrl);
return getFullDomainUrlForPortal(preferredPortalUrl, skappDomain);
}

/**
* Extracts the domain from the current portal URL,
* e.g. ("dac.hns.siasky.net") => "dac.hns"
Expand Down Expand Up @@ -64,3 +85,20 @@ export function popupCenter(url: string, winName: string, w: number, h: number):
}
return newWindow;
}

// TODO: Handle edge cases with specific servers as preferred portal?
/**
* Returns whether we should redirect from the current portal to the preferred
* portal. The protocol prefixes are allowed to be different and there can be
* other differences like a trailing slash.
*
* @param currentPortalUrl - The current portal URL.
* @param preferredPortalUrl - The preferred portal URL.
* @returns - Whether the two URLs are equal for the purposes of redirecting.
*/
export function shouldRedirectToPreferredPortalUrl(currentPortalUrl: string, preferredPortalUrl: string): boolean {
currentPortalUrl = currentPortalUrl.split("//", 2)[1] || currentPortalUrl;
peterjan marked this conversation as resolved.
Show resolved Hide resolved
preferredPortalUrl = preferredPortalUrl.split("//", 2)[1] || preferredPortalUrl;

return trimSuffix(currentPortalUrl, "/") === trimSuffix(preferredPortalUrl, "/");
}
Loading