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
37 changes: 37 additions & 0 deletions docker-compose.debugger.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# A copy of the docker compose, specifically for debuggers.
# Will not run django by default, your debugger needs to run ``manage.py``.
services:
db:
container_name: mapdb-postgres-debugger
image: postgres
volumes:
- ${POSTGRES_DATA_DIR}/debugger-db/:/var/lib/postgresql/data
environment:
- POSTGRES_DB=${POSTGRES_DB}
- POSTGRES_USER=${POSTGRES_USER}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
env_file:
- .env
ports:
- "127.0.0.1:${POSTGRES_PORT}:${POSTGRES_PORT}"
command: -p ${POSTGRES_PORT}

debugger-django:
container_name: mapdb-django-debugger
build:
context: .
dockerfile: docker/Dockerfile
target: debugger
volumes:
- .:/cncnet-map-api
- ${HOST_MEDIA_ROOT}:/data/cncnet_silo # django will save user-uploaded files here. MEDIA_ROOT
- ${HOST_STATIC_ROOT}:/data/cncnet_static # django will gather static files here. STATIC_ROOT
- ./data/tmp:/tmp/pytest-of-root # For inspecting files during pytests
ports:
- "8000:8000"
env_file:
- .env
environment:
POSTGRES_TEST_HOST: db # Necessary to connect to docker db. Overrides the .env setting.
depends_on:
- db
19 changes: 0 additions & 19 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -43,22 +43,3 @@ services:
- "80:80"
depends_on:
- django

debugger:
# Use this for launching via a debugger like PyCharm or VSCode.
# Just builds, and doesn't execute anything, your debugger will be in charge of executing.
build:
context: .
dockerfile: docker/Dockerfile
target: debugger
volumes:
- .:/cncnet-map-api
- ./data/tmp:/tmp/pytest-of-root # For inspecting files during pytests
env_file:
- .env
environment:
POSTGRES_TEST_HOST: mapdb-postgres-dev # Necessary to connect to docker db. Overrides the .env setting.
# ports:
# - "80:80"
depends_on:
- db
1 change: 1 addition & 0 deletions kirovy/constants/api_codes.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ class UploadApiCodes(enum.StrEnum):
EMPTY_UPLOAD = "where-file"
DUPLICATE_MAP = "duplicate-map"
FILE_EXTENSION_NOT_SUPPORTED = "file-extension-not-supported"
INVALID = "invalid-data-upload"


class LegacyUploadApiCodes(enum.StrEnum):
Expand Down
51 changes: 51 additions & 0 deletions kirovy/migrations/0014_cncmapimagefile.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# Generated by Django 4.2.21 on 2025-05-19 17:59

from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import kirovy.models.file_base
import uuid


class Migration(migrations.Migration):

dependencies = [
("kirovy", "0013_cncmap_is_mapdb1_compatible_alter_cncmapfile_file"),
]

operations = [
migrations.CreateModel(
name="CncMapImageFile",
fields=[
("id", models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
("created", models.DateTimeField(auto_now_add=True, null=True)),
("modified", models.DateTimeField(auto_now=True, null=True)),
("name", models.CharField(max_length=255)),
("file", models.FileField(upload_to=kirovy.models.file_base._generate_upload_to)),
("hash_md5", models.CharField(max_length=32)),
("hash_sha512", models.CharField(max_length=512)),
("hash_sha1", models.CharField(max_length=50, null=True)),
("width", models.IntegerField()),
("height", models.IntegerField()),
("version", models.IntegerField(editable=False)),
("cnc_game", models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to="kirovy.cncgame")),
("cnc_map", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="kirovy.cncmap")),
(
"file_extension",
models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to="kirovy.cncfileextension"),
),
(
"last_modified_by",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="modified_%(class)s_set",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"abstract": False,
},
),
]
46 changes: 46 additions & 0 deletions kirovy/models/cnc_map.py
Original file line number Diff line number Diff line change
Expand Up @@ -227,3 +227,49 @@ def generate_upload_to(instance: "CncMapFile", filename: str) -> pathlib.Path:

# e.g. "yr/maps/CNC_NET_MAP_ID_HEX/ra2_CNC_NET_MAP_ID_HEX_v1.map
return pathlib.Path(instance.cnc_map.get_map_directory_path(), final_file_name)


class CncMapImageFile(file_base.CncNetFileBaseModel):
"""Represents an image file to display on the website for a map.

.. warning::

``name`` is auto-generated for this file subclass.
"""

objects = CncMapFileManager()

width = models.IntegerField()
height = models.IntegerField()
version = models.IntegerField(editable=False)

cnc_map = models.ForeignKey(CncMap, on_delete=models.CASCADE, null=False)

ALLOWED_EXTENSION_TYPES = {game_models.CncFileExtension.ExtensionTypes.IMAGE.value}

UPLOAD_TYPE = settings.CNC_MAP_DIRECTORY

def save(self, *args, **kwargs):
super().save(*args, **kwargs)

@staticmethod
def generate_upload_to(instance: "CncMapFile", filename: str) -> pathlib.Path:
"""Generate the path to upload map files to.

Gets called by :func:`kirovy.models.file_base._generate_upload_to` when ``CncMapImageFile.save`` is called.
See [the django docs for file fields](https://docs.djangoproject.com/en/5.0/ref/models/fields/#filefield).
``upload_to`` is set in :attr:`kirovy.models.file_base.CncNetFileBaseModel.file`, which calls
``_generate_upload_to``, which calls this function.

:param instance:
Acts as ``self``. The image file object that we are creating an upload path for.
:param filename:
The filename of the uploaded image file.
:return:
Path to upload map to relative to :attr:`~kirovy.settings.base.MEDIA_ROOT`.
"""
filename = pathlib.Path(filename)
final_file_name = f"{instance.name}{filename.suffix}"

# e.g. "yr/maps/CNC_NET_MAP_ID_HEX/screenshot_of_map.jpg
return pathlib.Path(instance.cnc_map.get_map_directory_path(), final_file_name)
2 changes: 1 addition & 1 deletion kirovy/serializers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ def get_fields(self):
"""
fields = super().get_fields()
request: t.Optional[KirovyRequest] = self.context.get("request")
if not all([request, request.user.is_authenticated, request.user.is_staff]):
if not (request and request.user.is_authenticated and request.user.is_staff):
fields.pop("last_modified_by_id", None)
return fields

Expand Down
28 changes: 22 additions & 6 deletions kirovy/serializers/cnc_map_serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ class Meta:
hash_sha512 = serializers.CharField(required=True, allow_blank=False)
hash_sha1 = serializers.CharField(required=True, allow_blank=False)

def create(self, validated_data: t) -> cnc_map.CncMapFile:
def create(self, validated_data: t.DictStrAny) -> cnc_map.CncMapFile:
map_file = cnc_map.CncMapFile(**validated_data)
map_file.save()
return map_file
Expand All @@ -106,7 +106,7 @@ class CncMapBaseSerializer(CncNetUserOwnedModelSerializer):
description = serializers.CharField(
required=True,
allow_null=False,
allow_blank=False,
allow_blank=True,
trim_whitespace=True,
min_length=10,
)
Expand All @@ -117,11 +117,9 @@ class CncMapBaseSerializer(CncNetUserOwnedModelSerializer):
)
category_ids = serializers.PrimaryKeyRelatedField(
source="categories",
queryset=cnc_map.MapCategory.objects.all(),
pk_field=serializers.UUIDField(),
many=True,
allow_null=False,
allow_empty=False,
read_only=True, # Set it manually.
)
is_published = serializers.BooleanField(
default=False,
Expand All @@ -139,9 +137,27 @@ class CncMapBaseSerializer(CncNetUserOwnedModelSerializer):
legacy_upload_date = serializers.DateTimeField(
read_only=True,
)
incomplete_upload = serializers.BooleanField(
default=False,
)

parent_id = serializers.PrimaryKeyRelatedField(
source="parent",
queryset=cnc_map.CncMap.objects.all(),
pk_field=serializers.UUIDField(),
many=False,
allow_null=True,
allow_empty=False,
default=None,
)

class Meta:
model = cnc_map.CncMap
# We return the ID instead of the whole object.
exclude = ["cnc_game", "categories"]
exclude = ["cnc_game", "categories", "parent"]
fields = "__all__"

def create(self, validated_data: t.DictStrAny) -> cnc_map.CncMap:
cnc_map_instance = cnc_map.CncMap(**validated_data)
cnc_map_instance.save()
return cnc_map_instance
2 changes: 1 addition & 1 deletion kirovy/services/cnc_gen_2_services.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ def read_django_file(self, file: File):
# We can't use ConfigParser.read_file because parser expects the file to be read as a string,
# but django uploaded files are read as bytes. So we need to convert to string first.
# If `decode` is crashing in a test, make sure your test file is read in read-mode "rb".
self.read_string(file.read().decode())
self.read_string(file.read().decode(errors="ignore"))
except configparser.ParsingError as e:
raise exceptions.InvalidMapFile(
ParseErrorMsg.CORRUPT_MAP,
Expand Down
4 changes: 4 additions & 0 deletions kirovy/settings/_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,10 @@
"""
str: The directory inside of :attr:`~kirovy.settings._base.STATIC_URL` where we store game-specific and mod-specific
logos and backgrounds. So a Red Alert 2 icon would be in e.g. ``URL/static/game_images/ra2/icons/allies.png``

.. warning::

This is **not** where we store user-uploaded images. Do not store them here.
"""


Expand Down
Empty file added kirovy/templates/__init__.py
Empty file.
59 changes: 59 additions & 0 deletions kirovy/templates/map_legacy_upload_ui.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CncNet Test Map Upload</title>
{% load static %}
<link rel="stylesheet" type="text/css" href="{% static 'bc-assets/bc-main.css' %}">
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans:wght@400;500;600&amp;display=swap" rel="stylesheet">
<link rel="icon" type="image/png" sizes="32x32" href="https://cncnet.org/favicon-32x32.png">
</head>
<body>
<div id="flex-main">
<div id="flex-container">
<div id="header">
<a class="navbar-brand" href="http://cncnet.org" title="CnCNet Home">
<img src="https://cncnet.org/build/assets/logo-ad41e578.svg" alt="CnCNet logo" loading="lazy" class="logo-full">
<span class="logo-tagline">
Keeping C&amp;C Alive Since 2009
</span>
</a>
<h1>CnCNet 5 client upload test form</h1>
</div>
<div id="content">
<p>
Select a <span class="inline-code"><span class="it">&lt;sha1&gt;</span>.zip</span> from your file system.
It should contain the <span class="inline-code">.mpr</span> file for
Red Alert or <span class="inline-code">.ini</span> and
<span class="inline-code">.bin</span> for Tiberian Dawn.
</p>
<p>
The archive will then be extracted, validated and rebuilt for storage.
You will receive a <span class="inline-code">200 OK</span> status code if your
uploaded file was valid.
</p>
<form action="/upload" method="post" enctype="multipart/form-data">
<label hidden="hidden" for="game-slug">Game</label>
<select id="game-slug" name="game">
<option value="td" selected="selected">Command &amp; Conquer (Tiberian Dawn)</option>
<option value="ra" selected="selected">Red Alert 1</option>
<option value="d2" selected="selected">Dune 2000</option>
<option value="ts" selected="selected">Tiberian Sun</option>
<option value="yr" selected="selected">Yuri's Revenge</option>
</select>
<label hidden="hidden" for="map-file-input">Map zip file</label>
<input id="map-file-input" type="file" name="file" accept="application/zip">
<button type="submit">Upload</button>
</form>
<p>The CnCNet client will function <strong>exactly</strong> like this form.</p>
</div>
<div id="footer">
<a href="https://www.digitalocean.com/?refcode=337544e2ec7b&amp;utm_campaign=Referral_Invite&amp;utm_medium=opensource&amp;utm_source=CnCNet" title="Powered by Digital Ocean" target="_blank">
<img src="https://opensource.nyc3.cdn.digitaloceanspaces.com/attribution/assets/PoweredByDO/DO_Powered_by_Badge_blue.svg" width="201px" alt="Powered By Digital Ocean">
</a>
</div>
</div>
</div>
</body>
</html>
9 changes: 6 additions & 3 deletions kirovy/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,16 +18,18 @@
from django.conf.urls.static import static
from django.contrib import admin
from django.db import connection
from django.urls import path, include
from django.urls import path, include, URLPattern, URLResolver
from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView, SpectacularRedocView

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

_DjangoPath = URLPattern | URLResolver

def _get_games_url_patterns() -> list[path]:

def _get_games_url_patterns() -> list[_DjangoPath]:
"""Return URLs compatible with legacy CnCNet clients.

- URLs are loaded when the :mod:`kirovy.urls` module is loaded, which happens when Django starts.
Expand All @@ -48,6 +50,7 @@ def _get_games_url_patterns() -> list[path]:
return []

return [
path("upload-manual", cnc_map_views.MapLegacyStaticUI.as_view()),
path("upload", map_upload_views.CncNetBackwardsCompatibleUploadView.as_view()),
*(
# Make e.g. /yr/map_hash, /ra2/map_hash, etc
Expand All @@ -61,7 +64,7 @@ def _get_games_url_patterns() -> list[path]:
]


def _get_url_patterns() -> list[path]:
def _get_url_patterns() -> list[_DjangoPath]:
"""Return the root level url patterns.

I added this because I wanted to have the root URLs at the top of the file,
Expand Down
Loading