168 lines
9.2 KiB
Python
168 lines
9.2 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 Form, Request
|
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
|
|
|
from gitea_service import GiteaError, GiteaService, redact
|
|
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('')):
|
|
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)
|
|
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)
|
|
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)
|