Skip to content

Commit 5d5fd20

Browse files
author
x1arch
committed
Fix share access to attachments for notes protected by login:password
1 parent c16eee7 commit 5d5fd20

File tree

8 files changed

+201
-49
lines changed

8 files changed

+201
-49
lines changed

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ out-tsc
88

99
# dependencies
1010
node_modules
11+
.pnpm-store
1112

1213
# IDEs and editors
1314
/.idea
@@ -18,6 +19,7 @@ node_modules
1819
*.launch
1920
.settings/
2021
*.sublime-workspace
22+
.devcontainer
2123

2224
# misc
2325
/.sass-cache

README.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,21 @@ Here's the language coverage we have so far:
146146

147147
### Code
148148

149+
General (OS / docker / podman, etc.) dependencies:
150+
151+
Debian
152+
```
153+
apt update
154+
apt install -y build-essential python3 make g++ libsqlite3-dev
155+
corepack enable
156+
```
157+
158+
Alpine
159+
```
160+
apk add --no-cache build-base python3 python3-dev sqlite-dev
161+
corepack enable
162+
```
163+
149164
Download the repository, install dependencies using `pnpm` and then run the server (available at http://localhost:8080):
150165
```shell
151166
git clone https://github.com/TriliumNext/Trilium.git
@@ -154,6 +169,10 @@ pnpm install
154169
pnpm run server:start
155170
```
156171

172+
> 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`.
173+
174+
Share styles not compiling by default, if you see share page without styles, make `pnpm run server:build` and then run development server.
175+
157176
### Documentation
158177

159178
Download the repository, install dependencies using `pnpm` and then run the environment required to edit the documentation:

apps/server/src/share/content_renderer.ts

Lines changed: 19 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,13 @@ interface Subroot {
4040

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

43+
function addContentAccessQuery(note: SNote | BNote, secondEl?:boolean) {
44+
if (!(note instanceof BNote) && note.contentAccessor && note.contentAccessor?.type === "query") {
45+
return secondEl ? `&cat=${note.contentAccessor.getToken()}` : `?cat=${note.contentAccessor.getToken()}`;
46+
}
47+
return ""
48+
}
49+
4350
function getSharedSubTreeRoot(note: SNote | BNote | undefined): Subroot {
4451
if (!note || note.noteId === shareRoot.SHARE_ROOT_NOTE_ID) {
4552
// share root itself is not shared
@@ -111,19 +118,19 @@ export function renderNoteContent(note: SNote) {
111118
cssToLoad.push(`assets/scripts.css`);
112119
}
113120
for (const cssRelation of note.getRelations("shareCss")) {
114-
cssToLoad.push(`api/notes/${cssRelation.value}/download`);
121+
cssToLoad.push(`api/notes/${cssRelation.value}/download${addContentAccessQuery(note)}`);
115122
}
116123

117124
// Determine JS to load.
118125
const jsToLoad: string[] = [
119126
"assets/scripts.js"
120127
];
121128
for (const jsRelation of note.getRelations("shareJs")) {
122-
jsToLoad.push(`api/notes/${jsRelation.value}/download`);
129+
jsToLoad.push(`api/notes/${jsRelation.value}/download${addContentAccessQuery(note)}`);
123130
}
124131

125132
const customLogoId = note.getRelation("shareLogo")?.value;
126-
const logoUrl = customLogoId ? `api/images/${customLogoId}/image.png` : `../${assetUrlFragment}/images/icon-color.svg`;
133+
const logoUrl = customLogoId ? `api/images/${customLogoId}/image.png${addContentAccessQuery(note)}` : `../${assetUrlFragment}/images/icon-color.svg`;
127134

128135
return renderNoteContentInternal(note, {
129136
subRoot,
@@ -133,7 +140,7 @@ export function renderNoteContent(note: SNote) {
133140
logoUrl,
134141
ancestors,
135142
isStatic: false,
136-
faviconUrl: note.hasRelation("shareFavicon") ? `api/notes/${note.getRelationValue("shareFavicon")}/download` : `../favicon.ico`
143+
faviconUrl: note.hasRelation("shareFavicon") ? `api/notes/${note.getRelationValue("shareFavicon")}/download${addContentAccessQuery(note)}` : `../favicon.ico`
137144
});
138145
}
139146

@@ -158,6 +165,7 @@ function renderNoteContentInternal(note: SNote | BNote, renderArgs: RenderArgs)
158165
isEmpty,
159166
assetPath: shareAdjustedAssetPath,
160167
assetUrlFragment,
168+
addContentAccessQuery: (second: boolean | undefined) => addContentAccessQuery(note, second),
161169
showLoginInShareTheme,
162170
t,
163171
isDev,
@@ -325,7 +333,7 @@ function renderText(result: Result, note: SNote | BNote) {
325333
}
326334

327335
if (href?.startsWith("#")) {
328-
handleAttachmentLink(linkEl, href, getNote, getAttachment);
336+
handleAttachmentLink(linkEl, href, getNote, getAttachment, note);
329337
}
330338
}
331339

@@ -349,15 +357,15 @@ function renderText(result: Result, note: SNote | BNote) {
349357
}
350358
}
351359

352-
function handleAttachmentLink(linkEl: HTMLElement, href: string, getNote: GetNoteFunction, getAttachment: (id: string) => BAttachment | SAttachment | null) {
360+
function handleAttachmentLink(linkEl: HTMLElement, href: string, getNote: GetNoteFunction, getAttachment: (id: string) => BAttachment | SAttachment | null, note: SNote | BNote) {
353361
const linkRegExp = /attachmentId=([a-zA-Z0-9_]+)/g;
354362
let attachmentMatch;
355363
if ((attachmentMatch = linkRegExp.exec(href))) {
356364
const attachmentId = attachmentMatch[1];
357365
const attachment = getAttachment(attachmentId);
358366

359367
if (attachment) {
360-
linkEl.setAttribute("href", `api/attachments/${attachmentId}/download`);
368+
linkEl.setAttribute("href", `api/attachments/${attachmentId}/download${addContentAccessQuery(note)}`);
361369
linkEl.classList.add(`attachment-link`);
362370
linkEl.classList.add(`role-${attachment.role}`);
363371
linkEl.childNodes.length = 0;
@@ -430,7 +438,7 @@ function renderMermaid(result: Result, note: SNote | BNote) {
430438
}
431439

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

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

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

apps/server/src/share/routes.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,20 @@ function checkNoteAccess(noteId: string, req: Request, res: Response) {
6060
const header = req.header("Authorization");
6161

6262
if (!header?.startsWith("Basic ")) {
63+
if (req.path.startsWith("/share/api") && note.contentAccessor) {
64+
let contentAccessToken = ""
65+
if (note.contentAccessor.type === "cookie") contentAccessToken += req.cookies["trilium.cat"] || ""
66+
else if (note.contentAccessor.type === "query") contentAccessToken += req.query['cat'] || ""
67+
68+
if (contentAccessToken){
69+
if (note.contentAccessor.isTokenValid(contentAccessToken)){
70+
return note
71+
}
72+
res.status(401).send("Access is expired. Return back and update the page.");
73+
74+
return false;
75+
}
76+
}
6377
return false;
6478
}
6579

@@ -124,9 +138,14 @@ function register(router: Router) {
124138
return;
125139
}
126140

141+
if (note.isLabelTruthy("shareExclude")) {
142+
res.status(404);
143+
render404(res);
144+
return;
145+
}
146+
127147
if (!checkNoteAccess(note.noteId, req, res)) {
128148
requestCredentials(res);
129-
130149
return;
131150
}
132151

@@ -138,6 +157,10 @@ function register(router: Router) {
138157
return;
139158
}
140159

160+
if (note.contentAccessor && note.contentAccessor.type === "cookie") {
161+
res.cookie('trilium.cat', note.contentAccessor.getToken(), { maxAge: note.contentAccessor.getTokenExpiration() * 1000, httpOnly: true })
162+
}
163+
141164
res.send(renderNoteContent(note));
142165
}
143166

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

165188
const note = shaca.aliasToNote[shareId] || shaca.notes[shareId];
189+
if (note){
190+
note.initContentAccessor()
191+
}
166192

167193
renderNote(note, req, res);
168194
});
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import crypto from "crypto";
2+
import SNote from "./snote";
3+
import utils from "../../../services/utils";
4+
5+
const DefaultAccessTimeoutSec = 10 * 60; // 10 minutes
6+
7+
export class ContentAccessor {
8+
note: SNote;
9+
token: string;
10+
timestamp: number;
11+
type: string;
12+
timeout: number;
13+
key: Buffer;
14+
15+
constructor(note: SNote) {
16+
this.note = note;
17+
this.key = crypto.randomBytes(32);
18+
this.token = "";
19+
this.timestamp = 0;
20+
this.timeout = Number(this.note.getAttributeValue("label", "shareAccessTokenTimeout") || DefaultAccessTimeoutSec)
21+
22+
switch (this.note.getAttributeValue("label", "shareContentAccess")) {
23+
case "basic": this.type = "basic"; break
24+
case "query": this.type = "query"; break
25+
default: this.type = "cookie"; break
26+
};
27+
28+
}
29+
30+
__encrypt(text: string) {
31+
const iv = crypto.randomBytes(16);
32+
const cipher = crypto.createCipheriv('aes-256-cbc', this.key, iv);
33+
let encrypted = cipher.update(text, 'utf8', 'hex');
34+
encrypted += cipher.final('hex');
35+
return iv.toString('hex') + encrypted;
36+
}
37+
38+
__decrypt(encryptedText: string) {
39+
try {
40+
const iv = Buffer.from(encryptedText.slice(0, 32), 'hex');
41+
const decipher = crypto.createDecipheriv('aes-256-cbc', this.key, iv);
42+
let decrypted = decipher.update(encryptedText.slice(32), 'hex', 'utf8');
43+
decrypted += decipher.final('utf8');
44+
return decrypted;
45+
} catch {
46+
return ""
47+
}
48+
}
49+
50+
__compare(originalText: string, encryptedText: string) {
51+
return originalText === this.__decrypt(encryptedText)
52+
}
53+
54+
update() {
55+
if (new Date().getTime() < this.timestamp + this.getTimeout() * 1000) return
56+
this.token = utils.randomString(36);
57+
this.key = crypto.randomBytes(32);
58+
this.timestamp = new Date().getTime();
59+
}
60+
61+
isTokenValid(encToken: string) {
62+
return this.__compare(this.token, encToken) && new Date().getTime() < this.timestamp + this.getTimeout() * 1000;
63+
}
64+
65+
getToken() {
66+
return this.__encrypt(this.token);
67+
}
68+
69+
getTokenExpiration() {
70+
return (this.timestamp + (this.timeout * 1000) - new Date().getTime()) /1000;
71+
}
72+
73+
getTimeout() {
74+
return this.timeout;
75+
}
76+
77+
getContentAccessType() {
78+
return this.type;
79+
}
80+
81+
}

apps/server/src/share/shaca/entities/snote.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import type SAttribute from "./sattribute.js";
1010
import type SBranch from "./sbranch.js";
1111
import type { SNoteRow } from "./rows.js";
1212
import { NOTE_TYPE_ICONS } from "../../../becca/entities/bnote.js";
13+
import { ContentAccessor } from "./content_accessor.js";
1314

1415
const LABEL = "label";
1516
const RELATION = "relation";
@@ -33,6 +34,7 @@ class SNote extends AbstractShacaEntity {
3334
private __inheritableAttributeCache: SAttribute[] | null;
3435
targetRelations: SAttribute[];
3536
attachments: SAttachment[];
37+
contentAccessor: ContentAccessor | undefined;
3638

3739
constructor([noteId, title, type, mime, blobId, utcDateModified, isProtected]: SNoteRow) {
3840
super();
@@ -59,6 +61,15 @@ class SNote extends AbstractShacaEntity {
5961
this.shaca.notes[this.noteId] = this;
6062
}
6163

64+
initContentAccessor(){
65+
if (!this.contentAccessor && this.getCredentials().length > 0) {
66+
this.contentAccessor = new ContentAccessor(this);
67+
}
68+
if (this.contentAccessor) {
69+
this.contentAccessor.update()
70+
}
71+
}
72+
6273
getParentBranches() {
6374
return this.parentBranches;
6475
}
@@ -72,15 +83,15 @@ class SNote extends AbstractShacaEntity {
7283
}
7384

7485
getVisibleChildBranches() {
75-
return this.getChildBranches().filter((branch) => !branch.isHidden && !branch.getNote().isLabelTruthy("shareHiddenFromTree"));
86+
return this.getChildBranches().filter((branch) => !branch.isHidden && !branch.getNote().isLabelTruthy("shareHiddenFromTree") && !branch.getNote().isLabelTruthy("shareExclude"));
7687
}
7788

7889
getParentNotes() {
7990
return this.parents;
8091
}
8192

8293
getChildNotes() {
83-
return this.children;
94+
return this.children.filter((note) => !note.isLabelTruthy("shareExclude"));
8495
}
8596

8697
getVisibleChildNotes() {

docs/User Guide/User Guide/Advanced Usage/Sharing.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,7 @@ To do so, create a shared text note and apply the `shareIndex` label. When viewe
131131

132132
## Attribute reference
133133

134-
<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>
134+
<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>
135135

136136
### Customizing logo
137137

0 commit comments

Comments
 (0)