From 1a7880899241c75e8b5e9306d86950032718dc9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torma=20Krist=C3=B3f?= Date: Thu, 8 Oct 2026 18:50:10 +0200 Subject: [PATCH 1/4] fix(backend): close an account takeover and harden auth and validation Logins and token checks looked users up with ILIKE, so `%` and `_` in an email acted as wildcards. With self-registration on, anyone could register `%@example.org` and receive a token that resolved to another account at that domain. Emails are now matched exactly, on the ix_users_email_lower index. Authentication - The JWT subject is the user id, and every token carries a hash that changes with the password, so a password change signs out the other sessions. - The frontend's session is an HttpOnly, SameSite=Strict cookie, __Host- prefixed in production. Requests that change data with it need an X-Requested-With header (CSRF), and POST /logout removes it. API clients can still send the bearer token. - Any invalid token is a 401 instead of a 500, unknown emails are checked against a dummy hash so that logins take as long either way, and changing the password needs the current one. Authorization and validation - Stage items and their inputs can't reference another tournament's stage items or rankings, ranking routes are scoped to their tournament, and archived tournaments can't gain or lose stage items. - Names, scores, durations and the Details link are validated. Bulk imports report invalid names instead of failing with a 500, and link the players to their teams. - A duplicate email is a 400, another user's account a 403, and a missing player or team a 404. Removed - The /static mount, which served whatever directory was mounted on it. - The unused upcoming_matches endpoint, whose iterations parameter could exhaust the server's memory, along with its suggestion search. - fastapi-sso, gunicorn and the deprecated uvicorn worker. The image runs uvicorn as a user that can't modify the code. - The API docs in production, which now sends a deny-all CSP. A replaced logo is deleted once the change is committed. Every fix has a test, the stale tests pass again, and the row count helper now asserts the count it is given. Co-Authored-By: Claude Opus 5.5 --- backend/Dockerfile | 15 +- backend/bracket/app.py | 68 +-- backend/bracket/config.py | 2 - .../bracket/logic/scheduling/ladder_teams.py | 141 ------ backend/bracket/logic/scheduling/shared.py | 39 -- .../logic/scheduling/upcoming_matches.py | 52 --- backend/bracket/models/db/club.py | 5 +- backend/bracket/models/db/match.py | 33 +- backend/bracket/models/db/player.py | 23 +- backend/bracket/models/db/round.py | 5 +- backend/bracket/models/db/stage.py | 3 +- backend/bracket/models/db/stage_item.py | 5 +- backend/bracket/models/db/team.py | 31 +- backend/bracket/models/db/tournament.py | 6 +- backend/bracket/models/db/user.py | 23 +- backend/bracket/routes/auth.py | 259 ++++++----- backend/bracket/routes/matches.py | 38 +- backend/bracket/routes/models.py | 6 +- backend/bracket/routes/players.py | 36 +- backend/bracket/routes/rankings.py | 12 +- backend/bracket/routes/stage_item_inputs.py | 6 +- backend/bracket/routes/stage_items.py | 2 + backend/bracket/routes/teams.py | 64 ++- backend/bracket/routes/tournaments.py | 31 +- backend/bracket/routes/users.py | 79 ++-- backend/bracket/routes/util.py | 34 +- backend/bracket/schema.py | 14 +- backend/bracket/sql/players.py | 25 +- backend/bracket/sql/stage_item_inputs.py | 14 +- backend/bracket/sql/users.py | 39 +- backend/bracket/sql/validation.py | 15 + backend/bracket/utils/db_init.py | 3 - backend/bracket/utils/errors.py | 2 + backend/bracket/utils/pagination.py | 8 +- backend/bracket/utils/pydantic.py | 49 +- backend/bracket/uvicorn.py | 31 -- backend/openapi/openapi.json | 434 ++++++++++-------- backend/pyproject.toml | 22 +- backend/static/.gitignore | 1 - .../tests/integration_tests/api/auth_test.py | 140 +++++- .../tests/integration_tests/api/clubs_test.py | 15 +- .../integration_tests/api/inputs_test.py | 54 +++ .../integration_tests/api/matches_test.py | 160 ------- .../integration_tests/api/players_test.py | 23 +- .../integration_tests/api/rankings_test.py | 36 +- .../integration_tests/api/stage_items_test.py | 90 +++- .../integration_tests/api/stages_test.py | 9 +- .../tests/integration_tests/api/teams_test.py | 55 ++- .../integration_tests/api/tournaments_test.py | 58 ++- .../tests/integration_tests/api/users_test.py | 134 +++++- backend/tests/integration_tests/mocks.py | 14 +- backend/tests/integration_tests/sql.py | 7 +- backend/tests/integration_tests/uploads.py | 14 + .../tests/unit_tests/swiss_pairing_test.py | 4 +- backend/tests/unit_tests/swiss_test.py | 108 ----- backend/uv.lock | 107 +---- 56 files changed, 1392 insertions(+), 1311 deletions(-) delete mode 100644 backend/bracket/logic/scheduling/ladder_teams.py delete mode 100644 backend/bracket/logic/scheduling/shared.py delete mode 100644 backend/bracket/logic/scheduling/upcoming_matches.py delete mode 100644 backend/bracket/uvicorn.py delete mode 100644 backend/static/.gitignore create mode 100644 backend/tests/integration_tests/uploads.py delete mode 100644 backend/tests/unit_tests/swiss_test.py diff --git a/backend/Dockerfile b/backend/Dockerfile index e67f3f85..ef6d2663 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -19,14 +19,15 @@ RUN uv export --frozen --no-dev --no-emit-project --output-file /tmp/requirement && python -m pip uninstall --yes uv \ && rm -rf /root/.cache /tmp/requirements.txt -COPY --chown=10001:10001 alembic.ini ./ -COPY --chown=10001:10001 cli.py ./ -COPY --chown=10001:10001 alembic ./alembic -COPY --chown=10001:10001 bracket ./bracket -COPY --chown=10001:10001 openapi ./openapi -COPY --chown=10001:10001 static ./static +# The code stays owned by root, so the app can't modify it. Uploaded logos are its only writes. +COPY alembic.ini cli.py ./ +COPY alembic ./alembic +COPY bracket ./bracket +COPY openapi ./openapi +RUN install --directory --owner=10001 --group=10001 uploads USER 10001:10001 EXPOSE 8400 -CMD ["gunicorn", "-k", "uvicorn.workers.UvicornWorker", "bracket.app:app", "--bind", "0.0.0.0:8400", "--workers", "1", "--no-control-socket"] +# uvicorn trusts the `X-Forwarded-For` of the proxies listed in `FORWARDED_ALLOW_IPS`. +CMD ["uvicorn", "bracket.app:app", "--host", "0.0.0.0", "--port", "8400", "--no-server-header"] diff --git a/backend/bracket/app.py b/backend/bracket/app.py index dafb485f..5b79c9bb 100644 --- a/backend/bracket/app.py +++ b/backend/bracket/app.py @@ -1,7 +1,7 @@ from collections.abc import AsyncGenerator from contextlib import asynccontextmanager -from fastapi import FastAPI, Request +from fastapi import Depends, FastAPI, Request from secure import ( ContentSecurityPolicy, PermissionsPolicy, @@ -14,10 +14,8 @@ from secure.middleware import SecureASGIMiddleware from slowapi.errors import RateLimitExceeded from slowapi.extension import _rate_limit_exceeded_handler -from starlette.exceptions import HTTPException from starlette.middleware.cors import CORSMiddleware from starlette.responses import JSONResponse, Response -from starlette.staticfiles import StaticFiles from bracket.config import Environment, config, environment from bracket.database import engine @@ -74,44 +72,22 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None]: "Users": users.router, } -table_of_contents = "\n\n".join( - [f"- [{tag}](#tag/{tag.replace(' ', '-')})" for tag in routers.keys()] -) - - -description = f""" -### Description -This API allows you to do everything the frontend of [Bracket](https://github.com/evroon/bracket) -allows you to do (the frontend uses this API as well). - -Fore more information, see the [documentation](https://docs.bracketapp.nl). - -### Table of Contents -*(links only work for [ReDoc](https://api.bracketapp.nl/redoc), not for Swagger UI)* - -{table_of_contents} - -### Links -GitHub: - -Docs: - -API docs (Redoc): - -API docs (Swagger UI): -""" +is_development = environment is Environment.DEVELOPMENT app = FastAPI( title="Bracket API", - docs_url="/docs", version="1.0.0", lifespan=lifespan, - summary="API for Bracket, an open source tournament system.", - description=description, + summary="API of the Ctrl-Alt-GG fork of Bracket, an open source tournament system.", + description="Everything the Bracket frontend does, it does through this API.", license_info={ "name": "AGPL-3.0", "url": "https://www.gnu.org/licenses/agpl-3.0.en.html", }, + # The interactive docs load Swagger UI and ReDoc from a CDN, which the production security + # headers don't allow, so only development serves them. + openapi_url="/openapi.json" if is_development else None, + dependencies=[Depends(auth.protect_session_from_csrf)], ) @@ -136,18 +112,17 @@ async def handle_rate_limit_exceeded(request: Request, exc: Exception) -> Respon app.add_middleware( SecureASGIMiddleware, secure=Secure( - csp=ContentSecurityPolicy() - .default_src("'self'") - .base_uri("'self'") - .frame_ancestors("'none'") - .form_action("'self'") - .img_src("'self'", "data:", "https:") - .style_src("'self'", "'unsafe-inline'") - .script_src("'self'", "'unsafe-inline'"), + # The API only answers with JSON and uploaded images, so nothing may load or frame it. + # Development leaves it out, so that the interactive docs work. + csp=( + None + if is_development + else ContentSecurityPolicy().default_src("'none'").frame_ancestors("'none'") + ), hsts=( - StrictTransportSecurity().max_age(31536000).include_subdomains() - if environment is Environment.PRODUCTION - else None + None + if is_development + else StrictTransportSecurity().max_age(31536000).include_subdomains() ), permissions=PermissionsPolicy().camera().geolocation().microphone(), referrer=ReferrerPolicy().no_referrer(), @@ -157,11 +132,6 @@ async def handle_rate_limit_exceeded(request: Request, exc: Exception) -> Respon ) -@app.exception_handler(HTTPException) -async def validation_exception_handler(request: Request, exc: HTTPException) -> JSONResponse: - return JSONResponse({"detail": exc.detail}, status_code=exc.status_code) - - @app.exception_handler(Exception) async def generic_exception_handler(request: Request, exc: Exception) -> JSONResponse: logger.exception( @@ -170,8 +140,6 @@ async def generic_exception_handler(request: Request, exc: Exception) -> JSONRes return JSONResponse({"detail": "Internal server error"}, status_code=500) -app.mount(f"{config.api_prefix}/static", StaticFiles(directory="static"), name="static") - for tag, router in routers.items(): assert router.prefix == config.api_prefix, f"Prefix not set on router with tag `{tag}`" app.include_router(router, tags=[tag]) diff --git a/backend/bracket/config.py b/backend/bracket/config.py index 972d9247..a8aba8d8 100644 --- a/backend/bracket/config.py +++ b/backend/bracket/config.py @@ -29,7 +29,6 @@ class Config(BaseSettings): admin_email: str | None = None admin_password: str | None = None - allow_insecure_http_sso: bool = False allow_user_registration: bool = True base_url: str = "http://localhost:8400" cors_origin_regex: str = "" @@ -96,7 +95,6 @@ def validate_security_settings(self) -> "Config": class DevelopmentConfig(Config): admin_email: Annotated[str | None, Field("test@example.org")] - allow_insecure_http_sso: Annotated[bool, Field(True)] cors_origin_regex: Annotated[str, Field(r"^https?://(localhost|127\.0\.0\.1)(:\d+)?$")] model_config = SettingsConfigDict(env_file="dev.env") diff --git a/backend/bracket/logic/scheduling/ladder_teams.py b/backend/bracket/logic/scheduling/ladder_teams.py deleted file mode 100644 index 5f92da07..00000000 --- a/backend/bracket/logic/scheduling/ladder_teams.py +++ /dev/null @@ -1,141 +0,0 @@ -import itertools -import random -from collections import defaultdict - -from bracket.logic.scheduling.shared import check_input_combination_adheres_to_filter -from bracket.models.db.match import ( - MatchFilter, - MatchWithDetailsDefinitive, - SuggestedMatch, - get_match_hash, -) -from bracket.models.db.stage_item_inputs import StageItemInput, StageItemInputFinal -from bracket.models.db.util import RoundWithMatches -from bracket.utils.id_types import StageItemInputId -from bracket.utils.types import assert_some - - -def get_draft_round_input_ids(draft_round: RoundWithMatches) -> frozenset[StageItemInputId]: - return frozenset( - { - stage_item_input_id - for match in draft_round.matches - if isinstance(match, MatchWithDetailsDefinitive) - for stage_item_input_id in match.stage_item_input_ids - } - ) - - -def get_previous_matches_hashes(rounds: list[RoundWithMatches]) -> frozenset[str]: - return frozenset( - [ - hash_ - for round_ in rounds - for match in round_.matches - if isinstance(match, MatchWithDetailsDefinitive) - for hash_ in match.get_input_ids_hashes() - ] - ) - - -def get_number_of_inputs_played_per_input( - rounds: list[RoundWithMatches], excluded_input_ids: frozenset[StageItemInputId] -) -> dict[int, int]: - result: dict[int, int] = defaultdict(int) - - for round_ in rounds: - for match in round_.matches: - if isinstance(match, MatchWithDetailsDefinitive): - for input_ in match.stage_item_inputs: - if input_.id not in excluded_input_ids: - result[input_.id] += 1 - - return result - - -def get_possible_upcoming_matches_for_swiss( - filter_: MatchFilter, - rounds: list[RoundWithMatches], - stage_item_inputs: list[StageItemInput], - draft_round: RoundWithMatches | None = None, -) -> list[SuggestedMatch]: - # pylint: disable=too-many-branches,unsubscriptable-object - suggestions: list[SuggestedMatch] = [] - scheduled_hashes: list[str] = [] - draft_round_input_ids = get_draft_round_input_ids(draft_round) if draft_round else frozenset() - - inputs_to_schedule = [ - input_ - for input_ in stage_item_inputs - if input_.id not in draft_round_input_ids - and (not isinstance(input_, StageItemInputFinal) or input_.team.active) - ] - - if len(inputs_to_schedule) < 1: - return [] - - previous_match_input_hashes = get_previous_matches_hashes(rounds) - times_played_per_input = get_number_of_inputs_played_per_input( - rounds, excluded_input_ids=draft_round_input_ids - ) - - for input_ in inputs_to_schedule: - if input_.id not in times_played_per_input: - times_played_per_input[input_.id] = 0 - - # If there are more possible matches to schedule (N * (N - 1)) than iteration count, then - # pick random combinations. - # Otherwise, when there's not too many inputs, just take all possible combinations. - # Note: `itertools.product` creates N * N results, so we look at N * N instead of N * (N - 1). - # For example: iteration count: 2_000, number of inputs: 20. Then N * N = 380, - # 380 is less than 2_000, so we just loop over all possible combinations. - N = len(inputs_to_schedule) - Item = tuple[StageItemInput, StageItemInput] - inputs_iter: itertools.product[Item] | zip[Item] - if N * N <= filter_.iterations: - inputs1 = inputs_to_schedule.copy() - inputs2 = inputs_to_schedule.copy() - random.shuffle(inputs1) - random.shuffle(inputs2) - inputs_iter = itertools.product(inputs1, inputs2) - else: - inputs1 = random.choices(inputs_to_schedule, k=filter_.iterations) - inputs2 = random.choices(inputs_to_schedule, k=filter_.iterations) - inputs_iter = zip(inputs1, inputs2) - - for i1, i2 in inputs_iter: - if assert_some(i1.id) > assert_some(i2.id): - input2, input1 = i1, i2 - elif assert_some(i1.id) < assert_some(i2.id): - input1, input2 = i1, i2 - else: - continue - - match_hash = get_match_hash(input1.id, input2.id) - if get_match_hash(input1.id, input2.id) in previous_match_input_hashes: - continue - - suggested_match = check_input_combination_adheres_to_filter( - input1, - input2, - filter_, - times_played_per_input[input1.id] + times_played_per_input[input2.id], - ) - if suggested_match and match_hash not in scheduled_hashes: - suggestions.append(suggested_match) - scheduled_hashes.append(match_hash) - scheduled_hashes.append(get_match_hash(input2.id, input1.id)) - - if len(suggestions) < 1: - return [] - - lowest_times_played_sum = min(sug.times_played_sum for sug in suggestions) - for sug in suggestions: - sug.is_recommended = sug.times_played_sum == lowest_times_played_sum - - if filter_.only_recommended: - suggestions = [sug for sug in suggestions if sug.is_recommended] - - sorted_by_elo = sorted(suggestions, key=lambda x: x.elo_diff) - sorted_by_times_played = sorted(sorted_by_elo, key=lambda x: x.times_played_sum) - return sorted_by_times_played[: filter_.limit] diff --git a/backend/bracket/logic/scheduling/shared.py b/backend/bracket/logic/scheduling/shared.py deleted file mode 100644 index f7a838b2..00000000 --- a/backend/bracket/logic/scheduling/shared.py +++ /dev/null @@ -1,39 +0,0 @@ -from bracket.models.db.match import MatchFilter, SuggestedMatch -from bracket.models.db.stage_item_inputs import StageItemInput - - -def get_suggested_match( - stage_item_input1: StageItemInput, - stage_item_input2: StageItemInput, - times_played_sum: int, -) -> SuggestedMatch: - elo_diff = abs(stage_item_input1.elo - stage_item_input2.elo) - swiss_diff = abs(stage_item_input1.points - stage_item_input2.points) - - return SuggestedMatch( - stage_item_input1=stage_item_input1, - stage_item_input2=stage_item_input2, - elo_diff=elo_diff, - swiss_diff=swiss_diff, - is_recommended=False, - times_played_sum=times_played_sum, - player_behind_schedule_count=0, - ) - - -def check_input_combination_adheres_to_filter( - stage_item_input1: StageItemInput, - stage_item_input2: StageItemInput, - filter_: MatchFilter, - times_played_sum: int, -) -> SuggestedMatch | None: - suggested_match = get_suggested_match( - stage_item_input1, - stage_item_input2, - times_played_sum, - ) - - if suggested_match.elo_diff <= filter_.elo_diff_threshold: - return suggested_match - - return None diff --git a/backend/bracket/logic/scheduling/upcoming_matches.py b/backend/bracket/logic/scheduling/upcoming_matches.py deleted file mode 100644 index bde63848..00000000 --- a/backend/bracket/logic/scheduling/upcoming_matches.py +++ /dev/null @@ -1,52 +0,0 @@ -from fastapi import HTTPException -from sqlalchemy.ext.asyncio import AsyncConnection -from starlette import status - -from bracket.logic.scheduling.ladder_teams import get_possible_upcoming_matches_for_swiss -from bracket.models.db.match import MatchFilter, SuggestedMatch -from bracket.models.db.stage_item import StageType -from bracket.models.db.util import RoundWithMatches, StageItemWithRounds -from bracket.sql.stages import get_full_tournament_details -from bracket.utils.id_types import StageItemId, TournamentId - - -async def get_draft_round_in_stage_item( - conn: AsyncConnection, - tournament_id: TournamentId, - stage_item_id: StageItemId, -) -> tuple[RoundWithMatches, StageItemWithRounds]: - [stage] = await get_full_tournament_details(conn, tournament_id, stage_item_ids={stage_item_id}) - draft_round, stage_item = next( - ( - (round_, stage_item) - for stage_item in stage.stage_items - for round_ in stage_item.rounds - if round_.is_draft - ), - (None, None), - ) - if draft_round is None or stage_item is None: - raise HTTPException( - status.HTTP_400_BAD_REQUEST, - "There is no draft round, so no matches can be scheduled.", - ) - return draft_round, stage_item - - -def get_upcoming_matches_for_swiss( - match_filter: MatchFilter, - stage_item: StageItemWithRounds, - draft_round: RoundWithMatches | None = None, -) -> list[SuggestedMatch]: - if stage_item.type is not StageType.SWISS: - raise HTTPException(status.HTTP_400_BAD_REQUEST, "Expected stage item to be of type SWISS.") - - if draft_round is not None and not draft_round.is_draft: - raise HTTPException( - status.HTTP_400_BAD_REQUEST, - "There is no draft round, so no matches can be scheduled.", - ) - - return get_possible_upcoming_matches_for_swiss( - match_filter, stage_item.rounds, stage_item.inputs, draft_round - ) diff --git a/backend/bracket/models/db/club.py b/backend/bracket/models/db/club.py index 0b7dbea8..1d1f4ae1 100644 --- a/backend/bracket/models/db/club.py +++ b/backend/bracket/models/db/club.py @@ -2,6 +2,7 @@ from bracket.models.db.shared import BaseModelORM from bracket.utils.id_types import ClubId +from bracket.utils.pydantic import Name class ClubInsertable(BaseModelORM): @@ -14,8 +15,8 @@ class Club(ClubInsertable): class ClubCreateBody(BaseModelORM): - name: str + name: Name class ClubUpdateBody(BaseModelORM): - name: str + name: Name diff --git a/backend/bracket/models/db/match.py b/backend/bracket/models/db/match.py index fea4a94e..652e63d3 100644 --- a/backend/bracket/models/db/match.py +++ b/backend/bracket/models/db/match.py @@ -1,7 +1,5 @@ -from decimal import Decimal - from heliclockter import datetime_utc, timedelta -from pydantic import BaseModel +from pydantic import BaseModel, Field from bracket.models.db.shared import BaseModelORM from bracket.models.db.stage_item_inputs import StageItemInput @@ -82,10 +80,10 @@ def get_input_ids_hashes(self) -> list[str]: class MatchBody(BaseModelORM): round_id: RoundId - stage_item_input1_score: int = 0 - stage_item_input2_score: int = 0 - custom_duration_minutes: int | None = None - custom_margin_minutes: int | None = None + stage_item_input1_score: int = Field(0, ge=0) + stage_item_input2_score: int = Field(0, ge=0) + custom_duration_minutes: int | None = Field(None, ge=1) + custom_margin_minutes: int | None = Field(None, ge=0) class MatchCreateBodyFrontend(BaseModelORM): @@ -108,24 +106,3 @@ class MatchTiming(BaseModel): start_time: datetime_utc duration_minutes: int margin_minutes: int - - -class MatchFilter(BaseModel): - elo_diff_threshold: int - only_recommended: bool - limit: int - iterations: int - - -class SuggestedMatch(BaseModel): - stage_item_input1: StageItemInput - stage_item_input2: StageItemInput - elo_diff: Decimal - swiss_diff: Decimal - is_recommended: bool - times_played_sum: int - player_behind_schedule_count: int - - @property - def stage_item_input_ids(self) -> list[int]: - return [self.stage_item_input1.id, self.stage_item_input2.id] diff --git a/backend/bracket/models/db/player.py b/backend/bracket/models/db/player.py index c841dc33..435e61e9 100644 --- a/backend/bracket/models/db/player.py +++ b/backend/bracket/models/db/player.py @@ -1,10 +1,11 @@ from decimal import Decimal from heliclockter import datetime_utc -from pydantic import Field +from pydantic import Field, field_validator from bracket.models.db.shared import BaseModelORM from bracket.utils.id_types import PlayerId, TournamentId +from bracket.utils.pydantic import ParticipantName, check_participant_names class PlayerInsertable(BaseModelORM): @@ -27,14 +28,32 @@ def __hash__(self) -> int: class PlayerBody(BaseModelORM): - name: str = Field(..., min_length=1, max_length=30) + name: ParticipantName active: bool +def parse_player_names(names: str) -> list[str]: + """One player per line.""" + return [name.strip() for name in names.splitlines() if name.strip()] + + class PlayerMultiBody(BaseModelORM): names: str = Field(..., min_length=1) active: bool + @field_validator("names") + @classmethod + def validate_names(cls, value: str) -> str: + names = parse_player_names(value) + if not names: + raise ValueError("Enter at least one player") + check_participant_names(names) + return value + + @property + def player_names(self) -> list[str]: + return parse_player_names(self.names) + class PlayerToInsert(PlayerBody): created: datetime_utc diff --git a/backend/bracket/models/db/round.py b/backend/bracket/models/db/round.py index d7365c18..ace087ce 100644 --- a/backend/bracket/models/db/round.py +++ b/backend/bracket/models/db/round.py @@ -2,6 +2,7 @@ from bracket.models.db.shared import BaseModelORM from bracket.utils.id_types import RoundId, StageItemId +from bracket.utils.pydantic import Name class RoundInsertable(BaseModelORM): @@ -16,10 +17,10 @@ class Round(RoundInsertable): class RoundUpdateBody(BaseModelORM): - name: str + name: Name is_draft: bool class RoundCreateBody(BaseModelORM): - name: str | None = None + name: Name | None = None stage_item_id: StageItemId diff --git a/backend/bracket/models/db/stage.py b/backend/bracket/models/db/stage.py index c3d65340..7acfd982 100644 --- a/backend/bracket/models/db/stage.py +++ b/backend/bracket/models/db/stage.py @@ -5,6 +5,7 @@ from bracket.models.db.shared import BaseModelORM from bracket.utils.id_types import StageId, TournamentId +from bracket.utils.pydantic import Name class StageInsertable(BaseModelORM): @@ -20,7 +21,7 @@ class Stage(StageInsertable): class StageUpdateBody(BaseModelORM): - name: str + name: Name custom_duration_minutes: int | None = Field(default=None, ge=1) diff --git a/backend/bracket/models/db/stage_item.py b/backend/bracket/models/db/stage_item.py index b01589a1..8319eb4c 100644 --- a/backend/bracket/models/db/stage_item.py +++ b/backend/bracket/models/db/stage_item.py @@ -7,6 +7,7 @@ from bracket.models.db.shared import BaseModelORM from bracket.models.db.stage_item_inputs import StageItemInputCreateBody from bracket.utils.id_types import RankingId, StageId, StageItemId +from bracket.utils.pydantic import Name from bracket.utils.types import EnumAutoStr @@ -34,13 +35,13 @@ class StageItem(StageItemInsertable): class StageItemUpdateBody(BaseModelORM): - name: str + name: Name ranking_id: RankingId class StageItemCreateBody(BaseModelORM): stage_id: StageId - name: str | None = None + name: Name | None = None type: StageType team_count: int = Field(ge=2, le=64) ranking_id: RankingId | None = None diff --git a/backend/bracket/models/db/team.py b/backend/bracket/models/db/team.py index 85656e84..f8629a8c 100644 --- a/backend/bracket/models/db/team.py +++ b/backend/bracket/models/db/team.py @@ -1,15 +1,16 @@ from __future__ import annotations +import csv from decimal import Decimal -from typing import Annotated from heliclockter import datetime_utc -from pydantic import BaseModel, Field, StringConstraints, field_validator +from pydantic import BaseModel, Field, field_validator from bracket.logic.ranking.statistics import START_ELO from bracket.models.db.player import Player from bracket.models.db.shared import BaseModelORM from bracket.utils.id_types import PlayerId, TeamId, TournamentId +from bracket.utils.pydantic import ParticipantName, check_participant_names class TeamInsertable(BaseModelORM): @@ -56,11 +57,35 @@ class FullTeamWithPlayers(TeamWithPlayers, Team): class TeamBody(BaseModelORM): - name: Annotated[str, StringConstraints(min_length=1, max_length=30)] + name: ParticipantName active: bool player_ids: set[PlayerId] +def parse_teams_with_players(names: str) -> list[tuple[str, list[str]]]: + """Every line is a team's name, optionally followed by its players, separated by commas.""" + return [ + (row[0].strip(), [player.strip() for player in row[1:] if player.strip()]) + for row in csv.reader(names.splitlines()) + if any(cell.strip() for cell in row) + ] + + class TeamMultiBody(BaseModelORM): names: str = Field(..., min_length=1) active: bool + + @field_validator("names") + @classmethod + def validate_names(cls, value: str) -> str: + teams = parse_teams_with_players(value) + if not teams: + raise ValueError("Enter at least one team") + if any(not team_name for team_name, _ in teams): + raise ValueError("Every line needs a team name before its players") + check_participant_names([name for team in teams for name in (team[0], *team[1])]) + return value + + @property + def teams_with_players(self) -> list[tuple[str, list[str]]]: + return parse_teams_with_players(self.names) diff --git a/backend/bracket/models/db/tournament.py b/backend/bracket/models/db/tournament.py index 909969bb..536fff8d 100644 --- a/backend/bracket/models/db/tournament.py +++ b/backend/bracket/models/db/tournament.py @@ -5,7 +5,7 @@ from bracket.models.db.shared import BaseModelORM from bracket.utils.id_types import ClubId, TournamentId -from bracket.utils.pydantic import EmptyStrToNone +from bracket.utils.pydantic import DashboardEndpoint, Name from bracket.utils.types import EnumAutoStr @@ -34,9 +34,9 @@ class Tournament(TournamentInsertable): class TournamentUpdateBody(BaseModelORM): start_time: datetime_utc - name: str + name: Name dashboard_public: bool - dashboard_endpoint: EmptyStrToNone | str = None + dashboard_endpoint: DashboardEndpoint = None players_can_be_in_multiple_teams: bool duration_minutes: int = Field(..., ge=1) margin_minutes: int = Field(..., ge=0) diff --git a/backend/bracket/models/db/user.py b/backend/bracket/models/db/user.py index 77369b3a..945443f7 100644 --- a/backend/bracket/models/db/user.py +++ b/backend/bracket/models/db/user.py @@ -8,7 +8,13 @@ from bracket.models.db.account import UserAccountType from bracket.models.db.shared import BaseModelORM from bracket.utils.id_types import UserId -from bracket.utils.security import normalize_email, validate_password_strength +from bracket.utils.pydantic import Name +from bracket.utils.security import ( + PASSWORD_MAX_LENGTH, + PASSWORD_MIN_LENGTH, + normalize_email, + validate_password_strength, +) if TYPE_CHECKING: from bracket.logic.subscriptions import Subscription @@ -47,7 +53,7 @@ class UserPublic(UserBase): class UserToUpdate(BaseModel): email: EmailStr - name: str + name: Name @field_validator("email") @classmethod @@ -56,17 +62,16 @@ def normalize_updated_email(cls, value: str) -> str: class UserPasswordToUpdate(BaseModel): - password: Annotated[str, StringConstraints(min_length=12, max_length=72)] - - @field_validator("password") - @classmethod - def validate_updated_password(cls, value: str) -> str: - return validate_password_strength(value) + current_password: str + # The strength is checked by the route, which knows the user's details a password mustn't use. + password: Annotated[ + str, StringConstraints(min_length=PASSWORD_MIN_LENGTH, max_length=PASSWORD_MAX_LENGTH) + ] class UserToRegister(BaseModelORM): email: EmailStr - name: str + name: Name password: str @field_validator("email") diff --git a/backend/bracket/routes/auth.py b/backend/bracket/routes/auth.py index 60e0db34..02a18bc7 100644 --- a/backend/bracket/routes/auth.py +++ b/backend/bracket/routes/auth.py @@ -1,15 +1,17 @@ -from typing import Any +import hashlib +import hmac +from http import HTTPMethod +from typing import Annotated import jwt from fastapi import APIRouter, Depends, HTTPException, Response, status -from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm +from fastapi.security import APIKeyCookie, OAuth2PasswordBearer, OAuth2PasswordRequestForm from heliclockter import datetime_utc, timedelta -from jwt import DecodeError, ExpiredSignatureError from pydantic import BaseModel from sqlalchemy.ext.asyncio import AsyncConnection from starlette.requests import Request -from bracket.config import config +from bracket.config import Environment, config, environment from bracket.database import DbConnection from bracket.models.db.tournament import Tournament, TournamentStatus from bracket.models.db.user import UserInDB, UserPublic @@ -19,9 +21,10 @@ get_user, get_user_access_to_club, get_user_access_to_tournament, + get_user_in_db, update_user_password, ) -from bracket.utils.db import fetch_all_parsed +from bracket.utils.db import fetch_one_parsed from bracket.utils.id_types import ClubId, TournamentId, UserId from bracket.utils.rate_limit import ( get_account_lockout_retry_after, @@ -29,18 +32,30 @@ record_account_failure, reset_account_failures, ) -from bracket.utils.security import normalize_email, verify_password +from bracket.utils.security import hash_password, normalize_email, verify_password from bracket.utils.types import assert_some router = APIRouter(prefix=config.api_prefix) ALGORITHM = "HS256" -ACCESS_TOKEN_EXPIRE_MINUTES = config.access_token_expire_minutes -oauth2_scheme = OAuth2PasswordBearer(tokenUrl=f"{config.api_prefix}/token") -oauth2_scheme_optional = OAuth2PasswordBearer( - tokenUrl=f"{config.api_prefix}/token", auto_error=False -) +# The frontend's session is a cookie that scripts can't read, so an injected script can't steal it. +# In production it's secure, and the `__Host-` prefix makes browsers reject it unless it was set by +# this host itself, so another subdomain can't plant a session. +SECURE_SESSION_COOKIE = environment is Environment.PRODUCTION +SESSION_COOKIE = "__Host-bracket_session" if SECURE_SESSION_COOKIE else "bracket_session" +# The header that state-changing requests signed in with the session cookie have to send. +CSRF_HEADER = "X-Requested-With" +CSRF_HEADER_VALUE = "XMLHttpRequest" +SAFE_METHODS = frozenset({HTTPMethod.GET, HTTPMethod.HEAD, HTTPMethod.OPTIONS}) + +# API clients send the token as a bearer token instead. +oauth2_scheme = OAuth2PasswordBearer(tokenUrl=f"{config.api_prefix}/token", auto_error=False) +session_cookie = APIKeyCookie(name=SESSION_COOKIE, auto_error=False) + +# Checked against when the email is unknown, so that a failed login takes as long whether or not +# the account exists, and the response time doesn't reveal which emails have an account. +DUMMY_PASSWORD_HASH = hash_password("password of an account that does not exist") class Token(BaseModel): @@ -50,8 +65,39 @@ class Token(BaseModel): name: str -class TokenData(BaseModel): - email: str | None = None +def unauthorized(detail: str = "Could not validate credentials") -> HTTPException: + return HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=detail, + headers={"WWW-Authenticate": "Bearer"}, + ) + + +async def protect_session_from_csrf(request: Request) -> None: + """ + Other sites can make a browser send its cookies, but not a custom header: browsers ask the + API's CORS allowlist first. So a state-changing request that is signed in with the session + cookie has to send one. This is OWASP's custom request header defense against CSRF. + """ + if ( + request.method not in SAFE_METHODS + and SESSION_COOKIE in request.cookies + # A request with its own credentials doesn't use the ambient cookie. + and "Authorization" not in request.headers + and request.headers.get(CSRF_HEADER) != CSRF_HEADER_VALUE + ): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"Requests signed in with the session cookie need the {CSRF_HEADER} header", + ) + + +def get_auth_hash(password_hash: str) -> str: + """ + Changes with the user's password. Tokens carry it, so changing the password signs out every + other session. + """ + return hmac.new(config.jwt_secret.encode(), password_hash.encode(), hashlib.sha256).hexdigest() async def authenticate_user(conn: AsyncConnection, email: str, password: str) -> UserInDB | None: @@ -61,7 +107,8 @@ async def authenticate_user(conn: AsyncConnection, email: str, password: str) -> return None user = await get_user(conn, normalized_email) - if not user: + if user is None: + verify_password(password, DUMMY_PASSWORD_HASH) return None is_valid, updated_hash = verify_password(password, user.password_hash) @@ -70,27 +117,50 @@ async def authenticate_user(conn: AsyncConnection, email: str, password: str) -> if updated_hash is not None: await update_user_password(conn, user.id, updated_hash) + return user.model_copy(update={"password_hash": updated_hash}) return user -def create_access_token(data: dict[str, Any], expires_delta: timedelta) -> str: - to_encode = data.copy() - if "sub" not in to_encode and "user" in to_encode: - to_encode["sub"] = to_encode["user"] +def create_access_token(user_id: UserId, password_hash: str) -> str: + # The subject is the user's id, not their email: it never changes, so a token stays valid when + # its user changes their email, and can't be taken over by a later account with the same email. issued_at = datetime_utc.now() - expire = issued_at + expires_delta - to_encode.update( + return jwt.encode( { + "sub": str(user_id), "aud": config.jwt_audience, - "exp": expire, + "exp": issued_at + timedelta(minutes=config.access_token_expire_minutes), "iat": issued_at, "iss": assert_some(config.jwt_issuer), "nbf": issued_at, "type": "access", - } + "auth_hash": get_auth_hash(password_hash), + }, + config.jwt_secret, + algorithm=ALGORITHM, ) - return jwt.encode(to_encode, config.jwt_secret, algorithm=ALGORITHM) + + +def issue_token(response: Response, user_id: UserId, name: str, password_hash: str) -> Token: + """Signs the user in: the token is in the body for API clients, and the session cookie.""" + access_token = create_access_token(user_id, password_hash) + response.set_cookie( + SESSION_COOKIE, + access_token, + max_age=config.access_token_expire_minutes * 60, + secure=SECURE_SESSION_COOKIE, + httponly=True, + samesite="strict", + ) + return Token(access_token=access_token, token_type="bearer", user_id=user_id, name=name) + + +async def get_token( + bearer_token: str | None = Depends(oauth2_scheme), + cookie_token: str | None = Depends(session_cookie), +) -> str | None: + return bearer_token or cookie_token async def check_jwt_and_get_user(conn: AsyncConnection, token: str) -> UserPublic | None: @@ -101,109 +171,88 @@ async def check_jwt_and_get_user(conn: AsyncConnection, token: str) -> UserPubli algorithms=[ALGORITHM], audience=config.jwt_audience, issuer=assert_some(config.jwt_issuer), - options={"require": ["aud", "exp", "iat", "iss", "nbf", "sub", "type"]}, + options={"require": ["aud", "exp", "iat", "iss", "nbf", "sub", "type", "auth_hash"]}, ) - if payload.get("type") != "access": - return None - email = str(payload.get("sub")) - token_data = TokenData(email=email) - except (DecodeError, ExpiredSignatureError): + user_id = UserId(int(payload["sub"])) + # `InvalidTokenError` is the base of every error a token can fail with, such as a bad signature, + # expiry, or a wrong audience. Tokens from before the subject became the user id fail `int()`. + except (jwt.InvalidTokenError, ValueError): return None - user = await get_user(conn, email=assert_some(token_data.email)) - if user is None: + if payload["type"] != "access": return None - return UserPublic.model_validate(user.model_dump()) + user = await get_user_in_db(conn, user_id) + if user is None or not hmac.compare_digest( + str(payload["auth_hash"]), get_auth_hash(user.password_hash) + ): + return None + return UserPublic.model_validate(user.model_dump(exclude={"password_hash"})) -async def user_authenticated(conn: DbConnection, token: str = Depends(oauth2_scheme)) -> UserPublic: - user = await check_jwt_and_get_user(conn, token) - if not user: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Could not validate credentials", - headers={"WWW-Authenticate": "Bearer"}, - ) - return UserPublic.model_validate(user.model_dump()) +async def user_authenticated( + conn: DbConnection, token: str | None = Depends(get_token) +) -> UserPublic: + user = await check_jwt_and_get_user(conn, token) if token is not None else None + if user is None: + raise unauthorized() + + return user async def user_authenticated_for_tournament( - tournament_id: TournamentId, conn: DbConnection, token: str = Depends(oauth2_scheme) + tournament_id: TournamentId, conn: DbConnection, token: str | None = Depends(get_token) ) -> UserPublic: - user = await check_jwt_and_get_user(conn, token) - - if not user or not await get_user_access_to_tournament(conn, tournament_id, user.id): - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Could not validate credentials", - headers={"WWW-Authenticate": "Bearer"}, - ) + user = await check_jwt_and_get_user(conn, token) if token is not None else None + if user is None or not await get_user_access_to_tournament(conn, tournament_id, user.id): + raise unauthorized() - return UserPublic.model_validate(user.model_dump()) + return user async def user_authenticated_for_club( - club_id: ClubId, conn: DbConnection, token: str = Depends(oauth2_scheme) + club_id: ClubId, conn: DbConnection, token: str | None = Depends(get_token) ) -> UserPublic: - user = await check_jwt_and_get_user(conn, token) + user = await check_jwt_and_get_user(conn, token) if token is not None else None + if user is None or not await get_user_access_to_club(conn, club_id, user.id): + raise unauthorized() - if not user or not await get_user_access_to_club(conn, club_id, user.id): - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Could not validate credentials", - headers={"WWW-Authenticate": "Bearer"}, - ) - - return UserPublic.model_validate(user.model_dump()) + return user async def user_authenticated_or_public_dashboard( - tournament_id: TournamentId, request: Request, conn: DbConnection + tournament_id: TournamentId, + conn: DbConnection, + token: str | None = Depends(get_token), ) -> UserPublic | None: - try: - token: str = assert_some(await oauth2_scheme(request)) + """ + The organizer of the tournament, or `None` for anyone else while the tournament is public. + """ + if token is not None: user = await check_jwt_and_get_user(conn, token) if user is not None and await get_user_access_to_tournament(conn, tournament_id, user.id): return user - except HTTPException: - pass - tournaments_fetched = await fetch_all_parsed( + tournament = await fetch_one_parsed( conn, Tournament, tournaments.select().where(tournaments.c.id == tournament_id) ) - if len(tournaments_fetched) < 1: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Could not validate credentials or page is not publicly available", - headers={"WWW-Authenticate": "Bearer"}, - ) - - tournament = tournaments_fetched[0] - is_public_read = tournament.dashboard_public or tournament.status == TournamentStatus.OPEN - if not is_public_read: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Could not validate credentials or page is not publicly available", - headers={"WWW-Authenticate": "Bearer"}, - ) + if tournament is None or not ( + tournament.dashboard_public or tournament.status is TournamentStatus.OPEN + ): + raise unauthorized("Could not validate credentials or page is not publicly available") return None async def user_authenticated_or_public_dashboard_by_endpoint_name( conn: DbConnection, - token: str | None = Depends(oauth2_scheme_optional), + token: str | None = Depends(get_token), endpoint_name: str | None = None, ) -> UserPublic | None: if endpoint_name is not None: if await sql_get_tournament_by_endpoint_name(conn, endpoint_name) is None: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Could not validate credentials", - headers={"WWW-Authenticate": "Bearer"}, - ) + raise unauthorized() return None if token is None: @@ -218,9 +267,9 @@ async def login_for_access_token( request: Request, response: Response, conn: DbConnection, - form_data: OAuth2PasswordRequestForm = Depends(), + form_data: Annotated[OAuth2PasswordRequestForm, Depends()], ) -> Token: - del request, response + del request try: normalized_email = normalize_email(form_data.username) @@ -238,23 +287,21 @@ async def login_for_access_token( user = await authenticate_user(conn, form_data.username, form_data.password) if not user: retry_after = record_account_failure(normalized_email) - raise HTTPException( - status_code=( - status.HTTP_429_TOO_MANY_REQUESTS - if retry_after is not None - else status.HTTP_401_UNAUTHORIZED - ), - detail="Incorrect email or password", - headers={ - "WWW-Authenticate": "Bearer", - **({"Retry-After": str(retry_after)} if retry_after is not None else {}), - }, - ) + if retry_after is not None: + raise HTTPException( + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + detail="Incorrect email or password", + headers={"Retry-After": str(retry_after)}, + ) + raise unauthorized("Incorrect email or password") reset_account_failures(normalized_email) + return issue_token(response, user.id, user.name, user.password_hash) + - access_token_expires = timedelta(minutes=config.access_token_expire_minutes) - access_token = create_access_token( - data={"sub": user.email, "user": user.email}, expires_delta=access_token_expires +@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT) +async def logout(response: Response) -> None: + """Ends the session in this browser by removing its cookie.""" + response.delete_cookie( + SESSION_COOKIE, secure=SECURE_SESSION_COOKIE, httponly=True, samesite="strict" ) - return Token(access_token=access_token, token_type="bearer", user_id=user.id, name=user.name) diff --git a/backend/bracket/routes/matches.py b/backend/bracket/routes/matches.py index 9f128627..f337a0ba 100644 --- a/backend/bracket/routes/matches.py +++ b/backend/bracket/routes/matches.py @@ -8,22 +8,17 @@ recalculate_ranking_for_stage_item, ) from bracket.logic.ranking.elimination import update_inputs_in_subsequent_elimination_rounds -from bracket.logic.scheduling.upcoming_matches import ( - get_draft_round_in_stage_item, - get_upcoming_matches_for_swiss, -) from bracket.models.db.match import ( Match, MatchBody, MatchCreateBody, MatchCreateBodyFrontend, - MatchFilter, ) from bracket.models.db.stage_item import StageType from bracket.models.db.tournament import Tournament from bracket.models.db.user import UserPublic from bracket.routes.auth import user_authenticated_for_tournament -from bracket.routes.models import SingleMatchResponse, SuccessResponse, UpcomingMatchesResponse +from bracket.routes.models import SingleMatchResponse, SuccessResponse from bracket.routes.util import disallow_archived_tournament, match_dependency from bracket.sql.matches import ( sql_create_match, @@ -35,40 +30,11 @@ from bracket.sql.stage_items import get_stage_item from bracket.sql.tournaments import sql_get_tournament from bracket.sql.validation import check_foreign_keys_belong_to_tournament -from bracket.utils.id_types import MatchId, StageItemId, TournamentId +from bracket.utils.id_types import MatchId, TournamentId router = APIRouter(prefix=config.api_prefix) -@router.get( - "/tournaments/{tournament_id}/stage_items/{stage_item_id}/upcoming_matches", - response_model=UpcomingMatchesResponse, -) -async def get_matches_to_schedule( - conn: DbConnection, - tournament_id: TournamentId, - stage_item_id: StageItemId, - elo_diff_threshold: int = 200, - iterations: int = 2_000, - only_recommended: bool = False, - limit: int = 50, - _: UserPublic = Depends(user_authenticated_for_tournament), -) -> UpcomingMatchesResponse: - match_filter = MatchFilter( - elo_diff_threshold=elo_diff_threshold, - only_recommended=only_recommended, - limit=limit, - iterations=iterations, - ) - - draft_round, stage_item = await get_draft_round_in_stage_item( - conn, tournament_id, stage_item_id - ) - return UpcomingMatchesResponse( - data=get_upcoming_matches_for_swiss(match_filter, stage_item, draft_round) - ) - - @router.delete("/tournaments/{tournament_id}/matches/{match_id}", response_model=SuccessResponse) async def delete_match( conn: DbConnection, diff --git a/backend/bracket/routes/models.py b/backend/bracket/routes/models.py index dc693c9a..5a390eb7 100644 --- a/backend/bracket/routes/models.py +++ b/backend/bracket/routes/models.py @@ -3,7 +3,7 @@ from bracket.logic.ranking.statistics import TeamStatistics from bracket.logic.scheduling.handle_stage_activation import StageItemInputUpdate from bracket.models.db.club import Club -from bracket.models.db.match import Match, SuggestedMatch +from bracket.models.db.match import Match from bracket.models.db.player import Player from bracket.models.db.ranking import Ranking from bracket.models.db.stage_item_inputs import ( @@ -59,10 +59,6 @@ class StagesWithStageItemsResponse(DataResponse[list[StageWithStageItems]]): pass -class UpcomingMatchesResponse(DataResponse[list[SuggestedMatch]]): - pass - - class SingleMatchResponse(DataResponse[Match]): pass diff --git a/backend/bracket/routes/players.py b/backend/bracket/routes/players.py index f8edd1af..9d8910ea 100644 --- a/backend/bracket/routes/players.py +++ b/backend/bracket/routes/players.py @@ -1,4 +1,5 @@ -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, HTTPException +from starlette import status from bracket.config import config from bracket.database import DbConnection @@ -26,10 +27,8 @@ insert_player, sql_delete_player, ) -from bracket.utils.db import fetch_one_parsed from bracket.utils.id_types import PlayerId, TournamentId from bracket.utils.pagination import PaginationPlayers -from bracket.utils.types import assert_some router = APIRouter(prefix=config.api_prefix) @@ -86,22 +85,21 @@ async def update_player_by_id( _: UserPublic = Depends(user_authenticated_for_tournament), __: Tournament = Depends(disallow_archived_tournament), ) -> SinglePlayerResponse: - await conn.execute( - players.update() - .where((players.c.id == player_id) & (players.c.tournament_id == tournament_id)) - .values(**player_body.model_dump()) - ) - return SinglePlayerResponse( - data=assert_some( - await fetch_one_parsed( - conn, - Player, - players.select().where( - (players.c.id == player_id) & (players.c.tournament_id == tournament_id) - ), - ) + player = ( + await conn.execute( + players.update() + .where((players.c.id == player_id) & (players.c.tournament_id == tournament_id)) + .values(**player_body.model_dump()) + .returning(players) ) - ) + ).first() + if player is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Could not find player with id {player_id}", + ) + + return SinglePlayerResponse(data=Player.model_validate(player._mapping)) @router.delete("/tournaments/{tournament_id}/players/{player_id}", response_model=SuccessResponse) @@ -138,7 +136,7 @@ async def create_multiple_players( user: UserPublic = Depends(user_authenticated_for_tournament), _: Tournament = Depends(disallow_archived_tournament), ) -> SuccessResponse: - player_names = [player.strip() for player in player_body.names.split("\n") if len(player) > 0] + player_names = player_body.player_names existing_players = await get_all_players_in_tournament(conn, tournament_id) check_requirement(existing_players, user, "max_players", additions=len(player_names)) diff --git a/backend/bracket/routes/rankings.py b/backend/bracket/routes/rankings.py index 1e879c70..5fed4087 100644 --- a/backend/bracket/routes/rankings.py +++ b/backend/bracket/routes/rankings.py @@ -11,7 +11,7 @@ update_inputs_in_complete_elimination_stage_item, ) from bracket.logic.subscriptions import check_requirement -from bracket.models.db.ranking import RankingBody, RankingCreateBody +from bracket.models.db.ranking import Ranking, RankingBody, RankingCreateBody from bracket.models.db.stage_item import StageType from bracket.models.db.tournament import Tournament from bracket.models.db.user import UserPublic @@ -25,14 +25,14 @@ StandingsResponse, SuccessResponse, ) -from bracket.routes.util import disallow_archived_tournament +from bracket.routes.util import disallow_archived_tournament, ranking_dependency from bracket.sql.rankings import ( get_all_rankings_in_tournament, sql_create_ranking, sql_delete_ranking, sql_update_ranking, ) -from bracket.sql.stage_item_inputs import get_stage_item_input_ids_by_ranking_id +from bracket.sql.stage_item_inputs import get_stage_item_ids_by_ranking_id from bracket.sql.stage_items import get_stage_item from bracket.sql.stages import get_full_tournament_details from bracket.utils.id_types import RankingId, TournamentId @@ -79,6 +79,7 @@ async def update_ranking_by_id( ranking_body: RankingBody, _: UserPublic = Depends(user_authenticated_for_tournament), __: Tournament = Depends(disallow_archived_tournament), + ___: Ranking = Depends(ranking_dependency), ) -> SuccessResponse: await sql_update_ranking( conn, @@ -86,7 +87,7 @@ async def update_ranking_by_id( ranking_id=ranking_id, ranking_body=ranking_body, ) - stage_item_ids = await get_stage_item_input_ids_by_ranking_id(conn, ranking_id) + stage_item_ids = await get_stage_item_ids_by_ranking_id(conn, tournament_id, ranking_id) for stage_item_id in stage_item_ids: stage_item = await get_stage_item(conn, tournament_id, stage_item_id) await recalculate_ranking_for_stage_item(conn, tournament_id, stage_item) @@ -103,8 +104,9 @@ async def delete_ranking( ranking_id: RankingId, _: UserPublic = Depends(user_authenticated_for_tournament), __: Tournament = Depends(disallow_archived_tournament), + ___: Ranking = Depends(ranking_dependency), ) -> SuccessResponse: - stage_item_ids = await get_stage_item_input_ids_by_ranking_id(conn, ranking_id) + stage_item_ids = await get_stage_item_ids_by_ranking_id(conn, tournament_id, ranking_id) if stage_item_ids: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, diff --git a/backend/bracket/routes/stage_item_inputs.py b/backend/bracket/routes/stage_item_inputs.py index a7651653..f7dd837e 100644 --- a/backend/bracket/routes/stage_item_inputs.py +++ b/backend/bracket/routes/stage_item_inputs.py @@ -47,10 +47,8 @@ async def validate_stage_item_update( if isinstance(stage_item_input_body, StageItemInputUpdateBodyTentative): input_id = stage_item_input_body.winner_from_stage_item_id - winner_from_stage = await get_full_tournament_details( - conn, tournament_id, stage_item_ids={input_id} - ) - if winner_from_stage is None: + # Empty unless the stage item is part of this tournament. + if not await get_full_tournament_details(conn, tournament_id, stage_item_ids={input_id}): raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Could not find stage item with id {input_id}", diff --git a/backend/bracket/routes/stage_items.py b/backend/bracket/routes/stage_items.py index 8b744948..3c557b47 100644 --- a/backend/bracket/routes/stage_items.py +++ b/backend/bracket/routes/stage_items.py @@ -49,6 +49,7 @@ async def delete_stage_item( stage_item_id: StageItemId, _: UserPublic = Depends(user_authenticated_for_tournament), __: StageItemWithRounds = Depends(stage_item_dependency), + ___: Tournament = Depends(disallow_archived_tournament), ) -> SuccessResponse: with check_foreign_key_violation( {ForeignKey.matches_stage_item_input1_id_fkey, ForeignKey.matches_stage_item_input2_id_fkey} @@ -64,6 +65,7 @@ async def create_stage_item( tournament_id: TournamentId, stage_body: StageItemCreateBody, user: UserPublic = Depends(user_authenticated_for_tournament), + _: Tournament = Depends(disallow_archived_tournament), ) -> SuccessResponse: await check_foreign_keys_belong_to_tournament(conn, stage_body, tournament_id) diff --git a/backend/bracket/routes/teams.py b/backend/bracket/routes/teams.py index ce870f47..2efb1b56 100644 --- a/backend/bracket/routes/teams.py +++ b/backend/bracket/routes/teams.py @@ -1,7 +1,4 @@ -import csv - -import aiofiles.os -from fastapi import APIRouter, Depends, HTTPException, UploadFile +from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, UploadFile from heliclockter import datetime_utc from sqlalchemy.ext.asyncio import AsyncConnection from starlette import status @@ -48,10 +45,13 @@ from bracket.utils.db import fetch_one_parsed from bracket.utils.errors import ForeignKey, check_foreign_key_violation from bracket.utils.id_types import PlayerId, TeamId, TournamentId -from bracket.utils.logging import logger from bracket.utils.pagination import PaginationTeams from bracket.utils.types import assert_some -from bracket.utils.uploads import get_image_media_type, store_validated_image_upload +from bracket.utils.uploads import ( + get_image_media_type, + remove_existing_upload, + store_validated_image_upload, +) router = APIRouter(prefix=config.api_prefix) @@ -127,24 +127,16 @@ async def update_team_by_id( async def update_team_logo( conn: DbConnection, tournament_id: TournamentId, + background_tasks: BackgroundTasks, file: UploadFile | None = None, _: UserPublic = Depends(user_authenticated_for_tournament), __: Tournament = Depends(disallow_archived_tournament), team: Team = Depends(team_dependency), ) -> SingleTeamResponse: - old_logo_path = await get_team_logo_path(conn, tournament_id, team.id) - filename: str | None = None - - if file: - filename = await store_validated_image_upload(file, "team-logos") - - if old_logo_path is not None and old_logo_path != filename: - try: - await aiofiles.os.remove(old_logo_path) - except OSError as exc: - logger.error(f"Could not remove logo that should still exist: {old_logo_path}\n{exc}") - + filename = await store_validated_image_upload(file, "team-logos") if file else None await conn.execute(teams.update().where(teams.c.id == team.id).values(logo_path=filename)) + # Background tasks run after the response, so the new logo is committed by then. + background_tasks.add_task(remove_existing_upload, "team-logos", team.logo_path) return SingleTeamResponse(data=assert_some(await get_team_by_id(conn, team.id, tournament_id))) @@ -152,11 +144,10 @@ async def update_team_logo( async def get_team_logo( conn: DbConnection, tournament_id: TournamentId, - team_id: TeamId, _: UserPublic | None = Depends(user_authenticated_or_public_dashboard), + team: Team = Depends(team_dependency), ) -> FileResponse: - team = assert_some(await get_team_by_id(conn, team_id, tournament_id)) - logo_path = await get_team_logo_path(conn, tournament_id, team_id) + logo_path = await get_team_logo_path(conn, tournament_id, team.id) if team.logo_path is None or logo_path is None: raise HTTPException(status.HTTP_404_NOT_FOUND, "Logo not found") @@ -224,23 +215,19 @@ async def create_multiple_teams( user: UserPublic = Depends(user_authenticated_for_tournament), _: Tournament = Depends(disallow_archived_tournament), ) -> SuccessResponse: - reader = list(csv.reader(team_body.names.split("\n"), delimiter=",")) - teams_and_players = [ - (row[0], [p for p in row[1:] if len(p) > 0] if len(row) > 1 else []) - for row in reader - if len(row) > 0 - ] - players = [player for row in teams_and_players for player in row[1]] + teams_with_players = team_body.teams_with_players + player_count = sum(len(player_names) for _, player_names in teams_with_players) existing_teams = await get_teams_with_members(conn, tournament_id) existing_players = await get_all_players_in_tournament(conn, tournament_id) - check_requirement(existing_teams, user, "max_teams", additions=len(reader)) - check_requirement(existing_players, user, "max_players", additions=len(players)) + check_requirement(existing_teams, user, "max_teams", additions=len(teams_with_players)) + check_requirement(existing_players, user, "max_players", additions=player_count) - for team_name, players in teams_and_players: - await conn.execute( - teams.insert().values( + for team_name, player_names in teams_with_players: + team_id = await conn.scalar( + teams.insert() + .values( **TeamInsertable( name=team_name, active=team_body.active, @@ -248,9 +235,14 @@ async def create_multiple_teams( tournament_id=tournament_id, ).model_dump() ) + .returning(teams.c.id) ) - for player in players: - player_body = PlayerBody(name=player, active=team_body.active) - await insert_player(conn, player_body, tournament_id) + for player_name in player_names: + player_id = await insert_player( + conn, PlayerBody(name=player_name, active=team_body.active), tournament_id + ) + await conn.execute( + players_x_teams.insert().values(team_id=team_id, player_id=player_id) + ) return SuccessResponse() diff --git a/backend/bracket/routes/tournaments.py b/backend/bracket/routes/tournaments.py index c81abf38..4be4098a 100644 --- a/backend/bracket/routes/tournaments.py +++ b/backend/bracket/routes/tournaments.py @@ -1,6 +1,5 @@ from typing import Literal -import aiofiles.os from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, UploadFile from starlette import status from starlette.responses import FileResponse @@ -45,7 +44,6 @@ check_unique_constraint_violation, ) from bracket.utils.id_types import TournamentId -from bracket.utils.logging import logger from bracket.utils.uploads import ( get_image_media_type, remove_existing_upload, @@ -158,17 +156,12 @@ async def create_tournament( tournament_to_insert: TournamentBody, user: UserPublic = Depends(user_authenticated), ) -> SuccessResponse: + if not await get_user_access_to_club(conn, tournament_to_insert.club_id, user.id): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Club ID is invalid") + existing_tournaments = await sql_get_tournaments(conn, (tournament_to_insert.club_id,)) check_requirement(existing_tournaments, user, "max_tournaments") - has_access_to_club = await get_user_access_to_club(conn, tournament_to_insert.club_id, user.id) - if not has_access_to_club: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Club ID is invalid", - headers={"WWW-Authenticate": "Bearer"}, - ) - with check_unique_constraint_violation({UniqueIndex.ix_tournaments_dashboard_endpoint}): tournament_id = await sql_create_tournament(conn, tournament_to_insert) @@ -182,25 +175,17 @@ async def create_tournament( async def upload_logo( conn: DbConnection, tournament_id: TournamentId, + background_tasks: BackgroundTasks, file: UploadFile | None = None, _: UserPublic = Depends(user_authenticated_for_tournament), - __: Tournament = Depends(disallow_archived_tournament), + tournament: Tournament = Depends(disallow_archived_tournament), ) -> TournamentResponse: - old_logo_path = await get_tournament_logo_path(conn, tournament_id) - filename: str | None = None - - if file: - filename = await store_validated_image_upload(file, "tournament-logos") - - if old_logo_path is not None and old_logo_path != filename: - try: - await aiofiles.os.remove(old_logo_path) - except OSError as exc: - logger.error(f"Could not remove logo that should still exist: {old_logo_path}\n{exc}") - + filename = await store_validated_image_upload(file, "tournament-logos") if file else None await conn.execute( tournaments.update().where(tournaments.c.id == tournament_id).values(logo_path=filename) ) + # Background tasks run after the response, so the new logo is committed by then. + background_tasks.add_task(remove_existing_upload, "tournament-logos", tournament.logo_path) return TournamentResponse(data=await sql_get_tournament(conn, tournament_id)) diff --git a/backend/bracket/routes/users.py b/backend/bracket/routes/users.py index f1cec55b..1bdbd57c 100644 --- a/backend/bracket/routes/users.py +++ b/backend/bracket/routes/users.py @@ -1,5 +1,5 @@ from fastapi import APIRouter, Depends, HTTPException, Response -from heliclockter import datetime_utc, timedelta +from heliclockter import datetime_utc from starlette import status from starlette.requests import Request @@ -13,26 +13,31 @@ UserToRegister, UserToUpdate, ) -from bracket.routes.auth import ( - Token, - create_access_token, - user_authenticated, -) +from bracket.routes.auth import issue_token, user_authenticated from bracket.routes.models import SuccessResponse, TokenResponse, UserPublicResponse from bracket.sql.users import ( check_whether_email_is_in_use, create_user, get_user_by_id, + get_user_in_db, update_user, update_user_password, ) +from bracket.utils.errors import UniqueIndex, check_unique_constraint_violation from bracket.utils.id_types import UserId from bracket.utils.rate_limit import limiter -from bracket.utils.security import hash_password +from bracket.utils.security import hash_password, validate_password_strength, verify_password from bracket.utils.types import assert_some router = APIRouter(prefix=config.api_prefix) +EMAIL_UNIQUE_INDEXES = {UniqueIndex.ix_users_email, UniqueIndex.ix_users_email_lower} + + +def check_is_same_user(user_public: UserPublic, user_id: UserId) -> None: + if user_public.id != user_id: + raise HTTPException(status.HTTP_403_FORBIDDEN, "You can only manage your own account") + @router.get("/users/me", response_model=UserPublicResponse) async def get_user(user_public: UserPublic = Depends(user_authenticated)) -> UserPublicResponse: @@ -43,9 +48,7 @@ async def get_user(user_public: UserPublic = Depends(user_authenticated)) -> Use async def get_me( user_id: UserId, user_public: UserPublic = Depends(user_authenticated) ) -> UserPublicResponse: - if user_public.id != user_id: - raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Can't view details of this user") - + check_is_same_user(user_public, user_id) return UserPublicResponse(data=user_public) @@ -56,24 +59,46 @@ async def update_user_details( user_to_update: UserToUpdate, user_public: UserPublic = Depends(user_authenticated), ) -> UserPublicResponse: - if user_public.id != user_id: - raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Can't change details of this user") + check_is_same_user(user_public, user_id) + + with check_unique_constraint_violation(EMAIL_UNIQUE_INDEXES): + await update_user(conn, user_public.id, user_to_update) - await update_user(conn, user_public.id, user_to_update) user_updated = await get_user_by_id(conn, user_id) return UserPublicResponse(data=assert_some(user_updated)) @router.put("/users/{user_id}/password", response_model=SuccessResponse) +@limiter.limit("10/5minutes") async def put_user_password( + request: Request, + response: Response, conn: DbConnection, user_id: UserId, user_to_update: UserPasswordToUpdate, user_public: UserPublic = Depends(user_authenticated), ) -> SuccessResponse: - if user_public.id != user_id: - raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Can't change details of this user") - await update_user_password(conn, user_public.id, hash_password(user_to_update.password)) + """ + Asks for the current password as well, so that a stolen session can't lock the owner out. + + Signs out every other session. This one gets a new session cookie. + """ + del request + check_is_same_user(user_public, user_id) + + user = assert_some(await get_user_in_db(conn, user_public.id)) + is_current_password, _ = verify_password(user_to_update.current_password, user.password_hash) + if not is_current_password: + raise HTTPException(status.HTTP_400_BAD_REQUEST, "The current password is incorrect") + + try: + validate_password_strength(user_to_update.password, (user.email, user.name)) + except ValueError as exc: + raise HTTPException(status.HTTP_422_UNPROCESSABLE_CONTENT, str(exc)) from exc + + password_hash = hash_password(user_to_update.password) + await update_user_password(conn, user.id, password_hash) + issue_token(response, user.id, user.name, password_hash) return SuccessResponse() @@ -82,10 +107,10 @@ async def put_user_password( async def register_user( conn: DbConnection, request: Request, response: Response, user_to_register: UserToRegister ) -> TokenResponse: - if not config.allow_user_registration: - raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Account creation is unavailable for now") + del request - del request, response + if not config.allow_user_registration: + raise HTTPException(status.HTTP_403_FORBIDDEN, "Account creation is unavailable for now") user = UserInsertable( email=user_to_register.email, @@ -97,17 +122,11 @@ async def register_user( if await check_whether_email_is_in_use(conn, user.email): raise HTTPException(status.HTTP_400_BAD_REQUEST, "Email address already in use") - user_created = await create_user(conn, user) - access_token_expires = timedelta(minutes=config.access_token_expire_minutes) - access_token = create_access_token( - data={"sub": user_created.email, "user": user_created.email}, - expires_delta=access_token_expires, - ) + with check_unique_constraint_violation(EMAIL_UNIQUE_INDEXES): + user_created = await create_user(conn, user) + return TokenResponse( - data=Token( - access_token=access_token, - token_type="bearer", - user_id=user_created.id, - name=user_created.name, + data=issue_token( + response, user_created.id, user_created.name, assert_some(user_created.password_hash) ) ) diff --git a/backend/bracket/routes/util.py b/backend/bracket/routes/util.py index 2865f355..2d05352f 100644 --- a/backend/bracket/routes/util.py +++ b/backend/bracket/routes/util.py @@ -3,18 +3,27 @@ from bracket.database import DbConnection from bracket.models.db.match import Match +from bracket.models.db.ranking import Ranking from bracket.models.db.round import Round from bracket.models.db.team import FullTeamWithPlayers, Team from bracket.models.db.tournament import Tournament, TournamentStatus from bracket.models.db.util import RoundWithMatches, StageItemWithRounds, StageWithStageItems -from bracket.schema import matches, rounds, stage_items, stages, teams +from bracket.schema import matches, rankings, rounds, stage_items, stages, teams from bracket.sql.rounds import get_round_by_id from bracket.sql.stage_items import get_stage_item from bracket.sql.stages import get_full_tournament_details from bracket.sql.teams import get_teams_with_members from bracket.sql.tournaments import sql_get_tournament from bracket.utils.db import fetch_one_parsed -from bracket.utils.id_types import MatchId, RoundId, StageId, StageItemId, TeamId, TournamentId +from bracket.utils.id_types import ( + MatchId, + RankingId, + RoundId, + StageId, + StageItemId, + TeamId, + TournamentId, +) async def round_dependency( @@ -123,6 +132,26 @@ async def team_with_players_dependency( return teams_with_members[0] +async def ranking_dependency( + tournament_id: TournamentId, ranking_id: RankingId, conn: DbConnection +) -> Ranking: + ranking = await fetch_one_parsed( + conn, + Ranking, + rankings.select().where( + (rankings.c.id == ranking_id) & (rankings.c.tournament_id == tournament_id) + ), + ) + + if ranking is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Could not find ranking with id {ranking_id}", + ) + + return ranking + + async def disallow_archived_tournament( tournament_id: TournamentId, conn: DbConnection ) -> Tournament: @@ -131,7 +160,6 @@ async def disallow_archived_tournament( raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Can't update archived tournament", - headers={"WWW-Authenticate": "Bearer"}, ) return tournament diff --git a/backend/bracket/schema.py b/backend/bracket/schema.py index 716028e5..9e90e403 100644 --- a/backend/bracket/schema.py +++ b/backend/bracket/schema.py @@ -1,4 +1,14 @@ -from sqlalchemy import Column, ForeignKey, Integer, MetaData, String, Table, UniqueConstraint, func +from sqlalchemy import ( + Column, + ForeignKey, + Index, + Integer, + MetaData, + String, + Table, + UniqueConstraint, + func, +) from sqlalchemy.sql.sqltypes import BigInteger, Boolean, DateTime, Enum, Float, Text metadata = MetaData() @@ -230,6 +240,8 @@ nullable=False, ), ) +# Created by the `6d5f2a8ec1b1` migration. Logins look users up by it. +Index("ix_users_email_lower", func.lower(users.c.email), unique=True) users_x_clubs = Table( "users_x_clubs", diff --git a/backend/bracket/sql/players.py b/backend/bracket/sql/players.py index d15376ec..b00e480a 100644 --- a/backend/bracket/sql/players.py +++ b/backend/bracket/sql/players.py @@ -97,14 +97,19 @@ async def sql_delete_players_of_tournament( async def insert_player( conn: AsyncConnection, player_body: PlayerBody, tournament_id: TournamentId -) -> None: - await conn.execute( - players.insert(), - PlayerToInsert( - **player_body.model_dump(), - created=datetime_utc.now(), - tournament_id=tournament_id, - elo_score=START_ELO, - swiss_score=Decimal("0.0"), - ).model_dump(), +) -> PlayerId: + return PlayerId( + await conn.scalar( + players.insert() + .values( + **PlayerToInsert( + **player_body.model_dump(), + created=datetime_utc.now(), + tournament_id=tournament_id, + elo_score=START_ELO, + swiss_score=Decimal("0.0"), + ).model_dump() + ) + .returning(players.c.id) + ) ) diff --git a/backend/bracket/sql/stage_item_inputs.py b/backend/bracket/sql/stage_item_inputs.py index 84dbf053..51f5be01 100644 --- a/backend/bracket/sql/stage_item_inputs.py +++ b/backend/bracket/sql/stage_item_inputs.py @@ -40,15 +40,19 @@ async def get_stage_item_input_by_id( return TypeAdapter(StageItemInput).validate_python(dict(result._mapping)) -async def get_stage_item_input_ids_by_ranking_id( - conn: AsyncConnection, ranking_id: RankingId +async def get_stage_item_ids_by_ranking_id( + conn: AsyncConnection, tournament_id: TournamentId, ranking_id: RankingId ) -> list[StageItemId]: query = """ - SELECT id + SELECT stage_items.id FROM stage_items - WHERE ranking_id = :ranking_id + JOIN stages ON stages.id = stage_items.stage_id + WHERE stage_items.ranking_id = :ranking_id + AND stages.tournament_id = :tournament_id """ - results = await conn.execute(text(query), {"ranking_id": ranking_id}) + results = await conn.execute( + text(query), {"ranking_id": ranking_id, "tournament_id": tournament_id} + ) return [StageItemId(result.id) for result in results] diff --git a/backend/bracket/sql/users.py b/backend/bracket/sql/users.py index 34fad9cf..18ac2c60 100644 --- a/backend/bracket/sql/users.py +++ b/backend/bracket/sql/users.py @@ -1,4 +1,4 @@ -from sqlalchemy import text +from sqlalchemy import func, text from sqlalchemy.ext.asyncio import AsyncConnection from bracket.logic.tournaments import sql_delete_tournament_completely @@ -16,13 +16,17 @@ async def get_user_access_to_tournament( conn: AsyncConnection, tournament_id: TournamentId, user_id: UserId ) -> bool: query = """ - SELECT DISTINCT t.id - FROM users_x_clubs - JOIN tournaments t ON t.club_id = users_x_clubs.club_id - WHERE user_id = :user_id + SELECT EXISTS ( + SELECT 1 + FROM users_x_clubs + JOIN tournaments ON tournaments.club_id = users_x_clubs.club_id + WHERE users_x_clubs.user_id = :user_id + AND tournaments.id = :tournament_id + ) """ - result = await conn.execute(text(query), {"user_id": user_id}) - return tournament_id in {tournament.id for tournament in result} + return bool( + await conn.scalar(text(query), {"user_id": user_id, "tournament_id": tournament_id}) + ) async def get_which_clubs_has_user_access_to(conn: AsyncConnection, user_id: UserId) -> set[ClubId]: @@ -36,7 +40,15 @@ async def get_which_clubs_has_user_access_to(conn: AsyncConnection, user_id: Use async def get_user_access_to_club(conn: AsyncConnection, club_id: ClubId, user_id: UserId) -> bool: - return club_id in await get_which_clubs_has_user_access_to(conn, user_id) + query = """ + SELECT EXISTS ( + SELECT 1 + FROM users_x_clubs + WHERE user_id = :user_id + AND club_id = :club_id + ) + """ + return bool(await conn.scalar(text(query), {"user_id": user_id, "club_id": club_id})) async def update_user(conn: AsyncConnection, user_id: UserId, user: UserToUpdate) -> None: @@ -119,12 +131,19 @@ async def check_whether_email_is_in_use(conn: AsyncConnection, email: str) -> bo async def get_user(conn: AsyncConnection, email: str) -> UserInDB | None: - normalized_email = normalize_email(email) + # An exact match on the unique `ix_users_email_lower` index. Never a pattern match such as + # `ILIKE`: `%` and `_` are valid in emails, so `%@example.org` would match other accounts. return await fetch_one_parsed( - conn, UserInDB, users.select().where(users.c.email.ilike(normalized_email)) + conn, + UserInDB, + users.select().where(func.lower(users.c.email) == normalize_email(email)), ) +async def get_user_in_db(conn: AsyncConnection, user_id: UserId) -> UserInDB | None: + return await fetch_one_parsed(conn, UserInDB, users.select().where(users.c.id == user_id)) + + async def delete_user_and_owned_clubs(conn: AsyncConnection, user_id: UserId) -> list[str]: """ Delete the user with their clubs and tournaments. diff --git a/backend/bracket/sql/validation.py b/backend/bracket/sql/validation.py index 3b569c33..4f239ef5 100644 --- a/backend/bracket/sql/validation.py +++ b/backend/bracket/sql/validation.py @@ -8,11 +8,13 @@ from bracket.models.db.util import StageWithStageItems from bracket.sql.players import get_all_players_in_tournament, get_player_by_id +from bracket.sql.rankings import get_all_rankings_in_tournament from bracket.sql.stages import get_full_tournament_details from bracket.sql.teams import get_team_by_id from bracket.utils.id_types import ( MatchId, PlayerId, + RankingId, RoundId, StageId, StageItemId, @@ -98,6 +100,18 @@ async def check_player_belongs_to_tournament( return await get_player_by_id(conn, player_id, tournament_id) is not None +async def check_ranking_belongs_to_tournament( + conn: AsyncConnection, + ranking_id: RankingId, + _: list[StageWithStageItems], + tournament_id: TournamentId, +) -> bool: + return any( + ranking.id == ranking_id + for ranking in await get_all_rankings_in_tournament(conn, tournament_id) + ) + + async def check_players_belong_to_tournament( conn: AsyncConnection, player_ids: set[PlayerId], tournament_id: TournamentId ) -> bool: @@ -130,6 +144,7 @@ async def check_foreign_keys_belong_to_tournament( RoundId: check_round_belongs_to_tournament, PlayerId: check_player_belongs_to_tournament, MatchId: check_match_belongs_to_tournament, + RankingId: check_ranking_belongs_to_tournament, } for field_key, field_info in type(some_body).model_fields.items(): diff --git a/backend/bracket/utils/db_init.py b/backend/bracket/utils/db_init.py index e5ee23cb..d6e94179 100644 --- a/backend/bracket/utils/db_init.py +++ b/backend/bracket/utils/db_init.py @@ -117,9 +117,6 @@ async def init_db_when_empty(conn: AsyncConnection) -> UserId | None: if table_count is not None and table_count <= 1: logger.warning("Empty db detected, creating tables...") await conn.run_sync(metadata.create_all) - await conn.exec_driver_sql( - "CREATE UNIQUE INDEX IF NOT EXISTS ix_users_email_lower ON users (LOWER(email));" - ) await conn.run_sync(alembic_stamp_head) if config.admin_email and config.admin_password: diff --git a/backend/bracket/utils/errors.py b/backend/bracket/utils/errors.py index ef273ca0..f96c0039 100644 --- a/backend/bracket/utils/errors.py +++ b/backend/bracket/utils/errors.py @@ -14,6 +14,7 @@ class UniqueIndex(EnumAutoStr): ix_clubs_name = auto() ix_tournaments_dashboard_endpoint = auto() ix_users_email = auto() + ix_users_email_lower = auto() stage_item_inputs_stage_item_id_team_id_key = auto() stage_item_inputs_stage_item_id_winner_from_stage_item_id_w_key = auto() @@ -29,6 +30,7 @@ class ForeignKey(EnumAutoStr): UniqueIndex.ix_clubs_name: "This event name is already taken", UniqueIndex.ix_tournaments_dashboard_endpoint: "This Details link is already taken", UniqueIndex.ix_users_email: "This email is already taken", + UniqueIndex.ix_users_email_lower: "This email is already taken", UniqueIndex.stage_item_inputs_stage_item_id_team_id_key: ( "This team is already assigned to another stage item" ), diff --git a/backend/bracket/utils/pagination.py b/backend/bracket/utils/pagination.py index 246de3af..f14d3d0b 100644 --- a/backend/bracket/utils/pagination.py +++ b/backend/bracket/utils/pagination.py @@ -3,10 +3,16 @@ from fastapi import Query +# Larger than any tournament's players or teams can get (see `bracket.logic.subscriptions`), so the +# frontend can load all of them with one request. +MAX_PAGE_SIZE = 500 + @dataclass class Pagination: - limit: int = Query(25, ge=1, le=100, description="Max number of results in a single page.") + limit: int = Query( + 25, ge=1, le=MAX_PAGE_SIZE, description="Max number of results in a single page." + ) offset: int = Query(0, ge=0, description="Filter results starting from this offset.") sort_direction: Literal["asc", "desc"] = "asc" diff --git a/backend/bracket/utils/pydantic.py b/backend/bracket/utils/pydantic.py index 2fcc20d1..958a3016 100644 --- a/backend/bracket/utils/pydantic.py +++ b/backend/bracket/utils/pydantic.py @@ -1,13 +1,52 @@ +import re from typing import Annotated -from pydantic import PlainValidator +from pydantic import AfterValidator, StringConstraints +# What a team or player name may be. They are shown in brackets and schedules, so they are short. +PARTICIPANT_NAME_MAX_LENGTH = 30 -def accept_none_and_empty_str(value: str | None) -> None: - if value is None or value == "": +# A name typed into a form. Surrounding whitespace is dropped and the name can't be blank. +Name = Annotated[str, StringConstraints(strip_whitespace=True, min_length=1, max_length=100)] +ParticipantName = Annotated[ + str, + StringConstraints(strip_whitespace=True, min_length=1, max_length=PARTICIPANT_NAME_MAX_LENGTH), +] + + +DASHBOARD_ENDPOINT_MAX_LENGTH = 64 + + +def validate_dashboard_endpoint(value: str | None) -> str | None: + """ + A tournament's custom Details link, or `None` without one. + + It is a segment of the page's URL, so only URL-safe characters are allowed. It also needs a + letter: the frontend reads a link of only digits as a tournament id. + """ + if value is None or not value.strip(): return None - raise ValueError("Not an empty str or None") + value = value.strip() + if len(value) > DASHBOARD_ENDPOINT_MAX_LENGTH: + raise ValueError( + f"The Details link can be at most {DASHBOARD_ENDPOINT_MAX_LENGTH} characters long" + ) + if not re.fullmatch(r"[A-Za-z0-9_-]*[A-Za-z][A-Za-z0-9_-]*", value): + raise ValueError( + 'Use only letters, numbers, "-" and "_" in the Details link, with at least one letter' + ) + + return value + + +DashboardEndpoint = Annotated[str | None, AfterValidator(validate_dashboard_endpoint)] -EmptyStrToNone = Annotated[None, PlainValidator(accept_none_and_empty_str)] +def check_participant_names(names: list[str]) -> None: + for name in names: + if len(name) > PARTICIPANT_NAME_MAX_LENGTH: + raise ValueError( + f'"{name}" is longer than the {PARTICIPANT_NAME_MAX_LENGTH} characters a name ' + "can have" + ) diff --git a/backend/bracket/uvicorn.py b/backend/bracket/uvicorn.py deleted file mode 100644 index 87c25fc8..00000000 --- a/backend/bracket/uvicorn.py +++ /dev/null @@ -1,31 +0,0 @@ -import os -import signal -import threading -import time -from typing import Any - -from uvicorn.workers import UvicornWorker - - -class ReloaderThread(threading.Thread): - def __init__(self, worker: UvicornWorker, sleep_interval: float = 1.0): - super().__init__() - self._worker = worker - self._interval = sleep_interval - - def run(self) -> None: - while True: - if not self._worker.alive: - os.kill(os.getpid(), signal.SIGINT) - time.sleep(self._interval) - - -class RestartableUvicornWorker(UvicornWorker): - def __init__(self, *args: list[Any], **kwargs: dict[str, Any]): - super().__init__(*args, **kwargs) - self._reloader_thread = ReloaderThread(self) - - def run(self) -> None: - if self.cfg.reload: - self._reloader_thread.start() - super().run() diff --git a/backend/openapi/openapi.json b/backend/openapi/openapi.json index d6583577..72ca9aa9 100644 --- a/backend/openapi/openapi.json +++ b/backend/openapi/openapi.json @@ -164,6 +164,8 @@ "ClubCreateBody": { "properties": { "name": { + "maxLength": 100, + "minLength": 1, "title": "Name", "type": "string" } @@ -196,6 +198,8 @@ "ClubUpdateBody": { "properties": { "name": { + "maxLength": 100, + "minLength": 1, "title": "Name", "type": "string" } @@ -501,6 +505,7 @@ "custom_duration_minutes": { "anyOf": [ { + "minimum": 1.0, "type": "integer" }, { @@ -512,6 +517,7 @@ "custom_margin_minutes": { "anyOf": [ { + "minimum": 0.0, "type": "integer" }, { @@ -526,11 +532,13 @@ }, "stage_item_input1_score": { "default": 0, + "minimum": 0.0, "title": "Stage Item Input1 Score", "type": "integer" }, "stage_item_input2_score": { "default": 0, + "minimum": 0.0, "title": "Stage Item Input2 Score", "type": "integer" } @@ -1296,6 +1304,8 @@ "name": { "anyOf": [ { + "maxLength": 100, + "minLength": 1, "type": "string" }, { @@ -1323,6 +1333,8 @@ "type": "boolean" }, "name": { + "maxLength": 100, + "minLength": 1, "title": "Name", "type": "string" } @@ -1442,6 +1454,8 @@ "name": { "anyOf": [ { + "maxLength": 100, + "minLength": 1, "type": "string" }, { @@ -1920,6 +1934,8 @@ "StageItemUpdateBody": { "properties": { "name": { + "maxLength": 100, + "minLength": 1, "title": "Name", "type": "string" }, @@ -2061,6 +2077,8 @@ "title": "Custom Duration Minutes" }, "name": { + "maxLength": 100, + "minLength": 1, "title": "Name", "type": "string" } @@ -2176,71 +2194,6 @@ "title": "SuccessResponse", "type": "object" }, - "SuggestedMatch": { - "properties": { - "elo_diff": { - "pattern": "^(?!^[-+.]*$)[+-]?0*\\d*\\.?\\d*$", - "title": "Elo Diff", - "type": "string" - }, - "is_recommended": { - "title": "Is Recommended", - "type": "boolean" - }, - "player_behind_schedule_count": { - "title": "Player Behind Schedule Count", - "type": "integer" - }, - "stage_item_input1": { - "anyOf": [ - { - "$ref": "#/components/schemas/StageItemInputTentative" - }, - { - "$ref": "#/components/schemas/StageItemInputFinal" - }, - { - "$ref": "#/components/schemas/StageItemInputEmpty" - } - ], - "title": "Stage Item Input1" - }, - "stage_item_input2": { - "anyOf": [ - { - "$ref": "#/components/schemas/StageItemInputTentative" - }, - { - "$ref": "#/components/schemas/StageItemInputFinal" - }, - { - "$ref": "#/components/schemas/StageItemInputEmpty" - } - ], - "title": "Stage Item Input2" - }, - "swiss_diff": { - "pattern": "^(?!^[-+.]*$)[+-]?0*\\d*\\.?\\d*$", - "title": "Swiss Diff", - "type": "string" - }, - "times_played_sum": { - "title": "Times Played Sum", - "type": "integer" - } - }, - "required": [ - "stage_item_input1", - "stage_item_input2", - "elo_diff", - "swiss_diff", - "is_recommended", - "times_played_sum", - "player_behind_schedule_count" - ], - "title": "SuggestedMatch", - "type": "object" - }, "Team": { "properties": { "active": { @@ -2513,9 +2466,11 @@ }, "dashboard_endpoint": { "anyOf": [ - {}, { "type": "string" + }, + { + "type": "null" } ], "title": "Dashboard Endpoint" @@ -2535,6 +2490,8 @@ "type": "integer" }, "name": { + "maxLength": 100, + "minLength": 1, "title": "Name", "type": "string" }, @@ -2597,9 +2554,11 @@ "properties": { "dashboard_endpoint": { "anyOf": [ - {}, { "type": "string" + }, + { + "type": "null" } ], "title": "Dashboard Endpoint" @@ -2619,6 +2578,8 @@ "type": "integer" }, "name": { + "maxLength": 100, + "minLength": 1, "title": "Name", "type": "string" }, @@ -2660,22 +2621,6 @@ "title": "TournamentsResponse", "type": "object" }, - "UpcomingMatchesResponse": { - "properties": { - "data": { - "items": { - "$ref": "#/components/schemas/SuggestedMatch" - }, - "title": "Data", - "type": "array" - } - }, - "required": [ - "data" - ], - "title": "UpcomingMatchesResponse", - "type": "object" - }, "UserAccountType": { "enum": [ "REGULAR" @@ -2685,6 +2630,10 @@ }, "UserPasswordToUpdate": { "properties": { + "current_password": { + "title": "Current Password", + "type": "string" + }, "password": { "maxLength": 72, "minLength": 12, @@ -2693,6 +2642,7 @@ } }, "required": [ + "current_password", "password" ], "title": "UserPasswordToUpdate", @@ -2752,6 +2702,8 @@ "type": "string" }, "name": { + "maxLength": 100, + "minLength": 1, "title": "Name", "type": "string" }, @@ -2776,6 +2728,8 @@ "type": "string" }, "name": { + "maxLength": 100, + "minLength": 1, "title": "Name", "type": "string" } @@ -2829,6 +2783,11 @@ } }, "securitySchemes": { + "APIKeyCookie": { + "in": "cookie", + "name": "bracket_session", + "type": "apiKey" + }, "OAuth2PasswordBearer": { "flows": { "password": { @@ -2841,12 +2800,12 @@ } }, "info": { - "description": "\n### Description\nThis API allows you to do everything the frontend of [Bracket](https://github.com/evroon/bracket)\nallows you to do (the frontend uses this API as well).\n\nFore more information, see the [documentation](https://docs.bracketapp.nl).\n\n### Table of Contents\n*(links only work for [ReDoc](https://api.bracketapp.nl/redoc), not for Swagger UI)*\n\n- [Auth](#tag/Auth)\n\n- [Clubs](#tag/Clubs)\n\n- [Internals](#tag/Internals)\n\n- [Matches](#tag/Matches)\n\n- [Players](#tag/Players)\n\n- [Rankings](#tag/Rankings)\n\n- [Rounds](#tag/Rounds)\n\n- [Stage Items](#tag/Stage-Items)\n\n- [Stage Item Inputs](#tag/Stage-Item-Inputs)\n\n- [Stages](#tag/Stages)\n\n- [Teams](#tag/Teams)\n\n- [Tournaments](#tag/Tournaments)\n\n- [Users](#tag/Users)\n\n### Links\nGitHub: \n\nDocs: \n\nAPI docs (Redoc): \n\nAPI docs (Swagger UI): \n", + "description": "Everything the Bracket frontend does, it does through this API.", "license": { "name": "AGPL-3.0", "url": "https://www.gnu.org/licenses/agpl-3.0.en.html" }, - "summary": "API for Bracket, an open source tournament system.", + "summary": "API of the Ctrl-Alt-GG fork of Bracket, an open source tournament system.", "title": "Bracket API", "version": "1.0.0" }, @@ -2891,6 +2850,9 @@ "security": [ { "OAuth2PasswordBearer": [] + }, + { + "APIKeyCookie": [] } ], "summary": "Get Clubs", @@ -2935,6 +2897,9 @@ "security": [ { "OAuth2PasswordBearer": [] + }, + { + "APIKeyCookie": [] } ], "summary": "Create New Club", @@ -2982,6 +2947,9 @@ "security": [ { "OAuth2PasswordBearer": [] + }, + { + "APIKeyCookie": [] } ], "summary": "Delete Club", @@ -3037,6 +3005,9 @@ "security": [ { "OAuth2PasswordBearer": [] + }, + { + "APIKeyCookie": [] } ], "summary": "Update Club", @@ -3045,6 +3016,21 @@ ] } }, + "/logout": { + "post": { + "description": "Ends the session in this browser by removing its cookie.", + "operationId": "logout_logout_post", + "responses": { + "204": { + "description": "Successful Response" + } + }, + "summary": "Logout", + "tags": [ + "Auth" + ] + } + }, "/ping": { "get": { "operationId": "ping_ping_get", @@ -3169,6 +3155,9 @@ "security": [ { "OAuth2PasswordBearer": [] + }, + { + "APIKeyCookie": [] } ], "summary": "Get Tournaments", @@ -3213,6 +3202,9 @@ "security": [ { "OAuth2PasswordBearer": [] + }, + { + "APIKeyCookie": [] } ], "summary": "Create Tournament", @@ -3260,6 +3252,9 @@ "security": [ { "OAuth2PasswordBearer": [] + }, + { + "APIKeyCookie": [] } ], "summary": "Delete Tournament", @@ -3302,6 +3297,14 @@ "description": "Validation Error" } }, + "security": [ + { + "OAuth2PasswordBearer": [] + }, + { + "APIKeyCookie": [] + } + ], "summary": "Get Tournament", "tags": [ "Tournaments" @@ -3355,6 +3358,9 @@ "security": [ { "OAuth2PasswordBearer": [] + }, + { + "APIKeyCookie": [] } ], "summary": "Update Tournament By Id", @@ -3402,6 +3408,9 @@ "security": [ { "OAuth2PasswordBearer": [] + }, + { + "APIKeyCookie": [] } ], "summary": "Get Available Inputs", @@ -3460,6 +3469,9 @@ "security": [ { "OAuth2PasswordBearer": [] + }, + { + "APIKeyCookie": [] } ], "summary": "Change Status", @@ -3502,6 +3514,14 @@ "description": "Validation Error" } }, + "security": [ + { + "OAuth2PasswordBearer": [] + }, + { + "APIKeyCookie": [] + } + ], "summary": "Get Tournament Logo", "tags": [ "Tournaments" @@ -3554,6 +3574,9 @@ "security": [ { "OAuth2PasswordBearer": [] + }, + { + "APIKeyCookie": [] } ], "summary": "Upload Logo", @@ -3611,6 +3634,9 @@ "security": [ { "OAuth2PasswordBearer": [] + }, + { + "APIKeyCookie": [] } ], "summary": "Create Match", @@ -3667,6 +3693,9 @@ "security": [ { "OAuth2PasswordBearer": [] + }, + { + "APIKeyCookie": [] } ], "summary": "Delete Match", @@ -3731,6 +3760,9 @@ "security": [ { "OAuth2PasswordBearer": [] + }, + { + "APIKeyCookie": [] } ], "summary": "Update Match By Id", @@ -3779,6 +3811,9 @@ "security": [ { "OAuth2PasswordBearer": [] + }, + { + "APIKeyCookie": [] } ], "summary": "Get Next Stage Rankings", @@ -3818,7 +3853,7 @@ "schema": { "default": 25, "description": "Max number of results in a single page.", - "maximum": 100, + "maximum": 500, "minimum": 1, "title": "Limit", "type": "integer" @@ -3894,6 +3929,14 @@ "description": "Validation Error" } }, + "security": [ + { + "OAuth2PasswordBearer": [] + }, + { + "APIKeyCookie": [] + } + ], "summary": "Get Players", "tags": [ "Players" @@ -3947,6 +3990,9 @@ "security": [ { "OAuth2PasswordBearer": [] + }, + { + "APIKeyCookie": [] } ], "summary": "Create Single Player", @@ -4003,6 +4049,9 @@ "security": [ { "OAuth2PasswordBearer": [] + }, + { + "APIKeyCookie": [] } ], "summary": "Delete Player", @@ -4067,6 +4116,9 @@ "security": [ { "OAuth2PasswordBearer": [] + }, + { + "APIKeyCookie": [] } ], "summary": "Update Player By Id", @@ -4124,6 +4176,9 @@ "security": [ { "OAuth2PasswordBearer": [] + }, + { + "APIKeyCookie": [] } ], "summary": "Create Multiple Players", @@ -4168,6 +4223,14 @@ "description": "Validation Error" } }, + "security": [ + { + "OAuth2PasswordBearer": [] + }, + { + "APIKeyCookie": [] + } + ], "summary": "Get Rankings", "tags": [ "Rankings" @@ -4221,6 +4284,9 @@ "security": [ { "OAuth2PasswordBearer": [] + }, + { + "APIKeyCookie": [] } ], "summary": "Create Ranking", @@ -4277,6 +4343,9 @@ "security": [ { "OAuth2PasswordBearer": [] + }, + { + "APIKeyCookie": [] } ], "summary": "Delete Ranking", @@ -4341,6 +4410,9 @@ "security": [ { "OAuth2PasswordBearer": [] + }, + { + "APIKeyCookie": [] } ], "summary": "Update Ranking By Id", @@ -4399,6 +4471,9 @@ "security": [ { "OAuth2PasswordBearer": [] + }, + { + "APIKeyCookie": [] } ], "summary": "Create Round", @@ -4455,6 +4530,9 @@ "security": [ { "OAuth2PasswordBearer": [] + }, + { + "APIKeyCookie": [] } ], "summary": "Delete Round", @@ -4519,6 +4597,9 @@ "security": [ { "OAuth2PasswordBearer": [] + }, + { + "APIKeyCookie": [] } ], "summary": "Update Round By Id", @@ -4566,6 +4647,9 @@ "security": [ { "OAuth2PasswordBearer": [] + }, + { + "APIKeyCookie": [] } ], "summary": "Schedule Matches", @@ -4623,6 +4707,9 @@ "security": [ { "OAuth2PasswordBearer": [] + }, + { + "APIKeyCookie": [] } ], "summary": "Create Stage Item", @@ -4679,6 +4766,9 @@ "security": [ { "OAuth2PasswordBearer": [] + }, + { + "APIKeyCookie": [] } ], "summary": "Delete Stage Item", @@ -4743,6 +4833,9 @@ "security": [ { "OAuth2PasswordBearer": [] + }, + { + "APIKeyCookie": [] } ], "summary": "Update Stage Item", @@ -4829,6 +4922,9 @@ "security": [ { "OAuth2PasswordBearer": [] + }, + { + "APIKeyCookie": [] } ], "summary": "Update Stage Item Input", @@ -4837,9 +4933,9 @@ ] } }, - "/tournaments/{tournament_id}/stage_items/{stage_item_id}/upcoming_matches": { + "/tournaments/{tournament_id}/stages": { "get": { - "operationId": "get_matches_to_schedule_tournaments__tournament_id__stage_items__stage_item_id__upcoming_matches_get", + "operationId": "get_stages_tournaments__tournament_id__stages_get", "parameters": [ { "in": "path", @@ -4850,54 +4946,15 @@ "type": "integer" } }, - { - "in": "path", - "name": "stage_item_id", - "required": true, - "schema": { - "title": "Stage Item Id", - "type": "integer" - } - }, - { - "in": "query", - "name": "elo_diff_threshold", - "required": false, - "schema": { - "default": 200, - "title": "Elo Diff Threshold", - "type": "integer" - } - }, - { - "in": "query", - "name": "iterations", - "required": false, - "schema": { - "default": 2000, - "title": "Iterations", - "type": "integer" - } - }, { "in": "query", - "name": "only_recommended", + "name": "no_draft_rounds", "required": false, "schema": { "default": false, - "title": "Only Recommended", + "title": "No Draft Rounds", "type": "boolean" } - }, - { - "in": "query", - "name": "limit", - "required": false, - "schema": { - "default": 50, - "title": "Limit", - "type": "integer" - } } ], "responses": { @@ -4905,7 +4962,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UpcomingMatchesResponse" + "$ref": "#/components/schemas/StagesWithStageItemsResponse" } } }, @@ -4925,60 +4982,11 @@ "security": [ { "OAuth2PasswordBearer": [] - } - ], - "summary": "Get Matches To Schedule", - "tags": [ - "Matches" - ] - } - }, - "/tournaments/{tournament_id}/stages": { - "get": { - "operationId": "get_stages_tournaments__tournament_id__stages_get", - "parameters": [ - { - "in": "path", - "name": "tournament_id", - "required": true, - "schema": { - "title": "Tournament Id", - "type": "integer" - } }, { - "in": "query", - "name": "no_draft_rounds", - "required": false, - "schema": { - "default": false, - "title": "No Draft Rounds", - "type": "boolean" - } + "APIKeyCookie": [] } ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/StagesWithStageItemsResponse" - } - } - }, - "description": "Successful Response" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - }, - "description": "Validation Error" - } - }, "summary": "Get Stages", "tags": [ "Stages" @@ -5022,6 +5030,9 @@ "security": [ { "OAuth2PasswordBearer": [] + }, + { + "APIKeyCookie": [] } ], "summary": "Create Stage", @@ -5079,6 +5090,9 @@ "security": [ { "OAuth2PasswordBearer": [] + }, + { + "APIKeyCookie": [] } ], "summary": "Activate Next Stage", @@ -5135,6 +5149,9 @@ "security": [ { "OAuth2PasswordBearer": [] + }, + { + "APIKeyCookie": [] } ], "summary": "Delete Stage", @@ -5199,6 +5216,9 @@ "security": [ { "OAuth2PasswordBearer": [] + }, + { + "APIKeyCookie": [] } ], "summary": "Update Stage", @@ -5244,6 +5264,14 @@ "description": "Validation Error" } }, + "security": [ + { + "OAuth2PasswordBearer": [] + }, + { + "APIKeyCookie": [] + } + ], "summary": "Get Standings", "tags": [ "Rankings" @@ -5271,7 +5299,7 @@ "schema": { "default": 25, "description": "Max number of results in a single page.", - "maximum": 100, + "maximum": 500, "minimum": 1, "title": "Limit", "type": "integer" @@ -5347,6 +5375,14 @@ "description": "Validation Error" } }, + "security": [ + { + "OAuth2PasswordBearer": [] + }, + { + "APIKeyCookie": [] + } + ], "summary": "Get Teams", "tags": [ "Teams" @@ -5400,6 +5436,9 @@ "security": [ { "OAuth2PasswordBearer": [] + }, + { + "APIKeyCookie": [] } ], "summary": "Create Team", @@ -5456,6 +5495,9 @@ "security": [ { "OAuth2PasswordBearer": [] + }, + { + "APIKeyCookie": [] } ], "summary": "Delete Team", @@ -5520,6 +5562,9 @@ "security": [ { "OAuth2PasswordBearer": [] + }, + { + "APIKeyCookie": [] } ], "summary": "Update Team By Id", @@ -5571,6 +5616,14 @@ "description": "Validation Error" } }, + "security": [ + { + "OAuth2PasswordBearer": [] + }, + { + "APIKeyCookie": [] + } + ], "summary": "Get Team Logo", "tags": [ "Teams" @@ -5632,6 +5685,9 @@ "security": [ { "OAuth2PasswordBearer": [] + }, + { + "APIKeyCookie": [] } ], "summary": "Update Team Logo", @@ -5689,6 +5745,9 @@ "security": [ { "OAuth2PasswordBearer": [] + }, + { + "APIKeyCookie": [] } ], "summary": "Create Multiple Teams", @@ -5715,6 +5774,9 @@ "security": [ { "OAuth2PasswordBearer": [] + }, + { + "APIKeyCookie": [] } ], "summary": "Get User", @@ -5803,6 +5865,9 @@ "security": [ { "OAuth2PasswordBearer": [] + }, + { + "APIKeyCookie": [] } ], "summary": "Get Me", @@ -5858,6 +5923,9 @@ "security": [ { "OAuth2PasswordBearer": [] + }, + { + "APIKeyCookie": [] } ], "summary": "Update User Details", @@ -5868,6 +5936,7 @@ }, "/users/{user_id}/password": { "put": { + "description": "Asks for the current password as well, so that a stolen session can't lock the owner out.\n\nSigns out every other session. This one gets a new session cookie.", "operationId": "put_user_password_users__user_id__password_put", "parameters": [ { @@ -5915,6 +5984,9 @@ "security": [ { "OAuth2PasswordBearer": [] + }, + { + "APIKeyCookie": [] } ], "summary": "Put User Password", diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 5b5a408e..0255b3c6 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -9,8 +9,6 @@ dependencies = [ "click==8.5.0", "email-validator==2.3.0", "fastapi==0.142.2", - "fastapi-sso==0.23.0", - "gunicorn==26.2.0", "heliclockter==3.0.1", "networkx==3.7", "pillow==12.3.0", @@ -24,9 +22,6 @@ dependencies = [ "slowapi==0.1.10", "sqlalchemy[asyncio]==2.1.3", "starlette==1.7.0", - "types-aiofiles==25.1.0.20260518", - "types-networkx==3.6.1.20261006", - "types-zxcvbn==4.5.0.20260518", "uvicorn==0.54.0", "zxcvbn==4.5.0", ] @@ -53,7 +48,6 @@ filterwarnings = [ [dependency-groups] dev = [ "aiohttp==3.14.4", - "aioresponses==0.7.9", "mypy==2.4.0", "pluggy==1.6.0", "pylint==4.1.2", @@ -63,6 +57,9 @@ dev = [ "pytest-xdist==3.8.0", "pytest==9.1.1", "ruff==0.16.10", + "types-aiofiles==25.1.0.20260518", + "types-networkx==3.6.1.20261006", + "types-zxcvbn==4.5.0.20260518", "vulture==2.16", ] @@ -98,15 +95,6 @@ init_typed = true warn_required_dynamic_aliases = true warn_untyped_fields = true -[[tool.mypy.overrides]] -module = ['aioresponses.*'] -disallow_any_explicit = false -disallow_any_generics = false - -[[tool.mypy.overrides]] -module = ['fastapi_sso.*'] -ignore_missing_imports = true - [tool.pylint.'MESSAGES CONTROL'] disable = [ 'broad-except', @@ -179,7 +167,6 @@ paths = ["."] exclude = [".venv"] ignore_decorators = ["@pytest.*", "@*router*", "@app.*", "@*validator*", "@cli.command*"] ignore_names = [ - "RestartableUvicornWorker", "_generate_next_value_", "downgrade", "startup_and_shutdown_uvicorn_server", @@ -187,6 +174,3 @@ ignore_names = [ "upgrade", ] sort_by_size = true - -[tool.pyrefly] -ignore-missing-imports = ["fastapi_sso.*"] diff --git a/backend/static/.gitignore b/backend/static/.gitignore deleted file mode 100644 index f59ec20a..00000000 --- a/backend/static/.gitignore +++ /dev/null @@ -1 +0,0 @@ -* \ No newline at end of file diff --git a/backend/tests/integration_tests/api/auth_test.py b/backend/tests/integration_tests/api/auth_test.py index 6f12009b..27d3d5db 100644 --- a/backend/tests/integration_tests/api/auth_test.py +++ b/backend/tests/integration_tests/api/auth_test.py @@ -1,27 +1,28 @@ -from collections.abc import Generator -from contextlib import contextmanager from http import HTTPMethod -from unittest.mock import Mock, patch +from uuid import uuid4 +import aiohttp import jwt import pytest +from heliclockter import datetime_utc, timedelta from bracket.config import config +from bracket.database import engine from bracket.models.db.account import UserAccountType +from bracket.routes.auth import CSRF_HEADER, CSRF_HEADER_VALUE, SESSION_COOKIE, get_auth_hash +from bracket.sql.clubs import sql_delete_club from bracket.utils.dummy_records import DUMMY_CLUB, DUMMY_TOURNAMENT from bracket.utils.types import JsonDict -from tests.integration_tests.api.shared import send_auth_request, send_request -from tests.integration_tests.mocks import MOCK_NOW, get_mock_token, get_mock_user +from tests.integration_tests.api.shared import ( + get_root_uvicorn_url, + send_auth_request, + send_request, +) +from tests.integration_tests.mocks import get_mock_token, get_mock_user from tests.integration_tests.models import AuthContext from tests.integration_tests.sql import inserted_club, inserted_tournament, inserted_user -@contextmanager -def mock_auth_time() -> Generator[None]: - with patch("bracket.routes.auth.datetime_utc.now", Mock(return_value=MOCK_NOW)): - yield - - @pytest.mark.asyncio(loop_scope="session") async def test_get_token_success(startup_and_shutdown_uvicorn_server: None) -> None: mock_user = get_mock_user() @@ -29,27 +30,38 @@ async def test_get_token_success(startup_and_shutdown_uvicorn_server: None) -> N "username": mock_user.email, "password": "mypassword", } - with mock_auth_time(): - async with inserted_user(mock_user): - response = JsonDict(await send_request(HTTPMethod.POST, "token", body)) + async with inserted_user(mock_user) as user_inserted: + response = JsonDict(await send_request(HTTPMethod.POST, "token", body)) + # The mock user's bcrypt hash is replaced by an Argon2 one on login, and the token must + # already belong to the new hash. + headers = {"Authorization": f"Bearer {response['access_token']}"} + me = await send_request(HTTPMethod.GET, "users/me", None, None, headers) - assert "access_token" in response assert response.get("token_type") == "bearer" + assert response.get("user_id") == user_inserted.id + assert me["data"]["id"] == user_inserted.id - decoded = jwt.decode(response["access_token"], config.jwt_secret, algorithms=["HS256"]) - assert decoded == {"user": mock_user.email, "exp": 7258723200} + decoded = jwt.decode( + response["access_token"], + config.jwt_secret, + algorithms=["HS256"], + audience=config.jwt_audience, + issuer=config.jwt_issuer, + ) + assert decoded["sub"] == str(user_inserted.id) + assert decoded["type"] == "access" + assert decoded["exp"] - decoded["iat"] == config.access_token_expire_minutes * 60 @pytest.mark.asyncio(loop_scope="session") async def test_get_token_invalid_credentials(startup_and_shutdown_uvicorn_server: None) -> None: mock_user = get_mock_user() body = { - "username": mock_user, + "username": mock_user.email, "password": "invalid password", } - with mock_auth_time(): - async with inserted_user(mock_user): - response = JsonDict(await send_request(HTTPMethod.POST, "token", body)) + async with inserted_user(mock_user): + response = JsonDict(await send_request(HTTPMethod.POST, "token", body)) assert response == {"detail": "Incorrect email or password"} @@ -57,9 +69,9 @@ async def test_get_token_invalid_credentials(startup_and_shutdown_uvicorn_server @pytest.mark.asyncio(loop_scope="session") async def test_auth_on_protected_endpoint(startup_and_shutdown_uvicorn_server: None) -> None: mock_user = get_mock_user() - headers = {"Authorization": f"Bearer {get_mock_token(mock_user.email)}"} async with inserted_user(mock_user) as user_inserted: + headers = {"Authorization": f"Bearer {get_mock_token(user_inserted)}"} response = JsonDict( await send_request(HTTPMethod.GET, f"users/{user_inserted.id}", {}, None, headers) ) @@ -83,11 +95,52 @@ async def test_invalid_token(startup_and_shutdown_uvicorn_server: None) -> None: assert response == {"detail": "Could not validate credentials"} +@pytest.mark.asyncio(loop_scope="session") +@pytest.mark.parametrize( + "claims", + [ + {"aud": "another-api"}, + {"iss": "https://another-issuer.example.org"}, + {"type": "refresh"}, + {"sub": "someone@example.org"}, + {"nbf": datetime_utc.now() + timedelta(hours=1)}, + # Issued before the password changed. + {"auth_hash": get_auth_hash("$argon2id$an-old-password-hash")}, + ], +) +async def test_signed_token_with_wrong_claims( + startup_and_shutdown_uvicorn_server: None, auth_context: AuthContext, claims: JsonDict +) -> None: + """Every token that fails validation is rejected the same way, instead of crashing.""" + now = datetime_utc.now() + token = jwt.encode( + { + "sub": str(auth_context.user.id), + "aud": config.jwt_audience, + "iss": config.jwt_issuer, + "iat": now, + "nbf": now, + "exp": now + timedelta(minutes=5), + "type": "access", + "auth_hash": get_auth_hash(auth_context.user.password_hash), + **claims, + }, + config.jwt_secret, + algorithm="HS256", + ) + headers = {"Authorization": f"Bearer {token}"} + + response = JsonDict(await send_request(HTTPMethod.GET, "users/me", {}, None, headers)) + assert response == {"detail": "Could not validate credentials"} + + @pytest.mark.asyncio(loop_scope="session") async def test_not_authenticated_for_tournament( startup_and_shutdown_uvicorn_server: None, auth_context: AuthContext ) -> None: - async with inserted_club(DUMMY_CLUB) as club_inserted: + async with inserted_club( + DUMMY_CLUB.model_copy(update={"name": f"Another club {uuid4()}"}) + ) as club_inserted: async with inserted_tournament( DUMMY_TOURNAMENT.model_copy( update={"club_id": club_inserted.id, "dashboard_endpoint": "some-slug"} @@ -95,7 +148,46 @@ async def test_not_authenticated_for_tournament( ) as tournament_inserted: response = JsonDict( await send_auth_request( - HTTPMethod.GET, f"tournaments/{tournament_inserted.id}/players", auth_context + HTTPMethod.GET, + f"tournaments/{tournament_inserted.id}/available_inputs", + auth_context, ) ) assert response == {"detail": "Could not validate credentials"} + + +@pytest.mark.asyncio(loop_scope="session") +async def test_session_cookie(startup_and_shutdown_uvicorn_server: None) -> None: + """The frontend signs in with an HttpOnly cookie, which needs the CSRF header to change data.""" + mock_user = get_mock_user() + csrf_headers = {CSRF_HEADER: CSRF_HEADER_VALUE} + + async with ( + inserted_user(mock_user), + # Cookies of a host that is an IP address are only kept by an "unsafe" cookie jar. + aiohttp.ClientSession(cookie_jar=aiohttp.CookieJar(unsafe=True)) as session, + ): + url = get_root_uvicorn_url() + login = await session.post( + f"{url}token", data={"username": mock_user.email, "password": "mypassword"} + ) + set_cookie = login.headers["Set-Cookie"] + me = await (await session.get(f"{url}users/me")).json() + without_csrf_header = await session.post(f"{url}clubs", json={"name": f"Club {uuid4()}"}) + with_csrf_header = await session.post( + f"{url}clubs", json={"name": f"Club {uuid4()}"}, headers=csrf_headers + ) + club = await with_csrf_header.json() + logout = await session.post(f"{url}logout", headers=csrf_headers) + after_logout = await session.get(f"{url}users/me") + async with engine.begin() as conn: + await sql_delete_club(conn, club["data"]["id"]) + + assert set_cookie.startswith(f"{SESSION_COOKIE}=") + assert "HttpOnly" in set_cookie + assert "SameSite=strict" in set_cookie + assert me["data"]["email"] == mock_user.email + assert without_csrf_header.status == 403 + assert with_csrf_header.status == 200 + assert logout.status == 204 + assert after_logout.status == 401 diff --git a/backend/tests/integration_tests/api/clubs_test.py b/backend/tests/integration_tests/api/clubs_test.py index 3d3031b2..430d480d 100644 --- a/backend/tests/integration_tests/api/clubs_test.py +++ b/backend/tests/integration_tests/api/clubs_test.py @@ -1,8 +1,10 @@ from http import HTTPMethod +from uuid import uuid4 import pytest from sqlalchemy.ext.asyncio import AsyncConnection +from bracket.models.db.club import ClubInsertable from bracket.models.db.user_x_club import UserXClubInsertable, UserXClubRelation from bracket.sql.clubs import get_clubs_for_user_id, sql_delete_club from bracket.utils.dummy_records import DUMMY_CLUB, DUMMY_MOCK_TIME @@ -11,6 +13,11 @@ from tests.integration_tests.sql import inserted_club, inserted_user_x_club +def unique_club() -> ClubInsertable: + # Club names are unique, and the auth context already has a club named like `DUMMY_CLUB`. + return DUMMY_CLUB.model_copy(update={"name": f"Club {uuid4()}"}) + + @pytest.mark.asyncio(loop_scope="session") async def test_clubs_endpoint( startup_and_shutdown_uvicorn_server: None, auth_context: AuthContext @@ -30,7 +37,7 @@ async def test_clubs_endpoint( async def test_create_club( conn: AsyncConnection, startup_and_shutdown_uvicorn_server: None, auth_context: AuthContext ) -> None: - payload = {"name": "Some Cool Club"} + payload = {"name": f"New club {uuid4()}"} response = await send_auth_request(HTTPMethod.POST, "clubs", auth_context, json=payload) clubs = await get_clubs_for_user_id(conn, auth_context.user.id) @@ -46,8 +53,8 @@ async def test_create_club( async def test_update_club( conn: AsyncConnection, startup_and_shutdown_uvicorn_server: None, auth_context: AuthContext ) -> None: - payload = {"name": "Some Cool Club"} - async with inserted_club(DUMMY_CLUB) as club_inserted: + payload = {"name": f"Renamed club {uuid4()}"} + async with inserted_club(unique_club()) as club_inserted: async with inserted_user_x_club( UserXClubInsertable( user_id=auth_context.user.id, @@ -70,7 +77,7 @@ async def test_update_club( async def test_delete_club( startup_and_shutdown_uvicorn_server: None, auth_context: AuthContext ) -> None: - async with inserted_club(DUMMY_CLUB) as club_inserted: + async with inserted_club(unique_club()) as club_inserted: async with inserted_user_x_club( UserXClubInsertable( user_id=auth_context.user.id, diff --git a/backend/tests/integration_tests/api/inputs_test.py b/backend/tests/integration_tests/api/inputs_test.py index 61337043..0ad0fa5c 100644 --- a/backend/tests/integration_tests/api/inputs_test.py +++ b/backend/tests/integration_tests/api/inputs_test.py @@ -4,19 +4,24 @@ from bracket.models.db.stage_item_inputs import StageItemInputInsertable from bracket.utils.dummy_records import ( + DUMMY_RANKING1, DUMMY_STAGE1, + DUMMY_STAGE2, DUMMY_STAGE_ITEM1, DUMMY_TEAM1, + DUMMY_TOURNAMENT, ) from tests.integration_tests.api.shared import ( send_tournament_request, ) from tests.integration_tests.models import AuthContext from tests.integration_tests.sql import ( + inserted_ranking, inserted_stage, inserted_stage_item, inserted_stage_item_input, inserted_team, + inserted_tournament, ) @@ -109,3 +114,52 @@ async def test_update_stage_item_input_invalid_team( ) assert response == {"detail": "Could not find team with id -42"} + + +@pytest.mark.asyncio(loop_scope="session") +async def test_update_stage_item_input_from_other_tournament( + startup_and_shutdown_uvicorn_server: None, auth_context: AuthContext +) -> None: + """Regression test: an input could take its teams from another tournament's stage item.""" + async with ( + inserted_tournament( + DUMMY_TOURNAMENT.model_copy( + update={"club_id": auth_context.club.id, "dashboard_endpoint": None} + ) + ) as other_tournament, + inserted_ranking( + DUMMY_RANKING1.model_copy(update={"tournament_id": other_tournament.id}) + ) as other_ranking, + inserted_stage( + DUMMY_STAGE1.model_copy(update={"tournament_id": other_tournament.id}) + ) as other_stage, + inserted_stage_item( + DUMMY_STAGE_ITEM1.model_copy( + update={"stage_id": other_stage.id, "ranking_id": other_ranking.id} + ) + ) as other_stage_item, + inserted_stage( + DUMMY_STAGE2.model_copy(update={"tournament_id": auth_context.tournament.id}) + ) as stage_inserted, + inserted_stage_item( + DUMMY_STAGE_ITEM1.model_copy( + update={"stage_id": stage_inserted.id, "ranking_id": auth_context.ranking.id} + ) + ) as stage_item_inserted, + inserted_stage_item_input( + StageItemInputInsertable( + slot=0, + team_id=None, + tournament_id=auth_context.tournament.id, + stage_item_id=stage_item_inserted.id, + ) + ) as stage_item_input_inserted, + ): + response = await send_tournament_request( + HTTPMethod.PUT, + f"stage_items/{stage_item_inserted.id}/inputs/{stage_item_input_inserted.id}", + auth_context, + json={"winner_from_stage_item_id": other_stage_item.id, "winner_position": 1}, + ) + + assert response == {"detail": f"Could not find stage item with id {other_stage_item.id}"} diff --git a/backend/tests/integration_tests/api/matches_test.py b/backend/tests/integration_tests/api/matches_test.py index 39e807f5..ccf28eed 100644 --- a/backend/tests/integration_tests/api/matches_test.py +++ b/backend/tests/integration_tests/api/matches_test.py @@ -1,4 +1,3 @@ -from decimal import Decimal from http import HTTPMethod import pytest @@ -13,10 +12,6 @@ from bracket.utils.db import fetch_one_parsed_certain from bracket.utils.dummy_records import ( DUMMY_MATCH1, - DUMMY_PLAYER1, - DUMMY_PLAYER2, - DUMMY_PLAYER3, - DUMMY_PLAYER4, DUMMY_ROUND1, DUMMY_STAGE1, DUMMY_STAGE_ITEM1, @@ -30,7 +25,6 @@ from tests.integration_tests.sql import ( assert_row_count_and_clear, inserted_match, - inserted_player_in_team, inserted_round, inserted_stage, inserted_stage_item, @@ -300,160 +294,6 @@ async def test_update_endpoint_custom_duration_margin( await assert_row_count_and_clear(matches, 1) -@pytest.mark.asyncio(loop_scope="session") -async def test_upcoming_matches_endpoint( - startup_and_shutdown_uvicorn_server: None, auth_context: AuthContext -) -> None: - async with ( - inserted_stage( - DUMMY_STAGE1.model_copy( - update={ - "is_active": True, - "tournament_id": auth_context.tournament.id, - } - ) - ) as stage_inserted, - inserted_stage_item( - DUMMY_STAGE_ITEM1.model_copy( - update={ - "stage_id": stage_inserted.id, - "type": StageType.SWISS, - "ranking_id": auth_context.ranking.id, - } - ) - ) as stage_item_inserted, - inserted_round( - DUMMY_ROUND1.model_copy( - update={ - "is_draft": True, - "stage_item_id": stage_item_inserted.id, - } - ) - ), - inserted_team( - DUMMY_TEAM1.model_copy( - update={"tournament_id": auth_context.tournament.id, "elo_score": Decimal("1150.0")} - ) - ) as team1_inserted, - inserted_team( - DUMMY_TEAM2.model_copy( - update={"tournament_id": auth_context.tournament.id, "elo_score": Decimal("1350.0")} - ) - ) as team2_inserted, - inserted_stage_item_input( - StageItemInputInsertable( - slot=0, - team_id=team1_inserted.id, - tournament_id=auth_context.tournament.id, - stage_item_id=stage_item_inserted.id, - ) - ) as stage_item_input1_inserted, - inserted_stage_item_input( - StageItemInputInsertable( - slot=1, - team_id=team2_inserted.id, - tournament_id=auth_context.tournament.id, - stage_item_id=stage_item_inserted.id, - ) - ) as stage_item_input2_inserted, - inserted_player_in_team( - DUMMY_PLAYER1.model_copy( - update={"elo_score": Decimal("1100.0"), "tournament_id": auth_context.tournament.id} - ), - team1_inserted.id, - ), - inserted_player_in_team( - DUMMY_PLAYER2.model_copy( - update={"elo_score": Decimal("1300.0"), "tournament_id": auth_context.tournament.id} - ), - team2_inserted.id, - ), - inserted_player_in_team( - DUMMY_PLAYER3.model_copy( - update={"elo_score": Decimal("1200.0"), "tournament_id": auth_context.tournament.id} - ), - team1_inserted.id, - ), - inserted_player_in_team( - DUMMY_PLAYER4.model_copy( - update={"elo_score": Decimal("1400.0"), "tournament_id": auth_context.tournament.id} - ), - team2_inserted.id, - ), - ): - json_response = await send_tournament_request( - HTTPMethod.GET, - f"stage_items/{stage_item_inserted.id}/upcoming_matches", - auth_context, - {}, - ) - # print(json_response["data"][0]["stage_item_input1"]["team"]) - # 1 / 0 - assert json_response == { - "data": [ - { - "stage_item_input1": { - "team_id": team1_inserted.id, - "winner_from_stage_item_id": None, - "winner_position": None, - "points": "0", - "wins": 0, - "draws": 0, - "losses": 0, - "id": stage_item_input1_inserted.id, - "slot": 0, - "tournament_id": auth_context.tournament.id, - "stage_item_id": stage_item_inserted.id, - "team": { - "created": "2022-01-11T04:32:11Z", - "name": team1_inserted.name, - "tournament_id": auth_context.tournament.id, - "active": True, - "elo_score": "1150", - "swiss_score": "0", - "wins": 0, - "draws": 0, - "losses": 0, - "logo_path": None, - "id": team1_inserted.id, - }, - }, - "stage_item_input2": { - "team_id": team2_inserted.id, - "winner_from_stage_item_id": None, - "winner_position": None, - "points": "0", - "wins": 0, - "draws": 0, - "losses": 0, - "id": stage_item_input2_inserted.id, - "slot": 1, - "tournament_id": auth_context.tournament.id, - "stage_item_id": stage_item_inserted.id, - "team": { - "created": "2022-01-11T04:32:11Z", - "name": team2_inserted.name, - "tournament_id": auth_context.tournament.id, - "active": True, - "elo_score": "1350", - "swiss_score": "0", - "wins": 0, - "draws": 0, - "losses": 0, - "logo_path": None, - "id": team2_inserted.id, - }, - }, - "elo_diff": "0", - "swiss_diff": "0", - "times_played_sum": 0, - "is_recommended": True, - "player_behind_schedule_count": 0, - } - ] - } - - @pytest.mark.asyncio(loop_scope="session") async def test_create_match_for_team_already_playing_in_round( startup_and_shutdown_uvicorn_server: None, auth_context: AuthContext diff --git a/backend/tests/integration_tests/api/players_test.py b/backend/tests/integration_tests/api/players_test.py index aa34498e..89476d80 100644 --- a/backend/tests/integration_tests/api/players_test.py +++ b/backend/tests/integration_tests/api/players_test.py @@ -1,8 +1,10 @@ from http import HTTPMethod import pytest +from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncConnection +from bracket.database import engine from bracket.models.db.player import Player from bracket.schema import players from bracket.utils.db import fetch_one_parsed_certain @@ -57,14 +59,33 @@ async def test_create_player( async def test_create_players( startup_and_shutdown_uvicorn_server: None, auth_context: AuthContext ) -> None: - body = {"names": "Player x\nPlayer y", "active": True} + body = {"names": "Player x\n Player y \n\n", "active": True} response = await send_tournament_request( HTTPMethod.POST, "players_multi", auth_context, json=body ) assert response["success"] is True + + async with engine.begin() as conn: + names = (await conn.scalars(select(players.c.name).order_by(players.c.name))).all() + assert names == ["Player x", "Player y"] await assert_row_count_and_clear(players, 2) +@pytest.mark.asyncio(loop_scope="session") +async def test_create_players_with_too_long_name( + startup_and_shutdown_uvicorn_server: None, auth_context: AuthContext +) -> None: + body = {"names": f"Player x\n{'y' * 31}", "active": True} + response = await send_tournament_request( + HTTPMethod.POST, "players_multi", auth_context, json=body + ) + + assert response["detail"][0]["msg"] == ( + f'Value error, "{"y" * 31}" is longer than the 30 characters a name can have' + ) + await assert_row_count_and_clear(players, 0) + + @pytest.mark.asyncio(loop_scope="session") async def test_delete_player( startup_and_shutdown_uvicorn_server: None, auth_context: AuthContext diff --git a/backend/tests/integration_tests/api/rankings_test.py b/backend/tests/integration_tests/api/rankings_test.py index 56993c35..7e18cc3a 100644 --- a/backend/tests/integration_tests/api/rankings_test.py +++ b/backend/tests/integration_tests/api/rankings_test.py @@ -12,10 +12,10 @@ sql_delete_ranking, ) from bracket.utils.db import fetch_one_parsed_certain -from bracket.utils.dummy_records import DUMMY_RANKING1, DUMMY_TEAM1 +from bracket.utils.dummy_records import DUMMY_RANKING1, DUMMY_TEAM1, DUMMY_TOURNAMENT from tests.integration_tests.api.shared import SUCCESS_RESPONSE, send_tournament_request from tests.integration_tests.models import AuthContext -from tests.integration_tests.sql import inserted_ranking, inserted_team +from tests.integration_tests.sql import inserted_ranking, inserted_team, inserted_tournament @pytest.mark.asyncio(loop_scope="session") @@ -99,3 +99,35 @@ async def test_update_ranking( ) assert response["success"] is True assert updated_ranking.win_points == Decimal("7.5") + + +@pytest.mark.asyncio(loop_scope="session") +async def test_ranking_of_other_tournament_is_not_found( + startup_and_shutdown_uvicorn_server: None, auth_context: AuthContext +) -> None: + async with ( + inserted_tournament( + DUMMY_TOURNAMENT.model_copy( + update={"club_id": auth_context.club.id, "dashboard_endpoint": None} + ) + ) as other_tournament, + inserted_ranking( + DUMMY_RANKING1.model_copy(update={"tournament_id": other_tournament.id}) + ) as other_ranking, + ): + body = { + "win_points": "3", + "draw_points": "1", + "loss_points": "0", + "add_score_points": False, + "position": 0, + } + updated = await send_tournament_request( + HTTPMethod.PUT, f"rankings/{other_ranking.id}", auth_context, json=body + ) + deleted = await send_tournament_request( + HTTPMethod.DELETE, f"rankings/{other_ranking.id}", auth_context + ) + + expected = {"detail": f"Could not find ranking with id {other_ranking.id}"} + assert updated == deleted == expected diff --git a/backend/tests/integration_tests/api/stage_items_test.py b/backend/tests/integration_tests/api/stage_items_test.py index 8873ea0e..fc655ef6 100644 --- a/backend/tests/integration_tests/api/stage_items_test.py +++ b/backend/tests/integration_tests/api/stage_items_test.py @@ -5,13 +5,16 @@ from bracket.models.db.stage_item import StageType from bracket.models.db.stage_item_inputs import StageItemInputCreateBodyFinal +from bracket.models.db.tournament import TournamentStatus from bracket.schema import matches, rounds, stage_items, stages from bracket.sql.stage_items import get_stage_item from bracket.utils.dummy_records import ( + DUMMY_RANKING1, DUMMY_STAGE1, DUMMY_STAGE2, DUMMY_STAGE_ITEM1, DUMMY_TEAM1, + DUMMY_TOURNAMENT, ) from tests.integration_tests.api.shared import ( SUCCESS_RESPONSE, @@ -20,9 +23,11 @@ from tests.integration_tests.models import AuthContext from tests.integration_tests.sql import ( assert_row_count_and_clear, + inserted_ranking, inserted_stage, inserted_stage_item, inserted_team, + inserted_tournament, ) @@ -87,7 +92,8 @@ async def test_delete_stage_item( ) == SUCCESS_RESPONSE ) - await assert_row_count_and_clear(stages, 0) + await assert_row_count_and_clear(stage_items, 0) + await assert_row_count_and_clear(stages, 1) @pytest.mark.asyncio(loop_scope="session") @@ -117,3 +123,85 @@ async def test_update_stage_item( conn, auth_context.tournament.id, stage_item_inserted.id ) assert updated_stage_item.name == body["name"] + + +@pytest.mark.asyncio(loop_scope="session") +async def test_stage_items_of_archived_tournament_are_read_only( + startup_and_shutdown_uvicorn_server: None, auth_context: AuthContext +) -> None: + async with ( + inserted_tournament( + DUMMY_TOURNAMENT.model_copy( + update={ + "club_id": auth_context.club.id, + "dashboard_endpoint": None, + "status": TournamentStatus.ARCHIVED, + } + ) + ) as archived_tournament, + inserted_ranking( + DUMMY_RANKING1.model_copy(update={"tournament_id": archived_tournament.id}) + ) as ranking_inserted, + inserted_stage( + DUMMY_STAGE2.model_copy(update={"tournament_id": archived_tournament.id}) + ) as stage_inserted, + inserted_stage_item( + DUMMY_STAGE_ITEM1.model_copy( + update={"stage_id": stage_inserted.id, "ranking_id": ranking_inserted.id} + ) + ) as stage_item_inserted, + ): + archived_context = auth_context.model_copy(update={"tournament": archived_tournament}) + created = await send_tournament_request( + HTTPMethod.POST, + "stage_items", + archived_context, + json={ + "type": StageType.ROUND_ROBIN.value, + "team_count": 2, + "stage_id": stage_inserted.id, + }, + ) + deleted = await send_tournament_request( + HTTPMethod.DELETE, f"stage_items/{stage_item_inserted.id}", archived_context + ) + await assert_row_count_and_clear(stage_items, 1) + + assert created == deleted == {"detail": "Can't update archived tournament"} + + +@pytest.mark.asyncio(loop_scope="session") +async def test_create_stage_item_with_ranking_of_other_tournament( + startup_and_shutdown_uvicorn_server: None, auth_context: AuthContext +) -> None: + """ + Regression test: a stage item could use another tournament's ranking, and was then deleted + along with it. + """ + async with ( + inserted_tournament( + DUMMY_TOURNAMENT.model_copy( + update={"club_id": auth_context.club.id, "dashboard_endpoint": None} + ) + ) as other_tournament, + inserted_ranking( + DUMMY_RANKING1.model_copy(update={"tournament_id": other_tournament.id}) + ) as other_ranking, + inserted_stage( + DUMMY_STAGE2.model_copy(update={"tournament_id": auth_context.tournament.id}) + ) as stage_inserted, + ): + response = await send_tournament_request( + HTTPMethod.POST, + "stage_items", + auth_context, + json={ + "type": StageType.ROUND_ROBIN.value, + "team_count": 2, + "stage_id": stage_inserted.id, + "ranking_id": other_ranking.id, + }, + ) + await assert_row_count_and_clear(stage_items, 0) + + assert response == {"detail": f"Could not find Ranking(s) with ID {other_ranking.id}"} diff --git a/backend/tests/integration_tests/api/stages_test.py b/backend/tests/integration_tests/api/stages_test.py index 87e9e9f2..30674562 100644 --- a/backend/tests/integration_tests/api/stages_test.py +++ b/backend/tests/integration_tests/api/stages_test.py @@ -106,8 +106,9 @@ async def test_create_stage( ) == SUCCESS_RESPONSE ) - await assert_row_count_and_clear(rounds, 1) - await assert_row_count_and_clear(stage_items, 1) + # A new stage starts out empty. + await assert_row_count_and_clear(rounds, 0) + await assert_row_count_and_clear(stage_items, 0) await assert_row_count_and_clear(stages, 1) @@ -187,8 +188,8 @@ async def test_activate_stage( == SUCCESS_RESPONSE ) - await assert_row_count_and_clear(stage_items, 1) - await assert_row_count_and_clear(stages, 1) + await assert_row_count_and_clear(stage_items, 0) + await assert_row_count_and_clear(stages, 2) @pytest.mark.asyncio(loop_scope="session") diff --git a/backend/tests/integration_tests/api/teams_test.py b/backend/tests/integration_tests/api/teams_test.py index bc466bc0..98678a97 100644 --- a/backend/tests/integration_tests/api/teams_test.py +++ b/backend/tests/integration_tests/api/teams_test.py @@ -3,12 +3,15 @@ import aiofiles.os import aiohttp import pytest +from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncConnection +from bracket.database import engine from bracket.models.db.team import Team -from bracket.schema import players, teams +from bracket.schema import players, players_x_teams, teams from bracket.utils.db import fetch_one_parsed_certain from bracket.utils.dummy_records import DUMMY_MOCK_TIME, DUMMY_TEAM1, DUMMY_TOURNAMENT +from bracket.utils.uploads import build_upload_path from tests.integration_tests.api.shared import ( SUCCESS_RESPONSE, send_auth_request, @@ -20,6 +23,7 @@ inserted_team, inserted_tournament, ) +from tests.integration_tests.uploads import wait_until_removed @pytest.mark.asyncio(loop_scope="session") @@ -66,13 +70,49 @@ async def test_create_team( async def test_create_teams( startup_and_shutdown_uvicorn_server: None, auth_context: AuthContext ) -> None: - body = {"names": "Team -1,Player 42,Player 43\nTeam -2,", "active": True} + body = {"names": "Team -1,Player 42, Player 43\n\nTeam -2,", "active": True} response = await send_tournament_request( HTTPMethod.POST, "teams_multi", auth_context, None, body ) assert response["success"] is True + + async with engine.begin() as conn: + members = ( + await conn.execute( + select(teams.c.name, players.c.name) + .join(players_x_teams, players_x_teams.c.team_id == teams.c.id) + .join(players, players.c.id == players_x_teams.c.player_id) + .order_by(players.c.name) + ) + ).all() + assert [tuple(member) for member in members] == [ + ("Team -1", "Player 42"), + ("Team -1", "Player 43"), + ] + + await assert_row_count_and_clear(players_x_teams, 2) await assert_row_count_and_clear(teams, 2) - await assert_row_count_and_clear(players, 3) + await assert_row_count_and_clear(players, 2) + + +@pytest.mark.asyncio(loop_scope="session") +@pytest.mark.parametrize( + ("names", "error"), + [ + (" \n ", "Enter at least one team"), + (",Player without a team", "Every line needs a team name before its players"), + (f"Team,{'x' * 31}", f'"{"x" * 31}" is longer than the 30 characters a name can have'), + ], +) +async def test_create_teams_invalid_names( + startup_and_shutdown_uvicorn_server: None, auth_context: AuthContext, names: str, error: str +) -> None: + response = await send_tournament_request( + HTTPMethod.POST, "teams_multi", auth_context, None, {"names": names, "active": True} + ) + + assert [detail["msg"] for detail in response["detail"]] == [f"Value error, {error}"] + await assert_row_count_and_clear(teams, 0) @pytest.mark.asyncio(loop_scope="session") @@ -149,19 +189,18 @@ async def test_team_upload_and_remove_logo( ) assert response["data"]["logo_path"], f"Response: {response}" - assert await aiofiles.os.path.exists(f"static/team-logos/{response['data']['logo_path']}") + logo_path = build_upload_path("team-logos", response["data"]["logo_path"]) + assert await aiofiles.os.path.exists(logo_path) response = await send_tournament_request( method=HTTPMethod.POST, - endpoint="logo", + endpoint=f"teams/{team_inserted.id}/logo", auth_context=auth_context, body=aiohttp.FormData(), ) assert response["data"]["logo_path"] is None, f"Response: {response}" - assert not await aiofiles.os.path.exists( - f"static/team-logos/{response['data']['logo_path']}" - ) + await wait_until_removed(logo_path) @pytest.mark.asyncio(loop_scope="session") diff --git a/backend/tests/integration_tests/api/tournaments_test.py b/backend/tests/integration_tests/api/tournaments_test.py index e021a608..c2f558a7 100644 --- a/backend/tests/integration_tests/api/tournaments_test.py +++ b/backend/tests/integration_tests/api/tournaments_test.py @@ -13,6 +13,7 @@ from bracket.utils.db import fetch_one_parsed_certain from bracket.utils.dummy_records import DUMMY_MOCK_TIME, DUMMY_TOURNAMENT from bracket.utils.types import assert_some +from bracket.utils.uploads import build_upload_path from tests.integration_tests.api.shared import ( SUCCESS_RESPONSE, send_auth_request, @@ -21,6 +22,7 @@ ) from tests.integration_tests.models import AuthContext from tests.integration_tests.sql import inserted_tournament +from tests.integration_tests.uploads import wait_until_removed @pytest.mark.asyncio(loop_scope="session") @@ -140,6 +142,55 @@ async def test_update_tournament( assert updated_tournament.dashboard_public == body["dashboard_public"] +@pytest.mark.asyncio(loop_scope="session") +@pytest.mark.parametrize( + ("dashboard_endpoint", "valid"), + [ + ("summer-cup_2026", True), + (" padded ", True), + ("", True), + ("2026", False), + ("with space", False), + ("a/b", False), + ("x" * 65, False), + ], +) +async def test_update_tournament_details_link( + conn: AsyncConnection, + startup_and_shutdown_uvicorn_server: None, + auth_context: AuthContext, + dashboard_endpoint: str, + valid: bool, +) -> None: + body = { + "name": " Some new name ", + "start_time": DUMMY_MOCK_TIME.isoformat().replace("+00:00", "Z"), + "dashboard_public": True, + "dashboard_endpoint": dashboard_endpoint, + "players_can_be_in_multiple_teams": True, + "duration_minutes": 12, + "margin_minutes": 3, + } + response = await send_tournament_request(HTTPMethod.PUT, "", auth_context, json=body) + updated_tournament = await fetch_one_parsed_certain( + conn, + Tournament, + query=tournaments.select().where(tournaments.c.id == auth_context.tournament.id), + ) + await conn.execute( + tournaments.update() + .where(tournaments.c.id == auth_context.tournament.id) + .values(dashboard_endpoint=auth_context.tournament.dashboard_endpoint) + ) + + if valid: + assert response == SUCCESS_RESPONSE + assert updated_tournament.name == "Some new name" + assert updated_tournament.dashboard_endpoint == (dashboard_endpoint.strip() or None) + else: + assert [error["loc"] for error in response["detail"]] == [["body", "dashboard_endpoint"]] + + @pytest.mark.asyncio(loop_scope="session") async def test_archive_and_unarchive_tournament( conn: AsyncConnection, startup_and_shutdown_uvicorn_server: None, auth_context: AuthContext @@ -224,16 +275,15 @@ async def test_tournament_upload_and_remove_logo( ) assert response.get("data", {}).get("logo_path"), f"Response: {response}" - assert await aiofiles.os.path.exists(f"static/tournament-logos/{response['data']['logo_path']}") + logo_path = build_upload_path("tournament-logos", response["data"]["logo_path"]) + assert await aiofiles.os.path.exists(logo_path) response = await send_tournament_request( method=HTTPMethod.POST, endpoint="logo", auth_context=auth_context, body=aiohttp.FormData() ) assert response["data"]["logo_path"] is None, f"Response: {response}" - assert not await aiofiles.os.path.exists( - f"static/tournament-logos/{response['data']['logo_path']}" - ) + await wait_until_removed(logo_path) UNAUTHORIZED_RESPONSE = { diff --git a/backend/tests/integration_tests/api/users_test.py b/backend/tests/integration_tests/api/users_test.py index 034b3358..dfdbfde3 100644 --- a/backend/tests/integration_tests/api/users_test.py +++ b/backend/tests/integration_tests/api/users_test.py @@ -2,19 +2,26 @@ from contextlib import asynccontextmanager from http import HTTPMethod +import aiohttp import pytest from heliclockter import datetime_utc +from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncConnection from bracket.database import engine from bracket.models.db.account import UserAccountType from bracket.models.db.user import User, UserInsertable +from bracket.routes.auth import SESSION_COOKIE from bracket.schema import users from bracket.sql.users import create_user, delete_user, get_user_by_id from bracket.utils.db import fetch_one_parsed_certain -from bracket.utils.security import hash_password +from bracket.utils.security import hash_password, verify_password from bracket.utils.types import assert_some -from tests.integration_tests.api.shared import send_auth_request, send_request +from tests.integration_tests.api.shared import ( + get_root_uvicorn_url, + send_auth_request, + send_request, +) from tests.integration_tests.mocks import get_mock_token from tests.integration_tests.models import AuthContext @@ -70,7 +77,7 @@ async def temporary_user() -> AsyncIterator[tuple[User, dict[str, str]]]: ) async with engine.begin() as conn: user_created = await create_user(conn, new_user) - headers = {"Authorization": f"Bearer {get_mock_token(user_created.email)}"} + headers = {"Authorization": f"Bearer {get_mock_token(user_created)}"} yield user_created, headers finally: if user_created is not None: @@ -95,12 +102,51 @@ async def test_update_user( assert updated_user.name == body["name"] +@pytest.mark.asyncio(loop_scope="session") +async def test_update_user_email_already_taken( + startup_and_shutdown_uvicorn_server: None, auth_context: AuthContext +) -> None: + async with temporary_user() as (user_created, headers): + response = await send_auth_request( + HTTPMethod.PUT, + f"users/{user_created.id}", + auth_context.model_copy(update={"user": user_created, "headers": headers}), + json={"name": "name", "email": auth_context.user.email.upper()}, + ) + + assert response == {"detail": "This email is already taken"} + + +@pytest.mark.asyncio(loop_scope="session") +async def test_users_cannot_access_each_other( + startup_and_shutdown_uvicorn_server: None, auth_context: AuthContext +) -> None: + async with temporary_user() as (user_created, _): + responses = [ + await send_auth_request(HTTPMethod.GET, f"users/{user_created.id}", auth_context), + await send_auth_request( + HTTPMethod.PUT, + f"users/{user_created.id}", + auth_context, + json={"name": "Hijacked", "email": "hijacked@example.org"}, + ), + await send_auth_request( + HTTPMethod.PUT, + f"users/{user_created.id}/password", + auth_context, + json={"current_password": "mypassword", "password": "hijacked-pass-phrase-42"}, + ), + ] + + assert responses == [{"detail": "You can only manage your own account"}] * 3 + + @pytest.mark.asyncio(loop_scope="session") async def test_update_user_password( conn: AsyncConnection, startup_and_shutdown_uvicorn_server: None, auth_context: AuthContext ) -> None: async with temporary_user() as (user_created, headers): - body = {"password": "correct-horse-battery-staple"} + body = {"current_password": "some password", "password": "correct-horse-battery-staple"} response = await send_auth_request( HTTPMethod.PUT, f"users/{user_created.id}/password", @@ -113,4 +159,82 @@ async def test_update_user_password( assert response.get("success") is True, response assert updated_user.password_hash and updated_user.password_hash.startswith("$argon2id$") - assert auth_context.user != updated_user.password_hash + assert verify_password(body["password"], updated_user.password_hash)[0] + + +@pytest.mark.asyncio(loop_scope="session") +async def test_update_user_password_needs_current_password( + conn: AsyncConnection, startup_and_shutdown_uvicorn_server: None, auth_context: AuthContext +) -> None: + async with temporary_user() as (user_created, headers): + body = {"current_password": "wrong password", "password": "correct-horse-battery-staple"} + response = await send_auth_request( + HTTPMethod.PUT, + f"users/{user_created.id}/password", + auth_context.model_copy(update={"user": user_created, "headers": headers}), + json=body, + ) + password_hash = await conn.scalar( + select(users.c.password_hash).where(users.c.id == user_created.id) + ) + + assert response == {"detail": "The current password is incorrect"} + assert password_hash == user_created.password_hash + + +@pytest.mark.asyncio(loop_scope="session") +async def test_email_with_wildcards_is_not_a_pattern( + conn: AsyncConnection, startup_and_shutdown_uvicorn_server: None, auth_context: AuthContext +) -> None: + """ + Regression test: emails were looked up with `ILIKE`, so a token of `%@example.org` belonged to + whichever account with that domain came first. + """ + victim_domain = auth_context.user.email.split("@")[1] + response = await send_request( + HTTPMethod.POST, + "users/register", + None, + {"name": "Mallory", "email": f"%@{victim_domain}", "password": "a-long-passphrase-42"}, + ) + assert "data" in response, response + headers = {"Authorization": f"Bearer {response['data']['access_token']}"} + + try: + me = await send_request(HTTPMethod.GET, "users/me", None, None, headers) + login = await send_request( + HTTPMethod.POST, + "token", + {"username": f"%@{victim_domain}", "password": "a-long-passphrase-42"}, + ) + finally: + await delete_user(conn, response["data"]["user_id"]) + + assert me["data"]["id"] == response["data"]["user_id"] != auth_context.user.id + assert login["user_id"] == response["data"]["user_id"] + + +@pytest.mark.asyncio(loop_scope="session") +async def test_update_user_password_signs_out_other_sessions( + startup_and_shutdown_uvicorn_server: None, auth_context: AuthContext +) -> None: + async with ( + temporary_user() as (user_created, headers), + aiohttp.ClientSession() as session, + ): + response = await session.put( + f"{get_root_uvicorn_url()}users/{user_created.id}/password", + json={"current_password": "some password", "password": "correct-horse-battery-staple"}, + headers=headers, + ) + new_session = {"Authorization": f"Bearer {response.cookies[SESSION_COOKIE].value}"} + # The token the password was changed with stands for every other session. + context = auth_context.model_copy(update={"user": user_created, "headers": headers}) + other_session = await send_auth_request(HTTPMethod.GET, "users/me", context) + this_session = await send_auth_request( + HTTPMethod.GET, "users/me", context.model_copy(update={"headers": new_session}) + ) + + assert response.status == 200 + assert other_session == {"detail": "Could not validate credentials"} + assert this_session["data"]["id"] == user_created.id diff --git a/backend/tests/integration_tests/mocks.py b/backend/tests/integration_tests/mocks.py index 4829508f..32469ba9 100644 --- a/backend/tests/integration_tests/mocks.py +++ b/backend/tests/integration_tests/mocks.py @@ -1,11 +1,12 @@ from uuid import uuid4 from zoneinfo import ZoneInfo -from heliclockter import datetime_utc, timedelta +from heliclockter import datetime_utc from bracket.models.db.account import UserAccountType -from bracket.models.db.user import UserInsertable -from bracket.routes.auth import ACCESS_TOKEN_EXPIRE_MINUTES, create_access_token +from bracket.models.db.user import User, UserInDB, UserInsertable +from bracket.routes.auth import create_access_token +from bracket.utils.types import assert_some MOCK_NOW = datetime_utc( year=2200, month=1, day=1, hour=0, minute=0, microsecond=0, second=0, tzinfo=ZoneInfo("UTC") @@ -27,8 +28,5 @@ def get_mock_user() -> UserInsertable: ) -def get_mock_token(mock_user_email: str) -> str: - return create_access_token( - data={"user": mock_user_email}, - expires_delta=timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES), - ) +def get_mock_token(user: User | UserInDB) -> str: + return create_access_token(user.id, assert_some(user.password_hash)) diff --git a/backend/tests/integration_tests/sql.py b/backend/tests/integration_tests/sql.py index cf693f54..86487f3a 100644 --- a/backend/tests/integration_tests/sql.py +++ b/backend/tests/integration_tests/sql.py @@ -3,7 +3,7 @@ from typing import cast from pydantic import BaseModel -from sqlalchemy import Table +from sqlalchemy import Table, func, select from bracket.database import engine from bracket.models.db.club import Club, ClubInsertable @@ -49,8 +49,11 @@ async def assert_row_count_and_clear(table: Table, expected_rows: int) -> None: async with engine.begin() as conn: + row_count = await conn.scalar(select(func.count()).select_from(table)) await conn.execute(table.delete()) + assert row_count == expected_rows + @asynccontextmanager async def inserted_generic[BaseModelT: BaseModel]( @@ -187,7 +190,7 @@ async def inserted_auth_context() -> AsyncIterator[AuthContext]: ) as user_x_club_inserted, ): yield AuthContext( - headers={"Authorization": f"Bearer {get_mock_token(mock_user.email)}"}, + headers={"Authorization": f"Bearer {get_mock_token(user_inserted)}"}, user=user_inserted, club=club_inserted, tournament=tournament_inserted, diff --git a/backend/tests/integration_tests/uploads.py b/backend/tests/integration_tests/uploads.py new file mode 100644 index 00000000..c232d7e4 --- /dev/null +++ b/backend/tests/integration_tests/uploads.py @@ -0,0 +1,14 @@ +import asyncio +from pathlib import Path + +import aiofiles.os + + +async def wait_until_removed(path: Path) -> None: + """Replaced uploads are removed by a background task, which runs after the response is sent.""" + for _ in range(50): + if not await aiofiles.os.path.exists(path): + return + await asyncio.sleep(0.02) + + raise AssertionError(f"{path} was not removed") diff --git a/backend/tests/unit_tests/swiss_pairing_test.py b/backend/tests/unit_tests/swiss_pairing_test.py index c80919e1..fb6f20c5 100644 --- a/backend/tests/unit_tests/swiss_pairing_test.py +++ b/backend/tests/unit_tests/swiss_pairing_test.py @@ -4,7 +4,7 @@ from bracket.logic.scheduling.swiss import get_swiss_pairing from bracket.models.db.match import Match, MatchWithDetailsDefinitive from bracket.models.db.stage_item import StageType -from bracket.models.db.stage_item_inputs import StageItemInputFinal +from bracket.models.db.stage_item_inputs import StageItemInput, StageItemInputFinal from bracket.models.db.team import Team from bracket.models.db.util import RoundWithMatches, StageItemWithRounds from bracket.utils.dummy_records import DUMMY_MATCH1, DUMMY_MOCK_TIME, DUMMY_TEAM1 @@ -69,7 +69,7 @@ def get_stage_item( type_name="Swiss", team_count=len(teams), ranking_id=None, - inputs=teams, + inputs=list[StageItemInput](teams), rounds=rounds, ) diff --git a/backend/tests/unit_tests/swiss_test.py b/backend/tests/unit_tests/swiss_test.py deleted file mode 100644 index 256d92a9..00000000 --- a/backend/tests/unit_tests/swiss_test.py +++ /dev/null @@ -1,108 +0,0 @@ -from decimal import Decimal - -from bracket.logic.scheduling.ladder_teams import get_possible_upcoming_matches_for_swiss -from bracket.models.db.match import Match, MatchFilter, MatchWithDetailsDefinitive, SuggestedMatch -from bracket.models.db.stage_item_inputs import ( - StageItemInput, - StageItemInputFinal, -) -from bracket.models.db.team import Team -from bracket.models.db.util import RoundWithMatches -from bracket.utils.dummy_records import ( - DUMMY_MATCH1, - DUMMY_TEAM1, -) -from bracket.utils.id_types import ( - MatchId, - RoundId, - StageItemId, - StageItemInputId, - TeamId, - TournamentId, -) -from tests.integration_tests.mocks import MOCK_NOW - -MATCH_FILTER = MatchFilter(elo_diff_threshold=50, iterations=100, limit=20, only_recommended=False) - - -def get_match( - match: Match, stage_item_input1: StageItemInputFinal, stage_item_input2: StageItemInputFinal -) -> MatchWithDetailsDefinitive: - return MatchWithDetailsDefinitive( - **match.model_copy( - update={ - "stage_item_input1_id": stage_item_input1.id, - "stage_item_input2_id": stage_item_input2.id, - } - ).model_dump(), - stage_item_input1=stage_item_input1, - stage_item_input2=stage_item_input2, - ) - - -def test_constraints() -> None: - stage_item_input_dummy = StageItemInputFinal( - id=StageItemInputId(-1), - tournament_id=TournamentId(-1), - team_id=TeamId(-1), - slot=0, - points=Decimal("2.0"), - wins=1, - draws=0, - losses=0, - team=Team(**DUMMY_TEAM1.model_dump(), id=TeamId(-1)), - ) - input1 = stage_item_input_dummy.model_copy(update={"id": -1, "points": Decimal("1125.0")}) - input2 = stage_item_input_dummy.model_copy(update={"id": -2, "points": Decimal("1175.0")}) - input3 = stage_item_input_dummy.model_copy(update={"id": -3, "points": Decimal("1200.0")}) - input4 = stage_item_input_dummy.model_copy(update={"id": -4, "points": Decimal("1250.0")}) - - rounds = [ - RoundWithMatches( - id=RoundId(-1), - matches=[ - get_match( - Match.model_validate(DUMMY_MATCH1.model_dump() | {"id": MatchId(-1)}), - input1, - input2, - ) - ], - is_draft=False, - stage_item_id=StageItemId(-1), - name="R1", - created=MOCK_NOW, - ), - RoundWithMatches( - id=RoundId(-2), - matches=[], - is_draft=True, - stage_item_id=StageItemId(-1), - name="R2", - created=MOCK_NOW, - ), - ] - inputs: list[StageItemInput] = [input1, input2, input3, input4] - result = get_possible_upcoming_matches_for_swiss(MATCH_FILTER, rounds, inputs) - - # Team 3 and 4 haven't played yet, so any suggested match with one or more of those teams - # is recommended. - assert result == [ - SuggestedMatch( - stage_item_input1=input4, - stage_item_input2=input3, - elo_diff=Decimal("50.0"), - swiss_diff=Decimal("50.0"), - is_recommended=True, - times_played_sum=0, - player_behind_schedule_count=0, - ), - SuggestedMatch( - stage_item_input1=input3, - stage_item_input2=input2, - elo_diff=Decimal("25.0"), - swiss_diff=Decimal("25.0"), - is_recommended=False, - times_played_sum=1, - player_behind_schedule_count=0, - ), - ] diff --git a/backend/uv.lock b/backend/uv.lock index c831f1f8..f8038791 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -167,19 +167,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2b/27/5e1f8446545be98e9416af59c396b1ed1f9f3294afb0a40ee3a709889273/aiohttp-3.14.4-py3-none-any.whl", hash = "sha256:5c6758ba62aea282c537179cfc8474a90f2b5f7b089cc5ff66d8920d86a9bfdd", size = 279487, upload-time = "2026-10-05T17:43:44.905Z" }, ] -[[package]] -name = "aioresponses" -version = "0.7.9" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "aiohttp" }, - { name = "packaging" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/28/fb/e3f08af812b3e66fca511ea1babb9dfddeca5965dea2a4d13b6926e0b1c2/aioresponses-0.7.9.tar.gz", hash = "sha256:1dcfa28938fc006f046a98383a7c07ac180be7a492c1ed557f5cd7b0805357d3", size = 34072, upload-time = "2026-06-23T21:23:23.828Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/71/55/4c77cda7e69c1ac81a32e6895a361e0da9350eb7835a2ddb161a37ef1ce9/aioresponses-0.7.9-py2.py3-none-any.whl", hash = "sha256:94f9617f841c5bd7ee088ed783284f2cf4e6acc85d3933d92fc2fc7bd572a1b0", size = 12832, upload-time = "2026-06-23T21:23:22.426Z" }, -] - [[package]] name = "aiosignal" version = "1.4.0" @@ -514,8 +501,6 @@ dependencies = [ { name = "click" }, { name = "email-validator" }, { name = "fastapi" }, - { name = "fastapi-sso" }, - { name = "gunicorn" }, { name = "heliclockter" }, { name = "networkx" }, { name = "pillow" }, @@ -529,9 +514,6 @@ dependencies = [ { name = "slowapi" }, { name = "sqlalchemy", extra = ["asyncio"] }, { name = "starlette" }, - { name = "types-aiofiles" }, - { name = "types-networkx" }, - { name = "types-zxcvbn" }, { name = "uvicorn" }, { name = "zxcvbn" }, ] @@ -539,7 +521,6 @@ dependencies = [ [package.dev-dependencies] dev = [ { name = "aiohttp" }, - { name = "aioresponses" }, { name = "mypy" }, { name = "pluggy" }, { name = "pylint" }, @@ -549,6 +530,9 @@ dev = [ { name = "pytest-cov" }, { name = "pytest-xdist" }, { name = "ruff" }, + { name = "types-aiofiles" }, + { name = "types-networkx" }, + { name = "types-zxcvbn" }, { name = "vulture" }, ] @@ -560,8 +544,6 @@ requires-dist = [ { name = "click", specifier = "==8.5.0" }, { name = "email-validator", specifier = "==2.3.0" }, { name = "fastapi", specifier = "==0.142.2" }, - { name = "fastapi-sso", specifier = "==0.23.0" }, - { name = "gunicorn", specifier = "==26.2.0" }, { name = "heliclockter", specifier = "==3.0.1" }, { name = "networkx", specifier = "==3.7" }, { name = "pillow", specifier = "==12.3.0" }, @@ -575,9 +557,6 @@ requires-dist = [ { name = "slowapi", specifier = "==0.1.10" }, { name = "sqlalchemy", extras = ["asyncio"], specifier = "==2.1.3" }, { name = "starlette", specifier = "==1.7.0" }, - { name = "types-aiofiles", specifier = "==25.1.0.20260518" }, - { name = "types-networkx", specifier = "==3.6.1.20261006" }, - { name = "types-zxcvbn", specifier = "==4.5.0.20260518" }, { name = "uvicorn", specifier = "==0.54.0" }, { name = "zxcvbn", specifier = "==4.5.0" }, ] @@ -585,7 +564,6 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ { name = "aiohttp", specifier = "==3.14.4" }, - { name = "aioresponses", specifier = "==0.7.9" }, { name = "mypy", specifier = "==2.4.0" }, { name = "pluggy", specifier = "==1.6.0" }, { name = "pylint", specifier = "==4.1.2" }, @@ -595,18 +573,12 @@ dev = [ { name = "pytest-cov", specifier = "==7.1.0" }, { name = "pytest-xdist", specifier = "==3.8.0" }, { name = "ruff", specifier = "==0.16.10" }, + { name = "types-aiofiles", specifier = "==25.1.0.20260518" }, + { name = "types-networkx", specifier = "==3.6.1.20261006" }, + { name = "types-zxcvbn", specifier = "==4.5.0.20260518" }, { name = "vulture", specifier = "==2.16" }, ] -[[package]] -name = "certifi" -version = "2025.11.12" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a2/8c/58f469717fa48465e4a50c014a0400602d3c437d7c0c468e17ada824da3a/certifi-2025.11.12.tar.gz", hash = "sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316", size = 160538, upload-time = "2025-11-12T02:54:51.517Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/70/7d/9bc192684cea499815ff478dfcdc13835ddf401365057044fb721ec6bddb/certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b", size = 159438, upload-time = "2025-11-12T02:54:49.735Z" }, -] - [[package]] name = "cffi" version = "2.1.1" @@ -853,22 +825,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a0/b6/78aaf9141fb46742928c113f3cf6ef2259d538cb02b604b7656c1dc9883c/fastapi-0.142.2-py3-none-any.whl", hash = "sha256:bd5f4d81f1e93a88bcd77caf4dfe3c2dbffc3805407a0007e9a114c18b3a670b", size = 144409, upload-time = "2026-09-30T09:51:09.8Z" }, ] -[[package]] -name = "fastapi-sso" -version = "0.23.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "fastapi" }, - { name = "httpx" }, - { name = "oauthlib" }, - { name = "pydantic", extra = ["email"] }, - { name = "pyjwt" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/ec/22/cbe208936aa09345e4b7855d3397792dc15b0b72a479aa5a3b4866ba15ad/fastapi_sso-0.23.0.tar.gz", hash = "sha256:be2858b30603a6337ad59aba82049099f5536ebc1eb41b036ad498a95f06ce29", size = 21387, upload-time = "2026-09-21T13:21:11.227Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b3/60/d5dcb6978ccf1fce22f5944c51c31ebfc97e1dacc9241b67cff19c4e0c4c/fastapi_sso-0.23.0-py3-none-any.whl", hash = "sha256:7dd537adc836142e8b64def86a901fead21c9d42d4001872a18e6f9316bc4ec0", size = 31821, upload-time = "2026-09-21T13:21:12.238Z" }, -] - [[package]] name = "frozenlist" version = "1.8.0" @@ -997,15 +953,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e3/a5/6ddab2b4c112be95601c13428db1d8b6608a8b6039816f2ba09c346c08fc/greenlet-3.2.4-cp314-cp314-win_amd64.whl", hash = "sha256:e37ab26028f12dbb0ff65f29a8d3d44a765c61e729647bf2ddfbbed621726f01", size = 303425, upload-time = "2025-08-07T13:32:27.59Z" }, ] -[[package]] -name = "gunicorn" -version = "26.2.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d9/8a/e4ef6ee11701b6cd64702848415ffb69eeff85cb388a3c6c7fe86f22f3f8/gunicorn-26.2.0.tar.gz", hash = "sha256:62b864895d9ebff0b2f9867ba04fe811c93121596540830c9c916d0769668447", size = 787921, upload-time = "2026-08-24T15:05:59.3Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fe/85/7522a52e5e2f42faf1a129113ab63e548c42e103e9af395b7bfe65e403e2/gunicorn-26.2.0-py3-none-any.whl", hash = "sha256:bd249d0b3f7972f7432f0a6b6ff3b3ee2d129f70cd1ff6c09a9dd9e29a2b88e3", size = 228389, upload-time = "2026-08-24T15:05:57.67Z" }, -] - [[package]] name = "h11" version = "0.16.0" @@ -1024,34 +971,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/38/1e/6fee94fcc46955b4e4447b72ab257fae66bcb2fbdbe54c7c67432ff95318/heliclockter-3.0.1-py3-none-any.whl", hash = "sha256:b453481854a8d10c9f08b0a272bc757133054741f4ef0fcbb675135b95207ce8", size = 8157, upload-time = "2025-11-04T15:45:07.777Z" }, ] -[[package]] -name = "httpcore" -version = "1.0.9" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "certifi" }, - { name = "h11" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, -] - -[[package]] -name = "httpx" -version = "0.28.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, - { name = "certifi" }, - { name = "httpcore" }, - { name = "idna" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, -] - [[package]] name = "idna" version = "3.15" @@ -1488,15 +1407,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7e/cd/fe58041e9011f307c490e3e17dd48cc516448f7c698a3f2d9d9d65d7e6a8/networkx-3.7-py3-none-any.whl", hash = "sha256:e3fd2c13a7814cee3746340d8d7f8598a67f16a58bf47fb7f8793fab6efca1b0", size = 2142205, upload-time = "2026-09-21T16:45:14.609Z" }, ] -[[package]] -name = "oauthlib" -version = "4.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7a/d8/a1bcc8ba112a627f8ffbdc212a78ce18d3ac07e91a5ca65d27918eee25a1/oauthlib-4.0.0.tar.gz", hash = "sha256:efb274799819440f95b4ab3b818869f1ce9ae26c5beacba0201d1a1b76b54f86", size = 187232, upload-time = "2026-09-28T06:01:18.77Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d9/f4/78229a1066068ca14fc60fb26cf7381cabe4382261392b90e5f9552722d4/oauthlib-4.0.0-py3-none-any.whl", hash = "sha256:624c28c13a0a59cabf9747dfa52af63be3e512a7f2714df16e91b5b3a145e6cd", size = 159715, upload-time = "2026-09-28T06:01:17.008Z" }, -] - [[package]] name = "opentelemetry-api" version = "1.45.1" @@ -1741,11 +1651,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/eb/47/c95ffc2009878c7aac0c5e08528022dcb885933252a88b5f170058014464/pydantic-2.13.5-py3-none-any.whl", hash = "sha256:346a034f080da3755d8e9cb5e00e8b07de1d39e4f6e2c87d8ab7cafa0b269a73", size = 472589, upload-time = "2026-08-28T14:03:59.136Z" }, ] -[package.optional-dependencies] -email = [ - { name = "email-validator" }, -] - [[package]] name = "pydantic-core" version = "2.46.5" From 8721c427c0f418737698a586e515960c72972052 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torma=20Krist=C3=B3f?= Date: Thu, 8 Oct 2026 18:50:10 +0200 Subject: [PATCH 2/4] feat(frontend): cookie sessions, strict CSP, code splitting, Swiss brackets Sessions - The session token is an HttpOnly cookie, so scripts can't read it. The page keeps only the signed-in user's name and id, logs out through the API and removes the tokens that older versions left in localStorage. - Changing the password asks for the current one. - The dev server proxies /api, so the page and the API share an origin, as in production. Content-Security-Policy - nginx sends a strict CSP (no inline scripts, no eval), frame denial, nosniff, HSTS, and cache headers that never serve a stale index.html. Its config is a template that the image's own entrypoint fills in. - The fonts are self-hosted with Fontsource instead of loaded from Google Fonts, and Zod runs without eval. Bundle - Organizer pages, forms, Zod and brackets-viewer load on demand, and React has a chunk of its own: visitors download 578 KB instead of 936 KB, and a release changes 263 KB of it. - The bracket theme loads with brackets-viewer's stylesheet, after it. Brackets - The Bracket tab also draws Swiss stages, as a one-group round robin with the rounds side by side. The ranking stays on Standings. - Nothing in a bracket reacts to hover except the team names, which open the team pages. Also: large tournaments load all their players and teams, and the bulk team import explains its "team, players" format. Co-Authored-By: Claude Opus 5.5 --- frontend/.dockerignore | 3 +- frontend/Dockerfile | 22 +- .../05-bracket-runtime-config.envsh | 16 + frontend/docker-entrypoint.sh | 10 - frontend/index.html | 18 +- frontend/nginx/default.conf | 17 - .../nginx/templates/default.conf.template | 35 ++ frontend/package.json | 6 +- frontend/pnpm-lock.yaml | 16 + frontend/src/app.tsx | 48 +- frontend/src/app/api.ts | 47 +- frontend/src/app/bracket-adapter.ts | 18 +- .../app/components/bracket-viewer-canvas.css | 84 +++ .../app/components/bracket-viewer-canvas.tsx | 76 +++ .../src/app/components/bracket-viewer.tsx | 74 +-- frontend/src/app/forms.ts | 7 + frontend/src/app/hooks.ts | 4 +- frontend/src/app/pages/auth-pages.tsx | 8 +- frontend/src/app/pages/home-page.tsx | 135 +---- .../src/app/pages/new-tournament-form.tsx | 112 ++++ frontend/src/app/pages/tournament-page.tsx | 45 +- frontend/src/app/pages/user-page.tsx | 11 +- .../src/app/sections/tournament-bracket.tsx | 10 +- .../app/sections/tournament-management.tsx | 326 +----------- .../src/app/sections/tournament-roster.tsx | 2 +- .../src/app/sections/tournament-schedule.tsx | 148 ++++++ .../src/app/sections/tournament-standings.tsx | 187 +++++++ frontend/src/app/types.ts | 9 +- frontend/src/app/utils.ts | 17 +- frontend/src/main.tsx | 15 + .../src/openapi/@tanstack/react-query.gen.ts | 82 ++- frontend/src/openapi/index.ts | 12 +- frontend/src/openapi/sdk.gen.ts | 498 +++++++++++++++--- frontend/src/openapi/types.gen.ts | 125 +---- frontend/src/openapi/zod.gen.ts | 92 +--- frontend/src/styles.css | 66 +-- frontend/src/zod-config.ts | 7 + frontend/vite.config.ts | 21 + 38 files changed, 1417 insertions(+), 1012 deletions(-) create mode 100755 frontend/docker-entrypoint.d/05-bracket-runtime-config.envsh delete mode 100644 frontend/docker-entrypoint.sh delete mode 100644 frontend/nginx/default.conf create mode 100644 frontend/nginx/templates/default.conf.template create mode 100644 frontend/src/app/components/bracket-viewer-canvas.css create mode 100644 frontend/src/app/components/bracket-viewer-canvas.tsx create mode 100644 frontend/src/app/forms.ts create mode 100644 frontend/src/app/pages/new-tournament-form.tsx create mode 100644 frontend/src/app/sections/tournament-schedule.tsx create mode 100644 frontend/src/app/sections/tournament-standings.tsx create mode 100644 frontend/src/zod-config.ts diff --git a/frontend/.dockerignore b/frontend/.dockerignore index 74438d89..f06235c4 100644 --- a/frontend/.dockerignore +++ b/frontend/.dockerignore @@ -1,3 +1,2 @@ node_modules -.next -.pytest_cache +dist diff --git a/frontend/Dockerfile b/frontend/Dockerfile index a36e6652..5ed7f7ef 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -3,28 +3,24 @@ FROM node:24-bookworm-slim AS builder WORKDIR /app -ENV NODE_ENV=production +# The dependencies first, so their layer is reused when only the source changes. +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +RUN corepack enable && pnpm install --frozen-lockfile COPY . . - -RUN corepack enable && \ - CI=true pnpm install && \ - pnpm build +RUN pnpm build # Production image, copy all the static files and run nginx as an unprivileged user FROM nginxinc/nginx-unprivileged:stable-bookworm AS runner LABEL org.opencontainers.image.description="Bracket frontend for the Ctrl-Alt-GG LAN tournament system: React 19 and Vite SPA served by nginx." -WORKDIR /usr/share/nginx/html - -ENV API_BASE_URL=http://localhost:8400 +# Empty: the API is on the page's own origin, behind the same reverse proxy. +ENV API_BASE_URL= COPY --from=builder --chown=101:101 /app/dist/ /usr/share/nginx/html/ -COPY ./nginx/default.conf /etc/nginx/conf.d/default.conf -COPY --chmod=755 ./docker-entrypoint.sh /docker-entrypoint.sh +# The image's entrypoint runs the scripts in /docker-entrypoint.d and fills in the templates. +COPY ./nginx/templates/ /etc/nginx/templates/ +COPY --chmod=755 ./docker-entrypoint.d/ /docker-entrypoint.d/ EXPOSE 8080 - -ENTRYPOINT ["/docker-entrypoint.sh"] -CMD ["nginx", "-g", "daemon off;"] diff --git a/frontend/docker-entrypoint.d/05-bracket-runtime-config.envsh b/frontend/docker-entrypoint.d/05-bracket-runtime-config.envsh new file mode 100755 index 00000000..c448aa08 --- /dev/null +++ b/frontend/docker-entrypoint.d/05-bracket-runtime-config.envsh @@ -0,0 +1,16 @@ +#!/bin/sh +# Sourced by the nginx image's entrypoint before it fills in /etc/nginx/templates, so the variables +# exported here are available there. + +# The page reads the API's address from this file, so one image works with any backend. Quotes and +# backslashes are escaped so that the value stays a string. +api_base_url_js=$(printf '%s' "${API_BASE_URL}" | sed 's/[\\"]/\\&/g') +cat >/usr/share/nginx/html/runtime-config.js </usr/share/nginx/html/runtime-config.js -window.__BRACKET_RUNTIME_CONFIG__ = { - apiBaseUrl: "${API_BASE_URL}" -}; -EOF - -exec "$@" \ No newline at end of file diff --git a/frontend/index.html b/frontend/index.html index b2c69e7a..9433ea4f 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -9,22 +9,6 @@ content="Schedules, standings and brackets of Ctrl-Alt-GG tournaments" name="description" /> - - - - - -