From 514a6105898bc6d032ae13a27e820cf41feb0d0a Mon Sep 17 00:00:00 2001 From: 11EJDE11 Date: Mon, 6 Apr 2026 01:35:18 +1200 Subject: [PATCH 1/2] Force LF line endings on sh files --- .gitattributes | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..480e421 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,7 @@ +# Ensure shell scripts always use LF line endings on all platforms. +# Without this, Windows git (autocrlf=true) will convert LF->CRLF on checkout, +# causing "no such file or directory" errors when Docker runs the entrypoint. +*.sh text eol=lf + +# Standard: force LF for all text files committed to the repo +* text=auto From bd542f35ac3ae429ea93c0c1aefdcb7ffab989da Mon Sep 17 00:00:00 2001 From: 11EJDE11 Date: Tue, 14 Apr 2026 22:27:12 +1200 Subject: [PATCH 2/2] =?UTF-8?q?Add=20sync=20log=20endpoint=20for=20desync?= =?UTF-8?q?=20log=20uploads=20-=20SyncLog=20model=20with=20migrations=20(g?= =?UTF-8?q?ame=5Fhash,=20player=5Fname,=20slot=20index,=20map/game=20metad?= =?UTF-8?q?ata,=20file,=20IP,=20500=20MB=20storage=20cap=20with=20LRU=20ev?= =?UTF-8?q?iction)=20-=20POST=20/sync-logs/=20-=20unauthenticated=20upload?= =?UTF-8?q?=20endpoint=20-=20GET=20/sync-logs/session//=20=E2=80=94?= =?UTF-8?q?=20lists=20logs=20for=20a=20session=20(IP=20omitted)=20-=20GET?= =?UTF-8?q?=20/sync-logs/compare//view/=20-=20HTML=20page=20for=20cl?= =?UTF-8?q?ient-side=20side-by-side=20diff=20of=20any=20two=20logs=20using?= =?UTF-8?q?=20jsdiff=20+=20diff2html=20-=20Django=20admin=20with=20game=20?= =?UTF-8?q?slug=20column=20and=20per-game=20filtering=20-=20Enable=20Djang?= =?UTF-8?q?o=20admin=20login=20(ModelBackend,=20has=5Fperm,=20get=5Fby=5Fn?= =?UTF-8?q?atural=5Fkey)=20-=20nginx=20client=5Fmax=5Fbody=5Fsize=20raised?= =?UTF-8?q?=20to=2015m=20to=20accommodate=20large=20logs=20-=20Add=20.vs/?= =?UTF-8?q?=20to=20.gitignore?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 9 +- docker/nginx.conf | 2 + kirovy/admin.py | 45 +++ kirovy/migrations/0022_synclog.py | 69 ++++ kirovy/migrations/0023_synclog_file_size.py | 19 ++ kirovy/models/__init__.py | 1 + kirovy/models/cnc_user.py | 14 + kirovy/models/sync_log.py | 64 ++++ kirovy/settings/_base.py | 4 + kirovy/urls.py | 11 + kirovy/views/sync_log_views.py | 361 ++++++++++++++++++++ 11 files changed, 594 insertions(+), 5 deletions(-) create mode 100644 kirovy/admin.py create mode 100644 kirovy/migrations/0022_synclog.py create mode 100644 kirovy/migrations/0023_synclog_file_size.py create mode 100644 kirovy/models/sync_log.py create mode 100644 kirovy/views/sync_log_views.py diff --git a/.gitignore b/.gitignore index f49d4c3..cee361d 100644 --- a/.gitignore +++ b/.gitignore @@ -152,13 +152,12 @@ dmypy.json # Cython debug symbols cython_debug/ -# PyCharm -# JetBrains specific template is maintained in a separate JetBrains.gitignore that can -# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore -# and can be added to the global gitignore or merged into this file. For a more nuclear -# option (not recommended) you can uncomment the following to ignore the entire idea folder. +# PyCharm / JetBrains .idea/ +# Visual Studio +.vs/ + # ignores the directory that postgres gets saved to. data/ diff --git a/docker/nginx.conf b/docker/nginx.conf index a32e30d..53498bf 100644 --- a/docker/nginx.conf +++ b/docker/nginx.conf @@ -7,6 +7,8 @@ events { } http { + client_max_body_size 15m; + server { listen 80; diff --git a/kirovy/admin.py b/kirovy/admin.py new file mode 100644 index 0000000..1946b5b --- /dev/null +++ b/kirovy/admin.py @@ -0,0 +1,45 @@ +"""Django admin registrations for Kirovy models. + +The sync log admin is the primary reason this file exists. It provides a +grouped view of sync logs by game session with a direct link to the +compare endpoint for side-by-side log analysis. +""" + +from django.contrib import admin +from django.utils.html import format_html + +from kirovy.models.sync_log import SyncLog + + +@admin.register(SyncLog) +class SyncLogAdmin(admin.ModelAdmin): + list_display = ( + "player_name", + "sync_file_index", + "game_hash_short", + "map_name", + "game_mode", + "game_slug", + "ip_address", + "uploaded_at", + "compare_link", + ) + list_filter = ("cnc_game__slug", "uploaded_at") + search_fields = ("game_hash", "player_name", "map_sha1", "map_name") + readonly_fields = ("id", "uploaded_at", "game_hash", "ip_address", "compare_link") + ordering = ("-uploaded_at",) + + @admin.display(description="Game", ordering="cnc_game__slug") + def game_slug(self, obj: SyncLog) -> str: + return obj.cnc_game.slug if obj.cnc_game else "—" + + @admin.display(description="Game Hash") + def game_hash_short(self, obj: SyncLog) -> str: + return obj.game_hash[:12] + "…" + + @admin.display(description="Compare Session") + def compare_link(self, obj: SyncLog) -> str: + return format_html( + 'Compare logs', + obj.game_hash, + ) diff --git a/kirovy/migrations/0022_synclog.py b/kirovy/migrations/0022_synclog.py new file mode 100644 index 0000000..55e80c0 --- /dev/null +++ b/kirovy/migrations/0022_synclog.py @@ -0,0 +1,69 @@ +import uuid + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("kirovy", "0021_remove_cncmapfile_kirovy_cncm_cnc_map_a1e8af_idx_and_more"), + ] + + operations = [ + migrations.CreateModel( + name="SyncLog", + fields=[ + ("id", models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ( + "game_hash", + models.CharField( + db_index=True, + help_text="SHA1 of (seed + map_sha1 + game_slug). Same for all players in the same game session.", + max_length=64, + ), + ), + ("player_name", models.CharField(max_length=64)), + ( + "sync_file_index", + models.SmallIntegerField( + help_text="Player slot index from the SYNC filename (0-7). e.g. SYNC2.TXT → index 2." + ), + ), + ("map_sha1", models.CharField(blank=True, max_length=40)), + ("map_name", models.CharField(blank=True, max_length=255)), + ("game_mode", models.CharField(blank=True, max_length=128)), + ( + "cnc_game", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="sync_logs", + to="kirovy.cncgame", + ), + ), + ("file", models.FileField(upload_to="sync_logs/%Y/%m/%d/")), + ("ip_address", models.GenericIPAddressField()), + ("uploaded_at", models.DateTimeField(auto_now_add=True)), + ], + options={ + "app_label": "kirovy", + }, + ), + migrations.AddIndex( + model_name="synclog", + index=models.Index(fields=["game_hash", "sync_file_index"], name="kirovy_sync_game_ha_idx"), + ), + migrations.AddIndex( + model_name="synclog", + index=models.Index(fields=["uploaded_at"], name="kirovy_sync_uploade_idx"), + ), + migrations.AddConstraint( + model_name="synclog", + constraint=models.UniqueConstraint( + fields=["game_hash", "sync_file_index", "ip_address"], + name="unique_sync_log_per_slot_per_machine", + ), + ), + ] diff --git a/kirovy/migrations/0023_synclog_file_size.py b/kirovy/migrations/0023_synclog_file_size.py new file mode 100644 index 0000000..7bc7e4b --- /dev/null +++ b/kirovy/migrations/0023_synclog_file_size.py @@ -0,0 +1,19 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("kirovy", "0022_synclog"), + ] + + operations = [ + migrations.AddField( + model_name="synclog", + name="file_size", + field=models.PositiveIntegerField( + default=0, + help_text="File size in bytes. Used for storage cap enforcement without filesystem calls.", + ), + ), + ] diff --git a/kirovy/models/__init__.py b/kirovy/models/__init__.py index afdeaed..003462f 100644 --- a/kirovy/models/__init__.py +++ b/kirovy/models/__init__.py @@ -7,6 +7,7 @@ from .cnc_user import CncUser from .file_base import CncNetFileBaseModel from .map_preview import MapPreview +from .sync_log import SyncLog class SupportsBan(typing.Protocol): diff --git a/kirovy/models/cnc_user.py b/kirovy/models/cnc_user.py index 9c659f1..f3f7837 100644 --- a/kirovy/models/cnc_user.py +++ b/kirovy/models/cnc_user.py @@ -20,6 +20,10 @@ class CncUserManager(models.Manager): constants.LegacyUploadUser.CNCNET_ID, } + def get_by_natural_key(self, cncnet_id): + """Required by ModelBackend for Django admin password-based login.""" + return super().get_queryset().get(cncnet_id=cncnet_id) + def find_by_cncnet_id(self, cncnet_id: int) -> t.Union["CncUser", None]: return super().get_queryset().filter(cncnet_id=cncnet_id).first() @@ -141,6 +145,16 @@ def is_admin(self) -> bool: self.refresh_from_db(fields=["group"]) return self.CncnetUserGroup.is_admin(self.group) + @property + def is_superuser(self) -> bool: + return self.is_admin + + def has_perm(self, perm, obj=None) -> bool: + return self.is_staff + + def has_module_perms(self, app_label) -> bool: + return self.is_staff + @staticmethod def create_or_update_from_cncnet(user_dto: CncnetUserInfo) -> "CncUser": """Create or Update a user object, based on the user DTO, from the CnCNet ladder API. diff --git a/kirovy/models/sync_log.py b/kirovy/models/sync_log.py new file mode 100644 index 0000000..4aece10 --- /dev/null +++ b/kirovy/models/sync_log.py @@ -0,0 +1,64 @@ +import uuid + +from django.db import models + +from kirovy.models.cnc_game import CncGame + + +class SyncLog(models.Model): + """A sync error log uploaded from a CnCNet game client. + + When a multiplayer game desyncs, the game engine writes SYNC{n}.TXT files (one per player slot). + Clients upload these so admins can compare logs across machines to diagnose the desync. + + All clients in the same game session share the same ``game_hash`` (derived from the random seed + and map SHA1), making it easy to group and compare logs from different players. + """ + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + + game_hash = models.CharField( + max_length=64, + db_index=True, + help_text='SHA1 of "{mapSha1}|{randomSeed}|{gameSlug}" as a lowercase hex string. Same for all players in the same game session.', + ) + player_name = models.CharField(max_length=64) + sync_file_index = models.SmallIntegerField( + help_text="Player slot index from the SYNC filename (0-7). e.g. SYNC2.TXT → index 2.", + ) + + map_sha1 = models.CharField(max_length=40, blank=True) + map_name = models.CharField(max_length=255, blank=True) + game_mode = models.CharField(max_length=128, blank=True) + cnc_game = models.ForeignKey( + CncGame, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="sync_logs", + ) + + file = models.FileField(upload_to="sync_logs/%Y/%m/%d/") + file_size = models.PositiveIntegerField( + default=0, + help_text="File size in bytes. Used for storage cap enforcement without filesystem calls.", + ) + ip_address = models.GenericIPAddressField() + uploaded_at = models.DateTimeField(auto_now_add=True) + + class Meta: + app_label = "kirovy" + indexes = [ + models.Index(fields=["game_hash", "sync_file_index"]), + models.Index(fields=["uploaded_at"]), + ] + # One sync file per player slot per machine per game session. + constraints = [ + models.UniqueConstraint( + fields=["game_hash", "sync_file_index", "ip_address"], + name="unique_sync_log_per_slot_per_machine", + ) + ] + + def __str__(self): + return f"SyncLog[{self.player_name} SYNC{self.sync_file_index} {self.game_hash[:8]}]" diff --git a/kirovy/settings/_base.py b/kirovy/settings/_base.py index fccedf1..1a1ccf3 100644 --- a/kirovy/settings/_base.py +++ b/kirovy/settings/_base.py @@ -241,6 +241,10 @@ AUTH_USER_MODEL = "kirovy.CncUser" +AUTHENTICATION_BACKENDS = [ + "django.contrib.auth.backends.ModelBackend", # enables password-based login for Django admin +] + RUN_ENVIRONMENT = get_env_var("RUN_ENVIRONMENT", settings_constants.RunEnvironment.PRODUCTION, run_environment_valid) """attr: Defines which type of environment we are running on. Useful for debug logic.""" diff --git a/kirovy/urls.py b/kirovy/urls.py index 1563224..ee53ff7 100644 --- a/kirovy/urls.py +++ b/kirovy/urls.py @@ -32,6 +32,7 @@ map_upload_views, map_image_views, game_views, + sync_log_views, ) from kirovy import typing as t, constants @@ -93,12 +94,14 @@ def _get_url_patterns() -> list[_DjangoPath]: return ( [ + path("django-admin/", admin.site.urls), path("admin/", include(admin_patterns)), path("test/jwt", test.TestJwt.as_view()), path("ui-permissions/", permission_views.ListPermissionForAuthUser.as_view()), path("maps/", include(map_patterns)), # path("users//", ...), # will show which files a user has uploaded. path("games/", include(game_patterns)), + path("sync-logs/", include(sync_log_patterns)), ] + backwards_compatible_urls + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) # static assets @@ -138,4 +141,12 @@ def _get_url_patterns() -> list[_DjangoPath]: path("/", game_views.GameDetailView.as_view()), ] +# /sync-logs/ +sync_log_patterns = [ + path("", sync_log_views.SyncLogUploadView.as_view()), + path("session//", sync_log_views.SyncLogSessionView.as_view()), + path("compare//", sync_log_views.SyncLogCompareView.as_view()), + path("compare//view/", sync_log_views.SyncLogComparePageView.as_view()), +] + urlpatterns = _get_url_patterns() diff --git a/kirovy/views/sync_log_views.py b/kirovy/views/sync_log_views.py new file mode 100644 index 0000000..17777aa --- /dev/null +++ b/kirovy/views/sync_log_views.py @@ -0,0 +1,361 @@ +from django.db.models import QuerySet, Sum +from django.http import HttpResponse +from django.utils.html import escape +from django.views import View +from rest_framework import status +from rest_framework.parsers import MultiPartParser +from rest_framework.permissions import AllowAny + +from kirovy import logging +from kirovy.models import CncGame, SyncLog +from kirovy.objects.ui_objects import ResultResponseData +from kirovy.request import KirovyRequest +from kirovy.response import KirovyResponse +from kirovy.views.base_views import KirovyApiView + +_LOGGER = logging.get_logger(__name__) + +_MAX_SYNC_LOG_BYTES = 10 * 1024 * 1024 # 10 MB per file — sync logs are typically 300-3000 KB each +_STORAGE_CAP_BYTES = 500 * 1024 * 1024 # 500 MB total storage cap + + +class SyncLogUploadView(KirovyApiView): + """Accept a SYNC*.TXT file from a CnCNet game client after a desync. + + No authentication required — players should not need an account for this. + One file per player slot per machine per game session is enforced by a unique constraint. + """ + + parser_classes = [MultiPartParser] + permission_classes = [AllowAny] + + def post(self, request: KirovyRequest, format=None) -> KirovyResponse: + uploaded_file = request.data.get("file") + game_hash = request.data.get("game_hash", "").strip() + player_name = request.data.get("player_name", "").strip() + sync_file_index = request.data.get("sync_file_index", "0") + map_sha1 = request.data.get("map_sha1", "").strip() + map_name = request.data.get("map_name", "").strip() + game_mode = request.data.get("game_mode", "").strip() + game_slug = request.data.get("game_slug", "").strip() + + if not uploaded_file: + return KirovyResponse( + ResultResponseData(message="No file provided."), + status=status.HTTP_400_BAD_REQUEST, + ) + if not game_hash: + return KirovyResponse( + ResultResponseData(message="game_hash is required."), + status=status.HTTP_400_BAD_REQUEST, + ) + if not player_name: + return KirovyResponse( + ResultResponseData(message="player_name is required."), + status=status.HTTP_400_BAD_REQUEST, + ) + + try: + sync_file_index = int(sync_file_index) + except (ValueError, TypeError): + return KirovyResponse( + ResultResponseData(message="sync_file_index must be an integer 0–7."), + status=status.HTTP_400_BAD_REQUEST, + ) + + if not (0 <= sync_file_index <= 7): + return KirovyResponse( + ResultResponseData(message="sync_file_index must be 0–7."), + status=status.HTTP_400_BAD_REQUEST, + ) + + if uploaded_file.size > _MAX_SYNC_LOG_BYTES: + return KirovyResponse( + ResultResponseData(message="File too large."), + status=status.HTTP_400_BAD_REQUEST, + ) + + cnc_game = CncGame.objects.filter(slug__iexact=game_slug).first() if game_slug else None + + ip_address = request.client_ip_address + + # Silently succeed on duplicates — the client may retry on network failure. + existing = SyncLog.objects.filter( + game_hash=game_hash, + sync_file_index=sync_file_index, + ip_address=ip_address, + ).first() + if existing: + _LOGGER.debug( + "sync_log.duplicate_upload", + game_hash=game_hash, + sync_file_index=sync_file_index, + ip=ip_address, + ) + return KirovyResponse( + ResultResponseData(message="Sync log already uploaded.", result={"id": str(existing.id)}), + status=status.HTTP_200_OK, + ) + + uploaded_file.name = f"{game_hash}_{sync_file_index}_{ip_address.replace(':', '_')}.txt" + file_size = uploaded_file.size + + sync_log = SyncLog( + game_hash=game_hash, + player_name=player_name[:64], + sync_file_index=sync_file_index, + map_sha1=map_sha1[:40], + map_name=map_name[:255], + game_mode=game_mode[:128], + cnc_game=cnc_game, + file=uploaded_file, + file_size=file_size, + ip_address=ip_address, + ) + sync_log.save() + + _LOGGER.info( + "sync_log.uploaded", + game_hash=game_hash, + player=player_name, + index=sync_file_index, + ip=ip_address, + ) + + self._enforce_storage_cap() + + return KirovyResponse( + ResultResponseData(message="Sync log uploaded.", result={"id": str(sync_log.id)}), + status=status.HTTP_201_CREATED, + ) + + @staticmethod + def _enforce_storage_cap() -> None: + """Delete oldest sync logs until total storage is under :data:`_STORAGE_CAP_BYTES`.""" + total = SyncLog.objects.aggregate(total=Sum("file_size"))["total"] or 0 + if total <= _STORAGE_CAP_BYTES: + return + + # Order oldest-first; delete until we're under the cap. + for log in SyncLog.objects.order_by("uploaded_at").iterator(): + if total <= _STORAGE_CAP_BYTES: + break + try: + log.file.delete(save=False) + except Exception: + pass + total -= log.file_size + log.delete() + _LOGGER.info("sync_log.evicted_for_cap", id=str(log.id), remaining_bytes=total) + + +class SyncLogSessionView(KirovyApiView): + """List all sync logs for a game session, identified by game_hash.""" + + permission_classes = [AllowAny] + + def get(self, request: KirovyRequest, game_hash: str, format=None) -> KirovyResponse: + logs: QuerySet[SyncLog] = ( + SyncLog.objects.filter(game_hash=game_hash) + .select_related("cnc_game") + .order_by("sync_file_index", "uploaded_at") + ) + + result = [ + { + "id": str(log.id), + "player_name": log.player_name, + "sync_file_index": log.sync_file_index, + "map_name": log.map_name, + "map_sha1": log.map_sha1, + "game_mode": log.game_mode, + "game_slug": log.cnc_game.slug if log.cnc_game else "", + "uploaded_at": log.uploaded_at.isoformat(), + "file_url": request.build_absolute_uri(log.file.url), + } + for log in logs + ] + + return KirovyResponse( + ResultResponseData( + message=f"{len(result)} log(s) for session {game_hash[:8]}…", + result={"game_hash": game_hash, "logs": result}, + ) + ) + + +class SyncLogCompareView(KirovyApiView): + """Return the raw text of all sync logs for a game session for side-by-side comparison.""" + + permission_classes = [AllowAny] + + def get(self, request: KirovyRequest, game_hash: str, format=None) -> KirovyResponse: + logs: QuerySet[SyncLog] = SyncLog.objects.filter(game_hash=game_hash).order_by("sync_file_index", "uploaded_at") + + result = {} + for log in logs: + key = f"SYNC{log.sync_file_index}_{log.player_name}" + try: + with log.file.open("r") as f: + result[key] = f.read() + except Exception as e: + result[key] = f"[Error reading file: {e}]" + + return KirovyResponse( + ResultResponseData( + message=f"Raw sync logs for session {game_hash[:8]}…", + result={"game_hash": game_hash, "files": result}, + ) + ) + + +# --------------------------------------------------------------------------- +# HTML compare page +# --------------------------------------------------------------------------- + +_COMPARE_PAGE = """\ + + + + + + Sync Logs \u2014 __SHORT_HASH__\u2026 + + + + +

Sync Log Compare \u2014 __SHORT_HASH__\u2026

+ +
+ + + + + + +
+ +
+ + + + + + +""" + + +class SyncLogComparePageView(View): + """Serve an HTML page for side-by-side sync log diffing. + + The page fetches the session data via the JSON API, lets the user pick two + logs from dropdowns, then diffs them client-side using jsdiff + diff2html. + No authentication required — same access level as the session endpoint. + """ + + def get(self, request, game_hash: str) -> HttpResponse: + safe_hash = escape(game_hash) + short_hash = escape(game_hash[:8]) + html = _COMPARE_PAGE.replace("__GAME_HASH__", safe_hash).replace("__SHORT_HASH__", short_hash) + return HttpResponse(html, content_type="text/html; charset=utf-8")