Add FCM device registration and Gitea bug reporter
This commit is contained in:
@@ -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
|
||||
|
||||
+12
@@ -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/
|
||||
|
||||
@@ -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) {
|
||||
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.')
|
||||
}
|
||||
}
|
||||
} catch(Exception e) {
|
||||
logger.info("google-services.json not found, google-services plugin not applied. Push Notifications won't work")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- Only debug APKs can load the localhost server reached through adb reverse. -->
|
||||
<application android:usesCleartextTraffic="true" />
|
||||
</manifest>
|
||||
@@ -8,6 +8,9 @@
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/AppTheme">
|
||||
<meta-data android:name="firebase_messaging_auto_init_enabled" android:value="false" />
|
||||
<meta-data android:name="firebase_analytics_collection_enabled" android:value="false" />
|
||||
<meta-data android:name="com.google.firebase.messaging.default_notification_icon" android:resource="@drawable/ic_notification" />
|
||||
|
||||
<activity
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode"
|
||||
@@ -38,4 +41,5 @@
|
||||
<!-- Permissions -->
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
</manifest>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
|
||||
<path android:fillColor="#FFFFFFFF" android:pathData="M12,2a10,10 0,1 0,0 20a10,10 0,1 0,0 -20M12,5a7,7 0,1 1,0 14a7,7 0,1 1,0 -14M7,8h2l3,4 3,-4h2v8h-2v-5l-3,4 -3,-4v5H7z" />
|
||||
</vector>
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Generated
+1
-1
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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_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)
|
||||
@@ -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);''',
|
||||
)
|
||||
@@ -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
|
||||
+44
-1
@@ -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."
|
||||
}
|
||||
|
||||
+29
-1
@@ -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)
|
||||
|
||||
@@ -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<CURRENT_TIMESTAMP - INTERVAL \'90 days\'')
|
||||
connection.commit()
|
||||
return JSONResponse({'registered': True, 'id': device_id}, headers={'Cache-Control': 'no-store'})
|
||||
|
||||
@app.delete('/api/push/devices/{device_id}')
|
||||
def unregister_device(request: Request, device_id: UUID):
|
||||
user = get_user(request)
|
||||
if not user:
|
||||
return JSONResponse({'error': 'authentication_required'}, status_code=401)
|
||||
with get_db() as connection:
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute('DELETE FROM push_devices WHERE device_id=%s AND user_id=%s '
|
||||
'AND session_id IN (SELECT id FROM sessions WHERE token_hash=%s)',
|
||||
(device_id, user['id'], session_hash(request, cookie_name)))
|
||||
connection.commit()
|
||||
return JSONResponse({'registered': False}, headers={'Cache-Control': 'no-store'})
|
||||
@@ -592,3 +592,20 @@ h1, .page-title h1 {
|
||||
color: var(--muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.native-push-panel { padding:16px; margin:16px 0; border:1px solid var(--border); border-radius:10px; background:var(--surface); }
|
||||
.native-push-panel .button { margin:4px 8px 4px 0; }
|
||||
.bug-report-section { max-width:820px; margin:0 auto; }
|
||||
.bug-report-form { display:grid; gap:10px; max-width:none; }
|
||||
.bug-report-form input:not([type="checkbox"]):not([type="hidden"]),
|
||||
.bug-report-form textarea, .bug-report-form select { width:100%; margin:0; padding:12px; border:1px solid var(--border); border-radius:8px; background:#101010; color:var(--text); font:inherit; }
|
||||
.bug-report-form textarea { resize:vertical; }
|
||||
.bug-report-options { display:grid; grid-template-columns:1fr 1fr; gap:16px; }
|
||||
.bug-report-options label { display:grid; gap:8px; }
|
||||
.bug-report-form .bug-context { display:flex; align-items:flex-start; gap:10px; margin-top:14px; }
|
||||
.bug-context input { width:auto; margin-top:3px; }
|
||||
.bug-help { color:var(--muted); font-size:.9rem; margin:0 0 8px; overflow-wrap:anywhere; }
|
||||
.bug-error { color:#fca5a5; }
|
||||
.bug-success { color:#bbf7d0; }
|
||||
.bug-report-form button:disabled { opacity:.6; cursor:wait; }
|
||||
@media(max-width:600px) { .bug-report-options { grid-template-columns:1fr; } }
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
(() => {
|
||||
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(); });
|
||||
})();
|
||||
@@ -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. */ });
|
||||
})();
|
||||
@@ -0,0 +1,10 @@
|
||||
<script id="native-push-config" type="application/json">{{ {
|
||||
'permission': _('Möchtest du Benachrichtigungen von MetalCircle auf diesem Gerät erhalten?'),
|
||||
'enable': _('Benachrichtigungen aktivieren'),
|
||||
'later': _('Später'),
|
||||
'denied': _('Benachrichtigungen sind deaktiviert. Du kannst sie in den Android-Einstellungen für MetalCircle erlauben.'),
|
||||
'ready': _('Dieses Gerät ist für Benachrichtigungen registriert.'),
|
||||
'failed': _('Die Push-Registrierung ist momentan nicht möglich. Bitte versuche es später erneut.'),
|
||||
'debug': _('FCM-Testtoken anzeigen (nur Debug-App)')
|
||||
} | tojson }}</script>
|
||||
<script src="/static/js/native-push.js" defer></script>
|
||||
@@ -8,8 +8,10 @@
|
||||
{% if user.is_admin %}<a href="/admin">{{ _('⚙️ Verwaltung') }}</a>{% endif %}
|
||||
<a href="/datenschutz">{{ _('Datenschutz') }}</a>
|
||||
<a href="/impressum">{{ _('Impressum') }}</a>
|
||||
<a href="{{ bug_report_url() }}">{{ _('🐛 Bug melden') }}</a>
|
||||
<form method="post" action="/logout">
|
||||
<button type="submit">{{ _('Abmelden') }}</button>
|
||||
</form>
|
||||
</div>
|
||||
</details>
|
||||
{% include '_native_push.html' %}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ language() }}">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{ _('Bug melden · MetalCircle') }}</title>
|
||||
<link rel="icon" href="/static/images/metalcircle-circle.png">
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
<link rel="stylesheet" href="/static/css/language.css">
|
||||
</head>
|
||||
<body>
|
||||
<header><div class="header-inner">
|
||||
<a href="/" class="logo"><img class="brand-logo" src="/static/images/metalcircle-full.png" alt="MetalCircle"></a>
|
||||
{% include '_user_menu.html' %}
|
||||
</div>{% include '_language_switch.html' %}</header>
|
||||
<main>
|
||||
<div class="page-title"><h1>{{ _('🐛 Bug melden') }}</h1><p>{{ _('Hilf uns, MetalCircle zu verbessern.') }}</p></div>
|
||||
<section class="admin-section bug-report-section">
|
||||
{% if issue_number %}
|
||||
<p class="bug-success" role="status">{{ _('Bugreport #{number} wurde erfolgreich erstellt. Danke!').format(number=issue_number) }}</p>
|
||||
<a class="button" href="/">{{ _('Zurück zur Übersicht') }}</a>
|
||||
{% 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">
|
||||
<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 }}">
|
||||
<label for="bug-description">{{ _('Bugbeschreibung') }} <small>{{ _('(Pflichtfeld)') }}</small></label>
|
||||
<textarea id="bug-description" name="description" required minlength="10" maxlength="5000" rows="5">{{ form.description }}</textarea>
|
||||
<label for="bug-expected">{{ _('Erwartetes Verhalten') }} <small>{{ _('(Pflichtfeld)') }}</small></label>
|
||||
<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>
|
||||
<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>
|
||||
</label>
|
||||
<label for="bug-severity">{{ _('Schweregrad') }}
|
||||
<select id="bug-severity" name="severity">{% for value, label in severities.items() %}<option value="{{ value }}"{% if form.severity == value %} selected{% endif %}>{{ label | t }}</option>{% endfor %}</select>
|
||||
</label>
|
||||
</div>
|
||||
<label class="bug-context"><input type="checkbox" name="technical" value="true"{% if form.technical %} checked{% endif %}> {{ _('Technische Informationen mitsenden (optional)') }}</label>
|
||||
<p class="bug-help">{{ _('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.') }}</p>
|
||||
<p class="bug-help">{{ _('Betroffene Seite:') }} <code>{{ form.route }}</code></p>
|
||||
<input type="hidden" name="route" value="{{ form.route }}">
|
||||
<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>
|
||||
<button type="submit" class="button"{% if blocked %} disabled{% endif %}>{{ _('Bugreport senden') }}</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</section>
|
||||
</main>
|
||||
<script src="/static/js/bug-report.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -18,6 +18,8 @@
|
||||
<p>{{ _('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.') }}</p>
|
||||
<h2>{{ _('Weitergabe und externe Dienste') }}</h2>
|
||||
<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>{{ _('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>
|
||||
<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>
|
||||
|
||||
@@ -39,5 +39,6 @@
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
{% include '_native_push.html' %}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -88,5 +88,6 @@
|
||||
|
||||
</form>
|
||||
|
||||
{% include '_native_push.html' %}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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'<html lang="{language}">', page)
|
||||
self.assertIn('class="language-switch"', page)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user