From daaaa133ab204aba8dcd8788ae61f60142ddabca Mon Sep 17 00:00:00 2001 From: Laurent Cozic Date: Tue, 18 May 2021 15:53:56 +0200 Subject: [PATCH] Server: Allow enabling or disabling the sharing feature per user --- packages/server/schema.sqlite | Bin 290816 -> 290816 bytes packages/server/src/db.ts | 2 + .../migrations/20210518150551_can_share.ts | 12 +++++ packages/server/src/models/ItemModel.ts | 23 ++------- packages/server/src/models/ShareModel.ts | 2 + packages/server/src/models/ShareUserModel.ts | 4 +- packages/server/src/models/UserModel.ts | 43 +++++++++++++++- .../src/routes/api/shares.folder.test.ts | 47 ++++++++++++------ packages/server/src/routes/index/users.ts | 1 + packages/server/src/views/index/user.mustache | 6 +++ .../server/src/views/index/users.mustache | 2 + 11 files changed, 105 insertions(+), 37 deletions(-) create mode 100644 packages/server/src/migrations/20210518150551_can_share.ts diff --git a/packages/server/schema.sqlite b/packages/server/schema.sqlite index 55af64b63dd8c622f95fea5850800e2b36681307..9f15aaf2d52250c2656652cab2e268993b734b8a 100644 GIT binary patch delta 884 zcmZp8AlUFgaDud83BxyX#*8r=6PEBxaPb8(@ZaWN&)>zL#Ba{e!}ox1 zEnh2N&}KyeGd?CmuE{lWdfMyxeAGBu8Jsy;jSP$o4NMI!3{4G8O%3Cd6Z7JWGZKqZ z^|Fc?E9=+uc}-p`XTZFk&tvifIZYt#Hkn6W14zqFHV4zvlau6Cfqe1FUGhpmns@Sg zd5}8p$+y9L_Du@U_}RF`8JSHvCr{uL*<{cnUtew%niXmk5D@H%gP>h9@pUKVO* z#HN4;L?|k#*`JW(n3?HqV3y=l=H`_a;hpUgn49cv;bdH8Y2cok5fDYlCPMlr+p|jW zAo~IAG(x(FQnW3A5$H=oMr})A57jTg{M30V&q~qRM$~Rm^?vFWV_xj#x7BX@)DDf2&a4_W6$zRcNYW8 zD5LUh$H2t2j0__S;_&}bZ$%`f!ELxzjX#uyucJ2hG QtMLTmzwH3ivIEQ;047%%)&Kwi delta 844 zcmZp8AlUFgaDud86axc;I}~#P>4=Fs#*9%L6PECcaq?9$@ZaWN&)>zL#Ba{e!}ox1 zEnn4UL4^c9CPU82A#!@m(~|Wjuaz?Z(z=r$$Y}y;?a4gy8bF$PvN@P$n4Bc9$~-OU z&*Uz7B_RE2@_Koox}*=2Z-e>IHz_>hXX6xSWH#lTJb_PSlR=Aoy}NH{l~Z7me`JYS zxmi(OVvb>UL4moEpJ`-ips}fcSz=0VW}ZV%esW??Mt*ULiczA9p@E87V5NnlsZ(fy zv1gH4kg;#Eo^yVAsb^VPuwi*caF}J7v2(e8WVydzvQwm+TS;1Yday;fBgiPm%KB+Z zQz0ZU5H>Rh+~rpwUPi;FY1MsGi`gVBI-yVNem zd7=t#=0({qDY>P_iN+?ze!hub5jm+|j;`S@ekqm_A$dmAZ=7e8=Y&TW*cXKKYTA;9L0k^<*?gXZ*@dQ&q+X1F!2bebi01T5Eng9R* diff --git a/packages/server/src/db.ts b/packages/server/src/db.ts index 2bf092e98cc..94e2f7a8139 100644 --- a/packages/server/src/db.ts +++ b/packages/server/src/db.ts @@ -276,6 +276,7 @@ export interface User extends WithDates, WithUuid { full_name?: string; is_admin?: number; max_item_size?: number; + can_share?: number; max_share_recipients?: number; } @@ -380,6 +381,7 @@ export const databaseSchema: DatabaseTables = { updated_time: { type: 'string' }, created_time: { type: 'string' }, max_item_size: { type: 'number' }, + can_share: { type: 'number' }, max_share_recipients: { type: 'number' }, }, sessions: { diff --git a/packages/server/src/migrations/20210518150551_can_share.ts b/packages/server/src/migrations/20210518150551_can_share.ts new file mode 100644 index 00000000000..cd092fc18e2 --- /dev/null +++ b/packages/server/src/migrations/20210518150551_can_share.ts @@ -0,0 +1,12 @@ +import { Knex } from 'knex'; +import { DbConnection } from '../db'; + +export async function up(db: DbConnection): Promise { + await db.schema.alterTable('users', function(table: Knex.CreateTableBuilder) { + table.integer('can_share').defaultTo(1).notNullable(); + }); +} + +export async function down(_db: DbConnection): Promise { + +} diff --git a/packages/server/src/models/ItemModel.ts b/packages/server/src/models/ItemModel.ts index 37e15e211c4..6a842651745 100644 --- a/packages/server/src/models/ItemModel.ts +++ b/packages/server/src/models/ItemModel.ts @@ -3,12 +3,10 @@ import { ItemType, databaseSchema, Uuid, Item, ShareType, Share, ChangeType, Use import { defaultPagination, paginateDbQuery, PaginatedResults, Pagination } from './utils/pagination'; import { isJoplinItemName, isJoplinResourceBlobPath, linkedResourceIds, serializeJoplinItem, unserializeJoplinItem } from '../utils/joplinUtils'; import { ModelType } from '@joplin/lib/BaseModel'; -import { ApiError, ErrorForbidden, ErrorNotFound, ErrorPayloadTooLarge, ErrorUnprocessableEntity } from '../utils/errors'; +import { ApiError, ErrorForbidden, ErrorNotFound, ErrorUnprocessableEntity } from '../utils/errors'; import { Knex } from 'knex'; import { ChangePreviousItem } from './ChangeModel'; -import { _ } from '@joplin/lib/locale'; -const prettyBytes = require('pretty-bytes'); const mimeUtils = require('@joplin/lib/mime-utils.js').mime; // Converts "root:/myfile.txt:" to "myfile.txt" @@ -291,16 +289,17 @@ export default class ItemModel extends BaseModel { const isJoplinItem = isJoplinItemName(name); let isNote = false; - let itemTitle = ''; const item: Item = { name, }; + let joplinItem: any = null; + let resourceIds: string[] = []; if (isJoplinItem) { - const joplinItem = await unserializeJoplinItem(buffer.toString()); + joplinItem = await unserializeJoplinItem(buffer.toString()); isNote = joplinItem.type_ === ModelType.Note; resourceIds = isNote ? linkedResourceIds(joplinItem.body) : []; @@ -316,8 +315,6 @@ export default class ItemModel extends BaseModel { delete joplinItem.type_; delete joplinItem.encryption_applied; - itemTitle = joplinItem.title || ''; - item.content = Buffer.from(JSON.stringify(joplinItem)); } else { item.content = buffer; @@ -327,17 +324,7 @@ export default class ItemModel extends BaseModel { if (options.shareId) item.jop_share_id = options.shareId; - // If the item is encrypted, we apply a multipler because encrypted - // items can be much larger (seems to be up to twice the size but for - // safety let's go with 2.2). - const maxSize = user.max_item_size * (item.jop_encryption_applied ? 2.2 : 1); - if (maxSize && buffer.byteLength > maxSize) { - throw new ErrorPayloadTooLarge(_('Cannot save %s "%s" because it is larger than than the allowed limit (%s)', - isNote ? _('note') : _('attachment'), - itemTitle ? itemTitle : name, - prettyBytes(user.max_item_size) - )); - } + await this.models().user().checkMaxItemSizeLimit(user, buffer, item, joplinItem); return this.withTransaction(async () => { const savedItem = await this.saveForUser(user.id, item); diff --git a/packages/server/src/models/ShareModel.ts b/packages/server/src/models/ShareModel.ts index dc0c722d748..3dd7bce3802 100644 --- a/packages/server/src/models/ShareModel.ts +++ b/packages/server/src/models/ShareModel.ts @@ -14,6 +14,8 @@ export default class ShareModel extends BaseModel { public async checkIfAllowed(user: User, action: AclAction, resource: Share = null): Promise { if (action === AclAction.Create) { + if (!user.can_share) throw new ErrorForbidden('The sharing feature is not enabled for this account'); + if (!await this.models().item().userHasItem(user.id, resource.item_id)) throw new ErrorForbidden('cannot share an item not owned by the user'); if (resource.type === ShareType.Folder) { diff --git a/packages/server/src/models/ShareUserModel.ts b/packages/server/src/models/ShareUserModel.ts index 7a0615f31dd..cc3a96b1555 100644 --- a/packages/server/src/models/ShareUserModel.ts +++ b/packages/server/src/models/ShareUserModel.ts @@ -10,6 +10,9 @@ export default class ShareUserModel extends BaseModel { public async checkIfAllowed(user: User, action: AclAction, resource: ShareUser = null): Promise { if (action === AclAction.Create) { + const recipient = await this.models().user().load(resource.user_id, { fields: ['can_share'] }); + if (!recipient.can_share) throw new ErrorForbidden('The sharing feature is not enabled for the recipient account'); + const share = await this.models().share().load(resource.share_id); if (share.owner_id !== user.id) throw new ErrorForbidden('no access to the share object'); if (share.owner_id === resource.user_id) throw new ErrorForbidden('cannot share an item with yourself'); @@ -96,7 +99,6 @@ export default class ShareUserModel extends BaseModel { } public async addByEmail(shareId: Uuid, userEmail: string): Promise { - // TODO: check that user can access this share const share = await this.models().share().load(shareId); if (!share) throw new ErrorNotFound(`No such share: ${shareId}`); diff --git a/packages/server/src/models/UserModel.ts b/packages/server/src/models/UserModel.ts index b66e883cc64..9705ade4a7a 100644 --- a/packages/server/src/models/UserModel.ts +++ b/packages/server/src/models/UserModel.ts @@ -1,7 +1,10 @@ import BaseModel, { AclAction, SaveOptions, ValidateOptions } from './BaseModel'; -import { User } from '../db'; +import { Item, User } from '../db'; import * as auth from '../utils/auth'; -import { ErrorUnprocessableEntity, ErrorForbidden } from '../utils/errors'; +import { ErrorUnprocessableEntity, ErrorForbidden, ErrorPayloadTooLarge } from '../utils/errors'; +import { ModelType } from '@joplin/lib/BaseModel'; +import { _ } from '@joplin/lib/locale'; +import prettyBytes = require('pretty-bytes'); export default class UserModel extends BaseModel { @@ -30,6 +33,7 @@ export default class UserModel extends BaseModel { if ('is_admin' in object) user.is_admin = object.is_admin; if ('full_name' in object) user.full_name = object.full_name; if ('max_item_size' in object) user.max_item_size = object.max_item_size; + if ('can_share' in object) user.can_share = object.can_share; return user; } @@ -69,6 +73,41 @@ export default class UserModel extends BaseModel { } } + public async checkMaxItemSizeLimit(user: User, buffer: Buffer, item: Item, joplinItem: any) { + const itemTitle = joplinItem ? joplinItem.title || '' : ''; + const isNote = joplinItem && joplinItem.type_ === ModelType.Note; + + // If the item is encrypted, we apply a multipler because encrypted + // items can be much larger (seems to be up to twice the size but for + // safety let's go with 2.2). + const maxSize = user.max_item_size * (item.jop_encryption_applied ? 2.2 : 1); + if (maxSize && buffer.byteLength > maxSize) { + throw new ErrorPayloadTooLarge(_('Cannot save %s "%s" because it is larger than than the allowed limit (%s)', + isNote ? _('note') : _('attachment'), + itemTitle ? itemTitle : name, + prettyBytes(user.max_item_size) + )); + } + } + + // public async checkCanShare(share:Share) { + + // // const itemTitle = joplinItem ? joplinItem.title || '' : ''; + // // const isNote = joplinItem && joplinItem.type_ === ModelType.Note; + + // // // If the item is encrypted, we apply a multipler because encrypted + // // // items can be much larger (seems to be up to twice the size but for + // // // safety let's go with 2.2). + // // const maxSize = user.max_item_size * (item.jop_encryption_applied ? 2.2 : 1); + // // if (maxSize && buffer.byteLength > maxSize) { + // // throw new ErrorPayloadTooLarge(_('Cannot save %s "%s" because it is larger than than the allowed limit (%s)', + // // isNote ? _('note') : _('attachment'), + // // itemTitle ? itemTitle : name, + // // prettyBytes(user.max_item_size) + // // )); + // // } + // } + protected async validate(object: User, options: ValidateOptions = {}): Promise { const user: User = await super.validate(object, options); diff --git a/packages/server/src/routes/api/shares.folder.test.ts b/packages/server/src/routes/api/shares.folder.test.ts index c61efe90f55..1da213556e6 100644 --- a/packages/server/src/routes/api/shares.folder.test.ts +++ b/packages/server/src/routes/api/shares.folder.test.ts @@ -793,21 +793,36 @@ describe('shares.folder', function() { await expectHttpError(async () => postApi(session1.id, 'shares', { folder_id: '000000000000000000000000000000F2' }), ErrorForbidden.httpCode); }); - // test('should check permissions - only owner of share can deleted associated folder', async function() { - // const { session: session1 } = await createUserAndSession(1); - // const { session: session2 } = await createUserAndSession(2); - - // await shareFolderWithUser(session1.id, session2.id, '000000000000000000000000000000F1', [ - // { - // id: '000000000000000000000000000000F1', - // children: [ - // { - // id: '00000000000000000000000000000001', - // }, - // ], - // }, - // ]); - // await expectHttpError(async () => deleteApi(session2.id, 'items/root:/000000000000000000000000000000F1.md:'), ErrorForbidden.httpCode); - // }); + test('should check permissions - cannot share if share feature not enabled', async function() { + const { user: user1, session: session1 } = await createUserAndSession(1); + const { session: session2 } = await createUserAndSession(2); + await models().user().save({ id: user1.id, can_share: 0 }); + + await expectHttpError(async () => + shareFolderWithUser(session1.id, session2.id, '000000000000000000000000000000F1', [ + { + id: '000000000000000000000000000000F1', + children: [], + }, + ]), + ErrorForbidden.httpCode + ); + }); + + test('should check permissions - cannot share if share feature not enabled for recipient', async function() { + const { session: session1 } = await createUserAndSession(1); + const { user: user2, session: session2 } = await createUserAndSession(2); + await models().user().save({ id: user2.id, can_share: 0 }); + + await expectHttpError(async () => + shareFolderWithUser(session1.id, session2.id, '000000000000000000000000000000F1', [ + { + id: '000000000000000000000000000000F1', + children: [], + }, + ]), + ErrorForbidden.httpCode + ); + }); }); diff --git a/packages/server/src/routes/index/users.ts b/packages/server/src/routes/index/users.ts index 9d5893062ba..f83186525be 100644 --- a/packages/server/src/routes/index/users.ts +++ b/packages/server/src/routes/index/users.ts @@ -17,6 +17,7 @@ function makeUser(isNew: boolean, fields: any): User { if ('full_name' in fields) user.full_name = fields.full_name; if ('is_admin' in fields) user.is_admin = fields.is_admin; if ('max_item_size' in fields) user.max_item_size = fields.max_item_size; + user.can_share = fields.can_share ? 1 : 0; if (fields.password) { if (fields.password !== fields.password2) throw new ErrorUnprocessableEntity('Passwords do not match'); diff --git a/packages/server/src/views/index/user.mustache b/packages/server/src/views/index/user.mustache index f8d7d2264cf..acd82495514 100644 --- a/packages/server/src/views/index/user.mustache +++ b/packages/server/src/views/index/user.mustache @@ -21,6 +21,12 @@ + +
+ +
{{/global.owner.is_admin}}
diff --git a/packages/server/src/views/index/users.mustache b/packages/server/src/views/index/users.mustache index b445e9d5b70..c99f3d4aad0 100644 --- a/packages/server/src/views/index/users.mustache +++ b/packages/server/src/views/index/users.mustache @@ -4,6 +4,7 @@ Full name Email Max Item Size + Can share Is admin? Actions @@ -14,6 +15,7 @@ {{full_name}} {{email}} {{formattedItemMaxSize}} + {{can_share}} {{is_admin}} Edit