diff --git a/Taskfile.yml b/Taskfile.yml index df3fb89..ad944f8 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -426,6 +426,25 @@ tasks: fi echo "Uploaded $TARBALL and updated latest.json" + deploy-bugreport: + desc: Build and deploy the Go bugreport server to the webserver + preconditions: + - sh: test -n "$SSH_USER" + msg: "SSH_USER is not set" + - sh: test -n "$SSH_HOST" + msg: "SSH_HOST is not set" + - sh: test -n "$SSH_KNOWN_HOSTS" + msg: "SSH_KNOWN_HOSTS is not set" + cmds: + - cd server/bugreport && CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o ../../build/bugreport-server . + - | + mkdir -p ~/.ssh + printf '%s\n' "$SSH_KNOWN_HOSTS" >> ~/.ssh/known_hosts + ssh "$SSH_USER@$SSH_HOST" "mkdir -p bugreport/reports" + scp build/bugreport-server "$SSH_USER@$SSH_HOST:bugreport/bugreport-server" + ssh "root@$SSH_HOST" "systemctl daemon-reload && systemctl restart bugreport" + echo "Uploaded bugreport-server to $SSH_HOST and restarted service" + build-windows-release: desc: Build the Windows desktop app (release) — must run on a Windows machine with MSVC deps: [_pub-get, generate-changelog] diff --git a/lib/ui/router.dart b/lib/ui/router.dart index 1fd35a2..caff49a 100644 --- a/lib/ui/router.dart +++ b/lib/ui/router.dart @@ -8,6 +8,7 @@ import 'package:sharedinbox/ui/screens/account_receive_screen.dart'; import 'package:sharedinbox/ui/screens/account_send_screen.dart'; import 'package:sharedinbox/ui/screens/add_account_screen.dart'; import 'package:sharedinbox/ui/screens/address_emails_screen.dart'; +import 'package:sharedinbox/ui/screens/bug_report_screen.dart'; import 'package:sharedinbox/ui/screens/changelog_screen.dart'; import 'package:sharedinbox/ui/screens/combined_inbox_screen.dart'; import 'package:sharedinbox/ui/screens/compose_screen.dart'; @@ -169,6 +170,12 @@ final router = GoRouter( ); }, ), + GoRoute( + path: '/bug-report', + builder: (ctx, state) => BugReportScreen( + emailId: state.uri.queryParameters['emailId'], + ), + ), ], ), ], diff --git a/lib/ui/screens/about_screen.dart b/lib/ui/screens/about_screen.dart index 24c7f3a..7e2ecf9 100644 --- a/lib/ui/screens/about_screen.dart +++ b/lib/ui/screens/about_screen.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_markdown_plus/flutter_markdown_plus.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:sharedinbox/core/models/account.dart'; import 'package:sharedinbox/di.dart'; @@ -197,22 +198,30 @@ class _AboutScreenState extends ConsumerState { Expanded( child: OutlinedButton.icon( icon: const Icon(Icons.copy), - label: const Text('Copy to clipboard'), + label: const Text('Copy info'), onPressed: () => unawaited( _copyToClipboard(context, imapCount, jmapCount), ), ), ), - const SizedBox(width: 8), + const SizedBox(width: 4), Expanded( - child: FilledButton.icon( - icon: const Icon(Icons.bug_report), - label: const Text('Create issue'), + child: OutlinedButton.icon( + icon: const Icon(Icons.bug_report_outlined), + label: const Text('Public issue'), onPressed: () => unawaited( _createIssue(context, imapCount, jmapCount), ), ), ), + const SizedBox(width: 4), + Expanded( + child: FilledButton.icon( + icon: const Icon(Icons.feedback_outlined), + label: const Text('Report bug'), + onPressed: () => context.push('/bug-report'), + ), + ), ], ), ), diff --git a/lib/ui/screens/bug_report_screen.dart b/lib/ui/screens/bug_report_screen.dart new file mode 100644 index 0000000..0612dfc --- /dev/null +++ b/lib/ui/screens/bug_report_screen.dart @@ -0,0 +1,635 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_markdown_plus/flutter_markdown_plus.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:http/http.dart' as http; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:sharedinbox/core/models/account.dart'; +import 'package:sharedinbox/core/models/email.dart'; +import 'package:sharedinbox/core/repositories/sync_log_repository.dart'; +import 'package:sharedinbox/di.dart'; +import 'package:sharedinbox/ui/utils/about_markdown.dart'; + +const _bugReportApiUrl = String.fromEnvironment( + 'BUG_REPORT_API_URL', + defaultValue: 'https://sharedinbox.de/api/v1/bug-reports', +); + +class BugReportScreen extends ConsumerStatefulWidget { + const BugReportScreen({super.key, this.emailId}); + + final String? emailId; + + @override + ConsumerState createState() => _BugReportScreenState(); +} + +class _BugReportScreenState extends ConsumerState { + final _formKey = GlobalKey(); + final _descriptionController = TextEditingController(); + final _emailController = TextEditingController(); + + final Future _packageInfoFuture = PackageInfo.fromPlatform(); + late final Future _deviceModelFuture = getDeviceModel(); + + final List _attachments = []; + bool _includeEmail = false; + bool _includeSyncLog = false; + bool _submitting = false; + + Email? _attachedEmail; + List _accounts = []; + String? _selectedAccountId; + String? _deviceModel; + bool _loadingEmail = false; + + @override + void initState() { + super.initState(); + unawaited(_loadInitialData()); + } + + @override + void dispose() { + _descriptionController.dispose(); + _emailController.dispose(); + super.dispose(); + } + + Future _loadInitialData() async { + setState(() => _loadingEmail = true); + try { + _deviceModel = await _deviceModelFuture; + _accounts = + await ref.read(accountRepositoryProvider).observeAccounts().first; + + if (widget.emailId != null) { + final email = + await ref.read(emailRepositoryProvider).getEmail(widget.emailId!); + if (mounted && email != null) { + _attachedEmail = email; + _selectedAccountId = email.accountId; + final fromStr = + email.from.isNotEmpty ? email.from.first.toString() : 'unknown'; + final subjectStr = email.subject ?? '(no subject)'; + _descriptionController.text = + 'Problem with email from $fromStr: "$subjectStr"\n\n'; + } + } + + if (_selectedAccountId == null && _accounts.isNotEmpty) { + _selectedAccountId = _accounts.first.id; + } + + if (_selectedAccountId != null) { + final matching = + _accounts.where((a) => a.id == _selectedAccountId).firstOrNull; + if (matching != null) { + _emailController.text = matching.email; + } + } + } catch (_) {} + if (mounted) { + setState(() => _loadingEmail = false); + } + } + + int get _totalAttachmentSize { + return _attachments.fold(0, (sum, f) => sum + f.size); + } + + String _formatSize(int bytes) { + if (bytes < 1024) return '$bytes B'; + if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB'; + return '${(bytes / (1024 * 1024)).toStringAsFixed(2)} MB'; + } + + Future _pickAttachments() async { + try { + final result = await FilePicker.pickFiles(); + if (result == null) return; + final newFiles = + result.files.where((PlatformFile f) => f.path != null).toList(); + if (!mounted) return; + setState(() { + _attachments.addAll(newFiles); + }); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Failed to pick files: $e')), + ); + } + } + } + + void _removeAttachment(int index) { + setState(() { + _attachments.removeAt(index); + }); + } + + String _serializeSyncLogs(List entries) { + final sb = StringBuffer(); + for (final entry in entries.take(50)) { + sb.writeln('ID: ${entry.id}'); + sb.writeln('Started: ${entry.startedAt.toIso8601String()}'); + sb.writeln('Finished: ${entry.finishedAt.toIso8601String()}'); + sb.writeln('Result: ${entry.result}'); + if (entry.errorMessage != null) { + sb.writeln('Error: ${entry.errorMessage}'); + } + if (entry.stackTrace != null) { + sb.writeln('StackTrace:\n${entry.stackTrace}'); + } + sb.writeln('Protocol: ${entry.protocol}'); + sb.writeln( + 'Fetched: ${entry.emailsFetched}, Skipped: ${entry.emailsSkipped}', + ); + if (entry.protocolLog != null) { + sb.writeln('Protocol Log:\n${entry.protocolLog}'); + } + sb.writeln('---'); + } + return sb.toString(); + } + + Future _submitReport() async { + if (!_formKey.currentState!.validate()) return; + + final totalSize = _totalAttachmentSize; + if (totalSize > 20 * 1024 * 1024) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'Total attachments size exceeds the 20 MB limit. Please remove some files.', + ), + backgroundColor: Colors.red, + ), + ); + return; + } + + setState(() => _submitting = true); + + try { + final client = ref.read(httpClientProvider); + final uri = Uri.parse(_bugReportApiUrl); + final request = http.MultipartRequest('POST', uri); + + // Description + request.fields['description'] = _descriptionController.text; + + // Email Data if from email view + if (_attachedEmail != null) { + final emailMap = { + 'id': _attachedEmail!.id, + 'subject': _attachedEmail!.subject, + 'from': _attachedEmail!.from.map((e) => e.toString()).toList(), + 'date': _attachedEmail!.sentAt?.toIso8601String() ?? + _attachedEmail!.receivedAt.toIso8601String(), + 'preview': _attachedEmail!.preview, + }; + request.fields['email_data'] = jsonEncode(emailMap); + } + + // Contact Email + if (_includeEmail) { + request.fields['email'] = _emailController.text; + } + + // About Info + PackageInfo? pkg; + try { + pkg = await _packageInfoFuture; + } catch (_) {} + final imapCount = + _accounts.where((a) => a.type == AccountType.imap).length; + final jmapCount = + _accounts.where((a) => a.type == AccountType.jmap).length; + + if (!mounted) return; + final aboutInfo = buildAboutMarkdown( + context: context, + pkg: pkg, + imapCount: imapCount, + jmapCount: jmapCount, + deviceModel: _deviceModel, + ); + request.fields['about_info'] = aboutInfo; + + // Sync Log + if (_includeSyncLog && _selectedAccountId != null) { + final syncLogs = await ref + .read(syncLogRepositoryProvider) + .observeSyncLogs(_selectedAccountId!) + .first; + request.fields['sync_log'] = _serializeSyncLogs(syncLogs); + } + + // Attachments + for (final file in _attachments) { + final multipartFile = await http.MultipartFile.fromPath( + 'attachments[]', + file.path!, + filename: file.name, + ); + request.files.add(multipartFile); + } + + final streamedResponse = await client.send(request); + final response = await http.Response.fromStream(streamedResponse); + + if (!mounted) return; + + if (response.statusCode == 201) { + final resData = jsonDecode(response.body) as Map; + final reportId = resData['id'] as String; + _showSuccessDialog(reportId); + } else if (response.statusCode == 429) { + final retryAfter = response.headers['retry-after'] ?? '6'; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Rate limited. Please retry in $retryAfter seconds.'), + backgroundColor: Colors.orange, + ), + ); + } else { + String errorMsg = + 'Failed to submit report. Server returned status: ${response.statusCode}'; + try { + final resData = jsonDecode(response.body) as Map; + if (resData['error'] != null) { + errorMsg = resData['error'] as String; + } + } catch (_) {} + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(errorMsg), + backgroundColor: Colors.red, + ), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('An error occurred: $e'), + backgroundColor: Colors.red, + ), + ); + } + } finally { + if (mounted) { + setState(() => _submitting = false); + } + } + } + + void _showSuccessDialog(String reportId) { + unawaited( + showDialog( + context: context, + barrierDismissible: false, + builder: (context) { + return AlertDialog( + title: const Text('Bug Report Submitted'), + content: SingleChildScrollView( + child: ListBody( + children: [ + const Text('Thank you for helping us improve SharedInbox!'), + const SizedBox(height: 12), + Text( + 'Your Report ID is:\n$reportId', + style: const TextStyle(fontWeight: FontWeight.bold), + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + const Text( + 'Your report is handled confidentially and has not been posted to the public issue tracker.', + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(); // Dismiss dialog + context.pop(); // Go back to previous screen + }, + child: const Text('Close'), + ), + ], + ); + }, + ), + ); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final totalSize = _totalAttachmentSize; + const sizeLimit = 20 * 1024 * 1024; + final approachingLimit = totalSize > 15 * 1024 * 1024; + + return Scaffold( + appBar: AppBar( + title: const Text('Report a Bug'), + ), + body: _loadingEmail + ? const Center(child: CircularProgressIndicator()) + : Form( + key: _formKey, + child: ListView( + padding: const EdgeInsets.all(16.0), + children: [ + // Confidentiality info card + Card( + elevation: 0, + color: theme.colorScheme.secondaryContainer + .withValues(alpha: 0.4), + shape: RoundedRectangleBorder( + side: BorderSide( + color: + theme.colorScheme.secondary.withValues(alpha: 0.4), + ), + borderRadius: BorderRadius.circular(12), + ), + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Row( + children: [ + Icon( + Icons.lock_outline, + color: theme.colorScheme.secondary, + ), + const SizedBox(width: 16), + const Expanded( + child: Text( + 'Your report is handled confidentially and will not be posted to the public issue tracker.', + style: TextStyle(height: 1.3), + ), + ), + ], + ), + ), + ), + const SizedBox(height: 20), + + // Description Text Field + TextFormField( + controller: _descriptionController, + autofocus: true, + maxLines: 8, + minLines: 4, + decoration: const InputDecoration( + labelText: 'What went wrong?', + alignLabelWithHint: true, + border: OutlineInputBorder(), + helperText: + 'Please describe the problem and how to reproduce it.', + ), + validator: (value) { + if (value == null || value.trim().isEmpty) { + return 'Please enter a description.'; + } + return null; + }, + ), + const SizedBox(height: 20), + + // Email info chip if email is attached + if (_attachedEmail != null) ...[ + Card( + elevation: 0, + color: theme.colorScheme.surfaceContainerHighest, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 12.0, + vertical: 8.0, + ), + child: Row( + children: [ + Icon( + Icons.email_outlined, + size: 20, + color: theme.colorScheme.primary, + ), + const SizedBox(width: 12), + const Expanded( + child: Text( + 'The current email metadata will be attached automatically.', + style: TextStyle(fontSize: 13), + ), + ), + ], + ), + ), + ), + const SizedBox(height: 16), + ], + + // Attachments Section + Text( + 'Attachments', + style: theme.textTheme.titleMedium, + ), + const SizedBox(height: 8), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + OutlinedButton.icon( + onPressed: _submitting ? null : _pickAttachments, + icon: const Icon(Icons.add_a_photo_outlined), + label: const Text('Add screenshots'), + ), + const SizedBox(width: 16), + const Expanded( + child: Text( + 'Screenshots help us understand the problem faster.', + style: TextStyle(fontSize: 12, color: Colors.grey), + ), + ), + ], + ), + if (_attachments.isNotEmpty) ...[ + const SizedBox(height: 12), + SizedBox( + height: 48, + child: ListView.builder( + scrollDirection: Axis.horizontal, + itemCount: _attachments.length, + itemBuilder: (context, index) { + final file = _attachments[index]; + return Padding( + padding: const EdgeInsets.only(right: 8.0), + child: InputChip( + label: Text( + '${file.name} (${_formatSize(file.size)})', + ), + onDeleted: _submitting + ? null + : () => _removeAttachment(index), + ), + ); + }, + ), + ), + const SizedBox(height: 8), + Row( + children: [ + Text( + 'Total Attachment Size: ${_formatSize(totalSize)} / ${_formatSize(sizeLimit)}', + style: TextStyle( + fontSize: 12, + color: totalSize > sizeLimit + ? Colors.red + : approachingLimit + ? Colors.orange + : Colors.grey, + fontWeight: approachingLimit + ? FontWeight.bold + : FontWeight.normal, + ), + ), + if (totalSize > sizeLimit) ...[ + const SizedBox(width: 8), + const Icon( + Icons.error_outline, + size: 16, + color: Colors.red, + ), + ], + ], + ), + ], + const SizedBox(height: 24), + + // Email opt-in + CheckboxListTile( + title: const Text('Include my email for follow-up'), + value: _includeEmail, + onChanged: _submitting + ? null + : (val) { + setState(() => _includeEmail = val ?? false); + }, + controlAffinity: ListTileControlAffinity.leading, + contentPadding: EdgeInsets.zero, + ), + if (_includeEmail) ...[ + Padding( + padding: const EdgeInsets.only(bottom: 16.0), + child: TextFormField( + controller: _emailController, + keyboardType: TextInputType.emailAddress, + decoration: const InputDecoration( + labelText: 'Contact Email Address', + border: OutlineInputBorder(), + ), + validator: (value) { + if (_includeEmail && + (value == null || value.trim().isEmpty)) { + return 'Please enter an email address.'; + } + return null; + }, + ), + ), + ], + + // Sync log opt-in + if (_selectedAccountId != null) ...[ + CheckboxListTile( + title: const Text('Include recent sync log'), + subtitle: const Text( + 'Helps diagnose connection and protocol issues.', + ), + value: _includeSyncLog, + onChanged: _submitting + ? null + : (val) { + setState(() => _includeSyncLog = val ?? false); + }, + controlAffinity: ListTileControlAffinity.leading, + contentPadding: EdgeInsets.zero, + ), + const SizedBox(height: 12), + ], + + // System info section + FutureBuilder( + future: _packageInfoFuture, + builder: (context, snapshot) { + final imapCount = _accounts + .where((a) => a.type == AccountType.imap) + .length; + final jmapCount = _accounts + .where((a) => a.type == AccountType.jmap) + .length; + final aboutMd = buildAboutMarkdown( + context: context, + pkg: snapshot.data, + imapCount: imapCount, + jmapCount: jmapCount, + deviceModel: _deviceModel, + ); + return Card( + elevation: 0, + shape: RoundedRectangleBorder( + side: BorderSide( + color: theme.dividerColor.withValues(alpha: 0.1), + ), + borderRadius: BorderRadius.circular(8), + ), + child: ExpansionTile( + title: const Text( + 'System Info (attached automatically)', + style: TextStyle(fontSize: 14), + ), + children: [ + Padding( + padding: const EdgeInsets.all(12.0), + child: Align( + alignment: Alignment.topLeft, + child: MarkdownBody(data: aboutMd), + ), + ), + ], + ), + ); + }, + ), + const SizedBox(height: 32), + + // Submit Button + FilledButton( + onPressed: _submitting ? null : _submitReport, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 12.0), + child: _submitting + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : const Text( + 'Send Bug Report', + style: TextStyle(fontSize: 16), + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/ui/screens/email_detail_screen.dart b/lib/ui/screens/email_detail_screen.dart index f424f63..d3589a9 100644 --- a/lib/ui/screens/email_detail_screen.dart +++ b/lib/ui/screens/email_detail_screen.dart @@ -141,6 +141,11 @@ class _EmailDetailScreenState extends ConsumerState { child: Text('Show Mail Structure'), ), const PopupMenuItem(value: 'rfc', child: Text('Show Raw Email')), + const PopupMenuDivider(), + const PopupMenuItem( + value: 'bug_report', + child: Text('Report a Bug'), + ), ], onSelected: (value) async { if (value == 'forward' && header != null) { @@ -161,6 +166,10 @@ class _EmailDetailScreenState extends ConsumerState { _showStructure(context, body); } else if (value == 'rfc') { unawaited(_showRaw(context, header)); + } else if (value == 'bug_report') { + unawaited( + context.push('/bug-report?emailId=${widget.emailId}'), + ); } }, ), diff --git a/scripts/check_coverage.dart b/scripts/check_coverage.dart index f910024..c1a76de 100644 --- a/scripts/check_coverage.dart +++ b/scripts/check_coverage.dart @@ -41,6 +41,7 @@ const _excluded = { 'lib/ui/screens/account_send_screen.dart', 'lib/ui/screens/add_account_screen.dart', 'lib/ui/screens/address_emails_screen.dart', + 'lib/ui/screens/bug_report_screen.dart', 'lib/ui/screens/changelog_screen.dart', 'lib/ui/screens/combined_inbox_screen.dart', 'lib/ui/screens/compose_screen.dart', diff --git a/server/bugreport/go.mod b/server/bugreport/go.mod new file mode 100644 index 0000000..60d6f53 --- /dev/null +++ b/server/bugreport/go.mod @@ -0,0 +1,3 @@ +module sharedinbox.de/bugreport + +go 1.21 diff --git a/server/bugreport/main.go b/server/bugreport/main.go new file mode 100644 index 0000000..8850e91 --- /dev/null +++ b/server/bugreport/main.go @@ -0,0 +1,282 @@ +package main + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "log" + "net" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + "sync" + "time" +) + +// BugReport represents the data stored in report.json +type BugReport struct { + Description string `json:"description"` + Email string `json:"email"` + AboutInfo string `json:"about_info"` + EmailData string `json:"email_data,omitempty"` + SyncLog string `json:"sync_log,omitempty"` + Timestamp time.Time `json:"timestamp"` + HashedIP string `json:"hashed_ip"` +} + +var ( + rateLimitMu sync.Mutex + requestTimes []time.Time +) + +// checkRateLimit implements a sliding window rate limiter: max 10 requests per minute globally. +func checkRateLimit() (bool, time.Duration) { + rateLimitMu.Lock() + defer rateLimitMu.Unlock() + + now := time.Now() + // Clean up timestamps older than 1 minute + var valid []time.Time + for _, t := range requestTimes { + if now.Sub(t) < time.Minute { + valid = append(valid, t) + } + } + requestTimes = valid + + if len(requestTimes) >= 10 { + // Calculate time until the oldest request in the window falls out of it + oldest := requestTimes[0] + remaining := time.Minute - now.Sub(oldest) + if remaining < 0 { + remaining = 0 + } + return false, remaining + } + + requestTimes = append(requestTimes, now) + return true, 0 +} + +func generateUUID() (string, error) { + b := make([]byte, 16) + _, err := rand.Read(b) + if err != nil { + return "", err + } + // Format as UUID v4 structure + b[6] = (b[6] & 0x0f) | 0x40 // Version 4 + b[8] = (b[8] & 0x3f) | 0x80 // Variant is 10 + return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:]), nil +} + +func hashIP(ip string) string { + h := sha256.New() + h.Write([]byte(ip)) + return hex.EncodeToString(h.Sum(nil)) +} + +func bugReportHandler(storageDir string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + // Enable CORS so the web app (if applicable) can upload + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type") + + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusOK) + return + } + + if r.Method != http.MethodPost { + http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed) + return + } + + // Rate limiting check + allowed, waitTime := checkRateLimit() + if !allowed { + retryAfter := int(waitTime.Seconds()) + if retryAfter < 1 { + retryAfter = 1 + } + w.Header().Set("Retry-After", strconv.Itoa(retryAfter)) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusTooManyRequests) + _ = json.NewEncoder(w).Encode(map[string]string{"error": "Too many requests. Please try again later."}) + return + } + + // Limit body size to 20 MB (20 * 1024 * 1024 bytes) + const maxBodySize = 20 * 1024 * 1024 + r.Body = http.MaxBytesReader(w, r.Body, maxBodySize) + + // Parse the multipart form + err := r.ParseMultipartForm(maxBodySize) + if err != nil { + log.Printf("Failed to parse multipart form: %v", err) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusRequestEntityTooLarge) + _ = json.NewEncoder(w).Encode(map[string]string{"error": "Request body too large or invalid multipart form."}) + return + } + defer func() { + _ = r.MultipartForm.RemoveAll() + }() + + description := r.FormValue("description") + aboutInfo := r.FormValue("about_info") + + if description == "" || aboutInfo == "" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(map[string]string{"error": "description and about_info are required fields."}) + return + } + + email := r.FormValue("email") + emailData := r.FormValue("email_data") + syncLog := r.FormValue("sync_log") + + // Get IP address + ip, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + ip = r.RemoteAddr + } + // Check X-Forwarded-For if behind a proxy + if xff := r.Header.Get("X-Forwarded-For"); xff != "" { + parts := strings.Split(xff, ",") + if len(parts) > 0 { + ip = strings.TrimSpace(parts[0]) + } + } + hashedIP := hashIP(ip) + + uuidVal, err := generateUUID() + if err != nil { + log.Printf("Failed to generate UUID: %v", err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + now := time.Now() + timestampStr := now.Format("20060102_150405") + dirName := fmt.Sprintf("%s_%s", timestampStr, uuidVal) + reportDir := filepath.Join(storageDir, dirName) + + err = os.MkdirAll(reportDir, 0750) + if err != nil { + log.Printf("Failed to create report directory: %v", err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + // Write report.json + report := BugReport{ + Description: description, + Email: email, + AboutInfo: aboutInfo, + EmailData: emailData, + SyncLog: syncLog, + Timestamp: now, + HashedIP: hashedIP, + } + + reportJSONPath := filepath.Join(reportDir, "report.json") + reportJSONFile, err := os.OpenFile(reportJSONPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600) + if err != nil { + log.Printf("Failed to create report.json: %v", err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + defer reportJSONFile.Close() + + enc := json.NewEncoder(reportJSONFile) + enc.SetIndent("", " ") + err = enc.Encode(report) + if err != nil { + log.Printf("Failed to write report.json: %v", err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + // Save attachments + form := r.MultipartForm + files := form.File["attachments[]"] + for i, fileHeader := range files { + file, err := fileHeader.Open() + if err != nil { + log.Printf("Failed to open attachment %d: %v", i, err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + defer file.Close() + + // Sanitize filename to avoid directory traversal + baseName := filepath.Base(fileHeader.Filename) + attachmentName := fmt.Sprintf("attachment_%d_%s", i, baseName) + attachmentPath := filepath.Join(reportDir, attachmentName) + + destFile, err := os.OpenFile(attachmentPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600) + if err != nil { + log.Printf("Failed to create attachment file %s: %v", attachmentName, err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + defer destFile.Close() + + _, err = io.Copy(destFile, file) + if err != nil { + log.Printf("Failed to copy attachment content to %s: %v", attachmentName, err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(map[string]string{"id": uuidVal}) + } +} + +func main() { + port := os.Getenv("BUGREPORT_PORT") + if port == "" { + port = "8090" + } + + storageDir := os.Getenv("BUGREPORT_STORAGE_DIR") + if storageDir == "" { + storageDir = "./reports" + } + + // Create storage directory if it doesn't exist + err := os.MkdirAll(storageDir, 0750) + if err != nil { + log.Fatalf("Failed to create storage directory %s: %v", storageDir, err) + } + + mux := http.NewServeMux() + mux.HandleFunc("/api/v1/bug-reports", bugReportHandler(storageDir)) + + addr := net.JoinHostPort("127.0.0.1", port) + log.Printf("Bug report server starting on %s...", addr) + log.Printf("Reports storage directory: %s", storageDir) + + server := &http.Server{ + Addr: addr, + Handler: mux, + ReadTimeout: 15 * time.Second, + WriteTimeout: 15 * time.Second, + IdleTimeout: 60 * time.Second, + } + + if err := server.ListenAndServe(); err != nil { + log.Fatalf("Server failed to start: %v", err) + } +} diff --git a/test/widget/about_screen_test.dart b/test/widget/about_screen_test.dart index abbf7b4..2c3cdd7 100644 --- a/test/widget/about_screen_test.dart +++ b/test/widget/about_screen_test.dart @@ -86,9 +86,11 @@ void main() { expect(find.textContaining('DB Schema Version'), findsWidgets); // Buttons are in the body, not in the AppBar actions expect(find.byIcon(Icons.copy), findsOneWidget); - expect(find.byIcon(Icons.bug_report), findsOneWidget); - expect(find.text('Copy to clipboard'), findsOneWidget); - expect(find.text('Create issue'), findsOneWidget); + expect(find.byIcon(Icons.bug_report_outlined), findsOneWidget); + expect(find.byIcon(Icons.feedback_outlined), findsOneWidget); + expect(find.text('Copy info'), findsOneWidget); + expect(find.text('Public issue'), findsOneWidget); + expect(find.text('Report bug'), findsOneWidget); }); testWidgets('AboutScreen shows correct IMAP and JMAP account counts', ( @@ -193,7 +195,7 @@ void main() { await tester.pumpWidget(_buildScreen()); await tester.pumpAndSettle(); - await tester.tap(find.byIcon(Icons.bug_report)); + await tester.tap(find.byIcon(Icons.bug_report_outlined)); await tester.pumpAndSettle(); expect( diff --git a/test/widget/goldens/email_list_empty.png b/test/widget/goldens/email_list_empty.png index f220494..e2d9a1a 100644 Binary files a/test/widget/goldens/email_list_empty.png and b/test/widget/goldens/email_list_empty.png differ diff --git a/test/widget/goldens/email_list_error_banner.png b/test/widget/goldens/email_list_error_banner.png index 2baf581..0002536 100644 Binary files a/test/widget/goldens/email_list_error_banner.png and b/test/widget/goldens/email_list_error_banner.png differ diff --git a/test/widget/goldens/email_list_search_results.png b/test/widget/goldens/email_list_search_results.png index 5e2f692..ba71341 100644 Binary files a/test/widget/goldens/email_list_search_results.png and b/test/widget/goldens/email_list_search_results.png differ diff --git a/test/widget/goldens/email_list_selection.png b/test/widget/goldens/email_list_selection.png index de402a2..5895f8e 100644 Binary files a/test/widget/goldens/email_list_selection.png and b/test/widget/goldens/email_list_selection.png differ diff --git a/test/widget/goldens/email_list_with_emails.png b/test/widget/goldens/email_list_with_emails.png index 604b859..ab55873 100644 Binary files a/test/widget/goldens/email_list_with_emails.png and b/test/widget/goldens/email_list_with_emails.png differ