Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
5 changes: 5 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 Expand Up @@ -96,6 +99,8 @@ export default function ServerCompatBanner(props: Props): Node {
label: 'Learn more',
onPress: () => {
openLinkWithUserPreference(
// 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',
settings,
);
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
Loading