diff --git a/.env.example b/.env.example index 51f1a65..ac5bfba 100644 --- a/.env.example +++ b/.env.example @@ -8,3 +8,9 @@ INITIAL_ADMIN_EMAIL=admin@example.invalid # Produktion ausschließlich hinter HTTPS betreiben. COOKIE_SECURE=true + +# Dedicated metalcircle-bot development/test token. Never use a personal token. +GITEA_URL=http://192.168.178.5:3000 +GITEA_TOKEN= +GITEA_OWNER=kai +GITEA_REPO=pingu-concerts diff --git a/.gitignore b/.gitignore index 357235e..69b932a 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,15 @@ db/data/ .idea/ pingu-concerts-prod-snapshot.dump pingu-concerts-uploads.tar.gz +android/android/app/google-services.json +**/google-services.json +**/*firebase-adminsdk*.json +**/*service-account*.json +**/*service_account*.json +secrets/ +*.pem +*.key +*.jks +*.keystore +*.dump +app/private_uploads/ diff --git a/android/android/app/build.gradle b/android/android/app/build.gradle index e7ca18c..2f1c605 100644 --- a/android/android/app/build.gradle +++ b/android/android/app/build.gradle @@ -1,14 +1,15 @@ apply plugin: 'com.android.application' android { + buildFeatures { buildConfig true } namespace "de.pinguholic.concerts" compileSdk rootProject.ext.compileSdkVersion defaultConfig { applicationId "de.pinguholic.concerts" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 1 - versionName "1.0" + versionCode 2 + versionName "1.1.0" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" aaptOptions { // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. @@ -17,6 +18,10 @@ android { } } buildTypes { + debug { + versionNameSuffix '-debug' + resValue 'string', 'app_name', 'MetalCircle (Test)' + } release { minifyEnabled false proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' @@ -36,6 +41,8 @@ dependencies { implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion" implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion" implementation project(':capacitor-android') + // Also used by MetalCircleDevice to await token invalidation at account boundaries. + implementation "com.google.firebase:firebase-messaging:$firebaseMessagingVersion" testImplementation "junit:junit:$junitVersion" androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion" androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion" @@ -44,11 +51,16 @@ dependencies { apply from: 'capacitor.build.gradle' -try { - def servicesJSON = file('google-services.json') - if (servicesJSON.text) { - apply plugin: 'com.google.gms.google-services' - } -} catch(Exception e) { - logger.info("google-services.json not found, google-services plugin not applied. Push Notifications won't work") +if (!file('google-services.json').exists()) { + throw new GradleException('Add the local Firebase google-services.json to the Android app module before building.') +} +apply plugin: 'com.google.gms.google-services' + +gradle.taskGraph.whenReady { graph -> + if (graph.allTasks.any { it.name.toLowerCase().contains('release') }) { + def capacitorConfig = new groovy.json.JsonSlurper().parse(file('src/main/assets/capacitor.config.json')) + if (capacitorConfig.server?.cleartext || !capacitorConfig.server?.url?.startsWith('https://')) { + throw new GradleException('Release builds require an HTTPS server configuration. Run cap sync without local test mode.') + } + } } diff --git a/android/android/app/src/debug/AndroidManifest.xml b/android/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..8ee295e --- /dev/null +++ b/android/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android/android/app/src/main/AndroidManifest.xml b/android/android/app/src/main/AndroidManifest.xml index 4d7ca38..f8e6fe0 100644 --- a/android/android/app/src/main/AndroidManifest.xml +++ b/android/android/app/src/main/AndroidManifest.xml @@ -8,6 +8,9 @@ android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" android:theme="@style/AppTheme"> + + + + diff --git a/android/android/app/src/main/java/de/pinguholic/concerts/MainActivity.java b/android/android/app/src/main/java/de/pinguholic/concerts/MainActivity.java index 292b119..dd9f326 100644 --- a/android/android/app/src/main/java/de/pinguholic/concerts/MainActivity.java +++ b/android/android/app/src/main/java/de/pinguholic/concerts/MainActivity.java @@ -1,5 +1,12 @@ package de.pinguholic.concerts; import com.getcapacitor.BridgeActivity; +import android.os.Bundle; -public class MainActivity extends BridgeActivity {} +public class MainActivity extends BridgeActivity { + @Override + public void onCreate(Bundle state) { + registerPlugin(MetalCircleDevicePlugin.class); + super.onCreate(state); + } +} diff --git a/android/android/app/src/main/java/de/pinguholic/concerts/MetalCircleDevicePlugin.java b/android/android/app/src/main/java/de/pinguholic/concerts/MetalCircleDevicePlugin.java new file mode 100644 index 0000000..4f8a1ea --- /dev/null +++ b/android/android/app/src/main/java/de/pinguholic/concerts/MetalCircleDevicePlugin.java @@ -0,0 +1,91 @@ +package de.pinguholic.concerts; + +import android.app.AlertDialog; +import android.app.NotificationManager; +import android.content.ClipData; +import android.content.ClipboardManager; +import android.content.Context; +import android.content.SharedPreferences; +import com.getcapacitor.JSObject; +import com.getcapacitor.Plugin; +import com.getcapacitor.PluginCall; +import com.getcapacitor.PluginMethod; +import com.getcapacitor.annotation.CapacitorPlugin; +import com.google.firebase.messaging.FirebaseMessaging; +import java.util.UUID; + +@CapacitorPlugin(name = "MetalCircleDevice") +public class MetalCircleDevicePlugin extends Plugin { + private SharedPreferences preferences() { + return getContext().getSharedPreferences("metalcircle_push", Context.MODE_PRIVATE); + } + + @PluginMethod + public void getInfo(PluginCall call) { + SharedPreferences prefs = preferences(); + String id = prefs.getString("device_id", null); + if (id == null) { + id = UUID.randomUUID().toString(); + prefs.edit().putString("device_id", id).commit(); + } + JSObject result = new JSObject(); + result.put("deviceId", id); + result.put("appVersion", BuildConfig.VERSION_NAME); + result.put("debug", BuildConfig.DEBUG); + result.put("binding", prefs.getString("binding", "")); + call.resolve(result); + } + + @PluginMethod + public void prepareSession(PluginCall call) { + String binding = call.getString("binding", ""); + if (!binding.isEmpty() && !binding.matches("[a-f0-9]{64}")) { + call.reject("Invalid session binding"); + return; + } + if (binding.equals(preferences().getString("binding", ""))) { + call.resolve(); + return; + } + FirebaseMessaging messaging = FirebaseMessaging.getInstance(); + messaging.setAutoInitEnabled(false); + NotificationManager manager = (NotificationManager) getContext().getSystemService(Context.NOTIFICATION_SERVICE); + manager.cancelAll(); + messaging.deleteToken().addOnCompleteListener(task -> { + if (!task.isSuccessful()) { + call.reject("Push reset unavailable; please retry"); + return; + } + preferences().edit().putString("binding", binding).commit(); + call.resolve(); + }); + } + + @PluginMethod + public void showDebugToken(PluginCall call) { + // A release APK can never display or copy a registration token through this method. + if (!BuildConfig.DEBUG || preferences().getString("binding", "").isEmpty()) { + call.reject("Debug token unavailable"); + return; + } + boolean english = "en".equals(call.getString("language")); + FirebaseMessaging.getInstance().getToken().addOnCompleteListener(task -> { + if (!task.isSuccessful()) { + call.reject("Push token unavailable"); + return; + } + getActivity().runOnUiThread(() -> { + new AlertDialog.Builder(getActivity()) + .setTitle("FCM token — DEBUG ONLY") + .setMessage(task.getResult()) + .setPositiveButton(english ? "Copy" : "Kopieren", (dialog, which) -> { + ClipboardManager clipboard = (ClipboardManager) getContext().getSystemService(Context.CLIPBOARD_SERVICE); + clipboard.setPrimaryClip(ClipData.newPlainText("FCM debug token", task.getResult())); + }) + .setNegativeButton(english ? "Close" : "Schließen", null) + .show(); + call.resolve(); + }); + }); + } +} diff --git a/android/android/app/src/main/res/drawable/ic_notification.xml b/android/android/app/src/main/res/drawable/ic_notification.xml new file mode 100644 index 0000000..eda5bff --- /dev/null +++ b/android/android/app/src/main/res/drawable/ic_notification.xml @@ -0,0 +1,4 @@ + + + diff --git a/android/android/variables.gradle b/android/android/variables.gradle index 8ef305d..ce8f5e6 100644 --- a/android/android/variables.gradle +++ b/android/android/variables.gradle @@ -2,6 +2,8 @@ ext { minSdkVersion = 22 compileSdkVersion = 34 targetSdkVersion = 34 + // Capacitor Push Notifications 6.x's supported default (minSdk 22). + firebaseMessagingVersion = '23.3.1' androidxActivityVersion = '1.8.0' androidxAppCompatVersion = '1.6.1' androidxCoordinatorLayoutVersion = '1.2.0' @@ -13,4 +15,4 @@ ext { androidxJunitVersion = '1.1.5' androidxEspressoCoreVersion = '3.5.1' cordovaAndroidVersion = '10.1.1' -} \ No newline at end of file +} diff --git a/android/capacitor.config.ts b/android/capacitor.config.ts index d758b5c..4914750 100644 --- a/android/capacitor.config.ts +++ b/android/capacitor.config.ts @@ -1,12 +1,22 @@ import type { CapacitorConfig } from '@capacitor/cli'; +const serverUrl = process.env.METALCIRCLE_SERVER_URL || 'https://konzerte.pinguholic.de/'; +const localTest = process.env.METALCIRCLE_LOCAL_TEST === '1'; +if (!serverUrl.startsWith('https://') && !(localTest && /^http:\/\/(127\.0\.0\.1|localhost):\d+\/?$/.test(serverUrl))) { + throw new Error('MetalCircle requires HTTPS, or explicit localhost test mode.'); +} + const config: CapacitorConfig = { appId: 'de.pinguholic.concerts', appName: 'MetalCircle', webDir: 'www', + loggingBehavior: 'none', + plugins: { + PushNotifications: { presentationOptions: ['sound', 'alert'] } + }, server: { - url: 'https://konzerte.pinguholic.de/', - cleartext: false + url: serverUrl, + cleartext: localTest } }; diff --git a/android/package-lock.json b/android/package-lock.json index 003eb57..64e1294 100644 --- a/android/package-lock.json +++ b/android/package-lock.json @@ -10,7 +10,7 @@ "dependencies": { "@capacitor/android": "6.2.1", "@capacitor/core": "6.2.1", - "@capacitor/push-notifications": "^6.0.5" + "@capacitor/push-notifications": "6.0.5" }, "devDependencies": { "@capacitor/cli": "6.2.1", diff --git a/android/package.json b/android/package.json index c2f98e3..69a89ca 100644 --- a/android/package.json +++ b/android/package.json @@ -11,7 +11,7 @@ "dependencies": { "@capacitor/android": "6.2.1", "@capacitor/core": "6.2.1", - "@capacitor/push-notifications": "^6.0.5" + "@capacitor/push-notifications": "6.0.5" }, "devDependencies": { "@capacitor/cli": "6.2.1", diff --git a/app/bug_reporter.py b/app/bug_reporter.py new file mode 100644 index 0000000..ebcc88e --- /dev/null +++ b/app/bug_reporter.py @@ -0,0 +1,167 @@ +"""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_atCURRENT_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) diff --git a/app/feature_schema.py b/app/feature_schema.py new file mode 100644 index 0000000..1e8c6a2 --- /dev/null +++ b/app/feature_schema.py @@ -0,0 +1,30 @@ +"""Startup equivalents of migrations 19 and 20 (kept in sync by tests).""" + +FEATURE_SCHEMA = ( + '''CREATE TABLE IF NOT EXISTS push_devices ( + id BIGSERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + session_id INTEGER NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, + device_id UUID NOT NULL UNIQUE, + token TEXT NOT NULL UNIQUE CHECK (length(token) BETWEEN 20 AND 4096), + platform VARCHAR(16) NOT NULL CHECK (platform = 'android'), + app_version VARCHAR(32) NOT NULL DEFAULT '', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + last_seen_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + CREATE INDEX IF NOT EXISTS idx_push_devices_user ON push_devices(user_id); + CREATE INDEX IF NOT EXISTS idx_push_devices_session ON push_devices(session_id);''', + '''CREATE TABLE IF NOT EXISTS bug_report_submissions ( + id UUID PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + state VARCHAR(16) NOT NULL DEFAULT 'new' + CHECK (state IN ('new', 'sending', 'success', 'failed', 'unknown')), + issue_number INTEGER, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + submitted_at TIMESTAMP, + expires_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + INTERVAL '24 hours' + ); + CREATE INDEX IF NOT EXISTS idx_bug_report_submissions_user + ON bug_report_submissions(user_id, submitted_at);''', +) diff --git a/app/gitea_service.py b/app/gitea_service.py new file mode 100644 index 0000000..6d5ded6 --- /dev/null +++ b/app/gitea_service.py @@ -0,0 +1,121 @@ +"""The only component allowed to contact Gitea. Never logs payloads or credentials.""" + +from dataclasses import dataclass, field +import logging +import os +import re +from urllib.parse import quote, urlsplit + +import httpx + + +logger = logging.getLogger(__name__) + + +class GiteaError(Exception): + def __init__(self, kind, uncertain=False): + self.kind = kind + self.uncertain = uncertain + super().__init__(kind) + + +def redact(text, secrets=()): + for secret in secrets: + if secret and len(secret) >= 8: + text = text.replace(secret, '[REDACTED]') + text = re.sub(r'-----BEGIN [^-]*PRIVATE KEY-----.*?-----END [^-]*PRIVATE KEY-----', + '[REDACTED PRIVATE KEY]', text, flags=re.S) + text = re.sub(r'(?im)((?:password|passwd|authorization|cookie|[\w]*(?:token|secret|credential|api_key)[\w]*)\s*[:=]\s*)[^\r\n]+', + r'\1[REDACTED]', text) + text = re.sub(r'[A-Za-z0-9_-]{20,}:[A-Za-z0-9_-]{80,}', '[REDACTED FCM TOKEN]', text) + text = re.sub(r'(https?://)[^/\s:@]+:[^/\s@]+@', r'\1[REDACTED]@', text) + return text.replace('\x00', '') + + +@dataclass(frozen=True) +class GiteaConfig: + url: str + token: str = field(repr=False) + owner: str + repo: str + + @classmethod + def from_env(cls): + return cls(*(os.environ.get(key, '').strip() for key in + ('GITEA_URL', 'GITEA_TOKEN', 'GITEA_OWNER', 'GITEA_REPO'))) + + def validate(self): + try: + parsed = urlsplit(self.url) + except ValueError: + raise GiteaError('configuration') from None + if (not all((self.url, self.token, self.owner, self.repo)) or + parsed.scheme not in ('http', 'https') or not parsed.netloc or + parsed.username or parsed.password or parsed.query or parsed.fragment or + not re.fullmatch(r'[A-Za-z0-9_.-]+', self.owner) or + not re.fullmatch(r'[A-Za-z0-9_.-]+', self.repo)): + raise GiteaError('configuration') + + +class GiteaService: + def __init__(self, config=None, transport=None): + self.config = config or GiteaConfig.from_env() + self.transport = transport + + def create_issue(self, title, body, category): + 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 + try: + with httpx.Client(base_url=self.config.url.rstrip('/') + '/api/v1/', + headers={'Authorization': 'token ' + self.config.token, 'Accept': 'application/json'}, + timeout=httpx.Timeout(15.0, connect=5.0), follow_redirects=False, + transport=self.transport, trust_env=False) as client: + # Fail closed if a personal account token is accidentally configured. + identity = client.get('user') + identity.raise_for_status() + if identity.json().get('login') != 'metalcircle-bot': + raise GiteaError('wrong_account') + labels = [] + try: + for page in range(1, 11): + result = client.get(repository.lstrip('/') + '/labels', params={'limit': 50, 'page': page}) + result.raise_for_status() + items = result.json() + if not isinstance(items, list): + break + labels.extend(item['id'] for item in items if isinstance(item, dict) and + isinstance(item.get('name'), str) and + item.get('name', '').lower() in wanted and type(item.get('id')) is int) + if len(items) < 50: + break + except (httpx.HTTPError, ValueError, TypeError, KeyError): + # Labels are optional; permissions/missing labels must not block a report. + labels = [] + attempted = True + result = client.post(repository.lstrip('/') + '/issues', json={ + 'title': redact('[MetalCircle Bug] ' + title, (self.config.token,)), + 'body': redact(body, (self.config.token,)), 'labels': sorted(set(labels)), + }) + result.raise_for_status() + issue = result.json() + if (not isinstance(issue, dict) or type(issue.get('number')) is not int or + 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'] + except GiteaError as error: + 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 + 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 + except (ValueError, TypeError, KeyError, AttributeError, httpx.HTTPError): + logger.warning('Gitea issue submission failed: invalid_response') + raise GiteaError('invalid_response', uncertain=attempted) from None diff --git a/app/locales/en.json b/app/locales/en.json index 15ca356..c5be192 100644 --- a/app/locales/en.json +++ b/app/locales/en.json @@ -459,5 +459,48 @@ "Dieses Bild entfernen?": "Remove this image?", "Wirklich unwiderruflich löschen? Die Veranstaltung kann nicht mehr eingetragen werden.": "Really delete permanently? This event cannot be added again.", "Diesen Nutzer wirklich blockieren?": "Really block this user?", - "Account wirklich dauerhaft löschen?": "Really delete your account permanently?" + "Account wirklich dauerhaft löschen?": "Really delete your account permanently?", + "🐛 Bug melden": "🐛 Report a bug", + "Bug melden · MetalCircle": "Report a bug · MetalCircle", + "Hilf uns, MetalCircle zu verbessern.": "Help us improve MetalCircle.", + "Bugreport #{number} wurde erfolgreich erstellt. Danke!": "Bug report #{number} was created successfully. Thank you!", + "Zurück zur Übersicht": "Back to overview", + "Deine Meldung geht direkt an unser internes Ticketsystem. Sie ist für das Projektteam sichtbar.": "Your report goes directly to our internal ticket system and is visible to the project team.", + "Titel": "Title", + "(Pflichtfeld)": "(required)", + "Bugbeschreibung": "Bug description", + "Erwartetes Verhalten": "Expected behavior", + "Schritte zum Reproduzieren": "Steps to reproduce", + "Kontext": "Context", + "Zeitpunkt": "Time", + "Plattform": "Platform", + "Schweregrad": "Severity", + "Allgemein": "General", + "Webseite": "Website", + "Freunde": "Friends", + "Fotos": "Photos", + "Gering": "Minor", + "Normal": "Normal", + "Hoch": "Major", + "Kritisch": "Critical", + "Technische Informationen mitsenden (optional)": "Include technical information (optional)", + "Enthalten sind die betroffene Seite ohne URL-Parameter, Plattform, App-Version und Browsertyp. Dein Benutzername und deine User-ID werden immer aus deiner Anmeldung übernommen.": "Includes the affected page without URL parameters, platform, app version and browser type. Your username and user ID are always taken from your login session.", + "Betroffene Seite:": "Affected page:", + "Bitte keine Passwörter, Zugangstoken oder privaten Schlüssel eintragen.": "Please do not include passwords, access tokens or private keys.", + "Bugreport senden": "Send bug report", + "Der Bugreport konnte momentan nicht an unser Ticketsystem übermittelt werden. Bitte versuche es später erneut.": "The bug report could not be sent to our ticket system at the moment. Please try again later.", + "Die Übermittlung konnte nicht bestätigt werden. Bitte sende den Bericht nicht erneut und frage einen Admin, ob er angekommen ist.": "Delivery could not be confirmed. Please do not submit the report again; ask an admin whether it arrived.", + "Bitte öffne das Formular erneut.": "Please open the form again.", + "Bitte prüfe die Pflichtfelder, Textlängen, Kategorie und den Schweregrad.": "Please check the required fields, text lengths, category and severity.", + "Bitte warte eine Minute, bevor du einen weiteren Bug meldest.": "Please wait one minute before reporting another bug.", + "Meldung nicht gefunden.": "Report not found.", + "Möchtest du Benachrichtigungen von MetalCircle auf diesem Gerät erhalten?": "Would you like to receive MetalCircle notifications on this device?", + "Benachrichtigungen aktivieren": "Enable notifications", + "Später": "Later", + "Benachrichtigungen sind deaktiviert. Du kannst sie in den Android-Einstellungen für MetalCircle erlauben.": "Notifications are disabled. You can allow them in Android settings for MetalCircle.", + "Dieses Gerät ist für Benachrichtigungen registriert.": "This device is registered for notifications.", + "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." } diff --git a/app/main.py b/app/main.py index 870f88a..a7ea35e 100644 --- a/app/main.py +++ b/app/main.py @@ -30,6 +30,11 @@ from fastapi.encoders import jsonable_encoder from fastapi.staticfiles import StaticFiles from jinja2 import Environment, FileSystemLoader, select_autoescape +from fastapi.exceptions import RequestValidationError +from fastapi.exception_handlers import request_validation_exception_handler +from feature_schema import FEATURE_SCHEMA +import push_devices +import bug_reporter from i18n import ( LANGUAGE_COOKIE, current_language, current_page, gettext as _, language_url, safe_return_path, format_time, format_datetime, @@ -484,7 +489,7 @@ def ensure_schema(): with get_db_connection() as connection: with connection.cursor() as cursor: - for statement in statements: + for statement in [*statements, *FEATURE_SCHEMA]: cursor.execute(statement) if INITIAL_ADMIN_USERNAME and INITIAL_ADMIN_PASSWORD: @@ -528,6 +533,13 @@ async def lifespan(_app: FastAPI): app = FastAPI(title="MetalCircle", lifespan=lifespan) +@app.exception_handler(RequestValidationError) +async def safe_validation_error(request: Request, exc: RequestValidationError): + if request.url.path.startswith('/api/push/'): + return JSONResponse({'error': 'invalid_device'}, status_code=422) + return await request_validation_exception_handler(request, exc) + + @app.middleware("http") async def security_controls(request: Request, call_next): if COOKIE_SECURE and request.url.path not in {"/impressum", "/datenschutz"}: @@ -554,6 +566,8 @@ async def security_controls(request: Request, call_next): bucket_name, limit, window = "account", 10, 60 * 60 elif path.startswith("/messages/"): bucket_name, limit, window = "messages", 30, 60 + elif path == '/bug-report': + bucket_name, limit, window = 'bug_reports', 10, 60 * 60 elif any(part in path for part in ("/photos", "/patches")) or path in {"/profile", "/concerts"}: bucket_name, limit, window = "uploads", 20, 60 * 60 else: @@ -588,6 +602,7 @@ async def require_login(request: Request, call_next): or request.url.path.startswith("/register/") or request.url.path.startswith("/password-reset") or request.url.path in {"/impressum", "/datenschutz"} + or request.url.path == '/api/push/session' or request.url.path.startswith("/language/") or request.url.path == "/profile/export" or request.url.path.startswith("/static/") @@ -597,6 +612,8 @@ async def require_login(request: Request, call_next): if get_current_user(request): return await call_next(request) + if request.url.path.startswith('/api/push/'): + return JSONResponse({'error': 'authentication_required'}, status_code=401) return login_redirect("/") @@ -2475,6 +2492,7 @@ def login_page(request: Request, next: str = "/"): @app.post("/login") def login( + request: Request, username: str = Form(...), password: str = Form(...), next: str = Form("/"), @@ -2508,6 +2526,12 @@ def login( status_code=401, ) + # Replacing a login revokes the previous session and its push-device bindings. + old_token = request.cookies.get(SESSION_COOKIE) + if old_token: + with get_db_connection() as connection: + connection.execute('DELETE FROM sessions WHERE token_hash=%s', (hash_token(old_token),)) + connection.commit() response = RedirectResponse(next_path, status_code=303) return attach_session(response, create_session(row[0])) @@ -5087,3 +5111,7 @@ def search_venues(q: str): unique_results[key] = venue return list(unique_results.values())[:10] + + +push_devices.register_routes(app, get_db_connection, get_current_user, SESSION_COOKIE) +bug_reporter.register_routes(app, templates, get_db_connection, get_current_user) diff --git a/app/push_devices.py b/app/push_devices.py new file mode 100644 index 0000000..4e791b8 --- /dev/null +++ b/app/push_devices.py @@ -0,0 +1,79 @@ +"""Device registration only. This module deliberately contains no push sender.""" + +import hashlib +from uuid import UUID + +from fastapi import Request +from fastapi.responses import JSONResponse +from pydantic import BaseModel, Field + + +class DeviceRegistration(BaseModel): + device_id: UUID + token: str = Field(min_length=20, max_length=4096, pattern=r'^[A-Za-z0-9_:.-]+$') + platform: str = Field(pattern=r'^android$') + app_version: str = Field(default='', max_length=32, pattern=r'^[0-9A-Za-z.+_-]*$') + session_tag: str = Field(pattern=r'^[a-f0-9]{64}$') + + +def session_hash(request, cookie_name): + return hashlib.sha256(request.cookies.get(cookie_name, '').encode()).hexdigest() + + +def register_routes(app, get_db, get_user, cookie_name): + @app.get('/api/push/session') + def push_session(request: Request): + user = get_user(request) + payload = {'authenticated': bool(user)} + if user: + payload.update(user_id=user['id'], session_tag=session_hash(request, cookie_name)) + return JSONResponse(payload, headers={'Cache-Control': 'no-store'}) + + @app.post('/api/push/devices') + def register_device(request: Request, data: DeviceRegistration): + user = get_user(request) + if not user: + return JSONResponse({'error': 'authentication_required'}, status_code=401) + token_hash = session_hash(request, cookie_name) + if data.session_tag != token_hash: + return JSONResponse({'error': 'session_changed'}, status_code=409) + with get_db() as connection: + with connection.cursor() as cursor: + # The row lock serializes registration against logout/session deletion. + cursor.execute('SELECT id FROM sessions WHERE token_hash=%s AND user_id=%s ' + 'AND expires_at>CURRENT_TIMESTAMP FOR UPDATE', (token_hash, user['id'])) + session = cursor.fetchone() + if not session: + return JSONResponse({'error': 'session_expired'}, status_code=401) + # Serialize token/device transfers across users as well as token rotations. + cursor.execute('SELECT pg_advisory_xact_lock(71001)') + cursor.execute('DELETE FROM push_devices WHERE token=%s AND device_id<>%s', + (data.token, data.device_id)) + cursor.execute(''' + INSERT INTO push_devices(user_id, session_id, device_id, token, platform, app_version) + VALUES (%s,%s,%s,%s,%s,%s) + ON CONFLICT(device_id) DO UPDATE SET + user_id=EXCLUDED.user_id, session_id=EXCLUDED.session_id, + token=EXCLUDED.token, platform=EXCLUDED.platform, app_version=EXCLUDED.app_version, + updated_at=CURRENT_TIMESTAMP, last_seen_at=CURRENT_TIMESTAMP + RETURNING id + ''', (user['id'], session[0], data.device_id, data.token, data.platform, data.app_version)) + device_id = cursor.fetchone()[0] + cursor.execute('DELETE FROM push_devices WHERE session_id IN ' + '(SELECT id FROM sessions WHERE expires_at<=CURRENT_TIMESTAMP) ' + 'OR last_seen_at { + const form = document.querySelector('.bug-report-form'); + if (!form) return; + const cap = window.Capacitor; + if (cap?.getPlatform() === 'android') { + document.getElementById('bug-platform').value = 'Android'; + cap.Plugins.MetalCircleDevice?.getInfo().then(info => { + document.getElementById('bug-app-version').value = info.appVersion || ''; + }).catch(() => {}); + } + form.addEventListener('submit', () => { form.querySelector('button[type="submit"]').disabled = true; }); + window.addEventListener('pageshow', event => { if (event.persisted) location.reload(); }); +})(); diff --git a/app/static/js/native-push.js b/app/static/js/native-push.js new file mode 100644 index 0000000..d9500dd --- /dev/null +++ b/app/static/js/native-push.js @@ -0,0 +1,144 @@ +/* Android-only bridge. FCM tokens stay in memory and are never logged or persisted in JS. */ +(() => { + const config = document.getElementById('native-push-config'); + const cap = window.Capacitor; + if (!config || !cap || cap.getPlatform() !== 'android') return; + const push = cap.Plugins.PushNotifications; + const device = cap.Plugins.MetalCircleDevice; + if (!push || !device) return; + const texts = JSON.parse(config.textContent); + let binding = ''; + let info; + let stopped = false; + let running = false; + let ready = false; + let sendQueue = Promise.resolve(); + let panel; + + function notice(message, offerPermission = false) { + if (!panel) { + panel = document.createElement('section'); + panel.className = 'native-push-panel'; + (document.querySelector('main') || document.querySelector('.container') || document.body).prepend(panel); + } + panel.replaceChildren(); + const label = document.createElement('p'); + label.textContent = message; + label.setAttribute('role', 'status'); + panel.append(label); + if (offerPermission) { + const allow = document.createElement('button'); + allow.type = 'button'; + allow.className = 'button'; + allow.textContent = texts.enable; + allow.onclick = async () => { + allow.disabled = true; + try { + await push.requestPermissions(); + localStorage.setItem('metalcircle_push_prompt', 'seen'); + await synchronize(); + } catch (_) { notice(texts.failed); } + }; + const later = document.createElement('button'); + later.type = 'button'; + later.className = 'button button-secondary'; + later.textContent = texts.later; + later.onclick = () => { + localStorage.setItem('metalcircle_push_prompt', 'seen'); + panel.remove(); panel = null; + }; + panel.append(allow, later); + } + if (ready && info?.debug && location.pathname === '/profile') { + const debug = document.createElement('button'); + debug.type = 'button'; + debug.className = 'button button-secondary'; + debug.textContent = texts.debug; + debug.onclick = () => device.showDebugToken({language: document.documentElement.lang}).catch(() => notice(texts.failed)); + panel.append(debug); + } + } + + async function sendToken(token) { + if (stopped || !binding || !info) return; + const expected = binding; + const response = await fetch('/api/push/devices', { + method: 'POST', credentials: 'same-origin', redirect: 'error', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({device_id: info.deviceId, token, platform: 'android', + app_version: info.appVersion, session_tag: expected}) + }); + if (stopped || binding !== expected) return; + ready = response.ok; + if (!response.ok) { notice(texts.failed); return; } + if (location.pathname === '/profile') notice(texts.ready); + else if (panel) { panel.remove(); panel = null; } + } + + async function synchronize() { + if (stopped || running) return; + running = true; + try { + const response = await fetch('/api/push/session', {credentials: 'same-origin', cache: 'no-store', redirect: 'error'}); + if (!response.ok) return; + const session = await response.json(); + info = await device.getInfo(); + if (stopped) return; + if (!session.authenticated) { + binding = ''; ready = false; + await device.prepareSession({binding: ''}); + return; + } + const permissions = await push.checkPermissions(); + if (permissions.receive !== 'granted') { + binding = ''; ready = false; + await fetch('/api/push/devices/' + encodeURIComponent(info.deviceId), {method: 'DELETE', credentials: 'same-origin'}); + await device.prepareSession({binding: ''}); + const prompt = permissions.receive === 'prompt' || permissions.receive === 'prompt-with-rationale'; + if (location.pathname === '/profile' || (!localStorage.getItem('metalcircle_push_prompt') && prompt)) { + notice(prompt ? texts.permission : texts.denied, prompt); + } + return; + } + // Invalidate the previous account/session's FCM token before acquiring a new one. + binding = ''; + await device.prepareSession({binding: session.session_tag}); + if (stopped) return; + binding = session.session_tag; + await push.register(); + } catch (_) { + if (!stopped && location.pathname === '/profile') notice(texts.failed); + } finally { running = false; } + } + + document.addEventListener('submit', async event => { + const form = event.target; + if (!(form instanceof HTMLFormElement) || !['/logout', '/profile/delete'].includes(new URL(form.action).pathname)) return; + event.preventDefault(); + stopped = true; binding = ''; ready = false; + try { + await sendQueue; + await device.prepareSession({binding: ''}); + } catch (_) { /* Server-side session deletion still revokes every bound device. */ } + form.submit(); + }, true); + + Promise.all([ + push.addListener('registration', event => { + sendQueue = sendQueue.then(() => sendToken(event.value)).catch(() => { + if (!stopped) notice(texts.failed); + }); + }), + push.addListener('registrationError', () => { if (!stopped) notice(texts.failed); }), + push.addListener('pushNotificationActionPerformed', () => { + // Do not navigate to arbitrary URLs supplied by a notification payload. + location.assign('/'); + }) + ]).then(() => { + synchronize(); + document.addEventListener('visibilitychange', () => { if (!document.hidden) synchronize(); }); + document.addEventListener('resume', synchronize); + window.addEventListener('online', synchronize); + window.addEventListener('pageshow', synchronize); + }).catch(() => { /* The website remains usable when the bridge is unavailable. */ }); +})(); diff --git a/app/templates/_native_push.html b/app/templates/_native_push.html new file mode 100644 index 0000000..d6f0eb2 --- /dev/null +++ b/app/templates/_native_push.html @@ -0,0 +1,10 @@ + + diff --git a/app/templates/_user_menu.html b/app/templates/_user_menu.html index 523d4f5..41529e0 100644 --- a/app/templates/_user_menu.html +++ b/app/templates/_user_menu.html @@ -8,8 +8,10 @@ {% if user.is_admin %}{{ _('⚙️ Verwaltung') }}{% endif %} {{ _('Datenschutz') }} {{ _('Impressum') }} + {{ _('🐛 Bug melden') }}
+{% include '_native_push.html' %} diff --git a/app/templates/bug_report.html b/app/templates/bug_report.html new file mode 100644 index 0000000..83948bb --- /dev/null +++ b/app/templates/bug_report.html @@ -0,0 +1,57 @@ + + + + + + {{ _('Bug melden · MetalCircle') }} + + + + + +
+ + {% include '_user_menu.html' %} +
{% include '_language_switch.html' %}
+
+

{{ _('🐛 Bug melden') }}

{{ _('Hilf uns, MetalCircle zu verbessern.') }}

+
+ {% if issue_number %} +

{{ _('Bugreport #{number} wurde erfolgreich erstellt. Danke!').format(number=issue_number) }}

+ {{ _('Zurück zur Übersicht') }} + {% else %} +

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

+ {% if error %}{% endif %} +
+ + + + + + + + + +
+ + +
+ +

{{ _('Enthalten sind die betroffene Seite ohne URL-Parameter, Plattform, App-Version und Browsertyp. Dein Benutzername und deine User-ID werden immer aus deiner Anmeldung übernommen.') }}

+

{{ _('Betroffene Seite:') }} {{ form.route }}

+ + + +

{{ _('Bitte keine Passwörter, Zugangstoken oder privaten Schlüssel eintragen.') }}

+ +
+ {% endif %} +
+
+ + + diff --git a/app/templates/datenschutz.html b/app/templates/datenschutz.html index 6246b58..daf7e9f 100644 --- a/app/templates/datenschutz.html +++ b/app/templates/datenschutz.html @@ -18,6 +18,8 @@

{{ _('Die Verarbeitung erfolgt im geschlossenen Projektbetrieb zur Bereitstellung der gewünschten Funktionen und – soweit erforderlich – auf Grundlage deiner Einwilligung. Daten bleiben gespeichert, solange dein Konto besteht oder gesetzliche Aufbewahrungspflichten gelten. Nicht mehr benötigte Daten werden gelöscht.') }}

{{ _('Weitergabe und externe Dienste') }}

{{ _('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.') }}

+

{{ _('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.') }}

+

{{ _('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.') }}

{{ _('Deine Rechte') }}

{{ _('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') }} konzert@pinguholic.de.

{{ _('Cookies und Sicherheit') }}

diff --git a/app/templates/login.html b/app/templates/login.html index fc77918..5e3895c 100644 --- a/app/templates/login.html +++ b/app/templates/login.html @@ -39,5 +39,6 @@ +{% include '_native_push.html' %} diff --git a/app/templates/register.html b/app/templates/register.html index b47b8d3..1c02514 100644 --- a/app/templates/register.html +++ b/app/templates/register.html @@ -88,5 +88,6 @@ +{% include '_native_push.html' %} diff --git a/app/tests/test_feature_api.py b/app/tests/test_feature_api.py new file mode 100644 index 0000000..33bbdce --- /dev/null +++ b/app/tests/test_feature_api.py @@ -0,0 +1,210 @@ +"""Opt-in PostgreSQL integration tests, isolated in a disposable schema. + +METALCIRCLE_TEST_DATABASE=1 python -m unittest discover -s tests +Never enable this flag against a non-local database. +""" +from concurrent.futures import ThreadPoolExecutor +import os +from pathlib import Path +import re +import unittest +from unittest.mock import patch +from uuid import uuid4 + +import bcrypt +import psycopg +from psycopg import sql +from psycopg.conninfo import make_conninfo +from fastapi.testclient import TestClient + +import main +from feature_schema import FEATURE_SCHEMA +from gitea_service import GiteaError, GiteaService + + +@unittest.skipUnless(os.environ.get('METALCIRCLE_TEST_DATABASE') == '1', 'requires explicit local test DB opt-in') +class FeatureApiTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.original_dsn = main.DATABASE_URL + cls.schema = 'metalcircle_test_' + uuid4().hex + with psycopg.connect(cls.original_dsn) as db: + db.execute(sql.SQL('CREATE SCHEMA {}').format(sql.Identifier(cls.schema))) + main.DATABASE_URL = make_conninfo(cls.original_dsn, options='-csearch_path='+cls.schema) + with main.get_db_connection() as db: + db.execute(''' + CREATE TABLE users(id SERIAL PRIMARY KEY, username TEXT UNIQUE, email TEXT UNIQUE, + display_name TEXT, password_hash TEXT, is_admin BOOLEAN DEFAULT FALSE); + CREATE TABLE sessions(id SERIAL PRIMARY KEY, user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, + token_hash TEXT UNIQUE, expires_at TIMESTAMP); + CREATE TABLE friendships(addressee_id INTEGER, status TEXT); + CREATE TABLE direct_messages(recipient_id INTEGER, read_at TIMESTAMP); + CREATE TABLE event_invitations(user_id INTEGER, viewed_at TIMESTAMP); + ''') + for statement in FEATURE_SCHEMA: db.execute(statement) + cls.password_hash = bcrypt.hashpw(b'Test-only-password-123', bcrypt.gensalt()).decode() + + @classmethod + def tearDownClass(cls): + main.DATABASE_URL = cls.original_dsn + with psycopg.connect(cls.original_dsn) as db: + db.execute(sql.SQL('DROP SCHEMA {} CASCADE').format(sql.Identifier(cls.schema))) + + def setUp(self): + self.secure = patch.object(main, 'COOKIE_SECURE', False) + self.secure.start() + main.rate_limit_buckets.clear() + with main.get_db_connection() as db: + db.execute('TRUNCATE users RESTART IDENTITY CASCADE') + for username in ('tester_a', 'tester_b'): + db.execute('INSERT INTO users(username,email,display_name,password_hash) VALUES (%s,%s,%s,%s)', + (username, username+'@example.invalid', username, self.password_hash)) + self.client = TestClient(main.app) + self.client.headers['Origin'] = 'http://testserver' + self.login('tester_a') + + def tearDown(self): + self.secure.stop() + + def login(self, username): + result = self.client.post('/login', data={'username': username, 'password': 'Test-only-password-123'}, follow_redirects=False) + self.assertEqual(result.status_code, 303) + + def device(self, **updates): + data = dict(device_id=str(uuid4()), token='synthetic-fcm-token-'+'a'*120, platform='android', + app_version='1.1.0-debug', session_tag=self.client.get('/api/push/session').json()['session_tag']) + data.update(updates) + return data + + def count_devices(self): + with main.get_db_connection() as db: return db.execute('SELECT count(*) FROM push_devices').fetchone()[0] + + def test_migrations_repeat_and_match_startup_schema(self): + with main.get_db_connection() as db: + for name, runtime in zip(('19_push_devices.sql', '20_bug_report_submissions.sql'), FEATURE_SCHEMA): + source = Path('/test-migrations', name).read_text() + normalize = lambda s: re.sub(r'\s+', '', re.sub(r'--[^\n]*', '', s)) + self.assertEqual(normalize(source), normalize(runtime)) + db.execute(source) + db.execute(source) + + def test_repeated_registration_rotation_and_multiple_devices(self): + data = self.device() + for _ in range(2): self.assertEqual(self.client.post('/api/push/devices', json=data).status_code, 200) + self.assertEqual(self.count_devices(), 1) + data['token'] = 'synthetic-fcm-token-'+'b'*120 + self.assertEqual(self.client.post('/api/push/devices', json=data).status_code, 200) + with main.get_db_connection() as db: + self.assertEqual(db.execute('SELECT token FROM push_devices').fetchone()[0], data['token']) + self.assertEqual(self.client.post('/api/push/devices', json=self.device()).status_code, 200) + self.assertEqual(self.count_devices(), 2) + + def test_logout_login_user_switch_and_stale_registration(self): + data = self.device() + self.client.post('/api/push/devices', json=data) + self.client.post('/logout', follow_redirects=False) + self.assertEqual(self.count_devices(), 0) + self.assertFalse(self.client.get('/api/push/session').json()['authenticated']) + self.assertEqual(self.client.post('/api/push/devices', json=data).status_code, 401) + self.login('tester_b') + self.assertEqual(self.client.post('/api/push/devices', json=data).status_code, 409) + data['session_tag'] = self.client.get('/api/push/session').json()['session_tag'] + self.assertEqual(self.client.post('/api/push/devices', json=data).status_code, 200) + with main.get_db_connection() as db: + self.assertEqual(db.execute('SELECT user_id FROM push_devices').fetchone()[0], 2) + self.login('tester_a') # successful replacement login also revokes B's binding + self.assertEqual(self.count_devices(), 0) + + def test_token_transfer_and_owner_only_unregister(self): + data = self.device() + self.client.post('/api/push/devices', json=data) + other = TestClient(main.app, headers={'Origin': 'http://testserver'}) + other.post('/login', data={'username':'tester_b','password':'Test-only-password-123'}, follow_redirects=False) + self.assertEqual(other.delete('/api/push/devices/'+data['device_id']).status_code, 200) + self.assertEqual(self.count_devices(), 1) + transferred = dict(data, session_tag=other.get('/api/push/session').json()['session_tag']) + other.post('/api/push/devices', json=transferred) + self.client.post('/logout', follow_redirects=False) + self.assertEqual(self.count_devices(), 1) + other.post('/logout', follow_redirects=False) + self.assertEqual(self.count_devices(), 0) + + def test_push_validation_csrf_and_no_echoed_token(self): + data = self.device(token='secret invalid token!') + response = self.client.post('/api/push/devices', json=data) + self.assertEqual(response.status_code, 422) + self.assertNotIn(data['token'], response.text) + self.assertEqual(self.client.post('/api/push/devices', json=self.device(), headers={'Origin':'http://evil.invalid'}).status_code, 403) + + def new_report(self): + page = self.client.get('/bug-report?route=/concerts/481?token=secret-query') + self.assertEqual(page.status_code, 200) + return re.search(r'name="submission_id" value="([^"]+)"', page.text).group(1) + + def report(self, **updates): + data = dict(submission_id=self.new_report(), title='Local test report', description='A sufficiently detailed description.', + expected='The expected result.', steps='One, two, three.', category='android', severity='normal') + data.update(updates) + return data + + def test_report_success_session_identity_and_no_internal_link(self): + data = self.report(username='forged', user_id='9999', technical='true', route='/concerts/481?token=private') + with patch.object(GiteaService, 'create_issue', return_value=123) as create: + response = self.client.post('/bug-report', data=data) + self.assertEqual(response.status_code, 200) + self.assertIn('#123', response.text) + body = create.call_args.args[1] + self.assertIn('tester_a', body) + self.assertNotIn('forged', body) + self.assertNotIn('9999', body) + self.assertNotIn('token=private', body) + self.assertNotIn('192.168.', response.text) + self.assertNotIn('GITEA_TOKEN', response.text) + self.client.post('/logout', follow_redirects=False) + 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_validation(self): + for changes in ({'title':''}, {'description':''}, {'expected':''}, {'title':'x'*161}, + {'description':'x'*5001}, {'expected':'x'*3001}, {'steps':'x'*3001}, + {'category':'bad'}, {'severity':'bad'}): + main.rate_limit_buckets.clear() + with self.subTest(changes=list(changes)), patch.object(GiteaService, 'create_issue') as create: + self.assertEqual(self.client.post('/bug-report', data=self.report(**changes)).status_code, 400) + create.assert_not_called() + + def test_duplicate_submit_and_cooldown(self): + data = self.report() + with patch.object(GiteaService, 'create_issue', return_value=123) as create: + self.client.post('/bug-report', data=data) + self.client.post('/bug-report', data=data) + self.assertEqual(create.call_count, 1) + self.assertEqual(self.client.post('/bug-report', data=self.report()).status_code, 429) + self.assertEqual(create.call_count, 1) + + def test_parallel_duplicate(self): + data = self.report() + with patch.object(GiteaService, 'create_issue', return_value=123) as create: + with ThreadPoolExecutor(max_workers=2) as pool: + results = list(pool.map(lambda _: self.client.post('/bug-report', data=data, follow_redirects=False), range(2))) + self.assertEqual(create.call_count, 1) + self.assertTrue(all(response.status_code in (303,409) for response in results)) + + def test_gitea_failure_keeps_form_and_core_app_available(self): + data = self.report() + with patch.object(GiteaService, 'create_issue', side_effect=GiteaError('http_403')): + response = self.client.post('/bug-report', data=data) + self.assertEqual(response.status_code, 503) + self.assertIn(data['title'], response.text) + self.assertEqual(self.client.get('/impressum').status_code, 200) + + def test_unknown_delivery_is_not_retried(self): + data = self.report() + with patch.object(GiteaService, 'create_issue', side_effect=GiteaError('timeout', uncertain=True)) as create: + self.assertEqual(self.client.post('/bug-report', data=data).status_code, 503) + self.assertEqual(self.client.post('/bug-report', data=data).status_code, 409) + self.assertEqual(create.call_count, 1) + + +if __name__ == '__main__': + unittest.main() diff --git a/app/tests/test_gitea.py b/app/tests/test_gitea.py new file mode 100644 index 0000000..881ea88 --- /dev/null +++ b/app/tests/test_gitea.py @@ -0,0 +1,104 @@ +import json +import unittest +from unittest.mock import patch + +import httpx +from starlette.requests import Request + +from bug_reporter import browser_summary, issue_body, safe_route +from gitea_service import GiteaConfig, GiteaError, GiteaService, redact + + +class GiteaTests(unittest.TestCase): + def service(self, handler): + return GiteaService(GiteaConfig('http://gitea.invalid', 'test-secret-never-print', 'kai', 'pingu-concerts'), httpx.MockTransport(handler)) + + def test_existing_labels_bot_and_payload(self): + requests = [] + 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=[{'id': 7, 'name': 'reported-from-metalcircle'}, {'id': 9, 'name': 'android'}, {'id': 99, 'name': 'unrelated'}]) + return httpx.Response(201, json={'number': 123, 'user': {'login': 'metalcircle-bot'}}) + self.assertEqual(self.service(handler).create_issue('A test title', 'A test body', 'android'), 123) + payload = json.loads(requests[-1].content) + self.assertEqual(payload['labels'], [7, 9]) + self.assertEqual(payload['title'], '[MetalCircle Bug] A test title') + self.assertEqual(requests[-1].url.path, '/api/v1/repos/kai/pingu-concerts/issues') + self.assertNotIn('test-secret', str(requests[-1].url)) + + 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): + if request.url.path.endswith('/user'): return httpx.Response(200, json={'login': 'metalcircle-bot'}) + if request.url.path.endswith('/labels'): return response + self.assertEqual(json.loads(request.content)['labels'], []) + return httpx.Response(201, json={'number': 4, 'user': {'login': 'metalcircle-bot'}}) + self.assertEqual(self.service(handler).create_issue('Test title', 'body', 'general'), 4) + + def test_personal_account_is_rejected_before_post(self): + def handler(request): + self.assertEqual(request.method, 'GET') + return httpx.Response(200, json={'login': 'kai'}) + with self.assertRaisesRegex(GiteaError, 'wrong_account'): + self.service(handler).create_issue('title', 'body', 'general') + + def test_http_errors_and_secret_free_logs(self): + for code in (401, 403, 404, 500, 502): + 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=[]) + return httpx.Response(code, text='test-secret-never-print') + with self.subTest(code=code), self.assertLogs('gitea_service', 'WARNING') as logs: + with self.assertRaises(GiteaError) as caught: + self.service(handler).create_issue('title', 'body', 'general') + self.assertEqual(caught.exception.uncertain, code >= 500) + self.assertNotIn('test-secret-never-print', '\n'.join(logs.output)) + + def test_timeout_unreachable_and_invalid_response(self): + for failure in ('timeout', 'unreachable', 'invalid_json', 'invalid_issue'): + def handler(request): + if failure == 'unreachable': raise httpx.ConnectError('test-secret-never-print') + 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 failure == 'timeout': raise httpx.ReadTimeout('test-secret-never-print') + if failure == 'invalid_json': return httpx.Response(201, text='not json') + return httpx.Response(201, json={'number': 'x'}) + with self.subTest(failure=failure), self.assertLogs('gitea_service', 'WARNING') as logs: + with self.assertRaises(GiteaError) as caught: + self.service(handler).create_issue('title', 'body', 'general') + self.assertEqual(caught.exception.uncertain, failure != 'unreachable') + self.assertNotIn('test-secret', '\n'.join(logs.output)) + + def test_context_allowlist_and_redaction(self): + secret = 'session-secret-never-send' + request = Request({'type': 'http', 'headers': [ + (b'cookie', ('pingu_session='+secret).encode()), + (b'authorization', b'Bearer authorization-secret'), + (b'user-agent', b'Chrome/128.0 secret-header'), + ]}) + data = dict(description='This is '+secret, expected='Expected result', steps='', category='android', + severity='normal', technical=True, platform='Android', app_version='1.1.0-debug', + route='/concerts/1?token=secret-query') + with patch.dict('os.environ', {'GITEA_TOKEN': 'bot-secret-never-send'}): + body = issue_body(data, {'id': 12, 'username': 'real-user'}, request) + for excluded in (secret, 'authorization-secret', 'secret-header', 'secret-query', 'bot-secret-never-send'): + self.assertNotIn(excluded, body) + self.assertIn('real-user', body) + self.assertIn('12', body) + self.assertIn('/concerts/1', body) + self.assertIn('Chrome 128.0', body) + self.assertEqual(safe_route('/password-reset/sensitive'), '/') + self.assertEqual(safe_route('/register/sensitive'), '/') + self.assertEqual(browser_summary('Cookie: evil'), 'Unknown') + data['technical'] = False + self.assertNotIn('Chrome', issue_body(data, {'id': 12, 'username': 'real-user'}, request)) + self.assertNotIn('/concerts/1', issue_body(data, {'id': 12, 'username': 'real-user'}, request)) + self.assertNotIn('super-secret', redact('password=super-secret')) + self.assertNotIn('private bytes', redact('-----BEGIN PRIVATE KEY-----\nprivate bytes\n-----END PRIVATE KEY-----')) + + +if __name__ == '__main__': + unittest.main() diff --git a/app/tests/test_i18n.py b/app/tests/test_i18n.py index 585034a..ef08054 100644 --- a/app/tests/test_i18n.py +++ b/app/tests/test_i18n.py @@ -121,7 +121,8 @@ class TranslationTests(unittest.TestCase): current_language.set(language) for path in Path(main.BASE_DIR, 'templates').glob('*.html'): with self.subTest(language=language, template=path.name): - page = env.get_template(path.name).render(**context) + from bug_reporter import CATEGORIES, SEVERITIES + page = env.get_template(path.name).render(**context, categories=CATEGORIES, severities=SEVERITIES, form={}) if not path.name.startswith('_'): self.assertIn(f'', page) self.assertIn('class="language-switch"', page) diff --git a/compose.yml b/compose.yml index 5558d7a..4a0fd37 100644 --- a/compose.yml +++ b/compose.yml @@ -30,6 +30,10 @@ services: INITIAL_ADMIN_EMAIL: ${INITIAL_ADMIN_EMAIL} COOKIE_SECURE: ${COOKIE_SECURE:-true} PRIVATE_UPLOAD_DIR: /app/private_uploads + GITEA_URL: ${GITEA_URL:-} + GITEA_TOKEN: ${GITEA_TOKEN:-} + GITEA_OWNER: ${GITEA_OWNER:-} + GITEA_REPO: ${GITEA_REPO:-} volumes: - concert_uploads:/app/static/uploads diff --git a/db/migrations/19_push_devices.sql b/db/migrations/19_push_devices.sql new file mode 100644 index 0000000..dc7d77c --- /dev/null +++ b/db/migrations/19_push_devices.sql @@ -0,0 +1,14 @@ +CREATE TABLE IF NOT EXISTS push_devices ( + id BIGSERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + session_id INTEGER NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, + device_id UUID NOT NULL UNIQUE, + token TEXT NOT NULL UNIQUE CHECK (length(token) BETWEEN 20 AND 4096), + platform VARCHAR(16) NOT NULL CHECK (platform = 'android'), + app_version VARCHAR(32) NOT NULL DEFAULT '', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + last_seen_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX IF NOT EXISTS idx_push_devices_user ON push_devices(user_id); +CREATE INDEX IF NOT EXISTS idx_push_devices_session ON push_devices(session_id); diff --git a/db/migrations/20_bug_report_submissions.sql b/db/migrations/20_bug_report_submissions.sql new file mode 100644 index 0000000..45fb9b5 --- /dev/null +++ b/db/migrations/20_bug_report_submissions.sql @@ -0,0 +1,13 @@ +-- Short-lived deduplication/cooldown metadata only, never bug titles or bodies. +CREATE TABLE IF NOT EXISTS bug_report_submissions ( + id UUID PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + state VARCHAR(16) NOT NULL DEFAULT 'new' + CHECK (state IN ('new', 'sending', 'success', 'failed', 'unknown')), + issue_number INTEGER, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + submitted_at TIMESTAMP, + expires_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + INTERVAL '24 hours' +); +CREATE INDEX IF NOT EXISTS idx_bug_report_submissions_user + ON bug_report_submissions(user_id, submitted_at);