Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
658e3d6ec7
|
||
|
|
5e4e1310b4
|
||
|
|
db7ba30d13
|
||
|
|
f7fa8e78e5
|
@@ -32,7 +32,6 @@ jobs:
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
builder: multiplatform-builder
|
||||
file: server/Dockerfile
|
||||
platforms: linux/amd64
|
||||
push: true
|
||||
|
||||
@@ -61,16 +61,6 @@ data:
|
||||
GAME_TTL_SECONDS: "86400"
|
||||
HAND_ACK_TIMEOUT_SECONDS: "30"
|
||||
TURN_TIMEOUT_SECONDS: "30"
|
||||
# CORS (kaya-cors' CorsMixin). Disabled unless CORS_ALLOW_ORIGINS or
|
||||
# CORS_ALLOW_ORIGIN_REGEX is set — unneeded when the SPA and the API are
|
||||
# served from the same origin. See server/.env.example for details.
|
||||
# CORS_ALLOW_ORIGINS: "https://example.com,https://app.example.com" # or "*"
|
||||
# CORS_ALLOW_ORIGIN_REGEX: 'https://tavolo-[a-z0-9-]+\.vercel\.app'
|
||||
# CORS_ALLOW_METHODS: "GET,POST" # default: GET; "*" = all
|
||||
# CORS_ALLOW_HEADERS: "Authorization,Content-Type" # "*" mirrors the request
|
||||
# CORS_ALLOW_CREDENTIALS: "false"
|
||||
# CORS_EXPOSE_HEADERS: ""
|
||||
# CORS_MAX_AGE: "600"
|
||||
# OIDC (provider lives in another namespace).
|
||||
OIDC_CLIENT_ID: tavolo
|
||||
OIDC_POST_LOGIN_REDIRECT: /
|
||||
|
||||
@@ -113,15 +113,6 @@ services:
|
||||
REDIS_URL: redis://redis:6379/0
|
||||
HAND_ACK_TIMEOUT_SECONDS: ${HAND_ACK_TIMEOUT_SECONDS:-30}
|
||||
TURN_TIMEOUT_SECONDS: ${TURN_TIMEOUT_SECONDS:-30}
|
||||
# CORS is disabled unless CORS_ALLOW_ORIGINS or CORS_ALLOW_ORIGIN_REGEX
|
||||
# is set (see server/.env.example for the full list of options).
|
||||
CORS_ALLOW_ORIGINS: ${CORS_ALLOW_ORIGINS:-}
|
||||
CORS_ALLOW_ORIGIN_REGEX: ${CORS_ALLOW_ORIGIN_REGEX:-}
|
||||
CORS_ALLOW_METHODS: ${CORS_ALLOW_METHODS:-}
|
||||
CORS_ALLOW_HEADERS: ${CORS_ALLOW_HEADERS:-}
|
||||
CORS_ALLOW_CREDENTIALS: ${CORS_ALLOW_CREDENTIALS:-}
|
||||
CORS_EXPOSE_HEADERS: ${CORS_EXPOSE_HEADERS:-}
|
||||
CORS_MAX_AGE: ${CORS_MAX_AGE:-}
|
||||
ports:
|
||||
- "127.0.0.1:${APP_PORT:-8080}:8080"
|
||||
|
||||
|
||||
@@ -39,28 +39,6 @@ TURN_TIMEOUT_SECONDS=30
|
||||
# schema). Unset logs DEBUG to the console.
|
||||
#LOGGING_CONFIG=/path/to/logging.yaml
|
||||
|
||||
# CORS (via kaya-cors' CorsMixin; same semantics as Starlette's
|
||||
# CORSMiddleware). Disabled unless CORS_ALLOW_ORIGINS or
|
||||
# CORS_ALLOW_ORIGIN_REGEX is set — the app serves the SPA and the API from
|
||||
# the same origin, so no CORS headers are needed by default.
|
||||
# Comma-separated list of origins allowed to make cross-origin requests,
|
||||
# or "*" for any origin:
|
||||
#CORS_ALLOW_ORIGINS=https://example.com,https://app.example.com
|
||||
# Optional regex (fullmatch) allowed origins are additionally checked
|
||||
# against — handy for dynamic preview URLs:
|
||||
#CORS_ALLOW_ORIGIN_REGEX=https://tavolo-[a-z0-9-]+\.vercel\.app
|
||||
# Comma-separated allowed methods, or "*" for all (default GET):
|
||||
#CORS_ALLOW_METHODS=GET,POST
|
||||
# Comma-separated allowed request headers, or "*" to mirror back whatever
|
||||
# the browser requests (default: only the CORS-safelisted headers):
|
||||
#CORS_ALLOW_HEADERS=Authorization,Content-Type
|
||||
# Allow cookies/credentials on cross-origin requests (1/true/yes/on):
|
||||
#CORS_ALLOW_CREDENTIALS=false
|
||||
# Comma-separated response headers exposed to the browser:
|
||||
#CORS_EXPOSE_HEADERS=
|
||||
# Seconds browsers may cache the preflight response (default 600):
|
||||
#CORS_MAX_AGE=600
|
||||
|
||||
# App server
|
||||
APP_HOST=0.0.0.0
|
||||
APP_PORT=8080
|
||||
|
||||
+6
-10
@@ -48,21 +48,17 @@ RUN --mount=type=cache,target=/var/cache/apk \
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
|
||||
COPY server/requirements.txt ./
|
||||
RUN --mount=type=cache,target=/root/.cache/pip \
|
||||
python3 -m venv /opt/venv \
|
||||
&& /opt/venv/bin/pip install --upgrade pip \
|
||||
&& /opt/venv/bin/pip install -r requirements.txt
|
||||
|
||||
COPY server/pyproject.toml server/README.md ./
|
||||
COPY server/pyproject.toml server/README.md server/requirements.txt ./
|
||||
COPY server/src/ ./src/
|
||||
RUN --mount=type=cache,target=/root/.cache/pip \
|
||||
/opt/venv/bin/pip install .
|
||||
# aerich migration files are a release artifact: the db-migrate compose
|
||||
# service runs `aerich upgrade` from this image before the app starts.
|
||||
COPY server/migrations/ ./migrations/
|
||||
|
||||
RUN --mount=type=cache,target=/root/.cache/pip \
|
||||
python3 -m venv /opt/venv \
|
||||
&& /opt/venv/bin/pip install --upgrade pip \
|
||||
&& /opt/venv/bin/pip install -r requirements.txt .
|
||||
|
||||
# --- Runtime ---------------------------------------------------------------
|
||||
FROM alpine:3.24
|
||||
|
||||
|
||||
@@ -62,13 +62,6 @@ All configuration comes from environment variables (see `.env.example`):
|
||||
| `TURN_TIMEOUT_SECONDS` | `30` | Seconds a player has to play before the server plays a random legal card for them |
|
||||
| `DEADLINE_HEARTBEAT_MS` | `1000` | Upper bound on the deadline consumer's poll interval (locally enqueued deadlines fire on time regardless) |
|
||||
| `LOGGING_CONFIG` | unset | Path to a YAML logging configuration file (see below). Unset logs DEBUG to the console |
|
||||
| `CORS_ALLOW_ORIGINS` | unset | Comma-separated origins allowed for cross-origin requests, or `*` for any. CORS is disabled unless this or `CORS_ALLOW_ORIGIN_REGEX` is set |
|
||||
| `CORS_ALLOW_ORIGIN_REGEX` | unset | Regex (fullmatch) additionally matched against request origins, e.g. `https://tavolo-[a-z0-9-]+\.vercel\.app` |
|
||||
| `CORS_ALLOW_METHODS` | `GET` | Comma-separated methods allowed for cross-origin requests, or `*` for all |
|
||||
| `CORS_ALLOW_HEADERS` | unset | Comma-separated request headers allowed in cross-origin requests, or `*` to mirror back the requested ones. The CORS-safelisted headers are always allowed |
|
||||
| `CORS_ALLOW_CREDENTIALS` | `false` | `1`/`true`/`yes`/`on` allow cookies/credentials on cross-origin requests |
|
||||
| `CORS_EXPOSE_HEADERS` | unset | Comma-separated response headers exposed to the browser |
|
||||
| `CORS_MAX_AGE` | `600` | Seconds browsers may cache the preflight response |
|
||||
| `APP_HOST` / `APP_PORT` | `0.0.0.0` / `8080` | Bind address |
|
||||
|
||||
## Logging
|
||||
|
||||
@@ -10,7 +10,6 @@ readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"kaya-core",
|
||||
"kaya-cors",
|
||||
"kaya-session",
|
||||
"kaya-session-redis",
|
||||
"kaya-oidc",
|
||||
|
||||
@@ -52,14 +52,11 @@ iso8601==2.1.0
|
||||
# via tortoise-orm
|
||||
kaya-core==0.0.3
|
||||
# via
|
||||
# kaya-cors
|
||||
# kaya-oidc
|
||||
# kaya-openapi
|
||||
# kaya-rsgi
|
||||
# kaya-session
|
||||
# tavolo (pyproject.toml)
|
||||
kaya-cors==0.0.3
|
||||
# via tavolo (pyproject.toml)
|
||||
kaya-oidc==0.0.3
|
||||
# via tavolo (pyproject.toml)
|
||||
kaya-openapi==0.0.3
|
||||
|
||||
@@ -11,9 +11,6 @@ Assembles the :class:`~kaya.core.KayaApp` with four mixins:
|
||||
- :class:`~kaya.openapi.OpenAPIMixin` (serves the OpenAPI document at
|
||||
``/api/openapi.json`` and a Swagger UI at ``/api/docs``)
|
||||
|
||||
A :class:`~kaya.cors.CorsMixin` is prepended when CORS is configured via the
|
||||
``CORS_*`` environment variables (see :mod:`tavolo.config`).
|
||||
|
||||
Live games are kept in :data:`game_store` (Redis when configured, in-memory
|
||||
otherwise). Routes and the websocket handlers are registered by importing
|
||||
their modules at the bottom; imports must happen after ``app`` is built.
|
||||
@@ -22,17 +19,15 @@ from __future__ import annotations
|
||||
|
||||
from importlib.metadata import version as _pkg_version
|
||||
from logging import getLogger
|
||||
from typing import Optional
|
||||
|
||||
from kaya.core import KayaApp, KayaMixin
|
||||
from kaya.cors import CorsMixin
|
||||
from kaya.core import KayaApp
|
||||
from kaya.oidc import OIDCConfig, OIDCMixin
|
||||
from kaya.openapi import OpenAPIMixin
|
||||
from kaya.session import InMemorySessionStore, SessionMixin, SessionStore
|
||||
from kaya.session.redis import RedisSessionStore
|
||||
from redis.asyncio import Redis
|
||||
|
||||
from .config import Settings, settings
|
||||
from .config import settings
|
||||
from .deadlines import DeadlineSchedulerMixin
|
||||
from .logging_config import configure_logging
|
||||
from .store import GameStore, InMemoryGameStore, RedisGameStore
|
||||
@@ -41,27 +36,6 @@ from .tortoise_mixin import TortoiseMixin
|
||||
configure_logging(settings.logging_config)
|
||||
log = getLogger(__name__)
|
||||
|
||||
|
||||
def cors_mixin_from_settings(settings: Settings) -> Optional[CorsMixin]:
|
||||
"""Build a :class:`~kaya.cors.CorsMixin` from the CORS settings.
|
||||
|
||||
Returns ``None`` — CORS disabled — unless at least one of
|
||||
``CORS_ALLOW_ORIGINS`` / ``CORS_ALLOW_ORIGIN_REGEX`` is configured.
|
||||
Settings left unset fall back to the mixin's own defaults.
|
||||
"""
|
||||
if settings.cors_allow_origins is None and settings.cors_allow_origin_regex is None:
|
||||
return None
|
||||
return CorsMixin(
|
||||
allow_origins=settings.cors_allow_origins or (),
|
||||
allow_origin_regex=settings.cors_allow_origin_regex,
|
||||
allow_methods=settings.cors_allow_methods or ("GET",),
|
||||
allow_headers=settings.cors_allow_headers or (),
|
||||
allow_credentials=settings.cors_allow_credentials,
|
||||
expose_headers=settings.cors_expose_headers or (),
|
||||
max_age=settings.cors_max_age,
|
||||
)
|
||||
|
||||
|
||||
session_store: SessionStore
|
||||
if settings.redis_url is not None:
|
||||
# Lazy client: no connection is opened until a session is actually
|
||||
@@ -103,21 +77,8 @@ tortoise_mixin = TortoiseMixin(
|
||||
skip_paths=frozenset({"/api/health", "/api/docs", "/api/openapi.json"}),
|
||||
)
|
||||
|
||||
mixins: list[KayaMixin] = [session_mixin, oidc_mixin, tortoise_mixin, openapi_mixin,
|
||||
DeadlineSchedulerMixin(game_store)]
|
||||
cors_mixin = cors_mixin_from_settings(settings)
|
||||
if cors_mixin is not None:
|
||||
# First in the list: preflight requests are answered before the session
|
||||
# and OIDC hooks run.
|
||||
mixins.insert(0, cors_mixin)
|
||||
log.info(
|
||||
"CORS enabled: origins=%s origin_regex=%s credentials=%s",
|
||||
settings.cors_allow_origins,
|
||||
settings.cors_allow_origin_regex,
|
||||
settings.cors_allow_credentials,
|
||||
)
|
||||
|
||||
app = KayaApp(mixins=mixins)
|
||||
app = KayaApp(mixins=[session_mixin, oidc_mixin, tortoise_mixin, openapi_mixin,
|
||||
DeadlineSchedulerMixin(game_store)])
|
||||
log.debug(
|
||||
"timeouts: hand_ack=%ds turn=%ds",
|
||||
settings.hand_ack_timeout_seconds,
|
||||
|
||||
@@ -7,7 +7,7 @@ from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional, Tuple
|
||||
from typing import Optional
|
||||
from urllib.parse import quote
|
||||
|
||||
|
||||
@@ -20,25 +20,6 @@ def _env(name: str, default: Optional[str] = None) -> str:
|
||||
return value
|
||||
|
||||
|
||||
def _env_list(name: str) -> Optional[Tuple[str, ...]]:
|
||||
"""Parse a comma-separated environment variable into a tuple of values.
|
||||
|
||||
Items are stripped and empty items dropped. Unset or empty variables
|
||||
yield ``None``.
|
||||
"""
|
||||
value = os.environ.get(name)
|
||||
if value is None or value.strip() == "":
|
||||
return None
|
||||
return tuple(part.strip() for part in value.split(",") if part.strip())
|
||||
|
||||
|
||||
def _env_bool(name: str, default: bool = False) -> bool:
|
||||
value = os.environ.get(name)
|
||||
if value is None or value == "":
|
||||
return default
|
||||
return value.strip().lower() in ("1", "true", "yes", "on")
|
||||
|
||||
|
||||
def _database_url_from_parts(engine: str,
|
||||
user: str,
|
||||
password: Optional[str],
|
||||
@@ -103,16 +84,6 @@ class Settings:
|
||||
# Path to a YAML logging configuration file (logging.config.dictConfig
|
||||
# schema). Unset uses the built-in default: DEBUG to the console.
|
||||
logging_config: Optional[str]
|
||||
# CORS (kaya-cors' CorsMixin). Disabled unless CORS_ALLOW_ORIGINS or
|
||||
# CORS_ALLOW_ORIGIN_REGEX is set; the app serves the SPA and the API
|
||||
# from the same origin, so no CORS headers are needed by default.
|
||||
cors_allow_origins: Optional[Tuple[str, ...]]
|
||||
cors_allow_origin_regex: Optional[str]
|
||||
cors_allow_methods: Optional[Tuple[str, ...]]
|
||||
cors_allow_headers: Optional[Tuple[str, ...]]
|
||||
cors_allow_credentials: bool
|
||||
cors_expose_headers: Optional[Tuple[str, ...]]
|
||||
cors_max_age: int
|
||||
|
||||
@staticmethod
|
||||
def from_env() -> "Settings":
|
||||
@@ -149,16 +120,6 @@ class Settings:
|
||||
turn_timeout_seconds=int(_env("TURN_TIMEOUT_SECONDS", "30")),
|
||||
deadline_heartbeat_ms=int(_env("DEADLINE_HEARTBEAT_MS", "1000")),
|
||||
logging_config=os.environ.get("LOGGING_CONFIG"),
|
||||
# CORS is disabled unless CORS_ALLOW_ORIGINS (a comma-separated
|
||||
# list of origins, or "*" for any) or CORS_ALLOW_ORIGIN_REGEX
|
||||
# is set.
|
||||
cors_allow_origins=_env_list("CORS_ALLOW_ORIGINS"),
|
||||
cors_allow_origin_regex=os.environ.get("CORS_ALLOW_ORIGIN_REGEX") or None,
|
||||
cors_allow_methods=_env_list("CORS_ALLOW_METHODS"),
|
||||
cors_allow_headers=_env_list("CORS_ALLOW_HEADERS"),
|
||||
cors_allow_credentials=_env_bool("CORS_ALLOW_CREDENTIALS"),
|
||||
cors_expose_headers=_env_list("CORS_EXPOSE_HEADERS"),
|
||||
cors_max_age=int(_env("CORS_MAX_AGE", "600")),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -214,19 +214,13 @@ async def _fire_hand_end(store: GameStore, state: GameState, entry: Dict[str, An
|
||||
# --- consumer lifecycle -------------------------------------------------------
|
||||
|
||||
|
||||
def ensure_consumer(
|
||||
store: GameStore, loop: Optional[asyncio.AbstractEventLoop] = None
|
||||
) -> None:
|
||||
"""Start the deadline consumer on the given (or running) loop if not
|
||||
yet running.
|
||||
def ensure_consumer(store: GameStore) -> None:
|
||||
"""Start the deadline consumer on the running loop if not yet running.
|
||||
|
||||
Called lazily whenever a deadline is enqueued (the ASGI test transport
|
||||
never fires the lifespan hooks, so the mixin's ``setup`` alone is not
|
||||
enough) and on application startup. The explicit ``loop`` matters at
|
||||
startup: under RSGI granian calls ``setup`` before the loop runs, so
|
||||
``asyncio.get_running_loop()`` would fail there.
|
||||
enough) and on application startup.
|
||||
"""
|
||||
if loop is None:
|
||||
loop = asyncio.get_running_loop()
|
||||
for old in list(_consumers):
|
||||
if old.is_closed():
|
||||
@@ -290,7 +284,7 @@ class DeadlineSchedulerMixin(KayaMixin):
|
||||
pass
|
||||
|
||||
def setup(self, loop: asyncio.AbstractEventLoop) -> None:
|
||||
ensure_consumer(self._store, loop)
|
||||
ensure_consumer(self._store)
|
||||
|
||||
def shutdown(self, loop: asyncio.AbstractEventLoop) -> None:
|
||||
stop_consumer(loop)
|
||||
|
||||
@@ -81,54 +81,5 @@ class DatabaseUrlTests(unittest.TestCase):
|
||||
)
|
||||
|
||||
|
||||
class CorsSettingsTests(unittest.TestCase):
|
||||
def test_cors_disabled_by_default(self):
|
||||
settings = _settings({})
|
||||
self.assertIsNone(settings.cors_allow_origins)
|
||||
self.assertIsNone(settings.cors_allow_origin_regex)
|
||||
self.assertIsNone(settings.cors_allow_methods)
|
||||
self.assertIsNone(settings.cors_allow_headers)
|
||||
self.assertFalse(settings.cors_allow_credentials)
|
||||
self.assertIsNone(settings.cors_expose_headers)
|
||||
self.assertEqual(600, settings.cors_max_age)
|
||||
|
||||
def test_allow_origins_parses_comma_separated_list(self):
|
||||
settings = _settings({
|
||||
"CORS_ALLOW_ORIGINS": "https://a.example, https://b.example ,,https://c.example",
|
||||
})
|
||||
self.assertEqual(
|
||||
("https://a.example", "https://b.example", "https://c.example"),
|
||||
settings.cors_allow_origins,
|
||||
)
|
||||
|
||||
def test_allow_origins_star_is_passed_through(self):
|
||||
settings = _settings({"CORS_ALLOW_ORIGINS": "*"})
|
||||
self.assertEqual(("*",), settings.cors_allow_origins)
|
||||
|
||||
def test_allow_origin_regex_is_passed_through(self):
|
||||
settings = _settings({"CORS_ALLOW_ORIGIN_REGEX": r"https://.*\.example\.com"})
|
||||
self.assertEqual(r"https://.*\.example\.com", settings.cors_allow_origin_regex)
|
||||
|
||||
def test_allow_methods_and_headers_parse_as_lists(self):
|
||||
settings = _settings({
|
||||
"CORS_ALLOW_METHODS": "GET,POST",
|
||||
"CORS_ALLOW_HEADERS": "Authorization, X-Custom-Header",
|
||||
"CORS_EXPOSE_HEADERS": "X-Total-Count",
|
||||
})
|
||||
self.assertEqual(("GET", "POST"), settings.cors_allow_methods)
|
||||
self.assertEqual(("Authorization", "X-Custom-Header"), settings.cors_allow_headers)
|
||||
self.assertEqual(("X-Total-Count",), settings.cors_expose_headers)
|
||||
|
||||
def test_allow_credentials_parses_boolean(self):
|
||||
for value in ("1", "true", "TRUE", "yes", "on"):
|
||||
self.assertTrue(_settings({"CORS_ALLOW_CREDENTIALS": value}).cors_allow_credentials)
|
||||
for value in ("0", "false", "no", "off", "anything-else"):
|
||||
self.assertFalse(_settings({"CORS_ALLOW_CREDENTIALS": value}).cors_allow_credentials)
|
||||
|
||||
def test_max_age_parses_int(self):
|
||||
settings = _settings({"CORS_MAX_AGE": "3600"})
|
||||
self.assertEqual(3600, settings.cors_max_age)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -1,129 +0,0 @@
|
||||
"""Integration tests for the CORS configuration in :mod:`tavolo.app`.
|
||||
|
||||
The mixin under test is kaya-cors' :class:`~kaya.cors.CorsMixin`; these
|
||||
tests only verify that :func:`tavolo.app.cors_mixin_from_settings` maps the
|
||||
environment-driven :class:`~tavolo.config.Settings` onto it correctly. A
|
||||
minimal ``KayaApp`` is used instead of the global ``app`` so the tests do
|
||||
not depend on the environment the suite was imported with.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from kaya.core import HttpContext, KayaApp
|
||||
from pwo import async_test
|
||||
|
||||
from tavolo.app import cors_mixin_from_settings
|
||||
from tavolo.config import Settings
|
||||
|
||||
ORIGIN = "https://cards.example"
|
||||
|
||||
|
||||
def _settings(env: dict) -> Settings:
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
return Settings.from_env()
|
||||
|
||||
|
||||
def _app(settings: Settings) -> KayaApp:
|
||||
mixin = cors_mixin_from_settings(settings)
|
||||
assert mixin is not None
|
||||
app = KayaApp(mixins=[mixin])
|
||||
|
||||
@app.GET("/api/health")
|
||||
async def health(ctx: HttpContext) -> None:
|
||||
await ctx.send_str(200, "ok")
|
||||
|
||||
return app
|
||||
|
||||
|
||||
class CorsMixinFromSettingsTests(unittest.TestCase):
|
||||
def test_disabled_when_unconfigured(self):
|
||||
self.assertIsNone(cors_mixin_from_settings(_settings({})))
|
||||
|
||||
def test_enabled_by_allow_origins(self):
|
||||
self.assertIsNotNone(cors_mixin_from_settings(
|
||||
_settings({"CORS_ALLOW_ORIGINS": ORIGIN})))
|
||||
|
||||
def test_enabled_by_allow_origin_regex_alone(self):
|
||||
self.assertIsNotNone(cors_mixin_from_settings(
|
||||
_settings({"CORS_ALLOW_ORIGIN_REGEX": r"https://.*\.example\.com"})))
|
||||
|
||||
|
||||
class CorsBehaviorTests(unittest.TestCase):
|
||||
@async_test
|
||||
async def test_request_without_origin_is_untouched(self) -> None:
|
||||
app = _app(_settings({"CORS_ALLOW_ORIGINS": ORIGIN}))
|
||||
async with AsyncClient(transport=ASGITransport(app=app),
|
||||
base_url="http://127.0.0.1") as client:
|
||||
response = await client.get("/api/health")
|
||||
self.assertEqual(200, response.status_code)
|
||||
self.assertNotIn("access-control-allow-origin", response.headers)
|
||||
|
||||
@async_test
|
||||
async def test_simple_request_with_allowed_origin(self) -> None:
|
||||
app = _app(_settings({"CORS_ALLOW_ORIGINS": ORIGIN}))
|
||||
async with AsyncClient(transport=ASGITransport(app=app),
|
||||
base_url="http://127.0.0.1") as client:
|
||||
response = await client.get("/api/health", headers={"Origin": ORIGIN})
|
||||
self.assertEqual(200, response.status_code)
|
||||
self.assertEqual(ORIGIN, response.headers["access-control-allow-origin"])
|
||||
|
||||
@async_test
|
||||
async def test_simple_request_with_disallowed_origin(self) -> None:
|
||||
app = _app(_settings({"CORS_ALLOW_ORIGINS": ORIGIN}))
|
||||
async with AsyncClient(transport=ASGITransport(app=app),
|
||||
base_url="http://127.0.0.1") as client:
|
||||
response = await client.get(
|
||||
"/api/health", headers={"Origin": "https://mallory.example"})
|
||||
self.assertEqual(200, response.status_code)
|
||||
self.assertNotIn("access-control-allow-origin", response.headers)
|
||||
|
||||
@async_test
|
||||
async def test_preflight_allowed(self) -> None:
|
||||
app = _app(_settings({
|
||||
"CORS_ALLOW_ORIGINS": ORIGIN,
|
||||
"CORS_ALLOW_METHODS": "GET,POST",
|
||||
"CORS_MAX_AGE": "3600",
|
||||
}))
|
||||
async with AsyncClient(transport=ASGITransport(app=app),
|
||||
base_url="http://127.0.0.1") as client:
|
||||
response = await client.options("/api/health", headers={
|
||||
"Origin": ORIGIN,
|
||||
"Access-Control-Request-Method": "POST",
|
||||
})
|
||||
self.assertEqual(200, response.status_code)
|
||||
self.assertEqual(ORIGIN, response.headers["access-control-allow-origin"])
|
||||
self.assertEqual("GET, POST", response.headers["access-control-allow-methods"])
|
||||
self.assertEqual("3600", response.headers["access-control-max-age"])
|
||||
|
||||
@async_test
|
||||
async def test_preflight_disallowed_origin(self) -> None:
|
||||
app = _app(_settings({"CORS_ALLOW_ORIGINS": ORIGIN}))
|
||||
async with AsyncClient(transport=ASGITransport(app=app),
|
||||
base_url="http://127.0.0.1") as client:
|
||||
response = await client.options("/api/health", headers={
|
||||
"Origin": "https://mallory.example",
|
||||
"Access-Control-Request-Method": "GET",
|
||||
})
|
||||
self.assertEqual(400, response.status_code)
|
||||
self.assertIn("Disallowed CORS", response.text)
|
||||
|
||||
@async_test
|
||||
async def test_credentials_echo_origin_and_set_flag(self) -> None:
|
||||
app = _app(_settings({
|
||||
"CORS_ALLOW_ORIGINS": "*",
|
||||
"CORS_ALLOW_CREDENTIALS": "true",
|
||||
}))
|
||||
async with AsyncClient(transport=ASGITransport(app=app),
|
||||
base_url="http://127.0.0.1") as client:
|
||||
response = await client.get("/api/health", headers={"Origin": ORIGIN})
|
||||
self.assertEqual(200, response.status_code)
|
||||
self.assertEqual(ORIGIN, response.headers["access-control-allow-origin"])
|
||||
self.assertEqual("true", response.headers["access-control-allow-credentials"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+18
-207
@@ -1,7 +1,4 @@
|
||||
//! Live game page: table view over the websocket.
|
||||
use std::cell::Cell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use sycamore::prelude::*;
|
||||
|
||||
use crate::components::card::{card_back, card_img};
|
||||
@@ -73,143 +70,6 @@ fn move_banner(mv: MoveView) -> View {
|
||||
}
|
||||
}
|
||||
|
||||
/// Signals shared by the websocket connection and its reconnect attempts.
|
||||
#[derive(Clone, Copy)]
|
||||
struct ConnCtx {
|
||||
socket: Signal<Option<GameSocket>>,
|
||||
game: Signal<Option<GameView>>,
|
||||
over: Signal<Option<(Scores, Option<String>)>>,
|
||||
error: Signal<Option<String>>,
|
||||
closed: Signal<bool>,
|
||||
/// Reconnect attempts exhausted; only a manual retry resumes.
|
||||
gave_up: Signal<bool>,
|
||||
/// The server closed the connection deliberately (auth or game gone);
|
||||
/// retrying is pointless.
|
||||
fatal: Signal<bool>,
|
||||
attempts: Signal<u32>,
|
||||
capture_choice: Signal<Option<(String, Vec<Vec<String>>)>>,
|
||||
selected: Signal<Option<String>>,
|
||||
}
|
||||
|
||||
/// Reconnect attempts: 1s, 2s, 4s, … capped at 30s, at most this many.
|
||||
const MAX_RECONNECT_ATTEMPTS: u32 = 10;
|
||||
|
||||
fn backoff_ms(attempt: u32) -> u32 {
|
||||
(1000u32 << attempt.min(5)).min(30_000)
|
||||
}
|
||||
|
||||
/// Connect the game websocket, wiring state updates and reconnects.
|
||||
///
|
||||
/// The server pushes a full state snapshot on connect, so a reconnect is
|
||||
/// also a resync: no client-side state merging is needed.
|
||||
fn start_connect(id: Rc<String>, ctx: ConnCtx, alive: Rc<Cell<bool>>) {
|
||||
let on_message = {
|
||||
let alive = alive.clone();
|
||||
move |msg: ServerMessage| {
|
||||
if !alive.get() {
|
||||
// The page is unmounted; its signals are disposed.
|
||||
return;
|
||||
}
|
||||
match msg {
|
||||
ServerMessage::State { game: g } => {
|
||||
ctx.capture_choice.set(None);
|
||||
ctx.selected.set(None);
|
||||
// A received state proves the (re)connection works.
|
||||
ctx.attempts.set(0);
|
||||
ctx.gave_up.set(false);
|
||||
ctx.closed.set(false);
|
||||
ctx.game.set(Some(g));
|
||||
}
|
||||
ServerMessage::GameOver { scores, winner } => {
|
||||
ctx.over.set(Some((scores, winner)))
|
||||
}
|
||||
ServerMessage::Error { message, .. } => ctx.error.set(Some(message)),
|
||||
}
|
||||
}
|
||||
};
|
||||
let on_close = {
|
||||
let id = id.clone();
|
||||
let alive = alive.clone();
|
||||
move |code: Option<u16>| {
|
||||
if !alive.get() {
|
||||
return;
|
||||
}
|
||||
ctx.closed.set(true);
|
||||
match code {
|
||||
Some(4401) => {
|
||||
ctx.fatal.set(true);
|
||||
ctx.error
|
||||
.set(Some("Session expired — please log in again.".to_string()));
|
||||
}
|
||||
Some(4403) | Some(4404) => {
|
||||
ctx.fatal.set(true);
|
||||
ctx.error
|
||||
.set(Some("This game is no longer available.".to_string()));
|
||||
}
|
||||
_ => schedule_retry(id.clone(), ctx, alive.clone()),
|
||||
}
|
||||
}
|
||||
};
|
||||
match ws::connect(&id, on_message, on_close) {
|
||||
Some(s) => ctx.socket.set(Some(s)),
|
||||
// WebSocket::open failed synchronously: treat as a transient loss.
|
||||
None if alive.get() => {
|
||||
ctx.closed.set(true);
|
||||
schedule_retry(id, ctx, alive);
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Retry `start_connect` with exponential backoff, unless we gave up.
|
||||
fn schedule_retry(id: Rc<String>, ctx: ConnCtx, alive: Rc<Cell<bool>>) {
|
||||
let attempt = ctx.attempts.get();
|
||||
if attempt >= MAX_RECONNECT_ATTEMPTS {
|
||||
ctx.gave_up.set(true);
|
||||
return;
|
||||
}
|
||||
ctx.attempts.set(attempt + 1);
|
||||
gloo_timers::callback::Timeout::new(backoff_ms(attempt), move || {
|
||||
if alive.get() {
|
||||
start_connect(id, ctx, alive);
|
||||
}
|
||||
})
|
||||
.forget();
|
||||
}
|
||||
|
||||
/// Slim banner shown over the table while the socket is down.
|
||||
fn conn_banner(
|
||||
closed: bool,
|
||||
gave_up: bool,
|
||||
fatal: bool,
|
||||
has_game: bool,
|
||||
reconnect: Rc<dyn Fn()>,
|
||||
) -> View {
|
||||
if !closed || !has_game {
|
||||
return view! {};
|
||||
}
|
||||
if fatal {
|
||||
view! {
|
||||
div(class="conn-banner") {
|
||||
"Connection closed by the server. "
|
||||
a(href="/") { "Back to lobby" }
|
||||
}
|
||||
}
|
||||
} else if gave_up {
|
||||
view! {
|
||||
div(class="conn-banner") {
|
||||
"Connection lost."
|
||||
button(class="button", on:click=move |_| reconnect()) { "Retry now" }
|
||||
a(href="/") { "Back to lobby" }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
view! {
|
||||
div(class="conn-banner") { "Connection lost — reconnecting…" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[component(inline_props)]
|
||||
pub fn GamePage(id: String) -> View {
|
||||
let game = create_signal(Option::<GameView>::None);
|
||||
@@ -218,51 +78,32 @@ pub fn GamePage(id: String) -> View {
|
||||
let selected = create_signal(Option::<String>::None);
|
||||
let over = create_signal(Option::<(Scores, Option<String>)>::None);
|
||||
let closed = create_signal(false);
|
||||
let gave_up = create_signal(false);
|
||||
let fatal = create_signal(false);
|
||||
let attempts = create_signal(0u32);
|
||||
let socket = create_signal(Option::<GameSocket>::None);
|
||||
// Ticking clock driving the hand-end countdown display.
|
||||
let now = create_signal(js_sys::Date::now());
|
||||
let ticker = gloo_timers::callback::Interval::new(500, move || now.set(js_sys::Date::now()));
|
||||
gloo_timers::callback::Interval::new(500, move || now.set(js_sys::Date::now())).forget();
|
||||
|
||||
// Stops the ticker and any pending reconnect once the page unmounts.
|
||||
let alive = Rc::new(Cell::new(true));
|
||||
on_cleanup({
|
||||
let alive = alive.clone();
|
||||
move || {
|
||||
alive.set(false);
|
||||
drop(ticker);
|
||||
{
|
||||
let on_message = move |msg: ServerMessage| match msg {
|
||||
ServerMessage::State { game: g } => {
|
||||
capture_choice.set(None);
|
||||
selected.set(None);
|
||||
game.set(Some(g));
|
||||
}
|
||||
});
|
||||
|
||||
let id = Rc::new(id);
|
||||
let ctx = ConnCtx {
|
||||
socket,
|
||||
game,
|
||||
over,
|
||||
error,
|
||||
closed,
|
||||
gave_up,
|
||||
fatal,
|
||||
attempts,
|
||||
capture_choice,
|
||||
selected,
|
||||
ServerMessage::GameOver { scores, winner } => {
|
||||
over.set(Some((scores, winner)));
|
||||
}
|
||||
ServerMessage::Error { message, .. } => error.set(Some(message)),
|
||||
};
|
||||
start_connect(id.clone(), ctx, alive.clone());
|
||||
let reconnect: Rc<dyn Fn()> = Rc::new(move || {
|
||||
ctx.attempts.set(0);
|
||||
ctx.gave_up.set(false);
|
||||
ctx.closed.set(false);
|
||||
start_connect(id.clone(), ctx, alive.clone());
|
||||
});
|
||||
let on_close = move || closed.set(true);
|
||||
match ws::connect(&id, on_message, on_close) {
|
||||
Some(s) => socket.set(Some(s)),
|
||||
None => error.set(Some("Could not connect to the game".to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
// Clicking a card in the player's own hand.
|
||||
let on_hand_card = move |code: String| {
|
||||
if closed.get() {
|
||||
// A dead socket would swallow the play silently.
|
||||
return;
|
||||
}
|
||||
let Some(g) = game.get_clone() else { return };
|
||||
if g.your_turn != Some(true) {
|
||||
return;
|
||||
@@ -281,42 +122,13 @@ pub fn GamePage(id: String) -> View {
|
||||
}
|
||||
};
|
||||
|
||||
let reconnect_banner = reconnect.clone();
|
||||
view! {
|
||||
div(class="game-page") {
|
||||
(toast(error))
|
||||
(move || conn_banner(
|
||||
closed.get(),
|
||||
gave_up.get(),
|
||||
fatal.get(),
|
||||
game.get_clone().is_some(),
|
||||
reconnect_banner.clone(),
|
||||
))
|
||||
(move || match game.get_clone() {
|
||||
None => {
|
||||
if fatal.get() {
|
||||
view! {
|
||||
div(class="panel status-panel") {
|
||||
p { "Connection closed." }
|
||||
p { a(href="/") { "Back to lobby" } }
|
||||
}
|
||||
}
|
||||
} else if gave_up.get() {
|
||||
let reconnect = reconnect.clone();
|
||||
view! {
|
||||
div(class="panel status-panel") {
|
||||
p { "Connection lost." }
|
||||
p {
|
||||
button(class="button primary", on:click=move |_| reconnect()) {
|
||||
"Retry now"
|
||||
}
|
||||
}
|
||||
p { a(href="/") { "Back to lobby" } }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let status = if closed.get() {
|
||||
"Connection lost — reconnecting…"
|
||||
"Connection closed."
|
||||
} else {
|
||||
"Connecting to the game…"
|
||||
};
|
||||
@@ -327,7 +139,6 @@ pub fn GamePage(id: String) -> View {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(g) if g.phase == "lobby" => lobby_view(g),
|
||||
Some(g) => table_view(g, on_hand_card, selected, now),
|
||||
})
|
||||
|
||||
+3
-12
@@ -4,7 +4,7 @@ use std::rc::Rc;
|
||||
|
||||
use futures::channel::mpsc;
|
||||
use futures::{SinkExt, StreamExt};
|
||||
use gloo_net::websocket::{futures::WebSocket, Message, WebSocketError};
|
||||
use gloo_net::websocket::{futures::WebSocket, Message};
|
||||
use wasm_bindgen_futures::spawn_local;
|
||||
|
||||
use crate::model::ServerMessage;
|
||||
@@ -53,14 +53,10 @@ impl GameSocket {
|
||||
/// Open the websocket for `game_id` and forward parsed server messages to
|
||||
/// `on_message`. Returns the socket handle, or `None` if the connection
|
||||
/// could not be created.
|
||||
///
|
||||
/// `on_close` fires exactly once when the connection ends; it receives the
|
||||
/// server close code when one was sent (e.g. 4401 unauthenticated, 4403 not
|
||||
/// seated, 4404 unknown game) or `None` for an abnormal network loss.
|
||||
pub fn connect(
|
||||
game_id: &str,
|
||||
on_message: impl Fn(ServerMessage) + 'static,
|
||||
on_close: impl Fn(Option<u16>) + 'static,
|
||||
on_close: impl Fn() + 'static,
|
||||
) -> Option<GameSocket> {
|
||||
let ws = WebSocket::open(&ws_url(game_id)).ok()?;
|
||||
let (mut write, mut read) = ws.split();
|
||||
@@ -76,7 +72,6 @@ pub fn connect(
|
||||
});
|
||||
|
||||
spawn_local(async move {
|
||||
let mut close_code = None;
|
||||
while let Some(msg) = read.next().await {
|
||||
match msg {
|
||||
Ok(Message::Text(text)) => {
|
||||
@@ -85,14 +80,10 @@ pub fn connect(
|
||||
}
|
||||
}
|
||||
Ok(Message::Bytes(_)) => {}
|
||||
Err(WebSocketError::ConnectionClose(e)) => {
|
||||
close_code = Some(e.code);
|
||||
break;
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
on_close(close_code);
|
||||
on_close();
|
||||
});
|
||||
|
||||
Some(GameSocket {
|
||||
|
||||
@@ -418,27 +418,6 @@ table.matches td.lost {
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
|
||||
/* ---------- connection banner ---------- */
|
||||
|
||||
.conn-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.75rem;
|
||||
background: rgba(232, 197, 71, 0.15);
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: 8px;
|
||||
color: var(--accent);
|
||||
padding: 0.4rem 1rem;
|
||||
margin: 0.5rem auto 0;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.conn-banner a {
|
||||
color: var(--accent);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* ---------- overlays ---------- */
|
||||
|
||||
.overlay {
|
||||
|
||||
Reference in New Issue
Block a user