Compare commits
No commits in common. "b077097a33b17c64a24294f1531bcdeb807f6543" and "6dc145a2c8dd570fb263fc41940fb5c65cdc6efb" have entirely different histories.
b077097a33
...
6dc145a2c8
8 changed files with 83 additions and 429 deletions
|
|
@ -307,8 +307,7 @@ public class Requests
|
||||||
}
|
}
|
||||||
|
|
||||||
createHolder.TryRemove(serialized.idKey, out _);
|
createHolder.TryRemove(serialized.idKey, out _);
|
||||||
//już trzymamy userLock(id) - rdzeń bez ponownego Wejścia
|
await Utils.UpdateLastLogin(id.ToString());
|
||||||
await Utils.UpdateLastLoginLocked(id.ToString());
|
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
|
|
@ -466,31 +465,20 @@ public class Requests
|
||||||
{
|
{
|
||||||
nonceHolder.TryRemove(kvp.Key, out _);
|
nonceHolder.TryRemove(kvp.Key, out _);
|
||||||
}
|
}
|
||||||
}
|
else if (kvp.Key == id)
|
||||||
|
|
||||||
//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))
|
await context.Response.WriteAsync(Encryption.Encryption.EncryptForClient(kvp.Value.Item1,
|
||||||
{
|
|
||||||
await context.Response.WriteAsync(Encryption.Encryption.EncryptForClient(existing.Item1,
|
|
||||||
plainPass));
|
plainPass));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
string nonce = Encryption.Encryption.GetRandomString(64);
|
string nonce = Encryption.Encryption.GetRandomString(64);
|
||||||
if (nonceHolder.TryAdd(id, (nonce, DateTimeOffset.UtcNow)))
|
nonceHolder.TryAdd(id, (nonce, DateTimeOffset.UtcNow));
|
||||||
{
|
|
||||||
await context.Response.WriteAsync(Encryption.Encryption.EncryptForClient(nonce,
|
await context.Response.WriteAsync(Encryption.Encryption.EncryptForClient(nonce,
|
||||||
plainPass));
|
plainPass));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
//ktoś wstawił wpis w międzyczasie - pętla odczyta jego wartość
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public static async Task ChangePassword(HttpContext context, Func<Task> next, IQueryCollection query,
|
public static async Task ChangePassword(HttpContext context, Func<Task> next, IQueryCollection query,
|
||||||
StreamReader bodyReader)
|
StreamReader bodyReader)
|
||||||
|
|
@ -1110,12 +1098,6 @@ public class Requests
|
||||||
await Room.Shared.VoiceLeave(id, serializedBody.string2)
|
await Room.Shared.VoiceLeave(id, serializedBody.string2)
|
||||||
, password));
|
, password));
|
||||||
break;
|
break;
|
||||||
case "room/voice/state":
|
|
||||||
await context.Response.WriteAsync(
|
|
||||||
Encryption.Encryption.EncryptForClient(
|
|
||||||
await Room.Shared.VoiceState(id, serializedBody.string2)
|
|
||||||
, password));
|
|
||||||
break;
|
|
||||||
case "room/signal":
|
case "room/signal":
|
||||||
await context.Response.WriteAsync(
|
await context.Response.WriteAsync(
|
||||||
Encryption.Encryption.EncryptForClient(
|
Encryption.Encryption.EncryptForClient(
|
||||||
|
|
@ -1152,8 +1134,7 @@ public class Requests
|
||||||
serializedBody.string2,
|
serializedBody.string2,
|
||||||
AppJsonSerializerContext.Default.Universal2String
|
AppJsonSerializerContext.Default.Universal2String
|
||||||
);
|
);
|
||||||
//dispatch trzyma już userLock(id) - rdzeń bez ponownego Wejścia
|
await Account.Utils.UpdateUserDm(id, readGet.string1, "true", "");
|
||||||
await Account.Utils.UpdateUserDmLocked(id, readGet.string1, "true", "");
|
|
||||||
await context.Response.WriteAsync(Encryption.Encryption.EncryptForClient("success:dm.read", password));
|
await context.Response.WriteAsync(Encryption.Encryption.EncryptForClient("success:dm.read", password));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
@ -1163,8 +1144,7 @@ public class Requests
|
||||||
serializedBody.string2,
|
serializedBody.string2,
|
||||||
AppJsonSerializerContext.Default.Universal2String
|
AppJsonSerializerContext.Default.Universal2String
|
||||||
);
|
);
|
||||||
//dispatch trzyma już userLock(id) - rdzeń bez ponownego Wejścia
|
await Room.Groups.UpdateUserGroup(id, readGet.string1, "true", "");
|
||||||
await Room.Groups.UpdateUserGroupLocked(id, readGet.string1, "true", "");
|
|
||||||
await context.Response.WriteAsync(Encryption.Encryption.EncryptForClient("success:group.read", password));
|
await context.Response.WriteAsync(Encryption.Encryption.EncryptForClient("success:group.read", password));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
@ -1273,9 +1253,7 @@ public class Requests
|
||||||
|
|
||||||
string id = Utils.GetValidIdOrZero(idQuery.ToString());
|
string id = Utils.GetValidIdOrZero(idQuery.ToString());
|
||||||
string password = await Utils.GetPassword(id);
|
string password = await Utils.GetPassword(id);
|
||||||
//S9: jednorazowa akcja zapisu - konsumuj nonce (delEntry:false pozwalał
|
string secretStr = await Utils.NonceDecryptBody(id, password, secret, false);
|
||||||
//na nieograniczony replay przechwyconej pary secret+body)
|
|
||||||
string secretStr = await Utils.NonceDecryptBody(id, password, secret, true);
|
|
||||||
string auth = await Utils.Auth(id, secretStr, password);
|
string auth = await Utils.Auth(id, secretStr, password);
|
||||||
|
|
||||||
if (auth != Utils.LOGIN_SUCCESS)
|
if (auth != Utils.LOGIN_SUCCESS)
|
||||||
|
|
|
||||||
|
|
@ -210,31 +210,11 @@ public class Utils
|
||||||
}
|
}
|
||||||
|
|
||||||
public static async Task UpdateLastLogin(string id)
|
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"))
|
if (!Fs.Exists($"{ACCOUNTS_DATA_DIR}/{id}/secret"))
|
||||||
{
|
{
|
||||||
return;
|
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()));
|
await Fs.WriteFile($"{ACCOUNTS_DATA_DIR}/{id}/lastlogin", Encoding.UTF8.GetBytes(DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -502,29 +482,6 @@ public class Utils
|
||||||
|
|
||||||
public static async Task UpdateUserDm(string id, string dmId, string isRead = "false", string timestamp = "")
|
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
|
if (await Account.Utils.NameFromId(id) == "") //if user account just got deleted
|
||||||
{
|
{
|
||||||
|
|
@ -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<string> GetUserDms(string id)
|
public static async Task<string> GetUserDms(string id)
|
||||||
{
|
{
|
||||||
string path = $"{ACCOUNTS_DATA_DIR}/{id}/dms";
|
string path = $"{ACCOUNTS_DATA_DIR}/{id}/dms";
|
||||||
|
|
@ -607,8 +547,6 @@ public class Utils
|
||||||
string content = Encoding.UTF8.GetString(await Fs.ReadFile($"{path}/{dmFiles[i]}"));
|
string content = Encoding.UTF8.GetString(await Fs.ReadFile($"{path}/{dmFiles[i]}"));
|
||||||
if (string.IsNullOrWhiteSpace(content)) continue;
|
if (string.IsNullOrWhiteSpace(content)) continue;
|
||||||
|
|
||||||
content = AppendCallStateToEntry(content, dmFiles[i]);
|
|
||||||
|
|
||||||
if (!first) dmsBuilder.Append(',');
|
if (!first) dmsBuilder.Append(',');
|
||||||
dmsBuilder.Append($"\"{dmFiles[i]}\":");
|
dmsBuilder.Append($"\"{dmFiles[i]}\":");
|
||||||
dmsBuilder.Append(content);
|
dmsBuilder.Append(content);
|
||||||
|
|
|
||||||
|
|
@ -15,13 +15,6 @@ public class Program
|
||||||
{
|
{
|
||||||
await LoadEnv();
|
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
|
// Voice ticket sweeper: issued tickets live for 60s; remove expired
|
||||||
// ones a minute later so nothing lingers in memory. Also prunes stale
|
// ones a minute later so nothing lingers in memory. Also prunes stale
|
||||||
// per-IP auth rate-limit counters (they are written pre-HMAC-check,
|
// per-IP auth rate-limit counters (they are written pre-HMAC-check,
|
||||||
|
|
|
||||||
|
|
@ -23,24 +23,6 @@ public class Groups
|
||||||
}
|
}
|
||||||
|
|
||||||
public static async Task UpdateUserGroup(string id, string groupId, string isRead = "false", string timestamp = "")
|
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
|
if (await Account.Utils.NameFromId(id) == "") //if user account just got deleted
|
||||||
{
|
{
|
||||||
|
|
@ -58,11 +40,6 @@ public class Groups
|
||||||
groupId = $"{groupId};{Utils.Utils.DOMAIN}";
|
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}";
|
string groupPath = $"{groupDir}/{groupId}";
|
||||||
Universal3String fileGroup = new Universal3String();
|
Universal3String fileGroup = new Universal3String();
|
||||||
if (Fs.Exists(groupPath))
|
if (Fs.Exists(groupPath))
|
||||||
|
|
@ -164,7 +141,7 @@ public class Groups
|
||||||
JsonSerializer.Serialize(startingMessage, AppJsonSerializerContext.Default.Message)
|
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}";
|
return $"success:{groupId}";
|
||||||
}
|
}
|
||||||
|
|
@ -194,8 +171,6 @@ public class Groups
|
||||||
string content = Encoding.UTF8.GetString(await Fs.ReadFile($"{path}/{groupFiles[i]}"));
|
string content = Encoding.UTF8.GetString(await Fs.ReadFile($"{path}/{groupFiles[i]}"));
|
||||||
if (string.IsNullOrWhiteSpace(content)) continue;
|
if (string.IsNullOrWhiteSpace(content)) continue;
|
||||||
|
|
||||||
content = Account.Utils.AppendCallStateToEntry(content, groupFiles[i]);
|
|
||||||
|
|
||||||
if (!first) groupsBuilder.Append(',');
|
if (!first) groupsBuilder.Append(',');
|
||||||
groupsBuilder.Append($"\"{groupFiles[i]}\":");
|
groupsBuilder.Append($"\"{groupFiles[i]}\":");
|
||||||
groupsBuilder.Append(content);
|
groupsBuilder.Append(content);
|
||||||
|
|
@ -439,11 +414,6 @@ public class Groups
|
||||||
await userLock2.WaitAsync();
|
await userLock2.WaitAsync();
|
||||||
try
|
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");
|
Fs.CreateDirectory(ACCOUNTS_DATA_DIR + $"/{id2}/groupinvites/recv");
|
||||||
await Fs.WriteFile(inviteFile, inviteBytes);
|
await Fs.WriteFile(inviteFile, inviteBytes);
|
||||||
}
|
}
|
||||||
|
|
@ -521,14 +491,12 @@ public class Groups
|
||||||
string inviteFile = ACCOUNTS_DATA_DIR + $"/{id}/groupinvites/recv/{groupId};{iId}";
|
string inviteFile = ACCOUNTS_DATA_DIR + $"/{id}/groupinvites/recv/{groupId};{iId}";
|
||||||
|
|
||||||
string groupPath = GetGroupPath(groupId);
|
string groupPath = GetGroupPath(groupId);
|
||||||
|
if (!Fs.Exists(groupPath)) return "error:group.not.found";
|
||||||
|
|
||||||
SemaphoreSlim roomLock = Room.Requests.GetRoomLock(groupId);
|
SemaphoreSlim roomLock = Room.Requests.GetRoomLock(groupId);
|
||||||
await roomLock.WaitAsync();
|
await roomLock.WaitAsync();
|
||||||
try
|
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";
|
if (!Fs.Exists(inviteFile)) return "error:no.invite";
|
||||||
string inviteContent = Encoding.UTF8.GetString(await Fs.ReadFile(inviteFile));
|
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()));
|
await Fs.WriteFile($"{groupPath}/messages/last", Encoding.UTF8.GetBytes(msgId.ToString()));
|
||||||
|
|
||||||
//dispatch trzyma już userLock(id) - rdzeń bez ponownego Wejścia
|
await UpdateUserGroup(id, groupId, "true", timestamp);
|
||||||
await UpdateUserGroupLocked(id, groupId, "true", timestamp);
|
|
||||||
|
|
||||||
// Notify all members (including the new one) via WebSocket
|
// Notify all members (including the new one) via WebSocket
|
||||||
string[] currentMembers = Fs.ReadDirectory($"{groupPath}/members");
|
string[] currentMembers = Fs.ReadDirectory($"{groupPath}/members");
|
||||||
|
|
@ -1009,8 +976,13 @@ public class Groups
|
||||||
{
|
{
|
||||||
if (!IsSafeGroupId(groupId)) return "error:group.not.found";
|
if (!IsSafeGroupId(groupId)) return "error:group.not.found";
|
||||||
string groupPath = GetGroupPath(groupId);
|
string groupPath = GetGroupPath(groupId);
|
||||||
|
if (!Fs.Exists(groupPath)) return "error:group.not.found";
|
||||||
|
|
||||||
string ownerPath = $"{groupPath}/owner";
|
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);
|
string tId = Account.Utils.GetValidIdOrZero(targetId);
|
||||||
if (tId == "0") return "error:invalid.user";
|
if (tId == "0") return "error:invalid.user";
|
||||||
|
|
@ -1022,12 +994,6 @@ public class Groups
|
||||||
await roomLock.WaitAsync();
|
await roomLock.WaitAsync();
|
||||||
try
|
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 (!Fs.Exists(memberPath)) return "error:not.a.member";
|
||||||
if (tId == id) return "error:cannot.kick.self";
|
if (tId == id) return "error:cannot.kick.self";
|
||||||
|
|
||||||
|
|
@ -1100,8 +1066,13 @@ public class Groups
|
||||||
{
|
{
|
||||||
if (!IsSafeGroupId(groupId)) return "error:group.not.found";
|
if (!IsSafeGroupId(groupId)) return "error:group.not.found";
|
||||||
string groupPath = GetGroupPath(groupId);
|
string groupPath = GetGroupPath(groupId);
|
||||||
|
if (!Fs.Exists(groupPath)) return "error:group.not.found";
|
||||||
|
|
||||||
string ownerPath = $"{groupPath}/owner";
|
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);
|
string tId = Account.Utils.GetValidIdOrZero(targetId);
|
||||||
if (tId == "0") return "error:invalid.user";
|
if (tId == "0") return "error:invalid.user";
|
||||||
|
|
@ -1113,11 +1084,6 @@ public class Groups
|
||||||
await roomLock.WaitAsync();
|
await roomLock.WaitAsync();
|
||||||
try
|
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";
|
if (!Fs.Exists(memberPath)) return "error:not.a.member";
|
||||||
|
|
||||||
await Fs.WriteFile(ownerPath, Encoding.UTF8.GetBytes($"{tId};{tDomain}"));
|
await Fs.WriteFile(ownerPath, Encoding.UTF8.GetBytes($"{tId};{tDomain}"));
|
||||||
|
|
@ -1199,8 +1165,7 @@ public class Groups
|
||||||
{
|
{
|
||||||
if (memberId == userId)
|
if (memberId == userId)
|
||||||
{
|
{
|
||||||
//dispatch trzyma już userLock(userId) - rdzeń bez ponownego Wejścia
|
await UpdateUserGroup(memberId, groupId, "true", timestamp);
|
||||||
await UpdateUserGroupLocked(memberId, groupId, "true", timestamp);
|
|
||||||
await Utils.Utils.SendWsMessage(memberId, $"group_message:{groupId}:read");
|
await Utils.Utils.SendWsMessage(memberId, $"group_message:{groupId}:read");
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
|
|
@ -1211,7 +1176,7 @@ public class Groups
|
||||||
await userLock.WaitAsync();
|
await userLock.WaitAsync();
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await UpdateUserGroupLocked(memberId, groupId, "false", timestamp);
|
await UpdateUserGroup(memberId, groupId, "false", timestamp);
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -80,11 +80,6 @@ public class Requests
|
||||||
{
|
{
|
||||||
start = s;
|
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;
|
int count = 50;
|
||||||
if (!string.IsNullOrEmpty(countStr) && int.TryParse(countStr, out int c))
|
if (!string.IsNullOrEmpty(countStr) && int.TryParse(countStr, out int c))
|
||||||
|
|
@ -222,12 +217,6 @@ public class Requests
|
||||||
await userLock2.WaitAsync();
|
await userLock2.WaitAsync();
|
||||||
try
|
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);
|
await Fs.WriteFile(inviteFile, timestamp);
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
|
|
@ -413,7 +402,7 @@ public class Requests
|
||||||
JsonSerializer.Serialize(startingMessage, AppJsonSerializerContext.Default.Message)
|
JsonSerializer.Serialize(startingMessage, AppJsonSerializerContext.Default.Message)
|
||||||
));
|
));
|
||||||
|
|
||||||
await Account.Utils.UpdateUserDmLocked(id, dmId, "false",
|
await Account.Utils.UpdateUserDm(id, dmId, "false",
|
||||||
startingMessage.timestamp);
|
startingMessage.timestamp);
|
||||||
|
|
||||||
if (isUserLocal)
|
if (isUserLocal)
|
||||||
|
|
@ -424,7 +413,7 @@ public class Requests
|
||||||
await userLock.WaitAsync();
|
await userLock.WaitAsync();
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await Account.Utils.UpdateUserDmLocked(id2, dmId, "false",
|
await Account.Utils.UpdateUserDm(id2, dmId, "false",
|
||||||
startingMessage.timestamp);
|
startingMessage.timestamp);
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
|
|
@ -558,8 +547,7 @@ public class Requests
|
||||||
{
|
{
|
||||||
if (memberId == id)
|
if (memberId == id)
|
||||||
{
|
{
|
||||||
//dispatch trzyma już userLock(id) - rdzeń bez ponownego Wejścia
|
await Account.Utils.UpdateUserDm(memberId, dmId, "true", msg.timestamp);
|
||||||
await Account.Utils.UpdateUserDmLocked(memberId, dmId, "true", msg.timestamp);
|
|
||||||
await Utils.Utils.SendWsMessage(memberId, $"dm_message:{dmId}:read");
|
await Utils.Utils.SendWsMessage(memberId, $"dm_message:{dmId}:read");
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
|
|
@ -570,7 +558,7 @@ public class Requests
|
||||||
await userLock.WaitAsync();
|
await userLock.WaitAsync();
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await Account.Utils.UpdateUserDmLocked(memberId, dmId, "false", msg.timestamp);
|
await Account.Utils.UpdateUserDm(memberId, dmId, "false", msg.timestamp);
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
|
|
@ -596,12 +584,6 @@ public class Requests
|
||||||
if (!IsSafeMsgId(msgId)) return "error:invalid.msg.id";
|
if (!IsSafeMsgId(msgId)) return "error:invalid.msg.id";
|
||||||
if (!await IsMemberOfDm(id, dmId)) return "error:forbidden";
|
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}";
|
string msgPath = $"{ROOMS_DIR}/dms/{DOMAIN}/{dmId}/messages/{msgId}";
|
||||||
if (!Fs.Exists(msgPath)) return "error:message.not.found";
|
if (!Fs.Exists(msgPath)) return "error:message.not.found";
|
||||||
|
|
||||||
|
|
@ -632,11 +614,6 @@ public class Requests
|
||||||
{
|
{
|
||||||
Fs.DeleteDirectory(attDir);
|
Fs.DeleteDirectory(attDir);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
roomLock.Release();
|
|
||||||
}
|
|
||||||
|
|
||||||
string[] members = Fs.ReadDirectory($"{ROOMS_DIR}/dms/{DOMAIN}/{dmId}/members");
|
string[] members = Fs.ReadDirectory($"{ROOMS_DIR}/dms/{DOMAIN}/{dmId}/members");
|
||||||
foreach(string memberWD in members)
|
foreach(string memberWD in members)
|
||||||
|
|
|
||||||
|
|
@ -69,29 +69,15 @@ public class Shared
|
||||||
|
|
||||||
public static async Task<bool> IsMemberOfRoom(string id, string roomType, string roomId)
|
public static async Task<bool> 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;
|
if (!IsSafeId(roomId)) return false;
|
||||||
string memberPath = $"{GetRoomPath(roomType, roomId)}/members/{id};{DOMAIN}";
|
string memberPath = $"{GetRoomPath(roomType, roomId)}/members/{id};{DOMAIN}";
|
||||||
return Fs.Exists(memberPath);
|
return Fs.Exists(memberPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static bool IsSupportedRoomType(string roomType)
|
|
||||||
{
|
|
||||||
return roomType == "dms" || roomType == "groups";
|
|
||||||
}
|
|
||||||
|
|
||||||
public static async Task<string> GetUserRole(string id, string roomType, string roomId, string targetUserId)
|
public static async Task<string> GetUserRole(string id, string roomType, string roomId, string targetUserId)
|
||||||
{
|
{
|
||||||
if (!await IsMemberOfRoom(id, roomType, roomId)) return "error:forbidden";
|
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}";
|
string targetMemberPath = $"{GetRoomPath(roomType, roomId)}/members/{targetUserId}";
|
||||||
if (!Fs.Exists(targetMemberPath)) return "error:not.a.member";
|
if (!Fs.Exists(targetMemberPath)) return "error:not.a.member";
|
||||||
|
|
||||||
|
|
@ -155,10 +141,6 @@ public class Shared
|
||||||
{
|
{
|
||||||
start = s;
|
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;
|
int count = 50;
|
||||||
if (!string.IsNullOrEmpty(countStr) && int.TryParse(countStr, out int c))
|
if (!string.IsNullOrEmpty(countStr) && int.TryParse(countStr, out int c))
|
||||||
|
|
@ -338,10 +320,8 @@ public class Shared
|
||||||
{
|
{
|
||||||
if (memberId == id)
|
if (memberId == id)
|
||||||
{
|
{
|
||||||
//dispatch trzyma już userLock(memberId==id) - rdzenie bez
|
if (roomType == "dms") await Account.Utils.UpdateUserDm(memberId, roomId, "true", msg.timestamp);
|
||||||
//ponownego Wejścia w nie-reentrantny semaphore
|
if (roomType == "groups") await Room.Groups.UpdateUserGroup(memberId, roomId, "true", msg.timestamp);
|
||||||
if (roomType == "dms") await Account.Utils.UpdateUserDmLocked(memberId, roomId, "true", msg.timestamp);
|
|
||||||
if (roomType == "groups") await Room.Groups.UpdateUserGroupLocked(memberId, roomId, "true", msg.timestamp);
|
|
||||||
|
|
||||||
await Utils.Utils.SendWsMessage(memberId, $"{GetRoomEventPrefix(roomType)}_message:{roomId}:read");
|
await Utils.Utils.SendWsMessage(memberId, $"{GetRoomEventPrefix(roomType)}_message:{roomId}:read");
|
||||||
}
|
}
|
||||||
|
|
@ -353,8 +333,8 @@ public class Shared
|
||||||
await userLock.WaitAsync();
|
await userLock.WaitAsync();
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (roomType == "dms") await Account.Utils.UpdateUserDmLocked(memberId, roomId, "false", msg.timestamp);
|
if (roomType == "dms") await Account.Utils.UpdateUserDm(memberId, roomId, "false", msg.timestamp);
|
||||||
if (roomType == "groups") await Room.Groups.UpdateUserGroupLocked(memberId, roomId, "false", msg.timestamp);
|
if (roomType == "groups") await Room.Groups.UpdateUserGroup(memberId, roomId, "false", msg.timestamp);
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
|
|
@ -675,44 +655,6 @@ public class Shared
|
||||||
Timeout = TimeSpan.FromSeconds(2)
|
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<string> VoiceJoin(string id, string body)
|
public static async Task<string> VoiceJoin(string id, string body)
|
||||||
{
|
{
|
||||||
Universal2String serializedBody = JsonSerializer.Deserialize(
|
Universal2String serializedBody = JsonSerializer.Deserialize(
|
||||||
|
|
@ -724,43 +666,18 @@ public class Shared
|
||||||
|
|
||||||
if (!await IsMemberOfRoom(id, roomType, roomId)) return "error:forbidden";
|
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<string>());
|
var callList = ActiveCalls.GetOrAdd(roomId, _ => new List<string>());
|
||||||
lock (callList)
|
lock (callList)
|
||||||
{
|
{
|
||||||
if (!callList.Contains(id))
|
if (!callList.Contains(id))
|
||||||
{
|
{
|
||||||
callList.Add(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;
|
string callState;
|
||||||
lock (callList) { callState = JsonSerializer.Serialize(callList, AppJsonSerializerContext.Default.ListString); }
|
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 bestIp = "";
|
string bestIp = "";
|
||||||
long bestLoad = long.MaxValue;
|
long bestLoad = long.MaxValue;
|
||||||
|
|
||||||
|
|
@ -811,6 +728,15 @@ public class Shared
|
||||||
return "error:voice.not.configured"; // 48 random bytes colliding - practically impossible
|
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}";
|
return $"success:{bestIp}|{publicId}|{connKey}|{proof}";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -973,15 +899,9 @@ public class Shared
|
||||||
string roomType = serializedBody.string1;
|
string roomType = serializedBody.string1;
|
||||||
string roomId = serializedBody.string2;
|
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))
|
if (ActiveCalls.TryGetValue(roomId, out var callList))
|
||||||
{
|
{
|
||||||
bool removed = false;
|
bool removed = false;
|
||||||
bool removeEntry = false;
|
|
||||||
lock (callList)
|
lock (callList)
|
||||||
{
|
{
|
||||||
if (callList.Contains(id))
|
if (callList.Contains(id))
|
||||||
|
|
@ -989,7 +909,6 @@ public class Shared
|
||||||
callList.Remove(id);
|
callList.Remove(id);
|
||||||
removed = true;
|
removed = true;
|
||||||
}
|
}
|
||||||
if (callList.Count == 0) removeEntry = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (removed)
|
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";
|
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<string> 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<string> participants = new List<string>();
|
|
||||||
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<string> RoomSignal(string id, string body)
|
public static async Task<string> RoomSignal(string id, string body)
|
||||||
{
|
{
|
||||||
Universal3String serializedBody = JsonSerializer.Deserialize(
|
Universal3String serializedBody = JsonSerializer.Deserialize(
|
||||||
|
|
@ -1063,56 +942,13 @@ public class Shared
|
||||||
|
|
||||||
if (!await IsMemberOfRoom(id, roomType, roomId)) return "error:forbidden";
|
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");
|
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 _))
|
||||||
{
|
{
|
||||||
if (!Account.Utils.IsUserLocal(memberWD, out string _)) continue;
|
|
||||||
string memberId = Account.Utils.GetValidIdOrZero(memberWD);
|
string memberId = Account.Utils.GetValidIdOrZero(memberWD);
|
||||||
if (memberId != "0" && memberId == toId)
|
_ = Utils.Utils.SendWsMessage(memberId, $"signal:{roomId}:{signalPayload}");
|
||||||
{
|
|
||||||
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}");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return "success";
|
return "success";
|
||||||
|
|
|
||||||
|
|
@ -236,7 +236,6 @@ public class Utils
|
||||||
foreach (var kvp in ActiveCalls)
|
foreach (var kvp in ActiveCalls)
|
||||||
{
|
{
|
||||||
bool removed = false;
|
bool removed = false;
|
||||||
bool removeEntry = false;
|
|
||||||
lock (kvp.Value)
|
lock (kvp.Value)
|
||||||
{
|
{
|
||||||
if (kvp.Value.Contains(id))
|
if (kvp.Value.Contains(id))
|
||||||
|
|
@ -244,7 +243,6 @@ public class Utils
|
||||||
kvp.Value.Remove(id);
|
kvp.Value.Remove(id);
|
||||||
removed = true;
|
removed = true;
|
||||||
}
|
}
|
||||||
if (kvp.Value.Count == 0) removeEntry = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (removed)
|
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).
|
//(równoległe SendAsync na tym samym sockecie rzucają InvalidOperationException).
|
||||||
public static readonly ConcurrentDictionary<System.Net.WebSockets.WebSocket, SemaphoreSlim> WsSendLocks = new();
|
public static readonly ConcurrentDictionary<System.Net.WebSockets.WebSocket, SemaphoreSlim> 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)
|
private static SemaphoreSlim GetWsSendLock(System.Net.WebSockets.WebSocket ws)
|
||||||
{
|
{
|
||||||
return WsSendLocks.GetOrAdd(ws, _ => new SemaphoreSlim(1, 1));
|
return WsSendLocks.GetOrAdd(ws, _ => new SemaphoreSlim(1, 1));
|
||||||
|
|
@ -320,14 +303,7 @@ public class Utils
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
using var sendCts = new System.Threading.CancellationTokenSource(WsSendTimeout);
|
await ws.SendAsync(new ArraySegment<byte>(bytes), System.Net.WebSockets.WebSocketMessageType.Text, true, CancellationToken.None);
|
||||||
await ws.SendAsync(new ArraySegment<byte>(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;
|
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -90,15 +90,6 @@ public class Requests
|
||||||
}
|
}
|
||||||
|
|
||||||
currentSession = new Session(webSocket);
|
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;
|
roomClients[clientId] = currentSession;
|
||||||
|
|
||||||
_ = Task.Run(async () =>
|
_ = Task.Run(async () =>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue