Initial commit
Build and Push Docker Image / build-and-push (push) Successful in 18s

This commit is contained in:
ciomek committed 2026-08-16 11:41:07 +02:00
commit 279b2a7e90
49 files changed
+2664

No files matched your search

+3
View File
@@ -0,0 +1,3 @@
import logging
logger = logging.getLogger("loon")
View File
Whitespace-only changes.
View File
Whitespace-only changes.
@@ -0,0 +1,35 @@
import json
from sqlmodel import Session, select
from loon.web.db import engine
from loon.web.users.models import User
from loon.web.utils.password import hash_password
async def change_password_handler(client, userdata, msg):
data = json.loads(msg.payload.decode())
if msg.topic != "loon/register/change_password":
return
uuid = data["uuid"]
password = data["password"]
with Session(engine) as session:
statement = select(User).where(User.uuid == uuid)
user = session.exec(statement).first()
if user is None:
client.publish(f"loon/register/{uuid}/response",
json.dumps({"success": False, "error": "The user is not registered!"}))
return
with Session(engine) as session:
user = session.exec(
select(User).where(User.uuid == uuid)
).first()
user.password = hash_password(password)
session.commit()
client.publish(f"loon/register/{uuid}/response", json.dumps({"success": True, "error": None}))
+105
View File
@@ -0,0 +1,105 @@
import json
import random
from loon.web.users.state import user_threads
PLAYER_EVENT_SUFFIXES = ("died", "joined", "left")
DEATH_BY_DAMAGE = {
"fall": (
"{username} fell from a high place",
"{username} forgot to tie their shoes",
),
"fire": (
"{username} forgot fire burns",
"{username} was engulfed in flames",
),
"drown": (
"{username} drowned",
),
"blast": (
"{username} blew up",
),
"combat": (
"{username} was slain",
"{username} was pummeled to death",
),
"generic": (
"{username} died",
"{username} withered away",
),
}
DEATH_BY_KILLER = {
"fall": (
"{username} was doomed to fall by {killer}",
),
"fire": (
"{username} walked into fire whilst fighting {killer}",
),
"drown": (
"{username} drowned whilst trying to escape {killer}",
),
"blast": (
"{username} was blown up by {killer}",
),
"combat": (
"{username} was slain by {killer}",
"{username} was killed by {killer}",
"{username} was slain by {killer} using magic",
"{username} got finished off by {killer}",
),
"generic": (
"{username} was slain by {killer}",
"{username} was killed by {killer}",
),
}
DEATH_BY_KILLER_DEFAULT = DEATH_BY_KILLER["combat"]
def get_random(templates, **kwargs):
return random.choice(templates).format(**kwargs)
async def marquee_handler(client, userdata, msg):
prefix = "loon/player/"
if not msg.topic.startswith(prefix):
return
rest = msg.topic[len(prefix):]
_, _, subtopic = rest.partition("/")
if subtopic not in PLAYER_EVENT_SUFFIXES:
return
try:
data = json.loads(msg.payload.decode())
except json.JSONDecodeError:
return
username = data["player"]["username"]
if subtopic == "died":
killer = data.get("killer")
damage_type = data.get("damageType")
if killer:
templates = DEATH_BY_KILLER.get(damage_type, DEATH_BY_KILLER_DEFAULT)
message = get_random(templates, username=username, killer=killer)
elif damage_type in DEATH_BY_DAMAGE:
message = get_random(DEATH_BY_DAMAGE[damage_type], username=username)
elif damage_type:
message = f"{username} died of {damage_type}"
else:
message = f"{username} died"
elif subtopic == "joined":
message = f"{username} joined the game"
else:
message = f"{username} left the game"
envelope = json.dumps({"topic": "server/marquee", "payload": {"message": message}})
for queue in list(user_threads.values()):
await queue.put(envelope)
+19
View File
@@ -0,0 +1,19 @@
import json
from loon.web.users.state import user_threads
async def player_handler(client, userdata, msg):
data = json.loads(msg.payload.decode())
prefix = "loon/player/"
if not msg.topic.startswith(prefix):
return
rest = msg.topic[len(prefix):]
player_uuid, _, subtopic = rest.partition("/")
queue = user_threads.get(player_uuid)
if queue is not None:
await queue.put(json.dumps({"topic": subtopic, "payload": data}))
+34
View File
@@ -0,0 +1,34 @@
import json
from sqlmodel import Session, select
from loon.web.db import engine
from loon.web.users.models import User
from loon.web.utils.password import hash_password
async def register_request_handler(client, userdata, msg):
data = json.loads(msg.payload.decode())
if msg.topic != "loon/register/request":
return
uuid = data["uuid"]
password = data["password"]
internal_username = data["internalUsername"]
with Session(engine) as session:
statement = select(User).where(User.uuid == uuid)
user = session.exec(statement).first()
if user is not None:
client.publish(f"loon/register/{uuid}/response",
json.dumps({"success": False, "error": "The user is already registered!"}))
return
with Session(engine) as session:
user = User(uuid=uuid, password=hash_password(password), internal_username=internal_username, admin=False)
session.add(user)
session.commit()
client.publish(f"loon/register/{uuid}/response", json.dumps({"success": True, "error": None}))
+18
View File
@@ -0,0 +1,18 @@
import json
from loon.web.users.state import user_threads
async def server_handler(client, userdata, msg):
if msg.topic != "loon/server/info":
return
try:
data = json.loads(msg.payload.decode())
except json.JSONDecodeError:
return
envelope = json.dumps({"topic": "server/info", "payload": data})
for queue in list(user_threads.values()):
await queue.put(envelope)
+34
View File
@@ -0,0 +1,34 @@
import json
import re
import redis
from config import settings
from loon.redis.chunk import set_chunk_cache
from loon.web.users.state import user_threads
async def world_handler(client, userdata, msg):
chunk_topic = re.compile(r"^loon/world/chunk/(-?\d+)/(-?\d+)$")
match = chunk_topic.match(msg.topic)
if not match:
return
try:
data = json.loads(msg.payload.decode())
except json.JSONDecodeError:
return
x, z = match.group(1), match.group(2)
envelope = json.dumps({"topic": f"world/chunk/{x}/{z}", "payload": data})
if settings.redis.enabled:
try:
set_chunk_cache(int(x), int(z), data)
except redis.RedisError:
pass
for queue in list(user_threads.values()):
await queue.put(envelope)
+74
View File
@@ -0,0 +1,74 @@
import asyncio
import json
import threading
import paho.mqtt.client as mqtt
from config import settings
from loon import logger
from loon.mqtt.handlers.change_password_request import change_password_handler
from loon.mqtt.handlers.marquee import marquee_handler
from loon.mqtt.handlers.player import player_handler
from loon.mqtt.handlers.register_request import register_request_handler
from loon.mqtt.handlers.server import server_handler
from loon.mqtt.handlers.world import world_handler
class MQTTManager:
BASE_HANDLERS = [player_handler, register_request_handler, world_handler, server_handler, marquee_handler, change_password_handler]
def __init__(self, inject_handlers = None):
if inject_handlers is None:
inject_handlers = []
self.client = mqtt.Client()
self.thread = None
self.loop = asyncio.get_running_loop()
self.handlers = self.BASE_HANDLERS + inject_handlers
@staticmethod
def setup_and_start():
mqtt_client = MQTTManager()
mqtt_client.connect()
mqtt_client.start()
return mqtt_client
def connect(self):
self.client.on_connect = self.on_connect
self.client.on_disconnect = self.on_disconnect
self.client.on_message = self.on_topic
self.client.username_pw_set(username=settings.mqtt.username, password=settings.mqtt.password)
self.client.reconnect_delay_set(min_delay=1, max_delay=60)
self.client.connect_async(settings.mqtt.host, settings.mqtt.port)
def start(self):
self.thread = threading.Thread(target=self.client.loop_forever, daemon=True)
self.thread.start()
return self.thread
@staticmethod
def on_connect(client, userdata, flags, reason_code):
logger.info(f"MQTT connected (rc={reason_code})")
client.subscribe("loon/#")
@staticmethod
def on_disconnect(client, userdata, reason_code):
logger.warning(f"MQTT disconnected (rc={reason_code}). Reconnecting...")
def on_topic(self, client, userdata, msg):
async def handle_message():
try:
data = json.loads(msg.payload.decode())
except json.decoder.JSONDecodeError:
return
logger.info(f"[Received] {data}.")
for handler in self.handlers:
await handler(client, userdata, msg)
asyncio.run_coroutine_threadsafe(handle_message(), self.loop)
def publish(self, topic: str, payload: mqtt.PayloadType = None, qos: int = 0, retain: bool = False) -> mqtt.MQTTMessageInfo:
return self.client.publish(topic, payload, qos, retain)
+24
View File
@@ -0,0 +1,24 @@
import redis
from redis.backoff import ExponentialBackoff
from redis.exceptions import ConnectionError, TimeoutError
from redis.retry import Retry
from config import settings
r: redis.Redis | None = None
if not settings.redis.enabled:
r = None
else:
r = redis.Redis(
host=settings.redis.host,
port=settings.redis.port,
password=settings.redis.password,
decode_responses=True,
socket_connect_timeout=2,
socket_timeout=2,
socket_keepalive=True,
health_check_interval=30,
retry_on_timeout=True,
retry=Retry(ExponentialBackoff(cap=10, base=0.5), retries=0),
retry_on_error=[ConnectionError, TimeoutError],
)
+19
View File
@@ -0,0 +1,19 @@
import json
from config import settings
from loon.redis import r
def set_chunk_cache(x: int, z: int, chunk_payload):
r.setex(f'{x}:{z}', settings.redis.expire_time, str(json.dumps(chunk_payload)))
def get_chunk_cache(x: int, z: int):
chunk = r.get(f'{x}:{z}')
if chunk:
return json.loads(chunk)
return None
def reset_chunk_cache(x: int, z: int):
return r.delete(f'{x}:{z}')
+47
View File
@@ -0,0 +1,47 @@
from contextlib import asynccontextmanager
from fastapi import FastAPI
from starlette.middleware.authentication import AuthenticationMiddleware
from starlette.middleware.cors import CORSMiddleware
from config import settings
from loon.mqtt.manager import MQTTManager
from loon.web.auth.middleware import JWTAuthBackend, on_auth_error
@asynccontextmanager
async def lifespan(app: FastAPI):
app.state.mqtt_manager = MQTTManager.setup_and_start()
yield
app = FastAPI(lifespan=lifespan)
app.add_middleware(
AuthenticationMiddleware,
backend=JWTAuthBackend(),
on_error=on_auth_error,
)
app.add_middleware(
CORSMiddleware,
allow_origins=settings.web.allowed_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
def get_mqtt_manager() -> MQTTManager:
return app.state.mqtt_manager
from loon.web.auth.router import router as auth_router
from loon.web.users.router import router as users_router
from loon.web.world.router import router as world_router
from loon.web.server.router import router as server_router
from loon.web.admin.router import router as admin_router
app.include_router(auth_router)
app.include_router(users_router)
app.include_router(world_router)
app.include_router(server_router)
app.include_router(admin_router)
View File
Whitespace-only changes.
+25
View File
@@ -0,0 +1,25 @@
from sqlmodel import Session, select
from loon.web.db import engine
from loon.web.users.models import User
def choose_admin(username: str) -> None:
with Session(engine) as session:
user = session.exec(
select(User).where(User.internal_username == username)
).first()
if user is None:
print(f"No user named {username!r}.")
return
if user.admin:
print(f"{username} is already admin.")
return
user.admin = True
session.add(user)
session.commit()
print(f"Promoted {username} to admin.")
+7
View File
@@ -0,0 +1,7 @@
from fastapi import APIRouter
from starlette.requests import Request
from loon.web.auth.middleware import admin
router = APIRouter(prefix="/admin", tags=["admin"])
View File
Whitespace-only changes.
+72
View File
@@ -0,0 +1,72 @@
import jwt
from starlette.authentication import (
AuthCredentials,
AuthenticationBackend,
AuthenticationError,
BaseUser,
requires as _requires,
)
from starlette.requests import HTTPConnection
from starlette.responses import JSONResponse, Response
from sqlmodel import Session, select
from loon.web.db import engine
from loon.web.users.models import User
from loon.web.utils.jwt import JWT_SECRET, JWT_ALGORITHM
authenticated = _requires("authenticated", status_code=401)
admin = _requires(["authenticated", "admin"], status_code=403)
class AuthenticatedUser(BaseUser):
def __init__(self, user: User) -> None:
self.user = user
@property
def is_authenticated(self) -> bool:
return True
class JWTAuthBackend(AuthenticationBackend):
def _extract_token(self, conn: HTTPConnection) -> str | None:
auth_header = conn.headers.get("Authorization")
if auth_header is not None:
scheme, _, credentials = auth_header.partition(" ")
if scheme.lower() == "bearer" and credentials:
return credentials
# Browser WebSocket APIs cannot set Authorization headers.
token = conn.query_params.get("token")
return token or None
async def authenticate(
self, conn: HTTPConnection
) -> tuple[AuthCredentials, AuthenticatedUser] | None:
credentials = self._extract_token(conn)
if credentials is None:
return None
try:
payload = jwt.decode(credentials, JWT_SECRET, algorithms=[JWT_ALGORITHM])
except jwt.InvalidTokenError:
raise AuthenticationError("Invalid or expired token")
user_id = payload.get("user_id")
if not isinstance(user_id, int):
raise AuthenticationError("Invalid or expired token")
with Session(engine) as session:
user = session.exec(select(User).where(User.id == user_id)).first()
if user is None:
raise AuthenticationError("User not found")
scopes = ["authenticated"]
if user.admin:
scopes.append("admin")
return AuthCredentials(scopes), AuthenticatedUser(user)
def on_auth_error(conn: HTTPConnection, exc: Exception) -> Response:
return JSONResponse({"detail": str(exc)}, status_code=401)
+26
View File
@@ -0,0 +1,26 @@
from fastapi import APIRouter, HTTPException, status
from sqlmodel import Session, select
from loon.web.auth.schema import JWTResponse, JWTRequest
from loon.web.db import engine
from loon.web.users.models import User
from loon.web.utils.jwt import generate_token
from loon.web.utils.password import verify_password
router = APIRouter(prefix="/auth", tags=["auth"])
@router.post("/jwt/", response_model=JWTResponse)
async def get_jwt(body: JWTRequest) -> JWTResponse:
with Session(engine) as session:
statement = select(User).where(User.internal_username == body.username)
user = session.exec(statement).first()
if user is None or not verify_password(body.password, user.password):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid username or password",
)
assert user.id is not None
return JWTResponse(access_token=generate_token(user.id))
+11
View File
@@ -0,0 +1,11 @@
from pydantic import BaseModel
class JWTRequest(BaseModel):
username: str
password: str
class JWTResponse(BaseModel):
access_token: str
token_type: str = "bearer"
+24
View File
@@ -0,0 +1,24 @@
from sqlmodel import SQLModel, create_engine
from config import settings
def database_url(url: str | None = None) -> str:
url = url or settings.database.url
for src, dst in (
("mysql://", "mysql+pymysql://"),
("postgres://", "postgresql+psycopg://"),
("postgresql://", "postgresql+psycopg://"),
):
if url.startswith(src):
return dst + url.removeprefix(src)
return url
url = database_url()
kwargs: dict = {}
if url.startswith("sqlite"):
kwargs["connect_args"] = {"check_same_thread": False}
engine = create_engine(url, echo=True, **kwargs)
SQLModel.metadata.create_all(engine)
View File
Whitespace-only changes.
+13
View File
@@ -0,0 +1,13 @@
from fastapi import APIRouter, Request
from loon.web import get_mqtt_manager
from loon.web.auth.middleware import authenticated
router = APIRouter(prefix="/server", tags=["server"])
@router.post("/info/request")
@authenticated
async def request_server_info(request: Request):
get_mqtt_manager().publish("loon/server/info/request")
return 200
View File
Whitespace-only changes.
+9
View File
@@ -0,0 +1,9 @@
from sqlmodel import SQLModel, Field
class User(SQLModel, table=True):
id: int = Field(default=None, primary_key=True)
uuid: str = Field(unique=True)
internal_username: str
password: str
admin: bool = Field(default=False)
+60
View File
@@ -0,0 +1,60 @@
import asyncio
import threading
from fastapi import APIRouter, Request
from starlette.websockets import WebSocket, WebSocketDisconnect
from loon.web import get_mqtt_manager
from loon.web.auth.middleware import authenticated
from loon.web.users.state import user_threads
router = APIRouter(prefix="/users", tags=["users"])
@router.get("/me")
@authenticated
async def me(request: Request):
return request.user.user.model_dump(exclude={"password", "id"})
@router.post("/inventory/request")
@authenticated
async def request_inventory(request: Request):
user = request.user.user
get_mqtt_manager().publish(f"loon/player/{user.uuid}/inventory/full/request")
return 200
@router.post("/online/request")
@authenticated
async def request_online(request: Request):
user = request.user.user
get_mqtt_manager().publish(f"loon/player/{user.uuid}/online/request")
return 200
@router.post("/position/request")
@authenticated
async def request_position(request: Request):
user = request.user.user
get_mqtt_manager().publish(f"loon/player/{user.uuid}/position/request")
return 200
@router.websocket("/me")
@authenticated
async def websocket_endpoint(websocket: WebSocket):
await websocket.accept()
user = websocket.user.user
queue = asyncio.Queue()
user_threads[user.uuid] = queue
try:
while True:
message = await queue.get()
await websocket.send_text(message)
except WebSocketDisconnect:
pass
finally:
user_threads.pop(user.uuid, None)
+1
View File
@@ -0,0 +1 @@
user_threads = {}
View File
Whitespace-only changes.
+20
View File
@@ -0,0 +1,20 @@
import time
import jwt
from config import settings
JWT_SECRET = settings.jwt.secret
JWT_ALGORITHM = "HS256"
def generate_token(user_id: int) -> str:
payload = {
"user_id": user_id,
"exp": int(time.time()) + 60 * 60 * 24,
}
return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM)
def decode_token(token: str) -> dict:
payload = jwt.decode(token, JWT_SECRET)
return payload
+19
View File
@@ -0,0 +1,19 @@
from argon2 import PasswordHasher
from argon2.exceptions import InvalidHashError, VerifyMismatchError
def hash_password(password: str) -> str:
ph = PasswordHasher()
hashed = ph.hash(password)
return str(hashed)
def verify_password(password: str, hashed_password: str) -> bool:
ph = PasswordHasher()
try:
return ph.verify(hashed_password, password)
except (VerifyMismatchError, InvalidHashError):
return False
View File
Whitespace-only changes.
+80
View File
@@ -0,0 +1,80 @@
import json
import redis
from fastapi import APIRouter, Request
from config import settings
from loon.redis.chunk import get_chunk_cache, reset_chunk_cache
from loon.web import get_mqtt_manager
from loon.web.auth.middleware import authenticated
from loon.web.users.state import user_threads
router = APIRouter(prefix="/world", tags=["world"])
@router.get("/request")
@authenticated
async def request_world(
request: Request,
x_start: int,
x_end: int,
z_start: int,
z_end: int,
):
if not settings.redis.enabled:
get_mqtt_manager().publish(f"loon/world/chunks/{x_start}:{x_end}/{z_start}:{z_end}/request")
return 200
misses = []
thread = user_threads.get(request.user.user.uuid)
try:
for x in range(x_start, x_end + 1):
for z in range(z_start, z_end + 1):
cached_chunk = get_chunk_cache(x, z)
if cached_chunk is not None:
if thread:
await thread.put(
json.dumps({"topic": f"world/chunk/{x}/{z}", "payload": cached_chunk})
)
else:
misses.append((x, z))
except redis.RedisError as e:
get_mqtt_manager().publish(f"loon/world/chunks/{x_start}:{x_end}/{z_start}:{z_end}/request")
return 200
for xa, xb, za, zb in miss_row_rects(misses):
get_mqtt_manager().publish(f"loon/world/chunks/{xa}:{xb}/{za}:{zb}/request")
return 200
def miss_row_rects(misses: list):
rows = {}
for x, z in misses:
if z not in rows:
rows[z] = []
rows[z].append(x)
rects = []
for z, xs in rows.items():
xs.sort()
start = xs[0]
end = xs[0]
for x in xs[1:]:
if x == end + 1:
end = x
else:
rects.append((start, end, z, z))
start = x
end = x
rects.append((start, end, z, z))
return rects