Move nickname/position state server-side per account
This commit is contained in:
@@ -31,6 +31,8 @@ class AuthUser:
|
||||
status: str
|
||||
email: str | None
|
||||
last_nickname: str | None
|
||||
last_x: int | None
|
||||
last_y: int | None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -115,8 +117,8 @@ class AuthService:
|
||||
self._conn.execute(
|
||||
"""
|
||||
INSERT INTO users (
|
||||
username, password_hash, email, role, status, last_nickname, created_at_ms, updated_at_ms, last_login_at_ms
|
||||
) VALUES (?, ?, ?, ?, 'active', NULL, ?, ?, ?)
|
||||
username, password_hash, email, role, status, created_at_ms, updated_at_ms, last_login_at_ms
|
||||
) VALUES (?, ?, ?, ?, 'active', ?, ?, ?)
|
||||
""",
|
||||
(normalized_username, password_hash, normalized_email, role, now_ms, now_ms, now_ms),
|
||||
)
|
||||
@@ -131,6 +133,24 @@ class AuthService:
|
||||
user = self._get_user_by_username(normalized_username)
|
||||
if user is None:
|
||||
raise AuthError("Failed to load newly created user.")
|
||||
self._conn.execute(
|
||||
"""
|
||||
INSERT OR IGNORE INTO user_state (user_id, last_nickname, last_x, last_y, updated_at_ms)
|
||||
VALUES (?, ?, NULL, NULL, ?)
|
||||
""",
|
||||
(int(user.id), user.username, now_ms),
|
||||
)
|
||||
self._conn.commit()
|
||||
user = AuthUser(
|
||||
id=user.id,
|
||||
username=user.username,
|
||||
role=user.role,
|
||||
status=user.status,
|
||||
email=user.email,
|
||||
last_nickname=user.username,
|
||||
last_x=user.last_x,
|
||||
last_y=user.last_y,
|
||||
)
|
||||
return self._create_session(user)
|
||||
|
||||
def login(self, username: str, password: str) -> AuthSession:
|
||||
@@ -139,9 +159,19 @@ class AuthService:
|
||||
normalized_username = self._normalize_username(username)
|
||||
user_row = self._conn.execute(
|
||||
"""
|
||||
SELECT id, username, password_hash, email, role, status, last_nickname
|
||||
FROM users
|
||||
WHERE username = ?
|
||||
SELECT
|
||||
u.id,
|
||||
u.username,
|
||||
u.password_hash,
|
||||
u.email,
|
||||
u.role,
|
||||
u.status,
|
||||
us.last_nickname,
|
||||
us.last_x,
|
||||
us.last_y
|
||||
FROM users u
|
||||
LEFT JOIN user_state us ON us.user_id = u.id
|
||||
WHERE u.username = ?
|
||||
""",
|
||||
(normalized_username,),
|
||||
).fetchone()
|
||||
@@ -161,6 +191,8 @@ class AuthService:
|
||||
status=user.status,
|
||||
email=user.email,
|
||||
last_nickname=user.username,
|
||||
last_x=user.last_x,
|
||||
last_y=user.last_y,
|
||||
)
|
||||
self._conn.execute(
|
||||
"UPDATE users SET last_login_at_ms = ?, updated_at_ms = ? WHERE id = ?",
|
||||
@@ -179,9 +211,10 @@ class AuthService:
|
||||
row = self._conn.execute(
|
||||
"""
|
||||
SELECT s.id AS session_id, s.user_id, s.expires_at_ms, s.revoked_at_ms,
|
||||
u.username, u.role, u.status, u.email, u.last_nickname
|
||||
u.username, u.role, u.status, u.email, us.last_nickname, us.last_x, us.last_y
|
||||
FROM sessions s
|
||||
JOIN users u ON u.id = s.user_id
|
||||
LEFT JOIN user_state us ON us.user_id = u.id
|
||||
WHERE s.token_hash = ?
|
||||
""",
|
||||
(token_hash,),
|
||||
@@ -210,6 +243,8 @@ class AuthService:
|
||||
status=row["status"],
|
||||
email=row["email"],
|
||||
last_nickname=row["last_nickname"],
|
||||
last_x=row["last_x"] if "last_x" in row.keys() else None,
|
||||
last_y=row["last_y"] if "last_y" in row.keys() else None,
|
||||
)
|
||||
if not user.last_nickname:
|
||||
self.set_last_nickname(user.id, user.username)
|
||||
@@ -220,6 +255,8 @@ class AuthService:
|
||||
status=user.status,
|
||||
email=user.email,
|
||||
last_nickname=user.username,
|
||||
last_x=user.last_x,
|
||||
last_y=user.last_y,
|
||||
)
|
||||
return AuthSession(session_id=row["session_id"], token=cleaned, user=user)
|
||||
|
||||
@@ -242,11 +279,47 @@ class AuthService:
|
||||
cleaned = nickname.strip()
|
||||
if not cleaned:
|
||||
return
|
||||
self._conn.execute(
|
||||
"UPDATE users SET last_nickname = ?, updated_at_ms = ? WHERE id = ?",
|
||||
(cleaned, self.now_ms(), user_id),
|
||||
)
|
||||
self._conn.commit()
|
||||
try:
|
||||
user_id_value = int(user_id)
|
||||
except (TypeError, ValueError):
|
||||
return
|
||||
try:
|
||||
self._conn.execute(
|
||||
"""
|
||||
INSERT INTO user_state (user_id, last_nickname, last_x, last_y, updated_at_ms)
|
||||
VALUES (?, ?, NULL, NULL, ?)
|
||||
ON CONFLICT(user_id) DO UPDATE SET
|
||||
last_nickname = excluded.last_nickname,
|
||||
updated_at_ms = excluded.updated_at_ms
|
||||
""",
|
||||
(user_id_value, cleaned, self.now_ms()),
|
||||
)
|
||||
self._conn.commit()
|
||||
except sqlite3.IntegrityError:
|
||||
self._conn.rollback()
|
||||
|
||||
def set_last_position(self, user_id: str, x: int, y: int) -> None:
|
||||
"""Persist last known world position for one user."""
|
||||
|
||||
try:
|
||||
user_id_value = int(user_id)
|
||||
except (TypeError, ValueError):
|
||||
return
|
||||
try:
|
||||
self._conn.execute(
|
||||
"""
|
||||
INSERT INTO user_state (user_id, last_nickname, last_x, last_y, updated_at_ms)
|
||||
VALUES (?, NULL, ?, ?, ?)
|
||||
ON CONFLICT(user_id) DO UPDATE SET
|
||||
last_x = excluded.last_x,
|
||||
last_y = excluded.last_y,
|
||||
updated_at_ms = excluded.updated_at_ms
|
||||
""",
|
||||
(user_id_value, int(x), int(y), self.now_ms()),
|
||||
)
|
||||
self._conn.commit()
|
||||
except sqlite3.IntegrityError:
|
||||
self._conn.rollback()
|
||||
|
||||
@staticmethod
|
||||
def now_ms() -> int:
|
||||
@@ -267,7 +340,6 @@ class AuthService:
|
||||
email TEXT UNIQUE,
|
||||
role TEXT NOT NULL CHECK(role IN ('user', 'admin')) DEFAULT 'user',
|
||||
status TEXT NOT NULL CHECK(status IN ('active', 'disabled')) DEFAULT 'active',
|
||||
last_nickname TEXT,
|
||||
created_at_ms INTEGER NOT NULL,
|
||||
updated_at_ms INTEGER NOT NULL,
|
||||
last_login_at_ms INTEGER
|
||||
@@ -290,6 +362,18 @@ class AuthService:
|
||||
)
|
||||
"""
|
||||
)
|
||||
self._conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS user_state (
|
||||
user_id INTEGER PRIMARY KEY,
|
||||
last_nickname TEXT,
|
||||
last_x INTEGER,
|
||||
last_y INTEGER,
|
||||
updated_at_ms INTEGER NOT NULL,
|
||||
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)
|
||||
"""
|
||||
)
|
||||
self._conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_users_username ON users(username)")
|
||||
self._conn.execute(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email ON users(email) WHERE email IS NOT NULL"
|
||||
@@ -297,6 +381,7 @@ class AuthService:
|
||||
self._conn.execute("CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id)")
|
||||
self._conn.execute("CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions(expires_at_ms)")
|
||||
self._conn.execute("CREATE INDEX IF NOT EXISTS idx_sessions_token_hash ON sessions(token_hash)")
|
||||
self._conn.execute("CREATE INDEX IF NOT EXISTS idx_user_state_updated ON user_state(updated_at_ms)")
|
||||
self._conn.commit()
|
||||
|
||||
def _create_session(self, user: AuthUser) -> AuthSession:
|
||||
@@ -321,7 +406,20 @@ class AuthService:
|
||||
"""Fetch one user by normalized username."""
|
||||
|
||||
row = self._conn.execute(
|
||||
"SELECT id, username, role, status, email, last_nickname FROM users WHERE username = ?",
|
||||
"""
|
||||
SELECT
|
||||
u.id,
|
||||
u.username,
|
||||
u.role,
|
||||
u.status,
|
||||
u.email,
|
||||
us.last_nickname,
|
||||
us.last_x,
|
||||
us.last_y
|
||||
FROM users u
|
||||
LEFT JOIN user_state us ON us.user_id = u.id
|
||||
WHERE u.username = ?
|
||||
""",
|
||||
(username,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
@@ -338,7 +436,9 @@ class AuthService:
|
||||
role=row["role"],
|
||||
status=row["status"],
|
||||
email=row["email"],
|
||||
last_nickname=row["last_nickname"],
|
||||
last_nickname=row["last_nickname"] if "last_nickname" in row.keys() else None,
|
||||
last_x=row["last_x"] if "last_x" in row.keys() else None,
|
||||
last_y=row["last_y"] if "last_y" in row.keys() else None,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -19,6 +19,8 @@ class ClientConnection:
|
||||
role: str = "user"
|
||||
session_token: str | None = None
|
||||
nickname: str = "user..."
|
||||
saved_x: int | None = None
|
||||
saved_y: int | None = None
|
||||
x: int = 20
|
||||
y: int = 20
|
||||
last_position_update_ms: int = 0
|
||||
|
||||
@@ -88,6 +88,7 @@ PIANO_RECORDING_MAX_MS = 30_000
|
||||
PIANO_RECORDING_MAX_EVENTS = 4096
|
||||
MOVEMENT_TICK_MS = 200
|
||||
MOVEMENT_MAX_STEPS_PER_TICK = 1
|
||||
POSITION_PERSIST_DEBOUNCE_MS = 5_000
|
||||
|
||||
|
||||
class SignalingServer:
|
||||
@@ -141,6 +142,7 @@ class SignalingServer:
|
||||
self.state_save_max_delay_ms = max(self.state_save_debounce_ms, int(state_save_max_delay_ms))
|
||||
self._pending_state_save_handle: asyncio.TimerHandle | None = None
|
||||
self._pending_state_save_started_at: float | None = None
|
||||
self._last_position_persist_ms_by_user: dict[str, int] = {}
|
||||
|
||||
@staticmethod
|
||||
def _resolve_server_version() -> str:
|
||||
@@ -177,6 +179,19 @@ class SignalingServer:
|
||||
|
||||
return nickname.casefold()
|
||||
|
||||
def _persist_client_position(self, client: ClientConnection, *, force: bool = False) -> None:
|
||||
"""Persist one authenticated client's last known position with debounce."""
|
||||
|
||||
if not client.user_id:
|
||||
return
|
||||
now_ms = self.item_service.now_ms()
|
||||
if not force:
|
||||
last_saved_ms = self._last_position_persist_ms_by_user.get(client.user_id, 0)
|
||||
if now_ms - last_saved_ms < POSITION_PERSIST_DEBOUNCE_MS:
|
||||
return
|
||||
self.auth_service.set_last_position(client.user_id, client.x, client.y)
|
||||
self._last_position_persist_ms_by_user[client.user_id] = now_ms
|
||||
|
||||
def _auth_policy(self) -> dict[str, int]:
|
||||
"""Return server-auth policy limits advertised to clients."""
|
||||
|
||||
@@ -770,6 +785,9 @@ class SignalingServer:
|
||||
if websocket in self.clients:
|
||||
disconnected = self.clients.pop(websocket)
|
||||
self.active_piano_keys_by_client.pop(disconnected.id, None)
|
||||
self._persist_client_position(disconnected, force=True)
|
||||
if disconnected.user_id:
|
||||
self._last_position_persist_ms_by_user.pop(disconnected.user_id, None)
|
||||
for item_id, session in list(self.piano_recording_state_by_item.items()):
|
||||
if session.get("ownerClientId") != disconnected.id:
|
||||
continue
|
||||
@@ -829,8 +847,14 @@ class SignalingServer:
|
||||
|
||||
if client.websocket in self.clients:
|
||||
return
|
||||
client.x = random.randrange(self.grid_size)
|
||||
client.y = random.randrange(self.grid_size)
|
||||
saved_x = getattr(client, "saved_x", None)
|
||||
saved_y = getattr(client, "saved_y", None)
|
||||
if isinstance(saved_x, int) and isinstance(saved_y, int) and self._is_in_bounds(saved_x, saved_y):
|
||||
client.x = saved_x
|
||||
client.y = saved_y
|
||||
else:
|
||||
client.x = random.randrange(self.grid_size)
|
||||
client.y = random.randrange(self.grid_size)
|
||||
now_ms = self.item_service.now_ms()
|
||||
client.last_position_update_ms = now_ms
|
||||
client.movement_window_index = self._movement_window_index(now_ms)
|
||||
@@ -910,6 +934,8 @@ class SignalingServer:
|
||||
client.role = session.user.role
|
||||
client.session_token = session.token
|
||||
client.nickname = session.user.last_nickname or client.nickname
|
||||
client.saved_x = session.user.last_x
|
||||
client.saved_y = session.user.last_y
|
||||
await self._send(
|
||||
client.websocket,
|
||||
AuthResultPacket(
|
||||
@@ -1036,6 +1062,7 @@ class SignalingServer:
|
||||
client.x = packet.x
|
||||
client.y = packet.y
|
||||
client.last_position_update_ms = now_ms
|
||||
self._persist_client_position(client)
|
||||
await self._send(
|
||||
client.websocket,
|
||||
BroadcastPositionPacket(type="update_position", id=client.id, x=client.x, y=client.y),
|
||||
@@ -1070,6 +1097,7 @@ class SignalingServer:
|
||||
client.x = packet.x
|
||||
client.y = packet.y
|
||||
client.last_position_update_ms = self.item_service.now_ms()
|
||||
self._persist_client_position(client, force=True)
|
||||
await self._send(
|
||||
client.websocket,
|
||||
BroadcastPositionPacket(type="update_position", id=client.id, x=client.x, y=client.y),
|
||||
|
||||
Reference in New Issue
Block a user