diff --git a/.devcontainer/requirements-dev.txt b/.devcontainer/requirements-dev.txt index 21f1f694..2aa9d847 100644 --- a/.devcontainer/requirements-dev.txt +++ b/.devcontainer/requirements-dev.txt @@ -20,7 +20,7 @@ aiofiles injector autopep8 colorlog -ptvsd +debugpy google-cloud-logging google-cloud-firestore aiohttp-jinja2 diff --git a/LOCAL_AUTH.md b/LOCAL_AUTH.md index 5954d386..39e48db2 100644 --- a/LOCAL_AUTH.md +++ b/LOCAL_AUTH.md @@ -1,49 +1,45 @@ -# Notice 3/16/2022 -Because of [recent changes Google has made](https://developers.googleblog.com/2022/02/making-oauth-flows-safer.html#dates-oob) to its authentication backend the below instructions no longer work. I'm aware of the issue, have a workaround selected and am working toward implementing it. In the meantime using your own credentials is not available and the only way to authenticate the addon is using the big "Authenticate with Google Drive" button which sends credentials through the habackup.io domain. - - -# Local Authentication -You've arrived here because you'd like to use your own client ID and client secret to authenticate the add-on with Google Drive. I'll caution that this is a very detailed and complicated process geared more toward developers than end users, so if you'd like to do it the easy way, go back to your add-on (typically http://homeassistant.local:8123/hassio/ingress/hassio_google_drive_backup) and click the "Authenticate with Google Drive" button. These instructions will have you create a project on Google's Developer Cloud console, generate your own credentials, and use them to authenticate with Google Drive. You can expect this to take about 15 minutes. Typically this is what would be done by a developer when releasing a project that serveral users would use, but in this case you will be the only user. This workflow is for you if: -* You'd like to avoid having your account's credentials go through a server maintained by me. The typical authentication workflow never sees your Google account password, but it does recieve a token from Google that, if I were malicious, I could use to see the backups you've uploaded to Google Drive. I don't store this token anywhere and instead just pass it back to you, but becase of how Google oauth tokens are generated there is no way you could verify that. I tip my tinfoil hat to yours and respect your desire to protect your personal information :) -* The typical authentication flow didn't work. This may be because of a bug, or because the server I set up to handle it is down or broken. Its just me back here providing this as a free service to the community, so applogogies if things fall into disrepair. - -These instructions are current as of March 2021. If you do this and notice they're out of date, Please file an issue on this project's issue page so I can be made aware of it. Thanks! - -## Step 1 - Create a Google Cloud Project -* Go to http://console.developers.google.com and log in with your Google account. -* Click "Select Project" on the top left. -* Click "New Project" to create a project. -* Give the project any name you like, and click "Create Project". Don't worry about billing or location information, you won't be charged for anything we're doing here. -![](images/step1.png) - -## Step 2 - Enable the Drive API -With your project now created: -* Go to https://console.developers.google.com/apis/library -* Search for "Google Drive API", and click "Enable". This is necessary because the "Project" you're creating will use the [Google Drive API](https://developers.google.com/drive/api/v3/reference). - -## Step 3 - Create a Consent Screen -Before creating credentials, you'll need to create a consent screen. Normally this is what people would see when they request to allow your new application to access their Google Drive, but because you're creating it just for yourself this is basically just a necessary formality. -* Go back to http://console.developers.google.com and ensure the project name you created earlier is displayed in the upper left. -* In the menu on the upper left, click **APIs & Services** then *OAuth Consent Screen*. -* Select *External* for the user type and then click "Create". Even though you're probably making these credentials with the same account you'll be using to authenticate the addon, you'll still be considered an *External* user. -* On the next screen "App Information", fill in all the required fields, *App Name*, *Support Email*, and *Developer Email*. Then click Continue. What you enter here doesn't really matter, but a good App Name is something that will make you laugh if you ever have to see this again, like "Buy the name-brand SD Card this time, maybe?" -* On the next screen, click **Add OR Remove Scopes**. In the dialog that pops up check the box for "../auth/drive.file" and then click "Add". You might have to search for "drive.file" to make it show up. This part is very important since it gives the credentials we're about to create permission to see files in Google Drive. If you don't see this in the dialog that comes up, make sure you did step 2. -* You can leave the rest of this form blank, just click **Save** or **Continue** for any other screens. -* Once its created, either click **Go Back to Dashboard** or click **OAuth Consent Screen** on the left. Under **Publishing status** click **Publish App** and then **Confirm**. This dialog will warn that the app will be available to all users, but in our case it will still only be you if you keep the credentials you create later just to yourself. This step is necessary because "Testing" credentials would require you to manually re-authorize the addon ever 7 days, which is a pain. - -## Step 4 - Create Credentials -Now you've set up everything necessary to actually create credentials. -* From http://console.developers.google.com, click **APIs & Services** then **Credentials** on the left. -* Click **+ Create Credentials** at the top of the page. -* Select "OAuth client ID" form the drop down. -This should have opened a dialog titled "Create OAuth client ID". -![](images/step3-b.png) -* Select **Desktop app** for **Application Type** -* Give the credentials a **Name**, anything will do. -![](images/step4.png) -* Click "Create" - - -## Step 5 - Copy your credentials -This should have opened a new dialog with your generated client ID and client secret. Take these back to the Add-on, and paste them into the appropriate fields of the add-on web-UI, and follow the instructions from there. -![](images/step5.png) +# Using Custom/Personal Google Credentials +You've arrived here because you'd like to use your own client ID and client secret to authenticate the add-on with Google Drive. I'll caution that this is a very detailed and complicated process geared more toward developers than end users, so if you'd like to do it the easy way, go back to your add-on (typically http://homeassistant.local:8123/hassio/ingress/hassio_google_drive_backup) and click the "Authenticate with Google Drive" button. These instructions will have you create a project on Google's Developer Cloud console, generate your own credentials, and use them to authenticate with Google Drive. You can expect this to take about 15 minutes. Typically this is what would be done by a developer when releasing a project that serveral users would use, but in this case you will be the only user. This workflow is for you if: +* You'd like to avoid having your account's credentials go through a server maintained by the developer of this addon. The typical authentication workflow never sees your Google account password, but it does recieve a token from Google that, if I were malicious, I could use to see the backups you've uploaded to Google Drive. I don't store this token anywhere and instead just pass it back to you, but because of how Google OAuth tokens are generated there is no way you could verify that. I tip my tinfoil hat to yours and respect your desire to protect your personal information :) +* The typical authentication flow didn't work. This may be because of a bug, or because the server I set up to handle it is down or broken. Its just me back here providing this as a free service to the community, so applogogies if things fall into disrepair. + +These instructions are current as of March 2022. If you do this and notice they're out of date, Please file an issue on this project's issue page so I can be made aware of it. Thanks! +## Step 0 - Check addon version +You must be runnign version 0.106.1 or greater of the add-on for this to work. In Feb 2022 Google changed how some of their authentication APIs work which broke the way the addon did it before that version +## Step 1 - Create a Google Cloud Project +* Go to http://console.developers.google.com and log in with your Google account. +* Click "Select Project" on the top left. +* Click "New Project" to create a project. +* Give the project any name you like, and click "Create Project". Don't worry about billing or location information, you won't be charged for anything we're doing here. +![](images/step1.png) + +## Step 2 - Enable the Drive API +With your project now created: +* Go to https://console.developers.google.com/apis/library +* Search for "Google Drive API", and click "Enable". This is necessary because the "Project" you're creating will use the [Google Drive API](https://developers.google.com/drive/api/v3/reference). + +## Step 3 - Create a Consent Screen +Before creating credentials, you'll need to create a consent screen. Normally this is what people would see when they request to allow your new application to access their Google Drive, but because you're creating it just for yourself this is basically just a necessary formality. +* Go back to http://console.developers.google.com and ensure the project name you created earlier is displayed in the upper left. +* In the menu on the upper left, click **Enabled APIs & Services** then *OAuth Consent Screen*. +* Select *External* for the user type and then click "Create". Even though you're probably making these credentials with the same account you'll be using to authenticate the addon, you'll still be considered an *External* user. +* On the next screen "App Information", fill in all the required fields, *App Name*, *Support Email*, and *Developer Email*. Then click "Save & Continue". What you enter here doesn't really matter, but a good App Name is something that will make you laugh if you ever have to see this again, like "Buy the name-brand SD Card this time, maybe?" +* On the next screen, click **Add OR Remove Scopes**. In the dialog that pops up check the box for "../auth/drive.file" and then click "Update". You might have to search for "drive.file" to make it show up. This part is very important since it gives the credentials we're about to create permission to see files in Google Drive. If you don't see this in the dialog that comes up, make sure you did step 2. +* You can leave the rest of this form blank, just click **Save** or **Continue** for any other screens. +* Once its created, either click **Go Back to Dashboard** or click **OAuth Consent Screen** on the left. Under **Publishing status** click **Publish App** and then **Confirm**. This dialog will warn that the app will be available to all users, but in our case it will still only be you if you keep the credentials you create later just to yourself. This step is necessary because "Testing" credentials would require you to manually re-authorize the addon ever 7 days, which is a pain. + +## Step 4 - Create Credentials +Now you've set up everything necessary to actually create credentials. +* From http://console.developers.google.com, click **Enabled APIs & Services** then **Credentials** on the left. +* Click **+ Create Credentials** at the top of the page. +* Select "OAuth client ID" form the drop down. +This should have opened a dialog titled "Create OAuth client ID". +* Select **TVs and Limited Input Devices** for **Application Type**. Home Assistant might not seem like a "Limited Input Device" but is is necessary because its the only OAuth authentication method Google provides that doesn't require you to maintain a public SSL encrpted web service. +* Give the credentials a **Name**, anything will do and it doesn't matter. +![](images/step4.png) +* Click "Create" + + +## Step 5 - Copy your credentials +This should have opened a new dialog with your generated client ID and client secret. Take these back to the Add-on, and paste them into the appropriate fields of the add-on web-UI, and follow the instructions from there. +![](images/step5.png) diff --git a/README.md b/README.md index 3d070860..50c5d51f 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ This addon has been featured by %YOUR_FAVORITE_HA_YOUTUBER% and is often listed >[](https://www.patreon.com/bePatron?u=4064183) ### Detailed Install Instructions -1. Navigate in your Home Assistant frontend to Supervisor -> Add-on Store. +1. Navigate in your Home Assistant frontend to Configuration -> Add-ons, Backups & Supervisor -> Add-on Store (Bottom Right). 2. Click the 3-dots menu at upper right ... > Repositories and add this repository's URL: [https://github.com/sabeechen/hassio-google-drive-backup](https://github.com/sabeechen/hassio-google-drive-backup) diff --git a/hassio-google-drive-backup/CHANGELOG.md b/hassio-google-drive-backup/CHANGELOG.md index 89c3b82b..ca0d273e 100644 --- a/hassio-google-drive-backup/CHANGELOG.md +++ b/hassio-google-drive-backup/CHANGELOG.md @@ -1,3 +1,12 @@ +## [0.106.1 2022-3-21] +### Fixes +* Updates the mechanism for using custom/personal Google API credentials to work with Google's newer APIs +* Fixes a problem that prevented loading backups from Google Drive -> Home Assistant through the Nabu Casa remote UI + +### New +* Added the ability to delete any "ignored" snapshots after a certain age. + + ## [0.105.2 2021-9-7] ### Fixes * Include addon version number in the path component of static resources in an attempt to resolve [issue #466](https://github.com/sabeechen/hassio-google-drive-backup/issues/466). Special thanks to [@stigvig](https://github.com/stigvig) and [@loomyr](https://github.com/loomyr) for helping me dig into this. diff --git a/hassio-google-drive-backup/DOCS.md b/hassio-google-drive-backup/DOCS.md index a1d0f030..8b792675 100644 --- a/hassio-google-drive-backup/DOCS.md +++ b/hassio-google-drive-backup/DOCS.md @@ -10,7 +10,7 @@ _Note_: The configuration can be changed easily by starting the add-on and click The UI explains what each setting is and you don't need to modify anything before clicking `Start`. If you would still prefer to modify the settings in yaml, the options are detailed below. -Add-on configuration example. Don't use this directly, the addon has a lot of configuration options that most users don't need: +Add-on configuration example. Don't use this directly, the addon has a lot of configuration options that most users don't need or want: ```yaml # Keep 10 backups in Home Assistant @@ -25,6 +25,9 @@ ignore_other_backups: True # Ignore backups that look like they were created by Home Assistant automatic backup option during upgrades ignore_upgrade_backups: True +# Automatically delete "ignored" snapshots after this many days +delete_ignored_after_days: 7 + # Take a backup every 3 days days_between_backups: 3 diff --git a/hassio-google-drive-backup/backup/config/__init__.py b/hassio-google-drive-backup/backup/config/__init__.py index cf94d55b..ccee27f7 100644 --- a/hassio-google-drive-backup/backup/config/__init__.py +++ b/hassio-google-drive-backup/backup/config/__init__.py @@ -5,4 +5,5 @@ from .boolvalidator import BoolValidator from .startable import Startable from .listvalidator import ListValidator +from.durationasstringvalidator import DurationAsStringValidator from .version import Version diff --git a/hassio-google-drive-backup/backup/config/config.py b/hassio-google-drive-backup/backup/config/config.py index 8fd80f0d..38e3d644 100644 --- a/hassio-google-drive-backup/backup/config/config.py +++ b/hassio-google-drive-backup/backup/config/config.py @@ -33,6 +33,7 @@ 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, diff --git a/hassio-google-drive-backup/backup/config/durationassecondsvalidator.py b/hassio-google-drive-backup/backup/config/durationassecondsvalidator.py deleted file mode 100644 index 404e9938..00000000 --- a/hassio-google-drive-backup/backup/config/durationassecondsvalidator.py +++ /dev/null @@ -1,29 +0,0 @@ -from datetime import timedelta -from .durationparser import DurationParser -from .validator import Validator - - -class DurationAsSecondsValidator(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: - if type(value) == str: - value = DurationParser().parse(value).total_seconds() - 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 - - def formatForUi(self, value): - return DurationParser().format(timedelta(seconds=value)) diff --git a/hassio-google-drive-backup/backup/config/durationasstringvalidator.py b/hassio-google-drive-backup/backup/config/durationasstringvalidator.py new file mode 100644 index 00000000..8df5a3a0 --- /dev/null +++ b/hassio-google-drive-backup/backup/config/durationasstringvalidator.py @@ -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)) diff --git a/hassio-google-drive-backup/backup/config/settings.py b/hassio-google-drive-backup/backup/config/settings.py index bb4491f8..bf6e6f34 100644 --- a/hassio-google-drive-backup/backup/config/settings.py +++ b/hassio-google-drive-backup/backup/config/settings.py @@ -8,7 +8,7 @@ from .regexvalidator import RegexValidator from .stringvalidator import StringValidator from .listvalidator import ListValidator -from .durationassecondsvalidator import DurationAsSecondsValidator +from .durationasstringvalidator import DurationAsStringValidator from ..logger import getLogger logger = getLogger(__name__) @@ -21,6 +21,7 @@ class Setting(Enum): 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" @@ -103,6 +104,7 @@ class Setting(Enum): 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" @@ -155,6 +157,7 @@ def key(self): Setting.DAYS_BETWEEN_BACKUPS: 3, Setting.IGNORE_OTHER_BACKUPS: False, Setting.IGNORE_UPGRADE_BACKUPS: False, + 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: "", @@ -235,6 +238,7 @@ def key(self): 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", @@ -282,6 +286,7 @@ def key(self): 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$)?", @@ -362,6 +367,7 @@ def key(self): 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?", @@ -465,7 +471,8 @@ def getValidator(name, schema): # for key in addon_config["schema"]: # _VALIDATORS[_LOOKUP[key]] = getValidator(key, addon_config["schema"][key]) -_VALIDATORS[Setting.MAX_SYNC_INTERVAL_SECONDS] = DurationAsSecondsValidator("max_sync_interval_seconds", 1, None) +_VALIDATORS[Setting.MAX_SYNC_INTERVAL_SECONDS] = DurationAsStringValidator("max_sync_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) VERSION = addon_config["version"] diff --git a/hassio-google-drive-backup/backup/const.py b/hassio-google-drive-backup/backup/const.py index 15d4bf67..961be628 100644 --- a/hassio-google-drive-backup/backup/const.py +++ b/hassio-google-drive-backup/backup/const.py @@ -24,6 +24,7 @@ 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" diff --git a/hassio-google-drive-backup/backup/debug/debug_server.py b/hassio-google-drive-backup/backup/debug/debug_server.py index e5aa5a04..7e7b292b 100644 --- a/hassio-google-drive-backup/backup/debug/debug_server.py +++ b/hassio-google-drive-backup/backup/debug/debug_server.py @@ -1,6 +1,5 @@ from backup.config import Config, Setting, Startable from backup.logger import getLogger -import sys from injector import inject, singleton logger = getLogger(__name__) @@ -13,10 +12,7 @@ def __init__(self, config: Config): async def start(self): if self._config.get(Setting.DEBUGGER_PORT) is not None: - if 'ptvsd' not in sys.modules: - logger.error("Unable to start the debugger server because the ptvsd library is not installed") - else: - import ptvsd - port = self._config.get(Setting.DEBUGGER_PORT) - logger.info("Starting debugger on port {}".format(port)) - ptvsd.enable_attach(('0.0.0.0', port)) + import debugpy + port = self._config.get(Setting.DEBUGGER_PORT) + logger.info("Starting debugger on port {}".format(port)) + debugpy.listen(("0.0.0.0", port)) diff --git a/hassio-google-drive-backup/backup/drive/__init__.py b/hassio-google-drive-backup/backup/drive/__init__.py index 1d64e6a6..36cd1a6f 100644 --- a/hassio-google-drive-backup/backup/drive/__init__.py +++ b/hassio-google-drive-backup/backup/drive/__init__.py @@ -1,4 +1,5 @@ # flake8: noqa from .driverequests import DriveRequests, RETRY_SESSION_ATTEMPTS, UPLOAD_SESSION_EXPIRATION_DURATION, URL_START_UPLOAD from .drivesource import DriveSource, SOURCE_GOOGLE_DRIVE -from .folderfinder import FolderFinder \ No newline at end of file +from .folderfinder import FolderFinder +from .authcodequery import AuthCodeQuery \ No newline at end of file diff --git a/hassio-google-drive-backup/backup/drive/authcodequery.py b/hassio-google-drive-backup/backup/drive/authcodequery.py new file mode 100644 index 00000000..715371a9 --- /dev/null +++ b/hassio-google-drive-backup/backup/drive/authcodequery.py @@ -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") diff --git a/hassio-google-drive-backup/backup/drive/driverequests.py b/hassio-google-drive-backup/backup/drive/driverequests.py index 2a08429d..13e64b9c 100644 --- a/hassio-google-drive-backup/backup/drive/driverequests.py +++ b/hassio-google-drive-backup/backup/drive/driverequests.py @@ -36,7 +36,6 @@ URL_FILES = "/drive/v3/files/" URL_ABOUT = "/drive/v3/about" URL_START_UPLOAD = "/upload/drive/v3/files/?uploadType=resumable&supportsAllDrives=true" -URL_AUTH = "/oauth2/v4/token" PAGE_SIZE = 100 CHUNK_SIZE = 5 * 262144 RANGE_RE = re.compile("^bytes=0-\\d+$") diff --git a/hassio-google-drive-backup/backup/exceptions/__init__.py b/hassio-google-drive-backup/backup/exceptions/__init__.py index 3eb7c765..731c24c8 100644 --- a/hassio-google-drive-backup/backup/exceptions/__init__.py +++ b/hassio-google-drive-backup/backup/exceptions/__init__.py @@ -1,2 +1,2 @@ # flake8: noqa -from .exceptions import 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 +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 diff --git a/hassio-google-drive-backup/backup/exceptions/exceptions.py b/hassio-google-drive-backup/backup/exceptions/exceptions.py index 66fb0165..4f5e036c 100644 --- a/hassio-google-drive-backup/backup/exceptions/exceptions.py +++ b/hassio-google-drive-backup/backup/exceptions/exceptions.py @@ -3,7 +3,7 @@ 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_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, @@ -433,3 +433,14 @@ def message(self): 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 diff --git a/hassio-google-drive-backup/backup/model/model.py b/hassio-google-drive-backup/backup/model/model.py index f146d617..7917f8dc 100644 --- a/hassio-google-drive-backup/backup/model/model.py +++ b/hassio-google-drive-backup/backup/model/model.py @@ -175,6 +175,16 @@ async def sync(self, now: datetime): 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(): diff --git a/hassio-google-drive-backup/backup/static/authorize.jinja2 b/hassio-google-drive-backup/backup/static/authorize.jinja2 index 8dd26450..e3bc0ae2 100644 --- a/hassio-google-drive-backup/backup/static/authorize.jinja2 +++ b/hassio-google-drive-backup/backup/static/authorize.jinja2 @@ -34,7 +34,7 @@