basic admin panel stuff

This commit is contained in:
Lapis
2026-06-17 13:39:28 -04:00
parent c20d215ee0
commit 004f1921a6
44 changed files with 3432 additions and 315 deletions
+1
View File
@@ -9,3 +9,4 @@ CannedNet.sln.DotSettings.user
CannedNet/Data/PrivateKey.pem
/CannedNet/.zed
CannedNet/Data/PublicKey.pem
CannedNet.Admin/admin_users.json
+3
View File
@@ -7,6 +7,7 @@
<base href="/" />
<ResourcePreloader />
<link rel="stylesheet" href="@Assets["lib/bootstrap/dist/css/bootstrap.min.css"]" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" />
<link rel="stylesheet" href="@Assets["app.css"]" />
<link rel="stylesheet" href="@Assets["CannedNet.Admin.styles.css"]" />
<ImportMap />
@@ -15,8 +16,10 @@
</head>
<body>
<CascadingAuthenticationState>
<Routes />
<ReconnectModal />
</CascadingAuthenticationState>
<script src="@Assets["_framework/blazor.web.js"]"></script>
</body>
@@ -1,20 +1,30 @@
@inherits LayoutComponentBase
@using CannedNet.Admin.Services
@inherits LayoutComponentBase
@inject AdminAuthService AuthService
@inject NavigationManager Navigation
@{
var body = Body;
}
<AuthorizeView>
<Authorized>
<div class="page">
<div class="sidebar">
<NavMenu />
</div>
<main>
<div class="top-row px-4">
<a href="https://learn.microsoft.com/aspnet/core/" target="_blank">About</a>
</div>
<article class="content px-4">
@Body
@body
</article>
</main>
</div>
</Authorized>
<NotAuthorized>
@body
</NotAuthorized>
</AuthorizeView>
<div id="blazor-error-ui" data-nosnippet>
An unhandled error has occurred.
@@ -8,23 +8,69 @@
<div class="nav-scrollable" onclick="document.querySelector('.navbar-toggler').click()">
<nav class="nav flex-column">
<AuthorizeView>
<Authorized>
<div class="nav-item px-3">
<NavLink class="nav-link" href="" Match="NavLinkMatch.All">
<span class="bi bi-house-door-fill-nav-menu" aria-hidden="true"></span> Home
<span class="bi bi-house-door-fill-nav-menu" aria-hidden="true"></span> Dashboard
</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="counter">
<span class="bi bi-plus-square-fill-nav-menu" aria-hidden="true"></span> Counter
<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="weather">
<span class="bi bi-list-nested-nav-menu" aria-hidden="true"></span> Weather
<NavLink class="nav-link" href="users/search">
<span class="bi bi-search-nav-menu" aria-hidden="true"></span> Search Users
</NavLink>
</div>
<div class="nav-section-label px-3 mt-3 mb-1 text-muted small text-uppercase">Rooms</div>
<div class="nav-item px-3">
<NavLink class="nav-link" href="rooms/search">
<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
</NavLink>
</div>
<div class="nav-item px-3">
<NavLink class="nav-link" href="config/communityboard">
<span class="bi bi-megaphone-fill-nav-menu" aria-hidden="true"></span> Community Board
</NavLink>
</div>
<div class="nav-section-label px-3 mt-3 mb-1 text-muted small text-uppercase">Storefronts</div>
<div class="nav-item px-3">
<NavLink class="nav-link" href="storefronts">
<span class="bi bi-cart-fill-nav-menu" aria-hidden="true"></span> Storefronts
</NavLink>
</div>
</Authorized>
</AuthorizeView>
</nav>
</div>
@@ -16,7 +16,7 @@
}
.top-row {
min-height: 3.5rem;
height: 3.5rem;
background-color: rgba(0,0,0,0.4);
}
@@ -34,18 +34,6 @@
background-size: cover;
}
.bi-house-door-fill-nav-menu {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-house-door-fill' viewBox='0 0 16 16'%3E%3Cpath d='M6.5 14.5v-3.505c0-.245.25-.495.5-.495h2c.25 0 .5.25.5.5v3.5a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5v-7a.5.5 0 0 0-.146-.354L13 5.793V2.5a.5.5 0 0 0-.5-.5h-1a.5.5 0 0 0-.5.5v1.293L8.354 1.146a.5.5 0 0 0-.708 0l-6 6A.5.5 0 0 0 1.5 7.5v7a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5Z'/%3E%3C/svg%3E");
}
.bi-plus-square-fill-nav-menu {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-plus-square-fill' viewBox='0 0 16 16'%3E%3Cpath d='M2 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2H2zm6.5 4.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3a.5.5 0 0 1 1 0z'/%3E%3C/svg%3E");
}
.bi-list-nested-nav-menu {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-list-nested' viewBox='0 0 16 16'%3E%3Cpath fill-rule='evenodd' d='M4.5 11.5A.5.5 0 0 1 5 11h10a.5.5 0 0 1 0 1H5a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 3 7h10a.5.5 0 0 1 0 1H3a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 1 3h10a.5.5 0 0 1 0 1H1a.5.5 0 0 1-.5-.5z'/%3E%3C/svg%3E");
}
.nav-item {
font-size: 0.9rem;
padding-bottom: 0.5rem;
@@ -59,16 +47,13 @@
padding-bottom: 1rem;
}
.nav-item ::deep .nav-link {
.nav-item ::deep a {
color: #d7d7d7;
background: none;
border: none;
border-radius: 4px;
height: 3rem;
height: 2.5rem;
display: flex;
align-items: center;
line-height: 3rem;
width: 100%;
line-height: 2.5rem;
}
.nav-item ::deep a.active {
@@ -76,7 +61,7 @@
color: white;
}
.nav-item ::deep .nav-link:hover {
.nav-item ::deep a:hover {
background-color: rgba(255,255,255,0.1);
color: white;
}
@@ -89,16 +74,19 @@
display: block;
}
.nav-section-label {
font-size: 0.7rem;
letter-spacing: 0.05em;
opacity: 0.7;
}
@media (min-width: 641px) {
.navbar-toggler {
display: none;
}
.nav-scrollable {
/* Never collapse the sidebar for wide screens */
display: block;
/* Allow sidebar to scroll for tall menus */
height: calc(100vh - 3.5rem);
overflow-y: auto;
}
@@ -0,0 +1,13 @@
@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);
}
}
@@ -0,0 +1,106 @@
@page "/change-password"
@attribute [Authorize]
@rendermode InteractiveServer
@using System.Security.Claims
@using CannedNet.Admin.Services
@inject AdminAuthService AuthService
@inject NavigationManager Navigation
@inject IJSRuntime JSRuntime
<PageTitle>Change Password</PageTitle>
<h3>Change Password</h3>
<div class="row justify-content-center">
<div class="col-md-6">
<div class="card">
<div class="card-header">Change Your Password</div>
<div class="card-body">
<form>
<div class="mb-3">
<label class="form-label">Current Password</label>
<input type="password" class="form-control" @bind="currentPassword" required />
</div>
<div class="mb-3">
<label class="form-label">New Password</label>
<input type="password" class="form-control" @bind="newPassword" required minlength="6" />
</div>
<div class="mb-3">
<label class="form-label">Confirm New Password</label>
<input type="password" class="form-control" @bind="confirmNewPassword" required />
</div>
<button type="submit" class="btn btn-primary" @onclick="DoChangePassword" disabled="@saving">
@(saving ? "Changing..." : "Change Password")
</button>
<a href="/" 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>
}
</form>
</div>
</div>
</div>
</div>
@code {
[CascadingParameter] private Task<AuthenticationState>? AuthState { get; set; }
private string currentPassword = "";
private string newPassword = "";
private string confirmNewPassword = "";
private bool saving;
private string? message;
private async Task DoChangePassword()
{
saving = true;
message = null;
try
{
if (string.IsNullOrWhiteSpace(currentPassword))
{
message = "Current password is required.";
return;
}
if (string.IsNullOrWhiteSpace(newPassword) || newPassword.Length < 6)
{
message = "New password must be at least 6 characters.";
return;
}
if (newPassword != confirmNewPassword)
{
message = "New passwords do not match.";
return;
}
var state = await AuthState!;
var userIdClaim = state.User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (userIdClaim == null || !int.TryParse(userIdClaim, out var userId))
{
message = "Error: Could not identify user.";
return;
}
if (!AuthService.ChangePassword(userId, currentPassword, newPassword))
{
message = "Error: Current password is incorrect.";
return;
}
message = "Password changed successfully!";
currentPassword = "";
newPassword = "";
confirmNewPassword = "";
}
catch (Exception ex)
{
message = $"Error: {ex.Message}";
}
finally
{
saving = false;
}
}
}
@@ -0,0 +1,227 @@
@page "/config/communityboard"
@rendermode InteractiveServer
<PageTitle>Community Board</PageTitle>
<h3>Community Board</h3>
<p class="text-muted">Edit the community board content including featured players, announcements, Instagram images, and videos.</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="card mb-3">
<div class="card-header">Current Announcement</div>
<div class="card-body">
<div class="mb-3">
<label class="form-label">Message</label>
<input type="text" class="form-control" @bind="announcementMessage" />
</div>
<div class="mb-3">
<label class="form-label">More Info URL</label>
<input type="text" class="form-control" @bind="announcementUrl" />
</div>
</div>
</div>
<div class="card mb-3">
<div class="card-header">Featured Player</div>
<div class="card-body">
<div class="mb-3">
<label class="form-label">Player ID</label>
<input type="number" class="form-control" @bind="featuredPlayerId" />
</div>
<div class="mb-3">
<label class="form-label">Title Override</label>
<input type="text" class="form-control" @bind="featuredPlayerTitle" />
</div>
<div class="mb-3">
<label class="form-label">URL Override</label>
<input type="text" class="form-control" @bind="featuredPlayerUrl" />
</div>
</div>
</div>
<div class="card mb-3">
<div class="card-header">Videos</div>
<div class="card-body">
@if (videos.Count == 0)
{
<p class="text-muted">No videos.</p>
}
@for (int i = 0; i < videos.Count; i++)
{
var idx = i;
<div class="border rounded p-3 mb-2">
<div class="mb-2">
<label class="form-label">Title</label>
<input type="text" class="form-control" @bind="videos[idx].Title" />
</div>
<div class="mb-2">
<label class="form-label">Description</label>
<textarea class="form-control" rows="2" @bind="videos[idx].Description"></textarea>
</div>
<div class="mb-2">
<label class="form-label">Source URL</label>
<input type="text" class="form-control" @bind="videos[idx].SourceUrl" />
</div>
<div class="mb-2">
<label class="form-label">Blob Name</label>
<input type="text" class="form-control" @bind="videos[idx].BlobName" />
</div>
<div class="mb-2">
<label class="form-label">Thumbnail Blob</label>
<input type="text" class="form-control" @bind="videos[idx].ThumbnailBlobName" />
</div>
<button type="button" class="btn btn-danger btn-sm" @onclick="() => videos.RemoveAt(idx)">Remove</button>
</div>
}
<button type="button" class="btn btn-outline-primary btn-sm" @onclick="() => videos.Add(new VideoEntry())">Add Video</button>
</div>
</div>
<div class="mb-3">
<button type="submit" class="btn btn-success" @onclick="SaveFromForm" disabled="@saving">
@(saving ? "Saving..." : "Save Changes")
</button>
@if (!string.IsNullOrEmpty(saveMessage))
{
<span class="ms-2 @(saveMessage.Contains("Error") ? "text-danger" : "text-success")">@saveMessage</span>
}
</div>
</form>
}
@code {
[Inject] private HttpClient Http { get; set; } = default!;
private bool loading = true;
private bool saving;
private string? error;
private string? saveMessage;
private string announcementMessage = "";
private string announcementUrl = "";
private int featuredPlayerId;
private string? featuredPlayerTitle;
private string? featuredPlayerUrl;
private List<VideoEntry> videos = new();
protected override async Task OnInitializedAsync()
{
try
{
var jsonContent = await Http.GetStringAsync("/admin/api/config/communityboard");
using var doc = JsonDocument.Parse(jsonContent);
var root = doc.RootElement;
if (root.TryGetProperty("currentAnnouncement", out var ann))
{
announcementMessage = ann.TryGetProperty("message", out var m) ? m.GetString() ?? "" : "";
announcementUrl = ann.TryGetProperty("moreInfoUrl", out var u) ? u.GetString() ?? "" : "";
}
if (root.TryGetProperty("featuredPlayer", out var fp))
{
featuredPlayerId = fp.TryGetProperty("id", out var id) ? id.GetInt32() : 0;
featuredPlayerTitle = fp.TryGetProperty("titleOverride", out var t) ? t.GetString() : null;
featuredPlayerUrl = fp.TryGetProperty("urlOverride", out var url) ? url.GetString() : null;
}
if (root.TryGetProperty("videos", out var vids))
{
videos = new List<VideoEntry>();
foreach (var v in vids.EnumerateArray())
{
videos.Add(new VideoEntry
{
Title = v.TryGetProperty("title", out var title) ? title.GetString() ?? "" : "",
Description = v.TryGetProperty("description", out var desc) ? desc.GetString() ?? "" : "",
SourceUrl = v.TryGetProperty("sourceUrl", out var src) ? src.GetString() ?? "" : "",
BlobName = v.TryGetProperty("blobName", out var blob) ? blob.GetString() ?? "" : "",
ThumbnailBlobName = v.TryGetProperty("thumbnailBlobName", out var thumb) ? thumb.GetString() ?? "" : ""
});
}
}
}
catch (Exception ex)
{
error = $"Failed to load: {ex.Message}";
}
finally
{
loading = false;
}
}
private async Task SaveFromForm()
{
saving = true;
saveMessage = null;
try
{
var payload = new
{
featuredPlayer = new
{
id = featuredPlayerId,
titleOverride = featuredPlayerTitle,
urlOverride = featuredPlayerUrl
},
featuredRoomGroup = new
{
featuredRoomGroupId = 2,
name = "Featured Rooms",
rooms = new List<object>()
},
currentAnnouncement = new
{
message = announcementMessage,
moreInfoUrl = announcementUrl
},
instagramImages = new List<object>(),
videos = videos.Select(v => new
{
blobName = v.BlobName,
title = v.Title,
description = v.Description,
thumbnailBlobName = v.ThumbnailBlobName,
sourceUrl = v.SourceUrl
}).ToList()
};
var content = new StringContent(
System.Text.Json.JsonSerializer.Serialize(payload, new System.Text.Json.JsonSerializerOptions { WriteIndented = true }),
System.Text.Encoding.UTF8, "application/json");
var response = await Http.PutAsync("/admin/api/config/communityboard", content);
response.EnsureSuccessStatusCode();
saveMessage = "Saved successfully!";
}
catch (Exception ex)
{
saveMessage = $"Error: {ex.Message}";
}
finally
{
saving = false;
}
}
private class VideoEntry
{
public string Title { get; set; } = "";
public string Description { get; set; } = "";
public string SourceUrl { get; set; } = "";
public string BlobName { get; set; } = "";
public string ThumbnailBlobName { get; set; } = "";
}
}
@@ -0,0 +1,133 @@
@page "/config/gameconfig"
@rendermode InteractiveServer
<PageTitle>Game Config</PageTitle>
<h3>Game Config</h3>
<p class="text-muted">Edit the gameconfigs.json file that controls various game settings.</p>
@if (loading)
{
<div class="spinner-border" role="status"></div>
}
else if (!string.IsNullOrEmpty(error))
{
<div class="alert alert-danger">@error</div>
}
else
{
<ul class="nav nav-tabs mb-3">
<li class="nav-item">
<a class="nav-link @GetActiveClass("edit")" href="#" @onclick='() => SetTab("edit")' @onclick:preventDefault="true">Raw Editor</a>
</li>
<li class="nav-item">
<a class="nav-link @GetActiveClass("browse")" href="#" @onclick='() => SetTab("browse")' @onclick:preventDefault="true">Browse</a>
</li>
</ul>
@if (activeTab == "edit")
{
<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="ms-2 @(saveMessage.Contains("Error") ? "text-danger" : "text-success")">@saveMessage</span>
}
</div>
<textarea class="form-control font-monospace" rows="30" @bind="jsonContent"></textarea>
</form>
}
else if (activeTab == "browse")
{
<div class="mb-3">
<input type="text" class="form-control" placeholder="Filter keys..." @bind="filterText" @bind:event="oninput" />
</div>
<div class="table-responsive">
<table class="table table-sm table-striped">
<thead>
<tr><th>Key</th><th>Value</th></tr>
</thead>
<tbody>
@foreach (var entry in configEntries.Where(e => string.IsNullOrEmpty(filterText) || e.Key.Contains(filterText, StringComparison.OrdinalIgnoreCase)))
{
<tr>
<td class="font-monospace small">@entry.Key</td>
<td class="small" style="max-width: 400px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title="@entry.Value">@entry.Value</td>
</tr>
}
</tbody>
</table>
</div>
<div class="text-muted small">@configEntries.Count(e => string.IsNullOrEmpty(filterText) || e.Key.Contains(filterText, StringComparison.OrdinalIgnoreCase)) entries</div>
}
}
@code {
[Inject] private HttpClient Http { get; set; } = default!;
private bool loading = true;
private bool saving;
private string? error;
private string? saveMessage;
private string jsonContent = "";
private string activeTab = "browse";
private string filterText = "";
private List<ConfigEntry> configEntries = new();
protected override async Task OnInitializedAsync()
{
try
{
jsonContent = await Http.GetStringAsync("/admin/api/config/gameconfigs");
configEntries = JsonSerializer.Deserialize<List<ConfigEntry>>(jsonContent) ?? new();
}
catch (Exception ex)
{
error = $"Failed to load: {ex.Message}";
}
finally
{
loading = false;
}
}
private string GetActiveClass(string tab) => activeTab == tab ? "active" : "";
private void SetTab(string tab)
{
activeTab = tab;
}
private async Task Save()
{
saving = true;
saveMessage = null;
try
{
var content = new StringContent(jsonContent, System.Text.Encoding.UTF8, "application/json");
var response = await Http.PutAsync("/admin/api/config/gameconfigs", content);
response.EnsureSuccessStatusCode();
saveMessage = "Saved successfully!";
configEntries = JsonSerializer.Deserialize<List<ConfigEntry>>(jsonContent) ?? new();
}
catch (Exception ex)
{
saveMessage = $"Error: {ex.Message}";
}
finally
{
saving = false;
}
}
private class ConfigEntry
{
public string Key { get; set; } = "";
public string Value { get; set; } = "";
public string? EndTime { get; set; }
public string? StartTime { get; set; }
}
}
@@ -0,0 +1,115 @@
@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;
}
}
}
@@ -1,19 +0,0 @@
@page "/counter"
@rendermode InteractiveServer
<PageTitle>Counter</PageTitle>
<h1>Counter</h1>
<p role="status">Current count: @currentCount</p>
<button class="btn btn-primary" @onclick="IncrementCount">Click me</button>
@code {
private int currentCount = 0;
private void IncrementCount()
{
currentCount++;
}
}
+4 -10
View File
@@ -1,12 +1,12 @@
@page "/Error"
@using System.Diagnostics
@rendermode InteractiveServer
<PageTitle>Error</PageTitle>
<h1 class="text-danger">Error.</h1>
<h2 class="text-danger">An error occurred while processing your request.</h2>
@if (ShowRequestId)
@if (!string.IsNullOrEmpty(RequestId))
{
<p>
<strong>Request ID:</strong> <code>@RequestId</code>
@@ -20,17 +20,11 @@
<p>
<strong>The Development environment shouldn't be enabled for deployed applications.</strong>
It can result in displaying sensitive information from exceptions to end users.
For local debugging, enable the <strong>Development</strong> environment by setting the <strong>ASPNETCORE_ENVIRONMENT</strong> environment variable to <strong>Development</strong>
For local debugging, you can enable the <strong>Development</strong> environment by setting the <strong>ASPNETCORE_ENVIRONMENT</strong> environment variable to <strong>Development</strong>
and restarting the app.
</p>
@code {
[CascadingParameter]
private HttpContext? HttpContext { get; set; }
[SupplyParameterFromQuery]
private string? RequestId { get; set; }
private bool ShowRequestId => !string.IsNullOrEmpty(RequestId);
protected override void OnInitialized() =>
RequestId = Activity.Current?.Id ?? HttpContext?.TraceIdentifier;
}
+52 -2
View File
@@ -1,7 +1,57 @@
@page "/"
@attribute [Authorize]
<PageTitle>Home</PageTitle>
<h1>Hello, world!</h1>
<div class="admin-dashboard">
<h1 class="mb-4">CannedNet Admin Panel</h1>
<p class="lead text-muted">Manage your CannedNet Rec Room private server instance.</p>
Welcome to your new app.
<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">
<h5 class="card-title">Users</h5>
<p class="card-text">Search, edit, and manage player accounts.</p>
<a href="users/search" class="btn btn-primary">Manage Users</a>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card">
<div class="card-body">
<h5 class="card-title">Rooms</h5>
<p class="card-text">Search, edit, create, and delete rooms and subrooms.</p>
<a href="rooms/search" class="btn btn-primary">Manage Rooms</a>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card">
<div class="card-body">
<h5 class="card-title">Config</h5>
<p class="card-text">Modify server configuration files: NS config, game configs, community board.</p>
<a href="config/nameserver" class="btn btn-primary">Manage Config</a>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card">
<div class="card-body">
<h5 class="card-title">Storefronts</h5>
<p class="card-text">Manage storefront items, prices, and create new items.</p>
<a href="storefronts" class="btn btn-primary">Manage Storefronts</a>
</div>
</div>
</div>
</div>
</div>
@@ -0,0 +1,67 @@
@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>
</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>
}
@code {
private string? errorMessage;
protected override void OnInitialized()
{
var uri = new Uri(Navigation.Uri);
var query = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(uri.Query);
var error = query.TryGetValue("error", out var val) ? val.FirstOrDefault() : null;
errorMessage = error switch
{
"invalid" => "Invalid username or password.",
"missing" => "Please enter both username and password.",
"expired" => "Your session has expired. Please log in again.",
_ => null
};
}
}
@@ -1,5 +1,8 @@
@page "/not-found"
@layout MainLayout
@rendermode InteractiveServer
<h3>Not Found</h3>
<p>Sorry, the content you are looking for does not exist.</p>
<PageTitle>Not Found</PageTitle>
<h1>Page Not Found</h1>
<p>The page you're looking for doesn't exist.</p>
<a href="/" class="btn btn-primary">Go Home</a>
@@ -0,0 +1,176 @@
@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;
}
}
}
@@ -0,0 +1,358 @@
@page "/rooms/edit/{RoomId:int}"
@rendermode InteractiveServer
@inject ImageUrlService Img
<PageTitle>Edit Room @RoomId</PageTitle>
<h3>Edit Room #@RoomId</h3>
@if (loading)
{
<div class="spinner-border" role="status"></div>
}
else if (!string.IsNullOrEmpty(error))
{
<div class="alert alert-danger">@error</div>
}
else if (roomData == null)
{
<div class="alert alert-warning">Room not found.</div>
}
else
{
<div class="row">
<div class="col-md-12">
<form>
<div class="row">
<div class="col-md-6">
<div class="card mb-3">
<div class="card-header">Room Info</div>
<div class="card-body">
<div class="mb-3">
<label class="form-label">Room ID</label>
<input type="number" class="form-control" @bind="editRoomId" />
</div>
<div class="mb-3">
<label class="form-label">Name</label>
<input type="text" class="form-control" @bind="editName" />
</div>
<div class="mb-3">
<label class="form-label">Description</label>
<textarea class="form-control" rows="3" @bind="editDescription"></textarea>
</div>
<div class="mb-3">
<label class="form-label">Image Name</label>
<input type="text" class="form-control" @bind="editImageName" />
@if (!string.IsNullOrEmpty(editImageName))
{
<img src="@Img.GetUrl(editImageName)" alt="preview" class="mt-2 img-thumbnail" style="max-width: 200px;" onerror="this.style.display='none'" />
}
</div>
<div class="mb-3">
<label class="form-label">Creator Account ID</label>
<input type="number" class="form-control" @bind="editCreatorAccountId" />
</div>
<div class="mb-3">
<label class="form-label">Tags (JSON)</label>
<textarea class="form-control font-monospace" rows="3" @bind="editTags"></textarea>
</div>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card mb-3">
<div class="card-header">Settings</div>
<div class="card-body">
<div class="mb-3">
<label class="form-label">State</label>
<input type="number" class="form-control" @bind="editState" />
</div>
<div class="mb-3">
<label class="form-label">Accessibility</label>
<input type="number" class="form-control" @bind="editAccessibility" />
</div>
<div class="mb-3">
<label class="form-label">Min Level</label>
<input type="number" class="form-control" @bind="editMinLevel" />
</div>
<div class="mb-3">
<label class="form-label">Warning Mask</label>
<input type="number" class="form-control" @bind="editWarningMask" />
</div>
<div class="mb-3">
<label class="form-label">Custom Warning</label>
<input type="text" class="form-control" @bind="editCustomWarning" />
</div>
</div>
</div>
<div class="card mb-3">
<div class="card-header">Flags</div>
<div class="card-body">
<div class="form-check">
<input type="checkbox" class="form-check-input" @bind="editIsDorm" />
<label class="form-check-label">Is Dorm</label>
</div>
<div class="form-check">
<input type="checkbox" class="form-check-input" @bind="editIsRRO" />
<label class="form-check-label">Is RRO</label>
</div>
<div class="form-check">
<input type="checkbox" class="form-check-input" @bind="editCloningAllowed" />
<label class="form-check-label">Cloning Allowed</label>
</div>
<hr />
<h6>Platform Support</h6>
<div class="form-check">
<input type="checkbox" class="form-check-input" @bind="editSupportsVRLow" />
<label class="form-check-label">VR Low</label>
</div>
<div class="form-check">
<input type="checkbox" class="form-check-input" @bind="editSupportsQuest2" />
<label class="form-check-label">Quest 2</label>
</div>
<div class="form-check">
<input type="checkbox" class="form-check-input" @bind="editSupportsMobile" />
<label class="form-check-label">Mobile</label>
</div>
<div class="form-check">
<input type="checkbox" class="form-check-input" @bind="editSupportsScreens" />
<label class="form-check-label">Screens</label>
</div>
<div class="form-check">
<input type="checkbox" class="form-check-input" @bind="editSupportsWalkVR" />
<label class="form-check-label">Walk VR</label>
</div>
<div class="form-check">
<input type="checkbox" class="form-check-input" @bind="editSupportsTeleportVR" />
<label class="form-check-label">Teleport VR</label>
</div>
<div class="form-check">
<input type="checkbox" class="form-check-input" @bind="editSupportsJuniors" />
<label class="form-check-label">Supports Juniors</label>
</div>
</div>
</div>
</div>
</div>
@if (roomData.SubRooms?.Count > 0)
{
<div class="card mb-3">
<div class="card-header">SubRooms (@roomData.SubRooms.Count)</div>
<div class="card-body">
<table class="table table-sm">
<thead>
<tr><th>ID</th><th>SubRoomId</th><th>Name</th><th>Max Players</th><th>Is Sandbox</th><th>Accessibility</th></tr>
</thead>
<tbody>
@foreach (var sub in roomData.SubRooms)
{
<tr>
<td>@sub.Id</td>
<td>@sub.SubRoomId</td>
<td>@sub.Name</td>
<td>@sub.MaxPlayers</td>
<td>@sub.IsSandbox</td>
<td>@sub.Accessibility</td>
</tr>
}
</tbody>
</table>
</div>
</div>
}
@if (roomData.LoadScreens?.Count > 0)
{
<div class="card mb-3">
<div class="card-header">Load Screens (@roomData.LoadScreens.Count)</div>
<div class="card-body">
@foreach (var ls in roomData.LoadScreens)
{
<div>@ls.ImageUrl - @ls.Tooltip</div>
}
</div>
</div>
}
<div class="d-flex gap-2 mb-3">
<button type="submit" class="btn btn-success" @onclick="SaveChanges" disabled="@saving">
@(saving ? "Saving..." : "Save Changes")
</button>
<button type="button" class="btn btn-danger" @onclick="DeleteRoom" disabled="@deleting">
@(deleting ? "Deleting..." : "Delete Room")
</button>
<a href="rooms/search" class="btn btn-outline-secondary">Back to Search</a>
</div>
@if (!string.IsNullOrEmpty(saveMessage))
{
<div class="alert mt-2 @(saveMessage.Contains("Error") || saveMessage.Contains("Failed") ? "alert-danger" : "alert-success")">@saveMessage</div>
}
</form>
</div>
</div>
}
@code {
[Parameter] public int RoomId { get; set; }
private bool loading = true;
private bool saving;
private bool deleting;
private string? error;
private string? saveMessage;
private RoomDetails? roomData;
private int editRoomId;
private string editName = "";
private string editDescription = "";
private string editImageName = "";
private int editCreatorAccountId;
private string editTags = "[]";
private int editState;
private int editAccessibility;
private int editMinLevel;
private int editWarningMask;
private string? editCustomWarning;
private bool editIsDorm;
private bool editIsRRO;
private bool editCloningAllowed;
private bool editSupportsVRLow;
private bool editSupportsQuest2;
private bool editSupportsMobile;
private bool editSupportsScreens;
private bool editSupportsWalkVR;
private bool editSupportsTeleportVR;
private bool editSupportsJuniors;
protected override async Task OnInitializedAsync()
{
await LoadRoom();
}
private async Task LoadRoom()
{
loading = true;
error = null;
try
{
roomData = await Http.GetFromJsonAsync<RoomDetails>($"/admin/api/rooms/{RoomId}");
if (roomData?.Room != null)
{
var r = roomData.Room;
editRoomId = r.RoomId;
editName = r.Name;
editDescription = r.Description;
editImageName = r.ImageName ?? "";
editCreatorAccountId = r.CreatorAccountId;
editTags = r.Tags ?? "[]";
editState = r.State;
editAccessibility = r.Accessibility;
editMinLevel = r.MinLevel;
editWarningMask = r.WarningMask;
editCustomWarning = r.CustomWarning;
editIsDorm = r.IsDorm;
editIsRRO = r.IsRRO;
editCloningAllowed = r.CloningAllowed;
editSupportsVRLow = r.SupportsVRLow;
editSupportsQuest2 = r.SupportsQuest2;
editSupportsMobile = r.SupportsMobile;
editSupportsScreens = r.SupportsScreens;
editSupportsWalkVR = r.SupportsWalkVR;
editSupportsTeleportVR = r.SupportsTeleportVR;
editSupportsJuniors = r.SupportsJuniors;
}
}
catch (Exception ex)
{
error = $"Failed to load room: {ex.Message}";
}
finally
{
loading = false;
}
}
private async Task SaveChanges()
{
saving = true;
saveMessage = null;
try
{
var payload = new
{
name = editName,
description = editDescription,
imageName = editImageName,
state = editState,
accessibility = editAccessibility,
isDorm = editIsDorm,
isRRO = editIsRRO,
cloningAllowed = editCloningAllowed,
minLevel = editMinLevel,
warningMask = editWarningMask,
customWarning = editCustomWarning,
supportsVRLow = editSupportsVRLow,
supportsQuest2 = editSupportsQuest2,
supportsMobile = editSupportsMobile,
supportsScreens = editSupportsScreens,
supportsWalkVR = editSupportsWalkVR,
supportsTeleportVR = editSupportsTeleportVR,
supportsJuniors = editSupportsJuniors,
tags = editTags
};
var response = await Http.PutAsJsonAsync($"/admin/api/rooms/{RoomId}", payload);
response.EnsureSuccessStatusCode();
saveMessage = "Room updated successfully!";
}
catch (Exception ex)
{
saveMessage = $"Error: {ex.Message}";
}
finally
{
saving = false;
}
}
private async Task DeleteRoom()
{
if (!await ConfirmDelete()) return;
deleting = true;
saveMessage = null;
try
{
var response = await Http.DeleteAsync($"/admin/api/rooms/{RoomId}");
response.EnsureSuccessStatusCode();
saveMessage = "Room deleted successfully!";
}
catch (Exception ex)
{
saveMessage = $"Error: {ex.Message}";
}
finally
{
deleting = false;
}
}
private async Task<bool> ConfirmDelete()
{
var confirmed = await JSRuntime.InvokeAsync<bool>("confirm", $"Are you sure you want to delete room #{RoomId} and all its subrooms, load screens, promotions, and roles? This cannot be undone.");
return confirmed;
}
[Inject] private IJSRuntime JSRuntime { get; set; } = default!;
[Inject] private HttpClient Http { get; set; } = default!;
private class RoomDetails
{
public Room? Room { get; set; }
public List<SubRoom>? SubRooms { get; set; }
public List<LoadScreen>? LoadScreens { get; set; }
}
}
@@ -0,0 +1,96 @@
@page "/rooms/search"
@rendermode InteractiveServer
@inject ImageUrlService Img
<PageTitle>Search Rooms</PageTitle>
<h3>Search Rooms</h3>
<p class="text-muted">Search for rooms by name, description, or room ID.</p>
<div class="input-group mb-3">
<input type="text" class="form-control" placeholder="Search by name, description, or room ID..." @bind="searchQuery" @bind:event="oninput" />
<button class="btn btn-primary" @onclick="DoSearch" disabled="@(string.IsNullOrWhiteSpace(searchQuery) || searching)">
@(searching ? "Searching..." : "Search")
</button>
</div>
@if (searching)
{
<div class="spinner-border" role="status"></div>
}
else if (!string.IsNullOrEmpty(error))
{
<div class="alert alert-danger">@error</div>
}
else if (results != null && results.Count == 0)
{
<div class="alert alert-info">No rooms found matching "@searchQuery".</div>
}
else if (results != null)
{
<div class="table-responsive">
<table class="table table-striped table-hover">
<thead>
<tr>
<th>Room ID</th>
<th>Image</th>
<th>Name</th>
<th>Creator</th>
<th>State</th>
<th>Dorm</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@foreach (var room in results)
{
<tr>
<td>@room.RoomId</td>
<td>
@if (!string.IsNullOrEmpty(room.ImageName))
{
<img src="@Img.GetUrl(room.ImageName)" alt="room" style="width: 60px; height: 40px; object-fit: cover; border-radius: 4px;" onerror="this.style.display='none'" />
}
</td>
<td>@room.Name</td>
<td>@room.CreatorAccountId</td>
<td>@room.State</td>
<td>@(room.IsDorm ? "Yes" : "No")</td>
<td>
<a href="rooms/edit/@room.RoomId" class="btn btn-sm btn-outline-primary">Edit</a>
</td>
</tr>
}
</tbody>
</table>
</div>
}
@code {
[Inject] private HttpClient Http { get; set; } = default!;
private string searchQuery = "";
private bool searching;
private string? error;
private List<Room>? results;
private async Task DoSearch()
{
searching = true;
error = null;
results = null;
try
{
results = await Http.GetFromJsonAsync<List<Room>>($"/admin/api/rooms/search?q={Uri.EscapeDataString(searchQuery)}");
}
catch (Exception ex)
{
error = $"Search failed: {ex.Message}";
}
finally
{
searching = false;
}
}
}
@@ -0,0 +1,144 @@
@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!;
}
@@ -0,0 +1,110 @@
@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;
}
}
}
@@ -0,0 +1,110 @@
@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;
}
}
}
@@ -0,0 +1,277 @@
@page "/storefronts"
@rendermode InteractiveServer
<PageTitle>Storefronts</PageTitle>
<h3>Storefronts</h3>
<p class="text-muted">Manage storefronts and their items.</p>
@if (loading)
{
<div class="spinner-border" role="status"></div>
}
else if (!string.IsNullOrEmpty(error))
{
<div class="alert alert-danger">@error</div>
}
else if (storefronts == null || storefronts.Count == 0)
{
<div class="alert alert-info">No storefronts found.</div>
}
else
{
@foreach (var store in storefronts)
{
<div class="card mb-4">
<div class="card-header d-flex justify-content-between align-items-center">
<strong>@store.Name</strong>
<span class="badge bg-secondary">Type: @store.StorefrontType | Items: @(store.Items?.Count ?? 0)</span>
</div>
<div class="card-body">
@if (store.Items != null && store.Items.Count > 0)
{
<div class="table-responsive">
<table class="table table-sm table-striped">
<thead>
<tr>
<th>ID</th>
<th>PurchasableItemId</th>
<th>Type</th>
<th>Featured</th>
<th>Prices</th>
<th>Gift Drops</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@foreach (var item in store.Items)
{
<tr>
<td>@item.Id</td>
<td>@item.PurchasableItemId</td>
<td>@item.Type</td>
<td>@(item.IsFeatured ? "Yes" : "No")</td>
<td>
@if (item.Prices != null && item.Prices.Count > 0)
{
@foreach (var p in item.Prices)
{
<div>Type @p.CurrencyType: @p.Price</div>
}
}
</td>
<td>@(item.GiftDrops?.Count ?? 0)</td>
<td>
<button class="btn btn-sm btn-outline-primary" @onclick="() => EditItem(store.Id, item)">Edit</button>
<button class="btn btn-sm btn-outline-danger" @onclick="() => DeleteItem(store.Id, item.Id)">Delete</button>
</td>
</tr>
}
</tbody>
</table>
</div>
}
else
{
<p class="text-muted">No items in this storefront.</p>
}
<button class="btn btn-sm btn-success" @onclick="() => StartCreateItem(store.Id)">+ Add Item</button>
</div>
</div>
}
@if (showEditModal)
{
<div class="modal d-block" tabindex="-1" style="background: rgba(0,0,0,0.5);">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">@(editingItem.Id == 0 ? "Create Item" : "Edit Item")</h5>
<button type="button" class="btn-close" @onclick="CloseModal"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label">Purchasable Item ID</label>
<input type="number" class="form-control" @bind="editPurchasableItemId" />
</div>
<div class="mb-3">
<label class="form-label">Type</label>
<input type="number" class="form-control" @bind="editType" />
</div>
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" @bind="editIsFeatured" />
<label class="form-check-label">Is Featured</label>
</div>
<div class="mb-3">
<label class="form-label">New Until (optional, ISO date)</label>
<input type="text" class="form-control" @bind="editNewUntil" placeholder="2025-12-31T23:59:59" />
</div>
<hr />
<h6>Prices</h6>
<div class="mb-3">
<label class="form-label">Currency Type</label>
<input type="number" class="form-control" @bind="editCurrencyType" />
</div>
<div class="mb-3">
<label class="form-label">Price</label>
<input type="number" class="form-control" @bind="editPrice" />
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" @onclick="CloseModal">Cancel</button>
<button type="button" class="btn btn-primary" @onclick="SaveItem" disabled="@savingItem">
@(savingItem ? "Saving..." : "Save")
</button>
</div>
@if (!string.IsNullOrEmpty(modalMessage))
{
<div class="alert @(modalMessage.Contains("Error") ? "alert-danger" : "alert-success") mb-0 mx-3">@modalMessage</div>
}
</div>
</div>
</div>
}
}
@code {
private bool loading = true;
private string? error;
private List<Storefront>? storefronts;
private bool showEditModal;
private bool savingItem;
private string? modalMessage;
private StorefrontItem editingItem = new();
private int editingStorefrontId;
private int editPurchasableItemId;
private int editType;
private bool editIsFeatured;
private string? editNewUntil;
private int editCurrencyType;
private int editPrice;
protected override async Task OnInitializedAsync()
{
await LoadStorefronts();
}
private async Task LoadStorefronts()
{
loading = true;
error = null;
try
{
storefronts = await Http.GetFromJsonAsync<List<Storefront>>("/admin/api/storefronts");
}
catch (Exception ex)
{
error = $"Failed to load storefronts: {ex.Message}";
}
finally
{
loading = false;
}
}
private void EditItem(int storefrontId, StorefrontItem item)
{
editingStorefrontId = storefrontId;
editingItem = item;
editPurchasableItemId = item.PurchasableItemId;
editType = item.Type;
editIsFeatured = item.IsFeatured;
editNewUntil = item.NewUntil?.ToString("o");
editCurrencyType = item.Prices?.FirstOrDefault()?.CurrencyType ?? 0;
editPrice = item.Prices?.FirstOrDefault()?.Price ?? 0;
modalMessage = null;
showEditModal = true;
}
private void StartCreateItem(int storefrontId)
{
editingStorefrontId = storefrontId;
editingItem = new StorefrontItem { StorefrontId = storefrontId };
editPurchasableItemId = 0;
editType = 0;
editIsFeatured = false;
editNewUntil = null;
editCurrencyType = 0;
editPrice = 0;
modalMessage = null;
showEditModal = true;
}
private void CloseModal()
{
showEditModal = false;
modalMessage = null;
}
private async Task SaveItem()
{
savingItem = true;
modalMessage = null;
try
{
if (editingItem.Id == 0)
{
var payload = new
{
purchasableItemId = editPurchasableItemId,
type = editType,
isFeatured = editIsFeatured,
newUntil = editNewUntil,
price = editPrice,
currencyType = editCurrencyType
};
var response = await Http.PostAsJsonAsync($"/admin/api/storefronts/{editingStorefrontId}/items", payload);
response.EnsureSuccessStatusCode();
modalMessage = "Item created!";
}
else
{
var payload = new
{
purchasableItemId = editPurchasableItemId,
type = editType,
isFeatured = editIsFeatured,
newUntil = string.IsNullOrEmpty(editNewUntil) ? null : editNewUntil,
prices = new[] { new { currencyType = editCurrencyType, price = editPrice } }
};
var response = await Http.PutAsJsonAsync($"/admin/api/storefronts/items/{editingItem.Id}", payload);
response.EnsureSuccessStatusCode();
modalMessage = "Item updated!";
}
await LoadStorefronts();
}
catch (Exception ex)
{
modalMessage = $"Error: {ex.Message}";
}
finally
{
savingItem = false;
}
}
private async Task DeleteItem(int storefrontId, int itemId)
{
var confirmed = await JSRuntime.InvokeAsync<bool>("confirm", "Delete this item?");
if (!confirmed) return;
try
{
var response = await Http.DeleteAsync($"/admin/api/storefronts/items/{itemId}");
response.EnsureSuccessStatusCode();
await LoadStorefronts();
}
catch (Exception ex)
{
error = $"Failed to delete: {ex.Message}";
}
}
[Inject] private IJSRuntime JSRuntime { get; set; } = default!;
[Inject] private HttpClient Http { get; set; } = default!;
}
@@ -0,0 +1,278 @@
@page "/users/edit/{Id:int}"
@rendermode InteractiveServer
@inject ImageUrlService Img
<PageTitle>Edit User @Id</PageTitle>
<h3>Edit User #@Id</h3>
@if (loading)
{
<div class="spinner-border" role="status"></div>
}
else if (!string.IsNullOrEmpty(error))
{
<div class="alert alert-danger">@error</div>
}
else if (data == null)
{
<div class="alert alert-warning">User not found.</div>
}
else
{
<div class="row">
<div class="col-md-4 mb-4">
<div class="card">
<div class="card-body text-center">
@if (data.Account != null && !string.IsNullOrEmpty(data.Account.ProfileImage))
{
<img src="@Img.GetUrl(data.Account!.ProfileImage)" alt="Profile" class="img-fluid rounded-circle mb-3" style="max-width: 150px; max-height: 150px; object-fit: cover;" onerror="this.src='data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 width=%22150%22 height=%22150%22><rect fill=%22%23ddd%22 width=%22150%22 height=%22150%22/><text fill=%22%23666%22 font-size=%2250%22 x=%2250%25%22 y=%2255%25%22 text-anchor=%22middle%22>?</text></svg>'" />
}
<h5 class="mt-2">@data.Account!.Username</h5>
<p class="text-muted">@data.Account!.DisplayName</p>
<p class="small">ID: @data.Account!.AccountId</p>
</div>
</div>
@if (data.TokenBalances?.Count > 0)
{
<div class="card mt-3">
<div class="card-header">Token Balances</div>
<div class="card-body">
@foreach (var token in data.TokenBalances)
{
<div>Type @token.CurrencyType: @token.Balance</div>
}
</div>
</div>
}
@if (data.Progression != null)
{
<div class="card mt-3">
<div class="card-header">Progression</div>
<div class="card-body">
<div>Level: @data.Progression.Level</div>
<div>XP: @data.Progression.Xp</div>
</div>
</div>
}
</div>
<div class="col-md-8">
<form>
<div class="card mb-3">
<div class="card-header">Account Details</div>
<div class="card-body">
<div class="mb-3">
<label class="form-label">Username</label>
<input type="text" class="form-control" @bind="editUsername" />
</div>
<div class="mb-3">
<label class="form-label">Display Name</label>
<input type="text" class="form-control" @bind="editDisplayName" />
</div>
<div class="mb-3">
<label class="form-label">Profile Image Name</label>
<input type="text" class="form-control" @bind="editProfileImage" />
</div>
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" @bind="editIsJunior" />
<label class="form-check-label">Is Junior</label>
</div>
<div class="mb-3">
<label class="form-label">Personal Pronouns</label>
<input type="number" class="form-control" @bind="editPersonalPronouns" />
</div>
<div class="mb-3">
<label class="form-label">Identity Flags</label>
<input type="number" class="form-control" @bind="editIdentityFlags" />
</div>
</div>
</div>
@if (!string.IsNullOrEmpty(data.Bio))
{
<div class="card mb-3">
<div class="card-header">Bio</div>
<div class="card-body">
<p>@data.Bio</p>
</div>
</div>
}
@if (data.AvatarItems?.Count > 0)
{
<div class="card mb-3">
<div class="card-header">Avatar Items (@data.AvatarItems.Count)</div>
<div class="card-body" style="max-height: 300px; overflow-y: auto;">
<table class="table table-sm">
<thead>
<tr>
<th>ID</th>
<th>Name</th>
<th>Type</th>
<th>Rarity</th>
</tr>
</thead>
<tbody>
@foreach (var item in data.AvatarItems)
{
<tr>
<td>@item.Id</td>
<td>@item.FriendlyName</td>
<td>@item.AvatarItemType</td>
<td>@item.Rarity</td>
</tr>
}
</tbody>
</table>
</div>
</div>
}
@if (data.Settings?.Count > 0)
{
<div class="card mb-3">
<div class="card-header">Player Settings (@data.Settings.Count)</div>
<div class="card-body" style="max-height: 300px; overflow-y: auto;">
@foreach (var setting in data.Settings)
{
<div><strong>@setting.Key:</strong> @setting.Value</div>
}
</div>
</div>
}
<div class="d-flex gap-2">
<button type="submit" class="btn btn-success" @onclick="SaveChanges" disabled="@saving">
@(saving ? "Saving..." : "Save Changes")
</button>
<button type="button" class="btn btn-danger" @onclick="ConfirmAndDeleteUser" disabled="@deleting">
@(deleting ? "Deleting..." : "Delete User")
</button>
<a href="users/search" class="btn btn-outline-secondary">Back to Search</a>
</div>
@if (!string.IsNullOrEmpty(saveMessage))
{
<div class="alert mt-3 @(saveMessage.Contains("Error") ? "alert-danger" : "alert-success")">@saveMessage</div>
}
</form>
</div>
</div>
}
@code {
[Parameter] public int Id { get; set; }
[Inject] private IJSRuntime JSRuntime { get; set; } = default!;
[Inject] private HttpClient Http { get; set; } = default!;
private bool loading = true;
private bool saving;
private bool deleting;
private string? error;
private string? saveMessage;
private AccountDetails? data;
private string editUsername = "";
private string editDisplayName = "";
private string editProfileImage = "";
private bool editIsJunior;
private int editPersonalPronouns;
private int editIdentityFlags;
protected override async Task OnInitializedAsync()
{
await LoadUser();
}
private async Task LoadUser()
{
loading = true;
error = null;
try
{
data = await Http.GetFromJsonAsync<AccountDetails>($"/admin/api/accounts/{Id}");
if (data?.Account != null)
{
editUsername = data.Account.Username ?? "";
editDisplayName = data.Account.DisplayName ?? "";
editProfileImage = data.Account.ProfileImage ?? "";
editIsJunior = data.Account.IsJunior;
editPersonalPronouns = data.Account.PersonalPronouns ?? 0;
editIdentityFlags = data.Account.IdentityFlags ?? 0;
}
}
catch (Exception ex)
{
error = $"Failed to load user: {ex.Message}";
}
finally
{
loading = false;
}
}
private async Task SaveChanges()
{
saving = true;
saveMessage = null;
try
{
var payload = new
{
username = editUsername,
displayName = editDisplayName,
profileImage = editProfileImage,
isJunior = editIsJunior,
personalPronouns = editPersonalPronouns,
identityFlags = editIdentityFlags
};
var response = await Http.PutAsJsonAsync($"/admin/api/accounts/{Id}", payload);
response.EnsureSuccessStatusCode();
saveMessage = "User updated successfully!";
}
catch (Exception ex)
{
saveMessage = $"Error: {ex.Message}";
}
finally
{
saving = false;
}
}
private async Task ConfirmAndDeleteUser()
{
var confirmed = await JSRuntime.InvokeAsync<bool>("confirm", $"Are you sure you want to delete user #{Id}? This cannot be undone.");
if (!confirmed) return;
deleting = true;
saveMessage = null;
try
{
var response = await Http.DeleteAsync($"/admin/api/accounts/{Id}");
response.EnsureSuccessStatusCode();
saveMessage = "User deleted successfully!";
}
catch (Exception ex)
{
saveMessage = $"Error: {ex.Message}";
}
finally
{
deleting = false;
}
}
private class AccountDetails
{
public Account? Account { get; set; }
public string? Bio { get; set; }
public PlayerProgression? Progression { get; set; }
public List<AvatarItem>? AvatarItems { get; set; }
public List<PlayerSetting>? Settings { get; set; }
public List<TokenBalance>? TokenBalances { get; set; }
}
}
@@ -0,0 +1,94 @@
@page "/users/search"
@rendermode InteractiveServer
@inject ImageUrlService Img
<PageTitle>Search Users</PageTitle>
<h3>Search Users</h3>
<p class="text-muted">Search for player accounts by username, display name, or account ID.</p>
<div class="input-group mb-3">
<input type="text" class="form-control" placeholder="Search by username, display name, or ID..." @bind="searchQuery" @bind:event="oninput" />
<button class="btn btn-primary" @onclick="DoSearch" disabled="@(string.IsNullOrWhiteSpace(searchQuery) || searching)">
@(searching ? "Searching..." : "Search")
</button>
</div>
@if (searching)
{
<div class="spinner-border" role="status"></div>
}
else if (!string.IsNullOrEmpty(error))
{
<div class="alert alert-danger">@error</div>
}
else if (results != null && results.Count == 0)
{
<div class="alert alert-info">No users found matching "@searchQuery".</div>
}
else if (results != null)
{
<div class="table-responsive">
<table class="table table-striped table-hover">
<thead>
<tr>
<th>ID</th>
<th>Profile Image</th>
<th>Username</th>
<th>Display Name</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@foreach (var account in results)
{
<tr>
<td>@account.AccountId</td>
<td>
@if (!string.IsNullOrEmpty(account.ProfileImage))
{
<img src="@Img.GetUrl(account.ProfileImage)" alt="pfp" style="width: 40px; height: 40px; object-fit: cover; border-radius: 50%;" onerror="this.style.display='none'" />
}
</td>
<td>@account.Username</td>
<td>@account.DisplayName</td>
<td>@account.CreatedAt.ToString("g")</td>
<td>
<a href="users/edit/@account.AccountId" class="btn btn-sm btn-outline-primary">Edit</a>
</td>
</tr>
}
</tbody>
</table>
</div>
}
@code {
[Inject] private HttpClient Http { get; set; } = default!;
private string searchQuery = "";
private bool searching;
private string? error;
private List<Account>? results;
private async Task DoSearch()
{
searching = true;
error = null;
results = null;
try
{
results = await Http.GetFromJsonAsync<List<Account>>($"/admin/api/accounts/search?q={Uri.EscapeDataString(searchQuery)}");
}
catch (Exception ex)
{
error = $"Search failed: {ex.Message}";
}
finally
{
searching = false;
}
}
}
@@ -1,64 +0,0 @@
@page "/weather"
@attribute [StreamRendering]
<PageTitle>Weather</PageTitle>
<h1>Weather</h1>
<p>This component demonstrates showing data.</p>
@if (forecasts == null)
{
<p><em>Loading...</em></p>
}
else
{
<table class="table">
<thead>
<tr>
<th>Date</th>
<th aria-label="Temperature in Celsius">Temp. (C)</th>
<th aria-label="Temperature in Fahrenheit">Temp. (F)</th>
<th>Summary</th>
</tr>
</thead>
<tbody>
@foreach (var forecast in forecasts)
{
<tr>
<td>@forecast.Date.ToShortDateString()</td>
<td>@forecast.TemperatureC</td>
<td>@forecast.TemperatureF</td>
<td>@forecast.Summary</td>
</tr>
}
</tbody>
</table>
}
@code {
private WeatherForecast[]? forecasts;
protected override async Task OnInitializedAsync()
{
// Simulate asynchronous loading to demonstrate streaming rendering
await Task.Delay(500);
var startDate = DateOnly.FromDateTime(DateTime.Now);
var summaries = new[] { "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" };
forecasts = Enumerable.Range(1, 5).Select(index => new WeatherForecast
{
Date = startDate.AddDays(index),
TemperatureC = Random.Shared.Next(-20, 55),
Summary = summaries[Random.Shared.Next(summaries.Length)]
}).ToArray();
}
private class WeatherForecast
{
public DateOnly Date { get; set; }
public int TemperatureC { get; set; }
public string? Summary { get; set; }
public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
}
}
+5 -1
View File
@@ -1,6 +1,10 @@
<Router AppAssembly="typeof(Program).Assembly" NotFoundPage="typeof(Pages.NotFound)">
<Found Context="routeData">
<RouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)" />
<AuthorizeRouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)">
<NotAuthorized>
<RedirectToLogin />
</NotAuthorized>
</AuthorizeRouteView>
<FocusOnNavigate RouteData="routeData" Selector="h1" />
</Found>
</Router>
@@ -1,11 +1,18 @@
@using System.Net.Http
@using System.Net.Http.Json
@using System.Text.Json
@using Microsoft.AspNetCore.Components.Authorization
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web
@using static Microsoft.AspNetCore.Components.Web.RenderMode
@using Microsoft.AspNetCore.Components.Web.Virtualization
@using Microsoft.AspNetCore.Authorization
@using Microsoft.JSInterop
@using CannedNet.Admin
@using CannedNet.Admin.Components
@using CannedNet.Admin.Components.Layout
@using CannedNet.Admin.Components.Pages
@using CannedNet.Admin.Services
@using CannedNet
@using CannedNet.Models
+21
View File
@@ -0,0 +1,21 @@
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; }
}
@@ -0,0 +1,81 @@
namespace CannedNet.Admin.Models;
public static class EndpointDefinitions
{
public static readonly (string Name, string Path)[] Endpoints =
[
("Accounts", "/accounts"),
("AI", "/ai"),
("API", "/api"),
("Auth", "/auth"),
("BugReporting", "/bugreporting"),
("Cards", "/cards"),
("CDN", "/cdn"),
("Chat", "/chat"),
("Clubs", "/clubs"),
("CMS", "/cms"),
("Commerce", "/commerce"),
("Data", "/data"),
("DataCollection", "/datacollection"),
("Discovery", "/discovery"),
("Econ", "/econ"),
("GameLogs", "/gamelogs"),
("Geo", "/geo"),
("Images", "/img"),
("Leaderboard", "/leaderboard"),
("Link", "/link"),
("Lists", "/lists"),
("Matchmaking", "/match"),
("Moderation", "/moderation"),
("Notifications", ""),
("PlatformNotifications", ""),
("PlayerSettings", "/playersettings"),
("RoomComments", "/roomcomments"),
("RoomieIntegrations", "/roomieintegrations"),
("Rooms", "/rooms"),
("Storage", "/storage"),
("Strings", "/strings"),
("StringsCDN", "/stringscdn"),
("Studio", "/studio"),
("Thorn", "/thorn"),
("Videos", "/videos"),
("WWW", ""),
];
public static string ExtractBaseUrl(string json)
{
using var doc = System.Text.Json.JsonDocument.Parse(json);
var root = doc.RootElement;
foreach (var (name, path) in Endpoints)
{
if (!string.IsNullOrEmpty(path) && root.TryGetProperty(name, out var urlEl))
{
var fullUrl = urlEl.GetString();
if (fullUrl != null && fullUrl.EndsWith(path))
return fullUrl[..^path.Length].TrimEnd('/');
}
}
foreach (var (name, _) in Endpoints)
{
if (root.TryGetProperty(name, out var urlEl))
{
var fullUrl = urlEl.GetString();
if (!string.IsNullOrEmpty(fullUrl) && Uri.TryCreate(fullUrl, UriKind.Absolute, out var uri))
return $"{uri.Scheme}://{uri.Host}{(uri.IsDefaultPort ? "" : $":{uri.Port}")}";
}
}
return "";
}
public static string BuildEndpointsJson(string baseUrl)
{
var clean = baseUrl.TrimEnd('/');
var dict = new Dictionary<string, string>();
foreach (var (name, path) in Endpoints)
dict[name] = clean + path;
return System.Text.Json.JsonSerializer.Serialize(dict, new System.Text.Json.JsonSerializerOptions { WriteIndented = true });
}
}
+90 -4
View File
@@ -1,22 +1,108 @@
using System.Security.Claims;
using CannedNet.Admin.Components;
using CannedNet.Admin.Services;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
builder.Services.AddRazorComponents()
.AddInteractiveServerComponents();
builder.Services.AddAuthentication("AdminCookie")
.AddCookie("AdminCookie", options =>
{
options.Cookie.Name = "CannedNetAdmin";
options.Cookie.HttpOnly = true;
options.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest;
options.Cookie.SameSite = SameSiteMode.Strict;
options.LoginPath = "/login";
options.LogoutPath = "/admin/logout";
options.AccessDeniedPath = "/login";
options.ExpireTimeSpan = TimeSpan.FromHours(8);
options.SlidingExpiration = true;
});
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("OwnerOnly", policy => policy.RequireClaim("role", "Owner"));
});
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.AddScoped(sp =>
{
var handler = sp.GetRequiredService<AdminApiKeyHandler>();
handler.InnerHandler = new SocketsHttpHandler();
return new HttpClient(handler) { BaseAddress = new Uri(serverUrl) };
});
var app = builder.Build();
// Configure the HTTP request pipeline.
// --- Admin Auth Endpoints ---
app.MapPost("/admin/login", async (HttpContext context, AdminAuthService authService) =>
{
var form = await context.Request.ReadFormAsync();
var username = form["username"].FirstOrDefault() ?? "";
var password = form["password"].FirstOrDefault() ?? "";
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
{
context.Response.Redirect("/login?error=missing");
return;
}
var user = authService.GetByUsername(username);
if (user == null || !authService.VerifyPassword(password, user.PasswordHash))
{
context.Response.Redirect("/login?error=invalid");
return;
}
var claims = new[]
{
new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()),
new Claim(ClaimTypes.Name, user.Username),
new Claim("role", user.IsOwner ? "Owner" : "Admin")
};
var identity = new ClaimsIdentity(claims, "AdminCookie");
var principal = new ClaimsPrincipal(identity);
await context.SignInAsync("AdminCookie", principal, new AuthenticationProperties
{
IsPersistent = true,
ExpiresUtc = DateTime.UtcNow.AddHours(8)
});
context.Response.Redirect("/");
});
app.MapGet("/admin/logout", async (HttpContext context) =>
{
await context.SignOutAsync("AdminCookie");
context.Response.Redirect("/login");
});
// --- Standard middleware ---
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler("/Error", createScopeForErrors: true);
// The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
app.UseHsts();
app.UseHttpsRedirection();
}
app.UseStatusCodePagesWithReExecute("/not-found", createScopeForStatusCodePages: true);
app.UseHttpsRedirection();
app.UseAuthentication();
app.UseAuthorization();
app.UseAntiforgery();
@@ -0,0 +1,21 @@
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);
}
}
@@ -0,0 +1,127 @@
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,17 @@
namespace CannedNet.Admin.Services;
public class ImageUrlService
{
private readonly string _baseUrl;
public ImageUrlService(string baseUrl)
{
_baseUrl = baseUrl.TrimEnd('/');
}
public string GetUrl(string? path)
{
if (string.IsNullOrEmpty(path)) return "";
return $"{_baseUrl}/{path.TrimStart('/')}";
}
}
+6 -1
View File
@@ -5,5 +5,10 @@
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
"AllowedHosts": "*",
"ServerBaseUrl": "http://localhost:5000",
"ImagesBaseUrl": "https://cannednet.lapis.codes/img",
"Admin": {
"ApiKey": "changeme-replace-with-a-secure-key"
}
}
+8
View File
@@ -58,3 +58,11 @@ h1:focus {
.form-floating > .form-control-plaintext:focus::placeholder, .form-floating > .form-control:focus::placeholder {
text-align: start;
}
.card {
box-shadow: 0 1px 3px rgba(0,0,0,0.12);
}
.card-header {
font-weight: 600;
}
+5 -5
View File
@@ -9,18 +9,18 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.8" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.8">
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.9" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.9">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.4" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.7" />
<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" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.8" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.9" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.2" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.8">
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.9">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
-1
View File
@@ -277,4 +277,3 @@ public class AppDbContext : DbContext
});
}
}
+1 -47
View File
@@ -1,47 +1 @@
{
"FeaturedPlayer": {
"Id": 1,
"TitleOverride": "GAY SEX!",
"UrlOverride": null
},
"FeaturedRoomGroup": {
"FeaturedRoomGroupId": 2,
"Name": "Featured Rooms",
"Rooms": [
]
},
"CurrentAnnouncement": {
"Message": "CannedNet :3",
"MoreInfoUrl": "https://lapis.codes/"
},
"InstagramImages": [
{
"ImageName": "gay.png",
"ImageUrl": "https://epicquest.live/photo/152846"
}
],
"Videos": [
{
"BlobName": "facq8grasrhep777er7bhxjz0.mp4",
"Title": "AbyssalFrostTrailer",
"Description": "AbyssalFrost\\nBy\\n@Snox\\n@Rick\\n@Jessica\\n@Fandorn",
"ThumbnailBlobName": "gay.png",
"SourceUrl": "https://www.youtube.com/watch?v=-eUGRKyYA0w"
},
{
"BlobName": "nan5xp4xmqu5izjdxoc7tq9h1.mp4",
"Title": "How To Get Items & XP in Radium! [5-Stars] [Tokens] [XP]",
"Description": "Today ill be teaching the Radium Players on how to get xp and cool items using basic in game rooms. Feel free to look at my other links below.\n\nguns.lol/cge\nhaunt.gg/or\nhaunt.gg/gr\nrec.net/user/rud\nepicquest.live/user/skid",
"ThumbnailBlobName": "gay.png",
"SourceUrl": "https://www.youtube.com/watch?v=emiEDPz4Va4"
},
{
"BlobName": "iy3n1k9am3zc87j0mgei6831u.mp4",
"Title": "Extreme quest Recap | Radium",
"Description": "I tried more extreme quets, are they as hard as they look? find out!\n\nthumbnail maker: @ThatFellaRR\n\nRadium is a 2021 build of rec room which is community hosted. This is in no way meant to create any revenue.\nto learn more join discord.gg/Radium-rr/\nalso check out speedrun.com/radium_",
"ThumbnailBlobName": "gay.png",
"SourceUrl": "https://www.youtube.com/watch?v=zQZ0idzAIrA"
}
]
}
{"featuredPlayer":{"id":1,"titleOverride":"GAY SEX!","urlOverride":null},"featuredRoomGroup":{"featuredRoomGroupId":2,"name":"Featured Rooms","rooms":[]},"currentAnnouncement":{"message":"CannedNet :3","moreInfoUrl":"https://lapis.codes/"},"instagramImages":[],"videos":[]}
+12 -6
View File
@@ -1,8 +1,10 @@
using System.Security.Claims;
using System.Text.Json.Serialization;
using CannedNet.Data;
using CannedNet.Hubs;
using CannedNet.Services;
using CannedNet.Services.Infrastructure;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore;
@@ -28,7 +30,11 @@ public static class Program
builder.Services.AddSwaggerGen();
builder.Services.ConfigureHttpJsonOptions(options => options.SerializerOptions.PropertyNamingPolicy = null);
builder.Services.ConfigureHttpJsonOptions(options =>
{
options.SerializerOptions.PropertyNamingPolicy = null;
options.SerializerOptions.ReferenceHandler = ReferenceHandler.IgnoreCycles;
});
builder.Services.AddDbContext<AppDbContext>(options => options.UseNpgsql(connectionString));
builder.Services.AddSingleton<NotificationService>();
builder.Services.AddScoped<StorefrontFillService>();
@@ -66,8 +72,8 @@ public static class Program
NameClaimType = ClaimTypes.NameIdentifier,
RoleClaimType = "role"
};
});
})
.AddScheme<AuthenticationSchemeOptions, AdminApiKeyHandler>("AdminApiKey", null);
builder.Services.AddAuthorization();
@@ -95,9 +101,6 @@ public static class Program
using IServiceScope scope = app.Services.CreateScope();
AppDbContext db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
StorefrontFillService seedingService = scope.ServiceProvider.GetRequiredService<StorefrontFillService>();
await seedingService.FillStorefrontsAsync();
try
{
await db.Database.MigrateAsync();
@@ -107,6 +110,9 @@ public static class Program
await db.Database.EnsureCreatedAsync();
}
StorefrontFillService seedingService = scope.ServiceProvider.GetRequiredService<StorefrontFillService>();
await seedingService.FillStorefrontsAsync();
Signatures.Init();
await app.RunAsync();
+42
View File
@@ -0,0 +1,42 @@
using System.Security.Claims;
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Options;
namespace CannedNet.Services;
public class AdminApiKeyHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
public AdminApiKeyHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder)
: base(options, logger, encoder)
{
}
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
var config = Context.RequestServices.GetRequiredService<IConfiguration>();
var expectedKey = config.GetValue<string>("Admin:ApiKey")
?? config.GetValue<string>("AdminApiKey")
?? "";
string? apiKey = Request.Headers["X-Admin-Api-Key"].FirstOrDefault();
if (string.IsNullOrEmpty(apiKey) || apiKey != expectedKey)
return Task.FromResult(AuthenticateResult.Fail("Invalid or missing X-Admin-Api-Key header"));
var claims = new[] { new Claim(ClaimTypes.Role, "Admin") };
var identity = new ClaimsIdentity(claims, Scheme.Name);
var principal = new ClaimsPrincipal(identity);
var ticket = new AuthenticationTicket(principal, Scheme.Name);
return Task.FromResult(AuthenticateResult.Success(ticket));
}
protected override async Task HandleChallengeAsync(AuthenticationProperties properties)
{
await Results.Unauthorized().ExecuteAsync(Context);
}
}
@@ -177,47 +177,6 @@ public class APIController : ControllerBase
.Where(s => s.PlayerId == id)
.ToListAsync();
if (settings.Count == 0)
{
var defaults = new List<PlayerSetting>
{
new() { PlayerId = id, Key = "Recroom.OOBE", Value = "77" },
new() { PlayerId = id, Key = "SplitTestAssignedSegments", Value = "1|{\"SplitTesting+PhotonMaxDatagrams_2021_01_11\":\"Off\",\"SplitTesting+Curated_Rooms_2020_08_06\":\"Off\",\"SplitTesting+RoomRecommendationsType_2020_08_14\":\"Aug14MinVisitors35000\"}" },
new() { PlayerId = id, Key = "PlayerSessionCount", Value = "13" },
new() { PlayerId = id, Key = "TUTORIAL_COMPLETE_MASK", Value = "11" },
new() { PlayerId = id, Key = "BACKPACK_FAVORITE_TOOL", Value = "1" },
new() { PlayerId = id, Key = "VoiceChat", Value = "2" },
new() { PlayerId = id, Key = "VRAUTOSPRINT", Value = "1" },
new() { PlayerId = id, Key = "VR_MOVEMENT_MODE", Value = "0" },
new() { PlayerId = id, Key = "COMFORT_SPRINT", Value = "0" },
new() { PlayerId = id, Key = "COMFORT_WALK", Value = "0" },
new() { PlayerId = id, Key = "COMFORT_VEHICLES", Value = "0" },
new() { PlayerId = id, Key = "COMFORT_FLY", Value = "0" },
new() { PlayerId = id, Key = "COMFORT_ROTATE", Value = "0" },
new() { PlayerId = id, Key = "COMFORT_FORCES", Value = "0" },
new() { PlayerId = id, Key = "COMFORT_FALL", Value = "0" },
new() { PlayerId = id, Key = "COMFORT_TELEPORT", Value = "0" },
new() { PlayerId = id, Key = "ROTATE_IN_PLACE_ENABLED", Value = "1" },
new() { PlayerId = id, Key = "ROTATION_INCREMENT", Value = "2" },
new() { PlayerId = id, Key = "CONTINUOUS_ROTATION_MODE", Value = "1" },
new() { PlayerId = id, Key = "DONT_LOCK_TOOLS_TO_HAND", Value = "0" },
new() { PlayerId = id, Key = "QualitySettings", Value = "2" },
new() { PlayerId = id, Key = "TeleportBuffer", Value = "0" },
new() { PlayerId = id, Key = "IgnoreBuffer", Value = "1" },
new() { PlayerId = id, Key = "FIRST_TIME_IN_FLAGS", Value = "0" },
new() { PlayerId = id, Key = "ShowRoomCenter", Value = "1" },
new() { PlayerId = id, Key = "USER_TRACKING", Value = "1" },
new() { PlayerId = id, Key = "STABILIZE_HANDS", Value = "0" },
new() { PlayerId = id, Key = "MakerPen_SnappingMode", Value = "2" },
new() { PlayerId = id, Key = "Recroom.ChallengeMap", Value = "17" },
new() { PlayerId = id, Key = "VoiceFilter2", Value = "1" },
new() { PlayerId = id, Key = "SFX_VOLUME_PERCENT_PREF", Value = "1" },
};
db.PlayerSettings.AddRange(defaults);
await db.SaveChangesAsync();
settings = defaults;
}
return Results.Json(settings);
}
@@ -1,14 +1,486 @@
using System.Text.Json;
using CannedNet.Data;
using CannedNet.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace CannedNet.Services.Controllers;
[ApiController, Route("admin")]
[ApiController, Route("admin/api")]
[Authorize(AuthenticationSchemes = "AdminApiKey")]
public class AdminController : ControllerBase
{
[HttpGet]
public async Task<IResult> GetRoot()
private const string JsonDir = "JSON";
[HttpGet("endpoints")]
public async Task<IResult> GetEndpoints()
{
// TODO: allow getting admin panel from here
return Results.NoContent();
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)
{
if (string.IsNullOrWhiteSpace(q))
return Results.Ok(new List<Account>());
var accounts = await db.Accounts
.Where(a => a.Username != null && a.Username.ToLower().Contains(q.ToLower())
|| a.DisplayName != null && a.DisplayName.ToLower().Contains(q.ToLower())
|| a.AccountId.ToString() == q)
.Take(50)
.ToListAsync();
return Results.Ok(accounts);
}
[HttpGet("accounts/{id:int}")]
public async Task<IResult> GetAccount(int id, AppDbContext db)
{
var account = await db.Accounts.FindAsync(id);
if (account == null)
return Results.NotFound();
var bio = await db.PlayerBios.FirstOrDefaultAsync(b => b.accountId == id);
var progression = await db.PlayerProgressions.FirstOrDefaultAsync(p => p.PlayerId == id);
return Results.Ok(new
{
Account = account,
Bio = bio?.bio ?? "",
Progression = progression,
AvatarItems = await db.AvatarItems.Where(a => a.OwnerAccountId == id).ToListAsync(),
Settings = await db.PlayerSettings.Where(s => s.PlayerId == id).ToListAsync(),
TokenBalances = await db.TokenBalances.Where(t => t.Id == id).ToListAsync()
});
}
[HttpPut("accounts/{id:int}")]
public async Task<IResult> UpdateAccount(int id, AppDbContext db)
{
var account = await db.Accounts.FindAsync(id);
if (account == null)
return Results.NotFound();
using var reader = new StreamReader(Request.Body);
var body = await reader.ReadToEndAsync();
var data = JsonSerializer.Deserialize<JsonElement>(body);
if (data.TryGetProperty("username", out var username))
account.Username = username.GetString();
if (data.TryGetProperty("displayName", out var displayName))
account.DisplayName = displayName.GetString();
if (data.TryGetProperty("profileImage", out var profileImage))
account.ProfileImage = profileImage.GetString();
if (data.TryGetProperty("isJunior", out var isJunior))
account.IsJunior = isJunior.GetBoolean();
if (data.TryGetProperty("personalPronouns", out var personalPronouns))
account.PersonalPronouns = personalPronouns.GetInt32();
if (data.TryGetProperty("identityFlags", out var identityFlags))
account.IdentityFlags = identityFlags.GetInt32();
await db.SaveChangesAsync();
return Results.Ok(new { success = true });
}
[HttpDelete("accounts/{id:int}")]
public async Task<IResult> DeleteAccount(int id, AppDbContext db)
{
var account = await db.Accounts.FindAsync(id);
if (account == null)
return Results.NotFound();
db.Accounts.Remove(account);
await db.SaveChangesAsync();
return Results.Ok(new { success = true });
}
[HttpGet("rooms/search")]
public async Task<IResult> SearchRooms([FromQuery] string q, AppDbContext db)
{
if (string.IsNullOrWhiteSpace(q))
return Results.Ok(new List<Room>());
var rooms = await db.Rooms
.Where(r => r.Name.ToLower().Contains(q.ToLower())
|| r.Description.ToLower().Contains(q.ToLower())
|| r.RoomId.ToString() == q
|| r.Id.ToString() == q)
.Take(50)
.ToListAsync();
return Results.Ok(rooms);
}
[HttpGet("rooms/{id:int}")]
public async Task<IResult> GetRoom(int id, AppDbContext db)
{
var room = await db.Rooms.FirstOrDefaultAsync(r => r.RoomId == id);
if (room == null)
return Results.NotFound();
var subRooms = await db.SubRooms.Where(s => s.RoomId == id).ToListAsync();
var loadScreens = await db.LoadScreens.Where(l => l.RoomId == id).ToListAsync();
var promoImages = await db.PromoImages.Where(p => p.RoomId == id).ToListAsync();
var roles = await db.RoomRoles.Where(r => r.RoomId == id).ToListAsync();
return Results.Ok(new
{
Room = room,
SubRooms = subRooms,
LoadScreens = loadScreens,
PromoImages = promoImages,
Roles = roles
});
}
[HttpPut("rooms/{id:int}")]
public async Task<IResult> UpdateRoom(int id, AppDbContext db)
{
var room = await db.Rooms.FirstOrDefaultAsync(r => r.RoomId == id);
if (room == null)
return Results.NotFound();
using var reader = new StreamReader(Request.Body);
var body = await reader.ReadToEndAsync();
var data = JsonSerializer.Deserialize<JsonElement>(body);
if (data.TryGetProperty("name", out var name))
room.Name = name.GetString() ?? room.Name;
if (data.TryGetProperty("description", out var desc))
room.Description = desc.GetString() ?? room.Description;
if (data.TryGetProperty("imageName", out var imageName))
room.ImageName = imageName.GetString() ?? room.ImageName;
if (data.TryGetProperty("state", out var state))
room.State = state.GetInt32();
if (data.TryGetProperty("accessibility", out var accessibility))
room.Accessibility = accessibility.GetInt32();
if (data.TryGetProperty("isDorm", out var isDorm))
room.IsDorm = isDorm.GetBoolean();
if (data.TryGetProperty("isRRO", out var isRRO))
room.IsRRO = isRRO.GetBoolean();
if (data.TryGetProperty("cloningAllowed", out var cloningAllowed))
room.CloningAllowed = cloningAllowed.GetBoolean();
if (data.TryGetProperty("minLevel", out var minLevel))
room.MinLevel = minLevel.GetInt32();
if (data.TryGetProperty("warningMask", out var warningMask))
room.WarningMask = warningMask.GetInt32();
if (data.TryGetProperty("customWarning", out var customWarning))
room.CustomWarning = customWarning.GetString();
if (data.TryGetProperty("supportsVRLow", out var supportsVRLow))
room.SupportsVRLow = supportsVRLow.GetBoolean();
if (data.TryGetProperty("supportsQuest2", out var supportsQuest2))
room.SupportsQuest2 = supportsQuest2.GetBoolean();
if (data.TryGetProperty("supportsMobile", out var supportsMobile))
room.SupportsMobile = supportsMobile.GetBoolean();
if (data.TryGetProperty("supportsScreens", out var supportsScreens))
room.SupportsScreens = supportsScreens.GetBoolean();
if (data.TryGetProperty("supportsWalkVR", out var supportsWalkVR))
room.SupportsWalkVR = supportsWalkVR.GetBoolean();
if (data.TryGetProperty("supportsTeleportVR", out var supportsTeleportVR))
room.SupportsTeleportVR = supportsTeleportVR.GetBoolean();
if (data.TryGetProperty("supportsJuniors", out var supportsJuniors))
room.SupportsJuniors = supportsJuniors.GetBoolean();
if (data.TryGetProperty("tags", out var tags))
room.Tags = tags.GetString() ?? room.Tags;
await db.SaveChangesAsync();
return Results.Ok(new { success = true });
}
[HttpDelete("rooms/{id:int}")]
public async Task<IResult> DeleteRoom(int id, AppDbContext db)
{
var room = await db.Rooms.FirstOrDefaultAsync(r => r.RoomId == id);
if (room == null)
return Results.NotFound();
db.SubRooms.RemoveRange(db.SubRooms.Where(s => s.RoomId == id));
db.LoadScreens.RemoveRange(db.LoadScreens.Where(l => l.RoomId == id));
db.PromoImages.RemoveRange(db.PromoImages.Where(p => p.RoomId == id));
db.RoomRoles.RemoveRange(db.RoomRoles.Where(r => r.RoomId == id));
db.Rooms.Remove(room);
await db.SaveChangesAsync();
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()
{
var json = await System.IO.File.ReadAllTextAsync(Path.Combine(JsonDir, "gameconfigs.json"));
return Results.Content(json, "application/json");
}
[HttpPut("config/gameconfigs")]
public async Task<IResult> SaveGameConfigs()
{
using var reader = new StreamReader(Request.Body);
var body = await reader.ReadToEndAsync();
await System.IO.File.WriteAllTextAsync(Path.Combine(JsonDir, "gameconfigs.json"), body);
return Results.Ok(new { success = true });
}
[HttpGet("config/communityboard")]
public async Task<IResult> GetCommunityBoard()
{
var json = await System.IO.File.ReadAllTextAsync(Path.Combine(JsonDir, "communityboard.json"));
return Results.Content(json, "application/json");
}
[HttpPut("config/communityboard")]
public async Task<IResult> SaveCommunityBoard()
{
using var reader = new StreamReader(Request.Body);
var body = await reader.ReadToEndAsync();
await System.IO.File.WriteAllTextAsync(Path.Combine(JsonDir, "communityboard.json"), body);
return Results.Ok(new { success = true });
}
[HttpGet("config/configv2")]
public async Task<IResult> GetConfigV2()
{
var json = await System.IO.File.ReadAllTextAsync(Path.Combine(JsonDir, "configv2.json"));
return Results.Content(json, "application/json");
}
[HttpPut("config/configv2")]
public async Task<IResult> SaveConfigV2()
{
using var reader = new StreamReader(Request.Body);
var body = await reader.ReadToEndAsync();
await System.IO.File.WriteAllTextAsync(Path.Combine(JsonDir, "configv2.json"), body);
return Results.Ok(new { success = true });
}
[HttpGet("storefronts")]
public async Task<IResult> GetStorefronts(AppDbContext db)
{
var storefronts = await db.Storefronts
.Include(s => s.Items)
.ThenInclude(i => i.GiftDrops)
.Include(s => s.Items)
.ThenInclude(i => i.Prices)
.ToListAsync();
return Results.Ok(storefronts);
}
[HttpGet("storefronts/{id:int}")]
public async Task<IResult> GetStorefront(int id, AppDbContext db)
{
var storefront = await db.Storefronts
.Include(s => s.Items)
.ThenInclude(i => i.GiftDrops)
.Include(s => s.Items)
.ThenInclude(i => i.Prices)
.FirstOrDefaultAsync(s => s.Id == id);
if (storefront == null)
return Results.NotFound();
return Results.Ok(storefront);
}
[HttpPost("storefronts/{storefrontId:int}/items")]
public async Task<IResult> CreateStorefrontItem(int storefrontId, AppDbContext db)
{
var storefront = await db.Storefronts.FindAsync(storefrontId);
if (storefront == null)
return Results.NotFound();
using var reader = new StreamReader(Request.Body);
var body = await reader.ReadToEndAsync();
var data = JsonSerializer.Deserialize<JsonElement>(body);
var item = new StorefrontItem
{
StorefrontId = storefrontId,
PurchasableItemId = data.TryGetProperty("purchasableItemId", out var pid) ? pid.GetInt32() : 0,
Type = data.TryGetProperty("type", out var type) ? type.GetInt32() : 0,
IsFeatured = data.TryGetProperty("isFeatured", out var featured) && featured.GetBoolean(),
NewUntil = data.TryGetProperty("newUntil", out var newUntil) && newUntil.ValueKind == JsonValueKind.String
? DateTime.Parse(newUntil.GetString()!)
: null
};
db.StorefrontItems.Add(item);
await db.SaveChangesAsync();
// create default price if provided
if (data.TryGetProperty("price", out var priceEl) && data.TryGetProperty("currencyType", out var currencyEl))
{
db.StorefrontPrices.Add(new StorefrontPrice
{
StorefrontItemId = item.Id,
CurrencyType = currencyEl.GetInt32(),
Price = priceEl.GetInt32()
});
await db.SaveChangesAsync();
}
return Results.Ok(item);
}
[HttpPut("storefronts/items/{itemId:int}")]
public async Task<IResult> UpdateStorefrontItem(int itemId, AppDbContext db)
{
var item = await db.StorefrontItems
.Include(i => i.Prices)
.Include(i => i.GiftDrops)
.FirstOrDefaultAsync(i => i.Id == itemId);
if (item == null)
return Results.NotFound();
using var reader = new StreamReader(Request.Body);
var body = await reader.ReadToEndAsync();
var data = JsonSerializer.Deserialize<JsonElement>(body);
if (data.TryGetProperty("purchasableItemId", out var pid))
item.PurchasableItemId = pid.GetInt32();
if (data.TryGetProperty("type", out var type))
item.Type = type.GetInt32();
if (data.TryGetProperty("isFeatured", out var featured))
item.IsFeatured = featured.GetBoolean();
if (data.TryGetProperty("newUntil", out var newUntil))
item.NewUntil = newUntil.ValueKind == JsonValueKind.String ? DateTime.Parse(newUntil.GetString()!) : null;
// update prices
if (data.TryGetProperty("prices", out var prices))
{
db.StorefrontPrices.RemoveRange(item.Prices);
foreach (var p in prices.EnumerateArray())
{
db.StorefrontPrices.Add(new StorefrontPrice
{
StorefrontItemId = item.Id,
CurrencyType = p.GetProperty("currencyType").GetInt32(),
Price = p.GetProperty("price").GetInt32()
});
}
}
await db.SaveChangesAsync();
return Results.Ok(item);
}
[HttpDelete("storefronts/items/{itemId:int}")]
public async Task<IResult> DeleteStorefrontItem(int itemId, AppDbContext db)
{
var item = await db.StorefrontItems
.Include(i => i.Prices)
.Include(i => i.GiftDrops)
.FirstOrDefaultAsync(i => i.Id == itemId);
if (item == null)
return Results.NotFound();
db.StorefrontPrices.RemoveRange(item.Prices);
db.GiftDrops.RemoveRange(item.GiftDrops);
db.StorefrontItems.Remove(item);
await db.SaveChangesAsync();
return Results.Ok(new { success = true });
}
}
@@ -54,21 +54,10 @@ public class ImageController : ControllerBase
string? widthStr = context.Request.Query["width"].FirstOrDefault();
string? heightStr = context.Request.Query["height"].FirstOrDefault();
if (string.IsNullOrEmpty(widthStr) && string.IsNullOrEmpty(heightStr) && string.IsNullOrEmpty(cropSquare))
if (!string.IsNullOrEmpty(widthStr) || !string.IsNullOrEmpty(heightStr) || !string.IsNullOrEmpty(cropSquare))
{
string? signature1 = Signatures.Sign(imageBytes);
if (signature1 != null)
{
context.Response.Headers["Content-Signature"] = $"key-id=KEY:RSA:p1.rec.net; data={signature1}";
}
return Results.File(imageBytes, contentType);
}
using Image image = Image.Load(imageBytes);
int resizeWidth = 0;
int resizeHeight = 0;
if (!string.IsNullOrEmpty(cropSquare) && cropSquare != "0" && cropSquare != "false")
{
int size = Math.Min(image.Width, image.Height);
@@ -77,25 +66,25 @@ public class ImageController : ControllerBase
image.Mutate(img => img.Crop(new Rectangle(x, y, size, size)));
}
if (int.TryParse(widthStr, out int w))
resizeWidth = w;
if (int.TryParse(heightStr, out int h))
resizeHeight = h;
int resizeWidth = 0, resizeHeight = 0;
if (int.TryParse(widthStr, out var w)) resizeWidth = w;
if (int.TryParse(heightStr, out var h)) resizeHeight = h;
if (resizeWidth > 0 || resizeHeight > 0)
{
image.Mutate(x => x.Resize(resizeWidth, resizeHeight));
}
using MemoryStream output = new();
await image.SaveAsJpegAsync(output, new JpegEncoder { Quality = 85 });
imageBytes = output.ToArray();
contentType = "image/jpeg";
}
string? signature = Signatures.Sign(imageBytes);
if (signature != null)
context.Response.OnStarting(() =>
{
if (signature != null)
context.Response.Headers["Content-Signature"] = $"key-id=KEY:RSA:p1.rec.net; data={signature}";
}
return Results.File(imageBytes, "image/jpeg");
return Task.CompletedTask;
});
return Results.File(imageBytes, contentType);
}
}
+4 -1
View File
@@ -11,6 +11,9 @@
"Ports": {
"Primary": 5000
},
"AllowedHosts": "*"
"AllowedHosts": "*",
"Admin": {
"ApiKey": "changeme-replace-with-a-secure-key"
}
}