Skip to content

Commit 3297253

Browse files
committed
api: Don't allow connecting to servers <4.0
From the README: https://github.com/zulip/zulip-flutter?tab=readme-ov-file#server-compatibility > We support Zulip Server 4.0 and later. This implementation isn't ideal because it logs you out, instead of just taking down the account's UI as we discussed: https://chat.zulip.org/#narrow/channel/243-mobile-team/topic/.23F267.20Disallow.20connecting.20to.20unsupported.20ancient.20servers/near/2104182 But it was simpler this way since programmatically logging out is already an action we handle. Fixes: #267
1 parent da45a9c commit 3297253

15 files changed

+296
-2
lines changed

assets/l10n/app_en.arb

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -538,6 +538,15 @@
538538
"@topicValidationErrorMandatoryButEmpty": {
539539
"description": "Topic validation error when topic is required but was empty."
540540
},
541+
"errorServerVersionUnsupportedMessage": "{url} is running Zulip Server {zulipVersion}, which is unsupported. The minimum supported version is Zulip Server {minSupportedZulipVersion}.",
542+
"@errorServerVersionUnsupportedMessage": {
543+
"description": "Error message in the dialog for when the Zulip Server version is unsupported.",
544+
"placeholders": {
545+
"url": {"type": "String", "example": "http://chat.example.com/"},
546+
"zulipVersion": {"type": "String", "example": "3.2"},
547+
"minSupportedZulipVersion": {"type": "String", "example": "4.0"}
548+
}
549+
},
541550
"errorInvalidApiKeyMessage": "Your account at {url} could not be authenticated. Please try logging in again or use another account.",
542551
"@errorInvalidApiKeyMessage": {
543552
"description": "Error message in the dialog for invalid API key.",

lib/api/core.dart

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,24 @@ import '../model/binding.dart';
1010
import '../model/localizations.dart';
1111
import 'exception.dart';
1212

13+
/// The Zulip Server version below which we should refuse to connect.
14+
///
15+
/// When updating this, also update [kMinSupportedZulipFeatureLevel]
16+
/// and the README.
17+
const kMinSupportedZulipVersion = '4.0';
18+
19+
/// The Zulip feature level reserved for the [kMinSupportedZulipVersion] release.
20+
///
21+
/// For this value, see the API changelog:
22+
/// https://zulip.com/api/changelog
23+
const kMinSupportedZulipFeatureLevel = 65;
24+
25+
/// The doc stating our oldest supported server version.
26+
// TODO: Instead, link to new Help Center doc once we have it:
27+
// https://github.com/zulip/zulip/issues/23842
28+
final kServerSupportDocUrl = Uri.parse(
29+
'https://zulip.readthedocs.io/en/latest/overview/release-lifecycle.html#compatibility-and-upgrading');
30+
1331
/// A fused JSON + UTF-8 decoder.
1432
///
1533
/// This object is an instance of [`_JsonUtf8Decoder`][1] which is

lib/generated/l10n/zulip_localizations.dart

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -825,6 +825,12 @@ abstract class ZulipLocalizations {
825825
/// **'Topics are required in this organization.'**
826826
String get topicValidationErrorMandatoryButEmpty;
827827

828+
/// Error message in the dialog for when the Zulip Server version is unsupported.
829+
///
830+
/// In en, this message translates to:
831+
/// **'{url} is running Zulip Server {zulipVersion}, which is unsupported. The minimum supported version is Zulip Server {minSupportedZulipVersion}.'**
832+
String errorServerVersionUnsupportedMessage(String url, String zulipVersion, String minSupportedZulipVersion);
833+
828834
/// Error message in the dialog for invalid API key.
829835
///
830836
/// In en, this message translates to:

lib/generated/l10n/zulip_localizations_ar.dart

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -416,6 +416,11 @@ class ZulipLocalizationsAr extends ZulipLocalizations {
416416
@override
417417
String get topicValidationErrorMandatoryButEmpty => 'Topics are required in this organization.';
418418

419+
@override
420+
String errorServerVersionUnsupportedMessage(String url, String zulipVersion, String minSupportedZulipVersion) {
421+
return '$url is running Zulip Server $zulipVersion, which is unsupported. The minimum supported version is Zulip Server $minSupportedZulipVersion.';
422+
}
423+
419424
@override
420425
String errorInvalidApiKeyMessage(String url) {
421426
return 'Your account at $url could not be authenticated. Please try logging in again or use another account.';

lib/generated/l10n/zulip_localizations_en.dart

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -416,6 +416,11 @@ class ZulipLocalizationsEn extends ZulipLocalizations {
416416
@override
417417
String get topicValidationErrorMandatoryButEmpty => 'Topics are required in this organization.';
418418

419+
@override
420+
String errorServerVersionUnsupportedMessage(String url, String zulipVersion, String minSupportedZulipVersion) {
421+
return '$url is running Zulip Server $zulipVersion, which is unsupported. The minimum supported version is Zulip Server $minSupportedZulipVersion.';
422+
}
423+
419424
@override
420425
String errorInvalidApiKeyMessage(String url) {
421426
return 'Your account at $url could not be authenticated. Please try logging in again or use another account.';

lib/generated/l10n/zulip_localizations_ja.dart

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -416,6 +416,11 @@ class ZulipLocalizationsJa extends ZulipLocalizations {
416416
@override
417417
String get topicValidationErrorMandatoryButEmpty => 'Topics are required in this organization.';
418418

419+
@override
420+
String errorServerVersionUnsupportedMessage(String url, String zulipVersion, String minSupportedZulipVersion) {
421+
return '$url is running Zulip Server $zulipVersion, which is unsupported. The minimum supported version is Zulip Server $minSupportedZulipVersion.';
422+
}
423+
419424
@override
420425
String errorInvalidApiKeyMessage(String url) {
421426
return 'Your account at $url could not be authenticated. Please try logging in again or use another account.';

lib/generated/l10n/zulip_localizations_nb.dart

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -416,6 +416,11 @@ class ZulipLocalizationsNb extends ZulipLocalizations {
416416
@override
417417
String get topicValidationErrorMandatoryButEmpty => 'Topics are required in this organization.';
418418

419+
@override
420+
String errorServerVersionUnsupportedMessage(String url, String zulipVersion, String minSupportedZulipVersion) {
421+
return '$url is running Zulip Server $zulipVersion, which is unsupported. The minimum supported version is Zulip Server $minSupportedZulipVersion.';
422+
}
423+
419424
@override
420425
String errorInvalidApiKeyMessage(String url) {
421426
return 'Your account at $url could not be authenticated. Please try logging in again or use another account.';

lib/generated/l10n/zulip_localizations_pl.dart

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -416,6 +416,11 @@ class ZulipLocalizationsPl extends ZulipLocalizations {
416416
@override
417417
String get topicValidationErrorMandatoryButEmpty => 'Wątki są wymagane przez tę organizację.';
418418

419+
@override
420+
String errorServerVersionUnsupportedMessage(String url, String zulipVersion, String minSupportedZulipVersion) {
421+
return '$url is running Zulip Server $zulipVersion, which is unsupported. The minimum supported version is Zulip Server $minSupportedZulipVersion.';
422+
}
423+
419424
@override
420425
String errorInvalidApiKeyMessage(String url) {
421426
return 'Konto w ramach $url nie zostało przyjęte. Spróbuj ponownie lub skorzystaj z innego konta.';

lib/generated/l10n/zulip_localizations_ru.dart

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -416,6 +416,11 @@ class ZulipLocalizationsRu extends ZulipLocalizations {
416416
@override
417417
String get topicValidationErrorMandatoryButEmpty => 'Темы обязательны в этой организации.';
418418

419+
@override
420+
String errorServerVersionUnsupportedMessage(String url, String zulipVersion, String minSupportedZulipVersion) {
421+
return '$url is running Zulip Server $zulipVersion, which is unsupported. The minimum supported version is Zulip Server $minSupportedZulipVersion.';
422+
}
423+
419424
@override
420425
String errorInvalidApiKeyMessage(String url) {
421426
return 'Your account at $url could not be authenticated. Please try logging in again or use another account.';

lib/generated/l10n/zulip_localizations_sk.dart

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -416,6 +416,11 @@ class ZulipLocalizationsSk extends ZulipLocalizations {
416416
@override
417417
String get topicValidationErrorMandatoryButEmpty => 'Topics are required in this organization.';
418418

419+
@override
420+
String errorServerVersionUnsupportedMessage(String url, String zulipVersion, String minSupportedZulipVersion) {
421+
return '$url is running Zulip Server $zulipVersion, which is unsupported. The minimum supported version is Zulip Server $minSupportedZulipVersion.';
422+
}
423+
419424
@override
420425
String errorInvalidApiKeyMessage(String url) {
421426
return 'Your account at $url could not be authenticated. Please try logging in again or use another account.';

lib/model/store.dart

Lines changed: 63 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,20 @@ abstract class GlobalStore extends ChangeNotifier {
199199
assert(account != null); // doLoadPerAccount would have thrown AccountNotFoundException
200200
final zulipLocalizations = GlobalLocalizations.zulipLocalizations;
201201
switch (e) {
202+
case _ServerVersionUnsupportedException():
203+
reportErrorToUserModally(
204+
zulipLocalizations.errorCouldNotConnectTitle,
205+
message: zulipLocalizations.errorServerVersionUnsupportedMessage(
206+
account!.realmUrl.toString(),
207+
e.data.zulipVersion,
208+
kMinSupportedZulipVersion),
209+
learnMoreButtonUrl: kServerSupportDocUrl);
210+
// The important thing is to tear down per-account UI,
211+
// and logOutAccount conveniently handles that already.
212+
// It's not ideal to force the user to reauthenticate when they retry,
213+
// and we can revisit that later if needed.
214+
await logOutAccount(this, accountId);
215+
throw AccountNotFoundException();
202216
case HttpException(httpStatus: 401):
203217
// The API key is invalid and the store can never be loaded
204218
// unless the user retries manually.
@@ -1007,8 +1021,19 @@ class UpdateMachine {
10071021
}
10081022

10091023
final stopwatch = Stopwatch()..start();
1010-
final initialSnapshot = await _registerQueueWithRetry(connection,
1011-
stopAndThrowIfNoAccount: stopAndThrowIfNoAccount);
1024+
InitialSnapshot? initialSnapshot;
1025+
try {
1026+
initialSnapshot = await _registerQueueWithRetry(connection,
1027+
stopAndThrowIfNoAccount: stopAndThrowIfNoAccount);
1028+
} on _ServerVersionUnsupportedException catch (e) {
1029+
// `!` is OK because _registerQueueWithRetry would have thrown a
1030+
// not-_ServerVersionUnsupportedException if no account
1031+
if (!e.data.matchesAccount(globalStore.getAccount(accountId)!)) {
1032+
await globalStore.updateZulipVersionData(accountId, e.data);
1033+
}
1034+
connection.close();
1035+
rethrow;
1036+
}
10121037
final t = (stopwatch..stop()).elapsed;
10131038
assert(debugLog("initial fetch time: ${t.inMilliseconds}ms"));
10141039

@@ -1061,7 +1086,12 @@ class UpdateMachine {
10611086
} catch (e, s) {
10621087
stopAndThrowIfNoAccount();
10631088
// TODO(#890): tell user if initial-fetch errors persist, or look non-transient
1089+
final ZulipVersionData? zulipVersionData;
10641090
switch (e) {
1091+
case MalformedServerResponseException()
1092+
when (zulipVersionData = ZulipVersionData.fromMalformedServerResponseException(e))
1093+
?.isUnsupported == true:
1094+
throw _ServerVersionUnsupportedException(zulipVersionData!);
10651095
case HttpException(httpStatus: 401):
10661096
// We cannot recover from this error through retrying.
10671097
// Leave it to [GlobalStore.loadPerAccount].
@@ -1079,6 +1109,10 @@ class UpdateMachine {
10791109
}
10801110
if (result != null) {
10811111
stopAndThrowIfNoAccount();
1112+
final zulipVersionData = ZulipVersionData.fromInitialSnapshot(result);
1113+
if (zulipVersionData.isUnsupported) {
1114+
throw _ServerVersionUnsupportedException(zulipVersionData);
1115+
}
10821116
return result;
10831117
}
10841118
}
@@ -1501,6 +1535,25 @@ class ZulipVersionData {
15011535
zulipMergeBase: initialSnapshot.zulipMergeBase,
15021536
zulipFeatureLevel: initialSnapshot.zulipFeatureLevel);
15031537

1538+
/// Make a [ZulipVersionData] from a [MalformedServerResponseException],
1539+
/// if the body was readable/valid JSON and contained the data, else null.
1540+
///
1541+
/// If there's a zulip_version but no zulip_feature_level,
1542+
/// we infer it's indeed a Zulip server,
1543+
/// just an ancient one before feature levels were introduced in Zulip 3.0,
1544+
/// and we set 0 for zulipFeatureLevel.
1545+
static ZulipVersionData? fromMalformedServerResponseException(MalformedServerResponseException e) {
1546+
try {
1547+
final data = e.data!;
1548+
return ZulipVersionData(
1549+
zulipVersion: data['zulip_version'] as String,
1550+
zulipMergeBase: data['zulip_merge_base'] as String?,
1551+
zulipFeatureLevel: data['zulip_feature_level'] as int? ?? 0);
1552+
} catch (inner) {
1553+
return null;
1554+
}
1555+
}
1556+
15041557
final String zulipVersion;
15051558
final String? zulipMergeBase;
15061559
final int zulipFeatureLevel;
@@ -1509,6 +1562,14 @@ class ZulipVersionData {
15091562
zulipVersion == account.zulipVersion
15101563
&& zulipMergeBase == account.zulipMergeBase
15111564
&& zulipFeatureLevel == account.zulipFeatureLevel;
1565+
1566+
bool get isUnsupported => zulipFeatureLevel < kMinSupportedZulipFeatureLevel;
1567+
}
1568+
1569+
class _ServerVersionUnsupportedException implements Exception {
1570+
final ZulipVersionData data;
1571+
1572+
_ServerVersionUnsupportedException(this.data);
15121573
}
15131574

15141575
class _EventHandlingException implements Exception {

test/example_data.dart

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import 'dart:convert';
22
import 'dart:math';
33

4+
import 'package:zulip/api/core.dart';
45
import 'package:zulip/api/exception.dart';
56
import 'package:zulip/api/model/events.dart';
67
import 'package:zulip/api/model/initial_snapshot.dart';
@@ -76,6 +77,7 @@ Uri get _realmUrl => realmUrl;
7677
const String recentZulipVersion = '9.0';
7778
const int recentZulipFeatureLevel = 278;
7879
const int futureZulipFeatureLevel = 9999;
80+
const int ancientZulipFeatureLevel = kMinSupportedZulipFeatureLevel - 1;
7981

8082
GetServerSettingsResult serverSettings({
8183
Map<String, bool>? authenticationMethods,

test/model/store_checks.dart

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,3 +63,9 @@ extension PerAccountStoreChecks on Subject<PerAccountStore> {
6363
Subject<RecentDmConversationsView> get recentDmConversationsView => has((x) => x.recentDmConversationsView, 'recentDmConversationsView');
6464
Subject<AutocompleteViewManager> get autocompleteViewManager => has((x) => x.autocompleteViewManager, 'autocompleteViewManager');
6565
}
66+
67+
extension ZulipVersionDataChecks on Subject<ZulipVersionData> {
68+
Subject<String> get zulipVersion => has((x) => x.zulipVersion, 'zulipVersion');
69+
Subject<String?> get zulipMergeBase => has((x) => x.zulipMergeBase, 'zulipMergeBase');
70+
Subject<int> get zulipFeatureLevel => has((x) => x.zulipFeatureLevel, 'zulipFeatureLevel');
71+
}

0 commit comments

Comments
 (0)