Skip to content
Draft
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
7 changes: 7 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -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
9 changes: 4 additions & 5 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/

Expand Down
2 changes: 2 additions & 0 deletions docker/nginx.conf
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ events {
}

http {
client_max_body_size 15m;

server {
listen 80;

Expand Down
45 changes: 45 additions & 0 deletions kirovy/admin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
"""Django admin registrations for Kirovy models.
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am pretty against using django admin due to prior negative experiences and database integrity issues.

I am open to discussing it, but we'd need to find a way to make it use the ladder for auth.


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(
'<a href="/sync-logs/compare/{}/view/" target="_blank">Compare logs</a>',
obj.game_hash,
)
69 changes: 69 additions & 0 deletions kirovy/migrations/0022_synclog.py
Original file line number Diff line number Diff line change
@@ -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",
),
),
]
19 changes: 19 additions & 0 deletions kirovy/migrations/0023_synclog_file_size.py
Original file line number Diff line number Diff line change
@@ -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.",
),
),
]
1 change: 1 addition & 0 deletions kirovy/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
14 changes: 14 additions & 0 deletions kirovy/models/cnc_user.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ class CncUserManager(models.Manager):
constants.LegacyUploadUser.CNCNET_ID,
}

def get_by_natural_key(self, cncnet_id):
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remove: The API isn't an authenticator, the ladder API controls that

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

Expand Down Expand Up @@ -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:
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remove: The API isn't an authenticator, the ladder API controls that

return self.is_admin

def has_perm(self, perm, obj=None) -> bool:
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remove: The API isn't an authenticator, the ladder API controls that

return self.is_staff

def has_module_perms(self, app_label) -> bool:
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remove: The API isn't an authenticator, the ladder API controls that

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.
Expand Down
64 changes: 64 additions & 0 deletions kirovy/models/sync_log.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import uuid

from django.db import models

from kirovy.models.cnc_game import CncGame


class SyncLog(models.Model):
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inherit from KirovyModel

"""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)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not needed if you inherit


game_hash = models.CharField(
max_length=64,
db_index=True,
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why the index on this? It will change for every lobby.

help_text='SHA1 of "{mapSha1}|{randomSeed}|{gameSlug}" as a lowercase hex string. Same for all players in the same game session.',
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It'd be better to store these as separate fields and use the game_id to get the slug

)
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.",
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's call it player_index because that's more clear on what this integer actually represents.

)

map_sha1 = models.CharField(max_length=40, blank=True)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use the hash to look up the map file foreign key when the logs are uploaded.

cnc_map_file = models.ForeignKey() etc

map_name = models.CharField(max_length=255, blank=True)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remove. It will be on the map itself.

game_mode = models.CharField(max_length=128, blank=True)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It'd be better to check the game mode table first and see if we can link to a foreign key.

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]}]"
4 changes: 4 additions & 0 deletions kirovy/settings/_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,10 @@

AUTH_USER_MODEL = "kirovy.CncUser"

AUTHENTICATION_BACKENDS = [
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remove: The API isn't an authenticator, the ladder API controls that

"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."""
Expand Down
11 changes: 11 additions & 0 deletions kirovy/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
map_upload_views,
map_image_views,
game_views,
sync_log_views,
)
from kirovy import typing as t, constants

Expand Down Expand Up @@ -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/<uuid:cnc_user_id>/", ...), # 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
Expand Down Expand Up @@ -138,4 +141,12 @@ def _get_url_patterns() -> list[_DjangoPath]:
path("<uuid:pk>/", game_views.GameDetailView.as_view()),
]

# /sync-logs/
sync_log_patterns = [
path("", sync_log_views.SyncLogUploadView.as_view()),
path("session/<str:game_hash>/", sync_log_views.SyncLogSessionView.as_view()),
path("compare/<str:game_hash>/", sync_log_views.SyncLogCompareView.as_view()),
path("compare/<str:game_hash>/view/", sync_log_views.SyncLogComparePageView.as_view()),
]

urlpatterns = _get_url_patterns()
Loading
Loading