New Addon

Google BK
This commit is contained in:
2023-04-23 18:17:03 +07:00
parent 1c7fd2b476
commit 702d080a1e
248 changed files with 72745 additions and 0 deletions
@@ -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)
+128
View File
@@ -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')
File diff suppressed because one or more lines are too long
@@ -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
+217
View File
@@ -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;
}
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff

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';
}
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
@@ -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

File diff suppressed because one or more lines are too long

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

Binary file not shown.

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:cc="http://creativecommons.org/ns#" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:svg="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="282" height="75" id="Monero-Logo">
<metadata id="metadata8">
<rdf:RDF>
<cc:Work rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage"/>
<dc:title/>
</cc:Work>
</rdf:RDF>
</metadata>
<defs id="defs6"/>
<path transform="scale(0.5, 0.5)" 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 transform="translate(10, 5) scale(0.85, 0.85)">
<path d="m 37.3,0.35329395 c -20.377,0 -36.903,16.524 -36.903,36.902 0,4.074 0.66,7.992 1.88,11.657 l 11.036,0 0,-31.049 23.987,23.987 23.987,-23.987 0,31.049 11.037,0 c 1.22,-3.665 1.88,-7.583 1.88,-11.657 0,-20.378 -16.526,-36.902 -36.904,-36.902" id="path22" style="fill:#ff6600"/>
<path d="m 21.3164,36.895994 0,19.537 -15.55,0 c 6.478,10.628 18.178,17.726 31.533,17.726 13.355,0 25.056,-7.098 31.533,-17.726 l -15.549,0 0,-19.537 -15.984,15.984 z" id="path26" style="fill:#4c4c4c"/>
<path d="m 272.7087,47.761494 c -1.951,2.009 -4.317,3.01 -7.099,3.01 -2.458,0 -4.631,-0.772 -6.533,-2.324 -2.445,-1.979 -3.666,-4.674 -3.666,-8.084 0,-3.053 0.972,-5.576 2.916,-7.556 1.937,-1.987 4.331,-2.974 7.184,-2.974 2.817,0 5.212,1.016 7.177,3.045 1.973,2.03 2.959,4.512 2.959,7.449 0,2.945 -0.978,5.418 -2.938,7.434 m 4.097,-18.937 c -3.132,-3.151 -6.877,-4.731 -11.238,-4.731 -2.874,0 -5.561,0.723 -8.048,2.166 -2.496,1.444 -4.455,3.402 -5.877,5.876 -1.423,2.473 -2.137,5.183 -2.137,8.127 0,4.397 1.529,8.192 4.59,11.389 3.058,3.202 6.898,4.796 11.514,4.796 4.411,0 8.165,-1.551 11.26,-4.668 3.095,-3.11 4.639,-6.919 4.639,-11.416 0,-4.533 -1.566,-8.378 -4.703,-11.539" id="path30" style="fill:#4c4c4c"/>
<path d="m 238.3063,35.970494 c -0.743,0.518 -1.094,0.773 -3.06,0.773 l -7.736,0 0,-6.618 7.496,0 c 1.503,0 1.769,0.113 2.385,0.345 0.614,0.225 1.102,0.601 1.47,1.118 0.368,0.518 0.548,1.133 0.548,1.838 0,1.186 -0.368,2.034 -1.103,2.544 m 0.93,6.093 c 2.049,-0.736 3.587,-1.816 4.607,-3.241 1.021,-1.433 1.524,-3.205 1.524,-5.335 0,-2.019 -0.457,-3.775 -1.381,-5.253 -0.923,-1.478 -2.146,-2.536 -3.661,-3.174 -1.516,-0.638 -4.06,-0.96 -7.639,-0.96 l -11.329,0 0,32.34 6.153,0 0,-13.694 5.37,0 7.28,13.694 6.73,0 -7.654,-14.377 z" id="path34" style="fill:#4c4c4c"/>
<path d="m 193.7751,24.093494 20.968,0 0,6.025 -14.852,0 0,6.619 14.852,0 0,5.92 -14.852,0 0,7.728 14.852,0 0,6.049 -20.968,0 0,-32.341 z" id="path38" style="fill:#4c4c4c"/>
<path d="m 161.2868,24.093494 5.891,0 13.874,21.28 0,-21.28 6.153,0 0,32.34 -5.913,0 -13.852,-21.212 0,21.212 -6.153,0 0,-32.34 z" id="path42" style="fill:#4c4c4c"/>
<use transform="translate(-125.0586,0)" id="use46" x="0" y="0" width="282" height="75" xlink:href="#path30"/>
<path d="m 89.6882,24.092594 6.025,0 7.473,22.557 7.587,-22.557 5.935,0 5.449,32.341 -5.936,0 -3.474,-20.425 -6.881,20.425 -5.426,0 -6.79,-20.425 -3.542,20.425 -6.003,0 5.583,-32.341 z" id="path50" style="fill:#4c4c4c"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.4 KiB

@@ -0,0 +1 @@
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 3756.09 3756.49"><title>monero</title><path d="M4128,2249.81C4128,3287,3287.26,4127.86,2250,4127.86S372,3287,372,2249.81,1212.76,371.75,2250,371.75,4128,1212.54,4128,2249.81Z" transform="translate(-371.96 -371.75)" style="fill:#fff"/><path id="_149931032" data-name=" 149931032" d="M2250,371.75c-1036.89,0-1879.12,842.06-1877.8,1878,0.26,207.26,33.31,406.63,95.34,593.12h561.88V1263L2250,2483.57,3470.52,1263v1579.9h562c62.12-186.48,95-385.85,95.37-593.12C4129.66,1212.76,3287,372,2250,372Z" transform="translate(-371.96 -371.75)" style="fill:#f26822"/><path id="_149931160" data-name=" 149931160" d="M1969.3,2764.17l-532.67-532.7v994.14H1029.38l-384.29.07c329.63,540.8,925.35,902.56,1604.91,902.56S3525.31,3766.4,3855,3225.6H3063.25V2231.47l-532.7,532.7-280.61,280.61-280.62-280.61h0Z" transform="translate(-371.96 -371.75)" style="fill:#4d4d4d"/></svg>

After

Width:  |  Height:  |  Size: 940 B

@@ -0,0 +1,15 @@
<svg width="545" height="153" viewBox="0 0 545 153" xmlns="http://www.w3.org/2000/svg">
<path transform="scale(1, 1)" 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="#fff1eb"/>
<g transform="translate(20, -50) scale(4.2, 4.2)">
<g fill="#222c31" transform="matrix(.05770525 0 0 .05770525 14.044352 21.072947)">
<path d="m1680 272c-10.3 0-20.6 0-31.7 0 0-7.7 0-15.3 0-22.9-.1-29.5 0-59-.3-88.5-.3-23.1-17.7-43.9-40.3-48.6-31-6.5-62.5 18.6-62.6 50.4-.1 34.5.1 69 .2 103.5v6.2c-10.5 0-20.4 0-30.8 0 0-60.9 0-121.8 0-183.2h30.5v10.1c10-8.9 20.6-14.4 32.6-17.4 47.2-11.9 96.6 22.8 101.4 71.2.1 1.4.6 2.9.9 4.3.1 38.2.1 76.6.1 114.9z"/>
<path d="m481.8 251.5v91.4c-10.5 0-20.4 0-30.6 0-.1-1.3-.3-2.6-.3-3.9 0-53.2-.2-106.3.1-159.5.3-48.1 34-88.7 81-98.2 56.5-11.4 111.8 28.3 119.2 85.5 7.3 56.4-31.5 107-88.1 113.8-29.7 3.5-55.3-6.7-77.3-26.5-1-.9-1.5-2.5-2.3-3.8-.6.4-1.1.8-1.7 1.2zm69.9-1.1c38.7-.1 69.6-31.3 69.6-70.1-.1-38.7-31.4-70-69.9-69.8-38.6.2-69.6 31.4-69.5 70.2-.1 38.6 31.1 69.8 69.8 69.7z"/>
<path d="m1127.7 221.3c14.8 22.6 46.1 33.8 73.8 26.4 16.9-4.5 30.4-14.2 40.1-28.7s13.3-30.7 11.2-48.4h30.6c5.5 38.7-17.6 89.2-69.4 105.7-49 15.6-101.9-8.4-122.6-55.5-20.8-47.3-2.5-102.8 42.6-128.5 46.7-26.6 99-9.2 123.4 19.9-43.2 36.3-86.3 72.6-129.7 109.1zm-13-29.5c31.1-26.1 61.3-51.5 91.8-77.2-23.3-9.7-53-3.2-71.9 15.7-16.6 16.7-22.9 37-19.9 61.5z"/>
<path d="m864 272.1c-10.5 0-20.5 0-30.9 0 0-6.2 0-12.1 0-18.9-1.6 1.3-2.5 2-3.3 2.7-57.3 49.6-145.6 22-164.2-51.3-17.1-67.6 38.5-131.9 107.8-124.9 51 5.2 90 47.7 90.5 98.9.3 30 .1 60 .1 90zm-170.5-92.1c-.1 38.4 31 69.9 69.2 70.1 38.6.1 70.3-30.8 70.4-68.8.1-39.7-30.7-71.1-69.6-71.1-38.6-.2-69.9 31.1-70 69.8z"/>
<path d="m1295.3 180c.1-55.6 45.3-100.8 100.7-100.8 55.6 0 101 45.7 100.7 101.3-.3 55.7-45.4 100.6-101 100.5-55.4-.1-100.5-45.4-100.4-101zm100.6 70c38.6 0 70-31.2 70-69.8.1-38.5-31.3-70.1-69.7-70.2-38.5-.1-70 31.3-70.1 69.8 0 38.7 31.3 70.2 69.8 70.2z"/>
<path d="m873.8 88.3h17.7c0-23.7 0-47.1 0-70.8h31v70.8h46.2v30.9c-15.2 0-30.4 0-46.1 0v152.8c-10.4 0-20.3 0-30.8 0 0-2.2 0-4.1 0-6 0-47-.1-94 .1-141 0-4.5-1.1-6.3-5.8-5.8-3.9.4-7.9.1-12.2.1-.1-10.4-.1-20.3-.1-31z"/>
<path d="m1015.5 115.1c14.7-13.2 31.5-22.3 50.9-25.7 6.8-1.2 13.8-1.3 21.2-2v28.4c0 3.5-2.6 2.9-4.6 2.9-15.4.2-29.2 5.1-41.1 14.6-17.3 13.7-26.7 31.7-27 53.9-.3 26.3-.1 52.7-.1 79v5.8c-10.3 0-20.2 0-30.5 0 0-61.1 0-122.1 0-183.3h29.9v25.8c.5.2.9.4 1.3.6z"/>
</g>
<path d="m23.075695 16.136992c.788702.142108 1.591615.23448 2.366108.426327 5.343282 1.321609 9.393376 5.854873 10.146551 11.326052 1.001867 7.275959-3.73035 13.926641-10.942359 15.369042-.518699.106583-1.051605.163427-1.577407.241587-2.415846 0-4.831691 0-7.247538 0 0-4.583002 0-9.166003.0072-13.741902 0-.305532.02841-.618169.07816-.916598.468959-3.076652 3.275603-5.400126 6.352254-5.279335 3.247181.135003 5.854875 2.643221 6.060931 5.840663.305534 4.746427-4.526157 8.078872-8.853364 6.103563-.09238-.04263-.18474-.07816-.319745-.127898 0 1.456613-.0072 2.863488.01421 4.270363 0 .08527.163425.213163.270006.241585 1.385561.397904 2.79954.476064 4.220625.248689 6.011193-.966338 9.890758-6.671998 8.590465-12.597925-1.200819-5.48539-6.68621-9.073631-12.150284-7.95808-4.945379.994761-8.398617 5.215385-8.412828 10.274451-.0072 4.426683 0 8.853363 0 13.280046 0 .120793.01421.234479.02841.355272-1.087131 0-2.181366 0-3.268497 0 0-4.902746 0-9.805491 0-14.708238.04264-.277112.09237-.554223.127898-.838442.753175-5.520918 4.781953-10.03997 10.189184-11.390001.767387-.191846 1.556089-.284216 2.330581-.419221h1.98952z" fill="#e6461a" stroke-width=".071054"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

@@ -0,0 +1,15 @@
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" width="545" height="153" viewBox="0 0 545 153">
<defs>
<style>
.cls-1{fill:#009ee3;}.cls-1,.cls-2,.cls-3{fill-rule:evenodd;}.cls-2{fill:#113984;}.cls-3{fill:#172c70;}</style>
</defs>
<title>paypal-seeklogo.com</title>
<path transform="scale(1, 1)" 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="#ebf2ff"/>
<g transform="scale(0.8, 0.8) translate(45, 25)">
<path class="cls-1" d="M192.95,386.87h38.74c20.8,0,28.63,10.53,27.42,26-2,25.54-17.44,39.67-37.92,39.67H210.85c-2.81,0-4.7,1.86-5.46,6.9L201,488.74c-0.29,1.9-1.29,3-2.79,3.15H173.87c-2.29,0-3.1-1.75-2.5-5.54l14.84-93.93C186.79,388.66,188.85,386.87,192.95,386.87Z" transform="translate(-143.48 -354.54)"/>
<path class="cls-2" d="M361.14,385.13c13.07,0,25.13,7.09,23.48,24.76-2,21-13.25,32.62-31,32.67H338.11c-2.23,0-3.31,1.82-3.89,5.55l-3,19.07c-0.45,2.88-1.93,4.3-4.11,4.3H312.68c-2.3,0-3.1-1.47-2.59-4.76L322,390.29c0.59-3.76,2-5.16,4.57-5.16h34.54Zm-23.5,40.92h11.75c7.35-.28,12.23-5.37,12.72-14.55,0.3-5.67-3.53-9.73-9.62-9.7l-11.06.05-3.79,24.2h0Zm86.21,39.58c1.32-1.2,2.66-1.82,2.47-.34l-0.47,3.54c-0.24,1.85.49,2.83,2.21,2.83h12.82c2.16,0,3.21-.87,3.74-4.21l7.9-49.58c0.4-2.49-.21-3.71-2.1-3.71H436.32c-1.27,0-1.89.71-2.22,2.65l-0.52,3.05c-0.27,1.59-1,1.87-1.68.27-2.39-5.66-8.49-8.2-17-8-19.77.41-33.1,15.42-34.53,34.66-1.1,14.88,9.56,26.57,23.62,26.57,10.2,0,14.76-3,19.9-7.7h0ZM413.11,458c-8.51,0-14.44-6.79-13.21-15.11s9.19-15.11,17.7-15.11,14.44,6.79,13.21,15.11S421.63,458,413.11,458h0Zm64.5-44h-13c-2.68,0-3.77,2-2.92,4.46l16.14,47.26L462,488.21c-1.33,1.88-.3,3.59,1.57,3.59h14.61a4.47,4.47,0,0,0,4.34-2.13l49.64-71.2c1.53-2.19.81-4.49-1.7-4.49H516.63c-2.37,0-3.32.94-4.68,2.91l-20.7,30L482,416.82C481.46,415,480.11,414,477.62,414Z" transform="translate(-143.48 -354.54)"/>
<path class="cls-1" d="M583.8,385.13c13.07,0,25.13,7.09,23.48,24.76-2,21-13.25,32.62-31,32.67H560.78c-2.23,0-3.31,1.82-3.89,5.55l-3,19.07c-0.45,2.88-1.93,4.3-4.11,4.3H535.35c-2.3,0-3.1-1.47-2.59-4.76l11.93-76.45c0.59-3.76,2-5.16,4.57-5.16H583.8Zm-23.5,40.92h11.75c7.35-.28,12.23-5.37,12.72-14.55,0.3-5.67-3.53-9.73-9.62-9.7l-11.06.05-3.79,24.2h0Zm86.21,39.58c1.32-1.2,2.66-1.82,2.47-.34l-0.47,3.54c-0.24,1.85.49,2.83,2.21,2.83h12.82c2.16,0,3.21-.87,3.74-4.21l7.9-49.58c0.4-2.49-.21-3.71-2.1-3.71H659c-1.27,0-1.89.71-2.22,2.65l-0.52,3.05c-0.27,1.59-1,1.87-1.68.27-2.39-5.66-8.49-8.2-17-8-19.77.41-33.1,15.42-34.53,34.66-1.1,14.88,9.56,26.57,23.62,26.57,10.2,0,14.76-3,19.9-7.7h0ZM635.78,458c-8.51,0-14.44-6.79-13.21-15.11s9.19-15.11,17.7-15.11,14.44,6.79,13.21,15.11S644.29,458,635.78,458h0Zm59.13,13.74h-14.8a1.75,1.75,0,0,1-1.81-2l13-82.36a2.55,2.55,0,0,1,2.46-2h14.8a1.75,1.75,0,0,1,1.81,2l-13,82.36A2.55,2.55,0,0,1,694.91,471.76Z" transform="translate(-143.48 -354.54)"/>
<path class="cls-2" d="M168.72,354.54h38.78c10.92,0,23.88.35,32.54,8,5.79,5.11,8.83,13.24,8.13,22-2.38,29.61-20.09,46.2-43.85,46.2H185.2c-3.26,0-5.41,2.16-6.33,8l-5.34,34c-0.35,2.2-1.3,3.5-3,3.66H146.6c-2.65,0-3.59-2-2.9-6.42L160.9,361C161.59,356.62,164,354.54,168.72,354.54Z" transform="translate(-143.48 -354.54)"/>
<path class="cls-3" d="M179.43,435.29l6.77-42.87c0.59-3.76,2.65-5.56,6.75-5.56h38.74c6.41,0,11.6,1,15.66,2.85-3.89,26.36-20.94,41-43.26,41H185C182.44,430.72,180.56,432,179.43,435.29Z" transform="translate(-143.48 -354.54)"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Some files were not shown because too many files have changed in this diff Show More