Made Front-end use Razor instead of static html

This commit is contained in:
Exil Productions
2025-09-27 09:10:16 +02:00
parent c41d30a961
commit cded48aaff
15 changed files with 379 additions and 275 deletions

32
.dockerignore Normal file
View File

@@ -0,0 +1,32 @@
# Include any files or directories that you don't want to be copied to your
# container here (e.g., local build artifacts, temporary files, etc.).
#
# For more help, visit the .dockerignore file reference guide at
# https://docs.docker.com/go/build-context-dockerignore/
**/.DS_Store
**/.classpath
**/.dockerignore
**/.env
**/.git
**/.gitignore
**/.project
**/.settings
**/.toolstarget
**/.vs
**/.vscode
**/*.*proj.user
**/*.dbmdl
**/*.jfm
**/bin
**/charts
**/docker-compose*
**/compose.y*ml
**/Dockerfile*
**/node_modules
**/npm-debug.log
**/obj
**/secrets.dev.yaml
**/values.dev.yaml
LICENSE
README.md

2
.gitignore vendored
View File

@@ -362,4 +362,4 @@ MigrationBackup/
# Fody - auto-generated XML schema
FodyWeavers.xsd
appsettings.production.json
Certs

56
Dockerfile Normal file
View File

@@ -0,0 +1,56 @@
# syntax=docker/dockerfile:1
# Comments are provided throughout this file to help you get started.
# If you need more help, visit the Dockerfile reference guide at
# https://docs.docker.com/go/dockerfile-reference/
# Want to help us make this template better? Share your feedback here: https://forms.gle/ybq9Krt8jtBL3iCk7
################################################################################
# Learn about building .NET container images:
# https://github.com/dotnet/dotnet-docker/blob/main/samples/README.md
# Create a stage for building the application.
FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:8.0-alpine AS build
COPY . /source
WORKDIR /source/PurrLobby
# This is the architecture youre building for, which is passed in by the builder.
# Placing it here allows the previous steps to be cached across architectures.
ARG TARGETARCH
# Build the application.
# Leverage a cache mount to /root/.nuget/packages so that subsequent builds don't have to re-download packages.
# If TARGETARCH is "amd64", replace it with "x64" - "x64" is .NET's canonical name for this and "amd64" doesn't
# work in .NET 6.0.
RUN --mount=type=cache,id=nuget,target=/root/.nuget/packages \
dotnet publish -a ${TARGETARCH/amd64/x64} --use-current-runtime --self-contained false -o /app
# If you need to enable globalization and time zones:
# https://github.com/dotnet/dotnet-docker/blob/main/samples/enable-globalization.md
################################################################################
# Create a new stage for running the application that contains the minimal
# runtime dependencies for the application. This often uses a different base
# image from the build stage where the necessary files are copied from the build
# stage.
#
# The example below uses an aspnet alpine image as the foundation for running the app.
# It will also use whatever happens to be the most recent version of that tag when you
# build your Dockerfile. If reproducibility is important, consider using a more specific
# version (e.g., aspnet:7.0.10-alpine-3.18),
# or SHA (e.g., mcr.microsoft.com/dotnet/aspnet@sha256:f3d99f54d504a21d38e4cc2f13ff47d67235efeeb85c109d3d1ff1808b38d034).
FROM mcr.microsoft.com/dotnet/aspnet:8.0-alpine AS final
WORKDIR /app
# Copy everything needed to run the app from the "build" stage.
COPY --from=build /app .
# Switch to a non-privileged user (defined in the base image) that the app will run under.
# See https://docs.docker.com/go/dockerfile-user-best-practices/
# and https://github.com/dotnet/dotnet-docker/discussions/4764
USER $APP_UID
ENTRYPOINT ["dotnet", "PurrLobby.dll"]

View File

@@ -5,6 +5,7 @@ public class LobbyUser
{
public required string Id { get; init; }
public required string DisplayName { get; init; }
public int userPing { get; set; }
public bool IsReady { get; set; }
}

View File

@@ -0,0 +1,90 @@
@page
@model PurrLobby.Pages.IndexModel
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>PurrLobby</title>
<link rel="icon" href="/favicon.ico" type="image/x-icon" />
<style>
:root {
--bg: #000;
--text: #fff;
--muted: #aaa;
--border: #333;
--accent: #2aa9ff;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body { background: var(--bg); color: var(--text); font-family: monospace, sans-serif; line-height: 1.5; }
.container { max-width: 800px; margin: 20px auto; padding: 0 12px; }
.hero { display: flex; align-items: center; gap: 12px; margin-bottom: 20px; }
.logo { width: 64px; height: 64px; object-fit: contain; }
.title { font-size: 32px; }
.section-title { font-size: 14px; margin: 20px 0 8px; text-transform: uppercase; }
.center { text-align: center; }
.bigstats { display: flex; gap: 20px; justify-content: center; flex-wrap: wrap; margin-bottom: 20px; }
.bigstat { min-width: 120px; display: flex; flex-direction: column; align-items: center; }
.bignum { font-size: 32px; font-weight: bold; text-align: center; }
.biglabel { font-size: 12px; color: var(--muted); text-align: center; }
.stack { display: grid; gap: 8px; margin-bottom: 20px; }
#gameIdInput { padding: 8px; border: 1px solid var(--border); background: var(--bg); color: var(--text); }
button { padding: 8px; border: 1px solid var(--border); background: #111; color: var(--text); cursor: pointer; }
button:hover { background: #222; }
.disclaimer { font-size: 13px; color: var(--muted); margin-top: 20px; }
a { color: var(--accent); }
</style>
</head>
<body>
<div class="container">
<header class="hero">
<img class="logo" src="/purrlogo.png" alt="PurrLobby" />
<h1 class="title">PurrLobby</h1>
</header>
<h2 class="section-title center">Global Stats</h2>
<div class="bigstats">
<div class="bigstat">
<div class="bignum">@Model.GlobalPlayers</div>
<div class="biglabel">Players</div>
</div>
<div class="bigstat">
<div class="bignum">@Model.GlobalLobbies</div>
<div class="biglabel">Lobbies</div>
</div>
</div>
<h2 class="section-title">Track Your Game</h2>
<form method="post" class="stack">
<input id="gameIdInput" asp-for="GameIdInput" type="text" placeholder="Enter Game ID (GUID)" required />
<button type="submit">Set Game</button>
</form>
<small id="cookieStatus" class="muted">@Model.CookieStatus</small>
<h2 class="section-title center">Game Stats</h2>
<div class="bigstats">
<div class="bigstat">
<div class="bignum">@Model.GamePlayers</div>
<div class="biglabel">Players</div>
</div>
<div class="bigstat">
<div class="bignum">@Model.GameLobbies</div>
<div class="biglabel">Lobbies</div>
</div>
</div>
<div class="center" style="margin: 20px 0;">
<a href="/swagger" class="btn" target="_blank">Swagger API Docs</a>
</div>
<p class="disclaimer">
This Service does not (yet) belong to purrnet, it's just something I put together for an alternative to the Steam Lobby Service and Unity Service.
Since it's running on my own setup, it might go down or act weird sometimes.
If that happens, no worries, it'll probably be back soon.
Please don't contact the actual PurrNet devs about this, it's all on me.
If there are any problems contact me on discord: exil_s
</p>
</div>
</body>
</html>

View File

@@ -0,0 +1,91 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using System.Net.Http;
using System.Text.Json;
using System.Threading.Tasks;
namespace PurrLobby.Pages
{
public class IndexModel : PageModel
{
[BindProperty]
public string GameIdInput { get; set; } = string.Empty;
public string CookieStatus { get; set; } = string.Empty;
public int GlobalPlayers { get; set; } = 0;
public int GlobalLobbies { get; set; } = 0;
public int GamePlayers { get; set; } = 0;
public int GameLobbies { get; set; } = 0;
public async Task OnGetAsync()
{
await LoadStatsAsync();
}
public async Task<IActionResult> OnPostAsync()
{
if (!Guid.TryParse(GameIdInput, out var gameId))
{
CookieStatus = "Invalid Game ID format.";
await LoadStatsAsync();
return Page();
}
// Set gameId cookie via backend API
try
{
using var client = new HttpClient();
var response = await client.PostAsync($"{Request.Scheme}://{Request.Host}/session/game", new StringContent(JsonSerializer.Serialize(new { GameId = gameId }), System.Text.Encoding.UTF8, "application/json"));
if (response.IsSuccessStatusCode)
{
CookieStatus = "Game ID set!";
}
else
{
CookieStatus = "Failed to set Game ID.";
}
}
catch
{
CookieStatus = "Error contacting backend.";
}
await LoadStatsAsync(gameId);
return Page();
}
private async Task LoadStatsAsync(Guid? gameId = null)
{
using var client = new HttpClient();
try
{
var globalPlayersResp = await client.GetStringAsync($"{Request.Scheme}://{Request.Host}/stats/global/players");
GlobalPlayers = int.TryParse(globalPlayersResp, out var gp) ? gp : 0;
}
catch { GlobalPlayers = 0; }
try
{
var globalLobbiesResp = await client.GetStringAsync($"{Request.Scheme}://{Request.Host}/stats/global/lobbies");
GlobalLobbies = int.TryParse(globalLobbiesResp, out var gl) ? gl : 0;
}
catch { GlobalLobbies = 0; }
if (gameId.HasValue)
{
try
{
var gamePlayersResp = await client.GetStringAsync($"{Request.Scheme}://{Request.Host}/stats/{gameId}/players");
GamePlayers = int.TryParse(gamePlayersResp, out var gp) ? gp : 0;
}
catch { GamePlayers = 0; }
try
{
var gameLobbiesResp = await client.GetStringAsync($"{Request.Scheme}://{Request.Host}/stats/{gameId}/lobbies");
GameLobbies = int.TryParse(gameLobbiesResp, out var gl) ? gl : 0;
}
catch { GameLobbies = 0; }
}
else
{
GamePlayers = 0;
GameLobbies = 0;
}
}
}
}

View File

@@ -86,6 +86,7 @@ builder.Services.AddSwaggerGen(o =>
});
builder.Services.AddSingleton<ILobbyEventHub, LobbyEventHub>();
builder.Services.AddSingleton<ILobbyService, LobbyService>();
builder.Services.AddRazorPages(); // Register Razor Pages services
var app = builder.Build();
if (!app.Environment.IsDevelopment())
{
@@ -102,10 +103,10 @@ app.UseResponseCompression();
app.UseHttpLogging();
app.UseRateLimiter();
app.UseWebSockets();
app.UseDefaultFiles();
app.UseStaticFiles();
app.UseSwagger();
app.UseSwaggerUI();
app.MapRazorPages(); // Enable Razor Pages
static CookieOptions BuildStdCookieOptions() => new()
{
HttpOnly = true,
@@ -506,4 +507,4 @@ public record CreateLobbyRequest(string OwnerDisplayName, int MaxPlayers, Dictio
public record JoinLobbyRequest(string DisplayName);
public record ReadyRequest(string UserId, bool IsReady);
public record AllReadyRequest(string UserId);
public record LobbyDataRequest(string Key, string Value);
public record LobbyDataRequest(string Key, string Value);

View File

@@ -26,4 +26,14 @@
</Content>
</ItemGroup>
<!-- Copy certificates to output directory -->
<ItemGroup>
<Content Include="Certs\*.pem">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Include="Certs\*.key">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
</Project>

View File

@@ -1,14 +0,0 @@
{
"Kestrel": {
"Endpoints": {
"Https": {
"Url": "https://0.0.0.0:443",
"Certificate": {
"Path": "pemfile",
"KeyPath": "keyfile"
}
}
}
},
"AllowedHosts": "your.domain.com"
}

View File

@@ -0,0 +1,20 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "purrlobby.exil.dev",
"Kestrel": {
"Endpoints": {
"Https": {
"Url": "https://0.0.0.0:443",
"Certificate": {
"Path": "Certs/purrlobby.pem",
"KeyPath": "Certs/purrlobby.key"
}
}
}
}
}

View File

@@ -1,63 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>PurrLobby</title>
<link rel="icon" href="/favicon.ico" type="image/x-icon" />
<link rel="stylesheet" href="/styles.css?v=5" />
</head>
<body>
<div class="container">
<header class="hero">
<img class="logo" src="/purrlogo.png" alt="PurrLobby" />
<h1 class="title">PurrLobby</h1>
</header>
<h2 class="section-title center">Global Stats</h2>
<div class="bigstats">
<div class="bigstat">
<div class="bignum" id="globalPlayers">-</div>
<div class="biglabel">Players</div>
</div>
<div class="bigstat">
<div class="bignum" id="globalLobbies">-</div>
<div class="biglabel">Lobbies</div>
</div>
</div>
<h2 class="section-title">Track Your Game</h2>
<form id="gameForm" class="stack">
<input id="gameIdInput" type="text" placeholder="Enter Game ID (GUID)" required />
<button type="submit">Set Game</button>
</form>
<small id="cookieStatus" class="muted"></small>
<h2 class="section-title center">Game Stats</h2>
<div class="bigstats">
<div class="bigstat">
<div class="bignum" id="gamePlayers">-</div>
<div class="biglabel">Players</div>
</div>
<div class="bigstat">
<div class="bignum" id="gameLobbies">-</div>
<div class="biglabel">Lobbies</div>
</div>
</div>
<div class="center" style="margin: 20px 0;">
<a href="/swagger" class="btn" target="_blank">Swagger API Docs</a>
</div>
<p class="disclaimer">
This Service is does not (yet) belong to purrnet, it's just something I put together for a alternative to the Steam Lobby Service and Unity Service.
Since it's running on my own setup, it might go down or act weird sometimes.
If that happens, no worries, it'll probably be back soon.
Please don't contact the actual PurrNet devs about this, it's all on me.
If there are any proplem's contact me on discord: exil_s
</p>
</div>
<script src="/main.js" type="module"></script>
</body>
</html>

View File

@@ -1,80 +0,0 @@
async function fetchJson(url) {
const r = await fetch(url, { credentials: 'include' });
if (!r.ok) throw new Error(`Request failed: ${r.status}`);
return r.json();
}
async function loadGlobal() {
try {
const [players, lobbies] = await Promise.all([
fetchJson('/stats/global/players'),
fetchJson('/stats/global/lobbies')
]);
document.getElementById('globalPlayers').textContent = players;
document.getElementById('globalLobbies').textContent = lobbies;
} catch (e) {
console.error(e);
}
}
async function loadGame(gameId) {
try {
const [lobbies, players] = await Promise.all([
fetchJson(`/stats/${gameId}/lobbies`),
fetchJson(`/stats/${gameId}/players`)
]);
document.getElementById('gameLobbies').textContent = lobbies;
document.getElementById('gamePlayers').textContent = players.length ?? players; // endpoint returns list
} catch (e) {
console.error(e);
}
}
async function setGameCookie(gameId) {
const r = await fetch('/session/game', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ gameId }),
credentials: 'include'
});
if (!r.ok) throw new Error('Failed to set game cookie');
return r.json();
}
function getCookie(name) {
const m = document.cookie.match(new RegExp('(^| )' + name + '=([^;]+)'));
return m ? decodeURIComponent(m[2]) : null;
}
(async () => {
await loadGlobal();
const gameForm = document.getElementById('gameForm');
const gameIdInput = document.getElementById('gameIdInput');
const cookieStatus = document.getElementById('cookieStatus');
//if cookie exists prefill and load stats
const existing = getCookie('gameId');
if (existing) {
gameIdInput.value = existing;
loadGame(existing);
cookieStatus.textContent = `Using gameId from cookie.`;
}
gameForm.addEventListener('submit', async (e) => {
e.preventDefault();
const gameId = gameIdInput.value.trim();
if (!/^\{?[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}\}?$/.test(gameId)) {
alert('Please enter a valid GUID');
return;
}
try {
await setGameCookie(gameId);
cookieStatus.textContent = 'GameId stored in cookie.';
await loadGame(gameId);
} catch (err) {
cookieStatus.textContent = 'Failed to set cookie';
console.error(err);
}
});
})();

View File

@@ -1,115 +0,0 @@
:root {
--bg: #000;
--text: #fff;
--muted: #aaa;
--border: #333;
--accent: #2aa9ff;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
background: var(--bg);
color: var(--text);
font-family: monospace, sans-serif;
line-height: 1.5;
}
.container {
max-width: 800px;
margin: 20px auto;
padding: 0 12px;
}
.hero {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 20px;
}
.logo {
width: 64px;
height: 64px;
object-fit: contain;
}
.title {
font-size: 32px;
}
.section-title {
font-size: 14px;
margin: 20px 0 8px;
text-transform: uppercase;
}
.center {
text-align: center;
}
.bigstats {
display: flex;
gap: 20px;
justify-content: center;
flex-wrap: wrap;
margin-bottom: 20px;
}
.bigstat {
min-width: 120px;
display: flex;
flex-direction: column;
align-items: center;
}
.bignum {
font-size: 32px;
font-weight: bold;
text-align: center;
}
.biglabel {
font-size: 12px;
color: var(--muted);
text-align: center;
}
.stack {
display: grid;
gap: 8px;
margin-bottom: 20px;
}
#gameIdInput {
padding: 8px;
border: 1px solid var(--border);
background: var(--bg);
color: var(--text);
}
button {
padding: 8px;
border: 1px solid var(--border);
background: #111;
color: var(--text);
cursor: pointer;
}
button:hover {
background: #222;
}
.disclaimer {
font-size: 13px;
color: var(--muted);
margin-top: 20px;
}
a {
color: var(--accent);
}

24
README.Docker.md Normal file
View File

@@ -0,0 +1,24 @@
### Building and running your application
When you're ready, start your application by running:
`docker compose up --build`.
Your application will be available at http://localhost:443.
### Deploying your application to the cloud
First, build your image, e.g.: `docker build -t myapp .`.
If your cloud uses a different CPU architecture than your development
machine (e.g., you are on a Mac M1 and your cloud provider is amd64),
you'll want to build the image for that platform, e.g.:
`docker build --platform=linux/amd64 -t myapp .`.
Then, push it to your registry, e.g. `docker push myregistry.com/myapp`.
Consult Docker's [getting started](https://docs.docker.com/go/get-started-sharing/)
docs for more detail on building and pushing.
### References
* [Docker's .NET guide](https://docs.docker.com/language/dotnet/)
* The [dotnet-docker](https://github.com/dotnet/dotnet-docker/tree/main/samples)
repository has many relevant samples and docs.

51
compose.yaml Normal file
View File

@@ -0,0 +1,51 @@
# Comments are provided throughout this file to help you get started.
# If you need more help, visit the Docker Compose reference guide at
# https://docs.docker.com/go/compose-spec-reference/
# Here the instructions define your application as a service called "server".
# This service is built from the Dockerfile in the current directory.
# You can add other services your application may depend on here, such as a
# database or a cache. For examples, see the Awesome Compose repository:
# https://github.com/docker/awesome-compose
services:
server:
build:
context: .
target: final
ports:
- 443:8080
network_mode: "host"
# The commented out section below is an example of how to define a PostgreSQL
# database that your application can use. `depends_on` tells Docker Compose to
# start the database before your application. The `db-data` volume persists the
# database data between container restarts. The `db-password` secret is used
# to set the database password. You must create `db/password.txt` and add
# a password of your choosing to it before running `docker compose up`.
# depends_on:
# db:
# condition: service_healthy
# db:
# image: postgres
# restart: always
# user: postgres
# secrets:
# - db-password
# volumes:
# - db-data:/var/lib/postgresql/data
# environment:
# - POSTGRES_DB=example
# - POSTGRES_PASSWORD_FILE=/run/secrets/db-password
# expose:
# - 5432
# healthcheck:
# test: [ "CMD", "pg_isready" ]
# interval: 10s
# timeout: 5s
# retries: 5
# volumes:
# db-data:
# secrets:
# db-password:
# file: db/password.txt