First commit

This commit is contained in:
olcxja 2026-04-24 07:38:15 +02:00
commit 0ac6ff9196
26 changed files with 2836 additions and 0 deletions

9
.gitignore vendored Normal file
View file

@ -0,0 +1,9 @@
.idea/
bin/
obj/
Build/
projectSettingsUpdater.xml
indexLayout.xml
discord.xml
encodings.xml
*.DotSettings.user

22
Larpix.sln Normal file
View file

@ -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

View file

@ -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);
}
}
}

View file

@ -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<char> captchaSpan = stackalloc char[charCount];
Span<SKColor> 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<float> randOffsets = stackalloc float[256];
Span<float> randRadii = stackalloc float[256];
Span<SKColor> 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<int> sinY = stackalloc int[height];
for (int y = 0; y < height; y++)
sinY[y] = (int)(Math.Sin(y * frequency) * amplitude);
Span<int> 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<uint> srcPixels = MemoryMarshal.Cast<byte, uint>(sourceBitmap.GetPixelSpan());
Span<uint> dstPixels = MemoryMarshal.Cast<byte, uint>(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);
}
}

View file

@ -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<string, CreateHolder> createHolder = new();
public static ConcurrentDictionary<string, (string, DateTimeOffset)> nonceHolder = new();
public static SemaphoreSlim createLock = new SemaphoreSlim(1, 1);
public static async Task Delete(HttpContext context, Func<Task> 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<Task> 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<Task> 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<Task> 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<Task> 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<Task> 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<Task> 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<string> 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<Task> 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<Task> 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<Task> 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;
}

View file

@ -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<string, SemaphoreSlim> userLocks = new();
public static ConcurrentQueue<string> 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<string> 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<string> 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<string> 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<string> 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<DateTimeOffset> 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<string> 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<byte[]> 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<string> 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<string> 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<string> 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";
}
}

View file

@ -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";
/// <summary>
/// RFC 3526 4096-bit MODP Group (ID: 16) Prime
/// </summary>
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();
}
}

View file

@ -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;
}
}

View file

@ -0,0 +1,410 @@
using System.Collections.Concurrent;
using static LarpixServer.Utils.Utils;
namespace LarpixServer.Filesystem;
public class Fs
{
public static ConcurrentDictionary<string, FileData> fileCache = new ConcurrentDictionary<string, FileData>();
public static ConcurrentDictionary<string, bool> existCache = new ConcurrentDictionary<string, bool>();
public static ConcurrentDictionary<string, string[]> dirCache = new ConcurrentDictionary<string, string[]>();
private static ConcurrentDictionary<string, SemaphoreSlim> 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<TValue>(ConcurrentDictionary<string, TValue> 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<Stream> 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<byte[]> 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<string>();
}
directoryData = Directory.GetFileSystemEntries(path, "*", SearchOption.TopDirectoryOnly);
dirCache[path] = directoryData;
return directoryData;
}
finally
{
fileLock.Release();
}
}
public async static Task<bool> 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();
}
}
}

View file

@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<NoDefaultLaunchSettingsFile>True</NoDefaultLaunchSettingsFile>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="SkiaSharp" Version="3.119.2" />
<PackageReference Include="SkiaSharp.NativeAssets.Linux" Version="3.119.2" />
</ItemGroup>
</Project>

180
LarpixServer/Program.cs Normal file
View file

@ -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<BackgroundDelete>();
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<Task> 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<IHostApplicationLifetime>();
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();
}
}

View file

@ -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<string> 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<string> 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";
}
}

View file

@ -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
{
}

View file

@ -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; }
}

258
LarpixServer/Utils/Utils.cs Normal file
View file

@ -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<string> 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<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 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("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;");
}
}
}

View file

@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}

View file

@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}

View file

@ -0,0 +1,8 @@
using System.Text.Json.Serialization;
namespace LarpixVoice;
[JsonSerializable(typeof(string[]))]
internal partial class JsonAppSerializerContext : JsonSerializerContext
{
}

View file

@ -0,0 +1,10 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<NoDefaultLaunchSettingsFile>True</NoDefaultLaunchSettingsFile>
</PropertyGroup>
</Project>

95
LarpixVoice/Program.cs Normal file
View file

@ -0,0 +1,95 @@
using System.Collections.Concurrent;
namespace LarpixVoice;
public class Program
{
public static ConcurrentDictionary<string, ConcurrentDictionary<ulong, Session>> 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<Task> 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<string>(), JsonAppSerializerContext.Default.StringArray);
}
return;
default:
await next();
return;
}
}
catch (Exception)
{
}
});
Console.WriteLine("Starting server at port: " + Utils.PORT);
app.Run();
}
}

124
LarpixVoice/Requests.cs Normal file
View file

@ -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<ulong, Session>());
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<byte>(packet.Buffer, 0, packet.Length),
WebSocketMessageType.Binary, true, CancellationToken.None);
Interlocked.Add(ref TotalBytesSent, (ulong)packet.Length);
}
}
catch
{
}
finally
{
ArrayPool<byte>.Shared.Return(packet.Buffer);
}
}
});
var receiveBuffer = ArrayPool<byte>.Shared.Rent(1024 * 1024);
try
{
while (webSocket.State == WebSocketState.Open)
{
int totalBytes = 0;
WebSocketReceiveResult result;
do
{
result = await webSocket.ReceiveAsync(
new ArraySegment<byte>(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<byte>.Shared.Rent(payloadLength);
BitConverter.TryWriteBytes(payload, clientId);
Array.Copy(receiveBuffer, 0, payload, 8, totalBytes);
if (!client.SendQueue.Writer.TryWrite((payload, payloadLength)))
ArrayPool<byte>.Shared.Return(payload);
}
}
}
}
}
catch (WebSocketException)
{
}
finally
{
ArrayPool<byte>.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 _);
}
}
}

19
LarpixVoice/Session.cs Normal file
View file

@ -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
});
}
}

42
LarpixVoice/Utils.cs Normal file
View file

@ -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;
}
}

View file

@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}

View file

@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}

1
README.MD Normal file
View file

@ -0,0 +1 @@
# Larpix Server