From 64b1ae35984361cc06e6b803bd86fab1814bec1a Mon Sep 17 00:00:00 2001 From: Timothy Baldridge Date: Wed, 13 May 2020 15:52:34 -0600 Subject: [PATCH] Add List Validation back into the app --- Wabbajack.Server.Test/ADBTest.cs | 4 +- .../ModListValidationTests.cs | 85 ++++++++++++ Wabbajack.Server/Controllers/ListsStatus.cs | 130 ++++++++++++++++++ Wabbajack.Server/DTOs/ArchiveStatus.cs | 10 ++ Wabbajack.Server/DTOs/DetailedStatus.cs | 26 ++++ Wabbajack.Server/DTOs/ValidationData.cs | 14 ++ .../DataLayer/NonNexusModlistArchives.cs | 47 +++++++ Wabbajack.Server/DataLayer/ValidationData.cs | 54 ++++++++ Wabbajack.Server/Services/AbstractService.cs | 64 +++++++++ Wabbajack.Server/Services/ListValidator.cs | 108 +++++++++++++++ .../Services/NonNexusDownloadValidator.cs | 53 +++++++ Wabbajack.Server/Startup.cs | 9 +- 12 files changed, 601 insertions(+), 3 deletions(-) create mode 100644 Wabbajack.Server/Controllers/ListsStatus.cs create mode 100644 Wabbajack.Server/DTOs/ArchiveStatus.cs create mode 100644 Wabbajack.Server/DTOs/DetailedStatus.cs create mode 100644 Wabbajack.Server/DTOs/ValidationData.cs create mode 100644 Wabbajack.Server/DataLayer/NonNexusModlistArchives.cs create mode 100644 Wabbajack.Server/DataLayer/ValidationData.cs create mode 100644 Wabbajack.Server/Services/AbstractService.cs create mode 100644 Wabbajack.Server/Services/ListValidator.cs create mode 100644 Wabbajack.Server/Services/NonNexusDownloadValidator.cs diff --git a/Wabbajack.Server.Test/ADBTest.cs b/Wabbajack.Server.Test/ADBTest.cs index c5942b01..67ec54bc 100644 --- a/Wabbajack.Server.Test/ADBTest.cs +++ b/Wabbajack.Server.Test/ADBTest.cs @@ -39,8 +39,7 @@ namespace Wabbajack.BuildServer.Test private async Task CreateSchema() { - Utils.Log("Creating Database"); - //var conn = new SqlConnection("Data Source=localhost,1433;User ID=test;Password=test;MultipleActiveResultSets=true"); + Utils.Log($"Creating Database {DBName}"); await using var conn = new SqlConnection(CONN_STR); await conn.OpenAsync(); @@ -61,6 +60,7 @@ namespace Wabbajack.BuildServer.Test await new SqlCommand($"INSERT INTO dbo.ApiKeys (APIKey, Owner) VALUES ('{APIKey}', '{User}');", conn).ExecuteNonQueryAsync(); _finishedSchema = true; + Utils.Log($"Finished creating database {DBName}"); } private static IEnumerable SplitSqlStatements(string sqlScript) diff --git a/Wabbajack.Server.Test/ModListValidationTests.cs b/Wabbajack.Server.Test/ModListValidationTests.cs index 31f2dbdf..d20c39a7 100644 --- a/Wabbajack.Server.Test/ModListValidationTests.cs +++ b/Wabbajack.Server.Test/ModListValidationTests.cs @@ -9,6 +9,7 @@ using Wabbajack.Lib; using Wabbajack.Lib.Downloaders; using Wabbajack.Lib.ModListRegistry; using Wabbajack.Server.DataLayer; +using Wabbajack.Server.DTOs; using Wabbajack.Server.Services; using Xunit; using Xunit.Abstractions; @@ -50,6 +51,90 @@ namespace Wabbajack.BuildServer.Test } + [Fact] + public async Task CanValidateModLists() + { + var modlists = await MakeModList(); + Consts.ModlistMetadataURL = modlists.ToString(); + Utils.Log("Updating modlists"); + await RevalidateLists(true); + + Utils.Log("Checking validated results"); + var data = (await ModlistMetadata.LoadFromGithub()).FirstOrDefault(l => l.Links.MachineURL == "test_list"); + Assert.NotNull(data); + Assert.Equal(0, data.ValidationSummary.Failed); + Assert.Equal(1, data.ValidationSummary.Passed); + + await CheckListFeeds(0, 1); + + Utils.Log("Break List"); + var archive = "test_archive.txt".RelativeTo(Fixture.ServerPublicFolder); + await archive.MoveToAsync(archive.WithExtension(new Extension(".moved")), true); + + // We can revalidate but the non-nexus archives won't be checked yet since the list didn't change + await RevalidateLists(false); + + data = (await ModlistMetadata.LoadFromGithub()).FirstOrDefault(l => l.Links.MachineURL == "test_list"); + Assert.NotNull(data); + Assert.Equal(0, data.ValidationSummary.Failed); + Assert.Equal(1, data.ValidationSummary.Passed); + + // Run the non-nexus validator + await RevalidateLists(true); + + data = (await ModlistMetadata.LoadFromGithub()).FirstOrDefault(l => l.Links.MachineURL == "test_list"); + Assert.NotNull(data); + Assert.Equal(1, data.ValidationSummary.Failed); + Assert.Equal(0, data.ValidationSummary.Passed); + + await CheckListFeeds(1, 0); + + Utils.Log("Fix List"); + await archive.WithExtension(new Extension(".moved")).MoveToAsync(archive, false); + + await RevalidateLists(true); + + data = (await ModlistMetadata.LoadFromGithub()).FirstOrDefault(l => l.Links.MachineURL == "test_list"); + Assert.NotNull(data); + Assert.Equal(0, data.ValidationSummary.Failed); + Assert.Equal(1, data.ValidationSummary.Passed); + + await CheckListFeeds(0, 1); + + } + + private async Task RevalidateLists(bool runNonNexus) + { + + var downloader = Fixture.GetService(); + await downloader.CheckForNewLists(); + + if (runNonNexus) + { + var nonNexus = Fixture.GetService(); + await nonNexus.Execute(); + } + + var validator = Fixture.GetService(); + await validator.Execute(); + } + + private async Task CheckListFeeds(int failed, int passed) + { + var statusJson = await _client.GetJsonAsync(MakeURL("lists/status/test_list.json")); + Assert.Equal(failed, statusJson.Archives.Count(a => a.IsFailing)); + Assert.Equal(passed, statusJson.Archives.Count(a => !a.IsFailing)); + + + var statusHtml = await _client.GetHtmlAsync(MakeURL("lists/status/test_list.html")); + Assert.NotEmpty(statusHtml.DocumentNode.Descendants().Where(n => n.InnerHtml == $"Failed ({failed}):")); + Assert.NotEmpty(statusHtml.DocumentNode.Descendants().Where(n => n.InnerHtml == $"Passed ({passed}):")); + + var statusRss = await _client.GetHtmlAsync(MakeURL("lists/status/test_list/broken.rss")); + Assert.Equal(failed, statusRss.DocumentNode.SelectNodes("//item")?.Count ?? 0); + } + + private async Task MakeModList() { var archive_data = Encoding.UTF8.GetBytes("Cheese for Everyone!"); diff --git a/Wabbajack.Server/Controllers/ListsStatus.cs b/Wabbajack.Server/Controllers/ListsStatus.cs new file mode 100644 index 00000000..8f78ca15 --- /dev/null +++ b/Wabbajack.Server/Controllers/ListsStatus.cs @@ -0,0 +1,130 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using Nettle; +using Wabbajack.Common; +using Wabbajack.Lib.ModListRegistry; +using Wabbajack.Server.DataLayer; +using Wabbajack.Server.DTOs; +using Wabbajack.Server.Services; + +namespace Wabbajack.BuildServer.Controllers +{ + [ApiController] + [Route("/lists")] + public class ListsStatus : ControllerBase + { + private ILogger _logger; + private ListValidator _validator; + + public ListsStatus(ILogger logger, ListValidator validator) + { + _logger = logger; + _validator = validator; + } + + [HttpGet] + [Route("status.json")] + public async Task> HandleGetLists() + { + return (_validator.Summaries).Select(d => d.Summary); + } + + + private static readonly Func HandleGetRssFeedTemplate = NettleEngine.GetCompiler().Compile(@" + + + + {{lst.Name}} - Broken Mods + http://build.wabbajack.org/status/{{lst.Name}}.html + These are mods that are broken and need updating + {{ each $.failed }} + + {{$.Archive.Name}} {{$.Archive.Hash}} {{$.Archive.State.PrimaryKeyString}} + {{$.Archive.Name}} + + {{/each}} + + + "); + + [HttpGet] + [Route("status/{Name}/broken.rss")] + public async Task HandleGetRSSFeed(string Name) + { + var lst = await DetailedStatus(Name); + var response = HandleGetRssFeedTemplate(new + { + lst, + failed = lst.Archives.Where(a => a.IsFailing).ToList(), + passed = lst.Archives.Where(a => !a.IsFailing).ToList() + }); + return new ContentResult + { + ContentType = "application/rss+xml", + StatusCode = (int) HttpStatusCode.OK, + Content = response + }; + } + + private static readonly Func HandleGetListTemplate = NettleEngine.GetCompiler().Compile(@" + +

{{lst.Name}} - {{lst.Checked}} - {{ago}}min ago

+

Failed ({{failed.Count}}):

+
    + {{each $.failed }} +
  • {{$.Archive.Name}}
  • + {{/each}} +
+

Passed ({{passed.Count}}):

+
    + {{each $.passed }} +
  • {{$.Archive.Name}}
  • + {{/each}} +
+ + "); + + [HttpGet] + [Route("status/{Name}.html")] + public async Task HandleGetListHtml(string Name) + { + + var lst = await DetailedStatus(Name); + var response = HandleGetListTemplate(new + { + lst, + ago = (DateTime.UtcNow - lst.Checked).TotalMinutes, + failed = lst.Archives.Where(a => a.IsFailing).ToList(), + passed = lst.Archives.Where(a => !a.IsFailing).ToList() + }); + return new ContentResult + { + ContentType = "text/html", + StatusCode = (int) HttpStatusCode.OK, + Content = response + }; + } + + [HttpGet] + [Route("status/{Name}.json")] + public async Task HandleGetListJson(string Name) + { + return Ok((await DetailedStatus(Name)).ToJson()); + } + + private async Task DetailedStatus(string Name) + { + return _validator.Summaries + .Select(d => d.Detailed) + .FirstOrDefault(d => d.MachineName == Name); + } + + + + } +} diff --git a/Wabbajack.Server/DTOs/ArchiveStatus.cs b/Wabbajack.Server/DTOs/ArchiveStatus.cs new file mode 100644 index 00000000..0e44971e --- /dev/null +++ b/Wabbajack.Server/DTOs/ArchiveStatus.cs @@ -0,0 +1,10 @@ +namespace Wabbajack.Server.DTOs +{ + enum ArchiveStatus + { + Valid, + InValid, + Updating, + Updated, + } +} diff --git a/Wabbajack.Server/DTOs/DetailedStatus.cs b/Wabbajack.Server/DTOs/DetailedStatus.cs new file mode 100644 index 00000000..32372214 --- /dev/null +++ b/Wabbajack.Server/DTOs/DetailedStatus.cs @@ -0,0 +1,26 @@ +using System; +using System.Collections.Generic; +using Wabbajack.Common.Serialization.Json; +using Wabbajack.Lib; +using Wabbajack.Lib.ModListRegistry; + +namespace Wabbajack.Server.DTOs +{ + [JsonName("DetailedStatus")] + public class DetailedStatus + { + public string Name { get; set; } + public DateTime Checked { get; set; } = DateTime.UtcNow; + public List Archives { get; set; } + public DownloadMetadata DownloadMetaData { get; set; } + public bool HasFailures { get; set; } + public string MachineName { get; set; } + } + + [JsonName("DetailedStatusItem")] + public class DetailedStatusItem + { + public bool IsFailing { get; set; } + public Archive Archive { get; set; } + } +} diff --git a/Wabbajack.Server/DTOs/ValidationData.cs b/Wabbajack.Server/DTOs/ValidationData.cs new file mode 100644 index 00000000..2db82ff7 --- /dev/null +++ b/Wabbajack.Server/DTOs/ValidationData.cs @@ -0,0 +1,14 @@ +using System.Collections.Generic; +using Wabbajack.Common; +using Wabbajack.Lib; +using Wabbajack.Lib.ModListRegistry; + +namespace Wabbajack.Server.DTOs +{ + public class ValidationData + { + public HashSet<(long Game, long ModId, long FileId)> NexusFiles { get; set; } + public Dictionary<(string PrimaryKeyString, Hash Hash), bool> ArchiveStatus { get; set; } + public List<(ModlistMetadata Metadata, ModList ModList)> ModLists { get; set; } + } +} diff --git a/Wabbajack.Server/DataLayer/NonNexusModlistArchives.cs b/Wabbajack.Server/DataLayer/NonNexusModlistArchives.cs new file mode 100644 index 00000000..7df07b48 --- /dev/null +++ b/Wabbajack.Server/DataLayer/NonNexusModlistArchives.cs @@ -0,0 +1,47 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Dapper; +using Wabbajack.Common; +using Wabbajack.Lib; +using Wabbajack.Lib.Downloaders; + +namespace Wabbajack.Server.DataLayer +{ + public partial class SqlService + { + + public async Task> GetNonNexusModlistArchives() + { + await using var conn = await Open(); + var results = await conn.QueryAsync<(Hash Hash, long Size, string State)>( + @"SELECT Hash, Size, State FROM dbo.ModListArchives WHERE PrimaryKeyString NOT LIKE 'NexusDownloader+State|%'"); + return results.Select(r => new Archive (r.State.FromJsonString()) + { + Size = r.Size, + Hash = r.Hash, + + }).ToList();} + + public async Task UpdateNonNexusModlistArchivesStatus(IEnumerable<(Archive Archive, bool IsValid)> results) + { + await using var conn = await Open(); + var trans = await conn.BeginTransactionAsync(); + await conn.ExecuteAsync("DELETE FROM dbo.ModlistArchiveStatus;", transaction:trans); + + foreach (var itm in results.DistinctBy(itm => (itm.Archive.Hash, itm.Archive.State.PrimaryKeyString))) + { + await conn.ExecuteAsync( + @"INSERT INTO dbo.ModlistArchiveStatus (PrimaryKeyStringHash, PrimaryKeyString, Hash, IsValid) + VALUES (HASHBYTES('SHA2_256', @PrimaryKeyString), @PrimaryKeyString, @Hash, @IsValid)", new + { + PrimaryKeyString = itm.Archive.State.PrimaryKeyString, + Hash = itm.Archive.Hash, + IsValid = itm.IsValid + }, trans); + } + + await trans.CommitAsync(); + } + } +} diff --git a/Wabbajack.Server/DataLayer/ValidationData.cs b/Wabbajack.Server/DataLayer/ValidationData.cs new file mode 100644 index 00000000..f826104d --- /dev/null +++ b/Wabbajack.Server/DataLayer/ValidationData.cs @@ -0,0 +1,54 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Dapper; +using Wabbajack.Common; +using Wabbajack.Lib; +using Wabbajack.Lib.ModListRegistry; +using Wabbajack.Server.DTOs; + +namespace Wabbajack.Server.DataLayer +{ + public partial class SqlService + { + public async Task GetValidationData() + { + var nexusFiles = AllNexusFiles(); + var archiveStatus = AllModListArchivesStatus(); + var modLists = AllModLists(); + return new ValidationData + { + NexusFiles = await nexusFiles, + ArchiveStatus = await archiveStatus, + ModLists = await modLists, + }; + } + + public async Task> AllModListArchivesStatus() + { + await using var conn = await Open(); + var results = + await conn.QueryAsync<(string, Hash, bool)>( + @"SELECT PrimaryKeyString, Hash, IsValid FROM dbo.ModListArchiveStatus"); + return results.ToDictionary(v => (v.Item1, v.Item2), v => v.Item3); + } + + public async Task> AllNexusFiles() + { + await using var conn = await Open(); + var results = await conn.QueryAsync<(long, long, long)>(@"SELECT Game, ModId, p.file_id + FROM [NexusModFiles] files + CROSS APPLY + OPENJSON(Data, '$.files') WITH (file_id bigint '$.file_id', category varchar(max) '$.category_name') p + WHERE p.category is not null"); + return results.ToHashSet(); + } + + public async Task> AllModLists() + { + await using var conn = await Open(); + var results = await conn.QueryAsync<(string, string)>(@"SELECT Metadata, ModList FROM dbo.ModLists"); + return results.Select(m => (m.Item1.FromJsonString(), m.Item2.FromJsonString())).ToList(); + } + } +} diff --git a/Wabbajack.Server/Services/AbstractService.cs b/Wabbajack.Server/Services/AbstractService.cs new file mode 100644 index 00000000..3c42904b --- /dev/null +++ b/Wabbajack.Server/Services/AbstractService.cs @@ -0,0 +1,64 @@ +using System; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.Logging; +using Wabbajack.BuildServer; + +namespace Wabbajack.Server.Services +{ + public interface IStartable + { + public void Start(); + } + + public abstract class AbstractService : IStartable + { + protected AppSettings _settings; + private TimeSpan _delay; + protected ILogger _logger; + + public AbstractService(ILogger logger, AppSettings settings, TimeSpan delay) + { + _settings = settings; + _delay = delay; + _logger = logger; + } + + public void Start() + { + if (_settings.RunBackEndJobs) + { + Task.Run(async () => + { + while (true) + { + + try + { + await Execute(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Running Service Loop"); + } + + await Task.Delay(_delay); + } + + }); + } + } + + public abstract Task Execute(); + } + + public static class AbstractServiceExtensions + { + public static void UseService(this IApplicationBuilder b) + { + var poll = (IStartable)b.ApplicationServices.GetService(typeof(T)); + poll.Start(); + } + + } +} diff --git a/Wabbajack.Server/Services/ListValidator.cs b/Wabbajack.Server/Services/ListValidator.cs new file mode 100644 index 00000000..a6d20e32 --- /dev/null +++ b/Wabbajack.Server/Services/ListValidator.cs @@ -0,0 +1,108 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging; +using RocksDbSharp; +using Wabbajack.BuildServer; +using Wabbajack.Common; +using Wabbajack.Lib; +using Wabbajack.Lib.Downloaders; +using Wabbajack.Lib.ModListRegistry; +using Wabbajack.Server.DataLayer; +using Wabbajack.Server.DTOs; + +namespace Wabbajack.Server.Services +{ + public class ListValidator : AbstractService + { + private SqlService _sql; + + public IEnumerable<(ModListSummary Summary, DetailedStatus Detailed)> Summaries { get; private set; } = + new (ModListSummary Summary, DetailedStatus Detailed)[0]; + + + public ListValidator(ILogger logger, AppSettings settings, SqlService sql) + : base(logger, settings, TimeSpan.FromMinutes(10)) + { + _sql = sql; + } + + public override async Task Execute() + { + var data = await _sql.GetValidationData(); + + using var queue = new WorkQueue(); + + var results = await data.ModLists.PMap(queue, async list => + { + var (metadata, modList) = list; + var archives = await modList.Archives.PMap(queue, async archive => + { + var (_, result) = ValidateArchive(data, archive); + // TODO : auto-healing goes here + return (archive, result); + }); + + var failedCount = archives.Count(f => f.Item2 == ArchiveStatus.InValid); + var passCount = archives.Count(f => f.Item2 == ArchiveStatus.Valid || f.Item2 == ArchiveStatus.Updated); + var updatingCount = archives.Count(f => f.Item2 == ArchiveStatus.Updating); + + var summary = new ModListSummary + { + Checked = DateTime.UtcNow, + Failed = failedCount, + Passed = passCount, + Updating = updatingCount, + MachineURL = metadata.Links.MachineURL, + Name = metadata.Title, + }; + + var detailed = new DetailedStatus + { + Name = metadata.Title, + Checked = DateTime.UtcNow, + DownloadMetaData = metadata.DownloadMetadata, + HasFailures = failedCount > 0, + MachineName = metadata.Links.MachineURL, + Archives = archives.Select(a => new DetailedStatusItem + { + Archive = a.Item1, IsFailing = a.Item2 == ArchiveStatus.InValid || a.Item2 == ArchiveStatus.Updating + }).ToList() + }; + + return (summary, detailed); + }); + Summaries = results; + return Summaries.Count(s => s.Summary.HasFailures); + } + + private static (Archive archive, ArchiveStatus) ValidateArchive(ValidationData data, Archive archive) + { + switch (archive.State) + { + case GoogleDriveDownloader.State _: + // Disabled for now due to GDrive rate-limiting the build server + return (archive, ArchiveStatus.Valid); + case NexusDownloader.State nexusState when data.NexusFiles.Contains(( + nexusState.Game.MetaData().NexusGameId, nexusState.ModID, nexusState.FileID)): + return (archive, ArchiveStatus.Valid); + case NexusDownloader.State _: + return (archive, ArchiveStatus.InValid); + case ManualDownloader.State _: + return (archive, ArchiveStatus.Valid); + default: + { + if (data.ArchiveStatus.TryGetValue((archive.State.PrimaryKeyString, archive.Hash), + out bool isValid)) + { + return isValid ? (archive, ArchiveStatus.Valid) : (archive, ArchiveStatus.InValid); + } + + return (archive, ArchiveStatus.InValid); + } + } + } + } +} diff --git a/Wabbajack.Server/Services/NonNexusDownloadValidator.cs b/Wabbajack.Server/Services/NonNexusDownloadValidator.cs new file mode 100644 index 00000000..358674c3 --- /dev/null +++ b/Wabbajack.Server/Services/NonNexusDownloadValidator.cs @@ -0,0 +1,53 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using Dapper; +using Microsoft.Extensions.Logging; +using Splat; +using Wabbajack.BuildServer; +using Wabbajack.Common; +using Wabbajack.Server.DataLayer; +using LogLevel = Microsoft.Extensions.Logging.LogLevel; + +namespace Wabbajack.Server.Services +{ + public class NonNexusDownloadValidator : AbstractService + { + private SqlService _sql; + + public NonNexusDownloadValidator(ILogger logger, AppSettings settings, SqlService sql) + : base(logger, settings, TimeSpan.FromHours(2)) + { + _sql = sql; + } + + public override async Task Execute() + { + var archives = await _sql.GetNonNexusModlistArchives(); + _logger.Log(LogLevel.Information, "Validating {archives.Count} non-Nexus archives"); + using var queue = new WorkQueue(); + var results = await archives.PMap(queue, async archive => + { + try + { + var isValid = await archive.State.Verify(archive); + return (Archive: archive, IsValid: isValid); + } + catch (Exception) + { + return (Archive: archive, IsValid: false); + } + + }); + + await _sql.UpdateNonNexusModlistArchivesStatus(results); + var failed = results.Count(r => !r.IsValid); + var passed = results.Count() - failed; + foreach(var (archive, _) in results.Where(f => f.IsValid)) + _logger.Log(LogLevel.Warning, $"Validation failed for {archive.Name} from {archive.State.PrimaryKeyString}"); + + _logger.Log(LogLevel.Information, $"Non-nexus validation completed {failed} out of {passed} failed"); + return failed; + } + } +} diff --git a/Wabbajack.Server/Startup.cs b/Wabbajack.Server/Startup.cs index 61935204..8f74ded4 100644 --- a/Wabbajack.Server/Startup.cs +++ b/Wabbajack.Server/Startup.cs @@ -1,4 +1,5 @@ -using System.Diagnostics; +using System.Collections.Generic; +using System.Diagnostics; using System.IO; using System.Threading.Tasks; using Microsoft.AspNetCore.Builder; @@ -59,6 +60,9 @@ namespace Wabbajack.Server services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddMvc(); services.AddControllers() .AddNewtonsoftJson(o => @@ -103,6 +107,9 @@ namespace Wabbajack.Server app.UseNexusPoll(); app.UseArchiveMaintainer(); app.UseModListDownloader(); + + app.UseService(); + app.UseService(); app.Use(next => {