diff --git a/src/components/common/Sidebar.js b/src/components/common/Sidebar.js index 4c29eb4..3f62841 100644 --- a/src/components/common/Sidebar.js +++ b/src/components/common/Sidebar.js @@ -14,7 +14,7 @@ import { NavLink } from "react-router-dom"; import { Nav, NavItem } from "reactstrap"; import logo from "../../img/logo.svg"; import { mobileSidebarHide } from "./Header"; -import { api } from "../../utils"; +import api from "../../util/api"; import StatusBadge from "./StatusBadge"; const handleClick = e => { diff --git a/src/components/common/Sidebar.test.js b/src/components/common/Sidebar.test.js index e65c46f..3fc2043 100644 --- a/src/components/common/Sidebar.test.js +++ b/src/components/common/Sidebar.test.js @@ -11,7 +11,7 @@ import React from "react"; import { shallow } from "enzyme"; import { dropDownClassList, navDropdown, navItem, navList } from "./Sidebar"; -import { api } from "../../utils"; +import api from "../../util/api"; it("expands active drop down items", () => { const classList = dropDownClassList("/testRoute", { diff --git a/src/components/common/StatusBadge.js b/src/components/common/StatusBadge.js index e60585c..c9cd911 100644 --- a/src/components/common/StatusBadge.js +++ b/src/components/common/StatusBadge.js @@ -1,6 +1,7 @@ import React, { Component } from "react"; import { translate } from "react-i18next"; -import { api, makeCancelable, ignoreCancel } from "../../utils"; +import api from "../../util/api"; +import { makeCancelable, ignoreCancel } from "../../util"; class StatusBadge extends Component { state = { diff --git a/src/components/common/WithAPIData.js b/src/components/common/WithAPIData.js new file mode 100644 index 0000000..e658194 --- /dev/null +++ b/src/components/common/WithAPIData.js @@ -0,0 +1,102 @@ +/* Pi-hole: A black hole for Internet advertisements +* (c) 2017 Pi-hole, LLC (https://pi-hole.net) +* Network-wide ad blocking via your own hardware. +* +* Web Interface +* WithAPIData Component +* +* This file is copyright under the latest version of the EUPL. +* Please see LICENSE file for your rights under this license. */ + +import { Component } from "react"; +import PropTypes from "prop-types"; +import { ignoreCancel, makeCancelable, Result } from "../../util"; + +/** + * A component for getting API data. It can retrieve information once, + * periodically, or on call (refresh). + */ +export class WithAPIData extends Component { + static propTypes = { + apiCall: PropTypes.func.isRequired, + repeatOptions: PropTypes.shape({ + ignoreCancel: PropTypes.bool, + interval: PropTypes.number + }), + /** + * Render the children before the first API request is done + */ + renderInitial: PropTypes.func.isRequired, + /** + * Render the children if the API request succeeded + * + * @param data the data returned from the API call + * @param refresh a function to trigger an asynchronous data refresh + */ + renderOk: PropTypes.func.isRequired, + /** + * Render the children if the API request failed + * + * @param error the error returned from the API call + * @param refresh a function to trigger an asynchronous data refresh + */ + renderErr: PropTypes.func.isRequired + }; + + static defaultProps = { + repeatOptions: { + ignoreCancel: true, + repeatInterval: 0 + } + }; + + state = { + apiResult: null + }; + + loadData = () => { + // Only repeat if there is a non-zero repeat interval + const cancelOptions = { + repeat: this.props.repeatOptions.interval !== 0 ? this.loadData : null, + interval: this.props.repeatOptions.interval + }; + + this.dataHandle = makeCancelable(this.props.apiCall(), cancelOptions); + + if (this.props.repeatOptions.ignoreCancel) { + this.dataHandle.promise = this.dataHandle.promise.catch(ignoreCancel); + } + + this.dataHandle.promise + .then(data => { + this.setState({ + apiResult: Result.Ok(data) + }); + }) + .catch(error => { + this.setState({ + apiResult: Result.Err(error) + }); + }); + }; + + componentDidMount() { + this.loadData(); + } + + componentWillUnmount() { + this.dataHandle.cancel(); + } + + render() { + if (!this.state.apiResult) { + return this.props.renderInitial(); + } + + if (Result.isOk(this.state.apiResult)) { + return this.props.renderOk(this.state.apiResult.value, this.loadData); + } else { + return this.props.renderErr(this.state.apiResult.value, this.loadData); + } + } +} diff --git a/src/components/dashboard/ClientsGraph.js b/src/components/dashboard/ClientsGraph.js index e43f219..7c0e164 100644 --- a/src/components/dashboard/ClientsGraph.js +++ b/src/components/dashboard/ClientsGraph.js @@ -12,7 +12,8 @@ import React, { Component } from "react"; import ReactDOM from "react-dom"; import { Line } from "react-chartjs-2"; import { translate } from "react-i18next"; -import { padNumber, api, makeCancelable, ignoreCancel } from "../../utils"; +import { padNumber, makeCancelable, ignoreCancel } from "../../util"; +import api from "../../util/api"; import ChartTooltip from "./ChartTooltip"; class ClientsGraph extends Component { diff --git a/src/components/dashboard/ForwardDestinationsChart.js b/src/components/dashboard/ForwardDestinationsChart.js index 8c485f3..167e370 100644 --- a/src/components/dashboard/ForwardDestinationsChart.js +++ b/src/components/dashboard/ForwardDestinationsChart.js @@ -10,7 +10,7 @@ import React from "react"; import { translate } from "react-i18next"; -import { api } from "../../utils"; +import api from "../../util/api"; import GenericDoughnutChart from "./GenericDoughnutChart"; const ForwardDestinationsChart = ({ t }) => ( diff --git a/src/components/dashboard/GenericDoughnutChart.js b/src/components/dashboard/GenericDoughnutChart.js index 8c3206c..bd8ba39 100644 --- a/src/components/dashboard/GenericDoughnutChart.js +++ b/src/components/dashboard/GenericDoughnutChart.js @@ -11,7 +11,7 @@ import React, { Component } from "react"; import { Doughnut } from "react-chartjs-2"; import PropTypes from "prop-types"; -import { makeCancelable, ignoreCancel } from "../../utils"; +import { makeCancelable, ignoreCancel } from "../../util"; class GenericDoughnutChart extends Component { state = { diff --git a/src/components/dashboard/QueriesGraph.js b/src/components/dashboard/QueriesGraph.js index f61057f..30146f8 100644 --- a/src/components/dashboard/QueriesGraph.js +++ b/src/components/dashboard/QueriesGraph.js @@ -11,7 +11,8 @@ import React, { Component } from "react"; import { Line } from "react-chartjs-2"; import { translate } from "react-i18next"; -import { padNumber, api, makeCancelable, ignoreCancel } from "../../utils"; +import { padNumber, makeCancelable, ignoreCancel } from "../../util"; +import api from "../../util/api"; class QueriesGraph extends Component { state = { diff --git a/src/components/dashboard/QueryTypesChart.js b/src/components/dashboard/QueryTypesChart.js index 130ec54..1bc58b8 100644 --- a/src/components/dashboard/QueryTypesChart.js +++ b/src/components/dashboard/QueryTypesChart.js @@ -10,7 +10,7 @@ import React from "react"; import { translate } from "react-i18next"; -import { api } from "../../utils"; +import api from "../../util/api"; import GenericDoughnutChart from "./GenericDoughnutChart"; const QueryTypesChart = ({ t }) => ( diff --git a/src/components/dashboard/SummaryStats.js b/src/components/dashboard/SummaryStats.js index 001773a..f2e5f32 100644 --- a/src/components/dashboard/SummaryStats.js +++ b/src/components/dashboard/SummaryStats.js @@ -9,57 +9,21 @@ * Please see LICENSE file for your rights under this license. */ import React, { Component, Fragment } from "react"; +import PropTypes from "prop-types"; import { translate } from "react-i18next"; -import { api, ignoreCancel, makeCancelable } from "../../utils"; +import { WithAPIData } from "../common/WithAPIData"; +import api from "../../util/api"; class SummaryStats extends Component { - state = { - blockedQueries: "---", - totalQueries: "---", - percentBlocked: "---", - gravityDomains: "---", - uniqueClients: "---" + static propTypes = { + totalQueries: PropTypes.string.isRequired, + blockedQueries: PropTypes.string.isRequired, + percentBlocked: PropTypes.string.isRequired, + gravityDomains: PropTypes.string.isRequired, + uniqueClients: PropTypes.oneOfType([PropTypes.number, PropTypes.string]) + .isRequired }; - constructor(props) { - super(props); - this.updateStats = this.updateStats.bind(this); - } - - updateStats() { - this.updateHandler = makeCancelable(api.getSummary(), { - repeat: this.updateStats, - interval: 5000 - }); - this.updateHandler.promise - .then(res => { - this.setState({ - blockedQueries: res.blocked_queries.toLocaleString(), - totalQueries: res.total_queries.toLocaleString(), - percentBlocked: res.percent_blocked.toFixed(2).toLocaleString() + "%", - gravityDomains: res.domains_blocked.toLocaleString(), - uniqueClients: res.unique_clients - }); - }) - .catch(ignoreCancel) - .catch(() => { - this.setState({ - totalQueries: "Lost", - blockedQueries: "Connection", - percentBlocked: "To", - gravityDomains: "API" - }); - }); - } - - componentDidMount() { - this.updateStats(); - } - - componentWillUnmount() { - this.updateHandler.cancel(); - } - render() { const { t } = this.props; @@ -73,10 +37,10 @@ class SummaryStats extends Component {
-

{this.state.totalQueries}

+

{this.props.totalQueries}

{t("Total Queries ({{count}} clients)", { - count: this.state.uniqueClients + count: this.props.uniqueClients })}

@@ -90,7 +54,7 @@ class SummaryStats extends Component {
-

{this.state.blockedQueries}

+

{this.props.blockedQueries}

{t("Queries Blocked")}

@@ -103,7 +67,7 @@ class SummaryStats extends Component {
-

{this.state.percentBlocked}

+

{this.props.percentBlocked}

{t("Percent Blocked")}

@@ -116,7 +80,7 @@ class SummaryStats extends Component {
-

{this.state.gravityDomains}

+

{this.props.gravityDomains}

{t("Domains On Blocklist")}

@@ -126,4 +90,55 @@ class SummaryStats extends Component { } } -export default translate(["common", "dashboard"])(SummaryStats); +/** + * Transform the API data into props for the component + * + * @param data the API data + * @returns {*} the transformed props + */ +export const transformData = data => ({ + totalQueries: data.total_queries.toLocaleString(), + blockedQueries: data.blocked_queries.toLocaleString(), + percentBlocked: data.percent_blocked.toFixed(2).toLocaleString() + "%", + gravityDomains: data.domains_blocked.toLocaleString(), + uniqueClients: data.unique_clients +}); + +/** + * The props the summary stats should use when it fails to get the API data + * (it does not need the error object) + * + * @returns {*} the error props + */ +export const errorState = () => ({ + totalQueries: "Lost", + blockedQueries: "Connection", + percentBlocked: "To", + gravityDomains: "API", + uniqueClients: "" +}); + +export const TranslatedSummaryStats = translate(["common", "dashboard"])( + SummaryStats +); + +export default props => ( + ( + + )} + renderOk={data => ( + + )} + renderErr={() => } + /> +); diff --git a/src/components/dashboard/SummaryStats.test.js b/src/components/dashboard/SummaryStats.test.js index e939a93..40495c2 100644 --- a/src/components/dashboard/SummaryStats.test.js +++ b/src/components/dashboard/SummaryStats.test.js @@ -11,7 +11,11 @@ import React from "react"; import { shallow } from "enzyme"; import fetchMock from "fetch-mock"; -import SummaryStats from "./SummaryStats"; +import { + TranslatedSummaryStats, + transformData, + errorState +} from "./SummaryStats"; const endpoint = "/admin/api/stats/summary"; const fakeData = { @@ -30,7 +34,9 @@ const fakeData = { it("displays summary stats correctly", async () => { fetchMock.mock(endpoint, fakeData); - const wrapper = shallow(); + const wrapper = shallow( + + ); await tick(); wrapper.update(); @@ -38,7 +44,6 @@ it("displays summary stats correctly", async () => { expect(wrapper.childAt(0).find("h3")).toHaveText( fakeData.total_queries.toLocaleString() ); - expect(wrapper.state().uniqueClients).toEqual(fakeData.unique_clients); expect(wrapper.childAt(1).find("h3")).toHaveText( fakeData.blocked_queries.toLocaleString() ); @@ -53,7 +58,7 @@ it("displays summary stats correctly", async () => { it("displays an error message on error", async () => { fetchMock.mock(endpoint, { error: {} }); - const wrapper = shallow(); + const wrapper = shallow(); await tick(); wrapper.update(); diff --git a/src/components/dashboard/TopBlocked.js b/src/components/dashboard/TopBlocked.js index 8d9902a..85e8e59 100644 --- a/src/components/dashboard/TopBlocked.js +++ b/src/components/dashboard/TopBlocked.js @@ -10,7 +10,7 @@ import React from "react"; import { translate } from "react-i18next"; -import { api } from "../../utils"; +import api from "../../util/api"; import TopTable from "./TopTable"; const TopBlocked = ({ t, ...props }) => ( diff --git a/src/components/dashboard/TopClients.js b/src/components/dashboard/TopClients.js index a1c342d..e6a3b2c 100644 --- a/src/components/dashboard/TopClients.js +++ b/src/components/dashboard/TopClients.js @@ -10,7 +10,7 @@ import React from "react"; import { translate } from "react-i18next"; -import { api } from "../../utils"; +import api from "../../util/api"; import TopTable from "./TopTable"; const TopClients = ({ t, ...props }) => ( diff --git a/src/components/dashboard/TopDomains.js b/src/components/dashboard/TopDomains.js index 92d6072..e2a0a7e 100644 --- a/src/components/dashboard/TopDomains.js +++ b/src/components/dashboard/TopDomains.js @@ -10,7 +10,7 @@ import React from "react"; import { translate } from "react-i18next"; -import { api } from "../../utils"; +import api from "../../util/api"; import TopTable from "./TopTable"; const TopDomains = ({ t, ...props }) => ( diff --git a/src/components/dashboard/TopTable.js b/src/components/dashboard/TopTable.js index f117698..671bdec 100644 --- a/src/components/dashboard/TopTable.js +++ b/src/components/dashboard/TopTable.js @@ -10,7 +10,7 @@ import React, { Component } from "react"; import PropTypes from "prop-types"; -import { ignoreCancel, makeCancelable } from "../../utils"; +import { ignoreCancel, makeCancelable } from "../../util"; class TopTable extends Component { state = { diff --git a/src/components/list/DomainInput.js b/src/components/list/DomainInput.js index cbe1d4d..c5c3003 100644 --- a/src/components/list/DomainInput.js +++ b/src/components/list/DomainInput.js @@ -11,7 +11,7 @@ import React, { Component } from "react"; import PropTypes from "prop-types"; import { translate } from "react-i18next"; -import { api } from "../../utils"; +import api from "../../util/api"; class DomainInput extends Component { state = { diff --git a/src/components/list/DomainInput.test.js b/src/components/list/DomainInput.test.js index 052c914..a0f17b3 100644 --- a/src/components/list/DomainInput.test.js +++ b/src/components/list/DomainInput.test.js @@ -11,7 +11,7 @@ import React from "react"; import { shallow } from "enzyme"; import DomainInput from "./DomainInput"; -import { api } from "../../utils"; +import api from "../../util/api"; import { isValidDomain } from "../../validate"; it("has a placeholder", () => { diff --git a/src/components/list/DomainList.js b/src/components/list/DomainList.js index ccf6fc6..ba98db9 100644 --- a/src/components/list/DomainList.js +++ b/src/components/list/DomainList.js @@ -11,7 +11,8 @@ import React, { Component } from "react"; import PropTypes from "prop-types"; import { translate } from "react-i18next"; -import { api, ignoreCancel, makeCancelable } from "../../utils"; +import { ignoreCancel, makeCancelable } from "../../util"; +import api from "../../util/api"; class DomainList extends Component { onRemove(domain) { diff --git a/src/components/list/DomainList.test.js b/src/components/list/DomainList.test.js index ab04e30..77452ac 100644 --- a/src/components/list/DomainList.test.js +++ b/src/components/list/DomainList.test.js @@ -11,7 +11,7 @@ import React from "react"; import { shallow } from "enzyme"; import DomainList from "./DomainList"; -import { api } from "../../utils"; +import api from "../../util/api"; const domains = ["domain1.com", "domain2.com", "domain3.com"]; diff --git a/src/components/list/ListPage.js b/src/components/list/ListPage.js index 8c99ca0..7e73667 100644 --- a/src/components/list/ListPage.js +++ b/src/components/list/ListPage.js @@ -14,7 +14,7 @@ import { translate } from "react-i18next"; import DomainInput from "./DomainInput"; import Alert from "../common/Alert"; import DomainList from "./DomainList"; -import { ignoreCancel, makeCancelable } from "../../utils"; +import { ignoreCancel, makeCancelable } from "../../util"; class ListPage extends Component { state = { diff --git a/src/components/log/QueryLog.js b/src/components/log/QueryLog.js index 20ef6c3..4bd9da2 100644 --- a/src/components/log/QueryLog.js +++ b/src/components/log/QueryLog.js @@ -11,7 +11,8 @@ import React, { Component } from "react"; import ReactTable from "react-table"; import { translate } from "react-i18next"; -import { api, ignoreCancel, makeCancelable, padNumber } from "../../utils"; +import { ignoreCancel, makeCancelable, padNumber } from "../../util"; +import api from "../../util/api"; import "react-table/react-table.css"; class QueryLog extends Component { diff --git a/src/components/settings/DHCPInfo.js b/src/components/settings/DHCPInfo.js index c5a345c..9867a41 100644 --- a/src/components/settings/DHCPInfo.js +++ b/src/components/settings/DHCPInfo.js @@ -10,7 +10,8 @@ import React, { Component } from "react"; import { translate } from "react-i18next"; -import { api, ignoreCancel, makeCancelable } from "../../utils"; +import { ignoreCancel, makeCancelable } from "../../util"; +import api from "../../util/api"; import { Button, Col, diff --git a/src/components/settings/DNSInfo.js b/src/components/settings/DNSInfo.js index 5ecabf6..7e7c278 100644 --- a/src/components/settings/DNSInfo.js +++ b/src/components/settings/DNSInfo.js @@ -10,7 +10,8 @@ import React, { Component } from "react"; import { translate } from "react-i18next"; -import { api, ignoreCancel, makeCancelable } from "../../utils"; +import { ignoreCancel, makeCancelable } from "../../util"; +import api from "../../util/api"; class DNSInfo extends Component { state = { diff --git a/src/components/settings/FTLInfo.js b/src/components/settings/FTLInfo.js index 71f4bac..6a4d1ee 100644 --- a/src/components/settings/FTLInfo.js +++ b/src/components/settings/FTLInfo.js @@ -10,7 +10,8 @@ import React, { Component } from "react"; import { translate } from "react-i18next"; -import { api, ignoreCancel, makeCancelable } from "../../utils"; +import { ignoreCancel, makeCancelable } from "../../util"; +import api from "../../util/api"; class FTLInfo extends Component { state = { diff --git a/src/components/settings/NetworkInfo.js b/src/components/settings/NetworkInfo.js index 0cd94af..4f1c3e8 100644 --- a/src/components/settings/NetworkInfo.js +++ b/src/components/settings/NetworkInfo.js @@ -10,7 +10,8 @@ import React, { Component } from "react"; import { translate } from "react-i18next"; -import { api, ignoreCancel, makeCancelable } from "../../utils"; +import { ignoreCancel, makeCancelable } from "../../util"; +import api from "../../util/api"; import { Col, Form, FormGroup, Input, Label } from "reactstrap"; class NetworkInfo extends Component { diff --git a/src/components/settings/VersionInfo.js b/src/components/settings/VersionInfo.js index ced1ce5..a8b4d59 100644 --- a/src/components/settings/VersionInfo.js +++ b/src/components/settings/VersionInfo.js @@ -10,7 +10,8 @@ import React, { Component } from "react"; import { translate } from "react-i18next"; -import { api, ignoreCancel, makeCancelable } from "../../utils"; +import { ignoreCancel, makeCancelable } from "../../util"; +import api from "../../util/api"; import VersionCard from "./VersionCard"; class VersionInfo extends Component { diff --git a/src/containers/Full.js b/src/containers/Full.js index ead2fcf..b0f011a 100644 --- a/src/containers/Full.js +++ b/src/containers/Full.js @@ -13,7 +13,7 @@ import { Switch, Route, Redirect } from "react-router-dom"; import Header, { mobileSidebarHide } from "../components/common/Header"; import Sidebar from "../components/common/Sidebar"; import Footer from "../components/common/Footer"; -import { api } from "../utils"; +import api from "../util/api"; import { nav } from "../routes"; export default props => ( diff --git a/src/index.js b/src/index.js index 78fd6b3..828ba91 100644 --- a/src/index.js +++ b/src/index.js @@ -16,7 +16,7 @@ import "font-awesome/css/font-awesome.min.css"; import "simple-line-icons/css/simple-line-icons.css"; import "./scss/style.css"; import Full from "./containers/Full"; -import { api } from "./utils"; +import api from "./util/api"; import { setupI18n } from "./i18n"; // Before rendering anything, check if there is a session cookie. diff --git a/src/setupTests.js b/src/setupTests.js index 6dba3f4..087ea04 100644 --- a/src/setupTests.js +++ b/src/setupTests.js @@ -11,7 +11,7 @@ import { configure } from "enzyme"; import Adapter from "enzyme-adapter-react-16"; import "jest-enzyme"; -import { api } from "./utils"; +import api from "./util/api"; import fetchMock from "fetch-mock"; // Setup enzyme diff --git a/src/util/api.js b/src/util/api.js new file mode 100644 index 0000000..ec3358b --- /dev/null +++ b/src/util/api.js @@ -0,0 +1,85 @@ +import http from "./http"; + +export default { + loggedIn: false, + authenticate(key) { + return http.get("auth", { + headers: new Headers({ "X-Pi-hole-Authenticate": key }) + }); + }, + logout() { + return http.delete("auth"); + }, + getSummary() { + return http.get("stats/summary"); + }, + getHistoryGraph() { + return http.get("stats/overTime/history"); + }, + getClientsGraph() { + return http.get("stats/overTime/clients"); + }, + getQueryTypes() { + return http.get("stats/query_types"); + }, + getForwardDestinations() { + return http.get("stats/forward_destinations"); + }, + getTopDomains() { + return http.get("stats/top_domains"); + }, + getTopBlocked() { + return http.get("stats/top_blocked"); + }, + getTopClients() { + return http.get("stats/top_clients"); + }, + getHistory() { + return http.get("stats/history"); + }, + getWhitelist() { + return http.get("dns/whitelist"); + }, + getBlacklist() { + return http.get("dns/blacklist"); + }, + getRegexlist() { + return http.get("dns/regexlist"); + }, + addWhitelist(domain) { + return http.post("dns/whitelist", { domain: domain }); + }, + addBlacklist(domain) { + return http.post("dns/blacklist", { domain: domain }); + }, + addRegexlist(domain) { + return http.post("dns/regexlist", { domain: domain }); + }, + removeWhitelist(domain) { + return http.delete("dns/whitelist/" + domain); + }, + removeBlacklist(domain) { + return http.delete("dns/blacklist/" + domain); + }, + removeRegexlist(domain) { + return http.delete("dns/regexlist/" + encodeURIComponent(domain)); + }, + getStatus() { + return http.get("dns/status"); + }, + getNetworkInfo() { + return http.get("settings/network"); + }, + getVersion() { + return http.get("version"); + }, + getFTLdb() { + return http.get("settings/ftldb"); + }, + getDNSInfo() { + return http.get("settings/dns"); + }, + getDHCPInfo() { + return http.get("settings/dhcp"); + } +}; diff --git a/src/util/http.js b/src/util/http.js new file mode 100644 index 0000000..6740cc9 --- /dev/null +++ b/src/util/http.js @@ -0,0 +1,172 @@ +/* Pi-hole: A black hole for Internet advertisements +* (c) 2017 Pi-hole, LLC (https://pi-hole.net) +* Network-wide ad blocking via your own hardware. +* +* Web Interface +* Basic HTTP functions for accessing API endpoints +* +* 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 config from "../config"; + +/** + * A group of HTTP functions. Each function parses the response checks for + * errors + */ +export default { + /** + * Perform a GET request + * + * @param url the URL to access + * @param options optional fetch configuration + * @returns {Promise<* | never>} a promise with the data or error returned + */ + get(url, options = {}) { + return fetch(urlFor(url), { + credentials: credentialType(), + ...options + }) + .then(checkIfLoggedOut) + .then(convertJSON) + .catch(convertJSON) + .then(checkForErrors); + }, + + /** + * Perform a POST request + * + * @param url the URL to access + * @param data the data to send + * @returns {Promise<* | never>} a promise with the data or error returned + */ + post(url, data) { + return fetch(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); + }, + + /** + * Perform a PUT request + * + * @param url the URL to access + * @param data the data to send + * @returns {Promise<* | never>} a promise with the data or error returned + */ + put(url, data) { + return fetch(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); + }, + + /** + * Perform a DELETE request + * + * @param url the URL to access + * @returns {Promise<* | never>} a promise with the data or error returned + */ + delete(url) { + return fetch(urlFor(url), { + method: "DELETE", + credentials: credentialType() + }) + .then(checkIfLoggedOut) + .then(convertJSON) + .catch(convertJSON) + .then(checkForErrors); + } +}; + +/** + * 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 + */ +const checkIfLoggedOut = response => { + if (api.loggedIn && response.status === 401) { + // Clear the user's old session and refresh the page + document.cookie = + "user_id=; Path=/; Expires=Thu, 01 Jan 1970 00:00:01 GMT;"; + window.location.reload(); + return Promise.reject({ isCanceled: true }); + } + + return Promise.resolve(response); +}; + +/** + * If the input is a Response, return a promise for parsing the JSON. + * If the input is an Error, return a rejecting promise with error. + * If the request was canceled, return a rejecting promise with cancel object. + * + * @param data a Response or Error + * @returns {*} a promise with the parsed JSON, or the error + */ +const convertJSON = data => { + if (data.isCanceled || data instanceof Error) { + return Promise.reject(data); + } + + return data.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 + * rejecting promise with the error + */ +const checkForErrors = data => { + if (data.error) { + return Promise.reject(data.error); + } + + return Promise.resolve(data); +}; + +/** + * Get the URL for an endpoint + * + * @param endpoint the endpoint + * @returns {string} the URL for the endpoint + */ +const urlFor = endpoint => { + let apiLocation; + + if (config.fakeAPI) { + apiLocation = process.env.PUBLIC_URL + "/fakeAPI"; + } else { + apiLocation = "/admin/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"; +}; diff --git a/src/util/index.js b/src/util/index.js new file mode 100644 index 0000000..b7525bc --- /dev/null +++ b/src/util/index.js @@ -0,0 +1,104 @@ +/* Pi-hole: A black hole for Internet advertisements +* (c) 2017 Pi-hole, LLC (https://pi-hole.net) +* Network-wide ad blocking via your own hardware. +* +* Web Interface +* Various utilities +* +* This file is copyright under the latest version of the EUPL. +* Please see LICENSE file for your rights under this license. */ + +/** + * Pad a two digit number + * + * @param num the number + * @returns {string} a padding number string + */ +export const padNumber = num => { + return ("00" + num).substr(-2, 2); +}; + +/** + * Make a promise cancelable and repeatable + * + * @param promise the promise + * @param repeat the function to call to repeat the promise + * @param interval the amount of time to wait until repeating + * @returns {{promise: Promise, cancel(): void}} a handle on the cancelable + * promise + */ +export const makeCancelable = ( + promise, + { repeat = null, interval = 0 } = {} +) => { + let hasCanceled = false; + let repeatId = null; + + const handle = (resolve, reject, val, isError) => { + if (hasCanceled) reject({ isCanceled: true }); + else { + if (isError) reject(val); + else resolve(val); + + if (repeat) repeatId = setTimeout(repeat, interval); + } + }; + + const wrappedPromise = new Promise((resolve, reject) => { + promise.then( + val => handle(resolve, reject, val, false), + error => handle(resolve, reject, error, true) + ); + }); + + return { + promise: wrappedPromise, + cancel() { + clearTimeout(repeatId); + hasCanceled = true; + } + }; +}; + +/** + * Ignore canceled promises (pass into a promise's catch function) + * + * @param err the error from catching the promise + */ +export const ignoreCancel = err => { + if (!err.isCanceled) throw err; +}; + +/** + * A result type, similar to Rust's Result. + * A result can either be Ok or Err + */ +export const Result = { + /** + * Create a new Ok Result + * + * @param value the value of the result + */ + Ok: value => ({ type: "Ok", value }), + + /** + * Create a new Err Result + * + * @param value the value of the result (error) + */ + Err: value => ({ type: "Err", value }), + + /** + * Check if the result is Ok + * @param result the result + * @returns {boolean} if it is an Ok result + */ + isOk: result => result.type === "Ok", + + /** + * Check if the result is Err + * @param result the result + * @returns {boolean} if it is an Err result + */ + isErr: result => result.type === "Err" +}; diff --git a/src/utils.js b/src/utils.js deleted file mode 100644 index d53e52c..0000000 --- a/src/utils.js +++ /dev/null @@ -1,205 +0,0 @@ -/* Pi-hole: A black hole for Internet advertisements -* (c) 2017 Pi-hole, LLC (https://pi-hole.net) -* Network-wide ad blocking via your own hardware. -* -* Web Interface -* Various utilities including the API -* -* This file is copyright under the latest version of the EUPL. -* Please see LICENSE file for your rights under this license. */ - -import config from "./config"; - -export const padNumber = num => { - return ("00" + num).substr(-2, 2); -}; - -export const makeCancelable = ( - promise, - { repeat = null, interval = 0 } = {} -) => { - let hasCanceled = false; - let repeatId = null; - - const handle = (resolve, reject, val, isError) => { - if (hasCanceled) reject({ isCanceled: true }); - else { - if (isError) reject(val); - else resolve(val); - - if (repeat) repeatId = setTimeout(repeat, interval); - } - }; - - const wrappedPromise = new Promise((resolve, reject) => { - promise.then( - val => handle(resolve, reject, val, false), - error => handle(resolve, reject, error, true) - ); - }); - - return { - promise: wrappedPromise, - cancel() { - clearTimeout(repeatId); - hasCanceled = true; - } - }; -}; - -export const ignoreCancel = err => { - if (!err.isCanceled) throw err; -}; - -export const api = { - loggedIn: false, - authenticate(key) { - return fetch(api.urlFor("auth"), { - headers: new Headers({ "X-Pi-hole-Authenticate": key }), - credentials: this.credentialType() - }) - .then(api.convertJSON) - .then(api.checkForErrors); - }, - logout() { - return api.delete("auth"); - }, - getSummary() { - return api.get("stats/summary"); - }, - getHistoryGraph() { - return api.get("stats/overTime/history"); - }, - getClientsGraph() { - return api.get("stats/overTime/clients"); - }, - getQueryTypes() { - return api.get("stats/query_types"); - }, - getForwardDestinations() { - return api.get("stats/forward_destinations"); - }, - getTopDomains() { - return api.get("stats/top_domains"); - }, - getTopBlocked() { - return api.get("stats/top_blocked"); - }, - getTopClients() { - return api.get("stats/top_clients"); - }, - getHistory() { - return api.get("stats/history"); - }, - getWhitelist() { - return api.get("dns/whitelist"); - }, - getBlacklist() { - return api.get("dns/blacklist"); - }, - getRegexlist() { - return api.get("dns/regexlist"); - }, - addWhitelist(domain) { - return api.post("dns/whitelist", { domain: domain }); - }, - addBlacklist(domain) { - return api.post("dns/blacklist", { domain: domain }); - }, - addRegexlist(domain) { - return api.post("dns/regexlist", { domain: domain }); - }, - removeWhitelist(domain) { - return api.delete("dns/whitelist/" + domain); - }, - removeBlacklist(domain) { - return api.delete("dns/blacklist/" + domain); - }, - removeRegexlist(domain) { - return api.delete("dns/regexlist/" + encodeURIComponent(domain)); - }, - getStatus() { - return api.get("dns/status"); - }, - get(url) { - return fetch(api.urlFor(url), { - credentials: this.credentialType() - }) - .then(api.checkIfLoggedOut) - .then(api.convertJSON) - .then(api.checkForErrors); - }, - post(url, data) { - return fetch(api.urlFor(url), { - method: "POST", - body: JSON.stringify(data), - headers: new Headers({ "Content-Type": "application/json" }), - credentials: this.credentialType() - }) - .then(api.checkIfLoggedOut) - .then(api.convertJSON) - .then(api.checkForErrors); - }, - delete(url) { - return fetch(api.urlFor(url), { - method: "DELETE", - credentials: this.credentialType() - }) - .then(api.checkIfLoggedOut) - .then(api.convertJSON) - .then(api.checkForErrors); - }, - getNetworkInfo() { - return api.get("settings/network"); - }, - getVersion() { - return api.get("version"); - }, - getFTLdb() { - return api.get("settings/ftldb"); - }, - getDNSInfo() { - return api.get("settings/dns"); - }, - getDHCPInfo() { - return api.get("settings/dhcp"); - }, - /** - * If the user is logged in, check if the user's session has lapsed. - * If so, log them out and refresh the page. - */ - checkIfLoggedOut(response) { - if (api.loggedIn && response.status === 401) { - // Clear the user's old session and refresh the page - document.cookie = - "user_id=; Path=/; Expires=Thu, 01 Jan 1970 00:00:01 GMT;"; - window.location.reload(); - return Promise.reject({ isCanceled: true }); - } - - return Promise.resolve(response); - }, - async convertJSON(data) { - if (!data.ok) return Promise.reject({ data, json: await data.json() }); - else return data.json(); - }, - checkForErrors(data) { - if (data.error) { - return Promise.reject(data.error); - } - return Promise.resolve(data); - }, - urlFor(endpoint) { - let apiLocation; - - if (config.fakeAPI) apiLocation = process.env.PUBLIC_URL + "/fakeAPI"; - else apiLocation = "/admin/api"; - - return apiLocation + "/" + endpoint; - }, - 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"; - } -}; diff --git a/src/utils.test.js b/src/utils.test.js index 4d2355c..bb83461 100644 --- a/src/utils.test.js +++ b/src/utils.test.js @@ -8,7 +8,7 @@ * This file is copyright under the latest version of the EUPL. * Please see LICENSE file for your rights under this license. */ -import { api } from "./utils"; +import api from "./util/api"; // This is a dumb test used to set up the next test, // which checks that the logged in state is reset before each test diff --git a/src/views/Blacklist.js b/src/views/Blacklist.js index ede888d..4c3e1c5 100644 --- a/src/views/Blacklist.js +++ b/src/views/Blacklist.js @@ -10,7 +10,7 @@ import React from "react"; import { translate } from "react-i18next"; -import { api } from "../utils"; +import api from "../util/api"; import ListPage from "../components/list/ListPage"; import { isValidDomain } from "../validate"; diff --git a/src/views/Dashboard.js b/src/views/Dashboard.js index 88f1150..6357915 100644 --- a/src/views/Dashboard.js +++ b/src/views/Dashboard.js @@ -17,7 +17,7 @@ import ForwardDestinationsChart from "../components/dashboard/ForwardDestination import TopDomains from "../components/dashboard/TopDomains"; import TopBlocked from "../components/dashboard/TopBlocked"; import TopClients from "../components/dashboard/TopClients"; -import { api } from "../utils"; +import api from "../util/api"; export default () => (
diff --git a/src/views/Login.js b/src/views/Login.js index aa055f8..d65174f 100644 --- a/src/views/Login.js +++ b/src/views/Login.js @@ -11,7 +11,7 @@ import React, { Component, Fragment } from "react"; import { Redirect } from "react-router-dom"; import sha from "sha.js"; -import { api } from "../utils"; +import api from "../util/api"; import logo from "../img/logo.svg"; import { routes } from "../routes"; import ForgotPassword from "../components/login/ForgotPassword"; diff --git a/src/views/Logout.js b/src/views/Logout.js index 8760779..c1f6bab 100644 --- a/src/views/Logout.js +++ b/src/views/Logout.js @@ -10,7 +10,7 @@ import React, { Component } from "react"; import { Redirect } from "react-router-dom"; -import { api } from "../utils"; +import api from "../util/api"; import config from "../config"; export default class Logout extends Component { diff --git a/src/views/Regexlist.js b/src/views/Regexlist.js index e453792..08e39a5 100644 --- a/src/views/Regexlist.js +++ b/src/views/Regexlist.js @@ -11,7 +11,7 @@ import React from "react"; import { translate } from "react-i18next"; import ListPage from "../components/list/ListPage"; -import { api } from "../utils"; +import api from "../util/api"; import { isValidRegex } from "../validate"; const Regexlist = props => { diff --git a/src/views/Whitelist.js b/src/views/Whitelist.js index 5f641c0..73e7aad 100644 --- a/src/views/Whitelist.js +++ b/src/views/Whitelist.js @@ -11,7 +11,7 @@ import React from "react"; import { translate } from "react-i18next"; import ListPage from "../components/list/ListPage"; -import { api } from "../utils"; +import api from "../util/api"; import { isValidDomain } from "../validate"; const Whitelist = props => {