Skip to content
Closed
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
2 changes: 2 additions & 0 deletions web/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,5 @@ log/
src/common*/
/public
plugin/enterprise/
src/components/Licence
locales/*/license.js
10 changes: 6 additions & 4 deletions web/scripts/run-umi.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,16 +26,18 @@ const syncStaticAssets = () => {

const syncPluginDirectory = async () => {
const pluginDir = path.resolve(__dirname, "../../plugin/enterprise/web");
const pagesDir = path.resolve(__dirname, "../");
const webDir = path.resolve(__dirname, "../");
const licenceDir = path.resolve(webDir, "src/components/Licence");

if (fs.existsSync(pluginDir)) {
console.log("Plugin directory found. Syncing with the main project...");

try {
fs.cpSync(pluginDir, pagesDir, { recursive: true, force: true });
console.log(`Plugin synced directly to ${pagesDir}`);
fs.rmSync(licenceDir, { recursive: true, force: true })
fs.cpSync(pluginDir, webDir, { recursive: true, force: true });
console.log(`Plugin synced directly to ${webDir}`);
} catch (err) {
console.error(`Failed to sync plugin to ${pagesDir}:`, err);
console.error(`Failed to sync plugin to ${webDir}:`, err);
}
} else {
console.log("No plugin directory found, skipping sync.");
Expand Down
11 changes: 11 additions & 0 deletions web/src/layouts/BasicLayout.js
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,13 @@ class BasicLayout extends React.PureComponent {
// });
}
});
this.handleDataToolsLicenseRequired = () => {
this.licenceRef?.openToTab?.("license");
};
window.addEventListener(
"console:datatools-license-required",
this.handleDataToolsLicenseRequired
);
let firstLogin = localStorage.getItem("first-login");
if (firstLogin === "true" && isLogin()) {
localStorage.setItem("first-login", false);
Expand Down Expand Up @@ -240,6 +247,10 @@ class BasicLayout extends React.PureComponent {
componentWillUnmount() {
cancelAnimationFrame(this.renderRef);
unenquireScreen(this.enquireHandler);
window.removeEventListener(
"console:datatools-license-required",
this.handleDataToolsLicenseRequired
);
}

getContext() {
Expand Down
191 changes: 191 additions & 0 deletions web/src/utils/request.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,185 @@ export const formatResponse = (response) => {
}
}

const secureTransportErrorReason =
"Sensitive requests require HTTPS. Enable Console HTTPS or put Console behind an HTTPS reverse proxy.";
const ERROR_NOTIFICATION_DEDUPE_MS = 4000;
const DATATOOLS_LICENSE_REQUIRED_REASON =
"a valid license is required to use DataTools";
const DATATOOLS_LICENSE_MODAL_EVENT = "console:datatools-license-required";
const DATATOOLS_LICENSE_MODAL_DEDUPE_MS = 3000;
const recentErrorNotifications = new Map();
let lastDataToolsLicenseModalAt = 0;

const sensitiveRequestRules = [
{ method: "POST", pattern: /^\/account\/login\/challenge$/ },
{ method: "POST", pattern: /^\/account\/login$/ },
{ method: "PUT", pattern: /^\/account\/password$/ },
{ method: "POST", pattern: /^\/user$/ },
{ method: "PUT", pattern: /^\/user\/[^/]+\/password$/ },
{ method: "POST", pattern: /^\/credential$/ },
{ method: "PUT", pattern: /^\/credential\/[^/]+$/ },
{ method: "POST", pattern: /^\/setup\/_validate$/ },
{ method: "POST", pattern: /^\/setup\/_initialize$/ },
{ method: "POST", pattern: /^\/setup\/_validate_secret$/ },
{ method: "POST", pattern: /^\/elasticsearch\/$/ },
{ method: "PUT", pattern: /^\/elasticsearch\/[^/]+$/ },
{ method: "POST", pattern: /^\/elasticsearch\/try_connect$/ },
{ method: "POST", pattern: /^\/email\/server$/ },
{ method: "POST", pattern: /^\/email\/server\/_test$/ },
{ method: "PUT", pattern: /^\/email\/server\/[^/]+$/ },
{ method: "PUT", pattern: /^\/setting\/system\/rollup$/ },
{ method: "PUT", pattern: /^\/setting\/system\/retention$/ },
];

const getNormalizedRequestPath = (requestUrl) => {
if (typeof window === "undefined") {
return requestUrl;
}

const resolvedUrl = new URL(requestUrl, window.location.origin);
let pathname = resolvedUrl.pathname;
const basePath =
window.routerBase && window.routerBase !== "/"
? window.routerBase.replace(/\/$/, "")
: "";

if (basePath && pathname.startsWith(`${basePath}/`)) {
pathname = pathname.slice(basePath.length);
} else if (basePath && pathname === basePath) {
pathname = "/";
}

return pathname;
};

const requestUsesSecureTransport = (requestUrl) => {
if (typeof window === "undefined") {
return true;
}

return new URL(requestUrl, window.location.origin).protocol === "https:";
};

const getCurrentRoutePath = () => {
if (typeof window === "undefined") {
return "/";
}

const hash = window.location.hash || "";
const hashPath = hash.startsWith("#") ? hash.slice(1) : hash;
if (hashPath.startsWith("/")) {
return getNormalizedRequestPath(hashPath);
}

return getNormalizedRequestPath(window.location.pathname || "/");
};

const isDataToolsRoute = () => {
return /^\/data_tools(\/|$)/.test(getCurrentRoutePath());
};

const openDataToolsLicenseModal = () => {
if (typeof window === "undefined") {
return;
}

const now = Date.now();
if (now - lastDataToolsLicenseModalAt < DATATOOLS_LICENSE_MODAL_DEDUPE_MS) {
return;
}

lastDataToolsLicenseModalAt = now;
window.dispatchEvent(new CustomEvent(DATATOOLS_LICENSE_MODAL_EVENT));
};

const requestRequiresSecureTransport = (requestUrl, method = "GET") => {
const normalizedMethod = method.toUpperCase();
const normalizedPath = getNormalizedRequestPath(requestUrl);

return sensitiveRequestRules.some(
({ method: sensitiveMethod, pattern }) =>
sensitiveMethod === normalizedMethod && pattern.test(normalizedPath)
);
};

const getInsecureTransportResponse = () => ({
status: "error",
success: false,
currentAuthority: "guest",
error: {
reason: secureTransportErrorReason,
},
});

const cleanupRecentErrorNotifications = (now = Date.now()) => {
recentErrorNotifications.forEach((timestamp, key) => {
if (now - timestamp >= ERROR_NOTIFICATION_DEDUPE_MS) {
recentErrorNotifications.delete(key);
}
});
};

const showErrorNotification = ({
message,
description,
style,
dedupeKey,
}) => {
const now = Date.now();
cleanupRecentErrorNotifications(now);
const key = dedupeKey || `${message}`;
const lastShownAt = recentErrorNotifications.get(key);
if (lastShownAt && now - lastShownAt < ERROR_NOTIFICATION_DEDUPE_MS) {
return;
}
recentErrorNotifications.set(key, now);
notification.error({
key,
placement: "topRight",
message,
description,
style,
});
};

const fetchReplayNonce = async (requestUrl, requestMethod, authorizationHeader) => {
const nonceEndpoint = buildUrlWithBasePath("/account/replay_nonce");
const headers = {
Accept: "application/json",
"Content-Type": "application/json; charset=utf-8",
};
if (authorizationHeader) {
headers.Authorization = authorizationHeader;
}

const response = await fetch(nonceEndpoint, {
method: "POST",
credentials: "include",
headers,
body: JSON.stringify({
method: requestMethod,
path: getNormalizedRequestPath(requestUrl),
}),
});

let payload = null;
try {
payload = await response.json();
} catch (error) {
payload = null;
}

if (!response.ok || !payload?.nonce) {
const reason =
payload?.error?.reason ||
payload?.message ||
"failed to fetch replay nonce";
throw new Error(reason);
}

return payload.nonce;
};
const checkStatus = async (response, noticeable, option={}) => {
const codeMessage = {
200: formatMessage({ id: "app.message.http.status.200" }),
Expand All @@ -53,6 +232,18 @@ const checkStatus = async (response, noticeable, option={}) => {
if (response.status >= 200 && response.status < 300) {
return response;
}
if (response.status === 403 && isDataToolsRoute()) {
let jsonRes = null;
try {
jsonRes = await response.clone().json();
} catch (error) {
jsonRes = null;
}
if (jsonRes?.error?.reason === DATATOOLS_LICENSE_REQUIRED_REASON) {
openDataToolsLicenseModal();
return response;
}
}
if (response.status == 500) {
const jsonRes = await response.clone().json();
if (jsonRes.error && !jsonRes.stack) {
Expand Down
Loading