diff --git a/SaveHere/SaveHere.Tests/SaveHere.Tests.csproj b/SaveHere/SaveHere.Tests/SaveHere.Tests.csproj index 1cfb178..8db7650 100644 --- a/SaveHere/SaveHere.Tests/SaveHere.Tests.csproj +++ b/SaveHere/SaveHere.Tests/SaveHere.Tests.csproj @@ -21,9 +21,8 @@ all - + - diff --git a/SaveHere/SaveHere.Tests/Services/HttpRequestAuthenticatorTests.cs b/SaveHere/SaveHere.Tests/Services/HttpRequestAuthenticatorTests.cs new file mode 100644 index 0000000..73e3195 --- /dev/null +++ b/SaveHere/SaveHere.Tests/Services/HttpRequestAuthenticatorTests.cs @@ -0,0 +1,282 @@ +using Xunit; +using SaveHere.Helpers; +using SaveHere.Models; +using System.Net.Http; +using System.Text; + +namespace SaveHere.Tests.Services +{ + public class HttpRequestAuthenticatorTests + { + [Fact] + public void ApplyAuthentication_BasicAuth_SetsAuthorizationHeader() + { + // Arrange + var request = new HttpRequestMessage(HttpMethod.Get, "https://example.com/file.zip"); + var queueItem = new FileDownloadQueueItem + { + InputUrl = "https://example.com/file.zip", + AuthType = AuthenticationType.BasicAuth, + AuthUsername = "testuser", + AuthPassword = "testpass" + }; + + // Act + HttpRequestAuthenticator.ApplyAuthentication(request, queueItem); + + // Assert + Assert.NotNull(request.Headers.Authorization); + Assert.Equal("Basic", request.Headers.Authorization.Scheme); + + // Verify the credentials are correctly encoded + var expectedCredentials = Convert.ToBase64String(Encoding.UTF8.GetBytes("testuser:testpass")); + Assert.Equal(expectedCredentials, request.Headers.Authorization.Parameter); + } + + [Fact] + public void ApplyAuthentication_BasicAuth_WithEmptyPassword_SetsAuthorizationHeader() + { + // Arrange + var request = new HttpRequestMessage(HttpMethod.Get, "https://example.com/file.zip"); + var queueItem = new FileDownloadQueueItem + { + InputUrl = "https://example.com/file.zip", + AuthType = AuthenticationType.BasicAuth, + AuthUsername = "testuser", + AuthPassword = null + }; + + // Act + HttpRequestAuthenticator.ApplyAuthentication(request, queueItem); + + // Assert + Assert.NotNull(request.Headers.Authorization); + Assert.Equal("Basic", request.Headers.Authorization.Scheme); + + // Verify the credentials are correctly encoded with empty password + var expectedCredentials = Convert.ToBase64String(Encoding.UTF8.GetBytes("testuser:")); + Assert.Equal(expectedCredentials, request.Headers.Authorization.Parameter); + } + + [Fact] + public void ApplyAuthentication_BasicAuth_WithEmptyUsername_DoesNotSetHeader() + { + // Arrange + var request = new HttpRequestMessage(HttpMethod.Get, "https://example.com/file.zip"); + var queueItem = new FileDownloadQueueItem + { + InputUrl = "https://example.com/file.zip", + AuthType = AuthenticationType.BasicAuth, + AuthUsername = "", + AuthPassword = "testpass" + }; + + // Act + HttpRequestAuthenticator.ApplyAuthentication(request, queueItem); + + // Assert + Assert.Null(request.Headers.Authorization); + } + + [Fact] + public void ApplyAuthentication_BearerToken_SetsAuthorizationHeader() + { + // Arrange + var request = new HttpRequestMessage(HttpMethod.Get, "https://example.com/file.zip"); + var queueItem = new FileDownloadQueueItem + { + InputUrl = "https://example.com/file.zip", + AuthType = AuthenticationType.BearerToken, + AuthBearerToken = "my-jwt-token-12345" + }; + + // Act + HttpRequestAuthenticator.ApplyAuthentication(request, queueItem); + + // Assert + Assert.NotNull(request.Headers.Authorization); + Assert.Equal("Bearer", request.Headers.Authorization.Scheme); + Assert.Equal("my-jwt-token-12345", request.Headers.Authorization.Parameter); + } + + [Fact] + public void ApplyAuthentication_BearerToken_WithEmptyToken_DoesNotSetHeader() + { + // Arrange + var request = new HttpRequestMessage(HttpMethod.Get, "https://example.com/file.zip"); + var queueItem = new FileDownloadQueueItem + { + InputUrl = "https://example.com/file.zip", + AuthType = AuthenticationType.BearerToken, + AuthBearerToken = "" + }; + + // Act + HttpRequestAuthenticator.ApplyAuthentication(request, queueItem); + + // Assert + Assert.Null(request.Headers.Authorization); + } + + [Fact] + public void ApplyAuthentication_Cookie_SetsCookieHeader() + { + // Arrange + var request = new HttpRequestMessage(HttpMethod.Get, "https://example.com/file.zip"); + var queueItem = new FileDownloadQueueItem + { + InputUrl = "https://example.com/file.zip", + AuthType = AuthenticationType.Cookie, + AuthCookies = "session_id=abc123; user_token=xyz789" + }; + + // Act + HttpRequestAuthenticator.ApplyAuthentication(request, queueItem); + + // Assert + Assert.True(request.Headers.Contains("Cookie")); + var cookieValues = request.Headers.GetValues("Cookie"); + Assert.Contains("session_id=abc123; user_token=xyz789", cookieValues); + } + + [Fact] + public void ApplyAuthentication_Cookie_WithEmptyCookies_DoesNotSetHeader() + { + // Arrange + var request = new HttpRequestMessage(HttpMethod.Get, "https://example.com/file.zip"); + var queueItem = new FileDownloadQueueItem + { + InputUrl = "https://example.com/file.zip", + AuthType = AuthenticationType.Cookie, + AuthCookies = "" + }; + + // Act + HttpRequestAuthenticator.ApplyAuthentication(request, queueItem); + + // Assert + Assert.False(request.Headers.Contains("Cookie")); + } + + [Fact] + public void ApplyAuthentication_CustomHeaders_SetsMultipleHeaders() + { + // Arrange + var request = new HttpRequestMessage(HttpMethod.Get, "https://example.com/file.zip"); + var queueItem = new FileDownloadQueueItem + { + InputUrl = "https://example.com/file.zip", + AuthType = AuthenticationType.CustomHeaders, + AuthCustomHeaders = "{\"X-Api-Key\": \"my-api-key\", \"X-Custom-Auth\": \"custom-value\"}" + }; + + // Act + HttpRequestAuthenticator.ApplyAuthentication(request, queueItem); + + // Assert + Assert.True(request.Headers.Contains("X-Api-Key")); + Assert.True(request.Headers.Contains("X-Custom-Auth")); + Assert.Equal("my-api-key", request.Headers.GetValues("X-Api-Key").First()); + Assert.Equal("custom-value", request.Headers.GetValues("X-Custom-Auth").First()); + } + + [Fact] + public void ApplyAuthentication_CustomHeaders_WithInvalidJson_DoesNotThrow() + { + // Arrange + var request = new HttpRequestMessage(HttpMethod.Get, "https://example.com/file.zip"); + var queueItem = new FileDownloadQueueItem + { + InputUrl = "https://example.com/file.zip", + AuthType = AuthenticationType.CustomHeaders, + AuthCustomHeaders = "not valid json" + }; + + // Act & Assert - should not throw + var exception = Record.Exception(() => HttpRequestAuthenticator.ApplyAuthentication(request, queueItem)); + Assert.Null(exception); + } + + [Fact] + public void ApplyAuthentication_CustomHeaders_WithEmptyHeaders_DoesNotThrow() + { + // Arrange + var request = new HttpRequestMessage(HttpMethod.Get, "https://example.com/file.zip"); + var queueItem = new FileDownloadQueueItem + { + InputUrl = "https://example.com/file.zip", + AuthType = AuthenticationType.CustomHeaders, + AuthCustomHeaders = "" + }; + + // Act & Assert - should not throw + var exception = Record.Exception(() => HttpRequestAuthenticator.ApplyAuthentication(request, queueItem)); + Assert.Null(exception); + } + + [Fact] + public void ApplyAuthentication_None_DoesNotModifyRequest() + { + // Arrange + var request = new HttpRequestMessage(HttpMethod.Get, "https://example.com/file.zip"); + var queueItem = new FileDownloadQueueItem + { + InputUrl = "https://example.com/file.zip", + AuthType = AuthenticationType.None + }; + + // Act + HttpRequestAuthenticator.ApplyAuthentication(request, queueItem); + + // Assert + Assert.Null(request.Headers.Authorization); + Assert.False(request.Headers.Contains("Cookie")); + } + + [Fact] + public void ApplyAuthentication_NullRequest_DoesNotThrow() + { + // Arrange + var queueItem = new FileDownloadQueueItem + { + InputUrl = "https://example.com/file.zip", + AuthType = AuthenticationType.BasicAuth, + AuthUsername = "user", + AuthPassword = "pass" + }; + + // Act & Assert - should not throw + var exception = Record.Exception(() => HttpRequestAuthenticator.ApplyAuthentication(null!, queueItem)); + Assert.Null(exception); + } + + [Fact] + public void ApplyAuthentication_NullQueueItem_DoesNotThrow() + { + // Arrange + var request = new HttpRequestMessage(HttpMethod.Get, "https://example.com/file.zip"); + + // Act & Assert - should not throw + var exception = Record.Exception(() => HttpRequestAuthenticator.ApplyAuthentication(request, null!)); + Assert.Null(exception); + } + + [Theory] + [InlineData(AuthenticationType.BasicAuth, true)] + [InlineData(AuthenticationType.BearerToken, true)] + [InlineData(AuthenticationType.Cookie, true)] + [InlineData(AuthenticationType.CustomHeaders, true)] + [InlineData(AuthenticationType.None, false)] + public void HasAuthentication_ReturnsCorrectValue_ForAuthType(AuthenticationType authType, bool expected) + { + // Arrange + var queueItem = new FileDownloadQueueItem + { + AuthType = authType + }; + + // Assert + Assert.Equal(expected, queueItem.HasAuthentication); + } + } +} diff --git a/SaveHere/SaveHere/Components/Download/DownloadFromDirectLink.razor b/SaveHere/SaveHere/Components/Download/DownloadFromDirectLink.razor index 99f281a..aeedef9 100644 --- a/SaveHere/SaveHere/Components/Download/DownloadFromDirectLink.razor +++ b/SaveHere/SaveHere/Components/Download/DownloadFromDirectLink.razor @@ -136,6 +136,68 @@ + + + + Authentication Settings + + + + None + Basic Auth + Bearer Token + Cookies + Custom Headers + + + + + + + @if (_authType == AuthenticationType.BasicAuth) + { + + + + + + + + + } + else if (_authType == AuthenticationType.BearerToken) + { + + } + else if (_authType == AuthenticationType.Cookie) + { + + } + else if (_authType == AuthenticationType.CustomHeaders) + { + + } @@ -354,6 +416,14 @@ private bool _useHttp2 = true; private bool _enableCompression = true; + // Authentication settings + private AuthenticationType _authType = AuthenticationType.None; + private string? _authUsername; + private string? _authPassword; + private string? _authBearerToken; + private string? _authCookies; + private string? _authCustomHeaders; + private readonly ChartOptions _chartOptions = new() { LineStrokeWidth = 3, @@ -612,7 +682,13 @@ ParallelConnections = _parallelConnections, BufferSizeKB = _bufferSizeKB, UseHttp2 = _useHttp2, - EnableCompression = _enableCompression + EnableCompression = _enableCompression, + AuthType = _authType, + AuthUsername = _authUsername, + AuthPassword = _authPassword, + AuthBearerToken = _authBearerToken, + AuthCookies = _authCookies, + AuthCustomHeaders = _authCustomHeaders }; _context.FileDownloadQueueItems.Add(newFileDownload); await _context.SaveChangesAsync(); diff --git a/SaveHere/SaveHere/Helpers/HttpRequestAuthenticator.cs b/SaveHere/SaveHere/Helpers/HttpRequestAuthenticator.cs new file mode 100644 index 0000000..66032ba --- /dev/null +++ b/SaveHere/SaveHere/Helpers/HttpRequestAuthenticator.cs @@ -0,0 +1,76 @@ +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using SaveHere.Models; + +namespace SaveHere.Helpers +{ + /// + /// Helper class to apply authentication settings to HTTP requests. + /// + public static class HttpRequestAuthenticator + { + /// + /// Applies the authentication settings from a queue item to an HTTP request. + /// + /// The HTTP request to modify. + /// The queue item containing authentication settings. + public static void ApplyAuthentication(HttpRequestMessage request, FileDownloadQueueItem queueItem) + { + if (request == null || queueItem == null) + return; + + switch (queueItem.AuthType) + { + case AuthenticationType.BasicAuth: + if (!string.IsNullOrEmpty(queueItem.AuthUsername)) + { + var credentials = Convert.ToBase64String( + Encoding.UTF8.GetBytes($"{queueItem.AuthUsername}:{queueItem.AuthPassword ?? string.Empty}")); + request.Headers.Authorization = new AuthenticationHeaderValue("Basic", credentials); + } + break; + + case AuthenticationType.BearerToken: + if (!string.IsNullOrEmpty(queueItem.AuthBearerToken)) + { + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", queueItem.AuthBearerToken); + } + break; + + case AuthenticationType.Cookie: + if (!string.IsNullOrEmpty(queueItem.AuthCookies)) + { + request.Headers.TryAddWithoutValidation("Cookie", queueItem.AuthCookies); + } + break; + + case AuthenticationType.CustomHeaders: + if (!string.IsNullOrEmpty(queueItem.AuthCustomHeaders)) + { + try + { + var headers = JsonSerializer.Deserialize>(queueItem.AuthCustomHeaders); + if (headers != null) + { + foreach (var (key, value) in headers) + { + request.Headers.TryAddWithoutValidation(key, value); + } + } + } + catch (JsonException) + { + // Invalid JSON, silently ignore + } + } + break; + + case AuthenticationType.None: + default: + // No authentication needed + break; + } + } + } +} diff --git a/SaveHere/SaveHere/Migrations/20260118083444_AddDownloadAuthentication.Designer.cs b/SaveHere/SaveHere/Migrations/20260118083444_AddDownloadAuthentication.Designer.cs new file mode 100644 index 0000000..3ad21d9 --- /dev/null +++ b/SaveHere/SaveHere/Migrations/20260118083444_AddDownloadAuthentication.Designer.cs @@ -0,0 +1,414 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using SaveHere.Models.db; + +#nullable disable + +namespace SaveHere.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260118083444_AddDownloadAuthentication")] + partial class AddDownloadAuthentication + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "8.0.23"); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("SaveHere.Models.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("IsEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("SaveHere.Models.FileDownloadQueueItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AuthBearerToken") + .HasColumnType("TEXT"); + + b.Property("AuthCookies") + .HasColumnType("TEXT"); + + b.Property("AuthCustomHeaders") + .HasColumnType("TEXT"); + + b.Property("AuthPassword") + .HasColumnType("TEXT"); + + b.Property("AuthType") + .HasColumnType("INTEGER"); + + b.Property("AuthUsername") + .HasColumnType("TEXT"); + + b.Property("AverageDownloadSpeed") + .HasColumnType("REAL"); + + b.Property("BufferSizeKB") + .HasColumnType("INTEGER"); + + b.Property("CurrentDownloadSpeed") + .HasColumnType("REAL"); + + b.Property("CustomFileName") + .HasColumnType("TEXT"); + + b.Property("DownloadFolder") + .HasColumnType("TEXT"); + + b.Property("EnableCompression") + .HasColumnType("INTEGER"); + + b.Property("InputUrl") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("MaxBytesPerSecond") + .HasColumnType("INTEGER"); + + b.Property("ParallelConnections") + .HasColumnType("INTEGER"); + + b.Property("ProgressPercentage") + .HasColumnType("INTEGER"); + + b.Property("SpeedHistory") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("SupportsRangeRequests") + .HasColumnType("INTEGER"); + + b.Property("UseHttp2") + .HasColumnType("INTEGER"); + + b.Property("bShouldGetFilenameFromHttpHeaders") + .HasColumnType("INTEGER"); + + b.Property("bShowMoreOptions") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("FileDownloadQueueItems"); + }); + + modelBuilder.Entity("SaveHere.Models.RegistrationSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("IsRegistrationEnabled") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("RegistrationSettings"); + + b.HasData( + new + { + Id = 1, + IsRegistrationEnabled = false + }); + }); + + modelBuilder.Entity("SaveHere.Models.SaveHere.Models.YoutubeDownloadQueueItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CustomFileName") + .HasColumnType("TEXT"); + + b.Property("DownloadFolder") + .HasColumnType("TEXT"); + + b.Property("OutputLog") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PersistedLog") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Proxy") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Quality") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("SubtitleLanguage") + .HasColumnType("TEXT"); + + b.Property("Url") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("YoutubeDownloadQueueItems"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("SaveHere.Models.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("SaveHere.Models.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("SaveHere.Models.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("SaveHere.Models.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/SaveHere/SaveHere/Migrations/20260118083444_AddDownloadAuthentication.cs b/SaveHere/SaveHere/Migrations/20260118083444_AddDownloadAuthentication.cs new file mode 100644 index 0000000..4eccb4d --- /dev/null +++ b/SaveHere/SaveHere/Migrations/20260118083444_AddDownloadAuthentication.cs @@ -0,0 +1,164 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace SaveHere.Migrations +{ + /// + public partial class AddDownloadAuthentication : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.RenameColumn( + name: "CustomFilename", + table: "YoutubeDownloadQueueItems", + newName: "CustomFileName"); + + migrationBuilder.AddColumn( + name: "SubtitleLanguage", + table: "YoutubeDownloadQueueItems", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "AuthBearerToken", + table: "FileDownloadQueueItems", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "AuthCookies", + table: "FileDownloadQueueItems", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "AuthCustomHeaders", + table: "FileDownloadQueueItems", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "AuthPassword", + table: "FileDownloadQueueItems", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "AuthType", + table: "FileDownloadQueueItems", + type: "INTEGER", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "AuthUsername", + table: "FileDownloadQueueItems", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "BufferSizeKB", + table: "FileDownloadQueueItems", + type: "INTEGER", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "CustomFileName", + table: "FileDownloadQueueItems", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "EnableCompression", + table: "FileDownloadQueueItems", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "ParallelConnections", + table: "FileDownloadQueueItems", + type: "INTEGER", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "SupportsRangeRequests", + table: "FileDownloadQueueItems", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "UseHttp2", + table: "FileDownloadQueueItems", + type: "INTEGER", + nullable: false, + defaultValue: false); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "SubtitleLanguage", + table: "YoutubeDownloadQueueItems"); + + migrationBuilder.DropColumn( + name: "AuthBearerToken", + table: "FileDownloadQueueItems"); + + migrationBuilder.DropColumn( + name: "AuthCookies", + table: "FileDownloadQueueItems"); + + migrationBuilder.DropColumn( + name: "AuthCustomHeaders", + table: "FileDownloadQueueItems"); + + migrationBuilder.DropColumn( + name: "AuthPassword", + table: "FileDownloadQueueItems"); + + migrationBuilder.DropColumn( + name: "AuthType", + table: "FileDownloadQueueItems"); + + migrationBuilder.DropColumn( + name: "AuthUsername", + table: "FileDownloadQueueItems"); + + migrationBuilder.DropColumn( + name: "BufferSizeKB", + table: "FileDownloadQueueItems"); + + migrationBuilder.DropColumn( + name: "CustomFileName", + table: "FileDownloadQueueItems"); + + migrationBuilder.DropColumn( + name: "EnableCompression", + table: "FileDownloadQueueItems"); + + migrationBuilder.DropColumn( + name: "ParallelConnections", + table: "FileDownloadQueueItems"); + + migrationBuilder.DropColumn( + name: "SupportsRangeRequests", + table: "FileDownloadQueueItems"); + + migrationBuilder.DropColumn( + name: "UseHttp2", + table: "FileDownloadQueueItems"); + + migrationBuilder.RenameColumn( + name: "CustomFileName", + table: "YoutubeDownloadQueueItems", + newName: "CustomFilename"); + } + } +} diff --git a/SaveHere/SaveHere/Migrations/AppDbContextModelSnapshot.cs b/SaveHere/SaveHere/Migrations/AppDbContextModelSnapshot.cs index 560c02b..20a5048 100644 --- a/SaveHere/SaveHere/Migrations/AppDbContextModelSnapshot.cs +++ b/SaveHere/SaveHere/Migrations/AppDbContextModelSnapshot.cs @@ -15,7 +15,7 @@ partial class AppDbContextModelSnapshot : ModelSnapshot protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 - modelBuilder.HasAnnotation("ProductVersion", "8.0.14"); + modelBuilder.HasAnnotation("ProductVersion", "8.0.23"); modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => { @@ -218,15 +218,42 @@ protected override void BuildModel(ModelBuilder modelBuilder) .ValueGeneratedOnAdd() .HasColumnType("INTEGER"); + b.Property("AuthBearerToken") + .HasColumnType("TEXT"); + + b.Property("AuthCookies") + .HasColumnType("TEXT"); + + b.Property("AuthCustomHeaders") + .HasColumnType("TEXT"); + + b.Property("AuthPassword") + .HasColumnType("TEXT"); + + b.Property("AuthType") + .HasColumnType("INTEGER"); + + b.Property("AuthUsername") + .HasColumnType("TEXT"); + b.Property("AverageDownloadSpeed") .HasColumnType("REAL"); + b.Property("BufferSizeKB") + .HasColumnType("INTEGER"); + b.Property("CurrentDownloadSpeed") .HasColumnType("REAL"); + b.Property("CustomFileName") + .HasColumnType("TEXT"); + b.Property("DownloadFolder") .HasColumnType("TEXT"); + b.Property("EnableCompression") + .HasColumnType("INTEGER"); + b.Property("InputUrl") .IsRequired() .HasColumnType("TEXT"); @@ -234,6 +261,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("MaxBytesPerSecond") .HasColumnType("INTEGER"); + b.Property("ParallelConnections") + .HasColumnType("INTEGER"); + b.Property("ProgressPercentage") .HasColumnType("INTEGER"); @@ -244,6 +274,12 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("Status") .HasColumnType("INTEGER"); + b.Property("SupportsRangeRequests") + .HasColumnType("INTEGER"); + + b.Property("UseHttp2") + .HasColumnType("INTEGER"); + b.Property("bShouldGetFilenameFromHttpHeaders") .HasColumnType("INTEGER"); @@ -282,6 +318,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) .ValueGeneratedOnAdd() .HasColumnType("INTEGER"); + b.Property("CustomFileName") + .HasColumnType("TEXT"); + b.Property("DownloadFolder") .HasColumnType("TEXT"); @@ -304,6 +343,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("Status") .HasColumnType("INTEGER"); + b.Property("SubtitleLanguage") + .HasColumnType("TEXT"); + b.Property("Url") .IsRequired() .HasColumnType("TEXT"); diff --git a/SaveHere/SaveHere/Models/AuthenticationType.cs b/SaveHere/SaveHere/Models/AuthenticationType.cs new file mode 100644 index 0000000..854ce3b --- /dev/null +++ b/SaveHere/SaveHere/Models/AuthenticationType.cs @@ -0,0 +1,33 @@ +namespace SaveHere.Models +{ + /// + /// Authentication types supported for HTTP downloads. + /// + public enum AuthenticationType + { + /// + /// No authentication required. + /// + None = 0, + + /// + /// HTTP Basic Authentication (username/password). + /// + BasicAuth = 1, + + /// + /// Bearer token authentication (OAuth, JWT, etc.). + /// + BearerToken = 2, + + /// + /// Cookie-based authentication. + /// + Cookie = 3, + + /// + /// Custom HTTP headers for authentication. + /// + CustomHeaders = 4 + } +} diff --git a/SaveHere/SaveHere/Models/FileDownloadQueueItem.cs b/SaveHere/SaveHere/Models/FileDownloadQueueItem.cs index 5c89df0..6261d7d 100644 --- a/SaveHere/SaveHere/Models/FileDownloadQueueItem.cs +++ b/SaveHere/SaveHere/Models/FileDownloadQueueItem.cs @@ -1,4 +1,5 @@ using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; namespace SaveHere.Models { @@ -30,5 +31,16 @@ public class FileDownloadQueueItem public bool UseHttp2 { get; set; } = true; public bool EnableCompression { get; set; } = true; public bool SupportsRangeRequests { get; set; } = false; + + // Authentication settings + public AuthenticationType AuthType { get; set; } = AuthenticationType.None; + public string? AuthUsername { get; set; } + public string? AuthPassword { get; set; } + public string? AuthBearerToken { get; set; } + public string? AuthCookies { get; set; } + public string? AuthCustomHeaders { get; set; } + + [NotMapped] + public bool HasAuthentication => AuthType != AuthenticationType.None; } } diff --git a/SaveHere/SaveHere/Services/DownloadQueueService.cs b/SaveHere/SaveHere/Services/DownloadQueueService.cs index 5918564..7e47383 100644 --- a/SaveHere/SaveHere/Services/DownloadQueueService.cs +++ b/SaveHere/SaveHere/Services/DownloadQueueService.cs @@ -5,6 +5,7 @@ using System.Net; using System.Web; using System.Net.Http.Headers; +using SaveHere.Helpers; using SaveHere.Services; namespace SaveHere.Services @@ -185,7 +186,7 @@ public async Task DownloadFile(FileDownloadQueueItem queueItem, CancellationToke // Check if server supports range requests for parallel downloads if (queueItem.ParallelConnections > 1) { - queueItem.SupportsRangeRequests = await CheckRangeSupport(queueItem.InputUrl, cancellationToken); + queueItem.SupportsRangeRequests = await CheckRangeSupport(queueItem, cancellationToken); if (queueItem.SupportsRangeRequests) { await DownloadFileParallel(queueItem, cancellationToken); @@ -224,7 +225,9 @@ public async Task DownloadFile(FileDownloadQueueItem queueItem, CancellationToke : queueItem.CustomFileName; - var response = await httpClient.GetAsync(queueItem.InputUrl, HttpCompletionOption.ResponseHeadersRead, cancellationToken); + var initialRequest = new HttpRequestMessage(HttpMethod.Get, queueItem.InputUrl); + HttpRequestAuthenticator.ApplyAuthentication(initialRequest, queueItem); + var response = await httpClient.SendAsync(initialRequest, HttpCompletionOption.ResponseHeadersRead, cancellationToken); response.EnsureSuccessStatusCode(); if (string.IsNullOrWhiteSpace(queueItem.CustomFileName) && queueItem.bShouldGetFilenameFromHttpHeaders) @@ -286,6 +289,7 @@ public async Task DownloadFile(FileDownloadQueueItem queueItem, CancellationToke } var requestMessage = new HttpRequestMessage(HttpMethod.Get, queueItem.InputUrl); + HttpRequestAuthenticator.ApplyAuthentication(requestMessage, queueItem); if (totalBytesRead > 0) { @@ -546,9 +550,32 @@ public async Task CheckRangeSupport(string url, CancellationToken cancella } } + /// + /// Checks if the server supports range requests, with authentication support. + /// + public async Task CheckRangeSupport(FileDownloadQueueItem queueItem, CancellationToken cancellationToken) + { + try + { + var request = new HttpRequestMessage(HttpMethod.Head, queueItem.InputUrl); + HttpRequestAuthenticator.ApplyAuthentication(request, queueItem); + request.Headers.Range = new RangeHeaderValue(0, 0); + + using var response = await _httpClient.SendAsync(request, cancellationToken); + return response.StatusCode == HttpStatusCode.PartialContent || + response.Headers.AcceptRanges?.Contains("bytes") == true; + } + catch + { + return false; + } + } + public async Task DownloadFileParallel(FileDownloadQueueItem queueItem, CancellationToken cancellationToken) { - var response = await _httpClient.SendAsync(new HttpRequestMessage(HttpMethod.Head, queueItem.InputUrl), cancellationToken); + var headRequest = new HttpRequestMessage(HttpMethod.Head, queueItem.InputUrl); + HttpRequestAuthenticator.ApplyAuthentication(headRequest, queueItem); + var response = await _httpClient.SendAsync(headRequest, cancellationToken); var contentLength = response.Content.Headers.ContentLength; if (!contentLength.HasValue) @@ -631,6 +658,7 @@ public async Task DownloadFileParallel(FileDownloadQueueItem queueItem, Cancella private async Task DownloadChunk(FileDownloadQueueItem queueItem, long start, long end, string chunkFile, int chunkIndex, CancellationToken cancellationToken, ParallelDownloadProgress progressTracker) { var request = new HttpRequestMessage(HttpMethod.Get, queueItem.InputUrl); + HttpRequestAuthenticator.ApplyAuthentication(request, queueItem); request.Headers.Range = new RangeHeaderValue(start, end); using var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken);