diff --git a/apps/client/src/translations/cn/translation.json b/apps/client/src/translations/cn/translation.json
index 5a21313421..6b2b2df729 100644
--- a/apps/client/src/translations/cn/translation.json
+++ b/apps/client/src/translations/cn/translation.json
@@ -1743,9 +1743,17 @@
"show_login_link": "在共享主题中显示登录链接",
"show_login_link_description": "在共享页面底部添加登录链接",
"check_share_root": "检查共享根状态",
+ "check_share_root_error": "检查共享根状态时发生意外错误,请检查日志以获取更多信息。",
+ "share_note_title": "'{{noteTitle}}'",
"share_root_found": "共享根笔记 '{{noteTitle}}' 已准备好",
"share_root_not_found": "未找到带有 #shareRoot 标签的笔记",
- "share_root_not_shared": "笔记 '{{noteTitle}}' 具有 #shareRoot 标签,但未共享"
+ "share_root_not_shared": "笔记 '{{noteTitle}}' 具有 #shareRoot 标签,但未共享",
+ "share_root_multiple_found": "找到多个具有 #shareRoot 标签的共享笔记:{{- foundNoteTitles}}。将使用笔记 {{- activeNoteTitle}} 作为共享根笔记。",
+ "share_path": "共享路径",
+ "share_path_description": "共享笔记的 URL 前缀(例如 '/share' --> '/share/noteId' 或 '/custom-path' --> '/custom-path/noteId')。支持多级嵌套(例如 '/custom-path/sub-path' --> '/custom-path/sub-path/noteId')。刷新页面以应用更改。",
+ "share_path_placeholder": "/share 或 /custom-path",
+ "share_subtree": "共享子树",
+ "share_subtree_description": "共享整个子树,而不是仅共享笔记"
},
"time_selector": {
"invalid_input": "输入的时间值不是有效数字。",
diff --git a/apps/client/src/translations/en/translation.json b/apps/client/src/translations/en/translation.json
index 2f60eb8c3e..fb3d3d34d1 100644
--- a/apps/client/src/translations/en/translation.json
+++ b/apps/client/src/translations/en/translation.json
@@ -1907,9 +1907,17 @@
"show_login_link": "Show Login link in Share theme",
"show_login_link_description": "Add a login link to the Share page footer",
"check_share_root": "Check Share Root Status",
+ "check_share_root_error": "An unexpected error happened while checking the Share Root Status, please check the logs for more information.",
+ "share_note_title": "'{{noteTitle}}'",
"share_root_found": "Share root note '{{noteTitle}}' is ready",
"share_root_not_found": "No note with #shareRoot label found",
- "share_root_not_shared": "Note '{{noteTitle}}' has #shareRoot label but is not shared"
+ "share_root_not_shared": "Note '{{noteTitle}}' has #shareRoot label but is not Shared",
+ "share_root_multiple_found": "Found multiple shared notes with a #shareRoot label: {{- foundNoteTitles}}. The note {{- activeNoteTitle}} will be used as shared root note.",
+ "share_path": "Share path",
+ "share_path_description": "The url prefix for shared notes (e.g. '/share' --> '/share/noteId' or '/custom-path' --> '/custom-path/noteId'). Multiple levels of nesting are supported (e.g. '/custom-path/sub-path' --> '/custom-path/sub-path/noteId'). Refresh the page to apply the changes.",
+ "share_path_placeholder": "/share or /custom-path",
+ "share_subtree": "Share subtree",
+ "share_subtree_description": "Share the entire subtree, not just the note"
},
"time_selector": {
"invalid_input": "The entered time value is not a valid number.",
diff --git a/apps/client/src/widgets/type_widgets/options/other/share_path_utils.ts b/apps/client/src/widgets/type_widgets/options/other/share_path_utils.ts
new file mode 100644
index 0000000000..2ff042b1b2
--- /dev/null
+++ b/apps/client/src/widgets/type_widgets/options/other/share_path_utils.ts
@@ -0,0 +1,13 @@
+// Ensure sharePath always starts with a single slash and does not end with (one or multiple) trailing slashes
+export function normalizeSharePathInput(sharePathInput: string) {
+ const REGEXP_STARTING_SLASH = /^\/+/g;
+ const REGEXP_TRAILING_SLASH = /\b\/+$/g;
+
+ const normalizedSharePath = (!sharePathInput.startsWith("/")
+ ? `/${sharePathInput}`
+ : sharePathInput)
+ .replaceAll(REGEXP_TRAILING_SLASH, "")
+ .replaceAll(REGEXP_STARTING_SLASH, "/");
+
+ return normalizedSharePath;
+}
diff --git a/apps/client/src/widgets/type_widgets/options/other/share_settings.spec.ts b/apps/client/src/widgets/type_widgets/options/other/share_settings.spec.ts
new file mode 100644
index 0000000000..aa9c4efc5b
--- /dev/null
+++ b/apps/client/src/widgets/type_widgets/options/other/share_settings.spec.ts
@@ -0,0 +1,58 @@
+import { describe, it, expect } from "vitest";
+import { normalizeSharePathInput } from "./share_path_utils.js";
+
+type TestCase any> = [
+ desc: string,
+ fnParams: Parameters,
+ expected: ReturnType
+];
+
+describe("ShareSettingsOptions", () => {
+
+ describe("#normalizeSharePathInput", () => {
+
+ const testCases: TestCase[] = [
+ [
+ "should handle multiple trailing '/' and remove them completely",
+ ["/trailingtest////"],
+ "/trailingtest"
+ ],
+ [
+ "should handle multiple starting '/' and replace them by a single '/'",
+ ["////startingtest"],
+ "/startingtest"
+ ],
+ [
+ "should handle multiple starting & trailing '/' and replace them by a single '/'",
+ ["////startingAndTrailingTest///"],
+ "/startingAndTrailingTest"
+ ],
+ [
+ "should not remove any '/' other than at the end or start of the input",
+ ["/test/with/subpath"],
+ "/test/with/subpath"
+ ],
+ [
+ "should prepend the string with a '/' if it does not start with one",
+ ["testpath"],
+ "/testpath"
+ ],
+ [
+ "should not change anything, if the string is a single '/'",
+ ["/"],
+ "/"
+ ],
+ ];
+
+ testCases.forEach((testCase) => {
+ const [desc, fnParams, expected] = testCase;
+ it(desc, () => {
+ const actual = normalizeSharePathInput(...fnParams);
+ expect(actual).toStrictEqual(expected);
+ });
+ });
+
+
+ })
+
+})
\ No newline at end of file
diff --git a/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Advanced Usage/Sharing.html b/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Advanced Usage/Sharing.html
index 95cae2d8d8..723e4d3b0e 100644
--- a/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Advanced Usage/Sharing.html
+++ b/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Advanced Usage/Sharing.html
@@ -6,8 +6,7 @@
-
-
Features, interaction and limitations
+
Features, interaction and limitations
Searching by note title.
Automatic dark/light mode based on the user's browser settings.
@@ -189,11 +188,9 @@
Sharing a note
-
-
Access the Shared Note: The link provided will open the
- note in your browser. If your server is not configured with a public IP,
- the URL will refer to localhost (127.0.0.1).
-
+
Access the Shared Note: The link provided will open the
+ note in your browser. If your server is not configured with a public IP,
+ the URL will refer to localhost (127.0.0.1).
Sharing a note subtree
When you share a note, you actually share the entire subtree of notes
diff --git a/apps/server/src/routes/api/options.ts b/apps/server/src/routes/api/options.ts
index 8e9d6e1a69..0acc301112 100644
--- a/apps/server/src/routes/api/options.ts
+++ b/apps/server/src/routes/api/options.ts
@@ -97,6 +97,8 @@ const ALLOWED_OPTIONS = new Set([
"allowedHtmlTags",
"redirectBareDomain",
"showLoginInShareTheme",
+ "shareSubtree",
+ "sharePath",
"splitEditorOrientation",
"seenCallToActions",
diff --git a/apps/server/src/routes/routes.ts b/apps/server/src/routes/routes.ts
index f1aeb92097..641970842e 100644
--- a/apps/server/src/routes/routes.ts
+++ b/apps/server/src/routes/routes.ts
@@ -80,6 +80,7 @@ const GET = "get",
DEL = "delete";
function register(app: express.Application) {
+
route(GET, "/", [auth.checkAuth, csrfMiddleware], indexRoute.index);
route(GET, "/login", [auth.checkAppInitialized, auth.checkPasswordSet], loginRoute.loginPage);
route(GET, "/set-password", [auth.checkAppInitialized, auth.checkPasswordNotSet], loginRoute.setPasswordPage);
diff --git a/apps/server/src/services/auth.ts b/apps/server/src/services/auth.ts
index b10ef80977..973b117ff3 100644
--- a/apps/server/src/services/auth.ts
+++ b/apps/server/src/services/auth.ts
@@ -37,9 +37,26 @@ function checkAuth(req: Request, res: Response, next: NextFunction) {
// Check if any note has the #shareRoot label
const shareRootNotes = attributes.getNotesWithLabel("shareRoot");
if (shareRootNotes.length === 0) {
+ // should this be a translation string?
res.status(404).json({ message: "Share root not found. Please set up a note with #shareRoot label first." });
return;
}
+
+ // Get the configured share path
+ const sharePath = options.getOption("sharePath") || '/share';
+
+ // Check if we're already at the share path to prevent redirect loops
+ if (req.path === sharePath || req.path.startsWith(`${sharePath}/`)) {
+ log.info(`checkAuth: Already at share path, skipping redirect. Path: ${req.path}, SharePath: ${sharePath}`);
+ next();
+ return;
+ }
+
+ // Redirect to the share path
+ log.info(`checkAuth: Redirecting to share path. From: ${req.path}, To: ${sharePath}`);
+ res.redirect(`${sharePath}/`);
+ } else {
+ res.redirect("login");
}
res.redirect(hasRedirectBareDomain ? "share" : "login");
} else if (currentTotpStatus !== lastAuthState.totpEnabled || currentSsoStatus !== lastAuthState.ssoEnabled) {
@@ -81,15 +98,6 @@ function checkApiAuthOrElectron(req: Request, res: Response, next: NextFunction)
}
}
-function checkApiAuth(req: Request, res: Response, next: NextFunction) {
- if (!req.session.loggedIn && !noAuthentication) {
- console.warn(`Missing session with ID '${req.sessionID}'.`);
- reject(req, res, "Logged in session not found");
- } else {
- next();
- }
-}
-
function checkAppInitialized(req: Request, res: Response, next: NextFunction) {
if (!sqlInit.isDbInitialized()) {
res.redirect("setup");
diff --git a/apps/server/src/services/options_init.ts b/apps/server/src/services/options_init.ts
index 0908a05126..2154230757 100644
--- a/apps/server/src/services/options_init.ts
+++ b/apps/server/src/services/options_init.ts
@@ -196,8 +196,10 @@ const defaultOptions: DefaultOption[] = [
},
// Share settings
+ { name: "sharePath", value: "/share", isSynced: true },
{ name: "redirectBareDomain", value: "false", isSynced: true },
{ name: "showLoginInShareTheme", value: "false", isSynced: true },
+ { name: "shareSubtree", value: "false", isSynced: true },
// AI Options
{ name: "aiEnabled", value: "false", isSynced: true },
diff --git a/apps/server/src/share/content_renderer.ts b/apps/server/src/share/content_renderer.ts
index 83ddfde2e5..acd4bb451b 100644
--- a/apps/server/src/share/content_renderer.ts
+++ b/apps/server/src/share/content_renderer.ts
@@ -32,7 +32,7 @@ export function getContent(note: SNote) {
};
if (note.type === "text") {
- renderText(result, note);
+ renderText(result, note, relativePath);
} else if (note.type === "code") {
renderCode(result);
} else if (note.type === "mermaid") {
@@ -106,10 +106,10 @@ function renderText(result: Result, note: SNote) {
if (result.content.includes(``)) {
result.header += `
-
-
-
-
+
+
+
+