🔀 Merge pull request #392 from Lissy93/FEATURE/add-request-proxy

[FIX] Adds support for proxying CORS requests
Happy new year :) 🎇
This commit is contained in:
Alicia Sykes 2022-01-01 01:18:42 +00:00 committed by GitHub
commit 3b7d5a6ff7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 262 additions and 172 deletions

View File

@ -1,5 +1,11 @@
# Changelog
## ⚡️ 1.9.6 - Adds Proxy Support for Widget Requests [PR #392](https://github.com/Lissy93/dashy/pull/392)
- Refactors widget mixin to include data requests, so that code can be shared between widgets
- Adds a Node endpoint for proxying requests server-side, used for APIs that are not CORS enabled
- Adds option to config file for user to force proxying of requests
- Writes a Netlify cloud function to support proxying when the app is hosted on Netlify
## 🐛 1.9.5 - Bug fixes and Minor Improvements [PR #388](https://github.com/Lissy93/dashy/pull/388)
- Adds icon.horse to supported favicon APIs
- Fixes tile move bug, Re: #366

View File

@ -172,7 +172,8 @@ For more info, see the **[Authentication Docs](/docs/authentication.md)**
--- | --- | --- | ---
**`name`** | `string` | Required | The title for the section
**`icon`** | `string` | _Optional_ | An single icon to be displayed next to the title. See [`section.icon`](#sectionicon-and-sectionitemicon)
**`items`** | `array` | Required | An array of items to be displayed within the section. See [`item`](#sectionitem)
**`items`** | `array` | _Optional_ | An array of items to be displayed within the section. See [`item`](#sectionitem). Sections must include either 1 or more items, or 1 or more widgets.
**`widgets`** | `array` | _Optional_ | An array of widgets to be displayed within the section. See [`widget`](#sectionwidget-optional)
**`displayData`** | `object` | _Optional_ | Meta-data to optionally overide display settings for a given section. See [`displayData`](#sectiondisplaydata-optional)
**[⬆️ Back to Top](#configuring)**
@ -198,6 +199,18 @@ For more info, see the **[Authentication Docs](/docs/authentication.md)**
**[⬆️ Back to Top](#configuring)**
### `section.widget` _(optional)_
**Field** | **Type** | **Required**| **Description**
--- | --- | --- | ---
**`type`** | `string` | Required | The widget type. See [Widget Docs](/docs/widgets.md) for full list of supported widgets
**`options`** | `object` | _Optional_ | Some widgets accept either optional or required additional options. Again, see the [Widget Docs](/docs/widgets.md) for full list of options
**`updateInterval`** | `number` | _Optional_ | You can keep a widget constantly updated by specifying an update interval, in seconds. See [Continuous Updates Docs](/docs/widgets.md#continuous-updates) for more info
**`useProxy`** | `boolean` | _Optional_ | Some widgets make API requests to services that are not CORS-enabled. For these instances, you will need to route requests through a proxy, Dashy has a built in CORS-proxy, which you can use by setting this option to `true`. Defaults to `false`. See the [Proxying Requests Docs](/docs/widgets.md#proxying-requests) for more info
**[⬆️ Back to Top](#configuring)**
### `section.displayData` _(optional)_
**Field** | **Type** | **Required**| **Description**

View File

@ -1208,6 +1208,31 @@ For more info on how to apply custom variables, see the [Theming Docs](/docs/the
---
### Proxying Requests
If a widget fails to make a data request, and the console shows a CORS error, this means the server is blocking client-side requests.
Dashy has a built-in CORS proxy ([`services/cors-proxy.js`](https://github.com/Lissy93/dashy/blob/master/services/cors-proxy.js)), which will be used automatically by some widgets, or can be forced to use by other by setting the `useProxy` option.
For example:
```yaml
widgets:
- type: pi-hole-stats
useProxy: true
options:
hostname: http://pi-hole.local
```
Alternativley, and more securley, you can set the auth headers on your service to accept requests from Dashy. For example:
```
Access-Control-Allow-Origin: https://location-of-dashy/
Vary: Origin
```
---
### Language Translations
Since most of the content displayed within widgets is fetched from an external API, unless that API supports multiple languages, translating dynamic content is not possible.

View File

@ -1,42 +1,48 @@
# Enables you to easily deploy a fork of Dashy to Netlify
# without the need to configure anything in admin UI
# Docs: https://www.netlify.com/docs/netlify-toml-reference/
# Essential site config
[build]
base = "/"
command = "yarn build"
publish = "dist"
functions = "services/serverless-functions"
# Site info, used for the 1-Click deploy page
[template.environment]
STATUSKIT_PAGE_TITLE = "Dashy"
STATUSKIT_COMPANY_LOGO = "https://raw.githubusercontent.com/Lissy93/dashy/master/docs/assets/logo.png"
STATUSKIT_SUPPORT_CONTACT_LINK = "https://github.com/lissy93/dashy"
STATUSKIT_RESOURCES_LINK = "https://dashy.to/docs"
# Redirect the Node endpoints to serverless functions
[[redirects]]
from = "/status-check"
to = "/.netlify/functions/cloud-status-check"
status = 301
force = true
[[redirects]]
from = "/config-manager/*"
to = "/.netlify/functions/not-supported"
status = 301
force = true
# For router history mode, ensure pages land on index
[[redirects]]
from = "/*"
to = "/index.html"
status = 200
# Set any security headers here
[[headers]]
for = "/*"
[headers.values]
# Uncomment to enable Netlify user control. You must have a paid plan.
# Basic-Auth = "someuser:somepassword anotheruser:anotherpassword"
# Enables you to easily deploy a fork of Dashy to Netlify
# without the need to configure anything in admin UI
# Docs: https://www.netlify.com/docs/netlify-toml-reference/
# Essential site config
[build]
base = "/"
command = "yarn build"
publish = "dist"
functions = "services/serverless-functions"
# Site info, used for the 1-Click deploy page
[template.environment]
STATUSKIT_PAGE_TITLE = "Dashy"
STATUSKIT_COMPANY_LOGO = "https://raw.githubusercontent.com/Lissy93/dashy/master/docs/assets/logo.png"
STATUSKIT_SUPPORT_CONTACT_LINK = "https://github.com/lissy93/dashy"
STATUSKIT_RESOURCES_LINK = "https://dashy.to/docs"
# Redirect the Node endpoints to serverless functions
[[redirects]]
from = "/status-check"
to = "/.netlify/functions/cloud-status-check"
status = 301
force = true
[[redirects]]
from = "/config-manager/*"
to = "/.netlify/functions/not-supported"
status = 301
force = true
[[redirects]]
from = "/cors-proxy"
to = "/.netlify/functions/netlify-cors"
status = 301
force = true
# For router history mode, ensure pages land on index
[[redirects]]
from = "/*"
to = "/index.html"
status = 200
# Set any security headers here
[[headers]]
for = "/*"
[headers.values]
# Uncomment to enable Netlify user control. You must have a paid plan.
# Basic-Auth = "someuser:somepassword anotheruser:anotherpassword"

View File

@ -1,6 +1,6 @@
{
"name": "Dashy",
"version": "1.9.5",
"version": "1.9.6",
"license": "MIT",
"main": "server",
"author": "Alicia Sykes <alicia@omg.lol> (https://aliciasykes.com)",

View File

@ -32,7 +32,7 @@ module.exports = (req, res) => {
// Prepare the request
const requestConfig = {
method: req.method,
url: targetURL + req.url,
url: targetURL,
json: req.body,
headers,
};

View File

@ -0,0 +1,48 @@
/* A Netlify cloud function to handle requests to CORS-disabled services */
const axios = require('axios');
exports.handler = (event, context, callback) => {
// Get input data
const { body, headers, queryStringParameters } = event;
// Get URL from header or GET param
const requestUrl = queryStringParameters.url || headers['Target-URL'] || headers['target-url'];
const returnError = (msg, error) => {
callback(null, {
statusCode: 400,
body: JSON.stringify({ success: false, msg, error }),
});
};
// If URL missing, return error
if (!requestUrl) {
returnError('Missing Target-URL header', null);
}
let custom = {};
try {
custom = JSON.parse(headers.CustomHeaders || headers.customheaders || '{}');
} catch (e) { returnError('Unable to parse custom headers'); }
// Response headers
const requestHeaders = {
'Access-Control-Allow-Origin': '*',
...custom,
};
// Prepare request
const requestConfig = {
method: 'GET',
url: requestUrl,
json: body,
headers: requestHeaders,
};
// Make request
axios.request(requestConfig)
.then((response) => {
callback(null, { statusCode: 200, body: JSON.stringify(response.data) });
}).catch((error) => {
returnError('Request failed', error);
});
};

View File

@ -263,6 +263,12 @@
"up": "Online",
"down": "Offline"
},
"net-data": {
"cpu-chart-title": "CPU History",
"mem-chart-title": "Memory Usage",
"mem-breakdown-title": "Memory Breakdown",
"load-chart-title": "System Load"
},
"system-info": {
"uptime": "Uptime"
},

View File

@ -17,9 +17,8 @@
</template>
<script>
import axios from 'axios';
import WidgetMixin from '@/mixins/WidgetMixin';
import { widgetApiEndpoints, serviceEndpoints } from '@/utils/defaults';
import { widgetApiEndpoints } from '@/utils/defaults';
import { capitalize, timestampToDateTime } from '@/utils/MiscHelpers';
export default {
@ -48,10 +47,6 @@ export default {
if (this.options.host) return `${this.options.host}/api/v1/checks`;
return `${widgetApiEndpoints.healthChecks}`;
},
proxyReqEndpoint() {
const baseUrl = process.env.VUE_APP_DOMAIN || window.location.origin;
return `${baseUrl}${serviceEndpoints.corsProxy}`;
},
apiKey() {
if (!this.options.apiKey) {
this.error('An API key is required, please see the docs for more info');
@ -62,23 +57,11 @@ export default {
methods: {
/* Make GET request to CoinGecko API endpoint */
fetchData() {
const requestConfig = {
method: 'GET',
url: this.proxyReqEndpoint,
headers: {
'access-control-request-headers': '*',
'Target-URL': this.endpoint,
CustomHeaders: JSON.stringify({ 'X-Api-Key': this.apiKey }),
},
};
axios.request(requestConfig)
.then((response) => {
this.processData(response.data);
}).catch((error) => {
this.error('Unable to fetch cron data', error);
}).finally(() => {
this.finishLoading();
});
this.overrideProxyChoice = true;
const authHeaders = { 'X-Api-Key': this.apiKey };
this.makeRequest(this.endpoint, authHeaders).then(
(response) => { this.processData(response); },
);
},
/* Assign data variables to the returned data */
processData(data) {

View File

@ -3,20 +3,12 @@
</template>
<script>
import axios from 'axios';
import WidgetMixin from '@/mixins/WidgetMixin';
import ChartingMixin from '@/mixins/ChartingMixin';
export default {
mixins: [WidgetMixin, ChartingMixin],
components: {},
data() {
return {
chartTitle: null,
chartData: null,
chartDom: null,
};
},
computed: {
/* URL where NetData is hosted */
netDataHost() {
@ -41,50 +33,44 @@ export default {
methods: {
/* Make GET request to NetData */
fetchData() {
axios.get(this.endpoint)
.then((response) => {
this.processData(response.data);
})
.catch((dataFetchError) => {
this.error('Unable to fetch data', dataFetchError);
})
.finally(() => {
this.finishLoading();
});
this.makeRequest(this.endpoint).then(
(response) => { this.processData(response); },
);
},
/* Assign data variables to the returned data */
processData(data) {
const timeData = [];
const systemCpu = [];
const userCpu = [];
data.data.reverse().forEach((reading) => {
timeData.push(this.formatDate(reading[0] * 1000));
systemCpu.push(reading[2]);
userCpu.push(reading[3]);
processData(inputData) {
const { labels, data } = inputData;
const timeData = []; // List of timestamps for axis
const resultGroup = {}; // List of datasets, for each label
data.reverse().forEach((reading) => {
labels.forEach((label, indx) => {
if (indx === 0) { // First value is the timestamp, add to axis
timeData.push(this.formatTime(reading[indx] * 1000));
} else { // All other values correspond to a label
if (!resultGroup[label]) resultGroup[label] = [];
resultGroup[label].push(reading[indx]);
}
});
});
this.chartData = {
labels: timeData,
datasets: [
{ name: 'System CPU', type: 'bar', values: systemCpu },
{ name: 'User CPU', type: 'bar', values: userCpu },
],
};
this.chartTitle = this.makeChartTitle(data.data);
this.renderChart();
const datasets = [];
Object.keys(resultGroup).forEach((label) => {
datasets.push({ name: label, type: 'bar', values: resultGroup[label] });
});
const timeChartData = { labels: timeData, datasets };
const chartTitle = this.makeChartTitle(data);
this.generateChart(timeChartData, chartTitle);
},
makeChartTitle(data) {
if (!data || !data[0][0]) return '';
const prefix = this.$t('widgets.net-data.cpu-chart-title');
if (!data || !data[0][0]) return prefix;
const diff = Math.round((data[data.length - 1][0] - data[0][0]) / 60);
return `Past ${diff} minutes`;
},
renderChart() {
this.chartDom = this.generateChart();
return `${prefix}: Past ${diff} minutes`;
},
/* Create new chart, using the crypto data */
generateChart() {
generateChart(timeChartData, chartTitle) {
return new this.Chart(`#${this.chartId}`, {
title: this.chartTitle,
data: this.chartData,
title: chartTitle,
data: timeChartData,
type: 'axis-mixed',
height: this.chartHeight,
colors: this.chartColors,

View File

@ -10,13 +10,6 @@ import ChartingMixin from '@/mixins/ChartingMixin';
export default {
mixins: [WidgetMixin, ChartingMixin],
components: {},
data() {
return {
chartTitle: null,
chartData: null,
chartDom: null,
};
},
computed: {
/* URL where NetData is hosted */
netDataHost() {
@ -31,7 +24,7 @@ export default {
return this.options.apiVersion || 'v1';
},
endpoint() {
return `${this.netDataHost}/api/${this.apiVersion}/data?chart=system.cpu`;
return `${this.netDataHost}/api/${this.apiVersion}/data?chart=system.load`;
},
/* A sudo-random ID for the chart DOM element */
chartId() {
@ -64,7 +57,7 @@ export default {
load5mins.push(reading[2]);
load15mins.push(reading[3]);
});
this.chartData = {
const chartData = {
labels: timeData,
datasets: [
{ name: '1 Min', type: 'bar', values: load1min },
@ -72,22 +65,20 @@ export default {
{ name: '15 Mins', type: 'bar', values: load15mins },
],
};
this.chartTitle = this.makeChartTitle(data.data);
this.renderChart();
const chartTitle = this.makeChartTitle(data.data);
this.generateChart(chartData, chartTitle);
},
makeChartTitle(data) {
if (!data || !data[0][0]) return '';
const prefix = this.$t('widgets.net-data.load-chart-title');
if (!data || !data[0][0]) return prefix;
const diff = Math.round((data[data.length - 1][0] - data[0][0]) / 60);
return `Past ${diff} minutes`;
},
renderChart() {
this.chartDom = this.generateChart();
return `${prefix}: Past ${diff} minutes`;
},
/* Create new chart, using the crypto data */
generateChart() {
generateChart(chartData, chartTitle) {
return new this.Chart(`#${this.chartId}`, {
title: this.chartTitle,
data: this.chartData,
title: chartTitle,
data: chartData,
type: 'axis-mixed',
height: this.chartHeight,
colors: this.chartColors,

View File

@ -6,7 +6,6 @@
</template>
<script>
import axios from 'axios';
import WidgetMixin from '@/mixins/WidgetMixin';
import ChartingMixin from '@/mixins/ChartingMixin';
@ -37,16 +36,9 @@ export default {
methods: {
/* Make GET request to NetData */
fetchData() {
axios.get(this.endpoint)
.then((response) => {
this.processData(response.data);
})
.catch((dataFetchError) => {
this.error('Unable to fetch data', dataFetchError);
})
.finally(() => {
this.finishLoading();
});
this.makeRequest(this.endpoint).then(
(response) => { this.processData(response); },
);
},
/* Assign data variables to the returned data */
processData(inputData) {
@ -86,7 +78,7 @@ export default {
/* Create new chart, using the crypto data */
generateHistoryChart(timeChartData) {
return new this.Chart(`#${this.chartId}`, {
title: 'History',
title: this.$t('widgets.net-data.mem-chart-title'),
data: timeChartData,
type: 'axis-mixed',
height: this.chartHeight,
@ -107,7 +99,7 @@ export default {
},
generateAggregateChart(aggregateChartData) {
return new this.Chart(`#aggregate-${this.chartId}`, {
title: 'Averages',
title: this.$t('widgets.net-data.mem-breakdown-title'),
data: aggregateChartData,
type: 'percentage',
height: 100,

View File

@ -18,7 +18,7 @@
</template>
<script>
import axios from 'axios';
// import axios from 'axios';
import WidgetMixin from '@/mixins/WidgetMixin';
import ChartingMixin from '@/mixins/ChartingMixin';
import { capitalize } from '@/utils/MiscHelpers';
@ -55,15 +55,9 @@ export default {
methods: {
/* Make GET request to local pi-hole instance */
fetchData() {
axios.get(this.endpoint)
this.makeRequest(this.endpoint)
.then((response) => {
this.processData(response.data);
})
.catch((dataFetchError) => {
this.error('Unable to fetch data', dataFetchError);
})
.finally(() => {
this.finishLoading();
this.processData(response);
});
},
/* Assign data variables to the returned data */

View File

@ -11,7 +11,6 @@
</template>
<script>
import axios from 'axios';
import WidgetMixin from '@/mixins/WidgetMixin';
import { showNumAsThousand } from '@/utils/MiscHelpers';
@ -46,19 +45,13 @@ export default {
methods: {
/* Make GET request to local pi-hole instance */
fetchData() {
axios.get(this.endpoint)
this.makeRequest(this.endpoint)
.then((response) => {
if (Array.isArray(response.data)) {
if (Array.isArray(response)) {
this.error('Got success, but found no results, possible authorization error');
} else {
this.processData(response.data);
this.processData(response);
}
})
.catch((dataFetchError) => {
this.error('Unable to fetch data', dataFetchError);
})
.finally(() => {
this.finishLoading();
});
},
/* Assign data variables to the returned data */

View File

@ -3,7 +3,6 @@
</template>
<script>
import axios from 'axios';
import WidgetMixin from '@/mixins/WidgetMixin';
import ChartingMixin from '@/mixins/ChartingMixin';
@ -31,17 +30,23 @@ export default {
methods: {
/* Make GET request to local pi-hole instance */
fetchData() {
axios.get(this.endpoint)
this.makeRequest(this.endpoint)
.then((response) => {
this.processData(response.data);
})
.catch((dataFetchError) => {
this.error('Unable to fetch data', dataFetchError);
})
.finally(() => {
this.finishLoading();
if (this.validate(response)) {
this.processData(response);
}
});
},
validate(response) {
if (!response.ads_over_time || !response.domains_over_time) {
this.error('Expected data was not returned from Pi-Hole');
return false;
} else if (response.ads_over_time.length < 1) {
this.error('Request completed succesfully, but no data in Pi-Hole yet');
return false;
}
return true;
},
/* Assign data variables to the returned data */
processData(data) {
const timeData = [];

View File

@ -319,7 +319,10 @@ export default {
},
/* Returns users specified widget options, or empty object */
widgetOptions() {
return this.widget.options || {};
const options = this.widget.options || {};
const useProxy = !!this.widget.useProxy;
const updateInterval = this.widget.updateInterval || 0;
return { useProxy, updateInterval, ...options };
},
/* A unique string to reference the widget by */
widgetRef() {

View File

@ -2,8 +2,10 @@
* Mixin that all pre-built and custom widgets extend from.
* Manages loading state, error handling, data updates and user options
*/
import axios from 'axios';
import ProgressBar from 'rsup-progress';
import ErrorHandler from '@/utils/ErrorHandler';
import { serviceEndpoints } from '@/utils/defaults';
const WidgetMixin = {
props: {
@ -14,11 +16,21 @@ const WidgetMixin = {
},
data: () => ({
progress: new ProgressBar({ color: 'var(--progress-bar)' }),
overrideProxyChoice: false,
}),
/* When component mounted, fetch initial data */
mounted() {
this.fetchData();
},
computed: {
proxyReqEndpoint() {
const baseUrl = process.env.VUE_APP_DOMAIN || window.location.origin;
return `${baseUrl}${serviceEndpoints.corsProxy}`;
},
useProxy() {
return this.options.useProxy || this.overrideProxyChoice;
},
},
methods: {
/* Re-fetches external data, called by parent. Usually overridden by widget */
update() {
@ -44,9 +56,36 @@ const WidgetMixin = {
fetchData() {
this.finishLoading();
},
/* Used as v-tooltip, pass text content in, and will show on hover */
tooltip(content) {
return { content, trigger: 'hover focus', delay: 250 };
},
/* Makes data request, returns promise */
makeRequest(endpoint, options) {
// Request Options
const method = 'GET';
const url = this.useProxy ? this.proxyReqEndpoint : endpoint;
const CustomHeaders = options ? JSON.stringify(options) : null;
const headers = this.useProxy
? { 'Target-URL': endpoint, CustomHeaders } : CustomHeaders;
// Make request
return new Promise((resolve, reject) => {
axios.request({ method, url, headers })
.then((response) => {
if (response.data.success === false) {
this.error('Proxy returned error from target server', response.data.message);
}
resolve(response.data);
})
.catch((dataFetchError) => {
this.error('Unable to fetch data', dataFetchError);
reject(dataFetchError);
})
.finally(() => {
this.finishLoading();
});
});
},
},
};