From 413ec1a93341b3b6a4babd3f03408bd664088a2c Mon Sep 17 00:00:00 2001 From: Laurent Cozic Date: Thu, 14 Jan 2021 22:36:46 +0000 Subject: [PATCH] Server: Refactored to use Router class --- .eslintignore | 1 + .../server/src/middleware/routeHandler.ts | 14 +- packages/server/src/routes/api/files.ts | 195 +++++++----------- packages/server/src/routes/api/index.ts | 13 -- packages/server/src/routes/api/ping.ts | 16 +- packages/server/src/routes/api/sessions.ts | 40 ++-- packages/server/src/routes/default.ts | 36 ++-- packages/server/src/routes/index/files.ts | 180 ++++++++-------- packages/server/src/routes/index/home.test.ts | 34 +++ packages/server/src/routes/index/home.ts | 23 +-- packages/server/src/routes/index/login.ts | 42 ++-- packages/server/src/routes/index/logout.ts | 26 +-- .../src/routes/index/notifications.test.ts | 46 +++++ .../server/src/routes/index/notifications.ts | 44 ++-- packages/server/src/routes/index/users.ts | 139 ++++++------- packages/server/src/routes/routes.ts | 4 +- packages/server/src/utils/Router.ts | 62 ++++++ packages/server/src/utils/routeUtils.ts | 61 ++---- 18 files changed, 484 insertions(+), 492 deletions(-) delete mode 100644 packages/server/src/routes/api/index.ts create mode 100644 packages/server/src/routes/index/home.test.ts create mode 100644 packages/server/src/routes/index/notifications.test.ts create mode 100644 packages/server/src/utils/Router.ts diff --git a/.eslintignore b/.eslintignore index bfb76dcf3..e0ac08c75 100644 --- a/.eslintignore +++ b/.eslintignore @@ -6,6 +6,7 @@ _releases/ **/node_modules/ Assets/ docs/ +packages/plugins/**/dist packages/server/dist/ highlight.pack.js Modules/TinyMCE/IconPack/postinstall.js diff --git a/packages/server/src/middleware/routeHandler.ts b/packages/server/src/middleware/routeHandler.ts index 0fa32bd1b..117b0ed8d 100644 --- a/packages/server/src/middleware/routeHandler.ts +++ b/packages/server/src/middleware/routeHandler.ts @@ -1,7 +1,7 @@ import routes from '../routes/routes'; import { ErrorForbidden, ErrorNotFound } from '../utils/errors'; -import { routeResponseFormat, findMatchingRoute, Response, RouteResponseFormat, MatchedRoute, findEndPoint } from '../utils/routeUtils'; -import { AppContext, Env } from '../utils/types'; +import { routeResponseFormat, findMatchingRoute, Response, RouteResponseFormat, MatchedRoute } from '../utils/routeUtils'; +import { AppContext, Env, HttpMethod } from '../utils/types'; import mustacheService, { isView, View } from '../services/MustacheService'; export default async function(ctx: AppContext) { @@ -15,14 +15,10 @@ export default async function(ctx: AppContext) { if (match) { let responseObject = null; - if (match.route.endPoints) { - const routeHandler = findEndPoint(match.route, ctx.request.method, match.subPath.schema); - responseObject = await routeHandler(match.subPath, ctx); + const routeHandler = match.route.findEndPoint(ctx.request.method as HttpMethod, 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 (!match.route.public && !ctx.owner) throw new ErrorForbidden(); if (responseObject instanceof Response) { ctx.response = responseObject.response; diff --git a/packages/server/src/routes/api/files.ts b/packages/server/src/routes/api/files.ts index 542cfaa3d..ac3508d7c 100644 --- a/packages/server/src/routes/api/files.ts +++ b/packages/server/src/routes/api/files.ts @@ -1,133 +1,98 @@ -import { ErrorNotFound, ErrorMethodNotAllowed, ErrorBadRequest } from '../../utils/errors'; +import { ErrorNotFound, ErrorBadRequest } from '../../utils/errors'; import { File } from '../../db'; import { bodyFields, formParse } from '../../utils/requestUtils'; -import { SubPath, Route, respondWithFileContent } from '../../utils/routeUtils'; +import { SubPath, respondWithFileContent } from '../../utils/routeUtils'; +import Router from '../../utils/Router'; import { AppContext } from '../../utils/types'; import * as fs from 'fs-extra'; import { requestChangePagination, requestPagination } from '../../models/utils/pagination'; -const route: Route = { +const router = new Router(); - exec: async function(path: SubPath, ctx: AppContext) { - // console.info(`${ctx.method} ${path.id}${path.link ? `/${path.link}` : ''}`); +router.get('api/files/:id', async (path: SubPath, ctx: AppContext) => { + const fileModel = ctx.models.file({ userId: ctx.owner.id }); + const fileId = path.id; + const file: File = await fileModel.entityFromItemId(fileId); + const loadedFile = await fileModel.load(file.id); + if (!loadedFile) throw new ErrorNotFound(); + return fileModel.toApiOutput(loadedFile); +}); - // ------------------------------------------- - // ROUTE api/files/:id - // ------------------------------------------- +router.patch('api/files/:id', async (path: SubPath, ctx: AppContext) => { + const fileModel = ctx.models.file({ userId: ctx.owner.id }); + const fileId = path.id; + const inputFile: File = await bodyFields(ctx.req); + const existingFile: File = await fileModel.entityFromItemId(fileId); + const newFile = fileModel.fromApiInput(inputFile); + newFile.id = existingFile.id; + return fileModel.toApiOutput(await fileModel.save(newFile)); +}); - if (!path.link) { - const fileModel = ctx.models.file({ userId: ctx.owner.id }); - const fileId = path.id; - - if (ctx.method === 'GET') { - const file: File = await fileModel.entityFromItemId(fileId); - const loadedFile = await fileModel.load(file.id); - if (!loadedFile) throw new ErrorNotFound(); - return fileModel.toApiOutput(loadedFile); - } - - if (ctx.method === 'PATCH') { - const inputFile: File = await bodyFields(ctx.req); - const existingFile: File = await fileModel.entityFromItemId(fileId); - const newFile = fileModel.fromApiInput(inputFile); - newFile.id = existingFile.id; - return fileModel.toApiOutput(await fileModel.save(newFile)); - } - - if (ctx.method === 'DELETE') { - try { - const file: File = await fileModel.entityFromItemId(fileId, { mustExist: false }); - if (!file.id) return; - await fileModel.delete(file.id); - } catch (error) { - if (error instanceof ErrorNotFound) { - // That's ok - a no-op - } else { - throw error; - } - } - return; - } - - throw new ErrorMethodNotAllowed(); +router.del('api/files/:id', async (path: SubPath, ctx: AppContext) => { + const fileModel = ctx.models.file({ userId: ctx.owner.id }); + const fileId = path.id; + try { + const file: File = await fileModel.entityFromItemId(fileId, { mustExist: false }); + if (!file.id) return; + await fileModel.delete(file.id); + } catch (error) { + if (error instanceof ErrorNotFound) { + // That's ok - a no-op + } else { + throw error; } + } +}); - // ------------------------------------------- - // ROUTE api/files/:id/content - // ------------------------------------------- +router.get('api/files/:id/content', async (path: SubPath, ctx: AppContext) => { + const fileModel = ctx.models.file({ userId: ctx.owner.id }); + const fileId = path.id; + let file: File = await fileModel.entityFromItemId(fileId); + file = await fileModel.loadWithContent(file.id); + if (!file) throw new ErrorNotFound(); + return respondWithFileContent(ctx.response, file); +}); - if (path.link === 'content') { - const fileModel = ctx.models.file({ userId: ctx.owner.id }); - const fileId = path.id; +router.put('api/files/:id/content', async (path: SubPath, ctx: AppContext) => { + const fileModel = ctx.models.file({ userId: ctx.owner.id }); + const fileId = path.id; + const result = await formParse(ctx.req); + if (!result?.files?.file) throw new ErrorBadRequest('File data is missing'); + const buffer = await fs.readFile(result.files.file.path); - if (ctx.method === 'GET') { - let file: File = await fileModel.entityFromItemId(fileId); - file = await fileModel.loadWithContent(file.id); - if (!file) throw new ErrorNotFound(); - return respondWithFileContent(ctx.response, file); - } + const file: File = await fileModel.entityFromItemId(fileId, { mustExist: false }); + file.content = buffer; + return fileModel.toApiOutput(await fileModel.save(file, { validationRules: { mustBeFile: true } })); +}); - if (ctx.method === 'PUT') { - const result = await formParse(ctx.req); - if (!result?.files?.file) throw new ErrorBadRequest('File data is missing'); - const buffer = await fs.readFile(result.files.file.path); +router.del('api/files/:id/content', async (path: SubPath, ctx: AppContext) => { + const fileModel = ctx.models.file({ userId: ctx.owner.id }); + const fileId = path.id; + const file: File = await fileModel.entityFromItemId(fileId, { mustExist: false }); + if (!file) return; + file.content = Buffer.alloc(0); + await fileModel.save(file, { validationRules: { mustBeFile: true } }); +}); - const file: File = await fileModel.entityFromItemId(fileId, { mustExist: false }); - file.content = buffer; - return fileModel.toApiOutput(await fileModel.save(file, { validationRules: { mustBeFile: true } })); - } +router.get('api/files/:id/delta', async (path: SubPath, ctx: AppContext) => { + const fileModel = ctx.models.file({ userId: ctx.owner.id }); + const dir: File = await fileModel.entityFromItemId(path.id, { mustExist: true }); + const changeModel = ctx.models.change({ userId: ctx.owner.id }); + return changeModel.byDirectoryId(dir.id, requestChangePagination(ctx.query)); +}); - if (ctx.method === 'DELETE') { - const file: File = await fileModel.entityFromItemId(fileId, { mustExist: false }); - if (!file) return; - file.content = Buffer.alloc(0); - await fileModel.save(file, { validationRules: { mustBeFile: true } }); - return; - } +router.get('api/files/:id/children', async (path: SubPath, ctx: AppContext) => { + const fileModel = ctx.models.file({ userId: ctx.owner.id }); + const parent: File = await fileModel.entityFromItemId(path.id); + return fileModel.toApiOutput(await fileModel.childrens(parent.id, requestPagination(ctx.query))); +}); - throw new ErrorMethodNotAllowed(); - } +router.post('api/files/:id/children', async (path: SubPath, ctx: AppContext) => { + const fileModel = ctx.models.file({ userId: ctx.owner.id }); + const child: File = fileModel.fromApiInput(await bodyFields(ctx.req)); + const parent: File = await fileModel.entityFromItemId(path.id); + child.parent_id = parent.id; + return fileModel.toApiOutput(await fileModel.save(child)); +}); - // ------------------------------------------- - // ROUTE api/files/:id/delta - // ------------------------------------------- - - if (path.link === 'delta') { - if (ctx.method === 'GET') { - const fileModel = ctx.models.file({ userId: ctx.owner.id }); - const dir: File = await fileModel.entityFromItemId(path.id, { mustExist: true }); - const changeModel = ctx.models.change({ userId: ctx.owner.id }); - return changeModel.byDirectoryId(dir.id, requestChangePagination(ctx.query)); - } - - throw new ErrorMethodNotAllowed(); - } - - // ------------------------------------------- - // ROUTE api/files/:id/children - // ------------------------------------------- - - if (path.link === 'children') { - const fileModel = ctx.models.file({ userId: ctx.owner.id }); - - if (ctx.method === 'GET') { - const parent: File = await fileModel.entityFromItemId(path.id); - return fileModel.toApiOutput(await fileModel.childrens(parent.id, requestPagination(ctx.query))); - } - - if (ctx.method === 'POST') { - const child: File = fileModel.fromApiInput(await bodyFields(ctx.req)); - const parent: File = await fileModel.entityFromItemId(path.id); - child.parent_id = parent.id; - return fileModel.toApiOutput(await fileModel.save(child)); - } - - throw new ErrorMethodNotAllowed(); - } - - throw new ErrorNotFound(`Invalid link: ${path.link}`); - }, - -}; - -export default route; +export default router; diff --git a/packages/server/src/routes/api/index.ts b/packages/server/src/routes/api/index.ts deleted file mode 100644 index e133ca432..000000000 --- a/packages/server/src/routes/api/index.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { Route } from '../../utils/routeUtils'; - -const route: Route = { - - exec: async function() { - 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 e133ca432..f6a7ec2a5 100644 --- a/packages/server/src/routes/api/ping.ts +++ b/packages/server/src/routes/api/ping.ts @@ -1,13 +1,11 @@ -import { Route } from '../../utils/routeUtils'; +import Router from '../../utils/Router'; -const route: Route = { +const router = new Router(); - exec: async function() { - return { status: 'ok', message: 'Joplin Server is running' }; - }, +router.public = true; - public: true, +router.get('api/ping', async () => { + return { status: 'ok', message: 'Joplin Server is running' }; +}); -}; - -export default route; +export default router; diff --git a/packages/server/src/routes/api/sessions.ts b/packages/server/src/routes/api/sessions.ts index e7e427ecc..51f687caf 100644 --- a/packages/server/src/routes/api/sessions.ts +++ b/packages/server/src/routes/api/sessions.ts @@ -1,35 +1,21 @@ -import { SubPath, Route } from '../../utils/routeUtils'; -import { ErrorForbidden, ErrorMethodNotAllowed, ErrorNotFound } from '../../utils/errors'; +import { SubPath } from '../../utils/routeUtils'; +import Router from '../../utils/Router'; +import { ErrorForbidden } from '../../utils/errors'; import { AppContext } from '../../utils/types'; import { bodyFields } from '../../utils/requestUtils'; import { User } from '../../db'; -const route: Route = { +const router = new Router(); - exec: async function(path: SubPath, ctx: AppContext) { +router.public = true; - // ------------------------------------------- - // ROUTE api/sessions - // ------------------------------------------- +router.post('api/sessions', async (_path: SubPath, ctx: AppContext) => { + const fields: User = await bodyFields(ctx.req); + const user = await ctx.models.user().login(fields.email, fields.password); + if (!user) throw new ErrorForbidden('Invalid username or password'); - if (!path.link) { - if (ctx.method === 'POST') { - const fields: User = await bodyFields(ctx.req); - const user = await ctx.models.user().login(fields.email, fields.password); - if (!user) throw new ErrorForbidden('Invalid username or password'); + const session = await ctx.models.session().createUserSession(user.id); + return { id: session.id }; +}); - const session = await ctx.models.session().createUserSession(user.id); - return { id: session.id }; - } - - throw new ErrorMethodNotAllowed(); - } - - throw new ErrorNotFound(`Invalid link: ${path.link}`); - }, - - public: true, - -}; - -export default route; +export default router; diff --git a/packages/server/src/routes/default.ts b/packages/server/src/routes/default.ts index af56d610e..d0bb96a24 100644 --- a/packages/server/src/routes/default.ts +++ b/packages/server/src/routes/default.ts @@ -1,5 +1,6 @@ import * as Koa from 'koa'; -import { SubPath, Route, Response, ResponseType } from '../utils/routeUtils'; +import { SubPath, Response, ResponseType } from '../utils/routeUtils'; +import Router from '../utils/Router'; import { ErrorNotFound, ErrorForbidden } from '../utils/errors'; import { dirname, normalize } from 'path'; import { pathExists } from 'fs-extra'; @@ -36,28 +37,21 @@ async function findLocalFile(path: string): Promise { return localPath; } -const route: Route = { +const router = new Router(); - exec: async function(path: SubPath, ctx: Koa.Context) { +router.get('', async (path: SubPath, ctx: Koa.Context) => { + const localPath = await findLocalFile(path.raw); - if (ctx.method === 'GET') { - const localPath = await findLocalFile(path.raw); + let mimeType: string = mime.fromFilename(localPath); + if (!mimeType) mimeType = 'application/octet-stream'; - let mimeType: string = mime.fromFilename(localPath); - if (!mimeType) mimeType = 'application/octet-stream'; + const fileContent: Buffer = await fs.readFile(localPath); - const fileContent: Buffer = await fs.readFile(localPath); + const koaResponse = ctx.response; + koaResponse.body = fileContent; + koaResponse.set('Content-Type', mimeType); + koaResponse.set('Content-Length', fileContent.length.toString()); + return new Response(ResponseType.KoaResponse, koaResponse); +}); - const koaResponse = ctx.response; - koaResponse.body = fileContent; - koaResponse.set('Content-Type', mimeType); - koaResponse.set('Content-Length', fileContent.length.toString()); - return new Response(ResponseType.KoaResponse, koaResponse); - } - - throw new ErrorNotFound(); - }, - -}; - -export default route; +export default router; diff --git a/packages/server/src/routes/index/files.ts b/packages/server/src/routes/index/files.ts index 62f099cb7..330e2eb78 100644 --- a/packages/server/src/routes/index/files.ts +++ b/packages/server/src/routes/index/files.ts @@ -1,5 +1,6 @@ -import { SubPath, Route, respondWithFileContent, redirect } from '../../utils/routeUtils'; -import { AppContext } from '../../utils/types'; +import { SubPath, respondWithFileContent, redirect } from '../../utils/routeUtils'; +import Router from '../../utils/Router'; +import { AppContext, HttpMethod } from '../../utils/types'; import { contextSessionId, formParse } from '../../utils/requestUtils'; import { ErrorNotFound } from '../../utils/errors'; import { File } from '../../db'; @@ -21,115 +22,104 @@ function makeFilePagination(query: any): Pagination { return output; } -const route: Route = { +const router = new Router(); - endPoints: { +router.alias(HttpMethod.GET, 'files', 'files/:id'); - 'GET': { +router.get('files/:id', async (path: SubPath, ctx: AppContext) => { + const dirId = path.id; + const query = ctx.query; - 'files': 'files/:id', + // 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; - 'files/:id': async function(path: SubPath, ctx: AppContext) { - const dirId = path.id; - const query = ctx.query; + 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); - // 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 parentBaseUrl = await fileModel.fileUrl(parent.id); + const paginationLinks = createPaginationLinks(pagination.page, pageCount, setQueryParameters(parentBaseUrl, { ...baseUrlQuery, 'page': 'PAGE_NUMBER' })); - 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); + async function fileToViewItem(file: File, fileFullPaths: Record): Promise { + const filePath = fileFullPaths[file.id]; - const parentBaseUrl = await fileModel.fileUrl(parent.id); - const paginationLinks = createPaginationLinks(pagination.page, pageCount, setQueryParameters(parentBaseUrl, { ...baseUrlQuery, 'page': 'PAGE_NUMBER' })); + let url = `${baseUrl()}/files/${filePath}`; + if (!file.is_directory) { + url += '/content'; + } else { + url = setQueryParameters(url, baseUrlQuery); + } - async function fileToViewItem(file: File, fileFullPaths: Record): Promise { - const filePath = fileFullPaths[file.id]; + 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') : '', + }; + } - let url = `${baseUrl()}/files/${filePath}`; - if (!file.is_directory) { - url += '/content'; - } else { - url = setQueryParameters(url, baseUrlQuery); - } + const files: any[] = []; - 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 fileFullPaths = await fileModel.itemFullPaths(paginatedFiles.items); - const files: any[] = []; + 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: '..', + }); + } - const fileFullPaths = await fileModel.itemFullPaths(paginatedFiles.items); + for (const file of paginatedFiles.items) { + files.push(await fileToViewItem(file, fileFullPaths)); + } - 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: '..', - }); - } + 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; +}); - for (const file of paginatedFiles.items) { - files.push(await fileToViewItem(file, fileFullPaths)); - } +router.get('files/:id/content', async (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); +}); - 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; - }, +router.post('files', async (_path: SubPath, ctx: AppContext) => { + const sessionId = contextSessionId(ctx); - '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); - }, - }, + const body = await formParse(ctx.req); + const fields = body.fields; + const parentId = fields.parent_id; + const user = await ctx.models.session().sessionUser(sessionId); - 'POST': { + 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'); + } - 'files': async function(_path: SubPath, ctx: AppContext) { - const sessionId = contextSessionId(ctx); + return redirect(ctx, await ctx.models.file({ userId: user.id }).fileUrl(parentId, ctx.query)); +}); - 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)); - }, - }, - }, - -}; - -export default route; +export default router; diff --git a/packages/server/src/routes/index/home.test.ts b/packages/server/src/routes/index/home.test.ts new file mode 100644 index 000000000..0348f3623 --- /dev/null +++ b/packages/server/src/routes/index/home.test.ts @@ -0,0 +1,34 @@ +import routeHandler from '../../middleware/routeHandler'; +import { beforeAllDb, afterAllTests, beforeEachDb, koaAppContext, createUserAndSession } from '../../utils/testing/testUtils'; + +describe('index_home', function() { + + beforeAll(async () => { + await beforeAllDb('index_home'); + }); + + afterAll(async () => { + await afterAllTests(); + }); + + beforeEach(async () => { + await beforeEachDb(); + }); + + test('should show the home page', async function() { + const { user, session } = await createUserAndSession(); + + const context = await koaAppContext({ + sessionId: session.id, + request: { + method: 'GET', + url: '/home', + }, + }); + + await routeHandler(context); + + expect(context.response.body.indexOf(user.email) >= 0).toBe(true); + }); + +}); diff --git a/packages/server/src/routes/index/home.ts b/packages/server/src/routes/index/home.ts index 5105e96ee..8e7428c5e 100644 --- a/packages/server/src/routes/index/home.ts +++ b/packages/server/src/routes/index/home.ts @@ -1,21 +1,20 @@ -import { SubPath, Route } from '../../utils/routeUtils'; +import { SubPath } from '../../utils/routeUtils'; +import Router from '../../utils/Router'; import { AppContext } from '../../utils/types'; import { contextSessionId } from '../../utils/requestUtils'; import { ErrorMethodNotAllowed } from '../../utils/errors'; import defaultView from '../../utils/defaultView'; -const route: Route = { +const router: Router = new Router(); - exec: async function(_path: SubPath, ctx: AppContext) { - contextSessionId(ctx); +router.get('home', async (_path: SubPath, ctx: AppContext) => { + contextSessionId(ctx); - if (ctx.method === 'GET') { - return defaultView('home'); - } + if (ctx.method === 'GET') { + return defaultView('home'); + } - throw new ErrorMethodNotAllowed(); - }, + throw new ErrorMethodNotAllowed(); +}); -}; - -export default route; +export default router; diff --git a/packages/server/src/routes/index/login.ts b/packages/server/src/routes/index/login.ts index 842f450a0..58aab8162 100644 --- a/packages/server/src/routes/index/login.ts +++ b/packages/server/src/routes/index/login.ts @@ -1,5 +1,5 @@ -import { SubPath, Route, redirect } from '../../utils/routeUtils'; -import { ErrorMethodNotAllowed } from '../../utils/errors'; +import { SubPath, redirect } from '../../utils/routeUtils'; +import Router from '../../utils/Router'; import { AppContext } from '../../utils/types'; import { formParse } from '../../utils/requestUtils'; import { baseUrl } from '../../config'; @@ -13,30 +13,24 @@ function makeView(error: any = null): View { return view; } -const route: Route = { +const router: Router = new Router(); - exec: async function(_path: SubPath, ctx: AppContext) { - if (ctx.method === 'GET') { - return makeView(); - } +router.public = true; - if (ctx.method === 'POST') { - try { - const body = await formParse(ctx.req); +router.get('login', async (_path: SubPath, _ctx: AppContext) => { + return makeView(); +}); - const session = await ctx.models.session().authenticate(body.fields.email, body.fields.password); - ctx.cookies.set('sessionId', session.id); - return redirect(ctx, `${baseUrl()}/home`); - } catch (error) { - return makeView(error); - } - } +router.post('login', async (_path: SubPath, ctx: AppContext) => { + try { + const body = await formParse(ctx.req); - throw new ErrorMethodNotAllowed(); - }, + const session = await ctx.models.session().authenticate(body.fields.email, body.fields.password); + ctx.cookies.set('sessionId', session.id); + return redirect(ctx, `${baseUrl()}/home`); + } catch (error) { + return makeView(error); + } +}); - public: true, - -}; - -export default route; +export default router; diff --git a/packages/server/src/routes/index/logout.ts b/packages/server/src/routes/index/logout.ts index 3f839d44a..661ba433b 100644 --- a/packages/server/src/routes/index/logout.ts +++ b/packages/server/src/routes/index/logout.ts @@ -1,22 +1,16 @@ -import { SubPath, Route, redirect } from '../../utils/routeUtils'; -import { ErrorMethodNotAllowed } from '../../utils/errors'; +import { SubPath, redirect } from '../../utils/routeUtils'; +import Router from '../../utils/Router'; import { AppContext } from '../../utils/types'; import { baseUrl } from '../../config'; import { contextSessionId } from '../../utils/requestUtils'; -const route: Route = { +const router = new Router(); - exec: async function(_path: SubPath, ctx: AppContext) { - if (ctx.method === 'POST') { - const sessionId = contextSessionId(ctx, false); - ctx.cookies.set('sessionId', ''); - await ctx.models.session().logout(sessionId); - return redirect(ctx, `${baseUrl()}/login`); - } +router.post('logout', async (_path: SubPath, ctx: AppContext) => { + const sessionId = contextSessionId(ctx, false); + ctx.cookies.set('sessionId', ''); + await ctx.models.session().logout(sessionId); + return redirect(ctx, `${baseUrl()}/login`); +}); - throw new ErrorMethodNotAllowed(); - }, - -}; - -export default route; +export default router; diff --git a/packages/server/src/routes/index/notifications.test.ts b/packages/server/src/routes/index/notifications.test.ts new file mode 100644 index 000000000..8b1401505 --- /dev/null +++ b/packages/server/src/routes/index/notifications.test.ts @@ -0,0 +1,46 @@ +import { NotificationLevel } from '../../db'; +import routeHandler from '../../middleware/routeHandler'; +import { beforeAllDb, afterAllTests, beforeEachDb, koaAppContext, models, createUserAndSession } from '../../utils/testing/testUtils'; + +describe('index_notification', function() { + + beforeAll(async () => { + await beforeAllDb('index_notification'); + }); + + afterAll(async () => { + await afterAllTests(); + }); + + beforeEach(async () => { + await beforeEachDb(); + }); + + test('should update notification', async function() { + const { user, session } = await createUserAndSession(); + + const model = models().notification({ userId: user.id }); + + await model.add('my_notification', NotificationLevel.Normal, 'testing notification'); + + const notification = await model.loadByKey('my_notification'); + + expect(notification.read).toBe(0); + + const context = await koaAppContext({ + sessionId: session.id, + request: { + method: 'PATCH', + url: `/notifications/${notification.id}`, + body: { + read: 1, + }, + }, + }); + + await routeHandler(context); + + expect((await model.loadByKey('my_notification')).read).toBe(1); + }); + +}); diff --git a/packages/server/src/routes/index/notifications.ts b/packages/server/src/routes/index/notifications.ts index f1f23d557..a5daacdb2 100644 --- a/packages/server/src/routes/index/notifications.ts +++ b/packages/server/src/routes/index/notifications.ts @@ -1,33 +1,25 @@ -import { SubPath, Route } from '../../utils/routeUtils'; +import { SubPath } from '../../utils/routeUtils'; +import Router from '../../utils/Router'; import { AppContext } from '../../utils/types'; -import { bodyFields, contextSessionId } from '../../utils/requestUtils'; -import { ErrorMethodNotAllowed, ErrorNotFound } from '../../utils/errors'; +import { bodyFields } from '../../utils/requestUtils'; +import { ErrorNotFound } from '../../utils/errors'; import { Notification } from '../../db'; -const route: Route = { +const router = new Router(); - exec: async function(path: SubPath, ctx: AppContext) { - contextSessionId(ctx); +router.patch('notifications/:id', async (path: SubPath, ctx: AppContext) => { + const fields: Notification = await bodyFields(ctx.req); + const notificationId = path.id; + const model = ctx.models.notification({ userId: ctx.owner.id }); + const existingNotification = await model.load(notificationId); + if (!existingNotification) throw new ErrorNotFound(); - if (path.id && ctx.method === 'PATCH') { - const fields: Notification = await bodyFields(ctx.req); - const notificationId = path.id; - const model = ctx.models.notification({ userId: ctx.owner.id }); - const existingNotification = await model.load(notificationId); - if (!existingNotification) throw new ErrorNotFound(); + const toSave: Notification = {}; + if ('read' in fields) toSave.read = fields.read; + if (!Object.keys(toSave).length) return; - const toSave: Notification = {}; - if ('read' in fields) toSave.read = fields.read; - if (!Object.keys(toSave).length) return; + toSave.id = notificationId; + await model.save(toSave); +}); - toSave.id = notificationId; - await model.save(toSave); - return; - } - - throw new ErrorMethodNotAllowed(); - }, - -}; - -export default route; +export default router; diff --git a/packages/server/src/routes/index/users.ts b/packages/server/src/routes/index/users.ts index d51e4ff8e..8fb2d8f55 100644 --- a/packages/server/src/routes/index/users.ts +++ b/packages/server/src/routes/index/users.ts @@ -1,5 +1,6 @@ -import { SubPath, Route, redirect, findEndPoint } from '../../utils/routeUtils'; -import { AppContext } from '../../utils/types'; +import { SubPath, redirect } from '../../utils/routeUtils'; +import Router from '../../utils/Router'; +import { AppContext, HttpMethod } from '../../utils/types'; import { formParse } from '../../utils/requestUtils'; import { ErrorUnprocessableEntity } from '../../utils/errors'; import { User } from '../../db'; @@ -31,91 +32,79 @@ function userIsMe(path: SubPath): boolean { return path.id === 'me'; } -const route: Route = { +const router = new Router(); - endPoints: { +router.get('users', async (_path: SubPath, ctx: AppContext) => { + const userModel = ctx.models.user({ userId: ctx.owner.id }); + const users = await userModel.all(); - 'GET': { + const view: View = defaultView('users'); + view.content.users = users; + return view; +}); - 'users': async function(_path: SubPath, ctx: AppContext) { - const userModel = ctx.models.user({ userId: ctx.owner.id }); - const users = await userModel.all(); +router.get('users/:id', async (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; - const view: View = defaultView('users'); - view.content.users = users; - return view; - }, + user = !isNew ? user || await userModel.load(userId) : null; - '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; + let postUrl = ''; - user = !isNew ? user || await userModel.load(userId) : null; + if (isNew) { + postUrl = `${baseUrl()}/users/new`; + } else if (isMe) { + postUrl = `${baseUrl()}/users/me`; + } else { + postUrl = `${baseUrl()}/users/${user.id}`; + } - let postUrl = ''; + 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'); - if (isNew) { - postUrl = `${baseUrl()}/users/new`; - } else if (isMe) { - postUrl = `${baseUrl()}/users/me`; - } else { - postUrl = `${baseUrl()}/users/${user.id}`; - } + return view; +}); - 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'); +router.alias(HttpMethod.POST, 'users/:id', 'users'); - return view; - }, - }, +router.post('users', async (path: SubPath, ctx: AppContext) => { + let user: User = {}; + const userId = userIsMe(path) ? ctx.owner.id : path.id; - 'POST': { + try { + const body = await formParse(ctx.req); + const fields = body.fields; + if (userIsMe(path)) fields.id = userId; + user = makeUser(userIsNew(path), fields); - 'users/:id': 'users', + const userModel = ctx.models.user({ userId: ctx.owner.id }); - 'users': async function(path: SubPath, ctx: AppContext) { - let user: User = {}; - const userId = userIsMe(path) ? ctx.owner.id : path.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'); + } - try { - const body = await formParse(ctx.req); - const fields = body.fields; - if (userIsMe(path)) fields.id = userId; - user = makeUser(userIsNew(path), fields); + return redirect(ctx, `${baseUrl()}/users${userIsMe(path) ? '/me' : ''}`); + } catch (error) { + const endPoint = router.findEndPoint(HttpMethod.GET, 'users/:id'); + return endPoint(path, ctx, user, error); + } +}); - 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); - } - }, - }, - - }, - -}; - -export default route; +export default router; diff --git a/packages/server/src/routes/routes.ts b/packages/server/src/routes/routes.ts index 2b2db0f33..ad3b80389 100644 --- a/packages/server/src/routes/routes.ts +++ b/packages/server/src/routes/routes.ts @@ -1,4 +1,4 @@ -import { Routes } from '../utils/routeUtils'; +import { Routers } from '../utils/routeUtils'; import apiSessions from './api/sessions'; import apiPing from './api/ping'; @@ -11,7 +11,7 @@ import indexFilesRoute from './index/files'; import indexNotificationsRoute from './index/notifications'; import defaultRoute from './default'; -const routes: Routes = { +const routes: Routers = { 'api/ping': apiPing, 'api/sessions': apiSessions, 'api/files': apiFiles, diff --git a/packages/server/src/utils/Router.ts b/packages/server/src/utils/Router.ts new file mode 100644 index 000000000..3711b7d55 --- /dev/null +++ b/packages/server/src/utils/Router.ts @@ -0,0 +1,62 @@ +import { ErrorMethodNotAllowed, ErrorNotFound } from './errors'; +import { HttpMethod } from './types'; +import { RouteResponseFormat, RouteHandler } from './routeUtils'; + +export default class Router { + + public public: boolean = false; + public responseFormat: RouteResponseFormat = null; + + private routes_: Record> = {}; + private aliases_: Record> = {}; + + public findEndPoint(method: HttpMethod, schema: string): RouteHandler { + if (this.aliases_[method]?.[schema]) { return this.findEndPoint(method, this.aliases_[method]?.[schema]); } + + if (!this.routes_[method]) { throw new ErrorMethodNotAllowed(`Not allowed: ${method} ${schema}`); } + const endPoint = this.routes_[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 = this.routes_[method]?.[endPointFn]; + } else { + return endPointFn; + } + } + + throw new ErrorNotFound(`Could not resolve: ${method} ${schema}`); + } + + public alias(method: HttpMethod, path: string, target: string) { + if (!this.aliases_[method]) { this.aliases_[method] = {}; } + this.aliases_[method][path] = target; + } + + public get(path: string, handler: RouteHandler) { + if (!this.routes_.GET) { this.routes_.GET = {}; } + this.routes_.GET[path] = handler; + } + + public post(path: string, handler: RouteHandler) { + if (!this.routes_.POST) { this.routes_.POST = {}; } + this.routes_.POST[path] = handler; + } + + public patch(path: string, handler: RouteHandler) { + if (!this.routes_.PATCH) { this.routes_.PATCH = {}; } + this.routes_.PATCH[path] = handler; + } + + public del(path: string, handler: RouteHandler) { + if (!this.routes_.DELETE) { this.routes_.DELETE = {}; } + this.routes_.DELETE[path] = handler; + } + + public put(path: string, handler: RouteHandler) { + if (!this.routes_.PUT) { this.routes_.PUT = {}; } + this.routes_.PUT[path] = handler; + } + +} diff --git a/packages/server/src/utils/routeUtils.ts b/packages/server/src/utils/routeUtils.ts index 899a1cc80..296cef5f2 100644 --- a/packages/server/src/utils/routeUtils.ts +++ b/packages/server/src/utils/routeUtils.ts @@ -1,5 +1,6 @@ import { File, ItemAddressingType } from '../db'; -import { ErrorBadRequest, ErrorMethodNotAllowed, ErrorNotFound } from './errors'; +import { ErrorBadRequest } from './errors'; +import Router from './Router'; import { AppContext } from './types'; const { ltrimSlashes, rtrimSlashes } = require('@joplin/lib/path-utils'); @@ -22,27 +23,10 @@ export enum RouteResponseFormat { Json = 'json', } -type RouteHandler = (path: SubPath, ctx: AppContext, ...args: any[])=> Promise; +export 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?: RouteHandler; - responseFormat?: RouteResponseFormat; - endPoints?: RouteEndPoints; - - // Public routes can be accessed without authentication. - public?: boolean; -} - -export interface Routes { - [key: string]: Route; +export interface Routers { + [key: string]: Router; } export interface SubPath { @@ -54,7 +38,7 @@ export interface SubPath { } export interface MatchedRoute { - route: Route; + route: Router; basePath: string; subPath: SubPath; } @@ -85,23 +69,6 @@ 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; @@ -174,19 +141,17 @@ export function parseSubPath(basePath: string, 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('/'); - // } + if (basePath) { + const schema = [basePath]; + if (output.id) schema.push(':id'); + if (output.link) schema.push(output.link); + output.schema = schema.join('/'); + } return output; } export function routeResponseFormat(match: MatchedRoute, context: AppContext): RouteResponseFormat { - // if (context.query && context.query.response_format === 'json') return RouteResponseFormat.Json; - const rawPath = context.path; if (match && match.route.responseFormat) return match.route.responseFormat; @@ -200,7 +165,7 @@ export function routeResponseFormat(match: MatchedRoute, context: AppContext): R // - The base path: "api/files" // - The ID: "SOME_ID" // - The link: "content" -export function findMatchingRoute(path: string, routes: Routes): MatchedRoute { +export function findMatchingRoute(path: string, routes: Routers): MatchedRoute { const splittedPath = path.split('/'); // Because the path starts with "/", we remove the first element, which is