Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f903e21e3a | |||
| 923a6a01bd | |||
| 9985b79aa6 | |||
| b82f50f888 | |||
| 4f2cbdbdc4 | |||
| 90a1390716 |
@@ -4,6 +4,56 @@ Všechny podstatné změny v aplikaci Šepot. Formát dle
|
||||
[Keep a Changelog](https://keepachangelog.com/cs/1.1.0/),
|
||||
verzování dle [ajtak standardu](#verzování).
|
||||
|
||||
## [1.1.2] – 2026-08-13
|
||||
|
||||
### Opraveno
|
||||
- **Zprávy už nečekají na otevření aplikace.** Když mobilní síť tiše
|
||||
zahodila nečinné spojení, Šepot o tom nevěděl: tvářil se připojeně, ale
|
||||
odeslaná zpráva skončila chybou a příchozí nedorazily — a všechny se pak
|
||||
vysypaly naráz po restartu aplikace. Spojení se teď hlídá pravidelným
|
||||
pingem na obou stranách, takže výpadek se pozná a obnoví v řádu sekund.
|
||||
- **Neodeslaná zpráva se pošle sama.** Dosud zůstala označená jako
|
||||
neodeslaná (u příloh dokonce natrvalo na „odesílá se") a poslat znovu ji
|
||||
musel člověk ručně. Nově se po obnovení spojení odešle sama; opakované
|
||||
doručení téže zprávy druhá strana pozná a nezobrazí ji dvakrát.
|
||||
- **Zprávy, které nešly doručit, se přestaly hromadit.** Když se cestou
|
||||
ztratilo potvrzení o doručení, obálka na serveru zůstala a znovu a znovu
|
||||
se posílala, aniž by ji šlo přečíst. Server teď takové zprávy po několika
|
||||
marných pokusech zahodí, místo aby jimi zdržoval doručování ostatních.
|
||||
|
||||
### Přidáno
|
||||
- **Upozornění, když nechodí notifikace.** Bez nich Android aplikaci na
|
||||
pozadí uspí a zprávy uvidíš, až ji otevřeš. Šepot to teď řekne rovnou v
|
||||
seznamu chatů a nabídne nastavení; hlášku jde odmítnout. Selhání
|
||||
registrace u distributora se už nezamlčí.
|
||||
|
||||
## [1.1.1] – 2026-07-18
|
||||
|
||||
### Opraveno
|
||||
- **Odeslání první zprávy hned po instalaci už neselže.** Krátce po
|
||||
založení účtu, než se aplikace stihla připojit k relayi, mohla zpráva
|
||||
spadnout s hláškou „Odeslání selhalo". Nově se v tu chvíli ukáže
|
||||
srozumitelné „Šepot se ještě připojuje — zkus to za chvíli" a připojení
|
||||
proběhne na pozadí.
|
||||
- **QR kód a odkaz se ukážou až s doručovací adresou.** Když sis chtěl
|
||||
ukázat vlastní QR dřív, než se aplikace poprvé připojila, vygeneroval se
|
||||
neúplný odkaz — protistrana pak nemohla poslat první zprávu („Kontakt
|
||||
nemá doručovací adresu"). Nově se místo neúplného kódu ukáže stav
|
||||
připojování a QR se objeví, jakmile je hotové.
|
||||
- **Neúplný odkaz kontaktu se odmítne při přidání**, ne až při prvním
|
||||
odeslání — s jasnou výzvou požádat protistranu o nový odkaz.
|
||||
- **Zamčení po nečinnosti už neumlčí aplikaci.** Po automatickém zamknutí
|
||||
a opětovném odemčení se zprávy zase přijímají i odesílají — dřív mohl
|
||||
messaging zůstat po odemčení vypnutý až do restartu aplikace.
|
||||
- **Skupinová zpráva se při chybě neodeslání označí jako neodeslaná**
|
||||
místo aby zůstala navždy ve stavu „odesílá se".
|
||||
- Ošetřeno vzácné zdvojení spojení s relayem při rychlém přepnutí Toru
|
||||
nebo zamčení během připojování.
|
||||
|
||||
### Změněno
|
||||
- Interní úklid závislostí notifikací (odstraněna nepoužívaná větev, která
|
||||
komplikovala sestavení verze pro iOS).
|
||||
|
||||
## [1.1.0] – 2026-07-13
|
||||
|
||||
### Přidáno
|
||||
|
||||
@@ -42,6 +42,14 @@ android {
|
||||
jvmTarget = JavaVersion.VERSION_17.toString()
|
||||
}
|
||||
|
||||
// Lint na release buildu vynechán: jeho síťové kontroly (novější verze
|
||||
// závislostí) na omezené build síti visí donekonečna a pro Flutter appku
|
||||
// nemají přínos. Analýzu Dartu dělá `flutter analyze`, Kotlin je minimální.
|
||||
lint {
|
||||
checkReleaseBuilds = false
|
||||
abortOnError = false
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "cz.sepot.sepot"
|
||||
// libsignal binárky vyžadují Android 7.0+ (API 24)
|
||||
|
||||
+5
-1
@@ -196,8 +196,12 @@ class _AutoLockGateState extends ConsumerState<_AutoLockGate>
|
||||
final kde = _router.state.uri.path;
|
||||
if (_bezZamku.contains(kde)) return;
|
||||
|
||||
// Zastavit službu a zamknout DB. messagingStartProvider ZÁMĚRNĚ
|
||||
// neinvalidujeme tady — invalidace by ho eager-rebuildla ještě nad
|
||||
// odemčeným storage (těsně před lock) a messaging by nastartoval nad DB,
|
||||
// kterou hned zavíráme. Restart řeší odemykací obrazovky přes
|
||||
// ref.invalidate(messagingStartProvider) až po úspěšném unlocku.
|
||||
await ref.read(messagingServiceProvider).stop();
|
||||
ref.invalidate(messagingStartProvider);
|
||||
storage.lock();
|
||||
_router.go('/pin-lock');
|
||||
}
|
||||
|
||||
@@ -26,6 +26,24 @@ class RelayUnavailableError extends SepotUserError {
|
||||
String toString() => 'relay unavailable';
|
||||
}
|
||||
|
||||
/// Vlastní registrace na relay ještě nedoběhla — nemáme sender adresu.
|
||||
/// Přechodný stav po čerstvé instalaci; registrace běží na pozadí.
|
||||
class NotYetRegisteredError extends SepotUserError {
|
||||
const NotYetRegisteredError();
|
||||
|
||||
@override
|
||||
String toString() => 'not registered on relay yet';
|
||||
}
|
||||
|
||||
/// Odkaz/QR kontaktu je neúplný (chybí routing aid+da) — vygenerovaný
|
||||
/// dřív, než měla protistrana dokončenou registraci na relay.
|
||||
class ContactLinkIncompleteError extends SepotUserError {
|
||||
const ContactLinkIncompleteError();
|
||||
|
||||
@override
|
||||
String toString() => 'contact link is missing routing (incomplete)';
|
||||
}
|
||||
|
||||
/// Příloha přesahuje serverový limit.
|
||||
class AttachmentTooLargeError extends SepotUserError {
|
||||
const AttachmentTooLargeError(this.maxBytes);
|
||||
|
||||
@@ -11,6 +11,8 @@ import 'package:sepot/l10n/generated/app_localizations.dart';
|
||||
String describeError(AppLocalizations l10n, Object error) => switch (error) {
|
||||
TrustRootChangedError() => l10n.errorTrustRootChanged,
|
||||
RelayUnavailableError() => l10n.errorRelayUnavailable,
|
||||
NotYetRegisteredError() => l10n.errorNotYetRegistered,
|
||||
ContactLinkIncompleteError() => l10n.errorContactLinkIncomplete,
|
||||
AttachmentTooLargeError(:final maxMb) =>
|
||||
l10n.errorAttachmentTooLarge(maxMb),
|
||||
GroupNeedsMemberError() => l10n.errorGroupNeedsMember,
|
||||
|
||||
@@ -75,6 +75,9 @@ class RelayClient {
|
||||
|
||||
void _setState(RelayConnectionState s) {
|
||||
_state = s;
|
||||
// Po dispose() je controller zavřený — pozdní přechod z doběhnutého
|
||||
// _connect (zombie) by jinak hodil StateError do listenerů.
|
||||
if (_stateController.isClosed) return;
|
||||
_stateController.add(s);
|
||||
}
|
||||
|
||||
@@ -292,6 +295,14 @@ class RelayClient {
|
||||
try {
|
||||
final channel = _transport.connectWs(Uri.parse('$_wsUrl/v1/ws'));
|
||||
await channel.ready;
|
||||
// Během `ready` (přes Tor může viset dlouho) mohl přijít stop()/dispose()
|
||||
// — Tor toggle nebo auto-lock. Bez téhle kontroly by vzniklo zombie
|
||||
// autentizované spojení (relay má 2× jedno zařízení, DELIVERy padají do
|
||||
// zavřených controllerů). Nezakládat ho, jen zavřít.
|
||||
if (!_shouldRun) {
|
||||
await channel.sink.close();
|
||||
return;
|
||||
}
|
||||
_channel = channel;
|
||||
|
||||
// AUTH: výzva-odpověď podepsaná identity klíčem (XEdDSA)
|
||||
@@ -322,10 +333,14 @@ class RelayClient {
|
||||
return;
|
||||
}
|
||||
if (frame.hasDeliver()) {
|
||||
if (!_deliverController.isClosed) {
|
||||
_deliverController.add(frame.deliver);
|
||||
}
|
||||
} else if (frame.hasReceipt()) {
|
||||
if (!_receiptController.isClosed) {
|
||||
_receiptController.add(frame.receipt);
|
||||
}
|
||||
}
|
||||
},
|
||||
onDone: _onDisconnected,
|
||||
onError: (Object _) => _onDisconnected(),
|
||||
|
||||
@@ -21,6 +21,15 @@ import 'package:socks5_proxy/socks_client.dart';
|
||||
import 'package:web_socket_channel/io.dart';
|
||||
import 'package:web_socket_channel/web_socket_channel.dart';
|
||||
|
||||
/// Jak často posílat WS ping a jak dlouho čekat na pong.
|
||||
///
|
||||
/// Bez pingu se mrtvé spojení tváří jako živé: mobilní NAT zahodí nečinné TCP
|
||||
/// tiše, appka dál hlásí „připojeno" a píše do prázdna — zprávy pak odejdou až
|
||||
/// po restartu appky, všechny naráz. `dart:io` při nastaveném intervalu ping
|
||||
/// posílá a když pong nedorazí včas, spojení zavře → [RelayClient] se
|
||||
/// reconnectne sám. Relay pinguje protisměrně po 30 s (ws.rs PING_INTERVAL).
|
||||
const kWsPingInterval = Duration(seconds: 30);
|
||||
|
||||
/// Abstrakce nad tím, jak klient otevírá spojení k relayi.
|
||||
abstract class RelayTransport {
|
||||
/// Sdílený HTTP klient pro REST (registrace, prekey bundle, přílohy…).
|
||||
@@ -41,7 +50,8 @@ class DirectTransport implements RelayTransport {
|
||||
http.Client httpClient() => _client ??= http.Client();
|
||||
|
||||
@override
|
||||
WebSocketChannel connectWs(Uri url) => WebSocketChannel.connect(url);
|
||||
WebSocketChannel connectWs(Uri url) =>
|
||||
IOWebSocketChannel.connect(url, pingInterval: kWsPingInterval);
|
||||
|
||||
@override
|
||||
void close() {
|
||||
@@ -84,7 +94,7 @@ class TorTransport implements RelayTransport {
|
||||
@override
|
||||
WebSocketChannel connectWs(Uri url) =>
|
||||
IOWebSocketChannel.connect(_withExplicitPort(url),
|
||||
customClient: _sharedHttpClient());
|
||||
customClient: _sharedHttpClient(), pingInterval: kWsPingInterval);
|
||||
|
||||
/// `Uri` pro `ws`/`wss` nezná výchozí port → `url.port` vrací 0 a SOCKS
|
||||
/// CONNECT by cílil na port 0 (serverError). Doplníme ho podle schématu
|
||||
|
||||
@@ -45,6 +45,7 @@ class PushBootstrap {
|
||||
|
||||
static const _pendingEndpointKey = 'pending_push_endpoint';
|
||||
static const _modeKey = 'push_mode'; // 'off' | 'up' | 'fcm'
|
||||
static const _registrationFailedKey = 'push_registration_failed';
|
||||
static const _store = FlutterSecureStorage(
|
||||
aOptions: AndroidOptions(encryptedSharedPreferences: true),
|
||||
);
|
||||
@@ -57,6 +58,16 @@ class PushBootstrap {
|
||||
static Future<void> writeMode(String mode) =>
|
||||
_store.write(key: _modeKey, value: mode);
|
||||
|
||||
/// Odmítl distributor registraci? Selhání se dřív jen spolklo, takže
|
||||
/// uživatel měl v Nastavení „push zapnutý", ale budíčky nechodily a
|
||||
/// zprávy čekaly, než appku otevře. Nastavení si to přečte a řekne to.
|
||||
static Future<bool> registrationFailed() async =>
|
||||
await _store.read(key: _registrationFailedKey) == '1';
|
||||
|
||||
static Future<void> _setRegistrationFailed(bool failed) => failed
|
||||
? _store.write(key: _registrationFailedKey, value: '1')
|
||||
: _store.delete(key: _registrationFailedKey);
|
||||
|
||||
static Future<void> init(List<String> args) async {
|
||||
backgroundStart = args.contains('--unifiedpush-bg');
|
||||
|
||||
@@ -69,7 +80,8 @@ class PushBootstrap {
|
||||
} else {
|
||||
await UnifiedPush.initialize(
|
||||
onNewEndpoint: _onNewEndpoint,
|
||||
onRegistrationFailed: (_, _) {},
|
||||
onRegistrationFailed: (_, _) =>
|
||||
unawaited(_setRegistrationFailed(true)),
|
||||
onUnregistered: _onUnregistered,
|
||||
onMessage: _onMessage,
|
||||
);
|
||||
@@ -114,6 +126,7 @@ class PushBootstrap {
|
||||
}
|
||||
|
||||
static void _onNewEndpoint(PushEndpoint endpoint, String instance) {
|
||||
unawaited(_setRegistrationFailed(false)); // registrace se povedla
|
||||
final s = _service;
|
||||
if (s != null) {
|
||||
s.handleNewEndpoint(endpoint.url);
|
||||
|
||||
@@ -144,6 +144,10 @@ class PushService {
|
||||
return (await UnifiedPush.getDistributor()) != null ? 'up' : 'off';
|
||||
}
|
||||
|
||||
/// Odmítl distributor poslední registraci? Nastavení to ukáže — jinak by
|
||||
/// push vypadal zapnutý, ale budíčky by nechodily.
|
||||
Future<bool> registrationFailed() => PushBootstrap.registrationFailed();
|
||||
|
||||
Future<void> setModeOff() async {
|
||||
await _teardownFcm(deleteToken: true);
|
||||
try {
|
||||
|
||||
@@ -78,9 +78,15 @@ class MessagingService {
|
||||
bool _trustRootMismatch = false;
|
||||
bool get trustRootMismatch => _trustRootMismatch;
|
||||
StreamSubscription<dynamic>? _deliverSub;
|
||||
StreamSubscription<RelayConnectionState>? _stateSub;
|
||||
Timer? _registerRetryTimer;
|
||||
Timer? _purgeTimer;
|
||||
|
||||
/// Běží právě dodatečné odeslání zaseklých zpráv? Reconnectů může přijít
|
||||
/// několik rychle za sebou a dva souběžné průchody by tutéž zprávu poslaly
|
||||
/// dvakrát.
|
||||
bool _outboxRunning = false;
|
||||
|
||||
/// Transport relaye: false = přímé WSS, true = přes Tor (SOCKS5/Orbot na
|
||||
/// `.onion`). Čte se z nastavení při startu, přepíná se za běhu
|
||||
/// ([setTorEnabled]) přestavením [RelayClient].
|
||||
@@ -155,6 +161,10 @@ class MessagingService {
|
||||
final relay = _buildRelay();
|
||||
_relay = relay;
|
||||
_deliverSub = relay.deliveries.listen(_onDeliver);
|
||||
// Po každém (re)connectu dorazit, co zůstalo viset ve frontě odchozích.
|
||||
_stateSub = relay.stateStream.listen((state) {
|
||||
if (state == RelayConnectionState.connected) unawaited(_flushOutbox());
|
||||
});
|
||||
|
||||
if (existingAccount != null) {
|
||||
relay.restoreSession(
|
||||
@@ -211,6 +221,7 @@ class MessagingService {
|
||||
/// push registrace se znovu odešle. Trust root zůstává pinovaný.
|
||||
Future<void> _rebuildRelay() async {
|
||||
await _deliverSub?.cancel();
|
||||
await _stateSub?.cancel();
|
||||
await _relay?.dispose();
|
||||
_senderCert = null;
|
||||
_certFetchedAt = null;
|
||||
@@ -218,6 +229,10 @@ class MessagingService {
|
||||
final relay = _buildRelay();
|
||||
_relay = relay;
|
||||
_deliverSub = relay.deliveries.listen(_onDeliver);
|
||||
// Po každém (re)connectu dorazit, co zůstalo viset ve frontě odchozích.
|
||||
_stateSub = relay.stateStream.listen((state) {
|
||||
if (state == RelayConnectionState.connected) unawaited(_flushOutbox());
|
||||
});
|
||||
|
||||
final existingAccount = accountId;
|
||||
if (existingAccount != null) {
|
||||
@@ -357,6 +372,7 @@ class MessagingService {
|
||||
'v': 1,
|
||||
't': 'txt',
|
||||
'id': message.id,
|
||||
'ts': message.timestamp.millisecondsSinceEpoch,
|
||||
'txt': text,
|
||||
're': ?replyToId,
|
||||
'ttl': ?ttl,
|
||||
@@ -409,10 +425,16 @@ class MessagingService {
|
||||
await storage.addMessage(contact.id, message);
|
||||
onUpdate?.call();
|
||||
|
||||
// Stejné ošetření jako u textu: bez něj zůstala příloha po výpadku
|
||||
// spojení viset na „odesílá se" navždy. Označit jako failed ji dostane
|
||||
// do fronty odchozích ([_flushOutbox]) — blob je na relayi nahraný,
|
||||
// dosílá se jen meta.
|
||||
try {
|
||||
final delivered = await _sendContent(contact, {
|
||||
'v': 1,
|
||||
't': 'att',
|
||||
'id': message.id,
|
||||
'ts': message.timestamp.millisecondsSinceEpoch,
|
||||
if (caption != null && caption.isNotEmpty) 'txt': caption,
|
||||
'ttl': ?ttl,
|
||||
'att': meta.toWire(),
|
||||
@@ -426,6 +448,12 @@ class MessagingService {
|
||||
await storage.updateMessage(contact.id, sent);
|
||||
onUpdate?.call();
|
||||
return sent;
|
||||
} catch (_) {
|
||||
await storage.updateMessage(
|
||||
contact.id, message.copyWith(status: MessageStatus.failed));
|
||||
onUpdate?.call();
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Zpracuje a nahraje přílohu: obrázky re-enkóduje (EXIF/GPS pryč,
|
||||
@@ -496,10 +524,14 @@ class MessagingService {
|
||||
|
||||
Map<String, dynamic> _profile() {
|
||||
final identity = _identity!;
|
||||
final da = myDeliveryAddress;
|
||||
return {
|
||||
'n': identity.displayName,
|
||||
'aid': accountId,
|
||||
'da': base64Encode(myDeliveryAddress!),
|
||||
'aid': ?accountId,
|
||||
// Do doběhnutí vlastní registrace na relay ještě routing nemáme —
|
||||
// pole vynecháme (protistrana ho doplní z pozdějšího profilu), NIKDY
|
||||
// `da!` (null-check pád = „Null check operator used on a null value").
|
||||
'da': ?(da == null ? null : base64Encode(da)),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -694,10 +726,12 @@ class MessagingService {
|
||||
await storage.addMessage(group.id, message);
|
||||
onUpdate?.call();
|
||||
|
||||
try {
|
||||
final anyOk = await _fanoutToGroup(group, {
|
||||
'v': 1,
|
||||
't': 'txt',
|
||||
'id': message.id,
|
||||
'ts': message.timestamp.millisecondsSinceEpoch,
|
||||
'gid': group.id,
|
||||
'txt': text,
|
||||
're': ?replyToId,
|
||||
@@ -709,6 +743,14 @@ class MessagingService {
|
||||
await storage.updateMessage(group.id, sent);
|
||||
onUpdate?.call();
|
||||
return sent;
|
||||
} catch (e) {
|
||||
// Výjimka mimo per-member cestu (např. build obsahu) by jinak nechala
|
||||
// zprávu navždy ve stavu „odesílá se" (⏱). Označit jako selhalou.
|
||||
await storage.updateMessage(
|
||||
group.id, message.copyWith(status: MessageStatus.failed));
|
||||
onUpdate?.call();
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Odešle přílohu do skupiny — blob se šifruje a nahrává JEDNOU,
|
||||
@@ -741,10 +783,12 @@ class MessagingService {
|
||||
await storage.addMessage(group.id, message);
|
||||
onUpdate?.call();
|
||||
|
||||
try {
|
||||
final anyOk = await _fanoutToGroup(group, {
|
||||
'v': 1,
|
||||
't': 'att',
|
||||
'id': message.id,
|
||||
'ts': message.timestamp.millisecondsSinceEpoch,
|
||||
'gid': group.id,
|
||||
if (caption != null && caption.isNotEmpty) 'txt': caption,
|
||||
'ttl': ?ttl,
|
||||
@@ -756,6 +800,12 @@ class MessagingService {
|
||||
await storage.updateMessage(group.id, sent);
|
||||
onUpdate?.call();
|
||||
return sent;
|
||||
} catch (e) {
|
||||
await storage.updateMessage(
|
||||
group.id, message.copyWith(status: MessageStatus.failed));
|
||||
onUpdate?.call();
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Reakce ve skupině (emoji == null = odebrat).
|
||||
@@ -1002,6 +1052,115 @@ class MessagingService {
|
||||
/// Společná odesílací cesta: session bootstrap (X3DH s anti-MITM
|
||||
/// kontrolou identity klíče), sealed encrypt, obálka na relay.
|
||||
/// Vrací true = doručeno online, false = uloženo do offline fronty.
|
||||
/// Kolik zaseklých zpráv poslat na jeden průchod. Po delším výpadku sítě
|
||||
/// jich může být hodně; dávka drží start spojení svižný a zbytek dojde
|
||||
/// při dalším ticku.
|
||||
static const _outboxBatch = 30;
|
||||
|
||||
/// Jak staré zaseklé zprávy ještě dosílat. Co leží dýl, patří uživateli
|
||||
/// do rukou — poslat týden starou zprávu bez zeptání by překvapilo.
|
||||
static const _outboxMaxAge = Duration(hours: 24);
|
||||
|
||||
/// Došle zprávy, které zůstaly viset po výpadku spojení.
|
||||
///
|
||||
/// Bez tohohle skončila každá zpráva odeslaná do mrtvého socketu jako
|
||||
/// `failed` (nebo věčné `sending` u příloh) a znovu ji poslal jedině
|
||||
/// uživatel ručně. Volá se po každém úspěšném (re)connectu; id zprávy
|
||||
/// zůstává stejné, takže protistrana duplicitu zahodí
|
||||
/// ([Conversation.addMessage]).
|
||||
Future<void> _flushOutbox() async {
|
||||
if (_outboxRunning || !isStarted) return;
|
||||
_outboxRunning = true;
|
||||
try {
|
||||
final identity = _identity;
|
||||
if (identity == null) return;
|
||||
final cutoff = DateTime.now().subtract(_outboxMaxAge);
|
||||
|
||||
final stuck = <(String convId, Message message)>[];
|
||||
for (final conv in await storage.loadConversations()) {
|
||||
for (final m in conv.messages) {
|
||||
if (m.senderId != identity.id) continue;
|
||||
if (m.status != MessageStatus.failed &&
|
||||
m.status != MessageStatus.sending) {
|
||||
continue;
|
||||
}
|
||||
if (m.timestamp.isBefore(cutoff) || m.isDeleted) continue;
|
||||
stuck.add((conv.contactId, m));
|
||||
}
|
||||
}
|
||||
if (stuck.isEmpty) return;
|
||||
|
||||
stuck.sort((a, b) => a.$2.timestamp.compareTo(b.$2.timestamp));
|
||||
for (final (convId, message) in stuck.take(_outboxBatch)) {
|
||||
if (connectionState != RelayConnectionState.connected) break;
|
||||
await _resend(convId, message);
|
||||
}
|
||||
onUpdate?.call();
|
||||
} finally {
|
||||
_outboxRunning = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Jeden pokus o dodatečné odeslání. Trvalé chyby (chybný klíč protistrany,
|
||||
/// kontakt bez doručovací adresy) nechají zprávu `failed` a dál se
|
||||
/// neopakují — opakovat má smysl jen to, co shodila síť.
|
||||
Future<void> _resend(String convId, Message message) async {
|
||||
try {
|
||||
final group = await storage.loadGroup(convId);
|
||||
final bool delivered;
|
||||
if (group != null) {
|
||||
delivered = await _fanoutToGroup(group, _outboxWire(message, gid: convId));
|
||||
} else {
|
||||
final contact = (await storage.loadContacts())
|
||||
.where((c) => c.id == convId)
|
||||
.firstOrNull;
|
||||
if (contact == null) return;
|
||||
delivered = await _sendContent(contact, _outboxWire(message));
|
||||
}
|
||||
await storage.updateMessage(
|
||||
convId,
|
||||
message.copyWith(
|
||||
isDelivered: delivered,
|
||||
status: delivered ? MessageStatus.delivered : MessageStatus.sent,
|
||||
),
|
||||
);
|
||||
} on SepotUserError {
|
||||
// Srozumitelná chyba = stav, který se opakováním nespraví (chybí
|
||||
// doručovací adresa, nesedí identity klíč, podvržený relay).
|
||||
await storage.updateMessage(
|
||||
convId,
|
||||
message.copyWith(status: MessageStatus.failed),
|
||||
);
|
||||
} on Object {
|
||||
// Síť/timeout — nechat viset a zkusit po příštím reconnectu.
|
||||
await storage.updateMessage(
|
||||
convId,
|
||||
message.copyWith(status: MessageStatus.failed),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Obsah zprávy pro drát při dodatečném odeslání. Drží se tvaru, který
|
||||
/// posílá [sendText]/[sendAttachment] — včetně `id`, aby šlo o TUTÉŽ
|
||||
/// zprávu, ne novou.
|
||||
Map<String, dynamic> _outboxWire(Message message, {String? gid}) {
|
||||
final isAttachment = message.attachment != null;
|
||||
final caption = message.content;
|
||||
return {
|
||||
'v': 1,
|
||||
't': isAttachment ? 'att' : 'txt',
|
||||
'id': message.id,
|
||||
'ts': message.timestamp.millisecondsSinceEpoch,
|
||||
'gid': ?gid,
|
||||
if (isAttachment) 'att': message.attachment!.toWire(),
|
||||
if (!isAttachment || (caption != null && caption.isNotEmpty))
|
||||
'txt': caption ?? '',
|
||||
're': ?message.replyToId,
|
||||
'ttl': ?message.disappearAfterSeconds,
|
||||
'p': _profile(),
|
||||
};
|
||||
}
|
||||
|
||||
Future<bool> _sendContent(
|
||||
Contact contact,
|
||||
Map<String, dynamic> content,
|
||||
@@ -1009,6 +1168,13 @@ class MessagingService {
|
||||
final crypto = _crypto ?? (throw StateError('Service not started'));
|
||||
final relay = _relay!;
|
||||
|
||||
// Vlastní registrace na relay ještě nedoběhla (čerstvá instalace,
|
||||
// pomalá síť, Tor bez Orbotu) → nemáme sender adresu ani sender cert.
|
||||
// Srozumitelná chyba místo null-check pádu; registrace běží na pozadí.
|
||||
if (accountId == null || myDeliveryAddress == null) {
|
||||
throw const NotYetRegisteredError();
|
||||
}
|
||||
|
||||
final peer = contact.publicIdentity;
|
||||
final peerAid = peer.accountId;
|
||||
final peerDa = peer.deliveryAddress;
|
||||
@@ -1204,7 +1370,7 @@ class MessagingService {
|
||||
type: MessageType.text,
|
||||
content: text,
|
||||
replyToId: replyTo,
|
||||
timestamp: DateTime.now(),
|
||||
timestamp: wireSentAt(content['ts']),
|
||||
isDelivered: true,
|
||||
disappearAfterSeconds: ttl,
|
||||
);
|
||||
@@ -1230,18 +1396,31 @@ class MessagingService {
|
||||
fileName: meta.name,
|
||||
fileSize: meta.size,
|
||||
replyToId: replyTo,
|
||||
timestamp: DateTime.now(),
|
||||
timestamp: wireSentAt(content['ts']),
|
||||
isDelivered: true,
|
||||
attachment: meta,
|
||||
disappearAfterSeconds: ttl,
|
||||
);
|
||||
}
|
||||
await storage.addMessage(convId, message);
|
||||
final isNew = await storage.addMessage(convId, message);
|
||||
|
||||
// Uloženo na disku → teprve teď smí relay obálku zahodit.
|
||||
// (Blob přílohy do fronty nepatří — stahuje se na pozadí a při
|
||||
// neúspěchu má UI retry, ACK na něj nečeká.)
|
||||
_relay!.ack(envelope.msgUuid);
|
||||
|
||||
// Zprávu už známe (odesílatel ji poslal znovu po výpadku spojení).
|
||||
// Druhá bublina ani další stahování téhož blobu nemá vzniknout, ale
|
||||
// doručenku pošli — protistrana ji nejspíš právě proto nedostala.
|
||||
if (!isNew) {
|
||||
unawaited(_sendControl(contact, {
|
||||
't': 'dlv',
|
||||
'ref': message.id,
|
||||
'gid': ?gid,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
onUpdate?.call();
|
||||
onMessageReceived?.call(contact, message);
|
||||
|
||||
@@ -1273,6 +1452,24 @@ class MessagingService {
|
||||
}
|
||||
}
|
||||
|
||||
/// Čas odeslání z drátu (ms). Bez něj by zpráva dostala čas doručení —
|
||||
/// po vyzvednutí offline fronty by celá konverzace uskočila na „teď"
|
||||
/// a každý člen skupiny by u téže zprávy viděl jiný čas.
|
||||
///
|
||||
/// Klienti ≤1.1.1 `ts` neposílají → fallback na čas doručení (chová se
|
||||
/// jako dřív). Odesílatel si čas diktuje sám (jde E2E kanálem), proto
|
||||
/// strop: hodiny zařízení se běžně rozcházejí o minuty, ale zpráva
|
||||
/// nesmí uskočit mimo konverzaci.
|
||||
@visibleForTesting
|
||||
static DateTime wireSentAt(Object? raw) {
|
||||
final now = DateTime.now();
|
||||
if (raw is! int) return now;
|
||||
final sent = DateTime.fromMillisecondsSinceEpoch(raw);
|
||||
if (sent.isAfter(now.add(const Duration(minutes: 5)))) return now;
|
||||
if (sent.isBefore(DateTime(2020))) return now;
|
||||
return sent;
|
||||
}
|
||||
|
||||
/// Aplikuje příchozí reakci/edit/delete na cílovou zprávu v dané
|
||||
/// konverzaci (1:1 = kontakt, skupina = gid). Klíč reakce = fingerprint
|
||||
/// odesílatele (kryptograficky ověřený sealed senderem, ne
|
||||
@@ -1487,6 +1684,7 @@ class MessagingService {
|
||||
_purgeTimer?.cancel();
|
||||
_purgeTimer = null;
|
||||
await _deliverSub?.cancel();
|
||||
await _stateSub?.cancel();
|
||||
await _relay?.dispose();
|
||||
// DB vlastní SecureStorageService (lock/deleteAll) — tady jen pustit
|
||||
_db = null;
|
||||
|
||||
@@ -442,11 +442,15 @@ class SecureStorageService {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> addMessage(String contactId, Message message) async {
|
||||
/// Přidá zprávu do konverzace. Vrací false, když už tam zpráva s tímhle id
|
||||
/// je (opakované doručení) — volající pak přeskočí navazující kroky jako
|
||||
/// doručenku nebo stahování přílohy.
|
||||
Future<bool> addMessage(String contactId, Message message) async {
|
||||
var conv = await loadConversation(contactId);
|
||||
conv ??= Conversation(contactId: contactId);
|
||||
conv.addMessage(message);
|
||||
if (!conv.addMessage(message)) return false;
|
||||
await saveConversation(conv);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Založí prázdnou konverzaci, pokud ještě není (nová skupina musí být
|
||||
|
||||
@@ -6,6 +6,8 @@ import 'package:local_auth/local_auth.dart';
|
||||
import 'package:sepot/core/crypto/dek_wrap.dart';
|
||||
import 'package:sepot/core/l10n/l10n.dart';
|
||||
import 'package:sepot/core/theme/app_theme.dart';
|
||||
import 'package:sepot/providers/messaging_provider.dart';
|
||||
import 'package:sepot/providers/push_provider.dart';
|
||||
import 'package:sepot/providers/storage_provider.dart';
|
||||
import 'package:sepot/widgets/sepot_logo.dart';
|
||||
|
||||
@@ -43,6 +45,7 @@ class _PinLockScreenState extends ConsumerState<PinLockScreen> {
|
||||
);
|
||||
if (!ok) return; // uživatel zrušil → zůstává PIN
|
||||
await storage.unlockWithBiometrics();
|
||||
_restartMessagingAfterUnlock();
|
||||
if (mounted) context.go('/chats');
|
||||
} catch (_) {
|
||||
// biometrie nedostupná/selhala — PIN je vždy fallback
|
||||
@@ -211,6 +214,16 @@ class _PinLockScreenState extends ConsumerState<PinLockScreen> {
|
||||
});
|
||||
}
|
||||
|
||||
/// Auto-lock službu zastavil a storage zamkl; po odemčení je nutné
|
||||
/// messaging znovu nastartovat. messagingStartProvider drží starou
|
||||
/// „hotovou" hodnotu (start se sám neprovede) → invalidace ho přepočítá
|
||||
/// nad odemčeným storage a spustí start() znovu (jinak žádný příjem ani
|
||||
/// odesílání až do restartu appky).
|
||||
void _restartMessagingAfterUnlock() {
|
||||
ref.invalidate(messagingStartProvider);
|
||||
ref.invalidate(pushInitProvider);
|
||||
}
|
||||
|
||||
Future<void> _verifyPin() async {
|
||||
setState(() => _isVerifying = true);
|
||||
|
||||
@@ -218,6 +231,7 @@ class _PinLockScreenState extends ConsumerState<PinLockScreen> {
|
||||
// „Ověření" PINu = Argon2id unwrap DEK + otevření SQLCipher DB.
|
||||
// Špatný PIN = GCM tag nesedí = DB zůstává nečitelná.
|
||||
await ref.read(storageProvider).unlockWithPin(_pin);
|
||||
_restartMessagingAfterUnlock();
|
||||
if (mounted) context.go('/chats');
|
||||
} on WrongPinException {
|
||||
setState(() {
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:sepot/core/l10n/error_messages.dart';
|
||||
import 'package:sepot/core/l10n/l10n.dart';
|
||||
import 'package:sepot/core/theme/app_theme.dart';
|
||||
import 'package:sepot/features/chat_list/chat_list_tile.dart';
|
||||
import 'package:sepot/features/chat_list/push_hint_banner.dart';
|
||||
import 'package:sepot/models/conversation.dart';
|
||||
import 'package:sepot/providers/contacts_provider.dart';
|
||||
import 'package:sepot/providers/conversations_provider.dart';
|
||||
@@ -62,7 +63,13 @@ class ChatListScreen extends ConsumerWidget {
|
||||
}
|
||||
final hasArchive = archived.isNotEmpty;
|
||||
|
||||
return ListView.builder(
|
||||
return Column(
|
||||
children: [
|
||||
// Bez budíčků appka na pozadí spí — ať uživatel ví, proč
|
||||
// mu zprávy chodí, až když Šepot otevře.
|
||||
const PushHintBanner(),
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
itemCount: active.length + (hasArchive ? 1 : 0),
|
||||
itemBuilder: (context, index) {
|
||||
@@ -96,6 +103,9 @@ class ChatListScreen extends ConsumerWidget {
|
||||
onLongPress: () => _showChatActions(context, ref, conv, name),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
loading: () => const Center(
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:sepot/core/l10n/l10n.dart';
|
||||
import 'package:sepot/core/theme/app_theme.dart';
|
||||
import 'package:sepot/providers/push_provider.dart';
|
||||
import 'package:sepot/providers/storage_provider.dart';
|
||||
|
||||
/// Upozornění nad seznamem chatů, když budíčky nefungují.
|
||||
///
|
||||
/// Bez pushe Android appku na pozadí uspí a zprávy dorazí, až ji uživatel
|
||||
/// sám otevře — vypadá to jako výpadek doručování, i když relay má všechno
|
||||
/// připravené ve frontě. Proužek je proto informace o stavu, ne reklama:
|
||||
/// zmizí, jakmile si push nastaví, a jde odmítnout natrvalo.
|
||||
class PushHintBanner extends ConsumerWidget {
|
||||
const PushHintBanner({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final health = ref.watch(pushHealthProvider).valueOrNull;
|
||||
final dismissed = ref.watch(pushHintDismissedProvider).valueOrNull ?? true;
|
||||
if (dismissed || health == null || health == PushHealth.ok) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final text = health == PushHealth.failed
|
||||
? context.l10n.pushHintFailed
|
||||
: context.l10n.pushHintOff;
|
||||
|
||||
return Material(
|
||||
color: SepotColors.surfaceVariant,
|
||||
child: InkWell(
|
||||
onTap: () => context.push('/settings'),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 10, 8, 10),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.notifications_off_outlined,
|
||||
size: 18,
|
||||
color: health == PushHealth.failed
|
||||
? SepotColors.ember
|
||||
: SepotColors.textMuted,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
text,
|
||||
style: const TextStyle(
|
||||
fontFamily: kFontSans,
|
||||
fontSize: 13,
|
||||
color: SepotColors.textSecondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close, size: 18),
|
||||
color: SepotColors.textMuted,
|
||||
tooltip: context.l10n.commonClose,
|
||||
onPressed: () async {
|
||||
await ref
|
||||
.read(storageProvider)
|
||||
.setSetting('push_hint_dismissed', true);
|
||||
ref.invalidate(pushHintDismissedProvider);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import 'package:share_plus/share_plus.dart';
|
||||
import 'package:sepot/core/l10n/error_messages.dart';
|
||||
import 'package:sepot/core/l10n/l10n.dart';
|
||||
import 'package:sepot/core/theme/app_theme.dart';
|
||||
import 'package:sepot/models/identity.dart';
|
||||
import 'package:sepot/providers/identity_provider.dart';
|
||||
import 'package:sepot/providers/messaging_provider.dart';
|
||||
|
||||
@@ -27,12 +28,24 @@ class QrShareScreen extends ConsumerWidget {
|
||||
return Center(child: Text(context.l10n.qrIdentityNotFound));
|
||||
}
|
||||
|
||||
// Sdílet s routingem (accountId + delivery address z relaye),
|
||||
// jinak protistrana nemá kam poslat první zprávu
|
||||
// Sledovat stav připojení: po dokončení vlastní registrace se
|
||||
// routing (aid+da) zapíše do DB a relay se připojí → tenhle watch
|
||||
// obrazovku překreslí a QR se z „připojuji" přepne na hotový kód.
|
||||
ref.watch(relayConnectionProvider);
|
||||
final service = ref.watch(messagingServiceProvider);
|
||||
final shareString = (service.shareableIdentity ??
|
||||
identity.publicIdentity)
|
||||
.toShareString();
|
||||
final shareable = service.shareableIdentity;
|
||||
final hasRouting = shareable != null &&
|
||||
shareable.accountId != null &&
|
||||
shareable.deliveryAddress != null;
|
||||
|
||||
// Bez routingu by QR/odkaz byl neúplný (protistrana by neměla kam
|
||||
// poslat první zprávu → „Kontakt nemá doručovací adresu"). Radši
|
||||
// než mrtvý kód ukázat stav připojování.
|
||||
if (!hasRouting) {
|
||||
return _ConnectingPlaceholder(identity: identity);
|
||||
}
|
||||
|
||||
final shareString = shareable.toShareString();
|
||||
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(32),
|
||||
@@ -157,3 +170,56 @@ class QrShareScreen extends ConsumerWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Stav obrazovky, dokud vlastní registrace na relay nedoběhne — místo
|
||||
/// neúplného QR (bez routingu) ukáže identitu a hlášku o připojování.
|
||||
class _ConnectingPlaceholder extends StatelessWidget {
|
||||
const _ConnectingPlaceholder({required this.identity});
|
||||
|
||||
final LocalIdentity identity;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const CircularProgressIndicator(color: SepotColors.ember),
|
||||
const SizedBox(height: 32),
|
||||
Text(
|
||||
context.l10n.qrShareConnectingTitle,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
fontFamily: kFontSans,
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: SepotColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
context.l10n.qrShareConnectingBody,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: SepotColors.textSecondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
identity.sepotId,
|
||||
style: const TextStyle(
|
||||
fontFamily: kFontMono,
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: SepotColors.ember,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ class _PushSettingsTileState extends ConsumerState<PushSettingsTile> {
|
||||
String _mode = 'off';
|
||||
String? _distributor;
|
||||
bool _busy = false;
|
||||
bool _registrationFailed = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -31,10 +32,12 @@ class _PushSettingsTileState extends ConsumerState<PushSettingsTile> {
|
||||
final push = ref.read(pushServiceProvider);
|
||||
final mode = await push.currentMode();
|
||||
final d = await push.currentDistributor();
|
||||
final failed = await push.registrationFailed();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_mode = mode;
|
||||
_distributor = d;
|
||||
_registrationFailed = failed;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -160,7 +163,12 @@ class _PushSettingsTileState extends ConsumerState<PushSettingsTile> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final subtitle = switch (_mode) {
|
||||
// Selhaná registrace přebíjí popis režimu — „zapnuto" bez budíčků je
|
||||
// horší než vypnuto, protože uživatel čeká zprávy, které nepřijdou.
|
||||
final failed = _registrationFailed && _mode != 'off';
|
||||
final subtitle = failed
|
||||
? context.l10n.pushRegistrationFailed
|
||||
: switch (_mode) {
|
||||
'up' => context.l10n.pushSubtitleUnifiedPush(
|
||||
_distributor ?? context.l10n.pushNoDistributorFallback),
|
||||
'fcm' => context.l10n.pushSubtitleFcm,
|
||||
@@ -184,7 +192,10 @@ class _PushSettingsTileState extends ConsumerState<PushSettingsTile> {
|
||||
style: const TextStyle(fontSize: 15)),
|
||||
subtitle: Text(
|
||||
subtitle,
|
||||
style: const TextStyle(fontSize: 13, color: SepotColors.textMuted),
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: failed ? SepotColors.ember : SepotColors.textMuted,
|
||||
),
|
||||
),
|
||||
trailing: _busy
|
||||
? const SizedBox(
|
||||
|
||||
@@ -375,6 +375,14 @@
|
||||
"@errorRelayUnavailable": {
|
||||
"description": "StateError z _senderCertificate, když se nepodařilo získat sender certificate (offline relay); propaguje se do odesílací cesty, zobrazuje chat UI při selhání odeslání."
|
||||
},
|
||||
"errorNotYetRegistered": "Šepot se ještě připojuje k relayi — zkus zprávu poslat za chvíli.",
|
||||
"@errorNotYetRegistered": {
|
||||
"description": "NotYetRegisteredError z _sendContent, když vlastní registrace na relay ještě nedoběhla (čerstvá instalace). 'Šepot' je značka, nepřekládat."
|
||||
},
|
||||
"errorContactLinkIncomplete": "Odkaz je neúplný — protistrana ho vytvořila dřív, než se stihla připojit. Požádej ji o nový sepot:// odkaz.",
|
||||
"@errorContactLinkIncomplete": {
|
||||
"description": "ContactLinkIncompleteError při přidání kontaktu z odkazu/QR bez routingu (aid+da). 'sepot://' je schéma odkazu, zachovat."
|
||||
},
|
||||
"errorAttachmentTooLarge": "Příloha je příliš velká (max {maxMb} MB)",
|
||||
"@errorAttachmentTooLarge": {
|
||||
"description": "StateError z _prepareAttachment při překročení limitu velikosti přílohy; zobrazuje UI výběru/odeslání přílohy. POZOR: v kódu jde o dva adjacentní literály 'Příloha je příliš velká ' (ř. 448) + '(max ${…} MB)' (ř. 449) — sloučit do jednoho klíče.",
|
||||
@@ -1287,6 +1295,18 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"pushHintOff": "Zprávy ti přijdou, až Šepot otevřeš — budíčky nejsou nastavené.",
|
||||
"@pushHintOff": {
|
||||
"description": "Proužek nad seznamem chatů, když push není vůbec zapnutý."
|
||||
},
|
||||
"pushHintFailed": "Budíčky nefungují, zprávy uvidíš až po otevření Šepotu.",
|
||||
"@pushHintFailed": {
|
||||
"description": "Proužek nad seznamem chatů, když distributor registraci odmítl."
|
||||
},
|
||||
"pushRegistrationFailed": "Budíčky nechodí — distributor registraci odmítl. Zkus push vypnout a znovu zapnout.",
|
||||
"@pushRegistrationFailed": {
|
||||
"description": "Podtitulek dlaždice Push v Nastavení, když distributor odmítl registraci. Bez téhle hlášky uživatel vidí push jako zapnutý, ale zprávy mu chodí až po otevření appky."
|
||||
},
|
||||
"pushNoDistributorTitle": "Žádný distributor",
|
||||
"@pushNoDistributorTitle": {
|
||||
"description": "Titulek AlertDialog, když v systému není nainstalovaný žádný UnifiedPush distributor"
|
||||
@@ -1419,6 +1439,14 @@
|
||||
"@qrShareTitle": {
|
||||
"description": "AppBar titulek obrazovky sdílení vlastního QR/Šepot ID"
|
||||
},
|
||||
"qrShareConnectingTitle": "Připojuji k relayi…",
|
||||
"@qrShareConnectingTitle": {
|
||||
"description": "Titulek na QR obrazovce, když vlastní registrace na relay ještě nedoběhla — routing (aid+da) není hotový, QR by byl neúplný."
|
||||
},
|
||||
"qrShareConnectingBody": "Až se Šepot poprvé připojí, objeví se tvůj QR kód a odkaz. Chvilku strpení — stačí pár sekund.",
|
||||
"@qrShareConnectingBody": {
|
||||
"description": "Vysvětlení pod titulkem qrShareConnectingTitle. 'Šepot' je značka, nepřekládat."
|
||||
},
|
||||
"qrIdentityNotFound": "Identita nebyla nalezena",
|
||||
"@qrIdentityNotFound": {
|
||||
"description": "Text uprostřed obrazovky, když identityProvider vrátí null (nemělo by běžně nastat)"
|
||||
|
||||
@@ -75,6 +75,8 @@
|
||||
"chatEdited": "bearbeitet",
|
||||
"errorTrustRootChanged": "Der Trust Root des Relays hat sich geändert! Kommunikation wird verweigert.",
|
||||
"errorRelayUnavailable": "Relay nicht erreichbar (Sender-Zertifikat)",
|
||||
"errorNotYetRegistered": "Šepot verbindet sich noch mit dem Relay — versuch es gleich noch einmal.",
|
||||
"errorContactLinkIncomplete": "Dieser Link ist unvollständig — die andere Person hat ihn erstellt, bevor sie verbunden war. Bitte sie um einen neuen sepot://-Link.",
|
||||
"errorAttachmentTooLarge": "Anhang ist zu groß (max. {maxMb} MB)",
|
||||
"errorGroupNeedsMember": "Die Gruppe braucht mindestens 1 Mitglied",
|
||||
"errorGroupMaxMembers": "Die Gruppe kann höchstens {maxMembers} Mitglieder haben",
|
||||
@@ -269,6 +271,9 @@
|
||||
"pushModeFcm": "Google (FCM)",
|
||||
"pushModeFcmDesc": "Zuverlässig ohne zusätzliche App. Der Push-Token des Geräts geht an Google; der Inhalt der Nachrichten nie.",
|
||||
"pushSetupFailed": "Einrichtung der Push-Benachrichtigungen fehlgeschlagen: {error}",
|
||||
"pushHintOff": "Nachrichten kommen erst an, wenn du Šepot öffnest — keine Weckrufe eingerichtet.",
|
||||
"pushHintFailed": "Weckrufe funktionieren nicht; Nachrichten siehst du erst beim Öffnen von Šepot.",
|
||||
"pushRegistrationFailed": "Es kommen keine Weckrufe an — der Distributor hat die Registrierung abgelehnt. Schalte Push aus und wieder ein.",
|
||||
"pushNoDistributorTitle": "Kein Distributor",
|
||||
"pushNoDistributorBody": "Für Google-freie Benachrichtigungen installiere einen UnifiedPush-Distributor, z. B. ntfy (F-Droid oder Play). Aktiviere Push danach erneut.",
|
||||
"commonOk": "OK",
|
||||
@@ -294,6 +299,8 @@
|
||||
"chatListArchive": "Archivieren",
|
||||
"qrShareTitle": "Meine Šepot-ID",
|
||||
"qrIdentityNotFound": "Identität nicht gefunden",
|
||||
"qrShareConnectingTitle": "Verbinde mit dem Relay…",
|
||||
"qrShareConnectingBody": "Dein QR-Code und Link erscheinen, sobald sich Šepot zum ersten Mal verbindet. Einen Moment — es dauert nur ein paar Sekunden.",
|
||||
"commonCopiedToClipboard": "In die Zwischenablage kopiert",
|
||||
"qrShareInviteMessage": "Füg mich auf Šepot hinzu!\n\n{shareString}",
|
||||
"commonShare": "Teilen",
|
||||
|
||||
@@ -75,6 +75,8 @@
|
||||
"chatEdited": "edited",
|
||||
"errorTrustRootChanged": "The relay's trust root has changed! Refusing to communicate.",
|
||||
"errorRelayUnavailable": "Relay unavailable (sender certificate)",
|
||||
"errorNotYetRegistered": "Šepot is still connecting to the relay — try sending again in a moment.",
|
||||
"errorContactLinkIncomplete": "This link is incomplete — the other person created it before connecting. Ask them for a new sepot:// link.",
|
||||
"errorAttachmentTooLarge": "Attachment is too large (max {maxMb} MB)",
|
||||
"errorGroupNeedsMember": "A group needs at least 1 member",
|
||||
"errorGroupMaxMembers": "A group can have at most {maxMembers} members",
|
||||
@@ -269,6 +271,9 @@
|
||||
"pushModeFcm": "Google (FCM)",
|
||||
"pushModeFcmDesc": "Reliable without an extra app. The device push token goes to Google; message content never does.",
|
||||
"pushSetupFailed": "Setting up push failed: {error}",
|
||||
"pushHintOff": "Messages arrive only when you open Šepot — wake-ups aren’t set up.",
|
||||
"pushHintFailed": "Wake-ups aren’t working; you’ll see messages only after opening Šepot.",
|
||||
"pushRegistrationFailed": "Wake-ups aren’t arriving — the distributor refused registration. Try turning push off and on again.",
|
||||
"pushNoDistributorTitle": "No distributor",
|
||||
"pushNoDistributorBody": "For de-Googled notifications, install a UnifiedPush distributor such as ntfy (F-Droid or Play). Then turn push on again.",
|
||||
"commonOk": "OK",
|
||||
@@ -294,6 +299,8 @@
|
||||
"chatListArchive": "Archive",
|
||||
"qrShareTitle": "My Šepot ID",
|
||||
"qrIdentityNotFound": "Identity not found",
|
||||
"qrShareConnectingTitle": "Connecting to the relay…",
|
||||
"qrShareConnectingBody": "Your QR code and link will appear once Šepot connects for the first time. Just a moment — it only takes a few seconds.",
|
||||
"commonCopiedToClipboard": "Copied to clipboard",
|
||||
"qrShareInviteMessage": "Add me on Šepot!\n\n{shareString}",
|
||||
"commonShare": "Share",
|
||||
|
||||
@@ -75,6 +75,8 @@
|
||||
"chatEdited": "modificato",
|
||||
"errorTrustRootChanged": "Il trust root del relay è cambiato! Comunicazione rifiutata.",
|
||||
"errorRelayUnavailable": "Relay non disponibile (sender certificate)",
|
||||
"errorNotYetRegistered": "Šepot si sta ancora connettendo al relay — riprova a inviare tra un istante.",
|
||||
"errorContactLinkIncomplete": "Questo link è incompleto — l'altra persona l'ha creato prima di connettersi. Chiedile un nuovo link sepot://.",
|
||||
"errorAttachmentTooLarge": "L'allegato è troppo grande (max {maxMb} MB)",
|
||||
"errorGroupNeedsMember": "Il gruppo ha bisogno di almeno 1 membro",
|
||||
"errorGroupMaxMembers": "Il gruppo può avere al massimo {maxMembers} membri",
|
||||
@@ -269,6 +271,9 @@
|
||||
"pushModeFcm": "Google (FCM)",
|
||||
"pushModeFcmDesc": "Affidabile senza altre app. Il token push del dispositivo va a Google; il contenuto dei messaggi mai.",
|
||||
"pushSetupFailed": "Configurazione delle notifiche push non riuscita: {error}",
|
||||
"pushHintOff": "I messaggi arrivano solo quando apri Šepot — le notifiche non sono configurate.",
|
||||
"pushHintFailed": "Le notifiche non funzionano: vedrai i messaggi solo aprendo Šepot.",
|
||||
"pushRegistrationFailed": "Le notifiche non arrivano — il distributore ha rifiutato la registrazione. Prova a disattivare e riattivare il push.",
|
||||
"pushNoDistributorTitle": "Nessun distributore",
|
||||
"pushNoDistributorBody": "Per notifiche senza Google installa un distributore UnifiedPush, ad es. ntfy (F-Droid o Play). Poi riattiva le notifiche push.",
|
||||
"commonOk": "OK",
|
||||
@@ -294,6 +299,8 @@
|
||||
"chatListArchive": "Archivia",
|
||||
"qrShareTitle": "Il mio Šepot ID",
|
||||
"qrIdentityNotFound": "Identità non trovata",
|
||||
"qrShareConnectingTitle": "Connessione al relay…",
|
||||
"qrShareConnectingBody": "Il tuo codice QR e il link appariranno appena Šepot si connette per la prima volta. Un istante — bastano pochi secondi.",
|
||||
"commonCopiedToClipboard": "Copiato negli appunti",
|
||||
"qrShareInviteMessage": "Aggiungimi su Šepot!\n\n{shareString}",
|
||||
"commonShare": "Condividi",
|
||||
|
||||
@@ -75,6 +75,8 @@
|
||||
"chatEdited": "изменено",
|
||||
"errorTrustRootChanged": "Trust root релея изменился! Соединение отклонено.",
|
||||
"errorRelayUnavailable": "Релей недоступен (sender certificate)",
|
||||
"errorNotYetRegistered": "Šepot ещё подключается к релею — попробуйте отправить сообщение через пару секунд.",
|
||||
"errorContactLinkIncomplete": "Ссылка неполная — собеседник создал её до подключения. Попросите у него новую ссылку sepot://.",
|
||||
"errorAttachmentTooLarge": "Вложение слишком большое (макс. {maxMb} МБ)",
|
||||
"errorGroupNeedsMember": "В группе должен быть хотя бы 1 участник",
|
||||
"errorGroupMaxMembers": "Максимальное число участников группы — {maxMembers}",
|
||||
@@ -269,6 +271,9 @@
|
||||
"pushModeFcm": "Google (FCM)",
|
||||
"pushModeFcmDesc": "Надёжно и без дополнительных приложений. Push-токен устройства передаётся Google; содержимое сообщений — никогда.",
|
||||
"pushSetupFailed": "Не удалось настроить push: {error}",
|
||||
"pushHintOff": "Сообщения придут, только когда откроешь Šepot — сигналы не настроены.",
|
||||
"pushHintFailed": "Сигналы не работают: сообщения увидишь только после открытия Šepot.",
|
||||
"pushRegistrationFailed": "Сигналы не приходят — дистрибьютор отклонил регистрацию. Попробуй выключить и снова включить push.",
|
||||
"pushNoDistributorTitle": "Нет дистрибьютора",
|
||||
"pushNoDistributorBody": "Для уведомлений без сервисов Google установите дистрибьютор UnifiedPush, например ntfy (F-Droid или Play). Затем включите push снова.",
|
||||
"commonOk": "OK",
|
||||
@@ -294,6 +299,8 @@
|
||||
"chatListArchive": "Архивировать",
|
||||
"qrShareTitle": "Мой Šepot ID",
|
||||
"qrIdentityNotFound": "Идентичность не найдена",
|
||||
"qrShareConnectingTitle": "Подключение к релею…",
|
||||
"qrShareConnectingBody": "Ваш QR-код и ссылка появятся, как только Šepot подключится в первый раз. Подождите пару секунд.",
|
||||
"commonCopiedToClipboard": "Скопировано в буфер обмена",
|
||||
"qrShareInviteMessage": "Добавьте меня в Šepot!\n\n{shareString}",
|
||||
"commonShare": "Поделиться",
|
||||
|
||||
@@ -554,6 +554,18 @@ abstract class AppLocalizations {
|
||||
/// **'Relay nedostupný (sender certificate)'**
|
||||
String get errorRelayUnavailable;
|
||||
|
||||
/// NotYetRegisteredError z _sendContent, když vlastní registrace na relay ještě nedoběhla (čerstvá instalace). 'Šepot' je značka, nepřekládat.
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Šepot se ještě připojuje k relayi — zkus zprávu poslat za chvíli.'**
|
||||
String get errorNotYetRegistered;
|
||||
|
||||
/// ContactLinkIncompleteError při přidání kontaktu z odkazu/QR bez routingu (aid+da). 'sepot://' je schéma odkazu, zachovat.
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Odkaz je neúplný — protistrana ho vytvořila dřív, než se stihla připojit. Požádej ji o nový sepot:// odkaz.'**
|
||||
String get errorContactLinkIncomplete;
|
||||
|
||||
/// StateError z _prepareAttachment při překročení limitu velikosti přílohy; zobrazuje UI výběru/odeslání přílohy. POZOR: v kódu jde o dva adjacentní literály 'Příloha je příliš velká ' (ř. 448) + '(max ${…} MB)' (ř. 449) — sloučit do jednoho klíče.
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
@@ -1718,6 +1730,24 @@ abstract class AppLocalizations {
|
||||
/// **'Nastavení pushů selhalo: {error}'**
|
||||
String pushSetupFailed(String error);
|
||||
|
||||
/// Proužek nad seznamem chatů, když push není vůbec zapnutý.
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Zprávy ti přijdou, až Šepot otevřeš — budíčky nejsou nastavené.'**
|
||||
String get pushHintOff;
|
||||
|
||||
/// Proužek nad seznamem chatů, když distributor registraci odmítl.
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Budíčky nefungují, zprávy uvidíš až po otevření Šepotu.'**
|
||||
String get pushHintFailed;
|
||||
|
||||
/// Podtitulek dlaždice Push v Nastavení, když distributor odmítl registraci. Bez téhle hlášky uživatel vidí push jako zapnutý, ale zprávy mu chodí až po otevření appky.
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Budíčky nechodí — distributor registraci odmítl. Zkus push vypnout a znovu zapnout.'**
|
||||
String get pushRegistrationFailed;
|
||||
|
||||
/// Titulek AlertDialog, když v systému není nainstalovaný žádný UnifiedPush distributor
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
@@ -1862,6 +1892,18 @@ abstract class AppLocalizations {
|
||||
/// **'Můj Šepot ID'**
|
||||
String get qrShareTitle;
|
||||
|
||||
/// Titulek na QR obrazovce, když vlastní registrace na relay ještě nedoběhla — routing (aid+da) není hotový, QR by byl neúplný.
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Připojuji k relayi…'**
|
||||
String get qrShareConnectingTitle;
|
||||
|
||||
/// Vysvětlení pod titulkem qrShareConnectingTitle. 'Šepot' je značka, nepřekládat.
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
/// **'Až se Šepot poprvé připojí, objeví se tvůj QR kód a odkaz. Chvilku strpení — stačí pár sekund.'**
|
||||
String get qrShareConnectingBody;
|
||||
|
||||
/// Text uprostřed obrazovky, když identityProvider vrátí null (nemělo by běžně nastat)
|
||||
///
|
||||
/// In cs, this message translates to:
|
||||
|
||||
@@ -276,6 +276,14 @@ class AppLocalizationsCs extends AppLocalizations {
|
||||
@override
|
||||
String get errorRelayUnavailable => 'Relay nedostupný (sender certificate)';
|
||||
|
||||
@override
|
||||
String get errorNotYetRegistered =>
|
||||
'Šepot se ještě připojuje k relayi — zkus zprávu poslat za chvíli.';
|
||||
|
||||
@override
|
||||
String get errorContactLinkIncomplete =>
|
||||
'Odkaz je neúplný — protistrana ho vytvořila dřív, než se stihla připojit. Požádej ji o nový sepot:// odkaz.';
|
||||
|
||||
@override
|
||||
String errorAttachmentTooLarge(int maxMb) {
|
||||
return 'Příloha je příliš velká (max $maxMb MB)';
|
||||
@@ -953,6 +961,18 @@ class AppLocalizationsCs extends AppLocalizations {
|
||||
return 'Nastavení pushů selhalo: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String get pushHintOff =>
|
||||
'Zprávy ti přijdou, až Šepot otevřeš — budíčky nejsou nastavené.';
|
||||
|
||||
@override
|
||||
String get pushHintFailed =>
|
||||
'Budíčky nefungují, zprávy uvidíš až po otevření Šepotu.';
|
||||
|
||||
@override
|
||||
String get pushRegistrationFailed =>
|
||||
'Budíčky nechodí — distributor registraci odmítl. Zkus push vypnout a znovu zapnout.';
|
||||
|
||||
@override
|
||||
String get pushNoDistributorTitle => 'Žádný distributor';
|
||||
|
||||
@@ -1048,6 +1068,13 @@ class AppLocalizationsCs extends AppLocalizations {
|
||||
@override
|
||||
String get qrShareTitle => 'Můj Šepot ID';
|
||||
|
||||
@override
|
||||
String get qrShareConnectingTitle => 'Připojuji k relayi…';
|
||||
|
||||
@override
|
||||
String get qrShareConnectingBody =>
|
||||
'Až se Šepot poprvé připojí, objeví se tvůj QR kód a odkaz. Chvilku strpení — stačí pár sekund.';
|
||||
|
||||
@override
|
||||
String get qrIdentityNotFound => 'Identita nebyla nalezena';
|
||||
|
||||
|
||||
@@ -277,6 +277,14 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
String get errorRelayUnavailable =>
|
||||
'Relay nicht erreichbar (Sender-Zertifikat)';
|
||||
|
||||
@override
|
||||
String get errorNotYetRegistered =>
|
||||
'Šepot verbindet sich noch mit dem Relay — versuch es gleich noch einmal.';
|
||||
|
||||
@override
|
||||
String get errorContactLinkIncomplete =>
|
||||
'Dieser Link ist unvollständig — die andere Person hat ihn erstellt, bevor sie verbunden war. Bitte sie um einen neuen sepot://-Link.';
|
||||
|
||||
@override
|
||||
String errorAttachmentTooLarge(int maxMb) {
|
||||
return 'Anhang ist zu groß (max. $maxMb MB)';
|
||||
@@ -972,6 +980,18 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
return 'Einrichtung der Push-Benachrichtigungen fehlgeschlagen: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String get pushHintOff =>
|
||||
'Nachrichten kommen erst an, wenn du Šepot öffnest — keine Weckrufe eingerichtet.';
|
||||
|
||||
@override
|
||||
String get pushHintFailed =>
|
||||
'Weckrufe funktionieren nicht; Nachrichten siehst du erst beim Öffnen von Šepot.';
|
||||
|
||||
@override
|
||||
String get pushRegistrationFailed =>
|
||||
'Es kommen keine Weckrufe an — der Distributor hat die Registrierung abgelehnt. Schalte Push aus und wieder ein.';
|
||||
|
||||
@override
|
||||
String get pushNoDistributorTitle => 'Kein Distributor';
|
||||
|
||||
@@ -1067,6 +1087,13 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
@override
|
||||
String get qrShareTitle => 'Meine Šepot-ID';
|
||||
|
||||
@override
|
||||
String get qrShareConnectingTitle => 'Verbinde mit dem Relay…';
|
||||
|
||||
@override
|
||||
String get qrShareConnectingBody =>
|
||||
'Dein QR-Code und Link erscheinen, sobald sich Šepot zum ersten Mal verbindet. Einen Moment — es dauert nur ein paar Sekunden.';
|
||||
|
||||
@override
|
||||
String get qrIdentityNotFound => 'Identität nicht gefunden';
|
||||
|
||||
|
||||
@@ -274,6 +274,14 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get errorRelayUnavailable => 'Relay unavailable (sender certificate)';
|
||||
|
||||
@override
|
||||
String get errorNotYetRegistered =>
|
||||
'Šepot is still connecting to the relay — try sending again in a moment.';
|
||||
|
||||
@override
|
||||
String get errorContactLinkIncomplete =>
|
||||
'This link is incomplete — the other person created it before connecting. Ask them for a new sepot:// link.';
|
||||
|
||||
@override
|
||||
String errorAttachmentTooLarge(int maxMb) {
|
||||
return 'Attachment is too large (max $maxMb MB)';
|
||||
@@ -951,6 +959,18 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
return 'Setting up push failed: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String get pushHintOff =>
|
||||
'Messages arrive only when you open Šepot — wake-ups aren’t set up.';
|
||||
|
||||
@override
|
||||
String get pushHintFailed =>
|
||||
'Wake-ups aren’t working; you’ll see messages only after opening Šepot.';
|
||||
|
||||
@override
|
||||
String get pushRegistrationFailed =>
|
||||
'Wake-ups aren’t arriving — the distributor refused registration. Try turning push off and on again.';
|
||||
|
||||
@override
|
||||
String get pushNoDistributorTitle => 'No distributor';
|
||||
|
||||
@@ -1044,6 +1064,13 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get qrShareTitle => 'My Šepot ID';
|
||||
|
||||
@override
|
||||
String get qrShareConnectingTitle => 'Connecting to the relay…';
|
||||
|
||||
@override
|
||||
String get qrShareConnectingBody =>
|
||||
'Your QR code and link will appear once Šepot connects for the first time. Just a moment — it only takes a few seconds.';
|
||||
|
||||
@override
|
||||
String get qrIdentityNotFound => 'Identity not found';
|
||||
|
||||
|
||||
@@ -276,6 +276,14 @@ class AppLocalizationsIt extends AppLocalizations {
|
||||
String get errorRelayUnavailable =>
|
||||
'Relay non disponibile (sender certificate)';
|
||||
|
||||
@override
|
||||
String get errorNotYetRegistered =>
|
||||
'Šepot si sta ancora connettendo al relay — riprova a inviare tra un istante.';
|
||||
|
||||
@override
|
||||
String get errorContactLinkIncomplete =>
|
||||
'Questo link è incompleto — l\'altra persona l\'ha creato prima di connettersi. Chiedile un nuovo link sepot://.';
|
||||
|
||||
@override
|
||||
String errorAttachmentTooLarge(int maxMb) {
|
||||
return 'L\'allegato è troppo grande (max $maxMb MB)';
|
||||
@@ -961,6 +969,18 @@ class AppLocalizationsIt extends AppLocalizations {
|
||||
return 'Configurazione delle notifiche push non riuscita: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String get pushHintOff =>
|
||||
'I messaggi arrivano solo quando apri Šepot — le notifiche non sono configurate.';
|
||||
|
||||
@override
|
||||
String get pushHintFailed =>
|
||||
'Le notifiche non funzionano: vedrai i messaggi solo aprendo Šepot.';
|
||||
|
||||
@override
|
||||
String get pushRegistrationFailed =>
|
||||
'Le notifiche non arrivano — il distributore ha rifiutato la registrazione. Prova a disattivare e riattivare il push.';
|
||||
|
||||
@override
|
||||
String get pushNoDistributorTitle => 'Nessun distributore';
|
||||
|
||||
@@ -1055,6 +1075,13 @@ class AppLocalizationsIt extends AppLocalizations {
|
||||
@override
|
||||
String get qrShareTitle => 'Il mio Šepot ID';
|
||||
|
||||
@override
|
||||
String get qrShareConnectingTitle => 'Connessione al relay…';
|
||||
|
||||
@override
|
||||
String get qrShareConnectingBody =>
|
||||
'Il tuo codice QR e il link appariranno appena Šepot si connette per la prima volta. Un istante — bastano pochi secondi.';
|
||||
|
||||
@override
|
||||
String get qrIdentityNotFound => 'Identità non trovata';
|
||||
|
||||
|
||||
@@ -276,6 +276,14 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
@override
|
||||
String get errorRelayUnavailable => 'Релей недоступен (sender certificate)';
|
||||
|
||||
@override
|
||||
String get errorNotYetRegistered =>
|
||||
'Šepot ещё подключается к релею — попробуйте отправить сообщение через пару секунд.';
|
||||
|
||||
@override
|
||||
String get errorContactLinkIncomplete =>
|
||||
'Ссылка неполная — собеседник создал её до подключения. Попросите у него новую ссылку sepot://.';
|
||||
|
||||
@override
|
||||
String errorAttachmentTooLarge(int maxMb) {
|
||||
return 'Вложение слишком большое (макс. $maxMb МБ)';
|
||||
@@ -968,6 +976,18 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
return 'Не удалось настроить push: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String get pushHintOff =>
|
||||
'Сообщения придут, только когда откроешь Šepot — сигналы не настроены.';
|
||||
|
||||
@override
|
||||
String get pushHintFailed =>
|
||||
'Сигналы не работают: сообщения увидишь только после открытия Šepot.';
|
||||
|
||||
@override
|
||||
String get pushRegistrationFailed =>
|
||||
'Сигналы не приходят — дистрибьютор отклонил регистрацию. Попробуй выключить и снова включить push.';
|
||||
|
||||
@override
|
||||
String get pushNoDistributorTitle => 'Нет дистрибьютора';
|
||||
|
||||
@@ -1072,6 +1092,13 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
@override
|
||||
String get qrShareTitle => 'Мой Šepot ID';
|
||||
|
||||
@override
|
||||
String get qrShareConnectingTitle => 'Подключение к релею…';
|
||||
|
||||
@override
|
||||
String get qrShareConnectingBody =>
|
||||
'Ваш QR-код и ссылка появятся, как только Šepot подключится в первый раз. Подождите пару секунд.';
|
||||
|
||||
@override
|
||||
String get qrIdentityNotFound => 'Идентичность не найдена';
|
||||
|
||||
|
||||
@@ -39,10 +39,16 @@ class Conversation {
|
||||
.length;
|
||||
}
|
||||
|
||||
/// Přidej zprávu
|
||||
void addMessage(Message message) {
|
||||
/// Přidej zprávu. Vrací false, když už zpráva s tímhle id v konverzaci je.
|
||||
///
|
||||
/// Id zprávy nese odesílatel (pole `id` na drátě), takže opakované doručení
|
||||
/// téhož obsahu — retry odesílatele po výpadku spojení nebo redelivery
|
||||
/// z fronty relaye — nesmí založit druhou bublinu.
|
||||
bool addMessage(Message message) {
|
||||
if (messages.any((m) => m.id == message.id)) return false;
|
||||
messages.add(message);
|
||||
lastActivity = DateTime.now();
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Nahraď zprávu se stejným id (změna stavu přílohy, doručenka…).
|
||||
|
||||
@@ -17,6 +17,15 @@ class ContactsNotifier extends AsyncNotifier<List<Contact>> {
|
||||
final storage = ref.read(storageProvider);
|
||||
final contacts = await future;
|
||||
|
||||
// Odkaz bez routingu (aid+da) = protistrana ho vytvořila dřív, než měla
|
||||
// hotovou registraci. Přidat takový kontakt = časovaná bomba: první
|
||||
// odeslání spadne na „nemá doručovací adresu". Odmítnout hned s
|
||||
// jasnou hláškou. (Nullable routing má smysl jen na interních příjmových
|
||||
// cestách — roster/_findOrCreateContact —, ne při ručním importu.)
|
||||
if (identity.accountId == null || identity.deliveryAddress == null) {
|
||||
throw const ContactLinkIncompleteError();
|
||||
}
|
||||
|
||||
// Kontrola duplicity
|
||||
if (contacts.any((c) => c.publicIdentity.id == identity.id)) {
|
||||
throw const ContactAlreadyExistsError();
|
||||
|
||||
@@ -23,6 +23,14 @@ final messagingStartProvider = FutureProvider<void>((ref) async {
|
||||
final identity = await ref.watch(identityProvider.future);
|
||||
if (identity == null) return;
|
||||
|
||||
// Po auto-locku se tenhle provider eager-rebuildne (drží ho pushInitProvider)
|
||||
// ještě NAD zamčeným storage → service.start() by spadl na „Storage is
|
||||
// locked" a provider by uvázl v AsyncError (žádný příjem/odeslání až do
|
||||
// killu procesu). Nad zamčeným storage tiše nic nestartuj; po odemčení
|
||||
// se provider invaliduje (viz unlock cesty) a spustí start znovu.
|
||||
final storage = ref.read(storageProvider);
|
||||
if (!storage.isUnlocked) return;
|
||||
|
||||
final service = ref.read(messagingServiceProvider);
|
||||
if (service.isStarted) return;
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:sepot/core/push/push_service.dart';
|
||||
import 'package:sepot/providers/messaging_provider.dart';
|
||||
import 'package:sepot/providers/storage_provider.dart';
|
||||
|
||||
/// Push vrstva nad messagingem.
|
||||
final pushServiceProvider = Provider<PushService>((ref) {
|
||||
@@ -18,3 +19,31 @@ final pushInitProvider = FutureProvider<void>((ref) async {
|
||||
// re-registruje u relaye po každém startu.
|
||||
await push.restoreMode();
|
||||
});
|
||||
|
||||
/// Stav budíčků pro chat list. Bez funkčního pushe appka na pozadí spí a
|
||||
/// zprávy dorazí, až ji uživatel otevře — o tom má vědět dřív, než mu
|
||||
/// někdo vyčte, že neodpovídá.
|
||||
enum PushHealth {
|
||||
/// Budíčky nastavené a bez chyby.
|
||||
ok,
|
||||
|
||||
/// Push vůbec nezapnutý (výchozí stav po instalaci).
|
||||
off,
|
||||
|
||||
/// Distributor registraci odmítl — zapnuto, ale nefunguje.
|
||||
failed,
|
||||
}
|
||||
|
||||
final pushHealthProvider = FutureProvider<PushHealth>((ref) async {
|
||||
await ref.watch(pushInitProvider.future);
|
||||
final push = ref.read(pushServiceProvider);
|
||||
if (await push.registrationFailed()) return PushHealth.failed;
|
||||
return await push.currentMode() == 'off' ? PushHealth.off : PushHealth.ok;
|
||||
});
|
||||
|
||||
/// Skryl si uživatel upozornění na chat listu? Rozhodnutí se respektuje —
|
||||
/// připomínat donekonečna by bylo otravné.
|
||||
final pushHintDismissedProvider = FutureProvider<bool>((ref) async {
|
||||
final storage = ref.watch(storageProvider);
|
||||
return await storage.getSetting<bool>('push_hint_dismissed') ?? false;
|
||||
});
|
||||
|
||||
@@ -10,9 +10,7 @@
|
||||
#include <file_selector_linux/file_selector_plugin.h>
|
||||
#include <flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h>
|
||||
#include <record_linux/record_linux_plugin.h>
|
||||
#include <screen_retriever_linux/screen_retriever_linux_plugin.h>
|
||||
#include <url_launcher_linux/url_launcher_plugin.h>
|
||||
#include <window_manager/window_manager_plugin.h>
|
||||
|
||||
void fl_register_plugins(FlPluginRegistry* registry) {
|
||||
g_autoptr(FlPluginRegistrar) audioplayers_linux_registrar =
|
||||
@@ -27,13 +25,7 @@ void fl_register_plugins(FlPluginRegistry* registry) {
|
||||
g_autoptr(FlPluginRegistrar) record_linux_registrar =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "RecordLinuxPlugin");
|
||||
record_linux_plugin_register_with_registrar(record_linux_registrar);
|
||||
g_autoptr(FlPluginRegistrar) screen_retriever_linux_registrar =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "ScreenRetrieverLinuxPlugin");
|
||||
screen_retriever_linux_plugin_register_with_registrar(screen_retriever_linux_registrar);
|
||||
g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
|
||||
url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);
|
||||
g_autoptr(FlPluginRegistrar) window_manager_registrar =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "WindowManagerPlugin");
|
||||
window_manager_plugin_register_with_registrar(window_manager_registrar);
|
||||
}
|
||||
|
||||
@@ -7,9 +7,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
|
||||
file_selector_linux
|
||||
flutter_secure_storage_linux
|
||||
record_linux
|
||||
screen_retriever_linux
|
||||
url_launcher_linux
|
||||
window_manager
|
||||
)
|
||||
|
||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||
|
||||
+6
-102
@@ -193,14 +193,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "8.12.3"
|
||||
change_case:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: change_case
|
||||
sha256: e41ef3df58521194ef8d7649928954805aeb08061917cf658322305e61568003
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
characters:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -924,14 +916,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.17.6"
|
||||
native_toolchain_cmake:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: native_toolchain_cmake
|
||||
sha256: "9960e1e926ec210a3ba6fe61720d48e1dd6cbab00d05890a6e147ee45b7e47c5"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.2.6"
|
||||
objective_c:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1196,46 +1180,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.5.2"
|
||||
screen_retriever:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: screen_retriever
|
||||
sha256: "42cc3b402a0f67d2455a0d067553d0f13453f6a008d98eababf8b63958d506bd"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.2.1"
|
||||
screen_retriever_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: screen_retriever_linux
|
||||
sha256: "2a476f1a5538065bc5badf376cfdc83d6ecf07d77eb2391b9c2bff5a76970048"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.2.1"
|
||||
screen_retriever_macos:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: screen_retriever_macos
|
||||
sha256: b5abb900fcb86614ff10b738b34e37b9e1d03b0447280668e2bc8a98bdc7bd59
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.2.1"
|
||||
screen_retriever_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: screen_retriever_platform_interface
|
||||
sha256: "3af22d926bedf20c2caa308eea376776451a3af125919ce072e56525fded8901"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.2.1"
|
||||
screen_retriever_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: screen_retriever_windows
|
||||
sha256: c44b38a4c4bab34af259180a70a4eee1e29384e7b82e627c9faa68afcdab2e73
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.2.1"
|
||||
share_plus:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -1413,42 +1357,26 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: unifiedpush
|
||||
sha256: "8ed9767f750a1dc6159a77e2171641d0cb825dc87682d1ce1b8618689b79f58e"
|
||||
sha256: b52a0eff848d0446b9d521e0ad2f7bc76c4dda88d0f6ac0a79f1765ccfe37939
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.2.0"
|
||||
version: "6.1.0"
|
||||
unifiedpush_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: unifiedpush_android
|
||||
sha256: "2e6684e0a1a39ad8d6c7bc0d197954d50686acb1b1a7614b18ab0e0126f5492a"
|
||||
sha256: "2af6a09a5fb9fdd97b61e704a587e88abcdb5f7ee2aea9a2c35e80159f9d586d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.4.1"
|
||||
unifiedpush_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: unifiedpush_linux
|
||||
sha256: c062d5eedd1cec70bcd33270cc4e01ae0ff6501f33d471167c06b34a968adfeb
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.0"
|
||||
version: "3.3.1"
|
||||
unifiedpush_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: unifiedpush_platform_interface
|
||||
sha256: "83372bc8d794b8b12ef6993b518d7be907dcfc2191bdf6de0ece5c4445d89880"
|
||||
sha256: "5aa3eefc2699e4d8db6d723f0cfdfb56ac850487549a0b3b83574f4a89f33aab"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.0.0"
|
||||
unifiedpush_storage_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: unifiedpush_storage_interface
|
||||
sha256: b8d423a4695efc616aa21d8ab48fb5ef99d6288c68b56282b8faac1579ceabd9
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.0"
|
||||
version: "3.1.0"
|
||||
universal_io:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1585,14 +1513,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.3"
|
||||
webcrypto:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: webcrypto
|
||||
sha256: "9885ed99a846191542dd6ca820c83a098117df56be57453832233a3fef68f3f2"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.6.1"
|
||||
webdriver:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1601,14 +1521,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.0"
|
||||
webpush_encryption:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: webpush_encryption
|
||||
sha256: "5b83272b91acda6ae515fcd980c94f06bf413702282497c5a68f5dfc64fed27f"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.1"
|
||||
win32:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1617,14 +1529,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.15.0"
|
||||
window_manager:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: window_manager
|
||||
sha256: "7eb6d6c4164ec08e1bf978d6e733f3cebe792e2a23fb07cbca25c2872bfdbdcd"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.5.1"
|
||||
xdg_directories:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
+5
-2
@@ -1,7 +1,7 @@
|
||||
name: sepot
|
||||
description: "Šepot — Soukromý messenger s E2E šifrováním"
|
||||
publish_to: 'none'
|
||||
version: 1.1.0+20
|
||||
version: 1.1.2+22
|
||||
|
||||
environment:
|
||||
sdk: ^3.10.8
|
||||
@@ -37,7 +37,10 @@ dependencies:
|
||||
pointycastle: ^3.9.1
|
||||
protobuf: ^6.0.0
|
||||
sqlite3: ^3.3.4
|
||||
unifiedpush: ^6.2.0
|
||||
# Pin pod 6.2.0: od 6.2.0 táhne unifiedpush_linux → webpush_encryption →
|
||||
# webcrypto, jehož native framework na iOS chybí LC_ENCRYPTION_INFO (blokuje
|
||||
# upload). Linux desktop neshipujeme, na Android/iOS je 6.1.x funkčně shodná.
|
||||
unifiedpush: ">=6.1.0 <6.2.0"
|
||||
image: ^4.8.0
|
||||
mime: ^2.0.0
|
||||
image_picker: ^1.2.3
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:sepot/models/conversation.dart';
|
||||
import 'package:sepot/models/message.dart';
|
||||
|
||||
/// Id zprávy nese odesílatel, takže tatáž zpráva může dorazit dvakrát:
|
||||
/// odesílatel ji po výpadku spojení pošle znovu (fronta odchozích) nebo ji
|
||||
/// relay doručí opakovaně, než dorazí ACK. Ani v jednom případě nesmí
|
||||
/// vzniknout druhá bublina.
|
||||
void main() {
|
||||
Message zprava(String id, {String text = 'ahoj'}) => Message(
|
||||
id: id,
|
||||
senderId: 'alice',
|
||||
recipientId: 'bob',
|
||||
type: MessageType.text,
|
||||
content: text,
|
||||
timestamp: DateTime.parse('2026-08-13T09:00:00.000'),
|
||||
);
|
||||
|
||||
test('zpráva se stejným id se podruhé nepřidá', () {
|
||||
final c = Conversation(contactId: 'bob');
|
||||
|
||||
expect(c.addMessage(zprava('m1')), isTrue, reason: 'první doručení');
|
||||
expect(c.addMessage(zprava('m1')), isFalse, reason: 'opakované doručení');
|
||||
expect(c.messages.length, 1);
|
||||
});
|
||||
|
||||
test('dedup se řídí id, ne obsahem — stejný text jinou zprávou projde', () {
|
||||
final c = Conversation(contactId: 'bob');
|
||||
|
||||
expect(c.addMessage(zprava('m1', text: 'ahoj')), isTrue);
|
||||
expect(c.addMessage(zprava('m2', text: 'ahoj')), isTrue);
|
||||
expect(c.messages.length, 2);
|
||||
});
|
||||
|
||||
test('duplicita nechá původní zprávu být', () {
|
||||
final c = Conversation(contactId: 'bob');
|
||||
c.addMessage(zprava('m1', text: 'původní'));
|
||||
|
||||
c.addMessage(zprava('m1', text: 'přepsaný'));
|
||||
|
||||
expect(c.messages.single.content, 'původní');
|
||||
});
|
||||
}
|
||||
@@ -133,5 +133,28 @@ void main() {
|
||||
expect(PublicIdentity.fromShareString('sepot://add/%%%'), isNull);
|
||||
expect(PublicIdentity.fromShareString(''), isNull);
|
||||
});
|
||||
|
||||
test('share link s routingem: aid+da přežijí roundtrip', () async {
|
||||
final base = await LocalIdentity.create('Routovaná');
|
||||
final withRouting = base.publicIdentity.withRouting(
|
||||
accountId: 'deadbeef',
|
||||
deliveryAddress: Uint8List.fromList(List.generate(32, (i) => i)),
|
||||
);
|
||||
final parsed =
|
||||
PublicIdentity.fromShareString(withRouting.toShareString());
|
||||
expect(parsed, isNotNull);
|
||||
expect(parsed!.accountId, 'deadbeef');
|
||||
expect(parsed.deliveryAddress,
|
||||
Uint8List.fromList(List.generate(32, (i) => i)));
|
||||
});
|
||||
|
||||
test('fromShareString: da špatné délky → null (ne vadný kontakt)', () async {
|
||||
final base = await LocalIdentity.create('Krátká');
|
||||
final bad = base.publicIdentity.withRouting(
|
||||
accountId: 'deadbeef',
|
||||
deliveryAddress: Uint8List.fromList(List.generate(8, (i) => i)),
|
||||
);
|
||||
expect(PublicIdentity.fromShareString(bad.toShareString()), isNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:sepot/features/chat_list/push_hint_banner.dart';
|
||||
import 'package:sepot/l10n/generated/app_localizations.dart';
|
||||
import 'package:sepot/providers/push_provider.dart';
|
||||
|
||||
/// Proužek říká uživateli, proč mu zprávy chodí až po otevření appky.
|
||||
/// Musí se ukázat jen tehdy, když budíčky opravdu nefungují — jinak by
|
||||
/// z informace o stavu byla otravná reklama.
|
||||
Widget _app({required PushHealth health, required bool dismissed}) =>
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
pushHealthProvider.overrideWith((ref) async => health),
|
||||
pushHintDismissedProvider.overrideWith((ref) async => dismissed),
|
||||
],
|
||||
child: const MaterialApp(
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
locale: Locale('cs'),
|
||||
home: Scaffold(body: PushHintBanner()),
|
||||
),
|
||||
);
|
||||
|
||||
void main() {
|
||||
testWidgets('push vypnutý → proužek se ukáže', (tester) async {
|
||||
await tester.pumpWidget(_app(health: PushHealth.off, dismissed: false));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byIcon(Icons.notifications_off_outlined), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('registrace selhala → proužek se ukáže', (tester) async {
|
||||
await tester.pumpWidget(_app(health: PushHealth.failed, dismissed: false));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byIcon(Icons.notifications_off_outlined), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('budíčky fungují → proužek mlčí', (tester) async {
|
||||
await tester.pumpWidget(_app(health: PushHealth.ok, dismissed: false));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byIcon(Icons.notifications_off_outlined), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('odmítnutý proužek se znovu neukáže', (tester) async {
|
||||
await tester.pumpWidget(_app(health: PushHealth.off, dismissed: true));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byIcon(Icons.notifications_off_outlined), findsNothing);
|
||||
});
|
||||
}
|
||||
@@ -12,10 +12,8 @@
|
||||
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
|
||||
#include <local_auth_windows/local_auth_plugin.h>
|
||||
#include <record_windows/record_windows_plugin_c_api.h>
|
||||
#include <screen_retriever_windows/screen_retriever_windows_plugin_c_api.h>
|
||||
#include <share_plus/share_plus_windows_plugin_c_api.h>
|
||||
#include <url_launcher_windows/url_launcher_windows.h>
|
||||
#include <window_manager/window_manager_plugin.h>
|
||||
|
||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||
AudioplayersWindowsPluginRegisterWithRegistrar(
|
||||
@@ -30,12 +28,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||
registry->GetRegistrarForPlugin("LocalAuthPlugin"));
|
||||
RecordWindowsPluginCApiRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("RecordWindowsPluginCApi"));
|
||||
ScreenRetrieverWindowsPluginCApiRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("ScreenRetrieverWindowsPluginCApi"));
|
||||
SharePlusWindowsPluginCApiRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("SharePlusWindowsPluginCApi"));
|
||||
UrlLauncherWindowsRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
|
||||
WindowManagerPluginRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("WindowManagerPlugin"));
|
||||
}
|
||||
|
||||
@@ -9,10 +9,8 @@ list(APPEND FLUTTER_PLUGIN_LIST
|
||||
flutter_secure_storage_windows
|
||||
local_auth_windows
|
||||
record_windows
|
||||
screen_retriever_windows
|
||||
share_plus
|
||||
url_launcher_windows
|
||||
window_manager
|
||||
)
|
||||
|
||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||
|
||||
@@ -22,6 +22,13 @@ services:
|
||||
DATABASE_URL: postgres://sepot:${POSTGRES_PASSWORD}@db:5432/sepot_relay
|
||||
SEPOT_BIND: 0.0.0.0:8470
|
||||
SEPOT_ATTACHMENTS_DIR: /var/lib/sepot/attachments
|
||||
# Sítě reverzní proxy, které smí určovat klientskou IP. Forwarded
|
||||
# hlavičky (X-Forwarded-For/X-Real-Ip) se uznají JEN od IP v této sadě,
|
||||
# jinak platí adresa protistrany — jinak by si anti-abuse klíč mohl
|
||||
# nastavit sám klient. Musí pokrýt síť, ze které proxy na relay doráží.
|
||||
# Síť Toru sem NEPATŘÍ: onion klienti mají sdílet jeden bucket.
|
||||
# Prázdné = fail-safe, vždy adresa protistrany.
|
||||
SEPOT_TRUSTED_PROXIES: ${SEPOT_TRUSTED_PROXIES:-}
|
||||
# FCM fast mode (opt-in budíčky přes Google) — SA key ŽIJE JEN NA
|
||||
# serveru (fcm-sa.json vedle .env, chmod 600, NIKDY do gitu)
|
||||
SEPOT_FCM_CREDENTIALS: /etc/sepot/fcm-sa.json
|
||||
|
||||
Generated
+1
@@ -2456,6 +2456,7 @@ dependencies = [
|
||||
"dashmap",
|
||||
"futures-util",
|
||||
"hex",
|
||||
"ipnet",
|
||||
"jsonwebtoken",
|
||||
"libsignal-core",
|
||||
"libsignal-protocol",
|
||||
|
||||
@@ -22,6 +22,8 @@ sqlx = { version = "0.8", default-features = false, features = [
|
||||
"macros",
|
||||
] }
|
||||
dashmap = "6"
|
||||
# CIDR match trusted proxy sítí pro anti-abuse klíč (viz net.rs)
|
||||
ipnet = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
base64 = "0.22"
|
||||
|
||||
@@ -123,11 +123,13 @@ impl ApnsSender {
|
||||
.body(r#"{"aps":{"content-available":1}}"#)
|
||||
.send()
|
||||
.await;
|
||||
// warn, ne debug — viz shodný komentář ve fcm.rs: na RUST_LOG=info
|
||||
// bylo selhání pushe neviditelné.
|
||||
match resp {
|
||||
Ok(r) if !r.status().is_success() => {
|
||||
tracing::debug!(status = %r.status(), "APNs budíček odmítnut");
|
||||
tracing::warn!(status = %r.status(), "APNs budíček odmítnut");
|
||||
}
|
||||
Err(e) => tracing::debug!("APNs budíček selhal: {e}"),
|
||||
Err(e) => tracing::warn!("APNs budíček selhal: {e}"),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -302,6 +302,7 @@ mod db_tests {
|
||||
let certs = crate::certs::CertAuthority::init(&pool).await.unwrap();
|
||||
let dir = std::env::temp_dir().join(format!("sepot-att-test-{}", std::process::id()));
|
||||
std::fs::create_dir_all(dir.join("tmp")).unwrap();
|
||||
let trusted = std::sync::Arc::new(crate::net::TrustedProxies::from_env());
|
||||
AppState::new(
|
||||
pool,
|
||||
certs,
|
||||
@@ -309,6 +310,7 @@ mod db_tests {
|
||||
dir,
|
||||
max_bytes: 1024,
|
||||
},
|
||||
trusted,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -19,8 +19,18 @@ pub struct CertAuthority {
|
||||
|
||||
impl CertAuthority {
|
||||
/// Načte klíče z DB; při prvním startu je vygeneruje a uloží.
|
||||
/// Trust root private v DB je dev pohodlí kostry — v produkci se
|
||||
/// vygeneruje offline a v DB bude jen server keypair + certifikát.
|
||||
///
|
||||
/// BEZPEČNOST (SECURITY audit #2): trust root private se do DB NEUKLÁDÁ.
|
||||
/// Runtime ho nepotřebuje (podpis SenderCertificate dělá server_private),
|
||||
/// slouží jen k jednorázovému podpisu server certifikátu při bootstrapu
|
||||
/// a hned se zahodí. Kompromitace DB tak neprozradí kotvu důvěry, kterou
|
||||
/// klienti pinnují.
|
||||
///
|
||||
/// ZBYTKOVÉ RIZIKO: server_private v DB zůstat MUSÍ (runtime podepisování)
|
||||
/// a sám stačí na výrobu platného SenderCertificate → kompromitace DB
|
||||
/// stále dovolí dočasnou impersonaci. Plné řešení = server keypair do
|
||||
/// KMS/HSM, mimo scope kostry. Rotace server klíče vyžaduje offline trust
|
||||
/// root (v dev se regeneruje celá kotva → mění se i pin klientů).
|
||||
pub async fn init(pool: &PgPool) -> anyhow::Result<Self> {
|
||||
let row: Option<(Vec<u8>, Vec<u8>, Vec<u8>)> = sqlx::query_as(
|
||||
"SELECT trust_root_public, server_private, server_certificate FROM relay_keys WHERE id = 1",
|
||||
@@ -52,20 +62,21 @@ impl CertAuthority {
|
||||
.map_err(|e| anyhow::anyhow!("serialized: {e}"))?
|
||||
.to_vec();
|
||||
|
||||
// trust_root_private se ZÁMĚRNĚ neukládá (audit #2) — sloupec zůstane
|
||||
// NULL, private klíč kotvy důvěry žije jen po dobu tohoto bootstrapu.
|
||||
sqlx::query(
|
||||
"INSERT INTO relay_keys
|
||||
(id, trust_root_public, trust_root_private, server_public, server_private, server_certificate)
|
||||
VALUES (1, $1, $2, $3, $4, $5)",
|
||||
(id, trust_root_public, server_public, server_private, server_certificate)
|
||||
VALUES (1, $1, $2, $3, $4)",
|
||||
)
|
||||
.bind(trust_root.public_key.serialize().to_vec())
|
||||
.bind(trust_root.private_key.serialize())
|
||||
.bind(server.public_key.serialize().to_vec())
|
||||
.bind(server.private_key.serialize())
|
||||
.bind(&server_cert_bytes)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
tracing::info!("vygenerován nový trust root + server certificate");
|
||||
tracing::info!("vygenerován nový trust root + server certificate (root private nezachován)");
|
||||
Ok(Self {
|
||||
trust_root_public: trust_root.public_key.serialize().to_vec(),
|
||||
server_private: server.private_key,
|
||||
|
||||
@@ -143,11 +143,15 @@ impl FcmSender {
|
||||
"android": { "priority": "HIGH" }
|
||||
}
|
||||
});
|
||||
// warn, ne debug: relay běží na RUST_LOG=info, takže na debugu bylo
|
||||
// selhání pushe neviditelné a tiše se hromadila fronta. Protistranou
|
||||
// je Google, ne telefon uživatele — chyba tu znamená mrtvý token nebo
|
||||
// rozbitou konfiguraci projektu, obojí je provozní problém.
|
||||
match http.post(&url).bearer_auth(access).json(&body).send().await {
|
||||
Ok(resp) if !resp.status().is_success() => {
|
||||
tracing::debug!(status = %resp.status(), "FCM budíček odmítnut");
|
||||
tracing::warn!(status = %resp.status(), "FCM budíček odmítnut");
|
||||
}
|
||||
Err(e) => tracing::debug!("FCM budíček selhal: {e}"),
|
||||
Err(e) => tracing::warn!("FCM budíček selhal: {e}"),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ mod apns;
|
||||
mod attachments;
|
||||
mod certs;
|
||||
mod fcm;
|
||||
mod net;
|
||||
mod push;
|
||||
mod rest;
|
||||
mod state;
|
||||
@@ -24,8 +25,9 @@ use axum::routing::{delete, get, post};
|
||||
use axum::Router;
|
||||
use sqlx::postgres::PgPoolOptions;
|
||||
use tower_governor::governor::GovernorConfigBuilder;
|
||||
use tower_governor::key_extractor::SmartIpKeyExtractor;
|
||||
use tower_governor::GovernorLayer;
|
||||
|
||||
use crate::net::{TrustedProxies, TrustedProxyIp};
|
||||
use tower_http::timeout::TimeoutLayer;
|
||||
use tracing_subscriber::EnvFilter;
|
||||
|
||||
@@ -34,7 +36,7 @@ const MAX_BODY_BYTES: usize = 64 * 1024;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
// Log formát bez IP adres a bez identifikátorů klientů.
|
||||
// Log formát bez IP adres a bez identifikátorů klientů — DoD sprintu.
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")),
|
||||
@@ -54,23 +56,29 @@ async fn main() -> anyhow::Result<()> {
|
||||
let certs = certs::CertAuthority::init(&pool).await?;
|
||||
let attachments_cfg = attachments::Config::from_env();
|
||||
tokio::fs::create_dir_all(attachments_cfg.dir.join("tmp")).await?;
|
||||
let state = Arc::new(state::AppState::new(pool, certs, attachments_cfg));
|
||||
// Trusted proxy sada je sdílená mezi WS conn-cap (AppState) a všemi
|
||||
// governor limity — jeden zdroj pravdy o tom, komu se věří XFF.
|
||||
let trusted = Arc::new(TrustedProxies::from_env());
|
||||
let state = Arc::new(state::AppState::new(
|
||||
pool,
|
||||
certs,
|
||||
attachments_cfg,
|
||||
trusted.clone(),
|
||||
));
|
||||
attachments::spawn_cleanup(state.clone());
|
||||
|
||||
// ── Rate limiting (základní minimum; plný PoW/Privacy Pass později) ─
|
||||
// ── Rate limiting (minimální sada; plný PoW/Privacy Pass později) ──
|
||||
//
|
||||
// Klíč = klientská IP: za traefikem X-Forwarded-For/X-Real-Ip (na
|
||||
// relay dosáhne jen traefik z docker sítě), jinak peer adresa.
|
||||
// Klíč = klientská IP přes TrustedProxyIp: X-Forwarded-For/X-Real-Ip se
|
||||
// uzná JEN od trusted proxy (traefik), jinak peer adresa. Onion klienti
|
||||
// přicházejí z tor kontejneru (mimo trusted sadu) → sdílí jeden bucket
|
||||
// a nemohou si IP nastavit hlavičkou (SECURITY audit #1).
|
||||
// In-memory token bucket — nikdy se nezapisuje na disk (privacy).
|
||||
//
|
||||
// Známý limit: Tor onion klienti přicházejí z tor kontejneru bez XFF
|
||||
// → sdílí jeden bucket. Pro v1 (nízký provoz) přijatelné, řeší až
|
||||
// Privacy Pass — limity proto raději volnější.
|
||||
|
||||
// registrace: zápis do DB bez auth → nejpřísnější (burst 10, +1/5 min)
|
||||
let gov_register = Arc::new(
|
||||
GovernorConfigBuilder::default()
|
||||
.key_extractor(SmartIpKeyExtractor)
|
||||
.key_extractor(TrustedProxyIp(trusted.clone()))
|
||||
.burst_size(10)
|
||||
.period(Duration::from_secs(300))
|
||||
.finish()
|
||||
@@ -79,7 +87,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
// čtecí API (bundle žere OTP klíče → drain ochrana): burst 60, +2/s
|
||||
let gov_general = Arc::new(
|
||||
GovernorConfigBuilder::default()
|
||||
.key_extractor(SmartIpKeyExtractor)
|
||||
.key_extractor(TrustedProxyIp(trusted.clone()))
|
||||
.burst_size(60)
|
||||
.per_second(2)
|
||||
.finish()
|
||||
@@ -89,7 +97,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
// přílohy: upload je autentizovaný, ale drahý (disk) → burst 30, +1/s
|
||||
let gov_attachments = Arc::new(
|
||||
GovernorConfigBuilder::default()
|
||||
.key_extractor(SmartIpKeyExtractor)
|
||||
.key_extractor(TrustedProxyIp(trusted.clone()))
|
||||
.burst_size(30)
|
||||
.per_second(1)
|
||||
.finish()
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
//! Určení klientské IP za reverse proxy — anti-abuse klíč.
|
||||
//!
|
||||
//! BEZPEČNOST (SECURITY audit #1): forwarded hlavičky (X-Forwarded-For,
|
||||
//! X-Real-Ip) smí nastavit kdokoli. Relay je dosažitelný dvěma cestami:
|
||||
//! clearnet přes traefik (síť `pangolin`), kde traefik klientské X-hlavičky
|
||||
//! přepisuje a jsou tedy důvěryhodné; a Tor onion přes tor kontejner (síť
|
||||
//! `tornet`) PŘÍMO na :8470, mimo traefik — tor forwarduje HTTP beze změny,
|
||||
//! takže hlavičky si nastaví sám klient.
|
||||
//!
|
||||
//! Kdybychom hlavičkám věřili vždy, onion klient by si zvolil libovolnou IP
|
||||
//! a obešel VŠECHNY rate-limity i WS conn-cap (registrace, fetch_bundle,
|
||||
//! upload, počet spojení). Proto: hlavičku uznej JEN když peer (skutečná
|
||||
//! TCP adresa spojení) padne do explicitně nakonfigurované sady trusted
|
||||
//! proxy. Onion peer (tor kontejner) tam není → použije se jeho IP a všichni
|
||||
//! onion klienti sdílí jeden bucket (přesně jak počítal původní komentář).
|
||||
|
||||
use std::net::{IpAddr, SocketAddr};
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::ConnectInfo;
|
||||
use axum::http::{HeaderMap, Request};
|
||||
use ipnet::IpNet;
|
||||
use tower_governor::key_extractor::KeyExtractor;
|
||||
use tower_governor::GovernorError;
|
||||
|
||||
/// Sada sítí, jejichž forwarded hlavičkám se věří (traefik/pangolin).
|
||||
/// Konfiguruje se přes `SEPOT_TRUSTED_PROXIES` (čárkou oddělené IP nebo CIDR).
|
||||
pub struct TrustedProxies {
|
||||
nets: Vec<IpNet>,
|
||||
}
|
||||
|
||||
impl TrustedProxies {
|
||||
/// Načte z env `SEPOT_TRUSTED_PROXIES`. Prázdné/nenastavené = nevěří se
|
||||
/// nikomu (vždy peer IP) — fail-safe: chybějící konfigurace nesmí otevřít
|
||||
/// spoofing, maximálně sloučí clearnet klienty do jednoho bucketu.
|
||||
pub fn from_env() -> Self {
|
||||
let raw = std::env::var("SEPOT_TRUSTED_PROXIES").unwrap_or_default();
|
||||
let nets: Vec<IpNet> = raw
|
||||
.split(',')
|
||||
.filter_map(|s| {
|
||||
let s = s.trim();
|
||||
if s.is_empty() {
|
||||
return None;
|
||||
}
|
||||
// "10.0.0.0/24" i holé "10.0.0.5" (bereme jako /32, resp. /128)
|
||||
s.parse::<IpNet>()
|
||||
.ok()
|
||||
.or_else(|| s.parse::<IpAddr>().ok().map(IpNet::from))
|
||||
})
|
||||
.collect();
|
||||
if nets.is_empty() {
|
||||
tracing::warn!(
|
||||
"SEPOT_TRUSTED_PROXIES prázdné — forwarded hlavičky se ignorují, \
|
||||
anti-abuse klíč = peer IP. Za reverse proxy nastav CIDR proxy sítě."
|
||||
);
|
||||
}
|
||||
Self { nets }
|
||||
}
|
||||
|
||||
fn trusts(&self, peer: IpAddr) -> bool {
|
||||
self.nets.iter().any(|n| n.contains(&peer))
|
||||
}
|
||||
|
||||
/// Klientská IP pro anti-abuse. Forwarded hlavičku uznej jen od trusted
|
||||
/// proxy; jinak (onion, dev, přímé spojení) peer IP. Pořadí X-Forwarded-For
|
||||
/// → X-Real-Ip drží konzistenci s governor extraktorem. Bere se PRVNÍ
|
||||
/// (nejlevější) záznam XFF = původní klient — platí jen díky tomu, že
|
||||
/// traefik pro netrusted klienty příchozí XFF přepisuje (nedopisuje).
|
||||
pub fn client_ip(&self, peer: IpAddr, headers: &HeaderMap) -> IpAddr {
|
||||
if !self.trusts(peer) {
|
||||
return peer;
|
||||
}
|
||||
let from_header = |name: &str| {
|
||||
headers
|
||||
.get(name)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| v.split(',').next())
|
||||
.and_then(|v| v.trim().parse::<IpAddr>().ok())
|
||||
};
|
||||
from_header("x-forwarded-for")
|
||||
.or_else(|| from_header("x-real-ip"))
|
||||
.unwrap_or(peer)
|
||||
}
|
||||
}
|
||||
|
||||
/// Governor key extractor s ověřením trusted proxy. Nahrazuje
|
||||
/// `SmartIpKeyExtractor`, který forwarded hlavičkám věří bezpodmínečně.
|
||||
#[derive(Clone)]
|
||||
pub struct TrustedProxyIp(pub Arc<TrustedProxies>);
|
||||
|
||||
impl KeyExtractor for TrustedProxyIp {
|
||||
type Key = IpAddr;
|
||||
|
||||
fn extract<T>(&self, req: &Request<T>) -> Result<Self::Key, GovernorError> {
|
||||
let peer = req
|
||||
.extensions()
|
||||
.get::<ConnectInfo<SocketAddr>>()
|
||||
.map(|ci| ci.0.ip())
|
||||
.ok_or(GovernorError::UnableToExtractKey)?;
|
||||
Ok(self.0.client_ip(peer, req.headers()))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use axum::http::header::{HeaderName, HeaderValue};
|
||||
use axum::http::HeaderMap;
|
||||
|
||||
fn proxies(cfg: &[&str]) -> TrustedProxies {
|
||||
let nets = cfg
|
||||
.iter()
|
||||
.filter_map(|s| {
|
||||
s.parse::<IpNet>()
|
||||
.ok()
|
||||
.or_else(|| s.parse::<IpAddr>().ok().map(IpNet::from))
|
||||
})
|
||||
.collect();
|
||||
TrustedProxies { nets }
|
||||
}
|
||||
|
||||
fn ip(s: &str) -> IpAddr {
|
||||
s.parse().unwrap()
|
||||
}
|
||||
|
||||
fn headers(pairs: &[(&str, &str)]) -> HeaderMap {
|
||||
let mut h = HeaderMap::new();
|
||||
for (k, v) in pairs {
|
||||
h.insert(
|
||||
HeaderName::from_bytes(k.as_bytes()).unwrap(),
|
||||
HeaderValue::from_str(v).unwrap(),
|
||||
);
|
||||
}
|
||||
h
|
||||
}
|
||||
|
||||
/// JÁDRO auditu #1: onion peer (mimo trusted sadu) si NESMÍ nastavit IP
|
||||
/// hlavičkou — anti-abuse klíč = peer IP tor kontejneru, ne spoof.
|
||||
#[test]
|
||||
fn onion_peer_nesmi_spoofnout_ip() {
|
||||
let tp = proxies(&["10.20.0.0/16"]); // jen pangolin síť
|
||||
let onion_peer = ip("172.30.0.5"); // tor kontejner na jiné síti
|
||||
let spoof = headers(&[("x-real-ip", "1.2.3.4"), ("x-forwarded-for", "5.6.7.8")]);
|
||||
assert_eq!(tp.client_ip(onion_peer, &spoof), onion_peer);
|
||||
}
|
||||
|
||||
/// Trusted proxy (traefik) → forwarded hlavička se uzná, klíč = klient.
|
||||
#[test]
|
||||
fn trusted_proxy_uzna_forwarded() {
|
||||
let tp = proxies(&["10.20.0.0/16"]);
|
||||
let traefik = ip("10.20.0.3");
|
||||
let h = headers(&[("x-forwarded-for", "203.0.113.9")]);
|
||||
assert_eq!(tp.client_ip(traefik, &h), ip("203.0.113.9"));
|
||||
}
|
||||
|
||||
/// XFF má přednost před X-Real-Ip (konzistence s governor extraktorem);
|
||||
/// z řetězce se bere nejlevější (původní klient).
|
||||
#[test]
|
||||
fn xff_prednost_a_nejlevejsi() {
|
||||
let tp = proxies(&["10.20.0.3"]); // holá IP = /32
|
||||
let traefik = ip("10.20.0.3");
|
||||
let h = headers(&[
|
||||
("x-forwarded-for", "203.0.113.9, 10.20.0.3"),
|
||||
("x-real-ip", "198.51.100.1"),
|
||||
]);
|
||||
assert_eq!(tp.client_ip(traefik, &h), ip("203.0.113.9"));
|
||||
}
|
||||
|
||||
/// Trusted proxy bez hlaviček → fallback na peer.
|
||||
#[test]
|
||||
fn trusted_bez_hlavicek_fallback_peer() {
|
||||
let tp = proxies(&["10.20.0.0/16"]);
|
||||
let traefik = ip("10.20.0.3");
|
||||
assert_eq!(tp.client_ip(traefik, &HeaderMap::new()), traefik);
|
||||
}
|
||||
|
||||
/// Prázdná konfigurace (fail-safe) → vždy peer, hlavičky se ignorují.
|
||||
#[test]
|
||||
fn prazdna_konfigurace_vzdy_peer() {
|
||||
let tp = proxies(&[]);
|
||||
let peer = ip("10.20.0.3");
|
||||
let spoof = headers(&[("x-forwarded-for", "1.2.3.4")]);
|
||||
assert_eq!(tp.client_ip(peer, &spoof), peer);
|
||||
}
|
||||
|
||||
/// IPv6 trusted CIDR taky funguje.
|
||||
#[test]
|
||||
fn ipv6_trusted_cidr() {
|
||||
let tp = proxies(&["fd00::/8"]);
|
||||
let peer = ip("fd00::1234");
|
||||
let h = headers(&[("x-forwarded-for", "2001:db8::1")]);
|
||||
assert_eq!(tp.client_ip(peer, &h), ip("2001:db8::1"));
|
||||
}
|
||||
}
|
||||
@@ -85,13 +85,23 @@ pub async fn send_wake(
|
||||
/// Samotný POST budíčku (bez guardu — ten řeší volající). Oddělené kvůli
|
||||
/// testovatelnosti proti loopback mocku (guard by loopback jinak odmítl).
|
||||
async fn post_wake(client: &Client, url: &str) {
|
||||
let _ = client
|
||||
let resp = client
|
||||
.post(url)
|
||||
.header("content-type", "application/json")
|
||||
.header("ttl", "2419200") // WebPush TTL (28 dní), ntfy ignoruje
|
||||
.body(WAKE_BODY)
|
||||
.send()
|
||||
.await;
|
||||
// Výsledek se dřív zahazoval (`let _ =`), takže nešlo poznat, jestli
|
||||
// budíčky vůbec odcházejí. Logujeme jen stavový kód — endpoint je
|
||||
// identifikátor uživatele a do logu nesmí.
|
||||
match resp {
|
||||
Ok(r) if !r.status().is_success() => {
|
||||
tracing::warn!(status = %r.status(), "UnifiedPush budíček odmítnut");
|
||||
}
|
||||
Err(e) => tracing::warn!("UnifiedPush budíček selhal: {e}"),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Povolit jen bezpečné cíle. https vždy; http jen pro .onion (samo šifruje).
|
||||
@@ -191,7 +201,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn verejne_adresy_projdou() {
|
||||
for ip in ["1.1.1.1", "8.8.8.8", "203.0.113.10", "2606:4700:4700::1111"] {
|
||||
for ip in ["1.1.1.1", "8.8.8.8", "9.9.9.9", "2606:4700:4700::1111"] {
|
||||
assert!(!is_internal_ip(&ip.parse().unwrap()), "{ip} nemělo být interní");
|
||||
}
|
||||
}
|
||||
|
||||
+454
-19
@@ -1,4 +1,6 @@
|
||||
use chrono::{Duration, Utc};
|
||||
use std::sync::Arc;
|
||||
|
||||
use chrono::{DateTime, Duration, Utc};
|
||||
use dashmap::DashMap;
|
||||
use prost::Message;
|
||||
use sepot_proto::v1;
|
||||
@@ -7,18 +9,80 @@ use sqlx::PgPool;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
use crate::certs::CertAuthority;
|
||||
use crate::net::TrustedProxies;
|
||||
|
||||
/// TTL offline fronty.
|
||||
pub const QUEUE_TTL_DAYS: i64 = 30;
|
||||
|
||||
/// Strop celkové velikosti fronty na příjemce (SECURITY audit #3). Bez něj
|
||||
/// autentizovaný útočník nasype neomezeně obálek (256 KiB × N) a při flushi
|
||||
/// z toho vznikne OOM, na disku neomezený růst. Přetečení = FIFO zahození
|
||||
/// nejstarších. 50 MiB ≈ 200 max obálek na příjemce.
|
||||
const MAX_QUEUE_BYTES_PER_RECIPIENT: i64 = 50 * 1024 * 1024;
|
||||
|
||||
/// Velikost dávky při vylévání fronty (SECURITY audit #3). Strop paměti =
|
||||
/// FLUSH_BATCH × MAX_ENVELOPE_PAYLOAD (~16 MiB), ne celá fronta najednou.
|
||||
const FLUSH_BATCH: i64 = 64;
|
||||
|
||||
/// Po kolika marných pokusech o doručení obálku zahodit.
|
||||
///
|
||||
/// Ztracený ACK je jednosměrka: klient obálku dešifruje (ratchet postoupí),
|
||||
/// uloží a pošle ACK — když ten cestou zmizí (mrtvé spojení), relay obálku
|
||||
/// drží dál, jenže druhý pokus o dešifrování už libsignal odmítne. Klient ji
|
||||
/// tedy neACKuje a fronta se otravuje: tatáž obálka se posílá při každém
|
||||
/// připojení až do TTL 30 dní a příjemce ji nikdy nedostane. Strop tomu dá
|
||||
/// konec. Osm pokusů je velkoryse nad běžné „appka spadla před ACK".
|
||||
const MAX_DELIVERY_ATTEMPTS: i16 = 8;
|
||||
|
||||
/// Jak dlouho po posledním rámci od klienta se spojení ještě považuje za
|
||||
/// prokazatelně živé. Klient odpovídá na ping à 30 s (ws::PING_INTERVAL),
|
||||
/// takže dva zmeškané cykly znamenají, že už nedýchá.
|
||||
const FRESH_CONNECTION_MS: u64 = 70_000;
|
||||
|
||||
/// Připojené zařízení: kanál na jeho WS + kdy od něj naposled něco přišlo.
|
||||
///
|
||||
/// Samotný `tx` o životnosti spojení nic neříká — je to mpsc s bufferem, do
|
||||
/// kterého zápis projde i u dávno mrtvého socketu. Právě proto se dřív vracelo
|
||||
/// falešné DELIVERED_ONLINE a push budíček se vůbec nezkusil.
|
||||
pub struct OnlineDevice {
|
||||
pub tx: mpsc::Sender<v1::ServerFrame>,
|
||||
/// ms od startu procesu; monotónní, aby s ním neházel NTP.
|
||||
last_frame_ms: std::sync::atomic::AtomicU64,
|
||||
}
|
||||
|
||||
impl OnlineDevice {
|
||||
pub fn new(tx: mpsc::Sender<v1::ServerFrame>, now_ms: u64) -> Self {
|
||||
Self {
|
||||
tx,
|
||||
last_frame_ms: std::sync::atomic::AtomicU64::new(now_ms),
|
||||
}
|
||||
}
|
||||
|
||||
/// Přišel rámec od klienta (Pong/ACK/Envelope) — spojení dýchá.
|
||||
pub fn touch(&self, now_ms: u64) {
|
||||
self.last_frame_ms
|
||||
.store(now_ms, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
|
||||
fn is_fresh(&self, now_ms: u64) -> bool {
|
||||
let last = self.last_frame_ms.load(std::sync::atomic::Ordering::Relaxed);
|
||||
now_ms.saturating_sub(last) <= FRESH_CONNECTION_MS
|
||||
}
|
||||
}
|
||||
|
||||
pub struct AppState {
|
||||
pub pool: PgPool,
|
||||
/// delivery_address → kanál na živé WS spojení zařízení.
|
||||
pub online: DashMap<Vec<u8>, mpsc::Sender<v1::ServerFrame>>,
|
||||
/// Kdy proces nastartoval — základ monotónních značek v [`OnlineDevice`].
|
||||
pub started: std::time::Instant,
|
||||
/// delivery_address → živé WS spojení zařízení.
|
||||
pub online: DashMap<Vec<u8>, Arc<OnlineDevice>>,
|
||||
/// klientská IP → počet souběžných WS spojení (anti-flood cap).
|
||||
/// Jen v paměti, nikam se neloguje ani neukládá.
|
||||
pub ws_conns: DashMap<std::net::IpAddr, u32>,
|
||||
pub certs: CertAuthority,
|
||||
/// Trusted proxy sada pro určení klientské IP (WS conn-cap). Sdílená
|
||||
/// s governor limity — komu se věří forwarded hlavička.
|
||||
pub trusted_proxies: Arc<TrustedProxies>,
|
||||
/// HTTP klient na push budíčky (bez redirectů, krátký timeout).
|
||||
pub http: reqwest::Client,
|
||||
/// Úložiště šifrovaných příloh (adresář + strop velikosti).
|
||||
@@ -30,12 +94,19 @@ pub struct AppState {
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
pub fn new(pool: PgPool, certs: CertAuthority, attachments: crate::attachments::Config) -> Self {
|
||||
pub fn new(
|
||||
pool: PgPool,
|
||||
certs: CertAuthority,
|
||||
attachments: crate::attachments::Config,
|
||||
trusted_proxies: Arc<TrustedProxies>,
|
||||
) -> Self {
|
||||
Self {
|
||||
pool,
|
||||
started: std::time::Instant::now(),
|
||||
online: DashMap::new(),
|
||||
ws_conns: DashMap::new(),
|
||||
certs,
|
||||
trusted_proxies,
|
||||
http: crate::push::build_client(),
|
||||
attachments,
|
||||
fcm: crate::fcm::FcmSender::from_env(),
|
||||
@@ -71,20 +142,57 @@ impl AppState {
|
||||
.bind(expires)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
// FIFO strop na příjemce (audit #3). Nově vložená má nejmladší
|
||||
// enqueued_at → sama se nikdy nezahodí, padne jen starý přebytek.
|
||||
self.trim_queue(&dst, MAX_QUEUE_BYTES_PER_RECIPIENT).await?;
|
||||
|
||||
if let Some(tx) = self.online.get(&dst) {
|
||||
if let Some(dev) = self.online.get(&dst).map(|d| d.clone()) {
|
||||
let deliver = v1::ServerFrame {
|
||||
frame: Some(v1::server_frame::Frame::Deliver(env)),
|
||||
};
|
||||
if tx.send(deliver).await.is_ok() {
|
||||
let fresh = dev.is_fresh(self.now_ms());
|
||||
let queued = dev.tx.send(deliver).await.is_ok();
|
||||
// ONLINE hlásíme jen u spojení, které prokazatelně dýchá. Do
|
||||
// zatuchlého se sice pošle taky (kdyby to byl jen tichý klient),
|
||||
// ale propadne se i na push — jinak by mrtvé spojení budíček
|
||||
// spolklo a příjemce se o zprávě nedozvěděl.
|
||||
if fresh && queued {
|
||||
return Ok(v1::receipt::Status::DeliveredOnline);
|
||||
}
|
||||
}
|
||||
// Offline (nebo online kanál selhal) → zkusit push budíček.
|
||||
// Offline, zatuchlé nebo selhalý kanál → zkusit push budíček.
|
||||
self.wake(&dst).await;
|
||||
Ok(v1::receipt::Status::StoredOffline)
|
||||
}
|
||||
|
||||
/// FIFO strop fronty příjemce: nech nejnovější obálky do `cap_bytes`
|
||||
/// (běžný součet od nejnovější), starší přebytek zahoď. Vrací počet
|
||||
/// zahozených. Oddělené kvůli přímému testu bez vkládání 50 MiB.
|
||||
async fn trim_queue(&self, dst: &[u8], cap_bytes: i64) -> anyhow::Result<u64> {
|
||||
let res = sqlx::query(
|
||||
"DELETE FROM message_queue
|
||||
WHERE dst_address = $1 AND msg_uuid IN (
|
||||
SELECT msg_uuid FROM (
|
||||
SELECT msg_uuid,
|
||||
sum(octet_length(envelope)) OVER (
|
||||
ORDER BY enqueued_at DESC, msg_uuid DESC
|
||||
ROWS UNBOUNDED PRECEDING) AS running
|
||||
FROM message_queue WHERE dst_address = $1
|
||||
) s WHERE running > $2
|
||||
)",
|
||||
)
|
||||
.bind(dst)
|
||||
.bind(cap_bytes)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(res.rows_affected())
|
||||
}
|
||||
|
||||
/// Monotónní značka pro [`OnlineDevice`] — ms od startu procesu.
|
||||
pub fn now_ms(&self) -> u64 {
|
||||
self.started.elapsed().as_millis() as u64
|
||||
}
|
||||
|
||||
/// Budíček pro offline příjemce: dohledá jeho push token a pošle prázdný
|
||||
/// „ťuk". Best-effort, na pozadí — neblokuje receipt odesílateli.
|
||||
/// Endpoint se NELOGUJE.
|
||||
@@ -172,32 +280,118 @@ impl AppState {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Po AUTH: vyleje nedoručenou frontu zařízení (DELIVER stream).
|
||||
/// Po AUTH: vyleje nedoručenou frontu zařízení (DELIVER stream). Čte po
|
||||
/// dávkách keyset kurzorem přes (enqueued_at, msg_uuid) — paměť je
|
||||
/// omezená na FLUSH_BATCH obálek, ne celá fronta najednou (audit #3).
|
||||
/// Index idx_queue_dst(dst_address, enqueued_at) kurzor pokrývá.
|
||||
pub async fn flush_queue(
|
||||
&self,
|
||||
delivery_address: &[u8],
|
||||
tx: &mpsc::Sender<v1::ServerFrame>,
|
||||
) -> anyhow::Result<usize> {
|
||||
let rows: Vec<(Vec<u8>,)> = sqlx::query_as(
|
||||
"SELECT envelope FROM message_queue
|
||||
WHERE dst_address = $1 AND expires_at > now()
|
||||
ORDER BY enqueued_at",
|
||||
// Obálky, které strop pokusů vyčerpaly, jsou neprůchodné (skoro vždy
|
||||
// ztracený ACK po úspěšném dešifrování) — zahodit dřív, než se znovu
|
||||
// pošlou. Maže se až tady, ne hned po inkrementu: poslední pokus
|
||||
// dostane šanci doběhnout do ACK.
|
||||
let dropped = sqlx::query(
|
||||
"DELETE FROM message_queue WHERE dst_address = $1 AND attempt_count >= $2",
|
||||
)
|
||||
.bind(delivery_address)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
.bind(MAX_DELIVERY_ATTEMPTS)
|
||||
.execute(&self.pool)
|
||||
.await?
|
||||
.rows_affected();
|
||||
if dropped > 0 {
|
||||
// Bez identifikátoru příjemce — jen že se fronta čistila.
|
||||
tracing::info!(dropped, "nedoručitelné obálky zahozeny (strop pokusů)");
|
||||
}
|
||||
|
||||
let count = rows.len();
|
||||
for (encoded,) in rows {
|
||||
let mut total = 0usize;
|
||||
// (enqueued_at, msg_uuid) poslední odeslané obálky = kurzor další dávky.
|
||||
let mut cursor: Option<(DateTime<Utc>, Vec<u8>)> = None;
|
||||
loop {
|
||||
let rows: Vec<(Vec<u8>, DateTime<Utc>, Vec<u8>)> = match &cursor {
|
||||
None => {
|
||||
sqlx::query_as(
|
||||
"SELECT envelope, enqueued_at, msg_uuid FROM message_queue
|
||||
WHERE dst_address = $1 AND expires_at > now()
|
||||
AND attempt_count < $2
|
||||
ORDER BY enqueued_at, msg_uuid
|
||||
LIMIT $3",
|
||||
)
|
||||
.bind(delivery_address)
|
||||
.bind(MAX_DELIVERY_ATTEMPTS)
|
||||
.bind(FLUSH_BATCH)
|
||||
.fetch_all(&self.pool)
|
||||
.await?
|
||||
}
|
||||
Some((ts, uuid)) => {
|
||||
sqlx::query_as(
|
||||
"SELECT envelope, enqueued_at, msg_uuid FROM message_queue
|
||||
WHERE dst_address = $1 AND expires_at > now()
|
||||
AND attempt_count < $2
|
||||
AND (enqueued_at, msg_uuid) > ($3, $4)
|
||||
ORDER BY enqueued_at, msg_uuid
|
||||
LIMIT $5",
|
||||
)
|
||||
.bind(delivery_address)
|
||||
.bind(MAX_DELIVERY_ATTEMPTS)
|
||||
.bind(ts)
|
||||
.bind(uuid)
|
||||
.bind(FLUSH_BATCH)
|
||||
.fetch_all(&self.pool)
|
||||
.await?
|
||||
}
|
||||
};
|
||||
let batch_len = rows.len();
|
||||
if batch_len == 0 {
|
||||
break;
|
||||
}
|
||||
let last = rows
|
||||
.last()
|
||||
.map(|(_, ts, uuid)| (*ts, uuid.clone()))
|
||||
.expect("dávka není prázdná");
|
||||
let mut sent_uuids: Vec<Vec<u8>> = Vec::with_capacity(batch_len);
|
||||
for (encoded, _, uuid) in &rows {
|
||||
let env = v1::Envelope::decode(encoded.as_slice())?;
|
||||
let frame = v1::ServerFrame {
|
||||
frame: Some(v1::server_frame::Frame::Deliver(env)),
|
||||
};
|
||||
if tx.send(frame).await.is_err() {
|
||||
break;
|
||||
// Příjemce odpadl uprostřed dávky — započítat jen to, co
|
||||
// reálně odešlo, ať pokus „zdarma" nekrátí strop.
|
||||
self.bump_attempts(&sent_uuids).await;
|
||||
return Ok(total);
|
||||
}
|
||||
sent_uuids.push(uuid.clone());
|
||||
total += 1;
|
||||
}
|
||||
self.bump_attempts(&sent_uuids).await;
|
||||
if (batch_len as i64) < FLUSH_BATCH {
|
||||
break; // poslední (neúplná) dávka
|
||||
}
|
||||
cursor = Some(last);
|
||||
}
|
||||
Ok(total)
|
||||
}
|
||||
|
||||
/// Započítá pokus o doručení odeslaným obálkám. Selhání se jen zaloguje —
|
||||
/// obálky už jsou na drátě a shodit kvůli tomu flush by bylo horší než
|
||||
/// nepřesné počítadlo.
|
||||
async fn bump_attempts(&self, msg_uuids: &[Vec<u8>]) {
|
||||
if msg_uuids.is_empty() {
|
||||
return;
|
||||
}
|
||||
if let Err(e) = sqlx::query(
|
||||
"UPDATE message_queue SET attempt_count = attempt_count + 1
|
||||
WHERE msg_uuid = ANY($1)",
|
||||
)
|
||||
.bind(msg_uuids)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
{
|
||||
tracing::warn!("attempt_count se nepodařilo zvýšit: {e}");
|
||||
}
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
/// ACK příjemce → hard-delete z fronty (žádná retence po doručení).
|
||||
@@ -228,7 +422,8 @@ mod db_tests {
|
||||
.await
|
||||
.expect("dev DB");
|
||||
let certs = crate::certs::CertAuthority::init(&pool).await.unwrap();
|
||||
AppState::new(pool, certs, crate::attachments::Config::from_env())
|
||||
let trusted = Arc::new(crate::net::TrustedProxies::from_env());
|
||||
AppState::new(pool, certs, crate::attachments::Config::from_env(), trusted)
|
||||
}
|
||||
|
||||
fn rand_bytes(n: usize) -> Vec<u8> {
|
||||
@@ -413,4 +608,244 @@ mod db_tests {
|
||||
// idempotence: druhé smazání neexistujícího účtu nesmí spadnout
|
||||
state.delete_identity(&account_id).await.unwrap();
|
||||
}
|
||||
|
||||
/// Audit #3: flush přes hranici dávky (150 > FLUSH_BATCH) nesmí keyset
|
||||
/// kurzorem žádnou obálku ztratit ani zdvojit.
|
||||
#[tokio::test]
|
||||
#[ignore = "vyžaduje běžící Postgres (dev DB)"]
|
||||
async fn flush_queue_davkuje_pres_hranici_bez_ztraty() {
|
||||
let state = test_state().await;
|
||||
let dst = rand_bytes(32);
|
||||
let n = 150usize;
|
||||
let mut expected = std::collections::HashSet::new();
|
||||
for i in 0..n {
|
||||
let msg_uuid = rand_bytes(16);
|
||||
expected.insert(msg_uuid.clone());
|
||||
let env = v1::Envelope {
|
||||
dst_address: dst.clone(),
|
||||
msg_uuid: msg_uuid.clone(),
|
||||
content_hint: 0,
|
||||
payload: vec![0x11; 32],
|
||||
};
|
||||
sqlx::query(
|
||||
"INSERT INTO message_queue (msg_uuid, dst_address, envelope, enqueued_at, expires_at)
|
||||
VALUES ($1, $2, $3, now() + (interval '1 millisecond' * $4), now() + interval '30 days')",
|
||||
)
|
||||
.bind(&msg_uuid)
|
||||
.bind(&dst)
|
||||
.bind(env.encode_to_vec())
|
||||
.bind(i as i32)
|
||||
.execute(&state.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let (tx, mut rx) = mpsc::channel::<v1::ServerFrame>(256);
|
||||
let count = state.flush_queue(&dst, &tx).await.unwrap();
|
||||
drop(tx);
|
||||
assert_eq!(count, n);
|
||||
|
||||
let mut got = std::collections::HashSet::new();
|
||||
while let Some(frame) = rx.recv().await {
|
||||
if let Some(v1::server_frame::Frame::Deliver(env)) = frame.frame {
|
||||
assert!(got.insert(env.msg_uuid), "obálka doručena dvakrát");
|
||||
}
|
||||
}
|
||||
assert_eq!(got, expected, "sada doručených ≠ sada zařazených");
|
||||
|
||||
sqlx::query("DELETE FROM message_queue WHERE dst_address = $1")
|
||||
.bind(&dst)
|
||||
.execute(&state.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// Zapsat do mpsc bufferu jde i u dávno mrtvého spojení — DELIVERED_ONLINE
|
||||
/// se proto smí hlásit jen u zařízení, od kterého nedávno něco přišlo.
|
||||
/// Zatuchlé musí spadnout na offline větev, jinak spolkne push budíček.
|
||||
#[tokio::test]
|
||||
#[ignore = "vyžaduje běžící Postgres (dev DB)"]
|
||||
async fn route_envelope_zatuchle_spojeni_neni_online() {
|
||||
let mut state = test_state().await;
|
||||
// Posunout start procesu do minulosti, ať jde značka „naposled se
|
||||
// ozval na začátku" vyjádřit bez čekání reálných 70 s.
|
||||
state.started = std::time::Instant::now()
|
||||
- std::time::Duration::from_millis(FRESH_CONNECTION_MS + 10_000);
|
||||
let (_account_id, _device_uuid, delivery_address) = seed_device(&state).await;
|
||||
|
||||
// Kanál nikdo nečte, ale buffer zápis spolkne — přesně situace
|
||||
// mrtvého klienta, kterou dřív relay hlásil jako doručeno.
|
||||
let (tx, _rx) = mpsc::channel::<v1::ServerFrame>(64);
|
||||
let device = Arc::new(OnlineDevice::new(tx, 0));
|
||||
state
|
||||
.online
|
||||
.insert(delivery_address.clone(), device.clone());
|
||||
|
||||
let env = v1::Envelope {
|
||||
dst_address: delivery_address.clone(),
|
||||
msg_uuid: rand_bytes(16),
|
||||
content_hint: 0,
|
||||
payload: vec![0xCD; 64],
|
||||
};
|
||||
assert_eq!(
|
||||
state.route_envelope(env).await.unwrap(),
|
||||
v1::receipt::Status::StoredOffline,
|
||||
"zatuchlé spojení se nesmí hlásit jako doručené online"
|
||||
);
|
||||
|
||||
// A naopak: čerstvě označené zařízení online být má.
|
||||
device.touch(state.now_ms());
|
||||
let env2 = v1::Envelope {
|
||||
dst_address: delivery_address.clone(),
|
||||
msg_uuid: rand_bytes(16),
|
||||
content_hint: 0,
|
||||
payload: vec![0xCE; 64],
|
||||
};
|
||||
assert_eq!(
|
||||
state.route_envelope(env2).await.unwrap(),
|
||||
v1::receipt::Status::DeliveredOnline,
|
||||
"živé spojení má být online"
|
||||
);
|
||||
|
||||
state.online.remove(&delivery_address);
|
||||
sqlx::query("DELETE FROM message_queue WHERE dst_address = $1")
|
||||
.bind(&delivery_address)
|
||||
.execute(&state.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// Flush počítá pokusy — bez toho by strop nikdy nezabral.
|
||||
#[tokio::test]
|
||||
#[ignore = "vyžaduje běžící Postgres (dev DB)"]
|
||||
async fn flush_queue_pocita_pokusy() {
|
||||
let state = test_state().await;
|
||||
let dst = rand_bytes(32);
|
||||
let msg_uuid = rand_bytes(16);
|
||||
let env = v1::Envelope {
|
||||
dst_address: dst.clone(),
|
||||
msg_uuid: msg_uuid.clone(),
|
||||
content_hint: 0,
|
||||
payload: vec![0x22; 32],
|
||||
};
|
||||
sqlx::query(
|
||||
"INSERT INTO message_queue (msg_uuid, dst_address, envelope, expires_at)
|
||||
VALUES ($1, $2, $3, now() + interval '30 days')",
|
||||
)
|
||||
.bind(&msg_uuid)
|
||||
.bind(&dst)
|
||||
.bind(env.encode_to_vec())
|
||||
.execute(&state.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Dva flushe bez ACK = dva započtené pokusy.
|
||||
for _ in 0..2 {
|
||||
let (tx, rx) = mpsc::channel::<v1::ServerFrame>(16);
|
||||
assert_eq!(state.flush_queue(&dst, &tx).await.unwrap(), 1);
|
||||
drop(rx);
|
||||
}
|
||||
|
||||
let attempts: (i16,) =
|
||||
sqlx::query_as("SELECT attempt_count FROM message_queue WHERE msg_uuid = $1")
|
||||
.bind(&msg_uuid)
|
||||
.fetch_one(&state.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(attempts.0, 2, "každý flush musí započítat pokus");
|
||||
|
||||
sqlx::query("DELETE FROM message_queue WHERE dst_address = $1")
|
||||
.bind(&dst)
|
||||
.execute(&state.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// Obálka, kterou příjemce nikdy neACKne (ztracený ACK → ratchet už
|
||||
/// postoupil), musí po stropu zmizet, ne otravovat frontu 30 dní.
|
||||
#[tokio::test]
|
||||
#[ignore = "vyžaduje běžící Postgres (dev DB)"]
|
||||
async fn flush_queue_zahodi_nepruchodnou_obalku() {
|
||||
let state = test_state().await;
|
||||
let dst = rand_bytes(32);
|
||||
let msg_uuid = rand_bytes(16);
|
||||
let env = v1::Envelope {
|
||||
dst_address: dst.clone(),
|
||||
msg_uuid: msg_uuid.clone(),
|
||||
content_hint: 0,
|
||||
payload: vec![0x33; 32],
|
||||
};
|
||||
// Rovnou na stropu = obálka, která se marně posílala MAX_DELIVERY_ATTEMPTS×.
|
||||
sqlx::query(
|
||||
"INSERT INTO message_queue (msg_uuid, dst_address, envelope, expires_at, attempt_count)
|
||||
VALUES ($1, $2, $3, now() + interval '30 days', $4)",
|
||||
)
|
||||
.bind(&msg_uuid)
|
||||
.bind(&dst)
|
||||
.bind(env.encode_to_vec())
|
||||
.bind(MAX_DELIVERY_ATTEMPTS)
|
||||
.execute(&state.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let (tx, rx) = mpsc::channel::<v1::ServerFrame>(16);
|
||||
assert_eq!(
|
||||
state.flush_queue(&dst, &tx).await.unwrap(),
|
||||
0,
|
||||
"vyčerpaná obálka se nesmí znovu poslat"
|
||||
);
|
||||
drop(rx);
|
||||
|
||||
let zbylo: (i64,) =
|
||||
sqlx::query_as("SELECT count(*) FROM message_queue WHERE dst_address = $1")
|
||||
.bind(&dst)
|
||||
.fetch_one(&state.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(zbylo.0, 0, "vyčerpaná obálka měla být zahozena");
|
||||
}
|
||||
|
||||
/// Audit #3: FIFO strop zahodí nejstarší přes limit, nejnovější nechá.
|
||||
#[tokio::test]
|
||||
#[ignore = "vyžaduje běžící Postgres (dev DB)"]
|
||||
async fn trim_queue_fifo_zahodi_nejstarsi() {
|
||||
let state = test_state().await;
|
||||
let dst = rand_bytes(32);
|
||||
// 5 obálek po 100 B, rostoucí enqueued_at. Strop 250 B → nejnovější
|
||||
// dvě (2×100 ≤ 250) zůstanou, tři nejstarší padnou.
|
||||
let mut uuids = Vec::new();
|
||||
for i in 0..5 {
|
||||
let msg_uuid = rand_bytes(16);
|
||||
uuids.push(msg_uuid.clone());
|
||||
sqlx::query(
|
||||
"INSERT INTO message_queue (msg_uuid, dst_address, envelope, enqueued_at, expires_at)
|
||||
VALUES ($1, $2, $3, now() + (interval '1 second' * $4), now() + interval '30 days')",
|
||||
)
|
||||
.bind(&msg_uuid)
|
||||
.bind(&dst)
|
||||
.bind(vec![0u8; 100])
|
||||
.bind(i as i32)
|
||||
.execute(&state.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let dropped = state.trim_queue(&dst, 250).await.unwrap();
|
||||
assert_eq!(dropped, 3);
|
||||
|
||||
let remaining: Vec<Vec<u8>> = sqlx::query_scalar(
|
||||
"SELECT msg_uuid FROM message_queue WHERE dst_address = $1 ORDER BY enqueued_at",
|
||||
)
|
||||
.bind(&dst)
|
||||
.fetch_all(&state.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(remaining, vec![uuids[3].clone(), uuids[4].clone()]);
|
||||
|
||||
sqlx::query("DELETE FROM message_queue WHERE dst_address = $1")
|
||||
.bind(&dst)
|
||||
.execute(&state.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
+63
-30
@@ -12,6 +12,7 @@ use std::net::{IpAddr, SocketAddr};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use axum::body::Bytes;
|
||||
use axum::extract::ws::{Message as WsMessage, WebSocket, WebSocketUpgrade};
|
||||
use axum::extract::{ConnectInfo, State};
|
||||
use axum::http::{HeaderMap, StatusCode};
|
||||
@@ -26,38 +27,26 @@ use tokio::time::Instant;
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
/// Max souběžných WS spojení z jedné IP (za traefikem = reálná klientská
|
||||
/// IP z X-Real-Ip; tor kontejner sdílí jednu — proto ne moc těsně).
|
||||
/// Max souběžných WS spojení z jedné IP. Klientská IP jde přes trusted-proxy
|
||||
/// logiku (viz net.rs): za traefikem reálná IP z forwarded hlavičky, onion
|
||||
/// klienti sdílí IP tor kontejneru (jeden bucket) — proto ne moc těsně.
|
||||
const MAX_CONNS_PER_IP: u32 = 32;
|
||||
/// Neautentizované spojení zavřít po tomto čase.
|
||||
const AUTH_DEADLINE: Duration = Duration::from_secs(10);
|
||||
/// Rate limit rámců od klienta: max FRAME_WINDOW_MAX za FRAME_WINDOW.
|
||||
const FRAME_WINDOW: Duration = Duration::from_secs(10);
|
||||
const FRAME_WINDOW_MAX: u32 = 200;
|
||||
|
||||
/// Klientská IP: hlavičky od trusted proxy (na relay dosáhne jen traefik
|
||||
/// z docker sítě), fallback peer adresa (dev/tor).
|
||||
/// Jak často server posílá WS Ping autentizovanému klientovi.
|
||||
///
|
||||
/// TRUST MODEL — POZOR: X-Real-Ip / X-Forwarded-For se přebírají bezpodmínečně
|
||||
/// a řídí per-IP limity (cap spojení, rate limit). To je bezpečné VÝHRADNĚ
|
||||
/// proto, že před relayem stojí důvěryhodná reverzní proxy (traefik), která
|
||||
/// tyhle hlavičky vždy přepisuje reálnou klientskou IP. Klientem poslané
|
||||
/// hodnoty tak nikdy neprojdou. Kdyby byl relay vystaven přímo do internetu
|
||||
/// (bez proxy, která hlavičky sanitizuje), útočník by je mohl libovolně
|
||||
/// podvrhnout, tím rozprostřít provoz přes falešné IP a všechny per-IP limity
|
||||
/// obejít. Relay proto NIKDY nenasazovat bez takové proxy.
|
||||
fn client_ip(headers: &HeaderMap, peer: SocketAddr) -> IpAddr {
|
||||
let from_header = |name: &str| {
|
||||
headers
|
||||
.get(name)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| v.split(',').next())
|
||||
.and_then(|v| v.trim().parse::<IpAddr>().ok())
|
||||
};
|
||||
from_header("x-real-ip")
|
||||
.or_else(|| from_header("x-forwarded-for"))
|
||||
.unwrap_or_else(|| peer.ip())
|
||||
}
|
||||
/// Mobilní NAT zahazuje nečinné TCP tiše — bez keepalive se ani jedna strana
|
||||
/// nedozví, že spojení je mrtvé: klient hlásí „připojeno" a píše do prázdna,
|
||||
/// relay drží záznam v `online` a proto ani nepošle push budíček. Ping je
|
||||
/// PROTOKOLOVÝ (ne `v1::Ping`), protože na ten dart:io odpovídá Pongem sám —
|
||||
/// funguje tedy i s klienty, které jsou už venku a aplikační Ping neznají.
|
||||
const PING_INTERVAL: Duration = Duration::from_secs(30);
|
||||
/// Bez jediného rámce od klienta (stačí Pong) je spojení mrtvé. Tři zmeškané
|
||||
/// pingy — velkoryse, ať krátký výpadek mobilní sítě spojení nezabije.
|
||||
const IDLE_TIMEOUT: Duration = Duration::from_secs(95);
|
||||
|
||||
/// Drop-guard: úbytek čítače spojení i při panice/cancelu handleru.
|
||||
struct ConnGuard {
|
||||
@@ -80,7 +69,7 @@ pub async fn ws_handler(
|
||||
headers: HeaderMap,
|
||||
ws: WebSocketUpgrade,
|
||||
) -> impl IntoResponse {
|
||||
let ip = client_ip(&headers, peer);
|
||||
let ip = state.trusted_proxies.client_ip(peer.ip(), &headers);
|
||||
|
||||
let over_cap = {
|
||||
let mut entry = state.ws_conns.entry(ip).or_insert(0);
|
||||
@@ -134,19 +123,39 @@ async fn handle(state: Arc<AppState>, socket: WebSocket) {
|
||||
let (mut sink, mut stream) = socket.split();
|
||||
let (tx, mut rx) = mpsc::channel::<v1::ServerFrame>(64);
|
||||
|
||||
// Writer drží sink sám, takže si i pinguje — jinak by control rámce
|
||||
// musely téct přes `tx`, který nese jen aplikační ServerFrame.
|
||||
let writer = tokio::spawn(async move {
|
||||
while let Some(frame) = rx.recv().await {
|
||||
let mut ping = tokio::time::interval(PING_INTERVAL);
|
||||
// Zpožděné ticky se nedohánějí — po pauze chceme jeden ping, ne dávku.
|
||||
ping.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
||||
ping.tick().await; // první tick padne hned, ten nechceme
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
frame = rx.recv() => {
|
||||
let Some(frame) = frame else { break };
|
||||
let bytes = frame.encode_to_vec();
|
||||
if sink.send(WsMessage::Binary(bytes.into())).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
_ = ping.tick() => {
|
||||
if sink.send(WsMessage::Ping(Bytes::new())).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let mut pending: Option<Pending> = None;
|
||||
let mut authed_address: Option<Vec<u8>> = None;
|
||||
// device_uuid ověřeného zařízení — klíč pro push registraci.
|
||||
let mut authed_device: Option<Vec<u8>> = None;
|
||||
// Záznam v `online` mapě — značkuje se každým rámcem od klienta, aby
|
||||
// routing poznal živé spojení od zatuchlého.
|
||||
let mut online_device: Option<Arc<crate::state::OnlineDevice>> = None;
|
||||
|
||||
// Anti-abuse okno: počítá VŠECHNY rámce od klienta (envelope/ack/pong)
|
||||
let mut window_start = Instant::now();
|
||||
@@ -163,10 +172,29 @@ async fn handle(state: Arc<AppState>, socket: WebSocket) {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
stream.next().await
|
||||
// Po AUTH hlídá spojení idle timeout: klient, který dýchá, pošle
|
||||
// aspoň Pong na náš ping. Ticho = mrtvé spojení → zavřít, ať
|
||||
// záznam zmizí z `online` a další zpráva jde přes push budíček.
|
||||
// Bez tohohle tam zombie visel, dokud traefiku nedošel readTimeout
|
||||
// (30 min), a push se celou tu dobu vůbec nezkusil.
|
||||
match tokio::time::timeout(IDLE_TIMEOUT, stream.next()).await {
|
||||
Ok(next) => next,
|
||||
Err(_) => {
|
||||
// info, ne debug: relay běží na RUST_LOG=info a kolik
|
||||
// spojení umírá na ticho je provozní signál (kvalita sítě
|
||||
// klientů), ne ladicí detail. Bez identifikátoru zařízení.
|
||||
tracing::info!("ws zavřeno: klient neodpovídá na ping");
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
let Some(Ok(msg)) = next else { break };
|
||||
|
||||
// Cokoliv od klienta (i Pong na náš ping) = spojení dýchá.
|
||||
if let Some(dev) = &online_device {
|
||||
dev.touch(state.now_ms());
|
||||
}
|
||||
|
||||
let now = Instant::now();
|
||||
if now.duration_since(window_start) > FRAME_WINDOW {
|
||||
window_start = now;
|
||||
@@ -242,9 +270,14 @@ async fn handle(state: Arc<AppState>, socket: WebSocket) {
|
||||
break;
|
||||
}
|
||||
|
||||
let device = Arc::new(crate::state::OnlineDevice::new(
|
||||
tx.clone(),
|
||||
state.now_ms(),
|
||||
));
|
||||
state
|
||||
.online
|
||||
.insert(pd.delivery_address.clone(), tx.clone());
|
||||
.insert(pd.delivery_address.clone(), device.clone());
|
||||
online_device = Some(device);
|
||||
authed_address = Some(pd.delivery_address.clone());
|
||||
|
||||
// Potvrzení AUTH — klient až teď smí hlásit „připojeno"
|
||||
@@ -254,7 +287,7 @@ async fn handle(state: Arc<AppState>, socket: WebSocket) {
|
||||
)))
|
||||
.await;
|
||||
|
||||
// last_seen jen v hrubé granularitě (privacy — zaokrouhleno na hodinu).
|
||||
// last_seen jen v hrubé granularitě (privacy, §1.2).
|
||||
let _ = sqlx::query(
|
||||
"UPDATE devices SET last_seen_at = date_trunc('hour', now())
|
||||
WHERE device_uuid = $1",
|
||||
|
||||
Reference in New Issue
Block a user