From 7ad29577f94b4802f67b5caa747f48b7ad124b47 Mon Sep 17 00:00:00 2001 From: Laurent Cozic Date: Thu, 14 Jan 2021 18:27:59 +0000 Subject: [PATCH] Server: Improved how routes can be defined --- .../server/src/middleware/routeHandler.ts | 15 +- packages/server/src/routes/api/index.ts | 2 + packages/server/src/routes/api/ping.ts | 2 + packages/server/src/routes/api/sessions.ts | 2 + packages/server/src/routes/index/files.ts | 220 ++++++++---------- packages/server/src/routes/index/login.ts | 2 + packages/server/src/routes/index/users.ts | 174 +++++++------- packages/server/src/utils/routeUtils.test.ts | 2 +- packages/server/src/utils/routeUtils.ts | 52 ++++- packages/server/src/utils/types.ts | 8 + 10 files changed, 259 insertions(+), 220 deletions(-) diff --git a/packages/server/src/middleware/routeHandler.ts b/packages/server/src/middleware/routeHandler.ts index 125210979..0fa32bd1b 100644 --- a/packages/server/src/middleware/routeHandler.ts +++ b/packages/server/src/middleware/routeHandler.ts @@ -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; diff --git a/packages/server/src/routes/api/index.ts b/packages/server/src/routes/api/index.ts index 448b6e792..e133ca432 100644 --- a/packages/server/src/routes/api/index.ts +++ b/packages/server/src/routes/api/index.ts @@ -6,6 +6,8 @@ const route: Route = { return { status: 'ok', message: 'Joplin Server is running' }; }, + public: true, + }; export default route; diff --git a/packages/server/src/routes/api/ping.ts b/packages/server/src/routes/api/ping.ts index 448b6e792..e133ca432 100644 --- a/packages/server/src/routes/api/ping.ts +++ b/packages/server/src/routes/api/ping.ts @@ -6,6 +6,8 @@ const route: Route = { return { status: 'ok', message: 'Joplin Server is running' }; }, + public: true, + }; export default route; diff --git a/packages/server/src/routes/api/sessions.ts b/packages/server/src/routes/api/sessions.ts index b8cf77f33..e7e427ecc 100644 --- a/packages/server/src/routes/api/sessions.ts +++ b/packages/server/src/routes/api/sessions.ts @@ -28,6 +28,8 @@ const route: Route = { throw new ErrorNotFound(`Invalid link: ${path.link}`); }, + public: true, + }; export default route; diff --git a/packages/server/src/routes/index/files.ts b/packages/server/src/routes/index/files.ts index f9933ac0f..62f099cb7 100644 --- a/packages/server/src/routes/index/files.ts +++ b/packages/server/src/routes/index/files.ts @@ -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): Promise { - 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): Promise { + 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)); + }, + }, }, }; diff --git a/packages/server/src/routes/index/login.ts b/packages/server/src/routes/index/login.ts index ccaf7fd01..842f450a0 100644 --- a/packages/server/src/routes/index/login.ts +++ b/packages/server/src/routes/index/login.ts @@ -35,6 +35,8 @@ const route: Route = { throw new ErrorMethodNotAllowed(); }, + public: true, + }; export default route; diff --git a/packages/server/src/routes/index/users.ts b/packages/server/src/routes/index/users.ts index 488372d88..d51e4ff8e 100644 --- a/packages/server/src/routes/index/users.ts +++ b/packages/server/src/routes/index/users.ts @@ -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(); }, }; diff --git a/packages/server/src/utils/routeUtils.test.ts b/packages/server/src/utils/routeUtils.test.ts index cf1867654..59db01120 100644 --- a/packages/server/src/utils/routeUtils.test.ts +++ b/packages/server/src/utils/routeUtils.test.ts @@ -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); diff --git a/packages/server/src/utils/routeUtils.ts b/packages/server/src/utils/routeUtils.ts index fa4a6ae8e..899a1cc80 100644 --- a/packages/server/src/utils/routeUtils.ts +++ b/packages/server/src/utils/routeUtils.ts @@ -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; + +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('/')}`), }; } diff --git a/packages/server/src/utils/types.ts b/packages/server/src/utils/types.ts index 1e5a974fc..3df9acbc6 100644 --- a/packages/server/src/utils/types.ts +++ b/packages/server/src/utils/types.ts @@ -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;