Skip to content

Commit

Permalink
2.3.5 (#250)
Browse files Browse the repository at this point in the history
2.3.5
  • Loading branch information
lavinir authored Nov 20, 2024
1 parent 8c8c5f3 commit 0ba8bf6
Show file tree
Hide file tree
Showing 15 changed files with 280 additions and 166 deletions.
12 changes: 3 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@ This addon enables easy Home Assistant backup creation and sync to OneDrive.
[Installation Instructions](#installation-instructions)<br/>
[Configuration](#configuration)<br/>
[Backup Location in OneDrive](#backup-location-in-onedrive)<br/>
[OneDrive FreeSpace Sensor](#onedrive-free-space-sensor)<br/>
[Home Assistant Sensor](#home-assistant-backup-sensor)<br/>
[Events](#events)<br/>
[Restoring from backup](#restoring-from-backup)<br/>
Expand All @@ -34,8 +33,9 @@ This addon enables easy Home Assistant backup creation and sync to OneDrive.
>If you've installed Add-ons before this will be pretty straightforward and you can skip reading thorugh all the steps below **except the initial authentication parts in steps 7 and 8**.
1. From the Home Assistant frontend navigate to the Add-on Store ( **Settings** -> **Add-ons** -> **Add-on Store** [bottom right])
2. Select the **Repositories** option from the 3-dot menu in the top right corner and add this repository url: <https://github.com/lavinir/hassio-onedrive-backup> </br> ![repositories-menu](/onedrive-backup/images/addon-repo-menu.png)
![add-repo](/onedrive-backup/images/add-repo.png)
2. Select the **Repositories** option from the 3-dot menu in the top right corner and add this repository url: <https://github.com/lavinir/hassio-onedrive-backup> </br> ![repositories-menu](onedrive-backup/images/addon-repo-menu.png)
![add-repo](onedrive-backup/images/add-repo.png)
> A **Preview channel** is also available to get and test newer versions earlier. If you want to participate in the preview channel use the following repository URL instead: *https://github.com/lavinir/hassio-onedrive-backup#preview*
3. Reload the Add-on page (hard refresh might be required) and scroll down. You should now see a new section titled **Home Assistant Onedrive Backup Repository** that contains the **OneDrive Backup** addon.
4. Click **Install** and wait a few minutes for the addon to download
5. I recommend setting a backup password for your Home Assistant backups. You can do this in the addon **Configuration**.
Expand Down Expand Up @@ -198,12 +198,6 @@ You can choose to 'pin' specific backups so that they stop counting against the
dedicated buttons in the main Dashboard for each backup.
> Note - As any 'retained' backup will not count towards your maximum backup quota you could for example, have a maximum of 3 local backups set but chose to retain 2 Local backups. The addon will ignore the retained backups for the quota and will store a maximum of 3 addtional local backups
## OneDrive free space Sensor
The add-on creates a native Home Assistant Sensor entity <kbd>sensor.onedrivefreespace</kbd> that will show you the amount of free space in your OneDrive account.

![freespace_sensor_snapshot](onedrive-backup/images/sensor_freespace.png)


## Home Assistant backup sensor
The add-on creates a native Home Assistant Sensor entity <kbd>sensor.onedrivebackup</kbd> which grants visibility to the backup status as well as allows you to create automations on these values as needed.

Expand Down
11 changes: 11 additions & 0 deletions onedrive-backup/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,14 @@
## v2.3.5 [November 20th 2024]
### ❗Important
The OneDrive Entra App was created as a Multi Tenant app (to enable future Business account support). Earlier this month due to a new MS policy, these apps required Verified Publishers (Microsoft Partners) otherwise it will not allow users to grant consent. I've updated the App to only allow Personal Accounts. This also required code changes. Please make sure authentication goes through properly after the update and if you have any issues with this please consolidate them around the [opened Github issue]("https://github.com/lavinir/hassio-onedrive-backup/issues/247)

### 🐞 Fixed
* Authentication / Permissions issue
* Continous backup upload / delete loop in certain edge cases with Generational Backups enabled

### 🗑️ Removed
* Free Space Sensor - Turns out getting the Available free space in OneDrive requires Read All permissions on the OneDrive account. I didn't notice this was happening with my account but this could prompt for additional consent when the App makes the API call. Unfortunately having The app run with these extended permissions is something I wasn't willing to do since the beginning and regretably I've removed this feature currently.

## v2.3.1 [March 19th 2024]
### ❗Important
Upgrade to Version 2.3 included updates to authentication libraries which caused some connection resets with OneDrive. Please make sure that you have a working connection post upgrade. For troubleshooting please refer to [this link]("https://github.com/lavinir/hassio-onedrive-backup/issues/174")
Expand Down
2 changes: 1 addition & 1 deletion onedrive-backup/Contracts/AddonOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ namespace hassio_onedrive_backup.Contracts
{
public class AddonOptions : IEqualityComparer<AddonOptions>

Check warning on line 10 in onedrive-backup/Contracts/AddonOptions.cs

View workflow job for this annotation

GitHub Actions / build

'AddonOptions' overrides Object.Equals(object o) but does not override Object.GetHashCode()
{
public const string AddonVersion = "2.3.1";
public const string AddonVersion = "2.3.5";

public event Action OnOptionsChanged;

Expand Down
1 change: 0 additions & 1 deletion onedrive-backup/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,3 @@ LABEL \
io.hass.version="$VERSION" \
io.hass.type="addon" \
io.hass.arch="armhf|aarch64|amd64|armv7"

177 changes: 129 additions & 48 deletions onedrive-backup/Graph/GraphHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,14 @@
using File = System.IO.File;
using DriveUpload = Microsoft.Graph.Drives.Item.Items.Item.CreateUploadSession;
using onedrive_backup.Telemetry;
using Azure.Core.Diagnostics;

namespace hassio_onedrive_backup.Graph
{
public class GraphHelper : IGraphHelper
{
private const string AuthRecordFile = "record.auth";
private const string GraphSpecialAppFolderUrl = "https://graph.microsoft.com/v1.0/me/drive/special/approot";
private const int UploadRetryCount = 3;
private const int DownloadRetryCount = 3;
private const int GraphRequestTimeoutMinutes = 2;
Expand All @@ -34,6 +36,7 @@ public class GraphHelper : IGraphHelper
private string _persistentDataPath;
private HttpClient _downloadHttpClient;
private bool? _isAuthenticated = null;
private bool _authRecordSavedInSession = false;

public event AuthStatusChanged? AuthStatusChangedEventHandler;

Expand All @@ -51,6 +54,8 @@ public GraphHelper(
_logger = logger;
_telemetryManager = telemetryManager;
_persistentDataPath = persistentDataPath;

//AzureEventSourceListener.CreateConsoleLogger();
}

public bool? IsAuthenticated
Expand All @@ -59,6 +64,12 @@ public bool? IsAuthenticated
private set
{
_isAuthenticated = value; AuthStatusChangedEventHandler?.Invoke();
if (_isAuthenticated != null && _isAuthenticated.Value && _authRecordSavedInSession == false)
{
var authRecord = GetAuthenticationRecordFromCredential(_deviceCodeCredential);
PersistAuthenticationRecordAsync(authRecord);
_authRecordSavedInSession = true;
}
}
}

Expand All @@ -68,40 +79,59 @@ private set

private string PersistentAuthRecordFullPath => Path.Combine(_persistentDataPath, AuthRecordFile);

public async Task<string> GetAndCacheUserTokenAsync()
public async Task GetAndCacheUserTokenAsync()
{
if (_deviceCodeCredential == null)
try
{
await InitializeGraphForUserAuthAsync();
}
if (_deviceCodeCredential == null)
{
await InitializeGraphForUserAuthAsync();
}

_ = _deviceCodeCredential ??
throw new NullReferenceException("User Auth not Initialized");
_ = _deviceCodeCredential ??
throw new NullReferenceException("User Auth not Initialized");

_ = _scopes ?? throw new ArgumentNullException("'scopes' cannot be null");

if (GetAuthenticationRecordFromCredential(_deviceCodeCredential) == null)
{
_logger.LogVerbose("Missing Auth Record in Device Credential");
var context = new TokenRequestContext(_scopes.ToArray());
// var response = await _deviceCodeCredential.GetTokenAsync(context);
//var authRecord = await _deviceCodeCredential.AuthenticateAsync(context);
// await PersistAuthenticationRecordAsync(authRecord);
}
else
{
_logger.LogVerbose("Token Cache exists. Skipping Auth");
}
}
catch (Exception)
{

_ = _scopes ?? throw new ArgumentNullException("'scopes' cannot be null");
throw;
}

var context = new TokenRequestContext(_scopes.ToArray());
var response = await _deviceCodeCredential.GetTokenAsync(context);
await PersistAuthenticationRecordAsync(GetAuthenticationRecordFromCredential());
IsAuthenticated = true;
return response.Token;
// IsAuthenticated = true;
// return response.Token;
}

public async Task<DriveItem?> GetItemInAppFolderAsync(string subPath = "/")
{
try
{
var driveItem = await _userClient.Me.Drive.GetAsync();
var appFolder = await _userClient.Drives[driveItem.Id].Special["approot"].GetAsync();
string driveId = await GetDriveIdFromAppFolder();
IsAuthenticated = true;
var appFolder = await _userClient.Drives[driveId].Special["approot"].GetAsync();
DriveItem? item;

if (subPath == "/")
{
item = await _userClient.Drives[driveItem.Id].Items[appFolder.Id].GetAsync(config => config.QueryParameters.Expand = new string[] { "children" });
item = await _userClient.Drives[driveId].Items[appFolder.Id].GetAsync(config => config.QueryParameters.Expand = new string[] { "children" });
}
else
{
item = await _userClient.Drives[driveItem.Id].Items[appFolder.Id].ItemWithPath(subPath).GetAsync(config => config.QueryParameters.Expand = new string[] { "children" });
item = await _userClient.Drives[driveId].Items[appFolder.Id].ItemWithPath(subPath).GetAsync(config => config.QueryParameters.Expand = new string[] { "children" });
}

return item;
Expand Down Expand Up @@ -131,9 +161,9 @@ public async Task<bool> DeleteItemFromAppFolderAsync(string itemPath)
try
{
_logger.LogInfo($"Deleting item: {itemPath}");
var driveItem = await _userClient.Me.Drive.GetAsync();
var appFolder = await _userClient.Drives[driveItem.Id].Special["approot"].GetAsync();
await _userClient.Drives[driveItem.Id].Items[appFolder.Id].ItemWithPath(itemPath).DeleteAsync();
string driveId = await GetDriveIdFromAppFolder();
var appFolder = await _userClient.Drives[driveId].Special["approot"].GetAsync();
await _userClient.Drives[driveId].Items[appFolder.Id].ItemWithPath(itemPath).DeleteAsync();
}
catch (Exception ex)
{
Expand All @@ -155,10 +185,28 @@ public async Task<bool> UploadFileAsync(string filePath, DateTime date, string?
using var fileStream = File.OpenRead(filePath);
destinationFileName = destinationFileName ?? (flatten ? Path.GetFileName(filePath) : filePath);
string sanitizedDestinationFileName = NormalizeDestinationFileName(destinationFileName);
var driveItem = await _userClient.Me.Drive.GetAsync();
var appFolder = await _userClient.Drives[driveItem.Id].Special["approot"].GetAsync();
string driveId = await GetDriveIdFromAppFolder();
var appFolder = await _userClient.Drives[driveId].Special["approot"].GetAsync();

var uploadSession = await _userClient.Drives[driveItem?.Id]
var uploadSessionRequest = _userClient.Drives[driveId]
.Items[appFolder.Id]
.ItemWithPath(sanitizedDestinationFileName)
.CreateUploadSession
.ToPostRequestInformation(new DriveUpload.CreateUploadSessionPostRequestBody()
{
Item = new DriveItemUploadableProperties
{
Description = description
}
});

using (var reader = new StreamReader(uploadSessionRequest.Content))
{
string requestBody = await reader.ReadToEndAsync();
_logger.LogVerbose($"UploadSession Request: {uploadSessionRequest.URI}. Body: {requestBody}");
}

var uploadSession = await _userClient.Drives[driveId]
.Items[appFolder.Id]
.ItemWithPath(sanitizedDestinationFileName)
.CreateUploadSession
Expand Down Expand Up @@ -236,40 +284,55 @@ private static string NormalizeDestinationFileName(string destinationFileName)
return sanitizedFileName;
}

public async Task<OneDriveFreeSpaceData> GetFreeSpaceInGB()
//public async Task<OneDriveFreeSpaceData> GetFreeSpaceInGB()
//{
// try
// {
// var drive = await _userClient.Me.Drive.GetAsync();
// IsAuthenticated = true;
// double? totalSpace = drive.Quota.Total == null ? null : drive.Quota.Total.Value / (double)Math.Pow(1024, 3);
// double? freeSpace = drive.Quota.Remaining == null ? null : drive.Quota.Remaining.Value / (double)Math.Pow(1024, 3);
// return new OneDriveFreeSpaceData
// {
// FreeSpace = freeSpace,
// TotalSpace = totalSpace
// };

// }
// catch (Exception ex)
// {
// _logger.LogError($"Error getting free space: {ex}", ex, _telemetryManager);
// return null;
// }
//}

public async Task<string> GetDriveIdFromAppFolder()
{
try
{
var drive = await _userClient.Me.Drive.GetAsync();
double? totalSpace = drive.Quota.Total == null ? null : drive.Quota.Total.Value / (double)Math.Pow(1024, 3);
double? freeSpace = drive.Quota.Remaining == null ? null : drive.Quota.Remaining.Value / (double)Math.Pow(1024, 3);
return new OneDriveFreeSpaceData
{
FreeSpace = freeSpace,
TotalSpace = totalSpace
};

var resp = await _userClient.Drives.WithUrl("https://graph.microsoft.com/v1.0/me/drive/special/approot").GetAsync();
return resp.AdditionalData["id"].ToString().Split("!").First();
}
catch (Exception ex)
{
_logger.LogError($"Error getting free space: {ex}", ex, _telemetryManager);
return null;
_logger.LogError("Failed getting Drive Id", ex);
throw;
}
}

public async Task<string?> DownloadFileAsync(string fileName, TransferSpeedHelper transferSpeedHelper, Action<int, int>? progressCallback)
{
var drive = await _userClient.Me.Drive.GetAsync();

var driveItem = await _userClient.Me.Drive.GetAsync();
var appFolder = await _userClient.Drives[driveItem.Id].Special["approot"].GetAsync();
var item = await _userClient.Drives[driveItem?.Id]
string driveId = await GetDriveIdFromAppFolder();
var appFolder = await _userClient.Drives[driveId].Special["approot"].GetAsync();
var item = await _userClient.Drives[driveId]
.Items[appFolder.Id]
.ItemWithPath(fileName)
.GetAsync();

transferSpeedHelper.Start();
var itemStream = await _userClient.Drives[driveItem?.Id]
var itemStream = await _userClient.Drives[driveId]
.Items[appFolder.Id]
.ItemWithPath(fileName)
.Content
Expand Down Expand Up @@ -330,30 +393,40 @@ protected virtual async Task InitializeGraphForUserAuthAsync()
{
ClientId = _clientId,
DeviceCodeCallback = DeviceCodeBallBackPrompt,
TenantId = "common",
TenantId = "consumers",
AuthenticationRecord = authRecord,
TokenCachePersistenceOptions = new TokenCachePersistenceOptions
{
Name = "hassio-onedrive-backup",
UnsafeAllowUnencryptedStorage = true
},
Name = "hassio-onedrive-auth",
UnsafeAllowUnencryptedStorage = true
},
};

_deviceCodeCredential = new DeviceCodeCredential(deviceCodeCredOptions);
_userClient = new GraphServiceClient(_deviceCodeCredential, _scopes);
}

private AuthenticationRecord GetAuthenticationRecordFromCredential()
private AuthenticationRecord GetAuthenticationRecordFromCredential(DeviceCodeCredential credential)
{
if (credential == null)
{
return null;
}

var record = typeof(DeviceCodeCredential)
.GetProperty("Record", System.Reflection.BindingFlags.NonPublic | BindingFlags.Instance)
.GetValue(_deviceCodeCredential) as AuthenticationRecord;
.GetValue(credential) as AuthenticationRecord;

return record;
}

private async Task PersistAuthenticationRecordAsync(AuthenticationRecord record)
{
if (record == null)
{
return;
}

using var authRecordStream = new FileStream(PersistentAuthRecordFullPath, FileMode.Create, FileAccess.Write);
await record.SerializeAsync(authRecordStream);
}
Expand All @@ -362,13 +435,21 @@ private async Task PersistAuthenticationRecordAsync(AuthenticationRecord record)
{
if (File.Exists(PersistentAuthRecordFullPath) == false)
{
_logger.LogWarning("Token cache is empty");
_logger.LogVerbose("Auth Record not found on disk");
return null;
}

using var authRecordStream = new FileStream(PersistentAuthRecordFullPath, FileMode.Open, FileAccess.Read);
var record = await AuthenticationRecord.DeserializeAsync(authRecordStream);
return record;
try
{
using var authRecordStream = new FileStream(PersistentAuthRecordFullPath, FileMode.Open, FileAccess.Read);
var record = await AuthenticationRecord.DeserializeAsync(authRecordStream);
return record;
}
catch (Exception ex)
{
_logger.LogError("Error reading Auth Record", ex);
return null;
}
}

private Task DeviceCodeBallBackPrompt(DeviceCodeInfo info, CancellationToken ct)
Expand Down
Loading

0 comments on commit 0ba8bf6

Please sign in to comment.