diff --git a/.eslintignore b/.eslintignore index 09c0e6297f..594efa4e3a 100644 --- a/.eslintignore +++ b/.eslintignore @@ -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 diff --git a/.gitignore b/.gitignore index e7e20f32c4..31d6b4d788 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/packages/lib/models/Resource.js b/packages/lib/models/Resource.js index cf59f72f61..20b9db4cda 100644 --- a/packages/lib/models/Resource.js +++ b/packages/lib/models/Resource.js @@ -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'); diff --git a/packages/lib/services/report.js b/packages/lib/services/report.js index ecf872c631..1694431fa9 100644 --- a/packages/lib/services/report.js +++ b/packages/lib/services/report.js @@ -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)); diff --git a/packages/server/package-lock.json b/packages/server/package-lock.json index 7a9b6a31d2..9a70285784 100644 --- a/packages/server/package-lock.json +++ b/packages/server/package-lock.json @@ -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", diff --git a/packages/server/package.json b/packages/server/package.json index b6371ae58f..16a7da04fc 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -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", diff --git a/packages/server/src/app.ts b/packages/server/src/app.ts index 0ee1deab0c..19f2bcc599 100644 --- a/packages/server/src/app.ts +++ b/packages/server/src/app.ts @@ -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); diff --git a/packages/server/src/controllers/api/SessionController.ts b/packages/server/src/controllers/api/SessionController.ts index ff21e165b2..f1ce8d2b16 100644 --- a/packages/server/src/controllers/api/SessionController.ts +++ b/packages/server/src/controllers/api/SessionController.ts @@ -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 { 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 }); } } diff --git a/packages/server/src/controllers/factory.ts b/packages/server/src/controllers/factory.ts index acd7559581..2f3d9f432c 100644 --- a/packages/server/src/controllers/factory.ts +++ b/packages/server/src/controllers/factory.ts @@ -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) { diff --git a/packages/server/src/controllers/index/NotificationController.ts b/packages/server/src/controllers/index/NotificationController.ts new file mode 100644 index 0000000000..521af5cf7b --- /dev/null +++ b/packages/server/src/controllers/index/NotificationController.ts @@ -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 { + 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); + } + +} diff --git a/packages/server/src/db.ts b/packages/server/src/db.ts index bdc2a26afe..8437631bcb 100644 --- a/packages/server/src/db.ts +++ b/packages/server/src/db.ts @@ -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 { } } +export async function truncateTables(db: DbConnection): Promise { + 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 diff --git a/packages/server/src/middleware/notificationHandler.ts b/packages/server/src/middleware/notificationHandler.ts new file mode 100644 index 0000000000..eb6de88ffc --- /dev/null +++ b/packages/server/src/middleware/notificationHandler.ts @@ -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 { + 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(); +} diff --git a/packages/server/src/middleware/requestProcessor.ts b/packages/server/src/middleware/requestProcessor.ts new file mode 100644 index 0000000000..e1569bda93 --- /dev/null +++ b/packages/server/src/middleware/requestProcessor.ts @@ -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; + } + } +} diff --git a/packages/server/src/migrations/20190913171451_create.ts b/packages/server/src/migrations/20190913171451_create.ts index 4e177bff0e..c8f582ba0a 100644 --- a/packages/server/src/migrations/20190913171451_create.ts +++ b/packages/server/src/migrations/20190913171451_create.ts @@ -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 { 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, diff --git a/packages/server/src/migrations/20203012152842_notifications.ts b/packages/server/src/migrations/20203012152842_notifications.ts new file mode 100644 index 0000000000..069e84f3cb --- /dev/null +++ b/packages/server/src/migrations/20203012152842_notifications.ts @@ -0,0 +1,24 @@ +import * as Knex from 'knex'; +import { DbConnection } from '../db'; + +export async function up(db: DbConnection): Promise { + 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 { + await db.schema.dropTable('notifications'); +} diff --git a/packages/server/src/models/BaseModel.ts b/packages/server/src/models/BaseModel.ts index e5b831543d..e4a879951c 100644 --- a/packages/server/src/models/BaseModel.ts +++ b/packages/server/src/models/BaseModel.ts @@ -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; diff --git a/packages/server/src/models/NotificationModel.test.ts b/packages/server/src/models/NotificationModel.test.ts new file mode 100644 index 0000000000..8bf9ef1345 --- /dev/null +++ b/packages/server/src/models/NotificationModel.test.ts @@ -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); + }); + +}); diff --git a/packages/server/src/models/NotificationModel.ts b/packages/server/src/models/NotificationModel.ts new file mode 100644 index 0000000000..ee2b57d5e0 --- /dev/null +++ b/packages/server/src/models/NotificationModel.ts @@ -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 { + 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 { + await this.db(this.tableName) + .update({ read: 1 }) + .where('key', '=', key) + .andWhere('owner_id', '=', this.userId); + } + + public loadByKey(key: string): Promise { + return this.db(this.tableName) + .select(this.defaultFields) + .where('key', '=', key) + .andWhere('owner_id', '=', this.userId) + .first(); + } + + public allUnreadByUserId(userId: Uuid): Promise { + 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 { + return this.db(this.tableName) + .select(this.defaultFields) + .where({ id: id }) + .andWhere('owner_id', '=', this.userId) + .first(); + } + +} diff --git a/packages/server/src/models/UserModel.ts b/packages/server/src/models/UserModel.ts index a806032818..90140a16b0 100644 --- a/packages/server/src/models/UserModel.ts +++ b/packages/server/src/models/UserModel.ts @@ -14,6 +14,13 @@ export default class UserModel extends BaseModel { return this.db(this.tableName).where(user).first(); } + public async login(email: string, password: string): Promise { + 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 { + return `${this.baseUrl}/profile`; + } + private async checkIsOwnerOrAdmin(userId: string): Promise { if (!this.userId) throw new ErrorForbidden('no user is active'); diff --git a/packages/server/src/models/factory.ts b/packages/server/src/models/factory.ts index 143683088a..0eb0ce4a72 100644 --- a/packages/server/src/models/factory.ts +++ b/packages/server/src/models/factory.ts @@ -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 { diff --git a/packages/server/src/routes/index/notifications.ts b/packages/server/src/routes/index/notifications.ts new file mode 100644 index 0000000000..3003fba90e --- /dev/null +++ b/packages/server/src/routes/index/notifications.ts @@ -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; diff --git a/packages/server/src/routes/routes.ts b/packages/server/src/routes/routes.ts index 58f1d11752..ce78cd3043 100644 --- a/packages/server/src/routes/routes.ts +++ b/packages/server/src/routes/routes.ts @@ -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, }; diff --git a/packages/server/src/services/MustacheService.ts b/packages/server/src/services/MustacheService.ts index 87ef99cb7e..d718af53ea 100644 --- a/packages/server/src/services/MustacheService.ts +++ b/packages/server/src/services/MustacheService.ts @@ -46,7 +46,7 @@ class MustacheService { return output; } - public async renderView(view: View): Promise { + public async renderView(view: View, globalParams: any = null): Promise { 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 { - // 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(); diff --git a/packages/server/src/tools/generate-types.ts b/packages/server/src/tools/generate-types.ts index 99592b768c..a1ff1953a9 100644 --- a/packages/server/src/tools/generate-types.ts +++ b/packages/server/src/tools/generate-types.ts @@ -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};`); diff --git a/packages/server/src/utils/defaultView.ts b/packages/server/src/utils/defaultView.ts index 9206cb273a..acf52af2c6 100644 --- a/packages/server/src/utils/defaultView.ts +++ b/packages/server/src/utils/defaultView.ts @@ -9,6 +9,9 @@ export default function(name: string, owner: User = null): View { content: { owner, }, - partials: ['navbar'], + partials: [ + 'navbar', + 'notifications', + ], }; } diff --git a/packages/server/src/utils/requestUtils.ts b/packages/server/src/utils/requestUtils.ts index b9d2c2674c..eae556cf49 100644 --- a/packages/server/src/utils/requestUtils.ts +++ b/packages/server/src/utils/requestUtils.ts @@ -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; +} diff --git a/packages/server/src/utils/testUtils.ts b/packages/server/src/utils/testUtils.ts index 94f5330f36..bdc8452a8e 100644 --- a/packages/server/src/utils/testUtils.ts +++ b/packages/server/src/utils/testUtils.ts @@ -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 { - 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 { diff --git a/packages/server/src/utils/types.ts b/packages/server/src/utils/types.ts index 72444f680e..93fd801e22 100644 --- a/packages/server/src/utils/types.ts +++ b/packages/server/src/utils/types.ts @@ -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; diff --git a/packages/server/src/views/layouts/default.mustache b/packages/server/src/views/layouts/default.mustache index ba5ffd523a..ffa7e066d1 100644 --- a/packages/server/src/views/layouts/default.mustache +++ b/packages/server/src/views/layouts/default.mustache @@ -13,6 +13,7 @@ {{> navbar}} + {{> notifications}}
{{{contentHtml}}}
diff --git a/packages/server/src/views/partials/notifications.mustache b/packages/server/src/views/partials/notifications.mustache new file mode 100644 index 0000000000..034add3426 --- /dev/null +++ b/packages/server/src/views/partials/notifications.mustache @@ -0,0 +1,28 @@ +{{#global.notifications}} +
+ + {{{messageHtml}}} +
+{{/global.notifications}} + + \ No newline at end of file