Skip to content

Commit df91e5e

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

File tree

6 files changed

+167
-47
lines changed

6 files changed

+167
-47
lines changed

apps/server/src/share/content_renderer.ts

Lines changed: 19 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,13 @@ interface Subroot {
3838
branch?: SBranch | BBranch
3939
}
4040

41+
function addContentAccessQuery(note: any, secondEl?:boolean) {
42+
if ((note as SNote).contentAccessor && (note as SNote).contentAccessor?.type === "query") {
43+
return secondEl ? `&cat=${note.contentAccessor.getToken()}` : `?cat=${note.contentAccessor.getToken()}`;
44+
}
45+
return ""
46+
}
47+
4148
export function getSharedSubTreeRoot(note: SNote | BNote | undefined, parentId: string | undefined = undefined): Subroot {
4249
if (!note || note.noteId === shareRoot.SHARE_ROOT_NOTE_ID) {
4350
// share root itself is not shared
@@ -109,19 +116,19 @@ export function renderNoteContent(note: SNote) {
109116
cssToLoad.push(`../assets/scripts.css`);
110117
}
111118
for (const cssRelation of note.getRelations("shareCss")) {
112-
cssToLoad.push(`../api/notes/${cssRelation.value}/download`);
119+
cssToLoad.push(`../api/notes/${cssRelation.value}/download${addContentAccessQuery(note)}`);
113120
}
114121

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

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

126133
return renderNoteContentInternal(note, {
127134
subRoot,
@@ -131,7 +138,7 @@ export function renderNoteContent(note: SNote) {
131138
logoUrl,
132139
ancestors,
133140
isStatic: false,
134-
faviconUrl: note.hasRelation("shareFavicon") ? `../api/notes/${note.getRelationValue("shareFavicon")}/download` : `../../favicon.ico`
141+
faviconUrl: note.hasRelation("shareFavicon") ? `../api/notes/${note.getRelationValue("shareFavicon")}/download${addContentAccessQuery(note)}` : `../../favicon.ico`
135142
});
136143
}
137144

@@ -156,6 +163,7 @@ function renderNoteContentInternal(note: SNote | BNote, renderArgs: RenderArgs)
156163
isEmpty,
157164
assetPath: shareAdjustedAssetPath,
158165
assetUrlFragment,
166+
addContentAccessQuery: (second: boolean | undefined) => addContentAccessQuery(note, second),
159167
showLoginInShareTheme,
160168
t,
161169
isDev,
@@ -319,7 +327,7 @@ function renderText(result: Result, note: SNote | BNote) {
319327
}
320328

321329
if (href?.startsWith("#")) {
322-
handleAttachmentLink(linkEl, href, getNote, getAttachment);
330+
handleAttachmentLink(linkEl, href, getNote, getAttachment, note);
323331
}
324332
}
325333

@@ -343,15 +351,15 @@ function renderText(result: Result, note: SNote | BNote) {
343351
}
344352
}
345353

346-
function handleAttachmentLink(linkEl: HTMLElement, href: string, getNote: (id: string) => SNote | BNote | null, getAttachment: (id: string) => BAttachment | SAttachment | null) {
354+
function handleAttachmentLink(linkEl: HTMLElement, href: string, getNote: (id: string) => SNote | BNote | null, getAttachment: (id: string) => BAttachment | SAttachment | null, note: SNote | BNote) {
347355
const linkRegExp = /attachmentId=([a-zA-Z0-9_]+)/g;
348356
let attachmentMatch;
349357
if ((attachmentMatch = linkRegExp.exec(href))) {
350358
const attachmentId = attachmentMatch[1];
351359
const attachment = getAttachment(attachmentId);
352360

353361
if (attachment) {
354-
linkEl.setAttribute("href", `../api/attachments/${attachmentId}/download`);
362+
linkEl.setAttribute("href", `../api/attachments/${attachmentId}/download${addContentAccessQuery(note)}`);
355363
linkEl.classList.add(`attachment-link`);
356364
linkEl.classList.add(`role-${attachment.role}`);
357365
linkEl.childNodes.length = 0;
@@ -402,7 +410,7 @@ function renderMermaid(result: Result, note: SNote | BNote) {
402410
}
403411

404412
result.content = `
405-
<img src="../api/images/${note.noteId}/${note.encodedTitle}?${note.utcDateModified}">
413+
<img src="../api/images/${note.noteId}/${note.encodedTitle}?${note.utcDateModified}${addContentAccessQuery(note, true)}">
406414
<hr>
407415
<details>
408416
<summary>Chart source</summary>
@@ -411,14 +419,14 @@ function renderMermaid(result: Result, note: SNote | BNote) {
411419
}
412420

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

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

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)