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
26 changes: 26 additions & 0 deletions apps/blog/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,29 @@ class CategoryListSerializer(serializers.Serializer):

def get_parent_name(self, obj: Category) -> str | None:
return obj.parent.name if obj.parent else None


class BlogIngestImageUploadSerializer(serializers.Serializer):
"""블로그 ingest용 이미지/파일 업로드 요청 검증. 파일 1~10개."""

files = serializers.ListField(
child=serializers.FileField(),
min_length=1,
max_length=10,
)


class BlogIngestImageUploadResultSerializer(serializers.Serializer):
"""이미지/파일 업로드 결과 응답 아이템. 파일 단위로 성공/실패를 구분한다."""

filename = serializers.CharField()
success = serializers.BooleanField()
url = serializers.CharField(allow_blank=True)
markdown = serializers.CharField(allow_blank=True)
error_message = serializers.CharField(allow_blank=True)


class BlogIngestImageUploadResponseSerializer(serializers.Serializer):
"""이미지/파일 업로드 API의 응답 스키마 문서화용. 실제 응답 본문({"results": [...]})과 구조를 맞추기 위해 존재하며 런타임에는 인스턴스화하지 않는다."""

results = BlogIngestImageUploadResultSerializer(many=True)
44 changes: 43 additions & 1 deletion apps/blog/tests/test_serializers.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
from apps.blog.serializers import BlogIngestSerializer
from django.core.files.uploadedfile import SimpleUploadedFile

from apps.blog.serializers import BlogIngestImageUploadSerializer, BlogIngestSerializer


def test_필수_필드만_있어도_유효하다() -> None:
Expand Down Expand Up @@ -58,3 +60,43 @@ def test_모든_필드를_채우면_그대로_반영된다() -> None:
assert serializer.is_valid(), serializer.errors
assert serializer.validated_data['tags'] == ['django', 'api-design']
assert serializer.validated_data['repo_url'] == 'https://github.com/example/waitee-app'


def test_이미지_업로드_시리얼라이저는_파일_1개면_유효하다() -> None:
upload = SimpleUploadedFile('photo.png', b'fake-image-bytes', content_type='image/png')

serializer = BlogIngestImageUploadSerializer(data={'files': [upload]})

assert serializer.is_valid(), serializer.errors
assert len(serializer.validated_data['files']) == 1


def test_이미지_업로드_시리얼라이저는_파일이_없으면_무효하다() -> None:
serializer = BlogIngestImageUploadSerializer(data={})

assert not serializer.is_valid()
assert 'files' in serializer.errors


def test_이미지_업로드_시리얼라이저는_10개_초과면_무효하다() -> None:
uploads = [
SimpleUploadedFile(f'photo{i}.png', b'fake-image-bytes', content_type='image/png')
for i in range(11)
]

serializer = BlogIngestImageUploadSerializer(data={'files': uploads})

assert not serializer.is_valid()
assert 'files' in serializer.errors


def test_이미지_업로드_시리얼라이저는_10개면_유효하다() -> None:
uploads = [
SimpleUploadedFile(f'photo{i}.png', b'fake-image-bytes', content_type='image/png')
for i in range(10)
]

serializer = BlogIngestImageUploadSerializer(data={'files': uploads})

assert serializer.is_valid(), serializer.errors
assert len(serializer.validated_data['files']) == 10
108 changes: 108 additions & 0 deletions apps/blog/tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@

from apps.blog.models import Category, Post, Tag

import io

from django.core.files.uploadedfile import SimpleUploadedFile
from PIL import Image


@pytest.mark.django_db
def test_올바른_키로_요청하면_초안이_생성된다(client: Client, settings) -> None:
Expand Down Expand Up @@ -176,3 +181,106 @@ def test_ingest는_대소문자만_다른_태그를_기존_태그로_재사용
)

assert Tag.objects.count() == 1


def _make_image_upload(name: str = 'photo.png') -> SimpleUploadedFile:
buffer = io.BytesIO()
Image.new('RGB', (10, 10), color='red').save(buffer, format='PNG')
buffer.seek(0)
return SimpleUploadedFile(name, buffer.read(), content_type='image/png')


@pytest.mark.django_db
def test_이미지_업로드는_1개면_200과_webp_url을_반환한다(client: Client, settings, tmp_path) -> None:
settings.BLOG_INGEST_API_KEY = 'secret-key'
settings.MEDIA_ROOT = tmp_path
url = reverse('blog-ingest-image-upload')

response = client.post(
url,
data={'files': [_make_image_upload()]},
HTTP_X_BLOG_INGEST_KEY='secret-key',
)

assert response.status_code == 200
results = response.json()['results']
assert len(results) == 1
assert results[0]['success'] is True
assert results[0]['url'].endswith('.webp')
assert results[0]['markdown'] == f"![업로드 이미지]({results[0]['url']})"


@pytest.mark.django_db
def test_이미지_업로드는_여러_개면_순서대로_결과가_대응된다(client: Client, settings, tmp_path) -> None:
settings.BLOG_INGEST_API_KEY = 'secret-key'
settings.MEDIA_ROOT = tmp_path
url = reverse('blog-ingest-image-upload')

response = client.post(
url,
data={'files': [_make_image_upload('a.png'), _make_image_upload('b.png')]},
HTTP_X_BLOG_INGEST_KEY='secret-key',
)

assert response.status_code == 200
results = response.json()['results']
assert len(results) == 2
assert all(r['success'] is True for r in results)
assert results[0]['filename'] == 'a.png'
assert results[1]['filename'] == 'b.png'


@pytest.mark.django_db
def test_이미지_업로드는_일부_파일이_지원하지_않는_형식이면_부분_성공을_반환한다(client: Client, settings, tmp_path) -> None:
settings.BLOG_INGEST_API_KEY = 'secret-key'
settings.MEDIA_ROOT = tmp_path
url = reverse('blog-ingest-image-upload')
bad_file = SimpleUploadedFile('notes.txt', b'plain text', content_type='text/plain')

response = client.post(
url,
data={'files': [_make_image_upload('good.png'), bad_file]},
HTTP_X_BLOG_INGEST_KEY='secret-key',
)

assert response.status_code == 200
results = response.json()['results']
assert len(results) == 2
assert results[0]['success'] is True
assert results[1]['success'] is False
assert '.txt' in results[1]['error_message']


@pytest.mark.django_db
def test_이미지_업로드는_파일이_없으면_400이다(client: Client, settings) -> None:
settings.BLOG_INGEST_API_KEY = 'secret-key'
url = reverse('blog-ingest-image-upload')

response = client.post(url, data={}, HTTP_X_BLOG_INGEST_KEY='secret-key')

assert response.status_code == 400


@pytest.mark.django_db
def test_이미지_업로드는_10개_초과면_400이다(client: Client, settings) -> None:
settings.BLOG_INGEST_API_KEY = 'secret-key'
url = reverse('blog-ingest-image-upload')
uploads = [_make_image_upload(f'{i}.png') for i in range(11)]

response = client.post(url, data={'files': uploads}, HTTP_X_BLOG_INGEST_KEY='secret-key')

assert response.status_code == 400


@pytest.mark.django_db
def test_이미지_업로드는_키가_틀리면_403이다(client: Client, settings) -> None:
settings.BLOG_INGEST_API_KEY = 'secret-key'
url = reverse('blog-ingest-image-upload')

response = client.post(
url,
data={'files': [_make_image_upload()]},
HTTP_X_BLOG_INGEST_KEY='wrong-key',
)

assert response.status_code == 403
3 changes: 2 additions & 1 deletion apps/blog/urls.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
from django.urls import path

from apps.blog.views import BlogCategoryListView, BlogIngestView
from apps.blog.views import BlogCategoryListView, BlogIngestImageUploadView, BlogIngestView

urlpatterns = [
path('ingest/', BlogIngestView.as_view(), name='blog-ingest'),
path('ingest/images/', BlogIngestImageUploadView.as_view(), name='blog-ingest-image-upload'),
path('categories/', BlogCategoryListView.as_view(), name='blog-category-list'),
]
59 changes: 58 additions & 1 deletion apps/blog/views.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,25 @@
from typing import ClassVar

from django.core.files.uploadedfile import UploadedFile
from django.urls import reverse
from django.utils import timezone
from drf_spectacular.utils import OpenApiResponse, extend_schema
from rest_framework.parsers import FormParser, MultiPartParser
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.throttling import ScopedRateThrottle
from rest_framework.views import APIView

from apps.blog.models import Category, Post
from apps.blog.permissions import HasBlogIngestKey
from apps.blog.serializers import BlogIngestSerializer, CategoryListSerializer
from apps.blog.serializers import (
BlogIngestImageUploadResponseSerializer,
BlogIngestImageUploadSerializer,
BlogIngestSerializer,
CategoryListSerializer,
)
from apps.blog.services.category import CategoryNotFoundError, get_category_by_name
from apps.blog.services.media_storage import MediaUploadResult, save_uploaded_media
from apps.blog.services.slug import generate_unique_slug
from apps.blog.services.tags import get_or_create_tags

Expand Down Expand Up @@ -98,3 +106,52 @@ def get(self, request: Request) -> Response:
categories = Category.objects.select_related('parent').all()
serializer = CategoryListSerializer(categories, many=True)
return Response(serializer.data)


class BlogIngestImageUploadView(APIView):
"""블로그 ingest용 이미지/파일 업로드 엔드포인트. 기존 admin 업로드와 동일한 저장 로직(save_uploaded_media)을 재사용한다."""

authentication_classes: ClassVar[list] = []
permission_classes: ClassVar[list] = [HasBlogIngestKey]
parser_classes: ClassVar[list] = [MultiPartParser, FormParser]
throttle_classes: ClassVar[list] = [ScopedRateThrottle]
throttle_scope = 'blog_ingest_upload'

@extend_schema(
summary='블로그 글 이미지/파일 업로드 (Ingest)',
description=(
'외부에서 블로그 글 본문(`content`)에 삽입할 이미지·동영상·PDF를 업로드한다. '
'`X-Blog-Ingest-Key` 헤더의 전용 API 키로만 인증하며(JWT 미사용), 한 요청에 파일 1~10개를 함께 보낼 수 있다. '
'이미지는 webp로 변환되어 저장되고(애니메이션 GIF는 원본 유지), 동영상(mp4/mov/webm)·PDF는 원본 그대로 저장된다. '
'파일당 최대 50MB이며, 일부 파일이 형식·크기 문제로 실패해도 나머지는 정상 저장된다(부분 성공 허용). '
'요청 형식이 유효하면(파일 1~10개) 개별 파일 성공/실패와 무관하게 항상 200을 반환하며, '
'`results[].markdown` 필드를 그대로 `content`에 이어붙이면 된다.'
),
request=BlogIngestImageUploadSerializer,
responses={
200: BlogIngestImageUploadResponseSerializer,
400: OpenApiResponse(description='파일 누락 또는 10개 초과'),
403: OpenApiResponse(description='API 키 누락 또는 불일치'),
},
tags=['blog'],
)
def post(self, request: Request) -> Response:
serializer = BlogIngestImageUploadSerializer(data=request.data)
if not serializer.is_valid():
return Response(serializer.errors, status=400)

results = [
_to_upload_result(uploaded_file, save_uploaded_media(uploaded_file))
for uploaded_file in serializer.validated_data['files']
Comment on lines +143 to +145

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

ast-grep outline apps/blog/services/media_storage.py --items all
rg -n -P -C 12 'def (save_uploaded_media|_save_image|_store)\b|except\b|Image\.(open|save)\b' \
  apps/blog/services/media_storage.py

Repository: Chuseok22/chuseok22-home-server

Length of output: 6495


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== media_storage.py relevant lines =="
sed -n '42,86p' apps/blog/services/media_storage.py

echo
echo "== MediaUploadResult uses =="
rg -n 'save_uploaded_media\(|MediaUploadResult|results = \[' apps -g '*.py'

echo
echo "== views.py relevant lines =="
sed -n '130,165p' apps/blog/views.py

Repository: Chuseok22/chuseok22-home-server

Length of output: 5950


🌐 Web query:

Pillow Image save can raise exceptions malformed or corrupt image documentation

💡 Result:

In Pillow, exceptions related to malformed or corrupt images typically occur because the library uses lazy loading, where metadata is read upon opening but the actual image data is only processed (and validated) when you perform an operation like load() or save() [1][2]. Common exceptions and behaviors include: OSError (e.g., "broken data stream", "image file is truncated"): This is the most common error when Pillow encounters malformed or incomplete data during decoding [3][4]. Because of lazy loading, if you do not explicitly call load() or access image data after Image.open(), such errors may be deferred and only raised later during a save() operation [1]. Handling Truncated Images: If you need to process images that are partially corrupt or truncated, you can set ImageFile.LOAD_TRUNCATED_IMAGES = True [3][5]. This allows Pillow to attempt to parse and load as much data as possible instead of raising an error [4]. Integrity Checking: The Image.verify() method is available to check for file integrity without decoding the entire image [5]. However, be aware that its effectiveness varies by format; for example, it is primarily implemented for PNG files to verify CRC checksums and may not catch all types of corruption in other formats [4]. Encoder and Memory Errors: During save(), you may encounter OSError (e.g., "encoder error -2"), MemoryError, or ValueError due to internal buffer limitations, unsupported image modes, or invalid data structures (e.g., zero-dimension images, invalid palette sizes) [1][6][7][8]. If you encounter persistent encoder errors during save operations—often with progressive or optimized JPEGs—adjusting ImageFile.MAXBLOCK can sometimes serve as a workaround by increasing the internal buffer size [6][9]. To robustly handle potential issues, it is recommended to wrap image loading and processing operations in a try-except block and explicitly call img.load() if you need to validate the integrity of the image data immediately upon opening [4][1].

Citations:


_save_image()의 Pillow 인코딩 실패를 실패 결과로 변환하십시오.

image.save(buffer, ...)는 손상/잘린 GIF/WebP 입력 등에서 OSError/ValueError를 발생할 수 있습니다. 현재는 image.load()만 처리하므로 이 경로가 예외를 전달하면 파일별 컴프리헨션이 실패하고 전체 업로드 응답이 500이 됩니다. 해당 예외를 MediaUploadResult(success=False, error_message=...)로 변환하십시오.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/blog/views.py` around lines 143 - 145, Update _save_image to catch
OSError and ValueError raised during image.save encoding, including malformed
GIF/WebP inputs. Convert those exceptions into MediaUploadResult(success=False,
error_message=...) so the per-file upload comprehension continues and the
overall response does not become a 500.

]
return Response({'results': results}, status=200)


def _to_upload_result(uploaded_file: UploadedFile, result: MediaUploadResult) -> dict[str, bool | str]:
return {
'filename': uploaded_file.name,
'success': result.success,
'url': result.url,
'markdown': result.markdown,
'error_message': result.error_message,
}
1 change: 1 addition & 0 deletions config/settings/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@
'PAGE_SIZE': 20,
'DEFAULT_THROTTLE_RATES': {
'blog_ingest': '30/day',
'blog_ingest_upload': '100/day',
},
}

Expand Down