Skip to content

Commit 9152a31

Browse files
authored
feat: Map screenshots / images (#29)
- fix the model for screenshot uploads. - Add map images to the map serializer. - Test map images return - Start work on reusable file upload view - fix logger type - use user id in `CanEdit` to save a query - Add the map image upload endpoint - fix crashing with postgres 18 - tests for uploads - disallow uploads for banned maps, temporary maps, and legacy maps - Test file type checks - Test file size checks - modify uploaded files to convert to jpeg, slightly shrink, and remove exif - add the cnc_user_id to uploaded files - fix legacy uploads for system user validation - fix docker issues with user IDs - make moderabile object to standardize moderating objects - Fix circular imports with file extensions by removing their imports in the ban object data - Fix broken 0002 migration due to the object manager not using the correct schema state - fix nginx mimetypes so that assets are served - move image uploading to its own file - Make kirovy exception handler more generic - Add `/maps/img/id` edit endpoint - Add `editable_fields` to serializers, which allows for fields to be set during creation, but never allowed to be updated. - switch to image field for map images. Just adds some extra helpers to the model file field - Add a base upload view for file subclasses. Will be useful later on - Base view for deleting objects
1 parent 3930de0 commit 9152a31

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

50 files changed

+1732
-274
lines changed

docker-compose.debugger.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,9 @@
33
services:
44
db:
55
container_name: mapdb-postgres-debugger
6-
image: postgres
6+
image: postgres:18
77
volumes:
8-
- ${POSTGRES_DATA_DIR}/debugger-db/:/var/lib/postgresql/data
8+
- ${POSTGRES_DATA_DIR}/debugger-db/:/var/lib/postgresql
99
environment:
1010
- POSTGRES_DB=${POSTGRES_DB}
1111
- POSTGRES_USER=${POSTGRES_USER}

docker-compose.prod.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
services:
22
db:
33
container_name: mapdb-postgres
4-
image: postgres
4+
image: postgres:18
55
volumes:
6-
- ${POSTGRES_DATA_DIR}:/var/lib/postgresql/data
6+
- ${POSTGRES_DATA_DIR}:/var/lib/postgresql
77
environment:
88
- POSTGRES_DB=${POSTGRES_DB}
99
- POSTGRES_USER=${POSTGRES_USER}

docker-compose.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
services:
22
db:
33
container_name: mapdb-postgres-dev
4-
image: postgres
4+
image: postgres:18
55
volumes:
6-
- ${POSTGRES_DATA_DIR}:/var/lib/postgresql/data
6+
- ${POSTGRES_DATA_DIR}:/var/lib/postgresql
77
environment:
88
- POSTGRES_DB=${POSTGRES_DB}
99
- POSTGRES_USER=${POSTGRES_USER}

docker/Dockerfile

Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,38 +4,29 @@ FROM python:3.12-bookworm AS base
44
ENV PYTHONDONTWRITEBYTECODE=1
55
ENV PYTHONUNBUFFERED=1
66

7-
# Configurable user setup
8-
ENV USER=cncnet
9-
ENV UID=1000
10-
117
WORKDIR /cncnet-map-api
128

139
# Install system dependencies
1410
RUN apt-get update && apt-get install -y liblzo2-dev libmagic1
1511

16-
# Create non-root user with configurable name and UID
17-
RUN useradd -m -u ${UID} ${USER}
1812

1913
# Copy necessary files for the build
2014
COPY requirements.txt /cncnet-map-api
2115
COPY requirements-dev.txt /cncnet-map-api
2216
COPY web_entry_point.sh /cncnet-map-api
2317

2418
# Set permissions and make script executable
25-
RUN chmod +x /cncnet-map-api/web_entry_point.sh && \
26-
chown -R ${USER}:${USER} /cncnet-map-api
19+
RUN chmod +x /cncnet-map-api/web_entry_point.sh
2720

2821
RUN pip install --upgrade pip
2922

3023
FROM base AS dev
3124
RUN pip install -r ./requirements-dev.txt
32-
USER ${USER}
3325
ENTRYPOINT ["/cncnet-map-api/web_entry_point.sh"]
3426

3527
FROM base AS prod
3628
COPY . /cncnet-map-api
3729
RUN pip install -r ./requirements.txt
38-
USER ${USER}
3930
ENTRYPOINT ["/cncnet-map-api/web_entry_point.sh"]
4031

4132
FROM base AS debugger

docker/nginx.conf

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,13 @@ http {
1515
alias /usr/share/nginx/html/static/; # The nginx container's mounted volume.
1616
expires 30d;
1717
add_header Cache-Control public;
18+
include /etc/nginx/mime.types;
1819
}
1920

2021
# Serve user uploaded files
2122
location /silo/ {
2223
alias /usr/share/nginx/html/silo/; # The container's mounted volume.
24+
include /etc/nginx/mime.types;
2325
}
2426

2527
# Proxy requests to the Django app running in gunicorn
@@ -29,6 +31,7 @@ http {
2931
proxy_set_header X-Real-IP $remote_addr;
3032
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
3133
proxy_set_header X-Forwarded-Proto $scheme;
34+
proxy_redirect off;
3235
}
3336
}
3437
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
# API Errors in API class helpers
2+
3+
Returning errors in the helper functions of your API endpoint can be annoying.
4+
To avoid that annoyance, just raise one of the [view exceptions](kirovy/exceptions/view_exceptions.py)
5+
or write your own that subclasses `KirovyValidationError`.
6+
7+
**Example where you annoy yourself with bubbling returns:**
8+
9+
```python
10+
class MyView(APIView):
11+
...
12+
def helper(self, request: KirovyRequest) -> MyObject | KirovyResponse:
13+
object_id = request.data.get("id")
14+
if not object_id:
15+
return KirovyResponse(
16+
status=status.HTTP_400_BAD_REQUEST,
17+
data=ErrorResponseData(
18+
message="Must specify id",
19+
code=api_codes.FileUploadApiCodes.MISSING_FOREIGN_ID,
20+
additional={"expected_field": "id"}
21+
)
22+
)
23+
24+
object = get_object_or_404(self.file_parent_class.objects, id=object_id)
25+
self.check_object_permissions(request, object)
26+
27+
return object
28+
29+
def post(self, request: KirovyRequest, format=None) -> KirovyResponse:
30+
object = self.helper(request)
31+
if isinstance(object, KirovyResponse):
32+
return object
33+
...
34+
```
35+
36+
**Example where you just raise the exception:**
37+
38+
```python
39+
class MyView(APIView):
40+
...
41+
def helper(self, request: KirovyRequest) -> MyObject:
42+
object_id = request.data.get("id")
43+
if not object_id:
44+
raise KirovyValidationError(
45+
detail="Must specify id",
46+
code=api_codes.FileUploadApiCodes.MISSING_FOREIGN_ID,
47+
additional={"expected_field": "id"}
48+
)
49+
50+
object = get_object_or_404(self.file_parent_class.objects, id=object_id)
51+
self.check_object_permissions(request, object)
52+
53+
return object
54+
55+
def post(self, request: KirovyRequest, format=None) -> KirovyResponse:
56+
object = self.helper(request)
57+
...
58+
```

kirovy/constants/api_codes.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,3 +21,19 @@ class LegacyUploadApiCodes(enum.StrEnum):
2121
INVALID_FILE_TYPE = "invalid-file-type-in-zip"
2222
GAME_NOT_SUPPORTED = "game-not-supported"
2323
MAP_FAILED_TO_PARSE = "map-failed-to-parse"
24+
25+
26+
class FileUploadApiCodes(enum.StrEnum):
27+
MISSING_FOREIGN_ID = "missing-foreign-id"
28+
INVALID = "file-failed-validation"
29+
UNSUPPORTED = "parent-does-not-support-this-upload"
30+
"""attr: Raised when the parent object for the file does not allow this upload.
31+
32+
e.g. a temporary map does not support custom image uploads.
33+
"""
34+
TOO_LARGE = "file-too-large"
35+
36+
37+
class GenericApiCodes(enum.StrEnum):
38+
CANNOT_UPDATE_FIELD = "field-cannot-be-updated-after-creation"
39+
"""attr: Some fields are not allowed to be edited via any API endpoint."""

kirovy/exception_handler.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
1-
from rest_framework import status
21
from rest_framework.views import exception_handler
32

4-
from kirovy.exceptions.view_exceptions import KirovyValidationError
3+
from kirovy.exceptions.view_exceptions import KirovyAPIException
54
from kirovy.objects import ui_objects
65
from kirovy.response import KirovyResponse
76

@@ -23,7 +22,7 @@ def kirovy_exception_handler(exception: Exception, context) -> KirovyResponse[ui
2322
Returns the ``KirovyResponse`` if the exception is one we defined.
2423
Otherwise, it calls the base DRF exception handler :func:`rest_framework.views.exception_handler`.
2524
"""
26-
if isinstance(exception, KirovyValidationError):
27-
return KirovyResponse(exception.as_error_response_data(), status=status.HTTP_400_BAD_REQUEST)
25+
if isinstance(exception, KirovyAPIException):
26+
return KirovyResponse(exception.as_error_response_data(), status=exception.status_code)
2827

2928
return exception_handler(exception, context)
Lines changed: 40 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from django.utils.encoding import force_str
12
from rest_framework import status
23
from rest_framework.exceptions import APIException as _DRFAPIException
34
from django.utils.translation import gettext_lazy as _
@@ -6,20 +7,23 @@
67
from kirovy.objects import ui_objects
78

89

9-
class KirovyValidationError(_DRFAPIException):
10-
"""A custom exception that easily converts to the standard ``ErrorResponseData``
10+
class KirovyAPIException(_DRFAPIException):
11+
status_code: _t.ClassVar[int] = status.HTTP_500_INTERNAL_SERVER_ERROR
12+
additional: _t.DictStrAny | None = None
13+
code: str | None
14+
"""attr: Some kind of string that the UI will recognize. e.g. ``file-too-large``.
1115
12-
See: :class:`kirovy.objects.ui_objects.ErrorResponseData`
16+
Maps to the UI object attr :attr:`kirovy.objects.ui_objects.ErrorResponseData.code`.
1317
14-
This exception is meant to be used within serializers or views.
15-
"""
18+
.. warning::
1619
17-
status_code = status.HTTP_400_BAD_REQUEST
18-
default_detail = _("Invalid input.")
19-
default_code = "invalid"
20-
additional: _t.DictStrAny | None = None
21-
code: str | None
20+
This is **not** the HTTP code. The HTTP code will always be ``400`` for validation errors.
21+
"""
2222
detail: str | None
23+
"""attr: Extra detail in plain language. Think of this as a message for the user.
24+
25+
Maps to the UI object attr :attr:`kirovy.objects.ui_objects.ErrorResponseData.message`.
26+
"""
2327

2428
def __init__(self, detail: str | None = None, code: str | None = None, additional: _t.DictStrAny | None = None):
2529
super().__init__(detail=detail, code=code)
@@ -29,3 +33,29 @@ def __init__(self, detail: str | None = None, code: str | None = None, additiona
2933

3034
def as_error_response_data(self) -> ui_objects.ErrorResponseData:
3135
return ui_objects.ErrorResponseData(message=self.detail, code=self.code, additional=self.additional)
36+
37+
38+
class KirovyValidationError(KirovyAPIException):
39+
"""A custom exception that easily converts to the standard ``ErrorResponseData``
40+
41+
See: :class:`kirovy.objects.ui_objects.ErrorResponseData`
42+
43+
This exception is meant to be used within serializers or views.
44+
"""
45+
46+
status_code = status.HTTP_400_BAD_REQUEST
47+
default_detail = _("Invalid input.")
48+
default_code = "invalid"
49+
50+
51+
class KirovyMethodNotAllowed(KirovyAPIException):
52+
status_code = status.HTTP_405_METHOD_NOT_ALLOWED
53+
default_detail = _('Method "{method}" not allowed.')
54+
default_code = "method_not_allowed"
55+
56+
def __init__(
57+
self, method, detail: str | None = None, code: str | None = None, additional: _t.DictStrAny | None = None
58+
):
59+
if detail is None:
60+
detail = force_str(self.default_detail).format(method=method)
61+
super().__init__(detail, code, additional)

kirovy/logging.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1-
from structlog import get_logger
2-
import orjson
1+
from structlog import get_logger as _get_logger
32
import typing as t
43

4+
from structlog.stdlib import BoundLogger
5+
56

67
def default_json_encode_object(value: object) -> str:
78
json_func: t.Callable[[object], str] | None = getattr(value, "__json__", None)
@@ -13,3 +14,7 @@ def default_json_encode_object(value: object) -> str:
1314
return str(value)
1415

1516
return f"cannot-json-encode--{type(value).__name__}"
17+
18+
19+
def get_logger(*args: t.Any, **initial_values: t.Any) -> BoundLogger:
20+
return _get_logger(*args, **initial_values)

0 commit comments

Comments
 (0)