You've already forked joplin
mirror of
https://github.com/laurent22/joplin.git
synced 2025-07-13 00:10:37 +02:00
Server: Fixed sidebar menu selection
This commit is contained in:
@ -10,7 +10,7 @@ export default async function(ctx: AppContext) {
|
|||||||
const requestStartTime = Date.now();
|
const requestStartTime = Date.now();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const responseObject = await execRequest(ctx.joplin.routes, ctx);
|
const { response: responseObject, path } = await execRequest(ctx.joplin.routes, ctx);
|
||||||
|
|
||||||
if (responseObject instanceof Response) {
|
if (responseObject instanceof Response) {
|
||||||
ctx.response = responseObject.response;
|
ctx.response = responseObject.response;
|
||||||
@ -20,7 +20,7 @@ export default async function(ctx: AppContext) {
|
|||||||
const view = responseObject as View;
|
const view = responseObject as View;
|
||||||
ctx.response.status = view?.content?.error ? view?.content?.error?.httpCode || 500 : 200;
|
ctx.response.status = view?.content?.error ? view?.content?.error?.httpCode || 500 : 200;
|
||||||
ctx.response.body = await ctx.joplin.services.mustache.renderView(view, {
|
ctx.response.body = await ctx.joplin.services.mustache.renderView(view, {
|
||||||
currentUrl: ctx.URL,
|
currentPath: path,
|
||||||
notifications: ctx.joplin.notifications || [],
|
notifications: ctx.joplin.notifications || [],
|
||||||
hasNotifications: !!ctx.joplin.notifications && !!ctx.joplin.notifications.length,
|
hasNotifications: !!ctx.joplin.notifications && !!ctx.joplin.notifications.length,
|
||||||
owner: ctx.joplin.owner,
|
owner: ctx.joplin.owner,
|
||||||
|
@ -5,14 +5,13 @@ import config from '../config';
|
|||||||
import { filename } from '@joplin/lib/path-utils';
|
import { filename } from '@joplin/lib/path-utils';
|
||||||
import { NotificationView } from '../utils/types';
|
import { NotificationView } from '../utils/types';
|
||||||
import { User } from '../services/database/types';
|
import { User } from '../services/database/types';
|
||||||
import { makeUrl, UrlType } from '../utils/routeUtils';
|
import { makeUrl, SubPath, urlMatchesSchema, UrlType } from '../utils/routeUtils';
|
||||||
import MarkdownIt = require('markdown-it');
|
import MarkdownIt = require('markdown-it');
|
||||||
import { headerAnchor } from '@joplin/renderer';
|
import { headerAnchor } from '@joplin/renderer';
|
||||||
import { _ } from '@joplin/lib/locale';
|
import { _ } from '@joplin/lib/locale';
|
||||||
import { adminDashboardUrl, adminEmailsUrl, adminTasksUrl, adminUserDeletionsUrl, adminUsersUrl, changesUrl, homeUrl, itemsUrl, stripOffQueryParameters } from '../utils/urlUtils';
|
import { adminDashboardUrl, adminEmailsUrl, adminTasksUrl, adminUserDeletionsUrl, adminUsersUrl, changesUrl, homeUrl, itemsUrl } from '../utils/urlUtils';
|
||||||
import { URL } from 'url';
|
|
||||||
|
|
||||||
type MenuItemSelectedCondition = (selectedUrl: URL)=> boolean;
|
type MenuItemSelectedCondition = (selectedUrl: SubPath)=> boolean;
|
||||||
|
|
||||||
export interface MenuItem {
|
export interface MenuItem {
|
||||||
title: string;
|
title: string;
|
||||||
@ -64,7 +63,7 @@ interface GlobalParams {
|
|||||||
isAdminPage?: boolean;
|
isAdminPage?: boolean;
|
||||||
adminMenu?: MenuItem[];
|
adminMenu?: MenuItem[];
|
||||||
navbarMenu?: MenuItem[];
|
navbarMenu?: MenuItem[];
|
||||||
currentUrl?: URL;
|
currentPath?: SubPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isView(o: any): boolean {
|
export function isView(o: any): boolean {
|
||||||
@ -112,25 +111,23 @@ export default class MustacheService {
|
|||||||
return `${config().layoutDir}/${name}.mustache`;
|
return `${config().layoutDir}/${name}.mustache`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private setSelectedMenu(selectedUrl: URL, menuItems: MenuItem[]) {
|
private setSelectedMenu(selectedPath: SubPath, menuItems: MenuItem[]) {
|
||||||
if (!selectedUrl) return;
|
if (!selectedPath) return;
|
||||||
if (!menuItems) return;
|
if (!menuItems) return;
|
||||||
|
|
||||||
const url = stripOffQueryParameters(selectedUrl.href);
|
|
||||||
|
|
||||||
for (const menuItem of menuItems) {
|
for (const menuItem of menuItems) {
|
||||||
if (menuItem.url) {
|
if (menuItem.url) {
|
||||||
if (menuItem.selectedCondition) {
|
if (menuItem.selectedCondition) {
|
||||||
menuItem.selected = menuItem.selectedCondition(selectedUrl);
|
menuItem.selected = menuItem.selectedCondition(selectedPath);
|
||||||
} else {
|
} else {
|
||||||
menuItem.selected = url === menuItem.url;
|
menuItem.selected = urlMatchesSchema(menuItem.url, selectedPath.schema);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.setSelectedMenu(selectedUrl, menuItem.children);
|
this.setSelectedMenu(selectedPath, menuItem.children);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private makeAdminMenu(selectedUrl: URL): MenuItem[] {
|
private makeAdminMenu(selectedPath: SubPath): MenuItem[] {
|
||||||
const output: MenuItem[] = [
|
const output: MenuItem[] = [
|
||||||
{
|
{
|
||||||
title: _('General'),
|
title: _('General'),
|
||||||
@ -159,12 +156,12 @@ export default class MustacheService {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
this.setSelectedMenu(selectedUrl, output);
|
this.setSelectedMenu(selectedPath, output);
|
||||||
|
|
||||||
return output;
|
return output;
|
||||||
}
|
}
|
||||||
|
|
||||||
private makeNavbar(selectedUrl: URL, isAdmin: boolean): MenuItem[] {
|
private makeNavbar(selectedPath: SubPath, isAdmin: boolean): MenuItem[] {
|
||||||
let output: MenuItem[] = [
|
let output: MenuItem[] = [
|
||||||
{
|
{
|
||||||
title: _('Home'),
|
title: _('Home'),
|
||||||
@ -186,14 +183,14 @@ export default class MustacheService {
|
|||||||
title: _('Admin'),
|
title: _('Admin'),
|
||||||
url: adminDashboardUrl(),
|
url: adminDashboardUrl(),
|
||||||
icon: 'fas fa-hammer',
|
icon: 'fas fa-hammer',
|
||||||
selectedCondition: (selectedUrl: URL) => {
|
selectedCondition: (selectedPath: SubPath) => {
|
||||||
return selectedUrl.pathname.startsWith('/admin/') || selectedUrl.pathname === '/admin';
|
return selectedPath.schema.startsWith('admin/') || selectedPath.schema === 'admin';
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.setSelectedMenu(selectedUrl, output);
|
this.setSelectedMenu(selectedPath, output);
|
||||||
|
|
||||||
return output;
|
return output;
|
||||||
}
|
}
|
||||||
@ -295,8 +292,8 @@ export default class MustacheService {
|
|||||||
globalParams = {
|
globalParams = {
|
||||||
...this.defaultLayoutOptions,
|
...this.defaultLayoutOptions,
|
||||||
...globalParams,
|
...globalParams,
|
||||||
adminMenu: globalParams ? this.makeAdminMenu(globalParams.currentUrl) : null,
|
adminMenu: globalParams ? this.makeAdminMenu(globalParams.currentPath) : null,
|
||||||
navbarMenu: this.makeNavbar(globalParams?.currentUrl, globalParams?.owner ? !!globalParams.owner.is_admin : false),
|
navbarMenu: this.makeNavbar(globalParams?.currentPath, globalParams?.owner ? !!globalParams.owner.is_admin : false),
|
||||||
userDisplayName: this.userDisplayName(globalParams ? globalParams.owner : null),
|
userDisplayName: this.userDisplayName(globalParams ? globalParams.owner : null),
|
||||||
isAdminPage,
|
isAdminPage,
|
||||||
s: {
|
s: {
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { findMatchingRoute, isValidOrigin, parseSubPath, splitItemPath } from './routeUtils';
|
import { findMatchingRoute, isValidOrigin, parseSubPath, splitItemPath, urlMatchesSchema } from './routeUtils';
|
||||||
import { ItemAddressingType } from '../services/database/types';
|
import { ItemAddressingType } from '../services/database/types';
|
||||||
import { RouteType } from './types';
|
import { RouteType } from './types';
|
||||||
import { expectThrow } from './testing/testUtils';
|
import { expectThrow } from './testing/testUtils';
|
||||||
@ -99,7 +99,7 @@ describe('routeUtils', function() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should check the request origin for API URLs', async function() {
|
it('should check the request origin for API URLs', async function() {
|
||||||
const testCases: any[] = [
|
const testCases: [string, string, boolean][] = [
|
||||||
[
|
[
|
||||||
'https://example.com', // Request origin
|
'https://example.com', // Request origin
|
||||||
'https://example.com', // Config base URL
|
'https://example.com', // Config base URL
|
||||||
@ -141,7 +141,7 @@ describe('routeUtils', function() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should check the request origin for User Content URLs', async function() {
|
it('should check the request origin for User Content URLs', async function() {
|
||||||
const testCases: any[] = [
|
const testCases: [string, string, boolean][] = [
|
||||||
[
|
[
|
||||||
'https://usercontent.local', // Request origin
|
'https://usercontent.local', // Request origin
|
||||||
'https://usercontent.local', // Config base URL
|
'https://usercontent.local', // Config base URL
|
||||||
@ -170,4 +170,40 @@ describe('routeUtils', function() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should check if a URL matches a schema', async function() {
|
||||||
|
const testCases: [string, string, boolean][] = [
|
||||||
|
[
|
||||||
|
'https://test.com/items/123/children',
|
||||||
|
'items/:id/children',
|
||||||
|
true,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'https://test.com/items/123',
|
||||||
|
'items/:id',
|
||||||
|
true,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'https://test.com/items',
|
||||||
|
'items',
|
||||||
|
true,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'https://test.com/items/123/children',
|
||||||
|
'items/:id',
|
||||||
|
false,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'',
|
||||||
|
'items/:id',
|
||||||
|
false,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const testCase of testCases) {
|
||||||
|
const [url, schema, expected] = testCase;
|
||||||
|
const actual = urlMatchesSchema(url, schema);
|
||||||
|
expect(actual).toBe(expected);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
@ -6,6 +6,7 @@ import { AppContext, HttpMethod, RouteType } from './types';
|
|||||||
import { URL } from 'url';
|
import { URL } from 'url';
|
||||||
import { csrfCheck } from './csrf';
|
import { csrfCheck } from './csrf';
|
||||||
import { contextSessionId } from './requestUtils';
|
import { contextSessionId } from './requestUtils';
|
||||||
|
import { stripOffQueryParameters } from './urlUtils';
|
||||||
|
|
||||||
const { ltrimSlashes, rtrimSlashes } = require('@joplin/lib/path-utils');
|
const { ltrimSlashes, rtrimSlashes } = require('@joplin/lib/path-utils');
|
||||||
|
|
||||||
@ -112,6 +113,12 @@ export function isPathBasedAddressing(fileId: string): boolean {
|
|||||||
return fileId.indexOf(':') >= 0;
|
return fileId.indexOf(':') >= 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const urlMatchesSchema = (url: string, schema: string): boolean => {
|
||||||
|
url = stripOffQueryParameters(url);
|
||||||
|
const regex = new RegExp(`${schema.replace(/:id/, '[a-zA-Z0-9]+')}$`);
|
||||||
|
return !!url.match(regex);
|
||||||
|
};
|
||||||
|
|
||||||
// Allows parsing the two types of paths supported by the API:
|
// Allows parsing the two types of paths supported by the API:
|
||||||
//
|
//
|
||||||
// root:/Documents/MyFile.md:/content
|
// root:/Documents/MyFile.md:/content
|
||||||
@ -189,7 +196,12 @@ function disabledAccountCheck(route: MatchedRoute, user: User) {
|
|||||||
if (route.subPath.schema.startsWith('api/')) throw new ErrorForbidden(`This account is disabled. Please login to ${config().baseUrl} for more information.`);
|
if (route.subPath.schema.startsWith('api/')) throw new ErrorForbidden(`This account is disabled. Please login to ${config().baseUrl} for more information.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function execRequest(routes: Routers, ctx: AppContext) {
|
interface ExecRequestResult {
|
||||||
|
response: any;
|
||||||
|
path: SubPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function execRequest(routes: Routers, ctx: AppContext): Promise<ExecRequestResult> {
|
||||||
const match = findMatchingRoute(ctx.path, routes);
|
const match = findMatchingRoute(ctx.path, routes);
|
||||||
if (!match) throw new ErrorNotFound();
|
if (!match) throw new ErrorNotFound();
|
||||||
|
|
||||||
@ -215,7 +227,10 @@ export async function execRequest(routes: Routers, ctx: AppContext) {
|
|||||||
await csrfCheck(ctx, isPublicRoute);
|
await csrfCheck(ctx, isPublicRoute);
|
||||||
disabledAccountCheck(match, ctx.joplin.owner);
|
disabledAccountCheck(match, ctx.joplin.owner);
|
||||||
|
|
||||||
return endPoint.handler(match.subPath, ctx);
|
return {
|
||||||
|
response: await endPoint.handler(match.subPath, ctx),
|
||||||
|
path: match.subPath,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// In a path such as "/api/files/SOME_ID/content" we want to find:
|
// In a path such as "/api/files/SOME_ID/content" we want to find:
|
||||||
|
Reference in New Issue
Block a user