Files
space-station-14/Content.Server.Database/Migrations/Postgres/20260120200503_BanRefactor.cs
Pieter-Jan Briers 26d4d7d337 Fix PostgreSQL migration for ban DB refactor (#42609)
This didn't come up in testing. The ban hit foreign key was added before the data was migrated, so it didn't work.

Fix that.
2026-01-23 19:51:24 +01:00

536 lines
23 KiB
C#

using System;
using Content.Shared.Database;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using NpgsqlTypes;
#nullable disable
namespace Content.Server.Database.Migrations.Postgres
{
/// <inheritdoc />
public partial class BanRefactor : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "ban",
columns: table => new
{
ban_id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
type = table.Column<byte>(type: "smallint", nullable: false),
playtime_at_note = table.Column<TimeSpan>(type: "interval", nullable: false),
ban_time = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
expiration_time = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
reason = table.Column<string>(type: "text", nullable: false),
severity = table.Column<int>(type: "integer", nullable: false),
banning_admin = table.Column<Guid>(type: "uuid", nullable: true),
last_edited_by_id = table.Column<Guid>(type: "uuid", nullable: true),
last_edited_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
exempt_flags = table.Column<int>(type: "integer", nullable: false),
auto_delete = table.Column<bool>(type: "boolean", nullable: false),
hidden = table.Column<bool>(type: "boolean", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ban", x => x.ban_id);
table.CheckConstraint("NoExemptOnRoleBan", "type = 0 OR exempt_flags = 0");
table.ForeignKey(
name: "FK_ban_player_banning_admin",
column: x => x.banning_admin,
principalTable: "player",
principalColumn: "user_id",
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "FK_ban_player_last_edited_by_id",
column: x => x.last_edited_by_id,
principalTable: "player",
principalColumn: "user_id",
onDelete: ReferentialAction.SetNull);
});
migrationBuilder.CreateTable(
name: "ban_address",
columns: table => new
{
ban_address_id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
address = table.Column<NpgsqlInet>(type: "inet", nullable: false),
ban_id = table.Column<int>(type: "integer", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ban_address", x => x.ban_address_id);
table.CheckConstraint("AddressNotIPv6MappedIPv4", "NOT inet '::ffff:0.0.0.0/96' >>= address");
table.ForeignKey(
name: "FK_ban_address_ban_ban_id",
column: x => x.ban_id,
principalTable: "ban",
principalColumn: "ban_id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "ban_hwid",
columns: table => new
{
ban_hwid_id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
hwid = table.Column<byte[]>(type: "bytea", nullable: false),
hwid_type = table.Column<int>(type: "integer", nullable: false),
ban_id = table.Column<int>(type: "integer", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ban_hwid", x => x.ban_hwid_id);
table.ForeignKey(
name: "FK_ban_hwid_ban_ban_id",
column: x => x.ban_id,
principalTable: "ban",
principalColumn: "ban_id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "ban_player",
columns: table => new
{
ban_player_id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
user_id = table.Column<Guid>(type: "uuid", nullable: false),
ban_id = table.Column<int>(type: "integer", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ban_player", x => x.ban_player_id);
table.ForeignKey(
name: "FK_ban_player_ban_ban_id",
column: x => x.ban_id,
principalTable: "ban",
principalColumn: "ban_id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "ban_role",
columns: table => new
{
ban_role_id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
role_type = table.Column<string>(type: "text", nullable: false),
role_id = table.Column<string>(type: "text", nullable: false),
ban_id = table.Column<int>(type: "integer", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ban_role", x => x.ban_role_id);
table.ForeignKey(
name: "FK_ban_role_ban_ban_id",
column: x => x.ban_id,
principalTable: "ban",
principalColumn: "ban_id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "ban_round",
columns: table => new
{
ban_round_id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
ban_id = table.Column<int>(type: "integer", nullable: false),
round_id = table.Column<int>(type: "integer", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ban_round", x => x.ban_round_id);
table.ForeignKey(
name: "FK_ban_round_ban_ban_id",
column: x => x.ban_id,
principalTable: "ban",
principalColumn: "ban_id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_ban_round_round_round_id",
column: x => x.round_id,
principalTable: "round",
principalColumn: "round_id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "unban",
columns: table => new
{
unban_id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
ban_id = table.Column<int>(type: "integer", nullable: false),
unbanning_admin = table.Column<Guid>(type: "uuid", nullable: true),
unban_time = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_unban", x => x.unban_id);
table.ForeignKey(
name: "FK_unban_ban_ban_id",
column: x => x.ban_id,
principalTable: "ban",
principalColumn: "ban_id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_ban_banning_admin",
table: "ban",
column: "banning_admin");
migrationBuilder.CreateIndex(
name: "IX_ban_last_edited_by_id",
table: "ban",
column: "last_edited_by_id");
migrationBuilder.CreateIndex(
name: "IX_ban_address_ban_id",
table: "ban_address",
column: "ban_id");
migrationBuilder.CreateIndex(
name: "IX_ban_hwid_ban_id",
table: "ban_hwid",
column: "ban_id");
migrationBuilder.CreateIndex(
name: "IX_ban_player_ban_id",
table: "ban_player",
column: "ban_id");
migrationBuilder.CreateIndex(
name: "IX_ban_player_user_id_ban_id",
table: "ban_player",
columns: new[] { "user_id", "ban_id" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_ban_role_ban_id",
table: "ban_role",
column: "ban_id");
migrationBuilder.CreateIndex(
name: "IX_ban_role_role_type_role_id_ban_id",
table: "ban_role",
columns: new[] { "role_type", "role_id", "ban_id" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_ban_round_ban_id",
table: "ban_round",
column: "ban_id");
migrationBuilder.CreateIndex(
name: "IX_ban_round_round_id_ban_id",
table: "ban_round",
columns: new[] { "round_id", "ban_id" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_unban_ban_id",
table: "unban",
column: "ban_id",
unique: true);
migrationBuilder.Sql("""
CREATE INDEX "IX_ban_address_address"
ON ban_address
USING gist
(address inet_ops)
INCLUDE (ban_id);
CREATE UNIQUE INDEX "IX_ban_hwid_hwid_ban_id"
ON ban_hwid
(hwid_type, hwid, ban_id);
CREATE UNIQUE INDEX "IX_ban_address_address_ban_id"
ON ban_address
(address, ban_id);
""");
migrationBuilder.Sql($"""
-- REMOVE:
-- TRUNCATE ban RESTART IDENTITY CASCADE;
--
-- Insert game bans
--
INSERT INTO
ban (ban_id, type, playtime_at_note, ban_time, expiration_time, reason, severity, banning_admin, last_edited_by_id, last_edited_at, exempt_flags, auto_delete, hidden)
SELECT
server_ban_id, {(int)BanType.Server}, playtime_at_note, ban_time, expiration_time, reason, severity, banning_admin, last_edited_by_id, last_edited_at, exempt_flags, auto_delete, hidden
FROM
server_ban;
-- Update ID sequence to be after newly inserted IDs.
SELECT setval('ban_ban_id_seq', (SELECT MAX(ban_id) FROM ban));
-- Insert ban player records.
INSERT INTO
ban_player (user_id, ban_id)
SELECT
player_user_id, server_ban_id
FROM
server_ban
WHERE
player_user_id IS NOT NULL;
-- Insert ban address records.
INSERT INTO
ban_address (address, ban_id)
SELECT
address, server_ban_id
FROM
server_ban
WHERE
address IS NOT NULL;
-- Insert ban HWID records.
INSERT INTO
ban_hwid (hwid, hwid_type, ban_id)
SELECT
hwid, hwid_type, server_ban_id
FROM
server_ban
WHERE
hwid IS NOT NULL;
-- Insert ban unban records.
INSERT INTO
unban (ban_id, unbanning_admin, unban_time)
SELECT
ban_id, unbanning_admin, unban_time
FROM server_unban;
-- Insert ban round records.
INSERT INTO
ban_round (round_id, ban_id)
SELECT
round_id, server_ban_id
FROM
server_ban
WHERE
round_id IS NOT NULL;
--
-- Insert role bans
-- This shit is a pain in the ass
-- > Declarative language
-- > Has to write procedural code in it
--
-- Create mapping table from role ban -> server ban.
-- We have to manually calculate the new ban IDs by using the sequence.
-- We also want to merge role ban records because the game code previously did that in some UI,
-- and that code is now gone, expecting the DB to do it.
-- Create a table to store IDs to merge.
CREATE TEMPORARY TABLE /*IF NOT EXISTS*/ _role_ban_import_merge_map (merge_id INTEGER, server_role_ban_id INTEGER UNIQUE) ON COMMIT DROP;
-- TRUNCATE _role_ban_import_merge_map;
-- Create a table to store merged IDs -> new ban IDs
CREATE TEMPORARY TABLE /*IF NOT EXISTS*/ _role_ban_import_id_map (ban_id INTEGER UNIQUE, merge_id INTEGER UNIQUE) ON COMMIT DROP;
-- TRUNCATE _role_ban_import_id_map;
-- Calculate merged role bans.
INSERT INTO
_role_ban_import_merge_map
SELECT
(
SELECT
sub.server_role_ban_id
FROM
server_role_ban AS sub
LEFT JOIN server_role_unban AS sub_unban
ON sub_unban.ban_id = sub.server_role_ban_id
WHERE
main.reason IS NOT DISTINCT FROM sub.reason
AND main.player_user_id IS NOT DISTINCT FROM sub.player_user_id
AND main.address IS NOT DISTINCT FROM sub.address
AND main.hwid IS NOT DISTINCT FROM sub.hwid
AND main.hwid_type IS NOT DISTINCT FROM sub.hwid_type
AND date_trunc('second', main.ban_time, 'utc') = date_trunc('second', sub.ban_time, 'utc')
AND (
(main.expiration_time IS NULL) = (sub.expiration_time IS NULL)
OR date_trunc('minute', main.expiration_time, 'utc') = date_trunc('minute', sub.expiration_time, 'utc')
)
AND main.round_id IS NOT DISTINCT FROM sub.round_id
AND main.severity IS NOT DISTINCT FROM sub.severity
AND main.hidden IS NOT DISTINCT FROM sub.hidden
AND main.banning_admin IS NOT DISTINCT FROM sub.banning_admin
AND (sub_unban.ban_id IS NULL) = (main_unban.ban_id IS NULL)
ORDER BY
sub.server_role_ban_id ASC
LIMIT 1
), main.server_role_ban_id
FROM
server_role_ban AS main
LEFT JOIN server_role_unban AS main_unban
ON main_unban.ban_id = main.server_role_ban_id;
-- Assign new ban IDs for merged IDs.
INSERT INTO
_role_ban_import_id_map
SELECT
DISTINCT ON (merge_id)
nextval('ban_ban_id_seq'),
merge_id
FROM
_role_ban_import_merge_map;
-- I sure fucking wish CTEs could span multiple queries...
-- Insert new ban records
INSERT INTO
ban (ban_id, type, playtime_at_note, ban_time, expiration_time, reason, severity, banning_admin, last_edited_by_id, last_edited_at, exempt_flags, auto_delete, hidden)
SELECT
im.ban_id, {(int)BanType.Role}, playtime_at_note, ban_time, expiration_time, reason, severity, banning_admin, last_edited_by_id, last_edited_at, 0, FALSE, hidden
FROM
_role_ban_import_id_map im
INNER JOIN _role_ban_import_merge_map mm
ON im.merge_id = mm.merge_id
INNER JOIN server_role_ban srb
ON srb.server_role_ban_id = im.merge_id
WHERE mm.merge_id = mm.server_role_ban_id;
-- Insert role ban player records.
INSERT INTO
ban_player (user_id, ban_id)
SELECT
player_user_id, im.ban_id
FROM
_role_ban_import_id_map im
INNER JOIN _role_ban_import_merge_map mm
ON im.merge_id = mm.merge_id
INNER JOIN server_role_ban srb
ON srb.server_role_ban_id = im.merge_id
WHERE mm.merge_id = mm.server_role_ban_id
AND player_user_id IS NOT NULL;
-- Insert role ban address records.
INSERT INTO
ban_address (address, ban_id)
SELECT
address, im.ban_id
FROM
_role_ban_import_id_map im
INNER JOIN _role_ban_import_merge_map mm
ON im.merge_id = mm.merge_id
INNER JOIN server_role_ban srb
ON srb.server_role_ban_id = im.merge_id
WHERE mm.merge_id = mm.server_role_ban_id
AND address IS NOT NULL;
-- Insert role ban HWID records.
INSERT INTO
ban_hwid (hwid, hwid_type, ban_id)
SELECT
hwid, hwid_type, im.ban_id
FROM
_role_ban_import_id_map im
INNER JOIN _role_ban_import_merge_map mm
ON im.merge_id = mm.merge_id
INNER JOIN server_role_ban srb
ON srb.server_role_ban_id = im.merge_id
WHERE mm.merge_id = mm.server_role_ban_id
AND hwid IS NOT NULL;
-- Insert role ban role records.
INSERT INTO
ban_role (role_type, role_id, ban_id)
SELECT
split_part(role_id, ':', 1), split_part(role_id, ':', 2), im.ban_id
FROM
_role_ban_import_id_map im
INNER JOIN _role_ban_import_merge_map mm
ON im.merge_id = mm.merge_id
INNER JOIN server_role_ban srb
ON srb.server_role_ban_id = mm.server_role_ban_id
-- Yes, we have some messy ban records which, after merging, end up with duplicate roles.
ON CONFLICT DO NOTHING;
-- Insert role unban records.
INSERT INTO
unban (ban_id, unbanning_admin, unban_time)
SELECT
im.ban_id, unbanning_admin, unban_time
FROM server_role_unban sru
INNER JOIN _role_ban_import_id_map im
ON im.merge_id = sru.ban_id;
-- Insert role rounds
INSERT INTO
ban_round (round_id, ban_id)
SELECT
round_id, im.ban_id
FROM
_role_ban_import_id_map im
INNER JOIN _role_ban_import_merge_map mm
ON im.merge_id = mm.merge_id
INNER JOIN server_role_ban srb
ON srb.server_role_ban_id = im.merge_id
WHERE mm.merge_id = mm.server_role_ban_id
AND round_id IS NOT NULL;
""");
migrationBuilder.AddForeignKey(
name: "FK_server_ban_hit_ban_ban_id",
table: "server_ban_hit",
column: "ban_id",
principalTable: "ban",
principalColumn: "ban_id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.DropForeignKey(
name: "FK_server_ban_hit_server_ban_ban_id",
table: "server_ban_hit");
migrationBuilder.DropTable(
name: "server_role_unban");
migrationBuilder.DropTable(
name: "server_unban");
migrationBuilder.DropTable(
name: "server_role_ban");
migrationBuilder.DropTable(
name: "server_ban");
migrationBuilder.Sql($"""
CREATE OR REPLACE FUNCTION send_server_ban_notification()
RETURNS trigger AS $$
BEGIN
PERFORM pg_notify(
'ban_notification',
json_build_object('ban_id', NEW.ban_id)::text
);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER notify_on_server_ban_insert
AFTER INSERT ON ban
FOR EACH ROW
WHEN (NEW.type = {(int)BanType.Server})
EXECUTE FUNCTION send_server_ban_notification();
""");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
throw new NotSupportedException("This migration cannot be rolled back");
}
}
}