You've already forked joplin
mirror of
https://github.com/laurent22/joplin.git
synced 2025-09-05 20:56:22 +02:00
Compare commits
19 Commits
ios-v12.12
...
server_app
Author | SHA1 | Date | |
---|---|---|---|
|
aeb3c4a98d | ||
|
58a464d040 | ||
|
8e13ccb665 | ||
|
6dd14ff04b | ||
|
2022b5bc48 | ||
|
7ade9b2948 | ||
|
4157dad9f1 | ||
|
a088061de9 | ||
|
439d29387f | ||
|
2f15e4db59 | ||
|
0b37e99132 | ||
|
6d41787a29 | ||
|
28fc0374c5 | ||
|
726ee4a574 | ||
|
25e32226ef | ||
|
9efdbf9854 | ||
|
09c95f10f4 | ||
|
a6453af3e5 | ||
|
b8c8178b26 |
Binary file not shown.
Before Width: | Height: | Size: 514 B After Width: | Height: | Size: 0 B |
@@ -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>;
|
|
||||||
}
|
|
@@ -42,9 +42,6 @@ export default class FileApiDriverJoplinServer {
|
|||||||
isDeleted: isDeleted,
|
isDeleted: isDeleted,
|
||||||
};
|
};
|
||||||
|
|
||||||
// TODO - HANDLE DELETED
|
|
||||||
// if (md['.tag'] === 'deleted') output.isDeleted = true;
|
|
||||||
|
|
||||||
return output;
|
return output;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -429,7 +429,7 @@ class Setting extends BaseModel {
|
|||||||
return settings['sync.target'] == SyncTargetRegistry.nameToId('joplinServer');
|
return settings['sync.target'] == SyncTargetRegistry.nameToId('joplinServer');
|
||||||
},
|
},
|
||||||
public: true,
|
public: true,
|
||||||
label: () => _('Joplin Server username'),
|
label: () => _('Joplin Server email'),
|
||||||
},
|
},
|
||||||
'sync.9.password': {
|
'sync.9.password': {
|
||||||
value: '',
|
value: '',
|
||||||
|
@@ -274,4 +274,8 @@ export default class Application extends BaseApplication {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async processSharedContentForSave(file:File):Promise<File> {
|
||||||
|
// console.info('
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
14
packages/server/src/apps/joplin/routes/notes.ts
Normal file
14
packages/server/src/apps/joplin/routes/notes.ts
Normal 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;
|
@@ -268,6 +268,7 @@ export interface File extends WithDates, WithUuid {
|
|||||||
is_directory?: number;
|
is_directory?: number;
|
||||||
is_root?: number;
|
is_root?: number;
|
||||||
parent_id?: Uuid;
|
parent_id?: Uuid;
|
||||||
|
source_file_id?: Uuid;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Change extends WithDates, WithUuid {
|
export interface Change extends WithDates, WithUuid {
|
||||||
@@ -300,6 +301,12 @@ export interface Share extends WithDates, WithUuid {
|
|||||||
type?: ShareType;
|
type?: ShareType;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ShareUser extends WithDates, WithUuid {
|
||||||
|
share_id?: Uuid;
|
||||||
|
user_id?: Uuid;
|
||||||
|
is_accepted?: number;
|
||||||
|
}
|
||||||
|
|
||||||
export const databaseSchema: DatabaseTables = {
|
export const databaseSchema: DatabaseTables = {
|
||||||
users: {
|
users: {
|
||||||
id: { type: 'string' },
|
id: { type: 'string' },
|
||||||
@@ -339,6 +346,7 @@ export const databaseSchema: DatabaseTables = {
|
|||||||
parent_id: { type: 'string' },
|
parent_id: { type: 'string' },
|
||||||
updated_time: { type: 'string' },
|
updated_time: { type: 'string' },
|
||||||
created_time: { type: 'string' },
|
created_time: { type: 'string' },
|
||||||
|
source_file_id: { type: 'string' },
|
||||||
},
|
},
|
||||||
changes: {
|
changes: {
|
||||||
counter: { type: 'number' },
|
counter: { type: 'number' },
|
||||||
@@ -378,5 +386,13 @@ export const databaseSchema: DatabaseTables = {
|
|||||||
updated_time: { type: 'string' },
|
updated_time: { type: 'string' },
|
||||||
created_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
|
// AUTO-GENERATED-TYPES
|
||||||
|
@@ -1,7 +1,6 @@
|
|||||||
import routes from '../routes/routes';
|
import routes from '../routes/routes';
|
||||||
import { ErrorForbidden, ErrorNotFound } from '../utils/errors';
|
import { routeResponseFormat, Response, RouteResponseFormat, execRequest } from '../utils/routeUtils';
|
||||||
import { routeResponseFormat, findMatchingRoute, Response, RouteResponseFormat, MatchedRoute } from '../utils/routeUtils';
|
import { AppContext, Env } from '../utils/types';
|
||||||
import { AppContext, Env, HttpMethod } from '../utils/types';
|
|
||||||
import MustacheService, { isView, View } from '../services/MustacheService';
|
import MustacheService, { isView, View } from '../services/MustacheService';
|
||||||
import config from '../config';
|
import config from '../config';
|
||||||
|
|
||||||
@@ -16,38 +15,21 @@ function mustache(): MustacheService {
|
|||||||
export default async function(ctx: AppContext) {
|
export default async function(ctx: AppContext) {
|
||||||
ctx.appLogger().info(`${ctx.request.method} ${ctx.path}`);
|
ctx.appLogger().info(`${ctx.request.method} ${ctx.path}`);
|
||||||
|
|
||||||
const match: MatchedRoute = null;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const match = findMatchingRoute(ctx.path, routes);
|
const responseObject = await execRequest(routes, ctx);
|
||||||
|
|
||||||
if (match) {
|
if (responseObject instanceof Response) {
|
||||||
let responseObject = null;
|
ctx.response = responseObject.response;
|
||||||
|
} else if (isView(responseObject)) {
|
||||||
const routeHandler = match.route.findEndPoint(ctx.request.method as HttpMethod, match.subPath.schema);
|
ctx.response.status = 200;
|
||||||
|
ctx.response.body = await mustache().renderView(responseObject, {
|
||||||
// This is a generic catch-all for all private end points - if we
|
notifications: ctx.notifications || [],
|
||||||
// couldn't get a valid session, we exit now. Individual end points
|
hasNotifications: !!ctx.notifications && !!ctx.notifications.length,
|
||||||
// might have additional permission checks depending on the action.
|
owner: ctx.owner,
|
||||||
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;
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
throw new ErrorNotFound();
|
ctx.response.status = 200;
|
||||||
|
ctx.response.body = responseObject;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.httpCode >= 400 && error.httpCode < 500) {
|
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;
|
ctx.response.status = error.httpCode ? error.httpCode : 500;
|
||||||
|
|
||||||
const responseFormat = routeResponseFormat(match, ctx);
|
const responseFormat = routeResponseFormat(ctx);
|
||||||
|
|
||||||
if (responseFormat === RouteResponseFormat.Html) {
|
if (responseFormat === RouteResponseFormat.Html) {
|
||||||
ctx.response.set('Content-Type', 'text/html');
|
ctx.response.set('Content-Type', 'text/html');
|
||||||
|
34
packages/server/src/migrations/20210201143859_app_share.ts
Normal file
34
packages/server/src/migrations/20210201143859_app_share.ts
Normal 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');
|
||||||
|
}
|
@@ -3,9 +3,11 @@ import TransactionHandler from '../utils/TransactionHandler';
|
|||||||
import uuidgen from '../utils/uuidgen';
|
import uuidgen from '../utils/uuidgen';
|
||||||
import { ErrorUnprocessableEntity, ErrorBadRequest } from '../utils/errors';
|
import { ErrorUnprocessableEntity, ErrorBadRequest } from '../utils/errors';
|
||||||
import { Models } from './factory';
|
import { Models } from './factory';
|
||||||
|
import Applications from '../services/Applications';
|
||||||
|
|
||||||
export interface ModelOptions {
|
export interface ModelOptions {
|
||||||
userId?: string;
|
userId?: string;
|
||||||
|
apps?: Applications;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SaveOptions {
|
export interface SaveOptions {
|
||||||
@@ -15,6 +17,10 @@ export interface SaveOptions {
|
|||||||
trackChanges?: boolean;
|
trackChanges?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface LoadOptions {
|
||||||
|
fields?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface DeleteOptions {
|
export interface DeleteOptions {
|
||||||
validationRules?: any;
|
validationRules?: any;
|
||||||
}
|
}
|
||||||
@@ -63,6 +69,10 @@ export default abstract class BaseModel<T> {
|
|||||||
return this.options.userId;
|
return this.options.userId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected get apps():Applications {
|
||||||
|
return this.options.apps;
|
||||||
|
}
|
||||||
|
|
||||||
protected get db(): DbConnection {
|
protected get db(): DbConnection {
|
||||||
if (this.transactionHandler_.activeTransaction) return this.transactionHandler_.activeTransaction;
|
if (this.transactionHandler_.activeTransaction) return this.transactionHandler_.activeTransaction;
|
||||||
return this.db_;
|
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
|
// The `name` argument is only for debugging, so that any stuck transaction
|
||||||
// can be more easily identified.
|
// 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 debugTransaction = false;
|
||||||
|
|
||||||
const debugTimerId = debugTransaction ? setTimeout(() => {
|
const debugTimerId = debugTransaction ? setTimeout(() => {
|
||||||
@@ -132,8 +142,10 @@ export default abstract class BaseModel<T> {
|
|||||||
|
|
||||||
if (debugTransaction) console.info('START', name, txIndex);
|
if (debugTransaction) console.info('START', name, txIndex);
|
||||||
|
|
||||||
|
let output: T = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await fn();
|
output = await fn();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await this.transactionHandler_.rollback(txIndex);
|
await this.transactionHandler_.rollback(txIndex);
|
||||||
|
|
||||||
@@ -151,6 +163,7 @@ export default abstract class BaseModel<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await this.transactionHandler_.commit(txIndex);
|
await this.transactionHandler_.commit(txIndex);
|
||||||
|
return output;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async all(): Promise<T[]> {
|
public async all(): Promise<T[]> {
|
||||||
@@ -182,6 +195,7 @@ export default abstract class BaseModel<T> {
|
|||||||
protected async isNew(object: T, options: SaveOptions): Promise<boolean> {
|
protected async isNew(object: T, options: SaveOptions): Promise<boolean> {
|
||||||
if (options.isNew === false) return false;
|
if (options.isNew === false) return false;
|
||||||
if (options.isNew === true) return true;
|
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;
|
return !(object as WithUuid).id;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -249,15 +263,15 @@ export default abstract class BaseModel<T> {
|
|||||||
return toSave;
|
return toSave;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async loadByIds(ids: string[]): Promise<T[]> {
|
public async loadByIds(ids: string[], options: LoadOptions = {}): Promise<T[]> {
|
||||||
if (!ids.length) return [];
|
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');
|
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> {
|
public async delete(id: string | string[]): Promise<void> {
|
||||||
|
@@ -8,6 +8,10 @@ export interface ChangeWithItem {
|
|||||||
type: ChangeType;
|
type: ChangeType;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ChangeWithDestFile extends Change {
|
||||||
|
dest_file_id: Uuid;
|
||||||
|
}
|
||||||
|
|
||||||
export interface PaginatedChanges extends PaginatedResults {
|
export interface PaginatedChanges extends PaginatedResults {
|
||||||
items: ChangeWithItem[];
|
items: ChangeWithItem[];
|
||||||
}
|
}
|
||||||
@@ -68,27 +72,82 @@ export default class ChangeModel extends BaseModel<Change> {
|
|||||||
const directory = await fileModel.load(dirId);
|
const directory = await fileModel.load(dirId);
|
||||||
if (!directory.is_directory) throw new ErrorUnprocessableEntity(`Item with id "${dirId}" is not a directory.`);
|
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
|
// Retrieves the IDs of all the files that have been shared with the
|
||||||
// be possible to do both in one query.
|
// current user.
|
||||||
// https://stackoverflow.com/questions/65348794
|
const linkedFilesQuery = this
|
||||||
const query = this.db(this.tableName)
|
.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([
|
.select([
|
||||||
'counter',
|
'counter',
|
||||||
'id',
|
'id',
|
||||||
'item_id',
|
'item_id',
|
||||||
'item_name',
|
'item_name',
|
||||||
'type',
|
'type',
|
||||||
|
this.db.raw('"" as dest_file_id'),
|
||||||
])
|
])
|
||||||
.where('parent_id', dirId)
|
.where('parent_id', dirId);
|
||||||
.orderBy('counter', 'asc')
|
|
||||||
.limit(pagination.limit);
|
|
||||||
|
|
||||||
|
// 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) {
|
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 compressedChanges = this.compressChanges(changes);
|
||||||
|
|
||||||
const changeWithItems = await this.loadChangeItems(compressedChanges);
|
const changeWithItems = await this.loadChangeItems(compressedChanges);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -102,8 +161,12 @@ export default class ChangeModel extends BaseModel<Change> {
|
|||||||
|
|
||||||
private async loadChangeItems(changes: Change[]): Promise<ChangeWithItem[]> {
|
private async loadChangeItems(changes: Change[]): Promise<ChangeWithItem[]> {
|
||||||
const itemIds = changes.map(c => c.item_id);
|
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[] = [];
|
const output: ChangeWithItem[] = [];
|
||||||
|
|
||||||
@@ -140,7 +203,8 @@ export default class ChangeModel extends BaseModel<Change> {
|
|||||||
const itemChanges: Record<Uuid, Change> = {};
|
const itemChanges: Record<Uuid, Change> = {};
|
||||||
|
|
||||||
for (const change of changes) {
|
for (const change of changes) {
|
||||||
const previous = itemChanges[change.item_id];
|
const itemId = change.item_id;
|
||||||
|
const previous = itemChanges[itemId];
|
||||||
|
|
||||||
if (previous) {
|
if (previous) {
|
||||||
// create - update => create
|
// create - update => create
|
||||||
@@ -153,22 +217,22 @@ export default class ChangeModel extends BaseModel<Change> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (previous.type === ChangeType.Create && change.type === ChangeType.Delete) {
|
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) {
|
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) {
|
if (previous.type === ChangeType.Update && change.type === ChangeType.Delete) {
|
||||||
itemChanges[change.item_id] = change;
|
itemChanges[itemId] = change;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
itemChanges[change.item_id] = change;
|
itemChanges[itemId] = change;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const output = [];
|
const output: Change[] = [];
|
||||||
|
|
||||||
for (const itemId in itemChanges) {
|
for (const itemId in itemChanges) {
|
||||||
output.push(itemChanges[itemId]);
|
output.push(itemChanges[itemId]);
|
||||||
|
@@ -40,8 +40,13 @@ describe('FileModel', function() {
|
|||||||
.concat(Object.keys(tree.folder2))
|
.concat(Object.keys(tree.folder2))
|
||||||
.concat(Object.keys(tree.folder3));
|
.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) {
|
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 path = await fileModel.itemFullPath(file);
|
||||||
const fileBackId: string = await fileModel.pathToFileId(path);
|
const fileBackId: string = await fileModel.pathToFileId(path);
|
||||||
expect(file.id).toBe(fileBackId);
|
expect(file.id).toBe(fileBackId);
|
||||||
|
@@ -23,6 +23,8 @@ export interface PathToFileOptions {
|
|||||||
|
|
||||||
export interface LoadOptions {
|
export interface LoadOptions {
|
||||||
skipPermissionCheck?: boolean;
|
skipPermissionCheck?: boolean;
|
||||||
|
fields?: string[];
|
||||||
|
skipFollowLinks?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default class FileModel extends BaseModel<File> {
|
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> {
|
public async fileByName(parentId: string, name: string, options: LoadOptions = {}): Promise<File> {
|
||||||
const file = await this.db<File>(this.tableName).select(...this.defaultFields).where({
|
const file = await this.db<File>(this.tableName)
|
||||||
parent_id: parentId,
|
.select(...this.defaultFields)
|
||||||
name: name,
|
.where({
|
||||||
}).first();
|
parent_id: parentId,
|
||||||
|
name: name,
|
||||||
|
})
|
||||||
|
.first();
|
||||||
|
|
||||||
if (!file) return null;
|
if (!file) return null;
|
||||||
|
|
||||||
if (!options.skipPermissionCheck) await this.checkCanReadPermissions(file);
|
if (!options.skipPermissionCheck) await this.checkCanReadPermissions(file);
|
||||||
|
|
||||||
return file;
|
return this.processFileLink(file);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected async validate(object: File, options: ValidateOptions = {}): Promise<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) {
|
if ('name' in file && !file.is_root) {
|
||||||
const existingFile = await this.fileByName(parentId, file.name);
|
const existingFile = await this.fileByName(parentId, file.name);
|
||||||
if (existingFile && options.isNew) 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}"`);
|
if (existingFile && file.id !== existingFile.id) throw new ErrorConflict(`Already a file with name "${file.name}" (2)`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ('name' in file) {
|
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> {
|
public async createRootFile(): Promise<File> {
|
||||||
const existingRootFile = await this.userRootFile();
|
const existingRootFile = await this.userRootFile();
|
||||||
if (existingRootFile) throw new Error(`User ${this.userId} has already a root file`);
|
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();
|
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 permissionModel = this.models().permission();
|
||||||
const permissionGrantedMap = await permissionModel[methodName](fileIds, this.userId);
|
const permissionGrantedMap = await permissionModel[methodName](fileIds, this.userId);
|
||||||
@@ -329,7 +343,7 @@ export default class FileModel extends BaseModel<File> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const fileId in permissionGrantedMap) {
|
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;
|
return output;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mostly makes sense for testing/debugging because the filename would
|
private async processFileLink(file: File): Promise<File> {
|
||||||
// have to globally unique, which is not a requirement.
|
const files = await this.processFileLinks([file]);
|
||||||
public async loadByName(name: string, options: LoadOptions = {}): Promise<File> {
|
return files[0];
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async loadWithContent(id: string, options: LoadOptions = {}): Promise<any> {
|
// If the file is a link to another file, the content of the source if
|
||||||
const file: File = await this.db<File>(this.tableName).select('*').where({ id: id }).first();
|
// assigned to the content of the destination. The updated_time property is
|
||||||
if (!file) return null;
|
// also set to the most recent one among the source and the dest.
|
||||||
if (!options.skipPermissionCheck) await this.checkCanReadPermissions(file);
|
private async processFileLinks(files: File[]): Promise<File[]> {
|
||||||
return 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[]> {
|
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 (!files.length) return [];
|
||||||
if (!options.skipPermissionCheck) await this.checkCanReadPermissions(files);
|
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> {
|
public async load(id: string, options: LoadOptions = {}): Promise<File> {
|
||||||
const file: File = await super.load(id);
|
const files = await this.loadByIds([id], options);
|
||||||
if (!file) return null;
|
return files.length ? files[0] : null;
|
||||||
if (!options.skipPermissionCheck) await this.checkCanReadPermissions(file);
|
|
||||||
return file;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async save(object: File, options: SaveOptions = {}): Promise<File> {
|
public async save(object: File, options: SaveOptions = {}): Promise<File> {
|
||||||
const isNew = await this.isNew(object, options);
|
const isNew = await this.isNew(object, options);
|
||||||
|
|
||||||
const file: File = { ... object };
|
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 (isNew) {
|
||||||
if (!file.parent_id && !file.is_root) file.parent_id = await this.userRootFileId();
|
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;
|
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> {
|
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> {
|
public async childrens(id: string, pagination: Pagination): Promise<PaginatedFiles> {
|
||||||
const parent = await this.load(id);
|
const parent = await this.load(id);
|
||||||
await this.checkCanReadPermissions(parent);
|
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[]> {
|
private async childrenIds(id: string): Promise<string[]> {
|
||||||
|
@@ -1,6 +1,6 @@
|
|||||||
import { createUserAndSession, beforeAllDb, afterAllTests, beforeEachDb, models, checkThrowAsync } from '../utils/testing/testUtils';
|
import { createUserAndSession, beforeAllDb, afterAllTests, beforeEachDb, models, checkThrowAsync } from '../utils/testing/testUtils';
|
||||||
import { ShareType } from '../db';
|
|
||||||
import { ErrorBadRequest, ErrorNotFound } from '../utils/errors';
|
import { ErrorBadRequest, ErrorNotFound } from '../utils/errors';
|
||||||
|
import { ShareType } from '../db';
|
||||||
|
|
||||||
describe('ShareModel', function() {
|
describe('ShareModel', function() {
|
||||||
|
|
||||||
@@ -26,10 +26,10 @@ describe('ShareModel', function() {
|
|||||||
|
|
||||||
let error = null;
|
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);
|
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);
|
expect(error instanceof ErrorNotFound).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@@ -1,4 +1,4 @@
|
|||||||
import { Share, ShareType, Uuid } from '../db';
|
import { File, Share, ShareType, Uuid } from '../db';
|
||||||
import { ErrorBadRequest } from '../utils/errors';
|
import { ErrorBadRequest } from '../utils/errors';
|
||||||
import { setQueryParameters } from '../utils/urlUtils';
|
import { setQueryParameters } from '../utils/urlUtils';
|
||||||
import BaseModel, { ValidateOptions } from './BaseModel';
|
import BaseModel, { ValidateOptions } from './BaseModel';
|
||||||
@@ -15,12 +15,14 @@ export default class ShareModel extends BaseModel<Share> {
|
|||||||
return share;
|
return share;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async add(type: ShareType, path: string): Promise<Share> {
|
public async createShare(shareType: ShareType, path: string): Promise<Share> {
|
||||||
const fileId: Uuid = await this.models().file({ userId: this.userId }).pathToFileId(path);
|
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 = {
|
const toSave: Share = {
|
||||||
type: type,
|
type: shareType,
|
||||||
file_id: fileId,
|
file_id: file.id,
|
||||||
owner_id: this.userId,
|
owner_id: this.userId,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
39
packages/server/src/models/ShareUserModel.test.ts
Normal file
39
packages/server/src/models/ShareUserModel.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
77
packages/server/src/models/ShareUserModel.ts
Normal file
77
packages/server/src/models/ShareUserModel.ts
Normal 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');
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@@ -64,19 +64,23 @@ import SessionModel from './SessionModel';
|
|||||||
import ChangeModel from './ChangeModel';
|
import ChangeModel from './ChangeModel';
|
||||||
import NotificationModel from './NotificationModel';
|
import NotificationModel from './NotificationModel';
|
||||||
import ShareModel from './ShareModel';
|
import ShareModel from './ShareModel';
|
||||||
|
import ShareUserModel from './ShareUserModel';
|
||||||
|
import Applications from '../services/Applications';
|
||||||
|
|
||||||
export class Models {
|
export class Models {
|
||||||
|
|
||||||
private db_: DbConnection;
|
private db_: DbConnection;
|
||||||
private baseUrl_: string;
|
private baseUrl_: string;
|
||||||
|
private apps_:Applications;
|
||||||
|
|
||||||
public constructor(db: DbConnection, baseUrl: string) {
|
public constructor(db: DbConnection, baseUrl: string, apps:Applications) {
|
||||||
this.db_ = db;
|
this.db_ = db;
|
||||||
this.baseUrl_ = baseUrl;
|
this.baseUrl_ = baseUrl;
|
||||||
|
this.apps_ = apps;
|
||||||
}
|
}
|
||||||
|
|
||||||
public file(options: ModelOptions = null) {
|
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) {
|
public user(options: ModelOptions = null) {
|
||||||
@@ -107,6 +111,10 @@ export class Models {
|
|||||||
return new ShareModel(this.db_, newModelFactory, this.baseUrl_, options);
|
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 {
|
export default function newModelFactory(db: DbConnection, baseUrl: string): Models {
|
||||||
|
@@ -60,8 +60,23 @@ router.put('api/files/:id/content', async (path: SubPath, ctx: AppContext) => {
|
|||||||
// https://github.com/laurent22/joplin/issues/4402
|
// https://github.com/laurent22/joplin/issues/4402
|
||||||
const buffer = result?.files?.file ? await fs.readFile(result.files.file.path) : Buffer.alloc(0);
|
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 });
|
const parsedFile: File = await fileModel.pathToFile(fileId, { mustExist: false });
|
||||||
file.content = buffer;
|
|
||||||
|
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 } }));
|
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 fileId = path.id;
|
||||||
const file: File = await fileModel.pathToFile(fileId, { mustExist: false, returnFullEntity: false });
|
const file: File = await fileModel.pathToFile(fileId, { mustExist: false, returnFullEntity: false });
|
||||||
if (!file) return;
|
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) => {
|
router.get('api/files/:id/delta', async (path: SubPath, ctx: AppContext) => {
|
||||||
|
24
packages/server/src/routes/api/share_users.ts
Normal file
24
packages/server/src/routes/api/share_users.ts
Normal 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;
|
@@ -1,7 +1,12 @@
|
|||||||
import { ShareType } from '../../db';
|
import { ChangeType, File, Share, ShareType, ShareUser } from '../../db';
|
||||||
import { putFileContent, testFilePath } from '../../utils/testing/fileApiUtils';
|
import { putFileContent, testFilePath } from '../../utils/testing/fileApiUtils';
|
||||||
import { getShareContext, postShareContext } from '../../utils/testing/shareApiUtils';
|
import { beforeAllDb, afterAllTests, beforeEachDb, createUserAndSession, models, createFile, updateFile, checkThrowAsync } from '../../utils/testing/testUtils';
|
||||||
import { beforeAllDb, afterAllTests, beforeEachDb, createUserAndSession } 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() {
|
describe('api_shares', function() {
|
||||||
|
|
||||||
@@ -17,20 +22,222 @@ describe('api_shares', function() {
|
|||||||
await beforeEachDb();
|
await beforeEachDb();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should share a file', async function() {
|
test('should share a file by link', async function() {
|
||||||
const { session } = await createUserAndSession(1, false);
|
const { session } = await createUserAndSession(1);
|
||||||
const file = await putFileContent(session.id, 'root:/photo.jpg:', testFilePath());
|
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);
|
expect(context.response.status).toBe(200);
|
||||||
const shareId = context.response.body.id;
|
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.id).toBe(shareId);
|
||||||
expect(context.response.body.file_id).toBe(file.id);
|
expect(context.response.body.file_id).toBe(file.id);
|
||||||
expect(context.response.body.type).toBe(ShareType.Link);
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
@@ -1,5 +1,5 @@
|
|||||||
import { ErrorNotFound } from '../../utils/errors';
|
import { ErrorNotFound } from '../../utils/errors';
|
||||||
import { Share } from '../../db';
|
import { Share, User } from '../../db';
|
||||||
import { bodyFields, ownerRequired } from '../../utils/requestUtils';
|
import { bodyFields, ownerRequired } from '../../utils/requestUtils';
|
||||||
import { SubPath } from '../../utils/routeUtils';
|
import { SubPath } from '../../utils/routeUtils';
|
||||||
import Router from '../../utils/Router';
|
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 shareModel = ctx.models.share({ userId: ctx.owner.id });
|
||||||
const share: Share = shareModel.fromApiInput(await bodyFields(ctx.req)) as Share;
|
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) => {
|
router.get('api/shares/:id', async (path: SubPath, ctx: AppContext) => {
|
||||||
|
@@ -1,4 +1,5 @@
|
|||||||
import { NoteEntity } from '@joplin/lib/services/database/types';
|
import { NoteEntity } from '@joplin/lib/services/database/types';
|
||||||
|
import { ShareType } from '../../db';
|
||||||
import routeHandler from '../../middleware/routeHandler';
|
import routeHandler from '../../middleware/routeHandler';
|
||||||
import { putFileContent, testFilePath, postDirectory } from '../../utils/testing/fileApiUtils';
|
import { putFileContent, testFilePath, postDirectory } from '../../utils/testing/fileApiUtils';
|
||||||
import { postShare } from '../../utils/testing/shareApiUtils';
|
import { postShare } from '../../utils/testing/shareApiUtils';
|
||||||
@@ -92,7 +93,7 @@ describe('shares.joplin', function() {
|
|||||||
body: 'Testing body',
|
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);
|
const bodyHtml = await getShareContent(share.id);
|
||||||
|
|
||||||
@@ -109,7 +110,7 @@ describe('shares.joplin', function() {
|
|||||||
body: '$\\sqrt{3x-1}+(1+x)^2$',
|
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);
|
const bodyHtml = await getShareContent(share.id);
|
||||||
|
|
||||||
@@ -126,7 +127,7 @@ describe('shares.joplin', function() {
|
|||||||
await putFileContent(session.id, 'root:/.resource/96765a68655f4446b3dbad7d41b6566e:', testFilePath());
|
await putFileContent(session.id, 'root:/.resource/96765a68655f4446b3dbad7d41b6566e:', testFilePath());
|
||||||
await createFile(user.id, 'root:/96765a68655f4446b3dbad7d41b6566e.md:', resourceContents.image);
|
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;
|
const bodyHtml = await getShareContent(share.id) as string;
|
||||||
|
|
||||||
@@ -156,7 +157,7 @@ describe('shares.joplin', function() {
|
|||||||
await createFile(user.id, `root:/${noteId}.md:`, makeNoteSerializedBody({
|
await createFile(user.id, `root:/${noteId}.md:`, makeNoteSerializedBody({
|
||||||
body: '',
|
body: '',
|
||||||
}));
|
}));
|
||||||
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));
|
await expectNotThrow(async () => getShareContent(share.id));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,7 +166,7 @@ describe('shares.joplin', function() {
|
|||||||
await createFile(user.id, `root:/${noteId}.md:`, makeNoteSerializedBody({
|
await createFile(user.id, `root:/${noteId}.md:`, makeNoteSerializedBody({
|
||||||
body: '[missing too](:/531a2a839a2c493a88c45e39c6cb9ed4)',
|
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));
|
await expectNotThrow(async () => getShareContent(share.id));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
@@ -33,7 +33,6 @@ router.get('shares/:id', async (path: SubPath, ctx: AppContext) => {
|
|||||||
const file = await fileModel.loadWithContent(share.file_id, { skipPermissionCheck: true });
|
const file = await fileModel.loadWithContent(share.file_id, { skipPermissionCheck: true });
|
||||||
if (!file) throw new ErrorNotFound();
|
if (!file) throw new ErrorNotFound();
|
||||||
|
|
||||||
|
|
||||||
const result = await renderFile(ctx, file, share);
|
const result = await renderFile(ctx, file, share);
|
||||||
|
|
||||||
ctx.response.body = result.body;
|
ctx.response.body = result.body;
|
||||||
|
@@ -4,6 +4,7 @@ import apiSessions from './api/sessions';
|
|||||||
import apiPing from './api/ping';
|
import apiPing from './api/ping';
|
||||||
import apiFiles from './api/files';
|
import apiFiles from './api/files';
|
||||||
import apiShares from './api/shares';
|
import apiShares from './api/shares';
|
||||||
|
import apiShareUsers from './api/share_users';
|
||||||
|
|
||||||
import indexLogin from './index/login';
|
import indexLogin from './index/login';
|
||||||
import indexLogout from './index/logout';
|
import indexLogout from './index/logout';
|
||||||
@@ -13,6 +14,8 @@ import indexFiles from './index/files';
|
|||||||
import indexNotifications from './index/notifications';
|
import indexNotifications from './index/notifications';
|
||||||
import indexShares from './index/shares';
|
import indexShares from './index/shares';
|
||||||
|
|
||||||
|
import appJoplinNotes from '../apps/joplin/routes/notes';
|
||||||
|
|
||||||
import defaultRoute from './default';
|
import defaultRoute from './default';
|
||||||
|
|
||||||
const routes: Routers = {
|
const routes: Routers = {
|
||||||
@@ -20,6 +23,7 @@ const routes: Routers = {
|
|||||||
'api/sessions': apiSessions,
|
'api/sessions': apiSessions,
|
||||||
'api/files': apiFiles,
|
'api/files': apiFiles,
|
||||||
'api/shares': apiShares,
|
'api/shares': apiShares,
|
||||||
|
'api/share_users': apiShareUsers,
|
||||||
|
|
||||||
'login': indexLogin,
|
'login': indexLogin,
|
||||||
'logout': indexLogout,
|
'logout': indexLogout,
|
||||||
@@ -29,6 +33,8 @@ const routes: Routers = {
|
|||||||
'notifications': indexNotifications,
|
'notifications': indexNotifications,
|
||||||
'shares': indexShares,
|
'shares': indexShares,
|
||||||
|
|
||||||
|
'apps/joplin/notes': appJoplinNotes,
|
||||||
|
|
||||||
'': defaultRoute,
|
'': defaultRoute,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@@ -1,5 +1,6 @@
|
|||||||
import ApplicationJoplin from '../apps/joplin/Application';
|
import ApplicationJoplin from '../apps/joplin/Application';
|
||||||
import config from '../config';
|
import config from '../config';
|
||||||
|
import { File } from '../db';
|
||||||
import { Models } from '../models/factory';
|
import { Models } from '../models/factory';
|
||||||
|
|
||||||
export default class Applications {
|
export default class Applications {
|
||||||
@@ -21,6 +22,11 @@ export default class Applications {
|
|||||||
return this.joplin_;
|
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> {
|
public async localFileFromUrl(url: string): Promise<string> {
|
||||||
if (url.indexOf('apps/') !== 0) return null;
|
if (url.indexOf('apps/') !== 0) return null;
|
||||||
|
|
||||||
|
@@ -30,6 +30,7 @@ const config = {
|
|||||||
'main.changes': 'WithDates, WithUuid',
|
'main.changes': 'WithDates, WithUuid',
|
||||||
'main.notifications': 'WithDates, WithUuid',
|
'main.notifications': 'WithDates, WithUuid',
|
||||||
'main.shares': 'WithDates, WithUuid',
|
'main.shares': 'WithDates, WithUuid',
|
||||||
|
'main.share_users': 'WithDates, WithUuid',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@@ -1,7 +1,7 @@
|
|||||||
// For explanation of the setPrototypeOf call, see:
|
// 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
|
// 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 static httpCode: number = 400;
|
||||||
|
|
||||||
public httpCode: number;
|
public httpCode: number;
|
||||||
|
@@ -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
|
// 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
|
// we explicitely set it to that. However save the previous value so that it
|
||||||
// can be restored.
|
// can be restored.
|
||||||
@@ -47,7 +47,18 @@ export async function bodyFields(req: any): Promise<BodyFields> {
|
|||||||
|
|
||||||
const form = await formParse(req);
|
const form = await formParse(req);
|
||||||
if (previousContentType) req.headers['content-type'] = previousContentType;
|
if (previousContentType) req.headers['content-type'] = previousContentType;
|
||||||
|
|
||||||
return form.fields;
|
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) {
|
export function ownerRequired(ctx: AppContext) {
|
||||||
|
@@ -1,7 +1,7 @@
|
|||||||
import { File, ItemAddressingType } from '../db';
|
import { File, ItemAddressingType } from '../db';
|
||||||
import { ErrorBadRequest } from './errors';
|
import { ErrorBadRequest, ErrorForbidden, ErrorNotFound } from './errors';
|
||||||
import Router from './Router';
|
import Router from './Router';
|
||||||
import { AppContext } from './types';
|
import { AppContext, HttpMethod } from './types';
|
||||||
|
|
||||||
const { ltrimSlashes, rtrimSlashes } = require('@joplin/lib/path-utils');
|
const { ltrimSlashes, rtrimSlashes } = require('@joplin/lib/path-utils');
|
||||||
|
|
||||||
@@ -151,16 +151,33 @@ export function parseSubPath(basePath: string, p: string): SubPath {
|
|||||||
return output;
|
return output;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function routeResponseFormat(match: MatchedRoute, context: AppContext): RouteResponseFormat {
|
export function routeResponseFormat(context: AppContext): RouteResponseFormat {
|
||||||
const rawPath = context.path;
|
// const rawPath = context.path;
|
||||||
if (match && match.route.responseFormat) return match.route.responseFormat;
|
// if (match && match.route.responseFormat) return match.route.responseFormat;
|
||||||
|
|
||||||
let path = rawPath;
|
// let path = rawPath;
|
||||||
if (match) path = match.basePath ? match.basePath : match.subPath.raw;
|
// 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;
|
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:
|
// In a path such as "/api/files/SOME_ID/content" we want to find:
|
||||||
// - The base path: "api/files"
|
// - The base path: "api/files"
|
||||||
// - The ID: "SOME_ID"
|
// - 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'].
|
// an empty string. So for example we now have ['api', 'files', 'SOME_ID', 'content'].
|
||||||
splittedPath.splice(0, 1);
|
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) {
|
if (splittedPath.length >= 2) {
|
||||||
// Create the base path, eg. "api/files", to match it to one of the
|
// Create the base path, eg. "api/files", to match it to one of the
|
||||||
// routes.s
|
// routes.
|
||||||
const basePath = `${splittedPath[0]}/${splittedPath[1]}`;
|
const basePath = `${namespace ? `${namespace}/` : ''}${splittedPath[0]}/${splittedPath[1]}`;
|
||||||
if (routes[basePath]) {
|
if (routes[basePath]) {
|
||||||
// Remove the base path from the array so that parseSubPath() can
|
// Remove the base path from the array so that parseSubPath() can
|
||||||
// extract the ID and link from the URL. So the array will contain
|
// 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];
|
const basePath = splittedPath[0];
|
||||||
if (routes[basePath]) {
|
const basePathNS = (namespace ? `${namespace}/` : '') + basePath;
|
||||||
|
if (routes[basePathNS]) {
|
||||||
splittedPath.splice(0, 1);
|
splittedPath.splice(0, 1);
|
||||||
return {
|
return {
|
||||||
route: routes[basePath],
|
route: routes[basePathNS],
|
||||||
basePath: basePath,
|
basePath: basePath,
|
||||||
subPath: parseSubPath(basePath, `/${splittedPath.join('/')}`),
|
subPath: parseSubPath(basePath, `/${splittedPath.join('/')}`),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Default routes - to process CSS or JS files for example
|
||||||
if (routes['']) {
|
if (routes['']) {
|
||||||
return {
|
return {
|
||||||
route: routes[''],
|
route: routes[''],
|
||||||
|
@@ -4,11 +4,13 @@ import { DbConnection } from '../db';
|
|||||||
import newModelFactory from '../models/factory';
|
import newModelFactory from '../models/factory';
|
||||||
import Applications from '../services/Applications';
|
import Applications from '../services/Applications';
|
||||||
import { AppContext, Env } from './types';
|
import { AppContext, Env } from './types';
|
||||||
|
import routes from '../routes/routes';
|
||||||
|
|
||||||
export default async function(appContext: AppContext, env: Env, dbConnection: DbConnection, appLogger: ()=> LoggerWrapper) {
|
export default async function(appContext: AppContext, env: Env, dbConnection: DbConnection, appLogger: ()=> LoggerWrapper) {
|
||||||
appContext.env = env;
|
appContext.env = env;
|
||||||
appContext.db = dbConnection;
|
appContext.db = dbConnection;
|
||||||
appContext.models = newModelFactory(appContext.db, config().baseUrl);
|
|
||||||
appContext.apps = new Applications(appContext.models);
|
appContext.apps = new Applications(appContext.models);
|
||||||
|
appContext.models = newModelFactory(appContext.db, config().baseUrl, appContext.apps);
|
||||||
appContext.appLogger = appLogger;
|
appContext.appLogger = appLogger;
|
||||||
|
appContext.routes = routes;
|
||||||
}
|
}
|
||||||
|
67
packages/server/src/utils/testing/apiUtils.ts
Normal file
67
packages/server/src/utils/testing/apiUtils.ts
Normal 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;
|
||||||
|
}
|
@@ -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 routeHandler from '../../middleware/routeHandler';
|
||||||
import { AppContext } from '../types';
|
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({
|
const context = await koaAppContext({
|
||||||
sessionId: sessionId,
|
sessionId: sessionId,
|
||||||
request: {
|
request: {
|
||||||
@@ -11,7 +42,7 @@ export async function postShareContext(sessionId: string, itemId: Uuid): Promise
|
|||||||
url: '/api/shares',
|
url: '/api/shares',
|
||||||
body: {
|
body: {
|
||||||
file_id: itemId,
|
file_id: itemId,
|
||||||
type: ShareType.Link,
|
type: shareType,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -19,8 +50,47 @@ export async function postShareContext(sessionId: string, itemId: Uuid): Promise
|
|||||||
return context;
|
return context;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function postShare(sessionId: string, itemId: Uuid): Promise<Share> {
|
export async function postShare(sessionId: string, shareType: ShareType, itemId: Uuid): Promise<Share> {
|
||||||
const context = await postShareContext(sessionId, itemId);
|
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);
|
checkContextError(context);
|
||||||
return context.response.body;
|
return context.response.body;
|
||||||
}
|
}
|
||||||
|
@@ -13,6 +13,7 @@ import * as crypto from 'crypto';
|
|||||||
import * as fs from 'fs-extra';
|
import * as fs from 'fs-extra';
|
||||||
import * as jsdom from 'jsdom';
|
import * as jsdom from 'jsdom';
|
||||||
import setupAppContext from '../setupAppContext';
|
import setupAppContext from '../setupAppContext';
|
||||||
|
import { ApiError } from '../errors';
|
||||||
|
|
||||||
// Takes into account the fact that this file will be inside the /dist directory
|
// Takes into account the fact that this file will be inside the /dist directory
|
||||||
// when it runs.
|
// when it runs.
|
||||||
@@ -68,7 +69,7 @@ export async function beforeEachDb() {
|
|||||||
await truncateTables(db_);
|
await truncateTables(db_);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AppContextTestOptions {
|
export interface AppContextTestOptions {
|
||||||
// owner?: User;
|
// owner?: User;
|
||||||
sessionId?: string;
|
sessionId?: string;
|
||||||
request?: any;
|
request?: any;
|
||||||
@@ -79,6 +80,33 @@ function initGlobalLogger() {
|
|||||||
Logger.initializeGlobalLogger(globalLogger);
|
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> {
|
export async function koaAppContext(options: AppContextTestOptions = null): Promise<AppContext> {
|
||||||
if (!db_) throw new Error('Database must be initialized first');
|
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);
|
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) {
|
export function checkContextError(context: AppContext) {
|
||||||
if (context.response.status >= 400) {
|
if (context.response.status >= 400) {
|
||||||
// console.info(context.response.body);
|
// 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -3,6 +3,7 @@ import * as Koa from 'koa';
|
|||||||
import { DbConnection, User, Uuid } from '../db';
|
import { DbConnection, User, Uuid } from '../db';
|
||||||
import { Models } from '../models/factory';
|
import { Models } from '../models/factory';
|
||||||
import Applications from '../services/Applications';
|
import Applications from '../services/Applications';
|
||||||
|
import { Routers } from './routeUtils';
|
||||||
|
|
||||||
export enum Env {
|
export enum Env {
|
||||||
Dev = 'dev',
|
Dev = 'dev',
|
||||||
@@ -25,6 +26,7 @@ export interface AppContext extends Koa.Context {
|
|||||||
notifications: NotificationView[];
|
notifications: NotificationView[];
|
||||||
owner: User;
|
owner: User;
|
||||||
apps: Applications;
|
apps: Applications;
|
||||||
|
routes: Routers;
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum DatabaseConfigClient {
|
export enum DatabaseConfigClient {
|
||||||
|
Reference in New Issue
Block a user