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

Server: Improved how routes can be defined

This commit is contained in:
Laurent Cozic 2021-01-14 18:27:59 +00:00
parent 7652a5a0a0
commit 7ad29577f9
10 changed files with 259 additions and 220 deletions

View File

@ -1,6 +1,6 @@
import routes from '../routes/routes';
import { ErrorNotFound } from '../utils/errors';
import { routeResponseFormat, findMatchingRoute, Response, RouteResponseFormat, MatchedRoute } from '../utils/routeUtils';
import { ErrorForbidden, ErrorNotFound } from '../utils/errors';
import { routeResponseFormat, findMatchingRoute, Response, RouteResponseFormat, MatchedRoute, findEndPoint } from '../utils/routeUtils';
import { AppContext, Env } from '../utils/types';
import mustacheService, { isView, View } from '../services/MustacheService';
@ -13,7 +13,16 @@ export default async function(ctx: AppContext) {
const match = findMatchingRoute(ctx.path, routes);
if (match) {
const responseObject = await match.route.exec(match.subPath, ctx);
let responseObject = null;
if (match.route.endPoints) {
const routeHandler = findEndPoint(match.route, ctx.request.method, match.subPath.schema);
responseObject = await routeHandler(match.subPath, ctx);
if (!match.route.public && !ctx.owner) throw new ErrorForbidden();
} else {
responseObject = await match.route.exec(match.subPath, ctx);
}
if (responseObject instanceof Response) {
ctx.response = responseObject.response;

View File

@ -6,6 +6,8 @@ const route: Route = {
return { status: 'ok', message: 'Joplin Server is running' };
},
public: true,
};
export default route;

View File

@ -6,6 +6,8 @@ const route: Route = {
return { status: 'ok', message: 'Joplin Server is running' };
},
public: true,
};
export default route;

View File

@ -28,6 +28,8 @@ const route: Route = {
throw new ErrorNotFound(`Invalid link: ${path.link}`);
},
public: true,
};
export default route;

View File

@ -1,7 +1,7 @@
import { SubPath, Route, respondWithFileContent, redirect } from '../../utils/routeUtils';
import { AppContext } from '../../utils/types';
import { contextSessionId, formParse } from '../../utils/requestUtils';
import { ErrorMethodNotAllowed, ErrorNotFound } from '../../utils/errors';
import { ErrorNotFound } from '../../utils/errors';
import { File } from '../../db';
import { createPaginationLinks, pageMaxSize, Pagination, PaginationOrder, PaginationOrderDir, requestPaginationOrder, validatePagination } from '../../models/utils/pagination';
import { setQueryParameters } from '../../utils/urlUtils';
@ -21,127 +21,113 @@ function makeFilePagination(query: any): Pagination {
return output;
}
const endPoints = {
'GET': {
'files/:id': async function(path: SubPath, ctx: AppContext) {
const dirId = path.id;
const query = ctx.query;
// Query parameters that should be appended to pagination-related URLs
const baseUrlQuery: any = {};
if (query.limit) baseUrlQuery.limit = query.limit;
if (query.order_by) baseUrlQuery.order_by = query.order_by;
if (query.order_dir) baseUrlQuery.order_dir = query.order_dir;
const pagination = makeFilePagination(query);
const owner = ctx.owner;
const fileModel = ctx.models.file({ userId: owner.id });
const root = await fileModel.userRootFile();
const parentTemp: File = dirId ? await fileModel.entityFromItemId(dirId) : root;
const parent: File = await fileModel.load(parentTemp.id);
const paginatedFiles = await fileModel.childrens(parent.id, pagination);
const pageCount = Math.ceil((await fileModel.childrenCount(parent.id)) / pagination.limit);
const parentBaseUrl = await fileModel.fileUrl(parent.id);
const paginationLinks = createPaginationLinks(pagination.page, pageCount, setQueryParameters(parentBaseUrl, { ...baseUrlQuery, 'page': 'PAGE_NUMBER' }));
async function fileToViewItem(file: File, fileFullPaths: Record<string, string>): Promise<any> {
const filePath = fileFullPaths[file.id];
let url = `${baseUrl()}/files/${filePath}`;
if (!file.is_directory) {
url += '/content';
} else {
url = setQueryParameters(url, baseUrlQuery);
}
return {
name: file.name,
url,
type: file.is_directory ? 'directory' : 'file',
icon: file.is_directory ? 'far fa-folder' : 'far fa-file',
timestamp: formatDateTime(file.updated_time),
mime: !file.is_directory ? (file.mime_type || 'binary') : '',
};
}
const files: any[] = [];
const fileFullPaths = await fileModel.itemFullPaths(paginatedFiles.items);
if (parent.id !== root.id) {
const p = await fileModel.load(parent.parent_id);
files.push({
...await fileToViewItem(p, await fileModel.itemFullPaths([p])),
icon: 'fas fa-arrow-left',
name: '..',
});
}
for (const file of paginatedFiles.items) {
files.push(await fileToViewItem(file, fileFullPaths));
}
const view: View = defaultView('files');
view.content.paginatedFiles = { ...paginatedFiles, items: files };
view.content.paginationLinks = paginationLinks;
view.content.postUrl = `${baseUrl()}/files`;
view.content.parentId = parent.id;
view.cssFiles = ['index/files'];
view.partials.push('pagination');
return view;
},
'files/:id/content': async function(path: SubPath, ctx: AppContext) {
const fileModel = ctx.models.file({ userId: ctx.owner.id });
let file: File = await fileModel.entityFromItemId(path.id);
file = await fileModel.loadWithContent(file.id);
if (!file) throw new ErrorNotFound();
return respondWithFileContent(ctx.response, file);
},
},
'POST': {
'files': async function(_path: SubPath, ctx: AppContext) {
const sessionId = contextSessionId(ctx);
const body = await formParse(ctx.req);
const fields = body.fields;
const parentId = fields.parent_id;
const user = await ctx.models.session().sessionUser(sessionId);
if (fields.delete_all_button) {
const fileModel = ctx.models.file({ userId: ctx.owner.id });
const parent: File = await fileModel.entityFromItemId(parentId, { returnFullEntity: true });
await fileModel.deleteChildren(parent.id);
} else {
throw new Error('Invalid form button');
}
return redirect(ctx, await ctx.models.file({ userId: user.id }).fileUrl(parentId, ctx.query));
},
},
};
const route: Route = {
exec: async function(path: SubPath, ctx: AppContext) {
if (ctx.method === 'GET') {
if (!path.link) {
return endPoints.GET['files/:id'](path, ctx);
} else if (path.link === 'content') {
return endPoints.GET['files/:id/content'](path, ctx);
}
endPoints: {
throw new ErrorNotFound();
}
'GET': {
if (ctx.method === 'POST') {
return endPoints.POST['files'](path, ctx);
}
'files': 'files/:id',
throw new ErrorMethodNotAllowed();
'files/:id': async function(path: SubPath, ctx: AppContext) {
const dirId = path.id;
const query = ctx.query;
// Query parameters that should be appended to pagination-related URLs
const baseUrlQuery: any = {};
if (query.limit) baseUrlQuery.limit = query.limit;
if (query.order_by) baseUrlQuery.order_by = query.order_by;
if (query.order_dir) baseUrlQuery.order_dir = query.order_dir;
const pagination = makeFilePagination(query);
const owner = ctx.owner;
const fileModel = ctx.models.file({ userId: owner.id });
const root = await fileModel.userRootFile();
const parentTemp: File = dirId ? await fileModel.entityFromItemId(dirId) : root;
const parent: File = await fileModel.load(parentTemp.id);
const paginatedFiles = await fileModel.childrens(parent.id, pagination);
const pageCount = Math.ceil((await fileModel.childrenCount(parent.id)) / pagination.limit);
const parentBaseUrl = await fileModel.fileUrl(parent.id);
const paginationLinks = createPaginationLinks(pagination.page, pageCount, setQueryParameters(parentBaseUrl, { ...baseUrlQuery, 'page': 'PAGE_NUMBER' }));
async function fileToViewItem(file: File, fileFullPaths: Record<string, string>): Promise<any> {
const filePath = fileFullPaths[file.id];
let url = `${baseUrl()}/files/${filePath}`;
if (!file.is_directory) {
url += '/content';
} else {
url = setQueryParameters(url, baseUrlQuery);
}
return {
name: file.name,
url,
type: file.is_directory ? 'directory' : 'file',
icon: file.is_directory ? 'far fa-folder' : 'far fa-file',
timestamp: formatDateTime(file.updated_time),
mime: !file.is_directory ? (file.mime_type || 'binary') : '',
};
}
const files: any[] = [];
const fileFullPaths = await fileModel.itemFullPaths(paginatedFiles.items);
if (parent.id !== root.id) {
const p = await fileModel.load(parent.parent_id);
files.push({
...await fileToViewItem(p, await fileModel.itemFullPaths([p])),
icon: 'fas fa-arrow-left',
name: '..',
});
}
for (const file of paginatedFiles.items) {
files.push(await fileToViewItem(file, fileFullPaths));
}
const view: View = defaultView('files');
view.content.paginatedFiles = { ...paginatedFiles, items: files };
view.content.paginationLinks = paginationLinks;
view.content.postUrl = `${baseUrl()}/files`;
view.content.parentId = parent.id;
view.cssFiles = ['index/files'];
view.partials.push('pagination');
return view;
},
'files/:id/content': async function(path: SubPath, ctx: AppContext) {
const fileModel = ctx.models.file({ userId: ctx.owner.id });
let file: File = await fileModel.entityFromItemId(path.id);
file = await fileModel.loadWithContent(file.id);
if (!file) throw new ErrorNotFound();
return respondWithFileContent(ctx.response, file);
},
},
'POST': {
'files': async function(_path: SubPath, ctx: AppContext) {
const sessionId = contextSessionId(ctx);
const body = await formParse(ctx.req);
const fields = body.fields;
const parentId = fields.parent_id;
const user = await ctx.models.session().sessionUser(sessionId);
if (fields.delete_all_button) {
const fileModel = ctx.models.file({ userId: ctx.owner.id });
const parent: File = await fileModel.entityFromItemId(parentId, { returnFullEntity: true });
await fileModel.deleteChildren(parent.id);
} else {
throw new Error('Invalid form button');
}
return redirect(ctx, await ctx.models.file({ userId: user.id }).fileUrl(parentId, ctx.query));
},
},
},
};

View File

@ -35,6 +35,8 @@ const route: Route = {
throw new ErrorMethodNotAllowed();
},
public: true,
};
export default route;

View File

@ -1,7 +1,7 @@
import { SubPath, Route, redirect } from '../../utils/routeUtils';
import { SubPath, Route, redirect, findEndPoint } from '../../utils/routeUtils';
import { AppContext } from '../../utils/types';
import { contextSessionId, formParse } from '../../utils/requestUtils';
import { ErrorMethodNotAllowed, ErrorUnprocessableEntity } from '../../utils/errors';
import { formParse } from '../../utils/requestUtils';
import { ErrorUnprocessableEntity } from '../../utils/errors';
import { User } from '../../db';
import { baseUrl } from '../../config';
import { View } from '../../services/MustacheService';
@ -31,101 +31,89 @@ function userIsMe(path: SubPath): boolean {
return path.id === 'me';
}
const endPoints = {
'GET': {
'users': async function(_path: SubPath, ctx: AppContext) {
const userModel = ctx.models.user({ userId: ctx.owner.id });
const users = await userModel.all();
const view: View = defaultView('users');
view.content.users = users;
return view;
},
'users/:id': async function(path: SubPath, ctx: AppContext, user: User = null, error: any = null) {
const owner = ctx.owner;
const isMe = userIsMe(path);
const isNew = userIsNew(path);
const userModel = ctx.models.user({ userId: owner.id });
const userId = userIsMe(path) ? owner.id : path.id;
user = !isNew ? user || await userModel.load(userId) : null;
let postUrl = '';
if (isNew) {
postUrl = `${baseUrl()}/users/new`;
} else if (isMe) {
postUrl = `${baseUrl()}/users/me`;
} else {
postUrl = `${baseUrl()}/users/${user.id}`;
}
const view: View = defaultView('user');
view.content.user = user;
view.content.isNew = isNew;
view.content.buttonTitle = isNew ? 'Create user' : 'Update profile';
view.content.error = error;
view.content.postUrl = postUrl;
view.content.showDeleteButton = !isNew && !!owner.is_admin && owner.id !== user.id;
view.partials.push('errorBanner');
return view;
},
},
'POST': {
'users': async function(path: SubPath, ctx: AppContext) {
let user: User = {};
const userId = userIsMe(path) ? ctx.owner.id : path.id;
try {
const body = await formParse(ctx.req);
const fields = body.fields;
if (userIsMe(path)) fields.id = userId;
user = makeUser(userIsNew(path), fields);
const userModel = ctx.models.user({ userId: ctx.owner.id });
if (fields.post_button) {
if (userIsNew(path)) {
await userModel.save(userModel.fromApiInput(user));
} else {
await userModel.save(userModel.fromApiInput(user), { isNew: false });
}
} else if (fields.delete_button) {
await userModel.delete(path.id);
} else {
throw new Error('Invalid form button');
}
return redirect(ctx, `${baseUrl()}/users${userIsMe(path) ? '/me' : ''}`);
} catch (error) {
return endPoints.GET['users/:id'](path, ctx, user, error);
}
},
},
};
const route: Route = {
exec: async function(path: SubPath, ctx: AppContext) {
contextSessionId(ctx);
endPoints: {
if (ctx.method === 'GET') {
if (path.id) {
return endPoints.GET['users/:id'](path, ctx);
} else {
return endPoints.GET['users'](path, ctx);
}
}
'GET': {
if (ctx.method === 'POST') {
return endPoints.POST['users'](path, ctx);
}
'users': async function(_path: SubPath, ctx: AppContext) {
const userModel = ctx.models.user({ userId: ctx.owner.id });
const users = await userModel.all();
const view: View = defaultView('users');
view.content.users = users;
return view;
},
'users/:id': async function(path: SubPath, ctx: AppContext, user: User = null, error: any = null) {
const owner = ctx.owner;
const isMe = userIsMe(path);
const isNew = userIsNew(path);
const userModel = ctx.models.user({ userId: owner.id });
const userId = userIsMe(path) ? owner.id : path.id;
user = !isNew ? user || await userModel.load(userId) : null;
let postUrl = '';
if (isNew) {
postUrl = `${baseUrl()}/users/new`;
} else if (isMe) {
postUrl = `${baseUrl()}/users/me`;
} else {
postUrl = `${baseUrl()}/users/${user.id}`;
}
const view: View = defaultView('user');
view.content.user = user;
view.content.isNew = isNew;
view.content.buttonTitle = isNew ? 'Create user' : 'Update profile';
view.content.error = error;
view.content.postUrl = postUrl;
view.content.showDeleteButton = !isNew && !!owner.is_admin && owner.id !== user.id;
view.partials.push('errorBanner');
return view;
},
},
'POST': {
'users/:id': 'users',
'users': async function(path: SubPath, ctx: AppContext) {
let user: User = {};
const userId = userIsMe(path) ? ctx.owner.id : path.id;
try {
const body = await formParse(ctx.req);
const fields = body.fields;
if (userIsMe(path)) fields.id = userId;
user = makeUser(userIsNew(path), fields);
const userModel = ctx.models.user({ userId: ctx.owner.id });
if (fields.post_button) {
if (userIsNew(path)) {
await userModel.save(userModel.fromApiInput(user));
} else {
await userModel.save(userModel.fromApiInput(user), { isNew: false });
}
} else if (fields.delete_button) {
await userModel.delete(path.id);
} else {
throw new Error('Invalid form button');
}
return redirect(ctx, `${baseUrl()}/users${userIsMe(path) ? '/me' : ''}`);
} catch (error) {
const endPoint = findEndPoint(route, 'GET', 'users/:id');
return endPoint(path, ctx, user, error);
}
},
},
throw new ErrorMethodNotAllowed();
},
};

View File

@ -18,7 +18,7 @@ describe('routeUtils', function() {
const link = t[2];
const addressingType = t[3];
const parsed = parseSubPath(path);
const parsed = parseSubPath('', path);
expect(parsed.id).toBe(id);
expect(parsed.link).toBe(link);
expect(parsed.addressingType).toBe(addressingType);

View File

@ -1,5 +1,5 @@
import { File, ItemAddressingType } from '../db';
import { ErrorBadRequest } from './errors';
import { ErrorBadRequest, ErrorMethodNotAllowed, ErrorNotFound } from './errors';
import { AppContext } from './types';
const { ltrimSlashes, rtrimSlashes } = require('@joplin/lib/path-utils');
@ -22,9 +22,23 @@ export enum RouteResponseFormat {
Json = 'json',
}
type RouteHandler = (path: SubPath, ctx: AppContext, ...args: any[])=> Promise<any>;
export interface RouteEndPoint {
[path: string]: RouteHandler | string;
}
export interface RouteEndPoints {
[method: string]: RouteEndPoint;
}
export interface Route {
exec: Function;
exec?: RouteHandler;
responseFormat?: RouteResponseFormat;
endPoints?: RouteEndPoints;
// Public routes can be accessed without authentication.
public?: boolean;
}
export interface Routes {
@ -36,6 +50,7 @@ export interface SubPath {
link: string;
addressingType: ItemAddressingType;
raw: string;
schema: string;
}
export interface MatchedRoute {
@ -70,6 +85,23 @@ export interface PathInfo {
dirname: string;
}
export function findEndPoint(route: Route, method: string, schema: string): RouteHandler {
if (!route.endPoints[method]) throw new ErrorMethodNotAllowed(`Not allowed: ${method} ${schema}`);
const endPoint = route.endPoints[method][schema];
if (!endPoint) throw new ErrorNotFound(`Not found: ${method} ${schema}`);
let endPointFn = endPoint;
for (let i = 0; i < 1000; i++) {
if (typeof endPointFn === 'string') {
endPointFn = route.endPoints[method]?.[endPointFn];
} else {
return endPointFn;
}
}
throw new ErrorNotFound(`Could not resolve: ${method} ${schema}`);
}
export function redirect(ctx: AppContext, url: string): Response {
ctx.redirect(url);
ctx.response.status = 302;
@ -113,7 +145,7 @@ export function isPathBasedAddressing(fileId: string): boolean {
//
// root:/Documents/MyFile.md:/content
// ABCDEFG/content
export function parseSubPath(p: string): SubPath {
export function parseSubPath(basePath: string, p: string): SubPath {
p = rtrimSlashes(ltrimSlashes(p));
const output: SubPath = {
@ -121,6 +153,7 @@ export function parseSubPath(p: string): SubPath {
link: '',
addressingType: ItemAddressingType.Id,
raw: p,
schema: '',
};
const colonIndex1 = p.indexOf(':');
@ -141,6 +174,13 @@ export function parseSubPath(p: string): SubPath {
if (s.length >= 2) output.link = s[1];
}
// if (basePath) {
const schema = [basePath];
if (output.id) schema.push(':id');
if (output.link) schema.push(output.link);
output.schema = schema.join('/');
// }
return output;
}
@ -179,7 +219,7 @@ export function findMatchingRoute(path: string, routes: Routes): MatchedRoute {
return {
route: routes[basePath],
basePath: basePath,
subPath: parseSubPath(`/${splittedPath.join('/')}`),
subPath: parseSubPath(basePath, `/${splittedPath.join('/')}`),
};
}
}
@ -190,7 +230,7 @@ export function findMatchingRoute(path: string, routes: Routes): MatchedRoute {
return {
route: routes[basePath],
basePath: basePath,
subPath: parseSubPath(`/${splittedPath.join('/')}`),
subPath: parseSubPath(basePath, `/${splittedPath.join('/')}`),
};
}
@ -198,7 +238,7 @@ export function findMatchingRoute(path: string, routes: Routes): MatchedRoute {
return {
route: routes[''],
basePath: '',
subPath: parseSubPath(`/${splittedPath.join('/')}`),
subPath: parseSubPath('', `/${splittedPath.join('/')}`),
};
}

View File

@ -44,4 +44,12 @@ export interface Config {
database: DatabaseConfig;
}
export enum HttpMethod {
GET = 'GET',
POST = 'POST',
DELETE = 'DELETE',
PATCH = 'PATCH',
HEAD = 'HEAD',
}
export type KoaNext = ()=> Promise<void>;