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
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")