mirror of
https://github.com/Lissy93/dashy.git
synced 2025-07-28 16:14:33 +02:00
Merge pull request #6 from Lissy93/feature-cloud-backup-restore
Feature: Adds Cloud Backup and Restore
This commit is contained in:
commit
fcb260271b
@ -15,10 +15,12 @@ COPY . .
|
|||||||
RUN yarn build
|
RUN yarn build
|
||||||
|
|
||||||
# Production Stage
|
# Production Stage
|
||||||
|
ENV PORT 80
|
||||||
|
|
||||||
FROM nginx:1.15.7-alpine as production-stage
|
FROM nginx:1.15.7-alpine as production-stage
|
||||||
|
|
||||||
COPY --from=build-stage /app/dist /usr/share/nginx/html
|
COPY --from=build-stage /app/dist /usr/share/nginx/html
|
||||||
|
|
||||||
EXPOSE 80
|
EXPOSE ${PORT}
|
||||||
|
VOLUME /usr/share/nginx/html/item-icons
|
||||||
CMD ["nginx", "-g", "daemon off;"]
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
|
38
README.md
38
README.md
@ -19,10 +19,11 @@
|
|||||||
- Quickly preview a website, by holding down the Alt key while clicking, to open it in a resizable pop-up modal
|
- Quickly preview a website, by holding down the Alt key while clicking, to open it in a resizable pop-up modal
|
||||||
- Many options for icons, including full Font-Awesome support and the ability to auto-fetch icon from URLs favicon
|
- Many options for icons, including full Font-Awesome support and the ability to auto-fetch icon from URLs favicon
|
||||||
- Additional info for each item visible on hover (including opening method icon and description as a tooltip)
|
- Additional info for each item visible on hover (including opening method icon and description as a tooltip)
|
||||||
- Option for full-screen background image, custom nav-bar links, and custom footer
|
- Option for full-screen background image, custom nav-bar links, and custom footer text
|
||||||
- Preferences stored in local storage and applied on load
|
- User settings stored in local storage and applied on load
|
||||||
- Easy YAML-based configuration
|
- Encrypted cloud backup and restore feature available
|
||||||
- Small bundle size and a fully responsive UI makes the app easy to use on any device
|
- Easy single-file YAML-based configuration
|
||||||
|
- Small bundle size, fully responsive UI and PWA makes the app easy to use on any device
|
||||||
- Plus lots more...
|
- Plus lots more...
|
||||||
|
|
||||||
**Live Demos**: [Demo 1](https://dashy-demo-1.as93.net) ┆ [Demo 2](https://dashy-demo-2.as93.net) ┆ [Demo 3](https://dashy-demo-3.as93.net)
|
**Live Demos**: [Demo 1](https://dashy-demo-1.as93.net) ┆ [Demo 2](https://dashy-demo-2.as93.net) ┆ [Demo 3](https://dashy-demo-3.as93.net)
|
||||||
@ -38,7 +39,6 @@
|
|||||||
---
|
---
|
||||||
|
|
||||||
## Running the App 🏃♂️
|
## Running the App 🏃♂️
|
||||||
|
|
||||||
### Deploying 🚀
|
### Deploying 🚀
|
||||||
- Get Code: `git clone git@github.com:Lissy93/dashy.git` and `cd dashy`
|
- Get Code: `git clone git@github.com:Lissy93/dashy.git` and `cd dashy`
|
||||||
- Configuration: Fill in you're settings in `./public/conf.yml`
|
- Configuration: Fill in you're settings in `./public/conf.yml`
|
||||||
@ -46,17 +46,22 @@
|
|||||||
- Build: `yarn build`
|
- Build: `yarn build`
|
||||||
- Run: `yarn start`
|
- Run: `yarn start`
|
||||||
|
|
||||||
### Deploying with Docker 🐳
|
### Deploying with Docker from Source 🛳️
|
||||||
- Get Code: `git clone git@github.com:Lissy93/dashy.git` and `cd dashy`
|
- Get Code: `git clone git@github.com:Lissy93/dashy.git` and `cd dashy`
|
||||||
- Configuration: Fill in you're settings in `./public/conf.yml`
|
- Configuration: Fill in you're settings in `./public/conf.yml`
|
||||||
- Build: `docker build -t lissy93/dashy .`
|
- Build: `docker build -t lissy93/dashy .`
|
||||||
- Start: `docker run -it -p 8080:80 --rm --name my-dashboard lissy93/dashy`
|
- Start: `docker run -p 8080:80 --name my-dashboard lissy93/dashy`
|
||||||
|
|
||||||
|
### Deploying from Docker Hub 🐳
|
||||||
|
- Get the Image: `docker pull lissy93/dashy`
|
||||||
|
- Start the Container: `docker run -d -p 8080:80 --name my-dashboard lissy93/dashy`
|
||||||
### Developing 🧱
|
### Developing 🧱
|
||||||
- Get Code: `git clone git@github.com:Lissy93/dashy.git` and `cd dashy`
|
- Get Code: `git clone git@github.com:Lissy93/dashy.git` and `cd dashy`
|
||||||
- Install dependencies: `yarn`
|
- Install dependencies: `yarn`
|
||||||
- Start dev server: `yarn dev`
|
- Start dev server: `yarn dev`
|
||||||
|
|
||||||
|
Note that although recommended, it is not required to use the conf.yml file- all settings can be specified through the UI, and backed up on the cloud.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Configuring 🔧
|
## Configuring 🔧
|
||||||
@ -162,17 +167,24 @@ There are a few self-hosted web apps, that serve a similar purpose to Dashy. Inc
|
|||||||
|
|
||||||
### Credits 🏆
|
### Credits 🏆
|
||||||
|
|
||||||
And the app itself is built with [Vue.js](https://github.com/vuejs/vue) 
|
This wouldn't have been quite so possible without the following components, kudos to their respective authors
|
||||||
|
|
||||||
And wouldn't have been quite possible, without the following components, kudos to their respective authors
|
|
||||||
- [`vue-select`](https://github.com/sagalbot/vue-select) - Dropdown component by @sagalbot `MIT`
|
- [`vue-select`](https://github.com/sagalbot/vue-select) - Dropdown component by @sagalbot `MIT`
|
||||||
- [`vue-js-modal`](https://github.com/euvl/vue-js-modal) - Modal component by @euvl `MIT`
|
- [`vue-js-modal`](https://github.com/euvl/vue-js-modal) - Modal component by @euvl `MIT`
|
||||||
- [`v-tooltip`](https://github.com/Akryum/v-tooltip) - Tooltip component by @Akryum `MIT`
|
- [`v-tooltip`](https://github.com/Akryum/v-tooltip) - Tooltip component by @Akryum `MIT`
|
||||||
- [`vue-material-tabs`](https://github.com/jairoblatt/vue-material-tabs) - Tab view component by @jairoblatt `MIT`
|
- [`vue-material-tabs`](https://github.com/jairoblatt/vue-material-tabs) - Tab view component by @jairoblatt `MIT`
|
||||||
- [`VJsoneditor`](https://github.com/yansenlei/VJsoneditor) - Interactive JSON editor component by @yansenlei `MIT`
|
- [`VJsoneditor`](https://github.com/yansenlei/VJsoneditor) - Interactive JSON editor component by @yansenlei `MIT`
|
||||||
- Forked from [JsonEditor](https://github.com/josdejong/jsoneditor) by @josdejong `Apache-2.0 License`
|
- Forked from [`JsonEditor`](https://github.com/josdejong/jsoneditor) by @josdejong `Apache-2.0 License`
|
||||||
|
- And using [`ajv`](https://github.com/ajv-validator/ajv) `MIT` JSON schema Validator [`ace`](https://github.com/ajaxorg/ace) `BSD` code editor
|
||||||
- [`vue-toasted`](https://github.com/shakee93/vue-toasted) - Toast notification component by @shakee93 `MIT`
|
- [`vue-toasted`](https://github.com/shakee93/vue-toasted) - Toast notification component by @shakee93 `MIT`
|
||||||
|
|
||||||
|
Utils:
|
||||||
|
- [`crypto-js`](https://github.com/brix/crypto-js) - Encryption implementations by @evanvosberg and community `MIT`
|
||||||
|
- [`axios`](https://github.com/axios/axios) - Promise based HTTP client by @mzabriskie and community `MIT`
|
||||||
|
|
||||||
|
And the app itself is built with [Vue.js](https://github.com/vuejs/vue) 
|
||||||
|
|
||||||
|
Although the app is purely frontend, there is an optional cloud backup and restore feature. This is built as a serverless function on [Cloudflare workers](https://workers.cloudflare.com/) using [KV](https://developers.cloudflare.com/workers/runtime-apis/kv) and [web crypto](https://developers.cloudflare.com/workers/runtime-apis/web-crypto)
|
||||||
|
|
||||||
### License 📜
|
### License 📜
|
||||||
|
|
||||||
```
|
```
|
||||||
@ -194,3 +206,7 @@ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRA
|
|||||||
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWAREOR THE USE
|
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWAREOR THE USE
|
||||||
OR OTHER DEALINGS IN THE SOFTWARE.
|
OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
```
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<a href="https://www.producthunt.com/posts/dashy" target="_blank" align="center"><img src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=294872&theme=dark" alt="Dashy - A feature-rich dashboard for your homelab 🚀 | Product Hunt" width="250" height="54" /></a>
|
||||||
|
@ -8,7 +8,9 @@
|
|||||||
"lint": "vue-cli-service lint --fix"
|
"lint": "vue-cli-service lint --fix"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"axios": "^0.21.1",
|
||||||
"connect": "^3.7.0",
|
"connect": "^3.7.0",
|
||||||
|
"crypto-js": "^4.0.0",
|
||||||
"register-service-worker": "^1.6.2",
|
"register-service-worker": "^1.6.2",
|
||||||
"remedial": "^1.0.8",
|
"remedial": "^1.0.8",
|
||||||
"serve-static": "^1.14.1",
|
"serve-static": "^1.14.1",
|
||||||
|
1
src/assets/interface-icons/cloud-backup-restore.svg
Normal file
1
src/assets/interface-icons/cloud-backup-restore.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg aria-hidden="true" focusable="false" data-prefix="far" data-icon="cloud-upload-alt" class="svg-inline--fa fa-cloud-upload-alt fa-w-20" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 512"><path fill="currentColor" d="M395.5 267.5l-99-99c-4.7-4.7-12.3-4.7-17 0l-99 99c-7.6 7.6-2.2 20.5 8.5 20.5h67v84c0 6.6 5.4 12 12 12h40c6.6 0 12-5.4 12-12v-84h67c10.7 0 16.1-12.9 8.5-20.5zm148.2-67.4C539.7 142.1 491.4 96 432 96c-7.6 0-15.1.8-22.4 2.3C377.7 58.3 328.1 32 272 32c-84.6 0-155.5 59.7-172.3 139.8C39.9 196.1 0 254.4 0 320c0 88.4 71.6 160 160 160h336c79.5 0 144-64.5 144-144 0-61.8-39.2-115.8-96.3-135.9zM496 432H160c-61.9 0-112-50.1-112-112 0-56.4 41.7-103.1 96-110.9V208c0-70.7 57.3-128 128-128 53.5 0 99.3 32.8 118.4 79.4 11.2-9.6 25.7-15.4 41.6-15.4 35.3 0 64 28.7 64 64 0 11.8-3.2 22.9-8.8 32.4 2.9-.3 5.9-.4 8.8-.4 53 0 96 43 96 96s-43 96-96 96z"></path></svg>
|
After Width: | Height: | Size: 883 B |
1
src/assets/interface-icons/config-backup.svg
Normal file
1
src/assets/interface-icons/config-backup.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg aria-hidden="true" focusable="false" data-prefix="fal" data-icon="upload" class="svg-inline--fa fa-upload fa-w-16" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="currentColor" d="M452 432c0 11-9 20-20 20s-20-9-20-20 9-20 20-20 20 9 20 20zm-84-20c-11 0-20 9-20 20s9 20 20 20 20-9 20-20-9-20-20-20zm144-48v104c0 24.3-19.7 44-44 44H44c-24.3 0-44-19.7-44-44V364c0-24.3 19.7-44 44-44h124v-99.3h-52.7c-35.6 0-53.4-43.1-28.3-68.3L227.7 11.7c15.6-15.6 40.9-15.6 56.6 0L425 152.4c25.2 25.2 7.3 68.3-28.3 68.3H344V320h124c24.3 0 44 19.7 44 44zM200 188.7V376c0 4.4 3.6 8 8 8h96c4.4 0 8-3.6 8-8V188.7h84.7c7.1 0 10.7-8.6 5.7-13.7L261.7 34.3c-3.1-3.1-8.2-3.1-11.3 0L109.7 175c-5 5-1.5 13.7 5.7 13.7H200zM480 364c0-6.6-5.4-12-12-12H344v24c0 22.1-17.9 40-40 40h-96c-22.1 0-40-17.9-40-40v-24H44c-6.6 0-12 5.4-12 12v104c0 6.6 5.4 12 12 12h424c6.6 0 12-5.4 12-12V364z"></path></svg>
|
After Width: | Height: | Size: 902 B |
1
src/assets/interface-icons/config-restore.svg
Normal file
1
src/assets/interface-icons/config-restore.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg aria-hidden="true" focusable="false" data-prefix="fal" data-icon="download" class="svg-inline--fa fa-download fa-w-16" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="currentColor" d="M452 432c0 11-9 20-20 20s-20-9-20-20 9-20 20-20 20 9 20 20zm-84-20c-11 0-20 9-20 20s9 20 20 20 20-9 20-20-9-20-20-20zm144-48v104c0 24.3-19.7 44-44 44H44c-24.3 0-44-19.7-44-44V364c0-24.3 19.7-44 44-44h99.4L87 263.6c-25.2-25.2-7.3-68.3 28.3-68.3H168V40c0-22.1 17.9-40 40-40h96c22.1 0 40 17.9 40 40v155.3h52.7c35.6 0 53.4 43.1 28.3 68.3L368.6 320H468c24.3 0 44 19.7 44 44zm-261.7 17.7c3.1 3.1 8.2 3.1 11.3 0L402.3 241c5-5 1.5-13.7-5.7-13.7H312V40c0-4.4-3.6-8-8-8h-96c-4.4 0-8 3.6-8 8v187.3h-84.7c-7.1 0-10.7 8.6-5.7 13.7l140.7 140.7zM480 364c0-6.6-5.4-12-12-12H336.6l-52.3 52.3c-15.6 15.6-41 15.6-56.6 0L175.4 352H44c-6.6 0-12 5.4-12 12v104c0 6.6 5.4 12 12 12h424c6.6 0 12-5.4 12-12V364z"></path></svg>
|
After Width: | Height: | Size: 920 B |
238
src/components/Configuration/CloudBackupRestore.vue
Normal file
238
src/components/Configuration/CloudBackupRestore.vue
Normal file
@ -0,0 +1,238 @@
|
|||||||
|
<template>
|
||||||
|
<div class="cloud-backup-restore-wrapper">
|
||||||
|
<div class="section intro">
|
||||||
|
<h2>Cloud Backup & Restore</h2>
|
||||||
|
<p class="intro">
|
||||||
|
Cloud backup and restore is an optional feature, that enabled you to upload your
|
||||||
|
config to the internet, and then restore it on any other device or instance of Dashy.
|
||||||
|
<br><br>
|
||||||
|
All data is fully end-to-end encrypted with AES, using your password as the key.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="section backup-section">
|
||||||
|
<h3 v-if="backupId">Update Backup</h3>
|
||||||
|
<h3 v-else>Make a Backup</h3>
|
||||||
|
<Input
|
||||||
|
v-model="backupPassword"
|
||||||
|
name="backup-password"
|
||||||
|
:label="backupId ? 'Enter your Password' : 'Choose a Password'"
|
||||||
|
layout="vertical"
|
||||||
|
type="password"
|
||||||
|
/>
|
||||||
|
<Button :click="checkPass">
|
||||||
|
<template v-slot:text>{{backupId ? 'Update Backup' : 'Backup'}}</template>
|
||||||
|
<template v-slot:icon><IconBackup /></template>
|
||||||
|
</Button>
|
||||||
|
<div class="results-view" v-if="backupId">
|
||||||
|
<span class="backup-id-label">Your Backup ID: </span>
|
||||||
|
<pre class="backup-id-value">{{ backupId }}</pre>
|
||||||
|
<span class="backup-id-note">
|
||||||
|
This is used to restore from backups later.
|
||||||
|
So keep it, along with your password somewhere safe.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="section restore-section">
|
||||||
|
<h3>Restore a Backup</h3>
|
||||||
|
<Input
|
||||||
|
v-model="restoreCode"
|
||||||
|
name="restore-code"
|
||||||
|
label="Restore ID"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
v-model="restorePassword"
|
||||||
|
name="restore-password"
|
||||||
|
label="Password"
|
||||||
|
type="password"
|
||||||
|
/>
|
||||||
|
<Button :click="restoreBackup">
|
||||||
|
<template v-slot:text>Restore</template>
|
||||||
|
<template v-slot:icon><IconRestore /></template>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
|
||||||
|
import sha256 from 'crypto-js/sha256';
|
||||||
|
import Button from '@/components/FormElements/Button';
|
||||||
|
import Input from '@/components/FormElements/Input';
|
||||||
|
import IconBackup from '@/assets/interface-icons/config-backup.svg';
|
||||||
|
import IconRestore from '@/assets/interface-icons/config-restore.svg';
|
||||||
|
import { backup, update, restore } from '@/utils/CloudBackup';
|
||||||
|
import { localStorageKeys } from '@/utils/defaults';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'CloudBackupRestore',
|
||||||
|
props: {
|
||||||
|
config: Object,
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
backupPassword: '',
|
||||||
|
restorePassword: '',
|
||||||
|
restoreCode: '',
|
||||||
|
backupId: localStorage[localStorageKeys.BACKUP_ID] || '',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
components: {
|
||||||
|
Button,
|
||||||
|
Input,
|
||||||
|
IconBackup,
|
||||||
|
IconRestore,
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
restoreBackup() {
|
||||||
|
restore(this.restoreCode, this.restorePassword)
|
||||||
|
.then((response) => {
|
||||||
|
this.restoreFromBackup(response, this.restoreCode);
|
||||||
|
}).catch((msg) => {
|
||||||
|
this.showErrorMsg(msg);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
checkPass() {
|
||||||
|
const savedHash = localStorage[localStorageKeys.BACKUP_HASH] || undefined;
|
||||||
|
if (!savedHash) {
|
||||||
|
this.makeBackup();
|
||||||
|
} else if (savedHash === this.makeHash(this.backupPassword)) {
|
||||||
|
this.makeUpdate();
|
||||||
|
} else {
|
||||||
|
this.showErrorMsg('Incorrect password. Please enter your current password.');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
makeBackup() {
|
||||||
|
backup(this.config, this.backupPassword)
|
||||||
|
.then((response) => {
|
||||||
|
if (!response.data || response.data.errorMsg || !response.data.backupId) {
|
||||||
|
this.showErrorMsg(response.data.errorMsg || 'Error');
|
||||||
|
} else { // All clear, no error
|
||||||
|
this.updateUiAfterBackup(response.data.backupId, false);
|
||||||
|
}
|
||||||
|
}).catch(() => {
|
||||||
|
this.showErrorMsg('Unable to process request');
|
||||||
|
});
|
||||||
|
},
|
||||||
|
makeUpdate() {
|
||||||
|
update(this.config, this.backupPassword, this.backupId)
|
||||||
|
.then((response) => {
|
||||||
|
if (!response.data || response.data.errorMsg || !response.data.backupId) {
|
||||||
|
this.showErrorMsg(response.data.errorMsg || 'Error');
|
||||||
|
} else { // All clear, no error
|
||||||
|
this.updateUiAfterBackup(response.data.backupId, true);
|
||||||
|
}
|
||||||
|
}).catch(() => {
|
||||||
|
this.showErrorMsg('Unable to process request');
|
||||||
|
});
|
||||||
|
},
|
||||||
|
restoreFromBackup(config, backupId) {
|
||||||
|
localStorage.setItem(localStorageKeys.CONF_SECTIONS, JSON.stringify(config.sections));
|
||||||
|
localStorage.setItem(localStorageKeys.APP_CONFIG, JSON.stringify(config.appConfig));
|
||||||
|
localStorage.setItem(localStorageKeys.PAGE_INFO, JSON.stringify(config.pageInfo));
|
||||||
|
if (config.appConfig.theme) {
|
||||||
|
localStorage.setItem(localStorageKeys.THEME, config.appConfig.theme);
|
||||||
|
}
|
||||||
|
this.setBackupIdLocally(backupId, this.restorePassword);
|
||||||
|
this.showSuccessMsg('Config Restored Succesfully');
|
||||||
|
setTimeout(() => { location.reload(); }, 1500); // eslint-disable-line no-restricted-globals
|
||||||
|
},
|
||||||
|
updateUiAfterBackup(backupId, isUpdate = false) {
|
||||||
|
this.setBackupIdLocally(backupId, this.backupPassword);
|
||||||
|
this.showSuccessMsg(`${isUpdate ? 'Update' : 'Backup'} Completed Succesfully`);
|
||||||
|
this.backupPassword = '';
|
||||||
|
},
|
||||||
|
showErrorMsg(errorMsg) {
|
||||||
|
this.$toasted.show(errorMsg, { className: 'toast-error' });
|
||||||
|
},
|
||||||
|
showSuccessMsg(msg) {
|
||||||
|
this.$toasted.show(msg, { className: 'toast-success' });
|
||||||
|
},
|
||||||
|
makeHash(pass) {
|
||||||
|
return sha256(pass).toString();
|
||||||
|
},
|
||||||
|
setBackupIdLocally(backupId, pass) {
|
||||||
|
this.backupId = backupId;
|
||||||
|
const hash = this.makeHash(pass);
|
||||||
|
localStorage.setItem(localStorageKeys.BACKUP_ID, backupId);
|
||||||
|
localStorage.setItem(localStorageKeys.BACKUP_HASH, hash);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
div.cloud-backup-restore-wrapper {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
text-align: center;
|
||||||
|
height: 100%;
|
||||||
|
background: var(--config-settings-background);
|
||||||
|
color: var(--config-settings-color);
|
||||||
|
|
||||||
|
.section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: fit-content;
|
||||||
|
margin: 0 auto 1rem auto;
|
||||||
|
padding: 0 0.5rem 1rem 0.5rem;
|
||||||
|
&:first-child {
|
||||||
|
border-bottom: 1px dashed var(--config-settings-color);
|
||||||
|
}
|
||||||
|
&.intro {
|
||||||
|
width: 100%;
|
||||||
|
height: fit-content;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 { font-size: 2rem; }
|
||||||
|
h3 { font-size: 1.6rem; }
|
||||||
|
p.intro {
|
||||||
|
text-align: left;
|
||||||
|
font-size: 1rem;
|
||||||
|
margin: 0.25rem;
|
||||||
|
padding: 0.25rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
div.results-view {
|
||||||
|
width: 16rem;
|
||||||
|
margin: 0.5rem auto;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border: 1px dashed var(--config-settings-color);
|
||||||
|
border-radius: var(--curve-factor);
|
||||||
|
text-align: left;
|
||||||
|
.backup-id-label, .backup-id-value {
|
||||||
|
display: inline;
|
||||||
|
font-size: 1rem;
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
}
|
||||||
|
.backup-id-note {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
display: block;
|
||||||
|
opacity: 0.8;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Overide form element colors, so that config menu can be themed by user */
|
||||||
|
input, button, {
|
||||||
|
color: var(--config-settings-color);
|
||||||
|
border: 1px solid var(--config-settings-color);
|
||||||
|
background: none;
|
||||||
|
width: 16rem;
|
||||||
|
}
|
||||||
|
input:focus {
|
||||||
|
box-shadow: 1px 1px 6px var(--config-settings-color);
|
||||||
|
}
|
||||||
|
button:hover {
|
||||||
|
color: var(--config-settings-background);
|
||||||
|
border: 1px solid var(--config-settings-background);
|
||||||
|
background: var(--config-settings-color);
|
||||||
|
}
|
||||||
|
h2, h3 {
|
||||||
|
margin: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
</style>
|
@ -60,7 +60,9 @@ export default {
|
|||||||
pageInfo.title = this.formElements.title;
|
pageInfo.title = this.formElements.title;
|
||||||
pageInfo.description = this.formElements.description;
|
pageInfo.description = this.formElements.description;
|
||||||
pageInfo.footerText = this.formElements.footerText;
|
pageInfo.footerText = this.formElements.footerText;
|
||||||
|
if (this.formElements.navLinks) {
|
||||||
pageInfo.navLinks = this.formElements.navLinks.filter(link => (link.title !== ''));
|
pageInfo.navLinks = this.formElements.navLinks.filter(link => (link.title !== ''));
|
||||||
|
}
|
||||||
localStorage.setItem(localStorageKeys.PAGE_INFO, JSON.stringify(pageInfo));
|
localStorage.setItem(localStorageKeys.PAGE_INFO, JSON.stringify(pageInfo));
|
||||||
this.$toasted.show('Changes saved succesfully');
|
this.$toasted.show('Changes saved succesfully');
|
||||||
setTimeout(() => { location.reload(); }, 1500); // eslint-disable-line no-restricted-globals
|
setTimeout(() => { location.reload(); }, 1500); // eslint-disable-line no-restricted-globals
|
||||||
|
53
src/components/FormElements/Button.vue
Normal file
53
src/components/FormElements/Button.vue
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
<template>
|
||||||
|
<button @click="click()">
|
||||||
|
<slot name="text"></slot>
|
||||||
|
<slot name="icon"></slot>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'Button',
|
||||||
|
props: {
|
||||||
|
text: String,
|
||||||
|
click: Function,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
|
||||||
|
/* Layout settings */
|
||||||
|
button {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
flex-direction: row-reverse;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
margin: 0.5rem auto;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
min-width: 10rem;
|
||||||
|
cursor: pointer;
|
||||||
|
svg {
|
||||||
|
width: 1.2rem;
|
||||||
|
margin: 0 0.5rem;
|
||||||
|
path, g {
|
||||||
|
fill: currentColor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Default visual settings, can be overridden when needed */
|
||||||
|
button {
|
||||||
|
color: var(--primary);
|
||||||
|
background: var(--background);
|
||||||
|
border: 1px solid var(--primary);
|
||||||
|
border-radius: var(--curve-factor);
|
||||||
|
&:hover {
|
||||||
|
color: var(--background);
|
||||||
|
background: var(--primary);
|
||||||
|
border-color: var(--background);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
73
src/components/FormElements/Input.vue
Normal file
73
src/components/FormElements/Input.vue
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
<template>
|
||||||
|
<div :class="`input-container ${layout}`">
|
||||||
|
<label v-if="label" for="name">{{label}}</label>
|
||||||
|
<input
|
||||||
|
:type="type"
|
||||||
|
:value="value"
|
||||||
|
v-on:input="updateValue($event.target.value)"
|
||||||
|
:name="name"
|
||||||
|
:id="name"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'Input',
|
||||||
|
props: {
|
||||||
|
value: String, // The value bound to v-model
|
||||||
|
label: String, // An optional label to display above
|
||||||
|
name: String, // Required unique ID value, for accessibility
|
||||||
|
placeholder: String, // Optional placeholder value
|
||||||
|
type: {
|
||||||
|
default: 'text', // Input type, e.g. text, password, number
|
||||||
|
type: String,
|
||||||
|
},
|
||||||
|
layout: { // Layout alignment direction, either horizonal or verical
|
||||||
|
validator: (value) => ['horizontal', 'vertical'].indexOf(value) !== -1,
|
||||||
|
type: String,
|
||||||
|
default: 'vertical',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
updateValue(value) {
|
||||||
|
this.$emit('input', value);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
div.input-container {
|
||||||
|
margin: 0.25rem auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
&.vertical {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
&.horizontal {
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: space-between;
|
||||||
|
label { margin-right: 0.25rem; }
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
min-width: 10rem;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
margin: 0.5rem auto;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
box-sizing: border-box;
|
||||||
|
color: var(--primary);
|
||||||
|
background: var(--background);;
|
||||||
|
border: 1px solid var(--primary);
|
||||||
|
border-radius: var(--curve-factor);
|
||||||
|
&:focus {
|
||||||
|
box-shadow: 1px 1px 6px var(--config-settings-color);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
</style>
|
@ -4,32 +4,46 @@
|
|||||||
<span>Config</span>
|
<span>Config</span>
|
||||||
<div class="config-buttons">
|
<div class="config-buttons">
|
||||||
<IconSpanner v-tooltip="tooltip('Update configuration locally')" @click="showEditor()" />
|
<IconSpanner v-tooltip="tooltip('Update configuration locally')" @click="showEditor()" />
|
||||||
|
<IconCloud v-tooltip="tooltip('Backup / restore cloud config')" @click="showCloudModal()" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Modal containing all the configuration options -->
|
<!-- Modal containing all the configuration options -->
|
||||||
<modal :name="modalName" :resizable="true" width="60%" height="80%"
|
<modal :name="modalNames.CONF_EDITOR" :resizable="true" width="60%" height="80%"
|
||||||
@closed="$emit('modalChanged', false)">
|
@closed="$emit('modalChanged', false)">
|
||||||
<ConfigContainer :config="combineConfig()" />
|
<ConfigContainer :config="combineConfig()" />
|
||||||
</modal>
|
</modal>
|
||||||
|
|
||||||
|
<!-- Modal for cloud backup and restore options -->
|
||||||
|
<modal :name="modalNames.CLOUD_BACKUP" :resizable="true" width="65%" height="60%"
|
||||||
|
@closed="$emit('modalChanged', false)">
|
||||||
|
<CloudBackupRestore :config="combineConfig()" />
|
||||||
|
</modal>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
|
||||||
import IconSpanner from '@/assets/interface-icons/config-editor.svg';
|
import IconSpanner from '@/assets/interface-icons/config-editor.svg';
|
||||||
|
import IconCloud from '@/assets/interface-icons/cloud-backup-restore.svg';
|
||||||
import ConfigContainer from '@/components/Configuration/ConfigContainer';
|
import ConfigContainer from '@/components/Configuration/ConfigContainer';
|
||||||
|
import CloudBackupRestore from '@/components/Configuration/CloudBackupRestore';
|
||||||
import { topLevelConfKeys, localStorageKeys } from '@/utils/defaults';
|
import { topLevelConfKeys, localStorageKeys } from '@/utils/defaults';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'ConfigLauncher',
|
name: 'ConfigLauncher',
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
modalName: 'CONF-EDITOR',
|
modalNames: {
|
||||||
|
CONF_EDITOR: 'CONF_EDITOR',
|
||||||
|
CLOUD_BACKUP: 'CLOUD_BACKUP',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
components: {
|
components: {
|
||||||
IconSpanner,
|
IconSpanner,
|
||||||
|
IconCloud,
|
||||||
ConfigContainer,
|
ConfigContainer,
|
||||||
|
CloudBackupRestore,
|
||||||
},
|
},
|
||||||
props: {
|
props: {
|
||||||
sections: Array,
|
sections: Array,
|
||||||
@ -38,7 +52,11 @@ export default {
|
|||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
showEditor: function show() {
|
showEditor: function show() {
|
||||||
this.$modal.show(this.modalName);
|
this.$modal.show(this.modalNames.CONF_EDITOR);
|
||||||
|
this.$emit('modalChanged', true);
|
||||||
|
},
|
||||||
|
showCloudModal: function show() {
|
||||||
|
this.$modal.show(this.modalNames.CLOUD_BACKUP);
|
||||||
this.$emit('modalChanged', true);
|
this.$emit('modalChanged', true);
|
||||||
},
|
},
|
||||||
combineConfig() {
|
combineConfig() {
|
||||||
|
@ -5,6 +5,16 @@
|
|||||||
--background: #0b1021; // Page background
|
--background: #0b1021; // Page background
|
||||||
--background-darker: #05070e; // Used for navigation bar, footer and fills
|
--background-darker: #05070e; // Used for navigation bar, footer and fills
|
||||||
|
|
||||||
|
/* Action Colors */
|
||||||
|
--info: #04e4f4;
|
||||||
|
--success: #20e253;
|
||||||
|
--warning: #f6f000;
|
||||||
|
--danger: #f80363;
|
||||||
|
--neutral: #272f4d;
|
||||||
|
|
||||||
|
--white: #fff;
|
||||||
|
--black: #000;
|
||||||
|
|
||||||
/* Modified Colors */
|
/* Modified Colors */
|
||||||
--item-group-background: #0b1021cc;
|
--item-group-background: #0b1021cc;
|
||||||
--medium-grey: #5e6474;
|
--medium-grey: #5e6474;
|
||||||
@ -16,6 +26,11 @@
|
|||||||
--transparent-50: #00000080;
|
--transparent-50: #00000080;
|
||||||
--transparent-30: #0000004d;
|
--transparent-30: #0000004d;
|
||||||
|
|
||||||
|
/* Semi-Transparent White*/
|
||||||
|
--transparent-white-70: #ffffffb3;
|
||||||
|
--transparent-white-50: #ffffff80;
|
||||||
|
--transparent-white-30: #ffffff4d;
|
||||||
|
|
||||||
/* Other Variables */
|
/* Other Variables */
|
||||||
--outline-color: none;
|
--outline-color: none;
|
||||||
--curve-factor: 5px; // Border radius for most components
|
--curve-factor: 5px; // Border radius for most components
|
||||||
|
@ -20,6 +20,14 @@ h1, h2, h3, h4, h5 {
|
|||||||
font-family: 'Inconsolata', sans-serif;
|
font-family: 'Inconsolata', sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bold { font-weight: bold; }
|
||||||
|
.light { font-weight: lighter; }
|
||||||
|
.text-left { text-align: left;}
|
||||||
|
.text-right { text-align: right;}
|
||||||
|
.text-center { text-align: center;}
|
||||||
|
.horizontal-center { margin: 0 auto; }
|
||||||
|
.border-box { box-sizing: border-box; }
|
||||||
|
|
||||||
/* Overiding styles for the global toast component */
|
/* Overiding styles for the global toast component */
|
||||||
.toast-message {
|
.toast-message {
|
||||||
background: var(--toast-background) !important;
|
background: var(--toast-background) !important;
|
||||||
@ -29,4 +37,15 @@ h1, h2, h3, h4, h5 {
|
|||||||
font-size: 1.25rem !important;
|
font-size: 1.25rem !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.toast-error {
|
||||||
|
background: var(--danger) !important;
|
||||||
|
color: var(--white) !important;
|
||||||
|
font-size: 1.25rem !important;
|
||||||
|
}
|
||||||
|
.toast-success {
|
||||||
|
background: var(--success) !important;
|
||||||
|
color: var(--white) !important;
|
||||||
|
font-size: 1.25rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
52
src/utils/CloudBackup.js
Normal file
52
src/utils/CloudBackup.js
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
import sha256 from 'crypto-js/sha256';
|
||||||
|
import aes from 'crypto-js/aes';
|
||||||
|
import Utf8 from 'crypto-js/enc-utf8';
|
||||||
|
import axios from 'axios';
|
||||||
|
import { backupEndpoint } from '@/utils/defaults';
|
||||||
|
|
||||||
|
const ENDPOINT = backupEndpoint; // 'https://dashy-sync-service.as93.net';
|
||||||
|
|
||||||
|
/* Stringify, encrypt and encode data for transmission */
|
||||||
|
const encryptData = (data, password) => {
|
||||||
|
const stringifiedData = JSON.stringify(data);
|
||||||
|
const encryptedData = aes.encrypt(stringifiedData, password);
|
||||||
|
return encryptedData.toString();
|
||||||
|
};
|
||||||
|
|
||||||
|
/* Decrypt, decode and parse received data */
|
||||||
|
const decryptData = (data, password) => aes.decrypt(data, password).toString(Utf8);
|
||||||
|
|
||||||
|
/* Returns a splice of the hash of the users password */
|
||||||
|
const makeSubHash = (pass) => sha256(pass).toString().slice(0, 14);
|
||||||
|
|
||||||
|
/* Makes the backup */
|
||||||
|
export const backup = (data, password) => axios.post(ENDPOINT, {
|
||||||
|
userData: encryptData(data, password),
|
||||||
|
subHash: makeSubHash(password),
|
||||||
|
});
|
||||||
|
|
||||||
|
/* Updates and existing backup */
|
||||||
|
export const update = (data, password, backupId) => axios.put(ENDPOINT, {
|
||||||
|
backupId,
|
||||||
|
userData: encryptData(data, password),
|
||||||
|
subHash: makeSubHash(password),
|
||||||
|
});
|
||||||
|
|
||||||
|
const encodeGetParams = p => Object.entries(p).map(kv => kv.map(encodeURIComponent).join('=')).join('&');
|
||||||
|
|
||||||
|
/* Restores the backup */
|
||||||
|
export const restore = (backupId, password) => {
|
||||||
|
const params = encodeGetParams({ backupId, subHash: makeSubHash(password) });
|
||||||
|
console.log(makeSubHash(password));
|
||||||
|
const url = `${ENDPOINT}/?${params}`;
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
axios.get(url).then((response) => {
|
||||||
|
if (!response.data || response.data.errorMsg) {
|
||||||
|
reject(response.data.errorMsg || 'Error');
|
||||||
|
} else {
|
||||||
|
const decryptedData = decryptData(response.data.userData.userData, password);
|
||||||
|
try { resolve(JSON.parse(decryptedData)); } catch (e) { reject(e); }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
@ -47,6 +47,8 @@ module.exports = {
|
|||||||
CONF_SECTIONS: 'confSections',
|
CONF_SECTIONS: 'confSections',
|
||||||
PAGE_INFO: 'pageInfo',
|
PAGE_INFO: 'pageInfo',
|
||||||
APP_CONFIG: 'appConfig',
|
APP_CONFIG: 'appConfig',
|
||||||
|
BACKUP_ID: 'backupId',
|
||||||
|
BACKUP_HASH: 'backupHash',
|
||||||
},
|
},
|
||||||
topLevelConfKeys: {
|
topLevelConfKeys: {
|
||||||
PAGE_INFO: 'pageInfo',
|
PAGE_INFO: 'pageInfo',
|
||||||
@ -55,9 +57,10 @@ module.exports = {
|
|||||||
},
|
},
|
||||||
toastedOptions: {
|
toastedOptions: {
|
||||||
position: 'bottom-center',
|
position: 'bottom-center',
|
||||||
duration: 2000,
|
duration: 2500,
|
||||||
keepOnHover: true,
|
keepOnHover: true,
|
||||||
className: 'toast-message',
|
className: 'toast-message',
|
||||||
iconPack: 'fontawesome',
|
iconPack: 'fontawesome',
|
||||||
},
|
},
|
||||||
|
backupEndpoint: 'https://dashy-sync-service.as93.net',
|
||||||
};
|
};
|
||||||
|
17
yarn.lock
17
yarn.lock
@ -1904,6 +1904,13 @@ aws4@^1.8.0:
|
|||||||
resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.11.0.tgz#d61f46d83b2519250e2784daf5b09479a8b41c59"
|
resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.11.0.tgz#d61f46d83b2519250e2784daf5b09479a8b41c59"
|
||||||
integrity sha512-xh1Rl34h6Fi1DC2WWKfxUTVqRsNnr6LsKz2+hfwDxQJWmrx8+c7ylaqBMcHfl1U1r2dsifOvKX3LQuLNZ+XSvA==
|
integrity sha512-xh1Rl34h6Fi1DC2WWKfxUTVqRsNnr6LsKz2+hfwDxQJWmrx8+c7ylaqBMcHfl1U1r2dsifOvKX3LQuLNZ+XSvA==
|
||||||
|
|
||||||
|
axios@^0.21.1:
|
||||||
|
version "0.21.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/axios/-/axios-0.21.1.tgz#22563481962f4d6bde9a76d516ef0e5d3c09b2b8"
|
||||||
|
integrity sha512-dKQiRHxGD9PPRIUNIWvZhPTPpl1rf/OxTYKsqKUDjBwYylTvV7SjSHJb9ratfyzM6wCdLCOYLzs73qpg5c4iGA==
|
||||||
|
dependencies:
|
||||||
|
follow-redirects "^1.10.0"
|
||||||
|
|
||||||
babel-eslint@^10.0.1:
|
babel-eslint@^10.0.1:
|
||||||
version "10.1.0"
|
version "10.1.0"
|
||||||
resolved "https://registry.yarnpkg.com/babel-eslint/-/babel-eslint-10.1.0.tgz#6968e568a910b78fb3779cdd8b6ac2f479943232"
|
resolved "https://registry.yarnpkg.com/babel-eslint/-/babel-eslint-10.1.0.tgz#6968e568a910b78fb3779cdd8b6ac2f479943232"
|
||||||
@ -2956,6 +2963,11 @@ crypto-browserify@^3.11.0:
|
|||||||
randombytes "^2.0.0"
|
randombytes "^2.0.0"
|
||||||
randomfill "^1.0.3"
|
randomfill "^1.0.3"
|
||||||
|
|
||||||
|
crypto-js@^4.0.0:
|
||||||
|
version "4.0.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/crypto-js/-/crypto-js-4.0.0.tgz#2904ab2677a9d042856a2ea2ef80de92e4a36dcc"
|
||||||
|
integrity sha512-bzHZN8Pn+gS7DQA6n+iUmBfl0hO5DJq++QP3U6uTucDtk/0iGpXd/Gg7CGR0p8tJhofJyaKoWBuJI4eAO00BBg==
|
||||||
|
|
||||||
css-color-names@0.0.4, css-color-names@^0.0.4:
|
css-color-names@0.0.4, css-color-names@^0.0.4:
|
||||||
version "0.0.4"
|
version "0.0.4"
|
||||||
resolved "https://registry.yarnpkg.com/css-color-names/-/css-color-names-0.0.4.tgz#808adc2e79cf84738069b646cb20ec27beb629e0"
|
resolved "https://registry.yarnpkg.com/css-color-names/-/css-color-names-0.0.4.tgz#808adc2e79cf84738069b646cb20ec27beb629e0"
|
||||||
@ -4167,6 +4179,11 @@ follow-redirects@^1.0.0:
|
|||||||
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.13.3.tgz#e5598ad50174c1bc4e872301e82ac2cd97f90267"
|
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.13.3.tgz#e5598ad50174c1bc4e872301e82ac2cd97f90267"
|
||||||
integrity sha512-DUgl6+HDzB0iEptNQEXLx/KhTmDb8tZUHSeLqpnjpknR70H0nC2t9N73BK6fN4hOvJ84pKlIQVQ4k5FFlBedKA==
|
integrity sha512-DUgl6+HDzB0iEptNQEXLx/KhTmDb8tZUHSeLqpnjpknR70H0nC2t9N73BK6fN4hOvJ84pKlIQVQ4k5FFlBedKA==
|
||||||
|
|
||||||
|
follow-redirects@^1.10.0:
|
||||||
|
version "1.14.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.14.1.tgz#d9114ded0a1cfdd334e164e6662ad02bfd91ff43"
|
||||||
|
integrity sha512-HWqDgT7ZEkqRzBvc2s64vSZ/hfOceEol3ac/7tKwzuvEyWx3/4UegXh5oBOIotkGsObyk3xznnSRVADBgWSQVg==
|
||||||
|
|
||||||
for-in@^1.0.2:
|
for-in@^1.0.2:
|
||||||
version "1.0.2"
|
version "1.0.2"
|
||||||
resolved "https://registry.yarnpkg.com/for-in/-/for-in-1.0.2.tgz#81068d295a8142ec0ac726c6e2200c30fb6d5e80"
|
resolved "https://registry.yarnpkg.com/for-in/-/for-in-1.0.2.tgz#81068d295a8142ec0ac726c6e2200c30fb6d5e80"
|
||||||
|
Loading…
x
Reference in New Issue
Block a user