diff --git a/.editorconfig b/.editorconfig index 630b6f4..76103ed 100644 --- a/.editorconfig +++ b/.editorconfig @@ -3,3 +3,4 @@ end_of_line = lf charset = utf-8 trim_trailing_whitespace = true insert_final_newline = true +indent_style = tab diff --git a/Controllers/Seeder.cs b/Controllers/Seeder.cs deleted file mode 100644 index 8892d61..0000000 --- a/Controllers/Seeder.cs +++ /dev/null @@ -1,29 +0,0 @@ -using Microsoft.AspNetCore.Mvc; -using Microsoft.EntityFrameworkCore; -using Shadow.Controllers; -using Shadow.Data; -using Shadow.Entities; - -namespace Shadow.Controllers; -public class Seeder -{ - private readonly ApplicationDbContext db; - private readonly GeneralUseHelpers guhf; - - public Seeder(ApplicationDbContext _db, GeneralUseHelpers _guhf) - { - db = _db; - guhf = _guhf; - } - public void Seed() - { - - // TODO: Ensure [unknown album], [unknown artist] exist - - // TODO: Force add a new user through CLI if no users exist - - - Console.WriteLine($"You're running Shadow, commit {ThisAssembly.Git.Commit} of branch {ThisAssembly.Git.Branch} ({ThisAssembly.Git.CommitDate})\n"); - - } -} diff --git a/Program.cs b/Program.cs index 690c206..7b17bb7 100644 --- a/Program.cs +++ b/Program.cs @@ -1,8 +1,8 @@ using Microsoft.AspNetCore.Mvc.Formatters; using Microsoft.EntityFrameworkCore; using Microsoft.OpenApi; -using Shadow.Controllers; using Shadow.Data; +using Shadow.Tools; using System.Reflection; var builder = WebApplication.CreateBuilder(args); @@ -66,12 +66,13 @@ builder.Services.AddSwaggerGen(options => var xmlFilename = $"{Assembly.GetExecutingAssembly().GetName().Name}.xml"; options.IncludeXmlComments(Path.Combine(AppContext.BaseDirectory, xmlFilename)); }); +builder.Services.AddHttpLogging(); var app = builder.Build(); +bool shutdown = false; if (args.FirstOrDefault() is not null) { // Handle CLI if arguments have been passed. - bool shutdown = false; using (IServiceScope scope = app.Services.CreateScope()) { ApplicationDbContext db = scope.ServiceProvider.GetRequiredService(); @@ -87,9 +88,9 @@ using (IServiceScope scope = app.Services.CreateScope()) ApplicationDbContext db = scope.ServiceProvider.GetRequiredService(); GeneralUseHelpers guhf = scope.ServiceProvider.GetRequiredService(); Seeder seeder = new(db, guhf); - seeder.Seed(); + shutdown = seeder.Seed(); } - +if (shutdown) return; // Configure the HTTP request pipeline. if (app.Environment.IsDevelopment()) @@ -97,6 +98,7 @@ if (app.Environment.IsDevelopment()) app.MapOpenApi(); app.UseSwagger(); app.UseSwaggerUI(); + app.UseHttpLogging(); } app.UseHttpsRedirection(); diff --git a/Shadow.csproj b/Shadow.csproj index 3d71426..c1a347f 100644 --- a/Shadow.csproj +++ b/Shadow.csproj @@ -23,15 +23,17 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - - - + + + all runtime; build; native; contentfiles; analyzers; buildtransitive + + @@ -42,6 +44,7 @@ + diff --git a/Controllers/Cli.cs b/Tools/Cli.cs similarity index 69% rename from Controllers/Cli.cs rename to Tools/Cli.cs index bfcfe89..c1eee2c 100644 --- a/Controllers/Cli.cs +++ b/Tools/Cli.cs @@ -1,14 +1,14 @@ using Shadow.Data; using Shadow.Entities; -namespace Shadow.Controllers; +namespace Shadow.Tools; public class Cli { private readonly ApplicationDbContext db; private readonly GeneralUseHelpers guhf; private readonly string[] args; - // TODO: Add "changeUser" + // TODO: Add "changeUser", "fullRescan", "destructiveRescan" public Cli(ApplicationDbContext _db, GeneralUseHelpers _guhf, string[] _args) { @@ -28,12 +28,18 @@ public class Cli switch (args[0].ToLower()) { case "adduser": + case "--adduser": + case "--add-user": AddUser(); break; case "resetpassword": + case "--resetpassword": + case "--reset-password": ResetPassword(); break; case "removeuser": + case "--removeuser": + case "--remove-user": RemoveUser(); break; case "help": @@ -45,6 +51,15 @@ public class Cli case "?": ShowHelp(); break; + case "setupwizard": + case "--setupwizard": + case "--setup-wizard": + case "setup": + case "--setup": + case "configure": + case "--configure": + SetupWizard(); + break; default: Console.WriteLine($"Unknown option: \"{args[0]}\". See \"help\" for available arguments."); break; @@ -154,7 +169,7 @@ public class Cli return; } - string password = ReadPassword(); + string password = ReadPassword(); string passwordConfirmation = ReadPassword(" Confirm new password: "); if (!password.Equals(passwordConfirmation)) { @@ -242,6 +257,68 @@ public class Cli } + public bool SetupWizard() + { + // Returns true if setup successful. Otherwise false. + bool success = false; + + Console.WriteLine("[Shadow] Setup wizard\n" + + " Welcome to Shadow's setup wizard. Here, you can customize:\n" + + " - Shadow's music library path, used to scan for audio files,\n" + + " - Shadow's image thumbnails path, used to provide cover art extracted from media.\n" + + " Finally, you will be asked if you want your changes saved.\n"); + + Global? musicLibraryPath = db.Globals.FirstOrDefault(g => g.Key == "musicLibraryPath"); + Global? musicThumbnailPath = db.Globals.FirstOrDefault(g => g.Key == "musicThumbnailPath"); + + if (musicLibraryPath is not null || musicThumbnailPath is not null) + { + Console.WriteLine(" Found existing configuration:"); + if (musicLibraryPath is not null) + Console.WriteLine($" - for music library: {musicLibraryPath.Value}"); + if (musicThumbnailPath is not null) + Console.WriteLine($" - for image thumbnails: {musicThumbnailPath.Value}"); + Console.WriteLine(" They will be OVERWRITTEN if you'll commit your changes.\n"); + } + else + { + Console.WriteLine(" No existing configuration found.\n"); + } + + string newMusicLibraryPath = DefaultPrompt("Please enter your new music library path", musicLibraryPath?.Value).TrimEnd(['\\', '/']); + string newMusicThumbnailPath = DefaultPrompt("Please enter your new image thumbnail path", musicThumbnailPath?.Value ?? Path.Combine(newMusicLibraryPath, ".shadow")).TrimEnd(['\\', '/']); + + bool confirmed = YesNoPrompt("Are you sure you want:\n" + + $"- \"{newMusicLibraryPath}\" as your new music library path,\n" + + $"- \"{newMusicThumbnailPath}\" as your new image thumbnail path?\n" + + $"Please answer yes or no [y/N]: ", false); + + if (!confirmed) + { + Console.WriteLine("Changes not saved."); + success = false; + } + else + { + Console.Write("Saving changes... "); + + musicLibraryPath ??= new Global() { Key = "musicLibraryPath" }; + musicThumbnailPath ??= new Global() { Key = "musicThumbnailPath" }; + + musicLibraryPath.Value = newMusicLibraryPath; + musicThumbnailPath.Value = newMusicThumbnailPath; + + // UpdateRange can both Add and Update rows. + db.Globals.UpdateRange(musicLibraryPath, musicThumbnailPath); + db.SaveChanges(); + + Console.WriteLine("success!"); + success = true; + } + + return success; + } + public string ReadPassword(string prompt = " Enter password (will not be echoed back): ") { // https://www.silicloud.com/blog/how-to-hide-content-in-the-console-using-c/ @@ -281,12 +358,14 @@ public class Cli // user will be asked repeatedly. bool exit = false; bool response = false; + string? input = null; - Console.Write(prompt); - string? input = Console.ReadLine(); while (!exit) { + Console.Write(prompt); + input = Console.ReadLine(); + if (input is not null && input.Length > 0) { if (input.ToLower().StartsWith("y")) @@ -308,4 +387,25 @@ public class Cli } return response; } + + public string DefaultPrompt(string prompt, string? default_value = null) + { + // Prompt the user repeatedly for answer. If default value + // is specified, an enter will be treated as if the user + // entered the default value. + string response = String.Empty; + string? input; + + while (response.Trim() == String.Empty) + { + Console.Write($"{prompt}" + (default_value is not null ? $" [{default_value}]" : "") + ": "); + input = Console.ReadLine(); + + if (input is not null && input.Length > 0) + response = input; + else if (default_value is not null) + response = default_value; + } + return response; + } } diff --git a/Controllers/GeneralUseHelperFunctions.cs b/Tools/GeneralUseHelperFunctions.cs similarity index 96% rename from Controllers/GeneralUseHelperFunctions.cs rename to Tools/GeneralUseHelperFunctions.cs index 1f4fa9b..56d755f 100644 --- a/Controllers/GeneralUseHelperFunctions.cs +++ b/Tools/GeneralUseHelperFunctions.cs @@ -3,7 +3,7 @@ using Shadow.Data; using Shadow.Entities; using System.Text; -namespace Shadow.Controllers; +namespace Shadow.Tools; public class GeneralUseHelpers(ApplicationDbContext db, IConfiguration appsettings) { diff --git a/Tools/Seeder.cs b/Tools/Seeder.cs new file mode 100644 index 0000000..07b1ef5 --- /dev/null +++ b/Tools/Seeder.cs @@ -0,0 +1,93 @@ +using Microsoft.EntityFrameworkCore; +using Shadow.Data; +using Shadow.Entities; + +namespace Shadow.Tools; +public class Seeder +{ + private readonly ApplicationDbContext db; + private readonly GeneralUseHelpers guhf; + + public Seeder(ApplicationDbContext _db, GeneralUseHelpers _guhf) + { + db = _db; + guhf = _guhf; + } + public bool Seed() + { + // Will return true if application should be shut down. + // Otherwise returns false. + bool shutdown = false; + + Console.WriteLine($"You're running Shadow, commit {ThisAssembly.Git.Commit} of branch {ThisAssembly.Git.Branch} ({ThisAssembly.Git.CommitDate})\n"); + + // Check if this is a clean, first run. If so, run the setup wizard. + Global? lastVersion = db.Globals.FirstOrDefault(c => c.Key == "lastVersion"); + if (lastVersion is null) + { + shutdown = true; + bool setupSuccessfull = false; + Console.WriteLine("This seems to be a fresh install. Running setup wizard for you.\n"); + Cli cli = new(db, guhf, []); + setupSuccessfull = cli.SetupWizard(); + + if (setupSuccessfull) + { + // Cli.SetupWizard() takes care of musicLibraryPath and musicThumbnailPath + lastVersion = new Global() { Key = "lastVersion", Value = ThisAssembly.Git.Commit }; + Global lastVersionDate = new Global() { Key = "lastVersionDate", Value = ThisAssembly.Git.CommitDate }; + Global runs = new Global() { Key = "runs", Value = "0" }; + Global libraryState = new Global() { Key = "libraryState", Value = "00000000000000000000000000000000" }; + db.Globals.AddRange(lastVersion, lastVersionDate, runs, libraryState); + } + else + Console.WriteLine("Setup wizard failed. Please try running it again using \"Shadow setupWizard\"."); + + db.SaveChanges(); + return shutdown; + } + + // Check if running a newer (different) version + if (lastVersion.Value != ThisAssembly.Git.Commit) + { + Global? lastVersionDate = db.Globals.FirstOrDefault(c => c.Key == "lastVersionDate"); + if (lastVersionDate is not null && String.Compare(lastVersionDate.Value, ThisAssembly.Git.CommitDate) > 0) + { + // User is running an earlier version of the application. + Console.WriteLine("Downgrade detected! Waiting 30 seconds.\n" + + "Please consider stopping Shadow in order to avoid accidental data loss!"); + } + Console.WriteLine("Upgrade detected. Make sure you're using the most recent migrations.\n" + + "If not, apply them with `dotnet ef database update`."); + } + + // Check if any user/admin exist. Display appropriate warnings if not. + int userCount = db.Users.Count(); + if (userCount == 0) + Console.WriteLine("[Warn]: No user accounts found. Running a server no one can access! Consider creating an account with `Shadow addUser`."); + + int adminCount = db.Users.Count(u => u.IsAdmin()); + if (adminCount == 0 && userCount > 0) + Console.WriteLine("[Warn]: No admin accounts exist. Consider creating one with `Shadow addUser`."); + + // Ensure [Unknown Album], [Unknown Artist] exist + Album unknownAlbum = db.Albums.FirstOrDefault(a => a.Name == "[Unknown Album]") ?? new Album() + { + Name = "[Unknown Album]", + Uri = "00000000000000000000000000000000" + }; + + Artist unknownArtist = db.Artists.FirstOrDefault(a => a.Name == "[Unknown Artist]") ?? new Artist() + { + Name = "[Unknown Artist]", + NormalizedName = "[unknown artist]" + }; + + // UpdateRange works both as an Add and Update. + db.UpdateRange(unknownAlbum, unknownArtist); + db.SaveChanges(); + + return shutdown; + + } +}