diff --git a/src/api/apiErrors.js b/src/api/apiErrors.js index a32576692fc..a19245f30a6 100644 --- a/src/api/apiErrors.js +++ b/src/api/apiErrors.js @@ -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. @@ -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; + } +} diff --git a/src/api/pollForEvents.js b/src/api/pollForEvents.js index c0c8cc079c1..3290a2f4e4a 100644 --- a/src/api/pollForEvents.js +++ b/src/api/pollForEvents.js @@ -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 => apiGet( auth, diff --git a/src/api/registerForEvents.js b/src/api/registerForEvents.js index b53398f4326..6d1a161f34c 100644 --- a/src/api/registerForEvents.js +++ b/src/api/registerForEvents.js @@ -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; @@ -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 ( diff --git a/src/api/settings/getServerSettings.js b/src/api/settings/getServerSettings.js index 43aa93653f0..267a8b855e1 100644 --- a/src/api/settings/getServerSettings.js +++ b/src/api/settings/getServerSettings.js @@ -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 . @@ -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. @@ -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, diff --git a/src/common/ServerCompatBanner.js b/src/common/ServerCompatBanner.js index 52e7bb464f0..b11898643da 100644 --- a/src/common/ServerCompatBanner.js +++ b/src/common/ServerCompatBanner.js @@ -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 diff --git a/src/events/eventActions.js b/src/events/eventActions.js index 53a62f74ebd..712ad7632d8 100644 --- a/src/events/eventActions.js +++ b/src/events/eventActions.js @@ -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 => ({ @@ -115,8 +121,11 @@ const registerComplete = (data: InitialData): PerAccountAction => ({ * (`SearchMessagesScreen`). */ export const registerAndStartPolling = - (): ThunkAction> => async (dispatch, getState) => { - const auth = getAuth(getState()); + (): ThunkAction> => + async (dispatch, getState, { getGlobalSettings }) => { + const account = getAccount(getState()); + const identity = identityOfAccount(account); + const auth = authOfAccount(account); const haveServerData = getHaveServerData(getState()); @@ -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) { diff --git a/src/haveServerDataSelectors.js b/src/haveServerDataSelectors.js index 25559de5e2f..d8fae8bfcd2 100644 --- a/src/haveServerDataSelectors.js +++ b/src/haveServerDataSelectors.js @@ -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. @@ -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`: diff --git a/src/message/fetchActions.js b/src/message/fetchActions.js index ece21fbdae1..5a35a6debc6 100644 --- a/src/message/fetchActions.js +++ b/src/message/fetchActions.js @@ -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, @@ -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, @@ -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.', diff --git a/static/translations/messages_en.json b/static/translations/messages_en.json index f3bc3dea511..3c13cddc537 100644 --- a/static/translations/messages_en.json +++ b/static/translations/messages_en.json @@ -84,6 +84,7 @@ "Could not connect": "Could not connect", "The server at {realm} said:\n\n{message}": "The server at {realm} said:\n\n{message}", "Could not connect to {realm}. Please check your network connection and try again.": "Could not connect to {realm}. Please check your network connection and try again.", + "{realm} is running Zulip Server {version}, which is unsupported. The minimum supported version is Zulip Server {minSupportedVersion}.": "{realm} is running Zulip Server {version}, which is unsupported. The minimum supported version is Zulip Server {minSupportedVersion}.", "The server at {realm} does not seem to be a Zulip server.": "The server at {realm} does not seem to be a Zulip server.", "Find your Zulip server URL": "Find your Zulip server URL", "The server at {realm} encountered an error.": "The server at {realm} encountered an error.",