Compare commits

..

20 Commits

Author SHA1 Message Date
Alexa 67970e5d23 Merge december-2020-12-18 into microservices-scaffold
Resolve modify/delete conflicts in favor of the microservices scaffold:
re-add the Contracts, ServiceDefaults, Gateway, and Identity/Notify/Rooms
service projects that december had removed. Keep december's branding and
route-case updates, and preserve its DorkNet.ApiRouteCoverage tool in the
solution (union of both branches' projects).
2026-06-16 00:16:45 +03:00
Alexa fc5b116263 Merge pull request #3 from DorkSquadRR/december-route-cases
Add December route cases
2026-06-16 00:03:32 +03:00
Alexa e2ec948c74 Document admin page and refresh architecture docs 2026-06-15 23:44:48 +03:00
Alexa c6a4357c6f Update docs for microservices architecture 2026-06-15 23:35:30 +03:00
Alexa 6704505dae Build web assets in service Dockerfile 2026-06-15 23:24:28 +03:00
Alexa 269a600869 Refactor web project asset handling to use DorkNetServerWebAsset for improved publish process 2026-06-15 23:06:38 +03:00
Alexa b3a06ce5e8 Clarify deployment version env keys 2026-06-15 22:48:38 +03:00
Alexa 1b0a9a62b4 Implement version check enhancements and add tests for December 2020 build 2026-06-15 22:45:40 +03:00
Alexa babfd2833f Add network configuration for microservices in Docker Compose 2026-06-15 22:17:14 +03:00
Alexa 78ff4481db Allow custom microservices Postgres connection 2026-06-15 22:06:29 +03:00
Alexa dccefb0158 Route browser paths to web service first 2026-06-15 22:04:04 +03:00
Alexa 49cf91f77d Remove root redirect endpoint from DorkNet service 2026-06-15 21:54:58 +03:00
Alexa 46e2d879cd Add new SVG logo for DorkNet branding 2026-06-15 21:49:35 +03:00
Alexa 26d183af7d Merge pull request #6 from DorkSquadRR/server-branding-december
Match server branding to logo
2026-06-15 21:08:59 +03:00
Alexa 94bad2912b Add endpoint contract tool and GitHub workflow 2026-06-15 20:31:41 +03:00
Alexa 44f53e80b8 Split remaining service routes 2026-06-15 20:21:05 +03:00
Alexa bd4ceada65 Use Cloudflare Tunnel for Dokploy routing 2026-06-15 15:31:05 +03:00
Alexa 86be2d004f Add env-driven Dokploy routing 2026-06-15 15:10:16 +03:00
Alexa 1b9afcd0c6 Harden December route cases 2026-06-15 13:04:45 +03:00
Alexa 4f41253cdc Add December route cases 2026-06-15 12:47:12 +03:00
72 changed files with 4734 additions and 315 deletions
@@ -0,0 +1,78 @@
# Disabled template. GitHub ignores files outside .github/workflows/.
# Move this file to .github/workflows/endpoint-contract.yml when it should run.
name: Endpoint Contract
on:
workflow_dispatch:
jobs:
endpoint-contract:
runs-on: ubuntu-latest
env:
ASPNETCORE_ENVIRONMENT: Development
ASPNETCORE_URLS: http://127.0.0.1:18080
Database__Provider: sqlite
Database__SqlitePath: ${{ runner.temp }}/dorknet-endpoint-contract.db
DORKNET_JWT_SECRET: endpoint-contract-secret-000000000000000000000000000000000000000000
S3__Endpoint: ""
S3__AccessKey: ""
S3__SecretKey: ""
PROTOBUF_PROTOC: /usr/bin/protoc
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: 9.0.x
- name: Install protobuf compiler
run: |
sudo apt-get update
sudo apt-get install -y protobuf-compiler
- name: Build server
run: dotnet build DorkNet.Server/DorkNet.Server.csproj
- name: Build endpoint contract runner
run: dotnet build tools/DorkNet.EndpointContract/DorkNet.EndpointContract.csproj --no-restore
- name: Start server
run: |
dotnet DorkNet.Server/bin/Debug/net9.0/DorkNet.Server.dll > "$RUNNER_TEMP/dorknet-server.log" 2>&1 &
echo "$!" > "$RUNNER_TEMP/dorknet-server.pid"
- name: Wait for server readiness
run: |
for attempt in {1..60}; do
if curl --fail --silent http://127.0.0.1:18080/healthz; then
exit 0
fi
sleep 2
done
cat "$RUNNER_TEMP/dorknet-server.log"
exit 1
- name: Run endpoint contract
run: |
dotnet run --project tools/DorkNet.EndpointContract/DorkNet.EndpointContract.csproj --no-build -- \
--server-assembly DorkNet.Server/bin/Debug/net9.0/DorkNet.Server.dll \
--base-url http://127.0.0.1:18080 \
--report "$RUNNER_TEMP/endpoint-contract-report.json"
- name: Upload endpoint contract artifacts
if: always()
uses: actions/upload-artifact@v4
with:
name: endpoint-contract
path: |
${{ runner.temp }}/endpoint-contract-report.json
${{ runner.temp }}/dorknet-server.log
- name: Stop server
if: always()
run: |
if [ -f "$RUNNER_TEMP/dorknet-server.pid" ]; then
kill "$(cat "$RUNNER_TEMP/dorknet-server.pid")" || true
fi
+1 -1
View File
@@ -21,7 +21,7 @@ tmp/
## OS ## OS
.DS_Store .DS_Store
Thumbs.db Thumbs.db
.backups
## Reverse-engineered artifacts (Il2CppDumper / Cpp2IL output, not for commit) ## Reverse-engineered artifacts (Il2CppDumper / Cpp2IL output, not for commit)
dump.cs dump.cs
il2cpp.h il2cpp.h
+55
View File
@@ -0,0 +1,55 @@
ARG DOTNET_VERSION=9.0
ARG NODE_VERSION=20
FROM node:${NODE_VERSION}-alpine AS admin-ui
WORKDIR /ui
COPY DorkNet.Server/admin-ui/package.json DorkNet.Server/admin-ui/package-lock.json* ./
RUN npm install --no-fund --no-audit --loglevel=error
COPY DorkNet.Server/admin-ui/ ./
RUN mkdir -p /wwwroot/admin \
&& npm run build
FROM node:${NODE_VERSION}-alpine AS site
WORKDIR /ui
COPY DorkNet.Server/site/package.json DorkNet.Server/site/package-lock.json* ./
RUN npm install --no-fund --no-audit --loglevel=error
COPY DorkNet.Server/site/ ./
RUN mkdir -p /wwwroot/site \
&& npm run build
FROM mcr.microsoft.com/dotnet/sdk:${DOTNET_VERSION} AS build
ARG PROJECT_PATH
WORKDIR /src
COPY . .
COPY --from=admin-ui /wwwroot/admin/ DorkNet.Server/wwwroot/admin/
COPY --from=site /wwwroot/site/ DorkNet.Server/wwwroot/site/
RUN dotnet publish "${PROJECT_PATH}" -c Release \
-p:SkipAdminUIBuild=true \
-p:DorkNetServiceContainerPublish=true \
-o /app/publish
FROM mcr.microsoft.com/dotnet/aspnet:${DOTNET_VERSION} AS runtime
ARG APP_DLL
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*
USER app
COPY --from=build --chown=app:app /app/publish .
ENV ASPNETCORE_ENVIRONMENT=Production \
ASPNETCORE_URLS=http://0.0.0.0:8080 \
DOTNET_RUNNING_IN_CONTAINER=true \
DOTNET_PRINT_TELEMETRY_MESSAGE=false \
APP_DLL=${APP_DLL}
EXPOSE 8080
HEALTHCHECK --interval=15s --timeout=5s --start-period=30s --retries=3 \
CMD curl --fail --silent http://localhost:8080/healthz || exit 1
ENTRYPOINT ["sh", "-c", "dotnet \"$APP_DLL\""]
+10
View File
@@ -2,6 +2,16 @@
Native Android/iOS admin app for DorkNet, built with .NET MAUI. Native Android/iOS admin app for DorkNet, built with .NET MAUI.
In the microservices deployment, point the app at the public admin host
for the stack, usually `https://admin.<domain>`. Cloudflare and
`DorkNet.Gateway` keep the public URL stable. Requests made through the
admin host are handled by the `web` service's same-origin admin route;
the `/api/admin/*` path family is also exposed by the moderation slice
for non-admin-host routing.
The browser admin page, route map, and troubleshooting flow are
documented in [`../docs/admin.md`](../docs/admin.md).
## Authentication ## Authentication
The app stores sensitive values in platform secure storage: The app stores sensitive values in platform secure storage:
@@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
</Project>
+298
View File
@@ -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<DorkNetRouteGroup> 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<string> 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<string> 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);
@@ -0,0 +1,38 @@
namespace DorkNet.Contracts;
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 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<DorkNetServiceEndpoint> Endpoints()
{
DorkNetServiceEndpoint[] endpoints =
[
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),
];
return endpoints
.Where(endpoint => !string.IsNullOrWhiteSpace(endpoint.BaseUrl))
.ToArray();
}
}
public sealed record DorkNetServiceEndpoint(string Name, string BaseUrl);
+19
View File
@@ -0,0 +1,19 @@
namespace DorkNet.Contracts;
public sealed record ServiceHealthResponse(
string Service,
string Status,
DateTimeOffset CheckedAtUtc);
public sealed record ServiceCapabilityResponse(
string Service,
string[] Owns,
string[] PlannedPublicRoutes);
public sealed record ServiceProbeResponse(
string Service,
string BaseUrl,
string Status,
int? StatusCode,
string? Error,
DateTimeOffset CheckedAtUtc);
+16
View File
@@ -0,0 +1,16 @@
namespace DorkNet.Contracts;
public static class ServiceNames
{
public const string Gateway = "gateway";
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";
}
+18
View File
@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Yarp.ReverseProxy" Version="2.3.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\DorkNet.Contracts\DorkNet.Contracts.csproj" />
<ProjectReference Include="..\DorkNet.ServiceDefaults\DorkNet.ServiceDefaults.csproj" />
</ItemGroup>
</Project>
+351
View File
@@ -0,0 +1,351 @@
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",
optional: true, reloadOnChange: true);
builder.AddDorkNetServiceDefaults(ServiceNames.Gateway);
builder.Services.Configure<DorkNetServiceMapOptions>(
builder.Configuration.GetSection("DorkNet:Services"));
var serviceMap = builder.Configuration
.GetSection("DorkNet:Services")
.Get<DorkNetServiceMapOptions>() ?? 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();
app.MapDorkNetServiceDefaults();
app.MapGet("/internal/services", (IOptions<DorkNetServiceMapOptions> 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<DorkNetServiceMapOptions> options,
IHttpClientFactory httpClientFactory,
CancellationToken ct) =>
{
var client = httpClientFactory.CreateClient();
var probes = new List<ServiceProbeResponse>();
foreach (var endpoint in options.Value.Endpoints())
{
probes.Add(await ProbeServiceAsync(client, endpoint, ct));
}
return Results.Ok(probes);
});
app.MapReverseProxy();
app.Run();
static async Task<ServiceProbeResponse> ProbeServiceAsync(
HttpClient client,
DorkNetServiceEndpoint endpoint,
CancellationToken ct)
{
var checkedAt = DateTimeOffset.UtcNow;
var baseUrl = endpoint.BaseUrl.TrimEnd('/');
try
{
using var response = await client.GetAsync($"{baseUrl}/internal/healthz", ct);
return new ServiceProbeResponse(
endpoint.Name,
endpoint.BaseUrl,
response.IsSuccessStatusCode ? "ok" : "unhealthy",
(int)response.StatusCode,
null,
checkedAt);
}
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException)
{
return new ServiceProbeResponse(
endpoint.Name,
endpoint.BaseUrl,
"unreachable",
null,
ex.Message,
checkedAt);
}
}
static IReadOnlyList<RouteConfig> BuildRoutes(
string apexDomain,
DorkNetServiceMapOptions services)
{
var routes = new List<RouteConfig>();
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)]);
AddHostRoute(
routes,
ref order,
webGroup.ServiceName,
BaseUrlFor(services, webGroup.ServiceName),
"web-feed-host",
[SubdomainHost("feed", apexDomain)]);
AddHostPathRoutes(
routes,
ref order,
webGroup.ServiceName,
BaseUrlFor(services, webGroup.ServiceName),
WebBrowserHosts(apexDomain),
[
"/admin",
"/assets",
"/brand",
"/feed",
"/join",
"/p",
"/photo",
"/players",
"/rooms",
"/u",
]);
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<ClusterConfig> BuildClusters(DorkNetServiceMapOptions services)
{
var clusters = new List<ClusterConfig>();
foreach (var endpoint in services.Endpoints())
{
AddCluster(clusters, endpoint.Name, endpoint.BaseUrl);
}
return clusters;
}
static string[] WebBrowserHosts(string apexDomain)
{
return [apexDomain, SubdomainHost("www", apexDomain)];
}
static void AddHostRoute(
List<RouteConfig> 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<RouteConfig> routes,
ref int order,
string serviceName,
string baseUrl,
IEnumerable<string> 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 void AddHostPathRoutes(
List<RouteConfig> routes,
ref int order,
string serviceName,
string baseUrl,
string[] hosts,
IEnumerable<string> paths)
{
if (string.IsNullOrWhiteSpace(baseUrl) || hosts.Length == 0)
{
return;
}
foreach (var path in paths)
{
var routeId = $"{serviceName}-browser-{path.Trim('/').Replace('/', '-').Replace("{**catch-all}", "all")}";
var exactPath = NormalizeRoutePrefix(path);
routes.Add(Route(serviceName, $"{routeId}-exact", exactPath, order++, hosts));
routes.Add(Route(serviceName, routeId, $"{exactPath}/{{**catch-all}}", order++, hosts));
}
}
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<string>();
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<string, string>
{
["RequestHeaderOriginalHost"] = "true",
},
],
};
}
static void AddCluster(
List<ClusterConfig> 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<string, DestinationConfig>
{
["primary"] = new() { Address = address },
},
});
}
+23
View File
@@ -0,0 +1,23 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"System.Net.Http.HttpClient": "Warning"
}
},
"DorkNet": {
"Services": {
"Identity": "http://localhost:8081",
"Rooms": "http://localhost:8082",
"Notify": "http://localhost:8083",
"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"
}
}
}
@@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.2" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.0.4" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\DorkNet.Server\DorkNet.Server.csproj" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
</Project>
@@ -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<Program>
{
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<string, string?>
{
["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<HostFilteringOptions>(options =>
{
options.AllowedHosts =
[
ApexDomain,
$"*.{ApexDomain}",
"localhost",
"127.0.0.1",
];
options.AllowEmptyHosts = true;
options.IncludeFailureMessage = true;
});
services.PostConfigure<JwtBearerOptions>(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.
}
}
}
@@ -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<EndpointContract> 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<EndpointContract> DiscoverControllerRoutes(Type controller)
{
var controllerName = ControllerName(controller);
var controllerRouteTemplates = controller.GetCustomAttributes(inherit: true)
.OfType<IRouteTemplateProvider>()
.Select(a => a.Template)
.Where(t => !string.IsNullOrWhiteSpace(t))
.Cast<string>()
.DefaultIfEmpty("")
.ToArray();
foreach (var method in controller.GetMethods(BindingFlags.Instance | BindingFlags.Public | BindingFlags.DeclaredOnly))
{
var httpAttrs = method.GetCustomAttributes(inherit: true)
.OfType<HttpMethodAttribute>()
.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<IAllowAnonymous>().Any())
{
return false;
}
return attributes.OfType<IAuthorizeData>().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<ConsumesAttribute>()
.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(@"\{[*]?(?<name>[^}:=\?]+)(?<constraint>:[^}=\?]+)?(?<optional>\?)?(?<default>=[^}]+)?\}")]
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);
}
}
@@ -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<DorkNetServerFactory>
{
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<string>();
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<EndpointProbeResult> 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<string, string>
{
["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<string>();
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";
}
+65
View File
@@ -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<GameClientSession> 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<string, string>
{
["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);
}
}
@@ -0,0 +1,56 @@
using System.Text.Json;
using DorkNet.Server.Compat;
using DorkNet.Server.Controllers.Auth;
using DorkNet.Server.Versions.Late2020;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
namespace DorkNet.Server.Tests;
public sealed class VersionCheckControllerTests
{
[Fact]
public void December_2020_build_is_valid_with_minimal_service_config()
{
var controller = CreateController();
var result = Assert.IsType<OkObjectResult>(controller.Check("20201210", "0"));
Assert.Equal(0, ReadVersionStatus(result.Value));
}
[Fact]
public void Unknown_build_still_requires_update()
{
var controller = CreateController();
var result = Assert.IsType<OkObjectResult>(controller.Check("unknown", "0"));
Assert.Equal(2, ReadVersionStatus(result.Value));
}
private static VersionCheckController CreateController()
{
var config = new ConfigurationBuilder().Build();
var registry = new VersionRegistry(
[new Late2020VersionPlugin()],
new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"december_2020_12_18",
},
"december_2020_12_18");
return new VersionCheckController(
config,
registry,
NullLogger<VersionCheckController>.Instance);
}
private static int ReadVersionStatus(object? value)
{
var json = JsonSerializer.Serialize(value);
using var document = JsonDocument.Parse(json);
return document.RootElement.GetProperty("VersionStatus").GetInt32();
}
}
@@ -201,12 +201,12 @@ public class AccountApiController(
[HttpPost("api/account/v1/profileimage")] [HttpPost("api/account/v1/profileimage")]
[HttpPost("api/account/me/profileimage")] [HttpPost("api/account/me/profileimage")]
[HttpPost("account/me/profileimage")] [HttpPost("account/me/profileimage", Order = 10)]
[HttpPost("profileimage")] [HttpPost("profileimage", Order = 10)]
[HttpPut("api/account/v1/profileimage")] [HttpPut("api/account/v1/profileimage")]
[HttpPut("api/account/me/profileimage")] [HttpPut("api/account/me/profileimage")]
[HttpPut("account/me/profileimage")] [HttpPut("account/me/profileimage", Order = 10)]
[HttpPut("profileimage")] [HttpPut("profileimage", Order = 10)]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")] [Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
public async Task<IActionResult> SetProfileImage( public async Task<IActionResult> SetProfileImage(
[FromForm(Name = "imageName")] string? imageName, [FromForm(Name = "imageName")] string? imageName,
@@ -41,9 +41,9 @@ public class PageviewController(ILogger<PageviewController> logger) : Controller
/// intentionally return an empty url + a 1-hour freshness — the /// intentionally return an empty url + a 1-hour freshness — the
/// private server has no in-app deep-link system, so there's /// private server has no in-app deep-link system, so there's
/// nothing to navigate to and no reason to be polled more often.</summary> /// nothing to navigate to and no reason to be polled more often.</summary>
[HttpPost("pageview/consume")] [HttpPost("pageview/consume", Order = 10)]
[HttpPost("/pageview/consume")] [HttpPost("/pageview/consume")]
[HttpGet("pageview/consume")] [HttpGet("pageview/consume", Order = 10)]
[HttpGet("/pageview/consume")] [HttpGet("/pageview/consume")]
public IActionResult Consume() public IActionResult Consume()
{ {
@@ -23,7 +23,7 @@ public class AuthAccountController(DorkNetDbContext db) : ControllerBase
/// <c>ExpectPrimitiveResponse&lt;bool&gt;</c> so the body must be /// <c>ExpectPrimitiveResponse&lt;bool&gt;</c> so the body must be
/// the literal JSON <c>true</c> or <c>false</c>.</summary> /// the literal JSON <c>true</c> or <c>false</c>.</summary>
[HttpGet("/account/me/haspassword")] [HttpGet("/account/me/haspassword")]
[HttpGet("/api/platformlogin/haspassword")] [HttpGet("/api/platformlogin/haspassword", Order = 10)]
[Authorize] [Authorize]
public async Task<IActionResult> HasPassword() public async Task<IActionResult> HasPassword()
{ {
@@ -40,8 +40,8 @@ public class AuthAccountController(DorkNetDbContext db) : ControllerBase
/// one; first-time creation skips that check. Form-encoded body.</summary> /// one; first-time creation skips that check. Form-encoded body.</summary>
[HttpPost("/account/me/changepassword")] [HttpPost("/account/me/changepassword")]
[HttpPost("/account/me/createpassword")] [HttpPost("/account/me/createpassword")]
[HttpPost("/api/platformlogin/password")] [HttpPost("/api/platformlogin/password", Order = 10)]
[HttpPut("/api/platformlogin/password")] [HttpPut("/api/platformlogin/password", Order = 10)]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data")] [Consumes("application/x-www-form-urlencoded", "multipart/form-data")]
[Authorize] [Authorize]
public async Task<IActionResult> ChangePassword( public async Task<IActionResult> ChangePassword(
@@ -76,7 +76,7 @@ public class AuthAccountController(DorkNetDbContext db) : ControllerBase
/// account from the admin tab and the user re-creates it via /// account from the admin tab and the user re-creates it via
/// changepassword on next login. Anonymous calls 401.</summary> /// changepassword on next login. Anonymous calls 401.</summary>
[HttpPost("/account/me/resetpassword")] [HttpPost("/account/me/resetpassword")]
[HttpPost("/api/platformlogin/resetpassword")] [HttpPost("/api/platformlogin/resetpassword", Order = 10)]
[Authorize] [Authorize]
[AdminOnly] [AdminOnly]
public async Task<IActionResult> AdminResetPassword( public async Task<IActionResult> AdminResetPassword(
@@ -46,6 +46,12 @@ public class VersionCheckController(
private const int VersionStatus_ValidForMenu = 1; private const int VersionStatus_ValidForMenu = 1;
private const int VersionStatus_UpdateRequired = 2; private const int VersionStatus_UpdateRequired = 2;
private static readonly IReadOnlyDictionary<string, string> BuiltInBuildIdToVersionKey =
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["20201210"] = "december_2020_12_18",
};
[HttpGet("/api/versioncheck/v4")] [HttpGet("/api/versioncheck/v4")]
[HttpGet("/api/versioncheck/v3")] [HttpGet("/api/versioncheck/v3")]
[HttpGet("/api/versioncheck/v2")] [HttpGet("/api/versioncheck/v2")]
@@ -66,9 +72,17 @@ public class VersionCheckController(
// } // }
// Mapping ABSENT → unknown build → UpdateRequired. // Mapping ABSENT → unknown build → UpdateRequired.
var mapSection = config.GetSection("DorkNet:BuildIdToVersionKey"); var mapSection = config.GetSection("DorkNet:BuildIdToVersionKey");
string? versionKey = !string.IsNullOrWhiteSpace(buildId) string? versionKey = null;
? mapSection[buildId] if (!string.IsNullOrWhiteSpace(buildId))
: null; {
var normalizedBuildId = buildId.Trim();
versionKey = mapSection[normalizedBuildId];
if (string.IsNullOrWhiteSpace(versionKey)
&& BuiltInBuildIdToVersionKey.TryGetValue(normalizedBuildId, out var builtInVersionKey))
{
versionKey = builtInVersionKey;
}
}
bool allowed = versionKey is not null && registry.IsSupported(versionKey); bool allowed = versionKey is not null && registry.IsSupported(versionKey);
var status = allowed ? VersionStatus_ValidForPlay : VersionStatus_UpdateRequired; var status = allowed ? VersionStatus_ValidForPlay : VersionStatus_UpdateRequired;
@@ -668,11 +668,14 @@ public class ClubsController(ClubService clubs) : ControllerBase
[HttpPost("/members/bulk")] [HttpPost("/members/bulk")]
public async Task<IActionResult> MembersBulk() public async Task<IActionResult> MembersBulk()
{ {
var form = Request.HasFormContentType ? Request.Form : null;
if (!long.TryParse(Request.Query["clubId"].ToString(), out var clubId) 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" }); 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 var ids = idsRaw
.SelectMany(v => (v ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) .SelectMany(v => (v ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
.Select(s => long.TryParse(s, out var n) ? n : 0) .Select(s => long.TryParse(s, out var n) ? n : 0)
@@ -223,9 +223,12 @@ public class MatchPlayerController(
var playerId = TryGetCurrentPlayerId(); var playerId = TryGetCurrentPlayerId();
if (playerId == 0) return Ok(); if (playerId == 0) return Ok();
var form = Request.Form; var regions = Request.HasFormContentType
var regions = form["region"].ToArray(); ? Request.Form["region"].ToArray()
var pings = form["ping"].ToArray(); : Request.Query["region"].ToArray();
var pings = Request.HasFormContentType
? Request.Form["ping"].ToArray()
: Request.Query["ping"].ToArray();
var pairs = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase); var pairs = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
for (var i = 0; i < Math.Min(regions.Length, pings.Length); i++) for (var i = 0; i < Math.Min(regions.Length, pings.Length); i++)
{ {
+7 -6
View File
@@ -6,8 +6,9 @@
(generated by `dotnet ef migrations add Initial` against the (generated by `dotnet ef migrations add Initial` against the
current entity model — captures all 67 `DbSet<>` tables). current entity model — captures all 67 `DbSet<>` tables).
- **`LegacyUpgrades.cs`** holds idempotent data transforms that can't - **`LegacyUpgrades.cs`** holds idempotent data transforms that can't
be derived from the entity model. Runs once per server boot, after be derived from the entity model. Runs during host boot for the
EF migrations complete and before `RoomService.SeedAsync()`. standalone server and for service-slice containers, after schema
bootstrap completes and before `RoomService.SeedAsync()`.
## What changed in the consolidation ## What changed in the consolidation
@@ -29,7 +30,7 @@ Both were collapsed in 2026-05-25:
no per-feature patches needed. no per-feature patches needed.
- The PhotonRegion coerce (the only data transform in the block) - The PhotonRegion coerce (the only data transform in the block)
was moved into `LegacyUpgrades.CoercePhotonRegionAsync`. was moved into `LegacyUpgrades.CoercePhotonRegionAsync`.
- The Storage admin page (`admin-ui/src/pages/Storage.tsx`) and - The legacy storage backfill page (`admin-ui/src/pages/Storage.tsx`) and
`StorageBackfillController.cs` were deleted — they were the UI for `StorageBackfillController.cs` were deleted — they were the UI for
draining the legacy `RoomDataBlobs.Bytes` column into S3, which is draining the legacy `RoomDataBlobs.Bytes` column into S3, which is
no longer needed. no longer needed.
@@ -37,8 +38,8 @@ Both were collapsed in 2026-05-25:
## Production cutover (one-time SQL) ## Production cutover (one-time SQL)
Existing prod databases need a one-time fixup to tell EF "the new Existing prod databases need a one-time fixup to tell EF "the new
Initial is already applied here" so the next boot doesn't try to Initial is already applied here" so the next standalone or service-slice
`CREATE TABLE` over existing data. boot doesn't try to `CREATE TABLE` over existing data.
### Postgres path ### Postgres path
@@ -48,7 +49,7 @@ table exists** because EF Core only creates it when `Migrate()` runs.
Postgres continues to use `EnsureCreated()` post-consolidation, so Postgres continues to use `EnsureCreated()` post-consolidation, so
there's *nothing to do* — `EnsureCreated()` reads the entity model, there's *nothing to do* — `EnsureCreated()` reads the entity model,
sees the schema already matches, and no-ops. Just deploy the new sees the schema already matches, and no-ops. Just deploy the new
build. standalone image or microservices compose stack.
If you ever switch the Postgres path to `Migrate()`, run this once If you ever switch the Postgres path to `Migrate()`, run this once
before the cutover deploy: before the cutover deploy:
+7
View File
@@ -2,6 +2,13 @@
Generated: 2026-05-09. Source: client URLs from `Cpp2IL_ISIL` ISIL dump (curated list provided to this report); server routes harvested via grep of `[HttpGet]`/`[HttpPost]`/`[HttpPut]`/`[HttpDelete]`/`[Route]` attributes across `Controllers/**/*.cs` plus the namespace-wildcard fall-throughs in `ApiNamespaceStubsController` and the global `ApiCatchAllController` / `GlobalCatchAllController`. Generated: 2026-05-09. Source: client URLs from `Cpp2IL_ISIL` ISIL dump (curated list provided to this report); server routes harvested via grep of `[HttpGet]`/`[HttpPost]`/`[HttpPut]`/`[HttpDelete]`/`[Route]` attributes across `Controllers/**/*.cs` plus the namespace-wildcard fall-throughs in `ApiNamespaceStubsController` and the global `ApiCatchAllController` / `GlobalCatchAllController`.
> Snapshot scope: this file is a historical controller-coverage matrix,
> not the live gateway route table. The dedicated microservice hosts
> still reuse the shared controller stack behind route guards, so the
> endpoint shape notes remain useful. For current service ownership, use
> `DorkNet.Contracts/DorkNetRouteOwnership.cs` or the gateway
> `/internal/routes` endpoint.
## How this matrix is built ## How this matrix is built
Each client URL is matched against the server's full route table. ASP.NET Core routes resolve "specific wins over wildcard", so a route literally registered as `api/foo/v1/bar` always wins over `api/foo/{*path}`. The `Status` column reflects the route that actually wins: Each client URL is matched against the server's full route table. ASP.NET Core routes resolve "specific wins over wildcard", so a route literally registered as `api/foo/v1/bar` always wins over `api/foo/{*path}`. The `Status` column reflects the route that actually wins:
+9
View File
@@ -75,6 +75,15 @@
<ProjectReference Include="..\DorkNet.Models\DorkNet.Models.csproj" /> <ProjectReference Include="..\DorkNet.Models\DorkNet.Models.csproj" />
</ItemGroup> </ItemGroup>
<!-- Service-slice containers reference DorkNet.Server for shared controllers
and services, but each slice owns its own appsettings.json. Without this
guard, dotnet publish sees both files targeting appsettings.json in the
same publish root and fails NETSDK1152. -->
<ItemGroup Condition="'$(DorkNetServiceContainerPublish)' == 'true'">
<Content Update="appsettings*.json" CopyToPublishDirectory="Never" />
<None Update="appsettings*.json" CopyToPublishDirectory="Never" />
</ItemGroup>
<!-- Vite admin SPA build. Source at admin-ui/, output goes to <!-- Vite admin SPA build. Source at admin-ui/, output goes to
wwwroot/admin/ which the per-host static file middleware wwwroot/admin/ which the per-host static file middleware
already serves on admin.rec.net / admin.localhost. already serves on admin.rec.net / admin.localhost.
+2
View File
@@ -15,3 +15,5 @@ var app = builder.Build();
await app.RunDatabaseBootstrapAsync(); await app.RunDatabaseBootstrapAsync();
app.UseDorkNetPipeline(); app.UseDorkNetPipeline();
app.Run(); app.Run();
public partial class Program;
@@ -0,0 +1,71 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 1080 1080" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-miterlimit:10;">
<rect id="Tavola-da-disegno1" serif:id="Tavola da disegno1" x="0" y="0" width="1080" height="1080" style="fill:none;"/>
<g id="Tavola-da-disegno11" serif:id="Tavola da disegno1">
<g transform="matrix(0.966367,-1.703882,1.703882,0.966367,-124.38412,723.826641)">
<ellipse cx="249.71" cy="250.06" rx="121.23" ry="247.82" style="fill:rgb(200,243,255);"/>
</g>
<g transform="matrix(1.958846,0,0,1.958846,53.870869,50.218665)">
<path d="M308.29,210.37C279.19,136 183.21,43 29.77,130.68L55.77,195.93C63.327,191.377 71.22,187.406 79.38,184.05L140.38,362.51C128.31,362.68 100.64,360.65 90.49,351.96C79.2,342.3 114.81,394.52 200.79,393.67C299.75,392.7 340.79,293.43 308.29,210.37ZM197.28,345.34L149.71,171.16C179.29,173.83 209.71,189.16 230.89,231.7C259.4,289 231.81,325.79 197.28,345.34Z" style="fill:url(#_Linear1);fill-rule:nonzero;"/>
<clipPath id="_clip2">
<path d="M308.29,210.37C279.19,136 183.21,43 29.77,130.68L55.77,195.93C63.327,191.377 71.22,187.406 79.38,184.05L140.38,362.51C128.31,362.68 100.64,360.65 90.49,351.96C79.2,342.3 114.81,394.52 200.79,393.67C299.75,392.7 340.79,293.43 308.29,210.37ZM197.28,345.34L149.71,171.16C179.29,173.83 209.71,189.16 230.89,231.7C259.4,289 231.81,325.79 197.28,345.34Z" clip-rule="nonzero"/>
</clipPath>
<g clip-path="url(#_clip2)">
<g transform="matrix(0.564731,0,0,0.564731,-40.206636,-65.042535)">
<path d="M383.298,380.419C359.792,373.449 334.386,368.102 300.108,372.736C272.966,376.405 245.845,385.565 231.091,390.596L225.318,373.463C259.192,362.43 295.348,352.411 333.238,354.558C351.669,355.602 369.398,359.365 388.465,365.398C417.174,374.482 403.887,387.691 418.365,412.449C434.713,420.625 486.506,448.447 514.251,516.006C528.415,550.494 533.264,588.31 529.151,624.638C522.661,681.958 496.254,716.526 489.48,725.417L481.335,719.228C486.524,712.173 516.533,673.169 518.45,608.869C520.6,536.765 485.888,461.595 411.148,425.724C385.618,413.471 396.109,403.478 383.298,380.419Z" style="fill:rgb(11,149,199);"/>
</g>
<g transform="matrix(0.564731,0,0,0.564731,-40.206636,-65.042535)">
<path d="M516.389,742.353L510.331,737.233C517.614,728.283 552.833,686.488 553.815,611.549C554.808,535.791 519.635,452.341 449.375,402.174C448.959,401.877 448.998,401.831 444.275,398.327L452.449,386.395C457.784,389.848 457.811,389.811 462.829,393.709C572.192,478.66 597.626,646.024 517.567,740.97L516.389,742.353Z" style="fill:rgb(11,149,199);"/>
</g>
<g transform="matrix(0.564731,0,0,0.564731,-23.866246,-81.382925)">
<path d="M533.438,757.673L526.891,753.57C536.596,737.676 570.912,680.138 559.158,593.709C541.725,465.513 432.263,349.713 295.811,357.465L294.658,357.532L293.758,339.475C314.259,338.685 385.515,336.865 457.769,392.943C564.058,475.436 610.007,636.274 534.39,756.175C533.916,756.927 533.915,756.923 533.438,757.673Z" style="fill:rgb(11,149,199);"/>
</g>
</g>
<path d="M308.29,210.37C279.19,136 183.21,43 29.77,130.68L55.77,195.93C63.327,191.377 71.22,187.406 79.38,184.05L140.38,362.51C128.31,362.68 100.64,360.65 90.49,351.96C79.2,342.3 114.81,394.52 200.79,393.67C299.75,392.7 340.79,293.43 308.29,210.37ZM197.28,345.34L149.71,171.16C179.29,173.83 209.71,189.16 230.89,231.7C259.4,289 231.81,325.79 197.28,345.34Z" style="fill:none;fill-rule:nonzero;stroke:white;stroke-width:5px;"/>
</g>
<g transform="matrix(1.958846,0,0,1.958846,53.870869,50.218665)">
<path d="M392.19,205.85L452.51,182C452.51,182 439.38,111.36 360.12,103.73C280.86,96.1 255,167.94 254.8,195C254.6,222.06 267.09,299.93 339.53,284.5C372.33,277.5 385.19,290.19 383.29,309.2C380.98,332.28 336.11,370.64 268.29,309.65L237,336.56C237,336.56 262,403.62 334.06,403.68C399.06,403.73 457.8,364.68 446.68,289.25C441.03,250.9 407.51,217.39 357.62,221.89C300.62,227.03 323.13,172.89 355.12,171.94C394.19,170.77 392.19,205.85 392.19,205.85Z" style="fill:url(#_Linear3);fill-rule:nonzero;"/>
<clipPath id="_clip4">
<path d="M392.19,205.85L452.51,182C452.51,182 439.38,111.36 360.12,103.73C280.86,96.1 255,167.94 254.8,195C254.6,222.06 267.09,299.93 339.53,284.5C372.33,277.5 385.19,290.19 383.29,309.2C380.98,332.28 336.11,370.64 268.29,309.65L237,336.56C237,336.56 262,403.62 334.06,403.68C399.06,403.73 457.8,364.68 446.68,289.25C441.03,250.9 407.51,217.39 357.62,221.89C300.62,227.03 323.13,172.89 355.12,171.94C394.19,170.77 392.19,205.85 392.19,205.85Z" clip-rule="nonzero"/>
</clipPath>
<g clip-path="url(#_clip4)">
<g transform="matrix(0.510505,0,0,0.510505,-25.969815,-25.636862)">
<path d="M719.298,286.117L719.472,302.116C714.034,302.198 680.645,301.87 647.172,333.748C618.509,361.044 599.916,402.886 602.893,445.447C605.339,480.414 612.06,542.816 672.087,560.368C704.064,569.718 738.525,562.673 746.775,561.646C798.125,555.255 863.977,582.481 844.976,672.066C826.557,758.904 708.786,793.434 604.896,734.778C603.991,734.267 603.996,734.265 603.094,733.751L611.06,719.875C619.352,724.604 666.616,752.472 726.054,747.259C776.183,742.862 819.57,715.532 829.427,668.257C846.074,588.41 787.093,574.077 752.204,577.157C744.709,577.819 735.843,579.959 719.66,580.846C610.634,586.829 593.432,503.624 588.863,467.303C588.402,463.633 586.629,448.059 586.581,437.107C586.387,392.666 606.6,349.614 637.435,320.939C674.957,286.046 712.599,286.218 719.298,286.117Z" style="fill:rgb(51,235,255);"/>
</g>
<g transform="matrix(0.510505,0,0,0.510505,-25.969815,-25.636862)">
<path d="M889.582,670.145C886.387,692.654 874.229,756.889 799.538,791.868C768.431,806.436 738.412,810.153 727.3,811.484C726.145,801.395 716.291,796.674 726.378,795.483C798.043,787.023 862.555,749.781 873.868,667.055C880.827,616.172 906.947,635.6 944.161,600.212L954.792,612.169L889.582,670.145Z" style="fill:rgb(51,235,255);"/>
</g>
<g transform="matrix(0.48417,0,0,0.48417,290.849928,159.731518)">
<path d="M-30.174,347.361C-30.091,347.362 -16.814,346.654 -7.19,358.034C5.418,372.943 0.646,395.741 -16.795,404.414C-33.634,412.787 -53.997,403.726 -59.125,385.688C-64.446,366.968 -50.777,348.122 -31.331,347.382C-31.239,347.379 -31.239,347.381 -30.174,347.361ZM-30.173,366.335C-30.269,366.339 -30.366,366.341 -30.465,366.344C-38.677,366.537 -44.049,375.524 -39.822,382.976C-35.513,390.572 -24.064,390.508 -20.093,382.142C-16.849,375.307 -21.5,366.447 -30.173,366.34C-30.173,366.338 -30.173,366.337 -30.173,366.335ZM-30.173,366.34C-30.173,366.36 -30.254,366.339 -30.234,366.34C-30.214,366.34 -30.193,366.34 -30.173,366.34Z" style="fill:rgb(51,235,255);"/>
</g>
<g transform="matrix(0.409654,0,0,0.409654,348.629981,229.899333)">
<path d="M-30.174,346.881C-30.091,346.882 -16.6,346.163 -6.823,357.724C6.389,373.348 0.742,397.3 -17.929,405.473C-36.437,413.575 -57.656,401.68 -60.452,381.718C-61.661,373.092 -59.166,364.231 -53.287,357.446C-43.552,346.209 -30.382,346.885 -30.174,346.881ZM-30.179,348.606C-30.272,365.541 -31.682,366.793 -30.237,366.819C-30.24,366.819 -30.241,366.82 -30.239,366.821C-40.09,366.999 -44.296,378.834 -37.414,385.24C-30.923,391.283 -20.188,387.144 -19.561,378.146C-19.192,372.846 -23.119,366.95 -30.173,366.82C-30.144,357.461 -30.198,356.892 -30.179,348.606ZM-30.173,366.82C-30.173,366.837 -30.23,366.826 -30.239,366.821C-30.218,366.821 -30.197,366.82 -30.176,366.82C-30.175,366.82 -30.174,366.82 -30.173,366.82Z" style="fill:rgb(51,235,255);"/>
</g>
</g>
<path d="M392.19,205.85L452.51,182C452.51,182 439.38,111.36 360.12,103.73C280.86,96.1 255,167.94 254.8,195C254.6,222.06 267.09,299.93 339.53,284.5C372.33,277.5 385.19,290.19 383.29,309.2C380.98,332.28 336.11,370.64 268.29,309.65L237,336.56C237,336.56 262,403.62 334.06,403.68C399.06,403.73 457.8,364.68 446.68,289.25C441.03,250.9 407.51,217.39 357.62,221.89C300.62,227.03 323.13,172.89 355.12,171.94C394.19,170.77 392.19,205.85 392.19,205.85Z" style="fill:none;fill-rule:nonzero;stroke:white;stroke-width:5px;"/>
</g>
<g transform="matrix(1.958846,0,0,1.958846,104.702924,223.752836)">
<path d="M125.81,223.01L161.59,281.96L234.69,250.76L228.53,178.91" style="fill:none;fill-rule:nonzero;stroke:rgb(125,132,143);stroke-width:9px;"/>
</g>
<g transform="matrix(1.958846,0,0,1.958846,104.702924,223.752836)">
<path d="M250.31,171.25L297.85,221.85L363.95,190.85L351.12,130.21" style="fill:none;fill-rule:nonzero;stroke:rgb(125,132,143);stroke-width:9px;"/>
</g>
<g transform="matrix(1.958846,0,0,1.958846,104.702924,223.752836)">
<path d="M76.64,234.99L78.95,211.91L122.32,223.62L133.9,209.26L204.54,179.63L228.56,179.91L250.31,171.25L264.34,155.33L331.44,124.3L347.49,125.82L335.99,92.65" style="fill:none;fill-rule:nonzero;stroke:rgb(26,26,26);stroke-width:13px;"/>
</g>
<g transform="matrix(1,0,0,1,237.128515,-26.039343)">
<path d="M-30.174,346.85C-30.091,346.852 -16.587,346.132 -6.8,357.704C5.921,372.747 1.262,395.736 -16.236,404.704C-32.978,413.284 -53.484,404.767 -59.286,386.911C-65.4,368.095 -52.217,348.44 -32.518,346.939C-31.347,346.85 -31.349,346.872 -30.174,346.85ZM-30.174,366.848C-30.745,366.832 -35.068,366.825 -38.277,370.619C-43.539,376.841 -40.028,386.366 -32.189,387.87C-23.778,389.483 -17.066,380.949 -20.45,373.218C-21.243,371.406 -22.544,369.843 -24.151,368.729C-26.865,366.847 -29.534,366.855 -30.174,366.85L-30.112,366.85C-30.116,366.85 -30.137,366.849 -30.174,366.848Z" style="fill:rgb(11,149,199);"/>
</g>
<g transform="matrix(0.868104,0,0,0.868104,340.346177,-48.424243)">
<path d="M-30.174,346.515C-30.091,346.517 -16.438,345.789 -6.544,357.488C6.621,373.056 1.318,396.894 -17.096,405.497C-35.045,413.883 -56.201,403.351 -60.405,384.057C-64.507,365.238 -50.552,347.268 -31.363,346.538C-31.268,346.534 -31.269,346.536 -30.174,346.515ZM-30.175,356.504L-30.176,367.186C-30.197,367.185 -30.218,367.185 -30.239,367.185C-30.243,367.185 -30.243,367.186 -30.241,367.187C-39.761,367.366 -43.804,378.792 -37.165,384.972C-30.855,390.847 -20.418,386.751 -19.918,377.991C-19.631,372.952 -23.381,367.313 -30.173,367.186C-30.156,361.786 -30.166,359.208 -30.175,356.504ZM-30.173,367.186C-30.173,367.204 -30.232,367.191 -30.241,367.187C-30.219,367.186 -30.198,367.186 -30.176,367.186C-30.175,367.186 -30.174,367.186 -30.173,367.186Z" style="fill:rgb(11,149,199);"/>
</g>
<g transform="matrix(0.736248,0,0,0.736248,483.945338,73.515167)">
<path d="M-30.174,345.984C-30.091,345.986 -16.202,345.245 -6.139,357.145C7.565,373.35 1.541,398.208 -17.922,406.446C-37.518,414.741 -59.637,401.448 -61.521,380.263C-62.303,371.462 -59.382,362.805 -53.465,356.295C-43.544,345.381 -30.619,345.993 -30.174,345.984ZM-30.175,356.435L-30.176,367.717C-30.198,367.716 -30.22,367.716 -30.242,367.716C-30.246,367.716 -30.247,367.717 -30.245,367.718C-39.336,367.901 -43.117,378.846 -36.717,384.664C-30.745,390.092 -21.176,386.235 -20.475,378.338C-20.018,373.2 -23.745,367.842 -30.173,367.717C-30.155,362.006 -30.166,359.296 -30.175,356.435ZM-30.173,367.717C-30.173,367.735 -30.235,367.723 -30.245,367.718C-30.222,367.717 -30.199,367.717 -30.176,367.717C-30.175,367.717 -30.173,367.717 -30.173,367.717Z" style="fill:rgb(11,149,199);"/>
</g>
</g>
<defs>
<linearGradient id="_Linear1" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(97.382232,67.814092,-67.814092,97.382232,151.218217,203.626226)"><stop offset="0" style="stop-color:rgb(51,235,255);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(11,149,199);stop-opacity:1"/></linearGradient>
<linearGradient id="_Linear3" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(-3.53364,107.893491,-162.475262,-5.321257,318.003313,232.433991)"><stop offset="0" style="stop-color:rgb(51,235,255);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(11,149,199);stop-opacity:1"/></linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 12 KiB

@@ -0,0 +1,71 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 1080 1080" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-miterlimit:10;">
<rect id="Tavola-da-disegno1" serif:id="Tavola da disegno1" x="0" y="0" width="1080" height="1080" style="fill:none;"/>
<g id="Tavola-da-disegno11" serif:id="Tavola da disegno1">
<g transform="matrix(0.966367,-1.703882,1.703882,0.966367,-124.38412,723.826641)">
<ellipse cx="249.71" cy="250.06" rx="121.23" ry="247.82" style="fill:rgb(200,243,255);"/>
</g>
<g transform="matrix(1.958846,0,0,1.958846,53.870869,50.218665)">
<path d="M308.29,210.37C279.19,136 183.21,43 29.77,130.68L55.77,195.93C63.327,191.377 71.22,187.406 79.38,184.05L140.38,362.51C128.31,362.68 100.64,360.65 90.49,351.96C79.2,342.3 114.81,394.52 200.79,393.67C299.75,392.7 340.79,293.43 308.29,210.37ZM197.28,345.34L149.71,171.16C179.29,173.83 209.71,189.16 230.89,231.7C259.4,289 231.81,325.79 197.28,345.34Z" style="fill:url(#_Linear1);fill-rule:nonzero;"/>
<clipPath id="_clip2">
<path d="M308.29,210.37C279.19,136 183.21,43 29.77,130.68L55.77,195.93C63.327,191.377 71.22,187.406 79.38,184.05L140.38,362.51C128.31,362.68 100.64,360.65 90.49,351.96C79.2,342.3 114.81,394.52 200.79,393.67C299.75,392.7 340.79,293.43 308.29,210.37ZM197.28,345.34L149.71,171.16C179.29,173.83 209.71,189.16 230.89,231.7C259.4,289 231.81,325.79 197.28,345.34Z" clip-rule="nonzero"/>
</clipPath>
<g clip-path="url(#_clip2)">
<g transform="matrix(0.564731,0,0,0.564731,-40.206636,-65.042535)">
<path d="M383.298,380.419C359.792,373.449 334.386,368.102 300.108,372.736C272.966,376.405 245.845,385.565 231.091,390.596L225.318,373.463C259.192,362.43 295.348,352.411 333.238,354.558C351.669,355.602 369.398,359.365 388.465,365.398C417.174,374.482 403.887,387.691 418.365,412.449C434.713,420.625 486.506,448.447 514.251,516.006C528.415,550.494 533.264,588.31 529.151,624.638C522.661,681.958 496.254,716.526 489.48,725.417L481.335,719.228C486.524,712.173 516.533,673.169 518.45,608.869C520.6,536.765 485.888,461.595 411.148,425.724C385.618,413.471 396.109,403.478 383.298,380.419Z" style="fill:rgb(11,149,199);"/>
</g>
<g transform="matrix(0.564731,0,0,0.564731,-40.206636,-65.042535)">
<path d="M516.389,742.353L510.331,737.233C517.614,728.283 552.833,686.488 553.815,611.549C554.808,535.791 519.635,452.341 449.375,402.174C448.959,401.877 448.998,401.831 444.275,398.327L452.449,386.395C457.784,389.848 457.811,389.811 462.829,393.709C572.192,478.66 597.626,646.024 517.567,740.97L516.389,742.353Z" style="fill:rgb(11,149,199);"/>
</g>
<g transform="matrix(0.564731,0,0,0.564731,-23.866246,-81.382925)">
<path d="M533.438,757.673L526.891,753.57C536.596,737.676 570.912,680.138 559.158,593.709C541.725,465.513 432.263,349.713 295.811,357.465L294.658,357.532L293.758,339.475C314.259,338.685 385.515,336.865 457.769,392.943C564.058,475.436 610.007,636.274 534.39,756.175C533.916,756.927 533.915,756.923 533.438,757.673Z" style="fill:rgb(11,149,199);"/>
</g>
</g>
<path d="M308.29,210.37C279.19,136 183.21,43 29.77,130.68L55.77,195.93C63.327,191.377 71.22,187.406 79.38,184.05L140.38,362.51C128.31,362.68 100.64,360.65 90.49,351.96C79.2,342.3 114.81,394.52 200.79,393.67C299.75,392.7 340.79,293.43 308.29,210.37ZM197.28,345.34L149.71,171.16C179.29,173.83 209.71,189.16 230.89,231.7C259.4,289 231.81,325.79 197.28,345.34Z" style="fill:none;fill-rule:nonzero;stroke:white;stroke-width:5px;"/>
</g>
<g transform="matrix(1.958846,0,0,1.958846,53.870869,50.218665)">
<path d="M392.19,205.85L452.51,182C452.51,182 439.38,111.36 360.12,103.73C280.86,96.1 255,167.94 254.8,195C254.6,222.06 267.09,299.93 339.53,284.5C372.33,277.5 385.19,290.19 383.29,309.2C380.98,332.28 336.11,370.64 268.29,309.65L237,336.56C237,336.56 262,403.62 334.06,403.68C399.06,403.73 457.8,364.68 446.68,289.25C441.03,250.9 407.51,217.39 357.62,221.89C300.62,227.03 323.13,172.89 355.12,171.94C394.19,170.77 392.19,205.85 392.19,205.85Z" style="fill:url(#_Linear3);fill-rule:nonzero;"/>
<clipPath id="_clip4">
<path d="M392.19,205.85L452.51,182C452.51,182 439.38,111.36 360.12,103.73C280.86,96.1 255,167.94 254.8,195C254.6,222.06 267.09,299.93 339.53,284.5C372.33,277.5 385.19,290.19 383.29,309.2C380.98,332.28 336.11,370.64 268.29,309.65L237,336.56C237,336.56 262,403.62 334.06,403.68C399.06,403.73 457.8,364.68 446.68,289.25C441.03,250.9 407.51,217.39 357.62,221.89C300.62,227.03 323.13,172.89 355.12,171.94C394.19,170.77 392.19,205.85 392.19,205.85Z" clip-rule="nonzero"/>
</clipPath>
<g clip-path="url(#_clip4)">
<g transform="matrix(0.510505,0,0,0.510505,-25.969815,-25.636862)">
<path d="M719.298,286.117L719.472,302.116C714.034,302.198 680.645,301.87 647.172,333.748C618.509,361.044 599.916,402.886 602.893,445.447C605.339,480.414 612.06,542.816 672.087,560.368C704.064,569.718 738.525,562.673 746.775,561.646C798.125,555.255 863.977,582.481 844.976,672.066C826.557,758.904 708.786,793.434 604.896,734.778C603.991,734.267 603.996,734.265 603.094,733.751L611.06,719.875C619.352,724.604 666.616,752.472 726.054,747.259C776.183,742.862 819.57,715.532 829.427,668.257C846.074,588.41 787.093,574.077 752.204,577.157C744.709,577.819 735.843,579.959 719.66,580.846C610.634,586.829 593.432,503.624 588.863,467.303C588.402,463.633 586.629,448.059 586.581,437.107C586.387,392.666 606.6,349.614 637.435,320.939C674.957,286.046 712.599,286.218 719.298,286.117Z" style="fill:rgb(51,235,255);"/>
</g>
<g transform="matrix(0.510505,0,0,0.510505,-25.969815,-25.636862)">
<path d="M889.582,670.145C886.387,692.654 874.229,756.889 799.538,791.868C768.431,806.436 738.412,810.153 727.3,811.484C726.145,801.395 716.291,796.674 726.378,795.483C798.043,787.023 862.555,749.781 873.868,667.055C880.827,616.172 906.947,635.6 944.161,600.212L954.792,612.169L889.582,670.145Z" style="fill:rgb(51,235,255);"/>
</g>
<g transform="matrix(0.48417,0,0,0.48417,290.849928,159.731518)">
<path d="M-30.174,347.361C-30.091,347.362 -16.814,346.654 -7.19,358.034C5.418,372.943 0.646,395.741 -16.795,404.414C-33.634,412.787 -53.997,403.726 -59.125,385.688C-64.446,366.968 -50.777,348.122 -31.331,347.382C-31.239,347.379 -31.239,347.381 -30.174,347.361ZM-30.173,366.335C-30.269,366.339 -30.366,366.341 -30.465,366.344C-38.677,366.537 -44.049,375.524 -39.822,382.976C-35.513,390.572 -24.064,390.508 -20.093,382.142C-16.849,375.307 -21.5,366.447 -30.173,366.34C-30.173,366.338 -30.173,366.337 -30.173,366.335ZM-30.173,366.34C-30.173,366.36 -30.254,366.339 -30.234,366.34C-30.214,366.34 -30.193,366.34 -30.173,366.34Z" style="fill:rgb(51,235,255);"/>
</g>
<g transform="matrix(0.409654,0,0,0.409654,348.629981,229.899333)">
<path d="M-30.174,346.881C-30.091,346.882 -16.6,346.163 -6.823,357.724C6.389,373.348 0.742,397.3 -17.929,405.473C-36.437,413.575 -57.656,401.68 -60.452,381.718C-61.661,373.092 -59.166,364.231 -53.287,357.446C-43.552,346.209 -30.382,346.885 -30.174,346.881ZM-30.179,348.606C-30.272,365.541 -31.682,366.793 -30.237,366.819C-30.24,366.819 -30.241,366.82 -30.239,366.821C-40.09,366.999 -44.296,378.834 -37.414,385.24C-30.923,391.283 -20.188,387.144 -19.561,378.146C-19.192,372.846 -23.119,366.95 -30.173,366.82C-30.144,357.461 -30.198,356.892 -30.179,348.606ZM-30.173,366.82C-30.173,366.837 -30.23,366.826 -30.239,366.821C-30.218,366.821 -30.197,366.82 -30.176,366.82C-30.175,366.82 -30.174,366.82 -30.173,366.82Z" style="fill:rgb(51,235,255);"/>
</g>
</g>
<path d="M392.19,205.85L452.51,182C452.51,182 439.38,111.36 360.12,103.73C280.86,96.1 255,167.94 254.8,195C254.6,222.06 267.09,299.93 339.53,284.5C372.33,277.5 385.19,290.19 383.29,309.2C380.98,332.28 336.11,370.64 268.29,309.65L237,336.56C237,336.56 262,403.62 334.06,403.68C399.06,403.73 457.8,364.68 446.68,289.25C441.03,250.9 407.51,217.39 357.62,221.89C300.62,227.03 323.13,172.89 355.12,171.94C394.19,170.77 392.19,205.85 392.19,205.85Z" style="fill:none;fill-rule:nonzero;stroke:white;stroke-width:5px;"/>
</g>
<g transform="matrix(1.958846,0,0,1.958846,104.702924,223.752836)">
<path d="M125.81,223.01L161.59,281.96L234.69,250.76L228.53,178.91" style="fill:none;fill-rule:nonzero;stroke:rgb(125,132,143);stroke-width:9px;"/>
</g>
<g transform="matrix(1.958846,0,0,1.958846,104.702924,223.752836)">
<path d="M250.31,171.25L297.85,221.85L363.95,190.85L351.12,130.21" style="fill:none;fill-rule:nonzero;stroke:rgb(125,132,143);stroke-width:9px;"/>
</g>
<g transform="matrix(1.958846,0,0,1.958846,104.702924,223.752836)">
<path d="M76.64,234.99L78.95,211.91L122.32,223.62L133.9,209.26L204.54,179.63L228.56,179.91L250.31,171.25L264.34,155.33L331.44,124.3L347.49,125.82L335.99,92.65" style="fill:none;fill-rule:nonzero;stroke:rgb(26,26,26);stroke-width:13px;"/>
</g>
<g transform="matrix(1,0,0,1,237.128515,-26.039343)">
<path d="M-30.174,346.85C-30.091,346.852 -16.587,346.132 -6.8,357.704C5.921,372.747 1.262,395.736 -16.236,404.704C-32.978,413.284 -53.484,404.767 -59.286,386.911C-65.4,368.095 -52.217,348.44 -32.518,346.939C-31.347,346.85 -31.349,346.872 -30.174,346.85ZM-30.174,366.848C-30.745,366.832 -35.068,366.825 -38.277,370.619C-43.539,376.841 -40.028,386.366 -32.189,387.87C-23.778,389.483 -17.066,380.949 -20.45,373.218C-21.243,371.406 -22.544,369.843 -24.151,368.729C-26.865,366.847 -29.534,366.855 -30.174,366.85L-30.112,366.85C-30.116,366.85 -30.137,366.849 -30.174,366.848Z" style="fill:rgb(11,149,199);"/>
</g>
<g transform="matrix(0.868104,0,0,0.868104,340.346177,-48.424243)">
<path d="M-30.174,346.515C-30.091,346.517 -16.438,345.789 -6.544,357.488C6.621,373.056 1.318,396.894 -17.096,405.497C-35.045,413.883 -56.201,403.351 -60.405,384.057C-64.507,365.238 -50.552,347.268 -31.363,346.538C-31.268,346.534 -31.269,346.536 -30.174,346.515ZM-30.175,356.504L-30.176,367.186C-30.197,367.185 -30.218,367.185 -30.239,367.185C-30.243,367.185 -30.243,367.186 -30.241,367.187C-39.761,367.366 -43.804,378.792 -37.165,384.972C-30.855,390.847 -20.418,386.751 -19.918,377.991C-19.631,372.952 -23.381,367.313 -30.173,367.186C-30.156,361.786 -30.166,359.208 -30.175,356.504ZM-30.173,367.186C-30.173,367.204 -30.232,367.191 -30.241,367.187C-30.219,367.186 -30.198,367.186 -30.176,367.186C-30.175,367.186 -30.174,367.186 -30.173,367.186Z" style="fill:rgb(11,149,199);"/>
</g>
<g transform="matrix(0.736248,0,0,0.736248,483.945338,73.515167)">
<path d="M-30.174,345.984C-30.091,345.986 -16.202,345.245 -6.139,357.145C7.565,373.35 1.541,398.208 -17.922,406.446C-37.518,414.741 -59.637,401.448 -61.521,380.263C-62.303,371.462 -59.382,362.805 -53.465,356.295C-43.544,345.381 -30.619,345.993 -30.174,345.984ZM-30.175,356.435L-30.176,367.717C-30.198,367.716 -30.22,367.716 -30.242,367.716C-30.246,367.716 -30.247,367.717 -30.245,367.718C-39.336,367.901 -43.117,378.846 -36.717,384.664C-30.745,390.092 -21.176,386.235 -20.475,378.338C-20.018,373.2 -23.745,367.842 -30.173,367.717C-30.155,362.006 -30.166,359.296 -30.175,356.435ZM-30.173,367.717C-30.173,367.735 -30.235,367.723 -30.245,367.718C-30.222,367.717 -30.199,367.717 -30.176,367.717C-30.175,367.717 -30.173,367.717 -30.173,367.717Z" style="fill:rgb(11,149,199);"/>
</g>
</g>
<defs>
<linearGradient id="_Linear1" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(97.382232,67.814092,-67.814092,97.382232,151.218217,203.626226)"><stop offset="0" style="stop-color:rgb(51,235,255);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(11,149,199);stop-opacity:1"/></linearGradient>
<linearGradient id="_Linear3" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(-3.53364,107.893491,-162.475262,-5.321257,318.003313,232.433991)"><stop offset="0" style="stop-color:rgb(51,235,255);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(11,149,199);stop-opacity:1"/></linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 12 KiB

@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<ProjectReference Include="..\DorkNet.Contracts\DorkNet.Contracts.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,83 @@
using DorkNet.Contracts;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace DorkNet.ServiceDefaults;
public static class DorkNetServiceDefaultsExtensions
{
public static WebApplicationBuilder AddDorkNetServiceDefaults(
this WebApplicationBuilder builder,
string serviceName)
{
builder.Services.AddSingleton(new DorkNetServiceInfo(serviceName));
builder.Services.AddHealthChecks();
builder.Services.AddHttpClient();
builder.Services.ConfigureHttpJsonOptions(opt =>
opt.SerializerOptions.PropertyNamingPolicy = null);
return builder;
}
public static WebApplication MapDorkNetServiceDefaults(
this WebApplication app,
bool mapPublicHealth = true)
{
if (mapPublicHealth)
{
app.MapHealthChecks("/healthz");
}
app.MapGet("/internal/healthz", (DorkNetServiceInfo info) =>
{
return Results.Ok(new ServiceHealthResponse(
info.Name,
"ok",
DateTimeOffset.UtcNow));
});
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<Microsoft.Extensions.Configuration.IConfiguration>()?["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<ILoggerFactory>()
.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;
}
}
@@ -0,0 +1,3 @@
namespace DorkNet.ServiceDefaults;
public sealed record DorkNetServiceInfo(string Name);
@@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\DorkNet.Contracts\DorkNet.Contracts.csproj" />
<ProjectReference Include="..\DorkNet.ServiceDefaults\DorkNet.ServiceDefaults.csproj" />
<ProjectReference Include="..\DorkNet.Server\DorkNet.Server.csproj" />
</ItemGroup>
</Project>
+37
View File
@@ -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();
@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}
@@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\DorkNet.Contracts\DorkNet.Contracts.csproj" />
<ProjectReference Include="..\DorkNet.ServiceDefaults\DorkNet.ServiceDefaults.csproj" />
<ProjectReference Include="..\DorkNet.Server\DorkNet.Server.csproj" />
</ItemGroup>
</Project>
+37
View File
@@ -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();
@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}
@@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\DorkNet.Contracts\DorkNet.Contracts.csproj" />
<ProjectReference Include="..\DorkNet.ServiceDefaults\DorkNet.ServiceDefaults.csproj" />
<ProjectReference Include="..\DorkNet.Server\DorkNet.Server.csproj" />
</ItemGroup>
</Project>
+35
View File
@@ -0,0 +1,35 @@
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();
await app.RunDatabaseBootstrapAsync();
app.MapDorkNetServiceDefaults(mapPublicHealth: false);
app.MapGet("/internal/identity/capabilities", () =>
Results.Ok(new ServiceCapabilityResponse(
ServiceNames.Identity,
[
"accounts",
"auth",
"platform-login",
"jwt-issuance",
],
[
"/account/*",
"/api/account/*",
"/api/platformlogin/*",
"/api/auth/*",
])));
app.UseDorkNetRouteOwnershipGuard(ServiceNames.Identity);
app.UseDorkNetPipeline();
app.Run();
@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}
@@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\DorkNet.Contracts\DorkNet.Contracts.csproj" />
<ProjectReference Include="..\DorkNet.ServiceDefaults\DorkNet.ServiceDefaults.csproj" />
<ProjectReference Include="..\DorkNet.Server\DorkNet.Server.csproj" />
</ItemGroup>
</Project>
+37
View File
@@ -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();
@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}
@@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\DorkNet.Contracts\DorkNet.Contracts.csproj" />
<ProjectReference Include="..\DorkNet.ServiceDefaults\DorkNet.ServiceDefaults.csproj" />
<ProjectReference Include="..\DorkNet.Server\DorkNet.Server.csproj" />
</ItemGroup>
</Project>
+34
View File
@@ -0,0 +1,34 @@
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();
await app.RunDatabaseBootstrapAsync();
app.MapDorkNetServiceDefaults(mapPublicHealth: false);
app.MapGet("/internal/notify/capabilities", () =>
Results.Ok(new ServiceCapabilityResponse(
ServiceNames.Notify,
[
"signalr-edge",
"notification-fanout",
"presence",
"player-request-logs",
],
[
"/hub/v1",
"/api/notification/*",
"/player/*presence*",
])));
app.UseDorkNetRouteOwnershipGuard(ServiceNames.Notify);
app.UseDorkNetPipeline();
app.Run();
+8
View File
@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}
@@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\DorkNet.Contracts\DorkNet.Contracts.csproj" />
<ProjectReference Include="..\DorkNet.ServiceDefaults\DorkNet.ServiceDefaults.csproj" />
<ProjectReference Include="..\DorkNet.Server\DorkNet.Server.csproj" />
</ItemGroup>
</Project>
+37
View File
@@ -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();
@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}
@@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\DorkNet.Contracts\DorkNet.Contracts.csproj" />
<ProjectReference Include="..\DorkNet.ServiceDefaults\DorkNet.ServiceDefaults.csproj" />
<ProjectReference Include="..\DorkNet.Server\DorkNet.Server.csproj" />
</ItemGroup>
</Project>
+37
View File
@@ -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.Rooms);
builder.AddDorkNetServices();
var app = builder.Build();
await app.RunDatabaseBootstrapAsync();
app.MapDorkNetServiceDefaults(mapPublicHealth: false);
app.MapGet("/internal/rooms/capabilities", () =>
Results.Ok(new ServiceCapabilityResponse(
ServiceNames.Rooms,
[
"rooms",
"subrooms",
"room-keys",
"matchmaking",
"discovery",
],
[
"/rooms/*",
"/api/rooms/*",
"/roomserver/*",
"/v1/find",
"/v1/join/*",
])));
app.UseDorkNetRouteOwnershipGuard(ServiceNames.Rooms);
app.UseDorkNetPipeline();
app.Run();
+8
View File
@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}
@@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\DorkNet.Contracts\DorkNet.Contracts.csproj" />
<ProjectReference Include="..\DorkNet.ServiceDefaults\DorkNet.ServiceDefaults.csproj" />
<ProjectReference Include="..\DorkNet.Server\DorkNet.Server.csproj" />
</ItemGroup>
</Project>
+37
View File
@@ -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();
+8
View File
@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}
@@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\DorkNet.Contracts\DorkNet.Contracts.csproj" />
<ProjectReference Include="..\DorkNet.ServiceDefaults\DorkNet.ServiceDefaults.csproj" />
<ProjectReference Include="..\DorkNet.Server\DorkNet.Server.csproj" />
</ItemGroup>
<ItemGroup>
<DorkNetServerWebAsset Include="..\DorkNet.Server\wwwroot\**\*" />
</ItemGroup>
<Target Name="CopyDorkNetServerWebAssets"
AfterTargets="Publish"
Condition="'$(DorkNetServiceContainerPublish)' == 'true'">
<Copy SourceFiles="@(DorkNetServerWebAsset)"
DestinationFiles="@(DorkNetServerWebAsset->'$(PublishDir)wwwroot/%(RecursiveDir)%(Filename)%(Extension)')"
SkipUnchangedFiles="true" />
</Target>
</Project>
+36
View File
@@ -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();
+8
View File
@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}
+199 -17
View File
@@ -7,12 +7,38 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DorkNet.Server", "DorkNet.S
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DorkNet.Models", "DorkNet.Models\DorkNet.Models.csproj", "{8A1F0C3A-5763-4176-B33E-F4EA89BAF999}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DorkNet.Models", "DorkNet.Models\DorkNet.Models.csproj", "{8A1F0C3A-5763-4176-B33E-F4EA89BAF999}"
EndProject 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}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DorkNet.AdminMobile", "DorkNet.AdminMobile\DorkNet.AdminMobile.csproj", "{C2265F00-DFC5-4352-A94C-B887E3C0E5CA}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DorkNet.Contracts", "DorkNet.Contracts\DorkNet.Contracts.csproj", "{7D9E995E-5333-43B2-848B-3522C6C3D893}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DorkNet.ServiceDefaults", "DorkNet.ServiceDefaults\DorkNet.ServiceDefaults.csproj", "{0A66087B-37DF-401D-AFAC-01E7324C2460}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DorkNet.Gateway", "DorkNet.Gateway\DorkNet.Gateway.csproj", "{1A7D03D5-DAC5-474C-9A21-8F2EEF82396F}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DorkNet.Services.Identity", "DorkNet.Services.Identity\DorkNet.Services.Identity.csproj", "{E85B21BA-ED08-4CED-ADB5-6B5E5ADEEBD1}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DorkNet.Services.Rooms", "DorkNet.Services.Rooms\DorkNet.Services.Rooms.csproj", "{3F01BA71-BD1E-461B-BCD0-F2E9CC92B640}"
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
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tools", "tools", "{07C2787E-EAC7-C090-1BA3-A61EC2A24D84}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DorkNet.ApiRouteCoverage", "tools\DorkNet.ApiRouteCoverage\DorkNet.ApiRouteCoverage.csproj", "{6BC447E7-3158-4FEC-81D4-506452157810}"
EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU Debug|Any CPU = Debug|Any CPU
@@ -47,18 +73,6 @@ Global
{8A1F0C3A-5763-4176-B33E-F4EA89BAF999}.Release|x64.Build.0 = Release|Any CPU {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.ActiveCfg = Release|Any CPU
{8A1F0C3A-5763-4176-B33E-F4EA89BAF999}.Release|x86.Build.0 = 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.ActiveCfg = Debug|Any CPU
{C2265F00-DFC5-4352-A94C-B887E3C0E5CA}.Debug|Any CPU.Build.0 = 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 {C2265F00-DFC5-4352-A94C-B887E3C0E5CA}.Debug|x64.ActiveCfg = Debug|Any CPU
@@ -71,11 +85,179 @@ Global
{C2265F00-DFC5-4352-A94C-B887E3C0E5CA}.Release|x64.Build.0 = Release|Any CPU {C2265F00-DFC5-4352-A94C-B887E3C0E5CA}.Release|x64.Build.0 = Release|Any CPU
{C2265F00-DFC5-4352-A94C-B887E3C0E5CA}.Release|x86.ActiveCfg = Release|Any CPU {C2265F00-DFC5-4352-A94C-B887E3C0E5CA}.Release|x86.ActiveCfg = Release|Any CPU
{C2265F00-DFC5-4352-A94C-B887E3C0E5CA}.Release|x86.Build.0 = Release|Any CPU {C2265F00-DFC5-4352-A94C-B887E3C0E5CA}.Release|x86.Build.0 = Release|Any CPU
{7D9E995E-5333-43B2-848B-3522C6C3D893}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{7D9E995E-5333-43B2-848B-3522C6C3D893}.Debug|Any CPU.Build.0 = Debug|Any CPU
{7D9E995E-5333-43B2-848B-3522C6C3D893}.Debug|x64.ActiveCfg = Debug|Any CPU
{7D9E995E-5333-43B2-848B-3522C6C3D893}.Debug|x64.Build.0 = Debug|Any CPU
{7D9E995E-5333-43B2-848B-3522C6C3D893}.Debug|x86.ActiveCfg = Debug|Any CPU
{7D9E995E-5333-43B2-848B-3522C6C3D893}.Debug|x86.Build.0 = Debug|Any CPU
{7D9E995E-5333-43B2-848B-3522C6C3D893}.Release|Any CPU.ActiveCfg = Release|Any CPU
{7D9E995E-5333-43B2-848B-3522C6C3D893}.Release|Any CPU.Build.0 = Release|Any CPU
{7D9E995E-5333-43B2-848B-3522C6C3D893}.Release|x64.ActiveCfg = Release|Any CPU
{7D9E995E-5333-43B2-848B-3522C6C3D893}.Release|x64.Build.0 = Release|Any CPU
{7D9E995E-5333-43B2-848B-3522C6C3D893}.Release|x86.ActiveCfg = Release|Any CPU
{7D9E995E-5333-43B2-848B-3522C6C3D893}.Release|x86.Build.0 = Release|Any CPU
{0A66087B-37DF-401D-AFAC-01E7324C2460}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{0A66087B-37DF-401D-AFAC-01E7324C2460}.Debug|Any CPU.Build.0 = Debug|Any CPU
{0A66087B-37DF-401D-AFAC-01E7324C2460}.Debug|x64.ActiveCfg = Debug|Any CPU
{0A66087B-37DF-401D-AFAC-01E7324C2460}.Debug|x64.Build.0 = Debug|Any CPU
{0A66087B-37DF-401D-AFAC-01E7324C2460}.Debug|x86.ActiveCfg = Debug|Any CPU
{0A66087B-37DF-401D-AFAC-01E7324C2460}.Debug|x86.Build.0 = Debug|Any CPU
{0A66087B-37DF-401D-AFAC-01E7324C2460}.Release|Any CPU.ActiveCfg = Release|Any CPU
{0A66087B-37DF-401D-AFAC-01E7324C2460}.Release|Any CPU.Build.0 = Release|Any CPU
{0A66087B-37DF-401D-AFAC-01E7324C2460}.Release|x64.ActiveCfg = Release|Any CPU
{0A66087B-37DF-401D-AFAC-01E7324C2460}.Release|x64.Build.0 = Release|Any CPU
{0A66087B-37DF-401D-AFAC-01E7324C2460}.Release|x86.ActiveCfg = Release|Any CPU
{0A66087B-37DF-401D-AFAC-01E7324C2460}.Release|x86.Build.0 = Release|Any CPU
{1A7D03D5-DAC5-474C-9A21-8F2EEF82396F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{1A7D03D5-DAC5-474C-9A21-8F2EEF82396F}.Debug|Any CPU.Build.0 = Debug|Any CPU
{1A7D03D5-DAC5-474C-9A21-8F2EEF82396F}.Debug|x64.ActiveCfg = Debug|Any CPU
{1A7D03D5-DAC5-474C-9A21-8F2EEF82396F}.Debug|x64.Build.0 = Debug|Any CPU
{1A7D03D5-DAC5-474C-9A21-8F2EEF82396F}.Debug|x86.ActiveCfg = Debug|Any CPU
{1A7D03D5-DAC5-474C-9A21-8F2EEF82396F}.Debug|x86.Build.0 = Debug|Any CPU
{1A7D03D5-DAC5-474C-9A21-8F2EEF82396F}.Release|Any CPU.ActiveCfg = Release|Any CPU
{1A7D03D5-DAC5-474C-9A21-8F2EEF82396F}.Release|Any CPU.Build.0 = Release|Any CPU
{1A7D03D5-DAC5-474C-9A21-8F2EEF82396F}.Release|x64.ActiveCfg = Release|Any CPU
{1A7D03D5-DAC5-474C-9A21-8F2EEF82396F}.Release|x64.Build.0 = Release|Any CPU
{1A7D03D5-DAC5-474C-9A21-8F2EEF82396F}.Release|x86.ActiveCfg = Release|Any CPU
{1A7D03D5-DAC5-474C-9A21-8F2EEF82396F}.Release|x86.Build.0 = Release|Any CPU
{E85B21BA-ED08-4CED-ADB5-6B5E5ADEEBD1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{E85B21BA-ED08-4CED-ADB5-6B5E5ADEEBD1}.Debug|Any CPU.Build.0 = Debug|Any CPU
{E85B21BA-ED08-4CED-ADB5-6B5E5ADEEBD1}.Debug|x64.ActiveCfg = Debug|Any CPU
{E85B21BA-ED08-4CED-ADB5-6B5E5ADEEBD1}.Debug|x64.Build.0 = Debug|Any CPU
{E85B21BA-ED08-4CED-ADB5-6B5E5ADEEBD1}.Debug|x86.ActiveCfg = Debug|Any CPU
{E85B21BA-ED08-4CED-ADB5-6B5E5ADEEBD1}.Debug|x86.Build.0 = Debug|Any CPU
{E85B21BA-ED08-4CED-ADB5-6B5E5ADEEBD1}.Release|Any CPU.ActiveCfg = Release|Any CPU
{E85B21BA-ED08-4CED-ADB5-6B5E5ADEEBD1}.Release|Any CPU.Build.0 = Release|Any CPU
{E85B21BA-ED08-4CED-ADB5-6B5E5ADEEBD1}.Release|x64.ActiveCfg = Release|Any CPU
{E85B21BA-ED08-4CED-ADB5-6B5E5ADEEBD1}.Release|x64.Build.0 = Release|Any CPU
{E85B21BA-ED08-4CED-ADB5-6B5E5ADEEBD1}.Release|x86.ActiveCfg = Release|Any CPU
{E85B21BA-ED08-4CED-ADB5-6B5E5ADEEBD1}.Release|x86.Build.0 = Release|Any CPU
{3F01BA71-BD1E-461B-BCD0-F2E9CC92B640}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{3F01BA71-BD1E-461B-BCD0-F2E9CC92B640}.Debug|Any CPU.Build.0 = Debug|Any CPU
{3F01BA71-BD1E-461B-BCD0-F2E9CC92B640}.Debug|x64.ActiveCfg = Debug|Any CPU
{3F01BA71-BD1E-461B-BCD0-F2E9CC92B640}.Debug|x64.Build.0 = Debug|Any CPU
{3F01BA71-BD1E-461B-BCD0-F2E9CC92B640}.Debug|x86.ActiveCfg = Debug|Any CPU
{3F01BA71-BD1E-461B-BCD0-F2E9CC92B640}.Debug|x86.Build.0 = Debug|Any CPU
{3F01BA71-BD1E-461B-BCD0-F2E9CC92B640}.Release|Any CPU.ActiveCfg = Release|Any CPU
{3F01BA71-BD1E-461B-BCD0-F2E9CC92B640}.Release|Any CPU.Build.0 = Release|Any CPU
{3F01BA71-BD1E-461B-BCD0-F2E9CC92B640}.Release|x64.ActiveCfg = Release|Any CPU
{3F01BA71-BD1E-461B-BCD0-F2E9CC92B640}.Release|x64.Build.0 = Release|Any CPU
{3F01BA71-BD1E-461B-BCD0-F2E9CC92B640}.Release|x86.ActiveCfg = Release|Any CPU
{3F01BA71-BD1E-461B-BCD0-F2E9CC92B640}.Release|x86.Build.0 = Release|Any CPU
{5A6D7277-0974-4F12-AD9D-C705F5CFE19C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{5A6D7277-0974-4F12-AD9D-C705F5CFE19C}.Debug|Any CPU.Build.0 = Debug|Any CPU
{5A6D7277-0974-4F12-AD9D-C705F5CFE19C}.Debug|x64.ActiveCfg = Debug|Any CPU
{5A6D7277-0974-4F12-AD9D-C705F5CFE19C}.Debug|x64.Build.0 = Debug|Any CPU
{5A6D7277-0974-4F12-AD9D-C705F5CFE19C}.Debug|x86.ActiveCfg = Debug|Any CPU
{5A6D7277-0974-4F12-AD9D-C705F5CFE19C}.Debug|x86.Build.0 = Debug|Any CPU
{5A6D7277-0974-4F12-AD9D-C705F5CFE19C}.Release|Any CPU.ActiveCfg = Release|Any CPU
{5A6D7277-0974-4F12-AD9D-C705F5CFE19C}.Release|Any CPU.Build.0 = Release|Any CPU
{5A6D7277-0974-4F12-AD9D-C705F5CFE19C}.Release|x64.ActiveCfg = Release|Any CPU
{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
{6BC447E7-3158-4FEC-81D4-506452157810}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{6BC447E7-3158-4FEC-81D4-506452157810}.Debug|Any CPU.Build.0 = Debug|Any CPU
{6BC447E7-3158-4FEC-81D4-506452157810}.Debug|x64.ActiveCfg = Debug|Any CPU
{6BC447E7-3158-4FEC-81D4-506452157810}.Debug|x64.Build.0 = Debug|Any CPU
{6BC447E7-3158-4FEC-81D4-506452157810}.Debug|x86.ActiveCfg = Debug|Any CPU
{6BC447E7-3158-4FEC-81D4-506452157810}.Debug|x86.Build.0 = Debug|Any CPU
{6BC447E7-3158-4FEC-81D4-506452157810}.Release|Any CPU.ActiveCfg = Release|Any CPU
{6BC447E7-3158-4FEC-81D4-506452157810}.Release|Any CPU.Build.0 = Release|Any CPU
{6BC447E7-3158-4FEC-81D4-506452157810}.Release|x64.ActiveCfg = Release|Any CPU
{6BC447E7-3158-4FEC-81D4-506452157810}.Release|x64.Build.0 = Release|Any CPU
{6BC447E7-3158-4FEC-81D4-506452157810}.Release|x86.ActiveCfg = Release|Any CPU
{6BC447E7-3158-4FEC-81D4-506452157810}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection EndGlobalSection
GlobalSection(SolutionProperties) = preSolution GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE HideSolutionNode = FALSE
EndGlobalSection EndGlobalSection
GlobalSection(NestedProjects) = preSolution GlobalSection(NestedProjects) = preSolution
{70C60098-1B11-4C13-943B-A8A056429EAE} = {07C2787E-EAC7-C090-1BA3-A61EC2A24D84} {6BC447E7-3158-4FEC-81D4-506452157810} = {07C2787E-EAC7-C090-1BA3-A61EC2A24D84}
EndGlobalSection EndGlobalSection
EndGlobal EndGlobal
+50 -5
View File
@@ -23,6 +23,7 @@ main also auto-detects your install.
| **Version key** (sent as `X-DorkNet-Version` header) | `december_2020_12_18` | | **Version key** (sent as `X-DorkNet-Version` header) | `december_2020_12_18` |
| **Version plugin** | `DorkNet.Server/Versions/Late2020/Late2020VersionPlugin.cs` | | **Version plugin** | `DorkNet.Server/Versions/Late2020/Late2020VersionPlugin.cs` |
| **Schema** | One EF `Initial` migration; SQLite + Postgres both work | | **Schema** | One EF `Initial` migration; SQLite + Postgres both work |
| **Runtime topology** | Gateway-fronted microservices with dedicated service slices and a monolith fallback |
| **Diverges from `march-2020-03-10` by** | ~170 files | | **Diverges from `march-2020-03-10` by** | ~170 files |
Subsystems present on this branch that the March branch doesn't have: Subsystems present on this branch that the March branch doesn't have:
@@ -58,7 +59,7 @@ cd DorkNet
| Config key | Env var | Required | Notes | | Config key | Env var | Required | Notes |
|---|---|---|---| |---|---|---|---|
| `Jwt:Secret` | `Jwt__Secret` | yes | ≥64 random chars | | `Jwt:Secret` | `DORKNET_JWT_SECRET` or `Jwt__Secret` | yes | >=64 random chars |
| `Photon:AppId` | `Photon__AppId` | yes | Photon Realtime AppId (free at dashboard.photonengine.com) | | `Photon:AppId` | `Photon__AppId` | yes | Photon Realtime AppId (free at dashboard.photonengine.com) |
| `Photon:VoiceAppId` | `Photon__VoiceAppId` | no | only if voice chat | | `Photon:VoiceAppId` | `Photon__VoiceAppId` | no | only if voice chat |
| `Photon:CloudRegion` | `Photon__CloudRegion` | no | defaults to `us` | | `Photon:CloudRegion` | `Photon__CloudRegion` | no | defaults to `us` |
@@ -66,8 +67,12 @@ cd DorkNet
| `ConnectionStrings:Default` | `ConnectionStrings__Default` | postgres-only | full Npgsql connection string | | `ConnectionStrings:Default` | `ConnectionStrings__Default` | postgres-only | full Npgsql connection string |
| `ConnectionStrings:Redis` | `ConnectionStrings__Redis` | no | enables SignalR backplane | | `ConnectionStrings:Redis` | `ConnectionStrings__Redis` | no | enables SignalR backplane |
| `Domain:Apex` | `DORKNET_DOMAIN` | no | defaults to `localhost`; set for production | | `Domain:Apex` | `DORKNET_DOMAIN` | no | defaults to `localhost`; set for production |
| `S3:Endpoint` | `S3__Endpoint` | production | S3-compatible API endpoint for blobs/images |
| `S3:AccessKey` | `S3__AccessKey` | production | S3 access key |
| `S3:SecretKey` | `S3__SecretKey` | production | S3 secret key |
| `S3:Region` | `S3__Region` | no | defaults to `garage`; use `auto` for R2 |
**4. Boot:** **4. Boot a local standalone server:**
```pwsh ```pwsh
dotnet run --project DorkNet.Server dotnet run --project DorkNet.Server
@@ -79,8 +84,9 @@ categories / playlists, and starts listening. You should see
`Application started.` in the log; `curl http://localhost:8080/healthz` `Application started.` in the log; `curl http://localhost:8080/healthz`
returns 200. returns 200.
For a full Docker / VPS deploy, see For the production microservices stack, Docker, and Dokploy deploy
[main's Advanced setup guide](../../blob/main/docs/advanced-setup.md). notes, see
[docs/deploy.md](docs/deploy.md).
--- ---
@@ -194,15 +200,54 @@ Default `0` (off).
| Folder | Purpose | | Folder | Purpose |
|---|---| |---|---|
| `DorkNet.Server/` | ASP.NET Core backend (controllers, services, data, hubs, middleware) | | `DorkNet.Server/` | Shared ASP.NET Core controller/service stack, standalone fallback, monolith fallback |
| `DorkNet.Models/` | DTOs shared between server and client mod | | `DorkNet.Models/` | DTOs shared between server and client mod |
| `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 | | `DorkNet.ClientMod/` | MelonLoader IL2CPP mod — the client patcher |
| `tools/` | Installers (`install-melon.ps1`, `install-legacy-client.ps1`, `remove-eac.ps1`), Cloudflare tunnel templates | | `tools/` | Installers (`install-melon.ps1`, `install-legacy-client.ps1`, `remove-eac.ps1`), Cloudflare tunnel templates |
| `Dockerfile`, `Dockerfile.service`, `docker-compose.microservices*.yml` | Standalone fallback image, reusable service image, and local/Dokploy microservices entrypoints |
See [docs/architecture.md](docs/architecture.md) for the full mental See [docs/architecture.md](docs/architecture.md) for the full mental
model (project layout, request lifecycle, watch-mirror controller model (project layout, request lifecycle, watch-mirror controller
pattern, where to start contributing). pattern, where to start contributing).
### Microservices
The production topology is the gateway-fronted compose stack.
`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.
The gateway routes owned host/path slices to the dedicated service hosts.
The `web` service owns the apex, `www`, `admin`, and `feed` static 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:
```pwsh
$env:DORKNET_S3_ENDPOINT="https://your-s3-endpoint"
$env:DORKNET_S3_ACCESS_KEY="..."
$env:DORKNET_S3_SECRET_KEY="..."
$env:DORKNET_S3_REGION="garage" # or auto for R2
docker compose -f docker-compose.microservices.yml up --build
```
For deployment details, including Dokploy env vars, Cloudflare Tunnel
routing, external Postgres, and S3 setup, see
[docs/deploy.md](docs/deploy.md).
For the browser admin console, its routes, auth model, and production
troubleshooting, see [docs/admin.md](docs/admin.md).
--- ---
## Database & migrations ## Database & migrations
+218
View File
@@ -0,0 +1,218 @@
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: ${DORKNET_POSTGRES_CONNECTION_STRING:-Host=postgres;Port=5432;Database=dorknet;Username=dorknet;Password=${POSTGRES_PASSWORD:-dorknet_dev}}
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}
Photon__AppId: ${Photon__AppId:?Set Photon__AppId in Dokploy}
Photon__VoiceAppId: ${Photon__VoiceAppId:-}
Photon__CloudRegion: ${Photon__CloudRegion:-eu}
DorkNet__DefaultClientVersion: ${DORKNET_DEFAULT_CLIENT_VERSION:-december_2020_12_18}
DorkNet__SupportedVersions__0: ${DORKNET_SUPPORTED_VERSION:-december_2020_12_18}
DorkNet__BuildIdToVersionKey__20201210: ${DORKNET_DECEMBER_BUILD_VERSION_KEY:-december_2020_12_18}
x-dorknet-app-networks: &dorknet-app-networks
networks:
- default
- dokploy-network
services:
gateway:
build:
context: .
dockerfile: Dockerfile.service
args:
PROJECT_PATH: DorkNet.Gateway/DorkNet.Gateway.csproj
APP_DLL: DorkNet.Gateway.dll
environment:
DORKNET_DOMAIN: ${DORKNET_DOMAIN:?Set DORKNET_DOMAIN in Dokploy}
DorkNet__Services__Identity: http://identity:8080
DorkNet__Services__Rooms: http://rooms:8080
DorkNet__Services__Notify: http://notify:8080
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
command: tunnel --no-autoupdate run --token ${CLOUDFLARE_TUNNEL_TOKEN:?Set CLOUDFLARE_TUNNEL_TOKEN in Dokploy}
depends_on:
- gateway
restart: unless-stopped
identity:
build:
context: .
dockerfile: Dockerfile.service
args:
PROJECT_PATH: DorkNet.Services.Identity/DorkNet.Services.Identity.csproj
APP_DLL: DorkNet.Services.Identity.dll
environment:
<<: *dorknet-service-env
<<: *dorknet-app-networks
depends_on:
- postgres
- redis
rooms:
build:
context: .
dockerfile: Dockerfile.service
args:
PROJECT_PATH: DorkNet.Services.Rooms/DorkNet.Services.Rooms.csproj
APP_DLL: DorkNet.Services.Rooms.dll
environment:
<<: *dorknet-service-env
<<: *dorknet-app-networks
depends_on:
- postgres
- redis
notify:
build:
context: .
dockerfile: Dockerfile.service
args:
PROJECT_PATH: DorkNet.Services.Notify/DorkNet.Services.Notify.csproj
APP_DLL: DorkNet.Services.Notify.dll
environment:
<<: *dorknet-service-env
<<: *dorknet-app-networks
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
<<: *dorknet-app-networks
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
<<: *dorknet-app-networks
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
<<: *dorknet-app-networks
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
<<: *dorknet-app-networks
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
<<: *dorknet-app-networks
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
<<: *dorknet-app-networks
depends_on:
- postgres
- redis
monolith:
build:
context: .
dockerfile: Dockerfile
environment:
<<: *dorknet-service-env
<<: *dorknet-app-networks
depends_on:
- postgres
- redis
postgres:
image: postgres:16-alpine
environment:
POSTGRES_DB: dorknet
POSTGRES_USER: dorknet
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-dorknet_dev}
volumes:
- microservices-postgres:/var/lib/postgresql/data
redis:
image: redis:7-alpine
volumes:
microservices-postgres:
networks:
dokploy-network:
external: true
+192
View File
@@ -0,0 +1,192 @@
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: ${DORKNET_POSTGRES_CONNECTION_STRING:-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}
Photon__AppId: ${Photon__AppId:-}
Photon__VoiceAppId: ${Photon__VoiceAppId:-}
Photon__CloudRegion: ${Photon__CloudRegion:-eu}
DorkNet__DefaultClientVersion: ${DORKNET_DEFAULT_CLIENT_VERSION:-december_2020_12_18}
DorkNet__SupportedVersions__0: ${DORKNET_SUPPORTED_VERSION:-december_2020_12_18}
DorkNet__BuildIdToVersionKey__20201210: ${DORKNET_DECEMBER_BUILD_VERSION_KEY:-december_2020_12_18}
services:
gateway:
build:
context: .
dockerfile: Dockerfile.service
args:
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__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:
context: .
dockerfile: Dockerfile.service
args:
PROJECT_PATH: DorkNet.Services.Identity/DorkNet.Services.Identity.csproj
APP_DLL: DorkNet.Services.Identity.dll
environment:
<<: *dorknet-service-env
depends_on:
- postgres
- redis
rooms:
build:
context: .
dockerfile: Dockerfile.service
args:
PROJECT_PATH: DorkNet.Services.Rooms/DorkNet.Services.Rooms.csproj
APP_DLL: DorkNet.Services.Rooms.dll
environment:
<<: *dorknet-service-env
depends_on:
- postgres
- redis
notify:
build:
context: .
dockerfile: Dockerfile.service
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:
image: postgres:16-alpine
environment:
POSTGRES_DB: dorknet
POSTGRES_USER: dorknet
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-dorknet_dev}
volumes:
- microservices-postgres:/var/lib/postgresql/data
redis:
image: redis:7-alpine
volumes:
microservices-postgres:
+136
View File
@@ -0,0 +1,136 @@
# DorkNet Admin Page
The admin page is the browser operations console for DorkNet. In the
microservices deployment it lives at `https://admin.<domain>/` and is
served by the `web` service from the built Vite SPA assets.
The admin backend endpoints are under `/api/admin/v1`. Requests from the
SPA are same-origin, so `https://admin.<domain>/api/admin/v1/*` is also
handled by the `web` service route on the admin host. The same
`/api/admin/*` route family is available as a path-owned moderation slice
for non-admin-host routing, so `/internal/routes` can show moderation as
the owner for that path family.
## Runtime Routing
| Item | Value |
|---|---|
| SPA source | `DorkNet.Server/admin-ui` |
| Build output | `DorkNet.Server/wwwroot/admin` |
| Container path | `/app/wwwroot/admin` |
| Public browser host | `admin.<domain>` |
| API base used by the SPA | `/api/admin/v1` |
| Login endpoint | `POST /api/admin/v1/login` |
| Same-origin admin-host service | `web` |
| Path-routed admin API slice | `moderation` |
`DorkNet.Gateway` preserves the original Host header when it proxies to
backend services. That matters for the admin page: the `admin.<domain>`
host route must reach `web`, and `web` must contain
`/app/wwwroot/admin/index.html`.
## Authentication
The first account created on a fresh database is promoted to admin so
there is always a bootstrap operator. The login form posts to
`/api/admin/v1/login`; the SPA stores the returned values in browser
`localStorage`:
| Key | Contents |
|---|---|
| `dorknet.admin.token` | DorkNet admin JWT |
| `dorknet.admin.me` | Admin identity object used by the layout |
Every admin API request sends `Authorization: Bearer <token>`. Protected
actions are guarded by `AdminOnlyAttribute`, which checks the resolved
player row still has `IsAdmin = true`. A `401` clears the local session
and sends the browser back to login.
If the admin host is public, put it behind Cloudflare Access or an
equivalent outer access control. The DorkNet JWT is still required after
that outer check.
## Navigation
| Section | Routes | Workflows |
|---|---|---|
| Overview | `/` | Live ops dashboard, online players, active sessions, quick kick/ban/broadcast actions |
| Moderation | `/players` | Player directory, bans, reports, per-player ban/grant/gift/password/avatar/account actions |
| Activity | `/activity` | Admin audit log and per-player request logs |
| Content | `/rooms`, `/rooms/:id`, `/import-room`, `/content` | Room list/detail, room import, instances, leaderboards, community board, loading tips |
| Operations | `/broadcast`, `/settings` | Server broadcast, server toggles, signup codes, weekly challenges, Play menu tags, Rec Center doors, game config values |
Several older admin URLs are kept as redirects:
| Old route | Current route |
|---|---|
| `/bans` | `/players?tab=bans` |
| `/reports` | `/players?tab=reports` |
| `/gift`, `/passwords`, `/grants` | `/players` |
| `/audit` | `/activity?tab=audit` |
| `/logs` | `/activity?tab=logs` |
| `/rr-originals`, `/instances`, `/leaderboards` | `/rooms` |
| `/community` | `/content?tab=community` |
| `/loading-tips` | `/content?tab=tips` |
| `/signup-codes` | `/settings?tab=signup` |
`/import-room-legacy` still exists for the legacy room importer.
## Build And Deploy
The service Dockerfile builds the admin SPA for service images that need
static assets. In the microservices stack the `web` image must include:
```text
/app/wwwroot/admin/index.html
/app/wwwroot/admin/assets/*
```
The admin SPA calls relative URLs, so it does not need a configured API
origin as long as the browser is opened on `https://admin.<domain>/`.
The native admin mobile app points at the same public admin host; see
[`../DorkNet.AdminMobile/README.md`](../DorkNet.AdminMobile/README.md).
## Smoke Checks
Check the static host:
```bash
curl -I https://admin.yourdomain.com/
```
Check that protected admin API routing is alive. A `401` is expected
without a valid admin token:
```bash
curl -i https://admin.yourdomain.com/api/admin/v1/stats
```
Check the built assets inside the running `web` container:
```bash
docker exec <web-container> sh -lc 'find /app/wwwroot/admin -maxdepth 2 -type f | sort | head -50'
```
Useful logs:
```bash
docker logs <stack>-gateway-1 --tail=200
docker logs <stack>-web-1 --tail=200
docker logs <stack>-moderation-1 --tail=200
```
For normal browser admin traffic, start with `gateway` and `web`.
Check `moderation` when you are testing `/api/admin/*` through a
non-admin host such as `api.<domain>`.
## Troubleshooting
| Symptom | Check |
|---|---|
| `admin.<domain>/` returns 404 | `web` logs for `probe="/app/wwwroot/admin/index.html" exists=False`; rebuild/redeploy the `web` image |
| Admin page loads but actions 404 | Gateway `/internal/routes`, then whether the request host is `admin.<domain>` or a path-routed host |
| Login or actions return 401 | Token expired, local session cleared, or the player no longer has `IsAdmin = true` |
| Browser spins forever | Open the browser network tab, then check `gateway` and `web` logs for the stuck URL |
| Upload/import fails near 100 MB | Use the chunked importer; the SPA uses 50 MB chunks to stay below Cloudflare request limits |
+94 -17
View File
@@ -14,15 +14,39 @@ files in this folder.
``` ```
DorkNet.sln DorkNet.sln
├── DorkNet.Server ASP.NET Core 9 backend — the bulk of the code ├── DorkNet.Server Shared ASP.NET Core backend + monolith fallback
├── DorkNet.Models Shared DTO + serialization types ├── DorkNet.Models Shared DTO + serialization types
└── DorkNet.ClientMod MelonLoader 6 IL2CPP mod — the client patcher ├── DorkNet.Contracts Shared service contracts for the service split
├── DorkNet.ServiceDefaults Shared service-host setup
├── 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
``` ```
| Project | Responsibility | Target | | Project | Responsibility | Target |
|---|---|---| |---|---|---|
| `DorkNet.Server` | REST API, SignalR hub, admin SPA host, static-site host, image CDN, matchmaking, persistence | .NET 9 | | `DorkNet.Server` | Shared REST/controller/service stack, database bootstrap, standalone fallback, monolith fallback | .NET 9 |
| `DorkNet.Models` | DTOs the wire protocol speaks. No project dependencies. | .NET 9 | | `DorkNet.Models` | DTOs the wire protocol speaks. No project dependencies. | .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, path-routed admin API, testcase routes | .NET 9 |
| `DorkNet.Services.Web` | Public site, admin static host, same-origin admin browser API, feed static host, site API | .NET 9 |
| `DorkNet.ClientMod` | MelonLoader 0.6.x IL2CPP mod — the client patcher, JSON config | .NET 6 | | `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 `ClientMod` applies the client-side patches needed to point the 2020
@@ -48,9 +72,57 @@ DorkNet.Server/
├── admin-ui/ Vite + React SPA source — builds to wwwroot/admin/ ├── admin-ui/ Vite + React SPA source — builds to wwwroot/admin/
├── site/ Public website Vite SPA source — builds to wwwroot/site/ ├── site/ Public website Vite SPA source — builds to wwwroot/site/
├── wwwroot/ Static file root, with per-host subdirs (admin/, site/, feed/) ├── wwwroot/ Static file root, with per-host subdirs (admin/, site/, feed/)
└── Program.cs Host + DI + middleware pipeline (split candidates noted at the bottom) ├── Startup/ Host composition, DI registration, database bootstrap, middleware pipeline
└── Program.cs Short composition root
``` ```
## Runtime topology
The production runtime is the gateway-fronted service split.
`DorkNet.Server` remains available as the shared compatibility runtime
and as a standalone debug/fallback image. In the compose stack it runs as
`monolith` behind the gateway for route families that have not been
assigned to a dedicated service yet.
The service split works like this:
- `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.Gateway` exposes `/internal/services` and
`/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, all dedicated service
slices, the monolith fallback, and Compose-managed Postgres and Redis for
local testing. `docker-compose.microservices.dokploy.yml` is the Dokploy
production 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.
`Dockerfile.service` is the reusable service image. It accepts
`PROJECT_PATH` and `APP_DLL` build args, rebuilds the admin/site Vite
SPAs for service images that need static assets, publishes the selected
.NET project, and runs that DLL in the runtime image. The root
`Dockerfile` builds only the standalone/fallback `DorkNet.Server` image.
For the admin browser console specifically, including the host routing
split between the admin static host and `/api/admin/v1`, see
[`admin.md`](admin.md).
### Controllers/ groupings ### Controllers/ groupings
20 top-level groups, each responsible for one product surface the 20 top-level groups, each responsible for one product surface the
@@ -190,11 +262,14 @@ Two providers, switched by `Database:Provider` config:
- **SQLite** — default for local dev. File at `bin/<config>/data/dorknet.db`. - **SQLite** — default for local dev. File at `bin/<config>/data/dorknet.db`.
- **Postgres** — production. Connection string from `ConnectionStrings:Default`. - **Postgres** — production. Connection string from `ConnectionStrings:Default`.
Schema lives in `Data/Entities/*` and `Data/DorkNetDbContext.cs`. EF Schema lives in `Data/Entities/*` and `Data/DorkNetDbContext.cs`.
migrations under `Data/Migrations/` apply via `Database.Migrate()` on SQLite dev DBs apply EF migrations under `Data/Migrations/` via
boot. Data transforms that can't be expressed as schema migrations `Database.Migrate()`. The production Postgres path still uses
(seed renames, computed backfills) go in `Data/LegacyUpgrades.cs` as `EnsureCreated()` plus idempotent bootstrap work under the same
idempotent methods registered in `RunAsync()`. See advisory-lock discipline. Data transforms that can't be expressed as
schema migrations (seed renames, computed backfills) go in
`Data/LegacyUpgrades.cs` as idempotent methods registered in `RunAsync()`.
See
[`Data/MIGRATIONS.md`](../DorkNet.Server/Data/MIGRATIONS.md) for the [`Data/MIGRATIONS.md`](../DorkNet.Server/Data/MIGRATIONS.md) for the
discipline: one release = one migration; everything else is discipline: one release = one migration; everything else is
`LegacyUpgrades`. `LegacyUpgrades`.
@@ -216,9 +291,11 @@ If you've never touched this codebase, the most approachable path:
4. Look at one **`Cpp2IL`-cited line** in a controller to see how 4. Look at one **`Cpp2IL`-cited line** in a controller to see how
wire shapes are pinned to decompiled provenance. wire shapes are pinned to decompiled provenance.
When in doubt: open `DorkNet.Server/Program.cs` and the When in doubt: open `DorkNet.Contracts/DorkNetRouteOwnership.cs`,
`DorkNet.Gateway/Program.cs`, `DorkNet.Server/Program.cs`, and the
`Versions/Late2020/Late2020VersionPlugin.cs` summary doc-comment. `Versions/Late2020/Late2020VersionPlugin.cs` summary doc-comment.
Between them they explain 80% of the cross-cutting decisions. Together they explain route ownership, proxying, the shared server
pipeline, and version compatibility.
--- ---
@@ -236,11 +313,11 @@ worse.
intentional — the decompiled source is provenance only, not intentional — the decompiled source is provenance only, not
shippable. Comments give a future contributor a breadcrumb to shippable. Comments give a future contributor a breadcrumb to
re-derive the wire shape if the decompile changes. re-derive the wire shape if the decompile changes.
- **`Program.cs` is long.** Most of it is one-time schema patching - **Microservices use shared server code during extraction.** The
(`RunPatchAsync` calls for the Postgres `EnsureCreated` path) that's dedicated service hosts run the proven controller/service stack behind
pending consolidation per [`Data/MIGRATIONS.md`](../DorkNet.Server/Data/MIGRATIONS.md). route guards instead of copying controllers into new projects. This
Once that lands, the remaining ~500 lines of middleware + host keeps the client contract stable while route families are peeled away
setup will be split into `Startup/*` extensions. from the monolith fallback.
- **Two-provider EF.** SQLite and Postgres each have their own - **Two-provider EF.** SQLite and Postgres each have their own
EnsureCreated/Migrate dance because some Postgres-only column types EnsureCreated/Migrate dance because some Postgres-only column types
drift the model snapshot. The split is annoying but lets one drift the model snapshot. The split is annoying but lets one
+386 -227
View File
@@ -1,280 +1,439 @@
# DorkNet — Coolify deployment # DorkNet Deployment
This doc covers the production cutover from the local-machine setup The recommended production shape is the gateway-fronted microservices
(SQLite + Kestrel direct + mkcert + per-machine cloudflared) to the compose stack. The standalone `DorkNet.Server` image still exists for
horizontally-scaled Coolify deployment (Postgres + Redis + Garage S3 + local debugging and emergency single-container fallback, but new Dokploy
2-replica Kestrel behind cloudflared, all on the same Coolify project). deployments should use `docker-compose.microservices.dokploy.yml`.
The plan-of-record is `BUILD_PROPER_PLAN.md` at the repo root and the Object storage is always a separate S3-compatible service. The compose
phased writeup at `~/.claude/plans/pahese-0-1-horizontal-cheeky-hickey.md`. files start Postgres and Redis, but they do not start MinIO, Garage, or
Anything that contradicts this doc, that doc wins. any other object-storage emulator.
--- ---
## Coolify project layout ## Production Topology
One Coolify *project* with these services: Public traffic enters at `DorkNet.Gateway`. The gateway preserves the
original Host header, routes owned host/path families to dedicated
service hosts, and sends unassigned route families to the `monolith`
fallback. That lets the 2020 client keep using the same public URLs
while route slices move out of the fallback server.
| Service | Image / source | Notes | | Service | Purpose |
| -------------------- | ---------------------------------------------- | ---------------------------------------------------------------------------- | |---|---|
| `dorknet-server` | Build from this repo's `Dockerfile` | Replicas: **2** to start. Internal port 8080. No public domain. | | `gateway` | Edge reverse proxy, service map, route table, and service health probes |
| `dorknet-postgres` | Coolify one-click **Postgres** | Persistent volume on Coolify's data store. | | `identity` | Auth, accounts, platform login, JWT issuance |
| `dorknet-redis` | Coolify one-click **Redis** | Persistent volume optional; used for ephemeral state + SignalR backplane. | | `rooms` | Rooms, room keys, playlists, matchmaking, discovery |
| `dorknet-garage` | Coolify one-click **Garage** | S3-compatible. Persistent volume (room blobs are 100s of MB). | | `notify` | Notify, messages/chat, SignalR, notification fan-out |
| `dorknet-cloudflared`| Cloudflare's `cloudflared` image, custom config | Reads `tools/cloudflared-config.coolify.yml.template` rendered with a tunnel ID. | | `content` | CDN paths, uploads, images, photos, room blobs, storage APIs |
| `social` | Clubs, groups, announcements, player events, subscriptions |
| `commerce` | Catalog, storefronts, econ, inventory, inventions |
| `platform` | Service directory, config, version checks, geo, strings |
| `moderation` | Bug reports, player reports, sanitize, path-routed admin API, testcase routes |
| `web` | Apex/www/admin/feed static hosts, same-origin admin browser API, and site API |
| `monolith` | Fallback shared server for route families not split yet |
| `postgres` | Bundled Postgres for the compose network |
| `redis` | Bundled Redis for ephemeral state and fan-out |
| `cloudflared` | Cloudflare Tunnel sidecar in the Dokploy compose file |
All five sit on the same project network so the server reaches its Every backend service uses the same database, Redis, S3, Photon, domain,
dependencies over Coolify's internal Docker DNS. JWT, and version-gate settings. The dedicated slices currently reuse the
shared `DorkNet.Server` controller/service stack behind route ownership
guards, so response contracts stay identical while the architecture is
split.
## Required environment variables on `dorknet-server` ---
Set these in Coolify's *Environment Variables* tab. Coolify expands ## Dokploy Microservices
`${...}` references to other services in the same project, so the
connection-string lookups link automatically.
Create a Dokploy **Compose** service from:
```text
docker-compose.microservices.dokploy.yml
``` ```
Use the GitHub repo/branch as the source and keep the build context at
the repository root. Do not add Dokploy domain rows for this compose
stack when using the Cloudflare Tunnel sidecar; public hostname routing
belongs to Cloudflare.
Set these Dokploy Compose environment variables:
```env
DORKNET_DOMAIN=yourdomain.com
DORKNET_JWT_SECRET=replace-with-at-least-64-random-characters
CLOUDFLARE_TUNNEL_TOKEN=<token from Cloudflare Zero Trust tunnel>
# Bundled compose Postgres. Optional when DORKNET_POSTGRES_CONNECTION_STRING is set.
POSTGRES_PASSWORD=replace-with-a-random-db-password
# External object storage. Required for production.
DORKNET_S3_ENDPOINT=https://your-s3-api-endpoint
DORKNET_S3_ACCESS_KEY=your-access-key
DORKNET_S3_SECRET_KEY=your-secret-key
DORKNET_S3_REGION=garage
# Photon Cloud.
Photon__AppId=your-photon-realtime-app-id
Photon__VoiceAppId=your-photon-voice-app-id
Photon__CloudRegion=eu
# December 2020 client version gate.
DORKNET_DEFAULT_CLIENT_VERSION=december_2020_12_18
DORKNET_SUPPORTED_VERSION=december_2020_12_18
DORKNET_DECEMBER_BUILD_VERSION_KEY=december_2020_12_18
```
The compose file maps the `DORKNET_S3_*` values into `S3__*` for every
backend service, and maps the version variables into `DorkNet__*`
configuration keys. `Photon__*` is passed directly to each service.
### Database Options
By default, service containers connect to the bundled compose Postgres:
```env
Host=postgres;Port=5432;Database=dorknet;Username=dorknet;Password=${POSTGRES_PASSWORD}
```
To use a Dokploy-managed or external Postgres instead, set a full
connection string:
```env
DORKNET_POSTGRES_CONNECTION_STRING=Host=<postgres-host>;Port=5432;Database=dorknet;Username=<user>;Password=<password>
```
For hosted Postgres that requires TLS:
```env
DORKNET_POSTGRES_CONNECTION_STRING=Host=<postgres-host>;Port=5432;Database=dorknet;Username=<user>;Password=<password>;SSL Mode=Require;Trust Server Certificate=true
```
`docker-compose.microservices.dokploy.yml` attaches every backend service
that opens database connections to both the stack's default network and
Dokploy's external `dokploy-network`. If you use another Dokploy-managed
database service, the hostname in `DORKNET_POSTGRES_CONNECTION_STRING`
must resolve to a Docker-network address from inside a DorkNet service:
```bash
docker exec <web-or-platform-container> getent hosts <postgres-host>
```
That should return a container/network IP, not `127.0.0.1`.
If you use the bundled compose Postgres, `POSTGRES_PASSWORD` must match
the password that initialized the existing Postgres volume. Changing
`POSTGRES_PASSWORD` later does not update the already-created `dorknet`
database user. Restore the original password, reset it inside Postgres,
or recreate the Postgres volume.
### Cloudflare Tunnel
In Cloudflare Zero Trust, configure two public hostnames:
```text
yourdomain.com -> http://gateway:8080
*.yourdomain.com -> http://gateway:8080
```
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.
Do not point the tunnel at individual service containers. All public
traffic should go to `http://gateway:8080`.
### Public Hosts
The client service map and the browser surfaces expect these hosts to
reach the gateway:
```text
yourdomain.com
www.yourdomain.com
admin.yourdomain.com
api.yourdomain.com
auth.yourdomain.com
accounts.yourdomain.com
bugreporting.yourdomain.com
cards.yourdomain.com
cdn.yourdomain.com
chat.yourdomain.com
clubs.yourdomain.com
cms.yourdomain.com
commerce.yourdomain.com
data.yourdomain.com
datacollection.yourdomain.com
discovery.yourdomain.com
econ.yourdomain.com
feed.yourdomain.com
gamelogs.yourdomain.com
geo.yourdomain.com
img.yourdomain.com
leaderboard.yourdomain.com
link.yourdomain.com
lists.yourdomain.com
match.yourdomain.com
moderation.yourdomain.com
notify.yourdomain.com
ns.yourdomain.com
platformnotifications.yourdomain.com
playersettings.yourdomain.com
roomcomments.yourdomain.com
roomieintegrations.yourdomain.com
rooms.yourdomain.com
storage.yourdomain.com
strings.yourdomain.com
strings-cdn.yourdomain.com
studio.yourdomain.com
thorn.yourdomain.com
videos.yourdomain.com
```
With Cloudflare Tunnel, the apex and wildcard hostname rules cover the
list. If you deploy without a tunnel, every host must route to the
gateway container on port `8080`.
### Smoke Checks
After Dokploy finishes rebuilding, verify the gateway and service map:
```bash
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
curl https://api.yourdomain.com/api/versioncheck/v4?v=20201210
```
The version check should return:
```json
{"VersionStatus":0}
```
Browser hosts should route to `web` and return static files:
```bash
curl -I https://yourdomain.com/
curl -I https://admin.yourdomain.com/
curl -I https://feed.yourdomain.com/
```
Inside the running web container, these files must exist:
```bash
docker exec <web-container> sh -lc 'ls -la /app/wwwroot/admin /app/wwwroot/site /app/wwwroot/feed'
```
Expected key files:
```text
/app/wwwroot/admin/index.html
/app/wwwroot/site/index.html
/app/wwwroot/feed/index.html
```
`/internal/routes` shows which host/path patterns route to each service
slice or to the monolith fallback. `/internal/services/health` reports
gateway-visible health for each backend service.
For the admin page's browser routes, auth flow, same-origin API calls,
and focused troubleshooting, see [`admin.md`](admin.md).
### Useful Logs
```bash
docker logs <stack>-gateway-1 --tail=200
docker logs <stack>-web-1 --tail=200
docker logs <stack>-moderation-1 --tail=200
docker logs <stack>-platform-1 --tail=200
docker logs <stack>-cloudflared-1 --tail=200
```
If `admin.<domain>` or the apex returns 404, check the `web` logs for
static-host probes such as:
```text
probe="/app/wwwroot/admin/index.html" exists=False
```
That means the `web` image did not include the built SPA assets or the
old container is still running. Force a clean Dokploy rebuild on the
latest branch commit.
Admin browser traffic on `admin.<domain>` is routed to `web`. The
`moderation` service is still useful when testing the `/api/admin/*`
path family through non-admin hosts.
---
## Local Microservices
For local service-split testing without Dokploy:
```bash
docker compose -f docker-compose.microservices.yml up --build
```
This starts the gateway on host port `8080`, all service slices, the
monolith fallback, Postgres, and Redis. The local compose file does not
start `cloudflared`; direct local testing uses Host headers:
```bash
curl -H 'Host: api.localhost' http://localhost:8080/healthz
curl -H 'Host: admin.localhost' http://localhost:8080/
curl -H 'Host: localhost' http://localhost:8080/
```
Set `DORKNET_S3_*` if you need blob/image features locally. Leaving S3
blank is fine for API smoke tests that do not touch object storage.
---
## Standalone Server Fallback
The root `Dockerfile` builds the standalone `DorkNet.Server` image. Use
it for local debugging, narrow rollback tests, or a temporary
single-container deployment. The current production docs and Dokploy
workflow assume the microservices compose stack instead.
Create a Dokploy **Application** only if you intentionally want the
single-container fallback:
| Field | Value |
|---|---|
| Source | This GitHub repo / branch |
| Build type | Dockerfile |
| Dockerfile path | `Dockerfile` |
| Context | `.` |
| Container port | `8080` |
Standalone environment variables use the direct .NET configuration keys:
```env
ASPNETCORE_ENVIRONMENT=Production ASPNETCORE_ENVIRONMENT=Production
ASPNETCORE_URLS=http://0.0.0.0:8080
DOTNET_RUNNING_IN_CONTAINER=true
# Database DORKNET_DOMAIN=yourdomain.com
ConnectionStrings__Default=Host=dorknet-postgres;Port=5432;Database=dorknet;Username=${POSTGRES_USER};Password=${POSTGRES_PASSWORD} DORKNET_JWT_SECRET=replace-with-at-least-64-random-characters
# Redis Database__Provider=postgres
ConnectionStrings__Redis=dorknet-redis:6379 ConnectionStrings__Default=Host=<postgres-host>;Port=5432;Database=dorknet;Username=<user>;Password=<password>
ConnectionStrings__Redis=redis://<redis-host>:6379
# JWT S3__Endpoint=https://your-s3-api-endpoint
DORKNET_JWT_SECRET=<openssl rand -base64 64> S3__AccessKey=your-access-key
S3__SecretKey=your-secret-key
# Object storage (Garage). Bucket names are hard-coded in code; only
# the endpoint + credentials need configuring. Objects are organized
# under owner-scoped keys inside each bucket, e.g.
# players/{playerId}/camera, players/{playerId}/profile,
# players/{playerId}/rooms/{roomId}, players/{playerId}/dorm.
S3__Endpoint=http://dorknet-garage:3900
S3__Region=garage S3__Region=garage
S3__AccessKey=<garage admin key>
S3__SecretKey=<garage admin secret>
# Image CDN signing. The 2020 client verifies Content-Signature on Photon__AppId=your-photon-realtime-app-id
# image downloads. Use the private half of the key whose public modulus Photon__VoiceAppId=your-photon-voice-app-id
# the client knows for p1. Store PEM newlines as \n if your env UI is Photon__CloudRegion=eu
# single-line, or use DORKNET_IMAGE_SIGNING_PRIVATE_KEY_BASE64. DorkNet__DefaultClientVersion=december_2020_12_18
DORKNET_IMAGE_SIGNING_KEY_ID=p1 DorkNet__SupportedVersions__0=december_2020_12_18
DORKNET_IMAGE_SIGNING_PRIVATE_KEY=<PEM RSA private key> DorkNet__BuildIdToVersionKey__20201210=december_2020_12_18
# Photon (copy from local appsettings.json — same AppId across envs)
Photon__AppId=<your-photon-realtime-app-id>
Photon__VoiceAppId=1917ff4a-b7ad-4ad6-9ea2-9642bc174b70
Photon__CloudRegion=us
``` ```
### Cloudflare R2 instead of Garage Every public host listed above must route to this single container when
running standalone.
DorkNet can use Cloudflare R2 without code changes because R2 speaks the ---
S3-compatible API. Create these three R2 buckets first:
``` ## S3 Storage
DorkNet uses S3-compatible storage for room blobs, profile images, camera
photos, and CDN/image pipeline backing data. Create these buckets before
production cutover:
```text
profile-images profile-images
camera-photos camera-photos
room-blobs room-blobs
``` ```
Then create an R2 API token with Object Read & Write access to both Garage-style endpoints usually use:
buckets and set the server env vars:
```env
S3__Endpoint=https://your-garage-or-s3-api
S3__Region=garage
S3__AccessKey=...
S3__SecretKey=...
``` ```
Cloudflare R2 uses the S3 API endpoint, not a public bucket URL:
```env
S3__Endpoint=https://<cloudflare-account-id>.r2.cloudflarestorage.com S3__Endpoint=https://<cloudflare-account-id>.r2.cloudflarestorage.com
S3__Region=auto S3__Region=auto
S3__AccessKey=<r2 access key id> S3__AccessKey=<r2 access key id>
S3__SecretKey=<r2 secret access key> S3__SecretKey=<r2 secret access key>
S3__MaxErrorRetry=1 S3__MaxErrorRetry=1
S3__TimeoutSeconds=8 S3__TimeoutSeconds=300
``` ```
Do not use an R2 public bucket URL or custom domain for `S3__Endpoint`. For the microservices compose files, use the `DORKNET_S3_*`
That value must be the S3 API endpoint ending in equivalents; compose maps them into `S3__*` for the service containers.
`.r2.cloudflarestorage.com`.
DorkNet disables S3 chunked upload encoding in code because R2 rejects
`STREAMING-AWS4-HMAC-SHA256-PAYLOAD` request bodies.
`appsettings.Production.json` already pins `Database:Provider=postgres` ---
and binds Kestrel to `http://0.0.0.0:8080`, so those don't need to be
set as env vars.
## First-time cutover (stop-the-world) ## First-Time Cutover From SQLite
1. **Drain the local server.** Tell players, take it down. The cutover 1. Stop the local server so the SQLite source stops accepting writes.
is non-incremental — once the SQLite source is read, no further 2. Snapshot the SQLite database.
writes can be accepted there. 3. Provision Postgres, Redis, and separate S3-compatible storage.
4. Run the migration/import tooling against the new Postgres and S3
targets.
5. Deploy the microservices compose stack.
6. Verify `/healthz`, `/internal/services/health`, versioncheck, and
the browser static hosts before sending players to the new host.
2. **Snapshot SQLite.** `tools/snapshot-db.ps1` writes a timestamped The database bootstrap path uses advisory locking so multiple service
copy. Keep it indefinitely as the pre-cutover baseline. containers can start without racing schema/bootstrap work.
3. **Deploy the Coolify project** with the four services above Rollback before players write to Postgres is simple: stop the deploy and
(Postgres / Redis / Garage / cloudflared) but the server replicas return to the SQLite snapshot. After players write to Postgres, rollback
set to 0 — we want the database empty and idle while the migrator means losing post-cutover writes unless you migrate them back.
runs.
4. **Run the migrator** from the operator's laptop, pointed at the ---
newly-provisioned Postgres + Garage:
```pwsh ## Photon Cloud
dotnet run --project Tools/MigrateSqliteToPostgres -- `
--sqlite ./snapshots/dorknet-pre-cutover.db `
--postgres "Host=<coolify-postgres-public-host>;Port=5432;Database=dorknet;Username=...;Password=...;SSL Mode=Require" `
--reset `
--upload-blobs `
--s3-endpoint http://<coolify-garage-public-host>:3900 `
--s3-bucket room-blobs `
--s3-access-key ... `
--s3-secret-key ...
```
The migrator runs the 9-step verification + an optional 10th step Use your own Photon AppIds. Rec Room's original Photon AppIds reject
that bulk-uploads every `RoomDataBlobs.Bytes` row to S3, idempotent external clients because their dashboard points CustomAuth at the
on re-run. A non-zero exit means **abort**: leave the destination official backend.
in its dirty state, fix the diff, re-run with `--reset` for a clean
slate.
5. **Scale `dorknet-server` to 2 replicas.** First boot: one replica 1. Create a Photon Realtime app at <https://dashboard.photonengine.com>.
wins the `pg_advisory_xact_lock` and runs `EnsureCreated` (no-op, 2. Create a Photon Voice app if voice chat is needed.
schema already there from the migrator) + the seed steps; the other 3. Set:
blocks on the lock until the first commits, sees the schema is
complete, and proceeds.
6. **Verify `/healthz` is 200** on both replicas (Coolify shows this ```env
in the Container tab) before pointing the tunnel at the service. Photon__AppId=<your realtime AppId>
Photon__VoiceAppId=<your voice AppId>
7. **Bring up `dorknet-cloudflared`** with the rendered config (see Photon__CloudRegion=eu
`tools/cloudflared-config.coolify.yml.template`). Confirm `dig` /
`curl` against `https://api.localhost/api/health/v1` returns 200.
8. **Tell players it's back.** Done.
## Notes on multi-replica behaviour
* **Sticky sessions are no longer required.** Auth's
`OrphanAccountTracker` writes the pending claim to Redis, so a
player whose two HTTP requests hit different replicas still sees
the same account-creation state.
* **SignalR groups fan out across replicas.** The Redis backplane is
registered under the channel prefix `dorknet-signalr`; both replicas
publish/subscribe to the same channels, so a notification raised on
replica B reaches a player connected to replica A.
* **Player presence is shared.** `PlayerPresenceService` writes
`presence:player:{id}` keys to Redis with a 45 s TTL (3× heartbeat).
Either replica can answer "where is player N?".
* **Game session ids are allocated by Postgres.** `GameSessionService`
uses a `bigserial` so two replicas can `JoinOrCreate` simultaneously
without colliding.
* **Private instances live in Postgres.** Cross-replica visibility is
automatic — both replicas read from the same `PrivateInstances`
table.
## Rollback
If the cutover goes sideways before step 5 (no replicas running yet),
just delete the Postgres / Redis / Garage volumes in Coolify, set the
server replica count back to 0, and bring the local SQLite server back
up. The pre-cutover snapshot is still untouched.
After step 5, rolling back means **restoring the SQLite snapshot**
(any data players wrote on Postgres post-cutover will be lost). Be
sure the cutover validation in step 6 is convincing before you tell
players it's safe.
## Photon Cloud — own AppId + CustomAuth callback
The hard-coded RecRoom Photon AppId rejects external clients (NameServer
error code 32736 / "No auth request during expected wait time") because
their dashboard has CustomAuth set to "Reject if Auth Failed" pointing
at RecRoom's own backend. Solution: register your own free AppId.
1. Go to https://dashboard.photonengine.com → sign up → **Create a New App**
→ "Photon Realtime" → save. Repeat for "Photon Voice" if you want voice.
2. Copy each **AppId** (UUID) and set in Coolify env vars:
```
Photon__AppId=<your realtime AppId>
Photon__VoiceAppId=<your voice AppId>
Photon__CloudRegion=us
```
3. Restart the server.
That alone gets multiplayer working — the Photon NameServer accepts the
client because no CustomAuth is required.
### (Optional) wire up server-side single-session enforcement
Server already exposes `https://auth.localhost/photon/customauth` — see
[PhotonCustomAuthController.cs](DorkNet.Server/Controllers/Auth/PhotonCustomAuthController.cs).
It accepts `userid` + `LoginLock` and validates them against
`PlayerPresenceService` (Redis). Enable it in the dashboard:
1. Photon dashboard → your app → **Manage** → **Custom Authentication** tab.
2. **Enable Custom Authentication**.
3. URL: `https://auth.localhost/photon/customauth?userid={userid}&LoginLock={LoginLock}`
4. **Reject if Auth Failed**: **OFF for now** (see caveat below).
5. Save.
**Caveat — current 2020 watch doesn't send AuthValues**. A sweep of
`Cpp2IL_ISIL/IsilDump/Assembly-CSharp/` turns up no caller of
`PhotonNetwork.set_AuthValues` outside Photon's own library code, so
the unmodified client connects without supplying `{userid, LoginLock}`.
With "Reject if Auth Failed" ON, every Photon connect would fail with
NameServer 32736 — same symptom we tried to solve. With it OFF, the
client connects fine, the endpoint exists for future client patching.
### Patching the watch to send AuthValues — `DorkNet.ClientMod/`
The vanilla 2020 watch never sets `PhotonNetwork.AuthValues` (verified
by an exhaustive grep across both `Assembly-CSharp.dll` and the
Photon `Assembly-CSharp-firstpass.dll` library). To make Photon
forward `userid` + `LoginLock` to our `/photon/customauth` endpoint a
Harmony patch on the watch's Photon auth path has to attach the values
in flight. That hook lives in the **MelonLoader IL2CPP mod**
(`DorkNet.ClientMod`); the AuthValues injector itself is currently
parked (see `DorkNet.ClientMod/attic/AuthValuesInjector.cs.attic` and
the notes in `Mod.cs`) since the `/photon/customauth` endpoint runs in
permissive mode.
Install the mod on a tester's machine with:
```pwsh
.\tools\install-melon.ps1 `
-RecRoomPath "C:\…\RecRoom\Recroom_Release_Data" `
-PhotonAppId cb0880d9-…
``` ```
The script unpacks MelonLoader, builds `DorkNet.ClientMod.dll`, and Server-side Photon custom auth exists at `/photon/customauth`, but the
drops it into the client's `Mods/` folder. On the first run it prints 2020 client does not send the needed AuthValues without client patching.
"launch the game once" so MelonLoader can generate its IL2CPP Leave Photon dashboard "Reject if Auth Failed" off unless the client mod
assemblies; relaunch the script with `-ResumeBuild` to finish. is updated to send `userid` and `LoginLock`.
Once AuthValues injection is re-enabled in the mod and in production: ---
1. Flip [`PhotonCustomAuthController`](../DorkNet.Server/Controllers/Auth/PhotonCustomAuthController.cs) ## Future Schema Changes
from permissive-mode (`ResultCode 1` always) back to strict —
parse `userid` + `LoginLock`, validate against
`PlayerPresenceService.ValidateLock`, return `ResultCode 3` on
mismatch.
2. In the Photon dashboard, flip **"Reject if Auth Failed"** ON.
3. Duplicate-account login from a different LoginLock now fails
Photon auth at the NameServer — never reaches the dorm.
## Future schema changes SQLite dev DBs apply EF migrations. The production Postgres path is still
based on `EnsureCreated()` plus idempotent bootstrap steps. Until that is
changed to `Migrate()` under the same advisory lock, treat production
schema changes as explicit deployment work:
PR 4 keeps `EnsureCreated` for the first deploy (the migrator builds 1. Add or update the entity model.
the schema directly, then EnsureCreated is a no-op the server can do 2. Add the migration for SQLite/dev.
under advisory-lock to be safe on multi-replica boot). Any 3. Add any needed idempotent Postgres bootstrap SQL.
**post-cutover** schema change needs a real Postgres migration: 4. Deploy and verify `/healthz` and `/internal/services/health`.
1. `dotnet ef migrations add <Name> --project DorkNet.Server` See [`../DorkNet.Server/Data/MIGRATIONS.md`](../DorkNet.Server/Data/MIGRATIONS.md)
2. Replace the `EnsureCreated` call in `Program.cs` with for the detailed migration discipline.
`db.Database.Migrate()` *inside* the same advisory-lock block.
3. Deploy. Both replicas race for the lock, the winner runs the
migration, the loser sees the new history row and skips.
Until that swap happens, treat the schema as frozen.
@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
</Project>
+216
View File
@@ -0,0 +1,216 @@
using System.Reflection;
using System.Runtime.Loader;
using System.Text.RegularExpressions;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Routing;
var serverAssembly = LoadServerAssembly(args);
var serverAssemblyWriteTime = File.GetLastWriteTimeUtc(serverAssembly.Location);
Console.WriteLine($"Using server assembly: {serverAssembly.Location}");
Console.WriteLine($"Server assembly timestamp (UTC): {serverAssemblyWriteTime:O}");
var routes = DiscoverRoutes(serverAssembly).OrderBy(r => r.Method).ThenBy(r => r.Template).ToArray();
var failures = new List<string>();
if (routes.Length == 0)
failures.Add("No HTTP controller routes were discovered.");
foreach (var route in routes)
{
if (string.IsNullOrWhiteSpace(route.Template))
failures.Add($"{route.Source}: empty route template");
if (route.SamplePath.Contains('{') || route.SamplePath.Contains('}'))
failures.Add($"{route.Source}: unresolved route parameter in {route.SamplePath}");
if (route.SamplePath.Contains('[') || route.SamplePath.Contains(']'))
failures.Add($"{route.Source}: unresolved token in {route.SamplePath}");
if (!route.SamplePath.StartsWith('/'))
failures.Add($"{route.Source}: sample path must be absolute: {route.SamplePath}");
}
Console.WriteLine($"Discovered {routes.Length} HTTP route test cases.");
foreach (var route in routes)
Console.WriteLine($"{route.Method,-6} {route.SamplePath,-72} {route.Source}");
if (failures.Count == 0)
return 0;
Console.Error.WriteLine();
Console.Error.WriteLine("Route coverage failed:");
foreach (var failure in failures)
Console.Error.WriteLine($"- {failure}");
return 1;
static Assembly LoadServerAssembly(string[] args)
{
if (args.Length > 0)
return LoadAssemblyFromPath(Path.GetFullPath(args[0]));
var repositoryRoot = FindRepositoryRoot();
var candidates = new[]
{
Path.Combine(repositoryRoot, "DorkNet.Server", "bin", "Debug", "net9.0", "DorkNet.Server.dll"),
Path.Combine(repositoryRoot, "DorkNet.Server", "bin", "Release", "net9.0", "DorkNet.Server.dll")
};
var existingCandidates = candidates.Where(File.Exists).ToArray();
if (existingCandidates.Length == 0)
{
throw new FileNotFoundException(
"Could not find a built DorkNet.Server assembly. Build DorkNet.Server first or pass the DLL path as the first argument.",
string.Join(Environment.NewLine, candidates));
}
if (existingCandidates.Length > 1)
{
throw new InvalidOperationException(
"Multiple DorkNet.Server assemblies were found. Pass the DLL path as the first argument so route coverage validates the intended build."
+ Environment.NewLine
+ string.Join(Environment.NewLine, existingCandidates));
}
return LoadAssemblyFromPath(existingCandidates[0]);
}
static Assembly LoadAssemblyFromPath(string assemblyPath)
{
var assemblyDirectory = Path.GetDirectoryName(assemblyPath)
?? throw new DirectoryNotFoundException($"Could not resolve assembly directory for {assemblyPath}.");
AssemblyLoadContext.Default.Resolving += (_, assemblyName) =>
{
var dependencyPath = Path.Combine(assemblyDirectory, assemblyName.Name + ".dll");
return File.Exists(dependencyPath)
? AssemblyLoadContext.Default.LoadFromAssemblyPath(dependencyPath)
: null;
};
return AssemblyLoadContext.Default.LoadFromAssemblyPath(assemblyPath);
}
static string FindRepositoryRoot()
{
var current = new DirectoryInfo(AppContext.BaseDirectory);
while (current is not null)
{
if (File.Exists(Path.Combine(current.FullName, "DorkNet.Server", "DorkNet.Server.csproj")))
return current.FullName;
current = current.Parent;
}
throw new DirectoryNotFoundException("Could not locate the repository root from the route coverage tool output path.");
}
static IEnumerable<RouteCase> DiscoverRoutes(Assembly serverAssembly)
{
var controllerBaseType = typeof(ControllerBase);
foreach (var controller in serverAssembly.GetTypes()
.Where(t => !t.IsAbstract && controllerBaseType.IsAssignableFrom(t)))
{
var controllerName = ControllerName(controller);
var controllerRouteTemplates = controller.GetCustomAttributes(inherit: true)
.OfType<IRouteTemplateProvider>()
.Select(a => a.Template)
.Where(t => !string.IsNullOrWhiteSpace(t))
.Cast<string>()
.DefaultIfEmpty("")
.ToArray();
foreach (var method in controller.GetMethods(BindingFlags.Instance | BindingFlags.Public | BindingFlags.DeclaredOnly))
{
var httpAttrs = method.GetCustomAttributes(inherit: true)
.OfType<HttpMethodAttribute>()
.ToArray();
if (httpAttrs.Length == 0)
continue;
foreach (var attr in httpAttrs)
{
var attrMethods = attr.HttpMethods.Any()
? attr.HttpMethods
: new[] { "ANY" };
var actionName = method.Name;
foreach (var httpMethod in attrMethods)
{
foreach (var controllerTemplate in controllerRouteTemplates)
{
var template = CombineTemplates(controllerTemplate, attr.Template);
template = ReplaceRouteTokens(template, controllerName, actionName);
yield return new RouteCase(
httpMethod.ToUpperInvariant(),
template,
ToSamplePath(template),
$"{controller.FullName}.{method.Name}");
}
}
}
}
}
}
static string ControllerName(Type controller)
{
const string suffix = "Controller";
return controller.Name.EndsWith(suffix, StringComparison.Ordinal)
? controller.Name[..^suffix.Length]
: controller.Name;
}
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;
}
static string ReplaceRouteTokens(string template, string controllerName, string actionName)
{
return template
.Replace("[controller]", controllerName, StringComparison.OrdinalIgnoreCase)
.Replace("[action]", actionName, StringComparison.OrdinalIgnoreCase);
}
static string ToSamplePath(string template)
{
var path = RouteParameterRegex().Replace(template, match =>
{
var constraint = match.Groups["constraint"].Value.ToLowerInvariant();
if (constraint.Contains("long") || constraint.Contains("int"))
return "1";
if (constraint.Contains("bool"))
return "true";
if (constraint.Contains("guid"))
return "00000000-0000-0000-0000-000000000001";
return "sample";
});
path = Regex.Replace(path, "/+", "/");
return path.StartsWith('/') ? path : "/" + path;
}
internal sealed record RouteCase(string Method, string Template, string SamplePath, string Source);
partial class Program
{
[GeneratedRegex(@"\{[*]?(?<name>[^}:=\?]+)(?<constraint>:[^}=\?]+)?(?<optional>\?)?(?<default>=[^}]+)?\}")]
private static partial Regex RouteParameterRegex();
}
@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
</Project>
+509
View File
@@ -0,0 +1,509 @@
using System.Net;
using System.Reflection;
using System.Runtime.Loader;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Routing;
var options = EndpointContractOptions.Parse(args);
var serverAssembly = LoadServerAssembly(options.ServerAssemblyPath);
var routes = DiscoverRoutes(serverAssembly).OrderBy(r => r.Method).ThenBy(r => r.SamplePath).ToArray();
var results = new List<EndpointProbeResult>();
var failures = new List<string>();
ValidateRouteShapes(routes, failures);
Console.WriteLine($"Using server assembly: {serverAssembly.Location}");
Console.WriteLine($"Server assembly timestamp (UTC): {File.GetLastWriteTimeUtc(serverAssembly.Location):O}");
Console.WriteLine($"Discovered {routes.Length} HTTP endpoint contracts.");
if (options.BaseUrl is { } baseUrl)
{
using var http = new HttpClient { BaseAddress = new Uri(baseUrl.TrimEnd('/') + "/") };
http.Timeout = TimeSpan.FromSeconds(options.TimeoutSeconds);
foreach (var route in routes)
{
var result = await ProbeAsync(http, route, options);
results.Add(result);
if (!result.Passed)
failures.Add($"{route.Source}: {route.Method} {route.SamplePath} {result.Expected} but got {result.Observed}");
}
}
else
{
foreach (var route in routes)
{
results.Add(new EndpointProbeResult(
route.Method,
route.SamplePath,
route.Source,
route.RequiresAuthorization,
"route-shape",
null,
null,
true));
}
}
if (options.ReportPath is { } reportPath)
WriteReport(reportPath, serverAssembly, routes.Length, results, failures);
foreach (var result in results)
{
var status = result.StatusCode is int code ? code.ToString() : "-";
Console.WriteLine($"{(result.Passed ? "PASS" : "FAIL"),-4} {result.Method,-6} {status,-3} {result.Path,-72} {result.Source}");
}
if (failures.Count == 0)
return 0;
Console.Error.WriteLine();
Console.Error.WriteLine("Endpoint contract failed:");
foreach (var failure in failures)
Console.Error.WriteLine($"- {failure}");
return 1;
static void ValidateRouteShapes(IEnumerable<EndpointContract> routes, List<string> failures)
{
foreach (var route in routes)
{
if (string.IsNullOrWhiteSpace(route.Template))
failures.Add($"{route.Source}: empty route template");
if (route.SamplePath.Contains('{') || route.SamplePath.Contains('}'))
failures.Add($"{route.Source}: unresolved route parameter in {route.SamplePath}");
if (route.SamplePath.Contains('[') || route.SamplePath.Contains(']'))
failures.Add($"{route.Source}: unresolved route token in {route.SamplePath}");
if (!route.SamplePath.StartsWith('/'))
failures.Add($"{route.Source}: sample path must be absolute: {route.SamplePath}");
}
}
static async Task<EndpointProbeResult> ProbeAsync(
HttpClient http,
EndpointContract route,
EndpointContractOptions options)
{
using var request = new HttpRequestMessage(ToHttpMethod(route.Method), route.SamplePath.TrimStart('/'));
request.Headers.Accept.ParseAdd("application/json");
request.Headers.TryAddWithoutValidation("X-DorkNet-Version", options.VersionHeader);
request.Headers.UserAgent.ParseAdd("DorkNetEndpointContract/1.0");
if (NeedsRequestBody(request.Method))
request.Content = CreateContent(route);
try
{
using var response = await http.SendAsync(request);
var statusCode = (int)response.StatusCode;
var passed = ResponseMatchesContract(route, response.StatusCode);
return new EndpointProbeResult(
route.Method,
route.SamplePath,
route.Source,
route.RequiresAuthorization,
ExpectedContract(route),
statusCode,
null,
passed);
}
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException)
{
return new EndpointProbeResult(
route.Method,
route.SamplePath,
route.Source,
route.RequiresAuthorization,
ExpectedContract(route),
null,
ex.Message,
false);
}
}
static bool ResponseMatchesContract(EndpointContract route, HttpStatusCode statusCode)
{
if (route.RequiresAuthorization)
return statusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden;
return (int)statusCode < 500
&& statusCode != HttpStatusCode.MethodNotAllowed;
}
static string ExpectedContract(EndpointContract route)
{
return route.RequiresAuthorization
? "unauthenticated protected endpoint should return 401 or 403"
: "public endpoint should return a non-5xx, non-405 response";
}
static HttpMethod ToHttpMethod(string method)
{
return string.Equals(method, "ANY", StringComparison.OrdinalIgnoreCase)
? HttpMethod.Get
: new HttpMethod(method);
}
static bool NeedsRequestBody(HttpMethod method)
{
return method == HttpMethod.Post
|| method == HttpMethod.Put
|| method.Method.Equals("PATCH", StringComparison.OrdinalIgnoreCase);
}
static HttpContent CreateContent(EndpointContract route)
{
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<string, string>
{
["grant_type"] = "password",
["username"] = "endpoint-contract",
["password"] = "endpoint-contract",
["device_id"] = "endpoint-contract-device",
["platform"] = "0",
["platform_id"] = "endpoint-contract-platform",
});
}
if (consumes.Any(c => c.Contains("multipart/form-data")))
{
var content = new MultipartFormDataContent();
content.Add(new StringContent("endpoint-contract"), "name");
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");
return new StringContent("{}", Encoding.UTF8, "application/json");
}
static IEnumerable<EndpointContract> DiscoverRoutes(Assembly serverAssembly)
{
var controllerBaseType = typeof(ControllerBase);
foreach (var controller in serverAssembly.GetTypes()
.Where(t => !t.IsAbstract && controllerBaseType.IsAssignableFrom(t)))
{
var controllerName = ControllerName(controller);
var controllerRouteTemplates = controller.GetCustomAttributes(inherit: true)
.OfType<IRouteTemplateProvider>()
.Select(a => a.Template)
.Where(t => !string.IsNullOrWhiteSpace(t))
.Cast<string>()
.DefaultIfEmpty("")
.ToArray();
foreach (var method in controller.GetMethods(BindingFlags.Instance | BindingFlags.Public | BindingFlags.DeclaredOnly))
{
var httpAttrs = method.GetCustomAttributes(inherit: true)
.OfType<HttpMethodAttribute>()
.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),
ConsumesContentTypes(controller, method));
}
}
}
}
}
}
static bool RequiresAuthorization(Type controller, MethodInfo method)
{
var attributes = controller.GetCustomAttributes(inherit: true)
.Concat(method.GetCustomAttributes(inherit: true))
.ToArray();
if (attributes.OfType<IAllowAnonymous>().Any())
return false;
return attributes.OfType<IAuthorizeData>().Any();
}
static string[] ConsumesContentTypes(Type controller, MethodInfo method)
{
var consumes = method.GetCustomAttributes(inherit: true)
.Concat(controller.GetCustomAttributes(inherit: true))
.OfType<ConsumesAttribute>()
.SelectMany(a => a.ContentTypes)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
return consumes.Length == 0
? ["application/json"]
: consumes;
}
static string ControllerName(Type controller)
{
const string suffix = "Controller";
return controller.Name.EndsWith(suffix, StringComparison.Ordinal)
? controller.Name[..^suffix.Length]
: controller.Name;
}
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;
}
static string ReplaceRouteTokens(string template, string controllerName, string actionName)
{
return template
.Replace("[controller]", controllerName, StringComparison.OrdinalIgnoreCase)
.Replace("[action]", actionName, StringComparison.OrdinalIgnoreCase);
}
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 (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;
}
static Assembly LoadServerAssembly(string? configuredPath)
{
if (!string.IsNullOrWhiteSpace(configuredPath))
return LoadAssemblyFromPath(Path.GetFullPath(configuredPath));
var repositoryRoot = FindRepositoryRoot();
var candidates = new[]
{
Path.Combine(repositoryRoot, "DorkNet.Server", "bin", "Debug", "net9.0", "DorkNet.Server.dll"),
Path.Combine(repositoryRoot, "DorkNet.Server", "bin", "Release", "net9.0", "DorkNet.Server.dll")
};
var existingCandidates = candidates.Where(File.Exists).ToArray();
if (existingCandidates.Length == 0)
{
throw new FileNotFoundException(
"Could not find a built DorkNet.Server assembly. Build DorkNet.Server first or pass --server-assembly.",
string.Join(Environment.NewLine, candidates));
}
if (existingCandidates.Length > 1)
{
throw new InvalidOperationException(
"Multiple DorkNet.Server assemblies were found. Pass --server-assembly so endpoint checks validate the intended build."
+ Environment.NewLine
+ string.Join(Environment.NewLine, existingCandidates));
}
return LoadAssemblyFromPath(existingCandidates[0]);
}
static Assembly LoadAssemblyFromPath(string assemblyPath)
{
var assemblyDirectory = Path.GetDirectoryName(assemblyPath)
?? throw new DirectoryNotFoundException($"Could not resolve assembly directory for {assemblyPath}.");
AssemblyLoadContext.Default.Resolving += (_, assemblyName) =>
{
var dependencyPath = Path.Combine(assemblyDirectory, assemblyName.Name + ".dll");
return File.Exists(dependencyPath)
? AssemblyLoadContext.Default.LoadFromAssemblyPath(dependencyPath)
: null;
};
return AssemblyLoadContext.Default.LoadFromAssemblyPath(assemblyPath);
}
static string FindRepositoryRoot()
{
var current = new DirectoryInfo(AppContext.BaseDirectory);
while (current is not null)
{
if (File.Exists(Path.Combine(current.FullName, "DorkNet.Server", "DorkNet.Server.csproj")))
return current.FullName;
current = current.Parent;
}
throw new DirectoryNotFoundException("Could not locate the repository root from the endpoint contract tool output path.");
}
static void WriteReport(
string reportPath,
Assembly serverAssembly,
int routeCount,
IReadOnlyCollection<EndpointProbeResult> results,
IReadOnlyCollection<string> failures)
{
var report = new EndpointContractReport(
serverAssembly.Location,
File.GetLastWriteTimeUtc(serverAssembly.Location),
routeCount,
results.Count(r => r.Passed),
results.Count(r => !r.Passed),
failures,
results);
var fullPath = Path.GetFullPath(reportPath);
Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!);
File.WriteAllText(fullPath, JsonSerializer.Serialize(report, new JsonSerializerOptions { WriteIndented = true }));
}
internal sealed record EndpointContract(
string Method,
string Template,
string SamplePath,
string Source,
bool RequiresAuthorization,
string[] Consumes);
internal sealed record EndpointProbeResult(
string Method,
string Path,
string Source,
bool RequiresAuthorization,
string Expected,
int? StatusCode,
string? Error,
bool Passed)
{
public string Observed => StatusCode is int code ? code.ToString() : Error ?? "no response";
}
internal sealed record EndpointContractReport(
string ServerAssembly,
DateTime ServerAssemblyTimestampUtc,
int RouteCount,
int Passed,
int Failed,
IReadOnlyCollection<string> Failures,
IReadOnlyCollection<EndpointProbeResult> Results);
internal sealed class EndpointContractOptions
{
public string? ServerAssemblyPath { get; private init; }
public string? BaseUrl { get; private init; }
public string? ReportPath { get; private init; }
public string VersionHeader { get; private init; } = "december_2020_12_18";
public int TimeoutSeconds { get; private init; } = 10;
public static EndpointContractOptions Parse(string[] args)
{
string? serverAssemblyPath = null;
string? baseUrl = null;
string? reportPath = null;
var versionHeader = "december_2020_12_18";
var timeoutSeconds = 10;
for (var i = 0; i < args.Length; i++)
{
var arg = args[i];
switch (arg)
{
case "--server-assembly":
serverAssemblyPath = NextValue(args, ref i, arg);
break;
case "--base-url":
baseUrl = NextValue(args, ref i, arg);
break;
case "--report":
reportPath = NextValue(args, ref i, arg);
break;
case "--version":
versionHeader = NextValue(args, ref i, arg);
break;
case "--timeout-seconds":
timeoutSeconds = int.Parse(NextValue(args, ref i, arg));
break;
case "--help":
case "-h":
Console.WriteLine("Usage: DorkNet.EndpointContract [--server-assembly PATH] [--base-url URL] [--report PATH]");
Environment.Exit(0);
break;
default:
throw new ArgumentException($"Unknown argument: {arg}");
}
}
return new EndpointContractOptions
{
ServerAssemblyPath = serverAssemblyPath,
BaseUrl = baseUrl,
ReportPath = reportPath,
VersionHeader = versionHeader,
TimeoutSeconds = timeoutSeconds,
};
}
private static string NextValue(string[] args, ref int index, string option)
{
if (index + 1 >= args.Length)
throw new ArgumentException($"{option} requires a value.");
index++;
return args[index];
}
}
partial class Program
{
[GeneratedRegex(@"\{[*]?(?<name>[^}:=\?]+)(?<constraint>:[^}=\?]+)?(?<optional>\?)?(?<default>=[^}]+)?\}")]
private static partial Regex RouteParameterRegex();
}
+69 -24
View File
@@ -1,11 +1,21 @@
# Cloudflare Tunnel for `localhost` # Cloudflare Tunnel
Stand up a Cloudflare Tunnel that exposes your local DorkNet server Cloudflare Tunnel is used in two DorkNet workflows:
on `*.localhost` for public testing — without opening port 443 on your
firewall, and using Cloudflare's real Let's-Encrypt-equivalent cert
edge-side so visitors don't need your mkcert CA installed.
## When to use this - Local standalone testing, where cloudflared forwards `*.localhost`
traffic to a locally running `DorkNet.Server`. The default origin is
`http://localhost:8080`; use local HTTPS only when you intentionally
run the standalone server with a wildcard dev certificate.
- Dokploy production microservices, where the compose sidecar forwards
the apex and wildcard hostnames to `http://gateway:8080`.
Both avoid opening ports directly on the host and let Cloudflare serve a
public TLS certificate at the edge.
## Local Standalone Tunnel
Use this when you are running the standalone server locally and want
friends to reach it through Cloudflare.
- You want a friend to play the patched 2020 Rec Room client against - You want a friend to play the patched 2020 Rec Room client against
your server, but they're not on your LAN and don't want to install your server, but they're not on your LAN and don't want to install
@@ -18,10 +28,9 @@ edge-side so visitors don't need your mkcert CA installed.
1. **`localhost` is on a Cloudflare account you control.** The DNS 1. **`localhost` is on a Cloudflare account you control.** The DNS
nameservers for the domain need to be Cloudflare's. Check at nameservers for the domain need to be Cloudflare's. Check at
<https://dash.cloudflare.com/> — the zone should be Active. <https://dash.cloudflare.com/> — the zone should be Active.
2. **Local server running** on `0.0.0.0:443` with the wildcard cert 2. **Local standalone server running.** The current quickstart listens
that covers `*.localhost` (re-run `tools/patch-client.ps1` if your on `http://localhost:8080`. Older local TLS setups can still run on
cert is still the `+1` rec.net-only one — it'll regenerate as `0.0.0.0:443` with a wildcard certificate that covers `*.localhost`.
`+3` covering both domains).
3. **Patched client** with `tools/patch-domain.ps1` so the in-game 3. **Patched client** with `tools/patch-domain.ps1` so the in-game
URLs resolve to `*.localhost`. (Players using the public deployment URLs resolve to `*.localhost`. (Players using the public deployment
need to run this on their own client too.) need to run this on their own client too.)
@@ -59,23 +68,59 @@ Or install as a Windows service so it survives reboots:
cloudflared service install cloudflared service install
``` ```
## What happens at request time ## Dokploy Microservices Sidecar
``` For Dokploy microservices, use
visitor → https://api.localhost/api/versioncheck/v4 `docker-compose.microservices.dokploy.yml`. It starts `cloudflared` in
→ Cloudflare edge (real LE cert, validated) the same Compose network as `gateway`, so the tunnel origin is:
→ cloudflared tunnel
→ cloudflared (running on your machine) ```text
→ https://localhost:443 (Host: api.localhost, mkcert cert) http://gateway:8080
→ DorkNet.Server matches [Host("api.localhost")] → handler runs
``` ```
The leg from cloudflared → localhost has `noTLSVerify: true` because Set `CLOUDFLARE_TUNNEL_TOKEN` in Dokploy from the Cloudflare Zero Trust
the local server's cert is from mkcert (private CA only trusted on tunnel token. In the Cloudflare tunnel's public hostnames, add:
your machine). The Cloudflare-edge → cloudflared leg is still
TLS-encrypted with their cert, so this isn't a security weakening ```text
— it's just acknowledging that "127.0.0.1 with self-trusted cert" is yourdomain.com -> http://gateway:8080
where the trust chain ends. *.yourdomain.com -> http://gateway:8080
```
Do not add these domains in Dokploy's domain modal for this compose
stack; Cloudflare owns the public hostname routing.
## What Happens At Request Time
Local standalone tunnel:
```
visitor -> https://api.localhost/api/versioncheck/v4
-> Cloudflare edge
-> cloudflared tunnel
-> cloudflared running on your machine
-> http://localhost:8080 (Host preserved)
-> DorkNet.Server
```
Dokploy microservices tunnel:
```
visitor -> https://api.yourdomain.com/api/versioncheck/v4
-> Cloudflare edge
-> cloudflared sidecar
-> http://gateway:8080 (Host preserved)
-> DorkNet.Gateway reverse proxy
-> dedicated service slice or monolith fallback
```
For default local testing, point the local cloudflared origin at
`http://localhost:8080`. If you intentionally test a local HTTPS origin,
the leg from cloudflared to localhost can use `noTLSVerify: true`
because that certificate is usually from a private dev CA. The
Cloudflare-edge to cloudflared leg is still TLS-encrypted with
Cloudflare's cert. In the Dokploy microservices stack, the tunnel
sidecar uses plain HTTP on the private Compose network and sends
everything to the gateway.
## Tearing down ## Tearing down