Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ out-tsc

# dependencies
node_modules
.pnpm-store

# IDEs and editors
/.idea
Expand All @@ -18,6 +19,7 @@ node_modules
*.launch
.settings/
*.sublime-workspace
.devcontainer

# misc
/.sass-cache
Expand Down
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down
30 changes: 19 additions & 11 deletions apps/server/src/share/content_renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,13 @@ interface Subroot {

type GetNoteFunction = (id: string) => SNote | BNote | null;

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 ""
}

function getSharedSubTreeRoot(note: SNote | BNote | undefined): Subroot {
if (!note || note.noteId === shareRoot.SHARE_ROOT_NOTE_ID) {
// share root itself is not shared
Expand Down Expand Up @@ -111,19 +118,19 @@ export function renderNoteContent(note: SNote) {
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"
];
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,
Expand All @@ -133,7 +140,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`
});
}

Expand All @@ -158,6 +165,7 @@ function renderNoteContentInternal(note: SNote | BNote, renderArgs: RenderArgs)
isEmpty,
assetPath: shareAdjustedAssetPath,
assetUrlFragment,
addContentAccessQuery: (second: boolean | undefined) => addContentAccessQuery(note, second),
showLoginInShareTheme,
t,
isDev,
Expand Down Expand Up @@ -325,7 +333,7 @@ function renderText(result: Result, note: SNote | BNote) {
}

if (href?.startsWith("#")) {
handleAttachmentLink(linkEl, href, getNote, getAttachment);
handleAttachmentLink(linkEl, href, getNote, getAttachment, note);
}
}

Expand All @@ -349,15 +357,15 @@ 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))) {
const attachmentId = attachmentMatch[1];
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;
Expand Down Expand Up @@ -430,7 +438,7 @@ function renderMermaid(result: Result, note: SNote | BNote) {
}

result.content = `
<img src="api/images/${note.noteId}/${note.encodedTitle}?${note.utcDateModified}">
<img src="api/images/${note.noteId}/${note.encodedTitle}?${note.utcDateModified}${addContentAccessQuery(note, true)}">
<hr>
<details>
<summary>Chart source</summary>
Expand All @@ -439,14 +447,14 @@ function renderMermaid(result: Result, note: SNote | BNote) {
}

function renderImage(result: Result, note: SNote | BNote) {
result.content = `<img src="api/images/${note.noteId}/${note.encodedTitle}?${note.utcDateModified}">`;
result.content = `<img src="api/images/${note.noteId}/${note.encodedTitle}?${note.utcDateModified}${addContentAccessQuery(note, true)}">`;
}

function renderFile(note: SNote | BNote, result: Result) {
if (note.mime === "application/pdf") {
result.content = `<iframe class="pdf-view" src="api/notes/${note.noteId}/view"></iframe>`;
result.content = `<iframe class="pdf-view" src="api/notes/${note.noteId}/view${addContentAccessQuery(note)}"></iframe>`;
} else {
result.content = `<button type="button" onclick="location.href='api/notes/${note.noteId}/download'">Download file</button>`;
result.content = `<button type="button" onclick="location.href='api/notes/${note.noteId}/download${addContentAccessQuery(note)}'">Download file</button>`;
}
}

Expand Down
28 changes: 27 additions & 1 deletion apps/server/src/share/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down Expand Up @@ -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;
}

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

Expand All @@ -163,6 +186,9 @@ function register(router: Router) {
const { shareId } = req.params;

const note = shaca.aliasToNote[shareId] || shaca.notes[shareId];
if (note){
note.initContentAccessor()
}

renderNote(note, req, res);
});
Expand Down
81 changes: 81 additions & 0 deletions apps/server/src/share/shaca/entities/content_accessor.ts
Original file line number Diff line number Diff line change
@@ -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;
}

}
15 changes: 13 additions & 2 deletions apps/server/src/share/shaca/entities/snote.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -33,6 +34,7 @@ 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();
Expand All @@ -59,6 +61,15 @@ 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()
}
}

getParentBranches() {
return this.parentBranches;
}
Expand All @@ -72,15 +83,15 @@ 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() {
return this.parents;
}

getChildNotes() {
return this.children;
return this.children.filter((note) => !note.isLabelTruthy("shareExclude"));
}

getVisibleChildNotes() {
Expand Down
2 changes: 1 addition & 1 deletion docs/User Guide/User Guide/Advanced Usage/Sharing.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ To do so, create a shared text note and apply the `shareIndex` label. When viewe

## Attribute reference

<table class="ck-table-resized"><colgroup><col style="width:18.38%;"><col style="width:81.62%;"></colgroup><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>
<table class="ck-table-resized"><colgroup><col style="width:18.38%;"><col style="width:81.62%;"></colgroup><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>#shareTemplateNoPrevNext</code></td><td>hide bottom page navigation prev and next page.</td></tr><tr><td><code>#shareTemplateNoLeftPanel</code></td><td>hide left panel fully.</td></tr><tr><td><code>#shareExclude</code></td><td>this note will be excluded from share, not accessible via direct URL (implemented to hide scripts from share)</td></tr><tr><td><code>#shareContentAccess</code></td><td>method 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.</td></tr><tr><td><code>#shareAccessTokenTimeout</code></td><td>token 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.`</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>

### Customizing logo

Expand Down
Loading