mirror of
https://github.com/zadam/trilium.git
synced 2025-10-26 07:46:30 +01:00
Compare commits
40 Commits
renovate/m
...
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