diff --git a/.gitignore b/.gitignore index eb5f465..855fc64 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ CannedNet.sln.DotSettings.user CannedNet/Data/PrivateKey.pem /CannedNet/.zed CannedNet/Data/PublicKey.pem +CannedNet.Admin/admin_users.json diff --git a/CannedNet.Admin/Components/App.razor b/CannedNet.Admin/Components/App.razor index 972fc02..8b43bfb 100644 --- a/CannedNet.Admin/Components/App.razor +++ b/CannedNet.Admin/Components/App.razor @@ -7,6 +7,7 @@ + @@ -15,8 +16,10 @@ - - + + + + diff --git a/CannedNet.Admin/Components/Layout/MainLayout.razor b/CannedNet.Admin/Components/Layout/MainLayout.razor index 78624f3..85433b4 100644 --- a/CannedNet.Admin/Components/Layout/MainLayout.razor +++ b/CannedNet.Admin/Components/Layout/MainLayout.razor @@ -1,20 +1,30 @@ -@inherits LayoutComponentBase +@using CannedNet.Admin.Services +@inherits LayoutComponentBase +@inject AdminAuthService AuthService +@inject NavigationManager Navigation -
- +@{ + var body = Body; +} -
-
- About + + +
+ + +
+
+ @body +
+
- -
- @Body -
-
-
+ + + @body + +
An unhandled error has occurred. diff --git a/CannedNet.Admin/Components/Layout/NavMenu.razor b/CannedNet.Admin/Components/Layout/NavMenu.razor index c9f9e94..359e2fd 100644 --- a/CannedNet.Admin/Components/Layout/NavMenu.razor +++ b/CannedNet.Admin/Components/Layout/NavMenu.razor @@ -8,23 +8,69 @@ - diff --git a/CannedNet.Admin/Components/Layout/NavMenu.razor.css b/CannedNet.Admin/Components/Layout/NavMenu.razor.css index a2aeace..c0d9481 100644 --- a/CannedNet.Admin/Components/Layout/NavMenu.razor.css +++ b/CannedNet.Admin/Components/Layout/NavMenu.razor.css @@ -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,27 +47,24 @@ 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 { - background-color: rgba(255,255,255,0.37); - color: white; -} + .nav-item ::deep a.active { + background-color: rgba(255,255,255,0.37); + color: white; + } -.nav-item ::deep .nav-link:hover { - background-color: rgba(255,255,255,0.1); - color: white; -} + .nav-item ::deep a:hover { + background-color: rgba(255,255,255,0.1); + color: white; + } .nav-scrollable { display: none; @@ -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; } diff --git a/CannedNet.Admin/Components/Layout/RedirectToLogin.razor b/CannedNet.Admin/Components/Layout/RedirectToLogin.razor new file mode 100644 index 0000000..f4b9f0a --- /dev/null +++ b/CannedNet.Admin/Components/Layout/RedirectToLogin.razor @@ -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); + } +} diff --git a/CannedNet.Admin/Components/Pages/ChangePassword.razor b/CannedNet.Admin/Components/Pages/ChangePassword.razor new file mode 100644 index 0000000..c784994 --- /dev/null +++ b/CannedNet.Admin/Components/Pages/ChangePassword.razor @@ -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 + +Change Password + +

Change Password

+ +
+
+
+
Change Your Password
+
+
+
+ + +
+
+ + +
+
+ + +
+ + Cancel + @if (!string.IsNullOrEmpty(message)) + { +
@message
+ } +
+
+
+
+
+ +@code { + [CascadingParameter] private Task? 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; + } + } +} diff --git a/CannedNet.Admin/Components/Pages/Config/CommunityBoard.razor b/CannedNet.Admin/Components/Pages/Config/CommunityBoard.razor new file mode 100644 index 0000000..a72fce1 --- /dev/null +++ b/CannedNet.Admin/Components/Pages/Config/CommunityBoard.razor @@ -0,0 +1,227 @@ +@page "/config/communityboard" +@rendermode InteractiveServer + +Community Board + +

Community Board

+

Edit the community board content including featured players, announcements, Instagram images, and videos.

+ +@if (loading) +{ +
+} +else if (!string.IsNullOrEmpty(error)) +{ +
@error
+} +else +{ +
+
+
Current Announcement
+
+
+ + +
+
+ + +
+
+
+ +
+
Featured Player
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+
Videos
+
+ @if (videos.Count == 0) + { +

No videos.

+ } + @for (int i = 0; i < videos.Count; i++) + { + var idx = i; +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ } + +
+
+ +
+ + @if (!string.IsNullOrEmpty(saveMessage)) + { + @saveMessage + } +
+
+} + +@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 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(); + 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() + }, + currentAnnouncement = new + { + message = announcementMessage, + moreInfoUrl = announcementUrl + }, + instagramImages = new List(), + 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; } = ""; + } +} diff --git a/CannedNet.Admin/Components/Pages/Config/GameConfig.razor b/CannedNet.Admin/Components/Pages/Config/GameConfig.razor new file mode 100644 index 0000000..a7e2f28 --- /dev/null +++ b/CannedNet.Admin/Components/Pages/Config/GameConfig.razor @@ -0,0 +1,133 @@ +@page "/config/gameconfig" +@rendermode InteractiveServer + +Game Config + +

Game Config

+

Edit the gameconfigs.json file that controls various game settings.

+ +@if (loading) +{ +
+} +else if (!string.IsNullOrEmpty(error)) +{ +
@error
+} +else +{ + + + @if (activeTab == "edit") + { +
+
+ + @if (!string.IsNullOrEmpty(saveMessage)) + { + @saveMessage + } +
+ +
+ } + else if (activeTab == "browse") + { +
+ +
+
+ + + + + + @foreach (var entry in configEntries.Where(e => string.IsNullOrEmpty(filterText) || e.Key.Contains(filterText, StringComparison.OrdinalIgnoreCase))) + { + + + + + } + +
KeyValue
@entry.Key@entry.Value
+
+
@configEntries.Count(e => string.IsNullOrEmpty(filterText) || e.Key.Contains(filterText, StringComparison.OrdinalIgnoreCase)) entries
+ } +} + +@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 configEntries = new(); + + protected override async Task OnInitializedAsync() + { + try + { + jsonContent = await Http.GetStringAsync("/admin/api/config/gameconfigs"); + configEntries = JsonSerializer.Deserialize>(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>(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; } + } +} diff --git a/CannedNet.Admin/Components/Pages/Config/NameserverConfig.razor b/CannedNet.Admin/Components/Pages/Config/NameserverConfig.razor new file mode 100644 index 0000000..40b0529 --- /dev/null +++ b/CannedNet.Admin/Components/Pages/Config/NameserverConfig.razor @@ -0,0 +1,115 @@ +@page "/config/nameserver" +@rendermode InteractiveServer +@using CannedNet.Admin.Models +@using System.Text.Json + +NS Config + +

Nameserver (NS) Config

+

Manage the nameserver endpoints.json file that controls where the game client connects.

+ +
+ This is the same editor as the Setup > Nameserver page. + All endpoint URLs are derived from a single base URL. +
+ +@if (loading) +{ +
+} +else if (!string.IsNullOrEmpty(error)) +{ +
@error
+} +else +{ +
+
+ + @if (!string.IsNullOrEmpty(saveMessage)) + { + @saveMessage + } +
+ +
+ + +
All endpoint URLs will use this as the base.
+
+ +
+ + + + + + + + + @foreach (var (name, path) in EndpointDefinitions.Endpoints) + { + + + + + } + +
ServiceFull URL
@name@(baseUrl.TrimEnd('/') + path)
+
+
+} + +@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; + } + } +} diff --git a/CannedNet.Admin/Components/Pages/Counter.razor b/CannedNet.Admin/Components/Pages/Counter.razor deleted file mode 100644 index 1a4f8e7..0000000 --- a/CannedNet.Admin/Components/Pages/Counter.razor +++ /dev/null @@ -1,19 +0,0 @@ -@page "/counter" -@rendermode InteractiveServer - -Counter - -

Counter

- -

Current count: @currentCount

- - - -@code { - private int currentCount = 0; - - private void IncrementCount() - { - currentCount++; - } -} diff --git a/CannedNet.Admin/Components/Pages/Error.razor b/CannedNet.Admin/Components/Pages/Error.razor index 576cc2d..9c8c321 100644 --- a/CannedNet.Admin/Components/Pages/Error.razor +++ b/CannedNet.Admin/Components/Pages/Error.razor @@ -1,12 +1,12 @@ @page "/Error" -@using System.Diagnostics +@rendermode InteractiveServer Error

Error.

An error occurred while processing your request.

-@if (ShowRequestId) +@if (!string.IsNullOrEmpty(RequestId)) {

Request ID: @RequestId @@ -20,17 +20,11 @@

The Development environment shouldn't be enabled for deployed applications. It can result in displaying sensitive information from exceptions to end users. - For local debugging, enable the Development environment by setting the ASPNETCORE_ENVIRONMENT environment variable to Development + For local debugging, you can enable the Development environment by setting the ASPNETCORE_ENVIRONMENT environment variable to Development and restarting the app.

-@code{ - [CascadingParameter] - private HttpContext? HttpContext { get; set; } - +@code { + [SupplyParameterFromQuery] private string? RequestId { get; set; } - private bool ShowRequestId => !string.IsNullOrEmpty(RequestId); - - protected override void OnInitialized() => - RequestId = Activity.Current?.Id ?? HttpContext?.TraceIdentifier; } diff --git a/CannedNet.Admin/Components/Pages/Home.razor b/CannedNet.Admin/Components/Pages/Home.razor index 9001e0b..c34f18c 100644 --- a/CannedNet.Admin/Components/Pages/Home.razor +++ b/CannedNet.Admin/Components/Pages/Home.razor @@ -1,7 +1,57 @@ @page "/" +@attribute [Authorize] Home -

Hello, world!

+
+

CannedNet Admin Panel

+

Manage your CannedNet Rec Room private server instance.

-Welcome to your new app. +
+
+
+
+
Setup
+

Configure nameserver endpoints, admin accounts, and authentication.

+ Go to Setup +
+
+
+
+
+
+
Users
+

Search, edit, and manage player accounts.

+ Manage Users +
+
+
+
+
+
+
Rooms
+

Search, edit, create, and delete rooms and subrooms.

+ Manage Rooms +
+
+
+
+
+
+
Config
+

Modify server configuration files: NS config, game configs, community board.

+ Manage Config +
+
+
+
+
+
+
Storefronts
+

Manage storefront items, prices, and create new items.

+ Manage Storefronts +
+
+
+
+
diff --git a/CannedNet.Admin/Components/Pages/Login.razor b/CannedNet.Admin/Components/Pages/Login.razor new file mode 100644 index 0000000..d0530c5 --- /dev/null +++ b/CannedNet.Admin/Components/Pages/Login.razor @@ -0,0 +1,67 @@ +@page "/login" +@rendermode InteractiveServer +@using CannedNet.Admin.Services +@inject AdminAuthService AuthService +@inject NavigationManager Navigation + +Login + +@if (AuthService.HasAnyUser() == false) +{ +
+
+
+
+

No Admin Accounts

+

No admin accounts exist yet. Complete the first-time setup.

+ Go to Setup +
+
+
+
+} +else +{ +
+
+
+

Admin Login

+
+
+ @if (errorMessage != null) + { +
@errorMessage
+ } +
+ + +
+
+ + +
+ +
+
+
+
+
+} + +@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 + }; + } +} diff --git a/CannedNet.Admin/Components/Pages/NotFound.razor b/CannedNet.Admin/Components/Pages/NotFound.razor index 917ada1..4cfd883 100644 --- a/CannedNet.Admin/Components/Pages/NotFound.razor +++ b/CannedNet.Admin/Components/Pages/NotFound.razor @@ -1,5 +1,8 @@ @page "/not-found" -@layout MainLayout +@rendermode InteractiveServer -

Not Found

-

Sorry, the content you are looking for does not exist.

\ No newline at end of file +Not Found + +

Page Not Found

+

The page you're looking for doesn't exist.

+Go Home diff --git a/CannedNet.Admin/Components/Pages/Rooms/Create.razor b/CannedNet.Admin/Components/Pages/Rooms/Create.razor new file mode 100644 index 0000000..b41096d --- /dev/null +++ b/CannedNet.Admin/Components/Pages/Rooms/Create.razor @@ -0,0 +1,176 @@ +@page "/rooms/create" +@rendermode InteractiveServer + +Create Room + +

Create Room

+

Create a new room and optionally a default subroom.

+ +
+
+
+
+
Room Details
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ +
+
+
SubRoom
+
+
+ + +
+ + @if (createSubRoom) + { +
+ + +
+
+ + +
+ } +
+
+ +
+
Platform Support
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+
+ + + Cancel + + @if (!string.IsNullOrEmpty(message)) + { +
@message
+ } + + @if (createdRoom != null) + { +
+ Room created! Edit Room #@createdRoom.RoomId +
+ } +
+ +@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(); + message = $"Room '{createdRoom?.Name}' created successfully!"; + } + catch (Exception ex) + { + message = $"Error: {ex.Message}"; + } + finally + { + creating = false; + } + } +} diff --git a/CannedNet.Admin/Components/Pages/Rooms/Edit.razor b/CannedNet.Admin/Components/Pages/Rooms/Edit.razor new file mode 100644 index 0000000..9bff138 --- /dev/null +++ b/CannedNet.Admin/Components/Pages/Rooms/Edit.razor @@ -0,0 +1,358 @@ +@page "/rooms/edit/{RoomId:int}" +@rendermode InteractiveServer +@inject ImageUrlService Img + +Edit Room @RoomId + +

Edit Room #@RoomId

+ +@if (loading) +{ +
+} +else if (!string.IsNullOrEmpty(error)) +{ +
@error
+} +else if (roomData == null) +{ +
Room not found.
+} +else +{ +
+
+
+
+
+
+
Room Info
+
+
+ + +
+
+ + +
+
+ + +
+
+ + + @if (!string.IsNullOrEmpty(editImageName)) + { + preview + } +
+
+ + +
+
+ + +
+
+
+
+ +
+
+
Settings
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+
Flags
+
+
+ + +
+
+ + +
+
+ + +
+
+
Platform Support
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+
+ + @if (roomData.SubRooms?.Count > 0) + { +
+
SubRooms (@roomData.SubRooms.Count)
+
+ + + + + + @foreach (var sub in roomData.SubRooms) + { + + + + + + + + + } + +
IDSubRoomIdNameMax PlayersIs SandboxAccessibility
@sub.Id@sub.SubRoomId@sub.Name@sub.MaxPlayers@sub.IsSandbox@sub.Accessibility
+
+
+ } + + @if (roomData.LoadScreens?.Count > 0) + { +
+
Load Screens (@roomData.LoadScreens.Count)
+
+ @foreach (var ls in roomData.LoadScreens) + { +
@ls.ImageUrl - @ls.Tooltip
+ } +
+
+ } + +
+ + + Back to Search +
+ + @if (!string.IsNullOrEmpty(saveMessage)) + { +
@saveMessage
+ } +
+
+
+} + +@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($"/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 ConfirmDelete() + { + var confirmed = await JSRuntime.InvokeAsync("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? SubRooms { get; set; } + public List? LoadScreens { get; set; } + } +} diff --git a/CannedNet.Admin/Components/Pages/Rooms/Search.razor b/CannedNet.Admin/Components/Pages/Rooms/Search.razor new file mode 100644 index 0000000..de3f252 --- /dev/null +++ b/CannedNet.Admin/Components/Pages/Rooms/Search.razor @@ -0,0 +1,96 @@ +@page "/rooms/search" +@rendermode InteractiveServer +@inject ImageUrlService Img + +Search Rooms + +

Search Rooms

+

Search for rooms by name, description, or room ID.

+ +
+ + +
+ +@if (searching) +{ +
+} +else if (!string.IsNullOrEmpty(error)) +{ +
@error
+} +else if (results != null && results.Count == 0) +{ +
No rooms found matching "@searchQuery".
+} +else if (results != null) +{ +
+ + + + + + + + + + + + + + @foreach (var room in results) + { + + + + + + + + + + } + +
Room IDImageNameCreatorStateDormActions
@room.RoomId + @if (!string.IsNullOrEmpty(room.ImageName)) + { + room + } + @room.Name@room.CreatorAccountId@room.State@(room.IsDorm ? "Yes" : "No") + Edit +
+
+} + +@code { + [Inject] private HttpClient Http { get; set; } = default!; + + private string searchQuery = ""; + private bool searching; + private string? error; + private List? results; + + private async Task DoSearch() + { + searching = true; + error = null; + results = null; + + try + { + results = await Http.GetFromJsonAsync>($"/admin/api/rooms/search?q={Uri.EscapeDataString(searchQuery)}"); + } + catch (Exception ex) + { + error = $"Search failed: {ex.Message}"; + } + finally + { + searching = false; + } + } +} diff --git a/CannedNet.Admin/Components/Pages/Setup/AdminAccounts.razor b/CannedNet.Admin/Components/Pages/Setup/AdminAccounts.razor new file mode 100644 index 0000000..c914b88 --- /dev/null +++ b/CannedNet.Admin/Components/Pages/Setup/AdminAccounts.razor @@ -0,0 +1,144 @@ +@page "/setup/accounts" +@attribute [Authorize(Policy = "OwnerOnly")] +@rendermode InteractiveServer +@using CannedNet.Admin.Models +@using CannedNet.Admin.Services +@inject AdminAuthService AuthService + +Admin Accounts + +

Admin Accounts

+

Manage admin panel accounts. Only the owner can create or delete accounts.

+ +
+
Create New Admin Account
+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + @if (!string.IsNullOrEmpty(createMessage)) + { + @createMessage + } +
+
+
+ +
+
Admin Accounts
+
+ + + + + + + + + + + + @foreach (var user in AuthService.GetAll()) + { + + + + + } + else + { + + } + + + + + } + +
IDUsernameRoleCreatedActions
@user.Id@user.Username + @if (user.IsOwner) + { + OwnerAdmin@user.CreatedAt.ToString("g") + @if (!user.IsOwner) + { + + } +
+
+
+ +@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("confirm", "Delete this admin account?"); + if (!confirmed) return; + AuthService.DeleteUser(id); + } + + [Inject] private IJSRuntime JSRuntime { get; set; } = default!; +} diff --git a/CannedNet.Admin/Components/Pages/Setup/Index.razor b/CannedNet.Admin/Components/Pages/Setup/Index.razor new file mode 100644 index 0000000..1be3917 --- /dev/null +++ b/CannedNet.Admin/Components/Pages/Setup/Index.razor @@ -0,0 +1,110 @@ +@page "/setup" +@rendermode InteractiveServer +@using CannedNet.Admin.Services +@inject AdminAuthService AuthService +@inject NavigationManager Navigation + +First-Time Setup + +
+
+
+

First-Time Setup

+
+ @if (completed) + { +
+
Owner account created!
+

You can now log in with your new admin account.

+
+ } + else if (AuthService.HasAnyUser()) + { +
+ Admin accounts already exist. Go to login. +
+ } + else + { +

Create the initial owner account. This account will have full access and can create additional admin accounts.

+
+
+ + +
+
+ + +
+
+ + +
+ + @if (!string.IsNullOrEmpty(message)) + { +
@message
+ } +
+ } +
+
+
+
+ +@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; + } + } +} diff --git a/CannedNet.Admin/Components/Pages/Setup/Nameserver.razor b/CannedNet.Admin/Components/Pages/Setup/Nameserver.razor new file mode 100644 index 0000000..7de3018 --- /dev/null +++ b/CannedNet.Admin/Components/Pages/Setup/Nameserver.razor @@ -0,0 +1,110 @@ +@page "/setup/nameserver" +@rendermode InteractiveServer +@using CannedNet.Admin.Models +@using System.Text.Json + +Nameserver Setup + +

Nameserver Endpoints

+

Configure the base server URL the game client connects to. All endpoints are derived from this URL.

+ +@if (loading) +{ +
+} +else if (!string.IsNullOrEmpty(error)) +{ +
@error
+} +else +{ +
+
+ + @if (!string.IsNullOrEmpty(saveMessage)) + { + @saveMessage + } +
+ +
+ + +
All endpoint URLs will use this as the base.
+
+ +
+ + + + + + + + + @foreach (var (name, path) in EndpointDefinitions.Endpoints) + { + + + + + } + +
ServiceFull URL
@name@(baseUrl.TrimEnd('/') + path)
+
+
+} + +@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; + } + } +} diff --git a/CannedNet.Admin/Components/Pages/Storefronts/List.razor b/CannedNet.Admin/Components/Pages/Storefronts/List.razor new file mode 100644 index 0000000..2a759de --- /dev/null +++ b/CannedNet.Admin/Components/Pages/Storefronts/List.razor @@ -0,0 +1,277 @@ +@page "/storefronts" +@rendermode InteractiveServer + +Storefronts + +

Storefronts

+

Manage storefronts and their items.

+ +@if (loading) +{ +
+} +else if (!string.IsNullOrEmpty(error)) +{ +
@error
+} +else if (storefronts == null || storefronts.Count == 0) +{ +
No storefronts found.
+} +else +{ + @foreach (var store in storefronts) + { +
+
+ @store.Name + Type: @store.StorefrontType | Items: @(store.Items?.Count ?? 0) +
+
+ @if (store.Items != null && store.Items.Count > 0) + { +
+ + + + + + + + + + + + + + @foreach (var item in store.Items) + { + + + + + + + + + + } + +
IDPurchasableItemIdTypeFeaturedPricesGift DropsActions
@item.Id@item.PurchasableItemId@item.Type@(item.IsFeatured ? "Yes" : "No") + @if (item.Prices != null && item.Prices.Count > 0) + { + @foreach (var p in item.Prices) + { +
Type @p.CurrencyType: @p.Price
+ } + } +
@(item.GiftDrops?.Count ?? 0) + + +
+
+ } + else + { +

No items in this storefront.

+ } + +
+
+ } + + @if (showEditModal) + { + + } +} + +@code { + private bool loading = true; + private string? error; + private List? 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>("/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("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!; +} diff --git a/CannedNet.Admin/Components/Pages/Users/Edit.razor b/CannedNet.Admin/Components/Pages/Users/Edit.razor new file mode 100644 index 0000000..dd9150b --- /dev/null +++ b/CannedNet.Admin/Components/Pages/Users/Edit.razor @@ -0,0 +1,278 @@ +@page "/users/edit/{Id:int}" +@rendermode InteractiveServer +@inject ImageUrlService Img + +Edit User @Id + +

Edit User #@Id

+ +@if (loading) +{ +
+} +else if (!string.IsNullOrEmpty(error)) +{ +
@error
+} +else if (data == null) +{ +
User not found.
+} +else +{ +
+
+
+
+ @if (data.Account != null && !string.IsNullOrEmpty(data.Account.ProfileImage)) + { + Profile + } +
@data.Account!.Username
+

@data.Account!.DisplayName

+

ID: @data.Account!.AccountId

+
+
+ + @if (data.TokenBalances?.Count > 0) + { +
+
Token Balances
+
+ @foreach (var token in data.TokenBalances) + { +
Type @token.CurrencyType: @token.Balance
+ } +
+
+ } + + @if (data.Progression != null) + { +
+
Progression
+
+
Level: @data.Progression.Level
+
XP: @data.Progression.Xp
+
+
+ } +
+ +
+
+
+
Account Details
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + @if (!string.IsNullOrEmpty(data.Bio)) + { +
+
Bio
+
+

@data.Bio

+
+
+ } + + @if (data.AvatarItems?.Count > 0) + { +
+
Avatar Items (@data.AvatarItems.Count)
+
+ + + + + + + + + + + @foreach (var item in data.AvatarItems) + { + + + + + + + } + +
IDNameTypeRarity
@item.Id@item.FriendlyName@item.AvatarItemType@item.Rarity
+
+
+ } + + @if (data.Settings?.Count > 0) + { +
+
Player Settings (@data.Settings.Count)
+
+ @foreach (var setting in data.Settings) + { +
@setting.Key: @setting.Value
+ } +
+
+ } + +
+ + + Back to Search +
+ + @if (!string.IsNullOrEmpty(saveMessage)) + { +
@saveMessage
+ } +
+
+
+} + +@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($"/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("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? AvatarItems { get; set; } + public List? Settings { get; set; } + public List? TokenBalances { get; set; } + } +} diff --git a/CannedNet.Admin/Components/Pages/Users/Search.razor b/CannedNet.Admin/Components/Pages/Users/Search.razor new file mode 100644 index 0000000..c4faeb4 --- /dev/null +++ b/CannedNet.Admin/Components/Pages/Users/Search.razor @@ -0,0 +1,94 @@ +@page "/users/search" +@rendermode InteractiveServer +@inject ImageUrlService Img + +Search Users + +

Search Users

+

Search for player accounts by username, display name, or account ID.

+ +
+ + +
+ +@if (searching) +{ +
+} +else if (!string.IsNullOrEmpty(error)) +{ +
@error
+} +else if (results != null && results.Count == 0) +{ +
No users found matching "@searchQuery".
+} +else if (results != null) +{ +
+ + + + + + + + + + + + + @foreach (var account in results) + { + + + + + + + + + } + +
IDProfile ImageUsernameDisplay NameCreatedActions
@account.AccountId + @if (!string.IsNullOrEmpty(account.ProfileImage)) + { + pfp + } + @account.Username@account.DisplayName@account.CreatedAt.ToString("g") + Edit +
+
+} + +@code { + [Inject] private HttpClient Http { get; set; } = default!; + + private string searchQuery = ""; + private bool searching; + private string? error; + private List? results; + + private async Task DoSearch() + { + searching = true; + error = null; + results = null; + + try + { + results = await Http.GetFromJsonAsync>($"/admin/api/accounts/search?q={Uri.EscapeDataString(searchQuery)}"); + } + catch (Exception ex) + { + error = $"Search failed: {ex.Message}"; + } + finally + { + searching = false; + } + } +} diff --git a/CannedNet.Admin/Components/Pages/Weather.razor b/CannedNet.Admin/Components/Pages/Weather.razor deleted file mode 100644 index f437e5e..0000000 --- a/CannedNet.Admin/Components/Pages/Weather.razor +++ /dev/null @@ -1,64 +0,0 @@ -@page "/weather" -@attribute [StreamRendering] - -Weather - -

Weather

- -

This component demonstrates showing data.

- -@if (forecasts == null) -{ -

Loading...

-} -else -{ - - - - - - - - - - - @foreach (var forecast in forecasts) - { - - - - - - - } - -
DateTemp. (C)Temp. (F)Summary
@forecast.Date.ToShortDateString()@forecast.TemperatureC@forecast.TemperatureF@forecast.Summary
-} - -@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); - } -} diff --git a/CannedNet.Admin/Components/Routes.razor b/CannedNet.Admin/Components/Routes.razor index 105855d..c387474 100644 --- a/CannedNet.Admin/Components/Routes.razor +++ b/CannedNet.Admin/Components/Routes.razor @@ -1,6 +1,10 @@  - + + + + + diff --git a/CannedNet.Admin/Components/_Imports.razor b/CannedNet.Admin/Components/_Imports.razor index 9b0a9ae..48d853d 100644 --- a/CannedNet.Admin/Components/_Imports.razor +++ b/CannedNet.Admin/Components/_Imports.razor @@ -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 diff --git a/CannedNet.Admin/Models/AdminUser.cs b/CannedNet.Admin/Models/AdminUser.cs new file mode 100644 index 0000000..246db69 --- /dev/null +++ b/CannedNet.Admin/Models/AdminUser.cs @@ -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; } +} diff --git a/CannedNet.Admin/Models/EndpointDefinitions.cs b/CannedNet.Admin/Models/EndpointDefinitions.cs new file mode 100644 index 0000000..11fdd4b --- /dev/null +++ b/CannedNet.Admin/Models/EndpointDefinitions.cs @@ -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(); + foreach (var (name, path) in Endpoints) + dict[name] = clean + path; + return System.Text.Json.JsonSerializer.Serialize(dict, new System.Text.Json.JsonSerializerOptions { WriteIndented = true }); + } +} diff --git a/CannedNet.Admin/Program.cs b/CannedNet.Admin/Program.cs index a3dc19e..4182f83 100644 --- a/CannedNet.Admin/Program.cs +++ b/CannedNet.Admin/Program.cs @@ -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(); +var imagesBaseUrl = builder.Configuration.GetValue("ImagesBaseUrl") ?? "http://localhost:5000/img"; +builder.Services.AddSingleton(new ImageUrlService(imagesBaseUrl)); +builder.Services.AddHttpContextAccessor(); + + +var serverUrl = builder.Configuration.GetValue("ServerBaseUrl") ?? "http://localhost:5000"; +builder.Services.AddTransient(); +builder.Services.AddScoped(sp => +{ + var handler = sp.GetRequiredService(); + 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(); diff --git a/CannedNet.Admin/Services/AdminApiKeyHandler.cs b/CannedNet.Admin/Services/AdminApiKeyHandler.cs new file mode 100644 index 0000000..a6fe317 --- /dev/null +++ b/CannedNet.Admin/Services/AdminApiKeyHandler.cs @@ -0,0 +1,21 @@ +namespace CannedNet.Admin.Services; + +public class AdminApiKeyHandler : DelegatingHandler +{ + private readonly string _apiKey; + + public AdminApiKeyHandler(IConfiguration config) + { + _apiKey = config.GetValue("Admin:ApiKey") + ?? config.GetValue("AdminApiKey") + ?? ""; + } + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + if (!string.IsNullOrEmpty(_apiKey)) + request.Headers.Add("X-Admin-Api-Key", _apiKey); + + return await base.SendAsync(request, cancellationToken); + } +} diff --git a/CannedNet.Admin/Services/AdminAuthService.cs b/CannedNet.Admin/Services/AdminAuthService.cs new file mode 100644 index 0000000..943fbc5 --- /dev/null +++ b/CannedNet.Admin/Services/AdminAuthService.cs @@ -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 _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 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>(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 { } + } +} diff --git a/CannedNet.Admin/Services/ImageUrlService.cs b/CannedNet.Admin/Services/ImageUrlService.cs new file mode 100644 index 0000000..57b4bbb --- /dev/null +++ b/CannedNet.Admin/Services/ImageUrlService.cs @@ -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('/')}"; + } +} diff --git a/CannedNet.Admin/appsettings.json b/CannedNet.Admin/appsettings.json index 10f68b8..b28cc6d 100644 --- a/CannedNet.Admin/appsettings.json +++ b/CannedNet.Admin/appsettings.json @@ -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" + } } diff --git a/CannedNet.Admin/wwwroot/app.css b/CannedNet.Admin/wwwroot/app.css index 73a69d6..3e2e10c 100644 --- a/CannedNet.Admin/wwwroot/app.css +++ b/CannedNet.Admin/wwwroot/app.css @@ -57,4 +57,12 @@ h1:focus { .form-floating > .form-control-plaintext:focus::placeholder, .form-floating > .form-control:focus::placeholder { text-align: start; -} \ No newline at end of file +} + +.card { + box-shadow: 0 1px 3px rgba(0,0,0,0.12); +} + +.card-header { + font-weight: 600; +} diff --git a/CannedNet/CannedNet.csproj b/CannedNet/CannedNet.csproj index 75eecf2..3bf911c 100644 --- a/CannedNet/CannedNet.csproj +++ b/CannedNet/CannedNet.csproj @@ -9,18 +9,18 @@ - - + + runtime; build; native; contentfiles; analyzers; buildtransitive all - + - + - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/CannedNet/Data/AppDbContext.cs b/CannedNet/Data/AppDbContext.cs index 4ae64a3..ac3a765 100644 --- a/CannedNet/Data/AppDbContext.cs +++ b/CannedNet/Data/AppDbContext.cs @@ -24,7 +24,7 @@ public class AppDbContext : DbContext public DbSet PromoExternalContents { get; set; } public DbSet RoomRoles { get; set; } public DbSet TokenBalances { get; set; } - public DbSet PlayerBios { get; set; } + public DbSet PlayerBios { get; set; } public DbSet Storefronts { get; set; } public DbSet StorefrontItems { get; set; } public DbSet GiftDrops { get; set; } @@ -98,7 +98,7 @@ public class AppDbContext : DbContext entity.HasIndex(e => e.OwnerAccountId).IsUnique(); entity.ToTable("player_avatars"); }); - + // saved_outfits modelBuilder.Entity(entity => { @@ -108,7 +108,7 @@ public class AppDbContext : DbContext entity.HasIndex(e => e.OwnerAccountId).IsUnique(); entity.ToTable("saved_outfits"); }); - + // room_instances modelBuilder.Entity(entity => { @@ -178,7 +178,7 @@ public class AppDbContext : DbContext entity.HasIndex(e => e.RoomId); entity.ToTable("room_roles"); }); - + // token_balances modelBuilder.Entity(entity => { @@ -186,7 +186,7 @@ public class AppDbContext : DbContext entity.Property(e => e.Id).IsRequired(); entity.ToTable("token_balances"); }); - + // player_bios modelBuilder.Entity(entity => { @@ -277,4 +277,3 @@ public class AppDbContext : DbContext }); } } - diff --git a/CannedNet/JSON/communityboard.json b/CannedNet/JSON/communityboard.json index 6855b96..2b43844 100644 --- a/CannedNet/JSON/communityboard.json +++ b/CannedNet/JSON/communityboard.json @@ -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" - } - ] -} \ No newline at end of file +{"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":[]} \ No newline at end of file diff --git a/CannedNet/Program.cs b/CannedNet/Program.cs index 2e69703..fed7514 100644 --- a/CannedNet/Program.cs +++ b/CannedNet/Program.cs @@ -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(options => options.UseNpgsql(connectionString)); builder.Services.AddSingleton(); builder.Services.AddScoped(); @@ -66,8 +72,8 @@ public static class Program NameClaimType = ClaimTypes.NameIdentifier, RoleClaimType = "role" }; - }); - + }) + .AddScheme("AdminApiKey", null); builder.Services.AddAuthorization(); @@ -95,9 +101,6 @@ public static class Program using IServiceScope scope = app.Services.CreateScope(); AppDbContext db = scope.ServiceProvider.GetRequiredService(); - StorefrontFillService seedingService = scope.ServiceProvider.GetRequiredService(); - 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(); + await seedingService.FillStorefrontsAsync(); + Signatures.Init(); await app.RunAsync(); diff --git a/CannedNet/Services/AdminApiKeyHandler.cs b/CannedNet/Services/AdminApiKeyHandler.cs new file mode 100644 index 0000000..9baa617 --- /dev/null +++ b/CannedNet/Services/AdminApiKeyHandler.cs @@ -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 +{ + public AdminApiKeyHandler( + IOptionsMonitor options, + ILoggerFactory logger, + UrlEncoder encoder) + : base(options, logger, encoder) + { + } + + protected override Task HandleAuthenticateAsync() + { + var config = Context.RequestServices.GetRequiredService(); + var expectedKey = config.GetValue("Admin:ApiKey") + ?? config.GetValue("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); + } +} diff --git a/CannedNet/Services/Controllers/APIController.cs b/CannedNet/Services/Controllers/APIController.cs index 86d3ef3..b5a2003 100644 --- a/CannedNet/Services/Controllers/APIController.cs +++ b/CannedNet/Services/Controllers/APIController.cs @@ -177,47 +177,6 @@ public class APIController : ControllerBase .Where(s => s.PlayerId == id) .ToListAsync(); - if (settings.Count == 0) - { - var defaults = new List - { - 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); } diff --git a/CannedNet/Services/Controllers/AdminController.cs b/CannedNet/Services/Controllers/AdminController.cs index 0502af0..b624915 100644 --- a/CannedNet/Services/Controllers/AdminController.cs +++ b/CannedNet/Services/Controllers/AdminController.cs @@ -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 GetRoot() + private const string JsonDir = "JSON"; + + [HttpGet("endpoints")] + public async Task 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 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 SearchAccounts([FromQuery] string q, AppDbContext db) + { + if (string.IsNullOrWhiteSpace(q)) + return Results.Ok(new List()); + + 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 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 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(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 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 SearchRooms([FromQuery] string q, AppDbContext db) + { + if (string.IsNullOrWhiteSpace(q)) + return Results.Ok(new List()); + + 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 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 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(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 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 CreateRoom(AppDbContext db) + { + using var reader = new StreamReader(Request.Body); + var body = await reader.ReadToEndAsync(); + var data = JsonSerializer.Deserialize(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 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(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 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 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 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 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 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 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 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 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 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(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 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(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 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 }); } } diff --git a/CannedNet/Services/Controllers/ImageController.cs b/CannedNet/Services/Controllers/ImageController.cs index 15e5800..930887c 100644 --- a/CannedNet/Services/Controllers/ImageController.cs +++ b/CannedNet/Services/Controllers/ImageController.cs @@ -54,48 +54,37 @@ 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) + using Image image = Image.Load(imageBytes); + + if (!string.IsNullOrEmpty(cropSquare) && cropSquare != "0" && cropSquare != "false") { - context.Response.Headers["Content-Signature"] = $"key-id=KEY:RSA:p1.rec.net; data={signature1}"; + int size = Math.Min(image.Width, image.Height); + int x = (image.Width - size) / 2; + int y = (image.Height - size) / 2; + image.Mutate(img => img.Crop(new Rectangle(x, y, size, size))); } - return Results.File(imageBytes, contentType); + + 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"; } - 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); - int x = (image.Width - size) / 2; - int y = (image.Height - size) / 2; - 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; - - 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(); string? signature = Signatures.Sign(imageBytes); - if (signature != null) + context.Response.OnStarting(() => { - context.Response.Headers["Content-Signature"] = $"key-id=KEY:RSA:p1.rec.net; data={signature}"; - } - return Results.File(imageBytes, "image/jpeg"); + if (signature != null) + context.Response.Headers["Content-Signature"] = $"key-id=KEY:RSA:p1.rec.net; data={signature}"; + return Task.CompletedTask; + }); + return Results.File(imageBytes, contentType); } } diff --git a/CannedNet/appsettings.json b/CannedNet/appsettings.json index 8d3a59f..ae950fc 100644 --- a/CannedNet/appsettings.json +++ b/CannedNet/appsettings.json @@ -11,6 +11,9 @@ "Ports": { "Primary": 5000 }, - "AllowedHosts": "*" + "AllowedHosts": "*", + "Admin": { + "ApiKey": "changeme-replace-with-a-secure-key" + } }