diff --git a/Changelog.md b/Changelog.md new file mode 100644 index 0000000000..65745d38c3 --- /dev/null +++ b/Changelog.md @@ -0,0 +1,20 @@ +# Deployment 2013.03.28-CommitID # + +*Support for MinClientVersion*: + + You can now upload packages with "[minclientVersion](http://nuget.codeplex.com/wikipage?title=NuGet%202.5%20list%20of%20features%20for%20Testing%20days%203%2f27%20to%203%2f29%20%2c%202013 )" to the NuGetGallery. + The minclientVersion of the package will shown in the package home page right next to the package description. + +*Contacting support*: + + The "Report Abuse" page has been revamped to enable users to chose the specific issue with the package they are reporting. It also guides the user to differentiate between "Contact Owners" and "Report Abuse". + +*Improvised Package statistics*: + + The package statistics now shows the break down of downloads based on the NuGet client (like NuGet CommandLine 2.1, NuGet Package Manager console 2.2 and so on and it also shows the split of the type of download operation (like Install, Restore, Update) + +*Other minor bug fixes*: + + Complete list can be found here @ https://github.com/NuGet/NuGetGallery/issues?milestone=17 + + diff --git a/Facts/Controllers/ApiControllerFacts.cs b/Facts/Controllers/ApiControllerFacts.cs index 535f9e76c7..2b370b7cd7 100644 --- a/Facts/Controllers/ApiControllerFacts.cs +++ b/Facts/Controllers/ApiControllerFacts.cs @@ -534,30 +534,6 @@ public async Task GetPackageReturnsLatestPackageIfNoVersionIsProvided() packageFileService.Verify(); packageService.Verify(); } - - [Fact] - public async Task GetPackageReturnsRedirectResultWhenExternalPackageUrlIsNotNull() - { - var package = new Package { ExternalPackageUrl = "http://theUrl" }; - var packageService = new Mock(); - packageService.Setup(x => x.FindPackageByIdAndVersion("thePackage", "42.1066", false)).Returns(package); - var httpRequest = new Mock(); - httpRequest.SetupGet(r => r.UserHostAddress).Returns("Foo"); - httpRequest.SetupGet(r => r.UserAgent).Returns("Qux"); - NameValueCollection headers = new NameValueCollection(); - headers.Add("NuGet-Operation", "Install"); - httpRequest.SetupGet(r => r.Headers).Returns(headers); - var httpContext = new Mock(); - httpContext.SetupGet(c => c.Request).Returns(httpRequest.Object); - var controller = CreateController(packageService); - var controllerContext = new ControllerContext(new RequestContext(httpContext.Object, new RouteData()), controller); - controller.ControllerContext = controllerContext; - - var result = await controller.GetPackage("thePackage", "42.1066") as RedirectResult; - - Assert.NotNull(result); - Assert.Equal("http://theUrl", result.Url); - } } public class ThePublishPackageAction diff --git a/Facts/Controllers/PackagesControllerFacts.cs b/Facts/Controllers/PackagesControllerFacts.cs index 3b14499bd4..9ecdb0c9fe 100644 --- a/Facts/Controllers/PackagesControllerFacts.cs +++ b/Facts/Controllers/PackagesControllerFacts.cs @@ -109,7 +109,7 @@ private static Mock CreateSearchService() { var searchService = new Mock(); int total; - searchService.Setup(s => s.Search(It.IsAny>(), It.IsAny(), out total)).Returns( + searchService.Setup(s => s.Search(It.IsAny(), out total, null)).Returns( (IQueryable p, string searchTerm) => p); return searchService; @@ -395,10 +395,7 @@ public void SendsMessageToGalleryOwnerWithEmailOnlyWhenUnauthenticated() { var messageService = new Mock(); messageService.Setup( - s => s.ReportAbuse( - It.IsAny(), - It.IsAny(), - "Mordor took my finger")); + s => s.ReportAbuse(It.Is(r => r.Message == "Mordor took my finger"))); var package = new Package { PackageRegistration = new PackageRegistration { Id = "mordor" }, @@ -415,18 +412,23 @@ public void SendsMessageToGalleryOwnerWithEmailOnlyWhenUnauthenticated() var model = new ReportAbuseViewModel { Email = "frodo@hobbiton.example.com", - Message = "Mordor took my finger." + Message = "Mordor took my finger.", + Reason = "GollumWasThere", + AlreadyContactedOwner = true, }; + TestUtility.SetupUrlHelper(controller, httpContext); var result = controller.ReportAbuse("mordor", "2.0.1", model) as RedirectToRouteResult; Assert.NotNull(result); messageService.Verify( s => s.ReportAbuse( - It.Is(m => m.Address == "frodo@hobbiton.example.com"), - package, - "Mordor took my finger." - )); + It.Is( + r => r.FromAddress.Address == "frodo@hobbiton.example.com" + && r.Package == package + && r.Reason == "GollumWasThere" + && r.Message == "Mordor took my finger." + && r.AlreadyContactedOwners))); } [Fact] @@ -434,10 +436,7 @@ public void SendsMessageToGalleryOwnerWithUserInfoWhenAuthenticated() { var messageService = new Mock(); messageService.Setup( - s => s.ReportAbuse( - It.IsAny(), - It.IsAny(), - "Mordor took my finger")); + s => s.ReportAbuse(It.Is(r => r.Message == "Mordor took my finger"))); var package = new Package { PackageRegistration = new PackageRegistration { Id = "mordor" }, @@ -449,7 +448,7 @@ public void SendsMessageToGalleryOwnerWithUserInfoWhenAuthenticated() httpContext.Setup(h => h.Request.IsAuthenticated).Returns(true); httpContext.Setup(h => h.User.Identity.Name).Returns("Frodo"); var userService = new Mock(); - userService.Setup(u => u.FindByUsername("Frodo")).Returns(new User { EmailAddress = "frodo@hobbiton.example.com", Username = "Frodo" }); + userService.Setup(u => u.FindByUsername("Frodo")).Returns(new User { EmailAddress = "frodo@hobbiton.example.com", Username = "Frodo", Key = 1 }); var controller = CreateController( packageService: packageService, messageService: messageService, @@ -457,21 +456,77 @@ public void SendsMessageToGalleryOwnerWithUserInfoWhenAuthenticated() httpContext: httpContext); var model = new ReportAbuseViewModel { - Message = "Mordor took my finger." + Message = "Mordor took my finger", + Reason = "GollumWasThere", }; - var result = controller.ReportAbuse("mordor", "2.0.1", model) as RedirectToRouteResult; + TestUtility.SetupUrlHelper(controller, httpContext); + ActionResult result = controller.ReportAbuse("mordor", "2.0.1", model) as RedirectToRouteResult; Assert.NotNull(result); userService.VerifyAll(); messageService.Verify( s => s.ReportAbuse( - It.Is( - m => m.Address == "frodo@hobbiton.example.com" - && m.DisplayName == "Frodo"), - package, - "Mordor took my finger." - )); + It.Is( + r => r.Message == "Mordor took my finger" + && r.FromAddress.Address == "frodo@hobbiton.example.com" + && r.FromAddress.DisplayName == "Frodo" + && r.Reason == "GollumWasThere"))); + } + + [Fact] + public void FormRedirectsPackageOwnerToReportMyPackage() + { + var package = new Package + { + PackageRegistration = new PackageRegistration { Id = "Mordor", Owners = { new User { Username = "Sauron" }} }, + Version = "2.0.1" + }; + var packageService = new Mock(); + packageService.Setup(p => p.FindPackageByIdAndVersion("Mordor", It.IsAny(), true)).Returns(package); + var httpContext = new Mock(); + httpContext.Setup(h => h.Request.IsAuthenticated).Returns(true); + httpContext.Setup(h => h.User.Identity.Name).Returns("Sauron"); + var userService = new Mock(); + userService.Setup(u => u.FindByUsername("Sauron")).Returns(new User { EmailAddress = "darklord@mordor.com", Username = "Sauron" }); + var controller = CreateController( + packageService: packageService, + userService: userService, + httpContext: httpContext); + + TestUtility.SetupUrlHelper(controller, httpContext); + ActionResult result = controller.ReportAbuse("Mordor", "2.0.1"); + Assert.IsType(result); + Assert.Equal("ReportMyPackage", ((RedirectToRouteResult)result).RouteValues["Action"]); + } + } + + public class TheReportMyPackageMethod + { + [Fact] + public void FormRedirectsNonOwnersToReportAbuse() + { + var package = new Package + { + PackageRegistration = new PackageRegistration { Id = "Mordor", Owners = { new User { Username = "Sauron", Key = 1 } } }, + Version = "2.0.1" + }; + var packageService = new Mock(); + packageService.Setup(p => p.FindPackageByIdAndVersion("Mordor", It.IsAny(), true)).Returns(package); + var httpContext = new Mock(); + httpContext.Setup(h => h.Request.IsAuthenticated).Returns(true); + httpContext.Setup(h => h.User.Identity.Name).Returns("Frodo"); + var userService = new Mock(); + userService.Setup(u => u.FindByUsername("Frodo")).Returns(new User { EmailAddress = "frodo@hobbiton.example.com", Username = "Frodo", Key = 2 }); + var controller = CreateController( + packageService: packageService, + userService: userService, + httpContext: httpContext); + + TestUtility.SetupUrlHelper(controller, httpContext); + ActionResult result = controller.ReportMyPackage("Mordor", "2.0.1"); + Assert.IsType(result); + Assert.Equal("ReportAbuse", ((RedirectToRouteResult)result).RouteValues["Action"]); } } diff --git a/Facts/Controllers/StatisticsControllerFacts.cs b/Facts/Controllers/StatisticsControllerFacts.cs index 0ee51037c6..7efc4a0039 100644 --- a/Facts/Controllers/StatisticsControllerFacts.cs +++ b/Facts/Controllers/StatisticsControllerFacts.cs @@ -151,11 +151,14 @@ public async void StatisticsHomePage_Per_Package_ValidateReportStructureAndAvail { string PackageId = "A"; - var fakeReport = "[{\"PackageVersion\":\"1.0\",\"Downloads\":101},{\"PackageVersion\":\"2.1\",\"Downloads\":202}]"; + var fakeReport = "{\"Downloads\":303, Items:[{\"Version\":\"1.0\",\"Downloads\":101},{\"Version\":\"2.1\",\"Downloads\":202}]}"; var fakeReportService = new Mock(); - fakeReportService.Setup(x => x.Load("RecentPopularity_" + PackageId + ".json")).Returns(Task.FromResult(fakeReport)); + string reportName = "RecentPopularityDetail_" + PackageId + ".json"; + reportName = reportName.ToLowerInvariant(); + + fakeReportService.Setup(x => x.Load(reportName)).Returns(Task.FromResult(fakeReport)); var controller = new StatisticsController(new JsonStatisticsService(fakeReportService.Object)); @@ -163,13 +166,49 @@ public async void StatisticsHomePage_Per_Package_ValidateReportStructureAndAvail int sum = 0; - foreach (var item in model.PackageDownloadsByVersion) + foreach (var item in model.Report.Rows) { sum += item.Downloads; } Assert.Equal(303, sum); - Assert.Equal(303, model.TotalPackageDownloads); + Assert.Equal(303, model.Report.Total); + } + + [Fact] + public async void Statistics_By_Client_Operation_ValidateReportStructureAndAvailability() + { + string PackageId = "A"; + string PackageVersion = "2.1"; + + var fakeReport = "{\"Downloads\":303, Items:["; + fakeReport += "{\"Version\":\"1.0\", \"Downloads\":101},"; + fakeReport += "{\"Version\":\"2.1\", \"Downloads\":70, Items:["; + fakeReport += "{\"Client\":\"Package Manager\", \"Operation\":\"Install\", Downloads:45},"; + fakeReport += "{\"Client\":\"Package Manager\", \"Operation\":\"Restore\", Downloads:25},"; + fakeReport += "]}"; + fakeReport += "]}"; + + var fakeReportService = new Mock(); + + string reportName = "RecentPopularityDetail_" + PackageId + ".json"; + reportName = reportName.ToLowerInvariant(); + + fakeReportService.Setup(x => x.Load(reportName)).Returns(Task.FromResult(fakeReport)); + + var controller = new StatisticsController(new JsonStatisticsService(fakeReportService.Object)); + + var model = (StatisticsPackagesViewModel)((ViewResult)await controller.PackageDownloadsDetail(PackageId, PackageVersion)).Model; + + int sum = 0; + + foreach (var item in model.Report.Rows) + { + sum += item.Downloads; + } + + Assert.Equal(70, sum); + Assert.Equal(70, model.Report.Total); } [Fact] diff --git a/Facts/Controllers/UsersControllerFacts.cs b/Facts/Controllers/UsersControllerFacts.cs index 554ad73463..7505dc100a 100644 --- a/Facts/Controllers/UsersControllerFacts.cs +++ b/Facts/Controllers/UsersControllerFacts.cs @@ -17,6 +17,7 @@ public class UsersControllerFacts Mock messageService = null, Mock feedsQuery = null, Mock currentUser = null) + { userService = userService ?? new Mock(); var packageService = new Mock(); diff --git a/Facts/Facts.csproj b/Facts/Facts.csproj index 9d3c84983c..1ecec526e8 100644 --- a/Facts/Facts.csproj +++ b/Facts/Facts.csproj @@ -76,9 +76,9 @@ ..\packages\MvcHaack.Ajax.MVC4.2.0.0.0\lib\net40\MvcHaack.Ajax.dll - + False - ..\packages\Nuget.Core.2.3.0-alpha002\lib\net40-Client\NuGet.Core.dll + ..\packages\NuGet.Core.2.3.0-alpha003\lib\net40-Client\NuGet.Core.dll diff --git a/Facts/Infrastructure/LuceneSearchServiceFacts.cs b/Facts/Infrastructure/LuceneSearchServiceFacts.cs index 1a6d170473..344e5934ae 100644 --- a/Facts/Infrastructure/LuceneSearchServiceFacts.cs +++ b/Facts/Infrastructure/LuceneSearchServiceFacts.cs @@ -538,7 +538,6 @@ private IList IndexAndSearch(Mock packageSource, string luceneIndexingService.UpdateIndex(forceRefresh: true); var luceneSearchService = new LuceneSearchService(d); - int totalHits = 0; var searchFilter = new SearchFilter { Skip = 0, @@ -546,10 +545,8 @@ private IList IndexAndSearch(Mock packageSource, string SearchTerm = searchTerm, }; - var results = luceneSearchService.Search( - packageSource.Object.GetPackagesForIndexing(null), - searchFilter, - out totalHits).ToList(); + int totalHits; + var results = luceneSearchService.Search(searchFilter, out totalHits).ToList(); return results; } diff --git a/Facts/Services/FeedServiceFacts.cs b/Facts/Services/FeedServiceFacts.cs index 91f07929b6..717ce8fe94 100644 --- a/Facts/Services/FeedServiceFacts.cs +++ b/Facts/Services/FeedServiceFacts.cs @@ -74,7 +74,7 @@ public void V1FeedSearchDoesNotReturnPrereleasePackages() configuration.Setup(c => c.GetSiteRoot(It.IsAny())).Returns("https://localhost:8081/"); var searchService = new Mock(MockBehavior.Strict); int total; - searchService.Setup(s => s.Search(It.IsAny>(), It.IsAny(), out total)).Returns + searchService.Setup(s => s.Search(It.IsAny(), out total, null)).Returns , string>((_, __) => _); var v1Service = new TestableV1Feed(repo.Object, configuration.Object, searchService.Object); diff --git a/Facts/Services/MessageServiceFacts.cs b/Facts/Services/MessageServiceFacts.cs index 94ccd5fd85..7c5e067f75 100644 --- a/Facts/Services/MessageServiceFacts.cs +++ b/Facts/Services/MessageServiceFacts.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.ObjectModel; using System.Net.Mail; using AnglicanGeek.MarkdownMailer; using Moq; @@ -27,12 +28,75 @@ public void WillSendEmailToGalleryOwner() MailMessage message = null; mailSender.Setup(m => m.Send(It.IsAny())).Callback(m => { message = m; }); - messageService.ReportAbuse(from, package, "Abuse!"); + messageService.ReportAbuse( + new ReportPackageRequest + { + AlreadyContactedOwners = true, + FromAddress = from, + Message = "Abuse!", + Package = package, + Reason = "Reason!", + RequestingUser = null, + Url = TestUtility.MockUrlHelper(), + }); + + Assert.Equal("joe@example.com", message.To[0].Address); + Assert.Equal("[NuGet Gallery] Support Request for 'smangit' version 1.42.0.1 (Reason: Reason!)", message.Subject); + Assert.Contains("Reason!", message.Body); + Assert.Contains("Abuse!", message.Body); + Assert.Contains("too (legit@example.com)", message.Body); + Assert.Contains("smangit", message.Body); + Assert.Contains("1.42.0.1", message.Body); + Assert.Contains("Yes", message.Body); + } + } + + public class TheReportMyPackageMethod + { + [Fact] + public void WillSendEmailToGalleryOwner() + { + var from = new MailAddress("legit@example.com", "too"); + var owner = new User + { + Username = "too", + EmailAddress = "legit@example.com", + }; + var package = new Package + { + PackageRegistration = new PackageRegistration + { + Id = "smangit", + Owners = new Collection { owner } + }, + Version = "1.42.0.1" + }; + var mailSender = new Mock(); + var config = new Mock(); + config.Setup(x => x.GalleryOwnerName).Returns("NuGet Gallery"); + config.Setup(x => x.GalleryOwnerEmail).Returns("joe@example.com"); + var messageService = new MessageService(mailSender.Object, config.Object); + MailMessage message = null; + mailSender.Setup(m => m.Send(It.IsAny())).Callback(m => { message = m; }); + + messageService.ReportMyPackage( + new ReportPackageRequest + { + FromAddress = from, + Message = "Abuse!", + Package = package, + Reason = "Reason!", + RequestingUser = owner, + Url = TestUtility.MockUrlHelper(), + }); Assert.Equal("joe@example.com", message.To[0].Address); - Assert.Equal("[NuGet Gallery] Abuse Report for Package 'smangit' Version '1.42.0.1'", message.Subject); + Assert.Equal("[NuGet Gallery] Owner Support Request for 'smangit' version 1.42.0.1 (Reason: Reason!)", message.Subject); + Assert.Contains("Reason!", message.Body); Assert.Contains("Abuse!", message.Body); - Assert.Contains("User too (legit@example.com) reports the package 'smangit' version '1.42.0.1' as abusive", message.Body); + Assert.Contains("too (legit@example.com)", message.Body); + Assert.Contains("smangit", message.Body); + Assert.Contains("1.42.0.1", message.Body); } } diff --git a/Facts/TestUtility.cs b/Facts/TestUtility.cs index 98ae14fee5..5a8117b655 100644 --- a/Facts/TestUtility.cs +++ b/Facts/TestUtility.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Specialized; using System.Reflection; using System.Threading.Tasks; using System.Web; @@ -26,6 +27,38 @@ public static Mock SetupHttpContextMockForUrlGeneration(Mock mockHttpContext) + { + var routes = new RouteCollection(); + Routes.RegisterRoutes(routes); + controller.Url = new UrlHelper(new RequestContext(mockHttpContext.Object, new RouteData()), routes); + } + + public static UrlHelper MockUrlHelper() + { + var mockHttpContext = new Mock(MockBehavior.Strict); + var mockHttpRequest = new Mock(MockBehavior.Strict); + var mockHttpResponse = new Mock(MockBehavior.Strict); + mockHttpContext.Setup(httpContext => httpContext.Request).Returns(mockHttpRequest.Object); + mockHttpContext.Setup(httpContext => httpContext.Response).Returns(mockHttpResponse.Object); + mockHttpRequest.Setup(httpRequest => httpRequest.Url).Returns(new Uri("http://unittest.nuget.org/")); + mockHttpRequest.Setup(httpRequest => httpRequest.ApplicationPath).Returns("http://unittest.nuget.org/"); + mockHttpRequest.Setup(httpRequest => httpRequest.ServerVariables).Returns(new NameValueCollection()); + + string value = null; + Action saveValue = x => + { + value = x; + }; + Func restoreValue = () => value; + mockHttpResponse.Setup(httpResponse => httpResponse.ApplyAppPathModifier(It.IsAny())) + .Callback(saveValue).Returns(restoreValue); + var requestContext = new RequestContext(mockHttpContext.Object, new RouteData()); + var routes = new RouteCollection(); + Routes.RegisterRoutes(routes); + return new UrlHelper(requestContext, routes); + } + public static T GetAnonymousPropertyValue(Object source, string propertyName) { var property = source.GetType().GetProperty(propertyName, BindingFlags.Instance | BindingFlags.DeclaredOnly | BindingFlags.Public); diff --git a/Facts/packages.config b/Facts/packages.config index 41d38655f6..2da4f98dcc 100644 --- a/Facts/packages.config +++ b/Facts/packages.config @@ -13,7 +13,7 @@ - + diff --git a/Scripts/NuGetGallery.base.cscfg b/Scripts/NuGetGallery.base.cscfg index f6b3bb7288..7bfcd372fb 100644 --- a/Scripts/NuGetGallery.base.cscfg +++ b/Scripts/NuGetGallery.base.cscfg @@ -45,10 +45,6 @@ - - - - diff --git a/Scripts/NuGetGallery.csdef b/Scripts/NuGetGallery.csdef index 32576d980f..a3f50f80dc 100644 --- a/Scripts/NuGetGallery.csdef +++ b/Scripts/NuGetGallery.csdef @@ -11,7 +11,6 @@ - @@ -21,29 +20,22 @@ - - + + - + - - - - - - - @@ -58,6 +50,16 @@ + + + + + + + + + + diff --git a/Scripts/NuGetGallery.emulator.cscfg b/Scripts/NuGetGallery.emulator.cscfg index a3f636320e..9e88f94bd5 100644 --- a/Scripts/NuGetGallery.emulator.cscfg +++ b/Scripts/NuGetGallery.emulator.cscfg @@ -25,11 +25,6 @@ - - - - - diff --git a/Scripts/Startup.ps1 b/Scripts/Startup.ps1 index adb42bdf9d..d1f11b5976 100644 --- a/Scripts/Startup.ps1 +++ b/Scripts/Startup.ps1 @@ -1,27 +1,3 @@ # Enable Dynamic HTTP Compression for application/* mime types. & "$env:windir\system32\inetsrv\appcmd.exe" set config /section:urlCompression /doDynamicCompression:True /commit:apphost -& "$env:windir\system32\inetsrv\appcmd.exe" set config /section:system.webServer/httpCompression /+"dynamicTypes.[mimeType='application/*',enabled='True']" /commit:apphost - -# Load Azure Service Runtime Assembly -[Reflection.Assembly]::LoadWithPartialName("Microsoft.WindowsAzure.ServiceRuntime") - -# Load machine keys from service config -$validationKey = [Microsoft.WindowsAzure.ServiceRuntime.RoleEnvironment]::GetConfigurationSettingValue("Gallery.ValidationKey"); -$decryptionKey = [Microsoft.WindowsAzure.ServiceRuntime.RoleEnvironment]::GetConfigurationSettingValue("Gallery.DecryptionKey"); - -# Push the keys in to the web.config -function set-machinekey { - param($path) - if($validationKey -AND $decryptionKey){ - $xml = [xml](get-content $path) - $machinekey = $xml.CreateElement("machineKey") - $machinekey.setattribute("validation", "HMACSHA256") - $machinekey.setattribute("validationKey", $validationKey) - $machinekey.setattribute("decryption", "AES") - $machinekey.setattribute("decryptionKey", $decryptionKey) - $xml.configuration."system.web".AppendChild($machineKey) - $resolvedPath = (resolve-path $path) - $xml.save($resolvedPath) - } -} -set-machinekey (Convert-Path "..\Web.Config") \ No newline at end of file +& "$env:windir\system32\inetsrv\appcmd.exe" set config /section:system.webServer/httpCompression /+"dynamicTypes.[mimeType='application/*',enabled='True']" /commit:apphost \ No newline at end of file diff --git a/Website/App_Code/ViewHelpers.cshtml b/Website/App_Code/ViewHelpers.cshtml index 43cb9dd22d..3750e72985 100644 --- a/Website/App_Code/ViewHelpers.cshtml +++ b/Website/App_Code/ViewHelpers.cshtml @@ -1,6 +1,7 @@ @using System.Configuration @using System.Web.Mvc @using Microsoft.Web.Helpers +@using Microsoft.WindowsAzure.ServiceRuntime @using NuGetGallery @using Ninject @@ -107,7 +108,7 @@ @helper OwnerGravatar(User owner, int size, UrlHelper url, bool showName = true) { - + @if (!String.IsNullOrEmpty(owner.EmailAddress)) { @GravatarImage(owner.EmailAddress, owner.Username, size) @@ -134,14 +135,30 @@ string sha = ConfigurationManager.AppSettings["Gallery.ReleaseSha"]; string branch = ConfigurationManager.AppSettings["Gallery.ReleaseBranch"]; string time = ConfigurationManager.AppSettings["Gallery.ReleaseTime"]; - if (!String.IsNullOrEmpty(sha) && !String.IsNullOrEmpty(branch) && !String.IsNullOrEmpty(time)) - { -

- Deployed from @sha.Substring(0, Math.Min(sha.Length, 10)). - Originally built on @branch - at @time. -

- } +

+ This is the NuGet Gallery. + @if (!String.IsNullOrEmpty(sha) && !String.IsNullOrEmpty(branch) && !String.IsNullOrEmpty(time)) + { + + Deployed from @sha.Substring(0, Math.Min(sha.Length, 10)). + Originally built on @branch + at @time. + + } + + @* A little quick-n-dirty code to display the current machine *@ + @* In Azure, we want the Instance ID. The Machine Name is total garbage *@ + @try { + if(RoleEnvironment.IsAvailable) { + You are on @RoleEnvironment.CurrentRoleInstance.Id. + } else { + You are on @Environment.MachineName. + } + } catch(Exception) { + @* Azure SDK not installed so we can't even run RoleEnvironment.IsAvailable. Just use Machine Name *@ + You are on @Environment.MachineName. + } +

} @helper AnalyticsScript() diff --git a/Website/App_Start/AppActivator.cs b/Website/App_Start/AppActivator.cs index 0519b6b99f..fdca618081 100644 --- a/Website/App_Start/AppActivator.cs +++ b/Website/App_Start/AppActivator.cs @@ -3,6 +3,7 @@ using System.Linq; using System.Web; using System.Web.Mvc; +using System.Web.Optimization; using System.Web.Routing; using DynamicDataEFCodeFirst; using Elmah; @@ -47,6 +48,7 @@ public static void PostStart() BackgroundJobsPostStart(config); AppPostStart(); DynamicDataPostStart(config); + BundlingPostStart(); } public static void Stop() @@ -55,6 +57,26 @@ public static void Stop() NinjectStop(); } + private static void BundlingPostStart() + { + var scriptBundle = new ScriptBundle("~/bundles/js") + .Include("~/Scripts/jquery-{version}.js") + .Include("~/Scripts/jquery.validate.js") + .Include("~/Scripts/jquery.validate.unobtrusive.js"); + BundleTable.Bundles.Add(scriptBundle); + + // Modernizr needs to be delivered at the top of the page but putting it in a bundle gets us a cache-buster. + // TODO: Use minified modernizr! + var modernizrBundle = new ScriptBundle("~/bundles/modernizr") + .Include("~/Scripts/modernizr-2.0.6-development-only.js"); + BundleTable.Bundles.Add(modernizrBundle); + + var stylesBundle = new StyleBundle("~/bundles/css") + .Include("~/Content/site.css"); + BundleTable.Bundles.Add(stylesBundle); + + } + private static void ElmahPreStart() { ServiceCenter.Current = _ => Container.Kernel; @@ -63,6 +85,7 @@ private static void ElmahPreStart() private static void AppPostStart() { Routes.RegisterRoutes(RouteTable.Routes); + Routes.RegisterServiceRoutes(RouteTable.Routes); GlobalFilters.Filters.Add(new ElmahHandleErrorAttribute()); GlobalFilters.Filters.Add(new ReadOnlyModeErrorFilter()); GlobalFilters.Filters.Add(new RequireRemoteHttpsAttribute() { OnlyWhenAuthenticated = true }); @@ -71,7 +94,13 @@ private static void AppPostStart() private static void BackgroundJobsPostStart(IConfiguration configuration) { - var jobs = new IJob[] + var jobs = configuration.HasWorker ? + new IJob[] + { + new LuceneIndexingJob(TimeSpan.FromMinutes(10), () => new EntitiesContext(configuration.SqlConnectionString, readOnly: true), timeout: TimeSpan.FromMinutes(2)) + } + : + new IJob[] { // readonly: false workaround - let statistics background job write to DB in read-only mode since we don't care too much about losing that data new UpdateStatisticsJob(TimeSpan.FromMinutes(5), @@ -101,7 +130,7 @@ private static void DbMigratorPostStart() // To make app startup not directly depend on the database, // we set the migrations to run when the database is first used, instead of doing it up-front. - Database.SetInitializer(new MigrateDatabaseToLatestVersion()); + Database.SetInitializer(new MigrateDatabaseToLatestVersion()); } private static void DynamicDataPostStart(IConfiguration configuration) diff --git a/Website/App_Start/Configuration.cs b/Website/App_Start/Configuration.cs index f5b860702a..7e8c264238 100644 --- a/Website/App_Start/Configuration.cs +++ b/Website/App_Start/Configuration.cs @@ -21,6 +21,11 @@ public Configuration() _httpsSiteRootThunk = new Lazy(GetHttpsSiteRoot); } + public bool HasWorker + { + get { return ReadAppSettings("HasWorker", str => Boolean.Parse(str ?? "true")); } + } + public string EnvironmentName { get { return ReadAppSettings("Environment") ?? "Development"; } diff --git a/Website/App_Start/ContainerBindings.cs b/Website/App_Start/ContainerBindings.cs index d87173c1e1..a743124935 100644 --- a/Website/App_Start/ContainerBindings.cs +++ b/Website/App_Start/ContainerBindings.cs @@ -51,11 +51,6 @@ public override void Load() if (IsDeployedToCloud) { - // when running on Windows Azure, use the Azure Cache local storage - Bind() - .To() - .InSingletonScope(); - // when running on Windows Azure, use the Azure Cache service if available if (!String.IsNullOrEmpty(configuration.AzureCacheEndpoint)) { @@ -81,10 +76,6 @@ public override void Load() } else { - Bind() - .To() - .InSingletonScope(); - // when running locally on dev box, use the built-in ASP.NET Http Cache Bind() .To() @@ -203,10 +194,10 @@ public override void Load() .To() .InSingletonScope(); break; - case PackageStoreType.AzureStorageBlob: + case PackageStoreType.AzureStorageBlob: Bind() .ToMethod( - context => new CloudBlobClientWrapper(CloudStorageAccount.Parse(configuration.AzureStorageConnectionString).CreateCloudBlobClient())) + _ => new CloudBlobClientWrapper(configuration.AzureStorageConnectionString)) .InSingletonScope(); Bind() .To() diff --git a/Website/App_Start/IConfiguration.cs b/Website/App_Start/IConfiguration.cs index 8adf08326c..48e669f78b 100644 --- a/Website/App_Start/IConfiguration.cs +++ b/Website/App_Start/IConfiguration.cs @@ -2,6 +2,7 @@ { public interface IConfiguration { + bool HasWorker { get; } bool RequireSSL { get; } int SSLPort { get; } diff --git a/Website/App_Start/Routes.cs b/Website/App_Start/Routes.cs index 4640447aa3..a8d0b7b26b 100644 --- a/Website/App_Start/Routes.cs +++ b/Website/App_Start/Routes.cs @@ -34,11 +34,16 @@ public static void RegisterRoutes(RouteCollection routes) "stats/packageversions", new { controller = MVC.Statistics.Name, action = "PackageVersions" }); + routes.MapRoute( + RouteName.StatisticsPackageDownloadsDetail, + "stats/packages/{id}/{version}", + new { controller = MVC.Statistics.Name, action = "PackageDownloadsDetail" }); + routes.MapRoute( RouteName.StatisticsPackageDownloadsByVersion, "stats/packages/{id}", new { controller = MVC.Statistics.Name, action = "PackageDownloadsByVersion" }); - + routes.Add(new JsonRoute("json/{controller}")); routes.MapRoute( @@ -150,16 +155,6 @@ public static void RegisterRoutes(RouteCollection routes) // TODO : Most of the routes are essentially of the format api/v{x}/*. We should refactor the code to vary them by the version. // V1 Routes // If the push url is /api/v1 then NuGet.Core would ping the path to resolve redirection. - routes.MapServiceRoute( - RouteName.V1ApiFeed, - "api/v1/FeedService.svc", - typeof(V1Feed)); - - routes.MapServiceRoute( - "LegacyFeedService", - "v1/FeedService.svc", - typeof(V1Feed)); - routes.MapRoute( "v1" + RouteName.VerifyPackageKey, "api/v1/verifykey/{id}/{version}", @@ -188,11 +183,6 @@ public static void RegisterRoutes(RouteCollection routes) "v1/PublishedPackages/Publish", MVC.Api.PublishPackage()); - routes.MapServiceRoute( - "v1" + RouteName.V1ApiFeed, - "api/v1", - typeof(V1Feed)); - // V2 routes routes.MapRoute( "v2" + RouteName.VerifyPackageKey, @@ -242,16 +232,6 @@ public static void RegisterRoutes(RouteCollection routes) "api/v2/package-versions/{id}", MVC.Api.GetPackageVersions()); - routes.MapServiceRoute( - RouteName.V2ApiCuratedFeed, - "api/v2/curated-feed", - typeof(V2CuratedFeed)); - - routes.MapServiceRoute( - RouteName.V2ApiFeed, - "api/v2/", - typeof(V2Feed)); - routes.MapRoute( RouteName.DownloadNuGetExe, "nuget.exe", @@ -314,5 +294,34 @@ public static void RegisterRoutes(RouteCollection routes) new { controller = MVC.Api.Name, action = "GetPackageApi", version = UrlParameter.Optional }), permanent: true).To(downloadRoute); } + + // note: Pulled out service route registration separately because it's not testable T.T (won't run outside IIS/WAS) + public static void RegisterServiceRoutes(RouteCollection routes) + { + routes.MapServiceRoute( + RouteName.V1ApiFeed, + "api/v1/FeedService.svc", + typeof(V1Feed)); + + routes.MapServiceRoute( + "LegacyFeedService", + "v1/FeedService.svc", + typeof(V1Feed)); + + routes.MapServiceRoute( + "v1" + RouteName.V1ApiFeed, + "api/v1", + typeof(V1Feed)); + + routes.MapServiceRoute( + RouteName.V2ApiCuratedFeed, + "api/v2/curated-feed", + typeof(V2CuratedFeed)); + + routes.MapServiceRoute( + RouteName.V2ApiFeed, + "api/v2/", + typeof(V2Feed)); + } } } \ No newline at end of file diff --git a/Website/Content/Site.css b/Website/Content/Site.css index 49b3294ebd..88dc83c126 100644 --- a/Website/Content/Site.css +++ b/Website/Content/Site.css @@ -791,6 +791,10 @@ section.package { padding-top: 10px; } +section.package div.minimum-client-version { + float: right; +} + section.package.my-package { background: url("Images/yourpackage.png") no-repeat 100% 0; } section.package .main { margin-left: 70px; } @@ -1054,6 +1058,7 @@ fieldset.form legend { display: none; } } .form-field textarea, +.form-field input[type="email"], .form-field input[type="text"], .form-field input[type="file"], .form-field input[type="password"] { @@ -1199,7 +1204,7 @@ input:focus ~ .field-validation-error ~ .field-hint-message { display: none; } footer#footer { margin: 0 auto; text-align: center; - width: 80%; + display: table; } footer#footer p { @@ -1220,7 +1225,6 @@ footer#footer ul.recommended { list-style: none; margin: 0 auto; padding: 0; - width: 760px; } footer#footer ul.recommended li { diff --git a/Website/Controllers/ApiController.cs b/Website/Controllers/ApiController.cs index 3538479ef7..ac3531da06 100644 --- a/Website/Controllers/ApiController.cs +++ b/Website/Controllers/ApiController.cs @@ -88,11 +88,6 @@ public virtual async Task GetPackage(string id, string version) QuietlyLogException(e); } - if (!String.IsNullOrWhiteSpace(package.ExternalPackageUrl)) - { - return Redirect(package.ExternalPackageUrl); - } - return await _packageFileService.CreateDownloadPackageActionResultAsync(HttpContext.Request.Url, package); } catch (SqlException e) diff --git a/Website/Controllers/PackagesController.cs b/Website/Controllers/PackagesController.cs index d3a6b4622e..c51aa6feed 100644 --- a/Website/Controllers/PackagesController.cs +++ b/Website/Controllers/PackagesController.cs @@ -179,8 +179,6 @@ public virtual ActionResult ListPackages(string q, string sortOrder = null, int page = 1; } - IQueryable packageVersions = _packageService.GetPackagesForListing(prerelease); - q = (q ?? "").Trim(); if (String.IsNullOrEmpty(sortOrder)) @@ -192,7 +190,7 @@ public virtual ActionResult ListPackages(string q, string sortOrder = null, int var searchFilter = GetSearchFilter(q, sortOrder, page, prerelease); int totalHits; - packageVersions = _searchService.Search(packageVersions, searchFilter, out totalHits); + IQueryable packageVersions = _searchService.Search(searchFilter, out totalHits); if (page == 1 && !packageVersions.Any()) { // In the event the index wasn't updated, we may get an incorrect count. @@ -225,20 +223,72 @@ public virtual ActionResult ReportAbuse(string id, string version) } var model = new ReportAbuseViewModel + { + ReasonChoices = { - PackageId = id, - PackageVersion = package.Version, - }; + ReportPackageReason.IsFraudulent, + ReportPackageReason.ViolatesALicenseIOwn, + ReportPackageReason.ContainsMaliciousCode, + ReportPackageReason.HasABug, + ReportPackageReason.Other + }, + PackageId = id, + PackageVersion = package.Version, + }; if (Request.IsAuthenticated) { var user = _userService.FindByUsername(HttpContext.User.Identity.Name); + + // If user logged on in as owner a different tab, then clicked the link, we can redirect them to ReportMyPackage + if (package.IsOwner(user)) + { + return RedirectToAction(ActionNames.ReportMyPackage, new {id, version}); + } + if (user.Confirmed) { model.ConfirmedUser = true; } } + ViewData[Constants.ReturnUrlViewDataKey] = Url.Action(ActionNames.ReportMyPackage, new {id, version}); + return View(model); + } + + [Authorize] + public virtual ActionResult ReportMyPackage(string id, string version) + { + var user = _userService.FindByUsername(HttpContext.User.Identity.Name); + + var package = _packageService.FindPackageByIdAndVersion(id, version); + + if (package == null) + { + return HttpNotFound(); + } + + // If user hit this url by constructing it manually but is not the owner, redirect them to ReportAbuse + if (!(HttpContext.User.IsInRole(Constants.AdminRoleName) || package.IsOwner(user))) + { + return RedirectToAction(ActionNames.ReportAbuse, new { id, version }); + } + + var model = new ReportAbuseViewModel + { + ReasonChoices = + { + ReportPackageReason.ContainsPrivateAndConfidentialData, + ReportPackageReason.PublishedWithWrongVersion, + ReportPackageReason.ReleasedInPublicByAccident, + ReportPackageReason.ContainsMaliciousCode, + ReportPackageReason.Other + }, + ConfirmedUser = user.Confirmed, + PackageId = id, + PackageVersion = package.Version, + }; + return View(model); } @@ -258,10 +308,11 @@ public virtual ActionResult ReportAbuse(string id, string version, ReportAbuseVi return HttpNotFound(); } + User user = null; MailAddress from; if (Request.IsAuthenticated) { - var user = _userService.FindByUsername(HttpContext.User.Identity.Name); + user = _userService.FindByUsername(HttpContext.User.Identity.Name); from = user.ToMailAddress(); } else @@ -269,12 +320,58 @@ public virtual ActionResult ReportAbuse(string id, string version, ReportAbuseVi from = new MailAddress(reportForm.Email); } - _messageService.ReportAbuse(from, package, reportForm.Message); + var request = new ReportPackageRequest + { + AlreadyContactedOwners = reportForm.AlreadyContactedOwner, + FromAddress = from, + Message = reportForm.Message, + Package = package, + Reason = reportForm.Reason, + RequestingUser = user, + Url = Url + }; + _messageService.ReportAbuse(request + ); TempData["Message"] = "Your abuse report has been sent to the gallery operators."; return RedirectToAction(MVC.Packages.DisplayPackage(id, version)); } + [HttpPost] + [Authorize] + [ValidateAntiForgeryToken] + [ValidateSpamPrevention] + public virtual ActionResult ReportMyPackage(string id, string version, ReportAbuseViewModel reportForm) + { + if (!ModelState.IsValid) + { + return ReportMyPackage(id, version); + } + + var package = _packageService.FindPackageByIdAndVersion(id, version); + if (package == null) + { + return HttpNotFound(); + } + + var user = _userService.FindByUsername(HttpContext.User.Identity.Name); + MailAddress from = user.ToMailAddress(); + + _messageService.ReportMyPackage( + new ReportPackageRequest + { + FromAddress = from, + Message = reportForm.Message, + Package = package, + Reason = reportForm.Reason, + RequestingUser = user, + Url = Url + }); + + TempData["Message"] = "Your support request has been sent to the gallery operators."; + return RedirectToAction(MVC.Packages.DisplayPackage(id, version)); + } + [Authorize] public virtual ActionResult ContactOwners(string id) { @@ -555,9 +652,6 @@ public virtual async Task VerifyPackage(bool? listed) // tell Lucene to update index for the new package _indexingService.UpdateIndex(); - // delete the uploaded binary in the Uploads container - await _uploadFileService.DeleteUploadFileAsync(currentUser.Key); - // If we're pushing a new stable version of NuGet.CommandLine, update the extracted executable. if (package.PackageRegistration.Id.Equals(Constants.NuGetCommandLinePackageId, StringComparison.OrdinalIgnoreCase) && package.IsLatestStable) @@ -566,6 +660,9 @@ public virtual async Task VerifyPackage(bool? listed) } } + // delete the uploaded binary in the Uploads container + await _uploadFileService.DeleteUploadFileAsync(currentUser.Key); + TempData["Message"] = String.Format( CultureInfo.CurrentCulture, Strings.SuccessfullyUploadedPackage, package.PackageRegistration.Id, package.Version); @@ -643,4 +740,4 @@ private static string GetSortExpression(string sortOrder) } } } -} \ No newline at end of file +} diff --git a/Website/Controllers/PagesController.cs b/Website/Controllers/PagesController.cs index 7010a78f57..634ef91d7a 100644 --- a/Website/Controllers/PagesController.cs +++ b/Website/Controllers/PagesController.cs @@ -10,6 +10,11 @@ public PagesController() { } + public virtual ActionResult Contact() + { + return View(); + } + public virtual ActionResult Home() { return View(); diff --git a/Website/Controllers/StatisticsController.cs b/Website/Controllers/StatisticsController.cs index 5fe4b1c5d9..90fc41627f 100644 --- a/Website/Controllers/StatisticsController.cs +++ b/Website/Controllers/StatisticsController.cs @@ -141,11 +141,30 @@ public virtual async Task PackageDownloadsByVersion(string id) return new HttpStatusCodeResult(HttpStatusCode.NotFound); } - bool isAvailable = await _statisticsService.LoadPackageDownloadsByVersion(id); + StatisticsPackagesReport report = await _statisticsService.GetPackageDownloadsByVersion(id); var model = new StatisticsPackagesViewModel(); - model.SetPackageDownloadsByVersion(id, isAvailable, _statisticsService.PackageDownloadsByVersion); + model.SetPackageDownloadsByVersion(id, report); + + return View(model); + } + + // + // GET: /stats/package/{id}/{version} + + public virtual async Task PackageDownloadsDetail(string id, string version) + { + if (_statisticsService == null) + { + return new HttpStatusCodeResult(HttpStatusCode.NotFound); + } + + StatisticsPackagesReport report = await _statisticsService.GetPackageVersionDownloadsByClient(id, version); + + var model = new StatisticsPackagesViewModel(); + + model.SetPackageVersionDownloadsByClient(id, version, report); return View(model); } diff --git a/Website/DataServices/FeedServiceBase.cs b/Website/DataServices/FeedServiceBase.cs index 409d2642d1..ccf727ac52 100644 --- a/Website/DataServices/FeedServiceBase.cs +++ b/Website/DataServices/FeedServiceBase.cs @@ -2,26 +2,16 @@ using System.Data.Services; using System.Data.Services.Common; using System.Data.Services.Providers; -using System.Diagnostics; using System.IO; -using System.Linq; using System.ServiceModel; using System.Web; using System.Web.Mvc; -using Microsoft.Data.OData.Query; -using Microsoft.Data.OData.Query.SyntacticAst; -using QueryInterceptor; namespace NuGetGallery { [ServiceBehavior(IncludeExceptionDetailInFaults = true, ConcurrencyMode = ConcurrencyMode.Multiple)] public abstract class FeedServiceBase : DataService>, IDataServiceStreamProvider, IServiceProvider { - /// - /// Determines the maximum number of packages returned in a single page of an OData result. - /// - private const int MaxPageSize = 40; - private readonly IConfiguration _configuration; private readonly IEntitiesContext _entities; @@ -157,125 +147,11 @@ protected static void InitializeServiceBase(DataServiceConfiguration config) config.SetServiceOperationAccessRule("Search", ServiceOperationRights.AllRead); config.SetServiceOperationAccessRule("FindPackagesById", ServiceOperationRights.AllRead); config.SetEntitySetAccessRule("Packages", EntitySetRights.AllRead); - config.SetEntitySetPageSize("Packages", MaxPageSize); + config.SetEntitySetPageSize("Packages", SearchAdaptor.MaxPageSize); config.DataServiceBehavior.MaxProtocolVersion = DataServiceProtocolVersion.V2; config.UseVerboseErrors = true; } - protected virtual IQueryable SearchCore( - IQueryable packages, string searchTerm, string targetFramework, bool includePrerelease) - { - SearchFilter searchFilter; - // We can only use Lucene if the client queries for the latest versions (IsLatest \ IsLatestStable) versions of a package - // and specific sort orders that we have in the index. - if (TryReadSearchFilter(HttpContext.Request, out searchFilter)) - { - searchFilter.SearchTerm = searchTerm; - searchFilter.IncludePrerelease = includePrerelease; - - return GetResultsFromSearchService(packages, searchFilter); - } - - if (!includePrerelease) - { - packages = packages.Where(p => !p.IsPrerelease); - } - return packages.Search(searchTerm); - } - - private IQueryable GetResultsFromSearchService(IQueryable packages, SearchFilter searchFilter) - { - int totalHits; - var result = SearchService.Search(packages, searchFilter, out totalHits); - - // For count queries, we can ask the SearchService to not filter the source results. This would avoid hitting the database and consequently make - // it very fast. - if (searchFilter.CountOnly) - { - // At this point, we already know what the total count is. We can have it return this value very quickly without doing any SQL. - return result.InterceptWith(new CountInterceptor(totalHits)); - } - - // For relevance search, Lucene returns us a paged\sorted list. OData tries to apply default ordering and Take \ Skip on top of this. - // We avoid it by yanking these expressions out of out the tree. - return result.InterceptWith(new DisregardODataInterceptor()); - } - - private bool TryReadSearchFilter(HttpRequestBase request, out SearchFilter searchFilter) - { - var odataQuery = SyntacticTree.ParseUri(new Uri(SiteRoot + request.RawUrl), new Uri(SiteRoot)); - - var keywordPath = odataQuery.Path as KeywordSegmentQueryToken; - searchFilter = new SearchFilter - { - // HACK: The way the default paging works is WCF attempts to read up to the MaxPageSize elements. If it finds as many, it'll assume there - // are more elements to be paged and generate a continuation link. Consequently we'll always ask to pull MaxPageSize elements so WCF generates the - // link for us and then allow it to do a Take on the results. The alternative to do is roll our IDataServicePagingProvider, but we run into - // issues since we need to manage state over concurrent requests. This seems like an easier solution. - Take = MaxPageSize, - Skip = odataQuery.Skip ?? 0, - CountOnly = keywordPath != null && keywordPath.Keyword == KeywordKind.Count, - SortDirection = SortDirection.Ascending - }; - - var filterProperty = odataQuery.Filter as PropertyAccessQueryToken; - if (filterProperty == null || - !(filterProperty.Name.Equals("IsLatestVersion", StringComparison.Ordinal) || - filterProperty.Name.Equals("IsAbsoluteLatestVersion", StringComparison.Ordinal))) - { - // We'll only use the index if we the query searches for latest \ latest-stable packages - return false; - } - - var orderBy = odataQuery.OrderByTokens.FirstOrDefault(); - if (orderBy == null || orderBy.Expression == null) - { - searchFilter.SortProperty = SortProperty.Relevance; - } - else if (orderBy.Expression.Kind == QueryTokenKind.PropertyAccess) - { - var propertyAccess = (PropertyAccessQueryToken)orderBy.Expression; - if (propertyAccess.Name.Equals("DownloadCount", StringComparison.Ordinal)) - { - searchFilter.SortProperty = SortProperty.DownloadCount; - } - else if (propertyAccess.Name.Equals("Published", StringComparison.Ordinal)) - { - searchFilter.SortProperty = SortProperty.Recent; - } - else if (propertyAccess.Name.Equals("Id", StringComparison.Ordinal)) - { - searchFilter.SortProperty = SortProperty.DisplayName; - } - else - { - Debug.WriteLine("Order by clause {0} is unsupported", propertyAccess.Name); - return false; - } - } - else if (orderBy.Expression.Kind == QueryTokenKind.FunctionCall) - { - var functionCall = (FunctionCallQueryToken)orderBy.Expression; - if (functionCall.Name.Equals("concat", StringComparison.OrdinalIgnoreCase)) - { - // We'll assume this is concat(Title, Id) - searchFilter.SortProperty = SortProperty.DisplayName; - searchFilter.SortDirection = orderBy.Direction == OrderByDirection.Descending ? SortDirection.Descending : SortDirection.Ascending; - } - else - { - Debug.WriteLine("Order by clause {0} is unsupported", functionCall.Name); - return false; - } - } - else - { - Debug.WriteLine("Order by clause {0} is unsupported", orderBy.Expression.Kind); - return false; - } - return true; - } - protected virtual bool UseHttps() { return HttpContext.Request.IsSecureConnection; diff --git a/Website/DataServices/PackageExtensions.cs b/Website/DataServices/PackageExtensions.cs index ebaa9e3dc9..c30d8df6a9 100644 --- a/Website/DataServices/PackageExtensions.cs +++ b/Website/DataServices/PackageExtensions.cs @@ -27,7 +27,7 @@ public static IQueryable ToV1FeedPackageQuery(this IQueryable + /// Determines the maximum number of packages returned in a single page of an OData result. + /// + internal const int MaxPageSize = 40; + + public static IQueryable SearchCore( + ISearchService searchService, + HttpRequestBase request, + string siteRoot, + IQueryable packages, + string searchTerm, + string targetFramework, + bool includePrerelease, + IQueryable filterToPackageSet = null) + { + SearchFilter searchFilter; + // We can only use Lucene if the client queries for the latest versions (IsLatest \ IsLatestStable) versions of a package + // and specific sort orders that we have in the index. + if (TryReadSearchFilter(request, siteRoot, out searchFilter)) + { + searchFilter.SearchTerm = searchTerm; + searchFilter.IncludePrerelease = includePrerelease; + + Trace.WriteLine("TODO: use target framework parameter - see #856" + targetFramework); + + var results = GetResultsFromSearchService(searchService, searchFilter, filterToPackageSet); + + return results; + } + + if (!includePrerelease) + { + packages = packages.Where(p => !p.IsPrerelease); + } + return packages.Search(searchTerm); + } + + private static bool TryReadSearchFilter(HttpRequestBase request, string siteRoot, out SearchFilter searchFilter) + { + var odataQuery = SyntacticTree.ParseUri(new Uri(siteRoot + request.RawUrl), new Uri(siteRoot)); + + var keywordPath = odataQuery.Path as KeywordSegmentQueryToken; + searchFilter = new SearchFilter + { + // HACK: The way the default paging works is WCF attempts to read up to the MaxPageSize elements. If it finds as many, it'll assume there + // are more elements to be paged and generate a continuation link. Consequently we'll always ask to pull MaxPageSize elements so WCF generates the + // link for us and then allow it to do a Take on the results. The alternative to do is roll our IDataServicePagingProvider, but we run into + // issues since we need to manage state over concurrent requests. This seems like an easier solution. + Take = MaxPageSize, + Skip = odataQuery.Skip ?? 0, + CountOnly = keywordPath != null && keywordPath.Keyword == KeywordKind.Count, + SortDirection = SortDirection.Ascending + }; + + var filterProperty = odataQuery.Filter as PropertyAccessQueryToken; + if (filterProperty == null || + !(filterProperty.Name.Equals("IsLatestVersion", StringComparison.Ordinal) || + filterProperty.Name.Equals("IsAbsoluteLatestVersion", StringComparison.Ordinal))) + { + // We'll only use the index if we the query searches for latest \ latest-stable packages + return false; + } + + var orderBy = odataQuery.OrderByTokens.FirstOrDefault(); + if (orderBy == null || orderBy.Expression == null) + { + searchFilter.SortProperty = SortProperty.Relevance; + } + else if (orderBy.Expression.Kind == QueryTokenKind.PropertyAccess) + { + var propertyAccess = (PropertyAccessQueryToken)orderBy.Expression; + if (propertyAccess.Name.Equals("DownloadCount", StringComparison.Ordinal)) + { + searchFilter.SortProperty = SortProperty.DownloadCount; + } + else if (propertyAccess.Name.Equals("Published", StringComparison.Ordinal)) + { + searchFilter.SortProperty = SortProperty.Recent; + } + else if (propertyAccess.Name.Equals("Id", StringComparison.Ordinal)) + { + searchFilter.SortProperty = SortProperty.DisplayName; + } + else + { + Debug.WriteLine("Order by clause {0} is unsupported", propertyAccess.Name); + return false; + } + } + else if (orderBy.Expression.Kind == QueryTokenKind.FunctionCall) + { + var functionCall = (FunctionCallQueryToken)orderBy.Expression; + if (functionCall.Name.Equals("concat", StringComparison.OrdinalIgnoreCase)) + { + // We'll assume this is concat(Title, Id) + searchFilter.SortProperty = SortProperty.DisplayName; + searchFilter.SortDirection = orderBy.Direction == OrderByDirection.Descending ? SortDirection.Descending : SortDirection.Ascending; + } + else + { + Debug.WriteLine("Order by clause {0} is unsupported", functionCall.Name); + return false; + } + } + else + { + Debug.WriteLine("Order by clause {0} is unsupported", orderBy.Expression.Kind); + return false; + } + return true; + } + + private static IQueryable GetResultsFromSearchService(ISearchService searchService, SearchFilter searchFilter, IQueryable filterToPackageSet) + { + int totalHits; + var result = searchService.Search(searchFilter, out totalHits, filterToPackageSet); + + // For count queries, we can ask the SearchService to not filter the source results. This would avoid hitting the database and consequently make + // it very fast. + if (searchFilter.CountOnly) + { + // At this point, we already know what the total count is. We can have it return this value very quickly without doing any SQL. + return result.InterceptWith(new CountInterceptor(totalHits)); + } + + // For relevance search, Lucene returns us a paged\sorted list. OData tries to apply default ordering and Take \ Skip on top of this. + // We avoid it by yanking these expressions out of out the tree. + return result.InterceptWith(new DisregardODataInterceptor()); + } + } +} \ No newline at end of file diff --git a/Website/DataServices/V1Feed.svc.cs b/Website/DataServices/V1Feed.svc.cs index cc5f18d89b..7d55fa2a89 100644 --- a/Website/DataServices/V1Feed.svc.cs +++ b/Website/DataServices/V1Feed.svc.cs @@ -66,7 +66,7 @@ public IQueryable Search(string searchTerm, string targetFramewor .Where(p => p.Listed && !p.IsPrerelease); // For v1 feed, only allow stable package versions. - packages = SearchCore(packages, searchTerm, targetFramework, includePrerelease: false); + packages = SearchAdaptor.SearchCore(SearchService, HttpContext.Request, SiteRoot, packages, searchTerm, targetFramework, includePrerelease: false); return packages.ToV1FeedPackageQuery(Configuration.GetSiteRoot(UseHttps())); } } diff --git a/Website/DataServices/V1FeedPackage.cs b/Website/DataServices/V1FeedPackage.cs index 03d12c6482..d9bf7db238 100644 --- a/Website/DataServices/V1FeedPackage.cs +++ b/Website/DataServices/V1FeedPackage.cs @@ -20,7 +20,7 @@ public class V1FeedPackage public string Dependencies { get; set; } public string Description { get; set; } public int DownloadCount { get; set; } - public string ExternalPackageUrl { get; set; } + public string ExternalPackageUrl { get; set; } // deprecated: always null/empty public string GalleryDetailsUrl { get; set; } public string IconUrl { get; set; } public bool IsLatestVersion { get; set; } diff --git a/Website/DataServices/V2CuratedFeed.svc.cs b/Website/DataServices/V2CuratedFeed.svc.cs index 4b522f1c0c..4b56bdb2f8 100644 --- a/Website/DataServices/V2CuratedFeed.svc.cs +++ b/Website/DataServices/V2CuratedFeed.svc.cs @@ -118,14 +118,9 @@ protected override void OnStartProcessingRequest(ProcessRequestArgs args) [WebGet] public IQueryable Search(string searchTerm, string targetFramework, bool includePrerelease) { - var packages = GetPackages(); - - packages = packages.Where(p => p.Listed); - if (!includePrerelease) - { - packages = packages.Where(p => !p.IsPrerelease); - } - return packages.Search(searchTerm).ToV2FeedPackageQuery(Configuration.GetSiteRoot(UseHttps())); + IQueryable curatedPackages = GetPackages(); + return SearchAdaptor.SearchCore(SearchService, HttpContext.Request, SiteRoot, curatedPackages, searchTerm, targetFramework, includePrerelease, filterToPackageSet: curatedPackages) + .ToV2FeedPackageQuery(Configuration.GetSiteRoot(UseHttps())); } public override Uri GetReadStreamUri( diff --git a/Website/DataServices/V2Feed.svc.cs b/Website/DataServices/V2Feed.svc.cs index 65559802d4..f2f1e77c5f 100644 --- a/Website/DataServices/V2Feed.svc.cs +++ b/Website/DataServices/V2Feed.svc.cs @@ -48,7 +48,7 @@ public IQueryable Search(string searchTerm, string targetFramewor .Include(p => p.PackageRegistration) .Include(p => p.PackageRegistration.Owners) .Where(p => p.Listed); - return SearchCore(packages, searchTerm, targetFramework, includePrerelease).ToV2FeedPackageQuery(GetSiteRoot()); + return SearchAdaptor.SearchCore(SearchService, HttpContext.Request, SiteRoot, packages, searchTerm, targetFramework, includePrerelease).ToV2FeedPackageQuery(GetSiteRoot()); } [WebGet] diff --git a/Website/Errors/Error.html b/Website/Errors/Error.html index 918d6045e8..e7082ee960 100644 --- a/Website/Errors/Error.html +++ b/Website/Errors/Error.html @@ -42,6 +42,10 @@

Error: Oh no, we broke something!

- @Html.LabelFor(m => m.Message) + @Html.LabelFor(m => m.Reason) + @Html.DropDownListFor(m => m.Reason, Model.ReasonChoices.Select( + x => new SelectListItem { Value = ReportAbuseViewModel.ReasonDescriptions[x], Text = ReportAbuseViewModel.ReasonDescriptions[x]})) +
+ @Html.CheckBoxFor(m => m.AlreadyContactedOwner) + @Html.LabelFor(m => m.AlreadyContactedOwner, "Yes! I have already tried to contact the package owner about this problem.", + new Dictionary + { + { "style", "display: inline" }, + })
+ @Html.LabelFor(m => m.Message, "Abuse Report") +

In addition to selecting the reason for reporting the package, you must provide details of the problem.

@Html.TextAreaFor(m => m.Message, 10, 50, null) - @Html.ValidationMessageFor(m => m.Message) + @Html.ValidationMessageFor(m => m.Message)
@Html.SpamPreventionFields() @@ -44,7 +62,5 @@ } @section BottomScripts { - - @Html.SpamPreventionScript() } \ No newline at end of file diff --git a/Website/Views/Packages/ReportMyPackage.cshtml b/Website/Views/Packages/ReportMyPackage.cshtml new file mode 100644 index 0000000000..aeac62e1e8 --- /dev/null +++ b/Website/Views/Packages/ReportMyPackage.cshtml @@ -0,0 +1,47 @@ +@using PoliteCaptcha +@model ReportAbuseViewModel +@{ + ViewBag.Tab = "Packages"; +} + +

Contact Support About My Package

+ +

Please select the reason for contacting support about your package.

+ +@using (Html.BeginForm()) +{ +
+ Contact Support + @Html.AntiForgeryToken() +
+ @if (!Model.ConfirmedUser) + { + @Html.LabelFor(m => m.Email) + @Html.EditorFor(m => m.Email) + Provide your email address so we can follow up with you. + @Html.ValidationMessageFor(m => m.Email) + } + else + { + + } +
+
+ @Html.LabelFor(m => m.Reason) + @Html.DropDownListFor(m => m.Reason, Model.ReasonChoices.Select( + x => new SelectListItem { Value = ReportAbuseViewModel.ReasonDescriptions[x], Text = ReportAbuseViewModel.ReasonDescriptions[x]})) + @Html.LabelFor(m => m.Message, "Problem") +

In addition to selecting the reason for reporting the package, you must provide details of the problem.

+ @Html.TextAreaFor(m => m.Message, 10, 50, null) + @Html.ValidationMessageFor(m => m.Message) +
+ @Html.SpamPreventionFields() + +
+} + +@section BottomScripts { + + + @Html.SpamPreventionScript() +} \ No newline at end of file diff --git a/Website/Views/Packages/UploadPackage.cshtml b/Website/Views/Packages/UploadPackage.cshtml index ad6bb8280b..a377870b0d 100644 --- a/Website/Views/Packages/UploadPackage.cshtml +++ b/Website/Views/Packages/UploadPackage.cshtml @@ -6,6 +6,7 @@ @section TopScripts { + @* Right now this is the only page that uses this script. If we increase our usage of it, we should put it in our bundles *@ } diff --git a/Website/Views/Packages/_ListPackage.cshtml b/Website/Views/Packages/_ListPackage.cshtml index 712428e710..9dff28a6f6 100644 --- a/Website/Views/Packages/_ListPackage.cshtml +++ b/Website/Views/Packages/_ListPackage.cshtml @@ -1,9 +1,6 @@ @using Links @model ListPackageItemViewModel -
+
@Model.Id icon @@ -19,7 +16,7 @@ @@ -36,6 +33,13 @@ }

+ @if (!String.IsNullOrEmpty(Model.MinClientVersion)) + { +
+ Requires NuGet @Model.MinClientVersion or higher. +
+ } +

@Model.TotalDownloadCount.ToString("n0") downloads diff --git a/Website/Views/Pages/Contact.cshtml b/Website/Views/Pages/Contact.cshtml new file mode 100644 index 0000000000..f1e702d083 --- /dev/null +++ b/Website/Views/Pages/Contact.cshtml @@ -0,0 +1,38 @@ +@{ + ViewBag.Title = "Contact Us"; +} +

+

+ Contact Us +

+

+ Need help with NuGet? Let us know! +

+ +

Having problems using a specific package?

+

+ If you're having trouble installing a specific package, try contacting the owner of that package first. + You can reach them using the "Contact Owners" link on the package details page. +

+ +

Is a package violating a license or otherwise abusive?

+

+ If you feel that a package is violating the license of software you own or is violating our terms of service, + use the "Report Abuse" link on the package details page to report it directly to us. +

+ +

Found a bug in NuGet or the website NuGet.org?

+

+ If you're having trouble with the NuGet.org Website, + file a bug on the NuGet Gallery Issue Tracker. +

+

+ If you're having trouble with the NuGet client tools (the Visual Studio extension, WebMatrix extension, NuGet.exe command line tool, etc.), + file a bug on the NuGet Client Issue Tracker +

+ +

Talk to the NuGet development team and wider NuGet community

+

+ For support and general discussions we have the NuGet discussion boards. +

+
\ No newline at end of file diff --git a/Website/Views/Pages/Home.cshtml b/Website/Views/Pages/Home.cshtml index 0c1f124f86..212f3e7a9d 100644 --- a/Website/Views/Pages/Home.cshtml +++ b/Website/Views/Pages/Home.cshtml @@ -55,7 +55,8 @@ @section BottomScripts { - + @* Right now this is the only page that uses this script. If we increase our usage of it, we should put it in our bundles *@ + @Scripts.Render("~/Scripts/stats.js") diff --git a/Website/Views/Shared/Layout.cshtml b/Website/Views/Shared/Layout.cshtml index b7259f560f..7eb5b90ab9 100644 --- a/Website/Views/Shared/Layout.cshtml +++ b/Website/Views/Shared/Layout.cshtml @@ -6,10 +6,9 @@ @RenderSection("OpenGraph", required: false) NuGet Gallery @(String.IsNullOrWhiteSpace(ViewBag.Title) ? "" : "| " + ViewBag.Title) - + @Styles.Render("~/bundles/css") - - + @Scripts.Render("~/bundles/modernizr") @MiniProfiler.RenderIncludes() @ViewHelpers.AnalyticsScript() @RenderSection("TopScripts", required: false) @@ -58,6 +57,10 @@
+ @Scripts.Render("~/bundles/js") @RenderSection("BottomScripts", required: false) diff --git a/Website/Views/Statistics/Index.cshtml b/Website/Views/Statistics/Index.cshtml index b6931f06f6..9b755a13e9 100644 --- a/Website/Views/Statistics/Index.cshtml +++ b/Website/Views/Statistics/Index.cshtml @@ -51,7 +51,7 @@ @item.PackageId @item.PackageVersion - @item.Downloads + @item.Downloads } diff --git a/Website/Views/Statistics/PackageDownloadsByVersion.cshtml b/Website/Views/Statistics/PackageDownloadsByVersion.cshtml index 0faa955e56..d83e144d08 100644 --- a/Website/Views/Statistics/PackageDownloadsByVersion.cshtml +++ b/Website/Views/Statistics/PackageDownloadsByVersion.cshtml @@ -5,7 +5,7 @@ }

Package Downloads for @Model.PackageId (Over the Last 6 Weeks)

-@if (Model.IsDownloadPackageByVersionAvailable) +@if (Model.IsReportAvailable) { @@ -14,16 +14,16 @@ - @foreach (var item in Model.PackageDownloadsByVersion) + @foreach (var item in Model.Report.Rows) { - + } - +
@item.PackageVersion@item.Downloads@item.Downloads
Total:@Model.TotalPackageDownloads@Model.Report.Total
@@ -33,4 +33,4 @@ else

Download statistics are not currently available for this package, please check back later.

-} \ No newline at end of file +} diff --git a/Website/Views/Statistics/PackageDownloadsDetail.cshtml b/Website/Views/Statistics/PackageDownloadsDetail.cshtml new file mode 100644 index 0000000000..d6806ba4c0 --- /dev/null +++ b/Website/Views/Statistics/PackageDownloadsDetail.cshtml @@ -0,0 +1,37 @@ +@model StatisticsPackagesViewModel +@{ + ViewBag.Title = "Package Downloads for " + Model.PackageId + "/" + Model.PackageVersion; + ViewBag.Tab = "Statistics"; +} + +

Package Downloads for @Model.PackageId @Model.PackageVersion (Over the Last 6 Weeks)

+@if (Model.IsReportAvailable) +{ + + + + + + + + @foreach (var item in Model.Report.Rows) + { + + + + + + } + + + + + +
ClientOperationDownloads
@item.Client@item.Operation@item.Downloads
Total:@Model.Report.Total
+} +else +{ +

+ Download statistics are not currently available for this package, please check back later. +

+} diff --git a/Website/Views/Statistics/PackageVersions.cshtml b/Website/Views/Statistics/PackageVersions.cshtml index 24b3ec0d19..2376199155 100644 --- a/Website/Views/Statistics/PackageVersions.cshtml +++ b/Website/Views/Statistics/PackageVersions.cshtml @@ -17,7 +17,7 @@ @item.PackageId @item.PackageVersion - @item.Downloads + @item.Downloads } diff --git a/Website/Views/Users/Account.cshtml b/Website/Views/Users/Account.cshtml index a9628be783..90399beb79 100644 --- a/Website/Views/Users/Account.cshtml +++ b/Website/Views/Users/Account.cshtml @@ -90,6 +90,7 @@ nuget.exe push MyPackage.1.0.nupkg
@section BottomScripts { + @* Right now this is the only page that uses this script. If we increase our usage of it, we should put it in our bundles *@ - } \ No newline at end of file diff --git a/Website/Views/Users/Edit.cshtml b/Website/Views/Users/Edit.cshtml index f964a7efa5..bdee759783 100644 --- a/Website/Views/Users/Edit.cshtml +++ b/Website/Views/Users/Edit.cshtml @@ -50,9 +50,4 @@ Cancel -} - -@section BottomScripts { - - } \ No newline at end of file diff --git a/Website/Views/Users/ForgotPassword.cshtml b/Website/Views/Users/ForgotPassword.cshtml index 8a4607ccfc..d5cc318591 100644 --- a/Website/Views/Users/ForgotPassword.cshtml +++ b/Website/Views/Users/ForgotPassword.cshtml @@ -25,9 +25,4 @@ Cancel -} - -@section BottomScripts { - - } \ No newline at end of file diff --git a/Website/Views/Users/Register.cshtml b/Website/Views/Users/Register.cshtml index 8a0f6c9a8f..fec541d555 100644 --- a/Website/Views/Users/Register.cshtml +++ b/Website/Views/Users/Register.cshtml @@ -26,9 +26,4 @@ -} - -@section BottomScripts { - - } \ No newline at end of file diff --git a/Website/Views/Users/ResendConfirmation.cshtml b/Website/Views/Users/ResendConfirmation.cshtml index 66db4d5c52..331ba7873a 100644 --- a/Website/Views/Users/ResendConfirmation.cshtml +++ b/Website/Views/Users/ResendConfirmation.cshtml @@ -28,9 +28,4 @@ Cancel -} - -@section BottomScripts { - - } \ No newline at end of file diff --git a/Website/Views/Users/ResetPassword.cshtml b/Website/Views/Users/ResetPassword.cshtml index 35633e841a..2763cdaa7c 100644 --- a/Website/Views/Users/ResetPassword.cshtml +++ b/Website/Views/Users/ResetPassword.cshtml @@ -26,9 +26,4 @@ Cancel -} - -@section BottomScripts { - - } \ No newline at end of file diff --git a/Website/Views/web.config b/Website/Views/web.config index 6bceaa1404..38fe715e2b 100644 --- a/Website/Views/web.config +++ b/Website/Views/web.config @@ -17,6 +17,7 @@ + diff --git a/Website/Web.config b/Website/Web.config index f7221c99e4..329911be58 100644 --- a/Website/Web.config +++ b/Website/Web.config @@ -15,11 +15,13 @@
-
+
- - + + + + @@ -27,19 +29,17 @@ - - + - + --> + - - + @@ -47,13 +47,14 @@ - + + @@ -193,11 +194,19 @@ + + + + + + + + - + @@ -302,4 +311,4 @@ - + \ No newline at end of file diff --git a/Website/Website.csproj b/Website/Website.csproj index c92123349b..77fb575563 100644 --- a/Website/Website.csproj +++ b/Website/Website.csproj @@ -27,14 +27,6 @@ 4.0 - 44300 - - - - 44300 - enabled - disabled - false @@ -61,6 +53,10 @@ False ..\packages\AnglicanGeek.MarkdownMailer.1.2\lib\net40\AnglicanGeek.MarkdownMailer.dll + + False + ..\packages\WebGrease.1.1.0\lib\Antlr3.Runtime.dll + False ..\packages\DynamicData.EFCodeFirstProvider.0.3.0.0\lib\net40\DynamicData.EFCodeFirstProvider.dll @@ -150,9 +146,9 @@ ..\packages\Ninject.MVC3.2.2.2.0\lib\net40-Full\Ninject.Web.Mvc.dll - + False - ..\packages\Nuget.Core.2.3.0-alpha002\lib\net40-Client\NuGet.Core.dll + ..\packages\Nuget.Core.2.3.0-alpha003\lib\net40-Client\NuGet.Core.dll ..\packages\ODataNullPropagationVisitor.0.5.4237.2641\lib\net40\ODataNullPropagationVisitor.dll @@ -197,6 +193,10 @@ True ..\packages\Microsoft.AspNet.Mvc.4.0.20710.0\lib\net40\System.Web.Mvc.dll + + False + ..\packages\Microsoft.AspNet.Web.Optimization.1.0.0\lib\net40\System.Web.Optimization.dll + True ..\packages\Microsoft.AspNet.Razor.2.0.20715.0\lib\net40\System.Web.Razor.dll @@ -237,6 +237,10 @@ False ..\packages\WebBackgrounder.EntityFramework.0.1\lib\net40\WebBackgrounder.EntityFramework.dll + + False + ..\packages\WebGrease.1.1.0\lib\WebGrease.dll + @@ -266,6 +270,7 @@ Code + V2CuratedFeed.svc @@ -305,6 +310,8 @@ + + @@ -477,7 +484,6 @@ - T4MVC.tt @@ -503,14 +509,11 @@ - - - @@ -527,6 +530,7 @@ + @@ -546,6 +550,7 @@ + @@ -980,6 +985,8 @@ + + @@ -988,7 +995,6 @@ - @@ -1082,6 +1088,9 @@ + + + @@ -1106,16 +1115,7 @@ - True - True - 55880 - / - http://localhost:55880/ - False - False - - - False + True diff --git a/Website/packages.config b/Website/packages.config index 403b15c906..cff7d38ed9 100644 --- a/Website/packages.config +++ b/Website/packages.config @@ -10,11 +10,13 @@ + + @@ -30,7 +32,7 @@ - + @@ -41,6 +43,7 @@ +