6 Commits

Author SHA1 Message Date
Julius Joska f903e21e3a release: 1.1.2 — reliable message delivery 2026-08-13 12:33:24 +02:00
Julius Joska 923a6a01bd fix(app): say it out loud when wake-ups do not work
Registration failures from the distributor were swallowed, so push looked
enabled while nothing arrived. Without wake-ups Android suspends the app
and messages only show up once the user opens it — which looks like broken
delivery even though the relay has them queued.

The failure is now remembered and surfaced in Settings, and the chat list
carries a dismissible line explaining the state.
2026-08-13 12:23:30 +02:00
Julius Joska 9985b79aa6 fix(app): ping the relay, retry stuck messages, drop duplicates
A message sent into a dead socket ended up as failed — attachments even
stayed on "sending" forever — and only the user could send it again by
hand. Mobile NAT drops idle TCP silently, so neither side knew.

- pingInterval on the WebSocket, so the client notices a dead link itself
- outbox: after every reconnect, stuck messages are re-sent (batch of 30,
  up to a day old); permanent errors are not retried
- sendAttachment gained the try/catch it was missing, otherwise the
  attachment never reached the outbox
- de-duplicate by message id: a retry re-sends the SAME message, which
  would otherwise show up twice on the other side
2026-08-13 12:23:24 +02:00
Julius Joska b82f50f888 fix(relay): keep-alive on WebSocket, cap delivery attempts, honest receipts
Messages arrived late or not at all, for two independent reasons.

Dead connections: neither side sent a ping, and after AUTH the server
waited for a frame without a timeout. Mobile NAT drops idle TCP silently,
so the client wrote into a void while the relay still held the device in
its online map — and therefore never sent a push wake-up either. The ping
is a protocol-level one rather than v1::Ping, because dart:io answers
those on its own; the fix works with clients already in the wild.

Poisoned queue: a client decrypts an envelope (ratchet advances), stores
it and acknowledges it. If that ACK is lost, a second decryption attempt
can only fail, so the envelope is never acknowledged — and was redelivered
on every connect until its 30-day TTL, without ever reaching the recipient.
attempt_count existed from the first migration but was never counted; it
now bounds redelivery.

Receipts reported DELIVERED_ONLINE whenever a write into the bounded mpsc
channel succeeded, which it does even for a long-dead socket. Online is now
claimed only for devices that recently sent something.

Push send failures were logged at debug while the relay runs at info, so a
rejected wake-up looked exactly like a working one.
2026-08-13 12:23:17 +02:00
Julius Joska 4f2cbdbdc4 harden(relay): gate forwarded-IP on trusted proxies, bound queue flush
Rate limiting and the per-IP WebSocket cap trusted X-Forwarded-For and
X-Real-Ip unconditionally. Onion traffic bypasses the reverse proxy, so a
client could set the header itself and slip past every limit. Forwarded
headers are now honoured only from peers inside SEPOT_TRUSTED_PROXIES;
everyone else is keyed by peer address, and onion clients share one bucket.

The queue flush also read the whole backlog into memory at once, and the
certificate authority kept the trust-root private key in the database even
though it is only ever needed offline.
2026-08-13 12:23:07 +02:00
Julius Joska 90a1390716 fix: reliable first-message send, QR routing, auto-lock recovery
Fixes reported by testers and found in an app review:

- First message right after install no longer fails. Before the app
  finished registering with the relay, the outgoing message could crash on
  a null delivery address. The profile field is now optional and the send
  path throws a clear "still connecting, try again shortly" error while
  registration completes in the background.
- The QR code and share link now appear only once the delivery address is
  ready. Previously a link generated before the first connection lacked
  routing, so the other side could not send the first message. The screen
  now shows a connecting state and reactively swaps in the QR when ready.
- Incomplete contact links are rejected on import instead of failing at
  first send, with a clear prompt to ask for a fresh link.
- Auto-lock no longer silences the app: after locking and unlocking,
  messaging is restarted (it previously stayed dead until app restart).
- Group messages are marked failed on a non-per-member send error instead
  of hanging in the "sending" state forever.
- Hardened the relay client against a rare duplicate connection when
  toggling Tor or locking mid-connect, and fromShareString now rejects a
  delivery address of the wrong length.
- Pin unifiedpush below 6.2.0 to drop the webcrypto dependency (its native
  framework lacks LC_ENCRYPTION_INFO and blocked the iOS upload).
- Skip Android release lint (network dependency check hangs on the build
  host and adds no value for a Flutter app).
2026-07-18 19:53:41 +02:00
52 changed files with 1779 additions and 277 deletions
+50
View File
@@ -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/), [Keep a Changelog](https://keepachangelog.com/cs/1.1.0/),
verzování dle [ajtak standardu](#verzování). 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 ## [1.1.0] – 2026-07-13
### Přidáno ### Přidáno
+8
View File
@@ -42,6 +42,14 @@ android {
jvmTarget = JavaVersion.VERSION_17.toString() 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 { defaultConfig {
applicationId = "cz.sepot.sepot" applicationId = "cz.sepot.sepot"
// libsignal binárky vyžadují Android 7.0+ (API 24) // libsignal binárky vyžadují Android 7.0+ (API 24)
+5 -1
View File
@@ -196,8 +196,12 @@ class _AutoLockGateState extends ConsumerState<_AutoLockGate>
final kde = _router.state.uri.path; final kde = _router.state.uri.path;
if (_bezZamku.contains(kde)) return; 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(); await ref.read(messagingServiceProvider).stop();
ref.invalidate(messagingStartProvider);
storage.lock(); storage.lock();
_router.go('/pin-lock'); _router.go('/pin-lock');
} }
+18
View File
@@ -26,6 +26,24 @@ class RelayUnavailableError extends SepotUserError {
String toString() => 'relay unavailable'; 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. /// Příloha přesahuje serverový limit.
class AttachmentTooLargeError extends SepotUserError { class AttachmentTooLargeError extends SepotUserError {
const AttachmentTooLargeError(this.maxBytes); const AttachmentTooLargeError(this.maxBytes);
+2
View File
@@ -11,6 +11,8 @@ import 'package:sepot/l10n/generated/app_localizations.dart';
String describeError(AppLocalizations l10n, Object error) => switch (error) { String describeError(AppLocalizations l10n, Object error) => switch (error) {
TrustRootChangedError() => l10n.errorTrustRootChanged, TrustRootChangedError() => l10n.errorTrustRootChanged,
RelayUnavailableError() => l10n.errorRelayUnavailable, RelayUnavailableError() => l10n.errorRelayUnavailable,
NotYetRegisteredError() => l10n.errorNotYetRegistered,
ContactLinkIncompleteError() => l10n.errorContactLinkIncomplete,
AttachmentTooLargeError(:final maxMb) => AttachmentTooLargeError(:final maxMb) =>
l10n.errorAttachmentTooLarge(maxMb), l10n.errorAttachmentTooLarge(maxMb),
GroupNeedsMemberError() => l10n.errorGroupNeedsMember, GroupNeedsMemberError() => l10n.errorGroupNeedsMember,
+17 -2
View File
@@ -75,6 +75,9 @@ class RelayClient {
void _setState(RelayConnectionState s) { void _setState(RelayConnectionState s) {
_state = 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); _stateController.add(s);
} }
@@ -292,6 +295,14 @@ class RelayClient {
try { try {
final channel = _transport.connectWs(Uri.parse('$_wsUrl/v1/ws')); final channel = _transport.connectWs(Uri.parse('$_wsUrl/v1/ws'));
await channel.ready; 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; _channel = channel;
// AUTH: výzva-odpověď podepsaná identity klíčem (XEdDSA) // AUTH: výzva-odpověď podepsaná identity klíčem (XEdDSA)
@@ -322,9 +333,13 @@ class RelayClient {
return; return;
} }
if (frame.hasDeliver()) { if (frame.hasDeliver()) {
_deliverController.add(frame.deliver); if (!_deliverController.isClosed) {
_deliverController.add(frame.deliver);
}
} else if (frame.hasReceipt()) { } else if (frame.hasReceipt()) {
_receiptController.add(frame.receipt); if (!_receiptController.isClosed) {
_receiptController.add(frame.receipt);
}
} }
}, },
onDone: _onDisconnected, onDone: _onDisconnected,
+12 -2
View File
@@ -21,6 +21,15 @@ import 'package:socks5_proxy/socks_client.dart';
import 'package:web_socket_channel/io.dart'; import 'package:web_socket_channel/io.dart';
import 'package:web_socket_channel/web_socket_channel.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. /// Abstrakce nad tím, jak klient otevírá spojení k relayi.
abstract class RelayTransport { abstract class RelayTransport {
/// Sdílený HTTP klient pro REST (registrace, prekey bundle, přílohy…). /// 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(); http.Client httpClient() => _client ??= http.Client();
@override @override
WebSocketChannel connectWs(Uri url) => WebSocketChannel.connect(url); WebSocketChannel connectWs(Uri url) =>
IOWebSocketChannel.connect(url, pingInterval: kWsPingInterval);
@override @override
void close() { void close() {
@@ -84,7 +94,7 @@ class TorTransport implements RelayTransport {
@override @override
WebSocketChannel connectWs(Uri url) => WebSocketChannel connectWs(Uri url) =>
IOWebSocketChannel.connect(_withExplicitPort(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 /// `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 /// CONNECT by cílil na port 0 (serverError). Doplníme ho podle schématu
+14 -1
View File
@@ -45,6 +45,7 @@ class PushBootstrap {
static const _pendingEndpointKey = 'pending_push_endpoint'; static const _pendingEndpointKey = 'pending_push_endpoint';
static const _modeKey = 'push_mode'; // 'off' | 'up' | 'fcm' static const _modeKey = 'push_mode'; // 'off' | 'up' | 'fcm'
static const _registrationFailedKey = 'push_registration_failed';
static const _store = FlutterSecureStorage( static const _store = FlutterSecureStorage(
aOptions: AndroidOptions(encryptedSharedPreferences: true), aOptions: AndroidOptions(encryptedSharedPreferences: true),
); );
@@ -57,6 +58,16 @@ class PushBootstrap {
static Future<void> writeMode(String mode) => static Future<void> writeMode(String mode) =>
_store.write(key: _modeKey, value: 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 { static Future<void> init(List<String> args) async {
backgroundStart = args.contains('--unifiedpush-bg'); backgroundStart = args.contains('--unifiedpush-bg');
@@ -69,7 +80,8 @@ class PushBootstrap {
} else { } else {
await UnifiedPush.initialize( await UnifiedPush.initialize(
onNewEndpoint: _onNewEndpoint, onNewEndpoint: _onNewEndpoint,
onRegistrationFailed: (_, _) {}, onRegistrationFailed: (_, _) =>
unawaited(_setRegistrationFailed(true)),
onUnregistered: _onUnregistered, onUnregistered: _onUnregistered,
onMessage: _onMessage, onMessage: _onMessage,
); );
@@ -114,6 +126,7 @@ class PushBootstrap {
} }
static void _onNewEndpoint(PushEndpoint endpoint, String instance) { static void _onNewEndpoint(PushEndpoint endpoint, String instance) {
unawaited(_setRegistrationFailed(false)); // registrace se povedla
final s = _service; final s = _service;
if (s != null) { if (s != null) {
s.handleNewEndpoint(endpoint.url); s.handleNewEndpoint(endpoint.url);
+4
View File
@@ -144,6 +144,10 @@ class PushService {
return (await UnifiedPush.getDistributor()) != null ? 'up' : 'off'; 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 { Future<void> setModeOff() async {
await _teardownFcm(deleteToken: true); await _teardownFcm(deleteToken: true);
try { try {
+249 -51
View File
@@ -78,9 +78,15 @@ class MessagingService {
bool _trustRootMismatch = false; bool _trustRootMismatch = false;
bool get trustRootMismatch => _trustRootMismatch; bool get trustRootMismatch => _trustRootMismatch;
StreamSubscription<dynamic>? _deliverSub; StreamSubscription<dynamic>? _deliverSub;
StreamSubscription<RelayConnectionState>? _stateSub;
Timer? _registerRetryTimer; Timer? _registerRetryTimer;
Timer? _purgeTimer; 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 /// 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 /// `.onion`). Čte se z nastavení při startu, přepíná se za běhu
/// ([setTorEnabled]) přestavením [RelayClient]. /// ([setTorEnabled]) přestavením [RelayClient].
@@ -155,6 +161,10 @@ class MessagingService {
final relay = _buildRelay(); final relay = _buildRelay();
_relay = relay; _relay = relay;
_deliverSub = relay.deliveries.listen(_onDeliver); _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) { if (existingAccount != null) {
relay.restoreSession( relay.restoreSession(
@@ -211,6 +221,7 @@ class MessagingService {
/// push registrace se znovu odešle. Trust root zůstává pinovaný. /// push registrace se znovu odešle. Trust root zůstává pinovaný.
Future<void> _rebuildRelay() async { Future<void> _rebuildRelay() async {
await _deliverSub?.cancel(); await _deliverSub?.cancel();
await _stateSub?.cancel();
await _relay?.dispose(); await _relay?.dispose();
_senderCert = null; _senderCert = null;
_certFetchedAt = null; _certFetchedAt = null;
@@ -218,6 +229,10 @@ class MessagingService {
final relay = _buildRelay(); final relay = _buildRelay();
_relay = relay; _relay = relay;
_deliverSub = relay.deliveries.listen(_onDeliver); _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; final existingAccount = accountId;
if (existingAccount != null) { if (existingAccount != null) {
@@ -357,6 +372,7 @@ class MessagingService {
'v': 1, 'v': 1,
't': 'txt', 't': 'txt',
'id': message.id, 'id': message.id,
'ts': message.timestamp.millisecondsSinceEpoch,
'txt': text, 'txt': text,
're': ?replyToId, 're': ?replyToId,
'ttl': ?ttl, 'ttl': ?ttl,
@@ -409,23 +425,35 @@ class MessagingService {
await storage.addMessage(contact.id, message); await storage.addMessage(contact.id, message);
onUpdate?.call(); onUpdate?.call();
final delivered = await _sendContent(contact, { // Stejné ošetření jako u textu: bez něj zůstala příloha po výpadku
'v': 1, // spojení viset na „odesílá se" navždy. Označit jako failed ji dostane
't': 'att', // do fronty odchozích ([_flushOutbox]) — blob je na relayi nahraný,
'id': message.id, // dosílá se jen meta.
if (caption != null && caption.isNotEmpty) 'txt': caption, try {
'ttl': ?ttl, final delivered = await _sendContent(contact, {
'att': meta.toWire(), 'v': 1,
'p': _profile(), 't': 'att',
}); 'id': message.id,
'ts': message.timestamp.millisecondsSinceEpoch,
if (caption != null && caption.isNotEmpty) 'txt': caption,
'ttl': ?ttl,
'att': meta.toWire(),
'p': _profile(),
});
final sent = message.copyWith( final sent = message.copyWith(
isDelivered: delivered, isDelivered: delivered,
status: delivered ? MessageStatus.delivered : MessageStatus.sent, status: delivered ? MessageStatus.delivered : MessageStatus.sent,
); );
await storage.updateMessage(contact.id, sent); await storage.updateMessage(contact.id, sent);
onUpdate?.call(); onUpdate?.call();
return sent; 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č, /// Zpracuje a nahraje přílohu: obrázky re-enkóduje (EXIF/GPS pryč,
@@ -496,10 +524,14 @@ class MessagingService {
Map<String, dynamic> _profile() { Map<String, dynamic> _profile() {
final identity = _identity!; final identity = _identity!;
final da = myDeliveryAddress;
return { return {
'n': identity.displayName, 'n': identity.displayName,
'aid': accountId, 'aid': ?accountId,
'da': base64Encode(myDeliveryAddress!), // 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,21 +726,31 @@ class MessagingService {
await storage.addMessage(group.id, message); await storage.addMessage(group.id, message);
onUpdate?.call(); onUpdate?.call();
final anyOk = await _fanoutToGroup(group, { try {
'v': 1, final anyOk = await _fanoutToGroup(group, {
't': 'txt', 'v': 1,
'id': message.id, 't': 'txt',
'gid': group.id, 'id': message.id,
'txt': text, 'ts': message.timestamp.millisecondsSinceEpoch,
're': ?replyToId, 'gid': group.id,
'ttl': ?ttl, 'txt': text,
'p': _profile(), 're': ?replyToId,
}); 'ttl': ?ttl,
final sent = message.copyWith( 'p': _profile(),
status: anyOk ? MessageStatus.sent : MessageStatus.failed); });
await storage.updateMessage(group.id, sent); final sent = message.copyWith(
onUpdate?.call(); status: anyOk ? MessageStatus.sent : MessageStatus.failed);
return sent; 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, /// Odešle přílohu do skupiny — blob se šifruje a nahrává JEDNOU,
@@ -741,21 +783,29 @@ class MessagingService {
await storage.addMessage(group.id, message); await storage.addMessage(group.id, message);
onUpdate?.call(); onUpdate?.call();
final anyOk = await _fanoutToGroup(group, { try {
'v': 1, final anyOk = await _fanoutToGroup(group, {
't': 'att', 'v': 1,
'id': message.id, 't': 'att',
'gid': group.id, 'id': message.id,
if (caption != null && caption.isNotEmpty) 'txt': caption, 'ts': message.timestamp.millisecondsSinceEpoch,
'ttl': ?ttl, 'gid': group.id,
'att': meta.toWire(), if (caption != null && caption.isNotEmpty) 'txt': caption,
'p': _profile(), 'ttl': ?ttl,
}); 'att': meta.toWire(),
final sent = message.copyWith( 'p': _profile(),
status: anyOk ? MessageStatus.sent : MessageStatus.failed); });
await storage.updateMessage(group.id, sent); final sent = message.copyWith(
onUpdate?.call(); status: anyOk ? MessageStatus.sent : MessageStatus.failed);
return sent; 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). /// Reakce ve skupině (emoji == null = odebrat).
@@ -1002,6 +1052,115 @@ class MessagingService {
/// Společná odesílací cesta: session bootstrap (X3DH s anti-MITM /// Společná odesílací cesta: session bootstrap (X3DH s anti-MITM
/// kontrolou identity klíče), sealed encrypt, obálka na relay. /// kontrolou identity klíče), sealed encrypt, obálka na relay.
/// Vrací true = doručeno online, false = uloženo do offline fronty. /// 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( Future<bool> _sendContent(
Contact contact, Contact contact,
Map<String, dynamic> content, Map<String, dynamic> content,
@@ -1009,6 +1168,13 @@ class MessagingService {
final crypto = _crypto ?? (throw StateError('Service not started')); final crypto = _crypto ?? (throw StateError('Service not started'));
final relay = _relay!; 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 peer = contact.publicIdentity;
final peerAid = peer.accountId; final peerAid = peer.accountId;
final peerDa = peer.deliveryAddress; final peerDa = peer.deliveryAddress;
@@ -1204,7 +1370,7 @@ class MessagingService {
type: MessageType.text, type: MessageType.text,
content: text, content: text,
replyToId: replyTo, replyToId: replyTo,
timestamp: DateTime.now(), timestamp: wireSentAt(content['ts']),
isDelivered: true, isDelivered: true,
disappearAfterSeconds: ttl, disappearAfterSeconds: ttl,
); );
@@ -1230,18 +1396,31 @@ class MessagingService {
fileName: meta.name, fileName: meta.name,
fileSize: meta.size, fileSize: meta.size,
replyToId: replyTo, replyToId: replyTo,
timestamp: DateTime.now(), timestamp: wireSentAt(content['ts']),
isDelivered: true, isDelivered: true,
attachment: meta, attachment: meta,
disappearAfterSeconds: ttl, 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. // Uloženo na disku → teprve teď smí relay obálku zahodit.
// (Blob přílohy do fronty nepatří — stahuje se na pozadí a při // (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á.) // neúspěchu má UI retry, ACK na něj nečeká.)
_relay!.ack(envelope.msgUuid); _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(); onUpdate?.call();
onMessageReceived?.call(contact, message); 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é /// Aplikuje příchozí reakci/edit/delete na cílovou zprávu v dané
/// konverzaci (1:1 = kontakt, skupina = gid). Klíč reakce = fingerprint /// konverzaci (1:1 = kontakt, skupina = gid). Klíč reakce = fingerprint
/// odesílatele (kryptograficky ověřený sealed senderem, ne /// odesílatele (kryptograficky ověřený sealed senderem, ne
@@ -1487,6 +1684,7 @@ class MessagingService {
_purgeTimer?.cancel(); _purgeTimer?.cancel();
_purgeTimer = null; _purgeTimer = null;
await _deliverSub?.cancel(); await _deliverSub?.cancel();
await _stateSub?.cancel();
await _relay?.dispose(); await _relay?.dispose();
// DB vlastní SecureStorageService (lock/deleteAll) — tady jen pustit // DB vlastní SecureStorageService (lock/deleteAll) — tady jen pustit
_db = null; _db = null;
+6 -2
View File
@@ -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); var conv = await loadConversation(contactId);
conv ??= Conversation(contactId: contactId); conv ??= Conversation(contactId: contactId);
conv.addMessage(message); if (!conv.addMessage(message)) return false;
await saveConversation(conv); await saveConversation(conv);
return true;
} }
/// Založí prázdnou konverzaci, pokud ještě není (nová skupina musí být /// 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/crypto/dek_wrap.dart';
import 'package:sepot/core/l10n/l10n.dart'; import 'package:sepot/core/l10n/l10n.dart';
import 'package:sepot/core/theme/app_theme.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/providers/storage_provider.dart';
import 'package:sepot/widgets/sepot_logo.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 if (!ok) return; // uživatel zrušil → zůstává PIN
await storage.unlockWithBiometrics(); await storage.unlockWithBiometrics();
_restartMessagingAfterUnlock();
if (mounted) context.go('/chats'); if (mounted) context.go('/chats');
} catch (_) { } catch (_) {
// biometrie nedostupná/selhala — PIN je vždy fallback // 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 { Future<void> _verifyPin() async {
setState(() => _isVerifying = true); setState(() => _isVerifying = true);
@@ -218,6 +231,7 @@ class _PinLockScreenState extends ConsumerState<PinLockScreen> {
// „Ověření" PINu = Argon2id unwrap DEK + otevření SQLCipher DB. // „Ověření" PINu = Argon2id unwrap DEK + otevření SQLCipher DB.
// Špatný PIN = GCM tag nesedí = DB zůstává nečitelná. // Špatný PIN = GCM tag nesedí = DB zůstává nečitelná.
await ref.read(storageProvider).unlockWithPin(_pin); await ref.read(storageProvider).unlockWithPin(_pin);
_restartMessagingAfterUnlock();
if (mounted) context.go('/chats'); if (mounted) context.go('/chats');
} on WrongPinException { } on WrongPinException {
setState(() { setState(() {
@@ -5,6 +5,7 @@ import 'package:sepot/core/l10n/error_messages.dart';
import 'package:sepot/core/l10n/l10n.dart'; import 'package:sepot/core/l10n/l10n.dart';
import 'package:sepot/core/theme/app_theme.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/chat_list_tile.dart';
import 'package:sepot/features/chat_list/push_hint_banner.dart';
import 'package:sepot/models/conversation.dart'; import 'package:sepot/models/conversation.dart';
import 'package:sepot/providers/contacts_provider.dart'; import 'package:sepot/providers/contacts_provider.dart';
import 'package:sepot/providers/conversations_provider.dart'; import 'package:sepot/providers/conversations_provider.dart';
@@ -62,7 +63,13 @@ class ChatListScreen extends ConsumerWidget {
} }
final hasArchive = archived.isNotEmpty; 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), padding: const EdgeInsets.symmetric(vertical: 8),
itemCount: active.length + (hasArchive ? 1 : 0), itemCount: active.length + (hasArchive ? 1 : 0),
itemBuilder: (context, index) { itemBuilder: (context, index) {
@@ -95,7 +102,10 @@ class ChatListScreen extends ConsumerWidget {
onTap: () => context.push('/conversation/${conv.contactId}'), onTap: () => context.push('/conversation/${conv.contactId}'),
onLongPress: () => _showChatActions(context, ref, conv, name), onLongPress: () => _showChatActions(context, ref, conv, name),
); );
}, },
),
),
],
); );
}, },
loading: () => const Center( 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/error_messages.dart';
import 'package:sepot/core/l10n/l10n.dart'; import 'package:sepot/core/l10n/l10n.dart';
import 'package:sepot/core/theme/app_theme.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/identity_provider.dart';
import 'package:sepot/providers/messaging_provider.dart'; import 'package:sepot/providers/messaging_provider.dart';
@@ -27,12 +28,24 @@ class QrShareScreen extends ConsumerWidget {
return Center(child: Text(context.l10n.qrIdentityNotFound)); return Center(child: Text(context.l10n.qrIdentityNotFound));
} }
// Sdílet s routingem (accountId + delivery address z relaye), // Sledovat stav připojení: po dokončení vlastní registrace se
// jinak protistrana nemá kam poslat první zprávu // 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 service = ref.watch(messagingServiceProvider);
final shareString = (service.shareableIdentity ?? final shareable = service.shareableIdentity;
identity.publicIdentity) final hasRouting = shareable != null &&
.toShareString(); 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( return SingleChildScrollView(
padding: const EdgeInsets.all(32), 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 _mode = 'off';
String? _distributor; String? _distributor;
bool _busy = false; bool _busy = false;
bool _registrationFailed = false;
@override @override
void initState() { void initState() {
@@ -31,10 +32,12 @@ class _PushSettingsTileState extends ConsumerState<PushSettingsTile> {
final push = ref.read(pushServiceProvider); final push = ref.read(pushServiceProvider);
final mode = await push.currentMode(); final mode = await push.currentMode();
final d = await push.currentDistributor(); final d = await push.currentDistributor();
final failed = await push.registrationFailed();
if (mounted) { if (mounted) {
setState(() { setState(() {
_mode = mode; _mode = mode;
_distributor = d; _distributor = d;
_registrationFailed = failed;
}); });
} }
} }
@@ -160,12 +163,17 @@ class _PushSettingsTileState extends ConsumerState<PushSettingsTile> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final subtitle = switch (_mode) { // Selhaná registrace přebíjí popis režimu — „zapnuto" bez budíčků je
'up' => context.l10n.pushSubtitleUnifiedPush( // horší než vypnuto, protože uživatel čeká zprávy, které nepřijdou.
_distributor ?? context.l10n.pushNoDistributorFallback), final failed = _registrationFailed && _mode != 'off';
'fcm' => context.l10n.pushSubtitleFcm, final subtitle = failed
_ => context.l10n.commonOff, ? context.l10n.pushRegistrationFailed
}; : switch (_mode) {
'up' => context.l10n.pushSubtitleUnifiedPush(
_distributor ?? context.l10n.pushNoDistributorFallback),
'fcm' => context.l10n.pushSubtitleFcm,
_ => context.l10n.commonOff,
};
return ListTile( return ListTile(
leading: Container( leading: Container(
width: 40, width: 40,
@@ -184,7 +192,10 @@ class _PushSettingsTileState extends ConsumerState<PushSettingsTile> {
style: const TextStyle(fontSize: 15)), style: const TextStyle(fontSize: 15)),
subtitle: Text( subtitle: Text(
subtitle, subtitle,
style: const TextStyle(fontSize: 13, color: SepotColors.textMuted), style: TextStyle(
fontSize: 13,
color: failed ? SepotColors.ember : SepotColors.textMuted,
),
), ),
trailing: _busy trailing: _busy
? const SizedBox( ? const SizedBox(
+28
View File
@@ -375,6 +375,14 @@
"@errorRelayUnavailable": { "@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í." "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": "Příloha je příliš velká (max {maxMb} MB)",
"@errorAttachmentTooLarge": { "@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.", "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": "Žádný distributor",
"@pushNoDistributorTitle": { "@pushNoDistributorTitle": {
"description": "Titulek AlertDialog, když v systému není nainstalovaný žádný UnifiedPush distributor" "description": "Titulek AlertDialog, když v systému není nainstalovaný žádný UnifiedPush distributor"
@@ -1419,6 +1439,14 @@
"@qrShareTitle": { "@qrShareTitle": {
"description": "AppBar titulek obrazovky sdílení vlastního QR/Šepot ID" "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": "Identita nebyla nalezena",
"@qrIdentityNotFound": { "@qrIdentityNotFound": {
"description": "Text uprostřed obrazovky, když identityProvider vrátí null (nemělo by běžně nastat)" "description": "Text uprostřed obrazovky, když identityProvider vrátí null (nemělo by běžně nastat)"
+7
View File
@@ -75,6 +75,8 @@
"chatEdited": "bearbeitet", "chatEdited": "bearbeitet",
"errorTrustRootChanged": "Der Trust Root des Relays hat sich geändert! Kommunikation wird verweigert.", "errorTrustRootChanged": "Der Trust Root des Relays hat sich geändert! Kommunikation wird verweigert.",
"errorRelayUnavailable": "Relay nicht erreichbar (Sender-Zertifikat)", "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)", "errorAttachmentTooLarge": "Anhang ist zu groß (max. {maxMb} MB)",
"errorGroupNeedsMember": "Die Gruppe braucht mindestens 1 Mitglied", "errorGroupNeedsMember": "Die Gruppe braucht mindestens 1 Mitglied",
"errorGroupMaxMembers": "Die Gruppe kann höchstens {maxMembers} Mitglieder haben", "errorGroupMaxMembers": "Die Gruppe kann höchstens {maxMembers} Mitglieder haben",
@@ -269,6 +271,9 @@
"pushModeFcm": "Google (FCM)", "pushModeFcm": "Google (FCM)",
"pushModeFcmDesc": "Zuverlässig ohne zusätzliche App. Der Push-Token des Geräts geht an Google; der Inhalt der Nachrichten nie.", "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}", "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", "pushNoDistributorTitle": "Kein Distributor",
"pushNoDistributorBody": "Für Google-freie Benachrichtigungen installiere einen UnifiedPush-Distributor, z. B. ntfy (F-Droid oder Play). Aktiviere Push danach erneut.", "pushNoDistributorBody": "Für Google-freie Benachrichtigungen installiere einen UnifiedPush-Distributor, z. B. ntfy (F-Droid oder Play). Aktiviere Push danach erneut.",
"commonOk": "OK", "commonOk": "OK",
@@ -294,6 +299,8 @@
"chatListArchive": "Archivieren", "chatListArchive": "Archivieren",
"qrShareTitle": "Meine Šepot-ID", "qrShareTitle": "Meine Šepot-ID",
"qrIdentityNotFound": "Identität nicht gefunden", "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", "commonCopiedToClipboard": "In die Zwischenablage kopiert",
"qrShareInviteMessage": "Füg mich auf Šepot hinzu!\n\n{shareString}", "qrShareInviteMessage": "Füg mich auf Šepot hinzu!\n\n{shareString}",
"commonShare": "Teilen", "commonShare": "Teilen",
+7
View File
@@ -75,6 +75,8 @@
"chatEdited": "edited", "chatEdited": "edited",
"errorTrustRootChanged": "The relay's trust root has changed! Refusing to communicate.", "errorTrustRootChanged": "The relay's trust root has changed! Refusing to communicate.",
"errorRelayUnavailable": "Relay unavailable (sender certificate)", "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)", "errorAttachmentTooLarge": "Attachment is too large (max {maxMb} MB)",
"errorGroupNeedsMember": "A group needs at least 1 member", "errorGroupNeedsMember": "A group needs at least 1 member",
"errorGroupMaxMembers": "A group can have at most {maxMembers} members", "errorGroupMaxMembers": "A group can have at most {maxMembers} members",
@@ -269,6 +271,9 @@
"pushModeFcm": "Google (FCM)", "pushModeFcm": "Google (FCM)",
"pushModeFcmDesc": "Reliable without an extra app. The device push token goes to Google; message content never does.", "pushModeFcmDesc": "Reliable without an extra app. The device push token goes to Google; message content never does.",
"pushSetupFailed": "Setting up push failed: {error}", "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", "pushNoDistributorTitle": "No distributor",
"pushNoDistributorBody": "For de-Googled notifications, install a UnifiedPush distributor such as ntfy (F-Droid or Play). Then turn push on again.", "pushNoDistributorBody": "For de-Googled notifications, install a UnifiedPush distributor such as ntfy (F-Droid or Play). Then turn push on again.",
"commonOk": "OK", "commonOk": "OK",
@@ -294,6 +299,8 @@
"chatListArchive": "Archive", "chatListArchive": "Archive",
"qrShareTitle": "My Šepot ID", "qrShareTitle": "My Šepot ID",
"qrIdentityNotFound": "Identity not found", "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", "commonCopiedToClipboard": "Copied to clipboard",
"qrShareInviteMessage": "Add me on Šepot!\n\n{shareString}", "qrShareInviteMessage": "Add me on Šepot!\n\n{shareString}",
"commonShare": "Share", "commonShare": "Share",
+7
View File
@@ -75,6 +75,8 @@
"chatEdited": "modificato", "chatEdited": "modificato",
"errorTrustRootChanged": "Il trust root del relay è cambiato! Comunicazione rifiutata.", "errorTrustRootChanged": "Il trust root del relay è cambiato! Comunicazione rifiutata.",
"errorRelayUnavailable": "Relay non disponibile (sender certificate)", "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)", "errorAttachmentTooLarge": "L'allegato è troppo grande (max {maxMb} MB)",
"errorGroupNeedsMember": "Il gruppo ha bisogno di almeno 1 membro", "errorGroupNeedsMember": "Il gruppo ha bisogno di almeno 1 membro",
"errorGroupMaxMembers": "Il gruppo può avere al massimo {maxMembers} membri", "errorGroupMaxMembers": "Il gruppo può avere al massimo {maxMembers} membri",
@@ -269,6 +271,9 @@
"pushModeFcm": "Google (FCM)", "pushModeFcm": "Google (FCM)",
"pushModeFcmDesc": "Affidabile senza altre app. Il token push del dispositivo va a Google; il contenuto dei messaggi mai.", "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}", "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", "pushNoDistributorTitle": "Nessun distributore",
"pushNoDistributorBody": "Per notifiche senza Google installa un distributore UnifiedPush, ad es. ntfy (F-Droid o Play). Poi riattiva le notifiche push.", "pushNoDistributorBody": "Per notifiche senza Google installa un distributore UnifiedPush, ad es. ntfy (F-Droid o Play). Poi riattiva le notifiche push.",
"commonOk": "OK", "commonOk": "OK",
@@ -294,6 +299,8 @@
"chatListArchive": "Archivia", "chatListArchive": "Archivia",
"qrShareTitle": "Il mio Šepot ID", "qrShareTitle": "Il mio Šepot ID",
"qrIdentityNotFound": "Identità non trovata", "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", "commonCopiedToClipboard": "Copiato negli appunti",
"qrShareInviteMessage": "Aggiungimi su Šepot!\n\n{shareString}", "qrShareInviteMessage": "Aggiungimi su Šepot!\n\n{shareString}",
"commonShare": "Condividi", "commonShare": "Condividi",
+7
View File
@@ -75,6 +75,8 @@
"chatEdited": "изменено", "chatEdited": "изменено",
"errorTrustRootChanged": "Trust root релея изменился! Соединение отклонено.", "errorTrustRootChanged": "Trust root релея изменился! Соединение отклонено.",
"errorRelayUnavailable": "Релей недоступен (sender certificate)", "errorRelayUnavailable": "Релей недоступен (sender certificate)",
"errorNotYetRegistered": "Šepot ещё подключается к релею — попробуйте отправить сообщение через пару секунд.",
"errorContactLinkIncomplete": "Ссылка неполная — собеседник создал её до подключения. Попросите у него новую ссылку sepot://.",
"errorAttachmentTooLarge": "Вложение слишком большое (макс. {maxMb} МБ)", "errorAttachmentTooLarge": "Вложение слишком большое (макс. {maxMb} МБ)",
"errorGroupNeedsMember": "В группе должен быть хотя бы 1 участник", "errorGroupNeedsMember": "В группе должен быть хотя бы 1 участник",
"errorGroupMaxMembers": "Максимальное число участников группы — {maxMembers}", "errorGroupMaxMembers": "Максимальное число участников группы — {maxMembers}",
@@ -269,6 +271,9 @@
"pushModeFcm": "Google (FCM)", "pushModeFcm": "Google (FCM)",
"pushModeFcmDesc": "Надёжно и без дополнительных приложений. Push-токен устройства передаётся Google; содержимое сообщений — никогда.", "pushModeFcmDesc": "Надёжно и без дополнительных приложений. Push-токен устройства передаётся Google; содержимое сообщений — никогда.",
"pushSetupFailed": "Не удалось настроить push: {error}", "pushSetupFailed": "Не удалось настроить push: {error}",
"pushHintOff": "Сообщения придут, только когда откроешь Šepot — сигналы не настроены.",
"pushHintFailed": "Сигналы не работают: сообщения увидишь только после открытия Šepot.",
"pushRegistrationFailed": "Сигналы не приходят — дистрибьютор отклонил регистрацию. Попробуй выключить и снова включить push.",
"pushNoDistributorTitle": "Нет дистрибьютора", "pushNoDistributorTitle": "Нет дистрибьютора",
"pushNoDistributorBody": "Для уведомлений без сервисов Google установите дистрибьютор UnifiedPush, например ntfy (F-Droid или Play). Затем включите push снова.", "pushNoDistributorBody": "Для уведомлений без сервисов Google установите дистрибьютор UnifiedPush, например ntfy (F-Droid или Play). Затем включите push снова.",
"commonOk": "OK", "commonOk": "OK",
@@ -294,6 +299,8 @@
"chatListArchive": "Архивировать", "chatListArchive": "Архивировать",
"qrShareTitle": "Мой Šepot ID", "qrShareTitle": "Мой Šepot ID",
"qrIdentityNotFound": "Идентичность не найдена", "qrIdentityNotFound": "Идентичность не найдена",
"qrShareConnectingTitle": "Подключение к релею…",
"qrShareConnectingBody": "Ваш QR-код и ссылка появятся, как только Šepot подключится в первый раз. Подождите пару секунд.",
"commonCopiedToClipboard": "Скопировано в буфер обмена", "commonCopiedToClipboard": "Скопировано в буфер обмена",
"qrShareInviteMessage": "Добавьте меня в Šepot!\n\n{shareString}", "qrShareInviteMessage": "Добавьте меня в Šepot!\n\n{shareString}",
"commonShare": "Поделиться", "commonShare": "Поделиться",
@@ -554,6 +554,18 @@ abstract class AppLocalizations {
/// **'Relay nedostupný (sender certificate)'** /// **'Relay nedostupný (sender certificate)'**
String get errorRelayUnavailable; 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. /// 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: /// In cs, this message translates to:
@@ -1718,6 +1730,24 @@ abstract class AppLocalizations {
/// **'Nastavení pushů selhalo: {error}'** /// **'Nastavení pushů selhalo: {error}'**
String pushSetupFailed(String 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 /// Titulek AlertDialog, když v systému není nainstalovaný žádný UnifiedPush distributor
/// ///
/// In cs, this message translates to: /// In cs, this message translates to:
@@ -1862,6 +1892,18 @@ abstract class AppLocalizations {
/// **'Můj Šepot ID'** /// **'Můj Šepot ID'**
String get qrShareTitle; 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) /// Text uprostřed obrazovky, když identityProvider vrátí null (nemělo by běžně nastat)
/// ///
/// In cs, this message translates to: /// In cs, this message translates to:
@@ -276,6 +276,14 @@ class AppLocalizationsCs extends AppLocalizations {
@override @override
String get errorRelayUnavailable => 'Relay nedostupný (sender certificate)'; 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 @override
String errorAttachmentTooLarge(int maxMb) { String errorAttachmentTooLarge(int maxMb) {
return 'Příloha je příliš velká (max $maxMb MB)'; return 'Příloha je příliš velká (max $maxMb MB)';
@@ -953,6 +961,18 @@ class AppLocalizationsCs extends AppLocalizations {
return 'Nastavení pushů selhalo: $error'; 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 @override
String get pushNoDistributorTitle => 'Žádný distributor'; String get pushNoDistributorTitle => 'Žádný distributor';
@@ -1048,6 +1068,13 @@ class AppLocalizationsCs extends AppLocalizations {
@override @override
String get qrShareTitle => 'Můj Šepot ID'; 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 @override
String get qrIdentityNotFound => 'Identita nebyla nalezena'; String get qrIdentityNotFound => 'Identita nebyla nalezena';
@@ -277,6 +277,14 @@ class AppLocalizationsDe extends AppLocalizations {
String get errorRelayUnavailable => String get errorRelayUnavailable =>
'Relay nicht erreichbar (Sender-Zertifikat)'; '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 @override
String errorAttachmentTooLarge(int maxMb) { String errorAttachmentTooLarge(int maxMb) {
return 'Anhang ist zu groß (max. $maxMb MB)'; return 'Anhang ist zu groß (max. $maxMb MB)';
@@ -972,6 +980,18 @@ class AppLocalizationsDe extends AppLocalizations {
return 'Einrichtung der Push-Benachrichtigungen fehlgeschlagen: $error'; 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 @override
String get pushNoDistributorTitle => 'Kein Distributor'; String get pushNoDistributorTitle => 'Kein Distributor';
@@ -1067,6 +1087,13 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get qrShareTitle => 'Meine Šepot-ID'; 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 @override
String get qrIdentityNotFound => 'Identität nicht gefunden'; String get qrIdentityNotFound => 'Identität nicht gefunden';
@@ -274,6 +274,14 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get errorRelayUnavailable => 'Relay unavailable (sender certificate)'; 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 @override
String errorAttachmentTooLarge(int maxMb) { String errorAttachmentTooLarge(int maxMb) {
return 'Attachment is too large (max $maxMb MB)'; return 'Attachment is too large (max $maxMb MB)';
@@ -951,6 +959,18 @@ class AppLocalizationsEn extends AppLocalizations {
return 'Setting up push failed: $error'; 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 @override
String get pushNoDistributorTitle => 'No distributor'; String get pushNoDistributorTitle => 'No distributor';
@@ -1044,6 +1064,13 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get qrShareTitle => 'My Šepot ID'; 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 @override
String get qrIdentityNotFound => 'Identity not found'; String get qrIdentityNotFound => 'Identity not found';
@@ -276,6 +276,14 @@ class AppLocalizationsIt extends AppLocalizations {
String get errorRelayUnavailable => String get errorRelayUnavailable =>
'Relay non disponibile (sender certificate)'; '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 @override
String errorAttachmentTooLarge(int maxMb) { String errorAttachmentTooLarge(int maxMb) {
return 'L\'allegato è troppo grande (max $maxMb MB)'; return 'L\'allegato è troppo grande (max $maxMb MB)';
@@ -961,6 +969,18 @@ class AppLocalizationsIt extends AppLocalizations {
return 'Configurazione delle notifiche push non riuscita: $error'; 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 @override
String get pushNoDistributorTitle => 'Nessun distributore'; String get pushNoDistributorTitle => 'Nessun distributore';
@@ -1055,6 +1075,13 @@ class AppLocalizationsIt extends AppLocalizations {
@override @override
String get qrShareTitle => 'Il mio Šepot ID'; 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 @override
String get qrIdentityNotFound => 'Identità non trovata'; String get qrIdentityNotFound => 'Identità non trovata';
@@ -276,6 +276,14 @@ class AppLocalizationsRu extends AppLocalizations {
@override @override
String get errorRelayUnavailable => 'Релей недоступен (sender certificate)'; String get errorRelayUnavailable => 'Релей недоступен (sender certificate)';
@override
String get errorNotYetRegistered =>
'Šepot ещё подключается к релею — попробуйте отправить сообщение через пару секунд.';
@override
String get errorContactLinkIncomplete =>
'Ссылка неполная — собеседник создал её до подключения. Попросите у него новую ссылку sepot://.';
@override @override
String errorAttachmentTooLarge(int maxMb) { String errorAttachmentTooLarge(int maxMb) {
return 'Вложение слишком большое (макс. $maxMb МБ)'; return 'Вложение слишком большое (макс. $maxMb МБ)';
@@ -968,6 +976,18 @@ class AppLocalizationsRu extends AppLocalizations {
return 'Не удалось настроить push: $error'; return 'Не удалось настроить push: $error';
} }
@override
String get pushHintOff =>
'Сообщения придут, только когда откроешь Šepot — сигналы не настроены.';
@override
String get pushHintFailed =>
'Сигналы не работают: сообщения увидишь только после открытия Šepot.';
@override
String get pushRegistrationFailed =>
'Сигналы не приходят — дистрибьютор отклонил регистрацию. Попробуй выключить и снова включить push.';
@override @override
String get pushNoDistributorTitle => 'Нет дистрибьютора'; String get pushNoDistributorTitle => 'Нет дистрибьютора';
@@ -1072,6 +1092,13 @@ class AppLocalizationsRu extends AppLocalizations {
@override @override
String get qrShareTitle => 'Мой Šepot ID'; String get qrShareTitle => 'Мой Šepot ID';
@override
String get qrShareConnectingTitle => 'Подключение к релею…';
@override
String get qrShareConnectingBody =>
'Ваш QR-код и ссылка появятся, как только Šepot подключится в первый раз. Подождите пару секунд.';
@override @override
String get qrIdentityNotFound => 'Идентичность не найдена'; String get qrIdentityNotFound => 'Идентичность не найдена';
+8 -2
View File
@@ -39,10 +39,16 @@ class Conversation {
.length; .length;
} }
/// Přidej zprávu /// Přidej zprávu. Vrací false, když už zpráva s tímhle id v konverzaci je.
void addMessage(Message message) { ///
/// 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); messages.add(message);
lastActivity = DateTime.now(); lastActivity = DateTime.now();
return true;
} }
/// Nahraď zprávu se stejným id (změna stavu přílohy, doručenka…). /// Nahraď zprávu se stejným id (změna stavu přílohy, doručenka…).
+9
View File
@@ -17,6 +17,15 @@ class ContactsNotifier extends AsyncNotifier<List<Contact>> {
final storage = ref.read(storageProvider); final storage = ref.read(storageProvider);
final contacts = await future; 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 // Kontrola duplicity
if (contacts.any((c) => c.publicIdentity.id == identity.id)) { if (contacts.any((c) => c.publicIdentity.id == identity.id)) {
throw const ContactAlreadyExistsError(); throw const ContactAlreadyExistsError();
@@ -23,6 +23,14 @@ final messagingStartProvider = FutureProvider<void>((ref) async {
final identity = await ref.watch(identityProvider.future); final identity = await ref.watch(identityProvider.future);
if (identity == null) return; 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); final service = ref.read(messagingServiceProvider);
if (service.isStarted) return; if (service.isStarted) return;
+29
View File
@@ -1,6 +1,7 @@
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:sepot/core/push/push_service.dart'; import 'package:sepot/core/push/push_service.dart';
import 'package:sepot/providers/messaging_provider.dart'; import 'package:sepot/providers/messaging_provider.dart';
import 'package:sepot/providers/storage_provider.dart';
/// Push vrstva nad messagingem. /// Push vrstva nad messagingem.
final pushServiceProvider = Provider<PushService>((ref) { final pushServiceProvider = Provider<PushService>((ref) {
@@ -18,3 +19,31 @@ final pushInitProvider = FutureProvider<void>((ref) async {
// re-registruje u relaye po každém startu. // re-registruje u relaye po každém startu.
await push.restoreMode(); 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 <file_selector_linux/file_selector_plugin.h>
#include <flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h> #include <flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h>
#include <record_linux/record_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 <url_launcher_linux/url_launcher_plugin.h>
#include <window_manager/window_manager_plugin.h>
void fl_register_plugins(FlPluginRegistry* registry) { void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) audioplayers_linux_registrar = g_autoptr(FlPluginRegistrar) audioplayers_linux_registrar =
@@ -27,13 +25,7 @@ void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) record_linux_registrar = g_autoptr(FlPluginRegistrar) record_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "RecordLinuxPlugin"); fl_plugin_registry_get_registrar_for_plugin(registry, "RecordLinuxPlugin");
record_linux_plugin_register_with_registrar(record_linux_registrar); 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 = g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); 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 file_selector_linux
flutter_secure_storage_linux flutter_secure_storage_linux
record_linux record_linux
screen_retriever_linux
url_launcher_linux url_launcher_linux
window_manager
) )
list(APPEND FLUTTER_FFI_PLUGIN_LIST list(APPEND FLUTTER_FFI_PLUGIN_LIST
+6 -102
View File
@@ -193,14 +193,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "8.12.3" 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: characters:
dependency: transitive dependency: transitive
description: description:
@@ -924,14 +916,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.17.6" 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: objective_c:
dependency: transitive dependency: transitive
description: description:
@@ -1196,46 +1180,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.5.2" 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: share_plus:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -1413,42 +1357,26 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: unifiedpush name: unifiedpush
sha256: "8ed9767f750a1dc6159a77e2171641d0cb825dc87682d1ce1b8618689b79f58e" sha256: b52a0eff848d0446b9d521e0ad2f7bc76c4dda88d0f6ac0a79f1765ccfe37939
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.2.0" version: "6.1.0"
unifiedpush_android: unifiedpush_android:
dependency: transitive dependency: transitive
description: description:
name: unifiedpush_android name: unifiedpush_android
sha256: "2e6684e0a1a39ad8d6c7bc0d197954d50686acb1b1a7614b18ab0e0126f5492a" sha256: "2af6a09a5fb9fdd97b61e704a587e88abcdb5f7ee2aea9a2c35e80159f9d586d"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.4.1" version: "3.3.1"
unifiedpush_linux:
dependency: transitive
description:
name: unifiedpush_linux
sha256: c062d5eedd1cec70bcd33270cc4e01ae0ff6501f33d471167c06b34a968adfeb
url: "https://pub.dev"
source: hosted
version: "1.0.0"
unifiedpush_platform_interface: unifiedpush_platform_interface:
dependency: transitive dependency: transitive
description: description:
name: unifiedpush_platform_interface name: unifiedpush_platform_interface
sha256: "83372bc8d794b8b12ef6993b518d7be907dcfc2191bdf6de0ece5c4445d89880" sha256: "5aa3eefc2699e4d8db6d723f0cfdfb56ac850487549a0b3b83574f4a89f33aab"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.0.0" version: "3.1.0"
unifiedpush_storage_interface:
dependency: transitive
description:
name: unifiedpush_storage_interface
sha256: b8d423a4695efc616aa21d8ab48fb5ef99d6288c68b56282b8faac1579ceabd9
url: "https://pub.dev"
source: hosted
version: "1.0.0"
universal_io: universal_io:
dependency: transitive dependency: transitive
description: description:
@@ -1585,14 +1513,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.3" version: "3.0.3"
webcrypto:
dependency: transitive
description:
name: webcrypto
sha256: "9885ed99a846191542dd6ca820c83a098117df56be57453832233a3fef68f3f2"
url: "https://pub.dev"
source: hosted
version: "0.6.1"
webdriver: webdriver:
dependency: transitive dependency: transitive
description: description:
@@ -1601,14 +1521,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.1.0" 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: win32:
dependency: transitive dependency: transitive
description: description:
@@ -1617,14 +1529,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "5.15.0" 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: xdg_directories:
dependency: transitive dependency: transitive
description: description:
+5 -2
View File
@@ -1,7 +1,7 @@
name: sepot name: sepot
description: "Šepot — Soukromý messenger s E2E šifrováním" description: "Šepot — Soukromý messenger s E2E šifrováním"
publish_to: 'none' publish_to: 'none'
version: 1.1.0+20 version: 1.1.2+22
environment: environment:
sdk: ^3.10.8 sdk: ^3.10.8
@@ -37,7 +37,10 @@ dependencies:
pointycastle: ^3.9.1 pointycastle: ^3.9.1
protobuf: ^6.0.0 protobuf: ^6.0.0
sqlite3: ^3.3.4 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 image: ^4.8.0
mime: ^2.0.0 mime: ^2.0.0
image_picker: ^1.2.3 image_picker: ^1.2.3
+43
View File
@@ -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í');
});
}
+23
View File
@@ -133,5 +133,28 @@ void main() {
expect(PublicIdentity.fromShareString('sepot://add/%%%'), isNull); expect(PublicIdentity.fromShareString('sepot://add/%%%'), isNull);
expect(PublicIdentity.fromShareString(''), 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 <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
#include <local_auth_windows/local_auth_plugin.h> #include <local_auth_windows/local_auth_plugin.h>
#include <record_windows/record_windows_plugin_c_api.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 <share_plus/share_plus_windows_plugin_c_api.h>
#include <url_launcher_windows/url_launcher_windows.h> #include <url_launcher_windows/url_launcher_windows.h>
#include <window_manager/window_manager_plugin.h>
void RegisterPlugins(flutter::PluginRegistry* registry) { void RegisterPlugins(flutter::PluginRegistry* registry) {
AudioplayersWindowsPluginRegisterWithRegistrar( AudioplayersWindowsPluginRegisterWithRegistrar(
@@ -30,12 +28,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
registry->GetRegistrarForPlugin("LocalAuthPlugin")); registry->GetRegistrarForPlugin("LocalAuthPlugin"));
RecordWindowsPluginCApiRegisterWithRegistrar( RecordWindowsPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("RecordWindowsPluginCApi")); registry->GetRegistrarForPlugin("RecordWindowsPluginCApi"));
ScreenRetrieverWindowsPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("ScreenRetrieverWindowsPluginCApi"));
SharePlusWindowsPluginCApiRegisterWithRegistrar( SharePlusWindowsPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("SharePlusWindowsPluginCApi")); registry->GetRegistrarForPlugin("SharePlusWindowsPluginCApi"));
UrlLauncherWindowsRegisterWithRegistrar( UrlLauncherWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("UrlLauncherWindows")); registry->GetRegistrarForPlugin("UrlLauncherWindows"));
WindowManagerPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("WindowManagerPlugin"));
} }
@@ -9,10 +9,8 @@ list(APPEND FLUTTER_PLUGIN_LIST
flutter_secure_storage_windows flutter_secure_storage_windows
local_auth_windows local_auth_windows
record_windows record_windows
screen_retriever_windows
share_plus share_plus
url_launcher_windows url_launcher_windows
window_manager
) )
list(APPEND FLUTTER_FFI_PLUGIN_LIST list(APPEND FLUTTER_FFI_PLUGIN_LIST
+7
View File
@@ -22,6 +22,13 @@ services:
DATABASE_URL: postgres://sepot:${POSTGRES_PASSWORD}@db:5432/sepot_relay DATABASE_URL: postgres://sepot:${POSTGRES_PASSWORD}@db:5432/sepot_relay
SEPOT_BIND: 0.0.0.0:8470 SEPOT_BIND: 0.0.0.0:8470
SEPOT_ATTACHMENTS_DIR: /var/lib/sepot/attachments 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 # 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) # serveru (fcm-sa.json vedle .env, chmod 600, NIKDY do gitu)
SEPOT_FCM_CREDENTIALS: /etc/sepot/fcm-sa.json SEPOT_FCM_CREDENTIALS: /etc/sepot/fcm-sa.json
+1
View File
@@ -2456,6 +2456,7 @@ dependencies = [
"dashmap", "dashmap",
"futures-util", "futures-util",
"hex", "hex",
"ipnet",
"jsonwebtoken", "jsonwebtoken",
"libsignal-core", "libsignal-core",
"libsignal-protocol", "libsignal-protocol",
+2
View File
@@ -22,6 +22,8 @@ sqlx = { version = "0.8", default-features = false, features = [
"macros", "macros",
] } ] }
dashmap = "6" dashmap = "6"
# CIDR match trusted proxy sítí pro anti-abuse klíč (viz net.rs)
ipnet = "2"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
base64 = "0.22" base64 = "0.22"
+4 -2
View File
@@ -123,11 +123,13 @@ impl ApnsSender {
.body(r#"{"aps":{"content-available":1}}"#) .body(r#"{"aps":{"content-available":1}}"#)
.send() .send()
.await; .await;
// warn, ne debug — viz shodný komentář ve fcm.rs: na RUST_LOG=info
// bylo selhání pushe neviditelné.
match resp { match resp {
Ok(r) if !r.status().is_success() => { 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}"),
_ => {} _ => {}
} }
} }
+2
View File
@@ -302,6 +302,7 @@ mod db_tests {
let certs = crate::certs::CertAuthority::init(&pool).await.unwrap(); let certs = crate::certs::CertAuthority::init(&pool).await.unwrap();
let dir = std::env::temp_dir().join(format!("sepot-att-test-{}", std::process::id())); let dir = std::env::temp_dir().join(format!("sepot-att-test-{}", std::process::id()));
std::fs::create_dir_all(dir.join("tmp")).unwrap(); std::fs::create_dir_all(dir.join("tmp")).unwrap();
let trusted = std::sync::Arc::new(crate::net::TrustedProxies::from_env());
AppState::new( AppState::new(
pool, pool,
certs, certs,
@@ -309,6 +310,7 @@ mod db_tests {
dir, dir,
max_bytes: 1024, max_bytes: 1024,
}, },
trusted,
) )
} }
+17 -6
View File
@@ -19,8 +19,18 @@ pub struct CertAuthority {
impl CertAuthority { impl CertAuthority {
/// Načte klíče z DB; při prvním startu je vygeneruje a uloží. /// 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> { pub async fn init(pool: &PgPool) -> anyhow::Result<Self> {
let row: Option<(Vec<u8>, Vec<u8>, Vec<u8>)> = sqlx::query_as( 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", "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}"))? .map_err(|e| anyhow::anyhow!("serialized: {e}"))?
.to_vec(); .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( sqlx::query(
"INSERT INTO relay_keys "INSERT INTO relay_keys
(id, trust_root_public, trust_root_private, server_public, server_private, server_certificate) (id, trust_root_public, server_public, server_private, server_certificate)
VALUES (1, $1, $2, $3, $4, $5)", VALUES (1, $1, $2, $3, $4)",
) )
.bind(trust_root.public_key.serialize().to_vec()) .bind(trust_root.public_key.serialize().to_vec())
.bind(trust_root.private_key.serialize())
.bind(server.public_key.serialize().to_vec()) .bind(server.public_key.serialize().to_vec())
.bind(server.private_key.serialize()) .bind(server.private_key.serialize())
.bind(&server_cert_bytes) .bind(&server_cert_bytes)
.execute(pool) .execute(pool)
.await?; .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 { Ok(Self {
trust_root_public: trust_root.public_key.serialize().to_vec(), trust_root_public: trust_root.public_key.serialize().to_vec(),
server_private: server.private_key, server_private: server.private_key,
+6 -2
View File
@@ -143,11 +143,15 @@ impl FcmSender {
"android": { "priority": "HIGH" } "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 { match http.post(&url).bearer_auth(access).json(&body).send().await {
Ok(resp) if !resp.status().is_success() => { 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}"),
_ => {} _ => {}
} }
} }
+21 -13
View File
@@ -9,6 +9,7 @@ mod apns;
mod attachments; mod attachments;
mod certs; mod certs;
mod fcm; mod fcm;
mod net;
mod push; mod push;
mod rest; mod rest;
mod state; mod state;
@@ -24,8 +25,9 @@ use axum::routing::{delete, get, post};
use axum::Router; use axum::Router;
use sqlx::postgres::PgPoolOptions; use sqlx::postgres::PgPoolOptions;
use tower_governor::governor::GovernorConfigBuilder; use tower_governor::governor::GovernorConfigBuilder;
use tower_governor::key_extractor::SmartIpKeyExtractor;
use tower_governor::GovernorLayer; use tower_governor::GovernorLayer;
use crate::net::{TrustedProxies, TrustedProxyIp};
use tower_http::timeout::TimeoutLayer; use tower_http::timeout::TimeoutLayer;
use tracing_subscriber::EnvFilter; use tracing_subscriber::EnvFilter;
@@ -34,7 +36,7 @@ const MAX_BODY_BYTES: usize = 64 * 1024;
#[tokio::main] #[tokio::main]
async fn main() -> anyhow::Result<()> { 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() tracing_subscriber::fmt()
.with_env_filter( .with_env_filter(
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")), 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 certs = certs::CertAuthority::init(&pool).await?;
let attachments_cfg = attachments::Config::from_env(); let attachments_cfg = attachments::Config::from_env();
tokio::fs::create_dir_all(attachments_cfg.dir.join("tmp")).await?; 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()); 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 // Klíč = klientská IP přes TrustedProxyIp: X-Forwarded-For/X-Real-Ip se
// relay dosáhne jen traefik z docker sítě), jinak peer adresa. // 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). // 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) // registrace: zápis do DB bez auth → nejpřísnější (burst 10, +1/5 min)
let gov_register = Arc::new( let gov_register = Arc::new(
GovernorConfigBuilder::default() GovernorConfigBuilder::default()
.key_extractor(SmartIpKeyExtractor) .key_extractor(TrustedProxyIp(trusted.clone()))
.burst_size(10) .burst_size(10)
.period(Duration::from_secs(300)) .period(Duration::from_secs(300))
.finish() .finish()
@@ -79,7 +87,7 @@ async fn main() -> anyhow::Result<()> {
// čtecí API (bundle žere OTP klíče → drain ochrana): burst 60, +2/s // čtecí API (bundle žere OTP klíče → drain ochrana): burst 60, +2/s
let gov_general = Arc::new( let gov_general = Arc::new(
GovernorConfigBuilder::default() GovernorConfigBuilder::default()
.key_extractor(SmartIpKeyExtractor) .key_extractor(TrustedProxyIp(trusted.clone()))
.burst_size(60) .burst_size(60)
.per_second(2) .per_second(2)
.finish() .finish()
@@ -89,7 +97,7 @@ async fn main() -> anyhow::Result<()> {
// přílohy: upload je autentizovaný, ale drahý (disk) → burst 30, +1/s // přílohy: upload je autentizovaný, ale drahý (disk) → burst 30, +1/s
let gov_attachments = Arc::new( let gov_attachments = Arc::new(
GovernorConfigBuilder::default() GovernorConfigBuilder::default()
.key_extractor(SmartIpKeyExtractor) .key_extractor(TrustedProxyIp(trusted.clone()))
.burst_size(30) .burst_size(30)
.per_second(1) .per_second(1)
.finish() .finish()
+194
View File
@@ -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"));
}
}
+12 -2
View File
@@ -85,13 +85,23 @@ pub async fn send_wake(
/// Samotný POST budíčku (bez guardu — ten řeší volající). Oddělené kvůli /// Samotný POST budíčku (bez guardu — ten řeší volající). Oddělené kvůli
/// testovatelnosti proti loopback mocku (guard by loopback jinak odmítl). /// testovatelnosti proti loopback mocku (guard by loopback jinak odmítl).
async fn post_wake(client: &Client, url: &str) { async fn post_wake(client: &Client, url: &str) {
let _ = client let resp = client
.post(url) .post(url)
.header("content-type", "application/json") .header("content-type", "application/json")
.header("ttl", "2419200") // WebPush TTL (28 dní), ntfy ignoruje .header("ttl", "2419200") // WebPush TTL (28 dní), ntfy ignoruje
.body(WAKE_BODY) .body(WAKE_BODY)
.send() .send()
.await; .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). /// Povolit jen bezpečné cíle. https vždy; http jen pro .onion (samo šifruje).
@@ -191,7 +201,7 @@ mod tests {
#[test] #[test]
fn verejne_adresy_projdou() { 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í"); assert!(!is_internal_ip(&ip.parse().unwrap()), "{ip} nemělo být interní");
} }
} }
+457 -22
View File
@@ -1,4 +1,6 @@
use chrono::{Duration, Utc}; use std::sync::Arc;
use chrono::{DateTime, Duration, Utc};
use dashmap::DashMap; use dashmap::DashMap;
use prost::Message; use prost::Message;
use sepot_proto::v1; use sepot_proto::v1;
@@ -7,18 +9,80 @@ use sqlx::PgPool;
use tokio::sync::mpsc; use tokio::sync::mpsc;
use crate::certs::CertAuthority; use crate::certs::CertAuthority;
use crate::net::TrustedProxies;
/// TTL offline fronty. /// TTL offline fronty.
pub const QUEUE_TTL_DAYS: i64 = 30; 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 struct AppState {
pub pool: PgPool, pub pool: PgPool,
/// delivery_address → kanál na živé WS spojení zařízení. /// Kdy proces nastartoval — základ monotónních značek v [`OnlineDevice`].
pub online: DashMap<Vec<u8>, mpsc::Sender<v1::ServerFrame>>, 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). /// klientská IP → počet souběžných WS spojení (anti-flood cap).
/// Jen v paměti, nikam se neloguje ani neukládá. /// Jen v paměti, nikam se neloguje ani neukládá.
pub ws_conns: DashMap<std::net::IpAddr, u32>, pub ws_conns: DashMap<std::net::IpAddr, u32>,
pub certs: CertAuthority, 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). /// HTTP klient na push budíčky (bez redirectů, krátký timeout).
pub http: reqwest::Client, pub http: reqwest::Client,
/// Úložiště šifrovaných příloh (adresář + strop velikosti). /// Úložiště šifrovaných příloh (adresář + strop velikosti).
@@ -30,12 +94,19 @@ pub struct AppState {
} }
impl 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 { Self {
pool, pool,
started: std::time::Instant::now(),
online: DashMap::new(), online: DashMap::new(),
ws_conns: DashMap::new(), ws_conns: DashMap::new(),
certs, certs,
trusted_proxies,
http: crate::push::build_client(), http: crate::push::build_client(),
attachments, attachments,
fcm: crate::fcm::FcmSender::from_env(), fcm: crate::fcm::FcmSender::from_env(),
@@ -71,20 +142,57 @@ impl AppState {
.bind(expires) .bind(expires)
.execute(&self.pool) .execute(&self.pool)
.await?; .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 { let deliver = v1::ServerFrame {
frame: Some(v1::server_frame::Frame::Deliver(env)), 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); 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; self.wake(&dst).await;
Ok(v1::receipt::Status::StoredOffline) 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ý /// Budíček pro offline příjemce: dohledá jeho push token a pošle prázdný
/// „ťuk". Best-effort, na pozadí — neblokuje receipt odesílateli. /// „ťuk". Best-effort, na pozadí — neblokuje receipt odesílateli.
/// Endpoint se NELOGUJE. /// Endpoint se NELOGUJE.
@@ -172,32 +280,118 @@ impl AppState {
Ok(()) 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( pub async fn flush_queue(
&self, &self,
delivery_address: &[u8], delivery_address: &[u8],
tx: &mpsc::Sender<v1::ServerFrame>, tx: &mpsc::Sender<v1::ServerFrame>,
) -> anyhow::Result<usize> { ) -> anyhow::Result<usize> {
let rows: Vec<(Vec<u8>,)> = sqlx::query_as( // Obálky, které strop pokusů vyčerpaly, jsou neprůchodné (skoro vždy
"SELECT envelope FROM message_queue // ztracený ACK po úspěšném dešifrování) — zahodit dřív, než se znovu
WHERE dst_address = $1 AND expires_at > now() // pošlou. Maže se až tady, ne hned po inkrementu: poslední pokus
ORDER BY enqueued_at", // 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) .bind(delivery_address)
.fetch_all(&self.pool) .bind(MAX_DELIVERY_ATTEMPTS)
.await?; .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(); let mut total = 0usize;
for (encoded,) in rows { // (enqueued_at, msg_uuid) poslední odeslané obálky = kurzor další dávky.
let env = v1::Envelope::decode(encoded.as_slice())?; let mut cursor: Option<(DateTime<Utc>, Vec<u8>)> = None;
let frame = v1::ServerFrame { loop {
frame: Some(v1::server_frame::Frame::Deliver(env)), 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?
}
}; };
if tx.send(frame).await.is_err() { let batch_len = rows.len();
if batch_len == 0 {
break; 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() {
// 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í). /// ACK příjemce → hard-delete z fronty (žádná retence po doručení).
@@ -228,7 +422,8 @@ mod db_tests {
.await .await
.expect("dev DB"); .expect("dev DB");
let certs = crate::certs::CertAuthority::init(&pool).await.unwrap(); 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> { fn rand_bytes(n: usize) -> Vec<u8> {
@@ -413,4 +608,244 @@ mod db_tests {
// idempotence: druhé smazání neexistujícího účtu nesmí spadnout // idempotence: druhé smazání neexistujícího účtu nesmí spadnout
state.delete_identity(&account_id).await.unwrap(); 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();
}
} }
+66 -33
View File
@@ -12,6 +12,7 @@ use std::net::{IpAddr, SocketAddr};
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
use axum::body::Bytes;
use axum::extract::ws::{Message as WsMessage, WebSocket, WebSocketUpgrade}; use axum::extract::ws::{Message as WsMessage, WebSocket, WebSocketUpgrade};
use axum::extract::{ConnectInfo, State}; use axum::extract::{ConnectInfo, State};
use axum::http::{HeaderMap, StatusCode}; use axum::http::{HeaderMap, StatusCode};
@@ -26,38 +27,26 @@ use tokio::time::Instant;
use crate::state::AppState; use crate::state::AppState;
/// Max souběžných WS spojení z jedné IP (za traefikem = reálná klientská /// Max souběžných WS spojení z jedné IP. Klientská IP jde přes trusted-proxy
/// IP z X-Real-Ip; tor kontejner sdílí jednu — proto ne moc těsně). /// 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; const MAX_CONNS_PER_IP: u32 = 32;
/// Neautentizované spojení zavřít po tomto čase. /// Neautentizované spojení zavřít po tomto čase.
const AUTH_DEADLINE: Duration = Duration::from_secs(10); const AUTH_DEADLINE: Duration = Duration::from_secs(10);
/// Rate limit rámců od klienta: max FRAME_WINDOW_MAX za FRAME_WINDOW. /// Rate limit rámců od klienta: max FRAME_WINDOW_MAX za FRAME_WINDOW.
const FRAME_WINDOW: Duration = Duration::from_secs(10); const FRAME_WINDOW: Duration = Duration::from_secs(10);
const FRAME_WINDOW_MAX: u32 = 200; const FRAME_WINDOW_MAX: u32 = 200;
/// Jak často server posílá WS Ping autentizovanému klientovi.
/// Klientská IP: hlavičky od trusted proxy (na relay dosáhne jen traefik
/// z docker sítě), fallback peer adresa (dev/tor).
/// ///
/// TRUST MODEL — POZOR: X-Real-Ip / X-Forwarded-For se přebírají bezpodmínečně /// Mobilní NAT zahazuje nečinné TCP tiše — bez keepalive se ani jedna strana
/// a řídí per-IP limity (cap spojení, rate limit). To je bezpečné VÝHRADNĚ /// nedozví, že spojení je mrtvé: klient hlásí „připojeno" a píše do prázdna,
/// proto, že před relayem stojí důvěryhodná reverzní proxy (traefik), která /// relay drží záznam v `online` a proto ani nepošle push budíček. Ping je
/// tyhle hlavičky vždy přepisuje reálnou klientskou IP. Klientem poslané /// PROTOKOLOVÝ (ne `v1::Ping`), protože na ten dart:io odpovídá Pongem sám —
/// hodnoty tak nikdy neprojdou. Kdyby byl relay vystaven přímo do internetu /// funguje tedy i s klienty, které jsou už venku a aplikační Ping neznají.
/// (bez proxy, která hlavičky sanitizuje), útočník by je mohl libovolně const PING_INTERVAL: Duration = Duration::from_secs(30);
/// podvrhnout, tím rozprostřít provoz přes falešné IP a všechny per-IP limity /// Bez jediného rámce od klienta (stačí Pong) je spojení mrtvé. Tři zmeškané
/// obejít. Relay proto NIKDY nenasazovat bez takové proxy. /// pingy — velkoryse, ať krátký výpadek mobilní sítě spojení nezabije.
fn client_ip(headers: &HeaderMap, peer: SocketAddr) -> IpAddr { const IDLE_TIMEOUT: Duration = Duration::from_secs(95);
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())
}
/// Drop-guard: úbytek čítače spojení i při panice/cancelu handleru. /// Drop-guard: úbytek čítače spojení i při panice/cancelu handleru.
struct ConnGuard { struct ConnGuard {
@@ -80,7 +69,7 @@ pub async fn ws_handler(
headers: HeaderMap, headers: HeaderMap,
ws: WebSocketUpgrade, ws: WebSocketUpgrade,
) -> impl IntoResponse { ) -> impl IntoResponse {
let ip = client_ip(&headers, peer); let ip = state.trusted_proxies.client_ip(peer.ip(), &headers);
let over_cap = { let over_cap = {
let mut entry = state.ws_conns.entry(ip).or_insert(0); let mut entry = state.ws_conns.entry(ip).or_insert(0);
@@ -134,11 +123,28 @@ async fn handle(state: Arc<AppState>, socket: WebSocket) {
let (mut sink, mut stream) = socket.split(); let (mut sink, mut stream) = socket.split();
let (tx, mut rx) = mpsc::channel::<v1::ServerFrame>(64); 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 { let writer = tokio::spawn(async move {
while let Some(frame) = rx.recv().await { let mut ping = tokio::time::interval(PING_INTERVAL);
let bytes = frame.encode_to_vec(); // Zpožděné ticky se nedohánějí — po pauze chceme jeden ping, ne dávku.
if sink.send(WsMessage::Binary(bytes.into())).await.is_err() { ping.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
break; 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;
}
}
} }
} }
}); });
@@ -147,6 +153,9 @@ async fn handle(state: Arc<AppState>, socket: WebSocket) {
let mut authed_address: Option<Vec<u8>> = None; let mut authed_address: Option<Vec<u8>> = None;
// device_uuid ověřeného zařízení — klíč pro push registraci. // device_uuid ověřeného zařízení — klíč pro push registraci.
let mut authed_device: Option<Vec<u8>> = None; 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) // Anti-abuse okno: počítá VŠECHNY rámce od klienta (envelope/ack/pong)
let mut window_start = Instant::now(); let mut window_start = Instant::now();
@@ -163,10 +172,29 @@ async fn handle(state: Arc<AppState>, socket: WebSocket) {
} }
} }
} else { } 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 }; 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(); let now = Instant::now();
if now.duration_since(window_start) > FRAME_WINDOW { if now.duration_since(window_start) > FRAME_WINDOW {
window_start = now; window_start = now;
@@ -242,9 +270,14 @@ async fn handle(state: Arc<AppState>, socket: WebSocket) {
break; break;
} }
let device = Arc::new(crate::state::OnlineDevice::new(
tx.clone(),
state.now_ms(),
));
state state
.online .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()); authed_address = Some(pd.delivery_address.clone());
// Potvrzení AUTH — klient až teď smí hlásit „připojeno" // Potvrzení AUTH — klient až teď smí hlásit „připojeno"
@@ -254,7 +287,7 @@ async fn handle(state: Arc<AppState>, socket: WebSocket) {
))) )))
.await; .await;
// last_seen jen v hrubé granularitě (privacy — zaokrouhleno na hodinu). // last_seen jen v hrubé granularitě (privacy, §1.2).
let _ = sqlx::query( let _ = sqlx::query(
"UPDATE devices SET last_seen_at = date_trunc('hour', now()) "UPDATE devices SET last_seen_at = date_trunc('hour', now())
WHERE device_uuid = $1", WHERE device_uuid = $1",