Skip to content

Commit 492a53b

Browse files
committed
api: Don't allow connecting to servers <2.0
With an error alert that appears at the following points: - On submitting `RealmInputScreen` (titled "Welcome") - On selecting a logged-out account in `AccountPickScreen` (titled "Pick account") - When we connect to the active account's server (get a `/register` response; the "Connecting" banner disappears) and we learn its current server version Fixes: #5102
1 parent 7669927 commit 492a53b

File tree

9 files changed

+193
-76
lines changed

9 files changed

+193
-76
lines changed

src/api/apiErrors.js

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
/* @flow strict-local */
22
import { ExtendableError } from '../utils/logging';
33
import type { ApiErrorCode, ApiResponseErrorData } from './transportTypes';
4+
import { ZulipVersion } from '../utils/zulipVersion';
45

56
/**
67
* Some kind of error from a Zulip API network request.
@@ -168,3 +169,26 @@ export const interpretApiResponse = (httpStatus: number, data: mixed): mixed =>
168169
// the API says that shouldn't happen.
169170
throw new UnexpectedHttpStatusError(httpStatus, data);
170171
};
172+
173+
/**
174+
* The Zulip Server version below which we should just refuse to connect.
175+
*/
176+
// Currently chosen to affect a truly tiny fraction of users, as we test the
177+
// feature of refusing to connect, to keep the risk small; see
178+
// https://github.com/zulip/zulip-mobile/issues/5102#issuecomment-1233446360
179+
// In steady state, this should lag a bit behind the threshold version for
180+
// ServerCompatBanner (kMinSupportedVersion), to give users time to see and
181+
// act on the banner.
182+
export const kMinAllowedServerVersion: ZulipVersion = new ZulipVersion('2.0');
183+
184+
/**
185+
* An error we throw in API bindings on finding a server is too old.
186+
*/
187+
export class ServerTooOldError extends ExtendableError {
188+
version: ZulipVersion;
189+
190+
constructor(version: ZulipVersion) {
191+
super(`Unsupported Zulip Server version: ${version.raw()}`);
192+
this.version = version;
193+
}
194+
}

src/api/pollForEvents.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ type ApiResponsePollEvents = {|
99
|};
1010

1111
/** See https://zulip.com/api/get-events */
12+
// TODO: Handle downgrading server across kThresholdVersion, which we'd hear
13+
// about in `restart` events, by throwing a ServerTooOldError. This case
14+
// seems pretty rare but is possible.
1215
export default (auth: Auth, queueId: string, lastEventId: number): Promise<ApiResponsePollEvents> =>
1316
apiGet(
1417
auth,

src/api/registerForEvents.js

Lines changed: 77 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import type { CrossRealmBot, User } from './modelTypes';
77
import { apiPost } from './apiFetch';
88
import { AvatarURL } from '../utils/avatar';
99
import { ZulipVersion } from '../utils/zulipVersion';
10+
import { ServerTooOldError, kMinAllowedServerVersion } from './apiErrors';
1011

1112
const transformUser = (rawUser: {| ...User, avatar_url?: string | null |}, realm: URL): User => {
1213
const { avatar_url: rawAvatarUrl, email } = rawUser;
@@ -34,71 +35,82 @@ const transformCrossRealmBot = (
3435
};
3536
};
3637

37-
const transform = (rawInitialData: RawInitialData, auth: Auth): InitialData => ({
38-
...rawInitialData,
39-
40-
zulip_feature_level: rawInitialData.zulip_feature_level ?? 0,
41-
zulip_version: new ZulipVersion(rawInitialData.zulip_version),
42-
43-
// Transform the newer `realm_linkifiers` format, if present, to the
44-
// older `realm_filters` format. We do the same transformation on
45-
// 'realm_linkifiers' events.
46-
// TODO(server-4.0): Switch to new format, if we haven't already;
47-
// and drop conversion.
48-
realm_filters: rawInitialData.realm_linkifiers
49-
? rawInitialData.realm_linkifiers.map(({ pattern, url_format, id }) => [
50-
pattern,
51-
url_format,
52-
id,
53-
])
54-
: rawInitialData.realm_filters,
55-
56-
// In 5.0 (feature level 100), the representation the server sends for "no
57-
// limit" changed from 0 to `null`.
58-
//
59-
// It's convenient to emulate Server 5.0's representation in our own data
60-
// structures. To get a correct initial value, it's sufficient to coerce
61-
// `0` to null here, without even looking at the server feature level.
62-
// That's because, in addition to the documented change in 5.0, there was
63-
// another: 0 became an invalid value, which means we don't have to
64-
// anticipate servers 5.0+ using it to mean anything, such as "0 seconds":
65-
// https://github.com/zulip/zulip/blob/b13bfa09c/zerver/lib/message.py#L1482.
66-
//
67-
// TODO(server-5.0) Remove this conditional.
68-
realm_message_content_delete_limit_seconds:
69-
rawInitialData.realm_message_content_delete_limit_seconds === 0
70-
? null
71-
: rawInitialData.realm_message_content_delete_limit_seconds,
72-
73-
realm_users: rawInitialData.realm_users.map(rawUser => transformUser(rawUser, auth.realm)),
74-
realm_non_active_users: rawInitialData.realm_non_active_users.map(rawNonActiveUser =>
75-
transformUser(rawNonActiveUser, auth.realm),
76-
),
77-
cross_realm_bots: rawInitialData.cross_realm_bots.map(rawCrossRealmBot =>
78-
transformCrossRealmBot(rawCrossRealmBot, auth.realm),
79-
),
80-
81-
// The doc says the field will be removed in a future release. So, while
82-
// we're still consuming it, fill it in if missing, with instructions from
83-
// the doc:
84-
//
85-
// > Its value will always equal
86-
// > `can_create_public_streams || can_create_private_streams`.
87-
//
88-
// TODO(server-5.0): Only use `can_create_public_streams` and
89-
// `can_create_private_streams`, and simplify this away.
90-
can_create_streams:
91-
rawInitialData.can_create_streams
92-
?? (() => {
93-
const canCreatePublicStreams = rawInitialData.can_create_public_streams;
94-
const canCreatePrivateStreams = rawInitialData.can_create_private_streams;
95-
invariant(
96-
canCreatePublicStreams != null && canCreatePrivateStreams != null,
97-
'these are both present if can_create_streams is missing; see doc',
98-
);
99-
return canCreatePublicStreams || canCreatePrivateStreams;
100-
})(),
101-
});
38+
const transform = (rawInitialData: RawInitialData, auth: Auth): InitialData => {
39+
// (Even ancient servers have `zulip_version` in the initial data.)
40+
const zulipVersion = new ZulipVersion(rawInitialData.zulip_version);
41+
42+
// Do this at the top, before we can accidentally trip on some later code
43+
// that's insensitive to ancient servers' behavior.
44+
if (!zulipVersion.isAtLeast(kMinAllowedServerVersion)) {
45+
throw new ServerTooOldError(zulipVersion);
46+
}
47+
48+
return {
49+
...rawInitialData,
50+
51+
zulip_feature_level: rawInitialData.zulip_feature_level ?? 0,
52+
zulip_version: zulipVersion,
53+
54+
// Transform the newer `realm_linkifiers` format, if present, to the
55+
// older `realm_filters` format. We do the same transformation on
56+
// 'realm_linkifiers' events.
57+
// TODO(server-4.0): Switch to new format, if we haven't already;
58+
// and drop conversion.
59+
realm_filters: rawInitialData.realm_linkifiers
60+
? rawInitialData.realm_linkifiers.map(({ pattern, url_format, id }) => [
61+
pattern,
62+
url_format,
63+
id,
64+
])
65+
: rawInitialData.realm_filters,
66+
67+
// In 5.0 (feature level 100), the representation the server sends for "no
68+
// limit" changed from 0 to `null`.
69+
//
70+
// It's convenient to emulate Server 5.0's representation in our own data
71+
// structures. To get a correct initial value, it's sufficient to coerce
72+
// `0` to null here, without even looking at the server feature level.
73+
// That's because, in addition to the documented change in 5.0, there was
74+
// another: 0 became an invalid value, which means we don't have to
75+
// anticipate servers 5.0+ using it to mean anything, such as "0 seconds":
76+
// https://github.com/zulip/zulip/blob/b13bfa09c/zerver/lib/message.py#L1482.
77+
//
78+
// TODO(server-5.0) Remove this conditional.
79+
realm_message_content_delete_limit_seconds:
80+
rawInitialData.realm_message_content_delete_limit_seconds === 0
81+
? null
82+
: rawInitialData.realm_message_content_delete_limit_seconds,
83+
84+
realm_users: rawInitialData.realm_users.map(rawUser => transformUser(rawUser, auth.realm)),
85+
realm_non_active_users: rawInitialData.realm_non_active_users.map(rawNonActiveUser =>
86+
transformUser(rawNonActiveUser, auth.realm),
87+
),
88+
cross_realm_bots: rawInitialData.cross_realm_bots.map(rawCrossRealmBot =>
89+
transformCrossRealmBot(rawCrossRealmBot, auth.realm),
90+
),
91+
92+
// The doc says the field will be removed in a future release. So, while
93+
// we're still consuming it, fill it in if missing, with instructions from
94+
// the doc:
95+
//
96+
// > Its value will always equal
97+
// > `can_create_public_streams || can_create_private_streams`.
98+
//
99+
// TODO(server-5.0): Only use `can_create_public_streams` and
100+
// `can_create_private_streams`, and simplify this away.
101+
can_create_streams:
102+
rawInitialData.can_create_streams
103+
?? (() => {
104+
const canCreatePublicStreams = rawInitialData.can_create_public_streams;
105+
const canCreatePrivateStreams = rawInitialData.can_create_private_streams;
106+
invariant(
107+
canCreatePublicStreams != null && canCreatePrivateStreams != null,
108+
'these are both present if can_create_streams is missing; see doc',
109+
);
110+
return canCreatePublicStreams || canCreatePrivateStreams;
111+
})(),
112+
};
113+
};
102114

103115
/** See https://zulip.com/api/register-queue */
104116
export default async (

src/api/settings/getServerSettings.js

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/* @flow strict-local */
22
import type { ApiResponseSuccess } from '../transportTypes';
33
import { apiGet } from '../apiFetch';
4-
import { ApiError } from '../apiErrors';
4+
import { ApiError, ServerTooOldError, kMinAllowedServerVersion } from '../apiErrors';
55
import { ZulipVersion } from '../../utils/zulipVersion';
66

77
// This corresponds to AUTHENTICATION_FLAGS in zulip/zulip:zerver/models.py .
@@ -83,6 +83,15 @@ export type ServerSettings = $ReadOnly<{|
8383
* Make a ServerSettings from a raw API response.
8484
*/
8585
function transform(raw: ApiResponseServerSettings): ServerSettings {
86+
// (Even ancient servers have `zulip_version` in the response.)
87+
const zulipVersion = new ZulipVersion(raw.zulip_version);
88+
89+
// Do this at the top, before we can accidentally trip on some later code
90+
// that's insensitive to ancient servers' behavior.
91+
if (!zulipVersion.isAtLeast(kMinAllowedServerVersion)) {
92+
throw new ServerTooOldError(zulipVersion);
93+
}
94+
8695
const { realm_name } = raw;
8796
if (realm_name == null) {
8897
// See comment on realm_name in ApiResponseServerSettings.
@@ -101,7 +110,7 @@ function transform(raw: ApiResponseServerSettings): ServerSettings {
101110
return {
102111
...raw,
103112
zulip_feature_level: raw.zulip_feature_level ?? 0,
104-
zulip_version: new ZulipVersion(raw.zulip_version),
113+
zulip_version: zulipVersion,
105114
realm_uri: new URL(raw.realm_uri),
106115
realm_name,
107116
realm_web_public_access_enabled: raw.realm_web_public_access_enabled ?? false,

src/common/ServerCompatBanner.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@ import { Role } from '../api/permissionsTypes';
1818
*
1919
* Should match what we say at:
2020
* https://zulip.readthedocs.io/en/stable/overview/release-lifecycle.html#compatibility-and-upgrading
21+
*
22+
* See also kMinAllowedServerVersion in apiErrors.js, for the version below
23+
* which we just refuse to connect.
2124
*/
2225
// "2.2.0" is a funny way of saying "3.0", differing in that it accepts
2326
// versions like 2.2-dev-1234-g08192a3b4c. Some servers running versions

src/events/eventActions.js

Lines changed: 38 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,20 +14,26 @@ import { REGISTER_START, REGISTER_ABORT, REGISTER_COMPLETE, DEAD_QUEUE } from '.
1414
import { logout } from '../account/logoutActions';
1515
import eventToAction from './eventToAction';
1616
import doEventActionSideEffects from './doEventActionSideEffects';
17-
import { getAuth, tryGetAuth, getIdentity } from '../selectors';
17+
import { getAccount, tryGetAuth, getIdentity } from '../selectors';
1818
import { getHaveServerData } from '../haveServerDataSelectors';
1919
import { getOwnUserRole, roleIsAtLeast } from '../permissionSelectors';
2020
import { Role } from '../api/permissionsTypes';
21-
import { identityOfAuth } from '../account/accountMisc';
21+
import { authOfAccount, identityOfAccount, identityOfAuth } from '../account/accountMisc';
2222
import { BackoffMachine, TimeoutError } from '../utils/async';
23-
import { ApiError, RequestError, Server5xxError, NetworkError } from '../api/apiErrors';
23+
import {
24+
ApiError,
25+
RequestError,
26+
Server5xxError,
27+
NetworkError,
28+
ServerTooOldError,
29+
} from '../api/apiErrors';
2430
import * as logging from '../utils/logging';
2531
import { showErrorAlert } from '../utils/info';
2632
import { tryFetch, fetchPrivateMessages } from '../message/fetchActions';
2733
import { MIN_RECENTPMS_SERVER_VERSION } from '../pm-conversations/pmConversationsModel';
2834
import { sendOutbox } from '../outbox/outboxActions';
29-
import { initNotifications } from '../notification/notifTokens';
30-
import { kNextMinSupportedVersion } from '../common/ServerCompatBanner';
35+
import { initNotifications, tryStopNotifications } from '../notification/notifTokens';
36+
import { kMinSupportedVersion, kNextMinSupportedVersion } from '../common/ServerCompatBanner';
3137
import { maybeRefreshServerEmojiData } from '../emoji/data';
3238

3339
const registerStart = (): PerAccountAction => ({
@@ -115,8 +121,11 @@ const registerComplete = (data: InitialData): PerAccountAction => ({
115121
* (`SearchMessagesScreen`).
116122
*/
117123
export const registerAndStartPolling =
118-
(): ThunkAction<Promise<void>> => async (dispatch, getState) => {
119-
const auth = getAuth(getState());
124+
(): ThunkAction<Promise<void>> =>
125+
async (dispatch, getState, { getGlobalSettings }) => {
126+
const account = getAccount(getState());
127+
const identity = identityOfAccount(account);
128+
const auth = authOfAccount(account);
120129

121130
const haveServerData = getHaveServerData(getState());
122131

@@ -152,6 +161,28 @@ export const registerAndStartPolling =
152161
// *do* expect that whatever invalidated the auth also caused the
153162
// server to forget all push tokens.
154163
dispatch(logout());
164+
} else if (e instanceof ServerTooOldError) {
165+
showErrorAlert(
166+
// TODO(i18n): Set up these user-facing strings for translation
167+
// once callers all have access to a `GetText` function. One
168+
// place we dispatch this action is in StoreProvider, which
169+
// isn't a descendant of `TranslationProvider`.
170+
'Could not connect',
171+
`${identity.realm.toString()} is running Zulip Server ${e.version.raw()}, which is unsupported. The minimum version is Zulip Server ${kMinSupportedVersion.raw()}.`,
172+
{
173+
url: new URL(
174+
// TODO: Instead, link to new Help Center doc once we have it:
175+
// https://github.com/zulip/zulip/issues/23842
176+
'https://zulip.readthedocs.io/en/stable/overview/release-lifecycle.html#compatibility-and-upgrading',
177+
),
178+
globalSettings: getGlobalSettings(),
179+
},
180+
);
181+
// Don't delay the logout action by awaiting this request: it may
182+
// take a long time or never succeed, and we need to kick the user
183+
// out immediately.
184+
dispatch(tryStopNotifications(account));
185+
dispatch(logout());
155186
} else if (e instanceof Server5xxError) {
156187
dispatch(registerAbort('server'));
157188
} else if (e instanceof NetworkError) {

src/haveServerDataSelectors.js

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,13 @@
22

33
import type { GlobalState, PerAccountState } from './types';
44
import { getUsers } from './directSelectors';
5-
import { tryGetAuth, tryGetActiveAccountState } from './account/accountsSelectors';
5+
import {
6+
tryGetAuth,
7+
tryGetActiveAccountState,
8+
getServerVersion,
9+
} from './account/accountsSelectors';
610
import { getUsersById } from './users/userSelectors';
11+
import { kMinAllowedServerVersion } from './api/apiErrors';
712

813
/**
914
* Whether we have server data for the active account.
@@ -114,6 +119,12 @@ export const getHaveServerData = (state: PerAccountState): boolean => {
114119
return false;
115120
}
116121

122+
// We may have server data, but it would be from an ancient server that we
123+
// don't support, so it might be malformed.
124+
if (!getServerVersion(state).isAtLeast(kMinAllowedServerVersion)) {
125+
return false;
126+
}
127+
117128
// Valid server data must have a user: the self user, at a minimum.
118129
if (getUsers(state).length === 0) {
119130
// From `usersReducer`:

src/message/fetchActions.js

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,13 @@ import type {
1212
LocalizableText,
1313
} from '../types';
1414
import * as api from '../api';
15-
import { Server5xxError, NetworkError, ApiError, MalformedResponseError } from '../api/apiErrors';
15+
import {
16+
Server5xxError,
17+
NetworkError,
18+
ApiError,
19+
MalformedResponseError,
20+
ServerTooOldError,
21+
} from '../api/apiErrors';
1622
import {
1723
getAuth,
1824
getRealm,
@@ -35,6 +41,7 @@ import { ALL_PRIVATE_NARROW, apiNarrowOfNarrow, caseNarrow, topicNarrow } from '
3541
import { BackoffMachine, promiseTimeout, TimeoutError } from '../utils/async';
3642
import { getAllUsersById, getOwnUserId } from '../users/userSelectors';
3743
import type { ServerSettings } from '../api/settings/getServerSettings';
44+
import { kMinSupportedVersion } from '../common/ServerCompatBanner';
3845

3946
const messageFetchStart = (
4047
narrow: Narrow,
@@ -421,6 +428,22 @@ export async function fetchServerSettings(realm: URL): Promise<
421428
text: 'Could not connect to {realm}. Please check your network connection and try again.',
422429
values: { realm: realm.toString() },
423430
};
431+
} else if (error instanceof ServerTooOldError) {
432+
message = {
433+
text: '{realm} is running Zulip Server {version}, which is unsupported. The minimum supported version is Zulip Server {minSupportedVersion}.',
434+
values: {
435+
realm: realm.toString(),
436+
version: error.version.raw(),
437+
minSupportedVersion: kMinSupportedVersion.raw(),
438+
},
439+
};
440+
learnMoreButton = {
441+
url: new URL(
442+
// TODO: Instead, link to new Help Center doc once we have it:
443+
// https://github.com/zulip/zulip/issues/23842
444+
'https://zulip.readthedocs.io/en/stable/overview/release-lifecycle.html#compatibility-and-upgrading',
445+
),
446+
};
424447
} else if (error instanceof MalformedResponseError && error.httpStatus === 404) {
425448
message = {
426449
text: 'The server at {realm} does not seem to be a Zulip server.',

0 commit comments

Comments
 (0)