Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f903e21e3a | |||
| 923a6a01bd | |||
| 9985b79aa6 | |||
| b82f50f888 | |||
| 4f2cbdbdc4 | |||
| 90a1390716 |
@@ -4,6 +4,56 @@ Všechny podstatné změny v aplikaci Šepot. Formát dle
|
|||||||
[Keep a Changelog](https://keepachangelog.com/cs/1.1.0/),
|
[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
|
||||||
|
|||||||
@@ -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
@@ -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');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,10 +333,14 @@ class RelayClient {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (frame.hasDeliver()) {
|
if (frame.hasDeliver()) {
|
||||||
|
if (!_deliverController.isClosed) {
|
||||||
_deliverController.add(frame.deliver);
|
_deliverController.add(frame.deliver);
|
||||||
|
}
|
||||||
} else if (frame.hasReceipt()) {
|
} else if (frame.hasReceipt()) {
|
||||||
|
if (!_receiptController.isClosed) {
|
||||||
_receiptController.add(frame.receipt);
|
_receiptController.add(frame.receipt);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onDone: _onDisconnected,
|
onDone: _onDisconnected,
|
||||||
onError: (Object _) => _onDisconnected(),
|
onError: (Object _) => _onDisconnected(),
|
||||||
|
|||||||
@@ -21,6 +21,15 @@ import 'package:socks5_proxy/socks_client.dart';
|
|||||||
import 'package:web_socket_channel/io.dart';
|
import 'package:web_socket_channel/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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,10 +425,16 @@ class MessagingService {
|
|||||||
await storage.addMessage(contact.id, message);
|
await storage.addMessage(contact.id, message);
|
||||||
onUpdate?.call();
|
onUpdate?.call();
|
||||||
|
|
||||||
|
// Stejné ošetření jako u textu: bez něj zůstala příloha po výpadku
|
||||||
|
// spojení viset na „odesílá se" navždy. Označit jako failed ji dostane
|
||||||
|
// do fronty odchozích ([_flushOutbox]) — blob je na relayi nahraný,
|
||||||
|
// dosílá se jen meta.
|
||||||
|
try {
|
||||||
final delivered = await _sendContent(contact, {
|
final delivered = await _sendContent(contact, {
|
||||||
'v': 1,
|
'v': 1,
|
||||||
't': 'att',
|
't': 'att',
|
||||||
'id': message.id,
|
'id': message.id,
|
||||||
|
'ts': message.timestamp.millisecondsSinceEpoch,
|
||||||
if (caption != null && caption.isNotEmpty) 'txt': caption,
|
if (caption != null && caption.isNotEmpty) 'txt': caption,
|
||||||
'ttl': ?ttl,
|
'ttl': ?ttl,
|
||||||
'att': meta.toWire(),
|
'att': meta.toWire(),
|
||||||
@@ -426,6 +448,12 @@ class MessagingService {
|
|||||||
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,10 +726,12 @@ class MessagingService {
|
|||||||
await storage.addMessage(group.id, message);
|
await storage.addMessage(group.id, message);
|
||||||
onUpdate?.call();
|
onUpdate?.call();
|
||||||
|
|
||||||
|
try {
|
||||||
final anyOk = await _fanoutToGroup(group, {
|
final anyOk = await _fanoutToGroup(group, {
|
||||||
'v': 1,
|
'v': 1,
|
||||||
't': 'txt',
|
't': 'txt',
|
||||||
'id': message.id,
|
'id': message.id,
|
||||||
|
'ts': message.timestamp.millisecondsSinceEpoch,
|
||||||
'gid': group.id,
|
'gid': group.id,
|
||||||
'txt': text,
|
'txt': text,
|
||||||
're': ?replyToId,
|
're': ?replyToId,
|
||||||
@@ -709,6 +743,14 @@ class MessagingService {
|
|||||||
await storage.updateMessage(group.id, sent);
|
await storage.updateMessage(group.id, sent);
|
||||||
onUpdate?.call();
|
onUpdate?.call();
|
||||||
return sent;
|
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,10 +783,12 @@ class MessagingService {
|
|||||||
await storage.addMessage(group.id, message);
|
await storage.addMessage(group.id, message);
|
||||||
onUpdate?.call();
|
onUpdate?.call();
|
||||||
|
|
||||||
|
try {
|
||||||
final anyOk = await _fanoutToGroup(group, {
|
final anyOk = await _fanoutToGroup(group, {
|
||||||
'v': 1,
|
'v': 1,
|
||||||
't': 'att',
|
't': 'att',
|
||||||
'id': message.id,
|
'id': message.id,
|
||||||
|
'ts': message.timestamp.millisecondsSinceEpoch,
|
||||||
'gid': group.id,
|
'gid': group.id,
|
||||||
if (caption != null && caption.isNotEmpty) 'txt': caption,
|
if (caption != null && caption.isNotEmpty) 'txt': caption,
|
||||||
'ttl': ?ttl,
|
'ttl': ?ttl,
|
||||||
@@ -756,6 +800,12 @@ class MessagingService {
|
|||||||
await storage.updateMessage(group.id, sent);
|
await storage.updateMessage(group.id, sent);
|
||||||
onUpdate?.call();
|
onUpdate?.call();
|
||||||
return sent;
|
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;
|
||||||
|
|||||||
@@ -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) {
|
||||||
@@ -96,6 +103,9 @@ class ChatListScreen extends ConsumerWidget {
|
|||||||
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,7 +163,12 @@ 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
|
||||||
|
// horší než vypnuto, protože uživatel čeká zprávy, které nepřijdou.
|
||||||
|
final failed = _registrationFailed && _mode != 'off';
|
||||||
|
final subtitle = failed
|
||||||
|
? context.l10n.pushRegistrationFailed
|
||||||
|
: switch (_mode) {
|
||||||
'up' => context.l10n.pushSubtitleUnifiedPush(
|
'up' => context.l10n.pushSubtitleUnifiedPush(
|
||||||
_distributor ?? context.l10n.pushNoDistributorFallback),
|
_distributor ?? context.l10n.pushNoDistributorFallback),
|
||||||
'fcm' => context.l10n.pushSubtitleFcm,
|
'fcm' => context.l10n.pushSubtitleFcm,
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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)"
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 => 'Идентичность не найдена';
|
||||||
|
|
||||||
|
|||||||
@@ -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…).
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:sepot/models/conversation.dart';
|
||||||
|
import 'package:sepot/models/message.dart';
|
||||||
|
|
||||||
|
/// Id zprávy nese odesílatel, takže tatáž zpráva může dorazit dvakrát:
|
||||||
|
/// odesílatel ji po výpadku spojení pošle znovu (fronta odchozích) nebo ji
|
||||||
|
/// relay doručí opakovaně, než dorazí ACK. Ani v jednom případě nesmí
|
||||||
|
/// vzniknout druhá bublina.
|
||||||
|
void main() {
|
||||||
|
Message zprava(String id, {String text = 'ahoj'}) => Message(
|
||||||
|
id: id,
|
||||||
|
senderId: 'alice',
|
||||||
|
recipientId: 'bob',
|
||||||
|
type: MessageType.text,
|
||||||
|
content: text,
|
||||||
|
timestamp: DateTime.parse('2026-08-13T09:00:00.000'),
|
||||||
|
);
|
||||||
|
|
||||||
|
test('zpráva se stejným id se podruhé nepřidá', () {
|
||||||
|
final c = Conversation(contactId: 'bob');
|
||||||
|
|
||||||
|
expect(c.addMessage(zprava('m1')), isTrue, reason: 'první doručení');
|
||||||
|
expect(c.addMessage(zprava('m1')), isFalse, reason: 'opakované doručení');
|
||||||
|
expect(c.messages.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dedup se řídí id, ne obsahem — stejný text jinou zprávou projde', () {
|
||||||
|
final c = Conversation(contactId: 'bob');
|
||||||
|
|
||||||
|
expect(c.addMessage(zprava('m1', text: 'ahoj')), isTrue);
|
||||||
|
expect(c.addMessage(zprava('m2', text: 'ahoj')), isTrue);
|
||||||
|
expect(c.messages.length, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('duplicita nechá původní zprávu být', () {
|
||||||
|
final c = Conversation(contactId: 'bob');
|
||||||
|
c.addMessage(zprava('m1', text: 'původní'));
|
||||||
|
|
||||||
|
c.addMessage(zprava('m1', text: 'přepsaný'));
|
||||||
|
|
||||||
|
expect(c.messages.single.content, 'původní');
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -133,5 +133,28 @@ void main() {
|
|||||||
expect(PublicIdentity.fromShareString('sepot://add/%%%'), isNull);
|
expect(PublicIdentity.fromShareString('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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Generated
+1
@@ -2456,6 +2456,7 @@ dependencies = [
|
|||||||
"dashmap",
|
"dashmap",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"hex",
|
"hex",
|
||||||
|
"ipnet",
|
||||||
"jsonwebtoken",
|
"jsonwebtoken",
|
||||||
"libsignal-core",
|
"libsignal-core",
|
||||||
"libsignal-protocol",
|
"libsignal-protocol",
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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}"),
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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}"),
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -0,0 +1,194 @@
|
|||||||
|
//! Určení klientské IP za reverse proxy — anti-abuse klíč.
|
||||||
|
//!
|
||||||
|
//! BEZPEČNOST (SECURITY audit #1): forwarded hlavičky (X-Forwarded-For,
|
||||||
|
//! X-Real-Ip) smí nastavit kdokoli. Relay je dosažitelný dvěma cestami:
|
||||||
|
//! clearnet přes traefik (síť `pangolin`), kde traefik klientské X-hlavičky
|
||||||
|
//! přepisuje a jsou tedy důvěryhodné; a Tor onion přes tor kontejner (síť
|
||||||
|
//! `tornet`) PŘÍMO na :8470, mimo traefik — tor forwarduje HTTP beze změny,
|
||||||
|
//! takže hlavičky si nastaví sám klient.
|
||||||
|
//!
|
||||||
|
//! Kdybychom hlavičkám věřili vždy, onion klient by si zvolil libovolnou IP
|
||||||
|
//! a obešel VŠECHNY rate-limity i WS conn-cap (registrace, fetch_bundle,
|
||||||
|
//! upload, počet spojení). Proto: hlavičku uznej JEN když peer (skutečná
|
||||||
|
//! TCP adresa spojení) padne do explicitně nakonfigurované sady trusted
|
||||||
|
//! proxy. Onion peer (tor kontejner) tam není → použije se jeho IP a všichni
|
||||||
|
//! onion klienti sdílí jeden bucket (přesně jak počítal původní komentář).
|
||||||
|
|
||||||
|
use std::net::{IpAddr, SocketAddr};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use axum::extract::ConnectInfo;
|
||||||
|
use axum::http::{HeaderMap, Request};
|
||||||
|
use ipnet::IpNet;
|
||||||
|
use tower_governor::key_extractor::KeyExtractor;
|
||||||
|
use tower_governor::GovernorError;
|
||||||
|
|
||||||
|
/// Sada sítí, jejichž forwarded hlavičkám se věří (traefik/pangolin).
|
||||||
|
/// Konfiguruje se přes `SEPOT_TRUSTED_PROXIES` (čárkou oddělené IP nebo CIDR).
|
||||||
|
pub struct TrustedProxies {
|
||||||
|
nets: Vec<IpNet>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TrustedProxies {
|
||||||
|
/// Načte z env `SEPOT_TRUSTED_PROXIES`. Prázdné/nenastavené = nevěří se
|
||||||
|
/// nikomu (vždy peer IP) — fail-safe: chybějící konfigurace nesmí otevřít
|
||||||
|
/// spoofing, maximálně sloučí clearnet klienty do jednoho bucketu.
|
||||||
|
pub fn from_env() -> Self {
|
||||||
|
let raw = std::env::var("SEPOT_TRUSTED_PROXIES").unwrap_or_default();
|
||||||
|
let nets: Vec<IpNet> = raw
|
||||||
|
.split(',')
|
||||||
|
.filter_map(|s| {
|
||||||
|
let s = s.trim();
|
||||||
|
if s.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
// "10.0.0.0/24" i holé "10.0.0.5" (bereme jako /32, resp. /128)
|
||||||
|
s.parse::<IpNet>()
|
||||||
|
.ok()
|
||||||
|
.or_else(|| s.parse::<IpAddr>().ok().map(IpNet::from))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
if nets.is_empty() {
|
||||||
|
tracing::warn!(
|
||||||
|
"SEPOT_TRUSTED_PROXIES prázdné — forwarded hlavičky se ignorují, \
|
||||||
|
anti-abuse klíč = peer IP. Za reverse proxy nastav CIDR proxy sítě."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Self { nets }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn trusts(&self, peer: IpAddr) -> bool {
|
||||||
|
self.nets.iter().any(|n| n.contains(&peer))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Klientská IP pro anti-abuse. Forwarded hlavičku uznej jen od trusted
|
||||||
|
/// proxy; jinak (onion, dev, přímé spojení) peer IP. Pořadí X-Forwarded-For
|
||||||
|
/// → X-Real-Ip drží konzistenci s governor extraktorem. Bere se PRVNÍ
|
||||||
|
/// (nejlevější) záznam XFF = původní klient — platí jen díky tomu, že
|
||||||
|
/// traefik pro netrusted klienty příchozí XFF přepisuje (nedopisuje).
|
||||||
|
pub fn client_ip(&self, peer: IpAddr, headers: &HeaderMap) -> IpAddr {
|
||||||
|
if !self.trusts(peer) {
|
||||||
|
return peer;
|
||||||
|
}
|
||||||
|
let from_header = |name: &str| {
|
||||||
|
headers
|
||||||
|
.get(name)
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.and_then(|v| v.split(',').next())
|
||||||
|
.and_then(|v| v.trim().parse::<IpAddr>().ok())
|
||||||
|
};
|
||||||
|
from_header("x-forwarded-for")
|
||||||
|
.or_else(|| from_header("x-real-ip"))
|
||||||
|
.unwrap_or(peer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Governor key extractor s ověřením trusted proxy. Nahrazuje
|
||||||
|
/// `SmartIpKeyExtractor`, který forwarded hlavičkám věří bezpodmínečně.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct TrustedProxyIp(pub Arc<TrustedProxies>);
|
||||||
|
|
||||||
|
impl KeyExtractor for TrustedProxyIp {
|
||||||
|
type Key = IpAddr;
|
||||||
|
|
||||||
|
fn extract<T>(&self, req: &Request<T>) -> Result<Self::Key, GovernorError> {
|
||||||
|
let peer = req
|
||||||
|
.extensions()
|
||||||
|
.get::<ConnectInfo<SocketAddr>>()
|
||||||
|
.map(|ci| ci.0.ip())
|
||||||
|
.ok_or(GovernorError::UnableToExtractKey)?;
|
||||||
|
Ok(self.0.client_ip(peer, req.headers()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use axum::http::header::{HeaderName, HeaderValue};
|
||||||
|
use axum::http::HeaderMap;
|
||||||
|
|
||||||
|
fn proxies(cfg: &[&str]) -> TrustedProxies {
|
||||||
|
let nets = cfg
|
||||||
|
.iter()
|
||||||
|
.filter_map(|s| {
|
||||||
|
s.parse::<IpNet>()
|
||||||
|
.ok()
|
||||||
|
.or_else(|| s.parse::<IpAddr>().ok().map(IpNet::from))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
TrustedProxies { nets }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ip(s: &str) -> IpAddr {
|
||||||
|
s.parse().unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn headers(pairs: &[(&str, &str)]) -> HeaderMap {
|
||||||
|
let mut h = HeaderMap::new();
|
||||||
|
for (k, v) in pairs {
|
||||||
|
h.insert(
|
||||||
|
HeaderName::from_bytes(k.as_bytes()).unwrap(),
|
||||||
|
HeaderValue::from_str(v).unwrap(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
h
|
||||||
|
}
|
||||||
|
|
||||||
|
/// JÁDRO auditu #1: onion peer (mimo trusted sadu) si NESMÍ nastavit IP
|
||||||
|
/// hlavičkou — anti-abuse klíč = peer IP tor kontejneru, ne spoof.
|
||||||
|
#[test]
|
||||||
|
fn onion_peer_nesmi_spoofnout_ip() {
|
||||||
|
let tp = proxies(&["10.20.0.0/16"]); // jen pangolin síť
|
||||||
|
let onion_peer = ip("172.30.0.5"); // tor kontejner na jiné síti
|
||||||
|
let spoof = headers(&[("x-real-ip", "1.2.3.4"), ("x-forwarded-for", "5.6.7.8")]);
|
||||||
|
assert_eq!(tp.client_ip(onion_peer, &spoof), onion_peer);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Trusted proxy (traefik) → forwarded hlavička se uzná, klíč = klient.
|
||||||
|
#[test]
|
||||||
|
fn trusted_proxy_uzna_forwarded() {
|
||||||
|
let tp = proxies(&["10.20.0.0/16"]);
|
||||||
|
let traefik = ip("10.20.0.3");
|
||||||
|
let h = headers(&[("x-forwarded-for", "203.0.113.9")]);
|
||||||
|
assert_eq!(tp.client_ip(traefik, &h), ip("203.0.113.9"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// XFF má přednost před X-Real-Ip (konzistence s governor extraktorem);
|
||||||
|
/// z řetězce se bere nejlevější (původní klient).
|
||||||
|
#[test]
|
||||||
|
fn xff_prednost_a_nejlevejsi() {
|
||||||
|
let tp = proxies(&["10.20.0.3"]); // holá IP = /32
|
||||||
|
let traefik = ip("10.20.0.3");
|
||||||
|
let h = headers(&[
|
||||||
|
("x-forwarded-for", "203.0.113.9, 10.20.0.3"),
|
||||||
|
("x-real-ip", "198.51.100.1"),
|
||||||
|
]);
|
||||||
|
assert_eq!(tp.client_ip(traefik, &h), ip("203.0.113.9"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Trusted proxy bez hlaviček → fallback na peer.
|
||||||
|
#[test]
|
||||||
|
fn trusted_bez_hlavicek_fallback_peer() {
|
||||||
|
let tp = proxies(&["10.20.0.0/16"]);
|
||||||
|
let traefik = ip("10.20.0.3");
|
||||||
|
assert_eq!(tp.client_ip(traefik, &HeaderMap::new()), traefik);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Prázdná konfigurace (fail-safe) → vždy peer, hlavičky se ignorují.
|
||||||
|
#[test]
|
||||||
|
fn prazdna_konfigurace_vzdy_peer() {
|
||||||
|
let tp = proxies(&[]);
|
||||||
|
let peer = ip("10.20.0.3");
|
||||||
|
let spoof = headers(&[("x-forwarded-for", "1.2.3.4")]);
|
||||||
|
assert_eq!(tp.client_ip(peer, &spoof), peer);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// IPv6 trusted CIDR taky funguje.
|
||||||
|
#[test]
|
||||||
|
fn ipv6_trusted_cidr() {
|
||||||
|
let tp = proxies(&["fd00::/8"]);
|
||||||
|
let peer = ip("fd00::1234");
|
||||||
|
let h = headers(&[("x-forwarded-for", "2001:db8::1")]);
|
||||||
|
assert_eq!(tp.client_ip(peer, &h), ip("2001:db8::1"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -85,13 +85,23 @@ pub async fn send_wake(
|
|||||||
/// Samotný POST budíčku (bez guardu — ten řeší volající). Oddělené kvůli
|
/// 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í");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+454
-19
@@ -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 mut cursor: Option<(DateTime<Utc>, Vec<u8>)> = None;
|
||||||
|
loop {
|
||||||
|
let rows: Vec<(Vec<u8>, DateTime<Utc>, Vec<u8>)> = match &cursor {
|
||||||
|
None => {
|
||||||
|
sqlx::query_as(
|
||||||
|
"SELECT envelope, enqueued_at, msg_uuid FROM message_queue
|
||||||
|
WHERE dst_address = $1 AND expires_at > now()
|
||||||
|
AND attempt_count < $2
|
||||||
|
ORDER BY enqueued_at, msg_uuid
|
||||||
|
LIMIT $3",
|
||||||
|
)
|
||||||
|
.bind(delivery_address)
|
||||||
|
.bind(MAX_DELIVERY_ATTEMPTS)
|
||||||
|
.bind(FLUSH_BATCH)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await?
|
||||||
|
}
|
||||||
|
Some((ts, uuid)) => {
|
||||||
|
sqlx::query_as(
|
||||||
|
"SELECT envelope, enqueued_at, msg_uuid FROM message_queue
|
||||||
|
WHERE dst_address = $1 AND expires_at > now()
|
||||||
|
AND attempt_count < $2
|
||||||
|
AND (enqueued_at, msg_uuid) > ($3, $4)
|
||||||
|
ORDER BY enqueued_at, msg_uuid
|
||||||
|
LIMIT $5",
|
||||||
|
)
|
||||||
|
.bind(delivery_address)
|
||||||
|
.bind(MAX_DELIVERY_ATTEMPTS)
|
||||||
|
.bind(ts)
|
||||||
|
.bind(uuid)
|
||||||
|
.bind(FLUSH_BATCH)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await?
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let batch_len = rows.len();
|
||||||
|
if batch_len == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let last = rows
|
||||||
|
.last()
|
||||||
|
.map(|(_, ts, uuid)| (*ts, uuid.clone()))
|
||||||
|
.expect("dávka není prázdná");
|
||||||
|
let mut sent_uuids: Vec<Vec<u8>> = Vec::with_capacity(batch_len);
|
||||||
|
for (encoded, _, uuid) in &rows {
|
||||||
let env = v1::Envelope::decode(encoded.as_slice())?;
|
let env = v1::Envelope::decode(encoded.as_slice())?;
|
||||||
let frame = v1::ServerFrame {
|
let frame = v1::ServerFrame {
|
||||||
frame: Some(v1::server_frame::Frame::Deliver(env)),
|
frame: Some(v1::server_frame::Frame::Deliver(env)),
|
||||||
};
|
};
|
||||||
if tx.send(frame).await.is_err() {
|
if tx.send(frame).await.is_err() {
|
||||||
break;
|
// Příjemce odpadl uprostřed dávky — započítat jen to, co
|
||||||
|
// reálně odešlo, ať pokus „zdarma" nekrátí strop.
|
||||||
|
self.bump_attempts(&sent_uuids).await;
|
||||||
|
return Ok(total);
|
||||||
}
|
}
|
||||||
|
sent_uuids.push(uuid.clone());
|
||||||
|
total += 1;
|
||||||
|
}
|
||||||
|
self.bump_attempts(&sent_uuids).await;
|
||||||
|
if (batch_len as i64) < FLUSH_BATCH {
|
||||||
|
break; // poslední (neúplná) dávka
|
||||||
|
}
|
||||||
|
cursor = Some(last);
|
||||||
|
}
|
||||||
|
Ok(total)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Započítá pokus o doručení odeslaným obálkám. Selhání se jen zaloguje —
|
||||||
|
/// obálky už jsou na drátě a shodit kvůli tomu flush by bylo horší než
|
||||||
|
/// nepřesné počítadlo.
|
||||||
|
async fn bump_attempts(&self, msg_uuids: &[Vec<u8>]) {
|
||||||
|
if msg_uuids.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Err(e) = sqlx::query(
|
||||||
|
"UPDATE message_queue SET attempt_count = attempt_count + 1
|
||||||
|
WHERE msg_uuid = ANY($1)",
|
||||||
|
)
|
||||||
|
.bind(msg_uuids)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
tracing::warn!("attempt_count se nepodařilo zvýšit: {e}");
|
||||||
}
|
}
|
||||||
Ok(count)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// ACK příjemce → hard-delete z fronty (žádná retence po doručení).
|
/// 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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+63
-30
@@ -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,19 +123,39 @@ 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);
|
||||||
|
// Zpožděné ticky se nedohánějí — po pauze chceme jeden ping, ne dávku.
|
||||||
|
ping.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
||||||
|
ping.tick().await; // první tick padne hned, ten nechceme
|
||||||
|
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
frame = rx.recv() => {
|
||||||
|
let Some(frame) = frame else { break };
|
||||||
let bytes = frame.encode_to_vec();
|
let bytes = frame.encode_to_vec();
|
||||||
if sink.send(WsMessage::Binary(bytes.into())).await.is_err() {
|
if sink.send(WsMessage::Binary(bytes.into())).await.is_err() {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
_ = ping.tick() => {
|
||||||
|
if sink.send(WsMessage::Ping(Bytes::new())).await.is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
let mut pending: Option<Pending> = None;
|
let mut pending: Option<Pending> = None;
|
||||||
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",
|
||||||
|
|||||||
Reference in New Issue
Block a user