From 44f53e80b8d451ce1054f452b0de03cb901c0414 Mon Sep 17 00:00:00 2001 From: Alexa <53471801+Alexa-RR@users.noreply.github.com> Date: Mon, 15 Jun 2026 20:21:05 +0300 Subject: [PATCH] Split remaining service routes --- .gitignore | 2 +- Dockerfile.service | 2 +- DorkNet.Contracts/DorkNetRouteOwnership.cs | 298 +++++++++++++ DorkNet.Contracts/DorkNetServiceMapOptions.cs | 14 +- DorkNet.Contracts/ServiceNames.cs | 6 + DorkNet.Gateway/DorkNet.Gateway.csproj | 4 + DorkNet.Gateway/Program.cs | 230 ++++++++++ DorkNet.Gateway/appsettings.json | 8 +- .../DorkNet.Server.Tests.csproj | 26 ++ DorkNet.Server.Tests/DorkNetServerFactory.cs | 90 ++++ .../EndpointContractDiscovery.cs | 222 ++++++++++ .../GameClientEndpointContractTests.cs | 411 ++++++++++++++++++ DorkNet.Server.Tests/GameClientSession.cs | 65 +++ .../API/Account/AccountApiController.cs | 8 +- .../API/Pageview/PageviewController.cs | 4 +- .../Controllers/Auth/AuthAccountController.cs | 8 +- .../Controllers/Clubs/ClubsController.cs | 7 +- .../Match/MatchPlayerController.cs | 9 +- DorkNet.Server/Program.cs | 2 + .../DorkNetServiceDefaultsExtensions.cs | 50 ++- .../DorkNet.Services.Commerce.csproj | 15 + DorkNet.Services.Commerce/Program.cs | 37 ++ DorkNet.Services.Commerce/appsettings.json | 8 + .../DorkNet.Services.Content.csproj | 15 + DorkNet.Services.Content/Program.cs | 37 ++ DorkNet.Services.Content/appsettings.json | 8 + .../DorkNet.Services.Identity.csproj | 1 + DorkNet.Services.Identity/Program.cs | 8 +- .../DorkNet.Services.Moderation.csproj | 15 + DorkNet.Services.Moderation/Program.cs | 37 ++ DorkNet.Services.Moderation/appsettings.json | 8 + .../DorkNet.Services.Notify.csproj | 1 + DorkNet.Services.Notify/Program.cs | 8 +- .../DorkNet.Services.Platform.csproj | 15 + DorkNet.Services.Platform/Program.cs | 37 ++ DorkNet.Services.Platform/appsettings.json | 8 + .../DorkNet.Services.Rooms.csproj | 1 + DorkNet.Services.Rooms/Program.cs | 8 +- .../DorkNet.Services.Social.csproj | 15 + DorkNet.Services.Social/Program.cs | 37 ++ DorkNet.Services.Social/appsettings.json | 8 + .../DorkNet.Services.Web.csproj | 15 + DorkNet.Services.Web/Program.cs | 36 ++ DorkNet.Services.Web/appsettings.json | 8 + DorkNet.sln | 117 ++++- README.md | 29 +- docker-compose.microservices.dokploy.yml | 126 +++++- docker-compose.microservices.yml | 127 +++++- docs/architecture.md | 78 ++-- docs/deploy.md | 59 +-- tools/cloudflared.README.md | 5 +- 51 files changed, 2271 insertions(+), 122 deletions(-) create mode 100644 DorkNet.Contracts/DorkNetRouteOwnership.cs create mode 100644 DorkNet.Server.Tests/DorkNet.Server.Tests.csproj create mode 100644 DorkNet.Server.Tests/DorkNetServerFactory.cs create mode 100644 DorkNet.Server.Tests/EndpointContractDiscovery.cs create mode 100644 DorkNet.Server.Tests/GameClientEndpointContractTests.cs create mode 100644 DorkNet.Server.Tests/GameClientSession.cs create mode 100644 DorkNet.Services.Commerce/DorkNet.Services.Commerce.csproj create mode 100644 DorkNet.Services.Commerce/Program.cs create mode 100644 DorkNet.Services.Commerce/appsettings.json create mode 100644 DorkNet.Services.Content/DorkNet.Services.Content.csproj create mode 100644 DorkNet.Services.Content/Program.cs create mode 100644 DorkNet.Services.Content/appsettings.json create mode 100644 DorkNet.Services.Moderation/DorkNet.Services.Moderation.csproj create mode 100644 DorkNet.Services.Moderation/Program.cs create mode 100644 DorkNet.Services.Moderation/appsettings.json create mode 100644 DorkNet.Services.Platform/DorkNet.Services.Platform.csproj create mode 100644 DorkNet.Services.Platform/Program.cs create mode 100644 DorkNet.Services.Platform/appsettings.json create mode 100644 DorkNet.Services.Social/DorkNet.Services.Social.csproj create mode 100644 DorkNet.Services.Social/Program.cs create mode 100644 DorkNet.Services.Social/appsettings.json create mode 100644 DorkNet.Services.Web/DorkNet.Services.Web.csproj create mode 100644 DorkNet.Services.Web/Program.cs create mode 100644 DorkNet.Services.Web/appsettings.json diff --git a/.gitignore b/.gitignore index 7ae432c..2aa3740 100644 --- a/.gitignore +++ b/.gitignore @@ -21,7 +21,7 @@ tmp/ ## OS .DS_Store Thumbs.db - +.backups ## Reverse-engineered artifacts (Il2CppDumper / Cpp2IL output, not for commit) dump.cs il2cpp.h diff --git a/Dockerfile.service b/Dockerfile.service index a84bde7..cd9a6ee 100644 --- a/Dockerfile.service +++ b/Dockerfile.service @@ -5,7 +5,7 @@ ARG PROJECT_PATH WORKDIR /src COPY . . -RUN dotnet publish "${PROJECT_PATH}" -c Release -o /app/publish +RUN dotnet publish "${PROJECT_PATH}" -c Release -p:SkipAdminUIBuild=true -o /app/publish FROM mcr.microsoft.com/dotnet/aspnet:${DOTNET_VERSION} AS runtime ARG APP_DLL diff --git a/DorkNet.Contracts/DorkNetRouteOwnership.cs b/DorkNet.Contracts/DorkNetRouteOwnership.cs new file mode 100644 index 0000000..b23474b --- /dev/null +++ b/DorkNet.Contracts/DorkNetRouteOwnership.cs @@ -0,0 +1,298 @@ +namespace DorkNet.Contracts; + +public static class DorkNetRouteOwnership +{ + private const string AdminHost = "admin"; + + private static readonly DorkNetRouteGroup[] ServiceRouteGroups = + [ + new( + ServiceNames.Identity, + ["auth", "accounts"], + [ + "/.well-known", + "/account", + "/api/account", + "/api/auth", + "/api/createaccount", + "/api/platformlogin", + "/api/playerreputation", + "/api/players", + "/api/relationships", + "/api/settings", + "/cachedlogin", + "/connect", + "/eac", + "/parentalcontrol", + "/photon", + "/profileimage", + "/settings", + ]), + new( + ServiceNames.Rooms, + ["rooms", "match", "discovery", "lists", "roomcomments", "roomieintegrations", "studio"], + [ + "/api/activities", + "/api/curatedroomplaylists", + "/api/gameconfigs", + "/api/gameconfigurations", + "/api/gamesessions", + "/api/leaderboard", + "/api/playerelo", + "/api/playlists", + "/api/roomkeys", + "/api/rooms", + "/api/royale", + "/discover", + "/featuredrooms", + "/goto", + "/hot_rooms", + "/hot_roomsandplaylists", + "/invite", + "/playlists", + "/player", + "/room", + "/roominstance", + "/rooms", + "/roomsandplaylists", + "/roomserver", + "/search_rooms", + "/search_roomsandplaylists", + "/v1/find", + "/v1/join", + "/v1/leave", + "/v1/session", + ]), + new( + ServiceNames.Notify, + ["notify", "chat", "platformnotifications"], + [ + "/api/messages", + "/api/notification", + "/api/offlineinvite", + "/hub", + "/notify", + "/thread", + ]), + new( + ServiceNames.Content, + ["cdn", "data", "img", "storage", "strings-cdn", "videos"], + [ + "/api/images", + "/api/photos", + "/config", + "/data", + "/img", + "/invention", + "/upload", + "/video", + ]), + new( + ServiceNames.Social, + ["clubs"], + [ + "/announcements", + "/api/announcement", + "/api/clubreporting", + "/api/communityboard", + "/api/groups", + "/api/playercheer", + "/api/playerevents", + "/api/playersubscriptions", + "/club", + "/members", + "/subscription", + ]), + new( + ServiceNames.Commerce, + ["commerce", "econ"], + [ + "/api/avatar", + "/api/catalog", + "/api/compatibility", + "/api/consumables", + "/api/equipment", + "/api/gamerewards", + "/api/inventions", + "/api/purchase", + "/api/storefronts", + "/commerce", + "/econ", + ]), + new( + ServiceNames.Platform, + ["cards", "cms", "datacollection", "gamelogs", "geo", "leaderboard", "link", "ns", "playersettings", "strings", "thorn"], + [ + "/api/cards", + "/api/config", + "/api/events", + "/api/quickplay", + "/api/role", + "/api/version", + "/api/versioncheck", + "/motd", + "/pageview", + "/ping", + "/role", + "/strings", + "/v1/ping", + "/v1/player", + "/v1/regions", + "/v1/services", + ]), + new( + ServiceNames.Moderation, + ["bugreporting", "moderation"], + [ + "/api/admin", + "/api/banappeal", + "/api/bugreporting", + "/api/moderation", + "/api/playerreporting", + "/api/playersbanned", + "/api/sanitize", + "/api/testcasemanagement", + ]), + new( + ServiceNames.Web, + ["www", AdminHost, "feed"], + [ + "/api/site", + ], + IncludesApexHost: true), + ]; + + public static IReadOnlyList RouteGroups => ServiceRouteGroups; + + public static bool IsInfrastructurePath(string path) + { + return path.Equals("/healthz", StringComparison.OrdinalIgnoreCase) + || path.StartsWith("/internal/", StringComparison.OrdinalIgnoreCase); + } + + public static string ResolvePublicService(string? host, string path, string apexDomain) + { + var normalizedPath = NormalizePath(path); + var subdomain = Subdomain(host, apexDomain); + + if (string.Equals(subdomain, AdminHost, StringComparison.OrdinalIgnoreCase)) + { + return ServiceNames.Web; + } + + foreach (var group in ServiceRouteGroups.Where(group => group.ServiceName != ServiceNames.Web)) + { + if (MatchesHost(subdomain, group.HostSubdomains)) + { + return group.ServiceName; + } + } + + foreach (var group in ServiceRouteGroups) + { + if (StartsWithAny(normalizedPath, group.PathPrefixes)) + { + return group.ServiceName; + } + } + + var web = ServiceRouteGroups.First(group => group.ServiceName == ServiceNames.Web); + if ((subdomain is null && web.IncludesApexHost) || MatchesHost(subdomain, web.HostSubdomains)) + { + return ServiceNames.Web; + } + + return ServiceNames.Monolith; + } + + public static bool IsOwnedBy(string serviceName, string? host, string path, string apexDomain) + { + if (IsInfrastructurePath(path)) + { + return true; + } + + return string.Equals( + ResolvePublicService(host, path, apexDomain), + serviceName, + StringComparison.OrdinalIgnoreCase); + } + + public static string? Subdomain(string? host, string apexDomain) + { + if (string.IsNullOrWhiteSpace(host)) + { + return null; + } + + var normalizedHost = host.Split(':', 2)[0].Trim().ToLowerInvariant(); + var normalizedApex = apexDomain.Split(':', 2)[0].Trim().ToLowerInvariant(); + if (string.IsNullOrWhiteSpace(normalizedHost) + || string.IsNullOrWhiteSpace(normalizedApex) + || string.Equals(normalizedHost, normalizedApex, StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + var suffix = "." + normalizedApex; + if (normalizedHost.EndsWith(suffix, StringComparison.OrdinalIgnoreCase)) + { + return normalizedHost[..^suffix.Length]; + } + + var dot = normalizedHost.IndexOf('.'); + return dot > 0 ? normalizedHost[..dot] : null; + } + + private static bool StartsWithAny(string path, IEnumerable prefixes) + { + foreach (var prefix in prefixes) + { + if (StartsWithPathSegment(path, prefix)) + { + return true; + } + } + + return false; + } + + private static bool StartsWithPathSegment(string path, string prefix) + { + var normalizedPath = NormalizePath(path); + var normalizedPrefix = NormalizePath(prefix).TrimEnd('/'); + if (string.IsNullOrWhiteSpace(normalizedPrefix)) + { + normalizedPrefix = "/"; + } + + return normalizedPath.Equals(normalizedPrefix, StringComparison.OrdinalIgnoreCase) + || normalizedPath.StartsWith(normalizedPrefix + "/", StringComparison.OrdinalIgnoreCase); + } + + private static bool MatchesHost(string? subdomain, IEnumerable candidates) + { + if (string.IsNullOrWhiteSpace(subdomain)) + { + return false; + } + + return candidates.Contains(subdomain, StringComparer.OrdinalIgnoreCase); + } + + private static string NormalizePath(string path) + { + if (string.IsNullOrWhiteSpace(path)) + { + return "/"; + } + + return path[0] == '/' ? path : "/" + path; + } +} + +public sealed record DorkNetRouteGroup( + string ServiceName, + string[] HostSubdomains, + string[] PathPrefixes, + bool IncludesApexHost = false); diff --git a/DorkNet.Contracts/DorkNetServiceMapOptions.cs b/DorkNet.Contracts/DorkNetServiceMapOptions.cs index cfb2244..bdc5119 100644 --- a/DorkNet.Contracts/DorkNetServiceMapOptions.cs +++ b/DorkNet.Contracts/DorkNetServiceMapOptions.cs @@ -5,7 +5,13 @@ public sealed class DorkNetServiceMapOptions public string Identity { get; set; } = "http://localhost:8081"; public string Rooms { get; set; } = "http://localhost:8082"; public string Notify { get; set; } = "http://localhost:8083"; - public string Monolith { get; set; } = "http://localhost:8084"; + public string Content { get; set; } = "http://localhost:8084"; + public string Social { get; set; } = "http://localhost:8085"; + public string Commerce { get; set; } = "http://localhost:8086"; + public string Platform { get; set; } = "http://localhost:8087"; + public string Moderation { get; set; } = "http://localhost:8088"; + public string Web { get; set; } = "http://localhost:8089"; + public string Monolith { get; set; } = "http://localhost:8090"; public IReadOnlyList Endpoints() { @@ -14,6 +20,12 @@ public sealed class DorkNetServiceMapOptions new(ServiceNames.Identity, Identity), new(ServiceNames.Rooms, Rooms), new(ServiceNames.Notify, Notify), + new(ServiceNames.Content, Content), + new(ServiceNames.Social, Social), + new(ServiceNames.Commerce, Commerce), + new(ServiceNames.Platform, Platform), + new(ServiceNames.Moderation, Moderation), + new(ServiceNames.Web, Web), new(ServiceNames.Monolith, Monolith), ]; diff --git a/DorkNet.Contracts/ServiceNames.cs b/DorkNet.Contracts/ServiceNames.cs index 12dae24..79f63ef 100644 --- a/DorkNet.Contracts/ServiceNames.cs +++ b/DorkNet.Contracts/ServiceNames.cs @@ -6,5 +6,11 @@ public static class ServiceNames public const string Identity = "identity"; public const string Rooms = "rooms"; public const string Notify = "notify"; + public const string Content = "content"; + public const string Social = "social"; + public const string Commerce = "commerce"; + public const string Platform = "platform"; + public const string Moderation = "moderation"; + public const string Web = "web"; public const string Monolith = "monolith"; } diff --git a/DorkNet.Gateway/DorkNet.Gateway.csproj b/DorkNet.Gateway/DorkNet.Gateway.csproj index 8e34531..358445e 100644 --- a/DorkNet.Gateway/DorkNet.Gateway.csproj +++ b/DorkNet.Gateway/DorkNet.Gateway.csproj @@ -6,6 +6,10 @@ enable + + + + diff --git a/DorkNet.Gateway/Program.cs b/DorkNet.Gateway/Program.cs index b0b2808..aa9e48c 100644 --- a/DorkNet.Gateway/Program.cs +++ b/DorkNet.Gateway/Program.cs @@ -1,6 +1,7 @@ using DorkNet.Contracts; using DorkNet.ServiceDefaults; using Microsoft.Extensions.Options; +using Yarp.ReverseProxy.Configuration; var builder = WebApplication.CreateBuilder(args); builder.Configuration.AddJsonFile("appsettings.Local.json", @@ -9,6 +10,17 @@ builder.Configuration.AddJsonFile("appsettings.Local.json", builder.AddDorkNetServiceDefaults(ServiceNames.Gateway); builder.Services.Configure( builder.Configuration.GetSection("DorkNet:Services")); +var serviceMap = builder.Configuration + .GetSection("DorkNet:Services") + .Get() ?? new DorkNetServiceMapOptions(); +var apexDomain = builder.Configuration["Domain:Apex"] + ?? Environment.GetEnvironmentVariable("DORKNET_DOMAIN") + ?? "localhost"; +builder.Services + .AddReverseProxy() + .LoadFromMemory( + BuildRoutes(apexDomain, serviceMap), + BuildClusters(serviceMap)); var app = builder.Build(); @@ -17,6 +29,19 @@ app.MapDorkNetServiceDefaults(); app.MapGet("/", () => Results.Redirect("/healthz")); app.MapGet("/internal/services", (IOptions options) => Results.Ok(options.Value.Endpoints())); +app.MapGet("/internal/routes", () => + Results.Ok(new + { + ApexDomain = apexDomain, + Routes = BuildRoutes(apexDomain, serviceMap).Select(r => new + { + r.RouteId, + r.ClusterId, + r.Match.Hosts, + r.Match.Path, + r.Order, + }), + })); app.MapGet("/internal/services/health", async ( IOptions options, @@ -34,6 +59,8 @@ app.MapGet("/internal/services/health", async ( return Results.Ok(probes); }); +app.MapReverseProxy(); + app.Run(); static async Task ProbeServiceAsync( @@ -66,3 +93,206 @@ static async Task ProbeServiceAsync( checkedAt); } } + +static IReadOnlyList BuildRoutes( + string apexDomain, + DorkNetServiceMapOptions services) +{ + var routes = new List(); + var order = 0; + var webGroup = DorkNetRouteOwnership.RouteGroups + .First(group => group.ServiceName == ServiceNames.Web); + + AddHostRoute( + routes, + ref order, + webGroup.ServiceName, + BaseUrlFor(services, webGroup.ServiceName), + "web-admin-host", + [SubdomainHost("admin", apexDomain)]); + + foreach (var group in DorkNetRouteOwnership.RouteGroups.Where(group => group.ServiceName != ServiceNames.Web)) + { + AddHostRoute( + routes, + ref order, + group.ServiceName, + BaseUrlFor(services, group.ServiceName), + $"{group.ServiceName}-hosts", + group.HostSubdomains.Select(subdomain => SubdomainHost(subdomain, apexDomain)).ToArray()); + } + + foreach (var group in DorkNetRouteOwnership.RouteGroups) + { + AddPathRoutes( + routes, + ref order, + group.ServiceName, + BaseUrlFor(services, group.ServiceName), + group.PathPrefixes); + } + + AddHostRoute( + routes, + ref order, + webGroup.ServiceName, + BaseUrlFor(services, webGroup.ServiceName), + "web-hosts", + BuildWebHosts(webGroup, apexDomain)); + + if (!string.IsNullOrWhiteSpace(services.Monolith)) + { + routes.Add(Route(ServiceNames.Monolith, "monolith-fallback", "/{**catch-all}", order++)); + } + + return routes; +} + +static IReadOnlyList BuildClusters(DorkNetServiceMapOptions services) +{ + var clusters = new List(); + foreach (var endpoint in services.Endpoints()) + { + AddCluster(clusters, endpoint.Name, endpoint.BaseUrl); + } + + return clusters; +} + +static void AddHostRoute( + List routes, + ref int order, + string serviceName, + string baseUrl, + string routeId, + string[] hosts) +{ + if (string.IsNullOrWhiteSpace(baseUrl) || hosts.Length == 0) + { + return; + } + + routes.Add(Route( + serviceName, + routeId, + "/{**catch-all}", + order++, + hosts)); +} + +static void AddPathRoutes( + List routes, + ref int order, + string serviceName, + string baseUrl, + IEnumerable paths) +{ + if (string.IsNullOrWhiteSpace(baseUrl)) + { + return; + } + + foreach (var path in paths) + { + var routeId = $"{serviceName}-{path.Trim('/').Replace('/', '-').Replace("{**catch-all}", "all")}"; + var exactPath = NormalizeRoutePrefix(path); + routes.Add(Route(serviceName, $"{routeId}-exact", exactPath, order++)); + routes.Add(Route(serviceName, routeId, $"{exactPath}/{{**catch-all}}", order++)); + } +} + +static string BaseUrlFor(DorkNetServiceMapOptions services, string serviceName) +{ + return serviceName switch + { + ServiceNames.Identity => services.Identity, + ServiceNames.Rooms => services.Rooms, + ServiceNames.Notify => services.Notify, + ServiceNames.Content => services.Content, + ServiceNames.Social => services.Social, + ServiceNames.Commerce => services.Commerce, + ServiceNames.Platform => services.Platform, + ServiceNames.Moderation => services.Moderation, + ServiceNames.Web => services.Web, + ServiceNames.Monolith => services.Monolith, + _ => string.Empty, + }; +} + +static string[] BuildWebHosts(DorkNetRouteGroup group, string apexDomain) +{ + var hosts = new List(); + if (group.IncludesApexHost) + { + hosts.Add(apexDomain); + } + + hosts.AddRange(group.HostSubdomains + .Where(subdomain => !string.Equals(subdomain, "admin", StringComparison.OrdinalIgnoreCase)) + .Select(subdomain => SubdomainHost(subdomain, apexDomain))); + + return hosts.ToArray(); +} + +static string SubdomainHost(string subdomain, string apexDomain) +{ + return $"{subdomain}.{apexDomain}"; +} + +static string NormalizeRoutePrefix(string path) +{ + if (string.IsNullOrWhiteSpace(path) || path == "/") + { + return "/"; + } + + return (path[0] == '/' ? path : "/" + path).TrimEnd('/'); +} + +static RouteConfig Route( + string clusterId, + string routeId, + string path, + int order, + string[]? hosts = null) +{ + return new RouteConfig + { + RouteId = routeId, + ClusterId = clusterId, + Order = order, + Match = new RouteMatch + { + Hosts = hosts, + Path = path, + }, + Transforms = + [ + new Dictionary + { + ["RequestHeaderOriginalHost"] = "true", + }, + ], + }; +} + +static void AddCluster( + List clusters, + string serviceName, + string baseUrl) +{ + if (string.IsNullOrWhiteSpace(baseUrl)) + { + return; + } + + var address = baseUrl.EndsWith('/') ? baseUrl : baseUrl + "/"; + clusters.Add(new ClusterConfig + { + ClusterId = serviceName, + Destinations = new Dictionary + { + ["primary"] = new() { Address = address }, + }, + }); +} diff --git a/DorkNet.Gateway/appsettings.json b/DorkNet.Gateway/appsettings.json index 3daa59e..e56c835 100644 --- a/DorkNet.Gateway/appsettings.json +++ b/DorkNet.Gateway/appsettings.json @@ -11,7 +11,13 @@ "Identity": "http://localhost:8081", "Rooms": "http://localhost:8082", "Notify": "http://localhost:8083", - "Monolith": "http://localhost:8084" + "Content": "http://localhost:8084", + "Social": "http://localhost:8085", + "Commerce": "http://localhost:8086", + "Platform": "http://localhost:8087", + "Moderation": "http://localhost:8088", + "Web": "http://localhost:8089", + "Monolith": "http://localhost:8090" } } } diff --git a/DorkNet.Server.Tests/DorkNet.Server.Tests.csproj b/DorkNet.Server.Tests/DorkNet.Server.Tests.csproj new file mode 100644 index 0000000..7cd69a8 --- /dev/null +++ b/DorkNet.Server.Tests/DorkNet.Server.Tests.csproj @@ -0,0 +1,26 @@ + + + + net9.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + diff --git a/DorkNet.Server.Tests/DorkNetServerFactory.cs b/DorkNet.Server.Tests/DorkNetServerFactory.cs new file mode 100644 index 0000000..58f73cf --- /dev/null +++ b/DorkNet.Server.Tests/DorkNetServerFactory.cs @@ -0,0 +1,90 @@ +using System.Text; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.HostFiltering; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.IdentityModel.Tokens; + +namespace DorkNet.Server.Tests; + +public sealed class DorkNetServerFactory : WebApplicationFactory +{ + private const string JwtSecret = "endpoint-contract-secret-000000000000000000000000000000000000000000"; + private readonly string _dataRoot; + + public DorkNetServerFactory() + { + _dataRoot = Path.Combine(Path.GetTempPath(), "dorknet-server-tests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_dataRoot); + } + + public string ApexDomain => "dork.test"; + + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.UseEnvironment("Development"); + builder.UseSetting("Domain:Apex", ApexDomain); + builder.ConfigureAppConfiguration((_, config) => + { + config.AddInMemoryCollection(new Dictionary + { + ["AllowedHosts"] = "*", + ["Database:Provider"] = "sqlite", + ["Database:SqlitePath"] = Path.Combine(_dataRoot, "dorknet-test.db"), + ["Domain:Apex"] = ApexDomain, + ["Jwt:Secret"] = JwtSecret, + ["S3:Endpoint"] = "", + ["S3:AccessKey"] = "", + ["S3:SecretKey"] = "", + ["DorkNet:DefaultClientVersion"] = "december_2020_12_18", + ["DorkNet:SupportedVersions:0"] = "december_2020_12_18", + }); + }); + builder.ConfigureServices(services => + { + services.PostConfigure(options => + { + options.AllowedHosts = + [ + ApexDomain, + $"*.{ApexDomain}", + "localhost", + "127.0.0.1", + ]; + options.AllowEmptyHosts = true; + options.IncludeFailureMessage = true; + }); + services.PostConfigure(JwtBearerDefaults.AuthenticationScheme, options => + { + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuerSigningKey = true, + IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(JwtSecret)), + ValidateIssuer = true, + ValidIssuer = "https://api.rec.net/", + ValidateAudience = false, + ClockSkew = TimeSpan.Zero, + }; + }); + }); + } + + protected override void Dispose(bool disposing) + { + base.Dispose(disposing); + + try + { + if (Directory.Exists(_dataRoot)) + { + Directory.Delete(_dataRoot, recursive: true); + } + } + catch + { + // Test cleanup must not hide the original test failure. + } + } +} diff --git a/DorkNet.Server.Tests/EndpointContractDiscovery.cs b/DorkNet.Server.Tests/EndpointContractDiscovery.cs new file mode 100644 index 0000000..abc75c9 --- /dev/null +++ b/DorkNet.Server.Tests/EndpointContractDiscovery.cs @@ -0,0 +1,222 @@ +using System.Reflection; +using System.Text.RegularExpressions; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Routing; + +namespace DorkNet.Server.Tests; + +public static partial class EndpointContractDiscovery +{ + public static IReadOnlyList Discover() + { + var serverAssembly = typeof(Program).Assembly; + var controllerBaseType = typeof(ControllerBase); + return serverAssembly.GetTypes() + .Where(t => !t.IsAbstract && controllerBaseType.IsAssignableFrom(t)) + .SelectMany(DiscoverControllerRoutes) + .OrderBy(route => route.Method, StringComparer.Ordinal) + .ThenBy(route => route.SamplePath, StringComparer.Ordinal) + .ThenBy(route => route.Source, StringComparer.Ordinal) + .ToArray(); + } + + private static IEnumerable DiscoverControllerRoutes(Type controller) + { + var controllerName = ControllerName(controller); + var controllerRouteTemplates = controller.GetCustomAttributes(inherit: true) + .OfType() + .Select(a => a.Template) + .Where(t => !string.IsNullOrWhiteSpace(t)) + .Cast() + .DefaultIfEmpty("") + .ToArray(); + + foreach (var method in controller.GetMethods(BindingFlags.Instance | BindingFlags.Public | BindingFlags.DeclaredOnly)) + { + var httpAttrs = method.GetCustomAttributes(inherit: true) + .OfType() + .ToArray(); + if (httpAttrs.Length == 0) + { + continue; + } + + foreach (var attr in httpAttrs) + { + var attrMethods = attr.HttpMethods.Any() + ? attr.HttpMethods + : new[] { "ANY" }; + + foreach (var httpMethod in attrMethods) + { + foreach (var controllerTemplate in controllerRouteTemplates) + { + var template = CombineTemplates(controllerTemplate, attr.Template); + template = ReplaceRouteTokens(template, controllerName, method.Name); + yield return new EndpointContract( + httpMethod.ToUpperInvariant(), + template, + ToSamplePath(template), + $"{controller.FullName}.{method.Name}", + RequiresAuthorization(controller, method), + IsAdminEndpoint(controller, method), + ConsumesContentTypes(controller, method)); + } + } + } + } + } + + private static bool RequiresAuthorization(Type controller, MethodInfo method) + { + var attributes = controller.GetCustomAttributes(inherit: true) + .Concat(method.GetCustomAttributes(inherit: true)) + .ToArray(); + + if (attributes.OfType().Any()) + { + return false; + } + + return attributes.OfType().Any(); + } + + private static bool IsAdminEndpoint(Type controller, MethodInfo method) + { + return controller.FullName?.Contains(".Controllers.Admin.", StringComparison.Ordinal) == true + || method.Name.Contains("Admin", StringComparison.OrdinalIgnoreCase); + } + + private static string[] ConsumesContentTypes(Type controller, MethodInfo method) + { + var consumes = method.GetCustomAttributes(inherit: true) + .Concat(controller.GetCustomAttributes(inherit: true)) + .OfType() + .SelectMany(a => a.ContentTypes) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + return consumes.Length == 0 + ? ["application/json"] + : consumes; + } + + private static string ControllerName(Type controller) + { + const string suffix = "Controller"; + return controller.Name.EndsWith(suffix, StringComparison.Ordinal) + ? controller.Name[..^suffix.Length] + : controller.Name; + } + + private static string CombineTemplates(string? controllerTemplate, string? actionTemplate) + { + controllerTemplate ??= ""; + actionTemplate ??= ""; + + if (actionTemplate.StartsWith("~/", StringComparison.Ordinal)) + { + return "/" + actionTemplate[2..].TrimStart('/'); + } + + if (actionTemplate.StartsWith("/", StringComparison.Ordinal)) + { + return "/" + actionTemplate.TrimStart('/'); + } + + var left = controllerTemplate.Trim('/'); + var right = actionTemplate.Trim('/'); + + if (string.IsNullOrEmpty(left) && string.IsNullOrEmpty(right)) + { + return "/"; + } + + if (string.IsNullOrEmpty(left)) + { + return "/" + right; + } + + if (string.IsNullOrEmpty(right)) + { + return "/" + left; + } + + return "/" + left + "/" + right; + } + + private static string ReplaceRouteTokens(string template, string controllerName, string actionName) + { + return template + .Replace("[controller]", controllerName, StringComparison.OrdinalIgnoreCase) + .Replace("[action]", actionName, StringComparison.OrdinalIgnoreCase); + } + + private static string ToSamplePath(string template) + { + var path = RouteParameterRegex().Replace(template, match => + { + var name = match.Groups["name"].Value.ToLowerInvariant(); + var constraint = match.Groups["constraint"].Value.ToLowerInvariant(); + + if (name.Contains("playerid") || name.Contains("accountid") || name == "id") + { + return EndpointContract.TestPlayerToken; + } + + if (constraint.Contains("long") || constraint.Contains("int") || name.EndsWith("id")) + { + return "1"; + } + + if (constraint.Contains("bool")) + { + return "true"; + } + + if (constraint.Contains("guid")) + { + return "00000000-0000-0000-0000-000000000001"; + } + + if (name.Contains("roomname")) + { + return "DormRoom"; + } + + if (name.Contains("locale")) + { + return "en"; + } + + return "sample"; + }); + + path = Regex.Replace(path, "/+", "/"); + return path.StartsWith('/') ? path : "/" + path; + } + + [GeneratedRegex(@"\{[*]?(?[^}:=\?]+)(?:[^}=\?]+)?(?\?)?(?=[^}]+)?\}")] + private static partial Regex RouteParameterRegex(); +} + +public sealed record EndpointContract( + string Method, + string Template, + string SamplePath, + string Source, + bool RequiresAuthorization, + bool IsAdminEndpoint, + string[] Consumes) +{ + public const string TestPlayerToken = "__test_player_id__"; + + public string PathFor(GameClientSession session) + { + return SamplePath.Replace( + TestPlayerToken, + session.PlayerId.ToString(), + StringComparison.Ordinal); + } +} diff --git a/DorkNet.Server.Tests/GameClientEndpointContractTests.cs b/DorkNet.Server.Tests/GameClientEndpointContractTests.cs new file mode 100644 index 0000000..2d7b860 --- /dev/null +++ b/DorkNet.Server.Tests/GameClientEndpointContractTests.cs @@ -0,0 +1,411 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; + +namespace DorkNet.Server.Tests; + +public sealed class GameClientEndpointContractTests : + IClassFixture +{ + private readonly DorkNetServerFactory _factory; + + public GameClientEndpointContractTests(DorkNetServerFactory factory) + { + _factory = factory; + } + + [Fact] + public async Task Game_client_contract_probe_reaches_every_controller_route() + { + var routes = EndpointContractDiscovery.Discover(); + Assert.NotEmpty(routes); + + using var client = _factory.CreateClient(new() + { + AllowAutoRedirect = false, + }); + client.BaseAddress = new Uri($"http://api.{_factory.ApexDomain}"); + client.Timeout = TimeSpan.FromSeconds(20); + + var session = await GameClientSessionFactory.CreateAsync(client, _factory.ApexDomain); + var failures = new List(); + + foreach (var route in routes) + { + var result = await ProbeAsync(client, route, session, _factory.ApexDomain); + if (!result.Passed) + { + failures.Add( + $"{route.Method,-6} {result.Host}{result.Path} {route.Source} expected {result.Expected}, got {result.Observed}" + + (string.IsNullOrWhiteSpace(result.ResponseExcerpt) ? "" : $" body={result.ResponseExcerpt}")); + } + } + + Assert.True( + failures.Count == 0, + $"Endpoint contract failures ({failures.Count}/{routes.Count}):{Environment.NewLine}" + + string.Join(Environment.NewLine, failures)); + } + + private static async Task ProbeAsync( + HttpClient client, + EndpointContract route, + GameClientSession session, + string apexDomain) + { + var path = route.PathFor(session); + path = AddClientQuery(path, route, session); + var host = HostFor(path, apexDomain); + using var request = new HttpRequestMessage( + ToHttpMethod(route.Method), + new Uri($"http://{host}{path}")); + request.Headers.Accept.ParseAdd("application/json"); + request.Headers.UserAgent.ParseAdd("RecRoom/2020.12.18"); + request.Headers.TryAddWithoutValidation("X-DorkNet-Version", "december_2020_12_18"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", session.AccessToken); + + if (NeedsRequestBody(request.Method)) + { + request.Content = CreateContent(route, session); + } + + try + { + using var response = await client.SendAsync(request); + var responseText = await response.Content.ReadAsStringAsync(); + var statusCode = (int)response.StatusCode; + var passed = ResponseMatchesContract(route, response.StatusCode); + return new EndpointProbeResult( + route.Method, + host, + path, + ExpectedContract(route), + statusCode, + null, + Excerpt(responseText), + passed); + } + catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException) + { + return new EndpointProbeResult( + route.Method, + host, + path, + ExpectedContract(route), + null, + ex.Message, + null, + false); + } + } + + private static bool ResponseMatchesContract( + EndpointContract route, + HttpStatusCode statusCode) + { + if ((int)statusCode >= 500) + { + return false; + } + + if (statusCode is HttpStatusCode.MethodNotAllowed + or HttpStatusCode.NotImplemented + or HttpStatusCode.UpgradeRequired) + { + return false; + } + + if (route.RequiresAuthorization + && statusCode is HttpStatusCode.Unauthorized) + { + return false; + } + + return true; + } + + private static string ExpectedContract(EndpointContract route) + { + if (route.IsAdminEndpoint) + { + return "admin endpoint may reject non-admin test player, but must not 5xx/405/426"; + } + + if (route.RequiresAuthorization) + { + return "authenticated test player should reach the action without 401/5xx/405/426; 403 is valid for owner/admin-only resources"; + } + + return "game-client request should not 5xx/405/426"; + } + + private static HttpMethod ToHttpMethod(string method) + { + return string.Equals(method, "ANY", StringComparison.OrdinalIgnoreCase) + ? HttpMethod.Get + : new HttpMethod(method); + } + + private static bool NeedsRequestBody(HttpMethod method) + { + return method == HttpMethod.Post + || method == HttpMethod.Put + || method.Method.Equals("PATCH", StringComparison.OrdinalIgnoreCase); + } + + private static HttpContent CreateContent( + EndpointContract route, + GameClientSession session) + { + var consumes = route.Consumes.Select(c => c.ToLowerInvariant()).ToArray(); + if (consumes.Any(c => c.Contains("application/x-www-form-urlencoded"))) + { + return new FormUrlEncodedContent(new Dictionary + { + ["account_id"] = session.PlayerId.ToString(), + ["AccountId"] = session.PlayerId.ToString(), + ["clubId"] = "1", + ["ClubId"] = "1", + ["description"] = "Endpoint contract description", + ["deviceId"] = session.DeviceId, + ["grant_type"] = "password", + ["image"] = "endpoint-contract.png", + ["imageName"] = "endpoint-contract.png", + ["fileName"] = "endpoint-contract.png", + ["message"] = "Endpoint contract message", + ["name"] = "EndpointContract", + ["newPassword"] = "endpoint-contract-password", + ["oldPassword"] = "", + ["password"] = "endpoint-contract-password", + ["ping"] = "42", + ["platformId"] = session.PlatformId, + ["playerId"] = session.PlayerId.ToString(), + ["playerIds"] = session.PlayerId.ToString(), + ["query"] = "DormRoom", + ["region"] = "us", + ["targetPlayerId"] = session.PlayerId.ToString(), + ["text"] = "Endpoint contract message", + ["username"] = "EndpointContract", + ["device_id"] = session.DeviceId, + ["platform"] = "0", + ["platform_id"] = session.PlatformId, + ["Ids"] = session.PlayerId.ToString(), + ["PlayerIds"] = session.PlayerId.ToString(), + }); + } + + if (consumes.Any(c => c.Contains("multipart/form-data"))) + { + var content = new MultipartFormDataContent(); + content.Add(new StringContent("endpoint-contract"), "name"); + content.Add(new StringContent(session.PlayerId.ToString()), "playerId"); + content.Add(new StringContent("endpoint-contract.png"), "imageName"); + content.Add(new StringContent("endpoint-contract.png"), "fileName"); + content.Add(new ByteArrayContent([]), "file", "endpoint-contract.bin"); + return content; + } + + if (consumes.Any(c => c.Contains("text/plain"))) + { + return new StringContent("", Encoding.UTF8, "text/plain"); + } + + var json = JsonSerializer.Serialize(new + { + PlayerId = session.PlayerId, + AccountId = session.PlayerId, + ClubId = 1, + Description = "Endpoint contract description", + DeviceId = session.DeviceId, + FileName = "endpoint-contract.png", + Id = session.PlayerId, + Image = "endpoint-contract.png", + ImageName = "endpoint-contract.png", + Message = "Endpoint contract message", + Platform = 0, + PlatformId = session.PlatformId, + PlayerIds = new[] { session.PlayerId }, + Name = "EndpointContract", + Password = "endpoint-contract-password", + Query = "DormRoom", + RoomName = "DormRoom", + TargetPlayerId = session.PlayerId, + Text = "Endpoint contract message", + }); + return new StringContent(json, Encoding.UTF8, "application/json"); + } + + private static string AddClientQuery( + string path, + EndpointContract route, + GameClientSession session) + { + var separator = path.Contains('?', StringComparison.Ordinal) ? "&" : "?"; + var lower = path.ToLowerInvariant(); + var query = new List(); + + if (lower.Contains("search", StringComparison.Ordinal) + || lower.Contains("query", StringComparison.Ordinal)) + { + query.Add("query=EndpointContract"); + } + + if (lower == "/members/bulk") + { + query.Add("clubId=1"); + query.Add($"playerIds={session.PlayerId}"); + } + + if (lower == "/player/photonregionpings") + { + query.Add("region=us"); + query.Add("ping=42"); + } + + if (route.Source.Contains("CachedLogins", StringComparison.Ordinal) + || lower.Contains("platformlogin/cached", StringComparison.Ordinal) + || lower.Contains("savedlogins", StringComparison.Ordinal)) + { + query.Add("platform=0"); + query.Add($"platformId={Uri.EscapeDataString(session.PlatformId)}"); + } + + if (route.Source.Contains("Bulk", StringComparison.Ordinal) + && !query.Any(q => q.StartsWith("Ids=", StringComparison.OrdinalIgnoreCase))) + { + query.Add($"Ids={session.PlayerId}"); + } + + if (query.Count == 0) + { + return path; + } + + return path + separator + string.Join("&", query); + } + + private static string HostFor(string path, string apexDomain) + { + var lower = path.ToLowerInvariant(); + + if (lower.StartsWith("/connect/") + || lower.StartsWith("/.well-known/") + || lower.StartsWith("/eac/") + || lower.StartsWith("/cachedlogin/") + || lower.StartsWith("/photon/") + || lower.StartsWith("/api/platformlogin/") + || lower.StartsWith("/api/version") + || lower.StartsWith("/api/auth/")) + { + return $"auth.{apexDomain}"; + } + + if (lower.StartsWith("/account/")) + { + return $"accounts.{apexDomain}"; + } + + if (lower.StartsWith("/roomserver/") + || lower.StartsWith("/rooms") + || lower.StartsWith("/playlists") + || lower.StartsWith("/featuredrooms") + || lower.StartsWith("/hot_rooms") + || lower.StartsWith("/search_rooms") + || lower.StartsWith("/api/rooms") + || lower.StartsWith("/api/roomkeys") + || lower.StartsWith("/api/gamesessions") + || lower.StartsWith("/api/curatedroomplaylists")) + { + return $"rooms.{apexDomain}"; + } + + if (lower.StartsWith("/v1/find") + || lower.StartsWith("/v1/join") + || lower.StartsWith("/v1/leave") + || lower.StartsWith("/v1/session") + || lower.StartsWith("/roominstance/") + || lower.StartsWith("/goto/") + || lower.StartsWith("/player/")) + { + return $"match.{apexDomain}"; + } + + if (lower.StartsWith("/thread")) + { + return $"chat.{apexDomain}"; + } + + if (lower.StartsWith("/club") + || lower.StartsWith("/announcements/") + || lower.StartsWith("/subscription/") + || lower.StartsWith("/members/") + || lower.StartsWith("/api/clubreporting/")) + { + return $"clubs.{apexDomain}"; + } + + if (lower.StartsWith("/api/storefronts/") + || lower.StartsWith("/api/purchase/") + || lower.StartsWith("/api/subscription/")) + { + return $"commerce.{apexDomain}"; + } + + if (lower.StartsWith("/hub/") || lower.StartsWith("/api/notification/")) + { + return $"notify.{apexDomain}"; + } + + if (lower.StartsWith("/v1/regions")) + { + return $"geo.{apexDomain}"; + } + + if (lower.StartsWith("/v1/services") || lower.StartsWith("/v1/ping")) + { + return $"ns.{apexDomain}"; + } + + if (lower.StartsWith("/upload")) + { + return $"storage.{apexDomain}"; + } + + if (lower.StartsWith("/img/") || lower.StartsWith("/room/") || lower.StartsWith("/data/")) + { + return $"cdn.{apexDomain}"; + } + + if (lower.StartsWith("/api/admin/")) + { + return $"admin.{apexDomain}"; + } + + return $"api.{apexDomain}"; + } + + private static string? Excerpt(string? body) + { + if (string.IsNullOrWhiteSpace(body)) + { + return null; + } + + var compact = body.ReplaceLineEndings(" ").Trim(); + return compact.Length <= 240 ? compact : compact[..240] + "..."; + } +} + +public sealed record EndpointProbeResult( + string Method, + string Host, + string Path, + string Expected, + int? StatusCode, + string? Error, + string? ResponseExcerpt, + bool Passed) +{ + public string Observed => StatusCode is int code ? code.ToString() : Error ?? "no response"; +} diff --git a/DorkNet.Server.Tests/GameClientSession.cs b/DorkNet.Server.Tests/GameClientSession.cs new file mode 100644 index 0000000..cfc519e --- /dev/null +++ b/DorkNet.Server.Tests/GameClientSession.cs @@ -0,0 +1,65 @@ +using System.Net.Http.Headers; +using System.Text.Json; + +namespace DorkNet.Server.Tests; + +public sealed record GameClientSession( + long PlayerId, + string AccessToken, + string DeviceId, + string PlatformId); + +public static class GameClientSessionFactory +{ + public static async Task CreateAsync( + HttpClient client, + string apexDomain) + { + var deviceId = $"endpoint-contract-device-{Guid.NewGuid():N}"; + var platformId = $"endpoint-contract-platform-{Guid.NewGuid():N}"; + var tokenRequest = new HttpRequestMessage( + HttpMethod.Post, + new Uri($"http://auth.{apexDomain}/connect/token")); + tokenRequest.Headers.Accept.ParseAdd("application/json"); + tokenRequest.Headers.UserAgent.ParseAdd("RecRoom/2020.12.18"); + tokenRequest.Headers.TryAddWithoutValidation("X-DorkNet-Version", "december_2020_12_18"); + tokenRequest.Content = new FormUrlEncodedContent(new Dictionary + { + ["grant_type"] = "password", + ["username"] = "EndpointContract", + ["device_id"] = deviceId, + ["platform"] = "0", + ["platform_id"] = platformId, + }); + + using var tokenResponse = await client.SendAsync(tokenRequest); + if (!tokenResponse.IsSuccessStatusCode) + { + var body = await tokenResponse.Content.ReadAsStringAsync(); + throw new InvalidOperationException( + $"Test player token request failed with {(int)tokenResponse.StatusCode} {tokenResponse.StatusCode}: {body}"); + } + + using var tokenJson = await JsonDocument.ParseAsync( + await tokenResponse.Content.ReadAsStreamAsync()); + var accessToken = tokenJson.RootElement.GetProperty("access_token").GetString() + ?? throw new InvalidOperationException("Token response did not include access_token."); + + var userInfoRequest = new HttpRequestMessage( + HttpMethod.Get, + new Uri($"http://auth.{apexDomain}/connect/userinfo")); + userInfoRequest.Headers.Accept.ParseAdd("application/json"); + userInfoRequest.Headers.UserAgent.ParseAdd("RecRoom/2020.12.18"); + userInfoRequest.Headers.TryAddWithoutValidation("X-DorkNet-Version", "december_2020_12_18"); + userInfoRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); + + using var userInfoResponse = await client.SendAsync(userInfoRequest); + userInfoResponse.EnsureSuccessStatusCode(); + + using var userInfoJson = await JsonDocument.ParseAsync( + await userInfoResponse.Content.ReadAsStreamAsync()); + var playerId = long.Parse(userInfoJson.RootElement.GetProperty("sub").GetString()!); + + return new GameClientSession(playerId, accessToken, deviceId, platformId); + } +} diff --git a/DorkNet.Server/Controllers/API/Account/AccountApiController.cs b/DorkNet.Server/Controllers/API/Account/AccountApiController.cs index 62b036c..ca0d63d 100644 --- a/DorkNet.Server/Controllers/API/Account/AccountApiController.cs +++ b/DorkNet.Server/Controllers/API/Account/AccountApiController.cs @@ -201,12 +201,12 @@ public class AccountApiController( [HttpPost("api/account/v1/profileimage")] [HttpPost("api/account/me/profileimage")] - [HttpPost("account/me/profileimage")] - [HttpPost("profileimage")] + [HttpPost("account/me/profileimage", Order = 10)] + [HttpPost("profileimage", Order = 10)] [HttpPut("api/account/v1/profileimage")] [HttpPut("api/account/me/profileimage")] - [HttpPut("account/me/profileimage")] - [HttpPut("profileimage")] + [HttpPut("account/me/profileimage", Order = 10)] + [HttpPut("profileimage", Order = 10)] [Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")] public async Task SetProfileImage( [FromForm(Name = "imageName")] string? imageName, diff --git a/DorkNet.Server/Controllers/API/Pageview/PageviewController.cs b/DorkNet.Server/Controllers/API/Pageview/PageviewController.cs index 198d831..b067fe1 100644 --- a/DorkNet.Server/Controllers/API/Pageview/PageviewController.cs +++ b/DorkNet.Server/Controllers/API/Pageview/PageviewController.cs @@ -41,9 +41,9 @@ public class PageviewController(ILogger logger) : Controller /// intentionally return an empty url + a 1-hour freshness — the /// private server has no in-app deep-link system, so there's /// nothing to navigate to and no reason to be polled more often. - [HttpPost("pageview/consume")] + [HttpPost("pageview/consume", Order = 10)] [HttpPost("/pageview/consume")] - [HttpGet("pageview/consume")] + [HttpGet("pageview/consume", Order = 10)] [HttpGet("/pageview/consume")] public IActionResult Consume() { diff --git a/DorkNet.Server/Controllers/Auth/AuthAccountController.cs b/DorkNet.Server/Controllers/Auth/AuthAccountController.cs index 767be6c..c2dc9ea 100644 --- a/DorkNet.Server/Controllers/Auth/AuthAccountController.cs +++ b/DorkNet.Server/Controllers/Auth/AuthAccountController.cs @@ -23,7 +23,7 @@ public class AuthAccountController(DorkNetDbContext db) : ControllerBase /// ExpectPrimitiveResponse<bool> so the body must be /// the literal JSON true or false. [HttpGet("/account/me/haspassword")] - [HttpGet("/api/platformlogin/haspassword")] + [HttpGet("/api/platformlogin/haspassword", Order = 10)] [Authorize] public async Task HasPassword() { @@ -40,8 +40,8 @@ public class AuthAccountController(DorkNetDbContext db) : ControllerBase /// one; first-time creation skips that check. Form-encoded body. [HttpPost("/account/me/changepassword")] [HttpPost("/account/me/createpassword")] - [HttpPost("/api/platformlogin/password")] - [HttpPut("/api/platformlogin/password")] + [HttpPost("/api/platformlogin/password", Order = 10)] + [HttpPut("/api/platformlogin/password", Order = 10)] [Consumes("application/x-www-form-urlencoded", "multipart/form-data")] [Authorize] public async Task ChangePassword( @@ -76,7 +76,7 @@ public class AuthAccountController(DorkNetDbContext db) : ControllerBase /// account from the admin tab and the user re-creates it via /// changepassword on next login. Anonymous calls 401. [HttpPost("/account/me/resetpassword")] - [HttpPost("/api/platformlogin/resetpassword")] + [HttpPost("/api/platformlogin/resetpassword", Order = 10)] [Authorize] [AdminOnly] public async Task AdminResetPassword( diff --git a/DorkNet.Server/Controllers/Clubs/ClubsController.cs b/DorkNet.Server/Controllers/Clubs/ClubsController.cs index 1fe0536..bc93560 100644 --- a/DorkNet.Server/Controllers/Clubs/ClubsController.cs +++ b/DorkNet.Server/Controllers/Clubs/ClubsController.cs @@ -668,11 +668,14 @@ public class ClubsController(ClubService clubs) : ControllerBase [HttpPost("/members/bulk")] public async Task MembersBulk() { + var form = Request.HasFormContentType ? Request.Form : null; if (!long.TryParse(Request.Query["clubId"].ToString(), out var clubId) - && !long.TryParse(Request.Form["clubId"].ToString(), out clubId)) + && (form is null || !long.TryParse(form["clubId"].ToString(), out clubId))) return BadRequest(new { error = "missing_club_id" }); - var idsRaw = Request.Query["playerIds"].Concat(Request.Form["playerIds"]); + var idsRaw = form is null + ? Request.Query["playerIds"].AsEnumerable() + : Request.Query["playerIds"].Concat(form["playerIds"]); var ids = idsRaw .SelectMany(v => (v ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) .Select(s => long.TryParse(s, out var n) ? n : 0) diff --git a/DorkNet.Server/Controllers/Match/MatchPlayerController.cs b/DorkNet.Server/Controllers/Match/MatchPlayerController.cs index b986621..883de03 100644 --- a/DorkNet.Server/Controllers/Match/MatchPlayerController.cs +++ b/DorkNet.Server/Controllers/Match/MatchPlayerController.cs @@ -223,9 +223,12 @@ public class MatchPlayerController( var playerId = TryGetCurrentPlayerId(); if (playerId == 0) return Ok(); - var form = Request.Form; - var regions = form["region"].ToArray(); - var pings = form["ping"].ToArray(); + var regions = Request.HasFormContentType + ? Request.Form["region"].ToArray() + : Request.Query["region"].ToArray(); + var pings = Request.HasFormContentType + ? Request.Form["ping"].ToArray() + : Request.Query["ping"].ToArray(); var pairs = new Dictionary(StringComparer.OrdinalIgnoreCase); for (var i = 0; i < Math.Min(regions.Length, pings.Length); i++) { diff --git a/DorkNet.Server/Program.cs b/DorkNet.Server/Program.cs index 2e528cc..601f981 100644 --- a/DorkNet.Server/Program.cs +++ b/DorkNet.Server/Program.cs @@ -15,3 +15,5 @@ var app = builder.Build(); await app.RunDatabaseBootstrapAsync(); app.UseDorkNetPipeline(); app.Run(); + +public partial class Program; diff --git a/DorkNet.ServiceDefaults/DorkNetServiceDefaultsExtensions.cs b/DorkNet.ServiceDefaults/DorkNetServiceDefaultsExtensions.cs index 19ae748..8de699d 100644 --- a/DorkNet.ServiceDefaults/DorkNetServiceDefaultsExtensions.cs +++ b/DorkNet.ServiceDefaults/DorkNetServiceDefaultsExtensions.cs @@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; namespace DorkNet.ServiceDefaults; @@ -21,9 +22,15 @@ public static class DorkNetServiceDefaultsExtensions return builder; } - public static WebApplication MapDorkNetServiceDefaults(this WebApplication app) + public static WebApplication MapDorkNetServiceDefaults( + this WebApplication app, + bool mapPublicHealth = true) { - app.MapHealthChecks("/healthz"); + if (mapPublicHealth) + { + app.MapHealthChecks("/healthz"); + } + app.MapGet("/internal/healthz", (DorkNetServiceInfo info) => { return Results.Ok(new ServiceHealthResponse( @@ -34,4 +41,43 @@ public static class DorkNetServiceDefaultsExtensions return app; } + + public static WebApplication UseDorkNetRouteOwnershipGuard( + this WebApplication app, + string serviceName) + { + app.Use(async (ctx, next) => + { + var domain = Environment.GetEnvironmentVariable("DORKNET_DOMAIN") + ?? ctx.RequestServices.GetService()?["Domain:Apex"] + ?? "localhost"; + var path = ctx.Request.Path.Value ?? "/"; + if (DorkNetRouteOwnership.IsOwnedBy(serviceName, ctx.Request.Host.Host, path, domain)) + { + await next(); + return; + } + + var owner = DorkNetRouteOwnership.ResolvePublicService(ctx.Request.Host.Host, path, domain); + var logger = ctx.RequestServices + .GetRequiredService() + .CreateLogger("DorkNet.ServiceDefaults.RouteGuard"); + logger.LogDebug( + "[route-guard] service={Service} rejected {Method} {Host}{Path}; owner={Owner}", + serviceName, + ctx.Request.Method, + ctx.Request.Host, + ctx.Request.Path, + owner); + ctx.Response.StatusCode = StatusCodes.Status404NotFound; + await ctx.Response.WriteAsJsonAsync(new + { + error = "route_not_owned_by_service", + service = serviceName, + owner, + }); + }); + + return app; + } } diff --git a/DorkNet.Services.Commerce/DorkNet.Services.Commerce.csproj b/DorkNet.Services.Commerce/DorkNet.Services.Commerce.csproj new file mode 100644 index 0000000..6ddeec4 --- /dev/null +++ b/DorkNet.Services.Commerce/DorkNet.Services.Commerce.csproj @@ -0,0 +1,15 @@ + + + + net9.0 + enable + enable + + + + + + + + + diff --git a/DorkNet.Services.Commerce/Program.cs b/DorkNet.Services.Commerce/Program.cs new file mode 100644 index 0000000..5157675 --- /dev/null +++ b/DorkNet.Services.Commerce/Program.cs @@ -0,0 +1,37 @@ +using DorkNet.Contracts; +using DorkNet.ServiceDefaults; +using DorkNet.Server.Startup; + +var builder = WebApplication.CreateBuilder(args); +builder.Configuration.AddJsonFile("appsettings.Local.json", + optional: true, reloadOnChange: true); + +builder.AddDorkNetServiceDefaults(ServiceNames.Commerce); +builder.AddDorkNetServices(); + +var app = builder.Build(); + +await app.RunDatabaseBootstrapAsync(); +app.MapDorkNetServiceDefaults(mapPublicHealth: false); +app.MapGet("/internal/commerce/capabilities", () => + Results.Ok(new ServiceCapabilityResponse( + ServiceNames.Commerce, + [ + "catalog", + "storefronts", + "econ", + "inventory", + "inventions", + ], + [ + "/api/catalog/*", + "/api/storefronts/*", + "/econ/*", + "/api/equipment/*", + "/api/inventions/*", + ]))); + +app.UseDorkNetRouteOwnershipGuard(ServiceNames.Commerce); +app.UseDorkNetPipeline(); + +app.Run(); diff --git a/DorkNet.Services.Commerce/appsettings.json b/DorkNet.Services.Commerce/appsettings.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/DorkNet.Services.Commerce/appsettings.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/DorkNet.Services.Content/DorkNet.Services.Content.csproj b/DorkNet.Services.Content/DorkNet.Services.Content.csproj new file mode 100644 index 0000000..6ddeec4 --- /dev/null +++ b/DorkNet.Services.Content/DorkNet.Services.Content.csproj @@ -0,0 +1,15 @@ + + + + net9.0 + enable + enable + + + + + + + + + diff --git a/DorkNet.Services.Content/Program.cs b/DorkNet.Services.Content/Program.cs new file mode 100644 index 0000000..d7c091f --- /dev/null +++ b/DorkNet.Services.Content/Program.cs @@ -0,0 +1,37 @@ +using DorkNet.Contracts; +using DorkNet.ServiceDefaults; +using DorkNet.Server.Startup; + +var builder = WebApplication.CreateBuilder(args); +builder.Configuration.AddJsonFile("appsettings.Local.json", + optional: true, reloadOnChange: true); + +builder.AddDorkNetServiceDefaults(ServiceNames.Content); +builder.AddDorkNetServices(); + +var app = builder.Build(); + +await app.RunDatabaseBootstrapAsync(); +app.MapDorkNetServiceDefaults(mapPublicHealth: false); +app.MapGet("/internal/content/capabilities", () => + Results.Ok(new ServiceCapabilityResponse( + ServiceNames.Content, + [ + "cdn", + "images", + "photos", + "room-blobs", + "storage", + ], + [ + "/api/images/*", + "/api/photos/*", + "/data/*", + "/img/*", + "/upload", + ]))); + +app.UseDorkNetRouteOwnershipGuard(ServiceNames.Content); +app.UseDorkNetPipeline(); + +app.Run(); diff --git a/DorkNet.Services.Content/appsettings.json b/DorkNet.Services.Content/appsettings.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/DorkNet.Services.Content/appsettings.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/DorkNet.Services.Identity/DorkNet.Services.Identity.csproj b/DorkNet.Services.Identity/DorkNet.Services.Identity.csproj index 8e34531..6ddeec4 100644 --- a/DorkNet.Services.Identity/DorkNet.Services.Identity.csproj +++ b/DorkNet.Services.Identity/DorkNet.Services.Identity.csproj @@ -9,6 +9,7 @@ + diff --git a/DorkNet.Services.Identity/Program.cs b/DorkNet.Services.Identity/Program.cs index 1697272..a111de9 100644 --- a/DorkNet.Services.Identity/Program.cs +++ b/DorkNet.Services.Identity/Program.cs @@ -1,15 +1,18 @@ using DorkNet.Contracts; using DorkNet.ServiceDefaults; +using DorkNet.Server.Startup; var builder = WebApplication.CreateBuilder(args); builder.Configuration.AddJsonFile("appsettings.Local.json", optional: true, reloadOnChange: true); builder.AddDorkNetServiceDefaults(ServiceNames.Identity); +builder.AddDorkNetServices(); var app = builder.Build(); -app.MapDorkNetServiceDefaults(); +await app.RunDatabaseBootstrapAsync(); +app.MapDorkNetServiceDefaults(mapPublicHealth: false); app.MapGet("/internal/identity/capabilities", () => Results.Ok(new ServiceCapabilityResponse( ServiceNames.Identity, @@ -26,4 +29,7 @@ app.MapGet("/internal/identity/capabilities", () => "/api/auth/*", ]))); +app.UseDorkNetRouteOwnershipGuard(ServiceNames.Identity); +app.UseDorkNetPipeline(); + app.Run(); diff --git a/DorkNet.Services.Moderation/DorkNet.Services.Moderation.csproj b/DorkNet.Services.Moderation/DorkNet.Services.Moderation.csproj new file mode 100644 index 0000000..6ddeec4 --- /dev/null +++ b/DorkNet.Services.Moderation/DorkNet.Services.Moderation.csproj @@ -0,0 +1,15 @@ + + + + net9.0 + enable + enable + + + + + + + + + diff --git a/DorkNet.Services.Moderation/Program.cs b/DorkNet.Services.Moderation/Program.cs new file mode 100644 index 0000000..954290e --- /dev/null +++ b/DorkNet.Services.Moderation/Program.cs @@ -0,0 +1,37 @@ +using DorkNet.Contracts; +using DorkNet.ServiceDefaults; +using DorkNet.Server.Startup; + +var builder = WebApplication.CreateBuilder(args); +builder.Configuration.AddJsonFile("appsettings.Local.json", + optional: true, reloadOnChange: true); + +builder.AddDorkNetServiceDefaults(ServiceNames.Moderation); +builder.AddDorkNetServices(); + +var app = builder.Build(); + +await app.RunDatabaseBootstrapAsync(); +app.MapDorkNetServiceDefaults(mapPublicHealth: false); +app.MapGet("/internal/moderation/capabilities", () => + Results.Ok(new ServiceCapabilityResponse( + ServiceNames.Moderation, + [ + "bug-reporting", + "player-reporting", + "sanitize", + "admin-api", + "testcase-management", + ], + [ + "/api/bugreporting/*", + "/api/playerreporting/*", + "/api/sanitize/*", + "/api/admin/*", + "/api/testcasemanagement/*", + ]))); + +app.UseDorkNetRouteOwnershipGuard(ServiceNames.Moderation); +app.UseDorkNetPipeline(); + +app.Run(); diff --git a/DorkNet.Services.Moderation/appsettings.json b/DorkNet.Services.Moderation/appsettings.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/DorkNet.Services.Moderation/appsettings.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/DorkNet.Services.Notify/DorkNet.Services.Notify.csproj b/DorkNet.Services.Notify/DorkNet.Services.Notify.csproj index 8e34531..6ddeec4 100644 --- a/DorkNet.Services.Notify/DorkNet.Services.Notify.csproj +++ b/DorkNet.Services.Notify/DorkNet.Services.Notify.csproj @@ -9,6 +9,7 @@ + diff --git a/DorkNet.Services.Notify/Program.cs b/DorkNet.Services.Notify/Program.cs index 4e2f842..5500162 100644 --- a/DorkNet.Services.Notify/Program.cs +++ b/DorkNet.Services.Notify/Program.cs @@ -1,15 +1,18 @@ using DorkNet.Contracts; using DorkNet.ServiceDefaults; +using DorkNet.Server.Startup; var builder = WebApplication.CreateBuilder(args); builder.Configuration.AddJsonFile("appsettings.Local.json", optional: true, reloadOnChange: true); builder.AddDorkNetServiceDefaults(ServiceNames.Notify); +builder.AddDorkNetServices(); var app = builder.Build(); -app.MapDorkNetServiceDefaults(); +await app.RunDatabaseBootstrapAsync(); +app.MapDorkNetServiceDefaults(mapPublicHealth: false); app.MapGet("/internal/notify/capabilities", () => Results.Ok(new ServiceCapabilityResponse( ServiceNames.Notify, @@ -25,4 +28,7 @@ app.MapGet("/internal/notify/capabilities", () => "/player/*presence*", ]))); +app.UseDorkNetRouteOwnershipGuard(ServiceNames.Notify); +app.UseDorkNetPipeline(); + app.Run(); diff --git a/DorkNet.Services.Platform/DorkNet.Services.Platform.csproj b/DorkNet.Services.Platform/DorkNet.Services.Platform.csproj new file mode 100644 index 0000000..6ddeec4 --- /dev/null +++ b/DorkNet.Services.Platform/DorkNet.Services.Platform.csproj @@ -0,0 +1,15 @@ + + + + net9.0 + enable + enable + + + + + + + + + diff --git a/DorkNet.Services.Platform/Program.cs b/DorkNet.Services.Platform/Program.cs new file mode 100644 index 0000000..fe5e38e --- /dev/null +++ b/DorkNet.Services.Platform/Program.cs @@ -0,0 +1,37 @@ +using DorkNet.Contracts; +using DorkNet.ServiceDefaults; +using DorkNet.Server.Startup; + +var builder = WebApplication.CreateBuilder(args); +builder.Configuration.AddJsonFile("appsettings.Local.json", + optional: true, reloadOnChange: true); + +builder.AddDorkNetServiceDefaults(ServiceNames.Platform); +builder.AddDorkNetServices(); + +var app = builder.Build(); + +await app.RunDatabaseBootstrapAsync(); +app.MapDorkNetServiceDefaults(mapPublicHealth: false); +app.MapGet("/internal/platform/capabilities", () => + Results.Ok(new ServiceCapabilityResponse( + ServiceNames.Platform, + [ + "service-directory", + "config", + "version-check", + "geo", + "strings", + ], + [ + "/v1/services", + "/api/config/*", + "/api/versioncheck/*", + "/v1/regions", + "/strings/*", + ]))); + +app.UseDorkNetRouteOwnershipGuard(ServiceNames.Platform); +app.UseDorkNetPipeline(); + +app.Run(); diff --git a/DorkNet.Services.Platform/appsettings.json b/DorkNet.Services.Platform/appsettings.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/DorkNet.Services.Platform/appsettings.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/DorkNet.Services.Rooms/DorkNet.Services.Rooms.csproj b/DorkNet.Services.Rooms/DorkNet.Services.Rooms.csproj index 8e34531..6ddeec4 100644 --- a/DorkNet.Services.Rooms/DorkNet.Services.Rooms.csproj +++ b/DorkNet.Services.Rooms/DorkNet.Services.Rooms.csproj @@ -9,6 +9,7 @@ + diff --git a/DorkNet.Services.Rooms/Program.cs b/DorkNet.Services.Rooms/Program.cs index ed11f71..1990c87 100644 --- a/DorkNet.Services.Rooms/Program.cs +++ b/DorkNet.Services.Rooms/Program.cs @@ -1,15 +1,18 @@ using DorkNet.Contracts; using DorkNet.ServiceDefaults; +using DorkNet.Server.Startup; var builder = WebApplication.CreateBuilder(args); builder.Configuration.AddJsonFile("appsettings.Local.json", optional: true, reloadOnChange: true); builder.AddDorkNetServiceDefaults(ServiceNames.Rooms); +builder.AddDorkNetServices(); var app = builder.Build(); -app.MapDorkNetServiceDefaults(); +await app.RunDatabaseBootstrapAsync(); +app.MapDorkNetServiceDefaults(mapPublicHealth: false); app.MapGet("/internal/rooms/capabilities", () => Results.Ok(new ServiceCapabilityResponse( ServiceNames.Rooms, @@ -28,4 +31,7 @@ app.MapGet("/internal/rooms/capabilities", () => "/v1/join/*", ]))); +app.UseDorkNetRouteOwnershipGuard(ServiceNames.Rooms); +app.UseDorkNetPipeline(); + app.Run(); diff --git a/DorkNet.Services.Social/DorkNet.Services.Social.csproj b/DorkNet.Services.Social/DorkNet.Services.Social.csproj new file mode 100644 index 0000000..6ddeec4 --- /dev/null +++ b/DorkNet.Services.Social/DorkNet.Services.Social.csproj @@ -0,0 +1,15 @@ + + + + net9.0 + enable + enable + + + + + + + + + diff --git a/DorkNet.Services.Social/Program.cs b/DorkNet.Services.Social/Program.cs new file mode 100644 index 0000000..5b4598e --- /dev/null +++ b/DorkNet.Services.Social/Program.cs @@ -0,0 +1,37 @@ +using DorkNet.Contracts; +using DorkNet.ServiceDefaults; +using DorkNet.Server.Startup; + +var builder = WebApplication.CreateBuilder(args); +builder.Configuration.AddJsonFile("appsettings.Local.json", + optional: true, reloadOnChange: true); + +builder.AddDorkNetServiceDefaults(ServiceNames.Social); +builder.AddDorkNetServices(); + +var app = builder.Build(); + +await app.RunDatabaseBootstrapAsync(); +app.MapDorkNetServiceDefaults(mapPublicHealth: false); +app.MapGet("/internal/social/capabilities", () => + Results.Ok(new ServiceCapabilityResponse( + ServiceNames.Social, + [ + "clubs", + "groups", + "announcements", + "player-events", + "subscriptions", + ], + [ + "/club/*", + "/api/groups/*", + "/api/playerevents/*", + "/api/playersubscriptions/*", + "/announcements/*", + ]))); + +app.UseDorkNetRouteOwnershipGuard(ServiceNames.Social); +app.UseDorkNetPipeline(); + +app.Run(); diff --git a/DorkNet.Services.Social/appsettings.json b/DorkNet.Services.Social/appsettings.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/DorkNet.Services.Social/appsettings.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/DorkNet.Services.Web/DorkNet.Services.Web.csproj b/DorkNet.Services.Web/DorkNet.Services.Web.csproj new file mode 100644 index 0000000..6ddeec4 --- /dev/null +++ b/DorkNet.Services.Web/DorkNet.Services.Web.csproj @@ -0,0 +1,15 @@ + + + + net9.0 + enable + enable + + + + + + + + + diff --git a/DorkNet.Services.Web/Program.cs b/DorkNet.Services.Web/Program.cs new file mode 100644 index 0000000..08b83d6 --- /dev/null +++ b/DorkNet.Services.Web/Program.cs @@ -0,0 +1,36 @@ +using DorkNet.Contracts; +using DorkNet.ServiceDefaults; +using DorkNet.Server.Startup; + +var builder = WebApplication.CreateBuilder(args); +builder.Configuration.AddJsonFile("appsettings.Local.json", + optional: true, reloadOnChange: true); + +builder.AddDorkNetServiceDefaults(ServiceNames.Web); +builder.AddDorkNetServices(); + +var app = builder.Build(); + +await app.RunDatabaseBootstrapAsync(); +app.MapDorkNetServiceDefaults(mapPublicHealth: false); +app.MapGet("/internal/web/capabilities", () => + Results.Ok(new ServiceCapabilityResponse( + ServiceNames.Web, + [ + "public-site", + "admin-site", + "feed-site", + "site-api", + ], + [ + "/", + "www.*", + "admin.*", + "feed.*", + "/api/site/*", + ]))); + +app.UseDorkNetRouteOwnershipGuard(ServiceNames.Web); +app.UseDorkNetPipeline(); + +app.Run(); diff --git a/DorkNet.Services.Web/appsettings.json b/DorkNet.Services.Web/appsettings.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/DorkNet.Services.Web/appsettings.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/DorkNet.sln b/DorkNet.sln index dfcd048..0aeafdd 100644 --- a/DorkNet.sln +++ b/DorkNet.sln @@ -7,10 +7,6 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DorkNet.Server", "DorkNet.S EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DorkNet.Models", "DorkNet.Models\DorkNet.Models.csproj", "{8A1F0C3A-5763-4176-B33E-F4EA89BAF999}" EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tools", "Tools", "{07C2787E-EAC7-C090-1BA3-A61EC2A24D84}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MigrateSqliteToPostgres", "Tools\MigrateSqliteToPostgres\MigrateSqliteToPostgres.csproj", "{70C60098-1B11-4C13-943B-A8A056429EAE}" -EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DorkNet.AdminMobile", "DorkNet.AdminMobile\DorkNet.AdminMobile.csproj", "{C2265F00-DFC5-4352-A94C-B887E3C0E5CA}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DorkNet.Contracts", "DorkNet.Contracts\DorkNet.Contracts.csproj", "{7D9E995E-5333-43B2-848B-3522C6C3D893}" @@ -25,6 +21,20 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DorkNet.Services.Rooms", "D EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DorkNet.Services.Notify", "DorkNet.Services.Notify\DorkNet.Services.Notify.csproj", "{5A6D7277-0974-4F12-AD9D-C705F5CFE19C}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DorkNet.Server.Tests", "DorkNet.Server.Tests\DorkNet.Server.Tests.csproj", "{43230177-A02F-440A-9B59-B79AB5E7BC27}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DorkNet.Services.Content", "DorkNet.Services.Content\DorkNet.Services.Content.csproj", "{8958DA76-3925-4F12-8884-DE2E79FE9677}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DorkNet.Services.Social", "DorkNet.Services.Social\DorkNet.Services.Social.csproj", "{C6DBC037-1E6B-4B2F-8490-08A6F9380E00}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DorkNet.Services.Commerce", "DorkNet.Services.Commerce\DorkNet.Services.Commerce.csproj", "{77BBD5A3-A610-4A20-A61A-B4AB56291C27}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DorkNet.Services.Platform", "DorkNet.Services.Platform\DorkNet.Services.Platform.csproj", "{C68AA2CA-9E80-46E5-9EE0-1544CE070036}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DorkNet.Services.Moderation", "DorkNet.Services.Moderation\DorkNet.Services.Moderation.csproj", "{1D44C401-17B8-4314-9425-AE7A2CEDBBDE}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DorkNet.Services.Web", "DorkNet.Services.Web\DorkNet.Services.Web.csproj", "{F29F1B14-B61E-47CC-820A-C98BAE8F21DB}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -59,18 +69,6 @@ Global {8A1F0C3A-5763-4176-B33E-F4EA89BAF999}.Release|x64.Build.0 = Release|Any CPU {8A1F0C3A-5763-4176-B33E-F4EA89BAF999}.Release|x86.ActiveCfg = Release|Any CPU {8A1F0C3A-5763-4176-B33E-F4EA89BAF999}.Release|x86.Build.0 = Release|Any CPU - {70C60098-1B11-4C13-943B-A8A056429EAE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {70C60098-1B11-4C13-943B-A8A056429EAE}.Debug|Any CPU.Build.0 = Debug|Any CPU - {70C60098-1B11-4C13-943B-A8A056429EAE}.Debug|x64.ActiveCfg = Debug|Any CPU - {70C60098-1B11-4C13-943B-A8A056429EAE}.Debug|x64.Build.0 = Debug|Any CPU - {70C60098-1B11-4C13-943B-A8A056429EAE}.Debug|x86.ActiveCfg = Debug|Any CPU - {70C60098-1B11-4C13-943B-A8A056429EAE}.Debug|x86.Build.0 = Debug|Any CPU - {70C60098-1B11-4C13-943B-A8A056429EAE}.Release|Any CPU.ActiveCfg = Release|Any CPU - {70C60098-1B11-4C13-943B-A8A056429EAE}.Release|Any CPU.Build.0 = Release|Any CPU - {70C60098-1B11-4C13-943B-A8A056429EAE}.Release|x64.ActiveCfg = Release|Any CPU - {70C60098-1B11-4C13-943B-A8A056429EAE}.Release|x64.Build.0 = Release|Any CPU - {70C60098-1B11-4C13-943B-A8A056429EAE}.Release|x86.ActiveCfg = Release|Any CPU - {70C60098-1B11-4C13-943B-A8A056429EAE}.Release|x86.Build.0 = Release|Any CPU {C2265F00-DFC5-4352-A94C-B887E3C0E5CA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {C2265F00-DFC5-4352-A94C-B887E3C0E5CA}.Debug|Any CPU.Build.0 = Debug|Any CPU {C2265F00-DFC5-4352-A94C-B887E3C0E5CA}.Debug|x64.ActiveCfg = Debug|Any CPU @@ -155,11 +153,92 @@ Global {5A6D7277-0974-4F12-AD9D-C705F5CFE19C}.Release|x64.Build.0 = Release|Any CPU {5A6D7277-0974-4F12-AD9D-C705F5CFE19C}.Release|x86.ActiveCfg = Release|Any CPU {5A6D7277-0974-4F12-AD9D-C705F5CFE19C}.Release|x86.Build.0 = Release|Any CPU + {43230177-A02F-440A-9B59-B79AB5E7BC27}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {43230177-A02F-440A-9B59-B79AB5E7BC27}.Debug|Any CPU.Build.0 = Debug|Any CPU + {43230177-A02F-440A-9B59-B79AB5E7BC27}.Debug|x64.ActiveCfg = Debug|Any CPU + {43230177-A02F-440A-9B59-B79AB5E7BC27}.Debug|x64.Build.0 = Debug|Any CPU + {43230177-A02F-440A-9B59-B79AB5E7BC27}.Debug|x86.ActiveCfg = Debug|Any CPU + {43230177-A02F-440A-9B59-B79AB5E7BC27}.Debug|x86.Build.0 = Debug|Any CPU + {43230177-A02F-440A-9B59-B79AB5E7BC27}.Release|Any CPU.ActiveCfg = Release|Any CPU + {43230177-A02F-440A-9B59-B79AB5E7BC27}.Release|Any CPU.Build.0 = Release|Any CPU + {43230177-A02F-440A-9B59-B79AB5E7BC27}.Release|x64.ActiveCfg = Release|Any CPU + {43230177-A02F-440A-9B59-B79AB5E7BC27}.Release|x64.Build.0 = Release|Any CPU + {43230177-A02F-440A-9B59-B79AB5E7BC27}.Release|x86.ActiveCfg = Release|Any CPU + {43230177-A02F-440A-9B59-B79AB5E7BC27}.Release|x86.Build.0 = Release|Any CPU + {8958DA76-3925-4F12-8884-DE2E79FE9677}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8958DA76-3925-4F12-8884-DE2E79FE9677}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8958DA76-3925-4F12-8884-DE2E79FE9677}.Debug|x64.ActiveCfg = Debug|Any CPU + {8958DA76-3925-4F12-8884-DE2E79FE9677}.Debug|x64.Build.0 = Debug|Any CPU + {8958DA76-3925-4F12-8884-DE2E79FE9677}.Debug|x86.ActiveCfg = Debug|Any CPU + {8958DA76-3925-4F12-8884-DE2E79FE9677}.Debug|x86.Build.0 = Debug|Any CPU + {8958DA76-3925-4F12-8884-DE2E79FE9677}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8958DA76-3925-4F12-8884-DE2E79FE9677}.Release|Any CPU.Build.0 = Release|Any CPU + {8958DA76-3925-4F12-8884-DE2E79FE9677}.Release|x64.ActiveCfg = Release|Any CPU + {8958DA76-3925-4F12-8884-DE2E79FE9677}.Release|x64.Build.0 = Release|Any CPU + {8958DA76-3925-4F12-8884-DE2E79FE9677}.Release|x86.ActiveCfg = Release|Any CPU + {8958DA76-3925-4F12-8884-DE2E79FE9677}.Release|x86.Build.0 = Release|Any CPU + {C6DBC037-1E6B-4B2F-8490-08A6F9380E00}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C6DBC037-1E6B-4B2F-8490-08A6F9380E00}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C6DBC037-1E6B-4B2F-8490-08A6F9380E00}.Debug|x64.ActiveCfg = Debug|Any CPU + {C6DBC037-1E6B-4B2F-8490-08A6F9380E00}.Debug|x64.Build.0 = Debug|Any CPU + {C6DBC037-1E6B-4B2F-8490-08A6F9380E00}.Debug|x86.ActiveCfg = Debug|Any CPU + {C6DBC037-1E6B-4B2F-8490-08A6F9380E00}.Debug|x86.Build.0 = Debug|Any CPU + {C6DBC037-1E6B-4B2F-8490-08A6F9380E00}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C6DBC037-1E6B-4B2F-8490-08A6F9380E00}.Release|Any CPU.Build.0 = Release|Any CPU + {C6DBC037-1E6B-4B2F-8490-08A6F9380E00}.Release|x64.ActiveCfg = Release|Any CPU + {C6DBC037-1E6B-4B2F-8490-08A6F9380E00}.Release|x64.Build.0 = Release|Any CPU + {C6DBC037-1E6B-4B2F-8490-08A6F9380E00}.Release|x86.ActiveCfg = Release|Any CPU + {C6DBC037-1E6B-4B2F-8490-08A6F9380E00}.Release|x86.Build.0 = Release|Any CPU + {77BBD5A3-A610-4A20-A61A-B4AB56291C27}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {77BBD5A3-A610-4A20-A61A-B4AB56291C27}.Debug|Any CPU.Build.0 = Debug|Any CPU + {77BBD5A3-A610-4A20-A61A-B4AB56291C27}.Debug|x64.ActiveCfg = Debug|Any CPU + {77BBD5A3-A610-4A20-A61A-B4AB56291C27}.Debug|x64.Build.0 = Debug|Any CPU + {77BBD5A3-A610-4A20-A61A-B4AB56291C27}.Debug|x86.ActiveCfg = Debug|Any CPU + {77BBD5A3-A610-4A20-A61A-B4AB56291C27}.Debug|x86.Build.0 = Debug|Any CPU + {77BBD5A3-A610-4A20-A61A-B4AB56291C27}.Release|Any CPU.ActiveCfg = Release|Any CPU + {77BBD5A3-A610-4A20-A61A-B4AB56291C27}.Release|Any CPU.Build.0 = Release|Any CPU + {77BBD5A3-A610-4A20-A61A-B4AB56291C27}.Release|x64.ActiveCfg = Release|Any CPU + {77BBD5A3-A610-4A20-A61A-B4AB56291C27}.Release|x64.Build.0 = Release|Any CPU + {77BBD5A3-A610-4A20-A61A-B4AB56291C27}.Release|x86.ActiveCfg = Release|Any CPU + {77BBD5A3-A610-4A20-A61A-B4AB56291C27}.Release|x86.Build.0 = Release|Any CPU + {C68AA2CA-9E80-46E5-9EE0-1544CE070036}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C68AA2CA-9E80-46E5-9EE0-1544CE070036}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C68AA2CA-9E80-46E5-9EE0-1544CE070036}.Debug|x64.ActiveCfg = Debug|Any CPU + {C68AA2CA-9E80-46E5-9EE0-1544CE070036}.Debug|x64.Build.0 = Debug|Any CPU + {C68AA2CA-9E80-46E5-9EE0-1544CE070036}.Debug|x86.ActiveCfg = Debug|Any CPU + {C68AA2CA-9E80-46E5-9EE0-1544CE070036}.Debug|x86.Build.0 = Debug|Any CPU + {C68AA2CA-9E80-46E5-9EE0-1544CE070036}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C68AA2CA-9E80-46E5-9EE0-1544CE070036}.Release|Any CPU.Build.0 = Release|Any CPU + {C68AA2CA-9E80-46E5-9EE0-1544CE070036}.Release|x64.ActiveCfg = Release|Any CPU + {C68AA2CA-9E80-46E5-9EE0-1544CE070036}.Release|x64.Build.0 = Release|Any CPU + {C68AA2CA-9E80-46E5-9EE0-1544CE070036}.Release|x86.ActiveCfg = Release|Any CPU + {C68AA2CA-9E80-46E5-9EE0-1544CE070036}.Release|x86.Build.0 = Release|Any CPU + {1D44C401-17B8-4314-9425-AE7A2CEDBBDE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1D44C401-17B8-4314-9425-AE7A2CEDBBDE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1D44C401-17B8-4314-9425-AE7A2CEDBBDE}.Debug|x64.ActiveCfg = Debug|Any CPU + {1D44C401-17B8-4314-9425-AE7A2CEDBBDE}.Debug|x64.Build.0 = Debug|Any CPU + {1D44C401-17B8-4314-9425-AE7A2CEDBBDE}.Debug|x86.ActiveCfg = Debug|Any CPU + {1D44C401-17B8-4314-9425-AE7A2CEDBBDE}.Debug|x86.Build.0 = Debug|Any CPU + {1D44C401-17B8-4314-9425-AE7A2CEDBBDE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1D44C401-17B8-4314-9425-AE7A2CEDBBDE}.Release|Any CPU.Build.0 = Release|Any CPU + {1D44C401-17B8-4314-9425-AE7A2CEDBBDE}.Release|x64.ActiveCfg = Release|Any CPU + {1D44C401-17B8-4314-9425-AE7A2CEDBBDE}.Release|x64.Build.0 = Release|Any CPU + {1D44C401-17B8-4314-9425-AE7A2CEDBBDE}.Release|x86.ActiveCfg = Release|Any CPU + {1D44C401-17B8-4314-9425-AE7A2CEDBBDE}.Release|x86.Build.0 = Release|Any CPU + {F29F1B14-B61E-47CC-820A-C98BAE8F21DB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F29F1B14-B61E-47CC-820A-C98BAE8F21DB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F29F1B14-B61E-47CC-820A-C98BAE8F21DB}.Debug|x64.ActiveCfg = Debug|Any CPU + {F29F1B14-B61E-47CC-820A-C98BAE8F21DB}.Debug|x64.Build.0 = Debug|Any CPU + {F29F1B14-B61E-47CC-820A-C98BAE8F21DB}.Debug|x86.ActiveCfg = Debug|Any CPU + {F29F1B14-B61E-47CC-820A-C98BAE8F21DB}.Debug|x86.Build.0 = Debug|Any CPU + {F29F1B14-B61E-47CC-820A-C98BAE8F21DB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F29F1B14-B61E-47CC-820A-C98BAE8F21DB}.Release|Any CPU.Build.0 = Release|Any CPU + {F29F1B14-B61E-47CC-820A-C98BAE8F21DB}.Release|x64.ActiveCfg = Release|Any CPU + {F29F1B14-B61E-47CC-820A-C98BAE8F21DB}.Release|x64.Build.0 = Release|Any CPU + {F29F1B14-B61E-47CC-820A-C98BAE8F21DB}.Release|x86.ActiveCfg = Release|Any CPU + {F29F1B14-B61E-47CC-820A-C98BAE8F21DB}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection - GlobalSection(NestedProjects) = preSolution - {70C60098-1B11-4C13-943B-A8A056429EAE} = {07C2787E-EAC7-C090-1BA3-A61EC2A24D84} - EndGlobalSection EndGlobal diff --git a/README.md b/README.md index 0eb468f..3055f4b 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ main also auto-detects your install. | **Version key** (sent as `X-DorkNet-Version` header) | `december_2020_12_18` | | **Version plugin** | `DorkNet.Server/Versions/Late2020/Late2020VersionPlugin.cs` | | **Schema** | One EF `Initial` migration; SQLite + Postgres both work | -| **Runtime topology** | Full API still runs from `DorkNet.Server`; microservice gateway/service hosts are scaffolded for incremental split | +| **Runtime topology** | Standalone `DorkNet.Server`, plus gateway-fronted service slices with monolith fallback | | **Diverges from `march-2020-03-10` by** | ~170 files | Subsystems present on this branch that the March branch doesn't have: @@ -84,7 +84,7 @@ categories / playlists, and starts listening. You should see `Application started.` in the log; `curl http://localhost:8080/healthz` returns 200. -For Docker, Dokploy, and microservices-scaffold deploy notes, see +For Docker, Dokploy, and microservices deploy notes, see [docs/deploy.md](docs/deploy.md). --- @@ -201,10 +201,10 @@ Default `0` (off). |---|---| | `DorkNet.Server/` | ASP.NET Core backend (controllers, services, data, hubs, middleware) | | `DorkNet.Models/` | DTOs shared between server and client mod | -| `DorkNet.Contracts/` | Shared service names, service-map options, and health/probe contracts | -| `DorkNet.ServiceDefaults/` | Shared microservice health, JSON, HTTP client, and service identity setup | -| `DorkNet.Gateway/` | Edge/gateway host scaffold with service-map and service-health endpoints | -| `DorkNet.Services.*` | Initial identity, rooms, and notify service host scaffolds | +| `DorkNet.Contracts/` | Shared service names, route ownership, service-map options, and health/probe contracts | +| `DorkNet.ServiceDefaults/` | Shared service health, JSON, HTTP client, service identity, and route-guard setup | +| `DorkNet.Gateway/` | Edge reverse proxy with service-map, route-table, and service-health endpoints | +| `DorkNet.Services.*` | Gateway-fronted service hosts for identity, rooms, notify, content, social, commerce, platform, moderation, and web | | `DorkNet.ClientMod/` | MelonLoader IL2CPP mod — the client patcher | | `tools/` | Installers (`install-melon.ps1`, `install-legacy-client.ps1`, `remove-eac.ps1`), Cloudflare tunnel templates | | `Dockerfile`, `Dockerfile.service`, `docker-compose.microservices*.yml` | Full-server, local microservices, and Dokploy microservices deployment entrypoints | @@ -213,16 +213,19 @@ See [docs/architecture.md](docs/architecture.md) for the full mental model (project layout, request lifecycle, watch-mirror controller pattern, where to start contributing). -### Microservices scaffold +### Microservices -`docker-compose.microservices.yml` starts the new gateway, identity, -rooms, and notify service hosts plus Compose-managed Postgres and Redis +`docker-compose.microservices.yml` starts the gateway, identity, rooms, +notify, content, social, commerce, platform, moderation, web, and +monolith fallback service hosts plus Compose-managed Postgres and Redis for local testing. `docker-compose.microservices.dokploy.yml` is the Dokploy version; it adds a `cloudflared` sidecar so Cloudflare Tunnel -can route the apex and wildcard hostnames to the gateway without -Dokploy domain rows. These files do **not** replace the full -`DorkNet.Server` runtime yet; the public API is still served by the -monolith while domains are peeled into services incrementally. +can route the apex and wildcard hostnames to the gateway without Dokploy +domain rows. + +The gateway routes owned host/path slices to the dedicated service hosts. +The monolith fallback is still present for unknown route families, so +public client URLs remain stable while the split continues. The compose file intentionally does not start object storage. Point it at your separate S3-compatible instance: diff --git a/docker-compose.microservices.dokploy.yml b/docker-compose.microservices.dokploy.yml index f3e0457..d54741d 100644 --- a/docker-compose.microservices.dokploy.yml +++ b/docker-compose.microservices.dokploy.yml @@ -1,3 +1,14 @@ +x-dorknet-service-env: &dorknet-service-env + DORKNET_DOMAIN: ${DORKNET_DOMAIN:?Set DORKNET_DOMAIN in Dokploy} + DORKNET_JWT_SECRET: ${DORKNET_JWT_SECRET:?Set DORKNET_JWT_SECRET in Dokploy} + Database__Provider: postgres + ConnectionStrings__Default: Host=postgres;Port=5432;Database=dorknet;Username=dorknet;Password=${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in Dokploy} + ConnectionStrings__Redis: redis:6379 + S3__Endpoint: ${DORKNET_S3_ENDPOINT:?Set DORKNET_S3_ENDPOINT in Dokploy} + S3__AccessKey: ${DORKNET_S3_ACCESS_KEY:?Set DORKNET_S3_ACCESS_KEY in Dokploy} + S3__SecretKey: ${DORKNET_S3_SECRET_KEY:?Set DORKNET_S3_SECRET_KEY in Dokploy} + S3__Region: ${DORKNET_S3_REGION:-garage} + services: gateway: build: @@ -11,13 +22,26 @@ services: DorkNet__Services__Identity: http://identity:8080 DorkNet__Services__Rooms: http://rooms:8080 DorkNet__Services__Notify: http://notify:8080 - DorkNet__Services__Monolith: "" + DorkNet__Services__Content: http://content:8080 + DorkNet__Services__Social: http://social:8080 + DorkNet__Services__Commerce: http://commerce:8080 + DorkNet__Services__Platform: http://platform:8080 + DorkNet__Services__Moderation: http://moderation:8080 + DorkNet__Services__Web: http://web:8080 + DorkNet__Services__Monolith: http://monolith:8080 expose: - "8080" depends_on: - identity - rooms - notify + - content + - social + - commerce + - platform + - moderation + - web + - monolith cloudflared: image: cloudflare/cloudflared:latest @@ -33,6 +57,8 @@ services: args: PROJECT_PATH: DorkNet.Services.Identity/DorkNet.Services.Identity.csproj APP_DLL: DorkNet.Services.Identity.dll + environment: + <<: *dorknet-service-env depends_on: - postgres - redis @@ -45,10 +71,7 @@ services: PROJECT_PATH: DorkNet.Services.Rooms/DorkNet.Services.Rooms.csproj APP_DLL: DorkNet.Services.Rooms.dll environment: - S3__Endpoint: ${DORKNET_S3_ENDPOINT:-} - S3__AccessKey: ${DORKNET_S3_ACCESS_KEY:-} - S3__SecretKey: ${DORKNET_S3_SECRET_KEY:-} - S3__Region: ${DORKNET_S3_REGION:-garage} + <<: *dorknet-service-env depends_on: - postgres - redis @@ -60,7 +83,98 @@ services: args: PROJECT_PATH: DorkNet.Services.Notify/DorkNet.Services.Notify.csproj APP_DLL: DorkNet.Services.Notify.dll + environment: + <<: *dorknet-service-env depends_on: + - postgres + - redis + + content: + build: + context: . + dockerfile: Dockerfile.service + args: + PROJECT_PATH: DorkNet.Services.Content/DorkNet.Services.Content.csproj + APP_DLL: DorkNet.Services.Content.dll + environment: + <<: *dorknet-service-env + depends_on: + - postgres + - redis + + social: + build: + context: . + dockerfile: Dockerfile.service + args: + PROJECT_PATH: DorkNet.Services.Social/DorkNet.Services.Social.csproj + APP_DLL: DorkNet.Services.Social.dll + environment: + <<: *dorknet-service-env + depends_on: + - postgres + - redis + + commerce: + build: + context: . + dockerfile: Dockerfile.service + args: + PROJECT_PATH: DorkNet.Services.Commerce/DorkNet.Services.Commerce.csproj + APP_DLL: DorkNet.Services.Commerce.dll + environment: + <<: *dorknet-service-env + depends_on: + - postgres + - redis + + platform: + build: + context: . + dockerfile: Dockerfile.service + args: + PROJECT_PATH: DorkNet.Services.Platform/DorkNet.Services.Platform.csproj + APP_DLL: DorkNet.Services.Platform.dll + environment: + <<: *dorknet-service-env + depends_on: + - postgres + - redis + + moderation: + build: + context: . + dockerfile: Dockerfile.service + args: + PROJECT_PATH: DorkNet.Services.Moderation/DorkNet.Services.Moderation.csproj + APP_DLL: DorkNet.Services.Moderation.dll + environment: + <<: *dorknet-service-env + depends_on: + - postgres + - redis + + web: + build: + context: . + dockerfile: Dockerfile.service + args: + PROJECT_PATH: DorkNet.Services.Web/DorkNet.Services.Web.csproj + APP_DLL: DorkNet.Services.Web.dll + environment: + <<: *dorknet-service-env + depends_on: + - postgres + - redis + + monolith: + build: + context: . + dockerfile: Dockerfile + environment: + <<: *dorknet-service-env + depends_on: + - postgres - redis postgres: @@ -68,7 +182,7 @@ services: environment: POSTGRES_DB: dorknet POSTGRES_USER: dorknet - POSTGRES_PASSWORD: dorknet_dev + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in Dokploy} volumes: - microservices-postgres:/var/lib/postgresql/data diff --git a/docker-compose.microservices.yml b/docker-compose.microservices.yml index d502099..220e076 100644 --- a/docker-compose.microservices.yml +++ b/docker-compose.microservices.yml @@ -1,3 +1,14 @@ +x-dorknet-service-env: &dorknet-service-env + DORKNET_DOMAIN: ${DORKNET_DOMAIN:-localhost} + DORKNET_JWT_SECRET: ${DORKNET_JWT_SECRET:-dorknet-dev-jwt-secret-000000000000000000000000000000000000000000} + Database__Provider: postgres + ConnectionStrings__Default: Host=postgres;Port=5432;Database=dorknet;Username=dorknet;Password=${POSTGRES_PASSWORD:-dorknet_dev} + ConnectionStrings__Redis: redis:6379 + S3__Endpoint: ${DORKNET_S3_ENDPOINT:-} + S3__AccessKey: ${DORKNET_S3_ACCESS_KEY:-} + S3__SecretKey: ${DORKNET_S3_SECRET_KEY:-} + S3__Region: ${DORKNET_S3_REGION:-garage} + services: gateway: build: @@ -7,16 +18,30 @@ services: PROJECT_PATH: DorkNet.Gateway/DorkNet.Gateway.csproj APP_DLL: DorkNet.Gateway.dll environment: + DORKNET_DOMAIN: ${DORKNET_DOMAIN:-localhost} DorkNet__Services__Identity: http://identity:8080 DorkNet__Services__Rooms: http://rooms:8080 DorkNet__Services__Notify: http://notify:8080 - DorkNet__Services__Monolith: "" + DorkNet__Services__Content: http://content:8080 + DorkNet__Services__Social: http://social:8080 + DorkNet__Services__Commerce: http://commerce:8080 + DorkNet__Services__Platform: http://platform:8080 + DorkNet__Services__Moderation: http://moderation:8080 + DorkNet__Services__Web: http://web:8080 + DorkNet__Services__Monolith: http://monolith:8080 ports: - "8080:8080" depends_on: - identity - rooms - notify + - content + - social + - commerce + - platform + - moderation + - web + - monolith identity: build: @@ -25,6 +50,8 @@ services: args: PROJECT_PATH: DorkNet.Services.Identity/DorkNet.Services.Identity.csproj APP_DLL: DorkNet.Services.Identity.dll + environment: + <<: *dorknet-service-env depends_on: - postgres - redis @@ -37,10 +64,7 @@ services: PROJECT_PATH: DorkNet.Services.Rooms/DorkNet.Services.Rooms.csproj APP_DLL: DorkNet.Services.Rooms.dll environment: - S3__Endpoint: ${DORKNET_S3_ENDPOINT:-} - S3__AccessKey: ${DORKNET_S3_ACCESS_KEY:-} - S3__SecretKey: ${DORKNET_S3_SECRET_KEY:-} - S3__Region: ${DORKNET_S3_REGION:-garage} + <<: *dorknet-service-env depends_on: - postgres - redis @@ -52,7 +76,98 @@ services: args: PROJECT_PATH: DorkNet.Services.Notify/DorkNet.Services.Notify.csproj APP_DLL: DorkNet.Services.Notify.dll + environment: + <<: *dorknet-service-env depends_on: + - postgres + - redis + + content: + build: + context: . + dockerfile: Dockerfile.service + args: + PROJECT_PATH: DorkNet.Services.Content/DorkNet.Services.Content.csproj + APP_DLL: DorkNet.Services.Content.dll + environment: + <<: *dorknet-service-env + depends_on: + - postgres + - redis + + social: + build: + context: . + dockerfile: Dockerfile.service + args: + PROJECT_PATH: DorkNet.Services.Social/DorkNet.Services.Social.csproj + APP_DLL: DorkNet.Services.Social.dll + environment: + <<: *dorknet-service-env + depends_on: + - postgres + - redis + + commerce: + build: + context: . + dockerfile: Dockerfile.service + args: + PROJECT_PATH: DorkNet.Services.Commerce/DorkNet.Services.Commerce.csproj + APP_DLL: DorkNet.Services.Commerce.dll + environment: + <<: *dorknet-service-env + depends_on: + - postgres + - redis + + platform: + build: + context: . + dockerfile: Dockerfile.service + args: + PROJECT_PATH: DorkNet.Services.Platform/DorkNet.Services.Platform.csproj + APP_DLL: DorkNet.Services.Platform.dll + environment: + <<: *dorknet-service-env + depends_on: + - postgres + - redis + + moderation: + build: + context: . + dockerfile: Dockerfile.service + args: + PROJECT_PATH: DorkNet.Services.Moderation/DorkNet.Services.Moderation.csproj + APP_DLL: DorkNet.Services.Moderation.dll + environment: + <<: *dorknet-service-env + depends_on: + - postgres + - redis + + web: + build: + context: . + dockerfile: Dockerfile.service + args: + PROJECT_PATH: DorkNet.Services.Web/DorkNet.Services.Web.csproj + APP_DLL: DorkNet.Services.Web.dll + environment: + <<: *dorknet-service-env + depends_on: + - postgres + - redis + + monolith: + build: + context: . + dockerfile: Dockerfile + environment: + <<: *dorknet-service-env + depends_on: + - postgres - redis postgres: @@ -60,7 +175,7 @@ services: environment: POSTGRES_DB: dorknet POSTGRES_USER: dorknet - POSTGRES_PASSWORD: dorknet_dev + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-dorknet_dev} volumes: - microservices-postgres:/var/lib/postgresql/data diff --git a/docs/architecture.md b/docs/architecture.md index b8b2fe8..c9c1b0b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -18,10 +18,16 @@ DorkNet.sln ├── DorkNet.Models Shared DTO + serialization types ├── DorkNet.Contracts Shared service contracts for the service split ├── DorkNet.ServiceDefaults Shared service-host setup -├── DorkNet.Gateway Gateway/edge host scaffold -├── DorkNet.Services.Identity Identity service scaffold -├── DorkNet.Services.Rooms Rooms service scaffold -├── DorkNet.Services.Notify Notify service scaffold +├── DorkNet.Gateway Gateway/edge reverse proxy +├── DorkNet.Services.Identity Auth/accounts service host +├── DorkNet.Services.Rooms Rooms/matchmaking service host +├── DorkNet.Services.Notify Notify/chat service host +├── DorkNet.Services.Content CDN/images/photos/storage service host +├── DorkNet.Services.Social Clubs/groups/events/social service host +├── DorkNet.Services.Commerce Store/econ/inventory service host +├── DorkNet.Services.Platform Service directory/config/platform service host +├── DorkNet.Services.Moderation Bug reporting/moderation service host +├── DorkNet.Services.Web Public/admin/feed web host └── DorkNet.ClientMod MelonLoader 6 IL2CPP mod — the client patcher ``` @@ -29,12 +35,18 @@ DorkNet.sln |---|---|---| | `DorkNet.Server` | REST API, SignalR hub, admin SPA host, static-site host, image CDN, matchmaking, persistence | .NET 9 | | `DorkNet.Models` | DTOs the wire protocol speaks. No project dependencies. | .NET 9 | -| `DorkNet.Contracts` | Service names, service-map options, health responses, probe responses | .NET 9 | -| `DorkNet.ServiceDefaults` | Common microservice health checks, JSON defaults, HTTP clients, service identity | .NET 9 | -| `DorkNet.Gateway` | Edge/gateway scaffold; exposes internal service map and service health probes | .NET 9 | -| `DorkNet.Services.Identity` | Future home for accounts, auth, platform login, JWT issuance | .NET 9 | -| `DorkNet.Services.Rooms` | Future home for rooms, room keys, matchmaking, discovery | .NET 9 | -| `DorkNet.Services.Notify` | Future home for SignalR edge, notification fan-out, presence, request logs | .NET 9 | +| `DorkNet.Contracts` | Service names, route ownership, service-map options, health responses, probe responses | .NET 9 | +| `DorkNet.ServiceDefaults` | Common service health checks, JSON defaults, HTTP clients, service identity, route guard | .NET 9 | +| `DorkNet.Gateway` | Edge reverse proxy; routes host/path slices to services and exposes service health probes | .NET 9 | +| `DorkNet.Services.Identity` | Accounts, auth, platform login, JWT issuance route slice | .NET 9 | +| `DorkNet.Services.Rooms` | Rooms, room keys, matchmaking, discovery route slice | .NET 9 | +| `DorkNet.Services.Notify` | SignalR edge, messages/chat, notification fan-out route slice | .NET 9 | +| `DorkNet.Services.Content` | CDN paths, image/photo APIs, uploads, S3-backed blobs | .NET 9 | +| `DorkNet.Services.Social` | Clubs, groups, announcements, player events, subscriptions | .NET 9 | +| `DorkNet.Services.Commerce` | Catalog, storefronts, econ, inventory, inventions | .NET 9 | +| `DorkNet.Services.Platform` | Service URLs, config, version checks, geo, strings, telemetry-style surfaces | .NET 9 | +| `DorkNet.Services.Moderation` | Bug reporting, player reporting, sanitize, admin API, testcase routes | .NET 9 | +| `DorkNet.Services.Web` | Public site, admin static host, feed static host, site API | .NET 9 | | `DorkNet.ClientMod` | MelonLoader 0.6.x IL2CPP mod — the client patcher, JSON config | .NET 6 | `ClientMod` applies the client-side patches needed to point the 2020 @@ -66,31 +78,42 @@ DorkNet.Server/ ## Runtime topology -`DorkNet.Server` is still the only production-complete runtime. It owns -the full public Rec Room-compatible HTTP surface, SignalR hub, static -hosts, database bootstrap, Redis-backed ephemeral state, and S3-backed -blob storage. +`DorkNet.Server` remains the compatibility fallback and full standalone +runtime. It can still run by itself, serving the full public +Rec Room-compatible HTTP surface, SignalR hub, static hosts, database +bootstrap, Redis-backed ephemeral state, and S3-backed blob storage. -The microservice projects are the migration scaffold. They are wired into -the solution and Docker Compose, but they only expose internal health and -capability endpoints today: +The microservice shape is a gateway-fronted split: + +- `DorkNet.Gateway` reverse-proxies public requests by host/path. +- `DorkNet.Services.Identity`, `DorkNet.Services.Rooms`, + `DorkNet.Services.Notify`, `DorkNet.Services.Content`, + `DorkNet.Services.Social`, `DorkNet.Services.Commerce`, + `DorkNet.Services.Platform`, `DorkNet.Services.Moderation`, and + `DorkNet.Services.Web` run the shared server stack behind a route + ownership guard. That keeps response shapes identical while moving + traffic by domain/path slice. +- `DorkNet.Server` runs as `monolith` behind the gateway for unknown + route families that are not assigned to a dedicated service yet. - `DorkNet.Gateway` exposes `/internal/services` and - `/internal/services/health`. + `/internal/services/health`, plus `/internal/routes` for the active + proxy table. - `DorkNet.Services.Identity` exposes `/internal/identity/capabilities`. - `DorkNet.Services.Rooms` exposes `/internal/rooms/capabilities`. - `DorkNet.Services.Notify` exposes `/internal/notify/capabilities`. +- The other slices expose matching `/internal/{service}/capabilities` + endpoints. -`docker-compose.microservices.yml` starts gateway, identity, rooms, and -notify alongside Compose-managed Postgres and Redis for local testing. +`docker-compose.microservices.yml` starts gateway, all dedicated service +slices, the monolith fallback, and Compose-managed Postgres and Redis for +local testing. `docker-compose.microservices.dokploy.yml` is the Dokploy shape: it adds a `cloudflared` sidecar that joins the same Compose network and forwards Cloudflare Tunnel traffic to `http://gateway:8080`. Object storage is external S3-compatible storage configured with `DORKNET_S3_*` -environment variables; the compose files do not run MinIO or Garage. The -next migration step is to move one public route group at a time behind -the gateway while preserving existing URLs and response shapes. +environment variables; the compose files do not run MinIO or Garage. ### Controllers/ groupings @@ -277,10 +300,11 @@ worse. intentional — the decompiled source is provenance only, not shippable. Comments give a future contributor a breadcrumb to re-derive the wire shape if the decompile changes. -- **Microservices are scaffolded, not fully cut over.** The gateway and - service hosts compile and run, but the public API still lives in - `DorkNet.Server`. Move endpoints by domain slice, not by copying random - controllers. +- **Microservices use shared server code during extraction.** The + dedicated service hosts run the proven controller/service stack behind + route guards instead of copying controllers into new projects. This + keeps the client contract stable while route families are peeled away + from the monolith fallback. - **Two-provider EF.** SQLite and Postgres each have their own EnsureCreated/Migrate dance because some Postgres-only column types drift the model snapshot. The split is annoying but lets one diff --git a/docs/deploy.md b/docs/deploy.md index 4d3aecb..f007725 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -5,13 +5,12 @@ This branch has two deployable shapes: - **Production/full API:** build the root `Dockerfile`, which runs `DorkNet.Server`. This is still the runtime that serves every public Rec Room-compatible endpoint. -- **Microservices scaffold:** deploy `docker-compose.microservices.yml` +- **Microservices:** deploy `docker-compose.microservices.yml` for local testing or `docker-compose.microservices.dokploy.yml` on - Dokploy. Both start `DorkNet.Gateway`, - `DorkNet.Services.Identity`, `DorkNet.Services.Rooms`, - `DorkNet.Services.Notify`, Postgres, and Redis. These hosts currently - expose internal health/capability endpoints only; public routes move - behind the gateway incrementally. + Dokploy. Both start `DorkNet.Gateway`, the dedicated service slices, + the `DorkNet.Server` monolith fallback, Postgres, and Redis. The + gateway routes public traffic to owned service slices and sends + unknown route families to the fallback. Object storage is always a **separate S3-compatible instance**. The microservices compose files do not start MinIO/Garage. @@ -120,10 +119,9 @@ service URLs returned to the client by `ConfigService`. --- -## Dokploy: microservices scaffold +## Dokploy: microservices -Use this to run the new gateway/service-host layout. It is not yet a -replacement for the full public API. +Use this to run the gateway/service-host layout. Create a Dokploy **Compose** service from one of these files: @@ -136,10 +134,17 @@ Both compose files provide: | Service | Purpose | |---|---| -| `gateway` | Edge host scaffold; service map and service health probes | -| `identity` | Future identity/auth/accounts service | -| `rooms` | Future rooms/matchmaking/discovery service | -| `notify` | Future notification/presence service | +| `gateway` | Edge reverse proxy; service map, route table, and service health probes | +| `identity` | Auth, accounts, and platform-login route slice | +| `rooms` | Rooms, room keys, playlists, matchmaking, and discovery route slice | +| `notify` | Notify, messages/chat, SignalR, and notification route slice | +| `content` | CDN paths, uploads, images, photos, room blobs, and storage route slice | +| `social` | Clubs, groups, announcements, player events, subscriptions route slice | +| `commerce` | Catalog, storefronts, econ, inventory, inventions route slice | +| `platform` | Service directory, config, version checks, geo, strings, and platform route slice | +| `moderation` | Bug reporting, player reporting, sanitize, admin API, and testcase route slice | +| `web` | Apex/www/admin/feed static hosts and site API route slice | +| `monolith` | Fallback full server for unknown route families not assigned to a service yet | | `postgres` | Internal Postgres for the service network | | `redis` | Internal Redis for ephemeral state and fan-out | | `cloudflared` | Cloudflare Tunnel sidecar; Dokploy file only | @@ -155,6 +160,8 @@ Set these Dokploy Compose environment variables: ```env DORKNET_DOMAIN=yourdomain.com +DORKNET_JWT_SECRET=replace-with-at-least-64-random-characters +POSTGRES_PASSWORD=replace-with-a-random-db-password CLOUDFLARE_TUNNEL_TOKEN= ``` @@ -165,15 +172,16 @@ yourdomain.com -> http://gateway:8080 *.yourdomain.com -> http://gateway:8080 ``` -The wildcard covers `api`, `auth`, `rooms`, `notify`, and the other -client-facing subdomains. The apex rule is separate because wildcard DNS -does not cover the root domain. Let Cloudflare create the tunnel DNS -records, or create CNAME records to the tunnel target Cloudflare gives -you. +The wildcard covers `api`, `auth`, `rooms`, `notify`, `cdn`, `clubs`, +`commerce`, and the other client-facing subdomains. The apex rule is +separate because wildcard DNS does not cover the root domain. Let +Cloudflare create the tunnel DNS records, or create CNAME records to the +tunnel target Cloudflare gives you. -When the gateway starts carrying the public API, every public DorkNet -domain listed above should point to `gateway`. Until then, keep the real -player-facing domains on the full `DorkNet.Server` application. +Every public DorkNet domain listed above should point to the gateway. +The gateway preserves the original Host header so service code still sees +`auth.yourdomain.com`, `rooms.yourdomain.com`, and the other client +hosts. Set these Compose environment variables in Dokploy: @@ -194,11 +202,12 @@ Useful smoke checks after deploy: curl https://api.yourdomain.com/healthz curl https://api.yourdomain.com/internal/services curl https://api.yourdomain.com/internal/services/health +curl https://api.yourdomain.com/internal/routes ``` -Expected during scaffold-only deploys: `/internal/services/health` -reports gateway-visible status for identity, rooms, and notify. It does -not prove the full game API has moved yet. +`/internal/routes` shows which host/path patterns route to each service +slice or the monolith fallback. `/internal/services/health` reports +gateway-visible health for every backend service. --- @@ -235,7 +244,7 @@ S3__TimeoutSeconds=300 ``` For the microservices compose file, use the `DORKNET_S3_*` equivalents; -Compose maps them into `S3__*` for the rooms service. +Compose maps them into `S3__*` for the service containers. --- diff --git a/tools/cloudflared.README.md b/tools/cloudflared.README.md index 8f219c5..904acbb 100644 --- a/tools/cloudflared.README.md +++ b/tools/cloudflared.README.md @@ -87,8 +87,9 @@ visitor → https://api.localhost/api/versioncheck/v4 → Cloudflare edge (real LE cert, validated) → cloudflared tunnel → cloudflared (running on your machine) - → https://localhost:443 (Host: api.localhost, mkcert cert) - → DorkNet.Server HostFiltering + subdomain-aware handlers + → http://gateway:8080 (Host preserved) + → DorkNet.Gateway reverse proxy + → dedicated service slice / monolith fallback ``` The leg from cloudflared → localhost has `noTLSVerify: true` because