mirror of
https://github.com/CannedNet/CannedNet.git
synced 2026-10-25 23:18:54 -07:00
basic admin panel stuff
This commit is contained in:
@@ -9,3 +9,4 @@ CannedNet.sln.DotSettings.user
|
||||
CannedNet/Data/PrivateKey.pem
|
||||
/CannedNet/.zed
|
||||
CannedNet/Data/PublicKey.pem
|
||||
CannedNet.Admin/admin_users.json
|
||||
|
||||
@@ -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 > 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++;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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('/')}";
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -277,4 +277,3 @@ public class AppDbContext : DbContext
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,9 @@
|
||||
"Ports": {
|
||||
"Primary": 5000
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
"AllowedHosts": "*",
|
||||
"Admin": {
|
||||
"ApiKey": "changeme-replace-with-a-secure-key"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user