diff --git a/src/BaGet.Core/Entities/IPackageContentsContext.cs b/src/BaGet.Core/Entities/IPackageContentsContext.cs new file mode 100644 index 000000000..add10b6a7 --- /dev/null +++ b/src/BaGet.Core/Entities/IPackageContentsContext.cs @@ -0,0 +1,13 @@ +using System.Threading; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; + +namespace BaGet.Core +{ + public interface IPackageContentsContext + { + DbSet PackageContents { get; set; } + + Task SaveChangesAsync(CancellationToken cancellationToken); + } +} diff --git a/src/BaGet.Core/Entities/PackageContents.cs b/src/BaGet.Core/Entities/PackageContents.cs new file mode 100644 index 000000000..6c5a9ec2f --- /dev/null +++ b/src/BaGet.Core/Entities/PackageContents.cs @@ -0,0 +1,11 @@ +namespace BaGet.Core +{ + public class PackageContents + { + public int Key { get; set; } + + public string Path { get; set; } + + public byte[] Data { get; set; } + } +} diff --git a/src/BaGet.Core/Extensions/BaGetApplicationExtensions.cs b/src/BaGet.Core/Extensions/BaGetApplicationExtensions.cs index 7dc2967f3..822c56112 100644 --- a/src/BaGet.Core/Extensions/BaGetApplicationExtensions.cs +++ b/src/BaGet.Core/Extensions/BaGetApplicationExtensions.cs @@ -22,6 +22,12 @@ public static BaGetApplication AddFileStorage(this BaGetApplication app) return app; } + public static BaGetApplication AddMySqlStorage(this BaGetApplication app) + { + app.Services.TryAddTransient(provider => provider.GetRequiredService()); + return app; + } + public static BaGetApplication AddNullStorage(this BaGetApplication app) { app.Services.TryAddTransient(provider => provider.GetRequiredService()); diff --git a/src/BaGet.Core/Extensions/DependencyInjectionExtensions.cs b/src/BaGet.Core/Extensions/DependencyInjectionExtensions.cs index a56c41eab..0aa8de2a0 100644 --- a/src/BaGet.Core/Extensions/DependencyInjectionExtensions.cs +++ b/src/BaGet.Core/Extensions/DependencyInjectionExtensions.cs @@ -100,6 +100,7 @@ private static void AddBaGetServices(this IServiceCollection services) services.TryAddTransient(); services.TryAddTransient(); + services.TryAddTransient(); services.TryAddTransient(); services.TryAddTransient(); services.TryAddTransient(); @@ -133,6 +134,11 @@ private static void AddDefaultProviders(this IServiceCollection services) return provider.GetRequiredService(); } + if (configuration.HasStorageType("mysql")) + { + return provider.GetRequiredService(); + } + if (configuration.HasStorageType("null")) { return provider.GetRequiredService(); diff --git a/src/BaGet.Core/Storage/DatabaseStorageService.cs b/src/BaGet.Core/Storage/DatabaseStorageService.cs new file mode 100644 index 000000000..7206cfc87 --- /dev/null +++ b/src/BaGet.Core/Storage/DatabaseStorageService.cs @@ -0,0 +1,73 @@ +using System.Linq; +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; + +namespace BaGet.Core +{ + public class DatabaseStorageService : IStorageService + { + private readonly IPackageContentsContext _context; + + public DatabaseStorageService(IPackageContentsContext context) + { + if (context == null) throw new ArgumentException(nameof(context)); + + _context = context; + } + + public async Task DeleteAsync(string path, CancellationToken cancellationToken = default) + { + var contents = await _context.PackageContents.SingleOrDefaultAsync(p => p.Path == path, cancellationToken); + if (contents != null) + { + _context.PackageContents.Remove(contents); + await _context.SaveChangesAsync(cancellationToken); + } + } + + public async Task GetAsync(string path, CancellationToken cancellationToken = default) + { + var contents = await _context.PackageContents.SingleOrDefaultAsync(p => p.Path == path, cancellationToken); + if (contents == null) + { + throw new Exception($"PackageContents record not found for path: {path}"); + } + var ms = new MemoryStream(contents.Data); + return ms; + } + + public Task GetDownloadUriAsync(string path, CancellationToken cancellationToken = default) + { + throw new NotImplementedException(); + } + + public async Task PutAsync(string path, Stream content, string contentType, CancellationToken cancellationToken = default) + { + byte[] newData; + using (var binaryReader = new BinaryReader(content)) + { + newData = binaryReader.ReadBytes((int)content.Length); + } + + var existingContents = await _context.PackageContents.SingleOrDefaultAsync(p => p.Path == path, cancellationToken); + if (existingContents != null) + { + return existingContents.Data.SequenceEqual(newData) + ? StoragePutResult.AlreadyExists + : StoragePutResult.Conflict; + } + + _context.PackageContents.Add(new PackageContents + { + Path = path, + Data = newData, + }); + await _context.SaveChangesAsync(cancellationToken); + + return StoragePutResult.Success; + } + } +} diff --git a/src/BaGet.Database.MySql/Migrations/20230710152239_CreatePackageContentsTable.Designer.cs b/src/BaGet.Database.MySql/Migrations/20230710152239_CreatePackageContentsTable.Designer.cs new file mode 100644 index 000000000..b039b6fa2 --- /dev/null +++ b/src/BaGet.Database.MySql/Migrations/20230710152239_CreatePackageContentsTable.Designer.cs @@ -0,0 +1,260 @@ +// +using System; +using BaGet.Database.MySql; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace BaGet.Database.MySql.Migrations +{ + [DbContext(typeof(MySqlContext))] + [Migration("20230710152239_CreatePackageContentsTable")] + partial class CreatePackageContentsTable + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "3.1.18") + .HasAnnotation("Relational:MaxIdentifierLength", 64); + + modelBuilder.Entity("BaGet.Core.Package", b => + { + b.Property("Key") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + b.Property("Authors") + .HasColumnType("longtext CHARACTER SET utf8mb4") + .HasMaxLength(4000); + + b.Property("Description") + .HasColumnType("longtext CHARACTER SET utf8mb4") + .HasMaxLength(4000); + + b.Property("Downloads") + .HasColumnType("bigint"); + + b.Property("HasEmbeddedIcon") + .HasColumnType("tinyint(1)"); + + b.Property("HasReadme") + .HasColumnType("tinyint(1)"); + + b.Property("IconUrl") + .HasColumnType("longtext CHARACTER SET utf8mb4") + .HasMaxLength(4000); + + b.Property("Id") + .IsRequired() + .HasColumnType("varchar(128) CHARACTER SET utf8mb4") + .HasMaxLength(128); + + b.Property("IsPrerelease") + .HasColumnType("tinyint(1)"); + + b.Property("Language") + .HasColumnType("varchar(20) CHARACTER SET utf8mb4") + .HasMaxLength(20); + + b.Property("LicenseUrl") + .HasColumnType("longtext CHARACTER SET utf8mb4") + .HasMaxLength(4000); + + b.Property("Listed") + .HasColumnType("tinyint(1)"); + + b.Property("MinClientVersion") + .HasColumnType("varchar(44) CHARACTER SET utf8mb4") + .HasMaxLength(44); + + b.Property("NormalizedVersionString") + .IsRequired() + .HasColumnName("Version") + .HasColumnType("varchar(64) CHARACTER SET utf8mb4") + .HasMaxLength(64); + + b.Property("OriginalVersionString") + .HasColumnName("OriginalVersion") + .HasColumnType("varchar(64) CHARACTER SET utf8mb4") + .HasMaxLength(64); + + b.Property("ProjectUrl") + .HasColumnType("longtext CHARACTER SET utf8mb4") + .HasMaxLength(4000); + + b.Property("Published") + .HasColumnType("datetime(6)"); + + b.Property("ReleaseNotes") + .HasColumnName("ReleaseNotes") + .HasColumnType("longtext CHARACTER SET utf8mb4"); + + b.Property("RepositoryType") + .HasColumnType("varchar(100) CHARACTER SET utf8mb4") + .HasMaxLength(100); + + b.Property("RepositoryUrl") + .HasColumnType("longtext CHARACTER SET utf8mb4") + .HasMaxLength(4000); + + b.Property("RequireLicenseAcceptance") + .HasColumnType("tinyint(1)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("timestamp(6)"); + + b.Property("SemVerLevel") + .HasColumnType("int"); + + b.Property("Summary") + .HasColumnType("longtext CHARACTER SET utf8mb4") + .HasMaxLength(4000); + + b.Property("Tags") + .HasColumnType("longtext CHARACTER SET utf8mb4") + .HasMaxLength(4000); + + b.Property("Title") + .HasColumnType("varchar(256) CHARACTER SET utf8mb4") + .HasMaxLength(256); + + b.HasKey("Key"); + + b.HasIndex("Id"); + + b.HasIndex("Id", "NormalizedVersionString") + .IsUnique(); + + b.ToTable("Packages"); + }); + + modelBuilder.Entity("BaGet.Core.PackageContents", b => + { + b.Property("Key") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + b.Property("Data") + .HasColumnType("longblob"); + + b.Property("Path") + .HasColumnType("varchar(255)"); + + b.HasKey("Key"); + + b.HasIndex("Path") + .IsUnique(); + + b.ToTable("PackageContents"); + }); + + modelBuilder.Entity("BaGet.Core.PackageDependency", b => + { + b.Property("Key") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + b.Property("Id") + .HasColumnType("varchar(128) CHARACTER SET utf8mb4") + .HasMaxLength(128); + + b.Property("PackageKey") + .HasColumnType("int"); + + b.Property("TargetFramework") + .HasColumnType("varchar(256) CHARACTER SET utf8mb4") + .HasMaxLength(256); + + b.Property("VersionRange") + .HasColumnType("varchar(256) CHARACTER SET utf8mb4") + .HasMaxLength(256); + + b.HasKey("Key"); + + b.HasIndex("Id"); + + b.HasIndex("PackageKey"); + + b.ToTable("PackageDependencies"); + }); + + modelBuilder.Entity("BaGet.Core.PackageType", b => + { + b.Property("Key") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + b.Property("Name") + .HasColumnType("varchar(512) CHARACTER SET utf8mb4") + .HasMaxLength(512); + + b.Property("PackageKey") + .HasColumnType("int"); + + b.Property("Version") + .HasColumnType("varchar(64) CHARACTER SET utf8mb4") + .HasMaxLength(64); + + b.HasKey("Key"); + + b.HasIndex("Name"); + + b.HasIndex("PackageKey"); + + b.ToTable("PackageTypes"); + }); + + modelBuilder.Entity("BaGet.Core.TargetFramework", b => + { + b.Property("Key") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + b.Property("Moniker") + .HasColumnType("varchar(256) CHARACTER SET utf8mb4") + .HasMaxLength(256); + + b.Property("PackageKey") + .HasColumnType("int"); + + b.HasKey("Key"); + + b.HasIndex("Moniker"); + + b.HasIndex("PackageKey"); + + b.ToTable("TargetFrameworks"); + }); + + modelBuilder.Entity("BaGet.Core.PackageDependency", b => + { + b.HasOne("BaGet.Core.Package", "Package") + .WithMany("Dependencies") + .HasForeignKey("PackageKey"); + }); + + modelBuilder.Entity("BaGet.Core.PackageType", b => + { + b.HasOne("BaGet.Core.Package", "Package") + .WithMany("PackageTypes") + .HasForeignKey("PackageKey") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("BaGet.Core.TargetFramework", b => + { + b.HasOne("BaGet.Core.Package", "Package") + .WithMany("TargetFrameworks") + .HasForeignKey("PackageKey") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/BaGet.Database.MySql/Migrations/20230710152239_CreatePackageContentsTable.cs b/src/BaGet.Database.MySql/Migrations/20230710152239_CreatePackageContentsTable.cs new file mode 100644 index 000000000..3d4fdd72e --- /dev/null +++ b/src/BaGet.Database.MySql/Migrations/20230710152239_CreatePackageContentsTable.cs @@ -0,0 +1,58 @@ +using System; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; + +namespace BaGet.Database.MySql.Migrations +{ + public partial class CreatePackageContentsTable : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "RowVersion", + table: "Packages", + rowVersion: true, + nullable: true, + oldClrType: typeof(DateTime), + oldType: "timestamp(6)", + oldNullable: true) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.ComputedColumn); + + migrationBuilder.CreateTable( + name: "PackageContents", + columns: table => new + { + Key = table.Column(nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + Path = table.Column(type: "varchar(255)", nullable: true), + Data = table.Column(nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_PackageContents", x => x.Key); + }); + + migrationBuilder.CreateIndex( + name: "IX_PackageContents_Path", + table: "PackageContents", + column: "Path", + unique: true); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "PackageContents"); + + migrationBuilder.AlterColumn( + name: "RowVersion", + table: "Packages", + type: "timestamp(6)", + nullable: true, + oldClrType: typeof(DateTime), + oldRowVersion: true, + oldNullable: true) + .OldAnnotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.ComputedColumn); + } + } +} diff --git a/src/BaGet.Database.MySql/Migrations/MySqlContextModelSnapshot.cs b/src/BaGet.Database.MySql/Migrations/MySqlContextModelSnapshot.cs index d9a6e93c8..85314793b 100644 --- a/src/BaGet.Database.MySql/Migrations/MySqlContextModelSnapshot.cs +++ b/src/BaGet.Database.MySql/Migrations/MySqlContextModelSnapshot.cs @@ -130,6 +130,26 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("Packages"); }); + modelBuilder.Entity("BaGet.Core.PackageContents", b => + { + b.Property("Key") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + b.Property("Data") + .HasColumnType("longblob"); + + b.Property("Path") + .HasColumnType("varchar(255)"); + + b.HasKey("Key"); + + b.HasIndex("Path") + .IsUnique(); + + b.ToTable("PackageContents"); + }); + modelBuilder.Entity("BaGet.Core.PackageDependency", b => { b.Property("Key") diff --git a/src/BaGet.Database.MySql/MySqlApplicationExtensions.cs b/src/BaGet.Database.MySql/MySqlApplicationExtensions.cs index 2156d90c3..c0fb5f2ee 100644 --- a/src/BaGet.Database.MySql/MySqlApplicationExtensions.cs +++ b/src/BaGet.Database.MySql/MySqlApplicationExtensions.cs @@ -18,6 +18,8 @@ public static BaGetApplication AddMySqlDatabase(this BaGetApplication app) options.UseMySql(databaseOptions.Value.ConnectionString); }); + app.Services.AddTransient(services => services.GetRequiredService()); + return app; } diff --git a/src/BaGet.Database.MySql/MySqlContext.cs b/src/BaGet.Database.MySql/MySqlContext.cs index 49bbb65fb..55426281d 100644 --- a/src/BaGet.Database.MySql/MySqlContext.cs +++ b/src/BaGet.Database.MySql/MySqlContext.cs @@ -1,16 +1,19 @@ using BaGet.Core; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; using MySql.Data.MySqlClient; namespace BaGet.Database.MySql { - public class MySqlContext : AbstractContext + public class MySqlContext : AbstractContext, IPackageContentsContext { /// /// The MySQL Server error code for when a unique constraint is violated. /// private const int UniqueConstraintViolationErrorCode = 1062; + public DbSet PackageContents { get; set; } + public MySqlContext(DbContextOptions options) : base(options) { } @@ -26,5 +29,21 @@ public override bool IsUniqueConstraintViolationException(DbUpdateException exce /// See: https://dev.mysql.com/doc/refman/8.0/en/subquery-restrictions.html /// public override bool SupportsLimitInSubqueries => false; + + protected override void OnModelCreating(ModelBuilder builder) + { + base.OnModelCreating(builder); + + builder.Entity(BuildPackageContentsEntity); + } + + private void BuildPackageContentsEntity(EntityTypeBuilder packageContents) + { + packageContents.HasKey(p => p.Key); + packageContents.HasIndex(p => new { p.Path }).IsUnique(); + + packageContents.Property(p => p.Path) + .HasColumnType("varchar(255)"); + } } } diff --git a/src/BaGet/ConfigureBaGetOptions.cs b/src/BaGet/ConfigureBaGetOptions.cs index b28a658d4..254afaf28 100644 --- a/src/BaGet/ConfigureBaGetOptions.cs +++ b/src/BaGet/ConfigureBaGetOptions.cs @@ -41,6 +41,7 @@ public class ConfigureBaGetOptions "AzureBlobStorage", "Filesystem", "GoogleCloud", + "MySql", "Null", }; diff --git a/src/BaGet/Startup.cs b/src/BaGet/Startup.cs index f637267bb..ff70fb35c 100644 --- a/src/BaGet/Startup.cs +++ b/src/BaGet/Startup.cs @@ -69,6 +69,7 @@ private void ConfigureBaGetApplication(BaGetApplication app) app.AddAwsS3Storage(); app.AddAzureBlobStorage(); app.AddGoogleCloudStorage(); + app.AddMySqlStorage(); // Add search providers. app.AddAzureSearch();