diff --git a/packages/server/public/js/items/note.js b/packages/server/public/js/items/note.js index e58cb0578..bcf143653 100644 --- a/packages/server/public/js/items/note.js +++ b/packages/server/public/js/items/note.js @@ -32,13 +32,13 @@ function docReady(fn) { docReady(() => { addPluginAssets(joplinNoteViewer.appBaseUrl, joplinNoteViewer.pluginAssets); - document.addEventListener('click', event => { - const element = event.target; + // document.addEventListener('click', event => { + // const element = event.target; - // Detects if it's a note link and, if so, display a message - if (element && element.getAttribute('href') === '#' && element.getAttribute('data-resource-id')) { - event.preventDefault(); - alert('This note has not been shared'); - } - }); + // // Detects if it's a note link and, if so, display a message + // if (element && element.getAttribute('href') === '#' && element.getAttribute('data-resource-id')) { + // event.preventDefault(); + // alert('This note has not been shared'); + // } + // }); }); diff --git a/packages/server/schema.sqlite b/packages/server/schema.sqlite index 09357d11e..852ceac16 100644 Binary files a/packages/server/schema.sqlite and b/packages/server/schema.sqlite differ diff --git a/packages/server/src/migrations/20220403172253_published_note_links.ts b/packages/server/src/migrations/20220403172253_published_note_links.ts new file mode 100644 index 000000000..868c98943 --- /dev/null +++ b/packages/server/src/migrations/20220403172253_published_note_links.ts @@ -0,0 +1,14 @@ +import { Knex } from 'knex'; +import { DbConnection } from '../db'; + +export async function up(db: DbConnection): Promise { + await db.schema.alterTable('shares', (table: Knex.CreateTableBuilder) => { + table.specificType('recursive', 'smallint').defaultTo(0).nullable(); + }); +} + +export async function down(db: DbConnection): Promise { + await db.schema.alterTable('shares', (table: Knex.CreateTableBuilder) => { + table.dropColumn('recursive'); + }); +} diff --git a/packages/server/src/models/ItemResourceModel.test.ts b/packages/server/src/models/ItemResourceModel.test.ts new file mode 100644 index 000000000..888fb0d0a --- /dev/null +++ b/packages/server/src/models/ItemResourceModel.test.ts @@ -0,0 +1,99 @@ +import { beforeAllDb, afterAllTests, beforeEachDb, models, createUserAndSession, createNote, createResource } from '../utils/testing/testUtils'; + +describe('ItemResourceModel', function() { + + beforeAll(async () => { + await beforeAllDb('ItemResourceModel'); + }); + + afterAll(async () => { + await afterAllTests(); + }); + + beforeEach(async () => { + await beforeEachDb(); + }); + + test('should get an item tree', async () => { + const { session } = await createUserAndSession(); + + const linkedNote1 = await createNote(session.id, { + id: '000000000000000000000000000000C1', + }); + + const resource = await createResource(session.id, { + id: '000000000000000000000000000000E1', + }, 'test'); + + const linkedNote2 = await createNote(session.id, { + id: '000000000000000000000000000000C2', + body: `![](:/${resource.jop_id})`, + }); + + const rootNote = await createNote(session.id, { + id: '00000000000000000000000000000001', + body: `[](:/${linkedNote1.jop_id}) [](:/${linkedNote2.jop_id})`, + }); + + const tree = await models().itemResource().itemTree(rootNote.id, rootNote.jop_id); + + expect(tree).toEqual({ + item_id: rootNote.id, + resource_id: '00000000000000000000000000000001', + children: [ + { + item_id: linkedNote1.id, + resource_id: '000000000000000000000000000000C1', + children: [], + }, + { + item_id: linkedNote2.id, + resource_id: '000000000000000000000000000000C2', + children: [ + { + item_id: resource.id, + resource_id: '000000000000000000000000000000E1', + children: [], + }, + ], + }, + ], + }); + }); + + test('should not go into infinite loop when a note links to itself', async () => { + const { session } = await createUserAndSession(); + + const rootNote = await createNote(session.id, { + id: '00000000000000000000000000000001', + body: '![](:/00000000000000000000000000000002)', + }); + + const linkedNote = await createNote(session.id, { + id: '00000000000000000000000000000002', + title: 'Linked note 2', + body: '![](:/00000000000000000000000000000001)', + }); + + const tree = await models().itemResource().itemTree(rootNote.id, rootNote.jop_id); + + expect(tree).toEqual({ + item_id: rootNote.id, + resource_id: '00000000000000000000000000000001', + children: [ + { + item_id: linkedNote.id, + resource_id: '00000000000000000000000000000002', + children: [ + { + item_id: rootNote.id, + resource_id: '00000000000000000000000000000001', + children: [], // Empty to prevent an infinite loop + }, + ], + }, + ], + }); + }); + +}); diff --git a/packages/server/src/models/ItemResourceModel.ts b/packages/server/src/models/ItemResourceModel.ts index 50d6f81ce..732d94eb2 100644 --- a/packages/server/src/models/ItemResourceModel.ts +++ b/packages/server/src/models/ItemResourceModel.ts @@ -2,6 +2,12 @@ import { resourceBlobPath } from '../utils/joplinUtils'; import { Item, ItemResource, Uuid } from '../services/database/types'; import BaseModel from './BaseModel'; +export interface TreeItem { + item_id: Uuid; + resource_id: string; + children: TreeItem[]; +} + export default class ItemResourceModel extends BaseModel { public get tableName(): string { @@ -52,9 +58,53 @@ export default class ItemResourceModel extends BaseModel { return output; } + public async itemIdsByResourceId(resourceId: string): Promise { + const rows: ItemResource[] = await this.db(this.tableName).select('item_id').where('resource_id', '=', resourceId); + return rows.map(r => r.item_id); + } + public async blobItemsByResourceIds(userIds: Uuid[], resourceIds: string[]): Promise { const resourceBlobNames = resourceIds.map(id => resourceBlobPath(id)); return this.models().item().loadByNames(userIds, resourceBlobNames); } + public async itemTree(rootItemId: Uuid, rootJopId: string, currentItemIds: string[] = []): Promise { + interface Row { + id: Uuid; + jop_id: string; + } + + const rows: Row[] = await this + .db('item_resources') + .leftJoin('items', 'item_resources.resource_id', 'items.jop_id') + .select('items.id', 'items.jop_id') + .where('item_resources.item_id', '=', rootItemId); + + const output: TreeItem[] = []; + + // Only process the children if the parent ID is not already in the + // tree. This is to prevent an infinite loop if one of the leaves links + // to a descendant note. + + if (!currentItemIds.includes(rootJopId)) { + currentItemIds.push(rootJopId); + + for (const row of rows) { + const subTree = await this.itemTree(row.id, row.jop_id, currentItemIds); + + output.push({ + item_id: row.id, + resource_id: row.jop_id, + children: subTree.children, + }); + } + } + + return { + item_id: rootItemId, + resource_id: rootJopId, + children: output, + }; + } + } diff --git a/packages/server/src/models/ShareModel.ts b/packages/server/src/models/ShareModel.ts index 3735d488e..e52dfb1f5 100644 --- a/packages/server/src/models/ShareModel.ts +++ b/packages/server/src/models/ShareModel.ts @@ -378,7 +378,7 @@ export default class ShareModel extends BaseModel { return super.save(shareToSave); } - public async shareNote(owner: User, noteId: string, masterKeyId: string): Promise { + public async shareNote(owner: User, noteId: string, masterKeyId: string, recursive: boolean): Promise { const noteItem = await this.models().item().loadByJopId(owner.id, noteId); if (!noteItem) throw new ErrorNotFound(`No such note: ${noteId}`); @@ -391,6 +391,7 @@ export default class ShareModel extends BaseModel { owner_id: owner.id, note_id: noteId, master_key_id: masterKeyId, + recursive: recursive ? 1 : 0, }; await this.checkIfAllowed(owner, AclAction.Create, shareToSave); diff --git a/packages/server/src/routes/api/shares.ts b/packages/server/src/routes/api/shares.ts index bead7bcfb..d4032ea2b 100644 --- a/packages/server/src/routes/api/shares.ts +++ b/packages/server/src/routes/api/shares.ts @@ -10,6 +10,7 @@ import { AclAction } from '../../models/BaseModel'; interface ShareApiInput extends Share { folder_id?: string; note_id?: string; + recursive?: number; } const router = new Router(RouteType.Api); @@ -23,6 +24,7 @@ router.post('api/shares', async (_path: SubPath, ctx: AppContext) => { folder_id?: string; note_id?: string; master_key_id?: string; + recursive?: number; } const shareModel = ctx.joplin.models.share(); @@ -40,7 +42,7 @@ router.post('api/shares', async (_path: SubPath, ctx: AppContext) => { if (shareInput.folder_id) { return ctx.joplin.models.share().shareFolder(ctx.joplin.owner, shareInput.folder_id, masterKeyId); } else if (shareInput.note_id) { - return ctx.joplin.models.share().shareNote(ctx.joplin.owner, shareInput.note_id, masterKeyId); + return ctx.joplin.models.share().shareNote(ctx.joplin.owner, shareInput.note_id, masterKeyId, fields.recursive === 1); } else { throw new ErrorBadRequest('Either folder_id or note_id must be provided'); } diff --git a/packages/server/src/routes/index/shares.link.test.ts b/packages/server/src/routes/index/shares.link.test.ts index aa2dcc98c..2ec3d9b59 100644 --- a/packages/server/src/routes/index/shares.link.test.ts +++ b/packages/server/src/routes/index/shares.link.test.ts @@ -1,9 +1,9 @@ import { Share, ShareType } from '../../services/database/types'; import routeHandler from '../../middleware/routeHandler'; -import { ErrorForbidden } from '../../utils/errors'; +import { ErrorForbidden, ErrorNotFound } from '../../utils/errors'; import { postApi } from '../../utils/testing/apiUtils'; import { testImageBuffer } from '../../utils/testing/fileApiUtils'; -import { beforeAllDb, afterAllTests, parseHtml, beforeEachDb, createUserAndSession, koaAppContext, checkContextError, expectNotThrow, createNote, createItem, models, expectHttpError } from '../../utils/testing/testUtils'; +import { beforeAllDb, afterAllTests, parseHtml, beforeEachDb, createUserAndSession, koaAppContext, checkContextError, expectNotThrow, createNote, createItem, models, expectHttpError, createResource } from '../../utils/testing/testUtils'; const resourceSize = 2720; @@ -129,6 +129,89 @@ describe('shares.link', function() { expect(resourceContent.byteLength).toBe(resourceSize); }); + test('should share a linked note', async function() { + const { session } = await createUserAndSession(); + + const linkedNote1 = await createNote(session.id, { + id: '000000000000000000000000000000C1', + }); + + const resource = await createResource(session.id, { + id: '000000000000000000000000000000E1', + }, 'test'); + + const linkedNote2 = await createNote(session.id, { + id: '000000000000000000000000000000C2', + body: `[](:/${resource.jop_id})`, + }); + + const rootNote = await createNote(session.id, { + id: '00000000000000000000000000000001', + body: `[](:/${linkedNote1.jop_id}) [](:/${linkedNote2.jop_id})`, + }); + + const share = await postApi(session.id, 'shares', { + type: ShareType.Note, + note_id: rootNote.jop_id, + recursive: 1, + }); + + const bodyHtml = await getShareContent(share.id, { note_id: '000000000000000000000000000000C2' }) as string; + const doc = parseHtml(bodyHtml); + const image = doc.querySelector('a[data-resource-id="000000000000000000000000000000E1"]'); + expect(image.getAttribute('href')).toBe(`http://localhost:22300/shares/${share.id}?resource_id=000000000000000000000000000000E1&t=1602758278090`); + + const resourceContent = await getShareContent(share.id, { resource_id: '000000000000000000000000000000E1' }); + expect(resourceContent.toString()).toBe('test'); + }); + + test('should not share items that are not linked to a shared note', async function() { + const { session } = await createUserAndSession(); + + const notSharedResource = await createResource(session.id, { + id: '000000000000000000000000000000E2', + }, 'test2'); + + await createNote(session.id, { + id: '000000000000000000000000000000C5', + body: `[](:/${notSharedResource.jop_id})`, + }); + + const rootNote = await createNote(session.id, { + id: '00000000000000000000000000000001', + }); + + const share = await postApi(session.id, 'shares', { + type: ShareType.Note, + note_id: rootNote.jop_id, + recursive: 1, + }); + + await expectNotThrow(async () => getShareContent(share.id, { note_id: '00000000000000000000000000000001' })); + await expectHttpError(async () => getShareContent(share.id, { note_id: '000000000000000000000000000000C5' }), ErrorNotFound.httpCode); + await expectHttpError(async () => getShareContent(share.id, { note_id: '000000000000000000000000000000E2' }), ErrorNotFound.httpCode); + }); + + test('should not share linked notes if the "recursive" field is not set', async function() { + const { session } = await createUserAndSession(); + + const linkedNote1 = await createNote(session.id, { + id: '000000000000000000000000000000C1', + }); + + const rootNote = await createNote(session.id, { + id: '00000000000000000000000000000001', + body: `[](:/${linkedNote1.jop_id})`, + }); + + const share = await postApi(session.id, 'shares', { + type: ShareType.Note, + note_id: rootNote.jop_id, + }); + + await expectHttpError(async () => getShareContent(share.id, { note_id: '000000000000000000000000000000C1' }), ErrorForbidden.httpCode); + }); + test('should not throw an error if the note contains links to non-existing items', async function() { const { session } = await createUserAndSession(); @@ -161,7 +244,6 @@ describe('shares.link', function() { } }); - test('should throw an error if owner of share is disabled', async function() { const { user, session } = await createUserAndSession(); diff --git a/packages/server/src/services/database/types.ts b/packages/server/src/services/database/types.ts index 081d34e53..10c6ee7ba 100644 --- a/packages/server/src/services/database/types.ts +++ b/packages/server/src/services/database/types.ts @@ -182,6 +182,7 @@ export interface Share extends WithDates, WithUuid { folder_id?: Uuid; note_id?: Uuid; master_key_id?: Uuid; + recursive?: number; } export interface Change extends WithDates, WithUuid { @@ -378,6 +379,7 @@ export const databaseSchema: DatabaseTables = { folder_id: { type: 'string' }, note_id: { type: 'string' }, master_key_id: { type: 'string' }, + recursive: { type: 'number' }, }, changes: { counter: { type: 'number' }, diff --git a/packages/server/src/utils/joplinUtils.ts b/packages/server/src/utils/joplinUtils.ts index 5a7298a30..3f362f95f 100644 --- a/packages/server/src/utils/joplinUtils.ts +++ b/packages/server/src/utils/joplinUtils.ts @@ -14,7 +14,7 @@ import { Item, Share, Uuid } from '../services/database/types'; import ItemModel from '../models/ItemModel'; import { NoteEntity } from '@joplin/lib/services/database/types'; import { formatDateTime } from './time'; -import { ErrorNotFound } from './errors'; +import { ErrorBadRequest, ErrorForbidden, ErrorNotFound } from './errors'; import { MarkupToHtml } from '@joplin/renderer'; import { OptionsResourceModel } from '@joplin/renderer/MarkupToHtml'; import { isValidHeaderIdentifier } from '@joplin/lib/services/e2ee/EncryptionService'; @@ -25,6 +25,7 @@ import { Models } from '../models/factory'; import MustacheService from '../services/MustacheService'; import Logger from '@joplin/lib/Logger'; import config from '../config'; +import { TreeItem } from '../models/ItemResourceModel'; const { substrWithEllipsis } = require('@joplin/lib/string-utils'); const logger = Logger.create('JoplinUtils'); @@ -136,8 +137,8 @@ async function getResourceInfos(linkedItemInfos: LinkedItemInfos): Promise { - const jopIds = await Note.linkedItemIds(note.body); +async function noteLinkedItemInfos(userId: Uuid, itemModel: ItemModel, noteBody: string): Promise { + const jopIds = await Note.linkedItemIds(noteBody); const output: LinkedItemInfos = {}; for (const jopId of jopIds) { @@ -190,7 +191,7 @@ async function renderNote(share: Share, note: NoteEntity, resourceInfos: Resourc if (!item) throw new Error(`No such item in this note: ${itemId}`); if (item.type_ === ModelType.Note) { - return '#'; + return `${models_.share().shareUrl(share.owner_id, share.id)}?note_id=${item.id}&t=${item.updated_time}`; } else if (item.type_ === ModelType.Resource) { return `${models_.share().shareUrl(share.owner_id, share.id)}?resource_id=${item.id}&t=${item.updated_time}`; } else { @@ -255,35 +256,120 @@ export function itemIsEncrypted(item: Item): boolean { return isValidHeaderIdentifier(header); } -export async function renderItem(userId: Uuid, item: Item, share: Share, query: Record): Promise { - const rootNote: NoteEntity = models_.item().itemToJoplinItem(item); // await this.unserializeItem(content); - const linkedItemInfos: LinkedItemInfos = await noteLinkedItemInfos(userId, models_.item(), rootNote); - const resourceInfos = await getResourceInfos(linkedItemInfos); +const findParentNote = async (itemTree: TreeItem, resourceId: string) => { + const find_ = (parentItem: TreeItem, currentTreeItems: TreeItem[], resourceId: string): TreeItem => { + for (const it of currentTreeItems) { + if (it.resource_id === resourceId) return parentItem; + const child = find_(it, it.children, resourceId); + if (child) return it; + } + return null; + }; + const result = find_(itemTree, itemTree.children, resourceId); + if (!result) throw new ErrorBadRequest(`Cannot find parent of ${resourceId}`); + + const item = await models_.item().loadWithContent(result.item_id); + if (!item) throw new ErrorNotFound(`Cannot load item with ID ${result.item_id}`); + + return models_.item().itemToJoplinItem(item); +}; + +const isInTree = (itemTree: TreeItem, jopId: string) => { + if (itemTree.resource_id === jopId) return true; + for (const child of itemTree.children) { + if (child.resource_id === jopId) return true; + const found = isInTree(child, jopId); + if (found) return true; + } + return false; +}; + +interface RenderItemQuery { + resource_id?: string; + note_id?: string; +} + +// "item" is always the item associated with the share (the "root item"). It may +// be different from the item that will eventually get rendered - for example +// for resources or linked notes. +export async function renderItem(userId: Uuid, item: Item, share: Share, query: RenderItemQuery): Promise { interface FileToRender { item: Item; content: any; jopItemId: string; } - const fileToRender: FileToRender = { - item: item, - content: null as any, - jopItemId: rootNote.id, - }; + const rootNote: NoteEntity = models_.item().itemToJoplinItem(item); + const itemTree = await models_.itemResource().itemTree(item.id, rootNote.id); + + let linkedItemInfos: LinkedItemInfos = {}; + let resourceInfos: ResourceInfos = {}; + let fileToRender: FileToRender; + let itemToRender: any = null; if (query.resource_id) { + // ------------------------------------------------------------------------------------------ + // Render a resource that is attached to a note + // ------------------------------------------------------------------------------------------ + const resourceItem = await models_.item().loadByName(userId, resourceBlobPath(query.resource_id), { fields: ['*'], withContent: true }); - fileToRender.item = resourceItem; - fileToRender.content = resourceItem.content; - fileToRender.jopItemId = query.resource_id; + if (!resourceItem) throw new ErrorNotFound(`No such resource: ${query.resource_id}`); + + fileToRender = { + item: resourceItem, + content: resourceItem.content, + jopItemId: query.resource_id, + }; + + const parentNote = await findParentNote(itemTree, fileToRender.jopItemId); + linkedItemInfos = await noteLinkedItemInfos(userId, models_.item(), parentNote.body); + itemToRender = linkedItemInfos[fileToRender.jopItemId].item; + } else if (query.note_id) { + // ------------------------------------------------------------------------------------------ + // Render a linked note + // ------------------------------------------------------------------------------------------ + + if (!share.recursive) throw new ErrorForbidden('Linked notes are not published'); + + const noteItem = await models_.item().loadByName(userId, `${query.note_id}.md`, { fields: ['*'], withContent: true }); + if (!noteItem) throw new ErrorNotFound(`No such note: ${query.note_id}`); + + fileToRender = { + item: noteItem, + content: noteItem.content, + jopItemId: query.note_id, + }; + + linkedItemInfos = await noteLinkedItemInfos(userId, models_.item(), noteItem.content.toString()); + resourceInfos = await getResourceInfos(linkedItemInfos); + itemToRender = models_.item().itemToJoplinItem(noteItem); + } else { + // ------------------------------------------------------------------------------------------ + // Render the root note + // ------------------------------------------------------------------------------------------ + + fileToRender = { + item: item, + content: null as any, + jopItemId: rootNote.id, + }; + + linkedItemInfos = await noteLinkedItemInfos(userId, models_.item(), rootNote.body); + resourceInfos = await getResourceInfos(linkedItemInfos); + itemToRender = rootNote; } - if (fileToRender.item !== item && !linkedItemInfos[fileToRender.jopItemId]) { - throw new ErrorNotFound(`Item "${fileToRender.jopItemId}" does not belong to this note`); + if (!itemToRender) throw new ErrorNotFound(`Cannot render item: ${item.id}: ${JSON.stringify(query)}`); + + // Verify that the item we're going to render is indeed part of the item + // tree (i.e. it is either the root note, or one of the ancestor is the root + // note). This is for security reason - otherwise it would be possible to + // display any note by setting note_id to an arbitrary ID. + if (!isInTree(itemTree, fileToRender.jopItemId)) { + throw new ErrorNotFound(`Item "${fileToRender.jopItemId}" does not belong to this share`); } - const itemToRender = fileToRender.item === item ? rootNote : linkedItemInfos[fileToRender.jopItemId].item; const itemType: ModelType = itemToRender.type_; if (itemType === ModelType.Resource) {