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.