Skip to content

Commit c429dfc

Browse files
committed
feat(core,desktop,web,platform): migrate metrics to Core, rewrite Desktop UI, build Web dashboard
- Move SystemMetricsService/ISystemMetricsService from Desktop → Trion.Core - Replace PerformanceCounter CPU sampling with native GetSystemTimes in WindowsMetricsProvider - Register Linux MySQL/Nginx-PHP services via PlatformServiceExtensions - Rewrite EmulatorAuthService to authenticate via Phoenix HTTP API instead of direct DB - Complete Desktop view/viewmodel rewrite: Settings, Dashboard, ServerControl, SinglePlayer - Add SparklineChart control, AvailablePackage and EmulatorProfile models - Replace Home.razor stub with full MudBlazor dashboard (infra status, CPU/RAM/disk/net) - Add Setup wizard pages, SetupGateMiddleware, ThemeSwitcher, and wwwroot assets to Trion.Web - Redesign Web layouts and Login page with MudBlazor - Add localization strings across EN/DE/RO - Update .gitignore: add servers/, Audit/, trion-db.json, webserver/
1 parent 9c4f614 commit c429dfc

111 files changed

Lines changed: 6718 additions & 1615 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,14 +78,24 @@ bower_components/
7878
# Runtime Artifacts (app-generated)
7979
# ================================
8080

81-
# TrionLogger output
81+
# TrionLogger / AuditLogger output
8282
Logs/
8383
logs/
84+
Audit/
8485

8586
# MySQL portable install + config (created by MySqlSetupService at runtime)
8687
database/
8788
my.ini
8889

90+
# Nginx + PHP portable install (created by NginxPhpSetupService at runtime)
91+
webserver/
92+
93+
# Emulator server installations (created by EmulatorInstallerService at runtime)
94+
servers/
95+
96+
# Runtime setup database config (written by SetupService at startup)
97+
trion-db.json
98+
8999
# ================================
90100
# Secrets & Local Config
91101
# ================================
@@ -96,6 +106,8 @@ my.ini
96106
secrets.json
97107
appsettings.Local.json
98108
appsettings.*.local.json
109+
appsettings.Production.json
110+
**/appsettings.Production.json
99111

100112
# ================================
101113
# SQLite / Database
@@ -147,3 +159,4 @@ issues_with_milestones.csv
147159
milestones_with_description.csv
148160
newplan.md
149161
plan.md
162+
/webserver

docs/api-webserver-integration.md

Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
# API — Web Server (nginx + PHP) Integration
2+
3+
This document explains exactly what the Trion client expects from the backend API
4+
when serving the nginx and PHP portable packages, and what you need to add to the
5+
Flying-Phoenix API to make the **Download & Install** button work end-to-end.
6+
7+
---
8+
9+
## How the client downloads packages
10+
11+
The client shares one generic download endpoint with MySQL, emulators, and all
12+
other packages. The package is identified by the `emulator` query parameter.
13+
14+
### Endpoint
15+
16+
```
17+
GET /Trion/DownloadFile?emulator=<package>
18+
```
19+
20+
The full URL is built in `ApiEndpoints.cs`:
21+
22+
```csharp
23+
// src/Trion.Core/Constants/ApiEndpoints.cs
24+
25+
public static string NginxDownloadUrl(string baseUrl) =>
26+
$"{baseUrl}{DownloadFile}?emulator=nginx";
27+
28+
public static string PhpDownloadUrl(string baseUrl) =>
29+
$"{baseUrl}{DownloadFile}?emulator=php";
30+
```
31+
32+
Both URLs are then surfaced through `AppLinks`:
33+
34+
```csharp
35+
// src/Trion.Desktop/Models/AppLinks.cs
36+
37+
public static string NginxApiDownloadUrl => ApiEndpoints.NginxDownloadUrl(ApiBaseUrl);
38+
public static string PhpApiDownloadUrl => ApiEndpoints.PhpDownloadUrl(ApiBaseUrl);
39+
```
40+
41+
### Request headers
42+
43+
| Header | Value | Required |
44+
|-------------|------------------------------------|----------|
45+
| `X-API-Key` | User's API key (supporter tier key) | No — omitted for guest/free tier |
46+
| `User-Agent` | `Mozilla/5.0 … TrionControlPanel/1.0` | Yes |
47+
48+
### Expected response
49+
50+
- **Status**: `200 OK`
51+
- **Body**: raw binary stream of the `.zip` file
52+
- **`Content-Length`**: strongly recommended — the client uses it to calculate
53+
download progress percentage and ETA
54+
- **`Content-Type`**: `application/zip` or `application/octet-stream`
55+
56+
The client writes the stream directly to a temp file and validates the ZIP magic
57+
bytes (`PK\x03\x04`) after download. Minimum accepted file size is 512 KB —
58+
smaller responses are treated as corrupt and retried.
59+
60+
### Fallback on API failure
61+
62+
If the API returns a non-2xx status or times out, the client falls back to the
63+
CDN URLs defined in `ExternalLinks.cs`:
64+
65+
```csharp
66+
// src/Trion.Core/Constants/ExternalLinks.cs
67+
68+
public const string NginxVersion = "1.28.2";
69+
public const string NginxCdnDownload = "https://nginx.org/download/nginx-1.28.2.zip";
70+
71+
public const string PhpVersion = "8.3.20";
72+
public const string PhpCdnDownload =
73+
"https://windows.php.net/downloads/releases/php-8.3.20-nts-Win32-vs16-x64.zip";
74+
```
75+
76+
Update both the version constant **and** the CDN URL together whenever you
77+
upgrade a package.
78+
79+
---
80+
81+
## What the API must serve
82+
83+
### `emulator=nginx`
84+
85+
Serve the **Windows portable nginx** zip. The client extracts it into
86+
`<appdir>/webserver/nginx/` and expects to find `nginx.exe` directly inside
87+
that folder (one level of nesting is automatically unwrapped during extraction).
88+
89+
| Field | Value |
90+
|-----------------|-------|
91+
| Current version | `1.28.2` |
92+
| Official source | `https://nginx.org/download/nginx-1.28.2.zip` |
93+
| Expected root | `nginx-1.28.2\` (single top-level folder inside the zip) |
94+
| Key binary | `nginx.exe` |
95+
96+
The API can either:
97+
- Re-serve the upstream zip as-is (proxy/mirror), or
98+
- Host a pre-downloaded copy on the Flying-Phoenix CDN
99+
100+
### `emulator=php`
101+
102+
Serve the **Windows PHP NTS x64** zip.
103+
104+
> **NTS (Non-Thread-Safe) is required.** The TS (Thread-Safe) build does not
105+
> work with php-cgi.exe in FastCGI mode.
106+
107+
| Field | Value |
108+
|-----------------|-------|
109+
| Current version | `8.3.20` |
110+
| Build variant | NTS, Win32, VS16, x64 |
111+
| Official source | `https://windows.php.net/downloads/releases/php-8.3.20-nts-Win32-vs16-x64.zip` |
112+
| Expected root | `php-8.3.20-nts-Win32-vs16-x64\` (single top-level folder) |
113+
| Key binary | `php-cgi.exe` |
114+
115+
---
116+
117+
## Adding the handler to the API (example)
118+
119+
The existing `/Trion/DownloadFile` endpoint likely already handles `emulator=mysql`.
120+
Add the two new cases to the same switch/map:
121+
122+
```csharp
123+
// Example — adapt to your actual API framework
124+
125+
app.MapGet("/Trion/DownloadFile", async (string emulator, HttpContext ctx) =>
126+
{
127+
var (fileName, filePath) = emulator.ToLowerInvariant() switch
128+
{
129+
"mysql" => ("mysql-8.4.8-winx64.zip", _config["Packages:MySqlPath"]),
130+
"nginx" => ("nginx-1.28.2.zip", _config["Packages:NginxPath"]),
131+
"php" => ("php-8.3.20-nts-Win32-vs16-x64.zip", _config["Packages:PhpPath"]),
132+
_ => throw new BadHttpRequestException("Unknown package")
133+
};
134+
135+
ctx.Response.ContentType = "application/zip";
136+
ctx.Response.Headers["Content-Disposition"] = $"attachment; filename=\"{fileName}\"";
137+
await ctx.Response.SendFileAsync(filePath);
138+
});
139+
```
140+
141+
If you proxy the upstream URL instead of hosting locally:
142+
143+
```csharp
144+
"nginx" => await ProxyDownloadAsync(ctx, "https://nginx.org/download/nginx-1.28.2.zip"),
145+
"php" => await ProxyDownloadAsync(ctx, "https://windows.php.net/downloads/releases/php-8.3.20-nts-Win32-vs16-x64.zip"),
146+
```
147+
148+
---
149+
150+
## Upgrading a package version
151+
152+
When you want to ship a newer nginx or PHP version:
153+
154+
1. Update the version constants and CDN URLs in the client:
155+
156+
```csharp
157+
// src/Trion.Core/Constants/ExternalLinks.cs
158+
public const string NginxVersion = "1.29.0"; // ← new version
159+
public const string NginxCdnDownload = "https://nginx.org/download/nginx-1.29.0.zip";
160+
161+
public const string PhpVersion = "8.3.21";
162+
public const string PhpCdnDownload = "https://windows.php.net/downloads/releases/php-8.3.21-nts-Win32-vs16-x64.zip";
163+
```
164+
165+
2. Update the API to serve the new zip under the same `emulator=nginx` /
166+
`emulator=php` keys.
167+
168+
3. The client will detect the new zip at next install or reinstall — existing
169+
installations are unaffected until the user clicks **Reinstall**.
170+
171+
---
172+
173+
## Supporter tier vs. free tier
174+
175+
The client sends `X-API-Key` when the user has an API key stored
176+
(`AppSettings.AccountApiKey`). The API can use this to:
177+
178+
- Serve packages from a faster CDN for paying supporters
179+
- Apply rate-limiting on the free tier
180+
- Log download statistics per user
181+
182+
If no key is present (guest / free user), the header is omitted. The API
183+
**must still serve the zip** to keyless requests — the header is optional, not
184+
required for authentication.
185+
186+
---
187+
188+
## Client download flow (for reference)
189+
190+
```
191+
InstallWebServerCommand (Settings → Web Server → Download & Install)
192+
└─ RunStartupCheckAsync()
193+
├─ GET /Trion/DownloadFile?emulator=nginx (X-API-Key if available)
194+
│ ├─ 200 OK → stream to temp zip → extract to webserver/nginx/
195+
│ └─ non-200 → fallback: GET https://nginx.org/download/nginx-1.28.2.zip
196+
197+
├─ GET /Trion/DownloadFile?emulator=php (X-API-Key if available)
198+
│ ├─ 200 OK → stream to temp zip → extract to webserver/php/
199+
│ └─ non-200 → fallback: GET https://windows.php.net/…
200+
201+
├─ GenerateNginxConf() → webserver/nginx/conf/nginx.conf
202+
├─ GeneratePhpIni() → webserver/php/php.ini
203+
├─ GenerateSelfSignedCert() (if HTTPS enabled)
204+
├─ AppSettings.WebServerInstalled = true
205+
└─ StartAsync() → php-cgi.exe -b 127.0.0.1:9000 + nginx.exe
206+
```
207+
208+
Progress text (including download speed and ETA) is streamed back through
209+
`INginxPhpSetupService.ProgressChanged` and displayed in the settings panel's
210+
progress bar while `IsWebServerBusy` is `true`.

res/icons/trion-logo/trion.ico

16.4 KB
Binary file not shown.

src/Trion.API/Endpoints/AdminEndpoints.cs

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ public static class AdminEndpoints
1111
{
1212
public static WebApplication MapAdminEndpoints(this WebApplication app)
1313
{
14-
var g = app.MapGroup("/Trion/admin").WithTags("Admin");
14+
var g = app.MapGroup("/Trion/admin").WithTags("Admin").RequireAuthorization("AdminPolicy");
1515

1616
g.MapPost("supporter-key", CreateSupporterKey);
1717
g.MapPost("upload-emulator", UploadEmulator);
@@ -111,7 +111,15 @@ await Task.Run(() =>
111111
foreach (var entry in archive.Entries)
112112
{
113113
ct.ThrowIfCancellationRequested();
114-
var target = Path.GetFullPath(Path.Combine(filesDir, entry.FullName));
114+
115+
// Reject symlinks (Unix mode 0xA000 in high 16 bits of ExternalAttributes)
116+
if ((entry.ExternalAttributes >> 16 & 0xF000) == 0xA000) continue;
117+
118+
// Validate the relative path BEFORE combining to prevent Zip Slip
119+
var relative = entry.FullName.Replace('\\', '/').TrimStart('/');
120+
if (relative.Contains("..") || Path.IsPathRooted(relative)) continue;
121+
122+
var target = Path.GetFullPath(Path.Combine(filesDir, relative));
115123
if (!target.StartsWith(baseDir, StringComparison.OrdinalIgnoreCase)) continue;
116124

117125
if (string.IsNullOrEmpty(entry.Name)) { Directory.CreateDirectory(target); continue; }
@@ -145,7 +153,10 @@ await Task.Run(() =>
145153
private static bool IsAdminKey(string? key, IConfiguration cfg)
146154
{
147155
var configured = cfg["AdminApiKey"];
148-
return !string.IsNullOrEmpty(key) && key == configured;
156+
if (string.IsNullOrEmpty(key) || string.IsNullOrEmpty(configured)) return false;
157+
var a = System.Text.Encoding.UTF8.GetBytes(key);
158+
var b = System.Text.Encoding.UTF8.GetBytes(configured);
159+
return CryptographicOperations.FixedTimeEquals(a, b);
149160
}
150161

151162
private static string NormalizeEmulatorName(string emulator) =>
@@ -177,7 +188,7 @@ await Task.WhenAll(files.Select(async f =>
177188
var info = new FileInfo(f);
178189
var relative = Path.GetRelativePath(filesPath, f).Replace('\\', '/');
179190
var dir = Path.GetDirectoryName(relative)?.Replace('\\', '/') ?? "";
180-
var hash = await ComputeMd5Async(f, ct);
191+
var hash = await ComputeSha256Async(f, ct);
181192

182193
entries.Add(new
183194
{
@@ -194,15 +205,13 @@ await Task.WhenAll(files.Select(async f =>
194205
return JsonSerializer.Serialize(new { files = entries.ToList() });
195206
}
196207

197-
#pragma warning disable CA5351 // MD5 is used for file integrity, not security
198-
private static async Task<string> ComputeMd5Async(string path, CancellationToken ct)
208+
private static async Task<string> ComputeSha256Async(string path, CancellationToken ct)
199209
{
200210
await using var stream = new FileStream(
201211
path, FileMode.Open, FileAccess.Read, FileShare.Read, 4096, useAsync: true);
202-
using var md5 = MD5.Create();
203-
return BitConverter.ToString(await md5.ComputeHashAsync(stream, ct)).Replace("-", "").ToUpperInvariant();
212+
using var sha = SHA256.Create();
213+
return Convert.ToHexString(await sha.ComputeHashAsync(stream, ct));
204214
}
205-
#pragma warning restore CA5351
206215

207216
private static int CountFiles(string manifestJson) =>
208217
manifestJson.Split("\"name\"").Length - 1;

src/Trion.API/Endpoints/PackageEndpoints.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,8 @@ public static WebApplication MapPackageEndpoints(this WebApplication app)
1515
g.MapGet("Ping", Ping);
1616
g.MapGet("GetExternalIPv4", GetExternalIPv4);
1717
g.MapGet("GetFileVersion", GetFileVersion);
18-
g.MapGet("DownloadFile", DownloadFileGet).RequireRateLimiting("download");
19-
g.MapPost("DownloadFile", DownloadFilePost).RequireRateLimiting("download");
18+
g.MapGet("DownloadFile", DownloadFileGet).RequireRateLimiting("download").RequireRateLimiting("downloads-ip");
19+
g.MapPost("DownloadFile", DownloadFilePost).RequireRateLimiting("download").RequireRateLimiting("downloads-ip");
2020
g.MapPost("RepairSPP", RepairSpp);
2121
g.MapGet("DownloadSpeedTest", DownloadSpeedTest);
2222

src/Trion.API/Program.cs

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,8 @@
4040
};
4141
});
4242

43-
builder.Services.AddAuthorization();
43+
builder.Services.AddAuthorization(o =>
44+
o.AddPolicy("AdminPolicy", p => p.RequireRole("admin")));
4445

4546
// ── Rate limiting ─────────────────────────────────────────────────────────────
4647
builder.Services.AddRateLimiter(o =>
@@ -56,12 +57,21 @@
5657
opt.PermitLimit = 5;
5758
opt.QueueLimit = 2;
5859
});
60+
o.AddFixedWindowLimiter("downloads-ip", opt =>
61+
{
62+
opt.PermitLimit = 10;
63+
opt.Window = TimeSpan.FromMinutes(1);
64+
opt.QueueLimit = 0;
65+
});
5966
o.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
6067
});
6168

6269
// ── CORS ──────────────────────────────────────────────────────────────────────
6370
builder.Services.AddCors(o =>
64-
o.AddDefaultPolicy(p => p.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader()));
71+
o.AddDefaultPolicy(p => p
72+
.WithOrigins("https://flying-phoenix.dev", "https://www.flying-phoenix.dev")
73+
.AllowAnyMethod()
74+
.AllowAnyHeader()));
6575

6676
// ── Caching ───────────────────────────────────────────────────────────────────
6777
builder.Services.AddMemoryCache();
@@ -70,8 +80,8 @@
7080
// ── Allow large multipart uploads (admin upload-emulator endpoint) ─────────────
7181
builder.Services.Configure<Microsoft.AspNetCore.Http.Features.FormOptions>(o =>
7282
{
73-
o.MultipartBodyLengthLimit = long.MaxValue;
74-
o.ValueLengthLimit = int.MaxValue;
83+
o.MultipartBodyLengthLimit = 10L * 1024 * 1024 * 1024; // 10 GB — enough for any emulator package
84+
o.ValueLengthLimit = 128 * 1024; // 128 KB for form fields
7585
});
7686

7787
// ── OpenAPI / Swagger ─────────────────────────────────────────────────────────
@@ -103,6 +113,14 @@
103113
builder.Services.AddScoped<IUserService, UserService>();
104114
builder.Services.AddScoped<INewsService, NewsService>();
105115

116+
// ── Startup validation: reject placeholder secrets ────────────────────────────
117+
var adminKey = builder.Configuration["AdminApiKey"] ?? "";
118+
var secretKey = builder.Configuration["Jwt:SecretKey"] ?? "";
119+
if (adminKey.StartsWith("change-this") || secretKey.StartsWith("change-this"))
120+
throw new InvalidOperationException(
121+
"Default placeholder secrets detected. Set real values via environment variables " +
122+
"(TRION_AdminApiKey, TRION_Jwt__SecretKey) before running in production.");
123+
106124
// ── Build ─────────────────────────────────────────────────────────────────────
107125
var app = builder.Build();
108126

0 commit comments

Comments
 (0)