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

Server: Allow enabling or disabling the sharing feature per user

This commit is contained in:
Laurent Cozic 2021-05-18 15:53:56 +02:00
parent e6c4eb7cdf
commit daaaa133ab
11 changed files with 104 additions and 36 deletions

Binary file not shown.

View File

@ -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: {

View File

@ -0,0 +1,12 @@
import { Knex } from 'knex';
import { DbConnection } from '../db';
export async function up(db: DbConnection): Promise<any> {
await db.schema.alterTable('users', function(table: Knex.CreateTableBuilder) {
table.integer('can_share').defaultTo(1).notNullable();
});
}
export async function down(_db: DbConnection): Promise<any> {
}

View File

@ -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<Item> {
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<Item> {
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<Item> {
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<Item>(async () => {
const savedItem = await this.saveForUser(user.id, item);

View File

@ -14,6 +14,8 @@ export default class ShareModel extends BaseModel<Share> {
public async checkIfAllowed(user: User, action: AclAction, resource: Share = null): Promise<void> {
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) {

View File

@ -10,6 +10,9 @@ export default class ShareUserModel extends BaseModel<ShareUser> {
public async checkIfAllowed(user: User, action: AclAction, resource: ShareUser = null): Promise<void> {
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<ShareUser> {
}
public async addByEmail(shareId: Uuid, userEmail: string): Promise<ShareUser> {
// 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}`);

View File

@ -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<User> {
@ -30,6 +33,7 @@ export default class UserModel extends BaseModel<User> {
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<User> {
}
}
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<User> {
const user: User = await super.validate(object, options);

View File

@ -793,21 +793,36 @@ describe('shares.folder', function() {
await expectHttpError(async () => postApi<Share>(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);
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 shareFolderWithUser(session1.id, session2.id, '000000000000000000000000000000F1', [
// {
// id: '000000000000000000000000000000F1',
// children: [
// {
// id: '00000000000000000000000000000001',
// },
// ],
// },
// ]);
// await expectHttpError(async () => deleteApi(session2.id, 'items/root:/000000000000000000000000000000F1.md:'), ErrorForbidden.httpCode);
// });
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
);
});
});

View File

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

View File

@ -21,6 +21,12 @@
<input class="input" type="number" name="max_item_size" value="{{user.max_item_size}}"/>
</div>
</div>
<div class="field">
<label class="checkbox">
<input type="checkbox" name="can_share" {{#user.can_share}}checked{{/user.can_share}} value="1"> Can share
</label>
</div>
{{/global.owner.is_admin}}
<div class="field">
<label class="label">Password</label>

View File

@ -4,6 +4,7 @@
<th>Full name</th>
<th>Email</th>
<th>Max Item Size</th>
<th>Can share</th>
<th>Is admin?</th>
<th>Actions</th>
</tr>
@ -14,6 +15,7 @@
<td>{{full_name}}</td>
<td>{{email}}</td>
<td>{{formattedItemMaxSize}}</td>
<td>{{can_share}}</td>
<td>{{is_admin}}</td>
<td><a href="{{{global.baseUrl}}}/users/{{id}}" class="button is-primary is-small">Edit</a></td>
</tr>