diff --git a/.github/scripts/inject-token.ps1 b/.github/scripts/inject-token.ps1 deleted file mode 100644 index f41d16e12..000000000 --- a/.github/scripts/inject-token.ps1 +++ /dev/null @@ -1,47 +0,0 @@ -param( - [string]$Token -) - -if ([string]::IsNullOrEmpty($Token)) { - # For PRs from forks, secrets are not available. We use a dummy token to allow the build to pass. - if ($env:GITHUB_EVENT_NAME -eq 'pull_request') { - Write-Host "Warning: No UPLOADTHING_TOKEN provided. Using dummy token for PR build." - $Token = "DUMMY_TOKEN_FOR_CI_ONLY" - } else { - Write-Error "No UPLOADTHING_TOKEN provided. Fails for Release builds." - exit 1 - } -} - -$constantsPath = "GenHub/GenHub.Core/Constants/ApiConstants.cs" -if (-not (Test-Path $constantsPath)) { - Write-Error "Could not find $constantsPath" - exit 1 -} - -$tokenBytes = [System.Text.Encoding]::UTF8.GetBytes($Token) -$key = New-Object byte[] 32 -[System.Security.Cryptography.RandomNumberGenerator]::Create().GetBytes($key) - -$obfuscated = New-Object byte[] $tokenBytes.Length -for ($i = 0; $i -lt $tokenBytes.Length; $i++) { - $obfuscated[$i] = $tokenBytes[$i] -bxor $key[$i % $key.Length] -} - -$dataStr = ($obfuscated | ForEach-Object { "0x{0:x2}" -f $_ }) -join ", " -$keyStr = ($key | ForEach-Object { "0x{0:x2}" -f $_ }) -join ", " - -$content = Get-Content $constantsPath -Raw - -# Use regex replace to be robust against whitespace -# Pattern: byte[] data = []; // [PLACEHOLDER_DATA] -$content = [System.Text.RegularExpressions.Regex]::Replace($content, 'byte\[\]\s+data\s*=\s*\[\];\s*//\s*\[PLACEHOLDER_DATA\]', "byte[] data = [$dataStr];") -$content = [System.Text.RegularExpressions.Regex]::Replace($content, 'byte\[\]\s+key\s*=\s*\[\];\s*//\s*\[PLACEHOLDER_KEY\]', "byte[] key = [$keyStr];") - -if ($content -notmatch "0x") { - Write-Error "Token injection failed! Placeholders were not found or replaced." - exit 1 -} - -Set-Content $constantsPath $content -Write-Host "Successfully injected and obfuscated UPLOADTHING_TOKEN into ApiConstants.cs" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 837e9ae40..3020a15a0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -123,11 +123,6 @@ jobs: echo "VERSION=$version" >> $env:GITHUB_OUTPUT echo "CHANNEL=$channel" >> $env:GITHUB_OUTPUT - - name: Inject Secrets - shell: pwsh - run: | - ./.github/scripts/inject-token.ps1 -Token "${{ secrets.UPLOADTHING_TOKEN }}" - - name: Build Projects shell: pwsh run: | @@ -285,11 +280,6 @@ jobs: echo "PR_NUMBER=$PR_NUMBER" >> $GITHUB_OUTPUT echo "VERSION=$VERSION" >> $GITHUB_OUTPUT - - name: Inject Secrets - shell: pwsh - run: | - ./.github/scripts/inject-token.ps1 -Token "${{ secrets.UPLOADTHING_TOKEN }}" - - name: Build Projects run: | BUILD_PROPS="-p:Version=${{ steps.buildinfo.outputs.VERSION }} -p:GitShortHash=${{ steps.buildinfo.outputs.SHORT_HASH }} -p:PullRequestNumber=${{ steps.buildinfo.outputs.PR_NUMBER }} -p:BuildChannel=${{ steps.buildinfo.outputs.CHANNEL }}" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9cee9e205..8ab2b06a1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -40,11 +40,6 @@ jobs: $version = "0.0.${{ github.run_number }}" echo "VERSION=$version" >> $env:GITHUB_OUTPUT - - name: Inject Secrets - shell: pwsh - run: | - ./.github/scripts/inject-token.ps1 -Token "${{ secrets.UPLOADTHING_TOKEN }}" - - name: Publish Windows App shell: pwsh run: | @@ -116,11 +111,6 @@ jobs: - name: Install Linux Dependencies run: sudo apt-get update && sudo apt-get install -y libgtk-3-dev libx11-dev - - name: Inject Secrets - shell: pwsh - run: | - ./.github/scripts/inject-token.ps1 -Token "${{ secrets.UPLOADTHING_TOKEN }}" - - name: Publish Linux App run: | dotnet publish "${{ env.LINUX_PROJECT }}" \ diff --git a/GenHub/GenHub.Core/Constants/ApiConstants.cs b/GenHub/GenHub.Core/Constants/ApiConstants.cs index 9a93c95f5..7ab83ccb9 100644 --- a/GenHub/GenHub.Core/Constants/ApiConstants.cs +++ b/GenHub/GenHub.Core/Constants/ApiConstants.cs @@ -59,84 +59,13 @@ public static class ApiConstants /// public const string GitHubApiRunArtifactsFormat = "https://api.github.com/repos/{0}/{1}/actions/runs/{2}/artifacts"; - // UploadThing - - /// - /// UploadThing API version. - /// - public const string UploadThingApiVersion = "7.7.4"; - - /// - /// UploadThing prepare upload URL. - /// - public const string UploadThingPrepareUrl = "https://api.uploadthing.com/v7/prepareUpload"; - - /// - /// UploadThing delete file URL. - /// - public const string UploadThingDeleteUrl = "https://api.uploadthing.com/v6/deleteFiles"; - - /// - /// UploadThing public file URL format. - /// - public const string UploadThingPublicUrlFormat = "https://utfs.io/f/{0}"; + // UploadThing links /// /// UploadThing URL fragment for identification. /// public const string UploadThingUrlFragment = "utfs.io/f/"; - /// - /// UploadThing token environment variable. - /// - public const string UploadThingTokenEnvVar = "UPLOADTHING_TOKEN"; - - /// - /// Alternative UploadThing token environment variable. - /// - public const string UploadThingTokenEnvVarAlt = "GENHUB_UPLOADTHING_TOKEN"; - - /// - /// Gets the default UploadThing token injected at build time (Obfuscated). - /// - public static string BuildTimeUploadThingToken - { - get - { - // This is a simple XOR obfuscation to prevent the raw token from appearing in strings/debuggers. - // The actual values are injected during the GitHub Actions build process. - byte[] data = []; // [PLACEHOLDER_DATA] - byte[] key = []; // [PLACEHOLDER_KEY] - - if (data.Length == 0 || key.Length == 0) return string.Empty; - - var result = new byte[data.Length]; - for (int i = 0; i < data.Length; i++) - { - result[i] = (byte)(data[i] ^ key[i % key.Length]); - } - - return System.Text.Encoding.UTF8.GetString(result); - } - } - - /// - /// UploadThing API key header. - /// - public const string UploadThingApiKeyHeader = "x-uploadthing-api-key"; - - /// - /// UploadThing version header. - /// - public const string UploadThingVersionHeader = "x-uploadthing-version"; - - // Media Types - - /// - /// Media type for ZIP files. - /// - public const string MediaTypeZip = "application/zip"; - // GenTool /// @@ -167,4 +96,4 @@ public static string BuildTimeUploadThingToken /// UserAgent string that mimics a standard web browser. /// public const string BrowserUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"; -} \ No newline at end of file +} diff --git a/GenHub/GenHub.Core/Constants/ErrorMessages.cs b/GenHub/GenHub.Core/Constants/ErrorMessages.cs index 7e5371288..ecbbe6be5 100644 --- a/GenHub/GenHub.Core/Constants/ErrorMessages.cs +++ b/GenHub/GenHub.Core/Constants/ErrorMessages.cs @@ -5,11 +5,6 @@ namespace GenHub.Core.Constants; /// public static class ErrorMessages { - /// - /// Error message for missing UploadThing token. - /// - public const string UploadThingTokenMissing = "UploadThing V7 Token is missing. Ensure UPLOADTHING_TOKEN is in your .env file."; - /// /// Error message for ZIP validation failure. /// @@ -20,26 +15,6 @@ public static class ErrorMessages /// public const string FileExceedsSizeLimit = "File exceeds size limit: {Path}"; - /// - /// Error message for failed prepare upload. - /// - public const string V7PrepareUploadFailed = "V7 PrepareUpload failed: {StatusCode} - {Error}"; - - /// - /// Error message for missing required fields in UploadThing response. - /// - public const string UploadThingMissingFields = "UploadThing V7 returned 200 OK but missing required fields. Response: {Response}"; - - /// - /// Error message for failed binary upload. - /// - public const string V7BinaryUploadFailed = "V7 PUT Binary Upload failed: {StatusCode} - {Error}"; - - /// - /// Error message for exception in UploadThing flow. - /// - public const string ExceptionInUploadThingFlow = "Exception in UploadThing V7 flow"; - /// /// Error message for could not extract download URL. /// diff --git a/GenHub/GenHub.Core/Constants/LogMessages.cs b/GenHub/GenHub.Core/Constants/LogMessages.cs index 290e58e97..aeaf5ad27 100644 --- a/GenHub/GenHub.Core/Constants/LogMessages.cs +++ b/GenHub/GenHub.Core/Constants/LogMessages.cs @@ -40,16 +40,6 @@ public static class LogMessages /// public const string FailedToDeleteReplay = "Failed to delete replay: {Path}"; - /// - /// Log message for uploading to UploadThing. - /// - public const string UploadingToUploadThing = "Uploading to UploadThing V7: {Path}"; - - /// - /// Log message for successful UploadThing upload. - /// - public const string UploadThingSuccessful = "UploadThing V7 successful. Public URL: {Url}"; - /// /// Log message for failed ZIP creation. /// diff --git a/GenHub/GenHub.Core/Interfaces/Common/IUploadHistoryService.cs b/GenHub/GenHub.Core/Interfaces/Common/IUploadHistoryService.cs index 3bfca422a..7af1df63a 100644 --- a/GenHub/GenHub.Core/Interfaces/Common/IUploadHistoryService.cs +++ b/GenHub/GenHub.Core/Interfaces/Common/IUploadHistoryService.cs @@ -42,15 +42,15 @@ public interface IUploadHistoryService Task> GetUploadHistoryAsync(); /// - /// Removes a history item. + /// Removes an item from local history without deleting the hosted file. /// /// The URL. /// A task representing the asynchronous operation. Task RemoveHistoryItemAsync(string url); /// - /// Clears the history. + /// Clears local history without deleting hosted files. /// /// A task representing the asynchronous operation. Task ClearHistoryAsync(); -} \ No newline at end of file +} diff --git a/GenHub/GenHub.Core/Models/Tools/UploadRecord.cs b/GenHub/GenHub.Core/Models/Tools/UploadRecord.cs index 30733b0c5..04e1c356a 100644 --- a/GenHub/GenHub.Core/Models/Tools/UploadRecord.cs +++ b/GenHub/GenHub.Core/Models/Tools/UploadRecord.cs @@ -1,3 +1,5 @@ +using System.Text.Json.Serialization; + namespace GenHub.Core.Models.Tools; /// @@ -26,7 +28,11 @@ public sealed class UploadRecord public string? FileName { get; set; } /// - /// Gets or sets a value indicating whether this item is queued for deletion. + /// Gets or sets a value indicating whether a legacy record was pending deletion. /// + /// + /// Retained only to migrate existing history files. New records leave this value unset. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] public bool IsPendingDeletion { get; set; } -} \ No newline at end of file +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Tools/Services/UploadHistoryServiceTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Tools/Services/UploadHistoryServiceTests.cs new file mode 100644 index 000000000..1d6cc74bf --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Tools/Services/UploadHistoryServiceTests.cs @@ -0,0 +1,171 @@ +using GenHub.Core.Interfaces.Common; +using GenHub.Features.Tools.Services; +using Microsoft.Extensions.Logging; +using Moq; + +namespace GenHub.Tests.Core.Features.Tools.Services; + +/// +/// Tests for local upload history behavior while cloud deletion is disabled. +/// +public sealed class UploadHistoryServiceTests : IDisposable +{ + private readonly string _tempDirectory; + + /// + /// Initializes a new instance of the class. + /// + public UploadHistoryServiceTests() + { + _tempDirectory = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); + Directory.CreateDirectory(_tempDirectory); + } + + /// + /// Removes temporary test data. + /// + public void Dispose() + { + if (Directory.Exists(_tempDirectory)) + { + Directory.Delete(_tempDirectory, recursive: true); + } + + GC.SuppressFinalize(this); + } + + /// + /// Verifies that removing an item deletes its local record immediately. + /// + /// A task representing the asynchronous test operation. + [Fact] + public async Task RemoveHistoryItemAsync_WhenItemExists_RemovesLocalRecord() + { + var service = CreateService(); + service.RecordUpload(1024, "https://utfs.io/f/example", "example.zip"); + + await service.RemoveHistoryItemAsync("https://utfs.io/f/example"); + + var reloadedService = CreateService(); + Assert.Empty(await reloadedService.GetUploadHistoryAsync()); + } + + /// + /// Verifies that removing one item preserves the other local records. + /// + /// A task representing the asynchronous test operation. + [Fact] + public async Task RemoveHistoryItemAsync_WhenOtherItemsExist_PreservesOtherRecords() + { + var service = CreateService(); + service.RecordUpload(1024, "https://utfs.io/f/first", "first.zip"); + service.RecordUpload(2048, "https://utfs.io/f/second", "second.zip"); + + await service.RemoveHistoryItemAsync("https://utfs.io/f/first"); + + var reloadedService = CreateService(); + var item = Assert.Single(await reloadedService.GetUploadHistoryAsync()); + Assert.Equal("https://utfs.io/f/second", item.Url); + } + + /// + /// Verifies that removing a non-matching URL leaves local history unchanged. + /// + /// A task representing the asynchronous test operation. + [Fact] + public async Task RemoveHistoryItemAsync_WhenUrlDoesNotMatch_PreservesHistory() + { + var service = CreateService(); + service.RecordUpload(1024, "https://utfs.io/f/example", "example.zip"); + + await service.RemoveHistoryItemAsync("https://utfs.io/f/missing"); + + var reloadedService = CreateService(); + var item = Assert.Single(await reloadedService.GetUploadHistoryAsync()); + Assert.Equal("https://utfs.io/f/example", item.Url); + } + + /// + /// Verifies that clearing history deletes every local record immediately. + /// + /// A task representing the asynchronous test operation. + [Fact] + public async Task ClearHistoryAsync_WhenItemsExist_RemovesAllLocalRecords() + { + var service = CreateService(); + service.RecordUpload(1024, "https://utfs.io/f/first", "first.zip"); + service.RecordUpload(2048, "https://utfs.io/f/second", "second.zip"); + + await service.ClearHistoryAsync(); + + var reloadedService = CreateService(); + Assert.Empty(await reloadedService.GetUploadHistoryAsync()); + } + + /// + /// Verifies that clearing empty history completes without creating records. + /// + /// A task representing the asynchronous test operation. + [Fact] + public async Task ClearHistoryAsync_WhenHistoryIsEmpty_RemainsEmpty() + { + var service = CreateService(); + + await service.ClearHistoryAsync(); + + var reloadedService = CreateService(); + Assert.Empty(await reloadedService.GetUploadHistoryAsync()); + } + + /// + /// Verifies that legacy pending-deletion records are removed during migration. + /// + /// A task representing the asynchronous test operation. + [Fact] + public async Task GetUploadHistoryAsync_WhenLegacyRecordIsPendingDeletion_RemovesRecord() + { + var historyPath = Path.Combine(_tempDirectory, "upload_history.json"); + var timestamp = DateTime.UtcNow.ToString("O"); + var historyJson = $$""" + [ + { + "timestamp": "{{timestamp}}", + "sizeBytes": 1024, + "url": "https://utfs.io/f/pending", + "fileName": "pending.zip", + "isPendingDeletion": true + }, + { + "timestamp": "{{timestamp}}", + "sizeBytes": 2048, + "url": "https://utfs.io/f/active", + "fileName": "active.zip" + } + ] + """; + File.WriteAllText(historyPath, historyJson); + + var service = CreateService(); + var history = await service.GetUploadHistoryAsync(); + + var item = Assert.Single(history); + Assert.Equal("https://utfs.io/f/active", item.Url); + + var migratedJson = File.ReadAllText(historyPath); + Assert.DoesNotContain("https://utfs.io/f/pending", migratedJson); + Assert.DoesNotContain("isPendingDeletion", migratedJson); + + var reloadedService = CreateService(); + Assert.Single(await reloadedService.GetUploadHistoryAsync()); + } + + private UploadHistoryService CreateService() + { + var appConfig = new Mock(); + appConfig.Setup(config => config.GetConfiguredDataPath()).Returns(_tempDirectory); + + return new UploadHistoryService( + Mock.Of>(), + appConfig.Object); + } +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Tools/Services/UploadThingServiceTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Tools/Services/UploadThingServiceTests.cs new file mode 100644 index 000000000..8bb511fed --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Tools/Services/UploadThingServiceTests.cs @@ -0,0 +1,38 @@ +using GenHub.Features.Tools.Services; +using Microsoft.Extensions.Logging; +using Moq; + +namespace GenHub.Tests.Core.Features.Tools.Services; + +/// +/// Tests for the disabled UploadThing integration. +/// +public class UploadThingServiceTests +{ + private readonly UploadThingService _service = + new(Mock.Of>()); + + /// + /// Verifies that uploads fail closed while short-lived credentials are unavailable. + /// + /// A task representing the asynchronous test operation. + [Fact] + public async Task UploadFileAsync_WhenCredentialsAreUnavailable_ReturnsNull() + { + var result = await _service.UploadFileAsync("unused.zip"); + + Assert.Null(result); + } + + /// + /// Verifies that authenticated deletion also fails closed. + /// + /// A task representing the asynchronous test operation. + [Fact] + public async Task DeleteFileAsync_WhenCredentialsAreUnavailable_ReturnsFalse() + { + var result = await _service.DeleteFileAsync("unused-key"); + + Assert.False(result); + } +} diff --git a/GenHub/GenHub/Features/Tools/MapManager/ViewModels/MapManagerViewModel.cs b/GenHub/GenHub/Features/Tools/MapManager/ViewModels/MapManagerViewModel.cs index cf6736c64..4803d69ca 100644 --- a/GenHub/GenHub/Features/Tools/MapManager/ViewModels/MapManagerViewModel.cs +++ b/GenHub/GenHub/Features/Tools/MapManager/ViewModels/MapManagerViewModel.cs @@ -1081,7 +1081,7 @@ private async Task RemoveHistoryItemAsync(UploadHistoryItemViewModel item) { _notificationService.ShowInfo( "Remove From History", - "Removes the item from your local history. This frees up your upload quota immediately."); + "Removes the item from local history without deleting the hosted file."); return; } @@ -1089,7 +1089,9 @@ private async Task RemoveHistoryItemAsync(UploadHistoryItemViewModel item) { await _uploadHistoryService.RemoveHistoryItemAsync(item.Url); await LoadHistoryAsync(); - _notificationService.ShowSuccess("Removed", "History item removed."); + _notificationService.ShowSuccess( + "Removed", + "Removed from local history. The hosted file was not deleted."); } catch (Exception ex) { @@ -1107,7 +1109,7 @@ private async Task ClearHistoryAsync() { _notificationService.ShowInfo( "Clear History", - "Clears your entire local upload history. This frees up all your upload quota."); + "Clears local upload history without deleting hosted files."); return; } @@ -1115,7 +1117,9 @@ private async Task ClearHistoryAsync() { await _uploadHistoryService.ClearHistoryAsync(); await LoadHistoryAsync(); - _notificationService.ShowSuccess("Cleared", "All upload history cleared."); + _notificationService.ShowSuccess( + "Cleared", + "Local history cleared. Hosted files were not deleted."); } catch (Exception ex) { diff --git a/GenHub/GenHub/Features/Tools/MapManager/Views/MapManagerView.axaml b/GenHub/GenHub/Features/Tools/MapManager/Views/MapManagerView.axaml index c530da3be..d4b7c7035 100644 --- a/GenHub/GenHub/Features/Tools/MapManager/Views/MapManagerView.axaml +++ b/GenHub/GenHub/Features/Tools/MapManager/Views/MapManagerView.axaml @@ -189,9 +189,9 @@ - - diff --git a/GenHub/GenHub/Features/Tools/ReplayManager/ViewModels/ReplayManagerViewModel.cs b/GenHub/GenHub/Features/Tools/ReplayManager/ViewModels/ReplayManagerViewModel.cs index 2522ad737..12c7e00d3 100644 --- a/GenHub/GenHub/Features/Tools/ReplayManager/ViewModels/ReplayManagerViewModel.cs +++ b/GenHub/GenHub/Features/Tools/ReplayManager/ViewModels/ReplayManagerViewModel.cs @@ -200,7 +200,7 @@ private async Task RemoveHistoryItemAsync(UploadHistoryItemViewModel item) { notificationService.ShowInfo( "Remove From History", - "Removes the item from your local history. This frees up your upload quota immediately."); + "Removes the item from local history without deleting the hosted file."); return; } @@ -208,7 +208,9 @@ private async Task RemoveHistoryItemAsync(UploadHistoryItemViewModel item) { await uploadHistoryService.RemoveHistoryItemAsync(item.Url); await LoadHistoryAsync(); - notificationService.ShowSuccess("Removed", "History item removed."); + notificationService.ShowSuccess( + "Removed", + "Removed from local history. The hosted file was not deleted."); } catch (Exception ex) { @@ -229,7 +231,7 @@ private async Task ClearHistoryAsync() { notificationService.ShowInfo( "Clear History", - "Clears your entire local upload history. This frees up all your upload quota."); + "Clears local upload history without deleting hosted files."); return; } @@ -237,7 +239,9 @@ private async Task ClearHistoryAsync() { await uploadHistoryService.ClearHistoryAsync(); await LoadHistoryAsync(); - notificationService.ShowSuccess("Cleared", "All upload history cleared."); + notificationService.ShowSuccess( + "Cleared", + "Local history cleared. Hosted files were not deleted."); } catch (Exception ex) { diff --git a/GenHub/GenHub/Features/Tools/ReplayManager/Views/ReplayManagerView.axaml b/GenHub/GenHub/Features/Tools/ReplayManager/Views/ReplayManagerView.axaml index b08e8f05a..15c8f1273 100644 --- a/GenHub/GenHub/Features/Tools/ReplayManager/Views/ReplayManagerView.axaml +++ b/GenHub/GenHub/Features/Tools/ReplayManager/Views/ReplayManagerView.axaml @@ -167,9 +167,9 @@ - - diff --git a/GenHub/GenHub/Features/Tools/Services/UploadHistoryService.cs b/GenHub/GenHub/Features/Tools/Services/UploadHistoryService.cs index 945e1faa4..ba7c855e4 100644 --- a/GenHub/GenHub/Features/Tools/Services/UploadHistoryService.cs +++ b/GenHub/GenHub/Features/Tools/Services/UploadHistoryService.cs @@ -6,7 +6,6 @@ using System.Threading.Tasks; using GenHub.Core.Constants; using GenHub.Core.Interfaces.Common; -using GenHub.Core.Interfaces.Services; using GenHub.Core.Models.Common; using GenHub.Core.Models.Tools; using Microsoft.Extensions.Logging; @@ -21,11 +20,9 @@ namespace GenHub.Features.Tools.Services; /// /// Logger instance. /// Application configuration service. -/// UploadThing service. public sealed class UploadHistoryService( ILogger logger, - IAppConfiguration appConfig, - IUploadThingService uploadThing) : IUploadHistoryService + IAppConfiguration appConfig) : IUploadHistoryService { private const int RateLimitDays = 3; private const int HistoryRetentionDays = 30; @@ -40,32 +37,8 @@ public sealed class UploadHistoryService( private readonly ILogger _logger = logger ?? throw new ArgumentNullException(nameof(logger)); private readonly string _historyFilePath = Path.Combine(appConfig.GetConfiguredDataPath(), "upload_history.json"); - - // Trigger cleanup on service instantiation (fire-and-forget) - private readonly Task _cleanupTask = Task.Run(async () => await ProcessPendingDeletionsAsync()); private List? _cache; - private static Task ProcessPendingDeletionsAsync() - { - return Task.CompletedTask; - } - - private static string? ExtractKeyFromUrl(string url) - { - if (string.IsNullOrWhiteSpace(url)) return null; - try - { - var uri = new Uri(url); - return uri.Segments.Last(); - } - catch - { - // Fallback for simple string logic if Uri fails - var lastSlash = url.LastIndexOf('/'); - return lastSlash >= 0 && lastSlash < url.Length - 1 ? url[(lastSlash + 1)..] : null; - } - } - /// public long MaxUploadBytesPerPeriod => MapManagerConstants.MaxUploadBytesPerPeriod; @@ -109,7 +82,6 @@ public Task GetUsageInfoAsync() var history = LoadHistoryInternal(); var periodStart = DateTime.UtcNow.AddDays(-RateLimitDays); - // Include items even if pending deletion, as they still occupy quota until confirmed deleted var recentUploads = history.Where(r => r.Timestamp >= periodStart).ToList(); var usedBytes = recentUploads.Sum(r => r.SizeBytes); @@ -127,116 +99,52 @@ public Task> GetUploadHistoryAsync() { var history = LoadHistoryInternal(); - // Return history, EXCLUDING pending deletions so user sees effective state - var items = history - .Where(r => !r.IsPendingDeletion) - .Select(r => new UploadHistoryItem( - r.Timestamp, - r.SizeBytes, - r.Url ?? string.Empty, - r.FileName ?? "Unknown File")); + var items = history.Select(r => new UploadHistoryItem( + r.Timestamp, + r.SizeBytes, + r.Url ?? string.Empty, + r.FileName ?? "Unknown File")); return Task.FromResult(items); } /// - public async Task RemoveHistoryItemAsync(string url) + public Task RemoveHistoryItemAsync(string url) { - List history; lock (FileLock) { - history = LoadHistoryInternal(); - var item = history.FirstOrDefault(r => r.Url == url); - if (item != null && !item.IsPendingDeletion) + var history = LoadHistoryInternal(); + var removed = history.RemoveAll(r => r.Url == url); + if (removed > 0) { - item.IsPendingDeletion = true; // Mark as pending - SaveHistoryInternal(history); // Persist state + SaveHistoryInternal(history); _cache = history; + _logger.LogInformation( + "Removed {Count} item(s) for {Url} from local upload history without deleting the hosted file.", + removed, + url); } } - // Attempt immediate deletion - await TryDeleteUrlAsync(url); + return Task.CompletedTask; } /// - public async Task ClearHistoryAsync() + public Task ClearHistoryAsync() { - List history; lock (FileLock) { - history = LoadHistoryInternal(); - if (history.Count == 0 || history.All(x => x.IsPendingDeletion)) return; - - foreach (var item in history) + var history = LoadHistoryInternal(); + if (history.Count > 0) { - item.IsPendingDeletion = true; + history.Clear(); + SaveHistoryInternal(history); + _cache = history; + _logger.LogInformation("Cleared local upload history without deleting hosted files."); } - - SaveHistoryInternal(history); - _cache = history; } - // Attempt deletion of all pending items - await ProcessPendingDeletionsAsync(); - } - - private async Task TryDeleteUrlAsync(string url) - { - var key = ExtractKeyFromUrl(url); - if (string.IsNullOrEmpty(key)) - { - _logger.LogError("Could not extract file key from URL: {Url}", url); - - // Even if invalid, we might want to just remove it from history? - // For now, keep it pending to avoid quota exploit via malformed URLs if that were possible. - // But practically, if we can't delete it, it's stuck. Let's remove it if invalid format. - RemoveFromHistoryPermanent(url); - return; - } - - var success = await uploadThing.DeleteFileAsync(key); - if (success) - { - RemoveFromHistoryPermanent(url); - _logger.LogInformation("Successfully deleted and removed history for: {Url}", url); - } - else - { - _logger.LogWarning("Failed to delete {Url}. Item remains in Pending Deletion state.", url); - } - } - - private void RemoveFromHistoryPermanent(string url) - { - lock (FileLock) - { - var history = LoadHistoryInternal(); - var removed = history.RemoveAll(r => r.Url == url); - if (removed > 0) - { - SaveHistoryInternal(history); - _cache = history; - } - } - } - - private async Task RunCleanupAsync() - { - List snapshot; - lock (FileLock) - { - var history = LoadHistoryInternal(); - snapshot = history.Where(x => x.IsPendingDeletion).ToList(); - } - - foreach (var item in snapshot) - { - if (item.Url != null) - { - await TryDeleteUrlAsync(item.Url); - } - } + return Task.CompletedTask; } private List LoadHistoryInternal() @@ -267,14 +175,20 @@ private List LoadHistoryInternal() // Clean up old entries (expired retention) var retentionCutoff = DateTime.UtcNow.AddDays(-HistoryRetentionDays); + var hasPendingDeletionRecords = history.Any(r => r.IsPendingDeletion); - // Also remove items that were pending deletion and are very old? No, we keep trying. - // Filter logic: Keep if New enough OR (IsPendingDeletion AND New enough?) - // If it's pending deletion and 30 days old, maybe just give up? - // Let's stick to standard retention. If it's old, it falls off history anyway. - _cache = history.Where(r => r.Timestamp >= retentionCutoff).OrderByDescending(r => r.Timestamp).ToList(); + var migratedHistory = history + .Where(r => !r.IsPendingDeletion && r.Timestamp >= retentionCutoff) + .OrderByDescending(r => r.Timestamp) + .ToList(); + _cache = migratedHistory; - return new List(_cache); + if (hasPendingDeletionRecords) + { + SaveHistoryInternal(migratedHistory); + } + + return new List(migratedHistory); } catch (Exception ex) { @@ -305,4 +219,4 @@ private void SaveHistoryInternal(List history) } } } -} \ No newline at end of file +} diff --git a/GenHub/GenHub/Features/Tools/Services/UploadThingService.cs b/GenHub/GenHub/Features/Tools/Services/UploadThingService.cs index 7afc2a252..001b43517 100644 --- a/GenHub/GenHub/Features/Tools/Services/UploadThingService.cs +++ b/GenHub/GenHub/Features/Tools/Services/UploadThingService.cs @@ -1,196 +1,37 @@ -using GenHub.Core.Constants; using GenHub.Core.Interfaces.Services; using Microsoft.Extensions.Logging; using System; -using System.Collections.Generic; -using System.IO; -using System.Net.Http; -using System.Net.Http.Json; -using System.Text.Json.Serialization; using System.Threading; using System.Threading.Tasks; namespace GenHub.Features.Tools.Services; /// -/// Implementation of for uploading files to UploadThing cloud storage using V7 API. +/// Disabled UploadThing integration. /// +/// +/// Upload and delete operations must remain disabled until the application obtains +/// narrowly scoped, short-lived credentials from a trusted backend. +/// public sealed class UploadThingService( - HttpClient httpClient, ILogger logger) : IUploadThingService { /// - public async Task UploadFileAsync( + public Task UploadFileAsync( string filePath, IProgress? progress = null, CancellationToken ct = default) { - var token = Environment.GetEnvironmentVariable(ApiConstants.UploadThingTokenEnvVar) ?? - Environment.GetEnvironmentVariable(ApiConstants.UploadThingTokenEnvVarAlt); - - // Fallback to build-time injected token if no env var is found - if (string.IsNullOrEmpty(token)) - { - token = ApiConstants.BuildTimeUploadThingToken; - } - - if (string.IsNullOrEmpty(token)) - { - logger.LogError("UploadThing V7 Token is missing. Ensure UPLOADTHING_TOKEN is set."); - return null; - } - - if (!File.Exists(filePath)) - { - logger.LogError("File to upload does not exist: {Path}", filePath); - return null; - } - - logger.LogInformation("Uploading to UploadThing V7: {Path}", filePath); - - try - { - var fileInfo = new FileInfo(filePath); - var fileName = Path.GetFileName(filePath); - - // Step 1: Prepare the upload - var requestPayload = new V7FileRequestDetail - { - FileName = fileName, - FileSize = fileInfo.Length, - ContentTypes = [ApiConstants.MediaTypeZip], - }; - - var prepareRequest = new HttpRequestMessage(HttpMethod.Post, ApiConstants.UploadThingPrepareUrl); - prepareRequest.Headers.Add(ApiConstants.UploadThingApiKeyHeader, token); - prepareRequest.Headers.Add(ApiConstants.UploadThingVersionHeader, ApiConstants.UploadThingApiVersion); - prepareRequest.Content = JsonContent.Create(requestPayload); - - var prepareResponse = await httpClient.SendAsync(prepareRequest, ct); - if (!prepareResponse.IsSuccessStatusCode) - { - var error = await prepareResponse.Content.ReadAsStringAsync(ct); - logger.LogError("V7 PrepareUpload failed: {Status} - {Error}", prepareResponse.StatusCode, error); - return null; - } - - var instruction = await prepareResponse.Content.ReadFromJsonAsync(cancellationToken: ct); - - if (instruction?.PresignedUrl == null || instruction?.Key == null) - { - var rawResponse = await prepareResponse.Content.ReadAsStringAsync(ct); - logger.LogError("UploadThing V7 returned 200 OK but missing required fields. Response: {Response}", rawResponse); - return null; - } - - // Step 2: Upload binary via PUT with multipart/form-data - using var fileStream = File.OpenRead(filePath); - var multipartContent = new MultipartFormDataContent(); - var fileContent = new StreamContent(fileStream); - fileContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue(ApiConstants.MediaTypeZip); - multipartContent.Add(fileContent, "file", fileName); - - var uploadRequest = new HttpRequestMessage(HttpMethod.Put, instruction.PresignedUrl) - { - Content = multipartContent, - }; - uploadRequest.Headers.UserAgent.ParseAdd(ApiConstants.DefaultUserAgent); - - progress?.Report(0.6); - - var uploadResponse = await httpClient.SendAsync(uploadRequest, ct); - if (!uploadResponse.IsSuccessStatusCode) - { - var uploadError = await uploadResponse.Content.ReadAsStringAsync(ct); - logger.LogError("V7 PUT Binary Upload failed: {Status} - {Error}", uploadResponse.StatusCode, uploadError); - return null; - } - - var publicFileUrl = string.Format(ApiConstants.UploadThingPublicUrlFormat, instruction.Key); - - logger.LogInformation("UploadThing V7 successful. Public URL: {Url}", publicFileUrl); - progress?.Report(1.0); - - return publicFileUrl; - } - catch (Exception ex) - { - logger.LogError(ex, "Exception in UploadThing V7 flow"); - return null; - } + logger.LogWarning( + "Cloud uploads are disabled until short-lived credentials are available."); + return Task.FromResult(null); } /// - public async Task DeleteFileAsync(string fileKey, CancellationToken ct = default) - { - var token = Environment.GetEnvironmentVariable(ApiConstants.UploadThingTokenEnvVar) ?? - Environment.GetEnvironmentVariable(ApiConstants.UploadThingTokenEnvVarAlt); - - // Fallback to build-time injected token if no env var is found - if (string.IsNullOrEmpty(token)) - { - token = ApiConstants.BuildTimeUploadThingToken; - } - - if (string.IsNullOrEmpty(token)) - { - logger.LogError("UploadThing Token is missing."); - return false; - } - - try - { - var requestPayload = new V6DeleteRequest { FileKeys = [fileKey] }; - var request = new HttpRequestMessage(HttpMethod.Post, ApiConstants.UploadThingDeleteUrl); - request.Headers.Add(ApiConstants.UploadThingApiKeyHeader, token); - - request.Content = JsonContent.Create(requestPayload); - - var response = await httpClient.SendAsync(request, ct); - if (!response.IsSuccessStatusCode) - { - var error = await response.Content.ReadAsStringAsync(ct); - logger.LogError("UploadThing Delete failed: {Status} - {Error}", response.StatusCode, error); - return false; - } - - logger.LogInformation("Deleted file from UploadThing: {Key}", fileKey); - return true; - } - catch (Exception ex) - { - logger.LogError(ex, "Exception deleting file from UploadThing"); - return false; - } - } - - // --- V7 Request DTOs --- - private sealed class V7FileRequestDetail - { - [JsonPropertyName("fileName")] - public string FileName { get; set; } = string.Empty; - - [JsonPropertyName("fileSize")] - public long FileSize { get; set; } - - [JsonPropertyName("contentTypes")] - public List ContentTypes { get; set; } = []; - } - - // --- V7 Response DTO --- - private sealed class V7FileInstruction - { - [JsonPropertyName("url")] - public string? PresignedUrl { get; set; } - - [JsonPropertyName("key")] - public string? Key { get; set; } - } - - // --- V6 Delete DTO --- - private sealed class V6DeleteRequest + public Task DeleteFileAsync(string fileKey, CancellationToken ct = default) { - [JsonPropertyName("fileKeys")] - public List FileKeys { get; set; } = []; + logger.LogWarning( + "Cloud file deletion is disabled until short-lived credentials are available."); + return Task.FromResult(false); } -} \ No newline at end of file +} diff --git a/docs/dev/constants.md b/docs/dev/constants.md index 629c18996..f465395cc 100644 --- a/docs/dev/constants.md +++ b/docs/dev/constants.md @@ -29,14 +29,11 @@ URI scheme constants for handling different types of URIs and paths. - `AvarUriScheme`: URI scheme for Avalonia embedded resources (`"avares://"`) - `HttpUriScheme`: HTTP URI scheme (`"http://"`) - `UploadThingUrlFragment`: URL fragment for identification (`"utfs.io/f/"`) -- `UploadThingTokenEnvVar`: Token environment variable (`"UPLOADTHING_TOKEN"`) -- `UploadThingTokenEnvVarAlt`: Alternative token environment variable (`"GENHUB_UPLOADTHING_TOKEN"`) -- `UploadThingApiKeyHeader`: API key header name (`"x-uploadthing-api-key"`) -- `UploadThingVersionHeader`: API version header name (`"x-uploadthing-version"`) +- Upload and credential constants were removed while cloud uploads are disabled. + `UploadThingUrlFragment` remains only for importing existing public links. ### Media Types -- `MediaTypeZip`: Media type for ZIP files (`"application/zip"`) - `HttpsUriScheme`: HTTPS URI scheme (`"https://"`) - `GeneralsIconUri`: Icon URI for Generals game type (`"avares://GenHub/Assets/Icons/generals-icon.png"`) diff --git a/docs/dev/uploading-api.md b/docs/dev/uploading-api.md index f22bd2f92..176ca8d3a 100644 --- a/docs/dev/uploading-api.md +++ b/docs/dev/uploading-api.md @@ -4,7 +4,23 @@ This document describes the Uploading API and the `UploadThingService` implement ## Overview -GenHub uses **UploadThing** (V7 API) as its primary cloud storage provider for sharing maps, replays, and other user-generated content. The uploading functionality is abstracted behind the `IUploadThingService` interface. +GenHub can still import existing UploadThing links, but creating and deleting cloud +uploads is temporarily disabled. + +## Security status + +The development build pipeline injected `UPLOADTHING_TOKEN` into desktop binaries +using reversible XOR obfuscation. Any CI artifacts produced while that path was +active must be treated as credential-bearing. The affected code was not present +in the last public release. + +Repository owners must revoke and rotate the exposed UploadThing token. The +replacement must not be added to GitHub Actions, source code, or desktop build +artifacts. + +The application must keep uploads disabled until a trusted backend can authenticate +the user and issue a narrowly scoped, short-lived credential or one-time signed +upload URL. Long-lived provider credentials must remain server-side. ## IUploadThingService Interface @@ -27,23 +43,25 @@ public interface IUploadThingService } ``` -## UploadThingService Implementation - -The `UploadThingService` (in `GenHub.Features.Tools.Services`) implements the V7 UploadThing API. It requires a valid API token to function. +## Disabled UploadThingService Implementation -### Configuration +The `UploadThingService` (in `GenHub.Features.Tools.Services`) is a fail-closed +implementation. Upload requests return `null`, delete requests return `false`, and +neither operation makes a network request. -The service looks for the UploadThing token in the following environment variables (defined in `ApiConstants`): +The map and replay upload buttons are also disabled so users do not enter a flow +that cannot complete. Importing existing public UploadThing links remains available. -1. `UPLOADTHING_TOKEN` -2. `GENHUB_UPLOADTHING_TOKEN` (Fallback) +## Requirements for re-enabling uploads -### Implementation Details +Before uploads are re-enabled: -The upload process follows the UploadThing V7 flow: -1. **Prepare Upload**: Sends a POST request to `https://api.uploadthing.com/v7/prepareUpload` with file metadata. -2. **Binary Upload**: Performs a PUT request to the presigned URL returned in the preparation step. -3. **URL Generation**: Constructs the public URL using the format `https://utfs.io/f/{key}`. +- A trusted backend must hold the UploadThing provider credential. +- The client must receive only narrowly scoped, short-lived authorization. +- Authorization must be constrained by file size, content type, and expiration. +- Upload and delete paths must have tests proving that expired or over-scoped + credentials are rejected. +- No reusable secret may be written into source files or packaged binaries. ## Dependency Injection @@ -57,31 +75,8 @@ public static IServiceCollection AddUploadThingServices(this IServiceCollection } ``` -## Usage Example - -```csharp -public class MyViewModel(IUploadThingService uploadService) -{ - public async Task ShareFile(string path) - { - var url = await uploadService.UploadFileAsync(path, new Progress(p => - { - Console.WriteLine($"Upload progress: {p:P0}"); - })); - - if (url != null) - { - Console.WriteLine($"File shared at: {url}"); - } - } -} -``` - ## Constants -Key constants used by the Uploading API (defined in `ApiConstants`): - -- `UploadThingApiVersion`: `"7.7.4"` -- `UploadThingPrepareUrl`: `https://api.uploadthing.com/v7/prepareUpload` -- `UploadThingPublicUrlFormat`: `https://utfs.io/f/{0}` -- `MediaTypeZip`: `"application/zip"` +Only `UploadThingUrlFragment` remains in `ApiConstants`, because it is needed to +recognize existing public links during import. Credential names, API-key headers, +provider endpoints, and build-time token decoding have been removed.