Skip to content
Draft
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
52 changes: 30 additions & 22 deletions apps/server/src/share/content_renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,15 +40,21 @@ interface Subroot {

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

function getSharedSubTreeRoot(note: SNote | BNote | undefined): Subroot {
function addContentAccessQuery(note: any, secondEl?:boolean) {
if ((note as SNote).contentAccessor && (note as SNote).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 {
Expand All @@ -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[]) {
Expand All @@ -91,9 +97,10 @@ 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[] = [];
const ancestors: string[] = []
let notePointer = note;
while (notePointer.parents[0]?.noteId !== subRoot.note?.noteId) {
const pointerParent = notePointer.parents[0];
Expand All @@ -107,23 +114,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,
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: (id: string) => SNote | BNote | null, 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 All @@ -373,7 +381,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);
}
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
41 changes: 38 additions & 3 deletions apps/server/src/share/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -60,6 +60,17 @@ 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) {
const contentAccessToken = "" + (note.contentAccessor.type === "cookie" ? req.cookies["trilium.cat"] || "" : note.contentAccessor.type === "query" ? 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 +135,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 +154,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 @@ -157,14 +177,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) => {
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";

export const DefultAccessTimeoutSec = 10 * 60; // 10 minutes


export class ContentAccessor {
note: SNote;
token: string;
timestamp: number;
type: string;
timeout: number;
key: NonSharedBuffer;
iv: NonSharedBuffer;

constructor(note: SNote) {
this.note = note;
this.key = crypto.randomBytes(32);
this.iv = crypto.randomBytes(16);
this.token = "";
this.timestamp = 0;
this.timeout = Number(this.note.getAttributeValue("label", "shareAccessTokenTimeout") || DefultAccessTimeoutSec)

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 cipher = crypto.createCipheriv('aes-256-cbc', this.key, this.iv);
let encrypted = cipher.update(text);
encrypted = Buffer.concat([encrypted, cipher.final()]);
return encrypted.toString('hex');
}

__decrypt(encryptedText: string) {
try {
const decipher = crypto.createDecipheriv('aes-256-cbc', this.key, this.iv);
let decrypted = decipher.update(Buffer.from(encryptedText, 'hex'));
decrypted = Buffer.concat([decrypted, decipher.final()]);
return decrypted.toString();
} 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.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;
}

}
Loading