Skip to content

Commit 85f8829

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 8eb76d1 commit 85f8829

15 files changed

+297
-2
lines changed

Diff for: assets/l10n/app_en.arb

+9
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.",

Diff for: lib/api/core.dart

+18
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#client-apps');
30+
1331
/// A fused JSON + UTF-8 decoder.
1432
///
1533
/// This object is an instance of [`_JsonUtf8Decoder`][1] which is

Diff for: lib/generated/l10n/zulip_localizations.dart

+6
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:

Diff for: lib/generated/l10n/zulip_localizations_ar.dart

+5
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.';

Diff for: lib/generated/l10n/zulip_localizations_en.dart

+5
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.';

Diff for: lib/generated/l10n/zulip_localizations_ja.dart

+5
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.';

Diff for: lib/generated/l10n/zulip_localizations_nb.dart

+5
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.';

Diff for: lib/generated/l10n/zulip_localizations_pl.dart

+5
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.';

Diff for: lib/generated/l10n/zulip_localizations_ru.dart

+5
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.';

Diff for: lib/generated/l10n/zulip_localizations_sk.dart

+5
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.';

Diff for: lib/model/store.dart

+64-2
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,20 @@ abstract class GlobalStore extends ChangeNotifier {
206206
assert(account != null); // doLoadPerAccount would have thrown AccountNotFoundException
207207
final zulipLocalizations = GlobalLocalizations.zulipLocalizations;
208208
switch (e) {
209+
case _ServerVersionUnsupportedException():
210+
reportErrorToUserModally(
211+
zulipLocalizations.errorCouldNotConnectTitle,
212+
message: zulipLocalizations.errorServerVersionUnsupportedMessage(
213+
account!.realmUrl.toString(),
214+
e.data.zulipVersion,
215+
kMinSupportedZulipVersion),
216+
learnMoreButtonUrl: kServerSupportDocUrl);
217+
// The important thing is to tear down per-account UI,
218+
// and logOutAccount conveniently handles that already.
219+
// It's not ideal to force the user to reauthenticate when they retry,
220+
// and we can revisit that later if needed.
221+
await logOutAccount(this, accountId);
222+
throw AccountNotFoundException();
209223
case HttpException(httpStatus: 401):
210224
// The API key is invalid and the store can never be loaded
211225
// unless the user retries manually.
@@ -1049,8 +1063,20 @@ class UpdateMachine {
10491063
}
10501064

10511065
final stopwatch = Stopwatch()..start();
1052-
final initialSnapshot = await _registerQueueWithRetry(connection,
1053-
stopAndThrowIfNoAccount: stopAndThrowIfNoAccount);
1066+
InitialSnapshot? initialSnapshot;
1067+
try {
1068+
initialSnapshot = await _registerQueueWithRetry(connection,
1069+
stopAndThrowIfNoAccount: stopAndThrowIfNoAccount);
1070+
} on _ServerVersionUnsupportedException catch (e) {
1071+
// `!` is OK because _registerQueueWithRetry would have thrown a
1072+
// not-_ServerVersionUnsupportedException if no account
1073+
final account = globalStore.getAccount(accountId)!;
1074+
if (!e.data.matchesAccount(account)) {
1075+
await globalStore.updateZulipVersionData(accountId, e.data);
1076+
}
1077+
connection.close();
1078+
rethrow;
1079+
}
10541080
if (kProfileMode) {
10551081
profilePrint("initial fetch time: ${stopwatch.elapsed.inMilliseconds}ms");
10561082
}
@@ -1104,7 +1130,12 @@ class UpdateMachine {
11041130
} catch (e, s) {
11051131
stopAndThrowIfNoAccount();
11061132
// TODO(#890): tell user if initial-fetch errors persist, or look non-transient
1133+
final ZulipVersionData? zulipVersionData;
11071134
switch (e) {
1135+
case MalformedServerResponseException()
1136+
when (zulipVersionData = ZulipVersionData.fromMalformedServerResponseException(e))
1137+
?.isUnsupported == true:
1138+
throw _ServerVersionUnsupportedException(zulipVersionData!);
11081139
case HttpException(httpStatus: 401):
11091140
// We cannot recover from this error through retrying.
11101141
// Leave it to [GlobalStore.loadPerAccount].
@@ -1122,6 +1153,10 @@ class UpdateMachine {
11221153
}
11231154
if (result != null) {
11241155
stopAndThrowIfNoAccount();
1156+
final zulipVersionData = ZulipVersionData.fromInitialSnapshot(result);
1157+
if (zulipVersionData.isUnsupported) {
1158+
throw _ServerVersionUnsupportedException(zulipVersionData);
1159+
}
11251160
return result;
11261161
}
11271162
}
@@ -1544,6 +1579,25 @@ class ZulipVersionData {
15441579
zulipMergeBase: initialSnapshot.zulipMergeBase,
15451580
zulipFeatureLevel: initialSnapshot.zulipFeatureLevel);
15461581

1582+
/// Make a [ZulipVersionData] from a [MalformedServerResponseException],
1583+
/// if the body was readable/valid JSON and contained the data, else null.
1584+
///
1585+
/// If there's a zulip_version but no zulip_feature_level,
1586+
/// we infer it's indeed a Zulip server,
1587+
/// just an ancient one before feature levels were introduced in Zulip 3.0,
1588+
/// and we set 0 for zulipFeatureLevel.
1589+
static ZulipVersionData? fromMalformedServerResponseException(MalformedServerResponseException e) {
1590+
try {
1591+
final data = e.data!;
1592+
return ZulipVersionData(
1593+
zulipVersion: data['zulip_version'] as String,
1594+
zulipMergeBase: data['zulip_merge_base'] as String?,
1595+
zulipFeatureLevel: data['zulip_feature_level'] as int? ?? 0);
1596+
} catch (inner) {
1597+
return null;
1598+
}
1599+
}
1600+
15471601
final String zulipVersion;
15481602
final String? zulipMergeBase;
15491603
final int zulipFeatureLevel;
@@ -1552,6 +1606,14 @@ class ZulipVersionData {
15521606
zulipVersion == account.zulipVersion
15531607
&& zulipMergeBase == account.zulipMergeBase
15541608
&& zulipFeatureLevel == account.zulipFeatureLevel;
1609+
1610+
bool get isUnsupported => zulipFeatureLevel < kMinSupportedZulipFeatureLevel;
1611+
}
1612+
1613+
class _ServerVersionUnsupportedException implements Exception {
1614+
final ZulipVersionData data;
1615+
1616+
_ServerVersionUnsupportedException(this.data);
15551617
}
15561618

15571619
class _EventHandlingException implements Exception {

Diff for: test/example_data.dart

+2
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';
@@ -77,6 +78,7 @@ Uri get _realmUrl => realmUrl;
7778
const String recentZulipVersion = '9.0';
7879
const int recentZulipFeatureLevel = 278;
7980
const int futureZulipFeatureLevel = 9999;
81+
const int ancientZulipFeatureLevel = kMinSupportedZulipFeatureLevel - 1;
8082

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

Diff for: test/model/store_checks.dart

+6
Original file line numberDiff line numberDiff line change
@@ -64,3 +64,9 @@ extension PerAccountStoreChecks on Subject<PerAccountStore> {
6464
Subject<RecentDmConversationsView> get recentDmConversationsView => has((x) => x.recentDmConversationsView, 'recentDmConversationsView');
6565
Subject<AutocompleteViewManager> get autocompleteViewManager => has((x) => x.autocompleteViewManager, 'autocompleteViewManager');
6666
}
67+
68+
extension ZulipVersionDataChecks on Subject<ZulipVersionData> {
69+
Subject<String> get zulipVersion => has((x) => x.zulipVersion, 'zulipVersion');
70+
Subject<String?> get zulipMergeBase => has((x) => x.zulipMergeBase, 'zulipMergeBase');
71+
Subject<int> get zulipFeatureLevel => has((x) => x.zulipFeatureLevel, 'zulipFeatureLevel');
72+
}

0 commit comments

Comments
 (0)