Skip to content

Commit

Permalink
Allow running at non-root path, version 2. Resolves #2090
Browse files Browse the repository at this point in the history
  • Loading branch information
tlrobinson committed Apr 10, 2017
1 parent 80f4b6d commit c84ebc5
Show file tree
Hide file tree
Showing 10 changed files with 126 additions and 111 deletions.
Expand Up @@ -2,6 +2,7 @@ import React, { Component, PropTypes } from "react";
import { Link } from "react-router";
import Icon from "metabase/components/Icon.jsx";
import LoadingAndErrorWrapper from "metabase/components/LoadingAndErrorWrapper.jsx";
import { SetupApi } from "metabase/services";

const TaskList = ({tasks}) =>
<ol>
Expand Down Expand Up @@ -57,11 +58,11 @@ export default class SettingsSetupList extends Component {
}

async componentWillMount() {
let response = await fetch("/api/setup/admin_checklist", { credentials: 'same-origin' });
if (response.status !== 200) {
this.setState({ error: await response.json() })
} else {
this.setState({ tasks: await response.json() });
try {
const tasks = await SetupApi.admin_checklist();
this.setState({ tasks: tasks });
} catch (e) {
this.setState({ error: e });
}
}

Expand Down
14 changes: 12 additions & 2 deletions frontend/src/metabase/app.js
Expand Up @@ -17,8 +17,18 @@ import { getStore } from './store'
import { refreshSiteSettings } from "metabase/redux/settings";
import { setErrorPage } from "metabase/redux/app";

import { Router, browserHistory } from "react-router";
import { push, syncHistoryWithStore } from 'react-router-redux'
import { Router, useRouterHistory } from "react-router";
import { createHistory } from 'history'
import { push, syncHistoryWithStore } from 'react-router-redux';

// remove trailing slash
const BASENAME = window.MetabaseBasename.replace(/\/+$/, "");

api.basename = BASENAME;

const browserHistory = useRouterHistory(createHistory)({
basename: BASENAME
});

// we shouldn't redirect these URLs because we want to handle them differently
const WHITELIST_FORBIDDEN_URLS = [
Expand Down
17 changes: 7 additions & 10 deletions frontend/src/metabase/home/components/NextStep.jsx
@@ -1,6 +1,6 @@
import React, { Component, PropTypes } from "react";
import { Link } from "react-router";
import fetch from 'isomorphic-fetch';
import { SetupApi } from "metabase/services";

import SidebarSection from "./SidebarSection.jsx";

Expand All @@ -13,15 +13,12 @@ export default class NextStep extends Component {
}

async componentWillMount() {
let response = await fetch("/api/setup/admin_checklist", { credentials: 'same-origin' });
if (response.status === 200) {
let sections = await response.json();
for (let section of sections) {
for (let task of section.tasks) {
if (task.is_next_step) {
this.setState({ next: task });
break;
}
const sections = await SetupApi.admin_checklist();
for (let section of sections) {
for (let task of section.tasks) {
if (task.is_next_step) {
this.setState({ next: task });
break;
}
}
}
Expand Down
145 changes: 75 additions & 70 deletions frontend/src/metabase/lib/api.js
Expand Up @@ -4,88 +4,93 @@ import querystring from "querystring";

import EventEmitter from "events";

let events = new EventEmitter();

type ParamsMap = { [key:string]: any };
type TransformFn = (o: any) => any;

function makeMethod(method: string, hasBody: boolean = false) {
return function(
urlTemplate: string,
params: ParamsMap|TransformFn = {},
transformResponse: TransformFn = (o) => o
) {
if (typeof params === "function") {
transformResponse = params;
params = {};
}
return function(
data?: { [key:string]: any },
options?: { [key:string]: any } = {}
): Promise<any> {
let url = urlTemplate;
data = { ...data };
for (let tag of (url.match(/:\w+/g) || [])) {
let value = data[tag.slice(1)];
if (value === undefined) {
console.warn("Warning: calling", method, "without", tag);
value = "";
}
url = url.replace(tag, encodeURIComponent(data[tag.slice(1)]))
delete data[tag.slice(1)];
}
class Api extends EventEmitter {
basename: "";

let headers: { [key:string]: string } = {
"Accept": "application/json",
};
constructor(...args) {
super(...args);

let body;
if (hasBody) {
headers["Content-Type"] = "application/json";
body = JSON.stringify(data);
} else {
let qs = querystring.stringify(data);
if (qs) {
url += (url.indexOf("?") >= 0 ? "&" : "?") + qs;
}
}
this.GET = this._makeMethod("GET").bind(this);
this.DELETE = this._makeMethod("DELETE").bind(this);
this.POST = this._makeMethod("POST", true).bind(this);
this.PUT = this._makeMethod("PUT", true).bind(this);
}

return new Promise((resolve, reject) => {
let xhr = new XMLHttpRequest();
xhr.open(method, url);
for (let headerName in headers) {
xhr.setRequestHeader(headerName, headers[headerName])
}
xhr.onreadystatechange = function() {
// $FlowFixMe
if (xhr.readyState === XMLHttpRequest.DONE) {
let body = xhr.responseText;
try { body = JSON.parse(body); } catch (e) {}
if (xhr.status >= 200 && xhr.status <= 299) {
resolve(transformResponse(body, { data }));
} else {
reject({
status: xhr.status,
data: body
});
}
events.emit(xhr.status, url);
_makeMethod(method: string, hasBody: boolean = false) {
return (
urlTemplate: string,
params: ParamsMap|TransformFn = {},
transformResponse: TransformFn = (o) => o
) => {
if (typeof params === "function") {
transformResponse = params;
params = {};
}
return (
data?: { [key:string]: any },
options?: { [key:string]: any } = {}
): Promise<any> => {
let url = urlTemplate;
data = { ...data };
for (let tag of (url.match(/:\w+/g) || [])) {
let value = data[tag.slice(1)];
if (value === undefined) {
console.warn("Warning: calling", method, "without", tag);
value = "";
}
url = url.replace(tag, encodeURIComponent(data[tag.slice(1)]))
delete data[tag.slice(1)];
}
xhr.send(body);

if (options.cancelled) {
options.cancelled.then(() => xhr.abort());
let headers: { [key:string]: string } = {
"Accept": "application/json",
};

let body;
if (hasBody) {
headers["Content-Type"] = "application/json";
body = JSON.stringify(data);
} else {
let qs = querystring.stringify(data);
if (qs) {
url += (url.indexOf("?") >= 0 ? "&" : "?") + qs;
}
}
})

return new Promise((resolve, reject) => {
let xhr = new XMLHttpRequest();
xhr.open(method, this.basename + url);
for (let headerName in headers) {
xhr.setRequestHeader(headerName, headers[headerName])
}
xhr.onreadystatechange = () => {
// $FlowFixMe
if (xhr.readyState === XMLHttpRequest.DONE) {
let body = xhr.responseText;
try { body = JSON.parse(body); } catch (e) {}
if (xhr.status >= 200 && xhr.status <= 299) {
resolve(transformResponse(body, { data }));
} else {
reject({
status: xhr.status,
data: body
});
}
this.emit(xhr.status, url);
}
}
xhr.send(body);

if (options.cancelled) {
options.cancelled.then(() => xhr.abort());
}
});
}
}
}
}

export const GET = makeMethod("GET");
export const DELETE = makeMethod("DELETE");
export const POST = makeMethod("POST", true);
export const PUT = makeMethod("PUT", true);

export default events;
export default new Api();
4 changes: 3 additions & 1 deletion frontend/src/metabase/services.js
@@ -1,6 +1,7 @@
/* @flow */

import { GET, PUT, POST, DELETE } from "metabase/lib/api";
import api from "metabase/lib/api";
const { GET, PUT, POST, DELETE } = api;

import { IS_EMBED_PREVIEW } from "metabase/lib/embed";

Expand Down Expand Up @@ -206,6 +207,7 @@ export const GettingStartedApi = {
export const SetupApi = {
create: POST("/api/setup"),
validate_db: POST("/api/setup/validate"),
admin_checklist: GET("/api/setup/admin_checklist"),
};

export const UserApi = {
Expand Down
2 changes: 1 addition & 1 deletion package.json
Expand Up @@ -20,7 +20,7 @@
"d3": "^3.5.17",
"dc": "^2.0.0",
"diff": "^3.2.0",
"history": "^4.5.0",
"history": "3",
"humanize-plus": "^1.8.1",
"icepick": "^1.1.0",
"inflection": "^1.7.1",
Expand Down
19 changes: 18 additions & 1 deletion resources/frontend_client/index_template.html
Expand Up @@ -11,8 +11,25 @@

<title>Metabase</title>

<base href="/" />
<script type="text/javascript">
window.MetabaseBootstrap = {{{bootstrap_json}}};
(function() {
window.MetabaseBootstrap = {{{bootstrap_json}}};
// THIS IS PROBABLY VULNERABLE TO XSS
// Add trailing slashes
var backendPathname = {{{uri}}}.replace(/\/*$/, "/");
// e.x. "/questions/"
var frontendPathname = window.location.pathname.replace(/\/*$/, "/");
// e.x. "/metabase/questions/"
if (backendPathname === frontendPathname.slice(-backendPathname.length)) {
// Remove the backend pathname from the end of the frontend pathname
window.MetabaseBasename = frontendPathname.slice(0, -backendPathname.length) + "/";
// e.x. "/metabase/"
} else {
window.MetabaseBasename = "/";
}
document.getElementsByTagName("base")[0].href = window.MetabaseBasename;
})();
</script>
</head>

Expand Down
1 change: 1 addition & 0 deletions src/metabase/routes.clj
Expand Up @@ -17,6 +17,7 @@
(stencil/render-string (slurp (or (io/resource (str "frontend_client/" entry ".html"))
(throw (Exception. (str "Cannot find './resources/frontend_client/" entry ".html'. Did you remember to build the Metabase frontend?")))))
{:bootstrap_json (json/generate-string (public-settings/public-settings))
:uri (json/generate-string uri)
:embed_code (when embeddable? (embed/head uri))})
(slurp (io/resource "frontend_client/init.html")))
resp/response
Expand Down
4 changes: 2 additions & 2 deletions webpack.config.js
Expand Up @@ -89,7 +89,7 @@ var config = module.exports = {
path: BUILD_PATH + '/app/dist',
// NOTE: the filename on disk won't include "?[chunkhash]" but the URL in index.html generated by HtmlWebpackPlugin will:
filename: '[name].bundle.js?[hash]',
publicPath: '/app/dist/'
publicPath: 'app/dist/'
},

module: {
Expand Down Expand Up @@ -212,7 +212,7 @@ if (NODE_ENV === "hot") {
config.output.filename = "[name].hot.bundle.js?[hash]";

// point the publicPath (inlined in index.html by HtmlWebpackPlugin) to the hot-reloading server
config.output.publicPath = "http://localhost:8080" + config.output.publicPath;
config.output.publicPath = "http://localhost:8080/" + config.output.publicPath;

config.module.loaders.unshift({
test: /\.jsx$/,
Expand Down
20 changes: 1 addition & 19 deletions yarn.lock
Expand Up @@ -3658,7 +3658,7 @@ he@1.1.x:
version "1.1.1"
resolved "https://registry.yarnpkg.com/he/-/he-1.1.1.tgz#93410fd21b009735151f8868c2f271f3427e23fd"

history@^3.0.0:
history@3, history@^3.0.0:
version "3.2.1"
resolved "https://registry.yarnpkg.com/history/-/history-3.2.1.tgz#71c7497f4e6090363d19a6713bb52a1bfcdd99aa"
dependencies:
Expand All @@ -3667,16 +3667,6 @@ history@^3.0.0:
query-string "^4.2.2"
warning "^3.0.0"

history@^4.5.0:
version "4.5.1"
resolved "https://registry.yarnpkg.com/history/-/history-4.5.1.tgz#44935a51021e3b8e67ebac267a35675732aba569"
dependencies:
invariant "^2.2.1"
loose-envify "^1.2.0"
resolve-pathname "^2.0.0"
value-equal "^0.2.0"
warning "^3.0.0"

hoek@2.x.x:
version "2.16.3"
resolved "https://registry.yarnpkg.com/hoek/-/hoek-2.16.3.tgz#20bb7403d3cea398e91dc4710a8ff1b8274a25ed"
Expand Down Expand Up @@ -7188,10 +7178,6 @@ resolve-from@^1.0.0:
version "1.0.1"
resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-1.0.1.tgz#26cbfe935d1aeeeabb29bc3fe5aeb01e93d44226"

resolve-pathname@^2.0.0:
version "2.0.2"
resolved "https://registry.yarnpkg.com/resolve-pathname/-/resolve-pathname-2.0.2.tgz#e55c016eb2e9df1de98e85002282bfb38c630436"

resolve@1.1.7:
version "1.1.7"
resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.1.7.tgz#203114d82ad2c5ed9e8e0411b3932875e889e97b"
Expand Down Expand Up @@ -8112,10 +8098,6 @@ validate-npm-package-license@^3.0.1:
spdx-correct "~1.0.0"
spdx-expression-parse "~1.0.0"

value-equal@^0.2.0:
version "0.2.0"
resolved "https://registry.yarnpkg.com/value-equal/-/value-equal-0.2.0.tgz#4f41c60a3fc011139a2ec3d3340a8998ae8b69c0"

vary@~1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.0.tgz#e1e5affbbd16ae768dd2674394b9ad3022653140"
Expand Down

0 comments on commit c84ebc5

Please sign in to comment.