LarpixServer/LarpixServer/Utils/Utils.cs
olcxja b077097a33
All checks were successful
Server Build / publish (push) Successful in 30s
Voice Build / publish (push) Successful in 24s
security
2026-08-26 00:11:06 +02:00

566 lines
No EOL
22 KiB
C#

using System.Collections.Concurrent;
using System.Text;
using System.Text.Json;
using LarpixServer.Filesystem;
using LarpixServer.Utils.Jsons;
namespace LarpixServer.Utils;
public class Utils
{
public static string SERVER_KEY = "SecretThingOMGSoHardToGuess";
public static string DOMAIN = "example.com";
public static string OWNER_CONTACT = "larpix://@admin:example.com";
public static int PORT = 8090;
public static ConcurrentBag<string> VOICE_IPS = new();
// Shared with LarpixVoice: HMAC key used to sign voice-join tickets.
public static string VOICE_KEY = "";
public sealed record VoiceTicketRecord(byte[] ConnKey, byte[] Proof, string RoomId, string UserId, long Exp);
// Outstanding (issued, not yet consumed) voice tickets: publicId -> record.
// Consumed atomically by /_larpix/voice/auth; sweeper removes expired ones.
public static ConcurrentDictionary<string, VoiceTicketRecord> VoiceTickets = new();
// Per-IP rate limit state for the internal voice auth endpoint.
// Fixed 60s window: (request count, window reset unix time).
public static ConcurrentDictionary<string, (int count, long resetAt)> VoiceAuthLimits = new();
// Rate limity per-minute (okno 60s). Wszystkie konfigurowalne przez .env.
public static ConcurrentDictionary<string, (int count, long resetAt)> AuthRateLimits = new();
public static ConcurrentDictionary<string, (int count, long resetAt)> NonceRateLimits = new();
public static ConcurrentDictionary<string, (int count, long resetAt)> KdfSaltRateLimits = new();
public static ConcurrentDictionary<string, (int count, long resetAt)> AccountInitRateLimits = new();
public static int AUTH_RATE_LIMIT = 10; // próby logowania /auth + /ws na konto+IP
public static int NONCE_RATE_LIMIT = 240; // pobrania nonce per konto
public static int KDFSALT_RATE_LIMIT = 60; // pobrania soli per IP
public static int ACCOUNT_INIT_RATE_LIMIT = 10; // inicjalizacje rejestracji (KEM keygen) per IP
// Zwraca true jeśli przekroczono limit. Okno stałe 60s.
public static bool RateLimited(ConcurrentDictionary<string, (int count, long resetAt)> store, string key, int limit)
{
if (limit <= 0) return false; // <=0 wyłącza limit
long nowSec = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
var window = store.AddOrUpdate(key,
(1, nowSec + 60),
(_, old) => old.resetAt <= nowSec ? (1, nowSec + 60) : (old.count + 1, old.resetAt));
return window.count > limit;
}
public static string DATA_DIR = "./data";
public static string ROOMS_DIR = "./data/rooms";
public static string ACCOUNTS_DIR = "./data/accounts";
public static string ACCOUNTS_DATA_DIR = "./data/accounts/data";
public static string ACCOUNTS_NAME_DIR = "./data/accounts/name";
public static string ACCOUNTS_FREEID_DIR = "./data/accounts/freeid";
public static int CACHE_ENTRY_SIZE = 10240;
public static int FILE_CACHE_SIZE = 800000;
public static int EXIST_CACHE_SIZE = 1000000;
public static int DIR_CACHE_SIZE = 800000;
public static int LOCK_SIZE = 1000000;
public static int MAX_BODY_SIZE = 20971520; //20mb //2621440; //2.5mb
public static int ACTION_RATE_LIMIT = 120;
// All rate limits below are requests per minute (fixed 60s window).
public static int VOICE_AUTH_RATE_LIMIT = 3600;
public static int PRIVATE_STORAGE_LIMIT = 5242880;
public static int PUBLIC_STORAGE_LIMIT = 10485760;
public static int ATTACHMENT_SIZE_LIMIT = 52428800; //50mb
public static int MAX_MESSAGE_SIZE = 4000;
public static int MAX_REACTION_SIZE = 100;
public static Dictionary<string, string> mimeTypes = new()
{
["unknown"] = "application/octet-stream",
["html"] = "text/html",
["htm"] = "text/html",
["css"] = "text/css",
["js"] = "application/javascript",
["mjs"] = "application/javascript",
["json"] = "application/json",
["xml"] = "application/xml",
["txt"] = "text/plain",
["csv"] = "text/csv",
["md"] = "text/markdown",
["png"] = "image/png",
["jpg"] = "image/jpeg",
["jpeg"] = "image/jpeg",
["gif"] = "image/gif",
["webp"] = "image/webp",
["svg"] = "image/svg+xml",
["ico"] = "image/x-icon",
["bmp"] = "image/bmp",
["tiff"] = "image/tiff",
["avif"] = "image/avif",
["mp3"] = "audio/mpeg",
["wav"] = "audio/wav",
["ogg"] = "audio/ogg",
["oga"] = "audio/ogg",
["weba"] = "audio/webm",
["aac"] = "audio/aac",
["flac"] = "audio/flac",
["mp4"] = "video/mp4",
["webm"] = "video/webm",
["mkv"] = "video/x-matroska",
["mov"] = "video/quicktime",
["avi"] = "video/x-msvideo",
["wmv"] = "video/x-ms-wmv",
["pdf"] = "application/pdf",
["zip"] = "application/zip",
["gz"] = "application/gzip",
["tar"] = "application/x-tar",
["rar"] = "application/vnd.rar",
["7z"] = "application/x-7z-compressed",
["woff"] = "font/woff",
["woff2"] = "font/woff2",
["ttf"] = "font/ttf",
["otf"] = "font/otf",
["eot"] = "application/vnd.ms-fontobject",
["wasm"] = "application/wasm",
["doc"] = "application/msword",
["docx"] = "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
["xls"] = "application/vnd.ms-excel",
["xlsx"] = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
["ppt"] = "application/vnd.ms-powerpoint",
["pptx"] = "application/vnd.openxmlformats-officedocument.presentationml.presentation"
};
public static ConcurrentDictionary<string, List<System.Net.WebSockets.WebSocket>> ActiveWebSockets = new();
public static ConcurrentDictionary<string, List<string>> ActiveCalls = new();
public static async Task WsHandler(HttpContext context, System.Net.WebSockets.WebSocket webSocket, IQueryCollection query)
{
var buffer = new byte[8192];
//odbierz PIERWSZĄ pełną ramkę (moze byc pofragmentowana)
int totalRead = 0;
System.Net.WebSockets.WebSocketReceiveResult result;
while (true)
{
if (totalRead >= buffer.Length)
{
await webSocket.CloseAsync(System.Net.WebSockets.WebSocketCloseStatus.MessageTooBig, "Auth too large", CancellationToken.None);
return;
}
result = await webSocket.ReceiveAsync(new ArraySegment<byte>(buffer, totalRead, buffer.Length - totalRead), CancellationToken.None);
totalRead += result.Count;
if (result.MessageType == System.Net.WebSockets.WebSocketMessageType.Close)
{
await webSocket.CloseAsync(System.Net.WebSockets.WebSocketCloseStatus.NormalClosure, string.Empty, CancellationToken.None);
return;
}
if (result.EndOfMessage) break;
}
string authMessage = Encoding.UTF8.GetString(buffer, 0, totalRead);
Universal2String authParams;
try {
authParams = JsonSerializer.Deserialize<Universal2String>(authMessage, AppJsonSerializerContext.Default.Universal2String);
} catch {
await webSocket.CloseAsync(System.Net.WebSockets.WebSocketCloseStatus.InvalidMessageType, "Invalid auth", CancellationToken.None);
return;
}
string id = Account.Utils.GetValidIdOrZero(authParams.string1);
//rate limit prób autoryzacji WS (konto+IP) - wspólny zasobnik z /auth
string clientIp = context.Connection.RemoteIpAddress?.ToString() ?? "";
if (RateLimited(AuthRateLimits, $"{id}:{clientIp}", AUTH_RATE_LIMIT))
{
await webSocket.CloseAsync(System.Net.WebSockets.WebSocketCloseStatus.PolicyViolation, "Rate limited", CancellationToken.None);
return;
}
string password = await Account.Utils.GetPassword(id);
string secret = await Account.Utils.NonceDecryptBody(id, password, authParams.string2);
string auth = await Account.Utils.Auth(id, password, secret);
if (auth != Account.Utils.LOGIN_SUCCESS)
{
await webSocket.CloseAsync(System.Net.WebSockets.WebSocketCloseStatus.PolicyViolation, "Unauthorized", CancellationToken.None);
return;
}
ActiveWebSockets.AddOrUpdate(id, new List<System.Net.WebSockets.WebSocket> { webSocket }, (k, list) => {
lock(list) {
list.Add(webSocket);
}
return list;
});
try
{
while (webSocket.State == System.Net.WebSockets.WebSocketState.Open)
{
result = await webSocket.ReceiveAsync(new ArraySegment<byte>(buffer), CancellationToken.None);
if (result.MessageType == System.Net.WebSockets.WebSocketMessageType.Close)
{
break;
}
}
}
catch { }
finally
{
if (ActiveWebSockets.TryGetValue(id, out var list))
{
lock(list) {
list.Remove(webSocket);
}
}
WsSendLocks.TryRemove(webSocket, out _);
if (webSocket.State == System.Net.WebSockets.WebSocketState.Open)
{
await webSocket.CloseAsync(System.Net.WebSockets.WebSocketCloseStatus.NormalClosure, string.Empty, CancellationToken.None);
}
// Check if user was in any call and remove them
foreach (var kvp in ActiveCalls)
{
bool removed = false;
bool removeEntry = false;
lock (kvp.Value)
{
if (kvp.Value.Contains(id))
{
kvp.Value.Remove(id);
removed = true;
}
if (kvp.Value.Count == 0) removeEntry = true;
}
if (removed)
{
string callState;
lock (kvp.Value) { callState = JsonSerializer.Serialize(kvp.Value, AppJsonSerializerContext.Default.ListString); }
// Broadcast to all room members
string[] members = Fs.ReadDirectory($"{ROOMS_DIR}/dms/{DOMAIN}/{kvp.Key}/members");
if (members.Length == 0) members = Fs.ReadDirectory($"{ROOMS_DIR}/groups/{DOMAIN}/{kvp.Key}/members");
foreach (string memberWD in members)
{
if (Account.Utils.IsUserLocal(memberWD, out string _))
{
string memberId = Account.Utils.GetValidIdOrZero(memberWD);
_ = SendWsMessage(memberId, $"call_update:{kvp.Key}:{callState}");
}
}
}
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 _);
}
}
}
}
//Jeden outstanding SendAsync na WebSocket - semafory per-socket serializują wysyłkę
//(równoległe SendAsync na tym samym sockecie rzucają InvalidOperationException).
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)
{
return WsSendLocks.GetOrAdd(ws, _ => new SemaphoreSlim(1, 1));
}
public static async Task SendWsMessage(string id, string message)
{
if (!ActiveWebSockets.TryGetValue(id, out var list))
{
return;
}
byte[] bytes;
System.Net.WebSockets.WebSocket[] snapshot;
lock (list)
{
if (list.Count == 0) return;
bytes = Encoding.UTF8.GetBytes(message);
snapshot = list.ToArray();
}
foreach (var ws in snapshot)
{
bool remove = false;
var sendLock = GetWsSendLock(ws);
await sendLock.WaitAsync();
try
{
if (ws.State == System.Net.WebSockets.WebSocketState.Open)
{
try
{
using var sendCts = new System.Threading.CancellationTokenSource(WsSendTimeout);
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
{
remove = true;
}
}
else
{
remove = true;
}
}
finally
{
sendLock.Release();
}
if (remove)
{
lock (list)
{
list.Remove(ws);
}
WsSendLocks.TryRemove(ws, out _);
}
}
}
public static async Task<string> LoadBody(StreamReader reader)
{
StringBuilder bodyBuilder = new();
char[] buffer = new char[8192];
int read;
int totalRead = 0;
while ((read = await reader.ReadAsync(buffer, 0, buffer.Length)) > 0)
{
totalRead += read;
if (totalRead > MAX_BODY_SIZE)
throw new Exception($"error:body.too.large:{MAX_BODY_SIZE / 1024 / 1024}");
bodyBuilder.Append(buffer, 0, read);
}
return bodyBuilder.ToString();
}
public static async Task<byte[]> LoadBodyBytes(Stream bodyStream, int maxBodySize)
{
using MemoryStream ms = new MemoryStream();
byte[] buffer = new byte[8192];
int read;
int totalRead = 0;
while ((read = await bodyStream.ReadAsync(buffer, 0, buffer.Length)) > 0)
{
totalRead += read;
if (totalRead > maxBodySize)
throw new Exception($"error:body.too.large:{maxBodySize / 1024 / 1024}");
ms.Write(buffer, 0, read);
}
return ms.ToArray();
}
public static long GetDirectorySize(string directoryPath)
{
if (!Directory.Exists(directoryPath)) return 0;
long size = 0;
DirectoryInfo d = new DirectoryInfo(directoryPath);
foreach (FileInfo fi in d.GetFiles("*", SearchOption.AllDirectories))
{
size += fi.Length;
}
return size;
}
public static async Task LoadEnv()
{
if (File.Exists(".env"))
{
foreach (var line in File.ReadAllLines(".env"))
{
var trimmed = line.Trim();
if (string.IsNullOrWhiteSpace(trimmed) || trimmed.StartsWith("#")) continue;
var parts = trimmed.Split('=', 2, StringSplitOptions.None);
if (parts.Length == 2)
{
var key = parts[0].Trim();
var val = parts[1];
int commentIndex = val.IndexOf('#');
if (commentIndex >= 0)
{
val = val.Substring(0, commentIndex);
}
val = val.Trim();
Environment.SetEnvironmentVariable(key, val);
}
}
}
string? port = Environment.GetEnvironmentVariable("PORT");
if (port != null)
{
PORT = int.Parse(port);
}
string? actionRateLimitStr = Environment.GetEnvironmentVariable("ACTION_RATE_LIMIT");
if (actionRateLimitStr != null && int.TryParse(actionRateLimitStr, out int parsedLimit))
{
ACTION_RATE_LIMIT = parsedLimit;
}
string? voiceAuthRateLimitStr = Environment.GetEnvironmentVariable("VOICE_AUTH_RATE_LIMIT");
if (voiceAuthRateLimitStr != null && int.TryParse(voiceAuthRateLimitStr, out int parsedVoiceAuthLimit))
{
VOICE_AUTH_RATE_LIMIT = parsedVoiceAuthLimit;
}
AUTH_RATE_LIMIT = int.TryParse(Environment.GetEnvironmentVariable("AUTH_RATE_LIMIT"), out int parsedAuthLimit) ? parsedAuthLimit : AUTH_RATE_LIMIT;
NONCE_RATE_LIMIT = int.TryParse(Environment.GetEnvironmentVariable("NONCE_RATE_LIMIT"), out int parsedNonceLimit) ? parsedNonceLimit : NONCE_RATE_LIMIT;
KDFSALT_RATE_LIMIT = int.TryParse(Environment.GetEnvironmentVariable("KDFSALT_RATE_LIMIT"), out int parsedKdfSaltLimit) ? parsedKdfSaltLimit : KDFSALT_RATE_LIMIT;
ACCOUNT_INIT_RATE_LIMIT = int.TryParse(Environment.GetEnvironmentVariable("ACCOUNT_INIT_RATE_LIMIT"), out int parsedAccountInitLimit) ? parsedAccountInitLimit : ACCOUNT_INIT_RATE_LIMIT;
VOICE_IPS.Clear();
port = Environment.GetEnvironmentVariable("VOICE_IPS");
if (port != null)
{
foreach (var address in port.Split(","))
{
VOICE_IPS.Add(address);
}
}
else
{
port = Environment.GetEnvironmentVariable("PORT_VOICE");
if (port != null)
{
VOICE_IPS.Add($"127.0.0.1:{port}");
}
}
DOMAIN = Environment.GetEnvironmentVariable("DOMAIN") ?? DOMAIN;
SERVER_KEY = Environment.GetEnvironmentVariable("SERVER_KEY") ?? SERVER_KEY;
VOICE_KEY = Environment.GetEnvironmentVariable("VOICE_KEY") ?? VOICE_KEY;
DATA_DIR = Environment.GetEnvironmentVariable("DATA_DIR") ?? DATA_DIR;
OWNER_CONTACT = Environment.GetEnvironmentVariable("OWNER_CONTACT") ?? OWNER_CONTACT;
CACHE_ENTRY_SIZE = int.TryParse(Environment.GetEnvironmentVariable("CACHE_ENTRY_SIZE"), out var res) ? res : CACHE_ENTRY_SIZE;
FILE_CACHE_SIZE = int.TryParse(Environment.GetEnvironmentVariable("FILE_CACHE_SIZE"), out res) ? res : FILE_CACHE_SIZE;
EXIST_CACHE_SIZE = int.TryParse(Environment.GetEnvironmentVariable("EXIST_CACHE_SIZE"), out res) ? res : EXIST_CACHE_SIZE;
DIR_CACHE_SIZE = int.TryParse(Environment.GetEnvironmentVariable("DIR_CACHE_SIZE"), out res) ? res : DIR_CACHE_SIZE;
LOCK_SIZE = int.TryParse(Environment.GetEnvironmentVariable("LOCK_SIZE"), out res) ? res : LOCK_SIZE;
PRIVATE_STORAGE_LIMIT = int.TryParse(Environment.GetEnvironmentVariable("PRIVATE_STORAGE_LIMIT"), out res) ? res : PRIVATE_STORAGE_LIMIT;
PUBLIC_STORAGE_LIMIT = int.TryParse(Environment.GetEnvironmentVariable("PUBLIC_STORAGE_LIMIT"), out res) ? res : PUBLIC_STORAGE_LIMIT;
MAX_BODY_SIZE = int.TryParse(Environment.GetEnvironmentVariable("MAX_BODY_SIZE"), out res) ? res : MAX_BODY_SIZE;
ATTACHMENT_SIZE_LIMIT = int.TryParse(Environment.GetEnvironmentVariable("ATTACHMENT_SIZE_LIMIT"), out res) ? res : ATTACHMENT_SIZE_LIMIT;
MAX_MESSAGE_SIZE = int.TryParse(Environment.GetEnvironmentVariable("MAX_MESSAGE_SIZE"), out res) ? res : MAX_MESSAGE_SIZE;
MAX_REACTION_SIZE = int.TryParse(Environment.GetEnvironmentVariable("MAX_REACTION_SIZE"), out res) ? res : MAX_REACTION_SIZE;
ROOMS_DIR = DATA_DIR + "/rooms";
ACCOUNTS_DIR = DATA_DIR + "/accounts";
ACCOUNTS_NAME_DIR = ACCOUNTS_DIR + "/name";
ACCOUNTS_DATA_DIR = ACCOUNTS_DIR + "/data";
ACCOUNTS_FREEID_DIR = ACCOUNTS_DIR + "/freeid";
if (!Fs.Exists(DATA_DIR))
{
Fs.CreateDirectory(DATA_DIR);
}
if (!Fs.Exists(ROOMS_DIR))
{
Fs.CreateDirectory(ROOMS_DIR);
}
if (!Fs.Exists(ROOMS_DIR + "/dms"))
Fs.CreateDirectory(ROOMS_DIR + "/dms");
if (!Fs.Exists(ROOMS_DIR + "/dms_staging"))
Fs.CreateDirectory(ROOMS_DIR + "/dms_staging");
if (!Fs.Exists(ROOMS_DIR + "/groups"))
{
Fs.CreateDirectory(ROOMS_DIR + "/groups");
}
if (!Fs.Exists(ROOMS_DIR + "/spaces"))
{
Fs.CreateDirectory(ROOMS_DIR + "/spaces");
}
if (!Fs.Exists(ROOMS_DIR + "/freegroups"))
{
Fs.CreateDirectory(ROOMS_DIR + "/freegroups");
}
if (!Fs.Exists(ROOMS_DIR + "/freespaces"))
{
Fs.CreateDirectory(ROOMS_DIR + "/freespaces");
}
if (!Fs.Exists(ROOMS_DIR + "/lastspace"))
{
await Fs.WriteFile(ROOMS_DIR + "/lastspace", Encoding.UTF8.GetBytes("0"));
}
if (!Fs.Exists(ACCOUNTS_DIR))
{
Fs.CreateDirectory(ACCOUNTS_DIR);
}
if (!Fs.Exists(ACCOUNTS_NAME_DIR))
{
Fs.CreateDirectory(ACCOUNTS_NAME_DIR);
}
if (!Fs.Exists(ACCOUNTS_DATA_DIR))
{
Fs.CreateDirectory(ACCOUNTS_DATA_DIR);
}
if (!Fs.Exists(ACCOUNTS_FREEID_DIR))
{
Fs.CreateDirectory(ACCOUNTS_FREEID_DIR);
}
if (!Fs.Exists(ACCOUNTS_DIR + "/last"))
{
await Fs.WriteFile(ACCOUNTS_DIR + "/last", Encoding.UTF8.GetBytes("0"));
}
if (!Fs.Exists($"{ACCOUNTS_DIR}/registration"))
{
await Fs.WriteFile($"{ACCOUNTS_DIR}/registration", Encoding.UTF8.GetBytes("first;"));
}
}
}