From 0edc3004cc4b30c2417e870c1eb89ff95d12c258 Mon Sep 17 00:00:00 2001 From: acrylic-style Date: Tue, 21 Jul 2026 22:31:20 +0900 Subject: [PATCH 1/4] feat: add punishment endpoints --- openapi/components/parameters/proof-id.yaml | 5 + .../components/parameters/punishment-id.yaml | 5 + openapi/components/schemas/api-key-scope.yaml | 2 + openapi/components/schemas/api-key.yaml | 8 + .../schemas/create-api-key-request.yaml | 3 + .../schemas/create-proof-request.yaml | 8 + .../schemas/create-punishment-request.yaml | 16 + .../schemas/list-punishments-response.yaml | 9 + openapi/components/schemas/proof-id.yaml | 4 + openapi/components/schemas/proof.yaml | 10 + openapi/components/schemas/punishment-id.yaml | 4 + .../components/schemas/punishment-type.yaml | 14 + openapi/components/schemas/punishment.yaml | 42 + openapi/components/schemas/revocation.yaml | 18 + .../schemas/revoke-punishment-request.yaml | 7 + .../schemas/update-proof-request.yaml | 9 + .../schemas/update-punishment-request.yaml | 9 + openapi/openapi.yaml | 38 + .../punishments/punishment-proofs-by-id.yaml | 61 + .../paths/punishments/punishment-proofs.yaml | 46 + .../paths/punishments/punishments-by-id.yaml | 65 + openapi/paths/punishments/punishments.yaml | 60 + server/app/src/api.rs | 8 + server/app/src/api/api_keys.rs | 237 ++- server/app/src/api/punishments.rs | 1325 +++++++++++++++++ server/app/src/auth/credentials.rs | 2 +- server/app/src/auth/provider.rs | 11 +- server/app/src/auth/scope.rs | 1 + server/app/src/main.rs | 23 + server/compose.yaml | 1 + ...dd_api_key_actor_and_punishment_scopes.sql | 35 + 31 files changed, 2073 insertions(+), 13 deletions(-) create mode 100644 openapi/components/parameters/proof-id.yaml create mode 100644 openapi/components/parameters/punishment-id.yaml create mode 100644 openapi/components/schemas/create-proof-request.yaml create mode 100644 openapi/components/schemas/create-punishment-request.yaml create mode 100644 openapi/components/schemas/list-punishments-response.yaml create mode 100644 openapi/components/schemas/proof-id.yaml create mode 100644 openapi/components/schemas/proof.yaml create mode 100644 openapi/components/schemas/punishment-id.yaml create mode 100644 openapi/components/schemas/punishment-type.yaml create mode 100644 openapi/components/schemas/punishment.yaml create mode 100644 openapi/components/schemas/revocation.yaml create mode 100644 openapi/components/schemas/revoke-punishment-request.yaml create mode 100644 openapi/components/schemas/update-proof-request.yaml create mode 100644 openapi/components/schemas/update-punishment-request.yaml create mode 100644 openapi/paths/punishments/punishment-proofs-by-id.yaml create mode 100644 openapi/paths/punishments/punishment-proofs.yaml create mode 100644 openapi/paths/punishments/punishments-by-id.yaml create mode 100644 openapi/paths/punishments/punishments.yaml create mode 100644 server/app/src/api/punishments.rs create mode 100644 server/migrations/20260721000000_add_api_key_actor_and_punishment_scopes.sql diff --git a/openapi/components/parameters/proof-id.yaml b/openapi/components/parameters/proof-id.yaml new file mode 100644 index 0000000..6ee5c82 --- /dev/null +++ b/openapi/components/parameters/proof-id.yaml @@ -0,0 +1,5 @@ +name: proofId +in: path +required: true +schema: + $ref: "../../openapi.yaml#/components/schemas/ProofId" diff --git a/openapi/components/parameters/punishment-id.yaml b/openapi/components/parameters/punishment-id.yaml new file mode 100644 index 0000000..f13b00a --- /dev/null +++ b/openapi/components/parameters/punishment-id.yaml @@ -0,0 +1,5 @@ +name: punishmentId +in: path +required: true +schema: + $ref: "../../openapi.yaml#/components/schemas/PunishmentId" diff --git a/openapi/components/schemas/api-key-scope.yaml b/openapi/components/schemas/api-key-scope.yaml index 7597a42..2669f4d 100644 --- a/openapi/components/schemas/api-key-scope.yaml +++ b/openapi/components/schemas/api-key-scope.yaml @@ -12,3 +12,5 @@ enum: - players:read - players:read-details - players:write + - punishments:read + - punishments:write diff --git a/openapi/components/schemas/api-key.yaml b/openapi/components/schemas/api-key.yaml index 9f1fa46..b004d9b 100644 --- a/openapi/components/schemas/api-key.yaml +++ b/openapi/components/schemas/api-key.yaml @@ -5,6 +5,7 @@ required: - scopes - createdAt - expiresAt + - playerId properties: name: type: string @@ -37,3 +38,10 @@ properties: - "null" format: date-time description: The date and time at which the API key expires, or `null` if it does not expire. + + playerId: + type: + - string + - "null" + format: uuid + description: The player linked to this API key, or `null` if the key is not linked to a player. diff --git a/openapi/components/schemas/create-api-key-request.yaml b/openapi/components/schemas/create-api-key-request.yaml index 7853e37..52f8d00 100644 --- a/openapi/components/schemas/create-api-key-request.yaml +++ b/openapi/components/schemas/create-api-key-request.yaml @@ -16,3 +16,6 @@ properties: expiresAt: $ref: "./api-key.yaml#/properties/expiresAt" + + playerId: + $ref: "./api-key.yaml#/properties/playerId" diff --git a/openapi/components/schemas/create-proof-request.yaml b/openapi/components/schemas/create-proof-request.yaml new file mode 100644 index 0000000..aeab87d --- /dev/null +++ b/openapi/components/schemas/create-proof-request.yaml @@ -0,0 +1,8 @@ +type: object +additionalProperties: false +required: [text, public] +properties: + text: + $ref: "./proof.yaml#/properties/text" + public: + $ref: "./proof.yaml#/properties/public" diff --git a/openapi/components/schemas/create-punishment-request.yaml b/openapi/components/schemas/create-punishment-request.yaml new file mode 100644 index 0000000..54d9036 --- /dev/null +++ b/openapi/components/schemas/create-punishment-request.yaml @@ -0,0 +1,16 @@ +type: object +additionalProperties: false +required: [targetName, target, type, reason, expiresAt, server] +properties: + targetName: + $ref: "./punishment.yaml#/properties/targetName" + target: + $ref: "./punishment.yaml#/properties/target" + type: + $ref: "../../openapi.yaml#/components/schemas/PunishmentType" + reason: + $ref: "./punishment.yaml#/properties/reason" + expiresAt: + $ref: "./punishment.yaml#/properties/expiresAt" + server: + $ref: "./punishment.yaml#/properties/server" diff --git a/openapi/components/schemas/list-punishments-response.yaml b/openapi/components/schemas/list-punishments-response.yaml new file mode 100644 index 0000000..8d2a7f5 --- /dev/null +++ b/openapi/components/schemas/list-punishments-response.yaml @@ -0,0 +1,9 @@ +type: object +required: [items, nextCursor] +properties: + items: + type: array + items: + $ref: "../../openapi.yaml#/components/schemas/Punishment" + nextCursor: + type: [string, "null"] diff --git a/openapi/components/schemas/proof-id.yaml b/openapi/components/schemas/proof-id.yaml new file mode 100644 index 0000000..bb52c21 --- /dev/null +++ b/openapi/components/schemas/proof-id.yaml @@ -0,0 +1,4 @@ +type: integer +format: int64 +minimum: 1 +readOnly: true diff --git a/openapi/components/schemas/proof.yaml b/openapi/components/schemas/proof.yaml new file mode 100644 index 0000000..a1b431e --- /dev/null +++ b/openapi/components/schemas/proof.yaml @@ -0,0 +1,10 @@ +type: object +required: [id, text, public] +properties: + id: + $ref: "../../openapi.yaml#/components/schemas/ProofId" + text: + type: string + minLength: 1 + public: + type: boolean diff --git a/openapi/components/schemas/punishment-id.yaml b/openapi/components/schemas/punishment-id.yaml new file mode 100644 index 0000000..bb52c21 --- /dev/null +++ b/openapi/components/schemas/punishment-id.yaml @@ -0,0 +1,4 @@ +type: integer +format: int64 +minimum: 1 +readOnly: true diff --git a/openapi/components/schemas/punishment-type.yaml b/openapi/components/schemas/punishment-type.yaml new file mode 100644 index 0000000..5979243 --- /dev/null +++ b/openapi/components/schemas/punishment-type.yaml @@ -0,0 +1,14 @@ +type: string +enum: + - ban + - tempBan + - ipBan + - tempIpBan + - mute + - tempMute + - ipMute + - tempIpMute + - warning + - caution + - kick + - note diff --git a/openapi/components/schemas/punishment.yaml b/openapi/components/schemas/punishment.yaml new file mode 100644 index 0000000..b91daea --- /dev/null +++ b/openapi/components/schemas/punishment.yaml @@ -0,0 +1,42 @@ +type: object +required: [id, targetName, target, type, reason, actorId, createdAt, expiresAt, server, seen, active, proofs, revocation] +properties: + id: + $ref: "../../openapi.yaml#/components/schemas/PunishmentId" + targetName: + type: string + minLength: 1 + target: + type: string + minLength: 1 + type: + $ref: "../../openapi.yaml#/components/schemas/PunishmentType" + reason: + type: string + minLength: 1 + actorId: + type: string + format: uuid + createdAt: + type: string + format: date-time + readOnly: true + expiresAt: + type: [string, "null"] + format: date-time + server: + type: string + minLength: 1 + seen: + type: boolean + active: + type: boolean + readOnly: true + proofs: + type: array + items: + $ref: "../../openapi.yaml#/components/schemas/Proof" + revocation: + oneOf: + - $ref: "../../openapi.yaml#/components/schemas/Revocation" + - type: "null" diff --git a/openapi/components/schemas/revocation.yaml b/openapi/components/schemas/revocation.yaml new file mode 100644 index 0000000..7ce2cb6 --- /dev/null +++ b/openapi/components/schemas/revocation.yaml @@ -0,0 +1,18 @@ +type: object +required: [id, reason, actorId, createdAt] +properties: + id: + type: integer + format: int64 + minimum: 1 + readOnly: true + reason: + type: string + minLength: 1 + actorId: + type: string + format: uuid + createdAt: + type: string + format: date-time + readOnly: true diff --git a/openapi/components/schemas/revoke-punishment-request.yaml b/openapi/components/schemas/revoke-punishment-request.yaml new file mode 100644 index 0000000..34ad1aa --- /dev/null +++ b/openapi/components/schemas/revoke-punishment-request.yaml @@ -0,0 +1,7 @@ +type: object +additionalProperties: false +required: [reason] +properties: + reason: + type: string + minLength: 1 diff --git a/openapi/components/schemas/update-proof-request.yaml b/openapi/components/schemas/update-proof-request.yaml new file mode 100644 index 0000000..1d7573b --- /dev/null +++ b/openapi/components/schemas/update-proof-request.yaml @@ -0,0 +1,9 @@ +type: object +additionalProperties: false +minProperties: 1 +properties: + text: + type: string + minLength: 1 + public: + type: boolean diff --git a/openapi/components/schemas/update-punishment-request.yaml b/openapi/components/schemas/update-punishment-request.yaml new file mode 100644 index 0000000..7544a4a --- /dev/null +++ b/openapi/components/schemas/update-punishment-request.yaml @@ -0,0 +1,9 @@ +type: object +additionalProperties: false +minProperties: 1 +properties: + reason: + type: string + minLength: 1 + seen: + type: boolean diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml index 18eeee2..abcad27 100644 --- a/openapi/openapi.yaml +++ b/openapi/openapi.yaml @@ -20,6 +20,8 @@ tags: description: Operations related to patch notes. - name: players description: Operations related to players. + - name: punishments + description: Operations related to punishment records and proofs. paths: /api-keys: @@ -34,6 +36,14 @@ paths: $ref: "./paths/players/players.yaml" /players/{playerId}: $ref: "./paths/players/players-by-id.yaml" + /punishments: + $ref: "./paths/punishments/punishments.yaml" + /punishments/{punishmentId}: + $ref: "./paths/punishments/punishments-by-id.yaml" + /punishments/{punishmentId}/proofs: + $ref: "./paths/punishments/punishment-proofs.yaml" + /punishments/{punishmentId}/proofs/{proofId}: + $ref: "./paths/punishments/punishment-proofs-by-id.yaml" components: parameters: @@ -43,6 +53,10 @@ components: $ref: "./components/parameters/patch-note-id.yaml" PlayerId: $ref: "./components/parameters/player-id.yaml" + PunishmentId: + $ref: "./components/parameters/punishment-id.yaml" + ProofId: + $ref: "./components/parameters/proof-id.yaml" responses: Unauthorized: @@ -83,6 +97,30 @@ components: $ref: "./components/schemas/player-status.yaml" UpdatePlayerRequest: $ref: "./components/schemas/update-player-request.yaml" + Punishment: + $ref: "./components/schemas/punishment.yaml" + PunishmentType: + $ref: "./components/schemas/punishment-type.yaml" + PunishmentId: + $ref: "./components/schemas/punishment-id.yaml" + Proof: + $ref: "./components/schemas/proof.yaml" + ProofId: + $ref: "./components/schemas/proof-id.yaml" + Revocation: + $ref: "./components/schemas/revocation.yaml" + CreatePunishmentRequest: + $ref: "./components/schemas/create-punishment-request.yaml" + UpdatePunishmentRequest: + $ref: "./components/schemas/update-punishment-request.yaml" + RevokePunishmentRequest: + $ref: "./components/schemas/revoke-punishment-request.yaml" + ListPunishmentsResponse: + $ref: "./components/schemas/list-punishments-response.yaml" + CreateProofRequest: + $ref: "./components/schemas/create-proof-request.yaml" + UpdateProofRequest: + $ref: "./components/schemas/update-proof-request.yaml" securitySchemes: ApiKeyAuth: diff --git a/openapi/paths/punishments/punishment-proofs-by-id.yaml b/openapi/paths/punishments/punishment-proofs-by-id.yaml new file mode 100644 index 0000000..f7e64ab --- /dev/null +++ b/openapi/paths/punishments/punishment-proofs-by-id.yaml @@ -0,0 +1,61 @@ +get: + summary: Get a punishment proof + operationId: getPunishmentProofById + tags: [punishments] + security: + - ApiKeyAuth: ["punishments:read"] + parameters: + - $ref: "../../openapi.yaml#/components/parameters/PunishmentId" + - $ref: "../../openapi.yaml#/components/parameters/ProofId" + responses: + "200": + description: The proof was retrieved successfully. + content: + application/json: + schema: + $ref: "../../openapi.yaml#/components/schemas/Proof" + "401": { $ref: "../../openapi.yaml#/components/responses/Unauthorized" } + "403": { $ref: "../../openapi.yaml#/components/responses/Forbidden" } + "404": { description: The punishment or proof was not found. } + +patch: + summary: Update a punishment proof + operationId: updatePunishmentProofById + tags: [punishments] + security: + - ApiKeyAuth: ["punishments:write"] + parameters: + - $ref: "../../openapi.yaml#/components/parameters/PunishmentId" + - $ref: "../../openapi.yaml#/components/parameters/ProofId" + requestBody: + required: true + content: + application/json: + schema: + $ref: "../../openapi.yaml#/components/schemas/UpdateProofRequest" + responses: + "200": + description: The proof was updated successfully. + content: + application/json: + schema: + $ref: "../../openapi.yaml#/components/schemas/Proof" + "400": { description: The request body is invalid. } + "401": { $ref: "../../openapi.yaml#/components/responses/Unauthorized" } + "403": { $ref: "../../openapi.yaml#/components/responses/Forbidden" } + "404": { description: The punishment or proof was not found. } + +delete: + summary: Delete a punishment proof + operationId: deletePunishmentProofById + tags: [punishments] + security: + - ApiKeyAuth: ["punishments:write"] + parameters: + - $ref: "../../openapi.yaml#/components/parameters/PunishmentId" + - $ref: "../../openapi.yaml#/components/parameters/ProofId" + responses: + "204": { description: The proof was deleted successfully. } + "401": { $ref: "../../openapi.yaml#/components/responses/Unauthorized" } + "403": { $ref: "../../openapi.yaml#/components/responses/Forbidden" } + "404": { description: The punishment or proof was not found. } diff --git a/openapi/paths/punishments/punishment-proofs.yaml b/openapi/paths/punishments/punishment-proofs.yaml new file mode 100644 index 0000000..747caf4 --- /dev/null +++ b/openapi/paths/punishments/punishment-proofs.yaml @@ -0,0 +1,46 @@ +get: + summary: List punishment proofs + operationId: listPunishmentProofs + tags: [punishments] + security: + - ApiKeyAuth: ["punishments:read"] + parameters: + - $ref: "../../openapi.yaml#/components/parameters/PunishmentId" + responses: + "200": + description: Proofs were retrieved successfully. + content: + application/json: + schema: + type: array + items: + $ref: "../../openapi.yaml#/components/schemas/Proof" + "401": { $ref: "../../openapi.yaml#/components/responses/Unauthorized" } + "403": { $ref: "../../openapi.yaml#/components/responses/Forbidden" } + "404": { description: The punishment was not found. } + +post: + summary: Add proof to an active punishment + operationId: createPunishmentProof + tags: [punishments] + security: + - ApiKeyAuth: ["punishments:write"] + parameters: + - $ref: "../../openapi.yaml#/components/parameters/PunishmentId" + requestBody: + required: true + content: + application/json: + schema: + $ref: "../../openapi.yaml#/components/schemas/CreateProofRequest" + responses: + "201": + description: The proof was created successfully. + content: + application/json: + schema: + $ref: "../../openapi.yaml#/components/schemas/Proof" + "400": { description: The request body is invalid. } + "401": { $ref: "../../openapi.yaml#/components/responses/Unauthorized" } + "403": { $ref: "../../openapi.yaml#/components/responses/Forbidden" } + "404": { description: The active punishment was not found. } diff --git a/openapi/paths/punishments/punishments-by-id.yaml b/openapi/paths/punishments/punishments-by-id.yaml new file mode 100644 index 0000000..6d90b8c --- /dev/null +++ b/openapi/paths/punishments/punishments-by-id.yaml @@ -0,0 +1,65 @@ +get: + summary: Get a punishment + operationId: getPunishmentById + tags: [punishments] + security: + - ApiKeyAuth: ["punishments:read"] + parameters: + - $ref: "../../openapi.yaml#/components/parameters/PunishmentId" + responses: + "200": + description: The punishment was retrieved successfully. + content: + application/json: + schema: + $ref: "../../openapi.yaml#/components/schemas/Punishment" + "401": { $ref: "../../openapi.yaml#/components/responses/Unauthorized" } + "403": { $ref: "../../openapi.yaml#/components/responses/Forbidden" } + "404": { description: The punishment was not found. } + +patch: + summary: Update an active punishment + operationId: updatePunishmentById + tags: [punishments] + security: + - ApiKeyAuth: ["punishments:write"] + parameters: + - $ref: "../../openapi.yaml#/components/parameters/PunishmentId" + requestBody: + required: true + content: + application/json: + schema: + $ref: "../../openapi.yaml#/components/schemas/UpdatePunishmentRequest" + responses: + "200": + description: The punishment was updated successfully. + content: + application/json: + schema: + $ref: "../../openapi.yaml#/components/schemas/Punishment" + "400": { description: The request body is invalid. } + "401": { $ref: "../../openapi.yaml#/components/responses/Unauthorized" } + "403": { $ref: "../../openapi.yaml#/components/responses/Forbidden" } + "404": { description: The active punishment was not found. } + +delete: + summary: Revoke an active punishment + operationId: deletePunishmentById + tags: [punishments] + security: + - ApiKeyAuth: ["punishments:write"] + parameters: + - $ref: "../../openapi.yaml#/components/parameters/PunishmentId" + requestBody: + required: true + content: + application/json: + schema: + $ref: "../../openapi.yaml#/components/schemas/RevokePunishmentRequest" + responses: + "204": { description: The punishment was revoked successfully. } + "400": { description: The request body is invalid. } + "401": { $ref: "../../openapi.yaml#/components/responses/Unauthorized" } + "403": { $ref: "../../openapi.yaml#/components/responses/Forbidden" } + "404": { description: The active punishment was not found. } diff --git a/openapi/paths/punishments/punishments.yaml b/openapi/paths/punishments/punishments.yaml new file mode 100644 index 0000000..c1b134b --- /dev/null +++ b/openapi/paths/punishments/punishments.yaml @@ -0,0 +1,60 @@ +get: + summary: List punishments + operationId: listPunishments + tags: [punishments] + security: + - ApiKeyAuth: ["punishments:read"] + parameters: + - name: target + in: query + schema: { type: string } + - name: type + in: query + schema: + $ref: "../../openapi.yaml#/components/schemas/PunishmentType" + - name: server + in: query + schema: { type: string } + - name: active + in: query + schema: { type: boolean } + - name: cursor + in: query + schema: { type: string } + - name: limit + in: query + schema: { type: integer, minimum: 1, maximum: 100, default: 20 } + responses: + "200": + description: Punishments were retrieved successfully. + content: + application/json: + schema: + $ref: "../../openapi.yaml#/components/schemas/ListPunishmentsResponse" + "400": { description: The request contains invalid query parameters. } + "401": { $ref: "../../openapi.yaml#/components/responses/Unauthorized" } + "403": { $ref: "../../openapi.yaml#/components/responses/Forbidden" } + +post: + summary: Create a punishment + operationId: createPunishment + tags: [punishments] + security: + - ApiKeyAuth: ["punishments:write"] + requestBody: + required: true + content: + application/json: + schema: + $ref: "../../openapi.yaml#/components/schemas/CreatePunishmentRequest" + responses: + "201": + description: The punishment was created successfully. + content: + application/json: + schema: + $ref: "../../openapi.yaml#/components/schemas/Punishment" + "400": { description: The request body is invalid. } + "401": { $ref: "../../openapi.yaml#/components/responses/Unauthorized" } + "403": { $ref: "../../openapi.yaml#/components/responses/Forbidden" } + "409": { description: A conflicting active punishment already exists. } diff --git a/server/app/src/api.rs b/server/app/src/api.rs index 5a5ffa1..0f182a8 100644 --- a/server/app/src/api.rs +++ b/server/app/src/api.rs @@ -1,6 +1,7 @@ mod api_keys; mod patch_notes; mod players; +mod punishments; use crate::mojang::PlayerDbClient; use aws_config::BehaviorVersion; @@ -14,6 +15,7 @@ use std::env; #[derive(Clone, Debug)] pub(crate) struct Api { pool: MySqlPool, + punishments_pool: MySqlPool, object_storage: Option, player_db: PlayerDbClient, } @@ -21,11 +23,13 @@ pub(crate) struct Api { impl Api { pub(crate) fn new( pool: MySqlPool, + punishments_pool: MySqlPool, object_storage: Option, player_db: PlayerDbClient, ) -> Self { Self { pool, + punishments_pool, object_storage, player_db, } @@ -34,6 +38,10 @@ impl Api { pub(crate) fn pool(&self) -> &MySqlPool { &self.pool } + + pub(crate) fn punishments_pool(&self) -> &MySqlPool { + &self.punishments_pool + } } #[derive(Clone, Debug)] diff --git a/server/app/src/api/api_keys.rs b/server/app/src/api/api_keys.rs index 01f63d0..0260fea 100644 --- a/server/app/src/api/api_keys.rs +++ b/server/app/src/api/api_keys.rs @@ -30,6 +30,7 @@ struct ApiKeyRecord { name: String, created_at: DateTime, expires_at: Option>, + player_id: Option, } impl ApiKeyRecord { @@ -42,6 +43,7 @@ impl ApiKeyRecord { name, created_at, expires_at, + player_id, } = self; let item_scopes = scopes.get(&public_id).cloned().unwrap_or_default(); @@ -52,6 +54,7 @@ impl ApiKeyRecord { item_scopes, created_at, into_nullable(expires_at), + into_nullable(player_id), ) } } @@ -84,6 +87,22 @@ impl ApiKeys for Api { return Ok(CreateApiKeyResponse::Status403_TheAuthenticatedAPIKeyLacksTheRequiredScope); } + let requested_player_id = body + .player_id + .as_ref() + .and_then(|player_id| match player_id { + graph_api::types::Nullable::Present(player_id) => Some(*player_id), + graph_api::types::Nullable::Null => None, + }); + let player_id = match delegated_player_id(api_key, requested_player_id) { + Ok(player_id) => player_id, + Err(()) => { + return Ok( + CreateApiKeyResponse::Status403_TheAuthenticatedAPIKeyLacksTheRequiredScope, + ); + } + }; + let created_at = Utc::now(); let expires_at = body .expires_at @@ -135,6 +154,15 @@ impl ApiKeys for Api { .map_err(log_database_error)?; } + if let Some(player_id) = player_id { + sqlx::query("INSERT INTO api_key_players (api_key_public_id, player_id) VALUES (?, ?)") + .bind(&public_id) + .bind(player_id) + .execute(&mut *transaction) + .await + .map_err(log_database_error)?; + } + transaction.commit().await.map_err(log_database_error)?; Ok( @@ -145,6 +173,7 @@ impl ApiKeys for Api { scopes, created_at, into_nullable(expires_at), + into_nullable(player_id), token, ), ), @@ -188,9 +217,10 @@ impl ApiKeys for Api { let record = sqlx::query_as::<_, ApiKeyRecord>( r#" - SELECT public_id, name, created_at, expires_at - FROM api_keys - WHERE public_id = ? + SELECT k.public_id, k.name, k.created_at, k.expires_at, p.player_id + FROM api_keys k + LEFT JOIN api_key_players p ON p.api_key_public_id = k.public_id + WHERE k.public_id = ? "#, ) .bind(&path_params.api_key_id) @@ -242,20 +272,20 @@ impl ApiKeys for Api { }; let mut query = QueryBuilder::::new( - "SELECT public_id, name, created_at, expires_at FROM api_keys WHERE 1 = 1", + "SELECT k.public_id, k.name, k.created_at, k.expires_at, p.player_id FROM api_keys k LEFT JOIN api_key_players p ON p.api_key_public_id = k.public_id WHERE 1 = 1", ); if let Some(cursor) = cursor { query - .push(" AND (created_at < ") + .push(" AND (k.created_at < ") .push_bind(cursor.value) - .push(" OR (created_at = ") + .push(" OR (k.created_at = ") .push_bind(cursor.value) - .push(" AND public_id < ") + .push(" AND k.public_id < ") .push_bind(cursor.tie_breaker) .push("))"); } query - .push(" ORDER BY created_at DESC, public_id DESC LIMIT ") + .push(" ORDER BY k.created_at DESC, k.public_id DESC LIMIT ") .push_bind((limit + 1) as i64); let mut rows = query @@ -326,6 +356,12 @@ impl Api { .await .map_err(log_database_error)?; + sqlx::query("DELETE FROM api_key_players WHERE api_key_public_id = ?") + .bind(credentials.public_id()) + .execute(&mut *transaction) + .await + .map_err(log_database_error)?; + sqlx::query("DELETE FROM api_key_scopes WHERE api_key_public_id = ?") .bind(credentials.public_id()) .execute(&mut *transaction) @@ -433,7 +469,192 @@ fn parse_api_key_scopes(scopes: &[String]) -> Option> { (unique.len() == parsed.len()).then_some(parsed) } +fn delegated_player_id( + api_key: &ApiKey, + requested: Option, +) -> Result, ()> { + match api_key.player_id { + graph_api::types::Nullable::Present(player_id) => { + if requested.is_some_and(|requested| requested != player_id) { + Err(()) + } else { + Ok(Some(player_id)) + } + } + graph_api::types::Nullable::Null => { + if requested.is_some() && !api_key.scopes.iter().any(|scope| scope == "*") { + Err(()) + } else { + Ok(requested) + } + } + } +} + fn log_database_error(error: sqlx::Error) -> String { tracing::error!(?error, "API key database operation failed"); error.to_string() } + +#[cfg(test)] +mod tests { + use super::*; + use crate::mojang::PlayerDbClient; + use graph_api::types::Nullable; + use headers::Header; + + fn test_host() -> Host { + let value = http::HeaderValue::from_static("localhost"); + Host::decode(&mut std::iter::once(&value)).unwrap() + } + + fn key(scopes: &[&str], player_id: Option) -> ApiKey { + ApiKey::new( + "test".to_string(), + "test".to_string(), + scopes.iter().map(|scope| (*scope).to_string()).collect(), + Utc::now(), + Nullable::Null, + into_nullable(player_id), + ) + } + + #[test] + fn player_key_children_inherit_player() { + let player = uuid::Uuid::new_v4(); + assert_eq!( + delegated_player_id(&key(&["api-keys:write"], Some(player)), None), + Ok(Some(player)) + ); + assert_eq!( + delegated_player_id(&key(&["api-keys:write"], Some(player)), Some(player)), + Ok(Some(player)) + ); + } + + #[test] + fn player_key_cannot_delegate_another_player() { + assert_eq!( + delegated_player_id( + &key(&["*"], Some(uuid::Uuid::new_v4())), + Some(uuid::Uuid::new_v4()) + ), + Err(()) + ); + } + + #[test] + fn only_unlinked_star_key_can_assign_player() { + let player = uuid::Uuid::new_v4(); + assert_eq!( + delegated_player_id(&key(&["*"], None), Some(player)), + Ok(Some(player)) + ); + assert_eq!( + delegated_player_id(&key(&["api-keys:write"], None), Some(player)), + Err(()) + ); + assert_eq!( + delegated_player_id(&key(&["api-keys:write"], None), None), + Ok(None) + ); + } + + #[tokio::test] + async fn mariadb_player_link_is_persisted_and_loaded() { + let Ok(database_url) = std::env::var("GRAPH_TEST_DATABASE_URL") else { + eprintln!("GRAPH_TEST_DATABASE_URL is not set; skipping MariaDB integration test"); + return; + }; + let pool = sqlx::MySqlPool::connect(&database_url).await.unwrap(); + sqlx::migrate!("../migrations").run(&pool).await.unwrap(); + + let api = Api::new( + pool.clone(), + pool.clone(), + None, + PlayerDbClient::new().unwrap(), + ); + let credentials = ApiKeyCredentials::generate().unwrap(); + api.provision_bootstrap_api_key(&credentials.to_token()) + .await + .unwrap(); + let player_id = uuid::Uuid::new_v4(); + sqlx::query("INSERT INTO players (id) VALUES (?)") + .bind(player_id) + .execute(&pool) + .await + .unwrap(); + + let parent = ApiKey::new( + "bootstrap".to_string(), + credentials.public_id().to_string(), + vec!["*".to_string()], + Utc::now(), + Nullable::Null, + Nullable::Null, + ); + let mut request = CreateApiKeyRequest::new( + "linked integration key".to_string(), + vec!["punishments:write".to_string()], + ); + request.player_id = Some(Nullable::Present(player_id)); + let response = api + .create_api_key( + &Method::POST, + &test_host(), + &CookieJar::new(), + &parent, + &request, + ) + .await + .unwrap(); + let created = match response { + CreateApiKeyResponse::Status201_TheAPIKeyWasCreatedSuccessfully(created) => created, + response => panic!("unexpected response: {response:?}"), + }; + assert_eq!(created.player_id, Nullable::Present(player_id)); + + let stored_player_id = sqlx::query_scalar::<_, uuid::Uuid>( + "SELECT player_id FROM api_key_players WHERE api_key_public_id = ?", + ) + .bind(&created.public_id) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(stored_player_id, player_id); + + let loaded = api + .get_api_key_by_id( + &Method::GET, + &test_host(), + &CookieJar::new(), + &parent, + &GetApiKeyByIdPathParams { + api_key_id: created.public_id.clone(), + }, + ) + .await + .unwrap(); + match loaded { + GetApiKeyByIdResponse::Status200_TheAPIKeyWasRetrievedSuccessfully(loaded) => { + assert_eq!(loaded.player_id, Nullable::Present(player_id)); + } + response => panic!("unexpected response: {response:?}"), + } + + sqlx::query("DELETE FROM api_keys WHERE public_id = ?") + .bind(&created.public_id) + .execute(&pool) + .await + .unwrap(); + sqlx::query("DELETE FROM players WHERE id = ?") + .bind(player_id) + .execute(&pool) + .await + .unwrap(); + api.delete_api_key_tree(credentials.public_id()) + .await + .unwrap(); + } +} diff --git a/server/app/src/api/punishments.rs b/server/app/src/api/punishments.rs new file mode 100644 index 0000000..0acdf8a --- /dev/null +++ b/server/app/src/api/punishments.rs @@ -0,0 +1,1325 @@ +use crate::api::{Api, into_nullable}; +use crate::auth::scope::ApiKeyScopeExt; +use crate::pagination::Cursor; +use async_trait::async_trait; +use axum_extra::extract::CookieJar; +use chrono::{DateTime, Utc}; +use graph_api::apis::punishments::*; +use graph_api::models::*; +use graph_api::types::Nullable; +use headers::Host; +use http::Method; +use sqlx::{FromRow, MySql, QueryBuilder}; +use std::net::IpAddr; +use uuid::Uuid; + +const DEFAULT_LIMIT: u8 = 20; +const MAX_LIMIT: u8 = 100; +type PunishmentCursor = Cursor; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum StoredPunishmentType { + Ban, + TempBan, + IpBan, + TempIpBan, + Mute, + TempMute, + IpMute, + TempIpMute, + Warning, + Caution, + Kick, + Note, +} + +impl StoredPunishmentType { + fn from_api(value: &str) -> Option { + Some(match value { + "ban" => Self::Ban, + "tempBan" => Self::TempBan, + "ipBan" => Self::IpBan, + "tempIpBan" => Self::TempIpBan, + "mute" => Self::Mute, + "tempMute" => Self::TempMute, + "ipMute" => Self::IpMute, + "tempIpMute" => Self::TempIpMute, + "warning" => Self::Warning, + "caution" => Self::Caution, + "kick" => Self::Kick, + "note" => Self::Note, + _ => return None, + }) + } + + fn from_db(value: &str) -> Option { + Some(match value { + "BAN" => Self::Ban, + "TEMP_BAN" => Self::TempBan, + "IP_BAN" => Self::IpBan, + "TEMP_IP_BAN" => Self::TempIpBan, + "MUTE" => Self::Mute, + "TEMP_MUTE" => Self::TempMute, + "IP_MUTE" => Self::IpMute, + "TEMP_IP_MUTE" => Self::TempIpMute, + "WARNING" => Self::Warning, + "CAUTION" => Self::Caution, + "KICK" => Self::Kick, + "NOTE" => Self::Note, + _ => return None, + }) + } + + fn api(self) -> &'static str { + match self { + Self::Ban => "ban", + Self::TempBan => "tempBan", + Self::IpBan => "ipBan", + Self::TempIpBan => "tempIpBan", + Self::Mute => "mute", + Self::TempMute => "tempMute", + Self::IpMute => "ipMute", + Self::TempIpMute => "tempIpMute", + Self::Warning => "warning", + Self::Caution => "caution", + Self::Kick => "kick", + Self::Note => "note", + } + } + + fn db(self) -> &'static str { + match self { + Self::Ban => "BAN", + Self::TempBan => "TEMP_BAN", + Self::IpBan => "IP_BAN", + Self::TempIpBan => "TEMP_IP_BAN", + Self::Mute => "MUTE", + Self::TempMute => "TEMP_MUTE", + Self::IpMute => "IP_MUTE", + Self::TempIpMute => "TEMP_IP_MUTE", + Self::Warning => "WARNING", + Self::Caution => "CAUTION", + Self::Kick => "KICK", + Self::Note => "NOTE", + } + } + + fn temporary(self) -> bool { + matches!( + self, + Self::TempBan | Self::TempIpBan | Self::TempMute | Self::TempIpMute + ) + } + + fn ip_based(self) -> bool { + matches!( + self, + Self::IpBan | Self::TempIpBan | Self::IpMute | Self::TempIpMute + ) + } +} + +#[derive(Debug, FromRow)] +struct PunishmentRecord { + id: i64, + name: String, + target: String, + reason: String, + operator: String, + r#type: String, + start: i64, + end: i64, + server: String, + extra: String, + active: bool, + revocation_id: Option, + revocation_reason: Option, + revocation_timestamp: Option, + revocation_operator: Option, +} + +#[derive(Debug, FromRow)] +struct ProofRecord { + id: i64, + text: String, + public: bool, +} + +fn can_read(key: &ApiKey) -> bool { + key.has_scope(&ApiKeyScope::PunishmentsColonRead) +} + +fn write_actor(key: &ApiKey) -> Option { + if !key.has_scope(&ApiKeyScope::PunishmentsColonWrite) { + return None; + } + match key.player_id { + Nullable::Present(actor_id) => Some(actor_id), + Nullable::Null => None, + } +} + +fn non_empty(value: &str) -> bool { + !value.trim().is_empty() +} + +fn punishable_ip(value: &str) -> bool { + let Ok(ip) = value.parse::() else { + return false; + }; + let IpAddr::V4(ip) = ip else { + return true; + }; + let [a, b, c, _] = ip.octets(); + !(a == 0 + || a == 10 + || (a == 100 && (64..=127).contains(&b)) + || a == 127 + || (a == 169 && b == 254) + || (a == 192 && b == 0 && (c == 0 || c == 2)) + || (a == 192 && b == 88 && c == 99) + || (a == 192 && b == 168) + || (a == 198 && (b == 18 || b == 19)) + || (a == 203 && b == 0 && c == 133) + || (224..=239).contains(&a) + || a >= 240) +} + +fn valid_target(kind: StoredPunishmentType, target: &str) -> bool { + if kind.ip_based() { + punishable_ip(target) + } else { + Uuid::parse_str(target).is_ok() + } +} + +fn end_millis( + kind: StoredPunishmentType, + expires_at: &Nullable>, + now: DateTime, +) -> Option { + match (kind.temporary(), expires_at) { + (true, Nullable::Present(expires_at)) if *expires_at > now => { + Some(expires_at.timestamp_millis()) + } + (false, Nullable::Null) => Some(-1), + _ => None, + } +} + +fn seen(extra: &str) -> bool { + extra.split(',').any(|flag| flag == "SEEN") +} + +fn with_seen(extra: &str, value: bool) -> String { + let mut flags = extra + .split(',') + .filter(|flag| !flag.is_empty() && *flag != "SEEN") + .map(str::to_owned) + .collect::>(); + if value { + flags.push("SEEN".to_string()); + } + flags.join(",") +} + +fn millis(value: i64) -> Result, String> { + DateTime::from_timestamp_millis(value) + .ok_or_else(|| "invalid epoch milliseconds in punishments database".to_string()) +} + +fn row_id(value: i64) -> Result { + u64::try_from(value).map_err(|_| "negative identifier in punishments database".to_string()) +} + +impl ProofRecord { + fn into_api(self) -> Result { + Ok(ListPunishments200ResponseItemsInnerProofsInner::new( + row_id(self.id)?, + self.text, + self.public, + )) + } +} + +impl Api { + 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", + ) + .bind(punishment_id) + .fetch_all(self.punishments_pool()) + .await + .map_err(db_error)? + .into_iter() + .map(ProofRecord::into_api) + .collect() + } + + async fn record_into_api( + &self, + record: PunishmentRecord, + ) -> Result { + let kind = StoredPunishmentType::from_db(&record.r#type) + .ok_or_else(|| format!("unknown punishment type in database: {}", record.r#type))?; + let actor_id = Uuid::parse_str(&record.operator) + .map_err(|_| "invalid punishment operator UUID in database".to_string())?; + let expires_at = if record.end == -1 { + Nullable::Null + } else { + Nullable::Present(millis(record.end)?) + }; + let revocation = match ( + record.revocation_id, + record.revocation_reason, + record.revocation_timestamp, + record.revocation_operator, + ) { + (Some(id), Some(reason), Some(timestamp), Some(operator)) => { + Nullable::Present(ListPunishments200ResponseItemsInnerRevocation::new( + row_id(id)?, + reason, + Uuid::parse_str(&operator) + .map_err(|_| "invalid revocation operator UUID in database".to_string())?, + millis(timestamp)?, + )) + } + _ => Nullable::Null, + }; + let proofs = self.load_proofs(record.id).await?; + Ok(ListPunishments200ResponseItemsInner::new( + row_id(record.id)?, + record.name, + record.target, + kind.api().to_string(), + record.reason, + actor_id, + millis(record.start)?, + expires_at, + record.server, + seen(&record.extra), + record.active, + proofs, + revocation, + )) + } + + async fn fetch_punishment( + &self, + id: i64, + ) -> Result, String> { + let record = sqlx::query_as::<_, PunishmentRecord>( + "SELECT h.id, h.name, h.target, h.reason, h.operator, h.type, h.start, h.end, h.server, h.extra, (p.id IS NOT NULL) AS active, u.id AS revocation_id, u.reason AS revocation_reason, u.timestamp AS revocation_timestamp, u.operator AS revocation_operator FROM punishmentHistory h LEFT JOIN punishments p ON p.id = h.id LEFT JOIN unpunish u ON u.punish_id = h.id WHERE h.id = ? ORDER BY u.id DESC LIMIT 1" + ).bind(id).fetch_optional(self.punishments_pool()).await.map_err(db_error)?; + match record { + Some(record) => self.record_into_api(record).await.map(Some), + None => Ok(None), + } + } + + async fn punishment_exists(&self, id: i64, active: bool) -> Result { + let row = if active { + sqlx::query_scalar::<_, i64>("SELECT id FROM punishments WHERE id = ?") + .bind(id) + .fetch_optional(self.punishments_pool()) + .await + } else { + sqlx::query_scalar::<_, i64>("SELECT id FROM punishmentHistory WHERE id = ?") + .bind(id) + .fetch_optional(self.punishments_pool()) + .await + }; + row.map(|row| row.is_some()).map_err(db_error) + } + + async fn fetch_proof( + &self, + punishment_id: i64, + proof_id: i64, + ) -> Result, String> { + sqlx::query_as::<_, ProofRecord>( + "SELECT id, text, public FROM proofs WHERE id = ? AND punish_id = ?", + ) + .bind(proof_id) + .bind(punishment_id) + .fetch_optional(self.punishments_pool()) + .await + .map_err(db_error)? + .map(ProofRecord::into_api) + .transpose() + } +} + +#[async_trait] +impl Punishments for Api { + type Claims = ApiKey; + + async fn create_punishment( + &self, + _: &Method, + _: &Host, + _: &CookieJar, + key: &ApiKey, + body: &CreatePunishmentRequest, + ) -> Result { + let Some(actor_id) = write_actor(key) else { + return Ok( + CreatePunishmentResponse::Status403_TheAuthenticatedAPIKeyLacksTheRequiredScope, + ); + }; + let Some(kind) = StoredPunishmentType::from_api(&body.r_type) else { + return Ok(CreatePunishmentResponse::Status400_TheRequestBodyIsInvalid); + }; + let now = Utc::now(); + let Some(end) = end_millis(kind, &body.expires_at, now) else { + return Ok(CreatePunishmentResponse::Status400_TheRequestBodyIsInvalid); + }; + if !non_empty(&body.target_name) + || !non_empty(&body.reason) + || !non_empty(&body.server) + || !valid_target(kind, &body.target) + { + return Ok(CreatePunishmentResponse::Status400_TheRequestBodyIsInvalid); + } + let server = body.server.to_lowercase(); + let mut tx = self.punishments_pool.begin().await.map_err(db_error)?; + let conflict = sqlx::query_scalar::<_, i64>("SELECT id FROM punishments WHERE target = ? AND type = ? AND server = ? LIMIT 1 FOR UPDATE") + .bind(&body.target).bind(kind.db()).bind(&server).fetch_optional(&mut *tx).await.map_err(db_error)?; + if conflict.is_some() { + tx.rollback().await.map_err(db_error)?; + return Ok( + CreatePunishmentResponse::Status409_AConflictingActivePunishmentAlreadyExists, + ); + } + let result = sqlx::query("INSERT INTO punishmentHistory (name, target, reason, operator, type, start, end, server, extra) VALUES (?, ?, ?, ?, ?, ?, ?, ?, '')") + .bind(&body.target_name).bind(&body.target).bind(&body.reason).bind(actor_id.to_string()).bind(kind.db()) + .bind(now.timestamp_millis()).bind(end).bind(&server).execute(&mut *tx).await.map_err(db_error)?; + let id = i64::try_from(result.last_insert_id()) + .map_err(|_| "punishment ID exceeds signed 64-bit range".to_string())?; + sqlx::query("INSERT INTO punishments (id, name, target, reason, operator, type, start, end, server, extra) SELECT id, name, target, reason, operator, type, start, end, server, extra FROM punishmentHistory WHERE id = ?") + .bind(id).execute(&mut *tx).await.map_err(db_error)?; + sqlx::query("INSERT INTO events (event_id, data, handled) VALUES ('add_punishment', ?, 1)") + .bind(serde_json::json!({"id": id}).to_string()) + .execute(&mut *tx) + .await + .map_err(db_error)?; + tx.commit().await.map_err(db_error)?; + let punishment = self + .fetch_punishment(id) + .await? + .ok_or_else(|| "created punishment disappeared".to_string())?; + Ok(CreatePunishmentResponse::Status201_ThePunishmentWasCreatedSuccessfully(punishment)) + } + + async fn get_punishment_by_id( + &self, + _: &Method, + _: &Host, + _: &CookieJar, + key: &ApiKey, + path: &GetPunishmentByIdPathParams, + ) -> Result { + if !can_read(key) { + return Ok( + GetPunishmentByIdResponse::Status403_TheAuthenticatedAPIKeyLacksTheRequiredScope, + ); + } + let Ok(id) = i64::try_from(path.punishment_id) else { + return Ok(GetPunishmentByIdResponse::Status404_ThePunishmentWasNotFound); + }; + match self.fetch_punishment(id).await? { + Some(value) => Ok( + GetPunishmentByIdResponse::Status200_ThePunishmentWasRetrievedSuccessfully(value), + ), + None => Ok(GetPunishmentByIdResponse::Status404_ThePunishmentWasNotFound), + } + } + + async fn list_punishments( + &self, + _: &Method, + _: &Host, + _: &CookieJar, + key: &ApiKey, + query: &ListPunishmentsQueryParams, + ) -> Result { + if !can_read(key) { + return Ok( + graph_api::apis::punishments::ListPunishmentsResponse::Status403_TheAuthenticatedAPIKeyLacksTheRequiredScope, + ); + } + let limit = query.limit.unwrap_or(DEFAULT_LIMIT); + if !(1..=MAX_LIMIT).contains(&limit) { + return Ok(graph_api::apis::punishments::ListPunishmentsResponse::Status400_TheRequestContainsInvalidQueryParameters); + } + let kind = + match query.r#type.as_deref() { + Some(value) => match StoredPunishmentType::from_api(value) { + Some(kind) => Some(kind), + None => return Ok( + graph_api::apis::punishments::ListPunishmentsResponse::Status400_TheRequestContainsInvalidQueryParameters, + ), + }, + None => None, + }; + let cursor = + match query.cursor.as_deref() { + Some(value) => match PunishmentCursor::decode(value) { + Ok(cursor) => Some(cursor), + Err(_) => return Ok( + graph_api::apis::punishments::ListPunishmentsResponse::Status400_TheRequestContainsInvalidQueryParameters, + ), + }, + None => None, + }; + let mut builder = QueryBuilder::::new( + "SELECT h.id, h.name, h.target, h.reason, h.operator, h.type, h.start, h.end, h.server, h.extra, (p.id IS NOT NULL) AS active, u.id AS revocation_id, u.reason AS revocation_reason, u.timestamp AS revocation_timestamp, u.operator AS revocation_operator FROM punishmentHistory h LEFT JOIN punishments p ON p.id = h.id LEFT JOIN unpunish u ON u.punish_id = h.id WHERE 1=1", + ); + if let Some(target) = &query.target { + builder.push(" AND h.target = ").push_bind(target); + } + if let Some(kind) = kind { + builder.push(" AND h.type = ").push_bind(kind.db()); + } + if let Some(server) = &query.server { + builder + .push(" AND h.server = ") + .push_bind(server.to_lowercase()); + } + if let Some(active) = query.active { + if active { + builder.push(" AND p.id IS NOT NULL"); + } else { + builder.push(" AND p.id IS NULL"); + } + } + if let Some(cursor) = &cursor { + builder + .push(" AND (h.start < ") + .push_bind(cursor.value) + .push(" OR (h.start = ") + .push_bind(cursor.value) + .push(" AND h.id < ") + .push_bind(cursor.tie_breaker) + .push("))"); + } + builder + .push(" ORDER BY h.start DESC, h.id DESC LIMIT ") + .push_bind(u16::from(limit) + 1); + let mut rows = builder + .build_query_as::() + .fetch_all(self.punishments_pool()) + .await + .map_err(db_error)?; + let next_cursor = if rows.len() > usize::from(limit) { + rows.pop(); + rows.last() + .map(|row| { + PunishmentCursor { + value: row.start, + tie_breaker: row.id as u64, + } + .encode() + }) + .transpose() + .map_err(|_| "failed to encode cursor".to_string())? + } else { + None + }; + let mut items = Vec::with_capacity(rows.len()); + for row in rows { + items.push(self.record_into_api(row).await?); + } + Ok( + graph_api::apis::punishments::ListPunishmentsResponse::Status200_PunishmentsWereRetrievedSuccessfully( + ListPunishments200Response::new(items, into_nullable(next_cursor)), + ), + ) + } + + async fn update_punishment_by_id( + &self, + _: &Method, + _: &Host, + _: &CookieJar, + key: &ApiKey, + path: &UpdatePunishmentByIdPathParams, + body: &UpdatePunishmentByIdRequest, + ) -> Result { + if write_actor(key).is_none() { + return Ok( + UpdatePunishmentByIdResponse::Status403_TheAuthenticatedAPIKeyLacksTheRequiredScope, + ); + } + if body.reason.is_none() && body.seen.is_none() + || body + .reason + .as_deref() + .is_some_and(|reason| !non_empty(reason)) + { + return Ok(UpdatePunishmentByIdResponse::Status400_TheRequestBodyIsInvalid); + } + let Ok(id) = i64::try_from(path.punishment_id) else { + return Ok(UpdatePunishmentByIdResponse::Status404_TheActivePunishmentWasNotFound); + }; + let mut tx = self.punishments_pool.begin().await.map_err(db_error)?; + let current = sqlx::query_as::<_, (String, String)>( + "SELECT reason, extra FROM punishments WHERE id = ? FOR UPDATE", + ) + .bind(id) + .fetch_optional(&mut *tx) + .await + .map_err(db_error)?; + let Some((old_reason, old_extra)) = current else { + tx.rollback().await.map_err(db_error)?; + return Ok(UpdatePunishmentByIdResponse::Status404_TheActivePunishmentWasNotFound); + }; + let reason = body.reason.as_ref().unwrap_or(&old_reason); + let extra = body + .seen + .map_or(old_extra.clone(), |value| with_seen(&old_extra, value)); + sqlx::query("UPDATE punishmentHistory SET reason = ?, extra = ? WHERE id = ?") + .bind(reason) + .bind(&extra) + .bind(id) + .execute(&mut *tx) + .await + .map_err(db_error)?; + sqlx::query("UPDATE punishments SET reason = ?, extra = ? WHERE id = ?") + .bind(reason) + .bind(&extra) + .bind(id) + .execute(&mut *tx) + .await + .map_err(db_error)?; + sqlx::query( + "INSERT INTO events (event_id, data, handled) VALUES ('updated_punishment', ?, 1)", + ) + .bind(serde_json::json!({"id": id}).to_string()) + .execute(&mut *tx) + .await + .map_err(db_error)?; + tx.commit().await.map_err(db_error)?; + let value = self + .fetch_punishment(id) + .await? + .ok_or_else(|| "updated punishment disappeared".to_string())?; + Ok(UpdatePunishmentByIdResponse::Status200_ThePunishmentWasUpdatedSuccessfully(value)) + } + + async fn delete_punishment_by_id( + &self, + _: &Method, + _: &Host, + _: &CookieJar, + key: &ApiKey, + path: &DeletePunishmentByIdPathParams, + body: &DeletePunishmentByIdRequest, + ) -> Result { + let Some(actor_id) = write_actor(key) else { + return Ok( + DeletePunishmentByIdResponse::Status403_TheAuthenticatedAPIKeyLacksTheRequiredScope, + ); + }; + if !non_empty(&body.reason) { + return Ok(DeletePunishmentByIdResponse::Status400_TheRequestBodyIsInvalid); + } + let Ok(id) = i64::try_from(path.punishment_id) else { + return Ok(DeletePunishmentByIdResponse::Status404_TheActivePunishmentWasNotFound); + }; + let mut tx = self.punishments_pool.begin().await.map_err(db_error)?; + let deleted = sqlx::query("DELETE FROM punishments WHERE id = ?") + .bind(id) + .execute(&mut *tx) + .await + .map_err(db_error)? + .rows_affected(); + if deleted == 0 { + tx.rollback().await.map_err(db_error)?; + return Ok(DeletePunishmentByIdResponse::Status404_TheActivePunishmentWasNotFound); + } + sqlx::query( + "INSERT INTO unpunish (punish_id, reason, timestamp, operator) VALUES (?, ?, ?, ?)", + ) + .bind(id) + .bind(&body.reason) + .bind(Utc::now().timestamp_millis()) + .bind(actor_id.to_string()) + .execute(&mut *tx) + .await + .map_err(db_error)?; + sqlx::query( + "INSERT INTO events (event_id, data, handled) VALUES ('removed_punishment', ?, 1)", + ) + .bind(serde_json::json!({"punish_id": id}).to_string()) + .execute(&mut *tx) + .await + .map_err(db_error)?; + tx.commit().await.map_err(db_error)?; + Ok(DeletePunishmentByIdResponse::Status204_ThePunishmentWasRevokedSuccessfully) + } + + async fn list_punishment_proofs( + &self, + _: &Method, + _: &Host, + _: &CookieJar, + key: &ApiKey, + path: &ListPunishmentProofsPathParams, + ) -> Result { + if !can_read(key) { + return Ok( + ListPunishmentProofsResponse::Status403_TheAuthenticatedAPIKeyLacksTheRequiredScope, + ); + } + let Ok(id) = i64::try_from(path.punishment_id) else { + return Ok(ListPunishmentProofsResponse::Status404_ThePunishmentWasNotFound); + }; + if !self.punishment_exists(id, false).await? { + return Ok(ListPunishmentProofsResponse::Status404_ThePunishmentWasNotFound); + } + Ok( + ListPunishmentProofsResponse::Status200_ProofsWereRetrievedSuccessfully( + self.load_proofs(id).await?, + ), + ) + } + + async fn create_punishment_proof( + &self, + _: &Method, + _: &Host, + _: &CookieJar, + key: &ApiKey, + path: &CreatePunishmentProofPathParams, + body: &CreatePunishmentProofRequest, + ) -> Result { + if write_actor(key).is_none() { + return Ok(CreatePunishmentProofResponse::Status403_TheAuthenticatedAPIKeyLacksTheRequiredScope); + } + if !non_empty(&body.text) { + return Ok(CreatePunishmentProofResponse::Status400_TheRequestBodyIsInvalid); + } + let Ok(id) = i64::try_from(path.punishment_id) else { + return Ok(CreatePunishmentProofResponse::Status404_TheActivePunishmentWasNotFound); + }; + let mut tx = self.punishments_pool.begin().await.map_err(db_error)?; + if sqlx::query_scalar::<_, i64>("SELECT id FROM punishments WHERE id = ? FOR UPDATE") + .bind(id) + .fetch_optional(&mut *tx) + .await + .map_err(db_error)? + .is_none() + { + tx.rollback().await.map_err(db_error)?; + return Ok(CreatePunishmentProofResponse::Status404_TheActivePunishmentWasNotFound); + } + let result = sqlx::query("INSERT INTO proofs (punish_id, text, public) VALUES (?, ?, ?)") + .bind(id) + .bind(&body.text) + .bind(body.public) + .execute(&mut *tx) + .await + .map_err(db_error)?; + let proof_id = i64::try_from(result.last_insert_id()) + .map_err(|_| "proof ID exceeds signed 64-bit range".to_string())?; + tx.commit().await.map_err(db_error)?; + let proof = self + .fetch_proof(id, proof_id) + .await? + .ok_or_else(|| "created proof disappeared".to_string())?; + Ok(CreatePunishmentProofResponse::Status201_TheProofWasCreatedSuccessfully(proof)) + } + + async fn get_punishment_proof_by_id( + &self, + _: &Method, + _: &Host, + _: &CookieJar, + key: &ApiKey, + path: &GetPunishmentProofByIdPathParams, + ) -> Result { + if !can_read(key) { + return Ok(GetPunishmentProofByIdResponse::Status403_TheAuthenticatedAPIKeyLacksTheRequiredScope); + } + let (Ok(punishment_id), Ok(proof_id)) = ( + i64::try_from(path.punishment_id), + i64::try_from(path.proof_id), + ) else { + return Ok(GetPunishmentProofByIdResponse::Status404_ThePunishmentOrProofWasNotFound); + }; + match self.fetch_proof(punishment_id, proof_id).await? { + Some(proof) => Ok( + GetPunishmentProofByIdResponse::Status200_TheProofWasRetrievedSuccessfully(proof), + ), + None => Ok(GetPunishmentProofByIdResponse::Status404_ThePunishmentOrProofWasNotFound), + } + } + + async fn update_punishment_proof_by_id( + &self, + _: &Method, + _: &Host, + _: &CookieJar, + key: &ApiKey, + path: &UpdatePunishmentProofByIdPathParams, + body: &UpdatePunishmentProofByIdRequest, + ) -> Result { + if write_actor(key).is_none() { + return Ok(UpdatePunishmentProofByIdResponse::Status403_TheAuthenticatedAPIKeyLacksTheRequiredScope); + } + if body.text.is_none() && body.public.is_none() + || body.text.as_deref().is_some_and(|text| !non_empty(text)) + { + return Ok(UpdatePunishmentProofByIdResponse::Status400_TheRequestBodyIsInvalid); + } + let (Ok(punishment_id), Ok(proof_id)) = ( + i64::try_from(path.punishment_id), + i64::try_from(path.proof_id), + ) else { + return Ok( + UpdatePunishmentProofByIdResponse::Status404_ThePunishmentOrProofWasNotFound, + ); + }; + let current = sqlx::query_as::<_, ProofRecord>( + "SELECT id, text, public FROM proofs WHERE id = ? AND punish_id = ?", + ) + .bind(proof_id) + .bind(punishment_id) + .fetch_optional(self.punishments_pool()) + .await + .map_err(db_error)?; + let Some(current) = current else { + return Ok( + UpdatePunishmentProofByIdResponse::Status404_ThePunishmentOrProofWasNotFound, + ); + }; + sqlx::query("UPDATE proofs SET text = ?, public = ? WHERE id = ? AND punish_id = ?") + .bind(body.text.as_ref().unwrap_or(¤t.text)) + .bind(body.public.unwrap_or(current.public)) + .bind(proof_id) + .bind(punishment_id) + .execute(self.punishments_pool()) + .await + .map_err(db_error)?; + let proof = self + .fetch_proof(punishment_id, proof_id) + .await? + .ok_or_else(|| "updated proof disappeared".to_string())?; + Ok(UpdatePunishmentProofByIdResponse::Status200_TheProofWasUpdatedSuccessfully(proof)) + } + + async fn delete_punishment_proof_by_id( + &self, + _: &Method, + _: &Host, + _: &CookieJar, + key: &ApiKey, + path: &DeletePunishmentProofByIdPathParams, + ) -> Result { + if write_actor(key).is_none() { + return Ok(DeletePunishmentProofByIdResponse::Status403_TheAuthenticatedAPIKeyLacksTheRequiredScope); + } + let (Ok(punishment_id), Ok(proof_id)) = ( + i64::try_from(path.punishment_id), + i64::try_from(path.proof_id), + ) else { + return Ok( + DeletePunishmentProofByIdResponse::Status404_ThePunishmentOrProofWasNotFound, + ); + }; + let affected = sqlx::query("DELETE FROM proofs WHERE id = ? AND punish_id = ?") + .bind(proof_id) + .bind(punishment_id) + .execute(self.punishments_pool()) + .await + .map_err(db_error)? + .rows_affected(); + if affected == 0 { + Ok(DeletePunishmentProofByIdResponse::Status404_ThePunishmentOrProofWasNotFound) + } else { + Ok(DeletePunishmentProofByIdResponse::Status204_TheProofWasDeletedSuccessfully) + } + } +} + +fn db_error(error: sqlx::Error) -> String { + tracing::error!(%error, "punishments database operation failed"); + "punishments database operation failed".to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::mojang::PlayerDbClient; + use headers::Header; + + fn test_host() -> Host { + let value = http::HeaderValue::from_static("localhost"); + Host::decode(&mut std::iter::once(&value)).unwrap() + } + + #[test] + fn all_types_round_trip() { + for api in [ + "ban", + "tempBan", + "ipBan", + "tempIpBan", + "mute", + "tempMute", + "ipMute", + "tempIpMute", + "warning", + "caution", + "kick", + "note", + ] { + let kind = StoredPunishmentType::from_api(api).unwrap(); + assert_eq!(StoredPunishmentType::from_db(kind.db()), Some(kind)); + assert_eq!(kind.api(), api); + } + } + + #[test] + fn target_validation_matches_legacy_rules() { + assert!(valid_target( + StoredPunishmentType::Ban, + "550e8400-e29b-41d4-a716-446655440000" + )); + assert!(!valid_target(StoredPunishmentType::Ban, "1.1.1.1")); + assert!(valid_target(StoredPunishmentType::IpBan, "1.1.1.1")); + for ip in [ + "127.0.0.1", + "10.0.0.1", + "100.64.0.1", + "169.254.1.1", + "192.168.1.1", + "203.0.133.1", + "255.255.255.255", + ] { + assert!(!punishable_ip(ip), "{ip}"); + } + assert!(punishable_ip("2001:4860:4860::8888")); + assert!(!punishable_ip("not-an-ip")); + } + + #[test] + fn expiry_rules_and_seen_flag_are_strict() { + let now = Utc::now(); + assert_eq!( + end_millis(StoredPunishmentType::Ban, &Nullable::Null, now), + Some(-1) + ); + assert!( + end_millis( + StoredPunishmentType::TempBan, + &Nullable::Present(now + chrono::Duration::seconds(1)), + now + ) + .is_some() + ); + assert!(end_millis(StoredPunishmentType::TempBan, &Nullable::Null, now).is_none()); + assert!( + end_millis( + StoredPunishmentType::Ban, + &Nullable::Present(now + chrono::Duration::seconds(1)), + now + ) + .is_none() + ); + assert_eq!(with_seen("OTHER", true), "OTHER,SEEN"); + assert_eq!(with_seen("OTHER,SEEN", false), "OTHER"); + assert!(seen("OTHER,SEEN")); + } + + async fn cleanup_targets(pool: &sqlx::MySqlPool, targets: &[String]) { + for target in targets { + let ids = + sqlx::query_scalar::<_, i64>("SELECT id FROM punishmentHistory WHERE target = ?") + .bind(target) + .fetch_all(pool) + .await + .unwrap(); + for id in ids { + sqlx::query("DELETE FROM proofs WHERE punish_id = ?") + .bind(id) + .execute(pool) + .await + .unwrap(); + sqlx::query("DELETE FROM unpunish WHERE punish_id = ?") + .bind(id) + .execute(pool) + .await + .unwrap(); + sqlx::query("DELETE FROM punishments WHERE id = ?") + .bind(id) + .execute(pool) + .await + .unwrap(); + sqlx::query("DELETE FROM punishmentHistory WHERE id = ?") + .bind(id) + .execute(pool) + .await + .unwrap(); + for event_id in ["add_punishment", "updated_punishment", "removed_punishment"] { + sqlx::query("DELETE FROM events WHERE event_id = ? AND (data = ? OR data = ?)") + .bind(event_id) + .bind(serde_json::json!({"id": id}).to_string()) + .bind(serde_json::json!({"punish_id": id}).to_string()) + .execute(pool) + .await + .unwrap(); + } + } + } + } + + #[tokio::test] + async fn mariadb_crud_cursor_events_and_rollback() { + let Ok(database_url) = std::env::var("PUNISHMENTS_TEST_DATABASE_URL") else { + eprintln!( + "PUNISHMENTS_TEST_DATABASE_URL is not set; skipping MariaDB integration test" + ); + return; + }; + let pool = sqlx::MySqlPool::connect(&database_url).await.unwrap(); + let api = Api::new( + pool.clone(), + pool.clone(), + None, + PlayerDbClient::new().unwrap(), + ); + let actor_id = Uuid::new_v4(); + let key = ApiKey::new( + "punishment integration key".to_string(), + "integration".to_string(), + vec![ + "punishments:read".to_string(), + "punishments:write".to_string(), + ], + Utc::now(), + Nullable::Null, + Nullable::Present(actor_id), + ); + let target_one = Uuid::new_v4().to_string(); + let target_two = Uuid::new_v4().to_string(); + let rollback_target = Uuid::new_v4().to_string(); + let targets = vec![ + target_one.clone(), + target_two.clone(), + rollback_target.clone(), + ]; + cleanup_targets(&pool, &targets).await; + sqlx::query("DROP TRIGGER IF EXISTS graph_test_fail_events") + .execute(&pool) + .await + .unwrap(); + + let host = test_host(); + let cookies = CookieJar::new(); + let create = |target: String| { + CreatePunishmentRequest::new( + "IntegrationTarget".to_string(), + target, + "ban".to_string(), + "integration reason".to_string(), + Nullable::Null, + "TeStServer".to_string(), + ) + }; + + let first = match api + .create_punishment( + &Method::POST, + &host, + &cookies, + &key, + &create(target_one.clone()), + ) + .await + .unwrap() + { + CreatePunishmentResponse::Status201_ThePunishmentWasCreatedSuccessfully(value) => value, + response => panic!("unexpected response: {response:?}"), + }; + assert!(first.active); + assert_eq!(first.server, "testserver"); + assert_eq!(first.actor_id, actor_id); + assert_eq!(first.expires_at, Nullable::Null); + + assert!(matches!( + api.create_punishment( + &Method::POST, + &host, + &cookies, + &key, + &create(target_one.clone()) + ) + .await + .unwrap(), + CreatePunishmentResponse::Status409_AConflictingActivePunishmentAlreadyExists + )); + + std::thread::sleep(std::time::Duration::from_millis(2)); + let second = match api + .create_punishment( + &Method::POST, + &host, + &cookies, + &key, + &create(target_two.clone()), + ) + .await + .unwrap() + { + CreatePunishmentResponse::Status201_ThePunishmentWasCreatedSuccessfully(value) => value, + response => panic!("unexpected response: {response:?}"), + }; + + let page_one = match api + .list_punishments( + &Method::GET, + &host, + &cookies, + &key, + &ListPunishmentsQueryParams { + target: None, + r#type: Some("ban".to_string()), + server: Some("TESTSERVER".to_string()), + active: Some(true), + cursor: None, + limit: Some(1), + }, + ) + .await + .unwrap() + { + graph_api::apis::punishments::ListPunishmentsResponse::Status200_PunishmentsWereRetrievedSuccessfully(value) => value, + response => panic!("unexpected response: {response:?}"), + }; + assert_eq!(page_one.items.len(), 1); + let cursor = match page_one.next_cursor { + Nullable::Present(cursor) => cursor, + Nullable::Null => panic!("expected a next cursor"), + }; + let page_two = match api + .list_punishments( + &Method::GET, + &host, + &cookies, + &key, + &ListPunishmentsQueryParams { + target: None, + r#type: Some("ban".to_string()), + server: Some("testserver".to_string()), + active: Some(true), + cursor: Some(cursor), + limit: Some(1), + }, + ) + .await + .unwrap() + { + graph_api::apis::punishments::ListPunishmentsResponse::Status200_PunishmentsWereRetrievedSuccessfully(value) => value, + response => panic!("unexpected response: {response:?}"), + }; + assert_eq!(page_two.items.len(), 1); + assert_ne!(page_one.items[0].id, page_two.items[0].id); + + let mut update = UpdatePunishmentByIdRequest::new(); + update.reason = Some("updated integration reason".to_string()); + update.seen = Some(true); + let updated = match api + .update_punishment_by_id( + &Method::PATCH, + &host, + &cookies, + &key, + &UpdatePunishmentByIdPathParams { + punishment_id: first.id, + }, + &update, + ) + .await + .unwrap() + { + UpdatePunishmentByIdResponse::Status200_ThePunishmentWasUpdatedSuccessfully(value) => { + value + } + response => panic!("unexpected response: {response:?}"), + }; + assert_eq!(updated.reason, "updated integration reason"); + assert!(updated.seen); + let copies = sqlx::query_as::<_, (String, String)>( + "SELECT reason, extra FROM punishmentHistory WHERE id = ? UNION ALL SELECT reason, extra FROM punishments WHERE id = ?", + ) + .bind(first.id as i64) + .bind(first.id as i64) + .fetch_all(&pool) + .await + .unwrap(); + assert_eq!( + copies, + vec![("updated integration reason".to_string(), "SEEN".to_string()); 2] + ); + + let proof = match api + .create_punishment_proof( + &Method::POST, + &host, + &cookies, + &key, + &CreatePunishmentProofPathParams { + punishment_id: first.id, + }, + &CreatePunishmentProofRequest::new("integration proof".to_string(), false), + ) + .await + .unwrap() + { + CreatePunishmentProofResponse::Status201_TheProofWasCreatedSuccessfully(value) => value, + response => panic!("unexpected response: {response:?}"), + }; + + assert!(matches!( + api.delete_punishment_by_id( + &Method::DELETE, + &host, + &cookies, + &key, + &DeletePunishmentByIdPathParams { + punishment_id: first.id + }, + &DeletePunishmentByIdRequest::new("integration revocation".to_string()), + ) + .await + .unwrap(), + DeletePunishmentByIdResponse::Status204_ThePunishmentWasRevokedSuccessfully + )); + let revoked = match api + .get_punishment_by_id( + &Method::GET, + &host, + &cookies, + &key, + &GetPunishmentByIdPathParams { + punishment_id: first.id, + }, + ) + .await + .unwrap() + { + GetPunishmentByIdResponse::Status200_ThePunishmentWasRetrievedSuccessfully(value) => { + value + } + response => panic!("unexpected response: {response:?}"), + }; + assert!(!revoked.active); + match revoked.revocation { + Nullable::Present(revocation) => { + assert_eq!(revocation.reason, "integration revocation"); + assert_eq!(revocation.actor_id, actor_id); + } + Nullable::Null => panic!("expected revocation"), + } + + let mut proof_update = UpdatePunishmentProofByIdRequest::new(); + proof_update.text = Some("updated integration proof".to_string()); + proof_update.public = Some(true); + assert!(matches!( + api.update_punishment_proof_by_id( + &Method::PATCH, + &host, + &cookies, + &key, + &UpdatePunishmentProofByIdPathParams { + punishment_id: first.id, + proof_id: proof.id + }, + &proof_update, + ) + .await + .unwrap(), + UpdatePunishmentProofByIdResponse::Status200_TheProofWasUpdatedSuccessfully(_) + )); + assert!(matches!( + api.create_punishment_proof( + &Method::POST, + &host, + &cookies, + &key, + &CreatePunishmentProofPathParams { + punishment_id: first.id + }, + &CreatePunishmentProofRequest::new("too late".to_string(), false), + ) + .await + .unwrap(), + CreatePunishmentProofResponse::Status404_TheActivePunishmentWasNotFound + )); + assert!(matches!( + api.delete_punishment_proof_by_id( + &Method::DELETE, + &host, + &cookies, + &key, + &DeletePunishmentProofByIdPathParams { + punishment_id: first.id, + proof_id: proof.id + }, + ) + .await + .unwrap(), + DeletePunishmentProofByIdResponse::Status204_TheProofWasDeletedSuccessfully + )); + + let events = sqlx::query_as::<_, (String, String, bool)>( + "SELECT event_id, data, handled FROM events WHERE data IN (?, ?, ?) ORDER BY id", + ) + .bind(serde_json::json!({"id": first.id}).to_string()) + .bind(serde_json::json!({"punish_id": first.id}).to_string()) + .bind(serde_json::json!({"id": second.id}).to_string()) + .fetch_all(&pool) + .await + .unwrap(); + assert!(events.iter().any(|event| event.0 == "add_punishment" + && event.1 == serde_json::json!({"id": first.id}).to_string() + && event.2)); + assert!(events.iter().any(|event| event.0 == "updated_punishment" + && event.1 == serde_json::json!({"id": first.id}).to_string() + && event.2)); + assert!(events.iter().any(|event| event.0 == "removed_punishment" + && event.1 == serde_json::json!({"punish_id": first.id}).to_string() + && event.2)); + + sqlx::query("CREATE TRIGGER graph_test_fail_events BEFORE INSERT ON events FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'graph rollback test'") + .execute(&pool).await.unwrap(); + let rollback_result = api + .create_punishment( + &Method::POST, + &host, + &cookies, + &key, + &create(rollback_target.clone()), + ) + .await; + sqlx::query("DROP TRIGGER graph_test_fail_events") + .execute(&pool) + .await + .unwrap(); + assert!(rollback_result.is_err()); + let rollback_rows = + sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM punishmentHistory WHERE target = ?") + .bind(&rollback_target) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(rollback_rows, 0); + + cleanup_targets(&pool, &targets).await; + } +} diff --git a/server/app/src/auth/credentials.rs b/server/app/src/auth/credentials.rs index 0f72330..b4084ff 100644 --- a/server/app/src/auth/credentials.rs +++ b/server/app/src/auth/credentials.rs @@ -1,5 +1,5 @@ -use base64::engine::general_purpose::URL_SAFE_NO_PAD; use base64::Engine; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; use sha2::{Digest, Sha256}; use std::error::Error; use std::fmt::{Debug, Display, Formatter}; diff --git a/server/app/src/auth/provider.rs b/server/app/src/auth/provider.rs index 2a4ab97..8dcec56 100644 --- a/server/app/src/auth/provider.rs +++ b/server/app/src/auth/provider.rs @@ -1,4 +1,4 @@ -use crate::api::Api; +use crate::api::{Api, into_nullable}; use crate::auth::credentials::ApiKeyCredentials; use async_trait::async_trait; use axum::http::HeaderMap; @@ -16,6 +16,7 @@ struct ApiKeyRecord { secret_digest: Vec, created_at: DateTime, expires_at: Option>, + player_id: Option, } #[async_trait] @@ -40,9 +41,10 @@ impl ApiAuthBasic for Api { let api_key = match sqlx::query_as::<_, ApiKeyRecord>( r#" - SELECT name, public_id, secret_digest, created_at, expires_at - FROM api_keys - WHERE public_id = ? + SELECT k.name, k.public_id, k.secret_digest, k.created_at, k.expires_at, p.player_id + FROM api_keys k + LEFT JOIN api_key_players p ON p.api_key_public_id = k.public_id + WHERE k.public_id = ? "#, ) .bind(credentials.public_id()) @@ -91,6 +93,7 @@ impl ApiAuthBasic for Api { scopes, api_key.created_at, expires_at, + into_nullable(api_key.player_id), )) } } diff --git a/server/app/src/auth/scope.rs b/server/app/src/auth/scope.rs index c5d207b..4914307 100644 --- a/server/app/src/auth/scope.rs +++ b/server/app/src/auth/scope.rs @@ -59,6 +59,7 @@ mod tests { scopes.iter().map(ToString::to_string).collect(), Utc::now(), Nullable::Null, + Nullable::Null, ) } } diff --git a/server/app/src/main.rs b/server/app/src/main.rs index 4288657..172aec1 100644 --- a/server/app/src/main.rs +++ b/server/app/src/main.rs @@ -29,8 +29,18 @@ async fn main() { .await .expect("failed to run database migrations"); + let punishments_database_url = + env::var("PUNISHMENTS_DATABASE_URL").expect("PUNISHMENTS_DATABASE_URL must be set"); + let punishments_pool = MySqlPool::connect(&punishments_database_url) + .await + .expect("failed to connect punishments database"); + validate_punishments_database(&punishments_pool) + .await + .expect("punishments database schema is incompatible"); + let api = Api::new( pool, + punishments_pool, ObjectStorage::from_env().await, PlayerDbClient::new().expect("failed to create PlayerDB client"), ); @@ -57,3 +67,16 @@ async fn main() { .await .expect("graph-server failed"); } + +async fn validate_punishments_database(pool: &MySqlPool) -> Result<(), sqlx::Error> { + for query in [ + "SELECT 1 FROM `punishmentHistory` LIMIT 1", + "SELECT 1 FROM `punishments` LIMIT 1", + "SELECT 1 FROM `unpunish` LIMIT 1", + "SELECT 1 FROM `proofs` LIMIT 1", + "SELECT 1 FROM `events` LIMIT 1", + ] { + sqlx::query(query).fetch_optional(pool).await?; + } + Ok(()) +} diff --git a/server/compose.yaml b/server/compose.yaml index 5c25d8e..4ca04d2 100644 --- a/server/compose.yaml +++ b/server/compose.yaml @@ -7,6 +7,7 @@ services: CARGO_HOME: /cargo CARGO_TARGET_DIR: /cargo-target DATABASE_URL: mysql://graph:graph@db:3306/graph + PUNISHMENTS_DATABASE_URL: ${PUNISHMENTS_DATABASE_URL:?PUNISHMENTS_DATABASE_URL must be set} GRAPH_SERVER_ADDR: 0.0.0.0:3000 GRAPH_BOOTSTRAP_API_KEY: ${GRAPH_BOOTSTRAP_API_KEY:-} R2_ACCESS_KEY_ID: ${R2_ACCESS_KEY_ID:-} diff --git a/server/migrations/20260721000000_add_api_key_actor_and_punishment_scopes.sql b/server/migrations/20260721000000_add_api_key_actor_and_punishment_scopes.sql new file mode 100644 index 0000000..87f4ca1 --- /dev/null +++ b/server/migrations/20260721000000_add_api_key_actor_and_punishment_scopes.sql @@ -0,0 +1,35 @@ +CREATE TABLE api_key_players ( + api_key_public_id VARCHAR(64) NOT NULL, + player_id BINARY(16) NOT NULL, + PRIMARY KEY (api_key_public_id), + KEY api_key_players_player_id_idx (player_id), + CONSTRAINT api_key_players_api_key_fk + FOREIGN KEY (api_key_public_id) + REFERENCES api_keys (public_id) + ON DELETE CASCADE, + CONSTRAINT api_key_players_player_fk + FOREIGN KEY (player_id) + REFERENCES players (id) + ON DELETE CASCADE +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_unicode_ci; + +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', + 'patch-notes:read', + 'patch-notes:write', + 'players:read', + 'players:read-details', + 'players:write', + 'punishments:read', + 'punishments:write' + ) + ); From a4111c400e8ba001562bc0903ae2be8d7fe58bba Mon Sep 17 00:00:00 2001 From: acrylic-style Date: Tue, 21 Jul 2026 22:48:52 +0900 Subject: [PATCH 2/4] fix: normalize and massive queries --- server/app/src/api/punishments.rs | 127 ++++++++++++++++++++++++------ 1 file changed, 102 insertions(+), 25 deletions(-) diff --git a/server/app/src/api/punishments.rs b/server/app/src/api/punishments.rs index 0acdf8a..efd97ea 100644 --- a/server/app/src/api/punishments.rs +++ b/server/app/src/api/punishments.rs @@ -10,7 +10,7 @@ use graph_api::types::Nullable; use headers::Host; use http::Method; use sqlx::{FromRow, MySql, QueryBuilder}; -use std::net::IpAddr; +use std::{collections::BTreeMap, net::IpAddr}; use uuid::Uuid; const DEFAULT_LIMIT: u8 = 20; @@ -145,6 +145,14 @@ struct ProofRecord { public: bool, } +#[derive(Debug, FromRow)] +struct PunishmentProofRecord { + punish_id: i64, + id: i64, + text: String, + public: bool, +} + fn can_read(key: &ApiKey) -> bool { key.has_scope(&ApiKeyScope::PunishmentsColonRead) } @@ -185,11 +193,18 @@ fn punishable_ip(value: &str) -> bool { || a >= 240) } -fn valid_target(kind: StoredPunishmentType, target: &str) -> bool { +fn normalize_target(kind: StoredPunishmentType, target: &str) -> Option { if kind.ip_based() { - punishable_ip(target) + punishable_ip(target).then(|| { + target + .parse::() + .expect("validated IP address") + .to_string() + }) } else { - Uuid::parse_str(target).is_ok() + Uuid::parse_str(target) + .ok() + .map(|target| target.to_string()) } } @@ -242,6 +257,17 @@ impl ProofRecord { } } +impl PunishmentProofRecord { + fn into_api(self) -> Result { + ProofRecord { + id: self.id, + text: self.text, + public: self.public, + } + .into_api() + } +} + impl Api { async fn load_proofs( &self, @@ -259,9 +285,44 @@ impl Api { .collect() } - async fn record_into_api( + async fn load_proofs_for_punishments( + &self, + punishment_ids: &[i64], + ) -> Result>, String> { + if punishment_ids.is_empty() { + return Ok(BTreeMap::new()); + } + + let mut query = QueryBuilder::::new( + "SELECT punish_id, id, text, public FROM proofs WHERE punish_id IN (", + ); + let mut separated = query.separated(", "); + for punishment_id in punishment_ids { + separated.push_bind(punishment_id); + } + separated.push_unseparated(") ORDER BY punish_id, id"); + + let rows = query + .build_query_as::() + .fetch_all(self.punishments_pool()) + .await + .map_err(db_error)?; + let mut proofs = + BTreeMap::>::new(); + for row in rows { + let punishment_id = row.punish_id; + proofs + .entry(punishment_id) + .or_default() + .push(row.into_api()?); + } + Ok(proofs) + } + + fn record_into_api( &self, record: PunishmentRecord, + proofs: Vec, ) -> Result { let kind = StoredPunishmentType::from_db(&record.r#type) .ok_or_else(|| format!("unknown punishment type in database: {}", record.r#type))?; @@ -289,7 +350,6 @@ impl Api { } _ => Nullable::Null, }; - let proofs = self.load_proofs(record.id).await?; Ok(ListPunishments200ResponseItemsInner::new( row_id(record.id)?, record.name, @@ -315,7 +375,10 @@ impl Api { "SELECT h.id, h.name, h.target, h.reason, h.operator, h.type, h.start, h.end, h.server, h.extra, (p.id IS NOT NULL) AS active, u.id AS revocation_id, u.reason AS revocation_reason, u.timestamp AS revocation_timestamp, u.operator AS revocation_operator FROM punishmentHistory h LEFT JOIN punishments p ON p.id = h.id LEFT JOIN unpunish u ON u.punish_id = h.id WHERE h.id = ? ORDER BY u.id DESC LIMIT 1" ).bind(id).fetch_optional(self.punishments_pool()).await.map_err(db_error)?; match record { - Some(record) => self.record_into_api(record).await.map(Some), + Some(record) => { + let proofs = self.load_proofs(record.id).await?; + self.record_into_api(record, proofs).map(Some) + } None => Ok(None), } } @@ -377,17 +440,16 @@ impl Punishments for Api { let Some(end) = end_millis(kind, &body.expires_at, now) else { return Ok(CreatePunishmentResponse::Status400_TheRequestBodyIsInvalid); }; - if !non_empty(&body.target_name) - || !non_empty(&body.reason) - || !non_empty(&body.server) - || !valid_target(kind, &body.target) - { + let Some(target) = normalize_target(kind, &body.target) else { + return Ok(CreatePunishmentResponse::Status400_TheRequestBodyIsInvalid); + }; + if !non_empty(&body.target_name) || !non_empty(&body.reason) || !non_empty(&body.server) { return Ok(CreatePunishmentResponse::Status400_TheRequestBodyIsInvalid); } let server = body.server.to_lowercase(); let mut tx = self.punishments_pool.begin().await.map_err(db_error)?; let conflict = sqlx::query_scalar::<_, i64>("SELECT id FROM punishments WHERE target = ? AND type = ? AND server = ? LIMIT 1 FOR UPDATE") - .bind(&body.target).bind(kind.db()).bind(&server).fetch_optional(&mut *tx).await.map_err(db_error)?; + .bind(&target).bind(kind.db()).bind(&server).fetch_optional(&mut *tx).await.map_err(db_error)?; if conflict.is_some() { tx.rollback().await.map_err(db_error)?; return Ok( @@ -395,7 +457,7 @@ impl Punishments for Api { ); } let result = sqlx::query("INSERT INTO punishmentHistory (name, target, reason, operator, type, start, end, server, extra) VALUES (?, ?, ?, ?, ?, ?, ?, ?, '')") - .bind(&body.target_name).bind(&body.target).bind(&body.reason).bind(actor_id.to_string()).bind(kind.db()) + .bind(&body.target_name).bind(&target).bind(&body.reason).bind(actor_id.to_string()).bind(kind.db()) .bind(now.timestamp_millis()).bind(end).bind(&server).execute(&mut *tx).await.map_err(db_error)?; let id = i64::try_from(result.last_insert_id()) .map_err(|_| "punishment ID exceeds signed 64-bit range".to_string())?; @@ -529,9 +591,12 @@ impl Punishments for Api { } else { None }; + let punishment_ids = rows.iter().map(|row| row.id).collect::>(); + let mut proofs = self.load_proofs_for_punishments(&punishment_ids).await?; let mut items = Vec::with_capacity(rows.len()); for row in rows { - items.push(self.record_into_api(row).await?); + let row_proofs = proofs.remove(&row.id).unwrap_or_default(); + items.push(self.record_into_api(row, row_proofs)?); } Ok( graph_api::apis::punishments::ListPunishmentsResponse::Status200_PunishmentsWereRetrievedSuccessfully( @@ -885,13 +950,23 @@ mod tests { } #[test] - fn target_validation_matches_legacy_rules() { - assert!(valid_target( - StoredPunishmentType::Ban, - "550e8400-e29b-41d4-a716-446655440000" - )); - assert!(!valid_target(StoredPunishmentType::Ban, "1.1.1.1")); - assert!(valid_target(StoredPunishmentType::IpBan, "1.1.1.1")); + fn target_validation_matches_legacy_rules_and_normalizes_values() { + assert_eq!( + normalize_target( + StoredPunishmentType::Ban, + "550E8400E29B41D4A716446655440000" + ), + Some("550e8400-e29b-41d4-a716-446655440000".to_string()) + ); + assert_eq!(normalize_target(StoredPunishmentType::Ban, "1.1.1.1"), None); + assert_eq!( + normalize_target(StoredPunishmentType::IpBan, "1.1.1.1"), + Some("1.1.1.1".to_string()) + ); + assert_eq!( + normalize_target(StoredPunishmentType::IpBan, "2001:4860:4860:0:0:0:0:8888"), + Some("2001:4860:4860::8888".to_string()) + ); for ip in [ "127.0.0.1", "10.0.0.1", @@ -1286,14 +1361,16 @@ mod tests { .fetch_all(&pool) .await .unwrap(); + let punishment_event_data = serde_json::json!({"id": first.id}).to_string(); + let removal_event_data = serde_json::json!({"punish_id": first.id}).to_string(); assert!(events.iter().any(|event| event.0 == "add_punishment" - && event.1 == serde_json::json!({"id": first.id}).to_string() + && event.1 == punishment_event_data && event.2)); assert!(events.iter().any(|event| event.0 == "updated_punishment" - && event.1 == serde_json::json!({"id": first.id}).to_string() + && event.1 == punishment_event_data && event.2)); assert!(events.iter().any(|event| event.0 == "removed_punishment" - && event.1 == serde_json::json!({"punish_id": first.id}).to_string() + && event.1 == removal_event_data && event.2)); sqlx::query("CREATE TRIGGER graph_test_fail_events BEFORE INSERT ON events FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'graph rollback test'") From df8ab0d6510285593cfece4b54b4e43e6406ea16 Mon Sep 17 00:00:00 2001 From: acrylic-style Date: Tue, 21 Jul 2026 23:08:13 +0900 Subject: [PATCH 3/4] chore: fix styling of openapi yaml files --- .../schemas/create-proof-request.yaml | 5 +- .../schemas/create-punishment-request.yaml | 13 ++++- .../schemas/list-punishments-response.yaml | 9 ++- openapi/components/schemas/proof.yaml | 7 ++- openapi/components/schemas/punishment.yaml | 31 +++++++++- openapi/components/schemas/revocation.yaml | 9 ++- .../schemas/revoke-punishment-request.yaml | 3 +- .../schemas/update-proof-request.yaml | 1 + .../schemas/update-punishment-request.yaml | 1 + .../punishments/punishment-proofs-by-id.yaml | 51 +++++++++++------ .../paths/punishments/punishment-proofs.yaml | 33 +++++++---- .../paths/punishments/punishments-by-id.yaml | 54 ++++++++++++------ openapi/paths/punishments/punishments.yaml | 56 +++++++++++++------ 13 files changed, 202 insertions(+), 71 deletions(-) diff --git a/openapi/components/schemas/create-proof-request.yaml b/openapi/components/schemas/create-proof-request.yaml index aeab87d..ff877fc 100644 --- a/openapi/components/schemas/create-proof-request.yaml +++ b/openapi/components/schemas/create-proof-request.yaml @@ -1,8 +1,11 @@ type: object additionalProperties: false -required: [text, public] +required: + - text + - public properties: text: $ref: "./proof.yaml#/properties/text" + public: $ref: "./proof.yaml#/properties/public" diff --git a/openapi/components/schemas/create-punishment-request.yaml b/openapi/components/schemas/create-punishment-request.yaml index 54d9036..15b7d4f 100644 --- a/openapi/components/schemas/create-punishment-request.yaml +++ b/openapi/components/schemas/create-punishment-request.yaml @@ -1,16 +1,27 @@ type: object additionalProperties: false -required: [targetName, target, type, reason, expiresAt, server] +required: + - targetName + - target + - type + - reason + - expiresAt + - server properties: targetName: $ref: "./punishment.yaml#/properties/targetName" + target: $ref: "./punishment.yaml#/properties/target" + type: $ref: "../../openapi.yaml#/components/schemas/PunishmentType" + reason: $ref: "./punishment.yaml#/properties/reason" + expiresAt: $ref: "./punishment.yaml#/properties/expiresAt" + server: $ref: "./punishment.yaml#/properties/server" diff --git a/openapi/components/schemas/list-punishments-response.yaml b/openapi/components/schemas/list-punishments-response.yaml index 8d2a7f5..c7cdb8e 100644 --- a/openapi/components/schemas/list-punishments-response.yaml +++ b/openapi/components/schemas/list-punishments-response.yaml @@ -1,9 +1,14 @@ type: object -required: [items, nextCursor] +required: + - items + - nextCursor properties: items: type: array items: $ref: "../../openapi.yaml#/components/schemas/Punishment" + nextCursor: - type: [string, "null"] + type: + - string + - "null" diff --git a/openapi/components/schemas/proof.yaml b/openapi/components/schemas/proof.yaml index a1b431e..9330e9e 100644 --- a/openapi/components/schemas/proof.yaml +++ b/openapi/components/schemas/proof.yaml @@ -1,10 +1,15 @@ type: object -required: [id, text, public] +required: + - id + - text + - public properties: id: $ref: "../../openapi.yaml#/components/schemas/ProofId" + text: type: string minLength: 1 + public: type: boolean diff --git a/openapi/components/schemas/punishment.yaml b/openapi/components/schemas/punishment.yaml index b91daea..240b906 100644 --- a/openapi/components/schemas/punishment.yaml +++ b/openapi/components/schemas/punishment.yaml @@ -1,41 +1,68 @@ type: object -required: [id, targetName, target, type, reason, actorId, createdAt, expiresAt, server, seen, active, proofs, revocation] +required: + - id + - targetName + - target + - type + - reason + - actorId + - createdAt + - expiresAt + - server + - seen + - active + - proofs + - revocation properties: id: $ref: "../../openapi.yaml#/components/schemas/PunishmentId" + targetName: type: string minLength: 1 + target: type: string minLength: 1 + type: $ref: "../../openapi.yaml#/components/schemas/PunishmentType" + reason: type: string minLength: 1 + actorId: type: string format: uuid + createdAt: type: string format: date-time readOnly: true + expiresAt: - type: [string, "null"] + type: + - string + - "null" format: date-time + server: type: string minLength: 1 + seen: type: boolean + active: type: boolean readOnly: true + proofs: type: array items: $ref: "../../openapi.yaml#/components/schemas/Proof" + revocation: oneOf: - $ref: "../../openapi.yaml#/components/schemas/Revocation" diff --git a/openapi/components/schemas/revocation.yaml b/openapi/components/schemas/revocation.yaml index 7ce2cb6..acde24a 100644 --- a/openapi/components/schemas/revocation.yaml +++ b/openapi/components/schemas/revocation.yaml @@ -1,17 +1,24 @@ type: object -required: [id, reason, actorId, createdAt] +required: + - id + - reason + - actorId + - createdAt properties: id: type: integer format: int64 minimum: 1 readOnly: true + reason: type: string minLength: 1 + actorId: type: string format: uuid + createdAt: type: string format: date-time diff --git a/openapi/components/schemas/revoke-punishment-request.yaml b/openapi/components/schemas/revoke-punishment-request.yaml index 34ad1aa..e70a3b6 100644 --- a/openapi/components/schemas/revoke-punishment-request.yaml +++ b/openapi/components/schemas/revoke-punishment-request.yaml @@ -1,6 +1,7 @@ type: object additionalProperties: false -required: [reason] +required: + - reason properties: reason: type: string diff --git a/openapi/components/schemas/update-proof-request.yaml b/openapi/components/schemas/update-proof-request.yaml index 1d7573b..806db60 100644 --- a/openapi/components/schemas/update-proof-request.yaml +++ b/openapi/components/schemas/update-proof-request.yaml @@ -5,5 +5,6 @@ properties: text: type: string minLength: 1 + public: type: boolean diff --git a/openapi/components/schemas/update-punishment-request.yaml b/openapi/components/schemas/update-punishment-request.yaml index 7544a4a..342cb8d 100644 --- a/openapi/components/schemas/update-punishment-request.yaml +++ b/openapi/components/schemas/update-punishment-request.yaml @@ -5,5 +5,6 @@ properties: reason: type: string minLength: 1 + seen: type: boolean diff --git a/openapi/paths/punishments/punishment-proofs-by-id.yaml b/openapi/paths/punishments/punishment-proofs-by-id.yaml index f7e64ab..56bcbbd 100644 --- a/openapi/paths/punishments/punishment-proofs-by-id.yaml +++ b/openapi/paths/punishments/punishment-proofs-by-id.yaml @@ -1,9 +1,11 @@ get: summary: Get a punishment proof operationId: getPunishmentProofById - tags: [punishments] + tags: + - punishments security: - - ApiKeyAuth: ["punishments:read"] + - ApiKeyAuth: + - punishments:read parameters: - $ref: "../../openapi.yaml#/components/parameters/PunishmentId" - $ref: "../../openapi.yaml#/components/parameters/ProofId" @@ -14,16 +16,21 @@ get: application/json: schema: $ref: "../../openapi.yaml#/components/schemas/Proof" - "401": { $ref: "../../openapi.yaml#/components/responses/Unauthorized" } - "403": { $ref: "../../openapi.yaml#/components/responses/Forbidden" } - "404": { description: The punishment or proof was not found. } + "401": + $ref: "../../openapi.yaml#/components/responses/Unauthorized" + "403": + $ref: "../../openapi.yaml#/components/responses/Forbidden" + "404": + description: The punishment or proof was not found. patch: summary: Update a punishment proof operationId: updatePunishmentProofById - tags: [punishments] + tags: + - punishments security: - - ApiKeyAuth: ["punishments:write"] + - ApiKeyAuth: + - punishments:write parameters: - $ref: "../../openapi.yaml#/components/parameters/PunishmentId" - $ref: "../../openapi.yaml#/components/parameters/ProofId" @@ -40,22 +47,32 @@ patch: application/json: schema: $ref: "../../openapi.yaml#/components/schemas/Proof" - "400": { description: The request body is invalid. } - "401": { $ref: "../../openapi.yaml#/components/responses/Unauthorized" } - "403": { $ref: "../../openapi.yaml#/components/responses/Forbidden" } - "404": { description: The punishment or proof was not found. } + "400": + description: The request body is invalid. + "401": + $ref: "../../openapi.yaml#/components/responses/Unauthorized" + "403": + $ref: "../../openapi.yaml#/components/responses/Forbidden" + "404": + description: The punishment or proof was not found. delete: summary: Delete a punishment proof operationId: deletePunishmentProofById - tags: [punishments] + tags: + - punishments security: - - ApiKeyAuth: ["punishments:write"] + - ApiKeyAuth: + - punishments:write parameters: - $ref: "../../openapi.yaml#/components/parameters/PunishmentId" - $ref: "../../openapi.yaml#/components/parameters/ProofId" responses: - "204": { description: The proof was deleted successfully. } - "401": { $ref: "../../openapi.yaml#/components/responses/Unauthorized" } - "403": { $ref: "../../openapi.yaml#/components/responses/Forbidden" } - "404": { description: The punishment or proof was not found. } + "204": + description: The proof was deleted successfully. + "401": + $ref: "../../openapi.yaml#/components/responses/Unauthorized" + "403": + $ref: "../../openapi.yaml#/components/responses/Forbidden" + "404": + description: The punishment or proof was not found. diff --git a/openapi/paths/punishments/punishment-proofs.yaml b/openapi/paths/punishments/punishment-proofs.yaml index 747caf4..fe91e7f 100644 --- a/openapi/paths/punishments/punishment-proofs.yaml +++ b/openapi/paths/punishments/punishment-proofs.yaml @@ -1,9 +1,11 @@ get: summary: List punishment proofs operationId: listPunishmentProofs - tags: [punishments] + tags: + - punishments security: - - ApiKeyAuth: ["punishments:read"] + - ApiKeyAuth: + - punishments:read parameters: - $ref: "../../openapi.yaml#/components/parameters/PunishmentId" responses: @@ -15,16 +17,21 @@ get: type: array items: $ref: "../../openapi.yaml#/components/schemas/Proof" - "401": { $ref: "../../openapi.yaml#/components/responses/Unauthorized" } - "403": { $ref: "../../openapi.yaml#/components/responses/Forbidden" } - "404": { description: The punishment was not found. } + "401": + $ref: "../../openapi.yaml#/components/responses/Unauthorized" + "403": + $ref: "../../openapi.yaml#/components/responses/Forbidden" + "404": + description: The punishment was not found. post: summary: Add proof to an active punishment operationId: createPunishmentProof - tags: [punishments] + tags: + - punishments security: - - ApiKeyAuth: ["punishments:write"] + - ApiKeyAuth: + - punishments:write parameters: - $ref: "../../openapi.yaml#/components/parameters/PunishmentId" requestBody: @@ -40,7 +47,11 @@ post: application/json: schema: $ref: "../../openapi.yaml#/components/schemas/Proof" - "400": { description: The request body is invalid. } - "401": { $ref: "../../openapi.yaml#/components/responses/Unauthorized" } - "403": { $ref: "../../openapi.yaml#/components/responses/Forbidden" } - "404": { description: The active punishment was not found. } + "400": + description: The request body is invalid. + "401": + $ref: "../../openapi.yaml#/components/responses/Unauthorized" + "403": + $ref: "../../openapi.yaml#/components/responses/Forbidden" + "404": + description: The active punishment was not found. diff --git a/openapi/paths/punishments/punishments-by-id.yaml b/openapi/paths/punishments/punishments-by-id.yaml index 6d90b8c..1cd3831 100644 --- a/openapi/paths/punishments/punishments-by-id.yaml +++ b/openapi/paths/punishments/punishments-by-id.yaml @@ -1,9 +1,11 @@ get: summary: Get a punishment operationId: getPunishmentById - tags: [punishments] + tags: + - punishments security: - - ApiKeyAuth: ["punishments:read"] + - ApiKeyAuth: + - punishments:read parameters: - $ref: "../../openapi.yaml#/components/parameters/PunishmentId" responses: @@ -13,16 +15,21 @@ get: application/json: schema: $ref: "../../openapi.yaml#/components/schemas/Punishment" - "401": { $ref: "../../openapi.yaml#/components/responses/Unauthorized" } - "403": { $ref: "../../openapi.yaml#/components/responses/Forbidden" } - "404": { description: The punishment was not found. } + "401": + $ref: "../../openapi.yaml#/components/responses/Unauthorized" + "403": + $ref: "../../openapi.yaml#/components/responses/Forbidden" + "404": + description: The punishment was not found. patch: summary: Update an active punishment operationId: updatePunishmentById - tags: [punishments] + tags: + - punishments security: - - ApiKeyAuth: ["punishments:write"] + - ApiKeyAuth: + - punishments:write parameters: - $ref: "../../openapi.yaml#/components/parameters/PunishmentId" requestBody: @@ -38,17 +45,23 @@ patch: application/json: schema: $ref: "../../openapi.yaml#/components/schemas/Punishment" - "400": { description: The request body is invalid. } - "401": { $ref: "../../openapi.yaml#/components/responses/Unauthorized" } - "403": { $ref: "../../openapi.yaml#/components/responses/Forbidden" } - "404": { description: The active punishment was not found. } + "400": + description: The request body is invalid. + "401": + $ref: "../../openapi.yaml#/components/responses/Unauthorized" + "403": + $ref: "../../openapi.yaml#/components/responses/Forbidden" + "404": + description: The active punishment was not found. delete: summary: Revoke an active punishment operationId: deletePunishmentById - tags: [punishments] + tags: + - punishments security: - - ApiKeyAuth: ["punishments:write"] + - ApiKeyAuth: + - punishments:write parameters: - $ref: "../../openapi.yaml#/components/parameters/PunishmentId" requestBody: @@ -58,8 +71,13 @@ delete: schema: $ref: "../../openapi.yaml#/components/schemas/RevokePunishmentRequest" responses: - "204": { description: The punishment was revoked successfully. } - "400": { description: The request body is invalid. } - "401": { $ref: "../../openapi.yaml#/components/responses/Unauthorized" } - "403": { $ref: "../../openapi.yaml#/components/responses/Forbidden" } - "404": { description: The active punishment was not found. } + "204": + description: The punishment was revoked successfully. + "400": + description: The request body is invalid. + "401": + $ref: "../../openapi.yaml#/components/responses/Unauthorized" + "403": + $ref: "../../openapi.yaml#/components/responses/Forbidden" + "404": + description: The active punishment was not found. diff --git a/openapi/paths/punishments/punishments.yaml b/openapi/paths/punishments/punishments.yaml index c1b134b..17b923f 100644 --- a/openapi/paths/punishments/punishments.yaml +++ b/openapi/paths/punishments/punishments.yaml @@ -1,29 +1,44 @@ get: summary: List punishments operationId: listPunishments - tags: [punishments] + tags: + - punishments security: - - ApiKeyAuth: ["punishments:read"] + - ApiKeyAuth: + - punishments:read parameters: - name: target in: query - schema: { type: string } + schema: + type: string + - name: type in: query schema: $ref: "../../openapi.yaml#/components/schemas/PunishmentType" + - name: server in: query - schema: { type: string } + schema: + type: string + - name: active in: query - schema: { type: boolean } + schema: + type: boolean + - name: cursor in: query - schema: { type: string } + schema: + type: string + - name: limit in: query - schema: { type: integer, minimum: 1, maximum: 100, default: 20 } + schema: + type: integer + minimum: 1 + maximum: 100 + default: 20 responses: "200": description: Punishments were retrieved successfully. @@ -31,16 +46,21 @@ get: application/json: schema: $ref: "../../openapi.yaml#/components/schemas/ListPunishmentsResponse" - "400": { description: The request contains invalid query parameters. } - "401": { $ref: "../../openapi.yaml#/components/responses/Unauthorized" } - "403": { $ref: "../../openapi.yaml#/components/responses/Forbidden" } + "400": + description: The request contains invalid query parameters. + "401": + $ref: "../../openapi.yaml#/components/responses/Unauthorized" + "403": + $ref: "../../openapi.yaml#/components/responses/Forbidden" post: summary: Create a punishment operationId: createPunishment - tags: [punishments] + tags: + - punishments security: - - ApiKeyAuth: ["punishments:write"] + - ApiKeyAuth: + - punishments:write requestBody: required: true content: @@ -54,7 +74,11 @@ post: application/json: schema: $ref: "../../openapi.yaml#/components/schemas/Punishment" - "400": { description: The request body is invalid. } - "401": { $ref: "../../openapi.yaml#/components/responses/Unauthorized" } - "403": { $ref: "../../openapi.yaml#/components/responses/Forbidden" } - "409": { description: A conflicting active punishment already exists. } + "400": + description: The request body is invalid. + "401": + $ref: "../../openapi.yaml#/components/responses/Unauthorized" + "403": + $ref: "../../openapi.yaml#/components/responses/Forbidden" + "409": + description: A conflicting active punishment already exists. From 7cca84d25199fa2e22ecf75c0659e02e058cd40b Mon Sep 17 00:00:00 2001 From: acrylic-style Date: Tue, 21 Jul 2026 23:13:55 +0900 Subject: [PATCH 4/4] chore: change migration filename (actor -> player) --- ...> 20260721000000_add_api_key_player_and_punishment_scopes.sql} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename server/migrations/{20260721000000_add_api_key_actor_and_punishment_scopes.sql => 20260721000000_add_api_key_player_and_punishment_scopes.sql} (100%) diff --git a/server/migrations/20260721000000_add_api_key_actor_and_punishment_scopes.sql b/server/migrations/20260721000000_add_api_key_player_and_punishment_scopes.sql similarity index 100% rename from server/migrations/20260721000000_add_api_key_actor_and_punishment_scopes.sql rename to server/migrations/20260721000000_add_api_key_player_and_punishment_scopes.sql