using System.Collections.Concurrent; using System.Numerics; using System.Text; using System.Text.Json; using System.Text.RegularExpressions; using System.Threading; using LarpixServer.Filesystem; using LarpixServer.Utils.Jsons; using static LarpixServer.Utils.Utils; namespace LarpixServer.Account; public class Utils { private static readonly Regex USERNAME_REGEX = new Regex(@"^[a-zA-Z0-9_]+$"); public static string LOGIN_SUCCESS = "success:login.successful"; private static SemaphoreSlim[]? _userLocksArray = null; public static SemaphoreSlim GetUserLock(string id) { if (_userLocksArray == null) { int size = LOCK_SIZE > 0 ? LOCK_SIZE : 65536; var newArray = Enumerable.Range(0, size).Select(_ => new SemaphoreSlim(1, 1)).ToArray(); Interlocked.CompareExchange(ref _userLocksArray, newArray, null); } int hash = id.GetHashCode(); int index = (hash & 0x7FFFFFFF) % _userLocksArray.Length; return _userLocksArray[index]; } public static string GetIdFromUsernameWD(string usernameWD) { int separatorIndex = usernameWD.IndexOf(':'); if (separatorIndex == -1) separatorIndex = usernameWD.IndexOf(';'); return separatorIndex == -1 ? usernameWD : usernameWD.Substring(0, separatorIndex); } public static string GetValidIdOrZero(string input) { string idPart = GetIdFromUsernameWD(input); return ulong.TryParse(idPart, out _) ? idPart : "0"; } //Pojedynczy komponent sciezki (nazwa pliku/katalogu, id pokoju, klucz itp.) //Blokuje traversal ("..", "/", "\\") oraz puste/nadmiernie dlugie wartosci. public static bool IsSafePathComponent(string value, int maxLength = 128) { if (string.IsNullOrEmpty(value) || value.Length > maxLength) { return false; } if (value.Contains("..") || value.Contains('/') || value.Contains('\\')) { return false; } return true; } public static string GetDmId(string id1, string domain1, string id2, string domain2) { string u1 = $"{id1};{domain1}"; string u2 = $"{id2};{domain2}"; return string.CompareOrdinal(u1, u2) < 0 ? $"{u1}_{u2}" : $"{u2}_{u1}"; } public static bool IsUserLocal(string usernameWD, out string domain) { int separatorIndex = usernameWD.IndexOf(':'); if (separatorIndex == -1) separatorIndex = usernameWD.IndexOf(';'); if (separatorIndex == -1 || usernameWD.EndsWith(":" + DOMAIN) || usernameWD.EndsWith(";" + DOMAIN)) { domain = DOMAIN; return true; } domain = usernameWD.Substring(separatorIndex + 1); return false; } public static bool IsValidUsername(string username, out string message) { message = "error:username.length:3-18"; if (string.IsNullOrWhiteSpace(username)) return false; if (username.Length is < 3 or > 18) return false; message = "error:username.conditions.allowed:letters;numbers;underscores"; return USERNAME_REGEX.IsMatch(username); } public static bool IsValidPassword(string password, out string message) { message = "error:password.not.hashed.properly"; if (password.Length != 128) { return false; } if (!IsValidUtf8(password)) { return false; } return true; } public static bool IsValidUtf8(string password) { if (string.IsNullOrEmpty(password)) return false; try { var encoding = Encoding.GetEncoding("utf-8", new EncoderExceptionFallback(), new DecoderExceptionFallback()); byte[] utf8Bytes = encoding.GetBytes(password); return true; } catch (EncoderFallbackException) { return false; } } public static async Task IdFromName(string name) { if (name == null || !IsValidUsername(name, out _)) { return "0"; } string path = $"{ACCOUNTS_NAME_DIR}/{name.ToLowerInvariant()}"; if (!Fs.Exists(path)) { return "0"; } string id = Encoding.UTF8.GetString(await Fs.ReadFile(path)); return id; } public static async Task NameFromId(string id) { string path = $"{ACCOUNTS_DATA_DIR}/{id}/username"; if (!Fs.Exists(path)) { return ""; } return Encoding.UTF8.GetString(await Fs.ReadFile(path)); } public static async Task Auth(string id, string password, string password2) { if (!Fs.Exists($"{ACCOUNTS_DATA_DIR}/{id}")) { return "error:invalid.username.or.password"; } if (password != password2) { return "error:invalid.password"; } await UpdateLastLogin(id); return LOGIN_SUCCESS; } public static async Task NonceDecryptBody(string id, string password, string body, bool delEntry = true) { (string, DateTimeOffset) nonce; if (delEntry) { if (!Requests.nonceHolder.TryRemove(id, out nonce)) { return "error:invalid.nonce"; } } else { if (!Requests.nonceHolder.TryGetValue(id, out nonce)) { return "error:invalid.nonce"; } } string decBody = Encryption.Encryption.PacketDecPass(body, password, nonce.Item1); return decBody; } public static async Task NonceDecryptBytes(string id, string password, byte[] body, bool delEntry = true) { (string, DateTimeOffset) nonce; if (delEntry) { if (!Requests.nonceHolder.TryRemove(id, out nonce)) { return null; } } else { if (!Requests.nonceHolder.TryGetValue(id, out nonce)) { return null; } } return Encryption.Encryption.DecryptBytesAuto(body, nonce.Item1 + password); } 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())); } public static async Task GetLastLogin(string id) { if (!Fs.Exists($"{ACCOUNTS_DATA_DIR}/{id}/secret")) { return DateTimeOffset.MinValue; } return DateTimeOffset.FromUnixTimeSeconds(long.Parse(await Fs.ReadFile($"{ACCOUNTS_DATA_DIR}/{id}/lastlogin"))); } public static async Task SetPassword(string id, string newPassword) { string path = $"{ACCOUNTS_DATA_DIR}/{id}/secret"; if (!Fs.Exists(path)) { return; } await Fs.WriteFile(path, Encoding.UTF8.GetBytes(newPassword)); } public static async Task GetUserSentInvites(string id) { StringBuilder dmBuilder = new StringBuilder("\"dms\": {"); StringBuilder groupBuilder = new StringBuilder("\"groups\": {"); string path = $"{ACCOUNTS_DATA_DIR}/{id}/dminvites/sent"; if (Fs.Exists(path)) { string[] invites = Fs.ReadDirectory(path); for (int i = 0; i < invites.Length; i++) { string invite = invites[i]; string content = Encoding.UTF8.GetString(await Fs.ReadFile($"{path}/{invite}")); if (i > 0) dmBuilder.Append(','); dmBuilder.Append($"\"{invite}\": {{ \"timestamp\": {content} }}"); } } dmBuilder.Append("}"); path = $"{ACCOUNTS_DATA_DIR}/{id}/groupinvites/sent"; if (Fs.Exists(path)) { string[] invites = Fs.ReadDirectory(path); bool first = true; for (int i = 0; i < invites.Length; i++) { string invite = invites[i]; int lastSemi = invite.LastIndexOf(';'); if (lastSemi == -1) continue; string groupId = invite.Substring(0, lastSemi); string userId = invite.Substring(lastSemi + 1); if (!ulong.TryParse(userId, out _)) continue; string content = Encoding.UTF8.GetString(await Fs.ReadFile($"{path}/{invite}")); if (!first) groupBuilder.Append(','); first = false; 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("}"); return $"{{{dmBuilder.ToString()},{groupBuilder.ToString()}}}"; } public static async Task GetUserReceivedInvites(string id) { StringBuilder dmBuilder = new StringBuilder("\"dms\": {"); StringBuilder groupBuilder = new StringBuilder("\"groups\": {"); string path = $"{ACCOUNTS_DATA_DIR}/{id}/dminvites/recv"; if (Fs.Exists(path)) { string[] invites = Fs.ReadDirectory(path); for (int i = 0; i < invites.Length; i++) { string invite = invites[i]; string content = Encoding.UTF8.GetString(await Fs.ReadFile($"{path}/{invite}")); if (i > 0) dmBuilder.Append(','); dmBuilder.Append($"\"{invite}\": {{ \"timestamp\": {content} }}"); } } dmBuilder.Append("}"); path = $"{ACCOUNTS_DATA_DIR}/{id}/groupinvites/recv"; if (Fs.Exists(path)) { string[] invites = Fs.ReadDirectory(path); bool first = true; for (int i = 0; i < invites.Length; i++) { string invite = invites[i]; int lastSemi = invite.LastIndexOf(';'); if (lastSemi == -1) continue; string groupId = invite.Substring(0, lastSemi); string userId = invite.Substring(lastSemi + 1); if (!ulong.TryParse(userId, out _)) continue; string content = Encoding.UTF8.GetString(await Fs.ReadFile($"{path}/{invite}")); if (!first) groupBuilder.Append(','); first = false; 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("}"); return $"{{{dmBuilder.ToString()},{groupBuilder.ToString()}}}"; } public static async Task UpdateUserKeys(string id, string body) { if (!Fs.Exists($"{ACCOUNTS_DATA_DIR}/{id}/secret")) { return; } await Fs.WriteFile($"{ACCOUNTS_DATA_DIR}/{id}/keys", Encoding.UTF8.GetBytes(body)); } public static async Task GetUserKeys(string id) { string path = $"{ACCOUNTS_DATA_DIR}/{id}/keys"; if (!Fs.Exists(path)) { return ""; } return Encoding.UTF8.GetString(await Fs.ReadFile(path)); } public static async Task GetUserPublicStorageEntry(string id, string entry) { if (string.IsNullOrEmpty(entry) || entry.Contains("..") || entry.Contains("/") || entry.Contains("\\")) { return new byte[] {}; } string path = $"{ACCOUNTS_DATA_DIR}/{id}/storage/public/{entry}"; if (!Fs.Exists(path)) { return new byte[] {}; } return await Fs.ReadFile(path); } public static async Task UpdateUserPublicStorageEntryRaw(string id, string entry, byte[] contentBytes) { if (string.IsNullOrEmpty(entry) || entry.Contains("..") || entry.Contains("/") || entry.Contains("\\")) { return "error:invalid.entry.name"; } 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) { return "error:storage.limit.exceeded"; } await Fs.WriteFile(path, contentBytes); return "success:storage.entry.updated"; } public static async Task UpdateUserPublicStorageEntry(string id, string entry, string contentBase64) { if (string.IsNullOrEmpty(entry) || entry.Contains("..") || entry.Contains("/") || entry.Contains("\\")) { return "error:invalid.entry.name"; } byte[] contentBytes; try { contentBytes = Convert.FromBase64String(contentBase64); } catch { contentBytes = Encoding.UTF8.GetBytes(contentBase64); } 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) { return "error:storage.limit.exceeded"; } await Fs.WriteFile(path, contentBytes); return "success:storage.entry.updated"; } public static async Task GetUserPrivateStorageEntry(string id, string entry) { if (string.IsNullOrEmpty(entry) || entry.Contains("..") || entry.Contains("/") || entry.Contains("\\")) { return new byte[] {}; } string path = $"{ACCOUNTS_DATA_DIR}/{id}/storage/private/{entry}"; if (!Fs.Exists(path)) { return new byte[] {}; } return await Fs.ReadFile(path); } public static async Task UpdateUserPrivateStorageEntry(string id, string entry, string contentBase64) { if (string.IsNullOrEmpty(entry) || entry.Contains("..") || entry.Contains("/") || entry.Contains("\\")) { return "error:invalid.entry.name"; } byte[] contentBytes; try { contentBytes = Convert.FromBase64String(contentBase64); } catch { contentBytes = Encoding.UTF8.GetBytes(contentBase64); } 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) { return "error:storage.limit.exceeded"; } await Fs.WriteFile(path, contentBytes); return "success:storage.entry.updated"; } 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; } string dmPath = $"{ACCOUNTS_DATA_DIR}/{id}/dms/{dmId}"; Universal3String fileDm = new Universal3String(); if (Fs.Exists(dmPath)) { fileDm = JsonSerializer.Deserialize( Encoding.UTF8.GetString(await Fs.ReadFile(dmPath)), AppJsonSerializerContext.Default.Universal3String ); } else { fileDm.string1 = ""; fileDm.string2 = ""; fileDm.string3 = ""; } fileDm.string1 = dmId; if (timestamp != "") { fileDm.string2 = timestamp; } fileDm.string3 = isRead; await Fs.WriteFile(dmPath, Encoding.UTF8.GetBytes(JsonSerializer.Serialize(fileDm, AppJsonSerializerContext.Default.Universal3String))); } //Usuwa nieaktualny wpis DM z listy użytkownika - wywoływane gdy serwer wykryje //brak permisji przy próbie dostępu (użytkownik nie jest uczestnikiem rozmowy). public static void RemoveStaleUserDmEntry(string userId, string dmId) { try { if (string.IsNullOrEmpty(dmId) || dmId.Contains("..") || dmId.Contains('/') || dmId.Contains('\\')) return; string entryPath = $"{ACCOUNTS_DATA_DIR}/{userId}/dms/{dmId}"; if (Fs.Exists(entryPath)) { Fs.DeleteFile(entryPath); } } catch { } } //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"; if (!Fs.Exists(path)) { return "{\"dms\":{}}"; } StringBuilder dmsBuilder = new StringBuilder("\"dms\":{"); string[] dmFiles = Fs.ReadDirectory(path); bool first = true; for (int i = 0; i < dmFiles.Length; i++) { 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()}}}"; } public static async Task RemoveOldestDmIndex(string id) //i wont implement this, client should just warn users that they have like 99999999 dms and should leave some { } public static async Task GetPassword(string id) { string path = $"{ACCOUNTS_DATA_DIR}/{id}/secret"; if (!Fs.Exists(path)) { return ""; } return Encoding.UTF8.GetString(await Fs.ReadFile(path)); } public static async Task SetUsername(string id, string newName) { if (!Fs.Exists($"{ACCOUNTS_DATA_DIR}/{id}/secret")) { return "error:account.not.exist"; } if (!IsValidUsername(newName, out string message)) { return message; } string oldName = await NameFromId(id); string lowerNewName = newName.ToLowerInvariant(); string lowerOldName = oldName.ToLowerInvariant(); //createLock -> userLock (ta sama kolejność co Create/finish i BackgroundDelete). //Zamyka TOCTOU z rejestracją: nazwa rezerwowana pod wspólnym lockiem. await Requests.createLock.WaitAsync(); try { if (lowerNewName != lowerOldName) { if (Fs.Exists($"{ACCOUNTS_NAME_DIR}/{lowerNewName}")) { return "error:username.taken"; } Fs.MoveFile($"{ACCOUNTS_NAME_DIR}/{lowerOldName}", $"{ACCOUNTS_NAME_DIR}/{lowerNewName}"); } SemaphoreSlim userLock = GetUserLock(id); await userLock.WaitAsync(); try { try { await Fs.WriteFile($"{ACCOUNTS_DATA_DIR}/{id}/username", Encoding.UTF8.GetBytes(newName)); } catch (Exception) { if (lowerNewName != lowerOldName) { Fs.MoveFile($"{ACCOUNTS_NAME_DIR}/{lowerNewName}", $"{ACCOUNTS_NAME_DIR}/{lowerOldName}"); } throw; } } finally { userLock.Release(); } } finally { Requests.createLock.Release(); } return "success:username.changed"; } }