Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ jobs:
cd src
uv run manage.py makemigrations --check
uv run manage.py migrate
uv run manage.py test tests/
uv run manage.py test

docker:
needs: [ lint, unit_test ]
Expand Down
12 changes: 5 additions & 7 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -13,22 +13,20 @@ repos:
args: ["--allow-multiple-documents"]
- id: debug-statements
- id: trailing-whitespace
exclude: >-
^.*.md$
exclude: ^.*.md$

- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.7.0
hooks:
- id: ruff
args: [ --fix ]
args: [--fix]
- id: ruff-format

# local mypy because of stub dependencies
- repo: local
hooks:
- id: typecheck
name: Typecheck
entry: mypy .
types: [python]
name: Typecheck (uv)
entry: uv run mypy
language: system
pass_filenames: false
args: ["."]
8 changes: 7 additions & 1 deletion dev-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,15 @@ services:
ports:
- "5432:5432"

cache:
image: memcached:latest
ports:
- "11211:11211"

web:
depends_on:
- db
- db
- cache
build: .
env_file: ./.env
ports:
Expand Down
6 changes: 6 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,13 @@ services:
volumes:
- db-data:/var/lib/postgresql/data

cache:
image: memcached:latest

web:
depends_on:
- db
- cache
image: unitystation/central-command:latest
environment:
- DEBUG=0
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ dependencies = [
"psycopg2-binary~=2.9.9",
"python-dotenv~=0.19.2",
"whitenoise~=6.6.0",
"pymemcache>=4.0,<5.0",
]

[build-system]
Expand Down
2 changes: 1 addition & 1 deletion src/accounts/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ def validate(self, data):

if not account_confirmation.is_token_valid():
raise serializers.ValidationError({"token": "Token is invalid or expired."})
return {"token": data["token"]}
return {"token": data["token"], "account_confirmation": account_confirmation}


class EmailSerializer(serializers.Serializer):
Expand Down
2 changes: 1 addition & 1 deletion src/accounts/api/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -324,7 +324,7 @@ def post(self, request):
if not serializer.is_valid():
return ErrorResponse(serializer.errors, status.HTTP_400_BAD_REQUEST)

account_confirmation = AccountConfirmation.objects.get(token=serializer.validated_data["token"])
account_confirmation: AccountConfirmation = serializer.validated_data["account_confirmation"]
account = account_confirmation.account

account.is_confirmed = True
Expand Down
Empty file added src/baby_serverlist/__init__.py
Empty file.
10 changes: 10 additions & 0 deletions src/baby_serverlist/admin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
from django.contrib import admin

from .models import BabyServer


@admin.register(BabyServer)
class BabyServerAdmin(admin.ModelAdmin):
list_display = ("id", "owner", "whitelisted", "serverlist_token")
search_fields = ("id", "owner__email", "owner__unique_identifier")
list_filter = ("whitelisted",)
31 changes: 31 additions & 0 deletions src/baby_serverlist/api/serializers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
from rest_framework import serializers


class ServerStatusSerializer(serializers.Serializer):
ServerToken = serializers.CharField()
Passworded = serializers.BooleanField()
ServerName = serializers.CharField()
ForkName = serializers.CharField()
BuildVersion = serializers.IntegerField()
CurrentMap = serializers.CharField()
GameMode = serializers.CharField()
IngameTime = serializers.CharField()
RoundTime = serializers.CharField()
PlayerCount = serializers.IntegerField()
PlayerCountMax = serializers.IntegerField()
ServerIP = serializers.CharField()
ServerPort = serializers.IntegerField()
WinDownload = serializers.CharField()
OSXDownload = serializers.CharField()
LinuxDownload = serializers.CharField()
fps = serializers.IntegerField()
GoodFileVersion = serializers.CharField()


class ActiveServersSerializer(serializers.Serializer):
CashDateTime = serializers.DateTimeField()
servers = ServerStatusSerializer(many=True)


class RegenerateServerlistTokenSerializer(serializers.Serializer):
server_id = serializers.UUIDField()
18 changes: 18 additions & 0 deletions src/baby_serverlist/api/urls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
from django.urls import path

from .views import (
CreateBabyServerView,
ListBabyServersView,
ListOwnedBabyServersView,
PostServerStatusView,
RegenerateServerlistTokenView,
)

app_name = "baby_serverlist"
urlpatterns = [
path("status/", PostServerStatusView.as_view(), name="report-status"),
path("servers/create/", CreateBabyServerView.as_view(), name="create"),
path("servers/owned/", ListOwnedBabyServersView.as_view(), name="list-owned"),
path("servers/", ListBabyServersView.as_view(), name="list"),
path("servers/regenerate-token/", RegenerateServerlistTokenView.as_view(), name="regenerate-token"),
]
154 changes: 154 additions & 0 deletions src/baby_serverlist/api/views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import logging

from datetime import UTC, datetime
from typing import cast

from django.core import signing
from rest_framework import status
from rest_framework.generics import GenericAPIView, ListAPIView
from rest_framework.permissions import AllowAny
from rest_framework.response import Response

from accounts.models import Account
from baby_serverlist.models import SERVERLIST_TOKEN_SALT, BabyServer
from commons.cache import (
get_many_baby_server_statuses,
set_baby_server_heartbeat,
set_baby_server_status,
)
from commons.error_response import ErrorResponse

from .serializers import RegenerateServerlistTokenSerializer, ServerStatusSerializer

logger = logging.getLogger(__name__)


class PostServerStatusView(GenericAPIView):
"""Accepts signed status payloads from baby servers and stores the latest state in cache."""

serializer_class = ServerStatusSerializer
permission_classes = (AllowAny,)

def post(self, request, *args, **kwargs):
serializer = self.get_serializer(data=request.data)

if not serializer.is_valid():
return ErrorResponse(serializer.errors, status.HTTP_400_BAD_REQUEST)

status_payload = serializer.validated_data
server_token = status_payload.get("ServerToken")

try:
payload = signing.loads(server_token, salt=SERVERLIST_TOKEN_SALT)
except signing.BadSignature:
return ErrorResponse("Invalid or expired token", status.HTTP_400_BAD_REQUEST)

try:
baby_server = BabyServer.objects.get(id=payload.get("server_id"), serverlist_token=server_token)
except BabyServer.DoesNotExist:
return ErrorResponse("Invalid or expired token", status.HTTP_400_BAD_REQUEST)

server_id = str(baby_server.id)
status_without_token = {key: value for key, value in status_payload.items() if key != "ServerToken"}

set_baby_server_status(server_id, status_without_token)
set_baby_server_heartbeat(server_id, datetime.now(tz=UTC).isoformat())

logger.debug("Received server status update for server %s: %s", baby_server.id, status_without_token)

return Response(status=status.HTTP_200_OK)


class CreateBabyServerView(GenericAPIView):
"""Creates a new baby server for the authenticated user and returns the freshly minted token."""

queryset = BabyServer.objects.all()

def post(self, request, *args, **kwargs):
user = cast(Account, request.user)

baby_server = BabyServer.objects.create(owner=user)

return Response(
{
"id": str(baby_server.id),
"serverlist_token": baby_server.serverlist_token,
"whitelisted": baby_server.whitelisted,
},
status=status.HTTP_201_CREATED,
)


class ListOwnedBabyServersView(ListAPIView):
"""Lists the caller's baby servers with a derived `live` flag based on recent heartbeats."""

def get_queryset(self):
user = cast(Account, self.request.user)
return BabyServer.objects.filter(owner=user).only("id", "whitelisted")

def list(self, request, *args, **kwargs):
queryset = self.get_queryset()

data = [
{
"id": str(server.id),
"whitelisted": server.whitelisted,
"live": server.is_live(),
}
for server in queryset
]
return Response(data, status=status.HTTP_200_OK)


class ListBabyServersView(ListAPIView):
"""Return cached status payloads for all baby servers that have reported recently."""

permission_classes = (AllowAny,)

def list(self, request, *args, **kwargs):
servers = BabyServer.objects.filter(whitelisted=True)
server_ids = [str(server.id) for server in servers]
status_map = get_many_baby_server_statuses(server_ids)

data = [
status_map[server_id] for server_id in sorted(status_map.keys()) if isinstance(status_map[server_id], dict)
]

return Response({"servers": data}, status=status.HTTP_200_OK)


class RegenerateServerlistTokenView(GenericAPIView):
"""Regenerates a server's signed token after validating ownership."""

serializer_class = RegenerateServerlistTokenSerializer
queryset = BabyServer.objects.all()

def post(self, request, *args, **kwargs):
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)

server_id = serializer.validated_data["server_id"]

try:
baby_server: BabyServer = BabyServer.objects.get(id=server_id)
except BabyServer.DoesNotExist:
return ErrorResponse("Baby server not found", status.HTTP_404_NOT_FOUND)

user = cast(Account, request.user)

if baby_server.owner != user:
return ErrorResponse(
"You do not have permission to modify this baby server",
status.HTTP_403_FORBIDDEN,
)

baby_server.serverlist_token = baby_server.generate_serverlist_token()
baby_server.save(update_fields=["serverlist_token"])

return Response(
{
"id": str(baby_server.id),
"serverlist_token": baby_server.serverlist_token,
},
status=status.HTTP_200_OK,
)
6 changes: 6 additions & 0 deletions src/baby_serverlist/apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
from django.apps import AppConfig


class BabyServerlistConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "baby_serverlist"
27 changes: 27 additions & 0 deletions src/baby_serverlist/migrations/0001_initial.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Generated by Django 3.2.25 on 2025-11-01 05:11

from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import uuid


class Migration(migrations.Migration):

initial = True

dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]

operations = [
migrations.CreateModel(
name='BabyServer',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('serverlist_token', models.TextField(editable=False, unique=True)),
('whitelisted', models.BooleanField(default=False)),
('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='baby_servers', to=settings.AUTH_USER_MODEL)),
],
),
]
Empty file.
53 changes: 53 additions & 0 deletions src/baby_serverlist/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
from datetime import UTC, datetime, timedelta
from secrets import token_urlsafe
from uuid import uuid4

from django.core import signing
from django.db import models

from accounts.models import Account
from commons.cache import get_baby_server_heartbeat

SERVERLIST_TOKEN_SALT = "baby_serverlist.serverlist_token"


class BabyServer(models.Model):
id = models.UUIDField(primary_key=True, default=uuid4, editable=False)
serverlist_token = models.TextField(unique=True, editable=False)
whitelisted = models.BooleanField(default=False)
owner = models.ForeignKey(
Account,
on_delete=models.CASCADE,
related_name="baby_servers",
)
objects = models.Manager()

def __str__(self) -> str:
return f"BabyServer(id={self.id}, owner={self.owner.unique_identifier})"

def save(self, *args, **kwargs):
if not self.serverlist_token:
self.serverlist_token = self.generate_serverlist_token()
super().save(*args, **kwargs)

def generate_serverlist_token(self) -> str:
"""Create a signed token that uniquely identifies this server and can be validated by clients."""
payload = {
"server_id": str(self.id),
"owner_id": str(self.owner.unique_identifier),
"nonce": token_urlsafe(16),
}
return signing.dumps(payload, salt=SERVERLIST_TOKEN_SALT)

def is_live(self) -> bool:
"""Return True when the server has reported within the last 12 seconds."""
heartbeat_iso = get_baby_server_heartbeat(str(self.id))
if not heartbeat_iso:
return False
try:
heartbeat_time = datetime.fromisoformat(heartbeat_iso)
except ValueError:
return False
if heartbeat_time.tzinfo is None:
heartbeat_time = heartbeat_time.replace(tzinfo=UTC)
return datetime.now(tz=UTC) - heartbeat_time <= timedelta(seconds=12)
Loading
Loading