Add optional screenshots to bug reports
This commit is contained in:
+19
-3
@@ -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',
|
||||
|
||||
+35
-5
@@ -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 + '>)',
|
||||
(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
|
||||
|
||||
+7
-2
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -22,7 +22,7 @@
|
||||
{% else %}
|
||||
<p>{{ _('Deine Meldung geht direkt an unser internes Ticketsystem. Sie ist für das Projektteam sichtbar.') }}</p>
|
||||
{% if error %}<p class="bug-error" role="alert">{{ error }}</p>{% endif %}
|
||||
<form method="post" action="/bug-report" class="bug-report-form">
|
||||
<form method="post" action="/bug-report" class="bug-report-form" enctype="multipart/form-data">
|
||||
<input type="hidden" name="submission_id" value="{{ submission_id }}">
|
||||
<label for="bug-title">{{ _('Titel') }} <small>{{ _('(Pflichtfeld)') }}</small></label>
|
||||
<input id="bug-title" name="title" required minlength="3" maxlength="160" value="{{ form.title }}">
|
||||
@@ -32,6 +32,9 @@
|
||||
<textarea id="bug-expected" name="expected" required minlength="3" maxlength="3000" rows="3">{{ form.expected }}</textarea>
|
||||
<label for="bug-steps">{{ _('Schritte zum Reproduzieren') }} <small>(optional)</small></label>
|
||||
<textarea id="bug-steps" name="steps" maxlength="3000" rows="3">{{ form.steps }}</textarea>
|
||||
<label for="bug-screenshot">{{ _('Screenshot anhängen (optional)') }}</label>
|
||||
<input id="bug-screenshot" name="screenshot" type="file" accept=".jpg,.jpeg,.png,.webp,image/jpeg,image/png,image/webp">
|
||||
<p class="bug-help">{{ _('JPG, PNG oder WebP, maximal 4 MB. Das Bild wird geprüft, neu kodiert und ohne Metadaten an das Gitea-Issue angehängt.') }}</p>
|
||||
<div class="bug-report-options">
|
||||
<label for="bug-category">{{ _('Kategorie') }}
|
||||
<select id="bug-category" name="category">{% for value, label in categories.items() %}<option value="{{ value }}"{% if form.category == value %} selected{% endif %}>{{ label | t }}</option>{% endfor %}</select>
|
||||
@@ -47,6 +50,7 @@
|
||||
<input type="hidden" name="platform" id="bug-platform" value="Web">
|
||||
<input type="hidden" name="app_version" id="bug-app-version" value="">
|
||||
<p class="bug-help">{{ _('Bitte keine Passwörter, Zugangstoken oder privaten Schlüssel eintragen.') }}</p>
|
||||
<p class="bug-help">{{ _('Bitte verdecke vor dem Senden Passwörter, Zugangstoken und private Informationen im Screenshot.') }}</p>
|
||||
<button type="submit" class="button"{% if blocked %} disabled{% endif %}>{{ _('Bugreport senden') }}</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
<p>{{ _('Es werden keine Werbe- oder Trackingdienste eingesetzt. Bei der Veranstaltungsortsuche können Suchanfragen an einen externen Geocoding-Dienst übermittelt werden. Externe Flyer- und Instagram-Links werden beim Aufruf direkt von deinem Browser geladen; dafür gelten die Datenschutzbestimmungen des jeweiligen Anbieters.') }}</p>
|
||||
<p>{{ _('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.') }}</p>
|
||||
<p>{{ _('Für automatische Pushs speichern wir deine Sprache, gewählte Kategorien und Versandmetadaten. Push-Texte enthalten keine privaten Nachrichteninhalte. Der aktive Versanddienst löscht Versandmetadaten nach sieben Tagen; beim Abmelden werden die zur Sitzung gehörenden Aufträge entfernt.') }}</p>
|
||||
<p>{{ _('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.') }}</p>
|
||||
<p>{{ _('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.') }}</p>
|
||||
<h2>{{ _('Deine Rechte') }}</h2>
|
||||
<p>{{ _('Du kannst Auskunft, Berichtigung, Löschung, Einschränkung der Verarbeitung und – soweit anwendbar – Datenübertragbarkeit verlangen. Einen Export deiner gespeicherten Anwendungsdaten findest du klein am Ende des eigenen Profilbereichs. Anfragen bitte an') }} <a href="mailto:konzert@pinguholic.de">konzert@pinguholic.de</a>.</p>
|
||||
<h2>{{ _('Cookies und Sicherheit') }}</h2>
|
||||
|
||||
@@ -234,6 +234,41 @@ class FeatureApiTests(unittest.TestCase):
|
||||
self.assertEqual(self.client.get('/bug-report', follow_redirects=False).status_code, 303)
|
||||
self.assertEqual(self.client.post('/bug-report', data=data, follow_redirects=False).status_code, 303)
|
||||
|
||||
def test_report_form_screenshot_is_optional_and_localized(self):
|
||||
for language,label in (('de','Screenshot anhängen (optional)'),('en','Attach a screenshot (optional)')):
|
||||
self.client.get('/language/'+language, follow_redirects=False)
|
||||
page=self.client.get('/bug-report')
|
||||
self.assertIn('enctype="multipart/form-data"',page.text)
|
||||
self.assertIn('name="screenshot" type="file"',page.text)
|
||||
self.assertIn(label,page.text)
|
||||
|
||||
def test_report_attaches_only_sanitized_image_bytes(self):
|
||||
from io import BytesIO
|
||||
from PIL import Image
|
||||
image=Image.new('RGB',(20,15),(50,80,100)); exif=Image.Exif(); exif[270]='secret exif test fixture'
|
||||
source=BytesIO(); image.save(source,format='PNG',exif=exif)
|
||||
data=self.report()
|
||||
with patch.object(GiteaService,'create_issue',return_value=123) as create:
|
||||
response=self.client.post('/bug-report',data=data,
|
||||
files={'screenshot':('../../payload.svg',source.getvalue()+b'<script>fixture</script>','image/svg+xml')})
|
||||
self.assertEqual(response.status_code,200)
|
||||
self.assertIn('#123',response.text)
|
||||
create.assert_called_once()
|
||||
filename,contents,media_type=create.call_args.kwargs['screenshot']
|
||||
self.assertRegex(filename,r'^screenshot-[0-9a-f]{32}\.jpg$')
|
||||
self.assertEqual(media_type,'image/jpeg')
|
||||
self.assertNotIn(b'secret exif test fixture',contents)
|
||||
self.assertNotIn(b'<script>',contents)
|
||||
self.assertNotIn('../../payload.svg',str(create.call_args))
|
||||
|
||||
def test_report_rejects_non_image_upload_without_calling_gitea(self):
|
||||
with patch.object(GiteaService,'create_issue') as create:
|
||||
response=self.client.post('/bug-report',data=self.report(),
|
||||
files={'screenshot':('malware.svg',b'<svg onload="alert(1)"></svg>','image/png')})
|
||||
self.assertEqual(response.status_code,400)
|
||||
self.assertIn('Bitte lade ein gültiges JPG-, PNG- oder WebP-Bild hoch.',response.text)
|
||||
create.assert_not_called()
|
||||
|
||||
def test_report_validation(self):
|
||||
for changes in ({'title':''}, {'description':''}, {'expected':''}, {'title':'x'*161},
|
||||
{'description':'x'*5001}, {'expected':'x'*3001}, {'steps':'x'*3001},
|
||||
|
||||
@@ -29,6 +29,45 @@ class GiteaTests(unittest.TestCase):
|
||||
self.assertEqual(requests[-1].url.path, '/api/v1/repos/kai/pingu-concerts/issues')
|
||||
self.assertNotIn('test-secret', str(requests[-1].url))
|
||||
|
||||
def test_screenshot_is_uploaded_as_issue_asset_and_linked_into_issue_body(self):
|
||||
requests = []
|
||||
image = b"sanitized-raster-image-bytes"
|
||||
def handler(request):
|
||||
requests.append(request)
|
||||
if request.url.path.endswith('/user'):
|
||||
return httpx.Response(200, json={'login':'metalcircle-bot'})
|
||||
if request.url.path.endswith('/labels'):
|
||||
return httpx.Response(200, json=[])
|
||||
if request.method == 'POST' and request.url.path.endswith('/issues'):
|
||||
return httpx.Response(201, json={'number':123,'user':{'login':'metalcircle-bot'}})
|
||||
if request.method == 'POST' and request.url.path.endswith('/issues/123/assets'):
|
||||
self.assertEqual(request.url.params['name'], 'screenshot-safe.png')
|
||||
self.assertIn('multipart/form-data', request.headers['content-type'])
|
||||
self.assertIn(image, request.read())
|
||||
self.assertIn('filename="screenshot-safe.png"', request.read().decode('latin1'))
|
||||
return httpx.Response(201, json={'browser_download_url':'http://gitea.invalid/attachments/safe-id','id':5})
|
||||
if request.method == 'PATCH' and request.url.path.endswith('/issues/123'):
|
||||
self.assertIn('', json.loads(request.content)['body'])
|
||||
return httpx.Response(200, json={'number':123})
|
||||
raise AssertionError(f'unexpected request {request.method} {request.url.path}')
|
||||
result=self.service(handler).create_issue('Screenshot test','safe body','android',
|
||||
screenshot=('screenshot-safe.png',image,'image/png'))
|
||||
self.assertEqual(result,123)
|
||||
self.assertEqual([request.method for request in requests],['GET','GET','POST','POST','PATCH'])
|
||||
self.assertNotIn(b'test-secret-never-print', b''.join(request.content for request in requests))
|
||||
|
||||
def test_screenshot_upload_failure_is_uncertain_and_secret_free(self):
|
||||
def handler(request):
|
||||
if request.url.path.endswith('/user'): return httpx.Response(200,json={'login':'metalcircle-bot'})
|
||||
if request.url.path.endswith('/labels'): return httpx.Response(200,json=[])
|
||||
if request.method == 'POST' and request.url.path.endswith('/issues'): return httpx.Response(201,json={'number':9,'user':{'login':'metalcircle-bot'}})
|
||||
return httpx.Response(413,text='private upload failure details')
|
||||
with self.assertLogs('gitea_service','WARNING') as logs:
|
||||
with self.assertRaises(GiteaError) as caught:
|
||||
self.service(handler).create_issue('Title','Body','general',screenshot=('screenshot.jpg',b'image','image/jpeg'))
|
||||
self.assertTrue(caught.exception.uncertain)
|
||||
self.assertNotIn('private upload failure details','\n'.join(logs.output))
|
||||
|
||||
def test_missing_or_inaccessible_labels_do_not_block(self):
|
||||
for response in (httpx.Response(200, json=[]), httpx.Response(403), httpx.Response(200, json={'invalid': True})):
|
||||
def handler(request):
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
"""Image uploads are decoded and re-encoded before they leave the application."""
|
||||
from io import BytesIO
|
||||
import unittest
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from safe_images import MAX_SCREENSHOT_BYTES, ScreenshotError, sanitize_screenshot
|
||||
|
||||
|
||||
class SafeScreenshotTests(unittest.TestCase):
|
||||
def png(self, size=(32, 24), exif=None):
|
||||
image = Image.new('RGB', size, (32, 64, 128))
|
||||
out = BytesIO()
|
||||
image.save(out, format='PNG', exif=exif or b'')
|
||||
return out.getvalue()
|
||||
|
||||
def test_valid_raster_is_reencoded_without_metadata_or_appended_payload(self):
|
||||
exif = Image.Exif()
|
||||
exif[270] = 'private camera metadata fixture'
|
||||
source = self.png(exif=exif)
|
||||
payload = b'<script>not part of screenshot</script>'
|
||||
cleaned, extension, media_type = sanitize_screenshot(source + payload)
|
||||
self.assertEqual((extension, media_type), ('jpg','image/jpeg'))
|
||||
self.assertTrue(cleaned.startswith(bytes((0xff,0xd8))))
|
||||
self.assertNotIn(b'private camera metadata fixture',cleaned)
|
||||
self.assertNotIn(payload,cleaned)
|
||||
with Image.open(BytesIO(cleaned)) as image:
|
||||
self.assertEqual(image.size,(32,24))
|
||||
|
||||
def test_alpha_image_remains_png(self):
|
||||
image=Image.new('RGBA',(10,12),(0,0,0,0)); out=BytesIO(); image.save(out,format='PNG')
|
||||
cleaned,extension,media_type=sanitize_screenshot(out.getvalue())
|
||||
self.assertEqual((extension,media_type),('png','image/png'))
|
||||
with Image.open(BytesIO(cleaned)) as checked: self.assertEqual(checked.mode,'RGBA')
|
||||
|
||||
def test_rejects_non_image_svg_and_oversized_input(self):
|
||||
for contents in (b'<svg onload="alert(1)"></svg>', b'%PDF-1.7 fake document'):
|
||||
with self.subTest(contents=contents[:4]), self.assertRaises(ScreenshotError):
|
||||
sanitize_screenshot(contents)
|
||||
with self.assertRaisesRegex(ScreenshotError,'too_large'):
|
||||
sanitize_screenshot(b'X'*(MAX_SCREENSHOT_BYTES+1))
|
||||
|
||||
def test_rejects_excessive_dimensions(self):
|
||||
source=self.png((5,5))
|
||||
from unittest.mock import patch
|
||||
with patch('safe_images.MAX_SCREENSHOT_PIXELS',16), self.assertRaisesRegex(ScreenshotError,'too_large'):
|
||||
sanitize_screenshot(source)
|
||||
|
||||
|
||||
if __name__ == '__main__': unittest.main()
|
||||
@@ -5,3 +5,10 @@ Eingeloggte Mitglieder erreichen „Bug melden“ im Benutzermenü. Das Formular
|
||||
Das Backend ruft die Gitea REST API für `kai/pingu-concerts` auf und akzeptiert vorab nur die Identität `metalcircle-bot`. Vorhandene Labels wie `reported-from-metalcircle`, `bug`, `android`, `web`, `frontend` und `push` werden opportunistisch verwendet. Milestones werden derzeit nicht automatisch gesetzt; der bekannte Projekt-Milestone ist `MetalCircle 0.1 Beta`.
|
||||
|
||||
CSRF-Schutz, Pflichtfeld-/Längenprüfung, Loginpflicht, Cooldown und kurzlebige Submission-Nonces verhindern Missbrauch und Doppelmeldungen. Gitea-Ausfälle bleiben für die restliche Anwendung folgenlos. Tokens, Cookies, Header, FCM-Daten, Passwörter und private Schlüssel gelangen nicht ins Issue.
|
||||
|
||||
|
||||
## Optionale Screenshots
|
||||
|
||||
Bugreports können optional einen Screenshot als JPG, PNG oder WebP (maximal 4 MiB) enthalten. MetalCircle dekodiert das Bild mit Pillow, lehnt ungültige/animierte oder übergroße Bilder ab und kodiert die Pixel neu als JPEG/PNG. Dadurch werden Metadaten und angehängte Dateiinhalte nicht an Gitea weitergegeben. Der Originaldateiname wird verworfen; der bereinigte Upload bleibt nur während der Übermittlung im Arbeitsspeicher und wird über die Gitea Issue-Assets-API direkt an das neue Issue angehängt und dort verlinkt. Eine lokale Bugreport-Dateiablage gibt es nicht.
|
||||
|
||||
Screenshots können persönliche Inhalte enthalten. Vor dem Senden sollten Nutzer Passwörter, Zugangstoken und private Informationen im Bild verdecken. Schlägt der Anhang nach erfolgreicher Issue-Erstellung fehl, wird die Übermittlung als unbestätigt angezeigt, um doppelte Issues durch blindes erneutes Absenden zu vermeiden.
|
||||
|
||||
Reference in New Issue
Block a user