Files
pingu-concerts/app/bug_reporter.py
T

184 lines
10 KiB
Python

"""Authenticated form -> Gitea. Local storage contains only expiring submission metadata."""
from datetime import datetime
import os
import re
from urllib.parse import quote, urlsplit
from uuid import UUID, uuid4
from zoneinfo import ZoneInfo
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 _
CATEGORIES = {
'general': 'Allgemein', 'android': 'Android', 'web': 'Webseite', 'concert': 'Konzert',
'profile': 'Profil', 'friends': 'Freunde', 'messages': 'Nachrichten', 'photos': 'Fotos',
'push': 'Push', 'other': 'Sonstiges',
}
SEVERITIES = {'minor': 'Gering', 'normal': 'Normal', 'major': 'Hoch', 'critical': 'Kritisch'}
FAILURE = 'Der Bugreport konnte momentan nicht an unser Ticketsystem übermittelt werden. Bitte versuche es später erneut.'
UNCERTAIN = 'Die Übermittlung konnte nicht bestätigt werden. Bitte sende den Bericht nicht erneut und frage einen Admin, ob er angekommen ist.'
def safe_route(value):
# Never include queries, fragments, reset/invitation tokens or unknown routes.
try:
path = urlsplit(value).path
except ValueError:
return '/'
if re.fullmatch(r'/(?:|profile|following|diary|messages|events/past|concerts/new|'
r'concerts/\d+(?:/edit)?|users/[A-Za-z0-9_.-]{1,50}|'
r'admin(?:/(?:users|venues|patches|statistics))?)', path):
return path
return '/'
def bug_report_url():
return '/bug-report?route=' + quote(safe_route(current_page.get()), safe='/')
def browser_summary(user_agent):
# Only parse fixed browser names and numeric versions, never forward the raw header.
match = re.search(r'\b(Edg|Firefox|Chrome|Version)/([0-9.]{1,24})', user_agent[:1000])
return ' '.join(match.groups()) if match else 'Unknown'
def issue_body(data, user, request):
parts = [
'## ' + _('Bugbeschreibung'), data['description'],
'## ' + _('Erwartetes Verhalten'), data['expected'],
'## ' + _('Schritte zum Reproduzieren'), data['steps'] or '',
'## ' + _('Kontext'),
'- Reporter: `' + user['username'].replace('`', '') + '`',
'- MetalCircle User-ID: ' + str(user['id']),
'- ' + _('Zeitpunkt') + ': ' + datetime.now(ZoneInfo('Europe/Berlin')).strftime('%Y-%m-%d %H:%M %Z') + ' (Europe/Berlin)',
'- ' + _('Sprache') + ': ' + current_language.get(),
]
if data['technical']:
parts += ['- ' + _('Plattform') + ': ' + data['platform'],
'- Route: `' + safe_route(data['route']) + '`',
'- App-Version: ' + (data['app_version'] or ''),
'- Browser: ' + browser_summary(request.headers.get('user-agent', ''))]
parts += ['## ' + _('Kategorie'), _(CATEGORIES[data['category']]),
'## ' + _('Schweregrad'), _(SEVERITIES[data['severity']])]
# Known secrets are redacted even if pasted accidentally into a text field.
database = urlsplit(os.environ.get('DATABASE_URL', ''))
return redact('\n\n'.join(parts), (
os.environ.get('GITEA_TOKEN', ''), os.environ.get('DATABASE_URL', ''),
os.environ.get('POSTGRES_PASSWORD', ''), database.password,
request.cookies.get('pingu_session', ''),
))
def register_routes(app, templates, get_db, get_user, service_factory=GiteaService):
templates.globals['bug_report_url'] = bug_report_url
def new_submission(user_id):
identifier = uuid4()
with get_db() as connection:
connection.execute('DELETE FROM bug_report_submissions WHERE expires_at<CURRENT_TIMESTAMP')
connection.execute('INSERT INTO bug_report_submissions(id,user_id) VALUES (%s,%s)', (identifier, user_id))
connection.commit()
return identifier
def render(user, data, identifier, error=None, status=200, blocked=False, issue_number=None):
return HTMLResponse(templates.get_template('bug_report.html').render(
user=user, form=data, submission_id=identifier, error=error, blocked=blocked,
categories=CATEGORIES, severities=SEVERITIES, issue_number=issue_number,
), status_code=status, headers={'Cache-Control': 'no-store'})
@app.get('/bug-report', response_class=HTMLResponse)
def report_form(request: Request, route: str = '/'):
user = get_user(request)
if not user:
return RedirectResponse('/login', status_code=303)
return render(user, {'route': safe_route(route), 'category': 'general', 'severity': 'normal'}, new_submission(user['id']))
@app.get('/bug-report/success/{identifier}', response_class=HTMLResponse)
def report_success(request: Request, identifier: UUID):
user = get_user(request)
if not user:
return RedirectResponse('/login', status_code=303)
with get_db() as connection:
row = connection.execute('SELECT issue_number FROM bug_report_submissions '
'WHERE id=%s AND user_id=%s AND state=\'success\' AND expires_at>CURRENT_TIMESTAMP',
(identifier, user['id'])).fetchone()
if not row:
return HTMLResponse(_('Meldung nicht gefunden.'), status_code=404)
return render(user, {}, identifier, issue_number=row[0])
@app.post('/bug-report', response_class=HTMLResponse)
def submit_report(request: Request, submission_id: str = Form(''), title: str = Form(''),
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(''), screenshot: UploadFile | None = File(None)):
user = get_user(request)
if not user:
return RedirectResponse('/login', status_code=303)
data = dict(title=title.strip(), description=description.strip(), expected=expected.strip(),
steps=steps.strip(), category=category, severity=severity, technical=technical,
route=safe_route(route), platform='Android' if platform == 'Android' else 'Web',
app_version=app_version if re.fullmatch(r'[0-9][0-9A-Za-z.+_-]{0,31}', app_version) else '')
try:
identifier = UUID(submission_id)
except ValueError:
return render(user, data, new_submission(user['id']), _('Bitte öffne das Formular erneut.'), 400)
if (not 3 <= len(data['title']) <= 160 or not 10 <= len(data['description']) <= 5000 or
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'],))
row = connection.execute('SELECT state,issue_number FROM bug_report_submissions '
'WHERE id=%s AND user_id=%s AND expires_at>CURRENT_TIMESTAMP FOR UPDATE',
(identifier, user['id'])).fetchone()
if not row:
return render(user, data, identifier, _('Bitte öffne das Formular erneut.'), 400, blocked=True)
if row[0] == 'success':
return RedirectResponse(f'/bug-report/success/{identifier}', status_code=303)
if row[0] != 'new':
return render(user, data, identifier, _(UNCERTAIN), 409, blocked=True)
recent = connection.execute('SELECT 1 FROM bug_report_submissions WHERE user_id=%s '
'AND submitted_at>CURRENT_TIMESTAMP-INTERVAL \'60 seconds\' LIMIT 1',
(user['id'],)).fetchone()
if recent:
return render(user, data, identifier, _('Bitte warte eine Minute, bevor du einen weiteren Bug meldest.'), 429)
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, screenshot=screenshot_payload)
except GiteaError as error:
with get_db() as connection:
connection.execute('UPDATE bug_report_submissions SET state=%s WHERE id=%s',
('unknown' if error.uncertain else 'failed', identifier))
connection.commit()
if error.uncertain:
return render(user, data, identifier, _(UNCERTAIN), 503, blocked=True)
return render(user, data, new_submission(user['id']), _(FAILURE), 503)
with get_db() as connection:
connection.execute('UPDATE bug_report_submissions SET state=\'success\',issue_number=%s WHERE id=%s', (number, identifier))
connection.commit()
return RedirectResponse(f'/bug-report/success/{identifier}', status_code=303)