Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[FIX] Adds support for proxying CORS requests #392

Merged
merged 13 commits into from
Jan 1, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
15 changes: 14 additions & 1 deletion docs/configuring.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)**
Expand All @@ -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**
Expand Down
25 changes: 25 additions & 0 deletions docs/widgets.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
90 changes: 48 additions & 42 deletions netlify.toml
Original file line number Diff line number Diff line change
@@ -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"

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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)",
Expand Down
2 changes: 1 addition & 1 deletion services/cors-proxy.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand Down
48 changes: 48 additions & 0 deletions services/serverless-functions/netlify-cors.js
Original file line number Diff line number Diff line change
@@ -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);
});
};
6 changes: 6 additions & 0 deletions src/assets/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
29 changes: 6 additions & 23 deletions src/components/Widgets/HealthChecks.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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');
Expand All @@ -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) {
Expand Down
72 changes: 29 additions & 43 deletions src/components/Widgets/NdCpuHistory.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -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,
Expand Down
Loading