New Addon
Google BK
@@ -0,0 +1,44 @@
|
||||
# Authentication with Google Drive
|
||||
This document describes how the addon (Home Assistant Google Drive Backup) authenticates with Google Drive and stores your credentials. It's geared toward those who wish to know more detail and is not necessary to take advantage of the full features of the addon. The document is provided in the interest of providing full transparency into how the add-on works. I've tried to describe this as plainly as possible, but it is technical and therefore may not be understandable to everyone. Feedback on its clarity is appreciated.
|
||||
|
||||
> This document describes how authentication works if you use the big blue "AUTHENTICATE WITH GOOGLE DRIVE" button in the addon. If you're using [your own Google Drive credentials](https://github.com/sabeechen/hassio-google-drive-backup/blob/master/LOCAL_AUTH.md), then none of this applies.
|
||||
|
||||
## Your Credentials and the Needed Permission
|
||||
To have access to any information in Google Drive, Google's authentication servers must be told that the add-on has the permission. The add-on uses [Google Drive's Rest API (v3)](https://developers.google.com/drive/api/v3/about-sdk) for communication and requests the [drive.file](https://developers.google.com/drive/api/v3/about-auth) permission *scope*. This *scope* means the add-on has access to files and folders that the add-on created, but nothing else. It can't see files you've added to Google Drive through their web interface or anywhere else. Google Drive's Rest API allows the addon to periodically check what backups are uploaded and upload new ones if necessary by making requests over the internet.
|
||||
|
||||
## Authentication with Google Services
|
||||
For reference, Google's documentation for how to authenticate users with the Google Drive REST API is [here](https://developers.google.com/drive/api/v3/about-auth). Authentication is handled through [OAuth 2.0](https://developers.google.com/identity/protocols/OAuth2), which means that the add-on never actually sees your Google username and password, only an opaque [security token](https://en.wikipedia.org/wiki/Access_token) used to verify that the addon has been given permission. More detail is provided about what that token is and where it is stored later in this document.
|
||||
|
||||
The way a web-based application would normally authenticate with a Google service (eg Google Drive) looks something like this:
|
||||
1. User navigates to the app's webpage, eg http://examplegoogleapp.com
|
||||
2. The app generates a URL to Google's servers (https://accounts.google.com) used to grant the app permission.
|
||||
3. User navigates there, enters their Google username and password, and confirms the intention to give the app some permission (eg one or more *scopes*).
|
||||
4. Google redirects the user back to the app's webpage with an access token appended to the URL (eg http://examplegoogleapp.com/authenticate?token=0x12345678)
|
||||
5. The app stores the access token (0x12345678 in this example), and then passes it back to Google whenever it wishes to make access the API on behalf of the user who logged in.
|
||||
|
||||
This access token allows the app to act as if it is the user who created it. In the case of this add-on, the permission granted by the drive.file scope allows it to create folders, upload backups, and retrieve the previously created folders. Because the add-on only ever sees the access token (not the username/password), and the access token only grants limited permissions, the add-on doesn't have a way to elevate its permission further to access other information in Google Drive or your Google account.
|
||||
|
||||
## Authentication for the Add-on
|
||||
|
||||
Google puts some limitations on how the access token must be generated that will be important for understanding how the add-on authenticates in reality:
|
||||
* When the user is redirected to https://accounts.google.com (step 2), the redirect must be from a known public website associated with the app.
|
||||
* When the user is redirected back to the app after authorization (step 4), the redirect must be a statically addressed and publicly accessible website.
|
||||
|
||||
These limitations make a technical problem for the addon because most people's Home Assistant instances aren't publicly accessible and the address is different for each one. Performing the authentication workflow exactly as described above won't work. To get around this, I (the developer of this addon) set up a website, https://habackup.io, which serves as the known public and statically addressable website that Google redirects from/to. The source code for this server is available within the add-on's GitHub repository.
|
||||
|
||||
So when you authenticate the add-on, the workflow looks like this:
|
||||
1. You start at the add-on's web interface, something like https://homeassistant.local:8123/ingress/hassio_google_drive_backup
|
||||
2. You click the "Authenticate With Google Drive" button, which takes note of the address of your Home Assistant installation (https://homeassistant.local:8123 in this case) and sends you to https://habackup.io/drive/authorize
|
||||
3. https://habackup.io immediately generates the Google login URL for you and redirects you to https://accounts.google.com
|
||||
4. You log in with your Google credentials on Google's domain, and confirm you want to give the add-on permission to see files and folders it creates (the drive.file scope)
|
||||
5. Google redirects you back to https://habackup.io, along with the access token that will be used for future authentication.
|
||||
6. https://habackup.io redirects you back to your add-on web-UI (which is kept track of in step 2) along with the access token.
|
||||
7. The addon (on your local Home Assistant installation) persists the access token and uses it in the future any time it needs to talk to Google Drive.
|
||||
|
||||
Notably, your access token isn't persisted at https://habackup.io, it is only passed through back to your local add-on installation. I do this because:
|
||||
- It ensures your information is only ever stored on your machine, which is reassuring from the user's perspective (eg you).
|
||||
- If my server (https://habackup.io) ever gets compromised, there isn't any valuable information stored there that compromises you as well.
|
||||
- This is practicing a form of [defense-in-depth](https://en.wikipedia.org/wiki/Defense_in_depth_%28computing%29) security, where-in [personal data](https://en.wikipedia.org/wiki/Personal_data) is only stored in the places where it is strictly critical.
|
||||
- It makes the server more simple since it is a stateless machine that doesn't require a database (eg to store your token).
|
||||
|
||||
After your token is generated and stored on your machine, it needs to be *refreshed* periodically with Google Drive. To do this, the addon will again ask https://habackup.io who will relay the request with Google Drive.
|
||||
@@ -0,0 +1,123 @@
|
||||
# 'Snapshot' vs 'Backup'
|
||||
In August 2021 [the Home Assistant team announced](https://www.home-assistant.io/blog/2021/08/24/supervisor-update/) that 'snapshots' will be called 'backups' moving forward. This addon exposes a binary sensor to indicate if snapshots are stale and a another sensor that publishes details about backups. Both of the sensors used 'snapshot' in their names and values, so they had to be changed to match the new language. To prevent breaking any existing automations you might have, the addon will only start using the new names and values when you upgrade if you tell it to.
|
||||
|
||||
This can be controlled by using the configuration option ```call_backup_snapshot```, which will use the old names and values for sensors when it is true. If you updated the addon from a version that used to use 'snapshot' in it names, this option will be automatically added when you update to make sure it doesn't break any existing automations.
|
||||
|
||||
Here is a breakdown of what the new and old sensor values mean:
|
||||
|
||||
## Old sensor name/values
|
||||
These will be the sensor values used when ```call_backup_snapshot: True``` or if the addon is below version 0.105.1. The addon sets ```call_backup_snapshot: True``` automatically if you upgrade the addon from an older version.
|
||||
### Backup Stale Binary Sensor
|
||||
#### Entity Id:
|
||||
```yaml
|
||||
binary_sensor.snapshots_stale
|
||||
```
|
||||
#### Possible states:
|
||||
```yaml
|
||||
on
|
||||
off
|
||||
```
|
||||
#### Example Attributes:
|
||||
```yaml
|
||||
friendly_name: Snapshots Stale
|
||||
device_class: problem
|
||||
```
|
||||
### Backup State Sensor
|
||||
#### Entity Id:
|
||||
```yaml
|
||||
sensor.snapshot_backup
|
||||
```
|
||||
#### Possible States:
|
||||
```yaml
|
||||
error
|
||||
waiting
|
||||
backed_up
|
||||
```
|
||||
#### Example Attributes:
|
||||
```yaml
|
||||
friendly_name: Snapshots State
|
||||
last_snapshot: 2021-09-01T20:26:49.100376+00:00
|
||||
snapshots_in_google_drive: 2
|
||||
snapshots_in_hassio: 2
|
||||
snapshots_in_home_assistant: 2
|
||||
size_in_google_drive: 2.5 GB
|
||||
size_in_home_assistant: 2.5 GB
|
||||
snapshots:
|
||||
- name: Full Snapshot 2021-02-06 11:37:00
|
||||
date: '2021-02-06T18:37:00.916510+00:00'
|
||||
state: Backed Up
|
||||
slug: DFG123
|
||||
- name: Full Snapshot 2021-02-07 11:00:00
|
||||
date: '2021-02-07T18:00:00.916510+00:00'
|
||||
state: Backed Up
|
||||
slug: DFG124
|
||||
```
|
||||
|
||||
## New Sensor Names/Values
|
||||
These will be the sensor values used when ```call_backup_snapshot: False``` or if the configuration option is un-set. New installations of the addon will default to this.
|
||||
### Backup Stale Binary Sensor
|
||||
#### Entity Id
|
||||
```yaml
|
||||
binary_sensor.backups_stale
|
||||
```
|
||||
#### Possible States
|
||||
```yaml
|
||||
on
|
||||
off
|
||||
```
|
||||
#### Example Attributes:
|
||||
```yaml
|
||||
friendly_name: Backups Stale
|
||||
device_class: problem
|
||||
```
|
||||
### Backup State Sensor
|
||||
#### Entity Id
|
||||
```yaml
|
||||
sensor.backup_state
|
||||
```
|
||||
#### Possible States
|
||||
```yaml
|
||||
error
|
||||
waiting
|
||||
backed_up
|
||||
```
|
||||
#### Example Attributes:
|
||||
```yaml
|
||||
friendly_name: Backup State
|
||||
last_backup: 2021-09-01T20:26:49.100376+00:00
|
||||
last_upload: 2021-09-01T20:26:49.100376+00:00
|
||||
backups_in_google_drive: 2
|
||||
backups_in_home_assistant: 2
|
||||
size_in_google_drive: 2.5 GB
|
||||
size_in_home_assistant: 2.5 GB
|
||||
backups:
|
||||
- name: Full Snapshot 2021-02-06 11:37:00
|
||||
date: '2021-02-06T18:37:00.916510+00:00
|
||||
state: Backed Up
|
||||
slug: DFG123
|
||||
- name: Full Snapshot 2021-02-07 11:00:00
|
||||
date: '2021-02-07T18:00:00.916510+00:00'
|
||||
state: Backed Up
|
||||
slug: DFG124
|
||||
```
|
||||
|
||||
### What do the values mean?
|
||||
```binary_sensor.backups_stale``` is "on" when backups are stale and "off"" otherwise. Backups are stale when the addon is 6 hours past a scheduled backup and no new backup has been made. This delay is in place to avoid triggerring on transient errors (eg internet connectivity problems or one-off problems in Home Assistant).
|
||||
|
||||
```sensor.backup_state``` is:
|
||||
- ```waiting``` when the addon is first booted up or hasn't been connected to Google Drive yet.
|
||||
- ```error``` immediately after any error is encountered, even transient ones.
|
||||
- ```backed_up``` when everything is running fine without errors.
|
||||
|
||||
It's attributes are:
|
||||
- ```last_backup``` The UTC ISO-8601 date of the most recent backup in Home Assistant or Google Drive.
|
||||
- ```last_upload``` The UTC ISO-8601 date of the most recent backup uploaded to Google Drive.
|
||||
- ```backups_in_google_drive``` The number of backups in Google Drive.
|
||||
- ```backups_in_home_assistant``` The number of backups in Home Assistant.
|
||||
- ```size_in_google_drive``` A string representation of the space used by backups in Google Drive.
|
||||
- ```size_in_home_assistant``` A string representation of the space used by backups in Home Assistant.
|
||||
- ```backups``` The list of each snapshot in decending order of date. Each snapshot includes its ```name```, ```date```, ```slug```, and ```state```. ```state``` can be one of:
|
||||
- ```Backed Up``` if its in Home Assistant and Google Drive.
|
||||
- ```HA Only``` if its only in Home Assistant.
|
||||
- ```Drive Only``` if its only in Google Drive.
|
||||
- ```Pending``` if the snapshot was requested but not yet complete.
|
||||
@@ -0,0 +1,33 @@
|
||||
## v0.110.3 [2023-03-24]
|
||||
- Fix an error causing "Days Between Backups" to be ignored when "Time of Day" for a backup is set.
|
||||
- Fix a bug causing some timezones to make the addon to fail to start.
|
||||
|
||||
## v0.110.2 [2023-03-24]
|
||||
- Fix a potential cause of SSL errors when communicating with Google Drive
|
||||
- Fix a bug causing backups to be requested indefinitely if scheduled during DST transitions.
|
||||
|
||||
## v0.110.1 [2023-01-09]
|
||||
- Adds some additional options for donating
|
||||
- Mitgigates SD card corruption by redundantly storing config files needed for addon startup.
|
||||
- Avoid global throttling of Google Drive API calls by:
|
||||
- Making sync intervals more spread out and a little random.
|
||||
- Syncing more selectively when there are modifications to the /backup directory.
|
||||
- Caching data from Google Drive for short periods during periodic syncing.
|
||||
- Backing off for a longer time (2 hours) when the addon hits permanent errors.
|
||||
- Fixes CSS issues that made the logs page hard to use.
|
||||
|
||||
## v0.109.2 [2022-11-15]
|
||||
* Fixed a bug where disabling deletion from Google Drive and enabling deltes after upload could cause backups in Google Drive to be deleted.
|
||||
|
||||
## v0.109.1 [2022-11-07]
|
||||
* If configured from the browser, defaults to a "dark" theme if haven't already configured custom colors
|
||||
* Makes the interval at which the addon publishes sensors to Home Assistant configurable (see the "Uncommon Options" settings)
|
||||
* "Free space in Google Drive" is now published as an attribute of the "sensor.backup_state" sensor.
|
||||
* The "binary_sensor.backups_stale" sensor will now report a problem if creating a backup hangs for more than a day.
|
||||
* Fixes potential whitespace errors when copy-pasting Google Drive credentials.
|
||||
* Fixes an exception when using generational backup and no backups are present.
|
||||
|
||||
## v0.108.4 [2022-08-22]
|
||||
* Fixed an error causing "Undefined" to show up for addon descriptions.
|
||||
* Fixed an error preventing addon thumbnails from showing up.
|
||||
* Fixed an error causing username/password authentication to fail.
|
||||
@@ -0,0 +1,198 @@
|
||||
# Home Assistant Add-on: Google Assistant SDK
|
||||
|
||||
## Installation
|
||||
|
||||
To install the add-on, first follow the installation steps from the [README on GitHub](https://github.com/sabeechen/hassio-google-drive-backup#installation).
|
||||
|
||||
## Configuration
|
||||
|
||||
_Note_: The configuration can be changed easily by starting the add-on and clicking `Settings` in the web UI.
|
||||
The UI explains what each setting is and you don't need to modify anything before clicking `Start`.
|
||||
If you would still prefer to modify the settings in yaml, the options are detailed below.
|
||||
|
||||
Add-on configuration example. Don't use this directly, the addon has a lot of configuration options that most users don't need or want:
|
||||
|
||||
```yaml
|
||||
# Keep 10 backups in Home Assistant
|
||||
max_backups_in_ha: 10
|
||||
|
||||
# Keep 10 backups in Google Drive
|
||||
max_backups_in_google_drive: 10
|
||||
|
||||
# Ignore backups the add-on hasn't created
|
||||
ignore_other_backups: True
|
||||
|
||||
# Ignore backups that look like they were created by Home Assistant automatic backup option during upgrades
|
||||
ignore_upgrade_backups: True
|
||||
|
||||
# Automatically delete "ignored" snapshots after this many days
|
||||
delete_ignored_after_days: 7
|
||||
|
||||
# Take a backup every 3 days
|
||||
days_between_backups: 3
|
||||
|
||||
# Create backups at 1:30pm exactly
|
||||
backup_time_of_day: "13:30"
|
||||
|
||||
# Delete backups from Home Assistant immediately after uploading them to Google Drive
|
||||
delete_after_upload: True
|
||||
|
||||
# Manually specify the backup folder used in Google Drive
|
||||
specify_backup_folder: true
|
||||
|
||||
# Use a dark and red theme
|
||||
background_color: "#242424"
|
||||
accent_color: "#7D0034"
|
||||
|
||||
# Use a password for backup archives. Use "!secret secret_name" to use a password form your secrets file
|
||||
backup_password: "super_secret"
|
||||
|
||||
# Create backup names like 'Full Backup HA 0.92.0'
|
||||
backup_name: "{type} Backup HA {version_ha}"
|
||||
|
||||
# Keep a backup once every day for 3 days and once a week for 4 weeks
|
||||
generational_days: 3
|
||||
generational_weeks: 4
|
||||
|
||||
# Create partial backups with no folders and no configurator add-on
|
||||
exclude_folders: "homeassistant,ssl,share,addons/local,media"
|
||||
exclude_addons: "core_configurator"
|
||||
|
||||
# Turn off notifications and staleness sensor
|
||||
enable_backup_stale_sensor: false
|
||||
notify_for_stale_backups: false
|
||||
|
||||
# Enable server directly on port 1627
|
||||
expose_extra_server: true
|
||||
|
||||
# Allow sending error reports
|
||||
send_error_reports: true
|
||||
|
||||
# Delete backups after they're uploaded to Google Drive
|
||||
delete_after_upload: true
|
||||
```
|
||||
|
||||
### Option: `max_backups_in_ha` (default: 4)
|
||||
|
||||
The number of backups the add-on will allow Home Assistant to store locally before old ones are deleted.
|
||||
|
||||
### Option: `max_backups_in_google_drive` (default: 4)
|
||||
|
||||
The number of backups the add-on will keep in Google Drive before old ones are deleted. Google Drive gives you 15GB of free storage (at the time of writing) so plan accordingly if you know how big your backups are.
|
||||
|
||||
### Option: `ignore_other_backups` (default: False)
|
||||
Make the addon ignore any backups it didn't directly create. Any backup already uploaded to Google Drive will not be ignored until you delete it from Google Drive.
|
||||
|
||||
### Option: `ignore_upgrade_backups` (default: False)
|
||||
Ignores backups that look like they were automatically created from updating an add-on or Home Assistant itself. This will make the add-on ignore any partial backup that has only one add-on or folder in it.
|
||||
|
||||
### Option: `days_between_backups` (default: 3)
|
||||
|
||||
How often a new backup should be scheduled, eg `1` for daily and `7` for weekly.
|
||||
|
||||
### Option: `backup_time_of_day`
|
||||
|
||||
The time of day (local time) that new backups should be created in 24-hour ("HH:MM") format. When not specified backups are created at (roughly) the same time of day as the most recent backup.
|
||||
|
||||
|
||||
### Options: `delete_after_upload` (default: False)
|
||||
|
||||
Deletes backups from Home Assistant immediately after uploading them to Google Drive. This is useful if you have very limited space inside Home Assistant since you only need to have available space for a single backup locally.
|
||||
|
||||
### Option: `specify_backup_folder` (default: False)
|
||||
|
||||
When true, you must select the folder in Google Drive where backups are stored. Once you turn this on, restart the add-on and visit the Web-UI to be prompted to select the backup folder.
|
||||
|
||||
### Option: `background_color` and `accent_color`
|
||||
|
||||
The background and accent colors for the web UI. You can use this to make the UI fit in with whatever color scheme you use in Home Assistant. When unset, the interface matches Home Assistant's default blue/white style.
|
||||
|
||||
### Option: `backup_password`
|
||||
|
||||
When set, backups are created with a password. You can use a value from your secrets.yaml by prefixing the password with "!secret". You'll need to remember this password when restoring a backup.
|
||||
|
||||
> Example: Use a password for backup archives
|
||||
>
|
||||
> ```yaml
|
||||
> backup_password: "super_secret"
|
||||
> ```
|
||||
>
|
||||
> Example: Use a password from secrets.yaml
|
||||
>
|
||||
> ```yaml
|
||||
> backup_password: "!secret backup_password"
|
||||
> ```
|
||||
|
||||
### Option: `backup_name` (default: "{type} Backup {year}-{month}-{day} {hr24}:{min}:{sec}")
|
||||
|
||||
Sets the name for new backups. Variable parameters of the form `{variable_name}` can be used to modify the name to your liking. A list of available variables is available [here](https://github.com/sabeechen/hassio-google-drive-backup#can-i-give-backups-a-different-name).
|
||||
|
||||
### Option: `generational_*`
|
||||
|
||||
When set, older backups will be kept longer using a [generational backup scheme](https://en.wikipedia.org/wiki/Backup_rotation_scheme). See the [question here](https://github.com/sabeechen/hassio-google-drive-backup#can-i-keep-older-backups-for-longer) for configuration options.
|
||||
|
||||
### Option: `exclude_folders`
|
||||
|
||||
When set, excludes the comma-separated list of folders by creating a partial backup.
|
||||
|
||||
### Option: `exclude_addons`
|
||||
|
||||
When set, excludes the comma-separated list of addons by creating a partial backup.
|
||||
|
||||
_Note_: Folders and add-ons must be identified by their "slug" name. It is recommended to use the `Settings` dialog within the add-on web UI to configure partial backups since these names are esoteric and hard to find.
|
||||
|
||||
### Option: `enable_backup_stale_sensor` (default: True)
|
||||
|
||||
When false, the add-on will not publish the [binary_sensor.backups_stale](https://github.com/sabeechen/hassio-google-drive-backup#how-will-i-know-this-will-be-there-when-i-need-it) stale sensor.
|
||||
|
||||
### Option: `enable_backup_state_sensor` (default: True)
|
||||
|
||||
When false, the add-on will not publish the [sensor.backup_state](https://github.com/sabeechen/hassio-google-drive-backup#how-will-i-know-this-will-be-there-when-i-need-it) sensor.
|
||||
|
||||
### Option: `notify_for_stale_backups` (default: True)
|
||||
|
||||
When false, the add-on will send a [persistent notification](https://github.com/sabeechen/hassio-google-drive-backup#how-will-i-know-this-will-be-there-when-i-need-it) in Home Assistant when backups are stale.
|
||||
|
||||
---
|
||||
|
||||
### UI Server Options
|
||||
|
||||
The UI is available through Home Assistant [ingress](https://www.home-assistant.io/blog/2019/04/15/hassio-ingress/).
|
||||
|
||||
It can also be exposed through a web server on port `1627`, which you can map to an externally visible port from the add-on `Network` panel. You can configure a few more options to add SSL or require your Home Assistant username/password.
|
||||
|
||||
#### Option: `expose_extra_server` (default: False)
|
||||
|
||||
Expose the webserver on port `1627`. This is optional, as the add-on is already available with Home Assistant ingress.
|
||||
|
||||
#### Option: `require_login` (default: False)
|
||||
|
||||
When true, requires your home assistant username and password to access the Web UI.
|
||||
|
||||
#### Option: `use_ssl` (default: False)
|
||||
|
||||
When true, requires your home assistant username and password to access the Web UI.
|
||||
|
||||
#### Option: `certfile` (default: `/ssl/certfile.pem`)
|
||||
|
||||
Required when `use_ssl: True`. The path to your SSL key file
|
||||
|
||||
#### Option: `keyfile` (default: `/ssl/keyfile.pem`)
|
||||
|
||||
Required when `use_ssl: True`. The path to your SSL cert file.
|
||||
|
||||
#### Option: `verbose` (default: False)
|
||||
|
||||
If true, enable additional debug logging. Useful if you start seeing errors and need to file a bug with me.
|
||||
|
||||
#### Option: `send_error_reports` (default: False)
|
||||
|
||||
When true, the text of unexpected errors will be sent to a database maintained by the developer. This helps identify problems with new releases and provide better context messages when errors come up.
|
||||
|
||||
#### Option: `delete_after_upload` (default: False)
|
||||
|
||||
When true, backups are always deleted after they've been uploaded to Google Drive. 'max_backups_in_ha' is ignored when this option is True, since a backup is always deleted from Home Assistant after it gets uploaded to Google Drive. Some find this useful if they only have enough space on their Home Assistant machine for one backup.
|
||||
|
||||
## FAQ
|
||||
|
||||
Read the [FAQ on GitHub](https://github.com/sabeechen/hassio-google-drive-backup#faq).
|
||||
@@ -0,0 +1,12 @@
|
||||
ARG BUILD_FROM
|
||||
FROM $BUILD_FROM
|
||||
WORKDIR /app
|
||||
COPY . /app
|
||||
RUN chmod +x addon_deps.sh
|
||||
RUN ./addon_deps.sh
|
||||
RUN pip3 install .
|
||||
COPY config.json /usr/local/lib/python3.8/site-packages/config.json
|
||||
|
||||
EXPOSE 1627
|
||||
EXPOSE 8099
|
||||
ENTRYPOINT ["python3", "-m", "backup"]
|
||||
@@ -0,0 +1,16 @@
|
||||
# Use the official lightweight Python image.
|
||||
# https://hub.docker.com/_/python
|
||||
FROM python:3.9-buster
|
||||
|
||||
# Copy local code to the container image.
|
||||
ENV APP_HOME /server
|
||||
WORKDIR $APP_HOME
|
||||
COPY . ./
|
||||
COPY config.json /usr/local/lib/python3.9/site-packages/config.json
|
||||
|
||||
# Install server python requirements
|
||||
RUN pip3 install --trusted-host pypi.python.org -r requirements-server.txt
|
||||
RUN pip3 install .
|
||||
|
||||
WORKDIR /
|
||||
ENTRYPOINT ["python3", "-m", "backup.server"]
|
||||
@@ -0,0 +1,41 @@
|
||||
# Generational Backup
|
||||
Generational backup lets you keep a longer history of backups on daily, weekly, monthly, and yearly cycles. This is in contrast to the "regular" scheme for keeping history backups, which will always just delete the oldest backup when needed. This has the effect of keeping older backups around for a longer time, which is particularly useful if you've made a bad configuration change but didn't notice until several days later.
|
||||
|
||||
## Configuration
|
||||
The generational backup will be used when any one of `generational_days`, `generational_weeks`, `generational_months`, or `generational_years` is greater than zero. All of the available configuration options are given below, but utes much easier to configure from the Settings dialog accessible from the "Settings" menu at the top of the web UI.
|
||||
* `generational_days` (int): The number of days to keep
|
||||
* `generational_weeks` (int): The number of weeks to keep
|
||||
* `generational_months` (int): The number of months to keep
|
||||
* `generational_years` (int): The number of years to keep
|
||||
* `generational_day_of_week` (str): The day of the week when weekly backups will be kept. It can be one of 'mon', 'tue', 'wed', 'thu', 'fri', 'sat' or 'sun'. The default is 'mon'.
|
||||
* `generational_day_of_month` (int): The day of the month when monthly backups will be kept, from 1 to 31. If a month has less than the configured number of days, the latest day of that month is used.
|
||||
* `generational_day_of_year` (int): The day of the year that yearly backups are kept, from 1 to 365.
|
||||
|
||||
## Some Details to Consider
|
||||
* Generational backup assumes that a backup is available for every day to work properly, so it's recommended that you set `days_between_backups`=1 if you're using the feature. Otherwise, a backup may not be available to be saved for a given day.
|
||||
* The backups maintained by generational backup will still never exceed the number you permit to be maintained in Google Drive or Home Assistant. For example, if `max_backups_in_google_drive`=3 and `generational_weeks`=4, then only 3 weeks of backups will be kept in Google Drive.
|
||||
* Generational backup will only delete older backups when it has to. For example, if you've configured it to keep 5 weekly backups on Monday, you've been running it for a week (so you have 7 backups), and `max_backups_in_google_drive`=7, then your backups on Tuesday, Wednesday, etc won't get deleted yet. They won't get deleted until doing so is necessary to keep older backups around without violating the maximum allowed in Google Drive.
|
||||
>Note: You can configure the addon to delete backups more aggressively by setting `generational_delete_early`=true. With this, the addon will delete old backups that don't match a daily, weekly, monthly, or yearly configured cycle even if you aren't yet at risk of exceeding `max_backups_in_ha` or `max_backups_in_google_drive`. Careful though! You can accidentally delete all your backups this way if you don't have all your settings configured just the way you want them.
|
||||
* If more than one backup is created for a day (for example if you create one manually) then only the latest backup from that day will be kept.
|
||||
|
||||
## Schedule
|
||||
Figuring out date math in your head is hard, so it's useful to see a concrete example. Consider you have the following configuration. Two backups for each day, week, month, and year along with a limit in Google drive large enough to accommodate them all:
|
||||
```json
|
||||
"days_between_backups": 1,
|
||||
"generational_days": 2,
|
||||
"generational_weeks": 2,
|
||||
"generational_months": 2
|
||||
"generational_years": 2
|
||||
"max_backups_in_google_drive": 8
|
||||
```
|
||||
Imagine you've been running the add-on for 2 years now, diligently making a backup every day with no interruptions. On 19 May 2021, you could expect your list of backups in Google Drive to look like this:
|
||||
- May 19, 2021 <-- 1st Daily backup
|
||||
- May 18, 2021 <-- 2nd Daily backup
|
||||
- May 13, 2021 <-- 1st Weekly backup
|
||||
- May 06, 2021 <-- 2nd Weekly backup
|
||||
- May 01, 2021 <-- 1st Monthly backup
|
||||
- April 01, 2021 <-- 2nd Monthly backup
|
||||
- January 01, 2021 <-- 1st Yearly backup
|
||||
- January 01, 2020 <-- 2nd Yearly backup
|
||||
|
||||
Note that sometimes a day might overlap more than one schedule. For example, a backup on January 1st could satisfy the constraints for both a yearly and monthly backup. In this case, the add-on will only delete older backups when it *must* to keep from exceeding `max_backups_in_ha` or `max_backups_in_google_drive`. Thus, the most recent backup that would otherwise be deleted will be kept until space is needed somewhere else in the schedule.
|
||||
@@ -0,0 +1,34 @@
|
||||
# Home Assistant Add-on: Google Drive Backup
|
||||
|
||||
A complete and easy way to upload your Home Assistant backups to Google Drive.
|
||||
|
||||
## About
|
||||
|
||||
Quickly set up a backup strategy without much fuss. It doesn't require much familiarity with Home Assistant, its architecture, or Google Drive. Detailed install instructions are provided below but you can just add the repo, click install and open the Web UI. It will tell you what to do and only takes a few simple clicks.
|
||||
|
||||
>This project requires financial support to make the Google Drive integration work, but it is free for you to use. You can join those helping to keep the lights on at:
|
||||
>
|
||||
>[<img src="https://raw.githubusercontent.com/sabeechen/hassio-google-drive-backup/master/images/bmc-button.svg" width=150 height=40 style="margin: 5px"/>](https://www.buymeacoffee.com/sabeechen)
|
||||
>[<img src="https://raw.githubusercontent.com/sabeechen/hassio-google-drive-backup/master/images/paypal-button.svg" width=150 height=40 style="margin: 5px"/>](https://www.paypal.com/paypalme/stephenbeechen)
|
||||
>[<img src="https://raw.githubusercontent.com/sabeechen/hassio-google-drive-backup/master/images/patreon-button.svg" width=150 height=40 style="margin: 5px"/>](https://www.patreon.com/bePatron?u=4064183)
|
||||
>[<img src="https://raw.githubusercontent.com/sabeechen/hassio-google-drive-backup/master/images/github-sponsors-button.svg" width=150 height=40 style="margin: 5px"/>](https://github.com/sponsors/sabeechen)
|
||||
>[<img src="https://raw.githubusercontent.com/sabeechen/hassio-google-drive-backup/master/images/monero-button.svg" width=150 height=40 style="margin: 5px"/>](https://github.com/sabeechen/hassio-google-drive-backup/blob/master/donate-crypto.md)
|
||||
>[<img src="https://raw.githubusercontent.com/sabeechen/hassio-google-drive-backup/master/images/bitcoin-button.svg" width=150 height=40 style="margin: 5px"/>](https://github.com/sabeechen/hassio-google-drive-backup/blob/master/donate-crypto.md)
|
||||
>[<img src="https://raw.githubusercontent.com/sabeechen/hassio-google-drive-backup/master/images/ethereum-button.svg" width=150 height=40 style="margin: 5px"/>](https://github.com/sabeechen/hassio-google-drive-backup/blob/master/donate-crypto.md)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
- Creates backups on a configurable schedule.
|
||||
- Uploads backups to Drive, even the ones it didn't create.
|
||||
- Clean up old backups in Home Assistant and Google Drive, so you don't run out of space.
|
||||
- Restore from a fresh install or recover quickly from disaster by uploading your backups directly from Google Drive.
|
||||
- Integrates with Home Assistant Notifications, and provides sensors you can trigger off of.
|
||||
- Notifies you when something goes wrong with your backups.
|
||||
- Super easy installation and configuration.
|
||||
- Privacy-centric design philosophy.
|
||||
- Comprehensive documentation.
|
||||
- _Most certainly_ doesn't mine bitcoin on your home automation server. Definitely no.
|
||||
|
||||
See the [README on GitHub](https://github.com/sabeechen/hassio-google-drive-backup) for all the details, or just install the add-on and open the Web UI.
|
||||
The Web-UI explains everything you have to do.
|
||||
@@ -0,0 +1,7 @@
|
||||
#!/bin/bash
|
||||
|
||||
apk add python3 fping linux-headers libc-dev libffi-dev python3-dev gcc py3-pip
|
||||
pip3 install --upgrade pip wheel setuptools
|
||||
pip3 install --trusted-host pypi.python.org -r requirements-addon.txt
|
||||
# Remove packages we only needed for installation
|
||||
apk del linux-headers libc-dev libffi-dev python3-dev gcc
|
||||
@@ -0,0 +1,22 @@
|
||||
import platform
|
||||
import asyncio
|
||||
from aiorun import run
|
||||
from injector import Injector
|
||||
|
||||
from backup.module import MainModule, BaseModule
|
||||
from backup.starter import Starter
|
||||
|
||||
|
||||
async def main():
|
||||
await Injector([BaseModule(), MainModule()]).get(Starter).start()
|
||||
while True:
|
||||
await asyncio.sleep(1)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
if platform.system() == "Windows":
|
||||
# Needed for dev on windows machines
|
||||
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
|
||||
asyncio.run(main())
|
||||
else:
|
||||
run(main())
|
||||
@@ -0,0 +1,11 @@
|
||||
# flake8: noqa
|
||||
from .config import Config, GenConfig, UPGRADE_OPTIONS
|
||||
from .settings import Setting, _DEFAULTS, _VALIDATORS, _LOOKUP, VERSION, PRIVATE, isStaging, addon_config, _CONFIG
|
||||
from .createoptions import CreateOptions
|
||||
from .boolvalidator import BoolValidator
|
||||
from .startable import Startable
|
||||
from .listvalidator import ListValidator
|
||||
from .durationasstringvalidator import DurationAsStringValidator
|
||||
from .bytesizeasstringvalidator import BytesizeAsStringValidator
|
||||
from .version import Version
|
||||
from .durationparser import DurationParser
|
||||
@@ -0,0 +1,18 @@
|
||||
from .validator import Validator
|
||||
from ..logger import getLogger
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
|
||||
class BoolValidator(Validator):
|
||||
def __init__(self, name):
|
||||
super().__init__(name)
|
||||
|
||||
def validate(self, value):
|
||||
if value is None or (type(value) == str and len(value) == 0):
|
||||
return None
|
||||
return BoolValidator.strToBool(value)
|
||||
|
||||
@classmethod
|
||||
def strToBool(cls, value) -> bool:
|
||||
return str(value).lower() in ['true', 't', 'on', 'yes', 'y', '1', 'hai', 'si', 'omgyesplease']
|
||||
@@ -0,0 +1,56 @@
|
||||
import re
|
||||
from injector import inject, singleton
|
||||
|
||||
SECOND_IDENTIFIERS = ["s", "sec", "secs", "second", "seconds"]
|
||||
MINUTE_IDENTIFIERS = ["m", "min", "mins", "minute", "minutes"]
|
||||
HOUR_IDENTIFIERS = ["h", "hr", "hour", "hours"]
|
||||
DAY_IDENTIFIERS = ["d", "day", "days"]
|
||||
NUMBER_REGEX = "^([0-9]*[.])?[0-9]+"
|
||||
VALID_REGEX = "^[ ]*([0-9,]*\\.?[0-9]*)[ ]*(b|B|k|K|m|M|g|G|t|T|p|P|e|E|z|Z|y|Y)[a-zA-Z ]*[ ]*$"
|
||||
BYTES_BASE = 1024
|
||||
PREFIX_VALUES = {
|
||||
"b": 1,
|
||||
"k": BYTES_BASE,
|
||||
"m": pow(BYTES_BASE, 2),
|
||||
"g": pow(BYTES_BASE, 3),
|
||||
"t": pow(BYTES_BASE, 4),
|
||||
"p": pow(BYTES_BASE, 5),
|
||||
"e": pow(BYTES_BASE, 6),
|
||||
"z": pow(BYTES_BASE, 7),
|
||||
"y": pow(BYTES_BASE, 8)
|
||||
}
|
||||
|
||||
PREFIX_CANONICAL = ["", "K", "M", "G", "T", "P", "E", "Z", "Y"]
|
||||
|
||||
|
||||
@singleton
|
||||
class ByteFormatter():
|
||||
@inject
|
||||
def __init__(self):
|
||||
pass
|
||||
|
||||
def parse(self, source: str):
|
||||
source = source.lower()
|
||||
match = re.match(VALID_REGEX, source.lower())
|
||||
if not match:
|
||||
raise ValueError()
|
||||
number, prefix = match.group(1, 2)
|
||||
if prefix not in PREFIX_VALUES:
|
||||
raise ValueError()
|
||||
|
||||
return float(number) * PREFIX_VALUES[prefix]
|
||||
|
||||
def format(self, bytes):
|
||||
for prefix in PREFIX_CANONICAL:
|
||||
if bytes < BYTES_BASE:
|
||||
if int(bytes) == bytes:
|
||||
return f"{int(bytes)} {prefix}B"
|
||||
else:
|
||||
return f"{bytes} {prefix}B"
|
||||
bytes /= BYTES_BASE
|
||||
|
||||
bytes *= BYTES_BASE
|
||||
if int(bytes) == bytes:
|
||||
return f"{int(bytes)} YB"
|
||||
else:
|
||||
return f"{bytes} YB"
|
||||
@@ -0,0 +1,30 @@
|
||||
from .byteformatter import ByteFormatter
|
||||
from .validator import Validator
|
||||
|
||||
|
||||
class BytesizeAsStringValidator(Validator):
|
||||
def __init__(self, name, minimum=None, maximum=None):
|
||||
super().__init__(name)
|
||||
self.min = minimum
|
||||
self.max = maximum
|
||||
|
||||
def validate(self, value):
|
||||
if type(value) is str:
|
||||
value = value.strip()
|
||||
if value is None or (type(value) == str and len(value) == 0):
|
||||
return None
|
||||
try:
|
||||
if type(value) == str:
|
||||
value = ByteFormatter().parse(value)
|
||||
value = float(value)
|
||||
except ValueError:
|
||||
self.raiseForValue(value)
|
||||
|
||||
if self.max is not None and value > self.max:
|
||||
self.raiseForValue(value)
|
||||
if self.min is not None and value < self.min:
|
||||
self.raiseForValue(value)
|
||||
return value
|
||||
|
||||
def formatForUi(self, value):
|
||||
return ByteFormatter().format(value)
|
||||
@@ -0,0 +1,307 @@
|
||||
import json
|
||||
import os
|
||||
import os.path
|
||||
import uuid
|
||||
from typing import Any, Dict, List, Optional
|
||||
from yarl import URL
|
||||
|
||||
from .settings import _LOOKUP, Setting, _VALIDATORS
|
||||
from ..logger import getLogger
|
||||
from backup.file import JsonFileSaver
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
ALWAYS_KEEP = {
|
||||
Setting.DAYS_BETWEEN_BACKUPS,
|
||||
Setting.MAX_BACKUPS_IN_HA,
|
||||
Setting.MAX_BACKUPS_IN_GOOGLE_DRIVE,
|
||||
}
|
||||
|
||||
KEEP_DEFAULT = {
|
||||
Setting.SEND_ERROR_REPORTS,
|
||||
Setting.IGNORE_UPGRADE_BACKUPS
|
||||
}
|
||||
|
||||
# these are the options that should trigger a restart of the server
|
||||
SERVER_OPTIONS = {
|
||||
Setting.USE_SSL,
|
||||
Setting.REQUIRE_LOGIN,
|
||||
Setting.CERTFILE,
|
||||
Setting.KEYFILE,
|
||||
Setting.EXPOSE_EXTRA_SERVER
|
||||
}
|
||||
|
||||
NON_UI_SETTING = {
|
||||
Setting.SUPERVISOR_URL,
|
||||
Setting.TOKEN_SERVER_HOSTS,
|
||||
Setting.DRIVE_AUTHORIZE_URL,
|
||||
Setting.DRIVE_DEVICE_CODE_URL,
|
||||
Setting.DEFAULT_DRIVE_CLIENT_ID,
|
||||
Setting.NEW_BACKUP_TIMEOUT_SECONDS,
|
||||
Setting.LOG_LEVEL,
|
||||
Setting.CONSOLE_LOG_LEVEL,
|
||||
Setting.DEFAULT_SYNC_INTERVAL_VARIATION,
|
||||
Setting.CACHE_WARMUP_MAX_SECONDS,
|
||||
Setting.CACHE_WARMUP_ERROR_TIMEOUT_SECONDS,
|
||||
Setting.WATCH_BACKUP_DIRECTORY,
|
||||
Setting.TRACE_REQUESTS,
|
||||
Setting.MAX_BACKOFF_SECONDS
|
||||
}
|
||||
|
||||
UPGRADE_OPTIONS = {
|
||||
Setting.DEPRECTAED_MAX_BACKUPS_IN_HA: Setting.MAX_BACKUPS_IN_HA,
|
||||
Setting.DEPRECTAED_MAX_BACKUPS_IN_GOOGLE_DRIVE: Setting.MAX_BACKUPS_IN_GOOGLE_DRIVE,
|
||||
Setting.DEPRECATED_DAYS_BETWEEN_BACKUPS: Setting.DAYS_BETWEEN_BACKUPS,
|
||||
Setting.DEPRECTAED_IGNORE_OTHER_BACKUPS: Setting.IGNORE_OTHER_BACKUPS,
|
||||
Setting.DEPRECTAED_IGNORE_UPGRADE_BACKUPS: Setting.IGNORE_UPGRADE_BACKUPS,
|
||||
Setting.DEPRECTAED_DELETE_BEFORE_NEW_BACKUP: Setting.DELETE_BEFORE_NEW_BACKUP,
|
||||
Setting.DEPRECTAED_BACKUP_NAME: Setting.BACKUP_NAME,
|
||||
Setting.DEPRECTAED_BACKUP_TIME_OF_DAY: Setting.BACKUP_TIME_OF_DAY,
|
||||
Setting.DEPRECTAED_SPECIFY_BACKUP_FOLDER: Setting.SPECIFY_BACKUP_FOLDER,
|
||||
Setting.DEPRECTAED_NOTIFY_FOR_STALE_BACKUPS: Setting.NOTIFY_FOR_STALE_BACKUPS,
|
||||
Setting.DEPRECTAED_ENABLE_BACKUP_STALE_SENSOR: Setting.ENABLE_BACKUP_STALE_SENSOR,
|
||||
Setting.DEPRECTAED_ENABLE_BACKUP_STATE_SENSOR: Setting.ENABLE_BACKUP_STATE_SENSOR,
|
||||
Setting.DEPRECATED_BACKUP_PASSWORD: Setting.BACKUP_PASSWORD
|
||||
}
|
||||
|
||||
EMPTY_IS_DEFAULT = {
|
||||
Setting.ACCENT_COLOR,
|
||||
Setting.BACKGROUND_COLOR,
|
||||
}
|
||||
|
||||
|
||||
class GenConfig():
|
||||
def __init__(self, days=0, weeks=0, months=0, years=0, day_of_week='mon', day_of_month=1, day_of_year=1, aggressive=False):
|
||||
self.days = days
|
||||
self.weeks = weeks
|
||||
self.months = months
|
||||
self.years = years
|
||||
self.day_of_week = day_of_week
|
||||
self.day_of_month = day_of_month
|
||||
self.day_of_year = day_of_year
|
||||
self.aggressive = aggressive
|
||||
self._config_was_upgraded = False
|
||||
|
||||
def __eq__(self, other):
|
||||
"""Overrides the default implementation"""
|
||||
if isinstance(other, GenConfig):
|
||||
return self.__dict__ == other.__dict__
|
||||
return NotImplemented
|
||||
|
||||
def __hash__(self):
|
||||
"""Overrides the default implementation"""
|
||||
return hash(tuple(sorted(self.__dict__.items())))
|
||||
|
||||
|
||||
class Config():
|
||||
@classmethod
|
||||
def fromFile(cls, config_path):
|
||||
return Config(JsonFileSaver.read(config_path))
|
||||
|
||||
@classmethod
|
||||
def withOverrides(cls, overrides):
|
||||
config = Config()
|
||||
for key in overrides.keys():
|
||||
config.override(key, overrides[key])
|
||||
return config
|
||||
|
||||
@classmethod
|
||||
def withFileOverrides(cls, override_path):
|
||||
data = JsonFileSaver.read(override_path)
|
||||
overrides = {}
|
||||
for key in data.keys():
|
||||
overrides[_LOOKUP[key]] = data[key]
|
||||
return Config.withOverrides(overrides)
|
||||
|
||||
@classmethod
|
||||
def fromEnvironment(cls):
|
||||
config = {}
|
||||
for key in os.environ:
|
||||
if key in _LOOKUP:
|
||||
config[_LOOKUP[key]] = _VALIDATORS[_LOOKUP[key]].validate(os.environ[key])
|
||||
elif str.lower(key) in _LOOKUP:
|
||||
config[_LOOKUP[str.lower(key)]] = _VALIDATORS[_LOOKUP[str.lower(key)]].validate(os.environ[key])
|
||||
return Config(config)
|
||||
|
||||
def __init__(self, data=None):
|
||||
self.overrides = {}
|
||||
if data is None:
|
||||
self.config = {}
|
||||
else:
|
||||
self.config = data
|
||||
self._legacy_ignored_behavior = False
|
||||
self._subscriptions = []
|
||||
self._clientIdentifier = None
|
||||
self.retained = self._loadRetained()
|
||||
self._gen_config_cache = self.getGenerationalConfig()
|
||||
|
||||
# Tracks when hosts have been seen to be offline to retry on different hosts.
|
||||
self._commFailure = {}
|
||||
|
||||
def getConfigFor(self, options):
|
||||
new_config = Config()
|
||||
new_config.overrides = self.overrides.copy()
|
||||
new_config.update(options)
|
||||
return new_config
|
||||
|
||||
def validateUpdate(self, additions):
|
||||
new_config = self.config.copy()
|
||||
new_config.update(additions)
|
||||
validated, upgraded = self.validate(new_config)
|
||||
return validated
|
||||
|
||||
def validate(self, new_config) -> Dict[str, Any]:
|
||||
final_config = {}
|
||||
|
||||
upgraded = False
|
||||
# validate each item
|
||||
for key in new_config:
|
||||
if type(key) == str:
|
||||
if key not in _LOOKUP:
|
||||
# its not in the schema, just ignore it
|
||||
continue
|
||||
setting = _LOOKUP[key]
|
||||
else:
|
||||
setting = key
|
||||
|
||||
value = setting.validator().validate(new_config[key])
|
||||
if setting in UPGRADE_OPTIONS:
|
||||
upgraded = True
|
||||
if isinstance(value, str) and len(value) == 0 and setting in EMPTY_IS_DEFAULT:
|
||||
value = setting.default()
|
||||
if value is not None and (setting in KEEP_DEFAULT or value != setting.default()):
|
||||
if setting in UPGRADE_OPTIONS and (UPGRADE_OPTIONS[setting] not in new_config or new_config[UPGRADE_OPTIONS[setting]] == UPGRADE_OPTIONS[setting].default()):
|
||||
upgraded = True
|
||||
final_config[UPGRADE_OPTIONS[setting]] = value
|
||||
elif setting not in UPGRADE_OPTIONS:
|
||||
final_config[setting] = value
|
||||
|
||||
if upgraded:
|
||||
final_config[Setting.CALL_BACKUP_SNAPSHOT] = True
|
||||
|
||||
# add in non-ui settings
|
||||
for setting in NON_UI_SETTING:
|
||||
if self.get(setting) != setting.default() and not (setting in new_config or setting.key in new_config) and setting not in self.overrides:
|
||||
final_config[setting] = self.get(setting)
|
||||
|
||||
# add defaults
|
||||
for key in ALWAYS_KEEP:
|
||||
if key not in final_config:
|
||||
final_config[key] = key.default()
|
||||
|
||||
if not final_config.get(Setting.USE_SSL, False):
|
||||
for key in [Setting.CERTFILE, Setting.KEYFILE]:
|
||||
if key in final_config:
|
||||
del final_config[key]
|
||||
|
||||
return final_config, upgraded
|
||||
|
||||
def update(self, new_config):
|
||||
validated, upgraded = self.validate(new_config)
|
||||
self._config_was_upgraded = upgraded
|
||||
self.config = validated
|
||||
self._gen_config_cache = self.getGenerationalConfig()
|
||||
for sub in self._subscriptions:
|
||||
sub()
|
||||
|
||||
def getServerOptions(self):
|
||||
ret = {}
|
||||
for setting in SERVER_OPTIONS:
|
||||
ret[setting] = self.get(setting)
|
||||
return ret
|
||||
|
||||
def subscribe(self, func):
|
||||
self._subscriptions.append(func)
|
||||
|
||||
def clientIdentifier(self) -> str:
|
||||
if self._clientIdentifier is None:
|
||||
try:
|
||||
if JsonFileSaver.exists(self.get(Setting.ID_FILE_PATH)):
|
||||
self._clientIdentifier = JsonFileSaver.read(self.get(Setting.ID_FILE_PATH))['id']
|
||||
else:
|
||||
self._clientIdentifier = str(uuid.uuid4())
|
||||
JsonFileSaver.write(self.get(Setting.ID_FILE_PATH), {'id': self._clientIdentifier})
|
||||
except Exception:
|
||||
self._clientIdentifier = str(uuid.uuid4())
|
||||
return self._clientIdentifier
|
||||
|
||||
def getGenerationalConfig(self) -> Optional[Dict[str, Any]]:
|
||||
days = self.get(Setting.GENERATIONAL_DAYS)
|
||||
weeks = self.get(Setting.GENERATIONAL_WEEKS)
|
||||
months = self.get(Setting.GENERATIONAL_MONTHS)
|
||||
years = self.get(Setting.GENERATIONAL_YEARS)
|
||||
if days + weeks + months + years == 0:
|
||||
return None
|
||||
base = GenConfig(
|
||||
days=days,
|
||||
weeks=weeks,
|
||||
months=months,
|
||||
years=years,
|
||||
day_of_week=self.get(Setting.GENERATIONAL_DAY_OF_WEEK),
|
||||
day_of_month=self.get(Setting.GENERATIONAL_DAY_OF_MONTH),
|
||||
day_of_year=self.get(Setting.GENERATIONAL_DAY_OF_YEAR),
|
||||
aggressive=self.get(Setting.GENERATIONAL_DELETE_EARLY)
|
||||
)
|
||||
if base.days <= 1:
|
||||
# must always be >= 1, otherwise we'll just create and delete backups constantly.
|
||||
base.days = 1
|
||||
return base
|
||||
|
||||
def _loadRetained(self) -> List[str]:
|
||||
if JsonFileSaver.exists(self.get(Setting.RETAINED_FILE_PATH)):
|
||||
try:
|
||||
return JsonFileSaver.read(self.get(Setting.RETAINED_FILE_PATH))['retained']
|
||||
except json.decoder.JSONDecodeError:
|
||||
logger.error("Unable to parse retained backup settings")
|
||||
return []
|
||||
return []
|
||||
|
||||
def isRetained(self, slug):
|
||||
return slug in self.retained
|
||||
|
||||
def setRetained(self, slug, retain):
|
||||
if retain and slug not in self.retained:
|
||||
self.retained.append(slug)
|
||||
JsonFileSaver.write(self.get(Setting.RETAINED_FILE_PATH), {'retained': self.retained})
|
||||
elif not retain and slug in self.retained:
|
||||
self.retained.remove(slug)
|
||||
JsonFileSaver.write(self.get(Setting.RETAINED_FILE_PATH), {'retained': self.retained})
|
||||
|
||||
def isExplicit(self, setting):
|
||||
return setting in self.config or setting.value in self.config
|
||||
|
||||
def override(self, setting: Setting, value):
|
||||
self.overrides[setting] = value
|
||||
return self
|
||||
|
||||
def get(self, setting: Setting) -> Any:
|
||||
if setting in self.overrides:
|
||||
return self.overrides[setting]
|
||||
if setting in self.config:
|
||||
return self.config[setting]
|
||||
if setting.key() in self.config:
|
||||
return self.config[setting.key()]
|
||||
else:
|
||||
if setting == Setting.IGNORE_UPGRADE_BACKUPS and self._legacy_ignored_behavior:
|
||||
# Use the old behavior, rather than the new one
|
||||
return False
|
||||
return setting.default()
|
||||
|
||||
def getForUi(self, setting: Setting):
|
||||
return _VALIDATORS[setting].formatForUi(self.get(setting))
|
||||
|
||||
def getTokenServers(self, path: str = "") -> List[URL]:
|
||||
return list(map(lambda s: URL(s).with_path(path), self.get(Setting.TOKEN_SERVER_HOSTS).split(",")))
|
||||
|
||||
def mustSaveUpgradeChanges(self):
|
||||
return self._config_was_upgraded
|
||||
|
||||
def getAllConfig(self) -> Dict[Setting, Any]:
|
||||
return self.config.copy()
|
||||
|
||||
def persistedChanges(self):
|
||||
self._config_was_upgraded = False
|
||||
|
||||
def useLegacyIgnoredBehavior(self, value: bool):
|
||||
"""If the user upgrades from an old version and hasn't explicitely said they want to include upgrade backups, then this reverts them to the old behavior where they aren't ignored"""
|
||||
self._legacy_ignored_behavior = value
|
||||
@@ -0,0 +1,13 @@
|
||||
from datetime import datetime
|
||||
from typing import Dict
|
||||
from ..logger import getLogger
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
|
||||
class CreateOptions(object):
|
||||
def __init__(self, when: datetime, name_template: str, retain_sources: Dict[str, bool] = {}, note: str = None):
|
||||
self.when: datetime = when
|
||||
self.name_template: str = name_template
|
||||
self.retain_sources: Dict[str, bool] = retain_sources
|
||||
self.note = note
|
||||
@@ -0,0 +1,37 @@
|
||||
from datetime import timedelta
|
||||
from .durationparser import DurationParser
|
||||
from .validator import Validator
|
||||
|
||||
|
||||
class DurationAsStringValidator(Validator):
|
||||
def __init__(self, name, minimum=None, maximum=None, base_seconds=1, default_as_empty=None):
|
||||
super().__init__(name)
|
||||
self.min = minimum
|
||||
self.max = maximum
|
||||
self.base_seconds = base_seconds
|
||||
self.default_as_empty = default_as_empty
|
||||
|
||||
def validate(self, value):
|
||||
if value is None or (type(value) == str and len(value) == 0):
|
||||
return None
|
||||
try:
|
||||
if type(value) == str:
|
||||
if self.default_as_empty is not None and value == "":
|
||||
value = self.default_as_empty
|
||||
else:
|
||||
value = DurationParser().parse(value).total_seconds() / self.base_seconds
|
||||
value = float(value)
|
||||
except ValueError:
|
||||
self.raiseForValue(value)
|
||||
|
||||
if self.max is not None and value > self.max:
|
||||
self.raiseForValue(value)
|
||||
if self.min is not None and value < self.min:
|
||||
self.raiseForValue(value)
|
||||
return value
|
||||
|
||||
def formatForUi(self, value):
|
||||
if self.default_as_empty is not None and value == self.default_as_empty:
|
||||
return ""
|
||||
else:
|
||||
return DurationParser().format(timedelta(seconds=value * self.base_seconds))
|
||||
@@ -0,0 +1,80 @@
|
||||
import re
|
||||
from datetime import timedelta
|
||||
from injector import inject, singleton
|
||||
|
||||
SECOND_IDENTIFIERS = ["s", "sec", "secs", "second", "seconds"]
|
||||
MINUTE_IDENTIFIERS = ["m", "min", "mins", "minute", "minutes"]
|
||||
HOUR_IDENTIFIERS = ["h", "hr", "hour", "hours"]
|
||||
DAY_IDENTIFIERS = ["d", "day", "days"]
|
||||
NUMBER_REGEX = "^([0-9]*[.])?[0-9]+"
|
||||
VALID_REGEX = "^([ ]*([0-9]*[.])?[0-9]+[ ]*(seconds|second|secs|sec|s|minutes|minute|mins|min|m|hours|hour|hr|h|days|day|d)?[ ,]*)*"
|
||||
|
||||
|
||||
@singleton
|
||||
class DurationParser():
|
||||
@inject
|
||||
def __init__(self):
|
||||
pass
|
||||
|
||||
def parse(self, source: str):
|
||||
source = source.lower()
|
||||
total_match = re.match(VALID_REGEX, source)
|
||||
if not total_match or total_match.group(0) != source:
|
||||
raise ValueError()
|
||||
parts = source.split()
|
||||
i = 0
|
||||
total = timedelta(seconds=0)
|
||||
while (i < len(parts)):
|
||||
part = parts[i].strip().strip(',')
|
||||
match = re.match(NUMBER_REGEX, part)
|
||||
i += 1
|
||||
if not match:
|
||||
raise ValueError()
|
||||
length = float(match.group(0))
|
||||
if match.group(0) == part:
|
||||
|
||||
if i < len(parts):
|
||||
next_part = parts[i].strip().strip(',')
|
||||
if next_part in SECOND_IDENTIFIERS or next_part in MINUTE_IDENTIFIERS or next_part in HOUR_IDENTIFIERS or next_part in DAY_IDENTIFIERS:
|
||||
identifier = next_part
|
||||
i += 1
|
||||
else:
|
||||
identifier = SECOND_IDENTIFIERS[0]
|
||||
else:
|
||||
identifier = "s"
|
||||
else:
|
||||
identifier = part[len(match.group(0)):]
|
||||
if identifier in SECOND_IDENTIFIERS:
|
||||
total += timedelta(seconds=length)
|
||||
elif identifier in MINUTE_IDENTIFIERS:
|
||||
total += timedelta(minutes=length)
|
||||
elif identifier in HOUR_IDENTIFIERS:
|
||||
total += timedelta(hours=length)
|
||||
elif identifier in DAY_IDENTIFIERS:
|
||||
total += timedelta(days=length)
|
||||
else:
|
||||
raise ValueError()
|
||||
return total
|
||||
|
||||
def format(self, duration: timedelta):
|
||||
parts = []
|
||||
if duration >= timedelta(days=1):
|
||||
days = int(duration.days)
|
||||
parts.append("{} days".format(days))
|
||||
duration = duration - timedelta(days=days)
|
||||
if duration >= timedelta(hours=1):
|
||||
hours = int(duration.seconds / (60 * 60))
|
||||
parts.append("{} hours".format(hours))
|
||||
duration = duration - timedelta(hours=hours)
|
||||
if duration >= timedelta(minutes=1):
|
||||
minutes = int(duration.seconds / 60)
|
||||
parts.append("{} minutes".format(minutes))
|
||||
duration = duration - timedelta(minutes=minutes)
|
||||
if duration >= timedelta(seconds=1):
|
||||
seconds = int(duration.seconds)
|
||||
parts.append("{} seconds".format(seconds))
|
||||
duration = duration - timedelta(seconds=seconds)
|
||||
if len(parts) > 0:
|
||||
return ", ".join(parts)
|
||||
else:
|
||||
return "0 seconds"
|
||||
@@ -0,0 +1,25 @@
|
||||
from .validator import Validator
|
||||
from ..logger import getLogger
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
|
||||
class FloatValidator(Validator):
|
||||
def __init__(self, name, minimum=None, maximum=None):
|
||||
super().__init__(name)
|
||||
self.min = minimum
|
||||
self.max = maximum
|
||||
|
||||
def validate(self, value):
|
||||
if value is None or (type(value) == str and len(value) == 0):
|
||||
return None
|
||||
try:
|
||||
value = float(value)
|
||||
except ValueError:
|
||||
self.raiseForValue(value)
|
||||
|
||||
if self.max is not None and value > self.max:
|
||||
self.raiseForValue(value)
|
||||
if self.min is not None and value < self.min:
|
||||
self.raiseForValue(value)
|
||||
return value
|
||||
@@ -0,0 +1,25 @@
|
||||
from .validator import Validator
|
||||
from ..logger import getLogger
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
|
||||
class IntValidator(Validator):
|
||||
def __init__(self, name, minimum=None, maximum=None):
|
||||
super().__init__(name)
|
||||
self.min = minimum
|
||||
self.max = maximum
|
||||
|
||||
def validate(self, value):
|
||||
if value is None or (type(value) == str and len(value) == 0):
|
||||
return None
|
||||
try:
|
||||
value = int(value)
|
||||
except ValueError:
|
||||
self.raiseForValue(value)
|
||||
|
||||
if self.max is not None and value > self.max:
|
||||
self.raiseForValue(value)
|
||||
if self.min is not None and value < self.min:
|
||||
self.raiseForValue(value)
|
||||
return value
|
||||
@@ -0,0 +1,15 @@
|
||||
from .validator import Validator
|
||||
from ..logger import getLogger
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
|
||||
class ListValidator(Validator):
|
||||
def __init__(self, name, values):
|
||||
super().__init__(name)
|
||||
self.values = values
|
||||
|
||||
def validate(self, value):
|
||||
if value not in self.values:
|
||||
self.raiseForValue(value)
|
||||
return value
|
||||
@@ -0,0 +1,19 @@
|
||||
from .validator import Validator
|
||||
import re
|
||||
from ..logger import getLogger
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
|
||||
class RegexValidator(Validator):
|
||||
def __init__(self, name, regex):
|
||||
super().__init__(name)
|
||||
self.re = re.compile(regex)
|
||||
|
||||
def validate(self, value):
|
||||
if value is None or (type(value) == str and len(value) == 0):
|
||||
return ""
|
||||
value = str(value)
|
||||
if not self.re.match(value):
|
||||
self.raiseForValue(value)
|
||||
return value
|
||||
@@ -0,0 +1,514 @@
|
||||
import json
|
||||
from enum import Enum, unique
|
||||
from os.path import abspath, join
|
||||
|
||||
from .boolvalidator import BoolValidator
|
||||
from .floatvalidator import FloatValidator
|
||||
from .intvalidator import IntValidator
|
||||
from .regexvalidator import RegexValidator
|
||||
from .stringvalidator import StringValidator
|
||||
from .listvalidator import ListValidator
|
||||
from .durationasstringvalidator import DurationAsStringValidator
|
||||
from .bytesizeasstringvalidator import BytesizeAsStringValidator
|
||||
from ..logger import getLogger
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
|
||||
@unique
|
||||
class Setting(Enum):
|
||||
MAX_BACKUPS_IN_HA = "max_backups_in_ha"
|
||||
MAX_BACKUPS_IN_GOOGLE_DRIVE = "max_backups_in_google_drive"
|
||||
DAYS_BETWEEN_BACKUPS = "days_between_backups"
|
||||
IGNORE_OTHER_BACKUPS = "ignore_other_backups"
|
||||
IGNORE_UPGRADE_BACKUPS = "ignore_upgrade_backups"
|
||||
DELETE_IGNORED_AFTER_DAYS = "delete_ignored_after_days"
|
||||
DELETE_BEFORE_NEW_BACKUP = "delete_before_new_backup"
|
||||
BACKUP_NAME = "backup_name"
|
||||
BACKUP_TIME_OF_DAY = "backup_time_of_day"
|
||||
SPECIFY_BACKUP_FOLDER = "specify_backup_folder"
|
||||
NOTIFY_FOR_STALE_BACKUPS = "notify_for_stale_backups"
|
||||
ENABLE_BACKUP_STALE_SENSOR = "enable_backup_stale_sensor"
|
||||
ENABLE_BACKUP_STATE_SENSOR = "enable_backup_state_sensor"
|
||||
BACKUP_PASSWORD = "backup_password"
|
||||
CALL_BACKUP_SNAPSHOT = "call_backup_snapshot"
|
||||
|
||||
# Basic backup settings
|
||||
WARN_FOR_LOW_SPACE = "warn_for_low_space"
|
||||
LOW_SPACE_THRESHOLD = "low_space_threshold"
|
||||
DELETE_AFTER_UPLOAD = "delete_after_upload"
|
||||
|
||||
# generational settings
|
||||
GENERATIONAL_DAYS = "generational_days"
|
||||
GENERATIONAL_WEEKS = "generational_weeks"
|
||||
GENERATIONAL_MONTHS = "generational_months"
|
||||
GENERATIONAL_YEARS = "generational_years"
|
||||
GENERATIONAL_DAY_OF_WEEK = "generational_day_of_week"
|
||||
GENERATIONAL_DAY_OF_MONTH = "generational_day_of_month"
|
||||
GENERATIONAL_DAY_OF_YEAR = "generational_day_of_year"
|
||||
GENERATIONAL_DELETE_EARLY = "generational_delete_early"
|
||||
|
||||
# Partial backups
|
||||
EXCLUDE_FOLDERS = "exclude_folders"
|
||||
EXCLUDE_ADDONS = "exclude_addons"
|
||||
|
||||
STOP_ADDONS = "stop_addons"
|
||||
DISABLE_WATCHDOG_WHEN_STOPPING = "disable_watchdog_when_stopping"
|
||||
|
||||
# UI Server Options
|
||||
USE_SSL = "use_ssl"
|
||||
CERTFILE = "certfile"
|
||||
KEYFILE = "keyfile"
|
||||
INGRESS_PORT = "ingress_port"
|
||||
PORT = "port"
|
||||
REQUIRE_LOGIN = "require_login"
|
||||
EXPOSE_EXTRA_SERVER = "expose_extra_server"
|
||||
|
||||
# Add-on options
|
||||
VERBOSE = "verbose"
|
||||
SEND_ERROR_REPORTS = "send_error_reports"
|
||||
CONFIRM_MULTIPLE_DELETES = "confirm_multiple_deletes"
|
||||
ENABLE_DRIVE_UPLOAD = "enable_drive_upload"
|
||||
WATCH_BACKUP_DIRECTORY = "watch_backup_directory"
|
||||
TRACE_REQUESTS = "trace_requests"
|
||||
|
||||
# Theme Settings
|
||||
BACKGROUND_COLOR = "background_color"
|
||||
ACCENT_COLOR = "accent_color"
|
||||
|
||||
# Network and dns stuff
|
||||
DRIVE_EXPERIMENTAL = "drive_experimental"
|
||||
DRIVE_IPV4 = "drive_ipv4"
|
||||
IGNORE_IPV6_ADDRESSES = "ignore_ipv6_addresses"
|
||||
GOOGLE_DRIVE_TIMEOUT_SECONDS = "google_drive_timeout_seconds"
|
||||
GOOGLE_DRIVE_PAGE_SIZE = "google_drive_page_size"
|
||||
ALTERNATE_DNS_SERVERS = "alternate_dns_servers"
|
||||
DEFAULT_DRIVE_CLIENT_ID = "default_drive_client_id"
|
||||
DEFAULT_DRIVE_CLIENT_SECRET = "default_drive_client_secret"
|
||||
DRIVE_PICKER_API_KEY = "drive_picker_api_key"
|
||||
MAXIMUM_UPLOAD_CHUNK_BYTES = "maximum_upload_chunk_bytes"
|
||||
|
||||
# Files and folders
|
||||
FOLDER_FILE_PATH = "folder_file_path"
|
||||
CREDENTIALS_FILE_PATH = "credentials_file_path"
|
||||
RETAINED_FILE_PATH = "retained_file_path"
|
||||
SECRETS_FILE_PATH = "secrets_file_path"
|
||||
BACKUP_DIRECTORY_PATH = "backup_directory_path"
|
||||
INGRESS_TOKEN_FILE_PATH = "ingress_token_file_path"
|
||||
CONFIG_FILE_PATH = "config_file_path"
|
||||
ID_FILE_PATH = "id_file_path"
|
||||
DATA_CACHE_FILE_PATH = "data_cache_file_path"
|
||||
|
||||
# endpoints
|
||||
AUTHORIZATION_HOST = "authorization_host"
|
||||
TOKEN_SERVER_HOSTS = "token_server_hosts"
|
||||
SUPERVISOR_URL = "supervisor_url"
|
||||
DRIVE_URL = "drive_url"
|
||||
SUPERVISOR_TOKEN = "hassio_header"
|
||||
DRIVE_HOST_NAME = "drive_host_name"
|
||||
DRIVE_REFRESH_URL = "drive_refresh_url"
|
||||
DRIVE_AUTHORIZE_URL = "drive_authorize_url"
|
||||
DRIVE_DEVICE_CODE_URL = "drive_device_code_url"
|
||||
DRIVE_TOKEN_URL = "drive_token_url"
|
||||
SAVE_DRIVE_CREDS_PATH = "save_drive_creds_path"
|
||||
STOP_ADDON_STATE_PATH = "stop_addon_state_path"
|
||||
|
||||
# Timing and timeouts
|
||||
MAX_SYNC_INTERVAL_SECONDS = "max_sync_interval_seconds"
|
||||
DEFAULT_SYNC_INTERVAL_VARIATION = "default_sync_interval_variation"
|
||||
BACKUP_STALE_SECONDS = "backup_stale_seconds"
|
||||
PENDING_BACKUP_TIMEOUT_SECONDS = "pending_backup_timeout_seconds"
|
||||
FAILED_BACKUP_TIMEOUT_SECONDS = "failed_backup_timeout_seconds"
|
||||
NEW_BACKUP_TIMEOUT_SECONDS = "new_backup_timeout_seconds"
|
||||
DOWNLOAD_TIMEOUT_SECONDS = "download_timeout_seconds"
|
||||
DEFAULT_CHUNK_SIZE = "default_chunk_size"
|
||||
DEBUGGER_PORT = "debugger_port"
|
||||
SERVER_PROJECT_ID = "server_project_id"
|
||||
LOG_LEVEL = "log_level"
|
||||
CONSOLE_LOG_LEVEL = "console_log_level"
|
||||
BACKUP_STARTUP_DELAY_MINUTES = "backup_startup_delay_minutes"
|
||||
EXCHANGER_TIMEOUT_SECONDS = "exchanger_timeout_seconds"
|
||||
HA_REPORTING_INTERVAL_SECONDS = "ha_reporting_interval_seconds"
|
||||
LONG_TERM_STALE_BACKUP_SECONDS = "long_term_stale_backup_seconds"
|
||||
PING_TIMEOUT = "ping_timeout"
|
||||
CACHE_WARMUP_MAX_SECONDS = "cache_warmup_max_seconds"
|
||||
CACHE_WARMUP_ERROR_TIMEOUT_SECONDS = "cache_warmup_error_timeout"
|
||||
MAX_BACKOFF_SECONDS = "max_backoff_seconds"
|
||||
|
||||
# Old, deprecated settings
|
||||
DEPRECTAED_MAX_BACKUPS_IN_HA = "max_snapshots_in_hassio"
|
||||
DEPRECTAED_MAX_BACKUPS_IN_GOOGLE_DRIVE = "max_snapshots_in_google_drive"
|
||||
DEPRECATED_DAYS_BETWEEN_BACKUPS = "days_between_snapshots"
|
||||
DEPRECTAED_IGNORE_OTHER_BACKUPS = "ignore_other_snapshots"
|
||||
DEPRECTAED_IGNORE_UPGRADE_BACKUPS = "ignore_upgrade_snapshots"
|
||||
DEPRECTAED_BACKUP_NAME = "snapshot_name"
|
||||
DEPRECTAED_BACKUP_TIME_OF_DAY = "snapshot_time_of_day"
|
||||
DEPRECATED_BACKUP_PASSWORD = "snapshot_password"
|
||||
DEPRECTAED_SPECIFY_BACKUP_FOLDER = "specify_snapshot_folder"
|
||||
DEPRECTAED_DELETE_BEFORE_NEW_BACKUP = "delete_before_new_snapshot"
|
||||
DEPRECTAED_NOTIFY_FOR_STALE_BACKUPS = "notify_for_stale_snapshots"
|
||||
DEPRECTAED_ENABLE_BACKUP_STALE_SENSOR = "enable_snapshot_stale_sensor"
|
||||
DEPRECTAED_ENABLE_BACKUP_STATE_SENSOR = "enable_snapshot_state_sensor"
|
||||
|
||||
def default(self):
|
||||
if "staging" in VERSION and self in _STAGING_DEFAULTS:
|
||||
return _STAGING_DEFAULTS[self]
|
||||
return _DEFAULTS[self]
|
||||
|
||||
def validator(self):
|
||||
return _VALIDATORS[self]
|
||||
|
||||
def key(self):
|
||||
return self.value
|
||||
|
||||
|
||||
_DEFAULTS = {
|
||||
Setting.MAX_BACKUPS_IN_HA: 4,
|
||||
Setting.MAX_BACKUPS_IN_GOOGLE_DRIVE: 4,
|
||||
Setting.DAYS_BETWEEN_BACKUPS: 3,
|
||||
Setting.IGNORE_OTHER_BACKUPS: False,
|
||||
Setting.IGNORE_UPGRADE_BACKUPS: True,
|
||||
Setting.DELETE_IGNORED_AFTER_DAYS: 0,
|
||||
Setting.DELETE_BEFORE_NEW_BACKUP: False,
|
||||
Setting.BACKUP_NAME: "{type} Backup {year}-{month}-{day} {hr24}:{min}:{sec}",
|
||||
Setting.BACKUP_TIME_OF_DAY: "",
|
||||
Setting.SPECIFY_BACKUP_FOLDER: False,
|
||||
Setting.NOTIFY_FOR_STALE_BACKUPS: True,
|
||||
Setting.ENABLE_BACKUP_STALE_SENSOR: True,
|
||||
Setting.ENABLE_BACKUP_STATE_SENSOR: True,
|
||||
Setting.BACKUP_PASSWORD: "",
|
||||
Setting.WATCH_BACKUP_DIRECTORY: True,
|
||||
Setting.TRACE_REQUESTS: False,
|
||||
|
||||
# Basic backup settings
|
||||
Setting.DEPRECTAED_MAX_BACKUPS_IN_HA: 4,
|
||||
Setting.DEPRECTAED_MAX_BACKUPS_IN_GOOGLE_DRIVE: 4,
|
||||
Setting.DEPRECATED_DAYS_BETWEEN_BACKUPS: 3,
|
||||
Setting.DEPRECTAED_IGNORE_OTHER_BACKUPS: False,
|
||||
Setting.DEPRECTAED_IGNORE_UPGRADE_BACKUPS: False,
|
||||
Setting.DEPRECTAED_BACKUP_TIME_OF_DAY: "",
|
||||
Setting.DEPRECTAED_BACKUP_NAME: "{type} Snapshot {year}-{month}-{day} {hr24}:{min}:{sec}",
|
||||
Setting.DEPRECATED_BACKUP_PASSWORD: "",
|
||||
Setting.DEPRECTAED_SPECIFY_BACKUP_FOLDER: False,
|
||||
Setting.WARN_FOR_LOW_SPACE: True,
|
||||
Setting.LOW_SPACE_THRESHOLD: 1024 * 1024 * 1024,
|
||||
Setting.DELETE_AFTER_UPLOAD: False,
|
||||
Setting.DEPRECTAED_DELETE_BEFORE_NEW_BACKUP: False,
|
||||
Setting.CALL_BACKUP_SNAPSHOT: False,
|
||||
|
||||
# Generational backup settings
|
||||
Setting.GENERATIONAL_DAYS: 0,
|
||||
Setting.GENERATIONAL_WEEKS: 0,
|
||||
Setting.GENERATIONAL_MONTHS: 0,
|
||||
Setting.GENERATIONAL_YEARS: 0,
|
||||
Setting.GENERATIONAL_DAY_OF_WEEK: "mon",
|
||||
Setting.GENERATIONAL_DAY_OF_MONTH: 1,
|
||||
Setting.GENERATIONAL_DAY_OF_YEAR: 1,
|
||||
Setting.GENERATIONAL_DELETE_EARLY: False,
|
||||
|
||||
# Partial backup settings
|
||||
Setting.EXCLUDE_FOLDERS: "",
|
||||
Setting.EXCLUDE_ADDONS: "",
|
||||
|
||||
Setting.STOP_ADDONS: "",
|
||||
Setting.DISABLE_WATCHDOG_WHEN_STOPPING: False,
|
||||
|
||||
# UI Server settings
|
||||
Setting.USE_SSL: False,
|
||||
Setting.REQUIRE_LOGIN: False,
|
||||
Setting.EXPOSE_EXTRA_SERVER: False,
|
||||
Setting.CERTFILE: "/ssl/fullchain.pem",
|
||||
Setting.KEYFILE: "/ssl/privkey.pem",
|
||||
Setting.INGRESS_PORT: 8099,
|
||||
Setting.PORT: 1627,
|
||||
|
||||
# Add-on options
|
||||
Setting.DEPRECTAED_NOTIFY_FOR_STALE_BACKUPS: True,
|
||||
Setting.DEPRECTAED_ENABLE_BACKUP_STALE_SENSOR: True,
|
||||
Setting.DEPRECTAED_ENABLE_BACKUP_STATE_SENSOR: True,
|
||||
Setting.SEND_ERROR_REPORTS: False,
|
||||
Setting.VERBOSE: False,
|
||||
Setting.CONFIRM_MULTIPLE_DELETES: True,
|
||||
Setting.ENABLE_DRIVE_UPLOAD: True,
|
||||
|
||||
# Theme Settings
|
||||
Setting.BACKGROUND_COLOR: "",
|
||||
Setting.ACCENT_COLOR: "",
|
||||
|
||||
# Network and DNS settings
|
||||
Setting.ALTERNATE_DNS_SERVERS: "8.8.8.8,8.8.4.4",
|
||||
Setting.DRIVE_EXPERIMENTAL: False,
|
||||
Setting.DRIVE_IPV4: "",
|
||||
Setting.IGNORE_IPV6_ADDRESSES: False,
|
||||
Setting.GOOGLE_DRIVE_TIMEOUT_SECONDS: 180,
|
||||
Setting.GOOGLE_DRIVE_PAGE_SIZE: 100,
|
||||
Setting.MAXIMUM_UPLOAD_CHUNK_BYTES: 10 * 1024 * 1024,
|
||||
|
||||
# Remote endpoints
|
||||
Setting.AUTHORIZATION_HOST: "https://habackup.io",
|
||||
Setting.TOKEN_SERVER_HOSTS: "https://token1.habackup.io,https://habackup.io",
|
||||
Setting.SUPERVISOR_URL: "",
|
||||
Setting.SUPERVISOR_TOKEN: "",
|
||||
Setting.DRIVE_URL: "https://www.googleapis.com",
|
||||
Setting.DRIVE_REFRESH_URL: "https://www.googleapis.com/oauth2/v4/token",
|
||||
Setting.DRIVE_AUTHORIZE_URL: "https://accounts.google.com/o/oauth2/v2/auth",
|
||||
Setting.DRIVE_DEVICE_CODE_URL: "https://oauth2.googleapis.com/device/code",
|
||||
Setting.DRIVE_TOKEN_URL: "https://oauth2.googleapis.com/token",
|
||||
Setting.DRIVE_HOST_NAME: "www.googleapis.com",
|
||||
Setting.SAVE_DRIVE_CREDS_PATH: "token",
|
||||
|
||||
# File locations used to store things
|
||||
Setting.FOLDER_FILE_PATH: "/data/folder.dat",
|
||||
Setting.CREDENTIALS_FILE_PATH: "/data/credentials.dat",
|
||||
Setting.BACKUP_DIRECTORY_PATH: "/backup",
|
||||
Setting.RETAINED_FILE_PATH: "/data/retained.json",
|
||||
Setting.SECRETS_FILE_PATH: "/config/secrets.yaml",
|
||||
Setting.INGRESS_TOKEN_FILE_PATH: "/data/ingress.dat",
|
||||
Setting.CONFIG_FILE_PATH: "/data/options.json",
|
||||
Setting.ID_FILE_PATH: "/data/id.json",
|
||||
Setting.STOP_ADDON_STATE_PATH: '/data/stop_addon_state.json',
|
||||
Setting.DATA_CACHE_FILE_PATH: '/data/data_cache.json',
|
||||
|
||||
# Various timeouts and intervals
|
||||
Setting.BACKUP_STALE_SECONDS: 60 * 60 * 3,
|
||||
Setting.PENDING_BACKUP_TIMEOUT_SECONDS: 60 * 60 * 5,
|
||||
Setting.FAILED_BACKUP_TIMEOUT_SECONDS: 60 * 15,
|
||||
Setting.NEW_BACKUP_TIMEOUT_SECONDS: 5,
|
||||
Setting.MAX_SYNC_INTERVAL_SECONDS: 60 * 60 * 3, # 3 hours
|
||||
Setting.DEFAULT_SYNC_INTERVAL_VARIATION: 0.5, # intermittent checkup syncs happen between 1.5 and 3 hours since the last one, randomly
|
||||
Setting.DEFAULT_DRIVE_CLIENT_ID: "933944288016-n35gnn2juc76ub7u5326ls0iaq9dgjgu.apps.googleusercontent.com",
|
||||
Setting.DEFAULT_DRIVE_CLIENT_SECRET: "",
|
||||
Setting.DRIVE_PICKER_API_KEY: "",
|
||||
Setting.DEFAULT_CHUNK_SIZE: 1024 * 1024 * 5,
|
||||
Setting.DOWNLOAD_TIMEOUT_SECONDS: 60,
|
||||
Setting.DEBUGGER_PORT: None,
|
||||
Setting.SERVER_PROJECT_ID: "",
|
||||
Setting.LOG_LEVEL: 'DEBUG',
|
||||
Setting.CONSOLE_LOG_LEVEL: 'INFO',
|
||||
Setting.BACKUP_STARTUP_DELAY_MINUTES: 10,
|
||||
Setting.EXCHANGER_TIMEOUT_SECONDS: 10,
|
||||
Setting.HA_REPORTING_INTERVAL_SECONDS: 10,
|
||||
Setting.LONG_TERM_STALE_BACKUP_SECONDS: 60 * 60 * 24,
|
||||
Setting.PING_TIMEOUT: 5,
|
||||
Setting.CACHE_WARMUP_MAX_SECONDS: 15 * 60, # 30 minutes
|
||||
Setting.CACHE_WARMUP_ERROR_TIMEOUT_SECONDS: 24 * 60 * 60, # 1 day
|
||||
Setting.MAX_BACKOFF_SECONDS: 60 * 60 * 2, # 2 hours
|
||||
}
|
||||
|
||||
_STAGING_DEFAULTS = {
|
||||
Setting.AUTHORIZATION_HOST: "https://dev.habackup.io",
|
||||
Setting.TOKEN_SERVER_HOSTS: "https://token1.dev.habackup.io,https://dev.habackup.io",
|
||||
Setting.DEFAULT_DRIVE_CLIENT_ID: "795575624694-jcdhoh1jr1ngccfsbi2f44arr4jupl79.apps.googleusercontent.com",
|
||||
}
|
||||
|
||||
_CONFIG = {
|
||||
Setting.MAX_BACKUPS_IN_HA: "int(0,)?",
|
||||
Setting.MAX_BACKUPS_IN_GOOGLE_DRIVE: "int(0,)?",
|
||||
Setting.DAYS_BETWEEN_BACKUPS: "float(0,)?",
|
||||
Setting.IGNORE_OTHER_BACKUPS: "bool?",
|
||||
Setting.IGNORE_UPGRADE_BACKUPS: "bool?",
|
||||
Setting.DELETE_IGNORED_AFTER_DAYS: "float(0,)?",
|
||||
Setting.DELETE_BEFORE_NEW_BACKUP: "bool?",
|
||||
Setting.BACKUP_NAME: "str?",
|
||||
Setting.BACKUP_TIME_OF_DAY: "match(^[0-2]\\d:[0-5]\\d$)?",
|
||||
Setting.SPECIFY_BACKUP_FOLDER: "bool?",
|
||||
Setting.NOTIFY_FOR_STALE_BACKUPS: "bool?",
|
||||
Setting.ENABLE_BACKUP_STALE_SENSOR: "bool?",
|
||||
Setting.ENABLE_BACKUP_STATE_SENSOR: "bool?",
|
||||
Setting.BACKUP_PASSWORD: "str?",
|
||||
Setting.WATCH_BACKUP_DIRECTORY: "bool?",
|
||||
Setting.TRACE_REQUESTS: "bool?",
|
||||
|
||||
# Basic backup settings
|
||||
Setting.DEPRECTAED_MAX_BACKUPS_IN_HA: "int(0,)?",
|
||||
Setting.DEPRECTAED_MAX_BACKUPS_IN_GOOGLE_DRIVE: "int(0,)?",
|
||||
Setting.DEPRECATED_DAYS_BETWEEN_BACKUPS: "float(0,)?",
|
||||
Setting.DEPRECTAED_IGNORE_OTHER_BACKUPS: "bool?",
|
||||
Setting.DEPRECTAED_IGNORE_UPGRADE_BACKUPS: "bool?",
|
||||
Setting.DEPRECTAED_BACKUP_TIME_OF_DAY: "match(^[0-2]\\d:[0-5]\\d$)?",
|
||||
Setting.DEPRECTAED_BACKUP_NAME: "str?",
|
||||
Setting.DEPRECATED_BACKUP_PASSWORD: "str?",
|
||||
Setting.DEPRECTAED_SPECIFY_BACKUP_FOLDER: "bool?",
|
||||
Setting.WARN_FOR_LOW_SPACE: "bool?",
|
||||
Setting.LOW_SPACE_THRESHOLD: "int(0,)?",
|
||||
Setting.DELETE_AFTER_UPLOAD: "bool?",
|
||||
Setting.DEPRECTAED_DELETE_BEFORE_NEW_BACKUP: "bool?",
|
||||
Setting.CALL_BACKUP_SNAPSHOT: "bool?",
|
||||
|
||||
# Generational backup settings
|
||||
Setting.GENERATIONAL_DAYS: "int(0,)?",
|
||||
Setting.GENERATIONAL_WEEKS: "int(0,)?",
|
||||
Setting.GENERATIONAL_MONTHS: "int(0,)?",
|
||||
Setting.GENERATIONAL_YEARS: "int(0,)?",
|
||||
Setting.GENERATIONAL_DAY_OF_WEEK: "match(^(mon|tue|wed|thu|fri|sat|sun)$)?",
|
||||
Setting.GENERATIONAL_DAY_OF_MONTH: "int(1,31)?",
|
||||
Setting.GENERATIONAL_DAY_OF_YEAR: "int(1,365)?",
|
||||
Setting.GENERATIONAL_DELETE_EARLY: "bool?",
|
||||
|
||||
# Partial backup settings
|
||||
Setting.EXCLUDE_FOLDERS: "str?",
|
||||
Setting.EXCLUDE_ADDONS: "str?",
|
||||
|
||||
Setting.STOP_ADDONS: "str?",
|
||||
Setting.DISABLE_WATCHDOG_WHEN_STOPPING: "bool?",
|
||||
|
||||
# UI Server settings
|
||||
Setting.USE_SSL: "bool?",
|
||||
Setting.REQUIRE_LOGIN: "bool?",
|
||||
Setting.EXPOSE_EXTRA_SERVER: "bool?",
|
||||
Setting.CERTFILE: "str?",
|
||||
Setting.KEYFILE: "str?",
|
||||
Setting.INGRESS_PORT: "int(0,)?",
|
||||
Setting.PORT: "int(0,)?",
|
||||
|
||||
# Add-on options
|
||||
Setting.DEPRECTAED_NOTIFY_FOR_STALE_BACKUPS: "bool?",
|
||||
Setting.DEPRECTAED_ENABLE_BACKUP_STALE_SENSOR: "bool?",
|
||||
Setting.DEPRECTAED_ENABLE_BACKUP_STATE_SENSOR: "bool?",
|
||||
Setting.SEND_ERROR_REPORTS: "bool?",
|
||||
Setting.VERBOSE: "bool?",
|
||||
Setting.CONFIRM_MULTIPLE_DELETES: "bool?",
|
||||
Setting.ENABLE_DRIVE_UPLOAD: "bool?",
|
||||
|
||||
# Theme Settings
|
||||
Setting.BACKGROUND_COLOR: "match(^(#[0-9ABCDEFabcdef]{6}|)$)?",
|
||||
Setting.ACCENT_COLOR: "match(^(#[0-9ABCDEFabcdef]{6}|)$)?",
|
||||
|
||||
# Network and DNS settings
|
||||
Setting.ALTERNATE_DNS_SERVERS: "match(^([0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3})(,[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3})*$)?",
|
||||
Setting.DRIVE_EXPERIMENTAL: "bool?",
|
||||
Setting.DRIVE_IPV4: "match(^[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}$)?",
|
||||
Setting.IGNORE_IPV6_ADDRESSES: "bool?",
|
||||
Setting.GOOGLE_DRIVE_TIMEOUT_SECONDS: "float(1,)?",
|
||||
Setting.GOOGLE_DRIVE_PAGE_SIZE: "int(1,)?",
|
||||
Setting.MAXIMUM_UPLOAD_CHUNK_BYTES: f"float({1024 * 256},)?",
|
||||
|
||||
# Remote endpoints
|
||||
Setting.AUTHORIZATION_HOST: "url?",
|
||||
Setting.TOKEN_SERVER_HOSTS: "str?",
|
||||
Setting.SUPERVISOR_URL: "url?",
|
||||
Setting.SUPERVISOR_TOKEN: "str?",
|
||||
Setting.DRIVE_URL: "url?",
|
||||
Setting.DRIVE_REFRESH_URL: "url?",
|
||||
Setting.DRIVE_AUTHORIZE_URL: "url?",
|
||||
Setting.DRIVE_DEVICE_CODE_URL: "url?",
|
||||
Setting.DRIVE_TOKEN_URL: "url?",
|
||||
Setting.DRIVE_HOST_NAME: "str?",
|
||||
Setting.SAVE_DRIVE_CREDS_PATH: "str?",
|
||||
|
||||
# File locations used to store things
|
||||
Setting.FOLDER_FILE_PATH: "str?",
|
||||
Setting.CREDENTIALS_FILE_PATH: "str?",
|
||||
Setting.BACKUP_DIRECTORY_PATH: "str?",
|
||||
Setting.RETAINED_FILE_PATH: "str?",
|
||||
Setting.SECRETS_FILE_PATH: "str?",
|
||||
Setting.INGRESS_TOKEN_FILE_PATH: "str?",
|
||||
Setting.CONFIG_FILE_PATH: "str?",
|
||||
Setting.ID_FILE_PATH: "str?",
|
||||
Setting.STOP_ADDON_STATE_PATH: "str?",
|
||||
Setting.DATA_CACHE_FILE_PATH: "str?",
|
||||
|
||||
# Various timeouts and intervals
|
||||
Setting.BACKUP_STALE_SECONDS: "float(0,)?",
|
||||
Setting.PENDING_BACKUP_TIMEOUT_SECONDS: "float(0,)?",
|
||||
Setting.FAILED_BACKUP_TIMEOUT_SECONDS: "float(0,)?",
|
||||
Setting.NEW_BACKUP_TIMEOUT_SECONDS: "float(0,)?",
|
||||
Setting.MAX_SYNC_INTERVAL_SECONDS: "float(300,)?",
|
||||
Setting.DEFAULT_SYNC_INTERVAL_VARIATION: "float(0,1)?",
|
||||
Setting.DEFAULT_DRIVE_CLIENT_ID: "str?",
|
||||
Setting.DEFAULT_DRIVE_CLIENT_SECRET: "str?",
|
||||
Setting.DRIVE_PICKER_API_KEY: "str?",
|
||||
Setting.DEFAULT_CHUNK_SIZE: "int(1,)?",
|
||||
Setting.DOWNLOAD_TIMEOUT_SECONDS: "float(0,)?",
|
||||
Setting.DEBUGGER_PORT: "int(100,)?",
|
||||
Setting.SERVER_PROJECT_ID: "str?",
|
||||
Setting.LOG_LEVEL: "list(DEBUG|TRACE|INFO|WARN|CRITICAL|WARNING)?",
|
||||
Setting.CONSOLE_LOG_LEVEL: "list(DEBUG|TRACE|INFO|WARN|CRITICAL|WARNING)?",
|
||||
Setting.BACKUP_STARTUP_DELAY_MINUTES: "float(0,)?",
|
||||
Setting.EXCHANGER_TIMEOUT_SECONDS: "float(0,)?",
|
||||
Setting.HA_REPORTING_INTERVAL_SECONDS: "int(1,)?",
|
||||
Setting.LONG_TERM_STALE_BACKUP_SECONDS: "int(1,)?",
|
||||
Setting.PING_TIMEOUT: "float(0,)?",
|
||||
Setting.CACHE_WARMUP_MAX_SECONDS: "float(0,)",
|
||||
Setting.CACHE_WARMUP_ERROR_TIMEOUT_SECONDS: "float(0,)",
|
||||
Setting.MAX_BACKOFF_SECONDS: "int(3600,)?",
|
||||
}
|
||||
|
||||
PRIVATE = [
|
||||
Setting.DEPRECATED_BACKUP_PASSWORD,
|
||||
Setting.DEPRECTAED_BACKUP_NAME,
|
||||
Setting.BACKUP_PASSWORD,
|
||||
Setting.BACKUP_NAME
|
||||
]
|
||||
|
||||
_LOOKUP = {}
|
||||
_VALIDATORS = {}
|
||||
|
||||
|
||||
def getValidator(name, schema):
|
||||
if schema.endswith("?"):
|
||||
schema = schema[:-1]
|
||||
if schema.startswith("int("):
|
||||
# its a int
|
||||
parts = schema[4:-1]
|
||||
minimum = None
|
||||
maximum = None
|
||||
if parts.endswith(","):
|
||||
minimum = int(parts[0:-1])
|
||||
elif parts.startswith(","):
|
||||
maximum = int(parts[1:])
|
||||
else:
|
||||
digits = parts.split(",")
|
||||
minimum = int(digits[0])
|
||||
maximum = int(digits[1])
|
||||
return IntValidator(name, minimum, maximum)
|
||||
elif schema.startswith("float("):
|
||||
# its a float
|
||||
parts = schema[6:-1]
|
||||
minimum = None
|
||||
maximum = None
|
||||
if parts.endswith(","):
|
||||
minimum = float(parts[0:-1])
|
||||
elif parts.startswith(","):
|
||||
maximum = float(parts[1:])
|
||||
else:
|
||||
digits = parts.split(",")
|
||||
minimum = float(digits[0])
|
||||
maximum = float(digits[1])
|
||||
return FloatValidator(name, minimum, maximum)
|
||||
elif schema.startswith("bool"):
|
||||
# its a bool
|
||||
return BoolValidator(name)
|
||||
elif schema.startswith("str") or schema.startswith("url"):
|
||||
# its a url (treat it just like any string)
|
||||
return StringValidator(name)
|
||||
elif schema.startswith("match("):
|
||||
return RegexValidator(name, schema[6:-1])
|
||||
elif schema.startswith("list("):
|
||||
return ListValidator(name, schema[5:-1].split("|"))
|
||||
else:
|
||||
raise Exception("Invalid schema: " + schema)
|
||||
|
||||
|
||||
# initalize validators
|
||||
for setting in Setting:
|
||||
_LOOKUP[setting.value] = setting
|
||||
|
||||
with open(abspath(join(__file__, "..", "..", "..", "config.json"))) as f:
|
||||
# Thsi is a static file included in the container, so don't worry about using JsonFileLoader
|
||||
addon_config = json.load(f)
|
||||
|
||||
for setting in Setting:
|
||||
_VALIDATORS[setting] = getValidator(setting.value, _CONFIG[setting])
|
||||
for key in addon_config["schema"]:
|
||||
_VALIDATORS[_LOOKUP[key]] = getValidator(key, addon_config["schema"][key])
|
||||
|
||||
_VALIDATORS[Setting.MAX_SYNC_INTERVAL_SECONDS] = DurationAsStringValidator("max_sync_interval_seconds", minimum=1, maximum=None)
|
||||
_VALIDATORS[Setting.HA_REPORTING_INTERVAL_SECONDS] = DurationAsStringValidator("ha_reporting_interval_seconds", minimum=1, maximum=None)
|
||||
_VALIDATORS[Setting.DELETE_IGNORED_AFTER_DAYS] = DurationAsStringValidator("delete_ignored_after_days", minimum=0, maximum=None, base_seconds=60 * 60 * 24, default_as_empty=0)
|
||||
_VALIDATORS[Setting.MAXIMUM_UPLOAD_CHUNK_BYTES] = BytesizeAsStringValidator("maximum_upload_chunk_bytes", minimum=256 * 1024)
|
||||
VERSION = addon_config["version"]
|
||||
|
||||
|
||||
def isStaging():
|
||||
return "staging" in VERSION
|
||||
@@ -0,0 +1,11 @@
|
||||
from ..logger import getLogger
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
|
||||
class Startable():
|
||||
async def start(self):
|
||||
pass
|
||||
|
||||
async def stop(self):
|
||||
pass
|
||||
@@ -0,0 +1,14 @@
|
||||
from .validator import Validator
|
||||
from ..logger import getLogger
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
|
||||
class StringValidator(Validator):
|
||||
def __init__(self, name):
|
||||
super().__init__(name)
|
||||
|
||||
def validate(self, value):
|
||||
if value is None or (type(value) == str and len(value) == 0):
|
||||
return ""
|
||||
return str(value)
|
||||
@@ -0,0 +1,21 @@
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
from ..exceptions import InvalidConfigurationValue
|
||||
from ..logger import getLogger
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
|
||||
class Validator(ABC):
|
||||
def __init__(self, name):
|
||||
self.name = name
|
||||
|
||||
@abstractmethod
|
||||
def validate(self, value):
|
||||
return True
|
||||
|
||||
def raiseForValue(self, value):
|
||||
raise InvalidConfigurationValue(self.name, str(value))
|
||||
|
||||
def formatForUi(self, value):
|
||||
return value
|
||||
@@ -0,0 +1,84 @@
|
||||
STAGING_KEY = ".staging."
|
||||
EXPECTED_VERISON_CHARS = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '.']
|
||||
|
||||
|
||||
class Version:
|
||||
def __init__(self, *args):
|
||||
self._identifiers = args
|
||||
self.staging = False
|
||||
|
||||
@classmethod
|
||||
def default(cls):
|
||||
return Version(0)
|
||||
|
||||
@classmethod
|
||||
def parse(cls, version: str):
|
||||
staging_version = None
|
||||
if STAGING_KEY in version:
|
||||
index = version.find(STAGING_KEY)
|
||||
staging_version = int(version[index + len(STAGING_KEY):])
|
||||
version = version[0:index]
|
||||
version = Version._removeUnexpected(version)
|
||||
parts = []
|
||||
for part in version.split("."):
|
||||
if len(part) > 0:
|
||||
parts.append(int(part))
|
||||
if staging_version is not None:
|
||||
parts.append(staging_version)
|
||||
if len(parts) == 0:
|
||||
parts.append(0)
|
||||
ret = Version(*parts)
|
||||
if staging_version is not None:
|
||||
ret.staging = True
|
||||
return ret
|
||||
|
||||
@classmethod
|
||||
def _removeUnexpected(cls, version: str):
|
||||
ret = ""
|
||||
for c in version:
|
||||
if c in EXPECTED_VERISON_CHARS:
|
||||
ret += c
|
||||
while ".." in ret:
|
||||
ret = ret.replace("..", ".")
|
||||
return ret
|
||||
|
||||
def __getitem__(self, key):
|
||||
return self._identifiers[key]
|
||||
|
||||
def length(self):
|
||||
return len(self._identifiers)
|
||||
|
||||
def _compare(self, other):
|
||||
i = 0
|
||||
while(i < min(self.length(), other.length())):
|
||||
if self[i] < other[i]:
|
||||
return -1
|
||||
if self[i] > other[i]:
|
||||
return 1
|
||||
i += 1
|
||||
if self.length() < other.length():
|
||||
return -1
|
||||
if self.length() > other.length():
|
||||
return 1
|
||||
return 0
|
||||
|
||||
def __lt__(self, other):
|
||||
return self._compare(other) < 0
|
||||
|
||||
def __le__(self, other):
|
||||
return self._compare(other) <= 0
|
||||
|
||||
def __eq__(self, other):
|
||||
return self._compare(other) == 0
|
||||
|
||||
def __ne__(self, other):
|
||||
return self._compare(other) != 0
|
||||
|
||||
def __gt__(self, other):
|
||||
return self._compare(other) > 0
|
||||
|
||||
def __ge__(self, other):
|
||||
return self._compare(other) >= 0
|
||||
|
||||
def __str__(self):
|
||||
return ".".join(str(i) for i in self._identifiers)
|
||||
@@ -0,0 +1,128 @@
|
||||
|
||||
SOURCE_GOOGLE_DRIVE = "GoogleDrive"
|
||||
SOURCE_HA = "HomeAssistant"
|
||||
|
||||
ERROR_PLEASE_WAIT = "please_wait"
|
||||
ERROR_NOT_UPLOADABLE = "not_uploadable"
|
||||
ERROR_NO_BACKUP = "invalid_slug"
|
||||
ERROR_CREDS_EXPIRED = "creds_bad"
|
||||
ERROR_UPLOAD_FAILED = "upload_failed"
|
||||
ERROR_BAD_PASSWORD_KEY = "password_key_invalid"
|
||||
ERROR_BACKUP_IN_PROGRESS = "backup_in_progress"
|
||||
ERROR_PROTOCOL = "protocol_error"
|
||||
ERROR_LOGIC = "logic_error"
|
||||
ERROR_INVALID_CONFIG = "illegal_config"
|
||||
ERROR_DRIVE_FULL = "drive_full"
|
||||
ERROR_GOOGLE_DNS = "google_dns"
|
||||
ERROR_GOOGLE_CONNECT = "google_cant_connect"
|
||||
ERROR_GOOGLE_INTERNAL = "google_server_error"
|
||||
ERROR_GOOGLE_SESSION = "google_session_expired"
|
||||
ERROR_GOOGLE_TIMEOUT = "google_timeout"
|
||||
ERROR_GOOGLE_UNEXPECTED = "google_unexpected"
|
||||
ERROR_HA_DELETE_ERROR = "delete_error"
|
||||
ERROR_MULTIPLE_DELETES = "multiple_deletes"
|
||||
ERROR_SUPERVISOR_UNEXPECTED = "supervisor_unexpected"
|
||||
ERROR_SUPERVISOR_TIMEOUT = "supervisor_timeout"
|
||||
ERROR_SUPERVISOR_FILE_SYSTEM = "supervisor_fs_error"
|
||||
ERROR_GOOGLE_CRED_PROCESS = "unable_to_make_creds"
|
||||
|
||||
ERROR_EXISTING_FOLDER = "existing_backup_folder"
|
||||
ERROR_BACKUP_FOLDER_MISSING = "backup_folder_missing"
|
||||
CHOOSE_BACKUP_FOLDER = "choose_backup_folder"
|
||||
ERROR_BACKUP_FOLDER_INACCESSIBLE = "backup_folder_inaccessible"
|
||||
ERROR_LOW_SPACE = "low_space"
|
||||
LOG_IN_TO_DRIVE = "log_in_to_drive"
|
||||
SUPERVISOR_PERMISSION = "supervisor_permission"
|
||||
|
||||
# these keys are necessary because they use the name "snapshot" in non-user-visible
|
||||
# places persisted outside the codebase. They can't be changed without an upgrade path.
|
||||
NECESSARY_OLD_BACKUP_NAME = "snapshot"
|
||||
NECESSARY_OLD_BACKUP_PLURAL_NAME = "snapshots"
|
||||
NECESSARY_OLD_SUPERVISOR_URL = "http://hassio"
|
||||
NECESSARY_PROP_KEY_SLUG = "snapshot_slug"
|
||||
NECESSARY_PROP_KEY_DATE = "snapshot_date"
|
||||
NECESSARY_PROP_KEY_NAME = "snapshot_name"
|
||||
PROP_NOTE = "note"
|
||||
|
||||
DRIVE_FOLDER_URL_FORMAT = "https://drive.google.com/drive/u/0/folders/{0}"
|
||||
GITHUB_ISSUE_URL = "https://github.com/sabeechen/hassio-google-drive-backup/issues/new?labels[]=People%20Management&labels[]=[Type]%20Bug&title={title}&assignee=sabeechen&body={body}"
|
||||
GITHUB_BUG_TEMPLATE = """
|
||||
###### Description:
|
||||
```
|
||||
If you have anything else that could help explain what happened, click "Markdown" above and write it here.
|
||||
```
|
||||
|
||||
Addon version: `{version}`
|
||||
Home Assistant Version: `{ha_version}`
|
||||
Supervisor Version: `{super_version}`
|
||||
Supervisor Channel: `{supervisor_channel}`
|
||||
Hassos Version: `{hassos_version}`
|
||||
Docker Version: `{docker_version}`
|
||||
Architecture: `{arch}`
|
||||
Machine: `{machine}`
|
||||
Date: `{time}`
|
||||
Timezone: `{timezone}`
|
||||
Failure Time: `{failure_time}`
|
||||
Last Good Sync: `{sync_last_start}`
|
||||
Next Sync: `{next_sync}`
|
||||
Next Backup: `{next_backup}`
|
||||
Next Cache Warm: `{next_cache_warm}`
|
||||
Time Offset: `{time_offset}`
|
||||
###### Exception:
|
||||
```
|
||||
{error}
|
||||
```
|
||||
Backups:
|
||||
```
|
||||
{backups}
|
||||
```
|
||||
###### Config:
|
||||
```
|
||||
{config}
|
||||
```
|
||||
###### Addon Logs:
|
||||
```
|
||||
{addon_logs}
|
||||
```
|
||||
###### Supervisor Logs:
|
||||
```
|
||||
{super_logs}
|
||||
```
|
||||
###### Home Assistant Core Logs:
|
||||
```
|
||||
{core_logs}
|
||||
```
|
||||
"""
|
||||
|
||||
FOLDERS = [
|
||||
{
|
||||
'slug': "homeassistant",
|
||||
'id': "folder_homeassistant",
|
||||
'name': "Home Assistant Configuration",
|
||||
'description': 'Backup the files and folders from your Home Assistant config directory, eg configuration.yaml'
|
||||
},
|
||||
{
|
||||
'slug': "media",
|
||||
'id': "folder_media",
|
||||
'name': "Media",
|
||||
'description': 'Backup your "/media" directory.'
|
||||
},
|
||||
{
|
||||
'slug': "ssl",
|
||||
'id': "folder_ssl",
|
||||
'name': "SSL",
|
||||
'description': 'Backup your "/ssl" directory, where your certfile and keyfile are typically stored.'
|
||||
},
|
||||
{
|
||||
'slug': "share",
|
||||
'id': "folder_share",
|
||||
'name': "Share",
|
||||
'description': 'Backup your "/share" directory.'
|
||||
},
|
||||
{
|
||||
'slug': "addons/local",
|
||||
'id': "folder_addons",
|
||||
'name': "Local Addons",
|
||||
'description': 'Backup your local addons directory. This directory will be empty unless you use it for add-on development.'
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,5 @@
|
||||
# flake8: noqa
|
||||
from .exchanger import Exchanger
|
||||
from .creds import Creds, KEY_TOKEN_EXPIRY, KEY_ACCESS_TOKEN, KEY_CLIENT_ID, KEY_CLIENT_SECRET
|
||||
from .driverequester import DriveRequester
|
||||
MANUAL_CODE_REDIRECT_URI: str = "urn:ietf:wg:oauth:2.0:oob"
|
||||
@@ -0,0 +1,90 @@
|
||||
from ..exceptions import ensureKey
|
||||
from ..time import Time
|
||||
from typing import Optional
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
KEY_REFRESH_TOKEN = 'refresh_token'
|
||||
KEY_CLIENT_ID = 'client_id'
|
||||
KEY_CLIENT_SECRET = 'client_secret'
|
||||
KEY_EXPIRES_IN = 'expires_in'
|
||||
KEY_TOKEN_EXPIRY = 'token_expiry'
|
||||
KEY_ACCESS_TOKEN = 'access_token'
|
||||
|
||||
|
||||
class Creds():
|
||||
def __init__(self, time: Time, id: str, expiration: datetime,
|
||||
access_token: str, refresh_token: str,
|
||||
secret: Optional[str] = None, original_expiration: datetime = None):
|
||||
self._id = id
|
||||
self.time: Time = time
|
||||
self._secret = secret
|
||||
self._access_token = access_token
|
||||
self._refresh_token = refresh_token
|
||||
self._expiration = expiration
|
||||
self._original_expiration = original_expiration
|
||||
|
||||
@property
|
||||
def id(self):
|
||||
return self._id
|
||||
|
||||
@property
|
||||
def secret(self):
|
||||
return self._secret
|
||||
|
||||
@property
|
||||
def refresh_token(self):
|
||||
return self._refresh_token
|
||||
|
||||
@property
|
||||
def access_token(self):
|
||||
return self._access_token
|
||||
|
||||
@property
|
||||
def expiration(self):
|
||||
if self._expiration is None:
|
||||
return self.time.now()
|
||||
return self._expiration
|
||||
|
||||
@property
|
||||
def original_expiration(self) -> datetime:
|
||||
return self._original_expiration
|
||||
|
||||
@property
|
||||
def is_expired(self):
|
||||
return self.time.now() >= self.expiration
|
||||
|
||||
def serialize(self, include_secret=True):
|
||||
ret = {
|
||||
"client_id": self.id
|
||||
}
|
||||
if self.secret is not None and include_secret:
|
||||
ret[KEY_CLIENT_SECRET] = self.secret
|
||||
if self.refresh_token is not None:
|
||||
ret[KEY_REFRESH_TOKEN] = self.refresh_token
|
||||
if self.access_token is not None:
|
||||
ret[KEY_ACCESS_TOKEN] = self.access_token
|
||||
if self.expiration is not None:
|
||||
ret[KEY_TOKEN_EXPIRY] = self.time.asRfc3339String(self.expiration)
|
||||
return ret
|
||||
|
||||
@classmethod
|
||||
def load(cls, time: Time, data, id=None, secret=None, original_expiration=None):
|
||||
if id is None:
|
||||
id = ensureKey(KEY_CLIENT_ID, data, "credentials")
|
||||
if secret is None and KEY_CLIENT_SECRET in data:
|
||||
secret = data[KEY_CLIENT_SECRET]
|
||||
refresh = ensureKey(KEY_REFRESH_TOKEN, data, "credentials")
|
||||
access = ensureKey(KEY_ACCESS_TOKEN, data, "credentials")
|
||||
expires = None
|
||||
try:
|
||||
if KEY_TOKEN_EXPIRY in data:
|
||||
expires = time.parse(data[KEY_TOKEN_EXPIRY])
|
||||
if original_expiration is None:
|
||||
original_expiration = expires
|
||||
elif KEY_EXPIRES_IN in data:
|
||||
expires = time.now() + timedelta(seconds=int(data[KEY_EXPIRES_IN]))
|
||||
else:
|
||||
expires = time.now()
|
||||
except BaseException:
|
||||
expires = time.now()
|
||||
return Creds(time=time, id=id, access_token=access, refresh_token=refresh, secret=secret, expiration=expires, original_expiration=original_expiration)
|
||||
@@ -0,0 +1,113 @@
|
||||
from aiohttp import ClientSession, ContentTypeError, ClientConnectorError, ClientTimeout, ClientResponse
|
||||
from aiohttp.client_exceptions import ServerTimeoutError, ServerDisconnectedError, ClientOSError
|
||||
from backup.exceptions import GoogleUnexpectedError, GoogleInternalError, GoogleRateLimitError, GoogleCredentialsExpired, CredRefreshGoogleError, DriveQuotaExceeded, GoogleDrivePermissionDenied, GoogleDnsFailure, GoogleCantConnect, GoogleTimeoutError
|
||||
from backup.util import Resolver
|
||||
from backup.logger import getLogger
|
||||
from backup.config import Config, Setting
|
||||
from injector import singleton, inject
|
||||
from dns.exception import DNSException
|
||||
|
||||
RATE_LIMIT_EXCEEDED = [403]
|
||||
TOO_MANY_REQUESTS = [429]
|
||||
INTERNAL_ERROR = [500, 503]
|
||||
PERMISSION_DENIED = [401]
|
||||
REQUEST_TIMEOUT = [408]
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
|
||||
@singleton
|
||||
class DriveRequester():
|
||||
@inject
|
||||
def __init__(self, config: Config, session: ClientSession, resolver: Resolver):
|
||||
self.session = session
|
||||
self.resolver = resolver
|
||||
self.config = config
|
||||
|
||||
async def request(self, method, url, headers={}, json=None, data=None) -> ClientResponse:
|
||||
try:
|
||||
response = await self.session.request(method, url, headers=headers, json=json, timeout=self.buildTimeout(), data=data)
|
||||
if response.status < 400:
|
||||
return response
|
||||
await self.raiseForKnownErrors(response)
|
||||
if response.status in PERMISSION_DENIED:
|
||||
response.release()
|
||||
raise GoogleCredentialsExpired()
|
||||
elif response.status in INTERNAL_ERROR:
|
||||
response.release()
|
||||
raise GoogleInternalError()
|
||||
elif response.status in RATE_LIMIT_EXCEEDED or response.status in TOO_MANY_REQUESTS:
|
||||
response.release()
|
||||
raise GoogleRateLimitError()
|
||||
elif response.status in REQUEST_TIMEOUT:
|
||||
response.release()
|
||||
raise GoogleTimeoutError()
|
||||
response.raise_for_status()
|
||||
return response
|
||||
except ClientConnectorError as e:
|
||||
logger.debug(
|
||||
"Ran into trouble reaching Google Drive's servers. We'll use alternate DNS servers on the next attempt.")
|
||||
self.resolver.toggle()
|
||||
if "Cannot connect to host" in str(e) or "Connection reset by peer" in str(e):
|
||||
raise GoogleCantConnect()
|
||||
if e.os_error.errno == -2:
|
||||
# -2 means dns lookup failed.
|
||||
raise GoogleDnsFailure()
|
||||
elif str(e.os_error) == "Domain name not found":
|
||||
raise GoogleDnsFailure()
|
||||
elif e.os_error.errno in [99, 111, 10061, 104]:
|
||||
# 111 means connection refused
|
||||
# Can't connect
|
||||
raise GoogleCantConnect()
|
||||
elif "Could not contact DNS serve" in str(e.os_error):
|
||||
# Wish there was a better way to identify this exception
|
||||
raise GoogleDnsFailure()
|
||||
raise
|
||||
except ClientOSError as e:
|
||||
if e.errno == 1:
|
||||
raise GoogleUnexpectedError()
|
||||
raise
|
||||
except ServerTimeoutError:
|
||||
raise GoogleTimeoutError()
|
||||
except ServerDisconnectedError:
|
||||
raise GoogleUnexpectedError()
|
||||
except DNSException:
|
||||
logger.debug(
|
||||
"Ran into trouble resolving Google Drive's servers. We'll use normal DNS servers on the next attempt.")
|
||||
self.resolver.toggle()
|
||||
raise GoogleDnsFailure()
|
||||
|
||||
def buildTimeout(self):
|
||||
return ClientTimeout(
|
||||
sock_connect=self.config.get(
|
||||
Setting.GOOGLE_DRIVE_TIMEOUT_SECONDS),
|
||||
sock_read=self.config.get(Setting.GOOGLE_DRIVE_TIMEOUT_SECONDS))
|
||||
|
||||
async def raiseForKnownErrors(self, response):
|
||||
try:
|
||||
message = await response.json()
|
||||
except ContentTypeError:
|
||||
return
|
||||
except ValueError:
|
||||
# parsing json failed, just give up
|
||||
return
|
||||
except TypeError:
|
||||
# Same
|
||||
return
|
||||
if "error" not in message:
|
||||
return
|
||||
error_obj = message["error"]
|
||||
if isinstance(error_obj, str):
|
||||
if error_obj == "expired":
|
||||
raise GoogleCredentialsExpired()
|
||||
else:
|
||||
raise CredRefreshGoogleError(error_obj)
|
||||
if "errors" not in error_obj:
|
||||
return
|
||||
for error in error_obj["errors"]:
|
||||
if "reason" not in error:
|
||||
continue
|
||||
if error["reason"] == "storageQuotaExceeded":
|
||||
raise DriveQuotaExceeded()
|
||||
elif error["reason"] in ["forbidden", "insufficientFilePermissions"]:
|
||||
raise GoogleDrivePermissionDenied()
|
||||
@@ -0,0 +1,160 @@
|
||||
import asyncio
|
||||
from aiohttp import ClientSession, ClientConnectorError, ClientTimeout
|
||||
from .creds import Creds, KEY_CLIENT_ID, KEY_CLIENT_SECRET, KEY_ACCESS_TOKEN, KEY_REFRESH_TOKEN, KEY_EXPIRES_IN
|
||||
from ..exceptions import ensureKey, GoogleCredentialsExpired, CredRefreshGoogleError, CredRefreshMyError
|
||||
from ..config import Config, Setting, VERSION
|
||||
from yarl import URL
|
||||
from ..time import Time
|
||||
from ..logger import getLogger
|
||||
from .driverequester import DriveRequester
|
||||
from datetime import timedelta
|
||||
from injector import singleton, inject
|
||||
|
||||
|
||||
SCOPE = 'https://www.googleapis.com/auth/drive.file'
|
||||
|
||||
KEY_REDIRECT_URI = 'redirect_uri'
|
||||
KEY_SCOPE = 'scope'
|
||||
KEY_RESPONSE_TYPE = 'response_type'
|
||||
KEY_INCLUDE_GRANTED_SCOPES = 'include_granted_scopes'
|
||||
KEY_ACCESS_TYPE = 'access_type'
|
||||
KEY_STATE = 'state'
|
||||
KEY_PROMPT = 'prompt'
|
||||
KEY_CODE = 'code'
|
||||
KEY_GRANT_TYPE = 'grant_type'
|
||||
KEY_VERSION = 'version'
|
||||
KEY_CLIENT = 'client'
|
||||
|
||||
CRED_OBJECT_NAME = "credential token response"
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
|
||||
@singleton
|
||||
class Exchanger():
|
||||
@inject
|
||||
def __init__(self,
|
||||
time: Time,
|
||||
session: ClientSession,
|
||||
config: Config,
|
||||
drive: DriveRequester,
|
||||
client_id: str,
|
||||
client_secret: str,
|
||||
redirect: URL):
|
||||
self.time = time
|
||||
self.config = config
|
||||
self.session = session
|
||||
self.drive = drive
|
||||
self._client_id = client_id
|
||||
self._client_secret = client_secret
|
||||
self._redirect = redirect
|
||||
|
||||
async def getAuthorizationUrl(self, state="") -> str:
|
||||
url = URL(self.config.get(Setting.DRIVE_AUTHORIZE_URL)).with_query({
|
||||
KEY_CLIENT_ID: self._client_id,
|
||||
KEY_SCOPE: SCOPE,
|
||||
KEY_RESPONSE_TYPE: 'code',
|
||||
KEY_INCLUDE_GRANTED_SCOPES: 'true',
|
||||
KEY_ACCESS_TYPE: "offline",
|
||||
KEY_STATE: state,
|
||||
KEY_REDIRECT_URI: str(self._redirect),
|
||||
KEY_PROMPT: "consent"
|
||||
})
|
||||
return str(url)
|
||||
|
||||
async def exchange(self, code):
|
||||
data = {
|
||||
KEY_CLIENT_ID: self._client_id,
|
||||
KEY_CLIENT_SECRET: self._client_secret,
|
||||
KEY_CODE: code,
|
||||
KEY_REDIRECT_URI: str(self._redirect),
|
||||
KEY_GRANT_TYPE: 'authorization_code'
|
||||
}
|
||||
resp = None
|
||||
async with await self.drive.request("post", self.config.get(Setting.DRIVE_TOKEN_URL), data=data) as resp:
|
||||
return Creds.load(self.time, await resp.json(), id=self._client_id, secret=self._client_secret)
|
||||
|
||||
async def refresh(self, creds: Creds):
|
||||
if creds.secret is not None:
|
||||
return await self._refresh_google(creds)
|
||||
else:
|
||||
return await self._refresh_default(creds)
|
||||
|
||||
async def _refresh_google(self, creds: Creds):
|
||||
data = {
|
||||
KEY_CLIENT_ID: creds.id,
|
||||
KEY_CLIENT_SECRET: creds.secret,
|
||||
KEY_REFRESH_TOKEN: creds.refresh_token,
|
||||
KEY_GRANT_TYPE: 'refresh_token'
|
||||
}
|
||||
async with await self.drive.request("post", self.config.get(Setting.DRIVE_REFRESH_URL), data=data) as resp:
|
||||
data = await resp.json()
|
||||
return Creds(
|
||||
self.time,
|
||||
id=creds.id,
|
||||
secret=creds.secret,
|
||||
access_token=ensureKey(KEY_ACCESS_TOKEN, data, CRED_OBJECT_NAME),
|
||||
refresh_token=creds.refresh_token,
|
||||
expiration=self._get_expiration(data),
|
||||
original_expiration=creds.original_expiration)
|
||||
|
||||
async def _refresh_default(self, creds: Creds):
|
||||
data = {
|
||||
KEY_CLIENT_ID: creds.id,
|
||||
KEY_REFRESH_TOKEN: creds.refresh_token,
|
||||
}
|
||||
token_paths = self.config.getTokenServers("/drive/refresh")
|
||||
last_error = None
|
||||
for url in token_paths:
|
||||
try:
|
||||
headers = {
|
||||
'addon_version': VERSION,
|
||||
'client': self.config.clientIdentifier()
|
||||
}
|
||||
async with self.session.post(str(url), headers=headers, json=data, timeout=ClientTimeout(total=self.config.get(Setting.EXCHANGER_TIMEOUT_SECONDS))) as resp:
|
||||
if resp.status < 400:
|
||||
return Creds.load(self.time, await resp.json(), original_expiration=creds.original_expiration)
|
||||
elif resp.status == 503:
|
||||
json = {}
|
||||
try:
|
||||
json = await resp.json()
|
||||
except BaseException:
|
||||
pass
|
||||
if "error" in json:
|
||||
if "invalid_grant" in json["error"]:
|
||||
raise GoogleCredentialsExpired()
|
||||
else:
|
||||
# Record the error, but still try other hosts
|
||||
last_error = CredRefreshGoogleError(json["error"])
|
||||
else:
|
||||
last_error = CredRefreshMyError("HTTP 503 from " + url.host)
|
||||
elif resp.status == 401:
|
||||
raise GoogleCredentialsExpired()
|
||||
else:
|
||||
try:
|
||||
extra = (await resp.json())["error"]
|
||||
except BaseException:
|
||||
extra = ""
|
||||
|
||||
# this is likely due to misconfiguration
|
||||
logger.warning("Got {0}:{1} from {2}, trying alternate server(s)...".format(resp.status, extra, url.host))
|
||||
last_error = CredRefreshMyError("HTTP {} {}".format(resp.status, extra))
|
||||
except ClientConnectorError:
|
||||
logger.warning("Unable to reach " + str(url.host) + ", trying alternate server(s)...")
|
||||
last_error = "Couldn't communicate with " + url.host
|
||||
except asyncio.exceptions.TimeoutError:
|
||||
logger.warning("Timed out communicating with " + str(url.host) + ", trying alternate server(s)...")
|
||||
last_error = "Timed out communicating with " + url.host
|
||||
logger.error("Unable to refresh credentials with Google Drive")
|
||||
if isinstance(last_error, str):
|
||||
raise CredRefreshMyError(last_error)
|
||||
elif isinstance(last_error, Exception):
|
||||
raise last_error
|
||||
else:
|
||||
raise Exception("Unexpected error type: " + str(last_error))
|
||||
|
||||
def refreshCredentials(self, refresh_token):
|
||||
return Creds(self.time, id=self._client_id, expiration=None, access_token=None, refresh_token=refresh_token, secret=self._client_secret)
|
||||
|
||||
def _get_expiration(self, data):
|
||||
return self.time.now() + timedelta(seconds=int(ensureKey(KEY_EXPIRES_IN, data, CRED_OBJECT_NAME)))
|
||||
@@ -0,0 +1,2 @@
|
||||
# flake8: noqa
|
||||
from .debug_server import DebugServer
|
||||
@@ -0,0 +1,18 @@
|
||||
from backup.config import Config, Setting, Startable
|
||||
from backup.logger import getLogger
|
||||
from injector import inject, singleton
|
||||
logger = getLogger(__name__)
|
||||
|
||||
|
||||
@singleton
|
||||
class DebugServer(Startable):
|
||||
@inject
|
||||
def __init__(self, config: Config):
|
||||
self._config = config
|
||||
|
||||
async def start(self):
|
||||
if self._config.get(Setting.DEBUGGER_PORT) is not None:
|
||||
import debugpy
|
||||
port = self._config.get(Setting.DEBUGGER_PORT)
|
||||
logger.info("Starting debugger on port {}".format(port))
|
||||
debugpy.listen(("0.0.0.0", port))
|
||||
@@ -0,0 +1,216 @@
|
||||
import asyncio
|
||||
import socket
|
||||
import aioping
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from aiohttp import ClientSession, ClientTimeout
|
||||
from injector import inject, singleton
|
||||
|
||||
from backup.config import Config, Setting, VERSION, _DEFAULTS, PRIVATE
|
||||
from backup.exceptions import KnownError
|
||||
from backup.util import GlobalInfo, Resolver
|
||||
from backup.time import Time
|
||||
from backup.worker import Worker
|
||||
from backup.logger import getLogger, getHistory
|
||||
from backup.ha import HaRequests, HaSource
|
||||
from backup.model import Coordinator, DestinationPrecache
|
||||
from yarl import URL
|
||||
|
||||
logger = getLogger(__name__)
|
||||
ERROR_LOG_LENGTH = 30
|
||||
|
||||
|
||||
@singleton
|
||||
class DebugWorker(Worker):
|
||||
@inject
|
||||
def __init__(self, time: Time, info: GlobalInfo, config: Config, resolver: Resolver, session: ClientSession, ha: HaRequests, coord: Coordinator, ha_source: HaSource, precache: DestinationPrecache):
|
||||
super().__init__("Debug Worker", self.doWork, time, interval=10)
|
||||
self.time = time
|
||||
self._info = info
|
||||
self.config = config
|
||||
self.ha = ha
|
||||
self.ha_source = ha_source
|
||||
self.coord = coord
|
||||
|
||||
self.last_dns_update = None
|
||||
self.dns_info = None
|
||||
|
||||
self.last_sent_error = None
|
||||
self.last_sent_error_time = None
|
||||
self._health = None
|
||||
self.resolver = resolver
|
||||
self.session = session
|
||||
self._last_server_check = None
|
||||
self._last_server_refresh = timedelta(days=1)
|
||||
self._precache = precache
|
||||
|
||||
async def doWork(self):
|
||||
if not self.last_dns_update or self.time.now() > self.last_dns_update + timedelta(hours=12):
|
||||
await self.updateDns()
|
||||
if not self._last_server_check or self.time.now() > self._last_server_check + self._last_server_refresh:
|
||||
await self.updateHealthCheck()
|
||||
if self.config.get(Setting.SEND_ERROR_REPORTS):
|
||||
try:
|
||||
await self.maybeSendErrorReport()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Once per day, query the health endpoint of the token server to see who is up.
|
||||
# This checks for broadcast messages for all users and also finds which token
|
||||
# servers are available.
|
||||
async def updateHealthCheck(self):
|
||||
headers = {
|
||||
'client': self.config.clientIdentifier(),
|
||||
'addon_version': VERSION
|
||||
}
|
||||
self._last_server_check = self.time.now()
|
||||
for host in self.config.getTokenServers():
|
||||
url = host.with_path("/health")
|
||||
try:
|
||||
async with self.session.get(url, headers=headers, timeout=ClientTimeout(total=10)) as resp:
|
||||
resp.raise_for_status()
|
||||
self._health = await resp.json()
|
||||
self._last_server_refresh = timedelta(days=1)
|
||||
return
|
||||
except: # noqa: E722
|
||||
# ignore any error and just try a different endpoint
|
||||
pass
|
||||
|
||||
# no good token host could be found, so reset it to the default and check again sooner.
|
||||
self._last_server_refresh = timedelta(minutes=1)
|
||||
|
||||
async def maybeSendErrorReport(self):
|
||||
error = self._info._last_error
|
||||
if error is not None:
|
||||
if isinstance(error, KnownError):
|
||||
error = error.code()
|
||||
else:
|
||||
error = logger.formatException(error)
|
||||
if error != self.last_sent_error:
|
||||
self.last_sent_error = error
|
||||
if error is not None:
|
||||
self.last_sent_error_time = self.time.now()
|
||||
package = await self.buildErrorReport(error)
|
||||
else:
|
||||
package = self.buildClearReport()
|
||||
logger.info("Sending error report (see settings to disable)")
|
||||
headers = {
|
||||
'client': self.config.clientIdentifier(),
|
||||
'addon_version': VERSION
|
||||
}
|
||||
url = URL(self.config.get(Setting.AUTHORIZATION_HOST)).with_path("/logerror")
|
||||
async with self.session.post(url, headers=headers, json=package):
|
||||
pass
|
||||
|
||||
async def updateDns(self):
|
||||
self.last_dns_update = self.time.now()
|
||||
try:
|
||||
# Resolve google's addresses
|
||||
self.dns_info = await self.getPingInfo()
|
||||
self._info.setDnsInfo(self.dns_info)
|
||||
except Exception as e:
|
||||
self.dns_info = logger.formatException(e)
|
||||
|
||||
async def buildErrorReport(self, error):
|
||||
config_special = {}
|
||||
for setting in Setting:
|
||||
if self.config.get(setting) != _DEFAULTS[setting]:
|
||||
if setting in PRIVATE:
|
||||
config_special[str(setting)] = "REDACTED"
|
||||
else:
|
||||
config_special[str(setting)] = self.config.get(setting)
|
||||
report = {}
|
||||
report['config'] = config_special
|
||||
report['time'] = self.formatDate(self.time.now())
|
||||
report['start_time'] = self.formatDate(self._info._start_time)
|
||||
report['addon_version'] = VERSION
|
||||
report['failure_time'] = self.formatDate(self._info._last_failure_time)
|
||||
report['failure_count'] = self._info._failures
|
||||
report['sync_last_start'] = self.formatDate(self._info._last_sync_start)
|
||||
report['sync_count'] = self._info._syncs
|
||||
report['sync_success_count'] = self._info._successes
|
||||
report['sync_last_success'] = self.formatDate(self._info._last_sync_success)
|
||||
report['upload_count'] = self._info._uploads
|
||||
report['upload_last_size'] = self._info._last_upload_size
|
||||
report['upload_last_attempt'] = self.formatDate(self._info._last_upload)
|
||||
report['next_sync'] = self.formatDate(self.coord.nextSyncAttempt())
|
||||
report['next_backup'] = self.formatDate(self.coord.nextBackupTime())
|
||||
report['next_cache_warm'] = self.formatDate(self._precache.getNextWarmDate())
|
||||
report['time_offset'] = self._time.offset.total_seconds()
|
||||
|
||||
report['debug'] = self._info.debug
|
||||
report['version'] = VERSION
|
||||
report['error'] = error
|
||||
report['client'] = self.config.clientIdentifier()
|
||||
|
||||
if self.ha_source.isInitialized() and self.ha_source.host_info and self.ha_source.super_info and self.ha_source.ha_info:
|
||||
report["super_version"] = self.ha_source.host_info.get('supervisor', "None")
|
||||
report["hassos_version"] = self.ha_source.host_info.get('hassos', "None")
|
||||
report["docker_version"] = self.ha_source.host_info.get('docker', "None")
|
||||
report["machine"] = self.ha_source.host_info.get('machine', "None")
|
||||
report["supervisor_channel"] = self.ha_source.host_info.get('channel', "None")
|
||||
report["arch"] = self.ha_source.super_info.get('arch', "None")
|
||||
report["timezone"] = self.ha_source.super_info.get('timezone', "None")
|
||||
report["ha_version"] = self.ha_source.ha_info.get('version', "None")
|
||||
else:
|
||||
report["super_version"] = "Uninitialized"
|
||||
report["arch"] = "Uninitialized"
|
||||
report["timezone"] = "Uninitialized"
|
||||
report["ha_version"] = "Uninitialized"
|
||||
report["backups"] = self.coord.buildBackupMetrics()
|
||||
return report
|
||||
|
||||
async def buildBugReportData(self, error):
|
||||
report = await self.buildErrorReport(error)
|
||||
report['addon_logs'] = "\n".join(b for a, b in list(getHistory(0, False))[-ERROR_LOG_LENGTH:])
|
||||
try:
|
||||
report['super_logs'] = "\n".join((await self.ha.getSuperLogs()).split("\n")[-ERROR_LOG_LENGTH:])
|
||||
except Exception as e:
|
||||
report['super_logs'] = logger.formatException(e)
|
||||
try:
|
||||
report['core_logs'] = "\n".join((await self.ha.getCoreLogs()).split("\n")[-ERROR_LOG_LENGTH:])
|
||||
except Exception as e:
|
||||
report['core_logs'] = logger.formatException(e)
|
||||
return report
|
||||
|
||||
def buildClearReport(self):
|
||||
duration = self.time.now() - self.last_sent_error_time
|
||||
report = {
|
||||
'duration': str(duration)
|
||||
}
|
||||
return report
|
||||
|
||||
def formatDate(self, date: datetime):
|
||||
if date is None:
|
||||
return "Never"
|
||||
else:
|
||||
return date.isoformat()
|
||||
|
||||
async def getPingInfo(self):
|
||||
who = self.config.get(Setting.DRIVE_HOST_NAME)
|
||||
ips = await self.resolve(who)
|
||||
results = {who: {}}
|
||||
tasks = {who: {}}
|
||||
for ip in ips:
|
||||
results[who][ip] = "Unknown"
|
||||
tasks[who][ip] = asyncio.create_task(aioping.ping(ip, timeout=self.config.get(Setting.PING_TIMEOUT)))
|
||||
|
||||
# ping each server
|
||||
for server in tasks.keys():
|
||||
for ip in tasks[server].keys():
|
||||
try:
|
||||
time = await tasks[server][ip]
|
||||
results[server][ip] = f"{round(time * 1000, 0)} ms"
|
||||
except Exception as e:
|
||||
results[server][ip] = str(e)
|
||||
return results
|
||||
|
||||
async def resolve(self, who: str):
|
||||
try:
|
||||
ret = [who]
|
||||
addresses = await self.resolver.resolve(who, 443, socket.AF_INET)
|
||||
for address in addresses:
|
||||
ret.append(address['host'])
|
||||
return ret
|
||||
except Exception:
|
||||
return [who]
|
||||
@@ -0,0 +1,5 @@
|
||||
# flake8: noqa
|
||||
from .driverequests import DriveRequests, RETRY_SESSION_ATTEMPTS, UPLOAD_SESSION_EXPIRATION_DURATION, URL_START_UPLOAD, OOB_CRED_CUTOFF
|
||||
from .drivesource import DriveSource, SOURCE_GOOGLE_DRIVE
|
||||
from .folderfinder import FolderFinder
|
||||
from .authcodequery import AuthCodeQuery
|
||||
@@ -0,0 +1,107 @@
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from backup.config import Config, Setting
|
||||
from backup.time import Time
|
||||
from backup.exceptions import GoogleCredGenerateError, KnownError, LogicError, ensureKey
|
||||
from aiohttp import ClientSession
|
||||
from injector import inject
|
||||
from .driverequests import DriveRequester
|
||||
from backup.logger import getLogger
|
||||
from backup.creds import Creds
|
||||
import asyncio
|
||||
|
||||
logger = getLogger(__name__)
|
||||
SCOPE = 'https://www.googleapis.com/auth/drive.file'
|
||||
|
||||
|
||||
class AuthCodeQuery:
|
||||
@inject
|
||||
def __init__(self, config: Config, session: ClientSession, time: Time, drive: DriveRequester):
|
||||
self.session = session
|
||||
self.config = config
|
||||
self.drive = drive
|
||||
self.time = time
|
||||
self.client_id: str = None
|
||||
self.client_secret: str = None
|
||||
self.device_code: str = None
|
||||
self.verification_url: str = None
|
||||
self.user_code: str = None
|
||||
self.check_interval: timedelta = timedelta(seconds=5)
|
||||
self.expiration: datetime = time.now()
|
||||
self.last_check = time.now()
|
||||
|
||||
async def requestCredentials(self, client_id: str, client_secret: str):
|
||||
self.client_id = client_id
|
||||
self.client_secret = client_secret
|
||||
request_data = {
|
||||
'client_id': self.client_id,
|
||||
'scope': SCOPE
|
||||
}
|
||||
resp = await self.session.post(self.config.get(Setting.DRIVE_DEVICE_CODE_URL), data=request_data, timeout=30)
|
||||
if resp.status != 200:
|
||||
raise GoogleCredGenerateError(f"Google responded with error status HTTP {resp.status}. Please verify your credentials are set up correctly.")
|
||||
data = await resp.json()
|
||||
self.device_code = str(ensureKey("device_code", data, "Google's authorization request"))
|
||||
self.verification_url = str(ensureKey("verification_url", data, "Google's authorization request"))
|
||||
self.user_code = str(ensureKey("user_code", data, "Google's authorization request"))
|
||||
self.expiration = self.time.now() + timedelta(seconds=int(ensureKey("expires_in", data, "Google's authorization request")))
|
||||
self.check_interval = timedelta(seconds=int(ensureKey("interval", data, "Google's authorization request")))
|
||||
|
||||
async def waitForPermission(self) -> Creds:
|
||||
if not self.device_code:
|
||||
raise LogicError("Please call requestCredentials() first")
|
||||
error_count = 0
|
||||
data = {
|
||||
'client_id': self.client_id,
|
||||
'client_secret': self.client_secret,
|
||||
'device_code': self.device_code,
|
||||
'grant_type': 'urn:ietf:params:oauth:grant-type:device_code'
|
||||
}
|
||||
while self.expiration > self.time.now():
|
||||
start = self.time.now()
|
||||
resp = None
|
||||
try:
|
||||
resp = await self.session.post(self.config.get(Setting.DRIVE_TOKEN_URL), data=data, timeout=self.check_interval.total_seconds())
|
||||
try:
|
||||
reply = await resp.json()
|
||||
except Exception:
|
||||
reply = {}
|
||||
if resp.status == 403:
|
||||
if reply.get("error", "") == "slow_down":
|
||||
# google wants us to chill out, so do that
|
||||
await asyncio.sleep(self.check_interval.total_seconds())
|
||||
else:
|
||||
# Google says no
|
||||
logger.error(f"Getting credentials from Google failed with HTTP 403 and error: {reply.get('error', 'unspecified')}")
|
||||
raise GoogleCredGenerateError("Google refused the request to connect your account, either because you rejected it or they were set up incorrectly.")
|
||||
elif resp.status == 428:
|
||||
# Google says PEBKAC
|
||||
logger.info(f"Waiting for you to authenticate with Google at {self.verification_url}")
|
||||
elif resp.status / 100 != 2:
|
||||
# Mysterious error
|
||||
logger.error(f"Getting credentials from Google failed with HTTP {resp.status} and error: {reply.get('error', 'unspecified')}")
|
||||
raise GoogleCredGenerateError("Failed unexpectedly while trying to reach Google. See the add-on logs for details.")
|
||||
else:
|
||||
# got the token, return it
|
||||
return Creds.load(self.time, reply, id=self.client_id, secret=self.client_secret)
|
||||
except KnownError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Error while trying to retrieve credentials from Google")
|
||||
logger.printException(e)
|
||||
|
||||
# Allowing 10 errors is arbitrary, but prevents us from just erroring out forever in the background
|
||||
error_count += 1
|
||||
if error_count > 10:
|
||||
raise GoogleCredGenerateError("Failed unexpectedly too many times while attempting to reach Google. See the logs for details.")
|
||||
finally:
|
||||
if resp is not None:
|
||||
resp.release()
|
||||
|
||||
# Make sure we never query more than google says we should
|
||||
remainder = self.check_interval - (self.time.now() - start)
|
||||
if remainder > timedelta(seconds=0):
|
||||
await asyncio.sleep(remainder.total_seconds())
|
||||
|
||||
logger.error("Getting credentials from Google expired, please try again")
|
||||
raise GoogleCredGenerateError("Credentials expired while waiting for you to authorize with Google")
|
||||
@@ -0,0 +1,373 @@
|
||||
import io
|
||||
import math
|
||||
import re
|
||||
from typing import Any, Dict, Optional
|
||||
from urllib.parse import urlencode
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from aiohttp import ClientSession, ClientTimeout, ClientResponse
|
||||
from aiohttp.client_exceptions import ClientResponseError, ServerTimeoutError
|
||||
from injector import inject, singleton
|
||||
|
||||
from ..util import AsyncHttpGetter
|
||||
from ..config import Config, Setting
|
||||
from ..exceptions import (GoogleCredentialsExpired,
|
||||
GoogleSessionError, LogicError,
|
||||
ProtocolError, ensureKey, KnownTransient, GoogleTimeoutError, GoogleUnexpectedError)
|
||||
from backup.util import Backoff
|
||||
from backup.file import JsonFileSaver
|
||||
from ..time import Time
|
||||
from ..logger import getLogger
|
||||
from backup.creds import Creds, Exchanger, DriveRequester
|
||||
from datetime import timezone
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
MIME_TYPE = "application/tar"
|
||||
FOLDER_MIME_TYPE = 'application/vnd.google-apps.folder'
|
||||
FOLDER_NAME = 'Home Assistant Backups'
|
||||
DRIVE_VERSION = "v3"
|
||||
DRIVE_SERVICE = "drive"
|
||||
|
||||
SELECT_FIELDS = "id,name,appProperties,size,trashed,mimeType,modifiedTime,capabilities,parents,driveId"
|
||||
THUMBNAIL_MIME_TYPE = "image/png"
|
||||
QUERY_FIELDS = "nextPageToken,files(" + SELECT_FIELDS + ")"
|
||||
CREATE_FIELDS = SELECT_FIELDS
|
||||
URL_FILES = "/drive/v3/files/"
|
||||
URL_ABOUT = "/drive/v3/about"
|
||||
URL_START_UPLOAD = "/upload/drive/v3/files/?uploadType=resumable&supportsAllDrives=true"
|
||||
PAGE_SIZE = 100
|
||||
CHUNK_SIZE = 5 * 262144
|
||||
RANGE_RE = re.compile("^bytes=0-\\d+$")
|
||||
|
||||
BASE_CHUNK_SIZE = 256 * 1024 # Google's api requires uploading chunks in multiples of 256kb
|
||||
|
||||
# During upload, chunks get sized to complete upload after 10s so we can give status updates on progress.
|
||||
CHUNK_UPLOAD_TARGET_SECONDS = 10
|
||||
|
||||
# don't attempt to resume a session with than this many times consistant failures, just in case something is broken on Google's
|
||||
# end so we don't retry the same broken session forever. Because the addon eventually backs off to doing 1 attempt/hour, this will
|
||||
# cause uploads to fail and start over after about 4 days. This gets reset every time a chunk successfully uploads.
|
||||
# God be with you if your upload takes that long.
|
||||
RETRY_SESSION_ATTEMPTS = 100
|
||||
|
||||
# Google claims that an upload session becomes invalid after 7 days. I have not verified this, but probably better to call it
|
||||
# after 6 and restart the session.
|
||||
UPLOAD_SESSION_EXPIRATION_DURATION = timedelta(days=6)
|
||||
|
||||
|
||||
RATE_LIMIT_EXCEEDED = 403
|
||||
TOO_MANY_REQUESTS = 429
|
||||
|
||||
|
||||
# Defines the retry strategy for calls made to Drive
|
||||
# max # of time to retry and call to Drive
|
||||
DRIVE_MAX_RETRIES: int = 5
|
||||
# The initial backoff for drive retries.
|
||||
DRIVE_RETRY_INITIAL_SECONDS: int = 2
|
||||
# How uch longer to wait for each Drive service call (Exponential backoff)
|
||||
DRIVE_EXPONENTIAL_BACKOFF: int = 2
|
||||
|
||||
OOB_CRED_CUTOFF = datetime(2022, 3, 16, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
@singleton
|
||||
class DriveRequests():
|
||||
@inject
|
||||
def __init__(self, config: Config, time: Time, drive: DriveRequester, session: ClientSession, exchanger: Exchanger):
|
||||
self.session = session
|
||||
self.config = config
|
||||
self.time = time
|
||||
self.drive = drive
|
||||
self.creds: Optional[Creds] = None
|
||||
self.exchanger: Exchanger = exchanger
|
||||
|
||||
# Between attempts to upload, we keep track of the info needed to resume a resumable upload.
|
||||
self.last_attempt_metadata = None
|
||||
self.last_attempt_location = None
|
||||
self.last_attempt_count = 0
|
||||
self.last_attempt_start_time = None
|
||||
self.tryLoadCredentials()
|
||||
|
||||
async def _getHeaders(self):
|
||||
return {
|
||||
"Authorization": "Bearer " + await self.getToken(),
|
||||
"Client-Identifier": self.config.clientIdentifier()
|
||||
}
|
||||
|
||||
@property
|
||||
def might_be_oob_creds(self):
|
||||
"""Attempts to determine if the user might be using custom creds affected by google's OOB cred deprecation"""
|
||||
if not self.isCustomCreds():
|
||||
return False
|
||||
if self.creds.original_expiration is None:
|
||||
# These creds must be old, so assume they're affected
|
||||
return True
|
||||
try:
|
||||
return self.creds.original_expiration < OOB_CRED_CUTOFF
|
||||
except: # noqa: E722
|
||||
# Regardless of why this happens, assume they need to check
|
||||
return True
|
||||
|
||||
def isCustomCreds(self):
|
||||
return self.creds is not None and self.creds.id != self.config.get(Setting.DEFAULT_DRIVE_CLIENT_ID)
|
||||
|
||||
def _getAuthHeaders(self):
|
||||
return {
|
||||
"Client-Identifier": self.config.clientIdentifier()
|
||||
}
|
||||
|
||||
def enabled(self):
|
||||
return self.creds is not None and self.config.get(Setting.ENABLE_DRIVE_UPLOAD)
|
||||
|
||||
def _enabledCheck(self):
|
||||
if not self.enabled():
|
||||
raise LogicError(
|
||||
"Attempt to use Google Drive before credentials are configured")
|
||||
|
||||
def tryLoadCredentials(self):
|
||||
path = self.config.get(Setting.CREDENTIALS_FILE_PATH)
|
||||
if JsonFileSaver.exists(path):
|
||||
try:
|
||||
self.creds = Creds.load(self.time, JsonFileSaver.read(path))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def saveCredentials(self, creds: Creds):
|
||||
path = self.config.get(Setting.CREDENTIALS_FILE_PATH)
|
||||
if not creds:
|
||||
if JsonFileSaver.exists(path):
|
||||
JsonFileSaver.delete(path)
|
||||
self.creds = None
|
||||
return
|
||||
JsonFileSaver.write(path, creds.serialize())
|
||||
self.tryLoadCredentials()
|
||||
|
||||
async def getToken(self, refresh=False):
|
||||
if self.creds and not self.creds.is_expired and not refresh:
|
||||
return self.creds.access_token
|
||||
|
||||
# refresh the credentials
|
||||
logger.debug("Requesting refreshed Google Drive credentials")
|
||||
self.creds = await self.exchanger.refresh(self.creds)
|
||||
return self.creds.access_token
|
||||
|
||||
async def refreshToken(self):
|
||||
await self.getToken(refresh=True)
|
||||
|
||||
async def get(self, id):
|
||||
q = {
|
||||
"fields": SELECT_FIELDS,
|
||||
"supportsAllDrives": "true"
|
||||
}
|
||||
async with await self.retryRequest("GET", URL_FILES + id + "/?" + urlencode(q)) as response:
|
||||
return await response.json()
|
||||
|
||||
async def download(self, id, size):
|
||||
ret = AsyncHttpGetter(self.config.get(Setting.DRIVE_URL) + URL_FILES + id + "/?alt=media&supportsAllDrives=true",
|
||||
await self._getHeaders(),
|
||||
self.session,
|
||||
size=size,
|
||||
timeoutFactory=GoogleTimeoutError.factory,
|
||||
otherErrorFactory=GoogleUnexpectedError.factory,
|
||||
timeout=ClientTimeout(
|
||||
sock_connect=self.config.get(Setting.DOWNLOAD_TIMEOUT_SECONDS),
|
||||
sock_read=self.config.get(Setting.DOWNLOAD_TIMEOUT_SECONDS)),
|
||||
time=self.time)
|
||||
return ret
|
||||
|
||||
async def query(self, query):
|
||||
# SOMEDAY: Add a test for page size, test server support is needed too for continuation tokens
|
||||
continuation = None
|
||||
while True:
|
||||
q = {
|
||||
"q": query,
|
||||
"fields": QUERY_FIELDS,
|
||||
"pageSize": self.config.get(Setting.GOOGLE_DRIVE_PAGE_SIZE),
|
||||
"supportsAllDrives": "true",
|
||||
"includeItemsFromAllDrives": "true",
|
||||
"corpora": "allDrives"
|
||||
}
|
||||
if continuation:
|
||||
q["pageToken"] = continuation
|
||||
async with await self.retryRequest("GET", URL_FILES + "?" + urlencode(q)) as response:
|
||||
data = await response.json()
|
||||
for item in data['files']:
|
||||
yield item
|
||||
if "nextPageToken" not in data or len(data['nextPageToken']) <= 0:
|
||||
break
|
||||
else:
|
||||
continuation = data['nextPageToken']
|
||||
|
||||
async def update(self, id, update_metadata):
|
||||
async with await self.retryRequest("PATCH", URL_FILES + id + "/?supportsAllDrives=true", json=update_metadata):
|
||||
pass
|
||||
|
||||
async def delete(self, id):
|
||||
async with await self.retryRequest("DELETE", URL_FILES + id + "/?supportsAllDrives=true"):
|
||||
pass
|
||||
|
||||
async def getAboutInfo(self):
|
||||
q = {"fields": 'storageQuota,user'}
|
||||
async with await self.retryRequest("GET", URL_ABOUT + "?" + urlencode(q)) as resp:
|
||||
return await resp.json()
|
||||
|
||||
async def create(self, stream, metadata, mime_type):
|
||||
# Upload logic is complicated. See https://developers.google.com/drive/api/v3/manage-uploads#resumable
|
||||
total_size = stream.size()
|
||||
location = None
|
||||
if metadata == self.last_attempt_metadata and self.last_attempt_location is not None and self.last_attempt_count < RETRY_SESSION_ATTEMPTS and self.time.now() < self.last_attempt_start_time + UPLOAD_SESSION_EXPIRATION_DURATION:
|
||||
logger.debug(
|
||||
"Attempting to resume a previously failed upload where we left off")
|
||||
self.last_attempt_count += 1
|
||||
# Attempt to resume from a partially completed upload.
|
||||
headers = {
|
||||
"Content-Length": "0",
|
||||
"Content-Range": "bytes */{0}".format(total_size)
|
||||
}
|
||||
try:
|
||||
async with await self.retryRequest("PUT", self.last_attempt_location, headers=headers, patch_url=False) as initial:
|
||||
if initial.status == 308:
|
||||
# We can resume the upload, check where it left off
|
||||
if 'Range' in initial.headers:
|
||||
position = int(initial.headers["Range"][len("bytes=0-"):])
|
||||
stream.position(position + 1)
|
||||
else:
|
||||
# No range header in the response means no bytes have been uploaded yet.
|
||||
stream.position(0)
|
||||
logger.debug("Resuming upload at byte {0} of {1}".format(
|
||||
stream.position(), total_size))
|
||||
location = self.last_attempt_location
|
||||
else:
|
||||
logger.debug("Drive returned status code {0}, so we'll have to start the upload over again.".format(
|
||||
initial.status))
|
||||
except ClientResponseError as e:
|
||||
if e.status == 410:
|
||||
# Drive doesn't recognize the resume token, so we'll just have to start over.
|
||||
logger.debug("Drive upload session wasn't recognized, restarting upload from the beginning.")
|
||||
location = None
|
||||
else:
|
||||
raise
|
||||
|
||||
if location is None:
|
||||
# There is no session resume, so start a new one.
|
||||
logger.debug("Starting a new upload session with Google Drive")
|
||||
headers = {
|
||||
"X-Upload-Content-Type": mime_type,
|
||||
"X-Upload-Content-Length": str(total_size),
|
||||
}
|
||||
async with await self.retryRequest("POST", URL_START_UPLOAD, headers=headers, json=metadata) as initial:
|
||||
# Google returns a url in the header "Location", which is where subsequent requests to upload
|
||||
# the backup's bytes should be sent. Logic below handles uploading the file bytes in chunks.
|
||||
location = ensureKey(
|
||||
'Location', initial.headers, "Google Drive's Upload headers")
|
||||
self.last_attempt_count = 0
|
||||
stream.position(0)
|
||||
|
||||
# Keep track of the location in case the upload fails and we want to resume where we left off.
|
||||
# "metadata" is a durable fingerprint that uniquely identifies a backup, so we can use it to identify a
|
||||
# resumable partial upload in future retrys.
|
||||
self.last_attempt_location = location
|
||||
self.last_attempt_metadata = metadata
|
||||
self.last_attempt_start_time = self.time.now()
|
||||
|
||||
# Always start with the minimum chunk size and work up from there in case the last attempt
|
||||
# failed due to connectivity errors or ... whatever.
|
||||
current_chunk_size = BASE_CHUNK_SIZE
|
||||
while True:
|
||||
start = stream.position()
|
||||
data = await stream.read(current_chunk_size)
|
||||
chunk_size = len(data.getbuffer())
|
||||
if chunk_size == 0:
|
||||
raise LogicError(
|
||||
"Backup file stream ended prematurely while uploading to Google Drive")
|
||||
headers = {
|
||||
"Content-Length": str(chunk_size),
|
||||
"Content-Range": "bytes {0}-{1}/{2}".format(start, start + chunk_size - 1, total_size)
|
||||
}
|
||||
startTime = self.time.now()
|
||||
logger.debug("Sending {0} bytes to Google Drive".format(current_chunk_size))
|
||||
try:
|
||||
async with await self.retryRequest("PUT", location, headers=headers, data=data, patch_url=False) as partial:
|
||||
# Base the next chunk size on how long it took to send the last chunk.
|
||||
current_chunk_size = self._getNextChunkSize(
|
||||
current_chunk_size, (self.time.now() - startTime).total_seconds())
|
||||
|
||||
# any time a chunk gets uploaded, reset the retry counter. This lets very flaky connections
|
||||
# complete eventually after enough retrying.
|
||||
self.last_attempt_count = 1
|
||||
yield float(start + chunk_size) / float(total_size)
|
||||
if partial.status == 200 or partial.status == 201:
|
||||
# Upload completed, return the object json
|
||||
self.last_attempt_location = None
|
||||
self.last_attempt_metadata = None
|
||||
yield await self.get((await partial.json())['id'])
|
||||
break
|
||||
elif partial.status == 308:
|
||||
# Upload partially complete, seek to the new requested position
|
||||
range_bytes = ensureKey(
|
||||
"Range", partial.headers, "Google Drive's upload response headers")
|
||||
if not RANGE_RE.match(range_bytes):
|
||||
raise ProtocolError(
|
||||
"Range", partial.headers, "Google Drive's upload response headers")
|
||||
position = int(partial.headers["Range"][len("bytes=0-"):])
|
||||
stream.position(position + 1)
|
||||
else:
|
||||
partial.raise_for_status()
|
||||
except ClientResponseError as e:
|
||||
if math.floor(e.status / 100) == 4:
|
||||
# clear the cached session location URI, since a 4XX error
|
||||
# always means the upload session is no good anymore (AFAIK)
|
||||
self.last_attempt_location = None
|
||||
self.last_attempt_metadata = None
|
||||
|
||||
if e.status == 404:
|
||||
raise GoogleSessionError()
|
||||
else:
|
||||
raise e
|
||||
|
||||
def _getNextChunkSize(self, last_chunk_size, last_chunk_seconds):
|
||||
max = BASE_CHUNK_SIZE * math.floor(self.config.get(Setting.MAXIMUM_UPLOAD_CHUNK_BYTES) / BASE_CHUNK_SIZE)
|
||||
if max < BASE_CHUNK_SIZE:
|
||||
max = BASE_CHUNK_SIZE
|
||||
if last_chunk_seconds <= 0:
|
||||
return max
|
||||
next_chunk = CHUNK_UPLOAD_TARGET_SECONDS * last_chunk_size / last_chunk_seconds
|
||||
if next_chunk >= max:
|
||||
return max
|
||||
if next_chunk < BASE_CHUNK_SIZE:
|
||||
return BASE_CHUNK_SIZE
|
||||
return math.floor(next_chunk / BASE_CHUNK_SIZE) * BASE_CHUNK_SIZE
|
||||
|
||||
async def createFolder(self, metadata):
|
||||
async with await self.retryRequest("POST", URL_FILES + "?supportsAllDrives=true", json=metadata) as resp:
|
||||
return await resp.json()
|
||||
|
||||
async def retryRequest(self, method, url, auth_headers: Optional[Dict[str, str]] = None, headers: Optional[Dict[str, str]] = None, json: Optional[Dict[str, Any]] = None, data: Any = None, cred_retry: bool = True, patch_url: bool = True) -> ClientResponse:
|
||||
backoff = Backoff(base=DRIVE_RETRY_INITIAL_SECONDS, attempts=DRIVE_MAX_RETRIES)
|
||||
if patch_url:
|
||||
url = self.config.get(Setting.DRIVE_URL) + url
|
||||
while True:
|
||||
headers_to_use = await self._getHeaders()
|
||||
if headers:
|
||||
headers_to_use.update(headers)
|
||||
if self.config.get(Setting.TRACE_REQUESTS):
|
||||
logger.trace("Making Google Drive request: " + url)
|
||||
try:
|
||||
data_to_use = data
|
||||
if isinstance(data_to_use, io.BytesIO):
|
||||
# This is a pretty low-down dirty hack, but it works and lets us reuse the byte stream.
|
||||
# aiohttp complains if you pass it a large byte object
|
||||
data_to_use = io.BytesIO(data_to_use.getbuffer())
|
||||
data_to_use.seek(0)
|
||||
return await self.drive.request(method, url, headers=headers_to_use, json=json, data=data_to_use)
|
||||
except GoogleCredentialsExpired:
|
||||
# Get fresh credentials, then retry right away.
|
||||
logger.debug("Google Drive credentials have expired. We'll retry with new ones.")
|
||||
await self.refreshToken()
|
||||
except KnownTransient as e:
|
||||
backoff.backoff(e)
|
||||
logger.error("{0}: we'll retry in {1} seconds".format(e.message(), backoff.peek()))
|
||||
await self.time.sleepAsync(backoff.peek())
|
||||
except ServerTimeoutError:
|
||||
raise GoogleTimeoutError()
|
||||
@@ -0,0 +1,282 @@
|
||||
from datetime import datetime
|
||||
from io import IOBase
|
||||
from asyncio import Event
|
||||
from typing import Dict
|
||||
|
||||
from aiohttp import ClientSession
|
||||
from aiohttp.client_exceptions import ClientResponseError
|
||||
from injector import inject, singleton
|
||||
|
||||
from ..util import AsyncHttpGetter, GlobalInfo
|
||||
from ..config import Config, Setting, CreateOptions
|
||||
from ..const import SOURCE_GOOGLE_DRIVE
|
||||
from ..exceptions import (BackupFolderInaccessible,
|
||||
ExistingBackupFolderError,
|
||||
GoogleDrivePermissionDenied, LogicError)
|
||||
from ..model.backups import (PROP_NOTE, PROP_PROTECTED, PROP_RETAINED, PROP_TYPE, PROP_VERSION)
|
||||
from ..time import Time
|
||||
from .driverequests import DriveRequests
|
||||
from .folderfinder import FolderFinder
|
||||
from .thumbnail import THUMBNAIL_IMAGE
|
||||
from ..model import BackupDestination, DriveBackup, Backup
|
||||
from ..logger import getLogger
|
||||
from ..creds.creds import Creds
|
||||
from backup.const import NECESSARY_OLD_BACKUP_NAME, NECESSARY_OLD_BACKUP_PLURAL_NAME, NECESSARY_PROP_KEY_SLUG, NECESSARY_PROP_KEY_DATE, NECESSARY_PROP_KEY_NAME
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
MIME_TYPE = "application/tar"
|
||||
THUMBNAIL_MIME_TYPE = "image/png"
|
||||
FOLDER_MIME_TYPE = 'application/vnd.google-apps.folder'
|
||||
FOLDER_NAME = 'Home Assistant Backups'
|
||||
FOLDER_CACHE_SECONDS = 30
|
||||
DRIVE_MAX_PROPERTY_LENGTH = 120
|
||||
|
||||
|
||||
@singleton
|
||||
class DriveSource(BackupDestination):
|
||||
# SOMEDAY: read backups all in one big batch request, then sort the folder and child addons from that. Would need to add test verifying the "current" backup directory is used instead of the "latest"
|
||||
@inject
|
||||
def __init__(self, config: Config, time: Time, drive_requests: DriveRequests, info: GlobalInfo, session: ClientSession, folderfinder: FolderFinder):
|
||||
super().__init__()
|
||||
self.session = session
|
||||
self.config = config
|
||||
self.drivebackend: DriveRequests = drive_requests
|
||||
self.time = time
|
||||
self.folder_finder = folderfinder
|
||||
self._info = info
|
||||
self._uploadedAtLeastOneChunk = False
|
||||
self._drive_info = None
|
||||
self._cred_trigger = Event()
|
||||
|
||||
def saveCreds(self, creds: Creds) -> None:
|
||||
logger.info("Saving new Google Drive credentials")
|
||||
self.drivebackend.saveCredentials(creds)
|
||||
self.trigger()
|
||||
self._cred_trigger.set()
|
||||
|
||||
async def debug_wait_for_credentials(self):
|
||||
await self._cred_trigger.wait()
|
||||
self._cred_trigger.clear()
|
||||
|
||||
def isCustomCreds(self):
|
||||
return self.drivebackend.isCustomCreds()
|
||||
|
||||
@property
|
||||
def might_be_oob_creds(self) -> bool:
|
||||
return self.drivebackend.might_be_oob_creds
|
||||
|
||||
def name(self) -> str:
|
||||
return SOURCE_GOOGLE_DRIVE
|
||||
|
||||
def title(self) -> str:
|
||||
return "Google Drive"
|
||||
|
||||
def maxCount(self) -> None:
|
||||
return self.config.get(Setting.MAX_BACKUPS_IN_GOOGLE_DRIVE)
|
||||
|
||||
def upload(self) -> bool:
|
||||
return self.config.get(Setting.ENABLE_DRIVE_UPLOAD)
|
||||
|
||||
def enabled(self) -> bool:
|
||||
return self.drivebackend.enabled()
|
||||
|
||||
def needsConfiguration(self) -> bool:
|
||||
if not self.config.get(Setting.ENABLE_DRIVE_UPLOAD):
|
||||
return False
|
||||
return super().needsConfiguration()
|
||||
|
||||
def freeSpace(self):
|
||||
if self._drive_info and self._drive_info.get("storageQuota") is not None and not self.folder_finder.currentIsSharedDrive():
|
||||
info = self._drive_info.get("storageQuota")
|
||||
if 'limit' in info and 'usage' in info:
|
||||
return int(info.get("limit")) - int((info.get("usage")))
|
||||
return super().freeSpace()
|
||||
|
||||
async def create(self, options: CreateOptions) -> DriveBackup:
|
||||
raise LogicError("Backups can't be created in Drive")
|
||||
|
||||
def checkBeforeChanges(self):
|
||||
existing = self.folder_finder.getExisting()
|
||||
if existing:
|
||||
raise ExistingBackupFolderError(
|
||||
existing.get('id'), existing.get('name'))
|
||||
|
||||
def icon(self) -> str:
|
||||
return "google-drive"
|
||||
|
||||
def isWorking(self):
|
||||
return self._uploadedAtLeastOneChunk
|
||||
|
||||
def detail(self):
|
||||
if self._drive_info and 'user' in self._drive_info and 'emailAddress' in self._drive_info['user']:
|
||||
return f'{self._drive_info["user"]["emailAddress"]}'
|
||||
else:
|
||||
return super().detail()
|
||||
|
||||
async def get(self, allow_retry=True) -> Dict[str, DriveBackup]:
|
||||
parent = await self.getFolderId()
|
||||
try:
|
||||
self._drive_info = await self.drivebackend.getAboutInfo()
|
||||
except Exception as e:
|
||||
# This is just used to get the remaining space in Drive, which is a
|
||||
# nice to have. Just log the error to debug if we can't get it
|
||||
logger.debug("Unable to retrieve Google Drive storage info: " + str(e))
|
||||
backups: Dict[str, DriveBackup] = {}
|
||||
try:
|
||||
async for child in self.drivebackend.query("'{}' in parents".format(parent)):
|
||||
properties = child.get('appProperties')
|
||||
if properties and NECESSARY_PROP_KEY_DATE in properties and NECESSARY_PROP_KEY_SLUG in properties and not child['trashed']:
|
||||
backup = DriveBackup(child)
|
||||
backups[backup.slug()] = backup
|
||||
except ClientResponseError as e:
|
||||
if e.status == 404:
|
||||
# IIUC, 404 on create can only mean that the parent id isn't valid anymore.
|
||||
if not self.config.get(Setting.SPECIFY_BACKUP_FOLDER) and allow_retry:
|
||||
self.folder_finder.deCache()
|
||||
await self.folder_finder.create()
|
||||
return await self.get(False)
|
||||
raise BackupFolderInaccessible(parent)
|
||||
raise e
|
||||
except GoogleDrivePermissionDenied:
|
||||
# This should always mean we lost permission on the backup folder, but at least it still exists.
|
||||
if not self.config.get(Setting.SPECIFY_BACKUP_FOLDER) and allow_retry:
|
||||
self.folder_finder.deCache()
|
||||
await self.folder_finder.create()
|
||||
return await self.get(False)
|
||||
raise BackupFolderInaccessible(parent)
|
||||
return backups
|
||||
|
||||
async def delete(self, backup: Backup):
|
||||
item = self._validateBackup(backup)
|
||||
if item.canDeleteDirectly():
|
||||
logger.info("Deleting '{}' From Google Drive".format(item.name()))
|
||||
await self.drivebackend.delete(item.id())
|
||||
else:
|
||||
logger.info("Trashing '{}' in Google Drive".format(item.name()))
|
||||
await self.drivebackend.update(item.id(), {"trashed": True})
|
||||
backup.removeSource(self.name())
|
||||
|
||||
async def save(self, backup: Backup, source: AsyncHttpGetter) -> DriveBackup:
|
||||
retain = backup.getOptions() and backup.getOptions().retain_sources.get(self.name(), False)
|
||||
parent_id = await self.getFolderId()
|
||||
if backup.note() is not None:
|
||||
desc = backup.note()
|
||||
else:
|
||||
desc = 'A Home Assistant backup file uploaded by Home Assistant Google Drive Backup'
|
||||
file_metadata = {
|
||||
'name': str(backup.name()) + ".tar",
|
||||
'parents': [parent_id],
|
||||
'description': desc,
|
||||
'appProperties': {
|
||||
NECESSARY_PROP_KEY_SLUG: backup.slug(),
|
||||
NECESSARY_PROP_KEY_DATE: str(backup.date()),
|
||||
PROP_TYPE: str(backup.backupType()),
|
||||
PROP_VERSION: str(backup.version()),
|
||||
PROP_PROTECTED: str(backup.protected()),
|
||||
PROP_RETAINED: str(retain),
|
||||
},
|
||||
'contentHints': {
|
||||
'indexableText': 'Home Assistant hassio ' + NECESSARY_OLD_BACKUP_NAME + ' ' + NECESSARY_OLD_BACKUP_PLURAL_NAME + ' backup backups home assistant ' + desc,
|
||||
'thumbnail': {
|
||||
'image': THUMBNAIL_IMAGE,
|
||||
'mimeType': THUMBNAIL_MIME_TYPE
|
||||
}
|
||||
},
|
||||
'createdTime': self._timeToRfc3339String(backup.date()),
|
||||
'modifiedTime': self._timeToRfc3339String(backup.date())
|
||||
}
|
||||
|
||||
if backup.note() is not None:
|
||||
file_metadata['appProperties'][PROP_NOTE] = self.truncateAppProperty(PROP_NOTE, backup.note())
|
||||
file_metadata['appProperties'][NECESSARY_PROP_KEY_NAME] = self.truncateAppProperty(NECESSARY_PROP_KEY_NAME, str(backup.name()))
|
||||
|
||||
async with source:
|
||||
try:
|
||||
logger.info("Uploading '{}' to Google Drive".format(
|
||||
backup.name()))
|
||||
size = source.size()
|
||||
self._info.upload(size)
|
||||
backup.overrideStatus("Uploading {0}%", source)
|
||||
backup.setUploadSource(self.title(), source)
|
||||
async for progress in self.drivebackend.create(source, file_metadata, MIME_TYPE):
|
||||
self._uploadedAtLeastOneChunk = True
|
||||
if isinstance(progress, float):
|
||||
logger.debug("Uploading {1} {0:.2f}%".format(
|
||||
progress * 100, backup.name()))
|
||||
else:
|
||||
return DriveBackup(progress)
|
||||
raise LogicError(
|
||||
"Google Drive backup upload didn't return a completed item before exiting")
|
||||
except ClientResponseError as e:
|
||||
if e.status == 404:
|
||||
# IIUC, 404 on create can only mean that the parent id isn't valid anymore.
|
||||
raise BackupFolderInaccessible(parent_id)
|
||||
raise e
|
||||
except GoogleDrivePermissionDenied:
|
||||
# This should always mean we lost permission on the backup folder, since we could have only just
|
||||
# created the backup item on this request.
|
||||
raise BackupFolderInaccessible(parent_id)
|
||||
finally:
|
||||
backup.clearUploadSource()
|
||||
self._uploadedAtLeastOneChunk = False
|
||||
backup.clearStatus()
|
||||
|
||||
def truncateAppProperty(self, key: str, value: str):
|
||||
# Annoylingly, Drive properties can be a maximum of 124 bytes, in len(key + value) UTF8 encoded.
|
||||
# https://developers.google.com/drive/api/guides/properties
|
||||
# Is the extra indexing REALLY that expensive? Thats like some 1990's mainframe limitation.
|
||||
# Make sure we stay well under that limit
|
||||
if value is None:
|
||||
return value
|
||||
permitted = ""
|
||||
current = 0
|
||||
while current < len(value) and len(str(key + permitted + value[current]).encode('utf-8')) < DRIVE_MAX_PROPERTY_LENGTH:
|
||||
permitted += value[current]
|
||||
current += 1
|
||||
return permitted
|
||||
|
||||
async def read(self, backup: Backup) -> IOBase:
|
||||
item = self._validateBackup(backup)
|
||||
return await self.drivebackend.download(item.id(), item.size())
|
||||
|
||||
async def retain(self, backup: Backup, retain: bool) -> None:
|
||||
item = self._validateBackup(backup)
|
||||
if item.retained() == retain:
|
||||
return
|
||||
file_metadata: Dict[str, str] = {
|
||||
'appProperties': {
|
||||
PROP_RETAINED: str(retain),
|
||||
},
|
||||
}
|
||||
await self.drivebackend.update(item.id(), file_metadata)
|
||||
item.setRetained(retain)
|
||||
|
||||
async def note(self, backup, note: str) -> None:
|
||||
item = self._validateBackup(backup)
|
||||
truncated = self.truncateAppProperty(PROP_NOTE, note)
|
||||
file_metadata: Dict[str, str] = {
|
||||
'appProperties': {
|
||||
PROP_NOTE: truncated,
|
||||
},
|
||||
'description': note,
|
||||
}
|
||||
logger.debug(f"Adding a note to drive backup '{item.name()}'")
|
||||
await self.drivebackend.update(item.id(), file_metadata)
|
||||
item.setNote(truncated)
|
||||
|
||||
async def getFolderId(self):
|
||||
return await self.folder_finder.get()
|
||||
|
||||
def _validateBackup(self, backup: Backup) -> DriveBackup:
|
||||
drive_item: DriveBackup = backup.getSource(self.name())
|
||||
if not drive_item:
|
||||
raise LogicError(
|
||||
"Requested to do something with a backup from Google Drive, but the backup has no Google Drive source")
|
||||
return drive_item
|
||||
|
||||
def _timeToRfc3339String(self, time: datetime) -> str:
|
||||
return time.strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
async def _get(self, id):
|
||||
return await self.drivebackend.get(id)
|
||||
@@ -0,0 +1,204 @@
|
||||
from datetime import timedelta
|
||||
from typing import Any, Dict
|
||||
from backup.file import File
|
||||
from aiohttp.client_exceptions import ClientResponseError
|
||||
from injector import inject, singleton
|
||||
|
||||
from ..config import Config, Setting
|
||||
from ..exceptions import (BackupFolderInaccessible, BackupFolderMissingError,
|
||||
GoogleDrivePermissionDenied, LogInToGoogleDriveError)
|
||||
from ..time import Time
|
||||
from .driverequests import DriveRequests
|
||||
from ..logger import getLogger
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
FOLDER_MIME_TYPE = 'application/vnd.google-apps.folder'
|
||||
FOLDER_NAME = 'Home Assistant Backups'
|
||||
FOLDER_CACHE_SECONDS = 60 * 31 # 31 minutes
|
||||
|
||||
|
||||
@singleton
|
||||
class FolderFinder():
|
||||
@inject
|
||||
def __init__(self, config: Config, time: Time, drive_requests: DriveRequests):
|
||||
self.config = config
|
||||
self.drivebackend: DriveRequests = drive_requests
|
||||
self.time = time
|
||||
|
||||
# The cached folder id
|
||||
self._folderId = None
|
||||
|
||||
# When the fodler id was last cached
|
||||
self._folder_queryied_last = None
|
||||
|
||||
# These get set when an existing folder is found and should cause the UI to
|
||||
# prompt for what to do about it.
|
||||
self._existing_folder = None
|
||||
self._use_existing = None
|
||||
self._folder_details = None
|
||||
|
||||
def resolveExisting(self, val):
|
||||
if self._existing_folder:
|
||||
self._use_existing = val
|
||||
else:
|
||||
self._use_existing = None
|
||||
|
||||
def _isSharedDrive(self, folder):
|
||||
driveId = folder.get("driveId", None)
|
||||
return driveId and len(driveId) > 0
|
||||
|
||||
def currentIsSharedDrive(self):
|
||||
return self._folder_details and self._isSharedDrive(self._folder_details)
|
||||
|
||||
async def get(self):
|
||||
if self._existing_folder and self._use_existing is not None:
|
||||
if self._use_existing:
|
||||
await self.save(self._existing_folder)
|
||||
else:
|
||||
await self.create()
|
||||
self._use_existing = None
|
||||
if not self._folder_queryied_last or self._folder_queryied_last + timedelta(seconds=FOLDER_CACHE_SECONDS) < self.time.now():
|
||||
try:
|
||||
self._folderId = await self._readFolderId()
|
||||
except (BackupFolderMissingError, BackupFolderInaccessible):
|
||||
if not self.config.get(Setting.SPECIFY_BACKUP_FOLDER):
|
||||
# Search for a folder, they may have created one before
|
||||
self._existing_folder = await self._search()
|
||||
if self._existing_folder:
|
||||
self._folderId = self._existing_folder.get('id')
|
||||
else:
|
||||
# Create folder, since no other folder is available
|
||||
await self.create()
|
||||
else:
|
||||
raise
|
||||
self._folder_queryied_last = self.time.now()
|
||||
return self._folderId
|
||||
|
||||
def getExisting(self):
|
||||
return self._existing_folder
|
||||
|
||||
async def save(self, folder: Any) -> str:
|
||||
if not isinstance(folder, str):
|
||||
self._folder_details = folder
|
||||
folder = folder.get('id')
|
||||
else:
|
||||
self._folder_details = None
|
||||
logger.info("Saving backup folder: " + folder)
|
||||
File.write(self.config.get(Setting.FOLDER_FILE_PATH), folder)
|
||||
self._folderId = folder
|
||||
self._folder_queryied_last = self.time.now()
|
||||
self._existing_folder = None
|
||||
|
||||
def reset(self):
|
||||
if File.exists(self.config.get(Setting.FOLDER_FILE_PATH)):
|
||||
File.delete(self.config.get(Setting.FOLDER_FILE_PATH))
|
||||
self._folderId = None
|
||||
self._folder_queryied_last = None
|
||||
self._existing_folder = None
|
||||
|
||||
def getCachedFolder(self):
|
||||
return self._folderId
|
||||
|
||||
def deCache(self):
|
||||
self._folderId = None
|
||||
self._folder_queryied_last = None
|
||||
|
||||
async def _readFolderId(self) -> str:
|
||||
# First, check if we cached the drive folder
|
||||
if not File.exists(self.config.get(Setting.FOLDER_FILE_PATH)):
|
||||
raise BackupFolderMissingError()
|
||||
else:
|
||||
folder_id: str = File.read(self.config.get(Setting.FOLDER_FILE_PATH)).strip()
|
||||
if await self._verify(folder_id):
|
||||
return folder_id
|
||||
else:
|
||||
raise BackupFolderInaccessible(folder_id)
|
||||
|
||||
async def _search(self) -> str:
|
||||
folders = []
|
||||
|
||||
try:
|
||||
async for child in self.drivebackend.query("mimeType='" + FOLDER_MIME_TYPE + "'"):
|
||||
if self._isValidFolder(child):
|
||||
folders.append(child)
|
||||
except ClientResponseError as e:
|
||||
# 404 means the folder doesn't exist (maybe it got moved?)
|
||||
if e.status == 404:
|
||||
"Make Error"
|
||||
raise LogInToGoogleDriveError()
|
||||
else:
|
||||
raise e
|
||||
|
||||
if len(folders) == 0:
|
||||
return None
|
||||
|
||||
folders.sort(key=lambda c: Time.parse(c.get("modifiedTime")))
|
||||
# Found a folder, which means we're probably using the add-on from a
|
||||
# previous (or duplicate) installation. Record and return the id but don't
|
||||
# persist it until the user chooses to do so.
|
||||
folder = folders[len(folders) - 1]
|
||||
logger.info("Found " + folder.get('name'))
|
||||
return folder
|
||||
|
||||
async def _verify(self, id):
|
||||
if self.drivebackend.isCustomCreds():
|
||||
# If the user is using custom creds and specifying the backup folder, then chances are the
|
||||
# app doesn't have permission to access the parent folder directly. Ironically, we can still
|
||||
# query for children and add/remove backups. Not a huge deal, just
|
||||
# means we can't verify the folder still exists, isn't trashed, etc. Just let it be valid
|
||||
# and handle potential errors elsewhere.
|
||||
return True
|
||||
# Query drive for the folder to make sure it still exists and we have the right permission on it.
|
||||
try:
|
||||
folder = await self.drivebackend.get(id)
|
||||
if not self._isValidFolder(folder):
|
||||
logger.info("Provided backup folder {0} is invalid".format(id))
|
||||
return False
|
||||
self._folder_details = folder
|
||||
return True
|
||||
except ClientResponseError as e:
|
||||
if e.status == 404:
|
||||
# 404 means the folder doesn't exist (maybe it got moved?) but can also mean that we
|
||||
# just don't have permission to see the folder. Often we can still upload into it, so just
|
||||
# let it pass without further verification and let other error handling (on upload) identify problems.
|
||||
return True
|
||||
else:
|
||||
raise e
|
||||
except GoogleDrivePermissionDenied:
|
||||
# Lost permission on the backup folder
|
||||
return False
|
||||
|
||||
def _isValidFolder(self, folder) -> bool:
|
||||
try:
|
||||
caps = folder.get('capabilities')
|
||||
if folder.get('trashed'):
|
||||
return False
|
||||
elif not caps['canAddChildren']:
|
||||
return False
|
||||
elif not caps['canListChildren']:
|
||||
return False
|
||||
elif not caps.get('canDeleteChildren', False) and not caps.get('canRemoveChildren', False):
|
||||
if self._isSharedDrive(folder) and caps.get("canTrashChildren", False):
|
||||
# Allow folders in shared drives if you can still trash items inside it.
|
||||
return True
|
||||
return False
|
||||
elif folder.get("mimeType") != FOLDER_MIME_TYPE:
|
||||
return False
|
||||
except Exception:
|
||||
return False
|
||||
return True
|
||||
|
||||
async def create(self) -> str:
|
||||
logger.info('Creating folder "{}" in "My Drive"'.format(FOLDER_NAME))
|
||||
file_metadata: Dict[str, str] = {
|
||||
'name': FOLDER_NAME,
|
||||
'mimeType': FOLDER_MIME_TYPE,
|
||||
'appProperties': {
|
||||
"backup_folder": "true",
|
||||
},
|
||||
}
|
||||
folder = await self.drivebackend.createFolder(file_metadata)
|
||||
self._folder_details = folder
|
||||
await self.save(folder)
|
||||
return folder.get('id')
|
||||
@@ -0,0 +1,2 @@
|
||||
# flake8: noqa
|
||||
from .exceptions import GoogleCredGenerateError, SupervisorUnexpectedError, SupervisorTimeoutError, GoogleUnexpectedError, SupervisorFileSystemError, SupervisorPermissionError, LogInToGoogleDriveError, KnownTransient, GoogleInternalError, GoogleRateLimitError, CredRefreshGoogleError, CredRefreshMyError, BackupFolderInaccessible, BackupFolderMissingError, DeleteMutlipleBackupsError, DriveQuotaExceeded, ensureKey, ExistingBackupFolderError, UserCancelledError, UploadFailed, SupervisorConnectionError, BackupPasswordKeyInvalid, BackupInProgress, SimulatedError, ProtocolError, PleaseWait, NotUploadable, NoBackup, LowSpaceError, LogicError, KnownError, InvalidConfigurationValue, HomeAssistantDeleteError, GoogleTimeoutError, GoogleSessionError, GoogleInternalError, GoogleDrivePermissionDenied, GoogleDnsFailure, GoogleCredentialsExpired, GoogleCantConnect, ExistingBackupFolderError
|
||||
@@ -0,0 +1,446 @@
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
from ..const import (DRIVE_FOLDER_URL_FORMAT, ERROR_BACKUP_FOLDER_INACCESSIBLE,
|
||||
ERROR_BACKUP_FOLDER_MISSING, ERROR_BAD_PASSWORD_KEY,
|
||||
ERROR_CREDS_EXPIRED, ERROR_DRIVE_FULL,
|
||||
ERROR_EXISTING_FOLDER, ERROR_GOOGLE_CONNECT, ERROR_GOOGLE_CRED_PROCESS,
|
||||
ERROR_GOOGLE_DNS, ERROR_GOOGLE_INTERNAL,
|
||||
ERROR_GOOGLE_SESSION, ERROR_GOOGLE_TIMEOUT,
|
||||
ERROR_HA_DELETE_ERROR, ERROR_INVALID_CONFIG, ERROR_LOGIC,
|
||||
ERROR_LOW_SPACE, ERROR_MULTIPLE_DELETES, ERROR_NO_BACKUP,
|
||||
ERROR_NOT_UPLOADABLE, ERROR_PLEASE_WAIT, ERROR_PROTOCOL,
|
||||
ERROR_BACKUP_IN_PROGRESS, ERROR_UPLOAD_FAILED, LOG_IN_TO_DRIVE,
|
||||
SUPERVISOR_PERMISSION, ERROR_GOOGLE_UNEXPECTED, ERROR_SUPERVISOR_TIMEOUT, ERROR_SUPERVISOR_UNEXPECTED, ERROR_SUPERVISOR_FILE_SYSTEM)
|
||||
|
||||
|
||||
def ensureKey(key, target, name):
|
||||
if key not in target:
|
||||
raise ProtocolError(key, name, target)
|
||||
return target[key]
|
||||
|
||||
|
||||
class KnownError(Exception, ABC):
|
||||
@abstractmethod
|
||||
def message(self) -> str:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def code(self) -> str:
|
||||
pass
|
||||
|
||||
def httpStatus(self) -> int:
|
||||
return 500
|
||||
|
||||
def data(self):
|
||||
return {}
|
||||
|
||||
def retrySoon(self):
|
||||
return True
|
||||
|
||||
|
||||
class KnownTransient(KnownError):
|
||||
pass
|
||||
|
||||
|
||||
class SimulatedError(KnownError):
|
||||
def __init__(self, code=None):
|
||||
self._code = code
|
||||
|
||||
def code(self):
|
||||
return self._code
|
||||
|
||||
def message(self):
|
||||
return "Gave code " + str(self._code)
|
||||
|
||||
|
||||
class LogicError(KnownError):
|
||||
def __init__(self, message=None):
|
||||
self._message = message
|
||||
|
||||
def message(self):
|
||||
return self._message
|
||||
|
||||
def code(self):
|
||||
return ERROR_LOGIC
|
||||
|
||||
|
||||
class ProtocolError(KnownError):
|
||||
def __init__(self, parameter=None, object_name=None, debug_object=None):
|
||||
self._parameter = parameter
|
||||
self._object_name = object_name
|
||||
self._debug_object = debug_object
|
||||
|
||||
def message(self):
|
||||
if self._object_name:
|
||||
return "Required key '{0}' was missing from {1}".format(self._parameter, self._object_name)
|
||||
else:
|
||||
return self._parameter
|
||||
|
||||
def code(self):
|
||||
return ERROR_PROTOCOL
|
||||
|
||||
|
||||
class BackupInProgress(KnownError):
|
||||
def message(self):
|
||||
return "A backup is already in progress"
|
||||
|
||||
def code(self):
|
||||
return ERROR_BACKUP_IN_PROGRESS
|
||||
|
||||
|
||||
class BackupPasswordKeyInvalid(KnownError):
|
||||
def message(self):
|
||||
return "Couldn't find your backup password in your secrets file. Please check your settings."
|
||||
|
||||
def code(self):
|
||||
return ERROR_BAD_PASSWORD_KEY
|
||||
|
||||
def retrySoon(self):
|
||||
return False
|
||||
|
||||
|
||||
class UploadFailed(KnownError):
|
||||
def message(self):
|
||||
return "Backup upload failed. Please check the supervisor logs for details."
|
||||
|
||||
def code(self):
|
||||
return ERROR_UPLOAD_FAILED
|
||||
|
||||
|
||||
class GoogleCredentialsExpired(KnownError):
|
||||
def message(self):
|
||||
return "Your Google Drive credentials have expired. Please reauthorize with Google Drive through the Web UI."
|
||||
|
||||
def code(self):
|
||||
return ERROR_CREDS_EXPIRED
|
||||
|
||||
def retrySoon(self):
|
||||
return False
|
||||
|
||||
|
||||
class NoBackup(KnownError):
|
||||
def message(self):
|
||||
return "The backup doesn't exist anymore"
|
||||
|
||||
def code(self):
|
||||
return ERROR_NO_BACKUP
|
||||
|
||||
|
||||
class NotUploadable(KnownError):
|
||||
def message(self):
|
||||
return "This backup can't be uploaded to Home Assistant yet"
|
||||
|
||||
def code(self):
|
||||
return ERROR_NOT_UPLOADABLE
|
||||
|
||||
|
||||
class PleaseWait(KnownError):
|
||||
def message(self):
|
||||
return "Please wait until the sync is finished."
|
||||
|
||||
def code(self):
|
||||
return ERROR_PLEASE_WAIT
|
||||
|
||||
|
||||
class InvalidConfigurationValue(KnownError):
|
||||
def __init__(self, key=None, current=None):
|
||||
self.key = key
|
||||
self.current = current
|
||||
|
||||
def message(self):
|
||||
return "'{0}' isn't a valid value for {1}".format(str(self.current), str(self.key))
|
||||
|
||||
def code(self):
|
||||
return ERROR_INVALID_CONFIG
|
||||
|
||||
|
||||
# UI Handler Done and updated
|
||||
|
||||
class DeleteMutlipleBackupsError(KnownError):
|
||||
def __init__(self, delete_sources=None):
|
||||
self.delete_sources = delete_sources
|
||||
|
||||
def message(self):
|
||||
return "The add-on has been configured to delete more than one older backups. Please confirm this by visiting the add-on's web UI or by setting the config option 'confirm_multiple_deletes'=false in your add-on configuration."
|
||||
|
||||
def code(self):
|
||||
return ERROR_MULTIPLE_DELETES
|
||||
|
||||
def data(self):
|
||||
return self.delete_sources
|
||||
|
||||
def retrySoon(self):
|
||||
return False
|
||||
|
||||
|
||||
class DriveQuotaExceeded(KnownError):
|
||||
def message(self):
|
||||
return "Google Drive is out of space"
|
||||
|
||||
def code(self):
|
||||
return ERROR_DRIVE_FULL
|
||||
|
||||
def retrySoon(self):
|
||||
return False
|
||||
|
||||
|
||||
class GoogleDnsFailure(KnownError):
|
||||
def message(self):
|
||||
return "Unable to resolve host www.googleapis.com"
|
||||
|
||||
def code(self):
|
||||
return ERROR_GOOGLE_DNS
|
||||
|
||||
|
||||
class GoogleCantConnect(KnownError):
|
||||
def message(self):
|
||||
return "Unable to connect to www.googleapis.com"
|
||||
|
||||
def code(self):
|
||||
return ERROR_GOOGLE_CONNECT
|
||||
|
||||
|
||||
class GoogleInternalError(KnownTransient):
|
||||
def message(self):
|
||||
return "Google Drive returned an internal error (HTTP: 5XX)"
|
||||
|
||||
def code(self):
|
||||
return ERROR_GOOGLE_INTERNAL
|
||||
|
||||
|
||||
class GoogleTimeoutError(KnownError):
|
||||
def message(self):
|
||||
return "Timed out while trying to reach Google Drive"
|
||||
|
||||
def code(self):
|
||||
return ERROR_GOOGLE_TIMEOUT
|
||||
|
||||
@classmethod
|
||||
def factory(cls):
|
||||
return GoogleTimeoutError()
|
||||
|
||||
|
||||
class GoogleRateLimitError(KnownTransient):
|
||||
def message(self):
|
||||
return "The addon has made too many requests to Google Drive, and will back off"
|
||||
|
||||
def code(self):
|
||||
return "google_rate_limit"
|
||||
|
||||
|
||||
class GoogleSessionError(KnownError):
|
||||
def message(self):
|
||||
return "Upload session with Google Drive expired. The upload could not complete."
|
||||
|
||||
def code(self):
|
||||
return ERROR_GOOGLE_SESSION
|
||||
|
||||
|
||||
class HomeAssistantDeleteError(KnownError):
|
||||
def message(self):
|
||||
return "Home Assistant refused to delete the backup."
|
||||
|
||||
def code(self):
|
||||
return ERROR_HA_DELETE_ERROR
|
||||
|
||||
|
||||
class ExistingBackupFolderError(KnownError):
|
||||
def __init__(self, existing_id: str = None, existing_name: str = None):
|
||||
self.existing_id = existing_id
|
||||
self.existing_name = existing_name
|
||||
|
||||
def message(self):
|
||||
return "A backup folder already exists. Please visit the add-on Web UI to select where to backup."
|
||||
|
||||
def code(self):
|
||||
return ERROR_EXISTING_FOLDER
|
||||
|
||||
def data(self):
|
||||
return {
|
||||
"existing_url#href": DRIVE_FOLDER_URL_FORMAT.format(self.existing_id),
|
||||
"existing_name": self.existing_name
|
||||
}
|
||||
|
||||
def retrySoon(self):
|
||||
return False
|
||||
|
||||
|
||||
class BackupFolderMissingError(KnownError):
|
||||
def message(self):
|
||||
return "Please visit the add-on Web UI to select where to backup."
|
||||
|
||||
def code(self):
|
||||
return ERROR_BACKUP_FOLDER_MISSING
|
||||
|
||||
def retrySoon(self):
|
||||
return False
|
||||
|
||||
|
||||
class BackupFolderInaccessible(KnownError):
|
||||
def __init__(self, existing_id: str = None):
|
||||
self.existing_id = existing_id
|
||||
|
||||
def message(self):
|
||||
return "The choosen backup folder has become inaccessible. Please visit the addon web UI to select a backup folder."
|
||||
|
||||
def data(self):
|
||||
return {
|
||||
"existing_url#href": DRIVE_FOLDER_URL_FORMAT.format(self.existing_id)
|
||||
}
|
||||
|
||||
def code(self):
|
||||
return ERROR_BACKUP_FOLDER_INACCESSIBLE
|
||||
|
||||
|
||||
class GoogleDrivePermissionDenied(KnownError):
|
||||
def message(self):
|
||||
return "Google Drive denied the request due to permissions."
|
||||
|
||||
def code(self):
|
||||
return "google_drive_permissions"
|
||||
|
||||
|
||||
class LowSpaceError(KnownError):
|
||||
def __init__(self, pct_used=None, space_remaining=None):
|
||||
self.pct_used = pct_used
|
||||
self.space_remaining = space_remaining
|
||||
|
||||
def message(self):
|
||||
return "Your backup folder is low on disk space. Backups can't be created until space is available."
|
||||
|
||||
def code(self):
|
||||
return ERROR_LOW_SPACE
|
||||
|
||||
def data(self):
|
||||
return {
|
||||
"pct_used": self.pct_used,
|
||||
"space_remaining": self.space_remaining
|
||||
}
|
||||
|
||||
|
||||
class SupervisorConnectionError(KnownError):
|
||||
def message(self):
|
||||
return "The addon couldn't connect to the supervisor. Backups can't continue until the supervisor is responding."
|
||||
|
||||
def code(self):
|
||||
return "supervisor_connection"
|
||||
|
||||
|
||||
class UserCancelledError(KnownError):
|
||||
def message(self):
|
||||
return "Sync was cancelled by you"
|
||||
|
||||
def code(self):
|
||||
return "cancelled"
|
||||
|
||||
def retrySoon(self):
|
||||
return False
|
||||
|
||||
|
||||
class CredRefreshGoogleError(KnownError):
|
||||
def __init__(self, from_google=None):
|
||||
self.from_google = from_google
|
||||
|
||||
def message(self):
|
||||
return "Couldn't refresh your credentials with Google because: '{}'".format(self.from_google)
|
||||
|
||||
def code(self):
|
||||
return "token_refresh_google_error"
|
||||
|
||||
def data(self):
|
||||
return {
|
||||
"from_google": self.from_google
|
||||
}
|
||||
|
||||
|
||||
class CredRefreshMyError(KnownError):
|
||||
def __init__(self, reason: str = None):
|
||||
self.reason = reason
|
||||
|
||||
def message(self):
|
||||
return "Couldn't refresh Google Drive credentials because: {}".format(self.reason)
|
||||
|
||||
def code(self):
|
||||
return "token_refresh_my_error"
|
||||
|
||||
def data(self):
|
||||
return {
|
||||
"reason": self.reason
|
||||
}
|
||||
|
||||
|
||||
class LogInToGoogleDriveError(KnownError):
|
||||
def message(self):
|
||||
return "Please visit drive.google.com to activate your Google Drive account."
|
||||
|
||||
def code(self):
|
||||
return LOG_IN_TO_DRIVE
|
||||
|
||||
def retrySoon(self):
|
||||
return False
|
||||
|
||||
|
||||
class SupervisorPermissionError(KnownError):
|
||||
def message(self):
|
||||
return "The supervisor is rejecting requests from the addon. Please visit the web-UI for guidance"
|
||||
|
||||
def code(self):
|
||||
return SUPERVISOR_PERMISSION
|
||||
|
||||
def retrySoon(self):
|
||||
return True
|
||||
|
||||
|
||||
class GoogleUnexpectedError(KnownError):
|
||||
def message(self):
|
||||
return "Google gave an unexpected response"
|
||||
|
||||
def code(self):
|
||||
return ERROR_GOOGLE_UNEXPECTED
|
||||
|
||||
@classmethod
|
||||
def factory(cls):
|
||||
return GoogleUnexpectedError()
|
||||
|
||||
|
||||
class SupervisorTimeoutError(KnownError):
|
||||
def message(self):
|
||||
return "A request to the supervisor timed out"
|
||||
|
||||
def code(self):
|
||||
return ERROR_SUPERVISOR_TIMEOUT
|
||||
|
||||
@classmethod
|
||||
def factory(cls):
|
||||
return SupervisorTimeoutError()
|
||||
|
||||
|
||||
class SupervisorUnexpectedError(KnownError):
|
||||
def message(self):
|
||||
return "The supervisor gave an unexpected response"
|
||||
|
||||
def code(self):
|
||||
return ERROR_SUPERVISOR_UNEXPECTED
|
||||
|
||||
@classmethod
|
||||
def factory(cls):
|
||||
return SupervisorUnexpectedError()
|
||||
|
||||
|
||||
class SupervisorFileSystemError(KnownError):
|
||||
def message(self):
|
||||
return "The host file system is read-only. Please restart Home Assistant and verify you have enough free space."
|
||||
|
||||
def code(self):
|
||||
return ERROR_SUPERVISOR_FILE_SYSTEM
|
||||
|
||||
|
||||
class GoogleCredGenerateError(KnownError):
|
||||
def __init__(self, message):
|
||||
self._msg = message
|
||||
|
||||
def message(self):
|
||||
return self._msg
|
||||
|
||||
def code(self):
|
||||
return ERROR_GOOGLE_CRED_PROCESS
|
||||
@@ -0,0 +1,2 @@
|
||||
from .jsonfilesaver import JsonFileSaver
|
||||
from .file import File
|
||||
@@ -0,0 +1,76 @@
|
||||
import os
|
||||
from backup.logger import getLogger
|
||||
from os.path import exists
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
|
||||
class File:
|
||||
"""
|
||||
The envrionment Home Assistant runs in is notorious for disk-related failures, often from running completely out of space and SD card corruption.
|
||||
Both of these can leave the addon in a state where the files it need to run are either corrupted or empty. This class attempts to mitigate that
|
||||
by writing all config files twice, first to a backup file and then to the "real" file path. Then when reading it will check both locations to try
|
||||
and find a copy of the file that isn't corrupted or deleted.
|
||||
This avoids a number of common failures, namely:
|
||||
- A power failure while writing a file can leave it empty or malformed.
|
||||
- Overwriting a file while the disk is full can truncateit without writing the new data
|
||||
- HD corruption cna make a file malformed, but its less likely to affect both files.
|
||||
"""
|
||||
@classmethod
|
||||
def _read(cls, path):
|
||||
with open(path, "r") as f:
|
||||
return f.read()
|
||||
|
||||
@classmethod
|
||||
def read(cls, path):
|
||||
try:
|
||||
data = File._read(path)
|
||||
if len(data) == 0:
|
||||
logger.error(f"The configuration file {path} had an invalid format. This could be caused by hard drive corruption or an unstable power event. We'll attempt to load from a backup file instead.")
|
||||
backup = File._backup_path(path)
|
||||
if not exists(backup):
|
||||
logger.error("Unable to locate a backup path")
|
||||
raise
|
||||
return File._read(backup)
|
||||
else:
|
||||
return data
|
||||
except FileNotFoundError:
|
||||
logger.error(f"The configuration file {path} was not found. This could be caused by hard drive corruption or an unstable power event. We'll attempt to load from a backup file instead.")
|
||||
backup = File._backup_path(path)
|
||||
if not exists(backup):
|
||||
logger.error("Unable to locate a backup path")
|
||||
raise
|
||||
return File._read(backup)
|
||||
|
||||
@classmethod
|
||||
def _write(cls, path, data):
|
||||
with open(path, "w") as f:
|
||||
f.write(data)
|
||||
|
||||
@classmethod
|
||||
def write(cls, path, data):
|
||||
# Crete the backup (recovery) file first. This ensures its present if the subsequent write is corrupted.
|
||||
File._write(File._backup_path(path), data)
|
||||
File._write(path, data)
|
||||
|
||||
@classmethod
|
||||
def exists(cls, path):
|
||||
if exists(path):
|
||||
return True
|
||||
return exists(File._backup_path(path))
|
||||
|
||||
@classmethod
|
||||
def delete(sels, path):
|
||||
if exists(File._backup_path(path)):
|
||||
os.remove(File._backup_path(path))
|
||||
if exists(path):
|
||||
os.remove(path)
|
||||
|
||||
@classmethod
|
||||
def _backup_path(cls, path):
|
||||
return path + ".backup"
|
||||
|
||||
@classmethod
|
||||
def touch(cls, file):
|
||||
with open(file, "w"):
|
||||
pass
|
||||
@@ -0,0 +1,71 @@
|
||||
import json
|
||||
import os
|
||||
from backup.logger import getLogger
|
||||
from os.path import exists
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
|
||||
class JsonFileSaver:
|
||||
"""
|
||||
The envrionment Home Assistant runs in is notorious for disk-related failures, often from running completely out of space and SD card corruption.
|
||||
Both of these can leave the addon in a state where the files it need to run are either corrupted or empty. This class attempts to mitigate that
|
||||
by writing all config files twice, first to a backup file and then to the "real" file path. Then when reading it will check both locations to try
|
||||
and find a copy of the file that isn't corrupted or deleted.
|
||||
This avoids a number of common failures, namely:
|
||||
- A power failure while writing a file can leave it empty or malformed.
|
||||
- Overwriting a file while the disk is full can truncateit without writing the new data
|
||||
- HD corruption cna make a file malformed, but its less likely to affect both files.
|
||||
"""
|
||||
@classmethod
|
||||
def _read(cls, path):
|
||||
with open(path, "r") as f:
|
||||
return json.load(f)
|
||||
|
||||
@classmethod
|
||||
def read(cls, path):
|
||||
try:
|
||||
return JsonFileSaver._read(path)
|
||||
except json.decoder.JSONDecodeError:
|
||||
logger.error(f"The configuration file {path} had an invalid format. This could be caused by hard drive corruption or an unstable power event. We'll attempt to load from a backup file instead.")
|
||||
backup = JsonFileSaver._backup_path(path)
|
||||
if not exists(backup):
|
||||
logger.error("Unable to locate a backup path")
|
||||
raise
|
||||
return JsonFileSaver._read(backup)
|
||||
except FileNotFoundError:
|
||||
logger.error(f"The configuration file {path} was not found. This could be caused by hard drive corruption or an unstable power event. We'll attempt to load from a backup file instead.")
|
||||
backup = JsonFileSaver._backup_path(path)
|
||||
if not exists(backup):
|
||||
logger.error("Unable to locate a backup path")
|
||||
raise
|
||||
return JsonFileSaver._read(backup)
|
||||
|
||||
@classmethod
|
||||
def _write(cls, path, data):
|
||||
with open(path, "w") as f:
|
||||
json.dump(data, f, indent=4)
|
||||
|
||||
@classmethod
|
||||
def write(cls, path, data):
|
||||
# Crete the backup (rcovery) file first. This ensures its present if the subsequent write is corrupted.
|
||||
JsonFileSaver._write(JsonFileSaver._backup_path(path), data)
|
||||
JsonFileSaver._write(path, data)
|
||||
|
||||
@classmethod
|
||||
def exists(cls, path):
|
||||
if exists(path):
|
||||
return True
|
||||
return exists(JsonFileSaver._backup_path(path))
|
||||
|
||||
@classmethod
|
||||
def delete(sels, path):
|
||||
if exists(JsonFileSaver._backup_path(path)):
|
||||
os.remove(JsonFileSaver._backup_path(path))
|
||||
if exists(path):
|
||||
os.remove(path)
|
||||
|
||||
@classmethod
|
||||
def _backup_path(cls, path):
|
||||
return path + ".backup"
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
# flake8: noqa
|
||||
from .hasource import HaSource, HABackup, PendingBackup, SOURCE_HA
|
||||
from .haupdater import HaUpdater
|
||||
from .harequests import HaRequests, EVENT_BACKUP_END, EVENT_BACKUP_START, VERSION_BACKUP_PATH
|
||||
from .backupname import BackupName, BACKUP_NAME_KEYS
|
||||
from .password import Password
|
||||
from .addon_stopper import AddonStopper
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
from backup.config import Config, Setting
|
||||
from backup.file import JsonFileSaver
|
||||
from backup.worker import Worker
|
||||
from backup.exceptions import SupervisorFileSystemError
|
||||
from .harequests import HaRequests
|
||||
from injector import inject, singleton
|
||||
from backup.time import Time
|
||||
from backup.logger import getLogger
|
||||
from datetime import timedelta
|
||||
from asyncio import Lock
|
||||
|
||||
LOGGER = getLogger(__name__)
|
||||
CHECK_DURATION = timedelta(seconds=60)
|
||||
ATTR_STATE = "state"
|
||||
ATTR_WATCHDOG = "watchdog"
|
||||
ATTR_NAME = "name"
|
||||
STATE_STOPPED = "stopped"
|
||||
STATE_STARTED = "started"
|
||||
|
||||
STATES_STOPPED = ["stopped", "unknown", "error"]
|
||||
|
||||
|
||||
@singleton
|
||||
class AddonStopper(Worker):
|
||||
@inject
|
||||
def __init__(self, config: Config, requests: HaRequests, time: Time):
|
||||
super().__init__("StartandStopTimer", self.check, time, 10)
|
||||
self.requests = requests
|
||||
self.config = config
|
||||
self.time = time
|
||||
self.must_start = set()
|
||||
self.must_enable_watchdog = set()
|
||||
self.stop_start_check_time = time.now()
|
||||
self._backing_up = False
|
||||
self.allow_run = False
|
||||
self.lock = Lock()
|
||||
|
||||
async def start(self, schedule=True):
|
||||
if schedule:
|
||||
await super().start()
|
||||
path = self.config.get(Setting.STOP_ADDON_STATE_PATH)
|
||||
if JsonFileSaver.exists(path):
|
||||
data = JsonFileSaver.read(path)
|
||||
self.must_enable_watchdog = set(data.get("watchdog", []))
|
||||
self.must_start = set(data.get("start", []))
|
||||
|
||||
def allowRun(self):
|
||||
if not self.allow_run:
|
||||
for slug in self.config.get(Setting.STOP_ADDONS).split(','):
|
||||
if len(slug) == 0:
|
||||
continue
|
||||
self.must_start.add(slug)
|
||||
self.allow_run = True
|
||||
|
||||
def isBackingUp(self, backingUp):
|
||||
self._backing_up = backingUp
|
||||
|
||||
async def stopAddons(self, self_slug):
|
||||
async with self.lock:
|
||||
self._backing_up = True
|
||||
for slug in self.config.get(Setting.STOP_ADDONS).split(','):
|
||||
if slug == self_slug or len(slug) == 0:
|
||||
# Don't ask the supervisor to stop yourself. That would be BAD.
|
||||
continue
|
||||
try:
|
||||
info = await self.requests.getAddonInfo(slug)
|
||||
if info.get(ATTR_STATE, None) == STATE_STARTED:
|
||||
if info.get(ATTR_WATCHDOG, False):
|
||||
try:
|
||||
LOGGER.info("Temporarily disabling watchdog for addon '%s'", info.get(ATTR_NAME, slug))
|
||||
await self.requests.updateAddonOptions(slug, {ATTR_WATCHDOG: False})
|
||||
self.must_enable_watchdog.add(slug)
|
||||
except Exception as e:
|
||||
LOGGER.error("Unable to disable watchdog for addon {0}".format(info.get(ATTR_NAME, slug)))
|
||||
LOGGER.printException(e)
|
||||
try:
|
||||
LOGGER.info("Stopping addon '%s'", info.get(ATTR_NAME, slug))
|
||||
await self.requests.stopAddon(slug)
|
||||
self.must_start.add(slug)
|
||||
except Exception as e:
|
||||
LOGGER.error("Unable to stop addon '{0}'".format(info.get(ATTR_NAME, slug)))
|
||||
LOGGER.printException(e)
|
||||
except Exception as e:
|
||||
LOGGER.error("Unable to lookup info for addon '{0}', please check your configuration".format(slug))
|
||||
LOGGER.printException(e)
|
||||
self._save()
|
||||
|
||||
async def startAddons(self):
|
||||
self._backing_up = False
|
||||
self.stop_start_check_time = self.time.now() + CHECK_DURATION
|
||||
await self.check()
|
||||
|
||||
async def check(self):
|
||||
async with self.lock:
|
||||
if self._backing_up:
|
||||
return
|
||||
if not self.allow_run:
|
||||
return
|
||||
changes = False
|
||||
if len(self.must_start) > 0:
|
||||
for slug in list(self.must_start):
|
||||
try:
|
||||
info = await self.requests.getAddonInfo(slug)
|
||||
state = info.get(ATTR_STATE, None)
|
||||
if info.get(ATTR_STATE, None) in STATES_STOPPED:
|
||||
LOGGER.info("Starting addon '%s'", info.get(ATTR_NAME, slug))
|
||||
await self.requests.startAddon(slug)
|
||||
self.must_start.remove(slug)
|
||||
changes = True
|
||||
elif info.get(ATTR_STATE, None) == STATE_STARTED and self.time.now() > self.stop_start_check_time:
|
||||
# Give up on restarting it, looks like it was never stopped
|
||||
self.must_start.remove(slug)
|
||||
changes = True
|
||||
else:
|
||||
LOGGER.error(f"Addon '{info.get(ATTR_NAME, slug)} had unrecognized state {state}'. The addon will most likely be unable to automatically restart this addon.", )
|
||||
except Exception as e:
|
||||
LOGGER.error("Unable to start addon '%s'", slug)
|
||||
LOGGER.printException(e)
|
||||
self.must_start.remove(slug)
|
||||
changes = True
|
||||
|
||||
if len(self.must_enable_watchdog) > 0:
|
||||
for slug in list(self.must_enable_watchdog):
|
||||
if slug in self.must_start:
|
||||
# Wait until we're done trying to start the addon before re-enabling the watchdog, otherwise the supervisor complains
|
||||
continue
|
||||
try:
|
||||
info = await self.requests.getAddonInfo(slug)
|
||||
if not info.get(ATTR_WATCHDOG, True):
|
||||
LOGGER.info("Re-enabling watchdog for addon '%s'", info.get(ATTR_NAME, slug))
|
||||
await self.requests.updateAddonOptions(slug, {ATTR_WATCHDOG: True})
|
||||
except Exception as e:
|
||||
LOGGER.error("Unable to re-enable watchdog for addon '%s'", slug)
|
||||
LOGGER.printException(e)
|
||||
self.must_enable_watchdog.remove(slug)
|
||||
changes = True
|
||||
if changes:
|
||||
self._save()
|
||||
|
||||
def _save(self):
|
||||
try:
|
||||
path = self.config.get(Setting.STOP_ADDON_STATE_PATH)
|
||||
data = {"start": list(self.must_start), "watchdog": list(self.must_enable_watchdog)}
|
||||
JsonFileSaver.write(path, data)
|
||||
except OSError:
|
||||
raise SupervisorFileSystemError()
|
||||
@@ -0,0 +1,39 @@
|
||||
from datetime import datetime
|
||||
|
||||
from ..logger import getLogger
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
BACKUP_NAME_KEYS = {
|
||||
"{type}": lambda backup_type, now_local, host_info: backup_type,
|
||||
"{year}": lambda backup_type, now_local, host_info: now_local.strftime("%Y"),
|
||||
"{year_short}": lambda backup_type, now_local, host_info: now_local.strftime("%y"),
|
||||
"{weekday}": lambda backup_type, now_local, host_info: now_local.strftime("%A"),
|
||||
"{weekday_short}": lambda backup_type, now_local, host_info: now_local.strftime("%a"),
|
||||
"{month}": lambda backup_type, now_local, host_info: now_local.strftime("%m"),
|
||||
"{month_long}": lambda backup_type, now_local, host_info: now_local.strftime("%B"),
|
||||
"{month_short}": lambda backup_type, now_local, host_info: now_local.strftime("%b"),
|
||||
"{ms}": lambda backup_type, now_local, host_info: now_local.strftime("%f"),
|
||||
"{day}": lambda backup_type, now_local, host_info: now_local.strftime("%d"),
|
||||
"{hr24}": lambda backup_type, now_local, host_info: now_local.strftime("%H"),
|
||||
"{hr12}": lambda backup_type, now_local, host_info: now_local.strftime("%I"),
|
||||
"{min}": lambda backup_type, now_local, host_info: now_local.strftime("%M"),
|
||||
"{sec}": lambda backup_type, now_local, host_info: now_local.strftime("%S"),
|
||||
"{ampm}": lambda backup_type, now_local, host_info: now_local.strftime("%p"),
|
||||
"{version_ha}": lambda backup_type, now_local, host_info: str(host_info.get('homeassistant', 'Unknown')),
|
||||
"{version_hassos}": lambda backup_type, now_local, host_info: str(host_info.get('hassos', 'Unknown')),
|
||||
"{version_super}": lambda backup_type, now_local, host_info: str(host_info.get('supervisor', 'Unknown')),
|
||||
"{date}": lambda backup_type, now_local, host_info: now_local.strftime("%x"),
|
||||
"{time}": lambda backup_type, now_local, host_info: now_local.strftime("%X"),
|
||||
"{datetime}": lambda backup_type, now_local, host_info: now_local.strftime("%c"),
|
||||
"{isotime}": lambda backup_type, now_local, host_info: now_local.isoformat(),
|
||||
"{hostname}": lambda backup_type, now_local, host_info: str(host_info.get('hostname', 'Unknown')),
|
||||
}
|
||||
|
||||
|
||||
class BackupName():
|
||||
def resolve(self, backup_type: str, template: str, now_local: datetime, host_info) -> str:
|
||||
for key in BACKUP_NAME_KEYS:
|
||||
template = template.replace(key, BACKUP_NAME_KEYS[key](
|
||||
backup_type, now_local, host_info))
|
||||
return template
|
||||
@@ -0,0 +1,329 @@
|
||||
import os
|
||||
from typing import Any, Dict
|
||||
|
||||
from aiohttp import ClientSession, ClientTimeout
|
||||
from aiohttp.client_exceptions import ClientResponseError, ClientConnectorError
|
||||
from injector import inject
|
||||
from asyncio.exceptions import TimeoutError
|
||||
|
||||
from ..util import AsyncHttpGetter
|
||||
from ..config import Config, Setting, Version
|
||||
from ..exceptions import HomeAssistantDeleteError, SupervisorConnectionError, SupervisorPermissionError, SupervisorTimeoutError, SupervisorUnexpectedError
|
||||
from ..model import HABackup
|
||||
from ..logger import getLogger
|
||||
from ..util import DataCache
|
||||
from backup.time import Time
|
||||
from backup.const import NECESSARY_OLD_BACKUP_PLURAL_NAME, NECESSARY_OLD_SUPERVISOR_URL
|
||||
from yarl import URL
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
HEADER_TOKEN = "X-Supervisor-Token"
|
||||
|
||||
NOTIFICATION_ID = "backup_broken"
|
||||
EVENT_BACKUP_START = "backup_started"
|
||||
EVENT_BACKUP_END = "backup_ended"
|
||||
|
||||
VERSION_BACKUP_PATH = Version.parse("2021.8")
|
||||
|
||||
|
||||
def supervisor_call(func):
|
||||
async def wrap_and_call(*args, **kwargs):
|
||||
try:
|
||||
return await func(*args, **kwargs)
|
||||
except ClientConnectorError:
|
||||
raise SupervisorConnectionError()
|
||||
except TimeoutError:
|
||||
raise SupervisorConnectionError()
|
||||
except ClientResponseError as e:
|
||||
if e.status == 403:
|
||||
raise SupervisorPermissionError()
|
||||
raise
|
||||
return wrap_and_call
|
||||
|
||||
|
||||
class HaRequests():
|
||||
"""
|
||||
Stores logic for interacting with the supervisor add-on API
|
||||
"""
|
||||
@inject
|
||||
def __init__(self, config: Config, session: ClientSession, time: Time, data_cache: DataCache):
|
||||
self.config: Config = config
|
||||
self.cache = {}
|
||||
self.session = session
|
||||
self._time = time
|
||||
self._data_cache = data_cache
|
||||
|
||||
# default the supervisor versio to using the "most featured" when it can't be parsed.
|
||||
self._super_version = VERSION_BACKUP_PATH
|
||||
|
||||
def getSupervisorURL(self) -> URL:
|
||||
if len(self.config.get(Setting.SUPERVISOR_URL)) > 0:
|
||||
return URL(self.config.get(Setting.SUPERVISOR_URL))
|
||||
if 'SUPERVISOR_TOKEN' in os.environ:
|
||||
return URL("http://supervisor")
|
||||
else:
|
||||
return URL(NECESSARY_OLD_SUPERVISOR_URL)
|
||||
|
||||
def _getBackupPath(self):
|
||||
if self.supportsBackupPaths():
|
||||
return "backups"
|
||||
return NECESSARY_OLD_BACKUP_PLURAL_NAME
|
||||
|
||||
def supportsBackupPaths(self):
|
||||
return not self._super_version or self._super_version >= VERSION_BACKUP_PATH
|
||||
|
||||
@supervisor_call
|
||||
async def createBackup(self, info):
|
||||
if 'folders' in info or 'addons' in info:
|
||||
url = self.getSupervisorURL().with_path("{0}/new/partial".format(self._getBackupPath()))
|
||||
else:
|
||||
url = self.getSupervisorURL().with_path("{0}/new/full".format(self._getBackupPath()))
|
||||
return await self._postHassioData(url, info, timeout=ClientTimeout(total=self.config.get(Setting.PENDING_BACKUP_TIMEOUT_SECONDS)))
|
||||
|
||||
@supervisor_call
|
||||
async def auth(self, user: str, password: str) -> None:
|
||||
await self._postHassioData(self.getSupervisorURL().with_path("auth"), {"username": user, "password": password}, headers=self._altAuthHeaders())
|
||||
|
||||
@supervisor_call
|
||||
async def upload(self, stream):
|
||||
url = self.getSupervisorURL().with_path("{0}/new/upload".format(self._getBackupPath()))
|
||||
return await self._postHassioData(url, data=stream)
|
||||
|
||||
@supervisor_call
|
||||
async def delete(self, slug) -> None:
|
||||
if slug in self.cache:
|
||||
del self.cache[slug]
|
||||
try:
|
||||
if self.supportsBackupPaths():
|
||||
delete_url = self.getSupervisorURL().with_path("{1}/{0}".format(slug, self._getBackupPath()))
|
||||
await self._sendHassioData("delete", delete_url, {})
|
||||
else:
|
||||
delete_url = self.getSupervisorURL().with_path("{1}/{0}/remove".format(slug, self._getBackupPath()))
|
||||
await self._sendHassioData("post", delete_url, {})
|
||||
except ClientResponseError as e:
|
||||
if e.status == 400:
|
||||
raise HomeAssistantDeleteError()
|
||||
raise e
|
||||
|
||||
@supervisor_call
|
||||
async def startAddon(self, slug) -> None:
|
||||
url = self.getSupervisorURL().with_path("addons/{0}/start".format(slug))
|
||||
await self._postHassioData(url, {})
|
||||
|
||||
@supervisor_call
|
||||
async def stopAddon(self, slug) -> None:
|
||||
url = self.getSupervisorURL().with_path("addons/{0}/stop".format(slug))
|
||||
await self._postHassioData(url, {})
|
||||
|
||||
@supervisor_call
|
||||
async def backup(self, slug):
|
||||
if slug in self.cache:
|
||||
info = self.cache[slug]
|
||||
else:
|
||||
info = await self._getHassioData(self.getSupervisorURL().with_path("{1}/{0}/info".format(slug, self._getBackupPath())))
|
||||
self.cache[slug] = info
|
||||
return HABackup(info, self._data_cache, self.config, self.config.isRetained(slug))
|
||||
|
||||
@supervisor_call
|
||||
async def backups(self):
|
||||
return await self._getHassioData(self.getSupervisorURL().with_path(self._getBackupPath()))
|
||||
|
||||
@supervisor_call
|
||||
async def haInfo(self):
|
||||
return await self._getHassioData(self.getSupervisorURL().with_path("core/info"))
|
||||
|
||||
@supervisor_call
|
||||
async def selfInfo(self) -> Dict[str, Any]:
|
||||
return await self.getAddonInfo("self")
|
||||
|
||||
@supervisor_call
|
||||
async def getAddonInfo(self, addon_slug) -> Dict[str, Any]:
|
||||
return await self._getHassioData(self.getSupervisorURL().with_path("addons/{0}/info".format(addon_slug)))
|
||||
|
||||
@supervisor_call
|
||||
async def getAddons(self) -> Dict[str, Any]:
|
||||
return await self._getHassioData(self.getSupervisorURL().with_path("addons"))
|
||||
|
||||
@supervisor_call
|
||||
async def hassosInfo(self) -> Dict[str, Any]:
|
||||
return await self._getHassioData(self.getSupervisorURL().with_path("hassos/info"))
|
||||
|
||||
@supervisor_call
|
||||
async def info(self) -> Dict[str, Any]:
|
||||
return await self._getHassioData(self.getSupervisorURL().with_path("info"))
|
||||
|
||||
@supervisor_call
|
||||
async def refreshBackups(self):
|
||||
url = self.getSupervisorURL().with_path("{0}/reload".format(self._getBackupPath()))
|
||||
return await self._postHassioData(url)
|
||||
|
||||
@supervisor_call
|
||||
async def supervisorInfo(self):
|
||||
url = self.getSupervisorURL().with_path("supervisor/info")
|
||||
info = await self._getHassioData(url)
|
||||
|
||||
# parse the supervisor version
|
||||
if 'version' in info:
|
||||
self._super_version = Version.parse(info['version'])
|
||||
return info
|
||||
|
||||
@supervisor_call
|
||||
async def restore(self, slug: str, password: str = None) -> None:
|
||||
url = self.getSupervisorURL().with_path("{1}/{0}/restore/full".format(slug, self._getBackupPath()))
|
||||
if password:
|
||||
await self._postHassioData(url, {'password': password})
|
||||
else:
|
||||
await self._postHassioData(url, {})
|
||||
|
||||
@supervisor_call
|
||||
async def download(self, slug) -> AsyncHttpGetter:
|
||||
url = self.getSupervisorURL().with_path("{1}/{0}/download".format(slug, self._getBackupPath()))
|
||||
ret = AsyncHttpGetter(url,
|
||||
self._getAuthHeaders(),
|
||||
self.session,
|
||||
timeoutFactory=SupervisorTimeoutError.factory,
|
||||
otherErrorFactory=SupervisorUnexpectedError.factory,
|
||||
timeout=ClientTimeout(sock_connect=self.config.get(Setting.DOWNLOAD_TIMEOUT_SECONDS),
|
||||
sock_read=self.config.get(Setting.DOWNLOAD_TIMEOUT_SECONDS)),
|
||||
time=self._time)
|
||||
return ret
|
||||
|
||||
@supervisor_call
|
||||
async def getSuperLogs(self):
|
||||
url = self.getSupervisorURL().with_path("supervisor/logs")
|
||||
async with self.session.get(url, headers=self._getAuthHeaders()) as resp:
|
||||
resp.raise_for_status()
|
||||
return await resp.text()
|
||||
|
||||
@supervisor_call
|
||||
async def getCoreLogs(self):
|
||||
url = self.getSupervisorURL().with_path("core/logs")
|
||||
async with self.session.get(url, headers=self._getAuthHeaders()) as resp:
|
||||
resp.raise_for_status()
|
||||
return await resp.text()
|
||||
|
||||
async def _validateHassioReply(self, resp) -> Dict[str, Any]:
|
||||
async with resp:
|
||||
resp.raise_for_status()
|
||||
details: Dict[str, Any] = await resp.json()
|
||||
if "result" not in details or details["result"] != "ok":
|
||||
if "result" in details:
|
||||
raise Exception("Hassio said: " + details["result"])
|
||||
else:
|
||||
raise Exception(
|
||||
"Malformed response from Hassio: " + str(details))
|
||||
|
||||
if "data" not in details:
|
||||
return {}
|
||||
if self.config.get(Setting.TRACE_REQUESTS):
|
||||
logger.trace("Hassio replied: %s", details)
|
||||
return details["data"]
|
||||
|
||||
async def getAddonLogo(self, slug: str):
|
||||
url = self.getSupervisorURL().with_path("addons/{0}/icon".format(slug))
|
||||
async with self.session.get(url, headers=self._getAuthHeaders()) as resp:
|
||||
resp.raise_for_status()
|
||||
return (resp.headers['Content-Type'], await resp.read())
|
||||
|
||||
def _getToken(self):
|
||||
configured = self.config.get(Setting.SUPERVISOR_TOKEN)
|
||||
if configured and len(configured) > 0:
|
||||
return configured
|
||||
if "SUPERVISOR_TOKEN" in os.environ:
|
||||
return os.environ.get("SUPERVISOR_TOKEN")
|
||||
# Older versions of the supervisor use a different name for the token.
|
||||
return os.environ.get("HASSIO_TOKEN")
|
||||
|
||||
def _getAuthHeaders(self):
|
||||
return {
|
||||
'Authorization': 'Bearer ' + self._getToken()
|
||||
}
|
||||
|
||||
def _altAuthHeaders(self):
|
||||
return {
|
||||
HEADER_TOKEN: self._getToken()
|
||||
}
|
||||
|
||||
@supervisor_call
|
||||
async def _getHassioData(self, url: URL) -> Dict[str, Any]:
|
||||
if self.config.get(Setting.TRACE_REQUESTS):
|
||||
logger.trace("Making Hassio request: " + str(url))
|
||||
return await self._validateHassioReply(await self.session.get(url, headers=self._getAuthHeaders()))
|
||||
|
||||
async def _postHassioData(self, url: URL, json=None, file=None, data=None, timeout=None, headers=None) -> Dict[str, Any]:
|
||||
return await self._sendHassioData("post", url, json, file, data, timeout, headers)
|
||||
|
||||
@supervisor_call
|
||||
async def _sendHassioData(self, method: str, url: URL, json=None, file=None, data=None, timeout=None, headers=None) -> Dict[str, Any]:
|
||||
if headers is None:
|
||||
headers = self._getAuthHeaders()
|
||||
if self.config.get(Setting.TRACE_REQUESTS):
|
||||
logger.trace("Making Hassio request: " + str(url))
|
||||
return await self._validateHassioReply(await self.session.request(method, url, headers=headers, json=json, data=data, timeout=timeout))
|
||||
|
||||
async def _postHaData(self, path: str, data: Dict[str, Any]) -> None:
|
||||
url = self.getSupervisorURL().with_path("/core/api/" + path)
|
||||
async with self.session.post(url, headers=self._getAuthHeaders(), json=data) as resp:
|
||||
resp.raise_for_status()
|
||||
|
||||
async def sendNotification(self, title: str, message: str) -> None:
|
||||
data: Dict[str, str] = {
|
||||
"title": title,
|
||||
"message": message,
|
||||
"notification_id": NOTIFICATION_ID
|
||||
}
|
||||
await self._postHaData("services/persistent_notification/create", data)
|
||||
|
||||
async def eventBackupStart(self, name, time):
|
||||
await self._sendEvent(EVENT_BACKUP_START, {
|
||||
'backup_name': name,
|
||||
'backup_time': str(time)
|
||||
})
|
||||
|
||||
async def eventBackupEnd(self, name, time, completed):
|
||||
await self._sendEvent(EVENT_BACKUP_END, {
|
||||
'completed': completed,
|
||||
'backup_name': name,
|
||||
'backup_time': str(time)
|
||||
})
|
||||
|
||||
async def _sendEvent(self, event_name: str, data: Dict[str, str]) -> None:
|
||||
await self._postHaData("events/" + event_name, data)
|
||||
|
||||
async def dismissNotification(self) -> None:
|
||||
data: Dict[str, str] = {
|
||||
"notification_id": NOTIFICATION_ID
|
||||
}
|
||||
await self._postHaData("services/persistent_notification/dismiss", data)
|
||||
|
||||
async def updateBackupStaleSensor(self, state: bool) -> None:
|
||||
if self.config.get(Setting.CALL_BACKUP_SNAPSHOT):
|
||||
data: Dict[str, Any] = {
|
||||
"state": state,
|
||||
"attributes": {
|
||||
"friendly_name": "Snapshots Stale",
|
||||
"device_class": "problem"
|
||||
}
|
||||
}
|
||||
await self._postHaData("states/binary_sensor." + NECESSARY_OLD_BACKUP_PLURAL_NAME + "_stale", data)
|
||||
else:
|
||||
data: Dict[str, Any] = {
|
||||
"state": state,
|
||||
"attributes": {
|
||||
"friendly_name": "Backups Stale",
|
||||
"device_class": "problem"
|
||||
}
|
||||
}
|
||||
await self._postHaData("states/binary_sensor.backups_stale", data)
|
||||
|
||||
@supervisor_call
|
||||
async def updateConfig(self, config) -> None:
|
||||
return await self._postHassioData(self.getSupervisorURL().with_path("addons/self/options"), {'options': config})
|
||||
|
||||
@supervisor_call
|
||||
async def updateAddonOptions(self, slug, options):
|
||||
return await self._postHassioData(self.getSupervisorURL().with_path("addons/{0}/options".format(slug)), options)
|
||||
|
||||
async def updateEntity(self, entity, data):
|
||||
await self._postHaData("states/" + entity, data)
|
||||
@@ -0,0 +1,537 @@
|
||||
import asyncio
|
||||
import aiohttp
|
||||
from datetime import timedelta
|
||||
from io import IOBase
|
||||
from threading import Lock, Thread
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from aiohttp.client_exceptions import ClientResponseError
|
||||
from injector import inject, singleton
|
||||
|
||||
from backup.util import AsyncHttpGetter, GlobalInfo, Estimator, DataCache, KEY_NOTE, KEY_LAST_SEEN, KEY_PENDING, KEY_NAME, KEY_CREATED, KEY_I_MADE_THIS, KEY_IGNORE
|
||||
from ..config import Config, Setting, CreateOptions, Startable
|
||||
from ..const import SOURCE_HA
|
||||
from ..model import BackupSource, AbstractBackup, HABackup, Backup
|
||||
from ..exceptions import (LogicError, BackupInProgress,
|
||||
UploadFailed, ensureKey)
|
||||
from .harequests import HaRequests
|
||||
from .password import Password
|
||||
from .backupname import BackupName
|
||||
from ..time import Time
|
||||
from ..logger import getLogger, StandardLogger
|
||||
from backup.const import FOLDERS, NECESSARY_OLD_BACKUP_PLURAL_NAME
|
||||
from .addon_stopper import LOGGER, AddonStopper
|
||||
|
||||
logger: StandardLogger = getLogger(__name__)
|
||||
|
||||
|
||||
class PendingBackup(AbstractBackup):
|
||||
def __init__(self, backupType, protected, options: CreateOptions, request_info, config, time):
|
||||
super().__init__(
|
||||
name=request_info['name'],
|
||||
slug="pending",
|
||||
date=options.when,
|
||||
size="pending",
|
||||
source=SOURCE_HA,
|
||||
backupType=backupType,
|
||||
version="",
|
||||
protected=protected,
|
||||
retained=False,
|
||||
uploadable=False,
|
||||
details=None,
|
||||
note=options.note,
|
||||
pending=True)
|
||||
self._config = config
|
||||
self._failed = False
|
||||
self._complete = False
|
||||
self._exception = None
|
||||
self._failed_at = None
|
||||
self.setOptions(options)
|
||||
self._request_info = request_info
|
||||
self._completed_slug = None
|
||||
self._time = time
|
||||
self._pending_subverted = False
|
||||
self._start_time = time.now()
|
||||
|
||||
def considerForPurge(self) -> bool:
|
||||
return False
|
||||
|
||||
def startTime(self):
|
||||
return self._start_time
|
||||
|
||||
def failed(self, exception, time):
|
||||
self._failed = True
|
||||
self._exception = exception
|
||||
self._failed_at = time
|
||||
|
||||
def getFailureTime(self):
|
||||
return self._failed_at
|
||||
|
||||
def complete(self, slug):
|
||||
self._complete = True
|
||||
self._completed_slug = slug
|
||||
|
||||
def setPendingUnknown(self):
|
||||
self._name = "Pending Backup"
|
||||
self._backupType = "unknown"
|
||||
self._protected = False
|
||||
self._pending_subverted = True
|
||||
self._note = None
|
||||
|
||||
def createdSlug(self):
|
||||
return self._completed_slug
|
||||
|
||||
def isComplete(self):
|
||||
return self._complete
|
||||
|
||||
def isFailed(self):
|
||||
return self._failed
|
||||
|
||||
def status(self):
|
||||
if self._complete:
|
||||
return "Created"
|
||||
if self._failed:
|
||||
return "Failed!"
|
||||
return "Pending"
|
||||
|
||||
def raiseIfNeeded(self):
|
||||
if self.isFailed():
|
||||
raise self._exception
|
||||
if self._pending_subverted:
|
||||
raise BackupInProgress()
|
||||
|
||||
def isStale(self):
|
||||
if self._pending_subverted:
|
||||
delta = timedelta(seconds=self._config.get(
|
||||
Setting.BACKUP_STALE_SECONDS))
|
||||
if self._time.now() > self.startTime() + delta:
|
||||
return True
|
||||
if not self.isFailed():
|
||||
return False
|
||||
delta = timedelta(seconds=self._config.get(
|
||||
Setting.FAILED_BACKUP_TIMEOUT_SECONDS))
|
||||
staleTime = self.getFailureTime() + delta
|
||||
return self._time.now() >= staleTime
|
||||
|
||||
def madeByTheAddon(self):
|
||||
return True
|
||||
|
||||
|
||||
@singleton
|
||||
class HaSource(BackupSource[HABackup], Startable):
|
||||
"""
|
||||
Stores logic for interacting with the supervisor add-on API
|
||||
"""
|
||||
@inject
|
||||
def __init__(self, config: Config, time: Time, ha: HaRequests, info: GlobalInfo, stopper: AddonStopper, estimator: Estimator, data_cache: DataCache):
|
||||
super().__init__()
|
||||
self.config: Config = config
|
||||
self._data_cache = data_cache
|
||||
self.backup_thread: Thread = None
|
||||
self.pending_backup_error: Optional[Exception] = None
|
||||
self.pending_backup_slug: Optional[str] = None
|
||||
self.self_info = None
|
||||
self.host_info = None
|
||||
self.ha_info = None
|
||||
self.super_info = None
|
||||
self.lock: Lock = Lock()
|
||||
self.time = time
|
||||
self.harequests = ha
|
||||
self.last_slugs = set()
|
||||
self.retained = []
|
||||
self.cached_retention = {}
|
||||
self._info = info
|
||||
self.pending_options = {}
|
||||
self.stopper = stopper
|
||||
self.estimator = estimator
|
||||
self._addons = {}
|
||||
self._changes_from_last_query = False
|
||||
|
||||
# This lock should be used for _ANYTHING_ that interacts with self._pending_backup
|
||||
self._pending_backup_lock = asyncio.Lock()
|
||||
self.pending_backup: Optional[PendingBackup] = None
|
||||
self._pending_backup_task = None
|
||||
self._initialized = False
|
||||
|
||||
def isInitialized(self):
|
||||
return self._initialized
|
||||
|
||||
async def check(self) -> bool:
|
||||
pending = self.pending_backup
|
||||
if pending and pending.isStale():
|
||||
self.trigger()
|
||||
return await super().check()
|
||||
|
||||
def icon(self) -> str:
|
||||
return "home-assistant"
|
||||
|
||||
def name(self) -> str:
|
||||
return SOURCE_HA
|
||||
|
||||
def title(self) -> str:
|
||||
return "Home Assistant"
|
||||
|
||||
def maxCount(self) -> None:
|
||||
return self.config.get(Setting.MAX_BACKUPS_IN_HA)
|
||||
|
||||
def enabled(self) -> bool:
|
||||
return True
|
||||
|
||||
def freeSpace(self):
|
||||
return self.estimator.getBytesFree()
|
||||
|
||||
async def create(self, options: CreateOptions) -> HABackup:
|
||||
# Make sure instance info is up-to-date, for the backup name
|
||||
await self._refreshInfo()
|
||||
|
||||
# Set a default name if it was unspecified
|
||||
if options.name_template is None or len(options.name_template) == 0:
|
||||
options.name_template = self.config.get(Setting.BACKUP_NAME)
|
||||
|
||||
# Build the backup request json, get type, etc
|
||||
request, type_name, protected = self._buildBackupInfo(
|
||||
options)
|
||||
|
||||
async with self._pending_backup_lock:
|
||||
# Check if a backup is already in progress
|
||||
if self.pending_backup:
|
||||
if not self.pending_backup.isFailed() and not self.pending_backup.isComplete():
|
||||
logger.info("A backup was already in progress")
|
||||
raise BackupInProgress()
|
||||
|
||||
# try to stop addons
|
||||
await self.stopper.stopAddons(self.self_info['slug'])
|
||||
|
||||
# Create the backup palceholder object
|
||||
self.pending_backup = PendingBackup(
|
||||
type_name, protected, options, request, self.config, self.time)
|
||||
logger.info("Requesting a new backup")
|
||||
self._pending_backup_task = asyncio.create_task(self._requestAsync(
|
||||
self.pending_backup), name="Pending Backup Requester")
|
||||
await asyncio.wait({self._pending_backup_task}, timeout=self.config.get(Setting.NEW_BACKUP_TIMEOUT_SECONDS))
|
||||
self.pending_backup.raiseIfNeeded()
|
||||
|
||||
# There is not other backup in progress, so assume its been requested.
|
||||
pending = self._data_cache.backup(KEY_PENDING)
|
||||
pending[KEY_NAME] = request['name']
|
||||
pending[KEY_CREATED] = options.when.isoformat()
|
||||
pending[KEY_LAST_SEEN] = self.time.now().isoformat()
|
||||
self._data_cache.makeDirty()
|
||||
if self.pending_backup.isComplete():
|
||||
# It completed while we waited, so just query the new backup
|
||||
ret = await self.harequests.backup(self.pending_backup.createdSlug())
|
||||
if options.note is not None:
|
||||
ret.setNote(options.note)
|
||||
self.setDataCacheInfo(ret)
|
||||
self._data_cache.backup(ret.slug())[KEY_I_MADE_THIS] = True
|
||||
return ret
|
||||
else:
|
||||
return self.pending_backup
|
||||
|
||||
def _isHttp400(self, e):
|
||||
if isinstance(e, ClientResponseError):
|
||||
return e.status == 400
|
||||
return False
|
||||
|
||||
async def start(self):
|
||||
try:
|
||||
await self.init()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def stop(self):
|
||||
if self._pending_backup_task:
|
||||
self._pending_backup_task.cancel()
|
||||
await asyncio.wait([self._pending_backup_task])
|
||||
|
||||
@property
|
||||
def query_had_changes(self):
|
||||
return self._changes_from_last_query
|
||||
|
||||
async def get(self) -> Dict[str, HABackup]:
|
||||
if not self._initialized:
|
||||
await self.init()
|
||||
else:
|
||||
# Always ensure the supervisor version is fresh before makign any other requests
|
||||
self.super_info = await self.harequests.supervisorInfo()
|
||||
slugs = set()
|
||||
retained = []
|
||||
backups: Dict[str, HABackup] = {}
|
||||
query = await self.harequests.backups()
|
||||
|
||||
# Different supervisor version use different names for the list of backups
|
||||
backup_list = []
|
||||
if NECESSARY_OLD_BACKUP_PLURAL_NAME in query:
|
||||
backup_list = query[NECESSARY_OLD_BACKUP_PLURAL_NAME]
|
||||
if 'backups' in query:
|
||||
backup_list = query['backups']
|
||||
|
||||
for backup in backup_list:
|
||||
slug = backup['slug']
|
||||
slugs.add(slug)
|
||||
item = await self.harequests.backup(slug)
|
||||
if slug in self.pending_options:
|
||||
item.setOptions(self.pending_options[slug])
|
||||
backups[slug] = item
|
||||
if item.retained():
|
||||
retained.append(item.slug())
|
||||
self.setDataCacheInfo(item)
|
||||
if self.pending_backup:
|
||||
async with self._pending_backup_lock:
|
||||
if self.pending_backup:
|
||||
if self.pending_backup.isStale():
|
||||
# The backup is stale, so just let it die.
|
||||
self._killPending()
|
||||
elif self.pending_backup.isComplete() and self.pending_backup.createdSlug() in backups:
|
||||
# Copy over options if we got the requested backup.
|
||||
backups[self.pending_backup.createdSlug()].setOptions(
|
||||
self.pending_backup.getOptions())
|
||||
if self.pending_backup.note() is not None:
|
||||
# Save the note with the now known slug
|
||||
await self.note(backups[self.pending_backup.createdSlug()], self.pending_backup.note())
|
||||
self._killPending()
|
||||
elif self.last_slugs.symmetric_difference(slugs).intersection(slugs):
|
||||
# New backup added, ignore pending backup.
|
||||
sorted = list(backups.values())
|
||||
sorted.sort(key=HABackup.date)
|
||||
if self.pending_backup.note() is not None and len(sorted) > 0:
|
||||
# Save the note with the newest backup
|
||||
await self.note(sorted[-1], self.pending_backup.note())
|
||||
self._killPending()
|
||||
if self.pending_backup:
|
||||
backups[self.pending_backup.slug()] = self.pending_backup
|
||||
for slug in retained:
|
||||
if not self.config.isRetained(slug):
|
||||
self.config.setRetained(slug, False)
|
||||
self._changes_from_last_query = self.last_slugs != slugs
|
||||
self.last_slugs = slugs
|
||||
return backups
|
||||
|
||||
def setDataCacheInfo(self, backup: HABackup):
|
||||
if backup.slug() not in self._data_cache.backups:
|
||||
# its a new backup, so we need to create a record for it
|
||||
pending = self._data_cache.backups.get(KEY_PENDING, {})
|
||||
pending_created = self.time.parse(pending.get(KEY_CREATED, self.time.now().isoformat()))
|
||||
|
||||
# If the backup has the same name as the one we created and it was created within a day
|
||||
# of the requested time, then assume the addon created it.
|
||||
self_created = backup.name() == pending.get(KEY_NAME, None) and abs((pending_created - backup.date()).total_seconds()) < timedelta(days=1).total_seconds()
|
||||
|
||||
stored_backup = self._data_cache.backup(backup.slug())
|
||||
stored_backup[KEY_I_MADE_THIS] = self_created
|
||||
stored_backup[KEY_CREATED] = backup.date().isoformat()
|
||||
stored_backup[KEY_NAME] = backup.name()
|
||||
stored_backup[KEY_NOTE] = backup.note()
|
||||
if self_created:
|
||||
# Remove the pending backup info from the cache so it doesn't get reused.
|
||||
del self._data_cache.backups[KEY_PENDING]
|
||||
# bump the last seen time
|
||||
self._data_cache.backup(backup.slug())[KEY_LAST_SEEN] = self.time.now().isoformat()
|
||||
self._data_cache.makeDirty()
|
||||
|
||||
async def delete(self, backup: Backup):
|
||||
slug = self._validateBackup(backup).slug()
|
||||
logger.info("Deleting '{0}' from Home Assistant".format(backup.name()))
|
||||
await self.harequests.delete(slug)
|
||||
backup.removeSource(self.name())
|
||||
|
||||
async def ignore(self, backup: Backup, ignore: bool):
|
||||
slug = self._validateBackup(backup).slug()
|
||||
logger.info("Updating ignore settings for '{0}'".format(backup.name()))
|
||||
self._data_cache.backup(slug)[KEY_IGNORE] = ignore
|
||||
self._data_cache.makeDirty()
|
||||
|
||||
async def note(self, backup, note: str) -> None:
|
||||
if isinstance(backup, HABackup):
|
||||
validated = backup
|
||||
else:
|
||||
validated = self._validateBackup(backup)
|
||||
logger.debug(f"Adding a note to ha backup '{validated.name()}'")
|
||||
if isinstance(validated, PendingBackup):
|
||||
# The ntoe will get set once the backup is created and we know the slug
|
||||
validated.setNote(note)
|
||||
else:
|
||||
self._data_cache.backup(validated.slug())[KEY_NOTE] = note
|
||||
self._data_cache.makeDirty()
|
||||
validated.setNote(note)
|
||||
return await super().note(backup, note)
|
||||
|
||||
async def save(self, backup: Backup, source: AsyncHttpGetter) -> HABackup:
|
||||
logger.info("Downloading '{0}'".format(backup.name()))
|
||||
self._info.upload(0)
|
||||
resp = None
|
||||
try:
|
||||
backup.overrideStatus("Loading {0}%", source)
|
||||
backup.setUploadSource(self.title(), source)
|
||||
async with source:
|
||||
with aiohttp.MultipartWriter('mixed') as mpwriter:
|
||||
mpwriter.append(source, {'CONTENT-TYPE': 'application/tar'})
|
||||
resp = await self.harequests.upload(mpwriter)
|
||||
backup.clearStatus()
|
||||
backup.clearUploadSource()
|
||||
except Exception as e:
|
||||
logger.printException(e)
|
||||
backup.overrideStatus("Failed!")
|
||||
backup.uploadFailure(logger.formatException(e))
|
||||
if resp and 'slug' in resp and resp['slug'] == backup.slug():
|
||||
self.config.setRetained(backup.slug(), True)
|
||||
return await self.harequests.backup(backup.slug())
|
||||
else:
|
||||
raise UploadFailed()
|
||||
|
||||
async def read(self, backup: Backup) -> IOBase:
|
||||
item = self._validateBackup(backup)
|
||||
return await self.harequests.download(item.slug())
|
||||
|
||||
async def retain(self, backup: Backup, retain: bool) -> None:
|
||||
item: HABackup = self._validateBackup(backup)
|
||||
item._retained = retain
|
||||
self.config.setRetained(backup.slug(), retain)
|
||||
|
||||
async def init(self):
|
||||
await self._refreshInfo()
|
||||
self._initialized = True
|
||||
|
||||
async def refresh(self):
|
||||
await self._refreshInfo()
|
||||
|
||||
async def _refreshInfo(self) -> None:
|
||||
try:
|
||||
self.self_info = await self.harequests.selfInfo()
|
||||
self.host_info = await self.harequests.info()
|
||||
self.ha_info = await self.harequests.haInfo()
|
||||
self.super_info = await self.harequests.supervisorInfo()
|
||||
addon_info = ensureKey("addons", await self.harequests.getAddons(), "Supervisor Metadata")
|
||||
self.config.update(
|
||||
ensureKey("options", self.self_info, "addon metdata"))
|
||||
if self.config.mustSaveUpgradeChanges():
|
||||
LOGGER.info("The configuration format has changed in this version of the addon and your configuration will be automatically updated")
|
||||
options = {}
|
||||
for option in self.config.getAllConfig().keys():
|
||||
options[option.value] = self.config.get(option)
|
||||
await self.harequests.updateConfig(options)
|
||||
self.config.persistedChanges()
|
||||
|
||||
self._info.ha_port = ensureKey(
|
||||
"port", self.ha_info, "Home Assistant metadata")
|
||||
self._info.ha_ssl = ensureKey(
|
||||
"ssl", self.ha_info, "Home Assistant metadata")
|
||||
self._info.addons = addon_info
|
||||
self._info.slug = ensureKey(
|
||||
"slug", self.self_info, "addon metdata")
|
||||
self._info.url = self.getAddonUrl()
|
||||
|
||||
self._addons = {}
|
||||
for addon in addon_info:
|
||||
self._addons[addon.get('slug', "default")] = addon
|
||||
|
||||
self._info.addDebugInfo("self_info", self.self_info)
|
||||
self._info.addDebugInfo("host_info", self.host_info)
|
||||
self._info.addDebugInfo("ha_info", self.ha_info)
|
||||
self._info.addDebugInfo("super_info", self.super_info)
|
||||
except Exception as e:
|
||||
logger.debug("Failed to connect to supervisor")
|
||||
logger.debug(logger.formatException(e))
|
||||
raise e
|
||||
|
||||
def addonHasLogo(self, slug):
|
||||
return self._addons.get(slug, {}).get('logo', False)
|
||||
|
||||
def getAddonUrl(self):
|
||||
"""
|
||||
Returns the relative path to the add-on, for the purpose of linking to the add-on page from within Home Assistant.
|
||||
"""
|
||||
if self._info.slug is None:
|
||||
return ""
|
||||
return "/hassio/ingress/" + str(self._info.slug)
|
||||
|
||||
def getHostInfo(self):
|
||||
if not self.isInitialized():
|
||||
return {}
|
||||
return self.host_info
|
||||
|
||||
def getFullAddonUrl(self):
|
||||
if not self.isInitialized():
|
||||
return ""
|
||||
return self._haUrl() + "hassio/ingress/" + str(self._info.slug)
|
||||
|
||||
def getHomeAssistantUrl(self):
|
||||
if not self.isInitialized():
|
||||
return ""
|
||||
return self._haUrl()
|
||||
|
||||
def _haUrl(self):
|
||||
if self._info.ha_ssl:
|
||||
protocol = "https://"
|
||||
else:
|
||||
protocol = "http://"
|
||||
return "".join([protocol, "{host}:", str(self._info.ha_port), "/"])
|
||||
|
||||
def _validateBackup(self, backup) -> HABackup:
|
||||
item: HABackup = backup.getSource(self.name())
|
||||
if not item:
|
||||
raise LogicError(
|
||||
"Requested to do something with a backup from Home Assistant, but the backup has no Home Assistant source")
|
||||
return item
|
||||
|
||||
def _killPending(self):
|
||||
self.pending_backup = None
|
||||
if self._pending_backup_task and not self._pending_backup_task.done():
|
||||
self._pending_backup_task.cancel()
|
||||
|
||||
def postSync(self):
|
||||
self.stopper.allowRun()
|
||||
self.stopper.isBackingUp(self.pending_backup is not None)
|
||||
|
||||
async def _requestAsync(self, pending: PendingBackup, start=[]) -> None:
|
||||
try:
|
||||
result = await asyncio.wait_for(self.harequests.createBackup(pending._request_info), timeout=self.config.get(Setting.PENDING_BACKUP_TIMEOUT_SECONDS))
|
||||
slug = ensureKey(
|
||||
"slug", result, "supervisor's create backup response")
|
||||
pending.complete(slug)
|
||||
self.config.setRetained(
|
||||
slug, pending.getOptions().retain_sources.get(self.name(), False))
|
||||
logger.info("Backup finished")
|
||||
except Exception as e:
|
||||
if self._isHttp400(e):
|
||||
logger.warning("A backup was already in progress")
|
||||
pending.setPendingUnknown()
|
||||
else:
|
||||
logger.error("Backup failed:")
|
||||
logger.printException(e)
|
||||
pending.failed(e, self.time.now())
|
||||
finally:
|
||||
await self.stopper.startAddons()
|
||||
self.trigger()
|
||||
|
||||
def _buildBackupInfo(self, options: CreateOptions):
|
||||
addons: List[str] = []
|
||||
for addon in self.super_info.get('addons', {}):
|
||||
addons.append(addon['slug'])
|
||||
request_info = {
|
||||
'addons': [],
|
||||
'folders': []
|
||||
}
|
||||
folders = list(map(lambda f: f['slug'], FOLDERS))
|
||||
type_name = "Full"
|
||||
for folder in folders:
|
||||
if folder not in self.config.get(Setting.EXCLUDE_FOLDERS):
|
||||
request_info['folders'].append(folder)
|
||||
else:
|
||||
type_name = "Partial"
|
||||
for addon in addons:
|
||||
if addon not in self.config.get(Setting.EXCLUDE_ADDONS):
|
||||
request_info['addons'].append(addon)
|
||||
else:
|
||||
type_name = "Partial"
|
||||
if type_name == "Full":
|
||||
del request_info['addons']
|
||||
del request_info['folders']
|
||||
protected = False
|
||||
password = Password(self.config).resolve()
|
||||
if password:
|
||||
request_info['password'] = password
|
||||
name = BackupName().resolve(type_name, options.name_template,
|
||||
self.time.toLocal(options.when), self.host_info)
|
||||
request_info['name'] = name
|
||||
return request_info, type_name, protected
|
||||
@@ -0,0 +1,189 @@
|
||||
from datetime import timedelta
|
||||
|
||||
from aiohttp.client_exceptions import ClientResponseError
|
||||
from injector import inject, singleton
|
||||
|
||||
from ..model import Coordinator, Backup
|
||||
from ..config import Config, Setting
|
||||
from ..util import GlobalInfo, Backoff, Estimator
|
||||
from .harequests import HaRequests
|
||||
from ..time import Time
|
||||
from ..worker import Worker
|
||||
from ..const import SOURCE_HA, SOURCE_GOOGLE_DRIVE
|
||||
from ..logger import getLogger
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
NOTIFICATION_TITLE = "Home Assistant Google Drive Backup is Having Trouble"
|
||||
NOTIFICATION_DESC_LINK = "The add-on is having trouble making backups and needs attention. Please visit the add-on [status page]({0}) for details."
|
||||
NOTIFICATION_DESC_STATIC = "The add-on is having trouble making backups and needs attention. Please visit the add-on status page for details."
|
||||
|
||||
MAX_BACKOFF = 60 * 5 # 5 minutes
|
||||
FIRST_BACKOFF = 60 # 1 minute
|
||||
|
||||
# Wait 5 minutes before logging
|
||||
NOTIFY_DELAY = 60 * 5 # 5 minute
|
||||
|
||||
OLD_BACKUP_ENTITY_NAME = "sensor.snapshot_backup"
|
||||
BACKUP_ENTITY_NAME = "sensor.backup_state"
|
||||
|
||||
REASSURING_MESSAGE = "Unable to reach Home Assistant (HTTP {0}). This is normal if Home Assistant is restarting. You will probably see some errors in the supervisor logs until it comes back online."
|
||||
|
||||
|
||||
@singleton
|
||||
class HaUpdater(Worker):
|
||||
@inject
|
||||
def __init__(self, requests: HaRequests, coordinator: Coordinator, config: Config, time: Time, global_info: GlobalInfo):
|
||||
self._config = config
|
||||
super().__init__("Sensor Updater", self.update, time, self.getInterval)
|
||||
self._time = time
|
||||
self._coordinator = coordinator
|
||||
self._requests: HaRequests = requests
|
||||
self._info = global_info
|
||||
self._notified = False
|
||||
self._backoff = Backoff(max=MAX_BACKOFF, base=FIRST_BACKOFF)
|
||||
self._first_error = None
|
||||
self._trigger_once = False
|
||||
|
||||
self._last_backup_update = None
|
||||
self.last_backup_update_time = time.now() - timedelta(days=1)
|
||||
self._config.subscribe(self.config_updated)
|
||||
self._last_interval = self.getInterval()
|
||||
|
||||
def config_updated(self):
|
||||
if self._last_interval != self.getInterval():
|
||||
self._wait_event.set()
|
||||
self._last_interval = self.getInterval()
|
||||
|
||||
def getInterval(self):
|
||||
return self._config.get(Setting.HA_REPORTING_INTERVAL_SECONDS)
|
||||
|
||||
async def update(self):
|
||||
try:
|
||||
if self._config.get(Setting.ENABLE_BACKUP_STALE_SENSOR):
|
||||
await self._requests.updateBackupStaleSensor('on' if self._stale() else 'off')
|
||||
if self._config.get(Setting.ENABLE_BACKUP_STATE_SENSOR):
|
||||
await self._maybeSendBackupUpdate()
|
||||
if self._config.get(Setting.NOTIFY_FOR_STALE_BACKUPS):
|
||||
if self._stale() and not self._notified:
|
||||
if self._info.url is None or len(self._info.url) == 0:
|
||||
message = NOTIFICATION_DESC_STATIC
|
||||
else:
|
||||
message = NOTIFICATION_DESC_LINK.format(self._info.url)
|
||||
await self._requests.sendNotification(NOTIFICATION_TITLE, message)
|
||||
self._notified = True
|
||||
elif not self._stale() and self._notified:
|
||||
await self._requests.dismissNotification()
|
||||
self._notified = False
|
||||
self._backoff.reset()
|
||||
self._first_error = None
|
||||
self._trigger_once = False
|
||||
except ClientResponseError as e:
|
||||
if self._first_error is None:
|
||||
self._first_error = self._time.now()
|
||||
if int(e.status / 100) == 5:
|
||||
if self._time.now() > self._first_error + timedelta(seconds=NOTIFY_DELAY):
|
||||
logger.error(
|
||||
"Unable to reach Home Assistant (HTTP {0}). This is normal if Home Assistant is restarting. You will probably see some errors in the supervisor logs until it comes back online.".format(e.status))
|
||||
else:
|
||||
logger.error("Trouble updating Home Assistant sensors.")
|
||||
self._last_backup_update = None
|
||||
await self._time.sleepAsync(self._backoff.backoff(e))
|
||||
except Exception as e:
|
||||
self._last_backup_update = None
|
||||
logger.error("Trouble updating Home Assistant sensors.")
|
||||
logger.printException(e)
|
||||
await self._time.sleepAsync(self._backoff.backoff(e))
|
||||
|
||||
async def _maybeSendBackupUpdate(self):
|
||||
update = self._buildBackupUpdate()
|
||||
if self._trigger_once or update != self._last_backup_update or self._time.now() > self.last_backup_update_time + timedelta(hours=1):
|
||||
if self._config.get(Setting.CALL_BACKUP_SNAPSHOT):
|
||||
await self._requests.updateEntity(OLD_BACKUP_ENTITY_NAME, update)
|
||||
else:
|
||||
await self._requests.updateEntity(BACKUP_ENTITY_NAME, update)
|
||||
self._last_backup_update = update
|
||||
self.last_backup_update_time = self._time.now()
|
||||
|
||||
def _stale(self):
|
||||
if self._info._first_sync:
|
||||
return False
|
||||
if self._info._last_error:
|
||||
return self._time.now() > self._info._last_success + timedelta(seconds=self._config.get(Setting.BACKUP_STALE_SECONDS))
|
||||
else:
|
||||
next_backup = self._coordinator.nextBackupTime(include_pending=False)
|
||||
if not next_backup:
|
||||
# no backups are configured
|
||||
return False
|
||||
|
||||
# Determine if a lot of time has passed since the last backup "should" have been made.
|
||||
warn_after = next_backup + timedelta(seconds=self._config.get(Setting.LONG_TERM_STALE_BACKUP_SECONDS))
|
||||
return self._time.now() >= warn_after
|
||||
|
||||
def _state(self):
|
||||
if self._stale():
|
||||
return "error"
|
||||
else:
|
||||
return "waiting" if self._info._first_sync else "backed_up"
|
||||
|
||||
def triggerRefresh(self):
|
||||
self._trigger_once = True
|
||||
|
||||
def _buildBackupUpdate(self):
|
||||
backups = list(filter(lambda s: not s.ignore(), self._coordinator.backups()))
|
||||
last = "Never"
|
||||
if len(backups) > 0:
|
||||
last = max(backups, key=lambda s: s.date()).date().isoformat()
|
||||
|
||||
def makeBackupData(backup: Backup):
|
||||
return {
|
||||
"name": backup.name(),
|
||||
"date": str(backup.date().isoformat()),
|
||||
"state": backup.status(),
|
||||
"size": backup.sizeString(),
|
||||
"slug": backup.slug()
|
||||
}
|
||||
ha_backups = list(filter(lambda s: s.getSource(SOURCE_HA) is not None, backups))
|
||||
drive_backups = list(filter(lambda s: s.getSource(SOURCE_GOOGLE_DRIVE) is not None, backups))
|
||||
|
||||
last_uploaded = "Never"
|
||||
if len(drive_backups) > 0:
|
||||
last_uploaded = max(drive_backups, key=lambda s: s.date()).date().isoformat()
|
||||
if self._config.get(Setting.CALL_BACKUP_SNAPSHOT):
|
||||
return {
|
||||
"state": self._state(),
|
||||
"attributes": {
|
||||
"friendly_name": "Snapshot State",
|
||||
"last_snapshot": last, # type: ignore
|
||||
"snapshots_in_google_drive": len(drive_backups),
|
||||
"snapshots_in_hassio": len(ha_backups),
|
||||
"snapshots_in_home_assistant": len(ha_backups),
|
||||
"size_in_google_drive": Estimator.asSizeString(sum(map(lambda v: v.sizeInt(), drive_backups))),
|
||||
"size_in_home_assistant": Estimator.asSizeString(sum(map(lambda v: v.sizeInt(), ha_backups))),
|
||||
"snapshots": list(map(makeBackupData, backups))
|
||||
}
|
||||
}
|
||||
else:
|
||||
source_metrics = self._coordinator.buildBackupMetrics()
|
||||
next = self._coordinator.nextBackupTime()
|
||||
if next is not None:
|
||||
next = next.isoformat()
|
||||
attr = {
|
||||
"friendly_name": "Backup State",
|
||||
"last_backup": last, # type: ignore
|
||||
"next_backup": next,
|
||||
"last_uploaded": last_uploaded,
|
||||
"backups_in_google_drive": len(drive_backups),
|
||||
"backups_in_home_assistant": len(ha_backups),
|
||||
"size_in_google_drive": Estimator.asSizeString(sum(map(lambda v: v.sizeInt(), drive_backups))),
|
||||
"size_in_home_assistant": Estimator.asSizeString(sum(map(lambda v: v.sizeInt(), ha_backups))),
|
||||
"backups": list(map(makeBackupData, backups))
|
||||
}
|
||||
if SOURCE_GOOGLE_DRIVE in source_metrics and 'free_space' in source_metrics[SOURCE_GOOGLE_DRIVE]:
|
||||
attr["free_space_in_google_drive"] = source_metrics[SOURCE_GOOGLE_DRIVE]['free_space']
|
||||
else:
|
||||
attr["free_space_in_google_drive"] = ""
|
||||
return {
|
||||
"state": self._state(),
|
||||
"attributes": attr
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import os
|
||||
|
||||
import yaml
|
||||
|
||||
from ..config import Config, Setting
|
||||
from ..exceptions import BackupPasswordKeyInvalid
|
||||
from ..logger import getLogger
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
|
||||
class Password():
|
||||
def __init__(self, config: Config):
|
||||
self.config = config
|
||||
|
||||
def resolve(self, password=None):
|
||||
if password is None:
|
||||
password = self.config.get(Setting.BACKUP_PASSWORD)
|
||||
if len(password) == 0:
|
||||
return None
|
||||
if password.startswith("!secret "):
|
||||
if not os.path.isfile(self.config.get(Setting.SECRETS_FILE_PATH)):
|
||||
raise BackupPasswordKeyInvalid()
|
||||
with open(self.config.get(Setting.SECRETS_FILE_PATH)) as f:
|
||||
secrets_yaml = yaml.load(f, Loader=yaml.SafeLoader)
|
||||
key = password[len("!secret "):]
|
||||
if key not in secrets_yaml:
|
||||
raise BackupPasswordKeyInvalid()
|
||||
return str(secrets_yaml[key])
|
||||
else:
|
||||
return password
|
||||
@@ -0,0 +1,217 @@
|
||||
import logging
|
||||
from logging import LogRecord, Formatter, ERROR
|
||||
from traceback import TracebackException
|
||||
from colorlog import ColoredFormatter
|
||||
from os.path import join, abspath
|
||||
|
||||
HISTORY_SIZE = 1000
|
||||
PATH_BASE = abspath(join(__file__, "..", ".."))
|
||||
|
||||
logging.addLevelName(5, "TRACE")
|
||||
logging.TRACE = 5
|
||||
|
||||
|
||||
class HistoryHandler(logging.Handler):
|
||||
def __init__(self):
|
||||
super(HistoryHandler, self).__init__()
|
||||
self.history = [None] * HISTORY_SIZE
|
||||
self.history_index = 0
|
||||
|
||||
def reset(self):
|
||||
self.history = [None] * HISTORY_SIZE
|
||||
self.history_index = 0
|
||||
|
||||
def emit(self, record: LogRecord):
|
||||
self.history[self.history_index % HISTORY_SIZE] = record
|
||||
self.history_index += 1
|
||||
|
||||
def getHistory(self, start=0, html=False):
|
||||
end = self.history_index
|
||||
if end - start >= HISTORY_SIZE:
|
||||
start = end - HISTORY_SIZE
|
||||
for x in range(start, end):
|
||||
item = self.history[x % HISTORY_SIZE]
|
||||
if html:
|
||||
if item.levelno == logging.WARN:
|
||||
style = "console-warning"
|
||||
elif item.levelno == logging.ERROR:
|
||||
style = "console-error"
|
||||
elif item.levelno == logging.DEBUG:
|
||||
style = "console-debug"
|
||||
elif item.levelno == logging.CRITICAL:
|
||||
style = "console-critical"
|
||||
elif item.levelno == logging.FATAL:
|
||||
style = "console-fatal"
|
||||
elif item.levelno == logging.WARNING:
|
||||
style = "console-warning"
|
||||
elif item.levelno == logging.TRACE:
|
||||
style = "console-trace"
|
||||
else:
|
||||
style = "console-default"
|
||||
line = "<span class='" + style + \
|
||||
"'>" + self.format(item) + "</span>"
|
||||
yield (x + 1, line)
|
||||
else:
|
||||
yield (x + 1, self.format(item))
|
||||
|
||||
def getLast(self) -> LogRecord:
|
||||
return self.history[(self.history_index - 1) % HISTORY_SIZE]
|
||||
|
||||
|
||||
CONSOLE = logging.StreamHandler()
|
||||
CONSOLE.setLevel(logging.INFO)
|
||||
formatter_color = ColoredFormatter(
|
||||
'%(log_color)s%(asctime)s %(levelname)s %(message)s%(reset)s',
|
||||
datefmt='%m-%d %H:%M:%S',
|
||||
reset=True,
|
||||
log_colors={
|
||||
"DEBUG": "cyan",
|
||||
"INFO": "green",
|
||||
"WARNING": "yellow",
|
||||
"ERROR": "red",
|
||||
"CRITICAL": "red",
|
||||
"TRACE": "white",
|
||||
},
|
||||
)
|
||||
CONSOLE.setFormatter(formatter_color)
|
||||
|
||||
HISTORY = HistoryHandler()
|
||||
HISTORY.setLevel(logging.DEBUG)
|
||||
HISTORY.setFormatter(Formatter('%(asctime)s %(levelname)s [%(name)s] %(message)s', '%m-%d %H:%M:%S'))
|
||||
|
||||
|
||||
class StandardLogger(logging.Logger):
|
||||
def __init__(self, name):
|
||||
super().__init__(name)
|
||||
self.setLevel(logging.TRACE)
|
||||
self.addHandler(CONSOLE)
|
||||
self.addHandler(HISTORY)
|
||||
|
||||
def trace(self, msg, *args, **kwargs):
|
||||
self.log(logging.TRACE, msg, *args, **kwargs)
|
||||
|
||||
def printException(self, ex: Exception, level=ERROR):
|
||||
self.log(level, self.formatException(ex))
|
||||
|
||||
def formatException(self, e: Exception) -> str:
|
||||
trace = None
|
||||
if (hasattr(e, "__traceback__")):
|
||||
trace = e.__traceback__
|
||||
tbe = TracebackException(type(e), e, trace, limit=None)
|
||||
lines = list(self._format(tbe))
|
||||
return '\n%s' % ''.join(lines)
|
||||
|
||||
def _format(self, tbe):
|
||||
if (tbe.__context__ is not None and not tbe.__suppress_context__):
|
||||
yield from self._format(tbe.__context__)
|
||||
yield "Whose handling caused:\n"
|
||||
is_addon, stack = self._formatStack(tbe)
|
||||
yield from stack
|
||||
yield from tbe.format_exception_only()
|
||||
|
||||
def _formatStack(self, tbe):
|
||||
_RECURSIVE_CUTOFF = 3
|
||||
result = []
|
||||
last_file = None
|
||||
last_line = None
|
||||
last_name = None
|
||||
count = 0
|
||||
is_addon = False
|
||||
buffer = []
|
||||
for frame in tbe.stack:
|
||||
line_internal = True
|
||||
if (last_file is None or last_file != frame.filename or last_line is None or last_line != frame.lineno or last_name is None or last_name != frame.name):
|
||||
if count > _RECURSIVE_CUTOFF:
|
||||
count -= _RECURSIVE_CUTOFF
|
||||
result.append(
|
||||
f' [Previous line repeated {count} more '
|
||||
f'time{"s" if count > 1 else ""}]\n'
|
||||
)
|
||||
last_file = frame.filename
|
||||
last_line = frame.lineno
|
||||
last_name = frame.name
|
||||
count = 0
|
||||
count += 1
|
||||
if count > _RECURSIVE_CUTOFF:
|
||||
continue
|
||||
fileName = frame.filename
|
||||
pos = fileName.rfind(PATH_BASE)
|
||||
if pos >= 0:
|
||||
is_addon = True
|
||||
line_internal = False
|
||||
fileName = "addon" + \
|
||||
fileName[pos + len(PATH_BASE):]
|
||||
|
||||
pos = fileName.rfind("site-packages")
|
||||
if pos > 0:
|
||||
fileName = fileName[pos - 1:]
|
||||
|
||||
pos = fileName.rfind("python3.7")
|
||||
if pos > 0:
|
||||
fileName = fileName[pos - 1:]
|
||||
pass
|
||||
line = ' {}:{} ({})\n'.format(fileName, frame.lineno, frame.name)
|
||||
if line_internal:
|
||||
buffer.append(line)
|
||||
else:
|
||||
result.extend(self._compressFrames(buffer))
|
||||
buffer = []
|
||||
result.append(line)
|
||||
if count > _RECURSIVE_CUTOFF:
|
||||
count -= _RECURSIVE_CUTOFF
|
||||
result.append(
|
||||
f' [Previous line repeated {count} more '
|
||||
f'time{"s" if count > 1 else ""}]\n'
|
||||
)
|
||||
result.extend(self._compressFrames(buffer))
|
||||
return is_addon, result
|
||||
|
||||
def overrideLevel(self, console, history):
|
||||
CONSOLE.setLevel(console)
|
||||
HISTORY.setLevel(history)
|
||||
|
||||
def _compressFrames(self, buffer):
|
||||
if len(buffer) > 1:
|
||||
yield buffer[0]
|
||||
if len(buffer) == 3:
|
||||
yield buffer[1]
|
||||
elif len(buffer) > 2:
|
||||
yield " [{} hidden frames]\n".format(len(buffer) - 2)
|
||||
yield buffer[len(buffer) - 1]
|
||||
elif len(buffer) > 0:
|
||||
yield buffer[len(buffer) - 1]
|
||||
pass
|
||||
|
||||
|
||||
def getLogger(name):
|
||||
return StandardLogger(name)
|
||||
|
||||
|
||||
def getHistory(index, html):
|
||||
return HISTORY.getHistory(index, html)
|
||||
|
||||
|
||||
def getLast() -> LogRecord:
|
||||
return HISTORY.getLast()
|
||||
|
||||
|
||||
def reset() -> None:
|
||||
return HISTORY.reset()
|
||||
|
||||
|
||||
class TraceLogger(StandardLogger):
|
||||
def __init__(self, name):
|
||||
super().__init__(name)
|
||||
self.setLevel(logging.TRACE)
|
||||
|
||||
def log(self, lvl, msg, *args, **kwargs):
|
||||
super().log(logging.TRACE, msg, *args, **kwargs)
|
||||
|
||||
def info(self, *args, **kwargs):
|
||||
super().log(logging.TRACE, *args, **kwargs)
|
||||
|
||||
def error(self, *args, **kwargs):
|
||||
super().log(logging.TRACE, *args, **kwargs)
|
||||
|
||||
def warn(self, *args, **kwargs):
|
||||
super().log(logging.TRACE, *args, **kwargs)
|
||||
@@ -0,0 +1,13 @@
|
||||
# flake8: noqa
|
||||
from .backupscheme import GenerationalScheme, OldestScheme, GenConfig, BackupScheme
|
||||
from .coordinator import Coordinator
|
||||
from .model import BackupSource, BackupDestination, Model
|
||||
from .syncer import Scyncer
|
||||
from .backups import AbstractBackup, Backup
|
||||
from .drivebackup import DriveBackup
|
||||
from .dummybackup import DummyBackup
|
||||
from .dummybackupsource import DummyBackupSource
|
||||
from .habackup import HABackup
|
||||
from .simulatedsource import SimulatedSource
|
||||
from .precache import Precache
|
||||
from .destinationprecache import DestinationPrecache
|
||||
@@ -0,0 +1,318 @@
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, Optional
|
||||
from dateutil.tz import tzutc
|
||||
from ..util import Estimator
|
||||
|
||||
from ..const import SOURCE_GOOGLE_DRIVE, SOURCE_HA
|
||||
from ..logger import getLogger
|
||||
from ..config import CreateOptions
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
PROP_TYPE = "type"
|
||||
PROP_VERSION = "version"
|
||||
PROP_PROTECTED = "protected"
|
||||
PROP_RETAINED = "retained"
|
||||
PROP_NOTE = "note"
|
||||
|
||||
DRIVE_KEY_TEXT = "Google Drive's backup metadata"
|
||||
HA_KEY_TEXT = "Home Assistant's backup metadata"
|
||||
|
||||
|
||||
class AbstractBackup():
|
||||
def __init__(self, name: str, slug: str, source: str, date: str, size: int, version: str, backupType: str, protected: bool, note=None, retained: bool = False, uploadable: bool = False, details={}, pending=False):
|
||||
self._options = None
|
||||
self._name = name
|
||||
self._slug = slug
|
||||
self._source = source
|
||||
self._date = date
|
||||
self._size = size
|
||||
self._retained = retained
|
||||
self._uploadable = uploadable
|
||||
self._details = details
|
||||
self._version = version
|
||||
self._backupType = backupType
|
||||
self._protected = protected
|
||||
self._ignore = False
|
||||
self._note = note
|
||||
self._pending = pending
|
||||
|
||||
def isPending(self):
|
||||
return self._pending
|
||||
|
||||
def setOptions(self, options: CreateOptions):
|
||||
self._options = options
|
||||
|
||||
def getOptions(self) -> CreateOptions:
|
||||
return self._options
|
||||
|
||||
def name(self) -> str:
|
||||
return self._name
|
||||
|
||||
def slug(self) -> str:
|
||||
return self._slug
|
||||
|
||||
def size(self) -> int:
|
||||
return self._size
|
||||
|
||||
def note(self) -> str:
|
||||
return self._note
|
||||
|
||||
def sizeInt(self) -> int:
|
||||
try:
|
||||
return int(self.size())
|
||||
except ValueError:
|
||||
return 0
|
||||
|
||||
def date(self) -> datetime:
|
||||
return self._date
|
||||
|
||||
def source(self) -> str:
|
||||
return self._source
|
||||
|
||||
def retained(self) -> str:
|
||||
return self._retained
|
||||
|
||||
def version(self):
|
||||
return self._version
|
||||
|
||||
def backupType(self):
|
||||
return self._backupType
|
||||
|
||||
def protected(self):
|
||||
return self._protected
|
||||
|
||||
def setRetained(self, retained):
|
||||
self._retained = retained
|
||||
|
||||
def uploadable(self) -> bool:
|
||||
return self._uploadable
|
||||
|
||||
def considerForPurge(self) -> bool:
|
||||
return not self.retained()
|
||||
|
||||
def setUploadable(self, uploadable):
|
||||
self._uploadable = uploadable
|
||||
|
||||
def details(self):
|
||||
return self._details
|
||||
|
||||
def setNote(self, note: str):
|
||||
self._note = note
|
||||
|
||||
def status(self):
|
||||
return None
|
||||
|
||||
def madeByTheAddon(self):
|
||||
return True
|
||||
|
||||
def ignore(self):
|
||||
return self._ignore
|
||||
|
||||
def setIgnore(self, ignore):
|
||||
self._ignore = ignore
|
||||
|
||||
|
||||
class Backup(object):
|
||||
"""
|
||||
Represents a Home Assistant backup stored on Google Drive, locally in
|
||||
Home Assistant, or a pending backup we expect to see show up later
|
||||
"""
|
||||
|
||||
def __init__(self, backup: Optional[AbstractBackup] = None):
|
||||
self.sources: Dict[str, AbstractBackup] = {}
|
||||
self._purgeNext: Dict[str, bool] = {}
|
||||
self._options = None
|
||||
self._status_override = None
|
||||
self._status_override_args = None
|
||||
self._state_detail = None
|
||||
self._upload_source = None
|
||||
self._upload_source_name = None
|
||||
self._upload_fail_info = None
|
||||
if backup is not None:
|
||||
self.addSource(backup)
|
||||
|
||||
def setOptions(self, options):
|
||||
self._options = options
|
||||
|
||||
def getOptions(self):
|
||||
return self._options
|
||||
|
||||
def updatePurge(self, source: str, purge: bool):
|
||||
self._purgeNext[source] = purge
|
||||
|
||||
def addSource(self, backup: AbstractBackup):
|
||||
self.sources[backup.source()] = backup
|
||||
if backup.getOptions() and not self.getOptions():
|
||||
self.setOptions(backup.getOptions())
|
||||
|
||||
def getStatusDetail(self):
|
||||
return self._state_detail
|
||||
|
||||
def setStatusDetail(self, info):
|
||||
self._state_detail = info
|
||||
|
||||
def removeSource(self, source):
|
||||
if source in self.sources:
|
||||
del self.sources[source]
|
||||
if source in self._purgeNext:
|
||||
del self._purgeNext[source]
|
||||
|
||||
def getPurges(self):
|
||||
return self._purgeNext
|
||||
|
||||
def uploadInfo(self):
|
||||
if not self._upload_source:
|
||||
return {}
|
||||
elif self._upload_source.progress() == 100:
|
||||
return {}
|
||||
else:
|
||||
return {
|
||||
'progress': self._upload_source.progress()
|
||||
}
|
||||
|
||||
def getSource(self, source: str):
|
||||
return self.sources.get(source, None)
|
||||
|
||||
def name(self):
|
||||
for backup in self.sources.values():
|
||||
return backup.name()
|
||||
return "error"
|
||||
|
||||
def note(self):
|
||||
longest = None
|
||||
for backup in self.sources.values():
|
||||
if backup.note() is not None and (longest is None or len(backup.note()) > len(longest)):
|
||||
longest = backup.note()
|
||||
return longest
|
||||
|
||||
def slug(self) -> str:
|
||||
for backup in self.sources.values():
|
||||
return backup.slug()
|
||||
return "error"
|
||||
|
||||
def size(self) -> int:
|
||||
for backup in self.sources.values():
|
||||
return backup.size()
|
||||
return 0
|
||||
|
||||
def sizeInt(self) -> int:
|
||||
for backup in self.sources.values():
|
||||
return backup.sizeInt()
|
||||
return 0
|
||||
|
||||
def backupType(self) -> str:
|
||||
for backup in self.sources.values():
|
||||
return backup.backupType()
|
||||
return "error"
|
||||
|
||||
def version(self) -> str:
|
||||
for backup in self.sources.values():
|
||||
if backup.version() is not None:
|
||||
return backup.version()
|
||||
return None
|
||||
|
||||
def details(self):
|
||||
for backup in self.sources.values():
|
||||
if backup.details() is not None:
|
||||
return backup.details()
|
||||
return {}
|
||||
|
||||
def getUploadInfo(self, time):
|
||||
if self._upload_source_name is None:
|
||||
return None
|
||||
ret = {
|
||||
'name': self._upload_source_name
|
||||
}
|
||||
if self._upload_fail_info:
|
||||
ret['failure'] = self._upload_fail_info
|
||||
elif self._upload_source is not None:
|
||||
ret['progress'] = self._upload_source.progress()
|
||||
ret['speed'] = self._upload_source.speed(timedelta(seconds=20))
|
||||
ret['total'] = self._upload_source.position()
|
||||
ret['started'] = time.formatDelta(self._upload_source.startTime())
|
||||
return ret
|
||||
|
||||
def protected(self) -> bool:
|
||||
for backup in self.sources.values():
|
||||
return backup.protected()
|
||||
return False
|
||||
|
||||
def ignore(self) -> bool:
|
||||
for backup in self.sources.values():
|
||||
if not backup.ignore():
|
||||
return False
|
||||
return True
|
||||
|
||||
def date(self) -> datetime:
|
||||
for backup in self.sources.values():
|
||||
return backup.date()
|
||||
return datetime.now(tzutc())
|
||||
|
||||
def sizeString(self) -> str:
|
||||
size_string = self.size()
|
||||
if type(size_string) == str:
|
||||
return size_string
|
||||
return Estimator.asSizeString(size_string)
|
||||
|
||||
def status(self) -> str:
|
||||
# TODO: Drive Specific
|
||||
if self._status_override is not None:
|
||||
return self._status_override.format(*self._status_override_args)
|
||||
|
||||
for backup in self.sources.values():
|
||||
status = backup.status()
|
||||
if status:
|
||||
return status
|
||||
|
||||
inDrive = self.getSource(SOURCE_GOOGLE_DRIVE) is not None
|
||||
inHa = self.getSource(SOURCE_HA) is not None
|
||||
|
||||
if inDrive and inHa:
|
||||
return "Backed Up"
|
||||
if inDrive:
|
||||
return "Drive Only"
|
||||
if inHa:
|
||||
return "HA Only"
|
||||
return "Deleted"
|
||||
|
||||
def isDeleted(self) -> bool:
|
||||
return len(self.sources) == 0
|
||||
|
||||
def overrideStatus(self, format, *args) -> None:
|
||||
self._status_override = format
|
||||
self._status_override_args = args
|
||||
|
||||
def setUploadSource(self, source_name: str, source):
|
||||
self._upload_source = source
|
||||
self._upload_source_name = source_name
|
||||
self._upload_fail_info = None
|
||||
|
||||
def clearUploadSource(self):
|
||||
self._upload_source = None
|
||||
self._upload_source_name = None
|
||||
self._upload_fail_info = None
|
||||
|
||||
def uploadFailure(self, info):
|
||||
self._upload_source = None
|
||||
self._upload_fail_info = info
|
||||
|
||||
def clearStatus(self):
|
||||
self._status_override = None
|
||||
self._status_override_args = None
|
||||
|
||||
def isPending(self):
|
||||
for backup in self.sources.values():
|
||||
if backup.isPending():
|
||||
return True
|
||||
return False
|
||||
|
||||
def __str__(self) -> str:
|
||||
return "<Slug: {0} {1} {2}>".format(self.slug(), " ".join(self.sources), self.date().isoformat())
|
||||
|
||||
def __format__(self, format_spec: str) -> str:
|
||||
return self.__str__()
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return self.__str__()
|
||||
@@ -0,0 +1,236 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from calendar import monthrange
|
||||
from datetime import datetime, timedelta, date
|
||||
from typing import List, Optional, Sequence, Set, Tuple, Any, Union
|
||||
|
||||
from .backups import Backup
|
||||
from backup.util import RangeLookup
|
||||
from ..time import Time
|
||||
from ..config import GenConfig
|
||||
from ..logger import getLogger
|
||||
logger = getLogger(__name__)
|
||||
|
||||
|
||||
class BackupScheme(ABC):
|
||||
def __init__(self):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def getOldest(self, backups: Sequence[Backup]) -> Tuple[str, Optional[Backup]]:
|
||||
pass
|
||||
|
||||
def handleNaming(self, backups: Sequence[Backup]) -> None:
|
||||
for backup in backups:
|
||||
backup.setStatusDetail(None)
|
||||
|
||||
|
||||
class DeleteAfterUploadScheme(BackupScheme):
|
||||
def __init__(self, source: str, destinations: List[str]):
|
||||
self.source = source
|
||||
self.destinations = destinations
|
||||
|
||||
def getOldest(self, backups: List[Backup]):
|
||||
consider = []
|
||||
for backup in backups:
|
||||
uploaded = True
|
||||
if backup.getSource(self.source) is None:
|
||||
# No source, so ignore it
|
||||
uploaded = False
|
||||
for destination in self.destinations:
|
||||
if backup.getSource(destination) is None:
|
||||
# its not in destination, so ignore it
|
||||
uploaded = False
|
||||
if uploaded:
|
||||
consider.append(backup)
|
||||
|
||||
# Delete the oldest first
|
||||
return OldestScheme().getOldest(consider)
|
||||
|
||||
|
||||
class OldestScheme(BackupScheme):
|
||||
def __init__(self, count=0):
|
||||
self.count = count
|
||||
|
||||
def getOldest(self, backups: Sequence[Backup]) -> Tuple[Any, Union[Backup, None]]:
|
||||
if len(backups) <= self.count:
|
||||
return None, None
|
||||
return "default", min(backups, default=None, key=lambda s: s.date())
|
||||
|
||||
def handleNaming(self, backups: Sequence[Backup]) -> None:
|
||||
for backup in backups:
|
||||
backup.setStatusDetail(None)
|
||||
|
||||
|
||||
class Partition(object):
|
||||
def __init__(self, start: datetime, end: datetime, prefer: datetime, time: Time, details=None, delete_only: bool = False):
|
||||
self.start: datetime = start
|
||||
self.end: datetime = end
|
||||
self.prefer: datetime = prefer
|
||||
self.time = time
|
||||
self.details = details
|
||||
self.selected = None
|
||||
self._delete_only_partitions = delete_only
|
||||
|
||||
def select(self, backups: List[Backup]) -> Optional[Backup]:
|
||||
options = list(RangeLookup(backups, lambda s: s.date()).matches(self.start, self.end - timedelta(milliseconds=1)))
|
||||
|
||||
searcher = lambda s: self.day(s.date()) == self.day(self.prefer)
|
||||
|
||||
preferred = list(filter(searcher, options))
|
||||
if len(preferred) > 0:
|
||||
# If there is a backup on the "preferred" day, then use the latest backup on that day
|
||||
self.selected = max(preferred, default=None, key=Backup.date)
|
||||
else:
|
||||
# Otherwise, use the earliest backup over the valid period.
|
||||
self.selected = min(options, default=None, key=Backup.date)
|
||||
return self.selected
|
||||
|
||||
def delta(self) -> timedelta:
|
||||
return self.end - self.start
|
||||
|
||||
def day(self, date: datetime):
|
||||
# TODO: this conversion isn't time-zone safe, but is ok because we only use it to compare local day to local day.
|
||||
local = self.time.toLocal(date)
|
||||
return datetime(day=local.day, month=local.month, year=local.year)
|
||||
|
||||
# True if the partition exists only to determine why a snapshot is getting deleted.
|
||||
@property
|
||||
def is_delete_only(self):
|
||||
return self._delete_only_partitions
|
||||
|
||||
def __hash__(self):
|
||||
"""Overrides the default implementation"""
|
||||
return hash(tuple(sorted(self.__dict__.items())))
|
||||
|
||||
|
||||
class GenerationalScheme(BackupScheme):
|
||||
def __init__(self, time: Time, config: GenConfig, count=0):
|
||||
self.count = count
|
||||
self.time: Time = time
|
||||
self.config = config
|
||||
|
||||
def _buildPartitions(self, backups_input):
|
||||
backups: List[Backup] = list(backups_input)
|
||||
|
||||
# build the list of dates we should partition by
|
||||
day_of_week = 3
|
||||
weekday_lookup = {
|
||||
'mon': 0,
|
||||
'tue': 1,
|
||||
'wed': 2,
|
||||
'thu': 3,
|
||||
'fri': 4,
|
||||
'sat': 5,
|
||||
'sun': 6,
|
||||
}
|
||||
if self.config.day_of_week in weekday_lookup:
|
||||
day_of_week = weekday_lookup[self.config.day_of_week]
|
||||
|
||||
last = self.time.toLocal(backups[len(backups) - 1].date())
|
||||
lookups: List[Partition] = []
|
||||
currentDay = self.day(last)
|
||||
if self.config.days > 0:
|
||||
for x in range(0, self.config.days + 1):
|
||||
nextDay = self.day(currentDay, add_days=1)
|
||||
lookups.append(
|
||||
Partition(currentDay, nextDay, currentDay, self.time, "Day {0} of {1}".format(x + 1, self.config.days), delete_only=(x >= self.config.days)))
|
||||
currentDay = self.day(currentDay, add_days=-1)
|
||||
|
||||
if self.config.weeks > 0:
|
||||
for x in range(0, self.config.weeks + 1):
|
||||
# Start at the first monday preceeding the last backup
|
||||
start = self.time.local(last.year, last.month, last.day)
|
||||
start = self.day(start, add_days=-1 * start.weekday())
|
||||
|
||||
# Move back x weeks
|
||||
start = self.day(start, add_days=-7 * x)
|
||||
end = self.day(start, add_days=7)
|
||||
|
||||
# Only consider backups from that week after the start day
|
||||
# TODO: should this actually "prefer" the day of week but start on monday?
|
||||
start = self.day(start, add_days=day_of_week)
|
||||
lookups.append(Partition(start, end, start, self.time, "Week {0} of {1}".format(x + 1, self.config.weeks), delete_only=(x >= self.config.weeks)))
|
||||
|
||||
if self.config.months > 0:
|
||||
for x in range(0, self.config.months + 1):
|
||||
year_offset = int(x / 12)
|
||||
month_offset = int(x % 12)
|
||||
if last.month - month_offset < 1:
|
||||
year_offset = year_offset + 1
|
||||
month_offset = month_offset - 12
|
||||
start = self.time.local(
|
||||
last.year - year_offset, last.month - month_offset, 1)
|
||||
weekday, days = monthrange(start.year, start.month)
|
||||
end = start + timedelta(days=days)
|
||||
lookups.append(Partition(
|
||||
start, end, start + timedelta(days=self.config.day_of_month - 1), self.time,
|
||||
"{0} ({1} of {2} months)".format(start.strftime("%B"), x + 1, self.config.months), delete_only=(x >= self.config.months)))
|
||||
|
||||
if self.config.years > 0:
|
||||
for x in range(0, self.config.years + 1):
|
||||
start = self.time.local(last.year - x, 1, 1)
|
||||
end = self.time.local(last.year - x + 1, 1, 1)
|
||||
lookups.append(Partition(
|
||||
start, end, start + timedelta(days=self.config.day_of_year - 1), self.time,
|
||||
"{0} ({1} of {2} years)".format(start.strftime("%Y"), x + 1, self.config.years), delete_only=(x >= self.config.years)))
|
||||
|
||||
# Keep track of which backups are being saved for which time period.
|
||||
for lookup in lookups:
|
||||
lookup.select(backups)
|
||||
return lookups
|
||||
|
||||
def getOldest(self, backups: Sequence[Backup]):
|
||||
if len(backups) == 0:
|
||||
return None, None
|
||||
|
||||
sorted = list(backups)
|
||||
sorted.sort(key=lambda s: s.date())
|
||||
|
||||
partitions = self._buildPartitions(sorted)
|
||||
keepers: Set[Backup] = set()
|
||||
for part in partitions:
|
||||
if part.selected is not None and not part.is_delete_only:
|
||||
keepers.add(part.selected)
|
||||
|
||||
extras = []
|
||||
for backup in sorted:
|
||||
if backup not in keepers:
|
||||
extras.append(backup)
|
||||
|
||||
if self.config.aggressive and len(extras) > 0:
|
||||
match = min(filter(lambda p: p.selected == extras[0], partitions), key=Partition.delta, default=None)
|
||||
if match is not None:
|
||||
return match, extras[0]
|
||||
return "default", extras[0]
|
||||
|
||||
if len(sorted) <= self.count and not self.config.aggressive:
|
||||
return "default", None
|
||||
elif (self.config.aggressive or len(sorted) > self.count) and len(extras) > 0:
|
||||
return "default", min(extras, default=None, key=lambda s: s.date())
|
||||
elif len(sorted) > self.count:
|
||||
# no non-keep is invalid, so delete the oldest keeper
|
||||
return "default", min(keepers, default=None, key=lambda s: s.date())
|
||||
return None, None
|
||||
|
||||
def handleNaming(self, backups: Sequence[Backup]) -> None:
|
||||
sorted = list(backups)
|
||||
sorted.sort(key=lambda s: s.date())
|
||||
for backup in sorted:
|
||||
backup.setStatusDetail(None)
|
||||
# Ignored snapshots should have their label cleared in case
|
||||
# it was added previosuly, but should not get new labels
|
||||
unignored = list(filter(lambda s: not s.ignore(), sorted))
|
||||
|
||||
if len(unignored) == 0:
|
||||
return
|
||||
for part in self._buildPartitions(unignored):
|
||||
if part.selected is not None:
|
||||
if part.selected.getStatusDetail() is None:
|
||||
part.selected.setStatusDetail([])
|
||||
part.selected.getStatusDetail().append(part.details)
|
||||
|
||||
def day(self, utc_datetime: datetime, add_days=0):
|
||||
local = self.time.toLocal(utc_datetime)
|
||||
|
||||
local_date = date.fromordinal(date(local.year, local.month, local.day).toordinal() + add_days)
|
||||
return self.time.localize(datetime(local_date.year, local_date.month, local_date.day, 0, 0))
|
||||
@@ -0,0 +1,354 @@
|
||||
from asyncio import CancelledError, Task, create_task, wait, Event
|
||||
from datetime import timedelta
|
||||
from threading import Lock
|
||||
from typing import Dict, List
|
||||
|
||||
from injector import inject, singleton
|
||||
|
||||
from backup.config import Config, Setting, CreateOptions, DurationParser
|
||||
from backup.exceptions import (KnownError, LogicError, NoBackup, PleaseWait,
|
||||
UserCancelledError)
|
||||
from backup.util import GlobalInfo, Backoff, Estimator
|
||||
from backup.time import Time
|
||||
from backup.worker import Trigger
|
||||
from backup.logger import getLogger
|
||||
from backup.creds.creds import Creds
|
||||
from .precache import Precache
|
||||
from .model import BackupSource, Model
|
||||
from .backups import AbstractBackup, Backup, SOURCE_HA
|
||||
from random import Random
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
|
||||
@singleton
|
||||
class Coordinator(Trigger):
|
||||
@inject
|
||||
def __init__(self, model: Model, time: Time, config: Config, global_info: GlobalInfo, estimator: Estimator):
|
||||
super().__init__()
|
||||
self._model = model
|
||||
self._precache: Precache = None
|
||||
self._time = time
|
||||
self._config = config
|
||||
self._lock: Lock = Lock()
|
||||
self._global_info: GlobalInfo = global_info
|
||||
self._sources: Dict[str, BackupSource] = {
|
||||
self._model.source.name(): self._model.source,
|
||||
self._model.dest.name(): self._model.dest
|
||||
}
|
||||
self._backoff = Backoff(initial=0, base=10, max=config.get(Setting.MAX_BACKOFF_SECONDS))
|
||||
self._estimator = estimator
|
||||
self._busy = False
|
||||
self._sync_task: Task = None
|
||||
self._sync_start = Event()
|
||||
self._sync_wait = Event()
|
||||
self._sync_wait.set()
|
||||
self._random = Random()
|
||||
self._random.seed()
|
||||
self._next_sync_offset = self._random.random()
|
||||
self._global_info.triggerBackupCooldown(timedelta(minutes=self._config.get(Setting.BACKUP_STARTUP_DELAY_MINUTES)))
|
||||
self.trigger()
|
||||
|
||||
def saveCreds(self, creds: Creds):
|
||||
if not self._model.dest.enabled():
|
||||
# Since this is the first time saving credentials (eg the addon was just enabled). Hold off on
|
||||
# automatic backups for a few minutes to give the user a little while to figure out whats going on.
|
||||
self._global_info.triggerBackupCooldown(timedelta(minutes=self._config.get(Setting.BACKUP_STARTUP_DELAY_MINUTES)))
|
||||
|
||||
self._model.dest.saveCreds(creds)
|
||||
self._global_info.credsSaved()
|
||||
|
||||
def setPrecache(self, precache: Precache):
|
||||
self._precache = precache
|
||||
|
||||
def name(self):
|
||||
return "Coordinator"
|
||||
|
||||
def enabled(self) -> bool:
|
||||
return self._model.enabled()
|
||||
|
||||
def isWaitingForStartup(self):
|
||||
return self._model.waiting_for_startup
|
||||
|
||||
def ignoreStartupDelay(self):
|
||||
self._model.ignore_startup_delay = True
|
||||
|
||||
async def check(self) -> bool:
|
||||
if self._time.now() >= self.nextSyncAttempt():
|
||||
self.reset()
|
||||
return True
|
||||
else:
|
||||
return await super().check()
|
||||
|
||||
async def sync(self):
|
||||
await self._withSoftLock(lambda: self._sync_wrapper())
|
||||
|
||||
def isSyncing(self):
|
||||
task = self._sync_task
|
||||
return task is not None and not task.done()
|
||||
|
||||
def isWorkingThroughUpload(self):
|
||||
return self.isSyncing() and self._model.isWorkingThroughUpload()
|
||||
|
||||
async def waitForSyncToFinish(self):
|
||||
task = self._sync_task
|
||||
if task is not None:
|
||||
await task
|
||||
|
||||
async def cancel(self):
|
||||
task = self._sync_task
|
||||
if task is not None and not task.done():
|
||||
task.cancel()
|
||||
self.clearCaches()
|
||||
await wait([task])
|
||||
|
||||
def nextSyncAttempt(self):
|
||||
if self._global_info._last_error is not None:
|
||||
# we had an error last
|
||||
failure = self._global_info._last_failure_time
|
||||
if failure is None:
|
||||
return self._time.now() - timedelta(minutes=1)
|
||||
return failure + timedelta(seconds=self._backoff.peek())
|
||||
else:
|
||||
scheduled = self._global_info._last_success
|
||||
if scheduled is None:
|
||||
scheduled = self._time.now() - timedelta(minutes=1)
|
||||
else:
|
||||
scheduled += timedelta(seconds=self.nextSyncCheckOffset())
|
||||
next_backup = self.nextBackupTime()
|
||||
if next_backup is None:
|
||||
return scheduled
|
||||
else:
|
||||
return min(self.nextBackupTime(), scheduled)
|
||||
|
||||
def nextSyncCheckOffset(self):
|
||||
"""Determines how long we shoudl wait from the last check the refresh the cache of backups from Google Drive and Home Assistant"""
|
||||
# If we always sync MAX_SYNC_INTERVAL_SECONDS secodns after the last
|
||||
# check, then the addon in aggregate puts a really high strain on google
|
||||
# on every hour and the addon's auth servers need to be provisioned for
|
||||
# a big peak, which is epxensive. Instead we add some randomness to the time interval.
|
||||
randomness_max = self._config.get(Setting.MAX_SYNC_INTERVAL_SECONDS) * self._config.get(Setting.DEFAULT_SYNC_INTERVAL_VARIATION)
|
||||
non_randomness = self._config.get(Setting.MAX_SYNC_INTERVAL_SECONDS) - randomness_max
|
||||
|
||||
# The offset should be stable between syncs, which gets controlled by updating _next_sync_offset on each good sync
|
||||
return self._next_sync_offset * randomness_max + non_randomness
|
||||
|
||||
def nextBackupTime(self, include_pending=True):
|
||||
return self._buildModel().nextBackup(self._time.now(), include_pending)
|
||||
|
||||
def buildBackupMetrics(self):
|
||||
info = {}
|
||||
for source in self._sources:
|
||||
source_class = self._sources[source]
|
||||
source_info = {
|
||||
'backups': 0,
|
||||
'retained': 0,
|
||||
'deletable': 0,
|
||||
'name': source,
|
||||
'title': source_class.title(),
|
||||
'latest': None,
|
||||
'max': source_class.maxCount(),
|
||||
'enabled': source_class.enabled(),
|
||||
'icon': source_class.icon(),
|
||||
'ignored': 0,
|
||||
'detail': source_class.detail()
|
||||
}
|
||||
size = 0
|
||||
ignored_size = 0
|
||||
latest = None
|
||||
for backup in self.backups():
|
||||
data: AbstractBackup = backup.getSource(source)
|
||||
if data is None:
|
||||
continue
|
||||
if data.ignore() and backup.ignore():
|
||||
source_info['ignored'] += 1
|
||||
if backup.ignore():
|
||||
ignored_size += backup.size()
|
||||
continue
|
||||
source_info['backups'] += 1
|
||||
if data.retained():
|
||||
source_info['retained'] += 1
|
||||
else:
|
||||
source_info['deletable'] += 1
|
||||
if latest is None or data.date() > latest:
|
||||
latest = data.date()
|
||||
size += int(data.sizeInt())
|
||||
if latest is not None:
|
||||
source_info['latest'] = self._time.asRfc3339String(latest)
|
||||
source_info['size'] = Estimator.asSizeString(size)
|
||||
source_info['ignored_size'] = Estimator.asSizeString(ignored_size)
|
||||
free_space = source_class.freeSpace()
|
||||
if free_space is not None:
|
||||
source_info['free_space'] = Estimator.asSizeString(free_space)
|
||||
info[source] = source_info
|
||||
return info
|
||||
|
||||
async def _sync_wrapper(self):
|
||||
self._sync_task = create_task(
|
||||
self._sync(), name="Internal sync worker")
|
||||
await wait([self._sync_task])
|
||||
|
||||
async def _sync(self):
|
||||
try:
|
||||
self._sync_start.set()
|
||||
await self._sync_wait.wait()
|
||||
logger.info("Syncing Backups")
|
||||
self._global_info.sync()
|
||||
self._estimator.refresh()
|
||||
await self._buildModel().sync(self._time.now())
|
||||
self._next_sync_offset = self._random.random()
|
||||
self._global_info.success()
|
||||
self._backoff.reset()
|
||||
self._global_info.setSkipSpaceCheckOnce(False)
|
||||
except BaseException as e:
|
||||
self.handleError(e)
|
||||
finally:
|
||||
if self._precache:
|
||||
# Any sync should invalidate the precache regardless of the outcome
|
||||
# so the next sync uses fresh data
|
||||
self.clearCaches()
|
||||
self._updateFreshness()
|
||||
|
||||
def handleError(self, e):
|
||||
if isinstance(e, CancelledError):
|
||||
e = UserCancelledError()
|
||||
if isinstance(e, KnownError):
|
||||
known: KnownError = e
|
||||
logger.error(known.message())
|
||||
if known.retrySoon():
|
||||
self._backoff.backoff(e)
|
||||
else:
|
||||
self._backoff.maxOut()
|
||||
else:
|
||||
logger.printException(e)
|
||||
self._backoff.backoff(e)
|
||||
self._global_info.failed(e)
|
||||
|
||||
text = DurationParser().format(timedelta(seconds=self._backoff.peek()))
|
||||
logger.info("I'll try again in {0}".format(text))
|
||||
|
||||
def backups(self) -> List[Backup]:
|
||||
ret = list(self._model.backups.values())
|
||||
ret.sort(key=lambda s: s.date())
|
||||
return ret
|
||||
|
||||
async def uploadBackups(self, slug):
|
||||
await self._withSoftLock(lambda: self._uploadBackup(slug))
|
||||
|
||||
async def _uploadBackup(self, slug):
|
||||
self.clearCaches()
|
||||
backup = self._ensureBackup(self._model.dest.name(), slug)
|
||||
backup_dest = backup.getSource(self._model.dest.name())
|
||||
backup_source = backup.getSource(self._model.source.name())
|
||||
if backup_source:
|
||||
raise LogicError("This backup already exists in Home Assistant")
|
||||
if not backup_dest:
|
||||
# Unreachable?
|
||||
raise LogicError("This backup isn't in Google Drive")
|
||||
created = await self._model.source.save(backup, await self._model.dest.read(backup))
|
||||
backup.addSource(created)
|
||||
self._updateFreshness()
|
||||
|
||||
async def startBackup(self, options: CreateOptions):
|
||||
return await self._withSoftLock(lambda: self._startBackup(options))
|
||||
|
||||
async def _startBackup(self, options: CreateOptions):
|
||||
self.clearCaches()
|
||||
self._estimator.refresh()
|
||||
self._estimator.checkSpace(self.backups())
|
||||
created = await self._buildModel().source.create(options)
|
||||
backup = Backup(created)
|
||||
self._model.backups[backup.slug()] = backup
|
||||
self._updateFreshness()
|
||||
self._estimator.refresh()
|
||||
return backup
|
||||
|
||||
def getBackup(self, slug):
|
||||
return self._ensureBackup(None, slug)
|
||||
|
||||
async def download(self, slug):
|
||||
self.clearCaches()
|
||||
backup = self._ensureBackup(None, slug)
|
||||
for source in self._sources.values():
|
||||
if not source.enabled():
|
||||
continue
|
||||
if backup.getSource(source.name()):
|
||||
return await source.read(backup)
|
||||
raise NoBackup()
|
||||
|
||||
async def retain(self, sources: Dict[str, bool], slug: str):
|
||||
self.clearCaches()
|
||||
for source in sources:
|
||||
backup = self._ensureBackup(source, slug)
|
||||
await self._ensureSource(source).retain(backup, sources[source])
|
||||
self._updateFreshness()
|
||||
|
||||
async def note(self, note: str, slug: str):
|
||||
self.clearCaches()
|
||||
backup = self._ensureBackup(None, slug)
|
||||
for source in backup.sources.keys():
|
||||
await self._ensureSource(source).note(backup, note)
|
||||
|
||||
async def delete(self, sources, slug):
|
||||
await self._withSoftLock(lambda: self._delete(sources, slug))
|
||||
|
||||
async def ignore(self, slug: str, ignore: bool):
|
||||
await self._withSoftLock(lambda: self._ignore(slug, ignore))
|
||||
|
||||
async def _delete(self, sources, slug):
|
||||
self.clearCaches()
|
||||
for source in sources:
|
||||
backup = self._ensureBackup(source, slug)
|
||||
await self._ensureSource(source).delete(backup)
|
||||
if backup.isDeleted():
|
||||
del self._model.backups[slug]
|
||||
self._updateFreshness()
|
||||
|
||||
async def _ignore(self, slug: str, ignore: bool):
|
||||
self.clearCaches()
|
||||
backup = self._ensureBackup(SOURCE_HA, slug)
|
||||
await self._ensureSource(SOURCE_HA).ignore(backup, ignore)
|
||||
|
||||
def _ensureBackup(self, source: str = None, slug=None) -> Backup:
|
||||
backup = self._buildModel().backups.get(slug)
|
||||
if not backup:
|
||||
raise NoBackup()
|
||||
if not source:
|
||||
return backup
|
||||
if not source:
|
||||
return backup
|
||||
if not backup.getSource(source):
|
||||
raise NoBackup()
|
||||
return backup
|
||||
|
||||
def _ensureSource(self, source):
|
||||
ret = self._sources.get(source)
|
||||
if ret and ret.enabled():
|
||||
return ret
|
||||
raise LogicError()
|
||||
|
||||
def _buildModel(self) -> Model:
|
||||
self._model.reinitialize(self._precache)
|
||||
return self._model
|
||||
|
||||
def _updateFreshness(self):
|
||||
purges = self._buildModel().getNextPurges()
|
||||
for backup in self._model.backups.values():
|
||||
for source in purges:
|
||||
if backup.getSource(source):
|
||||
backup.updatePurge(source, backup == purges[source])
|
||||
|
||||
def clearCaches(self):
|
||||
if self._precache:
|
||||
self._precache.clear()
|
||||
|
||||
async def _withSoftLock(self, callable):
|
||||
with self._lock:
|
||||
if self._busy:
|
||||
raise PleaseWait()
|
||||
self._busy = True
|
||||
try:
|
||||
return await callable()
|
||||
finally:
|
||||
with self._lock:
|
||||
self._busy = False
|
||||
@@ -0,0 +1,81 @@
|
||||
from .coordinator import Coordinator
|
||||
from backup.worker import Worker
|
||||
from injector import inject, singleton
|
||||
from backup.time import Time
|
||||
from backup.logger import getLogger
|
||||
from backup.config import Config, Setting
|
||||
from .model import BackupDestination
|
||||
from .precache import Precache
|
||||
from random import Random
|
||||
from datetime import datetime, timedelta
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict
|
||||
from logging import DEBUG
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class CacheItem:
|
||||
"""Class for keeping track of an item in inventory."""
|
||||
valid_until: datetime
|
||||
data: Any
|
||||
|
||||
|
||||
@singleton
|
||||
class DestinationPrecache(Worker, Precache):
|
||||
@inject
|
||||
def __init__(self, coord: Coordinator, time: Time, dest: BackupDestination, config: Config):
|
||||
super().__init__("Traffic Smoothing Cache", self.checkForSmoothing, time, 60)
|
||||
self._config = config
|
||||
self._coord = coord
|
||||
self._dest = dest
|
||||
self._offset = Random().random()
|
||||
self._cache: Dict[str, CacheItem] = {}
|
||||
self._last_error: datetime = None
|
||||
|
||||
async def checkForSmoothing(self):
|
||||
if self._config.get(Setting.CACHE_WARMUP_MAX_SECONDS) == 0:
|
||||
# disable cache warmup
|
||||
return
|
||||
try:
|
||||
self._coord.setPrecache(self)
|
||||
nextSync = self._coord.nextSyncAttempt()
|
||||
now = self._time.now()
|
||||
if nextSync <= now:
|
||||
# No reason to warm the cache if we should sync right now anyway
|
||||
return
|
||||
if self.cached(self._dest.name(), now):
|
||||
# A value is already cached, so don't do anything
|
||||
return
|
||||
if now >= self.getNextWarmDate():
|
||||
# Warm the cache
|
||||
logger.debug("Preemptively retrieving and caching info from the backup destination to avoid peak demand")
|
||||
data = await self._dest.get()
|
||||
validity = nextSync + timedelta(minutes=1)
|
||||
self._cache[self._dest.name()] = CacheItem(validity, data)
|
||||
self._offset = Random().random()
|
||||
except Exception as e:
|
||||
# Any error should make us avoid precaching for a solid day.
|
||||
logger.debug("Unable to precache data from backup destination")
|
||||
logger.printException(e, level=DEBUG)
|
||||
self._offset = Random().random()
|
||||
if self._config.get(Setting.CACHE_WARMUP_ERROR_TIMEOUT_SECONDS) != 0:
|
||||
self._last_error = self._time.now()
|
||||
|
||||
def getNextWarmDate(self):
|
||||
warm_date = self._coord.nextSyncAttempt() - timedelta(seconds=self._config.get(Setting.CACHE_WARMUP_MAX_SECONDS) * self._offset)
|
||||
if self._last_error:
|
||||
return max(warm_date, self._last_error + timedelta(self._config.get(Setting.CACHE_WARMUP_ERROR_TIMEOUT_SECONDS)))
|
||||
return warm_date
|
||||
|
||||
def cached(self, source: str, date: datetime) -> Any:
|
||||
cached = self._cache.get(source)
|
||||
if cached and cached.valid_until >= date:
|
||||
return cached.data
|
||||
return None
|
||||
|
||||
def clear(self):
|
||||
"""Clears any precached data"""
|
||||
self._cache = {}
|
||||
self._offset = Random().random()
|
||||
@@ -0,0 +1,74 @@
|
||||
from .backups import AbstractBackup
|
||||
from typing import Any, Dict
|
||||
|
||||
from ..const import SOURCE_GOOGLE_DRIVE, NECESSARY_PROP_KEY_SLUG, NECESSARY_PROP_KEY_DATE, NECESSARY_PROP_KEY_NAME, PROP_NOTE
|
||||
from ..exceptions import ensureKey
|
||||
from ..config import BoolValidator
|
||||
from ..time import Time
|
||||
from ..logger import getLogger
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
PROP_TYPE = "type"
|
||||
PROP_VERSION = "version"
|
||||
PROP_PROTECTED = "protected"
|
||||
PROP_RETAINED = "retained"
|
||||
DRIVE_KEY_TEXT = "Google Drive's backup metadata"
|
||||
|
||||
|
||||
class DriveBackup(AbstractBackup):
|
||||
|
||||
"""
|
||||
Represents a Home Assistant backup stored on Google Drive
|
||||
"""
|
||||
|
||||
def __init__(self, data: Dict[Any, Any]):
|
||||
props = ensureKey('appProperties', data, DRIVE_KEY_TEXT)
|
||||
retained = BoolValidator.strToBool(props.get(PROP_RETAINED, "False"))
|
||||
if NECESSARY_PROP_KEY_NAME in props:
|
||||
backup_name = ensureKey(NECESSARY_PROP_KEY_NAME, props, DRIVE_KEY_TEXT)
|
||||
else:
|
||||
backup_name = data['name'].replace(".tar", "")
|
||||
super().__init__(
|
||||
name=backup_name,
|
||||
slug=ensureKey(NECESSARY_PROP_KEY_SLUG, props, DRIVE_KEY_TEXT),
|
||||
date=Time.parse(
|
||||
ensureKey(NECESSARY_PROP_KEY_DATE, props, DRIVE_KEY_TEXT)),
|
||||
size=int(ensureKey("size", data, DRIVE_KEY_TEXT)),
|
||||
source=SOURCE_GOOGLE_DRIVE,
|
||||
backupType=props.get(PROP_TYPE, "?"),
|
||||
version=props.get(PROP_VERSION, None),
|
||||
protected=BoolValidator.strToBool(props.get(PROP_PROTECTED, "?")),
|
||||
retained=retained,
|
||||
uploadable=False,
|
||||
details=None,
|
||||
note=props.get(PROP_NOTE, None),
|
||||
pending=False)
|
||||
self._drive_data = data
|
||||
self._id = ensureKey('id', data, DRIVE_KEY_TEXT)
|
||||
|
||||
def id(self) -> str:
|
||||
return self._id
|
||||
|
||||
def canDeleteDirectly(self) -> str:
|
||||
caps = self._drive_data.get("capabilities", {})
|
||||
if caps.get('canDelete', False):
|
||||
return True
|
||||
|
||||
# check if the item is in a shared drive
|
||||
sharedId = self._drive_data.get("driveId")
|
||||
if sharedId and len(sharedId) > 0 and caps.get("canTrash", False):
|
||||
# Its in a shared drive and trashable, so trash won't exhaust quota
|
||||
return False
|
||||
|
||||
# We aren't certain we can trash or delete, so just make a try at deleting.
|
||||
return True
|
||||
|
||||
def __str__(self) -> str:
|
||||
return "<Drive: {0} Name: {1} Id: {2}>".format(self.slug(), self.name(), self.id())
|
||||
|
||||
def __format__(self, format_spec: str) -> str:
|
||||
return self.__str__()
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return self.__str__()
|
||||
@@ -0,0 +1,26 @@
|
||||
from .backups import Backup
|
||||
from .dummybackupsource import DummyBackupSource
|
||||
from ..logger import getLogger
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
|
||||
class DummyBackup(Backup):
|
||||
def __init__(self, name, date, source, slug, size=0, ignore=False, note=None):
|
||||
super().__init__(None)
|
||||
self._size = size
|
||||
self._ignore = ignore
|
||||
self._note = note
|
||||
self.addSource(DummyBackupSource(name, date, source, slug))
|
||||
|
||||
def size(self):
|
||||
return self._size
|
||||
|
||||
def ignore(self):
|
||||
return self._ignore
|
||||
|
||||
def note(self):
|
||||
if self._note is not None:
|
||||
return self._note
|
||||
else:
|
||||
return super().note()
|
||||
@@ -0,0 +1,20 @@
|
||||
from .backups import AbstractBackup
|
||||
from ..logger import getLogger
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
|
||||
class DummyBackupSource(AbstractBackup):
|
||||
def __init__(self, name, date, source, slug, retain=False):
|
||||
super().__init__(
|
||||
name=name,
|
||||
slug=slug,
|
||||
date=date,
|
||||
size=0,
|
||||
source=source,
|
||||
backupType="dummy",
|
||||
version="dummy_version",
|
||||
protected=True,
|
||||
retained=retain,
|
||||
uploadable=True,
|
||||
details={})
|
||||
@@ -0,0 +1,72 @@
|
||||
from typing import Any, Dict
|
||||
|
||||
from backup.const import SOURCE_HA
|
||||
from backup.exceptions import ensureKey
|
||||
from backup.time import Time
|
||||
from .backups import AbstractBackup
|
||||
from backup.logger import getLogger
|
||||
from backup.util import DataCache, KEY_I_MADE_THIS, KEY_IGNORE, KEY_NOTE
|
||||
from backup.config import Config, Setting
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
HA_KEY_TEXT = "Home Assistant's backup metadata"
|
||||
|
||||
|
||||
class HABackup(AbstractBackup):
|
||||
"""
|
||||
Represents a Home Assistant backup stored locally in Home Assistant
|
||||
"""
|
||||
|
||||
def __init__(self, data: Dict[str, Any], data_cache: DataCache, config: Config, retained=False):
|
||||
super().__init__(
|
||||
name=ensureKey('name', data, HA_KEY_TEXT),
|
||||
slug=ensureKey('slug', data, HA_KEY_TEXT),
|
||||
date=Time.parse(ensureKey('date', data, HA_KEY_TEXT)),
|
||||
size=float(ensureKey("size", data, HA_KEY_TEXT)) * 1024 * 1024,
|
||||
source=SOURCE_HA,
|
||||
backupType=ensureKey('type', data, HA_KEY_TEXT),
|
||||
version=ensureKey('homeassistant', data, HA_KEY_TEXT),
|
||||
protected=ensureKey('protected', data, HA_KEY_TEXT),
|
||||
retained=retained,
|
||||
uploadable=True,
|
||||
details=data,
|
||||
pending=False)
|
||||
self._data_cache = data_cache
|
||||
self._config = config
|
||||
|
||||
def madeByTheAddon(self):
|
||||
return self._data_cache.backup(self.slug()).get(KEY_I_MADE_THIS, False)
|
||||
|
||||
def note(self):
|
||||
parent = super().note()
|
||||
if parent is None:
|
||||
return self._data_cache.backup(self.slug()).get(KEY_NOTE, None)
|
||||
else:
|
||||
return parent
|
||||
|
||||
def ignore(self):
|
||||
override = self._data_cache.backup(self.slug()).get(KEY_IGNORE, None)
|
||||
if override is not None:
|
||||
return override
|
||||
if self.madeByTheAddon():
|
||||
return False
|
||||
if self._config.get(Setting.IGNORE_OTHER_BACKUPS):
|
||||
return True
|
||||
archive_count = len(self.details().get("addons", [])) + len(self.details().get("folders", []))
|
||||
if self.details().get("homeassistant", None) is not None:
|
||||
# Supervisor backup query API doesn't quite match the create API, if the HA config folder
|
||||
# is present in a backup then the Home Assistant version is present in its details
|
||||
archive_count += 1
|
||||
if archive_count == 1 and self._config.get(Setting.IGNORE_UPGRADE_BACKUPS):
|
||||
return True
|
||||
return super().ignore()
|
||||
|
||||
def __str__(self) -> str:
|
||||
return "<HA: {0} Name: {1} {2}>".format(self.slug(), self.name(), self.date().isoformat())
|
||||
|
||||
def __format__(self, format_spec: str) -> str:
|
||||
return self.__str__()
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return self.__str__()
|
||||
@@ -0,0 +1,397 @@
|
||||
from datetime import datetime, timedelta, date
|
||||
from io import IOBase
|
||||
from typing import Dict, Generic, List, Optional, Tuple, TypeVar
|
||||
|
||||
from injector import inject, singleton
|
||||
|
||||
from .backupscheme import GenerationalScheme, OldestScheme, DeleteAfterUploadScheme
|
||||
from backup.config import Config, Setting, CreateOptions
|
||||
from backup.exceptions import DeleteMutlipleBackupsError, SimulatedError
|
||||
from backup.util import GlobalInfo, Estimator, DataCache
|
||||
from .backups import AbstractBackup, Backup
|
||||
from .dummybackup import DummyBackup
|
||||
from .precache import Precache
|
||||
from backup.time import Time
|
||||
from backup.worker import Trigger
|
||||
from backup.logger import getLogger
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
T = TypeVar('T')
|
||||
|
||||
|
||||
class BackupSource(Trigger, Generic[T]):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
pass
|
||||
|
||||
def name(self) -> str:
|
||||
return "Unnamed"
|
||||
|
||||
def title(self) -> str:
|
||||
return "Default"
|
||||
|
||||
def enabled(self) -> bool:
|
||||
return True
|
||||
|
||||
def needsConfiguration(self) -> bool:
|
||||
return not self.enabled()
|
||||
|
||||
def upload(self) -> bool:
|
||||
return True
|
||||
|
||||
def icon(self) -> str:
|
||||
return "sd_card"
|
||||
|
||||
def freeSpace(self):
|
||||
return None
|
||||
|
||||
async def create(self, options: CreateOptions) -> T:
|
||||
pass
|
||||
|
||||
async def get(self) -> Dict[str, T]:
|
||||
pass
|
||||
|
||||
async def delete(self, backup: T):
|
||||
pass
|
||||
|
||||
async def ignore(self, backup: T, ignore: bool):
|
||||
pass
|
||||
|
||||
async def save(self, backup: AbstractBackup, bytes: IOBase) -> T:
|
||||
pass
|
||||
|
||||
async def read(self, backup: T) -> IOBase:
|
||||
pass
|
||||
|
||||
async def retain(self, backup: T, retain: bool) -> None:
|
||||
pass
|
||||
|
||||
async def note(self, backup, note: str) -> None:
|
||||
pass
|
||||
|
||||
def maxCount(self) -> None:
|
||||
return 0
|
||||
|
||||
def postSync(self) -> None:
|
||||
return
|
||||
|
||||
def detail(self) -> str:
|
||||
return ""
|
||||
|
||||
def isDestination(self) -> bool:
|
||||
return False
|
||||
|
||||
# Gets called after reading state but before any changes are made
|
||||
# to check for additional errors.
|
||||
def checkBeforeChanges(self) -> None:
|
||||
pass
|
||||
|
||||
|
||||
class BackupDestination(BackupSource):
|
||||
def isWorking(self):
|
||||
return False
|
||||
|
||||
@property
|
||||
def might_be_oob_creds(self) -> bool:
|
||||
return False
|
||||
|
||||
def isDestination(self) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
@singleton
|
||||
class Model():
|
||||
@inject
|
||||
def __init__(self, config: Config, time: Time, source: BackupSource, dest: BackupDestination, info: GlobalInfo, estimator: Estimator, data_cache: DataCache):
|
||||
self.config: Config = config
|
||||
self.time = time
|
||||
self.precache: Precache = None
|
||||
self.source: BackupSource = source
|
||||
self.dest: BackupDestination = dest
|
||||
self.reinitialize()
|
||||
self.backups: Dict[str, Backup] = {}
|
||||
self.firstSync = True
|
||||
self.info = info
|
||||
self.simulate_error = None
|
||||
self.estimator = estimator
|
||||
self.waiting_for_startup = False
|
||||
self.ignore_startup_delay = False
|
||||
self._data_cache = data_cache
|
||||
|
||||
def enabled(self):
|
||||
if self.source.needsConfiguration():
|
||||
return False
|
||||
if self.dest.needsConfiguration():
|
||||
return False
|
||||
return True
|
||||
|
||||
def allSources(self):
|
||||
return [self.source, self.dest]
|
||||
|
||||
def reinitialize(self, precache: Precache = None):
|
||||
self.precache = precache
|
||||
self._time_of_day: Optional[Tuple[int, int]] = self._parseTimeOfDay()
|
||||
|
||||
# SOMEDAY: this should be cached in config and regenerated on config updates, not here
|
||||
self.generational_config = self.config.getGenerationalConfig()
|
||||
|
||||
def getTimeOfDay(self):
|
||||
return self._time_of_day
|
||||
|
||||
def _nextBackup(self, now: datetime, last_backup: Optional[datetime]) -> Optional[datetime]:
|
||||
timeofDay = self.getTimeOfDay()
|
||||
if self.config.get(Setting.DAYS_BETWEEN_BACKUPS) <= 0:
|
||||
next = None
|
||||
elif self.dest.needsConfiguration():
|
||||
next = None
|
||||
elif not last_backup:
|
||||
# this isn't the cleanest logic, but the idea here is that if there are no backups,
|
||||
# then the backups shoudl be made right when the addon starts up.
|
||||
next = self.info.start_time
|
||||
elif not timeofDay:
|
||||
next = last_backup + timedelta(days=self.config.get(Setting.DAYS_BETWEEN_BACKUPS))
|
||||
else:
|
||||
newest_local: datetime = self.time.toLocal(last_backup)
|
||||
time_that_day_local = self.time.localize(datetime(newest_local.year, newest_local.month, newest_local.day, timeofDay[0], timeofDay[1]))
|
||||
if newest_local < time_that_day_local:
|
||||
# Latest backup is before the backup time for that day
|
||||
next = self.time.toUtc(time_that_day_local)
|
||||
else:
|
||||
# return the next backup after the delta
|
||||
next_date = date.fromordinal(int(date(newest_local.year, newest_local.month, newest_local.day).toordinal() + self.config.get(Setting.DAYS_BETWEEN_BACKUPS)))
|
||||
next_datetime_local = self.time.localize(datetime(next_date.year, next_date.month, next_date.day, timeofDay[0], timeofDay[1]))
|
||||
next = self.time.toUtc(next_datetime_local)
|
||||
|
||||
if next is None:
|
||||
self.waiting_for_startup = False
|
||||
return None
|
||||
|
||||
# Don't backup X minutes after startup, since that can put an unreasonable amount of strain on
|
||||
# the system while booting up.
|
||||
cooldown_minimum = self.info.backupCooldownTime()
|
||||
if next <= now and now < cooldown_minimum and not self.ignore_startup_delay:
|
||||
self.waiting_for_startup = True
|
||||
return cooldown_minimum
|
||||
elif self.ignore_startup_delay:
|
||||
self.waiting_for_startup = False
|
||||
return next
|
||||
elif cooldown_minimum > next:
|
||||
self.waiting_for_startup = cooldown_minimum > now
|
||||
return cooldown_minimum
|
||||
else:
|
||||
self.waiting_for_startup = False
|
||||
return next
|
||||
|
||||
def nextBackup(self, now: datetime, include_pending=True):
|
||||
latest = max(filter(lambda s: not s.ignore() and (not s.isPending() or include_pending), self.backups.values()),
|
||||
default=None, key=lambda s: s.date())
|
||||
if latest:
|
||||
latest = latest.date()
|
||||
return self._nextBackup(now, latest)
|
||||
|
||||
async def sync(self, now: datetime):
|
||||
if self.simulate_error is not None:
|
||||
if self.simulate_error.startswith("test"):
|
||||
raise Exception(self.simulate_error)
|
||||
else:
|
||||
raise SimulatedError(self.simulate_error)
|
||||
await self._syncBackups([self.source, self.dest], now)
|
||||
|
||||
self.source.checkBeforeChanges()
|
||||
self.dest.checkBeforeChanges()
|
||||
|
||||
if not self.dest.needsConfiguration():
|
||||
if self.source.enabled():
|
||||
await self._purge(self.source)
|
||||
if self.dest.enabled():
|
||||
await self._purge(self.dest)
|
||||
|
||||
# Delete any "ignored" backups that have expired
|
||||
if (self.config.get(Setting.IGNORE_OTHER_BACKUPS) or self.config.get(Setting.IGNORE_UPGRADE_BACKUPS)) and self.config.get(Setting.DELETE_IGNORED_AFTER_DAYS) > 0:
|
||||
cutoff = now - timedelta(days=self.config.get(Setting.DELETE_IGNORED_AFTER_DAYS))
|
||||
delete = []
|
||||
for backup in self.backups.values():
|
||||
if backup.ignore() and backup.date() < cutoff:
|
||||
delete.append(backup)
|
||||
for backup in delete:
|
||||
await self.deleteBackup(backup, self.source)
|
||||
|
||||
self._handleBackupDetails()
|
||||
next_backup = self.nextBackup(now)
|
||||
if next_backup and now >= next_backup and self.source.enabled() and not self.dest.needsConfiguration():
|
||||
if self.config.get(Setting.DELETE_BEFORE_NEW_BACKUP):
|
||||
await self._purge(self.source, pre_purge=True)
|
||||
await self.createBackup(CreateOptions(now, self.config.get(Setting.BACKUP_NAME)))
|
||||
await self._purge(self.source)
|
||||
self._handleBackupDetails()
|
||||
|
||||
if self.dest.enabled() and self.dest.upload():
|
||||
# get the backups we should upload
|
||||
uploads = []
|
||||
for backup in self.backups.values():
|
||||
if backup.getSource(self.source.name()) is not None and backup.getSource(self.source.name()).uploadable() and backup.getSource(self.dest.name()) is None and not backup.ignore():
|
||||
uploads.append(backup)
|
||||
uploads.sort(key=lambda s: s.date())
|
||||
uploads.reverse()
|
||||
for upload in uploads:
|
||||
# only upload if doing so won't result in it being deleted next
|
||||
dummy = DummyBackup(
|
||||
"", upload.date(), self.dest.name(), "dummy_slug_name")
|
||||
proposed = list(self.backups.values())
|
||||
proposed.append(dummy)
|
||||
if self._nextPurge(self.dest, proposed)[1] != dummy:
|
||||
if self.config.get(Setting.DELETE_BEFORE_NEW_BACKUP):
|
||||
await self._purge(self.dest, pre_purge=True)
|
||||
upload.addSource(await self.dest.save(upload, await self.source.read(upload)))
|
||||
await self._purge(self.dest)
|
||||
self._handleBackupDetails()
|
||||
else:
|
||||
break
|
||||
if self.config.get(Setting.DELETE_AFTER_UPLOAD):
|
||||
await self._purge(self.source)
|
||||
self._handleBackupDetails()
|
||||
self.source.postSync()
|
||||
self.dest.postSync()
|
||||
self._data_cache.saveIfDirty()
|
||||
|
||||
def isWorkingThroughUpload(self):
|
||||
return self.dest.isWorking()
|
||||
|
||||
async def createBackup(self, options):
|
||||
if not self.source.enabled():
|
||||
return
|
||||
|
||||
self.estimator.refresh()
|
||||
self.estimator.checkSpace(list(self.backups.values()))
|
||||
created = await self.source.create(options)
|
||||
backup = Backup(created)
|
||||
self.backups[backup.slug()] = backup
|
||||
|
||||
async def deleteBackup(self, backup, source):
|
||||
if not backup.getSource(source.name()):
|
||||
return
|
||||
slug = backup.slug()
|
||||
await source.delete(backup)
|
||||
backup.removeSource(source.name())
|
||||
if backup.isDeleted():
|
||||
del self.backups[slug]
|
||||
|
||||
def getNextPurges(self):
|
||||
purges = {}
|
||||
for source in [self.source, self.dest]:
|
||||
purges[source.name()] = self._nextPurge(
|
||||
source, self.backups.values(), findNext=True)[1]
|
||||
return purges
|
||||
|
||||
def _parseTimeOfDay(self) -> Optional[Tuple[int, int]]:
|
||||
from_config = self.config.get(Setting.BACKUP_TIME_OF_DAY)
|
||||
if len(from_config) == 0:
|
||||
return None
|
||||
parts = from_config.split(":")
|
||||
if len(parts) != 2:
|
||||
return None
|
||||
try:
|
||||
hour: int = int(parts[0])
|
||||
minute: int = int(parts[1])
|
||||
if hour < 0 or minute < 0 or hour > 23 or minute > 59:
|
||||
return None
|
||||
return (hour, minute)
|
||||
except ValueError:
|
||||
# Parse error
|
||||
return None
|
||||
|
||||
async def _syncBackups(self, sources: List[BackupSource], now: datetime):
|
||||
for source in sources:
|
||||
if source.enabled():
|
||||
# check if we have the results from this source precached
|
||||
from_source: Dict[str, AbstractBackup] = None
|
||||
if self.precache is not None:
|
||||
from_source = self.precache.cached(source.name(), now)
|
||||
if not from_source:
|
||||
from_source = await source.get()
|
||||
else:
|
||||
from_source: Dict[str, AbstractBackup] = {}
|
||||
for backup in from_source.values():
|
||||
if backup.slug() not in self.backups:
|
||||
self.backups[backup.slug()] = Backup(backup)
|
||||
else:
|
||||
self.backups[backup.slug()].addSource(backup)
|
||||
for backup in list(self.backups.values()):
|
||||
if backup.slug() not in from_source:
|
||||
slug = backup.slug()
|
||||
backup.removeSource(source.name())
|
||||
if backup.isDeleted():
|
||||
del self.backups[slug]
|
||||
self.firstSync = False
|
||||
|
||||
def _buildDeleteScheme(self, source, findNext=False):
|
||||
count = source.maxCount()
|
||||
if findNext:
|
||||
count -= 1
|
||||
if source == self.source and self.config.get(Setting.DELETE_AFTER_UPLOAD):
|
||||
return DeleteAfterUploadScheme(source.name(), [self.dest.name()])
|
||||
elif self.generational_config:
|
||||
return GenerationalScheme(
|
||||
self.time, self.generational_config, count=count)
|
||||
else:
|
||||
return OldestScheme(count=count)
|
||||
|
||||
def _buildNamingScheme(self):
|
||||
source = max(filter(BackupSource.enabled, self.allSources()), key=BackupSource.maxCount)
|
||||
return self._buildDeleteScheme(source)
|
||||
|
||||
def _handleBackupDetails(self):
|
||||
self._buildNamingScheme().handleNaming(self.backups.values())
|
||||
|
||||
def _nextPurge(self, source: BackupSource, backups, findNext=False):
|
||||
"""
|
||||
Given a list of backups, decides if one should be purged.
|
||||
"""
|
||||
if not source.enabled() or len(backups) == 0:
|
||||
return None, None
|
||||
if source.maxCount() == 0 and source.isDestination():
|
||||
# When maxCount is zero for a destination, we should never delete from it.
|
||||
return None, None
|
||||
if source.maxCount() == 0 and not self.config.get(Setting.DELETE_AFTER_UPLOAD):
|
||||
return None, None
|
||||
|
||||
scheme = self._buildDeleteScheme(source, findNext=findNext)
|
||||
consider_purging = []
|
||||
for backup in backups:
|
||||
source_backup = backup.getSource(source.name())
|
||||
if source_backup is not None and source_backup.considerForPurge() and not backup.ignore():
|
||||
consider_purging.append(backup)
|
||||
if len(consider_purging) == 0:
|
||||
return None, None
|
||||
return scheme.getOldest(consider_purging)
|
||||
|
||||
async def _purge(self, source: BackupSource, pre_purge=False):
|
||||
while True:
|
||||
purge = self._getPurgeList(source, pre_purge)
|
||||
|
||||
reasons = set(map(lambda p: p[1], purge))
|
||||
if len(purge) <= 0:
|
||||
return
|
||||
if len(purge) != len(reasons) and (self.config.get(Setting.CONFIRM_MULTIPLE_DELETES) and not self.info.isPermitMultipleDeletes()):
|
||||
raise DeleteMutlipleBackupsError(self._getPurgeStats())
|
||||
await self.deleteBackup(purge[0][0], source)
|
||||
|
||||
def _getPurgeStats(self):
|
||||
ret = {}
|
||||
for source in [self.source, self.dest]:
|
||||
ret[source.name()] = len(self._getPurgeList(source))
|
||||
return ret
|
||||
|
||||
def _getPurgeList(self, source: BackupSource, pre_purge=False):
|
||||
if not source.enabled():
|
||||
return []
|
||||
candidates = list(self.backups.values())
|
||||
purges = []
|
||||
while True:
|
||||
reason, next_purge = self._nextPurge(source, candidates, findNext=pre_purge)
|
||||
if next_purge is None:
|
||||
return purges
|
||||
else:
|
||||
purges.append((next_purge, reason))
|
||||
candidates.remove(next_purge)
|
||||
@@ -0,0 +1,15 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class Precache(ABC):
|
||||
@abstractmethod
|
||||
def cached(self, source: str, date: datetime) -> Any:
|
||||
"""For a given source and datetime, returns valid precached results if they're available"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def clear(self):
|
||||
"""Clears any cached results stored"""
|
||||
pass
|
||||
@@ -0,0 +1,129 @@
|
||||
from .model import CreateOptions, BackupDestination
|
||||
from .backups import Backup
|
||||
from .dummybackupsource import DummyBackupSource
|
||||
from typing import Dict
|
||||
from io import IOBase
|
||||
from ..ha import BackupName
|
||||
from ..logger import getLogger
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
|
||||
class SimulatedSource(BackupDestination):
|
||||
def __init__(self, name, is_destination=False):
|
||||
self._name = name
|
||||
self.current: Dict[str, DummyBackupSource] = {}
|
||||
self.saved = []
|
||||
self.deleted = []
|
||||
self.created = []
|
||||
self._enabled = True
|
||||
self._upload = True
|
||||
self.index = 0
|
||||
self.max = 0
|
||||
self.backup_name = BackupName()
|
||||
self.host_info = {}
|
||||
self.backup_type = "Full"
|
||||
self.working = False
|
||||
self.needConfig = None
|
||||
self.is_destination = is_destination
|
||||
|
||||
def isDestination(self):
|
||||
return self.is_destination
|
||||
|
||||
def setEnabled(self, value):
|
||||
self._enabled = value
|
||||
return self
|
||||
|
||||
def needsConfiguration(self) -> bool:
|
||||
if self.needConfig is not None:
|
||||
return self.needConfig
|
||||
return super().needsConfiguration()
|
||||
|
||||
def setNeedsConfiguration(self, value: bool):
|
||||
self.needConfig = value
|
||||
|
||||
def setUpload(self, value):
|
||||
self._upload = value
|
||||
return self
|
||||
|
||||
def upload(self):
|
||||
return self._upload
|
||||
|
||||
def setMax(self, count):
|
||||
self.max = count
|
||||
return self
|
||||
|
||||
def isWorking(self):
|
||||
return self.working
|
||||
|
||||
def setIsWorking(self, value):
|
||||
self.working = value
|
||||
|
||||
def maxCount(self) -> None:
|
||||
return self.max
|
||||
|
||||
def insert(self, name, date, slug=None, retain=False):
|
||||
if slug is None:
|
||||
slug = name
|
||||
new_backup = DummyBackupSource(
|
||||
name,
|
||||
date,
|
||||
self._name,
|
||||
slug)
|
||||
self.current[new_backup.slug()] = new_backup
|
||||
return new_backup
|
||||
|
||||
def name(self) -> str:
|
||||
return self._name
|
||||
|
||||
def enabled(self) -> bool:
|
||||
return self._enabled
|
||||
|
||||
def nameSetup(self, type, host_info):
|
||||
self.backup_type = type
|
||||
self.host_info = host_info
|
||||
|
||||
async def create(self, options: CreateOptions) -> DummyBackupSource:
|
||||
assert self.enabled
|
||||
new_backup = DummyBackupSource(
|
||||
self.backup_name.resolve(
|
||||
self.backup_type, options.name_template, options.when, self.host_info),
|
||||
options.when,
|
||||
self._name,
|
||||
"{0}slug{1}".format(self._name, self.index))
|
||||
self.index += 1
|
||||
self.current[new_backup.slug()] = new_backup
|
||||
self.created.append(new_backup)
|
||||
return new_backup
|
||||
|
||||
async def get(self) -> Dict[str, DummyBackupSource]:
|
||||
assert self.enabled
|
||||
return self.current
|
||||
|
||||
async def delete(self, backup: Backup):
|
||||
assert self.enabled
|
||||
assert backup.getSource(self._name) is not None
|
||||
assert backup.getSource(self._name).source() is self._name
|
||||
assert backup.slug() in self.current
|
||||
slug = backup.slug()
|
||||
self.deleted.append(backup.getSource(self._name))
|
||||
backup.removeSource(self._name)
|
||||
del self.current[slug]
|
||||
|
||||
async def save(self, backup: Backup, bytes: IOBase = None) -> DummyBackupSource:
|
||||
assert self.enabled
|
||||
assert backup.slug() not in self.current
|
||||
new_backup = DummyBackupSource(
|
||||
backup.name(), backup.date(), self._name, backup.slug())
|
||||
backup.addSource(new_backup)
|
||||
self.current[new_backup.slug()] = new_backup
|
||||
self.saved.append(new_backup)
|
||||
return new_backup
|
||||
|
||||
async def read(self, backup: DummyBackupSource) -> IOBase:
|
||||
assert self.enabled
|
||||
return None
|
||||
|
||||
async def retain(self, backup: DummyBackupSource, retain: bool) -> None:
|
||||
assert self.enabled
|
||||
backup.getSource(self.name()).setRetained(retain)
|
||||
@@ -0,0 +1,36 @@
|
||||
from typing import List
|
||||
|
||||
from injector import inject, singleton
|
||||
|
||||
from .coordinator import Coordinator
|
||||
from backup.time import Time
|
||||
from backup.worker import Worker, Trigger
|
||||
from backup.logger import getLogger
|
||||
from backup.exceptions import PleaseWait
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
|
||||
@singleton
|
||||
class Scyncer(Worker):
|
||||
@inject
|
||||
def __init__(self, time: Time, coord: Coordinator, triggers: List[Trigger]):
|
||||
super().__init__("Sync Worker", self.checkforSync, time, 0.5)
|
||||
self.coord = coord
|
||||
self.triggers: List[Trigger] = triggers
|
||||
self._time = time
|
||||
|
||||
async def checkforSync(self):
|
||||
try:
|
||||
doSync = False
|
||||
for trigger in self.triggers:
|
||||
if await trigger.check():
|
||||
logger.debug("Sync requested by " + str(trigger.name()))
|
||||
doSync = True
|
||||
if doSync:
|
||||
while self.coord.isSyncing():
|
||||
await self._time.sleepAsync(3)
|
||||
await self.coord.sync()
|
||||
except PleaseWait:
|
||||
# Ignore this, since it means a sync already started (unavilable race condition)
|
||||
pass
|
||||
@@ -0,0 +1,87 @@
|
||||
import socket
|
||||
import sys
|
||||
import aiohttp
|
||||
import os
|
||||
from aiohttp import ClientSession
|
||||
from injector import Module, provider, singleton, multiprovider
|
||||
from typing import List
|
||||
|
||||
from backup.config import Config, Startable, Setting
|
||||
from backup.drive import DriveSource
|
||||
from backup.ha import HaSource, HaUpdater, AddonStopper
|
||||
from backup.model import BackupDestination, BackupSource, Scyncer
|
||||
from backup.util import Resolver
|
||||
from backup.model import Coordinator, Precache, DestinationPrecache
|
||||
from backup.worker import Trigger
|
||||
from backup.watcher import Watcher
|
||||
from backup.ui import UiServer, Restarter
|
||||
from backup.logger import getLogger
|
||||
from backup.debug import DebugServer
|
||||
from .debugworker import DebugWorker
|
||||
from .tracing_session import TracingSession
|
||||
logger = getLogger(__name__)
|
||||
|
||||
|
||||
class BaseModule(Module):
|
||||
'''
|
||||
A module shared between tests and main
|
||||
'''
|
||||
def __init__(self, override_dns=True):
|
||||
self._override_dns = override_dns
|
||||
|
||||
@multiprovider
|
||||
@singleton
|
||||
def getTriggers(self, coord: Coordinator, ha: HaSource, drive: DriveSource, watcher: Watcher, server: UiServer) -> List[Trigger]:
|
||||
return [coord, ha, drive, watcher, server]
|
||||
|
||||
@provider
|
||||
@singleton
|
||||
def getDrive(self, drive: DriveSource) -> BackupDestination:
|
||||
return drive
|
||||
|
||||
@provider
|
||||
@singleton
|
||||
def getHa(self, ha: HaSource) -> BackupSource:
|
||||
return ha
|
||||
|
||||
@provider
|
||||
@singleton
|
||||
def getPrecache(self, cache: DestinationPrecache) -> Precache:
|
||||
return cache
|
||||
|
||||
@multiprovider
|
||||
@singleton
|
||||
def getStartables(self, debug_server: DebugServer, ha_updater: HaUpdater, debugger: DebugWorker, ha_source: HaSource,
|
||||
server: UiServer, restarter: Restarter, syncer: Scyncer, watcher: Watcher, stopper: AddonStopper, precache: Precache) -> List[Startable]:
|
||||
# Order here matters, since its the order in which components of the addon are initialized.
|
||||
return [debug_server, ha_updater, debugger, ha_source, server, restarter, syncer, watcher, stopper, precache]
|
||||
|
||||
@provider
|
||||
@singleton
|
||||
def getSession(self, resolver: Resolver, config: Config) -> ClientSession:
|
||||
conn = None
|
||||
if self._override_dns:
|
||||
conn = aiohttp.TCPConnector(resolver=resolver, family=socket.AF_INET)
|
||||
return TracingSession(config, connector=conn)
|
||||
|
||||
|
||||
class MainModule(Module):
|
||||
@provider
|
||||
@singleton
|
||||
def getConfig(self) -> Config:
|
||||
alt_config = None
|
||||
index = 1
|
||||
for arg in sys.argv[1:]:
|
||||
if arg == "--config":
|
||||
alt_config = sys.argv[index + 1]
|
||||
break
|
||||
index += 1
|
||||
|
||||
if alt_config:
|
||||
config = Config.withFileOverrides(alt_config)
|
||||
elif "PYTEST_CURRENT_TEST" in os.environ:
|
||||
config = Config()
|
||||
else:
|
||||
config = Config.fromFile(Setting.CONFIG_FILE_PATH.default())
|
||||
logger.overrideLevel(config.get(Setting.CONSOLE_LOG_LEVEL), config.get(Setting.LOG_LEVEL))
|
||||
return config
|
||||
@@ -0,0 +1,4 @@
|
||||
# flake8: noqa
|
||||
from .server import Server
|
||||
from .errorstore import ErrorStore
|
||||
from .cloudlogger import CloudLogger
|
||||
@@ -0,0 +1,27 @@
|
||||
import aiorun
|
||||
from .server import Server
|
||||
from backup.config import Config
|
||||
from backup.module import BaseModule
|
||||
from injector import Injector
|
||||
from injector import provider, singleton
|
||||
|
||||
|
||||
class ServerModule(BaseModule):
|
||||
def __init__(self):
|
||||
super().__init__(override_dns=False)
|
||||
|
||||
@provider
|
||||
@singleton
|
||||
def getConfig(self) -> Config:
|
||||
return Config.fromEnvironment()
|
||||
|
||||
|
||||
async def main():
|
||||
module = ServerModule()
|
||||
injector = Injector(module)
|
||||
await injector.get(Server).start()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
print("Starting")
|
||||
aiorun.run(main())
|
||||
@@ -0,0 +1,28 @@
|
||||
import os
|
||||
import json
|
||||
from backup.logger import getLogger, StandardLogger
|
||||
from injector import inject, singleton
|
||||
from google.cloud import logging
|
||||
from google.auth.exceptions import DefaultCredentialsError
|
||||
|
||||
basic_logger = getLogger(__name__)
|
||||
|
||||
|
||||
@singleton
|
||||
class CloudLogger(StandardLogger):
|
||||
@inject
|
||||
def __init__(self):
|
||||
super().__init__(__name__)
|
||||
self.google_logger = None
|
||||
if os.environ.get('GOOGLE_APPLICATION_CREDENTIALS') is not None:
|
||||
try:
|
||||
google_logger_client = logging.Client()
|
||||
self.googler_logger = google_logger_client.logger("refresh_server")
|
||||
except DefaultCredentialsError:
|
||||
basic_logger.error("Unable to start Google Logger, no default credentials")
|
||||
|
||||
def log_struct(self, data):
|
||||
if self.google_logger is not None:
|
||||
self.google_logger.log_struct(data)
|
||||
else:
|
||||
basic_logger.info(json.dumps(data))
|
||||
@@ -0,0 +1,32 @@
|
||||
import firebase_admin
|
||||
from firebase_admin import credentials
|
||||
from firebase_admin import firestore
|
||||
from datetime import datetime
|
||||
from backup.config import Setting, Config
|
||||
from .cloudlogger import CloudLogger
|
||||
from injector import inject, singleton
|
||||
|
||||
|
||||
@singleton
|
||||
class ErrorStore():
|
||||
@inject
|
||||
def __init__(self, logger: CloudLogger, config: Config):
|
||||
try:
|
||||
cred = credentials.ApplicationDefault()
|
||||
firebase_admin.initialize_app(cred, {
|
||||
'projectId': config.get(Setting.SERVER_PROJECT_ID),
|
||||
})
|
||||
self.db = firestore.client()
|
||||
except Exception as e:
|
||||
logger.log_struct({
|
||||
"error": "unable to initialize firestore, errors will not be logged to firestore. If you are running this on a developer machine, this error is normal.",
|
||||
"exception": str(e)
|
||||
})
|
||||
self.db = None
|
||||
self.last_error = None
|
||||
|
||||
def store(self, error_data):
|
||||
if self.db is not None:
|
||||
doc_ref = self.db.collection(u'error_reports').document(error_data.get('client', "unknown") + "-" + datetime.now().isoformat())
|
||||
doc_ref.set(error_data)
|
||||
self.last_error = error_data
|
||||
@@ -0,0 +1,218 @@
|
||||
import json
|
||||
import aiohttp_jinja2
|
||||
import jinja2
|
||||
import base64
|
||||
from os.path import abspath, join
|
||||
from aiohttp.web import Application, json_response, Request, TCPSite, AppRunner, post, Response, static, get
|
||||
from aiohttp.client_exceptions import ClientResponseError, ClientConnectorError, ServerConnectionError, ServerDisconnectedError, ServerTimeoutError
|
||||
from aiohttp.web_exceptions import HTTPBadRequest, HTTPSeeOther
|
||||
from backup.creds import Exchanger
|
||||
from backup.config import Config, Setting, VERSION
|
||||
from backup.exceptions import GoogleCredentialsExpired, ensureKey, KnownError
|
||||
from injector import ClassAssistedBuilder, inject, singleton
|
||||
from .errorstore import ErrorStore
|
||||
from .cloudlogger import CloudLogger
|
||||
from yarl import URL
|
||||
from backup.config import Version
|
||||
from urllib.parse import unquote
|
||||
|
||||
NEW_AUTH_MINIMUM = Version(0, 101, 3)
|
||||
|
||||
|
||||
@singleton
|
||||
class Server():
|
||||
@inject
|
||||
def __init__(self,
|
||||
config: Config,
|
||||
exchanger_builder: ClassAssistedBuilder[Exchanger],
|
||||
logger: CloudLogger,
|
||||
error_store: ErrorStore):
|
||||
self.exchanger = exchanger_builder.build(
|
||||
client_id=config.get(Setting.DEFAULT_DRIVE_CLIENT_ID),
|
||||
client_secret=config.get(Setting.DEFAULT_DRIVE_CLIENT_SECRET),
|
||||
redirect=URL(config.get(Setting.AUTHORIZATION_HOST)).with_path("/drive/authorize"))
|
||||
self.logger = logger
|
||||
self.config = config
|
||||
self.error_store = error_store
|
||||
|
||||
def base_context(self, request: Request):
|
||||
return {
|
||||
'version': VERSION,
|
||||
'backgroundColor': request.query.get('bg', self.config.get(Setting.BACKGROUND_COLOR)),
|
||||
'accentColor': request.query.get('ac', self.config.get(Setting.ACCENT_COLOR)),
|
||||
'bmc_logo_path': "/static/" + VERSION + "/images/bmc.svg"
|
||||
}
|
||||
|
||||
async def authorize(self, request: Request):
|
||||
if 'redirectbacktoken' in request.query:
|
||||
version = Version.parse(request.query.get('version', "0"))
|
||||
token_url = request.query.get('redirectbacktoken')
|
||||
return_url = request.query.get('return', None)
|
||||
state = {
|
||||
'v': str(version),
|
||||
'token': token_url,
|
||||
'return': return_url,
|
||||
'bg': self.base_context(request).get('backgroundColor'),
|
||||
'ac': self.base_context(request).get('accentColor'),
|
||||
}
|
||||
# Someone is trying to authenticate with the add-on, direct them to the google auth url
|
||||
raise HTTPSeeOther(await self.exchanger.getAuthorizationUrl(json.dumps(state)))
|
||||
elif 'state' in request.query and 'code' in request.query:
|
||||
state = json.loads(unquote(request.query.get('state')))
|
||||
code = request.query.get('code')
|
||||
try:
|
||||
version = Version.parse(state["v"])
|
||||
creds = (await self.exchanger.exchange(code)).serialize(include_secret=False)
|
||||
|
||||
if version < NEW_AUTH_MINIMUM:
|
||||
# Redirect back to the addon, since this is the older addon
|
||||
url = URL(state['token']).with_query({'creds': json.dumps(creds)})
|
||||
raise HTTPSeeOther(url)
|
||||
|
||||
serialized_creds = str(base64.b64encode(json.dumps(creds).encode("utf-8")), "utf-8")
|
||||
url = URL(state['token']).with_query({
|
||||
'creds': serialized_creds,
|
||||
'host': state['return']})
|
||||
context = {
|
||||
**self.base_context(request),
|
||||
'redirect_url': str(url),
|
||||
'credentials_serialized': serialized_creds,
|
||||
}
|
||||
if 'bg' in state:
|
||||
context['backgroundColor'] = state['bg']
|
||||
if 'ac' in state:
|
||||
context['accentColor'] = state['ac']
|
||||
return aiohttp_jinja2.render_template(
|
||||
"authorize.jinja2",
|
||||
request,
|
||||
context)
|
||||
except Exception as e:
|
||||
if isinstance(e, HTTPSeeOther):
|
||||
# expected, pass this thorugh
|
||||
raise
|
||||
self.logError(request, e)
|
||||
content = "The server encountered an error while processing this request: " + str(e) + "<br/>"
|
||||
content += "Please <a href='https://github.com/sabeechen/hassio-google-drive-backup/issues'>file an issue</a> on Home Assistant Google Backup's GitHub page so I'm aware of this problem or attempt authorizing with Google Drive again."
|
||||
return Response(status=500, body=content)
|
||||
else:
|
||||
raise HTTPBadRequest()
|
||||
|
||||
async def error(self, request: Request):
|
||||
try:
|
||||
self.logReport(request, await request.json())
|
||||
except BaseException as e:
|
||||
self.logError(request, e)
|
||||
return Response()
|
||||
|
||||
async def refresh(self, request: Request):
|
||||
try:
|
||||
token = ensureKey('refresh_token', await request.json(), "the request payload")
|
||||
creds = self.exchanger.refreshCredentials(token)
|
||||
new_creds = await self.exchanger.refresh(creds)
|
||||
return json_response(new_creds.serialize(include_secret=False))
|
||||
except ClientResponseError as e:
|
||||
if e.status == 401:
|
||||
return json_response({
|
||||
"error": "expired"
|
||||
}, status=401)
|
||||
else:
|
||||
self.logError(request, e)
|
||||
return json_response({
|
||||
"error": "Google returned HTTP {}".format(e.status)
|
||||
}, status=503)
|
||||
except ClientConnectorError:
|
||||
return json_response({
|
||||
"error": "Couldn't connect to Google's servers"
|
||||
}, status=503)
|
||||
except ServerConnectionError:
|
||||
return json_response({
|
||||
"error": "Couldn't connect to Google's servers"
|
||||
}, status=503)
|
||||
except ServerDisconnectedError:
|
||||
return json_response({
|
||||
"error": "Couldn't connect to Google's servers"
|
||||
}, status=503)
|
||||
except ServerTimeoutError:
|
||||
return json_response({
|
||||
"error": "Google's servers timed out"
|
||||
}, status=503)
|
||||
except GoogleCredentialsExpired:
|
||||
return json_response({
|
||||
"error": "expired"
|
||||
}, status=401)
|
||||
except KnownError as e:
|
||||
return json_response({
|
||||
"error": e.message()
|
||||
}, status=503)
|
||||
except Exception as e:
|
||||
self.logError(request, e)
|
||||
return json_response({
|
||||
"error": str(e)
|
||||
}, status=500)
|
||||
|
||||
@aiohttp_jinja2.template('picker.jinja2')
|
||||
async def picker(self, request: Request):
|
||||
version = Version.parse(request.query.get('version', "0"))
|
||||
bg = request.query.get('bg', self.config.get(Setting.BACKGROUND_COLOR))
|
||||
ac = request.query.get('ac', self.config.get(Setting.ACCENT_COLOR))
|
||||
return {
|
||||
**self.base_context(request),
|
||||
"client_id": self.config.get(Setting.DEFAULT_DRIVE_CLIENT_ID),
|
||||
"developer_key": self.config.get(Setting.DRIVE_PICKER_API_KEY),
|
||||
"app_id": self.config.get(Setting.DEFAULT_DRIVE_CLIENT_ID).split("-")[0],
|
||||
'backgroundColor': bg,
|
||||
'accentColor': ac,
|
||||
"do_redirect": str(version < NEW_AUTH_MINIMUM).lower()
|
||||
}
|
||||
|
||||
@aiohttp_jinja2.template('server-index.jinja2')
|
||||
async def index(self, request: Request):
|
||||
return self.base_context(request)
|
||||
|
||||
async def health(self, request: Request):
|
||||
return json_response({
|
||||
'status': 'ok',
|
||||
'messages': []
|
||||
})
|
||||
|
||||
def buildApp(self, app):
|
||||
path = abspath(join(__file__, "..", "..", "static"))
|
||||
app.add_routes([
|
||||
static("/static/" + VERSION, path, append_version=True),
|
||||
static("/drive/static/" + VERSION, path, append_version=True),
|
||||
get("/drive/picker", self.picker),
|
||||
get("/", self.index),
|
||||
get("/drive/authorize", self.authorize),
|
||||
post("/drive/refresh", self.refresh),
|
||||
post("/logerror", self.error),
|
||||
get("/health", self.health)
|
||||
])
|
||||
aiohttp_jinja2.setup(app, loader=jinja2.FileSystemLoader(path))
|
||||
return app
|
||||
|
||||
async def start(self):
|
||||
runner = AppRunner(self.buildApp(Application()))
|
||||
await runner.setup()
|
||||
site = TCPSite(runner, "0.0.0.0", int(self.config.get(Setting.PORT)))
|
||||
await site.start()
|
||||
self.logger.info("Backup Auth Server Started")
|
||||
|
||||
def logError(self, request: Request, exception: Exception):
|
||||
data = self.getRequestInfo(request)
|
||||
data['exception'] = self.logger.formatException(exception)
|
||||
self.logger.log_struct(data)
|
||||
|
||||
def logReport(self, request, report):
|
||||
data = self.getRequestInfo(request)
|
||||
data['report'] = report
|
||||
self.logger.log_struct(data)
|
||||
self.error_store.store(data)
|
||||
|
||||
def getRequestInfo(self, request: Request):
|
||||
return {
|
||||
'client': request.headers.get('client', "unknown"),
|
||||
'version': request.headers.get('addon_version', "unknown"),
|
||||
'address': request.remote,
|
||||
'url': str(request.url),
|
||||
'length': request.content_length
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
from injector import inject, singleton
|
||||
from typing import List
|
||||
|
||||
from .config import Startable, Config, Setting
|
||||
from .logger import getLogger
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
|
||||
@singleton
|
||||
class Starter(Startable):
|
||||
@inject
|
||||
def __init__(self, config: Config, startables: List[Startable]):
|
||||
self.startables = startables
|
||||
self.config = config
|
||||
|
||||
async def start(self):
|
||||
logger.overrideLevel(self.config.get(Setting.CONSOLE_LOG_LEVEL), self.config.get(Setting.LOG_LEVEL))
|
||||
for startable in self.startables:
|
||||
await startable.start()
|
||||
|
||||
async def stop(self):
|
||||
for startable in self.startables:
|
||||
await startable.stop()
|
||||
@@ -0,0 +1,73 @@
|
||||
{% import "layouts/macros.jinja2" as macros %}
|
||||
{% extends "layouts/base-server.jinja2" %}
|
||||
|
||||
{% block head %}
|
||||
{{ super() }}
|
||||
<script type="text/javascript">
|
||||
function redirect() {
|
||||
let searchParams = new URLSearchParams(window.location.search);
|
||||
if (searchParams.has('to')) {
|
||||
window.location.assign(searchParams.get('to'));
|
||||
}
|
||||
else {
|
||||
window.location.assign("{{ redirect_url }}");
|
||||
}
|
||||
}
|
||||
|
||||
function sleep (time) {
|
||||
return new Promise((resolve) => setTimeout(resolve, time));
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% call macros.header(version) %}{% endcall %}
|
||||
<main>
|
||||
<div class="section no-pad-bot" id="index-banner">
|
||||
<div class="container">
|
||||
<br><br>
|
||||
<div class="row center">
|
||||
<h6 class="header col s12 light">
|
||||
<div class="col s12 m8 offset-m2 l8 offset-l2" id="save_cred_message">
|
||||
<h5 class="header center">You're almost set.</h5>
|
||||
<br>
|
||||
<div class="center">
|
||||
You've authorized the addon to connect with Google Drive and created the authorization string shown below. Most of the time
|
||||
you can just click "Send Credentials" below to have them sent back to the addon, but depending on circumstances beyond the
|
||||
addon's control that might just return an error. If that happens, instead just copy the authorization string below
|
||||
and paste it into the addon where you clicked the "Authenticate With Google Drive" button that brought you here.
|
||||
</div>
|
||||
<br>
|
||||
<a class="center right btn-flat btn-high-vis" target="_blank" href="{{ redirect_url }}">
|
||||
<i class="material-icons">open_in_new</i>Send Credentials
|
||||
</a>
|
||||
</div>
|
||||
</h6>
|
||||
</div>
|
||||
<div class="row center">
|
||||
|
||||
</div>
|
||||
<div class="row center">
|
||||
<div class="col s12 m8 offset-m2 l4 offset-l4">
|
||||
<h6 class="left">Authorization String:</h6>
|
||||
<br>
|
||||
<textarea readonly style="height: 150px" id="credentials_serialized">
|
||||
{{ credentials_serialized }}
|
||||
</textarea>
|
||||
</div>
|
||||
<br>
|
||||
<div class="col s12 m8 offset-m2 l4 offset-l4">
|
||||
<button id="copy_button" onclick="copyFromInput('credentials_serialized')" class="right btn-flat btn-high-vis">
|
||||
<i class="material-icons">content_copy</i>Copy
|
||||
</button>
|
||||
</div>
|
||||
<br>
|
||||
<div class="col s12 m8 offset-m2 l4 offset-l4 light">
|
||||
<i>Note:</i> This string is like a password that lets the addon talk with Google Drive, so don't share it with anyone.
|
||||
</div>
|
||||
</div>
|
||||
<br><br>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,439 @@
|
||||
/*
|
||||
colpick Color Picker / colpick.com
|
||||
*/
|
||||
|
||||
/*Main container*/
|
||||
.colpick {
|
||||
position: absolute;
|
||||
box-sizing:content-box;
|
||||
width: 346px;
|
||||
height: 170px;
|
||||
overflow: hidden;
|
||||
display: none;
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
direction:ltr;
|
||||
background:#ebebeb;
|
||||
border: 1px solid #bbb;
|
||||
-webkit-border-radius: 5px;
|
||||
-moz-border-radius: 5px;
|
||||
border-radius: 5px;
|
||||
|
||||
/*Prevents selecting text when dragging the selectors*/
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
-o-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
/*Color selection box with gradients*/
|
||||
.colpick .colpick_color {
|
||||
position: absolute;
|
||||
left: 7px;
|
||||
top: 7px;
|
||||
width: 156px;
|
||||
height: 156px;
|
||||
overflow: hidden;
|
||||
outline: 1px solid #aaa;
|
||||
cursor: crosshair;
|
||||
}
|
||||
.colpick .colpick_color_overlay1 {
|
||||
position: absolute;
|
||||
left:0;
|
||||
top:0;
|
||||
width: 156px;
|
||||
height: 156px;
|
||||
-ms-filter: "progid:DXImageTransform.Microsoft.gradient(GradientType=1,startColorstr='#ffffff', endColorstr='#00ffffff')"; /* IE8 */
|
||||
background: -moz-linear-gradient(left, rgba(255,255,255,1) 0%, rgba(255,255,255,0) 100%); /* FF3.6+ */
|
||||
background: -webkit-gradient(linear, left top, right top, color-stop(0%,rgba(255,255,255,1)), color-stop(100%,rgba(255,255,255,0))); /* Chrome,Safari4+ */
|
||||
background: -webkit-linear-gradient(left, rgba(255,255,255,1) 0%,rgba(255,255,255,0) 100%); /* Chrome10+,Safari5.1+ */
|
||||
background: -o-linear-gradient(left, rgba(255,255,255,1) 0%,rgba(255,255,255,0) 100%); /* Opera 11.10+ */
|
||||
background: -ms-linear-gradient(left, rgba(255,255,255,1) 0%,rgba(255,255,255,0) 100%); /* IE10+ */
|
||||
background: linear-gradient(to right, rgba(255,255,255,1) 0%, rgba(255,255,255,0) 100%);
|
||||
filter: progid:DXImageTransform.Microsoft.gradient(GradientType=1,startColorstr='#ffffff', endColorstr='#00ffffff'); /* IE6 & IE7 */
|
||||
}
|
||||
.colpick .colpick_color_overlay2 {
|
||||
position: absolute;
|
||||
left:0;
|
||||
top:0;
|
||||
width: 156px;
|
||||
height: 156px;
|
||||
-ms-filter: "progid:DXImageTransform.Microsoft.gradient(GradientType=0,startColorstr='#00000000', endColorstr='#000000')"; /* IE8 */
|
||||
background: -moz-linear-gradient(top, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 100%); /* FF3.6+ */
|
||||
background: -webkit-gradient(linear, left top, left bottom, color-stop(0%,rgba(0,0,0,0)), color-stop(100%,rgba(0,0,0,1))); /* Chrome,Safari4+ */
|
||||
background: -webkit-linear-gradient(top, rgba(0,0,0,0) 0%,rgba(0,0,0,1) 100%); /* Chrome10+,Safari5.1+ */
|
||||
background: -o-linear-gradient(top, rgba(0,0,0,0) 0%,rgba(0,0,0,1) 100%); /* Opera 11.10+ */
|
||||
background: -ms-linear-gradient(top, rgba(0,0,0,0) 0%,rgba(0,0,0,1) 100%); /* IE10+ */
|
||||
background: linear-gradient(to bottom, rgba(0,0,0,0) 0%,rgba(0,0,0,1) 100%); /* W3C */
|
||||
filter: progid:DXImageTransform.Microsoft.gradient( startColorstr='#00000000', endColorstr='#000000',GradientType=0 ); /* IE6-9 */
|
||||
}
|
||||
/*Circular color selector*/
|
||||
.colpick .colpick_selector_outer {
|
||||
background:none;
|
||||
position: absolute;
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
margin: -6px 0 0 -6px;
|
||||
border: 1px solid black;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.colpick .colpick_selector_inner{
|
||||
position: absolute;
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border: 1px solid white;
|
||||
border-radius: 50%;
|
||||
}
|
||||
/*Vertical hue bar*/
|
||||
.colpick .colpick_hue {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
left: 175px;
|
||||
width: 19px;
|
||||
height: 156px;
|
||||
border: 1px solid #aaa;
|
||||
cursor: n-resize;
|
||||
}
|
||||
/*Hue bar sliding indicator*/
|
||||
.colpick .colpick_hue_arrs {
|
||||
position: absolute;
|
||||
left: -8px;
|
||||
width: 35px;
|
||||
height: 7px;
|
||||
margin: -7px 0 0 0;
|
||||
}
|
||||
.colpick .colpick_hue_larr {
|
||||
position:absolute;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-top: 6px solid transparent;
|
||||
border-bottom: 6px solid transparent;
|
||||
border-left: 7px solid #858585;
|
||||
}
|
||||
.colpick .colpick_hue_rarr {
|
||||
position:absolute;
|
||||
right:0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-top: 6px solid transparent;
|
||||
border-bottom: 6px solid transparent;
|
||||
border-right: 7px solid #858585;
|
||||
}
|
||||
/*New color box*/
|
||||
.colpick .colpick_new_color {
|
||||
position: absolute;
|
||||
left: 207px;
|
||||
top: 6px;
|
||||
width: 60px;
|
||||
height: 27px;
|
||||
background: #f00;
|
||||
border: 1px solid #8f8f8f;
|
||||
}
|
||||
/*Current color box*/
|
||||
.colpick .colpick_current_color {
|
||||
position: absolute;
|
||||
left: 277px;
|
||||
top: 6px;
|
||||
width: 60px;
|
||||
height: 27px;
|
||||
background: #f00;
|
||||
border: 1px solid #8f8f8f;
|
||||
}
|
||||
/*Input field containers*/
|
||||
.colpick .colpick_field, .colpick .colpick_hex_field {
|
||||
position: absolute;
|
||||
height: 20px;
|
||||
width: 60px;
|
||||
overflow:hidden;
|
||||
background:#f3f3f3;
|
||||
color:#b8b8b8;
|
||||
font-size:12px;
|
||||
border:1px solid #bdbdbd;
|
||||
-webkit-border-radius: 3px;
|
||||
-moz-border-radius: 3px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.colpick .colpick_rgb_r {
|
||||
top: 40px;
|
||||
left: 207px;
|
||||
}
|
||||
.colpick .colpick_rgb_g {
|
||||
top: 67px;
|
||||
left: 207px;
|
||||
}
|
||||
.colpick .colpick_rgb_b {
|
||||
top: 94px;
|
||||
left: 207px;
|
||||
}
|
||||
.colpick .colpick_hsb_h {
|
||||
top: 40px;
|
||||
left: 277px;
|
||||
}
|
||||
.colpick .colpick_hsb_s {
|
||||
top: 67px;
|
||||
left: 277px;
|
||||
}
|
||||
.colpick .colpick_hsb_b {
|
||||
top: 94px;
|
||||
left: 277px;
|
||||
}
|
||||
.colpick .colpick_hex_field {
|
||||
width: 68px;
|
||||
left: 207px;
|
||||
top: 121px;
|
||||
}
|
||||
/*Text field container on focus*/
|
||||
.colpick .colpick_focus {
|
||||
border-color: #999;
|
||||
}
|
||||
/*Field label container*/
|
||||
.colpick .colpick_field_letter {
|
||||
position: absolute;
|
||||
width: 12px;
|
||||
height: 20px;
|
||||
line-height: 20px;
|
||||
padding-left: 4px;
|
||||
background: #efefef;
|
||||
border-right: 1px solid #bdbdbd;
|
||||
font-weight: bold;
|
||||
color:#777;
|
||||
}
|
||||
/*Text inputs*/
|
||||
.colpick .colpick_field input, .colpick .colpick_hex_field input {
|
||||
position: absolute;
|
||||
right: 11px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: 20px;
|
||||
line-height: 20px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
font-size: 12px;
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
color: #555;
|
||||
text-align: right;
|
||||
outline: none;
|
||||
}
|
||||
.colpick .colpick_hex_field input {
|
||||
right: 4px;
|
||||
}
|
||||
/*Field up/down arrows*/
|
||||
.colpick .colpick_field_arrs {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 9px;
|
||||
height: 21px;
|
||||
cursor: n-resize;
|
||||
}
|
||||
.colpick .colpick_field_uarr {
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-left: 4px solid transparent;
|
||||
border-right: 4px solid transparent;
|
||||
border-bottom: 4px solid #959595;
|
||||
}
|
||||
.colpick .colpick_field_darr {
|
||||
position: absolute;
|
||||
bottom:5px;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-left: 4px solid transparent;
|
||||
border-right: 4px solid transparent;
|
||||
border-top: 4px solid #959595;
|
||||
}
|
||||
/*Submit/Select button*/
|
||||
.colpick .colpick_submit {
|
||||
position: absolute;
|
||||
left: 207px;
|
||||
top: 149px;
|
||||
width: 130px;
|
||||
height: 22px;
|
||||
line-height:22px;
|
||||
background: #efefef;
|
||||
text-align: center;
|
||||
color: #555;
|
||||
font-size: 12px;
|
||||
font-weight:bold;
|
||||
border: 1px solid #bdbdbd;
|
||||
-webkit-border-radius: 3px;
|
||||
-moz-border-radius: 3px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.colpick .colpick_submit:hover {
|
||||
background:#f3f3f3;
|
||||
border-color:#999;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/*full layout with no submit button*/
|
||||
.colpick.colpick_full_ns .colpick_submit,
|
||||
.colpick.colpick_full_ns .colpick_current_color{
|
||||
display:none;
|
||||
}
|
||||
.colpick.colpick_full_ns .colpick_new_color {
|
||||
width: 130px;
|
||||
height: 25px;
|
||||
}
|
||||
.colpick.colpick_full_ns .colpick_rgb_r,
|
||||
.colpick.colpick_full_ns .colpick_hsb_h {
|
||||
top: 42px;
|
||||
}
|
||||
.colpick.colpick_full_ns .colpick_rgb_g,
|
||||
.colpick.colpick_full_ns .colpick_hsb_s {
|
||||
top: 73px;
|
||||
}
|
||||
.colpick.colpick_full_ns .colpick_rgb_b,
|
||||
.colpick.colpick_full_ns .colpick_hsb_b {
|
||||
top: 104px;
|
||||
}
|
||||
.colpick.colpick_full_ns .colpick_hex_field {
|
||||
top: 135px;
|
||||
}
|
||||
|
||||
/*rgbhex layout*/
|
||||
.colpick.colpick_rgbhex .colpick_hsb_h,
|
||||
.colpick.colpick_rgbhex .colpick_hsb_s,
|
||||
.colpick.colpick_rgbhex .colpick_hsb_b {
|
||||
display:none;
|
||||
}
|
||||
.colpick.colpick_rgbhex {
|
||||
width:282px;
|
||||
}
|
||||
.colpick.colpick_rgbhex .colpick_field,
|
||||
.colpick.colpick_rgbhex .colpick_submit {
|
||||
width:68px;
|
||||
}
|
||||
.colpick.colpick_rgbhex .colpick_new_color {
|
||||
width:34px;
|
||||
border-right:none;
|
||||
}
|
||||
.colpick.colpick_rgbhex .colpick_current_color {
|
||||
width:34px;
|
||||
left:240px;
|
||||
border-left:none;
|
||||
}
|
||||
|
||||
/*rgbhex layout, no submit button*/
|
||||
.colpick.colpick_rgbhex_ns .colpick_submit,
|
||||
.colpick.colpick_rgbhex_ns .colpick_current_color{
|
||||
display:none;
|
||||
}
|
||||
.colpick.colpick_rgbhex_ns .colpick_new_color{
|
||||
width:68px;
|
||||
border: 1px solid #8f8f8f;
|
||||
}
|
||||
.colpick.colpick_rgbhex_ns .colpick_rgb_r {
|
||||
top: 42px;
|
||||
}
|
||||
.colpick.colpick_rgbhex_ns .colpick_rgb_g {
|
||||
top: 73px;
|
||||
}
|
||||
.colpick.colpick_rgbhex_ns .colpick_rgb_b {
|
||||
top: 104px;
|
||||
}
|
||||
.colpick.colpick_rgbhex_ns .colpick_hex_field {
|
||||
top: 135px;
|
||||
}
|
||||
|
||||
/*hex layout*/
|
||||
.colpick.colpick_hex .colpick_hsb_h,
|
||||
.colpick.colpick_hex .colpick_hsb_s,
|
||||
.colpick.colpick_hex .colpick_hsb_b,
|
||||
.colpick.colpick_hex .colpick_rgb_r,
|
||||
.colpick.colpick_hex .colpick_rgb_g,
|
||||
.colpick.colpick_hex .colpick_rgb_b {
|
||||
display:none;
|
||||
}
|
||||
.colpick.colpick_hex {
|
||||
width:206px;
|
||||
height:201px;
|
||||
}
|
||||
.colpick.colpick_hex .colpick_hex_field {
|
||||
width:72px;
|
||||
height:25px;
|
||||
top:168px;
|
||||
left:80px;
|
||||
}
|
||||
.colpick.colpick_hex .colpick_hex_field div,
|
||||
.colpick.colpick_hex .colpick_hex_field input {
|
||||
height: 25px;
|
||||
line-height: 25px;
|
||||
}
|
||||
.colpick.colpick_hex .colpick_new_color {
|
||||
left:9px;
|
||||
top:168px;
|
||||
width:30px;
|
||||
border-right:none;
|
||||
}
|
||||
.colpick.colpick_hex .colpick_current_color {
|
||||
left:39px;
|
||||
top:168px;
|
||||
width:30px;
|
||||
border-left:none;
|
||||
}
|
||||
.colpick.colpick_hex .colpick_submit {
|
||||
left:164px;
|
||||
top: 168px;
|
||||
width:30px;
|
||||
height:25px;
|
||||
line-height: 25px;
|
||||
}
|
||||
|
||||
/*hex layout, no submit button*/
|
||||
.colpick.colpick_hex_ns .colpick_submit,
|
||||
.colpick.colpick_hex_ns .colpick_current_color {
|
||||
display:none;
|
||||
}
|
||||
.colpick.colpick_hex_ns .colpick_hex_field {
|
||||
width:80px;
|
||||
}
|
||||
.colpick.colpick_hex_ns .colpick_new_color{
|
||||
width:60px;
|
||||
border: 1px solid #8f8f8f;
|
||||
}
|
||||
|
||||
/*Dark color scheme*/
|
||||
.colpick.colpick_dark {
|
||||
background: #161616;
|
||||
border-color: #2a2a2a;
|
||||
}
|
||||
.colpick.colpick_dark .colpick_color {
|
||||
outline-color: #333;
|
||||
}
|
||||
.colpick.colpick_dark .colpick_hue {
|
||||
border-color: #555;
|
||||
}
|
||||
.colpick.colpick_dark .colpick_field,
|
||||
.colpick.colpick_dark .colpick_hex_field {
|
||||
background: #101010;
|
||||
border-color: #2d2d2d;
|
||||
}
|
||||
.colpick.colpick_dark .colpick_field_letter {
|
||||
background: #131313;
|
||||
border-color: #2d2d2d;
|
||||
color: #696969;
|
||||
}
|
||||
.colpick.colpick_dark .colpick_field input,
|
||||
.colpick.colpick_dark .colpick_hex_field input {
|
||||
color: #7a7a7a;
|
||||
}
|
||||
.colpick.colpick_dark .colpick_field_uarr {
|
||||
border-bottom-color:#696969;
|
||||
}
|
||||
.colpick.colpick_dark .colpick_field_darr {
|
||||
border-top-color:#696969;
|
||||
}
|
||||
.colpick.colpick_dark .colpick_focus {
|
||||
border-color:#444;
|
||||
}
|
||||
.colpick.colpick_dark .colpick_submit {
|
||||
background: #131313;
|
||||
border-color:#2d2d2d;
|
||||
color:#7a7a7a;
|
||||
}
|
||||
.colpick.colpick_dark .colpick_submit:hover {
|
||||
background-color:#101010;
|
||||
border-color:#444;
|
||||
}
|
||||
|
After Width: | Height: | Size: 275 KiB |
@@ -0,0 +1,932 @@
|
||||
3d_rotation e84d
|
||||
ac_unit eb3b
|
||||
access_alarm e190
|
||||
access_alarms e191
|
||||
access_time e192
|
||||
accessibility e84e
|
||||
accessible e914
|
||||
account_balance e84f
|
||||
account_balance_wallet e850
|
||||
account_box e851
|
||||
account_circle e853
|
||||
adb e60e
|
||||
add e145
|
||||
add_a_photo e439
|
||||
add_alarm e193
|
||||
add_alert e003
|
||||
add_box e146
|
||||
add_circle e147
|
||||
add_circle_outline e148
|
||||
add_location e567
|
||||
add_shopping_cart e854
|
||||
add_to_photos e39d
|
||||
add_to_queue e05c
|
||||
adjust e39e
|
||||
airline_seat_flat e630
|
||||
airline_seat_flat_angled e631
|
||||
airline_seat_individual_suite e632
|
||||
airline_seat_legroom_extra e633
|
||||
airline_seat_legroom_normal e634
|
||||
airline_seat_legroom_reduced e635
|
||||
airline_seat_recline_extra e636
|
||||
airline_seat_recline_normal e637
|
||||
airplanemode_active e195
|
||||
airplanemode_inactive e194
|
||||
airplay e055
|
||||
airport_shuttle eb3c
|
||||
alarm e855
|
||||
alarm_add e856
|
||||
alarm_off e857
|
||||
alarm_on e858
|
||||
album e019
|
||||
all_inclusive eb3d
|
||||
all_out e90b
|
||||
android e859
|
||||
announcement e85a
|
||||
apps e5c3
|
||||
archive e149
|
||||
arrow_back e5c4
|
||||
arrow_downward e5db
|
||||
arrow_drop_down e5c5
|
||||
arrow_drop_down_circle e5c6
|
||||
arrow_drop_up e5c7
|
||||
arrow_forward e5c8
|
||||
arrow_upward e5d8
|
||||
art_track e060
|
||||
aspect_ratio e85b
|
||||
assessment e85c
|
||||
assignment e85d
|
||||
assignment_ind e85e
|
||||
assignment_late e85f
|
||||
assignment_return e860
|
||||
assignment_returned e861
|
||||
assignment_turned_in e862
|
||||
assistant e39f
|
||||
assistant_photo e3a0
|
||||
attach_file e226
|
||||
attach_money e227
|
||||
attachment e2bc
|
||||
audiotrack e3a1
|
||||
autorenew e863
|
||||
av_timer e01b
|
||||
backspace e14a
|
||||
backup e864
|
||||
battery_alert e19c
|
||||
battery_charging_full e1a3
|
||||
battery_full e1a4
|
||||
battery_std e1a5
|
||||
battery_unknown e1a6
|
||||
beach_access eb3e
|
||||
beenhere e52d
|
||||
block e14b
|
||||
bluetooth e1a7
|
||||
bluetooth_audio e60f
|
||||
bluetooth_connected e1a8
|
||||
bluetooth_disabled e1a9
|
||||
bluetooth_searching e1aa
|
||||
blur_circular e3a2
|
||||
blur_linear e3a3
|
||||
blur_off e3a4
|
||||
blur_on e3a5
|
||||
book e865
|
||||
bookmark e866
|
||||
bookmark_border e867
|
||||
border_all e228
|
||||
border_bottom e229
|
||||
border_clear e22a
|
||||
border_color e22b
|
||||
border_horizontal e22c
|
||||
border_inner e22d
|
||||
border_left e22e
|
||||
border_outer e22f
|
||||
border_right e230
|
||||
border_style e231
|
||||
border_top e232
|
||||
border_vertical e233
|
||||
branding_watermark e06b
|
||||
brightness_1 e3a6
|
||||
brightness_2 e3a7
|
||||
brightness_3 e3a8
|
||||
brightness_4 e3a9
|
||||
brightness_5 e3aa
|
||||
brightness_6 e3ab
|
||||
brightness_7 e3ac
|
||||
brightness_auto e1ab
|
||||
brightness_high e1ac
|
||||
brightness_low e1ad
|
||||
brightness_medium e1ae
|
||||
broken_image e3ad
|
||||
brush e3ae
|
||||
bubble_chart e6dd
|
||||
bug_report e868
|
||||
build e869
|
||||
burst_mode e43c
|
||||
business e0af
|
||||
business_center eb3f
|
||||
cached e86a
|
||||
cake e7e9
|
||||
call e0b0
|
||||
call_end e0b1
|
||||
call_made e0b2
|
||||
call_merge e0b3
|
||||
call_missed e0b4
|
||||
call_missed_outgoing e0e4
|
||||
call_received e0b5
|
||||
call_split e0b6
|
||||
call_to_action e06c
|
||||
camera e3af
|
||||
camera_alt e3b0
|
||||
camera_enhance e8fc
|
||||
camera_front e3b1
|
||||
camera_rear e3b2
|
||||
camera_roll e3b3
|
||||
cancel e5c9
|
||||
card_giftcard e8f6
|
||||
card_membership e8f7
|
||||
card_travel e8f8
|
||||
casino eb40
|
||||
cast e307
|
||||
cast_connected e308
|
||||
center_focus_strong e3b4
|
||||
center_focus_weak e3b5
|
||||
change_history e86b
|
||||
chat e0b7
|
||||
chat_bubble e0ca
|
||||
chat_bubble_outline e0cb
|
||||
check e5ca
|
||||
check_box e834
|
||||
check_box_outline_blank e835
|
||||
check_circle e86c
|
||||
chevron_left e5cb
|
||||
chevron_right e5cc
|
||||
child_care eb41
|
||||
child_friendly eb42
|
||||
chrome_reader_mode e86d
|
||||
class e86e
|
||||
clear e14c
|
||||
clear_all e0b8
|
||||
close e5cd
|
||||
closed_caption e01c
|
||||
cloud e2bd
|
||||
cloud_circle e2be
|
||||
cloud_done e2bf
|
||||
cloud_download e2c0
|
||||
cloud_off e2c1
|
||||
cloud_queue e2c2
|
||||
cloud_upload e2c3
|
||||
code e86f
|
||||
collections e3b6
|
||||
collections_bookmark e431
|
||||
color_lens e3b7
|
||||
colorize e3b8
|
||||
comment e0b9
|
||||
compare e3b9
|
||||
compare_arrows e915
|
||||
computer e30a
|
||||
confirmation_number e638
|
||||
contact_mail e0d0
|
||||
contact_phone e0cf
|
||||
contacts e0ba
|
||||
content_copy e14d
|
||||
content_cut e14e
|
||||
content_paste e14f
|
||||
control_point e3ba
|
||||
control_point_duplicate e3bb
|
||||
copyright e90c
|
||||
create e150
|
||||
create_new_folder e2cc
|
||||
credit_card e870
|
||||
crop e3be
|
||||
crop_16_9 e3bc
|
||||
crop_3_2 e3bd
|
||||
crop_5_4 e3bf
|
||||
crop_7_5 e3c0
|
||||
crop_din e3c1
|
||||
crop_free e3c2
|
||||
crop_landscape e3c3
|
||||
crop_original e3c4
|
||||
crop_portrait e3c5
|
||||
crop_rotate e437
|
||||
crop_square e3c6
|
||||
dashboard e871
|
||||
data_usage e1af
|
||||
date_range e916
|
||||
dehaze e3c7
|
||||
delete e872
|
||||
delete_forever e92b
|
||||
delete_sweep e16c
|
||||
description e873
|
||||
desktop_mac e30b
|
||||
desktop_windows e30c
|
||||
details e3c8
|
||||
developer_board e30d
|
||||
developer_mode e1b0
|
||||
device_hub e335
|
||||
devices e1b1
|
||||
devices_other e337
|
||||
dialer_sip e0bb
|
||||
dialpad e0bc
|
||||
directions e52e
|
||||
directions_bike e52f
|
||||
directions_boat e532
|
||||
directions_bus e530
|
||||
directions_car e531
|
||||
directions_railway e534
|
||||
directions_run e566
|
||||
directions_subway e533
|
||||
directions_transit e535
|
||||
directions_walk e536
|
||||
disc_full e610
|
||||
dns e875
|
||||
do_not_disturb e612
|
||||
do_not_disturb_alt e611
|
||||
do_not_disturb_off e643
|
||||
do_not_disturb_on e644
|
||||
dock e30e
|
||||
domain e7ee
|
||||
done e876
|
||||
done_all e877
|
||||
donut_large e917
|
||||
donut_small e918
|
||||
drafts e151
|
||||
drag_handle e25d
|
||||
drive_eta e613
|
||||
dvr e1b2
|
||||
edit e3c9
|
||||
edit_location e568
|
||||
eject e8fb
|
||||
email e0be
|
||||
enhanced_encryption e63f
|
||||
equalizer e01d
|
||||
error e000
|
||||
error_outline e001
|
||||
euro_symbol e926
|
||||
ev_station e56d
|
||||
event e878
|
||||
event_available e614
|
||||
event_busy e615
|
||||
event_note e616
|
||||
event_seat e903
|
||||
exit_to_app e879
|
||||
expand_less e5ce
|
||||
expand_more e5cf
|
||||
explicit e01e
|
||||
explore e87a
|
||||
exposure e3ca
|
||||
exposure_neg_1 e3cb
|
||||
exposure_neg_2 e3cc
|
||||
exposure_plus_1 e3cd
|
||||
exposure_plus_2 e3ce
|
||||
exposure_zero e3cf
|
||||
extension e87b
|
||||
face e87c
|
||||
fast_forward e01f
|
||||
fast_rewind e020
|
||||
favorite e87d
|
||||
favorite_border e87e
|
||||
featured_play_list e06d
|
||||
featured_video e06e
|
||||
feedback e87f
|
||||
fiber_dvr e05d
|
||||
fiber_manual_record e061
|
||||
fiber_new e05e
|
||||
fiber_pin e06a
|
||||
fiber_smart_record e062
|
||||
file_download e2c4
|
||||
file_upload e2c6
|
||||
filter e3d3
|
||||
filter_1 e3d0
|
||||
filter_2 e3d1
|
||||
filter_3 e3d2
|
||||
filter_4 e3d4
|
||||
filter_5 e3d5
|
||||
filter_6 e3d6
|
||||
filter_7 e3d7
|
||||
filter_8 e3d8
|
||||
filter_9 e3d9
|
||||
filter_9_plus e3da
|
||||
filter_b_and_w e3db
|
||||
filter_center_focus e3dc
|
||||
filter_drama e3dd
|
||||
filter_frames e3de
|
||||
filter_hdr e3df
|
||||
filter_list e152
|
||||
filter_none e3e0
|
||||
filter_tilt_shift e3e2
|
||||
filter_vintage e3e3
|
||||
find_in_page e880
|
||||
find_replace e881
|
||||
fingerprint e90d
|
||||
first_page e5dc
|
||||
fitness_center eb43
|
||||
flag e153
|
||||
flare e3e4
|
||||
flash_auto e3e5
|
||||
flash_off e3e6
|
||||
flash_on e3e7
|
||||
flight e539
|
||||
flight_land e904
|
||||
flight_takeoff e905
|
||||
flip e3e8
|
||||
flip_to_back e882
|
||||
flip_to_front e883
|
||||
folder e2c7
|
||||
folder_open e2c8
|
||||
folder_shared e2c9
|
||||
folder_special e617
|
||||
font_download e167
|
||||
format_align_center e234
|
||||
format_align_justify e235
|
||||
format_align_left e236
|
||||
format_align_right e237
|
||||
format_bold e238
|
||||
format_clear e239
|
||||
format_color_fill e23a
|
||||
format_color_reset e23b
|
||||
format_color_text e23c
|
||||
format_indent_decrease e23d
|
||||
format_indent_increase e23e
|
||||
format_italic e23f
|
||||
format_line_spacing e240
|
||||
format_list_bulleted e241
|
||||
format_list_numbered e242
|
||||
format_paint e243
|
||||
format_quote e244
|
||||
format_shapes e25e
|
||||
format_size e245
|
||||
format_strikethrough e246
|
||||
format_textdirection_l_to_r e247
|
||||
format_textdirection_r_to_l e248
|
||||
format_underlined e249
|
||||
forum e0bf
|
||||
forward e154
|
||||
forward_10 e056
|
||||
forward_30 e057
|
||||
forward_5 e058
|
||||
free_breakfast eb44
|
||||
fullscreen e5d0
|
||||
fullscreen_exit e5d1
|
||||
functions e24a
|
||||
g_translate e927
|
||||
gamepad e30f
|
||||
games e021
|
||||
gavel e90e
|
||||
gesture e155
|
||||
get_app e884
|
||||
gif e908
|
||||
golf_course eb45
|
||||
gps_fixed e1b3
|
||||
gps_not_fixed e1b4
|
||||
gps_off e1b5
|
||||
grade e885
|
||||
gradient e3e9
|
||||
grain e3ea
|
||||
graphic_eq e1b8
|
||||
grid_off e3eb
|
||||
grid_on e3ec
|
||||
group e7ef
|
||||
group_add e7f0
|
||||
group_work e886
|
||||
hd e052
|
||||
hdr_off e3ed
|
||||
hdr_on e3ee
|
||||
hdr_strong e3f1
|
||||
hdr_weak e3f2
|
||||
headset e310
|
||||
headset_mic e311
|
||||
healing e3f3
|
||||
hearing e023
|
||||
help e887
|
||||
help_outline e8fd
|
||||
high_quality e024
|
||||
highlight e25f
|
||||
highlight_off e888
|
||||
history e889
|
||||
home e88a
|
||||
hot_tub eb46
|
||||
hotel e53a
|
||||
hourglass_empty e88b
|
||||
hourglass_full e88c
|
||||
http e902
|
||||
https e88d
|
||||
image e3f4
|
||||
image_aspect_ratio e3f5
|
||||
import_contacts e0e0
|
||||
import_export e0c3
|
||||
important_devices e912
|
||||
inbox e156
|
||||
indeterminate_check_box e909
|
||||
info e88e
|
||||
info_outline e88f
|
||||
input e890
|
||||
insert_chart e24b
|
||||
insert_comment e24c
|
||||
insert_drive_file e24d
|
||||
insert_emoticon e24e
|
||||
insert_invitation e24f
|
||||
insert_link e250
|
||||
insert_photo e251
|
||||
invert_colors e891
|
||||
invert_colors_off e0c4
|
||||
iso e3f6
|
||||
keyboard e312
|
||||
keyboard_arrow_down e313
|
||||
keyboard_arrow_left e314
|
||||
keyboard_arrow_right e315
|
||||
keyboard_arrow_up e316
|
||||
keyboard_backspace e317
|
||||
keyboard_capslock e318
|
||||
keyboard_hide e31a
|
||||
keyboard_return e31b
|
||||
keyboard_tab e31c
|
||||
keyboard_voice e31d
|
||||
kitchen eb47
|
||||
label e892
|
||||
label_outline e893
|
||||
landscape e3f7
|
||||
language e894
|
||||
laptop e31e
|
||||
laptop_chromebook e31f
|
||||
laptop_mac e320
|
||||
laptop_windows e321
|
||||
last_page e5dd
|
||||
launch e895
|
||||
layers e53b
|
||||
layers_clear e53c
|
||||
leak_add e3f8
|
||||
leak_remove e3f9
|
||||
lens e3fa
|
||||
library_add e02e
|
||||
library_books e02f
|
||||
library_music e030
|
||||
lightbulb_outline e90f
|
||||
line_style e919
|
||||
line_weight e91a
|
||||
linear_scale e260
|
||||
link e157
|
||||
linked_camera e438
|
||||
list e896
|
||||
live_help e0c6
|
||||
live_tv e639
|
||||
local_activity e53f
|
||||
local_airport e53d
|
||||
local_atm e53e
|
||||
local_bar e540
|
||||
local_cafe e541
|
||||
local_car_wash e542
|
||||
local_convenience_store e543
|
||||
local_dining e556
|
||||
local_drink e544
|
||||
local_florist e545
|
||||
local_gas_station e546
|
||||
local_grocery_store e547
|
||||
local_hospital e548
|
||||
local_hotel e549
|
||||
local_laundry_service e54a
|
||||
local_library e54b
|
||||
local_mall e54c
|
||||
local_movies e54d
|
||||
local_offer e54e
|
||||
local_parking e54f
|
||||
local_pharmacy e550
|
||||
local_phone e551
|
||||
local_pizza e552
|
||||
local_play e553
|
||||
local_post_office e554
|
||||
local_printshop e555
|
||||
local_see e557
|
||||
local_shipping e558
|
||||
local_taxi e559
|
||||
location_city e7f1
|
||||
location_disabled e1b6
|
||||
location_off e0c7
|
||||
location_on e0c8
|
||||
location_searching e1b7
|
||||
lock e897
|
||||
lock_open e898
|
||||
lock_outline e899
|
||||
looks e3fc
|
||||
looks_3 e3fb
|
||||
looks_4 e3fd
|
||||
looks_5 e3fe
|
||||
looks_6 e3ff
|
||||
looks_one e400
|
||||
looks_two e401
|
||||
loop e028
|
||||
loupe e402
|
||||
low_priority e16d
|
||||
loyalty e89a
|
||||
mail e158
|
||||
mail_outline e0e1
|
||||
map e55b
|
||||
markunread e159
|
||||
markunread_mailbox e89b
|
||||
memory e322
|
||||
menu e5d2
|
||||
merge_type e252
|
||||
message e0c9
|
||||
mic e029
|
||||
mic_none e02a
|
||||
mic_off e02b
|
||||
mms e618
|
||||
mode_comment e253
|
||||
mode_edit e254
|
||||
monetization_on e263
|
||||
money_off e25c
|
||||
monochrome_photos e403
|
||||
mood e7f2
|
||||
mood_bad e7f3
|
||||
more e619
|
||||
more_horiz e5d3
|
||||
more_vert e5d4
|
||||
motorcycle e91b
|
||||
mouse e323
|
||||
move_to_inbox e168
|
||||
movie e02c
|
||||
movie_creation e404
|
||||
movie_filter e43a
|
||||
multiline_chart e6df
|
||||
music_note e405
|
||||
music_video e063
|
||||
my_location e55c
|
||||
nature e406
|
||||
nature_people e407
|
||||
navigate_before e408
|
||||
navigate_next e409
|
||||
navigation e55d
|
||||
near_me e569
|
||||
network_cell e1b9
|
||||
network_check e640
|
||||
network_locked e61a
|
||||
network_wifi e1ba
|
||||
new_releases e031
|
||||
next_week e16a
|
||||
nfc e1bb
|
||||
no_encryption e641
|
||||
no_sim e0cc
|
||||
not_interested e033
|
||||
note e06f
|
||||
note_add e89c
|
||||
notifications e7f4
|
||||
notifications_active e7f7
|
||||
notifications_none e7f5
|
||||
notifications_off e7f6
|
||||
notifications_paused e7f8
|
||||
offline_pin e90a
|
||||
ondemand_video e63a
|
||||
opacity e91c
|
||||
open_in_browser e89d
|
||||
open_in_new e89e
|
||||
open_with e89f
|
||||
pages e7f9
|
||||
pageview e8a0
|
||||
palette e40a
|
||||
pan_tool e925
|
||||
panorama e40b
|
||||
panorama_fish_eye e40c
|
||||
panorama_horizontal e40d
|
||||
panorama_vertical e40e
|
||||
panorama_wide_angle e40f
|
||||
party_mode e7fa
|
||||
pause e034
|
||||
pause_circle_filled e035
|
||||
pause_circle_outline e036
|
||||
payment e8a1
|
||||
people e7fb
|
||||
people_outline e7fc
|
||||
perm_camera_mic e8a2
|
||||
perm_contact_calendar e8a3
|
||||
perm_data_setting e8a4
|
||||
perm_device_information e8a5
|
||||
perm_identity e8a6
|
||||
perm_media e8a7
|
||||
perm_phone_msg e8a8
|
||||
perm_scan_wifi e8a9
|
||||
person e7fd
|
||||
person_add e7fe
|
||||
person_outline e7ff
|
||||
person_pin e55a
|
||||
person_pin_circle e56a
|
||||
personal_video e63b
|
||||
pets e91d
|
||||
phone e0cd
|
||||
phone_android e324
|
||||
phone_bluetooth_speaker e61b
|
||||
phone_forwarded e61c
|
||||
phone_in_talk e61d
|
||||
phone_iphone e325
|
||||
phone_locked e61e
|
||||
phone_missed e61f
|
||||
phone_paused e620
|
||||
phonelink e326
|
||||
phonelink_erase e0db
|
||||
phonelink_lock e0dc
|
||||
phonelink_off e327
|
||||
phonelink_ring e0dd
|
||||
phonelink_setup e0de
|
||||
photo e410
|
||||
photo_album e411
|
||||
photo_camera e412
|
||||
photo_filter e43b
|
||||
photo_library e413
|
||||
photo_size_select_actual e432
|
||||
photo_size_select_large e433
|
||||
photo_size_select_small e434
|
||||
picture_as_pdf e415
|
||||
picture_in_picture e8aa
|
||||
picture_in_picture_alt e911
|
||||
pie_chart e6c4
|
||||
pie_chart_outlined e6c5
|
||||
pin_drop e55e
|
||||
place e55f
|
||||
play_arrow e037
|
||||
play_circle_filled e038
|
||||
play_circle_outline e039
|
||||
play_for_work e906
|
||||
playlist_add e03b
|
||||
playlist_add_check e065
|
||||
playlist_play e05f
|
||||
plus_one e800
|
||||
poll e801
|
||||
polymer e8ab
|
||||
pool eb48
|
||||
portable_wifi_off e0ce
|
||||
portrait e416
|
||||
power e63c
|
||||
power_input e336
|
||||
power_settings_new e8ac
|
||||
pregnant_woman e91e
|
||||
present_to_all e0df
|
||||
print e8ad
|
||||
priority_high e645
|
||||
public e80b
|
||||
publish e255
|
||||
query_builder e8ae
|
||||
question_answer e8af
|
||||
queue e03c
|
||||
queue_music e03d
|
||||
queue_play_next e066
|
||||
radio e03e
|
||||
radio_button_checked e837
|
||||
radio_button_unchecked e836
|
||||
rate_review e560
|
||||
receipt e8b0
|
||||
recent_actors e03f
|
||||
record_voice_over e91f
|
||||
redeem e8b1
|
||||
redo e15a
|
||||
refresh e5d5
|
||||
remove e15b
|
||||
remove_circle e15c
|
||||
remove_circle_outline e15d
|
||||
remove_from_queue e067
|
||||
remove_red_eye e417
|
||||
remove_shopping_cart e928
|
||||
reorder e8fe
|
||||
repeat e040
|
||||
repeat_one e041
|
||||
replay e042
|
||||
replay_10 e059
|
||||
replay_30 e05a
|
||||
replay_5 e05b
|
||||
reply e15e
|
||||
reply_all e15f
|
||||
report e160
|
||||
report_problem e8b2
|
||||
restaurant e56c
|
||||
restaurant_menu e561
|
||||
restore e8b3
|
||||
restore_page e929
|
||||
ring_volume e0d1
|
||||
room e8b4
|
||||
room_service eb49
|
||||
rotate_90_degrees_ccw e418
|
||||
rotate_left e419
|
||||
rotate_right e41a
|
||||
rounded_corner e920
|
||||
router e328
|
||||
rowing e921
|
||||
rss_feed e0e5
|
||||
rv_hookup e642
|
||||
satellite e562
|
||||
save e161
|
||||
scanner e329
|
||||
schedule e8b5
|
||||
school e80c
|
||||
screen_lock_landscape e1be
|
||||
screen_lock_portrait e1bf
|
||||
screen_lock_rotation e1c0
|
||||
screen_rotation e1c1
|
||||
screen_share e0e2
|
||||
sd_card e623
|
||||
sd_storage e1c2
|
||||
search e8b6
|
||||
security e32a
|
||||
select_all e162
|
||||
send e163
|
||||
sentiment_dissatisfied e811
|
||||
sentiment_neutral e812
|
||||
sentiment_satisfied e813
|
||||
sentiment_very_dissatisfied e814
|
||||
sentiment_very_satisfied e815
|
||||
settings e8b8
|
||||
settings_applications e8b9
|
||||
settings_backup_restore e8ba
|
||||
settings_bluetooth e8bb
|
||||
settings_brightness e8bd
|
||||
settings_cell e8bc
|
||||
settings_ethernet e8be
|
||||
settings_input_antenna e8bf
|
||||
settings_input_component e8c0
|
||||
settings_input_composite e8c1
|
||||
settings_input_hdmi e8c2
|
||||
settings_input_svideo e8c3
|
||||
settings_overscan e8c4
|
||||
settings_phone e8c5
|
||||
settings_power e8c6
|
||||
settings_remote e8c7
|
||||
settings_system_daydream e1c3
|
||||
settings_voice e8c8
|
||||
share e80d
|
||||
shop e8c9
|
||||
shop_two e8ca
|
||||
shopping_basket e8cb
|
||||
shopping_cart e8cc
|
||||
short_text e261
|
||||
show_chart e6e1
|
||||
shuffle e043
|
||||
signal_cellular_4_bar e1c8
|
||||
signal_cellular_connected_no_internet_4_bar e1cd
|
||||
signal_cellular_no_sim e1ce
|
||||
signal_cellular_null e1cf
|
||||
signal_cellular_off e1d0
|
||||
signal_wifi_4_bar e1d8
|
||||
signal_wifi_4_bar_lock e1d9
|
||||
signal_wifi_off e1da
|
||||
sim_card e32b
|
||||
sim_card_alert e624
|
||||
skip_next e044
|
||||
skip_previous e045
|
||||
slideshow e41b
|
||||
slow_motion_video e068
|
||||
smartphone e32c
|
||||
smoke_free eb4a
|
||||
smoking_rooms eb4b
|
||||
sms e625
|
||||
sms_failed e626
|
||||
snooze e046
|
||||
sort e164
|
||||
sort_by_alpha e053
|
||||
spa eb4c
|
||||
space_bar e256
|
||||
speaker e32d
|
||||
speaker_group e32e
|
||||
speaker_notes e8cd
|
||||
speaker_notes_off e92a
|
||||
speaker_phone e0d2
|
||||
spellcheck e8ce
|
||||
star e838
|
||||
star_border e83a
|
||||
star_half e839
|
||||
stars e8d0
|
||||
stay_current_landscape e0d3
|
||||
stay_current_portrait e0d4
|
||||
stay_primary_landscape e0d5
|
||||
stay_primary_portrait e0d6
|
||||
stop e047
|
||||
stop_screen_share e0e3
|
||||
storage e1db
|
||||
store e8d1
|
||||
store_mall_directory e563
|
||||
straighten e41c
|
||||
streetview e56e
|
||||
strikethrough_s e257
|
||||
style e41d
|
||||
subdirectory_arrow_left e5d9
|
||||
subdirectory_arrow_right e5da
|
||||
subject e8d2
|
||||
subscriptions e064
|
||||
subtitles e048
|
||||
subway e56f
|
||||
supervisor_account e8d3
|
||||
surround_sound e049
|
||||
swap_calls e0d7
|
||||
swap_horiz e8d4
|
||||
swap_vert e8d5
|
||||
swap_vertical_circle e8d6
|
||||
switch_camera e41e
|
||||
switch_video e41f
|
||||
sync e627
|
||||
sync_disabled e628
|
||||
sync_problem e629
|
||||
system_update e62a
|
||||
system_update_alt e8d7
|
||||
tab e8d8
|
||||
tab_unselected e8d9
|
||||
tablet e32f
|
||||
tablet_android e330
|
||||
tablet_mac e331
|
||||
tag_faces e420
|
||||
tap_and_play e62b
|
||||
terrain e564
|
||||
text_fields e262
|
||||
text_format e165
|
||||
textsms e0d8
|
||||
texture e421
|
||||
theaters e8da
|
||||
thumb_down e8db
|
||||
thumb_up e8dc
|
||||
thumbs_up_down e8dd
|
||||
time_to_leave e62c
|
||||
timelapse e422
|
||||
timeline e922
|
||||
timer e425
|
||||
timer_10 e423
|
||||
timer_3 e424
|
||||
timer_off e426
|
||||
title e264
|
||||
toc e8de
|
||||
today e8df
|
||||
toll e8e0
|
||||
tonality e427
|
||||
touch_app e913
|
||||
toys e332
|
||||
track_changes e8e1
|
||||
traffic e565
|
||||
train e570
|
||||
tram e571
|
||||
transfer_within_a_station e572
|
||||
transform e428
|
||||
translate e8e2
|
||||
trending_down e8e3
|
||||
trending_flat e8e4
|
||||
trending_up e8e5
|
||||
tune e429
|
||||
turned_in e8e6
|
||||
turned_in_not e8e7
|
||||
tv e333
|
||||
unarchive e169
|
||||
undo e166
|
||||
unfold_less e5d6
|
||||
unfold_more e5d7
|
||||
update e923
|
||||
usb e1e0
|
||||
verified_user e8e8
|
||||
vertical_align_bottom e258
|
||||
vertical_align_center e259
|
||||
vertical_align_top e25a
|
||||
vibration e62d
|
||||
video_call e070
|
||||
video_label e071
|
||||
video_library e04a
|
||||
videocam e04b
|
||||
videocam_off e04c
|
||||
videogame_asset e338
|
||||
view_agenda e8e9
|
||||
view_array e8ea
|
||||
view_carousel e8eb
|
||||
view_column e8ec
|
||||
view_comfy e42a
|
||||
view_compact e42b
|
||||
view_day e8ed
|
||||
view_headline e8ee
|
||||
view_list e8ef
|
||||
view_module e8f0
|
||||
view_quilt e8f1
|
||||
view_stream e8f2
|
||||
view_week e8f3
|
||||
vignette e435
|
||||
visibility e8f4
|
||||
visibility_off e8f5
|
||||
voice_chat e62e
|
||||
voicemail e0d9
|
||||
volume_down e04d
|
||||
volume_mute e04e
|
||||
volume_off e04f
|
||||
volume_up e050
|
||||
vpn_key e0da
|
||||
vpn_lock e62f
|
||||
wallpaper e1bc
|
||||
warning e002
|
||||
watch e334
|
||||
watch_later e924
|
||||
wb_auto e42c
|
||||
wb_cloudy e42d
|
||||
wb_incandescent e42e
|
||||
wb_iridescent e436
|
||||
wb_sunny e430
|
||||
wc e63d
|
||||
web e051
|
||||
web_asset e069
|
||||
weekend e16b
|
||||
whatshot e80e
|
||||
widgets e1bd
|
||||
wifi e63e
|
||||
wifi_lock e1e1
|
||||
wifi_tethering e1e2
|
||||
work e8f9
|
||||
wrap_text e25b
|
||||
youtube_searched_for e8fa
|
||||
zoom_in e8ff
|
||||
zoom_out e900
|
||||
zoom_out_map e56b
|
||||
@@ -0,0 +1,36 @@
|
||||
@font-face {
|
||||
font-family: 'Material Icons';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
src: url(MaterialIcons-Regular.eot); /* For IE6-8 */
|
||||
src: local('Material Icons'),
|
||||
local('MaterialIcons-Regular'),
|
||||
url(MaterialIcons-Regular.woff2) format('woff2'),
|
||||
url(MaterialIcons-Regular.woff) format('woff'),
|
||||
url(MaterialIcons-Regular.ttf) format('truetype');
|
||||
}
|
||||
|
||||
.material-icons {
|
||||
font-family: 'Material Icons';
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
font-size: 24px; /* Preferred icon size */
|
||||
display: inline-block;
|
||||
line-height: 1;
|
||||
text-transform: none;
|
||||
letter-spacing: normal;
|
||||
word-wrap: normal;
|
||||
white-space: nowrap;
|
||||
direction: ltr;
|
||||
|
||||
/* Support for all WebKit browsers. */
|
||||
-webkit-font-smoothing: antialiased;
|
||||
/* Support for Safari and Chrome. */
|
||||
text-rendering: optimizeLegibility;
|
||||
|
||||
/* Support for Firefox. */
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
|
||||
/* Support for IE. */
|
||||
font-feature-settings: 'liga';
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
@font-face {
|
||||
font-family: 'Material Icons';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
src: url(iconfont/MaterialIcons-Regular.eot); /* For IE6-8 */
|
||||
src: local('Material Icons'),
|
||||
local('MaterialIcons-Regular'),
|
||||
url(iconfont/MaterialIcons-Regular.woff2) format('woff2'),
|
||||
url(iconfont/MaterialIcons-Regular.woff) format('woff'),
|
||||
url(iconfont/MaterialIcons-Regular.ttf) format('truetype');
|
||||
}
|
||||
|
||||
.material-icons {
|
||||
font-family: 'Material Icons';
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
font-size: 24px; /* Preferred icon size */
|
||||
display: inline-block;
|
||||
line-height: 1;
|
||||
text-transform: none;
|
||||
letter-spacing: normal;
|
||||
word-wrap: normal;
|
||||
white-space: nowrap;
|
||||
direction: ltr;
|
||||
|
||||
/* Support for all WebKit browsers. */
|
||||
-webkit-font-smoothing: antialiased;
|
||||
/* Support for Safari and Chrome. */
|
||||
text-rendering: optimizeLegibility;
|
||||
|
||||
/* Support for Firefox. */
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
|
||||
/* Support for IE. */
|
||||
font-feature-settings: 'liga';
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
/* css-loading-spinners.css
|
||||
Copyright (c) Ian A. Cook
|
||||
MIT License
|
||||
https://github.com/nai888/css-loading-spinners
|
||||
*/
|
||||
|
||||
:root {
|
||||
--cls-color: #558B6E;
|
||||
--cls-sec-color: #EEEEEE;
|
||||
--cls-size: 5rem;
|
||||
--cls-margin: 1rem;
|
||||
--cls-speed: 2s;
|
||||
}
|
||||
|
||||
@-webkit-keyframes spin {
|
||||
0% {
|
||||
-webkit-transform: rotate(0deg);
|
||||
} 100% {
|
||||
-webkit-transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% {
|
||||
-webkit-transform: rotate(0deg);
|
||||
transform: rotate(0deg);
|
||||
} 100% {
|
||||
-webkit-transform: rotate(360deg);
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@-webkit-keyframes flip-flop {
|
||||
0%, 100% {
|
||||
-webkit-transform: perspective(calc(var(--cls-size) / 5 * 8)) rotateY(0deg);
|
||||
} 50% {
|
||||
-webkit-transform: perspective(calc(var(--cls-size) / 5 * 8)) rotateY(180deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes flip-flop {
|
||||
0%, 100% {
|
||||
-webkit-transform: perspective(calc(var(--cls-size) / 5 * 8)) rotateY(0deg);
|
||||
transform: perspective(calc(var(--cls-size) / 5 * 8)) rotateY(0deg);
|
||||
} 50% {
|
||||
-webkit-transform: perspective(calc(var(--cls-size) / 5 * 8)) rotateY(180deg);
|
||||
transform: perspective(calc(var(--cls-size) / 5 * 8)) rotateY(180deg);
|
||||
}
|
||||
}
|
||||
|
||||
@-webkit-keyframes signal {
|
||||
0% {
|
||||
-webkit-transform: scale(0);
|
||||
-webkit-opacity: 0;
|
||||
} 50% {
|
||||
-webkit-opacity: 1;
|
||||
} 100% {
|
||||
-webkit-transform: scale(1);
|
||||
-webkit-opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes signal {
|
||||
0% {
|
||||
-webkit-transform: scale(0);
|
||||
transform: scale(0);
|
||||
-webkit-opacity: 0;
|
||||
opacity: 0;
|
||||
} 50% {
|
||||
-webkit-opacity: 1;
|
||||
opacity: 1;
|
||||
} 100% {
|
||||
-webkit-transform: scale(1);
|
||||
transform: scale(1);
|
||||
-webkit-opacity: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@-webkit-keyframes grow-shrink {
|
||||
0%, 100% {
|
||||
-webkit-transform: scale(0);
|
||||
} 50% {
|
||||
-webkit-transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes grow-shrink {
|
||||
0%, 100% {
|
||||
-webkit-transform: scale(0);
|
||||
transform: scale(0);
|
||||
} 50% {
|
||||
-webkit-transform: scale(1);
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
[class^="cls-"] {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.cls-spinner {
|
||||
display: block;
|
||||
position: relative;
|
||||
width: var(--cls-size);
|
||||
height: var(--cls-size);
|
||||
margin: var(--cls-margin);
|
||||
}
|
||||
|
||||
.cls-flip-flop {
|
||||
-webkit-animation: flip-flop calc(var(--cls-speed) / 2) ease infinite;
|
||||
animation: flip-flop calc(var(--cls-speed) / 2) ease infinite;
|
||||
}
|
||||
|
||||
.cls-spin {
|
||||
-webkit-animation: spin var(--cls-speed) linear infinite;
|
||||
animation: spin var(--cls-speed) linear infinite;
|
||||
}
|
||||
|
||||
.cls-circle {
|
||||
border-width: calc(var(--cls-size) / 5 * 0.75);
|
||||
border-style: solid;
|
||||
border-color: var(--cls-sec-color);
|
||||
border-top-color: var(--cls-color);
|
||||
border-radius: 50%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.cls-dual-circle {
|
||||
border-width: calc(var(--cls-size) / 5 * 0.75);
|
||||
border-style: solid;
|
||||
border-color: var(--cls-color) transparent;
|
||||
border-radius: 50%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.cls-bowtie, .cls-bowtie-v {
|
||||
border-width: calc(var(--cls-size) / 2);
|
||||
border-style: solid;
|
||||
border-color: transparent var(--cls-color);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.cls-bowtie-v {
|
||||
border-color: var(--cls-color) transparent;
|
||||
}
|
||||
|
||||
.cls-square {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: var(--cls-color);
|
||||
}
|
||||
|
||||
.cls-signal, .cls-triple-signal, .cls-triple-signal::before, .cls-triple-signal::after {
|
||||
border-width: calc(var(--cls-size) / 25);
|
||||
border-style: solid;
|
||||
border-color: var(--cls-color);
|
||||
border-radius: 50%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
opacity: 0;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.cls-triple-signal::before, .cls-triple-signal::after, .cls-rings::before, .cls-rings::after {
|
||||
content: '';
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
margin: calc(var(--cls-size) / -2) 0 0 calc(var(--cls-size) / -2);
|
||||
}
|
||||
|
||||
.cls-signal, .cls-triple-signal, .cls-triple-signal::before, .cls-triple-signal::after {
|
||||
-webkit-animation: signal calc(var(--cls-speed) / 2) ease-out infinite;
|
||||
animation: signal calc(var(--cls-speed) / 2) ease-out infinite;
|
||||
}
|
||||
|
||||
.cls-triple-signal::before {
|
||||
-webkit-animation-delay: calc(var(--cls-speed) / 20);
|
||||
animation-delay: calc(var(--cls-speed) / 20);
|
||||
}
|
||||
|
||||
.cls-triple-signal::after {
|
||||
-webkit-animation-delay: calc(var(--cls-speed) * 3 / 20);
|
||||
animation-delay: calc(var(--cls-speed) * 3 / 20);
|
||||
}
|
||||
|
||||
.cls-ring, .cls-rings, .cls-rings::before, .cls-rings::after {
|
||||
border-width: calc(var(--cls-size) / 25);
|
||||
border-style: solid;
|
||||
border-color: var(--cls-color);
|
||||
border-radius: 50%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.cls-rings, .cls-rings::before, .cls-rings::after {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.cls-ring, .cls-rings, .cls-rings::before, .cls-rings::after {
|
||||
-webkit-animation: grow-shrink var(--cls-speed) ease-in-out infinite;
|
||||
animation: grow-shrink var(--cls-speed) ease-in-out infinite;
|
||||
}
|
||||
|
||||
.cls-rings::before {
|
||||
-webkit-animation-delay: calc(var(--cls-speed) / -4);
|
||||
animation-delay: calc(var(--cls-speed) / -4);
|
||||
}
|
||||
|
||||
.cls-rings::after {
|
||||
-webkit-animation-delay: calc(var(--cls-speed) / 4);
|
||||
animation-delay: calc(var(--cls-speed) / 4);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,590 @@
|
||||
body {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
main {
|
||||
flex: 1 0 auto;
|
||||
}
|
||||
|
||||
html {
|
||||
background-color: var(--background-primary-color);
|
||||
color: var(--text-primary-color);
|
||||
}
|
||||
|
||||
label {
|
||||
color: var(--text-primary-color);
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--accent-link-color);
|
||||
}
|
||||
|
||||
input {
|
||||
color: var(--text-primary-color);
|
||||
}
|
||||
|
||||
i.material-icons.active, input:focus + label {
|
||||
color: var(--accent-focus-color) !important;
|
||||
}
|
||||
|
||||
i.material-icons.active, textarea:focus + label {
|
||||
color: var(--accent-focus-color) !important;
|
||||
}
|
||||
|
||||
input:focus {
|
||||
border-bottom: 1px solid var(--accent-focus-color) !important;
|
||||
}
|
||||
|
||||
.helper-text {
|
||||
color: var(--helper-text-color) !important;
|
||||
}
|
||||
|
||||
.sidenav,
|
||||
.card,
|
||||
.dropdown-content {
|
||||
background-color: var(--background-color);
|
||||
}
|
||||
|
||||
.dropdown-content li:hover,
|
||||
.dropdown-content li.active {
|
||||
background-color: var(--background-hover);
|
||||
}
|
||||
|
||||
nav {
|
||||
background-color: var(--background-color);
|
||||
border-bottom: 1px solid var(--divider-color);
|
||||
}
|
||||
|
||||
nav .brand-logo {
|
||||
color: var(--text-secondary-color);
|
||||
}
|
||||
|
||||
nav ul a {
|
||||
color: var(--text-secondary-color);
|
||||
}
|
||||
|
||||
nav a i,
|
||||
nav ul a i,
|
||||
.sidenav li > a > i.material-icons {
|
||||
color: var(--icon-color);
|
||||
}
|
||||
|
||||
.modal,
|
||||
.modal .modal-footer {
|
||||
background-color: var(--background-modal-color);
|
||||
}
|
||||
|
||||
.modal {
|
||||
box-shadow: 0 24px 38px 3px var(--shadow-color), 0 9px 46px 8px var(--shadow-color), 0 11px 15px -7px var(--shadow-color);
|
||||
}
|
||||
|
||||
.card, .dropdown-content {
|
||||
box-shadow: 0 2px 2px 0 var(--shadow-color), 0 3px 1px -2px var(--shadow-color), 0 1px 5px 0 var(--shadow-color);
|
||||
|
||||
}
|
||||
|
||||
.dropdown-content li > a,
|
||||
.dropdown-content li > span,
|
||||
.sidenav li > a {
|
||||
color: var(--text-secondary-color);
|
||||
}
|
||||
|
||||
[type="checkbox"].filled-in:checked + span:not(.lever):after {
|
||||
border: 2px solid var(--accent-color);
|
||||
background-color: var(--accent-color);
|
||||
}
|
||||
|
||||
.source-icon {
|
||||
margin-right: 6px;
|
||||
fill: var(--accent-color);
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.icon-block .material-icons {
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
.indented-li {
|
||||
padding-left: 15px;
|
||||
padding-top: 5px;
|
||||
padding-bottom: 5px;
|
||||
}
|
||||
|
||||
.important-inline {
|
||||
display: inline !important;
|
||||
}
|
||||
|
||||
.detail-node {
|
||||
margin-top: 0px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.top-btn {
|
||||
box-shadow: none;
|
||||
-webkit-box-shadow: none;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.icon-warn-delete {
|
||||
color: var(--icon-warn);
|
||||
}
|
||||
|
||||
.input-field > label {
|
||||
color: var(--input-label);
|
||||
}
|
||||
|
||||
.default-hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.console-error {
|
||||
color: #b30000;
|
||||
}
|
||||
|
||||
.console-warning {
|
||||
color: #a55d00;
|
||||
}
|
||||
|
||||
.console-default {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.console-debug {
|
||||
color: #3141d1;
|
||||
}
|
||||
|
||||
.console-trace {
|
||||
color: #282828;
|
||||
}
|
||||
|
||||
.console-critical {
|
||||
color: #810000;
|
||||
}
|
||||
|
||||
.colpick {
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
#toast-container {
|
||||
top: auto !important;
|
||||
left: auto !important;
|
||||
bottom: 10%;
|
||||
right: 7%;
|
||||
}
|
||||
|
||||
nav {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
nav .brand-logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
white-space: nowrap;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
nav .brand-logo img {
|
||||
margin-left: 1rem;
|
||||
margin-right: 1rem;
|
||||
}
|
||||
|
||||
.nav-wrapper {
|
||||
height: 56px;
|
||||
}
|
||||
|
||||
.sidenav-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
background-color: var(--background-sidenav-color);
|
||||
padding-top: 1rem;
|
||||
padding-bottom: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.modal {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.choose-folder-container {
|
||||
padding: 5px 15px;
|
||||
margin: 10px 0;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.snapshot-card {
|
||||
padding-top: 24px;
|
||||
padding-right: 24px;
|
||||
padding-left: 24px;
|
||||
height: 145px;
|
||||
border-radius: 0 0 2px 2px;
|
||||
}
|
||||
|
||||
.copyright {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 18px;
|
||||
}
|
||||
|
||||
.backup-ui .archive-icon {
|
||||
position: relative;
|
||||
margin-right: 18px;
|
||||
top: -10px;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
.page-footer {
|
||||
padding: 10px !important;
|
||||
color: var(--text-secondary-color) !important;
|
||||
background-color: var(--background-color);
|
||||
border-top: 1px solid var(--divider-color);
|
||||
}
|
||||
|
||||
.bmc-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 30px;
|
||||
text-decoration: none;
|
||||
color: var(--accent-text-color);
|
||||
background-color: var(--accent-color);
|
||||
border-radius: 5px;
|
||||
border: 1px solid transparent;
|
||||
padding: 5px;
|
||||
font-family: Cookie, cursive;
|
||||
font-size: 20px;
|
||||
min-width: 128px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.bmc-button svg {
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
.bmc-cup {
|
||||
fill: var(--accent-dark)
|
||||
}
|
||||
|
||||
.bmc-outline {
|
||||
fill: var(--accent-color)
|
||||
}
|
||||
.btn-floating {
|
||||
background-color: var(--accent-color);
|
||||
color: var(--accent-text-color);
|
||||
}
|
||||
|
||||
.btn-floating:hover {
|
||||
background-color: var(--accent-color);
|
||||
}
|
||||
|
||||
#contributors-simple a {
|
||||
margin-right: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
#contributors-extra .contributor-img-wrapper a {
|
||||
display: flex;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
#contributors a img {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
#contributors-count {
|
||||
font-size: 17px;
|
||||
background-color: var(--divider-color);
|
||||
padding: 2px 8px;
|
||||
border-radius: 9999px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.contributor-extra {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.truncate-two-lines {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.divider {
|
||||
background-color: var(--divider-color);
|
||||
}
|
||||
|
||||
textarea {
|
||||
background-color: var(--background-primary-color);
|
||||
color: var(--text-primary-color);
|
||||
}
|
||||
|
||||
.card .card-action a:not(.btn):not(.btn-large):not(.btn-small):not(.btn-large):not(.btn-floating) {
|
||||
color: var(--accent-color);
|
||||
}
|
||||
|
||||
.card .card-action a:not(.btn):not(.btn-large):not(.btn-small):not(.btn-large):not(.btn-floating):hover {
|
||||
color: var(--accent-hover-color);
|
||||
}
|
||||
|
||||
.btn-flat {
|
||||
background-color: transparent;
|
||||
color: var(--accent-color);
|
||||
font-weight: 600;
|
||||
-webkit-transition: all .3s ease;
|
||||
transition: all .3s ease;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.btn-flat:hover {
|
||||
color: var(--accent-hover-color);
|
||||
background-color: var(--accent-bg-hover-color);
|
||||
}
|
||||
|
||||
.btn-flat:focus {
|
||||
color: var(--accent-hover-color);
|
||||
background-color: var(--accent-bg-hover-color);
|
||||
}
|
||||
|
||||
.btn-high-vis {
|
||||
color: var(--accent-hover-color);
|
||||
background-color: var(--accent-bg-hover-color);
|
||||
}
|
||||
|
||||
.btn-high-vis:hover {
|
||||
color: var(--accent-hover-color);
|
||||
background-color: var(--accent-bg-hover-color);
|
||||
}
|
||||
|
||||
.danger-btn {
|
||||
color: var(--danger-text);
|
||||
}
|
||||
|
||||
.danger-btn:hover {
|
||||
color: var(--danger-bg);
|
||||
background-color: var(--danger-text);
|
||||
}
|
||||
|
||||
.card .card-action .danger-btn a:not(.btn):not(.btn-large):not(.btn-small):not(.btn-large):not(.btn-floating) {
|
||||
color: var(--danger-text);
|
||||
}
|
||||
|
||||
.high-vis-danger-btn {
|
||||
color: var(--danger-bg);
|
||||
background-color: var(--danger-text);
|
||||
}
|
||||
|
||||
.high-vis-danger-btn:hover {
|
||||
color: var(--danger-bg);
|
||||
background-color: var(--danger-text);
|
||||
}
|
||||
|
||||
.danger-text {
|
||||
color: var(--danger-text);
|
||||
}
|
||||
|
||||
.blue-icon {
|
||||
color: var(--blue-icon);
|
||||
}
|
||||
|
||||
.card .card-action {
|
||||
padding: 8px 24px;
|
||||
}
|
||||
|
||||
.card-title > .material-icons {
|
||||
font-size: 50px;
|
||||
padding-right: 10px;
|
||||
}
|
||||
|
||||
.card-title > span {
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.inline-icon {
|
||||
display: inline;
|
||||
margin-right: 2px;
|
||||
vertical-align: middle;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.btn-flat .material-icons {
|
||||
display: inline;
|
||||
margin-right: 5px;
|
||||
vertical-align: middle;
|
||||
font-size: 20px;
|
||||
padding-bottom: 3px;
|
||||
}
|
||||
|
||||
.detail-badge {
|
||||
margin: 8px 8px;
|
||||
white-space: nowrap;
|
||||
align-content: center;
|
||||
}
|
||||
|
||||
.detail-badge .material-icons {
|
||||
display: inline;
|
||||
margin-right: 2px;
|
||||
vertical-align: bottom;
|
||||
}
|
||||
|
||||
.spinner-layer {
|
||||
border-color: var(--accent-color);
|
||||
}
|
||||
|
||||
.preloader-wrapper.inline {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.sub-detail {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.sub-detail .material-icons {
|
||||
font-size: 16px;
|
||||
vertical-align: text-bottom;
|
||||
}
|
||||
|
||||
.progress .determinate {
|
||||
background-color: var(--accent-color);
|
||||
}
|
||||
|
||||
.progress .indeterminate {
|
||||
background-color: var(--accent-color);
|
||||
}
|
||||
|
||||
.progress {
|
||||
background: var(--progress-bg);
|
||||
}
|
||||
|
||||
.indent {
|
||||
margin-left: 36px;
|
||||
}
|
||||
|
||||
blockquote {
|
||||
border-left: 5px solid var(--accent-color);
|
||||
}
|
||||
|
||||
.content-logo {
|
||||
max-height: 2rem;
|
||||
margin-right: 9px;
|
||||
}
|
||||
|
||||
span.content-logo > i {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.sub {
|
||||
color: var(--input-label);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.content-icon {
|
||||
margin-right: 9px;
|
||||
fill: var(--text-primary-color);
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.detail-badge {
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.detail-name {
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.backup-card-icon {
|
||||
vertical-align: -2px;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.backup-help-icon {
|
||||
vertical-align: 3px;
|
||||
display: inline;
|
||||
font-size: 0.75rem;
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
input.valid:not([type]), input.valid:not([type]):focus, input[type="text"].valid:not(.browser-default), input[type="text"].valid:not(.browser-default):focus, input[type="password"].valid:not(.browser-default), input[type="password"].valid:not(.browser-default):focus, input[type="email"].valid:not(.browser-default), input[type="email"].valid:not(.browser-default):focus, input[type="url"].valid:not(.browser-default), input[type="url"].valid:not(.browser-default):focus, input[type="time"].valid:not(.browser-default), input[type="time"].valid:not(.browser-default):focus, input[type="date"].valid:not(.browser-default), input[type="date"].valid:not(.browser-default):focus, input[type="datetime"].valid:not(.browser-default), input[type="datetime"].valid:not(.browser-default):focus, input[type="datetime-local"].valid:not(.browser-default), input[type="datetime-local"].valid:not(.browser-default):focus, input[type="tel"].valid:not(.browser-default), input[type="tel"].valid:not(.browser-default):focus, input[type="number"].valid:not(.browser-default), input[type="number"].valid:not(.browser-default):focus, input[type="search"].valid:not(.browser-default), input[type="search"].valid:not(.browser-default):focus, textarea.materialize-textarea.valid, textarea.materialize-textarea.valid:focus, .select-wrapper.valid > input.select-dropdown {
|
||||
border-bottom: 1px solid var(--accent-color);
|
||||
-webkit-box-shadow: 0 1px 0 0 var(--accent-color);
|
||||
box-shadow: 0 1px 0 0 var(--accent-color);
|
||||
}
|
||||
|
||||
input:not([type]):focus:not([readonly]), input[type="text"]:not(.browser-default):focus:not([readonly]), input[type="password"]:not(.browser-default):focus:not([readonly]), input[type="email"]:not(.browser-default):focus:not([readonly]), input[type="url"]:not(.browser-default):focus:not([readonly]), input[type="time"]:not(.browser-default):focus:not([readonly]), input[type="date"]:not(.browser-default):focus:not([readonly]), input[type="datetime"]:not(.browser-default):focus:not([readonly]), input[type="datetime-local"]:not(.browser-default):focus:not([readonly]), input[type="tel"]:not(.browser-default):focus:not([readonly]), input[type="number"]:not(.browser-default):focus:not([readonly]), input[type="search"]:not(.browser-default):focus:not([readonly]), textarea.materialize-textarea:focus:not([readonly]) {
|
||||
border-bottom: 1px solid var(--accent-color);
|
||||
-webkit-box-shadow: 0 1px 0 0 var(--accent-color);
|
||||
box-shadow: 0 1px 0 0 var(--accent-color);
|
||||
}
|
||||
|
||||
.toast .toast-action {
|
||||
color: var(--accent-color);
|
||||
font-weight: 600;
|
||||
margin-right: 0px;
|
||||
}
|
||||
|
||||
.toast .toast-action:hover {
|
||||
color: var(--accent-hover-color);
|
||||
background-color: var(--accent-bg-hover-color);
|
||||
}
|
||||
|
||||
.device-code-box {
|
||||
font-size: 2em;
|
||||
width: auto;
|
||||
border-color: var(--accent-text-color);
|
||||
color: var(--accent-text-color);
|
||||
background: var(--accent-color);
|
||||
border-radius: 6px;
|
||||
padding: 6px;
|
||||
margin: 6px;
|
||||
}
|
||||
|
||||
.card .card-content .card-title.mini-title {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.device-code-box:hover {
|
||||
background-color: var(--accent-bg-hover-color);
|
||||
}
|
||||
|
||||
.qr-code {
|
||||
padding: 5px;
|
||||
background-color: white;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.title-logo {
|
||||
position: relative;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
top: 8px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.flex-wrap {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.donate-button {
|
||||
margin: 7px;
|
||||
}
|
||||
|
||||
.donate-button img {
|
||||
width: 150px;
|
||||
height: 40px;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<!-- Creator: CorelDRAW 2019 (64-Bit) -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" width="100%" height="100%" version="1.1" shape-rendering="geometricPrecision" text-rendering="geometricPrecision" image-rendering="optimizeQuality" fill-rule="evenodd" clip-rule="evenodd" viewBox="0 0 4257.46 1132" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:xodm="http://www.corel.com/coreldraw/odm/2003">
|
||||
<g id="Layer_x0020_1">
|
||||
<metadata id="CorelCorpID_0Corel-Layer"/>
|
||||
<path transform="scale(7.8, 7.4)" d="M0 24.48C0 10.9601 10.9601 0 24.48 0H520.2C533.72 0 544.68 10.9601 544.68 24.48V128.52C544.68 142.04 533.72 153 520.2 153H24.48C10.9601 153 0 142.04 0 128.52V24.48Z" fill="#eeeeee"/>
|
||||
<g id="_1421487920208" transform="translate(400, 200) scale(0.8, 0.8)">
|
||||
<path fill="#F7931A" fill-rule="nonzero" d="M875.92 551.95c-59.08,238.24 -300.11,383.47 -538.36,324.39 -238.24,-59.07 -383.47,-300.11 -324.39,-538.35 59.08,-238.25 300.11,-383.49 538.35,-324.41 0.4,0.1 0.81,0.2 1.21,0.31 237.61,59.63 382.17,300.28 323.19,538.06z"/>
|
||||
<path fill="white" fill-rule="nonzero" d="M545.37 380.28c-13.89,55.56 -98.61,27.08 -126.11,20.28l24.3 -97.22c27.08,6.67 115.7,19.44 101.39,76.94l0.42 0zm-15.14 157.1c-15,60.54 -116.94,27.78 -150,19.57l26.8 -107.36c33.06,8.33 138.89,24.58 123.2,87.79zm111.11 -156.26c8.75,-59.17 -36.25,-90.97 -97.22,-112.08l20 -80.14 -49.3 -12.22 -19.44 78.06c-12.78,-3.19 -25.97,-6.25 -39.03,-9.17l19.44 -78.89 -48.75 -12.22 -20 80.14 -31.11 -6.94 -67.36 -16.81 -12.92 52.09c0,0 36.11,8.33 35.42,8.89 13.79,1.67 23.85,13.92 22.78,27.78l-22.78 91.25c1.71,0.39 3.39,0.94 5,1.67l-5.14 -1.39 -31.81 127.92c-2.98,9.36 -12.98,14.51 -22.35,11.53l-0.01 0c0,0.69 -35.56,-8.75 -35.56,-8.75l-24.17 55.56 63.47 15.7 34.72 9.03 -20.14 80.97 48.75 12.22 20 -80.14c13.14,3.61 26.07,6.94 38.75,10l-19.72 80.42 48.75 12.08 20.14 -80.83c83.33,15.71 145.69,9.44 172.08,-65.83 21.25,-60.56 -1.11,-95.42 -44.86,-118.2 31.94,-7.36 55.56,-27.78 62.36,-71.67z"/>
|
||||
<path fill="#4D4D4D" fill-rule="nonzero" d="M1157.17 750c25.96,0.08 51.5,-6.53 74.17,-19.17 23.33,-12.44 43.85,-29.57 60.28,-50.28 17.12,-21.9 30.58,-46.43 39.86,-72.64 9.79,-27.08 14.76,-55.65 14.72,-84.44 1.56,-29.21 -4.68,-58.31 -18.06,-84.31 -11.94,-20.14 -34.17,-30.41 -66.39,-30.41 -14.04,0.48 -28,2.39 -41.67,5.69 -16.97,3.79 -32.67,11.93 -45.56,23.61l-73.61 307.78 11.81 2.22c3.51,0.86 7.1,1.42 10.69,1.67 4.61,0.57 9.25,0.8 13.89,0.69l19.86 -0.42zm146.25 -481.11c32.15,-0.74 64.07,5.82 93.33,19.17 25.4,12.11 47.72,29.82 65.28,51.81 17.98,22.74 31.2,48.85 38.89,76.8 8.42,30.82 12.57,62.64 12.36,94.59 -0.04,99.4 -38.57,194.91 -107.5,266.53 -33.42,34.28 -73.18,61.72 -117.08,80.83 -46.03,20.26 -95.82,30.59 -146.11,30.28l-35.56 0c-19.33,-0.63 -38.63,-2.29 -57.78,-5 -23.39,-3.39 -46.57,-8.02 -69.44,-13.89 -23.98,-5.69 -47.26,-13.98 -69.44,-24.72l195.14 -817.5 174.31 -27.78 -69.44 290.14c14.46,-6.54 29.47,-11.79 44.86,-15.69 15.86,-3.86 32.15,-5.78 48.47,-5.69l-0.28 0.14z"/>
|
||||
<path fill="#4D4D4D" fill-rule="nonzero" d="M1774.39 209.3c-22.72,0.22 -44.87,-7.06 -63.06,-20.69 -19.28,-15.25 -29.7,-39.1 -27.78,-63.61 -0.08,-15.29 3.18,-30.42 9.58,-44.31 6.13,-13.53 14.74,-25.78 25.42,-36.11 10.67,-10.14 23.03,-18.32 36.53,-24.17 14.04,-6.03 29.17,-9.1 44.44,-9.03 22.56,-0.07 44.5,7.25 62.5,20.83 19.24,15.28 29.65,39.11 27.78,63.61 0.12,15.33 -3.1,30.5 -9.44,44.44 -6.13,13.46 -14.7,25.65 -25.28,35.97 -10.64,10.18 -23,18.36 -36.53,24.17 -14.04,6.03 -29.17,9.06 -44.44,8.89l0.28 0zm-80.97 663.76l-166.67 0 140.83 -591.68 167.64 0 -141.81 591.68z"/>
|
||||
<path fill="#4D4D4D" fill-rule="nonzero" d="M1981.06 134.03l174.3 -26.94 -43.33 174.31 186.68 0 -33.63 137.22 -185.14 0 -49.44 206.39c-4.28,15.8 -6.93,32 -7.92,48.35 -1.02,13.22 0.94,26.5 5.69,38.87 4.65,11.1 13.26,20.07 24.17,25.14 15.7,6.94 32.84,10.1 50,9.17 17.58,0.06 35.12,-1.67 52.36,-5.14 17.35,-3.39 34.44,-8.03 51.11,-13.89l12.5 128.33c-23.93,8.6 -48.38,15.71 -73.2,21.25 -30.67,6.51 -61.98,9.54 -93.33,9.03 -51.81,0 -91.81,-7.78 -120.42,-23.06 -26.76,-13.61 -48.2,-35.81 -60.83,-63.06 -12.33,-28.91 -17.71,-60.31 -15.69,-91.67 1.87,-36.79 7.12,-73.33 15.69,-109.17l110.42 -465.69 0 0.56z"/>
|
||||
<path fill="#4D4D4D" fill-rule="nonzero" d="M2292.59 636.8c-0.35,-49.08 8.01,-97.86 24.72,-144.02 15.67,-43.65 39.74,-83.8 70.83,-118.19 31.35,-34.11 69.47,-61.26 111.94,-79.74 46.32,-20 96.35,-29.99 146.8,-29.29 30.46,-0.36 60.86,2.85 90.56,9.57 24.97,5.92 49.26,14.39 72.5,25.28l-57.91 130.14c-15,-6.11 -30.56,-11.37 -46.67,-16.25 -19.21,-5.35 -39.1,-7.83 -59.03,-7.36 -50.36,-1.82 -98.8,19.47 -131.53,57.78 -32.5,38.23 -48.81,89.62 -48.89,154.17 -1.53,32.74 7.11,65.14 24.72,92.79 16.47,23.61 46.86,35.4 91.11,35.4 21.21,0.02 42.35,-2.26 63.06,-6.8 18.47,-3.94 36.57,-9.56 54.03,-16.81l12.36 133.9c-22.72,8.6 -45.9,15.93 -69.44,21.93 -29.89,6.74 -60.47,9.92 -91.11,9.46 -40.67,1.18 -81.15,-5.67 -119.17,-20.15 -30.22,-12.18 -57.43,-30.8 -79.72,-54.57 -21.15,-23.04 -36.78,-50.6 -45.69,-80.57 -9.43,-31.54 -14.11,-64.31 -13.89,-97.22l0.42 0.56z"/>
|
||||
<path fill="#4D4D4D" fill-rule="nonzero" d="M3114.94 407.5c-23.5,-0.33 -46.47,7.14 -65.28,21.25 -19.07,14.76 -35.07,33.13 -47.08,54.03 -13.21,22.25 -23.13,46.31 -29.44,71.41 -6.11,24.06 -9.29,48.76 -9.44,73.59 -1.5,30.31 4.67,60.49 17.92,87.78 12.09,20.97 33.75,31.53 65.28,31.53 23.54,0.4 46.54,-7.13 65.28,-21.39 19.11,-14.76 35.14,-33.13 47.22,-54.01 13.02,-22.28 22.7,-46.33 28.76,-71.41 6.04,-24.11 9.22,-48.87 9.43,-73.75 1.56,-30.29 -4.63,-60.5 -17.92,-87.78 -12.08,-20.83 -33.89,-31.39 -65.28,-31.39l0.56 0.14zm-83.33 481.39c-35.39,0.82 -70.58,-5.32 -103.61,-18.06 -27.79,-10.96 -52.61,-28.26 -72.5,-50.56 -19.56,-22.46 -34.29,-48.69 -43.33,-77.08 -9.82,-31.83 -14.52,-65.03 -13.89,-98.33 0.11,-45.86 7.46,-91.43 21.82,-135 13.93,-43.85 35.68,-84.82 64.15,-120.97 28.6,-36.14 64.17,-66.18 104.58,-88.33 43.5,-23.39 92.28,-35.21 141.67,-34.31 35.18,-0.64 70.18,5.5 103.06,18.06 27.82,10.78 52.78,27.85 72.91,49.86 19.44,22.52 34.14,48.74 43.2,77.08 9.89,31.86 14.58,65.11 13.89,98.47 -0.14,45.82 -7.31,91.36 -21.25,135 -13.69,43.91 -35.04,85.04 -63.06,121.53 -28.18,36.29 -63.61,66.33 -104.03,88.2 -44.17,23.51 -93.58,35.37 -143.61,34.44z"/>
|
||||
<path fill="#4D4D4D" fill-rule="nonzero" d="M3626.75 209.3c-22.68,0.24 -44.81,-7.04 -62.91,-20.69 -19.29,-15.25 -29.71,-39.1 -27.78,-63.61 -0.08,-15.29 3.18,-30.42 9.57,-44.31 5.96,-13.46 14.33,-25.69 24.72,-36.11 10.74,-10.11 23.14,-18.29 36.67,-24.17 14,-6.02 29.08,-9.09 44.32,-9.03 22.72,-0.2 44.91,7.14 63.06,20.83 19.24,15.28 29.64,39.11 27.78,63.61 0.03,15.35 -3.29,30.52 -9.74,44.44 -6.02,13.47 -14.56,25.68 -25.13,35.97 -10.64,10.18 -23,18.36 -36.53,24.17 -13.92,5.97 -28.9,9 -44.04,8.89zm-80.82 663.76l-166.67 0 140.56 -591.68 167.78 0 -141.67 591.68z"/>
|
||||
<path fill="#4D4D4D" fill-rule="nonzero" d="M3807.59 308.33c12.64,-3.61 26.67,-8.06 41.67,-12.91 15,-4.86 32.5,-9.31 51.79,-13.89 21.17,-4.49 42.54,-7.87 64.04,-10.14 26.71,-2.82 53.56,-4.17 80.42,-4.03 87.78,0 148.33,25.5 181.67,76.53 19.96,30.56 30.06,67.84 30.3,111.85l0 3.17c-0.15,28.62 -4.42,60.05 -12.8,94.28l-76.53 319.44 -167.22 0 74.3 -312.79c4.44,-19.43 8.06,-38.32 10.7,-56.79 2.89,-15.99 2.89,-32.36 0,-48.33 -2.76,-13.35 -10.14,-25.29 -20.83,-33.75 -14.82,-9.72 -32.46,-14.26 -50.14,-12.92 -22.26,0.04 -44.46,2.32 -66.25,6.81l-109.17 458.33 -168.21 0 136.26 -564.86z"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 7.3 KiB |
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<!-- Creator: CorelDRAW 2019 (64-Bit) -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" width="100%" height="100%" version="1.1" shape-rendering="geometricPrecision" text-rendering="geometricPrecision" image-rendering="optimizeQuality" fill-rule="evenodd" clip-rule="evenodd" viewBox="0 0 4091.27 4091.73" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:xodm="http://www.corel.com/coreldraw/odm/2003">
|
||||
<g id="Layer_x0020_1">
|
||||
<metadata id="CorelCorpID_0Corel-Layer"/>
|
||||
<g id="_1421344023328">
|
||||
<path fill="#F7931A" fill-rule="nonzero" d="M4030.06 2540.77c-273.24,1096.01 -1383.32,1763.02 -2479.46,1489.71 -1095.68,-273.24 -1762.69,-1383.39 -1489.33,-2479.31 273.12,-1096.13 1383.2,-1763.19 2479,-1489.95 1096.06,273.24 1763.03,1383.51 1489.76,2479.57l0.02 -0.02z"/>
|
||||
<path fill="white" fill-rule="nonzero" d="M2947.77 1754.38c40.72,-272.26 -166.56,-418.61 -450,-516.24l91.95 -368.8 -224.5 -55.94 -89.51 359.09c-59.02,-14.72 -119.63,-28.59 -179.87,-42.34l90.16 -361.46 -224.36 -55.94 -92 368.68c-48.84,-11.12 -96.81,-22.11 -143.35,-33.69l0.26 -1.16 -309.59 -77.31 -59.72 239.78c0,0 166.56,38.18 163.05,40.53 90.91,22.69 107.35,82.87 104.62,130.57l-104.74 420.15c6.26,1.59 14.38,3.89 23.34,7.49 -7.49,-1.86 -15.46,-3.89 -23.73,-5.87l-146.81 588.57c-11.11,27.62 -39.31,69.07 -102.87,53.33 2.25,3.26 -163.17,-40.72 -163.17,-40.72l-111.46 256.98 292.15 72.83c54.35,13.63 107.61,27.89 160.06,41.3l-92.9 373.03 224.24 55.94 92 -369.07c61.26,16.63 120.71,31.97 178.91,46.43l-91.69 367.33 224.51 55.94 92.89 -372.33c382.82,72.45 670.67,43.24 791.83,-303.02 97.63,-278.78 -4.86,-439.58 -206.26,-544.44 146.69,-33.83 257.18,-130.31 286.64,-329.61l-0.07 -0.05zm-512.93 719.26c-69.38,278.78 -538.76,128.08 -690.94,90.29l123.28 -494.2c152.17,37.99 640.17,113.17 567.67,403.91zm69.43 -723.3c-63.29,253.58 -453.96,124.75 -580.69,93.16l111.77 -448.21c126.73,31.59 534.85,90.55 468.94,355.05l-0.02 0z"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 36 KiB |
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="24px" height="36px" viewBox="0 0 24 36" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<g id="Group-2" transform="translate(-17.000000, -7.000000)">
|
||||
<g id="Logo">
|
||||
<g id="Group-4" transform="translate(17.000000, 8.000000)">
|
||||
<g id="Group">
|
||||
<g id="Logo" transform="translate(0.559947, 0.000000)">
|
||||
<polygon id="Fill-1" fill="#FF9100" points="11.2752139 6.65517263 2.53594776 6.60250871 6.82755165 33.7158335 7.76390159 33.7158335 16.3471094 33.7158335 17.2834593 33.7158335 21.5750632 6.60250871"></polygon>
|
||||
<polygon id="Fill-2" fill="#FFDD00" points="11.2752139 6.65517263 2.53594776 6.60250871 6.82755165 33.7158335 7.76390159 33.7158335 14.1622929 33.7158335 15.0986428 33.7158335 19.3902467 6.60250871"></polygon>
|
||||
<polygon id="Fill-3" fill="#FFFFFF" points="0.0390145809 6.60252433 22.5894423 6.60252433 22.5894423 4.10216009 0.0390145809 4.10216009"></polygon>
|
||||
<polygon id="Stroke-4" stroke="#000000" stroke-width="1.17043743" points="0.0390145809 6.60252433 22.5894423 6.60252433 22.5894423 4.10216009 0.0390145809 4.10216009"></polygon>
|
||||
<polygon id="Fill-6" fill="#FFFFFF" points="18.2198093 0.0390681913 12.8357971 0.0390681913 9.63660147 0.0390681913 4.25258931 0.0390681913 2.61397692 3.78961456 9.63660147 3.78961456 12.8357971 3.78961456 19.8584217 3.78961456"></polygon>
|
||||
<g id="Group-11" transform="translate(0.936350, 0.000000)" stroke-width="1.17043743">
|
||||
<polygon id="Stroke-7" stroke="#050505" points="17.2834593 0.0390681913 11.8994472 0.0390681913 8.70025153 0.0390681913 3.31623937 0.0390681913 1.67762698 3.78961456 8.70025153 3.78961456 11.8994472 3.78961456 18.9220717 3.78961456"></polygon>
|
||||
<polygon id="Stroke-9" stroke="#000000" points="10.3388639 6.65517263 0.0390145809 6.60250871 4.33061848 33.7158335 5.26696842 33.7158335 15.4107594 33.7158335 16.3471094 33.7158335 20.6387133 6.60250871"></polygon>
|
||||
</g>
|
||||
<polygon id="Fill-12" fill="#FFFFFF" points="21.8871799 14.2598898 11.6059795 14.2598898 10.8664191 14.2598898 0.585218713 14.2598898 2.50832543 25.0427106 11.2361993 24.9487126 19.9640731 25.0427106"></polygon>
|
||||
<polygon id="Stroke-13" stroke="#000000" stroke-width="1.17043743" points="21.8871799 14.2598898 11.6059795 14.2598898 10.8664191 14.2598898 0.585218713 14.2598898 2.50832543 25.0427106 11.2361993 24.9487126 19.9640731 25.0427106"></polygon>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 305 KiB |
@@ -0,0 +1,37 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1080 287.23">
|
||||
<title>ethereum-eth-logo-full-horizontal</title>
|
||||
<g id="Layer_2" data-name="Layer 2">
|
||||
<path transform="scale(1.98, 1.89)" d="M0 24.48C0 10.9601 10.9601 0 24.48 0H520.2C533.72 0 544.68 10.9601 544.68 24.48V128.52C544.68 142.04 533.72 153 520.2 153H24.48C10.9601 153 0 142.04 0 128.52V24.48Z" fill="#eeeeee"/>
|
||||
<g id="Layer_1-2" transform="translate(90, 30) scale(0.8, 0.8)" data-name="Layer 1">
|
||||
<path
|
||||
d="M306.2,155.7a3.56,3.56,0,0,1-3.6,3.5H236.3c1.7,16.4,14,31.4,31.4,31.4,11.9,0,20.7-4.5,27.3-14a3.58,3.58,0,0,1,2.9-1.7,3.21,3.21,0,0,1,3.3,3.3,3.1,3.1,0,0,1-.5,1.7c-6.7,11.6-20,17.3-33,17.3-22.3,0-38.3-20-38.3-41.3s15.9-41.3,38.3-41.3,38.4,19.8,38.5,41.1Zm-7.1-3.1c-1.4-16.4-14-31.4-31.4-31.4s-29.7,15-31.4,31.4Z"
|
||||
style="fill:#3b3b3b" />
|
||||
<path
|
||||
d="M386.8,116.2a3.4,3.4,0,0,1,3.3,3.3,3.21,3.21,0,0,1-3.3,3.3H369v69.9a3.33,3.33,0,0,1-3.3,3.3,3.4,3.4,0,0,1-3.3-3.3V122.8H345.3a3.21,3.21,0,0,1-3.3-3.3,3.33,3.33,0,0,1,3.3-3.3h17.1V90.7a3.55,3.55,0,0,1,3-3.5,3.27,3.27,0,0,1,3.7,3.3v25.7Z"
|
||||
style="fill:#3b3b3b" />
|
||||
<path
|
||||
d="M495.3,150v42.3a3.4,3.4,0,0,1-3.3,3.3,3.21,3.21,0,0,1-3.3-3.3V150c0-14.3-8.1-28.5-24-28.5-20.4,0-29.2,17.8-28,36.1,0,.5.2,2.6.2,2.9v31.7a3.55,3.55,0,0,1-3,3.5,3.27,3.27,0,0,1-3.7-3.3V53.3a3.33,3.33,0,0,1,3.3-3.3,3.4,3.4,0,0,1,3.3,3.3v78.6c5.7-10.2,15.9-17.1,27.8-17.1,19.6,0,30.7,17.1,30.7,35.2Z"
|
||||
style="fill:#3b3b3b" />
|
||||
<path
|
||||
d="M614.4,155.7a3.56,3.56,0,0,1-3.6,3.5H544.5c1.7,16.4,14,31.4,31.4,31.4,11.9,0,20.7-4.5,27.3-14a3.58,3.58,0,0,1,2.9-1.7,3.21,3.21,0,0,1,3.3,3.3,3.1,3.1,0,0,1-.5,1.7c-6.7,11.6-20,17.3-33,17.3-22.3,0-38.3-20-38.3-41.3s15.9-41.3,38.3-41.3c22.2,0,38.4,19.8,38.5,41.1Zm-7.2-3.1c-1.4-16.4-14-31.4-31.4-31.4s-29.7,15-31.4,31.4Z"
|
||||
style="fill:#3b3b3b" />
|
||||
<path
|
||||
d="M695.9,119.3a3.37,3.37,0,0,1-3.1,3.6c-19.5,2.9-28.3,18.8-28.3,37.3v31.7a3.55,3.55,0,0,1-3,3.5,3.27,3.27,0,0,1-3.7-3.3V119.8a3.55,3.55,0,0,1,3-3.5,3.27,3.27,0,0,1,3.7,3.3v14.7c5.5-9.3,16.4-18.1,27.8-18.1C694,116.2,695.9,117.4,695.9,119.3Z"
|
||||
style="fill:#3b3b3b" />
|
||||
<path
|
||||
d="M804.9,155.7a3.56,3.56,0,0,1-3.6,3.5H735c1.7,16.4,14,31.4,31.4,31.4,11.9,0,20.7-4.5,27.3-14a3.58,3.58,0,0,1,2.9-1.7,3.21,3.21,0,0,1,3.3,3.3,3.1,3.1,0,0,1-.5,1.7c-6.7,11.6-20,17.3-33,17.3-22.3,0-38.3-20-38.3-41.3s15.9-41.3,38.3-41.3,38.4,19.8,38.5,41.1Zm-7.1-3.1c-1.4-16.4-14-31.4-31.4-31.4s-29.7,15-31.4,31.4Z"
|
||||
style="fill:#3b3b3b" />
|
||||
<path
|
||||
d="M912.1,120.1v72.6a3.4,3.4,0,0,1-3.3,3.3,3.21,3.21,0,0,1-3.3-3.3V178.9c-5.5,10.9-15.2,18.8-27.6,18.8-19.7,0-30.6-17.1-30.6-35.2V120a3.33,3.33,0,0,1,3.3-3.3,3.4,3.4,0,0,1,3.3,3.3v42.5c0,14.3,8.1,28.5,24,28.5,22.3,0,27.6-20.9,27.6-44V119.9a3.35,3.35,0,0,1,4.5-3.1,3.63,3.63,0,0,1,2.1,3.3Z"
|
||||
style="fill:#3b3b3b" />
|
||||
<path
|
||||
d="M1080,149.7v42.5a3.4,3.4,0,0,1-3.3,3.3,3.21,3.21,0,0,1-3.3-3.3V149.7c0-14.3-8.1-28.3-24-28.3-20,0-27.6,21.4-27.6,38v32.8a3.4,3.4,0,0,1-3.3,3.3,3.21,3.21,0,0,1-3.3-3.3V149.7c0-14.3-8.1-28.3-24-28.3-20.2,0-28.5,15.9-27.8,37.1,0,.5.2,1.4,0,1.7v31.9a3.55,3.55,0,0,1-3,3.5,3.27,3.27,0,0,1-3.7-3.3V119.8a3.55,3.55,0,0,1,3-3.5,3.27,3.27,0,0,1,3.7,3.3v12.1c5.7-10.2,15.9-16.9,27.8-16.9,13.5,0,24,8.6,28.3,21.1,5.5-12.4,16.2-21.1,29.9-21.1,19.5,0,30.6,16.9,30.6,34.9Z"
|
||||
style="fill:#3b3b3b" />
|
||||
<path d="M83,100.1,0,137.8l83,49.1,83.1-49.1Z" style="opacity:0.6000000238418579;isolation:isolate" />
|
||||
<path d="M0,137.8l83,49.1V0Z" style="opacity:0.44999998807907104;isolation:isolate" />
|
||||
<path d="M83,0V186.9l83.1-49.1Z" style="opacity:0.800000011920929;isolation:isolate" />
|
||||
<path d="M0,153.6l83,117v-68Z" style="opacity:0.44999998807907104;isolation:isolate" />
|
||||
<path d="M83,202.6v68l83.1-117Z" style="opacity:0.800000011920929;isolation:isolate" />
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<!-- Creator: CorelDRAW 2019 (64-Bit) -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" width="100%" height="100%" version="1.1" shape-rendering="geometricPrecision" text-rendering="geometricPrecision" image-rendering="optimizeQuality" fill-rule="evenodd" clip-rule="evenodd" viewBox="0 0 784.37 1277.39" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:xodm="http://www.corel.com/coreldraw/odm/2003">
|
||||
<g id="Layer_x0020_1">
|
||||
<metadata id="CorelCorpID_0Corel-Layer"/>
|
||||
<g id="_1421394342400">
|
||||
<g>
|
||||
<polygon fill="#343434" fill-rule="nonzero" points="392.07,0 383.5,29.11 383.5,873.74 392.07,882.29 784.13,650.54 "/>
|
||||
<polygon fill="#8C8C8C" fill-rule="nonzero" points="392.07,0 -0,650.54 392.07,882.29 392.07,472.33 "/>
|
||||
<polygon fill="#3C3C3B" fill-rule="nonzero" points="392.07,956.52 387.24,962.41 387.24,1263.28 392.07,1277.38 784.37,724.89 "/>
|
||||
<polygon fill="#8C8C8C" fill-rule="nonzero" points="392.07,1277.38 392.07,956.52 -0,724.89 "/>
|
||||
<polygon fill="#141414" fill-rule="nonzero" points="392.07,882.29 784.13,650.54 392.07,472.33 "/>
|
||||
<polygon fill="#393939" fill-rule="nonzero" points="0,650.54 392.07,882.29 392.07,472.33 "/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 7.1 KiB |
|
After Width: | Height: | Size: 18 KiB |