Compare commits

..
5 Commits
Author SHA1 Message Date
ciomek 655fb89a7f Users
Build and Push Docker Image / build-and-push (push) Successful in 27s
2026-09-04 17:52:08 +02:00
ciomek 632d990c85 Player format 2026-09-04 17:47:36 +02:00
ciomek 3dcd4173c2 Fixes 2026-09-04 17:41:24 +02:00
ciomek dbed39ee3a MC Users 2026-09-04 17:31:30 +02:00
ciomek 5732e26380 Logs 2026-09-04 17:02:25 +02:00
20 changed files with 348 additions and 59 deletions

No files matched your search

+67
View File
@@ -0,0 +1,67 @@
"""init
Revision ID: 0f809a005dc6
Revises:
Create Date: 2026-09-04 17:30:49.572225
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import sqlmodel
# revision identifiers, used by Alembic.
revision: str = '0f809a005dc6'
down_revision: Union[str, Sequence[str], None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('logtype',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('code', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('code')
)
op.create_table('minecraftuser',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('uuid', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('username', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('uuid')
)
op.create_table('logentry',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('log_type_id', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('message', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.ForeignKeyConstraint(['log_type_id'], ['logtype.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('user',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('minecraft_user_id', sa.Integer(), nullable=False),
sa.Column('internal_username', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('password', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('admin', sa.Boolean(), nullable=False),
sa.ForeignKeyConstraint(['minecraft_user_id'], ['minecraftuser.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('internal_username'),
sa.UniqueConstraint('minecraft_user_id')
)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('user')
op.drop_table('logentry')
op.drop_table('minecraftuser')
op.drop_table('logtype')
# ### end Alembic commands ###
-42
View File
@@ -1,42 +0,0 @@
"""Users
Revision ID: 2df7e2c6af43
Revises:
Create Date: 2026-08-17 12:42:05.103510
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import sqlmodel
# revision identifiers, used by Alembic.
revision: str = '2df7e2c6af43'
down_revision: Union[str, Sequence[str], None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('user',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('uuid', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('internal_username', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('password', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('admin', sa.Boolean(), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('internal_username'),
sa.UniqueConstraint('uuid')
)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('user')
# ### end Alembic commands ###
@@ -4,7 +4,9 @@ from sqlalchemy.exc import IntegrityError
from sqlmodel import Session, select from sqlmodel import Session, select
from loon.web.db import engine from loon.web.db import engine
from loon.web.users.models import User from loon.web.logs.service import write_log, player_tag
from loon.web.logs.types import LogTypeEnum
from loon.web.users.models import MinecraftUser, User
async def change_password_handler(client, userdata, msg, data): async def change_password_handler(client, userdata, msg, data):
@@ -22,7 +24,7 @@ async def change_password_handler(client, userdata, msg, data):
password = data["password"] password = data["password"]
with Session(engine) as session: with Session(engine) as session:
statement = select(User).where(User.uuid == uuid) statement = select(User).join(MinecraftUser).where(MinecraftUser.uuid == uuid)
user = session.exec(statement).first() user = session.exec(statement).first()
if user is None: if user is None:
@@ -31,9 +33,10 @@ async def change_password_handler(client, userdata, msg, data):
return return
user = session.exec( user = session.exec(
select(User).where(User.uuid == uuid) select(User).join(MinecraftUser).where(MinecraftUser.uuid == uuid)
).first() ).first()
user.password = password user.password = password
internal_username = user.internal_username
try: try:
session.commit() session.commit()
@@ -44,4 +47,6 @@ async def change_password_handler(client, userdata, msg, data):
return return
await write_log(LogTypeEnum.PLAYER_PASSWORD_CHANGED, f"Player {player_tag(uuid, internal_username)} changed their password.")
client.publish(f"loon/auth/change_password/{uuid}/response", json.dumps({"success": True, "error": None})) client.publish(f"loon/auth/change_password/{uuid}/response", json.dumps({"success": True, "error": None}))
+30 -2
View File
@@ -1,10 +1,14 @@
import json import json
import random import random
from loon.web.logs.service import write_log, player_tag
from loon.web.logs.types import LogTypeEnum
from loon.web.users.state import user_threads from loon.web.users.state import user_threads
PLAYER_EVENT_SUFFIXES = ("died", "joined", "left", "kicked") PLAYER_EVENT_SUFFIXES = ("died", "joined", "left", "kicked")
BAN_KICK_REASON = "Banned by admin"
DEATH_BY_DAMAGE = { DEATH_BY_DAMAGE = {
"fall": ( "fall": (
"{username} fell from a high place", "{username} fell from a high place",
@@ -78,7 +82,8 @@ async def marquee_handler(client, userdata, msg, data):
if not isinstance(player, dict): if not isinstance(player, dict):
return return
username = player.get("username") username = player.get("username")
if not username: uuid = player.get("uuid")
if not username or not uuid:
return return
if subtopic == "died": if subtopic == "died":
@@ -88,20 +93,43 @@ async def marquee_handler(client, userdata, msg, data):
if killer: if killer:
templates = DEATH_BY_KILLER.get(damage_type, DEATH_BY_KILLER_DEFAULT) templates = DEATH_BY_KILLER.get(damage_type, DEATH_BY_KILLER_DEFAULT)
message = get_random(templates, username=username, killer=killer) message = get_random(templates, username=username, killer=killer)
await write_log(LogTypeEnum.PLAYER_DEAD, f"Player {player_tag(uuid, username)} was killed by {killer}.")
elif damage_type in DEATH_BY_DAMAGE: elif damage_type in DEATH_BY_DAMAGE:
message = get_random(DEATH_BY_DAMAGE[damage_type], username=username) message = get_random(DEATH_BY_DAMAGE[damage_type], username=username)
await write_log(LogTypeEnum.PLAYER_DEAD, f"Player {player_tag(uuid, username)} died of {damage_type}.")
elif damage_type: elif damage_type:
message = f"{username} died of {damage_type}" message = f"{username} died of {damage_type}"
await write_log(LogTypeEnum.PLAYER_DEAD, f"Player {player_tag(uuid, username)} died of {damage_type}.")
else: else:
message = f"{username} died" message = f"{username} died"
await write_log(LogTypeEnum.PLAYER_DEAD, f"Player {player_tag(uuid, username)} died.")
elif subtopic == "joined": elif subtopic == "joined":
message = f"{username} joined the game" message = f"{username} joined the game"
await write_log(LogTypeEnum.PLAYER_JOINED, f"Player {player_tag(uuid, username)} joined the game.")
elif subtopic == "kicked": elif subtopic == "kicked":
reason = data.get("reason") reason = data.get("reason")
message = f"{username} was kicked: {reason}" if reason else f"{username} was kicked"
if reason == BAN_KICK_REASON:
message = f"{username} was banned"
await write_log(LogTypeEnum.PLAYER_BANNED, f"Player {player_tag(uuid, username)} was banned.")
else:
message = f"{username} was kicked: {reason}" if reason else f"{username} was kicked"
await write_log(
LogTypeEnum.PLAYER_KICKED,
f"Player {player_tag(uuid, username)} was kicked: {reason}." if reason else f"Player {player_tag(uuid, username)} was kicked.",
)
else: else:
message = f"{username} left the game" message = f"{username} left the game"
await write_log(LogTypeEnum.PLAYER_LEFT, f"Player {player_tag(uuid, username)} left the game.")
envelope = json.dumps({"topic": "server/marquee", "payload": {"message": message}}) envelope = json.dumps({"topic": "server/marquee", "payload": {"message": message}})
for queue in list(user_threads.values()): for queue in list(user_threads.values()):
+12
View File
@@ -0,0 +1,12 @@
from loon.web.users.service import sync_minecraft_users
async def player_joined_handler(client, userdata, msg, data):
if msg.topic != "loon/playerlist/joined":
return
player = data.get("player")
if not player:
return
await sync_minecraft_users([{"uuid": player["uuid"], "username": player["username"]}])
+8
View File
@@ -0,0 +1,8 @@
from loon.web.users.service import sync_minecraft_users
async def players_sync_handler(client, userdata, msg, data):
if msg.topic != "loon/playerlist/sync":
return
await sync_minecraft_users(data.get("players", []))
+14 -3
View File
@@ -4,7 +4,9 @@ from sqlalchemy.exc import IntegrityError
from sqlmodel import Session, select from sqlmodel import Session, select
from loon.web.db import engine from loon.web.db import engine
from loon.web.users.models import User from loon.web.logs.service import write_log, player_tag
from loon.web.logs.types import LogTypeEnum
from loon.web.users.models import MinecraftUser, User
async def register_request_handler(client, userdata, msg, data): async def register_request_handler(client, userdata, msg, data):
@@ -23,7 +25,14 @@ async def register_request_handler(client, userdata, msg, data):
internal_username = data["internalUsername"] internal_username = data["internalUsername"]
with Session(engine) as session: with Session(engine) as session:
statement = select(User).where(User.uuid == uuid) minecraft_user = session.exec(select(MinecraftUser).where(MinecraftUser.uuid == uuid)).first()
if minecraft_user is None:
client.publish(f"loon/auth/register/{uuid}/response",
json.dumps({"success": False, "error": "Player not recognized yet, please rejoin the server and try again."}))
return
statement = select(User).where(User.minecraft_user_id == minecraft_user.id)
user = session.exec(statement).first() user = session.exec(statement).first()
if user is not None: if user is not None:
@@ -39,7 +48,7 @@ async def register_request_handler(client, userdata, msg, data):
json.dumps({"success": False, "error": f"The {internal_username} is taken!"})) json.dumps({"success": False, "error": f"The {internal_username} is taken!"}))
return return
user = User(uuid=uuid, password=password, internal_username=internal_username, admin=False) user = User(minecraft_user_id=minecraft_user.id, password=password, internal_username=internal_username, admin=False)
session.add(user) session.add(user)
try: try:
session.commit() session.commit()
@@ -50,4 +59,6 @@ async def register_request_handler(client, userdata, msg, data):
return return
await write_log(LogTypeEnum.PLAYER_REGISTERED, f"Player {player_tag(uuid, internal_username)} registered.")
client.publish(f"loon/auth/register/{uuid}/response", json.dumps({"success": True, "error": None})) client.publish(f"loon/auth/register/{uuid}/response", json.dumps({"success": True, "error": None}))
+4 -2
View File
@@ -3,7 +3,7 @@ import json
from sqlmodel import Session, select from sqlmodel import Session, select
from loon.web.db import engine from loon.web.db import engine
from loon.web.users.models import User from loon.web.users.models import MinecraftUser, User
async def show_username_handler(client, userdata, msg, data): async def show_username_handler(client, userdata, msg, data):
@@ -19,7 +19,9 @@ async def show_username_handler(client, userdata, msg, data):
return return
with Session(engine) as session: with Session(engine) as session:
user = session.exec(select(User).where(User.uuid == uuid)).first() user = session.exec(
select(User).join(MinecraftUser).where(MinecraftUser.uuid == uuid)
).first()
if user is None: if user is None:
client.publish(f"loon/auth/show_username/{uuid}/response", client.publish(f"loon/auth/show_username/{uuid}/response",
+5 -1
View File
@@ -9,6 +9,8 @@ from loon import logger
from loon.mqtt.handlers.change_password_request import change_password_handler from loon.mqtt.handlers.change_password_request import change_password_handler
from loon.mqtt.handlers.marquee import marquee_handler from loon.mqtt.handlers.marquee import marquee_handler
from loon.mqtt.handlers.player import player_handler from loon.mqtt.handlers.player import player_handler
from loon.mqtt.handlers.player_joined import player_joined_handler
from loon.mqtt.handlers.players_sync import players_sync_handler
from loon.mqtt.handlers.register_request import register_request_handler from loon.mqtt.handlers.register_request import register_request_handler
from loon.mqtt.handlers.server import server_handler from loon.mqtt.handlers.server import server_handler
from loon.mqtt.handlers.show_username_request import show_username_handler from loon.mqtt.handlers.show_username_request import show_username_handler
@@ -16,7 +18,7 @@ from loon.mqtt.handlers.world import world_handler
class MQTTManager: class MQTTManager:
BASE_HANDLERS = [player_handler, register_request_handler, world_handler, server_handler, marquee_handler, change_password_handler, show_username_handler] BASE_HANDLERS = [player_handler, register_request_handler, world_handler, server_handler, marquee_handler, change_password_handler, show_username_handler, players_sync_handler, player_joined_handler]
def __init__(self, inject_handlers = None): def __init__(self, inject_handlers = None):
if inject_handlers is None: if inject_handlers is None:
@@ -53,6 +55,8 @@ class MQTTManager:
logger.info(f"MQTT connected (rc={reason_code})") logger.info(f"MQTT connected (rc={reason_code})")
client.subscribe("loon/#") client.subscribe("loon/#")
client.publish("loon/playerlist/sync/request")
@staticmethod @staticmethod
def on_disconnect(client, userdata, reason_code): def on_disconnect(client, userdata, reason_code):
logger.warning(f"MQTT disconnected (rc={reason_code}). Reconnecting...") logger.warning(f"MQTT disconnected (rc={reason_code}). Reconnecting...")
+47
View File
@@ -1,7 +1,54 @@
from fastapi import APIRouter from fastapi import APIRouter
from sqlalchemy.orm import selectinload
from sqlmodel import Session, select
from starlette.requests import Request from starlette.requests import Request
from loon.web.auth.middleware import admin from loon.web.auth.middleware import admin
from loon.web.db import engine
from loon.web.logs.models import LogEntry
from loon.web.logs.schema import LogEntryResponse
from loon.web.users.models import MinecraftUser, User
from loon.web.users.schema import MinecraftUserPublic, UserPublic
router = APIRouter(prefix="/admin", tags=["admin"]) router = APIRouter(prefix="/admin", tags=["admin"])
@router.get("/logs", response_model=list[LogEntryResponse])
@admin
async def list_logs(request: Request):
with Session(engine) as session:
statement = (
select(LogEntry)
.options(selectinload(LogEntry.log_type))
.order_by(LogEntry.created_at)
)
entries = session.exec(statement).all()
return [
LogEntryResponse(
id=entry.id,
log_type=entry.log_type.code,
created_at=entry.created_at,
message=entry.message,
)
for entry in entries
]
@router.get("/mc-users", response_model=list[MinecraftUserPublic])
@admin
async def list_mc_users(request: Request):
with Session(engine) as session:
statement = select(MinecraftUser).order_by(MinecraftUser.username)
return session.exec(statement).all()
@router.get("/users", response_model=list[UserPublic])
@admin
async def list_users(request: Request):
with Session(engine) as session:
statement = (
select(User)
.options(selectinload(User.minecraft_user))
.order_by(User.internal_username)
)
return session.exec(statement).all()
+4 -1
View File
@@ -8,6 +8,7 @@ from starlette.authentication import (
) )
from starlette.requests import HTTPConnection from starlette.requests import HTTPConnection
from starlette.responses import JSONResponse, Response from starlette.responses import JSONResponse, Response
from sqlalchemy.orm import selectinload
from sqlmodel import Session, select from sqlmodel import Session, select
from loon.web.db import engine from loon.web.db import engine
@@ -62,7 +63,9 @@ class JWTAuthBackend(AuthenticationBackend):
raise AuthenticationError("Invalid or expired token") raise AuthenticationError("Invalid or expired token")
with Session(engine) as session: with Session(engine) as session:
user = session.exec(select(User).where(User.id == user_id)).first() user = session.exec(
select(User).where(User.id == user_id).options(selectinload(User.minecraft_user))
).first()
if user is None: if user is None:
raise AuthenticationError("User not found") raise AuthenticationError("User not found")
View File
Whitespace-only changes.
+17
View File
@@ -0,0 +1,17 @@
from datetime import datetime, timezone
from sqlmodel import SQLModel, Field, Relationship
class LogType(SQLModel, table=True):
id: int = Field(default=None, primary_key=True)
code: str = Field(nullable=False, unique=True)
entries: list["LogEntry"] = Relationship(back_populates="log_type")
class LogEntry(SQLModel, table=True):
id: int = Field(default=None, primary_key=True)
log_type_id: int = Field(foreign_key="logtype.id", nullable=False)
log_type: LogType = Relationship(back_populates="entries")
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
message: str = Field(nullable=False)
+10
View File
@@ -0,0 +1,10 @@
from datetime import datetime
from pydantic import BaseModel
class LogEntryResponse(BaseModel):
id: int
log_type: str
created_at: datetime
message: str
+31
View File
@@ -0,0 +1,31 @@
import asyncio
from sqlmodel import Session, select
from loon.web.db import engine
from loon.web.logs.models import LogType, LogEntry
from loon.web.logs.types import LogTypeEnum
def player_tag(uuid: str, username: str) -> str:
return f"[player:{uuid}]{username}[/player]"
def _write_log(log_type: LogTypeEnum, message: str) -> None:
with Session(engine) as session:
statement = select(LogType).where(LogType.code == log_type.value)
log_type_object = session.exec(statement).first()
if not log_type_object:
log_type_object = LogType(code=log_type.value)
session.add(log_type_object)
session.commit()
session.refresh(log_type_object)
log_entry = LogEntry(log_type_id=log_type_object.id, message=message)
session.add(log_entry)
session.commit()
async def write_log(log_type: LogTypeEnum, message: str) -> None:
await asyncio.to_thread(_write_log, log_type, message)
+11
View File
@@ -0,0 +1,11 @@
from enum import Enum
class LogTypeEnum(Enum):
PLAYER_DEAD = "player_dead"
PLAYER_JOINED = "player_joined"
PLAYER_LEFT = "player_left"
PLAYER_KICKED = "player_kicked"
PLAYER_BANNED = "player_banned"
PLAYER_REGISTERED = "player_registered"
PLAYER_PASSWORD_CHANGED = "player_password_changed"
+16 -2
View File
@@ -1,9 +1,23 @@
from sqlmodel import SQLModel, Field from sqlmodel import SQLModel, Field, Relationship
class MinecraftUser(SQLModel, table=True):
id: int = Field(default=None, primary_key=True)
uuid: str = Field(unique=True)
username: str
user: "User" = Relationship(back_populates="minecraft_user")
class User(SQLModel, table=True): class User(SQLModel, table=True):
id: int = Field(default=None, primary_key=True) id: int = Field(default=None, primary_key=True)
uuid: str = Field(unique=True)
minecraft_user_id: int = Field(foreign_key="minecraftuser.id", nullable=False, unique=True)
minecraft_user: MinecraftUser = Relationship(back_populates="user")
internal_username: str = Field(unique=True) internal_username: str = Field(unique=True)
password: str password: str
admin: bool = Field(default=False) admin: bool = Field(default=False)
@property
def uuid(self) -> str:
return self.minecraft_user.uuid
+5 -3
View File
@@ -3,15 +3,17 @@ from starlette.websockets import WebSocket, WebSocketDisconnect
from loon.web import get_mqtt_manager from loon.web import get_mqtt_manager
from loon.web.auth.middleware import authenticated from loon.web.auth.middleware import authenticated
from loon.web.users.models import User
from loon.web.users.schema import UserPublic
from loon.web.users.state import subscribe_player, get_user_messages, unsubscribe_player from loon.web.users.state import subscribe_player, get_user_messages, unsubscribe_player
router = APIRouter(prefix="/users", tags=["users"]) router = APIRouter(prefix="/users", tags=["users"])
@router.get("/me") @router.get("/me", response_model=UserPublic)
@authenticated @authenticated
async def me(request: Request): async def me(request: Request) -> User:
return request.user.user.model_dump(exclude={"password", "id"}) return request.user.user
@router.post("/inventory/request") @router.post("/inventory/request")
@authenticated @authenticated
+16
View File
@@ -0,0 +1,16 @@
from pydantic import BaseModel, ConfigDict
class MinecraftUserPublic(BaseModel):
model_config = ConfigDict(from_attributes=True)
uuid: str
username: str
class UserPublic(BaseModel):
model_config = ConfigDict(from_attributes=True)
internal_username: str
admin: bool
minecraft_user: MinecraftUserPublic
+43
View File
@@ -0,0 +1,43 @@
import asyncio
from sqlmodel import Session, select
from loon.web.db import engine
from loon.web.users.models import MinecraftUser
def _sync_minecraft_users(players: list[dict]) -> list[MinecraftUser]:
players_by_uuid = {player["uuid"]: player["username"] for player in players}
with Session(engine) as session:
existing = session.exec(
select(MinecraftUser).where(MinecraftUser.uuid.in_(players_by_uuid))
).all()
existing_by_uuid = {minecraft_user.uuid: minecraft_user for minecraft_user in existing}
new_users = []
for uuid, username in players_by_uuid.items():
minecraft_user = existing_by_uuid.get(uuid)
if minecraft_user is None:
minecraft_user = MinecraftUser(uuid=uuid, username=username)
new_users.append(minecraft_user)
session.add(minecraft_user)
elif minecraft_user.username != username:
minecraft_user.username = username
session.add(minecraft_user)
session.commit()
minecraft_users = [*existing, *new_users]
for minecraft_user in minecraft_users:
session.refresh(minecraft_user)
return minecraft_users
async def sync_minecraft_users(players: list[dict]) -> list[MinecraftUser]:
if not players:
return []
return await asyncio.to_thread(_sync_minecraft_users, players)