diff --git a/Nibriboard/CommandConsole.cs b/Nibriboard/CommandConsole.cs index 81f26e1..6f97305 100644 --- a/Nibriboard/CommandConsole.cs +++ b/Nibriboard/CommandConsole.cs @@ -1,11 +1,13 @@ using System; using System.Diagnostics; using System.IO; +using System.Linq; using System.Net; using System.Net.Sockets; using System.Threading.Tasks; using Nibriboard.Client; using Nibriboard.RippleSpace; +using Nibriboard.Userspace; using Nibriboard.Utilities; namespace Nibriboard @@ -55,116 +57,50 @@ namespace Nibriboard } } - private async Task executeCommand(StreamWriter destination, string[] commandParts) + private async Task executeCommand(StreamWriter dest, string[] commandParts) { string commandName = commandParts[0].Trim(); switch(commandName) { case "help": - await destination.WriteLineAsync("Nibriboard Server Command Console"); - await destination.WriteLineAsync("================================="); - await destination.WriteLineAsync("Available commands:"); - await destination.WriteLineAsync(" help Show this message"); - await destination.WriteLineAsync(" version Show the version of nibriboard that is currently running"); - await destination.WriteLineAsync(" save Save the ripplespace to disk"); - await destination.WriteLineAsync(" plane {subcommand} Interact with planes"); - await destination.WriteLineAsync(" clients List the currently connected clients"); + await dest.WriteLineAsync("Nibriboard Server Command Console"); + await dest.WriteLineAsync("================================="); + await dest.WriteLineAsync("Available commands:"); + await dest.WriteLineAsync(" help Show this message"); + await dest.WriteLineAsync(" version Show the version of nibriboard that is currently running"); + await dest.WriteLineAsync(" save Save the ripplespace to disk"); + await dest.WriteLineAsync(" plane {subcommand} Interact with planes"); + await dest.WriteLineAsync(" users Interact with user accounts"); + await dest.WriteLineAsync(" clients List the currently connected clients"); break; case "version": - await destination.WriteLineAsync($"Nibriboard Server {NibriboardServer.Version}, built on {NibriboardServer.BuildDate.ToString("R")}"); - await destination.WriteLineAsync("By Starbeamrainbowlabs, licensed under MPL-2.0"); + await dest.WriteLineAsync($"Nibriboard Server {NibriboardServer.Version}, built on {NibriboardServer.BuildDate.ToString("R")}"); + await dest.WriteLineAsync("By Starbeamrainbowlabs, licensed under MPL-2.0"); break; case "save": - await destination.WriteAsync("Saving ripple space - "); + await dest.WriteAsync("Saving ripple space - "); Stopwatch timer = Stopwatch.StartNew(); long bytesWritten = await server.PlaneManager.Save(); long msTaken = timer.ElapsedMilliseconds; - await destination.WriteLineAsync("done."); - await destination.WriteLineAsync($"{Formatters.HumanSize(bytesWritten)} written in {msTaken}ms."); - await destination.WriteLineAsync($"Save is now {Formatters.HumanSize(server.PlaneManager.LastSaveSize)} in size."); + await dest.WriteLineAsync("done."); + await dest.WriteLineAsync($"{Formatters.HumanSize(bytesWritten)} written in {msTaken}ms."); + await dest.WriteLineAsync($"Save is now {Formatters.HumanSize(server.PlaneManager.LastSaveSize)} in size."); break; case "plane": - if(commandParts.Length < 2) { - await destination.WriteLineAsync("Nibriboard Server Command Console: plane"); - await destination.WriteLineAsync("----------------------------------------"); - await destination.WriteLineAsync("Interact with planes."); - await destination.WriteLineAsync("Usage:"); - await destination.WriteLineAsync(" plane {subcommand}"); - await destination.WriteLineAsync(); - await destination.WriteLineAsync("Subcommands:"); - await destination.WriteLineAsync(" list"); - await destination.WriteLineAsync(" List all the currently loaded planes"); - await destination.WriteLineAsync(" create {new-plane-name} [{chunkSize}]"); - await destination.WriteLineAsync(" Create a new named plane, optionally with the specified chunk size"); - await destination.WriteLineAsync(" status {plane-name}"); - await destination.WriteLineAsync(" Show the statistics of the specified plane"); - break; - } - string subAction = commandParts[1].Trim(); - switch(subAction) - { - case "list": - await destination.WriteLineAsync("Planes:"); - foreach(Plane plane in server.PlaneManager.Planes) - await destination.WriteLineAsync($" {plane.Name} @ {plane.ChunkSize} ({plane.LoadedChunks} / ~{plane.SoftLoadedChunkLimit} chunks loaded, {plane.UnloadableChunks} inactive, {plane.TotalChunks} total at last save)"); - await destination.WriteLineAsync(); - await destination.WriteLineAsync($"Total {server.PlaneManager.Planes.Count}"); - break; - case "create": - if(commandParts.Length < 3) { - await destination.WriteLineAsync("Error: No name specified for the new plane!"); - return; - } - string newPlaneName = commandParts[2].Trim(); - int chunkSize = server.PlaneManager.DefaultChunkSize; - if(commandParts.Length >= 4) - chunkSize = int.Parse(commandParts[3]); - - server.PlaneManager.CreatePlane(new PlaneInfo( - newPlaneName, - chunkSize - )); - - await destination.WriteLineAsync($"Created plane with name {newPlaneName} and chunk size {chunkSize}."); - - break; - case "status": - if(commandParts.Length < 3) { - await destination.WriteLineAsync("Error: No plane name specified!"); - return; - } - - string targetPlaneName = commandParts[2].Trim(); - Plane targetPlane = server.PlaneManager.GetByName(targetPlaneName); - if(targetPlane == null) { - await destination.WriteLineAsync($"Error: A plane with the name {targetPlaneName} doesn't exist."); - return; - } - - await destination.WriteLineAsync($"Name: {targetPlane.Name}"); - await destination.WriteLineAsync($"Chunk size: {targetPlane.ChunkSize}"); - await destination.WriteLineAsync($"Loaded chunks: {targetPlane.LoadedChunks}"); - await destination.WriteLineAsync($"Unloaded chunks: {targetPlane.TotalChunks - targetPlane.LoadedChunks}"); - await destination.WriteLineAsync($"Total chunks: {targetPlane.TotalChunks}"); - await destination.WriteLineAsync($"Primary chunk area size: {targetPlane.PrimaryChunkAreaSize}"); - await destination.WriteLineAsync($"Min unloadeable chunks: {targetPlane.MinUnloadeableChunks}"); - await destination.WriteLineAsync($"Soft loaded chunk limit: {targetPlane.SoftLoadedChunkLimit}"); - - break; - default: - await destination.WriteLineAsync($"Error: Unknown sub-action {subAction}."); - break; - } + await handlePlaneCommand(commandParts, dest); break; case "clients": foreach(NibriClient client in server.AppServer.NibriClients) { - await destination.WriteLineAsync($"{client.Id}: {client.Name} from {client.RemoteEndpoint}, on {client.CurrentPlane.Name} looking at {client.CurrentViewPort}"); + await dest.WriteLineAsync($"{client.Id}: {client.Name} from {client.RemoteEndpoint}, on {client.CurrentPlane.Name} looking at {client.CurrentViewPort}"); } - await destination.WriteLineAsync(); - await destination.WriteLineAsync($"Total {server.AppServer.ClientCount} clients"); + await dest.WriteLineAsync(); + await dest.WriteLineAsync($"Total {server.AppServer.ClientCount} clients"); + break; + case "users": + await handleUsersCommand(commandParts, dest); break; /*case "chunk": @@ -195,9 +131,243 @@ namespace Nibriboard break;*/ default: - await destination.WriteLineAsync($"Error: Unrecognised command {commandName}"); + await dest.WriteLineAsync($"Error: Unrecognised command {commandName}"); break; } } + + private async Task handlePlaneCommand(string[] commandParts, StreamWriter dest) + { + if (commandParts.Length < 2) + { + await dest.WriteLineAsync("Nibriboard Server Command Console: plane"); + await dest.WriteLineAsync("----------------------------------------"); + await dest.WriteLineAsync("Interact with planes."); + await dest.WriteLineAsync("Usage:"); + await dest.WriteLineAsync(" plane {subcommand}"); + await dest.WriteLineAsync(); + await dest.WriteLineAsync("Subcommands:"); + await dest.WriteLineAsync(" list"); + await dest.WriteLineAsync(" List all the currently loaded planes"); + await dest.WriteLineAsync(" create {new-plane-name} [{chunkSize}]"); + await dest.WriteLineAsync(" Create a new named plane, optionally with the specified chunk size"); + await dest.WriteLineAsync(" status {plane-name}"); + await dest.WriteLineAsync(" Show the statistics of the specified plane"); + return; + } + string subAction = commandParts[1].Trim(); + switch (subAction) + { + case "list": + await dest.WriteLineAsync("Planes:"); + foreach (Plane plane in server.PlaneManager.Planes) + await dest.WriteLineAsync($" {plane.Name} @ {plane.ChunkSize} ({plane.LoadedChunks} / ~{plane.SoftLoadedChunkLimit} chunks loaded, {plane.UnloadableChunks} inactive, {plane.TotalChunks} total at last save)"); + await dest.WriteLineAsync(); + await dest.WriteLineAsync($"Total {server.PlaneManager.Planes.Count}"); + break; + case "create": + if (commandParts.Length < 3) + { + await dest.WriteLineAsync("Error: No name specified for the new plane!"); + return; + } + string newPlaneName = commandParts[2].Trim(); + int chunkSize = server.PlaneManager.DefaultChunkSize; + if (commandParts.Length >= 4) + chunkSize = int.Parse(commandParts[3]); + + server.PlaneManager.CreatePlane(new PlaneInfo( + newPlaneName, + chunkSize + )); + + await dest.WriteLineAsync($"Created plane with name {newPlaneName} and chunk size {chunkSize}."); + + break; + case "status": + if (commandParts.Length < 3) + { + await dest.WriteLineAsync("Error: No plane name specified!"); + return; + } + + string targetPlaneName = commandParts[2].Trim(); + Plane targetPlane = server.PlaneManager.GetByName(targetPlaneName); + if (targetPlane == null) + { + await dest.WriteLineAsync($"Error: A plane with the name {targetPlaneName} doesn't exist."); + return; + } + + await dest.WriteLineAsync($"Name: {targetPlane.Name}"); + await dest.WriteLineAsync($"Chunk size: {targetPlane.ChunkSize}"); + await dest.WriteLineAsync($"Loaded chunks: {targetPlane.LoadedChunks}"); + await dest.WriteLineAsync($"Unloaded chunks: {targetPlane.TotalChunks - targetPlane.LoadedChunks}"); + await dest.WriteLineAsync($"Total chunks: {targetPlane.TotalChunks}"); + await dest.WriteLineAsync($"Primary chunk area size: {targetPlane.PrimaryChunkAreaSize}"); + await dest.WriteLineAsync($"Min unloadeable chunks: {targetPlane.MinUnloadeableChunks}"); + await dest.WriteLineAsync($"Soft loaded chunk limit: {targetPlane.SoftLoadedChunkLimit}"); + + break; + default: + await dest.WriteLineAsync($"Error: Unknown sub-action {subAction}."); + break; + } + } + + private async Task handleUsersCommand(string[] commandParts, StreamWriter dest) + { + if (commandParts.Length < 2) + { + await dest.WriteLineAsync("Nibriboard Server Command Console: users"); + await dest.WriteLineAsync("----------------------------------------"); + await dest.WriteLineAsync("Interact with user accounts."); + await dest.WriteLineAsync("Usage:"); + await dest.WriteLineAsync(" users {subcommand}"); + await dest.WriteLineAsync(); + await dest.WriteLineAsync("Subcommands:"); + await dest.WriteLineAsync(" list"); + await dest.WriteLineAsync(" Lists all users."); + await dest.WriteLineAsync(" add {username} {password}"); + await dest.WriteLineAsync(" Adds a new user"); + await dest.WriteLineAsync(" roles list"); + await dest.WriteLineAsync(" Lists all roles"); + await dest.WriteLineAsync(" roles grant {role-name} {username}"); + await dest.WriteLineAsync(" Adds a role to a user"); + await dest.WriteLineAsync(" roles revoke {role-name} {username}"); + await dest.WriteLineAsync(" Removes a role from a user"); + return; + } + + string subAction = commandParts[1].Trim(); + switch (subAction) + { + case "list": + await dest.WriteLineAsync( + string.Join("\n", server.AccountManager.Users.Select( + (User user) => $"{user.CreationTime}\t{user.Username}\t{string.Join(", ", user.Roles.Select((RbacRole role) => role.Name))}" + )) + ); + break; + case "add": + string newUsername = (commandParts[2] ?? "").Trim(); + string password = (commandParts[3] ?? "").Trim(); + + if (newUsername.Length == 0) { + await dest.WriteLineAsync("Error: No username specified!"); + break; + } + if (password.Length == 0) { + await dest.WriteLineAsync("Error: No password specified!"); + break; + } + + server.AccountManager.AddUser(newUsername, password); + await server.SaveUserData(); + await dest.WriteLineAsync($"Added user with name {newUsername} successfully."); + break; + case "roles": + await handleRoleCommand(commandParts, dest); + break; + default: + await dest.WriteLineAsync($"Unrecognised sub-command {subAction}."); + break; + } + } + + private async Task handleRoleCommand(string[] commandParts, StreamWriter dest) + { + string subAction = (commandParts[2] ?? "").Trim(); + if (subAction.Length == 0) + { + await dest.WriteLineAsync($"Error: No sub-action specified."); + return; + } + + switch (subAction) + { + case "list": + await dest.WriteLineAsync(string.Join("\n", server.AccountManager.Roles.Select( + (RbacRole role) => role.ToString() + ))); + break; + case "grant": + await handleRoleGrantCommand(commandParts, dest); + break; + + case "revoke": + await handleRoleRevokeCommand(commandParts, dest); + break; + } + } + + private async Task handleRoleGrantCommand(string[] commandParts, StreamWriter dest) + { + + string roleName = (commandParts[3] ?? "").Trim(); + string recievingUsername = (commandParts[4] ?? "").Trim(); + if (roleName.Length == 0) { + await dest.WriteLineAsync("Error: No role name specified!"); + return; + } + if (recievingUsername.Length == 0) { + await dest.WriteLineAsync("Error: No username specified!"); + return; + } + + User user = server.AccountManager.GetByName(recievingUsername); + RbacRole roleToGrant = server.AccountManager.ResolveRole(roleName); + if (user == null) { + await dest.WriteLineAsync($"Error: No user with the the name {recievingUsername} could be found."); + return; + } + if (roleToGrant == null) { + await dest.WriteLineAsync($"Error: No role with the the name {roleName} could be found."); + return; + } + if (user.HasRole(roleToGrant)) { + await dest.WriteLineAsync($"Error: {recievingUsername} already has the role {roleToGrant.Name}."); + return; + } + + user.Roles.Add(roleToGrant); + await server.SaveUserData(); + + await dest.WriteLineAsync($"Role {roleToGrant.Name} added to {user.Username} successfully."); + } + private async Task handleRoleRevokeCommand(string[] commandParts, StreamWriter dest) + { + + string roleName = (commandParts[3] ?? "").Trim(); + string recievingUsername = (commandParts[4] ?? "").Trim(); + if (roleName.Length == 0) { + await dest.WriteLineAsync("Error: No role name specified!"); + return; + } + if (recievingUsername.Length == 0) { + await dest.WriteLineAsync("Error: No username specified!"); + return; + } + + User user = server.AccountManager.GetByName(recievingUsername); + RbacRole roleToGrant = server.AccountManager.ResolveRole(roleName); + if (user == null) { + await dest.WriteLineAsync($"Error: No user with the the name {recievingUsername} could be found."); + return; + } + if (roleToGrant == null) { + await dest.WriteLineAsync($"Error: No role with the the name {roleName} could be found."); + return; + } + if (!user.HasRole(roleToGrant)) { + await dest.WriteLineAsync($"Error: {recievingUsername} doesn't have the role {roleToGrant.Name}."); + return; + } + + user.Roles.Remove(roleToGrant); + await server.SaveUserData(); + + await dest.WriteLineAsync($"Role {roleToGrant.Name} removed from {user.Username} successfully."); + } } } diff --git a/Nibriboard/NibriboardServer.cs b/Nibriboard/NibriboardServer.cs index 9bcd40a..ff46e51 100644 --- a/Nibriboard/NibriboardServer.cs +++ b/Nibriboard/NibriboardServer.cs @@ -7,6 +7,8 @@ using Nibriboard.RippleSpace; using Nibriboard.Client; using System.Reflection; using SBRL.Utilities; +using Nibriboard.Userspace; +using Nibriboard.Utilities; namespace Nibriboard { @@ -46,6 +48,7 @@ namespace Nibriboard public readonly int CommandPort = 31587; public readonly int Port = 31586; + public readonly UserManager AccountManager; public readonly RippleSpaceManager PlaneManager; public readonly NibriboardApp AppServer; @@ -62,6 +65,13 @@ namespace Nibriboard PlaneManager = new RippleSpaceManager(pathToRippleSpace); } + // Next, load the user account data + string accountDataPath = CalcPaths.RippleSpaceAccountData(pathToRippleSpace); + AccountManager = new UserManager(); + if(File.Exists(accountDataPath)) + AccountManager.LoadUserDataFile(CalcPaths.RippleSpaceAccountData(pathToRippleSpace)).Wait(); + + clientSettings = new ClientSettings() { SecureWebSocket = false, WebSocketHost = "192.168.0.56", @@ -100,5 +110,12 @@ namespace Nibriboard { await commandServer.Start(); } + + public async Task SaveUserData() + { + await AccountManager.SaveUserDataFile( + CalcPaths.RippleSpaceAccountData(PlaneManager.SourceDirectory) + ); + } } } diff --git a/Nibriboard/RippleSpace/Plane.cs b/Nibriboard/RippleSpace/Plane.cs index 01686d7..71b1440 100644 --- a/Nibriboard/RippleSpace/Plane.cs +++ b/Nibriboard/RippleSpace/Plane.cs @@ -310,7 +310,7 @@ namespace Nibriboard.RippleSpace foreach(KeyValuePair loadedChunkItem in loadedChunkspace) { // Figure out where to put the chunk and create the relevant directories - string chunkDestinationFilename = CalcPaths.ChunkFilepath(StorageDirectory, loadedChunkItem.Key); + string chunkDestinationFilename = CalcPaths.ChunkFilePath(StorageDirectory, loadedChunkItem.Key); Directory.CreateDirectory(Path.GetDirectoryName(chunkDestinationFilename)); // Ask the chunk to save itself, but only if it isn't empty @@ -336,7 +336,7 @@ namespace Nibriboard.RippleSpace long totalSize = 0; foreach (KeyValuePair loadedChunkItem in loadedChunkspace) { - string destFilename = CalcPaths.ChunkFilepath(StorageDirectory, loadedChunkItem.Key); + string destFilename = CalcPaths.ChunkFilePath(StorageDirectory, loadedChunkItem.Key); if (!File.Exists(destFilename)) // Don't assume that the file exists - it might be an empty chunk continue; totalSize += (new FileInfo(destFilename)).Length; diff --git a/Nibriboard/Userspace/RbacRole.cs b/Nibriboard/Userspace/RbacRole.cs index c2bbb97..fcef575 100644 --- a/Nibriboard/Userspace/RbacRole.cs +++ b/Nibriboard/Userspace/RbacRole.cs @@ -28,5 +28,25 @@ namespace Nibriboard.Userspace { return Permissions.Contains(permission) || SubRoles.Any((RbacRole obj) => obj.HasPermission(permission)); } + + public bool HasRole(RbacRole targetRole) + { + if (Name == targetRole.Name) + return true; + return SubRoles.Any((RbacRole subRole) => subRole.HasRole(targetRole)); + } + + public override string ToString() + { + List subItems = new List(); + subItems.AddRange(SubRoles.Select((RbacRole subRole) => $"[r] {subRole.Name}")); + subItems.AddRange(Permissions.Select((RbacPermission subPermission) => $"[p] {subPermission.Name}")); + + return string.Format( + "{0}: {1}", + Name, + string.Join(", ", subItems) + ); + } } } diff --git a/Nibriboard/Userspace/User.cs b/Nibriboard/Userspace/User.cs index c5a4ba6..fc045b6 100644 --- a/Nibriboard/Userspace/User.cs +++ b/Nibriboard/Userspace/User.cs @@ -40,7 +40,7 @@ namespace Nibriboard.Userspace public string HashedPassword { get; set; } [JsonIgnore] - public List Roles { get; set; } + public List Roles { get; set; } = new List(); private List rolesText = null; public List RolesText { @@ -48,7 +48,7 @@ namespace Nibriboard.Userspace return new List(Roles.Select((RbacRole role) => role.Name)); } set { - + rolesText = value; } } @@ -85,10 +85,15 @@ namespace Nibriboard.Userspace return Roles.Any((RbacRole role) => role.HasPermission(permission)); } + public bool HasRole(RbacRole targetRole) + { + return Roles.Any((RbacRole role) => role.HasRole(targetRole)); + } + [OnDeserialized] internal void OnDeserialized(StreamingContext context) { - Roles = new List(userManager.ResolveRoles(RolesText)); + Roles = new List(userManager.ResolveRoles(rolesText)); } } } diff --git a/Nibriboard/Userspace/UserManager.cs b/Nibriboard/Userspace/UserManager.cs index 833460f..af82151 100644 --- a/Nibriboard/Userspace/UserManager.cs +++ b/Nibriboard/Userspace/UserManager.cs @@ -9,8 +9,17 @@ namespace Nibriboard.Userspace { public class UserManager { + /// + /// The list of users that this is managing. + /// public List Users { get; private set; } = new List(); + /// + /// A list of the permissions that this is aware of. + /// public List Permissions { get; private set; } = new List(); + /// + /// A list of the roles that this is aware of. + /// public List Roles { get; private set; } = new List(); public UserManager() @@ -45,23 +54,75 @@ namespace Nibriboard.Userspace })); } + /// + /// Loads the user data stored in the specified file. + /// + /// The filename to load the user data from. + public async Task LoadUserDataFile(string filename) + { + StreamReader sourceStream = new StreamReader(filename); + await LoadUserData(sourceStream); + } + /// + /// Loads the user data from the specified stream. + /// + /// The stream to load the user data from. public async Task LoadUserData(StreamReader userDataStream) { LoadUserData(await userDataStream.ReadToEndAsync()); } + /// + /// Loads the user data from the specified JSON string. + /// + /// The JSON-serialised user data to load. public void LoadUserData(string userData) { Users = JsonConvert.DeserializeObject>(userData, new UserCreationConverter(this)); } + /// + /// Saves the user data to the specified file. + /// + /// The filename to save the user data to. + public async Task SaveUserDataFile(string filename) + { + StreamWriter destination = new StreamWriter(filename); + await SaveUserData(destination); + destination.Close(); + } + /// + /// Saves the user data to specified destination. + /// + /// The destination to save to. + public async Task SaveUserData(StreamWriter destination) + { + string json = JsonConvert.SerializeObject(Users); + await destination.WriteLineAsync(json); + } + + /// + /// Resolves a permission name to it's associated object. + /// + /// The permission name to resolve. + /// The resolved permission object. public RbacPermission ResolvePermission(string permissionName) { return Permissions.Find((RbacPermission permission) => permission.Name == permissionName); } + /// + /// Resolves a role name to it's associated object. + /// + /// The role name to resolve. + /// The resolved role object. public RbacRole ResolveRole(string roleName) { return Roles.Find((RbacRole role) => role.Name == roleName); } + /// + /// Resolves a list of role names to their associated objects. + /// + /// The role names to resolve. + /// The resolved role objects. public IEnumerable ResolveRoles(IEnumerable roleNames) { foreach (RbacRole role in Roles) @@ -70,5 +131,43 @@ namespace Nibriboard.Userspace yield return role; } } + + /// + /// Works out whether a user exists with the specified username. + /// + /// The target username to search for. + /// Whether a user exists with the specified username or not. + public bool UserExists(string username) + { + return Users.Any((User user) => user.Username == username); + } + /// + /// Finds the user with the specified name. + /// + /// The username to search for. + /// The user with the specified name. + public User GetByName(string username) + { + return Users.Find((User user) => user.Username == username); + } + + /// + /// Creates a new user with the specified username and password, and adds them to the system. + /// + /// The username for the new user. + /// The new user's password. + public void AddUser(string username, string password) + { + if (UserExists(username)) + throw new Exception($"Error: A user with the name {username} already exists, so it can't be created."); + + User newUser = new User(this) { + Username = username, + CreationTime = DateTime.Now + }; + newUser.SetPassword(password); + + Users.Add(newUser); + } } } diff --git a/Nibriboard/Utilities/CalcPaths.cs b/Nibriboard/Utilities/CalcPaths.cs index 4fc55da..c7d8b61 100644 --- a/Nibriboard/Utilities/CalcPaths.cs +++ b/Nibriboard/Utilities/CalcPaths.cs @@ -28,8 +28,13 @@ namespace Nibriboard.Utilities return Path.Combine(planeDirectory, "plane-index.json"); } + public static string RippleSpaceAccountData(string rippleSpaceRoot) + { + return Path.Combine(rippleSpaceRoot, "user-data.json"); + } - public static string ChunkFilepath(string planeStorageDirectory, ChunkReference chunkRef) + + public static string ChunkFilePath(string planeStorageDirectory, ChunkReference chunkRef) { return Path.Combine(planeStorageDirectory, chunkRef.AsFilepath()); }