diff --git a/citadel.dme b/citadel.dme
index e2f32a76e37..9ed6274dc3a 100644
--- a/citadel.dme
+++ b/citadel.dme
@@ -589,6 +589,7 @@
#include "code\controllers\subsystem\assets.dm"
#include "code\controllers\subsystem\atoms.dm"
#include "code\controllers\subsystem\automata.dm"
+#include "code\controllers\subsystem\bans.dm"
#include "code\controllers\subsystem\bellies_vr.dm"
#include "code\controllers\subsystem\chat.dm"
#include "code\controllers\subsystem\chemistry.dm"
@@ -2323,9 +2324,7 @@
#include "code\modules\admin\ToRban.dm"
#include "code\modules\admin\admin_modal\admin_modal.dm"
#include "code\modules\admin\admin_modal\modals\admin_narrate.dm"
-#include "code\modules\admin\ban\ban_system.dm"
-#include "code\modules\admin\ban\role_ban.dm"
-#include "code\modules\admin\ban\server_ban.dm"
+#include "code\modules\admin\ban\ban_system_legacy.dm"
#include "code\modules\admin\callproc\callproc.dm"
#include "code\modules\admin\DB ban\functions.dm"
#include "code\modules\admin\functions\modify_traits.dm"
@@ -2698,6 +2697,8 @@
#include "code\modules\client\viewport.dm"
#include "code\modules\client\wrappers.dm"
#include "code\modules\client\data\client_data.dm"
+#include "code\modules\client\data\player_ban.dm"
+#include "code\modules\client\data\player_ban_cache.dm"
#include "code\modules\client\data\player_data.dm"
#include "code\modules\client\game_preferences\game_preference_entry.dm"
#include "code\modules\client\game_preferences\game_preference_middleware.dm"
diff --git a/code/controllers/subsystem/bans.dm b/code/controllers/subsystem/bans.dm
new file mode 100644
index 00000000000..81565b98fb2
--- /dev/null
+++ b/code/controllers/subsystem/bans.dm
@@ -0,0 +1,62 @@
+//* This file is explicitly licensed under the MIT license. *//
+//* Copyright (c) 2025 Citadel Station Developers *//
+
+/**
+ * Ban management subsystem.
+ * * Can hook PreInit()
+ * * Must function as long as DBCore is connected.
+ */
+SUBSYSTEM_DEF(bans)
+ name = "Bans"
+ subsystem_flags = SS_NO_FIRE | SS_NO_INIT
+
+// Blank for now. This will be the new banning system eventually.
+
+/**
+ * * While the system supports IP/CID jobbans, it is up to the API user to know what is reasonable
+ * to match on. So basically, don't do that.
+ *
+ * @params
+ * * match_player_id - match any ckey on a player id
+ * * match_ckey - match this ckey
+ * * match_ip - match this ip (leave null to not ban by IP)
+ * * match_computer_id - match `computerid`
+ * * duration - duration in minutes; 0 for permanent
+ * * reason - ban reason plaintext
+ * * type_class - BAN_TYPE_* define (internally currently "server", "role", "role_class")
+ * * type_id - ID within context of `type_class`.
+ * "server": nothing
+ * "role": "station-janitor", "station-chief-engineer", "special-changeling", ...
+ * "role_class": "station", "traveller", "antagonist", ...
+ * * admin_ckey - (optional) banning admin's ckey
+ * * admin_ip - (optional) banning admin's ip
+ * * admin_computer_id - (optional) banning admin's computerid
+ */
+// /datum/controller/subsystem/bans/proc/place_ban(match_player_id, match_ckey, match_ip, match_computer_id, duration, reason, type_class, type_id, admin_ckey, admin_ip, admin_computer_id)
+// /datum/controller/subsystem/bans/proc/edit_ban(ban_id, edit_reason, set_reason, set_duration, admin_ckey, admin_ip, admin_computer_id)
+// /datum/controller/subsystem/bans/proc/remove_ban(ban_id, remove_reason, admin_ckey, admin_ip, admin_computer_id)
+// /datum/controller/subsystem/bans/proc/get_ban(ban_id) as /datum/player_ban
+
+/**
+ * Returns any currently active ban for given match criterion
+ * * It is not well defined what ban is returned if there's more than one active.
+ *
+ * @params
+ * * match_player_id - match any ban with given player ID
+ * * match_ckey
+ * * match_ip
+ * * match_computer_id
+ * * type_class - type class to check for
+ * * type_id - type id to check for
+ */
+// /datum/controller/subsystem/bans/proc/match_ban(match_player_id, match_ckey, match_ip, match_computer_id, type, type_id) as /datum/player_ban
+
+/**
+ * Scan bans to get a cache entry for a player.
+ */
+// /datum/controller/subsystem/bans/proc/scan_bans(match_player_id, match_ckey, match_ip, match_computer_id) as /datum/player_ban_cache
+
+/**
+ * Get all bans for a player. Paginated.
+ */
+// /datum/controller/subsystem/bans/proc/query_bans(match_player_id, match_ckey, match_ip, match_computer_id, active_only = FALSE, page = 1, per_page = 20) as /list
diff --git a/code/modules/admin/admin.dm b/code/modules/admin/admin.dm
index a1f03cba832..797b2dc3c44 100644
--- a/code/modules/admin/admin.dm
+++ b/code/modules/admin/admin.dm
@@ -69,7 +69,7 @@ var/global/floorIsLava = 0
Kick |
Ban |
Jobban |
- [is_role_banned_ckey(M.ckey, role = BAN_ROLE_OOC)? "OOC Ban" : "OOC Ban"] |
+ [SSbans.t_is_role_banned_ckey(M.ckey, role = BAN_ROLE_OOC)? "OOC Ban" : "OOC Ban"] |
Notes
"}
diff --git a/code/modules/admin/ban/ban_system.dm b/code/modules/admin/ban/ban_system.dm
deleted file mode 100644
index 63790e2e1dd..00000000000
--- a/code/modules/admin/ban/ban_system.dm
+++ /dev/null
@@ -1,7 +0,0 @@
-
-// placeholder file to replace DB_ban_record and other system stuff
-// we're going to want unified
-// /datum/controller/subsystem/bans/proc/place_ban()
-// /datum/controller/subsystem/bans/proc/lift_ban()
-// /datum/controller/subsystem/bans/proc/edit_ban()
-// /datum/controller/subsystem/bans/proc/query_ban()
diff --git a/code/modules/admin/ban/ban_system_legacy.dm b/code/modules/admin/ban/ban_system_legacy.dm
new file mode 100644
index 00000000000..4e67d39fa4c
--- /dev/null
+++ b/code/modules/admin/ban/ban_system_legacy.dm
@@ -0,0 +1,157 @@
+//* This file is explicitly licensed under the MIT license. *//
+//* Copyright (c) 2025 Citadel Station Developers *//
+
+// ban wrappers until we make new ban system
+
+/datum/controller/subsystem/bans/proc/t_place_system_ban(bantype, mob/banned_mob, duration = -1, reason, job = "", rounds = 0, banckey = null, banip = null, bancid = null)
+ if(!SSdbcore.Connect())
+ return
+
+ var/serverip = "[world.internet_address]:[world.port]"
+ var/bantype_pass = 0
+ var/bantype_str
+ switch(bantype)
+ if(BANTYPE_PERMA)
+ bantype_str = "PERMABAN"
+ duration = -1
+ bantype_pass = 1
+ if(BANTYPE_TEMP)
+ bantype_str = "TEMPBAN"
+ bantype_pass = 1
+ if(BANTYPE_JOB_PERMA)
+ bantype_str = "JOB_PERMABAN"
+ duration = -1
+ bantype_pass = 1
+ if(BANTYPE_JOB_TEMP)
+ bantype_str = "JOB_TEMPBAN"
+ bantype_pass = 1
+ if( !bantype_pass ) return
+ if( !istext(reason) ) return
+ if( !isnum(duration) ) return
+
+ var/ckey
+ var/computerid
+ var/ip
+
+ if(ismob(banned_mob))
+ ckey = banned_mob.ckey
+ if(banned_mob.client)
+ computerid = banned_mob.client.computer_id
+ ip = banned_mob.client.address
+ else if(banckey)
+ ckey = ckey(banckey)
+ computerid = bancid
+ ip = banip
+
+ var/a_ckey
+ var/a_computerid
+ var/a_ip
+
+ var/who
+ for(var/client/C in GLOB.clients)
+ if(!who)
+ who = "[C]"
+ else
+ who += ", [C]"
+
+ var/adminwho
+ for(var/client/C in GLOB.admins)
+ if(!adminwho)
+ adminwho = "[C]"
+ else
+ adminwho += ", [C]"
+
+ reason = sql_sanitize_text(reason)
+
+ if(isnull(computerid))
+ computerid = ""
+ if(isnull(ip))
+ ip = ""
+ var/sql = "INSERT INTO [DB_PREFIX_TABLE_NAME("ban")] \
+ (`id`,`bantime`,`serverip`,`bantype`,`reason`,`job`,`duration`,`rounds`,`expiration_time`,`ckey`,`computerid`,`ip`,`a_ckey`,`a_computerid`,`a_ip`,`who`,`adminwho`,`edits`,`unbanned`,`unbanned_datetime`,`unbanned_ckey`,`unbanned_computerid`,`unbanned_ip`) \
+ VALUES (null, Now(), :serverip, :type, :reason, :job, :duration, :rounds, Now() + INTERVAL :duration MINUTE, :ckey, :cid, :ip, :a_ckey, :a_cid, :a_ip, :who, :adminwho, '', null, null, null, null, null)"
+ SSdbcore.RunQuery(
+ sql,
+ list(
+ "serverip" = serverip,
+ "type" = bantype_str,
+ "reason" = reason,
+ "job" = job,
+ "duration" = duration? duration : 0,
+ "rounds" = rounds? rounds : 0,
+ "ckey" = ckey,
+ "cid" = computerid,
+ "ip" = ip,
+ "a_ckey" = a_ckey,
+ "a_cid" = a_computerid,
+ "a_ip" = a_ip,
+ "who" = who,
+ "adminwho" = adminwho
+ )
+ )
+ to_chat(usr, "Ban saved to database.")
+ message_admins("[key_name_admin(usr)] has added a [bantype_str] for [ckey] [(job)?"([job])":""] [(duration > 0)?"([duration] minutes)":""] with the reason: \"[reason]\" to the ban database.",1)
+ . = TRUE
+
+ // reload
+ jobban_loadbanfile()
+
+
+/**
+ * master proc to check if someone's banned from a role
+ *
+ * @params
+ * * ckey - the player's ckey
+ * * character_id - the character's id, null for global account ban
+ * * role - role enum check [code/__DEFINES/admin/bans.dm]
+ *
+ * @return TRUE / FALSE for if they are banned right now
+ */
+/datum/controller/subsystem/bans/proc/t_is_role_banned_ckey(ckey, character_id, role)
+ // sanitize just in case
+ ckey = ckey(ckey)
+
+ // isolate from proccall, this is sanitized
+ var/mob/old_usr = usr
+ usr = null
+
+ . = FALSE
+
+ switch(role)
+ if(BAN_ROLE_SERVER)
+ . = world.IsBanned(ckey)
+ else
+ // lol this is dumb, refactor jobbans when
+ for(var/str as anything in jobban_keylist)
+ if(findtext(str, "[ckey] - [role]") == 1)
+ . = TRUE
+
+ // restore admin proccall
+ usr = old_usr
+
+/**
+ * ban someone from a role for a certain time, specified in minutes.
+ *
+ * BAN_ROLE_SERVER is not allowed here.
+ *
+ * @params
+ * * ckey - the player's ckey
+ * * character_id - the character's id, null for global account ban
+ * * role - role enum check [code/__DEFINES/admin/bans.dm] - BAN_ROLE_SERVER is not allowed here!
+ * * minutes - minutes from Now() - null for permanent
+ * * reason - why?
+ * * admin - the banning admin, if any
+ *
+ * @return TRUE / FALSE on success / failure
+ */
+/datum/controller/subsystem/bans/proc/t_role_ban_ckey(ckey, character_id, role, minutes, reason, datum/admins/admin)
+ ASSERT(isnull(minutes) || (isnum(minutes) && minutes > 0))
+ // sanitize just in case
+ ckey = ckey(ckey)
+
+ if(IsAdminAdvancedProcCall())
+ return // use the panel!
+
+ //? shitcode alert: for now, db bans *must* be anchored to an admin datum.
+
+ . = admin?.DB_ban_record(isnull(minutes)? BANTYPE_JOB_PERMA : BANTYPE_JOB_TEMP, duration = isnull(minutes)? -1 : minutes, job = role, banckey = ckey, reason = reason) || FALSE
diff --git a/code/modules/admin/ban/role_ban.dm b/code/modules/admin/ban/role_ban.dm
deleted file mode 100644
index 89d9fb1d3ba..00000000000
--- a/code/modules/admin/ban/role_ban.dm
+++ /dev/null
@@ -1,71 +0,0 @@
-//? Role ban system
-//? Rolebans are for taking functionality away from problematic players.
-//? If you want a player off the server / there's no point in keeping then,
-//? use server_ban.dm functions instead.
-
-/**
- * master proc to check if someone's banned from a role
- *
- * @params
- * * ckey - the player's ckey
- * * character_id - the character's id, null for global account ban
- * * role - role enum check [code/__DEFINES/admin/bans.dm]
- *
- * @return TRUE / FALSE for if they are banned right now
- */
-/proc/is_role_banned_ckey(ckey, character_id, role)
- // sanitize just in case
- ckey = ckey(ckey)
-
- // isolate from proccall, this is sanitized
- var/mob/old_usr = usr
- usr = null
-
- . = FALSE
-
- switch(role)
- if(BAN_ROLE_SERVER)
- . = world.IsBanned(ckey)
- else
- // lol this is dumb, refactor jobbans when
- for(var/str as anything in jobban_keylist)
- if(findtext(str, "[ckey] - [role]") == 1)
- . = TRUE
-
- // restore admin proccall
- usr = old_usr
-
-/**
- * ban someone from a role for a certain time, specified in minutes.
- *
- * BAN_ROLE_SERVER is not allowed here.
- *
- * @params
- * * ckey - the player's ckey
- * * character_id - the character's id, null for global account ban
- * * role - role enum check [code/__DEFINES/admin/bans.dm] - BAN_ROLE_SERVER is not allowed here!
- * * minutes - minutes from Now() - null for permanent
- * * reason - why?
- * * admin - the banning admin, if any
- *
- * @return TRUE / FALSE on success / failure
- */
-/proc/role_ban_ckey(ckey, character_id, role, minutes, reason, datum/admins/admin)
- ASSERT(isnull(minutes) || (isnum(minutes) && minutes > 0))
- // sanitize just in case
- ckey = ckey(ckey)
-
- if(IsAdminAdvancedProcCall())
- return // use the panel!
-
- //? shitcode alert: for now, db bans *must* be anchored to an admin datum.
-
- . = admin?.DB_ban_record(isnull(minutes)? BANTYPE_JOB_PERMA : BANTYPE_JOB_TEMP, duration = isnull(minutes)? -1 : minutes, job = role, banckey = ckey, reason = reason) || FALSE
-
-// todo: query_role_banned_ckey(ckey)
-// todo: why_role_banned_ckey(ckey)
-// todo: time_role_banned_ckey(ckey)
-
-// todo: for the above, we should probably make the overhead not miserable by caching it in /datum/player_data.
-
-// todo: x_role_y_player(...) for player ids instead
diff --git a/code/modules/admin/ban/server_ban.dm b/code/modules/admin/ban/server_ban.dm
deleted file mode 100644
index 7a0c4237a41..00000000000
--- a/code/modules/admin/ban/server_ban.dm
+++ /dev/null
@@ -1,5 +0,0 @@
-
-// this file is a placeholder - we'll need it on ban rework
-// server bans are the complement to role bans
-// rolebans take functions away from a problematic player
-// server bans keep a player off the server
diff --git a/code/modules/admin/topic.dm b/code/modules/admin/topic.dm
index 3cb00cf2ef1..1a57d3f9c57 100644
--- a/code/modules/admin/topic.dm
+++ b/code/modules/admin/topic.dm
@@ -845,7 +845,7 @@
// we'll add the function later when we overhaul banning
return
- if(is_role_banned_ckey(target_ckey, role = BAN_ROLE_OOC))
+ if(SSbans.t_is_role_banned_ckey(target_ckey, role = BAN_ROLE_OOC))
to_chat(usr, SPAN_WARNING("[target_ckey] is already OOC banned. Use Unban-Panel to unban them."))
return
@@ -857,7 +857,7 @@
var/reason = sanitize(input(usr, "Reason?", "OOC Ban") as text|null)
if(!reason)
return
- role_ban_ckey(target_ckey, role = BAN_ROLE_OOC, minutes = minutes, reason = reason, admin = src)
+ SSbans.t_role_ban_ckey(target_ckey, role = BAN_ROLE_OOC, minutes = minutes, reason = reason, admin = src)
// incase they switched mobs
var/client/target_client = GLOB.directory[target_ckey]
notes_add(target_ckey, "[usr.ckey] has banned has banned [target_ckey] from OOC. Reason: [reason]. This will be removed in [minutes] minutes.")
@@ -869,7 +869,7 @@
var/reason = sanitize(input(usr, "Reason?", "OOC Ban") as text|null)
if(!reason)
return
- role_ban_ckey(target_ckey, role = BAN_ROLE_OOC, reason = reason, admin = src)
+ SSbans.t_role_ban_ckey(target_ckey, role = BAN_ROLE_OOC, reason = reason, admin = src)
// incase they switched mobs
var/client/target_client = GLOB.directory[target_ckey]
notes_add(target_ckey, "[usr.ckey] has banned has banned [target_ckey] from OOC. Reason: [reason].")
diff --git a/code/modules/client/data/player_ban.dm b/code/modules/client/data/player_ban.dm
new file mode 100644
index 00000000000..c1b086dd993
--- /dev/null
+++ b/code/modules/client/data/player_ban.dm
@@ -0,0 +1,14 @@
+//* This file is explicitly licensed under the MIT license. *//
+//* Copyright (c) 2025 Citadel Station Developers *//
+
+/datum/player_ban
+ var/ban_id
+ var/match_player_id
+ var/match_ckey
+ var/match_ip
+ var/match_computer_id
+ var/type_class
+ var/type_id
+ /// plaintext generated by subsystem
+ /// * not always filled out; if looking up metadata
+ var/list/edit_log
diff --git a/code/modules/client/data/player_ban_cache.dm b/code/modules/client/data/player_ban_cache.dm
new file mode 100644
index 00000000000..fdb282c21c7
--- /dev/null
+++ b/code/modules/client/data/player_ban_cache.dm
@@ -0,0 +1,15 @@
+//* This file is explicitly licensed under the MIT license. *//
+//* Copyright (c) 2025 Citadel Station Developers *//
+
+/**
+ * Contains all active bans for a player for quick lookup.
+ * * Will only contain one ban per relevant thing; e.g. if someone's somehow triple-jobbanned
+ * from captain, it'll only have one.
+ */
+/datum/player_ban_cache
+ var/total_bans
+ var/datum/player_ban/is_server_banned
+ /// id = entry
+ var/alist/datum/player_ban/currently_banned_role_ids
+ /// class = entry
+ var/alist/datum/player_ban/currently_banned_role_classes
diff --git a/code/modules/client/security.dm b/code/modules/client/security.dm
index c540a229c92..7e9f92e99ea 100644
--- a/code/modules/client/security.dm
+++ b/code/modules/client/security.dm
@@ -20,11 +20,19 @@
* tell_user is implicit
*/
/client/proc/security_ban(message, time = -1)
- var/time_displayed = time == -1? "" : "for [DisplayTimeText(time MINUTES)]"
+ var/time_displayed = time == -1? "(forever)" : "for [DisplayTimeText(time MINUTES)]"
log_access("client security: banning [key_name(src)] [time == -1? "" : "for [time_displayed]"] | [message]")
message_admins("client security: banning [key_name(src)] [time == -1? "" : "for [time_displayed]"] | [message]")
add_system_note("client-security", "banned for [time_displayed]: [message]")
- AddBan(ckey, computer_id, "client-security: [message]", minutes = time)
+ SSbans.t_place_system_ban(
+ time == -1 ? BANTYPE_PERMA : BANTYPE_TEMP,
+ null,
+ time,
+ "client-security: [message]",
+ banckey = ckey,
+ banip = address,
+ bancid = computer_id,
+ )
disconnection_message("Client Security - Autoban: [message]")
qdel(src)
diff --git a/code/modules/client/verbs/ooc.dm b/code/modules/client/verbs/ooc.dm
index 4bb061d0d29..1bbcc3f1576 100644
--- a/code/modules/client/verbs/ooc.dm
+++ b/code/modules/client/verbs/ooc.dm
@@ -74,7 +74,7 @@
to_chat(src, "You have OOC muted.")
return
- if(is_role_banned_ckey(ckey, role = BAN_ROLE_OOC))
+ if(SSbans.t_is_role_banned_ckey(ckey, role = BAN_ROLE_OOC))
to_chat(src, SPAN_WARNING("You are banned from OOC and deadchat."))
return
@@ -181,7 +181,7 @@
to_chat(src, "Guests may not use OOC.")
return
- if(is_role_banned_ckey(ckey, role = BAN_ROLE_OOC) && IS_DEAD(mob))
+ if(SSbans.t_is_role_banned_ckey(ckey, role = BAN_ROLE_OOC) && IS_DEAD(mob))
to_chat(src, SPAN_WARNING("You are banned from typing in LOOC while dead, and deadchat."))
return
diff --git a/code/modules/mob/emote.dm b/code/modules/mob/emote.dm
index d5a7da211cf..52e67a4f28a 100644
--- a/code/modules/mob/emote.dm
+++ b/code/modules/mob/emote.dm
@@ -79,7 +79,7 @@
to_chat(src, "Deadchat is globally muted.")
return
- if(is_role_banned_ckey(ckey, role = BAN_ROLE_OOC))
+ if(SSbans.t_is_role_banned_ckey(ckey, role = BAN_ROLE_OOC))
to_chat(src, SPAN_WARNING("You are banned from OOC and deadchat."))
return
diff --git a/code/modules/mob/say.dm b/code/modules/mob/say.dm
index a34ccf27cc0..d2a8449b8bf 100644
--- a/code/modules/mob/say.dm
+++ b/code/modules/mob/say.dm
@@ -57,7 +57,7 @@
to_chat(src, "You have deadchat muted.")
return
- if(is_role_banned_ckey(ckey, role = BAN_ROLE_OOC))
+ if(SSbans.t_is_role_banned_ckey(ckey, role = BAN_ROLE_OOC))
to_chat(src, SPAN_WARNING("You are banned from OOC and deadchat."))
return