diff --git a/src/BaGet.Core/IUrlGenerator.cs b/src/BaGet.Core/IUrlGenerator.cs index 443dba796..f992e6287 100644 --- a/src/BaGet.Core/IUrlGenerator.cs +++ b/src/BaGet.Core/IUrlGenerator.cs @@ -101,5 +101,13 @@ public interface IUrlGenerator /// The package's ID /// The package's version string GetPackageIconDownloadUrl(string id, NuGetVersion version); + + /// + /// Get the package repackage url with a replacement token for the new version {newVersion} + /// + /// The package ID + /// The package version + /// The package new version + string GetPackageRepackageUrl(string id, NuGetVersion version, NuGetVersion newVersion); } } diff --git a/src/BaGet.Web/BaGetEndpointBuilder.cs b/src/BaGet.Web/BaGetEndpointBuilder.cs index d485eb40e..4e6b62dad 100644 --- a/src/BaGet.Web/BaGetEndpointBuilder.cs +++ b/src/BaGet.Web/BaGetEndpointBuilder.cs @@ -46,6 +46,13 @@ public void MapPackagePublishRoutes(IEndpointRouteBuilder endpoints) pattern: "api/v2/package/{id}/{version}", defaults: new { controller = "PackagePublish", action = "Relist" }, constraints: new { httpMethod = new HttpMethodRouteConstraint("POST") }); + + // This is an unofficial API to repackage a package as a new version + endpoints.MapControllerRoute( + name: Routes.RepackageRouteName, + pattern: "api/v2/package/{id}/{version}/repackage/{newVersion}", + defaults: new { controller = "PackagePublish", action = "Repackage" }, + constraints: new { httpMethod = new HttpMethodRouteConstraint("POST") }); } public void MapSymbolRoutes(IEndpointRouteBuilder endpoints) diff --git a/src/BaGet.Web/BaGetUrlGenerator.cs b/src/BaGet.Web/BaGetUrlGenerator.cs index 48b61bb57..d3c7a3f3c 100644 --- a/src/BaGet.Web/BaGetUrlGenerator.cs +++ b/src/BaGet.Web/BaGetUrlGenerator.cs @@ -161,5 +161,22 @@ private string AbsoluteUrl(string relativePath) "/", relativePath); } + + public string GetPackageRepackageUrl(string id, NuGetVersion version, NuGetVersion newVersion) + { + id = id.ToLowerInvariant(); + var versionString = version.ToNormalizedString().ToLowerInvariant(); + var newVersionString = newVersion.ToNormalizedString().ToLowerInvariant(); + + return _linkGenerator.GetUriByRouteValues( + _httpContextAccessor.HttpContext, + Routes.RepackageRouteName, + values: new + { + Id = id, + Version = versionString, + NewVersion = newVersionString + }); + } } } diff --git a/src/BaGet.Web/Controllers/PackagePublishController.cs b/src/BaGet.Web/Controllers/PackagePublishController.cs index 8adb0b5f4..efa028c47 100644 --- a/src/BaGet.Web/Controllers/PackagePublishController.cs +++ b/src/BaGet.Web/Controllers/PackagePublishController.cs @@ -1,10 +1,17 @@ using System; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Text; using System.Threading; using System.Threading.Tasks; +using System.Xml; +using System.Xml.Linq; using BaGet.Core; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +using NuGet.Packaging; using NuGet.Versioning; namespace BaGet.Web @@ -15,6 +22,7 @@ public class PackagePublishController : Controller private readonly IPackageIndexingService _indexer; private readonly IPackageDatabase _packages; private readonly IPackageDeletionService _deleteService; + private readonly IPackageStorageService _storageService; private readonly IOptionsSnapshot _options; private readonly ILogger _logger; @@ -23,6 +31,7 @@ public PackagePublishController( IPackageIndexingService indexer, IPackageDatabase packages, IPackageDeletionService deletionService, + IPackageStorageService storageService, IOptionsSnapshot options, ILogger logger) { @@ -30,6 +39,7 @@ public PackagePublishController( _indexer = indexer ?? throw new ArgumentNullException(nameof(indexer)); _packages = packages ?? throw new ArgumentNullException(nameof(packages)); _deleteService = deletionService ?? throw new ArgumentNullException(nameof(deletionService)); + _storageService = storageService ?? throw new ArgumentNullException(nameof(storageService)); _options = options ?? throw new ArgumentNullException(nameof(options)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } @@ -135,5 +145,84 @@ public async Task Relist(string id, string version, CancellationT return NotFound(); } } + + [HttpPost] + public async Task Repackage(string id, string version, string newVersion, CancellationToken cancellationToken) + { + if (_options.Value.IsReadOnlyMode) + { + return Unauthorized(); + } + + if (!NuGetVersion.TryParse(version, out var nugetVersion)) + { + return NotFound(); + } + + if (!NuGetVersion.TryParse(newVersion, out var newNugetVersion)) + { + return BadRequest("Invalid version"); + } + + if (!await _authentication.AuthenticateAsync(Request.GetApiKey(), cancellationToken)) + { + return Unauthorized(); + } + + var packageStream = await _storageService.GetPackageStreamAsync(id, nugetVersion, cancellationToken); + + if (packageStream == null) + { + return NotFound(); + } + + if(await _packages.ExistsAsync(id, newNugetVersion, cancellationToken)) + { + return BadRequest("Package version already exists"); + } + + using (var ms = new MemoryStream()) + { + await packageStream.CopyToAsync(ms); + using (var archive = new ZipArchive(ms, ZipArchiveMode.Update)) + { + var nuspecEntry = archive.Entries.FirstOrDefault(x => x.Name.EndsWith(".nuspec", StringComparison.OrdinalIgnoreCase)); + + if (nuspecEntry == null) + { + return BadRequest("Nuget file is missing nuspec"); + } + + string updatedNuspec; + using (var nuspecStream = nuspecEntry.Open()) + { + var doc = XDocument.Load(nuspecStream); + var ns = XNamespace.Get("http://schemas.microsoft.com/packaging/2013/05/nuspec.xsd"); + doc.Descendants(ns + "version").First().Value = newNugetVersion.ToNormalizedString(); + using(var docMs = new MemoryStream()) + { + doc.Save(docMs); + updatedNuspec = Encoding.UTF8.GetString(docMs.ToArray()); + } + } + + nuspecEntry.Delete(); + nuspecEntry = archive.CreateEntry($"{id}.nuspec"); + + using(var writer = new StreamWriter(nuspecEntry.Open())) + { + writer.Write(updatedNuspec); + } + } + + + using(var repackgedStream = new MemoryStream(ms.ToArray())) + { + await _indexer.IndexAsync(repackgedStream, cancellationToken); + } + + return Ok(); + } + } } } diff --git a/src/BaGet.Web/Pages/Package.cshtml b/src/BaGet.Web/Pages/Package.cshtml index 8e42cd6d3..ed6f24554 100644 --- a/src/BaGet.Web/Pages/Package.cshtml +++ b/src/BaGet.Web/Pages/Package.cshtml @@ -239,6 +239,11 @@ else Download package + +
  • + + Repackage +
  • @@ -268,6 +273,44 @@ else } + + +
    + +
    } @if (Model.Found) @@ -321,6 +364,35 @@ else } ]; + + @section Scripts { + + } } @functions { diff --git a/src/BaGet.Web/Pages/Package.cshtml.cs b/src/BaGet.Web/Pages/Package.cshtml.cs index 8841648e7..eda6ead71 100644 --- a/src/BaGet.Web/Pages/Package.cshtml.cs +++ b/src/BaGet.Web/Pages/Package.cshtml.cs @@ -8,6 +8,7 @@ using Markdig; using Microsoft.AspNetCore.Html; using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.Extensions.Options; using NuGet.Frameworks; using NuGet.Versioning; @@ -20,6 +21,7 @@ public class PackageModel : PageModel private readonly IPackageService _packages; private readonly IPackageContentService _content; private readonly ISearchService _search; + private readonly IOptionsSnapshot _options; private readonly IUrlGenerator _url; static PackageModel() @@ -33,11 +35,13 @@ public PackageModel( IPackageService packages, IPackageContentService content, ISearchService search, + IOptionsSnapshot options, IUrlGenerator url) { _packages = packages ?? throw new ArgumentNullException(nameof(packages)); _content = content ?? throw new ArgumentNullException(nameof(content)); _search = search ?? throw new ArgumentNullException(nameof(search)); + _options = options ?? throw new ArgumentNullException(nameof(options)); _url = url ?? throw new ArgumentNullException(nameof(url)); } @@ -59,6 +63,8 @@ public PackageModel( public string IconUrl { get; private set; } public string LicenseUrl { get; private set; } public string PackageDownloadUrl { get; private set; } + public string RepackageUrl { get; private set; } + public string ApiKey { get; private set; } public async Task OnGetAsync(string id, string version, CancellationToken cancellationToken) { @@ -84,6 +90,8 @@ public async Task OnGetAsync(string id, string version, CancellationToken cancel return; } + ApiKey = _options.Value.ApiKey; + var packageVersion = Package.Version; Found = true; @@ -108,6 +116,7 @@ public async Task OnGetAsync(string id, string version, CancellationToken cancel : Package.IconUrlString; LicenseUrl = Package.LicenseUrlString; PackageDownloadUrl = _url.GetPackageDownloadUrl(Package.Id, packageVersion); + RepackageUrl = _url.GetPackageRepackageUrl(Package.Id, packageVersion, NuGetVersion.Parse("0.0.0")); } private IReadOnlyList ToDependencyGroups(Package package) diff --git a/src/BaGet.Web/Routes.cs b/src/BaGet.Web/Routes.cs index ff31ad389..5907dabe0 100644 --- a/src/BaGet.Web/Routes.cs +++ b/src/BaGet.Web/Routes.cs @@ -8,6 +8,7 @@ public class Routes public const string DeleteRouteName = "delete"; public const string RelistRouteName = "relist"; public const string SearchRouteName = "search"; + public const string RepackageRouteName = "repackage"; public const string AutocompleteRouteName = "autocomplete"; public const string DependentsRouteName = "dependents"; public const string RegistrationIndexRouteName = "registration-index"; diff --git a/src/BaGet/appsettings.json b/src/BaGet/appsettings.json index f431f367a..12976a840 100644 --- a/src/BaGet/appsettings.json +++ b/src/BaGet/appsettings.json @@ -10,7 +10,7 @@ "Storage": { "Type": "FileSystem", - "Path": "" + "Path": "D:\\baget-packages" }, "Search": { diff --git a/tests/BaGet.Web.Tests/Pages/PackageModelFacts.cs b/tests/BaGet.Web.Tests/Pages/PackageModelFacts.cs index 51a7d1ccb..1baaebef7 100644 --- a/tests/BaGet.Web.Tests/Pages/PackageModelFacts.cs +++ b/tests/BaGet.Web.Tests/Pages/PackageModelFacts.cs @@ -1,10 +1,11 @@ -using System; +using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; using BaGet.Core; +using Microsoft.Extensions.Options; using Moq; using NuGet.Versioning; using Xunit; @@ -17,6 +18,7 @@ public class PackageModelFacts private readonly Mock _packages; private readonly Mock _search; private readonly Mock _url; + private readonly Mock> _options; private readonly PackageModel _target; private readonly CancellationToken _cancellation = CancellationToken.None; @@ -27,10 +29,12 @@ public PackageModelFacts() _packages = new Mock(); _search = new Mock(); _url = new Mock(); + _options = new Mock>(); _target = new PackageModel( _packages.Object, _content.Object, _search.Object, + _options.Object, _url.Object); _search