From a890cc67fcf03682279c5da29bc4627981ead840 Mon Sep 17 00:00:00 2001 From: itqop Date: Wed, 3 Dec 2025 00:08:55 +0300 Subject: [PATCH] fix: fix warps --- .claude/settings.local.json | 15 ++++ API_ENDPOINTS.md | 123 ++++++++++++++++++++++++--- migrations/001_add_warps_owner.sql | 15 ++++ src/hubgw/core/errors.py | 18 +++- src/hubgw/models/warp.py | 11 ++- src/hubgw/repositories/warps_repo.py | 7 ++ src/hubgw/schemas/homes.py | 1 + src/hubgw/schemas/warps.py | 6 +- src/hubgw/services/homes_service.py | 21 ++++- src/hubgw/services/warps_service.py | 62 ++++++++++++-- 10 files changed, 254 insertions(+), 25 deletions(-) create mode 100644 .claude/settings.local.json create mode 100644 migrations/001_add_warps_owner.sql diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..8c04ec2 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,15 @@ +{ + "permissions": { + "allow": [ + "Bash(poetry run pytest:*)", + "Bash(poetry add:*)", + "Bash(poetry run python:*)", + "Bash(poetry run ruff:*)", + "Bash(python -m ruff check:*)", + "Bash(.venvScriptspython.exe -m ruff check src/hubgw/core/errors.py src/hubgw/schemas/homes.py src/hubgw/services/homes_service.py)", + "Bash(python -m py_compile:*)" + ], + "deny": [], + "ask": [] + } +} diff --git a/API_ENDPOINTS.md b/API_ENDPOINTS.md index fa15dec..b223f6d 100644 --- a/API_ENDPOINTS.md +++ b/API_ENDPOINTS.md @@ -31,7 +31,7 @@ ### PUT `/homes/` -Создает или обновляет дом игрока. +Создает или обновляет дом игрока с проверкой лимита. **Тело запроса:** ```json @@ -44,10 +44,14 @@ "z": 0.0, "yaw": 0.0, "pitch": 0.0, - "is_public": false + "is_public": false, + "max_homes": 5 } ``` +**Параметры:** +- `max_homes` (optional) - Максимальное количество домов для игрока. Если указан, проверяется лимит при создании нового дома. + **Ответ:** ```json { @@ -66,6 +70,20 @@ } ``` +**Ошибки:** +- `409 Conflict` - Лимит домов превышен (если указан `max_homes`) + ```json + { + "message": "Home limit exceeded. Maximum 5 homes allowed.", + "code": "limit_exceeded", + "details": { + "player_uuid": "string", + "current_count": 5, + "max_homes": 5 + } + } + ``` + ### POST `/homes/get` Получает конкретный дом игрока по имени. @@ -210,13 +228,16 @@ **Префикс:** `/warps` +Варпы имеют глобально уникальные имена и владельцев. Только владелец может редактировать или удалять свой варп. + ### POST `/warps/` -Создает новую точку варпа. +Создает новую точку варпа с проверкой лимита per-player. **Тело запроса:** ```json { + "player_uuid": "string", "name": "string", "world": "string", "x": 0.0, @@ -225,16 +246,23 @@ "yaw": 0.0, "pitch": 0.0, "is_public": true, - "description": "string" + "description": "string", + "max_warps": 10 } ``` +**Параметры:** +- `player_uuid` (required) - UUID владельца варпа +- `name` (required) - Глобально уникальное имя варпа +- `max_warps` (optional) - Максимальное количество варпов для игрока + **Статус:** `201 Created` **Ответ:** ```json { "id": "uuid", + "player_uuid": "string", "name": "string", "world": "string", "x": 0.0, @@ -249,13 +277,38 @@ } ``` +**Ошибки:** +- `409 Conflict` - Варп с таким именем уже существует + ```json + { + "message": "Warp 'spawn' already exists", + "code": "already_exists", + "details": { + "owner_uuid": "другой-uuid" + } + } + ``` +- `409 Conflict` - Лимит варпов превышен (если указан `max_warps`) + ```json + { + "message": "Warp limit exceeded. Maximum 10 warps allowed.", + "code": "limit_exceeded", + "details": { + "player_uuid": "string", + "current_count": 10, + "max_warps": 10 + } + } + ``` + ### PATCH `/warps/` -Обновляет существующую точку варпа. +Обновляет существующую точку варпа. Только владелец может редактировать варп. **Тело запроса:** ```json { + "player_uuid": "string", "name": "string", "world": "string", "x": 0.0, @@ -263,15 +316,21 @@ "z": 0.0, "yaw": 0.0, "pitch": 0.0, - "is_public": true, + "is_public": false, "description": "string" } ``` +**Параметры:** +- `player_uuid` (required) - UUID игрока (для проверки ownership) +- `name` (required) - Имя варпа для обновления +- Все остальные поля опциональны + **Ответ:** ```json { "id": "uuid", + "player_uuid": "string", "name": "string", "world": "string", "x": 0.0, @@ -279,29 +338,52 @@ "z": 0.0, "yaw": 0.0, "pitch": 0.0, - "is_public": true, + "is_public": false, "description": "string", "created_at": "2024-01-01T00:00:00Z", "updated_at": "2024-01-01T00:00:00Z" } ``` +**Ошибки:** +- `403 Forbidden` - Нет прав для редактирования варпа + ```json + { + "message": "You don't have permission to update warp 'spawn'", + "code": "permission_denied", + "details": { + "warp_owner": "uuid-владельца", + "requested_by": "ваш-uuid" + } + } + ``` +- `404 Not Found` - Варп не найден + ### DELETE `/warps/` -Удаляет точку варпа. +Удаляет точку варпа. Только владелец может удалить варп. **Тело запроса:** ```json { + "player_uuid": "string", "name": "string" } ``` +**Параметры:** +- `player_uuid` (required) - UUID игрока (для проверки ownership) +- `name` (required) - Имя варпа для удаления + **Статус:** `204 No Content` +**Ошибки:** +- `403 Forbidden` - Нет прав для удаления варпа +- `404 Not Found` - Варп не найден + ### POST `/warps/get` -Получает конкретную точку варпа по имени. +Получает конкретную точку варпа по имени. Доступно всем игрокам. **Тело запроса:** ```json @@ -314,6 +396,7 @@ ```json { "id": "uuid", + "player_uuid": "string", "name": "string", "world": "string", "x": 0.0, @@ -349,6 +432,7 @@ "warps": [ { "id": "uuid", + "player_uuid": "string", "name": "string", "world": "string", "x": 0.0, @@ -954,7 +1038,26 @@ API ключ настраивается через переменную окру Все endpoints возвращают стандартные HTTP коды ошибок: - `400 Bad Request` - некорректные данные в запросе - `401 Unauthorized` - отсутствует или неверный API ключ +- `403 Forbidden` - недостаточно прав для выполнения операции (например, попытка удалить чужой варп) - `404 Not Found` - ресурс не найден +- `409 Conflict` - конфликт ресурсов (ресурс уже существует, лимит превышен) - `500 Internal Server Error` - внутренняя ошибка сервера -Тело ответа при ошибке содержит детальное описание проблемы. +Тело ответа при ошибке имеет следующий формат: +```json +{ + "message": "Human-readable error message", + "code": "error_code", + "details": { + "additional": "context" + } +} +``` + +**Коды ошибок:** +- `not_found` - ресурс не найден (404) +- `already_exists` - ресурс уже существует (409) +- `limit_exceeded` - превышен лимит ресурсов (409) +- `permission_denied` - нет прав на операцию (403) +- `invalid_state` - некорректное состояние данных (400) +- `cooldown_active` - кулдаун активен (400) diff --git a/migrations/001_add_warps_owner.sql b/migrations/001_add_warps_owner.sql new file mode 100644 index 0000000..4503b5e --- /dev/null +++ b/migrations/001_add_warps_owner.sql @@ -0,0 +1,15 @@ +-- Migration: Add player_uuid to warps for ownership tracking +-- Date: 2024-12-02 +-- Description: Add owner field to warps, enabling per-player limits and ownership control + +-- Add player_uuid column (owner of the warp) +ALTER TABLE hubmc.hub_warps +ADD COLUMN player_uuid VARCHAR(36) NOT NULL + REFERENCES hubmc.luckperms_players(uuid) ON DELETE CASCADE; + +-- Add index for querying warps by player +CREATE INDEX idx_hub_warps_player_uuid + ON hubmc.hub_warps(player_uuid); + +-- Add comment +COMMENT ON COLUMN hubmc.hub_warps.player_uuid IS 'Owner of the warp (who created it)'; diff --git a/src/hubgw/core/errors.py b/src/hubgw/core/errors.py index b71bc8c..f553035 100644 --- a/src/hubgw/core/errors.py +++ b/src/hubgw/core/errors.py @@ -48,13 +48,29 @@ class CooldownActiveError(AppError): super().__init__(message, "cooldown_active", details) +class LimitExceededError(AppError): + """Limit exceeded error.""" + + def __init__(self, message: str, details: Optional[Dict[str, Any]] = None): + super().__init__(message, "limit_exceeded", details) + + +class PermissionError(AppError): + """Permission denied error.""" + + def __init__(self, message: str, details: Optional[Dict[str, Any]] = None): + super().__init__(message, "permission_denied", details) + + def create_http_exception(error: AppError) -> HTTPException: """Convert AppError to HTTPException.""" status_code = 400 if isinstance(error, NotFoundError): status_code = 404 - elif isinstance(error, AlreadyExistsError): + elif isinstance(error, (AlreadyExistsError, LimitExceededError)): status_code = 409 + elif isinstance(error, PermissionError): + status_code = 403 return HTTPException( status_code=status_code, diff --git a/src/hubgw/models/warp.py b/src/hubgw/models/warp.py index e18d9c1..f763eba 100644 --- a/src/hubgw/models/warp.py +++ b/src/hubgw/models/warp.py @@ -1,13 +1,14 @@ """Warp model.""" -from sqlalchemy import REAL, Boolean, Column, Float, Index, String, Text +from sqlalchemy import REAL, Boolean, Column, Float, ForeignKey, Index, String, Text from sqlalchemy.dialects.postgresql import UUID from hubgw.core.config import APP_CONFIG from hubgw.models.base import Base -# Get schema at module load time +# Get schemas at module load time _HUB_SCHEMA = APP_CONFIG.database.hub_schema +_LUCKPERMS_SCHEMA = APP_CONFIG.database.luckperms_schema class Warp(Base): @@ -25,6 +26,12 @@ class Warp(Base): id = Column( UUID(as_uuid=True), primary_key=True, server_default="gen_random_uuid()" ) + player_uuid = Column( + String(36), + ForeignKey(f"{_LUCKPERMS_SCHEMA}.luckperms_players.uuid", ondelete="CASCADE"), + nullable=False, + index=True, + ) name = Column(String(255), nullable=False, unique=True, index=True) world = Column(Text, nullable=False) x = Column(Float, nullable=False) diff --git a/src/hubgw/repositories/warps_repo.py b/src/hubgw/repositories/warps_repo.py index ac887f7..2fa900e 100644 --- a/src/hubgw/repositories/warps_repo.py +++ b/src/hubgw/repositories/warps_repo.py @@ -20,6 +20,7 @@ class WarpsRepository: async def create(self, request: WarpCreateRequest) -> Warp: """Create warp.""" warp = Warp( + player_uuid=request.player_uuid, name=request.name, world=request.world, x=request.x, @@ -118,3 +119,9 @@ class WarpsRepository: async def list(self, query: WarpQuery) -> tuple[List[Warp], int]: """List warps (alias for query).""" return await self.query(query) + + async def count_by_player(self, player_uuid: str) -> int: + """Count warps created by a specific player.""" + stmt = select(func.count(Warp.id)).where(Warp.player_uuid == player_uuid) + result = await self.session.execute(stmt) + return result.scalar() diff --git a/src/hubgw/schemas/homes.py b/src/hubgw/schemas/homes.py index 73dfb17..9e0e5e4 100644 --- a/src/hubgw/schemas/homes.py +++ b/src/hubgw/schemas/homes.py @@ -44,6 +44,7 @@ class HomeUpsertRequest(HomeBase): """Home upsert request schema.""" player_uuid: str + max_homes: Optional[int] = None class Home(HomeBase, BaseSchema): diff --git a/src/hubgw/schemas/warps.py b/src/hubgw/schemas/warps.py index 259565a..4f51e56 100644 --- a/src/hubgw/schemas/warps.py +++ b/src/hubgw/schemas/warps.py @@ -25,12 +25,14 @@ class WarpBase(BaseModel): class WarpCreateRequest(WarpBase): """Warp create request schema.""" - pass + player_uuid: str + max_warps: Optional[int] = None class WarpUpdateRequest(BaseModel): """Warp update request schema.""" + player_uuid: str name: str world: Optional[str] = None x: Optional[float] = None @@ -45,6 +47,7 @@ class WarpUpdateRequest(BaseModel): class WarpDeleteRequest(BaseModel): """Warp delete request schema.""" + player_uuid: str name: str @@ -58,6 +61,7 @@ class Warp(WarpBase, BaseSchema): """Warp response schema.""" id: UUID + player_uuid: str class WarpGetResponse(Warp): diff --git a/src/hubgw/services/homes_service.py b/src/hubgw/services/homes_service.py index 9dc3144..5ed4e32 100644 --- a/src/hubgw/services/homes_service.py +++ b/src/hubgw/services/homes_service.py @@ -3,7 +3,7 @@ from sqlalchemy.ext.asyncio import AsyncSession -from hubgw.core.errors import NotFoundError +from hubgw.core.errors import LimitExceededError, NotFoundError from hubgw.repositories.homes_repo import HomesRepository from hubgw.schemas.homes import (Home, HomeCountResponse, HomeGetRequest, HomeGetResponse, HomeListResponse, @@ -17,7 +17,24 @@ class HomesService: self.repo = HomesRepository(session) async def upsert_home(self, request: HomeUpsertRequest) -> Home: - """Upsert home with business logic.""" + """Upsert home with business logic and limit check.""" + if request.max_homes is not None: + existing_home = await self.repo.get_by_player_and_name( + request.player_uuid, request.name + ) + + if not existing_home: + current_count = await self.repo.count_by_player(request.player_uuid) + if current_count >= request.max_homes: + raise LimitExceededError( + f"Home limit exceeded. Maximum {request.max_homes} homes allowed.", + details={ + "player_uuid": request.player_uuid, + "current_count": current_count, + "max_homes": request.max_homes, + }, + ) + return await self.repo.upsert(request) async def get_home(self, request: HomeGetRequest) -> HomeGetResponse: diff --git a/src/hubgw/services/warps_service.py b/src/hubgw/services/warps_service.py index bb17f12..6f5ffa5 100644 --- a/src/hubgw/services/warps_service.py +++ b/src/hubgw/services/warps_service.py @@ -3,7 +3,12 @@ from sqlalchemy.ext.asyncio import AsyncSession -from hubgw.core.errors import AlreadyExistsError, NotFoundError +from hubgw.core.errors import ( + AlreadyExistsError, + LimitExceededError, + NotFoundError, + PermissionError, +) from hubgw.repositories.warps_repo import WarpsRepository from hubgw.schemas.warps import (Warp, WarpCreateRequest, WarpDeleteRequest, WarpGetRequest, WarpGetResponse, @@ -18,24 +23,61 @@ class WarpsService: self.repo = WarpsRepository(session) async def create_warp(self, request: WarpCreateRequest) -> Warp: - """Create warp with business logic.""" - # Check if warp with same name already exists - existing = await self.repo.get_by_request(WarpGetRequest(name=request.name)) + """Create warp with business logic and limit check.""" + existing = await self.repo.get_by_name(request.name) if existing: - raise AlreadyExistsError(f"Warp '{request.name}' already exists") + raise AlreadyExistsError( + f"Warp '{request.name}' already exists", + details={"owner_uuid": existing.player_uuid}, + ) + + if request.max_warps is not None: + current_count = await self.repo.count_by_player(request.player_uuid) + if current_count >= request.max_warps: + raise LimitExceededError( + f"Warp limit exceeded. Maximum {request.max_warps} warps allowed.", + details={ + "player_uuid": request.player_uuid, + "current_count": current_count, + "max_warps": request.max_warps, + }, + ) return await self.repo.create(request) async def update_warp(self, request: WarpUpdateRequest) -> Warp: - """Update warp with business logic.""" - warp = await self.repo.update(request) - if not warp: + """Update warp with business logic and ownership check.""" + existing = await self.repo.get_by_name(request.name) + if not existing: raise NotFoundError(f"Warp '{request.name}' not found") + if existing.player_uuid != request.player_uuid: + raise PermissionError( + f"You don't have permission to update warp '{request.name}'", + details={ + "warp_owner": existing.player_uuid, + "requested_by": request.player_uuid, + }, + ) + + warp = await self.repo.update(request) return warp async def delete_warp(self, request: WarpDeleteRequest) -> None: - """Delete warp with business logic.""" + """Delete warp with business logic and ownership check.""" + existing = await self.repo.get_by_name(request.name) + if not existing: + raise NotFoundError(f"Warp '{request.name}' not found") + + if existing.player_uuid != request.player_uuid: + raise PermissionError( + f"You don't have permission to delete warp '{request.name}'", + details={ + "warp_owner": existing.player_uuid, + "requested_by": request.player_uuid, + }, + ) + success = await self.repo.delete(request) if not success: raise NotFoundError(f"Warp '{request.name}' not found") @@ -48,6 +90,7 @@ class WarpsService: return WarpGetResponse( id=warp.id, + player_uuid=warp.player_uuid, name=warp.name, world=warp.world, x=warp.x, @@ -68,6 +111,7 @@ class WarpsService: warp_list = [ Warp( id=warp.id, + player_uuid=warp.player_uuid, name=warp.name, world=warp.world, x=warp.x,