feat(chat): disallow links in messages
Local-first chat between people whose web of trust is still forming is a natural phishing/scam vector, so messages may not contain URLs. A conservative `containsUrl` rule (explicit scheme, www., or a common-TLD domain — but not "3.5kg" or "12.30") gates it: the composer warns and keeps the text for editing, and MessagesCubit.send drops any URL as a backstop. Incoming text was already non-tappable (plain selectable text).
This commit is contained in:
parent
6a6a81e8a2
commit
1ab243f29e
14 changed files with 107 additions and 34 deletions
19
apps/app_seeds/lib/domain/message_rules.dart
Normal file
19
apps/app_seeds/lib/domain/message_rules.dart
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
/// Content rules for chat messages.
|
||||||
|
///
|
||||||
|
/// Links are not allowed in messages: it keeps the local-first, offline chat
|
||||||
|
/// free of phishing/scam URLs between people who may not know each other yet
|
||||||
|
/// (the web of trust is still forming). Enforced on send; incoming text is never
|
||||||
|
/// rendered as a tappable link either (the bubble uses plain selectable text).
|
||||||
|
library;
|
||||||
|
|
||||||
|
/// Matches an obvious URL: an explicit scheme, a `www.` host, or a
|
||||||
|
/// `domain.tld` with a common TLD. Kept deliberately conservative so ordinary
|
||||||
|
/// text ("3.5kg", "F1 hybrid") doesn't trip it, while real links do.
|
||||||
|
final RegExp _urlPattern = RegExp(
|
||||||
|
r'(https?://|www\.)\S+'
|
||||||
|
r'|\b[\w-]+\.(com|org|net|io|app|dev|es|eu|info|xyz|co|me|gg|link|shop|store|online|site|page|ru|cn|tk)\b',
|
||||||
|
caseSensitive: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Whether [text] contains something that looks like a URL.
|
||||||
|
bool containsUrl(String text) => _urlPattern.hasMatch(text);
|
||||||
|
|
@ -530,7 +530,8 @@
|
||||||
"g1Copied": "Direición Ğ1 copiada — apégala na to cartera",
|
"g1Copied": "Direición Ğ1 copiada — apégala na to cartera",
|
||||||
"today": "Güei",
|
"today": "Güei",
|
||||||
"yesterday": "Ayeri",
|
"yesterday": "Ayeri",
|
||||||
"sendError": "Nun se pudo unviar — comprueba la conexón"
|
"sendError": "Nun se pudo unviar — comprueba la conexón",
|
||||||
|
"noLinks": "Nun se permiten enllaces nos mensaxes"
|
||||||
},
|
},
|
||||||
"trust": {
|
"trust": {
|
||||||
"none": "Naide los avala entá",
|
"none": "Naide los avala entá",
|
||||||
|
|
|
||||||
|
|
@ -533,7 +533,8 @@
|
||||||
"g1Copied": "Ğ1 address copied — paste it in your wallet",
|
"g1Copied": "Ğ1 address copied — paste it in your wallet",
|
||||||
"today": "Today",
|
"today": "Today",
|
||||||
"yesterday": "Yesterday",
|
"yesterday": "Yesterday",
|
||||||
"sendError": "Couldn't send — check your connection"
|
"sendError": "Couldn't send — check your connection",
|
||||||
|
"noLinks": "Links aren't allowed in messages"
|
||||||
},
|
},
|
||||||
"trust": {
|
"trust": {
|
||||||
"none": "No one vouches for them yet",
|
"none": "No one vouches for them yet",
|
||||||
|
|
|
||||||
|
|
@ -532,7 +532,8 @@
|
||||||
"g1Copied": "Dirección Ğ1 copiada — pégala en tu cartera",
|
"g1Copied": "Dirección Ğ1 copiada — pégala en tu cartera",
|
||||||
"today": "Hoy",
|
"today": "Hoy",
|
||||||
"yesterday": "Ayer",
|
"yesterday": "Ayer",
|
||||||
"sendError": "No se pudo enviar — revisa tu conexión"
|
"sendError": "No se pudo enviar — revisa tu conexión",
|
||||||
|
"noLinks": "No se permiten enlaces en los mensajes"
|
||||||
},
|
},
|
||||||
"trust": {
|
"trust": {
|
||||||
"none": "Nadie los avala aún",
|
"none": "Nadie los avala aún",
|
||||||
|
|
|
||||||
|
|
@ -529,7 +529,8 @@
|
||||||
"g1Copied": "Endereço Ğ1 copiado — cola-o na tua carteira",
|
"g1Copied": "Endereço Ğ1 copiado — cola-o na tua carteira",
|
||||||
"today": "Hoje",
|
"today": "Hoje",
|
||||||
"yesterday": "Ontem",
|
"yesterday": "Ontem",
|
||||||
"sendError": "Não foi possível enviar — verifica a tua ligação"
|
"sendError": "Não foi possível enviar — verifica a tua ligação",
|
||||||
|
"noLinks": "Não são permitidos links nas mensagens"
|
||||||
},
|
},
|
||||||
"trust": {
|
"trust": {
|
||||||
"none": "Ainda ninguém os avaliza",
|
"none": "Ainda ninguém os avaliza",
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,9 @@
|
||||||
/// To regenerate, run: `dart run slang`
|
/// To regenerate, run: `dart run slang`
|
||||||
///
|
///
|
||||||
/// Locales: 4
|
/// Locales: 4
|
||||||
/// Strings: 1860 (465 per locale)
|
/// Strings: 1864 (466 per locale)
|
||||||
///
|
///
|
||||||
/// Built on 2026-07-11 at 04:45 UTC
|
/// Built on 2026-07-11 at 05:13 UTC
|
||||||
|
|
||||||
// coverage:ignore-file
|
// coverage:ignore-file
|
||||||
// ignore_for_file: type=lint, unused_import
|
// ignore_for_file: type=lint, unused_import
|
||||||
|
|
|
||||||
|
|
@ -772,6 +772,7 @@ class _Translations$chat$ast extends Translations$chat$en {
|
||||||
@override String get today => 'Güei';
|
@override String get today => 'Güei';
|
||||||
@override String get yesterday => 'Ayeri';
|
@override String get yesterday => 'Ayeri';
|
||||||
@override String get sendError => 'Nun se pudo unviar — comprueba la conexón';
|
@override String get sendError => 'Nun se pudo unviar — comprueba la conexón';
|
||||||
|
@override String get noLinks => 'Nun se permiten enllaces nos mensaxes';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Path: trust
|
// Path: trust
|
||||||
|
|
@ -1624,6 +1625,7 @@ extension on TranslationsAst {
|
||||||
'chat.today' => 'Güei',
|
'chat.today' => 'Güei',
|
||||||
'chat.yesterday' => 'Ayeri',
|
'chat.yesterday' => 'Ayeri',
|
||||||
'chat.sendError' => 'Nun se pudo unviar — comprueba la conexón',
|
'chat.sendError' => 'Nun se pudo unviar — comprueba la conexón',
|
||||||
|
'chat.noLinks' => 'Nun se permiten enllaces nos mensaxes',
|
||||||
'trust.none' => 'Naide los avala entá',
|
'trust.none' => 'Naide los avala entá',
|
||||||
'trust.count' => ({required Object n}) => 'Avalada por ${n}',
|
'trust.count' => ({required Object n}) => 'Avalada por ${n}',
|
||||||
'trust.vouch' => 'Conozo a esta persona',
|
'trust.vouch' => 'Conozo a esta persona',
|
||||||
|
|
|
||||||
|
|
@ -1443,6 +1443,9 @@ class Translations$chat$en {
|
||||||
|
|
||||||
/// en: 'Couldn't send — check your connection'
|
/// en: 'Couldn't send — check your connection'
|
||||||
String get sendError => 'Couldn\'t send — check your connection';
|
String get sendError => 'Couldn\'t send — check your connection';
|
||||||
|
|
||||||
|
/// en: 'Links aren't allowed in messages'
|
||||||
|
String get noLinks => 'Links aren\'t allowed in messages';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Path: trust
|
// Path: trust
|
||||||
|
|
@ -2544,6 +2547,7 @@ extension on Translations {
|
||||||
'chat.today' => 'Today',
|
'chat.today' => 'Today',
|
||||||
'chat.yesterday' => 'Yesterday',
|
'chat.yesterday' => 'Yesterday',
|
||||||
'chat.sendError' => 'Couldn\'t send — check your connection',
|
'chat.sendError' => 'Couldn\'t send — check your connection',
|
||||||
|
'chat.noLinks' => 'Links aren\'t allowed in messages',
|
||||||
'trust.none' => 'No one vouches for them yet',
|
'trust.none' => 'No one vouches for them yet',
|
||||||
'trust.count' => ({required Object n}) => 'Vouched for by ${n}',
|
'trust.count' => ({required Object n}) => 'Vouched for by ${n}',
|
||||||
'trust.vouch' => 'I know this person',
|
'trust.vouch' => 'I know this person',
|
||||||
|
|
|
||||||
|
|
@ -774,6 +774,7 @@ class _Translations$chat$es extends Translations$chat$en {
|
||||||
@override String get today => 'Hoy';
|
@override String get today => 'Hoy';
|
||||||
@override String get yesterday => 'Ayer';
|
@override String get yesterday => 'Ayer';
|
||||||
@override String get sendError => 'No se pudo enviar — revisa tu conexión';
|
@override String get sendError => 'No se pudo enviar — revisa tu conexión';
|
||||||
|
@override String get noLinks => 'No se permiten enlaces en los mensajes';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Path: trust
|
// Path: trust
|
||||||
|
|
@ -1628,6 +1629,7 @@ extension on TranslationsEs {
|
||||||
'chat.today' => 'Hoy',
|
'chat.today' => 'Hoy',
|
||||||
'chat.yesterday' => 'Ayer',
|
'chat.yesterday' => 'Ayer',
|
||||||
'chat.sendError' => 'No se pudo enviar — revisa tu conexión',
|
'chat.sendError' => 'No se pudo enviar — revisa tu conexión',
|
||||||
|
'chat.noLinks' => 'No se permiten enlaces en los mensajes',
|
||||||
'trust.none' => 'Nadie los avala aún',
|
'trust.none' => 'Nadie los avala aún',
|
||||||
'trust.count' => ({required Object n}) => 'Avalada por ${n}',
|
'trust.count' => ({required Object n}) => 'Avalada por ${n}',
|
||||||
'trust.vouch' => 'Conozco a esta persona',
|
'trust.vouch' => 'Conozco a esta persona',
|
||||||
|
|
|
||||||
|
|
@ -771,6 +771,7 @@ class _Translations$chat$pt extends Translations$chat$en {
|
||||||
@override String get today => 'Hoje';
|
@override String get today => 'Hoje';
|
||||||
@override String get yesterday => 'Ontem';
|
@override String get yesterday => 'Ontem';
|
||||||
@override String get sendError => 'Não foi possível enviar — verifica a tua ligação';
|
@override String get sendError => 'Não foi possível enviar — verifica a tua ligação';
|
||||||
|
@override String get noLinks => 'Não são permitidos links nas mensagens';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Path: trust
|
// Path: trust
|
||||||
|
|
@ -1622,6 +1623,7 @@ extension on TranslationsPt {
|
||||||
'chat.today' => 'Hoje',
|
'chat.today' => 'Hoje',
|
||||||
'chat.yesterday' => 'Ontem',
|
'chat.yesterday' => 'Ontem',
|
||||||
'chat.sendError' => 'Não foi possível enviar — verifica a tua ligação',
|
'chat.sendError' => 'Não foi possível enviar — verifica a tua ligação',
|
||||||
|
'chat.noLinks' => 'Não são permitidos links nas mensagens',
|
||||||
'trust.none' => 'Ainda ninguém os avaliza',
|
'trust.none' => 'Ainda ninguém os avaliza',
|
||||||
'trust.count' => ({required Object n}) => 'Avalizada por ${n}',
|
'trust.count' => ({required Object n}) => 'Avalizada por ${n}',
|
||||||
'trust.vouch' => 'Conheço esta pessoa',
|
'trust.vouch' => 'Conheço esta pessoa',
|
||||||
|
|
|
||||||
|
|
@ -4,16 +4,13 @@ import 'package:commons_core/commons_core.dart';
|
||||||
import 'package:equatable/equatable.dart';
|
import 'package:equatable/equatable.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
|
||||||
|
import '../domain/message_rules.dart';
|
||||||
import '../services/message_store.dart';
|
import '../services/message_store.dart';
|
||||||
|
|
||||||
/// A 1:1 conversation with one peer. Holds the running message list plus send
|
/// A 1:1 conversation with one peer. Holds the running message list plus send
|
||||||
/// status. Transport-agnostic — depends on [MessageTransport], not the relay.
|
/// status. Transport-agnostic — depends on [MessageTransport], not the relay.
|
||||||
class ChatState extends Equatable {
|
class ChatState extends Equatable {
|
||||||
const ChatState({
|
const ChatState({this.messages = const [], this.sending = false, this.error});
|
||||||
this.messages = const [],
|
|
||||||
this.sending = false,
|
|
||||||
this.error,
|
|
||||||
});
|
|
||||||
|
|
||||||
/// Messages in this conversation, oldest first. Incoming ones arrive from the
|
/// Messages in this conversation, oldest first. Incoming ones arrive from the
|
||||||
/// inbox; our own are appended optimistically on send (NIP-17 wraps don't come
|
/// inbox; our own are appended optimistically on send (NIP-17 wraps don't come
|
||||||
|
|
@ -26,12 +23,11 @@ class ChatState extends Equatable {
|
||||||
List<PrivateMessage>? messages,
|
List<PrivateMessage>? messages,
|
||||||
bool? sending,
|
bool? sending,
|
||||||
String? Function()? error,
|
String? Function()? error,
|
||||||
}) =>
|
}) => ChatState(
|
||||||
ChatState(
|
messages: messages ?? this.messages,
|
||||||
messages: messages ?? this.messages,
|
sending: sending ?? this.sending,
|
||||||
sending: sending ?? this.sending,
|
error: error != null ? error() : this.error,
|
||||||
error: error != null ? error() : this.error,
|
);
|
||||||
);
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
List<Object?> get props => [messages, sending, error];
|
List<Object?> get props => [messages, sending, error];
|
||||||
|
|
@ -47,9 +43,9 @@ class MessagesCubit extends Cubit<ChatState> {
|
||||||
required this.selfPubkey,
|
required this.selfPubkey,
|
||||||
MessageStore? store,
|
MessageStore? store,
|
||||||
Future<void> Function()? onDispose,
|
Future<void> Function()? onDispose,
|
||||||
}) : _store = store,
|
}) : _store = store,
|
||||||
_onDispose = onDispose,
|
_onDispose = onDispose,
|
||||||
super(const ChatState());
|
super(const ChatState());
|
||||||
|
|
||||||
final MessageTransport? _transport;
|
final MessageTransport? _transport;
|
||||||
final MessageStore? _store;
|
final MessageStore? _store;
|
||||||
|
|
@ -77,15 +73,12 @@ class MessagesCubit extends Cubit<ChatState> {
|
||||||
Future<void> start() async {
|
Future<void> start() async {
|
||||||
final transport = _transport;
|
final transport = _transport;
|
||||||
if (transport != null) {
|
if (transport != null) {
|
||||||
_sub = transport.inbox().listen(
|
_sub = transport.inbox().listen((message) async {
|
||||||
(message) async {
|
if (message.fromPubkey != peerPubkey) return; // another conversation
|
||||||
if (message.fromPubkey != peerPubkey) return; // another conversation
|
if (!_seen.add(_key(message))) return; // already shown / re-delivered
|
||||||
if (!_seen.add(_key(message))) return; // already shown / re-delivered
|
await _store?.append(peerPubkey, message);
|
||||||
await _store?.append(peerPubkey, message);
|
emit(state.copyWith(messages: [...state.messages, message]));
|
||||||
emit(state.copyWith(messages: [...state.messages, message]));
|
}, onError: (Object e) => emit(state.copyWith(error: () => '$e')));
|
||||||
},
|
|
||||||
onError: (Object e) => emit(state.copyWith(error: () => '$e')),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
final history = await _store?.history(peerPubkey);
|
final history = await _store?.history(peerPubkey);
|
||||||
if (history != null && history.isNotEmpty) {
|
if (history != null && history.isNotEmpty) {
|
||||||
|
|
@ -102,7 +95,9 @@ class MessagesCubit extends Cubit<ChatState> {
|
||||||
Future<void> send(String text) async {
|
Future<void> send(String text) async {
|
||||||
final transport = _transport;
|
final transport = _transport;
|
||||||
final trimmed = text.trim();
|
final trimmed = text.trim();
|
||||||
if (transport == null || trimmed.isEmpty) return;
|
// Links aren't allowed in messages (the UI warns; this is the backstop so
|
||||||
|
// nothing sends a URL programmatically). See message_rules.dart.
|
||||||
|
if (transport == null || trimmed.isEmpty || containsUrl(trimmed)) return;
|
||||||
emit(state.copyWith(sending: true, error: () => null));
|
emit(state.copyWith(sending: true, error: () => null));
|
||||||
try {
|
try {
|
||||||
await transport.send(toPubkey: peerPubkey, text: trimmed);
|
await transport.send(toPubkey: peerPubkey, text: trimmed);
|
||||||
|
|
@ -113,10 +108,7 @@ class MessagesCubit extends Cubit<ChatState> {
|
||||||
);
|
);
|
||||||
_seen.add(_key(mine));
|
_seen.add(_key(mine));
|
||||||
await _store?.append(peerPubkey, mine);
|
await _store?.append(peerPubkey, mine);
|
||||||
emit(state.copyWith(
|
emit(state.copyWith(sending: false, messages: [...state.messages, mine]));
|
||||||
sending: false,
|
|
||||||
messages: [...state.messages, mine],
|
|
||||||
));
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
emit(state.copyWith(sending: false, error: () => '$e'));
|
emit(state.copyWith(sending: false, error: () => '$e'));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import 'package:url_launcher/url_launcher.dart';
|
||||||
|
|
||||||
import '../di/injector.dart';
|
import '../di/injector.dart';
|
||||||
import '../domain/chat_timeline.dart';
|
import '../domain/chat_timeline.dart';
|
||||||
|
import '../domain/message_rules.dart';
|
||||||
import '../i18n/strings.g.dart';
|
import '../i18n/strings.g.dart';
|
||||||
import '../services/message_store.dart';
|
import '../services/message_store.dart';
|
||||||
import '../services/profile_cache.dart';
|
import '../services/profile_cache.dart';
|
||||||
|
|
@ -173,6 +174,13 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||||
final cubit = _messages;
|
final cubit = _messages;
|
||||||
if (cubit == null || _input.text.trim().isEmpty) return;
|
if (cubit == null || _input.text.trim().isEmpty) return;
|
||||||
final text = _input.text;
|
final text = _input.text;
|
||||||
|
// Links aren't allowed — warn and keep the text so it can be edited.
|
||||||
|
if (containsUrl(text)) {
|
||||||
|
ScaffoldMessenger.of(context)
|
||||||
|
..hideCurrentSnackBar()
|
||||||
|
..showSnackBar(SnackBar(content: Text(context.t.chat.noLinks)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
_input.clear();
|
_input.clear();
|
||||||
await cubit.send(text);
|
await cubit.send(text);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
31
apps/app_seeds/test/domain/message_rules_test.dart
Normal file
31
apps/app_seeds/test/domain/message_rules_test.dart
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:tane/domain/message_rules.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('containsUrl', () {
|
||||||
|
test('flags explicit schemes and www', () {
|
||||||
|
expect(containsUrl('look https://evil.example/login'), isTrue);
|
||||||
|
expect(containsUrl('http://foo'), isTrue);
|
||||||
|
expect(containsUrl('go to www.example.org now'), isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('flags bare domains with a common TLD', () {
|
||||||
|
expect(containsUrl('see tomate.com'), isTrue);
|
||||||
|
expect(containsUrl('my-shop.store has it'), isTrue);
|
||||||
|
expect(containsUrl('grab it at bit.ly'), isFalse); // ly not in the list
|
||||||
|
});
|
||||||
|
|
||||||
|
test('leaves ordinary seed talk alone', () {
|
||||||
|
expect(containsUrl('tengo 3.5kg de tomate rosa'), isFalse);
|
||||||
|
expect(containsUrl('variedad F1 vs polinización abierta'), isFalse);
|
||||||
|
expect(containsUrl('nos vemos a las 12.30'), isFalse);
|
||||||
|
expect(containsUrl('semillas de calabaza, ¿cambiamos?'), isFalse);
|
||||||
|
expect(containsUrl(''), isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('is case-insensitive', () {
|
||||||
|
expect(containsUrl('HTTPS://EXAMPLE.COM'), isTrue);
|
||||||
|
expect(containsUrl('Example.COM'), isTrue);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -100,6 +100,15 @@ void main() {
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
test('a message containing a link is not sent', () async {
|
||||||
|
final transport = FakeMessageTransport();
|
||||||
|
final cubit = MessagesCubit(transport, peerPubkey: peer, selfPubkey: me);
|
||||||
|
await cubit.send('check https://evil.example/login');
|
||||||
|
expect(transport.sent, isEmpty);
|
||||||
|
expect(cubit.state.messages, isEmpty);
|
||||||
|
await cubit.close();
|
||||||
|
});
|
||||||
|
|
||||||
test('empty/whitespace text is not sent', () async {
|
test('empty/whitespace text is not sent', () async {
|
||||||
final transport = FakeMessageTransport();
|
final transport = FakeMessageTransport();
|
||||||
final cubit = MessagesCubit(transport, peerPubkey: peer, selfPubkey: me);
|
final cubit = MessagesCubit(transport, peerPubkey: peer, selfPubkey: me);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue