Skip to content

Commit

Permalink
api: Don't allow connecting to servers <2.0
Browse files Browse the repository at this point in the history
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: zulip#5102
  • Loading branch information
chrisbobbe authored and gnprice committed Jan 25, 2023
1 parent 04ef0f3 commit 3b811d6
Show file tree
Hide file tree
Showing 9 changed files with 193 additions and 76 deletions.
24 changes: 24 additions & 0 deletions src/api/apiErrors.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
/* @flow strict-local */
import { ExtendableError } from '../utils/logging';
import type { ApiErrorCode, ApiResponseErrorData } from './transportTypes';
import { ZulipVersion } from '../utils/zulipVersion';

/**
* Some kind of error from a Zulip API network request.
Expand Down Expand Up @@ -168,3 +169,26 @@ export const interpretApiResponse = (httpStatus: number, data: mixed): mixed =>
// the API says that shouldn't happen.
throw new UnexpectedHttpStatusError(httpStatus, data);
};

/**
* The Zulip Server version below which we should just refuse to connect.
*/
// Currently chosen to affect a truly tiny fraction of users, as we test the
// feature of refusing to connect, to keep the risk small; see
// https://github.com/zulip/zulip-mobile/issues/5102#issuecomment-1233446360
// In steady state, this should lag a bit behind the threshold version for
// ServerCompatBanner (kMinSupportedVersion), to give users time to see and
// act on the banner.
export const kMinAllowedServerVersion: ZulipVersion = new ZulipVersion('2.0');

/**
* An error we throw in API bindings on finding a server is too old.
*/
export class ServerTooOldError extends ExtendableError {
version: ZulipVersion;

constructor(version: ZulipVersion) {
super(`Unsupported Zulip Server version: ${version.raw()}`);
this.version = version;
}
}
3 changes: 3 additions & 0 deletions src/api/pollForEvents.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ type ApiResponsePollEvents = {|
|};

/** See https://zulip.com/api/get-events */
// TODO: Handle downgrading server across kThresholdVersion, which we'd hear
// about in `restart` events, by throwing a ServerTooOldError. This case
// seems pretty rare but is possible.
export default (auth: Auth, queueId: string, lastEventId: number): Promise<ApiResponsePollEvents> =>
apiGet(
auth,
Expand Down
142 changes: 77 additions & 65 deletions src/api/registerForEvents.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type { CrossRealmBot, User } from './modelTypes';
import { apiPost } from './apiFetch';
import { AvatarURL } from '../utils/avatar';
import { ZulipVersion } from '../utils/zulipVersion';
import { ServerTooOldError, kMinAllowedServerVersion } from './apiErrors';

const transformUser = (rawUser: {| ...User, avatar_url?: string | null |}, realm: URL): User => {
const { avatar_url: rawAvatarUrl, email } = rawUser;
Expand Down Expand Up @@ -34,71 +35,82 @@ const transformCrossRealmBot = (
};
};

const transform = (rawInitialData: RawInitialData, auth: Auth): InitialData => ({
...rawInitialData,

zulip_feature_level: rawInitialData.zulip_feature_level ?? 0,
zulip_version: new ZulipVersion(rawInitialData.zulip_version),

// Transform the newer `realm_linkifiers` format, if present, to the
// older `realm_filters` format. We do the same transformation on
// 'realm_linkifiers' events.
// TODO(server-4.0): Switch to new format, if we haven't already;
// and drop conversion.
realm_filters: rawInitialData.realm_linkifiers
? rawInitialData.realm_linkifiers.map(({ pattern, url_format, id }) => [
pattern,
url_format,
id,
])
: rawInitialData.realm_filters,

// In 5.0 (feature level 100), the representation the server sends for "no
// limit" changed from 0 to `null`.
//
// It's convenient to emulate Server 5.0's representation in our own data
// structures. To get a correct initial value, it's sufficient to coerce
// `0` to null here, without even looking at the server feature level.
// That's because, in addition to the documented change in 5.0, there was
// another: 0 became an invalid value, which means we don't have to
// anticipate servers 5.0+ using it to mean anything, such as "0 seconds":
// https://github.com/zulip/zulip/blob/b13bfa09c/zerver/lib/message.py#L1482.
//
// TODO(server-5.0) Remove this conditional.
realm_message_content_delete_limit_seconds:
rawInitialData.realm_message_content_delete_limit_seconds === 0
? null
: rawInitialData.realm_message_content_delete_limit_seconds,

realm_users: rawInitialData.realm_users.map(rawUser => transformUser(rawUser, auth.realm)),
realm_non_active_users: rawInitialData.realm_non_active_users.map(rawNonActiveUser =>
transformUser(rawNonActiveUser, auth.realm),
),
cross_realm_bots: rawInitialData.cross_realm_bots.map(rawCrossRealmBot =>
transformCrossRealmBot(rawCrossRealmBot, auth.realm),
),

// The doc says the field will be removed in a future release. So, while
// we're still consuming it, fill it in if missing, with instructions from
// the doc:
//
// > Its value will always equal
// > `can_create_public_streams || can_create_private_streams`.
//
// TODO(server-5.0): Only use `can_create_public_streams` and
// `can_create_private_streams`, and simplify this away.
can_create_streams:
rawInitialData.can_create_streams
?? (() => {
const canCreatePublicStreams = rawInitialData.can_create_public_streams;
const canCreatePrivateStreams = rawInitialData.can_create_private_streams;
invariant(
canCreatePublicStreams != null && canCreatePrivateStreams != null,
'these are both present if can_create_streams is missing; see doc',
);
return canCreatePublicStreams || canCreatePrivateStreams;
})(),
});
const transform = (rawInitialData: RawInitialData, auth: Auth): InitialData => {
// (Even ancient servers have `zulip_version` in the initial data.)
const zulipVersion = new ZulipVersion(rawInitialData.zulip_version);

// Do this at the top, before we can accidentally trip on some later code
// that's insensitive to ancient servers' behavior.
if (!zulipVersion.isAtLeast(kMinAllowedServerVersion)) {
throw new ServerTooOldError(zulipVersion);
}

return {
...rawInitialData,

zulip_feature_level: rawInitialData.zulip_feature_level ?? 0,
zulip_version: zulipVersion,

// Transform the newer `realm_linkifiers` format, if present, to the
// older `realm_filters` format. We do the same transformation on
// 'realm_linkifiers' events.
// TODO(server-4.0): Switch to new format, if we haven't already;
// and drop conversion.
realm_filters: rawInitialData.realm_linkifiers
? rawInitialData.realm_linkifiers.map(({ pattern, url_format, id }) => [
pattern,
url_format,
id,
])
: rawInitialData.realm_filters,

// In 5.0 (feature level 100), the representation the server sends for "no
// limit" changed from 0 to `null`.
//
// It's convenient to emulate Server 5.0's representation in our own data
// structures. To get a correct initial value, it's sufficient to coerce
// `0` to null here, without even looking at the server feature level.
// That's because, in addition to the documented change in 5.0, there was
// another: 0 became an invalid value, which means we don't have to
// anticipate servers 5.0+ using it to mean anything, such as "0 seconds":
// https://github.com/zulip/zulip/blob/b13bfa09c/zerver/lib/message.py#L1482.
//
// TODO(server-5.0) Remove this conditional.
realm_message_content_delete_limit_seconds:
rawInitialData.realm_message_content_delete_limit_seconds === 0
? null
: rawInitialData.realm_message_content_delete_limit_seconds,

realm_users: rawInitialData.realm_users.map(rawUser => transformUser(rawUser, auth.realm)),
realm_non_active_users: rawInitialData.realm_non_active_users.map(rawNonActiveUser =>
transformUser(rawNonActiveUser, auth.realm),
),
cross_realm_bots: rawInitialData.cross_realm_bots.map(rawCrossRealmBot =>
transformCrossRealmBot(rawCrossRealmBot, auth.realm),
),

// The doc says the field will be removed in a future release. So, while
// we're still consuming it, fill it in if missing, with instructions from
// the doc:
//
// > Its value will always equal
// > `can_create_public_streams || can_create_private_streams`.
//
// TODO(server-5.0): Only use `can_create_public_streams` and
// `can_create_private_streams`, and simplify this away.
can_create_streams:
rawInitialData.can_create_streams
?? (() => {
const canCreatePublicStreams = rawInitialData.can_create_public_streams;
const canCreatePrivateStreams = rawInitialData.can_create_private_streams;
invariant(
canCreatePublicStreams != null && canCreatePrivateStreams != null,
'these are both present if can_create_streams is missing; see doc',
);
return canCreatePublicStreams || canCreatePrivateStreams;
})(),
};
};

/** See https://zulip.com/api/register-queue */
export default async (
Expand Down
13 changes: 11 additions & 2 deletions src/api/settings/getServerSettings.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/* @flow strict-local */
import type { ApiResponseSuccess } from '../transportTypes';
import { apiGet } from '../apiFetch';
import { ApiError } from '../apiErrors';
import { ApiError, ServerTooOldError, kMinAllowedServerVersion } from '../apiErrors';
import { ZulipVersion } from '../../utils/zulipVersion';

// This corresponds to AUTHENTICATION_FLAGS in zulip/zulip:zerver/models.py .
Expand Down Expand Up @@ -83,6 +83,15 @@ export type ServerSettings = $ReadOnly<{|
* Make a ServerSettings from a raw API response.
*/
function transform(raw: ApiResponseServerSettings): ServerSettings {
// (Even ancient servers have `zulip_version` in the response.)
const zulipVersion = new ZulipVersion(raw.zulip_version);

// Do this at the top, before we can accidentally trip on some later code
// that's insensitive to ancient servers' behavior.
if (!zulipVersion.isAtLeast(kMinAllowedServerVersion)) {
throw new ServerTooOldError(zulipVersion);
}

const { realm_name } = raw;
if (realm_name == null) {
// See comment on realm_name in ApiResponseServerSettings.
Expand All @@ -101,7 +110,7 @@ function transform(raw: ApiResponseServerSettings): ServerSettings {
return {
...raw,
zulip_feature_level: raw.zulip_feature_level ?? 0,
zulip_version: new ZulipVersion(raw.zulip_version),
zulip_version: zulipVersion,
realm_uri: new URL(raw.realm_uri),
realm_name,
realm_web_public_access_enabled: raw.realm_web_public_access_enabled ?? false,
Expand Down
3 changes: 3 additions & 0 deletions src/common/ServerCompatBanner.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ import { Role } from '../api/permissionsTypes';
*
* Should match what we say at:
* https://zulip.readthedocs.io/en/stable/overview/release-lifecycle.html#compatibility-and-upgrading
*
* See also kMinAllowedServerVersion in apiErrors.js, for the version below
* which we just refuse to connect.
*/
// "2.2.0" is a funny way of saying "3.0", differing in that it accepts
// versions like 2.2-dev-1234-g08192a3b4c. Some servers running versions
Expand Down
45 changes: 38 additions & 7 deletions src/events/eventActions.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,20 +14,26 @@ import { REGISTER_START, REGISTER_ABORT, REGISTER_COMPLETE, DEAD_QUEUE } from '.
import { logout } from '../account/logoutActions';
import eventToAction from './eventToAction';
import doEventActionSideEffects from './doEventActionSideEffects';
import { getAuth, tryGetAuth, getIdentity } from '../selectors';
import { getAccount, tryGetAuth, getIdentity } from '../selectors';
import { getHaveServerData } from '../haveServerDataSelectors';
import { getOwnUserRole, roleIsAtLeast } from '../permissionSelectors';
import { Role } from '../api/permissionsTypes';
import { identityOfAuth } from '../account/accountMisc';
import { authOfAccount, identityOfAccount, identityOfAuth } from '../account/accountMisc';
import { BackoffMachine, TimeoutError } from '../utils/async';
import { ApiError, RequestError, Server5xxError, NetworkError } from '../api/apiErrors';
import {
ApiError,
RequestError,
Server5xxError,
NetworkError,
ServerTooOldError,
} from '../api/apiErrors';
import * as logging from '../utils/logging';
import { showErrorAlert } from '../utils/info';
import { tryFetch, fetchPrivateMessages } from '../message/fetchActions';
import { MIN_RECENTPMS_SERVER_VERSION } from '../pm-conversations/pmConversationsModel';
import { sendOutbox } from '../outbox/outboxActions';
import { initNotifications } from '../notification/notifTokens';
import { kNextMinSupportedVersion } from '../common/ServerCompatBanner';
import { initNotifications, tryStopNotifications } from '../notification/notifTokens';
import { kMinSupportedVersion, kNextMinSupportedVersion } from '../common/ServerCompatBanner';
import { maybeRefreshServerEmojiData } from '../emoji/data';

const registerStart = (): PerAccountAction => ({
Expand Down Expand Up @@ -115,8 +121,11 @@ const registerComplete = (data: InitialData): PerAccountAction => ({
* (`SearchMessagesScreen`).
*/
export const registerAndStartPolling =
(): ThunkAction<Promise<void>> => async (dispatch, getState) => {
const auth = getAuth(getState());
(): ThunkAction<Promise<void>> =>
async (dispatch, getState, { getGlobalSettings }) => {
const account = getAccount(getState());
const identity = identityOfAccount(account);
const auth = authOfAccount(account);

const haveServerData = getHaveServerData(getState());

Expand Down Expand Up @@ -152,6 +161,28 @@ export const registerAndStartPolling =
// *do* expect that whatever invalidated the auth also caused the
// server to forget all push tokens.
dispatch(logout());
} else if (e instanceof ServerTooOldError) {
showErrorAlert(
// TODO(i18n): Set up these user-facing strings for translation
// once callers all have access to a `GetText` function. One
// place we dispatch this action is in StoreProvider, which
// isn't a descendant of `TranslationProvider`.
'Could not connect',
`${identity.realm.toString()} is running Zulip Server ${e.version.raw()}, which is unsupported. The minimum supported version is Zulip Server ${kMinSupportedVersion.raw()}.`,
{
url: new URL(
// TODO: Instead, link to new Help Center doc once we have it:
// https://github.com/zulip/zulip/issues/23842
'https://zulip.readthedocs.io/en/stable/overview/release-lifecycle.html#compatibility-and-upgrading',
),
globalSettings: getGlobalSettings(),
},
);
// Don't delay the logout action by awaiting this request: it may
// take a long time or never succeed, and we need to kick the user
// out immediately.
dispatch(tryStopNotifications(account));
dispatch(logout());
} else if (e instanceof Server5xxError) {
dispatch(registerAbort('server'));
} else if (e instanceof NetworkError) {
Expand Down
13 changes: 12 additions & 1 deletion src/haveServerDataSelectors.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,13 @@

import type { GlobalState, PerAccountState } from './types';
import { getUsers } from './directSelectors';
import { tryGetAuth, tryGetActiveAccountState } from './account/accountsSelectors';
import {
tryGetAuth,
tryGetActiveAccountState,
getServerVersion,
} from './account/accountsSelectors';
import { getUsersById } from './users/userSelectors';
import { kMinAllowedServerVersion } from './api/apiErrors';

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

// We may have server data, but it would be from an ancient server that we
// don't support, so it might be malformed.
if (!getServerVersion(state).isAtLeast(kMinAllowedServerVersion)) {
return false;
}

// Valid server data must have a user: the self user, at a minimum.
if (getUsers(state).length === 0) {
// From `usersReducer`:
Expand Down
25 changes: 24 additions & 1 deletion src/message/fetchActions.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,13 @@ import type {
LocalizableText,
} from '../types';
import * as api from '../api';
import { Server5xxError, NetworkError, ApiError, MalformedResponseError } from '../api/apiErrors';
import {
Server5xxError,
NetworkError,
ApiError,
MalformedResponseError,
ServerTooOldError,
} from '../api/apiErrors';
import {
getAuth,
getRealm,
Expand All @@ -35,6 +41,7 @@ import { ALL_PRIVATE_NARROW, apiNarrowOfNarrow, caseNarrow, topicNarrow } from '
import { BackoffMachine, promiseTimeout, TimeoutError } from '../utils/async';
import { getAllUsersById, getOwnUserId } from '../users/userSelectors';
import type { ServerSettings } from '../api/settings/getServerSettings';
import { kMinSupportedVersion } from '../common/ServerCompatBanner';

const messageFetchStart = (
narrow: Narrow,
Expand Down Expand Up @@ -421,6 +428,22 @@ export async function fetchServerSettings(realm: URL): Promise<
text: 'Could not connect to {realm}. Please check your network connection and try again.',
values: { realm: realm.toString() },
};
} else if (error instanceof ServerTooOldError) {
message = {
text: '{realm} is running Zulip Server {version}, which is unsupported. The minimum supported version is Zulip Server {minSupportedVersion}.',
values: {
realm: realm.toString(),
version: error.version.raw(),
minSupportedVersion: kMinSupportedVersion.raw(),
},
};
learnMoreButton = {
url: new URL(
// TODO: Instead, link to new Help Center doc once we have it:
// https://github.com/zulip/zulip/issues/23842
'https://zulip.readthedocs.io/en/stable/overview/release-lifecycle.html#compatibility-and-upgrading',
),
};
} else if (error instanceof MalformedResponseError && error.httpStatus === 404) {
message = {
text: 'The server at {realm} does not seem to be a Zulip server.',
Expand Down
Loading

0 comments on commit 3b811d6

Please sign in to comment.