1
0
mirror of https://github.com/laurent22/joplin.git synced 2025-08-24 20:19:10 +02:00

Compare commits

...

19 Commits

Author SHA1 Message Date
Laurent Cozic
aeb3c4a98d trying 2021-02-05 16:17:49 +00:00
Laurent Cozic
58a464d040 allow custom app routes 2021-02-05 12:12:53 +00:00
Laurent Cozic
8e13ccb665 fixed updated time handling 2021-02-05 10:34:12 +00:00
Laurent Cozic
6dd14ff04b clean up 2021-02-04 22:13:00 +00:00
Laurent Cozic
2022b5bc48 add indexes 2021-02-04 22:06:50 +00:00
Laurent Cozic
7ade9b2948 third user test 2021-02-04 21:57:58 +00:00
Laurent Cozic
4157dad9f1 prevent share from being shared again 2021-02-04 21:26:07 +00:00
Laurent Cozic
a088061de9 Fxied tests 2021-02-04 21:13:00 +00:00
Laurent Cozic
439d29387f no need for delta state 2021-02-04 17:35:36 +00:00
Laurent Cozic
2f15e4db59 tests 2021-02-04 17:06:40 +00:00
Laurent Cozic
0b37e99132 clean up 2021-02-04 16:46:01 +00:00
Laurent Cozic
6d41787a29 clean up 2021-02-04 15:54:21 +00:00
Laurent Cozic
28fc0374c5 testes 2021-02-04 12:03:50 +00:00
Laurent Cozic
726ee4a574 update 2021-02-04 10:11:21 +00:00
Laurent Cozic
25e32226ef Check linked file content 2021-02-03 12:05:40 +00:00
Laurent Cozic
9efdbf9854 refactor 2021-02-02 22:35:17 +00:00
Laurent Cozic
09c95f10f4 share 2021-02-02 18:20:19 +00:00
Laurent Cozic
a6453af3e5 Merge branch 'dev' into server_app_share 2021-02-02 11:06:50 +00:00
Laurent Cozic
b8c8178b26 update 2021-02-01 17:34:17 +00:00
35 changed files with 967 additions and 163 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 514 B

After

Width:  |  Height:  |  Size: 0 B

View File

@@ -1,17 +0,0 @@
import { ExportModule, ImportModule } from './types';
/**
* Provides a way to create modules to import external data into Joplin or to export notes into any arbitrary format.
*
* [View the demo plugin](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/json_export)
*
* To implement an import or export module, you would simply define an object with various event handlers that are called
* by the application during the import/export process.
*
* See the documentation of the [[ExportModule]] and [[ImportModule]] for more information.
*
* You may also want to refer to the Joplin API documentation to see the list of properties for each item (note, notebook, etc.) - https://joplinapp.org/api/references/rest_api/
*/
export default class JoplinInterop {
registerExportModule(module: ExportModule): Promise<void>;
registerImportModule(module: ImportModule): Promise<void>;
}

View File

@@ -42,9 +42,6 @@ export default class FileApiDriverJoplinServer {
isDeleted: isDeleted,
};
// TODO - HANDLE DELETED
// if (md['.tag'] === 'deleted') output.isDeleted = true;
return output;
}

View File

@@ -429,7 +429,7 @@ class Setting extends BaseModel {
return settings['sync.target'] == SyncTargetRegistry.nameToId('joplinServer');
},
public: true,
label: () => _('Joplin Server username'),
label: () => _('Joplin Server email'),
},
'sync.9.password': {
value: '',

View File

@@ -274,4 +274,8 @@ export default class Application extends BaseApplication {
return true;
}
public async processSharedContentForSave(file:File):Promise<File> {
// console.info('
}
}

View File

@@ -0,0 +1,14 @@
import routes from '../../../routes/routes';
import Router from '../../../utils/Router';
import { execRequest, SubPath } from '../../../utils/routeUtils';
import { AppContext } from '../../../utils/types';
const router = new Router();
router.get('notes/:id', async (path: SubPath, ctx: AppContext) => {
// return execRequest(routes, ctx, 'api/files/root:/' + path.id);
// return execRoute('GET', 'api/files/:id', path, ctx);
//return ctx.routes['api/files'](path, ctx);
});
export default router;

View File

@@ -268,6 +268,7 @@ export interface File extends WithDates, WithUuid {
is_directory?: number;
is_root?: number;
parent_id?: Uuid;
source_file_id?: Uuid;
}
export interface Change extends WithDates, WithUuid {
@@ -300,6 +301,12 @@ export interface Share extends WithDates, WithUuid {
type?: ShareType;
}
export interface ShareUser extends WithDates, WithUuid {
share_id?: Uuid;
user_id?: Uuid;
is_accepted?: number;
}
export const databaseSchema: DatabaseTables = {
users: {
id: { type: 'string' },
@@ -339,6 +346,7 @@ export const databaseSchema: DatabaseTables = {
parent_id: { type: 'string' },
updated_time: { type: 'string' },
created_time: { type: 'string' },
source_file_id: { type: 'string' },
},
changes: {
counter: { type: 'number' },
@@ -378,5 +386,13 @@ export const databaseSchema: DatabaseTables = {
updated_time: { type: 'string' },
created_time: { type: 'string' },
},
share_users: {
id: { type: 'string' },
share_id: { type: 'string' },
user_id: { type: 'string' },
is_accepted: { type: 'number' },
updated_time: { type: 'string' },
created_time: { type: 'string' },
},
};
// AUTO-GENERATED-TYPES

View File

@@ -1,7 +1,6 @@
import routes from '../routes/routes';
import { ErrorForbidden, ErrorNotFound } from '../utils/errors';
import { routeResponseFormat, findMatchingRoute, Response, RouteResponseFormat, MatchedRoute } from '../utils/routeUtils';
import { AppContext, Env, HttpMethod } from '../utils/types';
import { routeResponseFormat, Response, RouteResponseFormat, execRequest } from '../utils/routeUtils';
import { AppContext, Env } from '../utils/types';
import MustacheService, { isView, View } from '../services/MustacheService';
import config from '../config';
@@ -16,38 +15,21 @@ function mustache(): MustacheService {
export default async function(ctx: AppContext) {
ctx.appLogger().info(`${ctx.request.method} ${ctx.path}`);
const match: MatchedRoute = null;
try {
const match = findMatchingRoute(ctx.path, routes);
const responseObject = await execRequest(routes, ctx);
if (match) {
let responseObject = null;
const routeHandler = match.route.findEndPoint(ctx.request.method as HttpMethod, match.subPath.schema);
// This is a generic catch-all for all private end points - if we
// couldn't get a valid session, we exit now. Individual end points
// might have additional permission checks depending on the action.
if (!match.route.public && !ctx.owner) throw new ErrorForbidden();
responseObject = await routeHandler(match.subPath, ctx);
if (responseObject instanceof Response) {
ctx.response = responseObject.response;
} else if (isView(responseObject)) {
ctx.response.status = 200;
ctx.response.body = await mustache().renderView(responseObject, {
notifications: ctx.notifications || [],
hasNotifications: !!ctx.notifications && !!ctx.notifications.length,
owner: ctx.owner,
});
} else {
ctx.response.status = 200;
ctx.response.body = responseObject;
}
if (responseObject instanceof Response) {
ctx.response = responseObject.response;
} else if (isView(responseObject)) {
ctx.response.status = 200;
ctx.response.body = await mustache().renderView(responseObject, {
notifications: ctx.notifications || [],
hasNotifications: !!ctx.notifications && !!ctx.notifications.length,
owner: ctx.owner,
});
} else {
throw new ErrorNotFound();
ctx.response.status = 200;
ctx.response.body = responseObject;
}
} catch (error) {
if (error.httpCode >= 400 && error.httpCode < 500) {
@@ -58,7 +40,7 @@ export default async function(ctx: AppContext) {
ctx.response.status = error.httpCode ? error.httpCode : 500;
const responseFormat = routeResponseFormat(match, ctx);
const responseFormat = routeResponseFormat(ctx);
if (responseFormat === RouteResponseFormat.Html) {
ctx.response.set('Content-Type', 'text/html');

View File

@@ -0,0 +1,34 @@
import * as Knex from 'knex';
import { DbConnection } from '../db';
export async function up(db: DbConnection): Promise<any> {
await db.schema.createTable('share_users', function(table: Knex.CreateTableBuilder) {
table.string('id', 32).unique().primary().notNullable();
table.string('share_id', 32).notNullable();
table.string('user_id', 32).notNullable();
table.integer('is_accepted').defaultTo(0).notNullable();
table.bigInteger('updated_time').notNullable();
table.bigInteger('created_time').notNullable();
});
await db.schema.alterTable('share_users', function(table: Knex.CreateTableBuilder) {
table.unique(['share_id', 'user_id']);
});
await db.schema.table('files', function(table: Knex.CreateTableBuilder) {
table.string('source_file_id', 32).defaultTo('').notNullable();
});
await db.schema.alterTable('files', function(table: Knex.CreateTableBuilder) {
table.index(['owner_id']);
table.index(['source_file_id']);
});
await db.schema.alterTable('changes', function(table: Knex.CreateTableBuilder) {
table.index(['item_id']);
});
}
export async function down(db: DbConnection): Promise<any> {
await db.schema.dropTable('share_users');
}

View File

@@ -3,9 +3,11 @@ import TransactionHandler from '../utils/TransactionHandler';
import uuidgen from '../utils/uuidgen';
import { ErrorUnprocessableEntity, ErrorBadRequest } from '../utils/errors';
import { Models } from './factory';
import Applications from '../services/Applications';
export interface ModelOptions {
userId?: string;
apps?: Applications;
}
export interface SaveOptions {
@@ -15,6 +17,10 @@ export interface SaveOptions {
trackChanges?: boolean;
}
export interface LoadOptions {
fields?: string[];
}
export interface DeleteOptions {
validationRules?: any;
}
@@ -63,6 +69,10 @@ export default abstract class BaseModel<T> {
return this.options.userId;
}
protected get apps():Applications {
return this.options.apps;
}
protected get db(): DbConnection {
if (this.transactionHandler_.activeTransaction) return this.transactionHandler_.activeTransaction;
return this.db_;
@@ -121,7 +131,7 @@ export default abstract class BaseModel<T> {
//
// The `name` argument is only for debugging, so that any stuck transaction
// can be more easily identified.
protected async withTransaction(fn: Function, name: string = null): Promise<void> {
protected async withTransaction<T>(fn: Function, name: string = null): Promise<T> {
const debugTransaction = false;
const debugTimerId = debugTransaction ? setTimeout(() => {
@@ -132,8 +142,10 @@ export default abstract class BaseModel<T> {
if (debugTransaction) console.info('START', name, txIndex);
let output: T = null;
try {
await fn();
output = await fn();
} catch (error) {
await this.transactionHandler_.rollback(txIndex);
@@ -151,6 +163,7 @@ export default abstract class BaseModel<T> {
}
await this.transactionHandler_.commit(txIndex);
return output;
}
public async all(): Promise<T[]> {
@@ -182,6 +195,7 @@ export default abstract class BaseModel<T> {
protected async isNew(object: T, options: SaveOptions): Promise<boolean> {
if (options.isNew === false) return false;
if (options.isNew === true) return true;
if ('id' in object && !(object as WithUuid).id) throw new Error('ID cannot be undefined or null');
return !(object as WithUuid).id;
}
@@ -249,15 +263,15 @@ export default abstract class BaseModel<T> {
return toSave;
}
public async loadByIds(ids: string[]): Promise<T[]> {
public async loadByIds(ids: string[], options: LoadOptions = {}): Promise<T[]> {
if (!ids.length) return [];
return this.db(this.tableName).select(this.defaultFields).whereIn('id', ids);
return this.db(this.tableName).select(options.fields || this.defaultFields).whereIn('id', ids);
}
public async load(id: string): Promise<T> {
public async load(id: string, options: LoadOptions = {}): Promise<T> {
if (!id) throw new Error('id cannot be empty');
return this.db(this.tableName).select(this.defaultFields).where({ id: id }).first();
return this.db(this.tableName).select(options.fields || this.defaultFields).where({ id: id }).first();
}
public async delete(id: string | string[]): Promise<void> {

View File

@@ -8,6 +8,10 @@ export interface ChangeWithItem {
type: ChangeType;
}
export interface ChangeWithDestFile extends Change {
dest_file_id: Uuid;
}
export interface PaginatedChanges extends PaginatedResults {
items: ChangeWithItem[];
}
@@ -68,27 +72,82 @@ export default class ChangeModel extends BaseModel<Change> {
const directory = await fileModel.load(dirId);
if (!directory.is_directory) throw new ErrorUnprocessableEntity(`Item with id "${dirId}" is not a directory.`);
// Rather than query the changes, then use JS to compress them, it might
// be possible to do both in one query.
// https://stackoverflow.com/questions/65348794
const query = this.db(this.tableName)
// Retrieves the IDs of all the files that have been shared with the
// current user.
const linkedFilesQuery = this
.db('files')
.select('source_file_id')
.where('source_file_id', '!=', '')
.andWhere('parent_id', '=', dirId)
.andWhere('owner_id', '=', this.userId);
// Retrieves all the changes for the files that belong to the current
// user.
const ownChangesQuery = this.db(this.tableName)
.select([
'counter',
'id',
'item_id',
'item_name',
'type',
this.db.raw('"" as dest_file_id'),
])
.where('parent_id', dirId)
.orderBy('counter', 'asc')
.limit(pagination.limit);
.where('parent_id', dirId);
// Retrieves all the changes for the files that have been shared with
// this user.
//
// Each row will have an additional "dest_file_id" property that points
// to the destination of the link. For example:
//
// - User 1 shares a file with ID 123 with user 2.
// - User 2 get a new file with ID 456 that links to file 123.
// - User 1 changes file 123
// - When user 2 retrieves all the changes, they'll get a change for
// item_id = 123, and dest_file_id = 456
//
// We need this dest_file_id because when sending the list of files, we
// want to send back metadata for file 456, and not 123, as that belongs
// to a different user.
const sharedChangesQuery = this.db(this.tableName)
.select([
'counter',
'changes.id',
'item_id',
'item_name',
'type',
'files.id as dest_file_id',
])
.join('files', 'changes.item_id', 'files.source_file_id')
.whereIn('changes.item_id', linkedFilesQuery);
// If a cursor was provided, apply it to both queries.
if (changeAtCursor) {
void query.where('counter', '>', changeAtCursor.counter);
void ownChangesQuery.where('counter', '>', changeAtCursor.counter);
void sharedChangesQuery.where('counter', '>', changeAtCursor.counter);
}
const changes: Change[] = await query;
// This will give the list of all changes for shared and not shared
// files for the provided directory ID. Knexjs TypeScript support seems
// to be buggy here as it reports that will return `any[][]` so we fix
// that by forcing `any[]`
const changesWithDestFile: ChangeWithDestFile[] = await ownChangesQuery
.union(sharedChangesQuery)
.orderBy('counter', 'asc')
.limit(pagination.limit) as any[];
// Maps dest_file_id to item_id and then the rest of the code can just
// work without having to check if it's a shared file or not.
const changes = changesWithDestFile.map(c => {
if (c.dest_file_id) {
return { ...c, item_id: c.dest_file_id };
} else {
return c;
}
});
const compressedChanges = this.compressChanges(changes);
const changeWithItems = await this.loadChangeItems(compressedChanges);
return {
@@ -102,8 +161,12 @@ export default class ChangeModel extends BaseModel<Change> {
private async loadChangeItems(changes: Change[]): Promise<ChangeWithItem[]> {
const itemIds = changes.map(c => c.item_id);
const fileModel = this.models().file({ userId: this.userId });
const items: File[] = await fileModel.loadByIds(itemIds);
// We skip permission check here because, when a file is shared, we need
// to fetch files that don't belong to the current user. This check
// would not be needed anyway because the change items are generated in
// a context where permissions have already been checked.
const items: File[] = await this.models().file().loadByIds(itemIds, { skipPermissionCheck: true });
const output: ChangeWithItem[] = [];
@@ -140,7 +203,8 @@ export default class ChangeModel extends BaseModel<Change> {
const itemChanges: Record<Uuid, Change> = {};
for (const change of changes) {
const previous = itemChanges[change.item_id];
const itemId = change.item_id;
const previous = itemChanges[itemId];
if (previous) {
// create - update => create
@@ -153,22 +217,22 @@ export default class ChangeModel extends BaseModel<Change> {
}
if (previous.type === ChangeType.Create && change.type === ChangeType.Delete) {
delete itemChanges[change.item_id];
delete itemChanges[itemId];
}
if (previous.type === ChangeType.Update && change.type === ChangeType.Update) {
itemChanges[change.item_id] = change;
itemChanges[itemId] = change;
}
if (previous.type === ChangeType.Update && change.type === ChangeType.Delete) {
itemChanges[change.item_id] = change;
itemChanges[itemId] = change;
}
} else {
itemChanges[change.item_id] = change;
itemChanges[itemId] = change;
}
}
const output = [];
const output: Change[] = [];
for (const itemId in itemChanges) {
output.push(itemChanges[itemId]);

View File

@@ -40,8 +40,13 @@ describe('FileModel', function() {
.concat(Object.keys(tree.folder2))
.concat(Object.keys(tree.folder3));
const allFiles = await fileModel.all();
const loadByName = (name: string) => {
return allFiles.find(f => f.name === name);
};
for (const t of testCases) {
const file: File = await fileModel.loadByName(t);
const file: File = await loadByName(t);
const path = await fileModel.itemFullPath(file);
const fileBackId: string = await fileModel.pathToFileId(path);
expect(file.id).toBe(fileBackId);

View File

@@ -23,6 +23,8 @@ export interface PathToFileOptions {
export interface LoadOptions {
skipPermissionCheck?: boolean;
fields?: string[];
skipFollowLinks?: boolean;
}
export default class FileModel extends BaseModel<File> {
@@ -207,16 +209,19 @@ export default class FileModel extends BaseModel<File> {
}
public async fileByName(parentId: string, name: string, options: LoadOptions = {}): Promise<File> {
const file = await this.db<File>(this.tableName).select(...this.defaultFields).where({
parent_id: parentId,
name: name,
}).first();
const file = await this.db<File>(this.tableName)
.select(...this.defaultFields)
.where({
parent_id: parentId,
name: name,
})
.first();
if (!file) return null;
if (!options.skipPermissionCheck) await this.checkCanReadPermissions(file);
return file;
return this.processFileLink(file);
}
protected async validate(object: File, options: ValidateOptions = {}): Promise<File> {
@@ -264,8 +269,8 @@ export default class FileModel extends BaseModel<File> {
if ('name' in file && !file.is_root) {
const existingFile = await this.fileByName(parentId, file.name);
if (existingFile && options.isNew) throw new ErrorConflict(`Already a file with name "${file.name}"`);
if (existingFile && file.id === existingFile.id) throw new ErrorConflict(`Already a file with name "${file.name}"`);
if (existingFile && options.isNew) throw new ErrorConflict(`Already a file with name "${file.name}" (1)`);
if (existingFile && file.id !== existingFile.id) throw new ErrorConflict(`Already a file with name "${file.name}" (2)`);
}
if ('name' in file) {
@@ -297,6 +302,12 @@ export default class FileModel extends BaseModel<File> {
}
}
private async processSharedContentForSave(file:File):Promise<File> {
if (!('source_file_id' in file)) throw new Error('source_file_id prop is required');
if (!file.source_file_id) return file;
return this.apps.processSharedContentForSave(file);
}
public async createRootFile(): Promise<File> {
const existingRootFile = await this.userRootFile();
if (existingRootFile) throw new Error(`User ${this.userId} has already a root file`);
@@ -319,7 +330,10 @@ export default class FileModel extends BaseModel<File> {
if (!files.length || !files[0]) throw new ErrorNotFound();
const fileIds = files.map(f => f.id);
const fileIds = files.map(f => {
if (!f.id) throw new Error('One of the file is missing an ID');
return f.id;
});
const permissionModel = this.models().permission();
const permissionGrantedMap = await permissionModel[methodName](fileIds, this.userId);
@@ -329,7 +343,7 @@ export default class FileModel extends BaseModel<File> {
}
for (const fileId in permissionGrantedMap) {
if (!permissionGrantedMap[fileId]) throw new ErrorForbidden(`No read access to: ${fileId}`);
if (!permissionGrantedMap[fileId]) throw new ErrorForbidden(`No "${methodName}" access to: ${fileId}`);
}
}
@@ -378,46 +392,105 @@ export default class FileModel extends BaseModel<File> {
return output;
}
// Mostly makes sense for testing/debugging because the filename would
// have to globally unique, which is not a requirement.
public async loadByName(name: string, options: LoadOptions = {}): Promise<File> {
const file: File = await this.db(this.tableName)
.select(this.defaultFields)
.where({ name: name })
.andWhere({ owner_id: this.userId })
.first();
if (!file) throw new ErrorNotFound(`No such file: ${name}`);
if (!options.skipPermissionCheck) await this.checkCanReadPermissions(file);
return file;
private async processFileLink(file: File): Promise<File> {
const files = await this.processFileLinks([file]);
return files[0];
}
public async loadWithContent(id: string, options: LoadOptions = {}): Promise<any> {
const file: File = await this.db<File>(this.tableName).select('*').where({ id: id }).first();
if (!file) return null;
if (!options.skipPermissionCheck) await this.checkCanReadPermissions(file);
return file;
// If the file is a link to another file, the content of the source if
// assigned to the content of the destination. The updated_time property is
// also set to the most recent one among the source and the dest.
private async processFileLinks(files: File[]): Promise<File[]> {
const sourceFileIds: Uuid[] = [];
for (const file of files) {
if (!('source_file_id' in file)) throw new Error('Cannot process file links without a "source_file_id" property');
if (!file.source_file_id) continue;
sourceFileIds.push(file.source_file_id);
}
if (!sourceFileIds.length) return files;
const fields = Object.keys(files[0]);
const sourceFiles = await this.loadByIds(sourceFileIds, {
fields,
skipPermissionCheck: true,
// Current we don't follow links more than one level deep. In
// practice it means that if a user tries to share a note that has
// been shared with them, it will not work. Instead they'll have to
// make a copy of that note and share that. Anything else would
// probably be too complex to make any sense in terms of UI.
skipFollowLinks: true,
});
const modFiles = files.slice();
for (let i = 0; i < modFiles.length; i++) {
const file = modFiles[i];
if (!file.source_file_id) continue;
const sourceFile = sourceFiles.find(f => f.id === file.source_file_id);
if (!sourceFile) {
throw new Error(`File is linked to a file that no longer exists: ${file.id} => ${file.source_file_id}`);
}
const modFile = { ...file };
if ('updated_time' in modFile) modFile.updated_time = Math.max(sourceFile.updated_time, file.updated_time);
if ('content' in modFile) modFile.content = sourceFile.content;
modFiles[i] = modFile;
}
return modFiles;
}
public async loadWithContent(id: string, options: LoadOptions = {}): Promise<File> {
const fields = options.fields || this.defaultFields.concat(['content']);
return this.load(id, { ...options, fields });
}
public async loadByIds(ids: string[], options: LoadOptions = {}): Promise<File[]> {
const files: File[] = await super.loadByIds(ids);
const files: File[] = await super.loadByIds(ids, options);
if (!files.length) return [];
if (!options.skipPermissionCheck) await this.checkCanReadPermissions(files);
return files;
return options.skipFollowLinks ? files : this.processFileLinks(files);
}
public async load(id: string, options: LoadOptions = {}): Promise<File> {
const file: File = await super.load(id);
if (!file) return null;
if (!options.skipPermissionCheck) await this.checkCanReadPermissions(file);
return file;
const files = await this.loadByIds([id], options);
return files.length ? files[0] : null;
}
public async save(object: File, options: SaveOptions = {}): Promise<File> {
const isNew = await this.isNew(object, options);
const file: File = { ... object };
let sourceFile: File = null;
if ('content' in file) file.size = file.content ? file.content.byteLength : 0;
if ('content' in file) {
let sourceFileId: string = null;
if (file.id) {
if (!('source_file_id' in file)) throw new Error('source_file_id is required when setting the content');
sourceFileId = file.source_file_id;
}
const fileSize = file.content ? file.content.byteLength : 0;
if (sourceFileId) {
sourceFile = {
id: sourceFileId,
content: file.content,
size: fileSize,
source_file_id: '',
};
sourceFile = await this.processSharedContentForSave(file);
delete file.content;
} else {
file.size = file.content ? file.content.byteLength : 0;
}
}
if (isNew) {
if (!file.parent_id && !file.is_root) file.parent_id = await this.userRootFileId();
@@ -431,7 +504,10 @@ export default class FileModel extends BaseModel<File> {
file.owner_id = this.userId;
}
return super.save(file, options);
return this.withTransaction(async () => {
if (sourceFile) await this.save(sourceFile);
return super.save(file, options);
});
}
public async childrenCount(id: string): Promise<number> {
@@ -444,7 +520,9 @@ export default class FileModel extends BaseModel<File> {
public async childrens(id: string, pagination: Pagination): Promise<PaginatedFiles> {
const parent = await this.load(id);
await this.checkCanReadPermissions(parent);
return paginateDbQuery(this.db(this.tableName).select(...this.defaultFields).where('parent_id', id), pagination);
const page = await paginateDbQuery(this.db(this.tableName).select(...this.defaultFields).where('parent_id', id), pagination);
page.items = await this.processFileLinks(page.items);
return page;
}
private async childrenIds(id: string): Promise<string[]> {

View File

@@ -1,6 +1,6 @@
import { createUserAndSession, beforeAllDb, afterAllTests, beforeEachDb, models, checkThrowAsync } from '../utils/testing/testUtils';
import { ShareType } from '../db';
import { ErrorBadRequest, ErrorNotFound } from '../utils/errors';
import { ShareType } from '../db';
describe('ShareModel', function() {
@@ -26,10 +26,10 @@ describe('ShareModel', function() {
let error = null;
error = await checkThrowAsync(async () => await models().share({ userId: user.id }).add(20 as ShareType, file.id));
error = await checkThrowAsync(async () => await models().share({ userId: user.id }).createShare(20 as ShareType, file.id));
expect(error instanceof ErrorBadRequest).toBe(true);
error = await checkThrowAsync(async () => await models().share({ userId: user.id }).add(ShareType.Link, 'doesntexist'));
error = await checkThrowAsync(async () => await models().share({ userId: user.id }).createShare(ShareType.Link, 'doesntexist'));
expect(error instanceof ErrorNotFound).toBe(true);
});

View File

@@ -1,4 +1,4 @@
import { Share, ShareType, Uuid } from '../db';
import { File, Share, ShareType, Uuid } from '../db';
import { ErrorBadRequest } from '../utils/errors';
import { setQueryParameters } from '../utils/urlUtils';
import BaseModel, { ValidateOptions } from './BaseModel';
@@ -15,12 +15,14 @@ export default class ShareModel extends BaseModel<Share> {
return share;
}
public async add(type: ShareType, path: string): Promise<Share> {
const fileId: Uuid = await this.models().file({ userId: this.userId }).pathToFileId(path);
public async createShare(shareType: ShareType, path: string): Promise<Share> {
const file: File = await this.models().file({ userId: this.userId }).pathToFile(path);
if (file.source_file_id) throw new ErrorBadRequest('A shared file cannot be shared again');
const toSave: Share = {
type: type,
file_id: fileId,
type: shareType,
file_id: file.id,
owner_id: this.userId,
};

View File

@@ -0,0 +1,39 @@
import { createUserAndSession, beforeAllDb, afterAllTests, beforeEachDb, models, createFile } from '../utils/testing/testUtils';
import { shareWithUserAndAccept } from '../utils/testing/shareApiUtils';
describe('ShareUserModel', function() {
beforeAll(async () => {
await beforeAllDb('ShareUserModel');
});
afterAll(async () => {
await afterAllTests();
});
beforeEach(async () => {
await beforeEachDb();
});
test('should get the list of linked user IDs', async function() {
const { user: user1, session: session1 } = await createUserAndSession(1);
const { user: user2, session: session2 } = await createUserAndSession(2);
const { user: user3, session: session3 } = await createUserAndSession(3);
const file1a = await createFile(user1.id, 'root:/test1a.txt:', 'test1a');
const file1b = await createFile(user1.id, 'root:/test1b.txt:', 'test1b');
const file2 = await createFile(user2.id, 'root:/test2.txt:', 'test2');
await shareWithUserAndAccept(session1.id, user1, session3.id, user3, file1a);
await shareWithUserAndAccept(session1.id, user1, session3.id, user3, file1b);
await shareWithUserAndAccept(session2.id, user1, session3.id, user3, file2);
const userIds = await models().shareUser({ userId: user3.id }).linkedUserIds();
userIds.sort();
const expectedUserIds = [user1.id, user2.id];
expectedUserIds.sort();
expect(userIds).toEqual(expectedUserIds);
});
});

View File

@@ -0,0 +1,77 @@
import { File, ShareUser, Uuid } from '../db';
import { ErrorNotFound } from '../utils/errors';
import BaseModel from './BaseModel';
export default class ShareUserModel extends BaseModel<ShareUser> {
public get tableName(): string {
return 'share_users';
}
public async loadByShareIdAndUser(shareId: Uuid, userId: Uuid): Promise<ShareUser> {
const link: ShareUser = {
share_id: shareId,
user_id: userId,
};
return this.db(this.tableName).where(link).first();
}
public async addByEmail(shareId: Uuid, userEmail: string): Promise<ShareUser> {
// TODO: check that user can access this share
const share = await this.models().share({ userId: this.userId }).load(shareId);
if (!share) throw new ErrorNotFound(`No such share: ${shareId}`);
const user = await this.models().user().loadByEmail(userEmail);
if (!user) throw new ErrorNotFound(`No such user: ${userEmail}`);
return this.save({
share_id: shareId,
user_id: user.id,
});
}
public async accept(shareId: Uuid, userId: Uuid, accept: boolean = true): Promise<File> {
const shareUser = await this.loadByShareIdAndUser(shareId, userId);
if (!shareUser) throw new ErrorNotFound(`File has not been shared with this user: ${shareId} / ${userId}`);
const share = await this.models().share().load(shareId);
if (!share) throw new ErrorNotFound(`No such share: ${shareId}`);
const sourceFile = await this.models().file({ userId: share.owner_id }).load(share.file_id);
const rootId = await this.models().file({ userId: this.userId }).userRootFileId();
return this.withTransaction<File>(async () => {
await this.save({ ...shareUser, is_accepted: accept ? 1 : 0 });
const file: File = {
owner_id: userId,
source_file_id: share.file_id,
parent_id: rootId,
name: sourceFile.name,
};
return this.models().file({ userId }).save(file);
});
}
public async reject(shareId: Uuid, userId: Uuid) {
await this.accept(shareId, userId, false);
}
// Returns the users who have shared files with the current user
public async linkedUserIds(): Promise<Uuid[]> {
const fileSubQuery = this
.db('files')
.select('source_file_id')
.where('source_file_id', '!=', '')
.andWhere('owner_id', '=', this.userId);
return this
.db('files')
.distinct('owner_id')
.whereIn('files.id', fileSubQuery)
.pluck('owner_id');
}
}

View File

@@ -64,19 +64,23 @@ import SessionModel from './SessionModel';
import ChangeModel from './ChangeModel';
import NotificationModel from './NotificationModel';
import ShareModel from './ShareModel';
import ShareUserModel from './ShareUserModel';
import Applications from '../services/Applications';
export class Models {
private db_: DbConnection;
private baseUrl_: string;
private apps_:Applications;
public constructor(db: DbConnection, baseUrl: string) {
public constructor(db: DbConnection, baseUrl: string, apps:Applications) {
this.db_ = db;
this.baseUrl_ = baseUrl;
this.apps_ = apps;
}
public file(options: ModelOptions = null) {
return new FileModel(this.db_, newModelFactory, this.baseUrl_, options);
return new FileModel(this.db_, newModelFactory, this.baseUrl_, { ...options, apps: this.apps_ });
}
public user(options: ModelOptions = null) {
@@ -107,6 +111,10 @@ export class Models {
return new ShareModel(this.db_, newModelFactory, this.baseUrl_, options);
}
public shareUser(options: ModelOptions = null) {
return new ShareUserModel(this.db_, newModelFactory, this.baseUrl_, options);
}
}
export default function newModelFactory(db: DbConnection, baseUrl: string): Models {

View File

@@ -60,8 +60,23 @@ router.put('api/files/:id/content', async (path: SubPath, ctx: AppContext) => {
// https://github.com/laurent22/joplin/issues/4402
const buffer = result?.files?.file ? await fs.readFile(result.files.file.path) : Buffer.alloc(0);
const file: File = await fileModel.pathToFile(fileId, { mustExist: false, returnFullEntity: false });
file.content = buffer;
const parsedFile: File = await fileModel.pathToFile(fileId, { mustExist: false });
const isNewFile = !parsedFile.id;
const file: File = {
name: parsedFile.name,
content: buffer,
source_file_id: 'source_file_id' in parsedFile ? parsedFile.source_file_id : '',
};
if (!isNewFile) {
file.id = parsedFile.id;
} else {
file.name = parsedFile.name;
if ('parent_id' in parsedFile) file.parent_id = parsedFile.parent_id;
}
return fileModel.toApiOutput(await fileModel.save(file, { validationRules: { mustBeFile: true } }));
});
@@ -70,8 +85,12 @@ router.del('api/files/:id/content', async (path: SubPath, ctx: AppContext) => {
const fileId = path.id;
const file: File = await fileModel.pathToFile(fileId, { mustExist: false, returnFullEntity: false });
if (!file) return;
file.content = Buffer.alloc(0);
await fileModel.save(file, { validationRules: { mustBeFile: true } });
await fileModel.save({
id: file.id,
content: Buffer.alloc(0),
source_file_id: file.source_file_id,
}, { validationRules: { mustBeFile: true } });
});
router.get('api/files/:id/delta', async (path: SubPath, ctx: AppContext) => {

View File

@@ -0,0 +1,24 @@
import { ErrorBadRequest, ErrorNotFound } from '../../utils/errors';
import { bodyFields } from '../../utils/requestUtils';
import { SubPath } from '../../utils/routeUtils';
import Router from '../../utils/Router';
import { AppContext } from '../../utils/types';
const router = new Router();
router.patch('api/share_users/:id', async (path: SubPath, ctx: AppContext) => {
// TODO: check permissions
const shareUserModel = ctx.models.shareUser({ userId: ctx.owner.id });
const shareUser = await shareUserModel.load(path.id);
if (!shareUser) throw new ErrorNotFound();
const body = await bodyFields(ctx.req);
if ('is_accepted' in body) {
return shareUserModel.accept(shareUser.share_id, shareUser.user_id, !!body.is_accepted);
} else {
throw new ErrorBadRequest('Only setting is_accepted is supported');
}
});
export default router;

View File

@@ -1,7 +1,12 @@
import { ShareType } from '../../db';
import { ChangeType, File, Share, ShareType, ShareUser } from '../../db';
import { putFileContent, testFilePath } from '../../utils/testing/fileApiUtils';
import { getShareContext, postShareContext } from '../../utils/testing/shareApiUtils';
import { beforeAllDb, afterAllTests, beforeEachDb, createUserAndSession } from '../../utils/testing/testUtils';
import { beforeAllDb, afterAllTests, beforeEachDb, createUserAndSession, models, createFile, updateFile, checkThrowAsync } from '../../utils/testing/testUtils';
import { postApiC, postApi, getApiC, patchApi, getApi } from '../../utils/testing/apiUtils';
import { PaginatedFiles } from '../../models/FileModel';
import { PaginatedChanges } from '../../models/ChangeModel';
import { shareWithUserAndAccept } from '../../utils/testing/shareApiUtils';
import { msleep } from '../../utils/time';
import { ErrorBadRequest } from '../../utils/errors';
describe('api_shares', function() {
@@ -17,20 +22,222 @@ describe('api_shares', function() {
await beforeEachDb();
});
test('should share a file', async function() {
const { session } = await createUserAndSession(1, false);
test('should share a file by link', async function() {
const { session } = await createUserAndSession(1);
const file = await putFileContent(session.id, 'root:/photo.jpg:', testFilePath());
const context = await postShareContext(session.id, 'root:/photo.jpg:');
const context = await postApiC(session.id, 'shares', {
type: ShareType.Link,
file_id: 'root:/photo.jpg:',
});
expect(context.response.status).toBe(200);
const shareId = context.response.body.id;
{
const context = await getShareContext(shareId);
const context = await getApiC(session.id, `shares/${shareId}`);
expect(context.response.body.id).toBe(shareId);
expect(context.response.body.file_id).toBe(file.id);
expect(context.response.body.type).toBe(ShareType.Link);
}
});
test('should share a file with another user', async function() {
const { user: user1, session: session1 } = await createUserAndSession(1);
const { user: user2, session: session2 } = await createUserAndSession(2);
await createFile(user1.id, 'root:/test.txt:', 'created by sharer');
// ----------------------------------------------------------------
// Create the file share object
// ----------------------------------------------------------------
const share = await postApi<Share>(session1.id, 'shares', {
type: ShareType.App,
file_id: 'root:/test.txt:',
});
// ----------------------------------------------------------------
// Send the share to a user
// ----------------------------------------------------------------
let shareUser = await postApi(session1.id, `shares/${share.id}/users`, {
email: user2.email,
}) as ShareUser;
shareUser = await models().shareUser().load(shareUser.id);
expect(shareUser.share_id).toBe(share.id);
expect(shareUser.user_id).toBe(user2.id);
expect(shareUser.is_accepted).toBe(0);
// ----------------------------------------------------------------
// On the sharee side, accept the share
// ----------------------------------------------------------------
await patchApi<ShareUser>(session2.id, `share_users/${shareUser.id}`, { is_accepted: 1 });
{
shareUser = await models().shareUser().load(shareUser.id);
expect(shareUser.is_accepted).toBe(1);
}
// ----------------------------------------------------------------
// On the sharee side, check that the file is present
// and with the right content.
// ----------------------------------------------------------------
const results = await getApi<PaginatedFiles>(session2.id, 'files/root/children');
expect(results.items.length).toBe(1);
expect(results.items[0].name).toBe('test.txt');
const fileContent = await getApi<Buffer>(session2.id, 'files/root:/test.txt:/content');
expect(fileContent.toString()).toBe('created by sharer');
// ----------------------------------------------------------------
// If file is changed by sharee, sharer should see the change too
// ----------------------------------------------------------------
{
await updateFile(user2.id, 'root:/test.txt:', 'modified by sharee');
const fileContent = await getApi<Buffer>(session1.id, 'files/root:/test.txt:/content');
expect(fileContent.toString()).toBe('modified by sharee');
}
});
test('should get updated time of shared file', async function() {
// If sharer changes the file, sharee should see the updated_time of the sharer file.
const { user: user1, session: session1 } = await createUserAndSession(1);
const { user: user2, session: session2 } = await createUserAndSession(2);
let { sharerFile, shareeFile } = await shareWithUserAndAccept(session1.id, user1, session2.id, user2);
await msleep(1);
await updateFile(user1.id, sharerFile.id, 'content modified');
sharerFile = await models().file({ userId: user1.id }).load(sharerFile.id);
// Check files/:id
shareeFile = await getApi<File>(session2.id, `files/${shareeFile.id}`);
expect(shareeFile.updated_time).toBe(sharerFile.updated_time);
// Check files/:id/children
const rootFileId2 = await models().file({ userId: user2.id }).userRootFileId();
const page = await getApi<PaginatedFiles>(session2.id, `files/${rootFileId2}/children`);
expect(page.items[0].updated_time).toBe(sharerFile.updated_time);
});
test('should not share an already shared file', async function() {
const { user: user1, session: session1 } = await createUserAndSession(1);
const { user: user2, session: session2 } = await createUserAndSession(2);
const { user: user3, session: session3 } = await createUserAndSession(3);
const { shareeFile } = await shareWithUserAndAccept(session1.id, user1, session2.id, user2);
const error = await checkThrowAsync(async () => shareWithUserAndAccept(session2.id, user2, session3.id, user3, shareeFile));
expect(error.httpCode).toBe(ErrorBadRequest.httpCode);
});
test('should see delta changes for linked files', async function() {
const { user: user1, session: session1 } = await createUserAndSession(1);
const { user: user2, session: session2 } = await createUserAndSession(2);
const rootDirId1 = await models().file({ userId: user1.id }).userRootFileId();
const rootDirId2 = await models().file({ userId: user2.id }).userRootFileId();
const { sharerFile, shareeFile } = await shareWithUserAndAccept(session1.id, user1, session2.id, user2);
let cursor1: string = null;
let cursor2: string = null;
{
const page1 = await getApi<PaginatedChanges>(session1.id, `files/${rootDirId1}/delta`);
expect(page1.items.length).toBe(1);
expect(page1.items[0].item.id).toBe(sharerFile.id);
expect(page1.items[0].type).toBe(ChangeType.Create);
cursor1 = page1.cursor;
const page2 = await getApi<PaginatedChanges>(session2.id, `files/${rootDirId2}/delta`);
expect(page2.items.length).toBe(1);
expect(page2.items[0].item.id).toBe(shareeFile.id);
expect(page2.items[0].type).toBe(ChangeType.Create);
cursor2 = page2.cursor;
}
// --------------------------------------------------------------------
// If file is changed on sharer side, sharee should see the changes
// --------------------------------------------------------------------
await msleep(1);
await updateFile(user1.id, sharerFile.id, 'from sharer');
{
const page1 = await getApi<PaginatedChanges>(session1.id, `files/${rootDirId1}/delta`, { query: { cursor: cursor1 } });
expect(page1.items.length).toBe(1);
expect(page1.items[0].item.id).toBe(sharerFile.id);
expect(page1.items[0].type).toBe(ChangeType.Update);
cursor1 = page1.cursor;
const page2 = await getApi<PaginatedChanges>(session2.id, `files/${rootDirId2}/delta`, { query: { cursor: cursor2 } });
expect(page2.items.length).toBe(1);
expect(page2.items[0].item.id).toBe(shareeFile.id);
expect(page2.items[0].type).toBe(ChangeType.Update);
expect(page2.items[0].item.updated_time).toBe(page1.items[0].item.updated_time);
cursor2 = page2.cursor;
}
// --------------------------------------------------------------------
// If file is changed on sharee side, sharer should see the changes
// --------------------------------------------------------------------
await msleep(1);
await updateFile(user2.id, shareeFile.id, 'from sharee');
{
const page1 = await getApi<PaginatedChanges>(session1.id, `files/${rootDirId1}/delta`, { query: { cursor: cursor1 } });
expect(page1.items.length).toBe(1);
expect(page1.items[0].item.id).toBe(sharerFile.id);
expect(page1.items[0].type).toBe(ChangeType.Update);
cursor1 = page1.cursor;
const page2 = await getApi<PaginatedChanges>(session2.id, `files/${rootDirId2}/delta`, { query: { cursor: cursor2 } });
expect(page2.items.length).toBe(1);
expect(page2.items[0].item.id).toBe(shareeFile.id);
expect(page2.items[0].type).toBe(ChangeType.Update);
cursor2 = page2.cursor;
// The updated_time properties don't necessarily match because first
// the sharer's file content is updated, and then the sharee's file
// metadata may be updated too.
// expect(page1.items[0].item.updated_time).toBe(page2.items[0].item.updated_time);
}
});
test('should see delta changes when a third user joins in', async function() {
// - User 1 shares a file with User 2
// - User 2 syncs and get a new delta cursor C2
// - User 3 shares a file with User 2
// - User 2 syncs **starting from C2**
// => The new changes from User 3 share should be visible
const { user: user1, session: session1 } = await createUserAndSession(1);
const { user: user2, session: session2 } = await createUserAndSession(2);
const { user: user3, session: session3 } = await createUserAndSession(3);
const rootDirId2 = await models().file({ userId: user2.id }).userRootFileId();
await shareWithUserAndAccept(session1.id, user1, session2.id, user2);
let cursor = null;
{
const page = await getApi<PaginatedChanges>(session2.id, `files/${rootDirId2}/delta`);
cursor = page.cursor;
}
const file3 = await createFile(user3.id, 'root:/test3.txt:', 'from user 3');
const { shareeFile } = await shareWithUserAndAccept(session3.id, user3, session2.id, user2, file3);
{
const page = await getApi<PaginatedChanges>(session2.id, `files/${rootDirId2}/delta`, { query: { cursor } });
cursor = page.cursor;
expect(page.items.length).toBe(1);
expect(page.items[0].type).toBe(ChangeType.Create);
expect(page.items[0].item.id).toBe(shareeFile.id);
}
});
});

View File

@@ -1,5 +1,5 @@
import { ErrorNotFound } from '../../utils/errors';
import { Share } from '../../db';
import { Share, User } from '../../db';
import { bodyFields, ownerRequired } from '../../utils/requestUtils';
import { SubPath } from '../../utils/routeUtils';
import Router from '../../utils/Router';
@@ -14,7 +14,14 @@ router.post('api/shares', async (_path: SubPath, ctx: AppContext) => {
const shareModel = ctx.models.share({ userId: ctx.owner.id });
const share: Share = shareModel.fromApiInput(await bodyFields(ctx.req)) as Share;
return shareModel.add(share.type, share.file_id);
return shareModel.createShare(share.type, share.file_id);
});
router.post('api/shares/:id/users', async (path: SubPath, ctx: AppContext) => {
ownerRequired(ctx);
const user: User = await bodyFields(ctx.req) as User;
return ctx.models.shareUser({ userId: ctx.owner.id }).addByEmail(path.id, user.email);
});
router.get('api/shares/:id', async (path: SubPath, ctx: AppContext) => {

View File

@@ -1,4 +1,5 @@
import { NoteEntity } from '@joplin/lib/services/database/types';
import { ShareType } from '../../db';
import routeHandler from '../../middleware/routeHandler';
import { putFileContent, testFilePath, postDirectory } from '../../utils/testing/fileApiUtils';
import { postShare } from '../../utils/testing/shareApiUtils';
@@ -92,7 +93,7 @@ describe('shares.joplin', function() {
body: 'Testing body',
}));
const share = await postShare(session.id, 'root:/b39dadd7a63742bebf3125fd2a9286d4.md:');
const share = await postShare(session.id, ShareType.Link, 'root:/b39dadd7a63742bebf3125fd2a9286d4.md:');
const bodyHtml = await getShareContent(share.id);
@@ -109,7 +110,7 @@ describe('shares.joplin', function() {
body: '$\\sqrt{3x-1}+(1+x)^2$',
}));
const share = await postShare(session.id, 'root:/b39dadd7a63742bebf3125fd2a9286d4.md:');
const share = await postShare(session.id, ShareType.Link, 'root:/b39dadd7a63742bebf3125fd2a9286d4.md:');
const bodyHtml = await getShareContent(share.id);
@@ -126,7 +127,7 @@ describe('shares.joplin', function() {
await putFileContent(session.id, 'root:/.resource/96765a68655f4446b3dbad7d41b6566e:', testFilePath());
await createFile(user.id, 'root:/96765a68655f4446b3dbad7d41b6566e.md:', resourceContents.image);
const share = await postShare(session.id, 'root:/b39dadd7a63742bebf3125fd2a9286d4.md:');
const share = await postShare(session.id, ShareType.Link, 'root:/b39dadd7a63742bebf3125fd2a9286d4.md:');
const bodyHtml = await getShareContent(share.id) as string;
@@ -156,7 +157,7 @@ describe('shares.joplin', function() {
await createFile(user.id, `root:/${noteId}.md:`, makeNoteSerializedBody({
body: '![missing](:/531a2a839a2c493a88c45e39c6cb9ed4)',
}));
const share = await postShare(session.id, `root:/${noteId}.md:`);
const share = await postShare(session.id, ShareType.Link, `root:/${noteId}.md:`);
await expectNotThrow(async () => getShareContent(share.id));
}
@@ -165,7 +166,7 @@ describe('shares.joplin', function() {
await createFile(user.id, `root:/${noteId}.md:`, makeNoteSerializedBody({
body: '[missing too](:/531a2a839a2c493a88c45e39c6cb9ed4)',
}));
const share = await postShare(session.id, `root:/${noteId}.md:`);
const share = await postShare(session.id, ShareType.Link, `root:/${noteId}.md:`);
await expectNotThrow(async () => getShareContent(share.id));
}
});

View File

@@ -33,7 +33,6 @@ router.get('shares/:id', async (path: SubPath, ctx: AppContext) => {
const file = await fileModel.loadWithContent(share.file_id, { skipPermissionCheck: true });
if (!file) throw new ErrorNotFound();
const result = await renderFile(ctx, file, share);
ctx.response.body = result.body;

View File

@@ -4,6 +4,7 @@ import apiSessions from './api/sessions';
import apiPing from './api/ping';
import apiFiles from './api/files';
import apiShares from './api/shares';
import apiShareUsers from './api/share_users';
import indexLogin from './index/login';
import indexLogout from './index/logout';
@@ -13,6 +14,8 @@ import indexFiles from './index/files';
import indexNotifications from './index/notifications';
import indexShares from './index/shares';
import appJoplinNotes from '../apps/joplin/routes/notes';
import defaultRoute from './default';
const routes: Routers = {
@@ -20,6 +23,7 @@ const routes: Routers = {
'api/sessions': apiSessions,
'api/files': apiFiles,
'api/shares': apiShares,
'api/share_users': apiShareUsers,
'login': indexLogin,
'logout': indexLogout,
@@ -29,6 +33,8 @@ const routes: Routers = {
'notifications': indexNotifications,
'shares': indexShares,
'apps/joplin/notes': appJoplinNotes,
'': defaultRoute,
};

View File

@@ -1,5 +1,6 @@
import ApplicationJoplin from '../apps/joplin/Application';
import config from '../config';
import { File } from '../db';
import { Models } from '../models/factory';
export default class Applications {
@@ -21,6 +22,11 @@ export default class Applications {
return this.joplin_;
}
public async processSharedContentForSave(file:File):Promise<File> {
const app = await this.joplin();
return app.processSharedContentForSave(file);
}
public async localFileFromUrl(url: string): Promise<string> {
if (url.indexOf('apps/') !== 0) return null;

View File

@@ -30,6 +30,7 @@ const config = {
'main.changes': 'WithDates, WithUuid',
'main.notifications': 'WithDates, WithUuid',
'main.shares': 'WithDates, WithUuid',
'main.share_users': 'WithDates, WithUuid',
},
};

View File

@@ -1,7 +1,7 @@
// For explanation of the setPrototypeOf call, see:
// https://github.com/Microsoft/TypeScript-wiki/blob/master/Breaking-Changes.md#extending-built-ins-like-error-array-and-map-may-no-longer-work
class ApiError extends Error {
export class ApiError extends Error {
public static httpCode: number = 400;
public httpCode: number;

View File

@@ -35,7 +35,7 @@ export async function formParse(req: any): Promise<FormParseResult> {
});
}
export async function bodyFields(req: any): Promise<BodyFields> {
export async function bodyFields(req: any/* , filter:string[] = null*/): Promise<BodyFields> {
// Formidable needs the content-type to be 'application/json' so on our side
// we explicitely set it to that. However save the previous value so that it
// can be restored.
@@ -47,7 +47,18 @@ export async function bodyFields(req: any): Promise<BodyFields> {
const form = await formParse(req);
if (previousContentType) req.headers['content-type'] = previousContentType;
return form.fields;
// if (filter) {
// const output:BodyFields = {};
// Object.keys(form.fields).forEach(f => {
// if (filter.includes(f)) output[f] = form.fields[f];
// });
// return output;
// } else {
// return form.fields;
// }
}
export function ownerRequired(ctx: AppContext) {

View File

@@ -1,7 +1,7 @@
import { File, ItemAddressingType } from '../db';
import { ErrorBadRequest } from './errors';
import { ErrorBadRequest, ErrorForbidden, ErrorNotFound } from './errors';
import Router from './Router';
import { AppContext } from './types';
import { AppContext, HttpMethod } from './types';
const { ltrimSlashes, rtrimSlashes } = require('@joplin/lib/path-utils');
@@ -151,16 +151,33 @@ export function parseSubPath(basePath: string, p: string): SubPath {
return output;
}
export function routeResponseFormat(match: MatchedRoute, context: AppContext): RouteResponseFormat {
const rawPath = context.path;
if (match && match.route.responseFormat) return match.route.responseFormat;
export function routeResponseFormat(context: AppContext): RouteResponseFormat {
// const rawPath = context.path;
// if (match && match.route.responseFormat) return match.route.responseFormat;
let path = rawPath;
if (match) path = match.basePath ? match.basePath : match.subPath.raw;
// let path = rawPath;
// if (match) path = match.basePath ? match.basePath : match.subPath.raw;
const path = context.path;
return path.indexOf('api') === 0 || path.indexOf('/api') === 0 ? RouteResponseFormat.Json : RouteResponseFormat.Html;
}
export async function execRequest(routes:Routers, ctx:AppContext, path:string = null) {
path = path || ctx.path;
const match = findMatchingRoute(ctx.path, routes);
if (!match) throw new ErrorNotFound();
const routeHandler = match.route.findEndPoint(ctx.request.method as HttpMethod, match.subPath.schema);
// This is a generic catch-all for all private end points - if we
// couldn't get a valid session, we exit now. Individual end points
// might have additional permission checks depending on the action.
if (!match.route.public && !ctx.owner) throw new ErrorForbidden();
return routeHandler(match.subPath, ctx);
}
// In a path such as "/api/files/SOME_ID/content" we want to find:
// - The base path: "api/files"
// - The ID: "SOME_ID"
@@ -172,10 +189,16 @@ export function findMatchingRoute(path: string, routes: Routers): MatchedRoute {
// an empty string. So for example we now have ['api', 'files', 'SOME_ID', 'content'].
splittedPath.splice(0, 1);
let namespace = '';
if (splittedPath[0] === 'apps') {
namespace = splittedPath.splice(0, 2).join('/');
}
// Paths such as "/api/files/:id" will be processed here
if (splittedPath.length >= 2) {
// Create the base path, eg. "api/files", to match it to one of the
// routes.s
const basePath = `${splittedPath[0]}/${splittedPath[1]}`;
// routes.
const basePath = `${namespace ? `${namespace}/` : ''}${splittedPath[0]}/${splittedPath[1]}`;
if (routes[basePath]) {
// Remove the base path from the array so that parseSubPath() can
// extract the ID and link from the URL. So the array will contain
@@ -189,16 +212,19 @@ export function findMatchingRoute(path: string, routes: Routers): MatchedRoute {
}
}
// Paths such as "/users/:id" or "/apps/joplin/notes/:id" will get here
const basePath = splittedPath[0];
if (routes[basePath]) {
const basePathNS = (namespace ? `${namespace}/` : '') + basePath;
if (routes[basePathNS]) {
splittedPath.splice(0, 1);
return {
route: routes[basePath],
route: routes[basePathNS],
basePath: basePath,
subPath: parseSubPath(basePath, `/${splittedPath.join('/')}`),
};
}
// Default routes - to process CSS or JS files for example
if (routes['']) {
return {
route: routes[''],

View File

@@ -4,11 +4,13 @@ import { DbConnection } from '../db';
import newModelFactory from '../models/factory';
import Applications from '../services/Applications';
import { AppContext, Env } from './types';
import routes from '../routes/routes';
export default async function(appContext: AppContext, env: Env, dbConnection: DbConnection, appLogger: ()=> LoggerWrapper) {
appContext.env = env;
appContext.db = dbConnection;
appContext.models = newModelFactory(appContext.db, config().baseUrl);
appContext.apps = new Applications(appContext.models);
appContext.models = newModelFactory(appContext.db, config().baseUrl, appContext.apps);
appContext.appLogger = appLogger;
appContext.routes = routes;
}

View File

@@ -0,0 +1,67 @@
import { AppContext } from '../types';
import routeHandler from '../../middleware/routeHandler';
import { AppContextTestOptions, checkContextError, koaAppContext } from './testUtils';
interface ExecRequestOptions {
filePath?: string;
query?: Record<string, any>;
}
export async function putApiC(sessionId: string, path: string, body: Record<string, any> = null, options: ExecRequestOptions = null): Promise<AppContext> {
return execApiC(sessionId, 'PUT', path, body, options);
}
export async function putApi<T>(sessionId: string, path: string, body: Record<string, any> = null, options: ExecRequestOptions = null): Promise<T> {
return execApi<T>(sessionId, 'PUT', path, body, options);
}
export async function patchApiC(sessionId: string, path: string, body: Record<string, any> = null, options: ExecRequestOptions = null): Promise<AppContext> {
return execApiC(sessionId, 'PATCH', path, body, options);
}
export async function patchApi<T>(sessionId: string, path: string, body: Record<string, any> = null, options: ExecRequestOptions = null): Promise<T> {
return execApi<T>(sessionId, 'PATCH', path, body, options);
}
export async function getApiC(sessionId: string, path: string, options: ExecRequestOptions = null): Promise<AppContext> {
return execApiC(sessionId, 'GET', path, null, options);
}
export async function getApi<T>(sessionId: string, path: string, options: ExecRequestOptions = null): Promise<T> {
return execApi<T>(sessionId, 'GET', path, null, options);
}
export async function postApiC(sessionId: string, path: string, body: Record<string, any> = null, options: ExecRequestOptions = null): Promise<AppContext> {
return execApiC(sessionId, 'POST', path, body, options);
}
export async function postApi<T>(sessionId: string, path: string, body: Record<string, any> = null, options: ExecRequestOptions = null): Promise<T> {
return execApi<T>(sessionId, 'POST', path, body, options);
}
export async function execApiC(sessionId: string, method: string, path: string, body: Record<string, any> = null, options: ExecRequestOptions = null): Promise<AppContext> {
options = options || {};
const appContextOptions: AppContextTestOptions = {
sessionId,
request: {
method,
url: `/api/${path}`,
},
};
if (body) appContextOptions.request.body = body;
if (options.filePath) appContextOptions.request.files = { file: { path: options.filePath } };
if (options.query) appContextOptions.request.query = options.query;
const context = await koaAppContext(appContextOptions);
await routeHandler(context);
return context;
}
export async function execApi<T>(sessionId: string, method: string, url: string, body: Record<string, any> = null, options: ExecRequestOptions = null): Promise<T> {
const context = await execApiC(sessionId, method, url, body, options);
await checkContextError(context);
return context.response.body as T;
}

View File

@@ -1,9 +1,40 @@
import { Share, ShareType, Uuid } from '../../db';
import { File, Share, ShareType, ShareUser, User, Uuid } from '../../db';
import routeHandler from '../../middleware/routeHandler';
import { AppContext } from '../types';
import { checkContextError, koaAppContext } from './testUtils';
import { patchApi, postApi } from './apiUtils';
import { checkContextError, createFile, koaAppContext, models } from './testUtils';
export async function postShareContext(sessionId: string, itemId: Uuid): Promise<AppContext> {
// Handles the whole process of:
//
// - User 1 creates a file (optionally)
// - User 1 creates a file share for it
// - User 1 shares this with user 2
// - User 2 accepts the share
//
// The result is that user 2 will have a file linked to user 1's file.
export async function shareWithUserAndAccept(sharerSessionId:string, sharer:User, shareeSessionId:string, sharee:User, file:File = null) {
file = file || await createFile(sharer.id, 'root:/test.txt:', 'testing share');
const share = await postApi<Share>(sharerSessionId, 'shares', {
type: ShareType.App,
file_id: file.id,
});
let shareUser = await postApi(sharerSessionId, `shares/${share.id}/users`, {
email: sharee.email,
}) as ShareUser;
shareUser = await models().shareUser().load(shareUser.id);
const shareeFile:File = await patchApi(shareeSessionId, `share_users/${shareUser.id}`, { is_accepted: 1 });
return {
sharerFile: file,
shareeFile: shareeFile,
};
}
export async function postShareContext(sessionId: string, shareType: ShareType, itemId: Uuid): Promise<AppContext> {
const context = await koaAppContext({
sessionId: sessionId,
request: {
@@ -11,7 +42,7 @@ export async function postShareContext(sessionId: string, itemId: Uuid): Promise
url: '/api/shares',
body: {
file_id: itemId,
type: ShareType.Link,
type: shareType,
},
},
});
@@ -19,8 +50,47 @@ export async function postShareContext(sessionId: string, itemId: Uuid): Promise
return context;
}
export async function postShare(sessionId: string, itemId: Uuid): Promise<Share> {
const context = await postShareContext(sessionId, itemId);
export async function postShare(sessionId: string, shareType: ShareType, itemId: Uuid): Promise<Share> {
const context = await postShareContext(sessionId, shareType, itemId);
checkContextError(context);
return context.response.body;
}
export async function postShareUserContext(sessionId: string, shareId: Uuid, userEmail: string): Promise<AppContext> {
const context = await koaAppContext({
sessionId: sessionId,
request: {
method: 'POST',
url: `/api/shares/${shareId}/users`,
body: {
email: userEmail,
},
},
});
await routeHandler(context);
return context;
}
export async function patchShareUserContext(sessionId: string, shareUserId: Uuid, body: ShareUser): Promise<AppContext> {
const context = await koaAppContext({
sessionId: sessionId,
request: {
method: 'PATCH',
url: `/api/share_users/${shareUserId}`,
body: body,
},
});
await routeHandler(context);
return context;
}
export async function patchShareUser(sessionId: string, shareUserId: Uuid, body: ShareUser): Promise<void> {
const context = await patchShareUserContext(sessionId, shareUserId, body);
checkContextError(context);
}
export async function postShareUser(sessionId: string, shareId: Uuid, userEmail: string): Promise<ShareUser> {
const context = await postShareUserContext(sessionId, shareId, userEmail);
checkContextError(context);
return context.response.body;
}

View File

@@ -13,6 +13,7 @@ import * as crypto from 'crypto';
import * as fs from 'fs-extra';
import * as jsdom from 'jsdom';
import setupAppContext from '../setupAppContext';
import { ApiError } from '../errors';
// Takes into account the fact that this file will be inside the /dist directory
// when it runs.
@@ -68,7 +69,7 @@ export async function beforeEachDb() {
await truncateTables(db_);
}
interface AppContextTestOptions {
export interface AppContextTestOptions {
// owner?: User;
sessionId?: string;
request?: any;
@@ -79,6 +80,33 @@ function initGlobalLogger() {
Logger.initializeGlobalLogger(globalLogger);
}
export function msleep(ms: number) {
// It seems setTimeout can sometimes last less time than the provided
// interval:
//
// https://stackoverflow.com/a/50912029/561309
//
// This can cause issues in tests where we expect the actual duration to be
// the same as the provided interval or more, but not less. So the code
// below check that the elapsed time is no less than the provided interval,
// and if it is, it waits a bit longer.
const startTime = Date.now();
return new Promise((resolve) => {
setTimeout(() => {
if (Date.now() - startTime < ms) {
const iid = setInterval(() => {
if (Date.now() - startTime >= ms) {
clearInterval(iid);
resolve(null);
}
}, 2);
} else {
resolve(null);
}
}, ms);
});
}
export async function koaAppContext(options: AppContextTestOptions = null): Promise<AppContext> {
if (!db_) throw new Error('Database must be initialized first');
@@ -210,10 +238,21 @@ export async function createFile(userId: string, path: string, content: string):
return fileModel.load(savedFile.id);
}
export async function updateFile(userId: string, path: string, content: string): Promise<File> {
const fileModel = models().file({ userId });
const file: File = await fileModel.pathToFile(path, { returnFullEntity: true });
await fileModel.save({
id: file.id,
content: Buffer.from(content),
source_file_id: file.source_file_id,
});
return fileModel.load(file.id);
}
export function checkContextError(context: AppContext) {
if (context.response.status >= 400) {
// console.info(context.response.body);
throw new Error(`${context.method} ${context.path} ${JSON.stringify(context.response)}`);
throw new ApiError(`${context.method} ${context.path} ${JSON.stringify(context.response)}`, context.response.status);
}
}

View File

@@ -3,6 +3,7 @@ import * as Koa from 'koa';
import { DbConnection, User, Uuid } from '../db';
import { Models } from '../models/factory';
import Applications from '../services/Applications';
import { Routers } from './routeUtils';
export enum Env {
Dev = 'dev',
@@ -25,6 +26,7 @@ export interface AppContext extends Koa.Context {
notifications: NotificationView[];
owner: User;
apps: Applications;
routes: Routers;
}
export enum DatabaseConfigClient {