fix(shopinbit): handle ticket message media
What changed, and why it matters
This update improves how Stack Wallet's ShopInBit support-chat feature handles images and file attachments. It replaces an older inline-base64-only image renderer with a safer parser that also supports server-hosted attachments, adds request timeouts so background polling can't get stuck forever, and skips malformed messages instead of crashing the whole chat. The code also adds path-traversal checks on attachment URLs so a malicious link can't trick the app into requesting unintended server paths using the user's own login token.
Treat as a routine feature/robustness patch. Reviewers should verify that _proxyPathOf() is always called before any attachment-proxy URL is used in a network request, and that the 30-second timeout is appropriate for the expected network conditions. No urgent security response appears required, but the change should be included in the next release.
Security signals we found
Path-traversal guard added for attachment-proxy URLs before they are interpolated into authenticated requests
Request timeout added to prevent hung sockets from latching the in-flight polling guard
Malformed message tolerance added to avoid conversation-level parse failures
Inline base64 image cache bounded to 16 MB to cap memory growth
HTML parsing moved from multiple regexes to a single linear scan to avoid catastrophic backtracking
Customer key null checks added before using authenticated attachment APIs
Evidence from the diff
The commit refactors ticket message rendering and network handling. Key changes: (1) a new linear HTML parser in message.dart splits content into text, inline base64 images, attachment-proxy images, and attachment-proxy file links; (2) _proxyPathOf() validates attachment-proxy paths, rejecting percent-encoded separators and normalized traversal sequences; (3) the UI now loads proxy images and file links via authenticated signed URLs using url_launcher; (4) ShopInBitClient adds a 30-second request timeout to GET and dispatch calls; (5) malformed TicketMessage JSON is skipped both on the network parse and in the Drift database converter, preventing one bad message from stalling polling; (6) polling lifecycle guards (_pollInFlight, _paused) prevent duplicate or background polling loops.
Changed components
lib/services/shopinbit/src/models/message.dartlib/services/shopinbit/src/client.dartlib/pages/shopinbit/shopinbit_ticket_detail.dartlib/db/drift/shared_db/tables/shopin_bit_tickets.dartInspect captured patch +873 / −170
diff --git a/lib/db/drift/shared_db/tables/shopin_bit_tickets.dart b/lib/db/drift/shared_db/tables/shopin_bit_tickets.dart
index 54d8bd5..83385eb 100644
--- a/lib/db/drift/shared_db/tables/shopin_bit_tickets.dart
+++ b/lib/db/drift/shared_db/tables/shopin_bit_tickets.dart
@@ -4,6 +4,7 @@ import "package:drift/drift.dart";
import "../../../../models/shopinbit/shopinbit_enums.dart";
import "../../../../services/shopinbit/src/models/message.dart";
+import "../../../../utilities/logger.dart";
class ShopInBitTickets extends Table {
IntColumn get apiTicketId => integer()();
@@ -47,9 +48,21 @@ class MessagesConverter extends TypeConverter<List<TicketMessage>, String> {
@override
List<TicketMessage> fromSql(String fromDb) {
final List<dynamic> raw = jsonDecode(fromDb) as List<dynamic>;
- return raw
- .map((e) => TicketMessage.fromJson(e as Map<String, dynamic>))
- .toList(growable: false);
+ // Skip any message that fails to parse rather than dropping the whole
+ // conversation; mirrors the tolerant parse on the network side.
+ final messages = <TicketMessage>[];
+ for (final e in raw) {
+ try {
+ messages.add(TicketMessage.fromJson(e as Map<String, dynamic>));
+ } catch (err, s) {
+ Logging.instance.w(
+ "MessagesConverter skipping malformed message",
+ error: err,
+ stackTrace: s,
+ );
+ }
+ }
+ return List<TicketMessage>.unmodifiable(messages);
}
@override
diff --git a/lib/pages/shopinbit/shopinbit_ticket_detail.dart b/lib/pages/shopinbit/shopinbit_ticket_detail.dart
index 16944a1..8126fe1 100644
--- a/lib/pages/shopinbit/shopinbit_ticket_detail.dart
+++ b/lib/pages/shopinbit/shopinbit_ticket_detail.dart
@@ -1,20 +1,24 @@
import 'dart:async';
-import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_svg/svg.dart';
import 'package:intl/intl.dart';
+import 'package:url_launcher/url_launcher.dart';
import '../../db/drift/shared_db/shared_database.dart';
import '../../models/shopinbit/shopinbit_enums.dart';
+import '../../notifications/show_flush_bar.dart';
import '../../providers/db/drift_provider.dart';
import '../../providers/global/shopin_bit_service_provider.dart';
+import '../../services/shopinbit/src/api_response.dart';
import '../../services/shopinbit/src/client.dart';
import '../../services/shopinbit/src/models/message.dart';
+import '../../services/shopinbit/src/models/ticket.dart';
import '../../themes/stack_colors.dart';
import '../../utilities/assets.dart';
import '../../utilities/logger.dart';
+import '../../utilities/show_loading.dart';
import '../../utilities/text_styles.dart';
import '../../utilities/util.dart';
import '../../widgets/background.dart';
@@ -23,6 +27,7 @@ import '../../widgets/custom_buttons/app_bar_icon_button.dart';
import '../../widgets/desktop/desktop_dialog_close_button.dart';
import '../../widgets/desktop/primary_button.dart';
import '../../widgets/dialogs/s_dialog.dart';
+import '../../widgets/loading_indicator.dart';
import '../../widgets/refresh_control.dart';
import '../../widgets/rounded_container.dart';
import '../../widgets/rounded_white_container.dart';
@@ -48,6 +53,16 @@ class _ShopInBitTicketDetailState extends ConsumerState<ShopInBitTicketDetail>
static const Duration _kMaxPollInterval = Duration(seconds: 120);
Duration _pollInterval = _kBasePollInterval;
+ // True while a `_poll` is awaiting a refresh. `_startPolling` bails when a
+ // poll is already running so app-resume/lifecycle events can't start a second
+ // loop on top of the first.
+ bool _pollInFlight = false;
+
+ // True while the app is backgrounded. A poll already in flight when we get
+ // backgrounded checks this before re-arming its timer, so polling actually
+ // stops instead of quietly continuing in the background.
+ bool _paused = false;
+
// Optimistically-shown messages the user just sent, kept until the next
// refresh folds them into the persisted ticket row.
final List<TicketMessage> _pending = [];
@@ -80,14 +95,17 @@ class _ShopInBitTicketDetailState extends ConsumerState<ShopInBitTicketDetail>
void didChangeAppLifecycleState(AppLifecycleState state) {
// Don't poll while backgrounded; resume fresh when we come back.
if (state == AppLifecycleState.resumed) {
+ _paused = false;
_startPolling();
} else {
+ _paused = true;
_pollingTimer?.cancel();
}
}
Timer? _pollingTimer;
Future<void> _poll() async {
+ _pollInFlight = true;
bool ok = false;
try {
await _refresh();
@@ -99,16 +117,30 @@ class _ShopInBitTicketDetailState extends ConsumerState<ShopInBitTicketDetail>
stackTrace: s,
);
}
+ _pollInFlight = false;
if (!mounted) return;
-
- // Back off on failure (e.g. a 429), reset on success.
+ // Backgrounded while this poll was awaiting its refresh: don't re-arm.
+ // Resume will restart polling (`_pollInFlight` is already cleared above, so
+ // `_startPolling` won't be blocked).
+ if (_paused) return;
+
+ final ticket = ref.read(pShopInBitTicket(_id)).asData?.value;
+ final isTerminal =
+ ticket != null && TicketState.fromString(ticket.statusRaw).isTerminal;
+ // Just check terminal tickets less often. Was hitting limits in testing.
+ final baseInterval = isTerminal ? _kMaxPollInterval : _kBasePollInterval;
+
+ // Back off on failure (e.g. a 429), reset to the base interval on success.
_pollInterval = ok
- ? _kBasePollInterval
+ ? baseInterval
: ShopInBitClient.nextPollBackoff(_pollInterval, _kMaxPollInterval);
_pollingTimer = Timer(_pollInterval, _poll);
}
void _startPolling() {
+ // A poll is already running and will re-arm itself; don't start a second
+ // loop on top of it.
+ if (_pollInFlight) return;
_pollingTimer?.cancel();
_pollInterval = _kBasePollInterval;
unawaited(_poll());
@@ -120,178 +152,58 @@ class _ShopInBitTicketDetailState extends ConsumerState<ShopInBitTicketDetail>
final text = _messageController.text.trim();
if (text.isEmpty || _sending) return;
+ final optimistic = TicketMessage(
+ timestamp: DateTime.now(),
+ fromAgent: false,
+ content: text,
+ );
setState(() {
_sending = true;
- _pending.add(
- TicketMessage(
- timestamp: DateTime.now(),
- fromAgent: false,
- content: text,
- ),
- );
+ _pending.add(optimistic);
});
_messageController.clear();
+ var sent = false;
try {
final thisTicket = await ref
.read(pSharedDrift)
.shopInBitTicketsDao
.getByApiId(_id);
- final ok = await ref
- .read(pShopinBitService)
- .sendMessage(_id, text, thisTicket!.customerKey);
- if (ok) {
- // Pull the server's copy into the DB row, then drop our optimistic one.
- await _refresh();
- if (mounted) setState(() => _pending.clear());
+ final customerKey = thisTicket?.customerKey;
+ if (customerKey != null) {
+ sent = await ref
+ .read(pShopinBitService)
+ .sendMessage(_id, text, customerKey);
}
} catch (_) {
- // Keep the optimistic message on failure so the text isn't lost.
- } finally {
- if (mounted) setState(() => _sending = false);
+ sent = false;
}
- }
-
- String _formatTime(DateTime dt) {
- final local = dt.toLocal();
- final hour = local.hour.toString().padLeft(2, '0');
- final minute = local.minute.toString().padLeft(2, '0');
- final hm = "$hour:$minute";
- final now = DateTime.now();
- final isToday =
- local.year == now.year &&
- local.month == now.month &&
- local.day == now.day;
- return isToday ? hm : "${DateFormat('MMM d').format(local)} $hm";
- }
-
- static final _imgTagRegex = RegExp(
- r'<img[^>]+src="data:image/[^;]+;base64,([^"]+)"[^>]*/?>',
- caseSensitive: false,
- );
-
- List<Widget> _buildMessageContent(
- String html,
- bool isDesktop,
- Color? textColor,
- ) {
- final textStyle =
- (isDesktop
- ? STextStyles.desktopTextExtraExtraSmall(context)
- : STextStyles.itemSubtitle12(context))
- .copyWith(color: textColor);
-
- final widgets = <Widget>[];
- var lastEnd = 0;
-
- for (final match in _imgTagRegex.allMatches(html)) {
- // Add any text before this <img>
- if (match.start > lastEnd) {
- final textChunk = html
- .substring(lastEnd, match.start)
- .replaceAll(RegExp(r'</?div>'), '')
- .replaceAll(RegExp(r'<br\s*/?>'), '\n')
- .replaceAll(RegExp(r'<[^>]*>'), '')
- .trim();
- if (textChunk.isNotEmpty) {
- widgets.add(Text(textChunk, style: textStyle));
- }
- }
- // Decode and render the image
+ if (sent) {
+ // Delivered. sendMessage already scheduled its own refresh and the poll
+ // loop reconciles regardless, so a failure pulling the server's copy in
+ // here must not roll the (already sent) message back. Fold it in if we
+ // can; otherwise leave the optimistic bubble for the next refresh.
try {
- final bytes = base64Decode(match.group(1)!);
- widgets.add(
- Padding(
- padding: const EdgeInsets.symmetric(vertical: 4),
- child: Image.memory(bytes),
+ await _refresh();
+ } catch (_) {}
+ if (mounted) setState(() => _pending.remove(optimistic));
+ } else {
+ // The send didn't go through: roll the optimistic message back, restore
+ // the text so it isn't lost, and let the user know.
+ _pending.remove(optimistic);
+ if (mounted) {
+ if (_messageController.text.isEmpty) _messageController.text = text;
+ unawaited(
+ showFloatingFlushBar(
+ type: FlushBarType.warning,
+ message: 'Message failed to send',
+ context: context,
),
);
- } catch (_) {
- // Skip malformed images
}
-
- lastEnd = match.end;
}
-
- // Add any remaining text after the last <img>
- if (lastEnd < html.length) {
- final textChunk = html
- .substring(lastEnd)
- .replaceAll(RegExp(r'</?div>'), '')
- .replaceAll(RegExp(r'<br\s*/?>'), '\n')
- .replaceAll(RegExp(r'<[^>]*>'), '')
- .trim();
- if (textChunk.isNotEmpty) {
- widgets.add(Text(textChunk, style: textStyle));
- }
- }
-
- if (widgets.isEmpty) {
- widgets.add(Text('', style: textStyle));
- }
-
- return widgets;
- }
-
- Widget _chatBubble(TicketMessage message, bool isDesktop) {
- final isFromUser = !message.fromAgent;
- final textColor = isFromUser
- ? Theme.of(context).extension<StackColors>()!.buttonTextPrimary
- : Theme.of(context).extension<StackColors>()!.buttonTextSecondary;
-
- return Align(
- alignment: isFromUser ? Alignment.centerRight : Alignment.centerLeft,
- child: Container(
- constraints: BoxConstraints(maxWidth: isDesktop ? 380 : 260),
- margin: const EdgeInsets.symmetric(vertical: 4),
- padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
- decoration: BoxDecoration(
- color: isFromUser
- ? Theme.of(context).extension<StackColors>()!.buttonBackPrimary
- : Theme.of(context).extension<StackColors>()!.buttonBackSecondary,
- borderRadius: BorderRadius.only(
- topLeft: const Radius.circular(12),
- topRight: const Radius.circular(12),
- bottomLeft: isFromUser ? const Radius.circular(12) : Radius.zero,
- bottomRight: isFromUser ? Radius.zero : const Radius.circular(12),
- ),
- ),
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.end,
- children: [
- if (isFromUser)
- Text(
- message.content,
- style:
- (isDesktop
- ? STextStyles.desktopTextExtraExtraSmall(context)
- : STextStyles.itemSubtitle12(context))
- .copyWith(color: textColor),
- )
- else
- ..._buildMessageContent(message.content, isDesktop, textColor),
- const SizedBox(height: 4),
- Text(
- _formatTime(message.timestamp),
- style:
- (isDesktop
- ? STextStyles.desktopTextExtraExtraSmall(context)
- : STextStyles.itemSubtitle12(context))
- .copyWith(
- fontSize: 10,
- color: isFromUser
- ? Colors.white.withOpacity(0.7)
- : Theme.of(context)
- .extension<StackColors>()!
- .textSubtitle1
- .withOpacity(0.7),
- ),
- ),
- ],
- ),
- ),
- );
+ if (mounted) setState(() => _sending = false);
}
@override
@@ -303,6 +215,7 @@ class _ShopInBitTicketDetailState extends ConsumerState<ShopInBitTicketDetail>
?.value;
final ticketNumber = ticket?.ticketNumber ?? "Request";
+ final customerKey = ticket?.customerKey;
final status = ticket?.status ?? ShopInBitOrderStatus.pending;
final isCarResearch = ticket?.category == ShopInBitCategory.car;
final messages = <TicketMessage>[...?ticket?.messages, ..._pending];
@@ -419,7 +332,24 @@ class _ShopInBitTicketDetailState extends ConsumerState<ShopInBitTicketDetail>
itemCount: messages.length,
itemBuilder: (context, index) {
final message = messages[messages.length - 1 - index];
- return _chatBubble(message, isDesktop);
+ return _ChatBubble(
+ // Stable per-message identity so the list (which grows/shrinks as
+ // optimistic and polled messages come and go) keeps each bubble's
+ // state (e.g. a proxy image's fetched URL) with the right message.
+ // Value-stable across polls (objects are rebuilt each poll) but a
+ // cheap hash, so we don't allocate/compare the whole content (which
+ // can be a multi-MB inline image) on every itemBuilder call.
+ key: ValueKey(
+ Object.hash(
+ message.fromAgent,
+ message.timestamp.microsecondsSinceEpoch,
+ message.content.hashCode,
+ ),
+ ),
+ message: message,
+ isDesktop: isDesktop,
+ customerKey: customerKey,
+ );
},
);
@@ -596,3 +526,404 @@ class _ShopInBitTicketDetailState extends ConsumerState<ShopInBitTicketDetail>
);
}
}
+
+// Chat bubble / attachment layout dimensions.
+const double _kBubbleMaxWidthDesktop = 380;
+const double _kBubbleMaxWidthMobile = 260;
+const double _kAttachmentMaxHeight = 220;
+// Decode images down to ~2x the display height to cap decode/memory cost.
+const int _kAttachmentDecodeHeight = 440;
+const double _kAttachmentLoaderHeight = 80;
+const double _kAttachmentLoaderWidth = 40;
+
+/// Renders an authenticated `/attachment-proxy/` image.
+///
+/// The signed URL future is built once in [initState] (and only rebuilt when
+/// [proxyPath] or [customerKey] actually change) so the surrounding 30s poll
+/// can't re-fire `getAttachmentUrl`/re-fetch the image on every rebuild.
+class _ProxyImage extends StatefulWidget {
+ const _ProxyImage({
+ required this.client,
+ required this.proxyPath,
+ required this.customerKey,
+ required this.fallback,
+ });
+
+ final ShopInBitClient client;
+ final String proxyPath;
+ final String customerKey;
+ final Widget Function() fallback;
+
+ @override
+ State<_ProxyImage> createState() => _ProxyImageState();
+}
+
+class _ProxyImageState extends State<_ProxyImage> {
+ late Future<ApiResponse<Uri>> _urlFuture;
+
+ Future<ApiResponse<Uri>> _buildFuture() => widget.client.getAttachmentUrl(
+ widget.proxyPath,
+ useQueryAuth: true,
+ customerKey: widget.customerKey,
+ );
+
+ @override
+ void initState() {
+ super.initState();
+ _urlFuture = _buildFuture();
+ }
+
+ @override
+ void didUpdateWidget(covariant _ProxyImage oldWidget) {
+ super.didUpdateWidget(oldWidget);
+ if (oldWidget.proxyPath != widget.proxyPath ||
+ oldWidget.customerKey != widget.customerKey) {
+ _urlFuture = _buildFuture();
+ }
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return Padding(
+ padding: const EdgeInsets.symmetric(vertical: 4),
+ child: ClipRRect(
+ borderRadius: BorderRadius.circular(8),
+ child: ConstrainedBox(
+ constraints: const BoxConstraints(maxHeight: _kAttachmentMaxHeight),
+ child: FutureBuilder<ApiResponse<Uri>>(
+ future: _urlFuture,
+ builder: (context, snapshot) {
+ if (!snapshot.hasData) {
+ return const SizedBox(
+ height: _kAttachmentLoaderHeight,
+ child: LoadingIndicator(width: _kAttachmentLoaderWidth),
+ );
+ }
+ final resp = snapshot.data!;
+ if (resp.hasError || resp.value == null) {
+ return widget.fallback();
+ }
+ return Image.network(
+ resp.value!.toString(),
+ fit: BoxFit.contain,
+ cacheHeight: _kAttachmentDecodeHeight,
+ semanticLabel: "Image attachment",
+ errorBuilder: (_, _, _) => widget.fallback(),
+ );
+ },
+ ),
+ ),
+ ),
+ );
+ }
+}
+
+String _formatTime(DateTime dt) {
+ final local = dt.toLocal();
+ final hour = local.hour.toString().padLeft(2, '0');
+ final minute = local.minute.toString().padLeft(2, '0');
+ final hm = "$hour:$minute";
+ final now = DateTime.now();
+ final isToday =
+ local.year == now.year &&
+ local.month == now.month &&
+ local.day == now.day;
+ return isToday ? hm : "${DateFormat('MMM d').format(local)} $hm";
+}
+
+/// A single chat message bubble: the message body plus its timestamp.
+class _ChatBubble extends StatelessWidget {
+ const _ChatBubble({
+ super.key,
+ required this.message,
+ required this.isDesktop,
+ required this.customerKey,
+ });
+
+ final TicketMessage message;
+ final bool isDesktop;
+ final String? customerKey;
+
+ @override
+ Widget build(BuildContext context) {
+ final colors = Theme.of(context).extension<StackColors>()!;
+ final isFromUser = !message.fromAgent;
+ final textColor = isFromUser
+ ? colors.buttonTextPrimary
+ : colors.buttonTextSecondary;
+
+ return Align(
+ alignment: isFromUser ? Alignment.centerRight : Alignment.centerLeft,
+ child: Container(
+ constraints: BoxConstraints(
+ maxWidth: isDesktop
+ ? _kBubbleMaxWidthDesktop
+ : _kBubbleMaxWidthMobile,
+ ),
+ margin: const EdgeInsets.symmetric(vertical: 4),
+ padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
+ decoration: BoxDecoration(
+ color: isFromUser
+ ? colors.buttonBackPrimary
+ : colors.buttonBackSecondary,
+ borderRadius: BorderRadius.only(
+ topLeft: const Radius.circular(12),
+ topRight: const Radius.circular(12),
+ bottomLeft: isFromUser ? const Radius.circular(12) : Radius.zero,
+ bottomRight: isFromUser ? Radius.zero : const Radius.circular(12),
+ ),
+ ),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.end,
+ children: [
+ _MessageBody(
+ message: message,
+ isDesktop: isDesktop,
+ textColor: textColor,
+ customerKey: customerKey,
+ ),
+ const SizedBox(height: 4),
+ Text(
+ _formatTime(message.timestamp),
+ style:
+ (isDesktop
+ ? STextStyles.desktopTextExtraExtraSmall(context)
+ : STextStyles.itemSubtitle12(context))
+ .copyWith(
+ fontSize: 10,
+ color: isFromUser
+ ? colors.buttonTextPrimary.withOpacity(0.7)
+ : colors.textSubtitle1.withOpacity(0.7),
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+}
+
+/// Renders a ticket message's HTML [TicketMessage.content] as a column of text,
+/// inline base64 images, proxy images, and file links.
+class _MessageBody extends ConsumerWidget {
+ const _MessageBody({
+ required this.message,
+ required this.isDesktop,
+ required this.textColor,
+ required this.customerKey,
+ });
+
+ final TicketMessage message;
+ final bool isDesktop;
+ final Color? textColor;
+ final String? customerKey;
+
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ final textStyle =
+ (isDesktop
+ ? STextStyles.desktopTextExtraExtraSmall(context)
+ : STextStyles.itemSubtitle12(context))
+ .copyWith(color: textColor);
+
+ final widgets = <Widget>[];
+
+ // Render segments in document order. Proxy images and file links need the
+ // customer key to fetch; a loaded ticket always has one, so when it's null
+ // (e.g. an optimistic message) those segments are simply skipped.
+ final key = customerKey;
+ final client = key == null ? null : ref.read(pShopinBitService).client;
+
+ for (final segment in message.contentSegments) {
+ switch (segment) {
+ case MessageTextSegment(:final text):
+ widgets.add(Text(text, style: textStyle));
+ case MessageImageSegment(:final bytes):
+ widgets.add(
+ Padding(
+ padding: const EdgeInsets.symmetric(vertical: 4),
+ child: ClipRRect(
+ borderRadius: BorderRadius.circular(8),
+ child: ConstrainedBox(
+ constraints: const BoxConstraints(
+ maxHeight: _kAttachmentMaxHeight,
+ ),
+ child: Image.memory(
+ bytes,
+ fit: BoxFit.contain,
+ cacheHeight: _kAttachmentDecodeHeight,
+ semanticLabel: "Image",
+ // The decoded bytes are cached and reused across polls, so
+ // the provider stays equal; keep the last frame if it ever
+ // does reload (e.g. cache eviction) instead of flashing.
+ gaplessPlayback: true,
+ ),
+ ),
+ ),
+ ),
+ );
+ case MessageProxyImageSegment(:final proxyPath, :final filename):
+ if (key != null && client != null) {
+ widgets.add(
+ _ProxyImage(
+ client: client,
+ proxyPath: proxyPath,
+ customerKey: key,
+ fallback: () => _AttachmentImageFallback(filename: filename),
+ ),
+ );
+ }
+ case MessageFileLinkSegment(:final proxyPath, :final filename):
+ if (key != null) {
+ widgets.add(
+ _AttachmentFileLink(
+ proxyPath: proxyPath,
+ customerKey: key,
+ filename: filename,
+ textStyle: textStyle,
+ ),
+ );
+ }
+ }
+ }
+
+ if (widgets.isEmpty) {
+ widgets.add(Text('', style: textStyle));
+ }
+
+ return Column(
+ crossAxisAlignment: CrossAxisAlignment.end,
+ mainAxisSize: MainAxisSize.min,
+ children: widgets,
+ );
+ }
+}
+
+/// A tappable `/attachment-proxy/` file link, opened in the browser.
+class _AttachmentFileLink extends ConsumerWidget {
+ const _AttachmentFileLink({
+ required this.proxyPath,
+ required this.customerKey,
+ required this.filename,
+ required this.textStyle,
+ });
+
+ final String proxyPath;
+ final String customerKey;
+ final String? filename;
+ final TextStyle textStyle;
+
+ Future<void> _open(BuildContext context, WidgetRef ref) async {
+ // Resolving the signed URL hits the token manager (and possibly the
+ // network), so show the loading overlay and surface any failure rather than
+ // doing nothing.
+ await showLoading<void>(
+ whileFuture: _resolveAndLaunch(ref),
+ context: context,
+ message: "Opening attachment",
+ onException: (e) {
+ Logging.instance.w("ShopInBit open attachment failed", error: e);
+ showFloatingFlushBar(
+ type: FlushBarType.warning,
+ message: "Could not open attachment",
+ context: context,
+ );
+ },
+ );
+ }
+
+ Future<void> _resolveAndLaunch(WidgetRef ref) async {
+ final resp = await ref
+ .read(pShopinBitService)
+ .client
+ .getAttachmentUrl(
+ proxyPath,
+ useQueryAuth: true,
+ customerKey: customerKey,
+ );
+ if (resp.hasError || resp.value == null) {
+ throw resp.exception ?? Exception("Could not resolve attachment URL");
+ }
+ final launched = await launchUrl(
+ resp.value!,
+ mode: LaunchMode.externalApplication,
+ );
+ if (!launched) throw Exception("Could not open attachment");
+ }
+
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ final linkStyle = textStyle.copyWith(
+ decoration: TextDecoration.underline,
+ decorationColor: textStyle.color,
+ );
+ return Padding(
+ padding: const EdgeInsets.symmetric(vertical: 4),
+ child: MouseRegion(
+ cursor: SystemMouseCursors.click,
+ // TODO: Make sure we warn about browsing.
+ child: Semantics(
+ button: true,
+ label: filename ?? 'attachment',
+ excludeSemantics: true,
+ child: GestureDetector(
+ onTap: () => _open(context, ref),
+ child: Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ SvgPicture.asset(
+ Assets.svg.file,
+ width: 16,
+ height: 16,
+ color: textStyle.color,
+ ),
+ const SizedBox(width: 6),
+ Flexible(
+ child: Text(
+ filename ?? 'attachment',
+ style: linkStyle,
+ overflow: TextOverflow.ellipsis,
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ ),
+ );
+ }
+}
+
+/// Shown in place of a proxy image that failed to load.
+class _AttachmentImageFallback extends StatelessWidget {
+ const _AttachmentImageFallback({required this.filename});
+
+ final String? filename;
+
+ @override
+ Widget build(BuildContext context) {
+ final colors = Theme.of(context).extension<StackColors>()!;
+ return Container(
+ padding: const EdgeInsets.all(8),
+ color: colors.textFieldDefaultBG,
+ child: Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ SvgPicture.asset(
+ Assets.svg.alertCircle,
+ width: 16,
+ height: 16,
+ color: colors.textSubtitle1,
+ ),
+ const SizedBox(width: 6),
+ Flexible(
+ child: Text(
+ filename ?? 'image',
+ style: STextStyles.itemSubtitle12(context),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
diff --git a/lib/services/shopinbit/src/client.dart b/lib/services/shopinbit/src/client.dart
index 0a1dd0b..a457254 100644
--- a/lib/services/shopinbit/src/client.dart
+++ b/lib/services/shopinbit/src/client.dart
@@ -26,6 +26,11 @@ const _kTag = "ShopInBitClient";
const int _kMaxRetries = 3;
const Duration _kMaxBackoff = Duration(seconds: 30);
+// Per-request ceiling so a stalled socket (common on a sleeping/backgrounded
+// device) can't hang a request forever. A hung poll would otherwise latch the
+// caller's in-flight guard and silently stop all further polling.
+const Duration _kRequestTimeout = Duration(seconds: 30);
+
class ShopInBitClient {
final String accessKey;
final String partnerSecret;
@@ -194,9 +199,22 @@ class ShopInBitClient {
'/tickets/$ticketId/messages',
parse: (json) {
final list = json['messages'] as List<dynamic>;
- return list
- .map((e) => TicketMessage.fromJson(e as Map<String, dynamic>))
- .toList();
+ // Tolerate a single malformed message: skip it rather than throwing,
+ // which would discard the entire conversation for this (and every
+ // subsequent) poll and silently stall the chat.
+ final messages = <TicketMessage>[];
+ for (final raw in list) {
+ try {
+ messages.add(TicketMessage.fromJson(raw as Map<String, dynamic>));
+ } catch (e, s) {
+ Logging.instance.w(
+ "$_kTag skipping malformed ticket message",
+ error: e,
+ stackTrace: s,
+ );
+ }
+ }
+ return messages;
},
customerKey: customerKey,
);
@@ -261,11 +279,9 @@ class ShopInBitClient {
final uri = Uri.parse('$baseUrl$resolved');
Logging.instance.t("$_kTag GET $uri");
final headers = _headers(token, customerKey: customerKey);
- final response = await _httpClient.get(
- url: uri,
- headers: headers,
- proxyInfo: _proxyInfo,
- );
+ final response = await _httpClient
+ .get(url: uri, headers: headers, proxyInfo: _proxyInfo)
+ .timeout(_kRequestTimeout);
if (response.code >= 200 && response.code < 300) {
return ApiResponse(value: response);
} else {
@@ -663,7 +679,7 @@ class ShopInBitClient {
int attempt = 0;
bool reauthed = false;
while (true) {
- final response = await dispatch();
+ final response = await dispatch().timeout(_kRequestTimeout);
// A 401 means the bearer token is stale/expired: invalidate it,
// re-authenticate once, and retry before surfacing the error.
if (response.code == 401 && needsAuth && !reauthed) {
diff --git a/lib/services/shopinbit/src/models/message.dart b/lib/services/shopinbit/src/models/message.dart
index 1341322..80e4752 100644
--- a/lib/services/shopinbit/src/models/message.dart
+++ b/lib/services/shopinbit/src/models/message.dart
@@ -1,3 +1,6 @@
+import 'dart:convert';
+import 'dart:typed_data';
+
class TicketMessage {
final DateTime timestamp;
final bool fromAgent;
@@ -9,6 +12,13 @@ class TicketMessage {
required this.content,
});
+ /// [content] parsed once into ordered renderable segments: plain-text runs,
+ /// decoded inline base64 images, and `/attachment-proxy/` images and file
+ /// links, kept in the order they appear in the message.
+ late final List<MessageContentSegment> contentSegments = _parseSegments(
+ content,
+ );
+
factory TicketMessage.fromJson(Map<String, dynamic> json) {
return TicketMessage(
timestamp: DateTime.parse(json['timestamp'] as String),
@@ -26,3 +36,336 @@ class TicketMessage {
@override
String toString() => toMap().toString();
}
+
+/// A renderable piece of a ticket message, in document order.
+sealed class MessageContentSegment {}
+
+/// A run of plain text (structural tags stripped, entities decoded).
+class MessageTextSegment extends MessageContentSegment {
+ MessageTextSegment(this.text);
+ final String text;
+}
+
+/// A decoded inline base64 (data-URI) image.
+class MessageImageSegment extends MessageContentSegment {
+ MessageImageSegment(this.bytes);
+ final Uint8List bytes;
+}
+
+/// An authenticated `/attachment-proxy/` image, fetched on demand with the
+/// current token. [proxyPath] is the attachment-proxy path (query/fragment
+/// stripped); [filename] is the `alt` text when present.
+class MessageProxyImageSegment extends MessageContentSegment {
+ MessageProxyImageSegment({required this.proxyPath, this.filename});
+ final String proxyPath;
+ final String? filename;
+}
+
+/// An authenticated `/attachment-proxy/` file link, opened in the browser.
+/// [proxyPath] is the attachment-proxy path; [filename] is the link text.
+class MessageFileLinkSegment extends MessageContentSegment {
+ MessageFileLinkSegment({required this.proxyPath, this.filename});
+ final String proxyPath;
+ final String? filename;
+}
+
+/// Parse a ticket message's HTML [content] into ordered renderable segments.
+///
+/// A single linear scan rather than regexes: it keeps document order (text,
+/// inline images, proxy images and file links interleaved as they appear),
+/// tolerates `>` inside quoted attribute values, accepts either quote style,
+/// and runs in O(content length) with no catastrophic backtracking. Attachment
+/// `<img>`/`<a>` become media segments and their markup never leaks into text.
+List<MessageContentSegment> _parseSegments(String content) {
+ final segments = <MessageContentSegment>[];
+ final text = StringBuffer();
+
+ void flushText() {
+ final decoded = (unescapeHtml(text.toString()) ?? '').trim();
+ if (decoded.isNotEmpty) segments.add(MessageTextSegment(decoded));
+ text.clear();
+ }
+
+ final n = content.length;
+ var i = 0;
+ while (i < n) {
+ final lt = content.indexOf('<', i);
+ if (lt < 0) {
+ text.write(content.substring(i));
+ break;
+ }
+ if (lt > i) text.write(content.substring(i, lt));
+
+ // HTML comment: skip past the closing `-->` (drop its contents entirely).
+ if (content.startsWith('<!--', lt)) {
+ final end = content.indexOf('-->', lt + 4);
+ i = end < 0 ? n : end + 3;
+ continue;
+ }
+
+ final gt = _tagEnd(content, lt);
+ if (gt < 0) {
+ // No closing `>`; the remainder can't be a tag, render it as text.
+ text.write(content.substring(lt));
+ break;
+ }
+ final tag = content.substring(lt, gt + 1);
+ i = gt + 1;
+
+ switch (_tagName(tag)) {
+ case 'br':
+ text.write('\n');
+ case 'img':
+ final src = unescapeHtml(_attr(tag, 'src'));
+ if (src == null) break;
+ final bytes = _decodeInlineImage(src);
+ if (bytes != null) {
+ flushText();
+ segments.add(MessageImageSegment(bytes));
+ } else if (_isAttachmentProxy(src)) {
+ final proxyPath = _proxyPathOf(src);
+ if (proxyPath != null) {
+ flushText();
+ segments.add(
+ MessageProxyImageSegment(
+ proxyPath: proxyPath,
+ filename: _emptyOrNull(unescapeHtml(_attr(tag, 'alt'))),
+ ),
+ );
+ }
+ }
+ case 'a':
+ final href = unescapeHtml(_attr(tag, 'href'));
+ if (href != null && _isAttachmentProxy(href)) {
+ // Consume through the matching </a>; its inner text is the link label
+ // and must not also be emitted as body text.
+ final close = _findClose(content, i, 'a');
+ final inner = content.substring(i, close?.start ?? n);
+ i = close?.end ?? n;
+ final proxyPath = _proxyPathOf(href);
+ if (proxyPath != null) {
+ flushText();
+ segments.add(
+ MessageFileLinkSegment(
+ proxyPath: proxyPath,
+ filename: _emptyOrNull(_stripHtml(inner)),
+ ),
+ );
+ }
+ }
+ // Any other tag (div, span, closing tags, ...) contributes no markup;
+ // surrounding text flows through the buffer.
+ }
+ }
+ flushText();
+ return segments;
+}
+
+/// Index of the `>` that closes the tag starting at [lt], skipping any `>` that
+/// sits inside a quoted attribute value. Returns -1 if the tag is unterminated.
+int _tagEnd(String s, int lt) {
+ var i = lt + 1;
+ String? quote;
+ while (i < s.length) {
+ final c = s[i];
+ if (quote != null) {
+ if (c == quote) quote = null;
+ } else if (c == '"' || c == "'") {
+ quote = c;
+ } else if (c == '>') {
+ return i;
+ }
+ i++;
+ }
+ return -1;
+}
+
+/// The lowercased tag name from a raw tag string like `<img ...>` or `</a>`.
+String _tagName(String tag) {
+ var i = 1; // skip '<'
+ if (i < tag.length && tag[i] == '/') i++; // closing tag
+ final start = i;
+ while (i < tag.length) {
+ final c = tag[i];
+ if (c == ' ' ||
+ c == '\t' ||
+ c == '\n' ||
+ c == '\r' ||
+ c == '>' ||
+ c == '/') {
+ break;
+ }
+ i++;
+ }
+ return tag.substring(start, i).toLowerCase();
+}
+
+/// Find the closing `</name>` at or after [from], validating that `</name` is
+/// followed only by optional whitespace then `>` (so `</article>` doesn't match
+/// `</a>`). Returns the `<` index and the index just past `>`.
+({int start, int end})? _findClose(String s, int from, String name) {
+ final lower = s.toLowerCase();
+ final needle = '</$name';
+ var idx = lower.indexOf(needle, from);
+ while (idx >= 0) {
+ var j = idx + needle.length;
+ while (j < s.length &&
+ (s[j] == ' ' || s[j] == '\t' || s[j] == '\n' || s[j] == '\r')) {
+ j++;
+ }
+ if (j < s.length && s[j] == '>') {
+ return (start: idx, end: j + 1);
+ }
+ idx = lower.indexOf(needle, idx + needle.length);
+ }
+ return null;
+}
+
+final _whitespaceRe = RegExp(r'\s');
+
+// Decoded inline images are cached by their base64 payload and reused across
+// rebuilds. refreshOne rebuilds TicketMessage objects every ~30s poll, so
+// without this the `late final` memo re-decodes each poll and hands a fresh
+// Uint8List to Image.memory; MemoryImage compares bytes by identity, so that's
+// an image cache miss -> re-decode + GPU re-upload + a visible flicker every
+// poll. Returning the same instance keeps the provider equal so the cache hits.
+// Bounded by total decoded size so large/many inline images can't grow it
+// without limit.
+const int _kInlineImageCacheMaxBytes = 16 * 1024 * 1024;
+final _inlineImageCache = <String, Uint8List>{};
+int _inlineImageCacheBytes = 0;
+
+/// Decode a `data:image/<type>;base64,<data>` URI to bytes, or null if [src] is
+/// not such a data URI or the payload doesn't decode. Cached by payload.
+Uint8List? _decodeInlineImage(String src) {
+ if (!src.startsWith('data:image/')) return null;
+ const marker = ';base64,';
+ final idx = src.indexOf(marker);
+ if (idx < 0) return null;
+ final b64 = src.substring(idx + marker.length).replaceAll(_whitespaceRe, '');
+ if (b64.isEmpty) return null;
+
+ final cached = _inlineImageCache.remove(b64);
+ if (cached != null) {
+ _inlineImageCache[b64] = cached; // move to most-recently-used
+ return cached;
+ }
+
+ final Uint8List bytes;
+ try {
+ bytes = base64Decode(b64);
+ } catch (_) {
+ return null;
+ }
+ _inlineImageCache[b64] = bytes;
+ _inlineImageCacheBytes += bytes.length;
+ while (_inlineImageCacheBytes > _kInlineImageCacheMaxBytes &&
+ _inlineImageCache.length > 1) {
+ final oldest = _inlineImageCache.keys.first;
+ _inlineImageCacheBytes -= _inlineImageCache.remove(oldest)?.length ?? 0;
+ }
+ return bytes;
+}
+
+String? _attr(String tag, String name) {
+ final re = RegExp(
+ '\\b$name\\s*=\\s*(?:"([^"]*)"|\'([^\']*)\')',
+ caseSensitive: false,
+ );
+ final m = re.firstMatch(tag);
+ if (m == null) return null;
+ return m.group(1) ?? m.group(2);
+}
+
+bool _isAttachmentProxy(String url) => url.contains('/attachment-proxy/');
+
+String? _proxyPathOf(String url) {
+ const marker = '/attachment-proxy/';
+ final idx = url.indexOf(marker);
+ if (idx < 0) return null;
+ var rest = url.substring(idx + marker.length);
+ final q = rest.indexOf(RegExp(r'[?#]'));
+ if (q >= 0) rest = rest.substring(0, q);
+ if (rest.isEmpty) return null;
+ // Percent-encoded path separators (`%2f`, `%5c`) survive Uri.path
+ // normalisation un-decoded, so the dot-segment check below would miss a
+ // traversal smuggled through them; reject those outright.
+ final lower = rest.toLowerCase();
+ if (lower.contains('%2f') || lower.contains('%5c')) return null;
+ // Reject anything that still escapes the attachment-proxy namespace once the
+ // path is normalised (literal `../`, or `%2e%2e` which Uri does decode). The
+ // result is interpolated into a request URL that carries the user's auth
+ // token, so a traversal could otherwise point that authenticated request at
+ // another endpoint on the host.
+ final Uri probe;
+ try {
+ probe = Uri.parse('https://x$marker$rest');
+ } catch (_) {
+ return null;
+ }
+ if (!probe.path.startsWith(marker) || probe.path.length <= marker.length) {
+ return null;
+ }
+ return rest;
+}
+
+String? _emptyOrNull(String? s) {
+ if (s == null) return null;
+ final t = s.trim();
+ return t.isEmpty ? null : t;
+}
+
+String _stripHtml(String html) {
+ final noTags = html.replaceAll(RegExp(r'<[^>]*>'), ' ');
+ return unescapeHtml(noTags)!.replaceAll(RegExp(r'\s+'), ' ').trim();
+}
+
+final _entityRe = RegExp(r'&(#[xX]?[0-9a-fA-F]+|[a-zA-Z][a-zA-Z0-9]*);');
+
+const _namedEntities = <String, String>{
+ 'amp': '&',
+ 'lt': '<',
+ 'gt': '>',
+ 'quot': '"',
+ 'apos': "'",
+ 'nbsp': ' ',
+ 'mdash': '—',
+ 'ndash': '–',
+ 'hellip': '…',
+ 'copy': '©',
+ 'reg': '®',
+ 'trade': '™',
+ 'euro': '€',
+ 'pound': '£',
+ 'lsquo': '‘',
+ 'rsquo': '’',
+ 'ldquo': '“',
+ 'rdquo': '”',
+};
+
+/// Decode the HTML entities the ticket API emits, in a single pass so a decoded
+/// `&` can't be re-read as the start of another entity (e.g. `&lt;` decodes
+/// to the literal `<`, not `<`). Covers the named entities plus numeric
+/// (`&#NN;`) and hex (`&#xNN;`) references; unknown entities are left as-is.
+/// Returns null for null input so it can be threaded through nullable attribute
+/// lookups.
+String? unescapeHtml(String? s) {
+ if (s == null) return null;
+ return s.replaceAllMapped(_entityRe, (m) {
+ final body = m.group(1)!;
+ if (body.startsWith('#')) {
+ final isHex = body.length > 1 && (body[1] == 'x' || body[1] == 'X');
+ final code = int.tryParse(
+ isHex ? body.substring(2) : body.substring(1),
+ radix: isHex ? 16 : 10,
+ );
+ if (code == null || code < 0 || code > 0x10FFFF) return m.group(0)!;
+ try {
+ return String.fromCharCode(code);
+ } catch (_) {
+ return m.group(0)!;
+ }
+ }
+ return _namedEntities[body] ?? m.group(0)!;
+ });
+}
Why this scored 37/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.