diff --git a/openapi/components/schemas/api-key-scope.yaml b/openapi/components/schemas/api-key-scope.yaml index 30ef895..3aa22ea 100644 --- a/openapi/components/schemas/api-key-scope.yaml +++ b/openapi/components/schemas/api-key-scope.yaml @@ -18,5 +18,6 @@ enum: - players:read-details - players:write - punishments:read + - punishments:seen - punishments:write - stream:read diff --git a/openapi/components/schemas/seen-player-summary.yaml b/openapi/components/schemas/seen-player-summary.yaml new file mode 100644 index 0000000..4b13973 --- /dev/null +++ b/openapi/components/schemas/seen-player-summary.yaml @@ -0,0 +1,25 @@ +type: object +title: SeenPlayerSummary +description: The current activity summary for a player in SpicyAzisaBan's database. +required: + - id + - username + - ip + - lastSeenAt +properties: + id: + type: string + format: uuid + description: The player's UUID. + username: + type: string + description: The player's most recently recorded name. + ip: + type: + - string + - "null" + description: The player's most recently recorded IP address, or `null` if it is unknown. + lastSeenAt: + type: string + format: date-time + description: The time at which the player was last seen. diff --git a/openapi/components/schemas/seen-player.yaml b/openapi/components/schemas/seen-player.yaml new file mode 100644 index 0000000..8dbec7d --- /dev/null +++ b/openapi/components/schemas/seen-player.yaml @@ -0,0 +1,71 @@ +type: object +title: SeenPlayer +description: The activity information recorded for a player in SpicyAzisaBan's database. +required: + - id + - username + - ip + - lastSeenAt + - firstLoginAt + - firstLoginAttemptAt + - lastLoginAt + - lastLoginAttemptAt + - usernameHistory + - ipHistory + - sameIpPlayers +properties: + id: + type: string + format: uuid + description: The player's UUID. + username: + type: string + description: The player's most recently recorded name. + ip: + type: + - string + - "null" + description: The player's most recently recorded IP address, or `null` if it is unknown. + lastSeenAt: + type: string + format: date-time + description: The time at which the player was last seen. + firstLoginAt: + type: + - string + - "null" + format: date-time + description: The time at which the player first logged in, or `null` if unavailable. + firstLoginAttemptAt: + type: + - string + - "null" + format: date-time + description: The first recorded login attempt, or `null` if unavailable. + lastLoginAt: + type: + - string + - "null" + format: date-time + description: The last successful login, or `null` if unavailable. + lastLoginAttemptAt: + type: + - string + - "null" + format: date-time + description: The last recorded login attempt, or `null` if unavailable. + usernameHistory: + type: array + description: Recorded player names in most-recent-first order, without duplicates. + items: + type: string + ipHistory: + type: array + description: Recorded IP addresses in most-recent-first order, without duplicates. + items: + type: string + sameIpPlayers: + type: array + description: Other players that have used the player's current IP address. + items: + $ref: "../../openapi.yaml#/components/schemas/SeenPlayerSummary" diff --git a/openapi/components/schemas/seen-result.yaml b/openapi/components/schemas/seen-result.yaml new file mode 100644 index 0000000..a883f81 --- /dev/null +++ b/openapi/components/schemas/seen-result.yaml @@ -0,0 +1,26 @@ +title: SeenResult +description: The result of a SpicyAzisaBan activity lookup. +type: object +required: + - kind + - players +properties: + kind: + type: string + enum: + - player + - ip + description: Whether the target was resolved as a player or IP address. + player: + $ref: "../../openapi.yaml#/components/schemas/SeenPlayer" + description: Present when `kind` is `player`. + ip: + type: string + description: Present when `kind` is `ip`. + players: + type: array + description: > + Players that have used the target IP address. For player results, this + excludes the player in `player`. + items: + $ref: "../../openapi.yaml#/components/schemas/SeenPlayerSummary" diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml index 42e61ac..cd01e1c 100644 --- a/openapi/openapi.yaml +++ b/openapi/openapi.yaml @@ -59,6 +59,8 @@ paths: $ref: "./paths/players/players-by-id-friends-by-id.yaml" /punishments: $ref: "./paths/punishments/punishments.yaml" + /punishments/seen: + $ref: "./paths/punishments/punishments-seen-by-target.yaml" /punishments/{punishmentId}: $ref: "./paths/punishments/punishments-by-id.yaml" /punishments/{punishmentId}/proofs: @@ -166,6 +168,12 @@ components: $ref: "./components/schemas/punishment-updated-event.yaml" Revocation: $ref: "./components/schemas/revocation.yaml" + SeenPlayer: + $ref: "./components/schemas/seen-player.yaml" + SeenPlayerSummary: + $ref: "./components/schemas/seen-player-summary.yaml" + SeenResult: + $ref: "./components/schemas/seen-result.yaml" StreamEvent: $ref: "./components/schemas/stream-event.yaml" discriminator: diff --git a/openapi/paths/punishments/punishments-seen-by-target.yaml b/openapi/paths/punishments/punishments-seen-by-target.yaml new file mode 100644 index 0000000..ab88248 --- /dev/null +++ b/openapi/paths/punishments/punishments-seen-by-target.yaml @@ -0,0 +1,48 @@ +get: + operationId: getPunishmentSeen + summary: Look up player activity + description: > + Returns the activity information shown by SpicyAzisaBan's `/seen` command. + The target may be a player UUID, an exact player name, or an IP address. + An IP-address lookup returns every player that has used that address; a + player lookup includes accounts that have used that player's current IP + address. + tags: + - punishments + security: + - apiKeyAuth: + - punishments:seen + parameters: + - name: target + in: query + required: true + description: The player UUID, player name, or IP address to look up. + schema: + type: string + minLength: 1 + maxLength: 255 + - name: ambiguous + in: query + description: Whether to match the target against any part of a player name. + schema: + type: boolean + default: false + - name: includeDummy + in: query + description: Whether to include placeholder records without any login timestamps. + schema: + type: boolean + default: false + responses: + "200": + description: The activity information was retrieved successfully. + content: + application/json: + schema: + $ref: "../../openapi.yaml#/components/schemas/SeenResult" + "401": + $ref: "../../openapi.yaml#/components/responses/Unauthorized" + "403": + $ref: "../../openapi.yaml#/components/responses/Forbidden" + "404": + description: No matching player or IP-address history was found. diff --git a/server/app/src/api/punishments.rs b/server/app/src/api/punishments.rs index 982ee2c..1986e7b 100644 --- a/server/app/src/api/punishments.rs +++ b/server/app/src/api/punishments.rs @@ -17,7 +17,10 @@ use headers::Host; use http::Method; use sha2::{Digest, Sha256}; use sqlx::{Connection, MySql, MySqlConnection, QueryBuilder}; -use std::{collections::BTreeMap, net::IpAddr}; +use std::{ + collections::{BTreeMap, HashSet}, + net::IpAddr, +}; use uuid::Uuid; const DEFAULT_LIMIT: u8 = 20; @@ -30,6 +33,10 @@ fn can_read(key: &ApiKey) -> bool { key.has_scope(&ApiKeyScope::PunishmentsColonRead) } +fn can_read_seen(key: &ApiKey) -> bool { + key.has_scope(&ApiKeyScope::PunishmentsColonSeen) +} + fn write_actor(key: &ApiKey) -> Option { if !key.has_scope(&ApiKeyScope::PunishmentsColonWrite) { return None; @@ -81,6 +88,66 @@ fn normalize_target(kind: PunishmentType, target: &str) -> Option { } } +#[derive(Debug, sqlx::FromRow)] +struct SeenPlayerRecord { + uuid: String, + name: String, + ip: Option, + last_seen: i64, + first_login: i64, + first_login_attempt: i64, + last_login: i64, + last_login_attempt: i64, +} + +impl SeenPlayerRecord { + fn id(&self) -> Result { + Uuid::parse_str(&self.uuid).map_err(|_| { + format!( + "invalid player UUID in SpicyAzisaBan database: {}", + self.uuid + ) + }) + } + + fn into_summary(self) -> Result { + Ok(SeenPlayerSummary::new( + self.id()?, + self.name, + self.ip.map_or(Nullable::Null, Nullable::Present), + datetime_from_millis(self.last_seen)?, + )) + } + + fn is_dummy(&self) -> bool { + self.first_login == 0 + && self.first_login_attempt == 0 + && self.last_login == 0 + && self.last_login_attempt == 0 + } +} + +fn datetime_from_millis(value: i64) -> Result, String> { + DateTime::from_timestamp_millis(value) + .ok_or_else(|| "invalid epoch milliseconds in SpicyAzisaBan database".to_string()) +} + +fn nullable_datetime_from_millis(value: i64) -> Result>, String> { + if value == 0 { + Ok(Nullable::Null) + } else { + datetime_from_millis(value).map(Nullable::Present) + } +} + +fn distinct_strings(values: Vec) -> Vec { + let mut seen = HashSet::new(); + values + .into_iter() + .filter(|value| seen.insert(value.clone())) + .collect() +} + fn end_millis( kind: PunishmentType, expires_at: &Nullable>, @@ -145,6 +212,98 @@ fn punishment_type_database_value(kind: PunishmentType) -> &'static str { } impl Api { + async fn load_seen_player_by_uuid(&self, id: &str) -> Result, String> { + sqlx::query_as::<_, SeenPlayerRecord>( + "SELECT uuid, name, ip, last_seen, first_login, first_login_attempt, last_login, last_login_attempt FROM players WHERE uuid = ? LIMIT 1", + ) + .bind(id) + .fetch_optional(self.punishments_pool()) + .await + .map_err(db_error) + } + + async fn load_seen_player_by_name( + &self, + name: &str, + ambiguous: bool, + ) -> Result, String> { + let target = ambiguous.then(|| format!("%{name}%")); + sqlx::query_as::<_, SeenPlayerRecord>( + "SELECT uuid, name, ip, last_seen, first_login, first_login_attempt, last_login, last_login_attempt FROM players WHERE LOWER(name) LIKE LOWER(?) ORDER BY last_seen DESC LIMIT 1", + ) + .bind(target.as_deref().unwrap_or(name)) + .fetch_optional(self.punishments_pool()) + .await + .map_err(db_error) + } + + async fn load_seen_players_by_ip(&self, ip: &str) -> Result, String> { + let records = sqlx::query_as::<_, SeenPlayerRecord>( + "SELECT p.uuid, p.name, p.ip, p.last_seen, p.first_login, p.first_login_attempt, p.last_login, p.last_login_attempt FROM ipAddressHistory h INNER JOIN players p ON p.uuid = h.uuid WHERE h.ip = ? GROUP BY p.uuid, p.name, p.ip, p.last_seen, p.first_login, p.first_login_attempt, p.last_login, p.last_login_attempt ORDER BY MAX(h.last_seen) DESC", + ) + .bind(ip) + .fetch_all(self.punishments_pool()) + .await + .map_err(db_error)?; + records + .into_iter() + .map(SeenPlayerRecord::into_summary) + .collect() + } + + async fn load_seen_username_history(&self, id: &str) -> Result, String> { + sqlx::query_scalar::<_, String>( + "SELECT name FROM `usernameHistory` WHERE uuid = ? ORDER BY last_seen DESC", + ) + .bind(id) + .fetch_all(self.punishments_pool()) + .await + .map(distinct_strings) + .map_err(db_error) + } + + async fn load_seen_ip_history(&self, id: &str) -> Result, String> { + sqlx::query_scalar::<_, String>( + "SELECT ip FROM `ipAddressHistory` WHERE uuid = ? ORDER BY last_seen DESC", + ) + .bind(id) + .fetch_all(self.punishments_pool()) + .await + .map(distinct_strings) + .map_err(db_error) + } + + async fn into_seen_player(&self, record: SeenPlayerRecord) -> Result { + let id = record.id()?; + let (username_history, ip_history, same_ip_players) = tokio::try_join!( + self.load_seen_username_history(&record.uuid), + self.load_seen_ip_history(&record.uuid), + async { + match record.ip.as_deref() { + Some(ip) => self.load_seen_players_by_ip(ip).await, + None => Ok(Vec::new()), + } + }, + )?; + let same_ip_players = same_ip_players + .into_iter() + .filter(|player| player.id != id) + .collect(); + Ok(SeenPlayer::new( + id, + record.name, + record.ip.map_or(Nullable::Null, Nullable::Present), + datetime_from_millis(record.last_seen)?, + nullable_datetime_from_millis(record.first_login)?, + nullable_datetime_from_millis(record.first_login_attempt)?, + nullable_datetime_from_millis(record.last_login)?, + nullable_datetime_from_millis(record.last_login_attempt)?, + username_history, + ip_history, + same_ip_players, + )) + } + async fn load_proofs(&self, punishment_id: i64) -> Result, String> { sqlx::query_as::<_, ProofRecord>( "SELECT id, text, public FROM proofs WHERE punish_id = ? ORDER BY id", @@ -505,6 +664,57 @@ impl Punishments for Api { } } + async fn get_punishment_seen( + &self, + _: &Method, + _: &Host, + _: &CookieJar, + key: &ApiKey, + query: &GetPunishmentSeenQueryParams, + ) -> Result { + if !can_read_seen(key) { + return Ok( + GetPunishmentSeenResponse::Status403_TheAuthenticatedAPIKeyLacksTheRequiredScope, + ); + } + + if query.target.parse::().is_ok() { + let players = self.load_seen_players_by_ip(&query.target).await?; + if players.is_empty() { + return Ok(GetPunishmentSeenResponse::Status404_NoMatchingPlayerOrIP); + } + let mut result = SeenResult::new("ip".to_string(), players); + result.ip = Some(query.target.clone()); + return Ok( + GetPunishmentSeenResponse::Status200_TheActivityInformationWasRetrievedSuccessfully( + result, + ), + ); + } + + let record = if let Ok(id) = Uuid::parse_str(&query.target) { + self.load_seen_player_by_uuid(&id.to_string()).await? + } else { + self.load_seen_player_by_name(&query.target, query.ambiguous.unwrap_or(false)) + .await? + }; + let Some(record) = record else { + return Ok(GetPunishmentSeenResponse::Status404_NoMatchingPlayerOrIP); + }; + if !query.include_dummy.unwrap_or(false) && record.is_dummy() { + return Ok(GetPunishmentSeenResponse::Status404_NoMatchingPlayerOrIP); + } + + let player = self.into_seen_player(record).await?; + let mut result = SeenResult::new("player".to_string(), player.same_ip_players.clone()); + result.player = Some(player); + Ok( + GetPunishmentSeenResponse::Status200_TheActivityInformationWasRetrievedSuccessfully( + result, + ), + ) + } + async fn get_punishment_proof_by_id( &self, _: &Method, @@ -1039,6 +1249,38 @@ mod tests { assert_eq!(with_seen("OTHER,SEEN", false), "OTHER"); } + #[test] + fn seen_helpers_match_spicyazisaban_dummy_and_history_rules() { + assert_eq!( + distinct_strings(vec![ + "latest".to_string(), + "older".to_string(), + "latest".to_string(), + ]), + vec!["latest", "older"] + ); + assert!(matches!( + nullable_datetime_from_millis(0), + Ok(Nullable::Null) + )); + assert!(matches!( + nullable_datetime_from_millis(1), + Ok(Nullable::Present(_)) + )); + + let record = SeenPlayerRecord { + uuid: "550e8400-e29b-41d4-a716-446655440000".to_string(), + name: "dummy".to_string(), + ip: None, + last_seen: 0, + first_login: 0, + first_login_attempt: 0, + last_login: 0, + last_login_attempt: 0, + }; + assert!(record.is_dummy()); + } + #[test] fn punishment_creation_lock_names_are_stable_and_key_specific() { let lock = punishment_creation_lock_name("target", "BAN", "server"); diff --git a/server/app/src/main.rs b/server/app/src/main.rs index 805a301..d732387 100644 --- a/server/app/src/main.rs +++ b/server/app/src/main.rs @@ -109,6 +109,9 @@ async fn validate_punishments_database(pool: &MySqlPool) -> Result<(), sqlx::Err "SELECT 1 FROM `unpunish` LIMIT 1", "SELECT 1 FROM `proofs` LIMIT 1", "SELECT 1 FROM `events` LIMIT 1", + "SELECT 1 FROM `players` LIMIT 1", + "SELECT 1 FROM `usernameHistory` LIMIT 1", + "SELECT 1 FROM `ipAddressHistory` LIMIT 1", ] { sqlx::query(query).fetch_optional(pool).await?; } diff --git a/server/migrations/20260817000000_add_punishment_seen_api_key_scope.sql b/server/migrations/20260817000000_add_punishment_seen_api_key_scope.sql new file mode 100644 index 0000000..bd0a0c5 --- /dev/null +++ b/server/migrations/20260817000000_add_punishment_seen_api_key_scope.sql @@ -0,0 +1,24 @@ +ALTER TABLE api_key_scopes + DROP CONSTRAINT api_key_scopes_scope_check; + +ALTER TABLE api_key_scopes + ADD CONSTRAINT api_key_scopes_scope_check CHECK ( + scope IN ( + '*', + 'api-keys:read', + 'api-keys:write', + 'crawls:read', + 'crawls:write', + 'emojis:read', + 'emojis:write', + 'patch-notes:read', + 'patch-notes:write', + 'players:read', + 'players:read-details', + 'players:write', + 'punishments:read', + 'punishments:seen', + 'punishments:write', + 'stream:read' + ) + );