diff --git a/cmd/server.ts b/cmd/server.ts index d903131c..d6ae01dd 100644 --- a/cmd/server.ts +++ b/cmd/server.ts @@ -62,6 +62,22 @@ export async function serveCommand( ); } + let hostUrlPrefix = Deno.env.get("SB_URL_PREFIX"); + if (hostUrlPrefix) { + if (!hostUrlPrefix.startsWith("/")) { + hostUrlPrefix = "/" + hostUrlPrefix; + } + if (hostUrlPrefix.endsWith("/")) { + hostUrlPrefix = hostUrlPrefix.replace(/\/*$/, ""); + } + + if (hostUrlPrefix !== "") { + console.log(`Host URL Prefix: ${hostUrlPrefix}`); + } else { + hostUrlPrefix = undefined; + } + } + const userAuth = options.user ?? Deno.env.get("SB_USER"); let userCredentials: AuthOptions | undefined; @@ -100,7 +116,7 @@ export async function serveCommand( const manifestName = Deno.env.get("SB_NAME"); const manifestDescription = Deno.env.get("SB_DESCRIPTION"); - if (manifestName || manifestDescription) { + if (manifestName || manifestDescription || hostUrlPrefix) { const manifestData = JSON.parse( clientAssets.readTextFileSync(".client/manifest.json"), ); @@ -110,6 +126,13 @@ export async function serveCommand( if (manifestDescription) { manifestData.description = manifestDescription; } + if (hostUrlPrefix) { + for (const icon of manifestData.icons) { + if (icon.src) icon.src = hostUrlPrefix + icon.src; + } + manifestData.start_url = hostUrlPrefix + manifestData.start_url; + manifestData.scope = hostUrlPrefix + manifestData.scope; + } clientAssets.writeTextFileSync( ".client/manifest.json", "application/json", @@ -131,6 +154,7 @@ export async function serveCommand( shellBackend: backendConfig, enableSpaceScript, pagesPath: folder, + hostUrlPrefix, }); await httpServer.start(); diff --git a/lib/url_prefix.test.ts b/lib/url_prefix.test.ts new file mode 100644 index 00000000..b5b66acb --- /dev/null +++ b/lib/url_prefix.test.ts @@ -0,0 +1,204 @@ +import { assertEquals } from "@std/assert"; +import { applyUrlPrefix, removeUrlPrefix } from "$lib/url_prefix.ts"; + +Deno.test("url_prefix - removeUrlPrefix - with value", async (t) => { + await t.step("Absolute URL, present, should be removed", () => { + assertEquals( + removeUrlPrefix("http://myserver/sb/relevant", "/sb"), + "http://myserver/relevant", + ); + assertEquals( + removeUrlPrefix("https://myserver/sb/relevant", "/sb"), + "https://myserver/relevant", + ); + }); + + await t.step("Absolute URL, present, should only remove leading", () => { + assertEquals( + removeUrlPrefix("http://myserver/sb/sb/relevant/sb", "/sb"), + "http://myserver/sb/relevant/sb", + ); + assertEquals( + removeUrlPrefix("http://myserver/relevant/sb", "/sb"), + "http://myserver/relevant/sb", + ); + }); + + await t.step("Absolute URL, absent, should be untouched", () => { + assertEquals( + removeUrlPrefix("http://myserver/other/relevant", "/sb"), + "http://myserver/other/relevant", + ); + assertEquals( + removeUrlPrefix("https://myserver/other/relevant", "/sb"), + "https://myserver/other/relevant", + ); + }); + + await t.step("Absolute URL, queryString, should be preserved", () => { + assertEquals( + removeUrlPrefix("http://myserver/sb/sb/relevant/sb?param=arg", "/sb"), + "http://myserver/sb/relevant/sb?param=arg", + ); + }); + + await t.step("Absolute URL, unsupported, should be untouched", () => { + assertEquals( + removeUrlPrefix("ftp://myserver/sb/relevant", "/sb"), + "ftp://myserver/sb/relevant", + ); + }); + + await t.step("Host-Relative URL, present, should be removed", () => { + assertEquals(removeUrlPrefix("/sb/relevant", "/sb"), "/relevant"); + }); + + await t.step("Host-Relative URL, present, should only remove leading", () => { + assertEquals( + removeUrlPrefix("/sb/sb/relevant/sb", "/sb"), + "/sb/relevant/sb", + ); + assertEquals(removeUrlPrefix("/relevant/sb", "/sb"), "/relevant/sb"); + }); + + await t.step("Host-Relative URL, queryString, should be preserved", () => { + assertEquals( + removeUrlPrefix("/sb/sb/relevant/sb?param=arg", "/sb"), + "/sb/relevant/sb?param=arg", + ); + assertEquals( + removeUrlPrefix("/relevant/sb?param=arg", "/sb"), + "/relevant/sb?param=arg", + ); + }); + + await t.step("Host-Relative URL, absent, should be untouched", () => { + assertEquals(removeUrlPrefix("/other/relevant", "/sb"), "/other/relevant"); + }); + + await t.step("Page-Relative URL, should be untouched", () => { + assertEquals(removeUrlPrefix("sb/relevant", "/sb"), "sb/relevant"); + }); +}); + +Deno.test("url_prefix - removeUrlPrefix - no value", async (t) => { + await t.step("Absolute URL, should be untouched", () => { + assertEquals( + removeUrlPrefix("http://myserver/sb/relevant", ""), + "http://myserver/sb/relevant", + ); + assertEquals( + removeUrlPrefix("https://myserver/sb/relevant"), + "https://myserver/sb/relevant", + ); + }); + + await t.step("Host-Relative URL, should be untouched", () => { + assertEquals(removeUrlPrefix("/sb/relevant", ""), "/sb/relevant"); + assertEquals(removeUrlPrefix("/sb/relevant"), "/sb/relevant"); + }); + + await t.step("Page-Relative URL, should be untouched", () => { + assertEquals(removeUrlPrefix("sb/relevant", ""), "sb/relevant"); + assertEquals(removeUrlPrefix("sb/relevant"), "sb/relevant"); + }); +}); + +Deno.test("url_prefix - applyUrlPrefix - with value", async (t) => { + await t.step("string, Absolute URL, should be prefixed", () => { + assertEquals( + applyUrlPrefix("http://myserver/relevant", "/sb"), + "http://myserver/sb/relevant", + ); + assertEquals( + applyUrlPrefix("https://myserver/relevant", "/sb"), + "https://myserver/sb/relevant", + ); + }); + + await t.step("string, Absolute URL, should not care about dups", () => { + assertEquals( + applyUrlPrefix("http://myserver/sb/relevant/sb", "/sb"), + "http://myserver/sb/sb/relevant/sb", + ); + }); + + await t.step("string, Absolute URL, queryString should be preserved", () => { + assertEquals( + applyUrlPrefix("http://myserver/sb/relevant/sb?param=arg", "/sb"), + "http://myserver/sb/sb/relevant/sb?param=arg", + ); + }); + + await t.step("string, Absolute URL, unsupported, should be untouched", () => { + assertEquals( + applyUrlPrefix("ftp://myserver/relevant", "/sb"), + "ftp://myserver/relevant", + ); + }); + + await t.step("string, Host-Relative URL, should be prefixed", () => { + assertEquals(applyUrlPrefix("/relevant", "/sb"), "/sb/relevant"); + }); + + await t.step("string, Host-Relative URL, should not care about dups", () => { + assertEquals( + applyUrlPrefix("/sb/relevant/sb", "/sb"), + "/sb/sb/relevant/sb", + ); + }); + + await t.step( + "string, Host-Relative URL, queryString should be preserved", + () => { + assertEquals( + applyUrlPrefix("/sb/relevant/sb?param=arg", "/sb"), + "/sb/sb/relevant/sb?param=arg", + ); + }, + ); + + await t.step("string, Page-Relative URL, should be untouched", () => { + assertEquals(applyUrlPrefix("relevant", "/sb"), "relevant"); + }); + + await t.step("URL object, Absolute URL, should be prefixed", () => { + assertEquals( + applyUrlPrefix(new URL("http://myserver/relevant"), "/sb"), + new URL("http://myserver/sb/relevant"), + ); + }); + + await t.step( + "URL object, Absolute URL, queryString should be preserved", + () => { + assertEquals( + applyUrlPrefix(new URL("http://myserver/relevant?param=arg"), "/sb"), + new URL("http://myserver/sb/relevant?param=arg"), + ); + }, + ); +}); + +Deno.test("url_prefix - applyUrlPrefix - no value", async (t) => { + await t.step("Absolute URL, should be untouched", () => { + assertEquals( + applyUrlPrefix("http://myserver/relevant", ""), + "http://myserver/relevant", + ); + assertEquals( + applyUrlPrefix("https://myserver/relevant"), + "https://myserver/relevant", + ); + }); + + await t.step("Host-Relative URL, should be untouched", () => { + assertEquals(applyUrlPrefix("/relevant", ""), "/relevant"); + assertEquals(applyUrlPrefix("/relevant"), "/relevant"); + }); + + await t.step("Page-Relative URL, should be untouched", () => { + assertEquals(applyUrlPrefix("relevant", ""), "relevant"); + assertEquals(applyUrlPrefix("relevant"), "relevant"); + }); +}); diff --git a/lib/url_prefix.ts b/lib/url_prefix.ts new file mode 100644 index 00000000..8f24e233 --- /dev/null +++ b/lib/url_prefix.ts @@ -0,0 +1,44 @@ +export function removeUrlPrefix(url: string, prefix?: string): string { + if (!prefix || prefix === "") return url; + + if (url.startsWith("http://") || url.startsWith("https://")) { + const parsedUrl = new URL(url); + if (parsedUrl.pathname.startsWith(prefix)) { + parsedUrl.pathname = parsedUrl.pathname.substring( + prefix.length, + ); + return parsedUrl.href; + } else { + return url; + } + } else if (url.startsWith(prefix)) { + return url.substring(prefix.length); + } else { + return url; + } +} + +export function applyUrlPrefix( + url: T, + prefix?: string, +): T { + if (!prefix || prefix === "") return url; + + if (typeof url === "string") { + const urlString = url as string; + + if (urlString.startsWith("http://") || urlString.startsWith("https://")) { + return applyUrlPrefix(new URL(urlString), prefix).href as T; + } else if (urlString.startsWith("/")) { + return (prefix + urlString) as T; + } else { + return url; //return page-relative paths as-is + } + } else if (url.protocol === "http:" || url.protocol === "https:") { + const urlObj = new URL(url); + urlObj.pathname = prefix + urlObj.pathname; + return urlObj as T; + } else { + return url; + } +} diff --git a/plugs/markdown/preview.ts b/plugs/markdown/preview.ts index 0e557c89..8f11df4a 100644 --- a/plugs/markdown/preview.ts +++ b/plugs/markdown/preview.ts @@ -3,7 +3,6 @@ import { clientStore, editor, markdown, - system, } from "@silverbulletmd/silverbullet/syscalls"; import { renderMarkdownToHtml } from "./markdown_render.ts"; import { @@ -38,7 +37,7 @@ export async function updateMarkdownPreview() { "rhs", 2, ` - + - - - + + + diff --git a/web/navigator.ts b/web/navigator.ts index cb23be38..0beb7bcd 100644 --- a/web/navigator.ts +++ b/web/navigator.ts @@ -57,18 +57,18 @@ export class PathPageNavigator { globalThis.history.replaceState( cleanState, "", - `/${encodePageURI(currentState.page)}`, + `${document.baseURI}${encodePageURI(currentState.page)}`, ); globalThis.history.pushState( ref, "", - `/${encodePageURI(ref.page)}`, + `${document.baseURI}${encodePageURI(ref.page)}`, ); } else { globalThis.history.replaceState( ref, "", - `/${encodePageURI(ref.page)}`, + `${document.baseURI}${encodePageURI(ref.page)}`, ); } @@ -157,7 +157,7 @@ export class PathPageNavigator { export function parseRefFromURI(): Ref { const locationRef = parseRef(decodeURIComponent( - location.pathname.substring(1), + location.href.substring(document.baseURI.length), //this essentially returns location with prefix and leading slash removed (equivalent to location.pathname.substring(prefix.length).substring(1)), )); if (location.hash && locationRef.kind === "page") { diff --git a/web/service_worker.ts b/web/service_worker.ts index fbe7b98e..b226fe11 100644 --- a/web/service_worker.ts +++ b/web/service_worker.ts @@ -9,6 +9,23 @@ import { const CACHE_NAME = "{{CACHE_NAME}}"; +//`location.href` minus this worker's filename will be our base URL, including any URL prefix +//(-1 is to remove the trailing '/') +const workerFilename = location.pathname.substring( + location.pathname.lastIndexOf("/") + 1, +); +const baseURI = location.href.substring( + 0, + location.href.length - workerFilename.length - 1, +); +const basePathName = location.pathname.substring( + 0, + location.pathname.length - workerFilename.length - 1, +); +console.log( + `[Service Worker] Established baseURI=[${baseURI}]; basePathName=[${basePathName}]`, +); + const precacheFiles = Object.fromEntries([ "/", "/.client/client.js", @@ -21,7 +38,7 @@ const precacheFiles = Object.fromEntries([ "/.client/logo-dock.png", "/.client/main.css", "/.client/manifest.json", -].map((path) => [path, path + "?v=" + CACHE_NAME, path])); // Cache busting +].map((path) => [path, `${baseURI}${path}?v=${CACHE_NAME}`, path])); // Cache busting self.addEventListener("install", (event: any) => { console.log("[Service worker]", "Installing service worker..."); @@ -71,8 +88,10 @@ const filesContentPrefix = ["file", "content"]; self.addEventListener("fetch", (event: any) => { const url = new URL(event.request.url); + const pathname = url.pathname.substring(basePathName.length); //url.pathname with any URL prefix removed + // Use the custom cache key if available, otherwise use the request URL - const cacheKey = precacheFiles[url.pathname] || event.request.url; + const cacheKey = precacheFiles[pathname] || event.request.url; event.respondWith( (async () => { @@ -80,7 +99,7 @@ self.addEventListener("fetch", (event: any) => { const requestUrl = new URL(request.url); // Are we fetching a URL from the same origin as the app? If not, we don't handle it and pass it on - if (location.host !== requestUrl.host) { + if (!requestUrl.href.startsWith(baseURI)) { return fetch(request); } @@ -101,7 +120,7 @@ self.addEventListener("fetch", (event: any) => { return fetch(request); } - const pathname = requestUrl.pathname; + const pathname = requestUrl.pathname.substring(basePathName.length); //requestUrl.pathname without with any URL prefix removed if ( pathname === "/.auth" || diff --git a/web/space_lua/stdlib/space_lua.ts b/web/space_lua/stdlib/space_lua.ts index 09baee65..8695ead5 100644 --- a/web/space_lua/stdlib/space_lua.ts +++ b/web/space_lua/stdlib/space_lua.ts @@ -146,7 +146,8 @@ export const spaceluaApi = new LuaTable({ if (typeof location === "undefined") { return null; } else { - return location.protocol + "//" + location.host; + //NOTE: Removing trailing slash to stay compatible with original code: `location.protocol + "//" + location.host;` + return document.baseURI.replace(/\/*$/, ""); } }, ), diff --git a/web/styles/main.scss b/web/styles/main.scss index 91aeabc6..2fc7630e 100644 --- a/web/styles/main.scss +++ b/web/styles/main.scss @@ -6,28 +6,28 @@ @font-face { font-family: "iA-Mono"; - src: url("/.client/iAWriterMonoS-Regular.woff2"); + src: url("iAWriterMonoS-Regular.woff2"); font-weight: normal; font-style: normal; } @font-face { font-family: "iA-Mono"; - src: url("/.client/iAWriterMonoS-Bold.woff2"); + src: url("iAWriterMonoS-Bold.woff2"); font-weight: bold; font-style: normal; } @font-face { font-family: "iA-Mono"; - src: url("/.client/iAWriterMonoS-Italic.woff2"); + src: url("iAWriterMonoS-Italic.woff2"); font-weight: normal; font-style: italic; } @font-face { font-family: "iA-Mono"; - src: url("/.client/iAWriterMonoS-BoldItalic.woff2"); + src: url("iAWriterMonoS-BoldItalic.woff2"); font-weight: bold; font-style: italic; } diff --git a/web/syscalls/system.ts b/web/syscalls/system.ts index 09abe15b..adee8e09 100644 --- a/web/syscalls/system.ts +++ b/web/syscalls/system.ts @@ -158,7 +158,7 @@ export function systemSyscalls( await client.ds.batchDelete(allKeys); } if (logout) { - location.href = "/.logout"; + location.href = ".logout"; } else { alert("Client wiped, feel free to navigate elsewhere"); } diff --git a/website/Install/Configuration.md b/website/Install/Configuration.md index 54df1e8d..a910eb0a 100644 --- a/website/Install/Configuration.md +++ b/website/Install/Configuration.md @@ -8,6 +8,7 @@ SilverBullet is primarily configured via environment variables. This page gives # Network * `SB_HOSTNAME`: Set to the hostname to bind to (defaults to `127.0.0.0`, set to `0.0.0.0` to accept outside connections for the local deno setup, defaults to `0.0.0.0` for docker) * `SB_PORT`: Sets the port to listen to, e.g. `SB_PORT=1234`, default is `3000` +* `SB_URL_PREFIX`: Host SilverBullet on a particular URL prefix, e.g. `SB_URL_PREFIX=/notes` # Authentication SilverBullet supports basic authentication for a single user.