diff --git a/.env.example b/.env.example index b8f2bc8..3b7c539 100644 --- a/.env.example +++ b/.env.example @@ -4,6 +4,11 @@ DEBUG=True # ALLOWED_HOSTS=alerts.solvro.pl,localhost # CORS_ALLOWED_ORIGINS=https://solvro.pl,http://localhost:8000 +# === DRF Rate Limiting === +# NUM_PROXIES=1 +# THROTTLE_PUBLIC_REPORT_BURST=3/min +# THROTTLE_PUBLIC_REPORT_SUSTAINED=10/hour + # === Database === # DB_ENGINE=django.db.backends.postgresql # DB_NAME=your_db_name @@ -12,6 +17,9 @@ DEBUG=True # DB_HOST=localhost # DB_PORT=5432 +# === S3 / Storage Configuration === +# S3_BASE_URL=https://example-s3.local/ + # === Authentication === #SOLVRO_AUTH_CLIENT_ID=solvro-alerts -#SOLVRO_AUTH_CLIENT_SECRET=your-solvro-auth-client-secret \ No newline at end of file +#SOLVRO_AUTH_CLIENT_SECRET=your-solvro-auth-client-secret diff --git a/.gitignore b/.gitignore index 240e8d3..4863bd5 100644 --- a/.gitignore +++ b/.gitignore @@ -179,4 +179,7 @@ cython_debug/ db.json # macOS -.DS_Store \ No newline at end of file +.DS_Store + +# Zed +.zed diff --git a/backend_solvro_feedback/settings.py b/backend_solvro_feedback/settings.py index ea540f0..02d308f 100644 --- a/backend_solvro_feedback/settings.py +++ b/backend_solvro_feedback/settings.py @@ -13,8 +13,8 @@ import logging import os from pathlib import Path -import dotenv +import dotenv logger = logging.getLogger(__name__) @@ -55,6 +55,8 @@ "django.contrib.sessions", "django.contrib.messages", "django.contrib.staticfiles", + "rest_framework", + "reports", ] MIDDLEWARE = [ @@ -137,3 +139,20 @@ # https://docs.djangoproject.com/en/6.0/howto/static-files/ STATIC_URL = "static/" + +S3_BASE_URL = os.getenv("S3_BASE_URL", "https://example-s3.local/") + +NUM_PROXIES_ENV = os.getenv("NUM_PROXIES") + +REST_FRAMEWORK = { + "NUM_PROXIES": int(NUM_PROXIES_ENV) if NUM_PROXIES_ENV is not None else None, + "DEFAULT_THROTTLE_CLASSES": [ + "rest_framework.throttling.ScopedRateThrottle", + ], + "DEFAULT_THROTTLE_RATES": { + "public_report_burst": os.getenv("THROTTLE_PUBLIC_REPORT_BURST", "3/min"), + "public_report_sustained": os.getenv( + "THROTTLE_PUBLIC_REPORT_SUSTAINED", "10/hour" + ), + }, +} diff --git a/backend_solvro_feedback/urls.py b/backend_solvro_feedback/urls.py index 34dc4ec..d64d477 100644 --- a/backend_solvro_feedback/urls.py +++ b/backend_solvro_feedback/urls.py @@ -16,8 +16,9 @@ """ from django.contrib import admin -from django.urls import path +from django.urls import include, path urlpatterns = [ path("admin/", admin.site.urls), + path("", include("reports.urls")), ] diff --git a/reports/__init__.py b/reports/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/reports/admin.py b/reports/admin.py new file mode 100644 index 0000000..91444ce --- /dev/null +++ b/reports/admin.py @@ -0,0 +1,25 @@ +from django.contrib import admin + +from .models import Application, Issue, IssueAttachment + + +@admin.register(Application) +class ApplicationAdmin(admin.ModelAdmin): + list_display = ("name", "is_active", "repo_url") + list_filter = ("is_active",) + search_fields = ("name", "repo_url") + + +@admin.register(Issue) +class IssueAdmin(admin.ModelAdmin): + list_display = ("title", "application", "status", "created_at") + list_filter = ("status", "application") + search_fields = ("title", "description") + readonly_fields = ("created_at", "updated_at") + + +@admin.register(IssueAttachment) +class IssueAttachmentAdmin(admin.ModelAdmin): + list_display = ("filename", "issue", "content_type", "size", "created_at") + search_fields = ("filename", "issue__title") + readonly_fields = ("created_at",) diff --git a/reports/apps.py b/reports/apps.py new file mode 100644 index 0000000..f5fbe30 --- /dev/null +++ b/reports/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class ReportsConfig(AppConfig): + name = "reports" diff --git a/reports/migrations/0001_initial.py b/reports/migrations/0001_initial.py new file mode 100644 index 0000000..7ce4da1 --- /dev/null +++ b/reports/migrations/0001_initial.py @@ -0,0 +1,139 @@ +# Generated by Django 6.0.3 on 2026-03-31 18:50 + +import django.core.validators +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="Application", + fields=[ + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ("name", models.CharField(max_length=120, unique=True)), + ("repo_url", models.URLField(max_length=500)), + ("is_active", models.BooleanField(default=True)), + ], + ), + migrations.CreateModel( + name="Issue", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "title", + models.CharField( + max_length=200, + validators=[django.core.validators.MinLengthValidator(3)], + ), + ), + ( + "description", + models.TextField( + validators=[django.core.validators.MinLengthValidator(10)] + ), + ), + ("diagnostics", models.JSONField(blank=True, null=True)), + ( + "status", + models.CharField( + choices=[ + ("NEW", "New"), + ("VERIFIED", "Verified"), + ("GITHUB_CREATED", "GitHub created"), + ("REJECTED", "Rejected"), + ], + db_index=True, + default="NEW", + max_length=20, + ), + ), + ( + "github_issue_number", + models.PositiveIntegerField(blank=True, null=True), + ), + ( + "github_issue_url", + models.URLField(blank=True, max_length=500, null=True), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "application", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="issues", + to="reports.application", + ), + ), + ( + "author", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="reported_issues", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "ordering": ["-created_at"], + }, + ), + migrations.CreateModel( + name="IssueAttachment", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("original_filename", models.CharField(max_length=255)), + ("s3_key", models.CharField(max_length=500)), + ("file_url", models.URLField(max_length=1000)), + ("content_type", models.CharField(max_length=100)), + ("size", models.PositiveIntegerField(help_text="File size in bytes")), + ("created_at", models.DateTimeField(auto_now_add=True)), + ( + "issue", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="attachments", + to="reports.issue", + ), + ), + ], + options={ + "ordering": ["created_at"], + }, + ), + ] diff --git a/reports/migrations/0002_rename_original_filename_issueattachment_filename.py b/reports/migrations/0002_rename_original_filename_issueattachment_filename.py new file mode 100644 index 0000000..038714c --- /dev/null +++ b/reports/migrations/0002_rename_original_filename_issueattachment_filename.py @@ -0,0 +1,17 @@ +# Generated by Django 6.0.3 on 2026-03-31 20:49 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("reports", "0001_initial"), + ] + + operations = [ + migrations.RenameField( + model_name="issueattachment", + old_name="original_filename", + new_name="filename", + ), + ] diff --git a/reports/migrations/__init__.py b/reports/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/reports/models.py b/reports/models.py new file mode 100644 index 0000000..6e2bfa1 --- /dev/null +++ b/reports/models.py @@ -0,0 +1,76 @@ +import uuid + +from django.conf import settings +from django.core.validators import MinLengthValidator +from django.db import models + + +class IssueStatus(models.TextChoices): + NEW = "NEW", "New" + VERIFIED = "VERIFIED", "Verified" + GITHUB_CREATED = "GITHUB_CREATED", "GitHub created" + REJECTED = "REJECTED", "Rejected" + + +class Application(models.Model): + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + name = models.CharField(max_length=120, unique=True) + repo_url = models.URLField(max_length=500) + is_active = models.BooleanField(default=True) + + def __str__(self) -> str: + return self.name + + +class Issue(models.Model): + application = models.ForeignKey( + Application, + on_delete=models.CASCADE, + related_name="issues", + ) + author = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="reported_issues", + ) + title = models.CharField(max_length=200, validators=[MinLengthValidator(3)]) + description = models.TextField(validators=[MinLengthValidator(10)]) + diagnostics = models.JSONField(blank=True, null=True) + status = models.CharField( + max_length=20, + choices=IssueStatus.choices, + default=IssueStatus.NEW, + db_index=True, + ) + github_issue_number = models.PositiveIntegerField(null=True, blank=True) + github_issue_url = models.URLField(max_length=500, null=True, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ["-created_at"] + + def __str__(self) -> str: + return f"[{self.status}] {self.title}" + + +class IssueAttachment(models.Model): + issue = models.ForeignKey( + Issue, + on_delete=models.CASCADE, + related_name="attachments", + ) + filename = models.CharField(max_length=255) + s3_key = models.CharField(max_length=500) + file_url = models.URLField(max_length=1000) + content_type = models.CharField(max_length=100) + size = models.PositiveIntegerField(help_text="File size in bytes") + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ["created_at"] + + def __str__(self) -> str: + return self.filename diff --git a/reports/serializers.py b/reports/serializers.py new file mode 100644 index 0000000..f0e2287 --- /dev/null +++ b/reports/serializers.py @@ -0,0 +1,65 @@ +import base64 +import binascii +import os +from typing import Any + +from rest_framework import serializers + +ALLOWED_CONTENT_TYPES = {"image/png", "image/jpeg", "image/webp"} + + +class AttachmentInputSerializer(serializers.Serializer): + filename = serializers.CharField(max_length=255) + content_base64 = serializers.CharField() + content_type = serializers.ChoiceField(choices=sorted(ALLOWED_CONTENT_TYPES)) + + # We will store the decoded bytes here during validation to avoid double-decoding + decoded_bytes = serializers.HiddenField(default=b"") + + def validate(self, attrs: dict[str, Any]) -> dict[str, Any]: + value = attrs.get("content_base64", "") + # Limit base64 payload to ~7MB, which corresponds to roughly ~5MB raw file size. + # This prevents Out Of Memory (OOM) errors from decoding huge payloads. + if len(value) > 7_000_000: + raise serializers.ValidationError( + {"content_base64": "File size exceeds the 5MB limit."} + ) + + try: + attrs["decoded_bytes"] = base64.b64decode(value, validate=True) + except (binascii.Error, ValueError): + raise serializers.ValidationError( + {"content_base64": "Invalid base64 payload."} + ) + + return attrs + + def validate_filename(self, value: str) -> str: + if not value.strip(): + raise serializers.ValidationError("Filename cannot be empty.") + + # Normalize to just the basename to prevent directory traversal + basename = os.path.basename(value) + + # Ensure the resulting filename is safe + safe_chars = set( + "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-. " + ) + sanitized = "".join(c for c in basename if c in safe_chars) + + if not sanitized.strip(): + raise serializers.ValidationError("Filename contains no valid characters.") + + return sanitized + + +class ReportCreateSerializer(serializers.Serializer): + title = serializers.CharField(min_length=3, max_length=200) + description = serializers.CharField(min_length=10, max_length=10_000) + diagnostics = serializers.JSONField(required=False) + attachments = AttachmentInputSerializer(many=True, required=False) + + def validate_attachments(self, value: list[dict[str, Any]]) -> list[dict[str, Any]]: + if len(value) > 5: + raise serializers.ValidationError("You can upload up to 5 attachments.") + return value diff --git a/reports/tests/__init__.py b/reports/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/reports/tests/test_report_create.py b/reports/tests/test_report_create.py new file mode 100644 index 0000000..a3b7cd1 --- /dev/null +++ b/reports/tests/test_report_create.py @@ -0,0 +1,207 @@ +import base64 +import uuid + +from django.core.cache import cache +from django.test import override_settings +from django.urls import reverse +from rest_framework import status +from rest_framework.test import APITestCase + +from reports.models import Application, Issue, IssueAttachment + +TEST_REST_FRAMEWORK_NO_THROTTLE_INTERFERENCE = { + "DEFAULT_THROTTLE_CLASSES": [ + "rest_framework.throttling.AnonRateThrottle", + ], + "DEFAULT_THROTTLE_RATES": { + "public_report_burst": "1000/min", + "public_report_sustained": "1000/min", + }, +} + +TEST_REST_FRAMEWORK_THROTTLE_STRICT = { + "DEFAULT_THROTTLE_CLASSES": [ + "rest_framework.throttling.AnonRateThrottle", + ], + "DEFAULT_THROTTLE_RATES": { + "public_report_burst": "2/min", + "public_report_sustained": "2/min", + }, +} + + +@override_settings(REST_FRAMEWORK=TEST_REST_FRAMEWORK_NO_THROTTLE_INTERFERENCE) +class PublicReportCreateViewTests(APITestCase): + def setUp(self): + cache.clear() + from rest_framework.settings import api_settings + + from reports.views import ( + PublicReportBurstRateThrottle, + PublicReportSustainedRateThrottle, + ) + + PublicReportBurstRateThrottle.THROTTLE_RATES = ( + api_settings.DEFAULT_THROTTLE_RATES + ) + PublicReportSustainedRateThrottle.THROTTLE_RATES = ( + api_settings.DEFAULT_THROTTLE_RATES + ) + self.application = Application.objects.create( + name="Testownik", + repo_url="https://github.com/solvro/testownik", + is_active=True, + ) + self.url = reverse("report-create", kwargs={"app_id": self.application.id}) + self.valid_attachment_b64 = base64.b64encode(b"fake-image-bytes").decode( + "utf-8" + ) + + def _payload(self, attachments=None): + return { + "title": "Crash on login", + "description": "App crashes when user taps login after entering credentials.", + "diagnostics": {"platform": "android", "app_version": "1.2.3"}, + "attachments": attachments if attachments is not None else [], + } + + def test_create_report_happy_path(self): + payload = self._payload( + attachments=[ + { + "filename": "screen.png", + "content_type": "image/png", + "content_base64": self.valid_attachment_b64, + } + ] + ) + + response = self.client.post(self.url, payload, format="json") + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(response.data["status"], "NEW") + self.assertEqual(response.data["application_id"], str(self.application.id)) + + self.assertEqual(Issue.objects.count(), 1) + issue = Issue.objects.first() + self.assertEqual(issue.application_id, self.application.id) + self.assertEqual(issue.title, payload["title"]) + self.assertEqual(issue.description, payload["description"]) + self.assertEqual(issue.status, "NEW") + + self.assertEqual(IssueAttachment.objects.count(), 1) + attachment = IssueAttachment.objects.first() + self.assertEqual(attachment.issue_id, issue.id) + self.assertEqual(attachment.filename, "screen.png") + self.assertEqual(attachment.content_type, "image/png") + self.assertGreater(attachment.size, 0) + + def test_create_report_returns_404_for_unknown_application(self): + unknown_url = reverse("report-create", kwargs={"app_id": uuid.uuid4()}) + + response = self.client.post(unknown_url, self._payload(), format="json") + + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + self.assertEqual(Issue.objects.count(), 0) + + def test_create_report_rejects_more_than_five_attachments(self): + attachments = [ + { + "filename": f"screen-{i}.png", + "content_type": "image/png", + "content_base64": self.valid_attachment_b64, + } + for i in range(6) + ] + payload = self._payload(attachments=attachments) + + response = self.client.post(self.url, payload, format="json") + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn("attachments", response.data) + self.assertEqual(Issue.objects.count(), 0) + self.assertEqual(IssueAttachment.objects.count(), 0) + + def test_create_report_rejects_invalid_base64_attachment(self): + payload = self._payload( + attachments=[ + { + "filename": "broken.png", + "content_type": "image/png", + "content_base64": "not-valid-base64###", + } + ] + ) + + response = self.client.post(self.url, payload, format="json") + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn("attachments", response.data) + self.assertEqual(Issue.objects.count(), 0) + self.assertEqual(IssueAttachment.objects.count(), 0) + + def test_create_report_sanitizes_attachment_filename(self): + payload = self._payload( + attachments=[ + { + "filename": "../../../etc/passwd!@#.png", + "content_type": "image/png", + "content_base64": self.valid_attachment_b64, + } + ] + ) + + response = self.client.post(self.url, payload, format="json") + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + attachment = IssueAttachment.objects.first() + self.assertEqual(attachment.filename, "passwd.png") + + +@override_settings(REST_FRAMEWORK=TEST_REST_FRAMEWORK_THROTTLE_STRICT) +class PublicReportCreateThrottleTests(APITestCase): + def setUp(self): + cache.clear() + from rest_framework.settings import api_settings + + from reports.views import ( + PublicReportBurstRateThrottle, + PublicReportSustainedRateThrottle, + ) + + PublicReportBurstRateThrottle.THROTTLE_RATES = ( + api_settings.DEFAULT_THROTTLE_RATES + ) + PublicReportSustainedRateThrottle.THROTTLE_RATES = ( + api_settings.DEFAULT_THROTTLE_RATES + ) + self.application = Application.objects.create( + name="Testownik-Throttle", + repo_url="https://github.com/solvro/testownik", + is_active=True, + ) + self.url = reverse("report-create", kwargs={"app_id": self.application.id}) + self.valid_attachment_b64 = base64.b64encode(b"fake-image-bytes").decode( + "utf-8" + ) + + def test_create_report_is_rate_limited_by_ip(self): + payload = { + "title": "Crash on login", + "description": "App crashes when user taps login after entering credentials.", + "attachments": [ + { + "filename": "screen.png", + "content_type": "image/png", + "content_base64": self.valid_attachment_b64, + } + ], + } + + r1 = self.client.post(self.url, payload, format="json", REMOTE_ADDR="10.0.0.10") + r2 = self.client.post(self.url, payload, format="json", REMOTE_ADDR="10.0.0.10") + r3 = self.client.post(self.url, payload, format="json", REMOTE_ADDR="10.0.0.10") + + self.assertEqual(r1.status_code, status.HTTP_201_CREATED) + self.assertEqual(r2.status_code, status.HTTP_201_CREATED) + self.assertEqual(r3.status_code, status.HTTP_429_TOO_MANY_REQUESTS) diff --git a/reports/urls.py b/reports/urls.py new file mode 100644 index 0000000..f605820 --- /dev/null +++ b/reports/urls.py @@ -0,0 +1,9 @@ +from django.urls import path + +from .views import PublicReportCreateView + +urlpatterns = [ + path( + "report/", PublicReportCreateView.as_view(), name="report-create" + ), +] diff --git a/reports/views.py b/reports/views.py new file mode 100644 index 0000000..1fb7cb4 --- /dev/null +++ b/reports/views.py @@ -0,0 +1,78 @@ +import urllib.parse +import uuid +from typing import Any + +from django.conf import settings +from django.db import transaction +from django.shortcuts import get_object_or_404 +from rest_framework import status +from rest_framework.response import Response +from rest_framework.throttling import AnonRateThrottle +from rest_framework.views import APIView + +from .models import Application, Issue, IssueAttachment +from .serializers import ReportCreateSerializer + + +class PublicReportBurstRateThrottle(AnonRateThrottle): + scope = "public_report_burst" + + +class PublicReportSustainedRateThrottle(AnonRateThrottle): + scope = "public_report_sustained" + + +class PublicReportCreateView(APIView): + """ + Public endpoint for creating reports: + POST /report/ + """ + + authentication_classes: tuple[Any, ...] = () + permission_classes: tuple[Any, ...] = () + throttle_classes = ( + PublicReportBurstRateThrottle, + PublicReportSustainedRateThrottle, + ) + + def post(self, request, app_id: uuid.UUID, *args: Any, **kwargs: Any) -> Response: + application = get_object_or_404(Application, id=app_id, is_active=True) + + serializer = ReportCreateSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + validated = serializer.validated_data + + with transaction.atomic(): + issue = Issue.objects.create( + application=application, + title=validated["title"], + description=validated["description"], + diagnostics=validated.get("diagnostics"), + ) + + attachments = validated.get("attachments", []) + for attachment in attachments: + raw_content = attachment["decoded_bytes"] + safe_filename = urllib.parse.quote(attachment["filename"]) + generated_key = f"issues/{issue.id}/{uuid.uuid4().hex}_{safe_filename}" + + # TODO (S3 integration): upload `raw_content` to S3-compatible storage. + # For now we store a placeholder URL based on generated key. + IssueAttachment.objects.create( + issue=issue, + filename=attachment["filename"], + s3_key=generated_key, + file_url=f"{settings.S3_BASE_URL.rstrip('/')}/{generated_key}", + content_type=attachment["content_type"], + size=len(raw_content), + ) + + return Response( + { + "issue_id": issue.id, + "status": issue.status, + "application_id": str(application.id), + "created_at": issue.created_at, + }, + status=status.HTTP_201_CREATED, + )