diff --git a/src/components/common/EnableDisable.tsx b/src/components/common/EnableDisable.tsx index 2ceeb7b..266aba7 100644 --- a/src/components/common/EnableDisable.tsx +++ b/src/components/common/EnableDisable.tsx @@ -15,7 +15,10 @@ import { WithNamespaces, withNamespaces } from "react-i18next"; import NavButton from "./NavButton"; import NavDropdown from "./NavDropdown"; import { StatusContext } from "./context/StatusContext"; -import { CancelablePromise, makeCancelable } from "../../util"; +import { + CancelablePromise, + makeCancelable +} from "../../util/CancelablePromise"; import api from "../../util/api"; import { Button, @@ -49,7 +52,7 @@ class EnableDisable extends Component { customMultiplier: 60 }; - private updateHandler: CancelablePromise | undefined; + private updateHandler: CancelablePromise | undefined; /** * Convert a status action into a status. ex. "enable" -> "enabled" diff --git a/src/components/common/WithAPIData.tsx b/src/components/common/WithAPIData.tsx index 3e72e0a..2a165b3 100644 --- a/src/components/common/WithAPIData.tsx +++ b/src/components/common/WithAPIData.tsx @@ -9,7 +9,11 @@ * Please see LICENSE file for your rights under this license. */ import { Component, ReactNode } from "react"; -import { CancelablePromise, ignoreCancel, makeCancelable } from "../../util"; +import { + CancelablePromise, + ignoreCancel, + makeCancelable +} from "../../util/CancelablePromise"; import { Err, Ok, Result } from "../../util/result"; export interface WithAPIDataProps { diff --git a/src/components/dashboard/ClientsGraph.tsx b/src/components/dashboard/ClientsGraph.tsx index 17837ae..3ad42f2 100644 --- a/src/components/dashboard/ClientsGraph.tsx +++ b/src/components/dashboard/ClientsGraph.tsx @@ -12,7 +12,7 @@ import React, { Component, RefObject } from "react"; import ReactDOM from "react-dom"; import { Line } from "react-chartjs-2"; import { WithNamespaces, withNamespaces } from "react-i18next"; -import { getIntervalForRange, padNumber } from "../../util"; +import { getIntervalForRange, padNumber } from "../../util/graphUtils"; import api from "../../util/api"; import ChartTooltip from "./ChartTooltip"; import { WithAPIData } from "../common/WithAPIData"; diff --git a/src/components/dashboard/QueriesGraph.tsx b/src/components/dashboard/QueriesGraph.tsx index e321bb2..759bfb9 100644 --- a/src/components/dashboard/QueriesGraph.tsx +++ b/src/components/dashboard/QueriesGraph.tsx @@ -10,7 +10,7 @@ import React, { Component } from "react"; import { WithNamespaces, withNamespaces } from "react-i18next"; -import { getIntervalForRange, padNumber } from "../../util"; +import { getIntervalForRange, padNumber } from "../../util/graphUtils"; import api from "../../util/api"; import { WithAPIData } from "../common/WithAPIData"; import { ChartData, ChartOptions, TimeUnit } from "chart.js"; diff --git a/src/components/list/ListPage.tsx b/src/components/list/ListPage.tsx index 512b838..336beca 100644 --- a/src/components/list/ListPage.tsx +++ b/src/components/list/ListPage.tsx @@ -13,7 +13,11 @@ import { WithNamespaces, withNamespaces } from "react-i18next"; import DomainInput from "./DomainInput"; import Alert, { AlertType } from "../common/Alert"; import DomainList from "./DomainList"; -import { CancelablePromise, ignoreCancel, makeCancelable } from "../../util"; +import { + CancelablePromise, + ignoreCancel, + makeCancelable +} from "../../util/CancelablePromise"; export interface ListPageProps extends WithNamespaces { title: string; diff --git a/src/components/log/QueryLog.tsx b/src/components/log/QueryLog.tsx index a2b9421..16aa3fb 100644 --- a/src/components/log/QueryLog.tsx +++ b/src/components/log/QueryLog.tsx @@ -20,18 +20,18 @@ import i18next from "i18next"; import { WithNamespaces, withNamespaces } from "react-i18next"; import debounce from "lodash.debounce"; import moment from "moment"; -import { - CancelablePromise, - ignoreCancel, - makeCancelable, - padNumber -} from "../../util"; +import { padNumber } from "../../util/graphUtils"; import api from "../../util/api"; import { dateRanges } from "../../util/dateRanges"; import { TranslatedTimeRangeSelector } from "../dashboard/TimeRangeSelector"; import { TimeRange } from "../common/context/TimeRangeContext"; import "react-table/react-table.css"; import "bootstrap-daterangepicker/daterangepicker.css"; +import { + CancelablePromise, + ignoreCancel, + makeCancelable +} from "../../util/CancelablePromise"; export interface QueryLogState { history: Array; diff --git a/src/components/settings/DHCPInfo.tsx b/src/components/settings/DHCPInfo.tsx index 590efe8..4a5678a 100644 --- a/src/components/settings/DHCPInfo.tsx +++ b/src/components/settings/DHCPInfo.tsx @@ -10,7 +10,11 @@ import React, { ChangeEvent, Component, FormEvent } from "react"; import { WithNamespaces, withNamespaces } from "react-i18next"; -import { CancelablePromise, ignoreCancel, makeCancelable } from "../../util"; +import { + CancelablePromise, + ignoreCancel, + makeCancelable +} from "../../util/CancelablePromise"; import api from "../../util/api"; import { Button, @@ -52,7 +56,7 @@ class DHCPInfo extends Component { }; private loadHandler: undefined | CancelablePromise; - private updateHandler: undefined | CancelablePromise; + private updateHandler: undefined | CancelablePromise; loadDHCPInfo = () => { this.loadHandler = makeCancelable(api.getDHCPInfo()); diff --git a/src/components/settings/DNSInfo.tsx b/src/components/settings/DNSInfo.tsx index f2a4d15..a6ca83e 100644 --- a/src/components/settings/DNSInfo.tsx +++ b/src/components/settings/DNSInfo.tsx @@ -10,7 +10,11 @@ import React, { Component, FormEvent } from "react"; import { WithNamespaces, withNamespaces } from "react-i18next"; -import { CancelablePromise, ignoreCancel, makeCancelable } from "../../util"; +import { + CancelablePromise, + ignoreCancel, + makeCancelable +} from "../../util/CancelablePromise"; import api from "../../util/api"; import DnsList from "./DnsList"; import { Button, Col, Form, FormGroup } from "reactstrap"; @@ -52,7 +56,7 @@ class DNSInfo extends Component { }; private loadHandler: undefined | CancelablePromise; - private updateHandler: undefined | CancelablePromise; + private updateHandler: undefined | CancelablePromise; loadDNSInfo = () => { this.loadHandler = makeCancelable(api.getDNSInfo()); diff --git a/src/components/settings/PreferenceSettings.tsx b/src/components/settings/PreferenceSettings.tsx index 22ec7c3..4a6d242 100644 --- a/src/components/settings/PreferenceSettings.tsx +++ b/src/components/settings/PreferenceSettings.tsx @@ -10,7 +10,11 @@ import React, { ChangeEvent, Component, FormEvent } from "react"; import { WithNamespaces, withNamespaces } from "react-i18next"; -import { CancelablePromise, ignoreCancel, makeCancelable } from "../../util"; +import { + CancelablePromise, + ignoreCancel, + makeCancelable +} from "../../util/CancelablePromise"; import api from "../../util/api"; import Alert, { AlertType } from "../common/Alert"; import { Button, Col, Form, FormGroup, Input, Label } from "reactstrap"; @@ -48,7 +52,7 @@ class PreferenceSettings extends Component< }; private loadHandler: undefined | CancelablePromise; - private updateHandler: undefined | CancelablePromise; + private updateHandler: undefined | CancelablePromise; loadPreferences = () => { this.loadHandler = makeCancelable(api.getPreferences()); diff --git a/src/config.development.tsx b/src/config.development.tsx index 3bfa9cf..d9e4d4f 100644 --- a/src/config.development.tsx +++ b/src/config.development.tsx @@ -12,5 +12,6 @@ import { Config } from "./config"; export default { developmentMode: true, - fakeAPI: false + fakeAPI: false, + apiPath: process.env.PUBLIC_URL + "/api" } as Config; diff --git a/src/config.production.tsx b/src/config.production.tsx index 54fcb54..5bfc186 100644 --- a/src/config.production.tsx +++ b/src/config.production.tsx @@ -12,5 +12,6 @@ import { Config } from "./config"; export default { developmentMode: false, - fakeAPI: false + fakeAPI: false, + apiPath: process.env.PUBLIC_URL + "/api" } as Config; diff --git a/src/config.tsx b/src/config.tsx index 5606de8..db0dccd 100644 --- a/src/config.tsx +++ b/src/config.tsx @@ -14,6 +14,7 @@ import productionConfig from "./config.production"; export interface Config { developmentMode: boolean; fakeAPI: boolean; + apiPath: string; } let config: Config; @@ -26,6 +27,7 @@ if (process.env.NODE_ENV === "development") { if (process.env.REACT_APP_FAKE_API) { config.fakeAPI = true; + config.apiPath = process.env.PUBLIC_URL + "/fakeAPI"; } export default config; diff --git a/src/index.tsx b/src/index.tsx index 81b85c6..fcb1712 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -19,7 +19,7 @@ import "./scss/style.css"; import Full from "./containers/Full"; import api from "./util/api"; import { setupI18n } from "./util/i18n"; -import { getBasePath } from "./util"; +import { getBasePath } from "./util/basePath"; // Before rendering anything, check if there is a session cookie. // Note: the user could have an old session, so the first API call diff --git a/src/util/index.tsx b/src/util/CancelablePromise.tsx similarity index 53% rename from src/util/index.tsx rename to src/util/CancelablePromise.tsx index b3da291..2bda5bf 100644 --- a/src/util/index.tsx +++ b/src/util/CancelablePromise.tsx @@ -3,55 +3,22 @@ * Network-wide ad blocking via your own hardware. * * Web Interface - * Various utilities + * Wrap promises to make them cancelable * * This file is copyright under the latest version of the EUPL. * Please see LICENSE file for your rights under this license. */ -import { TimeRange } from "../components/common/context/TimeRangeContext"; - /** - * Pad a two digit number - * - * @param num the number - * @returns A padding number string + * A promise which can be canceled */ -export const padNumber = (num: number): string => { - return ("00" + num).substr(-2, 2); -}; - -/** - * Get the base path of the web interface. The API will inject a base element - * for this purpose, but if the web interface is not hosted by the API, it will - * fall back to the public URL set by Create React App. - * - * @returns The base path to use - */ -export const getBasePath = (): string => { - const baseElement = document.getElementsByTagName("base")[0]; - - if (baseElement) { - return new URL(baseElement.href).pathname; - } else { - return process.env.PUBLIC_URL; - } -}; - -/** - * Dynamically calculate a time interval so there are always 144 data points - * (144 so that every point represents 10 minutes when the range is 24 hours) - * - * @param range The range to find the interval for - */ -export const getIntervalForRange = (range: TimeRange): number => { - return Math.ceil((range.until.unix() - range.from.unix()) / 144); -}; - export interface CancelablePromise { promise: Promise; cancel: () => void; } +/** + * The options given to {@link makeCancelable} + */ export interface CancelableOptions { /** * The function to call to repeat the promise @@ -64,13 +31,19 @@ export interface CancelableOptions { interval: number; } +/** + * The error thrown when the {@link CancelablePromise} is canceled + */ +export interface CanceledError { + isCanceled: true; +} + /** * Make a promise cancelable and repeatable * * @param promise the promise * @param options the interval repeat options - * @returns {{promise: Promise, cancel(): void}} a handle on the cancelable - * promise + * @returns a cancelable promise */ export function makeCancelable( promise: Promise, @@ -80,17 +53,24 @@ export function makeCancelable( let repeatId: NodeJS.Timeout | null = null; const handle = ( - resolve: (value: any) => void, + resolve: (value: T) => void, reject: (error: any) => void, val: T, isError: boolean ) => { - if (hasCanceled) reject({ isCanceled: true }); - else { - if (isError) reject(val); - else resolve(val); + if (hasCanceled) { + reject({ isCanceled: true }); + return; + } - if (options) repeatId = setTimeout(options.repeat, options.interval); + if (isError) { + reject(val); + } else { + resolve(val); + } + + if (options) { + repeatId = setTimeout(options.repeat, options.interval); } }; diff --git a/src/util/__tests__/CancelablePromise.test.tsx b/src/util/__tests__/CancelablePromise.test.tsx new file mode 100644 index 0000000..77ef425 --- /dev/null +++ b/src/util/__tests__/CancelablePromise.test.tsx @@ -0,0 +1,102 @@ +/* Pi-hole: A black hole for Internet advertisements + * (c) 2019 Pi-hole, LLC (https://pi-hole.net) + * Network-wide ad blocking via your own hardware. + * + * Web Interface + * Tests for canceling promises + * + * This file is copyright under the latest version of the EUPL. + * Please see LICENSE file for your rights under this license. */ + +import { makeCancelable, ignoreCancel } from "../CancelablePromise"; + +describe("makeCancelable", () => { + const testValue = "test"; + const testError = "testError"; + const promise = Promise.resolve(testValue); + const promiseErr = Promise.reject(testError); + + it("passes through promises in the default case", async () => { + const cancelablePromise = makeCancelable(promise); + + await expect(cancelablePromise.promise).resolves.toEqual(testValue); + }); + + it("passes through errors in the default case", async () => { + const cancelablePromise = makeCancelable(promiseErr); + + await expect(cancelablePromise.promise).rejects.toEqual(testError); + }); + + it("rejects with cancel error if canceled", async () => { + const cancelablePromise = makeCancelable(promise); + + cancelablePromise.cancel(); + + await expect(cancelablePromise.promise).rejects.toEqual({ + isCanceled: true + }); + }); + + it("calls the repeat function after resolving", async () => { + jest.useFakeTimers(); + + const mockFunction = jest.fn(); + const interval = 1000; + const cancelablePromise = makeCancelable(promise, { + interval, + repeat: mockFunction + }); + + await expect(cancelablePromise.promise).resolves; + + expect(setTimeout).toHaveBeenCalledWith(mockFunction, interval); + }); + + it("calls the repeat function after rejecting", async () => { + jest.useFakeTimers(); + + const mockFunction = jest.fn(); + const interval = 1000; + const cancelablePromise = makeCancelable(promiseErr, { + interval, + repeat: mockFunction + }); + + await expect(cancelablePromise.promise).rejects.toEqual(testError); + + expect(setTimeout).toHaveBeenCalledWith(mockFunction, interval); + }); + + it("clears the timeout if canceled after resolving", async () => { + jest.useFakeTimers(); + + const mockFunction = jest.fn(); + const interval = 1000; + const cancelablePromise = makeCancelable(promise, { + interval, + repeat: mockFunction + }); + + await cancelablePromise.promise; + + cancelablePromise.cancel(); + + expect(clearTimeout).toHaveBeenCalled(); + }); +}); + +describe("ignoreCancel", () => { + it("passes through non-canceled errors", async () => { + const testError = "test"; + const promise = Promise.reject(testError); + + await expect(promise.catch(ignoreCancel)).rejects.toEqual(testError); + }); + + it("does not pass through canceled errors", async () => { + const canceledPromise = Promise.reject({ isCanceled: true }); + + await expect(canceledPromise.catch(ignoreCancel)).resolves; + }); +}); diff --git a/src/util/__tests__/api.test.tsx b/src/util/__tests__/api.test.tsx index 72b6ae3..dafd93e 100644 --- a/src/util/__tests__/api.test.tsx +++ b/src/util/__tests__/api.test.tsx @@ -3,21 +3,322 @@ * Network-wide ad blocking via your own hardware. * * Web Interface - * Utility function tests + * API service tests * * This file is copyright under the latest version of the EUPL. * Please see LICENSE file for your rights under this license. */ -import api from "../api"; +import { ApiClient } from "../api"; +import HttpClient from "../http"; +import { TimeRange } from "../../components/common/context/TimeRangeContext"; +import moment from "moment"; +import { Config } from "../../config"; -// This is a dumb test used to set up the next test, -// which checks that the logged in state is reset before each test -it("sets logged in to true", () => { - api.loggedIn = true; +// Test each endpoint function to make sure it is calling the right endpoint +// with the right data. +describe("ApiClient", () => { + // Services + let httpClient: HttpClient; + let api: ApiClient; - expect(api.loggedIn).toBeTruthy(); -}); + // Test response data + const getData = { test: "GET" }; + const postData = { test: "POST" }; + const putData = { test: "PUT" }; + const deleteData = { test: "DELETE" }; + const getPromise = Promise.resolve(getData); + const postPromise = Promise.resolve(postData); + const putPromise = Promise.resolve(putData); + const deletePromise = Promise.resolve(deleteData); -it("resets the logged in state for each test", () => { - expect(api.loggedIn).toBeFalsy(); + // Test data + const range: TimeRange = { + name: "Test time range", + from: moment("2019-04-12T01:03:17+00:00"), + until: moment("2019-04-13T01:03:17+00:00") + }; + const rangeParams = "from=1555030997&until=1555117397"; + const config: Config = { + developmentMode: true, + fakeAPI: true, + apiPath: "/admin/api" + }; + + beforeEach(() => { + // Create fresh services + httpClient = ({ + get: jest.fn(() => getPromise), + post: jest.fn(() => postPromise), + put: jest.fn(() => putPromise), + delete: jest.fn(() => deletePromise), + config + } as any) as HttpClient; + api = new ApiClient(httpClient); + }); + + describe("authentication calls", () => { + it("should call login endpoint with auth headers", async () => { + const key = "test"; + await expect(api.authenticate(key)).resolves.toEqual(getData); + expect(httpClient.get).toHaveBeenCalledWith("auth", { + headers: { "X-Pi-hole-Authenticate": key } + }); + }); + + it("should call logout endpoint", async () => { + await expect(api.logout()).resolves.toEqual(deleteData); + expect(httpClient.delete).toHaveBeenCalledWith("auth"); + }); + }); + + describe("statistics calls", () => { + it("should call summary endpoint", async () => { + await expect(api.getSummary()).resolves.toEqual(getData); + expect(httpClient.get).toHaveBeenCalledWith("stats/summary"); + }); + + it("should call history graph endpoint", async () => { + await expect(api.getHistoryGraph()).resolves.toEqual(getData); + expect(httpClient.get).toHaveBeenCalledWith("stats/overTime/history"); + }); + + it("should call clients graph endpoint", async () => { + await expect(api.getClientsGraph()).resolves.toEqual(getData); + expect(httpClient.get).toHaveBeenCalledWith("stats/overTime/clients"); + }); + + it("should call query types endpoint", async () => { + await expect(api.getQueryTypes()).resolves.toEqual(getData); + expect(httpClient.get).toHaveBeenCalledWith("stats/query_types"); + }); + + it("should call upstreams endpoint", async () => { + await expect(api.getUpstreams()).resolves.toEqual(getData); + expect(httpClient.get).toHaveBeenCalledWith("stats/upstreams"); + }); + + it("should call top domains endpoint", async () => { + await expect(api.getTopDomains()).resolves.toEqual(getData); + expect(httpClient.get).toHaveBeenCalledWith("stats/top_domains"); + }); + + it("should call top blocked endpoint (top_domains?blocked=true)", async () => { + config.fakeAPI = false; + await expect(api.getTopBlocked()).resolves.toEqual(getData); + expect(httpClient.get).toHaveBeenCalledWith( + "stats/top_domains?blocked=true" + ); + }); + + it("should call top blocked endpoint (top_blocked)", async () => { + config.fakeAPI = true; + await expect(api.getTopBlocked()).resolves.toEqual(getData); + expect(httpClient.get).toHaveBeenCalledWith("stats/top_blocked"); + }); + + it("should call top clients endpoint", async () => { + await expect(api.getTopClients()).resolves.toEqual(getData); + expect(httpClient.get).toHaveBeenCalledWith("stats/top_clients"); + }); + + it("should call top history endpoint with params", async () => { + const params = { test: "params" }; + await expect(api.getHistory(params)).resolves.toEqual(getData); + expect(httpClient.get).toHaveBeenCalledWith("stats/history?test=params"); + }); + }); + + describe("database statistic calls", () => { + it("should call summary DB endpoint with time range", async () => { + await expect(api.getSummaryDb(range)).resolves.toEqual(getData); + expect(httpClient.get).toHaveBeenCalledWith( + "stats/database/summary?" + rangeParams + ); + }); + + it("should call history graph DB endpoint with interval and time range", async () => { + await expect(api.getHistoryGraphDb(range, 100)).resolves.toEqual(getData); + expect(httpClient.get).toHaveBeenCalledWith( + "stats/database/overTime/history?interval=100&" + rangeParams + ); + }); + + it("should call client graph DB endpoint with interval and time range", async () => { + await expect(api.getClientsGraphDb(range, 100)).resolves.toEqual(getData); + expect(httpClient.get).toHaveBeenCalledWith( + "stats/database/overTime/clients?interval=100&" + rangeParams + ); + }); + + it("should call query types DB endpoint with time range", async () => { + await expect(api.getQueryTypesDb(range)).resolves.toEqual(getData); + expect(httpClient.get).toHaveBeenCalledWith( + "stats/database/query_types?" + rangeParams + ); + }); + + it("should call query types DB endpoint with time range", async () => { + await expect(api.getQueryTypesDb(range)).resolves.toEqual(getData); + expect(httpClient.get).toHaveBeenCalledWith( + "stats/database/query_types?" + rangeParams + ); + }); + + it("should call upstreams DB endpoint with time range", async () => { + await expect(api.getUpstreamsDb(range)).resolves.toEqual(getData); + expect(httpClient.get).toHaveBeenCalledWith( + "stats/database/upstreams?" + rangeParams + ); + }); + + it("should call top domains DB endpoint with time range", async () => { + await expect(api.getTopDomainsDb(range)).resolves.toEqual(getData); + expect(httpClient.get).toHaveBeenCalledWith( + "stats/database/top_domains?" + rangeParams + ); + }); + + it("should call top blocked DB endpoint (top_domains?blocked=true) with time range", async () => { + config.fakeAPI = false; + await expect(api.getTopBlockedDb(range)).resolves.toEqual(getData); + expect(httpClient.get).toHaveBeenCalledWith( + "stats/database/top_domains?blocked=true&" + rangeParams + ); + }); + + it("should call top blocked DB endpoint (top_blocked) with time range", async () => { + config.fakeAPI = true; + await expect(api.getTopBlockedDb(range)).resolves.toEqual(getData); + expect(httpClient.get).toHaveBeenCalledWith( + "stats/database/top_blocked?" + rangeParams + ); + }); + + it("should call top clients DB endpoint with time range", async () => { + await expect(api.getTopClientsDb(range)).resolves.toEqual(getData); + expect(httpClient.get).toHaveBeenCalledWith( + "stats/database/top_clients?" + rangeParams + ); + }); + }); + + describe("dns calls", () => { + it("should call get whitelist endpoint", async () => { + await expect(api.getWhitelist()).resolves.toEqual(getData); + expect(httpClient.get).toHaveBeenCalledWith("dns/whitelist"); + }); + + it("should call get blacklist endpoint", async () => { + await expect(api.getBlacklist()).resolves.toEqual(getData); + expect(httpClient.get).toHaveBeenCalledWith("dns/blacklist"); + }); + + it("should call get regexlist endpoint", async () => { + await expect(api.getRegexlist()).resolves.toEqual(getData); + expect(httpClient.get).toHaveBeenCalledWith("dns/regexlist"); + }); + + it("should call add whitelist endpoint with domain", async () => { + const domain = "test.com"; + await expect(api.addWhitelist(domain)).resolves.toEqual(postData); + expect(httpClient.post).toHaveBeenCalledWith("dns/whitelist", { domain }); + }); + + it("should call add blacklist endpoint with domain", async () => { + const domain = "test.com"; + await expect(api.addBlacklist(domain)).resolves.toEqual(postData); + expect(httpClient.post).toHaveBeenCalledWith("dns/blacklist", { domain }); + }); + + it("should call add regexlist endpoint with domain", async () => { + const domain = "test.com"; + await expect(api.addRegexlist(domain)).resolves.toEqual(postData); + expect(httpClient.post).toHaveBeenCalledWith("dns/regexlist", { domain }); + }); + + it("should call remove whitelist endpoint with domain", async () => { + const domain = "test.com"; + await expect(api.removeWhitelist(domain)).resolves.toEqual(deleteData); + expect(httpClient.delete).toHaveBeenCalledWith("dns/whitelist/" + domain); + }); + + it("should call remove blacklist endpoint with domain", async () => { + const domain = "test.com"; + await expect(api.removeBlacklist(domain)).resolves.toEqual(deleteData); + expect(httpClient.delete).toHaveBeenCalledWith("dns/blacklist/" + domain); + }); + + it("should call remove regexlist endpoint with encoded domain", async () => { + const regex = "^test\\.com$"; + await expect(api.removeRegexlist(regex)).resolves.toEqual(deleteData); + expect(httpClient.delete).toHaveBeenCalledWith( + "dns/regexlist/%5Etest%5C.com%24" + ); + }); + + it("should call get status endpoint", async () => { + await expect(api.getStatus()).resolves.toEqual(getData); + expect(httpClient.get).toHaveBeenCalledWith("dns/status"); + }); + + it("should call set status endpoint with action data", async () => { + const action: StatusAction = "enable"; + const time = 100; + await expect(api.setStatus(action, time)).resolves.toEqual(postData); + expect(httpClient.post).toHaveBeenCalledWith("dns/status", { + action, + time + }); + }); + }); + + describe("settings calls", () => { + it("should call get network settings endpoint", async () => { + await expect(api.getNetworkInfo()).resolves.toEqual(getData); + expect(httpClient.get).toHaveBeenCalledWith("settings/network"); + }); + + it("should call version endpoint", async () => { + await expect(api.getVersion()).resolves.toEqual(getData); + expect(httpClient.get).toHaveBeenCalledWith("version"); + }); + + it("should call FTL DB settings endpoint", async () => { + await expect(api.getFTLdb()).resolves.toEqual(getData); + expect(httpClient.get).toHaveBeenCalledWith("settings/ftldb"); + }); + + it("should call get DNS settings endpoint", async () => { + await expect(api.getDNSInfo()).resolves.toEqual(getData); + expect(httpClient.get).toHaveBeenCalledWith("settings/dns"); + }); + + it("should call get DHCP settings endpoint", async () => { + await expect(api.getDHCPInfo()).resolves.toEqual(getData); + expect(httpClient.get).toHaveBeenCalledWith("settings/dhcp"); + }); + + it("should call get web preferences settings endpoint", async () => { + await expect(api.getPreferences()).resolves.toEqual(getData); + expect(httpClient.get).toHaveBeenCalledWith("settings/web"); + }); + + it("should call set DNS settings endpoint", async () => { + const settings = ({ test: true } as any) as ApiDnsSettings; + await expect(api.updateDNSInfo(settings)).resolves.toEqual(putData); + expect(httpClient.put).toHaveBeenCalledWith("settings/dns", settings); + }); + + it("should call set DHCP settings endpoint", async () => { + const settings = ({ test: true } as any) as ApiDhcpSettings; + await expect(api.updateDHCPInfo(settings)).resolves.toEqual(putData); + expect(httpClient.put).toHaveBeenCalledWith("settings/dhcp", settings); + }); + + it("should call set web preferences settings endpoint", async () => { + const settings = ({ test: true } as any) as ApiPreferences; + await expect(api.updatePreferences(settings)).resolves.toEqual(putData); + expect(httpClient.put).toHaveBeenCalledWith("settings/web", settings); + }); + }); }); diff --git a/src/util/__tests__/basePath.test.tsx b/src/util/__tests__/basePath.test.tsx new file mode 100644 index 0000000..5ec92be --- /dev/null +++ b/src/util/__tests__/basePath.test.tsx @@ -0,0 +1,31 @@ +/* Pi-hole: A black hole for Internet advertisements + * (c) 2019 Pi-hole, LLC (https://pi-hole.net) + * Network-wide ad blocking via your own hardware. + * + * Web Interface + * Tests for base path function + * + * This file is copyright under the latest version of the EUPL. + * Please see LICENSE file for your rights under this license. */ + +import { getBasePath } from "../basePath"; + +it("should return the public URL when there is no base", () => { + const publicUrl = process.env.PUBLIC_URL; + + const basePath = getBasePath(); + + expect(basePath).toEqual(publicUrl); +}); + +it("should return the path from the base element when it exists", () => { + const expectedBasePath = "/admin"; + const baseElement = document.createElement("base"); + baseElement.href = expectedBasePath; + document.head.appendChild(baseElement); + + const actualBasePath = getBasePath(); + + document.head.removeChild(baseElement); + expect(actualBasePath).toEqual(expectedBasePath); +}); diff --git a/src/util/__tests__/graphUtils.test.tsx b/src/util/__tests__/graphUtils.test.tsx new file mode 100644 index 0000000..3258910 --- /dev/null +++ b/src/util/__tests__/graphUtils.test.tsx @@ -0,0 +1,51 @@ +/* Pi-hole: A black hole for Internet advertisements + * (c) 2019 Pi-hole, LLC (https://pi-hole.net) + * Network-wide ad blocking via your own hardware. + * + * Web Interface + * Graph utility tests + * + * This file is copyright under the latest version of the EUPL. + * Please see LICENSE file for your rights under this license. */ + +import { padNumber, getIntervalForRange } from "../graphUtils"; +import { TimeRange } from "../../components/common/context/TimeRangeContext"; +import moment from "moment"; + +describe("padNumber", () => { + it("pads 0 to 00", () => { + expect(padNumber(0)).toEqual("00"); + }); + + it("pads 1 to 01", () => { + expect(padNumber(1)).toEqual("01"); + }); + + it("pads 12 to 12", () => { + expect(padNumber(12)).toEqual("12"); + }); +}); + +describe("getIntervalForRange", () => { + it("returns 10 minutes for 24 hours", () => { + const range: TimeRange = { + name: "24 Hours", + from: moment().subtract(1, "day"), + until: moment() + }; + + expect(getIntervalForRange(range)).toEqual(10 * 60); + }); + + it("returns 1 day for 144 days", () => { + const range: TimeRange = { + name: "144 days", + // Use seconds instead of days to ensure the difference in epoch time is + // equal to 144 days + from: moment().subtract(144 * 24 * 60 * 60, "seconds"), + until: moment() + }; + + expect(getIntervalForRange(range)).toEqual(24 * 60 * 60); + }); +}); diff --git a/src/util/__tests__/http.test.tsx b/src/util/__tests__/http.test.tsx new file mode 100644 index 0000000..8bddd18 --- /dev/null +++ b/src/util/__tests__/http.test.tsx @@ -0,0 +1,284 @@ +/* Pi-hole: A black hole for Internet advertisements + * (c) 2019 Pi-hole, LLC (https://pi-hole.net) + * Network-wide ad blocking via your own hardware. + * + * Web Interface + * Test basic HTTP functions + * + * This file is copyright under the latest version of the EUPL. + * Please see LICENSE file for your rights under this license. */ + +import HttpClient, { + checkForErrors, + checkIfLoggedOut, + convertJSON, + paramsToString, + timeRangeToParams +} from "../http"; +import api from "../api"; +import { Config } from "../../config"; +import { CanceledError } from "../CancelablePromise"; +import { TimeRange } from "../../components/common/context/TimeRangeContext"; +import moment from "moment"; +import fetchMock from "fetch-mock"; + +const originalReload = window.location.reload; + +/** + * Mock window.location.reload + * https://remarkablemark.org/blog/2018/11/17/mock-window-location/ + * + * Restore it with {@link restoreLocationReload} + */ +const mockLocationReload = () => { + Object.defineProperty(window.location, "reload", { + configurable: true + }); + + window.location.reload = jest.fn(); +}; + +/** + * Restore window.location.reload after being mocked by + * {@link mockLocationReload} + */ +const restoreLocationReload = () => { + window.location.reload = originalReload; +}; + +describe("HttpClient", () => { + const testEndpoint = "test"; + const testEndpointFull = "/api/test"; + const data = { test: true }; + const config: Config = { + developmentMode: true, + apiPath: "/api", + fakeAPI: false + }; + const canceledError: CanceledError = { isCanceled: true }; + let httpClient: HttpClient; + + beforeEach(() => { + httpClient = new HttpClient(config); + }); + + describe("handleResponse", () => { + it("should make a GET request and return the parsed data", async () => { + const response = { + status: 200, + json: () => Promise.resolve(data) + } as Response; + + await expect(httpClient.handleResponse(response)).resolves.toEqual(data); + }); + + it("should cancel if logged out by API", async () => { + const error: ApiError = { + key: "unauthorized", + message: "Unauthorized", + data: null + }; + const response = { + status: 401, + json: () => Promise.resolve({ error }) + } as Response; + + api.loggedIn = true; + mockLocationReload(); + + await expect(httpClient.handleResponse(response)).rejects.toEqual( + canceledError + ); + expect(window.location.reload).toHaveBeenCalled(); + restoreLocationReload(); + }); + + it("should reject with the API error if set", async () => { + const error: ApiError = { + key: "test_key", + message: "Test message", + data: null + }; + const response = { + status: 500, + json: () => Promise.resolve({ error }) + } as Response; + + await expect(httpClient.handleResponse(response)).rejects.toEqual(error); + }); + }); + + describe("HTTP functions", () => { + it("should make a GET request and call handleResponse", async () => { + fetchMock.get(testEndpointFull, { body: data }); + httpClient.handleResponse = jest.fn(() => Promise.resolve(data)); + + await expect(httpClient.get(testEndpoint)).resolves.toEqual(data); + const request = fetchMock.lastCall(testEndpointFull)![1]!; + + expect(httpClient.handleResponse).toHaveBeenCalled(); + expect(request.method).toEqual("GET"); + }); + + it("should make a POST request and call handleResponse", async () => { + fetchMock.post(testEndpointFull, { body: data }); + httpClient.handleResponse = jest.fn(() => Promise.resolve(data)); + + await expect(httpClient.post(testEndpoint, data)).resolves.toEqual(data); + const request = fetchMock.lastCall(testEndpointFull)![1]!; + + expect(httpClient.handleResponse).toHaveBeenCalled(); + expect(request.method).toEqual("POST"); + expect(request.body).toEqual(JSON.stringify(data)); + }); + + it("should make a PUT request and call handleResponse", async () => { + fetchMock.put(testEndpointFull, { body: data }); + httpClient.handleResponse = jest.fn(() => Promise.resolve(data)); + + await expect(httpClient.put(testEndpoint, data)).resolves.toEqual(data); + const request = fetchMock.lastCall(testEndpointFull)![1]!; + + expect(httpClient.handleResponse).toHaveBeenCalled(); + expect(request.method).toEqual("PUT"); + expect(request.body).toEqual(JSON.stringify(data)); + }); + + it("should make a DELETE request and call handleResponse", async () => { + fetchMock.delete(testEndpointFull, { body: data }); + httpClient.handleResponse = jest.fn(() => Promise.resolve(data)); + + await expect(httpClient.delete(testEndpoint)).resolves.toEqual(data); + const request = fetchMock.lastCall(testEndpointFull)![1]!; + + expect(httpClient.handleResponse).toHaveBeenCalled(); + expect(request.method).toEqual("DELETE"); + }); + }); + + describe("urlFor", () => { + it("uses the fakeAPI route if configured", () => { + const config: Config = { + developmentMode: false, + fakeAPI: true, + apiPath: "/fakeAPI" + }; + const httpClient = new HttpClient(config); + + expect(httpClient.urlFor("test")).toEqual("/fakeAPI/test"); + }); + + it("uses the production route if configured", () => { + const config: Config = { + developmentMode: false, + fakeAPI: false, + apiPath: "/admin/api" + }; + const httpClient = new HttpClient(config); + + expect(httpClient.urlFor("test")).toEqual("/admin/api/test"); + }); + }); +}); + +describe("checkIfLoggedOut", () => { + it("should pass the response through if logged in and not a 401", async () => { + const response = { status: 200 } as Response; + api.loggedIn = true; + + await expect(checkIfLoggedOut(response)).resolves.toEqual(response); + }); + + it("should pass the response through if not logged in and not a 401", async () => { + const response = { status: 200 } as Response; + api.loggedIn = false; + + await expect(checkIfLoggedOut(response)).resolves.toEqual(response); + }); + + it("should pass the response through if not logged in and is a 401", async () => { + const response = { status: 401 } as Response; + api.loggedIn = false; + + await expect(checkIfLoggedOut(response)).resolves.toEqual(response); + }); + + it("should clear the session cookie and reload if logged in and response is a 401", async () => { + const response = { status: 401 } as Response; + api.loggedIn = true; + document.cookie = "user_id=test"; + mockLocationReload(); + + await expect(checkIfLoggedOut(response)).rejects.toEqual({ + isCanceled: true + }); + + expect(window.location.reload).toHaveBeenCalled(); + expect(document.cookie).toHaveLength(0); + + restoreLocationReload(); + }); +}); + +describe("convertJSON", () => { + it("should convert to JSON if it is not canceled or an error", async () => { + const body = { test: true }; + const response = new Response(JSON.stringify(body)); + + await expect(convertJSON(response)).resolves.toEqual(body); + }); + + it("should reject with input if canceled", async () => { + const cancelError: CanceledError = { + isCanceled: true + }; + + await expect(convertJSON(cancelError)).rejects.toEqual(cancelError); + }); + + it("should reject with input if error", async () => { + const error = new Error("test"); + + await expect(convertJSON(error)).rejects.toEqual(error); + }); +}); + +describe("checkForErrors", () => { + it("should pass through the data if there is no error", async () => { + const data = { test: true }; + + await expect(checkForErrors(data)).resolves.toEqual(data); + }); + + it("should reject with the error if there is an error", async () => { + const data = { error: { test: true } }; + + await expect(checkForErrors(data)).rejects.toEqual(data.error); + }); +}); + +describe("paramsToString", () => { + it("converts an object into parameters", () => { + const object = { + test1: "1", + test2: "two", + test3: 3 + }; + const expectedParams = "test1=1&test2=two&test3=3"; + + expect(paramsToString(object)).toEqual(expectedParams); + }); +}); + +describe("timeRangeToParams", () => { + it("converts a time range into parameters", () => { + const range: TimeRange = { + name: "Test time range", + from: moment("2019-04-12T01:03:17+00:00"), + until: moment("2019-04-13T01:03:17+00:00") + }; + const expectedParams = "from=1555030997&until=1555117397"; + + expect(timeRangeToParams(range)).toEqual(expectedParams); + }); +}); diff --git a/src/util/__tests__/i18n.test.tsx b/src/util/__tests__/i18n.test.tsx new file mode 100644 index 0000000..ab58f4f --- /dev/null +++ b/src/util/__tests__/i18n.test.tsx @@ -0,0 +1,32 @@ +/* Pi-hole: A black hole for Internet advertisements + * (c) 2019 Pi-hole, LLC (https://pi-hole.net) + * Network-wide ad blocking via your own hardware. + * + * Web Interface + * Test internationalization setup + * + * This file is copyright under the latest version of the EUPL. + * Please see LICENSE file for your rights under this license. */ + +import { setupI18n } from "../i18n"; + +// Mock react-i18next, as it does not properly export the i18next module during +// testing. +// https://github.com/i18next/react-i18next/issues/434 +jest.mock("react-i18next", () => ({ + reactI18nextModule: { + type: "3rdParty", + init: () => {} + } +})); + +// languages.json is generated during a build or run, so it may not exist yet +jest.mock("../../languages.json", () => [], { virtual: true }); + +it("configures i18n successfully", async () => { + // Provide a mock ajax function to the XHR backend + const fakeAjax = (url: any, options: any, callback: any) => callback("", {}); + + // Make sure i18n initializes without error + await setupI18n(fakeAjax); +}); diff --git a/src/util/__tests__/result.test.tsx b/src/util/__tests__/result.test.tsx new file mode 100644 index 0000000..eb7cd71 --- /dev/null +++ b/src/util/__tests__/result.test.tsx @@ -0,0 +1,47 @@ +/* Pi-hole: A black hole for Internet advertisements + * (c) 2019 Pi-hole, LLC (https://pi-hole.net) + * Network-wide ad blocking via your own hardware. + * + * Web Interface + * Result class tests + * + * This file is copyright under the latest version of the EUPL. + * Please see LICENSE file for your rights under this license. */ + +import { Ok, Err } from "../result"; + +describe("Ok", () => { + const testValue = "test"; + const ok = new Ok(testValue); + + it("knows it's Ok", () => { + expect(ok.isOk()).toBe(true); + expect(ok.isErr()).toBe(false); + }); + + it("unwraps without error", () => { + expect(ok.unwrap()).toEqual(testValue); + }); + + it("throws an error on unwrapErr", () => { + expect(() => ok.unwrapErr()).toThrow("unwrapErr on a Result.Ok"); + }); +}); + +describe("Err", () => { + const testValue = "test"; + const err = new Err(testValue); + + it("knows it's Err", () => { + expect(err.isOk()).toBe(false); + expect(err.isErr()).toBe(true); + }); + + it("throws an error on unwrap", () => { + expect(() => err.unwrap()).toThrow("unwrap on a Result.Err"); + }); + + it("does not throw an error on unwrapErr", () => { + expect(err.unwrapErr()).toEqual(testValue); + }); +}); diff --git a/src/util/api.tsx b/src/util/api.tsx index af51404..ba95804 100644 --- a/src/util/api.tsx +++ b/src/util/api.tsx @@ -8,157 +8,216 @@ * This file is copyright under the latest version of the EUPL. * Please see LICENSE file for your rights under this license. */ -import http, { paramsToString, timeRangeToParams } from "./http"; +import HttpClient, { paramsToString, timeRangeToParams } from "./http"; import config from "../config"; import { TimeRange } from "../components/common/context/TimeRangeContext"; -export default { - loggedIn: false, - authenticate(key: string) { - return http.get("auth", { - headers: new Headers({ "X-Pi-hole-Authenticate": key }) +export class ApiClient { + public loggedIn = false; + + constructor(private http: HttpClient) {} + + authenticate = (key: string): Promise => { + return this.http.get("auth", { + headers: { "X-Pi-hole-Authenticate": key } }); - }, - logout() { - return http.delete("auth"); - }, - getSummary(): Promise { - return http.get("stats/summary"); - }, - getSummaryDb(range: TimeRange): Promise { - return http.get("stats/database/summary?" + timeRangeToParams(range)); - }, - getHistoryGraph(): Promise> { - return http.get("stats/overTime/history"); - }, - getHistoryGraphDb( + }; + + logout = (): Promise => { + return this.http.delete("auth"); + }; + + getSummary = (): Promise => { + return this.http.get("stats/summary"); + }; + + getSummaryDb = (range: TimeRange): Promise => { + return this.http.get("stats/database/summary?" + timeRangeToParams(range)); + }; + + getHistoryGraph = (): Promise> => { + return this.http.get("stats/overTime/history"); + }; + + getHistoryGraphDb = ( range: TimeRange, interval: number - ): Promise> { - return http.get( + ): Promise> => { + return this.http.get( "stats/database/overTime/history?interval=" + interval + "&" + timeRangeToParams(range) ); - }, - getClientsGraph(): Promise { - return http.get("stats/overTime/clients"); - }, - getClientsGraphDb( + }; + + getClientsGraph = (): Promise => { + return this.http.get("stats/overTime/clients"); + }; + + getClientsGraphDb = ( range: TimeRange, interval: number - ): Promise { - return http.get( + ): Promise => { + return this.http.get( "stats/database/overTime/clients?interval=" + interval + "&" + timeRangeToParams(range) ); - }, - getQueryTypes(): Promise> { - return http.get("stats/query_types"); - }, - getQueryTypesDb(range: TimeRange): Promise> { - return http.get("stats/database/query_types?" + timeRangeToParams(range)); - }, - getUpstreams(): Promise { - return http.get("stats/upstreams"); - }, - getUpstreamsDb(range: TimeRange): Promise { - return http.get("stats/database/upstreams?" + timeRangeToParams(range)); - }, - getTopDomains(): Promise { - return http.get("stats/top_domains"); - }, - getTopDomainsDb(range: TimeRange): Promise { - return http.get("stats/database/top_domains?" + timeRangeToParams(range)); - }, + }; + + getQueryTypes = (): Promise> => { + return this.http.get("stats/query_types"); + }; + + getQueryTypesDb = (range: TimeRange): Promise> => { + return this.http.get( + "stats/database/query_types?" + timeRangeToParams(range) + ); + }; + + getUpstreams = (): Promise => { + return this.http.get("stats/upstreams"); + }; + + getUpstreamsDb = (range: TimeRange): Promise => { + return this.http.get( + "stats/database/upstreams?" + timeRangeToParams(range) + ); + }; + + getTopDomains = (): Promise => { + return this.http.get("stats/top_domains"); + }; + + getTopDomainsDb = (range: TimeRange): Promise => { + return this.http.get( + "stats/database/top_domains?" + timeRangeToParams(range) + ); + }; + getTopBlocked(): Promise { // The API uses a GET parameter to differentiate top domains from top // blocked, but the fake API is not able to handle GET parameters right now. - const url = config.fakeAPI + const url = this.http.config.fakeAPI ? "stats/top_blocked" : "stats/top_domains?blocked=true"; - return http.get(url); - }, + return this.http.get(url); + } + getTopBlockedDb(range: TimeRange): Promise { // The API uses a GET parameter to differentiate top domains from top // blocked, but the fake API is not able to handle GET parameters right now. - const url = config.fakeAPI + const url = this.http.config.fakeAPI ? "stats/database/top_blocked?" : "stats/database/top_domains?blocked=true&"; - return http.get(url + timeRangeToParams(range)); - }, - getTopClients(): Promise { - return http.get("stats/top_clients"); - }, - getTopClientsDb(range: TimeRange): Promise { - return http.get("stats/database/top_clients?" + timeRangeToParams(range)); - }, - getHistory(params: any): Promise { - return http.get("stats/history?" + paramsToString(params)); - }, - getWhitelist() { - return http.get("dns/whitelist"); - }, - getBlacklist() { - return http.get("dns/blacklist"); - }, - getRegexlist() { - return http.get("dns/regexlist"); - }, - addWhitelist(domain: string) { - return http.post("dns/whitelist", { domain: domain }); - }, - addBlacklist(domain: string) { - return http.post("dns/blacklist", { domain: domain }); - }, - addRegexlist(domain: string) { - return http.post("dns/regexlist", { domain: domain }); - }, - removeWhitelist(domain: string) { - return http.delete("dns/whitelist/" + domain); - }, - removeBlacklist(domain: string) { - return http.delete("dns/blacklist/" + domain); - }, - removeRegexlist(domain: string) { - return http.delete("dns/regexlist/" + encodeURIComponent(domain)); - }, - getStatus(): Promise { - return http.get("dns/status"); - }, - setStatus(action: StatusAction, time?: number) { - return http.post("dns/status", { action, time }); - }, - getNetworkInfo(): Promise { - return http.get("settings/network"); - }, - getVersion(): Promise { - return http.get("version"); - }, - getFTLdb(): Promise { - return http.get("settings/ftldb"); - }, - getDNSInfo(): Promise { - return http.get("settings/dns"); - }, - getDHCPInfo(): Promise { - return http.get("settings/dhcp"); - }, - updateDHCPInfo(settings: ApiDhcpSettings) { - return http.put("settings/dhcp", settings); - }, - updateDNSInfo(settings: ApiDnsSettings) { - return http.put("settings/dns", settings); - }, - getPreferences(): Promise { - return http.get("settings/web"); - }, - updatePreferences(settings: ApiPreferences) { - return http.put("settings/web", settings); + return this.http.get(url + timeRangeToParams(range)); } -}; + + getTopClients = (): Promise => { + return this.http.get("stats/top_clients"); + }; + + getTopClientsDb = (range: TimeRange): Promise => { + return this.http.get( + "stats/database/top_clients?" + timeRangeToParams(range) + ); + }; + + getHistory = (params: any): Promise => { + return this.http.get("stats/history?" + paramsToString(params)); + }; + + getWhitelist = (): Promise> => { + return this.http.get("dns/whitelist"); + }; + + getBlacklist = (): Promise> => { + return this.http.get("dns/blacklist"); + }; + + getRegexlist = (): Promise> => { + return this.http.get("dns/regexlist"); + }; + + addWhitelist = (domain: string): Promise => { + return this.http.post("dns/whitelist", { domain: domain }); + }; + + addBlacklist = (domain: string): Promise => { + return this.http.post("dns/blacklist", { domain: domain }); + }; + + addRegexlist = (domain: string): Promise => { + return this.http.post("dns/regexlist", { domain: domain }); + }; + + removeWhitelist = (domain: string): Promise => { + return this.http.delete("dns/whitelist/" + domain); + }; + + removeBlacklist = (domain: string): Promise => { + return this.http.delete("dns/blacklist/" + domain); + }; + + removeRegexlist = (domain: string): Promise => { + return this.http.delete("dns/regexlist/" + encodeURIComponent(domain)); + }; + + getStatus = (): Promise => { + return this.http.get("dns/status"); + }; + + setStatus = ( + action: StatusAction, + time?: number + ): Promise => { + return this.http.post("dns/status", { + action, + time + }); + }; + + getNetworkInfo = (): Promise => { + return this.http.get("settings/network"); + }; + + getVersion = (): Promise => { + return this.http.get("version"); + }; + + getFTLdb = (): Promise => { + return this.http.get("settings/ftldb"); + }; + + getDNSInfo = (): Promise => { + return this.http.get("settings/dns"); + }; + + getDHCPInfo = (): Promise => { + return this.http.get("settings/dhcp"); + }; + + updateDHCPInfo = (settings: ApiDhcpSettings): Promise => { + return this.http.put("settings/dhcp", settings); + }; + + updateDNSInfo = (settings: ApiDnsSettings): Promise => { + return this.http.put("settings/dns", settings); + }; + + getPreferences = (): Promise => { + return this.http.get("settings/web"); + }; + + updatePreferences = ( + settings: ApiPreferences + ): Promise => { + return this.http.put("settings/web", settings); + }; +} + +export default new ApiClient(new HttpClient(config)); diff --git a/src/util/basePath.ts b/src/util/basePath.ts new file mode 100644 index 0000000..8caa571 --- /dev/null +++ b/src/util/basePath.ts @@ -0,0 +1,26 @@ +/* Pi-hole: A black hole for Internet advertisements + * (c) 2019 Pi-hole, LLC (https://pi-hole.net) + * Network-wide ad blocking via your own hardware. + * + * Web Interface + * Provide the base path for relative paths + * + * This file is copyright under the latest version of the EUPL. + * Please see LICENSE file for your rights under this license. */ + +/** + * Get the base path of the web interface. The API will inject a base element + * for this purpose, but if the web interface is not hosted by the API, it will + * fall back to the public URL set by Create React App. + * + * @returns The base path to use + */ +export const getBasePath = (): string => { + const baseElement = document.getElementsByTagName("base")[0]; + + if (baseElement) { + return new URL(baseElement.href).pathname; + } else { + return process.env.PUBLIC_URL; + } +}; diff --git a/src/util/graphUtils.tsx b/src/util/graphUtils.tsx new file mode 100644 index 0000000..b49c4e8 --- /dev/null +++ b/src/util/graphUtils.tsx @@ -0,0 +1,31 @@ +/* Pi-hole: A black hole for Internet advertisements + * (c) 2019 Pi-hole, LLC (https://pi-hole.net) + * Network-wide ad blocking via your own hardware. + * + * Web Interface + * Graph utility functions + * + * This file is copyright under the latest version of the EUPL. + * Please see LICENSE file for your rights under this license. */ + +import { TimeRange } from "../components/common/context/TimeRangeContext"; + +/** + * Pad a two digit number + * + * @param num the number + * @returns {string} a padding number string + */ +export const padNumber = (num: number) => { + return ("00" + num).substr(-2, 2); +}; + +/** + * Dynamically calculate a time interval so there are always 144 data points + * (144 so that every point represents 10 minutes when the range is 24 hours) + * + * @param range The range to find the interval for + */ +export const getIntervalForRange = (range: TimeRange): number => { + return Math.ceil((range.until.unix() - range.from.unix()) / 144); +}; diff --git a/src/util/http.tsx b/src/util/http.tsx index 7ce4c7e..e8cde6a 100644 --- a/src/util/http.tsx +++ b/src/util/http.tsx @@ -9,98 +9,124 @@ * Please see LICENSE file for your rights under this license. */ import api from "./api"; -import config from "../config"; +import { Config } from "../config"; import { TimeRange } from "../components/common/context/TimeRangeContext"; +import { CanceledError } from "./CancelablePromise"; /** - * A group of HTTP functions. Each function parses the response checks for - * errors + * A class which provides HTTP functions. Each function parses the response and + * checks for errors */ -export default { +export default class HttpClient { + constructor(public config: Config) {} + + /** + * Check if the user is logged out, convert to JSON, and check for API errors + * + * @param response The HTTP response + */ + handleResponse = (response: Response): Promise => { + // @ts-ignore + return checkIfLoggedOut(response) + .then(convertJSON) + .then(checkForErrors); + }; + /** * Perform a GET request * - * @param url the URL to access - * @param options optional fetch configuration - * @returns {Promise} a promise with the data or error returned + * @param url The URL to access + * @param options Optional fetch configuration + * @returns A promise with the data or error returned */ - get(url: string, options = {}) { - return fetch(urlFor(url), { - credentials: credentialType(), + get = (url: string, options: RequestInit = {}): Promise => { + // @ts-ignore + return fetch(this.urlFor(url), { + method: "GET", + credentials: this.credentialType(), ...options - }) - .then(checkIfLoggedOut) - .then(convertJSON) - .catch(convertJSON) - .then(checkForErrors); - }, + }).then(this.handleResponse); + }; /** * Perform a POST request * - * @param url the URL to access - * @param data the data to send - * @returns {Promise} a promise with the data or error returned + * @param url The URL to access + * @param data The data to send + * @returns A promise with the data or error returned */ - post(url: string, data: {}) { - return fetch(urlFor(url), { + post = (url: string, data: object): Promise => { + // @ts-ignore + return fetch(this.urlFor(url), { method: "POST", body: JSON.stringify(data), headers: new Headers({ "Content-Type": "application/json" }), - credentials: credentialType() - }) - .then(checkIfLoggedOut) - .then(convertJSON) - .catch(convertJSON) - .then(checkForErrors); - }, + credentials: this.credentialType() + }).then(this.handleResponse); + }; /** * Perform a PUT request * - * @param url the URL to access - * @param data the data to send - * @returns {Promise} a promise with the data or error returned + * @param url The URL to access + * @param data The data to send + * @returns A promise with the data or error returned */ - put(url: string, data: {}) { - return fetch(urlFor(url), { + put = (url: string, data: object): Promise => { + // @ts-ignore + return fetch(this.urlFor(url), { method: "PUT", body: JSON.stringify(data), headers: new Headers({ "Content-Type": "application/json" }), - credentials: credentialType() - }) - .then(checkIfLoggedOut) - .then(convertJSON) - .catch(convertJSON) - .then(checkForErrors); - }, + credentials: this.credentialType() + }).then(this.handleResponse); + }; /** * Perform a DELETE request * - * @param url the URL to access - * @returns {Promise} a promise with the data or error returned + * @param url The URL to access + * @returns A promise with the data or error returned */ - delete(url: string) { - return fetch(urlFor(url), { + delete = (url: string): Promise => { + // @ts-ignore + return fetch(this.urlFor(url), { method: "DELETE", - credentials: credentialType() - }) - .then(checkIfLoggedOut) - .then(convertJSON) - .catch(convertJSON) - .then(checkForErrors); - } -}; + credentials: this.credentialType() + }).then(this.handleResponse); + }; + + /** + * Get the URL for an endpoint + * + * @param endpoint The endpoint + * @returns The URL for the endpoint + */ + urlFor = (endpoint: string): string => { + return this.config.apiPath + "/" + endpoint; + }; + + /** + * Get the credential type for requests + * + * @returns The credential type + */ + credentialType = (): RequestCredentials => { + // Development API requests may use a different origin (pi.hole) since it is + // running off of the developer's machine. Therefore, allow credentials to + // be used across origins when in development mode. + return this.config.developmentMode ? "include" : "same-origin"; + }; +} /** * If the user is logged in, check if the user's session has lapsed. * If so, log them out and refresh the page. * - * @param response the Response from fetch - * @return {Promise} if logged in, the response, otherwise a canceled promise + * @param response The Response from fetch + * @return If logged in, the response, otherwise a canceled promise */ -const checkIfLoggedOut = (response: Response) => { +export const checkIfLoggedOut = (response: Response): Promise => { if (api.loggedIn && response.status === 401) { // Clear the user's old session and refresh the page document.cookie = @@ -120,22 +146,24 @@ const checkIfLoggedOut = (response: Response) => { * @param data a Response or Error * @returns {*} a promise with the parsed JSON, or the error */ -const convertJSON = (data: any): Promise => { - if (data.isCanceled || data instanceof Error) { +export const convertJSON = ( + data: Response | Error | CanceledError +): Promise => { + if ((data as CanceledError).isCanceled || data instanceof Error) { return Promise.reject(data); } - return data.json(); + return (data as Response).json(); }; /** * Check for an error returned by the API * * @param data the parsed JSON body of the response - * @returns {*} a resolving promise with the data if no error, otherwise a + * @returns A resolving promise with the data if no error, otherwise a * rejecting promise with the error */ -const checkForErrors = (data: any): Promise => { +export const checkForErrors = (data: T): Promise => { if (data.error) { return Promise.reject(data.error); } @@ -143,46 +171,20 @@ const checkForErrors = (data: any): Promise => { return Promise.resolve(data); }; -/** - * Get the URL for an endpoint - * - * @param endpoint the endpoint - * @returns {string} the URL for the endpoint - */ -const urlFor = (endpoint: string): string => { - let apiLocation; - - if (config.fakeAPI) { - apiLocation = process.env.PUBLIC_URL + "/fakeAPI"; - } else { - apiLocation = process.env.PUBLIC_URL + "/api"; - } - - return apiLocation + "/" + endpoint; -}; - -/** - * Get the credential type for requests - * - * @returns {string} the credential type - */ -const credentialType = () => { - // Development API requests use a different origin (pi.hole) since it is running off of the developer's machine. - // Therefore, allow credentials to be used across origins when in development mode. - return config.developmentMode ? "include" : "same-origin"; -}; - /** * Convert an object into GET parameters. The object must be flat (only * key-value pairs). * - * @param params the parameters object - * @returns {string} the parameters converted into GET parameter form + * @param params The parameters object + * @returns The parameters converted into GET parameter form */ -export const paramsToString = (params: any) => - Object.keys(params) +export const paramsToString = (params: { + [key: string]: string | number; +}): string => { + return Object.keys(params) .map(key => key + "=" + params[key]) .join("&"); +}; /** * Convert a time range into GET parameters @@ -190,8 +192,9 @@ export const paramsToString = (params: any) => * @param range The time range to convert * @return The time range as GET parameters */ -export const timeRangeToParams = (range: TimeRange) => - paramsToString({ +export const timeRangeToParams = (range: TimeRange) => { + return paramsToString({ from: range.from.unix(), until: range.until.unix() }); +}; diff --git a/src/util/i18n.tsx b/src/util/i18n.tsx index 16e0ffa..89cf31c 100644 --- a/src/util/i18n.tsx +++ b/src/util/i18n.tsx @@ -15,8 +15,13 @@ import { reactI18nextModule } from "react-i18next"; import config from "../config"; import languages from "../languages.json"; -export function setupI18n() { - i18n +/** + * Set up the internationalization service + * + * @param ajax An optional ajax function to use when fetching translations + */ +export function setupI18n(ajax?: any) { + return i18n .use(XHR) .use(LanguageDetector) .use(reactI18nextModule) @@ -46,7 +51,8 @@ export function setupI18n() { escapeValue: false }, backend: { - loadPath: process.env.PUBLIC_URL + "/i18n/{{lng}}/{{ns}}.json" + loadPath: process.env.PUBLIC_URL + "/i18n/{{lng}}/{{ns}}.json", + ajax }, react: { // Wait until translations are loaded before rendering diff --git a/src/views/Login.tsx b/src/views/Login.tsx index 6ea0f82..3fde2ee 100644 --- a/src/views/Login.tsx +++ b/src/views/Login.tsx @@ -78,14 +78,7 @@ class Login extends Component { // Send the password to the API to authenticate the user api .authenticate(hashedPassword) - .then(data => { - // Verify status - if (data.status !== "success") { - console.log("Failed to log in:"); - console.log(data); - return; - } - + .then(() => { api.loggedIn = true; if (config.fakeAPI) {