Skip to content

Commit

Permalink
baseUrl support
Browse files Browse the repository at this point in the history
Add support for a baseUrl that is useful for e.g. reverse proxies that e.g.
forward localhost:80/markerEdit to localhost:3232.

Minimally tested.
  • Loading branch information
danrahn committed Oct 20, 2024
1 parent d4b664b commit 224f6c8
Show file tree
Hide file tree
Showing 12 changed files with 78 additions and 51 deletions.
2 changes: 1 addition & 1 deletion Client/Script/ClientSettings.js
Original file line number Diff line number Diff line change
Expand Up @@ -681,7 +681,7 @@ class ClientSettingsUI {
async #logout() {
try {
await ServerCommands.logout();
window.location = '/login.html';
window.location = 'login.html';
} catch (ex) {
errorToast('Failed to log out. Please try again later.');
}
Expand Down
2 changes: 1 addition & 1 deletion Client/Script/SVGHelper.js
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ async function getSvgAsync(iconName, color, attributes, placeholder) {
Log.tmi(`Already requesting data for "${iconName}", waiting...`);
} else {
svgFetchMap.set(iconName, new Promise(resolve => {
fetch(`/i/${iconName}.svg`, { headers : { accept : 'image/svg+xml' } }).then(r => r.text().then(data => {
fetch(`i/${iconName}.svg`, { headers : { accept : 'image/svg+xml' } }).then(r => r.text().then(data => {
Log.verbose(`Got SVG data for "${iconName}", caching it.`);
setCache(iconName, data);
resolve();
Expand Down
29 changes: 4 additions & 25 deletions Client/Script/ServerSettingsDialog/ServerSettingsDialog.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { allServerSettings, ServerConfigState, ServerSettings, SslState } from '/Shared/ServerConfig.js';
import { ConsoleLog, ContextualLog } from '/Shared/ConsoleLog.js';
import { ServerConfigState, ServerSettings, SslState } from '/Shared/ServerConfig.js';

import { $, $$, $append, $br, $buttonInput, $div, $divHolder, $h, $hr, $i, $id, $label, $numberInput, $option,
$passwordInput, $plainDivHolder, $select, $span, $text, $textInput, $textSpan } from '../HtmlHelpers.js';
Expand Down Expand Up @@ -103,6 +103,7 @@ class ServerSettingsDialog {
this.#buildStringSetting(ServerSettings.Host, config.host, this.#validateHostPort.bind(this), hostPortClass),
this.#buildNumberSetting(
ServerSettings.Port, config.port, this.#validatePort.bind(this, false), 1, 65535, hostPortClass),
this.#buildStringSetting(ServerSettings.BaseUrl, config.baseUrl),
...this.#buildSslSettings(),
...this.#buildAuthenticationSettings(),
this.#buildLogLevelSetting(),
Expand Down Expand Up @@ -1089,30 +1090,7 @@ class ServerSettingsDialog {

// TODO: Is it worth considering cases where session timeout/username changes, but
// auth is also being disabled? For now, no.
for (const setting of [
ServerSettings.DataPath,
ServerSettings.Database,
ServerSettings.Host,
ServerSettings.Port,
ServerSettings.LogLevel,
ServerSettings.UseSsl,
ServerSettings.SslOnly,
ServerSettings.SslHost,
ServerSettings.SslPort,
ServerSettings.CertType,
ServerSettings.PfxPath,
ServerSettings.PfxPassphrase,
ServerSettings.PemCert,
ServerSettings.PemKey,
ServerSettings.UseAuthentication,
ServerSettings.Username,
ServerSettings.SessionTimeout,
ServerSettings.AutoOpen,
ServerSettings.ExtendedStats,
ServerSettings.PreviewThumbnails,
ServerSettings.FFmpegThumbnails,
ServerSettings.PathMappings,
]) {
for (const setting of allServerSettings()) {
values[setting] = this.#getCurrentConfigValue(setting);
}

Expand All @@ -1135,6 +1113,7 @@ class ServerSettingsDialog {
case ServerSettings.DataPath:
case ServerSettings.Database:
case ServerSettings.Host:
case ServerSettings.BaseUrl:
case ServerSettings.SslHost:
case ServerSettings.CertType:
case ServerSettings.PfxPath:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export const SettingTitles = {
[ServerSettings.Database] : 'Database File',
[ServerSettings.Host] : 'Listen Host',
[ServerSettings.Port] : 'Listen Port',
[ServerSettings.BaseUrl] : 'Base URL',
[ServerSettings.UseSsl] : 'Enable HTTPS',
[ServerSettings.SslOnly] : 'Force HTTPS',
[ServerSettings.SslHost] : 'HTTPS Host',
Expand Down
5 changes: 5 additions & 0 deletions Client/Script/ServerSettingsDialog/ServerSettingsTooltips.js
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,11 @@ function initializeServerSettingsTooltips() {
`The port the server will listen on`,
`Must be a number between 1 and 65535, but it's recommended to stay above 1023.`
),
[ServerSettings.BaseUrl] : createTooltip(
`The root of this application.`,
`Useful for reverse proxies. E.g. if you're using a proxy that forwards example.com/markerEdit to localhost:3232, ` +
`this value should be markerEdit.`
),
[ServerSettings.UseSsl] : createTooltip(
`Create a server that supports SSL communication (HTTPS)`,
`In order for SSL to be enabled, a valid certificate and private key must be provided. The HTTPS server ` +
Expand Down
4 changes: 2 additions & 2 deletions Client/Script/login.js
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ class LoginManager {
this.#loggingIn = true;
try {
await ServerCommands.login(username, password);
window.location = '/';
window.location = 'login.html';
} catch (ex) {
++this.#failures;
if (this.#failures > 3) {
Expand Down Expand Up @@ -152,7 +152,7 @@ class LoginManager {
this.#loggingIn = true;
try {
await ServerCommands.changePassword(username, '' /*oldPass*/, this.#password.value);
window.location = '/';
window.location = 'index.html';
} catch (ex) {
errorToast(`Setting password failed: ${ex.message}`, 5000);
} finally {
Expand Down
1 change: 1 addition & 0 deletions Client/Style/Settings.css
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@
& #serverSettingsScroll {
padding: 20px;
max-height: calc(100vh - 230px); /* 230 is fragile comes from various padding and margins. Something better should be done here. */
min-height: 300px;
overflow: auto;
}

Expand Down
4 changes: 2 additions & 2 deletions Client/login.html
Original file line number Diff line number Diff line change
Expand Up @@ -278,10 +278,10 @@ <h1 id="siteTitle">Marker Editor</h1>
<script type="importmap">
{
"imports": {
"./" : "/",
"./" : "[[BASE_URL]]",
"StickySettings": "./Client/Script/StickySettings/index.js",
"ServerSettingsDialog": "./Client/Script/ServerSettingsDialog/index.js",
"Shared/*": "./Shared/*"
"/Shared/": "[[BASE_URL]]Shared/"
}
}
</script>
Expand Down
61 changes: 46 additions & 15 deletions Server/GETHandler.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,21 +48,24 @@ class GETHandler {
static async handleRequest(req, res) {
let url = req.url;

// Even if a baseUrl is set, also parse baseless requests.
const usingBase = url.startsWith(Config.baseUrl());
const baseUrl = usingBase ? Config.baseUrl() : '/';

if (usingBase) {
url = '/' + url.substring(baseUrl.length);
}

// GET requests should always have a URL
if (!url) {
res.writeHead(400).end(`Invalid request - no URL found`);
return;
}

if (!GETHandler.#requestAllowed(req, res)) {
if (!GETHandler.#requestAllowed(url, req, res)) {
return; // #requestAllowed handles writing the response.
}

// Only production files use cache-busing techniques, so don't
// set a cache-age if we're using raw dev files. Make an exception
// for production HTML though, as they do not use cache-busting.
let cacheable = isBinary();

if (url === '/') {
url = '/index.html';
}
Expand All @@ -71,23 +74,30 @@ class GETHandler {

if (urlPlain === '/index.html') {
if (Config.useAuth() && !User.signedIn(req)) {
res.redirect('/login.html');
res.redirect(`${baseUrl}login.html`);
return;
}

cacheable = false;
GETHandler.serveWithBaseUrl(url, baseUrl, res);
return;
}

if (urlPlain === '/login.html') {
if (!Config.useAuth() || User.signedIn(req)) {
res.redirect('/');
res.redirect(baseUrl);
return;
}

url = '/Client/login.html';
cacheable = false;
GETHandler.serveWithBaseUrl(url, baseUrl, res);
return;
}

// Only production files use cache-busing techniques, so don't
// set a cache-age if we're using raw dev files. Make an exception
// for production HTML though, as they do not use cache-busting.
const cacheable = isBinary();

switch (url.substring(0, 3)) {
case '/i/':
return ImageHandler.GetSvgIcon(url, res, cacheable);
Expand Down Expand Up @@ -129,25 +139,46 @@ class GETHandler {
/**
* Determines whether the given request is allowed. There are special cases for users
* who aren't signed in, and when the user hasn't gone through the first-time setup yet.
* @param {string} url The requested URL (with baseUrl stripped if present)
* @param {ExpressRequest} req
* @param {ExpressResponse} res */
static #requestAllowed(req, res) {
static #requestAllowed(url, req, res) {
if (Config.useAuth() && !req.session.authenticated) {
if (!this.#noAuthRegex.test(req.url.split('?')[0])) {
res.writeHead(401).end(`Cannot access resource without authorization: "${req.url}"`);
if (!this.#noAuthRegex.test(url.split('?')[0])) {
res.writeHead(401).end(`Cannot access resource without authorization: "${url}"`);
return false;
}
}

// Most GET requests are allowed in first run, except for thumbnails and export
if (GetServerState() === ServerState.RunningWithoutConfig
&& (req.url.substring(0, 3) === '/t/' || req.url.startsWith('/export/'))) {
res.writeHead(503).end(`Disallowed request during First Run experience: "${req.url}"`);
&& (url.substring(0, 3) === '/t/' || url.startsWith('/export/'))) {
res.writeHead(503).end(`Disallowed request during First Run experience: "${url}"`);
return false;
}

return true;
}

/**
* Serve an HTTP file that has [[BASE_URL]] indicators that should be
* replaced with the user-defined base url.
* @param {string} url The URL to retrieve
* @param {string} baseUrl The base URL to replace the placeholder with.
* @param {ExpressResponse} res */
static serveWithBaseUrl(url, baseUrl, res) {
const mimetype = contentType(lookup(url));
readFile(join(ProjectRoot(), url), (err, contents) => {
if (err) {
Log.warn(`Unable to serve ${url}: ${err.message}`);
res.writeHead(404).end(`Not Found: ${err.message}`);
return;
}

const html = contents.toString('utf-8').replaceAll('[[BASE_URL]]', baseUrl);
sendCompressedData(res, 200, html, mimetype, 0 /*cacheAge*/);
});
}
}

/**
Expand Down
11 changes: 8 additions & 3 deletions Server/MarkerEditor.js
Original file line number Diff line number Diff line change
Expand Up @@ -392,8 +392,8 @@ async function createServer(host, port, ssl, resolve) {
message : { Error : 'Too many requests' }
};

app.use(`/${PostCommands.Login}`, rateLimit(strictRateLimit));
app.use(`/${PostCommands.NeedsPassword}`, rateLimit(strictRateLimit));
app.use(`*/${PostCommands.Login}`, rateLimit(strictRateLimit));
app.use(`*/${PostCommands.NeedsPassword}`, rateLimit(strictRateLimit));

// Strict limit for other POST commands as well, but no limit for authenticated users.
app.post('*', rateLimit({ skip : (req, _res) => req.session?.authenticated, ...strictRateLimit }), serverPost);
Expand Down Expand Up @@ -559,7 +559,12 @@ ServerEventHandler.on(ServerEvents.SoftRestart, async (response, data, resolve)
* @param {ExpressRequest} req
* @param {ExpressResponse} res */
async function handlePost(req, res) {
const url = req.url.toLowerCase();
let url = req.url.toLowerCase();
const baseUrl = Config.baseUrl();
if (url.startsWith(baseUrl)) {
url = '/' + url.substring(baseUrl.length);
}

const endpointIndex = url.indexOf('?');
const endpoint = endpointIndex === -1 ? url.substring(1) : url.substring(1, endpointIndex);
if (GetServerState() === ServerState.Suspended
Expand Down
5 changes: 5 additions & 0 deletions Shared/ServerConfig.js
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ isDefault means value is the default value
* @property {TypedSetting<string>} database
* @property {TypedSetting<string>} host
* @property {TypedSetting<number>} port
* @property {TypedSetting<string>} baseUrl
* @property {TypedSetting<string>} logLevel
* @property {SslSettings} ssl
* @property {AuthenticationSettings} authentication
Expand All @@ -67,6 +68,7 @@ isDefault means value is the default value
* @property {TypedSetting<string>} database
* @property {TypedSetting<string>} host
* @property {TypedSetting<number>} port
* @property {TypedSetting<number>} port
* @property {TypedSetting<string>} logLevel
* @property {TypedSetting<boolean>} sslEnabled Whether to enable the HTTPS server
* @property {TypedSetting<string>} sslHost The address to listen on.
Expand Down Expand Up @@ -188,6 +190,8 @@ export const ServerSettings = {
Host : 'host',
/** @readonly The port to listen on. */
Port : 'port',
/** @readonly The base URL for this application. Useful for reverse proxies. */
BaseUrl : 'baseUrl',
/** @readonly The server-side logging level. */
LogLevel : 'logLevel',
/** @readonly Whether to auto-open a browser window on launch. */
Expand Down Expand Up @@ -245,6 +249,7 @@ export function allServerSettings() {
ServerSettings.Database,
ServerSettings.Host,
ServerSettings.Port,
ServerSettings.BaseUrl,
ServerSettings.LogLevel,
ServerSettings.UseSsl,
ServerSettings.SslHost,
Expand Down
4 changes: 2 additions & 2 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -274,11 +274,11 @@ <h1 id="siteTitle">Marker Editor <i id="settings" class="hidden" title="Settings
<script type="importmap">
{
"imports": {
"./" : "/",
"./" : "[[BASE_URL]]",
"ResultRow": "./Client/Script/ResultRow/index.js",
"StickySettings": "./Client/Script/StickySettings/index.js",
"ServerSettingsDialog": "./Client/Script/ServerSettingsDialog/index.js",
"Shared/*": "./Shared/*"
"/Shared/": "[[BASE_URL]]Shared/"
}
}
</script>
Expand Down

0 comments on commit 224f6c8

Please sign in to comment.