mirror of
				https://github.com/zadam/trilium.git
				synced 2025-10-31 02:16:05 +01:00 
			
		
		
		
	Compare commits
	
		
			40 Commits
		
	
	
		
			v0.99.3
			...
			feat/clean
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|  | 64f191b83b | ||
|  | 979fbe2e76 | ||
|  | ea5564c6e6 | ||
|  | 6ab8750726 | ||
|  | 923eabd750 | ||
|  | c52d6a6384 | ||
|  | 519b9648c6 | ||
|  | b25fd6ca3a | ||
|  | 2bd8c215ff | ||
|  | 34e7901de9 | ||
|  | 30a191cedf | ||
|  | 128d8907c3 | ||
|  | 3b1d7d045e | ||
|  | 0ae9a29e0d | ||
|  | f4b5ed73ad | ||
|  | 43166dbeb5 | ||
|  | 00b5aef890 | ||
|  | 1b7266f083 | ||
|  | d1d4b47111 | ||
|  | c90364bd76 | ||
|  | 6dc687ef43 | ||
|  | 0e31aab1ab | ||
|  | b0030f89b7 | ||
|  | 9f0a0238cc | ||
|  | dabdfaddec | ||
|  | a8901e6dc8 | ||
|  | ab901a5d32 | ||
|  | 56fc2d9b30 | ||
|  | df45fa2e1e | ||
|  | 9a11fc13d7 | ||
|  | d72a0d3c69 | ||
|  | 0be508ed70 | ||
|  | 37f3a9b19d | ||
|  | 81d2fbc057 | ||
|  | 1aecf66cbe | ||
|  | fbe7d64e00 | ||
|  | c9d151289c | ||
|  | bba2f6db64 | ||
|  | 6725f81a00 | ||
|  | 8487c8cd03 | 
| @@ -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": "输入的时间值不是有效数字。", | ||||
|   | ||||
| @@ -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.", | ||||
|   | ||||
| @@ -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; | ||||
| } | ||||
| @@ -0,0 +1,58 @@ | ||||
| import { describe, it, expect } from "vitest"; | ||||
| import { normalizeSharePathInput } from "./share_path_utils.js"; | ||||
|  | ||||
| type TestCase<T extends (...args: any) => any> = [ | ||||
|     desc: string, | ||||
|     fnParams: Parameters<T>, | ||||
|     expected: ReturnType<T> | ||||
| ]; | ||||
|  | ||||
| describe("ShareSettingsOptions", () => { | ||||
|  | ||||
|     describe("#normalizeSharePathInput", () => { | ||||
|  | ||||
|         const testCases: TestCase<typeof normalizeSharePathInput>[] = [ | ||||
|             [ | ||||
|                 "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); | ||||
|             }); | ||||
|         }); | ||||
|  | ||||
|  | ||||
|     }) | ||||
|  | ||||
| }) | ||||
							
								
								
									
										11
									
								
								apps/server/src/assets/doc_notes/en/User Guide/User Guide/Advanced Usage/Sharing.html
									
									
									
										generated
									
									
										vendored
									
									
								
							
							
						
						
									
										11
									
								
								apps/server/src/assets/doc_notes/en/User Guide/User Guide/Advanced Usage/Sharing.html
									
									
									
										generated
									
									
										vendored
									
									
								
							| @@ -6,8 +6,7 @@ class="image"> | ||||
|   <img style="aspect-ratio:1144/660;" src="Sharing_image.png" width="1144" | ||||
|   height="660"> | ||||
|   </figure> | ||||
|    | ||||
| <h2>Features, interaction and limitations</h2> | ||||
|   <h2>Features, interaction and limitations</h2> | ||||
|   <ul> | ||||
|     <li>Searching by note title.</li> | ||||
|     <li>Automatic dark/light mode based on the user's browser settings.</li> | ||||
| @@ -189,11 +188,9 @@ class="image"> | ||||
|         <img src="Sharing_share-single-note.png" alt="Share Note"> | ||||
|       </p> | ||||
|     </li> | ||||
|     <li> | ||||
|       <p><strong>Access the Shared Note</strong>: 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 <code>localhost (127.0.0.1)</code>.</p> | ||||
|     </li> | ||||
|     <li><strong>Access the Shared Note</strong>: 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 <code>localhost (127.0.0.1)</code>.</li> | ||||
|   </ol> | ||||
|   <h2>Sharing a note subtree</h2> | ||||
|   <p>When you share a note, you actually share the entire subtree of notes | ||||
|   | ||||
| @@ -97,6 +97,8 @@ const ALLOWED_OPTIONS = new Set<OptionNames>([ | ||||
|     "allowedHtmlTags", | ||||
|     "redirectBareDomain", | ||||
|     "showLoginInShareTheme", | ||||
|     "shareSubtree", | ||||
|     "sharePath", | ||||
|     "splitEditorOrientation", | ||||
|     "seenCallToActions", | ||||
|  | ||||
|   | ||||
| @@ -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); | ||||
|   | ||||
| @@ -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"); | ||||
|   | ||||
| @@ -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 }, | ||||
|   | ||||
| @@ -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(`<span class="math-tex">`)) { | ||||
|             result.header += ` | ||||
| <script src="../${assetPath}/node_modules/katex/dist/katex.min.js"></script> | ||||
| <link rel="stylesheet" href="../${assetPath}/node_modules/katex/dist/katex.min.css"> | ||||
| <script src="../${assetPath}/node_modules/katex/dist/contrib/auto-render.min.js"></script> | ||||
| <script src="../${assetPath}/node_modules/katex/dist/contrib/mhchem.min.js"></script> | ||||
| <script src="${relativePath}${assetPath}/node_modules/katex/dist/katex.min.js"></script> | ||||
| <link rel="stylesheet" href="${relativePath}${assetPath}/node_modules/katex/dist/katex.min.css"> | ||||
| <script src="${relativePath}${assetPath}/node_modules/katex/dist/contrib/auto-render.min.js"></script> | ||||
| <script src="${relativePath}${assetPath}/node_modules/katex/dist/contrib/mhchem.min.js"></script> | ||||
| <script> | ||||
| document.addEventListener("DOMContentLoaded", function() { | ||||
|     renderMathInElement(document.getElementById('content')); | ||||
|   | ||||
| @@ -44,3 +44,39 @@ describe("Share API test", () => { | ||||
|     }); | ||||
|  | ||||
| }); | ||||
|  | ||||
| describe("Share Routes - Asset Path Calculation", () => { | ||||
|     it("should calculate correct relative path depth for different share paths", () => { | ||||
|         // Helper function to simulate the path depth calculation | ||||
|         const calculateRelativePath = (sharePath: string) => { | ||||
|             const pathDepth = sharePath.split('/').filter(segment => segment.length > 0).length; | ||||
|             return '../'.repeat(pathDepth); | ||||
|         }; | ||||
|  | ||||
|         // Test single level path | ||||
|         expect(calculateRelativePath("/share")).toBe("../"); | ||||
|  | ||||
|         // Test double level path | ||||
|         expect(calculateRelativePath("/sharePath/test")).toBe("../../"); | ||||
|  | ||||
|         // Test triple level path | ||||
|         expect(calculateRelativePath("/my/custom/share")).toBe("../../../"); | ||||
|  | ||||
|         // Test root path | ||||
|         expect(calculateRelativePath("/")).toBe(""); | ||||
|  | ||||
|         // Test path with trailing slash | ||||
|         expect(calculateRelativePath("/share/")).toBe("../"); | ||||
|     }); | ||||
|  | ||||
|     it("should handle normalized share paths correctly", () => { | ||||
|         const calculateRelativePath = (sharePath: string) => { | ||||
|             const pathDepth = sharePath.split('/').filter(segment => segment.length > 0).length; | ||||
|             return '../'.repeat(pathDepth); | ||||
|         }; | ||||
|  | ||||
|         // Test the examples from the original TODO comment | ||||
|         expect(calculateRelativePath("/sharePath")).toBe("../"); | ||||
|         expect(calculateRelativePath("/sharePath/test")).toBe("../../"); | ||||
|     }); | ||||
| }); | ||||
|   | ||||
| @@ -1,6 +1,6 @@ | ||||
| import safeCompare from "safe-compare"; | ||||
|  | ||||
| import type { Request, Response, Router } from "express"; | ||||
| import type { Request, Response, Router, NextFunction } from "express"; | ||||
|  | ||||
| import shaca from "./shaca/shaca.js"; | ||||
| import shacaLoader from "./shaca/shaca_loader.js"; | ||||
| @@ -139,17 +139,21 @@ function renderImageAttachment(image: SNote, res: Response, attachmentName: stri | ||||
| } | ||||
|  | ||||
| function register(router: Router) { | ||||
|     function renderNote(note: SNote, req: Request, res: Response) { | ||||
|         function renderNote(note: SNote, req: Request, res: Response) { | ||||
|         // Calculate the correct relative path depth based on the current request path | ||||
|         // We need to go up one level for each path segment in the request URL | ||||
|         const pathSegments = req.path.split('/').filter(segment => segment.length > 0); | ||||
|         const relativePath = '../'.repeat(pathSegments.length); | ||||
|  | ||||
|         if (!note) { | ||||
|             console.log("Unable to find note ", note); | ||||
|             res.status(404); | ||||
|             renderDefault(res, "404"); | ||||
|             renderDefault(res, "404", { relativePath, t }); | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         if (!checkNoteAccess(note.noteId, req, res)) { | ||||
|             requestCredentials(res); | ||||
|  | ||||
|             return; | ||||
|         } | ||||
|  | ||||
| @@ -161,18 +165,20 @@ function register(router: Router) { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         const { header, content, isEmpty } = contentRenderer.getContent(note); | ||||
|         const { header, content, isEmpty } = contentRenderer.getContent(note, relativePath); | ||||
|         const subRoot = getSharedSubTreeRoot(note); | ||||
|         const showLoginInShareTheme = options.getOption("showLoginInShareTheme"); | ||||
|  | ||||
|         const opts = { | ||||
|             note, | ||||
|             header, | ||||
|             content, | ||||
|             isEmpty, | ||||
|             subRoot, | ||||
|             assetPath: isDev ? assetPath : `../${assetPath}`, | ||||
|             assetPath: isDev ? assetPath : `${relativePath}${assetPath}`, | ||||
|             assetUrlFragment, | ||||
|             appPath: isDev ? appPath : `../${appPath}`, | ||||
|             appPath: isDev ? appPath : `${relativePath}${appPath}`, | ||||
|             relativePath, | ||||
|             showLoginInShareTheme, | ||||
|             t, | ||||
|             isDev | ||||
| @@ -219,184 +225,165 @@ function register(router: Router) { | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     router.get("/share/", (req, res) => { | ||||
|         if (req.path.substr(-1) !== "/") { | ||||
|             res.redirect("../share/"); | ||||
|             return; | ||||
|     // Dynamic dispatch middleware | ||||
|     router.use((req: Request, res: Response, next: NextFunction) => { | ||||
|         const sharePath = options.getOptionOrNull("sharePath") || "/share"; | ||||
|         // Only handle requests starting with sharePath | ||||
|         if (req.path === sharePath || req.path.startsWith(sharePath + "/")) { | ||||
|             // Remove sharePath prefix to get the remaining path | ||||
|             const subPath = req.path.slice(sharePath.length); | ||||
|             // Handle root path | ||||
|             if (subPath === "" || subPath === "/") { | ||||
|                 shacaLoader.ensureLoad(); | ||||
|                 if (!shaca.shareRootNote) { | ||||
|                     res.status(404).json({ message: "Share root not found" }); | ||||
|                     return; | ||||
|                 } | ||||
|                 renderNote(shaca.shareRootNote, req, res); | ||||
|                 return; | ||||
|             } | ||||
|             // Handle /:shareId | ||||
|             const shareIdMatch = subPath.match(/^\/([^/]+)$/); | ||||
|             if (shareIdMatch) { | ||||
|                 shacaLoader.ensureLoad(); | ||||
|                 const shareId = shareIdMatch[1]; | ||||
|                 const note = shaca.aliasToNote[shareId] || shaca.notes[shareId]; | ||||
|                 renderNote(note, req, res); | ||||
|                 return; | ||||
|             } | ||||
|             // Handle /api/notes/:noteId | ||||
|             const apiNoteMatch = subPath.match(/^\/api\/notes\/([^/]+)$/); | ||||
|             if (apiNoteMatch) { | ||||
|                 shacaLoader.ensureLoad(); | ||||
|                 const noteId = apiNoteMatch[1]; | ||||
|                 let note: SNote | boolean; | ||||
|                 if (!(note = checkNoteAccess(noteId, req, res))) return; | ||||
|                 addNoIndexHeader(note, res); | ||||
|                 res.json(note.getPojo()); | ||||
|                 return; | ||||
|             } | ||||
|             // Handle /api/notes/:noteId/download | ||||
|             const apiNoteDownloadMatch = subPath.match(/^\/api\/notes\/([^/]+)\/download$/); | ||||
|             if (apiNoteDownloadMatch) { | ||||
|                 shacaLoader.ensureLoad(); | ||||
|                 const noteId = apiNoteDownloadMatch[1]; | ||||
|                 let note: SNote | boolean; | ||||
|                 if (!(note = checkNoteAccess(noteId, req, res))) return; | ||||
|                 addNoIndexHeader(note, res); | ||||
|                 const filename = utils.formatDownloadTitle(note.title, note.type, note.mime); | ||||
|                 res.setHeader("Content-Disposition", utils.getContentDisposition(filename)); | ||||
|                 res.setHeader("Cache-Control", "no-cache, no-store, must-revalidate"); | ||||
|                 res.setHeader("Content-Type", note.mime); | ||||
|                 res.send(note.getContent()); | ||||
|                 return; | ||||
|             } | ||||
|             // Handle /api/images/:noteId/:filename | ||||
|             const apiImageMatch = subPath.match(/^\/api\/images\/([^/]+)\/([^/]+)$/); | ||||
|             if (apiImageMatch) { | ||||
|                 shacaLoader.ensureLoad(); | ||||
|                 const noteId = apiImageMatch[1]; | ||||
|                 let image: SNote | boolean; | ||||
|                 if (!(image = checkNoteAccess(noteId, req, res))) { | ||||
|                     return; | ||||
|                 } | ||||
|                 if (image.type === "image") { | ||||
|                     // normal image | ||||
|                     res.set("Content-Type", image.mime); | ||||
|                     addNoIndexHeader(image, res); | ||||
|                     res.send(image.getContent()); | ||||
|                 } else if (image.type === "canvas") { | ||||
|                     renderImageAttachment(image, res, "canvas-export.svg"); | ||||
|                 } else if (image.type === "mermaid") { | ||||
|                     renderImageAttachment(image, res, "mermaid-export.svg"); | ||||
|                 } else if (image.type === "mindMap") { | ||||
|                     renderImageAttachment(image, res, "mindmap-export.svg"); | ||||
|                 } else { | ||||
|                     res.status(400).json({ message: "Requested note is not a shareable image" }); | ||||
|                 } | ||||
|                 return; | ||||
|             } | ||||
|             // Handle /api/attachments/:attachmentId/image/:filename | ||||
|             const apiAttachmentImageMatch = subPath.match(/^\/api\/attachments\/([^/]+)\/image\/([^/]+)$/); | ||||
|             if (apiAttachmentImageMatch) { | ||||
|                 shacaLoader.ensureLoad(); | ||||
|                 const attachmentId = apiAttachmentImageMatch[1]; | ||||
|                 let attachment: SAttachment | boolean; | ||||
|                 if (!(attachment = checkAttachmentAccess(attachmentId, req, res))) { | ||||
|                     return; | ||||
|                 } | ||||
|                 if (attachment.role === "image") { | ||||
|                     res.set("Content-Type", attachment.mime); | ||||
|                     addNoIndexHeader(attachment.note, res); | ||||
|                     res.send(attachment.getContent()); | ||||
|                 } else { | ||||
|                     res.status(400).json({ message: "Requested attachment is not a shareable image" }); | ||||
|                 } | ||||
|                 return; | ||||
|             } | ||||
|             // Handle /api/attachments/:attachmentId/download | ||||
|             const apiAttachmentDownloadMatch = subPath.match(/^\/api\/attachments\/([^/]+)\/download$/); | ||||
|             if (apiAttachmentDownloadMatch) { | ||||
|                 shacaLoader.ensureLoad(); | ||||
|                 const attachmentId = apiAttachmentDownloadMatch[1]; | ||||
|                 let attachment: SAttachment | boolean; | ||||
|                 if (!(attachment = checkAttachmentAccess(attachmentId, req, res))) { | ||||
|                     return; | ||||
|                 } | ||||
|                 addNoIndexHeader(attachment.note, res); | ||||
|                 const filename = utils.formatDownloadTitle(attachment.title, null, attachment.mime); | ||||
|                 res.setHeader("Content-Disposition", utils.getContentDisposition(filename)); | ||||
|                 res.setHeader("Cache-Control", "no-cache, no-store, must-revalidate"); | ||||
|                 res.setHeader("Content-Type", attachment.mime); | ||||
|                 res.send(attachment.getContent()); | ||||
|                 return; | ||||
|             } | ||||
|             // Handle /api/notes/:noteId/view | ||||
|             const apiNoteViewMatch = subPath.match(/^\/api\/notes\/([^/]+)\/view$/); | ||||
|             if (apiNoteViewMatch) { | ||||
|                 shacaLoader.ensureLoad(); | ||||
|                 const noteId = apiNoteViewMatch[1]; | ||||
|                 let note: SNote | boolean; | ||||
|                 if (!(note = checkNoteAccess(noteId, req, res))) { | ||||
|                     return; | ||||
|                 } | ||||
|                 addNoIndexHeader(note, res); | ||||
|                 res.setHeader("Cache-Control", "no-cache, no-store, must-revalidate"); | ||||
|                 res.setHeader("Content-Type", note.mime); | ||||
|                 res.send(note.getContent()); | ||||
|                 return; | ||||
|             } | ||||
|             // Handle /api/notes 搜索 | ||||
|             const apiNotesSearchMatch = subPath.match(/^\/api\/notes$/); | ||||
|             if (apiNotesSearchMatch) { | ||||
|                 shacaLoader.ensureLoad(); | ||||
|                 const ancestorNoteId = req.query.ancestorNoteId ?? "_share"; | ||||
|                 if (typeof ancestorNoteId !== "string") { | ||||
|                     res.status(400).json({ message: "'ancestorNoteId' parameter is mandatory." }); | ||||
|                     return; | ||||
|                 } | ||||
|                 // This will automatically return if no ancestorNoteId is provided and there is no shareIndex | ||||
|                 if (!checkNoteAccess(ancestorNoteId, req, res)) { | ||||
|                     return; | ||||
|                 } | ||||
|                 const { search } = req.query; | ||||
|                 if (typeof search !== "string" || !search?.trim()) { | ||||
|                     res.status(400).json({ message: "'search' parameter is mandatory." }); | ||||
|                     return; | ||||
|                 } | ||||
|                 const searchContext = new SearchContext({ ancestorNoteId: ancestorNoteId }); | ||||
|                 const searchResults = searchService.findResultsWithQuery(search, searchContext); | ||||
|                 const filteredResults = searchResults.map((sr) => { | ||||
|                     const fullNote = shaca.notes[sr.noteId]; | ||||
|                     const startIndex = sr.notePathArray.indexOf(ancestorNoteId); | ||||
|                     const localPathArray = sr.notePathArray.slice(startIndex + 1).filter((id) => shaca.notes[id]); | ||||
|                     const pathTitle = localPathArray.map((id) => shaca.notes[id].title).join(" / "); | ||||
|                     return { id: fullNote.shareId, title: fullNote.title, score: sr.score, path: pathTitle }; | ||||
|                 }); | ||||
|                 res.json({ results: filteredResults }); | ||||
|                 return; | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         shacaLoader.ensureLoad(); | ||||
|  | ||||
|         if (!shaca.shareRootNote) { | ||||
|             res.status(404).json({ message: "Share root note not found" }); | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         renderNote(shaca.shareRootNote, req, res); | ||||
|     }); | ||||
|  | ||||
|     router.get("/share/:shareId", (req, res) => { | ||||
|         shacaLoader.ensureLoad(); | ||||
|  | ||||
|         const { shareId } = req.params; | ||||
|  | ||||
|         const note = shaca.aliasToNote[shareId] || shaca.notes[shareId]; | ||||
|  | ||||
|         renderNote(note, req, res); | ||||
|     }); | ||||
|  | ||||
|     router.get("/share/api/notes/:noteId", (req, res) => { | ||||
|         shacaLoader.ensureLoad(); | ||||
|         let note: SNote | boolean; | ||||
|  | ||||
|         if (!(note = checkNoteAccess(req.params.noteId, req, res))) { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         addNoIndexHeader(note, res); | ||||
|  | ||||
|         res.json(note.getPojo()); | ||||
|     }); | ||||
|  | ||||
|     router.get("/share/api/notes/:noteId/download", (req, res) => { | ||||
|         shacaLoader.ensureLoad(); | ||||
|  | ||||
|         let note: SNote | boolean; | ||||
|  | ||||
|         if (!(note = checkNoteAccess(req.params.noteId, req, res))) { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         addNoIndexHeader(note, res); | ||||
|  | ||||
|         const filename = utils.formatDownloadTitle(note.title, note.type, note.mime); | ||||
|  | ||||
|         res.setHeader("Content-Disposition", utils.getContentDisposition(filename)); | ||||
|  | ||||
|         res.setHeader("Cache-Control", "no-cache, no-store, must-revalidate"); | ||||
|         res.setHeader("Content-Type", note.mime); | ||||
|  | ||||
|         res.send(note.getContent()); | ||||
|     }); | ||||
|  | ||||
|     // :filename is not used by trilium, but instead used for "save as" to assign a human-readable filename | ||||
|     router.get("/share/api/images/:noteId/:filename", (req, res) => { | ||||
|         shacaLoader.ensureLoad(); | ||||
|  | ||||
|         let image: SNote | boolean; | ||||
|  | ||||
|         if (!(image = checkNoteAccess(req.params.noteId, req, res))) { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         if (image.type === "image") { | ||||
|             // normal image | ||||
|             res.set("Content-Type", image.mime); | ||||
|             addNoIndexHeader(image, res); | ||||
|             res.send(image.getContent()); | ||||
|         } else if (image.type === "canvas") { | ||||
|             renderImageAttachment(image, res, "canvas-export.svg"); | ||||
|         } else if (image.type === "mermaid") { | ||||
|             renderImageAttachment(image, res, "mermaid-export.svg"); | ||||
|         } else if (image.type === "mindMap") { | ||||
|             renderImageAttachment(image, res, "mindmap-export.svg"); | ||||
|         } else { | ||||
|             res.status(400).json({ message: "Requested note is not a shareable image" }); | ||||
|         } | ||||
|     }); | ||||
|  | ||||
|     // :filename is not used by trilium, but instead used for "save as" to assign a human-readable filename | ||||
|     router.get("/share/api/attachments/:attachmentId/image/:filename", (req, res) => { | ||||
|         shacaLoader.ensureLoad(); | ||||
|  | ||||
|         let attachment: SAttachment | boolean; | ||||
|  | ||||
|         if (!(attachment = checkAttachmentAccess(req.params.attachmentId, req, res))) { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         if (attachment.role === "image") { | ||||
|             res.set("Content-Type", attachment.mime); | ||||
|             addNoIndexHeader(attachment.note, res); | ||||
|             res.send(attachment.getContent()); | ||||
|         } else { | ||||
|             res.status(400).json({ message: "Requested attachment is not a shareable image" }); | ||||
|         } | ||||
|     }); | ||||
|  | ||||
|     router.get("/share/api/attachments/:attachmentId/download", (req, res) => { | ||||
|         shacaLoader.ensureLoad(); | ||||
|  | ||||
|         let attachment: SAttachment | boolean; | ||||
|  | ||||
|         if (!(attachment = checkAttachmentAccess(req.params.attachmentId, req, res))) { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         addNoIndexHeader(attachment.note, res); | ||||
|  | ||||
|         const filename = utils.formatDownloadTitle(attachment.title, null, attachment.mime); | ||||
|  | ||||
|         res.setHeader("Content-Disposition", utils.getContentDisposition(filename)); | ||||
|  | ||||
|         res.setHeader("Cache-Control", "no-cache, no-store, must-revalidate"); | ||||
|         res.setHeader("Content-Type", attachment.mime); | ||||
|  | ||||
|         res.send(attachment.getContent()); | ||||
|     }); | ||||
|  | ||||
|     // used for PDF viewing | ||||
|     router.get("/share/api/notes/:noteId/view", (req, res) => { | ||||
|         shacaLoader.ensureLoad(); | ||||
|  | ||||
|         let note: SNote | boolean; | ||||
|  | ||||
|         if (!(note = checkNoteAccess(req.params.noteId, req, res))) { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         addNoIndexHeader(note, res); | ||||
|  | ||||
|         res.setHeader("Cache-Control", "no-cache, no-store, must-revalidate"); | ||||
|         res.setHeader("Content-Type", note.mime); | ||||
|  | ||||
|         res.send(note.getContent()); | ||||
|     }); | ||||
|  | ||||
|     // Used for searching, require noteId so we know the subTreeRoot | ||||
|     router.get("/share/api/notes", (req, res) => { | ||||
|         shacaLoader.ensureLoad(); | ||||
|  | ||||
|         const ancestorNoteId = req.query.ancestorNoteId ?? "_share"; | ||||
|  | ||||
|         if (typeof ancestorNoteId !== "string") { | ||||
|             res.status(400).json({ message: "'ancestorNoteId' parameter is mandatory." }); | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         // This will automatically return if no ancestorNoteId is provided and there is no shareIndex | ||||
|         if (!checkNoteAccess(ancestorNoteId, req, res)) { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         const { search } = req.query; | ||||
|  | ||||
|         if (typeof search !== "string" || !search?.trim()) { | ||||
|             res.status(400).json({ message: "'search' parameter is mandatory." }); | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         const searchContext = new SearchContext({ ancestorNoteId: ancestorNoteId }); | ||||
|         const searchResults = searchService.findResultsWithQuery(search, searchContext); | ||||
|         const filteredResults = searchResults.map((sr) => { | ||||
|             const fullNote = shaca.notes[sr.noteId]; | ||||
|             const startIndex = sr.notePathArray.indexOf(ancestorNoteId); | ||||
|             const localPathArray = sr.notePathArray.slice(startIndex + 1).filter((id) => shaca.notes[id]); | ||||
|             const pathTitle = localPathArray.map((id) => shaca.notes[id].title).join(" / "); | ||||
|             return { id: fullNote.shareId, title: fullNote.title, score: sr.score, path: pathTitle }; | ||||
|         }); | ||||
|  | ||||
|         res.json({ results: filteredResults }); | ||||
|         next(); | ||||
|     }); | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -123,6 +123,16 @@ When accessing a share, the sub-notes will be displayed in a tree on the left. B | ||||
|  | ||||
| To do so, create a shared text note and apply the `shareIndex` label. When viewed, the list of shared roots will be displayed at the bottom of the note. | ||||
|  | ||||
| ### Redirect Bare Domain to Share Page | ||||
|  | ||||
| This option can be enabled under `Option → Other → Share Settings`. When activated, anonymous users accessing the bare domain will be redirected to the Share page, preventing them from seeing the login option and thereby improving security.   | ||||
| To ensure accessibility for legitimate users, you can also enable a login link on the Share page, allowing yourself to access the login screen if you're redirected there. | ||||
|  | ||||
| ### Setting a Custom Share Path | ||||
|  | ||||
| This option can be enabled under `Option → Other → Share Settings`. It allows you to customize the share URL prefix before the `noteId`. Nested paths are supported.   | ||||
| If you're using a proxy service, make sure to update its configuration accordingly to reflect the new path structure. | ||||
|  | ||||
| ## Attribute reference | ||||
|  | ||||
| <table><thead><tr><th>Attribute</th><th>Description</th></tr></thead><tbody><tr><td><code>shareHiddenFromTree</code></td><td>this note is hidden from left navigation tree, but still accessible with its URL</td></tr><tr><td><code>shareExternalLink</code></td><td>note will act as a link to an external website in the share tree</td></tr><tr><td><code>shareAlias</code></td><td>define an alias using which the note will be available under <code>https://your_trilium_host/share/[your_alias]</code></td></tr><tr><td><code>shareOmitDefaultCss</code></td><td>default share page CSS will be omitted. Use when you make extensive styling changes.</td></tr><tr><td><code>shareRoot</code></td><td>marks note which is served on /share root.</td></tr><tr><td><code>shareDescription</code></td><td>define text to be added to the HTML meta tag for description</td></tr><tr><td><code>shareRaw</code></td><td>Note will be served in its raw format, without HTML wrapper. See also <a class="reference-link" href="Sharing/Serving%20directly%20the%20content%20o.md">Serving directly the content of a note</a> for an alternative method without setting an attribute.</td></tr><tr><td><code>shareDisallowRobotIndexing</code></td><td><p>Indicates to web crawlers that the page should not be indexed of this note by:</p><ul><li data-list-item-id="e6baa9f60bf59d085fd31aa2cce07a0e7">Setting the <code>X-Robots-Tag: noindex</code> HTTP header.</li><li data-list-item-id="ec0d067db136ef9794e4f1033405880b7">Setting the <code>noindex, follow</code> meta tag.</li></ul></td></tr><tr><td><code>shareCredentials</code></td><td>require credentials to access this shared note. Value is expected to be in format <code>username:password</code>. Don't forget to make this inheritable to apply to child-notes/images.</td></tr><tr><td><code>shareIndex</code></td><td>Note with this label will list all roots of shared notes.</td></tr><tr><td><code>shareHtmlLocation</code></td><td>defines where custom HTML injected via <code>~shareHtml</code> relation should be placed. Applied to the HTML snippet note itself. Format: <code>location:position</code> where location is <code>head</code>, <code>body</code>, or <code>content</code> and position is <code>start</code> or <code>end</code>. Defaults to <code>content:end</code>.</td></tr></tbody></table> | ||||
|   | ||||
| @@ -135,6 +135,8 @@ export interface OptionDefinitions extends KeyboardShortcutsOptions<KeyboardActi | ||||
|     // Share settings | ||||
|     redirectBareDomain: boolean; | ||||
|     showLoginInShareTheme: boolean; | ||||
|     shareSubtree: boolean; | ||||
|     sharePath: string; | ||||
|  | ||||
|     // AI/LLM integration options | ||||
|     aiEnabled: boolean; | ||||
|   | ||||
| @@ -2,7 +2,7 @@ | ||||
| <html lang="en"> | ||||
| <head> | ||||
|     <meta charset="utf-8"> | ||||
|     <link rel="shortcut icon" href="../favicon.ico"> | ||||
|     <link rel="shortcut icon" href="<%= relativePath %>favicon.ico"> | ||||
|     <title><%= t("share_404.title") %></title> | ||||
| </head> | ||||
| <body> | ||||
|   | ||||
| @@ -81,7 +81,7 @@ | ||||
| </head> | ||||
| <% | ||||
| const customLogoId = subRoot.note.getRelation("shareLogo")?.value; | ||||
| const logoUrl = customLogoId ? `api/images/${customLogoId}/image.png` : `../${assetUrlFragment}/images/icon-color.svg`; | ||||
| const logoUrl = customLogoId ? `api/images/${customLogoId}/image.png` : `${relativePath}${assetUrlFragment}/images/icon-color.svg`; | ||||
| const logoWidth = subRoot.note.getLabelValue("shareLogoWidth") ?? 53; | ||||
| const logoHeight = subRoot.note.getLabelValue("shareLogoHeight") ?? 40; | ||||
| const mobileLogoHeight = logoHeight && logoWidth ? 32 / (logoWidth / logoHeight) : ""; | ||||
|   | ||||
		Reference in New Issue
	
	Block a user