diff --git a/keyserver/src/responders/website-responders.js b/keyserver/src/responders/website-responders.js --- a/keyserver/src/responders/website-responders.js +++ b/keyserver/src/responders/website-responders.js @@ -4,10 +4,12 @@ import { detect as detectBrowser } from 'detect-browser'; import type { $Response, $Request } from 'express'; import fs from 'fs'; +import _isEqual from 'lodash/fp/isEqual.js'; import _keyBy from 'lodash/fp/keyBy.js'; import * as React from 'react'; // eslint-disable-next-line import/extensions import ReactDOMServer from 'react-dom/server'; +import t from 'tcomb'; import { promisify } from 'util'; import { baseLegalPolicies } from 'lib/facts/policies.js'; @@ -23,17 +25,28 @@ createPendingThread, } from 'lib/shared/thread-utils.js'; import { defaultWebEnabledApps } from 'lib/types/enabled-apps.js'; +import { entryStoreValidator } from 'lib/types/entry-types.js'; import { defaultCalendarFilters } from 'lib/types/filter-types.js'; -import { defaultNumberPerThread } from 'lib/types/message-types.js'; +import { + defaultNumberPerThread, + messageStoreValidator, +} from 'lib/types/message-types.js'; import { defaultEnabledReports } from 'lib/types/report-types.js'; import { defaultConnectionInfo } from 'lib/types/socket-types.js'; import { threadPermissions } from 'lib/types/thread-permission-types.js'; import { threadTypes } from 'lib/types/thread-types-enum.js'; +import { threadStoreValidator } from 'lib/types/thread-types.js'; +import { + currentUserInfoValidator, + userInfosValidator, +} from 'lib/types/user-types.js'; import { currentDateInTimeZone } from 'lib/utils/date-utils.js'; import { ServerError } from 'lib/utils/errors.js'; import { promiseAll } from 'lib/utils/promises.js'; import { defaultNotifPermissionAlertInfo } from 'lib/utils/push-alerts.js'; +import { tBool, tNumber, tShape, tString } from 'lib/utils/validation-utils.js'; import getTitle from 'web/title/getTitle.js'; +import { navInfoValidator } from 'web/types/nav-types.js'; import { navInfoFromURL } from 'web/url-utils.js'; import { fetchEntryInfos } from '../fetchers/entry-fetchers.js'; @@ -49,6 +62,7 @@ import { Viewer } from '../session/viewer.js'; import { streamJSON, waitForStream } from '../utils/json-stream.js'; import { getAppURLFactsFromRequestURL } from '../utils/urls.js'; +import { validateOutput } from '../utils/validation-utils.js'; const { renderToNodeStream } = ReactDOMServer; @@ -141,6 +155,86 @@ } } +const initialReduxStateValidator = tShape({ + navInfo: navInfoValidator, + deviceID: t.Nil, + currentUserInfo: currentUserInfoValidator, + draftStore: t.irreducible('default draftStore', _isEqual({ drafts: {} })), + sessionID: t.maybe(t.String), + entryStore: entryStoreValidator, + threadStore: threadStoreValidator, + userStore: tShape({ + userInfos: userInfosValidator, + inconsistencyReports: t.irreducible( + 'default inconsistencyReports', + _isEqual([]), + ), + }), + messageStore: messageStoreValidator, + updatesCurrentAsOf: t.Number, + loadingStatuses: t.irreducible('default loadingStatuses', _isEqual({})), + calendarFilters: t.irreducible( + 'defaultCalendarFilters', + _isEqual(defaultCalendarFilters), + ), + urlPrefix: tString(''), + windowDimensions: t.irreducible( + 'default windowDimensions', + _isEqual({ width: 0, height: 0 }), + ), + baseHref: t.String, + notifPermissionAlertInfo: t.irreducible( + 'default notifPermissionAlertInfo', + _isEqual(defaultNotifPermissionAlertInfo), + ), + connection: tShape({ + status: tString('connecting'), + queuedActivityUpdates: t.irreducible( + 'default queuedActivityUpdates', + _isEqual([]), + ), + actualizedCalendarQuery: tShape({ + startDate: t.String, + endDate: t.String, + filters: t.irreducible( + 'default filters', + _isEqual(defaultCalendarFilters), + ), + }), + lateResponses: t.irreducible('default lateResponses', _isEqual([])), + showDisconnectedBar: tBool(false), + }), + watchedThreadIDs: t.irreducible('default watchedThreadIDs', _isEqual([])), + lifecycleState: tString('active'), + enabledApps: t.irreducible( + 'defaultWebEnabledApps', + _isEqual(defaultWebEnabledApps), + ), + reportStore: t.irreducible( + 'default reportStore', + _isEqual({ + enabledReports: defaultEnabledReports, + queuedReports: [], + }), + ), + nextLocalID: tNumber(0), + cookie: t.Nil, + deviceToken: t.Nil, + dataLoaded: t.Boolean, + windowActive: tBool(true), + userPolicies: t.irreducible('default userPolicies', _isEqual({})), + cryptoStore: t.irreducible( + 'default cryptoStore', + _isEqual({ + primaryIdentityKeys: null, + notificationIdentityKeys: null, + }), + ), + pushApiPublicKey: t.maybe(t.String), + _persist: t.Nil, + commServicesAccessToken: t.Nil, +}); + async function websiteResponder( viewer: Viewer, req: $Request, @@ -432,7 +526,12 @@ _persist: null, commServicesAccessToken: null, }); - const jsonStream = streamJSON(res, initialReduxState); + const validatedInitialReduxState = validateOutput( + viewer, + initialReduxStateValidator, + initialReduxState, + ); + const jsonStream = streamJSON(res, validatedInitialReduxState); await waitForStream(jsonStream); res.end(html` diff --git a/lib/types/entry-types.js b/lib/types/entry-types.js --- a/lib/types/entry-types.js +++ b/lib/types/entry-types.js @@ -61,6 +61,11 @@ +daysToEntries: { +[day: string]: string[] }, +lastUserInteractionCalendar: number, }; +export const entryStoreValidator: TInterface = tShape({ + entryInfos: t.dict(tID, rawEntryInfoValidator), + daysToEntries: t.dict(t.String, t.list(tID)), + lastUserInteractionCalendar: t.Number, +}); export type CalendarQuery = { +startDate: string, diff --git a/lib/types/message-types.js b/lib/types/message-types.js --- a/lib/types/message-types.js +++ b/lib/types/message-types.js @@ -390,6 +390,13 @@ lastNavigatedTo: number, // millisecond timestamp lastPruned: number, // millisecond timestamp }; +const threadMessageInfoValidator: TInterface = + tShape({ + messageIDs: t.list(tID), + startReached: t.Boolean, + lastNavigatedTo: t.Number, + lastPruned: t.Number, + }); // Tracks client-local information about a message that hasn't been assigned an // ID by the server yet. As soon as the client gets an ack from the server for @@ -397,10 +404,18 @@ export type LocalMessageInfo = { +sendFailed?: boolean, }; +const localMessageInfoValidator: TInterface = + tShape({ + sendFailed: t.maybe(t.Boolean), + }); export type MessageStoreThreads = { +[threadID: string]: ThreadMessageInfo, }; +const messageStoreThreadsValidator: TDict = t.dict( + tID, + threadMessageInfoValidator, +); export type MessageStore = { +messages: { +[id: string]: RawMessageInfo }, @@ -408,6 +423,13 @@ +local: { +[id: string]: LocalMessageInfo }, +currentAsOf: number, }; +export const messageStoreValidator: TInterface = + tShape({ + messages: t.dict(tID, rawMessageInfoValidator), + threads: messageStoreThreadsValidator, + local: t.dict(t.String, localMessageInfoValidator), + currentAsOf: t.Number, + }); // MessageStore messages ops export type RemoveMessageOperation = { diff --git a/lib/types/thread-types.js b/lib/types/thread-types.js --- a/lib/types/thread-types.js +++ b/lib/types/thread-types.js @@ -28,7 +28,10 @@ import { type ThreadType, threadTypeValidator } from './thread-types-enum.js'; import type { ClientUpdateInfo, ServerUpdateInfo } from './update-types.js'; import type { UserInfo, UserInfos } from './user-types.js'; -import { type ThreadEntity } from '../utils/entity-text.js'; +import { + type ThreadEntity, + threadEntityValidator, +} from '../utils/entity-text.js'; import { tID, tShape } from '../utils/validation-utils.js'; export type MemberInfo = { @@ -49,6 +52,11 @@ +username: ?string, +isViewer: boolean, }; +const relativeMemberInfoValidator = tShape({ + ...memberInfoValidator.meta.props, + username: t.maybe(t.String), + isViewer: t.Boolean, +}); export type RoleInfo = { +id: string, @@ -133,6 +141,25 @@ +repliesCount: number, +pinnedCount?: number, }; +export const threadInfoValidator: TInterface = tShape({ + id: tID, + type: threadTypeValidator, + name: t.maybe(t.String), + uiName: t.union([t.String, threadEntityValidator]), + avatar: t.maybe(clientAvatarValidator), + description: t.maybe(t.String), + color: t.String, + creationTime: t.Number, + parentThreadID: t.maybe(tID), + containingThreadID: t.maybe(tID), + community: t.maybe(tID), + members: t.list(relativeMemberInfoValidator), + roles: t.dict(tID, roleInfoValidator), + currentUser: threadCurrentUserInfoValidator, + sourceMessageID: t.maybe(tID), + repliesCount: t.Number, + pinnedCount: t.maybe(t.Number), +}); export type ResolvedThreadInfo = { +id: string, @@ -185,6 +212,10 @@ export type ThreadStore = { +threadInfos: { +[id: string]: RawThreadInfo }, }; +export const threadStoreValidator: TInterface = + tShape({ + threadInfos: t.dict(tID, rawThreadInfoValidator), + }); export type RemoveThreadOperation = { +type: 'remove', diff --git a/lib/utils/entity-text.js b/lib/utils/entity-text.js --- a/lib/utils/entity-text.js +++ b/lib/utils/entity-text.js @@ -2,12 +2,18 @@ import invariant from 'invariant'; import * as React from 'react'; +import t, { type TInterface, type TUnion } from 'tcomb'; import type { GetENSNames } from './ens-helpers.js'; +import { tID, tShape, tString } from './validation-utils.js'; import { useENSNames } from '../hooks/ens-cache.js'; import { threadNoun } from '../shared/thread-utils.js'; import { stringForUser } from '../shared/user-utils.js'; -import { type ThreadType, threadTypes } from '../types/thread-types-enum.js'; +import { + type ThreadType, + threadTypes, + threadTypeValidator, +} from '../types/thread-types-enum.js'; import { type RawThreadInfo, type ThreadInfo } from '../types/thread-types.js'; import { basePluralize } from '../utils/text-utils.js'; @@ -18,6 +24,13 @@ +isViewer?: ?boolean, +possessive?: ?boolean, // eg. `user's` instead of `user` }; +export const userEntityValidator: TInterface = tShape({ + type: tString('user'), + id: t.String, + username: t.maybe(t.String), + isViewer: t.maybe(t.Boolean), + possessive: t.maybe(t.Boolean), +}); // Comments explain how thread name will appear from user4's perspective export type ThreadEntity = @@ -48,6 +61,28 @@ +possessive?: ?boolean, // eg. `this thread's` instead of `this thread` }; +export const threadEntityValidator: TUnion = t.union([ + tShape({ + type: tString('thread'), + id: tID, + name: t.maybe(t.String), + display: tString('uiName'), + uiName: t.union([t.list(userEntityValidator), t.String]), + ifJustViewer: t.maybe(t.enums.of(['just_you_string', 'viewer_username'])), + }), + tShape({ + type: tString('thread'), + id: tID, + name: t.maybe(t.String), + display: tString('shortName'), + threadType: t.maybe(threadTypeValidator), + parentThreadID: t.maybe(tID), + alwaysDisplayShortName: t.maybe(t.Boolean), + subchannel: t.maybe(t.Boolean), + possessive: t.maybe(t.Boolean), + }), +]); + type ColorEntity = { +type: 'color', +hex: string, diff --git a/web/types/nav-types.js b/web/types/nav-types.js --- a/web/types/nav-types.js +++ b/web/types/nav-types.js @@ -1,13 +1,25 @@ // @flow +import t from 'tcomb'; +import type { TInterface } from 'tcomb'; -import type { BaseNavInfo } from 'lib/types/nav-types.js'; -import type { ThreadInfo } from 'lib/types/thread-types.js'; +import { type BaseNavInfo } from 'lib/types/nav-types.js'; +import { + type ThreadInfo, + threadInfoValidator, +} from 'lib/types/thread-types.js'; +import { tID, tShape } from 'lib/utils/validation-utils.js'; export type NavigationTab = 'calendar' | 'chat' | 'settings'; +const navigationTabValidator = t.enums.of(['calendar', 'chat', 'settings']); export type NavigationSettingsSection = 'account' | 'danger-zone'; +const navigationSettingsSectionValidator = t.enums.of([ + 'account', + 'danger-zone', +]); export type NavigationChatMode = 'view' | 'create'; +const navigationChatModeValidator = t.enums.of(['view', 'create']); export type NavInfo = { ...$Exact, @@ -18,3 +30,14 @@ +selectedUserList?: $ReadOnlyArray, +chatMode?: NavigationChatMode, }; + +export const navInfoValidator: TInterface = tShape<$Exact>({ + startDate: t.String, + endDate: t.String, + tab: navigationTabValidator, + activeChatThreadID: t.maybe(tID), + pendingThread: t.maybe(threadInfoValidator), + settingsSection: t.maybe(navigationSettingsSectionValidator), + selectedUserList: t.maybe(t.list(t.String)), + chatMode: t.maybe(navigationChatModeValidator), +});