Skip to content

Commit d9d6803

Browse files
authored
feat: Backwards compatibility (#17)
- Fix docker compose for launching tests via pycharm. - Make a constant for `RUN_ENVIRONMENT`. - Tiberian sun backwards compatibility - Add random red alert map that I found on cncnet db. - Red Alert backwards compatible upload - Stop using `ZipStorage`. Revisit it at a later time. - Add support for dta - Dune 2000 wip - Fix download endpoint for backwards compatible client - Make sure tests end urls in `.zip` for backwards compatible uploads - Fix tests for filtering on `is_backwards_compatible` - Stop crashing when optional files are missing - Fix tests from rebase - Dune 2000 mis - Tiberian dawn validator - Add test for tiberian dawn - Tidy up the config parser usage
1 parent 8c94e8e commit d9d6803

36 files changed

+18977
-151
lines changed

docker-compose.yml

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,3 +43,22 @@ services:
4343
- "80:80"
4444
depends_on:
4545
- django
46+
47+
debugger:
48+
# Use this for launching via a debugger like PyCharm or VSCode.
49+
# Just builds, and doesn't execute anything, your debugger will be in charge of executing.
50+
build:
51+
context: .
52+
dockerfile: docker/Dockerfile
53+
target: debugger
54+
volumes:
55+
- .:/cncnet-map-api
56+
- ./data/tmp:/tmp/pytest-of-root # For inspecting files during pytests
57+
env_file:
58+
- .env
59+
environment:
60+
POSTGRES_TEST_HOST: mapdb-postgres-dev # Necessary to connect to docker db. Overrides the .env setting.
61+
# ports:
62+
# - "80:80"
63+
depends_on:
64+
- db

docker/Dockerfile

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,3 +31,6 @@ COPY . /cncnet-map-api
3131

3232
ENTRYPOINT "/cncnet-map-api/web_entry_point.sh"
3333

34+
FROM base AS debugger
35+
# Just build, but don't run anything. Your debugger will run pytest, manage.py, etc for you.
36+
RUN CFLAGS=-I$(brew --prefix)/include LDFLAGS=-L$(brew --prefix)/lib pip install -r ./requirements-dev.txt

example.env

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,3 +45,6 @@ TESTING_API_USERNAME=
4545

4646
# Required for pytest. An account password to use for tests that need a JWT. DO NOT COMMIT
4747
TESTING_API_PASSWORD=
48+
49+
# Options are in `kirovy.constants.RunEnvironment`
50+
RUN_ENVIRONMENT=

kirovy/constants/api_codes.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,4 @@ class LegacyUploadApiCodes(enum.StrEnum):
1919
HASH_MISMATCH = "file-hash-does-not-match-zip-name"
2020
INVALID_FILE_TYPE = "invalid-file-type-in-zip"
2121
GAME_NOT_SUPPORTED = "game-not-supported"
22+
MAP_FAILED_TO_PARSE = "map-failed-to-parse"
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# Generated by Django 4.2.20 on 2025-03-23 23:32
2+
3+
from django.db import migrations, models
4+
import kirovy.models.file_base
5+
6+
7+
class Migration(migrations.Migration):
8+
9+
dependencies = [
10+
("kirovy", "0012_raw_zip_extension"),
11+
]
12+
13+
operations = [
14+
migrations.AddField(
15+
model_name="cncmap",
16+
name="is_mapdb1_compatible",
17+
field=models.BooleanField(default=False),
18+
),
19+
migrations.AlterField(
20+
model_name="cncmapfile",
21+
name="file",
22+
field=models.FileField(upload_to=kirovy.models.file_base._generate_upload_to),
23+
),
24+
]

kirovy/models/cnc_map.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import pathlib
2+
from uuid import UUID
23

34
from django.conf import settings
45
from django.db import models
@@ -117,6 +118,12 @@ class CncMap(cnc_user.CncNetUserOwnedModel):
117118
)
118119
"""If set, then this map is a child of ``parent``. Used to track edits of other peoples' maps."""
119120

121+
is_mapdb1_compatible = models.BooleanField(default=False)
122+
"""If true, then this map was uploaded by a legacy CnCNet client and is backwards compatible with map db 1.0.
123+
124+
This should never be set for maps uploaded via the web UI.
125+
"""
126+
120127
def next_version_number(self) -> int:
121128
"""Generate the next version to use for a map file.
122129
@@ -158,14 +165,26 @@ def set_ban(self, is_banned: bool, banned_by: cnc_user.CncUser) -> None:
158165
self.save(update_fields=["is_banned"])
159166

160167

161-
class CncMapFile(file_base.CncNetZippedFileBaseModel):
168+
class CncMapFileManager(models.Manager["CncMapFile"]):
169+
def find_legacy_map_by_sha1(self, sha1: str, game_id: UUID) -> t.Union["CncMapFile", None]:
170+
return (
171+
super()
172+
.get_queryset()
173+
.filter(hash_sha1=sha1, cnc_game_id=game_id, cnc_map__is_mapdb1_compatible=True)
174+
.first()
175+
)
176+
177+
178+
class CncMapFile(file_base.CncNetFileBaseModel):
162179
"""Represents the actual map file that a Command & Conquer game reads.
163180
164181
.. warning::
165182
166183
``name`` is auto-generated for this file subclass.
167184
"""
168185

186+
objects = CncMapFileManager()
187+
169188
width = models.IntegerField()
170189
height = models.IntegerField()
171190
version = models.IntegerField(editable=False)

kirovy/models/cnc_user.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ class CncUserManager(models.Manager):
1919
constants.LegacyUploadUser.CNCNET_ID,
2020
}
2121

22-
def find_by_cncnet_id(self, cncnet_id: int) -> t.Tuple["CncUser"]:
22+
def find_by_cncnet_id(self, cncnet_id: int) -> t.Union["CncUser", None]:
2323
return super().get_queryset().filter(cncnet_id=cncnet_id).first()
2424

2525
def get_or_create_migration_user(self) -> "CncUser":

kirovy/services/cnc_gen_2_services.py

Lines changed: 32 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -35,11 +35,39 @@ class CncGen2MapSections(enum.StrEnum):
3535
DIGEST = "Digest"
3636

3737

38+
class ParseErrorMsg(enum.StrEnum):
39+
NO_BINARY = _("Binary files not allowed.")
40+
CORRUPT_MAP = _("Could not parse map file.")
41+
MISSING_INI = _("Missing necessary INI sections.")
42+
43+
3844
class MapConfigParser(configparser.ConfigParser):
3945
"""Config parser with some helpers."""
4046

4147
NAME_NOT_FOUND: str = "Map name not found in file"
4248

49+
@classmethod
50+
def from_file(cls, file: File) -> "MapConfigParser":
51+
parser = cls()
52+
parser.read_django_file(file)
53+
return parser
54+
55+
def read_django_file(self, file: File):
56+
if file.closed:
57+
file.open("r")
58+
file.seek(0)
59+
try:
60+
# We can't use ConfigParser.read_file because parser expects the file to be read as a string,
61+
# but django uploaded files are read as bytes. So we need to convert to string first.
62+
# If `decode` is crashing in a test, make sure your test file is read in read-mode "rb".
63+
self.read_string(file.read().decode())
64+
except configparser.ParsingError as e:
65+
raise exceptions.InvalidMapFile(
66+
ParseErrorMsg.CORRUPT_MAP,
67+
code=ParseErrorMsg.CORRUPT_MAP.name,
68+
params={"e": e},
69+
)
70+
4371
def optionxform(self, optionstr: str) -> str:
4472
"""Overwrite the base class to prevent lower-casing keys."""
4573
return optionstr
@@ -93,11 +121,6 @@ class CncGen2MapParser:
93121
CncGen2MapSections.DIGEST.value,
94122
}
95123

96-
class ErrorMsg(enum.StrEnum):
97-
NO_BINARY = _("Binary files not allowed.")
98-
CORRUPT_MAP = _("Could not parse map file.")
99-
MISSING_INI = _("Missing necessary INI sections.")
100-
101124
def __init__(self, uploaded_file: UploadedFile | File):
102125
self.validate_file_type(uploaded_file)
103126
self.file = uploaded_file
@@ -114,27 +137,13 @@ def _parse_file(self) -> None:
114137
:return:
115138
Nothing, but :attr:`~kirovy.services.MapParserService.parser` will be modified.
116139
"""
117-
if self.file.closed:
118-
self.file.open("r")
119-
120-
try:
121-
# We can't use read_file because parser expects the file to be read as a string,
122-
# but django uploaded files are read as bytes. So we need to convert to string first.
123-
# If `decode` is crashing in a test, make sure your test file is read in read-mode "rb".
124-
self.ini.read_string(self.file.read().decode())
125-
except configparser.ParsingError as e:
126-
raise exceptions.InvalidMapFile(
127-
self.ErrorMsg.CORRUPT_MAP,
128-
code=self.ErrorMsg.CORRUPT_MAP.name,
129-
params={"e": e},
130-
)
131-
140+
self.ini.read_django_file(self.file)
132141
sections: t.Set[str] = set(self.ini.sections())
133142
missing_sections = self.required_sections - sections
134143
if missing_sections:
135144
raise exceptions.InvalidMapFile(
136-
self.ErrorMsg.MISSING_INI,
137-
code=self.ErrorMsg.MISSING_INI.name,
145+
ParseErrorMsg.MISSING_INI,
146+
code=ParseErrorMsg.MISSING_INI.name,
138147
params={"missing": missing_sections},
139148
)
140149

@@ -156,7 +165,7 @@ def validate_file_type(self, uploaded_file: File) -> None:
156165
Raised if file is binary.
157166
"""
158167
if self.is_binary(uploaded_file):
159-
raise exceptions.InvalidMimeType(self.ErrorMsg.NO_BINARY)
168+
raise exceptions.InvalidMimeType(ParseErrorMsg.NO_BINARY)
160169

161170
@classmethod
162171
def is_binary(cls, uploaded_file: File) -> bool:

kirovy/services/legacy_upload/__init__.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,18 @@
11
from kirovy.constants import GameSlugs
22
from kirovy.constants.api_codes import LegacyUploadApiCodes
33
from kirovy.exceptions import view_exceptions
4-
from kirovy.services.legacy_upload import westwood, dune_2000
4+
from kirovy.services.legacy_upload import westwood, dune_2000, tiberian_dawn
55
from kirovy.services.legacy_upload.base import LegacyMapServiceBase
66
from kirovy import typing as t
77

88

99
_GAME_LEGACY_SERVICE_MAP: t.Dict[str, t.Type[LegacyMapServiceBase]] = {
1010
GameSlugs.yuris_revenge.value: westwood.YurisRevengeLegacyMapService,
1111
GameSlugs.dune_2000.value: dune_2000.Dune2000LegacyMapService,
12+
GameSlugs.tiberian_sun.value: westwood.TiberianSunLegacyMapService,
13+
GameSlugs.red_alert.value: westwood.RedAlertLegacyMapService,
14+
GameSlugs.dawn_of_the_tiberium_age.value: westwood.DawnOfTheTiberiumAgeLegacyMapService,
15+
GameSlugs.tiberian_dawn.value: tiberian_dawn.TiberianDawnLegacyMapService,
1216
}
1317

1418

kirovy/services/legacy_upload/base.py

Lines changed: 23 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import dataclasses
2-
import functools
32
import io
43
import pathlib
54
import zipfile
@@ -8,18 +7,18 @@
87
from django.core.files.base import ContentFile
98
from django.core.files.uploadedfile import UploadedFile
109

11-
from kirovy import typing as t, constants
10+
from kirovy import typing as t, constants, exceptions
1211
from kirovy.constants.api_codes import LegacyUploadApiCodes
1312
from kirovy.exceptions import view_exceptions
14-
from kirovy.services.cnc_gen_2_services import CncGen2MapParser
13+
from kirovy.services.cnc_gen_2_services import MapConfigParser, CncGen2MapParser
1514
from kirovy.utils import file_utils
1615
from kirovy.utils.file_utils import ByteSized
1716

1817

1918
@dataclasses.dataclass
2019
class ExpectedFile:
2120
possible_extensions: t.Set[str]
22-
file_validator: t.Callable[[str, ContentFile, zipfile.ZipInfo], bool]
21+
file_validator: t.Callable[[str, ContentFile, zipfile.ZipInfo], None]
2322
required: bool = True
2423
"""attr: If false, this file is not required to be present."""
2524

@@ -53,15 +52,15 @@ def expected_files(self) -> t.List[ExpectedFile]:
5352
raise NotImplementedError("This Game's map validator hasn't implemented the expectd file structure.")
5453

5554
def multi_file_validator(self):
56-
file_list = self._file.infolist()
55+
zip_file_list = self._file.infolist()
5756
min_files = len([x for x in self.expected_files if x.required])
5857
max_files = len(self.expected_files)
59-
if min_files > len(file_list) > max_files:
58+
if min_files > len(zip_file_list) > max_files:
6059
raise view_exceptions.KirovyValidationError(
6160
"Incorrect file count", code=LegacyUploadApiCodes.BAD_ZIP_STRUCTURE
6261
)
6362

64-
for file_info in file_list:
63+
for file_info in zip_file_list:
6564
expected_file = self._get_expected_file_for_extension(file_info)
6665
expected_file.file_validator(self._file.filename, ContentFile(self._file.read(file_info)), file_info)
6766

@@ -101,8 +100,11 @@ def file_contents_merged(self) -> io.BytesIO:
101100
"""
102101
output = io.BytesIO()
103102
for expected_file in self.expected_files:
104-
file_info = self._find_file_info_by_extension(expected_file.possible_extensions)
105-
output.write(self._file.read(file_info))
103+
file_info = self._find_file_info_by_extension(
104+
expected_file.possible_extensions, is_required=expected_file.required
105+
)
106+
if file_info:
107+
output.write(self._file.read(file_info))
106108
output.seek(0)
107109
return output
108110

@@ -111,9 +113,13 @@ def map_name(self) -> str:
111113
ini_file_info = self._find_file_info_by_extension(self.ini_extensions)
112114
fallback = f"legacy_client_upload_{self.map_sha1_from_filename}"
113115
ini_file = ContentFile(self._file.read(ini_file_info))
114-
return CncGen2MapParser(ini_file).ini.get("Basic", "Name", fallback=fallback)
116+
try:
117+
return MapConfigParser.from_file(ini_file).get("Basic", "Name", fallback=fallback)
118+
except exceptions.InvalidMapFile:
119+
# Having a bad map name for a temporary upload is better than a 500 error.
120+
return fallback
115121

116-
def _find_file_info_by_extension(self, extensions: t.Set[str]) -> zipfile.ZipInfo:
122+
def _find_file_info_by_extension(self, extensions: t.Set[str], is_required: bool = True) -> zipfile.ZipInfo | None:
117123
"""Find the zipinfo object for a file by a set of possible file extensions.
118124
119125
This is meant to be used to find specific files in the zip.
@@ -132,11 +138,12 @@ def _find_file_info_by_extension(self, extensions: t.Set[str]) -> zipfile.ZipInf
132138
for file in self._file.infolist():
133139
if pathlib.Path(file.filename).suffix in extensions:
134140
return file
135-
raise view_exceptions.KirovyValidationError(
136-
"No file matching the expected extensions was found",
137-
LegacyUploadApiCodes.NO_VALID_MAP_FILE,
138-
{"expected": extensions},
139-
)
141+
if is_required:
142+
raise view_exceptions.KirovyValidationError(
143+
"No file matching the expected extensions was found",
144+
LegacyUploadApiCodes.NO_VALID_MAP_FILE,
145+
{"expected": extensions},
146+
)
140147

141148
def _get_expected_file_for_extension(self, zip_info: zipfile.ZipInfo) -> ExpectedFile:
142149
"""Get the ``expected_file`` class instance corresponding to the file in the zipfile.

0 commit comments

Comments
 (0)