1
0
mirror of https://github.com/laurent22/joplin.git synced 2024-12-24 10:27:10 +02:00

Server: Add support for recursively publishing a note

This commit is contained in:
Laurent Cozic 2022-06-14 18:38:13 +01:00
parent af665f247c
commit 29a1cc022c
10 changed files with 368 additions and 32 deletions

View File

@ -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');
// }
// });
});

Binary file not shown.

View File

@ -0,0 +1,14 @@
import { Knex } from 'knex';
import { DbConnection } from '../db';
export async function up(db: DbConnection): Promise<any> {
await db.schema.alterTable('shares', (table: Knex.CreateTableBuilder) => {
table.specificType('recursive', 'smallint').defaultTo(0).nullable();
});
}
export async function down(db: DbConnection): Promise<any> {
await db.schema.alterTable('shares', (table: Knex.CreateTableBuilder) => {
table.dropColumn('recursive');
});
}

View File

@ -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
},
],
},
],
});
});
});

View File

@ -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<ItemResource> {
public get tableName(): string {
@ -52,9 +58,53 @@ export default class ItemResourceModel extends BaseModel<ItemResource> {
return output;
}
public async itemIdsByResourceId(resourceId: string): Promise<string[]> {
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<Item[]> {
const resourceBlobNames = resourceIds.map(id => resourceBlobPath(id));
return this.models().item().loadByNames(userIds, resourceBlobNames);
}
public async itemTree(rootItemId: Uuid, rootJopId: string, currentItemIds: string[] = []): Promise<TreeItem> {
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,
};
}
}

View File

@ -378,7 +378,7 @@ export default class ShareModel extends BaseModel<Share> {
return super.save(shareToSave);
}
public async shareNote(owner: User, noteId: string, masterKeyId: string): Promise<Share> {
public async shareNote(owner: User, noteId: string, masterKeyId: string, recursive: boolean): Promise<Share> {
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<Share> {
owner_id: owner.id,
note_id: noteId,
master_key_id: masterKeyId,
recursive: recursive ? 1 : 0,
};
await this.checkIfAllowed(owner, AclAction.Create, shareToSave);

View File

@ -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');
}

View File

@ -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<Share>(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<Share>(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<Share>(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();

View File

@ -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' },

View File

@ -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<Resou
return output;
}
async function noteLinkedItemInfos(userId: Uuid, itemModel: ItemModel, note: NoteEntity): Promise<LinkedItemInfos> {
const jopIds = await Note.linkedItemIds(note.body);
async function noteLinkedItemInfos(userId: Uuid, itemModel: ItemModel, noteBody: string): Promise<LinkedItemInfos> {
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<string, any>): Promise<FileViewerResponse> {
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<FileViewerResponse> {
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) {