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,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
}