Skip to content

Commit 005d365

Browse files
author
x1arch
committed
fix content access for credentials protected notes; add new attributes for hide parts of share template
1 parent c63f253 commit 005d365

File tree

6 files changed

+167
-48
lines changed

6 files changed

+167
-48
lines changed

apps/server/src/share/content_renderer.ts

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

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

43-
function getSharedSubTreeRoot(note: SNote | BNote | undefined): Subroot {
43+
function addContentAccessQuery(note: any, secondEl?:boolean) {
44+
if ((note as SNote).contentAccessor && (note as SNote).contentAccessor?.type === "query") {
45+
return secondEl ? `&cat=${note.contentAccessor.getToken()}` : `?cat=${note.contentAccessor.getToken()}`;
46+
}
47+
return ""
48+
}
49+
4450
export function getSharedSubTreeRoot(note: SNote | BNote | undefined, parentId: string | undefined = undefined): Subroot {
4551
if (!note || note.noteId === shareRoot.SHARE_ROOT_NOTE_ID) {
4652
// share root itself is not shared
@@ -112,19 +118,19 @@ export function renderNoteContent(note: SNote) {
112118
cssToLoad.push(`../assets/scripts.css`);
113119
}
114120
for (const cssRelation of note.getRelations("shareCss")) {
115-
cssToLoad.push(`../api/notes/${cssRelation.value}/download`);
121+
cssToLoad.push(`../api/notes/${cssRelation.value}/download${addContentAccessQuery(note)}`);
116122
}
117123

118124
// Determine JS to load.
119125
const jsToLoad: string[] = [
120126
"../assets/scripts.js"
121127
];
122128
for (const jsRelation of note.getRelations("shareJs")) {
123-
jsToLoad.push(`../api/notes/${jsRelation.value}/download`);
129+
jsToLoad.push(`../api/notes/${jsRelation.value}/download${addContentAccessQuery(note)}`);
124130
}
125131

126132
const customLogoId = note.getRelation("shareLogo")?.value;
127-
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`;
128134

129135
return renderNoteContentInternal(note, {
130136
subRoot,
@@ -134,7 +140,7 @@ export function renderNoteContent(note: SNote) {
134140
logoUrl,
135141
ancestors,
136142
isStatic: false,
137-
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`
138144
});
139145
}
140146

@@ -159,6 +165,7 @@ function renderNoteContentInternal(note: SNote | BNote, renderArgs: RenderArgs)
159165
isEmpty,
160166
assetPath: shareAdjustedAssetPath,
161167
assetUrlFragment,
168+
addContentAccessQuery: (second: boolean | undefined) => addContentAccessQuery(note, second),
162169
showLoginInShareTheme,
163170
t,
164171
isDev,
@@ -326,7 +333,7 @@ function renderText(result: Result, note: SNote | BNote) {
326333
}
327334

328335
if (href?.startsWith("#")) {
329-
handleAttachmentLink(linkEl, href, getNote, getAttachment);
336+
handleAttachmentLink(linkEl, href, getNote, getAttachment, note);
330337
}
331338
}
332339

@@ -350,15 +357,15 @@ function renderText(result: Result, note: SNote | BNote) {
350357
}
351358
}
352359

353-
function handleAttachmentLink(linkEl: HTMLElement, href: string, getNote: GetNoteFunction, getAttachment: (id: string) => BAttachment | SAttachment | null) {
360+
function handleAttachmentLink(linkEl: HTMLElement, href: string, getNote: (id: string) => SNote | BNote | null, getAttachment: (id: string) => BAttachment | SAttachment | null, note: SNote | BNote) {
354361
const linkRegExp = /attachmentId=([a-zA-Z0-9_]+)/g;
355362
let attachmentMatch;
356363
if ((attachmentMatch = linkRegExp.exec(href))) {
357364
const attachmentId = attachmentMatch[1];
358365
const attachment = getAttachment(attachmentId);
359366

360367
if (attachment) {
361-
linkEl.setAttribute("href", `../api/attachments/${attachmentId}/download`);
368+
linkEl.setAttribute("href", `../api/attachments/${attachmentId}/download${addContentAccessQuery(note)}`);
362369
linkEl.classList.add(`attachment-link`);
363370
linkEl.classList.add(`role-${attachment.role}`);
364371
linkEl.childNodes.length = 0;
@@ -431,7 +438,7 @@ function renderMermaid(result: Result, note: SNote | BNote) {
431438
}
432439

433440
result.content = `
434-
<img src="../api/images/${note.noteId}/${note.encodedTitle}?${note.utcDateModified}">
441+
<img src="../api/images/${note.noteId}/${note.encodedTitle}?${note.utcDateModified}${addContentAccessQuery(note, true)}">
435442
<hr>
436443
<details>
437444
<summary>Chart source</summary>
@@ -440,14 +447,14 @@ function renderMermaid(result: Result, note: SNote | BNote) {
440447
}
441448

442449
function renderImage(result: Result, note: SNote | BNote) {
443-
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)}">`;
444451
}
445452

446453
function renderFile(note: SNote | BNote, result: Result) {
447454
if (note.mime === "application/pdf") {
448-
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>`;
449456
} else {
450-
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>`;
451458
}
452459
}
453460

apps/server/src/share/routes.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,17 @@ 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+
const contentAccessToken = "" + (note.contentAccessor.type === "cookie" ? req.cookies["trilium.cat"] || "" : note.contentAccessor.type === "query" ? req.query['cat'] || "" : "")
65+
if (contentAccessToken){
66+
if (note.contentAccessor.isTokenValid(contentAccessToken)){
67+
return note
68+
}
69+
res.status(401).send("Access is expired. Return back and update the page.");
70+
71+
return false;
72+
}
73+
}
6374
return false;
6475
}
6576

@@ -143,6 +154,10 @@ function register(router: Router) {
143154
return;
144155
}
145156

157+
if (note.contentAccessor && note.contentAccessor.type === "cookie") {
158+
res.cookie('trilium.cat', note.contentAccessor.getToken(), { maxAge: note.contentAccessor.getTokenExpiration() * 1000, httpOnly: true })
159+
}
160+
146161
res.send(renderNoteContent(note));
147162
}
148163

@@ -170,6 +185,7 @@ function register(router: Router) {
170185
const note = shaca.aliasToNote[shareId] || shaca.notes[shareId];
171186
if (note){
172187
note.parentId = parentShareId
188+
note.initContentAccessor()
173189
}
174190

175191
renderNote(note, req, res);
@@ -184,7 +200,6 @@ function register(router: Router) {
184200
const parent = getSharedSubTreeRoot(note)
185201

186202
res.redirect(`${parent?.note?.noteId}/${shareId}`)
187-
// renderNote(note, req, res);
188203
});
189204

190205
router.get("/share/api/notes/:noteId", (req, res) => {
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+
export const DefultAccessTimeoutSec = 10 * 60; // 10 minutes
6+
7+
8+
export class ContentAccessor {
9+
note: SNote;
10+
token: string;
11+
timestamp: number;
12+
type: string;
13+
timeout: number;
14+
key: NonSharedBuffer;
15+
iv: NonSharedBuffer;
16+
17+
constructor(note: SNote) {
18+
this.note = note;
19+
this.key = crypto.randomBytes(32);
20+
this.iv = crypto.randomBytes(16);
21+
this.token = "";
22+
this.timestamp = 0;
23+
this.timeout = Number(this.note.getAttributeValue("label", "shareAccessTokenTimeout") || DefultAccessTimeoutSec)
24+
25+
switch (this.note.getAttributeValue("label", "shareContentAccess")) {
26+
case "basic": this.type = "basic"; break
27+
case "query": this.type = "query"; break
28+
default: this.type = "cookie"; break
29+
};
30+
31+
}
32+
33+
__encrypt(text: string) {
34+
const cipher = crypto.createCipheriv('aes-256-cbc', this.key, this.iv);
35+
let encrypted = cipher.update(text);
36+
encrypted = Buffer.concat([encrypted, cipher.final()]);
37+
return encrypted.toString('hex');
38+
}
39+
40+
__decrypt(encryptedText: string) {
41+
try {
42+
const decipher = crypto.createDecipheriv('aes-256-cbc', this.key, this.iv);
43+
let decrypted = decipher.update(Buffer.from(encryptedText, 'hex'));
44+
decrypted = Buffer.concat([decrypted, decipher.final()]);
45+
return decrypted.toString();
46+
} catch {
47+
return ""
48+
}
49+
}
50+
51+
__compare(originalText: string, encryptedText: string) {
52+
return originalText === this.__decrypt(encryptedText)
53+
}
54+
55+
update() {
56+
if (new Date().getTime() < this.timestamp + this.getTimeout() * 1000) return
57+
this.token = utils.randomString(36);
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: 11 additions & 0 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";
@@ -34,6 +35,7 @@ class SNote extends AbstractShacaEntity {
3435
private __inheritableAttributeCache: SAttribute[] | null;
3536
targetRelations: SAttribute[];
3637
attachments: SAttachment[];
38+
contentAccessor: ContentAccessor | undefined;
3739

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

66+
initContentAccessor(){
67+
if (!this.contentAccessor && this.getCredentials().length > 0) {
68+
this.contentAccessor = new ContentAccessor(this);
69+
}
70+
if (this.contentAccessor) {
71+
this.contentAccessor.update()
72+
}
73+
}
74+
6475
getParentId() {
6576
return this.parentId;
6677
}

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>#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>#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 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 couls 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)