Initial commit: RR3 Community Server with web admin panel

- ASP.NET Core 8 REST API server
- 12 API endpoints matching EA Synergy protocol
- SQLite database with Entity Framework Core
- Web admin panel with Bootstrap 5
- User, Catalog, Session, Purchase management
- Comprehensive documentation

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-02-17 22:02:12 -08:00
commit 0a327f3a8b
187 changed files with 9282 additions and 0 deletions

View File

@@ -0,0 +1,225 @@
@page "/admin"
@model RR3CommunityServer.Pages.AdminModel
@{
Layout = "_Layout";
ViewData["Title"] = "Dashboard";
}
<div class="container-fluid mt-4">
<!-- Header -->
<div class="row mb-4">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center">
<div>
<h1 class="display-4">🏎️ RR3 Community Server</h1>
<p class="text-muted">Administration Dashboard</p>
</div>
<div class="text-end">
<div class="badge bg-success fs-6">🟢 Server Online</div>
<div class="text-muted small mt-1">Uptime: @Model.Uptime</div>
</div>
</div>
</div>
</div>
<!-- Statistics Cards -->
<div class="row g-3 mb-4">
<div class="col-md-3">
<div class="card border-primary">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center">
<div>
<h6 class="text-muted mb-0">Total Users</h6>
<h2 class="mb-0">@Model.TotalUsers</h2>
</div>
<div class="fs-1 text-primary">👥</div>
</div>
<small class="text-muted">Registered players</small>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card border-success">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center">
<div>
<h6 class="text-muted mb-0">Active Sessions</h6>
<h2 class="mb-0">@Model.ActiveSessions</h2>
</div>
<div class="fs-1 text-success">🔄</div>
</div>
<small class="text-muted">Currently online</small>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card border-info">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center">
<div>
<h6 class="text-muted mb-0">Total Devices</h6>
<h2 class="mb-0">@Model.TotalDevices</h2>
</div>
<div class="fs-1 text-info">📱</div>
</div>
<small class="text-muted">Registered devices</small>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card border-warning">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center">
<div>
<h6 class="text-muted mb-0">Catalog Items</h6>
<h2 class="mb-0">@Model.TotalCatalogItems</h2>
</div>
<div class="fs-1 text-warning">🏪</div>
</div>
<small class="text-muted">Available items</small>
</div>
</div>
</div>
</div>
<!-- Quick Actions -->
<div class="row mb-4">
<div class="col-12">
<div class="card">
<div class="card-header bg-dark text-white">
<h5 class="mb-0">⚡ Quick Actions</h5>
</div>
<div class="card-body">
<div class="d-flex gap-2 flex-wrap">
<a href="/admin/users" class="btn btn-primary">
<i class="bi bi-people"></i> Manage Users
</a>
<a href="/admin/catalog" class="btn btn-info">
<i class="bi bi-shop"></i> Manage Catalog
</a>
<a href="/admin/sessions" class="btn btn-success">
<i class="bi bi-clock-history"></i> View Sessions
</a>
<a href="/admin/purchases" class="btn btn-warning">
<i class="bi bi-cart"></i> View Purchases
</a>
<a href="/admin/settings" class="btn btn-secondary">
<i class="bi bi-gear"></i> Server Settings
</a>
</div>
</div>
</div>
</div>
</div>
<!-- Recent Activity -->
<div class="row">
<div class="col-md-6">
<div class="card">
<div class="card-header bg-primary text-white">
<h5 class="mb-0">📊 Recent Users</h5>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>Synergy ID</th>
<th>Joined</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@foreach (var user in Model.RecentUsers)
{
<tr>
<td><code>@user.SynergyId</code></td>
<td><small>@user.CreatedAt.ToString("g")</small></td>
<td>
<a href="/admin/users?id=@user.Id" class="btn btn-sm btn-outline-primary">View</a>
</td>
</tr>
}
</tbody>
</table>
</div>
<a href="/admin/users" class="btn btn-sm btn-link">View All Users →</a>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card">
<div class="card-header bg-success text-white">
<h5 class="mb-0">🔄 Active Sessions</h5>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>Session ID</th>
<th>Expires</th>
<th>Status</th>
</tr>
</thead>
<tbody>
@foreach (var session in Model.RecentSessions)
{
<tr>
<td><code>@session.SessionId.Substring(0, 8)...</code></td>
<td><small>@session.ExpiresAt.ToString("g")</small></td>
<td><span class="badge bg-success">Active</span></td>
</tr>
}
</tbody>
</table>
</div>
<a href="/admin/sessions" class="btn btn-sm btn-link">View All Sessions →</a>
</div>
</div>
</div>
</div>
<!-- Server Info -->
<div class="row mt-4">
<div class="col-12">
<div class="card">
<div class="card-header bg-dark text-white">
<h5 class="mb-0"> Server Information</h5>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6">
<dl class="row">
<dt class="col-sm-4">Server URL:</dt>
<dd class="col-sm-8"><code>@Model.ServerUrl</code></dd>
<dt class="col-sm-4">Platform:</dt>
<dd class="col-sm-8">@Model.Platform</dd>
<dt class="col-sm-4">.NET Version:</dt>
<dd class="col-sm-8">@Model.DotNetVersion</dd>
</dl>
</div>
<div class="col-md-6">
<dl class="row">
<dt class="col-sm-4">Database:</dt>
<dd class="col-sm-8">SQLite (EF Core)</dd>
<dt class="col-sm-4">API Endpoints:</dt>
<dd class="col-sm-8">12 active</dd>
<dt class="col-sm-4">Swagger:</dt>
<dd class="col-sm-8"><a href="/swagger" target="_blank">View API Docs</a></dd>
</dl>
</div>
</div>
</div>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,58 @@
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.EntityFrameworkCore;
using RR3CommunityServer.Data;
using static RR3CommunityServer.Data.RR3DbContext;
namespace RR3CommunityServer.Pages;
public class AdminModel : PageModel
{
private readonly RR3DbContext _context;
public AdminModel(RR3DbContext context)
{
_context = context;
}
public int TotalUsers { get; set; }
public int ActiveSessions { get; set; }
public int TotalDevices { get; set; }
public int TotalCatalogItems { get; set; }
public string Uptime { get; set; } = "0:00:00";
public string ServerUrl { get; set; } = string.Empty;
public string Platform { get; set; } = string.Empty;
public string DotNetVersion { get; set; } = string.Empty;
public List<User> RecentUsers { get; set; } = new();
public List<Session> RecentSessions { get; set; } = new();
public async Task OnGetAsync()
{
// Get statistics
TotalUsers = await _context.Users.CountAsync();
TotalDevices = await _context.Devices.CountAsync();
TotalCatalogItems = await _context.CatalogItems.CountAsync();
ActiveSessions = await _context.Sessions
.Where(s => s.ExpiresAt > DateTime.UtcNow)
.CountAsync();
// Get recent activity
RecentUsers = await _context.Users
.OrderByDescending(u => u.CreatedAt)
.Take(5)
.ToListAsync();
RecentSessions = await _context.Sessions
.Where(s => s.ExpiresAt > DateTime.UtcNow)
.OrderByDescending(s => s.CreatedAt)
.Take(5)
.ToListAsync();
// Server info
var uptime = DateTime.UtcNow - System.Diagnostics.Process.GetCurrentProcess().StartTime.ToUniversalTime();
Uptime = $"{uptime.Days}d {uptime.Hours}h {uptime.Minutes}m";
ServerUrl = $"{Request.Scheme}://{Request.Host}";
Platform = Environment.OSVersion.Platform.ToString();
DotNetVersion = Environment.Version.ToString();
}
}

View File

@@ -0,0 +1,201 @@
@page
@model RR3CommunityServer.Pages.CatalogModel
@{
ViewData["Title"] = "Catalog Management";
}
<div class="container-fluid mt-4">
<div class="row mb-4">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center">
<div>
<h1>🏪 Catalog Management</h1>
<p class="text-muted">Manage in-game items and purchases</p>
</div>
<div>
<button class="btn btn-success" data-bs-toggle="modal" data-bs-target="#addItemModal">
<i class="bi bi-plus-circle"></i> Add New Item
</button>
<a href="/admin" class="btn btn-outline-secondary">← Back</a>
</div>
</div>
</div>
</div>
<div class="card">
<div class="card-header bg-info text-white">
<h5 class="mb-0">Catalog Items (@Model.CatalogItems.Count)</h5>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>SKU</th>
<th>Name</th>
<th>Type</th>
<th>Price</th>
<th>Available</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@foreach (var item in Model.CatalogItems)
{
<tr>
<td><code>@item.Sku</code></td>
<td><strong>@item.Name</strong></td>
<td>
<span class="badge bg-secondary">@item.Type</span>
</td>
<td>
@if (item.Price == 0)
{
<span class="text-success"><strong>FREE</strong></span>
}
else
{
<span>@item.Price.ToString("C2")</span>
}
</td>
<td>
@if (item.Available)
{
<span class="badge bg-success">✓ Yes</span>
}
else
{
<span class="badge bg-danger">✗ No</span>
}
</td>
<td>
<button class="btn btn-sm btn-primary" data-bs-toggle="modal" data-bs-target="#editModal@(item.Id)">
<i class="bi bi-pencil"></i> Edit
</button>
<form method="post" asp-page-handler="ToggleAvailability" class="d-inline">
<input type="hidden" name="itemId" value="@item.Id" />
<button type="submit" class="btn btn-sm btn-@(item.Available ? "warning" : "success")">
<i class="bi bi-@(item.Available ? "eye-slash" : "eye")"></i> @(item.Available ? "Disable" : "Enable")
</button>
</form>
<form method="post" asp-page-handler="Delete" class="d-inline">
<input type="hidden" name="itemId" value="@item.Id" />
<button type="submit" class="btn btn-sm btn-danger" onclick="return confirm('Delete this item?')">
<i class="bi bi-trash"></i>
</button>
</form>
</td>
</tr>
<!-- Edit Modal -->
<div class="modal fade" id="editModal@(item.Id)" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<form method="post" asp-page-handler="Update">
<div class="modal-header bg-primary text-white">
<h5 class="modal-title">Edit Item</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<input type="hidden" name="itemId" value="@item.Id" />
<div class="mb-3">
<label class="form-label">SKU</label>
<input type="text" name="sku" value="@item.Sku" class="form-control" required>
</div>
<div class="mb-3">
<label class="form-label">Name</label>
<input type="text" name="name" value="@item.Name" class="form-control" required>
</div>
<div class="mb-3">
<label class="form-label">Type</label>
<select name="type" class="form-select" required>
<option value="car" selected="@(item.Type == "car")">Car</option>
<option value="upgrade" selected="@(item.Type == "upgrade")">Upgrade</option>
<option value="currency" selected="@(item.Type == "currency")">Currency</option>
<option value="consumable" selected="@(item.Type == "consumable")">Consumable</option>
</select>
</div>
<div class="mb-3">
<label class="form-label">Price</label>
<input type="number" name="price" value="@item.Price" step="0.01" class="form-control" required>
</div>
<div class="mb-3">
<div class="form-check">
<input type="checkbox" name="available" checked="@item.Available" class="form-check-input" id="available@(item.Id)">
<label class="form-check-label" for="available@(item.Id)">Available for purchase</label>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary">Save Changes</button>
</div>
</form>
</div>
</div>
</div>
}
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- Add New Item Modal -->
<div class="modal fade" id="addItemModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<form method="post" asp-page-handler="Add">
<div class="modal-header bg-success text-white">
<h5 class="modal-title">Add New Item</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label">SKU</label>
<input type="text" name="sku" class="form-control" placeholder="com.ea.rr3.car.porsche911" required>
<small class="text-muted">Unique identifier (e.g., com.ea.rr3.car.name)</small>
</div>
<div class="mb-3">
<label class="form-label">Name</label>
<input type="text" name="name" class="form-control" placeholder="Porsche 911 GT3" required>
</div>
<div class="mb-3">
<label class="form-label">Type</label>
<select name="type" class="form-select" required>
<option value="car">Car</option>
<option value="upgrade">Upgrade</option>
<option value="currency">Currency</option>
<option value="consumable">Consumable</option>
</select>
</div>
<div class="mb-3">
<label class="form-label">Price</label>
<input type="number" name="price" step="0.01" value="0.00" class="form-control" required>
<small class="text-muted">Set to 0 for free items</small>
</div>
<div class="mb-3">
<div class="form-check">
<input type="checkbox" name="available" checked class="form-check-input" id="availableNew">
<label class="form-check-label" for="availableNew">Available for purchase</label>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-success">Add Item</button>
</div>
</form>
</div>
</div>
</div>

View File

@@ -0,0 +1,84 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.EntityFrameworkCore;
using RR3CommunityServer.Data;
using static RR3CommunityServer.Data.RR3DbContext;
namespace RR3CommunityServer.Pages;
public class CatalogModel : PageModel
{
private readonly RR3DbContext _context;
public CatalogModel(RR3DbContext context)
{
_context = context;
}
public List<CatalogItem> CatalogItems { get; set; } = new();
public async Task OnGetAsync()
{
CatalogItems = await _context.CatalogItems
.OrderBy(c => c.Type)
.ThenBy(c => c.Name)
.ToListAsync();
}
public async Task<IActionResult> OnPostAddAsync(string sku, string name, string type, decimal price, bool available)
{
var item = new CatalogItem
{
Sku = sku,
Name = name,
Type = type,
Price = price,
Available = available
};
_context.CatalogItems.Add(item);
await _context.SaveChangesAsync();
return RedirectToPage();
}
public async Task<IActionResult> OnPostUpdateAsync(int itemId, string sku, string name, string type, decimal price, bool available)
{
var item = await _context.CatalogItems.FindAsync(itemId);
if (item != null)
{
item.Sku = sku;
item.Name = name;
item.Type = type;
item.Price = price;
item.Available = available;
await _context.SaveChangesAsync();
}
return RedirectToPage();
}
public async Task<IActionResult> OnPostToggleAvailabilityAsync(int itemId)
{
var item = await _context.CatalogItems.FindAsync(itemId);
if (item != null)
{
item.Available = !item.Available;
await _context.SaveChangesAsync();
}
return RedirectToPage();
}
public async Task<IActionResult> OnPostDeleteAsync(int itemId)
{
var item = await _context.CatalogItems.FindAsync(itemId);
if (item != null)
{
_context.CatalogItems.Remove(item);
await _context.SaveChangesAsync();
}
return RedirectToPage();
}
}

View File

@@ -0,0 +1,185 @@
@page
@model RR3CommunityServer.Pages.PurchasesModel
@{
ViewData["Title"] = "Purchase History";
}
<div class="container-fluid mt-4">
<div class="row mb-4">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center">
<div>
<h1>🛒 Purchase History</h1>
<p class="text-muted">View all in-game purchases</p>
</div>
<a href="/admin" class="btn btn-outline-secondary">← Back to Dashboard</a>
</div>
</div>
</div>
@if (Model.Purchases.Count == 0)
{
<div class="alert alert-info">
<i class="bi bi-info-circle"></i> No purchases yet. Purchases will appear here when players buy items.
</div>
}
else
{
<!-- Statistics -->
<div class="row g-3 mb-4">
<div class="col-md-4">
<div class="card border-primary">
<div class="card-body">
<h6 class="text-muted">Total Purchases</h6>
<h2 class="text-primary">@Model.Purchases.Count</h2>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card border-success">
<div class="card-body">
<h6 class="text-muted">Approved</h6>
<h2 class="text-success">@Model.Purchases.Count(p => p.Status == "approved")</h2>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card border-info">
<div class="card-body">
<h6 class="text-muted">Total Value</h6>
<h2 class="text-info">@Model.TotalValue.ToString("C2")</h2>
</div>
</div>
</div>
</div>
<!-- Purchase List -->
<div class="card">
<div class="card-header bg-warning text-dark">
<div class="d-flex justify-content-between align-items-center">
<h5 class="mb-0">All Purchases</h5>
<form method="get" class="d-flex gap-2">
<input type="text" name="search" value="@Model.SearchQuery" class="form-control form-control-sm" placeholder="Search by SKU or User...">
<button type="submit" class="btn btn-sm btn-dark">Search</button>
@if (!string.IsNullOrEmpty(Model.SearchQuery))
{
<a href="/admin/purchases" class="btn btn-sm btn-outline-dark">Clear</a>
}
</form>
</div>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>ID</th>
<th>User</th>
<th>SKU</th>
<th>Price</th>
<th>Status</th>
<th>Purchase Date</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@foreach (var purchase in Model.Purchases)
{
<tr>
<td><strong>@purchase.Id</strong></td>
<td>
@if (purchase.UserId.HasValue)
{
<code>User @purchase.UserId</code>
}
else
{
<span class="text-muted">Unknown</span>
}
</td>
<td><code>@purchase.Sku</code></td>
<td>
@if (purchase.Price == 0)
{
<span class="text-success"><strong>FREE</strong></span>
}
else
{
<span>@purchase.Price.ToString("C2")</span>
}
</td>
<td>
@if (purchase.Status == "approved")
{
<span class="badge bg-success">✓ Approved</span>
}
else if (purchase.Status == "pending")
{
<span class="badge bg-warning">⏳ Pending</span>
}
else
{
<span class="badge bg-danger">✗ @purchase.Status</span>
}
</td>
<td>@purchase.PurchaseDate.ToString("g")</td>
<td>
<button class="btn btn-sm btn-info" data-bs-toggle="modal" data-bs-target="#purchaseModal@(purchase.Id)">
<i class="bi bi-eye"></i> Details
</button>
<form method="post" asp-page-handler="Delete" class="d-inline">
<input type="hidden" name="purchaseId" value="@purchase.Id" />
<button type="submit" class="btn btn-sm btn-danger" onclick="return confirm('Delete this purchase record?')">
<i class="bi bi-trash"></i>
</button>
</form>
</td>
</tr>
<!-- Purchase Details Modal -->
<div class="modal fade" id="purchaseModal@(purchase.Id)" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header bg-warning">
<h5 class="modal-title">Purchase Details</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<dl class="row">
<dt class="col-sm-3">Purchase ID:</dt>
<dd class="col-sm-9">@purchase.Id</dd>
<dt class="col-sm-3">User ID:</dt>
<dd class="col-sm-9">@(purchase.UserId?.ToString() ?? "N/A")</dd>
<dt class="col-sm-3">SKU:</dt>
<dd class="col-sm-9"><code>@purchase.Sku</code></dd>
<dt class="col-sm-3">Price:</dt>
<dd class="col-sm-9">@purchase.Price.ToString("C2")</dd>
<dt class="col-sm-3">Status:</dt>
<dd class="col-sm-9">
<span class="badge bg-@(purchase.Status == "approved" ? "success" : "warning")">
@purchase.Status
</span>
</dd>
<dt class="col-sm-3">Purchase Date:</dt>
<dd class="col-sm-9">@purchase.PurchaseDate.ToString("F")</dd>
</dl>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
}
</tbody>
</table>
</div>
</div>
</div>
}
</div>

View File

@@ -0,0 +1,52 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.EntityFrameworkCore;
using RR3CommunityServer.Data;
using static RR3CommunityServer.Data.RR3DbContext;
namespace RR3CommunityServer.Pages;
public class PurchasesModel : PageModel
{
private readonly RR3DbContext _context;
public PurchasesModel(RR3DbContext context)
{
_context = context;
}
public List<Purchase> Purchases { get; set; } = new();
public decimal TotalValue { get; set; }
public string? SearchQuery { get; set; }
public async Task OnGetAsync(string? search)
{
SearchQuery = search;
var query = _context.Purchases.AsQueryable();
if (!string.IsNullOrEmpty(search))
{
query = query.Where(p => p.Sku.Contains(search) ||
(p.UserId != null && p.UserId.ToString()!.Contains(search)));
}
Purchases = await query
.OrderByDescending(p => p.PurchaseDate)
.ToListAsync();
TotalValue = Purchases.Sum(p => p.Price);
}
public async Task<IActionResult> OnPostDeleteAsync(int purchaseId)
{
var purchase = await _context.Purchases.FindAsync(purchaseId);
if (purchase != null)
{
_context.Purchases.Remove(purchase);
await _context.SaveChangesAsync();
}
return RedirectToPage();
}
}

View File

@@ -0,0 +1,160 @@
@page
@model RR3CommunityServer.Pages.SessionsModel
@{
ViewData["Title"] = "Session Management";
}
<div class="container-fluid mt-4">
<div class="row mb-4">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center">
<div>
<h1>🔄 Session Management</h1>
<p class="text-muted">Monitor active and expired sessions</p>
</div>
<div>
<form method="post" asp-page-handler="CleanupExpired" class="d-inline">
<button type="submit" class="btn btn-warning">
<i class="bi bi-trash3"></i> Cleanup Expired
</button>
</form>
<a href="/admin" class="btn btn-outline-secondary">← Back</a>
</div>
</div>
</div>
</div>
<!-- Statistics -->
<div class="row g-3 mb-4">
<div class="col-md-4">
<div class="card border-success">
<div class="card-body">
<h6 class="text-muted">Active Sessions</h6>
<h2 class="text-success">@Model.ActiveSessions.Count</h2>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card border-danger">
<div class="card-body">
<h6 class="text-muted">Expired Sessions</h6>
<h2 class="text-danger">@Model.ExpiredSessions.Count</h2>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card border-info">
<div class="card-body">
<h6 class="text-muted">Total Sessions</h6>
<h2 class="text-info">@Model.AllSessions.Count</h2>
</div>
</div>
</div>
</div>
<!-- Active Sessions -->
@if (Model.ActiveSessions.Any())
{
<div class="card mb-4">
<div class="card-header bg-success text-white">
<h5 class="mb-0">🟢 Active Sessions (@Model.ActiveSessions.Count)</h5>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>Session ID</th>
<th>User ID</th>
<th>Device ID</th>
<th>Created</th>
<th>Expires</th>
<th>Time Left</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@foreach (var session in Model.ActiveSessions)
{
var timeLeft = session.ExpiresAt - DateTime.UtcNow;
<tr>
<td><code>@session.SessionId.Substring(0, 12)...</code></td>
<td>@session.UserId</td>
<td><code>@session.DeviceId.Substring(0, 12)...</code></td>
<td>@session.CreatedAt.ToString("g")</td>
<td>@session.ExpiresAt.ToString("g")</td>
<td>
<span class="badge bg-success">
@((int)timeLeft.TotalHours)h @timeLeft.Minutes)m
</span>
</td>
<td>
<form method="post" asp-page-handler="Delete" class="d-inline">
<input type="hidden" name="sessionId" value="@session.Id" />
<button type="submit" class="btn btn-sm btn-danger" onclick="return confirm('Terminate this session?')">
<i class="bi bi-x-circle"></i> Terminate
</button>
</form>
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
}
<!-- Expired Sessions -->
@if (Model.ExpiredSessions.Any())
{
<div class="card">
<div class="card-header bg-secondary text-white">
<h5 class="mb-0">⚫ Expired Sessions (@Model.ExpiredSessions.Count)</h5>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>Session ID</th>
<th>User ID</th>
<th>Device ID</th>
<th>Created</th>
<th>Expired</th>
<th>Duration</th>
</tr>
</thead>
<tbody>
@foreach (var session in Model.ExpiredSessions.Take(20))
{
var duration = session.ExpiresAt - session.CreatedAt;
<tr class="text-muted">
<td><code>@session.SessionId.Substring(0, 12)...</code></td>
<td>@session.UserId</td>
<td><code>@session.DeviceId.Substring(0, 12)...</code></td>
<td>@session.CreatedAt.ToString("g")</td>
<td>@session.ExpiresAt.ToString("g")</td>
<td>@((int)duration.TotalHours)h</td>
</tr>
}
</tbody>
</table>
</div>
@if (Model.ExpiredSessions.Count > 20)
{
<div class="text-muted text-center">
<small>Showing 20 of @Model.ExpiredSessions.Count expired sessions</small>
</div>
}
</div>
</div>
}
@if (!Model.AllSessions.Any())
{
<div class="alert alert-info">
<i class="bi bi-info-circle"></i> No sessions yet. Sessions will appear when players connect to the server.
</div>
}
</div>

View File

@@ -0,0 +1,56 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.EntityFrameworkCore;
using RR3CommunityServer.Data;
using static RR3CommunityServer.Data.RR3DbContext;
namespace RR3CommunityServer.Pages;
public class SessionsModel : PageModel
{
private readonly RR3DbContext _context;
public SessionsModel(RR3DbContext context)
{
_context = context;
}
public List<Session> AllSessions { get; set; } = new();
public List<Session> ActiveSessions { get; set; } = new();
public List<Session> ExpiredSessions { get; set; } = new();
public async Task OnGetAsync()
{
AllSessions = await _context.Sessions
.OrderByDescending(s => s.CreatedAt)
.ToListAsync();
var now = DateTime.UtcNow;
ActiveSessions = AllSessions.Where(s => s.ExpiresAt > now).ToList();
ExpiredSessions = AllSessions.Where(s => s.ExpiresAt <= now).ToList();
}
public async Task<IActionResult> OnPostDeleteAsync(int sessionId)
{
var session = await _context.Sessions.FindAsync(sessionId);
if (session != null)
{
_context.Sessions.Remove(session);
await _context.SaveChangesAsync();
}
return RedirectToPage();
}
public async Task<IActionResult> OnPostCleanupExpiredAsync()
{
var expiredSessions = await _context.Sessions
.Where(s => s.ExpiresAt <= DateTime.UtcNow)
.ToListAsync();
_context.Sessions.RemoveRange(expiredSessions);
await _context.SaveChangesAsync();
return RedirectToPage();
}
}

View File

@@ -0,0 +1,213 @@
@page
@model RR3CommunityServer.Pages.SettingsModel
@{
ViewData["Title"] = "Server Settings";
}
<div class="container-fluid mt-4">
<div class="row mb-4">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center">
<div>
<h1>⚙️ Server Settings</h1>
<p class="text-muted">Configure server behavior and options</p>
</div>
<a href="/admin" class="btn btn-outline-secondary">← Back to Dashboard</a>
</div>
</div>
</div>
<!-- Server Configuration -->
<div class="row">
<div class="col-lg-8">
<div class="card mb-4">
<div class="card-header bg-primary text-white">
<h5 class="mb-0">🌐 Server Configuration</h5>
</div>
<div class="card-body">
<dl class="row">
<dt class="col-sm-4">Server URL:</dt>
<dd class="col-sm-8">
<code>@Model.ServerUrl</code>
<button class="btn btn-sm btn-outline-secondary ms-2" onclick="copyToClipboard('@Model.ServerUrl')">
<i class="bi bi-clipboard"></i> Copy
</button>
</dd>
<dt class="col-sm-4">Director Endpoint:</dt>
<dd class="col-sm-8"><code>@Model.ServerUrl/synergy/director</code></dd>
<dt class="col-sm-4">Database:</dt>
<dd class="col-sm-8">SQLite (rr3community.db)</dd>
<dt class="col-sm-4">Session Timeout:</dt>
<dd class="col-sm-8">24 hours</dd>
<dt class="col-sm-4">Auto-Approve Purchases:</dt>
<dd class="col-sm-8">
<span class="badge bg-success">✓ Enabled</span>
<small class="text-muted d-block">All purchases auto-approved for community servers</small>
</dd>
</dl>
</div>
</div>
<!-- APK Configuration -->
<div class="card mb-4">
<div class="card-header bg-info text-white">
<h5 class="mb-0">📱 APK Configuration</h5>
</div>
<div class="card-body">
<h6 class="mb-3">To connect game clients to this server:</h6>
<div class="alert alert-info">
<strong>Method 1: Use the automated script</strong>
<pre class="mb-2 mt-2"><code>.\RR3-Community-Mod.ps1 -ServerUrl "@Model.ServerUrl"</code></pre>
<small>Located in: <code>E:\rr3\RR3-Community-Mod.ps1</code></small>
</div>
<div class="alert alert-secondary">
<strong>Method 2: Manual AndroidManifest.xml modification</strong>
<ol class="small mb-0 mt-2">
<li>Decompile APK with APKTool</li>
<li>Edit AndroidManifest.xml:
<ul>
<li>Change <code>com.ea.nimble.configuration</code> from "live" to "custom"</li>
<li>Add metadata: <code>NimbleCustomizedSynergyServerEndpointUrl</code> = <code>@Model.ServerUrl</code></li>
</ul>
</li>
<li>Recompile and sign APK</li>
</ol>
</div>
<p class="mb-0">
<a href="file:///E:/rr3/APK_MODIFICATION_GUIDE.md" class="btn btn-sm btn-primary">
<i class="bi bi-book"></i> View Full Guide
</a>
</p>
</div>
</div>
</div>
<!-- System Information -->
<div class="col-lg-4">
<div class="card mb-4">
<div class="card-header bg-dark text-white">
<h5 class="mb-0">💻 System Info</h5>
</div>
<div class="card-body">
<dl class="mb-0">
<dt>Operating System</dt>
<dd><code>@Model.Platform</code></dd>
<dt>.NET Version</dt>
<dd><code>@Model.DotNetVersion</code></dd>
<dt>ASP.NET Core</dt>
<dd><code>@Model.AspNetVersion</code></dd>
<dt>Server Uptime</dt>
<dd><strong>@Model.Uptime</strong></dd>
<dt>Process ID</dt>
<dd><code>@Model.ProcessId</code></dd>
<dt>Working Memory</dt>
<dd><code>@Model.MemoryUsage MB</code></dd>
</dl>
</div>
</div>
<!-- Quick Links -->
<div class="card">
<div class="card-header bg-secondary text-white">
<h5 class="mb-0">🔗 Quick Links</h5>
</div>
<div class="card-body">
<div class="d-grid gap-2">
<a href="/swagger" target="_blank" class="btn btn-outline-primary">
<i class="bi bi-code-slash"></i> Swagger API Docs
</a>
<a href="file:///E:/rr3/NETWORK_COMMUNICATION_ANALYSIS.md" class="btn btn-outline-info">
<i class="bi bi-file-text"></i> Protocol Documentation
</a>
<a href="file:///E:/rr3/RR3CommunityServer/README.md" class="btn btn-outline-success">
<i class="bi bi-journal"></i> Server README
</a>
<a href="file:///E:/rr3/PROJECT_INDEX.md" class="btn btn-outline-warning">
<i class="bi bi-folder"></i> Project Index
</a>
</div>
</div>
</div>
</div>
</div>
<!-- Database Management -->
<div class="row mt-4">
<div class="col-12">
<div class="card">
<div class="card-header bg-warning text-dark">
<h5 class="mb-0">🗄️ Database Management</h5>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-3">
<div class="card border-primary">
<div class="card-body text-center">
<h3 class="text-primary">@Model.DbStats.Users</h3>
<p class="mb-0 text-muted">Users</p>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card border-success">
<div class="card-body text-center">
<h3 class="text-success">@Model.DbStats.Devices</h3>
<p class="mb-0 text-muted">Devices</p>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card border-info">
<div class="card-body text-center">
<h3 class="text-info">@Model.DbStats.Sessions</h3>
<p class="mb-0 text-muted">Sessions</p>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card border-warning">
<div class="card-body text-center">
<h3 class="text-warning">@Model.DbStats.Purchases</h3>
<p class="mb-0 text-muted">Purchases</p>
</div>
</div>
</div>
</div>
<div class="mt-4">
<h6>⚠️ Danger Zone</h6>
<div class="alert alert-danger">
<form method="post" asp-page-handler="ResetDatabase" onsubmit="return confirm('This will DELETE ALL DATA and reset the database. Are you sure?')">
<button type="submit" class="btn btn-danger">
<i class="bi bi-exclamation-triangle"></i> Reset Database (Delete All Data)
</button>
</form>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
@section Scripts {
<script>
function copyToClipboard(text) {
navigator.clipboard.writeText(text).then(() => {
alert('Copied to clipboard: ' + text);
});
}
</script>
}

View File

@@ -0,0 +1,74 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.EntityFrameworkCore;
using RR3CommunityServer.Data;
namespace RR3CommunityServer.Pages;
public class SettingsModel : PageModel
{
private readonly RR3DbContext _context;
public SettingsModel(RR3DbContext context)
{
_context = context;
}
public string ServerUrl { get; set; } = string.Empty;
public string Platform { get; set; } = string.Empty;
public string DotNetVersion { get; set; } = string.Empty;
public string AspNetVersion { get; set; } = string.Empty;
public string Uptime { get; set; } = string.Empty;
public int ProcessId { get; set; }
public long MemoryUsage { get; set; }
public DatabaseStats DbStats { get; set; } = new();
public async Task OnGetAsync()
{
ServerUrl = $"{Request.Scheme}://{Request.Host}";
Platform = Environment.OSVersion.ToString();
DotNetVersion = Environment.Version.ToString();
AspNetVersion = typeof(IApplicationBuilder).Assembly.GetName().Version?.ToString() ?? "Unknown";
var process = System.Diagnostics.Process.GetCurrentProcess();
var uptime = DateTime.UtcNow - process.StartTime.ToUniversalTime();
Uptime = $"{uptime.Days}d {uptime.Hours}h {uptime.Minutes}m {uptime.Seconds}s";
ProcessId = process.Id;
MemoryUsage = process.WorkingSet64 / 1024 / 1024; // Convert to MB
// Get database stats
DbStats = new DatabaseStats
{
Users = await _context.Users.CountAsync(),
Devices = await _context.Devices.CountAsync(),
Sessions = await _context.Sessions.CountAsync(),
Purchases = await _context.Purchases.CountAsync()
};
}
public async Task<IActionResult> OnPostResetDatabaseAsync()
{
// Delete all data
_context.Purchases.RemoveRange(_context.Purchases);
_context.Sessions.RemoveRange(_context.Sessions);
_context.Users.RemoveRange(_context.Users);
_context.Devices.RemoveRange(_context.Devices);
_context.CatalogItems.RemoveRange(_context.CatalogItems);
await _context.SaveChangesAsync();
// Re-seed catalog
await _context.Database.EnsureDeletedAsync();
await _context.Database.EnsureCreatedAsync();
return RedirectToPage();
}
}
public class DatabaseStats
{
public int Users { get; set; }
public int Devices { get; set; }
public int Sessions { get; set; }
public int Purchases { get; set; }
}

View File

@@ -0,0 +1,111 @@
@page
@model RR3CommunityServer.Pages.UsersModel
@{
ViewData["Title"] = "User Management";
}
<div class="container-fluid mt-4">
<div class="row mb-4">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center">
<div>
<h1>👥 User Management</h1>
<p class="text-muted">Manage all registered users</p>
</div>
<a href="/admin" class="btn btn-outline-secondary">← Back to Dashboard</a>
</div>
</div>
</div>
@if (Model.Users.Count == 0)
{
<div class="alert alert-info">
<i class="bi bi-info-circle"></i> No users registered yet. Users will appear here when players connect to your server.
</div>
}
else
{
<div class="card">
<div class="card-header bg-primary text-white">
<div class="d-flex justify-content-between align-items-center">
<h5 class="mb-0">All Users (@Model.Users.Count)</h5>
<form method="get" class="d-flex gap-2">
<input type="text" name="search" value="@Model.SearchQuery" class="form-control form-control-sm" placeholder="Search by Synergy ID...">
<button type="submit" class="btn btn-sm btn-light">Search</button>
@if (!string.IsNullOrEmpty(Model.SearchQuery))
{
<a href="/admin/users" class="btn btn-sm btn-outline-light">Clear</a>
}
</form>
</div>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>ID</th>
<th>Synergy ID</th>
<th>Device ID</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@foreach (var user in Model.Users)
{
<tr>
<td><strong>@user.Id</strong></td>
<td><code>@user.SynergyId</code></td>
<td><code>@user.DeviceId</code></td>
<td>@user.CreatedAt.ToString("g")</td>
<td>
<button class="btn btn-sm btn-info" data-bs-toggle="modal" data-bs-target="#userModal@(user.Id)">
<i class="bi bi-eye"></i> View
</button>
<form method="post" asp-page-handler="Delete" class="d-inline">
<input type="hidden" name="userId" value="@user.Id" />
<button type="submit" class="btn btn-sm btn-danger" onclick="return confirm('Delete this user?')">
<i class="bi bi-trash"></i> Delete
</button>
</form>
</td>
</tr>
<!-- User Details Modal -->
<div class="modal fade" id="userModal@(user.Id)" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header bg-primary text-white">
<h5 class="modal-title">User Details</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<dl class="row">
<dt class="col-sm-3">User ID:</dt>
<dd class="col-sm-9">@user.Id</dd>
<dt class="col-sm-3">Synergy ID:</dt>
<dd class="col-sm-9"><code>@user.SynergyId</code></dd>
<dt class="col-sm-3">Device ID:</dt>
<dd class="col-sm-9"><code>@user.DeviceId</code></dd>
<dt class="col-sm-3">Created:</dt>
<dd class="col-sm-9">@user.CreatedAt.ToString("F")</dd>
</dl>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
}
</tbody>
</table>
</div>
</div>
</div>
}
</div>

View File

@@ -0,0 +1,48 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.EntityFrameworkCore;
using RR3CommunityServer.Data;
using static RR3CommunityServer.Data.RR3DbContext;
namespace RR3CommunityServer.Pages;
public class UsersModel : PageModel
{
private readonly RR3DbContext _context;
public UsersModel(RR3DbContext context)
{
_context = context;
}
public List<User> Users { get; set; } = new();
public string? SearchQuery { get; set; }
public async Task OnGetAsync(string? search)
{
SearchQuery = search;
var query = _context.Users.AsQueryable();
if (!string.IsNullOrEmpty(search))
{
query = query.Where(u => u.SynergyId.Contains(search) || u.DeviceId.Contains(search));
}
Users = await query
.OrderByDescending(u => u.CreatedAt)
.ToListAsync();
}
public async Task<IActionResult> OnPostDeleteAsync(int userId)
{
var user = await _context.Users.FindAsync(userId);
if (user != null)
{
_context.Users.Remove(user);
await _context.SaveChangesAsync();
}
return RedirectToPage();
}
}

View File

@@ -0,0 +1,130 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>@ViewData["Title"] - RR3 Community Server</title>
<!-- Bootstrap 5 CSS -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css" rel="stylesheet">
<style>
:root {
--rr3-primary: #e63946;
--rr3-dark: #1d3557;
--rr3-light: #f1faee;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background-color: #f8f9fa;
}
.navbar {
background: linear-gradient(135deg, var(--rr3-dark) 0%, #2a4d7a 100%);
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.card {
border: none;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
border-radius: 12px;
transition: transform 0.2s;
}
.card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
}
.btn-primary {
background-color: var(--rr3-primary);
border-color: var(--rr3-primary);
}
.btn-primary:hover {
background-color: #d62839;
border-color: #d62839;
}
.table-hover tbody tr:hover {
background-color: rgba(230, 57, 70, 0.05);
}
code {
background-color: #f4f4f4;
padding: 2px 6px;
border-radius: 4px;
font-size: 0.9em;
}
</style>
</head>
<body>
<!-- Navigation -->
<nav class="navbar navbar-expand-lg navbar-dark mb-3">
<div class="container-fluid">
<a class="navbar-brand" href="/admin">
<span class="fs-3">🏎️</span>
<strong>RR3 Community Server</strong>
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav ms-auto">
<li class="nav-item">
<a class="nav-link" href="/admin">
<i class="bi bi-speedometer2"></i> Dashboard
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/admin/users">
<i class="bi bi-people"></i> Users
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/admin/catalog">
<i class="bi bi-shop"></i> Catalog
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/admin/sessions">
<i class="bi bi-clock-history"></i> Sessions
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/admin/purchases">
<i class="bi bi-cart"></i> Purchases
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/swagger" target="_blank">
<i class="bi bi-code-slash"></i> API
</a>
</li>
</ul>
</div>
</div>
</nav>
<!-- Main Content -->
<main>
@RenderBody()
</main>
<!-- Footer -->
<footer class="mt-5 py-4 bg-light">
<div class="container text-center text-muted">
<p class="mb-0">
<strong>RR3 Community Server</strong> - Open Source Game Server
</p>
<small>Made for game preservation and educational purposes</small>
</div>
</footer>
<!-- Bootstrap 5 JS -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
@await RenderSectionAsync("Scripts", required: false)
</body>
</html>

View File

@@ -0,0 +1,3 @@
@{
Layout = "_Layout";
}