1
0
mirror of https://github.com/laurent22/joplin.git synced 2024-11-27 08:21:03 +02:00

Server: Support for notifications and clean up

This commit is contained in:
Laurent Cozic 2020-12-30 18:35:18 +00:00
parent 4128c53fcf
commit d2771029a3
30 changed files with 564 additions and 115 deletions

View File

@ -1460,6 +1460,9 @@ packages/server/src/controllers/index/HomeController.js.map
packages/server/src/controllers/index/LoginController.d.ts
packages/server/src/controllers/index/LoginController.js
packages/server/src/controllers/index/LoginController.js.map
packages/server/src/controllers/index/NotificationController.d.ts
packages/server/src/controllers/index/NotificationController.js
packages/server/src/controllers/index/NotificationController.js.map
packages/server/src/controllers/index/ProfileController.d.ts
packages/server/src/controllers/index/ProfileController.js
packages/server/src/controllers/index/ProfileController.js.map
@ -1469,9 +1472,18 @@ packages/server/src/controllers/index/UserController.js.map
packages/server/src/db.d.ts
packages/server/src/db.js
packages/server/src/db.js.map
packages/server/src/middleware/notificationHandler.d.ts
packages/server/src/middleware/notificationHandler.js
packages/server/src/middleware/notificationHandler.js.map
packages/server/src/middleware/requestProcessor.d.ts
packages/server/src/middleware/requestProcessor.js
packages/server/src/middleware/requestProcessor.js.map
packages/server/src/migrations/20190913171451_create.d.ts
packages/server/src/migrations/20190913171451_create.js
packages/server/src/migrations/20190913171451_create.js.map
packages/server/src/migrations/20203012152842_notifications.d.ts
packages/server/src/migrations/20203012152842_notifications.js
packages/server/src/migrations/20203012152842_notifications.js.map
packages/server/src/models/ApiClientModel.d.ts
packages/server/src/models/ApiClientModel.js
packages/server/src/models/ApiClientModel.js.map
@ -1490,6 +1502,12 @@ packages/server/src/models/FileModel.js.map
packages/server/src/models/FileModel.test.d.ts
packages/server/src/models/FileModel.test.js
packages/server/src/models/FileModel.test.js.map
packages/server/src/models/NotificationModel.d.ts
packages/server/src/models/NotificationModel.js
packages/server/src/models/NotificationModel.js.map
packages/server/src/models/NotificationModel.test.d.ts
packages/server/src/models/NotificationModel.test.js
packages/server/src/models/NotificationModel.test.js.map
packages/server/src/models/PermissionModel.d.ts
packages/server/src/models/PermissionModel.js
packages/server/src/models/PermissionModel.js.map
@ -1535,6 +1553,9 @@ packages/server/src/routes/index/login.js.map
packages/server/src/routes/index/logout.d.ts
packages/server/src/routes/index/logout.js
packages/server/src/routes/index/logout.js.map
packages/server/src/routes/index/notifications.d.ts
packages/server/src/routes/index/notifications.js
packages/server/src/routes/index/notifications.js.map
packages/server/src/routes/index/profile.d.ts
packages/server/src/routes/index/profile.js
packages/server/src/routes/index/profile.js.map

21
.gitignore vendored
View File

@ -1449,6 +1449,9 @@ packages/server/src/controllers/index/HomeController.js.map
packages/server/src/controllers/index/LoginController.d.ts
packages/server/src/controllers/index/LoginController.js
packages/server/src/controllers/index/LoginController.js.map
packages/server/src/controllers/index/NotificationController.d.ts
packages/server/src/controllers/index/NotificationController.js
packages/server/src/controllers/index/NotificationController.js.map
packages/server/src/controllers/index/ProfileController.d.ts
packages/server/src/controllers/index/ProfileController.js
packages/server/src/controllers/index/ProfileController.js.map
@ -1458,9 +1461,18 @@ packages/server/src/controllers/index/UserController.js.map
packages/server/src/db.d.ts
packages/server/src/db.js
packages/server/src/db.js.map
packages/server/src/middleware/notificationHandler.d.ts
packages/server/src/middleware/notificationHandler.js
packages/server/src/middleware/notificationHandler.js.map
packages/server/src/middleware/requestProcessor.d.ts
packages/server/src/middleware/requestProcessor.js
packages/server/src/middleware/requestProcessor.js.map
packages/server/src/migrations/20190913171451_create.d.ts
packages/server/src/migrations/20190913171451_create.js
packages/server/src/migrations/20190913171451_create.js.map
packages/server/src/migrations/20203012152842_notifications.d.ts
packages/server/src/migrations/20203012152842_notifications.js
packages/server/src/migrations/20203012152842_notifications.js.map
packages/server/src/models/ApiClientModel.d.ts
packages/server/src/models/ApiClientModel.js
packages/server/src/models/ApiClientModel.js.map
@ -1479,6 +1491,12 @@ packages/server/src/models/FileModel.js.map
packages/server/src/models/FileModel.test.d.ts
packages/server/src/models/FileModel.test.js
packages/server/src/models/FileModel.test.js.map
packages/server/src/models/NotificationModel.d.ts
packages/server/src/models/NotificationModel.js
packages/server/src/models/NotificationModel.js.map
packages/server/src/models/NotificationModel.test.d.ts
packages/server/src/models/NotificationModel.test.js
packages/server/src/models/NotificationModel.test.js.map
packages/server/src/models/PermissionModel.d.ts
packages/server/src/models/PermissionModel.js
packages/server/src/models/PermissionModel.js.map
@ -1524,6 +1542,9 @@ packages/server/src/routes/index/login.js.map
packages/server/src/routes/index/logout.d.ts
packages/server/src/routes/index/logout.js
packages/server/src/routes/index/logout.js.map
packages/server/src/routes/index/notifications.d.ts
packages/server/src/routes/index/notifications.js
packages/server/src/routes/index/notifications.js.map
packages/server/src/routes/index/profile.d.ts
packages/server/src/routes/index/profile.js
packages/server/src/routes/index/profile.js.map

View File

@ -313,6 +313,17 @@ class Resource extends BaseItem {
return r ? r.total : 0;
}
static async createdLocallyCount() {
const r = await this.db().selectOne(`
SELECT count(*) as total
FROM resources
WHERE id NOT IN
(SELECT resource_id FROM resource_local_states)
`);
return r ? r.total : 0;
}
static fetchStatusToLabel(status) {
if (status === Resource.FETCH_STATUS_IDLE) return _('Not downloaded');
if (status === Resource.FETCH_STATUS_STARTED) return _('Downloading');

View File

@ -187,8 +187,10 @@ class ReportService {
if (status === Resource.FETCH_STATUS_DONE) {
const downloadedButEncryptedBlobCount = await Resource.downloadedButEncryptedBlobCount();
const downloadedCount = await Resource.downloadStatusCounts(Resource.FETCH_STATUS_DONE);
const createdLocallyCount = await Resource.createdLocallyCount();
section.body.push(_('%s: %d', _('Downloaded and decrypted'), downloadedCount - downloadedButEncryptedBlobCount));
section.body.push(_('%s: %d', _('Downloaded and encrypted'), downloadedButEncryptedBlobCount));
section.body.push(_('%s: %d', _('Created locally'), createdLocallyCount));
} else {
const count = await Resource.downloadStatusCounts(status);
section.body.push(_('%s: %d', Resource.fetchStatusToLabel(status), count));

View File

@ -1262,6 +1262,12 @@
"@types/node": "*"
}
},
"@types/highlight.js": {
"version": "9.12.4",
"resolved": "https://registry.npmjs.org/@types/highlight.js/-/highlight.js-9.12.4.tgz",
"integrity": "sha512-t2szdkwmg2JJyuCM20e8kR2X59WCE5Zkl4bzm1u1Oukjm79zpbiAv+QjnwLnuuV0WHEcX2NgUItu0pAMKuOPww==",
"dev": true
},
"@types/http-assert": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/@types/http-assert/-/http-assert-1.5.1.tgz",
@ -1331,6 +1337,29 @@
"@types/koa": "*"
}
},
"@types/linkify-it": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-3.0.0.tgz",
"integrity": "sha512-x9OaQQTb1N2hPZ/LWJsqushexDvz7NgzuZxiRmZio44WPuolTZNHDBCrOxCzRVOMwamJRO2dWax5NbygOf1OTQ==",
"dev": true
},
"@types/markdown-it": {
"version": "12.0.0",
"resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-12.0.0.tgz",
"integrity": "sha512-+RJNprPSIcEUBzj3nx8WYwRsDdAKF6/dG932OleYKbTqBSJ7VvZK0JbPKeEpIYxoniUhgvgyZjO4vlCd4mFTdw==",
"dev": true,
"requires": {
"@types/highlight.js": "^9.7.0",
"@types/linkify-it": "*",
"@types/mdurl": "*"
}
},
"@types/mdurl": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-1.0.2.tgz",
"integrity": "sha512-eC4U9MlIcu2q0KQmXszyn5Akca/0jrQmwDRgpAMJai7qBWq4amIQhZyNau4VYGtCeALvW1/NtjzJJ567aZxfKA==",
"dev": true
},
"@types/mime": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-2.0.1.tgz",
@ -2652,6 +2681,11 @@
"once": "^1.4.0"
}
},
"entities": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-2.1.0.tgz",
"integrity": "sha512-hCx1oky9PFrJ611mf0ifBLBRW8lUUVRlFolb5gWRfIELabBlbp9xZvrqZLZAs+NxFnbfQoeGd8wDkygjg7U85w=="
},
"error-ex": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz",
@ -5562,6 +5596,14 @@
"integrity": "sha1-HADHQ7QzzQpOgHWPe2SldEDZ/wA=",
"dev": true
},
"linkify-it": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-3.0.2.tgz",
"integrity": "sha512-gDBO4aHNZS6coiZCKVhSNh43F9ioIL4JwRjLZPkoLIY4yZFwg264Y5lu2x6rb1Js42Gh6Yqm2f6L2AJcnkzinQ==",
"requires": {
"uc.micro": "^1.0.1"
}
},
"locate-path": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
@ -5632,6 +5674,30 @@
"object-visit": "^1.0.0"
}
},
"markdown-it": {
"version": "12.0.4",
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-12.0.4.tgz",
"integrity": "sha512-34RwOXZT8kyuOJy25oJNJoulO8L0bTHYWXcdZBYZqFnjIy3NgjeoM3FmPXIOFQ26/lSHYMr8oc62B6adxXcb3Q==",
"requires": {
"argparse": "^2.0.1",
"entities": "~2.1.0",
"linkify-it": "^3.0.1",
"mdurl": "^1.0.1",
"uc.micro": "^1.0.5"
},
"dependencies": {
"argparse": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="
}
}
},
"mdurl": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/mdurl/-/mdurl-1.0.1.tgz",
"integrity": "sha1-/oWy7HWlkDfyrf7BAP1sYBdhFS4="
},
"media-typer": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
@ -7177,11 +7243,6 @@
}
}
},
"sprintf-js": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.2.tgz",
"integrity": "sha512-VE0SOVEHCk7Qc8ulkWw3ntAzXuqf7S2lvwQaDLRnUeIEaKNQJzV6BwmLKhOqT61aGhfUMrXeaBk+oDGCzvhcug=="
},
"sqlite3": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/sqlite3/-/sqlite3-4.1.0.tgz",
@ -7583,6 +7644,11 @@
"integrity": "sha512-thGloWsGH3SOxv1SoY7QojKi0tc+8FnOmiarEGMbd/lar7QOEd3hvlx3Fp5y6FlDUGl9L+pd4n2e+oToGMmhRQ==",
"dev": true
},
"uc.micro": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-1.0.6.tgz",
"integrity": "sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA=="
},
"uglify-js": {
"version": "3.12.2",
"resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.12.2.tgz",

View File

@ -23,6 +23,7 @@
"html-entities": "^1.3.1",
"knex": "^0.19.4",
"koa": "^2.8.1",
"markdown-it": "^12.0.4",
"mustache": "^3.1.0",
"nanoid": "^2.1.1",
"nodemon": "^2.0.6",
@ -37,6 +38,7 @@
"@types/fs-extra": "^8.0.0",
"@types/jest": "^26.0.15",
"@types/koa": "^2.0.49",
"@types/markdown-it": "^12.0.0",
"@types/mustache": "^0.8.32",
"@types/yargs": "^13.0.2",
"jest": "^26.6.3",

View File

@ -1,10 +1,9 @@
// Allows displaying error stack traces with TypeScript file paths
require('source-map-support').install();
import * as Koa from 'koa';
import routes from './routes/routes';
import { ErrorNotFound } from './utils/errors';
import * as fs from 'fs-extra';
import { argv } from 'yargs';
import { routeResponseFormat, findMatchingRoute, Response, RouteResponseFormat, MatchedRoute } from './utils/routeUtils';
import Logger, { LoggerWrapper, TargetType } from '@joplin/lib/Logger';
import config, { initConfig, baseUrl } from './config';
import configDev from './config-dev';
@ -14,9 +13,15 @@ import { createDb, dropDb } from './tools/dbTools';
import { dropTables, connectDb, disconnectDb, migrateDb, waitForConnection } from './db';
import modelFactory from './models/factory';
import controllerFactory from './controllers/factory';
import { AppContext, Config } from './utils/types';
import { AppContext, Config, Env } from './utils/types';
import FsDriverNode from '@joplin/lib/fs-driver-node';
import mustacheService, { isView, View } from './services/MustacheService';
import requestProcessor from './middleware/requestProcessor';
import notificationHandler from './middleware/notificationHandler';
const { shimInit } = require('@joplin/lib/shim-init-node.js');
shimInit();
const env: Env = argv.env as Env || Env.Prod;
interface Configs {
[name: string]: Config;
@ -28,13 +33,6 @@ const configs: Configs = {
buildTypes: configBuildTypes,
};
require('source-map-support').install();
const env: string = argv.env as string || 'prod';
const { shimInit } = require('@joplin/lib/shim-init-node.js');
shimInit();
let appLogger_: LoggerWrapper = null;
function appLogger(): LoggerWrapper {
@ -46,60 +44,8 @@ function appLogger(): LoggerWrapper {
const app = new Koa();
app.use(async (ctx: Koa.Context) => {
appLogger().info(`${ctx.request.method} ${ctx.path}`);
const match: MatchedRoute = null;
try {
const match = findMatchingRoute(ctx.path, routes);
if (match) {
const responseObject = await match.route.exec(match.subPath, ctx);
if (responseObject instanceof Response) {
ctx.response = responseObject.response;
} else if (isView(responseObject)) {
ctx.response.status = 200;
ctx.response.body = await mustacheService.renderView(responseObject);
} else {
ctx.response.status = 200;
ctx.response.body = responseObject;
}
} else {
throw new ErrorNotFound();
}
} catch (error) {
if (error.httpCode >= 400 && error.httpCode < 500) {
appLogger().error(`${error.httpCode}: ` + `${ctx.request.method} ${ctx.path}` + ` : ${error.message}`);
} else {
appLogger().error(error);
}
ctx.response.status = error.httpCode ? error.httpCode : 500;
const responseFormat = routeResponseFormat(match, ctx.path);
if (responseFormat === RouteResponseFormat.Html) {
ctx.response.set('Content-Type', 'text/html');
const view: View = {
name: 'error',
path: 'index/error',
content: {
error,
stack: env === 'dev' ? error.stack : '',
},
};
ctx.response.body = await mustacheService.renderView(view);
} else { // JSON
ctx.response.set('Content-Type', 'application/json');
const r: any = { error: error.message };
if (env === 'dev' && error.stack) r.stack = error.stack;
if (error.code) r.code = error.code;
ctx.response.body = r;
}
}
});
app.use(notificationHandler);
app.use(requestProcessor);
async function main() {
const configObject: Config = configs[env];
@ -151,9 +97,11 @@ async function main() {
delete connectionCheckLogInfo.connection;
appLogger().info('Connection check:', connectionCheckLogInfo);
appContext.env = env;
appContext.db = connectionCheck.connection;
appContext.models = modelFactory(appContext.db, baseUrl());
appContext.controllers = controllerFactory(appContext.models);
appContext.appLogger = appLogger;
appLogger().info('Migrating database...');
await migrateDb(appContext.db);

View File

@ -1,5 +1,4 @@
import { Session, User } from '../../db';
import { checkPassword } from '../../utils/auth';
import { Session } from '../../db';
import { ErrorForbidden } from '../../utils/errors';
import uuidgen from '../../utils/uuidgen';
import BaseController from '../BaseController';
@ -8,12 +7,10 @@ export default class SessionController extends BaseController {
public async authenticate(email: string, password: string): Promise<Session> {
const userModel = this.models.user();
const user: User = await userModel.loadByEmail(email);
const user = await userModel.login(email, password);
if (!user) throw new ErrorForbidden('Invalid username or password');
if (!checkPassword(password, user.password)) throw new ErrorForbidden('Invalid username or password');
const session: Session = { id: uuidgen(), user_id: user.id };
const sessionModel = this.models.session();
return sessionModel.save(session, { isNew: true });
return this.models.session().save(session, { isNew: true });
}
}

View File

@ -8,6 +8,7 @@ import IndexHomeController from './index/HomeController';
import IndexProfileController from './index/ProfileController';
import IndexUserController from './index/UserController';
import IndexFileController from './index/FileController';
import IndexNotificationController from './index/NotificationController';
export class Controllers {
@ -53,6 +54,10 @@ export class Controllers {
return new IndexFileController(this.models_);
}
public indexNotifications() {
return new IndexNotificationController(this.models_);
}
}
export default function(models: Models) {

View File

@ -0,0 +1,23 @@
import BaseController from '../BaseController';
import { Notification } from '../../db';
import { ErrorNotFound } from '../../utils/errors';
export default class NotificationController extends BaseController {
public async patchOne(sessionId: string, notificationId: string, notification: Notification): Promise<void> {
const owner = await this.initSession(sessionId);
const model = this.models.notification({ userId: owner.id });
const existingNotification = await model.load(notificationId);
if (!existingNotification) throw new ErrorNotFound();
console.info('aaaaaaa', notification);
const toSave: Notification = {};
if ('read' in notification) toSave.read = notification.read;
if (!Object.keys(toSave).length) return;
toSave.id = notificationId;
await model.save(toSave);
}
}

View File

@ -19,6 +19,9 @@ const logger = Logger.create('db');
const migrationDir = `${__dirname}/migrations`;
const sqliteDbDir = pathUtils.dirname(__dirname);
export const defaultAdminEmail = 'admin@localhost';
export const defaultAdminPassword = 'admin';
export type DbConnection = Knex;
export interface DbConfigConnection {
@ -128,6 +131,17 @@ export async function dropTables(db: DbConnection): Promise<void> {
}
}
export async function truncateTables(db: DbConnection): Promise<void> {
for (const tableName of allTableNames()) {
try {
await db(tableName).truncate();
} catch (error) {
if (isNoSuchTableError(error)) continue;
throw error;
}
}
}
function isNoSuchTableError(error: any): boolean {
if (error) {
// Postgres error: 42P01: undefined_table
@ -181,6 +195,11 @@ export enum ItemAddressingType {
Path,
}
export enum NotificationLevel {
Important = 10,
Normal = 20,
}
export enum ItemType {
File = 1,
User,
@ -261,6 +280,15 @@ export interface ApiClient extends WithDates, WithUuid {
secret?: string;
}
export interface Notification extends WithDates, WithUuid {
owner_id?: Uuid;
level?: NotificationLevel;
key?: string;
message?: string;
read?: number;
canBeDismissed?: number;
}
export const databaseSchema: DatabaseTables = {
users: {
id: { type: 'string' },
@ -320,5 +348,16 @@ export const databaseSchema: DatabaseTables = {
updated_time: { type: 'string' },
created_time: { type: 'string' },
},
notifications: {
id: { type: 'string' },
owner_id: { type: 'string' },
level: { type: 'number' },
key: { type: 'string' },
message: { type: 'string' },
read: { type: 'number' },
canBeDismissed: { type: 'number' },
updated_time: { type: 'string' },
created_time: { type: 'string' },
},
};
// AUTO-GENERATED-TYPES

View File

@ -0,0 +1,52 @@
import { AppContext, KoaNext, NotificationView } from '../utils/types';
import { isApiRequest, contextSessionId } from '../utils/requestUtils';
import { defaultAdminEmail, defaultAdminPassword, NotificationLevel, User } from '../db';
import { _ } from '@joplin/lib/locale';
import Logger from '@joplin/lib/Logger';
import * as MarkdownIt from 'markdown-it';
const logger = Logger.create('notificationHandler');
export default async function(ctx: AppContext, next: KoaNext): Promise<void> {
ctx.notifications = [];
try {
if (isApiRequest(ctx)) return next();
const sessionId = contextSessionId(ctx);
const user: User = await ctx.models.session().sessionUser(sessionId);
if (!user) return next();
const notificationModel = ctx.models.notification({ userId: user.id });
if (user.is_admin) {
const defaultAdmin = await ctx.models.user().login(defaultAdminEmail, defaultAdminPassword);
if (defaultAdmin) {
await notificationModel.add(
'change_admin_password',
NotificationLevel.Important,
_('The default admin password is insecure and has not been changed! [Change it now](%s)', await ctx.models.user().profileUrl())
);
}
}
const markdownIt = new MarkdownIt();
const notifications = await notificationModel.allUnreadByUserId(user.id);
const views: NotificationView[] = [];
for (const n of notifications) {
views.push({
id: n.id,
messageHtml: markdownIt.render(n.message),
level: n.level === NotificationLevel.Important ? 'warning' : 'info',
closeUrl: notificationModel.closeUrl(n.id),
});
}
ctx.notifications = views;
} catch (error) {
logger.error(error);
}
return next();
}

View File

@ -0,0 +1,62 @@
import routes from '../routes/routes';
import { ErrorNotFound } from '../utils/errors';
import { routeResponseFormat, findMatchingRoute, Response, RouteResponseFormat, MatchedRoute } from '../utils/routeUtils';
import { AppContext, Env } from '../utils/types';
import mustacheService, { isView, View } from '../services/MustacheService';
export default async function(ctx: AppContext) {
ctx.appLogger().info(`${ctx.request.method} ${ctx.path}`);
const match: MatchedRoute = null;
try {
const match = findMatchingRoute(ctx.path, routes);
if (match) {
const responseObject = await match.route.exec(match.subPath, ctx);
if (responseObject instanceof Response) {
ctx.response = responseObject.response;
} else if (isView(responseObject)) {
ctx.response.status = 200;
ctx.response.body = await mustacheService.renderView(responseObject, {
notifications: ctx.notifications || [],
});
} else {
ctx.response.status = 200;
ctx.response.body = responseObject;
}
} else {
throw new ErrorNotFound();
}
} catch (error) {
if (error.httpCode >= 400 && error.httpCode < 500) {
ctx.appLogger().error(`${error.httpCode}: ` + `${ctx.request.method} ${ctx.path}` + ` : ${error.message}`);
} else {
ctx.appLogger().error(error);
}
ctx.response.status = error.httpCode ? error.httpCode : 500;
const responseFormat = routeResponseFormat(match, ctx.path);
if (responseFormat === RouteResponseFormat.Html) {
ctx.response.set('Content-Type', 'text/html');
const view: View = {
name: 'error',
path: 'index/error',
content: {
error,
stack: ctx.env === Env.Dev ? error.stack : '',
},
};
ctx.response.body = await mustacheService.renderView(view);
} else { // JSON
ctx.response.set('Content-Type', 'application/json');
const r: any = { error: error.message };
if (ctx.env === Env.Dev && error.stack) r.stack = error.stack;
if (error.code) r.code = error.code;
ctx.response.body = r;
}
}
}

View File

@ -1,5 +1,5 @@
import * as Knex from 'knex';
import { DbConnection } from '../db';
import { DbConnection, defaultAdminEmail, defaultAdminPassword } from '../db';
import { hashPassword } from '../utils/auth';
import uuidgen from '../utils/uuidgen';
@ -97,8 +97,8 @@ export async function up(db: DbConnection): Promise<any> {
await db('users').insert({
id: adminId,
email: 'admin@localhost',
password: hashPassword('admin'),
email: defaultAdminEmail,
password: hashPassword(defaultAdminPassword),
full_name: 'Admin',
is_admin: 1,
updated_time: now,

View File

@ -0,0 +1,24 @@
import * as Knex from 'knex';
import { DbConnection } from '../db';
export async function up(db: DbConnection): Promise<any> {
await db.schema.createTable('notifications', function(table: Knex.CreateTableBuilder) {
table.string('id', 32).unique().primary().notNullable();
table.string('owner_id', 32).notNullable();
table.integer('level').notNullable();
table.text('key', 'string').notNullable();
table.text('message', 'mediumtext').notNullable();
table.integer('read').defaultTo(0).notNullable();
table.integer('canBeDismissed').defaultTo(1).notNullable();
table.bigInteger('updated_time').notNullable();
table.bigInteger('created_time').notNullable();
});
await db.schema.alterTable('notifications', function(table: Knex.CreateTableBuilder) {
table.unique(['owner_id', 'key']);
});
}
export async function down(db: DbConnection): Promise<any> {
await db.schema.dropTable('notifications');
}

View File

@ -1,11 +1,11 @@
import { WithDates, WithUuid, File, User, Session, Permission, databaseSchema, ApiClient, DbConnection, Change, ItemType, ChangeType } from '../db';
import { WithDates, WithUuid, File, User, Session, Permission, databaseSchema, ApiClient, DbConnection, Change, ItemType, ChangeType, Notification } from '../db';
import TransactionHandler from '../utils/TransactionHandler';
import uuidgen from '../utils/uuidgen';
import { ErrorUnprocessableEntity, ErrorBadRequest } from '../utils/errors';
import { Models } from './factory';
export type AnyItemType = File | User | Session | Permission | ApiClient | Change;
export type AnyItemTypes = File[] | User[] | Session[] | Permission[] | ApiClient[] | Change[];
export type AnyItemType = File | User | Session | Permission | ApiClient | Change | Notification;
export type AnyItemTypes = File[] | User[] | Session[] | Permission[] | ApiClient[] | Change[] | Notification[];
export interface ModelOptions {
userId?: string;

View File

@ -0,0 +1,49 @@
import { createUserAndSession, beforeAllDb, afterAllDb, beforeEachDb, models, expectThrow } from '../utils/testUtils';
import { Notification, NotificationLevel } from '../db';
describe('NotificationModel', function() {
beforeAll(async () => {
await beforeAllDb('NotificationModel');
});
afterAll(async () => {
await afterAllDb();
});
beforeEach(async () => {
await beforeEachDb();
});
test('should require a user to create the notification', async function() {
await expectThrow(async () => models().notification().add('test', NotificationLevel.Normal, 'test'));
});
test('should create a notification', async function() {
const { user } = await createUserAndSession(1, true);
const model = models().notification({ userId: user.id });
await model.add('test', NotificationLevel.Important, 'testing');
const n: Notification = await model.loadByKey('test');
expect(n.key).toBe('test');
expect(n.message).toBe('testing');
expect(n.level).toBe(NotificationLevel.Important);
});
test('should create only one notification per key', async function() {
const { user } = await createUserAndSession(1, true);
const model = models().notification({ userId: user.id });
await model.add('test', NotificationLevel.Important, 'testing');
await model.add('test', NotificationLevel.Important, 'testing');
expect((await model.all()).length).toBe(1);
});
test('should mark a notification as read', async function() {
const { user } = await createUserAndSession(1, true);
const model = models().notification({ userId: user.id });
await model.add('test', NotificationLevel.Important, 'testing');
expect((await model.loadByKey('test')).read).toBe(0);
await model.markAsRead('test');
expect((await model.loadByKey('test')).read).toBe(1);
});
});

View File

@ -0,0 +1,51 @@
import { Notification, NotificationLevel, Uuid } from '../db';
import BaseModel from './BaseModel';
export default class NotificationModel extends BaseModel {
protected get tableName(): string {
return 'notifications';
}
public async add(key: string, level: NotificationLevel, message: string): Promise<Notification> {
const n: Notification = await this.loadByKey(key);
if (n) return n;
return this.save({ key, message, level, owner_id: this.userId });
}
public async markAsRead(key: string): Promise<void> {
await this.db(this.tableName)
.update({ read: 1 })
.where('key', '=', key)
.andWhere('owner_id', '=', this.userId);
}
public loadByKey(key: string): Promise<Notification> {
return this.db(this.tableName)
.select(this.defaultFields)
.where('key', '=', key)
.andWhere('owner_id', '=', this.userId)
.first();
}
public allUnreadByUserId(userId: Uuid): Promise<Notification[]> {
return this.db(this.tableName)
.select(this.defaultFields)
.where('owner_id', '=', userId)
.andWhere('read', '=', 0)
.orderBy('updated_time', 'asc');
}
public closeUrl(id: Uuid): string {
return `${this.baseUrl}/notifications/${id}`;
}
public load(id: Uuid): Promise<Notification> {
return this.db(this.tableName)
.select(this.defaultFields)
.where({ id: id })
.andWhere('owner_id', '=', this.userId)
.first();
}
}

View File

@ -14,6 +14,13 @@ export default class UserModel extends BaseModel {
return this.db<User>(this.tableName).where(user).first();
}
public async login(email: string, password: string): Promise<User> {
const user = await this.loadByEmail(email);
if (!user) return null;
if (!auth.checkPassword(password, user.password)) return null;
return user;
}
public fromApiInput(object: User): User {
const user: User = {};
@ -64,6 +71,10 @@ export default class UserModel extends BaseModel {
return !!s[0].length && !!s[1].length;
}
public async profileUrl(): Promise<string> {
return `${this.baseUrl}/profile`;
}
private async checkIsOwnerOrAdmin(userId: string): Promise<void> {
if (!this.userId) throw new ErrorForbidden('no user is active');

View File

@ -62,6 +62,7 @@ import UserModel from './UserModel';
import PermissionModel from './PermissionModel';
import SessionModel from './SessionModel';
import ChangeModel from './ChangeModel';
import NotificationModel from './NotificationModel';
export class Models {
@ -96,6 +97,11 @@ export class Models {
public change(options: ModelOptions = null) {
return new ChangeModel(this.db_, newModelFactory, this.baseUrl_, options);
}
public notification(options: ModelOptions = null) {
return new NotificationModel(this.db_, newModelFactory, this.baseUrl_, options);
}
}
export default function newModelFactory(db: DbConnection, baseUrl: string): Models {

View File

@ -0,0 +1,20 @@
import { SubPath, Route } from '../../utils/routeUtils';
import { AppContext } from '../../utils/types';
import { bodyFields, contextSessionId } from '../../utils/requestUtils';
import { ErrorMethodNotAllowed } from '../../utils/errors';
const route: Route = {
exec: async function(path: SubPath, ctx: AppContext) {
const sessionId = contextSessionId(ctx);
if (path.id && ctx.method === 'PATCH') {
return ctx.controllers.indexNotifications().patchOne(sessionId, path.id, await bodyFields(ctx.req));
}
throw new ErrorMethodNotAllowed();
},
};
export default route;

View File

@ -10,6 +10,7 @@ import indexProfileRoute from './index/profile';
import indexUsersRoute from './index/users';
import indexUserRoute from './index/user';
import indexFilesRoute from './index/files';
import indexNotificationsRoute from './index/notifications';
import defaultRoute from './default';
const routes: Routes = {
@ -24,6 +25,7 @@ const routes: Routes = {
'users': indexUsersRoute,
'user': indexUserRoute,
'files': indexFilesRoute,
'notifications': indexNotificationsRoute,
'': defaultRoute,
};

View File

@ -46,7 +46,7 @@ class MustacheService {
return output;
}
public async renderView(view: View): Promise<string> {
public async renderView(view: View, globalParams: any = null): Promise<string> {
const partials = view.partials || [];
const cssFiles = this.resolvesFilePaths('css', view.cssFiles || []);
const jsFiles = this.resolvesFilePaths('js', view.jsFiles || []);
@ -59,17 +59,22 @@ class MustacheService {
const filePath = `${config().viewDir}/${view.path}.mustache`;
globalParams = {
...this.defaultLayoutOptions,
...globalParams,
};
const contentHtml = Mustache.render(
await this.loadTemplateContent(filePath),
{
...view.content,
global: this.defaultLayoutOptions,
global: globalParams,
},
partialContents
);
const layoutView: any = Object.assign({}, {
global: this.defaultLayoutOptions,
global: globalParams,
pageName: view.name,
contentHtml: contentHtml,
cssFiles: cssFiles,
@ -80,23 +85,6 @@ class MustacheService {
return Mustache.render(await this.loadTemplateContent(this.defaultLayoutPath), layoutView, partialContents);
}
// public async render(path: string, view: any, options: RenderOptions = null): Promise<string> {
// const partials = options && options.partials ? options.partials : {};
// const cssFiles = this.resolvesFilePaths('css', options && options.cssFiles ? options.cssFiles : []);
// const jsFiles = this.resolvesFilePaths('js', options && options.jsFiles ? options.jsFiles : []);
// const filePath = `${config().viewDir}/${path}.mustache`;
// const contentHtml = Mustache.render(await this.loadTemplateContent(filePath), { ...view, global: this.defaultLayoutOptions }, partials);
// const layoutView: any = Object.assign({}, this.defaultLayoutOptions, {
// contentHtml: contentHtml,
// cssFiles: cssFiles,
// jsFiles: jsFiles,
// });
// return Mustache.render(await this.loadTemplateContent(this.defaultLayoutPath), layoutView);
// }
}
const mustacheService = new MustacheService();

View File

@ -28,6 +28,7 @@ const config = {
'main.files': 'WithDates, WithUuid',
'main.api_clients': 'WithDates, WithUuid',
'main.changes': 'WithDates, WithUuid',
'main.notifications': 'WithDates, WithUuid',
},
};
@ -66,6 +67,7 @@ function createTypeString(table: any) {
if (name === 'item_type') type = 'ItemType';
if (table.name === 'files' && name === 'content') type = 'Buffer';
if (table.name === 'changes' && name === 'type') type = 'ChangeType';
if (table.name === 'notifications' && name === 'level') type = 'NotificationLevel';
if ((name === 'id' || name.endsWith('_id') || name === 'uuid') && type === 'string') type = 'Uuid';
colStrings.push(`\t${name}?: ${type};`);

View File

@ -9,6 +9,9 @@ export default function(name: string, owner: User = null): View {
content: {
owner,
},
partials: ['navbar'],
partials: [
'navbar',
'notifications',
],
};
}

View File

@ -43,3 +43,7 @@ export function contextSessionId(ctx: AppContext): string {
if (!id) throw new ErrorForbidden('Invalid or missing session');
return id;
}
export function isApiRequest(ctx: AppContext): boolean {
return ctx.path.indexOf('/api/') === 0;
}

View File

@ -1,4 +1,4 @@
import { User, Session, DbConnection, connectDb, disconnectDb, File } from '../db';
import { User, Session, DbConnection, connectDb, disconnectDb, File, truncateTables } from '../db';
import { createDb } from '../tools/dbTools';
import modelFactory from '../models/factory';
import controllerFactory from '../controllers/factory';
@ -35,17 +35,9 @@ export async function afterAllDb() {
}
export async function beforeEachDb() {
await clearDatabase();
await truncateTables(db_);
}
export const clearDatabase = async function(): Promise<void> {
await db_('sessions').truncate();
await db_('users').truncate();
await db_('permissions').truncate();
await db_('files').truncate();
await db_('changes').truncate();
};
export const testAssetDir = `${packageRootDir}/assets/tests`;
interface UserAndSession {

View File

@ -1,12 +1,29 @@
import { LoggerWrapper } from '@joplin/lib/Logger';
import * as Koa from 'koa';
import { Controllers } from '../controllers/factory';
import { DbConnection } from '../db';
import { DbConnection, Uuid } from '../db';
import { Models } from '../models/factory';
export enum Env {
Dev = 'dev',
Prod = 'prod',
BuildTypes = 'buildTypes',
}
export interface NotificationView {
id: Uuid;
messageHtml: string;
level: string;
closeUrl: string;
}
export interface AppContext extends Koa.Context {
env: Env;
db: DbConnection;
models: Models;
controllers: Controllers;
appLogger(): LoggerWrapper;
notifications: NotificationView[];
}
export interface DatabaseConfig {
@ -27,3 +44,5 @@ export interface Config {
logDir: string;
database: DatabaseConfig;
}
export type KoaNext = ()=> Promise<void>;

View File

@ -13,6 +13,7 @@
</head>
<body class="page-{{{pageName}}}">
{{> navbar}}
{{> notifications}}
<main class="main">
{{{contentHtml}}}
</main>

View File

@ -0,0 +1,28 @@
{{#global.notifications}}
<div class="notification is-{{level}}" id="notification-{{id}}">
<button data-close-url="{{closeUrl}}" data-id="{{id}}" class="delete close-notification-button"></button>
{{{messageHtml}}}
</div>
{{/global.notifications}}
<script>
onDocumentReady(function() {
const buttons = document.getElementsByClassName('close-notification-button');
for (const button of buttons) {
button.addEventListener('click', function(event) {
const closeUrl = button.dataset.closeUrl;
const notificationId = button.dataset.id;
fetch(closeUrl, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
read: 1,
}),
});
document.getElementById('notification-' + notificationId).style.display = 'none';
});
}
});
</script>