New Addon
Google BK
This commit is contained in:
@@ -0,0 +1,908 @@
|
||||
import asyncio
|
||||
import ssl
|
||||
import json
|
||||
import aiohttp_jinja2
|
||||
import jinja2
|
||||
import base64
|
||||
from datetime import timedelta
|
||||
from os.path import abspath, join
|
||||
from typing import Any, Dict
|
||||
|
||||
from aiohttp import BasicAuth, hdrs, web, ClientSession, ClientResponseError
|
||||
from aiohttp.web import HTTPException, Request, HTTPSeeOther, HTTPNotFound
|
||||
from injector import ClassAssistedBuilder, ProviderOf, inject, singleton
|
||||
|
||||
from backup.config import Config, Setting, CreateOptions, BoolValidator, Startable, Version, VERSION
|
||||
from backup.const import SOURCE_GOOGLE_DRIVE, SOURCE_HA, GITHUB_BUG_TEMPLATE
|
||||
from backup.model import Coordinator, Backup, AbstractBackup
|
||||
from backup.exceptions import KnownError, GoogleCredGenerateError, ensureKey
|
||||
from backup.util import GlobalInfo, Estimator, DataCache, UpgradeFlags
|
||||
from backup.file import File
|
||||
from backup.ha import HaSource, PendingBackup, BACKUP_NAME_KEYS, HaRequests, HaUpdater
|
||||
from backup.ha import Password
|
||||
from backup.time import Time
|
||||
from backup.worker import Trigger
|
||||
from backup.logger import getLogger, getHistory, TraceLogger
|
||||
from backup.creds import Exchanger, Creds
|
||||
from backup.debugworker import DebugWorker
|
||||
from backup.drive import FolderFinder, AuthCodeQuery
|
||||
from backup.const import FOLDERS
|
||||
from .debug import Debug
|
||||
from yarl import URL
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
# Used to Google's oauth verification
|
||||
SCOPE: str = 'https://www.googleapis.com/auth/drive.file'
|
||||
|
||||
MIME_TEXT_HTML = "text/html"
|
||||
MIME_JSON = "application/json"
|
||||
VERSION_CREATION_TRACKING = Version(0, 104, 0)
|
||||
|
||||
|
||||
@singleton
|
||||
class UiServer(Trigger, Startable):
|
||||
@inject
|
||||
def __init__(self, debug: Debug, coord: Coordinator, ha_source: HaSource, harequests: HaRequests,
|
||||
time: Time, config: Config, global_info: GlobalInfo, estimator: Estimator,
|
||||
session: ClientSession, exchanger_builder: ClassAssistedBuilder[Exchanger],
|
||||
debug_worker: DebugWorker, folder_finder: FolderFinder, data_cache: DataCache,
|
||||
haupdater: HaUpdater, custom_auth_provider: ProviderOf[AuthCodeQuery]):
|
||||
super().__init__()
|
||||
# Currently running server tasks
|
||||
self.runners = []
|
||||
self.exchanger_builder = exchanger_builder
|
||||
self._coord = coord
|
||||
self._time = time
|
||||
self.custom_auth_provider = custom_auth_provider
|
||||
self.manual_exchanger: Exchanger = None
|
||||
self.config: Config = config
|
||||
self.auth_cache: Dict[str, Any] = {}
|
||||
self.last_log_index = 0
|
||||
self.host_server = None
|
||||
self.ingress_server = None
|
||||
self.running = False
|
||||
self._harequests = harequests
|
||||
self._global_info = global_info
|
||||
self._ha_source = ha_source
|
||||
self._starts = 0
|
||||
self._estimator = estimator
|
||||
self._debug = debug
|
||||
self.session = session
|
||||
self.debug_worker = debug_worker
|
||||
self.folder_finder = folder_finder
|
||||
self.ignore_other_turned_on = False
|
||||
self._data_cache = data_cache
|
||||
self._haupdater = haupdater
|
||||
self._check_creds_loop: asyncio.Task = None
|
||||
self._check_creds_error: Exception = None
|
||||
self._device_code_authorizer: AuthCodeQuery = None
|
||||
self._upload_event = asyncio.Event()
|
||||
|
||||
def name(self):
|
||||
return "UI Server"
|
||||
|
||||
def base_context(self):
|
||||
return {
|
||||
'version': VERSION,
|
||||
'backgroundColor': self.config.get(Setting.BACKGROUND_COLOR),
|
||||
'accentColor': self.config.get(Setting.ACCENT_COLOR),
|
||||
'coordEnabled': self._coord.enabled(),
|
||||
'save_drive_creds_path': self.config.get(Setting.SAVE_DRIVE_CREDS_PATH),
|
||||
'bmc_logo_path': "static/" + VERSION + "/images/bmc.svg"
|
||||
}
|
||||
|
||||
async def getstatus(self, request) -> Dict[Any, Any]:
|
||||
return web.json_response(await self.buildStatusInfo())
|
||||
|
||||
async def buildStatusInfo(self):
|
||||
status: Dict[Any, Any] = {}
|
||||
status['folder_id'] = self.folder_finder.getCachedFolder()
|
||||
status['backups'] = []
|
||||
backups = self._coord.backups()
|
||||
for backup in backups:
|
||||
status['backups'].append(self.getBackupDetails(backup))
|
||||
status['ha_url_base'] = self._ha_source.getHomeAssistantUrl()
|
||||
status['restore_backup_path'] = "hassio/backups"
|
||||
status['ask_error_reports'] = not self.config.isExplicit(
|
||||
Setting.SEND_ERROR_REPORTS)
|
||||
status['warn_ingress_upgrade'] = False
|
||||
status['cred_version'] = self._global_info.credVersion
|
||||
next = self._coord.nextBackupTime()
|
||||
if next is None:
|
||||
status['next_backup_text'] = "Disabled"
|
||||
status['next_backup_machine'] = ""
|
||||
status['next_backup_detail'] = "Disabled"
|
||||
elif (next < self._time.now()):
|
||||
status['next_backup_text'] = self._time.formatDelta(
|
||||
self._time.now())
|
||||
status['next_backup_machine'] = self._time.asRfc3339String(
|
||||
self._time.now())
|
||||
status['next_backup_detail'] = self._time.toLocal(
|
||||
self._time.now()).strftime("%c")
|
||||
else:
|
||||
status['next_backup_text'] = self._time.formatDelta(next)
|
||||
status['next_backup_machine'] = self._time.asRfc3339String(next)
|
||||
status['next_backup_detail'] = self._time.toLocal(
|
||||
next).strftime("%c")
|
||||
not_ignored = list(filter(lambda s: not s.ignore(), self._coord.backups()))
|
||||
if len(not_ignored) > 0:
|
||||
latest = not_ignored[len(not_ignored) - 1].date()
|
||||
status['last_backup_text'] = self._time.formatDelta(latest)
|
||||
status['last_backup_machine'] = self._time.asRfc3339String(
|
||||
latest)
|
||||
status['last_backup_detail'] = self._time.toLocal(
|
||||
latest).strftime("%c")
|
||||
else:
|
||||
status['last_backup_text'] = "Never"
|
||||
status['last_backup_machine'] = ""
|
||||
status['last_backup_detail'] = "Never"
|
||||
|
||||
status['last_error'] = None
|
||||
if self._global_info._last_error is not None and self._global_info.isErrorSuppressed():
|
||||
status['last_error'] = self.processError(
|
||||
self._global_info._last_error)
|
||||
status["last_error_count"] = self._global_info.failureCount()
|
||||
status["ignore_errors_for_now"] = self._global_info.ignoreErrorsForNow()
|
||||
status["syncing"] = self._coord.isSyncing()
|
||||
status["ignore_sync_error"] = self._coord.isWorkingThroughUpload()
|
||||
status["firstSync"] = self._global_info._first_sync
|
||||
status["backup_name_template"] = self.config.get(
|
||||
Setting.BACKUP_NAME)
|
||||
status['sources'] = self._coord.buildBackupMetrics()
|
||||
status['authenticate_url'] = str(URL(self.config.get(Setting.AUTHORIZATION_HOST)).with_path("/drive/authorize"))
|
||||
choose_url = str(URL(self.config.get(Setting.AUTHORIZATION_HOST)).with_path('/drive/picker').with_query({
|
||||
"bg": self.config.get(Setting.BACKGROUND_COLOR),
|
||||
"ac": self.config.get(Setting.ACCENT_COLOR),
|
||||
"version": VERSION
|
||||
}))
|
||||
status['choose_folder_url'] = str(choose_url)
|
||||
status['dns_info'] = self._global_info.getDnsInfo()
|
||||
status['enable_drive_upload'] = self.config.get(
|
||||
Setting.ENABLE_DRIVE_UPLOAD)
|
||||
status['is_custom_creds'] = self._coord._model.dest.isCustomCreds()
|
||||
status['is_specify_folder'] = self.config.get(
|
||||
Setting.SPECIFY_BACKUP_FOLDER)
|
||||
status['backup_cooldown_active'] = self._coord.isWaitingForStartup()
|
||||
name_keys = {}
|
||||
for key in BACKUP_NAME_KEYS:
|
||||
name_keys[key] = BACKUP_NAME_KEYS[key](
|
||||
"Full", self._time.now(), self._ha_source.getHostInfo())
|
||||
status['backup_name_keys'] = name_keys
|
||||
|
||||
# Indicate the user should be notified for a specific situation where:
|
||||
# - They recently turned on "IGNORE_OTHER_BACKUPS"
|
||||
# - They have ignored backups created before upgrading to v0.104.0 or higher.
|
||||
upgrade_date = self._data_cache.getUpgradeTime(VERSION_CREATION_TRACKING)
|
||||
ignored = len(list(filter(lambda s: s.date() < upgrade_date, filter(Backup.ignore, self._coord.backups()))))
|
||||
status["notify_check_ignored"] = ignored > 0 and self.ignore_other_turned_on
|
||||
status["warn_backup_upgrade"] = self.config.get(Setting.CALL_BACKUP_SNAPSHOT) and not self._data_cache.checkFlag(UpgradeFlags.NOTIFIED_ABOUT_BACKUP_RENAME)
|
||||
status["warn_upgrade_backups"] = self._data_cache.notifyForIgnoreUpgrades
|
||||
status["warn_oob_oauth"] = not self._data_cache.checkFlag(UpgradeFlags.NOTIFIED_ABOUT_OOB_FLOW) and self._coord._model.dest.might_be_oob_creds
|
||||
return status
|
||||
|
||||
async def bootstrap(self, request) -> Dict[Any, Any]:
|
||||
return web.Response(body="bootstrap_update_data = {0};".format(json.dumps(await self.buildStatusInfo(), indent=4)), content_type="text/javascript")
|
||||
|
||||
def getBackupDetails(self, backup: Backup):
|
||||
ha = backup.getSource(SOURCE_HA)
|
||||
sources = []
|
||||
for source_key in backup.sources:
|
||||
source: AbstractBackup = backup.sources[source_key]
|
||||
sources.append({
|
||||
'name': source.name(),
|
||||
'key': source_key,
|
||||
'size': source.size(),
|
||||
'retained': source.retained(),
|
||||
'delete_next': backup.getPurges().get(source_key) or False,
|
||||
'slug': backup.slug(),
|
||||
'ignored': source.ignore(),
|
||||
})
|
||||
|
||||
return {
|
||||
'name': backup.name(),
|
||||
'slug': backup.slug(),
|
||||
'size': backup.sizeString(),
|
||||
'status': backup.status(),
|
||||
'date': self._time.toLocal(backup.date()).strftime("%c"),
|
||||
'createdAt': self._time.formatDelta(backup.date()),
|
||||
'isPending': ha is not None and type(ha) is PendingBackup,
|
||||
'protected': backup.protected(),
|
||||
'type': backup.backupType(),
|
||||
'folders': backup.details().get("folders", []),
|
||||
'addons': self.formatAddons(backup.details()),
|
||||
'sources': sources,
|
||||
'haVersion': False if backup.version() == None else backup.version(),
|
||||
'uploadable': backup.getSource(SOURCE_HA) is None and len(backup.sources) > 0,
|
||||
'restorable': backup.getSource(SOURCE_HA) is not None,
|
||||
'status_detail': backup.getStatusDetail(),
|
||||
'upload_info': backup.getUploadInfo(self._time),
|
||||
'ignored': backup.ignore(),
|
||||
'timestamp': backup.date().timestamp(),
|
||||
'note': backup.note()
|
||||
}
|
||||
|
||||
def formatAddons(self, backup_data):
|
||||
addons = []
|
||||
for addon in backup_data.get("addons", []):
|
||||
addons.append({
|
||||
'name': addon.get('name', "Unknown"),
|
||||
'slug': addon.get("slug", "unknown"),
|
||||
'version': addon.get("version", ""),
|
||||
# The supervisor stores backup size in MB
|
||||
'size': self._estimator.asSizeString(float(addon.get("size", 0)) * 1024 * 1024),
|
||||
})
|
||||
return addons
|
||||
|
||||
async def manualCredCheckLoop(self, auth: AuthCodeQuery):
|
||||
try:
|
||||
creds = await auth.waitForPermission()
|
||||
self._coord.saveCreds(creds)
|
||||
self._data_cache.addFlag(UpgradeFlags.NOTIFIED_ABOUT_OOB_FLOW)
|
||||
except asyncio.CancelledError:
|
||||
# Cancelled, thats fine
|
||||
pass
|
||||
except Exception as e:
|
||||
self._check_creds_error = e
|
||||
|
||||
async def checkManualAuth(self, request: Request):
|
||||
if self._check_creds_error is not None:
|
||||
raise self._check_creds_error
|
||||
elif self._device_code_authorizer is not None:
|
||||
return web.json_response({
|
||||
'message': "Waiting for you to authorize the add-on.",
|
||||
'auth_url': self._device_code_authorizer.verification_url,
|
||||
'code': self._device_code_authorizer.user_code,
|
||||
'expires': self._time.formatDelta(self._device_code_authorizer.expiration)
|
||||
})
|
||||
else:
|
||||
return web.json_response({
|
||||
'message': "No request for authorization is in progress."
|
||||
})
|
||||
|
||||
async def manualauth(self, request: Request) -> None:
|
||||
client_id = request.query.get("client_id", "")
|
||||
client_secret = request.query.get("client_secret", "")
|
||||
if client_id == "" or client_secret == "":
|
||||
raise GoogleCredGenerateError("Invalid information provided")
|
||||
|
||||
if self._check_creds_loop is not None and not self._check_creds_loop.done():
|
||||
self._check_creds_loop.cancel()
|
||||
await self._check_creds_loop
|
||||
self._device_code_authorizer = self.custom_auth_provider.get()
|
||||
await self._device_code_authorizer.requestCredentials(client_id, client_secret)
|
||||
self._check_creds_error = None
|
||||
self._check_creds_loop = asyncio.create_task(self.manualCredCheckLoop(self._device_code_authorizer))
|
||||
return web.json_response({
|
||||
'auth_url': self._device_code_authorizer.verification_url,
|
||||
'code': self._device_code_authorizer.user_code,
|
||||
'expires': self._time.formatDelta(self._device_code_authorizer.expiration),
|
||||
})
|
||||
|
||||
async def backup(self, request: Request) -> Any:
|
||||
custom_name = request.query.get("custom_name", None)
|
||||
retain_drive = BoolValidator.strToBool(
|
||||
request.query.get("retain_drive", False))
|
||||
retain_ha = BoolValidator.strToBool(
|
||||
request.query.get("retain_ha", False))
|
||||
note = request.query.get("note", None)
|
||||
options = CreateOptions(self._time.now(), custom_name, {
|
||||
SOURCE_GOOGLE_DRIVE: retain_drive,
|
||||
SOURCE_HA: retain_ha
|
||||
}, note=note)
|
||||
backup = await self._coord.startBackup(options)
|
||||
return web.json_response({"message": "Requested backup '{0}'".format(backup.name())})
|
||||
|
||||
async def deleteSnapshot(self, request: Request):
|
||||
data = await request.json()
|
||||
# Check to make sure the slug is valid.
|
||||
self._coord.getBackup(data['slug'])
|
||||
await self._coord.delete(data['sources'], data['slug'])
|
||||
return web.json_response({"message": "Deleted from {0} place(s)".format(len(data['sources']))})
|
||||
|
||||
async def ignore(self, request: Request):
|
||||
data = await request.json()
|
||||
# Check to make sure the slug is valid.
|
||||
backup = self._coord.getBackup(data['slug'])
|
||||
await self._coord.ignore(data['slug'], data['ignore'])
|
||||
await self.startSync(request)
|
||||
if data['ignore']:
|
||||
return web.json_response({"message": "'{0}' will be ignored.".format(backup.name())})
|
||||
else:
|
||||
return web.json_response({"message": "'{0}' will be included.".format(backup.name())})
|
||||
|
||||
async def retain(self, request: Request):
|
||||
data = await request.json()
|
||||
slug = data['slug']
|
||||
|
||||
self._coord.getBackup(slug)
|
||||
await self._coord.retain(data['sources'], slug)
|
||||
return web.json_response({'message': "Updated the backup's settings"})
|
||||
|
||||
async def note(self, request: Request):
|
||||
data = await request.json()
|
||||
slug = data['slug']
|
||||
|
||||
self._coord.getBackup(slug)
|
||||
await self._coord.note(data.get("note", None), slug)
|
||||
return web.json_response({'message': "Updated the backup's settings"})
|
||||
|
||||
async def resolvefolder(self, request: Request):
|
||||
use_existing = BoolValidator.strToBool(
|
||||
request.query.get("use_existing", False))
|
||||
self.folder_finder.resolveExisting(use_existing)
|
||||
self._global_info.suppressError()
|
||||
self._global_info.setIngoreErrorsForNow(True)
|
||||
await self.sync()
|
||||
return web.json_response({'message': 'Done'})
|
||||
|
||||
async def skipspacecheck(self, request: Request):
|
||||
self._global_info.setSkipSpaceCheckOnce(True)
|
||||
self._global_info.setIngoreErrorsForNow(True)
|
||||
await self.startSync(request)
|
||||
return web.json_response({'message': 'Done'})
|
||||
|
||||
async def confirmdelete(self, request: Request):
|
||||
always = BoolValidator.strToBool(request.query.get("always", False))
|
||||
self._global_info.allowMultipleDeletes()
|
||||
self._global_info.setIngoreErrorsForNow(True)
|
||||
if always:
|
||||
validated = self.config.validateUpdate(
|
||||
{"confirm_multiple_deletes": False})
|
||||
await self._updateConfiguration(validated)
|
||||
await self.sync()
|
||||
return web.json_response({'message': 'Configuration updated, I\'ll never ask again'})
|
||||
else:
|
||||
await self.sync()
|
||||
return web.json_response({'message': 'Backups deleted this one time'})
|
||||
|
||||
async def log(self, request: Request) -> Any:
|
||||
format = request.query.get("format", "download")
|
||||
catchup = BoolValidator.strToBool(
|
||||
request.query.get("catchup", "False"))
|
||||
if not catchup:
|
||||
self.last_log_index = 0
|
||||
if format == "view":
|
||||
context = self.base_context()
|
||||
return aiohttp_jinja2.render_template("logs.jinja2",
|
||||
request,
|
||||
context)
|
||||
resp = web.StreamResponse()
|
||||
if format == "html":
|
||||
resp.content_type = 'text/html'
|
||||
else:
|
||||
resp.content_type = 'text/plain'
|
||||
resp.headers['Content-Disposition'] = 'attachment; filename="home-assistant-google-drive-backup.log"'
|
||||
|
||||
await resp.prepare(request)
|
||||
|
||||
def content():
|
||||
html = format == "colored"
|
||||
if format == "html":
|
||||
yield "<html><head><title>Home Assistant Google Drive Backup Log</title></head><body><pre>\n"
|
||||
for line in getHistory(self.last_log_index, html):
|
||||
self.last_log_index = line[0]
|
||||
if line:
|
||||
yield line[1].replace("\n", " \n") + "\n"
|
||||
if format == "html":
|
||||
yield "</pre></body>\n"
|
||||
|
||||
for line in content():
|
||||
await resp.write(line.encode())
|
||||
await resp.write_eof()
|
||||
|
||||
async def token(self, request: Request) -> None:
|
||||
self._global_info.setIngoreErrorsForNow(True)
|
||||
creds_deserialized = json.loads(str(base64.b64decode(request.query.get('creds').strip().encode("utf-8")), 'utf-8'))
|
||||
creds = Creds.load(self._time, creds_deserialized)
|
||||
self._coord.saveCreds(creds)
|
||||
|
||||
# Build the redirect url
|
||||
if 'host' in request.query:
|
||||
redirect = request.query.get('host')
|
||||
else:
|
||||
redirect = self._ha_source.getAddonUrl()
|
||||
if MIME_JSON in request.headers[hdrs.ACCEPT]:
|
||||
return web.json_response({'redirect': str(redirect)})
|
||||
else:
|
||||
raise HTTPSeeOther(redirect)
|
||||
|
||||
async def changefolder(self, request: Request) -> None:
|
||||
# update config to specify backup folder
|
||||
await self._updateConfiguration(self.config.validateUpdate({Setting.SPECIFY_BACKUP_FOLDER: True}))
|
||||
|
||||
id = request.query.get("id", None)
|
||||
await self.folder_finder.save(id)
|
||||
self._global_info.setIngoreErrorsForNow(True)
|
||||
self.trigger()
|
||||
return web.json_response({})
|
||||
|
||||
async def sync(self, request: Request = None) -> Any:
|
||||
self._coord.clearCaches()
|
||||
await self._coord.sync()
|
||||
return await self.getstatus(request)
|
||||
|
||||
async def startSync(self, request) -> Any:
|
||||
self._coord.clearCaches()
|
||||
asyncio.create_task(self._coord.sync(), name="Sync from web request")
|
||||
await self._coord._sync_start.wait()
|
||||
return await self.getstatus(request)
|
||||
|
||||
async def cancelSync(self, request: Request):
|
||||
await self._coord.cancel()
|
||||
return await self.getstatus(request)
|
||||
|
||||
async def getconfig(self, request: Request):
|
||||
await self._ha_source.refresh()
|
||||
current_config = {}
|
||||
for setting in Setting:
|
||||
current_config[setting.key()] = self.config.getForUi(setting)
|
||||
default_config = {}
|
||||
for setting in Setting:
|
||||
default_config[setting.key()] = setting.default()
|
||||
return web.json_response({
|
||||
'config': current_config,
|
||||
'addons': self._global_info.addons,
|
||||
'folders': FOLDERS,
|
||||
'defaults': default_config,
|
||||
'backup_folder': self.folder_finder.getCachedFolder(),
|
||||
'is_custom_creds': self._coord._model.dest.isCustomCreds()
|
||||
})
|
||||
|
||||
async def errorreports(self, request: Request):
|
||||
send = BoolValidator.strToBool(request.query.get("send", False))
|
||||
|
||||
update = {
|
||||
"send_error_reports": send
|
||||
}
|
||||
validated = self.config.validateUpdate(update)
|
||||
await self._updateConfiguration(validated)
|
||||
return web.json_response({'message': 'Configuration updated'})
|
||||
|
||||
async def callbackupsnapshot(self, request: Request):
|
||||
switch = BoolValidator.strToBool(request.query.get("switch", False))
|
||||
|
||||
if switch:
|
||||
validated = self.config.validateUpdate({Setting.CALL_BACKUP_SNAPSHOT: False})
|
||||
await self._updateConfiguration(validated)
|
||||
self._data_cache.addFlag(UpgradeFlags.NOTIFIED_ABOUT_BACKUP_RENAME)
|
||||
self._data_cache.addFlag(UpgradeFlags.NOTIFIED_ABOUT_BACKUP_RENAME)
|
||||
self._data_cache.saveIfDirty()
|
||||
return web.json_response({'message': 'Configuration updated'})
|
||||
|
||||
async def ignoredbackupswitch(self, request: Request):
|
||||
switch = BoolValidator.strToBool(request.query.get("switch", False))
|
||||
|
||||
validated = self.config.validateUpdate({Setting.IGNORE_UPGRADE_BACKUPS: switch})
|
||||
await self._updateConfiguration(validated)
|
||||
self._data_cache.addFlag(UpgradeFlags.NOTIFIED_ABOUT_IGNORED_BACKUPS)
|
||||
self._data_cache.saveIfDirty()
|
||||
if switch:
|
||||
return web.json_response({'message': 'Configuration updated'})
|
||||
else:
|
||||
return web.json_response({'message': 'Acknowledged'})
|
||||
|
||||
async def ignorestartupcooldown(self, request: Request):
|
||||
self._coord.ignoreStartupDelay()
|
||||
return await self.sync(request)
|
||||
|
||||
async def exposeserver(self, request: Request):
|
||||
expose = BoolValidator.strToBool(request.query.get("expose", False))
|
||||
if expose:
|
||||
update = {
|
||||
Setting.EXPOSE_EXTRA_SERVER: True
|
||||
}
|
||||
else:
|
||||
update = {
|
||||
Setting.EXPOSE_EXTRA_SERVER: False,
|
||||
Setting.USE_SSL: False,
|
||||
Setting.REQUIRE_LOGIN: False
|
||||
}
|
||||
validated = self.config.validateUpdate(update)
|
||||
await self._updateConfiguration(validated)
|
||||
|
||||
File.touch(self.config.get(Setting.INGRESS_TOKEN_FILE_PATH))
|
||||
await self._ha_source.init()
|
||||
|
||||
redirect = ""
|
||||
try:
|
||||
if request.url.port != self.config.get(Setting.INGRESS_PORT):
|
||||
redirect = self._ha_source.getFullAddonUrl()
|
||||
except: # noqa: E722
|
||||
# eat the error
|
||||
pass
|
||||
return web.json_response({
|
||||
'message': 'Configuration updated',
|
||||
'redirect': redirect
|
||||
})
|
||||
|
||||
async def makeanissue(self, request: Request):
|
||||
if self._global_info._last_error is not None:
|
||||
error = logger.formatException(self._global_info._last_error)
|
||||
else:
|
||||
error = "No error could be identified automatically."
|
||||
data = await self.debug_worker.buildBugReportData(error)
|
||||
body = GITHUB_BUG_TEMPLATE
|
||||
for key in data:
|
||||
if isinstance(data[key], dict):
|
||||
body = body.replace(
|
||||
"{" + key + "}", json.dumps(data[key], indent=4))
|
||||
else:
|
||||
body = body.replace("{" + key + "}", str(data[key]))
|
||||
return web.json_response({'markdown': body})
|
||||
|
||||
async def saveconfig(self, request: Request) -> Any:
|
||||
data = await request.json()
|
||||
update = ensureKey("config", data, "the configuration update request")
|
||||
|
||||
# validate the backup password
|
||||
Password(self.config.getConfigFor(update)).resolve()
|
||||
|
||||
validated, needUpdate = self.config.validate(update)
|
||||
message = await self._updateConfiguration(validated, ensureKey("backup_folder", data, "the configuration update request"), trigger=False)
|
||||
try:
|
||||
await self.cancelSync(request)
|
||||
await self.startSync(request)
|
||||
except: # noqa: E722
|
||||
# eat the error, just cancel optimistically
|
||||
pass
|
||||
return web.json_response(message)
|
||||
|
||||
async def ackignorecheck(self, request: Request):
|
||||
self.ignore_other_turned_on = False
|
||||
return web.json_response({'message': "Acknowledged."})
|
||||
|
||||
async def aknowledgeooboauth(self, request: Request):
|
||||
self._data_cache.addFlag(UpgradeFlags.NOTIFIED_ABOUT_OOB_FLOW)
|
||||
self._data_cache.saveIfDirty()
|
||||
return web.json_response({'message': "Acknowledged"})
|
||||
|
||||
async def _updateConfiguration(self, new_config, backup_folder_id=None, trigger=True):
|
||||
update = {}
|
||||
for key in new_config:
|
||||
update[key.key()] = new_config[key]
|
||||
old_drive_option = self.config.get(Setting.ENABLE_DRIVE_UPLOAD)
|
||||
old_ignore_others_option = self.config.get(Setting.IGNORE_OTHER_BACKUPS)
|
||||
await self._harequests.updateConfig(update)
|
||||
|
||||
self.config.update(new_config)
|
||||
|
||||
if not old_ignore_others_option and self.config.get(Setting.IGNORE_OTHER_BACKUPS):
|
||||
self.ignore_other_turned_on = True
|
||||
self._haupdater.triggerRefresh()
|
||||
if self.config.get(Setting.SPECIFY_BACKUP_FOLDER) and backup_folder_id is not None and len(backup_folder_id):
|
||||
await self.folder_finder.save(backup_folder_id)
|
||||
if trigger:
|
||||
self.trigger()
|
||||
return {
|
||||
'message': 'Settings saved',
|
||||
'reload_page': self.config.get(Setting.ENABLE_DRIVE_UPLOAD) != old_drive_option
|
||||
}
|
||||
|
||||
async def waitForUpload(self):
|
||||
await self._upload_event.wait()
|
||||
|
||||
async def _doUpload(self, slug):
|
||||
await self._coord.uploadBackups(slug)
|
||||
self._upload_event.set()
|
||||
|
||||
async def upload(self, request: Request):
|
||||
slug = request.query.get("slug", "")
|
||||
asyncio.create_task(self._doUpload(slug))
|
||||
return web.json_response({'message': "Uploading backup in the background"})
|
||||
|
||||
async def redirect(self, request, url):
|
||||
context = {
|
||||
**self.base_context(),
|
||||
'url': url
|
||||
}
|
||||
return aiohttp_jinja2.render_template("redirect.jinja2",
|
||||
request,
|
||||
context)
|
||||
|
||||
async def addonLogo(self, request: Request):
|
||||
slug = request.match_info.get('slug')
|
||||
if not self._ha_source.addonHasLogo(slug):
|
||||
raise HTTPNotFound()
|
||||
try:
|
||||
(content_type, data) = await self._harequests.getAddonLogo(slug)
|
||||
return web.Response(headers={hdrs.CONTENT_TYPE: content_type}, body=data)
|
||||
except ClientResponseError as e:
|
||||
return web.Response(status=e.status)
|
||||
|
||||
async def download(self, request: Request):
|
||||
slug = request.query.get("slug", "")
|
||||
backup = self._coord.getBackup(slug)
|
||||
stream = await self._coord.download(slug)
|
||||
await stream.setup()
|
||||
resp = web.StreamResponse()
|
||||
resp.content_type = 'application/tar'
|
||||
resp.headers['Content-Disposition'] = 'attachment; filename="{}.tar"'.format(
|
||||
backup.name())
|
||||
resp.headers['Content-Length'] = str(stream.size())
|
||||
|
||||
await resp.prepare(request)
|
||||
|
||||
# SOMEDAY: consider re-streaming a decrypted tar file for the sake of convenience
|
||||
|
||||
async for chunk in stream.generator(self.config.get(Setting.DEFAULT_CHUNK_SIZE)):
|
||||
await resp.write(chunk)
|
||||
|
||||
await resp.write_eof()
|
||||
|
||||
async def run(self) -> None:
|
||||
await self.stop()
|
||||
|
||||
# Create the ingress server
|
||||
app = web.Application(middlewares=[self.error_middleware])
|
||||
aiohttp_jinja2.setup(app, loader=jinja2.FileSystemLoader(self.filePath()))
|
||||
self._addRoutes(app)
|
||||
|
||||
# The ingress port is considered secured by Home Assistant, so it doesn't get SSL or basic HTTP auth
|
||||
logger.info("Starting server on port {}".format(
|
||||
self.config.get(Setting.INGRESS_PORT)))
|
||||
await self._start_site(app, self.config.get(Setting.INGRESS_PORT))
|
||||
|
||||
try:
|
||||
if self.config.get(Setting.EXPOSE_EXTRA_SERVER):
|
||||
ssl_context = None
|
||||
if self.config.get(Setting.USE_SSL):
|
||||
ssl_context = ssl.create_default_context(
|
||||
ssl.Purpose.CLIENT_AUTH)
|
||||
ssl_context.load_cert_chain(self.config.get(
|
||||
Setting.CERTFILE), self.config.get(Setting.KEYFILE))
|
||||
middleware = [self.error_middleware]
|
||||
if self.config.get(Setting.REQUIRE_LOGIN):
|
||||
middleware.append(HomeAssistantLoginAuth(
|
||||
self._time, self._harequests))
|
||||
|
||||
extra_app = web.Application(middlewares=middleware)
|
||||
aiohttp_jinja2.setup(extra_app, loader=jinja2.FileSystemLoader(self.filePath()))
|
||||
self._addRoutes(extra_app)
|
||||
logger.info("Starting server on port {}".format(
|
||||
self.config.get(Setting.PORT)))
|
||||
await self._start_site(extra_app, self.config.get(Setting.PORT), ssl_context=ssl_context)
|
||||
except FileNotFoundError:
|
||||
logger.error("The configured SSL key or certificate files couldn't be found and so \nan SSL server couldn't be started, please check your settings. \nThe addon web-ui is still available through ingress.")
|
||||
except ssl.SSLError:
|
||||
logger.error("Your SSL certificate or key couldn't be loaded and so an SSL server couldn't be started. Please verify that your SSL settings are correctly configured. The addon web-ui is still available through ingress.")
|
||||
logger.info("Server started")
|
||||
self.running = True
|
||||
self._starts += 1
|
||||
|
||||
def _addRoutes(self, app):
|
||||
app.add_routes(
|
||||
[web.static('/static/' + VERSION, abspath(join(__file__, "..", "..", "static")), append_version=True)])
|
||||
app.add_routes([web.get('/', self.index)])
|
||||
app.add_routes([web.get('/index.html', self.index)])
|
||||
app.add_routes([web.get('/index', self.index)])
|
||||
app.add_routes([web.get('/favicon.ico', self.favicon)])
|
||||
app.add_routes([web.get('/logo/{slug}', self.addonLogo)])
|
||||
self._addRoute(app, self.reauthenticate)
|
||||
self._addRoute(app, self.bootstrap)
|
||||
self._addRoute(app, self.tos)
|
||||
self._addRoute(app, self.pp)
|
||||
|
||||
self._addRoute(app, self.getstatus)
|
||||
self._addRoute(app, self.backup)
|
||||
self._addRoute(app, self.manualauth)
|
||||
self._addRoute(app, self.token)
|
||||
|
||||
self._addRoute(app, self.log)
|
||||
|
||||
self._addRoute(app, self.sync)
|
||||
self._addRoute(app, self.startSync)
|
||||
self._addRoute(app, self.cancelSync)
|
||||
|
||||
self._addRoute(app, self.getconfig)
|
||||
self._addRoute(app, self.errorreports)
|
||||
self._addRoute(app, self.exposeserver)
|
||||
self._addRoute(app, self.saveconfig)
|
||||
self._addRoute(app, self.changefolder)
|
||||
self._addRoute(app, self.confirmdelete)
|
||||
self._addRoute(app, self.resolvefolder)
|
||||
self._addRoute(app, self.skipspacecheck)
|
||||
|
||||
self._addRoute(app, self.upload)
|
||||
self._addRoute(app, self.download)
|
||||
self._addRoute(app, self.deleteSnapshot)
|
||||
self._addRoute(app, self.retain)
|
||||
self._addRoute(app, self.note)
|
||||
|
||||
self._addRoute(app, self._debug.simerror)
|
||||
self._addRoute(app, self._debug.getTasks)
|
||||
self._addRoute(app, self._debug.timeoffset)
|
||||
self._addRoute(app, self.makeanissue)
|
||||
self._addRoute(app, self.ignorestartupcooldown)
|
||||
self._addRoute(app, self.callbackupsnapshot)
|
||||
self._addRoute(app, self.ignoredbackupswitch)
|
||||
self._addRoute(app, self.ignore)
|
||||
self._addRoute(app, self.ackignorecheck)
|
||||
self._addRoute(app, self.aknowledgeooboauth)
|
||||
self._addRoute(app, self.checkManualAuth)
|
||||
|
||||
def _addRoute(self, app, method):
|
||||
app.add_routes([
|
||||
web.get("/" + method.__name__, method),
|
||||
web.post("/" + method.__name__, method)
|
||||
])
|
||||
|
||||
async def start(self):
|
||||
await self.run()
|
||||
|
||||
async def _start_site(self, app, port, ssl_context=None):
|
||||
aiohttp_logger = TraceLogger("aiohttp.access")
|
||||
if self.config.get(Setting.TRACE_REQUESTS):
|
||||
runner = web.AppRunner(app, logger=aiohttp_logger, access_log=aiohttp_logger,
|
||||
access_log_format='%a %t "%r" %s %b "%{Referer}i" "%{User-Agent}i (%Tfs)"')
|
||||
else:
|
||||
runner = web.AppRunner(app)
|
||||
self.runners.append(runner)
|
||||
await runner.setup()
|
||||
# maybe host should be 0.0.0.0
|
||||
site = web.TCPSite(runner, "0.0.0.0", port, ssl_context=ssl_context)
|
||||
await site.start()
|
||||
|
||||
async def stop(self):
|
||||
# Stop pending requests for all available servers
|
||||
for runner in self.runners:
|
||||
try:
|
||||
await runner.shutdown()
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Error while trying to shut down server: " + str(e))
|
||||
try:
|
||||
await runner.cleanup()
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Error while trying to shut down server: " + str(e))
|
||||
self.runners = []
|
||||
|
||||
async def shutdown(self):
|
||||
await self.stop()
|
||||
|
||||
@web.middleware
|
||||
async def error_middleware(self, request: Request, handler):
|
||||
try:
|
||||
log_trace = self.config.get(Setting.TRACE_REQUESTS)
|
||||
if log_trace:
|
||||
logger.trace("Serving %s %s to %s", request.method,
|
||||
request.url, request.remote)
|
||||
handled = await handler(request)
|
||||
if log_trace:
|
||||
logger.trace("Completed %s %s", request.method, request.url)
|
||||
return handled
|
||||
except Exception as ex:
|
||||
if isinstance(ex, HTTPException):
|
||||
raise
|
||||
logger.error("Error serving %s %s", request.method, request.url)
|
||||
logger.error(logger.formatException(ex))
|
||||
data = self.processError(ex)
|
||||
return web.json_response(data, status=data['http_status'])
|
||||
|
||||
def processError(self, e):
|
||||
if isinstance(e, KnownError):
|
||||
known: KnownError = e
|
||||
return {
|
||||
'http_status': known.httpStatus(),
|
||||
'error_type': known.code(),
|
||||
'message': known.message(),
|
||||
'details': logger.formatException(e),
|
||||
'data': known.data()
|
||||
}
|
||||
else:
|
||||
return {
|
||||
'http_status': 500,
|
||||
'error_type': "generic_error",
|
||||
'message': "An unexpected error occurred: " + str(e),
|
||||
'details': logger.formatException(e)
|
||||
}
|
||||
|
||||
def filePath(self, name=None):
|
||||
if name is None:
|
||||
return abspath(join(__file__, "..", "..", "static"))
|
||||
else:
|
||||
return abspath(join(__file__, "..", "..", "static", name))
|
||||
|
||||
def cssElement(self, selector, keys):
|
||||
ret = selector
|
||||
ret += " {\n"
|
||||
for key in keys:
|
||||
ret += "\t" + key + ": " + keys[key] + ";\n"
|
||||
ret += "}\n\n"
|
||||
return ret
|
||||
|
||||
async def index(self, request: Request):
|
||||
if not self._coord.enabled():
|
||||
template = "index.jinja2"
|
||||
context = {
|
||||
**self.base_context(),
|
||||
'showOpenDriveLink': True
|
||||
}
|
||||
else:
|
||||
template = "working.jinja2"
|
||||
context = {
|
||||
**self.base_context(),
|
||||
'showOpenDriveLink': True,
|
||||
'navBarTitle': 'Backups'
|
||||
}
|
||||
response = aiohttp_jinja2.render_template(template,
|
||||
request,
|
||||
context)
|
||||
response.headers['cache-control'] = 'no-store'
|
||||
return response
|
||||
|
||||
@aiohttp_jinja2.template('privacy_policy.jinja2')
|
||||
async def pp(self, request: Request):
|
||||
return self.base_context()
|
||||
|
||||
@aiohttp_jinja2.template('terms_of_service.jinja2')
|
||||
async def tos(self, request: Request):
|
||||
return self.base_context()
|
||||
|
||||
async def favicon(self, request: Request):
|
||||
return web.FileResponse(abspath(join(__file__, "..", "..", "static", "images", "favicon.png")))
|
||||
|
||||
@aiohttp_jinja2.template('index.jinja2')
|
||||
async def reauthenticate(self, request: Request) -> Any:
|
||||
return {
|
||||
**self.base_context(),
|
||||
'showOpenDriveLink': True
|
||||
}
|
||||
|
||||
|
||||
@web.middleware
|
||||
class HomeAssistantLoginAuth():
|
||||
def __init__(self, time, harequests):
|
||||
self._time = time
|
||||
self._harequests = harequests
|
||||
self.auth_cache: Dict[str, Any] = {}
|
||||
self.realm = "Home Assistant Login"
|
||||
|
||||
def parse_auth_header(self, request):
|
||||
auth_header = request.headers.get(hdrs.AUTHORIZATION)
|
||||
if not auth_header:
|
||||
return None
|
||||
try:
|
||||
auth = BasicAuth.decode(auth_header=auth_header)
|
||||
except ValueError:
|
||||
auth = None
|
||||
return auth
|
||||
|
||||
async def authenticate(self, request):
|
||||
auth = self.parse_auth_header(request)
|
||||
return (auth is not None and await self.check_credentials(auth.login, auth.password))
|
||||
|
||||
async def check_credentials(self, username, password):
|
||||
if username is None:
|
||||
raise ValueError('username is None') # pragma: no cover
|
||||
|
||||
if password is None:
|
||||
raise ValueError('password is None') # pragma: no cover
|
||||
|
||||
if username in self.auth_cache and self.auth_cache[username]['password'] == password and self.auth_cache[username]['timeout'] > self._time.now():
|
||||
return True
|
||||
try:
|
||||
await self._harequests.auth(username, password)
|
||||
self.auth_cache[username] = {'password': password, 'timeout': (
|
||||
self._time.now() + timedelta(minutes=10))}
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.printException(e)
|
||||
return False
|
||||
|
||||
def challenge(self):
|
||||
return web.Response(
|
||||
body=b'', status=401, reason='UNAUTHORIZED',
|
||||
headers={
|
||||
hdrs.WWW_AUTHENTICATE: 'Basic realm="%s"' % self.realm,
|
||||
hdrs.CONTENT_TYPE: 'text/html; charset=utf-8',
|
||||
hdrs.CONNECTION: 'keep-alive'
|
||||
}
|
||||
)
|
||||
|
||||
async def __call__(self, request, handler):
|
||||
if await self.authenticate(request):
|
||||
return await handler(request)
|
||||
else:
|
||||
return self.challenge()
|
||||
Reference in New Issue
Block a user