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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -165,3 +165,4 @@ data/
test.bmp
# Photoshop files
image-source/
docs/.obsidian/
6 changes: 6 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,9 @@ stop:
test:
docker compose build django
docker compose run test


django-bash:
# For running developer `./mange,py` commands in local dev.
docker compose build django
docker compose run django bash
7 changes: 7 additions & 0 deletions docs/auth_and_permissions.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,10 @@ How permission is determined:
3. The function `kirovy.authentication.CncNetAuthentication.authenticate` is called, which will create or updates
the user object in Kirovy, then set `request.user` to that object.
4. The permission classes check their various permissions based on `request.user`


```mermaid
graph LR;
url[Found in *urls.py*, points to a view]
view_base[Views have an *authentication_classes* attribute to define an authenticator. It defaults to *kirovy.authentication.CncNetAuthentication*]
```
15 changes: 14 additions & 1 deletion kirovy/constants/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ class GameSlugs(enum.StrEnum):
cnc_reloaded = "cncr"
rise_of_the_east = "rote"
red_resurrection = "rr"
dune_2000 = "d2k"
dune_2000 = "d2"
generals = "gen"
zero_hour = "zh"
battle_for_middle_earth = "bfme"
Expand All @@ -130,6 +130,19 @@ class GameSlugs(enum.StrEnum):
red_alert_3_uprising = "ra3u"


BACKWARDS_COMPATIBLE_GAMES = [
GameSlugs.tiberian_dawn,
GameSlugs.red_alert,
GameSlugs.tiberian_sun,
GameSlugs.dawn_of_the_tiberium_age,
GameSlugs.yuris_revenge,
GameSlugs.dune_2000,
]
"""attr: These are the games that MapDB 1.0 supported.
We need to maintain backwards compatibility for clients we can't update.
"""


class GameEngines:
"""Maps the game slugs to the general game engine.

Expand Down
22 changes: 22 additions & 0 deletions kirovy/migrations/0011_alter_cncmapfile_file.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# Generated by Django 4.2.20 on 2025-03-17 20:32

from django.db import migrations, models
import kirovy.models.file_base
import kirovy.zip_storage


class Migration(migrations.Migration):

dependencies = [
("kirovy", "0010_cncmapfile_hash_sha1_mappreview_hash_sha1"),
]

operations = [
migrations.AlterField(
model_name="cncmapfile",
name="file",
field=models.FileField(
storage=kirovy.zip_storage.ZipFileStorage, upload_to=kirovy.models.file_base._generate_upload_to
),
),
]
49 changes: 49 additions & 0 deletions kirovy/migrations/0012_raw_zip_extension.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# Generated by Django 4.2.20 on 2025-03-18 18:07

import typing

from django.db import migrations
from django.db.backends.postgresql.schema import DatabaseSchemaEditor
from django.db.migrations.state import StateApps

from kirovy.models import CncFileExtension as _Ext, CncUser as _User, CncGame as _Game


def _forward(apps: StateApps, schema_editor: DatabaseSchemaEditor):

# This is necessary in case later migrations make schema changes to these models.
# Importing them normally will use the latest schema state and will crash if those
# migrations are after this one.
CncFileExtension: typing.Type[_Ext] = apps.get_model("kirovy", "CncFileExtension")
CncUser: typing.Type[_User] = apps.get_model("kirovy", "CncUser")
CncGame: typing.Type[_Game] = apps.get_model("kirovy", "CncGame")

migration_user = CncUser.objects.get_or_create_migration_user()

zip_ext = CncFileExtension(
extension="zip",
extension_type=_Ext.ExtensionTypes.MAP.value,
about="Raw zip uploads for backwards compatibility with clients CnCNet doesn't control.",
last_modified_by_id=migration_user.id,
)
zip_ext.save()

for game in CncGame.objects.filter(is_visible=True):
game.allowed_extensions.add(zip_ext)


def _backward(apps: StateApps, schema_editor: DatabaseSchemaEditor):
"""Deleting the extension on accident could be devastating to the db so no."""
pass


class Migration(migrations.Migration):

dependencies = [
("kirovy", "0011_alter_cncmapfile_file"),
]

# Elidable=false means that squashmigrations will not delete this.
operations = [
migrations.RunPython(_forward, reverse_code=_backward, elidable=False),
]
16 changes: 15 additions & 1 deletion kirovy/settings/_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@
"django.contrib.staticfiles",
"django_filters", # Used for advanced querying on the API.
"rest_framework", # Django REST Framework.
"drf_spectacular", # Generates openapi docs.
"drf_spectacular_sidecar", # swagger assets for openapi
]


Expand Down Expand Up @@ -87,6 +89,7 @@
"kirovy.authentication.CncNetAuthentication",
],
"EXCEPTION_HANDLER": "kirovy.exception_handler.kirovy_exception_handler",
"DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
}
"""
attr: Define the default authentication backend for endpoints.
Expand All @@ -99,6 +102,17 @@
are set in Django Rest Framework, see `The DRF docs <https://www.django-rest-framework.org/api-guide/permissions/>`_
"""

SPECTACULAR_SETTINGS = {
"TITLE": "Kirovy",
"DESCRIPTION": "CnCNet Map API",
"VERSION": "0.1.0",
"SERVE_INCLUDE_SCHEMA": False,
"SWAGGER_UI_DIST": "SIDECAR", # shorthand to use the sidecar instead
"SWAGGER_UI_FAVICON_HREF": "SIDECAR",
"REDOC_DIST": "SIDECAR",
}


# Database
# https://docs.djangoproject.com/en/4.1/ref/settings/#databases

Expand Down Expand Up @@ -208,5 +222,5 @@
AUTH_USER_MODEL = "kirovy.CncUser"


RUN_ENVIRONMENT = get_env_var("RUN_ENVIRONMENT", "dev")
RUN_ENVIRONMENT = get_env_var("RUN_ENVIRONMENT", "prod")
"""attr: Defines which type of environment we are running on. Useful for debug logic."""
38 changes: 29 additions & 9 deletions kirovy/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,11 @@
from django.conf.urls.static import static
from django.contrib import admin
from django.urls import path, include
from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView, SpectacularRedocView

import kirovy.views.map_upload_views
from kirovy.views import test, cnc_map_views, permission_views, admin_views
from kirovy import typing as t
from kirovy.models import CncGame
from kirovy.views import test, cnc_map_views, permission_views, admin_views, map_upload_views
from kirovy import typing as t, constants


def _get_url_patterns() -> list[path]:
Expand All @@ -30,17 +31,37 @@ def _get_url_patterns() -> list[path]:
I added this because I wanted to have the root URLs at the top of the file,
but I didn't want to have other url files.
"""
dev_urls = []
if settings.RUN_ENVIRONMENT == "dev":
dev_urls = [
path("api/schema/", SpectacularAPIView.as_view(), name="schema"),
# Optional UI:
path("api/schema/swagger-ui/", SpectacularSwaggerView.as_view(url_name="schema"), name="swagger-ui"),
path("api/schema/redoc/", SpectacularRedocView.as_view(url_name="schema"), name="redoc"),
]

backwards_compatible_urls = [
path("upload", map_upload_views.CncNetBackwardsCompatibleUploadView.as_view()),
*(
# Make e.g. /yr/map_hash, /ra2/map_hash, etc
path(f"{g.slug}/<str:sha1_hash>", cnc_map_views.BackwardsCompatibleMapView.as_view(), {"game_id": g.id})
for g in CncGame.objects.filter(slug__in=constants.BACKWARDS_COMPATIBLE_GAMES)
),
]

return (
[
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/", ...), # get games.
# path("games/", ...), # get games.,
]
+ static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
+ static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
+ backwards_compatible_urls
+ static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) # static assets
+ static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) # user uploads
+ dev_urls
)


Expand All @@ -49,8 +70,8 @@ def _get_url_patterns() -> list[path]:
# path("categories/", ...), # return all categories
# path("categories/game/<uuid:cnc_game_id>/", ...),
path("categories/", cnc_map_views.MapCategoryListCreateView.as_view()),
path("upload/", kirovy.views.map_upload_views.MapFileUploadView.as_view()),
path("client/upload/", kirovy.views.map_upload_views.CncnetClientMapUploadView.as_view()),
path("upload/", map_upload_views.MapFileUploadView.as_view()),
path("client/upload/", map_upload_views.CncnetClientMapUploadView.as_view()),
path("<uuid:pk>/", cnc_map_views.MapRetrieveUpdateView.as_view()),
path("delete/<uuid:pk>/", cnc_map_views.MapDeleteView.as_view()),
path("search/", cnc_map_views.MapListCreateView.as_view()),
Expand All @@ -67,5 +88,4 @@ def _get_url_patterns() -> list[path]:
# /admin/
admin_patterns = [path("ban/", admin_views.BanView.as_view())]


urlpatterns = _get_url_patterns()
4 changes: 2 additions & 2 deletions kirovy/views/base_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,8 @@ def get_paginated_response(self, results: t.List[t.DictStrAny]) -> KirovyRespons

return KirovyResponse(data, status=status.HTTP_200_OK)

def get_paginated_response_schema(self, schema):
raise NotImplementedError()
# def get_paginated_response_schema(self, schema):
# raise NotImplementedError()


class KirovyListCreateView(_g.ListCreateAPIView):
Expand Down
20 changes: 20 additions & 0 deletions kirovy/views/cnc_map_views.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,23 @@
import logging
from uuid import UUID

from django.db.models import Q, QuerySet
from django.http import FileResponse
from rest_framework import status
from rest_framework.exceptions import PermissionDenied
from rest_framework.filters import SearchFilter, OrderingFilter
from django_filters import rest_framework as filters
from rest_framework.permissions import AllowAny
from rest_framework.views import APIView

from kirovy import permissions
from kirovy.models import (
MapCategory,
CncGame,
CncMap,
CncMapFile,
)
from kirovy.response import KirovyResponse
from kirovy.serializers import cnc_map_serializers
from kirovy.views import base_views

Expand Down Expand Up @@ -222,3 +228,17 @@ def perform_destroy(self, instance: CncMap):
if instance.is_legacy:
raise PermissionDenied("cannot-delete-legacy-maps", status.HTTP_403_FORBIDDEN)
return super().perform_destroy(instance)


class BackwardsCompatibleMapView(APIView):
permission_classes = [AllowAny]

def get(self, request, sha1_hash: str, game_id: UUID, format=None):
"""
Return the map matching the hash, if it exists.
"""
map_file = CncMapFile.objects.filter(hash_sha1=sha1_hash, cnc_game_id=game_id).first()
if not map_file:
return KirovyResponse(status=status.HTTP_404_NOT_FOUND)

return FileResponse(map_file.file.open("rb"), as_attachment=True, filename=f"{map_file.hash_sha1}.zip")
Loading