commit 0ac6ff919660cbd5dbcac0db684a2d3f0284042e Author: olcxja Date: Fri Apr 24 07:38:15 2026 +0200 First commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ca5e31a --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +.idea/ +bin/ +obj/ +Build/ +projectSettingsUpdater.xml +indexLayout.xml +discord.xml +encodings.xml +*.DotSettings.user \ No newline at end of file diff --git a/Larpix.sln b/Larpix.sln new file mode 100644 index 0000000..f450d99 --- /dev/null +++ b/Larpix.sln @@ -0,0 +1,22 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LarpixServer", "LarpixServer\LarpixServer.csproj", "{EB27B476-5689-4CF5-9ED2-5A006C4D3CFB}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LarpixVoice", "LarpixVoice\LarpixVoice.csproj", "{CC8E6434-17CB-4743-8D59-E85531114C1E}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {EB27B476-5689-4CF5-9ED2-5A006C4D3CFB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {EB27B476-5689-4CF5-9ED2-5A006C4D3CFB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {EB27B476-5689-4CF5-9ED2-5A006C4D3CFB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {EB27B476-5689-4CF5-9ED2-5A006C4D3CFB}.Release|Any CPU.Build.0 = Release|Any CPU + {CC8E6434-17CB-4743-8D59-E85531114C1E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CC8E6434-17CB-4743-8D59-E85531114C1E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CC8E6434-17CB-4743-8D59-E85531114C1E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CC8E6434-17CB-4743-8D59-E85531114C1E}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/LarpixServer/Account/BackgroundDelete.cs b/LarpixServer/Account/BackgroundDelete.cs new file mode 100644 index 0000000..e30026b --- /dev/null +++ b/LarpixServer/Account/BackgroundDelete.cs @@ -0,0 +1,71 @@ +using System.Text; +using LarpixServer.Filesystem; + +namespace LarpixServer.Account; + +public class BackgroundDelete : BackgroundService +{ + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + while (!stoppingToken.IsCancellationRequested) + { + Console.WriteLine("Running Background Delete"); + await ScanAndDelete(stoppingToken); + + await Task.Delay(TimeSpan.FromDays(0.5), stoppingToken); + } + Console.WriteLine("Stopped Background Delete"); + } + + private async Task ScanAndDelete(CancellationToken stoppingToken) + { + try + { + foreach (var account in Directory.EnumerateDirectories(LarpixServer.Utils.Utils.ACCOUNTS_DATA_DIR, "*", + SearchOption.TopDirectoryOnly)) + { + if (stoppingToken.IsCancellationRequested) return; + try + { + string lpath = Path.Combine(account, "lastlogin"); + if (!Fs.Exists(lpath)) + { + continue; + } + DateTimeOffset lastLogin = + DateTimeOffset.FromUnixTimeSeconds( + long.Parse(Encoding.UTF8.GetString(await Fs.ReadFile(lpath)))); + if (lastLogin < DateTimeOffset.UtcNow.AddMonths(-12)) + { + string id = Path.GetFileName(account); + SemaphoreSlim userLock = Utils.GetUserLock(id); + await userLock.WaitAsync(); + await Requests.createLock.WaitAsync(); + try + { + string username = await Account.Utils.NameFromId(id); + Fs.DeleteFile($"{LarpixServer.Utils.Utils.ACCOUNTS_NAME_DIR}/{username.ToLower()}"); + Fs.DeleteDirectory(account); + Fs.WriteFile($"{LarpixServer.Utils.Utils.ACCOUNTS_FREEID_DIR}/{id}", []); + Console.WriteLine($"Deleted {id} ({username})"); + } + finally + { + Requests.createLock.Release(); + userLock.Release(); + } + } + } + catch (Exception e) + { + Console.WriteLine(e); + } + await Task.Delay(5); + } + } + catch (Exception e) + { + Console.WriteLine(e); + } + } +} \ No newline at end of file diff --git a/LarpixServer/Account/Captcha.cs b/LarpixServer/Account/Captcha.cs new file mode 100644 index 0000000..43c467a --- /dev/null +++ b/LarpixServer/Account/Captcha.cs @@ -0,0 +1,167 @@ +using SkiaSharp; +using System; +using System.Runtime.InteropServices; + +namespace LarpixServer.Account; + +public class Captcha +{ + private const string Chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + private static readonly SKTypeface CaptchaTypeface = SKTypeface.FromFamilyName("Google Sans Code", SKFontStyle.Bold); + + public static (byte[] ImageBytes, string CaptchaText) GenerateCaptcha() + { + int width = 1120; + int height = 320; + int charCount = 8; + + Span captchaSpan = stackalloc char[charCount]; + Span charColors = stackalloc SKColor[charCount]; + + for (int i = 0; i < charCount; i++) + { + captchaSpan[i] = Chars[Random.Shared.Next(Chars.Length)]; + charColors[i] = new SKColor( + (byte)Random.Shared.Next(100, 256), + (byte)Random.Shared.Next(100, 256), + (byte)Random.Shared.Next(100, 256) + ); + } + string captchaText = new string(captchaSpan); + + var imageInfo = new SKImageInfo(width, height, SKColorType.Bgra8888, SKAlphaType.Premul); + using var sourceBitmap = new SKBitmap(imageInfo); + using var canvas = new SKCanvas(sourceBitmap); + canvas.Clear(SKColors.Transparent); + + using var dotPaint = new SKPaint { IsAntialias = true }; + float spacing = 12f; + + Span randOffsets = stackalloc float[256]; + Span randRadii = stackalloc float[256]; + Span randColors = stackalloc SKColor[256]; + + for (int i = 0; i < 256; i++) + { + randOffsets[i] = (float)(Random.Shared.NextDouble() - 0.5) * 8f; + randRadii[i] = (float)Random.Shared.NextDouble() * 3.5f + 1f; + randColors[i] = new SKColor( + (byte)Random.Shared.Next(120, 200), + (byte)Random.Shared.Next(120, 200), + (byte)Random.Shared.Next(120, 200) + ); + } + + int noiseIdx = 0; + for (float y = 0; y < height; y += spacing) + { + for (float x = 0; x < width; x += spacing) + { + noiseIdx = (noiseIdx + 1) & 255; + dotPaint.Color = randColors[noiseIdx]; + canvas.DrawCircle(x + randOffsets[noiseIdx], y + randOffsets[255 - noiseIdx], randRadii[noiseIdx], dotPaint); + } + } + + using var textPaint = new SKPaint + { + IsAntialias = true, + TextSize = 158, + Typeface = CaptchaTypeface + }; + + float cellWidth = width / (float)charCount; + + for (int i = 0; i < charCount; i++) + { + string charStr = captchaText[i].ToString(); + SKRect bounds = new SKRect(); + textPaint.MeasureText(charStr, ref bounds); + textPaint.Color = charColors[i]; + + canvas.Save(); + + float maxRadius = (float)Math.Sqrt((bounds.Width * bounds.Width) + (bounds.Height * bounds.Height)) / 2f; + float margin = 5f; + + float minX = (cellWidth * i) + maxRadius + margin; + float maxX = (cellWidth * (i + 1)) - maxRadius - margin; + float xCenter = (minX >= maxX) ? (cellWidth * i) + (cellWidth / 2f) : minX + (float)Random.Shared.NextDouble() * (maxX - minX); + + float minY = maxRadius + margin; + float maxY = height - maxRadius - margin; + float yCenter = (minY >= maxY) ? height / 2f : minY + (float)Random.Shared.NextDouble() * (maxY - minY); + + canvas.Translate(xCenter, yCenter); + canvas.RotateDegrees(Random.Shared.Next(-45, 46)); + canvas.DrawText(charStr, -bounds.MidX, -bounds.MidY, textPaint); + canvas.Restore(); + } + + using var linePaint = new SKPaint { StrokeWidth = 8, IsAntialias = true }; + + for (int i = 0; i < charCount; i++) + { + linePaint.Color = charColors[i]; + float cellStartX = cellWidth * i; + float x1 = cellStartX + (float)Random.Shared.NextDouble() * cellWidth; + float x2 = cellStartX + (float)Random.Shared.NextDouble() * cellWidth; + canvas.DrawLine(x1, 0, x2, height, linePaint); + } + + float bandHeight = height / 4f; + for (int i = 0; i < 4; i++) + { + linePaint.Color = charColors[Random.Shared.Next(charColors.Length)]; + float bandStartY = bandHeight * i; + float y1 = bandStartY + (float)Random.Shared.NextDouble() * bandHeight; + float y2 = bandStartY + (float)Random.Shared.NextDouble() * bandHeight; + canvas.DrawLine(0, y1, width, y2, linePaint); + } + + canvas.Flush(); + + float amplitude = 10.5f; + float frequency = 0.03f; + + Span sinY = stackalloc int[height]; + for (int y = 0; y < height; y++) + sinY[y] = (int)(Math.Sin(y * frequency) * amplitude); + + Span cosX = stackalloc int[width]; + for (int x = 0; x < width; x++) + cosX[x] = (int)(Math.Cos(x * frequency) * amplitude); + + using var warpedBitmap = new SKBitmap(imageInfo); + + Span srcPixels = MemoryMarshal.Cast(sourceBitmap.GetPixelSpan()); + Span dstPixels = MemoryMarshal.Cast(warpedBitmap.GetPixelSpan()); + + for (int y = 0; y < height; y++) + { + int offsetX = sinY[y]; + int yOffsetBase = y * width; + + for (int x = 0; x < width; x++) + { + int srcX = x + offsetX; + int srcY = y + cosX[x]; + + if ((uint)srcX < (uint)width && (uint)srcY < (uint)height) + { + dstPixels[yOffsetBase + x] = srcPixels[srcY * width + srcX]; + } + else + { + dstPixels[yOffsetBase + x] = 0; + } + } + } + + using var warpedImage = SKImage.FromBitmap(warpedBitmap); + + using var data = warpedImage.Encode(SKEncodedImageFormat.Webp, 90); + + return (data.ToArray(), captchaText); + } +} \ No newline at end of file diff --git a/LarpixServer/Account/Requests.cs b/LarpixServer/Account/Requests.cs new file mode 100644 index 0000000..a68f3de --- /dev/null +++ b/LarpixServer/Account/Requests.cs @@ -0,0 +1,591 @@ +using System.Collections.Concurrent; +using System.Numerics; +using System.Text; +using System.Text.Json; +using LarpixServer.Filesystem; +using LarpixServer.Utils; +using LarpixServer.Utils.Jsons; +using static LarpixServer.Utils.Utils; + +namespace LarpixServer.Account; + +public class Requests +{ + public static ConcurrentDictionary createHolder = new(); + public static ConcurrentDictionary nonceHolder = new(); + + public static SemaphoreSlim createLock = new SemaphoreSlim(1, 1); + + + public static async Task Delete(HttpContext context, Func next, IQueryCollection query, + StreamReader bodyReader) + { + if (!query.TryGetValue("u", out var username)) + { + return; + } + + if (!context.Request.Headers.TryGetValue("secret", out var secret)) + { + return; + } + + string body = await LoadBody(bodyReader); + string id = await Utils.IdFromName(username); + string password = await Utils.GetPassword(id); + body = await Utils.NonceDecryptBody(username, password, body); + string auth = await Utils.Auth(id, password, body); + if (auth != Utils.LOGIN_SUCCESS) + { + await context.Response.WriteAsync(auth); + return; + } + + SemaphoreSlim userLock = Utils.GetUserLock(id); + await userLock.WaitAsync(); + try + { + await Fs.WriteFile($"{ACCOUNTS_DATA_DIR}/{id}/lastlogin", + Encoding.UTF8.GetBytes(DateTimeOffset.UtcNow.AddMonths(-11).ToUnixTimeSeconds().ToString())); + } + finally + { + userLock.Release(); + } + + await context.Response.WriteAsync( + "ok|Your account will be deleted in one month|Please make sure you are logged out on all devices. Logging back into your account during this period will stop the deletion process"); + } + + public static async Task Create(HttpContext context, Func next, IQueryCollection query, + StreamReader bodyReader) + { + if (!query.TryGetValue("step", out var step)) + { + return; + } + + await createLock.WaitAsync(); + try + { + switch (step) + { + case "init": + foreach (var kvp in createHolder) // czyszczenie nieaktywnych od 2 minut requestow + { + if (kvp.Value.date < DateTimeOffset.UtcNow.AddMinutes(-2)) + { + createHolder.TryRemove(kvp.Key, out _); + } + } + + context.Response.ContentType = mimeTypes["json"]; + + (BigInteger p, BigInteger g, BigInteger pubServer, BigInteger secretServer) serverInfo = + Encryption.Encryption.Init(); + KeyExchangePayload payload = new KeyExchangePayload(); + payload.p = serverInfo.p.ToString(); + payload.g = serverInfo.g.ToString(); + payload.pubServer = serverInfo.pubServer.ToString(); + payload.idKey = DateTimeOffset.UtcNow.ToUnixTimeSeconds() + "\n"; + + while (createHolder.ContainsKey(payload.idKey)) + { + payload.idKey += Random.Shared.Next(0, 10).ToString(); + } + + CreateHolder dataHolder = new(); + dataHolder.date = DateTimeOffset.UtcNow; + dataHolder.serverInfo = serverInfo; + + createHolder.TryAdd(payload.idKey, dataHolder); + + var serializedPayload = JsonSerializer.Serialize( + payload, + AppJsonSerializerContext.Default.KeyExchangePayload + ); + + await context.Response.WriteAsync(serializedPayload); + return; + case "register": + string body = await LoadBody(bodyReader); + + KeyExchangePayloadClient serializedBody = JsonSerializer.Deserialize( + body, + AppJsonSerializerContext.Default.KeyExchangePayloadClient + ); + if (!createHolder.TryGetValue(serializedBody.idKey, out CreateHolder entry)) + { + await context.Response.WriteAsync("Account request expired"); + return; + } + + entry.date = DateTimeOffset.UtcNow; + entry.pubClient = BigInteger.Parse(serializedBody.pubClient); + byte[] sharedKey = Encryption.Encryption.CalcCommunicationKey(entry.pubClient, + entry.serverInfo.secretServer, entry.serverInfo.p); + + + entry.name = Encryption.Encryption.Decrypt(serializedBody.username, sharedKey); + entry.pass = Encryption.Encryption.Decrypt(serializedBody.password, sharedKey); + + if (!Utils.IsValidUsername(entry.name, out string message)) + { + await context.Response.WriteAsync("Username: " + message); + return; + } + + if (!Utils.IsValidPassword(entry.pass, out message)) + { + await context.Response.WriteAsync("Password: " + message); + return; + } + + (byte[] ImageBytes, string CaptchaText) captchaResult = Captcha.GenerateCaptcha(); + entry.captcha = captchaResult.CaptchaText; + context.Response.ContentType = mimeTypes["webp"]; + context.Response.ContentLength = captchaResult.ImageBytes.Length; + + await context.Response.Body.WriteAsync(captchaResult.ImageBytes, 0, + captchaResult.ImageBytes.Length); + return; + case "finish": + body = await LoadBody(bodyReader); + + CaptchaPayloadClient serialized = JsonSerializer.Deserialize( + body, + AppJsonSerializerContext.Default.CaptchaPayloadClient + ); + if (!createHolder.TryGetValue(serialized.idKey, out entry)) + { + await context.Response.WriteAsync("Account request expired"); + return; + } + + if (entry.captcha.ToLower() != serialized.captcha.ToLower()) + { + createHolder.TryRemove(serialized.idKey, out _); + await context.Response.WriteAsync("Incorrect captcha. Please try again"); + return; + } + + string lowerName = entry.name.ToLowerInvariant(); + + if (Fs.Exists($"{ACCOUNTS_NAME_DIR}/{lowerName}")) + { + await context.Response.WriteAsync("This username is already taken"); + return; + } + + string registrationString = + Encoding.UTF8.GetString(await Fs.ReadFile($"{ACCOUNTS_DIR}/registration")); + if (registrationString.StartsWith("0;")) + { + await context.Response.WriteAsync("Account creation is currently disabled. Try again later"); + return; + } + + if (registrationString.StartsWith("code;")) + { + bool valid = false; + string[] regArray = registrationString.Split(';'); + foreach (string regEntryString in regArray) + { + string[] regEntry = regEntryString.Split(":"); + if (regEntry[0] == "|" + serialized.regKey) + { + if (regEntry[1] != "infinite") + { + int left = int.Parse(regEntry[1]) - 1; + if (left < 1) + { + await Fs.WriteFile($"{ACCOUNTS_DIR}/registration", + Encoding.UTF8.GetBytes( + registrationString.Replace(regEntryString + ";", ""))); + } + } + + valid = true; + break; + } + } + + if (!valid) + { + await context.Response.WriteAsync( + "Account creation is currently disabled. Try again later"); + return; + } + } + else if (registrationString.StartsWith("first;")) + { + await Fs.WriteFile($"{ACCOUNTS_DIR}/registration", Encoding.UTF8.GetBytes("0;")); + } + + + ulong id = ulong.Parse(await Fs.ReadFile($"{ACCOUNTS_DIR}/last")); + id++; + var freeid = Path.GetFileName(Directory.EnumerateFiles(ACCOUNTS_FREEID_DIR).FirstOrDefault()); + if (freeid != null) + { + id = ulong.Parse(freeid); + Fs.DeleteFile($"{ACCOUNTS_FREEID_DIR}/{freeid}"); + } + else + { + if (id == ulong.MaxValue) + { + await context.Response.WriteAsync( + "Server is full, cannot create new accounts. Try again later."); + return; + } + + await Fs.WriteFile($"{ACCOUNTS_DIR}/last", Encoding.UTF8.GetBytes(id.ToString())); + } + + SemaphoreSlim userLock = Utils.GetUserLock(id.ToString()); + await userLock.WaitAsync(); + try + { + await Fs.WriteFile($"{ACCOUNTS_NAME_DIR}/{lowerName}", Encoding.UTF8.GetBytes(id.ToString())); + await Fs.WriteFile($"{ACCOUNTS_DATA_DIR}/{id.ToString()}/username", + Encoding.UTF8.GetBytes(entry.name)); + await Fs.WriteFile($"{ACCOUNTS_DATA_DIR}/{id.ToString()}/secret", + Encoding.UTF8.GetBytes(entry.pass)); + createHolder.TryRemove(serialized.idKey, out _); + await Utils.UpdateLastLogin(id.ToString()); + } + finally + { + userLock.Release(); + } + + await context.Response.WriteAsync("Account created"); + return; + } + + await next(); + } + finally + { + createLock.Release(); + } + } + + public static async Task Auth(HttpContext context, Func next, IQueryCollection query, StreamReader bodyReader) + { + if (!query.TryGetValue("u", out var username)) + { + return; + } + + if (!context.Request.Headers.TryGetValue("secret", out var secret)) + { + return; + } + + string id = await Utils.IdFromName(username); + string password = await Utils.GetPassword(id); + secret = await Utils.NonceDecryptBody(username, password, secret); + string auth = await Utils.Auth(id, password, secret); + + await context.Response.WriteAsync(auth); + return; + } + + public static async Task CorrectedName(HttpContext context, Func next, IQueryCollection query) + { + if (!query.TryGetValue("u", out var username)) + { + return; + } + + if (!Utils.IsUserLocal(username, out string domain)) //federation :( + { + return; + } + username = username.ToString().Split(":")[0]; + + await context.Response.WriteAsync(await Utils.NameFromId(await Utils.IdFromName(username))); + return; + + } + + public static async Task NextNonce(HttpContext context, Func next, IQueryCollection query) + { + if (!query.TryGetValue("u", out var username)) + { + return; + } + + string plainPass = await Utils.GetPassword(await Utils.IdFromName(username)); + foreach (var kvp in nonceHolder) //clearowanie nieuzytych nonce + { + /* + if (kvp.Key == username) + { + nonceHolder.TryRemove(kvp.Key, out _); + } + */ //tak teraz sobie mysle moze jednak nie usuwac nonce?? bo co jak jakis cep beedzie ciagle komus spamic i + //bedzie mial nizszy ping i gosc nie bedzie mogl zadnego req wyslac, a nawet jak ktos bez secret to odczyta + //to nic z tym nie zrobi + if (kvp.Value.Item2 < DateTimeOffset.UtcNow.AddMinutes(-2)) + { + nonceHolder.TryRemove(kvp.Key, out _); + } + else if (kvp.Key == username) + { + if (nonceHolder.TryGetValue(kvp.Key, out (string, DateTimeOffset) cachedNonce)) + { + await context.Response.WriteAsync(Encryption.Encryption.EncryptString(cachedNonce.Item1, + plainPass)); + + return; + } + } + } + + string nonce = Encryption.Encryption.GetRandomString(64); + nonceHolder.TryAdd(username, (nonce, DateTimeOffset.UtcNow)); + await context.Response.WriteAsync(Encryption.Encryption.EncryptString(nonce, + plainPass)); + return; + } + + public static async Task ChangePassword(HttpContext context, Func next, IQueryCollection query, + StreamReader bodyReader) + { + if (!query.TryGetValue("u", out var username)) + { + return; + } + + if (!context.Request.Headers.TryGetValue("secret", out var secret)) + { + return; + } + + string id = await Utils.IdFromName(username); + string body = await LoadBody(bodyReader); + string password = await Utils.GetPassword(id); + string newPass = await Utils.NonceDecryptBody(username, password, body); + secret = await Utils.NonceDecryptBody(username, password, secret); + + string auth = await Utils.Auth(id, password, secret); + + if (auth != Utils.LOGIN_SUCCESS) + { + await context.Response.WriteAsync(auth); + return; + } + + SemaphoreSlim userLock = Utils.GetUserLock(id); + await userLock.WaitAsync(); + try + { + if (!Utils.IsValidPassword(newPass, out string message)) + { + await context.Response.WriteAsync(message); + return; + } + + await Utils.SetPassword(id, newPass); + } + finally + { + userLock.Release(); + } + + await context.Response.WriteAsync("Password changed successfully"); + return; + } + + public static async Task ChangeUsername(HttpContext context, Func next, IQueryCollection query, + StreamReader bodyReader) + { + if (!query.TryGetValue("u", out var username)) + { + return; + } + + if (!context.Request.Headers.TryGetValue("secret", out var secret)) + { + return; + } + + string id = await Utils.IdFromName(username); + string body = await LoadBody(bodyReader); + string password = await Utils.GetPassword(id); + body = await Utils.NonceDecryptBody(username, password, body); + secret = await Utils.NonceDecryptBody(username, password, secret); + + string auth = await Utils.Auth(id, secret, password); + if (auth != Utils.LOGIN_SUCCESS) + { + await context.Response.WriteAsync(auth); + return; + } + + SemaphoreSlim userLock = Utils.GetUserLock(id.ToString()); + await userLock.WaitAsync(); + try + { + await context.Response.WriteAsync(await Utils.SetUsername(id, body)); + } + finally + { + userLock.Release(); + } + + return; + } + + public static async Task UpdateUserKeys(string id, string body) + { + Universal2String serializedBody = JsonSerializer.Deserialize( + body, + AppJsonSerializerContext.Default.Universal2String + ); + //string publicKey = serializedBody.string1; + //string privateEncryptedKey = serializedBody.string2; + await Utils.UpdateUserKeys(id, body); + return "Keys updated successfully"; + } + + public static async Task GetUserPublicKey(HttpContext context, Func next, IQueryCollection query, StreamReader bodyReader) + { + if (!query.TryGetValue("u", out var username)) + { + return; + } + + if (!Utils.IsUserLocal(username, out string domain)) //federation :( + { + return; + } + username = username.ToString().Split(":")[0]; + + string id = await Utils.IdFromName(username); + + Universal2String keys = JsonSerializer.Deserialize( + await Utils.GetUserKeys(id), + AppJsonSerializerContext.Default.Universal2String + ); + await context.Response.WriteAsync(keys.string2); + return; + } + + public static async Task GetUserPublicStorageEntry(HttpContext context, Func next, IQueryCollection query, StreamReader bodyReader) + { + if (!query.TryGetValue("u", out var username)) + { + return; + } + if (!query.TryGetValue("entry", out var entry)) + { + return; + } + if (!Utils.IsUserLocal(username, out string domain)) //federation :( + { + return; + } + username = username.ToString().Split(":")[0]; + + string id = await Utils.IdFromName(username); + + byte[] entryByte = await Utils.GetUserPublicStorageEntry(id, entry); + + context.Response.ContentType = mimeTypes["unknown"]; + context.Response.ContentLength = entryByte.Length; + + await context.Response.Body.WriteAsync(entryByte, 0, + entryByte.Length); + return; + } + + public static async Task EncryptedRequest(HttpContext context, Func next, IQueryCollection query, StreamReader bodyReader) + { + if (!query.TryGetValue("u", out var username)) + { + return; + } + + if (!context.Request.Headers.TryGetValue("secret", out var secret)) + { + return; + } + + string id = await Utils.IdFromName(username); + string body = await LoadBody(bodyReader); + string password = await Utils.GetPassword(id); + body = await Utils.NonceDecryptBody(username, password, body); + secret = await Utils.NonceDecryptBody(username, password, secret); + + Universal2String serializedBody = JsonSerializer.Deserialize( + body, + AppJsonSerializerContext.Default.Universal2String + ); + + string auth = await Utils.Auth(id, secret, password); + + if (auth != Utils.LOGIN_SUCCESS) + { + await context.Response.WriteAsync(Encryption.Encryption.EncryptString(auth, secret)); + return; + } + + SemaphoreSlim userLock = Utils.GetUserLock(id); + await userLock.WaitAsync(); + try + { + switch (serializedBody.string1) + { + case "user/key/update": + await context.Response.WriteAsync( + Encryption.Encryption.EncryptString( + await UpdateUserKeys(id, serializedBody.string2) + , password)); + break; + case "user/key/get": + await context.Response.WriteAsync( + Encryption.Encryption.EncryptString( + await Utils.GetUserKeys(id) + , password)); + break; + case "user/dm/list": + await context.Response.WriteAsync( + Encryption.Encryption.EncryptString( + await Utils.GetUserDms(id) + , password)); + break; + case "user/dm/invite": + await context.Response.WriteAsync( + Encryption.Encryption.EncryptString( + await Room.Requests.DmInvite(id, serializedBody.string2) + , password)); + break; + case "user/dm/create": + await context.Response.WriteAsync( + Encryption.Encryption.EncryptString( + await Room.Requests.DmCreate(id, serializedBody.string2) + , password)); + break; + } + } + finally + { + userLock.Release(); + } + return; + } +} + +public class CreateHolder +{ + public DateTimeOffset date; + public string name; + public string pass; + public string captcha; + public (BigInteger p, BigInteger g, BigInteger pubServer, BigInteger secretServer) serverInfo; + public BigInteger pubClient; +} \ No newline at end of file diff --git a/LarpixServer/Account/Utils.cs b/LarpixServer/Account/Utils.cs new file mode 100644 index 0000000..7436325 --- /dev/null +++ b/LarpixServer/Account/Utils.cs @@ -0,0 +1,260 @@ +using System.Collections.Concurrent; +using System.Numerics; +using System.Text; +using System.Text.RegularExpressions; +using LarpixServer.Filesystem; + +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 = "Login successful"; + + public static ConcurrentDictionary userLocks = new(); + public static ConcurrentQueue keyQueue = new(); + + public static SemaphoreSlim GetUserLock(string id) + { + while (userLocks.Count >= LOCK_SIZE) + { + if (!keyQueue.TryPeek(out var firstKey)) break; + + var sem = userLocks[firstKey]; + if (sem.Wait(0)) + { + try + { + if (userLocks.TryRemove(firstKey, out _)) + keyQueue.TryDequeue(out _); + } + finally { sem.Release(); } + } + else + { + break; + } + } + + var semLock = userLocks.GetOrAdd(id, _ => new SemaphoreSlim(1, 1)); + keyQueue.Enqueue(id); + return semLock; + } + + public static bool IsUserLocal(string usernameWD, out string domain) + { + if (usernameWD.EndsWith(":" + DOMAIN)) + { + domain = DOMAIN; + return true; + } + domain = usernameWD.Split(':', 2)[1]; + return false; + } + + public static bool IsValidUsername(string username, out string message) + { + message = "Username must be 3-18 characters long"; + if (string.IsNullOrWhiteSpace(username)) return false; + if (username.Length is < 3 or > 18) return false; + message = "Only letters, numbers, and underscores allowed"; + return USERNAME_REGEX.IsMatch(username); + } + + public static bool IsValidPassword(string password, out string message) + { + message = "Password is 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) + { + 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 "Invalid username or password"; + } + if (password != password2) + { + return "Invalid password"; + } + + await UpdateLastLogin(id); + return LOGIN_SUCCESS; + } + + public static async Task NonceDecryptBody(string username, string password, string body) + { + if (!Requests.nonceHolder.TryGetValue(username, out (string, DateTimeOffset) nonce)) + { + return "Invalid nonce"; + } + string decBody = Encryption.Encryption.PacketDecPass(body, password, nonce.Item1); + Requests.nonceHolder.TryRemove(username, out _); + return decBody; + } + + public static async Task UpdateLastLogin(string id) + { + if (!Fs.Exists($"{ACCOUNTS_DATA_DIR}/{id}/secret")) + { + return; + } + 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 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) + { + 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 GetUserDms(string id) + { + string path = $"{ACCOUNTS_DATA_DIR}/{id}/dms"; + if (!Fs.Exists(path)) + { + return ""; + } + return Encoding.UTF8.GetString(await Fs.ReadFile(path)); + } + + 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 "Account does not exist"; + } + + if (!IsValidUsername(newName, out string message)) + { + return message; + } + string oldName = await NameFromId(id); + string lowerNewName = newName.ToLowerInvariant(); + string lowerOldName = oldName.ToLowerInvariant(); + + if (lowerNewName != lowerOldName) + { + if (Fs.Exists($"{ACCOUNTS_NAME_DIR}/{lowerNewName}")) + { + return "This username is already taken"; + } + + Fs.MoveFile($"{ACCOUNTS_NAME_DIR}/{lowerOldName}", $"{ACCOUNTS_NAME_DIR}/{lowerNewName}"); + } + await Fs.WriteFile($"{ACCOUNTS_DATA_DIR}/{id}/username", Encoding.UTF8.GetBytes(newName)); + + return "Username changed successfully"; + } +} \ No newline at end of file diff --git a/LarpixServer/Encryption/Encryption.cs b/LarpixServer/Encryption/Encryption.cs new file mode 100644 index 0000000..3d645c6 --- /dev/null +++ b/LarpixServer/Encryption/Encryption.cs @@ -0,0 +1,234 @@ +using System.Globalization; +using System.Numerics; +using System.Security.Cryptography; +using System.Text; + +namespace LarpixServer.Encryption; + +public class Encryption +{ + private static readonly string PrimeHex = + "0" + + "FFFFFFFFFFFFFFFFC90FDAA22168C234C4C6628B80DC1CD1" + + "29024E088A67CC74020BBEA63B139B22514A08798E3404DD" + + "EF9519B3CD3A431B302B0A6DF25F14374FE1356D6D51C245" + + "E485B576625E7EC6F44C42E9A637ED6B0BFF5CB6F406B7ED" + + "EE386BFB5A899FA5AE9F24117C4B1FE649286651ECE45B3D" + + "C2007CB8A163BF0598DA48361C55D39A69163FA8FD24CF5F" + + "83655D23DCA3AD961C62F356208552BB9ED529077096966D" + + "670C354E4ABC9804F1746C08CA18217C32905E462E36CE3B" + + "E39E772C180E86039B2783A2EC07A28FB5C55DF06F4C52C9" + + "DE2BCBF6955817183995497CEA956AE515D2261898FA0510" + + "15728E5A8AAAC42DAD33170D04507A33A85521ABDF1CBA64" + + "ECFB850458DBEF0A8AEA71575D060C7DB3970F85A6E1E4C7" + + "ABF5AE8CDB0933D71E8C94E04A25619DCEE3D2261AD2EE6B" + + "F12FFA06D98A0864D87602733EC86A64521F2B18177B200C" + + "BBE117577A615D6C770988C0BAD946E208E24FA074E5AB31" + + "43DB5BFCE0FD108E4B82D120A92108011A723C12A787E6D7" + + "88719A10BDBA5B2699C327186AF4E23C1A946834B6150BDA" + + "2583E9CA2AD44CE8DBBBC2DB04DE8EF92E8EFC141FBECAA6" + + "287C59474E6BC05D99B2964FA090C3A2233BA186515BE7ED" + + "1F612970CEE2D7AFB81BDD762170481CD0069127D5B05AA9" + + "93B4EA988D8FDDC186FFB7DC90A6C08F4DF435C934063199" + + "FFFFFFFFFFFFFFFF"; + + /// + /// RFC 3526 4096-bit MODP Group (ID: 16) Prime + /// + public static BigInteger Prime { get; } = BigInteger.Parse(PrimeHex, NumberStyles.AllowHexSpecifier); + + public static void Chuj() + { + (BigInteger p, BigInteger g, BigInteger pubServer, BigInteger secretServer) serverInfo = Init(); + (BigInteger pubClient, byte[] aesKey) clientInfo = CalcCommunicationKeyClient(serverInfo.p, serverInfo.g, serverInfo.pubServer); + byte[] sharedKey = CalcCommunicationKey(clientInfo.pubClient, serverInfo.secretServer, serverInfo.p); + + if (sharedKey == clientInfo.aesKey) + { + Console.WriteLine("key ok"); + } + } + + public static (BigInteger p, BigInteger g, BigInteger pubServer, BigInteger secretServer) Init() + { + BigInteger p = Prime;//GenerateRandomBigInt(4096); + BigInteger g = 2; + BigInteger secretServer = GenerateRandomBigInt(4096); + + BigInteger pubServer = BigInteger.ModPow(g, secretServer, p); + + return (p, g, pubServer, secretServer); + } + + public static (BigInteger pubClient, byte[] aesKey) CalcCommunicationKeyClient(BigInteger p, BigInteger g, BigInteger pubServer) + { + BigInteger secretClient = GenerateRandomBigInt(4096); + + BigInteger pubClient = BigInteger.ModPow(g, secretClient, p); + + BigInteger sharedSecret = BigInteger.ModPow(pubServer, secretClient, p); + + byte[] aesKey; + using (SHA256 sha256 = SHA256.Create()) + { + aesKey = sha256.ComputeHash(sharedSecret.ToByteArray(isUnsigned: true, isBigEndian: true)); + } + + return (pubClient, aesKey); + } + + public static byte[] CalcCommunicationKey(BigInteger pubClient, BigInteger secretServer, BigInteger p) + { + BigInteger sharedSecret = BigInteger.ModPow(pubClient, secretServer, p); + using (SHA256 sha256 = SHA256.Create()) + { + return sha256.ComputeHash(sharedSecret.ToByteArray(isUnsigned: true, isBigEndian: true)); + } + } + + public static string Encrypt(string plainText, byte[] key) + { + using (Aes aes = Aes.Create()) + { + aes.Key = key; + aes.GenerateIV(); + + ICryptoTransform encryptor = aes.CreateEncryptor(); + byte[] inputBytes = Encoding.UTF8.GetBytes(plainText); + byte[] encryptedBytes = encryptor.TransformFinalBlock(inputBytes, 0, inputBytes.Length); + + byte[] result = new byte[aes.IV.Length + encryptedBytes.Length]; + Buffer.BlockCopy(aes.IV, 0, result, 0, aes.IV.Length); + Buffer.BlockCopy(encryptedBytes, 0, result, aes.IV.Length, encryptedBytes.Length); + + return Convert.ToBase64String(result); + } + } + + public static string Decrypt(string cipherText, byte[] key) + { + byte[] fullCipher = Convert.FromBase64String(cipherText); + using (Aes aes = Aes.Create()) + { + aes.Key = key; + byte[] iv = new byte[16]; + byte[] cipher = new byte[fullCipher.Length - 16]; + Buffer.BlockCopy(fullCipher, 0, iv, 0, 16); + Buffer.BlockCopy(fullCipher, 16, cipher, 0, cipher.Length); + aes.IV = iv; + + ICryptoTransform decryptor = aes.CreateDecryptor(); + byte[] decryptedBytes = decryptor.TransformFinalBlock(cipher, 0, cipher.Length); + return Encoding.UTF8.GetString(decryptedBytes); + } + } + + public static BigInteger GenerateRandomBigInt(int bits) + { + byte[] data = new byte[bits / 8]; + RandomNumberGenerator.Fill(data); + return BigInteger.Abs(new BigInteger(data)); + } + + + public static string EncryptString(string plainText, string passphrase) + { + using var aes = Aes.Create(); + aes.Key = SHA256.HashData(Encoding.UTF8.GetBytes(passphrase)); + aes.GenerateIV(); + var iv = aes.IV; + + using var encryptor = aes.CreateEncryptor(aes.Key, iv); + byte[] plainBytes = Encoding.UTF8.GetBytes(plainText); + byte[] cipherBytes = encryptor.TransformFinalBlock(plainBytes, 0, plainBytes.Length); + + byte[] result = new byte[iv.Length + cipherBytes.Length]; + Buffer.BlockCopy(iv, 0, result, 0, iv.Length); + Buffer.BlockCopy(cipherBytes, 0, result, iv.Length, cipherBytes.Length); + + return Convert.ToBase64String(result); + } + + public static string DecryptString(string cipherTextCombined, string passphrase) + { + try + { + byte[] combined = Convert.FromBase64String(cipherTextCombined); + using var aes = Aes.Create(); + aes.Key = SHA256.HashData(Encoding.UTF8.GetBytes(passphrase)); + + byte[] iv = new byte[16]; + byte[] cipherBytes = new byte[combined.Length - 16]; + Buffer.BlockCopy(combined, 0, iv, 0, 16); + Buffer.BlockCopy(combined, 16, cipherBytes, 0, cipherBytes.Length); + + using var decryptor = aes.CreateDecryptor(aes.Key, iv); + byte[] decryptedBytes = decryptor.TransformFinalBlock(cipherBytes, 0, cipherBytes.Length); + + return Encoding.UTF8.GetString(decryptedBytes); + } + catch (Exception e) + { + return ""; + } + + } + + public static byte[] EncryptByte(byte[] plainBytes, string passphrase) + { + using var aes = Aes.Create(); + aes.Key = SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(passphrase)); + aes.GenerateIV(); + var iv = aes.IV; + + using var encryptor = aes.CreateEncryptor(aes.Key, iv); + byte[] cipherBytes = encryptor.TransformFinalBlock(plainBytes, 0, plainBytes.Length); + + byte[] result = new byte[iv.Length + cipherBytes.Length]; + Buffer.BlockCopy(iv, 0, result, 0, iv.Length); + Buffer.BlockCopy(cipherBytes, 0, result, iv.Length, cipherBytes.Length); + + return result; + } + + public static byte[] DecryptByte(byte[] combinedBytes, string passphrase) + { + try + { + using var aes = Aes.Create(); + aes.Key = SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(passphrase)); + + byte[] iv = new byte[16]; + byte[] cipherBytes = new byte[combinedBytes.Length - 16]; + Buffer.BlockCopy(combinedBytes, 0, iv, 0, 16); + Buffer.BlockCopy(combinedBytes, 16, cipherBytes, 0, cipherBytes.Length); + + using var decryptor = aes.CreateDecryptor(aes.Key, iv); + return decryptor.TransformFinalBlock(cipherBytes, 0, cipherBytes.Length); + } + catch (Exception e) + { + return new byte[] { }; + } + + } + + public static string PacketDecPass(string encResult, string key, string nonce) + { + return DecryptString(encResult, nonce + key); + } + + public static string GetRandomString(int length) + { + const string chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyz!@#$%^&*()_+=-"; + return new string(Enumerable.Repeat(chars, length) + .Select(s => s[Random.Shared.Next(s.Length)]).ToArray()); + } + + public static string ComputeSHA3_512(string input) + { + byte[] inputBytes = Encoding.UTF8.GetBytes(input); + byte[] hashBytes = SHA3_512.HashData(inputBytes); + return Convert.ToHexString(hashBytes).ToLowerInvariant(); + } +} \ No newline at end of file diff --git a/LarpixServer/Filesystem/FileData.cs b/LarpixServer/Filesystem/FileData.cs new file mode 100644 index 0000000..8231ab5 --- /dev/null +++ b/LarpixServer/Filesystem/FileData.cs @@ -0,0 +1,18 @@ +namespace LarpixServer.Filesystem; + +public class FileData +{ + public byte[] content { get; set; } + public bool isDir { get; set; } + public long size { get; set; } + + public FileData(byte[]? content, bool isDir = false, long size = 0) + { + if (content != null) + { + this.content = content; + } + this.isDir = isDir; + this.size = size; + } +} \ No newline at end of file diff --git a/LarpixServer/Filesystem/Fs.cs b/LarpixServer/Filesystem/Fs.cs new file mode 100644 index 0000000..26b296f --- /dev/null +++ b/LarpixServer/Filesystem/Fs.cs @@ -0,0 +1,410 @@ +using System.Collections.Concurrent; +using static LarpixServer.Utils.Utils; + +namespace LarpixServer.Filesystem; + +public class Fs +{ + public static ConcurrentDictionary fileCache = new ConcurrentDictionary(); + public static ConcurrentDictionary existCache = new ConcurrentDictionary(); + public static ConcurrentDictionary dirCache = new ConcurrentDictionary(); + + private static ConcurrentDictionary fileLocks = new(); + + + private static void InvalidateDirCacheFor(string path) + { + var parentDir = Path.GetDirectoryName(path)?.Replace("\\", "/").TrimEnd('/'); + if (!string.IsNullOrEmpty(parentDir)) + { + dirCache.TryRemove(parentDir, out _); + } + } + + private static SemaphoreSlim GetFileLock(string path) + { + return fileLocks.GetOrAdd(path, _ => new SemaphoreSlim(1, 1)); + } + + public static void ProcessCacheSpace() + { + while (fileCache.Count >= FILE_CACHE_SIZE) + { + var firstKey = fileCache.Keys.FirstOrDefault(); + if (firstKey != null) fileCache.TryRemove(firstKey, out _); + } + + while (existCache.Count >= EXIST_CACHE_SIZE) + { + var firstKey = existCache.Keys.FirstOrDefault(); + if (firstKey != null) existCache.TryRemove(firstKey, out _); + } + + while (dirCache.Count >= DIR_CACHE_SIZE) + { + var firstKey = dirCache.Keys.FirstOrDefault(); + if (firstKey != null) dirCache.TryRemove(firstKey, out _); + } + + while (fileLocks.Count >= LOCK_SIZE) + { + var firstKey = fileLocks.Keys.FirstOrDefault(); + if (firstKey != null) fileLocks.TryRemove(firstKey, out _); + } + } + + public static ulong ClearCache(string pattern) + { + var comparison = StringComparison.OrdinalIgnoreCase; + + ulong removed = 0; + removed += ClearDict(fileCache, pattern, comparison); + removed += ClearDict(existCache, pattern, comparison); + removed += ClearDict(dirCache, pattern, comparison); + + return removed; + } + + static ulong ClearDict(ConcurrentDictionary dict, string pattern, + StringComparison comparison) + { + ulong count = 0; + + Parallel.ForEach( + dict.Where(x => x.Key.Contains(pattern, comparison)) + .Select(x => x.Key) + .ToList(), + key => + { + if (dict.TryRemove(key, out _)) + Interlocked.Increment(ref count); + }); + + return count; + } + + public static async Task ReadFileStream(string path) + { + ProcessCacheSpace(); + path = path.Replace("\\", "/").TrimEnd('/'); + + var fileLock = GetFileLock(path); + await fileLock.WaitAsync(); + try + { + if (fileCache.TryGetValue(path, out var cachedData)) + { + return new MemoryStream(cachedData.content); + } + + var fileInfo = new FileInfo(path); + if (!fileInfo.Exists) throw new FileNotFoundException(); + + if (fileInfo.Length < CACHE_ENTRY_SIZE) + { + var bytes = await File.ReadAllBytesAsync(path); + fileCache[path] = new FileData(bytes, false, fileInfo.Length); + return new MemoryStream(bytes); + } + + return new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, 8192, true); + } + finally + { + fileLock.Release(); + } + } + + public static async Task ReadFile(string path) + { + ProcessCacheSpace(); + path = path.Replace("\\", "/").TrimEnd('/'); + + var fileLock = GetFileLock(path); + await fileLock.WaitAsync(); + + try + { + if (fileCache.TryGetValue(path, out var fileData)) + { + return fileData.content; + } + + var fileInfo = new FileInfo(path); + if (fileInfo.Length < CACHE_ENTRY_SIZE) + { + byte[] content = await File.ReadAllBytesAsync(path); + fileCache[path] = new FileData(content, false, fileInfo.Length); + existCache[path] = true; + return content; + } + + + byte[] bytes = await File.ReadAllBytesAsync(path); + existCache[path] = true; + return bytes; + } + finally + { + fileLock.Release(); + } + } + + public static async Task WriteFile(string path, byte[] content) + { + ProcessCacheSpace(); + path = path.Replace("\\", "/").TrimEnd('/'); + + var fileLock = GetFileLock(path); + await fileLock.WaitAsync(); + + try + { + var dir = Path.GetDirectoryName(path); + if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir)) + { + CreateDirectory(dir); + } + + await File.WriteAllBytesAsync(path, content); + existCache[path] = true; + if (content.Length < CACHE_ENTRY_SIZE) + { + fileCache[path] = new FileData(content, false, content.Length); + } + else + { + fileCache.TryRemove(path, out _); + } + + InvalidateDirCacheFor(path); + } + finally + { + fileLock.Release(); + } + } + + public static void MoveFile(string path, string newPath) + { + ProcessCacheSpace(); + path = path.Replace("\\", "/").TrimEnd('/'); + newPath = newPath.Replace("\\", "/").TrimEnd('/'); + + var lock1Path = string.Compare(path, newPath, StringComparison.OrdinalIgnoreCase) < 0 ? path : newPath; + var lock2Path = string.Compare(path, newPath, StringComparison.OrdinalIgnoreCase) < 0 ? newPath : path; + + var sem1 = GetFileLock(lock1Path); + var sem2 = GetFileLock(lock2Path); + + sem1.Wait(); + try + { + sem2.Wait(); + try + { + File.Move(path, newPath); + existCache[path] = false; + existCache[newPath] = true; + fileCache.TryRemove(path, out _); + InvalidateDirCacheFor(path); + InvalidateDirCacheFor(newPath); + } + finally + { + sem2.Release(); + } + } + finally + { + sem1.Release(); + } + } + + public static void DeleteFile(string path) + { + ProcessCacheSpace(); + path = path.Replace("\\", "/").TrimEnd('/'); + + var fileLock = GetFileLock(path); + fileLock.Wait(); + try + { + if (File.Exists(path)) + { + File.Delete(path); + } + + existCache[path] = false; + if (fileCache.ContainsKey(path)) + { + fileCache.TryRemove(path, out _); + } + + InvalidateDirCacheFor(path); + } + finally + { + fileLock.Release(); + } + } + + public static void DeleteDirectory(string path) + { + ProcessCacheSpace(); + path = path.Replace("\\", "/").TrimEnd('/'); + + var fileLock = GetFileLock(path); + fileLock.Wait(); + try + { + if (Directory.Exists(path)) + { + Directory.Delete(path, true); + } + + var keysToRemove = existCache.Keys.Where(k => k.StartsWith(path + "/")).ToList(); + foreach (var key in keysToRemove) + { + existCache[key] = false; + fileCache.TryRemove(key, out _); + dirCache.TryRemove(key, out _); + } + + existCache[path] = false; + dirCache.TryRemove(path, out _); + InvalidateDirCacheFor(path); + } + finally + { + fileLock.Release(); + } + } + + public static void CreateDirectory(string path) + { + ProcessCacheSpace(); + path = path.Replace("\\", "/").TrimEnd('/'); + var fileLock = GetFileLock(path); + fileLock.Wait(); + try + { + Directory.CreateDirectory(path); + existCache[path] = true; + InvalidateDirCacheFor(path); + } + finally + { + fileLock.Release(); + } + } + + public static bool Exists(string path) + { + ProcessCacheSpace(); + path = path.Replace("\\", "/").TrimEnd('/'); + + var fileLock = GetFileLock(path); + fileLock.Wait(); + try + { + if (existCache.TryGetValue(path, out bool exists)) + { + return exists; + } + + if (File.Exists(path) || Directory.Exists(path)) + { + FileAttributes attr = File.GetAttributes(path); + bool isDir = attr.HasFlag(FileAttributes.Directory); + if (isDir) + { + fileCache[path] = new FileData(null, true, 0); + } + + existCache[path] = true; + return true; + } + else + { + existCache[path] = false; + return false; + } + } + finally + { + fileLock.Release(); + } + } + + public static string[] ReadDirectory(string path) + { + ProcessCacheSpace(); + path = path.Replace("\\", "/").TrimEnd('/'); + + var fileLock = GetFileLock(path); + fileLock.Wait(); + try + { + if (dirCache.TryGetValue(path, out var directoryData)) + { + return directoryData; + } + + if (!Directory.Exists(path)) + { + return Array.Empty(); + } + + directoryData = Directory.GetFileSystemEntries(path, "*", SearchOption.TopDirectoryOnly); + dirCache[path] = directoryData; + return directoryData; + } + finally + { + fileLock.Release(); + } + } + + public async static Task isDirectory(string path) + { + ProcessCacheSpace(); + path = path.Replace("\\", "/").TrimEnd('/'); + + var fileLock = GetFileLock(path); + await fileLock.WaitAsync(); + try + { + if (fileCache.TryGetValue(path, out var fileData)) + { + return fileData.isDir; + } + + if (!File.Exists(path) && !Directory.Exists(path)) + { + return false; + } + + var fileInfo = new FileInfo(path); + + if (fileInfo.Attributes.HasFlag(FileAttributes.Directory)) + { + fileCache[path] = new FileData(null, true, 0); + return true; + } + + if (fileInfo.Length < CACHE_ENTRY_SIZE) + { + byte[] content = await File.ReadAllBytesAsync(path); + fileCache[path] = new FileData(content, false, fileInfo.Length); + existCache[path] = true; + } + + return false; + } + finally + { + fileLock.Release(); + } + } +} \ No newline at end of file diff --git a/LarpixServer/LarpixServer.csproj b/LarpixServer/LarpixServer.csproj new file mode 100644 index 0000000..07e209b --- /dev/null +++ b/LarpixServer/LarpixServer.csproj @@ -0,0 +1,15 @@ + + + + net10.0 + enable + enable + True + + + + + + + + diff --git a/LarpixServer/Program.cs b/LarpixServer/Program.cs new file mode 100644 index 0000000..fb25df4 --- /dev/null +++ b/LarpixServer/Program.cs @@ -0,0 +1,180 @@ +using System.Text; +using System.Text.Json; +using LarpixServer.Account; +using LarpixServer.Filesystem; +using LarpixServer.Utils.Jsons; + +namespace LarpixServer; + +using static LarpixServer.Utils.Utils; + +public class Program +{ + public static async Task Main(string[] args) + { + await LoadEnv(); + var builder = WebApplication.CreateBuilder(args); + builder.Services.AddHostedService(); + builder.Logging.ClearProviders(); + + builder.WebHost.ConfigureKestrel(serverOptions => + { + serverOptions.AddServerHeader = false; + serverOptions.ListenAnyIP(PORT); + }); + + var app = builder.Build(); + app.UseWebSockets(); + + app.Use(async (HttpContext context, Func next) => + { + try + { + context.Response.ContentType = mimeTypes["txt"]; + context.Response.StatusCode = 200; + var path = context.Request.Path.Value; + var url = $"{context.Request.Scheme}://{context.Request.Host}"; + if (path == "" || path.Length > 512) + { + context.Response.Redirect(url + "/"); + return; + } + int index = path.LastIndexOf('.'); + string fileExtension = index >= 0 ? path[(index + 1)..] : ""; + IQueryCollection query = context.Request.Query; + using StreamReader reader = new StreamReader(context.Request.Body); + + switch (path) + { + case "/_larpix/serverinfo": + ServerInfo serverInfo = new ServerInfo(); + serverInfo.domain = DOMAIN; + serverInfo.owner_contact = OWNER_CONTACT; + + string registrationString = Encoding.UTF8.GetString(await Fs.ReadFile($"{ACCOUNTS_DIR}/registration")); + if (registrationString.StartsWith("first;")) + { + serverInfo.registration = "enabled"; + } + if (registrationString.StartsWith("1;")) + { + serverInfo.registration = "enabled"; + } + if (registrationString.StartsWith("0;")) + { + serverInfo.registration = "disabled"; + } + if (registrationString.StartsWith("code;")) + { + serverInfo.registration = "code"; + } + + var serializedPayload = JsonSerializer.Serialize( + serverInfo, + AppJsonSerializerContext.Default.ServerInfo + ); + context.Response.ContentType = mimeTypes["json"]; + await context.Response.WriteAsync(serializedPayload); + return; + } + + //if (!context.Request.Headers.TryGetValue("username", out var username)) how to header + + switch (path) + { + case "/restart": + if (!query.TryGetValue("skey", out var skey)) + { + context.Response.ContentType = mimeTypes["html"]; + context.Response.StatusCode = 403; + await context.Response.WriteAsync("Forbidden"); + return; + } + if (skey != SERVER_KEY) + { + context.Response.ContentType = mimeTypes["html"]; + context.Response.StatusCode = 403; + await context.Response.WriteAsync("Forbidden"); + return; + } + if (!query.TryGetValue("f", out var force)) + { + force = "false"; + } + if (force == "true") + { + await context.Response.WriteAsync("[FORCE] Shutting down..."); + Console.WriteLine("[FORCE] Shutting down..."); + Environment.Exit(0); + return; + } + await context.Response.WriteAsync("[SAFE] Shutting down..."); + Console.WriteLine("[SAFE] Shutting down..."); + var lifetime = context.RequestServices.GetRequiredService(); + lifetime.StopApplication(); + return; + case "/clearcache": //kiedys bedzie endpoint cache + if (!query.TryGetValue("skey", out skey)) + { + context.Response.ContentType = mimeTypes["html"]; + context.Response.StatusCode = 403; + await context.Response.WriteAsync("Forbidden"); + return; + } + if (skey != SERVER_KEY) + { + context.Response.ContentType = mimeTypes["html"]; + context.Response.StatusCode = 403; + await context.Response.WriteAsync("Forbidden"); + return; + } + if (!query.TryGetValue("p", out var p)) + { + p = ""; + } + ulong removed = Fs.ClearCache(p); + await context.Response.WriteAsync($"Cache cleared with pattern: {p}. {removed} elements removed"); + return; + case "/createaccount": + await Account.Requests.Create(context, next, query, reader); + return; + case "/deleteaccount": + await Account.Requests.Delete(context, next, query, reader); + return; + case "/auth": + await Account.Requests.Auth(context, next, query, reader); + return; + case "/chpass": + await Account.Requests.ChangePassword(context, next, query, reader); + return; + case "/chname": + await Account.Requests.ChangeUsername(context, next, query, reader); + return; + case "/correctedname": + await Account.Requests.CorrectedName(context, next, query); + return; + case "/nextnonce": + await Account.Requests.NextNonce(context, next, query); + return; + case "/encryptedrequest": + await Account.Requests.EncryptedRequest(context, next, query, reader); + return; + case "/user/key/getpublic": + await Account.Requests.GetUserPublicKey(context, next, query, reader); + return; + case "/user/storage/public/getentry": + await Account.Requests.GetUserPublicStorageEntry(context, next, query, reader); + return; + } + } + catch (Exception ex) + { + Console.WriteLine(ex); + } + + }); + + Console.WriteLine("Starting server at port: " + PORT); + app.Run(); + } +} \ No newline at end of file diff --git a/LarpixServer/Room/Requests.cs b/LarpixServer/Room/Requests.cs new file mode 100644 index 0000000..b44ea1c --- /dev/null +++ b/LarpixServer/Room/Requests.cs @@ -0,0 +1,198 @@ +using System.Numerics; +using System.Text; +using System.Text.Json; +using LarpixServer.Filesystem; +using LarpixServer.Utils.Jsons; +using static LarpixServer.Utils.Utils; + +namespace LarpixServer.Room; + +public class Requests +{ + public static SemaphoreSlim createLock = new SemaphoreSlim(1, 1); + public static async Task DmInvite(string id, string username2) + { + if (!Account.Utils.IsUserLocal(username2, out string domain)) //federation + { + return ""; + } + username2 = username2.Split(":")[0]; + string id2 = await Account.Utils.IdFromName(username2); + + SemaphoreSlim userLock = Account.Utils.GetUserLock(id2); + await userLock.WaitAsync(); + try + { + + string inviteFile = ACCOUNTS_DATA_DIR + $"/{id2}/dminvites/recv/{id};{DOMAIN}"; + if (Fs.Exists(inviteFile)) + { + string inviteState = Encoding.UTF8.GetString(await Fs.ReadFile(inviteFile)); + switch (inviteState) + { + case "0": //invited + return "User already invited"; + case "1": //accepted + return "You already have a DM with this user"; + } + + } + + await Fs.WriteFile(inviteFile, Encoding.UTF8.GetBytes("0")); + await Fs.WriteFile(ACCOUNTS_DATA_DIR + $"/{id}/dminvites/sent/{id2};{domain}", []); //we should also save that this user invited someone, because this will act as dm accept verification while federating + } + finally + { + userLock.Release(); + } + + return "User invited"; + } + + public static async Task DmCreate(string id, string body) + { + await createLock.WaitAsync(); + try + { + Universal3String serializedBody = JsonSerializer.Deserialize( //1 is ID2 nameWD, 2 is creators dm key, 3 is key for ID2 + body, + AppJsonSerializerContext.Default.Universal3String + ); + + string username2 = serializedBody.string1; + bool isUserLocal = Account.Utils.IsUserLocal(username2, out string domain); + string id2; + username2 = username2.Split(":")[0]; + if (isUserLocal) + { + id2 = await Account.Utils.IdFromName(username2); + } + else //id z federacji + { + id2 = "?"; + } + + string inviteFile = ACCOUNTS_DATA_DIR + $"/{id}/dminvites/recv/{id2};{domain}"; + if (Fs.Exists(inviteFile)) + { + string inviteState = Encoding.UTF8.GetString(await Fs.ReadFile(inviteFile)); + switch (inviteState) + { + case "0": //invited + await Fs.WriteFile(inviteFile, Encoding.UTF8.GetBytes("1")); //we are setting this to 1, + //so now we should have an option to delete id from dmslist and still search it + + + BigInteger dmId = + BigInteger.Parse(Encoding.UTF8.GetString(await Fs.ReadFile($"{ROOMS_DIR}/lastdm"))); + dmId++; + var freeid = + Path.GetFileName(Directory.EnumerateFiles(ROOMS_DIR + "/freedms").FirstOrDefault()); + if (!isUserLocal) //federacja + { + //jezeli freeid tez jest wolne na innym serwerze (tez jako freeid) to wszystko zostawiamy tak jak jest + //w przeciwnym razie porownojemy lastid z 2 serwerow i bierzemy to większe + 1 jako docelowe id dma + + //no ale moze to byc problematyczne + } + + if (freeid != null) + { + dmId = BigInteger.Parse(freeid); + Fs.DeleteFile($"{ROOMS_DIR}/freedms/{freeid}"); + } + else + { + await Fs.WriteFile($"{ROOMS_DIR}/lastdm", Encoding.UTF8.GetBytes(dmId.ToString())); + } + + await Fs.WriteFile($"{ROOMS_DIR}/dms/{dmId}/members", + Encoding.UTF8.GetBytes($"{id}:{DOMAIN};{id2}:{domain}")); + await Fs.WriteFile($"{ROOMS_DIR}/dms/{dmId}/messages/last", Encoding.UTF8.GetBytes($"0")); + + Message startingMessage = new(); + startingMessage.attachment = ""; + startingMessage.author = "0"; + startingMessage.timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds().ToString(); + startingMessage.type = "larp.info"; + startingMessage.content = + "At the beginning of a DM, you should always verify that the person you're talking to is the intended recipient"; + startingMessage.key = ""; //no key == NOT encrypted + startingMessage.pervious = ""; + + await Fs.WriteFile($"{ROOMS_DIR}/dms/{dmId}/keys/0/{id};{DOMAIN}", //klucz dla uzytkownika tworzącego jest juz gotowy i ma zwykly zapis + Encoding.UTF8.GetBytes(serializedBody.string2)); + + + Universal2String keys = JsonSerializer.Deserialize( + await Account.Utils.GetUserKeys(id), + AppJsonSerializerContext.Default.Universal2String + ); + await Fs.WriteFile($"{ROOMS_DIR}/dms/{dmId}/keys/0/{id2};{domain}", + Encoding.UTF8.GetBytes($"SETUP:{keys.string2};{serializedBody.string3}")); //jezeli mamy setup to [1] to jest publiczny klucz drugiej osoby, + //a string 3 to zaszyfrowany klucz pokoju ktory musi odszyfrowac za pomoca swoich kluczy i tego publicznego + + + await Fs.WriteFile($"{ROOMS_DIR}/dms/{dmId}/messages/0", Encoding.UTF8.GetBytes( + JsonSerializer.Serialize(startingMessage, AppJsonSerializerContext.Default.Message) + )); + + string dmspath = $"{ACCOUNTS_DATA_DIR}/{id}/dms"; + string dms; + if (!Fs.Exists(dmspath)) + { + dms = ""; + } + else + { + dms = Encoding.UTF8.GetString(await Fs.ReadFile(dmspath)); + } + + await Fs.WriteFile(dmspath, Encoding.UTF8.GetBytes($"{dmId};{dms}")); + + if (isUserLocal) + { + SemaphoreSlim userLock = Account.Utils.GetUserLock(id2); + await userLock.WaitAsync(); + try + { + string dms2path = $"{ACCOUNTS_DATA_DIR}/{id2}/dms"; + string dms2; + if (await Account.Utils.NameFromId(id2) == "") //if user2 account just got deleted + { + return "DM accepted"; + } + if (!Fs.Exists(dms2path)) + { + dms2 = ""; + } + else + { + dms2 = Encoding.UTF8.GetString(await Fs.ReadFile(dms2path)); + } + + await Fs.WriteFile(dms2path, Encoding.UTF8.GetBytes($"{dmId};{dms2}")); + } + finally + { + userLock.Release(); + } + } + else //federacja + { + + } + + return "DM accepted"; + case "1": //accepted + return "You already have a DM with this user"; + } + } + } + finally + { + createLock.Release(); + } + return "You can't create a DM without an invitation"; + } +} \ No newline at end of file diff --git a/LarpixServer/Utils/AppJsonSerializerContext.cs b/LarpixServer/Utils/AppJsonSerializerContext.cs new file mode 100644 index 0000000..ffdc077 --- /dev/null +++ b/LarpixServer/Utils/AppJsonSerializerContext.cs @@ -0,0 +1,14 @@ +using System.Text.Json.Serialization; + +namespace LarpixServer.Utils.Jsons; + +[JsonSerializable(typeof(KeyExchangePayload))] +[JsonSerializable(typeof(KeyExchangePayloadClient))] +[JsonSerializable(typeof(CaptchaPayloadClient))] +[JsonSerializable(typeof(Universal2String))] +[JsonSerializable(typeof(Universal3String))] +[JsonSerializable(typeof(ServerInfo))] +[JsonSerializable(typeof(Message))] +public partial class AppJsonSerializerContext : JsonSerializerContext +{ +} \ No newline at end of file diff --git a/LarpixServer/Utils/Jsons.cs b/LarpixServer/Utils/Jsons.cs new file mode 100644 index 0000000..ff9ad3e --- /dev/null +++ b/LarpixServer/Utils/Jsons.cs @@ -0,0 +1,54 @@ +namespace LarpixServer.Utils.Jsons; + +public class ServerInfo +{ + public string domain { get; set; } + public string owner_contact { get; set; } + public string registration { get; set; } +} +public class Universal2String +{ + public string string1 { get; set; } + public string string2 { get; set; } +} +public class Universal3String +{ + public string string1 { get; set; } + public string string2 { get; set; } + public string string3 { get; set; } +} + + +public class KeyExchangePayload +{ + public string p { get; set; } + public string g { get; set; } + public string pubServer { get; set; } + public string idKey { get; set; } +} + +public class KeyExchangePayloadClient +{ + public string pubClient { get; set; } + public string idKey { get; set; } + public string username { get; set; } + public string password { get; set; } +} + +public class CaptchaPayloadClient +{ + public string captcha { get; set; } + public string idKey { get; set; } + public string regKey { get; set; } +} + +public class Message +{ + public string author { get; set; } + public string pervious { get; set; } + public string type { get; set; } + public string content { get; set; } + public string attachment { get; set; } + public string timestamp { get; set; } + public string key { get; set; } +} \ No newline at end of file diff --git a/LarpixServer/Utils/Utils.cs b/LarpixServer/Utils/Utils.cs new file mode 100644 index 0000000..2a9ea35 --- /dev/null +++ b/LarpixServer/Utils/Utils.cs @@ -0,0 +1,258 @@ +using System.Collections.Concurrent; +using System.Text; + +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 VOICE_IPS = new(); + + 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 = 65536; + + public static int PRIVATE_STORAGE_LIMIT = 5242880; + public static int PUBLIC_STORAGE_LIMIT = 10485760; + + public static Dictionary 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 async Task 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("Body too large"); + + bodyBuilder.Append(buffer, 0, read); + } + + return bodyBuilder.ToString(); + + } + + 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].Trim(); + + Environment.SetEnvironmentVariable(key, val); + } + } + } + + string? port = Environment.GetEnvironmentVariable("PORT"); + if (port != null) + { + PORT = int.Parse(port); + } + + 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; + 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; + + 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 (!Directory.Exists(DATA_DIR)) + { + Directory.CreateDirectory(DATA_DIR); + } + + if (!Directory.Exists(ROOMS_DIR)) + { + Directory.CreateDirectory(ROOMS_DIR); + } + if (!Directory.Exists(ROOMS_DIR + "/dms")) + { + Directory.CreateDirectory(ROOMS_DIR + "/dms"); + } + if (!Directory.Exists(ROOMS_DIR + "/groups")) + { + Directory.CreateDirectory(ROOMS_DIR + "/groups"); + } + if (!Directory.Exists(ROOMS_DIR + "/spaces")) + { + Directory.CreateDirectory(ROOMS_DIR + "/spaces"); + } + if (!Directory.Exists(ROOMS_DIR + "/freedms")) + { + Directory.CreateDirectory(ROOMS_DIR + "/freedms"); + } + if (!Directory.Exists(ROOMS_DIR + "/freegroups")) + { + Directory.CreateDirectory(ROOMS_DIR + "/freegroups"); + } + if (!Directory.Exists(ROOMS_DIR + "/freespaces")) + { + Directory.CreateDirectory(ROOMS_DIR + "/freespaces"); + } + if (!File.Exists(ROOMS_DIR + "/lastdm")) + { + await File.WriteAllTextAsync(ROOMS_DIR + "/lastdm", "0"); + } + if (!File.Exists(ROOMS_DIR + "/lastgroup")) + { + await File.WriteAllTextAsync(ROOMS_DIR + "/lastgroup", "0"); + } + if (!File.Exists(ROOMS_DIR + "/lastspace")) + { + await File.WriteAllTextAsync(ROOMS_DIR + "/lastspace", "0"); + } + + if (!Directory.Exists(ACCOUNTS_DIR)) + { + Directory.CreateDirectory(ACCOUNTS_DIR); + } + if (!Directory.Exists(ACCOUNTS_NAME_DIR)) + { + Directory.CreateDirectory(ACCOUNTS_NAME_DIR); + } + if (!Directory.Exists(ACCOUNTS_DATA_DIR)) + { + Directory.CreateDirectory(ACCOUNTS_DATA_DIR); + } + if (!Directory.Exists(ACCOUNTS_FREEID_DIR)) + { + Directory.CreateDirectory(ACCOUNTS_FREEID_DIR); + } + if (!File.Exists(ACCOUNTS_DIR + "/last")) + { + await File.WriteAllTextAsync(ACCOUNTS_DIR + "/last", "0"); + } + if (!File.Exists($"{ACCOUNTS_DIR}/registration")) + { + await File.WriteAllTextAsync($"{ACCOUNTS_DIR}/registration", "first;"); + } + } +} \ No newline at end of file diff --git a/LarpixServer/appsettings.Development.json b/LarpixServer/appsettings.Development.json new file mode 100644 index 0000000..10f68b8 --- /dev/null +++ b/LarpixServer/appsettings.Development.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/LarpixServer/appsettings.json b/LarpixServer/appsettings.json new file mode 100644 index 0000000..10f68b8 --- /dev/null +++ b/LarpixServer/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/LarpixVoice/JsonAppSerializerContext.cs b/LarpixVoice/JsonAppSerializerContext.cs new file mode 100644 index 0000000..0376cca --- /dev/null +++ b/LarpixVoice/JsonAppSerializerContext.cs @@ -0,0 +1,8 @@ +using System.Text.Json.Serialization; + +namespace LarpixVoice; + +[JsonSerializable(typeof(string[]))] +internal partial class JsonAppSerializerContext : JsonSerializerContext +{ +} \ No newline at end of file diff --git a/LarpixVoice/LarpixVoice.csproj b/LarpixVoice/LarpixVoice.csproj new file mode 100644 index 0000000..ca2572f --- /dev/null +++ b/LarpixVoice/LarpixVoice.csproj @@ -0,0 +1,10 @@ + + + + net10.0 + enable + enable + True + + + diff --git a/LarpixVoice/Program.cs b/LarpixVoice/Program.cs new file mode 100644 index 0000000..3916d7d --- /dev/null +++ b/LarpixVoice/Program.cs @@ -0,0 +1,95 @@ +using System.Collections.Concurrent; + +namespace LarpixVoice; + +public class Program +{ + public static ConcurrentDictionary> Rooms = new (); + + public static ulong TotalBytesSent = 0; + public static ulong TotalBytesReceived = 0; + private static ulong CurrentBandwidthKps = 0; + + public static void Main(string[] args) + { + Utils.LoadEnv(); + + Task.Run(async () => + { + while (true) + { + await Task.Delay(1000); + + ulong sentDelta = Interlocked.Read(ref TotalBytesSent); + ulong receivedDelta = Interlocked.Read(ref TotalBytesReceived); + Interlocked.Exchange(ref TotalBytesReceived, 0); + Interlocked.Exchange(ref TotalBytesSent, 0); + CurrentBandwidthKps = (sentDelta + receivedDelta); + } + }); + + var builder = WebApplication.CreateBuilder(args); + builder.Logging.ClearProviders(); + + builder.WebHost.ConfigureKestrel(serverOptions => + { + serverOptions.AddServerHeader = false; + serverOptions.ListenAnyIP(Utils.PORT); + }); + + var app = builder.Build(); + + app.UseWebSockets(); + + app.Use(async (HttpContext context, Func next) => + { + try + { + context.Response.StatusCode = 200; + var path = context.Request.Path.Value; + var url = $"{context.Request.Scheme}://{context.Request.Host}"; + if (path == "" || path?.Length > 512) + { + context.Response.Redirect(url + "/"); + return; + } + + context.Response.Headers["Access-Control-Allow-Origin"] = "*";//(context.Request.Headers["Origin"]).ToString().Replace("http://", "https://"); + context.Response.Headers["Access-Control-Allow-Methods"]= "GET, POST, PUT, DELETE, OPTIONS"; + context.Response.Headers["Access-Control-Allow-Headers"]= "Content-Type, Authorization"; + context.Response.Headers["Access-Control-Allow-Credentials"]= "true"; + switch (path) + { + case "/ws": + await Requests.Websocket(context); + return; + case "/load": + await context.Response.WriteAsync(Interlocked.Read(ref CurrentBandwidthKps).ToString()); + return; + case "/room/users": + string roomId = context.Request.Query["room"].ToString(); + context.Response.ContentType = "application/json"; + + if (Rooms.TryGetValue(roomId, out var room)) + { + string[] users = room.Keys.Select(k => k.ToString()).ToArray(); + await context.Response.WriteAsJsonAsync(users, JsonAppSerializerContext.Default.StringArray); + } + else + { + await context.Response.WriteAsJsonAsync(Array.Empty(), JsonAppSerializerContext.Default.StringArray); + } + return; + default: + await next(); + return; + } + } + catch (Exception) + { + } + }); + Console.WriteLine("Starting server at port: " + Utils.PORT); + app.Run(); + } +} \ No newline at end of file diff --git a/LarpixVoice/Requests.cs b/LarpixVoice/Requests.cs new file mode 100644 index 0000000..9c78179 --- /dev/null +++ b/LarpixVoice/Requests.cs @@ -0,0 +1,124 @@ +using System.Buffers; +using System.Collections.Concurrent; +using System.Net.WebSockets; + +using static LarpixVoice.Program; + +namespace LarpixVoice; + +public class Requests +{ + public static async Task Websocket(HttpContext context) + { + if (!context.WebSockets.IsWebSocketRequest) + { + context.Response.StatusCode = 400; + return; + } + + string roomId = context.Request.Query["room"].ToString(); + string userIdStr = context.Request.Query["userId"].ToString(); + string secret = context.Request.Query["secret"].ToString(); + + if (string.IsNullOrEmpty(roomId) || !ulong.TryParse(userIdStr, out ulong clientId)) + { + context.Response.StatusCode = 400; + return; + } + + using var webSocket = await context.WebSockets.AcceptWebSocketAsync(); + var roomClients = Rooms.GetOrAdd(roomId, _ => new ConcurrentDictionary()); + + if (roomClients.TryGetValue(clientId, out var oldSession) && oldSession.Socket.State == WebSocketState.Open) + { + try + { + await oldSession.Socket.CloseAsync(WebSocketCloseStatus.NormalClosure, "Kicked", CancellationToken.None); + } + catch + { + } + } + + var currentSession = new Session(webSocket); + roomClients[clientId] = currentSession; + + _ = Task.Run(async () => + { + await foreach (var packet in currentSession.SendQueue.Reader.ReadAllAsync()) + { + try + { + if (currentSession.Socket.State == WebSocketState.Open) + { + await currentSession.Socket.SendAsync(new ArraySegment(packet.Buffer, 0, packet.Length), + WebSocketMessageType.Binary, true, CancellationToken.None); + + Interlocked.Add(ref TotalBytesSent, (ulong)packet.Length); + } + } + catch + { + } + finally + { + ArrayPool.Shared.Return(packet.Buffer); + } + } + }); + + var receiveBuffer = ArrayPool.Shared.Rent(1024 * 1024); + try + { + while (webSocket.State == WebSocketState.Open) + { + int totalBytes = 0; + WebSocketReceiveResult result; + + do + { + result = await webSocket.ReceiveAsync( + new ArraySegment(receiveBuffer, totalBytes, receiveBuffer.Length - totalBytes), + CancellationToken.None); + + totalBytes += result.Count; + Interlocked.Add(ref TotalBytesReceived, (ulong)result.Count); + + } while (!result.EndOfMessage); + + if (result.MessageType == WebSocketMessageType.Close) break; + if (result.MessageType == WebSocketMessageType.Binary) + { + int payloadLength = totalBytes + 8; + + foreach (var (id, client) in roomClients) + { + if (id != clientId && client.Socket.State == WebSocketState.Open) + { + byte[] payload = ArrayPool.Shared.Rent(payloadLength); + BitConverter.TryWriteBytes(payload, clientId); + Array.Copy(receiveBuffer, 0, payload, 8, totalBytes); + + if (!client.SendQueue.Writer.TryWrite((payload, payloadLength))) + ArrayPool.Shared.Return(payload); + } + } + } + } + } + catch (WebSocketException) + { + } + finally + { + ArrayPool.Shared.Return(receiveBuffer); + if (roomClients.TryGetValue(clientId, out var session) && session == currentSession) + { + roomClients.TryRemove(clientId, out _); + session.SendQueue.Writer.Complete(); + } + + if (roomClients.IsEmpty) Rooms.TryRemove(roomId, out _); + } + } +} \ No newline at end of file diff --git a/LarpixVoice/Session.cs b/LarpixVoice/Session.cs new file mode 100644 index 0000000..a5fbabe --- /dev/null +++ b/LarpixVoice/Session.cs @@ -0,0 +1,19 @@ +using System.Net.WebSockets; +using System.Threading.Channels; + +namespace LarpixVoice; + +public class Session +{ + public WebSocket Socket { get; } + public Channel<(byte[] Buffer, int Length)> SendQueue { get; } + + public Session(WebSocket socket) + { + Socket = socket; + SendQueue = Channel.CreateBounded<(byte[] Buffer, int Length)>(new BoundedChannelOptions(256) + { + FullMode = BoundedChannelFullMode.Wait + }); + } +} \ No newline at end of file diff --git a/LarpixVoice/Utils.cs b/LarpixVoice/Utils.cs new file mode 100644 index 0000000..69242b7 --- /dev/null +++ b/LarpixVoice/Utils.cs @@ -0,0 +1,42 @@ +namespace LarpixVoice; + +public class Utils +{ + public static int PORT = 8091; + public static int MAIN_PORT = 8090; + public static string SERVER_KEY = "SecretThingOMGSoHardToGuess"; + + public static void 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].Trim(); + + Environment.SetEnvironmentVariable(key, val); + } + } + } + + string? port = Environment.GetEnvironmentVariable("PORT_VOICE"); + if (port != null) + { + PORT = int.Parse(port); + } + port = Environment.GetEnvironmentVariable("PORT"); + if (port != null) + { + MAIN_PORT = int.Parse(port); + } + SERVER_KEY = Environment.GetEnvironmentVariable("SERVER_KEY") ?? SERVER_KEY; + } +} \ No newline at end of file diff --git a/LarpixVoice/appsettings.Development.json b/LarpixVoice/appsettings.Development.json new file mode 100644 index 0000000..10f68b8 --- /dev/null +++ b/LarpixVoice/appsettings.Development.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/LarpixVoice/appsettings.json b/LarpixVoice/appsettings.json new file mode 100644 index 0000000..10f68b8 --- /dev/null +++ b/LarpixVoice/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/README.MD b/README.MD new file mode 100644 index 0000000..9deb254 --- /dev/null +++ b/README.MD @@ -0,0 +1 @@ +# Larpix Server \ No newline at end of file