|
|
|
@@ -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()
|