diff --git a/.gitignore b/.gitignore
index 1d69868..ab36ab7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -165,3 +165,4 @@ data/
test.bmp
# Photoshop files
image-source/
+docs/.obsidian/
diff --git a/Makefile b/Makefile
index e98c5b3..d380861 100644
--- a/Makefile
+++ b/Makefile
@@ -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
diff --git a/docs/auth_and_permissions.md b/docs/auth_and_permissions.md
index db47983..fe9ea2a 100644
--- a/docs/auth_and_permissions.md
+++ b/docs/auth_and_permissions.md
@@ -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*]
+```
diff --git a/kirovy/constants/__init__.py b/kirovy/constants/__init__.py
index a89f833..2162621 100644
--- a/kirovy/constants/__init__.py
+++ b/kirovy/constants/__init__.py
@@ -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"
@@ -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.
diff --git a/kirovy/migrations/0011_alter_cncmapfile_file.py b/kirovy/migrations/0011_alter_cncmapfile_file.py
new file mode 100644
index 0000000..6c2c359
--- /dev/null
+++ b/kirovy/migrations/0011_alter_cncmapfile_file.py
@@ -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
+ ),
+ ),
+ ]
diff --git a/kirovy/migrations/0012_raw_zip_extension.py b/kirovy/migrations/0012_raw_zip_extension.py
new file mode 100644
index 0000000..093a51a
--- /dev/null
+++ b/kirovy/migrations/0012_raw_zip_extension.py
@@ -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),
+ ]
diff --git a/kirovy/settings/_base.py b/kirovy/settings/_base.py
index fa656f7..58ce179 100644
--- a/kirovy/settings/_base.py
+++ b/kirovy/settings/_base.py
@@ -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
]
@@ -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.
@@ -99,6 +102,17 @@
are set in Django Rest Framework, see `The DRF docs `_
"""
+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
@@ -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."""
diff --git a/kirovy/urls.py b/kirovy/urls.py
index ffd18dd..c0bc280 100644
--- a/kirovy/urls.py
+++ b/kirovy/urls.py
@@ -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]:
@@ -30,6 +31,24 @@ 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}/", 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)),
@@ -37,10 +56,12 @@ def _get_url_patterns() -> list[path]:
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/", ...), # 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
)
@@ -49,8 +70,8 @@ def _get_url_patterns() -> list[path]:
# path("categories/", ...), # return all categories
# path("categories/game//", ...),
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("/", cnc_map_views.MapRetrieveUpdateView.as_view()),
path("delete//", cnc_map_views.MapDeleteView.as_view()),
path("search/", cnc_map_views.MapListCreateView.as_view()),
@@ -67,5 +88,4 @@ def _get_url_patterns() -> list[path]:
# /admin/
admin_patterns = [path("ban/", admin_views.BanView.as_view())]
-
urlpatterns = _get_url_patterns()
diff --git a/kirovy/views/base_views.py b/kirovy/views/base_views.py
index 4f5da90..1e94a1e 100644
--- a/kirovy/views/base_views.py
+++ b/kirovy/views/base_views.py
@@ -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):
diff --git a/kirovy/views/cnc_map_views.py b/kirovy/views/cnc_map_views.py
index 81b3bc6..295cce6 100644
--- a/kirovy/views/cnc_map_views.py
+++ b/kirovy/views/cnc_map_views.py
@@ -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
@@ -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")
diff --git a/kirovy/views/map_upload_views.py b/kirovy/views/map_upload_views.py
index 47d207a..b263493 100644
--- a/kirovy/views/map_upload_views.py
+++ b/kirovy/views/map_upload_views.py
@@ -123,7 +123,27 @@ def post(self, request: KirovyRequest, format=None) -> KirovyResponse:
context={"request": self.request},
)
new_map_file_serializer.is_valid(raise_exception=True)
- new_map_file = new_map_file_serializer.save()
+ new_map_file: cnc_map.CncMapFile = new_map_file_serializer.save()
+
+ extracted_image_url = self.extract_preview(new_map_file, map_parser)
+
+ return KirovyResponse(
+ ResultResponseData(
+ message="File uploaded successfully",
+ result={
+ "cnc_map": new_map.map_name,
+ "cnc_map_file": new_map_file.file.url,
+ "cnc_map_id": new_map.id,
+ "extracted_preview_file": extracted_image_url,
+ "sha1": new_map_file.hash_sha1,
+ },
+ ),
+ status=status.HTTP_201_CREATED,
+ )
+
+ def extract_preview(self, new_map_file: cnc_map.CncMapFile, map_parser: CncGen2MapParser | None) -> str | None:
+ if not map_parser:
+ return None
extracted_image = map_parser.extract_preview()
extracted_image_url: str = ""
@@ -141,20 +161,7 @@ def post(self, request: KirovyRequest, format=None) -> KirovyResponse:
new_map_preview.save()
extracted_image_url = new_map_preview.file.url
- # TODO: Actually serialize the return data and include the link to the preview.
- # TODO: Should probably convert this to DRF for that step.
- return KirovyResponse(
- ResultResponseData(
- message="File uploaded successfully",
- result={
- "cnc_map": new_map.map_name,
- "cnc_map_file": new_map_file.file.url,
- "cnc_map_id": new_map.id,
- "extracted_preview_file": extracted_image_url,
- },
- ),
- status=status.HTTP_201_CREATED,
- )
+ return extracted_image_url
def get_map_parser(self, uploaded_file: UploadedFile) -> CncGen2MapParser:
try:
@@ -341,3 +348,67 @@ def get_game_id_from_request(self, request: KirovyRequest) -> str | None:
)
return str(game.id)
+
+
+class CncNetBackwardsCompatibleUploadView(CncnetClientMapUploadView):
+ """An endpoint to support backwards compatible uploads for clients that we don't control, or haven't been updated.
+
+ Skips all post-processing and just drops the file in as-is.
+ """
+
+ permission_classes = [AllowAny]
+ upload_is_temporary = True
+
+ def post(self, request: KirovyRequest, format=None) -> KirovyResponse:
+ uploaded_file: UploadedFile = request.data["file"]
+
+ game_id = self.get_game_id_from_request(request)
+ extension_id = self.get_extension_id_for_upload(uploaded_file)
+ self.verify_file_size_is_allowed(uploaded_file)
+
+ map_hashes = self._get_file_hashes(uploaded_file)
+ self.verify_file_does_not_exist(map_hashes)
+
+ if uploaded_file.name != f"{map_hashes.sha1}.zip":
+ return KirovyResponse(status=status.HTTP_400_BAD_REQUEST)
+
+ # Make the map that we will attach the map file too.
+ new_map = cnc_map.CncMap(
+ map_name=f"backwards_compatible_{map_hashes.sha1}",
+ cnc_game_id=game_id,
+ is_published=False,
+ incomplete_upload=True,
+ cnc_user=request.user,
+ parent=None,
+ )
+ new_map.save()
+
+ new_map_file_serializer = cnc_map_serializers.CncMapFileSerializer(
+ data=dict(
+ width=-1,
+ height=-1,
+ cnc_map_id=new_map.id,
+ file=uploaded_file,
+ file_extension_id=extension_id,
+ cnc_game_id=new_map.cnc_game_id,
+ hash_md5=map_hashes.md5,
+ hash_sha512=map_hashes.sha512,
+ hash_sha1=map_hashes.sha1,
+ ),
+ context={"request": self.request},
+ )
+ new_map_file_serializer.is_valid(raise_exception=True)
+ new_map_file: cnc_map.CncMapFile = new_map_file_serializer.save()
+
+ return KirovyResponse(
+ ResultResponseData(
+ message="File uploaded successfully",
+ result={
+ "cnc_map": new_map.map_name,
+ "cnc_map_file": new_map_file.file.url,
+ "cnc_map_id": new_map.id,
+ "extracted_preview_file": None,
+ },
+ ),
+ status=status.HTTP_200_OK,
+ )
diff --git a/kirovy/zip_storage.py b/kirovy/zip_storage.py
index 78d65b4..832f4cd 100644
--- a/kirovy/zip_storage.py
+++ b/kirovy/zip_storage.py
@@ -5,13 +5,16 @@
from django.core.files import File
from django.core.files.storage import FileSystemStorage
+from kirovy.utils import file_utils
+
class ZipFileStorage(FileSystemStorage):
def save(self, name: str, content: File, max_length: int | None = None):
if is_zipfile(content):
return super().save(name, content, max_length)
- internal_filename = pathlib.Path(name).name
+ internal_extension = pathlib.Path(name).suffix
+ internal_filename = file_utils.hash_file_sha1(content) + internal_extension
zip_buffer = io.BytesIO()
with zipfile.ZipFile(zip_buffer, "w", allowZip64=False, compresslevel=4) as zf:
content.seek(0)
diff --git a/requirements-dev.txt b/requirements-dev.txt
index f628e4b..6c5647f 100644
--- a/requirements-dev.txt
+++ b/requirements-dev.txt
@@ -6,3 +6,4 @@ pre-commit==3.*
pytest-django==4.*
markdown>=3.4.4, <=4.0
setuptools
+drf-spectacular[sidecar]
diff --git a/schema.yml b/schema.yml
new file mode 100644
index 0000000..dfb19db
--- /dev/null
+++ b/schema.yml
@@ -0,0 +1,547 @@
+openapi: 3.0.3
+info:
+ title: Kirovy
+ version: 0.1.0
+ description: CnCNet Map API
+paths:
+ /admin/ban/:
+ post:
+ operationId: admin_ban_create
+ description: |-
+ The view for banning things.
+
+ ``POST /admin/ban/``
+
+ Payload :attr:`kirovy.objects.ui_objects.BanData`.
+ tags:
+ - admin
+ responses:
+ '200':
+ description: No response body
+ /maps/{id}/:
+ get:
+ operationId: maps_retrieve
+ description: |-
+ Base view for detail views and editing.
+
+ We only allow partial updates because full updates always cause issues when two users are editing.
+
+ e.g. Bob and Alice both have the page open. Alice updates an object, Bob doesn't refresh his page and updates
+ the object. Bob's data doesn't have Alice's updates, so his stale data overwrites Alice's.
+ parameters:
+ - in: path
+ name: id
+ schema:
+ type: string
+ format: uuid
+ required: true
+ tags:
+ - maps
+ responses:
+ '200':
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/CncMapBase'
+ description: ''
+ put:
+ operationId: maps_update
+ description: |-
+ Base view for detail views and editing.
+
+ We only allow partial updates because full updates always cause issues when two users are editing.
+
+ e.g. Bob and Alice both have the page open. Alice updates an object, Bob doesn't refresh his page and updates
+ the object. Bob's data doesn't have Alice's updates, so his stale data overwrites Alice's.
+ parameters:
+ - in: path
+ name: id
+ schema:
+ type: string
+ format: uuid
+ required: true
+ tags:
+ - maps
+ requestBody:
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/CncMapBase'
+ application/x-www-form-urlencoded:
+ schema:
+ $ref: '#/components/schemas/CncMapBase'
+ multipart/form-data:
+ schema:
+ $ref: '#/components/schemas/CncMapBase'
+ required: true
+ responses:
+ '200':
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/CncMapBase'
+ description: ''
+ patch:
+ operationId: maps_partial_update
+ description: |-
+ Base view for detail views and editing.
+
+ We only allow partial updates because full updates always cause issues when two users are editing.
+
+ e.g. Bob and Alice both have the page open. Alice updates an object, Bob doesn't refresh his page and updates
+ the object. Bob's data doesn't have Alice's updates, so his stale data overwrites Alice's.
+ parameters:
+ - in: path
+ name: id
+ schema:
+ type: string
+ format: uuid
+ required: true
+ tags:
+ - maps
+ requestBody:
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/PatchedCncMapBase'
+ application/x-www-form-urlencoded:
+ schema:
+ $ref: '#/components/schemas/PatchedCncMapBase'
+ multipart/form-data:
+ schema:
+ $ref: '#/components/schemas/PatchedCncMapBase'
+ responses:
+ '200':
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/CncMapBase'
+ description: ''
+ delete:
+ operationId: maps_destroy
+ description: |-
+ Base view for detail views and editing.
+
+ We only allow partial updates because full updates always cause issues when two users are editing.
+
+ e.g. Bob and Alice both have the page open. Alice updates an object, Bob doesn't refresh his page and updates
+ the object. Bob's data doesn't have Alice's updates, so his stale data overwrites Alice's.
+ parameters:
+ - in: path
+ name: id
+ schema:
+ type: string
+ format: uuid
+ required: true
+ tags:
+ - maps
+ responses:
+ '204':
+ description: No response body
+ /maps/categories/:
+ get:
+ operationId: maps_categories_list
+ description: |-
+ Base view for listing and creating objects.
+
+ It is up to subclasses to figure out how they want to filter large queries.
+ parameters:
+ - name: limit
+ required: false
+ in: query
+ description: Number of results to return per page.
+ schema:
+ type: integer
+ - name: offset
+ required: false
+ in: query
+ description: The initial index from which to return the results.
+ schema:
+ type: integer
+ tags:
+ - maps
+ responses:
+ '200':
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/PaginatedMapCategoryList'
+ description: ''
+ post:
+ operationId: maps_categories_create
+ description: |-
+ Base view for listing and creating objects.
+
+ It is up to subclasses to figure out how they want to filter large queries.
+ tags:
+ - maps
+ requestBody:
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/MapCategory'
+ application/x-www-form-urlencoded:
+ schema:
+ $ref: '#/components/schemas/MapCategory'
+ multipart/form-data:
+ schema:
+ $ref: '#/components/schemas/MapCategory'
+ required: true
+ responses:
+ '201':
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/MapCategory'
+ description: ''
+ /maps/client/upload/:
+ post:
+ operationId: maps_client_upload_create
+ tags:
+ - maps
+ security:
+ - {}
+ responses:
+ '200':
+ description: No response body
+ /maps/delete/{id}/:
+ delete:
+ operationId: maps_delete_destroy
+ description: |-
+ Base view for all delete endpoints in the app.
+
+ For now, only admins can delete stuff.
+ parameters:
+ - in: path
+ name: id
+ schema:
+ type: string
+ format: uuid
+ required: true
+ tags:
+ - maps
+ responses:
+ '204':
+ description: No response body
+ /maps/search/:
+ get:
+ operationId: maps_search_list
+ description: The view for maps.
+ parameters:
+ - in: query
+ name: categories
+ schema:
+ type: array
+ items:
+ type: string
+ format: uuid
+ explode: true
+ style: form
+ - in: query
+ name: cnc_game
+ schema:
+ type: array
+ items:
+ type: string
+ format: uuid
+ explode: true
+ style: form
+ - in: query
+ name: include_edits
+ schema:
+ type: boolean
+ - in: query
+ name: is_legacy
+ schema:
+ type: boolean
+ - in: query
+ name: is_reviewed
+ schema:
+ type: boolean
+ - name: limit
+ required: false
+ in: query
+ description: Number of results to return per page.
+ schema:
+ type: integer
+ - name: offset
+ required: false
+ in: query
+ description: The initial index from which to return the results.
+ schema:
+ type: integer
+ - name: ordering
+ required: false
+ in: query
+ description: Which field to use when ordering the results.
+ schema:
+ type: string
+ - in: query
+ name: parent
+ schema:
+ type: string
+ format: uuid
+ - name: search
+ required: false
+ in: query
+ description: A search term.
+ schema:
+ type: string
+ tags:
+ - maps
+ responses:
+ '200':
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/PaginatedCncMapBaseList'
+ description: ''
+ post:
+ operationId: maps_search_create
+ description: The view for maps.
+ tags:
+ - maps
+ requestBody:
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/CncMapBase'
+ application/x-www-form-urlencoded:
+ schema:
+ $ref: '#/components/schemas/CncMapBase'
+ multipart/form-data:
+ schema:
+ $ref: '#/components/schemas/CncMapBase'
+ required: true
+ responses:
+ '201':
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/CncMapBase'
+ description: ''
+ /maps/upload/:
+ post:
+ operationId: maps_upload_create
+ tags:
+ - maps
+ responses:
+ '200':
+ description: No response body
+ /test/jwt:
+ get:
+ operationId: test_jwt_retrieve
+ description: Test JWT tokens. Only for use in tests.
+ tags:
+ - test
+ responses:
+ '200':
+ description: No response body
+ /ui-permissions/:
+ get:
+ operationId: ui_permissions_retrieve
+ description: |-
+ End point to check which buttons / views the UI should show.
+
+ The UI showing the buttons / views will not guarantee access. The backend still checks permissions for all
+ requests. This just helps the UI know what to render. DO NOT use for permission checks within Kirovy.
+ tags:
+ - ui-permissions
+ responses:
+ '200':
+ description: No response body
+components:
+ schemas:
+ CncMapBase:
+ type: object
+ description: Base serializer for any model that mixes in :class:`~kirovy.models.cnc_user.CncNetUserOwnedModel`
+ properties:
+ id:
+ type: string
+ format: uuid
+ readOnly: true
+ created:
+ type: string
+ format: date-time
+ readOnly: true
+ modified:
+ type: string
+ format: date-time
+ readOnly: true
+ cnc_user_id:
+ type: string
+ format: uuid
+ map_name:
+ type: string
+ minLength: 3
+ description:
+ type: string
+ minLength: 10
+ cnc_game_id:
+ type: string
+ format: uuid
+ category_ids:
+ type: array
+ items:
+ type: string
+ format: uuid
+ is_published:
+ type: boolean
+ default: false
+ is_temporary:
+ type: boolean
+ readOnly: true
+ is_reviewed:
+ type: boolean
+ readOnly: true
+ is_banned:
+ type: boolean
+ readOnly: true
+ is_legacy:
+ type: boolean
+ readOnly: true
+ legacy_upload_date:
+ type: string
+ format: date-time
+ readOnly: true
+ required:
+ - category_ids
+ - cnc_game_id
+ - cnc_user_id
+ - created
+ - description
+ - id
+ - is_banned
+ - is_legacy
+ - is_reviewed
+ - is_temporary
+ - legacy_upload_date
+ - map_name
+ - modified
+ MapCategory:
+ type: object
+ description: Base serializer for Kirovy models.
+ properties:
+ id:
+ type: string
+ format: uuid
+ readOnly: true
+ created:
+ type: string
+ format: date-time
+ readOnly: true
+ modified:
+ type: string
+ format: date-time
+ readOnly: true
+ name:
+ type: string
+ minLength: 3
+ slug:
+ type: string
+ readOnly: true
+ minLength: 2
+ required:
+ - created
+ - id
+ - modified
+ - name
+ - slug
+ PaginatedCncMapBaseList:
+ type: object
+ required:
+ - count
+ - results
+ properties:
+ count:
+ type: integer
+ example: 123
+ next:
+ type: string
+ nullable: true
+ format: uri
+ example: http://api.example.org/accounts/?offset=400&limit=100
+ previous:
+ type: string
+ nullable: true
+ format: uri
+ example: http://api.example.org/accounts/?offset=200&limit=100
+ results:
+ type: array
+ items:
+ $ref: '#/components/schemas/CncMapBase'
+ PaginatedMapCategoryList:
+ type: object
+ required:
+ - count
+ - results
+ properties:
+ count:
+ type: integer
+ example: 123
+ next:
+ type: string
+ nullable: true
+ format: uri
+ example: http://api.example.org/accounts/?offset=400&limit=100
+ previous:
+ type: string
+ nullable: true
+ format: uri
+ example: http://api.example.org/accounts/?offset=200&limit=100
+ results:
+ type: array
+ items:
+ $ref: '#/components/schemas/MapCategory'
+ PatchedCncMapBase:
+ type: object
+ description: Base serializer for any model that mixes in :class:`~kirovy.models.cnc_user.CncNetUserOwnedModel`
+ properties:
+ id:
+ type: string
+ format: uuid
+ readOnly: true
+ created:
+ type: string
+ format: date-time
+ readOnly: true
+ modified:
+ type: string
+ format: date-time
+ readOnly: true
+ cnc_user_id:
+ type: string
+ format: uuid
+ map_name:
+ type: string
+ minLength: 3
+ description:
+ type: string
+ minLength: 10
+ cnc_game_id:
+ type: string
+ format: uuid
+ category_ids:
+ type: array
+ items:
+ type: string
+ format: uuid
+ is_published:
+ type: boolean
+ default: false
+ is_temporary:
+ type: boolean
+ readOnly: true
+ is_reviewed:
+ type: boolean
+ readOnly: true
+ is_banned:
+ type: boolean
+ readOnly: true
+ is_legacy:
+ type: boolean
+ readOnly: true
+ legacy_upload_date:
+ type: string
+ format: date-time
+ readOnly: true
diff --git a/tests/models/test_cnc_game.py b/tests/models/test_cnc_game.py
index 7e4e21a..d518973 100644
--- a/tests/models/test_cnc_game.py
+++ b/tests/models/test_cnc_game.py
@@ -42,7 +42,8 @@ def _make(ext: str) -> CncFileExtension:
def test_cnc_game_extensions_set(db):
- extension_set = {"exe", "mp4", "mp3", "mp5", "zip"}
+ """Test that the allowed extension set only shows extensions linked to the game."""
+ extension_set = {"exe", "mp4", "mp3", "mp5"}
cnc_extensions: t.List[CncFileExtension] = []
for extension in extension_set:
cnc_extension = CncFileExtension(extension=extension)
diff --git a/tests/test_views/test_backwards_compatibility.py b/tests/test_views/test_backwards_compatibility.py
new file mode 100644
index 0000000..f465639
--- /dev/null
+++ b/tests/test_views/test_backwards_compatibility.py
@@ -0,0 +1,30 @@
+import hashlib
+import zipfile
+import io
+
+import pytest
+from django.http import FileResponse
+from rest_framework import status
+
+from kirovy.models import CncGame
+
+
+@pytest.mark.parametrize("game_slug", ["td", "ra", "ts", "dta", "yr", "d2"])
+def test_map_download_backwards_compatible(
+ create_cnc_map, create_cnc_map_file, file_map_desert, client_anonymous, game_slug
+):
+ """Test that we can properly fetch a map with the backwards compatible endpoints."""
+ game = CncGame.objects.get(slug__iexact=game_slug)
+ cnc_map = create_cnc_map(is_temporary=True, cnc_game=game)
+ map_file = create_cnc_map_file(file_map_desert, cnc_map)
+
+ response: FileResponse = client_anonymous.get(f"/{game_slug}/{map_file.hash_sha1}")
+
+ assert response.status_code == status.HTTP_200_OK
+ file_content_io = io.BytesIO(response.getvalue())
+ zip_file = zipfile.ZipFile(file_content_io)
+ assert zip_file
+
+ map_from_zip = zip_file.read(f"{map_file.hash_sha1}.map")
+ downloaded_map_hash = hashlib.sha1(map_from_zip).hexdigest()
+ assert downloaded_map_hash == map_file.hash_sha1
diff --git a/tests/test_views/test_map_upload.py b/tests/test_views/test_map_upload.py
index e5fb9b9..1cc6d03 100644
--- a/tests/test_views/test_map_upload.py
+++ b/tests/test_views/test_map_upload.py
@@ -41,7 +41,7 @@ def test_map_file_upload_happy_path(client_user, file_map_desert, game_yuri, ext
map_filename = pathlib.Path(uploaded_file_url).name.replace(".zip", "")
# Extract the map from the zipfile, and convert to something that the map parser understands
_unzip_io = io.BytesIO()
- _unzip_io.write(zipfile.ZipFile(uploaded_zipped_file).read(map_filename))
+ _unzip_io.write(zipfile.ZipFile(uploaded_zipped_file).read(response.data["result"]["sha1"] + ".map"))
_unzip_io.seek(0)
uploaded_file = UploadedFile(_unzip_io)
diff --git a/web_entry_point.sh b/web_entry_point.sh
index 6b030c0..3019f9d 100755
--- a/web_entry_point.sh
+++ b/web_entry_point.sh
@@ -1,5 +1,5 @@
# Don't run this file manually. It's the entry point for the dockerfile.
-python manage.py collectstatic --no-input
+python manage.py collectstatic --noinput
python manage.py migrate
# `python manage.py runserver`, but with gunicorn instead.
gunicorn "kirovy.wsgi:application" --bind "0.0.0.0:8000"