diff --git a/.eslintrc.json b/.eslintrc.json index 41df1eab..6e23fe12 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -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, diff --git a/src/client.test.ts b/src/client.test.ts index 86e06f0e..012718bb 100644 --- a/src/client.test.ts +++ b/src/client.test.ts @@ -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; @@ -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. // @@ -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) => { @@ -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." + ); + }); +}); diff --git a/src/client.ts b/src/client.ts index 51b54a06..6c4bddcf 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1,5 +1,6 @@ import axios, { AxiosError } from "axios"; import type { AxiosResponse, ResponseType, Method } from "axios"; +import { ensureUrl } from "skynet-mysky-utils"; import { uploadFile, @@ -56,6 +57,7 @@ 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; @@ -63,6 +65,7 @@ export type CustomClientOptions = { customCookie?: string; onDownloadProgress?: (progress: number, event: ProgressEvent) => void; onUploadProgress?: (progress: number, event: ProgressEvent) => void; + loginFn?: () => Promise; }; /** @@ -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; @@ -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 { + 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 { const response = await this.executeRequest({ ...this.customOptions, method: "head", url: this.initialPortalUrl, - endpointPath: "/", }); if (!response.headers) { diff --git a/src/download.test.ts b/src/download.test.ts index bb7e9792..be24f000 100644 --- a/src/download.test.ts +++ b/src/download.test.ts @@ -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( diff --git a/src/index.ts b/src/index.ts index 98e388cc..92c0ea96 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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"; diff --git a/src/mysky/connector.ts b/src/mysky/connector.ts index f4844a72..e85d964c 100644 --- a/src/mysky/connector.ts +++ b/src/mysky/connector.ts @@ -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, @@ -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 { const opts = { ...DEFAULT_CONNECTOR_OPTIONS, ...customOptions }; @@ -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 { return this.connection.remoteHandle().call(method, ...args); } diff --git a/src/mysky/dac.ts b/src/mysky/dac.ts index 1f564e57..0e418efb 100644 --- a/src/mysky/dac.ts +++ b/src/mysky/dac.ts @@ -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 { 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 { if (!this.connector) { throw new Error("init was not called"); diff --git a/src/mysky/index.ts b/src/mysky/index.ts index b41facdf..46ec72b1 100644 --- a/src/mysky/index.ts +++ b/src/mysky/index.ts @@ -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 { @@ -116,6 +116,10 @@ export async function loadMySky( return mySky; } +/** + * The singleton object that allows skapp developers to initialize and + * communicate with MySky. + */ export class MySky { static instance: MySky | null = null; @@ -132,10 +136,35 @@ export class MySky { // Constructors // ============ - constructor(protected connector: Connector, permissions: Permission[], protected hostDomain: string) { + /** + * Creates a `MySky` instance. + * + * @param connector - The `Connector` object. + * @param permissions - The initial requested permissions. + * @param hostDomain - The domain of the host skapp. + * @param currentPortalUrl - The URL of the current portal. This is the portal that the skapp is running on, not the portal that may have been requested by the developer when creating a `SkynetClient`. + */ + constructor( + protected connector: Connector, + permissions: Permission[], + protected hostDomain: string, + protected currentPortalUrl: string + ) { + if (MySky.instance) { + throw new Error("Trying to create a second MySky instance"); + } + this.pendingPermissions = permissions; } + /** + * Initializes MySky and returns a `MySky` instance. + * + * @param client - The Skynet Client. + * @param [skappDomain] - The domain of the host skapp. + * @param [customOptions] - Additional settings that can optionally be set. + * @returns - A `MySky` instance. + */ static async New(client: SkynetClient, skappDomain?: string, customOptions?: CustomConnectorOptions): Promise { const opts = { ...DEFAULT_CONNECTOR_OPTIONS, ...customOptions }; @@ -152,7 +181,31 @@ export class MySky { } const connector = await Connector.init(client, domain, customOptions); - const hostDomain = await client.extractDomain(window.location.hostname); + let currentPortalUrl; + let hostDomain; + if (window.location.hostname === "localhost") { + currentPortalUrl = window.location.href; + hostDomain = "localhost"; + } else { + // MySky expects to be on the same portal as the skapp, so create a new + // client on the current skapp 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); + // Trigger a resolve of the portal URL manually. `new SkynetClient` assumes + // a portal URL is given to it, so it doesn't make the request for the + // actual portal URL. + // + // TODO: We should rework this so it is possible without protected methods. + // + // @ts-expect-error - Using protected fields. + currentUrlClient.customPortalUrl = await currentUrlClient.resolvePortalUrl(); + currentPortalUrl = await currentUrlClient.portalUrl(); + + // Get the host domain. + hostDomain = await currentUrlClient.extractDomain(window.location.hostname); + } + + // Extract the skapp domain. const permissions = []; if (skappDomain) { const perm1 = new Permission(hostDomain, skappDomain, PermCategory.Discoverable, PermType.Write); @@ -161,7 +214,12 @@ export class MySky { permissions.push(perm1, perm2, perm3); } - MySky.instance = new MySky(connector, permissions, hostDomain); + MySky.instance = new MySky(connector, permissions, hostDomain, currentPortalUrl); + + // Redirect if we're not on the preferred portal. See + // `redirectIfNotOnPreferredPortal` for full load flow. + await MySky.instance.redirectIfNotOnPreferredPortal(); + return MySky.instance; } @@ -199,10 +257,22 @@ export class MySky { await Promise.all(promises); } + /** + * Adds the given permissions to the list of pending permissions. + * + * @param permissions - The list of permissions to add. + */ async addPermissions(...permissions: Permission[]): Promise { 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 { const [seedFound, permissionsResponse]: [boolean, CheckPermissionsResponse] = await this.connector.connection .remoteHandle() @@ -214,7 +284,9 @@ export class MySky { this.pendingPermissions = failedPermissions; const loggedIn = seedFound && failedPermissions.length === 0; - await this.handleLogin(loggedIn); + if (loggedIn) { + await this.handleLogin(); + } return loggedIn; } @@ -230,6 +302,8 @@ export class MySky { * @throws - Will throw if there is an unexpected DOM error. */ async destroy(): Promise { + // TODO: Make sure we are logged out first? + // TODO: For all connected dacs, send a destroy call. // TODO: Delete all connected dacs. @@ -248,10 +322,25 @@ export class MySky { } } + // TODO: Document what this does exactly. + /** + * Log out the user. + * + * @returns - An empty promise. + */ async logout(): Promise { - return await this.connector.connection.remoteHandle().call("logout"); + await this.connector.connection.remoteHandle().call("logout"); + + // Remove auto-relogin if it's set. + this.connector.client.customOptions.loginFn = undefined; } + /** + * 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 { let uiWindow: Window; let uiConnection: Connection; @@ -274,13 +363,12 @@ 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() @@ -288,7 +376,6 @@ export class MySky { seedFound = seedFoundResponse; // Save failed permissions. - const { grantedPermissions, failedPermissions } = permissionsResponse; this.grantedPermissions = grantedPermissions; this.pendingPermissions = failedPermissions; @@ -317,10 +404,17 @@ export class MySky { }); const loggedIn = seedFound && this.pendingPermissions.length === 0; - await this.handleLogin(loggedIn); + if (loggedIn) { + await this.handleLogin(); + } return loggedIn; } + /** + * Returns the user ID (i.e. same as the user's public key). + * + * @returns - The hex-encoded user ID. + */ async userID(): Promise { return await this.connector.connection.remoteHandle().call("userID"); } @@ -724,11 +818,33 @@ export class MySky { // Internal Methods // ================ + /** + * Catches any errors returned from the UI and dispatches them in the current + * window. This is how we bubble up errors from the MySky UI window to the + * skapp. + * + * @param errorMsg - The error message. + */ protected async catchError(errorMsg: string): Promise { const event = new CustomEvent(dispatchedErrorEvent, { detail: errorMsg }); window.dispatchEvent(event); } + /** + * Checks if the MySky user can be logged into a portal account. + * + * @returns - Whether the user can be logged into a portal account. + */ + protected async checkPortalLogin(): Promise { + return await this.connector.connection.remoteHandle().call("checkPortalLogin"); + } + + /** + * Launches the MySky UI popup window. + * + * @returns - The window handle. + * @throws - Will throw if the window could not be opened. + */ protected launchUI(): Window { const mySkyUrl = new URL(this.connector.url); mySkyUrl.pathname = mySkyUiRelativeUrl; @@ -744,6 +860,12 @@ export class MySky { return childWindow; } + /** + * Connects to the MySky UI window by establishing a postmessage handshake. + * + * @param childWindow - The MySky UI window. + * @returns - The `Connection` with the other window. + */ protected async connectUi(childWindow: Window): Promise { const options = this.connector.options; @@ -767,6 +889,20 @@ export class MySky { return connection; } + /** + * Gets the preferred portal from MySky, or `null` if not set. + * + * @returns - The preferred portal if set. + */ + protected async getPreferredPortal(): Promise { + return await this.connector.connection.remoteHandle().call("getPreferredPortal"); + } + + /** + * Loads the given DAC. + * + * @param dac - The dac to load. + */ protected async loadDac(dac: DacLibrary): Promise { // Initialize DAC. await dac.init(this.connector.client, this.connector.options); @@ -776,26 +912,151 @@ export class MySky { await this.addPermissions(...perms); } - protected async handleLogin(loggedIn: boolean): Promise { - 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); - } - }) + /** + * Handles the after-login logic. + */ + protected async handleLogin(): Promise { + // Call the `onUserLogin` hook for all DACs. + await Promise.allSettled( + 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(); + + // If we can log in to the portal account, set up auto-relogin. + if (await this.checkPortalLogin()) { + this.connector.client.customOptions.loginFn = this.portalLogin; + } + } + + /** + * Logs in to the user's portal account. + * + * @returns - An empty promise. + */ + protected async portalLogin(): Promise { + return await this.connector.connection.remoteHandle().call("portalLogin"); + } + + /** + * 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. If the preferred portal is found in localstorage, MySky connects to it + * and we go to step 5. + * 3. Else, MySky connects to siasky.net. + * 4. 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. + * 5. After MySky finishes loading, SDK queries `mySky.getPortalPreference`. + * 6. If the preferred portal is set and different than the current portal, + * SDK triggers refresh. + * 7. We go back to step 1 and repeat, but since we're on the right portal + * now we won't refresh in step 6. + * + * Login redirect flow: + * + * 1. SDK logs in through the UI. + * 2. MySky UI 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 6. + */ + protected async redirectIfNotOnPreferredPortal(): Promise { + const currentDomain = window.location.hostname; + if (currentDomain === "localhost") { + // Don't redirect on localhost as there is no subdomain to redirect to. + return; + } + + // Get the preferred portal. + const preferredPortalUrl = await this.getPreferredPortal(); + + // Is the preferred portal set and different from the current portal? + if (preferredPortalUrl === null) { + return; + } else if (shouldRedirectToPreferredPortalUrl(currentDomain, preferredPortalUrl)) { + // Redirect to the appropriate URL. + // + // Get the redirect URL based on the current URL. (Don't use current + // client as the developer may have set it to e.g. siasky.dev when we are + // really on siasky.net.) + const currentDomainClient = new SkynetClient(currentDomain); + const newUrl = await getRedirectUrlOnPreferredPortal( + currentDomainClient, + window.location.hostname, + preferredPortalUrl ); + + // Check if the portal is valid and working before redirecting. + const newUrlClient = new SkynetClient(newUrl); + try { + const portalUrl = await newUrlClient.portalUrl(); + if (portalUrl) { + // Redirect. + redirectPage(newUrl); + } + } catch (e) { + // Don't throw an error here for now as this is likely user error. + console.warn(e); + } + } else { + // If we are on the preferred portal already, we still need to set the + // client as the developer may have chosen a specific client. We always + // want to use the user's preference for a portal, if it is set. + + // Set the skapp client to use the user's preferred portal. + this.connector.client = new SkynetClient(preferredPortalUrl); } } + /** + * Asks MySky to sign the non-encrypted registry entry. + * + * @param entry - The non-encrypted registry entry. + * @param path - The MySky path. + * @returns - The signature. + */ protected async signRegistryEntry(entry: RegistryEntry, path: string): Promise { return await this.connector.connection.remoteHandle().call("signRegistryEntry", entry, path); } + /** + * Asks MySky to sign the encrypted registry entry. + * + * @param entry - The encrypted registry entry. + * @param path - The MySky path. + * @returns - The signature. + */ protected async signEncryptedRegistryEntry(entry: RegistryEntry, path: string): Promise { 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); +} diff --git a/src/mysky/tweak.ts b/src/mysky/tweak.ts index f002b079..8e477092 100644 --- a/src/mysky/tweak.ts +++ b/src/mysky/tweak.ts @@ -15,10 +15,18 @@ export function deriveDiscoverableFileTweak(path: string): string { return toHexString(bytes); } +/** + * The tweak for the discoverable bucket for the given path. + */ export class DiscoverableBucketTweak { version: number; path: Array; + /** + * Creates a new `DiscoverableBucketTweak`. + * + * @param path - The MySky data path. + */ constructor(path: string) { const paths = splitPath(path); const pathHashes = paths.map(hashPathComponent); @@ -26,6 +34,11 @@ export class DiscoverableBucketTweak { this.path = pathHashes; } + /** + * Encodes the tweak into a byte array. + * + * @returns - The encoded byte array. + */ encode(): Uint8Array { const size = 1 + 32 * this.path.length; const buf = new Uint8Array(size); @@ -39,6 +52,11 @@ export class DiscoverableBucketTweak { return buf; } + /** + * Gets the hash of the tweak. + * + * @returns - The hash. + */ getHash(): Uint8Array { const encoding = this.encode(); return hashAll(encoding); diff --git a/src/mysky/utils.test.ts b/src/mysky/utils.test.ts index 585b8645..10ddcda6 100644 --- a/src/mysky/utils.test.ts +++ b/src/mysky/utils.test.ts @@ -1,30 +1,99 @@ 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", () => { let mock: MockAdapter; + const portalDomain = "siasky.net"; + const serverPortalDomain = `us-va-1.${portalDomain}`; + const serverPortalUrl = `https://${serverPortalDomain}`; + const serverClient = new SkynetClient(serverPortalUrl); + beforeEach(() => { mock = new MockAdapter(axios); - mock.onHead(portalUrl).replyOnce(200, {}, { "skynet-portal-api": portalUrl }); + // Responses for regular portal. + mock + .onHead(portalUrl) + .replyOnce(200, {}, { "skynet-server-api": serverPortalUrl }) + .onHead(portalUrl) + .replyOnce(200, {}, { "skynet-portal-api": portalUrl }); }); - const domains = [ + const cases = [ ["https://crqa.hns.siasky.net", "crqa.hns"], ["https://crqa.hns.siasky.net/", "crqa.hns"], ["crqa.hns.siasky.net", "crqa.hns"], ["crqa.hns.siasky.net/", "crqa.hns"], ["localhost", "localhost"], ]; - it.each(domains)("Should extract from URL %s the app domain %s", async (fullUrl, expectedDomain) => { - const domain = await client.extractDomain(fullUrl); + const serverCases = cases.map(([fullDomain, domain]) => [ + fullDomain.replace("siasky.net", serverPortalDomain), + domain, + ]); + + it.each(cases)( + `should extract from full URL '%s' the app domain '%s' using portal '${portalUrl}'`, + async (fullUrl, expectedDomain) => { + const domain = await client.extractDomain(fullUrl); + + expect(domain).toEqual(expectedDomain); + } + ); + + it.each(serverCases)( + `should extract from full URL '%s' the app domain '%s' using portal '${serverPortalUrl}' and client on the same portal`, + async (fullUrl, expectedDomain) => { + // Responses for portal on server URL. + mock + .onHead(serverPortalUrl) + .replyOnce(200, {}, { "skynet-server-api": serverPortalUrl }) + .onHead(serverPortalUrl) + .replyOnce(200, {}, { "skynet-portal-api": portalUrl }); + + const domain = await serverClient.extractDomain(fullUrl); + + expect(domain).toEqual(expectedDomain); + } + ); - expect(domain).toEqual(expectedDomain); + it.each(serverCases)( + `should extract from full URL '%s' the app domain '%s' using portal '${serverPortalUrl}' and client on the same portal if portal returns domains instead of URLs`, + async (fullUrl, expectedDomain) => { + // Responses for portal on server URL. + mock + .onHead(serverPortalUrl) + .replyOnce(200, {}, { "skynet-server-api": serverPortalDomain }) + .onHead(serverPortalUrl) + .replyOnce(200, {}, { "skynet-portal-api": portalDomain }); + + const domain = await serverClient.extractDomain(fullUrl); + + expect(domain).toEqual(expectedDomain); + } + ); + + it.each(serverCases)( + `should extract from full URL '%s' the app domain '%s' using portal '${serverPortalUrl}' and client on the portal '${portalUrl}'`, + async (fullUrl, expectedDomain) => { + const domain = await client.extractDomain(fullUrl); + + expect(domain).toEqual(expectedDomain); + } + ); + + it("should just return the full domain if the full domain does not contain the portal domain", async () => { + const inputDomain = "crqa.hns.asdf.net"; + + const domain = await client.extractDomain(inputDomain); + + expect(domain).toEqual(inputDomain); }); }); @@ -49,3 +118,64 @@ 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", () => { + let mock: MockAdapter; + + beforeEach(() => { + mock = new MockAdapter(axios); + }); + + 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'", + async (portalDomain, currentUrl, preferredPortalUrl, expectedResult) => { + const portalUrl = `https://${portalDomain}`; + // Responses for regular portal. + mock + .onHead(portalUrl) + .replyOnce(200, {}, { "skynet-server-api": `us-va-1.${portalDomain}` }) + .onHead(portalUrl) + .replyOnce(200, {}, { "skynet-portal-api": portalDomain }); + + const client = new SkynetClient(portalUrl); + const result = await getRedirectUrlOnPreferredPortal(client, 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), false), + // Test cases where the portals are different. + ...composeTestCases(combineArrays(portal1Urls, portal2Urls), true), + ...composeTestCases(combineArrays(portal2Urls, portal1Urls), true), + ].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); + }); +}); diff --git a/src/mysky/utils.ts b/src/mysky/utils.ts index 14deb867..a66e48d6 100644 --- a/src/mysky/utils.ts +++ b/src/mysky/utils.ts @@ -1,9 +1,13 @@ import { SkynetClient } from "../client"; +import { trimForwardSlash, trimSuffix } from "../utils/string"; import { getFullDomainUrlForPortal, extractDomainForPortal, ensureUrlPrefix } from "../utils/url"; /** - * Constructs the full URL for the given component domain, - * e.g. "dac.hns" => "https://dac.hns.siasky.net" + * Constructs the full URL for the given component domain. + * + * Examples: + * + * ("dac.hns") => "https://dac.hns.siasky.net" * * @param this - SkynetClient * @param domain - Component domain. @@ -16,16 +20,60 @@ export async function getFullDomainUrl(this: SkynetClient, domain: string): Prom } /** - * Extracts the domain from the current portal URL, - * e.g. ("dac.hns.siasky.net") => "dac.hns" + * Gets the URL for the current skapp on the preferred portal, if we're not on + * the preferred portal already. + * + * @param client - The Skynet client. + * @param currentUrl - The current page URL. + * @param preferredPortalUrl - The preferred portal URL. + * @returns - The URL for the current skapp on the preferred portal. + */ +export async function getRedirectUrlOnPreferredPortal( + client: SkynetClient, + currentUrl: string, + preferredPortalUrl: string +): Promise { + // Get the current skapp on the preferred portal. + const skappDomain = await client.extractDomain(currentUrl); + return getFullDomainUrlForPortal(preferredPortalUrl, skappDomain); +} + +/** + * Extracts the domain from the current portal URL. Will take into account the + * server domain if it is found in the current portal URL. + * + * Examples: + * + * ("dac.hns.siasky.net") => "dac.hns" + * ("dac.hns.us-va-1.siasky.net") => "dac.hns" * * @param this - SkynetClient * @param fullDomain - Full URL. * @returns - The extracted domain. */ export async function extractDomain(this: SkynetClient, fullDomain: string): Promise { - const portalUrl = await this.portalUrl(); + fullDomain = trimForwardSlash(fullDomain); + // Check if the full domain contains a specific portal server. In that case, + // the extracted subdomain should not include the server. + // TODO: Could consolidate this and `resolvePortalUrl` into one network request. + const portalServerUrl = trimForwardSlash(await this.resolvePortalServerUrl()); + // Get the portal server domain. + let portalServerDomain; + try { + // Try to get the domain from a full URL. + const portalServerUrlObj = new URL(portalServerUrl); + portalServerDomain = portalServerUrlObj.hostname; + } catch (_) { + // If not a full URL, assume it is already a domain. + portalServerDomain = portalServerUrl; + } + if (fullDomain.endsWith(portalServerDomain)) { + return extractDomainForPortal(portalServerUrl, fullDomain); + } + + // Use the regular portal domain to extract out the subdomain. + const portalUrl = await this.resolvePortalUrl(); return extractDomainForPortal(portalUrl, fullDomain); } @@ -64,3 +112,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 currentDomain - The current domain. + * @param preferredPortalUrl - The preferred portal URL. + * @returns - Whether the two URLs are equal for the purposes of redirecting. + */ +export function shouldRedirectToPreferredPortalUrl(currentDomain: string, preferredPortalUrl: string): boolean { + // Strip protocol and trailing slash (case-insensitive). + currentDomain = trimSuffix(currentDomain.replace(/https:\/\/|http:\/\//i, ""), "/"); + preferredPortalUrl = trimSuffix(preferredPortalUrl.replace(/https:\/\/|http:\/\//i, ""), "/"); + return !currentDomain.endsWith(preferredPortalUrl); +} diff --git a/src/request.ts b/src/request.ts index acd9d574..dd615190 100644 --- a/src/request.ts +++ b/src/request.ts @@ -76,11 +76,22 @@ export async function buildRequestUrl( return url; } +/** + * The error type returned by `executeRequestError`. + */ export class ExecuteRequestError extends Error { originalError: AxiosError; responseStatus: number | null; responseMessage: string | null; + /** + * Creates an `ExecuteRequestError`. + * + * @param message - The error message. + * @param axiosError - The original axios error. + * @param responseStatus - The response status, if found in the original error. + * @param responseMessage - The response message, if found in the original error. + */ constructor(message: string, axiosError: AxiosError, responseStatus: number | null, responseMessage: string | null) { super(message); this.originalError = axiosError; diff --git a/src/revision_cache.ts b/src/revision_cache.ts index 6971889c..a396696a 100644 --- a/src/revision_cache.ts +++ b/src/revision_cache.ts @@ -10,6 +10,9 @@ export class RevisionNumberCache { private mutex: Mutex; private cache: { [key: string]: CachedRevisionNumber }; + /** + * Creates the `RevisionNumberCache`. + */ constructor() { this.mutex = new Mutex(); this.cache = {}; @@ -86,6 +89,9 @@ export class CachedRevisionNumber { mutex: Mutex; revision: bigint; + /** + * Creates a `CachedRevisionNumber`. + */ constructor() { this.mutex = new Mutex(); this.revision = BigInt(-1); diff --git a/src/skylink/sia.ts b/src/skylink/sia.ts index 0cbdb687..d6d9f5d2 100644 --- a/src/skylink/sia.ts +++ b/src/skylink/sia.ts @@ -30,12 +30,27 @@ export const RAW_SKYLINK_SIZE = 34; */ export const EMPTY_SKYLINK = new Uint8Array(RAW_SKYLINK_SIZE); +/** + * An object containing the skylink bitfield and merkleroot. Corresponds to the + * `Skylink` struct found in `skyd`. + */ export class SiaSkylink { + /** + * Creates a `SiaSkylink`. + * + * @param bitfield - The bitfield. + * @param merkleRoot - The merkle root. + */ constructor(public bitfield: number, public merkleRoot: Uint8Array) { validateNumber("bitfield", bitfield, "constructor parameter"); validateUint8ArrayLen("merkleRoot", merkleRoot, "constructor parameter", 32); } + /** + * Returns the byte array encoding of the skylink. + * + * @returns - The byte array encoding. + */ toBytes(): Uint8Array { const buf = new ArrayBuffer(RAW_SKYLINK_SIZE); const view = new DataView(buf); @@ -47,6 +62,11 @@ export class SiaSkylink { return uint8Bytes; } + /** + * Converts the skylink to a string. + * + * @returns - The skylink as a string. + */ toString(): string { return encodeSkylinkBase64(this.toBytes()); } @@ -162,9 +182,23 @@ export function newSpecifier(name: string): Uint8Array { const PUBLIC_KEY_SIZE = 32; +/** + * The sia public key object. Corresponds to the struct in `skyd`. + */ class SiaPublicKey { + /** + * Creates a `SiaPublicKey`. + * + * @param algorithm - The algorithm. + * @param key - The public key, as a byte array. + */ constructor(public algorithm: Uint8Array, public key: Uint8Array) {} + /** + * Encodes the public key as a byte array. + * + * @returns - The encoded byte array. + */ marshalSia(): Uint8Array { const bytes = new Uint8Array(SPECIFIER_LEN + 8 + PUBLIC_KEY_SIZE); bytes.set(this.algorithm); diff --git a/src/utils/options.ts b/src/utils/options.ts index adb5536b..6f577f47 100644 --- a/src/utils/options.ts +++ b/src/utils/options.ts @@ -1,10 +1,12 @@ import { CustomClientOptions } from "../client"; +// TODO: Unnecessary, remove. /** * Base custom options for methods hitting the API. */ export type BaseCustomOptions = CustomClientOptions; +// TODO: Move to client.ts. /** * The default base custom options. */ @@ -14,6 +16,7 @@ export const DEFAULT_BASE_OPTIONS = { customCookie: "", onDownloadProgress: undefined, onUploadProgress: undefined, + loginFn: undefined, }; /** diff --git a/src/utils/url.test.ts b/src/utils/url.test.ts index f1f51ba2..ca002483 100644 --- a/src/utils/url.test.ts +++ b/src/utils/url.test.ts @@ -1,15 +1,15 @@ -import { combineStrings } from "../../utils/testing"; +import { composeTestCases, combineStrings } from "../../utils/testing"; import { trimPrefix, trimSuffix } from "./string"; import { + addUrlSubdomain, addUrlQuery, - defaultSkynetPortalUrl, + DEFAULT_SKYNET_PORTAL_URL, getFullDomainUrlForPortal, extractDomainForPortal, makeUrl, - addUrlSubdomain, } from "./url"; -const portalUrl = defaultSkynetPortalUrl; +const portalUrl = DEFAULT_SKYNET_PORTAL_URL; const skylink = "XABvi7JtJbQSMAcDwnUnmp2FKDPjg8_tTTFP4BwMSxVdEg"; const skylinkBase32 = "bg06v2tidkir84hg0s1s4t97jaeoaa1jse1svrad657u070c9calq4g"; @@ -34,7 +34,7 @@ describe("addUrlSubdomain", () => { }); describe("addUrlQuery", () => { - const parts: Array<[string, { [key: string]: string | undefined }, string]> = [ + const cases: Array<[string, { [key: string]: string | undefined }, string]> = [ [portalUrl, { filename: "test" }, `${portalUrl}/?filename=test`], [`${portalUrl}/`, { attachment: "true" }, `${portalUrl}/?attachment=true`], [portalUrl, { attachment: "true" }, `${portalUrl}/?attachment=true`], @@ -46,7 +46,7 @@ describe("addUrlQuery", () => { [`${portalUrl}#foobar`, { foo: "bar" }, `${portalUrl}/?foo=bar#foobar`], ]; - it.each(parts)( + it.each(cases)( "Should call addUrlQuery with URL %s and parameters %s and form URL %s", (inputUrl, params, expectedUrl) => { const url = addUrlQuery(inputUrl, params); @@ -55,123 +55,165 @@ describe("addUrlQuery", () => { ); }); -/** - * Adds the given inputs with the expected output as test cases to the array. - * - * @param cases - The test cases array to append to. - * @param inputs - The given inputs. - * @param expected - The expected output for all the inputs. - */ -function addTestCases(cases: Array<[string, string]>, inputs: Array, expected: string): void { - const mappedInputs: Array<[string, string]> = inputs.map((input) => [input, expected]); - cases.push(...mappedInputs); -} - describe("getFullDomainUrlForPortal", () => { - const domains: Array<[string, string]> = []; // The casing in the path should not be affected by URL parsing. const path = "/path/File.json"; const expectedUrl = "https://dac.hns.siasky.net"; // Test with uppercase to ensure that it is properly converted to lowercase. const hnsDomains = combineStrings(["", "sia:", "sia://", "SIA:", "SIA://"], ["dac.hns", "DAC.HNS"], ["", "/"]); - addTestCases(domains, hnsDomains, expectedUrl); const expectedPathUrl = `${expectedUrl}${path}`; const hnsPathDomains = combineStrings(hnsDomains, [path]); - addTestCases(domains, hnsPathDomains, expectedPathUrl); const expectedSkylinkUrl = `https://${skylinkBase32}.siasky.net`; const skylinkDomains = combineStrings(["", "sia:", "sia://"], [skylinkBase32], ["", "/"]); - addTestCases(domains, skylinkDomains, expectedSkylinkUrl); const expectedSkylinkPathUrl = `${expectedSkylinkUrl}${path}`; const skylinkPathDomains = combineStrings(skylinkDomains, [path]); - addTestCases(domains, skylinkPathDomains, expectedSkylinkPathUrl); const expectedLocalhostUrl = `localhost`; const localhostDomains = combineStrings(["", "sia:", "sia://"], ["localhost"], ["", "/"]); - addTestCases(domains, localhostDomains, expectedLocalhostUrl); const expectedLocalhostPathUrl = `${expectedLocalhostUrl}${path}`; const localhostPathDomains = combineStrings(localhostDomains, [path]); - addTestCases(domains, localhostPathDomains, expectedLocalhostPathUrl); - it.each(domains)("domain %s should return correctly formed full URL %s", (domain, fullUrl) => { - const url = getFullDomainUrlForPortal(portalUrl, domain); - expect(url).toEqual(fullUrl); - }); + const cases: Array<[string, string]> = [ + ...composeTestCases(hnsDomains, expectedUrl), + ...composeTestCases(hnsPathDomains, expectedPathUrl), + ...composeTestCases(skylinkDomains, expectedSkylinkUrl), + ...composeTestCases(skylinkPathDomains, expectedSkylinkPathUrl), + ...composeTestCases(localhostDomains, expectedLocalhostUrl), + ...composeTestCases(localhostPathDomains, expectedLocalhostPathUrl), + ]; + const xyzCases = cases.map(([domain, fullUrl]) => [domain, fullUrl.replace("siasky.net", "siasky.xyz")]); + + it.each(cases)( + `domain '%s' should return correctly formed full URL '%s' using portal '${portalUrl}'`, + (domain, fullUrl) => { + const url = getFullDomainUrlForPortal(portalUrl, domain); + expect(url).toEqual(fullUrl); + } + ); + + it.each(xyzCases)( + `domain '%s' should return correctly formed full URL '%s' using portal 'siasky.xyz'`, + (domain, fullUrl) => { + const url = getFullDomainUrlForPortal("siasky.xyz", domain); + expect(url).toEqual(fullUrl); + } + ); }); describe("extractDomainForPortal", () => { - const urls: Array<[string, string]> = []; // The casing in the path should not be affected by URL parsing. const path = "/path/File.json"; // Add simple HNS domain URLs. const expectedDomain = "dac.hns"; // Test with uppercase to ensure that it is properly converted to lowercase by the URL parsing. - const hnsUrls = combineStrings(["", "https://", "HTTPS://"], ["dac.hns.siasky.net", "DAC.HNS.SIASKY.NET"], ["", "/"]); - addTestCases(urls, hnsUrls, expectedDomain); + const hnsUrls = combineStrings( + ["", "http://", "https://", "HTTPS://"], + ["dac.hns.siasky.net", "DAC.HNS.SIASKY.NET"], + ["", "/"] + ); // Add HNS domain URLs with a path. const expectedPathDomain = `${expectedDomain}${path}`; const hnsPathUrls = combineStrings(hnsUrls, [path]); - addTestCases(urls, hnsPathUrls, expectedPathDomain); // Add skylink domain URLs. const expectedSkylinkDomain = skylinkBase32; const skylinkUrls = combineStrings(["", "https://"], [`${skylinkBase32}.siasky.net`], ["", "/"]); - addTestCases(urls, skylinkUrls, expectedSkylinkDomain); // Add skylink domain URLs with a path. const expectedSkylinkPathDomain = `${expectedSkylinkDomain}${path}`; const skylinkPathUrls = combineStrings(skylinkUrls, [path]); - addTestCases(urls, skylinkPathUrls, expectedSkylinkPathDomain); // Add localhost domain URLs. const expectedLocalhostDomain = "localhost"; const localhostUrls = combineStrings(["", "https://"], ["localhost"], ["", "/"]); - addTestCases(urls, localhostUrls, expectedLocalhostDomain); // Add localhost domain URLs with a path. const expectedLocalhostPathDomain = `${expectedLocalhostDomain}${path}`; const localhostPathUrls = combineStrings(localhostUrls, [path]); - addTestCases(urls, localhostPathUrls, expectedLocalhostPathDomain); // Add traditional URLs. const expectedTraditionalUrlDomain = "traditionalurl.com"; const traditionalUrls = combineStrings(["", "https://"], ["traditionalUrl.com"], ["", "/"]); - addTestCases(urls, traditionalUrls, expectedTraditionalUrlDomain); // Add traditional URLs with a path. const expectedTraditionalUrlPathDomain = `${expectedTraditionalUrlDomain}${path}`; const traditionalPathUrls = combineStrings(traditionalUrls, [path]); - addTestCases(urls, traditionalPathUrls, expectedTraditionalUrlPathDomain); // Add traditional URLs with subdomains. const expectedTraditionalUrlSubdomain = "subdomain.traditionalurl.com"; const traditionalSubdomainUrls = combineStrings(["", "https://"], ["subdomain.traditionalUrl.com"], ["", "/"]); - addTestCases(urls, traditionalSubdomainUrls, expectedTraditionalUrlSubdomain); - it.each(urls)("should extract from full url %s the domain %s", (fullDomain, domain) => { - const receivedDomain = extractDomainForPortal(portalUrl, fullDomain); - expect(receivedDomain).toEqual(domain); - }); + const cases: Array<[string, string]> = [ + ...composeTestCases(hnsUrls, expectedDomain), + ...composeTestCases(hnsPathUrls, expectedPathDomain), + ...composeTestCases(skylinkUrls, expectedSkylinkDomain), + ...composeTestCases(skylinkPathUrls, expectedSkylinkPathDomain), + ...composeTestCases(localhostUrls, expectedLocalhostDomain), + ...composeTestCases(localhostPathUrls, expectedLocalhostPathDomain), + ...composeTestCases(traditionalUrls, expectedTraditionalUrlDomain), + ...composeTestCases(traditionalPathUrls, expectedTraditionalUrlPathDomain), + ...composeTestCases(traditionalSubdomainUrls, expectedTraditionalUrlSubdomain), + ]; + const xyzCases = cases.map(([fullDomain, domain]) => [ + fullDomain.replace("siasky.net", "siasky.xyz").replace("SIASKY.NET", "SIASKY.XYZ"), + domain, + ]); + const serverCases = cases.map(([fullDomain, domain]) => [ + fullDomain.replace("siasky.net", "us-va-1.siasky.net").replace("SIASKY.NET", "US-VA-1.SIASKY.NET"), + domain, + ]); + + it.each(cases)( + `should extract from full URL '%s' the app domain '%s' using portal '${portalUrl}'`, + (fullDomain, domain) => { + const receivedDomain = extractDomainForPortal(portalUrl, fullDomain); + expect(receivedDomain).toEqual(domain); + } + ); + + it.each(xyzCases)( + `should extract from full URL '%s' the app domain '%s' using portal 'siasky.xyz'`, + (fullDomain, domain) => { + const receivedDomain = extractDomainForPortal("siasky.xyz", fullDomain); + expect(receivedDomain).toEqual(domain); + } + ); + + it.each(serverCases)( + `should extract from full URL '%s' the app domain '%s' using portal 'us-va-1.siasky.net'`, + (fullDomain, domain) => { + const receivedDomain = extractDomainForPortal("us-va-1.siasky.net", fullDomain); + expect(receivedDomain).toEqual(domain); + } + ); }); describe("makeUrl", () => { - it("should return correctly formed URLs", () => { - expect(makeUrl(portalUrl, "/")).toEqual(`${portalUrl}/`); - expect(makeUrl(portalUrl, "/skynet")).toEqual(`${portalUrl}/skynet`); - expect(makeUrl(portalUrl, "/skynet/")).toEqual(`${portalUrl}/skynet/`); - - expect(makeUrl(portalUrl, "/", skylink)).toEqual(`${portalUrl}/${skylink}`); - expect(makeUrl(portalUrl, "/skynet", skylink)).toEqual(`${portalUrl}/skynet/${skylink}`); - expect(makeUrl(portalUrl, "//skynet/", skylink)).toEqual(`${portalUrl}/skynet/${skylink}`); - expect(makeUrl(portalUrl, "/skynet/", `${skylink}?foo=bar`)).toEqual(`${portalUrl}/skynet/${skylink}?foo=bar`); - expect(makeUrl(portalUrl, `${skylink}/?foo=bar`)).toEqual(`${portalUrl}/${skylink}?foo=bar`); - expect(makeUrl(portalUrl, `${skylink}#foobar`)).toEqual(`${portalUrl}/${skylink}#foobar`); + const cases = [ + // Some basic cases. + [[portalUrl, "/"], `${portalUrl}/`], + [[portalUrl, "/skynet"], `${portalUrl}/skynet`], + [[portalUrl, "/skynet/"], `${portalUrl}/skynet/`], + // Test passing in a URL without the protocol prefix. + [["siasky.net", "/"], `${portalUrl}/`], + // Some more advanced cases. + [[portalUrl, "/", skylink], `${portalUrl}/${skylink}`], + [[portalUrl, "/skynet", skylink], `${portalUrl}/skynet/${skylink}`], + [[portalUrl, "//skynet/", skylink], `${portalUrl}/skynet/${skylink}`], + [[portalUrl, "/skynet/", `${skylink}?foo=bar`], `${portalUrl}/skynet/${skylink}?foo=bar`], + [[portalUrl, `${skylink}/?foo=bar`], `${portalUrl}/${skylink}?foo=bar`], + [[portalUrl, `${skylink}#foobar`], `${portalUrl}/${skylink}#foobar`], + ]; + + it.each(cases)("makeUrl with inputs %s should equal '%s'", (inputs, expectedOutput) => { + expect(makeUrl(...inputs)).toEqual(expectedOutput); }); it("Should throw if no args provided", () => { diff --git a/src/utils/url.ts b/src/utils/url.ts index 5656c988..f1ce695a 100644 --- a/src/utils/url.ts +++ b/src/utils/url.ts @@ -1,7 +1,8 @@ +import { ensureUrl } from "skynet-mysky-utils"; import urljoin from "url-join"; import parse from "url-parse"; -import { trimForwardSlash, trimPrefix, trimSuffix, trimUriPrefix } from "./string"; +import { trimForwardSlash, trimSuffix, trimUriPrefix } from "./string"; import { throwValidationError, validateString } from "./validation"; export const DEFAULT_SKYNET_PORTAL_URL = "https://siasky.net"; @@ -100,20 +101,14 @@ export function addUrlQuery(url: string, query: { [key: string]: string | undefi * @returns - The URL. */ export function ensureUrlPrefix(url: string): string { - if (url.startsWith("http://") || url.startsWith("https://")) { - return url; - } - if (url.startsWith("http:")) { - return `http://${trimPrefix(url, "http:")}`; - } - if (url.startsWith("https:")) { - return `https://${trimPrefix(url, "https:")}`; - } - if (url === "localhost") { return "http://localhost/"; } - return `https://${url}`; + + if (!/^https?:(\/\/)?/i.test(url)) { + return `https://${url}`; + } + return url; } /** @@ -127,7 +122,7 @@ export function makeUrl(...args: string[]): string { if (args.length === 0) { throwValidationError("args", args, "parameter", "non-empty"); } - return args.reduce((acc, cur) => urljoin(acc, cur)); + return ensureUrl(args.reduce((acc, cur) => urljoin(acc, cur))); } /** @@ -142,7 +137,11 @@ export function getFullDomainUrlForPortal(portalUrl: string, domain: string): st validateString("portalUrl", portalUrl, "parameter"); validateString("domain", domain, "parameter"); - domain = trimUriPrefix(domain, uriSkynetPrefix); + // Normalize the portalURL. + portalUrl = ensureUrlPrefix(trimUriPrefix(portalUrl, "http://")); + + // Normalize the domain. + domain = trimUriPrefix(domain, URI_SKYNET_PREFIX); domain = trimForwardSlash(domain); // Split on first / to get the path. @@ -195,7 +194,7 @@ export function extractDomainForPortal(portalUrl: string, fullDomain: string): s } // Get the portal domain. - const portalUrlObj = new URL(portalUrl); + const portalUrlObj = new URL(ensureUrlPrefix(portalUrl)); const portalDomain = trimForwardSlash(portalUrlObj.hostname); // Remove the portal domain from the domain. diff --git a/tsconfig.build.json b/tsconfig.build.json index 376f3ca1..f7fc63ab 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -6,8 +6,10 @@ "declaration": true, "declarationMap": true, "isolatedModules": true, - "moduleResolution": "node", + + "lib": ["dom", "es2020"], "target": "es2019", + "moduleResolution": "node", "outDir": "dist/mjs", "types": ["node", "jest"], "typeRoots": ["./types", "./node_modules/@types"] diff --git a/tsconfig.json b/tsconfig.json index c1be2556..4ed8e8e4 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,13 +1,16 @@ { "compilerOptions": { "downlevelIteration": true, - "lib": ["dom", "es2020"], "noEmit": true, "esModuleInterop": true, "strict": true, "noImplicitAny": true, "declaration": true, "isolatedModules": true, + + "lib": ["dom", "es2020"], + "target": "es2019", + "moduleResolution": "node", "types": ["node", "jest"], "typeRoots": ["./types", "./node_modules/@types"], "strictNullChecks": true diff --git a/utils/testing.test.ts b/utils/testing.test.ts index a6b1db51..a6090adf 100644 --- a/utils/testing.test.ts +++ b/utils/testing.test.ts @@ -1,4 +1,27 @@ -import { combineStrings, randomUnicodeString } from "./testing"; +import { combineArrays, combineStrings, randomUnicodeString } from "./testing"; + +describe("combineArrays", () => { + it("should permute the given arrays from each input array of arrays", () => { + const inputArrays = [ + ["a", "b"], + ["x", "y"], + ["1", "2"], + ]; + const expectedPermutations = [ + ["a", "x", "1"], + ["a", "x", "2"], + ["a", "y", "1"], + ["a", "y", "2"], + ["b", "x", "1"], + ["b", "x", "2"], + ["b", "y", "1"], + ["b", "y", "2"], + ]; + + const permutations = combineArrays(...inputArrays); + expect(permutations).toEqual(expectedPermutations); + }); +}); describe("combineStrings", () => { it("should permute the given strings from each input string array", () => { diff --git a/utils/testing.ts b/utils/testing.ts index 651133bf..dae8c7e2 100644 --- a/utils/testing.ts +++ b/utils/testing.ts @@ -3,6 +3,32 @@ import parse from "url-parse"; import { trimForwardSlash } from "../src/utils/string"; +/** + * Returns a composed array with the given inputs and the expected output. + * + * @param inputs - The given inputs. + * @param expected - The expected output for all the inputs. + * @returns - The array of composed test cases. + */ +export function composeTestCases(inputs: Array, expected: U): Array<[T, U]> { + return inputs.map((input) => [input, expected]); +} + +/** + * Returns an array of arrays of all possible permutations by picking one + * element out of each of the input arrays. + * + * @param arrays - Array of arrays. + * @returns - Array of arrays of all possible permutations. + * @see {@link https://gist.github.com/ssippe/1f92625532eef28be6974f898efb23ef#gistcomment-3530882} + */ +export function combineArrays(...arrays: Array>): Array> { + return arrays.reduce( + (accArrays, array) => accArrays.flatMap((accArray) => array.map((value) => [...accArray, value])), + [[]] + ); +} + /** * Returns an array of strings of all possible permutations by picking one * string out of each of the input string arrays. @@ -11,9 +37,7 @@ import { trimForwardSlash } from "../src/utils/string"; * @returns - Array of strings of all possible permutations. */ export function combineStrings(...arrays: Array>): Array { - return arrays.reduce((acc, array) => { - return acc.map((first) => array.map((second) => first.concat(second))).reduce((acc, cases) => [...acc, ...cases]); - }); + return arrays.reduce((acc, array) => acc.flatMap((first: string) => array.map((second) => first.concat(second)))); } /**