Revert "A bit of DB model cleanup (#5016)"
This reverts commit 8a3cee9a10.
This commit is contained in:
@@ -80,6 +80,7 @@ namespace Content.Server.Database
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Table("preference")]
|
||||||
public class Preference
|
public class Preference
|
||||||
{
|
{
|
||||||
// NOTE: on postgres there SHOULD be an FK ensuring that the selected character slot always exists.
|
// NOTE: on postgres there SHOULD be an FK ensuring that the selected character slot always exists.
|
||||||
@@ -87,46 +88,49 @@ namespace Content.Server.Database
|
|||||||
// Because if I let EFCore know about it it would explode on a circular reference.
|
// Because if I let EFCore know about it it would explode on a circular reference.
|
||||||
// Also it has to be DEFERRABLE INITIALLY DEFERRED so that insertion of new preferences works.
|
// Also it has to be DEFERRABLE INITIALLY DEFERRED so that insertion of new preferences works.
|
||||||
// Also I couldn't figure out how to create it on SQLite.
|
// Also I couldn't figure out how to create it on SQLite.
|
||||||
public int Id { get; set; }
|
|
||||||
public Guid UserId { get; set; }
|
[Column("preference_id")] public int Id { get; set; }
|
||||||
public int SelectedCharacterSlot { get; set; }
|
[Column("user_id")] public Guid UserId { get; set; }
|
||||||
public string AdminOOCColor { get; set; } = null!;
|
[Column("selected_character_slot")] public int SelectedCharacterSlot { get; set; }
|
||||||
|
[Column("admin_ooc_color")] public string AdminOOCColor { get; set; } = null!;
|
||||||
public List<Profile> Profiles { get; } = new();
|
public List<Profile> Profiles { get; } = new();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Table("profile")]
|
||||||
public class Profile
|
public class Profile
|
||||||
{
|
{
|
||||||
public int Id { get; set; }
|
[Column("profile_id")] public int Id { get; set; }
|
||||||
public int Slot { get; set; }
|
[Column("slot")] public int Slot { get; set; }
|
||||||
[Column("char_name")] public string CharacterName { get; set; } = null!;
|
[Column("char_name")] public string CharacterName { get; set; } = null!;
|
||||||
public int Age { get; set; }
|
[Column("age")] public int Age { get; set; }
|
||||||
public string Sex { get; set; } = null!;
|
[Column("sex")] public string Sex { get; set; } = null!;
|
||||||
public string Gender { get; set; } = null!;
|
[Column("gender")] public string Gender { get; set; } = null!;
|
||||||
public string HairName { get; set; } = null!;
|
[Column("hair_name")] public string HairName { get; set; } = null!;
|
||||||
public string HairColor { get; set; } = null!;
|
[Column("hair_color")] public string HairColor { get; set; } = null!;
|
||||||
public string FacialHairName { get; set; } = null!;
|
[Column("facial_hair_name")] public string FacialHairName { get; set; } = null!;
|
||||||
public string FacialHairColor { get; set; } = null!;
|
[Column("facial_hair_color")] public string FacialHairColor { get; set; } = null!;
|
||||||
public string EyeColor { get; set; } = null!;
|
[Column("eye_color")] public string EyeColor { get; set; } = null!;
|
||||||
public string SkinColor { get; set; } = null!;
|
[Column("skin_color")] public string SkinColor { get; set; } = null!;
|
||||||
public string Clothing { get; set; } = null!;
|
[Column("clothing")] public string Clothing { get; set; } = null!;
|
||||||
public string Backpack { get; set; } = null!;
|
[Column("backpack")] public string Backpack { get; set; } = null!;
|
||||||
public List<Job> Jobs { get; } = new();
|
public List<Job> Jobs { get; } = new();
|
||||||
public List<Antag> Antags { get; } = new();
|
public List<Antag> Antags { get; } = new();
|
||||||
|
|
||||||
[Column("pref_unavailable")] public DbPreferenceUnavailableMode PreferenceUnavailable { get; set; }
|
[Column("pref_unavailable")] public DbPreferenceUnavailableMode PreferenceUnavailable { get; set; }
|
||||||
|
|
||||||
public int PreferenceId { get; set; }
|
[Column("preference_id")] public int PreferenceId { get; set; }
|
||||||
public Preference Preference { get; set; } = null!;
|
public Preference Preference { get; set; } = null!;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Table("job")]
|
||||||
public class Job
|
public class Job
|
||||||
{
|
{
|
||||||
public int Id { get; set; }
|
[Column("job_id")] public int Id { get; set; }
|
||||||
public Profile Profile { get; set; } = null!;
|
public Profile Profile { get; set; } = null!;
|
||||||
public int ProfileId { get; set; }
|
[Column("profile_id")] public int ProfileId { get; set; }
|
||||||
|
|
||||||
public string JobName { get; set; } = null!;
|
[Column("job_name")] public string JobName { get; set; } = null!;
|
||||||
public DbJobPriority Priority { get; set; }
|
[Column("priority")] public DbJobPriority Priority { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum DbJobPriority
|
public enum DbJobPriority
|
||||||
@@ -138,13 +142,14 @@ namespace Content.Server.Database
|
|||||||
High = 3
|
High = 3
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Table("antag")]
|
||||||
public class Antag
|
public class Antag
|
||||||
{
|
{
|
||||||
public int Id { get; set; }
|
[Column("antag_id")] public int Id { get; set; }
|
||||||
public Profile Profile { get; set; } = null!;
|
public Profile Profile { get; set; } = null!;
|
||||||
public int ProfileId { get; set; }
|
[Column("profile_id")] public int ProfileId { get; set; }
|
||||||
|
|
||||||
public string AntagName { get; set; } = null!;
|
[Column("antag_name")] public string AntagName { get; set; } = null!;
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum DbPreferenceUnavailableMode
|
public enum DbPreferenceUnavailableMode
|
||||||
@@ -154,49 +159,54 @@ namespace Content.Server.Database
|
|||||||
SpawnAsOverflow,
|
SpawnAsOverflow,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Table("assigned_user_id")]
|
||||||
public class AssignedUserId
|
public class AssignedUserId
|
||||||
{
|
{
|
||||||
public int Id { get; set; }
|
[Column("assigned_user_id_id")] public int Id { get; set; }
|
||||||
public string UserName { get; set; } = null!;
|
[Column("user_name")] public string UserName { get; set; } = null!;
|
||||||
|
|
||||||
public Guid UserId { get; set; }
|
[Column("user_id")] public Guid UserId { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Table("admin")]
|
||||||
public class Admin
|
public class Admin
|
||||||
{
|
{
|
||||||
[Key] public Guid UserId { get; set; }
|
[Column("user_id"), Key] public Guid UserId { get; set; }
|
||||||
public string? Title { get; set; }
|
[Column("title")] public string? Title { get; set; }
|
||||||
|
|
||||||
public int? AdminRankId { get; set; }
|
[Column("admin_rank_id")] public int? AdminRankId { get; set; }
|
||||||
public AdminRank? AdminRank { get; set; }
|
public AdminRank? AdminRank { get; set; }
|
||||||
public List<AdminFlag> Flags { get; set; } = default!;
|
public List<AdminFlag> Flags { get; set; } = default!;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Table("admin_flag")]
|
||||||
public class AdminFlag
|
public class AdminFlag
|
||||||
{
|
{
|
||||||
public int Id { get; set; }
|
[Column("admin_flag_id")] public int Id { get; set; }
|
||||||
public string Flag { get; set; } = default!;
|
[Column("flag")] public string Flag { get; set; } = default!;
|
||||||
public bool Negative { get; set; }
|
[Column("negative")] public bool Negative { get; set; }
|
||||||
|
|
||||||
public Guid AdminId { get; set; }
|
[Column("admin_id")] public Guid AdminId { get; set; }
|
||||||
public Admin Admin { get; set; } = default!;
|
public Admin Admin { get; set; } = default!;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Table("admin_rank")]
|
||||||
public class AdminRank
|
public class AdminRank
|
||||||
{
|
{
|
||||||
public int Id { get; set; }
|
[Column("admin_rank_id")] public int Id { get; set; }
|
||||||
public string Name { get; set; } = default!;
|
[Column("name")] public string Name { get; set; } = default!;
|
||||||
|
|
||||||
public List<Admin> Admins { get; set; } = default!;
|
public List<Admin> Admins { get; set; } = default!;
|
||||||
public List<AdminRankFlag> Flags { get; set; } = default!;
|
public List<AdminRankFlag> Flags { get; set; } = default!;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Table("admin_rank_flag")]
|
||||||
public class AdminRankFlag
|
public class AdminRankFlag
|
||||||
{
|
{
|
||||||
public int Id { get; set; }
|
[Column("admin_rank_flag_id")] public int Id { get; set; }
|
||||||
public string Flag { get; set; } = default!;
|
[Column("flag")] public string Flag { get; set; } = default!;
|
||||||
|
|
||||||
public int AdminRankId { get; set; }
|
[Column("admin_rank_id")] public int AdminRankId { get; set; }
|
||||||
public AdminRank Rank { get; set; } = default!;
|
public AdminRank Rank { get; set; } = default!;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
using System.ComponentModel.DataAnnotations.Schema;
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
using System.Net;
|
using System.Net;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
|
||||||
using Microsoft.EntityFrameworkCore.Storage;
|
using Microsoft.EntityFrameworkCore.Storage;
|
||||||
|
|
||||||
namespace Content.Server.Database
|
namespace Content.Server.Database
|
||||||
@@ -26,8 +25,6 @@ namespace Content.Server.Database
|
|||||||
options.UseNpgsql("dummy connection string");
|
options.UseNpgsql("dummy connection string");
|
||||||
|
|
||||||
options.ReplaceService<IRelationalTypeMappingSource, CustomNpgsqlTypeMappingSource>();
|
options.ReplaceService<IRelationalTypeMappingSource, CustomNpgsqlTypeMappingSource>();
|
||||||
|
|
||||||
((IDbContextOptionsBuilderInfrastructure) options).AddOrUpdateExtension(new SnakeCaseExtension());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public PostgresServerDbContext(DbContextOptions<ServerDbContext> options) : base(options)
|
public PostgresServerDbContext(DbContextOptions<ServerDbContext> options) : base(options)
|
||||||
@@ -77,32 +74,26 @@ namespace Content.Server.Database
|
|||||||
modelBuilder.Entity<PostgresConnectionLog>()
|
modelBuilder.Entity<PostgresConnectionLog>()
|
||||||
.HasCheckConstraint("AddressNotIPv6MappedIPv4",
|
.HasCheckConstraint("AddressNotIPv6MappedIPv4",
|
||||||
"NOT inet '::ffff:0.0.0.0/96' >>= address");
|
"NOT inet '::ffff:0.0.0.0/96' >>= address");
|
||||||
|
|
||||||
foreach(var entity in modelBuilder.Model.GetEntityTypes())
|
|
||||||
{
|
|
||||||
foreach(var property in entity.GetProperties())
|
|
||||||
{
|
|
||||||
if (property.FieldInfo.FieldType == typeof(DateTime) || property.FieldInfo.FieldType == typeof(DateTime?))
|
|
||||||
property.SetColumnType("timestamp with time zone");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[Table("server_ban")]
|
[Table("server_ban")]
|
||||||
public class PostgresServerBan
|
public class PostgresServerBan
|
||||||
{
|
{
|
||||||
public int Id { get; set; }
|
[Column("server_ban_id")] public int Id { get; set; }
|
||||||
public Guid? UserId { get; set; }
|
|
||||||
[Column(TypeName = "inet")] public (IPAddress, int)? Address { get; set; }
|
|
||||||
public byte[]? HWId { get; set; }
|
|
||||||
|
|
||||||
|
[Column("user_id")] public Guid? UserId { get; set; }
|
||||||
|
[Column("address", TypeName = "inet")] public (IPAddress, int)? Address { get; set; }
|
||||||
|
[Column("hwid")] public byte[]? HWId { get; set; }
|
||||||
|
|
||||||
|
[Column("ban_time", TypeName = "timestamp with time zone")]
|
||||||
public DateTime BanTime { get; set; }
|
public DateTime BanTime { get; set; }
|
||||||
|
|
||||||
|
[Column("expiration_time", TypeName = "timestamp with time zone")]
|
||||||
public DateTime? ExpirationTime { get; set; }
|
public DateTime? ExpirationTime { get; set; }
|
||||||
|
|
||||||
public string Reason { get; set; } = null!;
|
[Column("reason")] public string Reason { get; set; } = null!;
|
||||||
public Guid? BanningAdmin { get; set; }
|
[Column("banning_admin")] public Guid? BanningAdmin { get; set; }
|
||||||
|
|
||||||
public PostgresServerUnban? Unban { get; set; }
|
public PostgresServerUnban? Unban { get; set; }
|
||||||
}
|
}
|
||||||
@@ -112,44 +103,48 @@ namespace Content.Server.Database
|
|||||||
{
|
{
|
||||||
[Column("unban_id")] public int Id { get; set; }
|
[Column("unban_id")] public int Id { get; set; }
|
||||||
|
|
||||||
public int BanId { get; set; }
|
[Column("ban_id")] public int BanId { get; set; }
|
||||||
public PostgresServerBan Ban { get; set; } = null!;
|
[Column("ban")] public PostgresServerBan Ban { get; set; } = null!;
|
||||||
|
|
||||||
public Guid? UnbanningAdmin { get; set; }
|
[Column("unbanning_admin")] public Guid? UnbanningAdmin { get; set; }
|
||||||
|
|
||||||
|
[Column("unban_time", TypeName = "timestamp with time zone")]
|
||||||
public DateTime UnbanTime { get; set; }
|
public DateTime UnbanTime { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
[Table("player")]
|
[Table("player")]
|
||||||
public class PostgresPlayer
|
public class PostgresPlayer
|
||||||
{
|
{
|
||||||
public int Id { get; set; }
|
[Column("player_id")] public int Id { get; set; }
|
||||||
|
|
||||||
// Permanent data
|
// Permanent data
|
||||||
public Guid UserId { get; set; }
|
[Column("user_id")] public Guid UserId { get; set; }
|
||||||
|
|
||||||
|
[Column("first_seen_time", TypeName = "timestamp with time zone")]
|
||||||
public DateTime FirstSeenTime { get; set; }
|
public DateTime FirstSeenTime { get; set; }
|
||||||
|
|
||||||
// Data that gets updated on each join.
|
// Data that gets updated on each join.
|
||||||
public string LastSeenUserName { get; set; } = null!;
|
[Column("last_seen_user_name")] public string LastSeenUserName { get; set; } = null!;
|
||||||
|
|
||||||
|
[Column("last_seen_time", TypeName = "timestamp with time zone")]
|
||||||
public DateTime LastSeenTime { get; set; }
|
public DateTime LastSeenTime { get; set; }
|
||||||
|
|
||||||
public IPAddress LastSeenAddress { get; set; } = null!;
|
[Column("last_seen_address")] public IPAddress LastSeenAddress { get; set; } = null!;
|
||||||
public byte[]? LastSeenHWId { get; set; }
|
[Column("last_seen_hwid")] public byte[]? LastSeenHWId { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
[Table("connection_log")]
|
[Table("connection_log")]
|
||||||
public class PostgresConnectionLog
|
public class PostgresConnectionLog
|
||||||
{
|
{
|
||||||
public int Id { get; set; }
|
[Column("connection_log_id")] public int Id { get; set; }
|
||||||
|
|
||||||
public Guid UserId { get; set; }
|
[Column("user_id")] public Guid UserId { get; set; }
|
||||||
public string UserName { get; set; } = null!;
|
[Column("user_name")] public string UserName { get; set; } = null!;
|
||||||
|
|
||||||
|
[Column("time", TypeName = "timestamp with time zone")]
|
||||||
public DateTime Time { get; set; }
|
public DateTime Time { get; set; }
|
||||||
|
|
||||||
public IPAddress Address { get; set; } = null!;
|
[Column("address")] public IPAddress Address { get; set; } = null!;
|
||||||
public byte[]? HWId { get; set; }
|
[Column("hwid")] public byte[]? HWId { get; set; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,6 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.ComponentModel.DataAnnotations.Schema;
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
using System.Globalization;
|
|
||||||
using System.Net;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
|
||||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
|
||||||
|
|
||||||
namespace Content.Server.Database
|
namespace Content.Server.Database
|
||||||
{
|
{
|
||||||
@@ -23,8 +19,6 @@ namespace Content.Server.Database
|
|||||||
{
|
{
|
||||||
if (!InitializedWithOptions)
|
if (!InitializedWithOptions)
|
||||||
options.UseSqlite("dummy connection string");
|
options.UseSqlite("dummy connection string");
|
||||||
|
|
||||||
((IDbContextOptionsBuilderInfrastructure) options).AddOrUpdateExtension(new SnakeCaseExtension());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
@@ -33,56 +27,26 @@ namespace Content.Server.Database
|
|||||||
|
|
||||||
modelBuilder.Entity<SqlitePlayer>()
|
modelBuilder.Entity<SqlitePlayer>()
|
||||||
.HasIndex(p => p.LastSeenUserName);
|
.HasIndex(p => p.LastSeenUserName);
|
||||||
|
|
||||||
var converter = new ValueConverter<(IPAddress address, int mask), string>(
|
|
||||||
v => InetToString(v.address, v.mask),
|
|
||||||
v => StringToInet(v)
|
|
||||||
);
|
|
||||||
|
|
||||||
modelBuilder
|
|
||||||
.Entity<SqliteServerBan>()
|
|
||||||
.Property(e => e.Address)
|
|
||||||
.HasColumnType("TEXT")
|
|
||||||
.HasConversion(converter);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public SqliteServerDbContext(DbContextOptions<ServerDbContext> options) : base(options)
|
public SqliteServerDbContext(DbContextOptions<ServerDbContext> options) : base(options)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string InetToString(IPAddress address, int mask) {
|
|
||||||
if (address.IsIPv4MappedToIPv6)
|
|
||||||
{
|
|
||||||
// Fix IPv6-mapped IPv4 addresses
|
|
||||||
// So that IPv4 addresses are consistent between separate-socket and dual-stack socket modes.
|
|
||||||
address = address.MapToIPv4();
|
|
||||||
mask -= 96;
|
|
||||||
}
|
|
||||||
return $"{address}/{mask}";
|
|
||||||
}
|
|
||||||
|
|
||||||
private static (IPAddress, int) StringToInet(string inet) {
|
|
||||||
var idx = inet.IndexOf('/', StringComparison.Ordinal);
|
|
||||||
return (
|
|
||||||
IPAddress.Parse(inet.AsSpan(0, idx)),
|
|
||||||
int.Parse(inet.AsSpan(idx + 1), provider: CultureInfo.InvariantCulture)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Table("ban")]
|
[Table("ban")]
|
||||||
public class SqliteServerBan
|
public class SqliteServerBan
|
||||||
{
|
{
|
||||||
public int Id { get; set; }
|
[Column("ban_id")] public int Id { get; set; }
|
||||||
|
|
||||||
public Guid? UserId { get; set; }
|
[Column("user_id")] public Guid? UserId { get; set; }
|
||||||
public (IPAddress address, int mask)? Address { get; set; }
|
[Column("address")] public string? Address { get; set; }
|
||||||
public byte[]? HWId { get; set; }
|
[Column("hwid")] public byte[]? HWId { get; set; }
|
||||||
|
|
||||||
public DateTime BanTime { get; set; }
|
[Column("ban_time")] public DateTime BanTime { get; set; }
|
||||||
public DateTime? ExpirationTime { get; set; }
|
[Column("expiration_time")] public DateTime? ExpirationTime { get; set; }
|
||||||
public string Reason { get; set; } = null!;
|
[Column("reason")] public string Reason { get; set; } = null!;
|
||||||
public Guid? BanningAdmin { get; set; }
|
[Column("banning_admin")] public Guid? BanningAdmin { get; set; }
|
||||||
|
|
||||||
public SqliteServerUnban? Unban { get; set; }
|
public SqliteServerUnban? Unban { get; set; }
|
||||||
}
|
}
|
||||||
@@ -92,38 +56,38 @@ namespace Content.Server.Database
|
|||||||
{
|
{
|
||||||
[Column("unban_id")] public int Id { get; set; }
|
[Column("unban_id")] public int Id { get; set; }
|
||||||
|
|
||||||
public int BanId { get; set; }
|
[Column("ban_id")] public int BanId { get; set; }
|
||||||
public SqliteServerBan Ban { get; set; } = null!;
|
public SqliteServerBan Ban { get; set; } = null!;
|
||||||
|
|
||||||
public Guid? UnbanningAdmin { get; set; }
|
[Column("unbanning_admin")] public Guid? UnbanningAdmin { get; set; }
|
||||||
public DateTime UnbanTime { get; set; }
|
[Column("unban_time")] public DateTime UnbanTime { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
[Table("player")]
|
[Table("player")]
|
||||||
public class SqlitePlayer
|
public class SqlitePlayer
|
||||||
{
|
{
|
||||||
public int Id { get; set; }
|
[Column("player_id")] public int Id { get; set; }
|
||||||
|
|
||||||
// Permanent data
|
// Permanent data
|
||||||
public Guid UserId { get; set; }
|
[Column("user_id")] public Guid UserId { get; set; }
|
||||||
public DateTime FirstSeenTime { get; set; }
|
[Column("first_seen_time")] public DateTime FirstSeenTime { get; set; }
|
||||||
|
|
||||||
// Data that gets updated on each join.
|
// Data that gets updated on each join.
|
||||||
public string LastSeenUserName { get; set; } = null!;
|
[Column("last_seen_user_name")] public string LastSeenUserName { get; set; } = null!;
|
||||||
public DateTime LastSeenTime { get; set; }
|
[Column("last_seen_time")] public DateTime LastSeenTime { get; set; }
|
||||||
public string LastSeenAddress { get; set; } = null!;
|
[Column("last_seen_address")] public string LastSeenAddress { get; set; } = null!;
|
||||||
public byte[]? LastSeenHWId { get; set; }
|
[Column("last_seen_hwid")] public byte[]? LastSeenHWId { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
[Table("connection_log")]
|
[Table("connection_log")]
|
||||||
public class SqliteConnectionLog
|
public class SqliteConnectionLog
|
||||||
{
|
{
|
||||||
public int Id { get; set; }
|
[Column("connection_log_id")] public int Id { get; set; }
|
||||||
|
|
||||||
public Guid UserId { get; set; }
|
[Column("user_id")] public Guid UserId { get; set; }
|
||||||
public string UserName { get; set; } = null!;
|
[Column("user_name")] public string UserName { get; set; } = null!;
|
||||||
public DateTime Time { get; set; }
|
[Column("time")] public DateTime Time { get; set; }
|
||||||
public string Address { get; set; } = null!;
|
[Column("address")] public string Address { get; set; } = null!;
|
||||||
public byte[]? HWId { get; set; }
|
[Column("hwid")] public byte[]? HWId { get; set; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,307 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Text.RegularExpressions;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
|
||||||
using Microsoft.EntityFrameworkCore.Metadata;
|
|
||||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
|
||||||
using Microsoft.EntityFrameworkCore.Metadata.Conventions;
|
|
||||||
using Microsoft.EntityFrameworkCore.Metadata.Conventions.Infrastructure;
|
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
|
||||||
|
|
||||||
namespace Content.Server.Database
|
|
||||||
{
|
|
||||||
public class SnakeCaseExtension : IDbContextOptionsExtension
|
|
||||||
{
|
|
||||||
public DbContextOptionsExtensionInfo Info { get; }
|
|
||||||
|
|
||||||
public SnakeCaseExtension() {
|
|
||||||
Info = new ExtensionInfo(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void ApplyServices(IServiceCollection services)
|
|
||||||
=> services.AddSnakeCase();
|
|
||||||
|
|
||||||
public void Validate(IDbContextOptions options) {}
|
|
||||||
|
|
||||||
private sealed class ExtensionInfo : DbContextOptionsExtensionInfo
|
|
||||||
{
|
|
||||||
public ExtensionInfo(IDbContextOptionsExtension extension) : base(extension) {}
|
|
||||||
|
|
||||||
public override bool IsDatabaseProvider => false;
|
|
||||||
|
|
||||||
public override string LogFragment => "Snake Case Extension";
|
|
||||||
|
|
||||||
public override long GetServiceProviderHashCode() => 0;
|
|
||||||
|
|
||||||
public override void PopulateDebugInfo(IDictionary<string, string> debugInfo)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public static class SnakeCaseServiceCollectionExtensions
|
|
||||||
{
|
|
||||||
public static IServiceCollection AddSnakeCase(
|
|
||||||
this IServiceCollection serviceCollection)
|
|
||||||
{
|
|
||||||
new EntityFrameworkServicesBuilder(serviceCollection)
|
|
||||||
.TryAdd<IConventionSetPlugin, SnakeCaseConventionSetPlugin>();
|
|
||||||
|
|
||||||
return serviceCollection;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public class SnakeCaseConventionSetPlugin : IConventionSetPlugin
|
|
||||||
{
|
|
||||||
public ConventionSet ModifyConventions(ConventionSet conventionSet)
|
|
||||||
{
|
|
||||||
var convention = new SnakeCaseConvention();
|
|
||||||
|
|
||||||
conventionSet.EntityTypeAddedConventions.Add(convention);
|
|
||||||
conventionSet.EntityTypeAnnotationChangedConventions.Add(convention);
|
|
||||||
conventionSet.PropertyAddedConventions.Add(convention);
|
|
||||||
conventionSet.ForeignKeyOwnershipChangedConventions.Add(convention);
|
|
||||||
conventionSet.KeyAddedConventions.Add(convention);
|
|
||||||
conventionSet.ForeignKeyAddedConventions.Add(convention);
|
|
||||||
conventionSet.EntityTypeBaseTypeChangedConventions.Add(convention);
|
|
||||||
conventionSet.ModelFinalizingConventions.Add(convention);
|
|
||||||
|
|
||||||
return conventionSet;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public class SnakeCaseConvention :
|
|
||||||
IEntityTypeAddedConvention,
|
|
||||||
IEntityTypeAnnotationChangedConvention,
|
|
||||||
IPropertyAddedConvention,
|
|
||||||
IForeignKeyOwnershipChangedConvention,
|
|
||||||
IKeyAddedConvention,
|
|
||||||
IForeignKeyAddedConvention,
|
|
||||||
IEntityTypeBaseTypeChangedConvention,
|
|
||||||
IModelFinalizingConvention
|
|
||||||
{
|
|
||||||
private static readonly StoreObjectType[] _storeObjectTypes
|
|
||||||
= { StoreObjectType.Table, StoreObjectType.View, StoreObjectType.Function, StoreObjectType.SqlQuery };
|
|
||||||
|
|
||||||
public SnakeCaseConvention() {}
|
|
||||||
|
|
||||||
public static string RewriteName(string name)
|
|
||||||
{
|
|
||||||
var regex = new Regex("[A-Z]+", RegexOptions.Compiled);
|
|
||||||
return regex.Replace(
|
|
||||||
name,
|
|
||||||
(Match match) => {
|
|
||||||
if (match.Index == 0 && (match.Value == "FK" || match.Value == "PK" || match.Value == "IX")) {
|
|
||||||
return match.Value;
|
|
||||||
}
|
|
||||||
if (match.Value == "HWI")
|
|
||||||
return (match.Index == 0 ? "" : "_") + "hwi";
|
|
||||||
if (match.Index == 0)
|
|
||||||
return match.Value.ToLower();
|
|
||||||
if (match.Length > 1)
|
|
||||||
return $"_{match.Value[..^1].ToLower()}_{match.Value[^1..^0].ToLower()}";
|
|
||||||
return "_" + match.Value.ToLower();
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public virtual void ProcessEntityTypeAdded(
|
|
||||||
IConventionEntityTypeBuilder entityTypeBuilder,
|
|
||||||
IConventionContext<IConventionEntityTypeBuilder> context)
|
|
||||||
{
|
|
||||||
var entityType = entityTypeBuilder.Metadata;
|
|
||||||
|
|
||||||
if (entityType.BaseType is null)
|
|
||||||
{
|
|
||||||
entityTypeBuilder.ToTable(RewriteName(entityType.GetTableName()), entityType.GetSchema());
|
|
||||||
|
|
||||||
if (entityType.GetViewNameConfigurationSource() == ConfigurationSource.Convention)
|
|
||||||
{
|
|
||||||
entityTypeBuilder.ToView(RewriteName(entityType.GetViewName()), entityType.GetViewSchema());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public void ProcessEntityTypeBaseTypeChanged(
|
|
||||||
IConventionEntityTypeBuilder entityTypeBuilder,
|
|
||||||
IConventionEntityType newBaseType,
|
|
||||||
IConventionEntityType oldBaseType,
|
|
||||||
IConventionContext<IConventionEntityType> context)
|
|
||||||
{
|
|
||||||
var entityType = entityTypeBuilder.Metadata;
|
|
||||||
|
|
||||||
if (newBaseType is null)
|
|
||||||
{
|
|
||||||
entityTypeBuilder.ToTable(RewriteName(entityType.GetTableName()), entityType.GetSchema());
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
entityTypeBuilder.HasNoAnnotation(RelationalAnnotationNames.TableName);
|
|
||||||
entityTypeBuilder.HasNoAnnotation(RelationalAnnotationNames.Schema);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public virtual void ProcessPropertyAdded(
|
|
||||||
IConventionPropertyBuilder propertyBuilder,
|
|
||||||
IConventionContext<IConventionPropertyBuilder> context)
|
|
||||||
=> RewriteColumnName(propertyBuilder);
|
|
||||||
|
|
||||||
public void ProcessForeignKeyOwnershipChanged(IConventionForeignKeyBuilder relationshipBuilder, IConventionContext<bool?> context)
|
|
||||||
{
|
|
||||||
var foreignKey = relationshipBuilder.Metadata;
|
|
||||||
var ownedEntityType = foreignKey.DeclaringEntityType;
|
|
||||||
|
|
||||||
if (foreignKey.IsOwnership && ownedEntityType.GetTableNameConfigurationSource() != ConfigurationSource.Explicit)
|
|
||||||
{
|
|
||||||
ownedEntityType.Builder.HasNoAnnotation(RelationalAnnotationNames.TableName);
|
|
||||||
ownedEntityType.Builder.HasNoAnnotation(RelationalAnnotationNames.Schema);
|
|
||||||
|
|
||||||
ownedEntityType.FindPrimaryKey()?.Builder.HasNoAnnotation(RelationalAnnotationNames.Name);
|
|
||||||
|
|
||||||
foreach (var property in ownedEntityType.GetProperties())
|
|
||||||
{
|
|
||||||
RewriteColumnName(property.Builder);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public void ProcessEntityTypeAnnotationChanged(
|
|
||||||
IConventionEntityTypeBuilder entityTypeBuilder,
|
|
||||||
string name,
|
|
||||||
IConventionAnnotation annotation,
|
|
||||||
IConventionAnnotation oldAnnotation,
|
|
||||||
IConventionContext<IConventionAnnotation> context)
|
|
||||||
{
|
|
||||||
var entityType = entityTypeBuilder.Metadata;
|
|
||||||
|
|
||||||
if (name != RelationalAnnotationNames.TableName
|
|
||||||
|| StoreObjectIdentifier.Create(entityType, StoreObjectType.Table) is not StoreObjectIdentifier tableIdentifier)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (entityType.FindPrimaryKey() is IConventionKey primaryKey)
|
|
||||||
{
|
|
||||||
if (entityType.FindRowInternalForeignKeys(tableIdentifier).FirstOrDefault() is null
|
|
||||||
&& (entityType.BaseType is null || entityType.GetTableName() == entityType.BaseType.GetTableName()))
|
|
||||||
{
|
|
||||||
primaryKey.Builder.HasName(RewriteName(primaryKey.GetDefaultName()));
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
primaryKey.Builder.HasNoAnnotation(RelationalAnnotationNames.Name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (var foreignKey in entityType.GetForeignKeys())
|
|
||||||
{
|
|
||||||
foreignKey.Builder.HasConstraintName(RewriteName(foreignKey.GetDefaultName()));
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (var index in entityType.GetIndexes())
|
|
||||||
{
|
|
||||||
index.Builder.HasDatabaseName(RewriteName(index.GetDefaultDatabaseName()));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (annotation?.Value is not null
|
|
||||||
&& entityType.FindOwnership() is IConventionForeignKey ownership
|
|
||||||
&& (string)annotation.Value != ownership.PrincipalEntityType.GetTableName())
|
|
||||||
{
|
|
||||||
foreach (var property in entityType.GetProperties()
|
|
||||||
.Except(entityType.FindPrimaryKey().Properties)
|
|
||||||
.Where(p => p.Builder.CanSetColumnName(null)))
|
|
||||||
{
|
|
||||||
RewriteColumnName(property.Builder);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (entityType.FindPrimaryKey() is IConventionKey key)
|
|
||||||
{
|
|
||||||
key.Builder.HasName(RewriteName(key.GetDefaultName()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public void ProcessForeignKeyAdded(
|
|
||||||
IConventionForeignKeyBuilder relationshipBuilder,
|
|
||||||
IConventionContext<IConventionForeignKeyBuilder> context)
|
|
||||||
=> relationshipBuilder.HasConstraintName(RewriteName(relationshipBuilder.Metadata.GetDefaultName()));
|
|
||||||
|
|
||||||
public void ProcessKeyAdded(IConventionKeyBuilder keyBuilder, IConventionContext<IConventionKeyBuilder> context)
|
|
||||||
{
|
|
||||||
var entityType = keyBuilder.Metadata.DeclaringEntityType;
|
|
||||||
|
|
||||||
if (entityType.FindOwnership() is null)
|
|
||||||
{
|
|
||||||
keyBuilder.HasName(RewriteName(keyBuilder.Metadata.GetDefaultName()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public void ProcessModelFinalizing(IConventionModelBuilder modelBuilder, IConventionContext<IConventionModelBuilder> context)
|
|
||||||
{
|
|
||||||
foreach (var entityType in modelBuilder.Metadata.GetEntityTypes())
|
|
||||||
{
|
|
||||||
foreach (var property in entityType.GetProperties())
|
|
||||||
{
|
|
||||||
var columnName = property.GetColumnBaseName();
|
|
||||||
if (columnName.StartsWith(entityType.ShortName() + '_', StringComparison.Ordinal))
|
|
||||||
{
|
|
||||||
property.Builder.HasColumnName(
|
|
||||||
RewriteName(entityType.ShortName()) + columnName[entityType.ShortName().Length..]);
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (var storeObjectType in _storeObjectTypes)
|
|
||||||
{
|
|
||||||
var identifier = StoreObjectIdentifier.Create(entityType, storeObjectType);
|
|
||||||
if (identifier is null)
|
|
||||||
continue;
|
|
||||||
|
|
||||||
if (property.GetColumnNameConfigurationSource(identifier.Value) == ConfigurationSource.Convention)
|
|
||||||
{
|
|
||||||
columnName = property.GetColumnName(identifier.Value);
|
|
||||||
if (columnName.StartsWith(entityType.ShortName() + '_', StringComparison.Ordinal))
|
|
||||||
{
|
|
||||||
property.Builder.HasColumnName(
|
|
||||||
RewriteName(entityType.ShortName())
|
|
||||||
+ columnName[entityType.ShortName().Length..]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void RewriteColumnName(IConventionPropertyBuilder propertyBuilder)
|
|
||||||
{
|
|
||||||
var property = propertyBuilder.Metadata;
|
|
||||||
var entityType = property.DeclaringEntityType;
|
|
||||||
|
|
||||||
property.Builder.HasNoAnnotation(RelationalAnnotationNames.ColumnName);
|
|
||||||
|
|
||||||
var baseColumnName = StoreObjectIdentifier.Create(property.DeclaringEntityType, StoreObjectType.Table) is { } tableIdentifier
|
|
||||||
? property.GetDefaultColumnName(tableIdentifier)
|
|
||||||
: property.GetDefaultColumnBaseName();
|
|
||||||
|
|
||||||
if (baseColumnName == "Id")
|
|
||||||
baseColumnName = entityType.GetTableName() + baseColumnName;
|
|
||||||
propertyBuilder.HasColumnName(RewriteName(baseColumnName));
|
|
||||||
|
|
||||||
foreach (var storeObjectType in _storeObjectTypes)
|
|
||||||
{
|
|
||||||
var identifier = StoreObjectIdentifier.Create(entityType, storeObjectType);
|
|
||||||
if (identifier is null)
|
|
||||||
continue;
|
|
||||||
|
|
||||||
if (property.GetColumnNameConfigurationSource(identifier.Value) == ConfigurationSource.Convention)
|
|
||||||
{
|
|
||||||
var name = property.GetColumnName(identifier.Value);
|
|
||||||
if (name == "Id")
|
|
||||||
name = entityType.GetTableName() + name;
|
|
||||||
propertyBuilder.HasColumnName(
|
|
||||||
RewriteName(name), identifier.Value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
|
|
||||||
if [ -z "$1" ] ; then
|
|
||||||
echo "Must specify migration name"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
dotnet ef migrations add --context SqliteServerDbContext -o Migrations/Sqlite "$1"
|
|
||||||
dotnet ef migrations add --context PostgresServerDbContext -o Migrations/Postgres "$1"
|
|
||||||
@@ -313,9 +313,6 @@ namespace Content.Server.Database
|
|||||||
Username = user,
|
Username = user,
|
||||||
Password = pass
|
Password = pass
|
||||||
}.ConnectionString;
|
}.ConnectionString;
|
||||||
|
|
||||||
Logger.DebugS("db.manager", $"Using Postgres \"{host}:{port}/{db}\"");
|
|
||||||
|
|
||||||
builder.UseNpgsql(connectionString);
|
builder.UseNpgsql(connectionString);
|
||||||
SetupLogging(builder);
|
SetupLogging(builder);
|
||||||
return builder.Options;
|
return builder.Options;
|
||||||
@@ -332,12 +329,10 @@ namespace Content.Server.Database
|
|||||||
if (!inMemory)
|
if (!inMemory)
|
||||||
{
|
{
|
||||||
var finalPreferencesDbPath = Path.Combine(_res.UserData.RootDir!, configPreferencesDbPath);
|
var finalPreferencesDbPath = Path.Combine(_res.UserData.RootDir!, configPreferencesDbPath);
|
||||||
Logger.DebugS("db.manager", $"Using SQLite DB \"{finalPreferencesDbPath}\"");
|
|
||||||
connection = new SqliteConnection($"Data Source={finalPreferencesDbPath}");
|
connection = new SqliteConnection($"Data Source={finalPreferencesDbPath}");
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
Logger.DebugS("db.manager", $"Using in-memory SQLite DB");
|
|
||||||
connection = new SqliteConnection("Data Source=:memory:");
|
connection = new SqliteConnection("Data Source=:memory:");
|
||||||
// When using an in-memory DB we have to open it manually
|
// When using an in-memory DB we have to open it manually
|
||||||
// so EFCore doesn't open, close and wipe it.
|
// so EFCore doesn't open, close and wipe it.
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ namespace Content.Server.Database
|
|||||||
NetUserId? userId,
|
NetUserId? userId,
|
||||||
ImmutableArray<byte>? hwId)
|
ImmutableArray<byte>? hwId)
|
||||||
{
|
{
|
||||||
if (address != null && ban.Address is not null && IPAddressExt.IsInSubnet(address, ban.Address.Value))
|
if (address != null && ban.Address != null && IPAddressExt.IsInSubnet(address, ban.Address))
|
||||||
{
|
{
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -126,9 +126,15 @@ namespace Content.Server.Database
|
|||||||
{
|
{
|
||||||
await using var db = await GetDbImpl();
|
await using var db = await GetDbImpl();
|
||||||
|
|
||||||
|
string? addrStr = null;
|
||||||
|
if (serverBan.Address is { } addr)
|
||||||
|
{
|
||||||
|
addrStr = $"{addr.address}/{addr.cidrMask}";
|
||||||
|
}
|
||||||
|
|
||||||
db.SqliteDbContext.Ban.Add(new SqliteServerBan
|
db.SqliteDbContext.Ban.Add(new SqliteServerBan
|
||||||
{
|
{
|
||||||
Address = serverBan.Address,
|
Address = addrStr,
|
||||||
Reason = serverBan.Reason,
|
Reason = serverBan.Reason,
|
||||||
BanningAdmin = serverBan.BanningAdmin?.UserId,
|
BanningAdmin = serverBan.BanningAdmin?.UserId,
|
||||||
HWId = serverBan.HWId?.ToArray(),
|
HWId = serverBan.HWId?.ToArray(),
|
||||||
@@ -239,12 +245,20 @@ namespace Content.Server.Database
|
|||||||
aUid = new NetUserId(aGuid);
|
aUid = new NetUserId(aGuid);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
(IPAddress, int)? addrTuple = null;
|
||||||
|
if (ban.Address != null)
|
||||||
|
{
|
||||||
|
var idx = ban.Address.IndexOf('/', StringComparison.Ordinal);
|
||||||
|
addrTuple = (IPAddress.Parse(ban.Address.AsSpan(0, idx)),
|
||||||
|
int.Parse(ban.Address.AsSpan(idx + 1), provider: CultureInfo.InvariantCulture));
|
||||||
|
}
|
||||||
|
|
||||||
var unban = ConvertUnban(ban.Unban);
|
var unban = ConvertUnban(ban.Unban);
|
||||||
|
|
||||||
return new ServerBanDef(
|
return new ServerBanDef(
|
||||||
ban.Id,
|
ban.Id,
|
||||||
uid,
|
uid,
|
||||||
ban.Address,
|
addrTuple,
|
||||||
ban.HWId == null ? null : ImmutableArray.Create(ban.HWId),
|
ban.HWId == null ? null : ImmutableArray.Create(ban.HWId),
|
||||||
ban.BanTime,
|
ban.BanTime,
|
||||||
ban.ExpirationTime,
|
ban.ExpirationTime,
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ namespace Content.Server.IP
|
|||||||
}
|
}
|
||||||
|
|
||||||
// First parse the address of the netmask before the prefix length.
|
// First parse the address of the netmask before the prefix length.
|
||||||
var maskAddress = System.Net.IPAddress.Parse(subnetMask[..slashIdx]);
|
var maskAddress = System.Net.IPAddress.Parse(subnetMask.Substring(0, slashIdx));
|
||||||
|
|
||||||
if (maskAddress.AddressFamily != address.AddressFamily)
|
if (maskAddress.AddressFamily != address.AddressFamily)
|
||||||
{
|
{
|
||||||
@@ -27,18 +27,8 @@ namespace Content.Server.IP
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Now find out how long the prefix is.
|
// Now find out how long the prefix is.
|
||||||
int maskLength = int.Parse(subnetMask[(slashIdx + 1)..]);
|
int maskLength = int.Parse(subnetMask.Substring(slashIdx + 1));
|
||||||
|
|
||||||
return address.IsInSubnet(maskAddress, maskLength);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static bool IsInSubnet(this System.Net.IPAddress address, (System.Net.IPAddress maskAddress, int maskLength) tuple)
|
|
||||||
{
|
|
||||||
return address.IsInSubnet(tuple.maskAddress, tuple.maskLength);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static bool IsInSubnet(this System.Net.IPAddress address, System.Net.IPAddress maskAddress, int maskLength)
|
|
||||||
{
|
|
||||||
if (maskAddress.AddressFamily == AddressFamily.InterNetwork)
|
if (maskAddress.AddressFamily == AddressFamily.InterNetwork)
|
||||||
{
|
{
|
||||||
// Convert the mask address to an unsigned integer.
|
// Convert the mask address to an unsigned integer.
|
||||||
|
|||||||
Reference in New Issue
Block a user