Skip to content

Commit 0e60b5a

Browse files
authored
feat: Zip uploaded maps (#11)
1 parent 5739813 commit 0e60b5a

File tree

6 files changed

+71
-5
lines changed

6 files changed

+71
-5
lines changed

docker-compose.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ services:
6464
dockerfile: test.DockerFile
6565
volumes:
6666
- .:/cncnet-map-api
67+
- ./data/tmp:/tmp/pytest-of-root # For inspecting files during pytests
6768
env_file:
6869
- .env
6970
environment:

kirovy/models/cnc_map.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,7 @@ def set_ban(self, is_banned: bool, banned_by: cnc_user.CncUser) -> None:
158158
self.save(update_fields=["is_banned"])
159159

160160

161-
class CncMapFile(file_base.CncNetFileBaseModel):
161+
class CncMapFile(file_base.CncNetZippedFileBaseModel):
162162
"""Represents the actual map file that a Command & Conquer game reads.
163163
164164
.. warning::

kirovy/models/file_base.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from kirovy.models.cnc_base_model import CncNetBaseModel
77
from kirovy.models import cnc_game as game_models
88
from kirovy.utils import file_utils
9+
from kirovy.zip_storage import ZipFileStorage
910

1011

1112
def _generate_upload_to(instance: "CncNetFileBaseModel", filename: t.Union[str, pathlib.Path]) -> pathlib.Path:
@@ -116,3 +117,16 @@ def generate_upload_to(instance: "CncNetFileBaseModel", filename: str) -> pathli
116117
str(instance.id),
117118
filename.name,
118119
)
120+
121+
122+
class CncNetZippedFileBaseModel(CncNetFileBaseModel):
123+
"""A base file class that will zip and unzip the ``file`` attribute.
124+
125+
Do **not** use this class for files that will be directly accessed via hyperlink.
126+
e.g. don't use this class for images.
127+
"""
128+
129+
class Meta:
130+
abstract = True
131+
132+
file = models.FileField(null=False, upload_to=_generate_upload_to, storage=ZipFileStorage)

kirovy/zip_storage.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import io
2+
import pathlib
3+
import zipfile
4+
5+
from django.core.files import File
6+
from django.core.files.storage import FileSystemStorage
7+
8+
9+
class ZipFileStorage(FileSystemStorage):
10+
def save(self, name: str, content: File, max_length: int | None = None):
11+
if is_zipfile(content):
12+
return super().save(name, content, max_length)
13+
14+
internal_filename = pathlib.Path(name).name
15+
zip_buffer = io.BytesIO()
16+
with zipfile.ZipFile(zip_buffer, "w", allowZip64=False, compresslevel=4) as zf:
17+
content.seek(0)
18+
zf.writestr(internal_filename, content.read())
19+
20+
return super().save(f"{name}.zip", zip_buffer, max_length=max_length)
21+
22+
23+
def is_zipfile(file_or_path: File | pathlib.Path) -> bool:
24+
"""Checks if a file is a zip file.
25+
26+
:param file_or_path:
27+
The path to a file, or the file itself, to check.
28+
:returns:
29+
``True`` if the file is a zip file.
30+
"""
31+
try:
32+
with zipfile.ZipFile(file_or_path, "r") as zf:
33+
# zf.getinfo("") # check if zipfile is valid.
34+
return True
35+
except zipfile.BadZipfile:
36+
return False
37+
except Exception as e:
38+
raise e

tests/models/test_cnc_map.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ def test_cnc_map_generate_upload_to(game_yuri, extension_map, file_map_desert, c
2424
"yr",
2525
"worlds",
2626
cnc_map.id.hex,
27-
f"yr_{cnc_map.id.hex}_v01.map",
27+
f"yr_{cnc_map.id.hex}_v01.map.zip", # We use the ZipFileStorage for maps.
2828
)
2929
saved_map = CncMapFile(
3030
height=117, # doesn't matter for this test.

tests/test_views/test_map_upload.py

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import pathlib
2+
import zipfile
3+
import io
24

35
from django.core.files.uploadedfile import UploadedFile
46
from rest_framework import status
@@ -26,18 +28,29 @@ def test_map_file_upload_happy_path(client_user, file_map_desert, game_yuri, ext
2628

2729
uploaded_file_url: str = response.data["result"]["cnc_map_file"]
2830
uploaded_image_url: str = response.data["result"]["extracted_preview_file"]
31+
32+
# We need to strip the url path off of the files,
33+
# then check the tmp directory to make sure the uploaded files were saved
2934
strip_media_url = f"/{settings.MEDIA_URL}"
30-
uploaded_file = pathlib.Path(tmp_media_root) / uploaded_file_url.lstrip(strip_media_url)
35+
uploaded_zipped_file = pathlib.Path(tmp_media_root) / uploaded_file_url.lstrip(strip_media_url)
3136
uploaded_image = pathlib.Path(tmp_media_root) / uploaded_image_url.lstrip(strip_media_url)
32-
assert uploaded_file.exists()
37+
assert uploaded_zipped_file.exists()
3338
assert uploaded_image.exists()
3439

40+
# We need to unzip the map so that we can actually verify the saved map contents.
41+
map_filename = pathlib.Path(uploaded_file_url).name.replace(".zip", "")
42+
# Extract the map from the zipfile, and convert to something that the map parser understands
43+
_unzip_io = io.BytesIO()
44+
_unzip_io.write(zipfile.ZipFile(uploaded_zipped_file).read(map_filename))
45+
_unzip_io.seek(0)
46+
uploaded_file = UploadedFile(_unzip_io)
47+
3548
file_response = client_user.get(uploaded_file_url)
3649
image_response = client_user.get(uploaded_image_url)
3750
assert file_response.status_code == status.HTTP_200_OK
3851
assert image_response.status_code == status.HTTP_200_OK
3952

40-
parser = CncGen2MapParser(UploadedFile(open(uploaded_file, "rb")))
53+
parser = CncGen2MapParser(uploaded_file)
4154
assert parser.ini.get("CnCNet", "ID") == str(response.data["result"]["cnc_map_id"])
4255

4356
map_object = CncMap.objects.get(id=response.data["result"]["cnc_map_id"])

0 commit comments

Comments
 (0)