Compare commits

...

40 Commits

Author SHA1 Message Date
Jin
64f191b83b Merge branch 'main' into feat/clean-share-url 2025-10-07 23:39:38 +01:00
JYC333
979fbe2e76 Merge branch 'main' into feat/clean-share-url 2025-08-13 18:28:27 +02:00
Jin
ea5564c6e6 docs: ✏️ update docs 2025-06-26 23:48:05 +02:00
Jin
6ab8750726 feat: 🎸 update translation 2025-06-26 23:15:23 +02:00
Jin
923eabd750 feat: 🎸 Allow multi-segement path 2025-06-26 23:08:54 +02:00
Jin
c52d6a6384 Merge branch 'main' into feat/clean-share-url 2025-06-26 22:53:35 +02:00
Jin
519b9648c6 test: 💍 Fix test for path input 2025-06-26 22:30:24 +02:00
Jin
b25fd6ca3a feat: 🎸 update custom share path without restart server 2025-06-26 22:30:24 +02:00
Panagiotis Papadopoulos
2bd8c215ff Merge branch 'develop' into feat/clean-share-url 2025-05-12 20:38:28 +02:00
Panagiotis Papadopoulos
34e7901de9 refactor: remove "cleanUrl" related string for now
that should be part of a later PR
2025-04-25 09:12:29 +02:00
Panagiotis Papadopoulos
30a191cedf fix(share_settings): disallow "/" as share root for now as it is not working
this will be handled by cleanUrl PR later on
2025-04-25 08:53:13 +02:00
Panagiotis Papadopoulos
128d8907c3 chore(share_settings): add a TODO hint for currently active bug 2025-04-25 08:48:28 +02:00
Panagiotis Papadopoulos
3b1d7d045e feat: improve example and wording for share_path_description
needs to currently mention, that a server restart is required
2025-04-25 08:40:43 +02:00
Panagiotis Papadopoulos
0ae9a29e0d refactor: remove "cleanUrl" related code for now
that should be part of a later PR
2025-04-25 08:38:48 +02:00
Panagiotis Papadopoulos
f4b5ed73ad refactor(options_init): remove sharePath normalization
normalization is happening in the share settings options widget in the meantime, making it more obvious to the user
2025-04-25 08:31:27 +02:00
Panagiotis Papadopoulos
43166dbeb5 refactor: remove "cleanUrl" related code for now
that should be part of a later PR
2025-04-25 08:27:01 +02:00
Panagiotis Papadopoulos
00b5aef890 feat(share_settings): add support for adding "/" as sharePath 2025-04-21 10:45:41 +02:00
Panagiotis Papadopoulos
1b7266f083 chore(share_settings): remove unnecessary comment 2025-04-21 10:25:09 +02:00
Panagiotis Papadopoulos
d1d4b47111 test(share_settings): add initial test for normalizeSharePathInput
it currently fails trying to import the class though
a "manual" importing of the function did pass all checks though

-> will need to investigate why importing the class does not work like that
2025-04-21 09:15:48 +02:00
Panagiotis Papadopoulos
c90364bd76 feat(share_settings): improve sharePath input handling
* add normalization helper to ensure string does not start with multiple trailing slashes or ends with a trailing slash
* fall back to default "/share" when empty string is entered
2025-04-21 09:10:21 +02:00
Panagiotis Papadopoulos
6dc687ef43 feat(share_settings): improve checkShareRoot
* add rudimental error handling
* add handling of special case, where one has multiple shared notes with a #shareRoot label
* refactor styling into auxiliary setCheckShareRootStyle function
2025-04-21 07:54:24 +02:00
Panagiotis Papadopoulos
0e31aab1ab refactor(share_settings): use this.$shareRootCheck instead of creating new local $button variable 2025-04-20 19:24:58 +02:00
Panagiotis Papadopoulos
b0030f89b7 feat(share_settings): group options that belong together logically 2025-04-20 19:21:38 +02:00
Panagiotis Papadopoulos
9f0a0238cc fix(share_settings): stop runnning checkShareRoot on init and on redirectBareDomain change
→ that is what the shareRootCheck button is there for
2025-04-20 19:19:30 +02:00
Panagiotis Papadopoulos
dabdfaddec fix(share/routes): remove unnecessary redirects that cause loops 2025-04-20 10:40:20 +02:00
Panagiotis Papadopoulos
a8901e6dc8 chore: revert back unnecessary changes from unclean merge
c9d151289c
2025-04-18 11:53:56 +02:00
Panagiotis Papadopoulos
ab901a5d32 refactor(share_settings): get rid of save() method
there's no need to execute PUT requests for *all* Share Settings, when any option changes

moved the code to inside the "change" event handlers
2025-04-18 02:05:40 +02:00
Panagiotis Papadopoulos
56fc2d9b30 fix(share_settings): fix not being able to set share path
caused by having other several save operation inside the save() method (which doesn't even make sense to begin with, as far as I can tell)

moving it to inside the "change" event handler allows us to set and store a custom share path again
2025-04-18 02:00:23 +02:00
Panagiotis Papadopoulos
df45fa2e1e fix(share/routes): fix redirect loop
commented out for now, to make sure we get back to it – not sure if it was suppossed to have any special other reason to exist
2025-04-18 01:49:34 +02:00
Panagiotis Papadopoulos
9a11fc13d7 fix(share_settings): fix missing class in redirect-bare-domain input 2025-04-18 01:07:54 +02:00
Panagiotis Papadopoulos
d72a0d3c69 fix(services/auth): fix crash on clean DB startup when options are not set 2025-04-18 00:15:25 +02:00
Panagiotis Papadopoulos
0be508ed70 fix(share/routes): fix crash on clean DB startup when sharePath option is not set 2025-04-18 00:10:29 +02:00
Panagiotis Papadopoulos
37f3a9b19d Merge branch 'develop' into feat/clean-share-url 2025-04-17 23:43:18 +02:00
Jin
81d2fbc057 fix: 🐛 add back missing translation 2025-04-11 17:54:54 +02:00
Jin
1aecf66cbe Merge branch 'develop' into feat/clean-share-url 2025-04-10 21:47:44 +02:00
Jin
fbe7d64e00 Merge branch 'develop' into feat/clean-share-url 2025-04-04 19:06:10 +02:00
matt wilkie
c9d151289c conflicts fixed - Merge remote-tracking branch 'origin/develop'
1st time using Meld. I'm not sure if the conflict resolve is clean
2025-03-08 09:49:06 -07:00
matt wilkie
bba2f6db64 wip: another attempt (and use translation syntax this time) 2025-03-08 09:35:52 -07:00
matt wilkie
6725f81a00 WIP: options page works, but routing broken when logged out 2025-02-26 03:10:55 -07:00
matt wilkie
8487c8cd03 WIP: allow no share path url prefix at all (not working yet) 2025-02-25 14:51:29 -07:00
16 changed files with 341 additions and 209 deletions

View File

@@ -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": "输入的时间值不是有效数字。",

View File

@@ -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.",

View File

@@ -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;
}

View File

@@ -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);
});
});
})
})

View File

@@ -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

View File

@@ -97,6 +97,8 @@ const ALLOWED_OPTIONS = new Set<OptionNames>([
"allowedHtmlTags",
"redirectBareDomain",
"showLoginInShareTheme",
"shareSubtree",
"sharePath",
"splitEditorOrientation",
"seenCallToActions",

View File

@@ -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);

View File

@@ -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");

View File

@@ -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 },

View File

@@ -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'));

View File

@@ -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("../../");
});
});

View File

@@ -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();
});
}

View File

@@ -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&nbsp;<a class="reference-link" href="Sharing/Serving%20directly%20the%20content%20o.md">Serving directly the content of a note</a>&nbsp;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>

View File

@@ -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;

View File

@@ -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>

View File

@@ -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) : "";