From f15e84e0c0bf839a31c609260efab829249443ac Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Mar 2026 19:24:27 +0000 Subject: [PATCH 1/4] Initial plan From ebec72e10a297274a6f10e0de651a19eccf3970f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Mar 2026 19:38:54 +0000 Subject: [PATCH 2/4] Add backup/restore feature with Google Drive integration - Add BackupData DTO model for serializing user account data - Add GoogleDriveOptions configuration model - Add IBackupService/BackupService for creating and restoring backups - Add IGoogleDriveService/GoogleDriveService for Google Drive OAuth - Add GoogleDriveCallbackController for OAuth callback - Add Backup.razor page at /backup with full UI - Add backup link to NavMenu - Register services in Program.cs - Add Google Drive config to appsettings.json - Add Google.Apis.Drive.v3 NuGet package - Add DownloadFile JS interop method Co-authored-by: Undermozes <58026189+Undermozes@users.noreply.github.com> --- Server/API/GoogleDriveCallbackController.cs | 46 +++ Server/Components/Layout/NavMenu.razor | 5 + Server/Components/Pages/Backup.razor | 404 ++++++++++++++++++++ Server/Models/GoogleDriveOptions.cs | 8 + Server/Program.cs | 4 + Server/Server.csproj | 1 + Server/Services/BackupService.cs | 135 +++++++ Server/Services/GoogleDriveService.cs | 224 +++++++++++ Server/Services/JsInterop.cs | 7 + Server/appsettings.json | 4 + Server/wwwroot/interop.js | 12 + Shared/Dtos/BackupData.cs | 43 +++ 12 files changed, 893 insertions(+) create mode 100644 Server/API/GoogleDriveCallbackController.cs create mode 100644 Server/Components/Pages/Backup.razor create mode 100644 Server/Models/GoogleDriveOptions.cs create mode 100644 Server/Services/BackupService.cs create mode 100644 Server/Services/GoogleDriveService.cs create mode 100644 Shared/Dtos/BackupData.cs diff --git a/Server/API/GoogleDriveCallbackController.cs b/Server/API/GoogleDriveCallbackController.cs new file mode 100644 index 000000000..389fcf3a8 --- /dev/null +++ b/Server/API/GoogleDriveCallbackController.cs @@ -0,0 +1,46 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Remotely.Server.Services; +using Remotely.Shared.Entities; +using System.Threading.Tasks; + +namespace Remotely.Server.API; + +[Route("api/[controller]")] +[ApiController] +[Authorize] +public class GoogleDriveCallbackController : ControllerBase +{ + private readonly IGoogleDriveService _googleDriveService; + private readonly UserManager _userManager; + + public GoogleDriveCallbackController( + IGoogleDriveService googleDriveService, + UserManager userManager) + { + _googleDriveService = googleDriveService; + _userManager = userManager; + } + + [HttpGet] + public async Task Callback([FromQuery] string code, [FromQuery] string state) + { + if (string.IsNullOrEmpty(code)) + { + return BadRequest("Authorization code is missing."); + } + + var user = await _userManager.GetUserAsync(User); + if (user is null) + { + return Unauthorized(); + } + + var redirectUri = $"{Request.Scheme}://{Request.Host}/api/googledrivecallback"; + + await _googleDriveService.ExchangeCodeForTokenAsync(user.Id, code, redirectUri); + + return Redirect("/backup"); + } +} diff --git a/Server/Components/Layout/NavMenu.razor b/Server/Components/Layout/NavMenu.razor index 10c1e3286..c53767631 100644 --- a/Server/Components/Layout/NavMenu.razor +++ b/Server/Components/Layout/NavMenu.razor @@ -68,6 +68,11 @@ User Options + diff --git a/Server/Components/Pages/Backup.razor b/Server/Components/Pages/Backup.razor new file mode 100644 index 000000000..2b3192b69 --- /dev/null +++ b/Server/Components/Pages/Backup.razor @@ -0,0 +1,404 @@ +@page "/backup" +@attribute [Authorize] +@inherits AuthComponentBase + +@inject IDataService DataService +@inject IBackupService BackupService +@inject IGoogleDriveService GoogleDriveService +@inject IToastService ToastService +@inject IJsInterop JsInterop +@inject NavigationManager NavigationManager + +

Backup & Restore

+ +@if (!string.IsNullOrWhiteSpace(_alertMessage)) +{ + +} + +
+
+ @* ───────── Create Backup Section ───────── *@ +
+
+
+ Create Backup +
+
+
+

+ Create a backup of your account data including your devices and device groups. + The backup will be downloaded as a JSON file. +

+ +
+
+ + @* ───────── Restore Backup Section ───────── *@ +
+
+
+ Restore Backup +
+
+
+

+ Upload a previously created backup file to restore device metadata + (aliases, tags, notes, and device group assignments). +

+
+ +
+ @if (_restoreFileContent is not null) + { +

+ File loaded: @_restoreFileName +

+ + } +
+
+ + @* ───────── Google Drive Section ───────── *@ +
+
+
+ Google Drive +
+
+
+ @if (!GoogleDriveService.IsConfigured) + { +
+ Google Drive is not configured. +

+ Ask your server administrator to configure Google Drive OAuth credentials + (Client ID and Client Secret) in the server settings. +

+
+ } + else if (!_isGoogleDriveConnected) + { +

+ Connect your Google Drive account to store and retrieve backups in the cloud. +

+ + } + else + { +
+ + Connected to Google Drive + + +
+ +
+ + +
+ + @if (_driveBackups.Count > 0) + { +
Backups on Google Drive
+ + + + + + + + + + + @foreach (var file in _driveBackups) + { + + + + + + + } + +
NameCreatedSizeActions
@file.Name@file.CreatedTime?.LocalDateTime.ToString("g")@FormatFileSize(file.Size) + +
+ } + else if (_driveBackupsLoaded) + { +

No backups found on Google Drive.

+ } + } +
+
+
+
+ +@code { + private string? _alertMessage; + private bool _isProcessing; + private bool _isGoogleDriveConnected; + private bool _driveBackupsLoaded; + private string? _restoreFileContent; + private string? _restoreFileName; + private List _driveBackups = new(); + + protected override async Task OnInitializedAsync() + { + await base.OnInitializedAsync(); + + if (User is not null) + { + _isGoogleDriveConnected = GoogleDriveService.IsConnected(User.Id); + + if (_isGoogleDriveConnected) + { + await RefreshDriveBackups(); + } + } + } + + private async Task CreateAndDownloadBackup() + { + EnsureUserSet(); + _isProcessing = true; + _alertMessage = null; + + try + { + var devices = DataService.GetDevicesForUser(UserName); + var deviceGroups = DataService.GetDeviceGroups(UserName); + + var backup = BackupService.CreateBackup(User, devices, deviceGroups); + var json = BackupService.SerializeBackup(backup); + + var fileName = $"remotely-backup-{DateTime.Now:yyyyMMdd-HHmmss}.json"; + + await JsInterop.DownloadFile(fileName, json); + + _alertMessage = "Backup created and downloaded successfully."; + ToastService.ShowToast("Backup downloaded."); + } + catch (Exception ex) + { + _alertMessage = $"Error creating backup: {ex.Message}"; + } + finally + { + _isProcessing = false; + } + } + + private async Task OnRestoreFileSelected(InputFileChangeEventArgs e) + { + try + { + var file = e.File; + _restoreFileName = file.Name; + + using var reader = new StreamReader(file.OpenReadStream(maxAllowedSize: 10 * 1024 * 1024)); + _restoreFileContent = await reader.ReadToEndAsync(); + } + catch (Exception ex) + { + _alertMessage = $"Error reading file: {ex.Message}"; + _restoreFileContent = null; + } + } + + private Task RestoreFromFile() + { + EnsureUserSet(); + _isProcessing = true; + _alertMessage = null; + + try + { + if (string.IsNullOrEmpty(_restoreFileContent)) + { + _alertMessage = "No backup file loaded."; + return Task.CompletedTask; + } + + var backup = BackupService.DeserializeBackup(_restoreFileContent); + if (backup is null) + { + _alertMessage = "Invalid backup file format."; + return Task.CompletedTask; + } + + var existingDevices = DataService.GetDevicesForUser(UserName); + BackupService.RestoreBackup(backup, existingDevices, DataService); + + _alertMessage = "Backup restored successfully."; + _restoreFileContent = null; + _restoreFileName = null; + + ToastService.ShowToast("Backup restored."); + } + catch (Exception ex) + { + _alertMessage = $"Error restoring backup: {ex.Message}"; + } + finally + { + _isProcessing = false; + } + + return Task.CompletedTask; + } + + private void ConnectGoogleDrive() + { + EnsureUserSet(); + + var redirectUri = $"{NavigationManager.BaseUri.TrimEnd('/')}/api/googledrivecallback"; + var authUrl = GoogleDriveService.GetAuthorizationUrl(User.Id, redirectUri); + NavigationManager.NavigateTo(authUrl, forceLoad: true); + } + + private void DisconnectGoogleDrive() + { + EnsureUserSet(); + GoogleDriveService.Disconnect(User.Id); + _isGoogleDriveConnected = false; + _driveBackups.Clear(); + _driveBackupsLoaded = false; + ToastService.ShowToast("Disconnected from Google Drive."); + } + + private async Task UploadToGoogleDrive() + { + EnsureUserSet(); + _isProcessing = true; + _alertMessage = null; + + try + { + var devices = DataService.GetDevicesForUser(UserName); + var deviceGroups = DataService.GetDeviceGroups(UserName); + + var backup = BackupService.CreateBackup(User, devices, deviceGroups); + var json = BackupService.SerializeBackup(backup); + + var fileName = $"remotely-backup-{DateTime.Now:yyyyMMdd-HHmmss}.json"; + + using var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(json)); + await GoogleDriveService.UploadBackupAsync(User.Id, fileName, stream); + + await RefreshDriveBackups(); + + _alertMessage = "Backup uploaded to Google Drive successfully."; + ToastService.ShowToast("Backup uploaded to Google Drive."); + } + catch (Exception ex) + { + _alertMessage = $"Error uploading to Google Drive: {ex.Message}"; + } + finally + { + _isProcessing = false; + } + } + + private async Task RefreshDriveBackups() + { + EnsureUserSet(); + + try + { + _driveBackups = await GoogleDriveService.ListBackupsAsync(User.Id); + _driveBackupsLoaded = true; + } + catch (Exception ex) + { + _alertMessage = $"Error listing Google Drive backups: {ex.Message}"; + _driveBackupsLoaded = true; + } + } + + private async Task RestoreFromGoogleDrive(string fileId) + { + EnsureUserSet(); + _isProcessing = true; + _alertMessage = null; + + try + { + using var stream = await GoogleDriveService.DownloadBackupAsync(User.Id, fileId); + using var reader = new StreamReader(stream); + var json = await reader.ReadToEndAsync(); + + var backup = BackupService.DeserializeBackup(json); + if (backup is null) + { + _alertMessage = "Invalid backup file on Google Drive."; + return; + } + + var existingDevices = DataService.GetDevicesForUser(UserName); + BackupService.RestoreBackup(backup, existingDevices, DataService); + + _alertMessage = "Backup restored from Google Drive successfully."; + ToastService.ShowToast("Backup restored from Google Drive."); + } + catch (Exception ex) + { + _alertMessage = $"Error restoring from Google Drive: {ex.Message}"; + } + finally + { + _isProcessing = false; + } + } + + private static string FormatFileSize(long? bytes) + { + if (bytes is null) + { + return "Unknown"; + } + + if (bytes.Value < 1024) + { + return $"{bytes} B"; + } + else if (bytes.Value < 1024 * 1024) + { + return $"{bytes / 1024.0:F1} KB"; + } + else + { + return $"{bytes / (1024.0 * 1024.0):F1} MB"; + } + } +} diff --git a/Server/Models/GoogleDriveOptions.cs b/Server/Models/GoogleDriveOptions.cs new file mode 100644 index 000000000..005827a29 --- /dev/null +++ b/Server/Models/GoogleDriveOptions.cs @@ -0,0 +1,8 @@ +namespace Remotely.Server.Models; + +public class GoogleDriveOptions +{ + public const string SectionName = "GoogleDrive"; + public string? ClientId { get; set; } + public string? ClientSecret { get; set; } +} diff --git a/Server/Program.cs b/Server/Program.cs index 6f7bdc17d..2d0f6d991 100644 --- a/Server/Program.cs +++ b/Server/Program.cs @@ -257,6 +257,10 @@ services.AddSingleton(); services.AddHostedService(); services.AddHostedService(); +services.AddTransient(); +services.Configure( + configuration.GetSection(GoogleDriveOptions.SectionName)); +services.AddSingleton(); builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); diff --git a/Server/Server.csproj b/Server/Server.csproj index 9d748d11f..021d6262b 100644 --- a/Server/Server.csproj +++ b/Server/Server.csproj @@ -16,6 +16,7 @@ + diff --git a/Server/Services/BackupService.cs b/Server/Services/BackupService.cs new file mode 100644 index 000000000..2eeb5961e --- /dev/null +++ b/Server/Services/BackupService.cs @@ -0,0 +1,135 @@ +using Microsoft.Extensions.Logging; +using Remotely.Shared.Dtos; +using Remotely.Shared.Entities; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; + +namespace Remotely.Server.Services; + +public interface IBackupService +{ + BackupData CreateBackup(RemotelyUser user, Device[] devices, DeviceGroup[] deviceGroups); + string SerializeBackup(BackupData backup); + BackupData? DeserializeBackup(string json); + void RestoreBackup(BackupData backup, Device[] existingDevices, IDataService dataService); +} + +public class BackupService : IBackupService +{ + private readonly ILogger _logger; + + private static readonly JsonSerializerOptions _jsonOptions = new() + { + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + }; + + public BackupService(ILogger logger) + { + _logger = logger; + } + + public BackupData CreateBackup(RemotelyUser user, Device[] devices, DeviceGroup[] deviceGroups) + { + var backup = new BackupData + { + BackupVersion = "1.0", + CreatedAt = DateTimeOffset.Now, + UserName = user.UserName, + Email = user.Email, + DisplayName = user.UserOptions?.DisplayName, + UserOptions = user.UserOptions, + Devices = devices.Select(d => new DeviceBackupDto + { + ID = d.ID, + DeviceName = d.DeviceName, + Alias = d.Alias, + Tags = d.Tags, + Notes = d.Notes, + DeviceGroupID = d.DeviceGroupID, + Platform = d.Platform, + OSDescription = d.OSDescription, + CurrentUser = d.CurrentUser, + Is64Bit = d.Is64Bit, + ProcessorCount = d.ProcessorCount, + TotalMemory = d.TotalMemory, + TotalStorage = d.TotalStorage, + PublicIP = d.PublicIP, + AgentVersion = d.AgentVersion, + LastOnline = d.LastOnline + }).ToList(), + DeviceGroups = deviceGroups.Select(g => new DeviceGroupBackupDto + { + ID = g.ID, + Name = g.Name, + }).ToList() + }; + + _logger.LogInformation( + "Created backup for user {UserName} with {DeviceCount} devices and {GroupCount} device groups.", + user.UserName, + devices.Length, + deviceGroups.Length); + + return backup; + } + + public string SerializeBackup(BackupData backup) + { + return JsonSerializer.Serialize(backup, _jsonOptions); + } + + public BackupData? DeserializeBackup(string json) + { + try + { + return JsonSerializer.Deserialize(json, _jsonOptions); + } + catch (JsonException ex) + { + _logger.LogError(ex, "Failed to deserialize backup data."); + return null; + } + } + + public void RestoreBackup(BackupData backup, Device[] existingDevices, IDataService dataService) + { + var existingDeviceIds = new HashSet( + existingDevices.Select(d => d.ID), + StringComparer.OrdinalIgnoreCase); + + var restoredCount = 0; + + foreach (var deviceDto in backup.Devices) + { + if (string.IsNullOrEmpty(deviceDto.ID)) + { + continue; + } + + if (!existingDeviceIds.Contains(deviceDto.ID)) + { + _logger.LogInformation( + "Skipping device {DeviceId} during restore because it no longer exists in the organization.", + deviceDto.ID); + continue; + } + + dataService.UpdateDevice( + deviceDto.ID, + deviceDto.Tags, + deviceDto.Alias, + deviceDto.DeviceGroupID, + deviceDto.Notes).Wait(); + + restoredCount++; + } + + _logger.LogInformation( + "Restored {RestoredCount} devices from backup for user {UserName}.", + restoredCount, + backup.UserName); + } +} diff --git a/Server/Services/GoogleDriveService.cs b/Server/Services/GoogleDriveService.cs new file mode 100644 index 000000000..48ede1dde --- /dev/null +++ b/Server/Services/GoogleDriveService.cs @@ -0,0 +1,224 @@ +using Google.Apis.Auth.OAuth2; +using Google.Apis.Auth.OAuth2.Flows; +using Google.Apis.Auth.OAuth2.Responses; +using Google.Apis.Drive.v3; +using Google.Apis.Services; +using Google.Apis.Upload; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Remotely.Server.Models; +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace Remotely.Server.Services; + +public class GoogleDriveFileInfo +{ + public string Id { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; + public DateTimeOffset? CreatedTime { get; set; } + public long? Size { get; set; } +} + +public interface IGoogleDriveService +{ + bool IsConfigured { get; } + string GetAuthorizationUrl(string userId, string redirectUri); + Task ExchangeCodeForTokenAsync(string userId, string code, string redirectUri); + bool IsConnected(string userId); + void Disconnect(string userId); + Task UploadBackupAsync(string userId, string fileName, Stream content); + Task> ListBackupsAsync(string userId); + Task DownloadBackupAsync(string userId, string fileId); +} + +public class GoogleDriveService : IGoogleDriveService +{ + private const string AppFolderName = "Remotely Backups"; + private const string BackupMimeType = "application/json"; + private static readonly string[] Scopes = { DriveService.Scope.DriveFile }; + + private readonly ConcurrentDictionary _userTokens = new(); + private readonly GoogleDriveOptions _options; + private readonly ILogger _logger; + + public GoogleDriveService( + IOptions options, + ILogger logger) + { + _options = options.Value; + _logger = logger; + } + + public bool IsConfigured => + !string.IsNullOrWhiteSpace(_options.ClientId) && + !string.IsNullOrWhiteSpace(_options.ClientSecret); + + public string GetAuthorizationUrl(string userId, string redirectUri) + { + if (!IsConfigured) + { + throw new InvalidOperationException("Google Drive is not configured."); + } + + var flow = CreateFlow(); + var uri = flow.CreateAuthorizationCodeRequest(redirectUri); + uri.State = userId; + return uri.Build().AbsoluteUri; + } + + public async Task ExchangeCodeForTokenAsync(string userId, string code, string redirectUri) + { + if (!IsConfigured) + { + throw new InvalidOperationException("Google Drive is not configured."); + } + + var flow = CreateFlow(); + var token = await flow.ExchangeCodeForTokenAsync( + userId, + code, + redirectUri, + CancellationToken.None); + + _userTokens[userId] = token; + + _logger.LogInformation("Google Drive OAuth token obtained for user {UserId}.", userId); + } + + public bool IsConnected(string userId) + { + return _userTokens.ContainsKey(userId); + } + + public void Disconnect(string userId) + { + _userTokens.TryRemove(userId, out _); + _logger.LogInformation("Google Drive disconnected for user {UserId}.", userId); + } + + public async Task UploadBackupAsync(string userId, string fileName, Stream content) + { + var service = CreateDriveService(userId); + var folderId = await GetOrCreateBackupFolderAsync(service); + + var fileMetadata = new Google.Apis.Drive.v3.Data.File + { + Name = fileName, + Parents = new List { folderId }, + MimeType = BackupMimeType + }; + + var request = service.Files.Create(fileMetadata, content, BackupMimeType); + request.Fields = "id, name"; + + var result = await request.UploadAsync(); + + if (result.Status != UploadStatus.Completed) + { + throw new InvalidOperationException( + $"Failed to upload backup to Google Drive: {result.Exception?.Message}"); + } + + _logger.LogInformation( + "Uploaded backup '{FileName}' to Google Drive for user {UserId}.", + fileName, + userId); + + return request.ResponseBody.Id; + } + + public async Task> ListBackupsAsync(string userId) + { + var service = CreateDriveService(userId); + var folderId = await GetOrCreateBackupFolderAsync(service); + + var listRequest = service.Files.List(); + listRequest.Q = $"'{folderId}' in parents and trashed = false"; + listRequest.Fields = "files(id, name, createdTime, size)"; + listRequest.OrderBy = "createdTime desc"; + + var result = await listRequest.ExecuteAsync(); + + return result.Files.Select(f => new GoogleDriveFileInfo + { + Id = f.Id, + Name = f.Name, + CreatedTime = f.CreatedTimeDateTimeOffset, + Size = f.Size + }).ToList(); + } + + public async Task DownloadBackupAsync(string userId, string fileId) + { + var service = CreateDriveService(userId); + var request = service.Files.Get(fileId); + var stream = new MemoryStream(); + await request.DownloadAsync(stream); + stream.Position = 0; + return stream; + } + + private GoogleAuthorizationCodeFlow CreateFlow() + { + return new GoogleAuthorizationCodeFlow(new GoogleAuthorizationCodeFlow.Initializer + { + ClientSecrets = new ClientSecrets + { + ClientId = _options.ClientId, + ClientSecret = _options.ClientSecret + }, + Scopes = Scopes + }); + } + + private DriveService CreateDriveService(string userId) + { + if (!_userTokens.TryGetValue(userId, out var token)) + { + throw new InvalidOperationException("User is not connected to Google Drive."); + } + + var flow = CreateFlow(); + var credential = new UserCredential(flow, userId, token); + + return new DriveService(new BaseClientService.Initializer + { + HttpClientInitializer = credential, + ApplicationName = "Remotely Backup" + }); + } + + private async Task GetOrCreateBackupFolderAsync(DriveService service) + { + // Search for existing folder + var listRequest = service.Files.List(); + listRequest.Q = $"name = '{AppFolderName}' and mimeType = 'application/vnd.google-apps.folder' and trashed = false"; + listRequest.Fields = "files(id)"; + + var result = await listRequest.ExecuteAsync(); + + if (result.Files.Count > 0) + { + return result.Files[0].Id; + } + + // Create folder + var folderMetadata = new Google.Apis.Drive.v3.Data.File + { + Name = AppFolderName, + MimeType = "application/vnd.google-apps.folder" + }; + + var createRequest = service.Files.Create(folderMetadata); + createRequest.Fields = "id"; + + var folder = await createRequest.ExecuteAsync(); + return folder.Id; + } +} diff --git a/Server/Services/JsInterop.cs b/Server/Services/JsInterop.cs index 3c94149f8..33bd7cbfa 100644 --- a/Server/Services/JsInterop.cs +++ b/Server/Services/JsInterop.cs @@ -31,6 +31,8 @@ public interface IJsInterop void SetStyleProperty(ElementReference element, string propertyName, string value); void StartDraggingY(ElementReference element, double clientY); + + ValueTask DownloadFile(string fileName, string content); } public class JsInterop : IJsInterop @@ -115,4 +117,9 @@ public void StartDraggingY(ElementReference element, double clientY) { _jsRuntime.InvokeVoidAsync("startDraggingY", element, clientY); } + + public ValueTask DownloadFile(string fileName, string content) + { + return _jsRuntime.InvokeVoidAsync("downloadFileFromText", fileName, content); + } } diff --git a/Server/appsettings.json b/Server/appsettings.json index 05c3d090b..593448859 100644 --- a/Server/appsettings.json +++ b/Server/appsettings.json @@ -23,5 +23,9 @@ "ApplicationOptions": { "DbProvider": "SQLite", "DockerGatewayIp": "172.28.0.1" + }, + "GoogleDrive": { + "ClientId": "", + "ClientSecret": "" } } diff --git a/Server/wwwroot/interop.js b/Server/wwwroot/interop.js index 626ea2131..2a8f0ec44 100644 --- a/Server/wwwroot/interop.js +++ b/Server/wwwroot/interop.js @@ -111,6 +111,18 @@ window.addEventListener("load", () => { autoHeight(); }) +window.downloadFileFromText = (fileName, content) => { + var blob = new Blob([content], { type: "application/json" }); + var url = URL.createObjectURL(blob); + var a = document.createElement("a"); + a.href = url; + a.download = fileName; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); +} + window.addBeforeUnloadHandler = () => { window.addEventListener("beforeunload", (ev) => { if (location.href.indexOf("localhost") == -1) { diff --git a/Shared/Dtos/BackupData.cs b/Shared/Dtos/BackupData.cs new file mode 100644 index 000000000..d2c168749 --- /dev/null +++ b/Shared/Dtos/BackupData.cs @@ -0,0 +1,43 @@ +using Remotely.Shared.Models; +using System; +using System.Collections.Generic; + +namespace Remotely.Shared.Dtos; + +public class BackupData +{ + public string BackupVersion { get; set; } = "1.0"; + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.Now; + public string? UserName { get; set; } + public string? Email { get; set; } + public string? DisplayName { get; set; } + public RemotelyUserOptions? UserOptions { get; set; } + public List Devices { get; set; } = new(); + public List DeviceGroups { get; set; } = new(); +} + +public class DeviceBackupDto +{ + public string? ID { get; set; } + public string? DeviceName { get; set; } + public string? Alias { get; set; } + public string? Tags { get; set; } + public string? Notes { get; set; } + public string? DeviceGroupID { get; set; } + public string? Platform { get; set; } + public string? OSDescription { get; set; } + public string? CurrentUser { get; set; } + public bool Is64Bit { get; set; } + public int ProcessorCount { get; set; } + public double TotalMemory { get; set; } + public double TotalStorage { get; set; } + public string? PublicIP { get; set; } + public string? AgentVersion { get; set; } + public DateTimeOffset LastOnline { get; set; } +} + +public class DeviceGroupBackupDto +{ + public string? ID { get; set; } + public string? Name { get; set; } +} From e1b38d2c4f5001a98cc90617410f5a29126ae906 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Mar 2026 19:50:44 +0000 Subject: [PATCH 3/4] Address code review: fix async deadlock, timestamp consistency, add tests - Make RestoreBackup async (RestoreBackupAsync) to prevent deadlocks - Use DateTimeOffset.Now instead of DateTime.Now for consistency - Add TODO comment about in-memory token storage - Add 9 unit tests for BackupService - Register logging in test IoCActivator Co-authored-by: Undermozes <58026189+Undermozes@users.noreply.github.com> --- Server/Components/Pages/Backup.razor | 16 +- Server/Services/BackupService.cs | 8 +- Server/Services/GoogleDriveService.cs | 2 + Tests/Server.Tests/BackupServiceTests.cs | 177 +++++++++++++++++++++++ Tests/Server.Tests/IoCActivator.cs | 1 + 5 files changed, 191 insertions(+), 13 deletions(-) create mode 100644 Tests/Server.Tests/BackupServiceTests.cs diff --git a/Server/Components/Pages/Backup.razor b/Server/Components/Pages/Backup.razor index 2b3192b69..3328e83af 100644 --- a/Server/Components/Pages/Backup.razor +++ b/Server/Components/Pages/Backup.razor @@ -201,7 +201,7 @@ var backup = BackupService.CreateBackup(User, devices, deviceGroups); var json = BackupService.SerializeBackup(backup); - var fileName = $"remotely-backup-{DateTime.Now:yyyyMMdd-HHmmss}.json"; + var fileName = $"remotely-backup-{DateTimeOffset.Now:yyyyMMdd-HHmmss}.json"; await JsInterop.DownloadFile(fileName, json); @@ -235,7 +235,7 @@ } } - private Task RestoreFromFile() + private async Task RestoreFromFile() { EnsureUserSet(); _isProcessing = true; @@ -246,18 +246,18 @@ if (string.IsNullOrEmpty(_restoreFileContent)) { _alertMessage = "No backup file loaded."; - return Task.CompletedTask; + return; } var backup = BackupService.DeserializeBackup(_restoreFileContent); if (backup is null) { _alertMessage = "Invalid backup file format."; - return Task.CompletedTask; + return; } var existingDevices = DataService.GetDevicesForUser(UserName); - BackupService.RestoreBackup(backup, existingDevices, DataService); + await BackupService.RestoreBackupAsync(backup, existingDevices, DataService); _alertMessage = "Backup restored successfully."; _restoreFileContent = null; @@ -273,8 +273,6 @@ { _isProcessing = false; } - - return Task.CompletedTask; } private void ConnectGoogleDrive() @@ -310,7 +308,7 @@ var backup = BackupService.CreateBackup(User, devices, deviceGroups); var json = BackupService.SerializeBackup(backup); - var fileName = $"remotely-backup-{DateTime.Now:yyyyMMdd-HHmmss}.json"; + var fileName = $"remotely-backup-{DateTimeOffset.Now:yyyyMMdd-HHmmss}.json"; using var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(json)); await GoogleDriveService.UploadBackupAsync(User.Id, fileName, stream); @@ -366,7 +364,7 @@ } var existingDevices = DataService.GetDevicesForUser(UserName); - BackupService.RestoreBackup(backup, existingDevices, DataService); + await BackupService.RestoreBackupAsync(backup, existingDevices, DataService); _alertMessage = "Backup restored from Google Drive successfully."; ToastService.ShowToast("Backup restored from Google Drive."); diff --git a/Server/Services/BackupService.cs b/Server/Services/BackupService.cs index 2eeb5961e..4e6cb43a5 100644 --- a/Server/Services/BackupService.cs +++ b/Server/Services/BackupService.cs @@ -13,7 +13,7 @@ public interface IBackupService BackupData CreateBackup(RemotelyUser user, Device[] devices, DeviceGroup[] deviceGroups); string SerializeBackup(BackupData backup); BackupData? DeserializeBackup(string json); - void RestoreBackup(BackupData backup, Device[] existingDevices, IDataService dataService); + Task RestoreBackupAsync(BackupData backup, Device[] existingDevices, IDataService dataService); } public class BackupService : IBackupService @@ -94,7 +94,7 @@ public string SerializeBackup(BackupData backup) } } - public void RestoreBackup(BackupData backup, Device[] existingDevices, IDataService dataService) + public async Task RestoreBackupAsync(BackupData backup, Device[] existingDevices, IDataService dataService) { var existingDeviceIds = new HashSet( existingDevices.Select(d => d.ID), @@ -117,12 +117,12 @@ public void RestoreBackup(BackupData backup, Device[] existingDevices, IDataServ continue; } - dataService.UpdateDevice( + await dataService.UpdateDevice( deviceDto.ID, deviceDto.Tags, deviceDto.Alias, deviceDto.DeviceGroupID, - deviceDto.Notes).Wait(); + deviceDto.Notes); restoredCount++; } diff --git a/Server/Services/GoogleDriveService.cs b/Server/Services/GoogleDriveService.cs index 48ede1dde..69db80d0d 100644 --- a/Server/Services/GoogleDriveService.cs +++ b/Server/Services/GoogleDriveService.cs @@ -43,6 +43,8 @@ public class GoogleDriveService : IGoogleDriveService private const string BackupMimeType = "application/json"; private static readonly string[] Scopes = { DriveService.Scope.DriveFile }; + // TODO: Tokens are stored in memory and will be lost on application restart. + // For production use, consider persisting tokens securely in the database. private readonly ConcurrentDictionary _userTokens = new(); private readonly GoogleDriveOptions _options; private readonly ILogger _logger; diff --git a/Tests/Server.Tests/BackupServiceTests.cs b/Tests/Server.Tests/BackupServiceTests.cs new file mode 100644 index 000000000..c5ab8d43b --- /dev/null +++ b/Tests/Server.Tests/BackupServiceTests.cs @@ -0,0 +1,177 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Remotely.Server.Services; +using Remotely.Shared.Dtos; +using Remotely.Shared.Entities; +using Remotely.Shared.Models; +using System; +using System.Linq; +using System.Threading.Tasks; + +namespace Remotely.Server.Tests; + +[TestClass] +public class BackupServiceTests +{ + private IDataService _dataService = null!; + private IBackupService _backupService = null!; + private TestData _testData = null!; + + [TestInitialize] + public async Task TestInit() + { + _testData = new TestData(); + await _testData.Init(); + _dataService = IoCActivator.ServiceProvider.GetRequiredService(); + _backupService = new BackupService( + IoCActivator.ServiceProvider.GetRequiredService>()); + } + + [TestCleanup] + public void TestCleanup() + { + _testData.ClearData(); + } + + [TestMethod] + public void CreateBackup_ContainsUserInfo() + { + var user = _testData.Org1Admin1; + var devices = _dataService.GetDevicesForUser(user.UserName!); + var deviceGroups = _dataService.GetDeviceGroups(user.UserName!); + + var backup = _backupService.CreateBackup(user, devices, deviceGroups); + + Assert.IsNotNull(backup); + Assert.AreEqual("1.0", backup.BackupVersion); + Assert.AreEqual(user.UserName, backup.UserName); + Assert.AreEqual(user.Email, backup.Email); + Assert.IsTrue(backup.CreatedAt <= DateTimeOffset.Now); + } + + [TestMethod] + public void CreateBackup_ContainsDevices() + { + var user = _testData.Org1Admin1; + var devices = _dataService.GetDevicesForUser(user.UserName!); + var deviceGroups = _dataService.GetDeviceGroups(user.UserName!); + + var backup = _backupService.CreateBackup(user, devices, deviceGroups); + + Assert.AreEqual(2, backup.Devices.Count); + Assert.IsTrue(backup.Devices.Any(d => d.ID == _testData.Org1Device1.ID)); + Assert.IsTrue(backup.Devices.Any(d => d.ID == _testData.Org1Device2.ID)); + } + + [TestMethod] + public void CreateBackup_ContainsDeviceGroups() + { + var user = _testData.Org1Admin1; + var devices = _dataService.GetDevicesForUser(user.UserName!); + var deviceGroups = _dataService.GetDeviceGroups(user.UserName!); + + var backup = _backupService.CreateBackup(user, devices, deviceGroups); + + Assert.AreEqual(2, backup.DeviceGroups.Count); + Assert.IsTrue(backup.DeviceGroups.Any(g => g.Name == "Org1Group1")); + Assert.IsTrue(backup.DeviceGroups.Any(g => g.Name == "Org1Group2")); + } + + [TestMethod] + public void CreateBackup_DoesNotIncludeOtherOrgDevices() + { + var user = _testData.Org1Admin1; + var devices = _dataService.GetDevicesForUser(user.UserName!); + var deviceGroups = _dataService.GetDeviceGroups(user.UserName!); + + var backup = _backupService.CreateBackup(user, devices, deviceGroups); + + Assert.IsFalse(backup.Devices.Any(d => d.ID == _testData.Org2Device1.ID)); + Assert.IsFalse(backup.Devices.Any(d => d.ID == _testData.Org2Device2.ID)); + } + + [TestMethod] + public void SerializeAndDeserialize_RoundTrip() + { + var user = _testData.Org1Admin1; + var devices = _dataService.GetDevicesForUser(user.UserName!); + var deviceGroups = _dataService.GetDeviceGroups(user.UserName!); + + var backup = _backupService.CreateBackup(user, devices, deviceGroups); + var json = _backupService.SerializeBackup(backup); + var restored = _backupService.DeserializeBackup(json); + + Assert.IsNotNull(restored); + Assert.AreEqual(backup.BackupVersion, restored.BackupVersion); + Assert.AreEqual(backup.UserName, restored.UserName); + Assert.AreEqual(backup.Devices.Count, restored.Devices.Count); + Assert.AreEqual(backup.DeviceGroups.Count, restored.DeviceGroups.Count); + } + + [TestMethod] + public void DeserializeBackup_InvalidJson_ReturnsNull() + { + var result = _backupService.DeserializeBackup("not valid json"); + Assert.IsNull(result); + } + + [TestMethod] + public void DeserializeBackup_EmptyString_ReturnsNull() + { + var result = _backupService.DeserializeBackup(""); + Assert.IsNull(result); + } + + [TestMethod] + public async Task RestoreBackup_UpdatesDeviceMetadata() + { + var user = _testData.Org1Admin1; + var devices = _dataService.GetDevicesForUser(user.UserName!); + var deviceGroups = _dataService.GetDeviceGroups(user.UserName!); + + // Create backup + var backup = _backupService.CreateBackup(user, devices, deviceGroups); + + // Modify backup data + var deviceBackup = backup.Devices.First(d => d.ID == _testData.Org1Device1.ID); + deviceBackup.Alias = "TestAlias"; + deviceBackup.Tags = "test-tag"; + deviceBackup.Notes = "Test notes"; + + // Restore + var existingDevices = _dataService.GetDevicesForUser(user.UserName!); + await _backupService.RestoreBackupAsync(backup, existingDevices, _dataService); + + // Verify device was updated + var updatedDevice = (await _dataService.GetDevice(_testData.Org1Device1.ID)).Value; + Assert.IsNotNull(updatedDevice); + Assert.AreEqual("TestAlias", updatedDevice.Alias); + Assert.AreEqual("test-tag", updatedDevice.Tags); + Assert.AreEqual("Test notes", updatedDevice.Notes); + } + + [TestMethod] + public async Task RestoreBackup_SkipsNonExistentDevices() + { + var user = _testData.Org1Admin1; + + var backup = new BackupData + { + UserName = user.UserName, + Devices = new() + { + new DeviceBackupDto + { + ID = "NonExistentDevice", + Alias = "Should not fail" + } + } + }; + + var existingDevices = _dataService.GetDevicesForUser(user.UserName!); + + // Should not throw + await _backupService.RestoreBackupAsync(backup, existingDevices, _dataService); + } +} diff --git a/Tests/Server.Tests/IoCActivator.cs b/Tests/Server.Tests/IoCActivator.cs index 75d761e3c..601f0befd 100644 --- a/Tests/Server.Tests/IoCActivator.cs +++ b/Tests/Server.Tests/IoCActivator.cs @@ -42,6 +42,7 @@ public static void AssemblyInit(TestContext context) _builder.Services.AddTransient(); _builder.Services.AddTransient(); _builder.Services.AddTransient(); + _builder.Services.AddLogging(); _webApp = _builder.Build(); } From ed71d9a97ba39d8d32586caa93f20e4f647abebf Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Mar 2026 19:59:24 +0000 Subject: [PATCH 4/4] Security hardening: CSRF state validation, ownership checks, download limits - Add cryptographic state token validation for OAuth callback (CSRF protection) - Use LocalRedirect instead of Redirect to prevent open redirect - Add backup ownership verification before restore - Add 10MB download size limit for Google Drive files - ValidateOAuthState method for one-time use state tokens Co-authored-by: Undermozes <58026189+Undermozes@users.noreply.github.com> --- Server/API/GoogleDriveCallbackController.cs | 14 ++++++++- Server/Components/Pages/Backup.razor | 14 +++++++++ Server/Services/GoogleDriveService.cs | 33 +++++++++++++++++++-- 3 files changed, 57 insertions(+), 4 deletions(-) diff --git a/Server/API/GoogleDriveCallbackController.cs b/Server/API/GoogleDriveCallbackController.cs index 389fcf3a8..811311433 100644 --- a/Server/API/GoogleDriveCallbackController.cs +++ b/Server/API/GoogleDriveCallbackController.cs @@ -31,16 +31,28 @@ public async Task Callback([FromQuery] string code, [FromQuery] s return BadRequest("Authorization code is missing."); } + if (string.IsNullOrEmpty(state)) + { + return BadRequest("State parameter is missing."); + } + var user = await _userManager.GetUserAsync(User); if (user is null) { return Unauthorized(); } + // Validate the OAuth state token to prevent CSRF attacks. + var validatedUserId = _googleDriveService.ValidateOAuthState(state); + if (validatedUserId is null || validatedUserId != user.Id) + { + return BadRequest("Invalid or expired OAuth state token."); + } + var redirectUri = $"{Request.Scheme}://{Request.Host}/api/googledrivecallback"; await _googleDriveService.ExchangeCodeForTokenAsync(user.Id, code, redirectUri); - return Redirect("/backup"); + return LocalRedirect("/backup"); } } diff --git a/Server/Components/Pages/Backup.razor b/Server/Components/Pages/Backup.razor index 3328e83af..2adcf1bf3 100644 --- a/Server/Components/Pages/Backup.razor +++ b/Server/Components/Pages/Backup.razor @@ -256,6 +256,13 @@ return; } + if (!string.IsNullOrEmpty(backup.UserName) && + !string.Equals(backup.UserName, UserName, StringComparison.OrdinalIgnoreCase)) + { + _alertMessage = "This backup belongs to a different user account."; + return; + } + var existingDevices = DataService.GetDevicesForUser(UserName); await BackupService.RestoreBackupAsync(backup, existingDevices, DataService); @@ -363,6 +370,13 @@ return; } + if (!string.IsNullOrEmpty(backup.UserName) && + !string.Equals(backup.UserName, UserName, StringComparison.OrdinalIgnoreCase)) + { + _alertMessage = "This backup belongs to a different user account."; + return; + } + var existingDevices = DataService.GetDevicesForUser(UserName); await BackupService.RestoreBackupAsync(backup, existingDevices, DataService); diff --git a/Server/Services/GoogleDriveService.cs b/Server/Services/GoogleDriveService.cs index 69db80d0d..b67914bc9 100644 --- a/Server/Services/GoogleDriveService.cs +++ b/Server/Services/GoogleDriveService.cs @@ -29,6 +29,7 @@ public interface IGoogleDriveService { bool IsConfigured { get; } string GetAuthorizationUrl(string userId, string redirectUri); + string? ValidateOAuthState(string state); Task ExchangeCodeForTokenAsync(string userId, string code, string redirectUri); bool IsConnected(string userId); void Disconnect(string userId); @@ -41,11 +42,13 @@ public class GoogleDriveService : IGoogleDriveService { private const string AppFolderName = "Remotely Backups"; private const string BackupMimeType = "application/json"; + private const long MaxDownloadSizeBytes = 10 * 1024 * 1024; // 10 MB private static readonly string[] Scopes = { DriveService.Scope.DriveFile }; // TODO: Tokens are stored in memory and will be lost on application restart. // For production use, consider persisting tokens securely in the database. private readonly ConcurrentDictionary _userTokens = new(); + private readonly ConcurrentDictionary _pendingOAuthStates = new(); private readonly GoogleDriveOptions _options; private readonly ILogger _logger; @@ -68,12 +71,25 @@ public string GetAuthorizationUrl(string userId, string redirectUri) throw new InvalidOperationException("Google Drive is not configured."); } + var stateToken = Convert.ToBase64String( + System.Security.Cryptography.RandomNumberGenerator.GetBytes(32)); + _pendingOAuthStates[stateToken] = userId; + var flow = CreateFlow(); var uri = flow.CreateAuthorizationCodeRequest(redirectUri); - uri.State = userId; + uri.State = stateToken; return uri.Build().AbsoluteUri; } + public string? ValidateOAuthState(string state) + { + if (_pendingOAuthStates.TryRemove(state, out var userId)) + { + return userId; + } + return null; + } + public async Task ExchangeCodeForTokenAsync(string userId, string code, string redirectUri) { if (!IsConfigured) @@ -159,9 +175,20 @@ public async Task> ListBackupsAsync(string userId) public async Task DownloadBackupAsync(string userId, string fileId) { var service = CreateDriveService(userId); - var request = service.Files.Get(fileId); + + // Check file size before downloading to prevent memory exhaustion. + var fileRequest = service.Files.Get(fileId); + fileRequest.Fields = "size"; + var fileMeta = await fileRequest.ExecuteAsync(); + if (fileMeta.Size.HasValue && fileMeta.Size.Value > MaxDownloadSizeBytes) + { + throw new InvalidOperationException( + $"Backup file exceeds the maximum allowed size of {MaxDownloadSizeBytes / (1024 * 1024)} MB."); + } + + var downloadRequest = service.Files.Get(fileId); var stream = new MemoryStream(); - await request.DownloadAsync(stream); + await downloadRequest.DownloadAsync(stream); stream.Position = 0; return stream; }