migrate admin panel to actual accs, configs, etc (closes #16 and #13)

This commit is contained in:
Lapis
2026-06-18 11:17:50 -04:00
parent 004f1921a6
commit 3794e6e0ab
33 changed files with 1552 additions and 1255 deletions
+1
View File
@@ -10,3 +10,4 @@ CannedNet/Data/PrivateKey.pem
/CannedNet/.zed
CannedNet/Data/PublicKey.pem
CannedNet.Admin/admin_users.json
CannedNet/config.json
@@ -1,7 +1,4 @@
@using CannedNet.Admin.Services
@inherits LayoutComponentBase
@inject AdminAuthService AuthService
@inject NavigationManager Navigation
@inherits LayoutComponentBase
@{
var body = Body;
@@ -16,18 +16,6 @@
</NavLink>
</div>
<div class="nav-section-label px-3 mt-3 mb-1 text-muted small text-uppercase">Setup</div>
<div class="nav-item px-3">
<NavLink class="nav-link" href="setup/nameserver">
<span class="bi bi-server-nav-menu" aria-hidden="true"></span> Nameserver
</NavLink>
</div>
<div class="nav-item px-3">
<NavLink class="nav-link" href="setup/accounts">
<span class="bi bi-people-fill-nav-menu" aria-hidden="true"></span> Admin Accounts
</NavLink>
</div>
<div class="nav-section-label px-3 mt-3 mb-1 text-muted small text-uppercase">Users</div>
<div class="nav-item px-3">
<NavLink class="nav-link" href="users/search">
@@ -41,18 +29,8 @@
<span class="bi bi-building-nav-menu" aria-hidden="true"></span> Search Rooms
</NavLink>
</div>
<div class="nav-item px-3">
<NavLink class="nav-link" href="rooms/create">
<span class="bi bi-plus-circle-fill-nav-menu" aria-hidden="true"></span> Create Room
</NavLink>
</div>
<div class="nav-section-label px-3 mt-3 mb-1 text-muted small text-uppercase">Config</div>
<div class="nav-item px-3">
<NavLink class="nav-link" href="config/nameserver">
<span class="bi bi-diagram-3-fill-nav-menu" aria-hidden="true"></span> NS Config
</NavLink>
</div>
<div class="nav-item px-3">
<NavLink class="nav-link" href="config/gameconfig">
<span class="bi bi-sliders-nav-menu" aria-hidden="true"></span> Game Config
@@ -1,13 +1,8 @@
@using CannedNet.Admin.Services
@inject AdminAuthService AuthService
@inject NavigationManager Navigation
@code {
protected override void OnInitialized()
{
if (!AuthService.HasAnyUser())
Navigation.NavigateTo("/setup", forceLoad: true);
else
Navigation.NavigateTo("/login", forceLoad: true);
Navigation.NavigateTo("/login", forceLoad: true);
}
}
@@ -1,9 +1,7 @@
@page "/change-password"
@attribute [Authorize]
@rendermode InteractiveServer
@using System.Security.Claims
@using CannedNet.Admin.Services
@inject AdminAuthService AuthService
@inject HttpClient Http
@inject NavigationManager Navigation
@inject IJSRuntime JSRuntime
@@ -44,8 +42,6 @@
</div>
@code {
[CascadingParameter] private Task<AuthenticationState>? AuthState { get; set; }
private string currentPassword = "";
private string newPassword = "";
private string confirmNewPassword = "";
@@ -75,17 +71,15 @@
return;
}
var state = await AuthState!;
var userIdClaim = state.User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (userIdClaim == null || !int.TryParse(userIdClaim, out var userId))
var content = new FormUrlEncodedContent(new Dictionary<string, string>
{
message = "Error: Could not identify user.";
return;
}
["newPassword"] = newPassword
});
if (!AuthService.ChangePassword(userId, currentPassword, newPassword))
var response = await Http.PutAsync("/accounts/me/changepassword", content);
if (!response.IsSuccessStatusCode)
{
message = "Error: Current password is incorrect.";
message = "Error: Failed to change password.";
return;
}
@@ -1,115 +0,0 @@
@page "/config/nameserver"
@rendermode InteractiveServer
@using CannedNet.Admin.Models
@using System.Text.Json
<PageTitle>NS Config</PageTitle>
<h3>Nameserver (NS) Config</h3>
<p class="text-muted">Manage the nameserver endpoints.json file that controls where the game client connects.</p>
<div class="alert alert-info">
This is the same editor as the <a href="setup/nameserver">Setup &gt; Nameserver</a> page.
All endpoint URLs are derived from a single base URL.
</div>
@if (loading)
{
<div class="spinner-border" role="status"></div>
}
else if (!string.IsNullOrEmpty(error))
{
<div class="alert alert-danger">@error</div>
}
else
{
<form>
<div class="mb-3">
<button type="submit" class="btn btn-success" @onclick="Save" disabled="@saving">
@(saving ? "Saving..." : "Save Changes")
</button>
@if (!string.IsNullOrEmpty(saveMessage))
{
<span class="@(saveError ? "text-danger" : "text-success") ms-2">@saveMessage</span>
}
</div>
<div class="mb-3">
<label class="form-label" for="baseUrl">Base URL:</label>
<input id="baseUrl" class="form-control font-monospace" @bind="baseUrl" placeholder="https://cannednet.lapis.codes" />
<div class="form-text">All endpoint URLs will use this as the base.</div>
</div>
<div class="table-responsive">
<table class="table table-sm table-striped">
<thead>
<tr>
<th>Service</th>
<th>Full URL</th>
</tr>
</thead>
<tbody>
@foreach (var (name, path) in EndpointDefinitions.Endpoints)
{
<tr>
<td><code>@name</code></td>
<td><code>@(baseUrl.TrimEnd('/') + path)</code></td>
</tr>
}
</tbody>
</table>
</div>
</form>
}
@code {
[Inject] private HttpClient Http { get; set; } = default!;
private bool loading = true;
private bool saving;
private bool saveError;
private string? error;
private string? saveMessage;
private string baseUrl = "";
protected override async Task OnInitializedAsync()
{
try
{
var json = await Http.GetStringAsync("/admin/api/endpoints");
baseUrl = EndpointDefinitions.ExtractBaseUrl(json);
}
catch (Exception ex)
{
error = $"Failed to load: {ex.Message}";
}
finally
{
loading = false;
}
}
private async Task Save()
{
saving = true;
saveMessage = null;
saveError = false;
try
{
var json = EndpointDefinitions.BuildEndpointsJson(baseUrl);
var content = new StringContent(json, System.Text.Encoding.UTF8, "application/json");
var response = await Http.PutAsync("/admin/api/endpoints", content);
response.EnsureSuccessStatusCode();
saveMessage = "Saved successfully!";
}
catch (Exception ex)
{
saveMessage = $"Error: {ex.Message}";
saveError = true;
}
finally
{
saving = false;
}
}
}
@@ -8,15 +8,6 @@
<p class="lead text-muted">Manage your CannedNet Rec Room private server instance.</p>
<div class="row g-4 mt-3">
<div class="col-md-4">
<div class="card">
<div class="card-body">
<h5 class="card-title">Setup</h5>
<p class="card-text">Configure nameserver endpoints, admin accounts, and authentication.</p>
<a href="setup/nameserver" class="btn btn-primary">Go to Setup</a>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card">
<div class="card-body">
+21 -40
View File
@@ -1,52 +1,33 @@
@page "/login"
@rendermode InteractiveServer
@using CannedNet.Admin.Services
@inject AdminAuthService AuthService
@inject NavigationManager Navigation
<PageTitle>Login</PageTitle>
@if (AuthService.HasAnyUser() == false)
{
<div class="row justify-content-center mt-5">
<div class="col-md-4">
<div class="card">
<div class="card-body text-center">
<h4>No Admin Accounts</h4>
<p class="text-muted">No admin accounts exist yet. Complete the first-time setup.</p>
<a href="/setup" class="btn btn-primary">Go to Setup</a>
</div>
<div class="row justify-content-center mt-5">
<div class="col-md-4">
<div class="card">
<div class="card-header"><h4 class="mb-0">Admin Login</h4></div>
<div class="card-body">
<form method="post" action="/admin/login">
@if (errorMessage != null)
{
<div class="alert alert-danger">@errorMessage</div>
}
<div class="mb-3">
<label class="form-label">Username</label>
<input type="text" name="username" class="form-control" required />
</div>
<div class="mb-3">
<label class="form-label">Password</label>
<input type="password" name="password" class="form-control" required />
</div>
<button type="submit" class="btn btn-primary w-100">Login</button>
</form>
</div>
</div>
</div>
}
else
{
<div class="row justify-content-center mt-5">
<div class="col-md-4">
<div class="card">
<div class="card-header"><h4 class="mb-0">Admin Login</h4></div>
<div class="card-body">
<form method="post" action="/admin/login">
@if (errorMessage != null)
{
<div class="alert alert-danger">@errorMessage</div>
}
<div class="mb-3">
<label class="form-label">Username</label>
<input type="text" name="username" class="form-control" required />
</div>
<div class="mb-3">
<label class="form-label">Password</label>
<input type="password" name="password" class="form-control" required />
</div>
<button type="submit" class="btn btn-primary w-100">Login</button>
</form>
</div>
</div>
</div>
</div>
}
</div>
@code {
private string? errorMessage;
@@ -1,176 +0,0 @@
@page "/rooms/create"
@rendermode InteractiveServer
<PageTitle>Create Room</PageTitle>
<h3>Create Room</h3>
<p class="text-muted">Create a new room and optionally a default subroom.</p>
<form>
<div class="row">
<div class="col-md-6">
<div class="card mb-3">
<div class="card-header">Room Details</div>
<div class="card-body">
<div class="mb-3">
<label class="form-label">Room ID (leave 0 for auto)</label>
<input type="number" class="form-control" @bind="roomId" />
</div>
<div class="mb-3">
<label class="form-label">Name <span class="text-danger">*</span></label>
<input type="text" class="form-control" @bind="name" required />
</div>
<div class="mb-3">
<label class="form-label">Description</label>
<textarea class="form-control" rows="3" @bind="description"></textarea>
</div>
<div class="mb-3">
<label class="form-label">Creator Account ID</label>
<input type="number" class="form-control" @bind="creatorAccountId" />
</div>
<div class="mb-3">
<label class="form-label">Image Name</label>
<input type="text" class="form-control" @bind="imageName" />
</div>
<div class="mb-3">
<label class="form-label">Accessibility</label>
<input type="number" class="form-control" @bind="accessibility" />
</div>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card mb-3">
<div class="card-header">SubRoom</div>
<div class="card-body">
<div class="form-check mb-3">
<input type="checkbox" class="form-check-input" @bind="createSubRoom" />
<label class="form-check-label">Create a default subroom</label>
</div>
@if (createSubRoom)
{
<div class="mb-3">
<label class="form-label">SubRoom Name</label>
<input type="text" class="form-control" @bind="subRoomName" />
</div>
<div class="mb-3">
<label class="form-label">Max Players</label>
<input type="number" class="form-control" @bind="maxPlayers" />
</div>
}
</div>
</div>
<div class="card mb-3">
<div class="card-header">Platform Support</div>
<div class="card-body">
<div class="form-check">
<input type="checkbox" class="form-check-input" @bind="supportsVRLow" />
<label class="form-check-label">VR Low</label>
</div>
<div class="form-check">
<input type="checkbox" class="form-check-input" @bind="supportsQuest2" />
<label class="form-check-label">Quest 2</label>
</div>
<div class="form-check">
<input type="checkbox" class="form-check-input" @bind="supportsMobile" />
<label class="form-check-label">Mobile</label>
</div>
<div class="form-check">
<input type="checkbox" class="form-check-input" @bind="supportsScreens" />
<label class="form-check-label">Screens</label>
</div>
<div class="form-check">
<input type="checkbox" class="form-check-input" @bind="supportsWalkVR" />
<label class="form-check-label">Walk VR</label>
</div>
<div class="form-check">
<input type="checkbox" class="form-check-input" @bind="supportsTeleportVR" />
<label class="form-check-label">Teleport VR</label>
</div>
<div class="form-check">
<input type="checkbox" class="form-check-input" @bind="supportsJuniors" />
<label class="form-check-label">Supports Juniors</label>
</div>
</div>
</div>
</div>
</div>
<button type="submit" class="btn btn-primary" @onclick="CreateRoom" disabled="@creating">
@(creating ? "Creating..." : "Create Room")
</button>
<a href="rooms/search" class="btn btn-outline-secondary ms-2">Cancel</a>
@if (!string.IsNullOrEmpty(message))
{
<div class="alert mt-3 @(message.Contains("Error") ? "alert-danger" : "alert-success")">@message</div>
}
@if (createdRoom != null)
{
<div class="alert alert-success mt-2">
Room created! <a href="rooms/edit/@createdRoom.RoomId">Edit Room #@createdRoom.RoomId</a>
</div>
}
</form>
@code {
[Inject] private HttpClient Http { get; set; } = default!;
private bool creating;
private string? message;
private Room? createdRoom;
private int roomId;
private string name = "";
private string description = "";
private int creatorAccountId;
private string imageName = "";
private int accessibility;
private bool createSubRoom = true;
private string subRoomName = "";
private int maxPlayers = 16;
private bool supportsVRLow = true;
private bool supportsQuest2 = true;
private bool supportsMobile = true;
private bool supportsScreens = true;
private bool supportsWalkVR = true;
private bool supportsTeleportVR = true;
private bool supportsJuniors = true;
private async Task CreateRoom()
{
creating = true;
message = null;
try
{
var payload = new
{
roomId = roomId > 0 ? roomId : 0,
name,
description,
creatorAccountId,
imageName,
accessibility,
createSubRoom,
subRoomName = createSubRoom ? (string.IsNullOrWhiteSpace(subRoomName) ? name : subRoomName) : "",
maxPlayers
};
var response = await Http.PostAsJsonAsync("/admin/api/rooms", payload);
response.EnsureSuccessStatusCode();
createdRoom = await response.Content.ReadFromJsonAsync<Room>();
message = $"Room '{createdRoom?.Name}' created successfully!";
}
catch (Exception ex)
{
message = $"Error: {ex.Message}";
}
finally
{
creating = false;
}
}
}
@@ -1,144 +0,0 @@
@page "/setup/accounts"
@attribute [Authorize(Policy = "OwnerOnly")]
@rendermode InteractiveServer
@using CannedNet.Admin.Models
@using CannedNet.Admin.Services
@inject AdminAuthService AuthService
<PageTitle>Admin Accounts</PageTitle>
<h3>Admin Accounts</h3>
<p class="text-muted">Manage admin panel accounts. Only the owner can create or delete accounts.</p>
<div class="card mb-4">
<div class="card-header">Create New Admin Account</div>
<div class="card-body">
<form>
<div class="row">
<div class="col-md-4 mb-3">
<label class="form-label">Username</label>
<input type="text" class="form-control" @bind="newUsername" required />
</div>
<div class="col-md-4 mb-3">
<label class="form-label">Password</label>
<input type="password" class="form-control" @bind="newPassword" required minlength="6" />
</div>
<div class="col-md-4 mb-3">
<label class="form-label">Confirm Password</label>
<input type="password" class="form-control" @bind="newConfirmPassword" required />
</div>
</div>
<button type="submit" class="btn btn-success" @onclick="CreateAccount" disabled="@creating">
@(creating ? "Creating..." : "Create Account")
</button>
@if (!string.IsNullOrEmpty(createMessage))
{
<span class="ms-2 @(createMessage.Contains("Error") ? "text-danger" : "text-success")">@createMessage</span>
}
</form>
</div>
</div>
<div class="card">
<div class="card-header">Admin Accounts</div>
<div class="card-body">
<table class="table table-striped">
<thead>
<tr>
<th>ID</th>
<th>Username</th>
<th>Role</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@foreach (var user in AuthService.GetAll())
{
<tr>
<td>@user.Id</td>
<td>@user.Username</td>
<td>
@if (user.IsOwner)
{
<td>Owner</td>
}
else
{
<td>Admin</td>
}
</td>
<td>@user.CreatedAt.ToString("g")</td>
<td>
@if (!user.IsOwner)
{
<button class="btn btn-sm btn-danger" @onclick="() => DeleteUser(user.Id)">Delete</button>
}
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
@code {
private string newUsername = "";
private string newPassword = "";
private string newConfirmPassword = "";
private bool creating;
private string? createMessage;
private async Task CreateAccount()
{
creating = true;
createMessage = null;
try
{
if (string.IsNullOrWhiteSpace(newUsername))
{
createMessage = "Username is required.";
return;
}
if (string.IsNullOrWhiteSpace(newPassword) || newPassword.Length < 6)
{
createMessage = "Password must be at least 6 characters.";
return;
}
if (newPassword != newConfirmPassword)
{
createMessage = "Passwords do not match.";
return;
}
if (AuthService.GetByUsername(newUsername) != null)
{
createMessage = "Username already exists.";
return;
}
AuthService.CreateUser(newUsername, newPassword, isOwner: false);
createMessage = "Admin account created successfully!";
newUsername = "";
newPassword = "";
newConfirmPassword = "";
}
catch (Exception ex)
{
createMessage = $"Error: {ex.Message}";
}
finally
{
creating = false;
}
}
private async Task DeleteUser(int id)
{
var confirmed = await JSRuntime.InvokeAsync<bool>("confirm", "Delete this admin account?");
if (!confirmed) return;
AuthService.DeleteUser(id);
}
[Inject] private IJSRuntime JSRuntime { get; set; } = default!;
}
@@ -1,110 +0,0 @@
@page "/setup"
@rendermode InteractiveServer
@using CannedNet.Admin.Services
@inject AdminAuthService AuthService
@inject NavigationManager Navigation
<PageTitle>First-Time Setup</PageTitle>
<div class="row justify-content-center mt-5">
<div class="col-md-6">
<div class="card">
<div class="card-header"><h4 class="mb-0">First-Time Setup</h4></div>
<div class="card-body">
@if (completed)
{
<div class="alert alert-success">
<h5>Owner account created!</h5>
<p>You can now <a href="/login">log in</a> with your new admin account.</p>
</div>
}
else if (AuthService.HasAnyUser())
{
<div class="alert alert-info">
Admin accounts already exist. <a href="/login">Go to login</a>.
</div>
}
else
{
<p class="text-muted">Create the initial owner account. This account will have full access and can create additional admin accounts.</p>
<form>
<div class="mb-3">
<label class="form-label">Username</label>
<input type="text" class="form-control" @bind="username" required />
</div>
<div class="mb-3">
<label class="form-label">Password</label>
<input type="password" class="form-control" @bind="password" required minlength="6" />
</div>
<div class="mb-3">
<label class="form-label">Confirm Password</label>
<input type="password" class="form-control" @bind="confirmPassword" required />
</div>
<button type="submit" class="btn btn-primary w-100" @onclick="CreateOwner" disabled="@creating">
@(creating ? "Creating..." : "Create Owner Account")
</button>
@if (!string.IsNullOrEmpty(message))
{
<div class="alert mt-3 @(message.Contains("Error") ? "alert-danger" : "alert-success")">@message</div>
}
</form>
}
</div>
</div>
</div>
</div>
@code {
private string username = "";
private string password = "";
private string confirmPassword = "";
private bool creating;
private bool completed;
private string? message;
private async Task CreateOwner()
{
creating = true;
message = null;
try
{
if (string.IsNullOrWhiteSpace(username))
{
message = "Username is required.";
return;
}
if (string.IsNullOrWhiteSpace(password) || password.Length < 6)
{
message = "Password must be at least 6 characters.";
return;
}
if (password != confirmPassword)
{
message = "Passwords do not match.";
return;
}
if (AuthService.HasAnyUser())
{
message = "Admin accounts already exist.";
return;
}
if (AuthService.GetByUsername(username) != null)
{
message = "Username already taken.";
return;
}
AuthService.CreateUser(username, password, isOwner: true);
completed = true;
}
catch (Exception ex)
{
message = $"Error: {ex.Message}";
}
finally
{
creating = false;
}
}
}
@@ -1,110 +0,0 @@
@page "/setup/nameserver"
@rendermode InteractiveServer
@using CannedNet.Admin.Models
@using System.Text.Json
<PageTitle>Nameserver Setup</PageTitle>
<h3>Nameserver Endpoints</h3>
<p class="text-muted">Configure the base server URL the game client connects to. All endpoints are derived from this URL.</p>
@if (loading)
{
<div class="spinner-border" role="status"></div>
}
else if (!string.IsNullOrEmpty(error))
{
<div class="alert alert-danger">@error</div>
}
else
{
<form>
<div class="mb-3">
<button type="submit" class="btn btn-success" @onclick="Save" disabled="@saving">
@(saving ? "Saving..." : "Save Changes")
</button>
@if (!string.IsNullOrEmpty(saveMessage))
{
<span class="@(saveError ? "text-danger" : "text-success") ms-2">@saveMessage</span>
}
</div>
<div class="mb-3">
<label class="form-label" for="baseUrl">Base URL:</label>
<input id="baseUrl" class="form-control font-monospace" @bind="baseUrl" placeholder="https://cannednet.lapis.codes" />
<div class="form-text">All endpoint URLs will use this as the base.</div>
</div>
<div class="table-responsive">
<table class="table table-sm table-striped">
<thead>
<tr>
<th>Service</th>
<th>Full URL</th>
</tr>
</thead>
<tbody>
@foreach (var (name, path) in EndpointDefinitions.Endpoints)
{
<tr>
<td><code>@name</code></td>
<td><code>@(baseUrl.TrimEnd('/') + path)</code></td>
</tr>
}
</tbody>
</table>
</div>
</form>
}
@code {
[Inject] private HttpClient Http { get; set; } = default!;
private bool loading = true;
private bool saving;
private bool saveError;
private string? error;
private string? saveMessage;
private string baseUrl = "";
protected override async Task OnInitializedAsync()
{
try
{
var json = await Http.GetStringAsync("/admin/api/endpoints");
baseUrl = EndpointDefinitions.ExtractBaseUrl(json);
}
catch (Exception ex)
{
error = $"Failed to load: {ex.Message}";
}
finally
{
loading = false;
}
}
private async Task Save()
{
saving = true;
saveMessage = null;
saveError = false;
try
{
var json = EndpointDefinitions.BuildEndpointsJson(baseUrl);
var content = new StringContent(json, System.Text.Encoding.UTF8, "application/json");
var response = await Http.PutAsync("/admin/api/endpoints", content);
response.EnsureSuccessStatusCode();
saveMessage = "Saved successfully!";
}
catch (Exception ex)
{
saveMessage = $"Error: {ex.Message}";
saveError = true;
}
finally
{
saving = false;
}
}
}
-21
View File
@@ -1,21 +0,0 @@
using System.Text.Json.Serialization;
namespace CannedNet.Admin.Models;
public class AdminUser
{
[JsonPropertyName("id")]
public int Id { get; set; }
[JsonPropertyName("username")]
public string Username { get; set; } = "";
[JsonPropertyName("passwordHash")]
public string PasswordHash { get; set; } = "";
[JsonPropertyName("isOwner")]
public bool IsOwner { get; set; }
[JsonPropertyName("createdAt")]
public DateTime CreatedAt { get; set; }
}
+61 -16
View File
@@ -1,8 +1,9 @@
using System.Security.Claims;
using System.Text;
using System.Text.Json;
using CannedNet.Admin.Components;
using CannedNet.Admin.Services;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
var builder = WebApplication.CreateBuilder(args);
@@ -23,22 +24,17 @@ builder.Services.AddAuthentication("AdminCookie")
options.SlidingExpiration = true;
});
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("OwnerOnly", policy => policy.RequireClaim("role", "Owner"));
});
builder.Services.AddAuthorization();
builder.Services.AddSingleton<AdminAuthService>();
var imagesBaseUrl = builder.Configuration.GetValue<string>("ImagesBaseUrl") ?? "http://localhost:5000/img";
builder.Services.AddSingleton(new ImageUrlService(imagesBaseUrl));
builder.Services.AddHttpContextAccessor();
var serverUrl = builder.Configuration.GetValue<string>("ServerBaseUrl") ?? "http://localhost:5000";
builder.Services.AddTransient<AdminApiKeyHandler>();
builder.Services.AddTransient<JwtBearerHandler>();
builder.Services.AddScoped(sp =>
{
var handler = sp.GetRequiredService<AdminApiKeyHandler>();
var handler = sp.GetRequiredService<JwtBearerHandler>();
handler.InnerHandler = new SocketsHttpHandler();
return new HttpClient(handler) { BaseAddress = new Uri(serverUrl) };
});
@@ -47,7 +43,7 @@ var app = builder.Build();
// --- Admin Auth Endpoints ---
app.MapPost("/admin/login", async (HttpContext context, AdminAuthService authService) =>
app.MapPost("/admin/login", async (HttpContext context) =>
{
var form = await context.Request.ReadFormAsync();
var username = form["username"].FirstOrDefault() ?? "";
@@ -59,20 +55,55 @@ app.MapPost("/admin/login", async (HttpContext context, AdminAuthService authSer
return;
}
var user = authService.GetByUsername(username);
if (user == null || !authService.VerifyPassword(password, user.PasswordHash))
var serverUrl = app.Services.GetRequiredService<IConfiguration>().GetValue<string>("ServerBaseUrl") ?? "http://localhost:5000";
using var httpClient = new HttpClient { BaseAddress = new Uri(serverUrl) };
var tokenResponse = await httpClient.PostAsync("/auth/connect/token", new FormUrlEncodedContent(new Dictionary<string, string>
{
["grant_type"] = "password",
["username"] = username,
["password"] = password
}));
if (!tokenResponse.IsSuccessStatusCode)
{
context.Response.Redirect("/login?error=invalid");
return;
}
var claims = new[]
var json = await tokenResponse.Content.ReadAsStringAsync();
using var doc = System.Text.Json.JsonDocument.Parse(json);
var accessToken = doc.RootElement.GetProperty("access_token").GetString();
if (string.IsNullOrEmpty(accessToken))
{
new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()),
new Claim(ClaimTypes.Name, user.Username),
new Claim("role", user.IsOwner ? "Owner" : "Admin")
context.Response.Redirect("/login?error=invalid");
return;
}
var payloadJson = DecodeJwtPayload(accessToken);
using var payload = JsonDocument.Parse(payloadJson);
var accountId = payload.RootElement.TryGetProperty("sub", out var sub) ? sub.GetString() ?? "" : "";
var role = "";
if (payload.RootElement.TryGetProperty("role", out var r))
{
if (r.ValueKind == JsonValueKind.String)
role = r.GetString() ?? "";
else if (r.ValueKind == JsonValueKind.Array)
role = string.Join(",", r.EnumerateArray().Select(x => x.GetString() ?? ""));
}
var name = payload.RootElement.TryGetProperty("platform_id", out var pid) ? pid.GetString() ?? username : username;
var claims = new List<Claim>
{
new(ClaimTypes.NameIdentifier, accountId),
new(ClaimTypes.Name, name),
new("access_token", accessToken)
};
// Map JWT roles for admin app authorization
if (role.Split(',', StringSplitOptions.RemoveEmptyEntries).Any(rr => rr == "developer" || rr == "admin"))
claims.Add(new Claim(ClaimTypes.Role, "Admin"));
var identity = new ClaimsIdentity(claims, "AdminCookie");
var principal = new ClaimsPrincipal(identity);
@@ -111,3 +142,17 @@ app.MapRazorComponents<App>()
.AddInteractiveServerRenderMode();
app.Run();
static string DecodeJwtPayload(string jwt)
{
var parts = jwt.Split('.');
if (parts.Length < 2) return "{}";
var payload = parts[1];
switch (payload.Length % 4)
{
case 2: payload += "=="; break;
case 3: payload += "="; break;
}
var bytes = Convert.FromBase64String(payload);
return Encoding.UTF8.GetString(bytes);
}
@@ -1,21 +0,0 @@
namespace CannedNet.Admin.Services;
public class AdminApiKeyHandler : DelegatingHandler
{
private readonly string _apiKey;
public AdminApiKeyHandler(IConfiguration config)
{
_apiKey = config.GetValue<string>("Admin:ApiKey")
?? config.GetValue<string>("AdminApiKey")
?? "";
}
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
if (!string.IsNullOrEmpty(_apiKey))
request.Headers.Add("X-Admin-Api-Key", _apiKey);
return await base.SendAsync(request, cancellationToken);
}
}
@@ -1,127 +0,0 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using CannedNet.Admin.Models;
namespace CannedNet.Admin.Services;
public class AdminAuthService
{
private readonly string _filePath;
private List<AdminUser> _users = new();
private readonly object _lock = new();
private int _nextId = 1;
public AdminAuthService(IWebHostEnvironment env)
{
_filePath = Path.Combine(env.ContentRootPath, "admin_users.json");
Load();
}
public bool HasAnyUser()
{
lock (_lock) { return _users.Count > 0; }
}
public AdminUser? GetById(int id)
{
lock (_lock) { return _users.FirstOrDefault(u => u.Id == id); }
}
public AdminUser? GetByUsername(string username)
{
lock (_lock) { return _users.FirstOrDefault(u => u.Username.Equals(username, StringComparison.OrdinalIgnoreCase)); }
}
public List<AdminUser> GetAll()
{
lock (_lock) { return _users.OrderBy(u => u.Id).ToList(); }
}
public bool IsOwner(int id)
{
lock (_lock) { return _users.Any(u => u.Id == id && u.IsOwner); }
}
public AdminUser CreateUser(string username, string password, bool isOwner)
{
lock (_lock)
{
var user = new AdminUser
{
Id = _nextId++,
Username = username,
PasswordHash = HashPassword(password),
IsOwner = isOwner,
CreatedAt = DateTime.UtcNow
};
_users.Add(user);
Save();
return user;
}
}
public bool ChangePassword(int id, string oldPassword, string newPassword)
{
lock (_lock)
{
var user = _users.FirstOrDefault(u => u.Id == id);
if (user == null) return false;
if (user.PasswordHash != HashPassword(oldPassword)) return false;
user.PasswordHash = HashPassword(newPassword);
Save();
return true;
}
}
public bool DeleteUser(int id)
{
lock (_lock)
{
var user = _users.FirstOrDefault(u => u.Id == id);
if (user == null) return false;
_users.Remove(user);
Save();
return true;
}
}
public bool VerifyPassword(string password, string hash)
{
return HashPassword(password) == hash;
}
private string HashPassword(string password)
{
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(password + "CannedNetAdminSalt!@#$"));
return Convert.ToHexString(bytes).ToLowerInvariant();
}
private void Load()
{
try
{
if (File.Exists(_filePath))
{
var json = File.ReadAllText(_filePath);
var users = JsonSerializer.Deserialize<List<AdminUser>>(json);
if (users != null && users.Count > 0)
{
_users = users;
_nextId = _users.Max(u => u.Id) + 1;
}
}
}
catch { }
}
private void Save()
{
try
{
var json = JsonSerializer.Serialize(_users, new JsonSerializerOptions { WriteIndented = true });
File.WriteAllText(_filePath, json);
}
catch { }
}
}
@@ -0,0 +1,24 @@
using System.Net.Http.Headers;
using Microsoft.AspNetCore.Components.Authorization;
namespace CannedNet.Admin.Services;
public class JwtBearerHandler : DelegatingHandler
{
private readonly AuthenticationStateProvider _authStateProvider;
public JwtBearerHandler(AuthenticationStateProvider authStateProvider)
{
_authStateProvider = authStateProvider;
}
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
var state = await _authStateProvider.GetAuthenticationStateAsync();
var token = state.User.FindFirst("access_token")?.Value;
if (token != null)
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
return await base.SendAsync(request, cancellationToken);
}
}
@@ -13,4 +13,5 @@ public class Account
[JsonPropertyName("username")] public string? Username { get; set; }
[JsonPropertyName("displayName")] public string? DisplayName { get; set; }
[JsonPropertyName("createdAt")] public DateTime CreatedAt { get; set; }
[JsonPropertyName("password")] public string? Password { get; set; }
}
+1 -1
View File
@@ -14,7 +14,7 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.7" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.11" />
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerGen" Version="10.2.1" />
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" Version="10.2.1" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.19.1" />
+1 -1
View File
@@ -35,4 +35,4 @@
"Thorn": "https://cannednet.lapis.codes/thorn",
"Videos": "https://cannednet.lapis.codes/videos",
"WWW": "https://cannednet.lapis.codes"
}
}
@@ -0,0 +1,1137 @@
// <auto-generated />
using System;
using System.Collections.Generic;
using CannedNet.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace CannedNet.Migrations
{
[DbContext(typeof(AppDbContext))]
[Migration("20260618140741_AddAccountPassword")]
partial class AddAccountPassword
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.9")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("CannedNet.Account", b =>
{
b.Property<int>("AccountId")
.HasColumnType("integer")
.HasJsonPropertyName("accountId");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasJsonPropertyName("createdAt");
b.Property<string>("DisplayName")
.IsRequired()
.HasColumnType("text")
.HasJsonPropertyName("displayName");
b.Property<int?>("IdentityFlags")
.HasColumnType("integer")
.HasJsonPropertyName("identityFlags");
b.Property<bool>("IsJunior")
.HasColumnType("boolean")
.HasJsonPropertyName("isJunior");
b.Property<string>("Password")
.HasColumnType("text")
.HasJsonPropertyName("password");
b.Property<int?>("PersonalPronouns")
.HasColumnType("integer")
.HasJsonPropertyName("personalPronouns");
b.Property<int?>("Platforms")
.HasColumnType("integer")
.HasJsonPropertyName("platforms");
b.Property<string>("ProfileImage")
.HasColumnType("text")
.HasJsonPropertyName("profileImage");
b.Property<string>("Username")
.IsRequired()
.HasColumnType("text")
.HasJsonPropertyName("username");
b.HasKey("AccountId");
b.ToTable("accounts", (string)null);
});
modelBuilder.Entity("CannedNet.AvatarItem", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("AvatarItemDesc")
.IsRequired()
.HasColumnType("text")
.HasJsonPropertyName("avatarItemDesc");
b.Property<int>("AvatarItemType")
.HasColumnType("integer")
.HasJsonPropertyName("avatarItemType");
b.Property<string>("FriendlyName")
.IsRequired()
.HasColumnType("text")
.HasJsonPropertyName("friendlyName");
b.Property<int>("OwnerAccountId")
.HasColumnType("integer");
b.Property<int>("PlatformMask")
.HasColumnType("integer")
.HasJsonPropertyName("platformMask");
b.Property<int>("Rarity")
.HasColumnType("integer")
.HasJsonPropertyName("rarity");
b.Property<string>("Tooltip")
.IsRequired()
.HasColumnType("text")
.HasJsonPropertyName("tooltip");
b.HasKey("Id");
b.HasIndex("OwnerAccountId");
b.ToTable("avatar_items", (string)null);
});
modelBuilder.Entity("CannedNet.CachedLogin", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("AccountId")
.HasColumnType("integer")
.HasJsonPropertyName("accountId");
b.Property<DateTime>("LastLoginTime")
.HasColumnType("timestamp with time zone")
.HasJsonPropertyName("lastLoginTime");
b.Property<int>("Platform")
.HasColumnType("integer")
.HasJsonPropertyName("platform");
b.Property<string>("PlatformID")
.IsRequired()
.HasColumnType("text")
.HasJsonPropertyName("platformId");
b.Property<bool>("RequirePassword")
.HasColumnType("boolean")
.HasJsonPropertyName("requirePassword");
b.HasKey("Id");
b.HasIndex("Platform", "PlatformID");
b.ToTable("cached_logins", (string)null);
});
modelBuilder.Entity("CannedNet.LoadScreen", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ImageUrl")
.IsRequired()
.HasColumnType("text")
.HasJsonPropertyName("imageUrl");
b.Property<bool>("IsThumbnail")
.HasColumnType("boolean")
.HasJsonPropertyName("isThumbnail");
b.Property<int>("RoomId")
.HasColumnType("integer");
b.Property<string>("Tooltip")
.IsRequired()
.HasColumnType("text")
.HasJsonPropertyName("tooltip");
b.HasKey("Id");
b.HasIndex("RoomId");
b.ToTable("load_screens", (string)null);
});
modelBuilder.Entity("CannedNet.Models.ConsumableItem", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("ActiveDurationMinutes")
.HasColumnType("integer")
.HasJsonPropertyName("ActiveDurationMinutes");
b.Property<string>("ConsumableItemDesc")
.IsRequired()
.HasColumnType("text")
.HasJsonPropertyName("ConsumableItemDesc");
b.Property<int>("Count")
.HasColumnType("integer")
.HasJsonPropertyName("Count");
b.PrimitiveCollection<List<DateTime>>("CreatedAts")
.IsRequired()
.HasColumnType("timestamp with time zone[]")
.HasJsonPropertyName("CreatedAts");
b.PrimitiveCollection<List<int>>("Ids")
.IsRequired()
.HasColumnType("integer[]")
.HasJsonPropertyName("Ids");
b.Property<int>("InitialCount")
.HasColumnType("integer")
.HasJsonPropertyName("InitialCount");
b.Property<bool>("IsActive")
.HasColumnType("boolean")
.HasJsonPropertyName("IsActive");
b.Property<bool>("IsTransferable")
.HasColumnType("boolean")
.HasJsonPropertyName("IsTransferable");
b.Property<int>("OwnerAccountId")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("OwnerAccountId");
b.ToTable("consumable_items", (string)null);
});
modelBuilder.Entity("CannedNet.Models.EarnableReward", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("AvatarItemDesc")
.IsRequired()
.HasColumnType("text")
.HasJsonPropertyName("avatarItemDesc");
b.Property<int>("AvatarItemType")
.HasColumnType("integer")
.HasJsonPropertyName("avatarItemType");
b.Property<string>("ConsumableItemDesc")
.IsRequired()
.HasColumnType("text")
.HasJsonPropertyName("consumableItemDesc");
b.Property<string>("FriendlyName")
.IsRequired()
.HasColumnType("text")
.HasJsonPropertyName("friendlyName");
b.Property<int>("GiftRarity")
.HasColumnType("integer")
.HasJsonPropertyName("giftRarity");
b.Property<int>("RewardContext")
.HasColumnType("integer")
.HasJsonPropertyName("rewardContext");
b.HasKey("Id");
b.HasIndex("RewardContext");
b.ToTable("earnable_rewards", (string)null);
});
modelBuilder.Entity("CannedNet.Models.GiftDrop", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("AvatarItemDesc")
.HasColumnType("text");
b.Property<int>("AvatarItemType")
.HasColumnType("integer");
b.Property<string>("ConsumableItemDesc")
.HasColumnType("text");
b.Property<int>("Context")
.HasColumnType("integer");
b.Property<int>("Currency")
.HasColumnType("integer");
b.Property<int>("CurrencyType")
.HasColumnType("integer");
b.Property<string>("EquipmentModificationGuid")
.HasColumnType("text");
b.Property<string>("EquipmentPrefabName")
.HasColumnType("text");
b.Property<string>("FriendlyName")
.IsRequired()
.HasColumnType("text");
b.Property<int>("GiftDropId")
.HasColumnType("integer");
b.Property<bool>("IsQuery")
.HasColumnType("boolean");
b.Property<string>("ItemSetFriendlyName")
.HasColumnType("text");
b.Property<int?>("ItemSetId")
.HasColumnType("integer");
b.Property<int>("Level")
.HasColumnType("integer");
b.Property<int>("Rarity")
.HasColumnType("integer");
b.Property<int>("StorefrontItemId")
.HasColumnType("integer");
b.Property<bool>("SubscribersOnly")
.HasColumnType("boolean");
b.Property<string>("Tooltip")
.HasColumnType("text");
b.Property<bool>("Unique")
.HasColumnType("boolean");
b.HasKey("Id");
b.HasIndex("StorefrontItemId");
b.ToTable("gift_drops", (string)null);
});
modelBuilder.Entity("CannedNet.Models.ReceivedGift", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("AvatarItemDesc")
.IsRequired()
.HasColumnType("text")
.HasJsonPropertyName("avatarItemDesc");
b.Property<int>("AvatarItemType")
.HasColumnType("integer")
.HasJsonPropertyName("avatarItemType");
b.Property<int>("BalanceType")
.HasColumnType("integer")
.HasJsonPropertyName("balanceType");
b.Property<string>("ConsumableItemDesc")
.IsRequired()
.HasColumnType("text")
.HasJsonPropertyName("consumableItemDesc");
b.Property<DateTime?>("ConsumedAt")
.HasColumnType("timestamp with time zone")
.HasJsonPropertyName("consumedAt");
b.Property<int>("Currency")
.HasColumnType("integer")
.HasJsonPropertyName("currency");
b.Property<int>("CurrencyType")
.HasColumnType("integer")
.HasJsonPropertyName("currencyType");
b.Property<string>("EquipmentModificationGuid")
.IsRequired()
.HasColumnType("text")
.HasJsonPropertyName("equipmentModificationGuid");
b.Property<string>("EquipmentPrefabName")
.IsRequired()
.HasColumnType("text")
.HasJsonPropertyName("equipmentPrefabName");
b.Property<string>("FriendlyName")
.IsRequired()
.HasColumnType("text")
.HasJsonPropertyName("friendlyName");
b.Property<int?>("FromPlayerId")
.HasColumnType("integer")
.HasJsonPropertyName("fromPlayerId");
b.Property<int>("GiftContext")
.HasColumnType("integer")
.HasJsonPropertyName("giftContext");
b.Property<int>("GiftRarity")
.HasColumnType("integer")
.HasJsonPropertyName("giftRarity");
b.Property<bool>("IsConsumed")
.HasColumnType("boolean")
.HasJsonPropertyName("isConsumed");
b.Property<int>("Level")
.HasColumnType("integer")
.HasJsonPropertyName("level");
b.Property<string>("Message")
.IsRequired()
.HasColumnType("text")
.HasJsonPropertyName("message");
b.Property<int>("Platform")
.HasColumnType("integer")
.HasJsonPropertyName("platform");
b.Property<int>("PlatformsToSpawnOn")
.HasColumnType("integer")
.HasJsonPropertyName("platformsToSpawnOn");
b.Property<DateTime>("ReceivedAt")
.HasColumnType("timestamp with time zone")
.HasJsonPropertyName("receivedAt");
b.Property<int>("ReceiverAccountId")
.HasColumnType("integer")
.HasJsonPropertyName("receiverAccountId");
b.Property<int>("Xp")
.HasColumnType("integer")
.HasJsonPropertyName("xp");
b.HasKey("Id");
b.HasIndex("ReceiverAccountId");
b.HasIndex("ReceiverAccountId", "IsConsumed");
b.ToTable("received_gifts", (string)null);
});
modelBuilder.Entity("CannedNet.Models.Storefront", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime>("NextUpdate")
.HasColumnType("timestamp with time zone");
b.Property<int>("StorefrontType")
.HasColumnType("integer");
b.HasKey("Id");
b.ToTable("storefronts", (string)null);
});
modelBuilder.Entity("CannedNet.Models.StorefrontItem", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<bool>("IsFeatured")
.HasColumnType("boolean");
b.Property<DateTime?>("NewUntil")
.HasColumnType("timestamp with time zone");
b.Property<int>("PurchasableItemId")
.HasColumnType("integer");
b.Property<int>("StorefrontId")
.HasColumnType("integer");
b.Property<int>("Type")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("StorefrontId");
b.ToTable("storefront_items", (string)null);
});
modelBuilder.Entity("CannedNet.Models.StorefrontPrice", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("CurrencyType")
.HasColumnType("integer");
b.Property<int>("Price")
.HasColumnType("integer");
b.Property<int>("StorefrontItemId")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("StorefrontItemId");
b.ToTable("storefront_prices", (string)null);
});
modelBuilder.Entity("CannedNet.PlayerAvatar", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("FaceFeatures")
.IsRequired()
.HasColumnType("text")
.HasJsonPropertyName("FaceFeatures");
b.Property<string>("HairColor")
.IsRequired()
.HasColumnType("text")
.HasJsonPropertyName("HairColor");
b.Property<string>("OutfitSelections")
.IsRequired()
.HasColumnType("text")
.HasJsonPropertyName("OutfitSelections");
b.Property<int>("OwnerAccountId")
.HasColumnType("integer");
b.Property<string>("SkinColor")
.IsRequired()
.HasColumnType("text")
.HasJsonPropertyName("SkinColor");
b.HasKey("Id");
b.HasIndex("OwnerAccountId")
.IsUnique();
b.ToTable("player_avatars", (string)null);
});
modelBuilder.Entity("CannedNet.PlayerBio", b =>
{
b.Property<int>("accountId")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasJsonPropertyName("accountId");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("accountId"));
b.HasKey("accountId");
b.ToTable("player_bios", (string)null);
});
modelBuilder.Entity("CannedNet.PlayerProgression", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("Level")
.HasColumnType("integer")
.HasJsonPropertyName("level");
b.Property<int>("PlayerId")
.HasColumnType("integer")
.HasJsonPropertyName("playerId");
b.Property<int>("Xp")
.HasColumnType("integer")
.HasJsonPropertyName("xp");
b.HasKey("Id");
b.HasIndex("PlayerId")
.IsUnique();
b.ToTable("player_progressions", (string)null);
});
modelBuilder.Entity("CannedNet.PlayerSetting", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Key")
.IsRequired()
.HasColumnType("text")
.HasJsonPropertyName("key");
b.Property<int>("PlayerId")
.HasColumnType("integer")
.HasJsonPropertyName("playerId");
b.Property<string>("Value")
.IsRequired()
.HasColumnType("text")
.HasJsonPropertyName("value");
b.HasKey("Id");
b.HasIndex("PlayerId", "Key")
.IsUnique();
b.ToTable("player_settings", (string)null);
});
modelBuilder.Entity("CannedNet.PromoExternalContent", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("RoomId")
.HasColumnType("integer");
b.Property<string>("Tooltip")
.IsRequired()
.HasColumnType("text")
.HasJsonPropertyName("tooltip");
b.Property<int>("Type")
.HasColumnType("integer")
.HasJsonPropertyName("type");
b.Property<string>("Url")
.IsRequired()
.HasColumnType("text")
.HasJsonPropertyName("url");
b.HasKey("Id");
b.HasIndex("RoomId");
b.ToTable("promo_external_contents", (string)null);
});
modelBuilder.Entity("CannedNet.PromoImage", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ImageUrl")
.IsRequired()
.HasColumnType("text")
.HasJsonPropertyName("imageUrl");
b.Property<int>("RoomId")
.HasColumnType("integer");
b.Property<int>("SortOrder")
.HasColumnType("integer")
.HasJsonPropertyName("sortOrder");
b.Property<string>("Tooltip")
.IsRequired()
.HasColumnType("text")
.HasJsonPropertyName("tooltip");
b.HasKey("Id");
b.HasIndex("RoomId");
b.ToTable("promo_images", (string)null);
});
modelBuilder.Entity("CannedNet.Room", b =>
{
b.Property<int>("Id")
.HasColumnType("integer");
b.Property<int>("Accessibility")
.HasColumnType("integer")
.HasJsonPropertyName("accessibility");
b.Property<bool>("CloningAllowed")
.HasColumnType("boolean")
.HasJsonPropertyName("cloningAllowed");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasJsonPropertyName("createdAt");
b.Property<int>("CreatorAccountId")
.HasColumnType("integer")
.HasJsonPropertyName("creatorAccountId");
b.Property<string>("CustomWarning")
.HasColumnType("text")
.HasJsonPropertyName("customWarning");
b.Property<string>("Description")
.IsRequired()
.HasColumnType("text")
.HasJsonPropertyName("description");
b.Property<bool>("DisableMicAutoMute")
.HasColumnType("boolean")
.HasJsonPropertyName("disableMicAutoMute");
b.Property<bool>("DisableRoomComments")
.HasColumnType("boolean")
.HasJsonPropertyName("disableRoomComments");
b.Property<bool>("EncryptVoiceChat")
.HasColumnType("boolean")
.HasJsonPropertyName("encryptVoiceChat");
b.Property<string>("ImageName")
.IsRequired()
.HasColumnType("text")
.HasJsonPropertyName("imageName");
b.Property<bool>("IsDorm")
.HasColumnType("boolean")
.HasJsonPropertyName("isDorm");
b.Property<bool>("IsRRO")
.HasColumnType("boolean")
.HasJsonPropertyName("isRRO");
b.Property<int>("MinLevel")
.HasColumnType("integer")
.HasJsonPropertyName("minLevel");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text")
.HasJsonPropertyName("name");
b.Property<int>("RoomId")
.HasColumnType("integer")
.HasJsonPropertyName("roomId");
b.Property<int>("State")
.HasColumnType("integer")
.HasJsonPropertyName("state");
b.Property<bool>("SupportsJuniors")
.HasColumnType("boolean")
.HasJsonPropertyName("supportsJuniors");
b.Property<bool>("SupportsLevelVoting")
.HasColumnType("boolean")
.HasJsonPropertyName("supportsLevelVoting");
b.Property<bool>("SupportsMobile")
.HasColumnType("boolean")
.HasJsonPropertyName("supportsMobile");
b.Property<bool>("SupportsQuest2")
.HasColumnType("boolean")
.HasJsonPropertyName("supportsQuest2");
b.Property<bool>("SupportsScreens")
.HasColumnType("boolean")
.HasJsonPropertyName("supportsScreens");
b.Property<bool>("SupportsTeleportVR")
.HasColumnType("boolean")
.HasJsonPropertyName("supportsTeleportVR");
b.Property<bool>("SupportsVRLow")
.HasColumnType("boolean")
.HasJsonPropertyName("supportsVRLow");
b.Property<bool>("SupportsWalkVR")
.HasColumnType("boolean")
.HasJsonPropertyName("supportsWalkVR");
b.Property<string>("Tags")
.IsRequired()
.HasColumnType("text")
.HasJsonPropertyName("tags");
b.Property<int>("WarningMask")
.HasColumnType("integer")
.HasJsonPropertyName("warningMask");
b.HasKey("Id");
b.HasIndex("CreatorAccountId");
b.ToTable("rooms", (string)null);
});
modelBuilder.Entity("CannedNet.RoomInstance", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<bool>("EncryptVoiceChat")
.HasColumnType("boolean")
.HasJsonPropertyName("EncryptVoiceChat");
b.Property<int>("OwnerAccountId")
.HasColumnType("integer");
b.Property<int>("clubId")
.HasColumnType("integer")
.HasJsonPropertyName("clubId");
b.Property<string>("dataBlob")
.IsRequired()
.HasColumnType("text")
.HasJsonPropertyName("dataBlob");
b.Property<int>("eventId")
.HasColumnType("integer")
.HasJsonPropertyName("eventId");
b.Property<bool>("isFull")
.HasColumnType("boolean")
.HasJsonPropertyName("isFull");
b.Property<bool>("isInProgress")
.HasColumnType("boolean")
.HasJsonPropertyName("isInProgress");
b.Property<bool>("isPrivate")
.HasColumnType("boolean")
.HasJsonPropertyName("isPrivate");
b.Property<string>("location")
.IsRequired()
.HasColumnType("text")
.HasJsonPropertyName("location");
b.Property<int>("maxCapacity")
.HasColumnType("integer")
.HasJsonPropertyName("maxCapacity");
b.Property<string>("name")
.IsRequired()
.HasColumnType("text")
.HasJsonPropertyName("name");
b.Property<string>("photonRegionId")
.IsRequired()
.HasColumnType("text")
.HasJsonPropertyName("photonRegionId");
b.Property<string>("photonRoomId")
.IsRequired()
.HasColumnType("text")
.HasJsonPropertyName("photonRoomId");
b.Property<string>("roomCode")
.IsRequired()
.HasColumnType("text")
.HasJsonPropertyName("roomCode");
b.Property<int>("roomId")
.HasColumnType("integer")
.HasJsonPropertyName("roomId");
b.Property<int>("roomInstanceId")
.HasColumnType("integer")
.HasJsonPropertyName("roomInstanceId");
b.Property<int>("roomInstanceType")
.HasColumnType("integer")
.HasJsonPropertyName("roomInstanceType");
b.Property<int>("subRoomId")
.HasColumnType("integer")
.HasJsonPropertyName("subRoomId");
b.HasKey("Id");
b.HasIndex("OwnerAccountId", "roomId");
b.ToTable("room_instances", (string)null);
});
modelBuilder.Entity("CannedNet.RoomRole", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("AccountId")
.HasColumnType("integer")
.HasJsonPropertyName("accountId");
b.Property<int>("InvitedRole")
.HasColumnType("integer")
.HasJsonPropertyName("invitedRole");
b.Property<int>("Role")
.HasColumnType("integer")
.HasJsonPropertyName("role");
b.Property<int>("RoomId")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("RoomId");
b.ToTable("room_roles", (string)null);
});
modelBuilder.Entity("CannedNet.SavedOutfit", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("FaceFeatures")
.IsRequired()
.HasColumnType("text")
.HasJsonPropertyName("FaceFeatures");
b.Property<string>("HairColor")
.IsRequired()
.HasColumnType("text")
.HasJsonPropertyName("HairColor");
b.Property<string>("OutfitSelections")
.IsRequired()
.HasColumnType("text")
.HasJsonPropertyName("OutfitSelections");
b.Property<int>("OwnerAccountId")
.HasColumnType("integer");
b.Property<string>("PreviewImageName")
.IsRequired()
.HasColumnType("text")
.HasJsonPropertyName("PreviewImageName");
b.Property<string>("SkinColor")
.IsRequired()
.HasColumnType("text")
.HasJsonPropertyName("SkinColor");
b.Property<int>("Slot")
.HasColumnType("integer")
.HasJsonPropertyName("Slot");
b.HasKey("Id");
b.HasIndex("OwnerAccountId")
.IsUnique();
b.ToTable("saved_outfits", (string)null);
});
modelBuilder.Entity("CannedNet.SubRoom", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("Accessibility")
.HasColumnType("integer")
.HasJsonPropertyName("accessibility");
b.Property<string>("DataBlob")
.IsRequired()
.HasColumnType("text")
.HasJsonPropertyName("dataBlob");
b.Property<DateTime?>("DataSavedAt")
.HasColumnType("timestamp with time zone")
.HasJsonPropertyName("dataSavedAt");
b.Property<bool>("IsSandbox")
.HasColumnType("boolean")
.HasJsonPropertyName("isSandbox");
b.Property<int>("MaxPlayers")
.HasColumnType("integer")
.HasJsonPropertyName("maxPlayers");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text")
.HasJsonPropertyName("name");
b.Property<int>("RoomId")
.HasColumnType("integer");
b.Property<int>("SubRoomId")
.HasColumnType("integer")
.HasJsonPropertyName("subRoomId");
b.Property<string>("UnitySceneId")
.IsRequired()
.HasColumnType("text")
.HasJsonPropertyName("unitySceneId");
b.HasKey("Id");
b.HasIndex("RoomId");
b.ToTable("sub_rooms", (string)null);
});
modelBuilder.Entity("CannedNet.TokenBalance", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("Balance")
.HasColumnType("integer")
.HasJsonPropertyName("Balance");
b.Property<int>("BalanceType")
.HasColumnType("integer")
.HasJsonPropertyName("BalanceType");
b.Property<int>("CurrencyType")
.HasColumnType("integer")
.HasJsonPropertyName("CurrencyType");
b.Property<int>("Platform")
.HasColumnType("integer")
.HasJsonPropertyName("Platform");
b.HasKey("Id");
b.ToTable("token_balances", (string)null);
});
modelBuilder.Entity("CannedNet.Models.GiftDrop", b =>
{
b.HasOne("CannedNet.Models.StorefrontItem", "StorefrontItem")
.WithMany("GiftDrops")
.HasForeignKey("StorefrontItemId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("StorefrontItem");
});
modelBuilder.Entity("CannedNet.Models.StorefrontItem", b =>
{
b.HasOne("CannedNet.Models.Storefront", "Storefront")
.WithMany("Items")
.HasForeignKey("StorefrontId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Storefront");
});
modelBuilder.Entity("CannedNet.Models.StorefrontPrice", b =>
{
b.HasOne("CannedNet.Models.StorefrontItem", "StorefrontItem")
.WithMany("Prices")
.HasForeignKey("StorefrontItemId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("StorefrontItem");
});
modelBuilder.Entity("CannedNet.Models.Storefront", b =>
{
b.Navigation("Items");
});
modelBuilder.Entity("CannedNet.Models.StorefrontItem", b =>
{
b.Navigation("GiftDrops");
b.Navigation("Prices");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace CannedNet.Migrations
{
/// <inheritdoc />
public partial class AddAccountPassword : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "Password",
table: "accounts",
type: "text",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Password",
table: "accounts");
}
}
}
@@ -18,7 +18,7 @@ namespace CannedNet.Migrations
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.8")
.HasAnnotation("ProductVersion", "10.0.9")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
@@ -46,6 +46,10 @@ namespace CannedNet.Migrations
.HasColumnType("boolean")
.HasJsonPropertyName("isJunior");
b.Property<string>("Password")
.HasColumnType("text")
.HasJsonPropertyName("password");
b.Property<int?>("PersonalPronouns")
.HasColumnType("integer")
.HasJsonPropertyName("personalPronouns");
+10 -54
View File
@@ -39,6 +39,7 @@ public static class Program
builder.Services.AddSingleton<NotificationService>();
builder.Services.AddScoped<StorefrontFillService>();
builder.Services.AddScoped<JwtTokenService>();
builder.Services.AddSingleton<ConfigService>();
builder.Services.AddSignalR();
builder.Services.AddAuthentication(options =>
@@ -70,7 +71,7 @@ public static class Program
ClockSkew = TimeSpan.Zero,
NameClaimType = ClaimTypes.NameIdentifier,
RoleClaimType = "role"
RoleClaimType = ClaimTypes.Role
};
})
.AddScheme<AuthenticationSchemeOptions, AdminApiKeyHandler>("AdminApiKey", null);
@@ -89,14 +90,15 @@ public static class Program
IHubContext<NotificationsHub> hubContext = app.Services.GetRequiredService<IHubContext<NotificationsHub>>();
NotificationService.SetHubContext(hubContext);
//if (app.Environment.IsDevelopment()) {
app.UseSwagger();
app.UseSwaggerUI(options =>
if (app.Environment.IsDevelopment())
{
options.SwaggerEndpoint("/swagger/v1/swagger.json", "CannedNet v1");
options.RoutePrefix = "swagger/ui/";
});
//}
app.UseSwagger();
app.UseSwaggerUI(options =>
{
options.SwaggerEndpoint("/swagger/v1/swagger.json", "CannedNet v1");
options.RoutePrefix = "swagger/ui/";
});
}
using IServiceScope scope = app.Services.CreateScope();
AppDbContext db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
@@ -117,50 +119,4 @@ public static class Program
await app.RunAsync();
}
//public static async Task T() {
// List<(WebApplication App, ServiceRegistry Service)> apps = new();
//
// foreach (var service in Services.Infrastructure.Services.All)
// {
// var builder = WebApplication.CreateBuilder();
//
// builder.Services.AddLogging(logging =>
// {
// logging.AddConsole();
// logging.SetMinimumLevel(LogLevel.Debug);
// });
//
// service.ConfigureBuilder(builder);
// builder.WebHost.UseUrls($"http://*:{service.Port}");
// builder.Configuration.AddJsonFile($"AppConfigs/appsettings.{service.Name}.json", optional: true, reloadOnChange: true);
//
// apps.Add((builder.Build(), service));
// }
//
// using var scope = apps[0].App.Services.CreateScope();
// var dbContext = scope.ServiceProvider.GetRequiredService<CannedNet.Data.AppDbContext>();
// try
// {
// dbContext.Database.Migrate();
// }
// catch (Exception ex)
// {
// dbContext.Database.EnsureCreated();
// }
//
// // automatically fill out storefronts tables from the JSON's
// var seedingService = scope.ServiceProvider.GetRequiredService<StorefrontFillService>();
// await seedingService.FillStorefrontsAsync();
//
// var jwtService = scope.ServiceProvider.GetRequiredService<JwtTokenService>();
//
// foreach (var (app, service) in apps)
// {
// service.MapEndpoints(app, jwtService);
// app.UseRequestLogging();
// }
//
// await Task.WhenAll(apps.Select(t => t.App.RunAsync()));
//}
}
+117
View File
@@ -0,0 +1,117 @@
using System.Text.Json;
namespace CannedNet.Services;
public class ConfigService
{
private const string ConfigPath = "config.json";
private const string EndpointsPath = "JSON/endpoints.json";
private static readonly JsonSerializerOptions Indented = new() { WriteIndented = true };
public AppConfig Config { get; private set; }
public ConfigService()
{
LoadConfig();
SyncBaseUrlToEndpoints();
}
private void LoadConfig()
{
if (!File.Exists(ConfigPath))
{
Config = CreateDefaultConfig();
File.WriteAllText(ConfigPath, JsonSerializer.Serialize(Config, Indented));
}
else
{
var json = File.ReadAllText(ConfigPath);
Config = JsonSerializer.Deserialize<AppConfig>(json) ?? CreateDefaultConfig();
}
}
public void SaveConfig(AppConfig updated)
{
Config = updated;
File.WriteAllText(ConfigPath, JsonSerializer.Serialize(Config, Indented));
SyncBaseUrlToEndpoints();
}
private void SyncBaseUrlToEndpoints()
{
var configBase = Config.BaseUrl?.TrimEnd('/');
if (string.IsNullOrEmpty(configBase))
return;
if (!File.Exists(EndpointsPath))
return;
var endpointsJson = File.ReadAllText(EndpointsPath);
var endpoints = JsonSerializer.Deserialize<Dictionary<string, string>>(endpointsJson);
if (endpoints == null || endpoints.Count == 0)
return;
var currentBase = DetectBaseUrl(endpoints);
if (currentBase == null)
return;
if (string.Equals(currentBase, configBase, StringComparison.OrdinalIgnoreCase))
return;
var updated = new Dictionary<string, string>(endpoints.Count);
foreach (var (key, url) in endpoints)
{
if (url.StartsWith(currentBase, StringComparison.OrdinalIgnoreCase))
{
var path = url[currentBase.Length..];
updated[key] = configBase + path;
}
else
{
updated[key] = url;
}
}
File.WriteAllText(EndpointsPath, JsonSerializer.Serialize(updated, Indented));
}
private static string? DetectBaseUrl(Dictionary<string, string> endpoints)
{
foreach (var url in endpoints.Values)
{
if (string.IsNullOrEmpty(url))
continue;
if (Uri.TryCreate(url, UriKind.Absolute, out var uri))
return $"{uri.Scheme}://{uri.Host}{(uri.IsDefaultPort ? "" : $":{uri.Port}")}";
}
return null;
}
private static AppConfig CreateDefaultConfig()
{
return new AppConfig
{
BaseUrl = "http://localhost:5000",
DeveloperUserIds = new List<string>(),
VModUserIds = new List<string>(),
AutoFillRROs = true,
AutoFillStorefronts = true,
WhitelistOn = false,
WhitelistedPlatformIds = new List<string>(),
EnableAccountCreation = true,
};
}
public class AppConfig
{
public string BaseUrl { get; set; } = "";
public bool AutoFillRROs { get; set; } = true;
public bool AutoFillStorefronts { get; set; } = true;
public List<string> DeveloperUserIds { get; set; } = new();
public List<string> VModUserIds { get; set; } = new();
public List<string> AdminAccountIds { get; set; } = new();
public bool WhitelistOn { get; set; } = false;
public List<string> WhitelistedPlatformIds { get; set; } = new();
public bool EnableAccountCreation { get; set; } = true;
}
}
@@ -1,6 +1,7 @@
using CannedNet.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Primitives;
@@ -100,132 +101,6 @@ public class AccountsController : ControllerBase
return Results.Json(result);
}
[HttpPost("account/create")]
public async Task<IResult> Create(AppDbContext db)
{
var request = HttpContext.Request;
int platform = 0;
string platformId = "";
if (request.ContentLength is > 0)
{
try
{
string contentType = request.ContentType ?? "";
request.EnableBuffering();
using StreamReader reader = new(request.Body, leaveOpen: true);
string body = await reader.ReadToEndAsync();
if (!string.IsNullOrWhiteSpace(body) && contentType.Contains("application/x-www-form-urlencoded"))
{
foreach (string pair in body.Split('&'))
{
string[] keyValue = pair.Split('=');
if (keyValue.Length != 2) continue;
string key = Uri.UnescapeDataString(keyValue[0]);
string value = Uri.UnescapeDataString(keyValue[1]);
switch (key)
{
case "platform" when int.TryParse(value, out var parsedPlatform):
platform = parsedPlatform;
break;
case "platformId":
platformId = value;
break;
}
}
}
request.Body.Position = 0;
}
catch { }
}
int accountId = new Random().Next(10000, 99999);
Account account = new()
{
AccountId = accountId,
ProfileImage = "DefaultProfileImage.jpg",
IsJunior = false,
Platforms = 0,
PersonalPronouns = 0,
IdentityFlags = 0,
Username = $"Player{accountId}",
DisplayName = $"Player{accountId}",
CreatedAt = DateTime.UtcNow
};
db.Accounts.Add(account);
if (!string.IsNullOrEmpty(platformId))
{
db.CachedLogins.Add(new CachedLogin
{
AccountId = accountId,
Platform = (PlatformType)platform,
PlatformID = platformId,
LastLoginTime = DateTime.UtcNow,
RequirePassword = false
});
}
await db.SaveChangesAsync();
int maxRoomId = await db.Rooms.MaxAsync(r => (int?)r.RoomId) ?? 0;
int maxId = await db.Rooms.MaxAsync(r => (int?)r.Id) ?? 0;
int dormRoomId = maxRoomId + 1;
Room dormRoom = new Room
{
Id = maxId + 1,
RoomId = dormRoomId,
Name = "DormRoom",
Description = "Your personal room",
CreatorAccountId = accountId,
ImageName = "",
State = 0,
Accessibility = 0,
SupportsLevelVoting = false,
IsRRO = false,
IsDorm = true,
CloningAllowed = false,
SupportsVRLow = true,
SupportsQuest2 = true,
SupportsMobile = true,
SupportsScreens = true,
SupportsWalkVR = true,
SupportsTeleportVR = true,
SupportsJuniors = true,
MinLevel = 0,
WarningMask = 0,
CustomWarning = null,
DisableMicAutoMute = false,
DisableRoomComments = false,
EncryptVoiceChat = false,
CreatedAt = DateTime.UtcNow,
Tags = "[]"
};
db.Rooms.Add(dormRoom);
SubRoom dormSubRoom = new SubRoom
{
RoomId = dormRoomId,
SubRoomId = 1,
Name = "DormRoom",
DataBlob = "",
IsSandbox = false,
MaxPlayers = 4,
Accessibility = 0,
UnitySceneId = "76d98498-60a1-430c-ab76-b54a29b7a163",
DataSavedAt = DateTime.UtcNow
};
db.SubRooms.Add(dormSubRoom);
await db.SaveChangesAsync();
return Results.Ok(RecNetResult.Ok(account));
}
[HttpPut("me/displayname")]
[Authorize]
public async Task<IResult> PutMeDisplayName(AppDbContext db)
@@ -320,6 +195,54 @@ public class AccountsController : ControllerBase
return Results.Ok(RecNetResult.Ok());
}
[HttpPut("me/changepassword")]
[Authorize]
public async Task<IResult> PutMePassword(AppDbContext db)
{
if (!int.TryParse(User.Identity?.Name, out var id))
return Results.Unauthorized();
var request = HttpContext.Request;
string newPassword = "";
if (request.ContentLength is > 0)
{
try
{
request.EnableBuffering();
using StreamReader reader = new(request.Body, leaveOpen: true);
string body = await reader.ReadToEndAsync();
if (!string.IsNullOrWhiteSpace(body))
{
foreach (string pair in body.Split('&'))
{
string[] keyValue = pair.Split('=');
if (keyValue.Length != 2) continue;
string key = Uri.UnescapeDataString(keyValue[0]);
string value = Uri.UnescapeDataString(keyValue[1]);
if (key == "newPassword")
newPassword = value;
}
}
request.Body.Position = 0;
}
catch { }
}
Account? account = await db.Accounts.FindAsync(id);
if (account == null)
return Results.NotFound();
var hasher = new PasswordHasher<Account>();
account.Password = hasher.HashPassword(account, newPassword);
await db.SaveChangesAsync();
return Results.Ok(RecNetResult.Ok());
}
[HttpGet("{id}/bio")]
public async Task<IResult> GetBio(string id, AppDbContext db)
{
@@ -8,27 +8,11 @@ using Microsoft.EntityFrameworkCore;
namespace CannedNet.Services.Controllers;
[ApiController, Route("admin/api")]
[Authorize(AuthenticationSchemes = "AdminApiKey")]
[Authorize(Roles = "developer,admin")]
public class AdminController : ControllerBase
{
private const string JsonDir = "JSON";
[HttpGet("endpoints")]
public async Task<IResult> GetEndpoints()
{
var json = await System.IO.File.ReadAllTextAsync(Path.Combine(JsonDir, "endpoints.json"));
return Results.Content(json, "application/json");
}
[HttpPut("endpoints")]
public async Task<IResult> SaveEndpoints()
{
using var reader = new StreamReader(Request.Body);
var body = await reader.ReadToEndAsync();
await System.IO.File.WriteAllTextAsync(Path.Combine(JsonDir, "endpoints.json"), body);
return Results.Ok(new { success = true });
}
[HttpGet("accounts/search")]
public async Task<IResult> SearchAccounts([FromQuery] string q, AppDbContext db)
{
@@ -215,98 +199,6 @@ public class AdminController : ControllerBase
return Results.Ok(new { success = true });
}
[HttpPost("rooms")]
public async Task<IResult> CreateRoom(AppDbContext db)
{
using var reader = new StreamReader(Request.Body);
var body = await reader.ReadToEndAsync();
var data = JsonSerializer.Deserialize<JsonElement>(body);
int maxRoomId = await db.Rooms.MaxAsync(r => (int?)r.RoomId) ?? 0;
int maxId = await db.Rooms.MaxAsync(r => (int?)r.Id) ?? 0;
int newRoomId = data.TryGetProperty("roomId", out var rid) ? rid.GetInt32() : maxRoomId + 1;
var room = new Room
{
Id = maxId + 1,
RoomId = newRoomId,
Name = data.TryGetProperty("name", out var name) ? name.GetString() ?? "New Room" : "New Room",
Description = data.TryGetProperty("description", out var desc) ? desc.GetString() ?? "" : "",
CreatorAccountId = data.TryGetProperty("creatorAccountId", out var creatorId) ? creatorId.GetInt32() : 0,
ImageName = data.TryGetProperty("imageName", out var img) ? img.GetString() ?? "" : "",
State = data.TryGetProperty("state", out var state) ? state.GetInt32() : 0,
Accessibility = data.TryGetProperty("accessibility", out var accessibility) ? accessibility.GetInt32() : 0,
IsDorm = false,
IsRRO = false,
CloningAllowed = true,
SupportsVRLow = true,
SupportsQuest2 = true,
SupportsMobile = true,
SupportsScreens = true,
SupportsWalkVR = true,
SupportsTeleportVR = true,
SupportsJuniors = true,
MinLevel = 0,
WarningMask = 0,
CreatedAt = DateTime.UtcNow,
Tags = "[]"
};
db.Rooms.Add(room);
await db.SaveChangesAsync();
// create default subroom
if (data.TryGetProperty("createSubRoom", out var createSub) && createSub.GetBoolean())
{
var sub = new SubRoom
{
RoomId = newRoomId,
SubRoomId = 1,
Name = room.Name,
DataBlob = "",
IsSandbox = false,
MaxPlayers = 16,
Accessibility = 0,
// TODO: allow changing this scene id thru the dashboard
UnitySceneId = "00000000-0000-0000-0000-000000000000"
};
db.SubRooms.Add(sub);
await db.SaveChangesAsync();
}
return Results.Ok(room);
}
[HttpPost("rooms/{roomId:int}/subrooms")]
public async Task<IResult> CreateSubRoom(int roomId, AppDbContext db)
{
var room = await db.Rooms.FirstOrDefaultAsync(r => r.RoomId == roomId);
if (room == null)
return Results.NotFound();
using var reader = new StreamReader(Request.Body);
var body = await reader.ReadToEndAsync();
var data = JsonSerializer.Deserialize<JsonElement>(body);
int maxSubId = await db.SubRooms.Where(s => s.RoomId == roomId).MaxAsync(s => (int?)s.SubRoomId) ?? 0;
var sub = new SubRoom
{
RoomId = roomId,
SubRoomId = maxSubId + 1,
Name = data.TryGetProperty("name", out var name) ? name.GetString() ?? "New SubRoom" : "New SubRoom",
DataBlob = data.TryGetProperty("dataBlob", out var dataBlob) ? dataBlob.GetString() ?? "" : "",
IsSandbox = data.TryGetProperty("isSandbox", out var isSandbox) && isSandbox.GetBoolean(),
MaxPlayers = data.TryGetProperty("maxPlayers", out var maxPlayers) ? maxPlayers.GetInt32() : 16,
Accessibility = data.TryGetProperty("accessibility", out var accessibility) ? accessibility.GetInt32() : 0,
UnitySceneId = data.TryGetProperty("unitySceneId", out var sceneId) ? sceneId.GetString() ?? "" : "",
};
db.SubRooms.Add(sub);
await db.SaveChangesAsync();
return Results.Ok(sub);
}
[HttpGet("config/gameconfigs")]
public async Task<IResult> GetGameConfigs()
{
@@ -1,4 +1,5 @@
using CannedNet.Data;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.AspNetCore.Mvc;
@@ -26,13 +27,16 @@ public class AuthController : ControllerBase
}
[HttpPost("connect/token")]
public async Task<IResult> ConnectToken(AppDbContext db, JwtTokenService jwtService)
public async Task<IResult> ConnectToken(AppDbContext db, JwtTokenService jwtService, ConfigService config)
{
string grantType = "";
string accountId = "";
string platformId = "";
string platform = "";
int platformInt = 0;
string password = "";
string username = "";
List<string> adminRoles = [];
if (HttpContext.Request.ContentLength is > 0)
{
@@ -61,11 +65,22 @@ public class AuthController : ControllerBase
break;
case "platform_id":
platformId = value;
if (config.Config.WhitelistOn)
{
if (!config.Config.WhitelistedPlatformIds.Contains(platformId))
return Results.Unauthorized();
}
break;
case "platform" when int.TryParse(value, out var p):
platformInt = p;
platform = ((PlatformType)p).ToString();
break;
case "username":
username = value;
break;
case "password":
password = value;
break;
}
}
}
@@ -76,6 +91,11 @@ public class AuthController : ControllerBase
if (grantType == "create_account")
{
if (!config.Config.EnableAccountCreation)
{
return Results.Ok(RecNetResult.Err("Account creation is disabled"));
}
int newId = new Random().Next(10000, 99999);
Account account = new()
{
@@ -159,8 +179,27 @@ public class AuthController : ControllerBase
accountId = newId.ToString();
}
else if (grantType == "password")
{
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
return Results.Ok(RecNetResult.Err("Username and password are required"));
string accessToken = jwtService.GenerateToken(accountId, platformId, platform);
var account = await db.Accounts.FirstOrDefaultAsync(a => a.Username == username);
if (account == null || string.IsNullOrEmpty(account.Password))
return Results.Ok(RecNetResult.Err("Invalid username or password"));
var hasher = new PasswordHasher<Account>();
var result = hasher.VerifyHashedPassword(account, account.Password, password);
if (result == PasswordVerificationResult.Failed)
return Results.Ok(RecNetResult.Err("Invalid username or password"));
accountId = account.AccountId?.ToString() ?? "";
if (account.AccountId != null && config.Config.AdminAccountIds.Contains(account.AccountId.Value.ToString()))
adminRoles.Add("developer");
platformId = accountId;
}
string accessToken = jwtService.GenerateToken(accountId, platformId, config, platform, grantType == "password" ? adminRoles : null, amr: grantType == "create_account" ? "create_account" : "cached_login");
if (!string.IsNullOrEmpty(accountId) && int.TryParse(accountId, out var id))
{
@@ -421,9 +421,9 @@ public class MatchmakingController : ControllerBase
return Results.Json(new
{
playerId = heartbeat.playerId != 0 ? heartbeat.playerId : id,
statusVisibility = heartbeat.statusVisibility,
deviceClass = heartbeat.deviceClass,
PlayerId = heartbeat.playerId != 0 ? heartbeat.playerId : id,
StatusVisibility = heartbeat.statusVisibility,
DeviceClass = heartbeat.deviceClass,
vrMovementMode = heartbeat.vrMovementMode != 0 ? heartbeat.vrMovementMode : 1,
roomInstance = roomInstance != null ? new
{
@@ -445,9 +445,9 @@ public class MatchmakingController : ControllerBase
isInProgress = roomInstance.isInProgress,
EncryptVoiceChat = roomInstance.EncryptVoiceChat
} : null,
isOnline = roomInstance != null,
appVersion = heartbeat.appVersion ?? "",
platform = heartbeat.platform
IsOnline = true,
AppVersion = heartbeat.appVersion ?? "",
Platform = heartbeat.platform
});
}
@@ -2,7 +2,6 @@ using Microsoft.AspNetCore.Mvc;
namespace CannedNet.Services.Controllers;
//TODO: proper NameServer configs
[ApiController, Route("ns")]
public class NSController : ControllerBase
{
+29 -13
View File
@@ -15,22 +15,22 @@ public class JwtTokenService
public JwtTokenService()
{
var rsa = Signatures.GetRsaInstance()
var rsa = Signatures.GetRsaInstance()
?? throw new InvalidOperationException("jwt - sigs service was not found");
_securityKey = new RsaSecurityKey(rsa) { KeyId = KeyId };
_signingCredentials = new SigningCredentials(_securityKey, SecurityAlgorithms.RsaSha256);
}
public string GenerateToken(string accountId, string platformId, string platform = "Steam")
public string GenerateToken(string accountId, string platformId, ConfigService config, string platform = "Steam", List<string>? additionalRoles = null, string amr = "cached_login")
{
var now = DateTime.UtcNow;
var exp = now.AddHours(1);
// theres gotta be some other better way to do this, but im fucking stupid so :shrug:
// if it works it works ig idk, someone can clean this up later or i will later
var claims = new List<Claim>
{
new Claim("iss", "https://lapis.codes"),
@@ -38,7 +38,7 @@ public class JwtTokenService
new Claim("iat", ((DateTimeOffset)now).ToUnixTimeSeconds().ToString()),
new Claim("exp", ((DateTimeOffset)exp).ToUnixTimeSeconds().ToString()),
new Claim("aud", "https://lapis.codes/resources"),
new Claim("amr", "cached_login"),
new Claim("amr", amr),
new Claim("client_id", "recroom"),
new Claim("sub", accountId),
new Claim("auth_time", ((DateTimeOffset)now).ToUnixTimeSeconds().ToString()),
@@ -48,11 +48,25 @@ public class JwtTokenService
new Claim("rn.ver", "20210129"),
new Claim("rn.plat", "0"),
new Claim("role", "gameClient"),
new Claim("role", "developer"),
new Claim("role", "moderator"),
new Claim("jti", Guid.NewGuid().ToString("N").Substring(0, 32).ToUpper())
new Claim("jti", Guid.NewGuid().ToString("N")[..32].ToUpper())
};
if (config.Config.DeveloperUserIds.Contains(platformId))
{
claims.Add(new Claim("role", "developer"));
}
if (config.Config.VModUserIds.Contains(platformId))
{
claims.Add(new Claim("role", "moderator"));
}
if (additionalRoles != null)
{
foreach (var role in additionalRoles)
claims.Add(new Claim("role", role));
}
claims.Add(new Claim("scope", "profile"));
claims.Add(new Claim("scope", "rn"));
claims.Add(new Claim("scope", "rn.accounts"));
@@ -80,6 +94,8 @@ public class JwtTokenService
expires: exp,
signingCredentials: _signingCredentials);
token.Payload["amr"] = new List<string> { amr };
return handler.WriteToken(token);
}
@@ -89,7 +105,7 @@ public class JwtTokenService
{
var handler = new JwtSecurityTokenHandler();
handler.InboundClaimTypeMap.Clear();
var parameters = new TokenValidationParameters
{
ValidateIssuerSigningKey = true,
@@ -103,9 +119,9 @@ public class JwtTokenService
};
var principal = handler.ValidateToken(token, parameters, out SecurityToken validatedToken);
var accountIdClaim = principal.Claims.FirstOrDefault(c => c.Type == "sub");
return accountIdClaim?.Value;
}
catch (Exception ex)
@@ -124,10 +140,10 @@ public class JwtTokenService
var token = authHeader.Substring("Bearer ".Length);
var accountId = ValidateAndGetAccountId(token);
if (string.IsNullOrEmpty(accountId) || !int.TryParse(accountId, out var id))
return null;
return id;
}
catch
+9 -2
View File
@@ -10,12 +10,14 @@ public class StorefrontFillService
private readonly AppDbContext _dbContext;
private readonly ILogger<StorefrontFillService> _logger;
private readonly IWebHostEnvironment _environment;
private readonly ConfigService _config;
public StorefrontFillService(AppDbContext dbContext, ILogger<StorefrontFillService> logger, IWebHostEnvironment environment)
public StorefrontFillService(AppDbContext dbContext, ILogger<StorefrontFillService> logger, IWebHostEnvironment environment, ConfigService config)
{
_dbContext = dbContext;
_logger = logger;
_environment = environment;
_config = config;
}
public async Task FillStorefrontsAsync()
@@ -27,6 +29,11 @@ public class StorefrontFillService
return;
}
if (!_config.Config.AutoFillStorefronts)
{
return;
}
_logger.LogInformation("automatically filling out storefronts due to no data");
var storefronts = new[]
@@ -81,7 +88,7 @@ public class StorefrontFillService
{
Name = name,
StorefrontType = storefrontType,
NextUpdate = root.TryGetProperty("NextUpdate", out var nextUpdate)
NextUpdate = root.TryGetProperty("NextUpdate", out var nextUpdate)
? DateTime.SpecifyKind(DateTime.Parse(nextUpdate.GetString() ?? DateTime.UtcNow.ToString()), DateTimeKind.Utc)
: DateTime.UtcNow.AddDays(7)
};
+1 -5
View File
@@ -11,9 +11,5 @@
"Ports": {
"Primary": 5000
},
"AllowedHosts": "*",
"Admin": {
"ApiKey": "changeme-replace-with-a-secure-key"
}
"AllowedHosts": "*"
}