diff --git a/.gitignore b/.gitignore index b2c4e3c464..54d5e67282 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ out-tsc # dependencies node_modules +.pnpm-store # IDEs and editors /.idea @@ -18,6 +19,7 @@ node_modules *.launch .settings/ *.sublime-workspace +.devcontainer # misc /.sass-cache diff --git a/README.md b/README.md index be62b5370d..ad7e8b56d6 100644 --- a/README.md +++ b/README.md @@ -146,6 +146,21 @@ Here's the language coverage we have so far: ### Code +General (OS / docker / podman, etc.) dependencies: + +Debian +``` +apt update +apt install -y build-essential python3 make g++ libsqlite3-dev +corepack enable +``` + +Alpine +``` +apk add --no-cache build-base python3 python3-dev sqlite-dev +corepack enable +``` + Download the repository, install dependencies using `pnpm` and then run the server (available at http://localhost:8080): ```shell git clone https://github.com/TriliumNext/Trilium.git @@ -154,6 +169,10 @@ pnpm install pnpm run server:start ``` +> If you faced with some problems, try to delete all `node_modules` and `.pnpm-store` folders, not only from the root, from every directory, like `apps/{app_name}/node_modules`and `/packages/{package_name}/node_modules` and then reinstall it by the `pnpm install`. + +Share styles not compiling by default, if you see share page without styles, make `pnpm run server:build` and then run development server. + ### Documentation Download the repository, install dependencies using `pnpm` and then run the environment required to edit the documentation: diff --git a/apps/server/src/share/content_renderer.ts b/apps/server/src/share/content_renderer.ts index 96e228cc04..819f7bbe63 100644 --- a/apps/server/src/share/content_renderer.ts +++ b/apps/server/src/share/content_renderer.ts @@ -40,15 +40,21 @@ interface Subroot { type GetNoteFunction = (id: string) => SNote | BNote | null; -function getSharedSubTreeRoot(note: SNote | BNote | undefined): Subroot { +function addContentAccessQuery(note: SNote | BNote, secondEl?:boolean) { + if (!(note instanceof BNote) && note.contentAccessor && note.contentAccessor?.type === "query") { + return secondEl ? `&cat=${note.contentAccessor.getToken()}` : `?cat=${note.contentAccessor.getToken()}`; + } + return "" +} + +export function getSharedSubTreeRoot(note: SNote | BNote | undefined, parentId: string | undefined = undefined): Subroot { if (!note || note.noteId === shareRoot.SHARE_ROOT_NOTE_ID) { // share root itself is not shared return {}; } - // every path leads to share root, but which one to choose? - // for the sake of simplicity, URLs are not note paths - const parentBranch = note.getParentBranches()[0]; + const parentBranches = note.getParentBranches() + const parentBranch = (parentId ? parentBranches.find((pb: SBranch | BBranch) => pb.parentNoteId === parentId) : undefined) || parentBranches[0]; if (note instanceof BNote) { return { @@ -64,7 +70,7 @@ function getSharedSubTreeRoot(note: SNote | BNote | undefined): Subroot { }; } - return getSharedSubTreeRoot(parentBranch.getParentNote()); + return getSharedSubTreeRoot(parentBranch.getParentNote(), parentId); } export function renderNoteForExport(note: BNote, parentBranch: BBranch, basePath: string, ancestors: string[]) { @@ -91,7 +97,7 @@ export function renderNoteForExport(note: BNote, parentBranch: BBranch, basePath } export function renderNoteContent(note: SNote) { - const subRoot = getSharedSubTreeRoot(note); + const subRoot = getSharedSubTreeRoot(note, note.parentId); const ancestors: string[] = []; let notePointer = note; @@ -107,23 +113,23 @@ export function renderNoteContent(note: SNote) { // Determine CSS to load. const cssToLoad: string[] = []; if (!note.isLabelTruthy("shareOmitDefaultCss")) { - cssToLoad.push(`assets/styles.css`); - cssToLoad.push(`assets/scripts.css`); + cssToLoad.push(`../assets/styles.css`); + cssToLoad.push(`../assets/scripts.css`); } for (const cssRelation of note.getRelations("shareCss")) { - cssToLoad.push(`api/notes/${cssRelation.value}/download`); + cssToLoad.push(`../api/notes/${cssRelation.value}/download${addContentAccessQuery(note)}`); } // Determine JS to load. const jsToLoad: string[] = [ - "assets/scripts.js" + "../assets/scripts.js" ]; for (const jsRelation of note.getRelations("shareJs")) { - jsToLoad.push(`api/notes/${jsRelation.value}/download`); + jsToLoad.push(`../api/notes/${jsRelation.value}/download${addContentAccessQuery(note)}`); } const customLogoId = 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${addContentAccessQuery(note)}` : `../../${assetUrlFragment}/images/icon-color.svg`; return renderNoteContentInternal(note, { subRoot, @@ -133,7 +139,7 @@ export function renderNoteContent(note: SNote) { logoUrl, ancestors, isStatic: false, - faviconUrl: note.hasRelation("shareFavicon") ? `api/notes/${note.getRelationValue("shareFavicon")}/download` : `../favicon.ico` + faviconUrl: note.hasRelation("shareFavicon") ? `../api/notes/${note.getRelationValue("shareFavicon")}/download${addContentAccessQuery(note)}` : `../../favicon.ico` }); } @@ -158,6 +164,7 @@ function renderNoteContentInternal(note: SNote | BNote, renderArgs: RenderArgs) isEmpty, assetPath: shareAdjustedAssetPath, assetUrlFragment, + addContentAccessQuery: (second: boolean | undefined) => addContentAccessQuery(note, second), showLoginInShareTheme, t, isDev, @@ -325,7 +332,7 @@ function renderText(result: Result, note: SNote | BNote) { } if (href?.startsWith("#")) { - handleAttachmentLink(linkEl, href, getNote, getAttachment); + handleAttachmentLink(linkEl, href, getNote, getAttachment, note); } } @@ -349,7 +356,7 @@ function renderText(result: Result, note: SNote | BNote) { } } -function handleAttachmentLink(linkEl: HTMLElement, href: string, getNote: GetNoteFunction, getAttachment: (id: string) => BAttachment | SAttachment | null) { +function handleAttachmentLink(linkEl: HTMLElement, href: string, getNote: GetNoteFunction, getAttachment: (id: string) => BAttachment | SAttachment | null, note: SNote | BNote) { const linkRegExp = /attachmentId=([a-zA-Z0-9_]+)/g; let attachmentMatch; if ((attachmentMatch = linkRegExp.exec(href))) { @@ -357,7 +364,7 @@ function handleAttachmentLink(linkEl: HTMLElement, href: string, getNote: GetNot const attachment = getAttachment(attachmentId); if (attachment) { - linkEl.setAttribute("href", `api/attachments/${attachmentId}/download`); + linkEl.setAttribute("href", `../api/attachments/${attachmentId}/download${addContentAccessQuery(note)}`); linkEl.classList.add(`attachment-link`); linkEl.classList.add(`role-${attachment.role}`); linkEl.childNodes.length = 0; @@ -373,7 +380,7 @@ function handleAttachmentLink(linkEl: HTMLElement, href: string, getNote: GetNot const linkedNote = getNote(noteId); if (linkedNote) { const isExternalLink = linkedNote.hasLabel("shareExternalLink"); - const href = isExternalLink ? linkedNote.getLabelValue("shareExternalLink") : `./${linkedNote.shareId}`; + const href = isExternalLink ? linkedNote.getLabelValue("shareExternalLink") : `../${linkedNote.shareId}`; if (href) { linkEl.setAttribute("href", href); } @@ -430,7 +437,7 @@ function renderMermaid(result: Result, note: SNote | BNote) { } result.content = ` - +
Chart source @@ -439,14 +446,14 @@ function renderMermaid(result: Result, note: SNote | BNote) { } function renderImage(result: Result, note: SNote | BNote) { - result.content = ``; + result.content = ``; } function renderFile(note: SNote | BNote, result: Result) { if (note.mime === "application/pdf") { - result.content = ``; + result.content = ``; } else { - result.content = ``; + result.content = ``; } } diff --git a/apps/server/src/share/routes.ts b/apps/server/src/share/routes.ts index 80544fd998..f6a9646c5d 100644 --- a/apps/server/src/share/routes.ts +++ b/apps/server/src/share/routes.ts @@ -8,7 +8,7 @@ import searchService from "../services/search/services/search.js"; import SearchContext from "../services/search/search_context.js"; import type SNote from "./shaca/entities/snote.js"; import type SAttachment from "./shaca/entities/sattachment.js"; -import { getDefaultTemplatePath, renderNoteContent } from "./content_renderer.js"; +import { getDefaultTemplatePath, getSharedSubTreeRoot, renderNoteContent } from "./content_renderer.js"; import utils from "../services/utils.js"; function addNoIndexHeader(note: SNote, res: Response) { @@ -60,6 +60,20 @@ function checkNoteAccess(noteId: string, req: Request, res: Response) { const header = req.header("Authorization"); if (!header?.startsWith("Basic ")) { + if (req.path.startsWith("/share/api") && note.contentAccessor) { + let contentAccessToken = "" + if (note.contentAccessor.type === "cookie") contentAccessToken += req.cookies["trilium.cat"] || "" + else if (note.contentAccessor.type === "query") contentAccessToken += req.query['cat'] || "" + + if (contentAccessToken){ + if (note.contentAccessor.isTokenValid(contentAccessToken)){ + return note + } + res.status(401).send("Access is expired. Return back and update the page."); + + return false; + } + } return false; } @@ -124,9 +138,14 @@ function register(router: Router) { return; } + if (note.isLabelTruthy("shareExclude")) { + res.status(404); + render404(res); + return; + } + if (!checkNoteAccess(note.noteId, req, res)) { requestCredentials(res); - return; } @@ -138,6 +157,10 @@ function register(router: Router) { return; } + if (note.contentAccessor && note.contentAccessor.type === "cookie") { + res.cookie('trilium.cat', note.contentAccessor.getToken(), { maxAge: note.contentAccessor.getTokenExpiration() * 1000, httpOnly: true }) + } + res.send(renderNoteContent(note)); } @@ -157,14 +180,29 @@ function register(router: Router) { renderNote(shaca.shareRootNote, req, res); }); + router.get("/share/:parentShareId/:shareId", (req, res) => { + shacaLoader.ensureLoad(); + + const { parentShareId, shareId } = req.params; + + const note = shaca.aliasToNote[shareId] || shaca.notes[shareId]; + if (note){ + note.parentId = parentShareId + note.initContentAccessor() + } + + renderNote(note, req, res); + }); + router.get("/share/:shareId", (req, res) => { shacaLoader.ensureLoad(); const { shareId } = req.params; const note = shaca.aliasToNote[shareId] || shaca.notes[shareId]; + const parent = getSharedSubTreeRoot(note) - renderNote(note, req, res); + res.redirect(`${parent?.note?.noteId}/${shareId}`) }); router.get("/share/api/notes/:noteId", (req, res) => { diff --git a/apps/server/src/share/shaca/entities/content_accessor.ts b/apps/server/src/share/shaca/entities/content_accessor.ts new file mode 100644 index 0000000000..fa4bdad4af --- /dev/null +++ b/apps/server/src/share/shaca/entities/content_accessor.ts @@ -0,0 +1,81 @@ +import crypto from "crypto"; +import SNote from "./snote"; +import utils from "../../../services/utils"; + +const DefaultAccessTimeoutSec = 10 * 60; // 10 minutes + +export class ContentAccessor { + note: SNote; + token: string; + timestamp: number; + type: string; + timeout: number; + key: Buffer; + + constructor(note: SNote) { + this.note = note; + this.key = crypto.randomBytes(32); + this.token = ""; + this.timestamp = 0; + this.timeout = Number(this.note.getAttributeValue("label", "shareAccessTokenTimeout") || DefaultAccessTimeoutSec) + + switch (this.note.getAttributeValue("label", "shareContentAccess")) { + case "basic": this.type = "basic"; break + case "query": this.type = "query"; break + default: this.type = "cookie"; break + }; + + } + + __encrypt(text: string) { + const iv = crypto.randomBytes(16); + const cipher = crypto.createCipheriv('aes-256-cbc', this.key, iv); + let encrypted = cipher.update(text, 'utf8', 'hex'); + encrypted += cipher.final('hex'); + return iv.toString('hex') + encrypted; + } + + __decrypt(encryptedText: string) { + try { + const iv = Buffer.from(encryptedText.slice(0, 32), 'hex'); + const decipher = crypto.createDecipheriv('aes-256-cbc', this.key, iv); + let decrypted = decipher.update(encryptedText.slice(32), 'hex', 'utf8'); + decrypted += decipher.final('utf8'); + return decrypted; + } catch { + return "" + } + } + + __compare(originalText: string, encryptedText: string) { + return originalText === this.__decrypt(encryptedText) + } + + update() { + if (new Date().getTime() < this.timestamp + this.getTimeout() * 1000) return + this.token = utils.randomString(36); + this.key = crypto.randomBytes(32); + this.timestamp = new Date().getTime(); + } + + isTokenValid(encToken: string) { + return this.__compare(this.token, encToken) && new Date().getTime() < this.timestamp + this.getTimeout() * 1000; + } + + getToken() { + return this.__encrypt(this.token); + } + + getTokenExpiration() { + return (this.timestamp + (this.timeout * 1000) - new Date().getTime()) /1000; + } + + getTimeout() { + return this.timeout; + } + + getContentAccessType() { + return this.type; + } + +} \ No newline at end of file diff --git a/apps/server/src/share/shaca/entities/snote.ts b/apps/server/src/share/shaca/entities/snote.ts index 19dbd463e2..9ad38add38 100644 --- a/apps/server/src/share/shaca/entities/snote.ts +++ b/apps/server/src/share/shaca/entities/snote.ts @@ -10,6 +10,7 @@ import type SAttribute from "./sattribute.js"; import type SBranch from "./sbranch.js"; import type { SNoteRow } from "./rows.js"; import { NOTE_TYPE_ICONS } from "../../../becca/entities/bnote.js"; +import { ContentAccessor } from "./content_accessor.js"; const LABEL = "label"; const RELATION = "relation"; @@ -19,6 +20,7 @@ const isCredentials = (attr: SAttribute) => attr.type === "label" && attr.name = class SNote extends AbstractShacaEntity { noteId: string; + parentId?: string | undefined; title: string; type: string; mime: string; @@ -33,11 +35,13 @@ class SNote extends AbstractShacaEntity { private __inheritableAttributeCache: SAttribute[] | null; targetRelations: SAttribute[]; attachments: SAttachment[]; + contentAccessor: ContentAccessor | undefined; constructor([noteId, title, type, mime, blobId, utcDateModified, isProtected]: SNoteRow) { super(); this.noteId = noteId; + this.parentId = undefined; this.title = isProtected ? "[protected]" : title; this.type = type; this.mime = mime; @@ -59,6 +63,19 @@ class SNote extends AbstractShacaEntity { this.shaca.notes[this.noteId] = this; } + initContentAccessor(){ + if (!this.contentAccessor && this.getCredentials().length > 0) { + this.contentAccessor = new ContentAccessor(this); + } + if (this.contentAccessor) { + this.contentAccessor.update() + } + } + + getParentId() { + return this.parentId; + } + getParentBranches() { return this.parentBranches; } @@ -72,7 +89,7 @@ class SNote extends AbstractShacaEntity { } getVisibleChildBranches() { - return this.getChildBranches().filter((branch) => !branch.isHidden && !branch.getNote().isLabelTruthy("shareHiddenFromTree")); + return this.getChildBranches().filter((branch) => !branch.isHidden && !branch.getNote().isLabelTruthy("shareHiddenFromTree") && !branch.getNote().isLabelTruthy("shareExclude")); } getParentNotes() { @@ -80,7 +97,7 @@ class SNote extends AbstractShacaEntity { } getChildNotes() { - return this.children; + return this.children.filter((note) => !note.isLabelTruthy("shareExclude")); } getVisibleChildNotes() { diff --git a/docs/User Guide/User Guide/Advanced Usage/Sharing.md b/docs/User Guide/User Guide/Advanced Usage/Sharing.md index 5f87ce125f..50666e524f 100644 --- a/docs/User Guide/User Guide/Advanced Usage/Sharing.md +++ b/docs/User Guide/User Guide/Advanced Usage/Sharing.md @@ -131,7 +131,7 @@ To do so, create a shared text note and apply the `shareIndex` label. When viewe ## Attribute reference -
AttributeDescription
#shareHiddenFromTreethis note is hidden from left navigation tree, but still accessible with its URL
#shareExternalLinknote will act as a link to an external website in the share tree
#shareAliasdefine an alias using which the note will be available under https://your_trilium_host/share/[your_alias]
#shareOmitDefaultCssdefault share page CSS will be omitted. Use when you make extensive styling changes.
#shareRootmarks note which is served on /share root.
#shareDescriptiondefine text to be added to the HTML meta tag for description
#shareRawNote will be served in its raw format, without HTML wrapper. See also Serving directly the content of a note for an alternative method without setting an attribute.
#shareDisallowRobotIndexing

Indicates to web crawlers that the page should not be indexed of this note by:

  • Setting the X-Robots-Tag: noindex HTTP header.
  • Setting the noindex, follow meta tag.
#shareCredentialsrequire credentials to access this shared note. Value is expected to be in format username:password. Don't forget to make this inheritable to apply to child-notes/images.
#shareIndexNote with this label will list all roots of shared notes.
#shareHtmlLocationdefines where custom HTML injected via ~shareHtml relation should be placed. Applied to the HTML snippet note itself. Format: location:position where location is head, body, or content and position is start or end. Defaults to content:end.
+
AttributeDescription
#shareHiddenFromTreethis note is hidden from left navigation tree, but still accessible with its URL
#shareTemplateNoPrevNexthide bottom page navigation prev and next page.
#shareTemplateNoLeftPanelhide left panel fully.
#shareExcludethis note will be excluded from share, not accessible via direct URL (implemented to hide scripts from share)
#shareContentAccessmethod for attachments authorization in case when note protected with login and password (#shareCredentials). Could be cookie (the cookie will be provided when page loads) / query (every url will be updated with token) / basic (only basic header authorization)). By default for browser used cookie.
#shareAccessTokenTimeouttoken expiration timeout in seconds, by default 10 minutes. While token not expired user could download attachment, after that he will get message `Access is expired. Return back and update the page.`
#shareExternalLinknote will act as a link to an external website in the share tree
#shareAliasdefine an alias using which the note will be available under https://your_trilium_host/share/[your_alias]
#shareOmitDefaultCssdefault share page CSS will be omitted. Use when you make extensive styling changes.
#shareRootmarks note which is served on /share root.
#shareDescriptiondefine text to be added to the HTML meta tag for description
#shareRawNote will be served in its raw format, without HTML wrapper. See also Serving directly the content of a note for an alternative method without setting an attribute.
#shareDisallowRobotIndexing

Indicates to web crawlers that the page should not be indexed of this note by:

  • Setting the X-Robots-Tag: noindex HTTP header.
  • Setting the noindex, follow meta tag.
#shareCredentialsrequire credentials to access this shared note. Value is expected to be in format username:password. Don't forget to make this inheritable to apply to child-notes/images.
#shareIndexNote with this label will list all roots of shared notes.
#shareHtmlLocationdefines where custom HTML injected via ~shareHtml relation should be placed. Applied to the HTML snippet note itself. Format: location:position where location is head, body, or content and position is start or end. Defaults to content:end.
### Customizing logo diff --git a/packages/share-theme/src/templates/page.ejs b/packages/share-theme/src/templates/page.ejs index 0a7db95b43..f098c4c390 100644 --- a/packages/share-theme/src/templates/page.ejs +++ b/packages/share-theme/src/templates/page.ejs @@ -50,7 +50,7 @@ let openGraphImage = subRoot.note.getLabelValue("shareOpenGraphImage"); // Relation takes priority and requires some altering if (subRoot.note.hasRelation("shareOpenGraphImage")) { - openGraphImage = `api/images/${subRoot.note.getRelation("shareOpenGraphImage").value}/image.png`; + openGraphImage = `api/images/${subRoot.note.getRelation("shareOpenGraphImage").value}/image.png${addContentAccessQuery()}`; } %> <%= pageTitle %> @@ -109,40 +109,43 @@ content = content.replaceAll(headingRe, (...match) => {
-
-