From 5d3ae10036dd3a695010a58ca80e5373f62d666a Mon Sep 17 00:00:00 2001 From: MetalCircle Codex Bot Date: Tue, 15 Sep 2026 21:16:06 +0200 Subject: [PATCH] Add optional screenshots to bug reports --- app/bug_reporter.py | 22 +++++++-- app/gitea_service.py | 40 ++++++++++++++--- app/locales/en.json | 9 +++- app/safe_images.py | 65 +++++++++++++++++++++++++++ app/templates/bug_report.html | 6 ++- app/templates/datenschutz.html | 2 +- app/tests/test_feature_api.py | 35 +++++++++++++++ app/tests/test_gitea.py | 39 ++++++++++++++++ app/tests/test_safe_images.py | 50 +++++++++++++++++++++ docs/wiki/Issues-and-Bug-Reporting.md | 7 +++ 10 files changed, 263 insertions(+), 12 deletions(-) create mode 100644 app/safe_images.py create mode 100644 app/tests/test_safe_images.py diff --git a/app/bug_reporter.py b/app/bug_reporter.py index ebcc88e..af799ae 100644 --- a/app/bug_reporter.py +++ b/app/bug_reporter.py @@ -7,10 +7,11 @@ from urllib.parse import quote, urlsplit from uuid import UUID, uuid4 from zoneinfo import ZoneInfo -from fastapi import Form, Request +from fastapi import File, Form, Request, UploadFile from fastapi.responses import HTMLResponse, RedirectResponse from gitea_service import GiteaError, GiteaService, redact +from safe_images import MAX_SCREENSHOT_BYTES, ScreenshotError, sanitize_screenshot from i18n import current_language, current_page, gettext as _ @@ -116,7 +117,7 @@ def register_routes(app, templates, get_db, get_user, service_factory=GiteaServi description: str = Form(''), expected: str = Form(''), steps: str = Form(''), category: str = Form('general'), severity: str = Form('normal'), technical: bool = Form(False), route: str = Form('/'), platform: str = Form('Web'), - app_version: str = Form('')): + app_version: str = Form(''), screenshot: UploadFile | None = File(None)): user = get_user(request) if not user: return RedirectResponse('/login', status_code=303) @@ -132,6 +133,21 @@ def register_routes(app, templates, get_db, get_user, service_factory=GiteaServi not 3 <= len(data['expected']) <= 3000 or len(data['steps']) > 3000 or category not in CATEGORIES or severity not in SEVERITIES): return render(user, data, identifier, _('Bitte prüfe die Pflichtfelder, Textlängen, Kategorie und den Schweregrad.'), 400) + + screenshot_payload = None + if screenshot and screenshot.filename: + try: + raw_screenshot = screenshot.file.read(MAX_SCREENSHOT_BYTES + 1) + clean_bytes, extension, media_type = sanitize_screenshot(raw_screenshot) + screenshot_payload = (f'screenshot-{uuid4().hex}.{extension}', clean_bytes, media_type) + except ScreenshotError as error: + message = (_('Der Screenshot ist zu groß. Erlaubt sind maximal 4 MB.') + if error.code == 'too_large' else + _('Bitte lade ein gültiges JPG-, PNG- oder WebP-Bild hoch.')) + return render(user, data, identifier, message, 400) + finally: + screenshot.file.close() + with get_db() as connection: # Cross-worker cooldown and one-use form IDs, serialized for each reporter. connection.execute('SELECT pg_advisory_xact_lock(71002, %s)', (user['id'],)) @@ -152,7 +168,7 @@ def register_routes(app, templates, get_db, get_user, service_factory=GiteaServi connection.execute('UPDATE bug_report_submissions SET state=\'sending\',submitted_at=CURRENT_TIMESTAMP WHERE id=%s', (identifier,)) connection.commit() try: - number = service_factory().create_issue(data['title'], issue_body(data, user, request), category) + number = service_factory().create_issue(data['title'], issue_body(data, user, request), category, screenshot=screenshot_payload) except GiteaError as error: with get_db() as connection: connection.execute('UPDATE bug_report_submissions SET state=%s WHERE id=%s', diff --git a/app/gitea_service.py b/app/gitea_service.py index 6d5ded6..3b8d148 100644 --- a/app/gitea_service.py +++ b/app/gitea_service.py @@ -62,12 +62,13 @@ class GiteaService: self.config = config or GiteaConfig.from_env() self.transport = transport - def create_issue(self, title, body, category): + def create_issue(self, title, body, category, screenshot=None): self.config.validate() repository = '/repos/' + quote(self.config.owner, safe='') + '/' + quote(self.config.repo, safe='') wanted = {'reported-from-metalcircle', 'bug'} wanted.update({'android': {'android'}, 'web': {'web', 'frontend'}, 'push': {'push'}}.get(category, set())) attempted = False + issue_created = False try: with httpx.Client(base_url=self.config.url.rstrip('/') + '/api/v1/', headers={'Authorization': 'token ' + self.config.token, 'Accept': 'application/json'}, @@ -105,17 +106,46 @@ class GiteaService: issue['number'] <= 0 or not isinstance(issue.get('user'), dict) or issue['user'].get('login') != 'metalcircle-bot'): raise GiteaError('invalid_response', uncertain=True) - return issue['number'] + issue_number = issue['number'] + issue_created = True + + if screenshot: + filename, image_bytes, media_type = screenshot + uploaded = client.post( + repository.lstrip('/') + f'/issues/{issue_number}/assets', + params={'name': filename}, + files={'attachment': (filename, image_bytes, media_type)}, + ) + uploaded.raise_for_status() + attachment = uploaded.json() + download_url = attachment.get('browser_download_url') if isinstance(attachment, dict) else None + configured_origin = urlsplit(self.config.url) + attachment_origin = urlsplit(download_url) if isinstance(download_url, str) else None + if (not attachment_origin or attachment_origin.scheme not in ('http', 'https') or + attachment_origin.hostname != configured_origin.hostname or + attachment_origin.port != configured_origin.port or + attachment_origin.username or attachment_origin.password): + raise GiteaError('invalid_attachment_response', uncertain=True) + linked_body = redact( + body + '\n\n## Screenshot\n\n![Bug report screenshot](<' + quote(download_url, safe=':/?&=%') + '>)', + (self.config.token,), + ) + updated = client.patch(repository.lstrip('/') + f'/issues/{issue_number}', + json={'body': linked_body}) + updated.raise_for_status() + return issue_number except GiteaError as error: + if issue_created and screenshot: + error.uncertain = True logger.warning('Gitea issue submission failed: %s', error.kind) raise except httpx.HTTPStatusError as error: code = error.response.status_code logger.warning('Gitea issue submission failed: HTTP %d', code) - raise GiteaError('http_' + str(code), uncertain=attempted and code >= 500) from None + raise GiteaError('http_' + str(code), uncertain=(issue_created and bool(screenshot)) or (attempted and code >= 500)) from None except (httpx.TimeoutException, httpx.NetworkError, httpx.RemoteProtocolError): logger.warning('Gitea issue submission failed: network_or_timeout') - raise GiteaError('network_or_timeout', uncertain=attempted) from None + raise GiteaError('network_or_timeout', uncertain=issue_created or attempted) from None except (ValueError, TypeError, KeyError, AttributeError, httpx.HTTPError): logger.warning('Gitea issue submission failed: invalid_response') - raise GiteaError('invalid_response', uncertain=attempted) from None + raise GiteaError('invalid_response', uncertain=issue_created or attempted) from None diff --git a/app/locales/en.json b/app/locales/en.json index fadd82a..ec7a4f0 100644 --- a/app/locales/en.json +++ b/app/locales/en.json @@ -550,8 +550,13 @@ "Die Push-Registrierung ist momentan nicht möglich. Bitte versuche es später erneut.": "Push registration is currently unavailable. Please try again later.", "FCM-Testtoken anzeigen (nur Debug-App)": "Show FCM test token (debug app only)", "Für Android-Benachrichtigungen speichern wir die Gerätekennung, den FCM-Registrierungstoken, die App-Version und die Zuordnung zur aktuellen Anmeldung. Beim Abmelden wird die Zuordnung gelöscht. Firebase verarbeitet die für die Push-Zustellung erforderlichen Gerätedaten.": "For Android notifications, we store the device identifier, FCM registration token, app version and link to the current login session. Logging out deletes the link. Firebase processes the device data required to deliver push notifications.", - "Bugmeldungen werden mit Benutzername und User-ID an unser internes Gitea-Ticketsystem übertragen. Technische Zusatzinformationen werden nur auf Wunsch mitgesendet. Lokale Versandkennungen zur Vermeidung doppelter Meldungen laufen nach 24 Stunden ab.": "Bug reports are sent to our internal Gitea ticket system with your username and user ID. Additional technical information is sent only if you choose to include it. Local submission identifiers used to prevent duplicate reports expire after 24 hours.", "Freunde einladen": "Invite friends", "Noch keine bestätigten Freunde vorhanden.": "No confirmed friends yet.", - "Es können nur bestätigte Freunde eingeladen werden.": "Only confirmed friends can be invited." + "Es können nur bestätigte Freunde eingeladen werden.": "Only confirmed friends can be invited.", + "Screenshot anhängen (optional)": "Attach a screenshot (optional)", + "JPG, PNG oder WebP, maximal 4 MB. Das Bild wird geprüft, neu kodiert und ohne Metadaten an das Gitea-Issue angehängt.": "JPG, PNG or WebP, up to 4 MB. The image is validated, re-encoded and attached to the Gitea issue without metadata.", + "Der Screenshot ist zu groß. Erlaubt sind maximal 4 MB.": "The screenshot is too large. The maximum size is 4 MB.", + "Bitte lade ein gültiges JPG-, PNG- oder WebP-Bild hoch.": "Please upload a valid JPG, PNG or WebP image.", + "Bitte verdecke vor dem Senden Passwörter, Zugangstoken und private Informationen im Screenshot.": "Please hide passwords, access tokens and private information in the screenshot before sending it.", + "Bugmeldungen werden mit Benutzername und User-ID an unser internes Gitea-Ticketsystem übertragen. Technische Zusatzinformationen werden nur auf Wunsch mitgesendet. Lokale Versandkennungen zur Vermeidung doppelter Meldungen laufen nach 24 Stunden ab. Wenn du optional einen Screenshot anhängst, wird das neu kodierte Bild zusammen mit dem Issue im internen Gitea-Ticketsystem gespeichert.": "Bug reports are sent to our internal Gitea ticket system with your username and user ID. Additional technical information is sent only if you choose to include it. Local submission identifiers used to prevent duplicate reports expire after 24 hours. If you optionally attach a screenshot, the re-encoded image is stored with the issue in the internal Gitea ticket system." } diff --git a/app/safe_images.py b/app/safe_images.py new file mode 100644 index 0000000..11bbff1 --- /dev/null +++ b/app/safe_images.py @@ -0,0 +1,65 @@ +"""Decode and re-encode small raster uploads before they leave MetalCircle.""" +from io import BytesIO +import warnings + +from PIL import Image, ImageOps + + +MAX_SCREENSHOT_BYTES = 4 * 1024 * 1024 +MAX_SCREENSHOT_PIXELS = 16_000_000 +MAX_SCREENSHOT_OUTPUT_BYTES = 8 * 1024 * 1024 + + +class ScreenshotError(ValueError): + """Stable validation code; never includes the uploaded filename or bytes.""" + def __init__(self, code): + self.code = code + super().__init__(code) + + +def sanitize_screenshot(contents): + if not contents: + raise ScreenshotError('empty') + if len(contents) > MAX_SCREENSHOT_BYTES: + raise ScreenshotError('too_large') + + try: + with warnings.catch_warnings(): + warnings.simplefilter('error', Image.DecompressionBombWarning) + with Image.open(BytesIO(contents)) as candidate: + if candidate.format not in {'JPEG', 'PNG', 'WEBP'}: + raise ScreenshotError('unsupported') + if candidate.width * candidate.height > MAX_SCREENSHOT_PIXELS: + raise ScreenshotError('too_large') + if getattr(candidate, 'n_frames', 1) != 1: + raise ScreenshotError('unsupported') + candidate.verify() + with Image.open(BytesIO(contents)) as candidate: + if candidate.width * candidate.height > MAX_SCREENSHOT_PIXELS: + raise ScreenshotError('too_large') + image = ImageOps.exif_transpose(candidate) + image.load() + has_alpha = image.mode in {'RGBA', 'LA'} or ( + image.mode == 'P' and 'transparency' in image.info + ) + if image.mode not in {'RGB', 'RGBA'}: + image = image.convert('RGBA' if has_alpha else 'RGB') + image.thumbnail((1600, 1600)) + output = BytesIO() + if image.mode == 'RGBA': + image.save(output, format='PNG', optimize=True) + extension, media_type = 'png', 'image/png' + else: + image.save(output, format='JPEG', quality=85, optimize=True) + extension, media_type = 'jpg', 'image/jpeg' + safe_contents = output.getvalue() + except ScreenshotError: + raise + except Exception: + # Decoders may raise different exception types for malformed inputs. + # Fail closed without exposing decoder details to the caller. + raise ScreenshotError('invalid') from None + + if len(safe_contents) > MAX_SCREENSHOT_OUTPUT_BYTES: + raise ScreenshotError('too_large') + return safe_contents, extension, media_type diff --git a/app/templates/bug_report.html b/app/templates/bug_report.html index 5a8ac7c..b3e2f95 100644 --- a/app/templates/bug_report.html +++ b/app/templates/bug_report.html @@ -22,7 +22,7 @@ {% else %}

{{ _('Deine Meldung geht direkt an unser internes Ticketsystem. Sie ist für das Projektteam sichtbar.') }}

{% if error %}{% endif %} -
+ @@ -32,6 +32,9 @@ + + +

{{ _('JPG, PNG oder WebP, maximal 4 MB. Das Bild wird geprüft, neu kodiert und ohne Metadaten an das Gitea-Issue angehängt.') }}