diff --git a/apps/client/src/translations/en/translation.json b/apps/client/src/translations/en/translation.json index ab7933617f..641b9f36b4 100644 --- a/apps/client/src/translations/en/translation.json +++ b/apps/client/src/translations/en/translation.json @@ -1874,9 +1874,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').", + "share_path_placeholder": "/share or /custom-path", + "share_subtree": "Share subtree", + "share_subtree_description": "Share the entire subtree, not just the note" }, "time_selector": { "invalid_input": "The entered time value is not a valid number.", diff --git a/apps/client/src/widgets/shared_info.ts b/apps/client/src/widgets/shared_info.ts index b6170a3c73..4d0ef64fac 100644 --- a/apps/client/src/widgets/shared_info.ts +++ b/apps/client/src/widgets/shared_info.ts @@ -37,22 +37,23 @@ export default class SharedInfoWidget extends NoteContextAwareWidget { async refreshWithNote(note: FNote) { const syncServerHost = options.get("syncServerHost"); + const sharePath = options.get("sharePath"); let link; const shareId = this.getShareId(note); if (syncServerHost) { - link = `${syncServerHost}/share/${shareId}`; + link = `${syncServerHost}${sharePath}/${shareId}`; this.$sharedText.text(t("shared_info.shared_publicly")); } else { let host = location.host; if (host.endsWith("/")) { // seems like IE has trailing slash // https://github.com/zadam/trilium/issues/3782 - host = host.substr(0, host.length - 1); + host = host.slice(0, -1); } - link = `${location.protocol}//${host}${location.pathname}share/${shareId}`; + link = `${location.protocol}//${host}${location.pathname}${sharePath.slice(1)}/${shareId}`; this.$sharedText.text(t("shared_info.shared_locally")); } diff --git a/apps/client/src/widgets/type_widgets/options/other/share_path_utils.ts b/apps/client/src/widgets/type_widgets/options/other/share_path_utils.ts new file mode 100644 index 0000000000..2ff042b1b2 --- /dev/null +++ b/apps/client/src/widgets/type_widgets/options/other/share_path_utils.ts @@ -0,0 +1,13 @@ +// Ensure sharePath always starts with a single slash and does not end with (one or multiple) trailing slashes +export function normalizeSharePathInput(sharePathInput: string) { + const REGEXP_STARTING_SLASH = /^\/+/g; + const REGEXP_TRAILING_SLASH = /\b\/+$/g; + + const normalizedSharePath = (!sharePathInput.startsWith("/") + ? `/${sharePathInput}` + : sharePathInput) + .replaceAll(REGEXP_TRAILING_SLASH, "") + .replaceAll(REGEXP_STARTING_SLASH, "/"); + + return normalizedSharePath; +} diff --git a/apps/client/src/widgets/type_widgets/options/other/share_settings.spec.ts b/apps/client/src/widgets/type_widgets/options/other/share_settings.spec.ts new file mode 100644 index 0000000000..aa9c4efc5b --- /dev/null +++ b/apps/client/src/widgets/type_widgets/options/other/share_settings.spec.ts @@ -0,0 +1,58 @@ +import { describe, it, expect } from "vitest"; +import { normalizeSharePathInput } from "./share_path_utils.js"; + +type TestCase any> = [ + desc: string, + fnParams: Parameters, + expected: ReturnType +]; + +describe("ShareSettingsOptions", () => { + + describe("#normalizeSharePathInput", () => { + + const testCases: TestCase[] = [ + [ + "should handle multiple trailing '/' and remove them completely", + ["/trailingtest////"], + "/trailingtest" + ], + [ + "should handle multiple starting '/' and replace them by a single '/'", + ["////startingtest"], + "/startingtest" + ], + [ + "should handle multiple starting & trailing '/' and replace them by a single '/'", + ["////startingAndTrailingTest///"], + "/startingAndTrailingTest" + ], + [ + "should not remove any '/' other than at the end or start of the input", + ["/test/with/subpath"], + "/test/with/subpath" + ], + [ + "should prepend the string with a '/' if it does not start with one", + ["testpath"], + "/testpath" + ], + [ + "should not change anything, if the string is a single '/'", + ["/"], + "/" + ], + ]; + + testCases.forEach((testCase) => { + const [desc, fnParams, expected] = testCase; + it(desc, () => { + const actual = normalizeSharePathInput(...fnParams); + expect(actual).toStrictEqual(expected); + }); + }); + + + }) + +}) \ No newline at end of file diff --git a/apps/client/src/widgets/type_widgets/options/other/share_settings.ts b/apps/client/src/widgets/type_widgets/options/other/share_settings.ts index 7d51bb499f..caa4dc2a7a 100644 --- a/apps/client/src/widgets/type_widgets/options/other/share_settings.ts +++ b/apps/client/src/widgets/type_widgets/options/other/share_settings.ts @@ -1,27 +1,50 @@ import OptionsWidget from "../options_widget.js"; -import options from "../../../../services/options.js"; import { t } from "../../../../services/i18n.js"; -import type { OptionMap, OptionNames } from "@triliumnext/commons"; +import type { OptionMap } from "@triliumnext/commons"; import searchService from "../../../../services/search.js"; +import { normalizeSharePathInput } from "./share_path_utils.js"; const TPL = /*html*/`

${t("share.title")}

- -

${t("share.redirect_bare_domain_description")}

- - -

${t("share.show_login_link_description")}

+
+ +

${t("share.redirect_bare_domain_description")}

+ + +
+ +
+ +

${t("share.show_login_link_description")}

+
+ +
+ +
+ +
+
+ ${t("share.share_path_description")} +
+
+
`; export default class ShareSettingsOptions extends OptionsWidget { + private $redirectBareDomain!: JQuery; + private $showLoginInShareTheme!: JQuery; + private $sharePath!: JQuery; private $shareRootCheck!: JQuery; private $shareRootStatus!: JQuery; @@ -29,67 +52,111 @@ export default class ShareSettingsOptions extends OptionsWidget { this.$widget = $(TPL); this.contentSized(); + this.$redirectBareDomain = this.$widget.find(".redirect-bare-domain"); + this.$showLoginInShareTheme = this.$widget.find(".show-login-in-share-theme"); + this.$sharePath = this.$widget.find(".share-path"); this.$shareRootCheck = this.$widget.find(".share-root-check"); this.$shareRootStatus = this.$widget.find(".share-root-status"); + this.$shareRootCheck.hide(); - // Add change handlers for both checkboxes - this.$widget.find('input[type="checkbox"]').on("change", (e: JQuery.ChangeEvent) => { - this.save(); + this.$redirectBareDomain.on('change', async () => { + const redirectBareDomain = this.$redirectBareDomain.is(":checked"); + await this.updateOption<"redirectBareDomain">("redirectBareDomain", redirectBareDomain.toString()); // Show/hide share root status section based on redirectBareDomain checkbox - const target = e.target as HTMLInputElement; - if (target.name === "redirectBareDomain") { - this.$shareRootCheck.toggle(target.checked); - if (target.checked) { - this.checkShareRoot(); - } - } + this.$shareRootCheck.toggle(redirectBareDomain); + }); + + this.$showLoginInShareTheme.on('change', async () => { + const showLoginInShareTheme = this.$showLoginInShareTheme.is(":checked"); + await this.updateOption<"showLoginInShareTheme">("showLoginInShareTheme", showLoginInShareTheme.toString()); + }); + + this.$sharePath.on('change', async () => { + const DEFAULT_SHAREPATH = "/share"; + const sharePathInput = this.$sharePath.val()?.trim() || ""; + + // TODO: inform user if they try to add more than a single path prefix (i.e. /sharePath/test) + // โ†’ this currently is not properly working, as for some reason the assets URL is not correctly rewritten + // and it only includes the first path in the URL, e.g. + // http://localhost:8080/sharePath/assets/v0.93.0/node_modules/normalize.css/normalize.css + // instead of + // http://localhost:8080/sharePath/test/assets/v0.93.0/node_modules/normalize.css/normalize.css + // alternatively/better approach: fix this behaviour :-) + const normalizedSharePath = normalizeSharePathInput(sharePathInput); + const optionValue = (!sharePathInput || !normalizedSharePath || normalizedSharePath === "/") + ? DEFAULT_SHAREPATH + : normalizedSharePath; + + await this.updateOption<"sharePath">("sharePath", optionValue); }); - // Add click handler for check share root button this.$widget.find(".check-share-root").on("click", () => this.checkShareRoot()); } async optionsLoaded(options: OptionMap) { const redirectBareDomain = options.redirectBareDomain === "true"; - this.$widget.find('input[name="redirectBareDomain"]').prop("checked", redirectBareDomain); + this.$redirectBareDomain.prop("checked", redirectBareDomain); this.$shareRootCheck.toggle(redirectBareDomain); - if (redirectBareDomain) { - await this.checkShareRoot(); - } - this.$widget.find('input[name="showLoginInShareTheme"]').prop("checked", options.showLoginInShareTheme === "true"); + this.$showLoginInShareTheme.prop("checked", options.showLoginInShareTheme === "true"); + this.$sharePath.val(options.sharePath); } async checkShareRoot() { - const $button = this.$widget.find(".check-share-root"); - $button.prop("disabled", true); + this.$shareRootCheck.prop("disabled", true); + + const setCheckShareRootStyle = (removeClassName: string, addClassName: string, text: string) => { + this.$shareRootStatus + .removeClass(removeClassName) + .addClass(addClassName) + .text(text); + + this.$shareRootCheck.prop("disabled", false); + }; try { const shareRootNotes = await searchService.searchForNotes("#shareRoot"); - const sharedShareRootNote = shareRootNotes.find((note) => note.isShared()); - - if (sharedShareRootNote) { - this.$shareRootStatus - .removeClass("text-danger") - .addClass("text-success") - .text(t("share.share_root_found", { noteTitle: sharedShareRootNote.title })); - } else { - this.$shareRootStatus - .removeClass("text-success") - .addClass("text-danger") - .text(shareRootNotes.length > 0 ? t("share.share_root_not_shared", { noteTitle: shareRootNotes[0].title }) : t("share.share_root_not_found")); + const sharedShareRootNotes = shareRootNotes.filter((note) => note.isShared()); + + // No Note found that has the sharedRoot label AND is currently shared + if (sharedShareRootNotes.length < 1) { + const textMessage = (shareRootNotes.length > 0) + ? t("share.share_root_not_shared", { noteTitle: shareRootNotes[0].title }) + : t("share.share_root_not_found"); + + return setCheckShareRootStyle("text-success", "text-danger", textMessage); } - } finally { - $button.prop("disabled", false); - } - } - async save() { - const redirectBareDomain = this.$widget.find('input[name="redirectBareDomain"]').prop("checked"); - await this.updateOption<"redirectBareDomain">("redirectBareDomain", redirectBareDomain.toString()); + // more than one currently shared Note found with the sharedRoot label + // โ†’ use the first found, but warn user about it + if (sharedShareRootNotes.length > 1) { + + const foundNoteTitles = shareRootNotes.map(note => t("share.share_note_title", { + noteTitle: note.title, + interpolation: { + escapeValue: false + } + })); + const activeNoteTitle = foundNoteTitles[0]; + + return setCheckShareRootStyle("text-danger", "text-success", + t("share.share_root_multiple_found", { + activeNoteTitle, + foundNoteTitles: foundNoteTitles.join(", ") + }) + ); + } - const showLoginInShareTheme = this.$widget.find('input[name="showLoginInShareTheme"]').prop("checked"); - await this.updateOption<"showLoginInShareTheme">("showLoginInShareTheme", showLoginInShareTheme.toString()); + // exactly one note that has the sharedRoot label AND is currently shared + return setCheckShareRootStyle("text-danger", "text-success", + t("share.share_root_found", { noteTitle: sharedShareRootNotes[0].title }) + ); + } catch (err) { + console.error(err); + return setCheckShareRootStyle("text-success", "text-danger", + t("share.check_share_root_error",) + ); + } } } diff --git a/apps/server/src/routes/api/options.ts b/apps/server/src/routes/api/options.ts index f481b24a45..c9fb076016 100644 --- a/apps/server/src/routes/api/options.ts +++ b/apps/server/src/routes/api/options.ts @@ -90,6 +90,8 @@ const ALLOWED_OPTIONS = new Set([ "allowedHtmlTags", "redirectBareDomain", "showLoginInShareTheme", + "shareSubtree", + "sharePath", "splitEditorOrientation", // AI/LLM integration options diff --git a/apps/server/src/routes/routes.ts b/apps/server/src/routes/routes.ts index 6b984aed4a..92f019e61f 100644 --- a/apps/server/src/routes/routes.ts +++ b/apps/server/src/routes/routes.ts @@ -80,6 +80,7 @@ const GET = "get", DEL = "delete"; function register(app: express.Application) { + route(GET, "/", [auth.checkAuth, csrfMiddleware], indexRoute.index); route(GET, "/login", [auth.checkAppInitialized, auth.checkPasswordSet], loginRoute.loginPage); route(GET, "/set-password", [auth.checkAppInitialized, auth.checkPasswordNotSet], loginRoute.setPasswordPage); diff --git a/apps/server/src/services/auth.ts b/apps/server/src/services/auth.ts index df18fba495..8258ff6076 100644 --- a/apps/server/src/services/auth.ts +++ b/apps/server/src/services/auth.ts @@ -49,11 +49,27 @@ 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 { next(); } diff --git a/apps/server/src/services/options_init.ts b/apps/server/src/services/options_init.ts index b815079990..b429167324 100644 --- a/apps/server/src/services/options_init.ts +++ b/apps/server/src/services/options_init.ts @@ -189,8 +189,10 @@ const defaultOptions: DefaultOption[] = [ }, // Share settings + { name: "sharePath", value: "/share", isSynced: true }, { name: "redirectBareDomain", value: "false", isSynced: true }, { name: "showLoginInShareTheme", value: "false", isSynced: true }, + { name: "shareSubtree", value: "false", isSynced: true }, // AI Options { name: "aiEnabled", value: "false", isSynced: true }, diff --git a/apps/server/src/share/routes.ts b/apps/server/src/share/routes.ts index 7e18ca505b..475e4aad0e 100644 --- a/apps/server/src/share/routes.ts +++ b/apps/server/src/share/routes.ts @@ -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"; @@ -218,184 +218,165 @@ function register(router: Router) { } } - router.get("/share/", (req, res) => { - if (req.path.substr(-1) !== "/") { - res.redirect("../share/"); - 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; + // 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; + } } - - 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(); }); } diff --git a/packages/commons/src/lib/options_interface.ts b/packages/commons/src/lib/options_interface.ts index ad781b65cf..851f4331ce 100644 --- a/packages/commons/src/lib/options_interface.ts +++ b/packages/commons/src/lib/options_interface.ts @@ -123,6 +123,8 @@ export interface OptionDefinitions extends KeyboardShortcutsOptions