diff --git a/SS14.ServerHub.Shared/Data/AdvertisedServer.cs b/SS14.ServerHub.Shared/Data/AdvertisedServer.cs index 08d58ed..cac84e7 100644 --- a/SS14.ServerHub.Shared/Data/AdvertisedServer.cs +++ b/SS14.ServerHub.Shared/Data/AdvertisedServer.cs @@ -33,4 +33,9 @@ public sealed class AdvertisedServer /// IP address of the client doing the advertise request. Not actually related to the advertised data. /// [Column(TypeName = "inet")] public IPAddress? AdvertiserAddress { get; set; } + + /// + /// Extra tags inferred from the server information. + /// + public string[] InferredTags { get; set; } = Array.Empty(); } \ No newline at end of file diff --git a/SS14.ServerHub.Shared/Data/ServerStatusArchive.cs b/SS14.ServerHub.Shared/Data/ServerStatusArchive.cs index 28de226..d2b6a2e 100644 --- a/SS14.ServerHub.Shared/Data/ServerStatusArchive.cs +++ b/SS14.ServerHub.Shared/Data/ServerStatusArchive.cs @@ -21,4 +21,9 @@ public sealed class ServerStatusArchive public IPAddress? AdvertiserAddress { get; set; } public AdvertisedServer AdvertisedServer { get; set; } = default!; + + /// + /// Corresponds to . + /// + public string[] InferredTags { get; set; } = Array.Empty(); } \ No newline at end of file diff --git a/SS14.ServerHub.Shared/Migrations/20240331202957_InferredTags.Designer.cs b/SS14.ServerHub.Shared/Migrations/20240331202957_InferredTags.Designer.cs new file mode 100644 index 0000000..d113de2 --- /dev/null +++ b/SS14.ServerHub.Shared/Migrations/20240331202957_InferredTags.Designer.cs @@ -0,0 +1,308 @@ +// +using System; +using System.Net; +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using SS14.ServerHub.Shared.Data; + +#nullable disable + +namespace SS14.ServerHub.Shared.Migrations +{ + [DbContext(typeof(HubDbContext))] + [Migration("20240331202957_InferredTags")] + partial class InferredTags + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "6.0.1") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("SS14.ServerHub.Shared.Data.AdvertisedServer", b => + { + b.Property("AdvertisedServerId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("AdvertisedServerId")); + + b.Property("Address") + .IsRequired() + .HasColumnType("text"); + + b.Property("AdvertiserAddress") + .HasColumnType("inet"); + + b.Property("Expires") + .HasColumnType("timestamp with time zone"); + + b.Property("InferredTags") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("InfoData") + .HasColumnType("jsonb"); + + b.Property("StatusData") + .HasColumnType("jsonb"); + + b.HasKey("AdvertisedServerId"); + + b.HasIndex("Address") + .IsUnique(); + + b.ToTable("AdvertisedServer"); + + b.HasCheckConstraint("AddressSs14Uri", "\"Address\" LIKE 'ss14://%' OR \"Address\" LIKE 'ss14s://%'"); + }); + + modelBuilder.Entity("SS14.ServerHub.Shared.Data.HubAudit", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Actor") + .HasColumnType("uuid"); + + b.Property("Data") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Time") + .HasColumnType("timestamp with time zone"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Time"); + + b.ToTable("HubAudit"); + }); + + modelBuilder.Entity("SS14.ServerHub.Shared.Data.ServerStatusArchive", b => + { + b.Property("AdvertisedServerId") + .HasColumnType("integer"); + + b.Property("ServerStatusArchiveId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ServerStatusArchiveId")); + + b.Property("AdvertiserAddress") + .HasColumnType("inet"); + + b.Property("InferredTags") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("StatusData") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Time") + .HasColumnType("timestamp with time zone"); + + b.HasKey("AdvertisedServerId", "ServerStatusArchiveId"); + + b.ToTable("ServerStatusArchive"); + }); + + modelBuilder.Entity("SS14.ServerHub.Shared.Data.TrackedCommunity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("IsBanned") + .HasColumnType("boolean"); + + b.Property("LastUpdated") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Notes") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("TrackedCommunity"); + }); + + modelBuilder.Entity("SS14.ServerHub.Shared.Data.TrackedCommunityAddress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property>("Address") + .HasColumnType("inet"); + + b.Property("TrackedCommunityId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("TrackedCommunityId"); + + b.ToTable("TrackedCommunityAddress"); + }); + + modelBuilder.Entity("SS14.ServerHub.Shared.Data.TrackedCommunityDomain", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("DomainName") + .IsRequired() + .HasColumnType("text"); + + b.Property("TrackedCommunityId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("TrackedCommunityId"); + + b.ToTable("TrackedCommunityDomain"); + }); + + modelBuilder.Entity("SS14.ServerHub.Shared.Data.TrackedCommunityInfoMatch", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Field") + .HasColumnType("integer"); + + b.Property("Path") + .IsRequired() + .HasColumnType("jsonpath"); + + b.Property("TrackedCommunityId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("TrackedCommunityId"); + + b.ToTable("TrackedCommunityInfoMatch"); + }); + + modelBuilder.Entity("SS14.ServerHub.Shared.Data.UniqueServerName", b => + { + b.Property("AdvertisedServerId") + .HasColumnType("integer"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("FirstSeen") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSeen") + .HasColumnType("timestamp with time zone"); + + b.HasKey("AdvertisedServerId", "Name"); + + b.ToTable("UniqueServerName"); + }); + + modelBuilder.Entity("SS14.ServerHub.Shared.Data.ServerStatusArchive", b => + { + b.HasOne("SS14.ServerHub.Shared.Data.AdvertisedServer", "AdvertisedServer") + .WithMany() + .HasForeignKey("AdvertisedServerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AdvertisedServer"); + }); + + modelBuilder.Entity("SS14.ServerHub.Shared.Data.TrackedCommunityAddress", b => + { + b.HasOne("SS14.ServerHub.Shared.Data.TrackedCommunity", "TrackedCommunity") + .WithMany("Addresses") + .HasForeignKey("TrackedCommunityId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("TrackedCommunity"); + }); + + modelBuilder.Entity("SS14.ServerHub.Shared.Data.TrackedCommunityDomain", b => + { + b.HasOne("SS14.ServerHub.Shared.Data.TrackedCommunity", "TrackedCommunity") + .WithMany("Domains") + .HasForeignKey("TrackedCommunityId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("TrackedCommunity"); + }); + + modelBuilder.Entity("SS14.ServerHub.Shared.Data.TrackedCommunityInfoMatch", b => + { + b.HasOne("SS14.ServerHub.Shared.Data.TrackedCommunity", "TrackedCommunity") + .WithMany("InfoMatches") + .HasForeignKey("TrackedCommunityId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("TrackedCommunity"); + }); + + modelBuilder.Entity("SS14.ServerHub.Shared.Data.UniqueServerName", b => + { + b.HasOne("SS14.ServerHub.Shared.Data.AdvertisedServer", "AdvertisedServer") + .WithMany() + .HasForeignKey("AdvertisedServerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AdvertisedServer"); + }); + + modelBuilder.Entity("SS14.ServerHub.Shared.Data.TrackedCommunity", b => + { + b.Navigation("Addresses"); + + b.Navigation("Domains"); + + b.Navigation("InfoMatches"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/SS14.ServerHub.Shared/Migrations/20240331202957_InferredTags.cs b/SS14.ServerHub.Shared/Migrations/20240331202957_InferredTags.cs new file mode 100644 index 0000000..cb4a8b2 --- /dev/null +++ b/SS14.ServerHub.Shared/Migrations/20240331202957_InferredTags.cs @@ -0,0 +1,38 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace SS14.ServerHub.Shared.Migrations +{ + public partial class InferredTags : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "InferredTags", + table: "ServerStatusArchive", + type: "text[]", + nullable: false, + defaultValue: new string[0]); + + migrationBuilder.AddColumn( + name: "InferredTags", + table: "AdvertisedServer", + type: "text[]", + nullable: false, + defaultValue: new string[0]); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "InferredTags", + table: "ServerStatusArchive"); + + migrationBuilder.DropColumn( + name: "InferredTags", + table: "AdvertisedServer"); + } + } +} diff --git a/SS14.ServerHub.Shared/Migrations/HubDbContextModelSnapshot.cs b/SS14.ServerHub.Shared/Migrations/HubDbContextModelSnapshot.cs index 54fb953..8fbc5a8 100644 --- a/SS14.ServerHub.Shared/Migrations/HubDbContextModelSnapshot.cs +++ b/SS14.ServerHub.Shared/Migrations/HubDbContextModelSnapshot.cs @@ -42,6 +42,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("Expires") .HasColumnType("timestamp with time zone"); + b.Property("InferredTags") + .IsRequired() + .HasColumnType("text[]"); + b.Property("InfoData") .HasColumnType("jsonb"); @@ -100,6 +104,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("AdvertiserAddress") .HasColumnType("inet"); + b.Property("InferredTags") + .IsRequired() + .HasColumnType("text[]"); + b.Property("StatusData") .IsRequired() .HasColumnType("jsonb"); @@ -265,7 +273,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) modelBuilder.Entity("SS14.ServerHub.Shared.Data.TrackedCommunityInfoMatch", b => { b.HasOne("SS14.ServerHub.Shared.Data.TrackedCommunity", "TrackedCommunity") - .WithMany() + .WithMany("InfoMatches") .HasForeignKey("TrackedCommunityId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); @@ -289,6 +297,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("Addresses"); b.Navigation("Domains"); + + b.Navigation("InfoMatches"); }); #pragma warning restore 612, 618 } diff --git a/SS14.ServerHub/Controllers/ServerListController.cs b/SS14.ServerHub/Controllers/ServerListController.cs index afda82b..96bd00c 100644 --- a/SS14.ServerHub/Controllers/ServerListController.cs +++ b/SS14.ServerHub/Controllers/ServerListController.cs @@ -14,6 +14,7 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +using SS14.ServerHub.ServerData; using SS14.ServerHub.Shared; using SS14.ServerHub.Shared.Data; using SS14.ServerHub.Utility; @@ -47,7 +48,7 @@ public async Task> Get() { var dbInfos = await _dbContext.AdvertisedServer .Where(s => s.Expires > DateTime.UtcNow) - .Select(s => new ServerInfo(s.Address, s.StatusData == null ? null : new RawJson(s.StatusData))) + .Select(s => new ServerInfo(s.Address, s.StatusData == null ? null : new RawJson(s.StatusData), s.InferredTags)) .ToArrayAsync(); return dbInfos; @@ -117,6 +118,8 @@ parsedAddress.Scheme is not (Ss14UriHelper.SchemeSs14 or Ss14UriHelper.SchemeSs1 return Unauthorized("Your server has been blocked from advertising on the hub. If you believe this to be in error, please contact us."); } + var inferredTags = InferTags(statusJson); + // Check if a server with this address already exists. var addressEntity = await _dbContext.AdvertisedServer.SingleOrDefaultAsync(a => a.Address == advertise.Address); @@ -136,13 +139,15 @@ parsedAddress.Scheme is not (Ss14UriHelper.SchemeSs14 or Ss14UriHelper.SchemeSs1 addressEntity.StatusData = statusJson; addressEntity.InfoData = infoJson; addressEntity.AdvertiserAddress = senderIp; + addressEntity.InferredTags = inferredTags; _dbContext.ServerStatusArchive.Add(new ServerStatusArchive { Time = timeNow, AdvertisedServer = addressEntity, AdvertiserAddress = senderIp, - StatusData = statusJson + StatusData = statusJson, + InferredTags = inferredTags }); await _dbContext.SaveChangesAsync(); @@ -258,6 +263,13 @@ private BanCheckResult CheckMatchedCommunitiesForBan(Uri address, List b.TrackedCommunity.IsBanned); } + private static string[] InferTags(byte[] statusDataJson) + { + var statusData = JsonSerializer.Deserialize(statusDataJson)!; + + return ServerTagInfer.InferTags(statusData.Name!, statusData.Tags ?? Array.Empty()); + } + private enum BanCheckResult { Banned, @@ -265,14 +277,16 @@ private enum BanCheckResult FailedResolve } - public sealed record ServerInfo(string Address, RawJson? StatusData); + public sealed record ServerInfo(string Address, RawJson? StatusData, string[] InferredTags); public sealed record ServerAdvertise(string Address); // ReSharper disable once ClassNeverInstantiated.Local private sealed record ServerStatus( [property: JsonPropertyName("name")] string? Name, [property: JsonPropertyName("players")] - int PlayerCount); + int PlayerCount, + [property: JsonPropertyName("tags")] + string[]? Tags); [JsonConverter(typeof(RawJsonConverter))] public sealed record RawJson(byte[] Json) diff --git a/SS14.ServerHub/ServerData/ServerTagInfer.cs b/SS14.ServerHub/ServerData/ServerTagInfer.cs new file mode 100644 index 0000000..535d84a --- /dev/null +++ b/SS14.ServerHub/ServerData/ServerTagInfer.cs @@ -0,0 +1,69 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; + +namespace SS14.ServerHub.ServerData; + +/// +/// Helper code for inferring server tags from other metadata, such as their name. +/// Intended as a stopgap measure before servers properly tag their stuff in the API. +/// +public static partial class ServerTagInfer +{ + private static readonly Regex TagLikeRegex = MyRegex(); + + public static string[] InferTags(string currentName, string[] currentTags) + { + var addedTags = new List(); + + // no_tag_infer stops all inference logic. + if (currentTags.Contains(Tags.TagNoTagInfer)) + return Array.Empty(); + + // Extract all name [tags] via regex. + var tagLikes = TagLikeRegex + .Matches(currentName) + .Select(x => x.Groups[1].Captures[0].Value) + .ToHashSet(StringComparer.CurrentCultureIgnoreCase); + + // Infer language tags for [EN] and [RU] if there's no language tags. + // Not adding any other languages, advertise it properly with the API. + if (!currentTags.Any(t => t.StartsWith(Tags.TagLanguage, StringComparison.OrdinalIgnoreCase))) + { + if (tagLikes.Contains("en")) + { + addedTags.Add(Tags.TagLanguage + "en"); + } + else if (tagLikes.Contains("ru") || tagLikes.Contains("rus")) + { + addedTags.Add(Tags.TagLanguage + "ru"); + } + } + + // Infer 18+ + if (!currentTags.Contains(Tags.TagEighteenPlus)) + { + if (tagLikes.Contains("18+") || tagLikes.Contains("+18") || tagLikes.Contains("18") || tagLikes.Contains("ERP")) + addedTags.Add(Tags.TagEighteenPlus); + } + + // Infer NRP/LRP/MRP/HRP if no RP tags. + if (!currentTags.Any(t => t.StartsWith(Tags.TagRolePlay, StringComparison.OrdinalIgnoreCase))) + { + if (tagLikes.Contains("nrp")) + addedTags.Add(Tags.TagRolePlay + Tags.RolePlayNone); + else if (tagLikes.Contains("lrp")) + addedTags.Add(Tags.TagRolePlay + Tags.RolePlayLow); + else if (tagLikes.Contains("mrp")) + addedTags.Add(Tags.TagRolePlay + Tags.RolePlayMedium); + else if (tagLikes.Contains("hrp")) + addedTags.Add(Tags.TagRolePlay + Tags.RolePlayHigh); + } + + return addedTags.ToArray(); + } + + [GeneratedRegex("\\[(.*?)\\]")] + private static partial Regex MyRegex(); +} diff --git a/SS14.ServerHub/ServerData/Tags.cs b/SS14.ServerHub/ServerData/Tags.cs new file mode 100644 index 0000000..f11b232 --- /dev/null +++ b/SS14.ServerHub/ServerData/Tags.cs @@ -0,0 +1,76 @@ +using System; +using System.Diagnostics.CodeAnalysis; + +namespace SS14.ServerHub.ServerData; + +/// +/// Contains definitions for standard tags returned by game servers. +/// +public static class Tags +{ + // @formatter:off + + // Base tag definitions. + public const string TagEighteenPlus = "18+"; + public const string TagRegion = "region:"; + public const string TagLanguage = "lang:"; + public const string TagRolePlay = "rp:"; + public const string TagNoTagInfer = "no_tag_infer"; + + // Region tags. + public const string RegionAfricaCentral = "af_c"; + public const string RegionAfricaNorth = "af_n"; + public const string RegionAfricaSouth = "af_s"; + public const string RegionAntarctica = "ata"; + public const string RegionAsiaEast = "as_e"; + public const string RegionAsiaNorth = "as_n"; + public const string RegionAsiaSouthEast = "as_se"; + public const string RegionCentralAmerica = "am_c"; + public const string RegionEuropeEast = "eu_e"; + public const string RegionEuropeWest = "eu_w"; + public const string RegionGreenland = "grl"; + public const string RegionIndia = "ind"; + public const string RegionMiddleEast = "me"; + public const string RegionMoon = "luna"; + public const string RegionNorthAmericaCentral = "am_n_c"; + public const string RegionNorthAmericaEast = "am_n_e"; + public const string RegionNorthAmericaWest = "am_n_w"; + public const string RegionOceania = "oce"; + public const string RegionSouthAmericaEast = "am_s_e"; + public const string RegionSouthAmericaSouth = "am_s_s"; + public const string RegionSouthAmericaWest = "am_s_w"; + + // RolePlay level tags. + public const string RolePlayNone = "none"; + public const string RolePlayLow = "low"; + public const string RolePlayMedium = "med"; + public const string RolePlayHigh = "high"; + // @formatter:on + + public static bool TryRegion(string tag, [NotNullWhen(true)] out string? region) + { + return TryTagPrefix(tag, TagRegion, out region); + } + + public static bool TryLanguage(string tag, [NotNullWhen(true)] out string? language) + { + return TryTagPrefix(tag, TagLanguage, out language); + } + + public static bool TryRolePlay(string tag, [NotNullWhen(true)] out string? rolePlay) + { + return TryTagPrefix(tag, TagRolePlay, out rolePlay); + } + + public static bool TryTagPrefix(string tag, string prefix, [NotNullWhen(true)] out string? value) + { + if (!tag.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + { + value = null; + return false; + } + + value = tag[prefix.Length..]; + return true; + } +} \ No newline at end of file