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"