diff --git a/LarpixServer/Account/Requests.cs b/LarpixServer/Account/Requests.cs index a8b4cc7..7080d5b 100644 --- a/LarpixServer/Account/Requests.cs +++ b/LarpixServer/Account/Requests.cs @@ -666,7 +666,7 @@ public static async Task Auth(HttpContext context, Func next, IQueryCollec "dm/message/send", "dm/message/delete", "dm/message/react", "dm/key/update", "dm/message/pin/toggle", "dm/message/react/add", "dm/message/react/remove", "user/storage/public/update", "user/storage/private/update", "dm/read", - "group/create", "user/group/list", "group/info", + "group/create", "user/group/list", "group/info", "group/settings/update", "user/invite/accept", "user/invite/decline", "user/invite/revoke", "user/dm/list", "user/group/list", "user/storage/public/update", "user/storage/private/update", "group/storage/public/update", "group/storage/private/update", "group/storage/private/getentry", "dm/read", "group/read", "dm/message/send", "dm/message/delete", "dm/message/react/add", "dm/message/react/remove", "dm/message/pin/toggle", @@ -806,23 +806,32 @@ public static async Task Auth(HttpContext context, Func next, IQueryCollec break; case "group/create": { - Universal2String groupCreate = JsonSerializer.Deserialize( + Universal3String groupCreate = JsonSerializer.Deserialize( serializedBody.string2, - AppJsonSerializerContext.Default.Universal2String + AppJsonSerializerContext.Default.Universal3String ); - await context.Response.WriteAsync(Encryption.Encryption.EncryptString(await Room.Groups.GroupCreate(id, groupCreate.string1, groupCreate.string2), password)); + await context.Response.WriteAsync(Encryption.Encryption.EncryptString(await Room.Groups.GroupCreate(id, groupCreate.string1, groupCreate.string2, groupCreate.string3), password)); break; } case "group/info": await context.Response.WriteAsync(Encryption.Encryption.EncryptString(await Room.Groups.GroupInfo(serializedBody.string2), password)); break; + case "group/settings/update": + { + Universal3String req = JsonSerializer.Deserialize( + serializedBody.string2, + AppJsonSerializerContext.Default.Universal3String + ); + await context.Response.WriteAsync(Encryption.Encryption.EncryptString(await Room.Groups.GroupSettingsUpdate(id, req.string1, req.string2, req.string3), password)); + break; + } case "group/invite": { - Universal2String payload = JsonSerializer.Deserialize( + Universal3String payload = JsonSerializer.Deserialize( serializedBody.string2, - AppJsonSerializerContext.Default.Universal2String + AppJsonSerializerContext.Default.Universal3String ); - await context.Response.WriteAsync(Encryption.Encryption.EncryptString(await Room.Groups.GroupInvite(id, payload.string1, payload.string2), password)); + await context.Response.WriteAsync(Encryption.Encryption.EncryptString(await Room.Groups.GroupInvite(id, payload.string1, payload.string2, payload.string3), password)); break; } case "group/invite/revoke": diff --git a/LarpixServer/Account/Utils.cs b/LarpixServer/Account/Utils.cs index 2411e59..f0da0f6 100644 --- a/LarpixServer/Account/Utils.cs +++ b/LarpixServer/Account/Utils.cs @@ -256,7 +256,23 @@ public class Utils string content = Encoding.UTF8.GetString(await Fs.ReadFile($"{path}/{invite}")); if (!first) groupBuilder.Append(','); first = false; - groupBuilder.Append($"\"{groupId};{userId}\": {{ \"timestamp\": {content}, \"receiver\": \"{userId}\", \"groupId\": \"{groupId}\" }}"); + + string timestampStr; + string keysPayload = ""; + if (content.StartsWith("{")) + { + var data = JsonSerializer.Deserialize(content, LarpixServer.Utils.Jsons.AppJsonSerializerContext.Default.Universal2String); + timestampStr = data.string1; + if (!string.IsNullOrEmpty(data.string2)) { + keysPayload = $", \"keysPayload\": {JsonSerializer.Serialize(data.string2, LarpixServer.Utils.Jsons.AppJsonSerializerContext.Default.String)}"; + } + } + else + { + timestampStr = content; + } + + groupBuilder.Append($"\"{groupId};{userId}\": {{ \"timestamp\": {timestampStr}, \"receiver\": \"{userId}\", \"groupId\": \"{groupId}\"{keysPayload} }}"); } } groupBuilder.Append("}"); @@ -297,7 +313,23 @@ public class Utils string content = Encoding.UTF8.GetString(await Fs.ReadFile($"{path}/{invite}")); if (!first) groupBuilder.Append(','); first = false; - groupBuilder.Append($"\"{groupId};{userId}\": {{ \"timestamp\": {content}, \"sender\": \"{userId}\", \"groupId\": \"{groupId}\" }}"); + + string timestampStr; + string keysPayload = ""; + if (content.StartsWith("{")) + { + var data = JsonSerializer.Deserialize(content, LarpixServer.Utils.Jsons.AppJsonSerializerContext.Default.Universal2String); + timestampStr = data.string1; + if (!string.IsNullOrEmpty(data.string2)) { + keysPayload = $", \"keysPayload\": {JsonSerializer.Serialize(data.string2, LarpixServer.Utils.Jsons.AppJsonSerializerContext.Default.String)}"; + } + } + else + { + timestampStr = content; + } + + groupBuilder.Append($"\"{groupId};{userId}\": {{ \"timestamp\": {timestampStr}, \"sender\": \"{userId}\", \"groupId\": \"{groupId}\"{keysPayload} }}"); } } groupBuilder.Append("}"); @@ -347,15 +379,24 @@ public class Utils string dirPath = $"{ACCOUNTS_DATA_DIR}/{id}/storage/public"; string path = $"{dirPath}/{entry}"; - long currentTotalSize = LarpixServer.Utils.Utils.GetDirectorySize(dirPath); - long fileExistingSize = File.Exists(path) ? new FileInfo(path).Length : 0; - - if (currentTotalSize - fileExistingSize + contentBytes.Length > PUBLIC_STORAGE_LIMIT) + SemaphoreSlim userLock = GetUserLock(id); + await userLock.WaitAsync(); + try { - return "error:storage.limit.exceeded"; - } + long currentTotalSize = LarpixServer.Utils.Utils.GetDirectorySize(dirPath); + long fileExistingSize = File.Exists(path) ? new FileInfo(path).Length : 0; - await Fs.WriteFile(path, contentBytes); + if (currentTotalSize - fileExistingSize + contentBytes.Length > PUBLIC_STORAGE_LIMIT) + { + return "error:storage.limit.exceeded"; + } + + await Fs.WriteFile(path, contentBytes); + } + finally + { + userLock.Release(); + } return "success:storage.entry.updated"; } @@ -377,15 +418,24 @@ public class Utils string dirPath = $"{ACCOUNTS_DATA_DIR}/{id}/storage/public"; string path = $"{dirPath}/{entry}"; - long currentTotalSize = LarpixServer.Utils.Utils.GetDirectorySize(dirPath); - long fileExistingSize = File.Exists(path) ? new FileInfo(path).Length : 0; - - if (currentTotalSize - fileExistingSize + contentBytes.Length > PUBLIC_STORAGE_LIMIT) + SemaphoreSlim userLock = GetUserLock(id); + await userLock.WaitAsync(); + try { - return "error:storage.limit.exceeded"; - } + long currentTotalSize = LarpixServer.Utils.Utils.GetDirectorySize(dirPath); + long fileExistingSize = File.Exists(path) ? new FileInfo(path).Length : 0; - await Fs.WriteFile(path, contentBytes); + if (currentTotalSize - fileExistingSize + contentBytes.Length > PUBLIC_STORAGE_LIMIT) + { + return "error:storage.limit.exceeded"; + } + + await Fs.WriteFile(path, contentBytes); + } + finally + { + userLock.Release(); + } return "success:storage.entry.updated"; } @@ -421,15 +471,24 @@ public class Utils string dirPath = $"{ACCOUNTS_DATA_DIR}/{id}/storage/private"; string path = $"{dirPath}/{entry}"; - long currentTotalSize = LarpixServer.Utils.Utils.GetDirectorySize(dirPath); - long fileExistingSize = File.Exists(path) ? new FileInfo(path).Length : 0; - - if (currentTotalSize - fileExistingSize + contentBytes.Length > PRIVATE_STORAGE_LIMIT) + SemaphoreSlim userLock = GetUserLock(id); + await userLock.WaitAsync(); + try { - return "error:storage.limit.exceeded"; - } + long currentTotalSize = LarpixServer.Utils.Utils.GetDirectorySize(dirPath); + long fileExistingSize = File.Exists(path) ? new FileInfo(path).Length : 0; - await Fs.WriteFile(path, contentBytes); + if (currentTotalSize - fileExistingSize + contentBytes.Length > PRIVATE_STORAGE_LIMIT) + { + return "error:storage.limit.exceeded"; + } + + await Fs.WriteFile(path, contentBytes); + } + finally + { + userLock.Release(); + } return "success:storage.entry.updated"; } diff --git a/LarpixServer/Filesystem/Fs.cs b/LarpixServer/Filesystem/Fs.cs index 2d69a7d..ebacd4e 100644 --- a/LarpixServer/Filesystem/Fs.cs +++ b/LarpixServer/Filesystem/Fs.cs @@ -37,25 +37,33 @@ public class Fs public static void ProcessCacheSpace() { - while (fileCache.Count >= FILE_CACHE_SIZE) + if (fileCache.Count >= FILE_CACHE_SIZE) { - var firstKey = fileCache.Keys.FirstOrDefault(); - if (firstKey != null) fileCache.TryRemove(firstKey, out _); + foreach (var kvp in fileCache) + { + fileCache.TryRemove(kvp.Key, out _); + if (fileCache.Count < FILE_CACHE_SIZE) break; + } } - while (existCache.Count >= EXIST_CACHE_SIZE) + if (existCache.Count >= EXIST_CACHE_SIZE) { - var firstKey = existCache.Keys.FirstOrDefault(); - if (firstKey != null) existCache.TryRemove(firstKey, out _); + foreach (var kvp in existCache) + { + existCache.TryRemove(kvp.Key, out _); + if (existCache.Count < EXIST_CACHE_SIZE) break; + } } - while (dirCache.Count >= DIR_CACHE_SIZE) + if (dirCache.Count >= DIR_CACHE_SIZE) { - var firstKey = dirCache.Keys.FirstOrDefault(); - if (firstKey != null) dirCache.TryRemove(firstKey, out _); - } - + foreach (var kvp in dirCache) + { + dirCache.TryRemove(kvp.Key, out _); + if (dirCache.Count < DIR_CACHE_SIZE) break; + } } + } public static ulong ClearCache(string pattern) { diff --git a/LarpixServer/Room/Groups.cs b/LarpixServer/Room/Groups.cs index d7e355c..bad84bc 100644 --- a/LarpixServer/Room/Groups.cs +++ b/LarpixServer/Room/Groups.cs @@ -53,9 +53,10 @@ public class Groups await Fs.WriteFile(groupPath, Encoding.UTF8.GetBytes(JsonSerializer.Serialize(fileGroup, AppJsonSerializerContext.Default.Universal3String))); } - public static async Task GroupCreate(string id, string name, string body) + public static async Task GroupCreate(string id, string name, string body, string historyVisible = "false") { - await Room.Requests.createLock.WaitAsync(); + SemaphoreSlim roomLock = Room.Requests.GetRoomLock(id); + await roomLock.WaitAsync(); try { string groupsDir = $"{ROOMS_DIR}/groups"; @@ -84,6 +85,7 @@ public class Groups await Fs.WriteFile($"{groupPath}/keys/last", Encoding.UTF8.GetBytes("0")); await Fs.WriteFile($"{groupPath}/keys/newrequired", Encoding.UTF8.GetBytes("false")); await Fs.WriteFile($"{groupPath}/name", Encoding.UTF8.GetBytes(name)); + await Fs.WriteFile($"{groupPath}/history_visible", Encoding.UTF8.GetBytes(historyVisible == "true" ? "true" : "false")); // body is the key encrypted for the creator await Fs.WriteFile($"{groupPath}/keys/0/{id};{DOMAIN}", Encoding.UTF8.GetBytes(body)); @@ -111,7 +113,7 @@ public class Groups } finally { - Room.Requests.createLock.Release(); + roomLock.Release(); } } @@ -149,6 +151,103 @@ public class Groups return $"{ROOMS_DIR}/groups/{domain}/{id}"; } + public static async Task GroupSettingsUpdate(string userId, string groupId, string name, string historyVisible) + { + string groupPath = GetGroupPath(groupId); + if (!Fs.Exists(groupPath)) return "error:group.not.found"; + + string memberPath = $"{groupPath}/members/{userId};{Utils.Utils.DOMAIN}"; + + SemaphoreSlim roomLock = Room.Requests.GetRoomLock(groupId); + await roomLock.WaitAsync(); + try + { + if (!Fs.Exists(memberPath)) return "error:not.a.member"; + if (!string.IsNullOrEmpty(name)) + { + if (string.IsNullOrWhiteSpace(name)) return "error:invalid.name"; + await Fs.WriteFile($"{groupPath}/name", Encoding.UTF8.GetBytes(name)); + } + + if (!string.IsNullOrEmpty(historyVisible)) + { + bool currentState = false; + if (Fs.Exists($"{groupPath}/history_visible")) + { + currentState = Encoding.UTF8.GetString(await Fs.ReadFile($"{groupPath}/history_visible")) == "true"; + } + + bool newState = historyVisible == "true"; + + if (currentState != newState) + { + await Fs.WriteFile($"{groupPath}/history_visible", Encoding.UTF8.GetBytes(newState ? "true" : "false")); + + string timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds().ToString(); + + Message infoMsg = new(); + infoMsg.attachment = ""; + infoMsg.author = userId; + infoMsg.timestamp = timestamp; + infoMsg.type = "larp.info.history"; + infoMsg.content = newState ? "{blah(history.visible)}" : "{blah(history.hidden)}"; + infoMsg.key = ""; + + ulong lastMessageId = 0; + if (Fs.Exists($"{groupPath}/messages/last")) + { + lastMessageId = ulong.Parse(Encoding.UTF8.GetString(await Fs.ReadFile($"{groupPath}/messages/last"))); + } + ulong newMessageId = lastMessageId + 1; + + infoMsg.pervious = lastMessageId.ToString(); + + await Fs.WriteFile($"{groupPath}/messages/{newMessageId}", Encoding.UTF8.GetBytes(JsonSerializer.Serialize(infoMsg, AppJsonSerializerContext.Default.Message))); + await Fs.WriteFile($"{groupPath}/messages/last", Encoding.UTF8.GetBytes(newMessageId.ToString())); + + string[] members = Fs.ReadDirectory($"{groupPath}/members"); + foreach (string memberWD in members) + { + string memberStr = Path.GetFileName(memberWD); + bool isLocal = Account.Utils.IsUserLocal(memberStr, out string d); + string memberId = Account.Utils.GetValidIdOrZero(memberStr); + if (isLocal) + { + if (memberId == userId) + { + await UpdateUserGroup(memberId, groupId, "true", timestamp); + await Utils.Utils.SendWsMessage(memberId, $"group_message:{groupId}:read"); + } + else + { + _ = Task.Run(async () => + { + SemaphoreSlim userLock = Account.Utils.GetUserLock(memberId); + await userLock.WaitAsync(); + try + { + await UpdateUserGroup(memberId, groupId, "false", timestamp); + } + finally + { + userLock.Release(); + } + await Utils.Utils.SendWsMessage(memberId, $"group_message:{groupId}:unread"); + }); + } + } + } + } + } + } + finally + { + roomLock.Release(); + } + + return "success"; + } + public static async Task GroupInfo(string groupId) { string groupPath = GetGroupPath(groupId); @@ -181,19 +280,26 @@ public class Groups newRequired = Encoding.UTF8.GetString(await Fs.ReadFile($"{groupPath}/keys/newrequired")); } - var info = new Universal5String + string historyVisible = "false"; + if (Fs.Exists($"{groupPath}/history_visible")) + { + historyVisible = Encoding.UTF8.GetString(await Fs.ReadFile($"{groupPath}/history_visible")); + } + + var info = new Universal6String { string1 = groupId, string2 = name, string3 = memberCount, string4 = lastKeyIndex, - string5 = newRequired + string5 = newRequired, + string6 = historyVisible }; - return JsonSerializer.Serialize(info, AppJsonSerializerContext.Default.Universal5String); + return JsonSerializer.Serialize(info, AppJsonSerializerContext.Default.Universal6String); } - public static async Task GroupInvite(string id, string groupId, string targetId) + public static async Task GroupInvite(string id, string groupId, string targetId, string keysPayload = "") { bool isLocal = Account.Utils.IsUserLocal(targetId, out string domain); string tId = Account.Utils.GetValidIdOrZero(targetId); @@ -201,6 +307,54 @@ public class Groups string groupPath = GetGroupPath(groupId); if (!Fs.Exists(groupPath)) return "error:group.not.found"; + // Validate keys payload based on historyVisible + bool historyVisible = false; + string historyFile = $"{groupPath}/history_visible"; + if (Fs.Exists(historyFile)) + { + string histContent = Encoding.UTF8.GetString(await Fs.ReadFile(historyFile)); + historyVisible = (histContent == "true"); + } + + if (!historyVisible) + { + if (!string.IsNullOrEmpty(keysPayload)) + { + return "error:history.hidden.keys.provided"; + } + } + else + { + if (string.IsNullOrEmpty(keysPayload)) return "error:missing.keys"; + + System.Collections.Generic.Dictionary keysDict = null; + try + { + keysDict = JsonSerializer.Deserialize>(keysPayload, AppJsonSerializerContext.Default.DictionaryStringString); + } + catch { } + + if (keysDict == null) return "error:missing.keys"; + + string lastKeyFile = $"{groupPath}/keys/last"; + if (Fs.Exists(lastKeyFile)) + { + string lastKeyIndexStr = Encoding.UTF8.GetString(await Fs.ReadFile(lastKeyFile)); + if (int.TryParse(lastKeyIndexStr, out int lastKeyIndex)) + { + for (int i = 0; i <= lastKeyIndex; i++) + { + if (!keysDict.ContainsKey(i.ToString())) + { + return "error:missing.keys"; + } + } + } + else return "error:missing.keys"; + } + else return "error:missing.keys"; + } + // Check if inviter is in group if (!Fs.Exists($"{groupPath}/members/{id};{DOMAIN}")) return "error:not.in.group"; @@ -219,9 +373,15 @@ public class Groups string inviteFile = ACCOUNTS_DATA_DIR + $"/{id2}/groupinvites/recv/{groupId};{id}"; if (Fs.Exists(inviteFile)) return "info:user.already.invited"; - byte[] timestamp = Encoding.UTF8.GetBytes(DateTimeOffset.UtcNow.ToUnixTimeMilliseconds().ToString()); + LarpixServer.Utils.Jsons.Universal2String inviteData = new LarpixServer.Utils.Jsons.Universal2String + { + string1 = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds().ToString(), + string2 = keysPayload ?? "" + }; + byte[] inviteBytes = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(inviteData, AppJsonSerializerContext.Default.Universal2String)); + Fs.CreateDirectory(ACCOUNTS_DATA_DIR + $"/{id}/groupinvites/sent"); - await Fs.WriteFile(ACCOUNTS_DATA_DIR + $"/{id}/groupinvites/sent/{groupId};{id2}", timestamp); + await Fs.WriteFile(ACCOUNTS_DATA_DIR + $"/{id}/groupinvites/sent/{groupId};{id2}", inviteBytes); _ = Task.Run(async () => { @@ -230,7 +390,7 @@ public class Groups try { Fs.CreateDirectory(ACCOUNTS_DATA_DIR + $"/{id2}/groupinvites/recv"); - await Fs.WriteFile(inviteFile, timestamp); + await Fs.WriteFile(inviteFile, inviteBytes); } finally { @@ -301,14 +461,17 @@ public class Groups if (iId == "0") return "error:invalid.user"; string inviteFile = ACCOUNTS_DATA_DIR + $"/{id}/groupinvites/recv/{groupId};{iId}"; - if (!Fs.Exists(inviteFile)) return "error:no.invite"; string groupPath = GetGroupPath(groupId); if (!Fs.Exists(groupPath)) return "error:group.not.found"; - await Room.Requests.createLock.WaitAsync(); + SemaphoreSlim roomLock = Room.Requests.GetRoomLock(groupId); + await roomLock.WaitAsync(); try { + if (!Fs.Exists(inviteFile)) return "error:no.invite"; + string inviteContent = Encoding.UTF8.GetString(await Fs.ReadFile(inviteFile)); + // Delete the invites Fs.DeleteFile(inviteFile); if (Account.Utils.IsUserLocal(inviterId, out string domain)) @@ -319,9 +482,57 @@ public class Groups // Add to members await Fs.WriteFile($"{groupPath}/members/{id};{DOMAIN}", Array.Empty()); - // Require new key - await Fs.WriteFile($"{groupPath}/keys/newrequired", Encoding.UTF8.GetBytes("true")); + // Read invite data + string keysPayload = ""; + if (inviteContent.StartsWith("{")) + { + try + { + var data = JsonSerializer.Deserialize(inviteContent, LarpixServer.Utils.Jsons.AppJsonSerializerContext.Default.Universal2String); + if (data != null) + { + keysPayload = data.string2 ?? ""; + } + } + catch { } + } + // Require new key if history is not visible or no keys were provided + bool historyVisible = false; + if (Fs.Exists($"{groupPath}/history_visible")) + { + historyVisible = Encoding.UTF8.GetString(await Fs.ReadFile($"{groupPath}/history_visible")) == "true"; + } + + if (historyVisible && !string.IsNullOrEmpty(keysPayload)) + { + System.Collections.Generic.Dictionary keysDict = null; + try + { + keysDict = JsonSerializer.Deserialize>(keysPayload, AppJsonSerializerContext.Default.DictionaryStringString); + } + catch { } + + if (keysDict != null) + { + foreach (var kvp in keysDict) + { + string keyIndex = kvp.Key; + string keyData = kvp.Value; + string newKeyDir = $"{groupPath}/keys/{keyIndex}"; + Fs.CreateDirectory(newKeyDir); + await Fs.WriteFile($"{newKeyDir}/{id};{DOMAIN}", Encoding.UTF8.GetBytes(keyData)); + } + } + else + { + await Fs.WriteFile($"{groupPath}/keys/newrequired", Encoding.UTF8.GetBytes("true")); + } + } + else + { + await Fs.WriteFile($"{groupPath}/keys/newrequired", Encoding.UTF8.GetBytes("true")); + } string timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds().ToString(); Message joinMsg = new(); @@ -343,11 +554,26 @@ public class Groups await UpdateUserGroup(id, groupId, "true", timestamp); + // Notify all members (including the new one) via WebSocket + string[] currentMembers = Fs.ReadDirectory($"{groupPath}/members"); + foreach (string memberWD in currentMembers) + { + string memberStr = Path.GetFileName(memberWD); + string memberIdStr = Account.Utils.GetValidIdOrZero(memberStr); + if (Account.Utils.IsUserLocal(memberStr, out string d)) + { + _ = Task.Run(async () => + { + await Utils.Utils.SendWsMessage(memberIdStr, $"group_message:{groupId}:unread"); + }); + } + } + return "success:joined"; } finally { - Room.Requests.createLock.Release(); + roomLock.Release(); } } @@ -381,11 +607,13 @@ public class Groups string groupPath = GetGroupPath(groupId); if (!Fs.Exists(groupPath)) return "error:group.not.found"; - if (!Fs.Exists($"{groupPath}/members/{id};{DOMAIN}")) return "error:not.in.group"; - await Room.Requests.createLock.WaitAsync(); + SemaphoreSlim roomLock = Room.Requests.GetRoomLock(groupId); + await roomLock.WaitAsync(); try { + if (!Fs.Exists($"{groupPath}/members/{id};{DOMAIN}")) return "error:not.in.group"; + // 1. Check if newrequired is true string newRequiredPath = $"{groupPath}/keys/newrequired"; if (!Fs.Exists(newRequiredPath) || Encoding.UTF8.GetString(await Fs.ReadFile(newRequiredPath)) != "true") @@ -438,7 +666,7 @@ public class Groups } finally { - Room.Requests.createLock.Release(); + roomLock.Release(); } } @@ -480,11 +708,20 @@ public class Groups catch { contentBytes = Encoding.UTF8.GetBytes(contentBase64); } string dirPath = $"{Utils.Utils.ROOMS_DIR}/groups/{domain}/{id}/storage/public"; string path = $"{dirPath}/{entry}"; - Fs.CreateDirectory(dirPath); - long currentTotalSize = Utils.Utils.GetDirectorySize(dirPath); - long fileExistingSize = Fs.Exists(path) ? new System.IO.FileInfo(path).Length : 0; - if (currentTotalSize - fileExistingSize + contentBytes.Length > Utils.Utils.PUBLIC_STORAGE_LIMIT) return "error:storage.limit.exceeded"; - await Fs.WriteFile(path, contentBytes); + SemaphoreSlim roomLock = Room.Requests.GetRoomLock(groupId); + await roomLock.WaitAsync(); + try + { + Fs.CreateDirectory(dirPath); + long currentTotalSize = Utils.Utils.GetDirectorySize(dirPath); + long fileExistingSize = Fs.Exists(path) ? new System.IO.FileInfo(path).Length : 0; + if (currentTotalSize - fileExistingSize + contentBytes.Length > Utils.Utils.PUBLIC_STORAGE_LIMIT) return "error:storage.limit.exceeded"; + await Fs.WriteFile(path, contentBytes); + } + finally + { + roomLock.Release(); + } return "success:storage.entry.updated"; } public static async Task GetGroupPrivateStorageEntry(string userId, string groupId, string entry) @@ -512,14 +749,60 @@ public class Groups catch { contentBytes = Encoding.UTF8.GetBytes(contentBase64); } string dirPath = $"{Utils.Utils.ROOMS_DIR}/groups/{domain}/{id}/storage/private"; string path = $"{dirPath}/{entry}"; - Fs.CreateDirectory(dirPath); - long currentTotalSize = Utils.Utils.GetDirectorySize(dirPath); - long fileExistingSize = Fs.Exists(path) ? new System.IO.FileInfo(path).Length : 0; - if (currentTotalSize - fileExistingSize + contentBytes.Length > Utils.Utils.PRIVATE_STORAGE_LIMIT) return "error:storage.limit.exceeded"; - await Fs.WriteFile(path, contentBytes); + SemaphoreSlim roomLock = Room.Requests.GetRoomLock(groupId); + await roomLock.WaitAsync(); + try + { + Fs.CreateDirectory(dirPath); + long currentTotalSize = Utils.Utils.GetDirectorySize(dirPath); + long fileExistingSize = Fs.Exists(path) ? new System.IO.FileInfo(path).Length : 0; + if (currentTotalSize - fileExistingSize + contentBytes.Length > Utils.Utils.PRIVATE_STORAGE_LIMIT) return "error:storage.limit.exceeded"; + await Fs.WriteFile(path, contentBytes); + } + finally + { + roomLock.Release(); + } return "success:storage.entry.updated"; } + public static async Task GroupDelete(string groupId) + { + string[] parts = groupId.Split(';'); + string id = parts[0]; + string domain = parts.Length > 1 ? parts[1] : Utils.Utils.DOMAIN; + string groupPath = $"{Utils.Utils.ROOMS_DIR}/groups/{domain}/{id}"; + + if (!Fs.Exists(groupPath)) return "error:group.not.found"; + + SemaphoreSlim roomLock = Room.Requests.GetRoomLock(groupId); + await roomLock.WaitAsync(); + try + { + string membersDir = $"{groupPath}/members"; + if (Fs.Exists(membersDir)) + { + string[] memberFiles = Fs.ReadDirectory(membersDir); + foreach (string memberFile in memberFiles) + { + string memberId = memberFile.Split(';')[0]; + string userGroupPath = $"{Utils.Utils.ACCOUNTS_DATA_DIR}/{memberId}/groups/{id};{domain}"; + if (Fs.Exists(userGroupPath)) + { + Fs.DeleteFile(userGroupPath); + } + } + } + Fs.DeleteDirectory(groupPath); + } + finally + { + roomLock.Release(); + } + + return "success"; + } + public static async Task GroupLeave(string userId, string groupId) { string[] parts = groupId.Split(';'); @@ -531,12 +814,14 @@ public class Groups return "error:group.not.found"; string memberPath = $"{groupPath}/members/{userId};{Utils.Utils.DOMAIN}"; - if (!Fs.Exists(memberPath)) - return "error:not.a.member"; - await Room.Requests.createLock.WaitAsync(); + SemaphoreSlim roomLock = Room.Requests.GetRoomLock(groupId); + await roomLock.WaitAsync(); try { + if (!Fs.Exists(memberPath)) + return "error:not.a.member"; + // Remove from members Fs.DeleteFile(memberPath); @@ -547,7 +832,7 @@ public class Groups Fs.DeleteFile(userGroupPath); } - // Require new key + // Always require new key when someone leaves await Fs.WriteFile($"{groupPath}/keys/newrequired", Encoding.UTF8.GetBytes("true")); // Add info message @@ -569,10 +854,32 @@ public class Groups } await Fs.WriteFile($"{groupPath}/messages/last", Encoding.UTF8.GetBytes(msgId.ToString())); await Fs.WriteFile($"{groupPath}/messages/{msgId}", Encoding.UTF8.GetBytes(JsonSerializer.Serialize(msg, AppJsonSerializerContext.Default.Message))); + + // Notify remaining members via WebSocket + string[] currentMembers = Fs.ReadDirectory($"{groupPath}/members"); + foreach (string memberWD in currentMembers) + { + string memberStr = Path.GetFileName(memberWD); + string memberIdStr = Account.Utils.GetValidIdOrZero(memberStr); + if (Account.Utils.IsUserLocal(memberStr, out string d)) + { + _ = Task.Run(async () => + { + await Utils.Utils.SendWsMessage(memberIdStr, $"group_message:{groupId}:unread"); + }); + } + } } finally { - Room.Requests.createLock.Release(); + roomLock.Release(); + } + + // If members count == 0, delete group folder and we are done. + // Doing it outside the lock since GroupDelete takes the lock again. + if (Fs.ReadDirectory($"{groupPath}/members").Length == 0) + { + await GroupDelete(groupId); } return "success"; diff --git a/LarpixServer/Room/Requests.cs b/LarpixServer/Room/Requests.cs index 5fbafe1..57d8d57 100644 --- a/LarpixServer/Room/Requests.cs +++ b/LarpixServer/Room/Requests.cs @@ -10,6 +10,23 @@ namespace LarpixServer.Room; public class Requests { public static SemaphoreSlim createLock = new SemaphoreSlim(1, 1); + + private static SemaphoreSlim[]? _roomLocksArray = null; + + public static SemaphoreSlim GetRoomLock(string roomId) + { + if (_roomLocksArray == null) + { + int size = LOCK_SIZE > 0 ? LOCK_SIZE : 65536; + var newArray = Enumerable.Range(0, size).Select(_ => new SemaphoreSlim(1, 1)).ToArray(); + Interlocked.CompareExchange(ref _roomLocksArray, newArray, null); + } + + int hash = roomId.GetHashCode(); + int index = (hash & 0x7FFFFFFF) % _roomLocksArray.Length; + return _roomLocksArray[index]; + } + public const int MAX_DM_KEY_SIZE = 8192; private static bool IsSafeDmId(string dmId) diff --git a/LarpixServer/Room/Shared.cs b/LarpixServer/Room/Shared.cs index 0a6e4be..339af72 100644 --- a/LarpixServer/Room/Shared.cs +++ b/LarpixServer/Room/Shared.cs @@ -122,45 +122,46 @@ public class Shared public static async Task MessageSend(string id, string roomType, string roomId, string content, string attachment, string responded, string keyIndex = "0") { - if (!await IsMemberOfRoom(id, roomType, roomId)) return "error:forbidden"; - - if (roomType == "groups") - { - string newRequiredPath = $"{Groups.GetGroupPath(roomId)}/keys/newrequired"; - if (Fs.Exists(newRequiredPath)) - { - string isRequired = Encoding.UTF8.GetString(await Fs.ReadFile(newRequiredPath)); - if (isRequired == "true") - { - return "error:key.rotation.required"; - } - } - - // Check if keyIndex matches last key - string lastKeyPath = $"{Groups.GetGroupPath(roomId)}/keys/last"; - if (Fs.Exists(lastKeyPath)) - { - string lastKey = Encoding.UTF8.GetString(await Fs.ReadFile(lastKeyPath)); - if (keyIndex != lastKey && keyIndex != "") // empty key means larp.info messages - { - return "error:invalid.key.index"; - } - } - } - - if (content != null && content.Length > LarpixServer.Utils.Utils.MAX_MESSAGE_SIZE) - { - string sizeStr = LarpixServer.Utils.Utils.MAX_MESSAGE_SIZE >= 1024 ? $"{LarpixServer.Utils.Utils.MAX_MESSAGE_SIZE / 1024.0:0.##} KB" : $"{LarpixServer.Utils.Utils.MAX_MESSAGE_SIZE} B"; - return $"error:message.too.long:{sizeStr}"; - } - string msgDir = $"{GetRoomPath(roomType, roomId)}/messages"; long nextId = 0; Message msg = new Message(); - await Room.Requests.createLock.WaitAsync(); + SemaphoreSlim roomLock = Room.Requests.GetRoomLock(roomId); + await roomLock.WaitAsync(); try { + if (!await IsMemberOfRoom(id, roomType, roomId)) return "error:forbidden"; + + if (roomType == "groups") + { + string newRequiredPath = $"{Groups.GetGroupPath(roomId)}/keys/newrequired"; + if (Fs.Exists(newRequiredPath)) + { + string isRequired = Encoding.UTF8.GetString(await Fs.ReadFile(newRequiredPath)); + if (isRequired == "true") + { + return "error:key.rotation.required"; + } + } + + // Check if keyIndex matches last key + string lastKeyPath = $"{Groups.GetGroupPath(roomId)}/keys/last"; + if (Fs.Exists(lastKeyPath)) + { + string lastKey = Encoding.UTF8.GetString(await Fs.ReadFile(lastKeyPath)); + if (keyIndex != lastKey && keyIndex != "") // empty key means larp.info messages + { + return "error:invalid.key.index"; + } + } + } + + if (content != null && content.Length > LarpixServer.Utils.Utils.MAX_MESSAGE_SIZE) + { + string sizeStr = LarpixServer.Utils.Utils.MAX_MESSAGE_SIZE >= 1024 ? $"{LarpixServer.Utils.Utils.MAX_MESSAGE_SIZE / 1024.0:0.##} KB" : $"{LarpixServer.Utils.Utils.MAX_MESSAGE_SIZE} B"; + return $"error:message.too.long:{sizeStr}"; + } + long last = 0; if (Fs.Exists($"{msgDir}/last")) { @@ -218,7 +219,7 @@ public class Shared } finally { - Room.Requests.createLock.Release(); + roomLock.Release(); } string[] members = Fs.ReadDirectory($"{GetRoomPath(roomType, roomId)}/members"); @@ -266,32 +267,41 @@ public class Shared string msgPath = $"{GetRoomPath(roomType, roomId)}/messages/{msgId}"; if (!Fs.Exists(msgPath)) return "error:message.not.found"; - Message msg = JsonSerializer.Deserialize( - Encoding.UTF8.GetString(await Fs.ReadFile(msgPath)), - AppJsonSerializerContext.Default.Message - ); - - if (msg.author != id) return "error:forbidden"; - if (msg.type == "larp.redacted") return "error:message.already.deleted"; - - msg.type = "larp.redacted"; - msg.reactions = ""; - msg.content = "{blah(larp.redacted)}"; - msg.attachment = ""; - msg.key = ""; - - await Fs.WriteFile(msgPath, Encoding.UTF8.GetBytes(JsonSerializer.Serialize(msg, AppJsonSerializerContext.Default.Message))); - - string reactDir = $"{GetRoomPath(roomType, roomId)}/reactions/{msgId}"; - if (Fs.Exists(reactDir)) + SemaphoreSlim roomLock = Room.Requests.GetRoomLock(roomId); + await roomLock.WaitAsync(); + try { - Fs.DeleteDirectory(reactDir); + Message msg = JsonSerializer.Deserialize( + Encoding.UTF8.GetString(await Fs.ReadFile(msgPath)), + AppJsonSerializerContext.Default.Message + ); + + if (msg.author != id) return "error:forbidden"; + if (msg.type == "larp.redacted") return "error:message.already.deleted"; + + msg.type = "larp.redacted"; + msg.reactions = ""; + msg.content = "{blah(larp.redacted)}"; + msg.attachment = ""; + msg.key = ""; + + await Fs.WriteFile(msgPath, Encoding.UTF8.GetBytes(JsonSerializer.Serialize(msg, AppJsonSerializerContext.Default.Message))); + + string reactDir = $"{GetRoomPath(roomType, roomId)}/reactions/{msgId}"; + if (Fs.Exists(reactDir)) + { + Fs.DeleteDirectory(reactDir); + } + + string attDir = $"{GetRoomPath(roomType, roomId)}/attachments/{msgId}"; + if (Fs.Exists(attDir)) + { + Fs.DeleteDirectory(attDir); + } } - - string attDir = $"{GetRoomPath(roomType, roomId)}/attachments/{msgId}"; - if (Fs.Exists(attDir)) + finally { - Fs.DeleteDirectory(attDir); + roomLock.Release(); } string[] members = Fs.ReadDirectory($"{GetRoomPath(roomType, roomId)}/members"); @@ -331,7 +341,8 @@ public class Shared string reactDir = $"{GetRoomPath(roomType, roomId)}/reactions/{msgId}"; if (!Fs.Exists(reactDir)) Fs.CreateDirectory(reactDir); - await Room.Requests.createLock.WaitAsync(); + SemaphoreSlim roomLock = Room.Requests.GetRoomLock(roomId); + await roomLock.WaitAsync(); try { long last = 0; @@ -346,7 +357,7 @@ public class Shared } finally { - Room.Requests.createLock.Release(); + roomLock.Release(); } string[] members = Fs.ReadDirectory($"{GetRoomPath(roomType, roomId)}/members"); @@ -411,7 +422,8 @@ public class Shared string pinnedFilePath = $"{pinnedDir}/{msgId}"; bool isNowPinned = false; - await Room.Requests.createLock.WaitAsync(); + SemaphoreSlim roomLock = Room.Requests.GetRoomLock(roomId); + await roomLock.WaitAsync(); try { if (Fs.Exists(pinnedFilePath)) @@ -450,7 +462,7 @@ public class Shared } finally { - Room.Requests.createLock.Release(); + roomLock.Release(); } string[] members = Fs.ReadDirectory($"{GetRoomPath(roomType, roomId)}/members"); diff --git a/LarpixServer/Utils/AppJsonSerializerContext.cs b/LarpixServer/Utils/AppJsonSerializerContext.cs index 51e875a..606e290 100644 --- a/LarpixServer/Utils/AppJsonSerializerContext.cs +++ b/LarpixServer/Utils/AppJsonSerializerContext.cs @@ -11,6 +11,7 @@ using System.Collections.Generic; [JsonSerializable(typeof(Universal3String))] [JsonSerializable(typeof(Universal4String))] [JsonSerializable(typeof(Universal5String))] +[JsonSerializable(typeof(Universal6String))] [JsonSerializable(typeof(ServerInfo))] [JsonSerializable(typeof(Message))] [JsonSerializable(typeof(Dictionary))] diff --git a/LarpixServer/Utils/Jsons.cs b/LarpixServer/Utils/Jsons.cs index 3c9e25e..8c6dcc5 100644 --- a/LarpixServer/Utils/Jsons.cs +++ b/LarpixServer/Utils/Jsons.cs @@ -34,6 +34,15 @@ public class Universal5String public string string5 { get; set; } } +public class Universal6String +{ + public string string1 { get; set; } + public string string2 { get; set; } + public string string3 { get; set; } + public string string4 { get; set; } + public string string5 { get; set; } + public string string6 { get; set; } +} public class KeyExchangePayload {