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

Server: Refactored to use Router class

This commit is contained in:
Laurent Cozic 2021-01-14 22:36:46 +00:00
parent 7ad29577f9
commit 413ec1a933
18 changed files with 484 additions and 492 deletions

View File

@ -6,6 +6,7 @@ _releases/
**/node_modules/
Assets/
docs/
packages/plugins/**/dist
packages/server/dist/
highlight.pack.js
Modules/TinyMCE/IconPack/postinstall.js

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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<string> {
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;

View File

@ -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<string, string>): Promise<any> {
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<string, string>): Promise<any> {
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;

View File

@ -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);
});
});

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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);
});
});

View File

@ -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;

View File

@ -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;

View File

@ -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,

View File

@ -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<string, Record<string, RouteHandler>> = {};
private aliases_: Record<string, Record<string, string>> = {};
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;
}
}

View File

@ -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<any>;
export 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?: 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