mirror of
https://github.com/laurent22/joplin.git
synced 2024-12-24 10:27:10 +02:00
Server: Add support for item size limit
This commit is contained in:
parent
ec7f0f479a
commit
6afde54bda
Binary file not shown.
@ -275,6 +275,7 @@ export interface User extends WithDates, WithUuid {
|
|||||||
password?: string;
|
password?: string;
|
||||||
full_name?: string;
|
full_name?: string;
|
||||||
is_admin?: number;
|
is_admin?: number;
|
||||||
|
item_max_size?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Session extends WithDates, WithUuid {
|
export interface Session extends WithDates, WithUuid {
|
||||||
@ -377,6 +378,7 @@ export const databaseSchema: DatabaseTables = {
|
|||||||
is_admin: { type: 'number' },
|
is_admin: { type: 'number' },
|
||||||
updated_time: { type: 'string' },
|
updated_time: { type: 'string' },
|
||||||
created_time: { type: 'string' },
|
created_time: { type: 'string' },
|
||||||
|
item_max_size: { type: 'number' },
|
||||||
},
|
},
|
||||||
sessions: {
|
sessions: {
|
||||||
id: { type: 'string' },
|
id: { type: 'string' },
|
||||||
|
@ -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('item_max_size').defaultTo(0).notNullable();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down(_db: DbConnection): Promise<any> {
|
||||||
|
|
||||||
|
}
|
@ -3,7 +3,7 @@ import { ItemType, databaseSchema, Uuid, Item, ShareType, Share, ChangeType, Use
|
|||||||
import { defaultPagination, paginateDbQuery, PaginatedResults, Pagination } from './utils/pagination';
|
import { defaultPagination, paginateDbQuery, PaginatedResults, Pagination } from './utils/pagination';
|
||||||
import { isJoplinItemName, isJoplinResourceBlobPath, linkedResourceIds, serializeJoplinItem, unserializeJoplinItem } from '../utils/joplinUtils';
|
import { isJoplinItemName, isJoplinResourceBlobPath, linkedResourceIds, serializeJoplinItem, unserializeJoplinItem } from '../utils/joplinUtils';
|
||||||
import { ModelType } from '@joplin/lib/BaseModel';
|
import { ModelType } from '@joplin/lib/BaseModel';
|
||||||
import { ApiError, ErrorForbidden, ErrorNotFound, ErrorUnprocessableEntity } from '../utils/errors';
|
import { ApiError, ErrorForbidden, ErrorNotFound, ErrorPayloadTooLarge, ErrorUnprocessableEntity } from '../utils/errors';
|
||||||
import { Knex } from 'knex';
|
import { Knex } from 'knex';
|
||||||
import { ChangePreviousItem } from './ChangeModel';
|
import { ChangePreviousItem } from './ChangeModel';
|
||||||
|
|
||||||
@ -282,10 +282,10 @@ export default class ItemModel extends BaseModel<Item> {
|
|||||||
return this.itemToJoplinItem(raw);
|
return this.itemToJoplinItem(raw);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async saveFromRawContent(userId: Uuid, name: string, buffer: Buffer, options: ItemSaveOption = null): Promise<Item> {
|
public async saveFromRawContent(user: User, name: string, buffer: Buffer, options: ItemSaveOption = null): Promise<Item> {
|
||||||
options = options || {};
|
options = options || {};
|
||||||
|
|
||||||
const existingItem = await this.loadByName(userId, name);
|
const existingItem = await this.loadByName(user.id, name);
|
||||||
|
|
||||||
const isJoplinItem = isJoplinItemName(name);
|
const isJoplinItem = isJoplinItemName(name);
|
||||||
let isNote = false;
|
let isNote = false;
|
||||||
@ -322,8 +322,14 @@ export default class ItemModel extends BaseModel<Item> {
|
|||||||
|
|
||||||
if (options.shareId) item.jop_share_id = options.shareId;
|
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.item_max_size * (item.jop_encryption_applied ? 2.2 : 1);
|
||||||
|
if (maxSize && buffer.byteLength > maxSize) throw new ErrorPayloadTooLarge();
|
||||||
|
|
||||||
return this.withTransaction<Item>(async () => {
|
return this.withTransaction<Item>(async () => {
|
||||||
const savedItem = await this.saveForUser(userId, item);
|
const savedItem = await this.saveForUser(user.id, item);
|
||||||
|
|
||||||
if (isNote) {
|
if (isNote) {
|
||||||
await this.models().itemResource().deleteByItemId(savedItem.id);
|
await this.models().itemResource().deleteByItemId(savedItem.id);
|
||||||
|
@ -29,6 +29,7 @@ export default class UserModel extends BaseModel<User> {
|
|||||||
if ('password' in object) user.password = object.password;
|
if ('password' in object) user.password = object.password;
|
||||||
if ('is_admin' in object) user.is_admin = object.is_admin;
|
if ('is_admin' in object) user.is_admin = object.is_admin;
|
||||||
if ('full_name' in object) user.full_name = object.full_name;
|
if ('full_name' in object) user.full_name = object.full_name;
|
||||||
|
if ('item_max_size' in object) user.item_max_size = object.item_max_size;
|
||||||
|
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
@ -50,9 +51,12 @@ export default class UserModel extends BaseModel<User> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (action === AclAction.Update) {
|
if (action === AclAction.Update) {
|
||||||
|
const previousResource = await this.load(resource.id);
|
||||||
|
|
||||||
if (!user.is_admin && resource.id !== user.id) throw new ErrorForbidden('non-admin user cannot modify another user');
|
if (!user.is_admin && resource.id !== user.id) throw new ErrorForbidden('non-admin user cannot modify another user');
|
||||||
if (!user.is_admin && 'is_admin' in resource) throw new ErrorForbidden('non-admin user cannot make themselves an admin');
|
if (!user.is_admin && 'is_admin' in resource) throw new ErrorForbidden('non-admin user cannot make themselves an admin');
|
||||||
if (user.is_admin && user.id === resource.id && 'is_admin' in resource && !resource.is_admin) throw new ErrorForbidden('admin user cannot make themselves a non-admin');
|
if (user.is_admin && user.id === resource.id && 'is_admin' in resource && !resource.is_admin) throw new ErrorForbidden('admin user cannot make themselves a non-admin');
|
||||||
|
if (!user.is_admin && resource.item_max_size !== previousResource.item_max_size) throw new ErrorForbidden('non-admin user cannot change item_max_size');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (action === AclAction.Delete) {
|
if (action === AclAction.Delete) {
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { beforeAllDb, afterAllTests, beforeEachDb, createUserAndSession, models, createItem, makeTempFileWithContent, makeNoteSerializedBody, createItemTree, expectHttpError } from '../../utils/testing/testUtils';
|
import { beforeAllDb, afterAllTests, beforeEachDb, createUserAndSession, models, createItem, makeTempFileWithContent, makeNoteSerializedBody, createItemTree, expectHttpError, createNote, expectNoHttpError } from '../../utils/testing/testUtils';
|
||||||
import { NoteEntity } from '@joplin/lib/services/database/types';
|
import { NoteEntity } from '@joplin/lib/services/database/types';
|
||||||
import { ModelType } from '@joplin/lib/BaseModel';
|
import { ModelType } from '@joplin/lib/BaseModel';
|
||||||
import { deleteApi, getApi, putApi } from '../../utils/testing/apiUtils';
|
import { deleteApi, getApi, putApi } from '../../utils/testing/apiUtils';
|
||||||
@ -6,7 +6,7 @@ import { Item } from '../../db';
|
|||||||
import { PaginatedItems } from '../../models/ItemModel';
|
import { PaginatedItems } from '../../models/ItemModel';
|
||||||
import { shareFolderWithUser } from '../../utils/testing/shareApiUtils';
|
import { shareFolderWithUser } from '../../utils/testing/shareApiUtils';
|
||||||
import { resourceBlobPath } from '../../utils/joplinUtils';
|
import { resourceBlobPath } from '../../utils/joplinUtils';
|
||||||
import { ErrorForbidden } from '../../utils/errors';
|
import { ErrorForbidden, ErrorPayloadTooLarge } from '../../utils/errors';
|
||||||
|
|
||||||
describe('api_items', function() {
|
describe('api_items', function() {
|
||||||
|
|
||||||
@ -239,4 +239,41 @@ describe('api_items', function() {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('should check permissions - uploaded item should be below the allowed limit', async function() {
|
||||||
|
const { user: user1, session: session1 } = await createUserAndSession(1);
|
||||||
|
|
||||||
|
{
|
||||||
|
await models().user().save({ id: user1.id, item_max_size: 4 });
|
||||||
|
|
||||||
|
await expectHttpError(
|
||||||
|
async () => createNote(session1.id, {
|
||||||
|
id: '00000000000000000000000000000001',
|
||||||
|
body: '12345',
|
||||||
|
}),
|
||||||
|
ErrorPayloadTooLarge.httpCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
await models().user().save({ id: user1.id, item_max_size: 1000 });
|
||||||
|
|
||||||
|
await expectNoHttpError(
|
||||||
|
async () => createNote(session1.id, {
|
||||||
|
id: '00000000000000000000000000000002',
|
||||||
|
body: '12345',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
await models().user().save({ id: user1.id, item_max_size: 0 });
|
||||||
|
|
||||||
|
await expectNoHttpError(
|
||||||
|
async () => createNote(session1.id, {
|
||||||
|
id: '00000000000000000000000000000003',
|
||||||
|
body: '12345',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
@ -86,7 +86,7 @@ router.put('api/items/:id/content', async (path: SubPath, ctx: AppContext) => {
|
|||||||
await itemModel.checkIfAllowed(ctx.owner, AclAction.Create, { jop_share_id: saveOptions.shareId });
|
await itemModel.checkIfAllowed(ctx.owner, AclAction.Create, { jop_share_id: saveOptions.shareId });
|
||||||
}
|
}
|
||||||
|
|
||||||
const item = await itemModel.saveFromRawContent(ctx.owner.id, name, buffer, saveOptions);
|
const item = await itemModel.saveFromRawContent(ctx.owner, name, buffer, saveOptions);
|
||||||
outputItem = itemModel.toApiOutput(item) as Item;
|
outputItem = itemModel.toApiOutput(item) as Item;
|
||||||
} finally {
|
} finally {
|
||||||
if (filePath) await safeRemove(filePath);
|
if (filePath) await safeRemove(filePath);
|
||||||
|
@ -180,6 +180,9 @@ describe('index_users', function() {
|
|||||||
|
|
||||||
// cannot delete own user
|
// cannot delete own user
|
||||||
await expectHttpError(async () => execRequest(adminSession.id, 'POST', `users/${admin.id}`, { delete_button: true }), ErrorForbidden.httpCode);
|
await expectHttpError(async () => execRequest(adminSession.id, 'POST', `users/${admin.id}`, { delete_button: true }), ErrorForbidden.httpCode);
|
||||||
|
|
||||||
|
// non-admin cannot change item_max_size
|
||||||
|
await expectHttpError(async () => patchUser(session1.id, { id: admin.id, item_max_size: 1000 }), ErrorForbidden.httpCode);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
@ -8,6 +8,7 @@ import config from '../../config';
|
|||||||
import { View } from '../../services/MustacheService';
|
import { View } from '../../services/MustacheService';
|
||||||
import defaultView from '../../utils/defaultView';
|
import defaultView from '../../utils/defaultView';
|
||||||
import { AclAction } from '../../models/BaseModel';
|
import { AclAction } from '../../models/BaseModel';
|
||||||
|
const prettyBytes = require('pretty-bytes');
|
||||||
|
|
||||||
function makeUser(isNew: boolean, fields: any): User {
|
function makeUser(isNew: boolean, fields: any): User {
|
||||||
const user: User = {};
|
const user: User = {};
|
||||||
@ -15,6 +16,7 @@ function makeUser(isNew: boolean, fields: any): User {
|
|||||||
if ('email' in fields) user.email = fields.email;
|
if ('email' in fields) user.email = fields.email;
|
||||||
if ('full_name' in fields) user.full_name = fields.full_name;
|
if ('full_name' in fields) user.full_name = fields.full_name;
|
||||||
if ('is_admin' in fields) user.is_admin = fields.is_admin;
|
if ('is_admin' in fields) user.is_admin = fields.is_admin;
|
||||||
|
if ('item_max_size' in fields) user.item_max_size = fields.item_max_size;
|
||||||
|
|
||||||
if (fields.password) {
|
if (fields.password) {
|
||||||
if (fields.password !== fields.password2) throw new ErrorUnprocessableEntity('Passwords do not match');
|
if (fields.password !== fields.password2) throw new ErrorUnprocessableEntity('Passwords do not match');
|
||||||
@ -43,7 +45,12 @@ router.get('users', async (_path: SubPath, ctx: AppContext) => {
|
|||||||
const users = await userModel.all();
|
const users = await userModel.all();
|
||||||
|
|
||||||
const view: View = defaultView('users');
|
const view: View = defaultView('users');
|
||||||
view.content.users = users;
|
view.content.users = users.map(user => {
|
||||||
|
return {
|
||||||
|
...user,
|
||||||
|
formattedItemMaxSize: user.item_max_size ? prettyBytes(user.item_max_size) : '∞',
|
||||||
|
};
|
||||||
|
});
|
||||||
return view;
|
return view;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -77,3 +77,12 @@ export class ErrorResyncRequired extends ApiError {
|
|||||||
Object.setPrototypeOf(this, ErrorResyncRequired.prototype);
|
Object.setPrototypeOf(this, ErrorResyncRequired.prototype);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class ErrorPayloadTooLarge extends ApiError {
|
||||||
|
public static httpCode: number = 413;
|
||||||
|
|
||||||
|
public constructor(message: string = 'Payload Too Large') {
|
||||||
|
super(message, ErrorPayloadTooLarge.httpCode);
|
||||||
|
Object.setPrototypeOf(this, ErrorPayloadTooLarge.prototype);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -45,6 +45,8 @@ function convertTree(tree: any): any[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function createItemTree3(sessionId: Uuid, userId: Uuid, parentFolderId: string, shareId: Uuid, tree: any[]): Promise<void> {
|
async function createItemTree3(sessionId: Uuid, userId: Uuid, parentFolderId: string, shareId: Uuid, tree: any[]): Promise<void> {
|
||||||
|
const user = await models().user().load(userId);
|
||||||
|
|
||||||
for (const jopItem of tree) {
|
for (const jopItem of tree) {
|
||||||
const isFolder = !!jopItem.children;
|
const isFolder = !!jopItem.children;
|
||||||
const serializedBody = isFolder ?
|
const serializedBody = isFolder ?
|
||||||
@ -58,7 +60,7 @@ async function createItemTree3(sessionId: Uuid, userId: Uuid, parentFolderId: st
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const newItem = await models().item().saveFromRawContent(userId, `${jopItem.id}.md`, Buffer.from(serializedBody));
|
const newItem = await models().item().saveFromRawContent(user, `${jopItem.id}.md`, Buffer.from(serializedBody));
|
||||||
if (isFolder && jopItem.children.length) await createItemTree3(sessionId, userId, newItem.jop_id, shareId, jopItem.children);
|
if (isFolder && jopItem.children.length) await createItemTree3(sessionId, userId, newItem.jop_id, shareId, jopItem.children);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -272,26 +272,28 @@ export async function createItemTree(userId: Uuid, parentFolderId: string, tree:
|
|||||||
|
|
||||||
export async function createItemTree2(userId: Uuid, parentFolderId: string, tree: any[]): Promise<void> {
|
export async function createItemTree2(userId: Uuid, parentFolderId: string, tree: any[]): Promise<void> {
|
||||||
const itemModel = models().item();
|
const itemModel = models().item();
|
||||||
|
const user = await models().user().load(userId);
|
||||||
|
|
||||||
for (const jopItem of tree) {
|
for (const jopItem of tree) {
|
||||||
const isFolder = !!jopItem.children;
|
const isFolder = !!jopItem.children;
|
||||||
const serializedBody = isFolder ?
|
const serializedBody = isFolder ?
|
||||||
makeFolderSerializedBody({ ...jopItem, parent_id: parentFolderId }) :
|
makeFolderSerializedBody({ ...jopItem, parent_id: parentFolderId }) :
|
||||||
makeNoteSerializedBody({ ...jopItem, parent_id: parentFolderId });
|
makeNoteSerializedBody({ ...jopItem, parent_id: parentFolderId });
|
||||||
const newItem = await itemModel.saveFromRawContent(userId, `${jopItem.id}.md`, Buffer.from(serializedBody));
|
const newItem = await itemModel.saveFromRawContent(user, `${jopItem.id}.md`, Buffer.from(serializedBody));
|
||||||
if (isFolder && jopItem.children.length) await createItemTree2(userId, newItem.jop_id, jopItem.children);
|
if (isFolder && jopItem.children.length) await createItemTree2(userId, newItem.jop_id, jopItem.children);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createItemTree3(userId: Uuid, parentFolderId: string, shareId: Uuid, tree: any[]): Promise<void> {
|
export async function createItemTree3(userId: Uuid, parentFolderId: string, shareId: Uuid, tree: any[]): Promise<void> {
|
||||||
const itemModel = models().item();
|
const itemModel = models().item();
|
||||||
|
const user = await models().user().load(userId);
|
||||||
|
|
||||||
for (const jopItem of tree) {
|
for (const jopItem of tree) {
|
||||||
const isFolder = !!jopItem.children;
|
const isFolder = !!jopItem.children;
|
||||||
const serializedBody = isFolder ?
|
const serializedBody = isFolder ?
|
||||||
makeFolderSerializedBody({ ...jopItem, parent_id: parentFolderId, share_id: shareId }) :
|
makeFolderSerializedBody({ ...jopItem, parent_id: parentFolderId, share_id: shareId }) :
|
||||||
makeNoteSerializedBody({ ...jopItem, parent_id: parentFolderId, share_id: shareId });
|
makeNoteSerializedBody({ ...jopItem, parent_id: parentFolderId, share_id: shareId });
|
||||||
const newItem = await itemModel.saveFromRawContent(userId, `${jopItem.id}.md`, Buffer.from(serializedBody));
|
const newItem = await itemModel.saveFromRawContent(user, `${jopItem.id}.md`, Buffer.from(serializedBody));
|
||||||
if (isFolder && jopItem.children.length) await createItemTree3(userId, newItem.jop_id, shareId, jopItem.children);
|
if (isFolder && jopItem.children.length) await createItemTree3(userId, newItem.jop_id, shareId, jopItem.children);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -408,6 +410,22 @@ export async function expectHttpError(asyncFn: Function, expectedHttpCode: numbe
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function expectNoHttpError(asyncFn: Function): Promise<void> {
|
||||||
|
let thrownError = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await asyncFn();
|
||||||
|
} catch (error) {
|
||||||
|
thrownError = error;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (thrownError) {
|
||||||
|
expect('throw').toBe('not throw');
|
||||||
|
} else {
|
||||||
|
expect(true).toBe(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function makeNoteSerializedBody(note: NoteEntity = {}): string {
|
export function makeNoteSerializedBody(note: NoteEntity = {}): string {
|
||||||
return `${'title' in note ? note.title : 'Title'}
|
return `${'title' in note ? note.title : 'Title'}
|
||||||
|
|
||||||
|
@ -14,6 +14,14 @@
|
|||||||
<input class="input" type="email" name="email" value="{{user.email}}"/>
|
<input class="input" type="email" name="email" value="{{user.email}}"/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{{#global.owner.is_admin}}
|
||||||
|
<div class="field">
|
||||||
|
<label class="label">Max item size</label>
|
||||||
|
<div class="control">
|
||||||
|
<input class="input" type="number" name="item_max_size" value="{{user.item_max_size}}"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{/global.owner.is_admin}}
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label">Password</label>
|
<label class="label">Password</label>
|
||||||
<div class="control">
|
<div class="control">
|
||||||
|
@ -3,6 +3,7 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<th>Full name</th>
|
<th>Full name</th>
|
||||||
<th>Email</th>
|
<th>Email</th>
|
||||||
|
<th>Max Item Size</th>
|
||||||
<th>Is admin?</th>
|
<th>Is admin?</th>
|
||||||
<th>Actions</th>
|
<th>Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
@ -12,6 +13,7 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<td>{{full_name}}</td>
|
<td>{{full_name}}</td>
|
||||||
<td>{{email}}</td>
|
<td>{{email}}</td>
|
||||||
|
<td>{{formattedItemMaxSize}}</td>
|
||||||
<td>{{is_admin}}</td>
|
<td>{{is_admin}}</td>
|
||||||
<td><a href="{{{global.baseUrl}}}/users/{{id}}" class="button is-primary is-small">Edit</a></td>
|
<td><a href="{{{global.baseUrl}}}/users/{{id}}" class="button is-primary is-small">Edit</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
|
Loading…
Reference in New Issue
Block a user