diff --git a/bin/tpl/anonymize-database.sql b/bin/tpl/anonymize-database.sql index ff935d6e1d..86615370f8 100644 --- a/bin/tpl/anonymize-database.sql +++ b/bin/tpl/anonymize-database.sql @@ -13,13 +13,13 @@ UPDATE attributes SET name = 'name', value = 'value' WHERE type = 'label' 'workspaceTabBackgroundColor', 'workspaceCalendarRoot', 'workspaceTemplate', 'searchHome', 'workspaceInbox', 'workspaceSearchHome', 'sqlConsoleHome', 'datePattern', 'pageSize', 'viewType', 'mapRootNoteId', 'bookmarkFolder', 'sorted', 'sortDirection', 'sortFoldersFirst', 'sortNatural', 'sortLocale', 'top', - 'fullContentWidth', 'shareHiddenFromTree', 'shareOmitDefaultCss', 'shareRoot', 'shareDescription', + 'fullContentWidth', 'shareHiddenFromTree', 'shareExternalLink', 'shareOmitDefaultCss', 'shareRoot', 'shareDescription', 'shareRaw', 'shareDisallowRobotIndexing', 'shareIndex', 'displayRelations', 'hideRelations', 'titleTemplate', 'template', 'toc', 'color', 'keepCurrentHoisting', 'executeButton', 'executeDescription', 'newNotesOnTop', 'clipperInbox', 'internalLink', 'imageLink', 'relationMapLink', 'includeMapLink', 'runOnNoteCreation', 'runOnNoteTitleChange', 'runOnNoteChange', 'runOnNoteContentChange', 'runOnNoteDeletion', 'runOnBranchCreation', 'runOnBranchDeletion', 'runOnChildNoteCreation', 'runOnAttributeCreation', 'runOnAttributeChange', 'template', - 'inherit', 'widget', 'renderNote', 'shareCss', 'shareJs', 'shareFavicon'); + 'inherit', 'widget', 'renderNote', 'shareCss', 'shareJs', 'shareTemplate', 'shareFavicon'); UPDATE attributes SET name = 'name' WHERE type = 'relation' AND name NOT IN ('inbox', 'disableVersioning', 'calendarRoot', 'archived', 'excludeFromExport', 'disableInclusion', 'appCss', @@ -30,13 +30,13 @@ UPDATE attributes SET name = 'name' WHERE type = 'relation' 'workspaceTabBackgroundColor', 'workspaceCalendarRoot', 'workspaceTemplate', 'searchHome', 'workspaceInbox', 'workspaceSearchHome', 'sqlConsoleHome', 'datePattern', 'pageSize', 'viewType', 'mapRootNoteId', 'bookmarkFolder', 'sorted', 'sortDirection', 'sortFoldersFirst', 'sortNatural', 'sortLocale', 'top', - 'fullContentWidth', 'shareHiddenFromTree', 'shareOmitDefaultCss', 'shareRoot', 'shareDescription', + 'fullContentWidth', 'shareHiddenFromTree', 'shareExternalLink', 'shareOmitDefaultCss', 'shareRoot', 'shareDescription', 'shareRaw', 'shareDisallowRobotIndexing', 'shareIndex', 'displayRelations', 'hideRelations', 'titleTemplate', 'template', 'toc', 'color', 'keepCurrentHoisting', 'executeButton', 'executeDescription', 'newNotesOnTop', 'clipperInbox', 'internalLink', 'imageLink', 'relationMapLink', 'includeMapLink', 'runOnNoteCreation', 'runOnNoteTitleChange', 'runOnNoteChange', 'runOnNoteContentChange', 'runOnNoteDeletion', 'runOnBranchCreation', 'runOnBranchDeletion', 'runOnChildNoteCreation', 'runOnAttributeCreation', 'runOnAttributeChange', 'template', - 'inherit', 'widget', 'renderNote', 'shareCss', 'shareJs', 'shareFavicon'); + 'inherit', 'widget', 'renderNote', 'shareCss', 'shareJs', 'shareTemplate', 'shareFavicon'); UPDATE branches SET prefix = 'prefix' WHERE prefix IS NOT NULL AND prefix != 'recovered'; UPDATE options SET value = 'anonymized' WHERE name IN ('documentId', 'documentSecret', 'encryptedDataKey', diff --git a/src/public/app/widgets/attribute_widgets/attribute_detail.js b/src/public/app/widgets/attribute_widgets/attribute_detail.js index 715d3f093f..db209e168a 100644 --- a/src/public/app/widgets/attribute_widgets/attribute_detail.js +++ b/src/public/app/widgets/attribute_widgets/attribute_detail.js @@ -225,6 +225,7 @@ const ATTR_HELP = { "sqlConsoleHome": "default location of SQL console notes", "bookmarkFolder": "note with this label will appear in bookmarks as folder (allowing access to its children)", "shareHiddenFromTree": "this note is hidden from left navigation tree, but still accessible with its URL", + "shareExternalLink": "note will act as a link to an external website in the share tree", "shareAlias": "define an alias using which the note will be available under https://your_trilium_host/share/[your_alias]", "shareOmitDefaultCss": "default share page CSS will be omitted. Use when you make extensive styling changes.", "shareRoot": "marks note which is served on /share root.", @@ -271,6 +272,7 @@ const ATTR_HELP = { "widget": "target of this relation will be executed and rendered as a widget in the sidebar", "shareCss": "CSS note which will be injected into the share page. CSS note must be in the shared sub-tree as well. Consider using 'shareHiddenFromTree' and 'shareOmitDefaultCss' as well.", "shareJs": "JavaScript note which will be injected into the share page. JS note must be in the shared sub-tree as well. Consider using 'shareHiddenFromTree'.", + "shareTemplate": "Embedded JavaScript note that will be used as the template for displaying the shared note. Falls back to the default template. Consider using 'shareHiddenFromTree'.", "shareFavicon": "Favicon note to be set in the shared page. Typically you want to set it to share root and make it inheritable. Favicon note must be in the shared sub-tree as well. Consider using 'shareHiddenFromTree'.", } }; diff --git a/src/services/builtin_attributes.js b/src/services/builtin_attributes.js index 5083f3dc76..0c2fa5f0ce 100644 --- a/src/services/builtin_attributes.js +++ b/src/services/builtin_attributes.js @@ -48,6 +48,7 @@ module.exports = [ { type: 'label', name: 'bottom' }, { type: 'label', name: 'fullContentWidth' }, { type: 'label', name: 'shareHiddenFromTree' }, + { type: 'label', name: 'shareExternalLink' }, { type: 'label', name: 'shareAlias' }, { type: 'label', name: 'shareOmitDefaultCss' }, { type: 'label', name: 'shareRoot' }, @@ -89,5 +90,6 @@ module.exports = [ { type: 'relation', name: 'renderNote', isDangerous: true }, { type: 'relation', name: 'shareCss' }, { type: 'relation', name: 'shareJs' }, + { type: 'relation', name: 'shareTemplate' }, { type: 'relation', name: 'shareFavicon' }, ]; diff --git a/src/share/content_renderer.js b/src/share/content_renderer.js index cc456bc2ef..1870f039b4 100644 --- a/src/share/content_renderer.js +++ b/src/share/content_renderer.js @@ -44,7 +44,10 @@ function renderIndex(result) { const rootNote = shaca.getNote(shareRoot.SHARE_ROOT_NOTE_ID); for (const childNote of rootNote.getChildNotes()) { - result.content += `
  • ${childNote.escapedTitle}
  • `; + const isExternalLink = childNote.hasLabel("shareExternalLink"); + const href = isExternalLink ? childNote.getLabelValue("shareExternalLink") : `./${childNote.shareId}`; + const target = isExternalLink ? `target="_blank" rel="noopener noreferrer"` : ""; + result.content += `
  • ${childNote.escapedTitle}
  • `; } result.content += ''; @@ -84,7 +87,13 @@ function renderText(result, note) { const noteId = notePathSegments[notePathSegments.length - 1]; const linkedNote = shaca.getNote(noteId); if (linkedNote) { - linkEl.setAttribute("href", linkedNote.shareId); + const isExternalLink = linkedNote.hasLabel("shareExternalLink"); + const href = isExternalLink ? linkedNote.getLabelValue("shareExternalLink") : `./${linkedNote.shareId}`; + linkEl.setAttribute("href", href); + if (isExternalLink) { + linkEl.setAttribute("target", "_blank"); + linkEl.setAttribute("rel", "noopener noreferrer"); + } linkEl.classList.add(`type-${linkedNote.type}`); } else { linkEl.removeAttribute("href"); diff --git a/src/share/routes.js b/src/share/routes.js index 462ad1aeb1..a22691ce27 100644 --- a/src/share/routes.js +++ b/src/share/routes.js @@ -1,6 +1,7 @@ const express = require('express'); const path = require('path'); const safeCompare = require('safe-compare'); +const ejs = require("ejs"); const shaca = require("./shaca/shaca"); const shacaLoader = require("./shaca/shaca_loader"); @@ -8,6 +9,9 @@ const shareRoot = require("./share_root"); const contentRenderer = require("./content_renderer"); const assetPath = require("../services/asset_path"); const appPath = require("../services/app_path"); +const searchService = require("../services/search/services/search"); +const SearchContext = require("../services/search/search_context"); +const log = require("../services/log"); /** * @param {SNote} note @@ -128,18 +132,42 @@ function register(router) { } const {header, content, isEmpty} = contentRenderer.getContent(note); - const subRoot = getSharedSubTreeRoot(note); + const opts = {note, header, content, isEmpty, subRoot, assetPath, appPath}; + let useDefaultView = true; + + // Check if the user has their own template + if (note.hasRelation('shareTemplate')) { + // Get the template note and content + const templateId = note.getRelation('shareTemplate').value; + const templateNote = shaca.getNote(templateId); + + // Make sure the note type is correct + if (templateNote.type === 'code' && templateNote.mime === 'application/x-ejs') { + + // EJS caches the result of this so we don't need to pre-cache + const includer = (path) => { + const childNote = templateNote.children.find(n => path === n.title); + if (!childNote) return null; + if (childNote.type !== 'code' || childNote.mime !== 'application/x-ejs') return null; + return { template: childNote.getContent() }; + }; + + // Try to render user's template, w/ fallback to default view + try { + const ejsResult = ejs.render(templateNote.getContent(), opts, {includer}); + res.send(ejsResult); + useDefaultView = false; // Rendering went okay, don't use default view + } + catch (e) { + log.error(`Rendering user provided share template (${templateId}) threw exception ${e.message} with stacktrace: ${e.stack}`); + } + } + } - res.render("share/page", { - note, - header, - content, - isEmpty, - subRoot, - assetPath, - appPath - }); + if (useDefaultView) { + res.render('share/page', opts); + } } router.get('/share/', (req, res, next) => { @@ -303,6 +331,37 @@ function register(router) { res.send(note.getContent()); }); + + // Used for searching, require noteId so we know the subTreeRoot + router.get('/share/api/notes', (req, res, next) => { + shacaLoader.ensureLoad(); + + const ancestorNoteId = req.query.ancestorNoteId ?? "_share"; + let note; + + // This will automatically return if no ancestorNoteId is provided and there is no shareIndex + if (!(note = checkNoteAccess(ancestorNoteId, req, res))) { + return; + } + + const {search} = req.query; + + if (!search?.trim()) { + return res.status(400).json({ message: "'search' parameter is mandatory." }); + } + + 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 }); + }); } module.exports = { diff --git a/src/views/share/page.ejs b/src/views/share/page.ejs index 52e891dbcf..8c6c3033d5 100644 --- a/src/views/share/page.ejs +++ b/src/views/share/page.ejs @@ -32,7 +32,7 @@ <%- header %> <%= note.title %> - +
    <% if (note.parents[0].noteId !== '_share' && note.parents.length !== 0) { %> @@ -62,9 +62,14 @@ <% } %>