diff --git a/LarpixServer/Account/Requests.cs b/LarpixServer/Account/Requests.cs index 1ee4f03..b6028fe 100644 --- a/LarpixServer/Account/Requests.cs +++ b/LarpixServer/Account/Requests.cs @@ -307,8 +307,7 @@ public class Requests } createHolder.TryRemove(serialized.idKey, out _); - //już trzymamy userLock(id) - rdzeń bez ponownego Wejścia - await Utils.UpdateLastLoginLocked(id.ToString()); + await Utils.UpdateLastLogin(id.ToString()); } finally { @@ -466,30 +465,19 @@ public class Requests { nonceHolder.TryRemove(kvp.Key, out _); } - } - - //Get-or-add w pętli: wcześniej enumerate+warunkowy return+TryAdd był - //check-then-act - dwa równoległe żądania generowały różne nonce, drugi - //TryAdd przegrywał po cichu, a klient dostawał "obcy" nonce i kolejny - //auth kończył się error:invalid.nonce (audyt S3) - while (true) - { - if (nonceHolder.TryGetValue(id, out var existing)) + else if (kvp.Key == id) { - await context.Response.WriteAsync(Encryption.Encryption.EncryptForClient(existing.Item1, + await context.Response.WriteAsync(Encryption.Encryption.EncryptForClient(kvp.Value.Item1, plainPass)); return; } - - string nonce = Encryption.Encryption.GetRandomString(64); - if (nonceHolder.TryAdd(id, (nonce, DateTimeOffset.UtcNow))) - { - await context.Response.WriteAsync(Encryption.Encryption.EncryptForClient(nonce, - plainPass)); - return; - } - //ktoś wstawił wpis w międzyczasie - pętla odczyta jego wartość } + + string nonce = Encryption.Encryption.GetRandomString(64); + nonceHolder.TryAdd(id, (nonce, DateTimeOffset.UtcNow)); + await context.Response.WriteAsync(Encryption.Encryption.EncryptForClient(nonce, + plainPass)); + return; } public static async Task ChangePassword(HttpContext context, Func next, IQueryCollection query, @@ -1110,12 +1098,6 @@ public class Requests await Room.Shared.VoiceLeave(id, serializedBody.string2) , password)); break; - case "room/voice/state": - await context.Response.WriteAsync( - Encryption.Encryption.EncryptForClient( - await Room.Shared.VoiceState(id, serializedBody.string2) - , password)); - break; case "room/signal": await context.Response.WriteAsync( Encryption.Encryption.EncryptForClient( @@ -1152,8 +1134,7 @@ public class Requests serializedBody.string2, AppJsonSerializerContext.Default.Universal2String ); - //dispatch trzyma już userLock(id) - rdzeń bez ponownego Wejścia - await Account.Utils.UpdateUserDmLocked(id, readGet.string1, "true", ""); + await Account.Utils.UpdateUserDm(id, readGet.string1, "true", ""); await context.Response.WriteAsync(Encryption.Encryption.EncryptForClient("success:dm.read", password)); break; } @@ -1163,8 +1144,7 @@ public class Requests serializedBody.string2, AppJsonSerializerContext.Default.Universal2String ); - //dispatch trzyma już userLock(id) - rdzeń bez ponownego Wejścia - await Room.Groups.UpdateUserGroupLocked(id, readGet.string1, "true", ""); + await Room.Groups.UpdateUserGroup(id, readGet.string1, "true", ""); await context.Response.WriteAsync(Encryption.Encryption.EncryptForClient("success:group.read", password)); break; } @@ -1273,9 +1253,7 @@ public class Requests string id = Utils.GetValidIdOrZero(idQuery.ToString()); string password = await Utils.GetPassword(id); - //S9: jednorazowa akcja zapisu - konsumuj nonce (delEntry:false pozwalał - //na nieograniczony replay przechwyconej pary secret+body) - string secretStr = await Utils.NonceDecryptBody(id, password, secret, true); + string secretStr = await Utils.NonceDecryptBody(id, password, secret, false); string auth = await Utils.Auth(id, secretStr, password); if (auth != Utils.LOGIN_SUCCESS) diff --git a/LarpixServer/Account/Utils.cs b/LarpixServer/Account/Utils.cs index 8acd79f..b88bb93 100644 --- a/LarpixServer/Account/Utils.cs +++ b/LarpixServer/Account/Utils.cs @@ -210,31 +210,11 @@ public class Utils } public static async Task UpdateLastLogin(string id) - { - //Wrapper blokujący GetUserLock(id); rdzeń w UpdateLastLoginLocked. - //Uwaga: register woła rdzeń bezpośrednio - już trzyma ten lock. - SemaphoreSlim userLock = GetUserLock(id); - await userLock.WaitAsync(); - try - { - await UpdateLastLoginLocked(id); - } - finally - { - userLock.Release(); - } - } - - //Rdzeń UpdateLastLogin - WYMAGA trzymanego GetUserLock(id) przez wołającego. - public static async Task UpdateLastLoginLocked(string id) { if (!Fs.Exists($"{ACCOUNTS_DATA_DIR}/{id}/secret")) { return; } - //Pod GetUserLock: BackgroundDelete decyduje o usunięciu konta na podstawie - //lastlogin (createLock -> userLock) - niezablokowany zapis mógł wyścigowo - //odtworzyć plik w usuwanym drzewie albo przeoczyć świeży login await Fs.WriteFile($"{ACCOUNTS_DATA_DIR}/{id}/lastlogin", Encoding.UTF8.GetBytes(DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString())); } @@ -502,30 +482,7 @@ public class Utils public static async Task UpdateUserDm(string id, string dmId, string isRead = "false", string timestamp = "") { - //Wrapper blokujący GetUserLock(id). RDZENIEK jest UpdateUserDmLocked - - //ścieżki wołane spod już trzymanego userLock(id) MUSZĄ wołać wariant - //Locked bezpośrednio (SemaphoreSlim nie jest reentrantny - podwójne - //WaitAsync na tym samym pasku = samoczynny deadlock) - SemaphoreSlim userLock = GetUserLock(id); - await userLock.WaitAsync(); - try - { - await UpdateUserDmLocked(id, dmId, isRead, timestamp); - } - finally - { - userLock.Release(); - } - } - - //Rdzeń UpdateUserDm - WYMAGA trzymanego GetUserLock(id) przez wołającego. - public static async Task UpdateUserDmLocked(string id, string dmId, string isRead = "false", string timestamp = "") - { - //dmId trafia prosto do ścieżki pliku. Bez walidacji "dm/read" pozwalał - //zapisać plik członkostwa w dowolnym pokoju przez "../../.." (path - //traversal -> samodzielne nadanie sobie członkostwa / zapis gdziekolwiek) - if (!IsSafePathComponent(dmId)) return; - + if (await Account.Utils.NameFromId(id) == "") //if user account just got deleted { return; @@ -574,23 +531,6 @@ public class Utils } } - //Dopisuje "call":true do wpisu JSON listy, jeśli w pokoju trwa aktywna rozmowa - //głosowa (ActiveCalls). Pozwala klientowi od razu po starcie pokazać wskaźnik. - public static string AppendCallStateToEntry(string entryJson, string roomId) - { - bool active = false; - if (ActiveCalls.TryGetValue(roomId, out var callList)) - { - //Odczyt Count pod lockiem - ta sama lista jest mutowana pod lockiem - //w VoiceJoin/VoiceLeave/disconnect cleanup - lock (callList) { active = callList.Count > 0; } - } - if (!active) return entryJson; - string trimmed = entryJson.TrimEnd(); - if (!trimmed.EndsWith("}")) return entryJson; - return $"{trimmed.Substring(0, trimmed.Length - 1)},\"call\":true}}"; - } - public static async Task GetUserDms(string id) { string path = $"{ACCOUNTS_DATA_DIR}/{id}/dms"; @@ -606,16 +546,14 @@ public class Utils { string content = Encoding.UTF8.GetString(await Fs.ReadFile($"{path}/{dmFiles[i]}")); if (string.IsNullOrWhiteSpace(content)) continue; - - content = AppendCallStateToEntry(content, dmFiles[i]); - + if (!first) dmsBuilder.Append(','); dmsBuilder.Append($"\"{dmFiles[i]}\":"); dmsBuilder.Append(content); first = false; } dmsBuilder.Append("}"); - + return $"{{{dmsBuilder.ToString()}}}"; } diff --git a/LarpixServer/Program.cs b/LarpixServer/Program.cs index 3601ce7..dd0258a 100644 --- a/LarpixServer/Program.cs +++ b/LarpixServer/Program.cs @@ -15,13 +15,6 @@ public class Program { await LoadEnv(); - //S18: część operacji Fs (Exists/ReadDirectory/Delete) jest synchroniczna - //i blokuje wątek poola. Przy tysiącach równoległych żądań pool dokłada - //wątki tylko ~1/s co kończy się załamaniem latencji zanim zdąży nadążyć. - //Podniesienie minimum usuwa ten ramp-up przy zerowym ryzyku zmian w kodzie. - int workers = Environment.ProcessorCount; - ThreadPool.SetMinThreads(workers * 16, workers * 16); - // Voice ticket sweeper: issued tickets live for 60s; remove expired // ones a minute later so nothing lingers in memory. Also prunes stale // per-IP auth rate-limit counters (they are written pre-HMAC-check, diff --git a/LarpixServer/Room/Groups.cs b/LarpixServer/Room/Groups.cs index ab9396c..28932d3 100644 --- a/LarpixServer/Room/Groups.cs +++ b/LarpixServer/Room/Groups.cs @@ -23,46 +23,23 @@ public class Groups } public static async Task UpdateUserGroup(string id, string groupId, string isRead = "false", string timestamp = "") - { - //Wrapper blokujący GetUserLock(id). Rdzeń: UpdateUserGroupLocked - - //ścieżki spod trzymanego userLock(id) wołają wariant Locked bezpośrednio - //(SemaphoreSlim nie jest reentrantny) - SemaphoreSlim userLock = Account.Utils.GetUserLock(id); - await userLock.WaitAsync(); - try - { - await UpdateUserGroupLocked(id, groupId, isRead, timestamp); - } - finally - { - userLock.Release(); - } - } - - //Rdzeń UpdateUserGroup - WYMAGA trzymanego GetUserLock(id) przez wołającego. - public static async Task UpdateUserGroupLocked(string id, string groupId, string isRead = "false", string timestamp = "") { if (await Account.Utils.NameFromId(id) == "") //if user account just got deleted { return; } - + string groupDir = $"{ACCOUNTS_DATA_DIR}/{id}/groups"; if (!Fs.Exists(groupDir)) { Fs.CreateDirectory(groupDir); } - + if (!groupId.Contains(';')) { groupId = $"{groupId};{Utils.Utils.DOMAIN}"; } - //groupId trafia prosto do ścieżki pliku - bez walidacji "group/read" - //pozwalał zapisać plik członkostwa w dowolnej grupie przez "../../.." - //(path traversal -> samodzielne nadanie sobie członkostwa / zapis gdziekolwiek) - if (!IsSafeGroupId(groupId)) return; - string groupPath = $"{groupDir}/{groupId}"; Universal3String fileGroup = new Universal3String(); if (Fs.Exists(groupPath)) @@ -164,7 +141,7 @@ public class Groups JsonSerializer.Serialize(startingMessage, AppJsonSerializerContext.Default.Message) )); - await UpdateUserGroupLocked(id, groupId.ToString(), "true", startingMessage.timestamp); + await UpdateUserGroup(id, groupId.ToString(), "true", startingMessage.timestamp); return $"success:{groupId}"; } @@ -193,16 +170,14 @@ public class Groups { string content = Encoding.UTF8.GetString(await Fs.ReadFile($"{path}/{groupFiles[i]}")); if (string.IsNullOrWhiteSpace(content)) continue; - - content = Account.Utils.AppendCallStateToEntry(content, groupFiles[i]); - + if (!first) groupsBuilder.Append(','); groupsBuilder.Append($"\"{groupFiles[i]}\":"); groupsBuilder.Append(content); first = false; } groupsBuilder.Append("}"); - + return $"{{{groupsBuilder.ToString()}}}"; } @@ -439,11 +414,6 @@ public class Groups await userLock2.WaitAsync(); try { - //Jeśli zaproszenie zostało właśnie zaakceptowane (członkostwo - //istnieje), NIE odtwarzaj pliku invite - asynchroniczny zapis - //PO akceptacji tworzyłby zombie-invite na liście na zawsze - if (Fs.Exists($"{groupPath}/members/{id2};{DOMAIN}")) return; - Fs.CreateDirectory(ACCOUNTS_DATA_DIR + $"/{id2}/groupinvites/recv"); await Fs.WriteFile(inviteFile, inviteBytes); } @@ -521,14 +491,12 @@ public class Groups string inviteFile = ACCOUNTS_DATA_DIR + $"/{id}/groupinvites/recv/{groupId};{iId}"; string groupPath = GetGroupPath(groupId); + if (!Fs.Exists(groupPath)) return "error:group.not.found"; SemaphoreSlim roomLock = Room.Requests.GetRoomLock(groupId); await roomLock.WaitAsync(); try { - //Istnienie grupy sprawdzane POD lockiem - GroupDelete mógł usunąć - //grupę między checkiem a joinem (powstawał katalog bez ownera/nazwy) - if (!Fs.Exists(groupPath)) return "error:group.not.found"; if (!Fs.Exists(inviteFile)) return "error:no.invite"; string inviteContent = Encoding.UTF8.GetString(await Fs.ReadFile(inviteFile)); @@ -621,8 +589,7 @@ public class Groups )); await Fs.WriteFile($"{groupPath}/messages/last", Encoding.UTF8.GetBytes(msgId.ToString())); - //dispatch trzyma już userLock(id) - rdzeń bez ponownego Wejścia - await UpdateUserGroupLocked(id, groupId, "true", timestamp); + await UpdateUserGroup(id, groupId, "true", timestamp); // Notify all members (including the new one) via WebSocket string[] currentMembers = Fs.ReadDirectory($"{groupPath}/members"); @@ -1009,8 +976,13 @@ public class Groups { if (!IsSafeGroupId(groupId)) return "error:group.not.found"; string groupPath = GetGroupPath(groupId); - + if (!Fs.Exists(groupPath)) return "error:group.not.found"; + string ownerPath = $"{groupPath}/owner"; + if (!Fs.Exists(ownerPath)) return "error:forbidden"; + + string currentOwner = Encoding.UTF8.GetString(await Fs.ReadFile(ownerPath)); + if (currentOwner != $"{id};{DOMAIN}") return "error:forbidden"; string tId = Account.Utils.GetValidIdOrZero(targetId); if (tId == "0") return "error:invalid.user"; @@ -1022,12 +994,6 @@ public class Groups await roomLock.WaitAsync(); try { - //Check ownera POD lockiem - poza nim ex-owner wykonywał kick - //wyścigowo z równoległym transferem własności - if (!Fs.Exists(ownerPath)) return "error:forbidden"; - string currentOwner = Encoding.UTF8.GetString(await Fs.ReadFile(ownerPath)); - if (currentOwner != $"{id};{DOMAIN}") return "error:forbidden"; - if (!Fs.Exists(memberPath)) return "error:not.a.member"; if (tId == id) return "error:cannot.kick.self"; @@ -1100,8 +1066,13 @@ public class Groups { if (!IsSafeGroupId(groupId)) return "error:group.not.found"; string groupPath = GetGroupPath(groupId); - + if (!Fs.Exists(groupPath)) return "error:group.not.found"; + string ownerPath = $"{groupPath}/owner"; + if (!Fs.Exists(ownerPath)) return "error:forbidden"; + + string currentOwner = Encoding.UTF8.GetString(await Fs.ReadFile(ownerPath)); + if (currentOwner != $"{id};{DOMAIN}") return "error:forbidden"; string tId = Account.Utils.GetValidIdOrZero(targetId); if (tId == "0") return "error:invalid.user"; @@ -1113,11 +1084,6 @@ public class Groups await roomLock.WaitAsync(); try { - //Check ownera POD lockiem (j.w.) - okno TOCTOU z kickiem/transferem - if (!Fs.Exists(ownerPath)) return "error:forbidden"; - string currentOwner = Encoding.UTF8.GetString(await Fs.ReadFile(ownerPath)); - if (currentOwner != $"{id};{DOMAIN}") return "error:forbidden"; - if (!Fs.Exists(memberPath)) return "error:not.a.member"; await Fs.WriteFile(ownerPath, Encoding.UTF8.GetBytes($"{tId};{tDomain}")); @@ -1199,8 +1165,7 @@ public class Groups { if (memberId == userId) { - //dispatch trzyma już userLock(userId) - rdzeń bez ponownego Wejścia - await UpdateUserGroupLocked(memberId, groupId, "true", timestamp); + await UpdateUserGroup(memberId, groupId, "true", timestamp); await Utils.Utils.SendWsMessage(memberId, $"group_message:{groupId}:read"); } else @@ -1211,7 +1176,7 @@ public class Groups await userLock.WaitAsync(); try { - await UpdateUserGroupLocked(memberId, groupId, "false", timestamp); + await UpdateUserGroup(memberId, groupId, "false", timestamp); } finally { diff --git a/LarpixServer/Room/Requests.cs b/LarpixServer/Room/Requests.cs index e610b9f..274f546 100644 --- a/LarpixServer/Room/Requests.cs +++ b/LarpixServer/Room/Requests.cs @@ -80,11 +80,6 @@ public class Requests { start = s; } - //Clamp do [0, last] - startOffset spoza zakresu (np. long.MaxValue przy - //mniejszej historii) dekrementowałby od góry pętlę robiącą Fs.Exists na - //każdą iterację = trwały DoS wątków poola jednym żądaniem - if (start > last) start = last; - if (start < 0) start = 0; int count = 50; if (!string.IsNullOrEmpty(countStr) && int.TryParse(countStr, out int c)) @@ -222,12 +217,6 @@ public class Requests await userLock2.WaitAsync(); try { - //Jeśli zaproszenie zostało właśnie zaakceptowane (pokój istnieje), - //NIE odtwarzaj pliku invite - pisany asynchronicznie PO akceptacji - //tworzyłby zombie-invite wiszący na liście na zawsze - string dmIdCheck = Account.Utils.GetDmId(id, DOMAIN, id2, domain); - if (Fs.Exists($"{ROOMS_DIR}/dms/{DOMAIN}/{dmIdCheck}/members")) return; - await Fs.WriteFile(inviteFile, timestamp); } finally @@ -413,7 +402,7 @@ public class Requests JsonSerializer.Serialize(startingMessage, AppJsonSerializerContext.Default.Message) )); - await Account.Utils.UpdateUserDmLocked(id, dmId, "false", + await Account.Utils.UpdateUserDm(id, dmId, "false", startingMessage.timestamp); if (isUserLocal) @@ -424,7 +413,7 @@ public class Requests await userLock.WaitAsync(); try { - await Account.Utils.UpdateUserDmLocked(id2, dmId, "false", + await Account.Utils.UpdateUserDm(id2, dmId, "false", startingMessage.timestamp); } finally @@ -558,8 +547,7 @@ public class Requests { if (memberId == id) { - //dispatch trzyma już userLock(id) - rdzeń bez ponownego Wejścia - await Account.Utils.UpdateUserDmLocked(memberId, dmId, "true", msg.timestamp); + await Account.Utils.UpdateUserDm(memberId, dmId, "true", msg.timestamp); await Utils.Utils.SendWsMessage(memberId, $"dm_message:{dmId}:read"); } else @@ -570,7 +558,7 @@ public class Requests await userLock.WaitAsync(); try { - await Account.Utils.UpdateUserDmLocked(memberId, dmId, "false", msg.timestamp); + await Account.Utils.UpdateUserDm(memberId, dmId, "false", msg.timestamp); } finally { @@ -596,46 +584,35 @@ public class Requests if (!IsSafeMsgId(msgId)) return "error:invalid.msg.id"; if (!await IsMemberOfDm(id, dmId)) return "error:forbidden"; - //W3: jedyna mutacja DM bez room-locka - wyścig z DmMessageReactAdd - //(licznik reakcji vs DeleteDirectory) i z podwójnym delete'em - SemaphoreSlim roomLock = GetRoomLock(dmId); - await roomLock.WaitAsync(); - try + string msgPath = $"{ROOMS_DIR}/dms/{DOMAIN}/{dmId}/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 = $"{ROOMS_DIR}/dms/{DOMAIN}/{dmId}/reactions/{msgId}"; + if (Fs.Exists(reactDir)) { - string msgPath = $"{ROOMS_DIR}/dms/{DOMAIN}/{dmId}/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 = $"{ROOMS_DIR}/dms/{DOMAIN}/{dmId}/reactions/{msgId}"; - if (Fs.Exists(reactDir)) - { - Fs.DeleteDirectory(reactDir); - } - - string attDir = $"{ROOMS_DIR}/dms/{DOMAIN}/{dmId}/attachments/{msgId}"; - if (Fs.Exists(attDir)) - { - Fs.DeleteDirectory(attDir); - } + Fs.DeleteDirectory(reactDir); } - finally + + string attDir = $"{ROOMS_DIR}/dms/{DOMAIN}/{dmId}/attachments/{msgId}"; + if (Fs.Exists(attDir)) { - roomLock.Release(); + Fs.DeleteDirectory(attDir); } string[] members = Fs.ReadDirectory($"{ROOMS_DIR}/dms/{DOMAIN}/{dmId}/members"); diff --git a/LarpixServer/Room/Shared.cs b/LarpixServer/Room/Shared.cs index 6fb3cda..33f9467 100644 --- a/LarpixServer/Room/Shared.cs +++ b/LarpixServer/Room/Shared.cs @@ -69,29 +69,15 @@ public class Shared public static async Task IsMemberOfRoom(string id, string roomType, string roomId) { - //Whitelist roomType - trafia do GetRoomPath bezpośrednio, a pochodzi - //od użytkownika (audyt S7: "../.."-style wartości przekierowywały - //proby ścieżek poza katalog pokoi) - if (!IsSupportedRoomType(roomType)) return false; if (!IsSafeId(roomId)) return false; string memberPath = $"{GetRoomPath(roomType, roomId)}/members/{id};{DOMAIN}"; return Fs.Exists(memberPath); } - public static bool IsSupportedRoomType(string roomType) - { - return roomType == "dms" || roomType == "groups"; - } - public static async Task GetUserRole(string id, string roomType, string roomId, string targetUserId) { if (!await IsMemberOfRoom(id, roomType, roomId)) return "error:forbidden"; - //S8: targetUserId trafia do ścieżki pliku - bez walidacji odpowiedź - //"not.a.member" vs rola działała jako oracle istnienia dowolnych plików - //(probe "../../../.."). Pełny format "id;domena" zostaje zachowany - if (string.IsNullOrEmpty(targetUserId) || !Account.Utils.IsSafePathComponent(targetUserId)) return "error:not.a.member"; - string targetMemberPath = $"{GetRoomPath(roomType, roomId)}/members/{targetUserId}"; if (!Fs.Exists(targetMemberPath)) return "error:not.a.member"; @@ -155,10 +141,6 @@ public class Shared { start = s; } - //Clamp do [0, last] - patrz GetDmMessages (audyt W1: nieskończona - //pętla Fs.Exists przy startOffset poza zakresem) - if (start > last) start = last; - if (start < 0) start = 0; int count = 50; if (!string.IsNullOrEmpty(countStr) && int.TryParse(countStr, out int c)) @@ -338,10 +320,8 @@ public class Shared { if (memberId == id) { - //dispatch trzyma już userLock(memberId==id) - rdzenie bez - //ponownego Wejścia w nie-reentrantny semaphore - if (roomType == "dms") await Account.Utils.UpdateUserDmLocked(memberId, roomId, "true", msg.timestamp); - if (roomType == "groups") await Room.Groups.UpdateUserGroupLocked(memberId, roomId, "true", msg.timestamp); + if (roomType == "dms") await Account.Utils.UpdateUserDm(memberId, roomId, "true", msg.timestamp); + if (roomType == "groups") await Room.Groups.UpdateUserGroup(memberId, roomId, "true", msg.timestamp); await Utils.Utils.SendWsMessage(memberId, $"{GetRoomEventPrefix(roomType)}_message:{roomId}:read"); } @@ -353,8 +333,8 @@ public class Shared await userLock.WaitAsync(); try { - if (roomType == "dms") await Account.Utils.UpdateUserDmLocked(memberId, roomId, "false", msg.timestamp); - if (roomType == "groups") await Room.Groups.UpdateUserGroupLocked(memberId, roomId, "false", msg.timestamp); + if (roomType == "dms") await Account.Utils.UpdateUserDm(memberId, roomId, "false", msg.timestamp); + if (roomType == "groups") await Room.Groups.UpdateUserGroup(memberId, roomId, "false", msg.timestamp); } finally { @@ -675,44 +655,6 @@ public class Shared Timeout = TimeSpan.FromSeconds(2) }; - // ===== FEDERACJA GŁOSU - PLAN (celowo niezaimplementowany) ===== - // - // Zasady hostowania rozmów między serwerami: - // 1) GROUPS - rozmowa głosowa jest zawsze hostowana przez serwer DOMOWY - // grupy (domena z groupId, np. "123;example.com" -> example.com). - // Powód: członkowie, klucze pokoju i ActiveCalls żyją na serwerze grupy, - // tam najprościej utrzymać jeden spójny stan i rozsyłać call_update - // (także do użytkowników zdalnych, przez API federacyjne). - // - // 2) DMS - gdy uczestnicy DM są z dwóch różnych domen, rozmowę hostuje - // serwer domowy OSOBY, KTÓRA ROZPOCZĘŁA rozmowę (pierwszy uczestnik). - // Powód: initiator i tak wykonuje room/voice/join u siebie; druga strona - // dostaje call_update/sygnalizację i dołącza do pokoju na hoście. - // Determinacja "kto zaczął": pierwszy wpis w liście uczestników danej - // rozmowy; po stronie implementacji trzeba tę informację przekazać - // w protokole federacyjnym (np. w żądaniu forwardu join). - // - // Do zrobienia przy implementacji: - // - GetVoiceHostDomain() ma zwracać domenę hosta (groups: domena z roomId; - // dms: domena inicjatora gdy obie domeny różne), - // - VoiceJoin/VoiceState: gdy hostDomain != DOMAIN -> forward żądania do - // room/voice/join / room/voice/state na serwerze domowym hosta i zwrot - // jego odpowiedzi (proxy), zamiast lokalnej alokacji ticketu, - // - klucz ActiveCalls dla pokoi zdalnych kwalifikować domeną - // (np. "{roomId}@{hostDomain}"), żeby nie kolidował z lokalnymi, - // - call_update rozsyłać również do członków zdalnych (Sender API), - // a disconnect cleanup powiadamiał hosta o odejściu swojego usera, - // - sygnalizacja (room/signal): sprawdzić trasowanie signal: do zdalnych. - public static string GetVoiceHostDomain(string roomType, string roomId) - { - // TODO(federation): - // - groups -> domena z części roomId po ';', - // - dms -> rozparsować dmId ("idA;domA_idB;domB"); jeśli domA != domB, - // hostem jest domena inicjatora (wymaga informacji o inicjatorze - // przekazanej przez protokół federacyjny). - return DOMAIN; - } - public static async Task VoiceJoin(string id, string body) { Universal2String serializedBody = JsonSerializer.Deserialize( @@ -724,43 +666,18 @@ public class Shared if (!await IsMemberOfRoom(id, roomType, roomId)) return "error:forbidden"; - //Federacja (patrz plan wyżej): dla pokoju zdalnego to żądanie powinno - //zostać sforwardowane do serwera hostującego rozmowę. Na razie każda - //rozmowa jest hostowana lokalnie. - string voiceHostDomain = GetVoiceHostDomain(roomType, roomId); - - bool added = false; var callList = ActiveCalls.GetOrAdd(roomId, _ => new List()); lock (callList) { if (!callList.Contains(id)) { callList.Add(id); - added = true; } } - - //call_update rozsyłamy TYLKO gdy skład się zmienił - ponowne wejście - //(np. reconnect) nie generuje zbędnych broadcastów do wszystkich - //członków, co przy dużych grupach oszczędza WS-owe pakiety i CPU - if (added) - { - string callState; - lock (callList) { callState = JsonSerializer.Serialize(callList, AppJsonSerializerContext.Default.ListString); } - - string[] members = Fs.ReadDirectory($"{GetRoomPath(roomType, roomId)}/members"); - foreach(string memberWD in members) - { - if (Account.Utils.IsUserLocal(memberWD, out string _)) - { - string memberId = Account.Utils.GetValidIdOrZero(memberWD); - _ = Utils.Utils.SendWsMessage(memberId, $"call_update:{roomId}:{callState}"); - } - } - } - //Wybór serwera głosowego: najmniej obciążony z lokalnej listy VOICE_IPS. - //Federacja (plan): gdy voiceHostDomain != DOMAIN, tutaj należy użyć - //adresu serwera głosowego domeny hosta zamiast lokalnej listy. + + string callState; + lock (callList) { callState = JsonSerializer.Serialize(callList, AppJsonSerializerContext.Default.ListString); } + string bestIp = ""; long bestLoad = long.MaxValue; @@ -811,6 +728,15 @@ public class Shared return "error:voice.not.configured"; // 48 random bytes colliding - practically impossible } + string[] members = Fs.ReadDirectory($"{GetRoomPath(roomType, roomId)}/members"); + foreach(string memberWD in members) + { + if (Account.Utils.IsUserLocal(memberWD, out string _)) + { + string memberId = Account.Utils.GetValidIdOrZero(memberWD); + _ = Utils.Utils.SendWsMessage(memberId, $"call_update:{roomId}:{callState}"); + } + } return $"success:{bestIp}|{publicId}|{connKey}|{proof}"; } @@ -973,15 +899,9 @@ public class Shared string roomType = serializedBody.string1; string roomId = serializedBody.string2; - //S7: wcześniej brak jakiejkolwiek weryfikacji - każdy zalogowany mógł - //"wyjść" z cudzych rozmów (usuwał uczestników z ActiveCalls dowolnego - //pokoju i generował broadcasty na podstawie craftowanych ścieżek) - if (!await IsMemberOfRoom(id, roomType, roomId)) return "error:forbidden"; - if (ActiveCalls.TryGetValue(roomId, out var callList)) { bool removed = false; - bool removeEntry = false; lock (callList) { if (callList.Contains(id)) @@ -989,7 +909,6 @@ public class Shared callList.Remove(id); removed = true; } - if (callList.Count == 0) removeEntry = true; } if (removed) @@ -1007,50 +926,10 @@ public class Shared } } } - - if (removeEntry) - { - //S1: puste wpisy muszą znikać - inaczej dictionary rósł bez granic, - //a disconnect cleanup skanował wszystkie historyczne pokoje. - //Ewentualny wyścig z równoległym GetOrAdd nowego joinera kończy się - //co najwyżej jednym pominiętym broadcastem - następny join/leave to naprawia - ActiveCalls.TryRemove(roomId, out _); - } } return "success"; } - //Zwraca listę uczestników aktywnej rozmowy głosowej w pokoju (ID z domeną). - //Używane przez klienta do pokazania wskaźników "na rozmowie" po otwarciu pokoju. - public static async Task VoiceState(string id, string body) - { - Universal2String serializedBody = JsonSerializer.Deserialize( - body, - AppJsonSerializerContext.Default.Universal2String - ); - string roomType = serializedBody.string1; - string roomId = serializedBody.string2; - - if (!await IsMemberOfRoom(id, roomType, roomId)) return "error:forbidden"; - - List participants = new List(); - if (ActiveCalls.TryGetValue(roomId, out var callList)) - { - lock (callList) - { - foreach (string memberId in callList) - { - participants.Add($"{memberId};{DOMAIN}"); - } - } - } - return JsonSerializer.Serialize(participants, AppJsonSerializerContext.Default.ListString); - } - - //S12: sygnalizacja głosowa - limit rozmiaru payloadu (setupy klucza mieszczą - //się w kilobajtach; bez capu członek pokoju amplifikowałby ruch do wszystkich) - private const int MAX_SIGNAL_PAYLOAD_SIZE = 16 * 1024; - public static async Task RoomSignal(string id, string body) { Universal3String serializedBody = JsonSerializer.Deserialize( @@ -1063,56 +942,13 @@ public class Shared if (!await IsMemberOfRoom(id, roomType, roomId)) return "error:forbidden"; - if (string.IsNullOrEmpty(signalPayload) || signalPayload.Length > MAX_SIGNAL_PAYLOAD_SIZE) - { - return "error:invalid.signal"; - } - - //S12: pole 'from' jest nadpisywane uwierzytelnionym nadawcą - klient nie - //może podszyć się pod innego użytkownika (wcześniej odbiorca instalował - //klucz rozmowy wg client-asserted 'from') - string stamped; - string toId; - try - { - if (System.Text.Json.Nodes.JsonNode.Parse(signalPayload) is not System.Text.Json.Nodes.JsonObject signalObj) - { - return "error:invalid.signal"; - } - signalObj["from"] = id; - toId = Account.Utils.GetValidIdOrZero(signalObj["to"]?.ToString() ?? ""); - stamped = signalObj.ToJsonString(); - } - catch - { - return "error:invalid.signal"; - } - - //S21: doręczenie do adresata zamiast broadcastu do wszystkich członków - - //sygnalizacja była O(N²): każdy target = request, serwer rozsyłał do - //wszystkich i klient filtrował lokalnie. Gdy 'to' nie pasuje do nikogo - //(kompatybilność/stare klienty), zostaje broadcast jak dotychczas string[] members = Fs.ReadDirectory($"{GetRoomPath(roomType, roomId)}/members"); - bool targeted = false; - foreach (string memberWD in members) + foreach(string memberWD in members) { - if (!Account.Utils.IsUserLocal(memberWD, out string _)) continue; - string memberId = Account.Utils.GetValidIdOrZero(memberWD); - if (memberId != "0" && memberId == toId) + if (Account.Utils.IsUserLocal(memberWD, out string _)) { - targeted = true; - _ = Utils.Utils.SendWsMessage(memberId, $"signal:{roomId}:{stamped}"); - break; - } - } - - if (!targeted) - { - foreach (string memberWD in members) - { - if (!Account.Utils.IsUserLocal(memberWD, out string _)) continue; string memberId = Account.Utils.GetValidIdOrZero(memberWD); - _ = Utils.Utils.SendWsMessage(memberId, $"signal:{roomId}:{stamped}"); + _ = Utils.Utils.SendWsMessage(memberId, $"signal:{roomId}:{signalPayload}"); } } return "success"; diff --git a/LarpixServer/Utils/Utils.cs b/LarpixServer/Utils/Utils.cs index ff751c9..fa972c0 100644 --- a/LarpixServer/Utils/Utils.cs +++ b/LarpixServer/Utils/Utils.cs @@ -236,7 +236,6 @@ public class Utils foreach (var kvp in ActiveCalls) { bool removed = false; - bool removeEntry = false; lock (kvp.Value) { if (kvp.Value.Contains(id)) @@ -244,7 +243,6 @@ public class Utils kvp.Value.Remove(id); removed = true; } - if (kvp.Value.Count == 0) removeEntry = true; } if (removed) @@ -265,14 +263,6 @@ public class Utils } } } - - if (removeEntry) - { - //S1: przycinaj puste wpisy - bez tego dictionary rósł o każdy - //pokój, w którym kiedykolwiek była rozmowa, a każdy disconnect - //skanował wszystkie historyczne wpisy - ActiveCalls.TryRemove(kvp.Key, out _); - } } } } @@ -281,13 +271,6 @@ public class Utils //(równoległe SendAsync na tym samym sockecie rzucają InvalidOperationException). public static readonly ConcurrentDictionary WsSendLocks = new(); - //Timeout pojedynczej wysyłki WS. Bez niego klient, który uwierzytelnił się - //a potem przestał odbierać TCP (zero-window), zawiesza SendAsync w - //nieskończoność: semafor per-socket zajęty na zawsze => wieczne waitery - //(pamięć), a await siedzi wewnątrz userLock/roomLock i blokuje całe ścieżki - //żądań tego użytkownika/pokoju (audyt W2) - public static readonly TimeSpan WsSendTimeout = TimeSpan.FromMinutes(1); - private static SemaphoreSlim GetWsSendLock(System.Net.WebSockets.WebSocket ws) { return WsSendLocks.GetOrAdd(ws, _ => new SemaphoreSlim(1, 1)); @@ -320,14 +303,7 @@ public class Utils { try { - using var sendCts = new System.Threading.CancellationTokenSource(WsSendTimeout); - await ws.SendAsync(new ArraySegment(bytes), System.Net.WebSockets.WebSocketMessageType.Text, true, sendCts.Token); - } - catch (OperationCanceledException) - { - //Timeout wysyłki = socket przestał odbierać - usuwamy go - //z aktywnych, żeby nie blokował semafora na zawsze - remove = true; + await ws.SendAsync(new ArraySegment(bytes), System.Net.WebSockets.WebSocketMessageType.Text, true, CancellationToken.None); } catch { diff --git a/LarpixVoice/Requests.cs b/LarpixVoice/Requests.cs index 3313649..a73d909 100644 --- a/LarpixVoice/Requests.cs +++ b/LarpixVoice/Requests.cs @@ -90,15 +90,6 @@ public class Requests } currentSession = new Session(webSocket); - if (oldSession != null && !ReferenceEquals(oldSession, currentSession)) - { - //W4: nadpisujemy wpis w mapie - stara sesja nigdy nie przejdzie przez - //swoją gałąź finally (guard session==currentSession), więc jej kolejkę - //trzeba zamknąć TU. Bez tego task-pump starej sesji wisi wiecznie na - //ReadAllAsync = leak taska + kanału + buforów ArrayPool przy każdym - //reconnectcie - oldSession.SendQueue.Writer.TryComplete(); - } roomClients[clientId] = currentSession; _ = Task.Run(async () =>