diff --git a/app/client/public/index.html b/app/client/public/index.html index e8588fa4f890..d96361c0cf20 100755 --- a/app/client/public/index.html +++ b/app/client/public/index.html @@ -257,7 +257,7 @@ : LOG_LEVELS[1], cloudHosting: CLOUD_HOSTING, appVersion: { - id: parseConfig('{{env "APPSMITH_VERSION_ID"}}'), + id: parseConfig('{{env "APPSMITH_VERSION_ID"}}') || "UNKNOWN", sha: parseConfig('{{env "APPSMITH_VERSION_SHA"}}'), releaseDate: parseConfig('{{env "APPSMITH_VERSION_RELEASE_DATE"}}'), }, diff --git a/app/client/src/api/interceptors/request/addVersionHeader.ts b/app/client/src/api/interceptors/request/addVersionHeader.ts new file mode 100644 index 000000000000..bc4c20b32ab7 --- /dev/null +++ b/app/client/src/api/interceptors/request/addVersionHeader.ts @@ -0,0 +1,13 @@ +import type { InternalAxiosRequestConfig } from "axios"; + +export const addVersionHeader = ( + config: InternalAxiosRequestConfig, + options: { version: string }, +) => { + const { version } = options; + + config.headers = config.headers || {}; + config.headers["X-Appsmith-Version"] = version; + + return config; +}; diff --git a/app/client/src/api/interceptors/request/apiRequestInterceptor.ts b/app/client/src/api/interceptors/request/apiRequestInterceptor.ts index 8f7919c067dc..5371cbbcf23c 100644 --- a/app/client/src/api/interceptors/request/apiRequestInterceptor.ts +++ b/app/client/src/api/interceptors/request/apiRequestInterceptor.ts @@ -9,6 +9,7 @@ import getQueryParamsObject from "utils/getQueryParamsObject"; import { addRequestedByHeader } from "./addRequestedByHeader"; import { increaseGitApiTimeout } from "./increaseGitApiTimeout"; import { getCurrentGitBranch } from "selectors/gitSyncSelectors"; +import { addVersionHeader as _addVersionHeader } from "./addVersionHeader"; import { getCurrentEnvironmentId } from "ee/selectors/environmentSelectors"; import { addGitBranchHeader as _addGitBranchHeader } from "./addGitBranchHeader"; import { addPerformanceMonitoringHeaders } from "./addPerformanceMonitoringHeaders"; @@ -49,10 +50,18 @@ const addAnonymousUserIdHeader = (config: InternalAxiosRequestConfig) => { return _addAnonymousUserIdHeader(config, { anonymousId, segmentEnabled }); }; +const addVersionHeader = (config: InternalAxiosRequestConfig) => { + const appsmithConfig = getAppsmithConfigs(); + const version = appsmithConfig.appVersion.id; + + return _addVersionHeader(config, { version }); +}; + export const apiRequestInterceptor = (config: InternalAxiosRequestConfig) => { const interceptorPipeline = compose( blockAirgappedRoutes, addRequestedByHeader, + addVersionHeader, addGitBranchHeader, increaseGitApiTimeout, addEnvironmentHeader, diff --git a/app/client/src/api/interceptors/response/apiFailureResponseInterceptor.ts b/app/client/src/api/interceptors/response/apiFailureResponseInterceptor.ts index eae6f6162126..edf2d1975c87 100644 --- a/app/client/src/api/interceptors/response/apiFailureResponseInterceptor.ts +++ b/app/client/src/api/interceptors/response/apiFailureResponseInterceptor.ts @@ -15,6 +15,7 @@ export const apiFailureResponseInterceptor = async ( failureHandlers.handleServerError, failureHandlers.handleUnauthorizedError, failureHandlers.handleNotFoundError, + failureHandlers.handleBadRequestError, ]; for (const handler of handlers) { diff --git a/app/client/src/api/interceptors/response/failureHandlers/handleBadRequestError.ts b/app/client/src/api/interceptors/response/failureHandlers/handleBadRequestError.ts new file mode 100644 index 000000000000..e7aa3f3f334e --- /dev/null +++ b/app/client/src/api/interceptors/response/failureHandlers/handleBadRequestError.ts @@ -0,0 +1,30 @@ +import type { AxiosError } from "axios"; +import type { ApiResponse } from "../../../ApiResponses"; +import { handleVersionMismatch } from "sagas/WebsocketSagas/versionUpdatePrompt"; +import { getAppsmithConfigs } from "ee/configs"; +import { SERVER_ERROR_CODES } from "ee/constants/ApiConstants"; + +export const handleBadRequestError = async (error: AxiosError) => { + const errorCode = error?.response?.data?.responseMeta.error?.code; + + if ( + error?.response?.status === 400 && + SERVER_ERROR_CODES.VERSION_MISMATCH.includes("" + errorCode) + ) { + const responseData = error?.response?.data; + const message = responseData?.responseMeta.error?.message; + const serverVersion = (responseData?.data as { serverVersion: string }) + .serverVersion; + + handleVersionMismatch(getAppsmithConfigs().appVersion.id, serverVersion); + + return Promise.reject({ + ...error, + clientDefinedError: true, + statusCode: errorCode, + message, + }); + } + + return null; +}; diff --git a/app/client/src/api/interceptors/response/failureHandlers/index.ts b/app/client/src/api/interceptors/response/failureHandlers/index.ts index 2352095f1deb..bf0fe01621e8 100644 --- a/app/client/src/api/interceptors/response/failureHandlers/index.ts +++ b/app/client/src/api/interceptors/response/failureHandlers/index.ts @@ -4,6 +4,7 @@ export { handleCancelError } from "./handleCancelError"; export { handleOfflineError } from "./handleOfflineError"; export { handleTimeoutError } from "./handleTimeoutError"; export { handleNotFoundError } from "./handleNotFoundError"; +export { handleBadRequestError } from "./handleBadRequestError"; export { handleUnauthorizedError } from "./handleUnauthorizedError"; export { handleExecuteActionError } from "./handleExecuteActionError"; export { handleMissingResponseMeta } from "./handleMissingResponseMeta"; diff --git a/app/client/src/ce/constants/ApiConstants.tsx b/app/client/src/ce/constants/ApiConstants.tsx index b1c7a6c7ee26..be043d802b76 100644 --- a/app/client/src/ce/constants/ApiConstants.tsx +++ b/app/client/src/ce/constants/ApiConstants.tsx @@ -64,6 +64,7 @@ export const SERVER_ERROR_CODES = { "AE-APP-4013", ], UNABLE_TO_FIND_PAGE: ["AE-APP-4027", "AE-USR-4004"], + VERSION_MISMATCH: ["AE-BAD-4002"], }; export enum ERROR_CODES { diff --git a/app/client/src/sagas/WebsocketSagas/handleAppLevelSocketEvents.tsx b/app/client/src/sagas/WebsocketSagas/handleAppLevelSocketEvents.tsx index 600b4fc5671d..c5cf5d863d0d 100644 --- a/app/client/src/sagas/WebsocketSagas/handleAppLevelSocketEvents.tsx +++ b/app/client/src/sagas/WebsocketSagas/handleAppLevelSocketEvents.tsx @@ -2,8 +2,6 @@ import { put } from "redux-saga/effects"; import { APP_LEVEL_SOCKET_EVENTS } from "./socketEvents"; import { collabSetAppEditors } from "actions/appCollabActions"; -import { getAppsmithConfigs } from "ee/configs"; -import { handleVersionUpdate } from "./versionUpdatePrompt"; // TODO: Fix this the next time the file is edited // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -13,15 +11,6 @@ export default function* handleAppLevelSocketEvents(event: any) { case APP_LEVEL_SOCKET_EVENTS.LIST_ONLINE_APP_EDITORS: { yield put(collabSetAppEditors(event.payload[0])); - return; - } - // notification on release version - case APP_LEVEL_SOCKET_EVENTS.RELEASE_VERSION_NOTIFICATION: { - const { appVersion } = getAppsmithConfigs(); - const [serverVersion] = event.payload; - - handleVersionUpdate(appVersion, serverVersion); - return; } } diff --git a/app/client/src/sagas/WebsocketSagas/versionUpdatePrompt.ts b/app/client/src/sagas/WebsocketSagas/versionUpdatePrompt.ts index 4b28ab8c8ae4..a8edc806de20 100644 --- a/app/client/src/sagas/WebsocketSagas/versionUpdatePrompt.ts +++ b/app/client/src/sagas/WebsocketSagas/versionUpdatePrompt.ts @@ -1,111 +1,37 @@ -// Check if user is updating the app when toast is shown -// Check how many times does the user see a toast before updating - import { toast } from "@appsmith/ads"; import { createMessage, INFO_VERSION_MISMATCH_FOUND_RELOAD_REQUEST, } from "ee/constants/messages"; -import type { AppVersionData } from "ee/configs/types"; -import { - getVersionUpdateState, - removeVersionUpdateState, - setVersionUpdateState, -} from "utils/storage"; import AnalyticsUtil from "ee/utils/AnalyticsUtil"; -enum UpdateStateEvent { - PROMPT_SHOWN = "PROMPT_SHOWN", - UPDATE_REQUESTED = "UPDATE_REQUESTED", +function handleUpdateRequested(fromVersion: string, toVersion: string) { + AnalyticsUtil.logEvent("VERSION_UPDATE_REQUESTED", { + fromVersion, + toVersion, + }); + // Reload to fetch the latest app version + location.reload(); } -export interface VersionUpdateState { - currentVersion: string; - upgradeVersion: string; - timesShown: number; - event: UpdateStateEvent; -} +export async function handleVersionMismatch( + currentVersion: string, + serverVersion: string, +) { + // If no version is set, ignore + if (!currentVersion) return; -let timesShown = 0; + AnalyticsUtil.logEvent("VERSION_UPDATE_SHOWN", { + fromVersion: currentVersion, + toVersion: serverVersion, + }); -function showPrompt(newUpdateState: VersionUpdateState) { toast.show(createMessage(INFO_VERSION_MISMATCH_FOUND_RELOAD_REQUEST), { kind: "info", autoClose: false, action: { text: "refresh", - effect: () => handleUpdateRequested(newUpdateState), + effect: () => handleUpdateRequested(currentVersion, serverVersion), }, }); } - -function handleUpdateRequested(newUpdateState: VersionUpdateState) { - // store version update with timesShown counter - setVersionUpdateState({ - ...newUpdateState, - event: UpdateStateEvent.UPDATE_REQUESTED, - }).then(() => { - AnalyticsUtil.logEvent("VERSION_UPDATE_REQUESTED", { - fromVersion: newUpdateState.currentVersion, - toVersion: newUpdateState.upgradeVersion, - timesShown, - }); - // Reload to fetch the latest app version - location.reload(); - }); -} - -export async function handleVersionUpdate( - currentVersionData: AppVersionData, - serverVersion: string, -) { - const { edition, id: currentVersion } = currentVersionData; - - // If no version is set, ignore - if (!currentVersion) return; - - const versionState: VersionUpdateState | null = await getVersionUpdateState(); - - if (currentVersion === serverVersion) { - if (versionState) { - AnalyticsUtil.logEvent("VERSION_UPDATE_SUCCESS", { - fromVersion: versionState.currentVersion, - toVersion: versionState.upgradeVersion, - edition, - }); - await removeVersionUpdateState(); - } - } - - if (currentVersion !== serverVersion) { - if (versionState) { - timesShown = versionState.timesShown; - - if ( - currentVersion === versionState.currentVersion && - versionState.event === UpdateStateEvent.UPDATE_REQUESTED - ) { - AnalyticsUtil.logEvent("VERSION_UPDATED_FAILED", { - fromVersion: versionState.currentVersion, - toVersion: versionState.upgradeVersion, - edition, - }); - } - } - - const newUpdateState: VersionUpdateState = { - currentVersion, - upgradeVersion: serverVersion, - // Increment the timesShown counter - timesShown: timesShown + 1, - event: UpdateStateEvent.PROMPT_SHOWN, - }; - - AnalyticsUtil.logEvent("VERSION_UPDATE_SHOWN", { - fromVersion: currentVersion, - toVersion: serverVersion, - timesShown, - }); - showPrompt(newUpdateState); - } -} diff --git a/app/client/src/utils/storage.ts b/app/client/src/utils/storage.ts index dff7c051aaf3..8893261bb190 100644 --- a/app/client/src/utils/storage.ts +++ b/app/client/src/utils/storage.ts @@ -1,7 +1,6 @@ import log from "loglevel"; import moment from "moment"; import localforage from "localforage"; -import type { VersionUpdateState } from "../sagas/WebsocketSagas/versionUpdatePrompt"; import { isNumber } from "lodash"; import { EditorModes } from "components/editorComponents/CodeEditor/EditorConfig"; import type { EditorViewMode } from "ee/entities/IDE/constants"; @@ -761,25 +760,6 @@ export const isUserSignedUpFlagSet = async (email: string) => { } }; -export const setVersionUpdateState = async (state: VersionUpdateState) => { - try { - await store.setItem(STORAGE_KEYS.VERSION_UPDATE_STATE, state); - } catch (e) { - log.error("An error occurred while storing version update state", e); - } -}; - -export const getVersionUpdateState = - async (): Promise => { - return await store.getItem( - STORAGE_KEYS.VERSION_UPDATE_STATE, - ); - }; - -export const removeVersionUpdateState = async () => { - return store.removeItem(STORAGE_KEYS.VERSION_UPDATE_STATE); -}; - export const getAppKbState = async (appId: string) => { try { const aiKBApplicationMap: Record< diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/configurations/SecurityConfig.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/configurations/SecurityConfig.java index 2c7bd27e2cb4..f44711f4c82a 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/configurations/SecurityConfig.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/configurations/SecurityConfig.java @@ -1,5 +1,6 @@ package com.appsmith.server.configurations; +import com.appsmith.external.exceptions.ErrorDTO; import com.appsmith.server.authentication.handlers.AccessDeniedHandler; import com.appsmith.server.authentication.handlers.CustomServerOAuth2AuthorizationRequestResolver; import com.appsmith.server.authentication.handlers.LogoutSuccessHandler; @@ -8,6 +9,7 @@ import com.appsmith.server.constants.Url; import com.appsmith.server.domains.User; import com.appsmith.server.dtos.ResponseDTO; +import com.appsmith.server.exceptions.AppsmithErrorCode; import com.appsmith.server.filters.CSRFFilter; import com.appsmith.server.filters.ConditionalFilter; import com.appsmith.server.filters.LoginRateLimitFilter; @@ -24,6 +26,7 @@ import org.springframework.core.Ordered; import org.springframework.core.annotation.Order; import org.springframework.core.io.ClassPathResource; +import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; import org.springframework.http.InvalidMediaTypeException; @@ -114,6 +117,9 @@ public class SecurityConfig { @Autowired private CustomOauth2ClientRepositoryManager oauth2ClientManager; + @Autowired + private ProjectProperties projectProperties; + @Value("${appsmith.internal.password}") private String INTERNAL_PASSWORD; @@ -284,10 +290,12 @@ private User createAnonymousUser() { } private Mono sanityCheckFilter(ServerWebExchange exchange, WebFilterChain chain) { + final HttpHeaders headers = exchange.getRequest().getHeaders(); + // 1. Check if the content-type is valid at all. Mostly just checks if it contains a `/`. MediaType contentType; try { - contentType = exchange.getRequest().getHeaders().getContentType(); + contentType = headers.getContentType(); } catch (InvalidMediaTypeException e) { return writeErrorResponse(exchange, chain, e.getMessage()); } @@ -300,6 +308,15 @@ private Mono sanityCheckFilter(ServerWebExchange exchange, WebFilterChain return writeErrorResponse(exchange, chain, "Unsupported Content-Type"); } + // 3. Check Appsmith version, if present. Not making this a mandatory check for now, but reconsider later. + final String versionHeaderValue = headers.getFirst("X-Appsmith-Version"); + final String serverVersion = projectProperties.getVersion(); + if (versionHeaderValue != null && !serverVersion.equals(versionHeaderValue)) { + final ErrorDTO error = new ErrorDTO( + AppsmithErrorCode.VERSION_MISMATCH.getCode(), AppsmithErrorCode.VERSION_MISMATCH.getDescription()); + return writeErrorResponse(exchange, chain, error, new VersionMismatchData(serverVersion)); + } + return chain.filter(exchange); } @@ -315,4 +332,24 @@ private Mono writeErrorResponse(ServerWebExchange exchange, WebFilterChain return chain.filter(exchange); } } + + private Mono writeErrorResponse( + ServerWebExchange exchange, WebFilterChain chain, ErrorDTO error, T data) { + final ServerHttpResponse response = exchange.getResponse(); + final HttpStatus status = HttpStatus.BAD_REQUEST; + response.setStatusCode(status); + response.getHeaders().setContentType(MediaType.APPLICATION_JSON); + + final ResponseDTO responseBody = new ResponseDTO<>(status.value(), error); + responseBody.setData(data); + + try { + return response.writeWith( + Mono.just(response.bufferFactory().wrap(objectMapper.writeValueAsBytes(responseBody)))); + } catch (JsonProcessingException ex) { + return chain.filter(exchange); + } + } + + record VersionMismatchData(String serverVersion) {} } diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/exceptions/AppsmithErrorCode.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/exceptions/AppsmithErrorCode.java index 17682a720047..4824f9815e22 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/exceptions/AppsmithErrorCode.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/exceptions/AppsmithErrorCode.java @@ -67,6 +67,7 @@ public enum AppsmithErrorCode { NAME_CLASH_NOT_ALLOWED_IN_REFACTOR("AE-AST-4009", "Name clash not allowed in refactor"), GENERIC_BAD_REQUEST("AE-BAD-4000", "Generic bad request"), MALFORMED_REQUEST("AE-BAD-4001", "Malformed request body"), + VERSION_MISMATCH("AE-BAD-4002", "Appsmith version mismatch"), GOOGLE_RECAPTCHA_FAILED("AE-CAP-4035", "Google recaptcha failed"), GOOGLE_RECAPTCHA_INVITE_FLOW_FAILED("AE-CAP-4100", "Google recaptcha failed"), INVALID_CRUD_PAGE_REQUEST("AE-CRD-4039", "Invalid crud page request"),