feat: add setup wizard and proper seeder
All checks were successful
Update changelog / changelog (push) Successful in 26s
All checks were successful
Update changelog / changelog (push) Successful in 26s
This commit is contained in:
411
Tools/Cli.cs
Normal file
411
Tools/Cli.cs
Normal file
@@ -0,0 +1,411 @@
|
||||
using Shadow.Data;
|
||||
using Shadow.Entities;
|
||||
|
||||
namespace Shadow.Tools;
|
||||
public class Cli
|
||||
{
|
||||
private readonly ApplicationDbContext db;
|
||||
private readonly GeneralUseHelpers guhf;
|
||||
private readonly string[] args;
|
||||
|
||||
// TODO: Add "changeUser", "fullRescan", "destructiveRescan"
|
||||
|
||||
public Cli(ApplicationDbContext _db, GeneralUseHelpers _guhf, string[] _args)
|
||||
{
|
||||
db = _db;
|
||||
guhf = _guhf;
|
||||
args = _args;
|
||||
}
|
||||
|
||||
public async Task<bool> Parse()
|
||||
{
|
||||
// Returns true if the program can finish execution.
|
||||
bool exit = true;
|
||||
// Check if anything has been passed
|
||||
if (args.Length == 0 || args.FirstOrDefault() is null) {
|
||||
return true;
|
||||
}
|
||||
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":
|
||||
case "--help":
|
||||
case "h":
|
||||
case "-h":
|
||||
case "/h":
|
||||
case "/?":
|
||||
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;
|
||||
}
|
||||
|
||||
return exit;
|
||||
}
|
||||
|
||||
public void ShowHelp()
|
||||
{
|
||||
// Shown when "Shadow --help"/"Shadow help"/... is ran.
|
||||
Console.WriteLine(
|
||||
$"--- Shadow commandline utility ---\n" +
|
||||
$"Shadow version: #{ThisAssembly.Git.Commit} {ThisAssembly.Git.Branch} ({ThisAssembly.Git.CommitDate})\n\n" +
|
||||
$"Available commands:\n" +
|
||||
$"- addUser [username] - create a new user,\n" +
|
||||
$"- resetPassword [username] - reset a user's password,\n" +
|
||||
$"- removeUser [username] - remove the user COMPLETELY.\n\n" +
|
||||
$"Username is optional. If not provided, user will be prompted to enter it.\n" +
|
||||
$"Running without specifying a command launches a Kestrel web server.\n\n" +
|
||||
$"License: AGPLv3+, Source code: https://gitea.7o7.cx/sherl/Shadow"
|
||||
);
|
||||
}
|
||||
|
||||
public void AddUser()
|
||||
{
|
||||
// Check if any cli arguments have been passed
|
||||
// Both:
|
||||
// Shadow addUser
|
||||
// and
|
||||
// Shadow addUser username
|
||||
// are supported.
|
||||
|
||||
Console.WriteLine($"[Shadow] Add a new user");
|
||||
if (args.Length == 2)
|
||||
Console.WriteLine($" You will be promped to enter a password.");
|
||||
else
|
||||
Console.WriteLine($" You will be promped to enter a username and password.");
|
||||
|
||||
string? username = null;
|
||||
if (args.Length != 2)
|
||||
{
|
||||
while (username is null || username == String.Empty)
|
||||
username = ReadName(" Please enter a username: ");
|
||||
}
|
||||
else
|
||||
username = args[1];
|
||||
|
||||
// Check if user by this name exists in DB
|
||||
User? foundUser = db.Users
|
||||
.FirstOrDefault(u => u.NormalizedName == username!.ToLower());
|
||||
if (foundUser != null) {
|
||||
Console.WriteLine("Error! User with this name already exists in the database!");
|
||||
return;
|
||||
}
|
||||
|
||||
string password = ReadPassword();
|
||||
string passwordConfirmation = ReadPassword(" Confirm password: ");
|
||||
if (!password.Equals(passwordConfirmation))
|
||||
{
|
||||
Console.WriteLine("Error! Passwords do not match. Please try again.");
|
||||
return;
|
||||
}
|
||||
bool isUserAdmin = YesNoPrompt($" Should \"{username}\" be an administrator? [y/N]: ", false);
|
||||
|
||||
User newUser = new User
|
||||
{
|
||||
Name = username!,
|
||||
NormalizedName = username!.ToLower(),
|
||||
Password = password
|
||||
};
|
||||
|
||||
db.Users.Add(newUser);
|
||||
db.SaveChanges();
|
||||
}
|
||||
|
||||
public void ResetPassword()
|
||||
{
|
||||
// Check if any cli arguments have been passed
|
||||
// Both:
|
||||
// Shadow resetPassword
|
||||
// and
|
||||
// Shadow resetPassword username
|
||||
// are supported.
|
||||
|
||||
Console.WriteLine($"[Shadow] Reset password");
|
||||
if (args.Length == 2)
|
||||
Console.WriteLine($" You will be promped to enter a new password.");
|
||||
else
|
||||
Console.WriteLine($" You will be promped to enter a username and a new password.");
|
||||
|
||||
string? username = null;
|
||||
if (args.Length != 2)
|
||||
{
|
||||
while (username is null || username == String.Empty)
|
||||
username = ReadName(" Please enter a username: ");
|
||||
}
|
||||
else
|
||||
username = args[1];
|
||||
|
||||
// Check if user by this name exists in DB
|
||||
User? foundUser = db.Users
|
||||
.FirstOrDefault(u => u.NormalizedName == username!.ToLower());
|
||||
if (foundUser == null)
|
||||
{
|
||||
Console.WriteLine("Error! User with this name does not exist in the database!");
|
||||
return;
|
||||
}
|
||||
|
||||
string password = ReadPassword();
|
||||
string passwordConfirmation = ReadPassword(" Confirm new password: ");
|
||||
if (!password.Equals(passwordConfirmation))
|
||||
{
|
||||
Console.WriteLine("Error! Passwords do not match. Please try again.");
|
||||
return;
|
||||
}
|
||||
|
||||
foundUser.Password = password;
|
||||
db.SaveChanges();
|
||||
|
||||
}
|
||||
|
||||
public void RemoveUser()
|
||||
{
|
||||
// Check if any cli arguments have been passed
|
||||
// Both:
|
||||
// Shadow removeUser
|
||||
// and
|
||||
// Shadow removeUser username
|
||||
// are supported.
|
||||
|
||||
Console.WriteLine($"[Shadow] Remove user");
|
||||
if (args.Length == 2)
|
||||
Console.WriteLine($" You will be promped to enter the password.");
|
||||
else
|
||||
Console.WriteLine($" You will be promped to enter the username and password.");
|
||||
|
||||
string? username = null;
|
||||
if (args.Length != 2)
|
||||
{
|
||||
while (username is null || username == String.Empty)
|
||||
username = ReadName(" Please enter the username: ");
|
||||
}
|
||||
else
|
||||
username = args[1];
|
||||
|
||||
// Check if user by this name exists in DB
|
||||
User? foundUser = db.Users
|
||||
.FirstOrDefault(u => u.NormalizedName == username!.ToLower());
|
||||
if (foundUser == null)
|
||||
{
|
||||
Console.WriteLine("Error! User with this name does not exist in the database!");
|
||||
return;
|
||||
}
|
||||
|
||||
string password = ReadPassword();
|
||||
string passwordConfirmation = ReadPassword(" Confirm password: ");
|
||||
if (!password.Equals(passwordConfirmation))
|
||||
{
|
||||
Console.WriteLine("Error! Passwords do not match. Please try again.");
|
||||
return;
|
||||
}
|
||||
if (foundUser.Password != password)
|
||||
{
|
||||
Console.WriteLine($"Error! Entered password does not match that of \"{username}\"!");
|
||||
return;
|
||||
}
|
||||
|
||||
bool userDeletionConfirmation = YesNoPrompt($" Do you want to remove \"{username}\" completely?\n" +
|
||||
$" This cannot be undone! [y/N]: ", false);
|
||||
if (userDeletionConfirmation) {
|
||||
|
||||
// All playlists, interactions should be deleted as well.
|
||||
List<AlbumInteraction> albumInteractions = db.AlbumInteractions
|
||||
.Where(ai => ai.User == foundUser)
|
||||
.ToList();
|
||||
|
||||
List<SongInteraction> songInteractions = db.SongInteractions
|
||||
.Where(si => si.User == foundUser)
|
||||
.ToList();
|
||||
|
||||
foreach(AlbumInteraction ai in albumInteractions)
|
||||
db.AlbumInteractions.Remove(ai);
|
||||
|
||||
foreach (SongInteraction si in songInteractions)
|
||||
db.SongInteractions.Remove(si);
|
||||
|
||||
db.Users.Remove(foundUser);
|
||||
db.SaveChanges();
|
||||
|
||||
Console.WriteLine($"User \"{username}\" and all their data deleted successfully.");
|
||||
}
|
||||
else
|
||||
Console.WriteLine("User not removed.");
|
||||
|
||||
}
|
||||
|
||||
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/
|
||||
string password = String.Empty;
|
||||
ConsoleKeyInfo key;
|
||||
|
||||
bool exit = false;
|
||||
Console.Write(prompt);
|
||||
while (!exit) {
|
||||
key = Console.ReadKey(true);
|
||||
|
||||
// Exit on enter
|
||||
if (key.Key == ConsoleKey.Enter)
|
||||
exit = true;
|
||||
// Clear last character on backspace
|
||||
else if (key.Key == ConsoleKey.Backspace && password.Length > 0)
|
||||
password = password.Substring(0, (password.Length - 1));
|
||||
// Append any other character
|
||||
else
|
||||
password += key.KeyChar;
|
||||
}
|
||||
Console.WriteLine();
|
||||
return password;
|
||||
}
|
||||
|
||||
public string? ReadName(string prompt = " Enter username: ")
|
||||
{
|
||||
Console.Write(prompt);
|
||||
string? input = Console.ReadLine();
|
||||
return input;
|
||||
}
|
||||
|
||||
public bool YesNoPrompt(string prompt, bool? default_value = null)
|
||||
{
|
||||
// Checks if user input starts with "y", and if it does, returns true.
|
||||
// Otherwise checks for "n". If both checks fail, and default_value is null,
|
||||
// user will be asked repeatedly.
|
||||
bool exit = false;
|
||||
bool response = false;
|
||||
string? input = null;
|
||||
|
||||
|
||||
while (!exit)
|
||||
{
|
||||
Console.Write(prompt);
|
||||
input = Console.ReadLine();
|
||||
|
||||
if (input is not null && input.Length > 0)
|
||||
{
|
||||
if (input.ToLower().StartsWith("y"))
|
||||
{
|
||||
response = true;
|
||||
exit = true;
|
||||
}
|
||||
else if (input.ToLower().StartsWith("n"))
|
||||
{
|
||||
response = false;
|
||||
exit = true;
|
||||
}
|
||||
}
|
||||
else if (default_value is not null)
|
||||
{
|
||||
response = (bool)default_value;
|
||||
exit = true;
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
30
Tools/GeneralUseHelperFunctions.cs
Normal file
30
Tools/GeneralUseHelperFunctions.cs
Normal file
@@ -0,0 +1,30 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Shadow.Data;
|
||||
using Shadow.Entities;
|
||||
using System.Text;
|
||||
|
||||
namespace Shadow.Tools;
|
||||
|
||||
public class GeneralUseHelpers(ApplicationDbContext db, IConfiguration appsettings)
|
||||
{
|
||||
private readonly ApplicationDbContext _db = db;
|
||||
private readonly IConfiguration _appsettings = appsettings;
|
||||
|
||||
|
||||
//async public Task<User?> GetUserFromEmail(string email)
|
||||
//{
|
||||
// return await _db.Users.FirstOrDefaultAsync(e => e.Email == email);
|
||||
//}
|
||||
|
||||
//public string HashWithSHA512(string s)
|
||||
//{
|
||||
// using (var sha512 = SHA512.Create())
|
||||
// {
|
||||
// byte[] bytes = Encoding.ASCII.GetBytes(s);
|
||||
// byte[] hash = sha512.ComputeHash(bytes);
|
||||
// string hashstring = BitConverter.ToString(hash).Replace("-", "").ToLower();
|
||||
// return hashstring;
|
||||
// }
|
||||
//}
|
||||
|
||||
}
|
||||
93
Tools/Seeder.cs
Normal file
93
Tools/Seeder.cs
Normal file
@@ -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;
|
||||
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user