mirror of
https://github.com/laurent22/joplin.git
synced 2024-11-24 08:12:24 +02:00
Server: Allow enabling or disabling the sharing feature per user
This commit is contained in:
parent
e6c4eb7cdf
commit
daaaa133ab
Binary file not shown.
@ -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: {
|
||||
|
12
packages/server/src/migrations/20210518150551_can_share.ts
Normal file
12
packages/server/src/migrations/20210518150551_can_share.ts
Normal 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> {
|
||||
|
||||
}
|
@ -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);
|
||||
|
@ -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) {
|
||||
|
@ -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}`);
|
||||
|
||||
|
@ -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);
|
||||
|
||||
|
@ -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
|
||||
);
|
||||
});
|
||||
|
||||
});
|
||||
|
@ -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');
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
Loading…
Reference in New Issue
Block a user